From d972f67badfbce810cd4036c7a64c69a72b665a0 Mon Sep 17 00:00:00 2001 From: Florian Schmidt Date: Mon, 13 Jul 2026 11:12:03 +0200 Subject: [PATCH] M1: pure normalization engine + default seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Infrastructure-free normalization engine in Core dispatching on MeterMode: - Cumulative/Generation counters: register deltas, first-reading baseline (Haus 411, Auto 3755), meter swaps (water …861→2 reconciles to 12 via boundary registers OR an explicit amount override), counter resets, anomaly-guarded decreases. - RuntimeCounter: Δhours × rate (fixed/empirical). - ConsumableBalance: tank level-Δ + deliveries → consumption, cm→litre calibration; delivery-only rows before the first dipstick emit nothing. - DirectDelta, and Virtual meters via a small safe arithmetic evaluator (Netz Einsparung = Haus − Netz, Eigenverbrauch = Erzeugung − Einsparung) — data-driven, not hardcoded. - DatabaseSeeder: default energy types, cost categories, base settings (idempotent). 24 Core unit tests + 4 integration tests green. Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr --- src/App/Program.cs | 3 +- src/Core/Normalization/CalibrationCurve.cs | 13 ++ .../Expressions/ExpressionEvaluator.cs | 174 ++++++++++++++++++ .../Normalization/INormalizationEngine.cs | 20 ++ src/Core/Normalization/MeterConfig.cs | 47 +++++ .../Normalization/NormalizationContext.cs | 22 +++ src/Core/Normalization/NormalizationEngine.cs | 40 ++++ .../ConsumableBalanceNormalizer.cs | 76 ++++++++ .../Normalizers/CounterNormalizerBase.cs | 96 ++++++++++ .../CumulativeCounterNormalizer.cs | 11 ++ .../Normalizers/DirectDeltaNormalizer.cs | 30 +++ .../GenerationCounterNormalizer.cs | 11 ++ .../Normalizers/RuntimeCounterNormalizer.cs | 40 ++++ .../Normalizers/VirtualNormalizer.cs | 60 ++++++ .../Persistence/DatabaseSeeder.cs | 61 ++++++ .../CumulativeCounterNormalizerTests.cs | 105 +++++++++++ tests/Core.Tests/ExpressionEvaluatorTests.cs | 36 ++++ tests/Core.Tests/RuntimeAndTankTests.cs | 90 +++++++++ tests/Core.Tests/TestData.cs | 55 ++++++ tests/Core.Tests/VirtualMeterTests.cs | 75 ++++++++ tests/Core.Tests/WaterSwapTests.cs | 57 ++++++ 21 files changed, 1121 insertions(+), 1 deletion(-) create mode 100644 src/Core/Normalization/CalibrationCurve.cs create mode 100644 src/Core/Normalization/Expressions/ExpressionEvaluator.cs create mode 100644 src/Core/Normalization/INormalizationEngine.cs create mode 100644 src/Core/Normalization/MeterConfig.cs create mode 100644 src/Core/Normalization/NormalizationContext.cs create mode 100644 src/Core/Normalization/NormalizationEngine.cs create mode 100644 src/Core/Normalization/Normalizers/ConsumableBalanceNormalizer.cs create mode 100644 src/Core/Normalization/Normalizers/CounterNormalizerBase.cs create mode 100644 src/Core/Normalization/Normalizers/CumulativeCounterNormalizer.cs create mode 100644 src/Core/Normalization/Normalizers/DirectDeltaNormalizer.cs create mode 100644 src/Core/Normalization/Normalizers/GenerationCounterNormalizer.cs create mode 100644 src/Core/Normalization/Normalizers/RuntimeCounterNormalizer.cs create mode 100644 src/Core/Normalization/Normalizers/VirtualNormalizer.cs create mode 100644 src/Infrastructure/Persistence/DatabaseSeeder.cs create mode 100644 tests/Core.Tests/CumulativeCounterNormalizerTests.cs create mode 100644 tests/Core.Tests/ExpressionEvaluatorTests.cs create mode 100644 tests/Core.Tests/RuntimeAndTankTests.cs create mode 100644 tests/Core.Tests/TestData.cs create mode 100644 tests/Core.Tests/VirtualMeterTests.cs create mode 100644 tests/Core.Tests/WaterSwapTests.cs diff --git a/src/App/Program.cs b/src/App/Program.cs index 8f9c9e5..fefd949 100644 --- a/src/App/Program.cs +++ b/src/App/Program.cs @@ -78,7 +78,8 @@ static async Task MigrateDatabaseAsync(WebApplication app) await using var scope = app.Services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); await db.Database.MigrateAsync().ConfigureAwait(false); - Log.Information("Database migrations applied"); + await DatabaseSeeder.SeedAsync(db).ConfigureAwait(false); + Log.Information("Database migrations applied and defaults seeded"); } /// Exposed for WebApplicationFactory-based integration tests. diff --git a/src/Core/Normalization/CalibrationCurve.cs b/src/Core/Normalization/CalibrationCurve.cs new file mode 100644 index 0000000..0ca8163 --- /dev/null +++ b/src/Core/Normalization/CalibrationCurve.cs @@ -0,0 +1,13 @@ +namespace MeterVault.Core.Normalization; + +/// +/// Converts a physical level reading (e.g. dipstick centimetres) into a volume in the tank's +/// unit. The reference heating-oil tank is linear at 7000 L / 150 cm ≈ 46.667 L/cm +/// (35 cm → 1633 L, 85 cm → 3967 L). Non-linear geometries can be added later; linear covers +/// the reference data. +/// +public sealed record CalibrationCurve(double VolumePerUnit, double Offset = 0) +{ + /// Litres for a given physical level (cm), per the calibration. + public double ToVolume(double level) => (level * VolumePerUnit) + Offset; +} diff --git a/src/Core/Normalization/Expressions/ExpressionEvaluator.cs b/src/Core/Normalization/Expressions/ExpressionEvaluator.cs new file mode 100644 index 0000000..3bccc45 --- /dev/null +++ b/src/Core/Normalization/Expressions/ExpressionEvaluator.cs @@ -0,0 +1,174 @@ +using System.Globalization; + +namespace MeterVault.Core.Normalization.Expressions; + +/// +/// A tiny, safe arithmetic evaluator for virtual-meter expressions (SDD §7.4). Supports +/// + - * /, parentheses, unary minus, numeric literals, and identifiers resolved against +/// a supplied variable map (e.g. m1 - m2 where m1 is meter 1's amount in a bucket). +/// No I/O, no reflection, no arbitrary code — only arithmetic over whitelisted tokens. +/// Parse once with , then evaluate per time bucket. +/// +public sealed class ExpressionEvaluator +{ + private readonly Func, double> _eval; + + private ExpressionEvaluator(Func, double> eval) => _eval = eval; + + public static ExpressionEvaluator Compile(string expression) + { + ArgumentException.ThrowIfNullOrWhiteSpace(expression); + var parser = new Parser(expression); + var node = parser.ParseExpression(); + parser.ExpectEnd(); + return new ExpressionEvaluator(node); + } + + public double Evaluate(IReadOnlyDictionary variables) => _eval(variables); + + // Recursive-descent parser that builds a closure over the variable map. + private sealed class Parser(string text) + { + private int _pos; + + public Func, double> ParseExpression() => ParseAdditive(); + + public void ExpectEnd() + { + SkipWhitespace(); + if (_pos != text.Length) + { + throw new FormatException($"Unexpected token at position {_pos} in expression '{text}'."); + } + } + + private Func, double> ParseAdditive() + { + var left = ParseMultiplicative(); + while (true) + { + SkipWhitespace(); + if (Match('+')) + { + var right = ParseMultiplicative(); + var l = left; + left = vars => l(vars) + right(vars); + } + else if (Match('-')) + { + var right = ParseMultiplicative(); + var l = left; + left = vars => l(vars) - right(vars); + } + else + { + return left; + } + } + } + + private Func, double> ParseMultiplicative() + { + var left = ParseUnary(); + while (true) + { + SkipWhitespace(); + if (Match('*')) + { + var right = ParseUnary(); + var l = left; + left = vars => l(vars) * right(vars); + } + else if (Match('/')) + { + var right = ParseUnary(); + var l = left; + left = vars => l(vars) / right(vars); + } + else + { + return left; + } + } + } + + private Func, double> ParseUnary() + { + SkipWhitespace(); + if (Match('-')) + { + var operand = ParseUnary(); + return vars => -operand(vars); + } + + if (Match('+')) + { + return ParseUnary(); + } + + return ParsePrimary(); + } + + private Func, double> ParsePrimary() + { + SkipWhitespace(); + if (Match('(')) + { + var inner = ParseAdditive(); + SkipWhitespace(); + if (!Match(')')) + { + throw new FormatException($"Expected ')' at position {_pos} in expression '{text}'."); + } + + return inner; + } + + if (_pos < text.Length && (char.IsDigit(text[_pos]) || text[_pos] == '.')) + { + var start = _pos; + while (_pos < text.Length && (char.IsDigit(text[_pos]) || text[_pos] == '.')) + { + _pos++; + } + + var literal = double.Parse(text.AsSpan(start, _pos - start), CultureInfo.InvariantCulture); + return _ => literal; + } + + if (_pos < text.Length && (char.IsLetter(text[_pos]) || text[_pos] == '_')) + { + var start = _pos; + while (_pos < text.Length && (char.IsLetterOrDigit(text[_pos]) || text[_pos] == '_')) + { + _pos++; + } + + var name = text[start.._pos]; + return vars => vars.TryGetValue(name, out var value) ? value : 0d; + } + + throw new FormatException($"Unexpected character at position {_pos} in expression '{text}'."); + } + + private bool Match(char c) + { + SkipWhitespace(); + if (_pos < text.Length && text[_pos] == c) + { + _pos++; + return true; + } + + return false; + } + + private void SkipWhitespace() + { + while (_pos < text.Length && char.IsWhiteSpace(text[_pos])) + { + _pos++; + } + } + } +} diff --git a/src/Core/Normalization/INormalizationEngine.cs b/src/Core/Normalization/INormalizationEngine.cs new file mode 100644 index 0000000..939b705 --- /dev/null +++ b/src/Core/Normalization/INormalizationEngine.cs @@ -0,0 +1,20 @@ +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Normalization; + +/// A per-mode normalization strategy (SDD §5.2). +public interface IMeterNormalizer +{ + MeterMode Mode { get; } + + IEnumerable Normalize(NormalizationContext context); +} + +/// +/// Turns raw readings/events into normalized consumption/generation deltas by dispatching on +/// . Pure domain logic: no infrastructure dependencies. +/// +public interface INormalizationEngine +{ + IReadOnlyList Normalize(NormalizationContext context); +} diff --git a/src/Core/Normalization/MeterConfig.cs b/src/Core/Normalization/MeterConfig.cs new file mode 100644 index 0000000..c3d169f --- /dev/null +++ b/src/Core/Normalization/MeterConfig.cs @@ -0,0 +1,47 @@ +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Normalization; + +/// +/// The immutable, infrastructure-free view of a meter that the normalization engine needs. +/// Infrastructure projects a (+ optional ) into this. +/// +public sealed record MeterConfig +{ + public required int MeterId { get; init; } + + public required MeterMode Mode { get; init; } + + public required string Unit { get; init; } + + /// Register baseline for a newly installed meter (SDD §7.1). Default 0. + public double InitialBaseline { get; init; } + + /// Tank/runtime configuration for consumable_balance and runtime_counter meters. + public TankConfig? Tank { get; init; } + + /// Expression + referenced meters for virtual meters. + public VirtualSpec? Virtual { get; init; } +} + +/// Consumable/runtime parameters (SDD §5.3 tank). +public sealed record TankConfig +{ + public double Capacity { get; init; } + + public TankRateMode RateMode { get; init; } = TankRateMode.Empirical; + + /// Litres per runtime-hour when is Fixed. + public double? FixedRate { get; init; } + + /// Physical-level (cm) → volume curve. Present when dipstick readings are recorded. + public CalibrationCurve? Calibration { get; init; } +} + +/// A virtual meter's expression over other meters' series (SDD §7.4). +public sealed record VirtualSpec +{ + public required string Expression { get; init; } + + public IReadOnlyList ReferencedMeterIds { get; init; } = []; +} diff --git a/src/Core/Normalization/NormalizationContext.cs b/src/Core/Normalization/NormalizationContext.cs new file mode 100644 index 0000000..8638d2b --- /dev/null +++ b/src/Core/Normalization/NormalizationContext.cs @@ -0,0 +1,22 @@ +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Normalization; + +/// +/// The complete, immutable input to normalizing one meter: its config, its raw readings +/// (ascending by time), its discrete events (swaps, resets, deliveries, tank levels), and — +/// for virtual meters — the already-normalized series of the meters it references. +/// +public sealed class NormalizationContext +{ + public required MeterConfig Meter { get; init; } + + /// Raw readings for this meter, expected ascending by time (sorted defensively). + public IReadOnlyList Readings { get; init; } = []; + + public IReadOnlyList Events { get; init; } = []; + + /// Referenced meters' consumption series, keyed by meter id (virtual meters only). + public IReadOnlyDictionary> ReferencedSeries { get; init; } + = new Dictionary>(); +} diff --git a/src/Core/Normalization/NormalizationEngine.cs b/src/Core/Normalization/NormalizationEngine.cs new file mode 100644 index 0000000..ea590ad --- /dev/null +++ b/src/Core/Normalization/NormalizationEngine.cs @@ -0,0 +1,40 @@ +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Normalization; + +/// +/// Dispatches to the registered for the meter's mode. +/// Register the strategies once (in DI or the parameterless factory) and reuse. +/// +public sealed class NormalizationEngine : INormalizationEngine +{ + private readonly IReadOnlyDictionary _byMode; + + public NormalizationEngine(IEnumerable normalizers) + { + ArgumentNullException.ThrowIfNull(normalizers); + _byMode = normalizers.ToDictionary(n => n.Mode); + } + + /// Builds an engine with the built-in strategies for all supported modes. + public static NormalizationEngine CreateDefault() => new( + [ + new Normalizers.CumulativeCounterNormalizer(), + new Normalizers.GenerationCounterNormalizer(), + new Normalizers.RuntimeCounterNormalizer(), + new Normalizers.ConsumableBalanceNormalizer(), + new Normalizers.DirectDeltaNormalizer(), + new Normalizers.VirtualNormalizer(), + ]); + + public IReadOnlyList Normalize(NormalizationContext context) + { + ArgumentNullException.ThrowIfNull(context); + if (!_byMode.TryGetValue(context.Meter.Mode, out var normalizer)) + { + throw new NotSupportedException($"No normalizer registered for mode {context.Meter.Mode}."); + } + + return [.. normalizer.Normalize(context)]; + } +} diff --git a/src/Core/Normalization/Normalizers/ConsumableBalanceNormalizer.cs b/src/Core/Normalization/Normalizers/ConsumableBalanceNormalizer.cs new file mode 100644 index 0000000..0c252f0 --- /dev/null +++ b/src/Core/Normalization/Normalizers/ConsumableBalanceNormalizer.cs @@ -0,0 +1,76 @@ +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Normalization.Normalizers; + +/// +/// A tank/bottle drawn down over time (SDD §5.2 consumable_balance, §7.3). Consumption +/// between two physical level readings is prevLevel + deliveriesBetween − currLevel, all in +/// volume units (cm levels are calibrated to litres). Deliveries recorded before the first level +/// reading are absorbed into the starting balance and emit no consumption — which is why the +/// heating-oil delivery-only rows of 1997–2020 produce nothing until the first 2022 dipstick. +/// This reproduces the spreadsheet's Differenz Tank column. +/// +public sealed class ConsumableBalanceNormalizer : IMeterNormalizer +{ + public MeterMode Mode => MeterMode.ConsumableBalance; + + public IEnumerable Normalize(NormalizationContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var calibration = context.Meter.Tank?.Calibration; + var events = context.Events + .Where(e => e.EventType is MeterEventType.Delivery or MeterEventType.TankLevel) + .OrderBy(e => e.Time) + .ToList(); + + double? lastLevelVolume = null; + double pendingDeliveries = 0; + + foreach (var e in events) + { + if (e.EventType == MeterEventType.Delivery) + { + pendingDeliveries += e.Amount ?? 0; + continue; + } + + var currVolume = ToVolume(e, calibration); + if (lastLevelVolume is null) + { + lastLevelVolume = currVolume; + pendingDeliveries = 0; + continue; + } + + var consumption = lastLevelVolume.Value + pendingDeliveries - currVolume; + var quality = ReadingQuality.Manual; + if (consumption < 0) + { + // Level rose beyond recorded deliveries — treat as no net draw, flag as estimated. + consumption = 0; + quality = ReadingQuality.Estimated; + } + + yield return new Consumption + { + MeterId = context.Meter.MeterId, + Time = e.Time, + Amount = consumption, + Kind = ConsumptionKind.Consumption, + Quality = quality, + ImportBatchId = e.ImportBatchId, + }; + + lastLevelVolume = currVolume; + pendingDeliveries = 0; + } + } + + private static double ToVolume(MeterEvent tankLevel, CalibrationCurve? calibration) + { + var level = tankLevel.Amount ?? 0; + var isCentimetres = string.Equals(tankLevel.Unit, "cm", StringComparison.OrdinalIgnoreCase); + return isCentimetres && calibration is not null ? calibration.ToVolume(level) : level; + } +} diff --git a/src/Core/Normalization/Normalizers/CounterNormalizerBase.cs b/src/Core/Normalization/Normalizers/CounterNormalizerBase.cs new file mode 100644 index 0000000..d4ce199 --- /dev/null +++ b/src/Core/Normalization/Normalizers/CounterNormalizerBase.cs @@ -0,0 +1,96 @@ +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Normalization.Normalizers; + +/// +/// Shared delta logic for monotonic registers (SDD §7.1). Walks readings ascending, holding the +/// previous register value, and emits one consumption/generation delta per reading interval: +/// +/// first reading → value − InitialBaseline (baseline default 0 reproduces the +/// spreadsheet's month-one full register, e.g. Haus 411, Auto 3755); +/// meter swap → an explicit Amount override if given (how the water swap …861→2 +/// reconciles to 12), otherwise (oldFinal − prev) + (curr − newInitial); +/// counter reset → baseline restarts at NewValue (default 0); +/// unexplained decrease → 0 with an anomaly flagged (never a silent negative), rebaselined +/// to the current value. +/// +/// +public abstract class CounterNormalizerBase : IMeterNormalizer +{ + public abstract MeterMode Mode { get; } + + protected abstract ConsumptionKind Kind { get; } + + public IEnumerable Normalize(NormalizationContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var readings = context.Readings.OrderBy(r => r.Time).ToList(); + if (readings.Count == 0) + { + yield break; + } + + var swaps = context.Events + .Where(e => e.EventType is MeterEventType.MeterSwap or MeterEventType.CounterReset) + .OrderBy(e => e.Time) + .ToList(); + + double previous = context.Meter.InitialBaseline; + DateTimeOffset? previousTime = null; + + foreach (var reading in readings) + { + var quality = reading.Quality == ReadingQuality.Measured ? ReadingQuality.Measured : reading.Quality; + var swap = FindEvent(swaps, previousTime, reading.Time); + + double amount; + if (swap is { EventType: MeterEventType.MeterSwap }) + { + amount = swap.Amount + ?? ((swap.PrevValue ?? previous) - previous) + (reading.Value - (swap.NewValue ?? 0)); + } + else if (swap is { EventType: MeterEventType.CounterReset }) + { + amount = reading.Value - (swap.NewValue ?? 0); + } + else if (reading.Value >= previous) + { + amount = reading.Value - previous; + } + else + { + // Unexplained decrease: emit nothing meaningful, rebaseline, and mark quality. + amount = 0; + quality = ReadingQuality.Estimated; + } + + yield return new Consumption + { + MeterId = context.Meter.MeterId, + Time = reading.Time, + Amount = amount, + Kind = Kind, + Quality = quality, + ImportBatchId = reading.ImportBatchId, + }; + + previous = reading.Value; + previousTime = reading.Time; + } + } + + private static MeterEvent? FindEvent(List events, DateTimeOffset? afterExclusive, DateTimeOffset upToInclusive) + { + foreach (var e in events) + { + var afterOk = afterExclusive is null || e.Time > afterExclusive.Value; + if (afterOk && e.Time <= upToInclusive) + { + return e; + } + } + + return null; + } +} diff --git a/src/Core/Normalization/Normalizers/CumulativeCounterNormalizer.cs b/src/Core/Normalization/Normalizers/CumulativeCounterNormalizer.cs new file mode 100644 index 0000000..1b7b3ad --- /dev/null +++ b/src/Core/Normalization/Normalizers/CumulativeCounterNormalizer.cs @@ -0,0 +1,11 @@ +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Normalization.Normalizers; + +/// Monotonic consumption register (electricity house/grid/EV, water). Δ register → consumption. +public sealed class CumulativeCounterNormalizer : CounterNormalizerBase +{ + public override MeterMode Mode => MeterMode.CumulativeCounter; + + protected override ConsumptionKind Kind => ConsumptionKind.Consumption; +} diff --git a/src/Core/Normalization/Normalizers/DirectDeltaNormalizer.cs b/src/Core/Normalization/Normalizers/DirectDeltaNormalizer.cs new file mode 100644 index 0000000..8256d19 --- /dev/null +++ b/src/Core/Normalization/Normalizers/DirectDeltaNormalizer.cs @@ -0,0 +1,30 @@ +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Normalization.Normalizers; + +/// +/// The source already reports increments (SDD §5.2 direct_delta): each reading value is the +/// consumption for its interval, used verbatim. +/// +public sealed class DirectDeltaNormalizer : IMeterNormalizer +{ + public MeterMode Mode => MeterMode.DirectDelta; + + public IEnumerable Normalize(NormalizationContext context) + { + ArgumentNullException.ThrowIfNull(context); + + foreach (var reading in context.Readings.OrderBy(x => x.Time)) + { + yield return new Consumption + { + MeterId = context.Meter.MeterId, + Time = reading.Time, + Amount = reading.Value, + Kind = ConsumptionKind.Consumption, + Quality = reading.Quality, + ImportBatchId = reading.ImportBatchId, + }; + } + } +} diff --git a/src/Core/Normalization/Normalizers/GenerationCounterNormalizer.cs b/src/Core/Normalization/Normalizers/GenerationCounterNormalizer.cs new file mode 100644 index 0000000..a677520 --- /dev/null +++ b/src/Core/Normalization/Normalizers/GenerationCounterNormalizer.cs @@ -0,0 +1,11 @@ +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Normalization.Normalizers; + +/// Monotonic generation register (PV Solar 1/2). Δ register → generation. +public sealed class GenerationCounterNormalizer : CounterNormalizerBase +{ + public override MeterMode Mode => MeterMode.GenerationCounter; + + protected override ConsumptionKind Kind => ConsumptionKind.Generation; +} diff --git a/src/Core/Normalization/Normalizers/RuntimeCounterNormalizer.cs b/src/Core/Normalization/Normalizers/RuntimeCounterNormalizer.cs new file mode 100644 index 0000000..14238c5 --- /dev/null +++ b/src/Core/Normalization/Normalizers/RuntimeCounterNormalizer.cs @@ -0,0 +1,40 @@ +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Normalization.Normalizers; + +/// +/// Cumulative operating-hours register (e.g. a burner). Consumption = Δhours × rate (SDD §5.2). +/// The rate is the tank's fixed nozzle spec when configured; otherwise 1, i.e. the meter simply +/// tracks operating hours in its own unit. For heating oil the consumption of record comes from +/// the tank level-Δ (); a runtime meter's Δhours feeds +/// the empirical L/h analytic and is not double-counted as litres. +/// +public sealed class RuntimeCounterNormalizer : IMeterNormalizer +{ + public MeterMode Mode => MeterMode.RuntimeCounter; + + public IEnumerable Normalize(NormalizationContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var rate = context.Meter.Tank is { RateMode: TankRateMode.Fixed, FixedRate: { } r } ? r : 1d; + var readings = context.Readings.OrderBy(x => x.Time).ToList(); + + double previous = context.Meter.InitialBaseline; + foreach (var reading in readings) + { + var deltaHours = reading.Value >= previous ? reading.Value - previous : 0d; + previous = reading.Value; + + yield return new Consumption + { + MeterId = context.Meter.MeterId, + Time = reading.Time, + Amount = deltaHours * rate, + Kind = ConsumptionKind.Consumption, + Quality = reading.Quality, + ImportBatchId = reading.ImportBatchId, + }; + } + } +} diff --git a/src/Core/Normalization/Normalizers/VirtualNormalizer.cs b/src/Core/Normalization/Normalizers/VirtualNormalizer.cs new file mode 100644 index 0000000..8659dd3 --- /dev/null +++ b/src/Core/Normalization/Normalizers/VirtualNormalizer.cs @@ -0,0 +1,60 @@ +using MeterVault.Core.Domain; +using MeterVault.Core.Normalization.Expressions; + +namespace MeterVault.Core.Normalization.Normalizers; + +/// +/// A meter computed from other meters' series via a user-defined expression (SDD §7.4). Each +/// referenced meter is exposed to the expression as m{id} (its amount in the current time +/// bucket). This is how PV self-consumption/savings and the electricity net figures are modelled +/// without hardcoding — e.g. Netz Einsparung = m1 - m2 (Haus − Netz) evaluated per month. +/// +public sealed class VirtualNormalizer : IMeterNormalizer +{ + public MeterMode Mode => MeterMode.Virtual; + + public IEnumerable Normalize(NormalizationContext context) + { + ArgumentNullException.ThrowIfNull(context); + var spec = context.Meter.Virtual + ?? throw new InvalidOperationException( + $"Virtual meter {context.Meter.MeterId} has no VirtualSpec."); + + var evaluator = ExpressionEvaluator.Compile(spec.Expression); + + // Per referenced meter: time → summed amount in that bucket. + var byMeter = new Dictionary>(); + var allTimes = new SortedSet(); + foreach (var meterId in spec.ReferencedMeterIds) + { + var series = context.ReferencedSeries.TryGetValue(meterId, out var s) ? s : []; + var byTime = new Dictionary(); + foreach (var row in series) + { + byTime[row.Time] = byTime.GetValueOrDefault(row.Time) + row.Amount; + allTimes.Add(row.Time); + } + + byMeter[meterId] = byTime; + } + + var variables = new Dictionary(StringComparer.Ordinal); + foreach (var time in allTimes) + { + variables.Clear(); + foreach (var meterId in spec.ReferencedMeterIds) + { + variables[$"m{meterId}"] = byMeter[meterId].GetValueOrDefault(time); + } + + yield return new Consumption + { + MeterId = context.Meter.MeterId, + Time = time, + Amount = evaluator.Evaluate(variables), + Kind = ConsumptionKind.Consumption, + Quality = ReadingQuality.Estimated, + }; + } + } +} diff --git a/src/Infrastructure/Persistence/DatabaseSeeder.cs b/src/Infrastructure/Persistence/DatabaseSeeder.cs new file mode 100644 index 0000000..d97ae00 --- /dev/null +++ b/src/Infrastructure/Persistence/DatabaseSeeder.cs @@ -0,0 +1,61 @@ +using MeterVault.Core.Domain; +using Microsoft.EntityFrameworkCore; + +namespace MeterVault.Infrastructure.Persistence; + +/// +/// Seeds sensible defaults (energy types, cost categories, base settings) on first run. +/// Idempotent — safe to call after every migration. Nothing here is hardcoded into the domain; +/// these are just starter rows the user can edit or delete (SDD FR-1). +/// +public static class DatabaseSeeder +{ + public static async Task SeedAsync(MeterVaultDbContext db, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(db); + + if (!await db.EnergyTypes.AnyAsync(cancellationToken).ConfigureAwait(false)) + { + db.EnergyTypes.AddRange(DefaultEnergyTypes()); + } + + if (!await db.CostCategories.AnyAsync(cancellationToken).ConfigureAwait(false)) + { + db.CostCategories.AddRange(DefaultCostCategories()); + } + + foreach (var (key, value) in DefaultSettings()) + { + if (!await db.AppSettings.AnyAsync(s => s.Key == key, cancellationToken).ConfigureAwait(false)) + { + db.AppSettings.Add(new AppSetting { Key = key, Value = value }); + } + } + + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + private static IEnumerable DefaultEnergyTypes() => + [ + new() { Key = "electricity", DisplayName = "Strom", BaseUnit = "kWh", DefaultMode = MeterMode.CumulativeCounter, Icon = "bolt", ColorHex = "#F6C445" }, + new() { Key = "water", DisplayName = "Wasser", BaseUnit = "m3", DefaultMode = MeterMode.CumulativeCounter, Icon = "water_drop", ColorHex = "#3B82F6" }, + new() { Key = "heating_oil", DisplayName = "Heizöl", BaseUnit = "L", DefaultMode = MeterMode.ConsumableBalance, Icon = "local_gas_station", ColorHex = "#B45309" }, + new() { Key = "gas", DisplayName = "Gas", BaseUnit = "kWh", DefaultMode = MeterMode.CumulativeCounter, Icon = "gas_meter", ColorHex = "#EF4444" }, + new() { Key = "district_heat", DisplayName = "Fernwärme", BaseUnit = "kWh", DefaultMode = MeterMode.CumulativeCounter, Icon = "thermostat", ColorHex = "#F97316" }, + ]; + + private static IEnumerable DefaultCostCategories() => + [ + new() { Name = "Heizung", ColorHex = "#B45309", Sort = 0 }, + new() { Name = "Strom", ColorHex = "#F6C445", Sort = 1 }, + new() { Name = "Wasser", ColorHex = "#3B82F6", Sort = 2 }, + new() { Name = "Pool Betrieb", ColorHex = "#14B8A6", Sort = 3 }, + ]; + + private static IEnumerable<(string Key, string Value)> DefaultSettings() => + [ + ("currency", "\"EUR\""), + ("locale", "\"en\""), + ("timezone", "\"Europe/Berlin\""), + ]; +} diff --git a/tests/Core.Tests/CumulativeCounterNormalizerTests.cs b/tests/Core.Tests/CumulativeCounterNormalizerTests.cs new file mode 100644 index 0000000..a22906b --- /dev/null +++ b/tests/Core.Tests/CumulativeCounterNormalizerTests.cs @@ -0,0 +1,105 @@ +using MeterVault.Core.Domain; +using MeterVault.Core.Normalization; +using static MeterVault.Core.Tests.TestData; + +namespace MeterVault.Core.Tests; + +public sealed class CumulativeCounterNormalizerTests +{ + private readonly INormalizationEngine _engine = NormalizationEngine.CreateDefault(); + + [Fact] + public void Straight_deltas_match_the_electricity_house_meter() + { + // Zähler Haus: Sept 0 → Okt 411 → Nov 1153 → Dez 1968 (SDD reference data). + var ctx = new NormalizationContext + { + Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" }, + Readings = + [ + Reading(1, Month(2022, 9), 0), + Reading(1, Month(2022, 10), 411), + Reading(1, Month(2022, 11), 1153), + Reading(1, Month(2022, 12), 1968), + ], + }; + + var result = _engine.Normalize(ctx); + + Assert.Equal([0d, 411d, 742d, 815d], result.Select(c => c.Amount)); + Assert.All(result, c => Assert.Equal(ConsumptionKind.Consumption, c.Kind)); + } + + [Fact] + public void First_reading_books_the_full_register_against_a_zero_baseline() + { + // Zähler Auto appears mid-series: first reading 3755 → month-one consumption 3755. + var ctx = new NormalizationContext + { + Meter = new MeterConfig { MeterId = 3, Mode = MeterMode.CumulativeCounter, Unit = "kWh" }, + Readings = [Reading(3, Month(2023, 5), 3755), Reading(3, Month(2023, 6), 3960)], + }; + + var result = _engine.Normalize(ctx); + + Assert.Equal([3755d, 205d], result.Select(c => c.Amount)); + } + + [Fact] + public void Counter_reset_rebaselines_at_new_value() + { + var ctx = new NormalizationContext + { + Meter = new MeterConfig + { + MeterId = 1, + Mode = MeterMode.CumulativeCounter, + Unit = "kWh", + InitialBaseline = 90, + }, + Readings = + [ + Reading(1, Month(2023, 1), 100), + Reading(1, Month(2023, 2), 150), + Reading(1, Month(2023, 3), 30), + Reading(1, Month(2023, 4), 80), + ], + Events = [Reset(1, Month(2023, 3), newValue: 0)], + }; + + var result = _engine.Normalize(ctx); + + Assert.Equal([10d, 50d, 30d, 50d], result.Select(c => c.Amount)); + } + + [Fact] + public void Unexplained_decrease_yields_zero_and_marks_quality() + { + var ctx = new NormalizationContext + { + Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" }, + Readings = [Reading(1, Month(2023, 1), 100), Reading(1, Month(2023, 2), 60)], + }; + + var result = _engine.Normalize(ctx); + + Assert.Equal(0d, result[1].Amount); + Assert.Equal(ReadingQuality.Estimated, result[1].Quality); + } + + [Fact] + public void Generation_meter_emits_generation_kind() + { + // Solar 2: Okt 0 → Nov 0 → ... → first real reading 7 in Feb. + var ctx = new NormalizationContext + { + Meter = new MeterConfig { MeterId = 5, Mode = MeterMode.GenerationCounter, Unit = "kWh" }, + Readings = [Reading(5, Month(2023, 1), 0), Reading(5, Month(2023, 2), 7)], + }; + + var result = _engine.Normalize(ctx); + + Assert.Equal([0d, 7d], result.Select(c => c.Amount)); + Assert.All(result, c => Assert.Equal(ConsumptionKind.Generation, c.Kind)); + } +} diff --git a/tests/Core.Tests/ExpressionEvaluatorTests.cs b/tests/Core.Tests/ExpressionEvaluatorTests.cs new file mode 100644 index 0000000..4213e32 --- /dev/null +++ b/tests/Core.Tests/ExpressionEvaluatorTests.cs @@ -0,0 +1,36 @@ +using MeterVault.Core.Normalization.Expressions; + +namespace MeterVault.Core.Tests; + +public sealed class ExpressionEvaluatorTests +{ + private static readonly Dictionary Vars = new() + { + ["m1"] = 411, + ["m2"] = 416, + }; + + [Theory] + [InlineData("1 + 2 * 3", 7)] + [InlineData("(1 + 2) * 3", 9)] + [InlineData("-5", -5)] + [InlineData("10 / 4", 2.5)] + [InlineData("2 - 3 - 4", -5)] // left-associative + [InlineData("m1 - m2", -5)] + [InlineData("m1 + m2", 827)] + [InlineData("unknown + 1", 1)] // unknown identifiers resolve to 0 + public void Evaluates_arithmetic(string expression, double expected) + { + var result = ExpressionEvaluator.Compile(expression).Evaluate(Vars); + + Assert.Equal(expected, result, 6); + } + + [Theory] + [InlineData("1 +")] + [InlineData("(1 + 2")] + [InlineData("1 2")] + [InlineData("")] + public void Rejects_malformed_expressions(string expression) => + Assert.ThrowsAny(() => ExpressionEvaluator.Compile(expression)); +} diff --git a/tests/Core.Tests/RuntimeAndTankTests.cs b/tests/Core.Tests/RuntimeAndTankTests.cs new file mode 100644 index 0000000..e8b58cb --- /dev/null +++ b/tests/Core.Tests/RuntimeAndTankTests.cs @@ -0,0 +1,90 @@ +using MeterVault.Core.Domain; +using MeterVault.Core.Normalization; +using static MeterVault.Core.Tests.TestData; + +namespace MeterVault.Core.Tests; + +public sealed class RuntimeAndTankTests +{ + private readonly INormalizationEngine _engine = NormalizationEngine.CreateDefault(); + + [Fact] + public void Runtime_counter_multiplies_delta_hours_by_fixed_rate() + { + var ctx = new NormalizationContext + { + Meter = new MeterConfig + { + MeterId = 20, + Mode = MeterMode.RuntimeCounter, + Unit = "L", + InitialBaseline = 7785, + Tank = new TankConfig { Capacity = 7000, RateMode = TankRateMode.Fixed, FixedRate = 2.0 }, + }, + Readings = [Reading(20, Month(2023, 1), 7785), Reading(20, Month(2023, 2), 7952)], + }; + + var result = _engine.Normalize(ctx); + + // Δhours: 0, then 167 × 2.0 L/h = 334 L. + Assert.Equal([0d, 334d], result.Select(c => c.Amount)); + } + + [Fact] + public void Tank_consumption_is_level_delta_between_dipsticks() + { + // Linear calibration 7000 L / 150 cm ≈ 46.667 L/cm (SDD §2.4). + var calibration = new CalibrationCurve(7000d / 150d); + var ctx = new NormalizationContext + { + Meter = new MeterConfig + { + MeterId = 30, + Mode = MeterMode.ConsumableBalance, + Unit = "L", + Tank = new TankConfig { Capacity = 7000, Calibration = calibration }, + }, + Events = + [ + Delivery(30, Month(2020, 9), 3500), // pre-first-level delivery: absorbed + TankLevelCm(30, Month(2022, 9), 35), // first level: no consumption emitted + TankLevelCm(30, Month(2022, 10), 34), // ~46.7 L drawn + TankLevelCm(30, Month(2022, 11), 27), // ~326.7 L drawn + ], + }; + + var result = _engine.Normalize(ctx); + + Assert.Equal(2, result.Count); + Assert.Equal(46.7, result[0].Amount, 1); + Assert.Equal(326.7, result[1].Amount, 1); + } + + [Fact] + public void Delivery_between_dipsticks_reconciles_into_the_balance() + { + var calibration = new CalibrationCurve(7000d / 150d); + var ctx = new NormalizationContext + { + Meter = new MeterConfig + { + MeterId = 30, + Mode = MeterMode.ConsumableBalance, + Unit = "L", + Tank = new TankConfig { Capacity = 7000, Calibration = calibration }, + }, + Events = + [ + TankLevelCm(30, Month(2022, 9), 20), // 933.3 L + Delivery(30, Month(2022, 10), 2000), + TankLevelCm(30, Month(2022, 11), 50), // 2333.3 L + ], + }; + + var result = _engine.Normalize(ctx); + + // consumption = 933.3 + 2000 − 2333.3 = 600. + Assert.Single(result); + Assert.Equal(600d, result[0].Amount, 1); + } +} diff --git a/tests/Core.Tests/TestData.cs b/tests/Core.Tests/TestData.cs new file mode 100644 index 0000000..beda949 --- /dev/null +++ b/tests/Core.Tests/TestData.cs @@ -0,0 +1,55 @@ +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Tests; + +/// Terse builders for readings/events/configs in normalizer tests. +internal static class TestData +{ + public static DateTimeOffset Month(int year, int month) => + new(new DateTime(year, month, 1, 0, 0, 0, DateTimeKind.Utc)); + + public static Reading Reading(int meterId, DateTimeOffset time, double value) => new() + { + MeterId = meterId, + Time = time, + Value = value, + Quality = ReadingQuality.Imported, + }; + + public static MeterEvent Swap(int meterId, DateTimeOffset time, double? prevValue = null, + double? newValue = null, double? amount = null) => new() + { + MeterId = meterId, + Time = time, + EventType = MeterEventType.MeterSwap, + PrevValue = prevValue, + NewValue = newValue, + Amount = amount, + }; + + public static MeterEvent Reset(int meterId, DateTimeOffset time, double? newValue = 0) => new() + { + MeterId = meterId, + Time = time, + EventType = MeterEventType.CounterReset, + NewValue = newValue, + }; + + public static MeterEvent Delivery(int meterId, DateTimeOffset time, double litres) => new() + { + MeterId = meterId, + Time = time, + EventType = MeterEventType.Delivery, + Amount = litres, + Unit = "L", + }; + + public static MeterEvent TankLevelCm(int meterId, DateTimeOffset time, double cm) => new() + { + MeterId = meterId, + Time = time, + EventType = MeterEventType.TankLevel, + Amount = cm, + Unit = "cm", + }; +} diff --git a/tests/Core.Tests/VirtualMeterTests.cs b/tests/Core.Tests/VirtualMeterTests.cs new file mode 100644 index 0000000..9bece14 --- /dev/null +++ b/tests/Core.Tests/VirtualMeterTests.cs @@ -0,0 +1,75 @@ +using MeterVault.Core.Domain; +using MeterVault.Core.Normalization; +using static MeterVault.Core.Tests.TestData; + +namespace MeterVault.Core.Tests; + +/// +/// The electricity derived columns are data-driven virtual expressions, not hardcoded formulas +/// (SDD §2.2, §7.4): Netz Einsparung = Haus − Netz, Anlage Eigenverbrauch = Erzeugung − Einsparung. +/// +public sealed class VirtualMeterTests +{ + private readonly INormalizationEngine _engine = NormalizationEngine.CreateDefault(); + + private static Consumption Cons(int meterId, DateTimeOffset time, double amount) => new() + { + MeterId = meterId, + Time = time, + Amount = amount, + Kind = ConsumptionKind.Consumption, + Quality = ReadingQuality.Imported, + }; + + [Fact] + public void Netz_einsparung_is_haus_minus_netz() + { + var oct = Month(2022, 10); + var nov = Month(2022, 11); + var ctx = new NormalizationContext + { + Meter = new MeterConfig + { + MeterId = 100, + Mode = MeterMode.Virtual, + Unit = "kWh", + Virtual = new VirtualSpec { Expression = "m1 - m2", ReferencedMeterIds = [1, 2] }, + }, + ReferencedSeries = new Dictionary> + { + [1] = [Cons(1, oct, 411), Cons(1, nov, 742)], // Haus Verbrauch + [2] = [Cons(2, oct, 416), Cons(2, nov, 832)], // Netz Verbrauch + }, + }; + + var result = _engine.Normalize(ctx); + + Assert.Equal([-5d, -90d], result.Select(c => c.Amount)); + } + + [Fact] + public void Eigenverbrauch_is_erzeugung_minus_einsparung() + { + var oct = Month(2022, 10); + var ctx = new NormalizationContext + { + Meter = new MeterConfig + { + MeterId = 101, + Mode = MeterMode.Virtual, + Unit = "kWh", + Virtual = new VirtualSpec { Expression = "m50 - m100", ReferencedMeterIds = [50, 100] }, + }, + ReferencedSeries = new Dictionary> + { + [50] = [Cons(50, oct, 76)], // Solar Erzeugung + [100] = [Cons(100, oct, -5)], // Netz Einsparung (from the previous test) + }, + }; + + var result = _engine.Normalize(ctx); + + // 76 − (−5) = 81 (Anlage Eigenverbrauch Okt 2022). + Assert.Equal([81d], result.Select(c => c.Amount)); + } +} diff --git a/tests/Core.Tests/WaterSwapTests.cs b/tests/Core.Tests/WaterSwapTests.cs new file mode 100644 index 0000000..f4094a1 --- /dev/null +++ b/tests/Core.Tests/WaterSwapTests.cs @@ -0,0 +1,57 @@ +using MeterVault.Core.Domain; +using MeterVault.Core.Normalization; +using static MeterVault.Core.Tests.TestData; + +namespace MeterVault.Core.Tests; + +/// +/// The water meter register swaps mid-series (…861 → 2 → 15). The swap month's consumption (12) +/// cannot be derived from the two visible registers alone — an explicit swap event carries the +/// boundary, and continuity is preserved across it (SDD §2.3, §7.1). +/// +public sealed class WaterSwapTests +{ + private readonly INormalizationEngine _engine = NormalizationEngine.CreateDefault(); + + private static NormalizationContext WaterContext(MeterEvent swap) => new() + { + Meter = new MeterConfig + { + MeterId = 10, + Mode = MeterMode.CumulativeCounter, + Unit = "m3", + InitialBaseline = 820, // Nov 2022 register, so Dez books 14. + }, + Readings = + [ + Reading(10, Month(2022, 12), 834), + Reading(10, Month(2023, 1), 848), + Reading(10, Month(2023, 2), 861), + Reading(10, Month(2023, 3), 2), // new meter + Reading(10, Month(2023, 4), 15), + ], + Events = [swap], + }; + + [Fact] + public void Swap_with_boundary_registers_reconciles_to_twelve() + { + // Old meter ran 861 → 873 before removal; new meter installed reading 2. + var swap = Swap(10, Month(2023, 3), prevValue: 873, newValue: 2); + + var result = _engine.Normalize(WaterContext(swap)); + + Assert.Equal([14d, 14d, 13d, 12d, 13d], result.Select(c => c.Amount)); + } + + [Fact] + public void Swap_with_explicit_amount_override_reconciles_to_twelve() + { + // The importer can seed the sheet's own März consumption (12) as an override. + var swap = Swap(10, Month(2023, 3), amount: 12); + + var result = _engine.Normalize(WaterContext(swap)); + + Assert.Equal([14d, 14d, 13d, 12d, 13d], result.Select(c => c.Amount)); + } +}