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:
2026-07-18 19:39:42 +02:00
parent 95c51842e8
commit ad896db051
6 changed files with 417 additions and 10 deletions
@@ -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.");
}
}
}
}