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,38 @@
using System.Globalization;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>Frozen instants and zones for the period tests — nothing here reads the wall clock.</summary>
internal static class AnalysisClock
{
public static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
public static readonly TimeZoneInfo NewYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
/// <summary>An unambiguous local wall-clock time in <paramref name="zone"/>, as an instant.</summary>
public static DateTimeOffset At(TimeZoneInfo zone, int year, int month, int day, int hour = 0, int minute = 0)
{
var wall = new DateTime(year, month, day, hour, minute, 0, DateTimeKind.Unspecified);
Assert.False(zone.IsInvalidTime(wall), $"{wall:s} does not exist in {zone.Id}");
Assert.False(zone.IsAmbiguousTime(wall), $"{wall:s} is ambiguous in {zone.Id}; give the offset");
return new DateTimeOffset(wall, zone.GetUtcOffset(wall));
}
/// <summary>A local wall-clock time with an explicit offset — for the hour an autumn fold repeats.</summary>
public static DateTimeOffset At(int year, int month, int day, int hour, int minute, int offsetHours) =>
new(year, month, day, hour, minute, 0, TimeSpan.FromHours(offsetHours));
public static DateTimeOffset Utc(int year, int month, int day, int hour = 0, int minute = 0) =>
new(year, month, day, hour, minute, 0, TimeSpan.Zero);
public static DateOnly Day(int year, int month, int day) => new(year, month, day);
public static DateOnly Iso(string date) => DateOnly.ParseExact(date, "yyyy-MM-dd", CultureInfo.InvariantCulture);
public static DateTimeOffset IsoInstant(string instant) =>
DateTimeOffset.ParseExact(instant, "yyyy-MM-dd'T'HH:mm'Z'", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal);
/// <summary>The wall clock an instant shows in <paramref name="zone"/>, for readable assertions.</summary>
public static string Wall(DateTimeOffset instant, TimeZoneInfo zone) =>
TimeZoneInfo.ConvertTime(instant, zone).ToString("yyyy-MM-dd HH:mm zzz", CultureInfo.InvariantCulture);
}
@@ -0,0 +1,26 @@
namespace MeterVault.Core.Tests.Analysis;
/// <summary>Fixed instants in the zones the interval and coverage tests reason about.</summary>
internal static class AnalysisTestTime
{
public static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
public static readonly TimeZoneInfo NewYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
/// <summary>A wall-clock time in <paramref name="zone"/> as an instant.</summary>
public static DateTimeOffset Local(TimeZoneInfo zone, int year, int month, int day, int hour = 0, int minute = 0, int second = 0)
{
var wall = new DateTime(year, month, day, hour, minute, second);
return new DateTimeOffset(wall, zone.GetUtcOffset(wall));
}
public static DateTimeOffset InBerlin(int year, int month, int day, int hour = 0, int minute = 0, int second = 0) =>
Local(Berlin, year, month, day, hour, minute, second);
public static DateTimeOffset Utc(int year, int month, int day, int hour = 0, int minute = 0) =>
new(year, month, day, hour, minute, 0, TimeSpan.Zero);
/// <summary>The local calendar date an instant falls on.</summary>
public static DateOnly LocalDate(DateTimeOffset instant, TimeZoneInfo zone) =>
DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(instant, zone).DateTime);
}
@@ -0,0 +1,226 @@
using System.Globalization;
using MeterVault.Core.Analysis;
using static MeterVault.Core.Tests.Analysis.AnalysisClock;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// URL tokens are stable invariant identifiers (D-02, D-46): what a German browser writes, an English one
/// reads. Parsing never throws, so a hand-edited or stale link falls back to the page default instead of
/// breaking the page.
/// </summary>
public sealed class AnalysisTokensTests
{
[Theory]
[InlineData(PeriodPreset.MonthToDate, "mtd")]
[InlineData(PeriodPreset.LastMonth, "last-month")]
[InlineData(PeriodPreset.YearToDate, "ytd")]
[InlineData(PeriodPreset.PreviousYear, "prev-year")]
[InlineData(PeriodPreset.Last12Months, "12m")]
[InlineData(PeriodPreset.Last24Months, "24m")]
[InlineData(PeriodPreset.AllHistory, "all")]
[InlineData(PeriodPreset.Custom, "custom")]
public void Every_period_preset_has_its_documented_token_and_parses_back(PeriodPreset preset, string token)
{
Assert.Equal(token, AnalysisTokens.Format(preset));
Assert.True(AnalysisTokens.TryParsePeriod(token, out var parsed));
Assert.Equal(preset, parsed);
}
[Theory]
[InlineData(BucketSize.Auto, "auto")]
[InlineData(BucketSize.Day, "day")]
[InlineData(BucketSize.Week, "week")]
[InlineData(BucketSize.Month, "month")]
[InlineData(BucketSize.Year, "year")]
public void Every_bucket_size_has_its_documented_token_and_parses_back(BucketSize size, string token)
{
Assert.Equal(token, AnalysisTokens.Format(size));
Assert.True(AnalysisTokens.TryParseBucket(token, out var parsed));
Assert.Equal(size, parsed);
}
[Fact]
public void Every_enum_value_has_a_token_so_no_state_is_unlinkable()
{
Assert.All(Enum.GetValues<PeriodPreset>(), p => Assert.False(string.IsNullOrEmpty(AnalysisTokens.Format(p))));
Assert.All(Enum.GetValues<BucketSize>(), b => Assert.False(string.IsNullOrEmpty(AnalysisTokens.Format(b))));
}
[Theory]
[InlineData("none", ComparisonKind.None, null)]
[InlineData("prev-period", ComparisonKind.PreviousPeriod, null)]
[InlineData("prev-year", ComparisonKind.PreviousYear, null)]
[InlineData("year:2025", ComparisonKind.Year, 2025)]
[InlineData("year:1997", ComparisonKind.Year, 1997)]
public void Comparison_tokens_round_trip(string token, ComparisonKind kind, int? year)
{
Assert.True(AnalysisTokens.TryParseComparison(token, out var request));
Assert.Equal(new ComparisonRequest(kind, year), request);
Assert.Equal(token, AnalysisTokens.Format(request));
}
[Fact]
public void Parsing_none_returns_the_shared_none_request()
{
Assert.True(AnalysisTokens.TryParseComparison("none", out var request));
Assert.Same(ComparisonRequest.None, request);
}
[Theory]
[InlineData("MTD", PeriodPreset.MonthToDate)]
[InlineData(" 12m ", PeriodPreset.Last12Months)]
[InlineData("Last-Month", PeriodPreset.LastMonth)]
public void Period_tokens_are_read_regardless_of_case_and_surrounding_blanks(string token, PeriodPreset expected)
{
Assert.True(AnalysisTokens.TryParsePeriod(token, out var parsed));
Assert.Equal(expected, parsed);
}
[Theory]
[InlineData("previous-year", ComparisonKind.PreviousYear)]
[InlineData("previous-period", ComparisonKind.PreviousPeriod)]
[InlineData("PREV-YEAR", ComparisonKind.PreviousYear)]
[InlineData(" Year:2024 ", ComparisonKind.Year)]
public void Spelled_out_comparison_aliases_are_accepted_but_never_written(string token, ComparisonKind expected)
{
Assert.True(AnalysisTokens.TryParseComparison(token, out var request));
Assert.Equal(expected, request.Kind);
Assert.DoesNotContain("previous", AnalysisTokens.Format(request), StringComparison.Ordinal);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("month-to-date")]
[InlineData("13m")]
[InlineData("today")]
[InlineData("mtd;drop table")]
[InlineData("mtd2")]
public void Unknown_period_tokens_are_rejected_without_throwing(string? token)
{
Assert.False(AnalysisTokens.TryParsePeriod(token, out _));
Assert.False(AnalysisTokens.TryParseBucket(token, out _));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("year")]
[InlineData("year:")]
[InlineData("year:25")]
[InlineData("year:20250")]
[InlineData("year:-202")]
[InlineData("year:+202")]
[InlineData("year: 2025")]
[InlineData("year:2025.0")]
[InlineData("year:2025")] // full-width digits
[InlineData("year:1899")]
[InlineData("year:2300")]
[InlineData("year:abcd")]
[InlineData("last-year")]
public void Malformed_or_out_of_range_comparison_tokens_are_rejected_without_throwing(string? token)
{
Assert.False(AnalysisTokens.TryParseComparison(token, out var request));
Assert.Null(request);
}
[Fact]
public void A_long_garbage_token_is_rejected_without_throwing()
{
var garbage = new string('x', 100_000);
Assert.False(AnalysisTokens.TryParsePeriod(garbage, out _));
Assert.False(AnalysisTokens.TryParseComparison("year:" + garbage, out _));
Assert.False(AnalysisTokens.TryParseDate(garbage, out _));
}
[Theory]
[InlineData("2026-09-19", 2026, 9, 19)]
[InlineData("2028-02-29", 2028, 2, 29)]
[InlineData("1900-01-01", 1900, 1, 1)]
[InlineData("2299-12-31", 2299, 12, 31)]
public void Iso_dates_parse_exactly(string token, int year, int month, int day)
{
Assert.True(AnalysisTokens.TryParseDate(token, out var date));
Assert.Equal(Day(year, month, day), date);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("2026-9-19")]
[InlineData("19.09.2026")]
[InlineData("09/19/2026")]
[InlineData("2026-02-29")]
[InlineData("2026-13-01")]
[InlineData("2026-09-19T00:00")]
[InlineData("1899-12-31")]
[InlineData("2300-01-01")]
[InlineData("9999-12-31")]
public void Dates_in_any_other_layout_or_outside_the_supported_range_are_rejected(string? token)
{
Assert.False(AnalysisTokens.TryParseDate(token, out var date));
Assert.Equal(default(DateOnly), date);
}
[Fact]
public void Dates_are_written_invariantly_whatever_the_reader_culture()
{
var saved = CultureInfo.CurrentCulture;
try
{
foreach (var culture in new[] { "de-DE", "ar-SA", "th-TH" })
{
CultureInfo.CurrentCulture = new CultureInfo(culture);
Assert.Equal("2026-09-19", AnalysisTokens.FormatDate(Day(2026, 9, 19)));
Assert.Equal("year:2025", AnalysisTokens.Format(new ComparisonRequest(ComparisonKind.Year, 2025)));
Assert.True(AnalysisTokens.TryParseDate("2026-09-19", out var parsed));
Assert.Equal(Day(2026, 9, 19), parsed);
}
}
finally
{
CultureInfo.CurrentCulture = saved;
}
}
[Fact]
public void A_custom_range_needs_both_dates_in_order()
{
Assert.True(AnalysisTokens.TryParseCustomRange("2026-09-01", "2026-09-30", out var first, out var last));
Assert.Equal((Day(2026, 9, 1), Day(2026, 9, 30)), (first, last));
Assert.True(AnalysisTokens.TryParseCustomRange("2026-09-19", "2026-09-19", out _, out _));
Assert.False(AnalysisTokens.TryParseCustomRange("2026-09-30", "2026-09-01", out _, out _));
Assert.False(AnalysisTokens.TryParseCustomRange("2026-09-01", null, out _, out _));
Assert.False(AnalysisTokens.TryParseCustomRange(null, "2026-09-30", out _, out _));
Assert.False(AnalysisTokens.TryParseCustomRange("2026-09-01", "30.09.2026", out _, out _));
}
[Fact]
public void A_parsed_custom_range_resolves_without_error()
{
Assert.True(AnalysisTokens.TryParsePeriod("custom", out var preset));
Assert.True(AnalysisTokens.TryParseCustomRange("2026-09-01", "2026-12-31", out var first, out var last));
var period = PeriodResolver.Resolve(preset, first, last, At(Berlin, 2026, 9, 19, 14, 37), Berlin);
Assert.True(period.ExtendsPastNow);
Assert.Equal("2026-12-31", AnalysisTokens.FormatDate(period.LastDay));
}
[Fact]
public void Formatting_a_year_comparison_without_a_year_is_a_programming_error()
{
Assert.Throws<ArgumentException>(() => AnalysisTokens.Format(new ComparisonRequest(ComparisonKind.Year)));
}
[Fact]
public void Formatting_an_undefined_enum_value_is_a_programming_error()
{
Assert.Throws<ArgumentOutOfRangeException>(() => AnalysisTokens.Format((PeriodPreset)99));
Assert.Throws<ArgumentOutOfRangeException>(() => AnalysisTokens.Format((BucketSize)99));
}
}
@@ -0,0 +1,502 @@
using MeterVault.Core.Analysis;
using static MeterVault.Core.Tests.Analysis.AnalysisClock;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// Buckets are local calendar units clipped to the period (D-05). What these pin down: "last 12 months" is
/// exactly 12 buckets ending at now; weeks start on Monday; DST days are 23 or 25 hours; Auto picks one
/// sensible size that never undercuts the data's resolution; an explicit size over 400 points is refused
/// with a coarser suggestion rather than truncated.
/// </summary>
public sealed class BucketPlannerTests
{
private static readonly DateTimeOffset September19 = At(Berlin, 2026, 9, 19, 14, 37);
private static ResolvedPeriod Preset(PeriodPreset preset, DateTimeOffset? now = null, TimeZoneInfo? zone = null) =>
PeriodResolver.Resolve(preset, null, null, now ?? September19, zone ?? Berlin);
private static ResolvedPeriod Custom(DateOnly first, DateOnly last, DateTimeOffset? now = null, TimeZoneInfo? zone = null) =>
PeriodResolver.Resolve(PeriodPreset.Custom, first, last, now ?? September19, zone ?? Berlin);
private static void AssertTiles(ResolvedPeriod period, IReadOnlyList<AnalysisBucket> buckets)
{
Assert.NotEmpty(buckets);
Assert.Equal(period.From, buckets[0].From);
Assert.Equal(period.To, buckets[^1].To);
Assert.Equal(period.FirstDay, buckets[0].FirstDay);
for (var i = 1; i < buckets.Count; i++)
{
Assert.Equal(buckets[i - 1].To, buckets[i].From);
Assert.Equal(buckets[i - 1].EndDay, buckets[i].FirstDay);
}
}
[Fact]
public void The_last_12_months_give_exactly_12_month_buckets_the_last_one_ending_now()
{
var period = Preset(PeriodPreset.Last12Months);
var plan = BucketPlanner.Plan(period, BucketSize.Month);
Assert.False(plan.Refused);
Assert.Equal(12, plan.Buckets.Count);
Assert.Equal(12, plan.PointCount);
AssertTiles(period, plan.Buckets);
Assert.Equal(
["2025-10", "2025-11", "2025-12", "2026-01", "2026-02", "2026-03", "2026-04", "2026-05", "2026-06", "2026-07", "2026-08", "2026-09"],
plan.Buckets.Select(b => $"{b.FirstDay.Year:D4}-{b.FirstDay.Month:D2}"));
var october = plan.Buckets[0];
Assert.Equal((Day(2025, 10, 1), Day(2025, 11, 1)), (october.FirstDay, october.EndDay));
Assert.Equal((Utc(2025, 9, 30, 22), Utc(2025, 10, 31, 23)), (october.From, october.To));
var september = plan.Buckets[^1];
Assert.Equal((Day(2026, 9, 1), Day(2026, 9, 20)), (september.FirstDay, september.EndDay));
Assert.Equal(September19, september.To);
Assert.All(plan.Buckets, b => Assert.Equal(BucketSize.Month, b.Size));
}
[Fact]
public void Across_New_Year_the_last_12_months_still_give_12_buckets_ending_with_the_new_January()
{
var period = Preset(PeriodPreset.Last12Months, At(Berlin, 2027, 1, 1, 0, 30));
var plan = BucketPlanner.Plan(period, BucketSize.Month);
Assert.Equal(12, plan.Buckets.Count);
Assert.Equal(Day(2026, 2, 1), plan.Buckets[0].FirstDay);
Assert.Equal(Day(2027, 1, 1), plan.Buckets[^1].FirstDay);
Assert.Equal(TimeSpan.FromMinutes(30), plan.Buckets[^1].To - plan.Buckets[^1].From);
}
[Fact]
public void At_the_exact_midnight_that_starts_a_month_the_last_12_months_keep_12_buckets_the_new_one_empty()
{
var period = Preset(PeriodPreset.Last12Months, At(Berlin, 2026, 10, 1));
var plan = BucketPlanner.Plan(period, BucketSize.Month);
Assert.Equal(12, plan.Buckets.Count);
Assert.Equal(Day(2026, 10, 1), plan.Buckets[^1].FirstDay);
Assert.Equal(plan.Buckets[^1].From, plan.Buckets[^1].To);
AssertTiles(period, plan.Buckets);
}
[Theory]
[InlineData(BucketSize.Day)]
[InlineData(BucketSize.Week)]
[InlineData(BucketSize.Month)]
[InlineData(BucketSize.Auto)]
public void At_the_exact_midnight_that_starts_a_month_month_to_date_has_one_empty_bucket_like_the_last_12_months(BucketSize size)
{
var now = At(Berlin, 2026, 10, 1);
var monthToDate = Preset(PeriodPreset.MonthToDate, now);
var last12 = Preset(PeriodPreset.Last12Months, now);
var plan = BucketPlanner.Plan(monthToDate, size);
// Both presets agree that October exists and is empty: no "not yet occurred" for one and an empty
// bucket for the other.
var today = Assert.Single(plan.Buckets);
Assert.Equal((Day(2026, 10, 1), now, now), (today.FirstDay, today.From, today.To));
Assert.Equal(1, plan.PointCount);
Assert.Equal(last12.To, BucketPlanner.Plan(last12, BucketSize.Month).Buckets[^1].To);
AssertTiles(monthToDate, plan.Buckets);
}
[Fact]
public void Week_buckets_start_on_Monday_and_the_first_one_starts_with_the_period()
{
// 1 September 2026 is a Tuesday.
var period = Preset(PeriodPreset.MonthToDate);
var plan = BucketPlanner.Plan(period, BucketSize.Week);
Assert.Equal(
[(Day(2026, 9, 1), Day(2026, 9, 7)), (Day(2026, 9, 7), Day(2026, 9, 14)), (Day(2026, 9, 14), Day(2026, 9, 20))],
plan.Buckets.Select(b => (b.FirstDay, b.EndDay)));
Assert.Equal(DayOfWeek.Tuesday, plan.Buckets[0].FirstDay.DayOfWeek);
Assert.All(plan.Buckets.Skip(1), b => Assert.Equal(DayOfWeek.Monday, b.FirstDay.DayOfWeek));
Assert.Equal(September19, plan.Buckets[^1].To);
AssertTiles(period, plan.Buckets);
}
[Fact]
public void A_range_from_Monday_to_Sunday_is_whole_weeks()
{
var period = Custom(Day(2026, 9, 7), Day(2026, 9, 20), At(Berlin, 2026, 10, 1, 12, 0));
var plan = BucketPlanner.Plan(period, BucketSize.Week);
Assert.Equal(2, plan.Buckets.Count);
Assert.All(plan.Buckets, b => Assert.Equal(7, b.EndDay.DayNumber - b.FirstDay.DayNumber));
Assert.Equal(Utc(2026, 9, 20, 22), plan.Buckets[^1].To);
}
[Fact]
public void Day_buckets_follow_local_midnight_so_the_spring_DST_day_has_23_hours()
{
var march = Preset(PeriodPreset.LastMonth, At(Berlin, 2026, 4, 2, 9, 0));
var plan = BucketPlanner.Plan(march, BucketSize.Day);
Assert.Equal(31, plan.Buckets.Count);
var dstDay = plan.Buckets.Single(b => b.FirstDay == Day(2026, 3, 29));
Assert.Equal((Utc(2026, 3, 28, 23), Utc(2026, 3, 29, 22)), (dstDay.From, dstDay.To));
Assert.Equal(TimeSpan.FromHours(23), dstDay.To - dstDay.From);
Assert.All(plan.Buckets.Where(b => b != dstDay), b => Assert.Equal(TimeSpan.FromHours(24), b.To - b.From));
AssertTiles(march, plan.Buckets);
}
[Fact]
public void Day_buckets_follow_local_midnight_so_the_autumn_DST_day_has_25_hours()
{
var october = Preset(PeriodPreset.LastMonth, At(Berlin, 2026, 11, 2, 9, 0));
var plan = BucketPlanner.Plan(october, BucketSize.Day);
var dstDay = plan.Buckets.Single(b => b.FirstDay == Day(2026, 10, 25));
Assert.Equal((Utc(2026, 10, 24, 22), Utc(2026, 10, 25, 23)), (dstDay.From, dstDay.To));
Assert.Equal(TimeSpan.FromHours(25), dstDay.To - dstDay.From);
}
[Fact]
public void Behind_UTC_month_buckets_start_at_New_York_midnight()
{
var period = Custom(Day(2026, 10, 1), Day(2026, 11, 30), zone: NewYork, now: At(NewYork, 2026, 12, 15, 12, 0));
var plan = BucketPlanner.Plan(period, BucketSize.Month);
Assert.Equal(
[(Utc(2026, 10, 1, 4), Utc(2026, 11, 1, 4)), (Utc(2026, 11, 1, 4), Utc(2026, 12, 1, 5))],
plan.Buckets.Select(b => (b.From, b.To)));
}
[Fact]
public void A_custom_range_past_now_gets_buckets_only_up_to_now()
{
var period = Custom(Day(2026, 9, 1), Day(2026, 12, 31));
var plan = BucketPlanner.Plan(period, BucketSize.Month);
var only = Assert.Single(plan.Buckets);
Assert.Equal((Day(2026, 9, 1), Day(2026, 9, 20)), (only.FirstDay, only.EndDay));
Assert.Equal(September19, only.To);
}
[Fact]
public void A_period_that_has_not_started_or_has_no_history_has_no_buckets()
{
var future = Custom(Day(2027, 1, 1), Day(2027, 3, 31));
var none = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin);
foreach (var period in new[] { future, none })
{
foreach (var size in new[] { BucketSize.Auto, BucketSize.Day, BucketSize.Year })
{
var plan = BucketPlanner.Plan(period, size);
Assert.Empty(plan.Buckets);
Assert.False(plan.Refused);
Assert.Equal(0, plan.PointCount);
}
}
}
[Theory]
[InlineData(PeriodPreset.MonthToDate, BucketSize.Day, 19)]
[InlineData(PeriodPreset.LastMonth, BucketSize.Day, 31)]
[InlineData(PeriodPreset.YearToDate, BucketSize.Month, 9)]
[InlineData(PeriodPreset.Last12Months, BucketSize.Month, 12)]
[InlineData(PeriodPreset.Last24Months, BucketSize.Month, 24)]
[InlineData(PeriodPreset.PreviousYear, BucketSize.Month, 12)]
public void Auto_picks_days_for_short_ranges_and_months_for_a_year_or_more(PeriodPreset preset, BucketSize expected, int points)
{
var plan = BucketPlanner.Plan(Preset(preset), BucketSize.Auto);
Assert.Equal(BucketSize.Auto, plan.Requested);
Assert.Equal(expected, plan.Size);
Assert.Equal(points, plan.Buckets.Count);
Assert.False(plan.Refused);
}
[Theory]
[InlineData(62, BucketSize.Day)]
[InlineData(63, BucketSize.Week)]
[InlineData(182, BucketSize.Week)]
[InlineData(183, BucketSize.Month)]
public void Auto_switches_from_days_to_weeks_after_62_days_and_to_months_after_26_weeks(int days, BucketSize expected)
{
var first = Day(2025, 1, 1);
var period = Custom(first, first.AddDays(days - 1));
Assert.Equal(expected, BucketPlanner.Plan(period, BucketSize.Auto).Size);
}
[Theory]
[InlineData(1, 1)]
[InlineData(2, 15)]
[InlineData(3, 2)]
[InlineData(6, 15)]
[InlineData(7, 2)]
[InlineData(9, 19)]
[InlineData(12, 31)]
public void Auto_charts_year_to_date_by_month_on_every_day_of_the_year(int month, int day)
{
// Chosen from the named year, not the elapsed part (A-06): the same URL used to render by day until
// 2 March and by week until 1 July.
var period = Preset(PeriodPreset.YearToDate, At(Berlin, 2026, month, day, 12, 0));
var plan = BucketPlanner.Plan(period, BucketSize.Auto);
Assert.Equal(BucketSize.Month, plan.Size);
Assert.Equal(month, plan.Buckets.Count);
}
[Fact]
public void Auto_charts_month_to_date_by_day_even_on_the_1st()
{
var plan = BucketPlanner.Plan(Preset(PeriodPreset.MonthToDate, At(Berlin, 2026, 9, 1, 8, 0)), BucketSize.Auto);
Assert.Equal(BucketSize.Day, plan.Size);
Assert.Single(plan.Buckets);
}
[Fact]
public void Auto_sizes_a_custom_range_reaching_past_now_by_the_range_asked_for()
{
var first = Day(2026, 9, 1);
var last = Day(2026, 12, 31);
var inSeptember = BucketPlanner.Plan(Custom(first, last, At(Berlin, 2026, 9, 19, 12, 0)), BucketSize.Auto);
var inNovember = BucketPlanner.Plan(Custom(first, last, At(Berlin, 2026, 11, 20, 12, 0)), BucketSize.Auto);
Assert.Equal(BucketSize.Week, inSeptember.Size);
Assert.Equal(BucketSize.Week, inNovember.Size);
}
[Fact]
public void Auto_checks_the_point_limit_on_the_buckets_that_exist_up_to_now()
{
// The named year has 12 months, over a limit of 9; only the 9 that exist by 19 September are counted.
var plan = BucketPlanner.Plan(Preset(PeriodPreset.YearToDate), BucketSize.Auto, maxPoints: 9);
Assert.Equal(BucketSize.Month, plan.Size);
Assert.Equal(9, plan.PointCount);
Assert.False(plan.Refused);
}
[Fact]
public void Auto_weighs_months_against_the_point_limit_it_was_given()
{
// 501 months since 1985: over the default 400, within a limit of 1,000.
var since1985 = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin, Day(1985, 1, 1));
var generous = BucketPlanner.Plan(since1985, BucketSize.Auto, maxPoints: 1_000);
var standard = BucketPlanner.Plan(since1985, BucketSize.Auto);
Assert.Equal(BucketSize.Month, generous.Size);
Assert.Equal(501, generous.Buckets.Count);
Assert.Equal(BucketSize.Year, standard.Size);
}
[Fact]
public void Auto_charts_all_history_from_a_stray_ancient_reading_by_year_instead_of_refusing()
{
var period = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin, Day(206, 5, 1));
var plan = BucketPlanner.Plan(period, BucketSize.Auto);
Assert.False(plan.Refused);
Assert.Equal(BucketSize.Year, plan.Size);
Assert.Equal(2026 - 1900 + 1, plan.Buckets.Count);
}
[Fact]
public void Auto_uses_months_up_to_400_of_them_and_years_beyond()
{
// The reference data's oil history starts in 1997: 357 months to September 2026.
var since1997 = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin, Day(1997, 1, 1));
var since1900 = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin, Day(1900, 1, 1));
var monthly = BucketPlanner.Plan(since1997, BucketSize.Auto);
var yearly = BucketPlanner.Plan(since1900, BucketSize.Auto);
Assert.Equal(BucketSize.Month, monthly.Size);
Assert.Equal(357, monthly.Buckets.Count);
Assert.Equal(BucketSize.Year, yearly.Size);
Assert.Equal(127, yearly.Buckets.Count);
}
[Fact]
public void Auto_never_goes_finer_than_the_coarsest_resolution_a_series_needs()
{
var monthToDate = Preset(PeriodPreset.MonthToDate);
// A monthly import in a daily chart would be nothing but unresolved buckets.
var monthly = BucketPlanner.Plan(monthToDate, BucketSize.Auto, ResolutionClass.Month);
var weekly = BucketPlanner.Plan(monthToDate, BucketSize.Auto, ResolutionClass.Week);
var coarse = BucketPlanner.Plan(Preset(PeriodPreset.Last12Months), BucketSize.Auto, ResolutionClass.Coarse);
var month = Assert.Single(monthly.Buckets);
Assert.Equal((Utc(2026, 8, 31, 22), September19), (month.From, month.To));
Assert.Equal(BucketSize.Week, weekly.Size);
Assert.Equal(BucketSize.Year, coarse.Size);
Assert.Equal([Day(2025, 10, 1), Day(2026, 1, 1)], coarse.Buckets.Select(b => b.FirstDay));
}
[Fact]
public void Auto_keeps_the_length_based_default_when_the_data_is_finer()
{
// Hourly data does not turn a year into 365 bars; the user can still ask for days explicitly.
var plan = BucketPlanner.Plan(Preset(PeriodPreset.Last12Months), BucketSize.Auto, ResolutionClass.Hour);
Assert.Equal(BucketSize.Month, plan.Size);
}
[Fact]
public void Auto_coarsens_until_the_plan_fits_a_smaller_point_limit()
{
var plan = BucketPlanner.Plan(Preset(PeriodPreset.MonthToDate), BucketSize.Auto, maxPoints: 10);
Assert.Equal(BucketSize.Week, plan.Size);
Assert.Equal(3, plan.Buckets.Count);
Assert.False(plan.Refused);
}
[Fact]
public void An_explicit_day_bucket_over_400_points_is_refused_with_weeks_suggested()
{
var period = Preset(PeriodPreset.Last24Months);
var plan = BucketPlanner.Plan(period, BucketSize.Day);
Assert.True(plan.Refused);
Assert.Empty(plan.Buckets);
Assert.Equal(BucketSize.Day, plan.Size);
Assert.Equal(Day(2026, 9, 19).DayNumber - Day(2024, 10, 1).DayNumber + 1, plan.PointCount);
Assert.Equal(BucketSize.Week, plan.Suggested);
Assert.False(BucketPlanner.Plan(period, BucketSize.Week).Refused);
}
[Fact]
public void A_refusal_suggests_nothing_finer_than_the_data_resolves()
{
var plan = BucketPlanner.Plan(Preset(PeriodPreset.Last24Months), BucketSize.Day, ResolutionClass.Month);
Assert.True(plan.Refused);
Assert.Equal(BucketSize.Month, plan.Suggested);
}
[Fact]
public void A_refusal_over_centuries_suggests_years()
{
var period = Custom(Day(1950, 1, 1), Day(2025, 12, 31));
var plan = BucketPlanner.Plan(period, BucketSize.Month);
Assert.True(plan.Refused);
Assert.Equal(76 * 12, plan.PointCount);
Assert.Equal(BucketSize.Year, plan.Suggested);
}
[Fact]
public void An_explicit_size_within_the_limit_is_honoured_even_finer_than_the_data()
{
var period = Preset(PeriodPreset.Last12Months);
var plan = BucketPlanner.Plan(period, BucketSize.Day, ResolutionClass.Month);
Assert.False(plan.Refused);
Assert.Equal(Day(2026, 9, 19).DayNumber - Day(2025, 10, 1).DayNumber + 1, plan.Buckets.Count);
AssertTiles(period, plan.Buckets);
}
[Fact]
public void Every_size_counts_its_buckets_without_building_them()
{
var periods = new[]
{
Preset(PeriodPreset.MonthToDate),
Preset(PeriodPreset.Last24Months),
Preset(PeriodPreset.PreviousYear),
Custom(Day(2023, 12, 31), Day(2025, 1, 1)),
Preset(PeriodPreset.YearToDate, At(NewYork, 2026, 3, 8, 12, 0), NewYork),
};
foreach (var period in periods)
{
foreach (var size in new[] { BucketSize.Day, BucketSize.Week, BucketSize.Month, BucketSize.Year })
{
var plan = BucketPlanner.Plan(period, size, maxPoints: 10_000);
Assert.Equal(BucketPlanner.CountBuckets(period, size), plan.Buckets.Count);
AssertTiles(period, plan.Buckets);
}
}
}
[Fact]
public void The_current_month_bucket_is_cut_short_at_now_and_names_the_whole_month_to_drill_into()
{
// Drilling into "1 19 Sep" as a custom range would compare with 13 31 August; the whole month
// compares with 1 19 August, like month to date (D-51).
foreach (var preset in new[] { PeriodPreset.MonthToDate, PeriodPreset.YearToDate, PeriodPreset.Last12Months })
{
var plan = BucketPlanner.Plan(Preset(preset), BucketSize.Month);
var september = plan.Buckets[^1];
Assert.Equal((Day(2026, 9, 20), Day(2026, 10, 1)), (september.EndDay, september.NominalEndDay));
Assert.True(september.IsCutShort);
Assert.All(plan.Buckets.SkipLast(1), b => Assert.False(b.IsCutShort));
Assert.All(plan.Buckets.SkipLast(1), b => Assert.Null(b.NominalEndDay));
}
}
[Fact]
public void A_week_cut_at_now_names_its_Sunday_and_a_day_is_never_cut_short()
{
var weeks = BucketPlanner.Plan(Preset(PeriodPreset.MonthToDate), BucketSize.Week);
var days = BucketPlanner.Plan(Preset(PeriodPreset.MonthToDate), BucketSize.Day);
Assert.Equal(Day(2026, 9, 21), weeks.Buckets[^1].NominalEndDay);
Assert.All(days.Buckets, b => Assert.False(b.IsCutShort));
}
[Fact]
public void The_whole_unit_of_a_cut_bucket_never_reaches_past_the_range_that_was_asked_for()
{
var reachingPastNow = BucketPlanner.Plan(Custom(Day(2026, 9, 1), Day(2026, 9, 25)), BucketSize.Month);
var complete = BucketPlanner.Plan(Custom(Day(2026, 8, 10), Day(2026, 9, 15), At(Berlin, 2026, 10, 1, 12, 0)), BucketSize.Month);
Assert.Equal(Day(2026, 9, 26), Assert.Single(reachingPastNow.Buckets).NominalEndDay);
// A range that ends mid-month by request is not cut short: its last bucket is all it names.
Assert.All(complete.Buckets, b => Assert.False(b.IsCutShort));
Assert.Equal(Day(2026, 9, 16), complete.Buckets[^1].EndDay);
}
[Fact]
public void At_midnight_the_empty_current_month_still_names_the_whole_month()
{
var plan = BucketPlanner.Plan(Preset(PeriodPreset.Last12Months, At(Berlin, 2026, 10, 1)), BucketSize.Month);
Assert.Equal((Day(2026, 10, 2), Day(2026, 11, 1)), (plan.Buckets[^1].EndDay, plan.Buckets[^1].NominalEndDay));
}
[Fact]
public void The_point_limit_must_allow_at_least_one_point()
{
Assert.Throws<ArgumentOutOfRangeException>(() => BucketPlanner.Plan(Preset(PeriodPreset.MonthToDate), BucketSize.Day, maxPoints: 0));
}
[Theory]
[InlineData(ResolutionClass.Hour, BucketSize.Day)]
[InlineData(ResolutionClass.Day, BucketSize.Day)]
[InlineData(ResolutionClass.Week, BucketSize.Week)]
[InlineData(ResolutionClass.Month, BucketSize.Month)]
[InlineData(ResolutionClass.Coarse, BucketSize.Year)]
public void Each_resolution_class_maps_to_the_finest_bucket_it_can_fill(ResolutionClass resolution, BucketSize expected)
{
Assert.Equal(expected, BucketPlanner.MinimumSizeFor(resolution));
}
}
@@ -0,0 +1,319 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Domain;
using static MeterVault.Core.Tests.Analysis.TotalsSeed;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// D-42: a category's cost is the bill algorithm run on its members, and only categories that are slices of the
/// bill form the composition. The seed pins both halves — the Strom category bills Netz (the sheet's Kosten), a
/// car-only category bills the car as a view, and the tank, which is billed but in no category, is Uncategorized.
/// </summary>
public sealed class CategoryCoverTests
{
private const int Strom = 101;
private const int WasserCategory = 102;
private const int Heizung = 103;
private const int EAuto = 104;
[Fact]
public void The_seeded_Strom_category_bills_only_Netz()
{
var full = TotalsPolicy.Classify(Meters(), Links());
var cover = CategoryCover.Compute(full, Strom, [Haus, Netz, Auto, Solar1, Solar2]);
Assert.Equal([Netz], cover.BilledMeterIds);
Assert.Empty(cover.SeparatelyBilled);
Assert.Empty(cover.FeedInMeterIds);
Assert.Equal([Haus, Auto, Solar1, Solar2], cover.AnalysisOnlyMeterIds);
Assert.False(cover.LiesOutsideBill);
Assert.Equal(MeterTotalsClass.Breakdown, cover.Meters[Auto].Class);
}
[Fact]
public void A_category_holding_only_Auto_bills_Auto_as_an_overlapping_view()
{
var full = TotalsPolicy.Classify(Meters(), Links());
var cover = CategoryCover.Compute(full, EAuto, [Auto]);
Assert.Equal([Auto], cover.BilledMeterIds);
Assert.Equal(MeterTotalsClass.Use, cover.Meters[Auto].Class);
Assert.Equal([Auto], cover.OutsideBillMeterIds);
Assert.True(cover.LiesOutsideBill);
}
[Fact]
public void A_category_holding_the_house_but_not_the_grid_prices_household_use_as_a_view()
{
var full = TotalsPolicy.Classify(Meters(), Links());
var cover = CategoryCover.Compute(full, 105, [Haus, Auto]);
Assert.Equal([Haus], cover.BilledMeterIds);
Assert.True(cover.LiesOutsideBill);
}
[Fact]
public void An_expanded_heating_oil_type_member_bills_the_tank_and_not_the_burner()
{
var full = TotalsPolicy.Classify(Meters(), Links());
var cover = CategoryCover.Compute(full, Heizung, [Oeltank, Brenner]);
Assert.Equal([Oeltank], cover.BilledMeterIds);
Assert.Equal([Brenner], cover.AnalysisOnlyMeterIds);
Assert.False(cover.LiesOutsideBill);
}
[Fact]
public void Seeded_categories_split_into_slices_views_and_the_uncategorized_tank()
{
var full = TotalsPolicy.Classify(Meters(), Links());
CategoryCoverResult[] covers =
[
CategoryCover.Compute(full, Heizung, []),
CategoryCover.Compute(full, Strom, [Haus, Netz, Auto, Solar1, Solar2]),
CategoryCover.Compute(full, WasserCategory, [Wasser]),
CategoryCover.Compute(full, EAuto, [Auto]),
];
var report = CategoryCover.CheckOverlap(full, covers);
Assert.Equal([Heizung, Strom, WasserCategory], report.DisjointCategoryIds);
Assert.Equal([EAuto], report.OverlappingViewIds);
Assert.Empty(report.Overlaps);
Assert.Equal([Oeltank], report.UncategorizedMeterIds);
}
[Fact]
public void Two_categories_billing_the_same_meter_are_both_overlapping_views()
{
var full = TotalsPolicy.Classify(Meters(), Links());
CategoryCoverResult[] covers =
[
CategoryCover.Compute(full, Strom, [Haus, Netz, Auto, Solar1, Solar2]),
CategoryCover.Compute(full, 106, [Netz]),
CategoryCover.Compute(full, WasserCategory, [Wasser]),
];
var report = CategoryCover.CheckOverlap(full, covers);
Assert.Equal([new CategoryOverlap(Strom, 106, [Netz])], report.Overlaps, OverlapComparer.Instance);
Assert.Equal([Strom, 106], report.OverlappingViewIds);
Assert.Equal([WasserCategory], report.DisjointCategoryIds);
Assert.Equal([Netz, Oeltank], report.UncategorizedMeterIds);
}
[Fact]
public void A_category_keeps_containment_through_a_meter_it_left_out()
{
// Wasser → Keller → Garten; a category of Wasser and Garten must not bill the garden on top of the main
// meter just because the basement meter between them is not a member.
var meters = Meters();
meters.Add(Physical(20, "Keller", Water, MeterMode.CumulativeCounter, "m³"));
meters.Add(Physical(21, "Garten", Water, MeterMode.CumulativeCounter, "m³"));
var full = TotalsPolicy.Classify(meters, [.. Links(), new(Wasser, 20), new(20, 21)]);
var cover = CategoryCover.Compute(full, WasserCategory, [Wasser, 21]);
Assert.Equal([Wasser], cover.BilledMeterIds);
Assert.Equal([Wasser], cover.Meters[21].ParentIds);
Assert.Equal([21], cover.AnalysisOnlyMeterIds);
}
[Fact]
public void A_separately_billed_heat_pump_stays_billed_in_a_category_without_the_house()
{
var meters = Meters();
meters.Add(Physical(10, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh"));
var full = TotalsPolicy.Classify(meters, [.. Links(), new(Haus, 10)], id => id == 10);
var withGrid = CategoryCover.Compute(full, Strom, [Netz, 10]);
var heatPumpOnly = CategoryCover.Compute(full, 107, [10]);
Assert.Equal([Netz], withGrid.BilledMeterIds);
Assert.Equal([new SeparatelyBilledMeter(10, Netz, Haus)], withGrid.SeparatelyBilled);
Assert.False(withGrid.LiesOutsideBill);
Assert.Equal([10], heatPumpOnly.CoverMeterIds);
Assert.False(heatPumpOnly.LiesOutsideBill);
// Alone in its category the heat pump is a root of that restricted run, but the bill prices it at its own
// tariff — and so must the category, or its slice would be charged at the grid price.
Assert.Empty(heatPumpOnly.BilledMeterIds);
Assert.Equal([new SeparatelyBilledMeter(10, Netz, Haus)], heatPumpOnly.SeparatelyBilled);
Assert.Equal(BillLineKind.OwnPrice, Assert.Single(heatPumpOnly.Lines).Kind);
}
[Fact]
public void A_strom_slice_prices_the_grid_import_with_the_heat_pump_deducted_as_the_bill_does()
{
var meters = Meters();
meters.Add(Physical(10, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh"));
var full = TotalsPolicy.Classify(meters, [.. Links(), new(Haus, 10)], id => id == 10);
var strom = CategoryCover.Compute(full, Strom, [Haus, Netz, Auto, Solar1, Solar2]);
var line = Assert.Single(strom.Lines);
Assert.Equal(full.LineOf(Netz), line);
Assert.Equal([new BillDeduction(10, 1)], line.Deductions);
}
[Fact]
public void Strom_plus_uncategorized_reconciles_to_the_bill_when_a_heat_pump_is_billed_separately()
{
// The probed trap: pricing Strom's BilledMeterIds as returned books all of Netz, the heat pump is booked again
// as Uncategorized, and the composition exceeds the bill by 100 kWh × 0.30. Priced by lines it reconciles.
var meters = Meters();
meters.Add(Physical(10, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh"));
var full = TotalsPolicy.Classify(meters, [.. Links(), new(Haus, 10)], id => id == 10);
CategoryCoverResult[] covers =
[
CategoryCover.Compute(full, Strom, [Haus, Netz, Auto, Solar1, Solar2]),
CategoryCover.Compute(full, WasserCategory, [Wasser]),
CategoryCover.Compute(full, Heizung, [Oeltank, Brenner]),
];
var report = CategoryCover.CheckOverlap(full, covers);
Assert.Equal([Strom, WasserCategory, Heizung], report.DisjointCategoryIds);
Assert.Equal([10], report.UncategorizedMeterIds);
var bill = full.Types.Values.Sum(t => Cost(t.Billing.Lines, meters));
var composition = covers.Sum(c => Cost(c.Lines, meters))
+ Cost(report.UncategorizedMeterIds.Select(id => full.LineOf(id)!), meters);
Assert.Equal((300 - 100) * 0.30 + 100 * 0.22 + 10 * 5.0 + 200 * 1.10, bill, 9);
Assert.Equal(bill, composition, 9);
}
[Fact]
public void A_heat_pump_category_beside_strom_completes_the_composition_with_nothing_uncategorized()
{
var meters = Meters();
meters.Add(Physical(10, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh"));
var full = TotalsPolicy.Classify(meters, [.. Links(), new(Haus, 10)], id => id == 10);
CategoryCoverResult[] covers =
[
CategoryCover.Compute(full, Strom, [Haus, Netz, Auto, Solar1, Solar2]),
CategoryCover.Compute(full, 107, [10]),
CategoryCover.Compute(full, WasserCategory, [Wasser]),
CategoryCover.Compute(full, Heizung, [Oeltank, Brenner]),
];
var report = CategoryCover.CheckOverlap(full, covers);
Assert.Empty(report.UncategorizedMeterIds);
Assert.Empty(report.OverlappingViewIds);
Assert.Equal(full.Types.Values.Sum(t => Cost(t.Billing.Lines, meters)), covers.Sum(c => Cost(c.Lines, meters)), 9);
}
[Fact]
public void A_view_outside_the_bill_is_priced_as_its_own_restricted_bill()
{
// House and heat pump without the grid meter: household use is billed here, with the heat pump out of it.
var meters = Meters();
meters.Add(Physical(10, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh"));
var full = TotalsPolicy.Classify(meters, [.. Links(), new(Haus, 10)], id => id == 10);
var view = CategoryCover.Compute(full, 109, [Haus, 10]);
Assert.True(view.LiesOutsideBill);
Assert.Equal([Haus], view.BilledMeterIds);
Assert.Equal([new SeparatelyBilledMeter(10, Haus, null)], view.SeparatelyBilled);
Assert.Equal([new BillDeduction(10, 1)], view.Lines[0].Deductions);
}
[Fact]
public void A_strom_category_without_the_heat_pump_leaves_the_heat_pump_uncategorized()
{
var meters = Meters();
meters.Add(Physical(10, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh"));
var full = TotalsPolicy.Classify(meters, [.. Links(), new(Haus, 10)], id => id == 10);
CategoryCoverResult[] covers =
[
CategoryCover.Compute(full, Strom, [Haus, Netz, Auto, Solar1, Solar2]),
CategoryCover.Compute(full, WasserCategory, [Wasser]),
];
var report = CategoryCover.CheckOverlap(full, covers);
Assert.Equal([Strom, WasserCategory], report.DisjointCategoryIds);
Assert.Equal([Oeltank, 10], report.UncategorizedMeterIds);
}
[Fact]
public void An_export_member_brings_its_feed_in_credit_into_the_category()
{
List<TotalsMeter> meters =
[
Physical(20, "Import", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport),
Physical(21, "Export", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridExport),
Physical(22, "PV", Electricity, MeterMode.GenerationCounter, "kWh"),
];
var full = TotalsPolicy.Classify(meters, []);
var cover = CategoryCover.Compute(full, Strom, [20, 21, 22]);
Assert.Equal([20], cover.BilledMeterIds);
Assert.Equal([21], cover.FeedInMeterIds);
Assert.Equal([22], cover.AnalysisOnlyMeterIds);
Assert.Equal([20, 21], cover.CoverMeterIds);
Assert.False(cover.LiesOutsideBill);
}
[Fact]
public void A_category_counting_Summe_Solar_instead_of_its_strings_bills_nothing_extra()
{
var full = TotalsPolicy.Classify(MetersWithOverride((SummeSolar, TotalsOverride.Always)), Links());
var cover = CategoryCover.Compute(full, 108, [Solar1, Solar2, SummeSolar]);
Assert.Empty(cover.CoverMeterIds);
Assert.Equal(MeterTotalsClass.IncludedByOverride, cover.Meters[SummeSolar].Class);
Assert.Equal(MeterTotalsClass.ExcludedByOverride, cover.Meters[Solar1].Class);
}
[Fact]
public void Unknown_member_ids_are_ignored()
{
var full = TotalsPolicy.Classify(Meters(), Links());
var cover = CategoryCover.Compute(full, WasserCategory, [Wasser, 404, Wasser]);
Assert.Equal([Wasser], cover.MemberIds);
Assert.Equal([Wasser], cover.BilledMeterIds);
}
/// <summary>
/// One month priced the way costing prices lines: electricity 0.30/kWh, the heat pump's own 0.22/kWh, water
/// 5.00/m³, oil 1.10/L; quantities Haus 500, Netz 300, Auto 50, heat pump 100, Wasser 10, Öltank 200 (the others
/// are never on a line).
/// </summary>
private static double Cost(IEnumerable<BillLine> lines, IReadOnlyList<TotalsMeter> meters)
{
var quantity = new Dictionary<int, double> { [Haus] = 500, [Netz] = 300, [Auto] = 50, [10] = 100, [Wasser] = 10, [Oeltank] = 200 };
var typePrice = new Dictionary<int, double> { [Electricity] = 0.30, [Water] = 5.0, [Oil] = 1.10 };
var ownPrice = new Dictionary<int, double> { [10] = 0.22 };
return lines.Sum(line =>
{
var priced = quantity[line.MeterId] - line.Deductions.Sum(d => d.UnitFactor * quantity[d.MeterId]);
var price = line.Kind == BillLineKind.OwnPrice
? ownPrice[line.MeterId]
: typePrice[meters.Single(m => m.Id == line.MeterId).EnergyTypeId];
return priced * price;
});
}
private sealed class OverlapComparer : IEqualityComparer<CategoryOverlap>
{
public static readonly OverlapComparer Instance = new();
public bool Equals(CategoryOverlap? x, CategoryOverlap? y) =>
x is not null && y is not null
&& x.CategoryId == y.CategoryId && x.OtherCategoryId == y.OtherCategoryId
&& x.SharedMeterIds.SequenceEqual(y.SharedMeterIds);
public int GetHashCode(CategoryOverlap obj) => HashCode.Combine(obj.CategoryId, obj.OtherCategoryId);
}
}
+145
View File
@@ -0,0 +1,145 @@
using MeterVault.Core.Analysis;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// One rule for change figures everywhere (D-08): the absolute difference whenever both values are known,
/// a percentage only against a positive baseline, and "unknown" never read as zero.
/// </summary>
public sealed class ChangeTests
{
[Fact]
public void A_rise_from_a_positive_baseline_has_an_absolute_difference_a_percentage_and_an_upward_direction()
{
var change = Change.Between(120, 100);
Assert.Equal(20, change.Absolute!.Value, 9);
Assert.Equal(20, change.Percent!.Value, 9);
Assert.Equal(1, change.Direction);
Assert.True(change.IsAvailable);
Assert.True(change.PercentApplicable);
}
[Fact]
public void A_fall_is_negative_in_both_figures()
{
var change = Change.Between(75, 100);
Assert.Equal(-25, change.Absolute!.Value, 9);
Assert.Equal(-25, change.Percent!.Value, 9);
Assert.Equal(-1, change.Direction);
}
[Fact]
public void Against_a_zero_baseline_the_percentage_is_not_applicable_but_the_difference_is_shown()
{
// The old dashboard chip read "+0.0 %" here.
var change = Change.Between(120, 0);
Assert.Equal(120, change.Absolute!.Value, 9);
Assert.Null(change.Percent);
Assert.False(change.PercentApplicable);
Assert.Equal(1, change.Direction);
}
[Fact]
public void Against_a_negative_baseline_the_percentage_is_not_applicable()
{
// A credit shrinking from -10 to -5 is a rise of 5; dividing by -10 would call it -50 %.
var change = Change.Between(-5, -10);
Assert.Equal(5, change.Absolute!.Value, 9);
Assert.Null(change.Percent);
Assert.Equal(1, change.Direction);
}
[Fact]
public void A_signed_change_from_positive_to_negative_keeps_its_percentage()
{
var change = Change.Between(-50, 100);
Assert.Equal(-150, change.Absolute!.Value, 9);
Assert.Equal(-150, change.Percent!.Value, 9);
Assert.Equal(-1, change.Direction);
}
[Fact]
public void Equal_values_are_no_change_even_after_floating_point_noise()
{
var exact = Change.Between(100, 100);
var noisy = Change.Between(0.1 + 0.2, 0.3);
Assert.Equal(0, exact.Direction);
Assert.Equal(0, exact.Percent!.Value, 9);
Assert.Equal(0, noisy.Direction);
Assert.NotNull(noisy.Absolute);
}
[Fact]
public void A_baseline_within_the_tolerance_of_zero_counts_as_zero()
{
Assert.Null(Change.Between(5, 1e-12).Percent);
}
[Theory]
[InlineData(null, 100.0)]
[InlineData(100.0, null)]
[InlineData(null, null)]
[InlineData(double.NaN, 100.0)]
[InlineData(100.0, double.PositiveInfinity)]
public void A_missing_or_non_finite_value_makes_the_change_unavailable_not_minus_100_percent(double? current, double? previous)
{
var change = Change.Between(current, previous);
Assert.Equal(Change.Unavailable, change);
Assert.Null(change.Absolute);
Assert.Null(change.Percent);
Assert.Equal(0, change.Direction);
Assert.False(change.IsAvailable);
}
[Fact]
public void A_difference_that_overflows_is_unavailable()
{
Assert.Equal(Change.Unavailable, Change.Between(double.MaxValue, -double.MaxValue));
}
[Fact]
public void A_difference_below_the_callers_display_tolerance_has_no_direction_but_keeps_its_exact_value()
{
// 0.004 € shows as "+0.00 €"; an upward arrow beside it would claim a change nobody can see.
var cents = Change.Between(100.004, 100, tolerance: 0.005);
var noiseOnly = Change.Between(100.004, 100);
Assert.Equal(0, cents.Direction);
Assert.Equal(0.004, cents.Absolute!.Value, 9);
Assert.NotNull(cents.Percent);
Assert.Equal(1, noiseOnly.Direction);
}
[Fact]
public void A_baseline_that_displays_as_zero_under_the_callers_tolerance_has_no_percentage()
{
Assert.Null(Change.Between(5, 0.004, tolerance: 0.005).Percent);
Assert.NotNull(Change.Between(5, 0.004).Percent);
}
[Theory]
[InlineData(-0.001)]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
public void A_negative_or_non_finite_tolerance_is_rejected(double tolerance)
{
Assert.Throws<ArgumentOutOfRangeException>(() => Change.Between(1, 2, tolerance));
}
[Fact]
public void An_observed_zero_now_against_a_positive_baseline_is_a_real_minus_100_percent()
{
var change = Change.Between(0, 80);
Assert.Equal(-80, change.Absolute!.Value, 9);
Assert.Equal(-100, change.Percent!.Value, 9);
Assert.Equal(-1, change.Direction);
}
}
@@ -0,0 +1,832 @@
using MeterVault.Core.Analysis;
using static MeterVault.Core.Tests.Analysis.AnalysisClock;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// Comparisons shift by local calendar units and map the to-date cut-off as a local date plus wall-clock
/// time (D-06). What these pin down: a month to date compares with the same elapsed part of the previous
/// month, 31 March with all of February, 29 February with all of the previous February; a cut-off in a DST
/// gap takes the first instant after it and one in a fold takes the occurrence with now's offset. The same
/// public mapping shifts any instant (D-07) and pairs the current buckets with the comparison's, and it never
/// runs backwards.
/// </summary>
public sealed class ComparisonResolverTests
{
private static readonly DateTimeOffset September19 = At(Berlin, 2026, 9, 19, 14, 37);
private static readonly ComparisonRequest PreviousPeriod = new(ComparisonKind.PreviousPeriod);
private static readonly ComparisonRequest PreviousYear = new(ComparisonKind.PreviousYear);
private static ResolvedPeriod Preset(PeriodPreset preset, DateTimeOffset? now = null, TimeZoneInfo? zone = null) =>
PeriodResolver.Resolve(preset, null, null, now ?? September19, zone ?? Berlin);
private static ResolvedPeriod Custom(DateOnly first, DateOnly last, DateTimeOffset? now = null) =>
PeriodResolver.Resolve(PeriodPreset.Custom, first, last, now ?? September19, Berlin);
private static ComparisonPeriod Applicable(ResolvedPeriod current, ComparisonRequest request)
{
var resolution = ComparisonResolver.Resolve(current, request);
Assert.True(resolution.IsApplicable, $"not applicable: {resolution.Reason}");
Assert.Equal(ComparisonUnavailableReason.None, resolution.Reason);
return resolution.Period;
}
private static ComparisonUnavailableReason Reason(ResolvedPeriod current, ComparisonRequest request)
{
var resolution = ComparisonResolver.Resolve(current, request);
Assert.False(resolution.IsApplicable);
Assert.Null(resolution.Period);
return resolution.Reason;
}
[Fact]
public void Month_to_date_compares_with_the_same_elapsed_part_of_the_previous_month()
{
var comparison = Applicable(Preset(PeriodPreset.MonthToDate), PreviousPeriod);
Assert.Equal(Day(2026, 8, 1), comparison.FirstDay);
Assert.Equal(Day(2026, 8, 19), comparison.LastDay);
Assert.Equal(Day(2026, 8, 31), comparison.NominalLastDay);
Assert.Equal(Utc(2026, 7, 31, 22), comparison.From);
Assert.Equal(Utc(2026, 8, 19, 12, 37), comparison.To);
Assert.True(comparison.IsCutOff);
Assert.False(comparison.CappedAtNow);
Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Month, 1), comparison.Shift);
Assert.Equal(ComparisonKind.PreviousPeriod, comparison.Kind);
}
[Fact]
public void Month_to_date_against_the_previous_year_is_the_same_month_to_the_same_wall_clock_time()
{
var comparison = Applicable(Preset(PeriodPreset.MonthToDate), PreviousYear);
Assert.Equal((Day(2025, 9, 1), Day(2025, 9, 19)), (comparison.FirstDay, comparison.LastDay));
Assert.Equal((Utc(2025, 8, 31, 22), Utc(2025, 9, 19, 12, 37)), (comparison.From, comparison.To));
Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Year, 1), comparison.Shift);
}
[Fact]
public void Year_to_date_compares_with_the_previous_year_up_to_the_same_moment()
{
var current = Preset(PeriodPreset.YearToDate);
var previousPeriod = Applicable(current, PreviousPeriod);
var previousYear = Applicable(current, PreviousYear);
var named = Applicable(current, new ComparisonRequest(ComparisonKind.Year, 2023));
Assert.Equal((Utc(2024, 12, 31, 23), Utc(2025, 9, 19, 12, 37)), (previousPeriod.From, previousPeriod.To));
Assert.Equal(Day(2025, 12, 31), previousPeriod.NominalLastDay);
Assert.Equal(previousPeriod, previousYear with { Kind = ComparisonKind.PreviousPeriod });
Assert.Equal((Utc(2022, 12, 31, 23), Utc(2023, 9, 19, 12, 37)), (named.From, named.To));
Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Year, 3), named.Shift);
}
[Fact]
public void The_last_12_months_compare_with_the_12_months_before()
{
var comparison = Applicable(Preset(PeriodPreset.Last12Months), PreviousPeriod);
Assert.Equal(Day(2024, 10, 1), comparison.FirstDay);
Assert.Equal(Day(2025, 9, 30), comparison.NominalLastDay);
Assert.Equal((Utc(2024, 9, 30, 22), Utc(2025, 9, 19, 12, 37)), (comparison.From, comparison.To));
Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Month, 12), comparison.Shift);
}
[Fact]
public void The_last_24_months_compare_with_the_24_months_before_or_shift_one_year_for_the_previous_year()
{
var current = Preset(PeriodPreset.Last24Months);
var previousPeriod = Applicable(current, PreviousPeriod);
var previousYear = Applicable(current, PreviousYear);
Assert.Equal(Day(2022, 10, 1), previousPeriod.FirstDay);
Assert.Equal(Utc(2024, 9, 19, 12, 37), previousPeriod.To);
Assert.Equal(Day(2023, 10, 1), previousYear.FirstDay);
Assert.Equal(Utc(2025, 9, 19, 12, 37), previousYear.To);
}
[Fact]
public void A_complete_month_compares_with_the_complete_previous_month()
{
var august = Preset(PeriodPreset.LastMonth);
var july = Applicable(august, PreviousPeriod);
var augustLastYear = Applicable(august, PreviousYear);
Assert.Equal((Day(2026, 7, 1), Day(2026, 7, 31)), (july.FirstDay, july.LastDay));
Assert.Equal((Utc(2026, 6, 30, 22), Utc(2026, 7, 31, 22)), (july.From, july.To));
Assert.False(july.IsCutOff);
Assert.Equal((Utc(2025, 7, 31, 22), Utc(2025, 8, 31, 22)), (augustLastYear.From, augustLastYear.To));
}
[Fact]
public void A_whole_month_compares_with_the_whole_previous_month_however_long_each_is()
{
// February (28 days) against January (31 days), not against the 28 days before 1 February.
var february = Preset(PeriodPreset.LastMonth, At(Berlin, 2026, 3, 5, 12, 0));
var january = Applicable(february, PreviousPeriod);
Assert.Equal((Day(2026, 1, 1), Day(2026, 1, 31)), (january.FirstDay, january.LastDay));
Assert.Equal((Utc(2025, 12, 31, 23), Utc(2026, 1, 31, 23)), (january.From, january.To));
}
[Fact]
public void On_31_March_the_previous_period_is_all_of_February_because_February_has_no_31st()
{
var now = At(Berlin, 2026, 3, 31, 14, 37);
var february = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousPeriod);
Assert.Equal((Day(2026, 2, 1), Day(2026, 2, 28)), (february.FirstDay, february.LastDay));
Assert.Equal((Utc(2026, 1, 31, 23), Utc(2026, 2, 28, 23)), (february.From, february.To));
Assert.False(february.IsCutOff);
}
[Theory]
[InlineData(29)]
[InlineData(30)]
public void Late_March_days_February_lacks_also_compare_with_all_of_February(int day)
{
var february = Applicable(Preset(PeriodPreset.MonthToDate, At(Berlin, 2026, 3, day, 14, 37)), PreviousPeriod);
Assert.Equal(Utc(2026, 2, 28, 23), february.To);
}
[Fact]
public void On_28_March_the_previous_period_stops_on_28_February_at_the_same_wall_clock_time()
{
// March 28 is still winter time and so is February: 14:37 CET both times.
var february = Applicable(Preset(PeriodPreset.MonthToDate, At(Berlin, 2026, 3, 28, 14, 37)), PreviousPeriod);
Assert.Equal(Utc(2026, 2, 28, 13, 37), february.To);
Assert.Equal(Day(2026, 2, 28), february.LastDay);
Assert.True(february.IsCutOff);
}
[Fact]
public void On_29_February_2028_the_previous_year_is_all_of_February_2027()
{
var now = At(Berlin, 2028, 2, 29, 10, 0);
var month = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousYear);
var year = Applicable(Preset(PeriodPreset.YearToDate, now), PreviousYear);
Assert.Equal((Day(2027, 2, 1), Day(2027, 2, 28)), (month.FirstDay, month.LastDay));
Assert.Equal((Utc(2027, 1, 31, 23), Utc(2027, 2, 28, 23)), (month.From, month.To));
Assert.False(month.IsCutOff);
Assert.Equal((Day(2027, 1, 1), Day(2027, 2, 28)), (year.FirstDay, year.LastDay));
Assert.Equal((Utc(2026, 12, 31, 23), Utc(2027, 2, 28, 23)), (year.From, year.To));
Assert.True(year.IsCutOff);
}
[Fact]
public void A_complete_leap_February_compares_with_the_whole_shorter_February_a_year_earlier()
{
var february2028 = Preset(PeriodPreset.LastMonth, At(Berlin, 2028, 3, 5, 12, 0));
var february2027 = Applicable(february2028, PreviousYear);
Assert.Equal((Day(2027, 2, 1), Day(2027, 2, 28)), (february2027.FirstDay, february2027.NominalLastDay));
Assert.Equal(Utc(2027, 2, 28, 23), february2027.To);
}
[Fact]
public void A_cut_off_that_falls_in_the_spring_DST_gap_takes_the_first_instant_after_the_gap()
{
// 29 March 2027 02:30 exists (2027 switches on 28 March); 29 March 2026 02:30 does not.
var now = At(Berlin, 2027, 3, 29, 2, 30);
var comparison = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousYear);
Assert.Equal(Utc(2026, 3, 29, 1), comparison.To);
Assert.Equal("2026-03-29 03:00 +02:00", Wall(comparison.To, Berlin));
}
[Fact]
public void A_day_shifted_cut_off_into_the_spring_DST_gap_also_lands_on_the_end_of_the_gap()
{
var now = At(Berlin, 2026, 3, 30, 2, 30);
var today = Custom(Day(2026, 3, 30), Day(2026, 3, 30), now);
var yesterday = Applicable(today, PreviousPeriod);
Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Day, 1), yesterday.Shift);
Assert.Equal((Utc(2026, 3, 28, 23), Utc(2026, 3, 29, 1)), (yesterday.From, yesterday.To));
}
[Fact]
public void A_cut_off_in_the_repeated_autumn_hour_takes_the_occurrence_with_nows_winter_offset()
{
// Now is 02:30 winter time on 26 October; 25 October 02:30 happened twice.
var now = At(2026, 10, 26, 2, 30, offsetHours: 1);
var today = Custom(Day(2026, 10, 26), Day(2026, 10, 26), now);
var yesterday = Applicable(today, PreviousPeriod);
Assert.Equal(Utc(2026, 10, 25, 1, 30), yesterday.To);
Assert.Equal("2026-10-25 02:30 +01:00", Wall(yesterday.To, Berlin));
}
[Fact]
public void A_cut_off_in_the_repeated_autumn_hour_takes_the_occurrence_with_nows_summer_offset()
{
// 2027 leaves summer time on 31 October, so 25 October 2027 02:30 is summer time (+02:00).
var now = At(Berlin, 2027, 10, 25, 2, 30);
var comparison = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousYear);
Assert.Equal(Utc(2026, 10, 25, 0, 30), comparison.To);
Assert.Equal("2026-10-25 02:30 +02:00", Wall(comparison.To, Berlin));
}
[Fact]
public void An_ambiguous_wall_time_whose_offsets_do_not_include_nows_takes_the_first_occurrence()
{
var wall = new DateTime(2026, 10, 25, 2, 30, 0, DateTimeKind.Unspecified);
var instant = LocalCalendar.InstantOf(wall, Berlin, TimeSpan.FromHours(5));
Assert.Equal(Utc(2026, 10, 25, 0, 30), instant);
}
[Fact]
public void A_now_in_the_first_pass_of_the_repeated_hour_maps_into_an_ordinary_month_at_the_same_wall_clock_time()
{
var now = At(2026, 10, 25, 2, 30, offsetHours: 2);
var september = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousPeriod);
Assert.Equal("2026-09-25 02:30 +02:00", Wall(september.To, Berlin));
}
[Fact]
public void A_now_in_the_second_pass_of_the_repeated_hour_maps_to_the_end_of_that_hour_in_an_ordinary_month()
{
// By 02:30 winter time the whole first pass (02:00 03:00 summer time) has elapsed. Mapping it to
// 02:30 again would compare 3.5 elapsed hours of the day with 2.5, and put the cut before the image
// of 02:45 summer time — a mapping that runs backwards, which matched coverage cannot invert.
var now = At(2026, 10, 25, 2, 30, offsetHours: 1);
var september = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousPeriod);
Assert.Equal("2026-09-25 03:00 +02:00", Wall(september.To, Berlin));
}
[Fact]
public void A_month_shifted_cut_off_into_the_repeated_hour_takes_the_occurrence_with_nows_winter_offset()
{
// 25 November is winter time (+01:00); 25 October 02:30 happened twice. The first occurrence would be
// 00:30 UTC, so only now's offset explains 01:30 UTC.
var now = At(Berlin, 2026, 11, 25, 2, 30);
var october = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousPeriod);
Assert.Equal(Utc(2026, 10, 25, 1, 30), october.To);
Assert.Equal("2026-10-25 02:30 +01:00", Wall(october.To, Berlin));
}
[Theory]
[InlineData(2, 0)]
[InlineData(1, 1)]
public void A_cut_off_in_a_repeated_hour_against_a_year_that_repeats_the_same_hour_keeps_its_pass(int offsetHours, int utcHour)
{
// 31 October is the autumn change both in 2027 and in 2021: first pass maps to first, second to second.
var now = At(2027, 10, 31, 2, 30, offsetHours);
var year2021 = Applicable(Preset(PeriodPreset.YearToDate, now), new ComparisonRequest(ComparisonKind.Year, 2021));
Assert.Equal(Utc(2021, 10, 31, utcHour, 30), year2021.To);
}
[Fact]
public void In_New_York_a_cut_off_in_the_spring_gap_takes_the_first_instant_after_it()
{
// 8 March 2026 skips 02:00 03:00; 8 April 02:30 exists.
var now = At(NewYork, 2026, 4, 8, 2, 30);
var march = Applicable(Preset(PeriodPreset.MonthToDate, now, NewYork), PreviousPeriod);
Assert.Equal(Utc(2026, 3, 8, 7), march.To);
Assert.Equal("2026-03-08 03:00 -04:00", Wall(march.To, NewYork));
}
[Fact]
public void In_New_York_a_cut_off_in_the_repeated_hour_takes_nows_standard_offset()
{
// 1 November 2026 repeats 01:00 02:00; 1 December 01:30 is EST (-05:00). The first occurrence
// would be 05:30 UTC.
var now = At(NewYork, 2026, 12, 1, 1, 30);
var november = Applicable(Preset(PeriodPreset.MonthToDate, now, NewYork), PreviousPeriod);
Assert.Equal(Utc(2026, 11, 1, 6, 30), november.To);
Assert.Equal(Utc(2026, 11, 1, 4), november.From);
}
[Fact]
public void In_New_York_a_cut_off_in_the_repeated_hour_takes_nows_daylight_offset()
{
// 2027 falls back on 7 November, so 1 November 2027 01:30 is EDT (-04:00).
var now = At(NewYork, 2027, 11, 1, 1, 30);
var november = Applicable(Preset(PeriodPreset.MonthToDate, now, NewYork), PreviousYear);
Assert.Equal(Utc(2026, 11, 1, 5, 30), november.To);
}
[Fact]
public void Half_an_hour_into_New_Year_every_to_date_preset_compares_with_the_same_half_hour_before()
{
var now = At(Berlin, 2027, 1, 1, 0, 30);
var december = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousPeriod);
var january2026 = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousYear);
var year2026 = Applicable(Preset(PeriodPreset.YearToDate, now), PreviousPeriod);
var year2020 = Applicable(Preset(PeriodPreset.YearToDate, now), new ComparisonRequest(ComparisonKind.Year, 2020));
var last12 = Applicable(Preset(PeriodPreset.Last12Months, now), PreviousPeriod);
Assert.Equal((Utc(2026, 11, 30, 23), Utc(2026, 11, 30, 23, 30)), (december.From, december.To));
Assert.Equal((Day(2026, 12, 1), Day(2026, 12, 1), Day(2026, 12, 31)), (december.FirstDay, december.LastDay, december.NominalLastDay));
Assert.Equal((Utc(2025, 12, 31, 23), Utc(2025, 12, 31, 23, 30)), (january2026.From, january2026.To));
Assert.Equal((Utc(2025, 12, 31, 23), Utc(2025, 12, 31, 23, 30)), (year2026.From, year2026.To));
Assert.Equal(Day(2026, 12, 31), year2026.NominalLastDay);
Assert.Equal((Utc(2019, 12, 31, 23), Utc(2019, 12, 31, 23, 30)), (year2020.From, year2020.To));
Assert.Equal((Day(2025, 2, 1), Day(2026, 1, 31)), (last12.FirstDay, last12.NominalLastDay));
Assert.Equal((Utc(2025, 1, 31, 23), Utc(2025, 12, 31, 23, 30)), (last12.From, last12.To));
Assert.All(new[] { december, january2026, year2026, year2020, last12 }, c => Assert.True(c.IsCutOff));
}
[Fact]
public void Half_an_hour_into_New_Year_last_month_is_December_and_compares_with_all_of_November()
{
var november = Applicable(Preset(PeriodPreset.LastMonth, At(Berlin, 2027, 1, 1, 0, 30)), PreviousPeriod);
Assert.Equal((Utc(2026, 10, 31, 23), Utc(2026, 11, 30, 23)), (november.From, november.To));
Assert.False(november.IsCutOff);
}
[Fact]
public void At_the_midnight_that_starts_a_month_month_to_date_compares_with_an_equally_empty_start_of_the_previous_month()
{
var now = At(Berlin, 2026, 10, 1);
var september = Applicable(Preset(PeriodPreset.MonthToDate, now), PreviousPeriod);
var last12 = Applicable(Preset(PeriodPreset.Last12Months, now), PreviousYear);
// Nothing has elapsed on either side; the comparison applies, as it does for the last 12 months.
Assert.Equal((Utc(2026, 8, 31, 22), Utc(2026, 8, 31, 22)), (september.From, september.To));
Assert.Equal((Day(2026, 9, 1), Day(2026, 9, 1), Day(2026, 9, 30)), (september.FirstDay, september.LastDay, september.NominalLastDay));
Assert.True(september.IsCutOff);
Assert.Equal(Utc(2025, 9, 30, 22), last12.To);
}
[Fact]
public void A_day_range_starting_on_29_February_compares_from_1_March_because_that_day_has_no_counterpart()
{
var now = At(Berlin, 2028, 6, 1, 12, 0);
var comparison = Applicable(Custom(Day(2028, 2, 29), Day(2028, 3, 5), now), PreviousYear);
Assert.Equal((Day(2027, 3, 1), Day(2027, 3, 5)), (comparison.FirstDay, comparison.LastDay));
Assert.Equal((Utc(2027, 2, 28, 23), Utc(2027, 3, 5, 23)), (comparison.From, comparison.To));
// 29 February alone has nothing to compare with.
Assert.Equal(ComparisonUnavailableReason.Empty, Reason(Custom(Day(2028, 2, 29), Day(2028, 2, 29), now), PreviousYear));
}
[Theory]
[InlineData("2026-03-31", "2026-03-01")]
[InlineData("2026-03-30", "2026-03-01")]
[InlineData("2026-03-29", "2026-03-01")]
[InlineData("2026-03-28", "2026-02-28")]
[InlineData("2026-03-01", "2026-02-01")]
[InlineData("2026-04-01", "2026-03-01")]
public void Shifting_a_day_back_a_month_collapses_the_days_the_target_lacks_onto_its_end(string date, string expected)
{
Assert.Equal(Iso(expected), ComparisonResolver.ShiftDate(Iso(date), new ComparisonShift(ComparisonShiftUnit.Month, 1)));
}
[Fact]
public void Shifting_29_February_back_a_year_lands_on_1_March_and_a_day_shift_is_exact()
{
Assert.Equal(Day(2027, 3, 1), ComparisonResolver.ShiftDate(Day(2028, 2, 29), new ComparisonShift(ComparisonShiftUnit.Year, 1)));
Assert.Equal(Day(2028, 2, 28), ComparisonResolver.ShiftDate(Day(2029, 2, 28), new ComparisonShift(ComparisonShiftUnit.Year, 1)));
Assert.Equal(Day(2027, 3, 31), ComparisonResolver.ShiftDate(Day(2026, 3, 31), new ComparisonShift(ComparisonShiftUnit.Year, -1)));
Assert.Equal(Day(2026, 2, 21), ComparisonResolver.ShiftDate(Day(2026, 3, 3), new ComparisonShift(ComparisonShiftUnit.Day, 10)));
}
[Fact]
public void A_covered_range_that_starts_on_a_day_the_target_month_lacks_starts_where_that_month_ends()
{
var shift = new ComparisonShift(ComparisonShiftUnit.Month, 1);
var offset = TimeSpan.FromHours(2);
var start = ComparisonResolver.MapInstant(At(Berlin, 2026, 3, 30, 10, 0), shift, Berlin, offset);
var sameDayEnd = ComparisonResolver.MapInstant(At(Berlin, 2026, 3, 31, 14, 37), shift, Berlin, offset);
var aprilEnd = ComparisonResolver.MapInstant(At(Berlin, 2026, 4, 5, 12, 0), shift, Berlin, offset);
// Coverage of 30 31 March has no counterpart in February: its image is empty…
Assert.Equal(Utc(2026, 2, 28, 23), start);
Assert.Equal(start, sameDayEnd);
// …and coverage running on into April matches from 1 March, not from a clamped 28 February.
Assert.Equal(Utc(2026, 3, 5, 11), aprilEnd);
}
[Fact]
public void The_start_of_a_local_day_maps_to_the_start_of_the_target_day()
{
var month = new ComparisonShift(ComparisonShiftUnit.Month, 1);
var year = new ComparisonShift(ComparisonShiftUnit.Year, 1);
Assert.Equal(Utc(2026, 2, 28, 23), ComparisonResolver.MapInstant(At(Berlin, 2026, 4, 1), month, Berlin, TimeSpan.FromHours(2)));
Assert.Equal(Utc(2025, 3, 29, 23), ComparisonResolver.MapInstant(At(Berlin, 2026, 3, 30), year, Berlin, TimeSpan.FromHours(2)));
Assert.Equal(Utc(2026, 10, 1, 4), ComparisonResolver.MapInstant(At(NewYork, 2026, 11, 1), month, NewYork, TimeSpan.FromHours(-5)));
}
[Fact]
public void The_comparison_bounds_are_the_images_of_the_current_bounds_under_the_public_mapping()
{
var cases = new (ResolvedPeriod Current, ComparisonRequest Request)[]
{
(Preset(PeriodPreset.MonthToDate), PreviousPeriod),
(Preset(PeriodPreset.MonthToDate), PreviousYear),
(Preset(PeriodPreset.YearToDate), new ComparisonRequest(ComparisonKind.Year, 2023)),
(Preset(PeriodPreset.Last12Months), PreviousPeriod),
(Preset(PeriodPreset.LastMonth), PreviousPeriod),
(Custom(Day(2026, 9, 10), Day(2026, 9, 19)), PreviousPeriod),
(Preset(PeriodPreset.MonthToDate, At(Berlin, 2026, 3, 31, 14, 37)), PreviousPeriod),
(Preset(PeriodPreset.MonthToDate, At(2026, 10, 25, 2, 30, offsetHours: 1)), PreviousPeriod),
(Preset(PeriodPreset.YearToDate, At(Berlin, 2028, 2, 29, 10, 0)), PreviousYear),
(Preset(PeriodPreset.MonthToDate, At(NewYork, 2026, 12, 1, 1, 30), NewYork), PreviousPeriod),
};
foreach (var (current, request) in cases)
{
var comparison = Applicable(current, request);
Assert.Equal(comparison.From, comparison.MapInstant(current.From, current.Zone));
Assert.Equal(comparison.To, comparison.MapInstant(current.To, current.Zone));
}
}
public static TheoryData<string> SweepZones => ["Europe/Berlin", "America/New_York", "America/Santiago", "Australia/Lord_Howe"];
[Theory]
[MemberData(nameof(SweepZones))]
public void The_instant_mapping_never_runs_backwards_across_DST_changes_and_short_months(string zoneId)
{
// Matched coverage (D-07) inverts the mapping by bisection, which needs it non-decreasing. A year of
// quarter hours crosses both DST changes, both passes of the repeated hour, and every short month.
var zone = TimeZoneInfo.FindSystemTimeZoneById(zoneId);
ComparisonShift[] shifts =
[
new(ComparisonShiftUnit.Day, 1),
new(ComparisonShiftUnit.Day, 10),
new(ComparisonShiftUnit.Month, 1),
new(ComparisonShiftUnit.Month, 12),
new(ComparisonShiftUnit.Year, 1),
new(ComparisonShiftUnit.Year, -1),
];
TimeSpan[] offsets = [zone.GetUtcOffset(Utc(2026, 1, 15)), zone.GetUtcOffset(Utc(2026, 7, 15))];
foreach (var shift in shifts)
{
foreach (var offset in offsets)
{
var previousInstant = Utc(2026, 1, 1);
var previous = ComparisonResolver.MapInstant(previousInstant, shift, zone, offset);
for (var instant = previousInstant.AddMinutes(15); instant < Utc(2027, 1, 1); instant = instant.AddMinutes(15))
{
var mapped = ComparisonResolver.MapInstant(instant, shift, zone, offset);
if (mapped < previous)
{
Assert.Fail($"{shift} with offset {offset}: {Wall(instant, zone)} maps to {Wall(mapped, zone)}, before {Wall(previousInstant, zone)} → {Wall(previous, zone)}.");
}
previousInstant = instant;
previous = mapped;
}
}
}
}
[Fact]
public void A_cut_comparison_reads_as_a_period_to_date_as_of_its_mapped_cut_off()
{
var current = Preset(PeriodPreset.MonthToDate);
var comparison = Applicable(current, PreviousPeriod);
var period = comparison.ToResolvedPeriod(current);
Assert.Equal(PeriodPreset.Custom, period.Preset);
Assert.Equal((Day(2026, 8, 1), Day(2026, 8, 31)), (period.FirstDay, period.LastDay));
Assert.Equal((comparison.From, comparison.To, comparison.To), (period.From, period.To, period.Now));
Assert.True(period.IsToDate);
Assert.True(period.ExtendsPastNow);
Assert.Same(Berlin, period.Zone);
Assert.Equal(Day(2026, 8, 19), period.EffectiveLastDay());
Assert.Equal(Day(2026, 8, 31), period.NominalLastDay());
var days = BucketPlanner.Plan(period, BucketSize.Day);
var month = Assert.Single(BucketPlanner.Plan(period, BucketSize.Month).Buckets);
Assert.Equal(19, days.Buckets.Count);
Assert.Equal(comparison.To, days.Buckets[^1].To);
Assert.Equal((Day(2026, 8, 20), Day(2026, 9, 1)), (month.EndDay, month.NominalEndDay));
}
[Fact]
public void A_complete_comparison_reads_as_a_complete_period()
{
var current = Preset(PeriodPreset.LastMonth);
var july = Applicable(current, PreviousPeriod).ToResolvedPeriod(current);
Assert.False(july.IsToDate);
Assert.Equal((Day(2026, 7, 1), Day(2026, 7, 31)), (july.FirstDay, july.LastDay));
Assert.Equal((Utc(2026, 6, 30, 22), Utc(2026, 7, 31, 22)), (july.From, july.To));
Assert.Equal(current.Now, july.Now);
Assert.Equal(31, BucketPlanner.Plan(july, BucketSize.Day).Buckets.Count);
}
[Fact]
public void A_comparison_cut_at_a_midnight_by_a_missing_day_reads_as_complete_without_an_empty_extra_day()
{
var current = Preset(PeriodPreset.YearToDate, At(Berlin, 2028, 2, 29, 10, 0));
var comparison = Applicable(current, PreviousYear);
var period = comparison.ToResolvedPeriod(current);
Assert.False(period.IsToDate);
Assert.Equal((Day(2027, 2, 28), comparison.To), (period.LastDay, period.To));
Assert.Equal(
[Day(2027, 1, 1), Day(2027, 2, 1)],
BucketPlanner.Plan(period, BucketSize.Month).Buckets.Select(b => b.FirstDay));
}
[Fact]
public void At_midnight_the_comparison_keeps_the_same_empty_last_day_as_the_current_period()
{
var now = At(Berlin, 2026, 10, 1);
var last12 = Preset(PeriodPreset.Last12Months, now);
var monthToDate = Preset(PeriodPreset.MonthToDate, now);
var yearBefore = Applicable(last12, PreviousYear).ToResolvedPeriod(last12);
var monthBefore = Applicable(monthToDate, PreviousPeriod).ToResolvedPeriod(monthToDate);
Assert.True(yearBefore.IsToDate);
Assert.Equal(12, BucketPlanner.Plan(yearBefore, BucketSize.Month).Buckets.Count);
var empty = Assert.Single(BucketPlanner.Plan(monthBefore, BucketSize.Day).Buckets);
Assert.Equal((Day(2026, 9, 1), empty.From), (empty.FirstDay, empty.To));
}
[Fact]
public void A_named_later_year_still_running_reads_as_to_date_at_the_real_now()
{
var current = Preset(PeriodPreset.PreviousYear);
var year2026 = Applicable(current, new ComparisonRequest(ComparisonKind.Year, 2026)).ToResolvedPeriod(current);
Assert.True(year2026.IsToDate);
Assert.Equal((September19, September19), (year2026.To, year2026.Now));
Assert.Equal((Day(2026, 12, 31), Day(2026, 9, 19)), (year2026.LastDay, year2026.EffectiveLastDay()));
}
[Fact]
public void Month_to_date_day_buckets_pair_with_the_same_days_of_the_previous_month()
{
var current = Preset(PeriodPreset.MonthToDate);
var comparison = Applicable(current, PreviousPeriod);
var plan = BucketPlanner.Plan(current, BucketSize.Day);
var pairs = ComparisonResolver.PairBuckets(current, comparison, plan.Buckets);
Assert.Equal(plan.Buckets, pairs.Select(p => p.Current));
Assert.Equal(Enumerable.Range(1, 19).Select(d => Day(2026, 8, d)), pairs.Select(p => p.Comparison.FirstDay));
Assert.All(pairs, p => Assert.Equal(BucketSize.Day, p.Comparison.Size));
Assert.Equal(Utc(2026, 8, 19, 12, 37), pairs[^1].Comparison.To);
AssertTiles(comparison, pairs);
}
[Fact]
public void The_cut_short_current_month_pairs_with_a_cut_short_month_naming_its_whole_unit()
{
var current = Preset(PeriodPreset.Last12Months);
var comparison = Applicable(current, PreviousYear);
var pairs = ComparisonResolver.PairBuckets(current, comparison, BucketPlanner.Plan(current, BucketSize.Month).Buckets);
Assert.Equal(12, pairs.Count);
Assert.All(pairs, p => Assert.Equal(p.Current.FirstDay.AddYears(-1), p.Comparison.FirstDay));
var september = pairs[^1].Comparison;
Assert.Equal((Day(2025, 9, 1), Day(2025, 9, 20), Day(2025, 10, 1)), (september.FirstDay, september.EndDay, september.NominalEndDay));
Assert.Equal(Utc(2025, 9, 19, 12, 37), september.To);
AssertTiles(comparison, pairs);
}
[Fact]
public void Day_buckets_of_February_pair_its_last_day_with_the_rest_of_January_so_the_pairs_add_up()
{
var february = Preset(PeriodPreset.LastMonth, At(Berlin, 2026, 3, 5, 12, 0));
var january = Applicable(february, PreviousPeriod);
var pairs = ComparisonResolver.PairBuckets(february, january, BucketPlanner.Plan(february, BucketSize.Day).Buckets);
Assert.Equal(28, pairs.Count);
Assert.Equal((Day(2026, 1, 27), Day(2026, 1, 28)), (pairs[^2].Comparison.FirstDay, pairs[^2].Comparison.EndDay));
Assert.Equal((Day(2026, 1, 28), Day(2026, 2, 1)), (pairs[^1].Comparison.FirstDay, pairs[^1].Comparison.EndDay));
AssertTiles(january, pairs);
}
[Fact]
public void Days_of_March_that_February_lacks_pair_with_empty_buckets()
{
var march = Preset(PeriodPreset.MonthToDate, At(Berlin, 2026, 3, 31, 14, 37));
var february = Applicable(march, PreviousPeriod);
var pairs = ComparisonResolver.PairBuckets(march, february, BucketPlanner.Plan(march, BucketSize.Day).Buckets);
Assert.Equal(31, pairs.Count);
Assert.Equal((Day(2026, 2, 28), Day(2026, 3, 1)), (pairs[27].Comparison.FirstDay, pairs[27].Comparison.EndDay));
Assert.All(pairs.Skip(28), p => Assert.Equal((p.Comparison.From, p.Comparison.FirstDay), (p.Comparison.To, p.Comparison.EndDay)));
AssertTiles(february, pairs);
}
[Fact]
public void Against_a_named_later_year_still_running_the_buckets_after_now_pair_with_empty_ones()
{
var current = Preset(PeriodPreset.PreviousYear);
var comparison = Applicable(current, new ComparisonRequest(ComparisonKind.Year, 2026));
var pairs = ComparisonResolver.PairBuckets(current, comparison, BucketPlanner.Plan(current, BucketSize.Month).Buckets);
Assert.Equal(12, pairs.Count);
var september = pairs[8].Comparison;
Assert.Equal((Day(2026, 9, 1), Day(2026, 9, 20), Day(2026, 10, 1)), (september.FirstDay, september.EndDay, september.NominalEndDay));
Assert.Equal(September19, september.To);
Assert.All(pairs.Skip(9), p =>
{
Assert.Equal((September19, September19), (p.Comparison.From, p.Comparison.To));
Assert.Equal(p.Comparison.FirstDay, p.Comparison.EndDay);
Assert.True(p.Comparison.IsCutShort);
});
AssertTiles(comparison, pairs);
}
[Fact]
public void All_history_without_data_has_no_current_period_to_compare()
{
var none = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin);
Assert.Equal(ComparisonUnavailableReason.NoCurrentPeriod, Reason(none, PreviousYear));
}
[Fact]
public void A_comparison_outside_the_supported_years_is_reported_as_out_of_range_never_thrown()
{
var year1900 = Custom(Day(1900, 1, 1), Day(1900, 12, 31));
var yearToDate = Preset(PeriodPreset.YearToDate);
Assert.Equal(ComparisonUnavailableReason.OutOfRange, Reason(year1900, PreviousYear));
Assert.Equal(ComparisonUnavailableReason.OutOfRange, Reason(year1900, PreviousPeriod));
foreach (var year in new[] { 1, 1899, 2300, 9999, int.MinValue, int.MaxValue })
{
Assert.Equal(ComparisonUnavailableReason.OutOfRange, Reason(yearToDate, new ComparisonRequest(ComparisonKind.Year, year)));
}
}
private static void AssertTiles(ComparisonPeriod comparison, IReadOnlyList<BucketPair> pairs)
{
Assert.Equal(comparison.From, pairs[0].Comparison.From);
Assert.Equal(comparison.To, pairs[^1].Comparison.To);
for (var i = 1; i < pairs.Count; i++)
{
Assert.Equal(pairs[i - 1].Comparison.To, pairs[i].Comparison.From);
}
}
[Fact]
public void Behind_UTC_the_cut_off_keeps_the_wall_clock_time_across_the_DST_change()
{
// 19 November noon in New York is EST (-5); 19 October noon was EDT (-4).
var now = At(NewYork, 2026, 11, 19, 12, 0);
var october = Applicable(Preset(PeriodPreset.MonthToDate, now, NewYork), PreviousPeriod);
Assert.Equal((Utc(2026, 10, 1, 4), Utc(2026, 10, 19, 16)), (october.From, october.To));
}
[Fact]
public void A_custom_month_reaching_past_now_compares_like_month_to_date()
{
var custom = Applicable(Custom(Day(2026, 9, 1), Day(2026, 9, 30)), PreviousPeriod);
var monthToDate = Applicable(Preset(PeriodPreset.MonthToDate), PreviousPeriod);
Assert.Equal(monthToDate, custom);
}
[Fact]
public void A_custom_range_of_days_shifts_back_by_its_own_length()
{
var comparison = Applicable(Custom(Day(2026, 9, 10), Day(2026, 9, 19)), PreviousPeriod);
Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Day, 10), comparison.Shift);
Assert.Equal((Day(2026, 8, 31), Day(2026, 9, 9)), (comparison.FirstDay, comparison.LastDay));
Assert.Equal(Utc(2026, 9, 9, 12, 37), comparison.To);
}
[Fact]
public void A_custom_quarter_compares_with_the_quarter_before()
{
var comparison = Applicable(Custom(Day(2026, 1, 1), Day(2026, 3, 31)), PreviousPeriod);
Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Month, 3), comparison.Shift);
Assert.Equal((Day(2025, 10, 1), Day(2025, 12, 31)), (comparison.FirstDay, comparison.LastDay));
Assert.Equal(Utc(2025, 12, 31, 23), comparison.To);
}
[Fact]
public void A_complete_year_compares_with_the_year_before_or_any_named_year()
{
var year2025 = Preset(PeriodPreset.PreviousYear);
var year2024 = Applicable(year2025, PreviousPeriod);
var year2020 = Applicable(year2025, new ComparisonRequest(ComparisonKind.Year, 2020));
Assert.Equal((Utc(2023, 12, 31, 23), Utc(2024, 12, 31, 23)), (year2024.From, year2024.To));
Assert.Equal((Day(2020, 1, 1), Day(2020, 12, 31)), (year2020.FirstDay, year2020.LastDay));
}
[Fact]
public void A_named_later_year_still_in_progress_is_capped_at_now()
{
var comparison = Applicable(Preset(PeriodPreset.PreviousYear), new ComparisonRequest(ComparisonKind.Year, 2026));
Assert.Equal(Utc(2025, 12, 31, 23), comparison.From);
Assert.Equal(September19, comparison.To);
Assert.True(comparison.CappedAtNow);
Assert.Equal(new ComparisonShift(ComparisonShiftUnit.Year, -1), comparison.Shift);
}
[Fact]
public void Comparisons_that_cannot_apply_say_why()
{
var yearToDate = Preset(PeriodPreset.YearToDate);
Assert.Equal(ComparisonUnavailableReason.NotRequested, Reason(yearToDate, ComparisonRequest.None));
Assert.Equal(
ComparisonUnavailableReason.AllHistory,
Reason(PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, September19, Berlin, Day(1997, 1, 1)), PreviousYear));
Assert.Equal(
ComparisonUnavailableReason.NotYearAligned,
Reason(Preset(PeriodPreset.MonthToDate), new ComparisonRequest(ComparisonKind.Year, 2024)));
Assert.Equal(
ComparisonUnavailableReason.NotYearAligned,
Reason(Preset(PeriodPreset.Last12Months), new ComparisonRequest(ComparisonKind.Year, 2024)));
Assert.Equal(
ComparisonUnavailableReason.NotYearAligned,
Reason(Custom(Day(2023, 1, 1), Day(2024, 12, 31)), new ComparisonRequest(ComparisonKind.Year, 2020)));
Assert.Equal(ComparisonUnavailableReason.SameYear, Reason(yearToDate, new ComparisonRequest(ComparisonKind.Year, 2026)));
Assert.Equal(
ComparisonUnavailableReason.ComparisonNotYetOccurred,
Reason(yearToDate, new ComparisonRequest(ComparisonKind.Year, 2027)));
Assert.Equal(ComparisonUnavailableReason.MissingYear, Reason(yearToDate, new ComparisonRequest(ComparisonKind.Year)));
Assert.Equal(
ComparisonUnavailableReason.CurrentNotYetOccurred,
Reason(Custom(Day(2027, 1, 1), Day(2027, 1, 31)), PreviousPeriod));
}
[Fact]
public void In_December_the_last_12_months_are_a_calendar_year_and_accept_a_named_year()
{
var current = Preset(PeriodPreset.Last12Months, At(Berlin, 2026, 12, 10, 12, 0));
var comparison = Applicable(current, new ComparisonRequest(ComparisonKind.Year, 2024));
Assert.Equal(Day(2024, 1, 1), comparison.FirstDay);
Assert.Equal(Utc(2024, 12, 10, 11), comparison.To);
}
[Fact]
public void Shifting_off_the_representable_calendar_is_reported_rather_than_thrown()
{
var utc = TimeZoneInfo.Utc;
var yearOne = new ResolvedPeriod(
PeriodPreset.Custom,
new DateOnly(1, 1, 1),
new DateOnly(1, 12, 31),
new DateTimeOffset(1, 1, 1, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2, 1, 1, 0, 0, 0, TimeSpan.Zero),
September19,
IsToDate: false,
ExtendsPastNow: false,
utc);
Assert.Equal(ComparisonUnavailableReason.OutOfRange, Reason(yearOne, PreviousYear));
}
}
@@ -0,0 +1,167 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Domain;
using static MeterVault.Core.Tests.Analysis.Costing.CostingTestData;
namespace MeterVault.Core.Tests.Analysis.Costing;
/// <summary>
/// Cost figures fold exactly: the sum of the parts is the same figure — value, status and missing prices — however it
/// is grouped. And the calculator refuses input it cannot price honestly.
/// </summary>
public sealed class CostAmountTests
{
[Fact]
public void The_empty_figure_is_priced_with_no_value()
{
Assert.Equal(CostStatus.Priced, CostAmount.Empty.Status);
Assert.Null(CostAmount.Empty.Cost);
Assert.Equal(BucketStatus.Available, CostAmount.Empty.Availability);
Assert.Empty(CostAmount.Empty.MissingPrices);
var sum = CostAmount.Sum([CostAmount.Empty, CostAmount.Empty]);
Assert.Equal((CostStatus.Priced, (double?)null, false), (sum.Status, sum.Cost, sum.IncludesNotPriced));
}
[Fact]
public void Summing_is_associative_over_lines_and_buckets()
{
// A priced grid line, a water line with a gap, an unpriced tank, a standing charge and a manual cost: the bill
// total must be the same whether the buckets or the lines are added first.
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
TypePrice(2, Water, 5, "EUR/m3", D(2025, 3, 1)),
BasePrice(3, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 6, 30));
var result = Price(
buckets,
book,
[Line(Grid, Electricity, Each(buckets, 100)), Line(WaterMeter, Water, Each(buckets, 10), "m³"), Line(OilTank, Oil, Each(buckets, 50), "L")],
standing: [StandingChargeScope.ForEnergyType(Electricity, new ServicePeriod(D(2020, 1, 1)))],
manual: [Manual(1, D(2025, 4, 2), 99)]);
var byLine = CostAmount.Sum(result.Lines.Select(l => l.Total).Concat(result.StandingCharges.Select(r => r.Total)).Append(result.ManualCosts.Total));
var byBucket = CostAmount.Sum(result.Totals);
foreach (var figure in new[] { byLine, byBucket })
{
Assert.Equal(result.Total.Cost!.Value, figure.Cost!.Value, 9);
Assert.Equal(result.Total.Status, figure.Status);
Assert.Equal(result.Total.IncludesNotPriced, figure.IncludesNotPriced);
Assert.Equal(result.Total.MissingPrices, figure.MissingPrices);
}
Assert.Equal(CostStatus.Partial, result.Total.Status);
Assert.Equal((6 * 30) + (4 * 50) + (6 * 12) + 99, result.Total.Cost!.Value, 9);
}
[Fact]
public void Missing_prices_merge_into_one_entry_with_their_first_and_last_month()
{
var book = Book(TypePrice(1, Water, 5, "EUR/m3", D(2025, 6, 1)));
var buckets = Buckets(BucketSize.Day, D(2025, 3, 30), D(2025, 5, 2));
var result = Price(buckets, book, [Line(WaterMeter, Water, Each(buckets, 1), "m³")]);
var missing = Assert.Single(result.MissingPrices);
Assert.Equal((D(2025, 3, 1), D(2025, 5, 1)), (missing.FirstMonth, missing.LastMonth));
// Each day bucket names its own month.
Assert.Equal(D(2025, 4, 1), Assert.Single(result.Totals.At(buckets, D(2025, 4, 17)).MissingPrices).FirstMonth);
}
[Fact]
public void A_unit_mismatch_outranks_a_gap_when_nothing_is_priced()
{
var book = Book(Tariff(1, TariffScope.EnergyType, Water, TariffComponent.UnitPrice, 0.3, "EUR/kWh", D(2025, 2, 1)));
var buckets = Buckets(BucketSize.Year, D(2025, 1, 1), D(2025, 2, 28));
var result = Price(buckets, book, [Line(WaterMeter, Water, Each(buckets, 10), "m³")]);
Assert.Equal(CostStatus.UnitMismatch, result.Total.Status);
Assert.Equal([CostStatus.PriceGap, CostStatus.UnitMismatch], result.MissingPrices.Select(m => m.Reason));
Assert.Null(result.Total.Cost);
}
[Fact]
public void Availability_reports_the_most_telling_reason_when_nothing_is_known()
{
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Year, D(2025, 1, 1), D(2025, 3, 31));
var parts = CostCalculator.Parts(buckets);
var result = Price(
buckets,
book,
[Line(Grid, Electricity, [
CostQuantity.Unknown(parts[0]),
CostQuantity.Unknown(parts[1], BucketStatus.Unresolved),
CostQuantity.Unknown(parts[2], BucketStatus.Pending)])]);
Assert.Equal(BucketStatus.Pending, result.Total.Availability);
Assert.Null(result.Total.Cost);
}
// ---- refusing malformed input ---------------------------------------------------------------------
[Fact]
public void A_quantity_that_is_not_a_part_of_the_request_is_refused()
{
var buckets = Buckets(BucketSize.Week, D(2025, 1, 27), D(2025, 2, 2));
// The whole week in one quantity would be priced at one month's price: the caller must split it.
var wholeWeek = new CostQuantity(D(2025, 1, 27), D(2025, 2, 3), 70, BucketStatus.Available);
Assert.Throws<ArgumentException>(() => Price(buckets, Book(), [Line(Grid, Electricity, [wholeWeek])]));
}
[Fact]
public void A_quantity_given_twice_or_not_finite_is_refused()
{
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 1, 31));
var part = CostCalculator.Parts(buckets).Single();
Assert.Throws<ArgumentException>(() => Price(buckets, Book(), [Line(Grid, Electricity, [CostQuantity.Known(part, 1), CostQuantity.Known(part, 2)])]));
Assert.Throws<ArgumentException>(() => Price(buckets, Book(), [Line(Grid, Electricity, [CostQuantity.Known(part, double.NaN)])]));
}
[Fact]
public void Overlapping_buckets_are_refused()
{
var buckets = new[] { Bucket(D(2025, 1, 1), D(2025, 2, 1)), Bucket(D(2025, 1, 15), D(2025, 1, 16), BucketSize.Day) };
Assert.Throws<ArgumentException>(() => CostCalculator.Parts(buckets));
}
[Fact]
public void A_standing_charge_row_names_its_scope_and_a_meter_with_a_line_is_not_a_row()
{
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 1, 31));
// A-18: a meter's own row is for a meter without a line; with one, its charge accrues on the line.
Assert.Throws<ArgumentException>(() => Price(buckets, Book(), [], standing: [new StandingChargeScope(TariffScope.Meter, null, null)]));
Assert.Throws<ArgumentException>(() => Price(
buckets, Book(), [Line(Grid, Electricity, Each(buckets, 1))], standing: [StandingChargeScope.ForMeter(Grid, null)]));
Assert.Throws<ArgumentException>(() => Price(buckets, Book(), [], standing: [new StandingChargeScope(TariffScope.EnergyType, null, null)]));
Assert.Throws<ArgumentException>(() => Price(
buckets, Book(), [], standing: [StandingChargeScope.Global(null), StandingChargeScope.Global(null)]));
}
[Fact]
public void A_service_period_cannot_end_before_it_starts()
{
Assert.Throws<ArgumentException>(() => new ServicePeriod(D(2025, 2, 1), D(2025, 1, 31)));
}
[Fact]
public void An_empty_request_prices_nothing()
{
var result = Price([], Book(), []);
Assert.Empty(result.Totals);
Assert.Null(result.Total.Cost);
Assert.Equal("EUR", result.Currency);
}
}
@@ -0,0 +1,180 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Costing;
using MeterVault.Core.Domain;
using static MeterVault.Core.Tests.Analysis.Costing.CostingTestData;
using Seed = MeterVault.Core.Tests.Analysis.TotalsSeed;
namespace MeterVault.Core.Tests.Analysis.Costing;
/// <summary>
/// The calculator prices what <see cref="TotalsPolicy"/> bills (D-34/D-35): on the reference data that is Netz × price,
/// the spreadsheet's <c>Kosten</c>; a separately billed heat pump at its own price; and virtual meters by their named
/// cost rule (D-39).
/// </summary>
public sealed class CostCalculatorBillTests
{
/// <summary>The seeded electricity price history (ReferenceDataImporter).</summary>
private static readonly Tariff[] StromPrices =
[
TypePrice(1, Seed.Electricity, 0.16, "EUR/kWh", D(2022, 9, 1)),
TypePrice(2, Seed.Electricity, 0.44, "EUR/kWh", D(2023, 1, 1)),
TypePrice(3, Seed.Electricity, 0.37, "EUR/kWh", D(2023, 5, 1)),
TypePrice(4, Seed.Electricity, 0.27, "EUR/kWh", D(2023, 11, 1)),
TypePrice(5, Seed.Electricity, 0.36, "EUR/kWh", D(2025, 1, 1)),
TypePrice(6, Seed.Electricity, 0.27, "EUR/kWh", D(2026, 1, 1)),
];
[Fact]
public void The_seeded_Strom_bill_prices_the_grid_import_as_the_sheet_does()
{
// Kosten = Verbrauchskosten Ersparnis = Netz × €/kWh: Oct 2022 416 × 0.16, Jan 2023 1170 × 0.44, May 2026 827 × 0.27.
var classification = TotalsPolicy.Classify(Seed.Meters(), Seed.Links());
var bill = classification.ForType(Seed.Electricity).Billing;
var netz = new Dictionary<DateOnly, double> { [D(2022, 10, 1)] = 416, [D(2023, 1, 1)] = 1170, [D(2026, 5, 1)] = 827 };
var book = Book(StromPrices);
foreach (var (month, amount) in netz)
{
var buckets = Buckets(BucketSize.Month, month, month.AddMonths(1).AddDays(-1));
var lines = bill.Lines
.Select(l => new CostLine(l.MeterId, Seed.Electricity, l.Kind, "kWh", l.MeterId == Seed.Netz ? Each(buckets, amount) : Each(buckets, 9999)))
.ToList();
var result = CostCalculator.Calculate(new CostRequest(buckets, D(2026, 9, 19), book, lines));
Assert.Equal([Seed.Netz], result.Lines.Select(l => l.MeterId));
Assert.Equal(amount * SheetPrice(month), result.Total.Cost!.Value, 9);
}
static double SheetPrice(DateOnly month) =>
TariffResolver.ResolveValue(StromPrices, TariffComponent.UnitPrice, Seed.Netz, Seed.Electricity, TariffBook.PriceDate(month));
}
[Fact]
public void A_separately_billed_heat_pump_is_priced_at_its_own_price_out_of_the_grid_import()
{
// SeparatelyBilledSubmeterTests: main 300 kWh at 0.30 and heat pump 100 kWh at 0.22 bill 200 × 0.30 + 100 × 0.22.
const int heatPump = 10;
var meters = Seed.Meters();
meters.Add(Seed.Physical(heatPump, "Wärmepumpe", Seed.Electricity, MeterMode.CumulativeCounter, "kWh"));
var book = Book(TypePrice(1, Seed.Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)), MeterPrice(2, heatPump, 0.22, "EUR/kWh", D(2025, 1, 1)));
var classification = TotalsPolicy.Classify(meters, [.. Seed.Links(), new(Seed.Haus, heatPump)], book.HasMeterScopedUnitPrice);
var bill = classification.ForType(Seed.Electricity).Billing;
var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30));
var gross = new Dictionary<int, double> { [Seed.Netz] = 300, [heatPump] = 100 };
// The engine's part: net quantities, the deductions taken out of the line they belong to.
var lines = bill.Lines
.Select(l => new CostLine(
l.MeterId,
Seed.Electricity,
l.Kind,
"kWh",
Each(buckets, gross[l.MeterId] - l.Deductions.Sum(d => gross[d.MeterId] * d.UnitFactor))))
.ToList();
var result = CostCalculator.Calculate(new CostRequest(buckets, D(2026, 1, 1), book, lines));
Assert.Equal([(Seed.Netz, BillLineKind.UnitPrice), (heatPump, BillLineKind.OwnPrice)], result.Lines.Select(l => (l.MeterId, l.Kind)));
Assert.Equal(60, result.Lines[0].Total.Cost!.Value, 9);
Assert.Equal(22, result.Lines[1].Total.Cost!.Value, 9);
Assert.Equal(82, result.Total.Cost!.Value, 9);
}
[Fact]
public void Before_its_own_price_starts_a_subsection_is_billed_inside_its_parent()
{
// The heat pump's own tariff starts in March. In January and February it is not billed separately: the engine
// leaves its energy in the grid import (HasOwnUnitPrice), and its own line has nothing — no gap, no zero.
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)), MeterPrice(2, Heater, 0.22, "EUR/kWh", D(2025, 3, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31));
var grid = Monthly(buckets, m => book.HasOwnUnitPrice(Heater, m) ? 300 - 100 : 300);
var result = Price(buckets, book, [Line(Grid, Electricity, grid), Line(Heater, Electricity, Each(buckets, 100), kind: BillLineKind.OwnPrice)]);
var heater = result.Lines[1];
Assert.Equal([D(2025, 1, 1), D(2025, 2, 1)], heater.MonthsWithoutOwnPrice);
Assert.Null(heater.Buckets[0].Cost);
Assert.Equal(CostStatus.Priced, heater.Buckets[0].Status);
Assert.Equal(22, heater.Buckets[2].Cost!.Value, 9);
Assert.Empty(result.MissingPrices);
Assert.Equal([90d, 90d, 60d + 22d], result.Totals.Select(t => Math.Round(t.Cost!.Value, 9)));
}
[Fact]
public void An_own_price_line_never_takes_the_type_price()
{
// D-35's OwnPrice means the meter's own price: a line for a meter without one is not priced, not priced at the type's.
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 1, 31));
var result = Price(buckets, book, [Line(Heater, Electricity, Each(buckets, 100), kind: BillLineKind.OwnPrice)]);
Assert.Equal(CostStatus.NotPriced, result.Total.Status);
Assert.Equal((TariffScope.Meter, (int?)Heater), (result.MissingPrices[0].Scope, result.MissingPrices[0].ScopeId));
}
[Fact]
public void Source_costs_and_own_quantity_differ_when_the_sources_have_different_prices()
{
// D-39: a virtual sum of the house (type price 0.30) and a heat pump (own price 0.22), 100 kWh each. Summing the
// sources' costs gives 52; pricing the sum as its own quantity takes the type price: 60. The rule is named.
const int sum = 40;
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
MeterPrice(2, Heater, 0.22, "EUR/kWh", D(2025, 1, 1)),
BasePrice(3, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30));
// sourceCosts: each source at its own precedence, without the type's standing charge.
var sourceCosts = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100)), Line(Heater, Electricity, Each(buckets, 100))]);
// ownQuantity: the virtual meter's evaluated quantity as one line.
var ownQuantity = Price(buckets, book, [Line(sum, Electricity, Each(buckets, 200))]);
Assert.Equal(52, sourceCosts.Total.Cost!.Value, 9);
Assert.Empty(sourceCosts.StandingCharges);
Assert.Equal(60, ownQuantity.Total.Cost!.Value, 9);
}
[Fact]
public void Category_slices_add_up_to_the_bill()
{
// D-42: the composition of disjoint slices reconciles, because a figure is the exact sum of its lines.
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
TypePrice(2, Water, 5, "EUR/m3", D(2025, 2, 1)),
BasePrice(3, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31));
var always = new ServicePeriod(D(2020, 1, 1));
var bill = Price(
buckets,
book,
[Line(Grid, Electricity, Each(buckets, 100)), Line(WaterMeter, Water, Each(buckets, 10), "m³"), Line(OilTank, Oil, Each(buckets, 300), "L")],
standing: [StandingChargeScope.ForEnergyType(Electricity, always)],
manual: [Manual(1, D(2025, 2, 1), 100, categoryId: 7)]);
var slices = new[]
{
bill.Lines[0].Total,
bill.Lines[1].Total,
bill.Lines[2].Total,
bill.StandingCharges[0].Total,
bill.ManualCosts.Total,
};
var composed = CostAmount.Sum(slices);
Assert.Equal(bill.Total.Cost, composed.Cost);
Assert.Equal(bill.Total.Status, composed.Status);
Assert.Equal(bill.Total.MissingPrices, composed.MissingPrices);
Assert.Equal(CostStatus.Partial, bill.Total.Status);
Assert.True(bill.Total.IncludesNotPriced);
Assert.Equal((3 * 30) + (2 * 50) + 36 + 100, bill.Total.Cost!.Value, 9);
Assert.Equal(
[(TariffComponent.UnitPrice, CostStatus.PriceGap, (int?)WaterMeter), (TariffComponent.UnitPrice, CostStatus.NotPriced, OilTank)],
bill.MissingPrices.Select(m => (m.Component, m.Reason, m.MeterId)));
}
}
@@ -0,0 +1,424 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Domain;
using static MeterVault.Core.Tests.Analysis.Costing.CostingTestData;
namespace MeterVault.Core.Tests.Analysis.Costing;
/// <summary>
/// D-38 and brief §4.3: "not priced" (no tariff at all), "price gap" (a month without a price inside a priced
/// history), a free zero tariff, and an unknown quantity are four different answers — and none of them is a
/// fabricated zero.
/// </summary>
public sealed class CostCalculatorCoverageTests
{
[Fact]
public void A_line_without_any_tariff_is_not_priced_and_says_from_when()
{
var book = Book();
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31));
var result = Price(buckets, book, [Line(OilTank, Oil, Each(buckets, 400), "L")]);
Assert.All(result.Lines[0].Buckets, c =>
{
Assert.Equal(CostStatus.NotPriced, c.Status);
Assert.Null(c.Cost);
});
Assert.Equal(CostStatus.NotPriced, result.Total.Status);
Assert.Equal(
new MissingPrice(TariffComponent.UnitPrice, CostStatus.NotPriced, TariffScope.EnergyType, Oil, OilTank, D(2025, 1, 1), D(2025, 3, 1)),
Assert.Single(result.MissingPrices));
Assert.False(Assert.Single(result.MissingPrices).IsCredit);
}
[Fact]
public void A_not_priced_line_is_an_attention_item_not_a_reason_for_a_partial_bill()
{
// D-44: the seeded oil tank has no tariff; the bill is Strom + water, complete for everything priced.
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 2, 28));
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100)), Line(OilTank, Oil, Each(buckets, 400), "L")]);
Assert.Equal(CostStatus.Priced, result.Total.Status);
Assert.True(result.Total.IncludesNotPriced);
Assert.Equal(60, result.Total.Cost!.Value, 9);
Assert.Equal(BucketStatus.Available, result.Total.Availability);
Assert.Equal(CostStatus.NotPriced, Assert.Single(result.MissingPrices).Reason);
}
[Fact]
public void A_month_before_the_first_price_of_a_priced_scope_is_a_gap_and_the_year_is_partial()
{
var book = Book(TypePrice(1, Water, 5, "EUR/m3", D(2025, 3, 1)));
var months = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 4, 30));
var year = Buckets(BucketSize.Year, D(2025, 1, 1), D(2025, 4, 30));
var monthly = Price(months, book, [Line(WaterMeter, Water, Each(months, 10), "m³")]);
var yearly = Price(year, book, [Line(WaterMeter, Water, Each(year, 10), "m³")]);
Assert.Equal(
[CostStatus.PriceGap, CostStatus.PriceGap, CostStatus.Priced, CostStatus.Priced],
monthly.Lines[0].Buckets.Select(c => c.Status));
Assert.Null(monthly.Lines[0].Buckets[0].Cost);
Assert.Equal(50, monthly.Lines[0].Buckets[2].Cost!.Value, 9);
var total = yearly.Totals.Single();
Assert.Equal(CostStatus.Partial, total.Status);
Assert.Equal(100, total.Cost!.Value, 9);
Assert.Equal(
new MissingPrice(TariffComponent.UnitPrice, CostStatus.PriceGap, TariffScope.EnergyType, Water, WaterMeter, D(2025, 1, 1), D(2025, 2, 1)),
Assert.Single(total.MissingPrices));
}
[Fact]
public void A_price_that_ended_leaves_a_gap_after_it()
{
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2024, 1, 1), D(2025, 1, 31)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31));
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]);
Assert.Equal([CostStatus.Priced, CostStatus.PriceGap, CostStatus.PriceGap], result.Lines[0].Buckets.Select(c => c.Status));
var missing = Assert.Single(result.MissingPrices);
Assert.Equal((D(2025, 2, 1), D(2025, 3, 1)), (missing.FirstMonth, missing.LastMonth));
}
[Fact]
public void An_explicit_zero_tariff_is_a_valid_zero()
{
var book = Book(TypePrice(1, Water, 0, "EUR/m3", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 1, 31));
var result = Price(buckets, book, [Line(WaterMeter, Water, Each(buckets, 10), "m³")]);
Assert.Equal(CostStatus.Priced, result.Total.Status);
Assert.Equal(0, result.Total.Cost);
Assert.Empty(result.MissingPrices);
}
[Fact]
public void A_known_zero_quantity_needs_no_price()
{
// A meter not yet installed reads a known zero (D-24): the months before the first tariff are no gap.
var book = Book(TypePrice(1, Water, 5, "EUR/m3", D(2025, 3, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31));
var result = Price(buckets, book, [Line(WaterMeter, Water, Monthly(buckets, m => m.Month < 3 ? 0 : 10), "m³")]);
Assert.All(result.Lines[0].Buckets, c => Assert.Equal(CostStatus.Priced, c.Status));
Assert.Equal([0d, 0d, 50d], result.Lines[0].Buckets.Select(c => Math.Round(c.Cost!.Value, 9)));
Assert.Empty(result.MissingPrices);
}
[Fact]
public void A_month_without_data_needs_no_price()
{
// The seeded grid meter starts in September 2022, its first tariff too: January to August of that year have
// neither data nor a price, and asking for a tariff there would be noise — the cost is unknown for want of data.
var book = Book(TypePrice(1, Electricity, 0.16, "EUR/kWh", D(2022, 9, 1)));
var year = Buckets(BucketSize.Year, D(2022, 1, 1), D(2022, 12, 31));
var result = Price(year, book, [Line(Grid, Electricity, Monthly(year, m => m.Month < 10 ? null : 100))]);
Assert.Empty(result.MissingPrices);
Assert.Equal(CostStatus.Priced, result.Total.Status);
Assert.Equal(BucketStatus.Partial, result.Total.Availability);
Assert.Equal(48, result.Total.Cost!.Value, 9);
// A month on its own without data is unknown, not a gap.
var months = Buckets(BucketSize.Month, D(2022, 1, 1), D(2022, 12, 31));
var monthly = Price(months, book, [Line(Grid, Electricity, Monthly(months, m => m.Month < 10 ? null : 100))]);
var january = monthly.Lines[0].Buckets[0];
Assert.Equal(CostStatus.Priced, january.Status);
Assert.Equal(BucketStatus.Missing, january.Availability);
Assert.Null(january.Cost);
}
[Fact]
public void A_line_without_data_or_tariff_reports_nothing_missing()
{
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 2, 28));
var result = Price(buckets, Book(), [Line(OilTank, Oil, [.. CostCalculator.Parts(buckets).Select(p => CostQuantity.Unknown(p))], "L")]);
Assert.Empty(result.MissingPrices);
Assert.False(result.Total.IncludesNotPriced);
Assert.Null(result.Total.Cost);
Assert.Equal(BucketStatus.Missing, result.Total.Availability);
}
[Theory]
[InlineData(BucketStatus.Unresolved)]
[InlineData(BucketStatus.Pending)]
[InlineData(BucketStatus.Invalid)]
public void Data_that_cannot_be_cut_still_needs_its_price(BucketStatus status)
{
// Unresolved, pending or invalid quantities exist — they are only unknown per bucket — so a gap is still a gap.
var book = Book(TypePrice(1, Water, 5, "EUR/m3", D(2025, 3, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 1, 31));
var result = Price(buckets, book, [Line(WaterMeter, Water, [CostQuantity.Unknown(CostCalculator.Parts(buckets).Single(), status)], "m³")]);
Assert.Equal(CostStatus.PriceGap, result.Total.Status);
Assert.Equal(CostStatus.PriceGap, Assert.Single(result.MissingPrices).Reason);
}
[Fact]
public void Zero_quantities_under_no_tariff_do_not_make_a_line_priced()
{
// Folding the whole line: zero months price nothing, the rest has no tariff — the line is still not priced.
var book = Book();
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31));
var result = Price(buckets, book, [Line(OilTank, Oil, Monthly(buckets, m => m.Month < 3 ? 0 : 400), "L")]);
Assert.Equal(CostStatus.Priced, result.Lines[0].Buckets[0].Status);
Assert.Equal(CostStatus.NotPriced, result.Lines[0].Buckets[2].Status);
Assert.Equal(CostStatus.NotPriced, result.Lines[0].Total.Status);
Assert.Null(result.Lines[0].Total.Cost);
Assert.Equal(D(2025, 3, 1), Assert.Single(result.MissingPrices).FirstMonth);
}
[Fact]
public void A_bucket_holding_an_interval_longer_than_a_month_is_priced_whole_at_one_price()
{
// Review R5 (A-16): a quarterly delta meter leaves every month unresolved; the year holds its intervals whole,
// and one price covers every month, so the year costs its quantity at that price.
var book = Book(TypePrice(1, Electricity, 0.10, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Year, D(2025, 1, 1), D(2025, 12, 31));
var unresolved = CostCalculator.Parts(buckets).Select(p => CostQuantity.Unknown(p, BucketStatus.Unresolved)).ToList();
var line = Line(Grid, Electricity, unresolved) with { Spans = [new CostSpan(0, 1200, BucketStatus.Available)] };
var result = Price(buckets, book, [line]);
Assert.Equal(CostStatus.Priced, result.Total.Status);
Assert.Equal(BucketStatus.Available, result.Total.Availability);
Assert.Equal(120, result.Total.Cost!.Value, 9);
Assert.Empty(result.Lines[0].MonthsWithPriceChangeInsideInterval);
// Without a span, the year stays unknown.
var unknown = Price(buckets, book, [Line(Grid, Electricity, unresolved)]);
Assert.Null(unknown.Total.Cost);
Assert.Equal(BucketStatus.Unresolved, unknown.Total.Availability);
}
[Fact]
public void A_price_change_inside_a_whole_bucket_leaves_it_unknown_and_names_its_months()
{
var book = Book(
TypePrice(1, Electricity, 0.10, "EUR/kWh", D(2025, 1, 1), D(2025, 7, 31)),
TypePrice(2, Electricity, 0.20, "EUR/kWh", D(2025, 8, 1)));
var buckets = Buckets(BucketSize.Year, D(2025, 1, 1), D(2025, 12, 31));
var parts = CostCalculator.Parts(buckets);
var line = Line(Grid, Electricity, [.. parts.Select(p => CostQuantity.Unknown(p, BucketStatus.Unresolved))])
with { Spans = [new CostSpan(0, 1200, BucketStatus.Available)] };
var result = Price(buckets, book, [line]);
Assert.Null(result.Total.Cost);
Assert.Equal(BucketStatus.Unresolved, result.Total.Availability);
Assert.Equal([.. parts.Select(p => p.Month)], result.Lines[0].MonthsWithPriceChangeInsideInterval);
}
[Fact]
public void A_span_prices_only_the_months_with_data_and_leaves_resolved_buckets_alone()
{
// A tank read from March: January and February have no data and no price, which does not stop the span.
var book = Book(TypePrice(1, Electricity, 0.10, "EUR/kWh", D(2025, 3, 1)));
var buckets = Buckets(BucketSize.Year, D(2025, 1, 1), D(2025, 12, 31));
var quantities = CostCalculator.Parts(buckets)
.Select(p => p.Month.Month < 3 ? CostQuantity.Unknown(p) : CostQuantity.Unknown(p, BucketStatus.Unresolved))
.ToList();
var line = Line(Grid, Electricity, quantities) with { Spans = [new CostSpan(0, 500, BucketStatus.Partial)] };
var result = Price(buckets, book, [line]);
Assert.Equal(50, result.Total.Cost!.Value, 9);
Assert.Equal(BucketStatus.Partial, result.Total.Availability);
// A bucket whose months are all resolved keeps its month-by-month price, span or not.
var months = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31));
var resolved = Line(Grid, Electricity, Each(months, 10)) with { Spans = [new CostSpan(0, 999, BucketStatus.Available)] };
Assert.Equal(1, Price(months, book, [resolved]).Total.Cost!.Value, 9);
}
[Fact]
public void An_unknown_quantity_leaves_the_cost_unknown_but_priced()
{
// Price coverage and quantity availability are separate: March is priced but unmeasured.
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31));
var quantities = Monthly(buckets, m => m.Month == 3 ? null : 100);
var result = Price(buckets, book, [Line(Grid, Electricity, quantities)]);
var march = result.Lines[0].Buckets[2];
Assert.Equal(CostStatus.Priced, march.Status);
Assert.Equal(BucketStatus.Missing, march.Availability);
Assert.Null(march.Cost);
Assert.Equal(BucketStatus.Partial, result.Total.Availability);
Assert.Equal(CostStatus.Priced, result.Total.Status);
Assert.Equal(60, result.Total.Cost!.Value, 9);
}
[Theory]
[InlineData(BucketStatus.Unresolved)]
[InlineData(BucketStatus.Invalid)]
[InlineData(BucketStatus.Pending)]
public void A_quantity_that_is_not_a_number_is_never_priced_even_with_an_amount(BucketStatus status)
{
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 1, 31));
var part = CostCalculator.Parts(buckets).Single();
var result = Price(buckets, book, [Line(Grid, Electricity, [new CostQuantity(part.FirstDay, part.EndDay, 100, status)])]);
Assert.Null(result.Total.Cost);
Assert.Equal(status, result.Total.Availability);
}
[Fact]
public void A_partial_quantity_keeps_its_partial_cost()
{
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 1, 31));
var part = CostCalculator.Parts(buckets).Single();
var result = Price(buckets, book, [Line(Grid, Electricity, [CostQuantity.Known(part, 50, BucketStatus.Partial)])]);
Assert.Equal(15, result.Total.Cost!.Value, 9);
Assert.Equal(BucketStatus.Partial, result.Total.Availability);
}
[Fact]
public void A_part_without_a_quantity_reads_as_missing()
{
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 2, 28));
var result = Price(buckets, book, [Line(Grid, Electricity, [CostQuantity.Known(CostCalculator.Parts(buckets)[0], 100)])]);
Assert.Equal(BucketStatus.Missing, result.Lines[0].Buckets[1].Availability);
Assert.Null(result.Lines[0].Buckets[1].Cost);
Assert.Equal(30, result.Total.Cost!.Value, 9);
}
// ---- where the missing price belongs (D-52) -----------------------------------------------------
[Fact]
public void A_gap_is_suggested_in_the_scope_that_holds_the_price_history()
{
// The meter has its own history, so the gap is filled there, not at the type.
var book = Book(MeterPrice(1, Grid, 0.30, "EUR/kWh", D(2025, 2, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 2, 28));
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]);
var missing = Assert.Single(result.MissingPrices);
Assert.Equal((TariffScope.Meter, (int?)Grid), (missing.Scope, missing.ScopeId));
}
[Fact]
public void A_gap_under_a_global_price_is_suggested_globally()
{
var book = Book(Tariff(1, TariffScope.Global, null, TariffComponent.UnitPrice, 0.30, "EUR/kWh", D(2025, 2, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 2, 28));
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]);
var missing = Assert.Single(result.MissingPrices);
Assert.Equal((TariffScope.Global, (int?)null, CostStatus.PriceGap), (missing.Scope, missing.ScopeId, missing.Reason));
}
// ---- D-34: feed-in -------------------------------------------------------------------------------
[Fact]
public void Feed_in_is_credited_on_the_export_line_only_and_kept_apart_from_the_charges()
{
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
FeedInPrice(2, Electricity, 0.08, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 6, 1), D(2025, 6, 30));
var result = Price(
buckets,
book,
[Line(Grid, Electricity, Each(buckets, 300)), Line(Export, Electricity, Each(buckets, 200), kind: BillLineKind.FeedIn)]);
var import = result.Lines[0].Total;
Assert.Equal(90, import.Usage!.Value, 9);
Assert.Null(import.FeedInCredit);
var export = result.Lines[1].Total;
Assert.Equal(16, export.FeedInCredit!.Value, 9);
Assert.Null(export.Usage);
Assert.Null(export.Charges);
Assert.Equal(-16, export.Cost!.Value, 9);
Assert.Equal(90, result.Total.Charges!.Value, 9);
Assert.Equal(16, result.Total.FeedInCredit!.Value, 9);
Assert.Equal(74, result.Total.Cost!.Value, 9);
}
[Fact]
public void A_missing_feed_in_price_is_an_optional_credit_reported_only_where_there_is_export()
{
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31));
// No export in January and February, some in March.
var result = Price(
buckets,
book,
[Line(Grid, Electricity, Each(buckets, 100)), Line(Export, Electricity, Monthly(buckets, m => m.Month == 3 ? 50 : 0), kind: BillLineKind.FeedIn)]);
var missing = Assert.Single(result.MissingPrices);
Assert.True(missing.IsCredit);
Assert.Equal((CostStatus.NotPriced, D(2025, 3, 1)), (missing.Reason, missing.FirstMonth));
// The bill stays priced: the credit is left out, the charges are complete.
Assert.Equal(CostStatus.Priced, result.Total.Status);
Assert.True(result.Total.IncludesNotPriced);
Assert.Equal(90, result.Total.Cost!.Value, 9);
Assert.Equal(0d, result.Total.FeedInCredit);
}
[Fact]
public void A_feed_in_price_never_credits_a_billed_line()
{
// Generation and import are not export: only a FeedIn line earns the credit.
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
FeedInPrice(2, Electricity, 0.08, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 6, 1), D(2025, 6, 30));
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 300))]);
Assert.Null(result.Total.FeedInCredit);
Assert.Equal(90, result.Total.Cost!.Value, 9);
}
[Fact]
public void A_feed_in_price_in_the_wrong_unit_is_a_mismatch_on_the_credit()
{
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
FeedInPrice(2, Electricity, 0.08, "EUR/m3", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 6, 1), D(2025, 6, 30));
var result = Price(
buckets,
book,
[Line(Grid, Electricity, Each(buckets, 300)), Line(Export, Electricity, Each(buckets, 200), kind: BillLineKind.FeedIn)]);
Assert.Equal(CostStatus.UnitMismatch, result.Lines[1].Total.Status);
Assert.Equal(CostStatus.Partial, result.Total.Status);
Assert.Equal(90, result.Total.Cost!.Value, 9);
var missing = Assert.Single(result.MissingPrices);
Assert.Equal((TariffComponent.FeedIn, CostStatus.UnitMismatch, (int?)2), (missing.Component, missing.Reason, missing.TariffId));
}
}
@@ -0,0 +1,107 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using static MeterVault.Core.Tests.Analysis.Costing.CostingTestData;
namespace MeterVault.Core.Tests.Analysis.Costing;
/// <summary>
/// D-41: a manual cost is booked in full on its PeriodStart local day, when that day is inside the request and not
/// after today; PeriodEnd is informational.
/// </summary>
public sealed class CostCalculatorManualCostTests
{
[Fact]
public void A_manual_cost_is_booked_in_full_on_its_start_day()
{
// A yearly oil delivery invoice dated 3 March: the whole amount in March, nothing spread over the year.
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 12, 31));
var result = Price(buckets, Book(), [], manual: [Manual(1, D(2025, 3, 3), 1200, categoryId: 4, end: D(2025, 12, 31))]);
var booking = Assert.Single(result.ManualCosts.Bookings);
Assert.Equal(new ManualCostBooking(1, 4, null, D(2025, 3, 3), 2, 1200, false), booking);
Assert.Equal(1200, result.Totals[2].Manual!.Value, 9);
Assert.All(result.Totals.Where((_, i) => i != 2), t => Assert.Null(t.Manual));
Assert.Equal(1200, result.Total.Cost!.Value, 9);
Assert.Equal(CostStatus.Priced, result.Total.Status);
}
[Fact]
public void A_manual_cost_lands_in_the_week_that_contains_its_day()
{
// Mon 31 March Sun 6 April: a cost dated 31 March belongs to that week, not to a March bucket.
var weeks = Buckets(BucketSize.Week, D(2025, 3, 24), D(2025, 4, 13));
var result = Price(weeks, Book(), [], manual: [Manual(1, D(2025, 3, 31), 80)]);
Assert.Equal(80, result.Totals.At(weeks, D(2025, 3, 31)).Manual!.Value, 9);
Assert.Equal(1, Assert.Single(result.ManualCosts.Bookings).BucketIndex);
}
[Fact]
public void A_manual_cost_dated_after_today_is_not_booked_but_listed()
{
// The September bucket reaches the end of the month, but today is the 19th.
var buckets = new[] { Bucket(D(2025, 9, 1), D(2025, 10, 1)) };
var result = Price(
buckets,
Book(),
[],
today: D(2025, 9, 19),
manual: [Manual(1, D(2025, 9, 19), 50), Manual(2, D(2025, 9, 25), 70), Manual(3, D(2025, 8, 31), 90), Manual(4, D(2025, 10, 1), 30)]);
Assert.Equal([1], result.ManualCosts.Bookings.Select(b => b.ManualCostId));
Assert.Equal([2], result.ManualCosts.AfterTodayIds);
Assert.Equal(50, result.Total.Cost!.Value, 9);
}
[Fact]
public void Manual_costs_outside_the_request_are_ignored()
{
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31));
var result = Price(buckets, Book(), [], manual: [Manual(1, D(2025, 2, 28), 10), Manual(2, D(2025, 4, 1), 20)]);
Assert.Empty(result.ManualCosts.Bookings);
Assert.Empty(result.ManualCosts.AfterTodayIds);
Assert.Null(result.Total.Cost);
}
[Fact]
public void A_manual_cost_only_instance_still_has_a_cost()
{
// No meter at all: the Heizung manual costs alone make the bill (brief §11, manual-cost-only instance).
var buckets = Buckets(BucketSize.Month, D(2026, 1, 1), D(2026, 2, 28));
var result = Price(buckets, Book(), [], manual: [Manual(1, D(2026, 1, 1), 200), Manual(2, D(2026, 2, 1), 180)]);
Assert.Equal([200d, 180d], result.Totals.Select(t => t.Cost!.Value));
Assert.Equal(BucketStatus.Available, result.Total.Availability);
}
[Fact]
public void A_manual_cost_in_another_currency_is_booked_and_flagged()
{
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31));
var result = Price(buckets, Book(), [], manual: [Manual(1, D(2025, 3, 5), 40, currency: "USD"), Manual(2, D(2025, 3, 6), 60, currency: "€")]);
Assert.Equal([true, false], result.ManualCosts.Bookings.Select(b => b.CurrencyMismatch));
Assert.True(result.Total.Unverified);
Assert.Equal(100, result.Total.Cost!.Value, 9);
}
[Fact]
public void Manual_costs_add_to_a_bill_exactly_once()
{
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 2, 28));
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))], manual: [Manual(1, D(2025, 2, 10), 25)]);
Assert.Equal(30 + 30 + 25, result.Total.Cost!.Value, 9);
Assert.Equal(25, result.ManualCosts.Total.Manual!.Value, 9);
Assert.Equal(55, result.Totals[1].Cost!.Value, 9);
}
}
@@ -0,0 +1,268 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Quantities;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Domain;
using static MeterVault.Core.Tests.Analysis.Costing.CostingTestData;
namespace MeterVault.Core.Tests.Analysis.Costing;
/// <summary>
/// D-36 and D-37: every month is priced with the price in effect on its 15th, whatever the bucket size, and a price
/// applies only in a unit that fits the meter's normalized unit.
/// </summary>
public sealed class CostCalculatorPricingTests
{
// ---- D-36: the 15th ---------------------------------------------------------------------------
[Fact]
public void A_month_is_priced_with_the_price_in_effect_on_its_15th()
{
// 0.30 from January, 0.40 from 16 March, 0.50 from 15 May: March keeps the old price (the change comes a day
// too late), May already has the new one (in effect on the 15th itself).
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
TypePrice(2, Electricity, 0.40, "EUR/kWh", D(2025, 3, 16)),
TypePrice(3, Electricity, 0.50, "EUR/kWh", D(2025, 5, 15)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 5, 31));
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]);
var costs = result.Lines[0].Buckets.Select(c => c.Cost!.Value).ToList();
Assert.Equal([30, 30, 30, 40, 50], costs.Select(c => Math.Round(c, 9)));
Assert.All(result.Lines[0].Buckets, c => Assert.Equal(CostStatus.Priced, c.Status));
Assert.Equal(180, result.Total.Cost!.Value, 9);
}
[Fact]
public void A_price_ending_on_the_14th_leaves_its_month_to_the_next_price()
{
// The old price's ValidTo is inclusive but ends before the 15th; the successor starts on the 15th.
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1), D(2025, 2, 14)),
TypePrice(2, Electricity, 0.35, "EUR/kWh", D(2025, 2, 15)));
var buckets = Buckets(BucketSize.Month, D(2025, 2, 1), D(2025, 2, 28));
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]);
Assert.Equal(35, result.Total.Cost!.Value, 9);
}
[Fact]
public void A_week_straddling_a_month_is_priced_with_each_month_s_own_price()
{
// Mon 27 Jan Sun 2 Feb 2025: five January days at 0.30, two February days at 0.40, 10 kWh a day.
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
TypePrice(2, Electricity, 0.40, "EUR/kWh", D(2025, 2, 1)));
var buckets = Buckets(BucketSize.Week, D(2025, 1, 27), D(2025, 2, 2));
Assert.Single(buckets);
var parts = CostCalculator.Parts(buckets);
Assert.Equal([(D(2025, 1, 27), D(2025, 2, 1)), (D(2025, 2, 1), D(2025, 2, 3))], parts.Select(p => (p.FirstDay, p.EndDay)));
var result = Price(buckets, book, [Line(Grid, Electricity, Daily(buckets, _ => 10))]);
Assert.Equal((50 * 0.30) + (20 * 0.40), result.Total.Cost!.Value, 9);
Assert.Equal(CostStatus.Priced, result.Total.Status);
}
[Theory]
[InlineData(BucketSize.Day)]
[InlineData(BucketSize.Week)]
[InlineData(BucketSize.Month)]
[InlineData(BucketSize.Year)]
public void The_bucket_size_never_changes_the_total(BucketSize size)
{
// Three price changes (one mid-month), a yearly standing charge and a feed-in credit over 2024 (a leap year):
// day, week, month and year buckets must all give the same bill, and each equal the sum of its month buckets.
var book = Book(
TypePrice(1, Electricity, 0.31, "EUR/kWh", D(2023, 1, 1)),
TypePrice(2, Electricity, 0.28, "EUR/kWh", D(2024, 4, 20)),
TypePrice(3, Electricity, 26.5, "ct/kWh", D(2024, 9, 1)),
FeedInPrice(4, Electricity, 8.2, "ct/kWh", D(2020, 1, 1)),
BasePrice(5, TariffScope.EnergyType, Electricity, 180, "EUR/Jahr", D(2020, 1, 1)));
static double Import(DateOnly d) => 5 + (d.DayOfYear % 7);
static double Exported(DateOnly d) => d.Month is >= 4 and <= 9 ? 3 + (d.Day % 4) : 0.5;
CostResult PriceWith(BucketSize bucketSize)
{
var buckets = Buckets(bucketSize, D(2024, 1, 1), D(2024, 12, 31));
return Price(
buckets,
book,
[Line(Grid, Electricity, Daily(buckets, Import)), Line(Export, Electricity, Daily(buckets, Exported), kind: BillLineKind.FeedIn)],
standing: [StandingChargeScope.ForEnergyType(Electricity, new ServicePeriod(D(2020, 1, 1)))]);
}
var months = PriceWith(BucketSize.Month);
var other = PriceWith(size);
Assert.Equal(months.Total.Cost!.Value, other.Total.Cost!.Value, 6);
Assert.Equal(months.Total.Usage!.Value, other.Total.Usage!.Value, 6);
Assert.Equal(months.Total.StandingCharge!.Value, other.Total.StandingCharge!.Value, 6);
Assert.Equal(180, other.Total.StandingCharge!.Value, 6);
Assert.Equal(months.Total.FeedInCredit!.Value, other.Total.FeedInCredit!.Value, 6);
Assert.Equal(months.Totals.Sum(t => t.Cost!.Value), other.Totals.Sum(t => t.Cost!.Value), 6);
Assert.Equal(CostStatus.Priced, other.Total.Status);
}
[Fact]
public void A_year_is_the_sum_of_its_months_not_a_year_priced_from_one_sample()
{
// The old engine priced a year bucket at the 1 July price × the whole year's quantity.
var book = Book(
TypePrice(1, Electricity, 0.20, "EUR/kWh", D(2025, 1, 1)),
TypePrice(2, Electricity, 0.40, "EUR/kWh", D(2025, 7, 1)));
var year = Buckets(BucketSize.Year, D(2025, 1, 1), D(2025, 12, 31));
var months = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 12, 31));
Assert.Equal(12, CostCalculator.Parts(year).Count);
var yearly = Price(year, book, [Line(Grid, Electricity, Each(year, 100))]);
var monthly = Price(months, book, [Line(Grid, Electricity, Each(months, 100))]);
Assert.Equal((6 * 100 * 0.20) + (6 * 100 * 0.40), yearly.Total.Cost!.Value, 9);
Assert.Equal(monthly.Totals.Sum(t => t.Cost!.Value), yearly.Totals.Single().Cost!.Value, 9);
}
[Fact]
public void A_year_to_date_bucket_ends_in_a_part_of_the_current_month()
{
var buckets = ToDate(PeriodPreset.YearToDate, AnalysisClock.At(AnalysisClock.Berlin, 2025, 9, 19, 14, 0), BucketSize.Year);
var parts = CostCalculator.Parts(buckets);
Assert.Equal(9, parts.Count);
Assert.Equal((D(2025, 9, 1), D(2025, 9, 20)), (parts[^1].FirstDay, parts[^1].EndDay));
Assert.All(parts, p => Assert.Equal(0, p.BucketIndex));
}
// ---- D-37: units ------------------------------------------------------------------------------
[Fact]
public void A_price_in_cents_per_kWh_is_scaled_to_the_currency()
{
var book = Book(TypePrice(1, Electricity, 30, "ct/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31));
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]);
Assert.Equal(30, result.Total.Cost!.Value, 9);
Assert.False(result.Total.Unverified);
}
[Fact]
public void A_price_per_MWh_prices_a_kWh_meter()
{
var book = Book(TypePrice(1, Electricity, 250, "EUR/MWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31));
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 400))]);
Assert.Equal(100, result.Total.Cost!.Value, 9);
}
[Theory]
[InlineData("L", 1000, 950)]
[InlineData("m³", 1, 950)]
[InlineData("m3", 2, 1900)]
public void A_price_per_100_litres_prices_litres_and_cubic_metres(string unit, double quantity, double expected)
{
var book = Book(TypePrice(1, Oil, 95, "EUR/100L", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31));
var result = Price(buckets, book, [Line(OilTank, Oil, Each(buckets, quantity), unit)]);
Assert.Equal(expected, result.Total.Cost!.Value, 9);
}
[Fact]
public void A_price_whose_unit_does_not_fit_makes_the_cost_unavailable_for_unit()
{
// A kWh price on a water meter: the old engine charged m³ at the electricity price.
var book = Book(Tariff(7, TariffScope.EnergyType, Water, TariffComponent.UnitPrice, 0.30, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 4, 30));
var result = Price(buckets, book, [Line(WaterMeter, Water, Each(buckets, 12), "m³")]);
var line = result.Lines[0];
Assert.All(line.Buckets, c =>
{
Assert.Equal(CostStatus.UnitMismatch, c.Status);
Assert.Null(c.Cost);
});
var missing = Assert.Single(result.MissingPrices);
Assert.Equal(
new MissingPrice(TariffComponent.UnitPrice, CostStatus.UnitMismatch, TariffScope.EnergyType, Water, WaterMeter, D(2025, 3, 1), D(2025, 4, 1), 7, TariffUnitIssue.IncompatibleUnit),
missing);
Assert.Null(result.Total.Cost);
}
[Fact]
public void A_mismatching_meter_price_does_not_fall_back_to_the_type_price()
{
// The user's own meter price wins by precedence; applying the type's instead would hide the broken override.
var book = Book(
TypePrice(1, Water, 5, "EUR/m3", D(2025, 1, 1)),
MeterPrice(2, WaterMeter, 0.30, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31));
var result = Price(buckets, book, [Line(WaterMeter, Water, Each(buckets, 12), "m³")]);
Assert.Equal(CostStatus.UnitMismatch, result.Total.Status);
Assert.Equal(2, Assert.Single(result.MissingPrices).TariffId);
}
[Fact]
public void A_price_in_another_currency_is_a_mismatch()
{
var book = Book(TypePrice(3, Electricity, 0.25, "USD/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31));
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]);
Assert.Equal(CostStatus.UnitMismatch, result.Total.Status);
Assert.Equal(TariffUnitIssue.CurrencyMismatch, Assert.Single(result.MissingPrices).Issue);
}
[Fact]
public void An_instance_in_another_currency_prices_its_own_currency()
{
var book = TariffBook.Create([TypePrice(3, Electricity, 25, "Rp./kWh", D(2025, 1, 1))], "CHF");
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31));
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]);
Assert.Equal("CHF", result.Currency);
Assert.Equal(25, result.Total.Cost!.Value, 9);
}
[Fact]
public void An_unreadable_unit_applies_at_face_value_with_a_warning()
{
var book = Book(TypePrice(9, Electricity, 0.30, "pauschal", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 4, 30));
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 100))]);
Assert.Equal(60, result.Total.Cost!.Value, 9);
Assert.Equal(CostStatus.Priced, result.Total.Status);
Assert.True(result.Total.Unverified);
Assert.All(result.Lines[0].Buckets, c => Assert.True(c.Unverified));
Assert.Equal(new TariffWarning(9, TariffComponent.UnitPrice, TariffUnitIssue.Unparseable, Grid, D(2025, 3, 1)), Assert.Single(result.Warnings));
}
[Fact]
public void A_line_is_priced_in_the_unit_it_is_given_not_a_raw_unit()
{
// A burner converted to litres by a fixed rate is priced per litre; its raw unit (h) would mismatch.
var book = Book(Tariff(1, TariffScope.Meter, OilTank, TariffComponent.UnitPrice, 1.10, "EUR/L", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 3, 31));
var litres = Price(buckets, book, [Line(OilTank, Oil, Each(buckets, 100), "L")]);
var hours = Price(buckets, book, [Line(OilTank, Oil, Each(buckets, 100), "h")]);
Assert.Equal(110, litres.Total.Cost!.Value, 9);
Assert.Equal(CostStatus.UnitMismatch, hours.Total.Status);
}
}
@@ -0,0 +1,347 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Quantities;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Domain;
using static MeterVault.Core.Tests.Analysis.Costing.CostingTestData;
namespace MeterVault.Core.Tests.Analysis.Costing;
/// <summary>
/// D-40: a standing charge accrues per local day over its scope's service period — once per scope, never once per
/// meter with data (the old engine charged a type's base price for every meter of the type, and a whole month for a
/// month that had barely begun).
/// </summary>
public sealed class CostCalculatorStandingChargeTests
{
private static readonly ServicePeriod Always = new(D(2000, 1, 1));
[Fact]
public void A_type_standing_charge_accrues_once_per_day_however_many_meters_are_billed()
{
const int second = 13;
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
BasePrice(2, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30));
var result = Price(
buckets,
book,
[Line(Grid, Electricity, Each(buckets, 100), service: Always), Line(second, Electricity, Each(buckets, 50), service: Always)],
standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
var row = Assert.Single(result.StandingCharges);
Assert.Equal((TariffScope.EnergyType, (int?)Electricity), (row.Scope, row.ScopeId));
Assert.Equal(12, row.Total.StandingCharge!.Value, 9);
// The meters' own lines carry no share of it.
Assert.All(result.Lines, l => Assert.Null(l.Total.StandingCharge));
Assert.Equal(45 + 12, result.Total.Cost!.Value, 9);
}
[Fact]
public void A_month_to_date_accrues_the_days_up_to_today_only()
{
// 19 September at 14:00: nineteen days of a 30-day month, not the whole month.
var now = AnalysisClock.At(AnalysisClock.Berlin, 2025, 9, 19, 14, 0);
var buckets = ToDate(PeriodPreset.MonthToDate, now, BucketSize.Day);
var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 30, "EUR/month", D(2025, 1, 1)));
var result = Price(buckets, book, [], today: D(2025, 9, 19), standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
Assert.Equal(19, result.Totals.Count);
Assert.All(result.Totals, t => Assert.Equal(1, t.StandingCharge!.Value, 9));
Assert.Equal(19, result.Total.Cost!.Value, 9);
}
[Fact]
public void In_a_zone_behind_UTC_the_local_month_and_day_decide()
{
// 22:00 on 30 September in New York is already 1 October in UTC: month to date is still September, all 30 days.
var now = AnalysisClock.At(AnalysisClock.NewYork, 2025, 9, 30, 22, 0);
var period = PeriodResolver.Resolve(PeriodPreset.MonthToDate, null, null, now, AnalysisClock.NewYork);
var buckets = BucketPlanner.Plan(period, BucketSize.Month).Buckets;
var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 30, "USD/month", D(2025, 1, 1)));
var result = CostCalculator.Calculate(new CostRequest(
buckets,
PeriodResolver.LocalDate(now, AnalysisClock.NewYork),
TariffBook.Create(book.Tariffs, "USD"),
[],
[StandingChargeScope.ForEnergyType(Electricity, Always)]));
Assert.Equal((D(2025, 9, 1), D(2025, 10, 1)), (CostCalculator.Parts(buckets).Single().FirstDay, CostCalculator.Parts(buckets).Single().EndDay));
Assert.Equal(30, result.Total.StandingCharge!.Value, 9);
}
[Fact]
public void Nothing_accrues_after_today_even_inside_a_bucket()
{
var buckets = new[] { Bucket(D(2025, 9, 1), D(2025, 10, 1)) };
var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 30, "EUR/month", D(2025, 1, 1)));
var result = Price(buckets, book, [], today: D(2025, 9, 10), standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
Assert.Equal(10, result.Total.StandingCharge!.Value, 9);
}
[Fact]
public void A_meter_standing_charge_accrues_on_its_line_from_install_to_retire()
{
// Installed 10 March, retired 20 June (inclusive): 22/31 of March, April, May, 20/30 of June, nothing in July.
var service = ServicePeriod.ForMeter(D(2025, 3, 10), D(2025, 6, 20), firstDataDay: D(2025, 3, 12));
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
BasePrice(2, TariffScope.Meter, Grid, 10, "EUR/Monat", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 3, 1), D(2025, 7, 31));
var result = Price(buckets, book, [Line(Grid, Electricity, Each(buckets, 0), service: service)]);
var standing = result.Lines[0].Buckets.Select(c => c.StandingCharge!.Value).ToList();
Assert.Equal(10 * 22 / 31d, standing[0], 9);
Assert.Equal(10, standing[1], 9);
Assert.Equal(10, standing[2], 9);
Assert.Equal(10 * 20 / 30d, standing[3], 9);
Assert.Equal(0, standing[4], 9);
Assert.Empty(result.StandingCharges);
}
[Fact]
public void A_service_period_ignores_reading_gaps()
{
// A meter with a month of missing data still pays its standing charge for that month.
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
BasePrice(2, TariffScope.Meter, Grid, 10, "EUR/Monat", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31));
var result = Price(buckets, book, [Line(Grid, Electricity, Monthly(buckets, m => m.Month == 2 ? null : 100), service: Always)]);
var february = result.Lines[0].Buckets[1];
Assert.Equal(10, february.StandingCharge!.Value, 9);
Assert.Null(february.Usage);
Assert.Equal(10, february.Cost!.Value, 9);
Assert.Equal(BucketStatus.Partial, february.Availability);
}
[Fact]
public void A_yearly_charge_accrues_by_the_days_of_the_year()
{
var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 366, "EUR/Jahr", D(2020, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2024, 1, 1), D(2024, 12, 31));
var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
Assert.Equal(31, result.Totals[0].StandingCharge!.Value, 9);
Assert.Equal(29, result.Totals[1].StandingCharge!.Value, 9);
Assert.Equal(366, result.Total.StandingCharge!.Value, 9);
}
[Fact]
public void A_quarterly_charge_accrues_by_the_days_of_its_calendar_quarter()
{
// Q1 2025 has 90 days, Q2 91: 90 EUR per quarter is 1 EUR a day in Q1 and 90/91 in Q2.
var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 90, "EUR/Quartal", D(2020, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 6, 30));
var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
var charges = result.Totals.Select(t => t.StandingCharge!.Value).ToList();
Assert.Equal(31, charges[0], 9);
Assert.Equal(28, charges[1], 9);
Assert.Equal(31, charges[2], 9);
Assert.Equal(90 * 30 / 91d, charges[3], 9);
Assert.Equal(180, result.Total.StandingCharge!.Value, 9);
}
[Fact]
public void A_daily_charge_is_charged_per_day()
{
var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 0.5, "EUR/Tag", D(2020, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 2, 1), D(2025, 2, 28));
var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
Assert.Equal(14, result.Total.StandingCharge!.Value, 9);
}
[Theory]
[InlineData(10, 20)]
[InlineData(16, 10)]
public void A_standing_charge_price_change_takes_effect_by_the_15th_rule(int changeDay, double marchPrice)
{
// 10 EUR/month, then 20 from the change day: a change up to the 15th prices all of March at 20 (19 March
// included), one on the 16th leaves March at 10. April is 20 either way — per day, month by month.
var book = Book(
BasePrice(1, TariffScope.EnergyType, Electricity, 10, "EUR/Monat", D(2025, 1, 1)),
BasePrice(2, TariffScope.EnergyType, Electricity, 20, "EUR/Monat", D(2025, 3, changeDay)));
var weeks = Buckets(BucketSize.Week, D(2025, 2, 24), D(2025, 4, 6));
var result = Price(weeks, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
// Mon 24 February Sun 2 March: five February days at 10/28, two March days at the March price.
var straddling = result.Totals.At(weeks, D(2025, 2, 24));
Assert.Equal((5 * 10 / 28d) + (2 * marchPrice / 31d), straddling.StandingCharge!.Value, 9);
Assert.Equal((5 * 10 / 28d) + marchPrice + (6 * 20 / 30d), result.Total.StandingCharge!.Value, 9);
}
[Fact]
public void A_global_standing_charge_is_its_own_row()
{
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
BasePrice(2, TariffScope.Global, null, 5, "EUR/Monat", D(2025, 1, 1)),
BasePrice(3, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30));
var result = Price(
buckets,
book,
[Line(Grid, Electricity, Each(buckets, 100), service: Always)],
standing: [StandingChargeScope.ForEnergyType(Electricity, Always), StandingChargeScope.Global(Always)]);
Assert.Equal([TariffScope.EnergyType, TariffScope.Global], result.StandingCharges.Select(r => r.Scope));
Assert.Equal(5, result.StandingCharges[1].Total.StandingCharge!.Value, 9);
// Each scope's charge is its own: the type's does not replace the global one.
Assert.Equal(30 + 12 + 5, result.Total.Cost!.Value, 9);
}
[Fact]
public void A_meter_s_own_standing_charge_comes_on_top_of_the_type_s()
{
// A heat pump's meter fee next to the supply contract's base price.
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
BasePrice(2, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 1, 1)),
MeterPrice(3, Heater, 0.22, "EUR/kWh", D(2025, 1, 1)),
BasePrice(4, TariffScope.Meter, Heater, 8, "EUR/Monat", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30));
var result = Price(
buckets,
book,
[Line(Grid, Electricity, Each(buckets, 200), service: Always), Line(Heater, Electricity, Each(buckets, 100), kind: BillLineKind.OwnPrice, service: Always)],
standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
Assert.Equal(8, result.Lines[1].Total.StandingCharge!.Value, 9);
Assert.Equal(22 + 8, result.Lines[1].Total.Cost!.Value, 9);
Assert.Equal(60 + 22 + 8 + 12, result.Total.Cost!.Value, 9);
}
[Fact]
public void A_meter_fee_on_a_meter_without_a_line_is_its_own_row_on_that_meter()
{
// Review R3 (A-18): a PV meter's fee, on a bill that prices only the grid import. It accrues as the PV meter's
// own row, from its install date, not on some other meter's line and not nowhere.
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
BasePrice(2, TariffScope.Meter, Export, 2, "EUR/Monat", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 3, 31));
var result = Price(
buckets,
book,
[Line(Grid, Electricity, Each(buckets, 100), service: Always)],
standing: [StandingChargeScope.ForMeter(Export, new ServicePeriod(D(2025, 2, 1)))]);
var row = Assert.Single(result.StandingCharges);
Assert.Equal((TariffScope.Meter, (int?)Export), (row.Scope, row.ScopeId));
Assert.Equal([0d, 2d, 2d], row.Buckets.Select(b => Math.Round(b.Cost!.Value, 9)));
Assert.Equal(90 + 4, result.Total.Cost!.Value, 9);
// A gap in the fee's history names the meter it belongs to.
var gap = Price(
Buckets(BucketSize.Month, D(2024, 12, 1), D(2024, 12, 31)),
book,
[],
standing: [StandingChargeScope.ForMeter(Export, new ServicePeriod(D(2024, 1, 1)))]);
Assert.Equal((TariffScope.Meter, (int?)Export, (int?)Export), (gap.MissingPrices[0].Scope, gap.MissingPrices[0].ScopeId, gap.MissingPrices[0].MeterId));
}
[Fact]
public void A_scope_without_a_base_price_adds_no_row()
{
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30));
var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always), StandingChargeScope.Global(Always)]);
Assert.Empty(result.StandingCharges);
Assert.Empty(result.MissingPrices);
}
[Fact]
public void A_scope_out_of_service_accrues_nothing()
{
var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30));
var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, null)]);
Assert.Equal(0, Assert.Single(result.StandingCharges).Total.StandingCharge);
Assert.Equal(CostStatus.Priced, result.Total.Status);
}
[Fact]
public void A_gap_in_a_standing_charge_history_is_unavailable_for_those_months()
{
var book = Book(BasePrice(1, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 3, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 4, 30));
var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
var row = Assert.Single(result.StandingCharges);
Assert.Equal([CostStatus.PriceGap, CostStatus.PriceGap, CostStatus.Priced, CostStatus.Priced], row.Buckets.Select(c => c.Status));
Assert.Equal(CostStatus.Partial, row.Total.Status);
Assert.Equal(24, row.Total.StandingCharge!.Value, 9);
Assert.Equal(
new MissingPrice(TariffComponent.BasePrice, CostStatus.PriceGap, TariffScope.EnergyType, Electricity, null, D(2025, 1, 1), D(2025, 2, 1)),
Assert.Single(result.MissingPrices));
}
[Fact]
public void An_unreadable_standing_charge_unit_is_taken_per_month_with_a_warning()
{
var book = Book(BasePrice(4, TariffScope.EnergyType, Electricity, 12, "pauschal", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30));
var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
Assert.Equal(12, result.Total.StandingCharge!.Value, 9);
Assert.True(result.Total.Unverified);
Assert.Equal(TariffUnitIssue.Unparseable, Assert.Single(result.Warnings).Issue);
}
[Fact]
public void A_standing_charge_in_an_unsupported_period_is_a_mismatch()
{
var book = Book(BasePrice(4, TariffScope.EnergyType, Electricity, 24, "EUR/2 Monate", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 4, 1), D(2025, 4, 30));
var result = Price(buckets, book, [], standing: [StandingChargeScope.ForEnergyType(Electricity, Always)]);
Assert.Equal(CostStatus.UnitMismatch, result.Total.Status);
var missing = Assert.Single(result.MissingPrices);
Assert.Equal((4, TariffUnitIssue.UnsupportedPeriod), (missing.TariffId, missing.Issue));
}
[Fact]
public void The_scope_s_service_period_spans_its_meters()
{
// A retired grid meter and its successor: the type is in service from the first install to now.
var retired = ServicePeriod.ForMeter(D(2020, 5, 1), D(2023, 6, 30), null);
var successor = ServicePeriod.ForMeter(null, null, D(2023, 7, 3));
var neverInService = ServicePeriod.ForMeter(null, null, null);
var span = ServicePeriod.Span([retired, successor, neverInService]);
Assert.Null(neverInService);
Assert.Equal(D(2020, 5, 1), span!.FirstDay);
Assert.Null(span.LastDay);
Assert.Equal(D(2023, 6, 30), ServicePeriod.Span([retired])!.LastDay);
Assert.Null(ServicePeriod.Span([]));
Assert.Null(ServicePeriod.ForMeter(D(2024, 1, 1), D(2023, 1, 1), null));
}
}
@@ -0,0 +1,138 @@
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Quantities;
using MeterVault.Core.Costing;
using MeterVault.Core.Domain;
using static MeterVault.Core.Tests.Analysis.Costing.CostingTestData;
namespace MeterVault.Core.Tests.Analysis.Costing;
/// <summary>
/// The tariff book resolves exactly like <see cref="TariffResolver"/> (meter over type over global, latest start within
/// a scope, both ends inclusive) — but deterministically on ties — and answers the D-35/D-38 questions.
/// </summary>
public sealed class CostingTariffBookTests
{
private static readonly Tariff[] History =
[
Tariff(1, TariffScope.Global, null, TariffComponent.UnitPrice, 0.30, "EUR/kWh", D(2023, 1, 1)),
Tariff(2, TariffScope.Global, 99, TariffComponent.UnitPrice, 0.31, "EUR/kWh", D(2023, 6, 1)),
TypePrice(3, Electricity, 0.40, "EUR/kWh", D(2023, 3, 1), D(2023, 12, 31)),
TypePrice(4, Electricity, 0.42, "EUR/kWh", D(2023, 9, 1), D(2023, 10, 31)),
MeterPrice(5, Grid, 0.50, "EUR/kWh", D(2023, 5, 1), D(2023, 5, 31)),
TypePrice(6, Water, 5, "EUR/m3", D(2023, 1, 1)),
Tariff(7, TariffScope.EnergyType, Electricity, TariffComponent.BasePrice, 12, "EUR/Monat", D(2023, 1, 1)),
];
public static TheoryData<int, int, int, int> Dates => new()
{
{ Grid, Electricity, 2, 15 },
{ Grid, Electricity, 4, 15 },
{ Grid, Electricity, 5, 15 },
{ Grid, Electricity, 5, 31 },
{ Grid, Electricity, 6, 1 },
{ Grid, Electricity, 9, 15 },
{ Grid, Electricity, 11, 1 },
{ Grid, Electricity, 12, 31 },
{ WaterMeter, Water, 7, 15 },
{ 77, 5, 7, 15 },
};
[Theory]
[MemberData(nameof(Dates))]
public void Resolves_like_the_tariff_resolver(int meterId, int energyTypeId, int month, int day)
{
var book = Book(History);
var date = D(2023, month, day);
foreach (var component in new[] { TariffComponent.UnitPrice, TariffComponent.BasePrice, TariffComponent.FeedIn })
{
var expected = TariffResolver.Resolve(History, component, meterId, energyTypeId, date);
Assert.Same(expected, book.Resolve(component, meterId, energyTypeId, date));
}
}
[Fact]
public void A_newer_price_in_the_same_scope_overrides_an_older_one_that_still_covers_the_date()
{
var book = Book(History);
Assert.Equal(4, book.Resolve(TariffComponent.UnitPrice, Grid, Electricity, D(2023, 9, 15))!.Id);
Assert.Equal(3, book.Resolve(TariffComponent.UnitPrice, Grid, Electricity, D(2023, 11, 15))!.Id);
Assert.Equal(2, book.Resolve(TariffComponent.UnitPrice, Grid, Electricity, D(2024, 1, 15))!.Id);
}
[Fact]
public void A_tie_on_scope_and_start_goes_to_the_later_entry_whatever_the_load_order()
{
var older = TypePrice(10, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1));
var newer = TypePrice(11, Electricity, 0.35, "EUR/kWh", D(2025, 1, 1));
Assert.Same(newer, Book(older, newer).Resolve(TariffComponent.UnitPrice, Grid, Electricity, D(2025, 3, 15)));
Assert.Same(newer, Book(newer, older).Resolve(TariffComponent.UnitPrice, Grid, Electricity, D(2025, 3, 15)));
}
[Fact]
public void Resolving_in_one_scope_never_falls_back_to_another()
{
var book = Book(History);
Assert.Equal(7, book.ResolveInScope(TariffComponent.BasePrice, TariffScope.EnergyType, Electricity, D(2024, 1, 15))!.Id);
Assert.Null(book.ResolveInScope(TariffComponent.BasePrice, TariffScope.Global, null, D(2024, 1, 15)));
Assert.Null(book.ResolveInScope(TariffComponent.UnitPrice, TariffScope.Meter, Grid, D(2023, 6, 15)));
Assert.Equal(2, book.ResolveInScope(TariffComponent.UnitPrice, TariffScope.Global, 12345, D(2024, 1, 15))!.Id);
}
[Fact]
public void A_scope_is_priced_when_it_has_a_tariff_at_any_date()
{
var book = Book(TypePrice(1, Water, 5, "EUR/m3", D(2030, 1, 1)));
Assert.True(book.HasAny(TariffComponent.UnitPrice, WaterMeter, Water));
Assert.False(book.HasAny(TariffComponent.UnitPrice, Grid, Electricity));
Assert.False(book.HasAny(TariffComponent.FeedIn, WaterMeter, Water));
}
[Fact]
public void A_tariff_that_ends_before_it_starts_is_left_out()
{
var broken = TypePrice(1, Water, 5, "EUR/m3", D(2025, 6, 1), D(2025, 1, 31));
var book = Book(broken);
Assert.Empty(book.Tariffs);
Assert.False(book.HasAny(TariffComponent.UnitPrice, WaterMeter, Water));
}
[Fact]
public void An_own_price_is_known_per_month_by_the_15th()
{
var book = Book(MeterPrice(1, Heater, 0.22, "EUR/kWh", D(2025, 3, 16)), TypePrice(2, Electricity, 0.30, "EUR/kWh", D(2020, 1, 1)));
Assert.True(book.HasMeterScopedUnitPrice(Heater));
Assert.False(book.HasMeterScopedUnitPrice(Grid));
Assert.False(book.HasOwnUnitPrice(Heater, D(2025, 3, 1)));
Assert.True(book.HasOwnUnitPrice(Heater, D(2025, 4, 30)));
Assert.False(book.HasOwnUnitPrice(Grid, D(2025, 4, 1)));
}
[Fact]
public void Units_are_checked_against_the_instance_currency()
{
var eur = TypePrice(1, Electricity, 30, "ct/kWh", D(2025, 1, 1));
var chf = TypePrice(2, Electricity, 0.3, "CHF/kWh", D(2025, 1, 1));
var book = TariffBook.Create([eur, chf], "€");
Assert.Equal(0.3, book.Applicability(eur, "kWh").Convert(30), 12);
Assert.Equal(TariffUnitIssue.CurrencyMismatch, book.Applicability(chf, "kWh").Issue);
Assert.Equal(TariffUnitIssue.IncompatibleUnit, book.Applicability(eur, "m³").Issue);
Assert.Same(book.UnitOf(eur), book.UnitOf(eur));
Assert.Equal(BillingPeriod.Month, book.Accrual(Tariff(3, TariffScope.Global, null, TariffComponent.BasePrice, 5, "EUR/Monat", D(2025, 1, 1))).Period);
}
[Fact]
public void The_price_date_is_the_15th_of_the_month()
{
Assert.Equal(D(2024, 2, 15), TariffBook.PriceDate(D(2024, 2, 29)));
Assert.Equal(D(2025, 12, 15), TariffBook.PriceDate(D(2025, 12, 1)));
}
}
@@ -0,0 +1,153 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Domain;
namespace MeterVault.Core.Tests.Analysis.Costing;
/// <summary>
/// Builders for the cost-calculator tests: real buckets from the period resolver and bucket planner (Berlin), tariffs,
/// and quantities per part. Nothing here reads the wall clock.
/// </summary>
internal static class CostingTestData
{
public const int Electricity = 1;
public const int Water = 2;
public const int Oil = 3;
public const int Grid = 10;
public const int Heater = 11;
public const int Export = 12;
public const int WaterMeter = 20;
public const int OilTank = 30;
/// <summary>A "now" long after every range the tests price, so custom ranges resolve as complete.</summary>
public static readonly DateTimeOffset FarFuture = AnalysisClock.Utc(2040, 1, 1);
public static DateOnly D(int year, int month, int day) => new(year, month, day);
/// <summary>The buckets of the complete range <paramref name="first"/> <paramref name="last"/> (inclusive), as the planner cuts them.</summary>
public static IReadOnlyList<AnalysisBucket> Buckets(BucketSize size, DateOnly first, DateOnly last)
{
var period = PeriodResolver.Resolve(PeriodPreset.Custom, first, last, FarFuture, AnalysisClock.Berlin);
return Plan(period, size);
}
/// <summary>The buckets of a to-date preset at <paramref name="now"/> (Berlin).</summary>
public static IReadOnlyList<AnalysisBucket> ToDate(PeriodPreset preset, DateTimeOffset now, BucketSize size) =>
Plan(PeriodResolver.Resolve(preset, null, null, now, AnalysisClock.Berlin), size);
/// <summary>A bucket spelled out, for the cases a planner never produces (a bucket reaching past today).</summary>
public static AnalysisBucket Bucket(DateOnly first, DateOnly end, BucketSize size = BucketSize.Month) =>
new(first, end, AnalysisClock.At(AnalysisClock.Berlin, first.Year, first.Month, first.Day),
AnalysisClock.At(AnalysisClock.Berlin, end.Year, end.Month, end.Day), size);
public static Tariff Tariff(
int id,
TariffScope scope,
int? scopeId,
TariffComponent component,
double value,
string unit,
DateOnly from,
DateOnly? to = null) => new()
{
Id = id,
ScopeType = scope,
ScopeId = scopeId,
Component = component,
Value = value,
Unit = unit,
ValidFrom = from,
ValidTo = to,
};
/// <summary>A unit price of an energy type.</summary>
public static Tariff TypePrice(int id, int energyTypeId, double value, string unit, DateOnly from, DateOnly? to = null) =>
Tariff(id, TariffScope.EnergyType, energyTypeId, TariffComponent.UnitPrice, value, unit, from, to);
/// <summary>A meter's own unit price.</summary>
public static Tariff MeterPrice(int id, int meterId, double value, string unit, DateOnly from, DateOnly? to = null) =>
Tariff(id, TariffScope.Meter, meterId, TariffComponent.UnitPrice, value, unit, from, to);
public static Tariff BasePrice(int id, TariffScope scope, int? scopeId, double value, string unit, DateOnly from, DateOnly? to = null) =>
Tariff(id, scope, scopeId, TariffComponent.BasePrice, value, unit, from, to);
public static Tariff FeedInPrice(int id, int energyTypeId, double value, string unit, DateOnly from) =>
Tariff(id, TariffScope.EnergyType, energyTypeId, TariffComponent.FeedIn, value, unit, from);
public static TariffBook Book(params Tariff[] tariffs) => TariffBook.Create(tariffs, "EUR");
/// <summary>Every part's quantity from a per-day amount: the sum over its days.</summary>
public static List<CostQuantity> Daily(IReadOnlyList<AnalysisBucket> buckets, Func<DateOnly, double> perDay) =>
[.. CostCalculator.Parts(buckets).Select(p => CostQuantity.Known(p, DaysOf(p).Sum(perDay)))];
/// <summary>Every part's quantity from an amount per whole local month, prorated by days when a part is shorter.</summary>
public static List<CostQuantity> Monthly(IReadOnlyList<AnalysisBucket> buckets, Func<DateOnly, double?> perMonth) =>
[.. CostCalculator.Parts(buckets).Select(p => perMonth(p.Month) is { } amount
? CostQuantity.Known(p, amount * p.Days / DateTime.DaysInMonth(p.Month.Year, p.Month.Month))
: CostQuantity.Unknown(p))];
/// <summary>Every part the same amount.</summary>
public static List<CostQuantity> Each(IReadOnlyList<AnalysisBucket> buckets, double amount) =>
[.. CostCalculator.Parts(buckets).Select(p => CostQuantity.Known(p, amount))];
public static CostLine Line(
int meterId,
int energyTypeId,
IReadOnlyList<CostQuantity> quantities,
string unit = "kWh",
BillLineKind kind = BillLineKind.UnitPrice,
ServicePeriod? service = null) =>
new(meterId, energyTypeId, kind, unit, quantities, service);
public static CostResult Price(
IReadOnlyList<AnalysisBucket> buckets,
TariffBook book,
IEnumerable<CostLine> lines,
DateOnly? today = null,
IEnumerable<StandingChargeScope>? standing = null,
IEnumerable<ManualCost>? manual = null) =>
CostCalculator.Calculate(new CostRequest(
buckets,
today ?? D(2039, 12, 31),
book,
[.. lines],
standing is null ? null : [.. standing],
manual is null ? null : [.. manual]));
public static ManualCost Manual(int id, DateOnly start, double amount, int? categoryId = null, int? meterId = null, DateOnly? end = null, string currency = "EUR") => new()
{
Id = id,
CategoryId = categoryId,
MeterId = meterId,
PeriodStart = start,
PeriodEnd = end ?? start,
Amount = amount,
Currency = currency,
};
/// <summary>The cost of the bucket starting on <paramref name="first"/>.</summary>
public static CostAmount At(this IReadOnlyList<CostAmount> cells, IReadOnlyList<AnalysisBucket> buckets, DateOnly first)
{
for (var i = 0; i < buckets.Count; i++)
{
if (buckets[i].FirstDay == first)
{
return cells[i];
}
}
throw new InvalidOperationException($"No bucket starts on {first:yyyy-MM-dd}.");
}
private static IEnumerable<DateOnly> DaysOf(CostPart part) =>
Enumerable.Range(0, part.Days).Select(part.FirstDay.AddDays);
private static IReadOnlyList<AnalysisBucket> Plan(ResolvedPeriod period, BucketSize size)
{
var plan = BucketPlanner.Plan(period, size, maxPoints: 10_000);
Assert.False(plan.Refused);
return plan.Buckets;
}
}
@@ -0,0 +1,410 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using static MeterVault.Core.Tests.Analysis.AnalysisTestTime;
using static MeterVault.Core.Tests.TestData;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// Coverage runs (D-13): which stretches of time a meter's data covers, at what resolution, and where
/// it is a known hole. What a bucket may claim — available, partial, unresolved, missing — is decided
/// from these runs, so the shapes the seeded instance really has are pinned here: monthly sheets, a
/// burner read once in twelve years, live snapshots, and the holes a register or sensor can leave. Runs are
/// stored uncapped and say where their last interval starts, so any reader can cut them at its own now (A-04).
/// </summary>
public sealed class CoverageBuilderTests
{
private readonly INormalizationEngine _engine = NormalizationEngine.CreateDefault();
private static Reading Measured(DateTimeOffset time, double value) =>
new() { MeterId = 1, Time = time, Value = value, Quality = ReadingQuality.Measured };
private static TimeZoneInfo Zone(string id) => id == "UTC" ? TimeZoneInfo.Utc : TimeZoneInfo.FindSystemTimeZoneById(id);
private static CoverageRun Run(
DateTimeOffset from, DateTimeOffset to, ResolutionClass resolution, bool divided, DateTimeOffset last, CoverageGapReason gap = CoverageGapReason.None) =>
new(from.ToUniversalTime(), to.ToUniversalTime(), resolution, divided, gap, last.ToUniversalTime());
private IReadOnlyList<CoverageRun> Coverage(MeterMode mode, TimeZoneInfo zone, IReadOnlyList<Reading> readings, IReadOnlyList<MeterEvent>? events = null) =>
CoverageBuilder.Build(
_engine.Normalize(new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = mode, Unit = "kWh" },
Readings = readings,
Events = events ?? [],
TimeZone = zone,
}),
zone);
[Theory]
[InlineData("UTC")]
[InlineData("Europe/Berlin")]
[InlineData("America/New_York")]
public void A_monthly_sheet_is_one_month_run_divided_at_months(string zoneId)
{
var zone = Zone(zoneId);
var readings = Enumerable.Range(0, 12).Select(i => Reading(1, Month(2022, 9).AddMonths(i), 100 * i)).ToList();
var run = Assert.Single(Coverage(MeterMode.CumulativeCounter, zone, readings));
Assert.Equal(GapAttribution.LocalMidnight(new DateOnly(2022, 9, 1), zone), run.From);
Assert.Equal(GapAttribution.LocalMidnight(new DateOnly(2023, 9, 1), zone), run.To);
Assert.Equal(GapAttribution.LocalMidnight(new DateOnly(2023, 8, 1), zone), run.LastIntervalStart);
Assert.Equal(ResolutionClass.Month, run.Resolution);
Assert.True(run.DividedAtMonths);
Assert.False(run.IsGap);
}
[Fact]
public void A_twelve_year_burner_interval_is_its_own_coarse_run_followed_by_a_month_run()
{
// The seeded burner shape: 0 h on 18.10.2010, then monthly rows from "Oktober 2022" on. The twelve
// years of hours cannot be placed in any month; the months after can.
var runs = Coverage(MeterMode.RuntimeCounter, Berlin,
[
DayReading(1, Utc(2010, 10, 18), 0),
Reading(1, Month(2022, 10), 7758),
Reading(1, Month(2022, 11), 7785),
Reading(1, Month(2022, 12), 7952),
Reading(1, Month(2023, 1), 8127),
]);
Assert.Equal(
[
Run(Utc(2010, 10, 18), InBerlin(2022, 11, 1), ResolutionClass.Coarse, divided: false, last: Utc(2010, 10, 18)),
Run(InBerlin(2022, 11, 1), InBerlin(2023, 2, 1), ResolutionClass.Month, divided: true, last: InBerlin(2023, 1, 1)),
],
runs);
}
[Fact]
public void Consecutive_intervals_longer_than_a_month_are_each_their_own_run()
{
var runs = Coverage(MeterMode.RuntimeCounter, Berlin,
[DayReading(1, Utc(2022, 1, 10), 0), DayReading(1, Utc(2022, 3, 10), 100), DayReading(1, Utc(2022, 5, 10), 180)]);
Assert.Equal(
[
Run(Utc(2022, 1, 10), Utc(2022, 3, 10), ResolutionClass.Coarse, divided: false, last: Utc(2022, 1, 10)),
Run(Utc(2022, 3, 10), Utc(2022, 5, 10), ResolutionClass.Coarse, divided: false, last: Utc(2022, 3, 10)),
],
runs);
}
[Fact]
public void A_divided_interval_longer_than_a_month_is_month_class_so_month_charts_stay_monthly()
{
// m7 #3, A-03: two six-week gaps between hand readings, each divided at the month starts it crosses.
// Month buckets can place every share, so the data is month-resolution — not coarse, which would make
// auto bucketing jump to years — and no share is finer than the six weeks it was estimated from.
var runs = Coverage(MeterMode.CumulativeCounter, Berlin,
[
Measured(InBerlin(2026, 8, 1, 9), 700),
Measured(InBerlin(2026, 9, 16, 18), 746),
Measured(InBerlin(2026, 11, 2, 12), 800),
]);
var run = Assert.Single(runs);
Assert.Equal(Run(InBerlin(2026, 8, 1, 9), InBerlin(2026, 11, 2, 12), ResolutionClass.Month, divided: true, last: InBerlin(2026, 11, 1)), run);
Assert.Equal(BucketSize.Month, BucketPlanner.MinimumSizeFor(run.Resolution));
}
[Fact]
public void Two_divided_intervals_meeting_at_a_month_start_are_classified_each_by_its_own_length()
{
// m7 #3: read on 31 January 12:00, at 00:00 on 1 March, and on 2 April 12:00. Both intervals are
// divided and their shares meet at 1 March like the shares of one interval do; told apart by their
// source intervals they are 29.5 and 32.5 days — month-class, never one 61-day coarse interval.
var rows = _engine.Normalize(new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
Readings = [Measured(InBerlin(2026, 1, 31, 12), 100), Measured(InBerlin(2026, 3, 1), 130), Measured(InBerlin(2026, 4, 2, 12), 170)],
TimeZone = Berlin,
});
var run = Assert.Single(CoverageBuilder.Build(rows, Berlin));
Assert.Equal(ResolutionClass.Month, run.Resolution);
Assert.True(run.DividedAtMonths);
Assert.Equal(
[(InBerlin(2026, 1, 31, 12), InBerlin(2026, 3, 1)), (InBerlin(2026, 3, 1), InBerlin(2026, 4, 2, 12))],
rows.Where(r => r.Divided).Select(r => (r.SourceStart!.Value, r.SourceEnd!.Value)).Distinct());
}
[Fact]
public void Month_long_readings_on_the_twentieth_are_divided_and_merge_into_one_month_run()
{
var runs = Coverage(MeterMode.CumulativeCounter, Berlin,
[
Measured(InBerlin(2026, 6, 20, 12), 100),
Measured(InBerlin(2026, 7, 20, 12), 130),
Measured(InBerlin(2026, 8, 20, 12), 170),
Measured(InBerlin(2026, 9, 20, 12), 200),
]);
Assert.Equal([Run(InBerlin(2026, 6, 20, 12), InBerlin(2026, 9, 20, 12), ResolutionClass.Month, divided: true, last: InBerlin(2026, 9, 1))], runs);
}
[Fact]
public void A_register_that_stands_still_across_a_month_boundary_stays_in_the_month_aligned_run()
{
// m7 #2: the wallbox read on the 20th did not move from 20 June to 20 July. Zero is zero in both months.
var runs = Coverage(MeterMode.CumulativeCounter, Berlin,
[
Measured(InBerlin(2026, 5, 20, 12), 100),
Measured(InBerlin(2026, 6, 20, 12), 130),
Measured(InBerlin(2026, 7, 20, 12), 130),
Measured(InBerlin(2026, 8, 20, 12), 160),
]);
var run = Assert.Single(runs);
Assert.True(run.DividedAtMonths);
Assert.Equal(BucketStatus.Available, CoverageEvaluator.Evaluate(CoverageTestData.Month(2026, 6), runs, Berlin, false).Status);
Assert.Equal(BucketStatus.Available, CoverageEvaluator.Evaluate(CoverageTestData.Month(2026, 7), runs, Berlin, false).Status);
}
[Fact]
public void Burner_hours_that_stand_still_across_a_month_boundary_are_month_aligned_but_moving_hours_are_not()
{
var runs = Coverage(MeterMode.RuntimeCounter, Berlin,
[
Measured(InBerlin(2026, 5, 20, 12), 100),
Measured(InBerlin(2026, 6, 20, 12), 100),
Measured(InBerlin(2026, 7, 20, 12), 140),
]);
Assert.Equal(
[
Run(InBerlin(2026, 5, 20, 12), InBerlin(2026, 6, 20, 12), ResolutionClass.Month, divided: true, last: InBerlin(2026, 5, 20, 12)),
Run(InBerlin(2026, 6, 20, 12), InBerlin(2026, 7, 20, 12), ResolutionClass.Month, divided: false, last: InBerlin(2026, 6, 20, 12)),
],
runs);
}
[Fact]
public void Undivided_monthly_intervals_that_straddle_month_starts_are_each_their_own_run()
{
// Burner hours read on the 20th: every interval straddles a month start undivided, so its own two
// readings are the only places it can be cut — a run keeps only its ends, so each interval is one.
var runs = Coverage(MeterMode.RuntimeCounter, Berlin,
[
Measured(InBerlin(2026, 6, 20, 12), 100),
Measured(InBerlin(2026, 7, 20, 12), 130),
Measured(InBerlin(2026, 8, 20, 12), 170),
]);
Assert.Equal(
[
Run(InBerlin(2026, 6, 20, 12), InBerlin(2026, 7, 20, 12), ResolutionClass.Month, divided: false, last: InBerlin(2026, 6, 20, 12)),
Run(InBerlin(2026, 7, 20, 12), InBerlin(2026, 8, 20, 12), ResolutionClass.Month, divided: false, last: InBerlin(2026, 7, 20, 12)),
],
runs);
}
[Fact]
public void Undivided_weekly_hours_that_straddle_a_month_start_are_not_divided_at_months()
{
// Burner hours read every Monday. The week across 1 September is one undivided interval, so a
// month bucket cannot split it; a register read on the same days divides that week and stays whole.
Reading[] mondays =
[
Measured(InBerlin(2026, 8, 17, 10), 100),
Measured(InBerlin(2026, 8, 24, 10), 110),
Measured(InBerlin(2026, 8, 31, 10), 125),
Measured(InBerlin(2026, 9, 7, 10), 131),
Measured(InBerlin(2026, 9, 14, 10), 140),
];
var hours = Coverage(MeterMode.RuntimeCounter, Berlin, mondays);
var register = Coverage(MeterMode.CumulativeCounter, Berlin, mondays);
Assert.Equal(
[
Run(InBerlin(2026, 8, 17, 10), InBerlin(2026, 8, 31, 10), ResolutionClass.Week, divided: true, last: InBerlin(2026, 8, 24, 10)),
Run(InBerlin(2026, 8, 31, 10), InBerlin(2026, 9, 7, 10), ResolutionClass.Week, divided: false, last: InBerlin(2026, 8, 31, 10)),
Run(InBerlin(2026, 9, 7, 10), InBerlin(2026, 9, 14, 10), ResolutionClass.Week, divided: true, last: InBerlin(2026, 9, 7, 10)),
],
hours);
Assert.Equal([Run(InBerlin(2026, 8, 17, 10), InBerlin(2026, 9, 14, 10), ResolutionClass.Week, divided: true, last: InBerlin(2026, 9, 7, 10))], register);
}
[Fact]
public void Midnight_snapshots_are_one_day_run()
{
var snapshots = Enumerable.Range(0, 10).Select(i => Measured(InBerlin(2026, 8, 25).AddDays(i), 100 + i)).ToList();
var run = Assert.Single(Coverage(MeterMode.CumulativeCounter, Berlin, snapshots));
Assert.Equal(Run(InBerlin(2026, 8, 25), InBerlin(2026, 9, 3), ResolutionClass.Day, divided: true, last: InBerlin(2026, 9, 2)), run);
}
[Fact]
public void An_unexplained_decrease_is_a_gap_between_covered_runs()
{
var runs = Coverage(MeterMode.CumulativeCounter, Berlin,
[
Measured(InBerlin(2026, 9, 1), 10),
Measured(InBerlin(2026, 9, 2), 11),
Measured(InBerlin(2026, 9, 3), 12),
Measured(InBerlin(2026, 9, 4), 5),
Measured(InBerlin(2026, 9, 5), 6),
Measured(InBerlin(2026, 9, 6), 7),
]);
Assert.Equal(
[
Run(InBerlin(2026, 9, 1), InBerlin(2026, 9, 3), ResolutionClass.Day, divided: true, last: InBerlin(2026, 9, 2)),
Run(InBerlin(2026, 9, 3), InBerlin(2026, 9, 4), ResolutionClass.Day, divided: false, last: InBerlin(2026, 9, 3), CoverageGapReason.UnexplainedDecrease),
Run(InBerlin(2026, 9, 4), InBerlin(2026, 9, 6), ResolutionClass.Day, divided: true, last: InBerlin(2026, 9, 5)),
],
runs);
Assert.True(runs[1].IsGap);
}
[Fact]
public void Consecutive_decreases_are_one_gap_run()
{
var runs = Coverage(MeterMode.CumulativeCounter, Berlin,
[Measured(InBerlin(2026, 9, 1), 10), Measured(InBerlin(2026, 9, 2), 9), Measured(InBerlin(2026, 9, 3), 8)]);
Assert.Equal(
[Run(InBerlin(2026, 9, 1), InBerlin(2026, 9, 3), ResolutionClass.Day, divided: false, last: InBerlin(2026, 9, 2), CoverageGapReason.UnexplainedDecrease)],
runs);
}
[Fact]
public void A_reset_that_does_not_say_where_the_register_stopped_is_a_gap_run()
{
var runs = CoverageBuilder.Build(
_engine.Normalize(new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
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)],
}),
TimeZoneInfo.Utc);
Assert.Equal(
[
Run(Month(2023, 1), Month(2023, 3), ResolutionClass.Month, divided: true, last: Month(2023, 2)),
Run(Month(2023, 3), Month(2023, 4), ResolutionClass.Month, divided: false, last: Month(2023, 3), CoverageGapReason.ResetWithoutPrevious),
Run(Month(2023, 4), Month(2023, 5), ResolutionClass.Month, divided: true, last: Month(2023, 4)),
],
runs);
}
[Fact]
public void A_sensor_silence_is_a_gap_run_between_hourly_runs()
{
var samples = Enumerable.Range(0, 13).Select(i => Measured(Utc(2024, 6, 1, 10).AddMinutes(5 * i), 2))
.Append(Measured(Utc(2024, 6, 1, 14), 2))
.Append(Measured(Utc(2024, 6, 1, 14, 5), 2))
.ToList();
var runs = Coverage(MeterMode.InstantRate, TimeZoneInfo.Utc, samples);
Assert.Equal(
[
Run(Utc(2024, 6, 1, 10), Utc(2024, 6, 1, 11), ResolutionClass.Hour, divided: true, last: Utc(2024, 6, 1, 10, 55)),
Run(Utc(2024, 6, 1, 11), Utc(2024, 6, 1, 14), ResolutionClass.Day, divided: false, last: Utc(2024, 6, 1, 11), CoverageGapReason.SampleGap),
Run(Utc(2024, 6, 1, 14), Utc(2024, 6, 1, 14, 5), ResolutionClass.Hour, divided: true, last: Utc(2024, 6, 1, 14)),
],
runs);
}
[Fact]
public void Stored_runs_describe_rows_recorded_after_now_until_a_reader_caps_them()
{
// m7 #4, A-04: "September 2026" imported on the 19th. The stored run still covers September — the
// rebuild's "now" must not be frozen into it — and capping at the reader's now gives the month up.
var now = InBerlin(2026, 9, 19, 12);
var runs = Coverage(MeterMode.CumulativeCounter, Berlin,
[Reading(1, Month(2026, 7), 100), Reading(1, Month(2026, 8), 130), Reading(1, Month(2026, 9), 170)]);
Assert.Equal([Run(InBerlin(2026, 7, 1), InBerlin(2026, 10, 1), ResolutionClass.Month, divided: true, last: InBerlin(2026, 9, 1))], runs);
Assert.Equal(
[new CoverageRun(InBerlin(2026, 7, 1).ToUniversalTime(), InBerlin(2026, 9, 1).ToUniversalTime(), ResolutionClass.Month, DividedAtMonths: true)],
CoverageRuns.CapAt(runs, now, Berlin));
}
[Fact]
public void A_future_reading_costs_only_the_share_that_closes_after_now()
{
// m2 #5: read on the 15th, the 15 September reading stamped ahead (now: 10 September). The August
// share of its interval closed on 1 September and stays covered.
var runs = Coverage(MeterMode.CumulativeCounter, Berlin,
[
Measured(InBerlin(2026, 7, 15, 8), 100),
Measured(InBerlin(2026, 8, 15, 8), 130),
Measured(InBerlin(2026, 9, 15, 8), 170),
]);
var capped = Assert.Single(CoverageRuns.CapAt(runs, InBerlin(2026, 9, 10, 12), Berlin));
Assert.Equal(InBerlin(2026, 9, 1), capped.To);
}
[Fact]
public void A_tank_that_was_not_drawn_from_across_a_month_boundary_is_month_aligned()
{
var rows = _engine.Normalize(new NormalizationContext
{
Meter = new MeterConfig { MeterId = 30, Mode = MeterMode.ConsumableBalance, Unit = "L" },
Events =
[
new MeterEvent { MeterId = 30, Time = InBerlin(2026, 6, 20, 10), EventType = MeterEventType.TankLevel, Amount = 3000, Unit = "L" },
new MeterEvent { MeterId = 30, Time = InBerlin(2026, 7, 20, 10), EventType = MeterEventType.TankLevel, Amount = 3000, Unit = "L" },
new MeterEvent { MeterId = 30, Time = InBerlin(2026, 8, 20, 10), EventType = MeterEventType.TankLevel, Amount = 2800, Unit = "L" },
],
TimeZone = Berlin,
});
var runs = CoverageBuilder.Build(rows, Berlin);
Assert.Equal([true, false], runs.Select(r => r.DividedAtMonths));
}
[Fact]
public void An_opening_balance_and_rows_without_an_interval_cover_nothing()
{
Assert.Empty(Coverage(MeterMode.CumulativeCounter, Berlin, [Measured(InBerlin(2026, 9, 2, 8), 300)]));
Assert.Empty(CoverageBuilder.Build([new Consumption { MeterId = 1, Time = Utc(2026, 9, 1), Amount = 5 }], Berlin));
}
[Fact]
public void Overlapping_rows_never_produce_overlapping_runs()
{
// A nine-day and a seven-day interval that overlap: the earlier one keeps the overlap, the later one
// keeps its own resolution.
Consumption Row(DateTimeOffset from, DateTimeOffset to) =>
new() { MeterId = 1, Time = to, Amount = 1, IntervalStart = from, IntervalEnd = to };
var runs = CoverageBuilder.Build(
[Row(Utc(2026, 1, 5), Utc(2026, 1, 12)), Row(Utc(2026, 1, 1), Utc(2026, 1, 10)), Row(Utc(2026, 1, 2), Utc(2026, 1, 4))],
TimeZoneInfo.Utc);
Assert.Equal(
[
Run(Utc(2026, 1, 1), Utc(2026, 1, 10), ResolutionClass.Month, divided: true, last: Utc(2026, 1, 1)),
Run(Utc(2026, 1, 10), Utc(2026, 1, 12), ResolutionClass.Week, divided: true, last: Utc(2026, 1, 10)),
],
runs);
}
[Fact]
public void Runs_are_in_utc_whatever_offset_the_rows_carry()
{
var from = new DateTimeOffset(2026, 9, 1, 8, 0, 0, TimeSpan.FromHours(2));
var to = new DateTimeOffset(2026, 9, 1, 9, 0, 0, TimeSpan.FromHours(2));
var run = Assert.Single(CoverageBuilder.Build(
[new Consumption { MeterId = 1, Time = to, Amount = 1, IntervalStart = from, IntervalEnd = to }], Berlin));
Assert.Equal(TimeSpan.Zero, run.From.Offset);
Assert.Equal(TimeSpan.Zero, run.To.Offset);
Assert.Equal(TimeSpan.Zero, run.LastIntervalStart!.Value.Offset);
Assert.Equal(from, run.From);
}
}
@@ -0,0 +1,809 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using static MeterVault.Core.Tests.Analysis.CoverageTestData;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// A bucket's status comes from coverage runs, not from sums (D-14). What these pin down: monthly data stays
/// monthly — it resolves months and years and nothing finer; an interval divided at month edges resolves
/// months but not weeks; a long undivided interval leaves every bucket whose edge it crosses unresolved,
/// within the 5 % edge tolerance; outages make exactly the buckets they touch partial or missing; an
/// opening balance, which the caller reports from the rollup flag, keeps its bucket partial (A-01); and a
/// bucket that ends at now is judged on what has happened by then (A-04).
/// </summary>
public sealed class CoverageEvaluatorTests
{
private static readonly IReadOnlyList<CoverageGapReason> NoGaps = [];
private static BucketCoverage Evaluate(AnalysisBucket bucket, params CoverageRun[] runs) =>
CoverageEvaluator.Evaluate(bucket, runs, Berlin, openingBalanceInBucket: false);
// ---- Seeded water meter: imported monthly table rows ---------------------------------------------
[Fact]
public void Monthly_label_data_resolves_its_months_and_years()
{
var water = MonthLabels(2022, 1, 2023, 1);
var december = Evaluate(Month(2022, 12), water);
var year = Evaluate(Year(2022), water);
Assert.Equal(BucketStatus.Available, december.Status);
Assert.Equal(ValueIssue.None, december.Issue);
Assert.Equal(ResolutionClass.Month, december.Resolution);
Assert.Equal(BucketStatus.Available, year.Status);
}
[Fact]
public void Monthly_label_data_is_unresolved_for_days_and_weeks_although_it_covers_them()
{
var water = MonthLabels(2022, 1, 2023, 1);
foreach (var bucket in new[] { Day(2022, 12, 15), Week(2022, 12, 14), Week(2022, 6, 1), Day(2022, 12, 31), Day(2022, 1, 1) })
{
var coverage = Evaluate(bucket, water);
Assert.Equal(BucketStatus.Unresolved, coverage.Status);
Assert.Equal(ValueIssue.CoarseResolution, coverage.Issue);
Assert.Equal(1d, coverage.CoveredFraction, 9);
}
}
// ---- A counter read on 1 August and 16 September -------------------------------------------------
public static TheoryData<string> DividedIntervalShapes => ["one coarse divided run", "month shares (D-10 segment bounds)"];
private static CoverageRun[] CounterReadAugustToSeptember(string shape)
{
// As one run the 46-day interval is coarse. As D-10's month shares (1 Aug 09:00 - 1 Sep and
// 1 Sep - 16 Sep 18:00) both shares are month-class and merge into one month-class run. Either way
// the normalizer divided it at the month edge; daily readings surround it.
var interval = shape == "one coarse divided run"
? Run(At(2026, 8, 1, 9), At(2026, 9, 16, 18), ResolutionClass.Coarse, divided: true)
: Run(At(2026, 8, 1, 9), At(2026, 9, 16, 18), ResolutionClass.Month, divided: true);
return
[
Run(At(2026, 7, 1), At(2026, 8, 1, 9), ResolutionClass.Day),
interval,
Run(At(2026, 9, 16, 18), At(2026, 10, 1), ResolutionClass.Day),
];
}
[Theory]
[MemberData(nameof(DividedIntervalShapes))]
public void A_46_day_interval_divided_at_the_month_edge_resolves_both_months(string shape)
{
var runs = CounterReadAugustToSeptember(shape);
Assert.Equal(BucketStatus.Available, Evaluate(Month(2026, 8), runs).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Month(2026, 9), runs).Status);
}
[Theory]
[MemberData(nameof(DividedIntervalShapes))]
public void A_46_day_interval_divided_at_the_month_edge_still_cannot_say_which_week_it_was_used_in(string shape)
{
var runs = CounterReadAugustToSeptember(shape);
// Mid-August, the week across the month edge (Mon 31 Aug), and the week the closing reading lies in.
foreach (var bucket in new[] { Week(2026, 8, 12), Week(2026, 8, 31), Week(2026, 9, 16), Day(2026, 8, 20) })
{
Assert.Equal(BucketStatus.Unresolved, Evaluate(bucket, runs).Status);
}
// The weeks around it are resolved by the daily readings on either side.
Assert.Equal(BucketStatus.Available, Evaluate(Week(2026, 7, 15), runs).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Week(2026, 9, 23), runs).Status);
}
// ---- Seeded burner runtime: one 12-year undivided interval ---------------------------------------
[Fact]
public void A_twelve_year_undivided_runtime_interval_leaves_its_months_and_years_unresolved()
{
var brenner = Run(At(2013, 10, 1), At(2025, 10, 1), ResolutionClass.Coarse);
var afterwards = Run(At(2025, 10, 1), At(2026, 6, 1), ResolutionClass.Month, divided: true);
Assert.Equal(BucketStatus.Unresolved, Evaluate(Year(2022), brenner, afterwards).Status);
Assert.Equal(BucketStatus.Unresolved, Evaluate(Month(2022, 3), brenner, afterwards).Status);
// The year it closes in would receive twelve years of runtime.
Assert.Equal(BucketStatus.Unresolved, Evaluate(Year(2025), brenner, afterwards).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Month(2026, 2), brenner, afterwards).Status);
}
// ---- Seeded oil tank: dipstick levels weeks apart, never divided ---------------------------------
private static readonly CoverageRun[] Tank =
[
Run(At(2025, 9, 20), At(2025, 10, 14), ResolutionClass.Month),
Run(At(2025, 10, 14), At(2025, 11, 28), ResolutionClass.Coarse),
Run(At(2025, 11, 28), At(2025, 12, 20), ResolutionClass.Month),
];
[Fact]
public void A_45_day_undivided_tank_interval_leaves_both_months_it_spans_unresolved()
{
var october = Evaluate(Month(2025, 10), Tank);
var november = Evaluate(Month(2025, 11), Tank);
Assert.Equal(BucketStatus.Unresolved, october.Status);
Assert.Equal(ValueIssue.CoarseResolution, october.Issue);
Assert.Equal(ResolutionClass.Coarse, october.Resolution);
Assert.Equal(BucketStatus.Unresolved, november.Status);
}
[Fact]
public void A_45_day_tank_interval_inside_one_year_does_not_make_the_year_unresolved()
{
// D-14: only an interval crossing a bucket edge misbooks. This one starts and ends in 2025, so the
// year total is right — merely partial, because the tank was only read from 20 September.
var year = Evaluate(Year(2025), Tank);
Assert.Equal(BucketStatus.Partial, year.Status);
Assert.Equal(ValueIssue.PartialCoverage, year.Issue);
Assert.Equal(At(2025, 9, 20), year.FirstCovered);
Assert.Equal(At(2025, 12, 20), year.LastCovered);
}
[Fact]
public void A_month_class_tank_interval_crossing_a_month_edge_leaves_that_month_unresolved()
{
// D-14: the 20 Sep - 14 Oct dipstick interval is booked on 14 October, although 11 of its days lie in
// September. Being no longer than a month does not make an undivided interval land in the right month.
var september = Evaluate(Month(2025, 9), Tank);
Assert.Equal(BucketStatus.Unresolved, september.Status);
Assert.Equal(ValueIssue.CoarseResolution, september.Issue);
Assert.Equal(At(2025, 9, 20), september.FirstCovered);
}
[Fact]
public void Undivided_month_class_intervals_read_mid_month_leave_their_months_unresolved()
{
// Review F1: dipsticks (or burner hours) read on 15 Jan, 14 Feb and 16 Mar. February would receive the
// 15 Jan - 14 Feb draw, 17 of whose 30 days lie in January, so neither month can be told apart.
CoverageRun[] runs =
[
Single(At(2026, 1, 15, 10), At(2026, 2, 14, 10), ResolutionClass.Month),
Single(At(2026, 2, 14, 10), At(2026, 3, 16, 10), ResolutionClass.Month),
];
var february = CoverageEvaluator.Evaluate(Month(2026, 2), runs, Berlin, openingBalanceInBucket: false);
Assert.Equal(BucketStatus.Unresolved, february.Status);
Assert.Equal(ValueIssue.CoarseResolution, february.Issue);
Assert.Equal(BucketStatus.Unresolved, Evaluate(Month(2026, 1), runs).Status);
// The year holds both intervals whole: it is resolved, merely partial (read from 15 January only).
Assert.Equal(BucketStatus.Partial, Evaluate(Year(2026), runs).Status);
}
[Fact]
public void Undivided_month_class_intervals_that_cross_a_month_edge_by_less_than_5_percent_stay_resolved()
{
// The seeded oil tank's dipsticks sit on month-end days: 30 Nov 01:00 - 31 Dec 01:00 crosses into
// December by 23 hours of 31 days (3 %), which D-14 tolerates.
CoverageRun[] runs =
[
Single(At(2025, 11, 30, 1), At(2025, 12, 31, 1), ResolutionClass.Month),
Single(At(2025, 12, 31, 1), At(2026, 1, 31, 1), ResolutionClass.Month),
Single(At(2026, 1, 31, 1), At(2026, 2, 28, 1), ResolutionClass.Month),
];
Assert.Equal(BucketStatus.Available, Evaluate(Month(2025, 12), runs).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Month(2026, 1), runs).Status);
}
[Fact]
public void Day_class_intervals_across_a_month_edge_still_resolve_months_on_the_class_rule()
{
// Daily readings at 06:00 cross each month edge by six hours, far inside the tolerance.
var run = Single(At(2026, 1, 31, 6), At(2026, 2, 1, 6), ResolutionClass.Day);
Assert.True(CoverageEvaluator.Resolves(run, Month(2026, 1), Berlin));
Assert.True(CoverageEvaluator.Resolves(run, Month(2026, 2), Berlin));
}
[Fact]
public void An_undivided_interval_crossing_new_year_by_less_than_5_percent_of_a_year_keeps_both_years_resolved()
{
CoverageRun[] runs =
[
Run(At(2024, 1, 1), At(2024, 12, 20), ResolutionClass.Month),
Run(At(2024, 12, 20), At(2025, 2, 5), ResolutionClass.Coarse),
Run(At(2025, 2, 5), At(2026, 1, 1), ResolutionClass.Month),
];
Assert.Equal(BucketStatus.Available, Evaluate(Year(2024), runs).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Year(2025), runs).Status);
Assert.Equal(BucketStatus.Unresolved, Evaluate(Month(2024, 12), runs).Status);
Assert.Equal(BucketStatus.Unresolved, Evaluate(Month(2025, 1), runs).Status);
}
[Fact]
public void An_undivided_interval_crossing_new_year_by_more_than_5_percent_of_a_year_leaves_both_years_unresolved()
{
// 22 days on each side of a 365-day bucket is 6 %.
CoverageRun[] runs =
[
Run(At(2024, 1, 1), At(2024, 12, 10), ResolutionClass.Month),
Run(At(2024, 12, 10), At(2025, 1, 23), ResolutionClass.Coarse),
Run(At(2025, 1, 23), At(2026, 1, 1), ResolutionClass.Month),
];
Assert.Equal(BucketStatus.Unresolved, Evaluate(Year(2024), runs).Status);
Assert.Equal(BucketStatus.Unresolved, Evaluate(Year(2025), runs).Status);
}
[Fact]
public void Unresolved_wins_over_partial_because_a_value_too_coarse_to_divide_is_not_a_partial_total()
{
var coverage = Evaluate(Month(2026, 3), Run(At(2026, 3, 10), At(2026, 4, 20), ResolutionClass.Coarse));
Assert.Equal(BucketStatus.Unresolved, coverage.Status);
Assert.True(coverage.CoveredFraction < 1);
}
// ---- Hourly samples with a three-day outage ------------------------------------------------------
private static readonly CoverageRun[] HourlyWithOutage =
[
Run(At(2026, 1, 1), At(2026, 3, 10, 14), ResolutionClass.Hour),
Gap(At(2026, 3, 10, 14), At(2026, 3, 13, 9), CoverageGapReason.SampleGap),
Run(At(2026, 3, 13, 9), At(2026, 5, 1), ResolutionClass.Hour),
];
[Fact]
public void An_outage_leaves_exactly_the_days_it_touches_partial_or_missing()
{
var notAvailable = new List<(DateOnly Day, BucketStatus Status)>();
for (var day = new DateOnly(2026, 3, 1); day < new DateOnly(2026, 5, 1); day = day.AddDays(1))
{
var coverage = Evaluate(Day(day.Year, day.Month, day.Day), HourlyWithOutage);
if (coverage.Status != BucketStatus.Available)
{
notAvailable.Add((day, coverage.Status));
Assert.Equal(ValueIssue.SampleGap, coverage.Issue);
}
}
Assert.Equal(
[
(new DateOnly(2026, 3, 10), BucketStatus.Partial),
(new DateOnly(2026, 3, 11), BucketStatus.Missing),
(new DateOnly(2026, 3, 12), BucketStatus.Missing),
(new DateOnly(2026, 3, 13), BucketStatus.Partial),
],
notAvailable);
}
[Fact]
public void An_outage_makes_its_week_and_month_partial_and_leaves_their_neighbours_available()
{
var outageWeek = Evaluate(Week(2026, 3, 11), HourlyWithOutage);
Assert.Equal(BucketStatus.Partial, outageWeek.Status);
Assert.Equal(ValueIssue.SampleGap, outageWeek.Issue);
Assert.Equal([CoverageGapReason.SampleGap], outageWeek.Gaps);
Assert.Equal(TimeSpan.FromDays(7) - (At(2026, 3, 13, 9) - At(2026, 3, 10, 14)), outageWeek.Covered);
Assert.Equal(BucketStatus.Available, Evaluate(Week(2026, 3, 4), HourlyWithOutage).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Week(2026, 3, 18), HourlyWithOutage).Status);
Assert.Equal(BucketStatus.Partial, Evaluate(Month(2026, 3), HourlyWithOutage).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Month(2026, 2), HourlyWithOutage).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Month(2026, 4), HourlyWithOutage).Status);
}
[Fact]
public void The_first_outage_day_reports_what_is_covered_and_until_when()
{
var coverage = Evaluate(Day(2026, 3, 10), HourlyWithOutage);
Assert.Equal(TimeSpan.FromHours(14), coverage.Covered);
Assert.Equal(14d / 24, coverage.CoveredFraction, 9);
Assert.Equal(At(2026, 3, 10), coverage.FirstCovered);
Assert.Equal(At(2026, 3, 10, 14), coverage.LastCovered);
Assert.Equal(ResolutionClass.Hour, coverage.Resolution);
}
[Fact]
public void Hourly_data_resolves_the_23_and_25_hour_DST_days()
{
var hourly = Run(At(2026, 3, 1), At(2026, 11, 1), ResolutionClass.Hour);
Assert.Equal(BucketStatus.Available, Evaluate(Day(2026, 3, 29), hourly).Status);
Assert.Equal(BucketStatus.Available, Evaluate(Day(2026, 10, 25), hourly).Status);
}
// ---- Edges and tolerances --------------------------------------------------------------------------
[Fact]
public void A_bucket_nothing_covers_is_missing_not_zero()
{
var coverage = Evaluate(Month(2026, 3));
Assert.Equal(BucketStatus.Missing, coverage.Status);
Assert.Equal(ValueIssue.NoCoverage, coverage.Issue);
Assert.Null(coverage.FirstCovered);
Assert.Null(coverage.Resolution);
Assert.Equal(0d, coverage.CoveredFraction);
}
[Fact]
public void Runs_that_only_touch_the_bucket_edges_do_not_cover_it()
{
var march = Month(2026, 3);
var coverage = Evaluate(
march,
Run(At(2026, 2, 1), march.From, ResolutionClass.Hour),
Run(march.To, At(2026, 5, 1), ResolutionClass.Hour));
Assert.Equal(BucketStatus.Missing, coverage.Status);
}
[Fact]
public void Coverage_short_by_less_than_a_minute_is_complete_and_by_two_minutes_is_partial()
{
var day = Day(2026, 6, 10);
var jitter = Evaluate(day, Run(day.From.AddSeconds(59), day.To, ResolutionClass.Hour));
var shortfall = Evaluate(day, Run(day.From.AddMinutes(2), day.To, ResolutionClass.Hour));
Assert.Equal(BucketStatus.Available, jitter.Status);
Assert.Equal(BucketStatus.Partial, shortfall.Status);
Assert.Equal(ValueIssue.PartialCoverage, shortfall.Issue);
}
[Fact]
public void Overlapping_runs_are_unioned_rather_than_counted_twice()
{
var day = Day(2026, 6, 10);
var coverage = Evaluate(
day,
Run(day.From, day.From.AddHours(16), ResolutionClass.Hour),
Run(day.From.AddHours(8), day.From.AddHours(20), ResolutionClass.Hour));
Assert.Equal(TimeSpan.FromHours(20), coverage.Covered);
Assert.Equal(BucketStatus.Partial, coverage.Status);
}
[Fact]
public void An_empty_clipped_bucket_is_missing()
{
var instant = At(2026, 9, 1);
var empty = new AnalysisBucket(new DateOnly(2026, 9, 1), new DateOnly(2026, 9, 1), instant, instant, BucketSize.Day);
Assert.Equal(BucketStatus.Missing, Evaluate(empty, Run(At(2026, 8, 1), At(2026, 10, 1), ResolutionClass.Hour)).Status);
}
[Fact]
public void A_bucket_that_ends_before_it_starts_is_a_caller_error()
{
var reversed = new AnalysisBucket(new DateOnly(2026, 9, 2), new DateOnly(2026, 9, 1), At(2026, 9, 2), At(2026, 9, 1), BucketSize.Day);
Assert.Throws<ArgumentException>(() => Evaluate(reversed));
}
// ---- Opening balances and register discontinuities ------------------------------------------------
[Fact]
public void An_opening_balance_keeps_its_bucket_partial_even_when_readings_cover_the_rest()
{
var firstReading = At(2026, 3, 10, 14);
CoverageRun[] runs = [Run(firstReading, At(2026, 5, 1), ResolutionClass.Hour, divided: true)];
var day = CoverageEvaluator.Evaluate(Day(2026, 3, 10), runs, Berlin, openingBalanceInBucket: true);
var month = CoverageEvaluator.Evaluate(Month(2026, 3), runs, Berlin, openingBalanceInBucket: true);
var nextDay = CoverageEvaluator.Evaluate(Day(2026, 3, 11), runs, Berlin, openingBalanceInBucket: false);
Assert.Equal(BucketStatus.Partial, day.Status);
Assert.Equal(ValueIssue.OpeningBalance, day.Issue);
Assert.True(day.OpeningBalance);
Assert.Equal(ValueIssue.OpeningBalance, month.Issue);
Assert.Equal(BucketStatus.Available, nextDay.Status);
Assert.False(nextDay.OpeningBalance);
}
[Fact]
public void A_bucket_holding_only_an_opening_balance_is_partial_not_missing()
{
var coverage = CoverageEvaluator.Evaluate(Day(2026, 3, 10), [], Berlin, openingBalanceInBucket: true);
Assert.Equal(BucketStatus.Partial, coverage.Status);
Assert.Equal(ValueIssue.OpeningBalance, coverage.Issue);
Assert.Equal(TimeSpan.Zero, coverage.Covered);
}
[Fact]
public void An_opening_balance_read_at_midnight_marks_the_day_its_row_is_booked_in_and_not_the_day_before()
{
// A-01: the flag follows the booked row. A first reading describes no time before its midnight, so
// its row stays on 10 March 00:00 (D-11 only moves rows that close an interval), and [From, To)
// files that stamp under 10 March, the day the rollup flags.
var midnight = At(2026, 3, 10);
var rows = NormalizationEngine.CreateDefault().Normalize(new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
Readings =
[
new Reading { MeterId = 1, Time = midnight, Value = 100, Quality = ReadingQuality.Measured },
new Reading { MeterId = 1, Time = At(2026, 3, 10, 12), Value = 107, Quality = ReadingQuality.Measured },
],
TimeZone = Berlin,
});
var runs = CoverageBuilder.Build(rows, Berlin);
var opening = Assert.Single(rows, r => r.OpeningBalance);
bool BookedIn(AnalysisBucket bucket) => opening.Time >= bucket.From && opening.Time < bucket.To;
var dayBefore = CoverageEvaluator.Evaluate(Day(2026, 3, 9), runs, Berlin, BookedIn(Day(2026, 3, 9)));
var firstDay = CoverageEvaluator.Evaluate(Day(2026, 3, 10), runs, Berlin, BookedIn(Day(2026, 3, 10)));
Assert.Equal(midnight, opening.Time);
Assert.False(dayBefore.OpeningBalance);
Assert.Equal(BucketStatus.Missing, dayBefore.Status);
Assert.True(firstDay.OpeningBalance);
Assert.Equal(BucketStatus.Partial, firstDay.Status);
Assert.Equal(ValueIssue.OpeningBalance, firstDay.Issue);
}
[Theory]
[InlineData(CoverageGapReason.UnexplainedDecrease)]
[InlineData(CoverageGapReason.ResetWithoutPrevious)]
public void A_register_discontinuity_is_named_as_the_reason_a_bucket_is_partial_or_missing(CoverageGapReason reason)
{
CoverageRun[] runs =
[
Run(At(2026, 1, 1), At(2026, 3, 5, 12), ResolutionClass.Day),
Gap(At(2026, 3, 5, 12), At(2026, 3, 9, 12), reason),
Run(At(2026, 3, 9, 12), At(2026, 5, 1), ResolutionClass.Day),
];
var month = Evaluate(Month(2026, 3), runs);
var inside = Evaluate(Day(2026, 3, 7), runs);
Assert.Equal(BucketStatus.Partial, month.Status);
Assert.Equal(ValueIssue.RegisterDiscontinuity, month.Issue);
Assert.Equal([reason], month.Gaps);
Assert.Equal(BucketStatus.Missing, inside.Status);
Assert.Equal(ValueIssue.RegisterDiscontinuity, inside.Issue);
}
[Fact]
public void Gap_runs_never_count_as_coverage_and_never_make_a_bucket_unresolved()
{
var coverage = Evaluate(Month(2026, 3), Gap(At(2026, 1, 1), At(2026, 6, 1), CoverageGapReason.SampleGap));
Assert.Equal(BucketStatus.Missing, coverage.Status);
Assert.Equal(ValueIssue.SampleGap, coverage.Issue);
Assert.Equal(TimeSpan.Zero, coverage.Covered);
}
[Fact]
public void Several_gap_reasons_are_listed_once_each_most_significant_first()
{
var coverage = Evaluate(
Month(2026, 3),
Run(At(2026, 3, 1), At(2026, 3, 5), ResolutionClass.Hour),
Gap(At(2026, 3, 5), At(2026, 3, 6), CoverageGapReason.SampleGap),
Run(At(2026, 3, 6), At(2026, 3, 10), ResolutionClass.Hour),
Gap(At(2026, 3, 10), At(2026, 3, 11), CoverageGapReason.UnexplainedDecrease),
Run(At(2026, 3, 11), At(2026, 3, 20), ResolutionClass.Hour),
Gap(At(2026, 3, 20), At(2026, 3, 21), CoverageGapReason.SampleGap),
Run(At(2026, 3, 21), At(2026, 4, 1), ResolutionClass.Hour));
Assert.Equal([CoverageGapReason.UnexplainedDecrease, CoverageGapReason.SampleGap], coverage.Gaps);
Assert.Equal(ValueIssue.RegisterDiscontinuity, coverage.Issue);
}
[Fact]
public void Only_available_and_partial_buckets_carry_a_number()
{
var water = MonthLabels(2022, 1, 2023, 1);
var available = Evaluate(Month(2022, 12), water).ToValue(14, Provenance.Imported);
var partial = Evaluate(Day(2026, 3, 10), HourlyWithOutage).ToValue(3.5, Provenance.Measured);
var unresolved = Evaluate(Day(2022, 12, 15), water).ToValue(14, Provenance.Imported);
var missing = Evaluate(Month(2030, 1), water).ToValue(0, Provenance.None);
Assert.Equal(new BucketValue(14, BucketStatus.Available, Provenance.Imported), available);
Assert.Equal(new BucketValue(3.5, BucketStatus.Partial, Provenance.Measured, ValueIssue.SampleGap), partial);
Assert.Equal(new BucketValue(null, BucketStatus.Unresolved, Provenance.Imported, ValueIssue.CoarseResolution), unresolved);
Assert.Equal(BucketValue.Missing(), missing);
}
// ---- Virtual meters: joint evaluation -----------------------------------------------------------------
private static readonly IReadOnlyList<CoverageRun>[] MonthlyAndHourlySources =
[
[MonthLabels(2026, 1, 2026, 8)],
[Run(At(2026, 3, 15, 12), At(2026, 9, 1), ResolutionClass.Hour)],
];
[Fact]
public void A_virtual_bucket_is_as_coarse_as_its_coarsest_source()
{
Assert.Equal(BucketStatus.Available, CoverageEvaluator.EvaluateJoint(Month(2026, 4), MonthlyAndHourlySources, Berlin, false).Status);
var day = CoverageEvaluator.EvaluateJoint(Day(2026, 4, 10), MonthlyAndHourlySources, Berlin, false);
Assert.Equal(BucketStatus.Unresolved, day.Status);
Assert.Equal(ResolutionClass.Month, day.Resolution);
}
[Fact]
public void A_virtual_bucket_is_partial_where_its_sources_only_partly_overlap()
{
var march = CoverageEvaluator.EvaluateJoint(Month(2026, 3), MonthlyAndHourlySources, Berlin, false);
Assert.Equal(BucketStatus.Partial, march.Status);
Assert.Equal(At(2026, 3, 15, 12), march.FirstCovered);
}
[Fact]
public void A_missing_source_makes_the_virtual_bucket_missing()
{
var february = CoverageEvaluator.EvaluateJoint(Month(2026, 2), MonthlyAndHourlySources, Berlin, false);
var august = CoverageEvaluator.EvaluateJoint(Month(2026, 8), MonthlyAndHourlySources, Berlin, false);
Assert.Equal(BucketStatus.Missing, february.Status);
Assert.Equal(ValueIssue.MissingSource, february.Issue);
Assert.Equal(BucketStatus.Missing, august.Status);
Assert.Equal(ValueIssue.MissingSource, august.Issue);
}
[Fact]
public void Sources_covering_different_halves_of_a_bucket_cover_none_of_it_jointly()
{
IReadOnlyList<CoverageRun>[] sources =
[
[Run(At(2026, 6, 1), At(2026, 6, 16), ResolutionClass.Hour)],
[Run(At(2026, 6, 16), At(2026, 7, 1), ResolutionClass.Hour)],
];
var june = CoverageEvaluator.EvaluateJoint(Month(2026, 6), sources, Berlin, false);
Assert.Equal(BucketStatus.Missing, june.Status);
Assert.Equal(ValueIssue.NoCoverage, june.Issue);
}
[Fact]
public void A_source_whose_long_interval_is_clipped_by_the_other_sources_start_still_makes_the_bucket_unresolved()
{
// A books 1 January 15 March in March. B only starts on 1 March, so the joint run is the clipped
// piece 115 March, which on its own looks like a harmless interval inside March.
IReadOnlyList<CoverageRun>[] sources =
[
[Run(At(2026, 1, 1), At(2026, 3, 15), ResolutionClass.Coarse), Run(At(2026, 3, 15), At(2026, 6, 1), ResolutionClass.Day)],
[Run(At(2026, 3, 1), At(2026, 6, 1), ResolutionClass.Hour)],
];
var march = CoverageEvaluator.EvaluateJoint(Month(2026, 3), sources, Berlin, false);
Assert.Equal(BucketStatus.Unresolved, march.Status);
Assert.Equal(BucketStatus.Available, CoverageEvaluator.EvaluateJoint(Month(2026, 4), sources, Berlin, false).Status);
}
[Fact]
public void A_sources_opening_balance_reaches_the_virtual_bucket()
{
var firstReading = At(2026, 4, 10, 8);
IReadOnlyList<CoverageRun>[] sources =
[
[Run(firstReading, At(2026, 6, 1), ResolutionClass.Hour)],
[Run(At(2026, 1, 1), At(2026, 6, 1), ResolutionClass.Hour)],
];
var april = CoverageEvaluator.EvaluateJoint(Month(2026, 4), sources, Berlin, openingBalanceInBucket: true);
var may = CoverageEvaluator.EvaluateJoint(Month(2026, 5), sources, Berlin, openingBalanceInBucket: false);
Assert.True(april.OpeningBalance);
Assert.Equal(BucketStatus.Partial, april.Status);
Assert.Equal(ValueIssue.OpeningBalance, april.Issue);
Assert.Equal(BucketStatus.Available, may.Status);
}
[Fact]
public void A_virtual_meter_without_sources_covers_nothing()
{
Assert.Equal(BucketStatus.Missing, CoverageEvaluator.EvaluateJoint(Month(2026, 4), [], Berlin, false).Status);
}
// ---- Zero-length runs, series, now ------------------------------------------------------------------
[Fact]
public void A_zero_length_run_inside_a_bucket_covers_nothing_and_leaves_it_missing()
{
var instant = At(2026, 3, 10, 14);
var coverage = Evaluate(Day(2026, 3, 10), Run(instant, instant, ResolutionClass.Day));
Assert.Equal(BucketStatus.Missing, coverage.Status);
Assert.Equal(ValueIssue.NoCoverage, coverage.Issue);
Assert.Null(coverage.Resolution);
}
[Fact]
public void A_series_is_evaluated_bucket_for_bucket_exactly_as_one_bucket_at_a_time()
{
// Unsorted, overlapping runs with holes and gaps, and buckets of every size in no particular order.
CoverageRun[] runs =
[
Run(At(2026, 3, 13, 9), At(2026, 5, 1), ResolutionClass.Hour),
Gap(At(2026, 3, 10, 14), At(2026, 3, 13, 9), CoverageGapReason.SampleGap),
MonthLabels(2025, 1, 2026, 1),
Single(At(2026, 1, 1), At(2026, 2, 20), ResolutionClass.Coarse),
Run(At(2026, 2, 20), At(2026, 3, 10, 14), ResolutionClass.Day, divided: true),
Run(At(2026, 4, 1), At(2026, 4, 3), ResolutionClass.Hour),
Run(At(2026, 6, 5), At(2026, 6, 5), ResolutionClass.Hour),
];
var buckets = new List<AnalysisBucket> { Year(2025), Month(2026, 3), Week(2026, 3, 11) };
for (var day = new DateOnly(2026, 6, 10); day >= new DateOnly(2025, 12, 20); day = day.AddDays(-1))
{
buckets.Add(Day(day.Year, day.Month, day.Day));
}
buckets.Add(Month(2026, 1));
var flags = buckets.Select(b => b.FirstDay == new DateOnly(2026, 3, 13)).ToList();
var series = CoverageEvaluator.EvaluateSeries(buckets, runs, Berlin, flags);
Assert.Equal(buckets.Count, series.Count);
for (var i = 0; i < buckets.Count; i++)
{
var one = CoverageEvaluator.Evaluate(buckets[i], runs, Berlin, flags[i]);
Assert.Equal(one with { Gaps = NoGaps }, series[i] with { Gaps = NoGaps });
Assert.Equal(one.Gaps, series[i].Gaps);
}
}
[Fact]
public void A_joint_series_is_evaluated_bucket_for_bucket_exactly_as_one_bucket_at_a_time()
{
IReadOnlyList<CoverageRun>[] sources =
[
[MonthLabels(2026, 1, 2026, 8), Gap(At(2026, 8, 1), At(2026, 8, 3), CoverageGapReason.ResetWithoutPrevious)],
[Run(At(2026, 3, 15, 12), At(2026, 9, 1), ResolutionClass.Hour)],
];
var buckets = Enumerable.Range(1, 9).Select(m => Month(2026, m)).Concat([Day(2026, 4, 10), Week(2026, 3, 16)]).Reverse().ToList();
var series = CoverageEvaluator.EvaluateJointSeries(buckets, sources, Berlin, openingBalanceInBucket: null);
for (var i = 0; i < buckets.Count; i++)
{
var one = CoverageEvaluator.EvaluateJoint(buckets[i], sources, Berlin, openingBalanceInBucket: false);
Assert.Equal(one with { Gaps = NoGaps }, series[i] with { Gaps = NoGaps });
Assert.Equal(one.Gaps, series[i].Gaps);
}
}
[Fact]
public void A_series_needs_one_opening_balance_flag_per_bucket()
{
Assert.Throws<ArgumentException>(() =>
CoverageEvaluator.EvaluateSeries([Day(2026, 3, 1), Day(2026, 3, 2)], [], Berlin, [true]));
}
[Fact]
public void A_live_meters_day_and_month_to_date_are_complete_up_to_its_last_sample()
{
// m2 #9: five-minute samples, the last at 09:55, now 10:00. The five minutes since cannot have a row yet.
var now = At(2026, 9, 19, 10);
CoverageRun[] samples = [Run(At(2026, 8, 1), At(2026, 9, 19, 9, 55), ResolutionClass.Hour, divided: true)];
var today = CoverageEvaluator.Evaluate(CutAt(Day(2026, 9, 19), now), samples, Berlin, false, now);
var monthToDate = CoverageEvaluator.Evaluate(CutAt(Month(2026, 9), now), samples, Berlin, false, now);
Assert.Equal(BucketStatus.Available, today.Status);
Assert.Equal(BucketStatus.Available, monthToDate.Status);
Assert.Equal(At(2026, 9, 19, 9, 55), today.LastCovered);
}
[Fact]
public void Daily_readings_leave_today_complete_until_the_next_reading_is_due()
{
// Read at 06:00 each day: today's bucket ends at now (10:00), four hours after the last reading.
var now = At(2026, 9, 19, 10);
CoverageRun[] daily = [Run(At(2026, 9, 1, 6), At(2026, 9, 19, 6), ResolutionClass.Day, divided: true)];
Assert.Equal(BucketStatus.Available, CoverageEvaluator.Evaluate(CutAt(Day(2026, 9, 19), now), daily, Berlin, false, now).Status);
}
[Fact]
public void Coverage_that_stops_more_than_one_interval_before_now_is_partial()
{
var now = At(2026, 9, 19, 10);
CoverageRun[] hourly = [Run(At(2026, 9, 1), At(2026, 9, 19, 8, 30), ResolutionClass.Hour, divided: true)];
var today = CoverageEvaluator.Evaluate(CutAt(Day(2026, 9, 19), now), hourly, Berlin, false, now);
Assert.Equal(BucketStatus.Partial, today.Status);
Assert.Equal(ValueIssue.PartialCoverage, today.Issue);
}
[Fact]
public void A_known_hole_before_now_is_a_shortfall_not_lag()
{
var now = At(2026, 9, 19, 10);
CoverageRun[] runs =
[
Run(At(2026, 9, 1), At(2026, 9, 19, 9, 30), ResolutionClass.Hour, divided: true),
Gap(At(2026, 9, 19, 9, 30), At(2026, 9, 19, 9, 55), CoverageGapReason.SampleGap),
];
var today = CoverageEvaluator.Evaluate(CutAt(Day(2026, 9, 19), now), runs, Berlin, false, now);
Assert.Equal(BucketStatus.Partial, today.Status);
Assert.Equal(ValueIssue.SampleGap, today.Issue);
}
[Fact]
public void The_up_to_date_tolerance_applies_only_to_a_bucket_that_ends_at_now()
{
// The same shortfall in a finished day is a shortfall.
var now = At(2026, 9, 19, 10);
CoverageRun[] hourly = [Run(At(2026, 9, 1), At(2026, 9, 18, 23, 55), ResolutionClass.Hour, divided: true)];
Assert.Equal(BucketStatus.Partial, CoverageEvaluator.Evaluate(Day(2026, 9, 18), hourly, Berlin, false, now).Status);
Assert.Equal(BucketStatus.Partial, CoverageEvaluator.Evaluate(Day(2026, 9, 18), hourly, Berlin, false).Status);
}
[Fact]
public void Monthly_readings_leave_the_month_to_date_complete_until_the_next_one_is_due()
{
// Read on the 15th: September to date is covered to the 15th, and the next reading is not due until October.
var now = At(2026, 9, 19, 10);
CoverageRun[] monthly = [Run(At(2026, 5, 15), At(2026, 9, 15), ResolutionClass.Month, divided: true, last: At(2026, 9, 1))];
var september = CoverageEvaluator.Evaluate(CutAt(Month(2026, 9), now), monthly, Berlin, false, now);
Assert.Equal(BucketStatus.Available, september.Status);
}
[Fact]
public void The_running_months_label_row_is_recorded_after_now_so_the_month_to_date_is_missing()
{
// A-04, evaluated from stored runs: the September row closes on 1 October, so nothing covers September
// yet, and a zero would not be a true zero.
var now = At(2026, 9, 19, 10);
CoverageRun[] stored = [MonthLabels(2026, 1, 2026, 10)];
var september = CoverageEvaluator.Evaluate(CutAt(Month(2026, 9), now), stored, Berlin, false, now);
var august = CoverageEvaluator.Evaluate(Month(2026, 8), stored, Berlin, false, now);
Assert.Equal(BucketStatus.Missing, september.Status);
Assert.Equal(BucketStatus.Available, august.Status);
}
[Fact]
public void A_future_stamped_daily_reading_does_not_cost_yesterday_its_coverage()
{
// m2 #5: readings at 06:00 with one already stamped tomorrow. Only the interval containing now is lost.
var now = At(2026, 9, 19, 10);
CoverageRun[] stored = [Run(At(2026, 9, 1, 6), At(2026, 9, 20, 6), ResolutionClass.Day, divided: true, last: At(2026, 9, 19, 6))];
Assert.Equal(BucketStatus.Available, CoverageEvaluator.Evaluate(Day(2026, 9, 18), stored, Berlin, false, now).Status);
Assert.Equal(BucketStatus.Available, CoverageEvaluator.Evaluate(CutAt(Day(2026, 9, 19), now), stored, Berlin, false, now).Status);
Assert.Equal(At(2026, 9, 19, 6), CoverageEvaluator.Evaluate(CutAt(Day(2026, 9, 19), now), stored, Berlin, false, now).LastCovered);
}
// ---- Another zone ------------------------------------------------------------------------------------
[Fact]
public void In_New_York_monthly_labels_resolve_New_York_months_and_not_their_days()
{
var water = MonthLabels(NewYork, 2026, 1, 2026, 7);
var march = CoverageEvaluator.Evaluate(Month(NewYork, 2026, 3), [water], NewYork, false);
var dstDay = CoverageEvaluator.Evaluate(Day(NewYork, 2026, 3, 8), [water], NewYork, false);
Assert.Equal(BucketStatus.Available, march.Status);
Assert.Equal(TimeSpan.FromDays(31) - TimeSpan.FromHours(1), march.Length);
Assert.Equal(BucketStatus.Unresolved, dstDay.Status);
Assert.Equal(TimeSpan.FromHours(23), dstDay.Length);
}
}
@@ -0,0 +1,343 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using static MeterVault.Core.Tests.Analysis.CoverageTestData;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// Set operations on coverage runs: covered time, capping at now (D-04, D-13), and the joint coverage of a
/// virtual meter's sources (D-27) — where time counts only if every source covers it, as coarsely as the
/// coarsest source.
/// </summary>
public sealed class CoverageRunsTests
{
[Fact]
public void Covered_time_unions_overlapping_runs_ignores_gaps_and_reports_the_outer_bounds()
{
CoverageRun[] runs =
[
Run(At(2026, 3, 10), At(2026, 3, 20), ResolutionClass.Day),
Gap(At(2026, 3, 1), At(2026, 3, 10), CoverageGapReason.SampleGap),
Run(At(2026, 3, 15), At(2026, 3, 25), ResolutionClass.Hour),
Run(At(2026, 4, 1), At(2026, 4, 3), ResolutionClass.Hour),
];
var covered = CoverageRuns.Covered(runs);
Assert.Equal(TimeSpan.FromDays(17), covered.Total);
Assert.Equal(At(2026, 3, 10), covered.First);
Assert.Equal(At(2026, 4, 3), covered.Last);
Assert.False(covered.IsEmpty);
}
[Fact]
public void Covered_time_inside_a_window_is_clipped_to_it()
{
var covered = CoverageRuns.Covered([Run(At(2026, 3, 10), At(2026, 3, 20), ResolutionClass.Day)], At(2026, 3, 15), At(2026, 4, 1));
Assert.Equal(TimeSpan.FromDays(5), covered.Total);
Assert.Equal(At(2026, 3, 15), covered.First);
}
[Fact]
public void Nothing_covered_is_an_empty_span()
{
var covered = CoverageRuns.Covered([Gap(At(2026, 3, 1), At(2026, 3, 10), CoverageGapReason.SampleGap)]);
Assert.True(covered.IsEmpty);
Assert.Equal(CoveredSpan.None, covered);
}
[Fact]
public void A_current_month_label_row_is_recorded_after_now_so_coverage_ends_at_the_month_start()
{
// D-04: the September row describes all of September and is left out of actuals until it closes.
// Clipping at now would leave 1-19 September looking covered by a row nobody reads.
var now = At(2026, 9, 19, 10);
CoverageRun[] runs =
[
MonthLabels(2026, 1, 2026, 10),
Run(At(2026, 10, 1), At(2026, 11, 1), ResolutionClass.Hour),
];
var capped = CoverageRuns.CapAt(runs, now, Berlin);
Assert.Equal([MonthLabels(2026, 1, 2026, 9) with { LastIntervalStart = null }], capped);
}
[Fact]
public void Runs_that_end_by_now_are_kept_unchanged_and_runs_that_start_after_now_are_dropped()
{
var now = At(2026, 9, 19, 10);
var past = Run(At(2026, 1, 1), now, ResolutionClass.Hour, last: At(2026, 9, 19, 9));
Assert.Equal([past], CoverageRuns.CapAt([past, Run(now, At(2026, 10, 1), ResolutionClass.Hour)], now, Berlin));
}
[Fact]
public void A_future_stamped_daily_reading_gives_up_only_the_interval_containing_now()
{
// m2 #5: read at 06:00 daily, the next reading already stamped tomorrow. The run ends where the
// interval containing now starts, not at a midnight or a class limit before it.
var now = At(2026, 9, 19, 10);
var daily = Assert.Single(CoverageRuns.CapAt(
[Run(At(2026, 9, 1, 6), At(2026, 9, 20, 6), ResolutionClass.Day, divided: true, last: At(2026, 9, 19, 6))], now, Berlin));
Assert.Equal(Run(At(2026, 9, 1, 6), At(2026, 9, 19, 6), ResolutionClass.Day, divided: true), daily);
}
[Fact]
public void Monthly_readings_with_one_in_the_future_keep_every_share_recorded_before_now()
{
// m2 #5: read on the 15th; the reading of 15 September is stamped in the future (now: 10 September).
// Its interval's August share closed on 1 September and is an actual, so August stays covered.
var now = At(2026, 9, 10, 10);
var monthly = Assert.Single(CoverageRuns.CapAt(
[Run(At(2026, 5, 15), At(2026, 9, 15), ResolutionClass.Month, divided: true, last: At(2026, 9, 1))], now, Berlin));
Assert.Equal(At(2026, 9, 1), monthly.To);
}
[Fact]
public void Weekly_readings_give_up_their_last_week_when_now_falls_inside_it()
{
// m2 #11: burner hours read every Monday at 10:00, the last reading stamped a week ahead.
var stored = Run(At(2026, 8, 3, 10), At(2026, 9, 21, 10), ResolutionClass.Week, divided: true, last: At(2026, 9, 14, 10));
var capped = Assert.Single(CoverageRuns.CapAt([stored], At(2026, 9, 19, 10), Berlin));
Assert.Equal(At(2026, 9, 14, 10), capped.To);
Assert.Null(capped.LastIntervalStart);
}
[Fact]
public void Now_inside_an_earlier_interval_ends_the_run_one_interval_before_now()
{
// A-14: two readings stamped ahead. Only the final interval's start is stored, so the interval containing
// now (14 - 21 September) is not known; it started no earlier than one week-class interval before now, and
// claiming nothing after that never counts time whose row is recorded after now.
var stored = Run(At(2026, 8, 3, 10), At(2026, 9, 28, 10), ResolutionClass.Week, divided: true, last: At(2026, 9, 21, 10));
var capped = Assert.Single(CoverageRuns.CapAt([stored], At(2026, 9, 17, 10), Berlin));
Assert.Equal(At(2026, 9, 17, 10) - ResolutionClassifier.WeekLimit, capped.To);
Assert.Null(capped.LastIntervalStart);
}
[Fact]
public void A_label_run_reaching_past_the_current_month_gives_up_the_current_month()
{
// Review F4: sheet rows July - October, the October row carrying September's register forward. Now (19
// September) falls inside the September row's interval, not the last one; the September row closes after
// now, so September must not look covered — or it would read as a true zero.
var capped = Assert.Single(CoverageRuns.CapAt([MonthLabels(2026, 7, 2026, 11)], At(2026, 9, 19, 10), Berlin));
Assert.Equal(At(2026, 9, 1), capped.To);
}
[Fact]
public void Month_shares_of_a_reading_stamped_weeks_ahead_keep_the_months_before_now()
{
// Review F4: read on 20 August, the next reading typed as 5 October. The August share closed on 1 September
// and is an actual; the September share closes on 30 September, after now.
var stored = Run(At(2026, 7, 20, 9), At(2026, 10, 5, 9), ResolutionClass.Month, divided: true, last: At(2026, 10, 1));
var capped = Assert.Single(CoverageRuns.CapAt([stored], At(2026, 9, 19, 14), Berlin));
Assert.Equal(At(2026, 9, 1), capped.To);
}
[Fact]
public void Hourly_readings_with_two_stamped_ahead_give_up_one_hour_before_now_and_stay_up_to_date()
{
var now = At(2026, 9, 19, 10, 30);
var stored = Run(At(2026, 9, 1), At(2026, 9, 19, 13), ResolutionClass.Hour, divided: true, last: At(2026, 9, 19, 12));
var capped = Assert.Single(CoverageRuns.CapAt([stored], now, Berlin));
Assert.Equal(now - ResolutionClassifier.HourLimit, capped.To);
// A-04: today's bucket still counts as up to date — the coverage reaches within one interval of now.
var today = CutAt(Day(2026, 9, 19), now);
Assert.Equal(BucketStatus.Available, CoverageEvaluator.Evaluate(today, [stored], Berlin, openingBalanceInBucket: false, now).Status);
}
[Fact]
public void A_run_that_does_not_know_its_last_interval_is_cut_at_now()
{
var now = At(2026, 9, 19, 10);
Assert.Equal(now, Assert.Single(CoverageRuns.CapAt([Run(At(2026, 9, 1), At(2026, 9, 20), ResolutionClass.Hour)], now, Berlin)).To);
}
[Fact]
public void An_undivided_interval_longer_than_a_month_straddling_now_is_dropped_whole()
{
var now = At(2026, 9, 19, 10);
Assert.Empty(CoverageRuns.CapAt([Single(At(2026, 8, 5), At(2026, 10, 5), ResolutionClass.Coarse)], now, Berlin));
}
[Fact]
public void A_divided_interval_straddling_now_keeps_its_shares_of_the_months_before()
{
// Read on 20 July and (stamped ahead) on 25 September: the September share is the run's last
// interval and closes after now; the July and August shares closed before it and are actuals.
var now = At(2026, 9, 19, 10);
var capped = CoverageRuns.CapAt([Run(At(2026, 7, 20), At(2026, 9, 25), ResolutionClass.Month, divided: true, last: At(2026, 9, 1))], now, Berlin);
Assert.Equal([Run(At(2026, 7, 20), At(2026, 9, 1), ResolutionClass.Month, divided: true)], capped);
}
[Fact]
public void A_gap_straddling_now_is_clipped_at_now()
{
var now = At(2026, 9, 19, 10);
var capped = CoverageRuns.CapAt([Gap(At(2026, 9, 18), At(2026, 9, 21), CoverageGapReason.SampleGap) with { LastIntervalStart = At(2026, 9, 18) }], now, Berlin);
Assert.Equal(now, Assert.Single(capped).To);
}
[Fact]
public void Zero_length_runs_claim_no_time_and_are_dropped()
{
// A-01: an opening balance is not a run; nothing zero-length survives capping.
var now = At(2026, 9, 19, 10);
Assert.Empty(CoverageRuns.CapAt([Run(now.AddHours(-1), now.AddHours(-1), ResolutionClass.Hour)], now, Berlin));
}
[Fact]
public void Capping_twice_at_the_same_instant_changes_nothing()
{
var now = At(2026, 9, 19, 10);
CoverageRun[] stored =
[
MonthLabels(2026, 1, 2026, 10),
Run(At(2026, 9, 1, 6), At(2026, 9, 20, 6), ResolutionClass.Day, divided: true, last: At(2026, 9, 19, 6)),
Run(At(2026, 9, 1), At(2026, 9, 20), ResolutionClass.Hour),
];
var once = CoverageRuns.CapAt(stored, now, Berlin);
Assert.Equal(once, CoverageRuns.CapAt(once, now, Berlin));
}
[Fact]
public void In_New_York_the_running_months_label_is_given_up_at_the_New_York_month_start()
{
var now = At(NewYork, 2026, 9, 19, 10);
var capped = Assert.Single(CoverageRuns.CapAt([MonthLabels(NewYork, 2026, 1, 2026, 10)], now, NewYork));
Assert.Equal(At(NewYork, 2026, 9, 1), capped.To);
Assert.Equal(new DateTimeOffset(2026, 9, 1, 4, 0, 0, TimeSpan.Zero), capped.To);
}
[Fact]
public void The_joint_coverage_of_a_monthly_and_an_hourly_source_is_their_overlap_at_month_resolution()
{
var joint = CoverageRuns.Intersect(
[
[MonthLabels(2026, 1, 2026, 7)],
[Run(At(2026, 3, 15, 12), At(2026, 9, 1), ResolutionClass.Hour)],
]);
var run = Assert.Single(joint);
Assert.Equal(At(2026, 3, 15, 12), run.From);
Assert.Equal(At(2026, 7, 1), run.To);
Assert.Equal(ResolutionClass.Month, run.Resolution);
// Only divided when every source was: the hourly samples were never divided.
Assert.False(run.DividedAtMonths);
}
[Fact]
public void Joint_runs_split_where_the_coarsest_class_changes_and_merge_where_it_does_not()
{
var joint = CoverageRuns.Intersect(
[
[
Run(At(2026, 1, 1), At(2026, 2, 1), ResolutionClass.Hour),
Run(At(2026, 2, 1), At(2026, 3, 1), ResolutionClass.Hour),
Run(At(2026, 3, 1), At(2026, 4, 1), ResolutionClass.Week),
],
[Run(At(2026, 1, 1), At(2026, 4, 1), ResolutionClass.Day)],
]);
Assert.Equal(
[
Run(At(2026, 1, 1), At(2026, 3, 1), ResolutionClass.Day),
Run(At(2026, 3, 1), At(2026, 4, 1), ResolutionClass.Week),
],
joint);
}
[Fact]
public void Joint_coverage_is_divided_only_where_every_source_is()
{
var joint = CoverageRuns.Intersect(
[
[MonthLabels(2025, 1, 2026, 1)],
[MonthLabels(2025, 1, 2025, 7), Run(At(2025, 7, 1), At(2026, 1, 1), ResolutionClass.Month)],
]);
// Joint runs do not know where any source's last interval starts.
Assert.Equal(
[
MonthLabels(2025, 1, 2025, 7) with { LastIntervalStart = null },
Run(At(2025, 7, 1), At(2026, 1, 1), ResolutionClass.Month),
],
joint);
}
[Fact]
public void A_hole_in_one_source_is_a_hole_in_the_joint_coverage_and_keeps_its_reason()
{
var outage = Gap(At(2026, 3, 10, 14), At(2026, 3, 13, 9), CoverageGapReason.SampleGap);
var joint = CoverageRuns.Intersect(
[
[Run(At(2026, 3, 1), At(2026, 3, 10, 14), ResolutionClass.Hour), outage, Run(At(2026, 3, 13, 9), At(2026, 4, 1), ResolutionClass.Hour)],
[Run(At(2026, 3, 1), At(2026, 4, 1), ResolutionClass.Day)],
]);
Assert.Equal(
[
Run(At(2026, 3, 1), At(2026, 3, 10, 14), ResolutionClass.Day),
outage,
Run(At(2026, 3, 13, 9), At(2026, 4, 1), ResolutionClass.Day),
],
joint);
}
[Fact]
public void A_source_without_coverage_leaves_no_joint_coverage()
{
var joint = CoverageRuns.Intersect(
[
[Run(At(2026, 3, 1), At(2026, 4, 1), ResolutionClass.Hour)],
[],
]);
Assert.Empty(joint);
Assert.Empty(CoverageRuns.Intersect([]));
}
[Fact]
public void A_single_source_intersects_to_its_own_coverage_with_touching_runs_merged()
{
var joint = CoverageRuns.Intersect(
[
[
Run(At(2026, 3, 1), At(2026, 3, 15), ResolutionClass.Hour),
Run(At(2026, 3, 15), At(2026, 4, 1), ResolutionClass.Hour),
],
]);
Assert.Equal([Run(At(2026, 3, 1), At(2026, 4, 1), ResolutionClass.Hour)], joint);
}
}
@@ -0,0 +1,134 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Normalization;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>Terse builders for coverage tests: local instants, local buckets and coverage runs (Berlin unless a zone is given).</summary>
internal static class CoverageTestData
{
public static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
public static readonly TimeZoneInfo NewYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
/// <summary>A Berlin wall-clock time as an instant (UTC).</summary>
public static DateTimeOffset At(int year, int month, int day, int hour = 0, int minute = 0) => At(Berlin, year, month, day, hour, minute);
/// <summary>A wall-clock time in <paramref name="zone"/> as an instant (UTC).</summary>
public static DateTimeOffset At(TimeZoneInfo zone, int year, int month, int day, int hour = 0, int minute = 0)
{
var wall = new DateTime(year, month, day, hour, minute, 0);
return new DateTimeOffset(wall, zone.GetUtcOffset(wall)).ToUniversalTime();
}
public static DateTimeOffset Midnight(DateOnly date) => GapAttribution.LocalMidnight(date, Berlin);
public static AnalysisBucket Day(int year, int month, int day) => Day(Berlin, year, month, day);
public static AnalysisBucket Day(TimeZoneInfo zone, int year, int month, int day)
{
var date = new DateOnly(year, month, day);
return Bucket(zone, date, date.AddDays(1), BucketSize.Day);
}
/// <summary>The Monday-start week containing the given date.</summary>
public static AnalysisBucket Week(int year, int month, int day)
{
var date = new DateOnly(year, month, day);
var monday = date.AddDays(-(((int)date.DayOfWeek + 6) % 7));
return Bucket(Berlin, monday, monday.AddDays(7), BucketSize.Week);
}
public static AnalysisBucket Month(int year, int month) => Month(Berlin, year, month);
public static AnalysisBucket Month(TimeZoneInfo zone, int year, int month)
{
var first = new DateOnly(year, month, 1);
return Bucket(zone, first, first.AddMonths(1), BucketSize.Month);
}
public static AnalysisBucket Year(int year)
{
var first = new DateOnly(year, 1, 1);
return Bucket(Berlin, first, first.AddYears(1), BucketSize.Year);
}
/// <summary>A bucket cut at <paramref name="now"/>: the to-date bucket of a period that ends now.</summary>
public static AnalysisBucket CutAt(AnalysisBucket bucket, DateTimeOffset now) =>
bucket with { To = now, EndDay = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(now, Berlin).DateTime).AddDays(1), NominalEndDay = bucket.EndDay };
public static CoverageRun Run(DateTimeOffset from, DateTimeOffset to, ResolutionClass resolution, bool divided = false, DateTimeOffset? last = null) =>
new(from, to, resolution, divided, CoverageGapReason.None, last);
/// <summary>A run that is one source interval: it knows its final interval starts where it does.</summary>
public static CoverageRun Single(DateTimeOffset from, DateTimeOffset to, ResolutionClass resolution, bool divided = false) =>
Run(from, to, resolution, divided, last: from);
public static CoverageRun Gap(DateTimeOffset from, DateTimeOffset to, CoverageGapReason reason) =>
new(from, to, ResolutionClass.Hour, false, reason);
/// <summary>
/// A run of imported monthly table rows ("Jan 2022" … ): each row's interval is its labelled local month,
/// so the run is month-class, month-aligned, and its last interval is its last month.
/// </summary>
public static CoverageRun MonthLabels(int fromYear, int fromMonth, int toYear, int toMonthExclusive) =>
MonthLabels(Berlin, fromYear, fromMonth, toYear, toMonthExclusive);
public static CoverageRun MonthLabels(TimeZoneInfo zone, int fromYear, int fromMonth, int toYear, int toMonthExclusive)
{
var end = new DateOnly(toYear, toMonthExclusive, 1);
return Run(
GapAttribution.LocalMidnight(new DateOnly(fromYear, fromMonth, 1), zone),
GapAttribution.LocalMidnight(end, zone),
ResolutionClass.Month,
divided: true,
last: GapAttribution.LocalMidnight(end.AddMonths(-1), zone));
}
/// <summary>
/// The calendar shift of a comparison (D-06), as the period resolver maps a cut-off: the same local wall
/// time on the shifted date. A date the target month lacks (29 February, 31 April) cuts at that month's
/// end; a wall time lost to DST takes the first instant after the gap; a repeated one its first occurrence.
/// </summary>
public static DateTimeOffset ShiftYears(DateTimeOffset instant, int years) => Shift(instant, Berlin, months: 12 * years);
public static DateTimeOffset ShiftMonths(DateTimeOffset instant, int months) => Shift(instant, Berlin, months: months);
public static DateTimeOffset ShiftDays(DateTimeOffset instant, int days) => Shift(instant, Berlin, days: days);
public static DateTimeOffset Shift(DateTimeOffset instant, TimeZoneInfo zone, int months = 0, int days = 0)
{
var wall = TimeZoneInfo.ConvertTime(instant, zone).DateTime;
if (days != 0)
{
return FromWall(wall.AddDays(days), zone);
}
var target = wall.AddMonths(months);
return target.Day != wall.Day
? GapAttribution.LocalMidnight(new DateOnly(target.Year, target.Month, 1).AddMonths(1), zone)
: FromWall(target, zone);
}
public static ResolvedPeriod Period(PeriodPreset preset, DateOnly firstDay, DateOnly lastDay, DateTimeOffset now)
{
var from = Midnight(firstDay);
var endOfLastDay = Midnight(lastDay.AddDays(1));
var toDate = now < endOfLastDay;
return new ResolvedPeriod(preset, firstDay, lastDay, from, toDate ? now : endOfLastDay, now, toDate, false, Berlin);
}
private static AnalysisBucket Bucket(TimeZoneInfo zone, DateOnly first, DateOnly end, BucketSize size) =>
new(first, end, GapAttribution.LocalMidnight(first, zone), GapAttribution.LocalMidnight(end, zone), size);
private static DateTimeOffset FromWall(DateTime wall, TimeZoneInfo zone)
{
var local = DateTime.SpecifyKind(wall, DateTimeKind.Unspecified);
while (zone.IsInvalidTime(local))
{
local = local.AddMinutes(1);
}
var offset = zone.IsAmbiguousTime(local) ? zone.GetAmbiguousTimeOffsets(local).Max() : zone.GetUtcOffset(local);
return new DateTimeOffset(local, offset).ToUniversalTime();
}
}
@@ -0,0 +1,108 @@
using MeterVault.Core.Analysis.Virtual;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// Nested virtual meters are evaluated dependencies first, a loop is reported with the path through it and
/// withheld from evaluation, and a request expands to the physical meters it finally reads (D-27).
/// </summary>
public sealed class DependencyGraphTests
{
private static DependencyGraph Graph(params (int Meter, int[] Dependencies)[] meters) =>
DependencyGraph.Build(meters.ToDictionary(m => m.Meter, m => (IReadOnlyList<int>)m.Dependencies));
[Fact]
public void Nested_virtual_meters_are_ordered_dependencies_first_and_expand_to_their_physical_leaves()
{
// 10 = m1 + m2, 11 = m10 - m3, 12 = m11 + m10
var graph = Graph((12, [11, 10]), (11, [10, 3]), (10, [1, 2]));
Assert.Equal([10, 11, 12], graph.EvaluationOrder);
Assert.Equal([1, 2, 3], graph.PhysicalLeaves(12));
Assert.Equal([1, 2], graph.PhysicalLeaves(10));
Assert.Equal([7], graph.PhysicalLeaves(7));
Assert.Equal([12, 10, 1], graph.PathTo(12, 1)); // the shortest way, not 12 → 11 → 10 → 1
Assert.Null(graph.PathTo(10, 3));
Assert.Empty(graph.Cycles);
}
[Fact]
public void Evaluation_order_for_a_request_holds_only_what_it_reaches()
{
var graph = Graph((12, [11]), (11, [10]), (10, [1]), (20, [2]));
Assert.Equal([10, 11], graph.EvaluationOrderFor([11]));
Assert.Equal([20], graph.EvaluationOrderFor([20, 1]));
}
[Fact]
public void A_loop_is_reported_with_its_path_and_blocks_everything_that_depends_on_it()
{
// 10 → 11 → 12 → 10 is a loop; 13 reads it; 14 is independent.
var graph = Graph((10, [11]), (11, [12, 1]), (12, [10]), (13, [10, 2]), (14, [1]));
var cycle = Assert.Single(graph.Cycles);
Assert.Equal([10, 11, 12, 10], cycle);
Assert.Equal([11, 12, 10, 11], graph.CycleFor(11));
Assert.Equal([13, 10, 11, 12, 10], graph.CycleFor(13));
Assert.False(graph.IsEvaluable(13));
Assert.True(graph.IsEvaluable(14));
Assert.True(graph.IsEvaluable(1));
Assert.Null(graph.CycleFor(14));
Assert.Equal([14], graph.EvaluationOrder);
}
[Fact]
public void A_meter_referring_to_itself_is_a_loop_of_one()
{
var graph = Graph((10, [10, 1]));
Assert.Equal([10, 10], Assert.Single(graph.Cycles));
Assert.Empty(graph.EvaluationOrder);
Assert.Equal([1], graph.PhysicalLeaves(10));
}
[Fact]
public void Dependents_are_the_virtual_meters_a_deletion_would_break()
{
var graph = Graph((10, [1, 2]), (11, [10, 3]), (12, [3]));
Assert.Equal([10, 11], graph.Dependents(1));
Assert.Equal([11, 12], graph.Dependents(3));
Assert.Equal([11], graph.Dependents(10));
Assert.Empty(graph.Dependents(11));
}
[Fact]
public void A_legacy_virtual_meter_without_a_calculation_is_not_mistaken_for_a_physical_leaf()
{
var catalog = new MeterCatalog(VirtualFixtures.SeededElectricity()); // Summe Solar (6) has no definition
var graph = DependencyGraph.FromCatalog(catalog);
Assert.True(graph.IsVirtual(6));
Assert.Empty(graph.PhysicalLeaves(6));
Assert.False(graph.IsVirtual(4));
}
[Fact]
public void A_chain_of_five_thousand_virtual_meters_is_walked_without_recursion()
{
var chain = Enumerable.Range(1, 5000).Select(i => (Meter: 10_000 + i, Dependencies: new[] { i == 1 ? 1 : 10_000 + i - 1 })).ToArray();
var graph = Graph(chain);
Assert.Equal(5000, graph.EvaluationOrder.Count);
Assert.Equal(10_001, graph.EvaluationOrder[0]);
Assert.Equal([1], graph.PhysicalLeaves(15_000));
Assert.Equal(5001, graph.PathTo(15_000, 1)!.Count);
var looped = Graph([.. chain.Skip(1), (10_001, [15_000])]);
Assert.Equal(5001, Assert.Single(looped.Cycles).Count);
}
[Fact]
public void Paths_format_as_meter_ids_joined_by_the_separator()
{
Assert.Equal("12>7>12", DependencyGraph.FormatPath([12, 7, 12]));
}
}
@@ -0,0 +1,761 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using MeterVault.Core.Normalization.Normalizers;
using static MeterVault.Core.Tests.Analysis.AnalysisTestTime;
using static MeterVault.Core.Tests.TestData;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// Every normalized row says which stretch of time it accrued over (D-10), and a row that closes at a
/// local midnight is booked in the day it closes (D-11). Coverage, rollups and bucket status are built
/// on these intervals, so each mode is pinned here — including the first reading, whose start is only
/// known from a month label or an install date.
/// </summary>
public sealed class EngineIntervalTests
{
private readonly INormalizationEngine _engine = NormalizationEngine.CreateDefault();
private static Reading Manual(DateTimeOffset time, double value, int meterId = 1) =>
new() { MeterId = meterId, Time = time, Value = value, Quality = ReadingQuality.Manual };
private static Reading Measured(DateTimeOffset time, double value, int meterId = 1) =>
new() { MeterId = meterId, Time = time, Value = value, Quality = ReadingQuality.Measured };
private static NormalizationContext Context(
MeterMode mode, TimeZoneInfo zone, IReadOnlyList<Reading> readings, IReadOnlyList<MeterEvent>? events = null, DateOnly? installedAt = null) =>
new()
{
Meter = new MeterConfig { MeterId = 1, Mode = mode, Unit = "kWh", InstalledAt = installedAt },
Readings = readings,
Events = events ?? [],
TimeZone = zone,
};
private static TimeZoneInfo Zone(string id) => id == "UTC" ? TimeZoneInfo.Utc : TimeZoneInfo.FindSystemTimeZoneById(id);
// ---- Registers -------------------------------------------------------------------------------
[Fact]
public void Each_register_row_covers_the_time_since_the_previous_reading()
{
var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin,
[
Manual(InBerlin(2026, 9, 2, 8), 100),
Manual(InBerlin(2026, 9, 10, 20), 110),
Manual(InBerlin(2026, 9, 20, 6), 125),
]));
Assert.Equal(3, result.Count);
Assert.Equal([InBerlin(2026, 9, 2, 8), InBerlin(2026, 9, 10, 20)], result.Skip(1).Select(c => c.IntervalStart!.Value));
Assert.Equal([InBerlin(2026, 9, 10, 20), InBerlin(2026, 9, 20, 6)], result.Skip(1).Select(c => c.IntervalEnd!.Value));
Assert.All(result.Skip(1), c =>
{
Assert.False(c.Divided);
Assert.False(c.OpeningBalance);
Assert.Equal(CoverageGapReason.None, c.Gap);
});
}
[Fact]
public void A_first_reading_without_a_label_or_install_date_is_an_opening_balance_with_an_unknown_start()
{
var first = Assert.Single(_engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin, [Manual(InBerlin(2026, 9, 2, 8), 300)])));
Assert.True(first.OpeningBalance);
Assert.Equal(300, first.Amount);
Assert.Equal(InBerlin(2026, 9, 2, 8), first.IntervalStart);
Assert.Equal(InBerlin(2026, 9, 2, 8), first.IntervalEnd);
Assert.Equal(InBerlin(2026, 9, 2, 8), first.Time);
}
[Fact]
public void A_first_reading_starts_at_the_local_midnight_of_the_install_date()
{
var first = Assert.Single(_engine.Normalize(Context(
MeterMode.CumulativeCounter, Berlin, [Manual(InBerlin(2026, 9, 2, 8), 300)], installedAt: new DateOnly(2026, 8, 15))));
Assert.False(first.OpeningBalance);
Assert.False(first.Divided); // the baseline delta is booked at its reading, as it always was
Assert.Equal(Utc(2026, 8, 14, 22), first.IntervalStart); // 15 August 00:00 in Berlin
Assert.Equal(InBerlin(2026, 9, 2, 8), first.IntervalEnd);
Assert.Equal(InBerlin(2026, 9, 2, 8), first.Time);
}
[Fact]
public void An_install_date_after_the_first_reading_says_nothing_about_its_start()
{
var first = Assert.Single(_engine.Normalize(Context(
MeterMode.CumulativeCounter, Berlin, [Manual(InBerlin(2026, 9, 2, 8), 300)], installedAt: new DateOnly(2026, 9, 5))));
Assert.True(first.OpeningBalance);
Assert.Equal(first.IntervalEnd, first.IntervalStart);
}
[Fact]
public void A_first_month_row_covers_the_local_month_it_names()
{
// Zähler Auto's first sheet row, "Mai 2023" = 3755: the whole register booked as May's figure.
var first = Assert.Single(_engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin, [Reading(1, Month(2023, 5), 3755)])));
Assert.False(first.OpeningBalance);
Assert.Equal(InBerlin(2023, 5, 1), first.IntervalStart);
Assert.Equal(InBerlin(2023, 6, 1), first.IntervalEnd);
Assert.Equal(Month(2023, 5), first.Time);
}
[Theory]
[InlineData("UTC")]
[InlineData("Europe/Berlin")]
public void Consecutive_month_rows_keep_their_stamps_and_span_exactly_their_months(string zoneId)
{
// The golden-fixture shape. A label closes at a local midnight (its month's end) but is a month
// figure, not a reading at that instant: it keeps its stamp.
var zone = Zone(zoneId);
var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, zone,
[
Reading(1, Month(2022, 9), 0),
Reading(1, Month(2022, 10), 411),
Reading(1, Month(2022, 11), 1153),
Reading(1, Month(2022, 12), 1968),
]));
Assert.Equal([Month(2022, 9), Month(2022, 10), Month(2022, 11), Month(2022, 12)], result.Select(c => c.Time));
Assert.Equal([0, 411, 742, 815], result.Select(c => c.Amount).ToArray());
DateTimeOffset MonthStart(int month) => GapAttribution.LocalMidnight(new DateOnly(2022, month, 1), zone);
Assert.Equal([MonthStart(9), MonthStart(10), MonthStart(11), MonthStart(12)], result.Select(c => c.IntervalStart!.Value));
Assert.Equal([MonthStart(10), MonthStart(11), MonthStart(12), GapAttribution.LocalMidnight(new DateOnly(2023, 1, 1), zone)],
result.Select(c => c.IntervalEnd!.Value));
Assert.DoesNotContain(result, c => c.Divided || c.OpeningBalance || c.Gap != CoverageGapReason.None);
}
[Fact]
public void An_interval_divided_at_a_month_boundary_gives_each_share_its_own_segment()
{
var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin,
[
Manual(InBerlin(2026, 8, 1, 9), 700),
Manual(InBerlin(2026, 9, 16, 18), 746),
]));
var shares = result.Skip(1).ToList();
Assert.Equal(2, shares.Count);
Assert.All(shares, c =>
{
Assert.True(c.Divided);
Assert.Equal(ReadingQuality.Estimated, c.Quality);
});
Assert.Equal(InBerlin(2026, 8, 1, 9), shares[0].IntervalStart);
Assert.Equal(InBerlin(2026, 9, 1), shares[0].IntervalEnd);
Assert.Equal(InBerlin(2026, 8, 31, 23, 59, 59), shares[0].Time);
Assert.Equal(InBerlin(2026, 9, 1), shares[1].IntervalStart);
Assert.Equal(InBerlin(2026, 9, 16, 18), shares[1].IntervalEnd);
Assert.Equal(InBerlin(2026, 9, 16, 18), shares[1].Time);
Assert.Equal(46, shares.Sum(c => c.Amount), 9);
}
[Fact]
public void Attribution_segments_tile_the_interval_at_local_month_starts()
{
var from = InBerlin(2026, 5, 20, 7);
var to = InBerlin(2026, 8, 3, 21);
var segments = GapAttribution.Attribute(from, to, to, 1000, Berlin);
Assert.Equal([from, InBerlin(2026, 6, 1), InBerlin(2026, 7, 1), InBerlin(2026, 8, 1)], segments.Select(s => s.From));
Assert.Equal([InBerlin(2026, 6, 1), InBerlin(2026, 7, 1), InBerlin(2026, 8, 1), to], segments.Select(s => s.To));
Assert.All(segments, s => Assert.True(s.Time >= s.From && s.Time <= s.To, $"{s.Time:O} lies outside its segment"));
}
[Fact]
public void An_unexplained_decrease_marks_its_interval_as_a_gap()
{
var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, TimeZoneInfo.Utc,
[Reading(1, Month(2023, 1), 100), Reading(1, Month(2023, 2), 60), Reading(1, Month(2023, 3), 70)]));
Assert.Equal([CoverageGapReason.None, CoverageGapReason.UnexplainedDecrease, CoverageGapReason.None], result.Select(c => c.Gap));
Assert.Equal(Month(2023, 2), result[1].IntervalStart);
Assert.Equal(Month(2023, 3), result[1].IntervalEnd);
Assert.Equal(0, result[1].Amount);
}
[Theory]
[InlineData(null, CoverageGapReason.ResetWithoutPrevious)]
[InlineData(170d, CoverageGapReason.None)]
public void A_reset_is_a_gap_only_when_it_does_not_say_where_the_old_register_stopped(double? prevValue, CoverageGapReason expected)
{
var result = _engine.Normalize(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)],
Events = [Reset(1, Month(2023, 3), newValue: 0, prevValue: prevValue)],
});
Assert.Equal(expected, result[^1].Gap);
Assert.Equal(Month(2023, 3), result[^1].IntervalStart);
}
[Fact]
public void A_swap_with_its_amount_is_covered_time()
{
// The water register …861 → 2 books 12 m³ across the swap month: no hole.
var result = _engine.Normalize(new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m³" },
Readings = [Reading(1, Month(2023, 1), 848), Reading(1, Month(2023, 2), 861), Reading(1, Month(2023, 3), 2), Reading(1, Month(2023, 4), 15)],
Events = [Swap(1, Month(2023, 3), prevValue: 861, newValue: 2, amount: 12)],
});
Assert.DoesNotContain(result, c => c.Gap != CoverageGapReason.None);
Assert.Equal(Month(2023, 3), result[2].IntervalStart);
Assert.Equal(Month(2023, 4), result[2].IntervalEnd);
}
[Fact]
public void Every_row_names_the_whole_reading_interval_it_came_from()
{
// A-03: shares are classified and told apart by their source interval, not by their own segment.
var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin,
[
Manual(InBerlin(2026, 8, 2, 9), 700),
Manual(InBerlin(2026, 8, 10, 9), 720),
Manual(InBerlin(2026, 9, 16, 18), 766),
]));
var inside = result.Single(c => c.IntervalEnd == InBerlin(2026, 8, 10, 9));
var shares = result.Where(c => c.Divided).ToList();
Assert.Equal((inside.IntervalStart, inside.IntervalEnd), (inside.SourceStart, inside.SourceEnd));
Assert.Equal(2, shares.Count);
Assert.All(shares, c =>
{
Assert.Equal(InBerlin(2026, 8, 10, 9), c.SourceStart);
Assert.Equal(InBerlin(2026, 9, 16, 18), c.SourceEnd);
});
Assert.NotEqual(shares[0].IntervalEnd, shares[1].IntervalEnd);
}
[Fact]
public void A_register_that_stands_still_across_a_month_boundary_is_one_row_exact_in_every_month()
{
// m7 #2, A-02: nothing moved from 20 June to 20 July. One row, zero, flagged as month-exact, and not
// estimated — nothing about it was inferred.
var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin,
[Manual(InBerlin(2026, 6, 20, 12), 130), Manual(InBerlin(2026, 7, 20, 12), 130)]));
var still = result[^1];
Assert.Equal(2, result.Count);
Assert.Equal(0, still.Amount);
Assert.True(still.Divided);
Assert.Equal(ReadingQuality.Manual, still.Quality);
Assert.Equal(InBerlin(2026, 6, 20, 12), still.IntervalStart);
Assert.Equal(InBerlin(2026, 7, 20, 12), still.IntervalEnd);
}
[Fact]
public void A_standstill_inside_one_month_or_a_rejected_decrease_is_not_flagged()
{
var inside = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin,
[Manual(InBerlin(2026, 6, 2, 12), 130), Manual(InBerlin(2026, 6, 20, 12), 130)]));
var decrease = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin,
[Manual(InBerlin(2026, 6, 20, 12), 130), Manual(InBerlin(2026, 7, 20, 12), 90)]));
Assert.False(inside[^1].Divided);
Assert.False(decrease[^1].Divided);
Assert.Equal(CoverageGapReason.UnexplainedDecrease, decrease[^1].Gap);
}
[Theory]
[InlineData(MeterMode.RuntimeCounter)]
[InlineData(MeterMode.DirectDelta)]
[InlineData(MeterMode.GenerationCounter)]
public void Zero_across_a_month_boundary_is_exact_in_every_month_for_every_register_like_mode(MeterMode mode)
{
var first = mode == MeterMode.DirectDelta ? 4 : 130;
var second = mode == MeterMode.DirectDelta ? 0 : 130;
var result = _engine.Normalize(Context(mode, Berlin,
[Manual(InBerlin(2026, 6, 20, 12), first), Manual(InBerlin(2026, 7, 20, 12), second)]));
Assert.Equal(0, result[^1].Amount);
Assert.True(result[^1].Divided);
Assert.Equal(ReadingQuality.Manual, result[^1].Quality);
}
[Fact]
public void Burner_hours_that_moved_across_a_month_boundary_stay_undivided()
{
var result = _engine.Normalize(Context(MeterMode.RuntimeCounter, Berlin,
[Manual(InBerlin(2026, 6, 20, 12), 130), Manual(InBerlin(2026, 7, 20, 12), 150)]));
Assert.False(result[^1].Divided);
Assert.Equal(20, result[^1].Amount);
}
// ---- Midnight stamps (D-11) ------------------------------------------------------------------
[Theory]
[InlineData("Europe/Berlin")]
[InlineData("America/New_York")]
[InlineData("UTC")]
public void A_daily_snapshot_at_local_midnight_is_booked_in_the_day_it_closes(string zoneId)
{
var zone = Zone(zoneId);
var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, zone,
[
Measured(Local(zone, 2026, 9, 10), 100),
Measured(Local(zone, 2026, 9, 11), 105),
Measured(Local(zone, 2026, 9, 12), 112),
]));
// The opening balance describes no time before its midnight, so it stays on it.
Assert.Equal(Local(zone, 2026, 9, 10), result[0].Time);
Assert.Equal([Local(zone, 2026, 9, 10, 23, 59, 59), Local(zone, 2026, 9, 11, 23, 59, 59)], result.Skip(1).Select(c => c.Time));
Assert.Equal([new DateOnly(2026, 9, 10), new DateOnly(2026, 9, 11)], result.Skip(1).Select(c => LocalDate(c.Time, zone)));
Assert.Equal([5d, 7d], result.Skip(1).Select(c => c.Amount));
Assert.Equal([Local(zone, 2026, 9, 11), Local(zone, 2026, 9, 12)], result.Skip(1).Select(c => c.IntervalEnd!.Value));
Assert.All(result.Skip(1), c => Assert.Equal(ReadingQuality.Measured, c.Quality));
}
[Fact]
public void A_daily_snapshot_after_the_long_autumn_day_is_stamped_inside_that_day()
{
// 25 October 2026 has 25 hours in Berlin.
var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin,
[Measured(InBerlin(2026, 10, 25), 100), Measured(InBerlin(2026, 10, 26), 110)]));
Assert.Equal(new DateOnly(2026, 10, 25), LocalDate(result[1].Time, Berlin));
Assert.Equal(TimeSpan.FromHours(25), result[1].IntervalEnd - result[1].IntervalStart);
}
[Theory]
[InlineData(MeterMode.CumulativeCounter)]
[InlineData(MeterMode.RuntimeCounter)]
[InlineData(MeterMode.DirectDelta)]
[InlineData(MeterMode.InstantRate)]
public void The_midnight_that_ends_a_month_books_its_row_on_that_months_last_day(MeterMode mode)
{
var result = _engine.Normalize(Context(mode, Berlin,
[Manual(InBerlin(2026, 8, 20, 12), 100), Manual(InBerlin(2026, 9, 1), 130)]));
var closing = result[^1];
Assert.Equal(InBerlin(2026, 8, 31, 23, 59, 59), closing.Time);
Assert.Equal(InBerlin(2026, 8, 20, 12), closing.IntervalStart);
Assert.Equal(InBerlin(2026, 9, 1), closing.IntervalEnd);
}
[Fact]
public void A_divided_interval_that_closes_at_a_midnight_stamps_its_last_share_in_the_day_before()
{
var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, Berlin,
[Manual(InBerlin(2026, 8, 20, 12), 100), Manual(InBerlin(2026, 9, 10), 142)]));
var shares = result.Skip(1).ToList();
Assert.Equal([InBerlin(2026, 8, 31, 23, 59, 59), InBerlin(2026, 9, 9, 23, 59, 59)], shares.Select(c => c.Time));
Assert.Equal([InBerlin(2026, 9, 1), InBerlin(2026, 9, 10)], shares.Select(c => c.IntervalEnd!.Value));
Assert.All(shares, c => Assert.True(c.Divided));
}
[Fact]
public void A_day_dated_import_at_utc_midnight_closes_the_day_before_only_where_that_is_local_midnight()
{
// The importer stamps "20.07.2026" at 00:00 UTC. In a UTC instance that is the midnight that ends
// 19 July; in Berlin it is 02:00 on the 20th, an instant inside the day.
Reading[] readings = [DayReading(1, Utc(2026, 7, 15), 600), DayReading(1, Utc(2026, 7, 20), 650)];
var inUtc = _engine.Normalize(Context(MeterMode.RuntimeCounter, TimeZoneInfo.Utc, readings));
var inBerlin = _engine.Normalize(Context(MeterMode.RuntimeCounter, Berlin, readings));
Assert.Equal(Utc(2026, 7, 20).AddSeconds(-1), inUtc[^1].Time);
Assert.Equal(Utc(2026, 7, 20), inBerlin[^1].Time);
Assert.Equal(Utc(2026, 7, 20), inUtc[^1].IntervalEnd);
}
[Fact]
public void Where_midnight_happens_twice_only_the_first_starts_the_day()
{
// Havana leaves daylight time at 01:00 on 1 November 2026: 00:00 is at 04:00 and again at 05:00 UTC.
var havana = TimeZoneInfo.FindSystemTimeZoneById("America/Havana");
Assert.True(GapAttribution.IsLocalMidnight(Utc(2026, 11, 1, 4), havana));
Assert.False(GapAttribution.IsLocalMidnight(Utc(2026, 11, 1, 5), havana));
Assert.True(GapAttribution.IsLocalMonthStart(Utc(2026, 11, 1, 4), havana));
}
[Fact]
public void A_midnight_stamp_never_leaves_an_interval_shorter_than_a_second()
{
var end = InBerlin(2026, 9, 1);
var start = end.AddMilliseconds(-400);
var stamp = GapAttribution.CloseStamp(start, end, Berlin);
Assert.True(stamp > start && stamp < end, $"{stamp:O} lies outside the interval");
Assert.Equal(end, GapAttribution.CloseStamp(end, end, Berlin));
Assert.Equal(InBerlin(2026, 9, 1, 0, 0, 1), GapAttribution.CloseStamp(start, InBerlin(2026, 9, 1, 0, 0, 1), Berlin));
}
// ---- Runtime ---------------------------------------------------------------------------------
[Fact]
public void Burner_rows_run_from_the_previous_reading_and_a_long_silence_stays_one_interval()
{
// The seeded burner: 0 h on 18.10.2010, then nothing until 7758 h on 14.10.2022.
var result = _engine.Normalize(Context(MeterMode.RuntimeCounter, Berlin,
[
DayReading(1, Utc(2010, 10, 18), 0),
DayReading(1, Utc(2022, 10, 14), 7758),
DayReading(1, Utc(2022, 11, 28), 7758),
]));
Assert.Equal(3, result.Count);
Assert.True(result[0].OpeningBalance);
Assert.Equal(Utc(2010, 10, 18), result[1].IntervalStart);
Assert.Equal(Utc(2022, 10, 14), result[1].IntervalEnd);
Assert.Equal(7758, result[1].Amount);
Assert.Equal(Utc(2022, 10, 14), result[1].Time);
Assert.Equal(Utc(2022, 10, 14), result[2].IntervalStart);
// The twelve years of hours are not divided; the burner that then stood still across 1 November is
// exactly zero in October and November alike (A-02).
Assert.False(result[1].Divided);
Assert.True(result[2].Divided);
Assert.Equal(0, result[2].Amount);
}
[Fact]
public void Burner_hours_that_go_backwards_or_reset_blind_are_gaps()
{
var result = _engine.Normalize(new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.RuntimeCounter, Unit = "h" },
Readings = [Reading(1, Month(2023, 1), 100), Reading(1, Month(2023, 2), 40), Reading(1, Month(2023, 3), 50), Reading(1, Month(2023, 4), 10)],
Events = [Reset(1, Month(2023, 4), newValue: 0)],
});
Assert.Equal(
[CoverageGapReason.None, CoverageGapReason.UnexplainedDecrease, CoverageGapReason.None, CoverageGapReason.ResetWithoutPrevious],
result.Select(c => c.Gap));
Assert.Equal([100d, 0d, 10d, 10d], result.Select(c => c.Amount));
}
[Fact]
public void A_first_burner_reading_covers_its_label_month_or_starts_at_the_install_date()
{
var label = Assert.Single(_engine.Normalize(Context(MeterMode.RuntimeCounter, Berlin, [Reading(1, Month(2023, 1), 7952)])));
var installed = Assert.Single(_engine.Normalize(Context(
MeterMode.RuntimeCounter, Berlin, [Manual(InBerlin(2023, 1, 20, 10), 12)], installedAt: new DateOnly(2023, 1, 2))));
Assert.Equal(InBerlin(2023, 1, 1), label.IntervalStart);
Assert.Equal(InBerlin(2023, 2, 1), label.IntervalEnd);
Assert.False(label.OpeningBalance);
Assert.Equal(InBerlin(2023, 1, 2), installed.IntervalStart);
Assert.False(installed.OpeningBalance);
}
// ---- Tank ------------------------------------------------------------------------------------
[Fact]
public void A_tank_row_covers_the_time_between_two_dipsticks_and_early_deliveries_cover_nothing()
{
var result = _engine.Normalize(new NormalizationContext
{
Meter = new MeterConfig
{
MeterId = 30,
Mode = MeterMode.ConsumableBalance,
Unit = "L",
Tank = new TankConfig { Capacity = 7000, Calibration = new CalibrationCurve(7000d / 150d) },
},
Events =
[
Delivery(30, Utc(2020, 9, 7), 3500),
TankLevelCm(30, Utc(2022, 9, 8), 35),
TankLevelCm(30, Utc(2022, 10, 14), 34),
Delivery(30, Utc(2022, 12, 5), 3000),
TankLevelCm(30, Utc(2022, 12, 5), 85),
],
TimeZone = Berlin,
});
Assert.Equal(2, result.Count);
Assert.Equal([Utc(2022, 9, 8), Utc(2022, 10, 14)], result.Select(c => c.IntervalStart!.Value));
Assert.Equal([Utc(2022, 10, 14), Utc(2022, 12, 5)], result.Select(c => c.IntervalEnd!.Value));
Assert.Equal([Utc(2022, 10, 14), Utc(2022, 12, 5)], result.Select(c => c.Time));
Assert.DoesNotContain(result, c => c.OpeningBalance || c.Divided || c.Gap != CoverageGapReason.None);
}
[Fact]
public void A_dipstick_at_local_midnight_is_booked_in_the_day_it_closes()
{
var result = _engine.Normalize(new NormalizationContext
{
Meter = new MeterConfig { MeterId = 30, Mode = MeterMode.ConsumableBalance, Unit = "L" },
Events =
[
new MeterEvent { MeterId = 30, Time = InBerlin(2026, 8, 15, 10), EventType = MeterEventType.TankLevel, Amount = 3000, Unit = "L" },
new MeterEvent { MeterId = 30, Time = InBerlin(2026, 9, 1), EventType = MeterEventType.TankLevel, Amount = 2800, Unit = "L" },
],
TimeZone = Berlin,
});
var row = Assert.Single(result);
Assert.Equal(InBerlin(2026, 8, 31, 23, 59, 59), row.Time);
Assert.Equal(InBerlin(2026, 9, 1), row.IntervalEnd);
Assert.Equal(200, row.Amount);
}
[Fact]
public void A_tank_nobody_drew_from_across_a_month_boundary_is_exact_in_every_month_but_a_level_that_rose_is_not()
{
MeterEvent Level(DateTimeOffset time, double litres) =>
new() { MeterId = 30, Time = time, EventType = MeterEventType.TankLevel, Amount = litres, Unit = "L" };
var result = _engine.Normalize(new NormalizationContext
{
Meter = new MeterConfig { MeterId = 30, Mode = MeterMode.ConsumableBalance, Unit = "L" },
Events = [Level(InBerlin(2026, 6, 20, 10), 3000), Level(InBerlin(2026, 7, 20, 10), 3000), Level(InBerlin(2026, 8, 20, 10), 3100)],
TimeZone = Berlin,
});
Assert.Equal([true, false], result.Select(c => c.Divided));
Assert.Equal([ReadingQuality.Manual, ReadingQuality.Estimated], result.Select(c => c.Quality));
Assert.All(result, c => Assert.Equal(0, c.Amount));
}
// ---- Instant rate ----------------------------------------------------------------------------
[Fact]
public void A_silence_far_longer_than_the_sensors_rhythm_is_a_sample_gap_that_keeps_its_integral()
{
// 2 kW sampled every five minutes from 10:00 to 11:00, then nothing until 14:00, then 14:05.
var samples = Enumerable.Range(0, 13).Select(i => Measured(Utc(2024, 6, 1, 10).AddMinutes(5 * i), 2))
.Append(Measured(Utc(2024, 6, 1, 14), 2))
.Append(Measured(Utc(2024, 6, 1, 14, 5), 2))
.ToList();
var result = _engine.Normalize(Context(MeterMode.InstantRate, TimeZoneInfo.Utc, samples));
var gap = Assert.Single(result, c => c.Gap != CoverageGapReason.None);
Assert.Equal(CoverageGapReason.SampleGap, gap.Gap);
Assert.Equal(ReadingQuality.Estimated, gap.Quality);
Assert.Equal(6, gap.Amount, 9); // 2 kW over three hours, still counted
Assert.Equal(Utc(2024, 6, 1, 11), gap.IntervalStart);
Assert.Equal(Utc(2024, 6, 1, 14), gap.IntervalEnd);
Assert.Equal(13, result.Count(c => c.Gap == CoverageGapReason.None && c.Quality == ReadingQuality.Measured));
Assert.Equal(CoverageGapReason.None, result.Single(c => c.IntervalStart == Utc(2024, 6, 1, 14)).Gap);
}
[Fact]
public void Each_sample_interval_is_a_row_interval_and_the_first_sample_only_seeds_the_integral()
{
var result = _engine.Normalize(Context(MeterMode.InstantRate, TimeZoneInfo.Utc,
[Measured(Utc(2024, 6, 1, 10), 0), Measured(Utc(2024, 6, 1, 11), 2), Measured(Utc(2024, 6, 1, 12), 4)]));
Assert.Equal([Utc(2024, 6, 1, 10), Utc(2024, 6, 1, 11)], result.Select(c => c.IntervalStart!.Value));
Assert.Equal([Utc(2024, 6, 1, 11), Utc(2024, 6, 1, 12)], result.Select(c => c.IntervalEnd!.Value));
Assert.DoesNotContain(result, c => c.OpeningBalance || c.Gap != CoverageGapReason.None);
}
[Theory]
[InlineData(new[] { 1, 1, 1, 1 }, 61)] // a fast sensor: ten minutes would be too eager, an hourly interval is the floor
[InlineData(new[] { 60, 60, 60 }, 600)] // hourly: a gap is longer than ten hours
[InlineData(new[] { 5, 5, 5, 180 }, 61)] // the gap itself does not raise the threshold: the median stays 5
[InlineData(new[] { 10, 10, 20, 20 }, 150)] // an even count takes the middle of the two middle steps
public void The_sample_gap_threshold_is_ten_median_intervals_but_never_under_an_hourly_interval(int[] stepMinutes, int expectedMinutes)
{
var time = Utc(2024, 6, 1);
var samples = new List<Reading> { Measured(time, 1) };
foreach (var step in stepMinutes)
{
time = time.AddMinutes(step);
samples.Add(Measured(time, 1));
}
var thresholds = InstantRateNormalizer.SampleGapThresholds(samples);
Assert.Equal(stepMinutes.Length, thresholds.Length);
Assert.All(thresholds, t => Assert.Equal(TimeSpan.FromMinutes(expectedMinutes), t));
}
[Fact]
public void A_single_sample_has_no_interval_to_judge()
{
Assert.Empty(InstantRateNormalizer.SampleGapThresholds([Measured(Utc(2024, 6, 1), 1)]));
}
[Fact]
public void Hourly_polls_a_few_seconds_late_after_fast_samples_are_not_gaps()
{
// m7 #6: ten-second samples for a while, then a poller every hour, five seconds late each time.
var samples = Enumerable.Range(0, 360).Select(i => Measured(Utc(2024, 6, 1, 8).AddSeconds(10 * i), 2)).ToList();
var last = samples[^1].Time;
samples.AddRange(Enumerable.Range(1, 30).Select(i => Measured(last.AddSeconds(3605 * i), 2)));
var result = _engine.Normalize(Context(MeterMode.InstantRate, TimeZoneInfo.Utc, samples));
Assert.DoesNotContain(result, c => c.Gap != CoverageGapReason.None);
Assert.Equal(2 * (samples[^1].Time - samples[0].Time).TotalHours, result.Sum(c => c.Amount), 6);
}
[Fact]
public void A_silence_barely_over_an_hour_between_fast_samples_is_within_the_poll_slack()
{
var samples = Enumerable.Range(0, 30).Select(i => Measured(Utc(2024, 6, 1, 8).AddSeconds(10 * i), 2)).ToList();
var silent = samples[^1].Time;
samples.Add(Measured(silent.AddSeconds(3630), 2));
samples.Add(Measured(silent.AddSeconds(3640), 2));
samples.Add(Measured(silent.AddSeconds(3640 + 3662), 2));
samples.AddRange(Enumerable.Range(1, 30).Select(i => Measured(silent.AddSeconds(3640 + 3662 + (10 * i)), 2)));
var result = _engine.Normalize(Context(MeterMode.InstantRate, TimeZoneInfo.Utc, samples));
// An hour and thirty seconds is an hourly interval; an hour and a minute and two seconds is not.
var gap = Assert.Single(result, c => c.Gap == CoverageGapReason.SampleGap);
Assert.Equal(silent.AddSeconds(3640), gap.IntervalStart);
}
[Fact]
public void An_outage_is_judged_by_the_rhythm_around_it_not_by_the_meters_whole_history()
{
// A year of hourly polls would put the whole-history threshold at ten hours and hide a five-hour
// outage of the five-minute sensor that replaced them; the rhythm around the outage finds it.
var hourly = Enumerable.Range(0, 200).Select(i => Measured(Utc(2024, 1, 1).AddHours(i), 1));
var fastStart = Utc(2024, 1, 1).AddHours(199);
var fast = Enumerable.Range(1, 60).Select(i => Measured(fastStart.AddMinutes(5 * i), 1)).ToList();
var resumed = fast[^1].Time.AddHours(5);
var after = Enumerable.Range(0, 60).Select(i => Measured(resumed.AddMinutes(5 * i), 1));
var result = _engine.Normalize(Context(MeterMode.InstantRate, TimeZoneInfo.Utc, [.. hourly, .. fast, .. after]));
var gap = Assert.Single(result, c => c.Gap == CoverageGapReason.SampleGap);
Assert.Equal(fast[^1].Time, gap.IntervalStart);
Assert.Equal(resumed, gap.IntervalEnd);
}
// ---- Direct delta ----------------------------------------------------------------------------
[Fact]
public void Direct_delta_rows_cover_the_time_since_the_previous_report()
{
var result = _engine.Normalize(Context(MeterMode.DirectDelta, Berlin,
[
Manual(InBerlin(2026, 9, 1, 6), 5),
Manual(InBerlin(2026, 9, 1, 12), 3),
Manual(InBerlin(2026, 9, 2), 4),
]));
Assert.True(result[0].OpeningBalance);
Assert.Equal(result[0].IntervalEnd, result[0].IntervalStart);
Assert.Equal([InBerlin(2026, 9, 1, 6), InBerlin(2026, 9, 1, 12)], result.Skip(1).Select(c => c.IntervalStart!.Value));
Assert.Equal(InBerlin(2026, 9, 1, 23, 59, 59), result[2].Time);
Assert.Equal([5d, 3d, 4d], result.Select(c => c.Amount));
}
[Fact]
public void A_first_direct_delta_is_an_opening_balance_even_when_the_install_date_is_known()
{
// m7 #1: the first increment covers one reporting step nobody recorded, not the years since the meter
// was installed — otherwise 0.05 kWh would claim coverage all the way back to 2020.
var first = Assert.Single(_engine.Normalize(Context(
MeterMode.DirectDelta, Berlin, [Measured(InBerlin(2026, 9, 19, 10), 0.05)], installedAt: new DateOnly(2020, 1, 1))));
Assert.True(first.OpeningBalance);
Assert.Equal(InBerlin(2026, 9, 19, 10), first.IntervalStart);
Assert.Equal(first.IntervalEnd, first.IntervalStart);
Assert.Empty(MeterVault.Core.Analysis.Coverage.CoverageBuilder.Build([first], Berlin));
}
[Fact]
public void A_register_still_starts_its_first_reading_at_the_install_date()
{
var register = Assert.Single(_engine.Normalize(Context(
MeterMode.CumulativeCounter, Berlin, [Manual(InBerlin(2026, 9, 1, 6), 5)], installedAt: new DateOnly(2026, 8, 31))));
Assert.False(register.OpeningBalance);
Assert.Equal(InBerlin(2026, 8, 31), register.IntervalStart);
}
[Fact]
public void A_direct_delta_month_row_covers_its_month_whatever_came_before_it()
{
// Behind UTC the month rows are stamped at the local month start, and still cover whole months.
var result = _engine.Normalize(Context(MeterMode.DirectDelta, NewYork,
[
Manual(Local(NewYork, 2026, 6, 20, 12), 7),
Reading(1, Month(2026, 7), 960),
Reading(1, Month(2026, 8), 1000),
]));
var months = result.Where(c => c.Amount >= 960).ToList();
Assert.Equal([Local(NewYork, 2026, 7, 1), Local(NewYork, 2026, 8, 1)], months.Select(c => c.IntervalStart!.Value));
Assert.Equal([Local(NewYork, 2026, 8, 1), Local(NewYork, 2026, 9, 1)], months.Select(c => c.IntervalEnd!.Value));
Assert.Equal([Local(NewYork, 2026, 7, 1), Local(NewYork, 2026, 8, 1)], months.Select(c => c.Time));
Assert.DoesNotContain(months, c => c.OpeningBalance);
}
// ---- Coalesce --------------------------------------------------------------------------------
[Fact]
public void Rows_merged_onto_one_stamp_describe_both_intervals_and_keep_every_warning()
{
var at = Utc(2026, 8, 31, 21, 59);
var merged = Assert.Single(NormalizationEngine.Coalesce(
[
new Consumption
{
MeterId = 1, Time = at, Amount = 1, IntervalStart = at.AddHours(-2), IntervalEnd = at, Divided = true,
SourceStart = at.AddDays(-2), SourceEnd = at.AddMinutes(1),
},
new Consumption
{
MeterId = 1, Time = at, Amount = 2, IntervalStart = at.AddHours(-5), IntervalEnd = at.AddHours(1),
SourceStart = at.AddHours(-5), SourceEnd = at.AddHours(1), OpeningBalance = true, Gap = CoverageGapReason.SampleGap,
},
]));
Assert.Equal(3, merged.Amount);
Assert.Equal(at.AddHours(-5), merged.IntervalStart);
Assert.Equal(at.AddHours(1), merged.IntervalEnd);
Assert.Equal(at.AddDays(-2), merged.SourceStart);
Assert.Equal(at.AddHours(1), merged.SourceEnd);
Assert.False(merged.Divided);
Assert.True(merged.OpeningBalance);
Assert.Equal(CoverageGapReason.SampleGap, merged.Gap);
Assert.Equal(ReadingQuality.Estimated, merged.Quality);
}
[Fact]
public void Two_divided_shares_merged_onto_one_stamp_stay_a_divided_share()
{
var at = Utc(2026, 8, 31, 21, 59);
var merged = Assert.Single(NormalizationEngine.Coalesce(
[
new Consumption { MeterId = 1, Time = at, Amount = 1, IntervalStart = at.AddDays(-3), IntervalEnd = at.AddSeconds(1), Divided = true },
new Consumption { MeterId = 1, Time = at, Amount = 1, IntervalStart = at.AddDays(-1), IntervalEnd = at.AddSeconds(1), Divided = true },
]));
Assert.True(merged.Divided);
Assert.Equal(at.AddDays(-3), merged.IntervalStart);
}
[Fact]
public void A_live_reading_merged_with_a_month_row_spans_the_whole_month()
{
// New York: HA polled at exactly the local midnight the "Juli" row is stamped at. The two rows share
// one key; the merged row covers July.
var julyMidnight = Local(NewYork, 2026, 7, 1);
var result = _engine.Normalize(Context(MeterMode.CumulativeCounter, NewYork,
[
Reading(1, Month(2026, 6), 1000),
Reading(1, Month(2026, 7), 1300),
Measured(julyMidnight, 1005),
]));
var july = Assert.Single(result, c => c.Time == julyMidnight);
Assert.Equal(300, july.Amount, 9);
Assert.Equal(julyMidnight, july.IntervalStart);
Assert.Equal(Local(NewYork, 2026, 8, 1), july.IntervalEnd);
}
}
@@ -0,0 +1,163 @@
using MeterVault.Core.Analysis.Virtual;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// The formula grammar is user input evaluated on every read, so the parser is the safety boundary (D-26): it
/// accepts only numbers, <c>m&lt;id&gt;</c>, unary minus, <c>+ - * /</c> and parentheses, reports every rejection
/// with a position instead of throwing, and cannot be driven into a stack overflow. Ported from the old
/// ExpressionEvaluatorTests; the one deliberate change is that an unknown identifier is now an error, not 0.
/// </summary>
public sealed class FormulaParserTests
{
private static double Evaluate(string text) =>
Formula.Parse(text).Evaluate(id => id switch { 1 => 411, 2 => 416, _ => throw new KeyNotFoundException($"m{id}") });
[Theory]
[InlineData("1 + 2 * 3", 7)]
[InlineData("(1 + 2) * 3", 9)]
[InlineData("-5", -5)]
[InlineData("--5", 5)]
[InlineData("+5", 5)]
[InlineData("2 * -3", -6)]
[InlineData("-(1 + 2) * 3", -9)]
[InlineData("10 / 4", 2.5)]
[InlineData("2 - 3 - 4", -5)] // left-associative
[InlineData("8 / 4 / 2", 1)] // left-associative
[InlineData("0.5 * 4", 2)]
[InlineData(".5 + 5.", 5.5)]
[InlineData("m1 - m2", -5)] // Netz Einsparung Okt 2022: Haus 411 Netz 416
[InlineData("m1 + m2", 827)]
[InlineData("m1+m2", 827)]
[InlineData(" ( m1 ) - m2 ", -5)]
public void Evaluates_arithmetic_over_numbers_and_meter_references(string text, double expected)
{
Assert.Equal(expected, Evaluate(text), 9);
}
[Fact]
public void Numbers_are_invariant_decimals_whatever_the_current_culture()
{
var previous = Thread.CurrentThread.CurrentCulture;
try
{
Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("de-DE");
Assert.Equal(0.25, Formula.Parse("0.25").Evaluate(_ => 0), 12);
Assert.Equal(FormulaErrorKind.UnexpectedCharacter, FormulaParser.Parse("0,25").Error!.Kind);
}
finally
{
Thread.CurrentThread.CurrentCulture = previous;
}
}
[Theory]
[InlineData("1 +", FormulaErrorKind.UnexpectedEnd, 3, null)]
[InlineData("(1 + 2", FormulaErrorKind.MissingClosingParenthesis, 0, "(")]
[InlineData("m1 + (m2 * (3 - 1)", FormulaErrorKind.MissingClosingParenthesis, 5, "(")]
[InlineData("1 2", FormulaErrorKind.UnexpectedToken, 2, "2")]
[InlineData("m1 m2", FormulaErrorKind.UnexpectedToken, 3, "m2")]
[InlineData(")", FormulaErrorKind.UnexpectedToken, 0, ")")]
[InlineData("(m1))", FormulaErrorKind.UnexpectedToken, 4, ")")]
[InlineData("m1 * * m2", FormulaErrorKind.UnexpectedToken, 5, "*")]
[InlineData("1 % 2", FormulaErrorKind.UnexpectedCharacter, 2, "%")]
[InlineData("1.2.3", FormulaErrorKind.InvalidNumber, 0, "1.2.3")]
[InlineData(".", FormulaErrorKind.InvalidNumber, 0, ".")]
[InlineData("1e3", FormulaErrorKind.UnexpectedToken, 1, "e3")]
[InlineData("2m1", FormulaErrorKind.UnexpectedToken, 1, "m1")]
[InlineData("m99999999999", FormulaErrorKind.MeterIdOutOfRange, 0, "m99999999999")]
public void Rejects_malformed_formulas_with_the_position_and_token(string text, FormulaErrorKind kind, int position, string? token)
{
var result = FormulaParser.Parse(text);
Assert.False(result.Success);
Assert.Equal(new FormulaError(kind, position, token), result.Error);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Empty_text_is_an_error_not_an_exception(string? text)
{
Assert.Equal(FormulaErrorKind.Empty, FormulaParser.Parse(text).Error!.Kind);
}
[Theory]
[InlineData("unknown + 1", "unknown", 0)] // the old evaluator read this as 1
[InlineData("m1 - n2", "n2", 5)]
[InlineData("M1 + m2", "M1", 0)]
[InlineData("m1a", "m1a", 0)]
[InlineData("m_1", "m_1", 0)]
[InlineData("m", "m", 0)]
[InlineData("sum(m1)", "sum", 0)]
[InlineData("m1 + Hausverbrauch", "Hausverbrauch", 5)]
public void Only_m_digits_identifiers_are_meter_references_and_anything_else_is_named(string text, string identifier, int position)
{
Assert.Equal(new FormulaError(FormulaErrorKind.UnknownIdentifier, position, identifier), FormulaParser.Parse(text).Error);
}
[Fact]
public void Nesting_64_parentheses_deep_is_accepted_and_65_is_rejected_at_the_65th()
{
static string Nested(int depth) => new string('(', depth) + "m1" + new string(')', depth);
Assert.True(FormulaParser.Parse(Nested(FormulaParser.MaxDepth)).Success);
var tooDeep = FormulaParser.Parse(Nested(FormulaParser.MaxDepth + 1));
Assert.Equal(new FormulaError(FormulaErrorKind.TooDeep, 64, "("), tooDeep.Error);
}
[Fact]
public void Two_thousand_characters_are_accepted_and_2001_rejected()
{
var chain = "m1" + string.Concat(Enumerable.Repeat(" + m1", 399)); // 1,997 characters
var exactly = chain + " ";
Assert.Equal(FormulaParser.MaxLength, exactly.Length);
var accepted = FormulaParser.Parse(exactly);
Assert.True(accepted.Success);
Assert.Equal(400 * 411, accepted.Formula.Evaluate(_ => 411), 6);
Assert.Equal(FormulaErrorKind.TooLong, FormulaParser.Parse(exactly + " ").Error!.Kind);
}
[Fact]
public void A_hundred_thousand_open_parentheses_are_rejected_without_overflowing_the_stack()
{
Assert.Equal(FormulaErrorKind.TooLong, FormulaParser.Parse(new string('(', 100_000)).Error!.Kind);
Assert.Equal(FormulaErrorKind.TooDeep, FormulaParser.Parse(new string('(', 1_999)).Error!.Kind);
}
[Fact]
public void Long_operator_chains_and_sign_runs_parse_and_evaluate_without_recursion()
{
var signs = FormulaParser.Parse(new string('-', 1_999) + "1");
Assert.True(signs.Success);
Assert.Equal(-1, signs.Formula.Evaluate(_ => 0));
var product = FormulaParser.Parse("1" + string.Concat(Enumerable.Repeat(" * 1", 499)));
Assert.True(product.Success);
Assert.Equal(1, product.Formula.Evaluate(_ => 0));
Assert.Equal(product.Formula, Formula.Parse(product.Formula.ToString()));
}
[Fact]
public void Scanning_finds_meter_tokens_even_in_text_that_does_not_parse()
{
Assert.Equal([2, 5, 12], FormulaParser.ScanMeterIds("m12 + (m5 * m2 +"));
Assert.Empty(FormulaParser.ScanMeterIds("1.5 + x2"));
Assert.Empty(FormulaParser.ScanMeterIds(null));
}
[Fact]
public void Rewriting_ids_in_text_keeps_the_users_spacing_and_even_a_syntax_error()
{
var map = new Dictionary<int, int> { [1] = 41, [2] = 42 };
Assert.Equal("m41 -( m42 )", FormulaParser.RewriteMeterIds("m1 -( m2 )", id => map[id]));
Assert.Equal("m41 + ", FormulaParser.RewriteMeterIds("m1 + ", id => map[id]));
Assert.Equal("0.1m99 + xm1", FormulaParser.RewriteMeterIds("0.1m2 + xm1", _ => 99)); // "xm1" names no meter
}
}
+158
View File
@@ -0,0 +1,158 @@
using MeterVault.Core.Analysis.Virtual;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// A parsed formula knows its own shape: which meters it reads, whether it is linear (additive, so buckets sum to
/// the total and the quantity can be priced) or a pure sum (so costs can be the sources' own), and a canonical text
/// that parses back to the same formula.
/// </summary>
public sealed class FormulaTests
{
[Fact]
public void Meter_ids_are_distinct_and_ascending_and_references_keep_their_positions()
{
var formula = Formula.Parse("m12 + m3 - m12 * 2");
Assert.Equal([3, 12], formula.MeterIds);
Assert.Equal(
[new FormulaReference(12, 0, 3), new FormulaReference(3, 6, 2), new FormulaReference(12, 11, 3)],
formula.References);
}
[Theory]
[InlineData("m1 + m2", true, true)]
[InlineData("(m1 + m2) * 1", true, true)]
[InlineData("m1 - m2", true, false)]
[InlineData("m1 + m1", true, false)]
[InlineData("2 * (m1 + m2)", true, false)]
[InlineData("0.5 * m1 + m2 / 4", true, false)]
[InlineData("-(m1 - m2)", true, false)]
[InlineData("m1 + 5 - 5", true, true)] // the constants cancel
[InlineData("m1 + 5", false, false)] // a constant term: not additive across buckets
[InlineData("m1 * m2", false, false)]
[InlineData("m1 / m2", false, false)]
[InlineData("1 / m1", false, false)]
[InlineData("m1 / 0", false, false)]
[InlineData("m1 * (1 / 0)", false, false)]
[InlineData("(m1 - m1) * m2", false, false)] // decided by structure, not by the value that cancels
public void Linearity_and_pure_sums_are_recognised(string text, bool linear, bool pureSum)
{
var formula = Formula.Parse(text);
Assert.Equal(linear, formula.IsLinear);
Assert.Equal(pureSum, formula.IsPureSum);
Assert.Equal(linear, formula.Coefficients is not null);
}
[Fact]
public void Coefficients_are_each_meters_weight_in_a_linear_formula()
{
Assert.Equal(new Dictionary<int, double> { [1] = 1, [2] = -1 }, Formula.Parse("m1 - m2").Coefficients);
Assert.Equal(new Dictionary<int, double> { [1] = 0.5, [2] = 0.25 }, Formula.Parse("0.5 * m1 + m2 / 4").Coefficients);
Assert.Equal(new Dictionary<int, double> { [1] = -1, [2] = 1 }, Formula.Parse("-(m1 - m2)").Coefficients);
Assert.Equal(new Dictionary<int, double> { [1] = 0, [2] = 1 }, Formula.Parse("m1 - m1 + m2").Coefficients);
}
[Theory]
[InlineData("m1+m2", "m1 + m2")]
[InlineData("((((m1))))", "m1")]
[InlineData("(m1 + m2) * 2", "(m1 + m2) * 2")]
[InlineData("(m1 - m2) - m3", "m1 - m2 - m3")]
[InlineData("m1 - (m2 - m3)", "m1 - (m2 - m3)")]
[InlineData("m1 - (m2 + m3)", "m1 - (m2 + m3)")]
[InlineData("m1 + (m2 + m3)", "m1 + (m2 + m3)")]
[InlineData("m1 / (m2 * m3)", "m1 / (m2 * m3)")]
[InlineData("m1 * m2 + m3", "m1 * m2 + m3")]
[InlineData("-(m1 + m2)", "-(m1 + m2)")]
[InlineData("-m1 * m2", "-m1 * m2")]
[InlineData("--m1", "--m1")]
[InlineData("+m1", "m1")]
[InlineData("m1 * -m2", "m1 * -m2")]
[InlineData("m1 - -5", "m1 - -5")]
[InlineData("0.50 * m1", "0.50 * m1")]
public void The_canonical_text_has_minimal_parentheses_and_parses_back_to_an_equal_formula(string text, string canonical)
{
var formula = Formula.Parse(text);
Assert.Equal(canonical, formula.ToString());
Assert.Equal(text, formula.Text);
Assert.Equal(formula, Formula.Parse(formula.ToString()));
}
[Fact]
public void Equality_is_structural()
{
Assert.Equal(Formula.Parse("m1+m2"), Formula.Parse("(m1) + m2"));
Assert.Equal(Formula.Parse("m1+m2").GetHashCode(), Formula.Parse("(m1) + m2").GetHashCode());
Assert.Equal(Formula.Parse("1.0 * m1"), Formula.Parse("1 * m1"));
Assert.NotEqual(Formula.Parse("m1 + m2"), Formula.Parse("m2 + m1"));
Assert.NotEqual(Formula.Parse("m1 - m2 - m3"), Formula.Parse("m1 - (m2 - m3)"));
}
[Fact]
public void The_tree_mirrors_precedence()
{
var root = Assert.IsType<BinaryNode>(Formula.Parse("m1 - 2 * m2").Root);
Assert.Equal(FormulaOperator.Subtract, root.Operator);
Assert.Equal(1, Assert.IsType<MeterReferenceNode>(root.Left).MeterId);
var product = Assert.IsType<BinaryNode>(root.Right);
Assert.Equal(FormulaOperator.Multiply, product.Operator);
Assert.Equal(2, Assert.IsType<NumberNode>(product.Left).Value);
Assert.Equal(2, Assert.IsType<MeterReferenceNode>(product.Right).MeterId);
}
[Fact]
public void Division_by_zero_evaluates_to_a_non_finite_number_for_the_caller_to_judge()
{
var ratio = Formula.Parse("m1 / m2");
Assert.True(double.IsPositiveInfinity(ratio.Evaluate(id => id == 1 ? 80 : 0)));
Assert.True(double.IsNaN(ratio.Evaluate(_ => 0)));
}
[Fact]
public void Rewriting_ids_follows_meters_to_new_ids_and_keeps_the_users_text()
{
var map = new Dictionary<int, int> { [1] = 41, [2] = 42 };
var formula = Formula.Parse("m1 + (m2*m1)");
var rewritten = formula.RewriteIds(id => map[id]);
Assert.Equal("m41 + (m42*m41)", rewritten.Text);
Assert.Equal([41, 42], rewritten.MeterIds);
Assert.Equal(Formula.Parse("m41 + m42 * m41"), rewritten);
Assert.Equal(
[new FormulaReference(41, 0, 3), new FormulaReference(42, 8, 3), new FormulaReference(41, 12, 3)],
rewritten.References);
Assert.Equal(Formula.Parse(rewritten.Text), rewritten);
}
[Fact]
public void Rewriting_to_a_negative_id_is_refused()
{
Assert.Throws<ArgumentOutOfRangeException>(() => Formula.Parse("m1").RewriteIds(_ => -1));
}
[Fact]
public void Sum_and_difference_build_the_editors_simple_modes()
{
var sum = Formula.Sum([4, 5, 4]);
Assert.Equal("m4 + m5", sum.ToString());
Assert.True(sum.IsPureSum);
var difference = Formula.Difference(1, [2, 3]);
Assert.Equal("m1 - m2 - m3", difference.ToString());
Assert.Equal(new Dictionary<int, double> { [1] = 1, [2] = -1, [3] = -1 }, difference.Coefficients);
Assert.Throws<ArgumentException>(() => Formula.Sum([]));
Assert.Throws<ArgumentException>(() => Formula.Difference(1, []));
}
[Fact]
public void Parse_throws_only_for_trusted_text_that_turns_out_invalid()
{
Assert.Throws<FormatException>(() => Formula.Parse("m1 +"));
}
}
@@ -0,0 +1,106 @@
using MeterVault.Core.Analysis;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>D-18: the freshness mark is the last reading or event, and only a live source can be stale.</summary>
public sealed class FreshnessRulesTests
{
private static readonly DateTimeOffset Now = new(2026, 9, 19, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void Without_any_reading_or_event_there_is_no_data()
{
var freshness = FreshnessRules.Evaluate(new FreshnessInput(null, null, [], HasLiveSource: true, null), Now);
Assert.Equal(FreshnessState.NoData, freshness.State);
Assert.Null(freshness.LastActivity);
}
[Fact]
public void An_import_only_meter_is_historical_never_stale()
{
var years = Now.AddYears(-3);
var freshness = FreshnessRules.Evaluate(new FreshnessInput(years, null, [years], HasLiveSource: false, null), Now);
Assert.Equal(FreshnessState.Historical, freshness.State);
Assert.Equal(years, freshness.LastActivity);
}
[Fact]
public void A_live_source_is_stale_after_three_of_its_own_intervals()
{
// Readings every 10 minutes; the last one 40 minutes ago is more than 3 × 10 minutes.
var times = Enumerable.Range(0, 21).Select(i => Now.AddMinutes(-40 - (10 * i))).ToList();
var stale = FreshnessRules.Evaluate(new FreshnessInput(times[0], null, times, HasLiveSource: true, null), Now);
var fresh = FreshnessRules.Evaluate(new FreshnessInput(times[0], null, times, HasLiveSource: true, null), times[0].AddMinutes(25));
Assert.Equal(FreshnessState.Stale, stale.State);
Assert.Equal(TimeSpan.FromMinutes(30), stale.StaleAfter);
Assert.Equal(FreshnessState.Live, fresh.State);
}
[Fact]
public void The_poll_interval_keeps_a_rarely_polled_source_live_between_polls()
{
// Two readings a minute apart, but the source is polled hourly: 3 × 60 min wins over 3 × 1 min.
var last = Now.AddMinutes(-90);
var times = new[] { last, last.AddMinutes(-1) };
var freshness = FreshnessRules.Evaluate(new FreshnessInput(last, null, times, HasLiveSource: true, TimeSpan.FromHours(1)), Now);
Assert.Equal(FreshnessState.Live, freshness.State);
Assert.Equal(TimeSpan.FromHours(3), freshness.StaleAfter);
}
[Fact]
public void An_event_after_the_last_reading_is_the_mark()
{
var reading = Now.AddDays(-10);
var delivery = Now.AddHours(-1);
var freshness = FreshnessRules.Evaluate(new FreshnessInput(reading, delivery, [reading], HasLiveSource: false, null), Now);
Assert.Equal(delivery, freshness.LastActivity);
}
[Fact]
public void A_live_source_with_an_unknown_rhythm_is_not_called_stale()
{
var once = Now.AddDays(-30);
var freshness = FreshnessRules.Evaluate(new FreshnessInput(once, null, [once], HasLiveSource: true, null), Now);
Assert.Equal(FreshnessState.Live, freshness.State);
Assert.Null(freshness.StaleAfter);
}
[Fact]
public void The_median_uses_the_latest_twenty_intervals()
{
// 30 readings: the oldest ten are a day apart, the latest 21 an hour apart.
var times = Enumerable.Range(0, 21).Select(i => Now.AddHours(-i))
.Concat(Enumerable.Range(1, 9).Select(i => Now.AddHours(-20).AddDays(-i)))
.ToList();
Assert.Equal(TimeSpan.FromHours(1), FreshnessRules.MedianInterval(times));
Assert.Null(FreshnessRules.MedianInterval([Now]));
}
[Fact]
public void Combined_freshness_is_as_current_as_its_least_current_source()
{
var combined = FreshnessRules.Combine(
[
new Freshness(FreshnessState.Live, Now.AddMinutes(-5)),
new Freshness(FreshnessState.Stale, Now.AddDays(-2)),
Freshness.None,
]);
Assert.Equal(FreshnessState.Stale, combined.State);
Assert.Equal(Now.AddDays(-2), combined.LastActivity);
Assert.Equal(FreshnessState.Historical, FreshnessRules.Combine([new Freshness(FreshnessState.Historical, Now)]).State);
Assert.Equal(FreshnessState.NoData, FreshnessRules.Combine([]).State);
}
}
@@ -0,0 +1,166 @@
using MeterVault.Core.Analysis;
using static MeterVault.Core.Tests.Analysis.AnalysisClock;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// The legacy entry points' ranges (D-45): the REST API's exact instants and the older services' date ranges become
/// ordinary resolved periods, cut at now, that the bucket planner tiles exactly.
/// </summary>
public sealed class LegacyPeriodsTests
{
private static readonly DateTimeOffset Now = At(Berlin, 2026, 9, 19, 14, 37);
[Fact]
public void Utc_midnights_keep_their_exact_instants_in_a_zone_ahead_of_utc()
{
// The contract test's bounds: 2024-01-01T00:00Z is 01:00 in Berlin, and 2024-03-01T00:00Z is 01:00 on 1 March.
var period = LegacyPeriods.FromInstants(Utc(2024, 1, 1), Utc(2024, 3, 1), Now, Berlin);
Assert.Equal((Day(2024, 1, 1), Day(2024, 3, 1)), (period.FirstDay, period.LastDay));
Assert.Equal((Utc(2024, 1, 1), Utc(2024, 3, 1)), (period.From, period.To));
Assert.False(period.IsToDate);
Assert.False(period.HasNotStarted());
var plan = BucketPlanner.Plan(period, BucketSize.Month);
// January from 01:00, February whole, and the first hour of 1 March — the buckets tile [from, to) exactly.
Assert.Equal(3, plan.Buckets.Count);
Assert.Equal(Utc(2024, 1, 1), plan.Buckets[0].From);
Assert.Equal(At(Berlin, 2024, 2, 1), plan.Buckets[0].To);
Assert.Equal((At(Berlin, 2024, 3, 1), Utc(2024, 3, 1)), (plan.Buckets[2].From, plan.Buckets[2].To));
Assert.Equal([Day(2024, 1, 1), Day(2024, 2, 1), Day(2024, 3, 1)], plan.Buckets.Select(LegacyPeriods.KeyOf));
}
[Fact]
public void Local_midnights_give_whole_months()
{
var period = LegacyPeriods.FromInstants(At(Berlin, 2024, 1, 1), At(Berlin, 2024, 3, 1), Now, Berlin);
Assert.Equal((Day(2024, 1, 1), Day(2024, 2, 29)), (period.FirstDay, period.LastDay));
var plan = BucketPlanner.Plan(period, BucketSize.Month);
Assert.Equal(2, plan.Buckets.Count);
Assert.Equal(period, PeriodResolver.Resolve(PeriodPreset.Custom, Day(2024, 1, 1), Day(2024, 2, 29), Now, Berlin));
}
[Fact]
public void An_offset_is_read_as_the_instant_it_names()
{
var withOffset = LegacyPeriods.FromInstants(
new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.FromHours(1)), new DateTimeOffset(2024, 3, 1, 0, 0, 0, TimeSpan.FromHours(1)), Now, Berlin);
Assert.Equal(TimeSpan.Zero, withOffset.From.Offset);
Assert.Equal(At(Berlin, 2024, 1, 1), withOffset.From);
Assert.Equal(At(Berlin, 2024, 3, 1), withOffset.To);
}
[Fact]
public void A_range_reaching_past_now_stops_at_now()
{
// D-04: a legacy caller asking for this month and the next still gets actuals up to now only.
var period = LegacyPeriods.FromInstants(At(Berlin, 2026, 9, 1), At(Berlin, 2026, 11, 1), Now, Berlin);
Assert.True(period.IsToDate);
Assert.True(period.ExtendsPastNow);
Assert.Equal(Now, period.To);
Assert.Equal(Day(2026, 10, 31), period.LastDay);
var plan = BucketPlanner.Plan(period, BucketSize.Month);
var bucket = Assert.Single(plan.Buckets);
Assert.Equal(Now, bucket.To);
}
[Fact]
public void A_range_after_now_has_not_started_and_plans_nothing()
{
var period = LegacyPeriods.FromInstants(At(Berlin, 2026, 10, 1), At(Berlin, 2026, 11, 1), Now, Berlin);
Assert.True(period.HasNotStarted());
Assert.Equal(period.From, period.To);
Assert.Empty(BucketPlanner.Plan(period, BucketSize.Month).Buckets);
Assert.Empty(LegacyPeriods.WholePeriodPlan(period).Buckets);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void An_empty_or_inverted_range_plans_nothing(int days)
{
var from = At(Berlin, 2024, 5, 1);
var period = LegacyPeriods.FromInstants(from, from.AddDays(days), Now, Berlin);
Assert.True(period.HasNoHistory());
Assert.Empty(BucketPlanner.Plan(period, BucketSize.Month).Buckets);
}
[Fact]
public void Instants_outside_the_supported_dates_are_clamped()
{
var period = LegacyPeriods.FromInstants(DateTimeOffset.MinValue, DateTimeOffset.MaxValue, Now, Berlin);
Assert.Equal(PeriodResolver.MinSupportedDate, period.FirstDay);
Assert.Equal(At(Berlin, 1900, 1, 1), period.From);
Assert.Equal(Now, period.To);
Assert.Equal(PeriodResolver.MaxSupportedDate, period.LastDay);
Assert.False(BucketPlanner.Plan(period, BucketSize.Year).Refused);
}
[Fact]
public void A_date_range_ends_the_day_before_its_exclusive_end_and_is_cut_at_now()
{
// The dashboard's "this year": [1 January, 1 January next year).
var year = LegacyPeriods.FromDates(Day(2026, 1, 1), Day(2027, 1, 1), Now, Berlin);
Assert.Equal((Day(2026, 1, 1), Day(2026, 12, 31)), (year.FirstDay, year.LastDay));
Assert.Equal(At(Berlin, 2026, 1, 1), year.From);
Assert.Equal(Now, year.To);
Assert.Equal(BucketSize.Year, PeriodBucket.Of(year).Size);
var previous = LegacyPeriods.FromDates(Day(2025, 1, 1), Day(2026, 1, 1), Now, Berlin);
Assert.False(previous.IsToDate);
Assert.Equal(At(Berlin, 2026, 1, 1), previous.To);
}
[Fact]
public void A_date_range_in_a_zone_behind_utc_starts_at_local_midnight()
{
var period = LegacyPeriods.FromDates(Day(2026, 7, 1), Day(2026, 8, 1), Now, NewYork);
Assert.Equal(Utc(2026, 7, 1, 4), period.From);
Assert.Equal(Utc(2026, 8, 1, 4), period.To);
}
[Fact]
public void An_empty_date_range_or_one_outside_the_supported_dates_is_handled()
{
Assert.True(LegacyPeriods.FromDates(Day(2026, 5, 1), Day(2026, 5, 1), Now, Berlin).HasNoHistory());
Assert.True(LegacyPeriods.FromDates(Day(2026, 5, 2), Day(2026, 5, 1), Now, Berlin).HasNoHistory());
var wide = LegacyPeriods.FromDates(new DateOnly(1, 1, 1), new DateOnly(9999, 1, 1), Now, Berlin);
Assert.Equal((PeriodResolver.MinSupportedDate, PeriodResolver.MaxSupportedDate), (wide.FirstDay, wide.LastDay));
}
[Fact]
public void The_whole_period_plan_is_the_period_as_one_bucket()
{
var period = LegacyPeriods.FromDates(Day(2025, 1, 1), Day(2026, 1, 1), Now, Berlin);
var plan = LegacyPeriods.WholePeriodPlan(period);
Assert.False(plan.Refused);
Assert.Equal(BucketSize.Year, plan.Size);
Assert.Equal(PeriodBucket.Of(period), Assert.Single(plan.Buckets));
}
[Theory]
[InlineData(BucketSize.Day, "2026-09-17")]
[InlineData(BucketSize.Week, "2026-09-14")]
[InlineData(BucketSize.Month, "2026-09-01")]
[InlineData(BucketSize.Year, "2026-01-01")]
public void A_bucket_is_filed_under_the_start_of_its_calendar_unit(BucketSize size, string key)
{
var bucket = new AnalysisBucket(Day(2026, 9, 17), Day(2026, 9, 18), At(Berlin, 2026, 9, 17), At(Berlin, 2026, 9, 18), size);
Assert.Equal(Iso(key), LegacyPeriods.KeyOf(bucket));
}
}
@@ -0,0 +1,250 @@
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;
/// <summary>
/// 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.
/// </summary>
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);
}
}
@@ -0,0 +1,484 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using static MeterVault.Core.Tests.Analysis.CoverageTestData;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// A change figure is confident only over the time both periods cover (D-07). What these pin down: the
/// match follows the current data's actual end rather than the period's; it trims to whole months where
/// either side only has monthly data, and to interval edges inside undivided intervals; a bound on a reading
/// instant keeps the row closing there on the side its time lies on; every source of a combined scope has to
/// agree; holes split the match; an opening balance stays out of it; and no overlap means "not comparable"
/// rather than a percentage against nothing.
/// </summary>
public sealed class MatchedCoverageTests
{
private static readonly DateTimeOffset Now = At(2026, 9, 19, 10);
private static readonly TimeSpan Past = MatchedCoverage.PastBookedRow;
private static DateTimeOffset PreviousYear(DateTimeOffset instant) => ShiftYears(instant, -1);
private static ResolvedPeriod YearToDate(DateTimeOffset now) =>
Period(PeriodPreset.YearToDate, new DateOnly(now.Year, 1, 1), DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(now, Berlin).DateTime), now);
private static MatchedCoverageResult MatchYearToDate(
DateTimeOffset now,
CoverageRun[] currentRuns,
CoverageRun[] comparisonRuns,
DateTimeOffset[]? currentOpeningBalances = null,
DateTimeOffset[]? comparisonOpeningBalances = null)
{
var current = YearToDate(now);
return MatchedCoverage.Match(
MatchSide.Of(current.From, current.To, now, Berlin, currentRuns, currentOpeningBalances),
MatchSide.Of(PreviousYear(current.From), PreviousYear(current.To), now, Berlin, comparisonRuns, comparisonOpeningBalances),
PreviousYear);
}
[Fact]
public void Year_to_date_with_data_until_31_May_matches_January_to_May_in_both_years()
{
var result = MatchYearToDate(Now, [MonthLabels(2026, 1, 2026, 6)], [MonthLabels(2025, 1, 2026, 1)]);
Assert.True(result.IsComparable);
Assert.True(result.IsContiguous);
Assert.Equal(new MatchedRange(At(2026, 1, 1), At(2026, 6, 1), new DateOnly(2026, 1, 1), new DateOnly(2026, 5, 31)), result.Current);
Assert.Equal(new MatchedRange(At(2025, 1, 1), At(2025, 6, 1), new DateOnly(2025, 1, 1), new DateOnly(2025, 5, 31)), result.Comparison);
}
[Fact]
public void Hourly_data_on_both_sides_matches_up_to_the_cut_at_now()
{
var result = MatchYearToDate(
Now,
[Run(At(2025, 6, 1), At(2026, 12, 1), ResolutionClass.Hour)],
[Run(At(2024, 6, 1), At(2025, 12, 1), ResolutionClass.Hour)]);
Assert.Equal(At(2026, 1, 1), result.Current!.From);
Assert.Equal(Now, result.Current.To);
Assert.Equal(new DateOnly(2026, 9, 19), result.Current.LastDay);
Assert.Equal(At(2025, 9, 19, 10), result.Comparison!.To);
}
[Fact]
public void Monthly_data_on_the_comparison_side_trims_a_mid_month_cut_to_whole_months_on_both_sides()
{
// Month-resolution data cannot say how much of May had accrued by the 19th.
var now = At(2026, 5, 19, 14, 37);
var result = MatchYearToDate(
now,
[Run(At(2025, 12, 1), At(2026, 5, 19, 14, 37), ResolutionClass.Hour)],
[MonthLabels(2025, 1, 2026, 1)]);
Assert.Equal(new MatchedRange(At(2026, 1, 1), At(2026, 5, 1), new DateOnly(2026, 1, 1), new DateOnly(2026, 4, 30)), result.Current);
Assert.Equal(new MatchedRange(At(2025, 1, 1), At(2025, 5, 1), new DateOnly(2025, 1, 1), new DateOnly(2025, 4, 30)), result.Comparison);
}
[Fact]
public void Daily_readings_match_up_to_the_last_reading_before_now_and_include_its_row()
{
// Read at 06:00 daily; the reading of 20 September is already stamped. The interval containing now is
// given up (A-04), and the match ends on today's 06:00 reading, whose row closes the day before it.
var result = MatchYearToDate(
Now,
[Run(At(2025, 12, 31, 6), At(2026, 9, 20, 6), ResolutionClass.Day, divided: true, last: At(2026, 9, 19, 6))],
[Run(At(2024, 12, 1), At(2025, 12, 1), ResolutionClass.Hour)]);
Assert.Equal(At(2026, 1, 1), result.Current!.From);
Assert.Equal(At(2026, 9, 19, 6) + Past, result.Current.To);
Assert.Equal(new DateOnly(2026, 9, 19), result.Current.LastDay);
Assert.Equal(At(2025, 9, 19, 6), result.Comparison!.To);
}
[Fact]
public void A_cut_inside_daily_readings_falls_back_to_local_midnight()
{
// The comparison year was read at 06:00 daily; 10:00 on 19 September lies inside one of its days.
var result = MatchYearToDate(
Now,
[Run(At(2025, 12, 1), At(2026, 12, 1), ResolutionClass.Hour)],
[Run(At(2024, 12, 31, 6), At(2025, 12, 1, 6), ResolutionClass.Day, divided: true)]);
Assert.Equal(At(2026, 9, 19), result.Current!.To);
Assert.Equal(new DateOnly(2026, 9, 18), result.Current.LastDay);
Assert.Equal(At(2025, 9, 19), result.Comparison!.To);
}
[Fact]
public void An_undivided_tank_interval_open_at_the_cut_is_left_out_of_the_match()
{
var result = MatchYearToDate(
Now,
[Run(At(2026, 1, 1), At(2026, 8, 5), ResolutionClass.Month), Single(At(2026, 8, 5), At(2026, 10, 5), ResolutionClass.Coarse)],
[Run(At(2025, 1, 1), At(2025, 12, 1), ResolutionClass.Hour)]);
Assert.Equal(At(2026, 8, 5), result.Current!.To);
Assert.Equal(At(2025, 8, 5), result.Comparison!.To);
}
[Fact]
public void Trimming_month_data_never_reaches_back_into_the_long_interval_before_it()
{
// Monthly tank readings resumed on 3 May after a 44-day interval. Floored to 1 May, the cut would
// land inside that interval; the reading on 3 May is the last edge the data can end on.
var now = At(2026, 5, 19, 14);
var result = MatchYearToDate(
now,
[
Run(At(2026, 1, 1), At(2026, 3, 20), ResolutionClass.Month),
Run(At(2026, 3, 20), At(2026, 5, 3), ResolutionClass.Coarse),
Run(At(2026, 5, 3), At(2026, 6, 2), ResolutionClass.Month),
],
[Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)]);
Assert.Equal(At(2026, 5, 3), result.Current!.To);
Assert.Equal(At(2025, 5, 3), result.Comparison!.To);
}
[Fact]
public void A_match_ending_on_a_dipstick_reading_includes_the_row_booked_at_that_reading()
{
// m2 #3 (P4): the row for 20 March → 3 May is stamped at 3 May 10:00. A query ending exactly there
// would leave those 44 days out of the current side while the comparison year keeps them.
var now = At(2026, 5, 19, 14);
var result = MatchYearToDate(
now,
[
Run(At(2026, 1, 1), At(2026, 3, 20, 10), ResolutionClass.Month),
Single(At(2026, 3, 20, 10), At(2026, 5, 3, 10), ResolutionClass.Coarse),
Single(At(2026, 5, 3, 10), At(2026, 6, 2, 10), ResolutionClass.Month),
],
[Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)]);
Assert.Equal(At(2026, 5, 3, 10) + Past, result.Current!.To);
Assert.Equal(new DateOnly(2026, 5, 3), result.Current.LastDay);
Assert.Equal(At(2025, 5, 3, 10), result.Comparison!.To);
}
[Fact]
public void A_comparison_month_cut_inside_undivided_dipstick_intervals_is_cut_at_the_dipsticks()
{
// m2 #3 (P7): November 2026 hourly against November 2025, read on 20 October, 18 and 29 November and
// 20 December. 1 November and 1 December lie inside undivided intervals; their dipsticks are the only
// cut points, and the start moves past the row closing at the first one.
var current = Period(PeriodPreset.Custom, new DateOnly(2026, 11, 1), new DateOnly(2026, 11, 30), At(2026, 12, 5));
var comparisonRuns = new[]
{
Single(At(2025, 10, 20, 10), At(2025, 11, 18, 10), ResolutionClass.Month),
Single(At(2025, 11, 18, 10), At(2025, 11, 29, 10), ResolutionClass.Month, divided: true),
Single(At(2025, 11, 29, 10), At(2025, 12, 20, 10), ResolutionClass.Month),
};
var result = MatchedCoverage.Match(
MatchSide.Of(current.From, current.To, current.Now, Berlin, [Run(At(2026, 10, 1), At(2027, 1, 1), ResolutionClass.Hour)]),
MatchSide.Of(PreviousYear(current.From), PreviousYear(current.To), current.Now, Berlin, comparisonRuns),
PreviousYear);
Assert.Equal(new MatchedRange(At(2025, 11, 18, 10) + Past, At(2025, 11, 29, 10) + Past, new DateOnly(2025, 11, 18), new DateOnly(2025, 11, 29)), result.Comparison);
Assert.Equal(new MatchedRange(At(2026, 11, 18, 10), At(2026, 11, 29, 10), new DateOnly(2026, 11, 18), new DateOnly(2026, 11, 29)), result.Current);
}
[Fact]
public void A_comparison_month_that_one_undivided_interval_straddles_on_both_ends_is_not_comparable()
{
// m2 #3 (P7): 14 October → 28 November 10:00 → 20 December. No dipstick falls where November can be cut.
var current = Period(PeriodPreset.Custom, new DateOnly(2026, 11, 1), new DateOnly(2026, 11, 30), At(2026, 12, 5));
var result = MatchedCoverage.Match(
MatchSide.Of(current.From, current.To, current.Now, Berlin, [Run(At(2026, 10, 1), At(2027, 1, 1), ResolutionClass.Hour)]),
MatchSide.Of(
PreviousYear(current.From),
PreviousYear(current.To),
current.Now,
Berlin,
[Single(At(2025, 10, 14), At(2025, 11, 28, 10), ResolutionClass.Coarse), Single(At(2025, 11, 28, 10), At(2025, 12, 20), ResolutionClass.Month)]),
PreviousYear);
Assert.False(result.IsComparable);
}
[Fact]
public void A_combined_scope_starts_its_match_where_every_source_can_be_cut()
{
// m2 #4 (P1): source A books 1 January 15 March in one interval, then reads daily; source B starts
// hourly on 1 March. Together they cover from 1 March, but A's interval can only be cut at its end.
var current = YearToDate(Now);
IReadOnlyList<CoverageRun>[] currentSources =
[
[Single(At(2026, 1, 1), At(2026, 3, 15), ResolutionClass.Coarse), Run(At(2026, 3, 15), At(2026, 12, 1), ResolutionClass.Day, divided: true)],
[Run(At(2026, 3, 1), At(2026, 12, 1), ResolutionClass.Hour)],
];
IReadOnlyList<CoverageRun>[] comparisonSources =
[
[Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)],
[Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)],
];
var result = MatchedCoverage.Match(
MatchSide.OfSources(current.From, current.To, Now, Berlin, currentSources),
MatchSide.OfSources(PreviousYear(current.From), PreviousYear(current.To), Now, Berlin, comparisonSources),
PreviousYear);
Assert.Equal(At(2026, 3, 15), result.Current!.From);
Assert.Equal(At(2025, 3, 15), result.Comparison!.From);
}
[Fact]
public void A_scope_without_sources_is_not_comparable()
{
var current = YearToDate(Now);
var result = MatchedCoverage.Match(
MatchSide.OfSources(current.From, current.To, Now, Berlin, []),
MatchSide.Of(PreviousYear(current.From), PreviousYear(current.To), Now, Berlin, [MonthLabels(2025, 1, 2026, 1)]),
PreviousYear);
Assert.False(result.IsComparable);
}
[Fact]
public void A_meter_installed_in_March_last_year_matches_from_March_on_both_sides()
{
var result = MatchYearToDate(
Now,
[Run(At(2025, 1, 1), At(2026, 12, 1), ResolutionClass.Hour)],
[Run(At(2025, 3, 10), At(2026, 1, 1), ResolutionClass.Hour)]);
Assert.Equal(At(2026, 3, 10), result.Current!.From);
Assert.Equal(new DateOnly(2026, 3, 10), result.Current.FirstDay);
Assert.Equal(At(2025, 3, 10), result.Comparison!.From);
Assert.Equal(Now, result.Current.To);
}
[Fact]
public void An_outage_in_the_comparison_year_splits_the_match_and_is_left_out_of_both_sides()
{
var result = MatchYearToDate(
Now,
[Run(At(2026, 1, 1), At(2026, 12, 1), ResolutionClass.Hour)],
[
Run(At(2025, 1, 1), At(2025, 3, 10, 14), ResolutionClass.Hour),
Gap(At(2025, 3, 10, 14), At(2025, 3, 13, 9), CoverageGapReason.SampleGap),
Run(At(2025, 3, 13, 9), At(2026, 1, 1), ResolutionClass.Hour),
]);
Assert.False(result.IsContiguous);
Assert.Equal(2, result.Pieces.Count);
Assert.Equal(At(2026, 3, 10, 14), result.Pieces[0].Current.To);
Assert.Equal(At(2026, 3, 13, 9), result.Pieces[1].Current.From);
// The gap's row is booked at its end, 13 March 09:00: the second piece starts just past it, and the
// first ends just past the last sample before the outage.
Assert.Equal(At(2025, 3, 10, 14) + Past, result.Pieces[0].Comparison.To);
Assert.Equal(At(2025, 3, 13, 9) + Past, result.Pieces[1].Comparison.From);
// The spans still run from the first to the last matched instant.
Assert.Equal(At(2026, 1, 1), result.Current!.From);
Assert.Equal(Now, result.Current.To);
}
// ---- Opening balances (A-01) ----------------------------------------------------------------------
[Fact]
public void An_opening_balance_at_the_start_of_the_current_data_stays_out_of_the_match()
{
// D-14: the first reading's row holds consumption of unknown extent.
var firstReading = At(2026, 2, 3, 11);
var result = MatchYearToDate(
Now,
[Run(firstReading, At(2026, 12, 1), ResolutionClass.Hour)],
[Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)],
currentOpeningBalances: [firstReading]);
Assert.Equal(firstReading + Past, result.Current!.From);
Assert.Equal(new DateOnly(2026, 2, 3), result.Current.FirstDay);
Assert.Equal(At(2025, 2, 3, 11), result.Comparison!.From);
}
[Fact]
public void An_opening_balance_read_at_midnight_stays_out_of_a_match_starting_there()
{
// m2 #2: a first reading at 00:00 describes no time before it, so its row stays on the midnight
// (D-11 only moves rows that close an interval) and a range starting there must step past it.
var firstReading = At(2026, 2, 3);
var result = MatchYearToDate(
Now,
[Run(firstReading, At(2026, 12, 1), ResolutionClass.Hour)],
[Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)],
currentOpeningBalances: [firstReading]);
Assert.Equal(firstReading + Past, result.Current!.From);
Assert.Equal(new DateOnly(2026, 2, 3), result.Current.FirstDay);
Assert.Equal(At(2025, 2, 3), result.Comparison!.From);
}
[Fact]
public void An_opening_balance_in_the_comparison_year_moves_the_start_of_both_sides()
{
var firstReading = At(2025, 4, 7, 15);
var result = MatchYearToDate(
Now,
[Run(At(2025, 12, 1), At(2026, 12, 1), ResolutionClass.Hour)],
[Run(firstReading, At(2026, 1, 1), ResolutionClass.Hour)],
comparisonOpeningBalances: [firstReading]);
Assert.Equal(firstReading + Past, result.Comparison!.From);
Assert.Equal(At(2026, 4, 7, 15), result.Current!.From);
}
// ---- Not comparable ---------------------------------------------------------------------------------
[Fact]
public void No_coverage_in_the_comparison_period_is_not_comparable()
{
var result = MatchYearToDate(
Now,
[Run(At(2026, 1, 1), At(2026, 12, 1), ResolutionClass.Hour)],
[Run(At(2025, 10, 1), At(2026, 1, 1), ResolutionClass.Hour)]);
Assert.False(result.IsComparable);
Assert.Same(MatchedCoverageResult.NotComparable, result);
Assert.Null(result.Current);
Assert.Null(result.Comparison);
}
[Fact]
public void No_coverage_in_the_current_period_is_not_comparable()
{
var result = MatchYearToDate(Now, [], [MonthLabels(2025, 1, 2026, 1)]);
Assert.False(result.IsComparable);
}
[Fact]
public void An_overlap_shorter_than_the_monthly_side_can_resolve_is_not_comparable()
{
// Hourly data for 1-20 January against a year of month rows: January cannot be cut on the 20th.
var now = At(2026, 1, 20, 12);
var result = MatchYearToDate(now, [Run(At(2026, 1, 1), At(2026, 2, 1), ResolutionClass.Hour)], [MonthLabels(2025, 1, 2026, 1)]);
Assert.False(result.IsComparable);
}
// ---- Calendar shifts ------------------------------------------------------------------------------
[Fact]
public void A_previous_period_shifted_by_days_is_trimmed_on_the_side_whose_data_is_coarse()
{
// 10-19 September against 31 August - 9 September, with the earlier stretch read once a day at noon.
var now = At(2026, 9, 19, 10);
var current = Period(PeriodPreset.Custom, new DateOnly(2026, 9, 10), new DateOnly(2026, 9, 19), now);
var result = MatchedCoverage.Match(
MatchSide.Of(current.From, current.To, now, Berlin, [Run(At(2026, 9, 1), At(2026, 10, 1), ResolutionClass.Hour)]),
MatchSide.Of(At(2026, 8, 31), ShiftDays(now, -10), now, Berlin, [Run(At(2026, 8, 30, 12), At(2026, 9, 12, 12), ResolutionClass.Day, divided: true)]),
t => ShiftDays(t, -10));
// The hourly side needs no trimming; the daily side ends its match at local midnight on 9 September.
Assert.Equal(At(2026, 9, 10), result.Current!.From);
Assert.Equal(At(2026, 9, 19), result.Current.To);
Assert.Equal(At(2026, 8, 31), result.Comparison!.From);
Assert.Equal(At(2026, 9, 9), result.Comparison.To);
}
[Fact]
public void A_match_across_the_spring_DST_change_lines_up_on_local_wall_time()
{
// DST began on 30 March 2025 and 29 March 2026; the matched ranges still start and end at local midnights.
var now = At(2026, 4, 15, 12);
var result = MatchYearToDate(
now,
[Run(At(2026, 3, 1), At(2026, 4, 16, 6), ResolutionClass.Day, divided: true)],
[Run(At(2025, 1, 1), At(2026, 1, 1), ResolutionClass.Hour)]);
Assert.Equal(At(2026, 3, 1), result.Current!.From);
Assert.Equal(At(2026, 4, 15), result.Current.To);
Assert.Equal(At(2025, 3, 1), result.Comparison!.From);
Assert.Equal(At(2025, 4, 15), result.Comparison.To);
Assert.Equal(new DateOnly(2025, 4, 14), result.Comparison.LastDay);
}
[Fact]
public void A_match_on_the_long_autumn_day_cuts_both_years_at_the_same_wall_time()
{
// 25 October 2026 has 25 hours in Berlin; on 25 October 2025 the clocks had not gone back yet.
var now = At(2026, 10, 25, 12);
var hourly = MatchYearToDate(
now,
[Run(At(2025, 12, 1), At(2026, 12, 1), ResolutionClass.Hour)],
[Run(At(2024, 12, 1), At(2025, 12, 1), ResolutionClass.Hour)]);
var daily = MatchYearToDate(
now,
[Run(At(2025, 12, 31, 6), At(2026, 12, 1, 6), ResolutionClass.Day, divided: true)],
[Run(At(2024, 12, 1), At(2025, 12, 1), ResolutionClass.Hour)]);
Assert.Equal(now, hourly.Current!.To);
Assert.Equal(At(2025, 10, 25, 12), hourly.Comparison!.To);
Assert.Equal(TimeSpan.FromHours(1), (hourly.Current.To - hourly.Current.From) - (hourly.Comparison.To - hourly.Comparison.From));
Assert.Equal(At(2026, 10, 25), daily.Current!.To);
Assert.Equal(At(2025, 10, 25), daily.Comparison!.To);
Assert.Equal(new DateOnly(2025, 10, 24), daily.Comparison.LastDay);
}
[Fact]
public void March_to_date_on_the_30th_matches_all_of_February_because_the_29th_to_31st_map_onto_its_end()
{
// m2 #11: shifting by a month clamps 29-31 March to the end of February (D-06), so the shift is not
// one-to-one there: every instant from 29 March 00:00 maps to 1 March 00:00.
var now = At(2026, 3, 30, 10);
var current = Period(PeriodPreset.MonthToDate, new DateOnly(2026, 3, 1), new DateOnly(2026, 3, 30), now);
var result = MatchedCoverage.Match(
MatchSide.Of(current.From, current.To, now, Berlin, [Run(At(2026, 1, 1), At(2026, 5, 1), ResolutionClass.Hour)]),
MatchSide.Of(At(2026, 2, 1), At(2026, 3, 1), now, Berlin, [Run(At(2026, 1, 1), At(2026, 3, 1), ResolutionClass.Hour)]),
t => ShiftMonths(t, -1));
Assert.Equal(At(2026, 3, 1), ShiftMonths(At(2026, 3, 29), -1));
Assert.Equal(At(2026, 3, 1), ShiftMonths(At(2026, 3, 31, 23), -1));
Assert.Equal(new MatchedRange(At(2026, 3, 1), now, new DateOnly(2026, 3, 1), new DateOnly(2026, 3, 30)), result.Current);
Assert.Equal(new MatchedRange(At(2026, 2, 1), At(2026, 3, 1), new DateOnly(2026, 2, 1), new DateOnly(2026, 2, 28)), result.Comparison);
}
[Fact]
public void Year_to_date_on_29_February_matches_up_to_the_end_of_February_the_year_before()
{
// m2 #11: 29 February 2024 has no counterpart in 2023; the day maps onto 1 March 2023 00:00.
var now = At(2024, 2, 29, 10);
var result = MatchYearToDate(
now,
[Run(At(2023, 12, 31, 6), At(2024, 3, 10, 6), ResolutionClass.Day, divided: true)],
[Run(At(2022, 12, 1), At(2023, 12, 1), ResolutionClass.Hour)]);
Assert.Equal(new MatchedRange(At(2024, 1, 1), At(2024, 2, 29), new DateOnly(2024, 1, 1), new DateOnly(2024, 2, 28)), result.Current);
Assert.Equal(new MatchedRange(At(2023, 1, 1), At(2023, 3, 1), new DateOnly(2023, 1, 1), new DateOnly(2023, 2, 28)), result.Comparison);
}
[Fact]
public void In_New_York_monthly_labels_match_on_New_York_month_starts()
{
var now = At(NewYork, 2026, 9, 19, 10);
var from = At(NewYork, 2026, 1, 1);
DateTimeOffset LastYear(DateTimeOffset t) => Shift(t, NewYork, months: -12);
var result = MatchedCoverage.Match(
MatchSide.Of(from, now, now, NewYork, [MonthLabels(NewYork, 2026, 1, 2026, 7)]),
MatchSide.Of(LastYear(from), LastYear(now), now, NewYork, [MonthLabels(NewYork, 2025, 1, 2026, 1)]),
LastYear);
Assert.Equal(new MatchedRange(At(NewYork, 2026, 1, 1), At(NewYork, 2026, 7, 1), new DateOnly(2026, 1, 1), new DateOnly(2026, 6, 30)), result.Current);
Assert.Equal(new DateTimeOffset(2026, 7, 1, 4, 0, 0, TimeSpan.Zero), result.Current!.To);
Assert.Equal(new MatchedRange(At(NewYork, 2025, 1, 1), At(NewYork, 2025, 7, 1), new DateOnly(2025, 1, 1), new DateOnly(2025, 6, 30)), result.Comparison);
}
}
@@ -0,0 +1,281 @@
using MeterVault.Core.Analysis.Quantities;
using MeterVault.Core.Domain;
namespace MeterVault.Core.Tests.Analysis;
public sealed class MeterRoleRulesTests
{
private const short Electricity = 1;
private const short Water = 2;
private static Meter MeterOf(
int id, string name, short energyTypeId, MeterMode mode, string? role = null, DateOnly? retiredAt = null) => new()
{
Id = id,
Name = name,
EnergyTypeId = energyTypeId,
Mode = mode,
Unit = "kWh",
Meta = MeterMeta.SetRole("{}", role),
RetiredAt = retiredAt,
};
[Theory]
[InlineData("total_load", MeterRole.TotalLoad)]
[InlineData("grid_import", MeterRole.GridImport)]
[InlineData("grid_export", MeterRole.GridExport)]
[InlineData(" GRID_IMPORT ", MeterRole.GridImport)]
public void A_stored_token_parses_to_its_role(string token, MeterRole role)
{
Assert.True(MeterRoleRules.TryParse(token, out var parsed));
Assert.Equal(role, parsed);
Assert.Equal(role, MeterRoleRules.Parse(token));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("pv")]
[InlineData("grid-import")]
public void Anything_else_is_no_role(string? token)
{
Assert.False(MeterRoleRules.TryParse(token, out _));
Assert.Null(MeterRoleRules.Parse(token));
}
[Fact]
public void Every_role_round_trips_through_its_stored_token()
{
foreach (var role in MeterRoleRules.All)
{
Assert.Equal(role, MeterRoleRules.Parse(MeterRoleRules.Token(role)));
}
Assert.Equal(MeterRoles.TotalLoad, MeterRoleRules.Token(MeterRole.TotalLoad));
Assert.Equal(MeterRoles.GridImport, MeterRoleRules.Token(MeterRole.GridImport));
Assert.Equal(MeterRoles.GridExport, MeterRoleRules.Token(MeterRole.GridExport));
}
[Theory]
[InlineData(MeterMode.CumulativeCounter)]
[InlineData(MeterMode.DirectDelta)]
[InlineData(MeterMode.InstantRate)]
public void Meters_that_measure_a_flow_may_hold_every_role(MeterMode mode)
{
Assert.Equal(MeterRoleRules.All, MeterRoleRules.AllowedFor(mode));
Assert.All(MeterRoleRules.All, role => Assert.True(MeterRoleRules.IsAllowed(role, mode)));
}
[Theory]
[InlineData(MeterMode.GenerationCounter)]
[InlineData(MeterMode.RuntimeCounter)]
[InlineData(MeterMode.ConsumableBalance)]
[InlineData(MeterMode.Virtual)]
public void Generation_runtime_tank_and_virtual_meters_hold_no_role(MeterMode mode)
{
Assert.Empty(MeterRoleRules.AllowedFor(mode));
Assert.All(MeterRoleRules.All, role => Assert.False(MeterRoleRules.IsAllowed(role, mode)));
}
[Fact]
public void The_effective_role_ignores_a_token_the_mode_cannot_hold()
{
Assert.Equal(MeterRole.GridImport, MeterRoleRules.Effective(MeterOf(1, "Netz", Electricity, MeterMode.CumulativeCounter, "grid_import")));
Assert.Null(MeterRoleRules.Effective(MeterOf(2, "Solar", Electricity, MeterMode.GenerationCounter, "grid_export")));
Assert.Null(MeterRoleRules.Effective(MeterOf(3, "Summe Solar", Electricity, MeterMode.Virtual, "total_load")));
Assert.Null(MeterRoleRules.Effective(MeterOf(4, "Auto", Electricity, MeterMode.CumulativeCounter)));
}
[Fact]
public void Assigning_a_held_role_names_the_meter_it_moves_from()
{
// The seeded installation: Haus holds total_load, Netz holds grid_import.
var meters = new[]
{
MeterOf(1, "Zähler Haus", Electricity, MeterMode.CumulativeCounter, MeterRoles.TotalLoad),
MeterOf(2, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport),
MeterOf(3, "Zähler Auto", Electricity, MeterMode.CumulativeCounter),
};
var holder = MeterRoleRules.CurrentHolder(meters, MeterRole.TotalLoad, Electricity, meterId: 3);
Assert.NotNull(holder);
Assert.Equal("Zähler Haus", holder.Name);
}
[Fact]
public void A_free_role_has_no_holder()
{
var meters = new[]
{
MeterOf(1, "Zähler Haus", Electricity, MeterMode.CumulativeCounter, MeterRoles.TotalLoad),
MeterOf(2, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport),
};
Assert.Null(MeterRoleRules.CurrentHolder(meters, MeterRole.GridExport, Electricity, meterId: 2));
}
[Fact]
public void Re_saving_the_holder_does_not_move_the_role_from_itself()
{
var meters = new[] { MeterOf(1, "Zähler Haus", Electricity, MeterMode.CumulativeCounter, MeterRoles.TotalLoad) };
Assert.Null(MeterRoleRules.CurrentHolder(meters, MeterRole.TotalLoad, Electricity, meterId: 1));
}
[Fact]
public void Roles_are_unique_per_energy_type_not_globally()
{
var meters = new[]
{
MeterOf(1, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport),
MeterOf(2, "Hauswasser", Water, MeterMode.CumulativeCounter),
};
Assert.Null(MeterRoleRules.CurrentHolder(meters, MeterRole.GridImport, Water, meterId: 2));
}
[Fact]
public void Legacy_duplicates_and_invalid_leftovers_all_give_the_role_up()
{
var meters = new[]
{
MeterOf(9, "Einspeisung alt", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridExport),
MeterOf(4, "Solar 1", Electricity, MeterMode.GenerationCounter, MeterRoles.GridExport),
MeterOf(7, "Einspeisung Garage", Electricity, MeterMode.DirectDelta, MeterRoles.GridExport),
MeterOf(5, "Einspeisung neu", Electricity, MeterMode.CumulativeCounter),
MeterOf(6, "Wasser", Water, MeterMode.CumulativeCounter, MeterRoles.GridExport),
};
var holders = MeterRoleRules.Holders(meters, MeterRole.GridExport, Electricity, exceptMeterId: 5);
// Meters that really play the role first, then the leftover a generation counter cannot hold.
Assert.Equal([7, 9, 4], holders.Select(m => m.Id));
}
[Fact]
public void The_role_is_said_to_move_from_the_meter_that_played_it_not_from_an_invalid_leftover()
{
// Solar 1 (id 4) still stores a grid_export token it cannot hold; Einspeisung (id 9) is the export meter.
var meters = new[]
{
MeterOf(4, "Solar 1", Electricity, MeterMode.GenerationCounter, MeterRoles.GridExport),
MeterOf(9, "Einspeisung", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridExport),
MeterOf(12, "Einspeisung neu", Electricity, MeterMode.CumulativeCounter),
};
var holder = MeterRoleRules.CurrentHolder(meters, MeterRole.GridExport, Electricity, meterId: 12);
Assert.NotNull(holder);
Assert.Equal("Einspeisung", holder.Name);
}
[Fact]
public void An_invalid_leftover_is_named_only_when_no_meter_played_the_role()
{
var meters = new[]
{
MeterOf(4, "Solar 1", Electricity, MeterMode.GenerationCounter, MeterRoles.GridExport),
MeterOf(12, "Einspeisung neu", Electricity, MeterMode.CumulativeCounter),
};
Assert.Equal(4, MeterRoleRules.CurrentHolder(meters, MeterRole.GridExport, Electricity, meterId: 12)!.Id);
}
[Fact]
public void A_retired_meter_keeps_its_role_when_its_replacement_takes_it()
{
// A-07: the old grid meter measured the grid import of its own service period.
var meters = new[]
{
MeterOf(2, "Zähler Netz alt", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport, new DateOnly(2024, 6, 30)),
MeterOf(15, "Zähler Netz", Electricity, MeterMode.CumulativeCounter),
};
var replacement = MeterOf(15, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport);
Assert.Empty(MeterRoleRules.Holders(meters, MeterRole.GridImport, Electricity, exceptMeterId: 15));
Assert.Null(MeterRoleRules.CurrentHolder(meters, MeterRole.GridImport, Electricity, meterId: 15));
Assert.Empty(MeterRoleRules.Displaced(meters, replacement, MeterRole.GridImport));
}
[Fact]
public void A_retired_meter_saved_with_a_role_displaces_no_meter_in_service()
{
var meters = new[]
{
MeterOf(15, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport),
MeterOf(2, "Zähler Netz alt", Electricity, MeterMode.CumulativeCounter),
};
var retired = MeterOf(2, "Zähler Netz alt", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport, new DateOnly(2024, 6, 30));
Assert.Empty(MeterRoleRules.Displaced(meters, retired, MeterRole.GridImport));
}
[Fact]
public void A_meter_in_service_saved_with_a_role_displaces_every_other_holder_in_service()
{
var meters = new[]
{
MeterOf(1, "Zähler Haus", Electricity, MeterMode.CumulativeCounter, MeterRoles.TotalLoad),
MeterOf(3, "Solar 1", Electricity, MeterMode.GenerationCounter, MeterRoles.TotalLoad),
MeterOf(8, "Haus alt", Electricity, MeterMode.CumulativeCounter, MeterRoles.TotalLoad, new DateOnly(2020, 12, 31)),
MeterOf(20, "Hauptzähler", Electricity, MeterMode.CumulativeCounter, MeterRoles.TotalLoad),
};
var taker = MeterOf(20, "Hauptzähler", Electricity, MeterMode.CumulativeCounter, MeterRoles.TotalLoad);
var displaced = MeterRoleRules.Displaced(meters, taker, MeterRole.TotalLoad);
Assert.Equal([1, 3], displaced.Select(m => m.Id));
}
[Fact]
public void A_meter_whose_mode_cannot_hold_the_role_displaces_nobody()
{
var meters = new[] { MeterOf(2, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport) };
var generation = MeterOf(4, "Solar 1", Electricity, MeterMode.GenerationCounter, MeterRoles.GridImport);
Assert.Empty(MeterRoleRules.Displaced(meters, generation, MeterRole.GridImport));
}
[Fact]
public void A_meter_counts_as_retired_once_its_lifecycle_end_is_recorded()
{
var retired = RoleCandidate.From(MeterOf(2, "Zähler Netz alt", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport, new DateOnly(2024, 6, 30)));
var inService = RoleCandidate.From(MeterOf(15, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, " GRID_IMPORT "));
Assert.Equal(new RoleCandidate(2, Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport, IsRetired: true), retired);
Assert.False(inService.IsRetired);
Assert.Equal(MeterRole.GridImport, inService.StoredRole);
Assert.Equal(MeterRole.GridImport, inService.EffectiveRole);
}
[Fact]
public void A_caller_decides_what_retired_means_by_building_the_candidates_itself()
{
// An analysis input that treats a deactivated meter as retired passes the flag directly.
var candidates = new[]
{
new RoleCandidate(2, 1, MeterMode.CumulativeCounter, MeterRoles.GridImport, IsRetired: true),
new RoleCandidate(4, 1, MeterMode.GenerationCounter, MeterRoles.GridImport, IsRetired: false),
new RoleCandidate(15, 1, MeterMode.CumulativeCounter, MeterRoles.GridImport, IsRetired: false),
new RoleCandidate(16, 2, MeterMode.CumulativeCounter, MeterRoles.GridImport, IsRetired: false),
};
var holders = MeterRoleRules.Holders(candidates, MeterRole.GridImport, energyTypeId: 1);
Assert.Equal([15, 4], holders.Select(c => c.MeterId));
Assert.Equal(15, MeterRoleRules.CurrentHolder(candidates, MeterRole.GridImport, 1, meterId: 30)!.MeterId);
Assert.Null(holders[1].EffectiveRole);
}
[Fact]
public void Malformed_meta_holds_no_role()
{
var broken = MeterOf(1, "Kaputt", Electricity, MeterMode.CumulativeCounter);
broken.Meta = "not json";
Assert.Empty(MeterRoleRules.Holders([broken], MeterRole.TotalLoad, Electricity));
Assert.Null(MeterRoleRules.Effective(broken));
}
}
@@ -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"]));
}
}
@@ -0,0 +1,425 @@
using MeterVault.Core.Analysis;
using static MeterVault.Core.Tests.Analysis.AnalysisClock;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// A period resolves once, in the instance zone, into local dates for display and a half-open UTC range
/// for queries (D-02 D-04). What these pin down: ranges start at local midnight, never UTC midnight;
/// "today" is the local date; to-date periods stop at the captured now; and every preset is computed from
/// frozen instants, so none of this depends on when the tests run.
/// </summary>
public sealed class PeriodResolverTests
{
private static ResolvedPeriod Resolve(PeriodPreset preset, DateTimeOffset now, TimeZoneInfo? zone = null) =>
PeriodResolver.Resolve(preset, null, null, now, zone ?? Berlin);
private static ResolvedPeriod Custom(DateOnly first, DateOnly last, DateTimeOffset now, TimeZoneInfo? zone = null) =>
PeriodResolver.Resolve(PeriodPreset.Custom, first, last, now, zone ?? Berlin);
[Fact]
public void Month_to_date_in_Berlin_on_19_September_runs_from_local_midnight_on_the_1st_to_now()
{
var now = At(Berlin, 2026, 9, 19, 14, 37);
var period = Resolve(PeriodPreset.MonthToDate, now);
Assert.Equal(Day(2026, 9, 1), period.FirstDay);
Assert.Equal(Day(2026, 9, 19), period.LastDay);
Assert.Equal(Utc(2026, 8, 31, 22), period.From);
Assert.Equal(Utc(2026, 9, 19, 12, 37), period.To);
Assert.Equal(now, period.Now);
Assert.True(period.IsToDate);
Assert.False(period.ExtendsPastNow);
Assert.False(period.NotYetOccurred);
Assert.Same(Berlin, period.Zone);
// The named range is the whole month; comparisons and projections read that, the display reads the cut.
Assert.Equal(Day(2026, 9, 19), period.EffectiveLastDay());
Assert.Equal(Day(2026, 9, 30), period.NominalLastDay());
Assert.Equal(Utc(2026, 9, 30, 22), period.NominalEnd());
}
[Theory]
[InlineData(PeriodPreset.LastMonth, "2026-08-01", "2026-08-31", "2026-07-31T22:00Z", "2026-08-31T22:00Z", false)]
[InlineData(PeriodPreset.YearToDate, "2026-01-01", "2026-09-19", "2025-12-31T23:00Z", null, true)]
[InlineData(PeriodPreset.PreviousYear, "2025-01-01", "2025-12-31", "2024-12-31T23:00Z", "2025-12-31T23:00Z", false)]
[InlineData(PeriodPreset.Last12Months, "2025-10-01", "2026-09-19", "2025-09-30T22:00Z", null, true)]
[InlineData(PeriodPreset.Last24Months, "2024-10-01", "2026-09-19", "2024-09-30T22:00Z", null, true)]
public void Every_preset_on_19_September_resolves_to_its_local_calendar_range(
PeriodPreset preset, string first, string last, string from, string? to, bool toDate)
{
var now = At(Berlin, 2026, 9, 19, 14, 37);
var period = Resolve(preset, now);
Assert.Equal(Iso(first), period.FirstDay);
Assert.Equal(Iso(last), period.LastDay);
Assert.Equal(IsoInstant(from), period.From);
Assert.Equal(to is null ? now : IsoInstant(to), period.To);
Assert.Equal(toDate, period.IsToDate);
Assert.False(period.ExtendsPastNow);
}
[Fact]
public void The_last_12_months_are_twelve_calendar_months_ending_with_the_current_partial_one()
{
var period = Resolve(PeriodPreset.Last12Months, At(Berlin, 2026, 9, 19, 14, 37));
// Not 13 months, and no future month: October 2025 through September 2026, stopping now.
Assert.Equal(Day(2025, 10, 1), period.FirstDay);
Assert.Equal(Day(2026, 9, 30), period.NominalLastDay());
Assert.Equal(period.Now, period.To);
}
[Fact]
public void Half_an_hour_into_New_Year_the_current_month_and_year_are_2027_although_UTC_is_still_in_2026()
{
var now = At(Berlin, 2027, 1, 1, 0, 30);
Assert.Equal(Utc(2026, 12, 31, 23, 30), now.ToUniversalTime());
var month = Resolve(PeriodPreset.MonthToDate, now);
var year = Resolve(PeriodPreset.YearToDate, now);
var lastMonth = Resolve(PeriodPreset.LastMonth, now);
var previousYear = Resolve(PeriodPreset.PreviousYear, now);
var last12 = Resolve(PeriodPreset.Last12Months, now);
Assert.Equal((Day(2027, 1, 1), Day(2027, 1, 1)), (month.FirstDay, month.LastDay));
Assert.Equal((Utc(2026, 12, 31, 23), Utc(2026, 12, 31, 23, 30)), (month.From, month.To));
Assert.Equal((month.From, month.To), (year.From, year.To));
Assert.Equal(Day(2027, 12, 31), year.NominalLastDay());
Assert.Equal((Day(2026, 12, 1), Day(2026, 12, 31)), (lastMonth.FirstDay, lastMonth.LastDay));
Assert.Equal((Utc(2026, 11, 30, 23), Utc(2026, 12, 31, 23)), (lastMonth.From, lastMonth.To));
Assert.Equal((Day(2026, 1, 1), Day(2026, 12, 31)), (previousYear.FirstDay, previousYear.LastDay));
Assert.Equal((Utc(2025, 12, 31, 23), Utc(2026, 12, 31, 23)), (previousYear.From, previousYear.To));
Assert.Equal(Day(2026, 2, 1), last12.FirstDay);
Assert.Equal(Day(2027, 1, 1), last12.LastDay);
}
[Fact]
public void A_month_containing_the_spring_DST_change_is_one_hour_short_and_starts_in_winter_time()
{
var march = Resolve(PeriodPreset.LastMonth, At(Berlin, 2026, 4, 1, 9, 0));
Assert.Equal(Utc(2026, 2, 28, 23), march.From);
Assert.Equal(Utc(2026, 3, 31, 22), march.To);
Assert.Equal(TimeSpan.FromHours((31 * 24) - 1), march.To - march.From);
}
[Fact]
public void On_the_spring_DST_day_itself_month_to_date_runs_from_the_1st_in_winter_time_to_now_in_summer_time()
{
var now = At(Berlin, 2026, 3, 29, 10, 0);
Assert.Equal(TimeSpan.FromHours(2), now.Offset);
var period = Resolve(PeriodPreset.MonthToDate, now);
Assert.Equal(Utc(2026, 2, 28, 23), period.From);
Assert.Equal(Utc(2026, 3, 29, 8), period.To);
Assert.Equal(Day(2026, 3, 29), period.LastDay);
}
[Fact]
public void A_month_containing_the_autumn_DST_change_is_one_hour_long()
{
var october = Resolve(PeriodPreset.LastMonth, At(Berlin, 2026, 11, 2, 9, 0));
Assert.Equal(Utc(2026, 9, 30, 22), october.From);
Assert.Equal(Utc(2026, 10, 31, 23), october.To);
Assert.Equal(TimeSpan.FromHours((31 * 24) + 1), october.To - october.From);
}
[Fact]
public void In_the_repeated_autumn_hour_today_is_still_the_DST_day_and_the_cut_is_the_exact_instant()
{
// 02:30 in winter time, the second time the clock shows 02:30 on 25 October 2026.
var now = At(2026, 10, 25, 2, 30, offsetHours: 1);
var period = Resolve(PeriodPreset.MonthToDate, now);
Assert.Equal(Day(2026, 10, 25), period.LastDay);
Assert.Equal(Utc(2026, 10, 25, 1, 30), period.To);
}
[Fact]
public void Behind_UTC_the_local_date_decides_the_month_even_when_UTC_has_moved_on()
{
// 21:00 on 30 September in New York is already 1 October in UTC.
var now = At(NewYork, 2026, 9, 30, 21, 0);
Assert.Equal(Utc(2026, 10, 1, 1), now.ToUniversalTime());
var period = Resolve(PeriodPreset.MonthToDate, now, NewYork);
Assert.Equal(Day(2026, 9, 1), period.FirstDay);
Assert.Equal(Day(2026, 9, 30), period.LastDay);
Assert.Equal(Utc(2026, 9, 1, 4), period.From);
Assert.Equal(Utc(2026, 10, 1, 1), period.To);
}
[Fact]
public void Behind_UTC_a_month_ending_in_winter_time_spans_its_local_midnights()
{
// November 2026 starts in daylight time (EDT, -4) and ends in standard time (EST, -5).
var november = Resolve(PeriodPreset.LastMonth, At(NewYork, 2026, 12, 1, 0, 30), NewYork);
Assert.Equal(Utc(2026, 11, 1, 4), november.From);
Assert.Equal(Utc(2026, 12, 1, 5), november.To);
}
[Fact]
public void Every_instant_is_stored_in_UTC_whatever_offset_now_arrives_with()
{
var now = At(Berlin, 2026, 9, 19, 14, 37);
var period = Resolve(PeriodPreset.YearToDate, now);
Assert.Equal(TimeSpan.Zero, period.From.Offset);
Assert.Equal(TimeSpan.Zero, period.To.Offset);
Assert.Equal(TimeSpan.Zero, period.Now.Offset);
}
[Fact]
public void A_custom_range_in_the_past_is_complete_and_ends_at_the_local_midnight_after_its_last_day()
{
var period = Custom(Day(2026, 6, 1), Day(2026, 6, 30), At(Berlin, 2026, 9, 19, 14, 37));
Assert.Equal(Utc(2026, 5, 31, 22), period.From);
Assert.Equal(Utc(2026, 6, 30, 22), period.To);
Assert.False(period.IsToDate);
Assert.False(period.ExtendsPastNow);
}
[Fact]
public void A_custom_range_reaching_past_now_is_capped_at_now_and_keeps_the_requested_dates()
{
var now = At(Berlin, 2026, 9, 19, 14, 37);
var period = Custom(Day(2026, 9, 1), Day(2026, 12, 31), now);
Assert.Equal(Day(2026, 9, 1), period.FirstDay);
Assert.Equal(Day(2026, 12, 31), period.LastDay);
Assert.Equal(Utc(2026, 8, 31, 22), period.From);
Assert.Equal(now, period.To);
Assert.True(period.IsToDate);
Assert.True(period.ExtendsPastNow);
Assert.False(period.NotYetOccurred);
Assert.Equal(Day(2026, 9, 19), period.EffectiveLastDay());
// Rows recorded after now are looked for up to the end of what was asked.
Assert.Equal(Utc(2026, 12, 31, 23), period.NominalEnd());
}
[Fact]
public void A_custom_range_ending_today_is_cut_at_now_but_does_not_extend_past_it()
{
var now = At(Berlin, 2026, 9, 19, 14, 37);
var period = Custom(Day(2026, 9, 10), Day(2026, 9, 19), now);
Assert.Equal(now, period.To);
Assert.True(period.IsToDate);
Assert.False(period.ExtendsPastNow);
}
[Fact]
public void A_custom_range_entirely_in_the_future_has_not_occurred_and_an_empty_query_range()
{
var period = Custom(Day(2027, 1, 1), Day(2027, 3, 31), At(Berlin, 2026, 9, 19, 14, 37));
Assert.True(period.NotYetOccurred);
Assert.Equal(Day(2027, 1, 1), period.FirstDay);
Assert.Equal(Day(2027, 3, 31), period.LastDay);
Assert.Equal(Utc(2026, 12, 31, 23), period.From);
// Nothing can be counted as an actual: [From, To) is empty.
Assert.Equal(period.From, period.To);
Assert.False(period.IsToDate);
Assert.True(period.ExtendsPastNow);
Assert.False(period.HasNoHistory());
Assert.True(period.HasNotStarted());
}
[Fact]
public void A_range_that_has_not_started_has_no_effective_days_so_no_rollup_day_is_summed_as_an_actual()
{
// Summing daily rollups from FirstDay to EffectiveLastDay must not reach into 2027: every row there
// is recorded after now (D-04).
var period = Custom(Day(2027, 1, 1), Day(2027, 3, 31), At(Berlin, 2026, 9, 19, 14, 37));
Assert.Equal(Day(2026, 12, 31), period.EffectiveLastDay());
Assert.True(period.EffectiveLastDay() < period.FirstDay);
}
[Fact]
public void The_effective_last_day_is_today_for_a_to_date_period_and_the_last_day_for_a_complete_one()
{
var now = At(Berlin, 2026, 9, 19, 14, 37);
Assert.Equal(Day(2026, 9, 19), Custom(Day(2026, 9, 1), Day(2026, 12, 31), now).EffectiveLastDay());
Assert.Equal(Day(2026, 8, 31), Resolve(PeriodPreset.LastMonth, now).EffectiveLastDay());
Assert.Equal(Day(2026, 9, 18), PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin).EffectiveLastDay());
}
[Fact]
public void At_the_exact_local_midnight_that_starts_a_month_its_month_to_date_has_started_with_nothing_elapsed()
{
var now = At(Berlin, 2026, 10, 1);
var period = Resolve(PeriodPreset.MonthToDate, now);
// Like the current month of "last 12 months" at the same instant: today exists, and is empty.
Assert.Equal((period.From, period.From), (period.To, period.Now));
Assert.True(period.IsToDate);
Assert.False(period.HasNotStarted());
Assert.Equal((Day(2026, 10, 1), Day(2026, 10, 1)), (period.FirstDay, period.LastDay));
Assert.Equal(Day(2026, 10, 1), period.EffectiveLastDay());
}
[Fact]
public void At_the_exact_local_midnight_no_history_is_neither_started_nor_waiting_to_start()
{
var period = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, At(Berlin, 2026, 10, 1), Berlin);
Assert.True(period.HasNoHistory());
Assert.False(period.HasNotStarted());
}
[Theory]
[InlineData(null, "2026-09-30")]
[InlineData("2026-10-01", null)]
[InlineData("2026-09-30", "2026-09-01")]
[InlineData("1899-12-31", "2026-01-01")]
[InlineData("2026-01-01", "2300-01-01")]
public void A_custom_range_that_is_missing_reversed_or_out_of_bounds_is_rejected(string? first, string? last)
{
DateOnly? from = first is null ? null : Iso(first);
DateOnly? to = last is null ? null : Iso(last);
Assert.False(PeriodResolver.IsValidCustomRange(from, to));
Assert.Throws<ArgumentException>(() =>
PeriodResolver.Resolve(PeriodPreset.Custom, from, to, At(Berlin, 2026, 9, 19, 14, 37), Berlin));
}
[Fact]
public void All_history_spans_the_available_data_and_stops_at_its_last_day_when_that_is_in_the_past()
{
var now = At(Berlin, 2026, 9, 19, 14, 37);
var period = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(1997, 1, 1), Day(2026, 5, 31));
Assert.Equal(Day(1997, 1, 1), period.FirstDay);
Assert.Equal(Day(2026, 5, 31), period.LastDay);
Assert.Equal(Utc(1996, 12, 31, 23), period.From);
Assert.Equal(Utc(2026, 5, 31, 22), period.To);
Assert.False(period.IsToDate);
}
[Fact]
public void All_history_of_live_data_runs_to_now()
{
var now = At(Berlin, 2026, 9, 19, 14, 37);
var open = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(2020, 3, 1));
var toToday = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(2020, 3, 1), Day(2026, 9, 19));
Assert.Equal(now, open.To);
Assert.True(open.IsToDate);
Assert.Equal(Day(2026, 9, 19), open.LastDay);
Assert.Equal(open, toToday);
}
[Fact]
public void All_history_without_any_available_data_is_an_explicit_no_history_result()
{
var now = At(Berlin, 2026, 9, 19, 14, 37);
var period = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin);
Assert.True(period.HasNoHistory());
Assert.Equal(PeriodPreset.AllHistory, period.Preset);
Assert.Equal(Day(2026, 9, 19), period.FirstDay);
Assert.True(period.LastDay < period.FirstDay);
Assert.Equal(period.From, period.To);
Assert.Equal(Utc(2026, 9, 18, 22), period.From);
// "No history" is not "not yet occurred", and nothing is to date.
Assert.False(period.NotYetOccurred);
Assert.False(period.IsToDate);
Assert.False(period.ExtendsPastNow);
}
[Fact]
public void Availability_whose_last_day_precedes_its_first_is_treated_as_no_history()
{
var period = PeriodResolver.Resolve(
PeriodPreset.AllHistory, null, null, At(Berlin, 2026, 9, 19, 14, 37), Berlin, Day(2026, 5, 1), Day(2026, 4, 30));
Assert.True(period.HasNoHistory());
}
[Theory]
[InlineData("0001-01-01")]
[InlineData("0206-05-01")]
[InlineData("1899-12-31")]
public void All_history_reads_a_stray_reading_date_before_1900_as_1900_instead_of_throwing(string strayFirst)
{
// Availability comes from the data, not from a validated URL token: year 1 used to throw from the
// local-midnight arithmetic in Berlin, and year 206 spanned 1,821 years.
var now = At(Berlin, 2026, 9, 19, 14, 37);
var period = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Iso(strayFirst));
Assert.Equal(PeriodResolver.MinSupportedDate, period.FirstDay);
Assert.Equal(Day(2026, 9, 19), period.LastDay);
Assert.Equal(now, period.To);
}
[Fact]
public void All_history_reads_availability_after_today_as_today_because_actuals_stop_at_now()
{
var now = At(Berlin, 2026, 9, 19, 14, 37);
var future = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(2020, 3, 1), Day(2027, 3, 1));
var onlyFuture = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(2027, 1, 1));
Assert.Equal(PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(2020, 3, 1)), future);
Assert.True(future.IsToDate);
Assert.False(future.ExtendsPastNow);
Assert.True(onlyFuture.HasNoHistory());
}
[Fact]
public void All_history_is_open_ended_so_rows_recorded_far_after_now_are_still_looked_for()
{
var now = At(Berlin, 2026, 9, 19, 14, 37);
var live = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(2020, 3, 1));
var historical = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin, Day(1997, 1, 1), Day(2026, 5, 31));
// A Tasmota row stamped in 2027 must reach the "recorded after now" block (D-04): no upper bound.
Assert.Null(live.NominalEnd());
Assert.Null(historical.NominalEnd());
Assert.Equal(Day(2026, 9, 19), live.NominalLastDay());
// Every other preset names where it ends.
Assert.Equal(Utc(2026, 12, 31, 23), Resolve(PeriodPreset.YearToDate, now).NominalEnd());
Assert.Equal(Utc(2026, 8, 31, 22), Resolve(PeriodPreset.LastMonth, now).NominalEnd());
}
[Fact]
public void Presets_ignore_custom_dates_and_custom_ignores_availability()
{
var now = At(Berlin, 2026, 9, 19, 14, 37);
var month = PeriodPreset.MonthToDate;
Assert.Equal(
PeriodResolver.Resolve(month, null, null, now, Berlin),
PeriodResolver.Resolve(month, Day(2020, 1, 1), Day(2020, 1, 31), now, Berlin, Day(1997, 1, 1)));
var custom = PeriodResolver.Resolve(PeriodPreset.Custom, Day(2026, 6, 1), Day(2026, 6, 30), now, Berlin, Day(1997, 1, 1), Day(1998, 1, 1));
Assert.Equal(Day(2026, 6, 1), custom.FirstDay);
}
}
@@ -0,0 +1,62 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// Provenance is its own dimension (D-14): which qualities a bucket's amount rests on, whatever its status.
/// </summary>
public sealed class ProvenanceRulesTests
{
[Fact]
public void Each_quality_with_an_amount_sets_its_own_flag()
{
Assert.Equal(Provenance.Measured, ProvenanceRules.ProvenanceOf(1, 0, 0, 0, false, false));
Assert.Equal(Provenance.Manual, ProvenanceRules.ProvenanceOf(0, 2, 0, 0, false, false));
Assert.Equal(Provenance.Imported, ProvenanceRules.ProvenanceOf(0, 0, 3, 0, false, false));
Assert.Equal(Provenance.Estimated, ProvenanceRules.ProvenanceOf(0, 0, 0, 4, false, false));
}
[Fact]
public void A_bucket_mixing_imported_months_and_divided_shares_is_both_imported_and_estimated()
{
var provenance = ProvenanceRules.ProvenanceOf(0, 0, 14, 2.5, false, false);
Assert.Equal(Provenance.Imported | Provenance.Estimated, provenance);
}
[Fact]
public void A_negative_amount_is_still_a_contribution_because_savings_and_balances_are_signed()
{
Assert.Equal(Provenance.Measured, ProvenanceRules.ProvenanceOf(-5, 0, 0, 0, false, false));
}
[Fact]
public void Float_noise_left_by_dividing_and_resumming_does_not_count_as_a_contribution()
{
Assert.Equal(Provenance.Manual, ProvenanceRules.ProvenanceOf(1e-12, 7, -1e-10, 0, false, false));
}
[Fact]
public void Opening_balance_and_derived_are_flags_of_their_own()
{
Assert.Equal(
Provenance.Measured | Provenance.OpeningBalance | Provenance.Derived,
ProvenanceRules.ProvenanceOf(3, 0, 0, 0, openingBalance: true, derived: true));
}
[Fact]
public void A_true_zero_has_no_provenance()
{
Assert.Equal(Provenance.None, ProvenanceRules.ProvenanceOf(0, 0, 0, 0, false, false));
}
[Fact]
public void A_derived_value_keeps_everything_its_sources_rest_on()
{
var derived = ProvenanceRules.Derive([Provenance.Measured, Provenance.Imported | Provenance.Estimated]);
Assert.Equal(Provenance.Derived | Provenance.Measured | Provenance.Imported | Provenance.Estimated, derived);
Assert.Equal(Provenance.Derived, ProvenanceRules.Derive([]));
}
}
@@ -0,0 +1,219 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using MeterVault.Core.Analysis.Totals;
using static MeterVault.Core.Tests.Analysis.CoverageTestData;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// The pure pieces the Infrastructure reader rests on: a period as one bucket, lifecycle zeros (D-24), available
/// ranges (D-19) and the sum of a measure's members (D-22).
/// </summary>
public sealed class ReaderSupportTests
{
private static readonly DateTimeOffset Now = At(2026, 9, 19, 14, 37);
[Theory]
[InlineData(2025, 1, 1, 2025, 12, 31, BucketSize.Year)]
[InlineData(2024, 1, 1, 2025, 12, 31, BucketSize.Year)]
[InlineData(2026, 2, 1, 2026, 2, 28, BucketSize.Month)]
[InlineData(2025, 10, 1, 2026, 9, 30, BucketSize.Month)]
[InlineData(2026, 9, 14, 2026, 9, 20, BucketSize.Week)]
[InlineData(2026, 9, 15, 2026, 9, 21, BucketSize.Day)]
[InlineData(2026, 1, 15, 2026, 3, 31, BucketSize.Day)]
public void A_period_is_the_coarsest_unit_it_is_made_of(int y1, int m1, int d1, int y2, int m2, int d2, BucketSize expected) =>
Assert.Equal(expected, PeriodBucket.AlignedSize(new DateOnly(y1, m1, d1), new DateOnly(y2, m2, d2)));
[Fact]
public void A_year_to_date_is_a_year_cut_at_now()
{
var period = PeriodResolver.Resolve(PeriodPreset.YearToDate, null, null, Now, Berlin);
var bucket = PeriodBucket.Of(period);
Assert.Equal(BucketSize.Year, bucket.Size);
Assert.Equal(new DateOnly(2026, 1, 1), bucket.FirstDay);
Assert.Equal(new DateOnly(2026, 9, 20), bucket.EndDay);
Assert.Equal(period.From, bucket.From);
Assert.Equal(Now, bucket.To);
Assert.Equal(new DateOnly(2027, 1, 1), bucket.NominalEndDay);
}
[Fact]
public void A_monthly_source_resolves_the_month_as_a_period_although_not_its_days()
{
// The reason totals get their own status: last month by day is unresolved day by day, resolved as a month.
var period = PeriodResolver.Resolve(PeriodPreset.LastMonth, null, null, Now, Berlin);
var run = new CoverageRun(Midnight(new DateOnly(2026, 8, 1)), Midnight(new DateOnly(2026, 9, 1)), ResolutionClass.Month, DividedAtMonths: true);
var total = CoverageEvaluator.Evaluate(PeriodBucket.Of(period), [run], Berlin, openingBalanceInBucket: false, Now);
var day = CoverageEvaluator.Evaluate(Day(2026, 8, 10), [run], Berlin, openingBalanceInBucket: false, Now);
Assert.Equal(BucketStatus.Available, total.Status);
Assert.Equal(BucketStatus.Unresolved, day.Status);
}
[Fact]
public void A_period_that_has_not_started_is_an_empty_bucket()
{
var period = PeriodResolver.Resolve(PeriodPreset.Custom, new DateOnly(2026, 11, 1), new DateOnly(2026, 11, 30), Now, Berlin);
var bucket = PeriodBucket.Of(period);
Assert.Equal(bucket.From, bucket.To);
Assert.Equal(bucket.FirstDay, bucket.EndDay);
}
[Fact]
public void Time_outside_the_service_period_is_known_zero_coverage()
{
var runs = LifecycleCoverage.OutsideService(new DateOnly(2026, 2, 10), new DateOnly(2026, 6, 30), Berlin);
Assert.Equal(2, runs.Count);
Assert.Equal(Midnight(new DateOnly(2026, 2, 10)), runs[0].To);
Assert.Equal(Midnight(new DateOnly(2026, 7, 1)), runs[1].From);
Assert.All(runs, r => Assert.False(r.IsGap));
Assert.All(runs, r => Assert.Equal(ResolutionClass.Hour, r.Resolution));
Assert.Empty(LifecycleCoverage.OutsideService(null, null, Berlin));
}
[Fact]
public void A_meter_installed_mid_month_contributes_a_whole_month()
{
// Installed 10 February with daily data from then: as its own series February is partial, as a member of a
// total the days before installation are a known zero and February is complete (D-24).
var data = new CoverageRun(Midnight(new DateOnly(2026, 2, 10)), Midnight(new DateOnly(2026, 3, 1)), ResolutionClass.Day, DividedAtMonths: true);
var february = Month(2026, 2);
var own = CoverageEvaluator.Evaluate(february, [data], Berlin, false, Now);
var contributing = CoverageEvaluator.Evaluate(
february, LifecycleCoverage.WithService([data], new DateOnly(2026, 2, 10), null, Berlin), Berlin, false, Now);
Assert.Equal(BucketStatus.Partial, own.Status);
Assert.Equal(BucketStatus.Available, contributing.Status);
}
[Fact]
public void A_retired_meters_zero_is_known_only_up_to_now()
{
var runs = LifecycleCoverage.OutsideService(null, new DateOnly(2026, 6, 30), Berlin);
var capped = CoverageRuns.CapAt(runs, Now, Berlin);
Assert.Equal(Now, Assert.Single(capped).To);
}
[Fact]
public void Available_range_is_capped_at_now_and_names_its_latest_month()
{
// A current-month label row covers September but closes after now: availability ends where it starts.
var runs = new[]
{
new CoverageRun(Midnight(new DateOnly(2025, 11, 1)), Midnight(new DateOnly(2026, 10, 1)), ResolutionClass.Month, true,
LastIntervalStart: Midnight(new DateOnly(2026, 9, 1))),
};
var range = AvailableRange.OfRuns(runs, Now, Berlin);
Assert.NotNull(range);
Assert.Equal(new DateOnly(2025, 11, 1), range.FirstDay);
Assert.Equal(new DateOnly(2026, 8, 31), range.LastDay);
Assert.Equal(new DateOnly(2026, 8, 1), range.LatestMonth);
Assert.Null(AvailableRange.OfRuns([], Now, Berlin));
}
[Fact]
public void Available_ranges_unite_to_their_outer_bounds()
{
var early = AvailableRange.Of(Midnight(new DateOnly(2022, 11, 1)), Midnight(new DateOnly(2023, 1, 1)), Berlin);
var late = AvailableRange.Of(Midnight(new DateOnly(2026, 5, 1)), Midnight(new DateOnly(2026, 6, 1)), Berlin);
var union = AvailableRange.Union([early, null, late], Berlin);
Assert.NotNull(union);
Assert.Equal(new DateOnly(2022, 11, 1), union.FirstDay);
Assert.Equal(new DateOnly(2026, 5, 31), union.LastDay);
Assert.Equal(new DateOnly(2026, 5, 1), union.LatestMonth);
Assert.Null(AvailableRange.Union([null], Berlin));
}
[Fact]
public void Measure_members_add_up_when_all_are_available()
{
var sum = MeasureValues.Sum([(1, BucketValue.Available(100, Provenance.Measured)), (2, BucketValue.Available(150, Provenance.Imported))]);
Assert.Equal(BucketStatus.Available, sum.Status);
Assert.Equal(250, sum.Value);
Assert.Equal(Provenance.Measured | Provenance.Imported, sum.Provenance);
}
[Fact]
public void A_missing_member_makes_the_total_partial_and_names_it()
{
var sum = MeasureValues.Sum([(1, BucketValue.Available(100, Provenance.Measured)), (2, BucketValue.Missing())]);
Assert.Equal(BucketStatus.Partial, sum.Status);
Assert.Equal(100, sum.Value);
Assert.Equal(ValueIssue.MissingSource, sum.Issue);
Assert.Equal([2], sum.DependencyPath);
}
[Fact]
public void Without_any_member_data_the_total_is_missing()
{
var sum = MeasureValues.Sum([(1, BucketValue.Missing()), (2, BucketValue.Missing())]);
Assert.Equal(BucketStatus.Missing, sum.Status);
Assert.Null(sum.Value);
}
[Theory]
[InlineData(BucketStatus.Pending, ValueIssue.AnalysisPending)]
[InlineData(BucketStatus.Invalid, ValueIssue.NonFinite)]
[InlineData(BucketStatus.Unresolved, ValueIssue.CoarseResolution)]
public void A_spoiling_member_decides_the_total(BucketStatus status, ValueIssue issue)
{
var spoiling = new BucketValue(null, status, Provenance.None, issue, null, [7, 3]);
var sum = MeasureValues.Sum([(1, BucketValue.Available(100, Provenance.Measured)), (7, spoiling), (2, BucketValue.Missing())]);
Assert.Equal(status, sum.Status);
Assert.Null(sum.Value);
Assert.Equal(issue, sum.Issue);
Assert.Equal([7, 3], sum.DependencyPath);
}
[Fact]
public void A_partial_member_keeps_its_issue_and_signed_values_stay_signed()
{
var partial = new BucketValue(-40, BucketStatus.Partial, Provenance.Derived, ValueIssue.OpeningBalance);
var sum = MeasureValues.Sum([(1, BucketValue.Available(-10, Provenance.Measured)), (5, partial)]);
Assert.Equal(BucketStatus.Partial, sum.Status);
Assert.Equal(-50, sum.Value);
Assert.Equal(ValueIssue.OpeningBalance, sum.Issue);
Assert.Equal([5], sum.DependencyPath);
}
[Fact]
public void A_series_of_members_is_summed_bucket_by_bucket()
{
IReadOnlyList<BucketValue> a = [BucketValue.Available(100, Provenance.Measured), BucketValue.Available(80, Provenance.Measured)];
IReadOnlyList<BucketValue> b = [BucketValue.Available(150, Provenance.Measured), BucketValue.Missing()];
var series = MeasureValues.SumSeries([(1, a), (2, b)], 2);
Assert.Equal(250, series[0].Value);
Assert.Equal(BucketStatus.Available, series[0].Status);
Assert.Equal(80, series[1].Value);
Assert.Equal(BucketStatus.Partial, series[1].Status);
Assert.Throws<ArgumentException>(() => MeasureValues.SumSeries([(1, a)], 3));
}
private static AnalysisBucket Month(int year, int month)
{
var first = new DateOnly(year, month, 1);
return new AnalysisBucket(first, first.AddMonths(1), Midnight(first), Midnight(first.AddMonths(1)), BucketSize.Month);
}
}
@@ -0,0 +1,101 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using static MeterVault.Core.Tests.Analysis.AnalysisTestTime;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// The resolution classes of D-13, at their exact limits: an hour and a minute, 25 hours, seven days and
/// an hour, 31 days and two hours. The slack is what keeps real intervals — a late poll, a DST day, a
/// month with a DST hour — in the class they belong to. There is one classifier (A-09); the bucket side of
/// the same scale — which class a bucket size needs — is pinned here too.
/// </summary>
public sealed class ResolutionClassifierTests
{
public static TheoryData<TimeSpan, ResolutionClass> Limits => new()
{
{ TimeSpan.Zero, ResolutionClass.Hour },
{ TimeSpan.FromMinutes(5), ResolutionClass.Hour },
{ TimeSpan.FromMinutes(60), ResolutionClass.Hour },
{ TimeSpan.FromMinutes(61), ResolutionClass.Hour },
{ TimeSpan.FromMinutes(61) + TimeSpan.FromTicks(1), ResolutionClass.Day },
{ TimeSpan.FromHours(23), ResolutionClass.Day },
{ TimeSpan.FromHours(25), ResolutionClass.Day },
{ TimeSpan.FromHours(25) + TimeSpan.FromSeconds(1), ResolutionClass.Week },
{ TimeSpan.FromDays(7) + TimeSpan.FromHours(1), ResolutionClass.Week },
{ TimeSpan.FromDays(7) + TimeSpan.FromHours(1) + TimeSpan.FromSeconds(1), ResolutionClass.Month },
{ TimeSpan.FromDays(31) + TimeSpan.FromHours(1), ResolutionClass.Month },
{ TimeSpan.FromDays(31) + TimeSpan.FromHours(2), ResolutionClass.Month },
{ TimeSpan.FromDays(31) + TimeSpan.FromHours(2) + TimeSpan.FromSeconds(1), ResolutionClass.Coarse },
{ TimeSpan.FromDays(365 * 12), ResolutionClass.Coarse },
};
[Theory]
[MemberData(nameof(Limits))]
public void An_interval_is_classified_by_its_length_up_to_each_limit_inclusive(TimeSpan length, ResolutionClass expected)
{
Assert.Equal(expected, ResolutionClassifier.Classify(length));
}
[Fact]
public void A_negative_length_claims_no_time_and_is_the_finest_class()
{
Assert.Equal(ResolutionClass.Hour, ResolutionClassifier.Classify(TimeSpan.FromHours(-3)));
}
[Fact]
public void The_long_autumn_day_is_still_a_day_and_october_with_its_extra_hour_still_a_month()
{
Assert.Equal(TimeSpan.FromHours(25), InBerlin(2026, 10, 26) - InBerlin(2026, 10, 25));
Assert.Equal(ResolutionClass.Day, ResolutionClassifier.Classify(InBerlin(2026, 10, 25), InBerlin(2026, 10, 26)));
Assert.Equal(ResolutionClass.Month, ResolutionClassifier.Classify(InBerlin(2026, 10, 1), InBerlin(2026, 11, 1)));
Assert.Equal(ResolutionClass.Month, ResolutionClassifier.Classify(InBerlin(2027, 2, 1), InBerlin(2027, 3, 1)));
}
[Fact]
public void A_week_read_an_hour_late_is_still_a_week_but_two_weeks_are_not()
{
Assert.Equal(ResolutionClass.Week, ResolutionClassifier.Classify(InBerlin(2026, 9, 7, 10), InBerlin(2026, 9, 14, 11)));
Assert.Equal(ResolutionClass.Month, ResolutionClassifier.Classify(InBerlin(2026, 9, 7, 10), InBerlin(2026, 9, 21, 10)));
}
[Theory]
[InlineData(ResolutionClass.Hour, 61)]
[InlineData(ResolutionClass.Day, 25 * 60)]
[InlineData(ResolutionClass.Week, (7 * 24 * 60) + 60)]
[InlineData(ResolutionClass.Month, (31 * 24 * 60) + 120)]
public void Each_class_admits_intervals_up_to_its_own_limit(ResolutionClass resolution, int minutes)
{
Assert.Equal(TimeSpan.FromMinutes(minutes), ResolutionClassifier.LimitOf(resolution));
Assert.Equal(resolution, ResolutionClassifier.Classify(ResolutionClassifier.LimitOf(resolution)));
}
[Fact]
public void Data_coarser_than_a_month_has_no_limit()
{
Assert.Equal(TimeSpan.MaxValue, ResolutionClassifier.LimitOf(ResolutionClass.Coarse));
}
[Theory]
[InlineData(BucketSize.Day, ResolutionClass.Day)]
[InlineData(BucketSize.Week, ResolutionClass.Week)]
[InlineData(BucketSize.Month, ResolutionClass.Month)]
[InlineData(BucketSize.Year, ResolutionClass.Month)]
public void A_bucket_size_names_the_coarsest_class_that_resolves_it_outright(BucketSize size, ResolutionClass expected)
{
Assert.Equal(expected, ResolutionClassifier.CoarsestResolving(size));
}
[Fact]
public void Auto_has_no_class_because_it_is_resolved_before_any_bucket_exists()
{
Assert.Throws<ArgumentOutOfRangeException>(() => ResolutionClassifier.CoarsestResolving(BucketSize.Auto));
}
[Fact]
public void The_coarsest_of_two_classes_is_the_resolution_of_a_combined_value()
{
Assert.Equal(ResolutionClass.Month, ResolutionClassifier.Coarsest(ResolutionClass.Hour, ResolutionClass.Month));
Assert.Equal(ResolutionClass.Coarse, ResolutionClassifier.Coarsest(ResolutionClass.Coarse, ResolutionClass.Day));
}
}
@@ -0,0 +1,278 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Rollups;
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using static MeterVault.Core.Tests.Analysis.AnalysisTestTime;
using static MeterVault.Core.Tests.TestData;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// Day and month rollups (D-12): the buckets analysis reads instead of consumption. A rollup must sum exactly the
/// rows filed in its local day or month, keep the amount's provenance by quality, and carry the markers the
/// coverage evaluator and the recorded-after-now check need (opening balance, divided, latest interval end).
/// </summary>
public sealed class RollupBuilderTests
{
private readonly INormalizationEngine _engine = NormalizationEngine.CreateDefault();
private static Consumption Row(
DateTimeOffset time,
double amount,
ReadingQuality quality = ReadingQuality.Measured,
ConsumptionKind kind = ConsumptionKind.Consumption,
DateTimeOffset? intervalEnd = null,
bool divided = false,
bool openingBalance = false) => new()
{
MeterId = 1,
Time = time.ToUniversalTime(),
Amount = amount,
Quality = quality,
Kind = kind,
IntervalEnd = intervalEnd?.ToUniversalTime(),
Divided = divided,
OpeningBalance = openingBalance,
};
private IReadOnlyList<Consumption> Normalize(
MeterMode mode, TimeZoneInfo zone, IReadOnlyList<Reading> readings, DateOnly? installedAt = null) =>
_engine.Normalize(new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = mode, Unit = "kWh", InstalledAt = installedAt },
Readings = readings,
Events = [],
TimeZone = zone,
});
private static Reading Measured(DateTimeOffset time, double value) =>
new() { MeterId = 1, Time = time.ToUniversalTime(), Value = value, Quality = ReadingQuality.Measured };
[Fact]
public void No_rows_give_no_buckets()
{
var rollups = RollupBuilder.Build([], Berlin);
Assert.Empty(rollups.Days);
Assert.Empty(rollups.Months);
}
[Fact]
public void A_row_is_filed_under_the_local_day_and_month_of_its_stamp()
{
// 23:30 UTC on 31 January is 00:30 on 1 February in Berlin: February, not January.
var rollups = RollupBuilder.Build([Row(Utc(2026, 1, 31, 23, 30), 5), Row(Utc(2026, 1, 31, 22, 30), 3)], Berlin);
Assert.Equal([new DateOnly(2026, 1, 31), new DateOnly(2026, 2, 1)], rollups.Days.Select(d => d.Start));
Assert.Equal([3d, 5d], rollups.Days.Select(d => d.Amount));
Assert.Equal([new DateOnly(2026, 1, 1), new DateOnly(2026, 2, 1)], rollups.Months.Select(m => m.Start));
Assert.Equal([3d, 5d], rollups.Months.Select(m => m.Amount));
}
[Fact]
public void The_same_rows_land_in_other_days_in_another_zone()
{
var rows = new[] { Row(Utc(2026, 1, 31, 23, 30), 5) };
Assert.Equal(new DateOnly(2026, 1, 31), Assert.Single(RollupBuilder.Build(rows, TimeZoneInfo.Utc).Days).Start);
Assert.Equal(new DateOnly(2026, 1, 31), Assert.Single(RollupBuilder.Build(rows, NewYork).Days).Start);
Assert.Equal(new DateOnly(2026, 2, 1), Assert.Single(RollupBuilder.Build(rows, Berlin).Days).Start);
}
[Fact]
public void Amounts_are_split_by_quality_and_estimated_takes_interpolated_too()
{
var day = InBerlin(2026, 3, 10, 12);
var rollups = RollupBuilder.Build(
[
Row(day, 1, ReadingQuality.Measured),
Row(day.AddMinutes(1), 2, ReadingQuality.Manual),
Row(day.AddMinutes(2), 4, ReadingQuality.Imported),
Row(day.AddMinutes(3), 8, ReadingQuality.Estimated),
Row(day.AddMinutes(4), 16, ReadingQuality.Interpolated),
Row(day.AddMinutes(5), -0.5, ReadingQuality.Measured),
], Berlin);
var bucket = Assert.Single(rollups.Days);
Assert.Equal(30.5, bucket.Amount);
Assert.Equal(0.5, bucket.Measured);
Assert.Equal(2, bucket.Manual);
Assert.Equal(4, bucket.Imported);
Assert.Equal(24, bucket.Estimated);
Assert.Equal(6, bucket.Rows);
Assert.Equal(RollupFlags.None, bucket.Flags);
Assert.Equal(Provenance.Measured | Provenance.Manual | Provenance.Imported | Provenance.Estimated, bucket.Provenance);
}
[Fact]
public void Kinds_are_separate_buckets_of_the_same_day()
{
var noon = InBerlin(2026, 6, 1, 12);
var rollups = RollupBuilder.Build(
[
Row(noon, 7, kind: ConsumptionKind.Generation),
Row(noon, 3, kind: ConsumptionKind.Consumption),
], Berlin);
Assert.Equal(
[(ConsumptionKind.Consumption, 3d), (ConsumptionKind.Generation, 7d)],
rollups.Days.Select(d => (d.Kind, d.Amount)));
Assert.Equal(2, rollups.Months.Count);
}
[Fact]
public void A_bucket_is_flagged_when_any_of_its_rows_is_an_opening_balance_or_divided()
{
var rollups = RollupBuilder.Build(
[
Row(InBerlin(2026, 4, 1, 8), 700, openingBalance: true),
Row(InBerlin(2026, 4, 1, 9), 1),
Row(InBerlin(2026, 4, 30, 23, 59, 59), 20, ReadingQuality.Estimated, divided: true),
Row(InBerlin(2026, 5, 2, 9), 1),
], Berlin);
Assert.Equal(
[RollupFlags.OpeningBalance, RollupFlags.Divided, RollupFlags.None],
rollups.Days.Select(d => d.Flags));
Assert.Equal([RollupFlags.OpeningBalance | RollupFlags.Divided, RollupFlags.None], rollups.Months.Select(m => m.Flags));
Assert.True(rollups.Days[0].HasOpeningBalance);
Assert.True(rollups.Days[0].Provenance.HasFlag(Provenance.OpeningBalance));
}
[Fact]
public void The_latest_interval_end_falls_back_to_the_stamp_for_rows_without_an_interval()
{
var stamp = InBerlin(2026, 7, 3, 10);
var rollups = RollupBuilder.Build(
[
Row(stamp, 1, intervalEnd: stamp.AddHours(2)),
Row(stamp.AddHours(1), 1),
], Berlin);
Assert.Equal(stamp.AddHours(2).ToUniversalTime(), Assert.Single(rollups.Days).MaxIntervalEnd);
Assert.Equal(TimeSpan.Zero, rollups.Days[0].MaxIntervalEnd.Offset);
var bare = RollupBuilder.Build([Row(stamp, 1)], Berlin);
Assert.Equal(stamp.ToUniversalTime(), Assert.Single(bare.Days).MaxIntervalEnd);
}
[Theory]
[InlineData("UTC")]
[InlineData("Europe/Berlin")]
[InlineData("America/New_York")]
public void A_monthly_sheet_row_is_its_month_and_ends_where_the_month_ends(string zoneId)
{
// A-05: "Mai 2026" is the register at the end of May. Its row is stamped inside May, and it is recorded
// up to the local midnight that ends May — a reader on 20 May must not count it as an actual.
var zone = zoneId == "UTC" ? TimeZoneInfo.Utc : TimeZoneInfo.FindSystemTimeZoneById(zoneId);
var readings = Enumerable.Range(0, 4).Select(i => Reading(1, Month(2026, 3).AddMonths(i), 100 + (10 * i))).ToList();
var rollups = RollupBuilder.Build(Normalize(MeterMode.CumulativeCounter, zone, readings), zone);
Assert.Equal(
[new DateOnly(2026, 3, 1), new DateOnly(2026, 4, 1), new DateOnly(2026, 5, 1), new DateOnly(2026, 6, 1)],
rollups.Months.Select(m => m.Start));
Assert.Equal([100d, 10d, 10d, 10d], rollups.Months.Select(m => m.Amount));
Assert.All(rollups.Months, m => Assert.Equal(m.Amount, m.Imported));
Assert.Equal(
GapAttribution.LocalMidnight(new DateOnly(2026, 6, 1), zone),
rollups.Months.Single(m => m.Start == new DateOnly(2026, 5, 1)).MaxIntervalEnd);
// One row each, filed on the 1st of its month: the day table agrees with the month table.
Assert.Equal(rollups.Months.Select(m => (m.Start, m.Amount)), rollups.Days.Select(d => (d.Start, d.Amount)));
}
[Fact]
public void A_first_reading_without_a_start_flags_its_day_but_an_install_date_does_not()
{
var readings = new[] { Measured(InBerlin(2026, 8, 3, 9), 700), Measured(InBerlin(2026, 8, 4, 9), 710) };
var unknown = RollupBuilder.Build(Normalize(MeterMode.CumulativeCounter, Berlin, readings), Berlin);
Assert.Equal([RollupFlags.OpeningBalance, RollupFlags.None], unknown.Days.Select(d => d.Flags));
Assert.Equal(RollupFlags.OpeningBalance, Assert.Single(unknown.Months).Flags);
var installed = RollupBuilder.Build(
Normalize(MeterMode.CumulativeCounter, Berlin, readings, installedAt: new DateOnly(2026, 7, 1)), Berlin);
Assert.All(installed.Days, d => Assert.Equal(RollupFlags.None, d.Flags));
Assert.Equal(710, installed.Months.Sum(m => m.Amount));
}
[Fact]
public void An_interval_across_a_month_boundary_is_divided_between_both_months()
{
var august1 = InBerlin(2026, 8, 1, 9);
var september16 = InBerlin(2026, 9, 16, 18);
var rows = Normalize(MeterMode.CumulativeCounter, Berlin, [Measured(august1, 700), Measured(september16, 746)]);
var rollups = RollupBuilder.Build(rows, Berlin);
var august = rollups.Months.Single(m => m.Start == new DateOnly(2026, 8, 1));
var september = rollups.Months.Single(m => m.Start == new DateOnly(2026, 9, 1));
var augustShare = 46 * ((InBerlin(2026, 9, 1) - august1) / (september16 - august1));
Assert.Equal(700 + augustShare, august.Amount, 9);
Assert.Equal(46 - augustShare, september.Amount, 9);
// The first reading is measured; the shares are estimates, divided at 1 September.
Assert.Equal(700, august.Measured, 9);
Assert.Equal(augustShare, august.Estimated, 9);
Assert.Equal(46 - augustShare, september.Estimated, 9);
Assert.True(august.Flags.HasFlag(RollupFlags.Divided));
Assert.True(september.Flags.HasFlag(RollupFlags.Divided));
// August's share is stamped at its last second, and its interval ends at the start of September.
Assert.Equal(InBerlin(2026, 9, 1).ToUniversalTime(), august.MaxIntervalEnd);
Assert.Equal(new DateOnly(2026, 8, 31), rollups.Days.Single(d => d.Estimated != 0 && d.Start.Month == 8).Start);
}
[Fact]
public void Daily_snapshots_at_local_midnight_are_booked_in_the_day_they_close()
{
// D-11: the 00:00 reading on 11 March closes 10 March.
var readings = Enumerable.Range(0, 4)
.Select(i => Measured(InBerlin(2026, 3, 10).AddDays(i), 100 + (i * i)))
.ToList();
var rollups = RollupBuilder.Build(Normalize(MeterMode.CumulativeCounter, Berlin, readings), Berlin);
Assert.Equal(
[(new DateOnly(2026, 3, 10), 101d), (new DateOnly(2026, 3, 11), 3d), (new DateOnly(2026, 3, 12), 5d)],
rollups.Days.Select(d => (d.Start, d.Amount)));
Assert.Equal(InBerlin(2026, 3, 12).ToUniversalTime(), rollups.Days[1].MaxIntervalEnd);
}
[Fact]
public void Days_and_months_both_sum_to_the_rows_and_do_not_depend_on_row_order()
{
var start = InBerlin(2026, 1, 1, 6);
// Hourly for 75 days, across two month ends and the spring DST change; uneven increments, never falling.
var readings = Enumerable.Range(0, 24 * 75)
.Select(i => Measured(start.AddHours(i), 1000 + (i * 0.77) + (0.5 * Math.Sin(i))))
.ToList();
var rows = Normalize(MeterMode.CumulativeCounter, Berlin, readings);
var rollups = RollupBuilder.Build(rows, Berlin);
var shuffled = RollupBuilder.Build(rows.Reverse().ToList(), Berlin);
Assert.Equal(rows.Sum(r => r.Amount), rollups.Days.Sum(d => d.Amount), 6);
Assert.Equal(rows.Sum(r => r.Amount), rollups.Months.Sum(m => m.Amount), 6);
Assert.Equal(rows.Count, rollups.Days.Sum(d => d.Rows));
Assert.Equal(rows.Count, rollups.Months.Sum(m => m.Rows));
foreach (var month in rollups.Months)
{
var days = rollups.Days.Where(d => RollupBuilder.MonthOf(d.Start) == month.Start).ToList();
Assert.Equal(month.Amount, days.Sum(d => d.Amount), 6);
Assert.Equal(month.Rows, days.Sum(d => d.Rows));
}
// Rebuilding the same rows, in any order, gives bit-identical buckets: a diff write touches nothing.
Assert.Equal(rollups.Days, shuffled.Days);
Assert.Equal(rollups.Months, shuffled.Months);
}
[Fact]
public void Local_day_and_month_helpers_follow_the_zone()
{
Assert.Equal(new DateOnly(2026, 3, 29), RollupBuilder.LocalDay(Utc(2026, 3, 28, 23, 30), Berlin));
Assert.Equal(new DateOnly(2026, 11, 30), RollupBuilder.LocalDay(Utc(2026, 12, 1, 3), NewYork));
Assert.Equal(new DateOnly(2026, 2, 1), RollupBuilder.MonthOf(new DateOnly(2026, 2, 28)));
}
}
@@ -0,0 +1,260 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Domain;
using static MeterVault.Core.Tests.Analysis.TotalsSeed;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// D-35: a heat-pump meter in cascade is a subsection of the house for quantities, but the supplier bills it at
/// its own tariff. Main 300 kWh at 0.30 and heat pump 100 kWh at 0.22 must bill 200 × 0.30 + 100 × 0.22 — so the
/// policy has to say which meter is priced separately and which billed quantity it comes out of, without moving
/// anything in the quantity totals.
/// </summary>
public sealed class SeparatelyBilledSubmeterTests
{
private const int HeatPump = 10;
[Fact]
public void A_heat_pump_below_Haus_with_its_own_price_is_billed_separately_out_of_the_grid_import()
{
var meters = Meters();
meters.Add(Physical(HeatPump, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh"));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(Haus, HeatPump)], id => id == HeatPump);
var billing = result.ForType(Electricity).Billing;
Assert.Equal([Netz], billing.BilledMeterIds);
Assert.Equal([new SeparatelyBilledMeter(HeatPump, Netz, Haus)], billing.SeparatelyBilled);
Assert.True(result.IsBilled(HeatPump));
// The priceable form: Netz minus the heat pump at the normal price, the heat pump at its own.
Assert.Equal([new BillDeduction(HeatPump, 1)], result.LineOf(Netz)!.Deductions);
Assert.Equal(BillLineKind.UnitPrice, result.LineOf(Netz)!.Kind);
Assert.Equal(BillLineKind.OwnPrice, result.LineOf(HeatPump)!.Kind);
Assert.Empty(result.LineOf(HeatPump)!.Deductions);
Assert.Null(result.LineOf(Haus));
// Quantities do not move: the heat pump is still a breakdown of household use.
Assert.Equal(MeterTotalsClass.Breakdown, result.For(HeatPump).Class);
Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
}
[Fact]
public void Without_its_own_price_a_subsection_stays_inside_its_parent_s_bill()
{
var result = TotalsPolicy.Classify(Meters(), Links(), id => id == Haus);
Assert.Empty(result.ForType(Electricity).Billing.SeparatelyBilled);
Assert.False(result.IsBilled(Auto));
}
[Fact]
public void A_billed_meter_with_its_own_price_is_simply_billed_not_billed_twice()
{
var result = TotalsPolicy.Classify(Meters(), Links(), id => id == Netz || id == Wasser);
Assert.Empty(result.ForType(Electricity).Billing.SeparatelyBilled);
Assert.Empty(result.ForType(Water).Billing.SeparatelyBilled);
}
[Fact]
public void Where_household_use_is_billed_the_quantity_comes_out_of_the_parent_directly()
{
var meters = Meters();
meters.Add(Physical(20, "Garden", Water, MeterMode.CumulativeCounter, "m³"));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(Wasser, 20)], id => id == 20);
Assert.Equal([new SeparatelyBilledMeter(20, Wasser, null)], result.ForType(Water).Billing.SeparatelyBilled);
}
[Fact]
public void A_cascade_of_separately_billed_meters_subtracts_each_from_the_one_directly_above()
{
var meters = Meters();
meters.Add(Physical(20, "Garden", Water, MeterMode.CumulativeCounter, "m³"));
meters.Add(Physical(21, "Pool", Water, MeterMode.CumulativeCounter, "m³"));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(Wasser, 20), new(20, 21)], id => id is 20 or 21);
Assert.Equal(
[new SeparatelyBilledMeter(20, Wasser, null), new SeparatelyBilledMeter(21, 20, null)],
result.ForType(Water).Billing.SeparatelyBilled);
Assert.Equal([new BillDeduction(20, 1)], result.LineOf(Wasser)!.Deductions);
Assert.Equal([new BillDeduction(21, 1)], result.LineOf(20)!.Deductions);
}
[Fact]
public void An_unlinked_meter_assumed_inside_the_total_load_is_not_billed_separately_and_its_price_is_reported_unused()
{
// Being inside the total load is only an assumption about an unlinked meter; it must not take energy out of
// the grid bill. The price names the gap, and the hint tells the user to link the meter.
var meters = Meters();
meters.Add(Physical(HeatPump, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh"));
var result = TotalsPolicy.Classify(meters, Links(), id => id == HeatPump);
Assert.Empty(result.ForType(Electricity).Billing.SeparatelyBilled);
Assert.Empty(result.LineOf(Netz)!.Deductions);
Assert.Contains(new TotalsProblem(TotalsProblemKind.UnusedMeterPrice, HeatPump), result.Problems);
Assert.Contains(new OverlapHint(OverlapHintKind.NotLinkedBelowTotalLoad, Electricity, HeatPump, Haus), result.Hints);
}
[Fact]
public void A_subsection_set_to_never_is_not_billed_at_all()
{
var meters = Meters();
meters.Add(Physical(HeatPump, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh", totals: TotalsOverride.Never));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(Haus, HeatPump)], id => id == HeatPump);
Assert.Empty(result.ForType(Electricity).Billing.SeparatelyBilled);
Assert.Contains(new TotalsProblem(TotalsProblemKind.UnusedMeterPrice, HeatPump), result.Problems);
}
[Fact]
public void A_consumption_root_with_its_own_price_is_not_a_subsection_and_is_not_billed_separately()
{
// No link and no total_load: nothing establishes that the root sits behind the grid import, so D-35 (which is
// about containment children) does not apply. Link it or give the type a total_load meter to bill it.
List<TotalsMeter> meters =
[
Physical(20, "Import", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport),
Physical(21, "Heat pump", Electricity, MeterMode.CumulativeCounter, "kWh"),
];
var result = TotalsPolicy.Classify(meters, [], id => id == 21);
Assert.Equal(MeterTotalsClass.Use, result.For(21).Class);
Assert.Empty(result.ForType(Electricity).Billing.SeparatelyBilled);
Assert.Contains(new TotalsProblem(TotalsProblemKind.UnusedMeterPrice, 21), result.Problems);
}
[Fact]
public void A_priced_consumer_linked_directly_below_the_grid_meter_is_billed_at_its_own_price()
{
// Review R6 (D-35, Kaskade): grid meter, and a heat pump behind it with its own price, linked grid → pump and
// no house meter. The link out of a supply meter is a supply edge (D-22), so the pump is a use root, not a
// containment child — yet the grid meter measured it. It is billed at its own price and taken out of the grid
// meter that links to it; the quantity measures do not change.
List<TotalsMeter> meters =
[
Physical(20, "Import", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport),
Physical(21, "Heat pump", Electricity, MeterMode.CumulativeCounter, "kWh"),
Physical(22, "Unlinked", Electricity, MeterMode.CumulativeCounter, "kWh"),
];
var result = TotalsPolicy.Classify(meters, [new(20, 21)], id => id is 21 or 22);
Assert.Equal(MeterTotalsClass.Use, result.For(21).Class);
var billing = result.ForType(Electricity).Billing;
Assert.Equal([new SeparatelyBilledMeter(21, 20, null)], billing.SeparatelyBilled);
Assert.Contains(billing.Lines, l => l is { MeterId: 20, Kind: BillLineKind.UnitPrice } && l.Deductions.Single().MeterId == 21);
Assert.Contains(billing.Lines, l => l is { MeterId: 21, Kind: BillLineKind.OwnPrice });
Assert.DoesNotContain(new TotalsProblem(TotalsProblemKind.UnusedMeterPrice, 21), result.Problems);
// A priced meter nothing links is still reported: nothing says the grid meter measured it.
Assert.Contains(new TotalsProblem(TotalsProblemKind.UnusedMeterPrice, 22), result.Problems);
}
[Fact]
public void A_meter_price_on_a_meter_the_bill_never_prices_is_reported()
{
// Haus is not billed where the grid import is, so a price scoped to it applies to nothing.
var result = TotalsPolicy.Classify(Meters(), Links(), id => id == Haus);
Assert.Equal([new TotalsProblem(TotalsProblemKind.UnusedMeterPrice, Haus)], result.Problems);
}
[Fact]
public void A_subsection_below_meters_an_always_sum_replaced_comes_out_of_the_sum()
{
// 32 = 30 + 31 is billed in place of 30 and 31, and 33 below 30 has its own price: its quantity must come out
// of 32, or the bill charges it twice.
List<TotalsMeter> meters =
[
Physical(30, "House water", Water, MeterMode.CumulativeCounter, "m³"),
Physical(31, "Barn water", Water, MeterMode.CumulativeCounter, "m³"),
Virtual(32, "All water", Water, "m³", QuantityKind.Consumption, [30, 31], pureSum: true, totals: TotalsOverride.Always),
Physical(33, "Garden", Water, MeterMode.CumulativeCounter, "m³"),
];
var result = TotalsPolicy.Classify(meters, [new(30, 33)], id => id == 33);
var billing = result.ForType(Water).Billing;
Assert.Equal([32], billing.BilledMeterIds);
Assert.Equal([new SeparatelyBilledMeter(33, 32, 30)], billing.SeparatelyBilled);
Assert.Equal([new BillDeduction(33, 1)], result.LineOf(32)!.Deductions);
}
[Fact]
public void A_subsection_in_a_convertible_unit_is_deducted_with_its_conversion_factor()
{
var meters = Meters();
meters.Add(Physical(20, "Garden", Water, MeterMode.CumulativeCounter, "L"));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(Wasser, 20)], id => id == 20);
Assert.Equal([new SeparatelyBilledMeter(20, Wasser, null)], result.ForType(Water).Billing.SeparatelyBilled);
Assert.Equal([new BillDeduction(20, 0.001)], result.LineOf(Wasser)!.Deductions);
}
[Fact]
public void A_subsection_whose_unit_cannot_convert_stays_in_its_parent_s_bill_and_is_reported()
{
// An instant-rate sensor integrating to kWh cannot come out of an m³ line. It stays inside the water bill at
// the water price; the pool below it, in m³, then comes out of the next billed meter up.
var meters = Meters();
meters.Add(Physical(20, "Heat meter", Water, MeterMode.InstantRate, "kWh"));
meters.Add(Physical(21, "Pool", Water, MeterMode.CumulativeCounter, "m³"));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(Wasser, 20), new(20, 21)], id => id is 20 or 21);
Assert.Equal([new SeparatelyBilledMeter(21, Wasser, null)], result.ForType(Water).Billing.SeparatelyBilled);
Assert.Equal([new BillDeduction(21, 1)], result.LineOf(Wasser)!.Deductions);
Assert.Null(result.LineOf(20));
Assert.Equal([new TotalsProblem(TotalsProblemKind.SeparateBillingUnitMismatch, 20, Wasser)], result.Problems);
}
[Fact]
public void A_subsection_spanning_a_grid_meter_replacement_comes_out_of_each_grid_meter_in_service_with_it()
{
var meters = MetersWith(Netz, m => m with { RetiredAt = new DateOnly(2023, 1, 31) });
meters.Add(Physical(11, "Zähler Netz (neu)", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport, installedAt: new DateOnly(2023, 1, 31)));
meters.Add(Physical(HeatPump, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh"));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(11, Haus), new(Haus, HeatPump)], id => id == HeatPump);
Assert.Equal(
[new SeparatelyBilledMeter(HeatPump, Netz, Haus), new SeparatelyBilledMeter(HeatPump, 11, Haus)],
result.ForType(Electricity).Billing.SeparatelyBilled);
Assert.Equal([new BillDeduction(HeatPump, 1)], result.LineOf(Netz)!.Deductions);
Assert.Equal([new BillDeduction(HeatPump, 1)], result.LineOf(11)!.Deductions);
Assert.Single(result.ForType(Electricity).Billing.Lines, l => l.MeterId == HeatPump);
}
[Fact]
public void The_price_predicate_is_asked_once_per_meter_while_classifying_and_never_afterwards()
{
// A reader's predicate closes over a scoped DbContext; category covers computed later must not call into it.
var meters = Meters();
meters.Add(Physical(HeatPump, "Wärmepumpe", Electricity, MeterMode.CumulativeCounter, "kWh"));
var calls = 0;
var disposed = false;
bool HasPrice(int id)
{
ObjectDisposedException.ThrowIf(disposed, typeof(SeparatelyBilledSubmeterTests));
calls++;
return id == HeatPump;
}
var result = TotalsPolicy.Classify(meters, [.. Links(), new(Haus, HeatPump)], HasPrice);
disposed = true;
var cover = CategoryCover.Compute(result, 101, [Haus, Netz, HeatPump]);
Assert.Equal(meters.Count, calls);
Assert.Equal([new SeparatelyBilledMeter(HeatPump, Netz, Haus)], cover.SeparatelyBilled);
}
}
@@ -0,0 +1,744 @@
using MeterVault.Core.Analysis.Quantities;
using MeterVault.Core.Domain;
namespace MeterVault.Core.Tests.Analysis;
public sealed class TariffUnitTests
{
// ---- Parsing ----------------------------------------------------------------------------------
[Theory]
[InlineData("EUR/kWh", "EUR", 1, "kWh", 1)]
[InlineData("€/kWh", "EUR", 1, "kWh", 1)]
[InlineData("eur/kwh", "EUR", 1, "kWh", 1)]
[InlineData("Euro/kWh", "EUR", 1, "kWh", 1)]
[InlineData("ct/kWh", "ct", 0.01, "kWh", 1)]
[InlineData("Cent/kWh", "ct", 0.01, "kWh", 1)]
[InlineData("EUR/MWh", "EUR", 1, "MWh", 1)]
[InlineData("EUR/m3", "EUR", 1, "m³", 1)]
[InlineData("EUR/m³", "EUR", 1, "m³", 1)]
[InlineData("EUR/cbm", "EUR", 1, "m³", 1)]
[InlineData("EUR/100L", "EUR", 1, "L", 100)]
[InlineData("EUR / 100 l", "EUR", 1, "L", 100)]
[InlineData("EUR/1.000 L", "EUR", 1, "L", 1000)]
[InlineData("EUR/1000L", "EUR", 1, "L", 1000)]
[InlineData("EUR/hl", "EUR", 1, "hL", 1)]
[InlineData("EUR/h", "EUR", 1, "h", 1)]
[InlineData("EUR/Std", "EUR", 1, "h", 1)]
[InlineData("EUR/t", "EUR", 1, "t", 1)]
[InlineData("EUR per kWh", "EUR", 1, "kWh", 1)]
[InlineData("EUR je 100 L", "EUR", 1, "L", 100)]
[InlineData("SEK/kWh", "SEK", 1, "kWh", 1)]
[InlineData("Fr./kWh", "CHF", 1, "kWh", 1)]
[InlineData("Rp./kWh", "Rp", 0.01, "kWh", 1)]
[InlineData("p/kWh", "p", 0.01, "kWh", 1)]
public void A_quantity_price_parses_to_currency_scale_and_denominator(
string unit, string currency, double currencyScale, string denominator, double amount)
{
var parsed = TariffUnit.Parse(unit);
Assert.Equal(TariffUnitBasis.Quantity, parsed.Basis);
Assert.True(parsed.IsRecognised);
Assert.Equal(currency, parsed.Currency);
Assert.Equal(currencyScale, parsed.CurrencyScale);
Assert.Equal(denominator, parsed.Denominator);
Assert.Equal(amount, parsed.DenominatorAmount);
Assert.Null(parsed.Period);
}
[Theory]
[InlineData("EUR/month", BillingPeriod.Month)]
[InlineData("EUR/Monat", BillingPeriod.Month)]
[InlineData("€/Mon.", BillingPeriod.Month)]
[InlineData("€ pro Monat", BillingPeriod.Month)]
[InlineData("EUR monatlich", BillingPeriod.Month)]
[InlineData("EUR/1 Monat", BillingPeriod.Month)]
[InlineData("EUR/Jahr", BillingPeriod.Year)]
[InlineData("EUR/year", BillingPeriod.Year)]
[InlineData("EUR/a", BillingPeriod.Year)]
[InlineData("EUR p.a.", BillingPeriod.Year)]
[InlineData("EUR jährlich", BillingPeriod.Year)]
[InlineData("EUR/Tag", BillingPeriod.Day)]
[InlineData("EUR/day", BillingPeriod.Day)]
[InlineData("EUR/d", BillingPeriod.Day)]
[InlineData("EUR/12 Monate", BillingPeriod.Year)]
[InlineData("EUR/1 Jahr", BillingPeriod.Year)]
[InlineData("EUR/Quartal", BillingPeriod.Quarter)]
[InlineData("EUR/quarter", BillingPeriod.Quarter)]
[InlineData("EUR pro Quartal", BillingPeriod.Quarter)]
[InlineData("EUR vierteljährlich", BillingPeriod.Quarter)]
[InlineData("EUR/3 Monate", BillingPeriod.Quarter)]
public void A_standing_charge_parses_to_its_billing_period(string unit, BillingPeriod period)
{
var parsed = TariffUnit.Parse(unit);
Assert.Equal(TariffUnitBasis.Period, parsed.Basis);
Assert.Equal(period, parsed.Period);
Assert.Equal("EUR", parsed.Currency);
Assert.Null(parsed.Denominator);
}
[Theory]
[InlineData("pauschal")]
[InlineData("EUR")]
[InlineData("kWh")]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
[InlineData("EUR/100")]
[InlineData("EUR/kWh/h")]
[InlineData("EUR/0 L")]
[InlineData("EUR/1,5 L")]
[InlineData("EUR/1.00 L")]
[InlineData("kWh/EUR")]
[InlineData("Taler/kWh")]
[InlineData("(brutto)")]
[InlineData("brutto EUR/kWh")]
public void Anything_else_is_unparseable(string? unit)
{
var parsed = TariffUnit.Parse(unit);
Assert.Equal(TariffUnitBasis.Unparseable, parsed.Basis);
Assert.False(parsed.IsRecognised);
Assert.Null(parsed.Denominator);
Assert.Null(parsed.Period);
}
[Fact]
public void An_unknown_denominator_is_kept_to_match_itself()
{
var parsed = TariffUnit.Parse("EUR/Stk");
Assert.Equal(TariffUnitBasis.OtherDenominator, parsed.Basis);
Assert.Equal("Stk", parsed.Denominator);
Assert.False(parsed.IsRecognised);
}
[Theory]
[InlineData("EUR/100L", "100 L")]
[InlineData("EUR/m3", "m³")]
[InlineData("EUR/Monat", "month")]
[InlineData("EUR/a", "year")]
[InlineData("pauschal", "pauschal")]
public void The_denominator_text_says_what_the_price_is_quoted_per(string unit, string text)
{
Assert.Equal(text, TariffUnit.Parse(unit).DenominatorText);
}
[Theory]
[InlineData("EUR/kWh", TariffComponent.UnitPrice, true)]
[InlineData("ct/kWh", TariffComponent.FeedIn, true)]
[InlineData("EUR/month", TariffComponent.UnitPrice, false)]
[InlineData("EUR/Stk", TariffComponent.UnitPrice, true)]
[InlineData("pauschal", TariffComponent.UnitPrice, false)]
[InlineData("EUR/month", TariffComponent.BasePrice, true)]
[InlineData("EUR/Jahr", TariffComponent.BasePrice, true)]
[InlineData("EUR/kWh", TariffComponent.BasePrice, false)]
[InlineData("pauschal", TariffComponent.BasePrice, false)]
[InlineData("%", TariffComponent.Tax, true)]
public void The_editor_accepts_only_a_unit_shaped_for_its_component(string unit, TariffComponent component, bool suits)
{
Assert.Equal(suits, TariffUnit.Parse(unit).Suits(component));
}
// ---- Unit and feed-in prices --------------------------------------------------------------------
[Theory]
[InlineData("EUR/kWh", "kWh", 1)]
[InlineData("€/kWh", "kWh", 1)]
[InlineData("ct/kWh", "kWh", 0.01)]
[InlineData("EUR/100L", "L", 0.01)]
[InlineData("EUR/MWh", "kWh", 0.001)]
[InlineData("EUR/m3", "m³", 1)]
[InlineData("EUR/m3", "m3", 1)]
[InlineData("EUR/m³", "L", 0.001)]
[InlineData("EUR/100L", "m³", 10)]
[InlineData("EUR/kWh", "Wh", 0.001)]
[InlineData("EUR/kWh", "MWh", 1000)]
[InlineData("ct/100 L", "L", 0.0001)]
[InlineData("EUR/h", "h", 1)]
[InlineData("EUR/t", "kg", 0.001)]
public void A_matching_unit_price_applies_with_the_factor_to_currency_per_meter_unit(
string tariffUnit, string meterUnit, double factor)
{
var fit = TariffUnit.Applicability(tariffUnit, meterUnit, TariffComponent.UnitPrice);
Assert.Equal(TariffUnitFit.Applies, fit.Fit);
Assert.True(fit.Applies);
Assert.False(fit.NeedsWarning);
Assert.Equal(TariffUnitIssue.None, fit.Issue);
Assert.Equal(factor, fit.Factor, 12);
}
[Fact]
public void The_seeded_water_tariff_prices_the_seeded_water_meter_at_face_value()
{
// Seed: water 5.00 "EUR/m3" on a meter whose unit is "m3". Dec 2022 is 14 m³ = 70 € (D-56).
var fit = TariffUnit.Applicability("EUR/m3", "m3", TariffComponent.UnitPrice);
Assert.Equal(70d, 14 * fit.Convert(5.00), 9);
}
[Fact]
public void A_heating_oil_price_per_100_litres_is_charged_per_litre()
{
// 98.50 €/100 L on the seeded Öltank (L): 1,000 L cost 985 €, not 98,500 €.
var fit = TariffUnit.Applicability("EUR/100L", "L", TariffComponent.UnitPrice);
Assert.Equal(985d, 1000 * fit.Convert(98.50), 9);
}
[Fact]
public void A_feed_in_price_in_cents_is_converted_like_a_unit_price()
{
var fit = TariffUnit.Applicability("ct/kWh", "kWh", TariffComponent.FeedIn);
Assert.Equal(TariffUnitFit.Applies, fit.Fit);
Assert.Equal(0.082, fit.Convert(8.2), 12);
}
[Fact]
public void An_energy_price_does_not_price_water()
{
var fit = TariffUnit.Applicability("EUR/kWh", "m³", TariffComponent.UnitPrice);
Assert.Equal(TariffUnitFit.Mismatch, fit.Fit);
Assert.False(fit.Applies);
Assert.Equal(TariffUnitIssue.IncompatibleUnit, fit.Issue);
Assert.Equal("kWh", fit.TariffDenominator);
Assert.Equal("m³", fit.MeterUnit);
Assert.Equal("EUR/kWh", fit.TariffUnit);
Assert.Equal(0d, fit.Convert(0.30));
}
[Theory]
// A global electricity price used to price water and burner hours too (costing review, §10.2).
[InlineData("EUR/kWh", "h")]
[InlineData("EUR/100L", "h")]
[InlineData("EUR/h", "L")]
[InlineData("EUR/kWh", "kW")]
[InlineData("EUR/L", "L/min")]
[InlineData("EUR/m³", "m³/h")]
public void A_price_in_another_dimension_is_a_mismatch(string tariffUnit, string meterUnit)
{
var fit = TariffUnit.Applicability(tariffUnit, meterUnit, TariffComponent.UnitPrice);
Assert.Equal(TariffUnitFit.Mismatch, fit.Fit);
Assert.Equal(TariffUnitIssue.IncompatibleUnit, fit.Issue);
}
[Theory]
[InlineData("EUR/kWh", "Stk", 1)]
[InlineData("EUR/kWh", "kWh (el)", 1)]
[InlineData("EUR/m³", "Nm³", 1)]
[InlineData("ct/kWh", "Einheiten", 0.01)]
[InlineData("EUR/kWh", "mwh", 1)]
public void A_meter_unit_the_module_does_not_recognise_is_unverified_not_a_mismatch(
string tariffUnit, string meterUnit, double factor)
{
var fit = TariffUnit.Applicability(tariffUnit, meterUnit, TariffComponent.UnitPrice);
Assert.Equal(TariffUnitFit.Unverified, fit.Fit);
Assert.True(fit.Applies);
Assert.True(fit.NeedsWarning);
Assert.Equal(TariffUnitIssue.MeterUnitUnrecognised, fit.Issue);
Assert.Equal(factor, fit.Factor, 12);
}
[Theory]
[InlineData("EUR/kWh", "Kilowattstunden", 1)]
[InlineData("EUR/kWh", "kilowatt-hours", 1)]
[InlineData("EUR/MWh", "Kilowattstunde", 0.001)]
[InlineData("ct/kWh", "Wattstunden", 0.00001)]
[InlineData("EUR/Megawattstunde", "kWh", 0.001)]
public void Long_form_and_German_energy_units_are_priced_like_their_symbols(string tariffUnit, string meterUnit, double factor)
{
var fit = TariffUnit.Applicability(tariffUnit, meterUnit, TariffComponent.UnitPrice);
Assert.Equal(TariffUnitFit.Applies, fit.Fit);
Assert.Equal(factor, fit.Factor, 12);
}
[Theory]
[InlineData("mWh", 0.000_001)]
[InlineData("MWh", 1000)]
[InlineData("MWH", 1000)]
public void A_milli_unit_is_never_priced_as_a_mega_unit(string meterUnit, double factor)
{
var fit = TariffUnit.Applicability("EUR/kWh", meterUnit, TariffComponent.UnitPrice);
Assert.Equal(TariffUnitFit.Applies, fit.Fit);
Assert.Equal(factor, fit.Factor, 12);
}
[Fact]
public void A_standing_charge_unit_on_a_unit_price_is_a_mismatch()
{
var fit = TariffUnit.Applicability("EUR/month", "kWh", TariffComponent.UnitPrice);
Assert.Equal(TariffUnitFit.Mismatch, fit.Fit);
Assert.Equal(TariffUnitIssue.PeriodForQuantity, fit.Issue);
Assert.Equal("month", fit.TariffDenominator);
}
[Fact]
public void An_unparseable_unit_price_applies_at_face_value_with_a_warning()
{
var fit = TariffUnit.Applicability("pauschal", "kWh", TariffComponent.UnitPrice);
Assert.Equal(TariffUnitFit.Unverified, fit.Fit);
Assert.True(fit.Applies);
Assert.True(fit.NeedsWarning);
Assert.Equal(TariffUnitIssue.Unparseable, fit.Issue);
Assert.Equal(1d, fit.Factor);
Assert.Equal("pauschal", fit.TariffDenominator);
}
[Fact]
public void An_unparseable_unit_in_cents_still_converts_the_currency()
{
var fit = TariffUnit.Applicability("ct", "kWh", TariffComponent.UnitPrice);
Assert.Equal(TariffUnitFit.Unverified, fit.Fit);
Assert.Equal(0.01, fit.Factor);
}
[Fact]
public void An_unknown_denominator_applies_to_a_meter_in_that_unit()
{
var fit = TariffUnit.Applicability("EUR/Stk", "stk", TariffComponent.UnitPrice);
Assert.Equal(TariffUnitFit.Applies, fit.Fit);
Assert.Equal(1d, fit.Factor);
}
[Fact]
public void An_unknown_denominator_on_another_meter_applies_with_a_warning()
{
var fit = TariffUnit.Applicability("EUR/Stk", "kWh", TariffComponent.UnitPrice);
Assert.Equal(TariffUnitFit.Unverified, fit.Fit);
Assert.Equal(TariffUnitIssue.UnknownDenominator, fit.Issue);
Assert.Equal(1d, fit.Factor);
}
// ---- Qualifiers ---------------------------------------------------------------------------------
[Theory]
[InlineData("€/kWh brutto", TariffUnitBasis.Quantity, "kWh", "brutto")]
[InlineData("EUR/kWh netto", TariffUnitBasis.Quantity, "kWh", "netto")]
[InlineData("EUR/kWh zzgl. 19 % MwSt.", TariffUnitBasis.Quantity, "kWh", "zzgl. 19 % MwSt.")]
[InlineData("ct/kWh, inkl. USt", TariffUnitBasis.Quantity, "kWh", "inkl. USt")]
[InlineData("EUR/kWh (Grundversorgung)", TariffUnitBasis.Quantity, "kWh", "Grundversorgung")]
[InlineData("EUR/100 L [Heizöl EL] incl. VAT", TariffUnitBasis.Quantity, "100 L", "incl. VAT Heizöl EL")]
[InlineData("€/Jahr inkl. MwSt.", TariffUnitBasis.Period, "year", "inkl. MwSt.")]
[InlineData("EUR/Monat (netto)", TariffUnitBasis.Period, "month", "netto")]
[InlineData("EUR per kWh gross", TariffUnitBasis.Quantity, "kWh", "gross")]
public void VAT_notes_and_bracketed_text_are_set_aside_before_the_unit_is_read(
string unit, TariffUnitBasis basis, string denominator, string qualifier)
{
var parsed = TariffUnit.Parse(unit);
Assert.Equal(basis, parsed.Basis);
Assert.Equal(denominator, parsed.DenominatorText);
Assert.Equal(qualifier, parsed.Qualifier);
Assert.Equal(unit, parsed.Raw);
}
[Fact]
public void A_unit_without_a_note_has_no_qualifier()
{
Assert.Null(TariffUnit.Parse("EUR/kWh").Qualifier);
Assert.Null(TariffUnit.Parse("pauschal").Qualifier);
}
[Fact]
public void A_price_with_a_VAT_note_applies_cleanly()
{
var fit = TariffUnit.Applicability("€/kWh brutto", "kWh", TariffComponent.UnitPrice);
Assert.Equal(TariffUnitFit.Applies, fit.Fit);
Assert.Equal(1d, fit.Factor);
Assert.Equal("€/kWh brutto", fit.TariffUnit);
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void A_meter_without_a_unit_cannot_be_checked(string? meterUnit)
{
var fit = TariffUnit.Applicability("EUR/100L", meterUnit, TariffComponent.UnitPrice);
Assert.Equal(TariffUnitFit.Unverified, fit.Fit);
Assert.Equal(TariffUnitIssue.MeterUnitUnknown, fit.Issue);
Assert.Equal(0.01, fit.Factor);
}
[Theory]
[InlineData(TariffComponent.Bonus)]
[InlineData(TariffComponent.Discount)]
[InlineData(TariffComponent.Tax)]
public void Bonus_discount_and_tax_are_not_applied(TariffComponent component)
{
var fit = TariffUnit.Applicability("EUR/kWh", "kWh", component);
Assert.Equal(TariffUnitFit.NotApplied, fit.Fit);
Assert.False(fit.Applies);
Assert.Equal(TariffUnitIssue.ComponentNotApplied, fit.Issue);
Assert.Equal(0d, fit.Convert(12));
}
// ---- Standing charges ---------------------------------------------------------------------------
[Theory]
[InlineData("EUR/month", BillingPeriod.Month)]
[InlineData("EUR/Jahr", BillingPeriod.Year)]
[InlineData("EUR/Tag", BillingPeriod.Day)]
public void A_standing_charge_applies_per_its_period_whatever_the_meter_unit(string unit, BillingPeriod period)
{
var fit = TariffUnit.Applicability(unit, "m³", TariffComponent.BasePrice);
Assert.Equal(TariffUnitFit.Applies, fit.Fit);
Assert.Equal(period, fit.Period);
Assert.Equal(1d, fit.Factor);
}
[Fact]
public void A_monthly_charge_accrues_over_the_days_of_each_local_month()
{
var accrual = TariffUnit.BaseAccrual("EUR/month");
Assert.Equal(BillingPeriod.Month, accrual.Period);
Assert.False(accrual.Assumed);
Assert.Equal(12d / 28, accrual.PerDay(12, new DateOnly(2026, 2, 10)), 12);
Assert.Equal(12d / 29, accrual.PerDay(12, new DateOnly(2024, 2, 10)), 12);
Assert.Equal(12d / 31, accrual.PerDay(12, new DateOnly(2026, 1, 31)), 12);
Assert.Equal(12d, SumOverDays(accrual, 12, new DateOnly(2026, 2, 1), new DateOnly(2026, 3, 1)), 9);
Assert.Equal(12d, SumOverDays(accrual, 12, new DateOnly(2024, 2, 1), new DateOnly(2024, 3, 1)), 9);
}
[Fact]
public void A_yearly_charge_accrues_over_the_days_of_the_local_year()
{
var accrual = TariffUnit.BaseAccrual("EUR/Jahr");
Assert.Equal(BillingPeriod.Year, accrual.Period);
Assert.Equal(120d / 366, accrual.PerDay(120, new DateOnly(2024, 7, 1)), 12);
Assert.Equal(120d / 365, accrual.PerDay(120, new DateOnly(2026, 7, 1)), 12);
Assert.Equal(120d, SumOverDays(accrual, 120, new DateOnly(2024, 1, 1), new DateOnly(2025, 1, 1)), 9);
Assert.Equal(120d, SumOverDays(accrual, 120, new DateOnly(2026, 1, 1), new DateOnly(2027, 1, 1)), 9);
}
[Fact]
public void A_daily_charge_is_charged_as_is_and_a_charge_in_cents_is_converted()
{
Assert.Equal(0.5, TariffUnit.BaseAccrual("EUR/Tag").PerDay(0.5, new DateOnly(2026, 2, 1)), 12);
Assert.Equal(0.5, TariffUnit.BaseAccrual("ct/Tag").PerDay(50, new DateOnly(2026, 2, 1)), 12);
}
[Fact]
public void The_accrual_takes_the_month_length_directly()
{
var accrual = TariffUnit.BaseAccrual("EUR/Monat");
Assert.Equal(1d, accrual.PerDay(30, daysInMonth: 30, daysInYear: 365), 12);
Assert.Throws<ArgumentOutOfRangeException>(() => accrual.PerDay(30, daysInMonth: 0, daysInYear: 365));
}
[Fact]
public void An_unparseable_standing_charge_is_taken_per_month_with_a_warning()
{
var fit = TariffUnit.Applicability("pauschal", "kWh", TariffComponent.BasePrice);
var accrual = TariffUnit.BaseAccrual("pauschal");
Assert.Equal(TariffUnitFit.Unverified, fit.Fit);
Assert.True(fit.NeedsWarning);
Assert.Equal(BillingPeriod.Month, fit.Period);
Assert.Equal(TariffUnitIssue.Unparseable, fit.Issue);
Assert.Equal(BillingPeriod.Month, accrual.Period);
Assert.True(accrual.Assumed);
Assert.Equal(10d / 30, accrual.PerDay(10, new DateOnly(2026, 4, 15)), 12);
}
[Theory]
[InlineData("€/Jahr inkl. MwSt.", 120d / 365)]
[InlineData("EUR/Jahr brutto", 120d / 365)]
[InlineData("EUR/12 Monate", 120d / 365)]
[InlineData("EUR/Quartal", 120d / 92)]
public void A_standing_charge_is_never_taken_per_month_when_its_period_can_be_read(string unit, double perDay)
{
var accrual = TariffUnit.BaseAccrual(unit);
Assert.False(accrual.Assumed);
Assert.Equal(TariffUnitFit.Applies, accrual.Fit);
Assert.Equal(perDay, accrual.PerDay(120, new DateOnly(2026, 7, 1)), 12);
}
[Fact]
public void A_quarterly_charge_accrues_over_the_days_of_its_local_calendar_quarter()
{
var accrual = TariffUnit.BaseAccrual("EUR/Quartal");
Assert.Equal(BillingPeriod.Quarter, accrual.Period);
Assert.Equal(90, accrual.DaysInPeriod(new DateOnly(2026, 2, 14)));
Assert.Equal(91, accrual.DaysInPeriod(new DateOnly(2024, 3, 31)));
Assert.Equal(91, accrual.DaysInPeriod(new DateOnly(2026, 4, 1)));
Assert.Equal(92, accrual.DaysInPeriod(new DateOnly(2026, 8, 31)));
Assert.Equal(92, accrual.DaysInPeriod(new DateOnly(2026, 12, 31)));
Assert.Equal(30d, SumOverDays(accrual, 30, new DateOnly(2024, 1, 1), new DateOnly(2024, 4, 1)), 9);
Assert.Equal(30d, SumOverDays(accrual, 30, new DateOnly(2026, 7, 1), new DateOnly(2026, 10, 1)), 9);
Assert.Equal(120d, SumOverDays(accrual, 30, new DateOnly(2026, 1, 1), new DateOnly(2027, 1, 1)), 9);
}
[Fact]
public void A_quarterly_charge_cannot_be_spread_by_month_and_year_lengths_alone()
{
var accrual = TariffUnit.BaseAccrual("EUR/Quartal");
Assert.Throws<InvalidOperationException>(() => accrual.PerDay(30, daysInMonth: 31, daysInYear: 365));
}
[Theory]
[InlineData("EUR/2 Monate", "2 Monate")]
[InlineData("EUR/Woche", "Woche")]
[InlineData("EUR/Halbjahr", "Halbjahr")]
[InlineData("EUR/7 Tage", "7 Tage")]
[InlineData("EUR halbjährlich", "halbjährlich")]
public void A_period_that_is_read_but_cannot_be_accrued_is_a_mismatch_not_a_month(string unit, string written)
{
var parsed = TariffUnit.Parse(unit);
var fit = TariffUnit.Applicability(unit, "kWh", TariffComponent.BasePrice);
var accrual = TariffUnit.BaseAccrual(unit);
Assert.Equal(TariffUnitBasis.UnsupportedPeriod, parsed.Basis);
Assert.True(parsed.IsRecognised);
Assert.Equal(written, parsed.DenominatorText);
Assert.False(parsed.Suits(TariffComponent.BasePrice));
Assert.Equal(TariffUnitFit.Mismatch, fit.Fit);
Assert.Equal(TariffUnitIssue.UnsupportedPeriod, fit.Issue);
Assert.Equal(0d, fit.Factor);
Assert.Equal(TariffUnitFit.Mismatch, accrual.Fit);
Assert.False(accrual.Applies);
Assert.False(accrual.Assumed);
Assert.Equal(0d, accrual.PerDay(24, new DateOnly(2026, 5, 1)));
}
[Fact]
public void A_period_on_a_unit_price_is_a_mismatch_even_when_it_cannot_be_accrued()
{
var fit = TariffUnit.Applicability("EUR/2 Monate", "kWh", TariffComponent.UnitPrice);
Assert.Equal(TariffUnitFit.Mismatch, fit.Fit);
Assert.Equal(TariffUnitIssue.PeriodForQuantity, fit.Issue);
}
[Fact]
public void A_standing_charge_quoted_per_quantity_is_taken_per_month_with_a_warning()
{
var accrual = TariffUnit.BaseAccrual("EUR/kWh");
Assert.True(accrual.Assumed);
Assert.Equal(BillingPeriod.Month, accrual.Period);
Assert.Equal(TariffUnitIssue.QuantityForPeriod, accrual.Issue);
}
// ---- Currency -----------------------------------------------------------------------------------
[Theory]
[InlineData("USD/kWh", "EUR")]
[InlineData("SEK/kWh", "EUR")]
[InlineData("£/kWh", "EUR")]
[InlineData("p/kWh", "EUR")]
[InlineData("Rp./kWh", "EUR")]
[InlineData("ct/kWh", "GBP")]
[InlineData("EUR/kWh", "CHF")]
[InlineData("USD", "EUR")]
public void A_price_in_another_currency_does_not_price_the_instance_currency(string tariffUnit, string currency)
{
var fit = TariffUnit.Applicability(tariffUnit, "kWh", TariffComponent.UnitPrice, currency);
Assert.Equal(TariffUnitFit.Mismatch, fit.Fit);
Assert.Equal(TariffUnitIssue.CurrencyMismatch, fit.Issue);
Assert.Equal(0d, fit.Convert(0.30));
}
[Theory]
[InlineData("EUR/kWh", "EUR", 1)]
[InlineData("€/kWh", "eur", 1)]
[InlineData("EUR/kWh", "€", 1)]
[InlineData("ct/kWh", "EUR", 0.01)]
[InlineData("Cent/kWh", "USD", 0.01)]
[InlineData("p/kWh", "GBP", 0.01)]
[InlineData("Rp./kWh", "CHF", 0.01)]
[InlineData("Fr./kWh", "CHF", 1)]
[InlineData("SEK/kWh", "sek", 1)]
public void A_minor_unit_applies_only_under_its_own_major_currency(string tariffUnit, string currency, double factor)
{
var fit = TariffUnit.Applicability(tariffUnit, "kWh", TariffComponent.UnitPrice, currency);
Assert.Equal(TariffUnitFit.Applies, fit.Fit);
Assert.Equal(factor, fit.Factor, 12);
}
[Fact]
public void Without_an_expected_currency_the_currency_is_not_checked()
{
Assert.Equal(TariffUnitFit.Applies, TariffUnit.Applicability("USD/kWh", "kWh", TariffComponent.UnitPrice).Fit);
}
[Fact]
public void A_unit_without_a_currency_cannot_be_checked_against_one()
{
var fit = TariffUnit.Applicability("pauschal", "kWh", TariffComponent.UnitPrice, "EUR");
Assert.Equal(TariffUnitFit.Unverified, fit.Fit);
Assert.Equal(TariffUnitIssue.Unparseable, fit.Issue);
}
[Fact]
public void A_standing_charge_in_another_currency_accrues_nothing()
{
var fit = TariffUnit.Applicability("USD/month", "kWh", TariffComponent.BasePrice, "EUR");
var accrual = TariffUnit.BaseAccrual("USD/month", "EUR");
Assert.Equal(TariffUnitFit.Mismatch, fit.Fit);
Assert.Equal(TariffUnitIssue.CurrencyMismatch, fit.Issue);
Assert.Equal(BillingPeriod.Month, fit.Period);
Assert.Equal(TariffUnitFit.Mismatch, accrual.Fit);
Assert.Equal(0d, accrual.PerDay(12, new DateOnly(2026, 2, 1)));
Assert.Equal(TariffUnitFit.Applies, TariffUnit.BaseAccrual("EUR/month", "EUR").Fit);
}
[Fact]
public void Bonus_discount_and_tax_stay_not_applied_whatever_their_currency()
{
Assert.Equal(TariffUnitFit.NotApplied, TariffUnit.Applicability("USD/kWh", "kWh", TariffComponent.Tax, "EUR").Fit);
}
[Fact]
public void The_expected_currency_must_be_named()
{
Assert.Throws<ArgumentException>(() => TariffUnit.Applicability("EUR/kWh", "kWh", TariffComponent.UnitPrice, " "));
Assert.Throws<ArgumentException>(() => TariffUnit.BaseAccrual("EUR/month", ""));
}
// ---- Parsed once --------------------------------------------------------------------------------
[Theory]
[InlineData("EUR/kWh", "MWh", TariffComponent.UnitPrice)]
[InlineData("EUR/100L", "L", TariffComponent.UnitPrice)]
[InlineData("pauschal", "kWh", TariffComponent.UnitPrice)]
[InlineData("EUR/Jahr", "m³", TariffComponent.BasePrice)]
[InlineData("USD/kWh", "kWh", TariffComponent.FeedIn)]
[InlineData("EUR/kWh", "kWh", TariffComponent.Tax)]
public void A_unit_parsed_once_is_checked_exactly_like_its_text(string tariffUnit, string meterUnit, TariffComponent component)
{
var parsed = TariffUnit.Parse(tariffUnit);
Assert.Equal(
TariffUnit.Applicability(tariffUnit, meterUnit, component),
TariffUnit.Applicability(parsed, meterUnit, component));
Assert.Equal(
TariffUnit.Applicability(tariffUnit, meterUnit, component, "EUR"),
TariffUnit.Applicability(parsed, meterUnit, component, "EUR"));
Assert.Equal(TariffUnit.BaseAccrual(tariffUnit), TariffUnit.BaseAccrual(parsed));
Assert.Equal(TariffUnit.BaseAccrual(tariffUnit, "EUR"), TariffUnit.BaseAccrual(parsed, "EUR"));
}
// ---- Alias tables -------------------------------------------------------------------------------
public static TheoryData<string, string, double, string> CurrencyAliases
{
get
{
var data = new TheoryData<string, string, double, string>();
foreach (var entry in TariffUnit.CurrencyAliasTable)
{
data.Add(entry.Alias, entry.Code, entry.Scale, entry.Majors[0]);
}
return data;
}
}
public static TheoryData<string> PeriodAliases
{
get
{
var data = new TheoryData<string>();
foreach (var entry in TariffUnit.PeriodAliasTable)
{
data.Add(entry.Alias);
}
return data;
}
}
[Theory]
[MemberData(nameof(CurrencyAliases))]
public void Every_currency_spelling_parses_to_its_code_and_scale_and_prices_its_major_currency(
string alias, string code, double scale, string major)
{
var parsed = TariffUnit.Parse($"{alias}/kWh");
Assert.Equal(TariffUnitBasis.Quantity, parsed.Basis);
Assert.Equal(code, parsed.Currency);
Assert.Equal(scale, parsed.CurrencyScale);
Assert.Equal(TariffUnitFit.Applies, TariffUnit.Applicability(parsed, "kWh", TariffComponent.UnitPrice, major).Fit);
}
[Theory]
[MemberData(nameof(PeriodAliases))]
public void Every_period_spelling_is_read_as_a_period(string alias)
{
var slashed = TariffUnit.Parse($"EUR/{alias}");
var spaced = TariffUnit.Parse($"EUR pro {alias}");
Assert.Contains(slashed.Basis, new[] { TariffUnitBasis.Period, TariffUnitBasis.UnsupportedPeriod });
Assert.Equal(slashed.Basis, spaced.Basis);
Assert.Equal(slashed.Period, spaced.Period);
if (slashed.Basis == TariffUnitBasis.Period)
{
// A supported period round-trips through the token it is written with.
Assert.Equal(slashed.Period, TariffUnit.Parse($"EUR/{TariffUnit.PeriodToken(slashed.Period!.Value)}").Period);
}
}
// ---- Suggestions --------------------------------------------------------------------------------
[Theory]
[InlineData(TariffComponent.UnitPrice, "m3", "EUR/m³")]
[InlineData(TariffComponent.FeedIn, "kWh", "EUR/kWh")]
[InlineData(TariffComponent.BasePrice, "kWh", "EUR/month")]
[InlineData(TariffComponent.UnitPrice, "", "EUR")]
[InlineData(TariffComponent.Tax, "kWh", "EUR")]
public void The_editor_suggests_a_unit_for_the_component(TariffComponent component, string meterUnit, string expected)
{
Assert.Equal(expected, TariffUnit.Suggest(component, meterUnit));
}
[Theory]
[InlineData(TariffComponent.UnitPrice, "m3")]
[InlineData(TariffComponent.FeedIn, "kWh")]
[InlineData(TariffComponent.UnitPrice, "L")]
[InlineData(TariffComponent.BasePrice, "kWh")]
public void A_suggested_unit_applies_cleanly_to_the_meter_it_was_suggested_for(TariffComponent component, string meterUnit)
{
var suggested = TariffUnit.Suggest(component, meterUnit);
Assert.Equal(TariffUnitFit.Applies, TariffUnit.Applicability(suggested, meterUnit, component).Fit);
Assert.True(TariffUnit.Parse(suggested).Suits(component));
}
private static double SumOverDays(BasePriceAccrual accrual, double value, DateOnly from, DateOnly to)
{
var total = 0d;
for (var day = from; day < to; day = day.AddDays(1))
{
total += accrual.PerDay(value, day);
}
return total;
}
}
@@ -0,0 +1,940 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Quantities;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Domain;
using static MeterVault.Core.Tests.Analysis.TotalsSeed;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// The per-type totals policy (D-22, D-23, D-34, D-35, D-53). The seeded topology is the golden case: summing
/// every meter there counts Haus, Netz and Auto on top of each other, and the sheet bills Netz alone — so these
/// tests pin that the policy finds exactly one non-overlapping meter per measure, bills the grid import, and
/// refuses any override that would count the same energy twice.
/// </summary>
public sealed class TotalsPolicyTests
{
[Fact]
public void The_seeded_topology_classifies_every_meter_as_the_note_pins()
{
var result = TotalsPolicy.Classify(Meters(), Links());
Assert.Equal(MeterTotalsClass.Use, result.For(Haus).Class);
Assert.Equal(MeterTotalsReason.TotalLoadRole, result.For(Haus).Reason);
Assert.Equal(MeterTotalsClass.GridImport, result.For(Netz).Class);
Assert.Equal(MeterTotalsClass.Breakdown, result.For(Auto).Class);
Assert.Equal(Haus, result.For(Auto).ParentId);
Assert.Equal(MeterTotalsReason.ContainedByLink, result.For(Auto).Reason);
Assert.Equal(MeterTotalsClass.Generation, result.For(Solar1).Class);
Assert.Equal(MeterTotalsClass.Generation, result.For(Solar2).Class);
Assert.Equal(MeterTotalsClass.AnalysisOnly, result.For(SummeSolar).Class);
Assert.Equal(MeterTotalsReason.VirtualView, result.For(SummeSolar).Reason);
Assert.Equal(MeterTotalsClass.Use, result.For(Wasser).Class);
Assert.Equal(MeterTotalsReason.ConsumptionRoot, result.For(Wasser).Reason);
Assert.Equal(MeterTotalsClass.Use, result.For(Oeltank).Class);
Assert.Equal(MeterTotalsClass.Runtime, result.For(Brenner).Class);
}
[Fact]
public void Seeded_measures_are_one_set_per_type_and_unit()
{
var result = TotalsPolicy.Classify(Meters(), Links());
Assert.Equal(
[
new MeasureGroup(TotalsMeasure.Use, "kWh", [Haus]),
new MeasureGroup(TotalsMeasure.GridImport, "kWh", [Netz]),
new MeasureGroup(TotalsMeasure.Generation, "kWh", [Solar1, Solar2]),
],
result.ForType(Electricity).Measures,
MeasureGroupComparer.Instance);
Assert.Equal([new MeasureGroup(TotalsMeasure.Use, "m³", [Wasser])], result.ForType(Water).Measures, MeasureGroupComparer.Instance);
Assert.Equal(
[
new MeasureGroup(TotalsMeasure.Use, "L", [Oeltank]),
new MeasureGroup(TotalsMeasure.Runtime, "h", [Brenner]),
],
result.ForType(Oil).Measures,
MeasureGroupComparer.Instance);
Assert.Empty(result.ForType(Electricity).MetersIn(TotalsMeasure.Export));
}
[Fact]
public void The_seeded_bill_is_the_grid_import_for_electricity_and_household_use_elsewhere()
{
var result = TotalsPolicy.Classify(Meters(), Links());
var electricity = result.ForType(Electricity).Billing;
Assert.Equal(BillingBasis.GridImport, electricity.Basis);
Assert.Equal([Netz], electricity.BilledMeterIds);
Assert.Empty(electricity.FeedInMeterIds);
Assert.Empty(electricity.SeparatelyBilled);
Assert.Equal(BillingBasis.Use, result.ForType(Water).Billing.Basis);
Assert.Equal([Wasser], result.ForType(Water).Billing.BilledMeterIds);
Assert.Equal([Oeltank], result.ForType(Oil).Billing.BilledMeterIds);
Assert.Equal([Netz, Wasser, Oeltank], result.BillItems.Order());
Assert.False(result.IsBilled(Solar1));
Assert.False(result.IsBilled(Brenner));
}
[Fact]
public void The_seeded_configuration_raises_no_problem_and_no_overlap_hint()
{
var result = TotalsPolicy.Classify(Meters(), Links());
Assert.Empty(result.Problems);
Assert.Empty(result.Hints);
Assert.Empty(TotalsPolicy.PossibleOverlapHints(Meters(), Links()));
}
[Fact]
public void A_link_out_of_a_supply_meter_never_makes_its_target_a_subsection()
{
// Netz → Haus and Summe Solar → Haus say what feeds the house, not that the house is part of them.
var result = TotalsPolicy.Classify(Meters(), Links());
Assert.Empty(result.For(Haus).ParentIds);
Assert.Empty(result.For(SummeSolar).ParentIds);
}
[Fact]
public void Lifecycle_dates_move_no_meter_where_no_role_passes_between_meters()
{
var plain = TotalsPolicy.Classify(Meters(), Links());
var dated = TotalsPolicy.Classify(
[.. Meters().Select(m => m.Id switch
{
Auto => m with { RetiredAt = new DateOnly(2024, 6, 30) },
Solar2 => m with { InstalledAt = new DateOnly(2025, 3, 1) },
Netz => m with { InstalledAt = new DateOnly(2022, 9, 1), RetiredAt = new DateOnly(2023, 1, 31) },
_ => m,
})],
Links());
foreach (var id in plain.Meters.Keys)
{
Assert.Equal(plain.For(id).Class, dated.For(id).Class);
Assert.Equal(plain.For(id).Measure, dated.For(id).Measure);
}
Assert.Equal(plain.BillItems.Order(), dated.BillItems.Order());
}
[Fact]
public void The_result_does_not_depend_on_the_order_meters_and_links_arrive_in()
{
var forward = TotalsPolicy.Classify(Meters(), Links());
var backward = TotalsPolicy.Classify(Enumerable.Reverse(Meters()), Enumerable.Reverse(Links()));
foreach (var id in forward.Meters.Keys)
{
Assert.Equal(forward.For(id), backward.For(id), EntryComparer.Instance);
}
}
[Fact]
public void A_bidirectional_meter_without_total_load_bills_the_import_credits_the_export_and_leaves_use_unmeasured()
{
// Import 1.8.0, export 2.8.0 and PV, no house meter: none of them is household use — the import is supply,
// the export is never consumption, and generation is not use. Use stays empty rather than guessed; the bill
// is the import and the feed-in credit is earned by the export register only (never by generation).
List<TotalsMeter> meters =
[
Physical(20, "Import", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport),
Physical(21, "Export", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridExport),
Physical(22, "PV", Electricity, MeterMode.GenerationCounter, "kWh"),
];
var totals = TotalsPolicy.Classify(meters, [new(22, 20)]).ForType(Electricity);
Assert.Empty(totals.MetersIn(TotalsMeasure.Use));
Assert.Equal([20], totals.MetersIn(TotalsMeasure.GridImport));
Assert.Equal([21], totals.MetersIn(TotalsMeasure.Export));
Assert.Equal([22], totals.MetersIn(TotalsMeasure.Generation));
Assert.Equal(BillingBasis.GridImport, totals.Billing.Basis);
Assert.Equal([20], totals.Billing.BilledMeterIds);
Assert.Equal([21], totals.Billing.FeedInMeterIds);
}
[Fact]
public void A_virtual_meter_never_holds_a_role_so_a_virtual_total_load_stays_an_analysis_view()
{
// A-07: a virtual meter is a view over other meters. Counting a stored total_load on it would put a calculation
// into household use (and bill it where no grid import exists), which D-39 allows only through Always.
List<TotalsMeter> meters =
[
Physical(20, "Import", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport),
Physical(21, "Export", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridExport),
Physical(22, "PV", Electricity, MeterMode.GenerationCounter, "kWh"),
Virtual(23, "Household", Electricity, "kWh", QuantityKind.Net, [20, 21, 22], pureSum: false, MeterRoles.TotalLoad),
];
var result = TotalsPolicy.Classify(meters, []);
Assert.Null(meters[3].Role);
Assert.Equal(MeterTotalsClass.AnalysisOnly, result.For(23).Class);
Assert.Empty(result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.Equal([20], result.ForType(Electricity).Billing.BilledMeterIds);
Assert.Empty(result.Problems);
}
[Fact]
public void A_virtual_total_load_in_a_type_without_a_grid_import_is_never_billed()
{
// The probed case: the role made the view household use, and with no grid import that meant billing it.
var meters = Meters();
meters.Add(Virtual(22, "Wasser view", Water, "m³", QuantityKind.Consumption, [Wasser], pureSum: true, MeterRoles.TotalLoad));
var result = TotalsPolicy.Classify(meters, Links());
Assert.Equal(MeterTotalsClass.AnalysisOnly, result.For(22).Class);
Assert.Equal([Wasser], result.ForType(Water).Billing.BilledMeterIds);
Assert.Equal(MeterTotalsClass.Use, result.For(Wasser).Class);
}
[Fact]
public void A_role_handed_directly_to_a_virtual_meter_is_ignored_and_reported()
{
// A caller that skips MeterRoleRules.Effective still cannot make a view hold a role.
var meters = Meters();
meters.Add(Virtual(23, "House view", Electricity, "kWh", QuantityKind.Consumption, [Haus], pureSum: true) with { Role = MeterRole.TotalLoad });
var result = TotalsPolicy.Classify(meters, Links());
Assert.Contains(new TotalsProblem(TotalsProblemKind.RoleNotApplicable, 23, Role: MeterRole.TotalLoad), result.Problems);
Assert.Equal(MeterTotalsClass.AnalysisOnly, result.For(23).Class);
Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.DoesNotContain(result.Problems, p => p.Kind == TotalsProblemKind.DuplicateRole);
}
[Theory]
[InlineData("GRID_IMPORT")]
[InlineData(" Grid_Import ")]
[InlineData("grid_import")]
public void A_role_token_counts_whatever_its_case_and_surrounding_spaces(string token)
{
var result = TotalsPolicy.Classify(
MetersWith(Netz, m => Physical(m.Id, m.Name, m.EnergyTypeId, m.Mode, m.Unit, token)),
Links());
Assert.Equal(MeterTotalsClass.GridImport, result.For(Netz).Class);
Assert.Equal(MeterTotalsClass.Use, result.For(Haus).Class);
Assert.Equal(MeterTotalsClass.Breakdown, result.For(Auto).Class);
Assert.Equal([Netz], result.ForType(Electricity).Billing.BilledMeterIds);
Assert.Empty(result.Problems);
}
[Theory]
[InlineData(MeterRoles.GridImport)]
[InlineData(MeterRoles.GridExport)]
[InlineData(MeterRoles.TotalLoad)]
public void A_role_stored_on_a_tank_plays_no_part_so_the_tank_is_billed_as_household_use(string token)
{
// A tank measures a store, not a flow (MeterRoleRules): a stored grid_import must not bill it as the grid,
// and a stored grid_export must not earn it a feed-in credit.
var result = TotalsPolicy.Classify(
MetersWith(Oeltank, m => Physical(m.Id, m.Name, m.EnergyTypeId, m.Mode, m.Unit, token)),
Links());
Assert.Equal(MeterTotalsClass.Use, result.For(Oeltank).Class);
Assert.Equal(BillingBasis.Use, result.ForType(Oil).Billing.Basis);
Assert.Equal([Oeltank], result.ForType(Oil).Billing.BilledMeterIds);
Assert.Empty(result.ForType(Oil).Billing.FeedInMeterIds);
}
[Fact]
public void A_meter_reporting_export_is_never_consumption_even_without_the_role()
{
List<TotalsMeter> meters =
[
Physical(20, "Import", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport),
Physical(21, "Export", Electricity, MeterMode.CumulativeCounter, "kWh", kind: QuantityKind.Export),
];
var result = TotalsPolicy.Classify(meters, []);
Assert.Equal(MeterTotalsClass.Export, result.For(21).Class);
Assert.Empty(result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.Equal([21], result.ForType(Electricity).Billing.FeedInMeterIds);
}
[Fact]
public void Always_on_Summe_Solar_replaces_both_solar_meters_in_generation()
{
var meters = MetersWithOverride((SummeSolar, TotalsOverride.Always));
var result = TotalsPolicy.Classify(meters, Links());
Assert.Equal([SummeSolar], result.ForType(Electricity).MetersIn(TotalsMeasure.Generation));
Assert.Equal(MeterTotalsClass.IncludedByOverride, result.For(SummeSolar).Class);
Assert.Equal(TotalsMeasure.Generation, result.For(SummeSolar).Measure);
Assert.Equal([Solar1, Solar2], result.For(SummeSolar).ReplacesIds);
foreach (var solar in new[] { Solar1, Solar2 })
{
Assert.Equal(MeterTotalsClass.ExcludedByOverride, result.For(solar).Class);
Assert.Equal(MeterTotalsReason.CoveredByOverride, result.For(solar).Reason);
Assert.Equal(SummeSolar, result.For(solar).RelatedMeterId);
Assert.Equal(MeterTotalsClass.Generation, result.For(solar).Natural);
}
Assert.Equal([Netz], result.ForType(Electricity).Billing.BilledMeterIds);
Assert.True(TotalsPolicy.Validate(Meters(), Links(), SummeSolar, TotalsOverride.Always).IsAllowed);
}
[Fact]
public void Always_on_Auto_while_Haus_is_counted_is_refused_naming_Haus()
{
var check = TotalsPolicy.Validate(Meters(), Links(), Auto, TotalsOverride.Always);
Assert.False(check.IsAllowed);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, Haus), check.Conflict);
}
[Fact]
public void A_stored_always_that_overlaps_is_not_applied_and_is_reported()
{
var result = TotalsPolicy.Classify(MetersWithOverride((Auto, TotalsOverride.Always)), Links());
Assert.Equal(MeterTotalsClass.Breakdown, result.For(Auto).Class);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, Haus), result.For(Auto).RefusedOverride);
Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.Contains(new TotalsProblem(TotalsProblemKind.OverrideRefused, Auto, Haus, Conflict: TotalsConflictReason.OverlapsCountedMeter), result.Problems);
}
[Fact]
public void Always_on_Auto_is_allowed_once_Haus_is_set_to_never()
{
var neverHaus = MetersWithOverride((Haus, TotalsOverride.Never));
Assert.True(TotalsPolicy.Validate(neverHaus, Links(), Auto, TotalsOverride.Always).IsAllowed);
var result = TotalsPolicy.Classify(MetersWithOverride((Haus, TotalsOverride.Never), (Auto, TotalsOverride.Always)), Links());
Assert.Equal(MeterTotalsClass.ExcludedByOverride, result.For(Haus).Class);
Assert.Equal(MeterTotalsReason.OverrideNever, result.For(Haus).Reason);
Assert.Equal(MeterTotalsClass.IncludedByOverride, result.For(Auto).Class);
Assert.Empty(result.For(Auto).ReplacesIds);
Assert.Equal([Auto], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.Equal([Netz], result.ForType(Electricity).Billing.BilledMeterIds);
}
[Fact]
public void Putting_Haus_back_to_auto_is_refused_while_Autos_always_depends_on_its_absence()
{
var meters = MetersWithOverride((Haus, TotalsOverride.Never), (Auto, TotalsOverride.Always));
var check = TotalsPolicy.Validate(meters, Links(), Haus, TotalsOverride.Auto);
Assert.Equal(new TotalsConflict(TotalsConflictReason.DisplacesOverride, Auto), check.Conflict);
}
[Fact]
public void Always_on_a_solar_meter_that_Summe_Solar_replaces_is_refused_naming_Summe_Solar()
{
var meters = MetersWithOverride((SummeSolar, TotalsOverride.Always));
var check = TotalsPolicy.Validate(meters, Links(), Solar1, TotalsOverride.Always);
Assert.False(check.IsAllowed);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, SummeSolar), check.Conflict);
}
[Fact]
public void Always_on_a_virtual_that_depends_on_an_always_meter_is_refused_naming_it()
{
var meters = MetersWithOverride((Solar1, TotalsOverride.Always));
var check = TotalsPolicy.Validate(meters, Links(), SummeSolar, TotalsOverride.Always);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, Solar1), check.Conflict);
}
[Fact]
public void Two_stored_always_sums_over_the_same_sources_keep_the_lower_id_and_report_the_other()
{
var meters = MetersWithOverride((SummeSolar, TotalsOverride.Always));
meters.Add(Virtual(10, "PV total (copy)", Electricity, "kWh", QuantityKind.Generation, [Solar1, Solar2], pureSum: true, totals: TotalsOverride.Always));
var result = TotalsPolicy.Classify(meters, Links());
Assert.Equal([SummeSolar], result.ForType(Electricity).MetersIn(TotalsMeasure.Generation));
Assert.Equal(MeterTotalsClass.AnalysisOnly, result.For(10).Class);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, SummeSolar), result.For(10).RefusedOverride);
Assert.Contains(result.Problems, p => p.Kind == TotalsProblemKind.OverrideRefused && p.MeterId == 10);
}
[Fact]
public void Always_on_a_virtual_over_a_subsection_is_refused_naming_its_counted_ancestor()
{
var meters = Meters();
meters.Add(Virtual(10, "Car only", Electricity, "kWh", QuantityKind.Consumption, [Auto], pureSum: true));
var check = TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, Haus), check.Conflict);
}
[Fact]
public void Always_on_a_virtual_whose_sources_nest_is_refused_because_its_sum_double_counts()
{
var meters = Meters();
meters.Add(Virtual(10, "House plus car", Electricity, "kWh", QuantityKind.Consumption, [Haus, Auto], pureSum: true));
var check = TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always);
Assert.Equal(new TotalsConflict(TotalsConflictReason.SourcesOverlap, Haus), check.Conflict);
}
[Fact]
public void Always_on_a_formula_that_is_not_a_pure_sum_is_refused()
{
var meters = Meters();
meters.Add(Virtual(10, "Netz Einsparung", Electricity, "kWh", QuantityKind.Consumption, [Haus, Netz], pureSum: false));
var check = TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always);
Assert.Equal(new TotalsConflict(TotalsConflictReason.NotAPureSum, null), check.Conflict);
}
[Theory]
[InlineData(QuantityKind.Net)]
[InlineData(QuantityKind.Indicator)]
public void Always_on_a_net_or_indicator_view_is_refused_as_not_additive(QuantityKind kind)
{
var meters = Meters();
meters.Add(Virtual(10, "View", Electricity, "kWh", kind, [Haus, Netz], pureSum: false));
var check = TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always);
Assert.Equal(new TotalsConflict(TotalsConflictReason.NotAdditive, null), check.Conflict);
}
[Fact]
public void A_virtual_without_a_usable_definition_cannot_be_counted()
{
var meters = Meters();
meters.Add(Virtual(10, "Needs configuration", Electricity, "kWh", QuantityKind.Consumption, [], pureSum: true));
var check = TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always);
Assert.Equal(TotalsConflictReason.NotAPureSum, check.Conflict!.Reason);
}
[Fact]
public void Always_on_a_consumption_sum_of_roots_replaces_them_in_use_and_in_the_bill()
{
List<TotalsMeter> meters =
[
Physical(30, "House water", Water, MeterMode.CumulativeCounter, "m³"),
Physical(31, "Garden water", Water, MeterMode.CumulativeCounter, "m³"),
Virtual(32, "All water", Water, "m³", QuantityKind.Consumption, [30, 31], pureSum: true, totals: TotalsOverride.Always),
];
var result = TotalsPolicy.Classify(meters, []);
Assert.Equal([32], result.ForType(Water).MetersIn(TotalsMeasure.Use));
Assert.Equal([30, 31], result.For(32).ReplacesIds);
Assert.Equal(BillingBasis.Use, result.ForType(Water).Billing.Basis);
Assert.Equal([32], result.ForType(Water).Billing.BilledMeterIds);
}
[Fact]
public void Always_on_a_meter_that_already_counts_changes_nothing()
{
var result = TotalsPolicy.Classify(MetersWithOverride((Haus, TotalsOverride.Always)), Links());
Assert.Equal(MeterTotalsClass.Use, result.For(Haus).Class);
Assert.Null(result.For(Haus).RefusedOverride);
Assert.True(TotalsPolicy.Validate(Meters(), Links(), Haus, TotalsOverride.Always).IsAllowed);
}
[Fact]
public void Never_on_the_grid_import_makes_the_type_bill_household_use()
{
var result = TotalsPolicy.Classify(MetersWithOverride((Netz, TotalsOverride.Never)), Links());
Assert.Equal(MeterTotalsClass.ExcludedByOverride, result.For(Netz).Class);
Assert.Equal(MeterTotalsClass.GridImport, result.For(Netz).Natural);
Assert.Empty(result.ForType(Electricity).MetersIn(TotalsMeasure.GridImport));
Assert.Equal(BillingBasis.Use, result.ForType(Electricity).Billing.Basis);
Assert.Equal([Haus], result.ForType(Electricity).Billing.BilledMeterIds);
Assert.True(TotalsPolicy.Validate(Meters(), Links(), Netz, TotalsOverride.Never).IsAllowed);
}
[Fact]
public void Never_keeps_a_subsection_s_parent_so_the_page_can_still_explain_it()
{
var result = TotalsPolicy.Classify(MetersWithOverride((Auto, TotalsOverride.Never)), Links());
Assert.Equal(MeterTotalsClass.ExcludedByOverride, result.For(Auto).Class);
Assert.Equal(MeterTotalsClass.Breakdown, result.For(Auto).Natural);
Assert.Equal([Haus], result.For(Auto).ParentIds);
}
[Fact]
public void A_duplicated_role_is_kept_by_the_lowest_id_and_the_other_meter_is_reported()
{
var meters = Meters();
meters.Add(Physical(10, "Second grid meter", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(10, Haus)]);
Assert.Equal(MeterTotalsClass.NotCounted, result.For(10).Class);
Assert.Equal(MeterTotalsReason.DuplicateRole, result.For(10).Reason);
Assert.Equal(Netz, result.For(10).RelatedMeterId);
Assert.Contains(new TotalsProblem(TotalsProblemKind.DuplicateRole, 10, Netz, MeterRole.GridImport), result.Problems);
Assert.Equal([Netz], result.ForType(Electricity).Billing.BilledMeterIds);
// The loser still measures the grid: its link into Haus is a supply edge, and it never joins household use.
Assert.Equal(MeterTotalsClass.Use, result.For(Haus).Class);
Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.Equal(TotalsConflictReason.RoleConflict, TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always).Conflict!.Reason);
}
[Fact]
public void The_same_role_in_different_energy_types_is_no_conflict()
{
var meters = Meters();
meters.Add(Physical(10, "Main water", Water, MeterMode.CumulativeCounter, "m³", MeterRoles.TotalLoad));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(10, Wasser)]);
Assert.DoesNotContain(result.Problems, p => p.Kind == TotalsProblemKind.DuplicateRole);
Assert.Equal([10], result.ForType(Water).MetersIn(TotalsMeasure.Use));
Assert.Equal(MeterTotalsClass.Breakdown, result.For(Wasser).Class);
}
[Fact]
public void A_role_handed_to_a_mode_that_cannot_hold_it_is_ignored_and_reported()
{
var meters = MetersWith(Solar1, m => m with { Role = MeterRole.TotalLoad });
var result = TotalsPolicy.Classify(meters, Links());
Assert.Contains(new TotalsProblem(TotalsProblemKind.RoleNotApplicable, Solar1, Role: MeterRole.TotalLoad), result.Problems);
Assert.Equal(MeterTotalsClass.Generation, result.For(Solar1).Class);
Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
}
[Fact]
public void An_unlinked_consumption_meter_is_taken_as_part_of_the_total_load_and_hinted()
{
var meters = Meters();
meters.Add(Physical(10, "Pool pump", Electricity, MeterMode.CumulativeCounter, "kWh"));
var result = TotalsPolicy.Classify(meters, Links());
Assert.Equal(MeterTotalsClass.Breakdown, result.For(10).Class);
Assert.Equal(MeterTotalsReason.AssumedInsideTotalLoad, result.For(10).Reason);
Assert.Equal(Haus, result.For(10).ParentId);
Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.Contains(new OverlapHint(OverlapHintKind.NotLinkedBelowTotalLoad, Electricity, 10, Haus), result.Hints);
// Counting it as well would add a part of Haus on top of Haus.
var check = TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, Haus), check.Conflict);
}
[Fact]
public void A_grid_import_not_linked_to_the_total_load_raises_a_possible_overlap_hint()
{
var links = Links().Where(l => l != new TotalsLink(Netz, Haus)).ToList();
var result = TotalsPolicy.Classify(Meters(), links);
Assert.Equal([new OverlapHint(OverlapHintKind.GridImportNotLinkedToTotalLoad, Electricity, Netz, Haus)], result.Hints);
Assert.Equal([Netz], result.ForType(Electricity).Billing.BilledMeterIds);
Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
}
[Fact]
public void A_grid_import_reaching_the_total_load_through_another_meter_is_linked()
{
var meters = Meters();
meters.Add(Physical(10, "Sub-distribution", Electricity, MeterMode.CumulativeCounter, "kWh"));
var links = Links().Where(l => l != new TotalsLink(Netz, Haus)).Append(new(Netz, 10)).Append(new(10, Haus)).ToList();
var result = TotalsPolicy.Classify(meters, links);
Assert.DoesNotContain(result.Hints, h => h.Kind == OverlapHintKind.GridImportNotLinkedToTotalLoad);
}
[Fact]
public void A_generation_meter_below_another_generation_meter_is_a_breakdown_not_a_second_root()
{
var meters = Meters();
meters.Add(Physical(10, "Inverter", Electricity, MeterMode.GenerationCounter, "kWh"));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(10, Solar1)]);
Assert.Equal(MeterTotalsClass.Breakdown, result.For(Solar1).Class);
Assert.Equal(10, result.For(Solar1).ParentId);
Assert.Equal([Solar2, 10], result.ForType(Electricity).MetersIn(TotalsMeasure.Generation));
}
[Fact]
public void Runtime_meters_all_count_even_when_linked_below_a_tank()
{
var result = TotalsPolicy.Classify(Meters(), [.. Links(), new(Oeltank, Brenner)]);
Assert.Equal(MeterTotalsClass.Runtime, result.For(Brenner).Class);
Assert.Equal([Oeltank], result.ForType(Oil).MetersIn(TotalsMeasure.Use));
Assert.Equal([Brenner], result.ForType(Oil).MetersIn(TotalsMeasure.Runtime));
}
[Fact]
public void Measures_never_add_across_units()
{
List<TotalsMeter> meters =
[
Physical(30, "Main", Water, MeterMode.CumulativeCounter, "m³"),
Physical(31, "Cistern", Water, MeterMode.DirectDelta, "L"),
];
var groups = TotalsPolicy.Classify(meters, []).ForType(Water).GroupsOf(TotalsMeasure.Use);
Assert.Equal(
[new MeasureGroup(TotalsMeasure.Use, "L", [31]), new MeasureGroup(TotalsMeasure.Use, "m³", [30])],
groups,
MeasureGroupComparer.Instance);
}
[Fact]
public void Links_across_energy_types_are_ignored()
{
var result = TotalsPolicy.Classify(Meters(), [.. Links(), new(Haus, Wasser)]);
Assert.Equal(MeterTotalsClass.Use, result.For(Wasser).Class);
Assert.Empty(result.For(Wasser).ParentIds);
}
[Fact]
public void A_containment_cycle_is_reported_and_neither_meter_becomes_a_root()
{
List<TotalsMeter> meters =
[
Physical(30, "A", Water, MeterMode.CumulativeCounter, "m³"),
Physical(31, "B", Water, MeterMode.CumulativeCounter, "m³"),
];
var result = TotalsPolicy.Classify(meters, [new(30, 31), new(31, 30)]);
Assert.Equal(MeterTotalsClass.Breakdown, result.For(30).Class);
Assert.Equal(MeterTotalsClass.Breakdown, result.For(31).Class);
Assert.Contains(new TotalsProblem(TotalsProblemKind.ContainmentCycle, 30), result.Problems);
Assert.Contains(new TotalsProblem(TotalsProblemKind.ContainmentCycle, 31), result.Problems);
Assert.Equal(BillingBasis.None, result.ForType(Water).Billing.Basis);
}
[Fact]
public void A_total_load_linked_below_another_consumption_meter_is_reported_and_the_parent_is_not_counted()
{
var meters = Meters();
meters.Add(Physical(10, "Main breaker", Electricity, MeterMode.CumulativeCounter, "kWh"));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(10, Haus)]);
Assert.Contains(new TotalsProblem(TotalsProblemKind.TotalLoadIsContained, Haus, 10), result.Problems);
Assert.Equal(MeterTotalsClass.NotCounted, result.For(10).Class);
Assert.Equal(MeterTotalsReason.ContainsTotalLoad, result.For(10).Reason);
Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
}
[Fact]
public void A_type_with_only_generation_bills_nothing()
{
var result = TotalsPolicy.Classify([Physical(40, "Balcony PV", Electricity, MeterMode.GenerationCounter, "kWh")], []);
Assert.Equal(BillingBasis.None, result.ForType(Electricity).Billing.Basis);
Assert.Equal([40], result.ForType(Electricity).MetersIn(TotalsMeasure.Generation));
Assert.Empty(result.BillItems);
var unknown = result.ForType(99);
Assert.Empty(unknown.Measures);
Assert.Equal(BillingBasis.None, unknown.Billing.Basis);
}
[Fact]
public void Validate_needs_a_known_meter_and_classify_rejects_duplicate_ids()
{
Assert.Throws<ArgumentException>(() => TotalsPolicy.Validate(Meters(), Links(), 404, TotalsOverride.Always));
Assert.Throws<ArgumentException>(() => TotalsPolicy.Classify([.. Meters(), Meters()[0]], Links()));
}
[Theory]
[InlineData(null, TotalsOverride.Auto)]
[InlineData("", TotalsOverride.Auto)]
[InlineData("auto", TotalsOverride.Auto)]
[InlineData("always", TotalsOverride.Always)]
[InlineData(" Never ", TotalsOverride.Never)]
[InlineData("sometimes", TotalsOverride.Auto)]
public void Override_tokens_parse_leniently_and_default_to_auto(string? token, TotalsOverride expected) =>
Assert.Equal(expected, TotalsOverrideTokens.Parse(token));
[Fact]
public void Override_is_read_from_meter_meta_and_round_trips_its_token()
{
Assert.Equal(TotalsOverride.Always, TotalsOverrideTokens.FromMeta("""{"role":"total_load","totals":"always"}"""));
Assert.Equal(TotalsOverride.Auto, TotalsOverrideTokens.FromMeta("not json"));
Assert.Equal(TotalsOverride.Auto, TotalsOverrideTokens.FromMeta("""{"totals":1}"""));
foreach (var value in Enum.GetValues<TotalsOverride>())
{
Assert.Equal(value, TotalsOverrideTokens.Parse(TotalsOverrideTokens.ToToken(value)));
}
}
[Fact]
public void Measures_group_units_by_their_canonical_spelling()
{
List<TotalsMeter> meters =
[
Physical(30, "Main", Water, MeterMode.CumulativeCounter, "m3"),
Physical(31, "Well", Water, MeterMode.CumulativeCounter, "m³"),
];
var groups = TotalsPolicy.Classify(meters, []).ForType(Water).GroupsOf(TotalsMeasure.Use);
Assert.Equal([new MeasureGroup(TotalsMeasure.Use, "m³", [30, 31])], groups, MeasureGroupComparer.Instance);
}
[Theory]
[InlineData(Netz)]
[InlineData(Solar1)]
public void Always_on_a_consumption_sum_over_a_supply_meter_is_refused_naming_it(int source)
{
// m(Netz) as consumption would add the grid's energy to household use, which Haus already holds; m(Solar 1)
// as consumption would add generation to use.
var meters = Meters();
meters.Add(Virtual(10, "Grid as use", Electricity, "kWh", QuantityKind.Consumption, [source], pureSum: true));
var check = TotalsPolicy.Validate(meters, Links(), 10, TotalsOverride.Always);
Assert.Equal(new TotalsConflict(TotalsConflictReason.SourceInOtherMeasure, source), check.Conflict);
meters[^1] = meters[^1] with { Override = TotalsOverride.Always };
var result = TotalsPolicy.Classify(meters, Links());
Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.Equal([Solar1, Solar2], result.ForType(Electricity).MetersIn(TotalsMeasure.Generation));
}
[Fact]
public void Always_on_a_virtual_without_a_declared_result_kind_is_not_additive()
{
// A legacy Summe Solar whose definition is not usable yet: its kind falls back to consumption in D-20, but that
// is a display assumption — counting it would add PV generation to household use.
var meters = MetersWith(SummeSolar, m => m with { Kind = QuantityKind.Consumption, VirtualResultKind = null, Override = TotalsOverride.Always });
var result = TotalsPolicy.Classify(meters, Links());
Assert.Equal(new TotalsConflict(TotalsConflictReason.NotAdditive, null), result.For(SummeSolar).RefusedOverride);
Assert.Equal([Haus], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.Equal([Solar1, Solar2], result.ForType(Electricity).MetersIn(TotalsMeasure.Generation));
Assert.Equal(
TotalsConflictReason.NotAdditive,
TotalsPolicy.Validate(MetersWith(SummeSolar, m => m with { VirtualResultKind = null }), Links(), SummeSolar, TotalsOverride.Always).Conflict!.Reason);
}
[Fact]
public void An_always_beside_a_counted_total_load_is_refused_naming_the_total_load()
{
// Main breaker → Haus and Main breaker → Workshop: the workshop is no subsection of Haus by link, but Haus is
// the total load — everything used is already in it, so counting the workshop as well double-counts.
var meters = Meters();
meters.Add(Physical(10, "Main breaker", Electricity, MeterMode.CumulativeCounter, "kWh"));
meters.Add(Physical(11, "Workshop", Electricity, MeterMode.CumulativeCounter, "kWh"));
List<TotalsLink> links = [.. Links(), new(10, Haus), new(10, 11)];
var check = TotalsPolicy.Validate(meters, links, 11, TotalsOverride.Always);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, Haus), check.Conflict);
}
[Fact]
public void An_always_sum_over_meters_retired_before_the_total_load_was_installed_is_allowed()
{
// Nothing was inside the total load before it existed, so a sum over the older meters adds nothing twice.
var meters = MetersWith(Haus, m => m with { InstalledAt = new DateOnly(2020, 1, 1) });
meters.Add(Physical(10, "Old house", Electricity, MeterMode.CumulativeCounter, "kWh", retiredAt: new DateOnly(2019, 12, 31)));
meters.Add(Physical(11, "Old annex", Electricity, MeterMode.CumulativeCounter, "kWh", retiredAt: new DateOnly(2019, 12, 31)));
meters.Add(Virtual(12, "Old total", Electricity, "kWh", QuantityKind.Consumption, [10, 11], pureSum: true));
var check = TotalsPolicy.Validate(meters, Links(), 12, TotalsOverride.Always);
Assert.True(check.IsAllowed);
}
[Fact]
public void Always_on_a_sum_listing_a_meter_twice_is_refused_as_overlapping()
{
var meters = Meters();
meters.Add(Virtual(10, "Solar 1 twice", Electricity, "kWh", QuantityKind.Generation, [Solar1, Solar1], pureSum: true, totals: TotalsOverride.Always));
var result = TotalsPolicy.Classify(meters, Links());
Assert.Equal(new TotalsConflict(TotalsConflictReason.SourcesOverlap, Solar1), result.For(10).RefusedOverride);
Assert.Equal([Solar1, Solar2], result.ForType(Electricity).MetersIn(TotalsMeasure.Generation));
}
[Fact]
public void Always_on_nested_sums_sharing_a_meter_is_refused_as_overlapping()
{
// A = m30 + m31, B = m31 + m32, C = A + B: the expansion keeps multiplicity, so m31 appears twice.
List<TotalsMeter> meters =
[
Physical(30, "House", Water, MeterMode.CumulativeCounter, "m³"),
Physical(31, "Garden", Water, MeterMode.CumulativeCounter, "m³"),
Physical(32, "Pool", Water, MeterMode.CumulativeCounter, "m³"),
Virtual(40, "C", Water, "m³", QuantityKind.Consumption, [30, 31, 31, 32], pureSum: true),
];
var check = TotalsPolicy.Validate(meters, [], 40, TotalsOverride.Always);
Assert.Equal(new TotalsConflict(TotalsConflictReason.SourcesOverlap, 31), check.Conflict);
}
[Fact]
public void Always_on_a_sum_with_a_source_in_another_energy_type_is_refused_naming_it()
{
// Counted in electricity as an m³ group, while water still counts and bills the same meter.
List<TotalsMeter> meters =
[
Physical(30, "Water", Water, MeterMode.CumulativeCounter, "m³"),
Virtual(40, "Water in electricity", Electricity, "m³", QuantityKind.Consumption, [30], pureSum: true, totals: TotalsOverride.Always),
];
var result = TotalsPolicy.Classify(meters, []);
Assert.Equal(new TotalsConflict(TotalsConflictReason.SourceInOtherEnergyType, 30), result.For(40).RefusedOverride);
Assert.Empty(result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.Equal([30], result.ForType(Water).Billing.BilledMeterIds);
}
[Fact]
public void Never_is_always_allowed_even_when_it_lets_a_lower_id_always_push_another_out()
{
// Water 30 and 31, meter 3 below 30 set to Always, and 40 = 30 + 31 set to Always. Today 3 is refused (30
// counts) and 40 replaces 30 and 31. Excluding 30 lets 3 apply first, which then overlaps 40. Excluding a meter
// never counts anything twice, so the save goes through and the displaced override is reported instead.
List<TotalsMeter> meters =
[
Physical(3, "Kitchen", Water, MeterMode.CumulativeCounter, "m³", totals: TotalsOverride.Always),
Physical(30, "House", Water, MeterMode.CumulativeCounter, "m³"),
Physical(31, "Garden", Water, MeterMode.CumulativeCounter, "m³"),
Virtual(40, "All water", Water, "m³", QuantityKind.Consumption, [30, 31], pureSum: true, totals: TotalsOverride.Always),
];
List<TotalsLink> links = [new(30, 3)];
var check = TotalsPolicy.Validate(meters, links, 30, TotalsOverride.Never);
Assert.True(check.IsAllowed);
var result = TotalsPolicy.Classify([.. meters.Select(m => m.Id == 30 ? m with { Override = TotalsOverride.Never } : m)], links);
Assert.Equal(MeterTotalsClass.IncludedByOverride, result.For(3).Class);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, 3), result.For(40).RefusedOverride);
Assert.Contains(new TotalsProblem(TotalsProblemKind.OverrideRefused, 40, 3, Conflict: TotalsConflictReason.OverlapsCountedMeter), result.Problems);
}
[Fact]
public void A_retired_grid_meter_keeps_its_role_beside_its_replacement_and_both_are_billed()
{
// The grid meter was replaced by a new meter record on 31 Jan 2023. Both hold grid_import (A-07); each counts
// only within its own service period, so the bill before the replacement is not lost and the old meter's link
// into Haus stays a supply edge instead of turning Haus into a contained total load.
var meters = MetersWith(Netz, m => m with { RetiredAt = new DateOnly(2023, 1, 31) });
meters.Add(Physical(10, "Zähler Netz (neu)", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport, installedAt: new DateOnly(2023, 1, 31)));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(10, Haus)]);
Assert.Empty(result.Problems);
Assert.Empty(result.Hints);
Assert.Equal(MeterTotalsClass.GridImport, result.For(Netz).Class);
Assert.Equal(MeterTotalsClass.GridImport, result.For(10).Class);
Assert.Equal([Netz, 10], result.ForType(Electricity).Billing.BilledMeterIds);
Assert.Equal(MeterTotalsClass.Use, result.For(Haus).Class);
Assert.Empty(result.For(Haus).ParentIds);
}
[Fact]
public void Grid_meters_in_service_at_the_same_time_are_duplicates_whatever_their_dates()
{
var meters = MetersWith(Netz, m => m with { RetiredAt = new DateOnly(2023, 3, 31) });
meters.Add(Physical(10, "Second grid meter", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport, installedAt: new DateOnly(2023, 1, 1)));
var result = TotalsPolicy.Classify(meters, Links());
Assert.Equal(MeterTotalsReason.DuplicateRole, result.For(10).Reason);
Assert.Contains(new TotalsProblem(TotalsProblemKind.DuplicateRole, 10, Netz, MeterRole.GridImport), result.Problems);
Assert.Equal([Netz], result.ForType(Electricity).Billing.BilledMeterIds);
}
[Fact]
public void An_unlinked_meter_from_before_the_total_load_was_installed_is_a_root_of_its_own()
{
// The house meter only exists since 2020; the old workshop meter was retired before, so no total load ever
// measured it and it counts in household use for its own period. A meter in service with the house meter is
// still taken to be inside it.
var meters = MetersWith(Haus, m => m with { InstalledAt = new DateOnly(2020, 1, 1) });
meters.Add(Physical(10, "Old workshop", Electricity, MeterMode.CumulativeCounter, "kWh", retiredAt: new DateOnly(2019, 12, 31)));
meters.Add(Physical(11, "Pool pump", Electricity, MeterMode.CumulativeCounter, "kWh"));
var result = TotalsPolicy.Classify(meters, Links());
Assert.Equal(MeterTotalsReason.ConsumptionRoot, result.For(10).Reason);
Assert.Equal(MeterTotalsReason.AssumedInsideTotalLoad, result.For(11).Reason);
Assert.Equal([Haus, 10], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.DoesNotContain(result.Hints, h => h.MeterId == 10);
}
[Fact]
public void An_unlinked_meter_spanning_a_total_load_replacement_is_assumed_inside_both()
{
var meters = MetersWith(Haus, m => m with { RetiredAt = new DateOnly(2023, 1, 31) });
meters.Add(Physical(10, "Zähler Haus (neu)", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.TotalLoad, installedAt: new DateOnly(2023, 1, 31)));
meters.Add(Physical(11, "Pool pump", Electricity, MeterMode.CumulativeCounter, "kWh"));
var result = TotalsPolicy.Classify(meters, [.. Links(), new(Netz, 10)]);
Assert.Equal([Haus, 10], result.For(11).ParentIds);
Assert.Equal(Haus, result.For(11).RelatedMeterId);
Assert.Equal([Haus, 10], result.ForType(Electricity).MetersIn(TotalsMeasure.Use));
Assert.DoesNotContain(result.Problems, p => p.Kind == TotalsProblemKind.DuplicateRole);
Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, Haus), TotalsPolicy.Validate(meters, [.. Links(), new(Netz, 10)], 11, TotalsOverride.Always).Conflict);
}
[Fact]
public void A_grid_meter_and_a_total_load_never_in_service_together_raise_no_overlap_hint()
{
var meters = MetersWith(Netz, m => m with { RetiredAt = new DateOnly(2019, 12, 31) });
meters = [.. meters.Select(m => m.Id == Haus ? m with { InstalledAt = new DateOnly(2020, 1, 1) } : m)];
var links = Links().Where(l => l != new TotalsLink(Netz, Haus)).ToList();
var result = TotalsPolicy.Classify(meters, links);
Assert.DoesNotContain(result.Hints, h => h.Kind == OverlapHintKind.GridImportNotLinkedToTotalLoad);
}
private sealed class MeasureGroupComparer : IEqualityComparer<MeasureGroup>
{
public static readonly MeasureGroupComparer Instance = new();
public bool Equals(MeasureGroup? x, MeasureGroup? y) =>
x is not null && y is not null && x.Measure == y.Measure && x.Unit == y.Unit && x.MeterIds.SequenceEqual(y.MeterIds);
public int GetHashCode(MeasureGroup obj) => HashCode.Combine(obj.Measure, obj.Unit);
}
private sealed class EntryComparer : IEqualityComparer<MeterTotalsEntry>
{
public static readonly EntryComparer Instance = new();
public bool Equals(MeterTotalsEntry? x, MeterTotalsEntry? y) =>
x is not null && y is not null
&& (x.MeterId, x.EnergyTypeId, x.Class, x.Reason, x.Natural, x.Measure, x.RelatedMeterId)
== (y.MeterId, y.EnergyTypeId, y.Class, y.Reason, y.Natural, y.Measure, y.RelatedMeterId)
&& Equals(x.RefusedOverride, y.RefusedOverride)
&& x.ParentIds.SequenceEqual(y.ParentIds)
&& x.ReplacesIds.SequenceEqual(y.ReplacesIds);
public int GetHashCode(MeterTotalsEntry obj) => obj.MeterId;
}
}
+105
View File
@@ -0,0 +1,105 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Quantities;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Domain;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// The reference data's topology as the totals policy sees it (ReferenceDataImporter): five electricity meters
/// and the Summe Solar view, the water meter, and the oil tank with its burner. Ids follow the seed's creation
/// order; the energy types are electricity 1, water 2, heating oil 3.
/// </summary>
/// <remarks>
/// Roles are given as the stored tokens and turned into <see cref="TotalsMeter.Role"/> through
/// <see cref="MeterRoleRules.Effective(MeterMode, string?)"/>, exactly as the reader does (A-07) — so a test that stores
/// "GRID_IMPORT" or a role on a tank sees what production would.
/// </remarks>
internal static class TotalsSeed
{
public const int Electricity = 1;
public const int Water = 2;
public const int Oil = 3;
public const int Haus = 1;
public const int Netz = 2;
public const int Auto = 3;
public const int Solar1 = 4;
public const int Solar2 = 5;
public const int Wasser = 6;
public const int Oeltank = 7;
public const int Brenner = 8;
public const int SummeSolar = 9;
/// <summary>Solar 1 → Summe, Solar 2 → Summe, Netz → Haus, Summe → Haus, Haus → Auto.</summary>
public static List<TotalsLink> Links() =>
[
new(Solar1, SummeSolar),
new(Solar2, SummeSolar),
new(Netz, Haus),
new(SummeSolar, Haus),
new(Haus, Auto),
];
public static List<TotalsMeter> Meters() =>
[
Physical(Haus, "Zähler Haus", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.TotalLoad),
Physical(Netz, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, "kWh", MeterRoles.GridImport),
Physical(Auto, "Zähler Auto", Electricity, MeterMode.CumulativeCounter, "kWh"),
Physical(Solar1, "Zähler Solar 1", Electricity, MeterMode.GenerationCounter, "kWh"),
Physical(Solar2, "Zähler Solar 2", Electricity, MeterMode.GenerationCounter, "kWh"),
Physical(Wasser, "Zähler Wasser", Water, MeterMode.CumulativeCounter, "m³"),
Physical(Oeltank, "Öltank", Oil, MeterMode.ConsumableBalance, "L"),
Physical(Brenner, "Brenner", Oil, MeterMode.RuntimeCounter, "h"),
Virtual(SummeSolar, "Summe Solar", Electricity, "kWh", QuantityKind.Generation, [Solar1, Solar2], pureSum: true),
];
/// <summary>The seed with <paramref name="adjust"/> applied to the meter <paramref name="id"/>.</summary>
public static List<TotalsMeter> MetersWith(int id, Func<TotalsMeter, TotalsMeter> adjust) =>
[.. Meters().Select(m => m.Id == id ? adjust(m) : m)];
public static List<TotalsMeter> MetersWithOverride(params (int Id, TotalsOverride Override)[] overrides) =>
[.. Meters().Select(m => overrides.Any(o => o.Id == m.Id) ? m with { Override = overrides.First(o => o.Id == m.Id).Override } : m)];
/// <param name="role">The stored role token; the meter gets the effective role, as the reader gives it.</param>
public static TotalsMeter Physical(
int id,
string name,
int energyTypeId,
MeterMode mode,
string unit,
string? role = null,
TotalsOverride totals = TotalsOverride.Auto,
QuantityKind? kind = null,
DateOnly? installedAt = null,
DateOnly? retiredAt = null)
{
var effective = MeterRoleRules.Effective(mode, role);
return new(id, name, energyTypeId, mode, effective, kind ?? KindOf(mode, effective), unit, false, totals, installedAt, retiredAt,
[], false, null);
}
/// <param name="resultKind">The declared result kind; null for a definition that is not usable.</param>
/// <param name="role">A stored role token. A virtual meter never holds one (A-07), so it always reads as none.</param>
public static TotalsMeter Virtual(
int id,
string name,
int energyTypeId,
string unit,
QuantityKind? resultKind,
IReadOnlyList<int> sources,
bool pureSum,
string? role = null,
TotalsOverride totals = TotalsOverride.Auto) =>
new(id, name, energyTypeId, MeterMode.Virtual, MeterRoleRules.Effective(MeterMode.Virtual, role),
resultKind ?? QuantityKind.Consumption, unit, true, totals, null, null, sources, pureSum, resultKind);
// Mirrors NormalizedQuantity: an effective grid_export role makes a flow meter measure export.
private static QuantityKind KindOf(MeterMode mode, MeterRole? role) => mode switch
{
MeterMode.GenerationCounter => QuantityKind.Generation,
MeterMode.RuntimeCounter => QuantityKind.Runtime,
_ when role == MeterRole.GridExport => QuantityKind.Export,
_ => QuantityKind.Consumption,
};
}
+349
View File
@@ -0,0 +1,349 @@
using MeterVault.Core.Analysis.Quantities;
namespace MeterVault.Core.Tests.Analysis;
public sealed class UnitsTests
{
public static TheoryData<string, string, bool> AliasTableEntries
{
get
{
var data = new TheoryData<string, string, bool>();
foreach (var entry in Units.AliasTable)
{
data.Add(entry.Alias, entry.Info.Symbol, entry.CaseSensitive);
}
return data;
}
}
[Theory]
// Energy
[InlineData("kWh", "kWh")]
[InlineData("kwh", "kWh")]
[InlineData("KWH", "kWh")]
[InlineData("KWh", "kWh")]
[InlineData(" kWh ", "kWh")]
[InlineData("k Wh", "kWh")]
[InlineData("Kilowattstunden", "kWh")]
[InlineData("kilowatt-hour", "kWh")]
[InlineData("Kilowatt hours", "kWh")]
[InlineData("Wh", "Wh")]
[InlineData("wh", "Wh")]
[InlineData("Wattstunden", "Wh")]
[InlineData("MWh", "MWh")]
[InlineData("MWH", "MWh")]
[InlineData("Mwh", "MWh")]
[InlineData("Megawattstunde", "MWh")]
[InlineData("mWh", "mWh")]
[InlineData("GWh", "GWh")]
[InlineData("MJ", "MJ")]
[InlineData("gj", "GJ")]
// Power
[InlineData("W", "W")]
[InlineData("w", "W")]
[InlineData("Watt", "W")]
[InlineData("kW", "kW")]
[InlineData("kw", "kW")]
[InlineData("KW", "kW")]
[InlineData("Kilowatt", "kW")]
[InlineData("MW", "MW")]
[InlineData("Mw", "MW")]
[InlineData("mW", "mW")]
// Volume
[InlineData("m3", "m³")]
[InlineData("M3", "m³")]
[InlineData("m³", "m³")]
[InlineData("cbm", "m³")]
[InlineData("CBM", "m³")]
[InlineData("m^3", "m³")]
[InlineData("Kubikmeter", "m³")]
[InlineData("cubic metre", "m³")]
[InlineData("l", "L")]
[InlineData("L", "L")]
[InlineData("Liter", "L")]
[InlineData("liter", "L")]
[InlineData("Litre", "L")]
[InlineData("litres", "L")]
[InlineData("ltr", "L")]
[InlineData("dm3", "L")]
[InlineData("hl", "hL")]
[InlineData("Hektoliter", "hL")]
// Time
[InlineData("h", "h")]
[InlineData("H", "h")]
[InlineData("hour", "h")]
[InlineData("hours", "h")]
[InlineData("hrs", "h")]
[InlineData("Std", "h")]
[InlineData("Std.", "h")]
[InlineData("Stunden", "h")]
[InlineData("Betriebsstunden", "h")]
[InlineData("min", "min")]
[InlineData("Minuten", "min")]
[InlineData("s", "s")]
// Mass
[InlineData("kg", "kg")]
[InlineData("KG", "kg")]
[InlineData("t", "t")]
[InlineData("Tonne", "t")]
public void Every_alias_normalizes_to_its_canonical_symbol(string alias, string canonical)
{
Assert.Equal(canonical, Units.Normalize(alias));
}
[Theory]
[MemberData(nameof(AliasTableEntries))]
public void Every_entry_of_the_alias_table_names_its_own_unit_however_it_is_capitalised(
string alias, string symbol, bool caseSensitive)
{
var info = Units.Describe(symbol);
Assert.NotNull(info);
Assert.Equal(symbol, info.Symbol);
Assert.Equal(symbol, Units.Normalize(symbol));
Assert.Equal(symbol, Units.Normalize(alias));
Assert.Equal(info, Units.Describe(alias));
Assert.Equal(info, Units.Describe($" {alias} "));
if (!caseSensitive)
{
// Typing a unit in capitals never changes what it means ("KWH", "LITER", "M³").
Assert.Equal(info, Units.Describe(alias.ToUpperInvariant()));
}
}
[Fact]
public void The_alias_table_holds_every_canonical_symbol_once_per_spelling()
{
var spellings = Units.AliasTable.Select(a => a.Alias).ToList();
Assert.Equal(spellings.Count, spellings.Distinct(StringComparer.Ordinal).Count());
Assert.All(
new[] { "mWh", "Wh", "kWh", "MWh", "GWh", "MJ", "GJ", "mW", "W", "kW", "MW", "GW", "L", "hL", "m³", "g", "kg", "t", "h", "min", "s" },
symbol => Assert.Contains(symbol, spellings));
}
[Fact]
public void Milli_and_mega_are_told_apart_by_the_case_of_the_m()
{
// Home Assistant reports small sensors in mW/mWh; reading them as MW/MWh would be off by 10⁹.
Assert.Equal(new UnitInfo("mW", UnitDimension.Power, 0.000_001), Units.Describe("mW"));
Assert.Equal(new UnitInfo("MW", UnitDimension.Power, 1_000), Units.Describe("MW"));
Assert.Equal(new UnitInfo("mWh", UnitDimension.Energy, 0.000_001), Units.Describe("mWh"));
Assert.Equal(new UnitInfo("MWh", UnitDimension.Energy, 1_000), Units.Describe("MWh"));
Assert.Equal(0.000_001, Units.ConversionFactor("mWh", "kWh")!.Value, 12);
Assert.False(Units.AreSame("mW", "MW"));
}
[Theory]
[InlineData("mwh")]
[InlineData("mw")]
[InlineData("mj")]
[InlineData("mWH")]
public void A_lower_case_m_on_a_mega_symbol_is_not_guessed(string unit)
{
Assert.Null(Units.Describe(unit));
Assert.Null(Units.ConversionFactor(unit, "kWh"));
Assert.Null(Units.ConversionFactor(unit, "kW"));
}
[Theory]
[InlineData(" Stk ", "stk")]
[InlineData("STK", "stk")]
[InlineData("Pellets", "pellets")]
[InlineData("%", "%")]
[InlineData("100 L", "100 l")]
[InlineData("kWh (el)", "kwh (el)")]
[InlineData("Nm³", "nm³")]
public void An_unknown_unit_is_trimmed_and_folded_so_every_spelling_gives_one_key(string unit, string expected)
{
Assert.Equal(expected, Units.Normalize(unit));
Assert.Null(Units.Describe(unit));
}
[Fact]
public void Normalized_units_can_be_grouped_ordinally()
{
var meters = new[] { "Stk", "stk", "STK", "m3", "m³", "cbm", "kWh", "KWH" };
var groups = meters.GroupBy(Units.Normalize, StringComparer.Ordinal).Select(g => (g.Key, g.Count())).ToList();
Assert.Equal([("stk", 3), ("m³", 3), ("kWh", 2)], groups);
}
[Fact]
public void The_unit_comparer_treats_every_spelling_of_a_unit_as_one_key()
{
var comparer = Units.Comparer;
Assert.True(comparer.Equals("m3", "m³"));
Assert.True(comparer.Equals("Stk", "stk"));
Assert.True(comparer.Equals(null, " "));
Assert.False(comparer.Equals("kWh", "MWh"));
Assert.False(comparer.Equals("mW", "MW"));
Assert.Equal(comparer.GetHashCode("Stk"), comparer.GetHashCode("STK"));
Assert.Equal(comparer.GetHashCode("m3"), comparer.GetHashCode("cbm"));
Assert.Equal(3, new[] { "m3", "m³", "Stk", "stk", "kWh", "kwh" }.Distinct(comparer).Count());
var byUnit = new Dictionary<string, int>(comparer) { ["m3"] = 1 };
Assert.True(byUnit.ContainsKey("Kubikmeter"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void A_missing_unit_normalizes_to_empty(string? unit)
{
Assert.Equal(string.Empty, Units.Normalize(unit));
}
[Theory]
[InlineData("m3/h", "m³/h")]
[InlineData("l/h", "L/h")]
[InlineData("Liter / Std", "L/h")]
[InlineData("kWh / h", "kWh/h")]
[InlineData("Stk/h", "stk/h")]
public void A_rate_normalizes_both_sides(string unit, string expected)
{
Assert.Equal(expected, Units.Normalize(unit));
}
[Theory]
[InlineData("m3", "m³")]
[InlineData("cbm", "M3")]
[InlineData("Liter", "l")]
[InlineData("Stk", "stk")]
[InlineData("L/h", "l/Std")]
[InlineData("Kilowattstunden", "kWh")]
public void Different_spellings_of_one_unit_are_the_same(string a, string b)
{
Assert.True(Units.AreSame(a, b));
}
[Fact]
public void Units_that_only_convert_into_each_other_are_not_the_same()
{
Assert.False(Units.AreSame("kWh", "MWh"));
Assert.False(Units.AreSame("L", "m³"));
}
[Theory]
[InlineData("kWh", "MWh")]
[InlineData("Wh", "kWh")]
[InlineData("L", "m3")]
[InlineData("hl", "L")]
[InlineData("kW", "MW")]
[InlineData("h", "min")]
[InlineData("kg", "t")]
[InlineData("GJ", "kWh")]
[InlineData("Stk", "Stk")]
[InlineData("m3/h", "L/min")]
[InlineData("mWh", "kWh")]
public void Units_of_one_dimension_are_compatible(string a, string b)
{
Assert.True(Units.AreCompatible(a, b));
Assert.True(Units.AreCompatible(b, a));
}
[Theory]
[InlineData("kWh", "m³")]
[InlineData("kW", "kWh")]
[InlineData("h", "L")]
[InlineData("kg", "L")]
[InlineData("Stk", "kWh")]
[InlineData("Stk", "Pellets")]
[InlineData("", "kWh")]
[InlineData("", "")]
[InlineData("m³/h", "m³")]
public void Units_of_different_dimensions_are_not_compatible(string a, string b)
{
Assert.False(Units.AreCompatible(a, b));
Assert.Null(Units.ConversionFactor(a, b));
}
[Theory]
[InlineData("MWh", "kWh", 1000)]
[InlineData("kWh", "MWh", 0.001)]
[InlineData("Wh", "kWh", 0.001)]
[InlineData("kWh", "kWh", 1)]
[InlineData("m3", "L", 1000)]
[InlineData("L", "m³", 0.001)]
[InlineData("hL", "L", 100)]
[InlineData("L", "hL", 0.01)]
[InlineData("t", "kg", 1000)]
[InlineData("min", "h", 1 / 60d)]
[InlineData("GJ", "kWh", 1000 / 3.6)]
[InlineData("MJ", "kWh", 1 / 3.6)]
[InlineData("Stk", "stk", 1)]
[InlineData("m³/h", "L/min", 1000 / 60d)]
[InlineData("Kilowattstunden", "Wh", 1000)]
public void The_conversion_factor_expresses_an_amount_in_the_target_unit(string from, string to, double factor)
{
Assert.Equal(factor, Units.ConversionFactor(from, to)!.Value, 9);
}
[Theory]
[InlineData("W", "Wh")]
[InlineData("kW", "kWh")]
[InlineData("kw", "kWh")]
[InlineData("MW", "MWh")]
[InlineData("GW", "GWh")]
[InlineData("mW", "mWh")]
[InlineData("L/h", "L")]
[InlineData("m3/h", "m³")]
[InlineData("l/Std", "L")]
[InlineData("kWh/h", "kWh")]
[InlineData("W/m²", "Wh/m²")]
[InlineData("kW/m2", "kWh/m2")]
[InlineData("kWh", "kWh")]
[InlineData("L", "L")]
[InlineData("Stk", "stk")]
[InlineData("", "")]
public void A_rate_per_hour_integrates_over_hours_to_its_quantity_unit(string rateUnit, string integrated)
{
Assert.Equal(integrated, Units.IntegratedOverHours(rateUnit));
}
[Theory]
[InlineData("L/min", "L/min")]
[InlineData("l/Minute", "L/min")]
[InlineData("m³/s", "m³/s")]
[InlineData("L/Tag", "L/tag")]
public void A_rate_over_another_time_keeps_its_rate_unit_because_the_normalizer_does_not_rescale_it(
string rateUnit, string kept)
{
// The normalizer books value × elapsed hours: 10 L/min for an hour books 10, which is not 10 L.
Assert.Equal(kept, Units.IntegratedOverHours(rateUnit));
Assert.Null(Units.ConversionFactor(Units.IntegratedOverHours(rateUnit), "L"));
}
[Theory]
[InlineData("kW", true)]
[InlineData("W", true)]
[InlineData("mW", true)]
[InlineData("L/h", true)]
[InlineData("m3/Std", true)]
[InlineData("W/m²", true)]
[InlineData("L/min", false)]
[InlineData("m³/s", false)]
[InlineData("kWh", false)]
[InlineData("Stk", false)]
[InlineData("", false)]
public void Only_power_and_explicit_per_hour_units_are_stated_per_hour_rates(string rateUnit, bool perHour)
{
Assert.Equal(perHour, Units.IsPerHourRate(rateUnit));
}
[Fact]
public void Describe_reports_dimension_and_scale_against_the_reference_unit()
{
var mwh = Units.Describe("MWH")!;
var cubic = Units.Describe("cbm")!;
Assert.Equal(new UnitInfo("MWh", UnitDimension.Energy, 1000), mwh);
Assert.Equal(new UnitInfo("m³", UnitDimension.Volume, 1000), cubic);
Assert.Null(Units.Describe("L/h"));
}
}
@@ -0,0 +1,250 @@
using System.Text.Json;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Quantities;
using MeterVault.Core.Analysis.Virtual;
using MeterVault.Core.Domain;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// A virtual meter's definition lives in <c>Meter.Meta</c> next to other keys such as <c>role</c> (D-25). Writing it
/// must never lose those keys, the referenced ids must follow the expression rather than whatever was stored, and
/// reading must never throw — a bad blob becomes a "malformed" result for that one meter.
/// </summary>
public sealed class VirtualDefinitionJsonTests
{
private static readonly VirtualDefinition SummeSolar = new("m4 + m5", QuantityKind.Generation, "kWh", VirtualCostRule.None);
[Fact]
public void A_definition_round_trips_and_keeps_the_role_and_every_other_key()
{
const string existing = """{"role":"grid_import","custom":{"a":[1,2]},"note":"keep me"}""";
var written = VirtualDefinitionJson.Write(existing, SummeSolar);
var read = VirtualDefinitionJson.Read(written);
Assert.Equal(VirtualDefinitionReadStatus.Present, read.Status);
Assert.Equal(SummeSolar, read.Definition);
Assert.False(read.ReferencedIdsStale);
Assert.Equal(MeterRoles.GridImport, MeterMeta.Role(written));
Assert.Equal("keep me", MeterMeta.ReadString(written, "note"));
using var doc = JsonDocument.Parse(written);
Assert.Equal("[1,2]", doc.RootElement.GetProperty("custom").GetProperty("a").GetRawText());
}
[Fact]
public void Written_keys_use_the_documented_tokens_and_derive_the_referenced_ids()
{
using var doc = JsonDocument.Parse(VirtualDefinitionJson.Write("{}", SummeSolar));
var root = doc.RootElement;
Assert.Equal("m4 + m5", root.GetProperty("expression").GetString());
Assert.Equal("[4,5]", root.GetProperty("referencedMeterIds").GetRawText());
Assert.Equal("generation", root.GetProperty("resultKind").GetString());
Assert.Equal("kWh", root.GetProperty("resultUnit").GetString());
Assert.Equal("none", root.GetProperty("costRule").GetString());
using var consumption = JsonDocument.Parse(VirtualDefinitionJson.Write(
"{}", new VirtualDefinition("m1 + m3", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts)));
Assert.Equal("sourceCosts", consumption.RootElement.GetProperty("costRule").GetString());
}
[Fact]
public void Writing_a_definition_that_leaves_its_kind_or_cost_rule_to_inference_is_refused()
{
// A-08: what is stored is the effective definition. Storing "m4 + m5" without its kind would make every reader
// take Summe Solar for consumption unless it re-validated the whole catalog first.
const string meta = """{"role":"total_load"}""";
Assert.Throws<ArgumentException>(() => VirtualDefinitionJson.Write(meta, new VirtualDefinition("m1 - m2")));
Assert.Throws<ArgumentException>(() => VirtualDefinitionJson.Write(meta, new VirtualDefinition("m1 - m2", QuantityKind.Consumption)));
Assert.Throws<ArgumentException>(() => VirtualDefinitionJson.Write(meta, new VirtualDefinition("m9", QuantityKind.Runtime, "h", VirtualCostRule.None)));
}
[Fact]
public void Saving_stores_the_inferred_kind_unit_and_cost_rule_so_readers_never_infer_them_again()
{
var catalog = new MeterCatalog(VirtualFixtures.SeededElectricity());
var validation = VirtualValidator.Validate(new VirtualDefinition("m4 + m5"), 6, catalog);
var read = VirtualDefinitionJson.Read(VirtualDefinitionJson.Write("""{"note":"x"}""", validation.EffectiveDefinition!));
Assert.Equal(SummeSolar, read.Definition);
var quantity = NormalizedQuantity.Of(MeterMode.Virtual, "kWh", null, null, read.Definition!.DeclaredResult);
Assert.Equal(new NormalizedQuantity(QuantityKind.Generation, "kWh"), quantity);
}
[Fact]
public void The_unit_is_stored_in_canonical_spelling_and_a_blank_one_is_left_out()
{
using var water = JsonDocument.Parse(VirtualDefinitionJson.Write("{}", new VirtualDefinition("m7", QuantityKind.Consumption, " m3 ", VirtualCostRule.None)));
Assert.Equal("m³", water.RootElement.GetProperty("resultUnit").GetString());
using var unitless = JsonDocument.Parse(VirtualDefinitionJson.Write("""{"resultUnit":"kWh"}""", new VirtualDefinition("m7", QuantityKind.Consumption, null, VirtualCostRule.None)));
Assert.False(unitless.RootElement.TryGetProperty("resultUnit", out _));
}
[Theory]
[InlineData("runtime")]
[InlineData("export")]
[InlineData("cost")]
public void A_stored_kind_no_virtual_meter_may_have_is_malformed(string token)
{
var read = VirtualDefinitionJson.Read($$"""{"expression":"m1","resultKind":"{{token}}"}""");
Assert.Equal(VirtualDefinitionReadStatus.Malformed, read.Status);
Assert.Null(read.Definition!.ResultKind);
}
[Theory]
[InlineData("expression", "LONE")]
[InlineData("resultUnit", "LONEx")]
[InlineData("costRule", "LONE")]
public void Text_that_cannot_be_decoded_is_malformed_not_an_exception(string key, string value)
{
// A lone surrogate escape is valid JSON but no .NET string; jsonb refuses it, an import file does not.
var escaped = value.Replace("LONE", "\\ud800", StringComparison.Ordinal);
var meta = key == "expression"
? $$"""{"expression":"{{escaped}}"}"""
: $$"""{"expression":"m1","{{key}}":"{{escaped}}"}""";
Assert.Equal(VirtualDefinitionReadStatus.Malformed, VirtualDefinitionJson.Read(meta).Status);
}
[Fact]
public void Stored_referenced_ids_are_never_trusted_over_the_expression()
{
var read = VirtualDefinitionJson.Read("""{"expression":"m1 + m2","referencedMeterIds":[9]}""");
Assert.Equal(VirtualDefinitionReadStatus.Present, read.Status);
Assert.Equal([1, 2], read.Definition!.ReferencedMeterIds);
Assert.True(read.ReferencedIdsStale);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("{}")]
[InlineData("""{"role":"grid_import"}""")]
[InlineData("""{"expression":null}""")]
[InlineData("""{"expression":" "}""")]
public void Meta_without_an_expression_is_a_legacy_meter_not_an_error(string? meta)
{
var read = VirtualDefinitionJson.Read(meta);
Assert.Equal(VirtualDefinitionReadStatus.Absent, read.Status);
Assert.Null(read.Definition);
}
[Theory]
[InlineData("not json")]
[InlineData("{\"expression\":")]
[InlineData("[1,2,3]")]
[InlineData("\"m1 + m2\"")]
[InlineData("""{"expression":5}""")]
[InlineData("""{"expression":["m1"]}""")]
public void Unreadable_meta_is_malformed_and_never_throws(string meta)
{
var read = VirtualDefinitionJson.Read(meta);
Assert.Equal(VirtualDefinitionReadStatus.Malformed, read.Status);
Assert.NotNull(read.Problem);
}
[Fact]
public void Json_nested_beyond_the_reader_depth_is_malformed_not_an_exception()
{
var deep = """{"expression":"m1","x":""" + new string('[', 200) + new string(']', 200) + "}";
Assert.Equal(VirtualDefinitionReadStatus.Malformed, VirtualDefinitionJson.Read(deep).Status);
}
[Theory]
[InlineData("""{"expression":"m1","resultKind":"power"}""")]
[InlineData("""{"expression":"m1","resultKind":3}""")]
[InlineData("""{"expression":"m1","costRule":"cheap"}""")]
[InlineData("""{"expression":"m1","resultUnit":true}""")]
public void A_bad_optional_key_is_malformed_but_keeps_the_expression_for_repair(string meta)
{
var read = VirtualDefinitionJson.Read(meta);
Assert.Equal(VirtualDefinitionReadStatus.Malformed, read.Status);
Assert.Equal("m1", read.Definition!.Expression);
}
[Fact]
public void A_syntax_error_is_the_validators_business_not_a_json_problem()
{
var read = VirtualDefinitionJson.Read("""{"expression":"m1 +"}""");
Assert.Equal(VirtualDefinitionReadStatus.Present, read.Status);
Assert.False(read.Definition!.Parsed.Success);
Assert.Equal([1], read.Definition.ReferencedMeterIds);
}
[Fact]
public void Tokens_are_read_case_insensitively()
{
var read = VirtualDefinitionJson.Read("""{"expression":"m1","resultKind":"Generation","costRule":"SOURCECOSTS","resultUnit":" kWh "}""");
Assert.Equal(new VirtualDefinition("m1", QuantityKind.Generation, "kWh", VirtualCostRule.SourceCosts), read.Definition);
}
[Fact]
public void Rewriting_meter_ids_follows_an_import_renumbering_and_keeps_the_role()
{
const string meta = """{"role":"total_load","expression":"m1 - m2","referencedMeterIds":[1,2],"resultKind":"consumption"}""";
var map = new Dictionary<int, int> { [1] = 11, [2] = 12 };
var rewritten = VirtualDefinitionJson.RewriteMeterIds(meta, id => map[id]);
var read = VirtualDefinitionJson.Read(rewritten);
Assert.Equal("m11 - m12", read.Definition!.Expression);
Assert.Equal([11, 12], read.Definition.ReferencedMeterIds);
Assert.False(read.ReferencedIdsStale);
Assert.Equal(QuantityKind.Consumption, read.Definition.ResultKind);
Assert.Equal(MeterRoles.TotalLoad, MeterMeta.Role(rewritten));
}
[Theory]
[InlineData("""{"role":"grid_import"}""")]
[InlineData("not json")]
[InlineData("")]
public void Rewriting_meter_ids_leaves_meta_without_an_expression_untouched(string meta)
{
Assert.Equal(meta, VirtualDefinitionJson.RewriteMeterIds(meta, _ => throw new InvalidOperationException("no ids to map")));
}
[Fact]
public void Removing_a_definition_keeps_every_other_key()
{
var withDefinition = VirtualDefinitionJson.Write("""{"role":"grid_export"}""", SummeSolar);
var removed = VirtualDefinitionJson.Remove(withDefinition);
Assert.Equal(VirtualDefinitionReadStatus.Absent, VirtualDefinitionJson.Read(removed).Status);
Assert.Equal(MeterRoles.GridExport, MeterMeta.Role(removed));
}
[Theory]
[InlineData("[1,2]")]
[InlineData("not json")]
[InlineData("""{"a":1,"a":2}""")]
public void Meta_that_is_not_a_json_object_is_replaced_on_write(string meta)
{
var written = VirtualDefinitionJson.Write(meta, SummeSolar);
Assert.Equal(SummeSolar, VirtualDefinitionJson.Read(written).Definition);
}
[Fact]
public void Changing_the_expression_with_a_with_expression_reparses_it()
{
var changed = SummeSolar with { Expression = "m7 * 2" };
Assert.Equal([7], changed.ReferencedMeterIds);
Assert.Equal(Formula.Parse("m7 * 2"), changed.Formula);
Assert.Equal([4, 5], SummeSolar.ReferencedMeterIds);
}
}
@@ -0,0 +1,516 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Virtual;
using static MeterVault.Core.Tests.Analysis.VirtualFixtures;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>
/// Virtual meters evaluated on read (D-27), pinned on the brief's worked example (§5.4): A and B are generation
/// meters with complete monthly data — A 100/80 kWh, B 150/120 kWh in January/February. A missing source makes a
/// bucket unknown, never a confident partial number; an observed zero is a real input; non-finite arithmetic is
/// invalid, never zero; a non-additive formula's total is the formula over the totals.
/// </summary>
public sealed class VirtualEvaluatorTests
{
/// <summary>The virtual meter being evaluated; every dependency path starts here.</summary>
private const int Self = 99;
private const int A = 1;
private const int B = 2;
private static readonly AnalysisBucket[] JanFeb = Months(2025, 1, 2);
private static VirtualSource SourceA() => Source(A, Monthly((2025, 1, 100), (2025, 2, 80)));
private static VirtualSource SourceB() => Source(B, Monthly((2025, 1, 150), (2025, 2, 120)));
private static VirtualEvaluation Evaluate(string formula, params VirtualSource[] sources) =>
Evaluate(formula, JanFeb, sources);
private static VirtualEvaluation Evaluate(
string formula, IReadOnlyList<AnalysisBucket> buckets, IEnumerable<VirtualSource> sources, int meterId = Self, QuantityKind kind = QuantityKind.Generation) =>
VirtualEvaluator.Evaluate(meterId, Formula.Parse(formula), kind, buckets, sources);
private static BucketValue Unresolved(double? value = null) =>
new(value, BucketStatus.Unresolved, Provenance.Imported, ValueIssue.CoarseResolution);
[Fact]
public void A_plus_B_is_250_and_200_by_month_and_450_in_total()
{
var result = Evaluate("m1 + m2", SourceA(), SourceB());
Assert.Equal([250d, 200d], result.Values.Select(v => v.Value!.Value));
Assert.All(result.Values, v => Assert.Equal(BucketStatus.Available, v.Status));
Assert.Equal(450, result.Total.Value);
Assert.Equal(BucketStatus.Available, result.Total.Status);
Assert.True(result.IsAdditive);
Assert.Equal(Provenance.Derived | Provenance.Imported, result.Total.Provenance);
Assert.Equal(59, result.JointDays);
Assert.Equal(ResolutionClass.Month, result.Resolution);
Assert.Equal(Self, result.MeterId);
}
[Fact]
public void Each_source_contributes_its_own_series_and_the_amounts_that_entered_the_formula()
{
var result = Evaluate("m1 + m2", SourceA(), SourceB());
var a = result.Contributions.Single(c => c.MeterId == A);
var b = result.Contributions.Single(c => c.MeterId == B);
Assert.Equal([100d, 80d], a.Values.Select(v => v.Value!.Value));
Assert.Equal([150d, 120d], b.Values.Select(v => v.Value!.Value));
Assert.Equal([100d, 80d], a.UsedAmounts.Select(v => v!.Value));
Assert.Equal(1, a.Coefficient);
Assert.Equal(180, a.Total.Value);
Assert.Equal(270, b.UsedTotal);
}
[Fact]
public void B_missing_in_February_makes_February_missing_not_a_confident_80()
{
var bOnlyJanuary = Source(B, Monthly((2025, 1, 150)));
var result = Evaluate("m1 + m2", SourceA(), bOnlyJanuary);
var february = result.Values[1];
Assert.Equal(BucketStatus.Missing, february.Status);
Assert.Null(february.Value);
Assert.Equal(ValueIssue.MissingSource, february.Issue);
Assert.Equal([Self, B], february.DependencyPath!);
Assert.Null(february.IssueDetail);
// The period total is the formula over the joint coverage — January only — and says so. A's February is a
// whole month outside the joint coverage, not a month cut in two, so the total stays a partial 250.
Assert.Equal(BucketStatus.Partial, result.Total.Status);
Assert.Equal(250, result.Total.Value);
Assert.Equal(new DateOnly(2025, 1, 31), result.LastJointDay);
// The contribution table still shows A's own February and B's absence.
var a = result.Contributions.Single(c => c.MeterId == A);
var b = result.Contributions.Single(c => c.MeterId == B);
Assert.Equal(80, a.Values[1].Value);
Assert.Equal(BucketStatus.Missing, b.Values[1].Status);
Assert.Null(a.UsedAmounts[1]);
}
[Fact]
public void B_observed_zero_in_February_makes_a_complete_80()
{
var bZeroInFebruary = Source(B, Monthly((2025, 1, 150), (2025, 2, 0)));
var result = Evaluate("m1 + m2", SourceA(), bZeroInFebruary);
Assert.Equal(BucketValue.Available(80, Provenance.Derived | Provenance.Imported), result.Values[1]);
Assert.Equal(330, result.Total.Value);
Assert.Equal(BucketStatus.Available, result.Total.Status);
}
[Fact]
public void A_minus_B_is_minus_50_and_minus_40_and_stays_signed()
{
var result = Evaluate("m1 - m2", SourceA(), SourceB());
Assert.Equal([-50d, -40d], result.Values.Select(v => v.Value!.Value));
Assert.Equal(-90, result.Total.Value);
Assert.True(result.IsAdditive);
Assert.Equal(-1, result.Contributions.Single(c => c.MeterId == B).Coefficient);
}
[Fact]
public void A_source_covering_part_of_a_bucket_makes_it_partial_with_the_jointly_covered_value()
{
var feb1 = new DateOnly(2025, 2, 1);
var dailyA = Source(A, Daily(feb1, feb1.AddMonths(1), 1));
var halfB = Source(B, Daily(feb1, feb1.AddDays(14), 2));
var result = Evaluate("m1 + m2", [Month(2025, 2)], [dailyA, halfB]);
var february = Assert.Single(result.Values);
Assert.Equal(BucketStatus.Partial, february.Status);
Assert.Equal(14 + 28, february.Value);
Assert.Equal(ValueIssue.PartialCoverage, february.Issue);
Assert.Equal([Self, B], february.DependencyPath!);
Assert.Equal(ResolutionClass.Day, result.Resolution);
}
[Theory]
[InlineData(10, 22)] // m2 covers 1031 January: a full month of m1 against 22 days of m2 would read 78
[InlineData(1, 15)] // m2 covers 115 January: m1's amount lies outside the joint days, so it would read 15
public void A_monthly_source_cut_inside_its_month_by_another_sources_coverage_is_unresolved_not_a_wrong_partial(int firstDay, int dayCount)
{
var from = new DateOnly(2025, 1, firstDay);
var monthly = Source(A, Monthly((2025, 1, 100)));
var daily = Source(B, Daily(from, from.AddDays(dayCount), 1));
var result = Evaluate("m1 - m2", [Month(2025, 1)], [monthly, daily]);
foreach (var value in new[] { result.Values[0], result.Total })
{
Assert.Equal(BucketStatus.Unresolved, value.Status);
Assert.Equal(ValueIssue.CoarseResolution, value.Issue);
Assert.Null(value.Value);
Assert.Equal([Self, A], value.DependencyPath!);
}
Assert.Null(result.Contributions.Single(c => c.MeterId == A).UsedAmounts[0]);
}
[Theory]
[InlineData(true, BucketStatus.Partial)]
[InlineData(false, BucketStatus.Unresolved)]
public void A_monthly_source_may_be_cut_at_a_month_boundary_only_when_it_is_divided_at_months(bool dividedAtMonths, BucketStatus expected)
{
// m1 is monthly for January and February, m2 starts on 1 February. Divided at months, m1's February is exactly
// February's use and the two-month bucket is a partial 80 28. A tank read mid-month is not: its "February"
// amount belongs to an interval that starts in January, so the same cut is unresolved.
var monthly = Source(A, Monthly(dividedAtMonths, (2025, 1, 100), (2025, 2, 80)));
var daily = Source(B, Daily(new DateOnly(2025, 2, 1), new DateOnly(2025, 3, 1), 1));
var janFeb = new AnalysisBucket(new DateOnly(2025, 1, 1), new DateOnly(2025, 3, 1), Utc(new DateOnly(2025, 1, 1)), Utc(new DateOnly(2025, 3, 1)), BucketSize.Year);
var result = Evaluate("m1 - m2", [janFeb], [monthly, daily]);
Assert.Equal(expected, result.Values[0].Status);
Assert.Equal(expected, result.Total.Status);
if (expected == BucketStatus.Partial)
{
Assert.Equal(80 - 28, result.Values[0].Value);
Assert.Equal([Self, B], result.Values[0].DependencyPath!);
}
else
{
Assert.Equal([Self, A], result.Values[0].DependencyPath!);
}
}
[Fact]
public void A_quarterly_interval_cut_by_joint_coverage_leaves_the_period_total_unresolved()
{
// m1 books one quarterly reading (1 Jan 31 Mar, 300) and reports its month buckets unresolved while resolving
// the quarter. m2 only starts in February, so the joint coverage keeps all 300 against two months of m2.
var quarter = Source(A, Coarse(new DateOnly(2025, 1, 1), new DateOnly(2025, 4, 1), 300)) with
{
BucketStates = [Unresolved(), Unresolved(), Unresolved()],
PeriodState = BucketValue.Available(300, Provenance.Imported),
};
var daily = Source(B, Daily(new DateOnly(2025, 2, 1), new DateOnly(2025, 4, 1), 1));
var result = Evaluate("m1 - m2", Months(2025, 1, 3), [quarter, daily]);
Assert.Equal(BucketStatus.Unresolved, result.Total.Status);
Assert.Equal([Self, A], result.Total.DependencyPath!);
// Covering the whole quarter, m2 no longer cuts it: the total resolves.
var wholeQuarter = Source(B, Daily(new DateOnly(2025, 1, 1), new DateOnly(2025, 4, 1), 1));
Assert.Equal(300 - 90, Evaluate("m1 - m2", Months(2025, 1, 3), [quarter, wholeQuarter]).Total.Value);
}
[Fact]
public void A_ratio_is_non_additive_and_its_total_is_the_ratio_of_the_totals()
{
var a = Source(A, Monthly((2025, 1, 100), (2025, 2, 80)));
var b = Source(B, Monthly((2025, 1, 50), (2025, 2, 20)));
var result = Evaluate("m1 / m2", a, b);
Assert.Equal([2d, 4d], result.Values.Select(v => v.Value!.Value));
Assert.False(result.IsAdditive);
Assert.Equal(180d / 70d, result.Total.Value!.Value, 12); // not 2 + 4
}
[Fact]
public void An_indicator_is_never_additive_even_with_a_linear_formula()
{
var result = Evaluate("m1 + m2", JanFeb, [SourceA(), SourceB()], kind: QuantityKind.Indicator);
Assert.False(result.IsAdditive);
Assert.False(VirtualSource.FromEvaluation(result).IsAdditive);
Assert.True(Evaluate("m1 + m2", JanFeb, [SourceA(), SourceB()], kind: QuantityKind.Net).IsAdditive);
}
[Fact]
public void Division_by_zero_makes_the_bucket_invalid_with_the_reason_never_zero_or_infinity()
{
var bZeroInFebruary = Source(B, Monthly((2025, 1, 50), (2025, 2, 0)));
var result = Evaluate("m1 / m2", SourceA(), bZeroInFebruary);
Assert.Equal(2, result.Values[0].Value);
var february = result.Values[1];
Assert.Equal(BucketStatus.Invalid, february.Status);
Assert.Equal(ValueIssue.NonFinite, february.Issue);
Assert.Null(february.Value);
Assert.Equal([80d, 0d], result.Contributions.Select(c => c.UsedAmounts[1]!.Value));
Assert.Equal(180d / 50d, result.Total.Value!.Value, 12);
}
[Fact]
public void A_meter_outside_its_lifetime_contributes_a_known_zero()
{
// A retired at the end of June, its successor C installed on 1 July — no swap event joins them.
var retired = Source(A, Monthly([.. Enumerable.Range(1, 6).Select(m => (2024, m, 10d * m))])) with { RetiredAt = new DateOnly(2024, 6, 30) };
var successor = Source(3, Monthly([.. Enumerable.Range(7, 6).Select(m => (2024, m, 100d + m))])) with { InstalledAt = new DateOnly(2024, 7, 1) };
var result = Evaluate("m1 + m3", Months(2024, 1, 12), [retired, successor]);
Assert.All(result.Values, v => Assert.Equal(BucketStatus.Available, v.Status));
Assert.Equal(10, result.Values[0].Value);
Assert.Equal(107, result.Values[6].Value);
Assert.Equal(210 + 657, result.Total.Value);
Assert.Equal(BucketStatus.Available, result.Total.Status);
}
[Fact]
public void A_gap_inside_a_meters_lifetime_is_still_missing()
{
var withGap = Source(A, Monthly((2025, 1, 100))) with { InstalledAt = new DateOnly(2020, 1, 1) };
var result = Evaluate("m1 + m2", withGap, SourceB());
Assert.Equal(BucketStatus.Missing, result.Values[1].Status);
Assert.Equal([Self, A], result.Values[1].DependencyPath!);
}
[Fact]
public void A_source_that_resolves_only_months_leaves_day_buckets_unresolved()
{
var days = Days(new DateOnly(2025, 1, 1), 31);
var monthlyA = Source(A, Monthly((2025, 1, 100))) with
{
BucketStates = [.. days.Select(_ => Unresolved())],
PeriodState = BucketValue.Available(100, Provenance.Imported),
};
var dailyB = Source(B, Daily(new DateOnly(2025, 1, 1), new DateOnly(2025, 2, 1), 5));
var result = Evaluate("m1 + m2", days, [monthlyA, dailyB]);
Assert.All(result.Values, v =>
{
Assert.Equal(BucketStatus.Unresolved, v.Status);
Assert.Null(v.Value);
Assert.Equal(ValueIssue.CoarseResolution, v.Issue);
Assert.Equal([Self, A], v.DependencyPath!);
});
// The month as a whole is resolved: the period total is the full 100 + 31 × 5.
Assert.Equal(BucketValue.Available(255, Provenance.Derived | Provenance.Imported | Provenance.Measured), result.Total);
}
[Fact]
public void A_source_reporting_unresolved_buckets_must_also_state_its_period()
{
// Whether the range as a whole is resolved does not follow from its buckets: every day of January is unresolved
// for a monthly source, yet January resolves (the test above). Guessing either way would be wrong somewhere.
var days = Days(new DateOnly(2025, 1, 1), 31);
var monthlyA = Source(A, Monthly((2025, 1, 100))) with { BucketStates = [.. days.Select(_ => Unresolved())] };
Assert.Throws<ArgumentException>(() => Evaluate("m1", days, [monthlyA]));
}
[Fact]
public void Without_a_period_state_only_a_pending_or_invalid_bucket_decides_the_total()
{
var partialEdges = SourceA() with
{
BucketStates = [new BucketValue(100, BucketStatus.Partial, Provenance.Imported, ValueIssue.PartialCoverage), BucketValue.Available(80, Provenance.Imported)],
};
Assert.Equal(BucketStatus.Available, Evaluate("m1 + m2", partialEdges, SourceB()).Total.Status);
var pendingFebruary = SourceA() with
{
BucketStates = [BucketValue.Available(100, Provenance.Imported), new BucketValue(null, BucketStatus.Pending, Provenance.None, ValueIssue.AnalysisPending)],
};
var total = Evaluate("m1 + m2", pendingFebruary, SourceB()).Total;
Assert.Equal(BucketStatus.Pending, total.Status);
Assert.Equal([Self, A], total.DependencyPath!);
}
[Fact]
public void A_source_whose_bucket_state_is_partial_keeps_the_result_partial_even_when_its_days_are_covered()
{
// A's coverage ends at 10:00 while now is 15:00: the day holds A's row, but A itself says the day is partial.
var day = new DateOnly(2026, 3, 10);
var partial = new BucketValue(3, BucketStatus.Partial, Provenance.Measured, ValueIssue.SampleGap, "10:00");
var a = Source(A, Daily(day, day.AddDays(1), 3)) with { BucketStates = [partial], PeriodState = partial };
var b = Source(B, Daily(day, day.AddDays(1), 1));
var result = Evaluate("m1 + m2", [Day(day)], [a, b]);
foreach (var value in new[] { result.Values[0], result.Total })
{
Assert.Equal(BucketStatus.Partial, value.Status);
Assert.Equal(4, value.Value);
Assert.Equal(ValueIssue.SampleGap, value.Issue);
Assert.Equal("10:00", value.IssueDetail);
Assert.Equal([Self, A], value.DependencyPath!);
}
Assert.Equal(BucketStatus.Partial, result.Contributions.Single(c => c.MeterId == A).Values[0].Status);
}
[Fact]
public void Bucket_states_that_do_not_line_up_with_the_buckets_are_refused()
{
var oneState = SourceA() with { BucketStates = [BucketValue.Available(100, Provenance.Imported)] };
Assert.Throws<ArgumentException>(() => Evaluate("m1 + m2", oneState, SourceB()));
}
[Fact]
public void A_nested_meter_evaluated_over_other_buckets_is_refused()
{
// Evaluated by month, the nested January value (36) would otherwise land on 1 January of a day series.
var nested = Evaluate("m1 + 5", JanFeb, [SourceA()], meterId: 10);
var days = Days(new DateOnly(2025, 1, 1), 2);
Assert.Throws<ArgumentException>(() => Evaluate("m10 + m2", days, [VirtualSource.FromEvaluation(nested), Source(B, Daily(days[0].FirstDay, days[^1].EndDay, 1))]));
}
[Fact]
public void A_source_still_being_built_makes_every_bucket_pending()
{
var pending = VirtualSource.Failed(B, BucketStatus.Pending, ValueIssue.AnalysisPending);
var result = Evaluate("m1 + m2", SourceA(), pending);
Assert.All(result.Values.Append(result.Total), v =>
{
Assert.Equal(BucketStatus.Pending, v.Status);
Assert.Equal(ValueIssue.AnalysisPending, v.Issue);
Assert.Equal([Self, B], v.DependencyPath!);
});
}
[Fact]
public void A_referenced_meter_without_any_series_is_a_missing_source()
{
var result = Evaluate("m1 + m2", SourceA());
Assert.All(result.Values, v =>
{
Assert.Equal((BucketStatus.Missing, ValueIssue.MissingSource), (v.Status, v.Issue));
Assert.Equal([Self, B], v.DependencyPath!);
});
}
[Fact]
public void No_coverage_at_all_is_missing_without_a_culprit()
{
var result = Evaluate("m1 + m2", Source(A, []), Source(B, []));
Assert.All(result.Values, v => Assert.Equal(BucketValue.Missing(), v));
Assert.Equal(BucketValue.Missing(), result.Total);
Assert.Null(result.FirstJointDay);
}
[Fact]
public void A_formula_without_meters_is_an_invalid_definition()
{
var result = Evaluate("5");
Assert.All(result.Values.Append(result.Total), v => Assert.Equal((BucketStatus.Invalid, ValueIssue.InvalidDefinition), (v.Status, v.Issue)));
}
[Fact]
public void Nested_virtual_meters_resolve_through_their_evaluation_and_report_the_path_to_a_missing_leaf()
{
const int sum = 10;
var inner = Evaluate("m1 + m2", JanFeb, [SourceA(), SourceB()], meterId: sum);
var c = Source(3, Monthly((2025, 1, 50), (2025, 2, 50)));
var outer = Evaluate("m10 - m3", JanFeb, [VirtualSource.FromEvaluation(inner), c]);
Assert.Equal([200d, 150d], outer.Values.Select(v => v.Value!.Value));
Assert.Equal(350, outer.Total.Value);
Assert.Equal([250d, 200d], outer.Contributions.Single(x => x.MeterId == sum).Values.Select(v => v.Value!.Value));
var innerWithGap = Evaluate("m1 + m2", JanFeb, [SourceA(), Source(B, Monthly((2025, 1, 150)))], meterId: sum);
var outerWithGap = Evaluate("m10 - m3", JanFeb, [VirtualSource.FromEvaluation(innerWithGap), c]);
var february = outerWithGap.Values[1];
Assert.Equal((BucketStatus.Missing, ValueIssue.MissingSource), (february.Status, february.Issue));
Assert.Equal([Self, sum, B], february.DependencyPath!);
}
[Fact]
public void A_nested_ratio_enters_with_its_own_bucket_values_and_its_division_by_zero_surfaces_as_invalid()
{
// Monthly data books each month on its last day, so the ratio's days are mostly 0 / 0: summing them would be
// meaningless. A non-additive source therefore enters with its bucket value (2 in January), and makes the
// outer series non-additive as well.
var ratio = Evaluate("m1 / m2", JanFeb, [SourceA(), Source(B, Monthly((2025, 1, 50), (2025, 2, 0)))], meterId: 10, kind: QuantityKind.Indicator);
var outer = Evaluate("m10 * 2", JanFeb, [VirtualSource.FromEvaluation(ratio)], kind: QuantityKind.Indicator);
Assert.Equal(4, outer.Values[0].Value);
Assert.Equal((BucketStatus.Invalid, ValueIssue.NonFinite), (outer.Values[1].Status, outer.Values[1].Issue));
Assert.Equal([Self, 10], outer.Values[1].DependencyPath!);
Assert.Equal(2 * 180d / 50d, outer.Total.Value!.Value, 12);
Assert.False(outer.IsAdditive);
}
[Fact]
public void A_nested_meter_on_a_loop_makes_the_outer_meter_invalid_with_the_loop_path()
{
var looped = VirtualSource.Failed(10, BucketStatus.Invalid, ValueIssue.DependencyCycle, [10, 12, 10]);
var result = Evaluate("m10 + m1", looped, SourceA());
Assert.All(result.Values, v =>
{
Assert.Equal((BucketStatus.Invalid, ValueIssue.DependencyCycle), (v.Status, v.Issue));
Assert.Equal([Self, 10, 12, 10], v.DependencyPath!);
});
}
[Fact]
public void A_sources_issue_detail_is_data_and_never_read_as_a_path()
{
// A detail that happens to be all digits (a year, a meter named "2") stays detail; the path is ids only.
var days = Days(new DateOnly(2025, 1, 1), 2);
var coarse = new BucketValue(null, BucketStatus.Unresolved, Provenance.Imported, ValueIssue.CoarseResolution, "2");
var a = Source(A, Monthly((2025, 1, 100))) with { BucketStates = [coarse, coarse], PeriodState = coarse };
var result = Evaluate("m1", days, [a]);
Assert.Equal("2", result.Values[0].IssueDetail);
Assert.Equal([Self, A], result.Values[0].DependencyPath!);
}
[Fact]
public void Per_day_results_cover_exactly_the_joint_days_so_a_parent_can_use_them()
{
var jan = new DateOnly(2025, 1, 1);
var a = Source(A, Daily(jan, jan.AddDays(10), 3));
var b = Source(B, Daily(jan.AddDays(5), jan.AddDays(20), 1));
var result = Evaluate("m1 - m2", [Month(2025, 1)], [a, b]);
Assert.Equal(5, result.Days.Count);
Assert.All(result.Days.Values, d => Assert.Equal(2, d.Amount));
Assert.Equal(jan.AddDays(5), result.FirstJointDay);
Assert.Equal(jan.AddDays(9), result.LastJointDay);
Assert.Equal(10, result.Values[0].Value);
}
[Fact]
public void Per_day_results_carry_whether_every_source_was_divided_at_months()
{
var divided = Evaluate("m1 + m2", SourceA(), SourceB());
var undivided = Evaluate("m1 + m2", Source(A, Monthly(false, (2025, 1, 100), (2025, 2, 80))), SourceB());
Assert.All(divided.Days.Values, d => Assert.True(d.DividedAtMonths));
Assert.All(undivided.Days.Values, d => Assert.False(d.DividedAtMonths));
}
[Fact]
public void An_uncovered_amount_such_as_an_opening_balance_never_enters_a_sum()
{
var jan = new DateOnly(2025, 1, 1);
var days = Daily(jan, jan.AddDays(31), 1);
days[jan] = new SourceDay(5000, false, ResolutionClass.Day, Provenance.OpeningBalance);
var result = Evaluate("m1", [Month(2025, 1)], [Source(A, days)]);
Assert.Equal(BucketStatus.Partial, result.Values[0].Status);
Assert.Equal(30, result.Values[0].Value);
}
}
@@ -0,0 +1,115 @@
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Virtual;
using MeterVault.Core.Domain;
namespace MeterVault.Core.Tests.Analysis;
/// <summary>Builders for virtual-meter tests: UTC buckets and source day maps in the shapes the reader produces.</summary>
internal static class VirtualFixtures
{
public static DateTimeOffset Utc(DateOnly day) => new(day.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero);
public static AnalysisBucket Month(int year, int month)
{
var first = new DateOnly(year, month, 1);
var end = first.AddMonths(1);
return new AnalysisBucket(first, end, Utc(first), Utc(end), BucketSize.Month);
}
public static AnalysisBucket[] Months(int year, int firstMonth, int count) =>
[.. Enumerable.Range(0, count).Select(i => new DateOnly(year, firstMonth, 1).AddMonths(i)).Select(d => Month(d.Year, d.Month))];
public static AnalysisBucket Day(DateOnly day) => new(day, day.AddDays(1), Utc(day), Utc(day.AddDays(1)), BucketSize.Day);
public static AnalysisBucket[] Days(DateOnly first, int count) =>
[.. Enumerable.Range(0, count).Select(i => Day(first.AddDays(i)))];
/// <summary>
/// A monthly-resolution series, as an imported monthly table produces it: every day of each listed month is
/// covered by a month-long run, and the month's amount is booked on its last day. Each interval lies inside its
/// month, so the run is divided at months (A-02) unless <paramref name="dividedAtMonths"/> says otherwise (a tank
/// read on the 15th).
/// </summary>
public static Dictionary<DateOnly, SourceDay> Monthly(bool dividedAtMonths, params (int Year, int Month, double Amount)[] months)
{
var days = new Dictionary<DateOnly, SourceDay>();
foreach (var (year, month, amount) in months)
{
var first = new DateOnly(year, month, 1);
var last = first.AddMonths(1).AddDays(-1);
for (var day = first; day <= last; day = day.AddDays(1))
{
days[day] = new SourceDay(
day == last ? amount : 0d, true, ResolutionClass.Month, day == last ? Provenance.Imported : Provenance.None, dividedAtMonths);
}
}
return days;
}
/// <summary>A monthly-resolution series divided at months, the shape of an imported monthly table.</summary>
public static Dictionary<DateOnly, SourceDay> Monthly(params (int Year, int Month, double Amount)[] months) => Monthly(true, months);
/// <summary>
/// One interval longer than a month over <c>[from, to)</c>, as a quarterly reading books it: covered at
/// <see cref="ResolutionClass.Coarse"/>, the whole amount on the last day, never divided.
/// </summary>
public static Dictionary<DateOnly, SourceDay> Coarse(DateOnly from, DateOnly to, double amount)
{
var days = new Dictionary<DateOnly, SourceDay>();
for (var day = from; day < to; day = day.AddDays(1))
{
var last = day == to.AddDays(-1);
days[day] = new SourceDay(last ? amount : 0d, true, ResolutionClass.Coarse, last ? Provenance.Imported : Provenance.None);
}
return days;
}
/// <summary>A daily-resolution series with the same amount on every day of <c>[from, to)</c>.</summary>
public static Dictionary<DateOnly, SourceDay> Daily(DateOnly from, DateOnly to, double perDay, Provenance provenance = Provenance.Measured)
{
var days = new Dictionary<DateOnly, SourceDay>();
for (var day = from; day < to; day = day.AddDays(1))
{
days[day] = new SourceDay(perDay, true, ResolutionClass.Day, provenance);
}
return days;
}
public static VirtualSource Source(int meterId, Dictionary<DateOnly, SourceDay> days) => new(meterId, days);
public static CatalogMeter Physical(int id, string name, MeterMode mode, QuantityKind kind, string unit, short energyType = 1) =>
new(id, name, mode, kind, unit, energyType);
public static CatalogMeter Virtual(int id, string name, QuantityKind kind, string unit, string? expression, short energyType = 1) =>
new(id, name, MeterMode.Virtual, kind, unit, energyType, expression is null ? null : new VirtualDefinition(expression, kind, unit));
/// <summary>
/// The seeded electricity meters (ReferenceDataImporter): Haus 1, Netz 2, Auto 3, Solar 1 = 4, Solar 2 = 5 and the
/// legacy virtual Summe Solar 6, plus a water meter 7 of another energy type.
/// </summary>
public static List<CatalogMeter> SeededElectricity() =>
[
Physical(1, "Zähler Haus", MeterMode.CumulativeCounter, QuantityKind.Consumption, "kWh"),
Physical(2, "Zähler Netz", MeterMode.CumulativeCounter, QuantityKind.Consumption, "kWh"),
Physical(3, "Zähler Auto", MeterMode.CumulativeCounter, QuantityKind.Consumption, "kWh"),
Physical(4, "Solar 1", MeterMode.GenerationCounter, QuantityKind.Generation, "kWh"),
Physical(5, "Solar 2", MeterMode.GenerationCounter, QuantityKind.Generation, "kWh"),
new CatalogMeter(6, "Summe Solar", MeterMode.Virtual, QuantityKind.Generation, "kWh", 1),
Physical(7, "Wasser", MeterMode.CumulativeCounter, QuantityKind.Consumption, "m³", energyType: 2),
];
/// <summary>The seeded links: Solar 1 → Summe, Solar 2 → Summe, Netz → Haus, Summe → Haus, Haus → Auto.</summary>
public static List<MeterLink> SeededLinks() =>
[
Link(4, 6),
Link(5, 6),
Link(2, 1),
Link(6, 1),
Link(1, 3),
];
public static MeterLink Link(int from, int to) => new() { FromMeterId = from, ToMeterId = to };
}
@@ -0,0 +1,353 @@
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;
/// <summary>
/// Validation on save and on read (D-26): syntax, references, loops through nested virtual meters with their path,
/// and meaning — like is only added to like, a product of meters is an indicator with its own unit, and the cost
/// rule fits the formula's shape.
/// </summary>
public sealed class VirtualValidatorTests
{
private static readonly MeterCatalog Catalog = new(
[
.. SeededElectricity(),
Physical(8, "Einspeisung", MeterMode.CumulativeCounter, QuantityKind.Export, "kWh"),
Physical(9, "Brenner", MeterMode.RuntimeCounter, QuantityKind.Runtime, "h", energyType: 3),
Physical(10, "Gartenwasser", MeterMode.CumulativeCounter, QuantityKind.Consumption, "m3", energyType: 2),
Virtual(13, "Unkonvertiert", QuantityKind.Generation, "kWh", expression: null),
Virtual(14, "Kaputt", QuantityKind.Consumption, "kWh", expression: "m1 +"),
Physical(19, "Brenner 2", MeterMode.RuntimeCounter, QuantityKind.Runtime, "h", energyType: 3),
Virtual(20, "Autarkie", QuantityKind.Indicator, "%", expression: "m2 / m1"),
Virtual(21, "Eigenverbrauchsquote", QuantityKind.Indicator, "%", expression: "m4 / m1"),
]);
private static VirtualValidation Validate(string expression, QuantityKind? kind = null, string? unit = null, VirtualCostRule? costRule = null, int meterId = 100) =>
VirtualValidator.Validate(new VirtualDefinition(expression, kind, unit, costRule), meterId, Catalog);
private static VirtualProblem Single(VirtualValidation validation) => Assert.Single(validation.Problems);
[Fact]
public void Summe_solar_as_a_sum_of_two_generation_meters_is_generation_in_kWh_and_not_costed()
{
// A-15: generation is never billed (D-34), so a generation sum has no source costs to add; its default rule is none.
var validation = Validate("m4 + m5", meterId: 6);
Assert.True(validation.IsValid);
Assert.Equal(QuantityKind.Generation, validation.Kind);
Assert.Equal("kWh", validation.Unit);
Assert.Equal(VirtualCostRule.None, validation.CostRule);
}
[Fact]
public void Source_costs_over_a_nested_difference_are_refused_for_saving_and_not_costed_when_read()
{
// Review R2 (A-15): m30 + m3 is a pure sum at its own level, but m30 = m1 - m2, so the sources' metered costs
// (m1 + m2 + m3) are not the costs of its quantity (m1 - m2 + m3). The quantity stays valid; the rule does not.
var catalog = new MeterCatalog(
[
.. SeededElectricity(),
Virtual(30, "Haus ohne Netz", QuantityKind.Consumption, "kWh", expression: "m1 - m2"),
Virtual(31, "Haus und Auto", QuantityKind.Consumption, "kWh", expression: "m1 + m3"),
]);
var declared = VirtualValidator.Validate(new VirtualDefinition("m30 + m3", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts), 40, catalog);
Assert.True(declared.IsValid);
Assert.False(declared.IsSavable);
Assert.Equal(VirtualProblemKind.CostRuleNeedsPureSum, declared.CostRuleProblem!.Kind);
Assert.Equal([30], declared.CostRuleProblem.MeterIds);
Assert.Equal(VirtualCostRule.None, declared.CostRule);
var inferred = VirtualValidator.Validate(new VirtualDefinition("m30 + m3"), 40, catalog);
Assert.Equal(VirtualCostRule.None, inferred.CostRule);
Assert.Null(inferred.CostRuleProblem);
// A sum over a nested pure sum is a pure sum all the way down.
var nestedSum = VirtualValidator.Validate(new VirtualDefinition("m31 + m2", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts), 40, catalog);
Assert.True(nestedSum.IsSavable);
Assert.Equal(VirtualCostRule.SourceCosts, nestedSum.CostRule);
Assert.Null(VirtualValidator.NestedNonSum(nestedSum.Formula!, 40, catalog));
}
[Fact]
public void Netz_einsparung_as_haus_minus_netz_is_consumption_and_may_be_priced_as_its_own_quantity()
{
var byDefault = Validate("m1 - m2");
Assert.True(byDefault.IsValid);
Assert.Equal(QuantityKind.Consumption, byDefault.Kind);
Assert.Equal(VirtualCostRule.None, byDefault.CostRule);
Assert.True(Validate("m1 - m2", costRule: VirtualCostRule.OwnQuantity).IsValid);
}
[Fact]
public void A_syntax_error_is_reported_with_its_position()
{
var problem = Single(Validate("m1 +"));
Assert.Equal(VirtualProblemKind.Syntax, problem.Kind);
Assert.Equal(new FormulaError(FormulaErrorKind.UnexpectedEnd, 4), problem.SyntaxError);
}
[Fact]
public void Unknown_meters_are_named()
{
var problem = Single(Validate("m1 + m99"));
Assert.Equal(VirtualProblemKind.UnknownMeter, problem.Kind);
Assert.Equal([99], problem.MeterIds);
}
[Fact]
public void A_meter_may_not_refer_to_itself()
{
var problem = Single(Validate("m6 + m4", meterId: 6));
Assert.Equal(VirtualProblemKind.SelfReference, problem.Kind);
Assert.Equal([6], problem.MeterIds);
}
[Fact]
public void A_loop_through_nested_virtual_meters_is_reported_with_its_path()
{
var catalog = new MeterCatalog(
[
.. SeededElectricity(),
Virtual(20, "A", QuantityKind.Consumption, "kWh", "m21 + m1"),
Virtual(21, "B", QuantityKind.Consumption, "kWh", "m22 - m2"),
Virtual(22, "C", QuantityKind.Consumption, "kWh", "m1"),
]);
// Saving C as "m20" closes the loop C → A → B → C.
var closing = VirtualValidator.Validate(new VirtualDefinition("m20"), 22, catalog);
var problem = Assert.Single(closing.Problems);
Assert.Equal(VirtualProblemKind.DependencyCycle, problem.Kind);
Assert.Equal([22, 20, 21, 22], problem.MeterIds);
// A meter that only reads the loop is caught too, with the way into it.
var looped = new MeterCatalog([.. catalog.Meters.Where(m => m.MeterId != 22), Virtual(22, "C", QuantityKind.Consumption, "kWh", "m20")]);
var reader = VirtualValidator.Validate(new VirtualDefinition("m21 + m3"), 23, looped);
Assert.Equal([23, 21, 22, 20, 21], Assert.Single(reader.Problems).MeterIds);
}
[Fact]
public void Adding_different_units_is_refused_even_for_a_declared_net_result()
{
foreach (var kind in new QuantityKind?[] { null, QuantityKind.Net })
{
var problem = Single(Validate("m1 + m7", kind));
Assert.Equal(VirtualProblemKind.UnitMismatch, problem.Kind);
Assert.Equal([1, 7], problem.MeterIds);
Assert.Equal(["kWh", "m³"], problem.Values);
}
}
[Fact]
public void Superscript_and_plain_cubic_metres_are_the_same_unit_and_the_result_is_in_canonical_spelling()
{
var water = Validate("m10 + m7");
Assert.True(water.IsValid);
Assert.Equal("m³", water.Unit);
}
[Fact]
public void Units_are_compared_through_the_shared_unit_table_ignoring_case()
{
var typed = Validate("m1 - m2", unit: "kwh");
Assert.True(typed.IsValid);
Assert.Equal("kWh", typed.Unit);
Assert.Equal("kWh", typed.EffectiveDefinition!.ResultUnit);
}
[Theory]
[InlineData(QuantityKind.Runtime)]
[InlineData(QuantityKind.Export)]
[InlineData(QuantityKind.Cost)]
public void A_result_kind_is_consumption_generation_net_or_indicator_and_nothing_else(QuantityKind kind)
{
var problem = Single(Validate("m9 + m19", kind));
Assert.Equal(VirtualProblemKind.ResultKindUnsupported, problem.Kind);
}
[Fact]
public void A_sum_of_runtime_meters_needs_a_declared_result_kind()
{
// Two burners' hours are not consumption or generation; without a declaration the reader would take them for
// consumption (D-20's fallback) and a runtime result is not one a virtual meter can have.
var undeclared = Single(Validate("m9 + m19"));
Assert.Equal(VirtualProblemKind.ResultKindRequired, undeclared.Kind);
Assert.Equal(["runtime"], undeclared.Values);
var net = Validate("m9 + m19", QuantityKind.Net);
Assert.True(net.IsValid);
Assert.Equal("h", net.Unit);
}
[Fact]
public void Indicators_are_never_added_up_into_anything_but_an_indicator()
{
var asNet = Single(Validate("m20 + m21", QuantityKind.Net));
Assert.Equal(VirtualProblemKind.IndicatorSourceNeedsIndicator, asNet.Kind);
Assert.Equal([20, 21], asNet.MeterIds);
Assert.Equal(VirtualProblemKind.IndicatorSourceNeedsIndicator, Single(Validate("0.5 * m20", QuantityKind.Net)).Kind);
Assert.Equal(VirtualProblemKind.ResultKindRequired, Single(Validate("m20 + m21")).Kind);
Assert.Equal(VirtualProblemKind.ResultKindMismatch, Single(Validate("m20", QuantityKind.Consumption)).Kind);
var indicator = Validate("m20 + m21", QuantityKind.Indicator);
Assert.True(indicator.IsValid);
Assert.Equal(VirtualCostRule.None, indicator.CostRule);
}
[Fact]
public void A_formula_over_an_indicator_is_never_costed()
{
Assert.Equal(VirtualProblemKind.CostRuleNotForIndicator, Single(Validate("m20 + m21", QuantityKind.Indicator, costRule: VirtualCostRule.SourceCosts)).Kind);
var net = Validate("m20 + m21", QuantityKind.Net, costRule: VirtualCostRule.SourceCosts);
Assert.Equal(
[VirtualProblemKind.IndicatorSourceNeedsIndicator, VirtualProblemKind.CostRuleNotForIndicator],
net.Problems.Select(p => p.Kind));
// Undeclared, the cost rule over an indicator is never the pure-sum default.
Assert.Equal(VirtualCostRule.None, Validate("m20 + m21", QuantityKind.Net).CostRule);
}
[Fact]
public void The_effective_definition_writes_out_the_inferred_kind_unit_and_cost_rule()
{
var summeSolar = Validate("m4 + m5", meterId: 6);
Assert.Equal(new VirtualDefinition("m4 + m5", QuantityKind.Generation, "kWh", VirtualCostRule.None), summeSolar.EffectiveDefinition);
Assert.Equal(new VirtualDefinition("m7 + m10", QuantityKind.Consumption, "m³", VirtualCostRule.SourceCosts), Validate("m7 + m10").EffectiveDefinition);
Assert.Null(Validate("m1 - m4").EffectiveDefinition);
}
[Fact]
public void Mixing_kinds_needs_a_declared_net_result()
{
var undeclared = Single(Validate("m1 - m4"));
Assert.Equal(VirtualProblemKind.ResultKindRequired, undeclared.Kind);
Assert.Equal([1, 4], undeclared.MeterIds);
Assert.Equal(["consumption", "generation"], undeclared.Values);
var asConsumption = Single(Validate("m1 - m4", QuantityKind.Consumption));
Assert.Equal(VirtualProblemKind.KindMismatch, asConsumption.Kind);
Assert.Equal([1, 4], asConsumption.MeterIds);
var net = Validate("m2 - m8", QuantityKind.Net);
Assert.True(net.IsValid);
Assert.Equal(QuantityKind.Net, net.Kind);
Assert.Equal("kWh", net.Unit);
}
[Fact]
public void A_product_or_quotient_of_meters_must_be_a_declared_indicator_with_its_own_unit()
{
Assert.Equal(VirtualProblemKind.ProductNeedsIndicator, Single(Validate("m1 * m2")).Kind);
Assert.Equal(VirtualProblemKind.ProductNeedsIndicator, Single(Validate("m7 / m1", QuantityKind.Consumption)).Kind);
Assert.Equal(VirtualProblemKind.ProductNeedsIndicator, Single(Validate("1 / m1")).Kind);
Assert.Equal(VirtualProblemKind.IndicatorNeedsUnit, Single(Validate("m7 / m1", QuantityKind.Indicator)).Kind);
var ratio = Validate("m7 / m1", QuantityKind.Indicator, "m³/kWh");
Assert.True(ratio.IsValid);
Assert.Equal("m³/kWh", ratio.Unit);
Assert.Equal(VirtualCostRule.None, ratio.CostRule);
}
[Fact]
public void Indicators_are_never_costed()
{
var problem = Single(Validate("m7 / m1", QuantityKind.Indicator, "m³/kWh", VirtualCostRule.OwnQuantity));
Assert.Equal(VirtualProblemKind.CostRuleNotForIndicator, problem.Kind);
}
[Fact]
public void Scaling_by_a_constant_keeps_the_quantity_and_its_unit()
{
var half = Validate("0.5 * m1 + m2 / 2", costRule: VirtualCostRule.OwnQuantity);
Assert.True(half.IsValid);
Assert.Equal(QuantityKind.Consumption, half.Kind);
Assert.Equal("kWh", half.Unit);
}
[Fact]
public void Source_costs_need_a_pure_sum_and_own_quantity_a_linear_formula()
{
Assert.Equal(VirtualProblemKind.CostRuleNeedsPureSum, Single(Validate("m1 - m2", costRule: VirtualCostRule.SourceCosts)).Kind);
Assert.Equal(VirtualProblemKind.CostRuleNeedsPureSum, Single(Validate("0.5 * m1", costRule: VirtualCostRule.SourceCosts)).Kind);
Assert.Equal(VirtualProblemKind.CostRuleNeedsLinear, Single(Validate("m1 + 5", costRule: VirtualCostRule.OwnQuantity)).Kind);
Assert.True(Validate("m1 + 5").IsValid); // allowed, but non-additive and not priceable
}
[Fact]
public void A_declared_kind_or_unit_that_contradicts_the_sources_is_refused()
{
var kind = Single(Validate("m4 + m5", QuantityKind.Consumption));
Assert.Equal(VirtualProblemKind.ResultKindMismatch, kind.Kind);
Assert.Equal(["consumption", "generation"], kind.Values);
var unit = Single(Validate("m1 - m2", unit: "MWh"));
Assert.Equal(VirtualProblemKind.ResultUnitMismatch, unit.Kind);
Assert.Equal(["MWh", "kWh"], unit.Values);
Assert.Equal(VirtualProblemKind.ResultKindUnsupported, Single(Validate("m1", QuantityKind.Cost)).Kind);
}
[Fact]
public void A_formula_without_meters_is_not_a_meter()
{
Assert.Equal(VirtualProblemKind.NoReferences, Single(Validate("5")).Kind);
}
[Fact]
public void Nested_virtual_sources_must_themselves_be_configured_and_parse()
{
Assert.Equal(VirtualProblemKind.SourceNotConfigured, Single(Validate("m13 + m4")).Kind);
Assert.Equal(VirtualProblemKind.SourceInvalid, Single(Validate("m14 + m1")).Kind);
}
[Fact]
public void A_nested_virtual_source_counts_with_its_result_kind_and_unit()
{
var catalog = new MeterCatalog([.. SeededElectricity(), Virtual(20, "Summe Solar", QuantityKind.Generation, "kWh", "m4 + m5")]);
var validation = VirtualValidator.Validate(new VirtualDefinition("m20 - m4"), 21, catalog);
Assert.True(validation.IsValid);
Assert.Equal(QuantityKind.Generation, validation.Kind);
}
[Theory]
[InlineData(new[] { QuantityKind.Generation, QuantityKind.Generation }, QuantityKind.Generation)]
[InlineData(new[] { QuantityKind.Consumption }, QuantityKind.Consumption)]
[InlineData(new[] { QuantityKind.Runtime, QuantityKind.Runtime }, null)]
[InlineData(new[] { QuantityKind.Export }, null)]
[InlineData(new[] { QuantityKind.Net }, null)]
[InlineData(new[] { QuantityKind.Consumption, QuantityKind.Generation }, null)]
[InlineData(new[] { QuantityKind.Indicator }, null)]
[InlineData(new QuantityKind[0], null)]
public void The_default_kind_is_consumption_or_generation_when_every_source_shares_it_and_none_otherwise(QuantityKind[] kinds, QuantityKind? expected)
{
Assert.Equal(expected, VirtualValidator.DefaultKind(kinds));
}
[Theory]
[InlineData("m4 + m5", null, VirtualCostRule.SourceCosts)]
[InlineData("m4 + m5", QuantityKind.Indicator, VirtualCostRule.None)]
[InlineData("m1 - m2", null, VirtualCostRule.None)]
[InlineData("m1 / m2", QuantityKind.Indicator, VirtualCostRule.None)]
public void The_default_cost_rule_is_source_costs_for_pure_sums_and_none_otherwise(string expression, QuantityKind? kind, VirtualCostRule expected)
{
Assert.Equal(expected, VirtualValidator.DefaultCostRule(Formula.Parse(expression), kind));
}
}
@@ -1,36 +0,0 @@
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));
}
+27 -52
View File
@@ -1,75 +1,50 @@
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using static MeterVault.Core.Tests.TestData;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Virtual;
using static MeterVault.Core.Tests.Analysis.VirtualFixtures;
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.
/// (SDD §2.2, §7.4): Netz Einsparung = Haus Netz, Anlage Eigenverbrauch = Erzeugung Einsparung. Ported from
/// the removed VirtualNormalizer to the evaluator that runs on read (D-29), over month buckets.
/// </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,
};
private const int Haus = 1;
private const int Netz = 2;
private const int Solar = 50;
private const int NetzEinsparung = 100;
private const int Eigenverbrauch = 101;
[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 haus = Source(Haus, Monthly((2022, 10, 411), (2022, 11, 742))); // Haus Verbrauch
var netz = Source(Netz, Monthly((2022, 10, 416), (2022, 11, 832))); // Netz Verbrauch
var result = _engine.Normalize(ctx);
var result = VirtualEvaluator.Evaluate(
NetzEinsparung, Formula.Parse("m1 - m2"), QuantityKind.Consumption, Months(2022, 10, 2), [haus, netz]);
Assert.Equal([-5d, -90d], result.Select(c => c.Amount));
Assert.Equal([-5d, -90d], result.Values.Select(v => v.Value!.Value));
}
[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 october = Months(2022, 10, 1);
var einsparung = VirtualEvaluator.Evaluate(
NetzEinsparung,
Formula.Parse("m1 - m2"),
QuantityKind.Consumption,
october,
[Source(Haus, Monthly((2022, 10, 411))), Source(Netz, Monthly((2022, 10, 416)))]);
var solar = Source(Solar, Monthly((2022, 10, 76))); // Solar Erzeugung
var result = _engine.Normalize(ctx);
var result = VirtualEvaluator.Evaluate(
Eigenverbrauch, Formula.Parse("m50 - m100"), QuantityKind.Net, october, [solar, VirtualSource.FromEvaluation(einsparung)]);
// 76 (5) = 81 (Anlage Eigenverbrauch Okt 2022).
Assert.Equal([81d], result.Select(c => c.Amount));
// 76 (5) = 81 (Anlage Eigenverbrauch Okt 2022), through the nested Netz Einsparung meter.
Assert.Equal([81d], result.Values.Select(v => v.Value!.Value));
}
}