using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Quantities; using MeterVault.Core.Domain; using MeterVault.Core.Normalization; using static MeterVault.Core.Tests.TestData; namespace MeterVault.Core.Tests.Analysis; public sealed class NormalizedQuantityTests { private static readonly TankInfo OilTank = new("L", TankRateMode.Empirical, null); private static Meter MeterOf(string name, MeterMode mode, string unit, string? role = null) => new() { Id = 1, Name = name, Mode = mode, Unit = unit, Meta = role is null ? "{}" : MeterMeta.SetRole("{}", role), }; public static TheoryData SeededMeters => new() { // The reference installation (ReferenceDataImporter), as D-22 classifies it. { "Zähler Haus", MeterMode.CumulativeCounter, "kWh", MeterRoles.TotalLoad, QuantityKind.Consumption, "kWh" }, { "Zähler Netz", MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport, QuantityKind.Consumption, "kWh" }, { "Zähler Auto", MeterMode.CumulativeCounter, "kWh", null, QuantityKind.Consumption, "kWh" }, { "Zähler Solar 1", MeterMode.GenerationCounter, "kWh", null, QuantityKind.Generation, "kWh" }, { "Zähler Solar 2", MeterMode.GenerationCounter, "kWh", null, QuantityKind.Generation, "kWh" }, { "Zähler Wasser", MeterMode.CumulativeCounter, "m3", null, QuantityKind.Consumption, "m³" }, { "Brenner", MeterMode.RuntimeCounter, "h", null, QuantityKind.Runtime, "h" }, }; [Theory] [MemberData(nameof(SeededMeters))] public void Seeded_meters_have_the_kind_and_unit_their_normalizer_books( string name, MeterMode mode, string unit, string? role, QuantityKind kind, string normalizedUnit) { var quantity = NormalizedQuantity.Of(MeterOf(name, mode, unit, role)); Assert.Equal(new NormalizedQuantity(kind, normalizedUnit), quantity); } [Fact] public void The_seeded_oil_tank_books_litres_of_consumption() { var tank = new Tank { MeterId = 1, Capacity = 7000, Unit = "L" }; var quantity = NormalizedQuantity.Of(MeterOf("Öltank", MeterMode.ConsumableBalance, "L"), tank); Assert.Equal(new NormalizedQuantity(QuantityKind.Consumption, "L"), quantity); } [Fact] public void Summe_Solar_is_the_generation_it_declares() { // D-28: the reference importer writes m(Solar 1) + m(Solar 2) as generation in kWh. var quantity = NormalizedQuantity.Of( MeterOf("Summe Solar", MeterMode.Virtual, "kWh"), virtualResult: new DeclaredVirtualResult(QuantityKind.Generation, "kWh")); Assert.Equal(new NormalizedQuantity(QuantityKind.Generation, "kWh"), quantity); } [Fact] public void A_runtime_counter_without_a_tank_counts_hours() { var quantity = NormalizedQuantity.Of(MeterMode.RuntimeCounter, "h", null, null, null); Assert.Equal(QuantityKind.Runtime, quantity.Kind); Assert.Equal("h", quantity.Unit); Assert.Equal(QuantityNotes.None, quantity.Notes); Assert.Equal(Provenance.None, quantity.ImpliedProvenance); } [Theory] [InlineData("h", "h")] [InlineData("Std", "h")] [InlineData("Betriebsstunden", "h")] [InlineData("min", "min")] [InlineData("Minuten", "min")] [InlineData("s", "s")] public void A_runtime_counter_books_its_register_in_the_registers_own_time_unit(string meterUnit, string expected) { Assert.Equal( new NormalizedQuantity(QuantityKind.Runtime, expected), NormalizedQuantity.Of(MeterMode.RuntimeCounter, meterUnit, null, null, null)); } [Fact] public void A_runtime_register_in_minutes_books_minutes_exactly_as_the_normalizer_does() { var context = new NormalizationContext { Meter = new MeterConfig { MeterId = 21, Mode = MeterMode.RuntimeCounter, Unit = "min", InitialBaseline = 600 }, Readings = [Reading(21, Month(2023, 1), 600), Reading(21, Month(2023, 2), 720)], }; var booked = NormalizationEngine.CreateDefault().Normalize(context).Sum(c => c.Amount); // 120 minutes of burner time, not 120 hours: an "EUR/h" price must see minutes, not hours. Assert.Equal(120d, booked); Assert.Equal("min", NormalizedQuantity.Of(MeterMode.RuntimeCounter, "min", null, null, null).Unit); Assert.Equal(1 / 60d, TariffUnit.Applicability("EUR/h", "min", TariffComponent.UnitPrice).Factor, 12); } [Theory] [InlineData("")] [InlineData("Stk")] [InlineData("kWh")] public void A_runtime_counter_whose_register_names_no_time_counts_hours(string meterUnit) { Assert.Equal( new NormalizedQuantity(QuantityKind.Runtime, "h"), NormalizedQuantity.Of(MeterMode.RuntimeCounter, meterUnit, null, null, null)); } [Theory] [InlineData("min")] [InlineData("s")] public void A_fixed_hourly_rate_on_a_register_that_does_not_count_hours_is_flagged(string meterUnit) { var quantity = NormalizedQuantity.Of(MeterMode.RuntimeCounter, meterUnit, null, new TankInfo("L", TankRateMode.Fixed, 2.0), null); Assert.Equal("L", quantity.Unit); Assert.Equal(QuantityNotes.FixedRateEstimate | QuantityNotes.RegisterNotInHours, quantity.Notes); } [Fact] public void A_runtime_counter_with_a_fixed_rate_tank_books_the_tank_unit_as_an_estimate() { var tank = new TankInfo("Liter", TankRateMode.Fixed, 2.0); var quantity = NormalizedQuantity.Of(MeterMode.RuntimeCounter, "h", null, tank, null); // D-20: the kind stays runtime; the volume is hours × nozzle rate, so it is estimated. Assert.Equal(QuantityKind.Runtime, quantity.Kind); Assert.Equal("L", quantity.Unit); Assert.Equal(QuantityNotes.FixedRateEstimate, quantity.Notes); Assert.Equal(Provenance.Estimated, quantity.ImpliedProvenance); } [Fact] public void A_runtime_counter_with_an_empirical_tank_still_counts_hours() { var tank = new TankInfo("L", TankRateMode.Empirical, 2.0); Assert.Equal( new NormalizedQuantity(QuantityKind.Runtime, "h"), NormalizedQuantity.Of(MeterMode.RuntimeCounter, "h", null, tank, null)); } [Fact] public void A_fixed_tank_without_a_rate_books_hours_exactly_as_the_normalizer_does() { var tank = new TankInfo("L", TankRateMode.Fixed, null); var context = new NormalizationContext { Meter = new MeterConfig { MeterId = 20, Mode = MeterMode.RuntimeCounter, Unit = "h", InitialBaseline = 100, Tank = new TankConfig { Capacity = 7000, RateMode = TankRateMode.Fixed, FixedRate = null }, }, Readings = [Reading(20, Month(2023, 1), 100), Reading(20, Month(2023, 2), 167)], }; var booked = NormalizationEngine.CreateDefault().Normalize(context).Sum(c => c.Amount); Assert.Equal(67d, booked); // hours, not litres Assert.Equal("h", NormalizedQuantity.Of(MeterMode.RuntimeCounter, "h", null, tank, null).Unit); } [Fact] public void A_fixed_rate_tank_without_a_unit_falls_back_to_litres_and_says_so() { var quantity = NormalizedQuantity.Of(MeterMode.RuntimeCounter, "h", null, new TankInfo(" ", TankRateMode.Fixed, 1.8), null); Assert.Equal("L", quantity.Unit); Assert.Equal(QuantityNotes.FixedRateEstimate | QuantityNotes.NoTankUnit, quantity.Notes); } [Theory] [InlineData("kW", "kWh")] [InlineData("W", "Wh")] [InlineData("MW", "MWh")] [InlineData("mW", "mWh")] [InlineData("W/m²", "Wh/m²")] [InlineData("L/h", "L")] [InlineData("m3/h", "m³")] public void An_instant_rate_meter_books_its_rate_unit_integrated_over_hours(string rateUnit, string expected) { var quantity = NormalizedQuantity.Of(MeterMode.InstantRate, rateUnit, null, null, null); Assert.Equal(new NormalizedQuantity(QuantityKind.Consumption, expected), quantity); } [Fact] public void An_instant_rate_in_kilowatts_integrates_to_the_kilowatt_hours_the_normalizer_books() { // 2 kW held for 90 minutes is 3 kWh. var start = new DateTimeOffset(2026, 3, 1, 10, 0, 0, TimeSpan.Zero); var context = new NormalizationContext { Meter = new MeterConfig { MeterId = 7, Mode = MeterMode.InstantRate, Unit = "kW" }, Readings = [DayReading(7, start, 2), DayReading(7, start.AddMinutes(90), 2)], }; var booked = NormalizationEngine.CreateDefault().Normalize(context).Sum(c => c.Amount); Assert.Equal(3d, booked, 9); Assert.Equal("kWh", NormalizedQuantity.Of(MeterMode.InstantRate, "kW", null, null, null).Unit); } [Theory] [InlineData("kWh", "kWh")] [InlineData("Stk", "stk")] public void An_instant_rate_in_a_unit_that_is_not_a_rate_is_flagged_as_assumed_per_hour(string rateUnit, string expected) { var quantity = NormalizedQuantity.Of(MeterMode.InstantRate, rateUnit, null, null, null); Assert.Equal(expected, quantity.Unit); Assert.Equal(QuantityNotes.RateAssumedPerHour, quantity.Notes); } [Theory] [InlineData("L/min", "L/min")] [InlineData("m³/s", "m³/s")] [InlineData("l/Tag", "L/tag")] public void An_instant_rate_over_another_time_keeps_its_rate_unit_so_no_price_per_quantity_applies( string rateUnit, string expected) { var quantity = NormalizedQuantity.Of(MeterMode.InstantRate, rateUnit, null, null, null); Assert.Equal(new NormalizedQuantity(QuantityKind.Consumption, expected, QuantityNotes.RateNotPerHour), quantity); Assert.Equal(TariffUnitFit.Mismatch, TariffUnit.Applicability("EUR/L", quantity.Unit, TariffComponent.UnitPrice).Fit); Assert.Equal(TariffUnitFit.Mismatch, TariffUnit.Applicability("EUR/m³", quantity.Unit, TariffComponent.UnitPrice).Fit); } [Fact] public void A_flow_per_minute_books_a_sixtieth_of_its_litres_so_calling_them_litres_would_be_wrong() { // 10 L/min held for an hour is 600 L; the normalizer books 10 because it integrates per hour. var start = new DateTimeOffset(2026, 3, 1, 10, 0, 0, TimeSpan.Zero); var context = new NormalizationContext { Meter = new MeterConfig { MeterId = 8, Mode = MeterMode.InstantRate, Unit = "L/min" }, Readings = [DayReading(8, start, 10), DayReading(8, start.AddHours(1), 10)], }; var booked = NormalizationEngine.CreateDefault().Normalize(context).Sum(c => c.Amount); Assert.Equal(10d, booked, 9); Assert.NotEqual("L", NormalizedQuantity.Of(MeterMode.InstantRate, "L/min", null, null, null).Unit); } [Fact] public void An_instant_rate_meter_without_a_unit_carries_no_note() { Assert.Equal( new NormalizedQuantity(QuantityKind.Consumption, string.Empty), NormalizedQuantity.Of(MeterMode.InstantRate, " ", null, null, null)); } [Theory] [InlineData(MeterMode.CumulativeCounter, "kWh", "kWh")] [InlineData(MeterMode.DirectDelta, "kWh", "kWh")] [InlineData(MeterMode.InstantRate, "kW", "kWh")] public void A_grid_export_meter_measures_export_never_consumption(MeterMode mode, string unit, string expectedUnit) { var quantity = NormalizedQuantity.Of(mode, unit, MeterRoles.GridExport, null, null); Assert.Equal(new NormalizedQuantity(QuantityKind.Export, expectedUnit), quantity); } [Theory] [InlineData(MeterRoles.TotalLoad)] [InlineData(MeterRoles.GridImport)] [InlineData(null)] [InlineData("pv_inverter")] public void Other_roles_leave_a_counter_measuring_consumption(string? role) { var quantity = NormalizedQuantity.Of(MeterMode.CumulativeCounter, "kWh", role, null, null); Assert.Equal(new NormalizedQuantity(QuantityKind.Consumption, "kWh"), quantity); } [Fact] public void The_role_is_read_from_the_meters_meta() { var export = MeterOf("Einspeisung", MeterMode.CumulativeCounter, "kWh", "GRID_EXPORT "); Assert.Equal(QuantityKind.Export, NormalizedQuantity.Of(export).Kind); } [Fact] public void A_role_the_mode_cannot_hold_is_ignored_and_flagged() { var generation = NormalizedQuantity.Of(MeterMode.GenerationCounter, "kWh", MeterRoles.GridExport, null, null); var tank = NormalizedQuantity.Of(MeterMode.ConsumableBalance, "L", MeterRoles.TotalLoad, OilTank, null); Assert.Equal(new NormalizedQuantity(QuantityKind.Generation, "kWh", QuantityNotes.RoleIgnored), generation); Assert.Equal(new NormalizedQuantity(QuantityKind.Consumption, "L", QuantityNotes.RoleIgnored), tank); } [Fact] public void A_tank_books_in_the_tank_unit_not_the_level_unit() { // Dipstick readings in cm are calibrated to the tank's litres. var quantity = NormalizedQuantity.Of(MeterMode.ConsumableBalance, "cm", null, new TankInfo("Liter", TankRateMode.Empirical, null), null); Assert.Equal(new NormalizedQuantity(QuantityKind.Consumption, "L"), quantity); } [Fact] public void A_tank_meter_without_a_tank_falls_back_to_its_own_unit() { var quantity = NormalizedQuantity.Of(MeterMode.ConsumableBalance, "Liter", null, null, null); Assert.Equal(new NormalizedQuantity(QuantityKind.Consumption, "L", QuantityNotes.NoTankUnit), quantity); } [Theory] [InlineData("m3", "m³")] [InlineData("KWH", "kWh")] [InlineData("Stk", "stk")] public void A_direct_delta_meter_books_consumption_in_its_own_normalized_unit(string unit, string expected) { Assert.Equal( new NormalizedQuantity(QuantityKind.Consumption, expected), NormalizedQuantity.Of(MeterMode.DirectDelta, unit, null, null, null)); } [Fact] public void A_generation_counter_books_generation_in_its_own_unit() { Assert.Equal( new NormalizedQuantity(QuantityKind.Generation, "MWh"), NormalizedQuantity.Of(MeterMode.GenerationCounter, "MWH", null, null, null)); } [Theory] [InlineData(QuantityKind.Consumption, "m3", "m³")] [InlineData(QuantityKind.Generation, "kWh", "kWh")] [InlineData(QuantityKind.Net, "kWh", "kWh")] [InlineData(QuantityKind.Indicator, "%", "%")] public void A_virtual_meter_is_its_declared_result(QuantityKind kind, string unit, string expectedUnit) { var quantity = NormalizedQuantity.Of(MeterMode.Virtual, "kWh", null, null, new DeclaredVirtualResult(kind, unit)); Assert.Equal(new NormalizedQuantity(kind, expectedUnit), quantity); } [Fact] public void A_virtual_meter_without_a_declaration_assumes_consumption_in_its_unit_and_says_so() { var quantity = NormalizedQuantity.Of(MeterMode.Virtual, "kWh", null, null, null); Assert.Equal(new NormalizedQuantity(QuantityKind.Consumption, "kWh", QuantityNotes.UndeclaredResult), quantity); } [Theory] [InlineData(QuantityKind.Cost)] [InlineData(QuantityKind.Export)] [InlineData(QuantityKind.Runtime)] public void A_virtual_meter_cannot_declare_a_kind_outside_D25(QuantityKind kind) { var quantity = NormalizedQuantity.Of(MeterMode.Virtual, "kWh", null, null, new DeclaredVirtualResult(kind, "kWh")); Assert.Equal(QuantityKind.Consumption, quantity.Kind); Assert.Equal(QuantityNotes.UndeclaredResult, quantity.Notes); } [Fact] public void A_virtual_declaration_without_a_unit_keeps_the_meters_unit() { var quantity = NormalizedQuantity.Of(MeterMode.Virtual, "kwh", null, null, new DeclaredVirtualResult(QuantityKind.Generation, null)); Assert.Equal(new NormalizedQuantity(QuantityKind.Generation, "kWh"), quantity); } [Fact] public void A_virtual_meter_cannot_hold_a_role() { var quantity = NormalizedQuantity.Of( MeterMode.Virtual, "kWh", MeterRoles.GridExport, null, new DeclaredVirtualResult(QuantityKind.Generation, "kWh")); Assert.Equal(new NormalizedQuantity(QuantityKind.Generation, "kWh", QuantityNotes.RoleIgnored), quantity); } [Fact] public void Deconstruction_gives_kind_and_unit_as_D20_writes_it() { var (kind, unit) = NormalizedQuantity.Of(MeterMode.CumulativeCounter, "m3", null, null, null); var (_, _, notes) = NormalizedQuantity.Of(MeterMode.RuntimeCounter, "h", null, new TankInfo("L", TankRateMode.Fixed, 2), null); Assert.Equal(QuantityKind.Consumption, kind); Assert.Equal("m³", unit); Assert.Equal(QuantityNotes.FixedRateEstimate, notes); } [Fact] public void A_legacy_sum_of_generation_meters_is_the_generation_it_adds_up() { // Summe Solar before D-28 converts it: the implied sum of Solar 1 and Solar 2. var solar = NormalizedQuantity.Of(MeterMode.GenerationCounter, "kWh", null, null, null); var declared = DeclaredVirtualResult.FromSources([solar, solar with { Unit = "KWH" }]); Assert.Equal(new DeclaredVirtualResult(QuantityKind.Generation, "kWh"), declared); Assert.Equal( new NormalizedQuantity(QuantityKind.Generation, "kWh"), NormalizedQuantity.Of(MeterMode.Virtual, "kWh", null, null, declared)); } [Fact] public void A_legacy_sum_takes_the_canonical_unit_of_its_sources() { var declared = DeclaredVirtualResult.FromSources([QuantityKind.Consumption, QuantityKind.Consumption], ["m3", "m³"]); Assert.Equal(new DeclaredVirtualResult(QuantityKind.Consumption, "m³"), declared); } public static TheoryData SumsThatNeedConfiguration => new() { { [], [] }, { [QuantityKind.Consumption, QuantityKind.Generation], ["kWh", "kWh"] }, { [QuantityKind.Consumption, QuantityKind.Consumption], ["kWh", "m³"] }, { [QuantityKind.Consumption, QuantityKind.Consumption], ["kWh", null] }, { [QuantityKind.Indicator, QuantityKind.Indicator], ["%", "%"] }, { [QuantityKind.Export], ["kWh"] }, { [QuantityKind.Runtime, QuantityKind.Runtime], ["h", "h"] }, { [QuantityKind.Cost], ["EUR"] }, }; [Theory] [MemberData(nameof(SumsThatNeedConfiguration))] public void A_legacy_sum_that_mixes_kinds_or_units_or_adds_what_no_virtual_meter_may_declare_needs_configuration( QuantityKind[] kinds, string?[] units) { var declared = DeclaredVirtualResult.FromSources(kinds, units); Assert.Null(declared); Assert.Equal( QuantityNotes.UndeclaredResult, NormalizedQuantity.Of(MeterMode.Virtual, "kWh", null, null, declared).Notes); } [Fact] public void A_legacy_sum_over_a_virtual_source_that_is_itself_undeclared_needs_configuration() { var solar = NormalizedQuantity.Of(MeterMode.GenerationCounter, "kWh", null, null, null); var undeclared = NormalizedQuantity.Of(MeterMode.Virtual, "kWh", null, null, null); Assert.Null(DeclaredVirtualResult.FromSources([solar, undeclared])); Assert.Null(DeclaredVirtualResult.FromSources([undeclared])); } [Fact] public void A_legacy_sum_of_net_balances_stays_net() { Assert.Equal( new DeclaredVirtualResult(QuantityKind.Net, "kWh"), DeclaredVirtualResult.FromSources([QuantityKind.Net, QuantityKind.Net], ["kWh", "kwh"])); } [Fact] public void A_legacy_sum_whose_sources_have_no_unit_keeps_the_meters_unit() { var declared = DeclaredVirtualResult.FromSources([QuantityKind.Consumption], [" "]); Assert.Equal(new DeclaredVirtualResult(QuantityKind.Consumption, null), declared); Assert.Equal("kWh", NormalizedQuantity.Of(MeterMode.Virtual, "kWh", null, null, declared).Unit); } [Fact] public void Kinds_and_units_of_a_legacy_sum_must_describe_the_same_sources() { Assert.Throws(() => DeclaredVirtualResult.FromSources([QuantityKind.Consumption], ["kWh", "kWh"])); } }