using System.Diagnostics;
using System.Globalization;
using System.Text;
using System.Text.Json;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Rollups;
using MeterVault.Core.Normalization;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Options;
using Npgsql;
using static MeterVault.Integration.Tests.Performance.PerfReport;
using Xunit.Abstractions;
namespace MeterVault.Integration.Tests.Performance;
///
/// Reader and cost timings on the synthetic 1,000-meter decade (brief §9.10, D-56): the brief's target (cached metadata
/// plus a ten-year monthly request for 100 meters within two seconds), portfolio, energy-type, meter, virtual and bill
/// requests, the refusals a 1,000-meter selection meets before any SQL, the SQL each request sends, and the plans of
/// the reader's main queries. Writes results-<label>.md (and .json) to the output directory.
///
///
/// Skipped unless METERVAULT_PERF=1 (see ). Run it alone, in Release, on a quiet machine:
/// dotnet test tests/Integration.Tests -c Release --filter "FullyQualifiedName~Performance.ReaderTimingTests".
/// The numbers are measured, not asserted; only correctness of the measured requests (and the zero-SQL refusals) is.
///
[Trait("Category", "Performance")]
public sealed class ReaderTimingTests(ITestOutputHelper output)
{
/// The brief's proposed review target for (a).
private const double TargetMilliseconds = 2000;
[PerfFact]
public async Task Reader_and_cost_timings_on_a_synthetic_1000_meter_decade()
{
var settings = PerfSettings.Current;
using var log = new PerfLog(settings, "reader", output);
var loadBefore = await HostLoadAsync(TimeSpan.FromSeconds(3));
log.Write($"Host load before: {loadBefore}");
await using var database = await PerfDatabase.OpenAsync(settings, log);
var prepared = await database.PrepareAsync(settings, log);
var manifest = prepared.Manifest;
var zone = TimeZoneInfo.FindSystemTimeZoneById(manifest.Zone);
var options = Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = manifest.Zone, Currency = "EUR" });
var reader = new AnalysisReader(database, options);
var costs = new CostReader(database, reader, options);
var now = manifest.Now;
var today = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(now, zone).DateTime);
var currentMonth = new DateOnly(today.Year, today.Month, 1);
var firstMonth = currentMonth.AddMonths(-119);
ResolvedPeriod Custom(DateOnly first, DateOnly last) => PeriodResolver.Resolve(PeriodPreset.Custom, first, last, now, zone);
ResolvedPeriod Preset(PeriodPreset preset) => PeriodResolver.Resolve(preset, null, null, now, zone);
var tenYears = Custom(firstMonth, currentMonth.AddMonths(1).AddDays(-1));
var lastYearByDay = Custom(today.AddDays(-364), today);
var last12 = Preset(PeriodPreset.Last12Months);
var selection = manifest.Selection100;
var all = manifest.AllMeterIds;
var electricity = manifest.EnergyTypes["electricity"];
var samples = manifest.Samples;
// The catalog is the metadata a page holds for its lifetime: loaded once, then every request reads data only.
var catalog = await reader.LoadCatalogAsync();
var invalid = catalog.Meters.Values.Where(m => m.IsVirtual && m.VirtualStatus != VirtualMeterStatus.Valid).Select(m => $"{m.Name}: {m.VirtualStatus}").ToList();
log.Write(invalid.Count == 0 ? "All virtual meters validate" : "Virtual meters that do not validate: " + string.Join("; ", invalid));
async Task Cached(AnalysisRequest request)
{
await using var db = database.CreateDbContext();
return await reader.ReadAsync(db, catalog, request, CancellationToken.None);
}
var scenarios = new List<(string Id, string Description, Func> Run)>
{
("a", "100 selected meters (60 monthly, 25 daily, 10 live, 5 virtual), 10 years by month, catalog cached — the brief's target",
async () => Describe(await Cached(new AnalysisRequest(AnalysisScope.ForMeters(selection), tenYears) { Bucket = BucketSize.Month, MaxSeries = selection.Length }))),
("a'", "Same through the public API (catalog loaded by the request)",
async () => Describe(await reader.ReadAsync(new AnalysisRequest(AnalysisScope.ForMeters(selection), tenYears) { Bucket = BucketSize.Month, MaxSeries = selection.Length }))),
("b", "Portfolio, last 12 months by month (measures only, as the overview)",
async () => Describe(await reader.ReadAsync(new AnalysisRequest(AnalysisScope.Portfolio, last12) { Bucket = BucketSize.Month }))),
("b'", "Portfolio, last 12 months by month, with one series per meter",
async () => Describe(await reader.ReadAsync(new AnalysisRequest(AnalysisScope.Portfolio, last12) { Bucket = BucketSize.Month, IncludeMeterSeries = true }))),
("b''", "Portfolio, last 12 months by month, compared with the previous year",
async () => Describe(await reader.ReadAsync(new AnalysisRequest(AnalysisScope.Portfolio, last12)
{
Bucket = BucketSize.Month,
Comparison = new ComparisonRequest(ComparisonKind.PreviousYear),
}))),
("c", "Electricity type (≈420 meters), 10 years by month (measures only)",
async () => Describe(await reader.ReadAsync(new AnalysisRequest(AnalysisScope.ForEnergyType(electricity), tenYears) { Bucket = BucketSize.Month }))),
("c'", "Electricity type, 10 years by month, with one series per meter (the type page's table)",
async () => Describe(await reader.ReadAsync(new AnalysisRequest(AnalysisScope.ForEnergyType(electricity), tenYears) { Bucket = BucketSize.Month, IncludeMeterSeries = true }))),
("d", "One daily meter, 10 years by week (point limit raised to 600)",
async () => Describe(await reader.ReadAsync(new AnalysisRequest(AnalysisScope.ForMeter(samples["dailyMeter"]), tenYears) { Bucket = BucketSize.Week, MaxPoints = 600 }))),
("d'", "One monthly meter, 10 years by week (point limit raised to 600)",
async () => Describe(await reader.ReadAsync(new AnalysisRequest(AnalysisScope.ForMeter(samples["monthlyMeter"]), tenYears) { Bucket = BucketSize.Week, MaxPoints = 600 }))),
("d''", "One daily meter, the last 365 days by day",
async () => Describe(await reader.ReadAsync(new AnalysisRequest(AnalysisScope.ForMeter(samples["dailyMeter"]), lastYearByDay) { Bucket = BucketSize.Day }))),
("d'''", "One live (hourly) meter, the last 365 days by day",
async () => Describe(await reader.ReadAsync(new AnalysisRequest(AnalysisScope.ForMeter(samples["liveMeter"]), lastYearByDay) { Bucket = BucketSize.Day }))),
("e", "Portfolio bill, last 12 months by month, with categories",
async () => Describe(await costs.ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, last12) { Bucket = BucketSize.Month, IncludeCategories = true }))),
("e'", "Portfolio bill, 10 years by month",
async () => Describe(await costs.ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, tenYears) { Bucket = BucketSize.Month }))),
("f", "Virtual meter nested three levels (over 15 monthly, 10 daily, 2 differenced sources), 10 years by month",
async () => Describe(await reader.ReadAsync(new AnalysisRequest(AnalysisScope.ForMeter(samples["virtualNested"]), tenYears) { Bucket = BucketSize.Month }))),
("f'", "Virtual difference of a 40-meter sum and an 8-meter live sum, 10 years by month",
async () => Describe(await reader.ReadAsync(new AnalysisRequest(AnalysisScope.ForMeter(samples["virtualBigDifference"]), tenYears) { Bucket = BucketSize.Month }))),
("g", "1,000-meter selection with the chart limit (6 series): refused",
async () => Describe(await reader.ReadAsync(new AnalysisRequest(AnalysisScope.ForMeters(all), last12) { Bucket = BucketSize.Month }))),
("g'", "Portfolio by day over 10 years: refused (point limit)",
async () => Describe(await reader.ReadAsync(new AnalysisRequest(AnalysisScope.Portfolio, tenYears) { Bucket = BucketSize.Day }))),
("g''", "Portfolio bill by day over 10 years: refused (point limit)",
async () => Describe(await costs.ReadAsync(new CostAnalysisRequest(CostScope.Portfolio, tenYears) { Bucket = BucketSize.Day }))),
("g'''", "Catalog load: 1,000 meters, tanks, links, rollup states; virtual definitions validated, totals classified",
async () => $"{(await reader.LoadCatalogAsync()).Meters.Count} meters"),
("g''''", "1,000-meter selection with the limit raised (as an export may), last 12 months by month",
async () => Describe(await reader.ReadAsync(new AnalysisRequest(AnalysisScope.ForMeters(all), last12) { Bucket = BucketSize.Month, MaxSeries = int.MaxValue }))),
};
var loadDuring = HostLoadAsync(TimeSpan.FromSeconds(3));
var timings = new List();
foreach (var (id, description, run) in scenarios)
{
timings.Add(await MeasureAsync(id, description, run, settings, log));
}
// What the measured requests must hold, whatever they took.
var target = timings.Single(t => t.Id == "a");
Assert.True(target.Commands > 0, "The command counter saw no SQL for a request that reads data; Npgsql tracing did not reach it.");
foreach (var refused in timings.Where(t => t.Id.StartsWith('g') && t.Shape.StartsWith("refused", StringComparison.Ordinal)))
{
if (refused.Commands != 0)
{
Assert.Fail($"{refused.Id} was refused but sent {refused.Commands} SQL command(s): {string.Join(" / ", refused.Statements)}");
}
}
Assert.StartsWith("refused", timings.Single(t => t.Id == "g").Shape, StringComparison.Ordinal);
Assert.StartsWith("refused", timings.Single(t => t.Id == "g'").Shape, StringComparison.Ordinal);
Assert.StartsWith("refused", timings.Single(t => t.Id == "g''").Shape, StringComparison.Ordinal);
Assert.Contains($"{selection.Length} series", target.Shape, StringComparison.Ordinal);
var recomputes = await RecomputeTimingsAsync(database, manifest, log);
var plans = await PlansAsync(database, catalog, manifest, zone, firstMonth, currentMonth, today, log);
await using var connection = new NpgsqlConnection(database.ConnectionString);
await connection.OpenAsync();
var markdown = await WriteReportAsync(
settings, database, connection, prepared, timings, recomputes, plans, invalid, loadBefore, await loadDuring, catalog.Meters.Count);
log.Write($"Results written to {markdown}");
}
// ------------------------------------------------------------------------------------------------ measuring
private static async Task MeasureAsync(string id, string description, Func> run, PerfSettings settings, PerfLog log)
{
for (var i = 0; i < settings.Warmup; i++)
{
await run();
}
// One counted run, untimed: the listener is only attached while counting.
int commands;
IReadOnlyList statements;
string shape;
double sql;
using (var counter = CommandCounter.Start())
{
shape = await run();
commands = counter.Count;
statements = counter.Statements;
sql = counter.Duration.TotalMilliseconds;
}
var times = new List();
for (var i = 0; i < settings.Runs; i++)
{
var watch = Stopwatch.StartNew();
await run();
times.Add(watch.Elapsed.TotalMilliseconds);
}
var timing = new ScenarioTiming(id, description, times, commands, statements, shape) { SqlMilliseconds = sql };
log.Write($"({id}) median {F(timing.Median)} ms, p95 {F(timing.P95)} ms, {commands} SQL taking {F(sql)} ms — {shape}");
return timing;
}
private static string Describe(AnalysisResult result)
{
if (result.Refusal != AnalysisRefusal.None)
{
return $"refused ({result.Refusal})";
}
var values = result.Series.Concat(result.Measures).SelectMany(s => s.Values).ToList();
var statuses = values.GroupBy(v => v.Status).OrderBy(g => g.Key).Select(g => string.Create(CultureInfo.InvariantCulture, $"{g.Key} {g.Count()}"));
return string.Create(
CultureInfo.InvariantCulture,
$"{result.Series.Count} series · {result.Measures.Count} measures · {result.Plan.Buckets.Count} {result.Plan.Size} buckets · {string.Join(", ", statuses)}");
}
private static string Describe(CostAnalysis bill)
{
if (bill.Refusal != CostRefusal.None)
{
return $"refused ({bill.Refusal})";
}
return string.Create(
CultureInfo.InvariantCulture,
$"{bill.Plan.Buckets.Count} {bill.Plan.Size} buckets · total {bill.Total.Cost ?? double.NaN:N0} {bill.Currency} ({bill.Total.Status}) · {bill.Lines.Count} lines · {bill.EnergyTypes.Count} types · categories {(bill.Composition is null ? "no" : "yes")}");
}
///
/// How long one meter's full recompute takes (D-57: it still runs per live reading): delete and rewrite its
/// consumption, diff its rollups and coverage — as IngestionService does after each reading.
///
private static async Task> RecomputeTimingsAsync(
PerfDatabase database, DatasetManifest manifest, PerfLog log)
{
var result = new List<(string, int, ScenarioTiming)>();
(string Name, int Id)[] meters =
[
("monthly counter", manifest.Samples["monthlyMeter"]),
("imported month labels", manifest.Samples["labelMeter"]),
("daily counter", manifest.Samples["dailyMeter"]),
("live meter (hourly for a year)", manifest.Samples["liveMeter"]),
("tank", manifest.Samples["tankMeter"]),
("virtual (purge + state only)", manifest.Samples["virtualNested"]),
];
await using var connection = new NpgsqlConnection(database.ConnectionString);
await connection.OpenAsync();
foreach (var (name, id) in meters)
{
var readings = Convert.ToInt32(
await PerfDatabase.ScalarAsync(connection, string.Create(CultureInfo.InvariantCulture, $"SELECT count(*) FROM reading WHERE meter_id = {id}")),
CultureInfo.InvariantCulture);
var times = new List();
for (var i = 0; i < 4; i++)
{
await using var db = database.CreateDbContext();
await using var tx = await db.Database.BeginTransactionAsync();
var watch = Stopwatch.StartNew();
await PerfDatabase.Normalization(db, manifest).RecomputeMeterAsync(id, batchId: null);
await db.SaveChangesAsync();
await tx.CommitAsync();
if (i > 0)
{
times.Add(watch.Elapsed.TotalMilliseconds);
}
}
var timing = new ScenarioTiming(name, name, times, 0, [], string.Empty);
log.Write($"Recompute {name} ({readings} readings): median {F(timing.Median)} ms");
result.Add((name, readings, timing));
}
return result;
}
// ------------------------------------------------------------------------------------------------ query plans
///
/// EXPLAIN (ANALYZE, BUFFERS) of the reader's queries (copied from AnalysisQueries) with the parameters request
/// (a) — and, for the portfolio and day cases, (b) and (d'') — would send.
///
private static async Task> PlansAsync(
PerfDatabase database, AnalysisCatalog catalog, DatasetManifest manifest, TimeZoneInfo zone,
DateOnly firstMonth, DateOnly currentMonth, DateOnly today, PerfLog log)
{
var plans = new List<(string, string, string)>();
await using var connection = new NpgsqlConnection(database.ConnectionString);
await connection.OpenAsync();
var leaves = catalog.PhysicalLeaves(manifest.Selection100).Order().ToArray();
var virtualSources = manifest.Selection100.Where(id => catalog.Find(id)?.IsVirtual == true)
.SelectMany(id => catalog.PhysicalLeaves([id])).ToHashSet();
var portfolio = catalog.Meters.Values.Where(m => !m.IsVirtual).Select(m => m.Id).Order().ToArray();
var now = manifest.Now.UtcDateTime;
DateTime Midnight(DateOnly day) => GapAttribution.LocalMidnight(day, zone).UtcDateTime;
const string month = """
SELECT r.meter_id, r.month, r.kind, r.amount, r.measured, r.manual, r.imported, r.estimated, r.rows, r.flags,
r.max_interval_end
FROM unnest(@ids, @froms, @tos) AS w(meter_id, from_month, to_month)
JOIN consumption_rollup_month r ON r.meter_id = w.meter_id AND r.month >= w.from_month AND r.month < w.to_month
""";
const string day = """
SELECT r.meter_id, r.day, r.kind, r.amount, r.measured, r.manual, r.imported, r.estimated, r.rows, r.flags,
r.max_interval_end, false AS after_now
FROM unnest(@ids, @froms, @tos) AS w(meter_id, from_day, to_day)
JOIN consumption_rollup r ON r.meter_id = w.meter_id AND r.day >= w.from_day AND r.day < w.to_day
UNION ALL
SELECT r.meter_id, r.day, r.kind, r.amount, r.measured, r.manual, r.imported, r.estimated, r.rows, r.flags,
r.max_interval_end, true AS after_now
FROM consumption_rollup r
WHERE r.meter_id = ANY(@after_ids) AND r.day >= @after_from AND r.day < @after_to AND r.max_interval_end > @now
""";
const string raw = "SELECT meter_id, time, amount, quality FROM consumption WHERE meter_id = ANY(@i0) AND time >= @f0 AND time < @t0";
const string windows = """
SELECT w.idx, count(*)::int, sum(c.amount),
coalesce(sum(c.amount) FILTER (WHERE c.quality = 0), 0),
coalesce(sum(c.amount) FILTER (WHERE c.quality = 2), 0),
coalesce(sum(c.amount) FILTER (WHERE c.quality = 3), 0),
coalesce(sum(c.amount) FILTER (WHERE c.quality NOT IN (0, 2, 3)), 0)
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
""";
// Not the reader's SQL any more (A-40): the same statement without the overall bounds, to show what plan-time
// chunk exclusion saves.
const string windowsUnbounded = """
SELECT w.idx, count(*)::int, sum(c.amount),
coalesce(sum(c.amount) FILTER (WHERE c.quality = 0), 0),
coalesce(sum(c.amount) FILTER (WHERE c.quality = 2), 0),
coalesce(sum(c.amount) FILTER (WHERE c.quality = 3), 0),
coalesce(sum(c.amount) FILTER (WHERE c.quality NOT IN (0, 2, 3)), 0)
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
""";
const string coverage = """
SELECT meter_id, span_from, span_to, resolution_class, divided_at_months, gap_reason, last_interval_start
FROM meter_coverage
WHERE meter_id = ANY(@ids)
ORDER BY meter_id, span_from
""";
const string opening = """
SELECT m.id, c.time
FROM unnest(@ids) AS m(id)
CROSS JOIN LATERAL (
SELECT r.flags FROM consumption_rollup r WHERE r.meter_id = m.id ORDER BY r.day LIMIT 1) f
CROSS JOIN LATERAL (
SELECT c.time FROM consumption c WHERE c.meter_id = m.id ORDER BY c.time LIMIT 1) c
WHERE (f.flags & @flag) <> 0
""";
const string recent = """
SELECT m.id, r.time
FROM unnest(@ids) AS m(id)
CROSS JOIN LATERAL (
SELECT time FROM reading WHERE meter_id = m.id AND time >= @since ORDER BY time DESC LIMIT @count) r
""";
// Not the reader's SQL any more (A-40): the same statement without the window bound, which is what made every
// non-quantities-only request plan across every raw chunk.
const string recentUnbounded = """
SELECT m.id, r.time
FROM unnest(@ids) AS m(id)
CROSS JOIN LATERAL (
SELECT time FROM reading WHERE meter_id = m.id ORDER BY time DESC LIMIT @count) r
""";
const string events = "SELECT meter_id, max(time) FROM meter_event WHERE meter_id = ANY(@ids) GROUP BY meter_id";
async Task PlanAsync(string title, string sql, params (string Name, object Value)[] parameters)
{
try
{
await using var command = new NpgsqlCommand("EXPLAIN (ANALYZE, BUFFERS, SETTINGS) " + sql, connection) { CommandTimeout = 0 };
foreach (var (name, value) in parameters)
{
command.Parameters.AddWithValue(name, value);
}
var lines = new List();
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
lines.Add(reader.GetString(0));
}
plans.Add((title, sql, string.Join('\n', lines)));
}
catch (PostgresException ex)
{
plans.Add((title, sql, "EXPLAIN failed: " + ex.MessageText));
}
}
var dayFroms = leaves.Select(id => virtualSources.Contains(id) ? firstMonth : currentMonth).ToArray();
var afterTo = currentMonth.AddMonths(1);
log.Write("Capturing query plans…");
await PlanAsync($"(a) month rollups — {leaves.Length} physical leaves × {firstMonth:yyyy-MM} … {currentMonth.AddMonths(-1):yyyy-MM}", month,
("ids", leaves), ("froms", leaves.Select(_ => firstMonth).ToArray()), ("tos", leaves.Select(_ => currentMonth).ToArray()));
await PlanAsync($"(a) day rollups — the current month's complete days for every leaf, every day for virtual sources ({virtualSources.Count}), plus the after-now block", day,
("ids", leaves), ("froms", dayFroms), ("tos", leaves.Select(_ => today).ToArray()),
("after_ids", leaves), ("after_from", today.AddDays(1)), ("after_to", afterTo), ("now", now));
await PlanAsync($"(a) partial edge day — today ({today:yyyy-MM-dd}) from consumption", raw,
("i0", leaves), ("f0", Midnight(today)), ("t0", Midnight(today.AddDays(1))));
await PlanAsync("(a) coverage runs", coverage, ("ids", leaves));
await PlanAsync("(a) opening balances", opening, ("ids", leaves), ("flag", (int)RollupFlags.OpeningBalance));
var since = now.AddDays(-FreshnessRules.RecentWindow.TotalDays);
var liveMeters = manifest.Groups["live"];
await PlanAsync(
$"(a) freshness — the rhythm of the live meters among the leaves, {FreshnessRules.RecentWindow.TotalDays:F0} days back (reading is compressed after 30 days)",
recent, ("ids", leaves.Where(liveMeters.Contains).ToArray()), ("since", since), ("count", FreshnessRules.RecentReadingCount));
await PlanAsync("(a) freshness — the latest event per meter", events, ("ids", leaves));
await PlanAsync($"(b) freshness — the rhythm of every live meter ({liveMeters.Length}), as a portfolio request loads it", recent,
("ids", liveMeters), ("since", since), ("count", FreshnessRules.RecentReadingCount));
await PlanAsync(
$"(comparison, not the reader's SQL) the same statement unbounded for every physical meter ({portfolio.Length}), as it was sent before A-40",
recentUnbounded, ("ids", portfolio), ("count", FreshnessRules.RecentReadingCount));
var daily = manifest.Samples["dailyMeter"];
await PlanAsync("(d) freshness — one live meter, as a single-meter request sends it", recent,
("ids", new[] { manifest.Samples["liveMeter"] }), ("since", since), ("count", FreshnessRules.RecentReadingCount));
await PlanAsync("(comparison, not the reader's SQL) the same for one daily meter, unbounded, as it was sent before A-40", recentUnbounded,
("ids", new[] { daily }), ("count", FreshnessRules.RecentReadingCount));
await PlanAsync($"(b) month rollups — portfolio, {portfolio.Length} physical meters × the 11 complete months of the last 12", month,
("ids", portfolio), ("froms", portfolio.Select(_ => currentMonth.AddMonths(-11)).ToArray()), ("tos", portfolio.Select(_ => currentMonth).ToArray()));
var yearAgo = today.AddYears(-1);
var nowLocal = TimeZoneInfo.ConvertTimeFromUtc(now, zone);
var yearAgoCut = TimeZoneInfo.ConvertTimeToUtc(yearAgo.ToDateTime(TimeOnly.FromDateTime(nowLocal)), zone);
var single = manifest.Samples["monthlyMeter"];
await PlanAsync("(b'') window sums — one meter's two partial days (today, and its image a year ago): the small case", windows,
("ids", new[] { single, single }),
("froms", new[] { Midnight(today), Midnight(yearAgo) }),
("tos", new[] { now, yearAgoCut }),
("min_from", Midnight(yearAgo)), ("max_to", now));
var stressIds = portfolio.Concat(portfolio).ToArray();
var stressFroms = portfolio.Select(_ => Midnight(today)).Concat(portfolio.Select(_ => Midnight(yearAgo))).ToArray();
var stressTos = portfolio.Select(_ => now).Concat(portfolio.Select(_ => yearAgoCut)).ToArray();
await PlanAsync(
$"(b'') window sums — stress case, not what (b'') sends (see its SQL list): today's partial day and its image a year ago for all {portfolio.Length} meters",
windows,
("ids", stressIds), ("froms", stressFroms), ("tos", stressTos),
("min_from", stressFroms.Min()), ("max_to", stressTos.Max()));
await PlanAsync(
"(comparison, not the reader's SQL) the same stress case without the overall bounds, as it was sent before A-40",
windowsUnbounded, ("ids", stressIds), ("froms", stressFroms), ("tos", stressTos));
await PlanAsync("(d'') day rollups — one daily meter, the last 365 days", day,
("ids", new[] { daily }), ("froms", new[] { today.AddDays(-364) }), ("tos", new[] { today }),
("after_ids", new[] { daily }), ("after_from", today.AddDays(1)), ("after_to", today.AddDays(1)), ("now", now));
return plans;
}
// ------------------------------------------------------------------------------------------------ report
private static async Task WriteReportAsync(
PerfSettings settings,
PerfDatabase database,
NpgsqlConnection connection,
PreparedDatabase prepared,
List timings,
List<(string Meter, int Readings, ScenarioTiming Timing)> recomputes,
List<(string Title, string Sql, string Plan)> plans,
List invalidVirtuals,
string loadBefore,
string loadDuring,
int catalogMeters)
{
var manifest = prepared.Manifest;
var md = new StringBuilder();
md.AppendLine(CultureInfo.InvariantCulture, $"# Analysis reader performance — {settings.Label}");
md.AppendLine();
md.AppendLine(CultureInfo.InvariantCulture, $"Measured {DateTimeOffset.Now:yyyy-MM-dd HH:mm zzz}. Brief §9.10 / D-56. Dataset \"now\": {manifest.Now:O} ({manifest.Zone}).");
md.AppendLine(CultureInfo.InvariantCulture, $"{settings.Warmup} warm-up run(s), then {settings.Runs} measured runs per scenario; the SQL count comes from one extra untimed run.");
if (settings.Note is { } note)
{
md.AppendLine().AppendLine(CultureInfo.InvariantCulture, $"> Note: {note}");
}
md.AppendLine();
md.AppendLine("## Machine");
md.AppendLine();
var machine = Machine().Select(m => (IReadOnlyList)[m.Name, m.Value]).ToList();
machine.Add(["Host load before the run", loadBefore]);
machine.Add(["Host load while measuring", loadDuring]);
machine.Add(["Database", database.Origin]);
foreach (var (name, value) in await DatabaseAsync(connection))
{
machine.Add([name, value]);
}
Table(md, ["", "Value"], machine);
md.AppendLine("## Dataset");
md.AppendLine();
md.AppendLine(CultureInfo.InvariantCulture,
$"{manifest.MeterCount} meters (scale {F(manifest.Scale, 2)}): {string.Join(", ", manifest.Groups.Select(g => $"{g.Value.Length} {g.Key}"))}. " +
$"{manifest.EnergyTypes.Count} energy types, {manifest.Links} links, {manifest.Tariffs} tariff rows, {manifest.CategoryMembers} category members, {manifest.ManualCosts} manual costs, {N(manifest.Events)} events.");
md.AppendLine();
md.AppendLine("Monthly counters mix readings at local midnight on the 1st, imported month labels (00:00 UTC, flagged) and irregular manual readings " +
"(divided at month ends); some have no install date (opening balance), start late, retire in 2022, or have a register swap. Daily counters read at 06:xx, " +
"live meters hourly for the last year (monthly before), generation counters in the evening; the electricity type and two extra sites have grid import/export roles. " +
"Twenty virtual meters are sums and differences, nested up to three levels. The cost setup has yearly unit-price changes (a mid-2022 spike for electricity and gas), " +
"standing charges, feed-in, five meter prices on linked subsections, four categories (one overlapping) and manual costs.");
md.AppendLine();
Table(md, ["Table", "Rows", "Size", "Chunks"], (await TablesAsync(connection)).Select(t => (IReadOnlyList)[t.Table, N(t.Rows), t.Size, t.Note]));
md.AppendLine("## Loading and rebuild");
md.AppendLine();
var steps = new List>();
steps.Add(prepared.Loaded
? ["Raw load (binary COPY of readings, plus configuration)", F(manifest.LoadSeconds) + " s", $"{N(manifest.Readings)} readings; COPY itself {F(manifest.CopySeconds)} s"]
: ["Raw load", "reused", $"{N(manifest.Readings)} readings loaded earlier (COPY {F(manifest.CopySeconds)} s, total {F(manifest.LoadSeconds)} s)"]);
if (prepared.Rebuild is { } rebuild)
{
steps.Add([
"Startup rebuild (NormalizationUpgrade: consumption, rollups, coverage, state)",
F(rebuild.Seconds) + " s",
$"{rebuild.Rebuilt} of {rebuild.Meters} meters, {F(rebuild.Meters / rebuild.Seconds)} meters/s{(rebuild.MeasuredInThisRun ? string.Empty : " (measured when the dataset was loaded)")}",
]);
}
steps.Add(prepared.CompressSeconds is { } compress
? ["Compression of raw chunks older than 30 days", F(compress) + " s", $"{prepared.CompressedChunks} chunks"]
: ["Compression of raw chunks older than 30 days", "—", "already compressed"]);
Table(md, ["Step", "Time", "Detail"], steps);
md.AppendLine("One meter's full recompute (what every ingested reading triggers, D-57), median of 3 after a warm-up:");
md.AppendLine();
Table(md, ["Meter", "Readings", "Median ms", "Min ms", "Max ms"],
recomputes.Select(r => (IReadOnlyList)[r.Meter, N(r.Readings), F(r.Timing.Median), F(r.Timing.Min), F(r.Timing.Max)]));
md.AppendLine("## Reader and cost timings");
md.AppendLine();
md.AppendLine(CultureInfo.InvariantCulture, $"Catalog: {catalogMeters} meters. {(invalidVirtuals.Count == 0 ? "All virtual meters validate." : "Virtual meters that do not validate: " + string.Join("; ", invalidVirtuals))}");
md.AppendLine();
md.AppendLine("*SQL* is the number of commands one extra, untimed run sent, and *SQL ms* their summed duration (Npgsql activity: execution " +
"until the reader is closed, so row materialization is included); the rest of the median is in-process work.");
md.AppendLine();
Table(md, ["", "Request", "Median ms", "p95 ms", "Min ms", "Max ms", "SQL", "SQL ms", "Result"],
timings.Select(t => (IReadOnlyList)[
t.Id, t.Description, F(t.Median), F(t.P95), F(t.Min), F(t.Max), t.Commands.ToString(CultureInfo.InvariantCulture), F(t.SqlMilliseconds), t.Shape]));
var target = timings.Single(t => t.Id == "a");
md.AppendLine(CultureInfo.InvariantCulture,
$"**Target (a):** median {F(target.Median)} ms, p95 {F(target.P95)} ms against {F(TargetMilliseconds, 0)} ms — {(target.P95 < TargetMilliseconds ? "met" : target.Median < TargetMilliseconds ? "met at the median, not at p95" : "not met")}.");
md.AppendLine();
md.AppendLine("**Limits:** the refused requests (g, g', g'') sent " +
string.Join(", ", timings.Where(t => t.Shape.StartsWith("refused", StringComparison.Ordinal)).Select(t => $"{t.Id}: {t.Commands}")) +
" SQL commands — the limits are checked before any SQL runs.");
md.AppendLine();
md.AppendLine("### SQL sent per request");
md.AppendLine();
foreach (var timing in timings.Where(t => t.Commands > 0))
{
md.AppendLine(CultureInfo.InvariantCulture, $"({timing.Id}) {timing.Commands} commands, {F(timing.SqlMilliseconds)} ms
");
md.AppendLine();
md.AppendLine("```sql");
foreach (var statement in timing.Statements)
{
md.AppendLine(statement);
}
md.AppendLine("```");
md.AppendLine(" ");
md.AppendLine();
}
md.AppendLine("## Query plans");
md.AppendLine();
md.AppendLine("EXPLAIN (ANALYZE, BUFFERS, SETTINGS) of the reader's statements (copied from `AnalysisQueries`) with the parameters of the request named.");
md.AppendLine();
foreach (var (title, sql, plan) in plans)
{
md.AppendLine(CultureInfo.InvariantCulture, $"### {title}");
md.AppendLine();
md.AppendLine("```sql").AppendLine(sql.Trim()).AppendLine("```");
md.AppendLine("```").AppendLine(plan).AppendLine("```");
md.AppendLine();
}
Directory.CreateDirectory(settings.OutputDirectory);
var path = Path.Combine(settings.OutputDirectory, $"results-{settings.Label}.md");
await File.WriteAllTextAsync(path, md.ToString());
await File.WriteAllTextAsync(
Path.Combine(settings.OutputDirectory, $"results-{settings.Label}.json"),
JsonSerializer.Serialize(
new { settings.Label, manifest.Now, prepared.Rebuild, Timings = timings.Select(t => new { t.Id, t.Description, t.Median, t.P95, t.Min, t.Max, t.Commands, t.SqlMilliseconds, t.Shape, t.Milliseconds }) },
DatasetManifest.Json));
return path;
}
}