Analysis: one selected period, one set of numbers, on every page
ci / build-test (push) Successful in 2m31s

The dashboards told several stories at once. Overview asked for full
calendar years, meter detail for a fixed 12-month window that was really
13, Trends for 24 months with an Apply button, and the energy pages for
60. Each page derived "today" from UTC, so the first hours of a local day
belonged to yesterday. A missing tariff, a month nobody measured and a
genuine zero all rendered as 0. And a virtual meter -- the one thing the
spreadsheet leans on hardest -- was excluded from analysis outright:
MeterPeriodService returned null for it and the page offered a flow
diagram instead.

docs/DASHBOARD_ANALYSIS_CHANGE_BRIEF.md is the work order. Every choice it
left open is settled in docs/ANALYSIS_IMPLEMENTATION_NOTE.md as D-01..D-58
plus amendments A-01..A-30; code, tests and release notes cite those ids.

The analysis layer

Core/Analysis holds the pure rules: period presets resolved once in the
instance zone into a local date range and a half-open UTC range, bucket
plans, calendar-unit comparisons, coverage runs with a resolution class,
normalized quantities and units, the totals policy, the virtual formula
parser/validator/evaluator, and the cost calculator. "Now" comes from
TimeProvider; services never read the clock.

Normalization now writes, in the same transaction as consumption and by
diff, per-meter rollups by local day and month plus coverage runs and a
rollup state (AnalysisDataWriter). AnalysisReader answers a request from
those tables -- month rollups for month and year buckets, day rollups
otherwise, at most two partial edge days from consumption -- and
CostReader prices the result month by month. Pages, /api/v1 and the CSV
export read nothing else. The unused continuous aggregates are dropped.

The reader's statement count per request is constant whether it covers one
meter or a thousand. On a synthetic 1,000-meter, ten-year instance the
brief's target request (100 meters, ten years, monthly) takes 374 ms
against a two-second target, and the Overview went from 48,244 SQL
statements per load to 205.

Missing is not zero

Every bucket carries a status -- available, partial, missing, unresolved,
invalid, pending -- derived from coverage, never from the amount, with
provenance and a reason code beside it. A true zero is a number and a bar
on the baseline; an unknown bucket is a gap that says why; a month whose
data only exists monthly says so instead of inventing daily detail; a
scope with no tariff says "not priced" instead of 0. Rows whose interval
closes after now are reported separately rather than counted.

Virtual meters are analysis subjects

A virtual meter stores a canonical definition -- expression over m<id>
references, result kind, unit and cost rule -- validated on save and on
read for syntax, unknown or self references, loops and unit/kind rules.
It is evaluated on read from its sources' rollups over their joint
coverage: a missing source makes the bucket missing, an observed zero is
a valid input, a non-finite result is invalid with its dependency path,
and the page lists each source's contribution. Topology links are
topology only and never rewrite a saved calculation; expression-less
meters from older installs are converted once at startup. The editor has
Sum, Difference and Advanced modes with a live preview.

Totals and the bill

Per energy type the totals policy separates use, grid import, export,
generation and runtime, marks breakdown meters as breakdowns and virtual
meters as views, and never adds across units. The bill follows it: grid
import where there is one, separately priced subsections at their own
price, feed-in only on export meters, standing charges once per scope per
local day, manual costs once on their start day, categories as
non-overlapping covers whose composition reconciles to the bill. The
seeded demo's yearly totals now match the spreadsheet.

Pages and navigation

The period lives in the URL and every page reads the same contract, so a
link, a reload and the browser's Back button keep it. Shared components
carry it: page header with breadcrumbs, period toolbar, theme-aware chart
with an accessible table beside it, metric cards, comparison and
availability states, attention items that each link to the one action
that fixes them. Meter detail leads with an Analysis tab and resolves its
tabs by key; the energy page has Overview, History, Flow and Meters; the
old cost-only Trends page is a general Analysis page over portfolio, type,
category, meter or a meter comparison. Records tabs are paged server-side
instead of showing the latest 200. Everything is English and German,
light and dark, down to 360px.

Some figures change on purpose; docs/RELEASE_NOTES.md lists each one and
what the first start after the update does (it rebuilds all analysis data
before the web server listens). docs/SDD.md and CLAUDE.md describe the
system as it now is.

Tests: 1,733 Core and 746 integration, all green, plus an opt-in
performance suite with a synthetic 1,000-meter generator.
This commit is contained in:
Florian Schmidt
2026-09-20 10:29:13 +02:00
parent c0f52dbb6f
commit 8940ef25c3
384 changed files with 82753 additions and 4518 deletions
@@ -0,0 +1,482 @@
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<string, MeterMode, string, string?, QuantityKind, string> 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<QuantityKind[], string?[]> 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<ArgumentException>(() => DeclaredVirtualResult.FromSources([QuantityKind.Consumption], ["kWh", "kWh"]));
}
}