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)));
}
}