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