M1: pure normalization engine + default seed
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
This commit is contained in:
+2
-1
@@ -78,7 +78,8 @@ static async Task MigrateDatabaseAsync(WebApplication app)
|
|||||||
await using var scope = app.Services.CreateAsyncScope();
|
await using var scope = app.Services.CreateAsyncScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<MeterVaultDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<MeterVaultDbContext>();
|
||||||
await db.Database.MigrateAsync().ConfigureAwait(false);
|
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Exposed for WebApplicationFactory-based integration tests.</summary>
|
/// <summary>Exposed for WebApplicationFactory-based integration tests.</summary>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace MeterVault.Core.Normalization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CalibrationCurve(double VolumePerUnit, double Offset = 0)
|
||||||
|
{
|
||||||
|
/// <summary>Litres for a given physical level (cm), per the calibration.</summary>
|
||||||
|
public double ToVolume(double level) => (level * VolumePerUnit) + Offset;
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace MeterVault.Core.Normalization.Expressions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A tiny, safe arithmetic evaluator for virtual-meter expressions (SDD §7.4). Supports
|
||||||
|
/// <c>+ - * /</c>, parentheses, unary minus, numeric literals, and identifiers resolved against
|
||||||
|
/// a supplied variable map (e.g. <c>m1 - m2</c> where <c>m1</c> is meter 1's amount in a bucket).
|
||||||
|
/// No I/O, no reflection, no arbitrary code — only arithmetic over whitelisted tokens.
|
||||||
|
/// Parse once with <see cref="Compile"/>, then evaluate per time bucket.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExpressionEvaluator
|
||||||
|
{
|
||||||
|
private readonly Func<IReadOnlyDictionary<string, double>, double> _eval;
|
||||||
|
|
||||||
|
private ExpressionEvaluator(Func<IReadOnlyDictionary<string, double>, 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<string, double> 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<IReadOnlyDictionary<string, double>, double> ParseExpression() => ParseAdditive();
|
||||||
|
|
||||||
|
public void ExpectEnd()
|
||||||
|
{
|
||||||
|
SkipWhitespace();
|
||||||
|
if (_pos != text.Length)
|
||||||
|
{
|
||||||
|
throw new FormatException($"Unexpected token at position {_pos} in expression '{text}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Func<IReadOnlyDictionary<string, double>, 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<IReadOnlyDictionary<string, double>, 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<IReadOnlyDictionary<string, double>, double> ParseUnary()
|
||||||
|
{
|
||||||
|
SkipWhitespace();
|
||||||
|
if (Match('-'))
|
||||||
|
{
|
||||||
|
var operand = ParseUnary();
|
||||||
|
return vars => -operand(vars);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Match('+'))
|
||||||
|
{
|
||||||
|
return ParseUnary();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ParsePrimary();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Func<IReadOnlyDictionary<string, double>, 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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using MeterVault.Core.Domain;
|
||||||
|
|
||||||
|
namespace MeterVault.Core.Normalization;
|
||||||
|
|
||||||
|
/// <summary>A per-mode normalization strategy (SDD §5.2).</summary>
|
||||||
|
public interface IMeterNormalizer
|
||||||
|
{
|
||||||
|
MeterMode Mode { get; }
|
||||||
|
|
||||||
|
IEnumerable<Consumption> Normalize(NormalizationContext context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns raw readings/events into normalized consumption/generation deltas by dispatching on
|
||||||
|
/// <see cref="MeterConfig.Mode"/>. Pure domain logic: no infrastructure dependencies.
|
||||||
|
/// </summary>
|
||||||
|
public interface INormalizationEngine
|
||||||
|
{
|
||||||
|
IReadOnlyList<Consumption> Normalize(NormalizationContext context);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using MeterVault.Core.Domain;
|
||||||
|
|
||||||
|
namespace MeterVault.Core.Normalization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The immutable, infrastructure-free view of a meter that the normalization engine needs.
|
||||||
|
/// Infrastructure projects a <see cref="Meter"/> (+ optional <see cref="Tank"/>) into this.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record MeterConfig
|
||||||
|
{
|
||||||
|
public required int MeterId { get; init; }
|
||||||
|
|
||||||
|
public required MeterMode Mode { get; init; }
|
||||||
|
|
||||||
|
public required string Unit { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Register baseline for a newly installed meter (SDD §7.1). Default 0.</summary>
|
||||||
|
public double InitialBaseline { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Tank/runtime configuration for consumable_balance and runtime_counter meters.</summary>
|
||||||
|
public TankConfig? Tank { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Expression + referenced meters for virtual meters.</summary>
|
||||||
|
public VirtualSpec? Virtual { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Consumable/runtime parameters (SDD §5.3 <c>tank</c>).</summary>
|
||||||
|
public sealed record TankConfig
|
||||||
|
{
|
||||||
|
public double Capacity { get; init; }
|
||||||
|
|
||||||
|
public TankRateMode RateMode { get; init; } = TankRateMode.Empirical;
|
||||||
|
|
||||||
|
/// <summary>Litres per runtime-hour when <see cref="RateMode"/> is Fixed.</summary>
|
||||||
|
public double? FixedRate { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Physical-level (cm) → volume curve. Present when dipstick readings are recorded.</summary>
|
||||||
|
public CalibrationCurve? Calibration { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A virtual meter's expression over other meters' series (SDD §7.4).</summary>
|
||||||
|
public sealed record VirtualSpec
|
||||||
|
{
|
||||||
|
public required string Expression { get; init; }
|
||||||
|
|
||||||
|
public IReadOnlyList<int> ReferencedMeterIds { get; init; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using MeterVault.Core.Domain;
|
||||||
|
|
||||||
|
namespace MeterVault.Core.Normalization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NormalizationContext
|
||||||
|
{
|
||||||
|
public required MeterConfig Meter { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Raw readings for this meter, expected ascending by time (sorted defensively).</summary>
|
||||||
|
public IReadOnlyList<Reading> Readings { get; init; } = [];
|
||||||
|
|
||||||
|
public IReadOnlyList<MeterEvent> Events { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>Referenced meters' consumption series, keyed by meter id (virtual meters only).</summary>
|
||||||
|
public IReadOnlyDictionary<int, IReadOnlyList<Consumption>> ReferencedSeries { get; init; }
|
||||||
|
= new Dictionary<int, IReadOnlyList<Consumption>>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using MeterVault.Core.Domain;
|
||||||
|
|
||||||
|
namespace MeterVault.Core.Normalization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dispatches to the registered <see cref="IMeterNormalizer"/> for the meter's mode.
|
||||||
|
/// Register the strategies once (in DI or the parameterless factory) and reuse.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NormalizationEngine : INormalizationEngine
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyDictionary<MeterMode, IMeterNormalizer> _byMode;
|
||||||
|
|
||||||
|
public NormalizationEngine(IEnumerable<IMeterNormalizer> normalizers)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(normalizers);
|
||||||
|
_byMode = normalizers.ToDictionary(n => n.Mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds an engine with the built-in strategies for all supported modes.</summary>
|
||||||
|
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<Consumption> 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)];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using MeterVault.Core.Domain;
|
||||||
|
|
||||||
|
namespace MeterVault.Core.Normalization.Normalizers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A tank/bottle drawn down over time (SDD §5.2 <c>consumable_balance</c>, §7.3). Consumption
|
||||||
|
/// between two physical level readings is <c>prevLevel + deliveriesBetween − currLevel</c>, 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 <c>Differenz Tank</c> column.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ConsumableBalanceNormalizer : IMeterNormalizer
|
||||||
|
{
|
||||||
|
public MeterMode Mode => MeterMode.ConsumableBalance;
|
||||||
|
|
||||||
|
public IEnumerable<Consumption> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using MeterVault.Core.Domain;
|
||||||
|
|
||||||
|
namespace MeterVault.Core.Normalization.Normalizers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>first reading → <c>value − InitialBaseline</c> (baseline default 0 reproduces the
|
||||||
|
/// spreadsheet's month-one full register, e.g. Haus 411, Auto 3755);</item>
|
||||||
|
/// <item>meter swap → an explicit <c>Amount</c> override if given (how the water swap …861→2
|
||||||
|
/// reconciles to 12), otherwise <c>(oldFinal − prev) + (curr − newInitial)</c>;</item>
|
||||||
|
/// <item>counter reset → baseline restarts at <c>NewValue</c> (default 0);</item>
|
||||||
|
/// <item>unexplained decrease → 0 with an anomaly flagged (never a silent negative), rebaselined
|
||||||
|
/// to the current value.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public abstract class CounterNormalizerBase : IMeterNormalizer
|
||||||
|
{
|
||||||
|
public abstract MeterMode Mode { get; }
|
||||||
|
|
||||||
|
protected abstract ConsumptionKind Kind { get; }
|
||||||
|
|
||||||
|
public IEnumerable<Consumption> 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<MeterEvent> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using MeterVault.Core.Domain;
|
||||||
|
|
||||||
|
namespace MeterVault.Core.Normalization.Normalizers;
|
||||||
|
|
||||||
|
/// <summary>Monotonic consumption register (electricity house/grid/EV, water). Δ register → consumption.</summary>
|
||||||
|
public sealed class CumulativeCounterNormalizer : CounterNormalizerBase
|
||||||
|
{
|
||||||
|
public override MeterMode Mode => MeterMode.CumulativeCounter;
|
||||||
|
|
||||||
|
protected override ConsumptionKind Kind => ConsumptionKind.Consumption;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using MeterVault.Core.Domain;
|
||||||
|
|
||||||
|
namespace MeterVault.Core.Normalization.Normalizers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The source already reports increments (SDD §5.2 <c>direct_delta</c>): each reading value is the
|
||||||
|
/// consumption for its interval, used verbatim.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DirectDeltaNormalizer : IMeterNormalizer
|
||||||
|
{
|
||||||
|
public MeterMode Mode => MeterMode.DirectDelta;
|
||||||
|
|
||||||
|
public IEnumerable<Consumption> 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using MeterVault.Core.Domain;
|
||||||
|
|
||||||
|
namespace MeterVault.Core.Normalization.Normalizers;
|
||||||
|
|
||||||
|
/// <summary>Monotonic generation register (PV Solar 1/2). Δ register → generation.</summary>
|
||||||
|
public sealed class GenerationCounterNormalizer : CounterNormalizerBase
|
||||||
|
{
|
||||||
|
public override MeterMode Mode => MeterMode.GenerationCounter;
|
||||||
|
|
||||||
|
protected override ConsumptionKind Kind => ConsumptionKind.Generation;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using MeterVault.Core.Domain;
|
||||||
|
|
||||||
|
namespace MeterVault.Core.Normalization.Normalizers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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-Δ (<see cref="ConsumableBalanceNormalizer"/>); a runtime meter's Δhours feeds
|
||||||
|
/// the empirical L/h analytic and is not double-counted as litres.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RuntimeCounterNormalizer : IMeterNormalizer
|
||||||
|
{
|
||||||
|
public MeterMode Mode => MeterMode.RuntimeCounter;
|
||||||
|
|
||||||
|
public IEnumerable<Consumption> 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using MeterVault.Core.Domain;
|
||||||
|
using MeterVault.Core.Normalization.Expressions;
|
||||||
|
|
||||||
|
namespace MeterVault.Core.Normalization.Normalizers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A meter computed from other meters' series via a user-defined expression (SDD §7.4). Each
|
||||||
|
/// referenced meter is exposed to the expression as <c>m{id}</c> (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. <c>Netz Einsparung = m1 - m2</c> (Haus − Netz) evaluated per month.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VirtualNormalizer : IMeterNormalizer
|
||||||
|
{
|
||||||
|
public MeterMode Mode => MeterMode.Virtual;
|
||||||
|
|
||||||
|
public IEnumerable<Consumption> 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<int, Dictionary<DateTimeOffset, double>>();
|
||||||
|
var allTimes = new SortedSet<DateTimeOffset>();
|
||||||
|
foreach (var meterId in spec.ReferencedMeterIds)
|
||||||
|
{
|
||||||
|
var series = context.ReferencedSeries.TryGetValue(meterId, out var s) ? s : [];
|
||||||
|
var byTime = new Dictionary<DateTimeOffset, double>();
|
||||||
|
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<string, double>(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using MeterVault.Core.Domain;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace MeterVault.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
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<EnergyType> 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<CostCategory> 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\""),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using MeterVault.Core.Normalization.Expressions;
|
||||||
|
|
||||||
|
namespace MeterVault.Core.Tests;
|
||||||
|
|
||||||
|
public sealed class ExpressionEvaluatorTests
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<string, double> 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<Exception>(() => ExpressionEvaluator.Compile(expression));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using MeterVault.Core.Domain;
|
||||||
|
|
||||||
|
namespace MeterVault.Core.Tests;
|
||||||
|
|
||||||
|
/// <summary>Terse builders for readings/events/configs in normalizer tests.</summary>
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using MeterVault.Core.Domain;
|
||||||
|
using MeterVault.Core.Normalization;
|
||||||
|
using static MeterVault.Core.Tests.TestData;
|
||||||
|
|
||||||
|
namespace MeterVault.Core.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<int, IReadOnlyList<Consumption>>
|
||||||
|
{
|
||||||
|
[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<int, IReadOnlyList<Consumption>>
|
||||||
|
{
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using MeterVault.Core.Domain;
|
||||||
|
using MeterVault.Core.Normalization;
|
||||||
|
using static MeterVault.Core.Tests.TestData;
|
||||||
|
|
||||||
|
namespace MeterVault.Core.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user