ci / build-test (push) Successful in 2m41s
Three things a reported Heizoel page got wrong at once. Its tank is dipped a few times a year and its burner read every few months, which is exactly the shape the coverage rules had not been walked through. "No data" for data that exists. A tank books nothing until the next dipstick closes the interval, so the stretch after the last dipstick is covered by no run at all, and a bucket no run covers was reported missing. The burner, whose run reaches into the window, said "only coarser data" -- the honest answer -- so one card claimed there was nothing while the coverage panel beside it listed years of data. A bucket that no run covers, no gap overlaps and no opening balance explains now reports the meter's resolution when its preceding coverage is within one interval of its own class: it is not silent, it is read rarely. A meter that does book its own buckets and stops -- a dead hourly source, a sheet asked about a later month -- still reads missing. Auto answering twelve months with one bar. Coarse only means "longer than a month", so a dipstick taken each autumn straddles a New Year as surely as a month start: coarsening the chart to years bought nothing and cost every point. The planning resolution now caps coarse at month when a run crosses a local year edge, and a series that cannot resolve the natural size no longer coarsens the whole chart -- it is drawn at that size with its buckets marked, which the chart and table already explain. A page contradicting itself. The comparison line above the ranking was fed the leading measure's matched coverage but worded as if it spoke for the page, directly above a burner row that did compare. It now names the figure it is about. Alongside: the "largest changes" ranking no longer drops a meter whose change is not comparable. It ranks what can be ranked, then lists the rest with their values and the reason -- the tank had been vanishing from its own energy type. And every dated table now reads newest first, as lists are read; charts stay chronological left to right, and the CSV export stays ascending for spreadsheets. A-41 to A-43 in the note record the three rules.
294 lines
14 KiB
C#
294 lines
14 KiB
C#
using MeterVault.Core.Analysis;
|
|
using MeterVault.Core.Analysis.Virtual;
|
|
using MeterVault.Core.Domain;
|
|
using MeterVault.Infrastructure.Analysis;
|
|
using MeterVault.Infrastructure.Dashboard;
|
|
using MeterVault.Infrastructure.Ingestion;
|
|
using MeterVault.Infrastructure.Options;
|
|
using MeterVault.Integration.Tests.Costing;
|
|
using static MeterVault.Integration.Tests.Costing.CostSandbox;
|
|
|
|
namespace MeterVault.Integration.Tests.MeterPage;
|
|
|
|
/// <summary>
|
|
/// The meter page's read model (brief §7.2, D-50): record tabs paged server-side, newest first, keyset-ordered on each
|
|
/// table's key and filtered by a half-open range; the manual-entry dialog's own context for the entered instant (the
|
|
/// neighbours the ingestion guard reads); markers inside a range; applicable tariffs; and a virtual meter's calculation.
|
|
/// </summary>
|
|
[Collection("Timescale")]
|
|
public sealed class MeterDetailServiceTests(TimescaleFixture fx)
|
|
{
|
|
private static readonly DateTimeOffset Start = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
|
|
|
[Fact]
|
|
public async Task Readings_page_newest_first_by_keyset_and_filter_by_a_half_open_range()
|
|
{
|
|
await using var box = new CostSandbox(fx);
|
|
var type = await box.TypeAsync();
|
|
var meter = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh");
|
|
await InsertReadingsAsync(meter, 250);
|
|
var service = Service();
|
|
|
|
var first = await service.GetReadingsAsync(meter, RecordRange.All);
|
|
var second = await service.GetReadingsAsync(meter, RecordRange.All, first.Next);
|
|
var third = await service.GetReadingsAsync(meter, RecordRange.All, second.Next);
|
|
|
|
Assert.Equal((100, 100, 50), (first.Rows.Count, second.Rows.Count, third.Rows.Count));
|
|
Assert.All(new[] { first, second, third }, page => Assert.Equal(250, page.Total));
|
|
Assert.NotNull(first.Next);
|
|
Assert.NotNull(second.Next);
|
|
Assert.Null(third.Next);
|
|
var all = first.Rows.Concat(second.Rows).Concat(third.Rows).Select(r => r.Time).ToList();
|
|
Assert.Equal(250, all.Distinct().Count());
|
|
Assert.Equal(all.OrderDescending(), all);
|
|
Assert.Equal(Start.AddHours(249), all[0]);
|
|
|
|
// Keyset, not offset: a reading arriving at the top does not shift the older pages.
|
|
await InsertReadingsAsync(meter, 1, fromHour: 1000);
|
|
var again = await service.GetReadingsAsync(meter, RecordRange.All, first.Next);
|
|
Assert.Equal(second.Rows, again.Rows);
|
|
Assert.Equal(251, again.Total);
|
|
|
|
// [from, to): the start is included, the end is not.
|
|
var range = new RecordRange(Start.AddHours(10), Start.AddHours(20));
|
|
var filtered = await service.GetReadingsAsync(meter, range);
|
|
Assert.Equal(10, filtered.Total);
|
|
Assert.Equal(Start.AddHours(19), filtered.Rows[0].Time);
|
|
Assert.Equal(Start.AddHours(10), filtered.Rows[^1].Time);
|
|
Assert.Null(filtered.Next);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Normalized_rows_break_ties_on_their_kind_across_a_page_boundary()
|
|
{
|
|
await using var box = new CostSandbox(fx);
|
|
var type = await box.TypeAsync();
|
|
var meter = await box.MeterAsync(type, MeterMode.DirectDelta, "kWh");
|
|
|
|
// One row at the newest instant, then 51 instants with both kinds: the 100th and 101st rows share an instant.
|
|
await using (var db = fx.CreateContext())
|
|
{
|
|
db.Consumption.Add(Row(meter, 51, ConsumptionKind.Consumption));
|
|
for (var i = 0; i <= 50; i++)
|
|
{
|
|
db.Consumption.Add(Row(meter, i, ConsumptionKind.Consumption));
|
|
db.Consumption.Add(Row(meter, i, ConsumptionKind.Generation));
|
|
}
|
|
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
var service = Service();
|
|
var first = await service.GetConsumptionAsync(meter, RecordRange.All);
|
|
var second = await service.GetConsumptionAsync(meter, RecordRange.All, first.Next);
|
|
|
|
Assert.Equal(103, first.Total);
|
|
Assert.Equal(100, first.Rows.Count);
|
|
Assert.Equal((Start.AddHours(1), ConsumptionKind.Generation), (first.Rows[^1].Time, first.Rows[^1].Kind));
|
|
Assert.Equal(3, second.Rows.Count);
|
|
Assert.Equal((Start.AddHours(1), ConsumptionKind.Consumption), (second.Rows[0].Time, second.Rows[0].Kind));
|
|
Assert.Null(second.Next);
|
|
Assert.Equal(103, first.Rows.Concat(second.Rows).Select(r => (r.Time, r.Kind)).Distinct().Count());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Events_page_on_their_instant_then_their_id()
|
|
{
|
|
await using var box = new CostSandbox(fx);
|
|
var type = await box.TypeAsync();
|
|
var meter = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh");
|
|
await using (var db = fx.CreateContext())
|
|
{
|
|
// All at one instant: only the id orders them.
|
|
for (var i = 0; i < 101; i++)
|
|
{
|
|
db.MeterEvents.Add(new MeterEvent { MeterId = meter, Time = Start, EventType = MeterEventType.Note, Notes = $"note {i}" });
|
|
}
|
|
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
var service = Service();
|
|
var first = await service.GetEventsAsync(meter, RecordRange.All);
|
|
var second = await service.GetEventsAsync(meter, RecordRange.All, first.Next);
|
|
|
|
Assert.Equal((100, 1, 101), (first.Rows.Count, second.Rows.Count, first.Total));
|
|
var ids = first.Rows.Concat(second.Rows).Select(e => e.Id).ToList();
|
|
Assert.Equal(101, ids.Distinct().Count());
|
|
Assert.Equal(ids.OrderDescending(), ids);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_entry_context_judges_a_reading_by_the_neighbours_the_guard_reads()
|
|
{
|
|
await using var box = new CostSandbox(fx);
|
|
var type = await box.TypeAsync();
|
|
var meter = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh");
|
|
|
|
// Imported month rows: "July 2026" (400) and "August 2026" (500), stamped on the 1st (UTC, as the importer writes
|
|
// them), describing the month's end.
|
|
await using (var db = fx.CreateContext())
|
|
{
|
|
db.Readings.Add(new Reading { MeterId = meter, Time = new DateTimeOffset(2026, 7, 1, 0, 0, 0, TimeSpan.Zero), Value = 400, Quality = ReadingQuality.Imported, Flags = ReadingFlags.MonthLabel });
|
|
db.Readings.Add(new Reading { MeterId = meter, Time = new DateTimeOffset(2026, 8, 1, 0, 0, 0, TimeSpan.Zero), Value = 500, Quality = ReadingQuality.Imported, Flags = ReadingFlags.MonthLabel });
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
var service = Service();
|
|
var at = Midnight(2026, 8, 20);
|
|
var context = await service.GetReadingEntryContextAsync(meter, at);
|
|
|
|
// The latest reading by stamp is August's (500), but on the timeline a reading on 20 August comes after July's end
|
|
// and before August's: the dialog judges it against 400, exactly like the guard — the old page, holding only
|
|
// the latest row, would have called 450 a decrease.
|
|
Assert.NotNull(context);
|
|
Assert.Equal(500, context!.Latest!.Value);
|
|
Assert.Equal(400, context.Previous!.Value);
|
|
Assert.True(context.Monotonic);
|
|
Assert.False(context.BoundaryExplainsDecrease);
|
|
Assert.Null(context.AtTime);
|
|
|
|
await using (var db = fx.CreateContext())
|
|
{
|
|
var ingestion = new IngestionService(db, Normalization(db));
|
|
Assert.Equal(IngestionOutcome.RejectedDecrease, await ingestion.IngestByMeterAsync(meter, at, 350, renormalize: false, quality: ReadingQuality.Manual));
|
|
Assert.Equal(IngestionOutcome.Written, await ingestion.IngestByMeterAsync(meter, at, 450, renormalize: false, quality: ReadingQuality.Manual));
|
|
}
|
|
|
|
// The reading at that instant is now what a save would replace.
|
|
var replacing = await service.GetReadingEntryContextAsync(meter, at);
|
|
Assert.Equal(450, replacing!.AtTime!.Value);
|
|
Assert.False(replacing.AtTime.IsRegisterStart);
|
|
|
|
// A swap after the previous reading explains a lower value.
|
|
await using (var db = fx.CreateContext())
|
|
{
|
|
db.MeterEvents.Add(new MeterEvent { MeterId = meter, Time = Midnight(2026, 9, 5), EventType = MeterEventType.MeterSwap, PrevValue = 520, NewValue = 0 });
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
var afterSwap = await service.GetReadingEntryContextAsync(meter, Midnight(2026, 9, 10));
|
|
Assert.True(afterSwap!.BoundaryExplainsDecrease);
|
|
|
|
Assert.Null(await service.GetReadingEntryContextAsync(int.MaxValue, at));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_identity_read_carries_the_register_span_and_the_normalized_unit()
|
|
{
|
|
await using var box = new CostSandbox(fx);
|
|
var type = await box.TypeAsync();
|
|
var meter = await box.MeterAsync(type, MeterMode.GenerationCounter, "kWh", installedAt: D(2026, 1, 1));
|
|
await box.ReadingsAsync(meter, (Midnight(2026, 1, 1), 10), (Midnight(2026, 2, 1), 110));
|
|
var service = Service();
|
|
|
|
var detail = await service.GetAsync(meter);
|
|
|
|
Assert.NotNull(detail);
|
|
Assert.True(detail!.HasReadings);
|
|
Assert.False(detail.HasEvents);
|
|
Assert.Equal((10d, 110d), (detail.FirstReading!.Value, detail.LastReading!.Value));
|
|
Assert.Equal(QuantityKind.Generation, detail.Kind);
|
|
Assert.Equal("kWh", detail.NormalizedUnit);
|
|
Assert.Equal(D(2026, 1, 1), detail.InstalledAt);
|
|
Assert.Null(await service.GetAsync(int.MaxValue));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Markers_hold_the_events_and_tariff_changes_inside_the_range_only()
|
|
{
|
|
await using var box = new CostSandbox(fx);
|
|
var type = await box.TypeAsync();
|
|
var meter = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh");
|
|
var other = await box.TypeAsync();
|
|
await box.TypePriceAsync(type, 0.30, D(2025, 1, 1));
|
|
await box.TypePriceAsync(type, 0.31, D(2025, 5, 1));
|
|
await box.TypePriceAsync(type, 0.32, D(2025, 7, 1));
|
|
await box.MeterPriceAsync(meter, 0.25, D(2025, 3, 1));
|
|
await box.TypePriceAsync(other, 0.99, D(2025, 3, 1));
|
|
await using (var db = fx.CreateContext())
|
|
{
|
|
db.MeterEvents.Add(new MeterEvent { MeterId = meter, Time = Midnight(2025, 2, 10), EventType = MeterEventType.Note, Notes = "before" });
|
|
db.MeterEvents.Add(new MeterEvent { MeterId = meter, Time = Midnight(2025, 4, 10), EventType = MeterEventType.Note, Notes = "inside" });
|
|
db.MeterEvents.Add(new MeterEvent { MeterId = meter, Time = Midnight(2025, 5, 10), EventType = MeterEventType.Note, Notes = "later" });
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
var service = Service();
|
|
var markers = await service.GetMarkersAsync(
|
|
meter, new RecordRange(Midnight(2025, 3, 1), Midnight(2025, 7, 1)), D(2025, 3, 1), D(2025, 6, 30));
|
|
|
|
// Both lists newest first (A-42): the page draws them as one dated list.
|
|
Assert.Equal(["later", "inside"], markers.Events.Select(e => e.Notes));
|
|
Assert.False(markers.MoreEvents);
|
|
Assert.Equal([D(2025, 5, 1), D(2025, 3, 1)], markers.TariffChanges.Select(t => t.ValidFrom));
|
|
Assert.Equal((TariffScope.Meter, 0.25), (markers.TariffChanges[1].Scope, markers.TariffChanges[1].Value));
|
|
|
|
// The tariff list: the meter's own and its type's (and any global), never another type's — newest first
|
|
// inside each scope, so the price that applies now is at the top (A-42).
|
|
var tariffs = await service.GetTariffsAsync(meter);
|
|
Assert.Contains(tariffs, t => t.Scope == TariffScope.Meter && t.ScopeId == meter);
|
|
Assert.Equal(3, tariffs.Count(t => t.Scope == TariffScope.EnergyType && t.ScopeId == type));
|
|
Assert.DoesNotContain(tariffs, t => t.Scope == TariffScope.EnergyType && t.ScopeId == other);
|
|
Assert.Equal(
|
|
[D(2025, 7, 1), D(2025, 5, 1), D(2025, 1, 1)],
|
|
tariffs.Where(t => t.Scope == TariffScope.EnergyType && t.ScopeId == type).Select(t => t.ValidFrom));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_virtual_meters_calculation_names_its_sources_and_its_problems()
|
|
{
|
|
await using var box = new CostSandbox(fx);
|
|
var type = await box.TypeAsync();
|
|
var a = await box.MeterAsync(type, MeterMode.GenerationCounter, "kWh", name: "PV Ost");
|
|
var b = await box.MeterAsync(type, MeterMode.GenerationCounter, "kWh", name: "PV West");
|
|
var sum = await box.VirtualAsync(type, $"m{a} + m{b}", QuantityKind.Generation, "kWh", VirtualCostRule.None);
|
|
var broken = await box.VirtualAsync(type, $"m{a} + m{int.MaxValue}", QuantityKind.Generation, "kWh", VirtualCostRule.None);
|
|
var service = Service();
|
|
|
|
var calculation = await service.GetCalculationAsync(sum);
|
|
|
|
Assert.NotNull(calculation);
|
|
Assert.Equal(VirtualMeterStatus.Valid, calculation!.Status);
|
|
Assert.Equal($"m{a} + m{b}", calculation.Expression);
|
|
Assert.Equal((QuantityKind.Generation, "kWh"), (calculation.Kind, calculation.Unit));
|
|
Assert.Equal(["PV Ost", "PV West"], calculation.Sources.Select(s => s.Name));
|
|
Assert.All(calculation.Sources, s => Assert.True(s.Exists));
|
|
Assert.Equal("PV West", calculation.NameOf(b));
|
|
Assert.Empty(calculation.Problems);
|
|
|
|
var invalid = await service.GetCalculationAsync(broken);
|
|
Assert.Equal(VirtualMeterStatus.Invalid, invalid!.Status);
|
|
var problem = Assert.Single(invalid.Problems);
|
|
Assert.Equal(VirtualProblemKind.UnknownMeter, problem.Kind);
|
|
Assert.Equal([int.MaxValue], problem.MeterIds);
|
|
Assert.Contains(invalid.Sources, s => s.MeterId == int.MaxValue && !s.Exists);
|
|
|
|
// A physical meter has no calculation.
|
|
Assert.Null(await service.GetCalculationAsync(a));
|
|
}
|
|
|
|
private MeterDetailService Service() =>
|
|
new(fx, Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = BerlinId }));
|
|
|
|
private static Consumption Row(int meter, int hour, ConsumptionKind kind) => new()
|
|
{
|
|
MeterId = meter,
|
|
Time = Start.AddHours(hour),
|
|
Amount = 1,
|
|
Kind = kind,
|
|
Quality = ReadingQuality.Measured,
|
|
};
|
|
|
|
private async Task InsertReadingsAsync(int meter, int count, int fromHour = 0)
|
|
{
|
|
await using var db = fx.CreateContext();
|
|
for (var i = 0; i < count; i++)
|
|
{
|
|
db.Readings.Add(new Reading { MeterId = meter, Time = Start.AddHours(fromHour + i), Value = fromHour + i, Quality = ReadingQuality.Measured });
|
|
}
|
|
|
|
await db.SaveChangesAsync();
|
|
}
|
|
}
|