Normalization: apportion a long unread gap across the months it covers
A counter delta is booked at the reading that closes it. That is right at the reporting cadence -- a monthly series books December against the 1 January reading, which is what the reference sheet does -- and wrong after an outage. Observed on Solar 1: 78 days of generation arrived as one July row, leaving June looking like the array was switched off. An interval containing two or more complete calendar months is now divided across them in proportion to elapsed time. The meter recorded a total, not a shape, so every row a split produces is marked Estimated. The sum is exact: the final segment absorbs the rounding remainder, so a split never creates or destroys energy. Counting whole months *contained* rather than boundaries *crossed* is what makes the rule safe. A monthly series contains exactly one whole month per interval and is untouched, so the golden fixtures keep measuring the normalizer rather than the splitter; and a reading landing hours late cannot tip the rule and hand the new month a sliver. GapSplittingIsInertOnFixturesTests asserts the rule declines to fire on every reference interval, so this cannot drift into the oracle unnoticed. Not apportioned: swap and reset amounts (explicit corrections booked at their event -- apportioning one would rewrite a number the operator supplied), a rejected decrease, and a zero delta, which would otherwise fan out into rows that say nothing. Segments are stamped at their end, keeping the existing convention that a row records the period ending at its timestamp -- so nothing shifts relative to how unsplit intervals are already labelled. Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Core.Normalization;
|
||||
using static MeterVault.Core.Tests.TestData;
|
||||
|
||||
namespace MeterVault.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A counter delta is booked at the reading that closes it. That is correct at the reporting cadence
|
||||
/// and wrong after a long outage, so a gap containing two or more whole months is apportioned.
|
||||
/// The boundary between those two behaviours is what these pin down: a normal monthly series must
|
||||
/// come out byte-for-byte unchanged, because it is what reconciles against the reference spreadsheet.
|
||||
/// </summary>
|
||||
public sealed class GapAttributionTests
|
||||
{
|
||||
private readonly INormalizationEngine _engine = NormalizationEngine.CreateDefault();
|
||||
|
||||
[Fact]
|
||||
public void A_monthly_cadence_is_never_split()
|
||||
{
|
||||
// One whole month per interval — the reference-data shape. Splitting here would move energy
|
||||
// between months and break reconciliation (SDD §13).
|
||||
Assert.False(GapAttribution.ShouldSplit(Month(2023, 1), Month(2023, 2)));
|
||||
Assert.False(GapAttribution.ShouldSplit(Month(2023, 1), Month(2023, 2).AddDays(-1)));
|
||||
|
||||
// A reading that lands hours late must not tip the rule and hand January a sliver.
|
||||
Assert.False(GapAttribution.ShouldSplit(Month(2023, 12), Month(2024, 1).AddHours(6)));
|
||||
|
||||
// Nor should a six-week interval, which still contains only one whole month.
|
||||
Assert.False(GapAttribution.ShouldSplit(Month(2023, 1), Month(2023, 2).AddDays(14)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sub_month_intervals_are_never_split()
|
||||
{
|
||||
Assert.False(GapAttribution.ShouldSplit(Month(2023, 5), Month(2023, 5).AddHours(1)));
|
||||
Assert.False(GapAttribution.ShouldSplit(Month(2023, 5).AddDays(10), Month(2023, 5).AddDays(20)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_skipped_month_is_split()
|
||||
{
|
||||
Assert.True(GapAttribution.ShouldSplit(Month(2023, 1), Month(2023, 3)));
|
||||
Assert.True(GapAttribution.ShouldSplit(Month(2026, 5), new DateTimeOffset(2026, 7, 18, 15, 33, 0, TimeSpan.Zero)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Splitting_preserves_the_total_and_keeps_the_closing_timestamp()
|
||||
{
|
||||
var start = Month(2026, 5);
|
||||
var end = new DateTimeOffset(2026, 7, 18, 15, 33, 0, TimeSpan.Zero);
|
||||
|
||||
var segments = GapAttribution.Split(start, end, 714.5);
|
||||
|
||||
// May, June, July.
|
||||
Assert.Equal(3, segments.Count);
|
||||
Assert.Equal(714.5, segments.Sum(s => s.Amount), 6);
|
||||
Assert.Equal(end, segments[^1].Time);
|
||||
Assert.Equal(Month(2026, 6), segments[0].Time);
|
||||
Assert.Equal(Month(2026, 7), segments[1].Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Each_month_gets_a_share_proportional_to_the_time_it_covers()
|
||||
{
|
||||
// Exactly two whole months: an even split, to the cent.
|
||||
var segments = GapAttribution.Split(Month(2023, 1), Month(2023, 3), 620);
|
||||
|
||||
Assert.Equal(2, segments.Count);
|
||||
var januaryShare = 31d / 59d; // 2023 is not a leap year: Jan 31 + Feb 28.
|
||||
Assert.Equal(620 * januaryShare, segments[0].Amount, 6);
|
||||
Assert.Equal(620, segments.Sum(s => s.Amount), 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_gap_in_a_counter_series_is_spread_and_marked_estimated()
|
||||
{
|
||||
var ctx = new NormalizationContext
|
||||
{
|
||||
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
|
||||
Readings =
|
||||
[
|
||||
Reading(1, Month(2023, 1), 1000),
|
||||
Reading(1, Month(2023, 4), 1900), // three months in one reading
|
||||
],
|
||||
};
|
||||
|
||||
var result = _engine.Normalize(ctx).ToList();
|
||||
|
||||
// Baseline row for the first reading, then Jan/Feb/Mar shares of the 900 gap.
|
||||
Assert.Equal(4, result.Count);
|
||||
Assert.Equal(1000 + 900, result.Sum(c => c.Amount), 6);
|
||||
|
||||
var spread = result.Skip(1).ToList();
|
||||
Assert.All(spread, c => Assert.Equal(ReadingQuality.Estimated, c.Quality));
|
||||
Assert.Equal(900, spread.Sum(c => c.Amount), 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_ordinary_monthly_series_produces_one_measured_row_per_reading()
|
||||
{
|
||||
// The regression that matters: this is the reference-data shape, and it must not gain rows
|
||||
// or lose its quality markers.
|
||||
var ctx = new NormalizationContext
|
||||
{
|
||||
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
|
||||
Readings =
|
||||
[
|
||||
Reading(1, Month(2022, 9), 0),
|
||||
Reading(1, Month(2022, 10), 411),
|
||||
Reading(1, Month(2022, 11), 1153),
|
||||
Reading(1, Month(2022, 12), 1968),
|
||||
],
|
||||
};
|
||||
|
||||
var result = _engine.Normalize(ctx).ToList();
|
||||
|
||||
Assert.Equal(4, result.Count);
|
||||
Assert.DoesNotContain(result, c => c.Quality == ReadingQuality.Estimated);
|
||||
Assert.Equal([0, 411, 742, 815], result.Select(c => c.Amount).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_observed_solar_gap_is_apportioned_across_the_months_it_covers()
|
||||
{
|
||||
// The case this exists for: Solar 1 read monthly to 1 May 2026, then a single live reading on
|
||||
// 18 July. 714.5 kWh of generation arriving as one July row made June look like an outage.
|
||||
var ctx = new NormalizationContext
|
||||
{
|
||||
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.GenerationCounter, Unit = "kWh" },
|
||||
Readings =
|
||||
[
|
||||
Reading(1, Month(2026, 4), 10308),
|
||||
Reading(1, Month(2026, 5), 10731),
|
||||
Reading(1, new DateTimeOffset(2026, 7, 18, 15, 33, 0, TimeSpan.Zero), 11445.5),
|
||||
],
|
||||
};
|
||||
|
||||
var result = _engine.Normalize(ctx).ToList();
|
||||
var gap = result.Where(c => c.Time > Month(2026, 5)).ToList();
|
||||
|
||||
Assert.Equal(3, gap.Count);
|
||||
Assert.Equal(714.5, gap.Sum(c => c.Amount), 6);
|
||||
|
||||
// No single month swallows the whole gap any more.
|
||||
Assert.All(gap, c => Assert.True(c.Amount < 714.5 * 0.75, $"{c.Time:yyyy-MM-dd} took {c.Amount:0.#}"));
|
||||
|
||||
// Generation is preserved end to end: baseline 0 → 11445.5.
|
||||
Assert.Equal(11445.5, result.Sum(c => c.Amount), 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unchanged_register_across_a_long_gap_does_not_fan_out_into_empty_rows()
|
||||
{
|
||||
// Nothing was used. Three rows of zero say no more than one, and would dilute the
|
||||
// measured/estimated ratio on the detail page.
|
||||
var ctx = new NormalizationContext
|
||||
{
|
||||
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
|
||||
Readings = [Reading(1, Month(2023, 1), 500), Reading(1, Month(2023, 5), 500)],
|
||||
};
|
||||
|
||||
var result = _engine.Normalize(ctx).ToList();
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal(0, result[^1].Amount, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_rejected_decrease_across_a_long_gap_stays_a_single_row()
|
||||
{
|
||||
// The decrease branch already yields 0 and rebaselines; spreading that zero would invent
|
||||
// rows for months the meter never reported.
|
||||
var ctx = new NormalizationContext
|
||||
{
|
||||
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
|
||||
Readings = [Reading(1, Month(2023, 1), 900), Reading(1, Month(2023, 5), 100)],
|
||||
};
|
||||
|
||||
var result = _engine.Normalize(ctx).ToList();
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal(0, result[^1].Amount, 6);
|
||||
Assert.Equal(Month(2023, 5), result[^1].Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_swap_across_a_long_gap_keeps_its_explicit_amount_in_one_row()
|
||||
{
|
||||
// Swap amounts are corrections booked at the event (the water …861 → 2 case reconciles to
|
||||
// 12). Apportioning one across the gap would silently rewrite a number the operator supplied.
|
||||
var ctx = new NormalizationContext
|
||||
{
|
||||
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m³" },
|
||||
Readings =
|
||||
[
|
||||
Reading(1, Month(2023, 1), 861),
|
||||
Reading(1, Month(2023, 5), 15),
|
||||
],
|
||||
Events = [Swap(1, Month(2023, 3), prevValue: 861, newValue: 2, amount: 12)],
|
||||
};
|
||||
|
||||
var result = _engine.Normalize(ctx).ToList();
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal(12, result[^1].Amount, 6);
|
||||
Assert.NotEqual(ReadingQuality.Estimated, result[^1].Quality);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Core.Normalization;
|
||||
using MeterVault.Infrastructure.Import;
|
||||
using static MeterVault.Integration.Tests.Reconciliation.ReconciliationSupport;
|
||||
|
||||
namespace MeterVault.Integration.Tests.Reconciliation;
|
||||
|
||||
/// <summary>
|
||||
/// Gap splitting apportions a long unread stretch across the months it covers. The reference sheets
|
||||
/// are read monthly and must never trigger it, or their months would silently shift and the whole
|
||||
/// golden-fixture oracle (SDD §13) would be measuring the splitter instead of the normalizer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The reconciliation suites already compare month by month, so a spurious split would surface there
|
||||
/// as a numeric failure. This asserts the mechanism directly instead of relying on that side effect:
|
||||
/// it proves the rule was evaluated against real fixture cadence and declined to fire, rather than
|
||||
/// the fixtures simply having no gaps to find.
|
||||
/// </remarks>
|
||||
public sealed class GapSplittingIsInertOnFixturesTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(ReferenceProfiles.Haus, MeterMode.CumulativeCounter)]
|
||||
[InlineData(ReferenceProfiles.Netz, MeterMode.CumulativeCounter)]
|
||||
[InlineData(ReferenceProfiles.Auto, MeterMode.CumulativeCounter)]
|
||||
[InlineData(ReferenceProfiles.Solar1, MeterMode.GenerationCounter)]
|
||||
[InlineData(ReferenceProfiles.Solar2, MeterMode.GenerationCounter)]
|
||||
public void Electricity_meters_produce_exactly_one_row_per_reading(int meterId, MeterMode mode)
|
||||
{
|
||||
var staged = Stage(ReferenceProfiles.Electricity(), Electricity);
|
||||
var readings = staged.Readings.Count(r => r.MeterId == meterId);
|
||||
|
||||
var computed = Normalize(staged, new MeterConfig { MeterId = meterId, Mode = mode, Unit = "kWh" });
|
||||
|
||||
Assert.True(readings > 20, $"meter {meterId}: expected a real series, got {readings} readings.");
|
||||
Assert.Equal(readings, computed.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_fixture_interval_is_long_enough_to_split()
|
||||
{
|
||||
var staged = Stage(ReferenceProfiles.Electricity(), Electricity);
|
||||
|
||||
foreach (var group in staged.Readings.GroupBy(r => r.MeterId))
|
||||
{
|
||||
var times = group.Select(r => r.Time).OrderBy(t => t).ToList();
|
||||
for (var i = 1; i < times.Count; i++)
|
||||
{
|
||||
Assert.False(
|
||||
GapAttribution.ShouldSplit(times[i - 1], times[i]),
|
||||
$"meter {group.Key}: {times[i - 1]:yyyy-MM-dd} → {times[i]:yyyy-MM-dd} would be split.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user