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:
@@ -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