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; /// /// 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). /// 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 Normalize( MeterMode mode, TimeZoneInfo zone, IReadOnlyList 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))); } }