using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Virtual; using MeterVault.Core.Domain; using static MeterVault.Core.Tests.Analysis.VirtualFixtures; namespace MeterVault.Core.Tests.Analysis; /// /// Expression-less virtual meters get the explicit sum their incoming links implied, when that sum is /// unambiguous — one unit, one kind, same energy type — and are flagged "needs configuration" otherwise (D-28). /// The seeded Summe Solar is the reference case. /// public sealed class LegacyVirtualDerivationTests { [Fact] public void Seeded_summe_solar_becomes_solar_1_plus_solar_2_generation_in_kWh() { var result = LegacyVirtualDerivation.Derive(6, SeededLinks(), new MeterCatalog(SeededElectricity())); Assert.Equal(LegacyDerivationOutcome.Derived, result.Outcome); // A generation sum is not costed (A-15): generation is never billed, so its sources have no metered cost. Assert.Equal(new VirtualDefinition("m4 + m5", QuantityKind.Generation, "kWh", VirtualCostRule.None), result.Definition); Assert.Equal([4, 5], result.MeterIds); Assert.Empty(result.IgnoredMeterIds); } [Fact] public void Hundreds_of_incoming_links_are_a_finding_not_an_exception() { // Review virtual F3: 300 links into one sum render as "m1000 + … + m1299", longer than a formula may be. That is // this meter's configuration problem; it must never throw out of the derivation and fail every other meter. var catalog = new MeterCatalog( [.. Enumerable.Range(1000, 300).Select(id => Physical(id, $"PV {id}", MeterMode.GenerationCounter, QuantityKind.Generation, "kWh")), new CatalogMeter(5000, "Sum", MeterMode.Virtual, QuantityKind.Generation, "kWh", 1)]); MeterLink[] links = [.. Enumerable.Range(1000, 300).Select(id => Link(id, 5000))]; var run = LegacyVirtualDerivation.DeriveAll([5000], links, catalog); var result = Assert.Single(run.Results); Assert.Equal(LegacyDerivationOutcome.Invalid, result.Outcome); Assert.True(result.NeedsConfiguration); Assert.Equal(["Syntax"], result.Values); Assert.Null(result.Definition); // 250 sources still fit, and are derived. var fewer = LegacyVirtualDerivation.Derive(5000, links[..250], catalog); Assert.Equal(LegacyDerivationOutcome.Derived, fewer.Outcome); Assert.Equal(250, fewer.Definition!.ReferencedMeterIds.Count); } [Fact] public void Its_outgoing_topology_link_is_not_part_of_the_calculation() { // Summe Solar → Haus is flow topology; Haus gets no calculation from it, and Summe none from Netz → Haus. var catalog = new MeterCatalog(SeededElectricity()); var derived = LegacyVirtualDerivation.Derive(6, SeededLinks(), catalog).Definition!; Assert.DoesNotContain(1, derived.ReferencedMeterIds); Assert.True(VirtualValidator.Validate(derived, 6, catalog).IsValid); } [Fact] public void Rerunning_the_derivation_changes_nothing() { var catalog = new MeterCatalog(SeededElectricity()); var first = LegacyVirtualDerivation.Derive(6, SeededLinks(), catalog).Definition!; var converted = catalog.With(catalog.Find(6)! with { Definition = first }); var second = LegacyVirtualDerivation.Derive(6, SeededLinks(), converted); var rerun = LegacyVirtualDerivation.DeriveAll([6], SeededLinks(), converted); Assert.Equal(LegacyDerivationOutcome.AlreadyDefined, second.Outcome); Assert.Null(second.Definition); Assert.Equal((0, 0, 1), (rerun.Converted, rerun.NeedsConfiguration, rerun.Unchanged)); } [Fact] public void An_explicit_definition_is_never_replaced_by_one_derived_from_links() { // Summe Solar deliberately defined as Solar 1 only: its two incoming links must not turn it back into a sum. var catalog = new MeterCatalog(SeededElectricity()); var explicitOnly = catalog.With(catalog.Find(6)! with { Definition = new VirtualDefinition("m4", QuantityKind.Generation, "kWh", VirtualCostRule.SourceCosts) }); var result = LegacyVirtualDerivation.Derive(6, SeededLinks(), explicitOnly); Assert.Equal(LegacyDerivationOutcome.AlreadyDefined, result.Outcome); Assert.Null(result.Definition); Assert.False(result.NeedsConfiguration); } [Fact] public void A_water_sum_is_written_in_the_canonical_unit() { var catalog = new MeterCatalog( [ Physical(60, "Wasser Haus", MeterMode.CumulativeCounter, QuantityKind.Consumption, "m3", energyType: 2), Physical(61, "Wasser Garten", MeterMode.CumulativeCounter, QuantityKind.Consumption, "M³", energyType: 2), new CatalogMeter(62, "Wasser gesamt", MeterMode.Virtual, QuantityKind.Consumption, "m3", 2), ]); var result = LegacyVirtualDerivation.Derive(62, [Link(60, 62), Link(61, 62)], catalog); Assert.Equal(LegacyDerivationOutcome.Derived, result.Outcome); Assert.Equal("m³", result.Definition!.ResultUnit); } [Fact] public void Runtime_sources_need_configuration_because_runtime_is_no_virtual_result_kind() { var catalog = new MeterCatalog( [ Physical(70, "Brenner 1", MeterMode.RuntimeCounter, QuantityKind.Runtime, "h", energyType: 3), Physical(71, "Brenner 2", MeterMode.RuntimeCounter, QuantityKind.Runtime, "h", energyType: 3), new CatalogMeter(72, "Brenner gesamt", MeterMode.Virtual, QuantityKind.Consumption, "h", 3), ]); var result = LegacyVirtualDerivation.Derive(72, [Link(70, 72), Link(71, 72)], catalog); Assert.Equal(LegacyDerivationOutcome.UnsupportedKind, result.Outcome); Assert.Equal(["runtime"], result.Values); Assert.True(result.NeedsConfiguration); } [Fact] public void Sources_of_different_units_need_configuration_and_are_named() { // Heating oil: the tank measures litres, the burner hours — their "sum" means nothing. var catalog = new MeterCatalog( [ Physical(30, "Öltank", MeterMode.ConsumableBalance, QuantityKind.Consumption, "L", energyType: 3), Physical(31, "Brenner", MeterMode.RuntimeCounter, QuantityKind.Consumption, "h", energyType: 3), new CatalogMeter(32, "Heizung gesamt", MeterMode.Virtual, QuantityKind.Consumption, "L", 3), ]); var result = LegacyVirtualDerivation.Derive(32, [Link(30, 32), Link(31, 32)], catalog); Assert.Equal(LegacyDerivationOutcome.MixedUnits, result.Outcome); Assert.Null(result.Definition); Assert.Equal([30, 31], result.MeterIds); Assert.Equal(["L", "h"], result.Values); } [Fact] public void Consumption_plus_generation_is_ambiguous_and_needs_configuration() { var catalog = new MeterCatalog(SeededElectricity()); var result = LegacyVirtualDerivation.Derive(6, [Link(1, 6), Link(4, 6)], catalog); Assert.Equal(LegacyDerivationOutcome.MixedKinds, result.Outcome); Assert.Equal(["consumption", "generation"], result.Values); } [Fact] public void Links_from_another_energy_type_are_ignored_not_summed() { var catalog = new MeterCatalog(SeededElectricity()); var result = LegacyVirtualDerivation.Derive(6, [.. SeededLinks(), Link(7, 6)], catalog); Assert.Equal("m4 + m5", result.Definition!.Expression); Assert.Equal([7], result.IgnoredMeterIds); } [Fact] public void A_virtual_meter_without_incoming_links_needs_configuration() { var result = LegacyVirtualDerivation.Derive(6, [Link(6, 1)], new MeterCatalog(SeededElectricity())); Assert.Equal(LegacyDerivationOutcome.NoSources, result.Outcome); } [Fact] public void A_physical_meter_is_not_derived() { Assert.Equal(LegacyDerivationOutcome.NotVirtual, LegacyVirtualDerivation.Derive(1, SeededLinks(), new MeterCatalog(SeededElectricity())).Outcome); } [Fact] public void Nested_legacy_meters_are_converted_in_dependency_order() { // 40 = links from Solar 1 and Solar 2; 41 = links from 40 and a third generation meter 42. var catalog = new MeterCatalog( [ .. SeededElectricity(), new CatalogMeter(40, "PV Dach", MeterMode.Virtual, QuantityKind.Generation, "kWh", 1), new CatalogMeter(41, "PV gesamt", MeterMode.Virtual, QuantityKind.Generation, "kWh", 1), Physical(42, "Balkonkraftwerk", MeterMode.GenerationCounter, QuantityKind.Generation, "kWh"), ]); MeterLink[] links = [Link(4, 40), Link(5, 40), Link(40, 41), Link(42, 41)]; // Alone, 41 has to wait for 40. Assert.Equal(LegacyDerivationOutcome.SourceNeedsConfiguration, LegacyVirtualDerivation.Derive(41, links, catalog).Outcome); var run = LegacyVirtualDerivation.DeriveAll([41, 40], links, catalog); Assert.Equal([40, 41], run.Results.Select(r => r.MeterId)); Assert.Equal("m40 + m42", run.Results[1].Definition!.Expression); Assert.Equal(2, run.Converted); Assert.Equal(0, run.NeedsConfiguration); } [Fact] public void A_self_link_does_not_make_a_legacy_meter_a_loop() { var run = LegacyVirtualDerivation.DeriveAll([6], [.. SeededLinks(), Link(6, 6)], new MeterCatalog(SeededElectricity())); var summe = Assert.Single(run.Results); Assert.Equal(LegacyDerivationOutcome.Derived, summe.Outcome); Assert.Equal("m4 + m5", summe.Definition!.Expression); } [Fact] public void Links_across_energy_types_neither_order_nor_loop_the_run() { // A flow drawing links the electricity sum and a water sum both ways. Derive ignores those links, so the run // must not report them as a loop either. var catalog = new MeterCatalog( [ .. SeededElectricity(), Physical(60, "Wasser Haus", MeterMode.CumulativeCounter, QuantityKind.Consumption, "m³", energyType: 2), new CatalogMeter(62, "Wasser gesamt", MeterMode.Virtual, QuantityKind.Consumption, "m³", 2), ]); var run = LegacyVirtualDerivation.DeriveAll([6, 62], [.. SeededLinks(), Link(60, 62), Link(6, 62), Link(62, 6)], catalog); Assert.Equal(2, run.Converted); Assert.Equal("m60", run.Results.Single(r => r.MeterId == 62).Definition!.Expression); } [Fact] public void Legacy_meters_linked_in_a_loop_are_reported_with_the_path() { var catalog = new MeterCatalog( [ .. SeededElectricity(), new CatalogMeter(50, "X", MeterMode.Virtual, QuantityKind.Generation, "kWh", 1), new CatalogMeter(51, "Y", MeterMode.Virtual, QuantityKind.Generation, "kWh", 1), ]); var run = LegacyVirtualDerivation.DeriveAll([50, 51, 6], [.. SeededLinks(), Link(50, 51), Link(51, 50), Link(4, 50)], catalog); Assert.Equal(1, run.Converted); Assert.Equal(2, run.NeedsConfiguration); var x = run.Results.Single(r => r.MeterId == 50); Assert.Equal(LegacyDerivationOutcome.Cycle, x.Outcome); Assert.Equal([50, 51, 50], x.MeterIds); } }