ci / build-test (push) Successful in 2m31s
The dashboards told several stories at once. Overview asked for full calendar years, meter detail for a fixed 12-month window that was really 13, Trends for 24 months with an Apply button, and the energy pages for 60. Each page derived "today" from UTC, so the first hours of a local day belonged to yesterday. A missing tariff, a month nobody measured and a genuine zero all rendered as 0. And a virtual meter -- the one thing the spreadsheet leans on hardest -- was excluded from analysis outright: MeterPeriodService returned null for it and the page offered a flow diagram instead. docs/DASHBOARD_ANALYSIS_CHANGE_BRIEF.md is the work order. Every choice it left open is settled in docs/ANALYSIS_IMPLEMENTATION_NOTE.md as D-01..D-58 plus amendments A-01..A-30; code, tests and release notes cite those ids. The analysis layer Core/Analysis holds the pure rules: period presets resolved once in the instance zone into a local date range and a half-open UTC range, bucket plans, calendar-unit comparisons, coverage runs with a resolution class, normalized quantities and units, the totals policy, the virtual formula parser/validator/evaluator, and the cost calculator. "Now" comes from TimeProvider; services never read the clock. Normalization now writes, in the same transaction as consumption and by diff, per-meter rollups by local day and month plus coverage runs and a rollup state (AnalysisDataWriter). AnalysisReader answers a request from those tables -- month rollups for month and year buckets, day rollups otherwise, at most two partial edge days from consumption -- and CostReader prices the result month by month. Pages, /api/v1 and the CSV export read nothing else. The unused continuous aggregates are dropped. The reader's statement count per request is constant whether it covers one meter or a thousand. On a synthetic 1,000-meter, ten-year instance the brief's target request (100 meters, ten years, monthly) takes 374 ms against a two-second target, and the Overview went from 48,244 SQL statements per load to 205. Missing is not zero Every bucket carries a status -- available, partial, missing, unresolved, invalid, pending -- derived from coverage, never from the amount, with provenance and a reason code beside it. A true zero is a number and a bar on the baseline; an unknown bucket is a gap that says why; a month whose data only exists monthly says so instead of inventing daily detail; a scope with no tariff says "not priced" instead of 0. Rows whose interval closes after now are reported separately rather than counted. Virtual meters are analysis subjects A virtual meter stores a canonical definition -- expression over m<id> references, result kind, unit and cost rule -- validated on save and on read for syntax, unknown or self references, loops and unit/kind rules. It is evaluated on read from its sources' rollups over their joint coverage: a missing source makes the bucket missing, an observed zero is a valid input, a non-finite result is invalid with its dependency path, and the page lists each source's contribution. Topology links are topology only and never rewrite a saved calculation; expression-less meters from older installs are converted once at startup. The editor has Sum, Difference and Advanced modes with a live preview. Totals and the bill Per energy type the totals policy separates use, grid import, export, generation and runtime, marks breakdown meters as breakdowns and virtual meters as views, and never adds across units. The bill follows it: grid import where there is one, separately priced subsections at their own price, feed-in only on export meters, standing charges once per scope per local day, manual costs once on their start day, categories as non-overlapping covers whose composition reconciles to the bill. The seeded demo's yearly totals now match the spreadsheet. Pages and navigation The period lives in the URL and every page reads the same contract, so a link, a reload and the browser's Back button keep it. Shared components carry it: page header with breadcrumbs, period toolbar, theme-aware chart with an accessible table beside it, metric cards, comparison and availability states, attention items that each link to the one action that fixes them. Meter detail leads with an Analysis tab and resolves its tabs by key; the energy page has Overview, History, Flow and Meters; the old cost-only Trends page is a general Analysis page over portfolio, type, category, meter or a meter comparison. Records tabs are paged server-side instead of showing the latest 200. Everything is English and German, light and dark, down to 360px. Some figures change on purpose; docs/RELEASE_NOTES.md lists each one and what the first start after the update does (it rebuilds all analysis data before the web server listens). docs/SDD.md and CLAUDE.md describe the system as it now is. Tests: 1,733 Core and 746 integration, all green, plus an opt-in performance suite with a synthetic 1,000-meter generator.
1031 lines
51 KiB
C#
1031 lines
51 KiB
C#
using System.Diagnostics;
|
||
using System.Globalization;
|
||
using System.Text.Json;
|
||
using MeterVault.Core.Analysis;
|
||
using MeterVault.Core.Analysis.Virtual;
|
||
using MeterVault.Core.Domain;
|
||
using MeterVault.Core.Normalization;
|
||
using Npgsql;
|
||
using NpgsqlTypes;
|
||
|
||
namespace MeterVault.Integration.Tests.Performance;
|
||
|
||
/// <summary>How a synthetic meter's raw data looks.</summary>
|
||
internal enum MeterShape
|
||
{
|
||
/// <summary>One manual reading at local midnight on the 1st, closing the month before.</summary>
|
||
MonthlyMidnight,
|
||
|
||
/// <summary>An imported month-table row ("Mai 2026"): 00:00 UTC on the 1st, flagged as a month label (end of that month).</summary>
|
||
MonthlyLabel,
|
||
|
||
/// <summary>One manual reading a month on a varying day and hour; its intervals are divided at local month ends.</summary>
|
||
MonthlyIrregular,
|
||
|
||
/// <summary>One reading a day in the early morning (or the evening, for generation).</summary>
|
||
Daily,
|
||
|
||
/// <summary>Monthly readings for nine years, then hourly measured readings for the last year up to now.</summary>
|
||
LiveHourly,
|
||
|
||
/// <summary>A tank: monthly dipstick levels and deliveries (events only, no readings).</summary>
|
||
Tank,
|
||
|
||
/// <summary>A virtual meter: a formula over other meters, evaluated on read.</summary>
|
||
Virtual,
|
||
}
|
||
|
||
/// <summary>One synthetic meter as planned, before it has an id.</summary>
|
||
internal sealed record MeterPlan(
|
||
string Name,
|
||
string Group,
|
||
string TypeKey,
|
||
MeterMode Mode,
|
||
string Unit,
|
||
MeterShape Shape,
|
||
double MonthlyMean,
|
||
DateOnly? InstalledAt,
|
||
DateOnly Start,
|
||
DateOnly? RetiredAt,
|
||
string? Role,
|
||
bool Swap,
|
||
ulong Seed)
|
||
{
|
||
public int Id { get; set; }
|
||
|
||
/// <summary>Data from the first month to now, no swap, no opening balance: the sources a clean formula reads.</summary>
|
||
public bool FullHistory(DateOnly first) =>
|
||
Shape == MeterShape.MonthlyMidnight && InstalledAt is not null && Start == first.AddMonths(-1) && RetiredAt is null && !Swap && Role is null;
|
||
}
|
||
|
||
/// <summary>What was loaded, with the ids the measurements and page timings address.</summary>
|
||
internal sealed record DatasetManifest(
|
||
int Version,
|
||
DateTimeOffset Now,
|
||
string Zone,
|
||
double Scale,
|
||
Dictionary<string, int> EnergyTypes,
|
||
Dictionary<string, int[]> Groups,
|
||
Dictionary<string, int> Samples,
|
||
int[] Selection100,
|
||
long Readings,
|
||
long Events,
|
||
int Tariffs,
|
||
int ManualCosts,
|
||
int Links,
|
||
int CategoryMembers,
|
||
double CopySeconds,
|
||
double LoadSeconds)
|
||
{
|
||
public const int CurrentVersion = 1;
|
||
|
||
public static readonly JsonSerializerOptions Json = new() { WriteIndented = true };
|
||
|
||
public int MeterCount => Groups.Values.Sum(g => g.Length);
|
||
|
||
public int[] AllMeterIds => [.. Groups.Values.SelectMany(g => g).Order()];
|
||
}
|
||
|
||
/// <summary>
|
||
/// A deterministic synthetic portfolio for the performance measurement (brief §9.10, D-56): 1,000 meters over ten
|
||
/// years across seven energy types — mostly monthly counters (midnight readings, imported month labels, irregular
|
||
/// manual readings), a few hundred daily counters, forty live meters read hourly for the last year, generation counters
|
||
/// with grid import/export roles, two tanks, twenty virtual meters (sums, differences, three levels of nesting), linked
|
||
/// subsections, tariffs with yearly price changes, cost categories and manual costs.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Only raw data is written — readings (Npgsql binary COPY), events and configuration — through plain SQL against
|
||
/// columns that are identical in the schema before the analysis rework (c0f52db) and after it, so the same dataset can
|
||
/// be loaded into a database either app migrated. Consumption, rollups and coverage are then built by the real
|
||
/// pipeline (the startup upgrade). The same seed and "now" always give the same data.
|
||
/// </remarks>
|
||
internal sealed class SyntheticDataset
|
||
{
|
||
/// <summary>The <c>app_setting</c> key marking a loaded dataset; its value is the manifest.</summary>
|
||
public const string MarkerKey = "perf_dataset";
|
||
|
||
private const ulong Seed = 20260919;
|
||
|
||
private static readonly (string Key, string Name, string Unit, MeterMode Mode)[] Types =
|
||
[
|
||
("electricity", "Strom", "kWh", MeterMode.CumulativeCounter),
|
||
("water", "Wasser", "m3", MeterMode.CumulativeCounter),
|
||
("heating_oil", "Heizöl", "L", MeterMode.ConsumableBalance),
|
||
("gas", "Gas", "kWh", MeterMode.CumulativeCounter),
|
||
("district_heat", "Fernwärme", "kWh", MeterMode.CumulativeCounter),
|
||
("perf_site_b", "Electricity site B", "kWh", MeterMode.CumulativeCounter),
|
||
("perf_site_c", "Electricity site C", "kWh", MeterMode.CumulativeCounter),
|
||
];
|
||
|
||
private readonly DateTimeOffset _now;
|
||
private readonly TimeZoneInfo _zone;
|
||
private readonly double _scale;
|
||
private readonly List<MeterPlan> _physical = [];
|
||
private readonly Dictionary<int, List<SwapEvent>> _swaps = [];
|
||
|
||
private SyntheticDataset(DateTimeOffset now, TimeZoneInfo zone, double scale)
|
||
{
|
||
_now = now.ToUniversalTime();
|
||
_zone = zone;
|
||
_scale = scale;
|
||
Today = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(_now, zone).DateTime);
|
||
CurrentMonth = new DateOnly(Today.Year, Today.Month, 1);
|
||
First = CurrentMonth.AddMonths(-119);
|
||
Plan();
|
||
}
|
||
|
||
/// <summary>The local day of "now".</summary>
|
||
public DateOnly Today { get; }
|
||
|
||
/// <summary>The 1st of the current local month.</summary>
|
||
public DateOnly CurrentMonth { get; }
|
||
|
||
/// <summary>The first of the 120 months the data covers (plus the month before it, which the first reading closes).</summary>
|
||
public DateOnly First { get; }
|
||
|
||
public IReadOnlyList<MeterPlan> Physical => _physical;
|
||
|
||
/// <summary>
|
||
/// Loads the dataset into a migrated database (either schema) in one transaction, marks it, and clears the stored
|
||
/// normalization revision so the next app start (or <c>NormalizationUpgrade</c>) rebuilds every meter.
|
||
/// </summary>
|
||
public static async Task<DatasetManifest> LoadAsync(
|
||
string connectionString, DateTimeOffset now, TimeZoneInfo zone, double scale, Action<string> log, CancellationToken cancellationToken = default)
|
||
{
|
||
var dataset = new SyntheticDataset(now, zone, scale);
|
||
return await dataset.WriteAsync(connectionString, log, cancellationToken);
|
||
}
|
||
|
||
/// <summary>The manifest of a database that already holds the dataset, or null.</summary>
|
||
public static async Task<DatasetManifest?> ReadMarkerAsync(NpgsqlConnection connection, CancellationToken cancellationToken = default)
|
||
{
|
||
await using var command = new NpgsqlCommand("SELECT value::text FROM app_setting WHERE key = @key", connection);
|
||
command.Parameters.AddWithValue("key", MarkerKey);
|
||
return await command.ExecuteScalarAsync(cancellationToken) is string json
|
||
? JsonSerializer.Deserialize<DatasetManifest>(json, DatasetManifest.Json)
|
||
: null;
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------ plan
|
||
|
||
private int Count(int full) => Math.Max(1, (int)Math.Round(full * _scale));
|
||
|
||
private void Plan()
|
||
{
|
||
// Monthly counters (692 at full scale).
|
||
AddMonthly("electricity", "kWh", Count(252), 80, 600);
|
||
AddMonthly("water", "m3", Count(190), 3, 25);
|
||
AddMonthly("gas", "kWh", Count(130), 300, 2500);
|
||
AddMonthly("district_heat", "kWh", Count(90), 200, 1500);
|
||
AddMonthly("perf_site_b", "kWh", Count(20), 80, 400);
|
||
AddMonthly("perf_site_c", "kWh", Count(10), 80, 400);
|
||
|
||
// Daily counters (230).
|
||
AddSeries("daily", "electricity", "kWh", MeterMode.CumulativeCounter, MeterShape.Daily, Count(100), 150, 900);
|
||
AddSeries("daily", "water", "m3", MeterMode.CumulativeCounter, MeterShape.Daily, Count(60), 4, 30);
|
||
AddSeries("daily", "gas", "kWh", MeterMode.CumulativeCounter, MeterShape.Daily, Count(40), 400, 2500);
|
||
AddSeries("daily", "district_heat", "kWh", MeterMode.CumulativeCounter, MeterShape.Daily, Count(30), 300, 1800);
|
||
|
||
// Live meters (40): hourly for the last year. The first two hold the electricity grid roles.
|
||
var live = Count(30);
|
||
for (var i = 0; i < live; i++)
|
||
{
|
||
var role = i switch { 0 => MeterRoles.GridImport, 1 => MeterRoles.GridExport, _ => null };
|
||
Add("live", "electricity", "kWh", MeterMode.CumulativeCounter, MeterShape.LiveHourly, i, role == MeterRoles.GridExport ? 250 : 300, 1200, role: role);
|
||
}
|
||
|
||
AddSeries("live", "gas", "kWh", MeterMode.CumulativeCounter, MeterShape.LiveHourly, Count(5), 500, 2500);
|
||
AddSeries("live", "water", "m3", MeterMode.CumulativeCounter, MeterShape.LiveHourly, Count(5), 5, 30);
|
||
|
||
// Generation (12) and the grid roles of the two extra sites (4).
|
||
AddSeries("generation", "electricity", "kWh", MeterMode.GenerationCounter, MeterShape.Daily, Count(6), 4, 15);
|
||
AddSeries("generation", "perf_site_b", "kWh", MeterMode.GenerationCounter, MeterShape.Daily, Count(4), 4, 15);
|
||
AddSeries("generation", "perf_site_c", "kWh", MeterMode.GenerationCounter, MeterShape.MonthlyMidnight, Count(2), 4, 15);
|
||
Add("roles", "perf_site_b", "kWh", MeterMode.CumulativeCounter, MeterShape.Daily, 0, 800, 1500, role: MeterRoles.GridImport);
|
||
Add("roles", "perf_site_b", "kWh", MeterMode.CumulativeCounter, MeterShape.Daily, 1, 200, 400, role: MeterRoles.GridExport);
|
||
Add("roles", "perf_site_c", "kWh", MeterMode.CumulativeCounter, MeterShape.MonthlyMidnight, 2, 800, 1500, role: MeterRoles.GridImport);
|
||
Add("roles", "perf_site_c", "kWh", MeterMode.CumulativeCounter, MeterShape.MonthlyMidnight, 3, 200, 400, role: MeterRoles.GridExport);
|
||
|
||
// Tanks (2).
|
||
AddSeries("tanks", "heating_oil", "L", MeterMode.ConsumableBalance, MeterShape.Tank, 2, 250, 450);
|
||
}
|
||
|
||
private void AddMonthly(string typeKey, string unit, int count, double min, double max)
|
||
{
|
||
for (var i = 0; i < count; i++)
|
||
{
|
||
var shape = (i % 10) switch
|
||
{
|
||
<= 3 => MeterShape.MonthlyMidnight,
|
||
<= 6 => MeterShape.MonthlyLabel,
|
||
_ => MeterShape.MonthlyIrregular,
|
||
};
|
||
Add("monthly", typeKey, unit, MeterMode.CumulativeCounter, shape, i, min, max,
|
||
openingBalance: i % 10 == 3,
|
||
late: i % 13 == 5,
|
||
retired: i % 97 == 11,
|
||
swap: typeKey == "water" && shape == MeterShape.MonthlyMidnight && i % 10 != 3 && i < 40);
|
||
}
|
||
}
|
||
|
||
private void AddSeries(string group, string typeKey, string unit, MeterMode mode, MeterShape shape, int count, double min, double max)
|
||
{
|
||
for (var i = 0; i < count; i++)
|
||
{
|
||
Add(group, typeKey, unit, mode, shape, i, min, max);
|
||
}
|
||
}
|
||
|
||
private void Add(
|
||
string group, string typeKey, string unit, MeterMode mode, MeterShape shape, int index, double min, double max,
|
||
string? role = null, bool openingBalance = false, bool late = false, bool retired = false, bool swap = false)
|
||
{
|
||
var seed = Seed ^ ((ulong)_physical.Count * 0x9E3779B97F4A7C15UL);
|
||
var random = new DeterministicRandom(seed);
|
||
var mean = random.Between(min, max);
|
||
var start = First.AddMonths(-1);
|
||
if (late)
|
||
{
|
||
start = First.AddMonths(12 + (index * 7 % 60));
|
||
}
|
||
|
||
var name = string.Create(CultureInfo.InvariantCulture, $"{typeKey} {group} {index + 1:000}");
|
||
_physical.Add(new MeterPlan(
|
||
Name: name,
|
||
Group: group,
|
||
TypeKey: typeKey,
|
||
Mode: mode,
|
||
Unit: unit,
|
||
Shape: shape,
|
||
MonthlyMean: mean,
|
||
InstalledAt: openingBalance ? null : start,
|
||
Start: start,
|
||
RetiredAt: retired ? new DateOnly(2022, 6, 30) : null,
|
||
Role: role,
|
||
Swap: swap,
|
||
Seed: seed));
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------ write
|
||
|
||
private async Task<DatasetManifest> WriteAsync(string connectionString, Action<string> log, CancellationToken cancellationToken)
|
||
{
|
||
var total = Stopwatch.StartNew();
|
||
await using var connection = new NpgsqlConnection(connectionString);
|
||
await connection.OpenAsync(cancellationToken);
|
||
|
||
if (await ReadMarkerAsync(connection, cancellationToken) is not null)
|
||
{
|
||
throw new InvalidOperationException("This database already holds the synthetic dataset; recreate it to load again.");
|
||
}
|
||
|
||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||
|
||
var types = new Dictionary<string, int>();
|
||
foreach (var (key, name, unit, mode) in Types)
|
||
{
|
||
types[key] = await EnergyTypeAsync(connection, key, name, unit, mode, cancellationToken);
|
||
}
|
||
|
||
foreach (var meter in _physical)
|
||
{
|
||
var meta = meter.Role is null ? "{}" : MeterMeta.WithRole("{}", meter.Role);
|
||
meter.Id = await MeterAsync(connection, meter, types[meter.TypeKey], meta, cancellationToken);
|
||
}
|
||
|
||
log(string.Create(CultureInfo.InvariantCulture, $"Inserted {_physical.Count} physical meters; copying readings…"));
|
||
var copy = Stopwatch.StartNew();
|
||
var readings = await CopyReadingsAsync(connection, cancellationToken);
|
||
copy.Stop();
|
||
log(string.Create(CultureInfo.InvariantCulture, $"Copied {readings:N0} readings in {copy.Elapsed.TotalSeconds:F1} s"));
|
||
|
||
var events = await EventsAsync(connection, cancellationToken);
|
||
await TanksAndSourcesAsync(connection, cancellationToken);
|
||
|
||
var virtuals = await VirtualMetersAsync(connection, types, cancellationToken);
|
||
var links = await LinksAsync(connection, cancellationToken);
|
||
var tariffs = await TariffsAsync(connection, types, cancellationToken);
|
||
var (members, manualCosts) = await CostsAsync(connection, types, cancellationToken);
|
||
|
||
var manifest = Manifest(types, virtuals, readings, events, tariffs, manualCosts, links, members, copy.Elapsed.TotalSeconds, total.Elapsed.TotalSeconds);
|
||
await MarkAsync(connection, manifest, cancellationToken);
|
||
await transaction.CommitAsync(cancellationToken);
|
||
|
||
await using (var analyze = new NpgsqlCommand("ANALYZE", connection) { CommandTimeout = 0 })
|
||
{
|
||
await analyze.ExecuteNonQueryAsync(cancellationToken);
|
||
}
|
||
|
||
log(string.Create(CultureInfo.InvariantCulture, $"Dataset loaded in {total.Elapsed.TotalSeconds:F1} s ({manifest.MeterCount} meters)"));
|
||
return manifest with { LoadSeconds = total.Elapsed.TotalSeconds };
|
||
}
|
||
|
||
private static async Task<int> EnergyTypeAsync(
|
||
NpgsqlConnection connection, string key, string name, string unit, MeterMode mode, CancellationToken cancellationToken)
|
||
{
|
||
await using (var insert = new NpgsqlCommand(
|
||
"""
|
||
INSERT INTO energy_type (key, display_name, base_unit, default_mode, created_at)
|
||
VALUES (@key, @name, @unit, @mode, now())
|
||
ON CONFLICT (key) DO NOTHING
|
||
""", connection))
|
||
{
|
||
insert.Parameters.AddWithValue("key", key);
|
||
insert.Parameters.AddWithValue("name", name);
|
||
insert.Parameters.AddWithValue("unit", unit);
|
||
insert.Parameters.AddWithValue("mode", mode.ToString());
|
||
await insert.ExecuteNonQueryAsync(cancellationToken);
|
||
}
|
||
|
||
await using var select = new NpgsqlCommand("SELECT id FROM energy_type WHERE key = @key", connection);
|
||
select.Parameters.AddWithValue("key", key);
|
||
return Convert.ToInt32(await select.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture);
|
||
}
|
||
|
||
private static async Task<int> MeterAsync(NpgsqlConnection connection, MeterPlan meter, int typeId, string meta, CancellationToken cancellationToken)
|
||
{
|
||
await using var command = new NpgsqlCommand(
|
||
"""
|
||
INSERT INTO meter (name, energy_type_id, mode, unit, installed_at, retired_at, is_active, initial_baseline, meta, created_at, updated_at)
|
||
VALUES (@name, @type, @mode, @unit, @installed, @retired, @active, @baseline, @meta, now(), now())
|
||
RETURNING id
|
||
""", connection);
|
||
command.Parameters.AddWithValue("name", meter.Name);
|
||
command.Parameters.AddWithValue("type", (short)typeId);
|
||
command.Parameters.AddWithValue("mode", meter.Mode.ToString());
|
||
command.Parameters.AddWithValue("unit", meter.Unit);
|
||
command.Parameters.Add(new NpgsqlParameter("installed", NpgsqlDbType.Date) { Value = meter.InstalledAt is { } i ? i : DBNull.Value });
|
||
command.Parameters.Add(new NpgsqlParameter("retired", NpgsqlDbType.Date) { Value = meter.RetiredAt is { } r ? r : DBNull.Value });
|
||
command.Parameters.AddWithValue("active", meter.RetiredAt is null);
|
||
command.Parameters.AddWithValue("baseline", meter.Shape is MeterShape.Tank or MeterShape.Virtual ? 0d : Baseline(meter));
|
||
command.Parameters.Add(new NpgsqlParameter("meta", NpgsqlDbType.Jsonb) { Value = meta });
|
||
return Convert.ToInt32(await command.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture);
|
||
}
|
||
|
||
private static double Baseline(MeterPlan meter) => Math.Round(new DeterministicRandom(meter.Seed ^ 0xB5).Between(1_000, 60_000), 3);
|
||
|
||
private async Task<long> CopyReadingsAsync(NpgsqlConnection connection, CancellationToken cancellationToken)
|
||
{
|
||
long rows = 0;
|
||
await using var importer = await connection.BeginBinaryImportAsync(
|
||
"COPY reading (time, meter_id, value, quality, flags) FROM STDIN (FORMAT BINARY)", cancellationToken);
|
||
foreach (var meter in _physical.Where(m => m.Shape != MeterShape.Tank))
|
||
{
|
||
// Synchronous per-field writes: over a million rows, an await per field costs more than the COPY itself.
|
||
foreach (var reading in Readings(meter))
|
||
{
|
||
importer.StartRow();
|
||
importer.Write(reading.Time, NpgsqlDbType.TimestampTz);
|
||
importer.Write(meter.Id, NpgsqlDbType.Integer);
|
||
importer.Write(reading.Value, NpgsqlDbType.Double);
|
||
importer.Write((short)reading.Quality, NpgsqlDbType.Smallint);
|
||
importer.Write((int)reading.Flags, NpgsqlDbType.Integer);
|
||
rows++;
|
||
}
|
||
}
|
||
|
||
await importer.CompleteAsync(cancellationToken);
|
||
return rows;
|
||
}
|
||
|
||
private async Task<long> EventsAsync(NpgsqlConnection connection, CancellationToken cancellationToken)
|
||
{
|
||
long rows = 0;
|
||
await using var importer = await connection.BeginBinaryImportAsync(
|
||
"COPY meter_event (meter_id, time, event_type, amount, prev_value, new_value, unit, notes, meta) FROM STDIN (FORMAT BINARY)",
|
||
cancellationToken);
|
||
|
||
async Task RowAsync(int meterId, DateTime time, MeterEventType type, double? amount, double? prev, double? next, string unit, string note)
|
||
{
|
||
await importer.StartRowAsync(cancellationToken);
|
||
await importer.WriteAsync(meterId, NpgsqlDbType.Integer, cancellationToken);
|
||
await importer.WriteAsync(time, NpgsqlDbType.TimestampTz, cancellationToken);
|
||
await importer.WriteAsync(type.ToString(), NpgsqlDbType.Varchar, cancellationToken);
|
||
await WriteNullableAsync(amount);
|
||
await WriteNullableAsync(prev);
|
||
await WriteNullableAsync(next);
|
||
await importer.WriteAsync(unit, NpgsqlDbType.Varchar, cancellationToken);
|
||
await importer.WriteAsync(note, NpgsqlDbType.Text, cancellationToken);
|
||
await importer.WriteAsync("{}", NpgsqlDbType.Jsonb, cancellationToken);
|
||
rows++;
|
||
}
|
||
|
||
async Task WriteNullableAsync(double? value)
|
||
{
|
||
if (value is { } v)
|
||
{
|
||
await importer.WriteAsync(v, NpgsqlDbType.Double, cancellationToken);
|
||
}
|
||
else
|
||
{
|
||
await importer.WriteNullAsync(cancellationToken);
|
||
}
|
||
}
|
||
|
||
foreach (var (meterId, swaps) in _swaps)
|
||
{
|
||
foreach (var swap in swaps)
|
||
{
|
||
await RowAsync(meterId, swap.Time, MeterEventType.MeterSwap, null, swap.PrevValue, swap.NewValue, UnitOf(meterId), "synthetic swap");
|
||
}
|
||
}
|
||
|
||
foreach (var tank in _physical.Where(m => m.Shape == MeterShape.Tank))
|
||
{
|
||
foreach (var e in TankEvents(tank))
|
||
{
|
||
await RowAsync(tank.Id, e.Time, e.Type, e.Amount, null, null, "L", e.Type == MeterEventType.Delivery ? "synthetic delivery" : "synthetic dipstick");
|
||
}
|
||
}
|
||
|
||
await importer.CompleteAsync(cancellationToken);
|
||
return rows;
|
||
}
|
||
|
||
private string UnitOf(int meterId) => _physical.First(m => m.Id == meterId).Unit;
|
||
|
||
private async Task TanksAndSourcesAsync(NpgsqlConnection connection, CancellationToken cancellationToken)
|
||
{
|
||
foreach (var tank in _physical.Where(m => m.Shape == MeterShape.Tank))
|
||
{
|
||
await using var command = new NpgsqlCommand(
|
||
"""
|
||
INSERT INTO tank (meter_id, capacity, unit, rate_mode, low_threshold, reorder_threshold)
|
||
VALUES (@meter, 6000, 'L', 'Empirical', 800, 1500)
|
||
""", connection);
|
||
command.Parameters.AddWithValue("meter", tank.Id);
|
||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||
}
|
||
|
||
// The live meters report over MQTT (metadata only: freshness reads it, no broker is involved).
|
||
foreach (var live in _physical.Where(m => m.Shape == MeterShape.LiveHourly))
|
||
{
|
||
await using var command = new NpgsqlCommand(
|
||
"""
|
||
INSERT INTO meter_source (meter_id, source_type, config, value_kind, scale, "offset", priority, is_enabled)
|
||
VALUES (@meter, 'Mqtt', @config, 'Register', 1, 0, 0, true)
|
||
""", connection);
|
||
command.Parameters.AddWithValue("meter", live.Id);
|
||
command.Parameters.Add(new NpgsqlParameter("config", NpgsqlDbType.Jsonb)
|
||
{
|
||
Value = string.Create(CultureInfo.InvariantCulture, $$"""{"topic":"perf/meter/{{live.Id}}/SENSOR"}"""),
|
||
});
|
||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||
}
|
||
}
|
||
|
||
/// <summary>The twenty virtual meters, in dependency order: sums, differences, and formulas over formulas.</summary>
|
||
private async Task<List<(string Name, int Id)>> VirtualMetersAsync(
|
||
NpgsqlConnection connection, Dictionary<string, int> types, CancellationToken cancellationToken)
|
||
{
|
||
int[] Of(string group, string typeKey, int take, int skip = 0) =>
|
||
[.. _physical.Where(m => m.Group == group && m.TypeKey == typeKey && m.Role is null).Skip(skip).Take(take).Select(m => m.Id)];
|
||
|
||
// Monthly meters with a clean full history; a reduced-scale trial may have none of a type, and takes any then.
|
||
int[] Clean(string typeKey, int take, int skip = 0)
|
||
{
|
||
int[] clean = [.. _physical.Where(m => m.Group == "monthly" && m.TypeKey == typeKey && m.FullHistory(First)).Skip(skip).Take(take).Select(m => m.Id)];
|
||
return clean.Length > 0 ? clean : Of("monthly", typeKey, take, skip);
|
||
}
|
||
|
||
static string Sum(IEnumerable<int> ids) => string.Join(" + ", ids.Select(Ref));
|
||
static string Ref(int id) => string.Create(CultureInfo.InvariantCulture, $"m{id}");
|
||
|
||
// A reduced-scale trial run has fewer candidates; wrap around rather than fail.
|
||
static int Pick(int[] ids, int index) => ids[index % ids.Length];
|
||
|
||
var gridImport = _physical.First(m => m.TypeKey == "electricity" && m.Role == MeterRoles.GridImport).Id;
|
||
var gridExport = _physical.First(m => m.TypeKey == "electricity" && m.Role == MeterRoles.GridExport).Id;
|
||
var electricity = Clean("electricity", 60);
|
||
var water = Clean("water", 12);
|
||
var gas = Clean("gas", 8);
|
||
var heat = Clean("district_heat", 8);
|
||
var pv = Of("generation", "electricity", 6);
|
||
var dailyElectricity = Of("daily", "electricity", 10);
|
||
var liveElectricity = Of("live", "electricity", 10);
|
||
|
||
var created = new List<(string Name, int Id)>();
|
||
var ids = new Dictionary<string, int>();
|
||
|
||
async Task AddAsync(string key, string typeKey, string expression, QuantityKind kind, string unit, VirtualCostRule rule)
|
||
{
|
||
var meta = VirtualDefinitionJson.Write("{}", new VirtualDefinition(expression, kind, unit, rule));
|
||
var plan = new MeterPlan($"virtual {key}", "virtual", typeKey, MeterMode.Virtual, unit, MeterShape.Virtual, 0, null, First, null, null, false, 0);
|
||
var id = await MeterAsync(connection, plan, types[typeKey], meta, cancellationToken);
|
||
ids[key] = id;
|
||
created.Add((key, id));
|
||
}
|
||
|
||
string V(string key) => Ref(ids[key]);
|
||
|
||
await AddAsync("v01 sum of 5", "electricity", Sum(electricity.Take(5)), QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts);
|
||
await AddAsync("v02 sum of 10", "electricity", Sum(electricity.Skip(5).Take(10)), QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts);
|
||
await AddAsync("v03 daily sum of 10", "electricity", Sum(dailyElectricity), QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts);
|
||
await AddAsync("v04 difference", "electricity", $"{Ref(Pick(electricity, 15))} - {Ref(Pick(electricity, 16))}", QuantityKind.Consumption, "kWh", VirtualCostRule.None);
|
||
await AddAsync("v05 difference", "electricity", $"{Ref(Pick(electricity, 17))} - {Ref(Pick(electricity, 18))}", QuantityKind.Consumption, "kWh", VirtualCostRule.None);
|
||
await AddAsync("v06 water sum", "water", Sum(water.Take(8)), QuantityKind.Consumption, "m3", VirtualCostRule.SourceCosts);
|
||
await AddAsync("v07 water difference", "water", $"{Ref(Pick(water, 8))} - {Ref(Pick(water, 9))}", QuantityKind.Consumption, "m3", VirtualCostRule.None);
|
||
await AddAsync("v08 gas sum", "gas", Sum(gas.Take(6)), QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts);
|
||
await AddAsync("v09 heat sum", "district_heat", Sum(heat.Take(6)), QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts);
|
||
await AddAsync("v10 PV total", "electricity", Sum(pv), QuantityKind.Generation, "kWh", VirtualCostRule.None);
|
||
await AddAsync("v11 self-consumption", "electricity", $"{V("v10 PV total")} - {Ref(gridExport)}", QuantityKind.Net, "kWh", VirtualCostRule.None);
|
||
await AddAsync("v12 household", "electricity", $"{Ref(gridImport)} + {V("v11 self-consumption")}", QuantityKind.Net, "kWh", VirtualCostRule.None);
|
||
await AddAsync("v13 nested sum", "electricity", $"{V("v01 sum of 5")} + {V("v02 sum of 10")}", QuantityKind.Consumption, "kWh", VirtualCostRule.None);
|
||
await AddAsync("v14 nested difference", "electricity", $"{V("v13 nested sum")} - {V("v04 difference")}", QuantityKind.Consumption, "kWh", VirtualCostRule.None);
|
||
await AddAsync("v15 three levels", "electricity", $"{V("v14 nested difference")} + {V("v03 daily sum of 10")}", QuantityKind.Consumption, "kWh", VirtualCostRule.None);
|
||
await AddAsync("v16 sum of 40", "electricity", Sum(electricity.Length > 20 ? electricity.Skip(20).Take(40) : electricity), QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts);
|
||
await AddAsync("v17 live sum", "electricity", Sum(liveElectricity.Take(8)), QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts);
|
||
await AddAsync("v18 water nested", "water", $"{V("v06 water sum")} + {V("v07 water difference")}", QuantityKind.Consumption, "m3", VirtualCostRule.None);
|
||
await AddAsync("v19 heating", "gas", $"{V("v08 gas sum")} + {V("v09 heat sum")}", QuantityKind.Consumption, "kWh", VirtualCostRule.None);
|
||
await AddAsync("v20 nested big difference", "electricity", $"{V("v16 sum of 40")} - {V("v17 live sum")}", QuantityKind.Consumption, "kWh", VirtualCostRule.None);
|
||
return created;
|
||
}
|
||
|
||
/// <summary>Subsections: thirty electricity meters under the grid import, five under one of them, ten water meters under one.</summary>
|
||
private async Task<int> LinksAsync(NpgsqlConnection connection, CancellationToken cancellationToken)
|
||
{
|
||
var gridImport = _physical.First(m => m.TypeKey == "electricity" && m.Role == MeterRoles.GridImport).Id;
|
||
var electricity = _physical.Where(m => m.Group == "monthly" && m.TypeKey == "electricity" && m.FullHistory(First)).Skip(60).Take(36).Select(m => m.Id).ToList();
|
||
var water = _physical.Where(m => m.Group == "monthly" && m.TypeKey == "water" && m.FullHistory(First)).Skip(12).Take(11).Select(m => m.Id).ToList();
|
||
|
||
var links = new List<(int From, int To)>();
|
||
links.AddRange(electricity.Take(30).Select(to => (gridImport, to)));
|
||
if (electricity.Count > 30)
|
||
{
|
||
links.AddRange(electricity.Skip(31).Select(to => (electricity[30], to)));
|
||
links.Add((gridImport, electricity[30]));
|
||
}
|
||
|
||
if (water.Count > 1)
|
||
{
|
||
links.AddRange(water.Skip(1).Select(to => (water[0], to)));
|
||
}
|
||
|
||
foreach (var (from, to) in links)
|
||
{
|
||
await using var command = new NpgsqlCommand("INSERT INTO meter_link (from_meter_id, to_meter_id) VALUES (@from, @to)", connection);
|
||
command.Parameters.AddWithValue("from", from);
|
||
command.Parameters.AddWithValue("to", to);
|
||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||
}
|
||
|
||
return links.Count;
|
||
}
|
||
|
||
/// <summary>Per type: a unit price changing every year (and mid-2022 for electricity and gas), a standing charge, feed-in; five meter prices.</summary>
|
||
private async Task<int> TariffsAsync(NpgsqlConnection connection, Dictionary<string, int> types, CancellationToken cancellationToken)
|
||
{
|
||
var rows = new List<(string Scope, int ScopeId, string Component, double Value, string Unit, DateOnly From, DateOnly? To)>();
|
||
(string Key, double Price, string Unit, double Base)[] prices =
|
||
[
|
||
("electricity", 0.26, "EUR/kWh", 11.5),
|
||
("water", 3.9, "EUR/m3", 7.8),
|
||
("heating_oil", 0.62, "EUR/L", 0),
|
||
("gas", 0.065, "EUR/kWh", 14.2),
|
||
("district_heat", 0.095, "EUR/kWh", 21),
|
||
("perf_site_b", 0.25, "EUR/kWh", 9.5),
|
||
("perf_site_c", 0.27, "EUR/kWh", 9.5),
|
||
];
|
||
|
||
var firstYear = First.Year - 1;
|
||
foreach (var (key, price, unit, standing) in prices)
|
||
{
|
||
var type = types[key];
|
||
for (var year = firstYear; year <= Today.Year; year++)
|
||
{
|
||
var value = Math.Round(price * (1 + (0.03 * (year - firstYear))), 4);
|
||
var from = new DateOnly(year, 1, 1);
|
||
DateOnly? to = year == Today.Year ? null : new DateOnly(year, 12, 31);
|
||
if (year == 2022 && key is "electricity" or "gas")
|
||
{
|
||
rows.Add(("EnergyType", type, "UnitPrice", value, unit, from, new DateOnly(2022, 6, 30)));
|
||
rows.Add(("EnergyType", type, "UnitPrice", Math.Round(value * 1.6, 4), unit, new DateOnly(2022, 7, 1), to));
|
||
}
|
||
else
|
||
{
|
||
rows.Add(("EnergyType", type, "UnitPrice", value, unit, from, to));
|
||
}
|
||
}
|
||
|
||
if (standing > 0)
|
||
{
|
||
rows.Add(("EnergyType", type, "BasePrice", standing, "EUR/month", new DateOnly(firstYear, 1, 1), new DateOnly(2020, 12, 31)));
|
||
rows.Add(("EnergyType", type, "BasePrice", Math.Round(standing * 1.2, 2), "EUR/month", new DateOnly(2021, 1, 1), null));
|
||
}
|
||
|
||
if (key is "electricity" or "perf_site_b" or "perf_site_c")
|
||
{
|
||
rows.Add(("EnergyType", type, "FeedIn", 0.123, "EUR/kWh", new DateOnly(firstYear, 1, 1), new DateOnly(2020, 12, 31)));
|
||
rows.Add(("EnergyType", type, "FeedIn", 0.082, "EUR/kWh", new DateOnly(2021, 1, 1), null));
|
||
}
|
||
}
|
||
|
||
// Five linked electricity subsections have their own price (D-35).
|
||
foreach (var meter in _physical.Where(m => m.Group == "monthly" && m.TypeKey == "electricity" && m.FullHistory(First)).Skip(60).Take(5))
|
||
{
|
||
rows.Add(("Meter", meter.Id, "UnitPrice", 0.30, "EUR/kWh", new DateOnly(2019, 1, 1), null));
|
||
}
|
||
|
||
foreach (var row in rows)
|
||
{
|
||
await using var command = new NpgsqlCommand(
|
||
"""
|
||
INSERT INTO tariff (scope_type, scope_id, component, value, unit, currency, valid_from, valid_to)
|
||
VALUES (@scope, @id, @component, @value, @unit, 'EUR', @from, @to)
|
||
""", connection);
|
||
command.Parameters.AddWithValue("scope", row.Scope);
|
||
command.Parameters.AddWithValue("id", row.ScopeId);
|
||
command.Parameters.AddWithValue("component", row.Component);
|
||
command.Parameters.AddWithValue("value", row.Value);
|
||
command.Parameters.AddWithValue("unit", row.Unit);
|
||
command.Parameters.AddWithValue("from", row.From);
|
||
command.Parameters.Add(new NpgsqlParameter("to", NpgsqlDbType.Date) { Value = row.To is { } t ? t : DBNull.Value });
|
||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||
}
|
||
|
||
return rows.Count;
|
||
}
|
||
|
||
/// <summary>Four categories (types as members, and one overlapping meter category) and 64 manual costs.</summary>
|
||
private async Task<(int Members, int ManualCosts)> CostsAsync(NpgsqlConnection connection, Dictionary<string, int> types, CancellationToken cancellationToken)
|
||
{
|
||
var heating = await CategoryAsync(connection, "Heizung", 0, cancellationToken);
|
||
var power = await CategoryAsync(connection, "Strom", 1, cancellationToken);
|
||
var water = await CategoryAsync(connection, "Wasser", 2, cancellationToken);
|
||
var pool = await CategoryAsync(connection, "Pool Betrieb", 3, cancellationToken);
|
||
|
||
var members = new List<(int Category, int? Meter, int? Type)>
|
||
{
|
||
(heating, null, types["gas"]),
|
||
(heating, null, types["district_heat"]),
|
||
(heating, null, types["heating_oil"]),
|
||
(power, null, types["electricity"]),
|
||
(power, null, types["perf_site_b"]),
|
||
(power, null, types["perf_site_c"]),
|
||
(water, null, types["water"]),
|
||
};
|
||
members.AddRange(_physical.Where(m => m.Group == "monthly" && m.TypeKey == "electricity").Skip(100).Take(5).Select(m => (pool, (int?)m.Id, (int?)null)));
|
||
members.AddRange(_physical.Where(m => m.Group == "monthly" && m.TypeKey == "water").Skip(100).Take(2).Select(m => (pool, (int?)m.Id, (int?)null)));
|
||
|
||
foreach (var (category, meter, type) in members)
|
||
{
|
||
await using var command = new NpgsqlCommand(
|
||
"INSERT INTO cost_category_member (category_id, meter_id, energy_type_id) VALUES (@category, @meter, @type)", connection);
|
||
command.Parameters.AddWithValue("category", category);
|
||
command.Parameters.Add(new NpgsqlParameter("meter", NpgsqlDbType.Integer) { Value = meter is { } m ? m : DBNull.Value });
|
||
command.Parameters.Add(new NpgsqlParameter("type", NpgsqlDbType.Smallint) { Value = type is { } t ? (short)t : DBNull.Value });
|
||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||
}
|
||
|
||
var costs = new List<(int? Category, int? Meter, DateOnly Start, DateOnly End, double Amount, string Note)>();
|
||
for (var year = First.Year + 1; year <= Today.Year; year++)
|
||
{
|
||
costs.Add((heating, null, new DateOnly(year, 3, 1), new DateOnly(year, 3, 31), 95, "chimney sweep"));
|
||
}
|
||
|
||
var maintained = _physical.Where(m => m.Group == "monthly").Take(10).ToList();
|
||
for (var year = Today.Year - 2; year <= Today.Year; year++)
|
||
{
|
||
foreach (var meter in maintained)
|
||
{
|
||
costs.Add((null, meter.Id, new DateOnly(year, 5, 1), new DateOnly(year, 5, 31), 40, "meter maintenance"));
|
||
}
|
||
}
|
||
|
||
for (var month = CurrentMonth.AddMonths(-23); month <= CurrentMonth; month = month.AddMonths(1))
|
||
{
|
||
costs.Add((power, null, month, month.AddMonths(1).AddDays(-1), 9.9, "service fee"));
|
||
}
|
||
|
||
foreach (var cost in costs)
|
||
{
|
||
await using var command = new NpgsqlCommand(
|
||
"""
|
||
INSERT INTO manual_cost (category_id, meter_id, period_start, period_end, amount, currency, notes)
|
||
VALUES (@category, @meter, @start, @end, @amount, 'EUR', @note)
|
||
""", connection);
|
||
command.Parameters.Add(new NpgsqlParameter("category", NpgsqlDbType.Integer) { Value = cost.Category is { } c ? c : DBNull.Value });
|
||
command.Parameters.Add(new NpgsqlParameter("meter", NpgsqlDbType.Integer) { Value = cost.Meter is { } m ? m : DBNull.Value });
|
||
command.Parameters.AddWithValue("start", cost.Start);
|
||
command.Parameters.AddWithValue("end", cost.End);
|
||
command.Parameters.AddWithValue("amount", cost.Amount);
|
||
command.Parameters.AddWithValue("note", cost.Note);
|
||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||
}
|
||
|
||
return (members.Count, costs.Count);
|
||
}
|
||
|
||
private static async Task<int> CategoryAsync(NpgsqlConnection connection, string name, int sort, CancellationToken cancellationToken)
|
||
{
|
||
await using (var select = new NpgsqlCommand("SELECT id FROM cost_category WHERE name = @name ORDER BY id LIMIT 1", connection))
|
||
{
|
||
select.Parameters.AddWithValue("name", name);
|
||
if (await select.ExecuteScalarAsync(cancellationToken) is { } existing)
|
||
{
|
||
return Convert.ToInt32(existing, CultureInfo.InvariantCulture);
|
||
}
|
||
}
|
||
|
||
await using var insert = new NpgsqlCommand("INSERT INTO cost_category (name, sort) VALUES (@name, @sort) RETURNING id", connection);
|
||
insert.Parameters.AddWithValue("name", name);
|
||
insert.Parameters.AddWithValue("sort", sort);
|
||
return Convert.ToInt32(await insert.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture);
|
||
}
|
||
|
||
private DatasetManifest Manifest(
|
||
Dictionary<string, int> types, List<(string Name, int Id)> virtuals, long readings, long events, int tariffs, int manualCosts, int links,
|
||
int members, double copySeconds, double loadSeconds)
|
||
{
|
||
var groups = _physical.GroupBy(m => m.Group).ToDictionary(g => g.Key, g => g.Select(m => m.Id).ToArray());
|
||
groups["virtual"] = [.. virtuals.Select(v => v.Id)];
|
||
|
||
MeterPlan One(Func<MeterPlan, bool> predicate) => _physical.First(predicate);
|
||
int Virtual(string prefix) => virtuals.First(v => v.Name.StartsWith(prefix, StringComparison.Ordinal)).Id;
|
||
|
||
var samples = new Dictionary<string, int>
|
||
{
|
||
["monthlyMeter"] = One(m => m.Group == "monthly" && m.TypeKey == "electricity" && m.FullHistory(First)).Id,
|
||
["labelMeter"] = One(m => m.Group == "monthly" && m.Shape == MeterShape.MonthlyLabel).Id,
|
||
["irregularMeter"] = One(m => m.Group == "monthly" && m.Shape == MeterShape.MonthlyIrregular).Id,
|
||
["dailyMeter"] = One(m => m.Group == "daily" && m.TypeKey == "electricity").Id,
|
||
["liveMeter"] = One(m => m.Group == "live" && m.TypeKey == "electricity" && m.Role is null).Id,
|
||
["gridImport"] = One(m => m.TypeKey == "electricity" && m.Role == MeterRoles.GridImport).Id,
|
||
["gridExport"] = One(m => m.TypeKey == "electricity" && m.Role == MeterRoles.GridExport).Id,
|
||
["generationMeter"] = One(m => m.Group == "generation").Id,
|
||
["tankMeter"] = One(m => m.Shape == MeterShape.Tank).Id,
|
||
["virtualSum"] = Virtual("v01"),
|
||
["virtualDifference"] = Virtual("v04"),
|
||
["virtualNested"] = Virtual("v15"),
|
||
["virtualNet"] = Virtual("v12"),
|
||
["virtualBigDifference"] = Virtual("v20"),
|
||
};
|
||
|
||
// A hundred meters for the brief's target: 60 monthly (every shape, every type), 25 daily, 10 live, 5 virtual.
|
||
static IEnumerable<int> Spread(int[] ids, int take) =>
|
||
take >= ids.Length ? ids : Enumerable.Range(0, take).Select(i => ids[(int)((long)i * ids.Length / take)]);
|
||
|
||
int[] selection =
|
||
[
|
||
.. Spread(groups["monthly"], 60),
|
||
.. Spread(groups["daily"], 25),
|
||
.. Spread(groups["live"], 10),
|
||
Virtual("v01"), Virtual("v04"), Virtual("v10"), Virtual("v13"), Virtual("v15"),
|
||
];
|
||
|
||
return new DatasetManifest(
|
||
DatasetManifest.CurrentVersion,
|
||
_now,
|
||
_zone.Id,
|
||
_scale,
|
||
types,
|
||
groups,
|
||
samples,
|
||
[.. selection.Distinct()],
|
||
readings,
|
||
events,
|
||
tariffs,
|
||
manualCosts,
|
||
links,
|
||
members,
|
||
copySeconds,
|
||
loadSeconds);
|
||
}
|
||
|
||
private static async Task MarkAsync(NpgsqlConnection connection, DatasetManifest manifest, CancellationToken cancellationToken)
|
||
{
|
||
// The marker, and no stored normalization revision: whichever app (or upgrade) starts next rebuilds every meter.
|
||
await using var command = new NpgsqlCommand(
|
||
"""
|
||
INSERT INTO app_setting (key, value) VALUES (@key, @value)
|
||
ON CONFLICT (key) DO UPDATE SET value = excluded.value;
|
||
DELETE FROM app_setting WHERE key IN ('normalization_revision', 'normalization_zone', 'normalization_pending');
|
||
""", connection);
|
||
command.Parameters.AddWithValue("key", MarkerKey);
|
||
command.Parameters.Add(new NpgsqlParameter("value", NpgsqlDbType.Jsonb) { Value = JsonSerializer.Serialize(manifest, DatasetManifest.Json) });
|
||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------ raw data
|
||
|
||
private readonly record struct RawReading(DateTime Time, double Value, ReadingQuality Quality, ReadingFlags Flags);
|
||
|
||
private readonly record struct SwapEvent(DateTime Time, double PrevValue, double NewValue);
|
||
|
||
private readonly record struct TankEvent(DateTime Time, MeterEventType Type, double Amount);
|
||
|
||
/// <summary>A meter's readings, oldest first; swaps are recorded in <see cref="_swaps"/> on the way.</summary>
|
||
private IEnumerable<RawReading> Readings(MeterPlan meter)
|
||
{
|
||
var random = new DeterministicRandom(meter.Seed);
|
||
var register = Baseline(meter);
|
||
var now = _now.UtcDateTime;
|
||
var retiredMonth = meter.RetiredAt is { } retired ? new DateOnly(retired.Year, retired.Month, 1) : (DateOnly?)null;
|
||
var swapAt = meter.Swap ? First.AddMonths(40 + (int)(meter.Seed % 24)) : (DateOnly?)null;
|
||
|
||
switch (meter.Shape)
|
||
{
|
||
case MeterShape.MonthlyMidnight:
|
||
// The reading at the 1st closes the month before; a retired meter's last one closes its retirement month.
|
||
var lastClose = retiredMonth?.AddMonths(1) ?? CurrentMonth;
|
||
for (var close = meter.Start.AddMonths(1); close <= lastClose; close = close.AddMonths(1))
|
||
{
|
||
var time = Midnight(close);
|
||
if (time >= now)
|
||
{
|
||
break;
|
||
}
|
||
|
||
register = Round(register + MonthAmount(meter, close.AddMonths(-1), random));
|
||
if (close == swapAt)
|
||
{
|
||
// A swap at T: the event carries the old register's final value, the reading at T the new start (CLAUDE.md).
|
||
Swaps(meter.Id).Add(new SwapEvent(time, register, 5));
|
||
register = 5;
|
||
yield return new RawReading(time, register, ReadingQuality.Manual, ReadingFlags.MeterSwap);
|
||
}
|
||
else
|
||
{
|
||
yield return new RawReading(time, register, ReadingQuality.Manual, ReadingFlags.None);
|
||
}
|
||
}
|
||
|
||
break;
|
||
|
||
case MeterShape.MonthlyLabel:
|
||
// A label is the register at the end of its month; the current month has no label yet.
|
||
var lastLabel = retiredMonth ?? CurrentMonth.AddMonths(-1);
|
||
for (var month = meter.Start; month <= lastLabel; month = month.AddMonths(1))
|
||
{
|
||
register = Round(register + MonthAmount(meter, month, random));
|
||
yield return new RawReading(
|
||
new DateTime(month.Year, month.Month, 1, 0, 0, 0, DateTimeKind.Utc), register, ReadingQuality.Imported, ReadingFlags.MonthLabel);
|
||
}
|
||
|
||
break;
|
||
|
||
case MeterShape.MonthlyIrregular:
|
||
// Read on day 1–28 between 07:00 and 19:59, so a retired meter's last reading still lies before its retirement.
|
||
var lastRead = retiredMonth ?? CurrentMonth;
|
||
for (var month = meter.Start.AddMonths(1); month <= lastRead; month = month.AddMonths(1))
|
||
{
|
||
var day = month.AddDays(random.Below(28));
|
||
var time = Local(day, 7 + random.Below(13), random.Below(60));
|
||
if (time >= now)
|
||
{
|
||
break;
|
||
}
|
||
|
||
register = Round(register + MonthAmount(meter, month.AddMonths(-1), random));
|
||
yield return new RawReading(time, register, ReadingQuality.Manual, ReadingFlags.None);
|
||
}
|
||
|
||
break;
|
||
|
||
case MeterShape.Daily:
|
||
var generation = meter.Mode == MeterMode.GenerationCounter;
|
||
var minute = (int)(meter.Seed % 50);
|
||
for (var day = meter.Start.AddDays(1); ; day = day.AddDays(1))
|
||
{
|
||
var time = generation ? Local(day, 21, minute) : Local(day, 6, minute);
|
||
if (time >= now || (meter.RetiredAt is { } last && day > last))
|
||
{
|
||
break;
|
||
}
|
||
|
||
register = Round(register + DayAmount(meter, day.AddDays(-1), random));
|
||
yield return new RawReading(time, register, ReadingQuality.Measured, ReadingFlags.None);
|
||
}
|
||
|
||
break;
|
||
|
||
case MeterShape.LiveHourly:
|
||
var liveFrom = CurrentMonth.AddMonths(-12);
|
||
for (var month = meter.Start; month < liveFrom; month = month.AddMonths(1))
|
||
{
|
||
register = Round(register + MonthAmount(meter, month, random));
|
||
yield return new RawReading(Midnight(month.AddMonths(1)), register, ReadingQuality.Manual, ReadingFlags.None);
|
||
}
|
||
|
||
var export = meter.Role == MeterRoles.GridExport;
|
||
for (var time = Midnight(liveFrom).AddHours(1); time < now; time = time.AddHours(1))
|
||
{
|
||
var local = TimeZoneInfo.ConvertTimeFromUtc(time, _zone);
|
||
var hour = local.AddHours(-1);
|
||
register = Round(register + HourAmount(meter, hour, export, random));
|
||
yield return new RawReading(time, register, ReadingQuality.Measured, ReadingFlags.None);
|
||
}
|
||
|
||
break;
|
||
|
||
default:
|
||
yield break;
|
||
}
|
||
}
|
||
|
||
private List<SwapEvent> Swaps(int meterId)
|
||
{
|
||
if (!_swaps.TryGetValue(meterId, out var list))
|
||
{
|
||
_swaps[meterId] = list = [];
|
||
}
|
||
|
||
return list;
|
||
}
|
||
|
||
/// <summary>Monthly dipstick levels at 10:00 on the 1st and a 4,000 L delivery mid-month whenever the tank would run low.</summary>
|
||
private IEnumerable<TankEvent> TankEvents(MeterPlan tank)
|
||
{
|
||
var random = new DeterministicRandom(tank.Seed);
|
||
var level = 4800d;
|
||
for (var month = tank.Start; ; month = month.AddMonths(1))
|
||
{
|
||
var reading = Local(month, 10, 0);
|
||
if (reading >= _now.UtcDateTime)
|
||
{
|
||
yield break;
|
||
}
|
||
|
||
yield return new TankEvent(reading, MeterEventType.TankLevel, Math.Round(level, 1));
|
||
var usage = tank.MonthlyMean * Heating(month.Month) * random.Between(0.8, 1.2);
|
||
var delivery = Local(month.AddDays(14), 11, 0);
|
||
if (level - (usage / 2) < 1500 && delivery < _now.UtcDateTime)
|
||
{
|
||
yield return new TankEvent(delivery, MeterEventType.Delivery, 4000);
|
||
level += 4000;
|
||
}
|
||
|
||
level = Math.Max(100, level - usage);
|
||
}
|
||
}
|
||
|
||
private static double MonthAmount(MeterPlan meter, DateOnly month, DeterministicRandom random) =>
|
||
meter.Mode == MeterMode.GenerationCounter
|
||
? meter.MonthlyMean * 4.5 * 30.4 * Solar(new DateOnly(month.Year, month.Month, 15).DayOfYear) * random.Between(0.7, 1.1)
|
||
: meter.MonthlyMean * Season(meter.TypeKey, month.Month) * random.Between(0.85, 1.15);
|
||
|
||
private static double DayAmount(MeterPlan meter, DateOnly day, DeterministicRandom random) =>
|
||
meter.Mode == MeterMode.GenerationCounter
|
||
? meter.MonthlyMean * 4.5 * Solar(day.DayOfYear) * random.Between(0.15, 1.0)
|
||
: meter.MonthlyMean / 30.4 * Season(meter.TypeKey, day.Month) * random.Between(0.7, 1.3);
|
||
|
||
private static double HourAmount(MeterPlan meter, DateTime localHour, bool export, DeterministicRandom random)
|
||
{
|
||
var perDay = meter.MonthlyMean / 30.4;
|
||
if (export)
|
||
{
|
||
// Export only while the sun is up, most at noon.
|
||
var daylight = localHour.Hour is >= 7 and <= 19 ? Math.Sin(Math.PI * (localHour.Hour - 6) / 14.0) : 0;
|
||
return perDay * Solar(localHour.DayOfYear) * daylight / 8.9 * random.Between(0.3, 1.2);
|
||
}
|
||
|
||
var profile = localHour.Hour switch
|
||
{
|
||
< 6 => 0.6,
|
||
< 9 => 1.1,
|
||
< 17 => 0.9,
|
||
< 22 => 1.6,
|
||
_ => 0.9,
|
||
};
|
||
return perDay / 24 * Season(meter.TypeKey, localHour.Month) * profile * random.Between(0.6, 1.4);
|
||
}
|
||
|
||
private static double Season(string typeKey, int month) => typeKey switch
|
||
{
|
||
"gas" or "district_heat" or "heating_oil" => Heating(month),
|
||
"water" => 1 + (0.2 * Math.Cos(2 * Math.PI * (month - 7) / 12)),
|
||
_ => 1 + (0.18 * Math.Cos(2 * Math.PI * (month - 1) / 12)),
|
||
};
|
||
|
||
private static double Heating(int month) => 1 + (0.85 * Math.Cos(2 * Math.PI * (month - 1) / 12));
|
||
|
||
private static double Solar(int dayOfYear) => 0.55 + (0.45 * Math.Cos(2 * Math.PI * (dayOfYear - 172) / 365.25));
|
||
|
||
private static double Round(double value) => Math.Round(value, 3);
|
||
|
||
private DateTime Midnight(DateOnly day) => GapAttribution.LocalMidnight(day, _zone).UtcDateTime;
|
||
|
||
private DateTime Local(DateOnly day, int hour, int minute) =>
|
||
TimeZoneInfo.ConvertTimeToUtc(day.ToDateTime(new TimeOnly(hour, minute)), _zone);
|
||
}
|
||
|
||
/// <summary>SplitMix64: a tiny deterministic generator, so the dataset never depends on a runtime's Random.</summary>
|
||
internal sealed class DeterministicRandom(ulong seed)
|
||
{
|
||
private ulong _state = seed;
|
||
|
||
public ulong Next()
|
||
{
|
||
var z = _state += 0x9E3779B97F4A7C15UL;
|
||
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9UL;
|
||
z = (z ^ (z >> 27)) * 0x94D049BB133111EBUL;
|
||
return z ^ (z >> 31);
|
||
}
|
||
|
||
public double NextDouble() => (Next() >> 11) * (1.0 / (1UL << 53));
|
||
|
||
public double Between(double min, double max) => min + ((max - min) * NextDouble());
|
||
|
||
public int Below(int count) => (int)(Next() % (ulong)count);
|
||
}
|