using MeterVault.Core.Domain; using MeterVault.Core.Normalization; using MeterVault.Infrastructure.Normalization; using MeterVault.Infrastructure.Options; using MeterVault.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using Npgsql; namespace MeterVault.Integration.Tests.Analysis; /// /// The window-sum statement of the reader (D-15, A-40). Its windows arrive through an unnest join, so their /// bounds are columns: without the overall [min from, max to) repeated as constants, PostgreSQL has nothing to /// exclude chunks by and plans — and, with enough windows, scans — the whole consumption hypertable. The bounds /// are pure arithmetic over the window set, so they may never change a tally; this pins both halves of that. /// [Collection("Timescale")] public sealed class WindowSumPlanTests(TimescaleFixture fx) : IAsyncLifetime { private const string BerlinId = "Europe/Berlin"; private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById(BerlinId); /// The reader's statement (AnalysisQueries.WindowSumsAsync). private const string Bounded = """ SELECT w.idx, count(*)::int, sum(c.amount) FROM unnest(@ids, @froms, @tos) WITH ORDINALITY AS w(meter_id, from_time, to_time, idx) JOIN consumption c ON c.meter_id = w.meter_id AND c.time >= w.from_time AND c.time < w.to_time WHERE c.time >= @min_from AND c.time < @max_to GROUP BY w.idx """; /// The same without the overall bounds, as it was sent before A-40. private const string Unbounded = """ SELECT w.idx, count(*)::int, sum(c.amount) FROM unnest(@ids, @froms, @tos) WITH ORDINALITY AS w(meter_id, from_time, to_time, idx) JOIN consumption c ON c.meter_id = w.meter_id AND c.time >= w.from_time AND c.time < w.to_time GROUP BY w.idx """; private int _meter; private short _type; public async Task InitializeAsync() { await using var db = fx.CreateContext(); var type = new EnergyType { Key = $"windows-{Guid.NewGuid():N}", DisplayName = "Window sums", BaseUnit = "kWh", DefaultMode = MeterMode.CumulativeCounter, }; db.EnergyTypes.Add(type); await db.SaveChangesAsync(); _type = type.Id; var meter = new Meter { Name = $"windows-{Guid.NewGuid():N}", EnergyTypeId = _type, Mode = MeterMode.CumulativeCounter, Unit = "kWh", InstalledAt = new DateOnly(2020, 1, 1), }; db.Meters.Add(meter); await db.SaveChangesAsync(); _meter = meter.Id; // Three years of daily readings: `consumption` is chunked by 90 days, so the meter's own rows alone spread // over a dozen chunks — enough for plan-time exclusion to be visible. var register = 0d; for (var day = new DateOnly(2020, 1, 2); day <= new DateOnly(2022, 12, 31); day = day.AddDays(1)) { register += 1.5; db.Readings.Add(new Reading { MeterId = _meter, Time = GapAttribution.LocalMidnight(day, Berlin).AddHours(6), Value = register, Quality = ReadingQuality.Measured, }); } await db.SaveChangesAsync(); await Normalization(db).RecomputeMeterAsync(_meter, null); await db.SaveChangesAsync(); } public async Task DisposeAsync() { await using var db = fx.CreateContext(); await db.Consumption.Where(c => c.MeterId == _meter).ExecuteDeleteAsync(); await db.Readings.Where(r => r.MeterId == _meter).ExecuteDeleteAsync(); await db.Meters.Where(m => m.Id == _meter).ExecuteDeleteAsync(); await db.EnergyTypes.Where(t => t.Id == _type).ExecuteDeleteAsync(); } [Fact] public async Task The_overall_bounds_exclude_chunks_and_change_no_tally() { await using var connection = new NpgsqlConnection(fx.ConnectionString); await connection.OpenAsync(); // Two windows, both inside the last three months of the meter's history. var from = GapAttribution.LocalMidnight(new DateOnly(2022, 12, 20), Berlin); var ids = new[] { _meter, _meter }; var froms = new[] { from, from.AddDays(3) }; var tos = new[] { from.AddDays(1), from.AddDays(4) }; var bounded = await TalliesAsync(connection, Bounded, ids, froms, tos, bounds: true); var unbounded = await TalliesAsync(connection, Unbounded, ids, froms, tos, bounds: false); Assert.Equal(2, bounded.Count); Assert.Equal(unbounded, bounded); var boundedChunks = await ChunksAsync(connection, Bounded, ids, froms, tos, bounds: true); var unboundedChunks = await ChunksAsync(connection, Unbounded, ids, froms, tos, bounds: false); // The old statement has to keep every chunk of the table in its plan; the bounded one keeps the few the // windows can fall into. A table with a single chunk would make this vacuous, so the shape is asserted too. Assert.True(unboundedChunks > 4, $"expected a chunked consumption table, saw {unboundedChunks} chunks in the plan"); Assert.True( boundedChunks < unboundedChunks, $"the bounded statement planned {boundedChunks} chunks, the unbounded one {unboundedChunks}"); Assert.True(boundedChunks <= 2, $"two windows three days apart should reach at most two chunks, not {boundedChunks}"); } private static async Task> TalliesAsync( NpgsqlConnection connection, string sql, int[] ids, DateTimeOffset[] froms, DateTimeOffset[] tos, bool bounds) { await using var command = Prepare(connection, sql, ids, froms, tos, bounds); var rows = new List<(long, int, double)>(); await using var reader = await command.ExecuteReaderAsync(); while (await reader.ReadAsync()) { rows.Add((reader.GetInt64(0), reader.GetInt32(1), reader.GetDouble(2))); } rows.Sort(); return rows; } /// How many hypertable chunks the plan of mentions. private static async Task ChunksAsync( NpgsqlConnection connection, string sql, int[] ids, DateTimeOffset[] froms, DateTimeOffset[] tos, bool bounds) { await using var command = Prepare(connection, "EXPLAIN " + sql, ids, froms, tos, bounds); var chunks = new HashSet(StringComparer.Ordinal); await using var reader = await command.ExecuteReaderAsync(); while (await reader.ReadAsync()) { foreach (var word in reader.GetString(0).Split([' ', '(', ')', ','], StringSplitOptions.RemoveEmptyEntries)) { if (word.StartsWith("_hyper_", StringComparison.Ordinal)) { chunks.Add(word); } } } return chunks.Count; } private static NpgsqlCommand Prepare( NpgsqlConnection connection, string sql, int[] ids, DateTimeOffset[] froms, DateTimeOffset[] tos, bool bounds) { var command = new NpgsqlCommand(sql, connection); command.Parameters.AddWithValue("ids", ids); command.Parameters.AddWithValue("froms", froms); command.Parameters.AddWithValue("tos", tos); if (bounds) { command.Parameters.AddWithValue("min_from", froms.Min()); command.Parameters.AddWithValue("max_to", tos.Max()); } return command; } private static NormalizationService Normalization(MeterVaultDbContext db) => new(db, NormalizationEngine.CreateDefault(), Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = BerlinId }), TimeProvider.System); }