using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Rollups; using MeterVault.Core.Domain; namespace MeterVault.Infrastructure.Persistence.Analysis; // The analysis tables (D-12). All four are derived: RecomputeMeterAsync writes them from the same engine // output it stores as consumption, in the caller's transaction, by diff. They are plain tables — not // hypertables — each keyed by meter first, with an FK to meter that cascades, so deleting a meter (or // wiping all of them) never trips over them. /// The columns a day and a month rollup share ( without its start). public abstract class ConsumptionRollupBase { public int MeterId { get; set; } /// Consumption or generation, as the consumption rows are stored. public ConsumptionKind Kind { get; set; } /// The sum of the bucket's rows; signed. public double Amount { get; set; } public double Measured { get; set; } public double Manual { get; set; } public double Imported { get; set; } /// Divided, coalesced, interpolated or otherwise inferred amounts. public double Estimated { get; set; } /// How many consumption rows the bucket sums. public int Rows { get; set; } /// Opening balance (1) and divided (2) markers. public RollupFlags Flags { get; set; } /// The latest source-interval end among the bucket's rows (A-05), UTC. public DateTimeOffset MaxIntervalEnd { get; set; } /// The bucket this row stores, with as its day or month. protected RollupBucket ToBucket(DateOnly start) => new(start, Kind, Amount, Measured, Manual, Imported, Estimated, Rows, Flags, MaxIntervalEnd); /// Copies every column but the key from ; true when anything changed. internal bool Assign(RollupBucket bucket) { var end = bucket.MaxIntervalEnd.ToUniversalTime(); if (Amount.Equals(bucket.Amount) && Measured.Equals(bucket.Measured) && Manual.Equals(bucket.Manual) && Imported.Equals(bucket.Imported) && Estimated.Equals(bucket.Estimated) && Rows == bucket.Rows && Flags == bucket.Flags && MaxIntervalEnd == end && MaxIntervalEnd.Offset == TimeSpan.Zero) { return false; } Amount = bucket.Amount; Measured = bucket.Measured; Manual = bucket.Manual; Imported = bucket.Imported; Estimated = bucket.Estimated; Rows = bucket.Rows; Flags = bucket.Flags; MaxIntervalEnd = end; return true; } } /// One local day of one kind for one meter: table consumption_rollup, key (meter_id, day, kind). public sealed class ConsumptionRollup : ConsumptionRollupBase { /// The local calendar day in the instance zone. public DateOnly Day { get; set; } public RollupBucket ToBucket() => ToBucket(Day); public static ConsumptionRollup From(int meterId, RollupBucket bucket) { ArgumentNullException.ThrowIfNull(bucket); var row = new ConsumptionRollup { MeterId = meterId, Day = bucket.Start, Kind = bucket.Kind }; row.Assign(bucket); return row; } } /// /// One local month of one kind for one meter: table consumption_rollup_month, key (meter_id, month, kind). /// Month and year reads use it. /// public sealed class ConsumptionRollupMonth : ConsumptionRollupBase { /// The first day of the local month. public DateOnly Month { get; set; } public RollupBucket ToBucket() => ToBucket(Month); public static ConsumptionRollupMonth From(int meterId, RollupBucket bucket) { ArgumentNullException.ThrowIfNull(bucket); var row = new ConsumptionRollupMonth { MeterId = meterId, Month = bucket.Start, Kind = bucket.Kind }; row.Assign(bucket); return row; } } /// /// One stored coverage run of a meter (D-13): table meter_coverage, key (meter_id, span_from). Stored /// uncapped (A-04); a reader caps it at its own now with . /// public sealed class MeterCoverageRun { public int MeterId { get; set; } public DateTimeOffset SpanFrom { get; set; } public DateTimeOffset SpanTo { get; set; } public ResolutionClass ResolutionClass { get; set; } public bool DividedAtMonths { get; set; } /// Not for a known hole rather than covered time. public CoverageGapReason GapReason { get; set; } /// Where the run's final interval starts (A-04). public DateTimeOffset? LastIntervalStart { get; set; } public CoverageRun ToRun() => new(SpanFrom, SpanTo, ResolutionClass, DividedAtMonths, GapReason, LastIntervalStart); public static MeterCoverageRun From(int meterId, CoverageRun run) { ArgumentNullException.ThrowIfNull(run); var row = new MeterCoverageRun { MeterId = meterId, SpanFrom = run.From.ToUniversalTime() }; row.Assign(run); return row; } /// Copies every column but the key from ; true when anything changed. internal bool Assign(CoverageRun run) { var to = run.To.ToUniversalTime(); var last = run.LastIntervalStart?.ToUniversalTime(); if (SpanTo == to && SpanTo.Offset == TimeSpan.Zero && ResolutionClass == run.Resolution && DividedAtMonths == run.DividedAtMonths && GapReason == run.Gap && Nullable.Equals(LastIntervalStart, last)) { return false; } SpanTo = to; ResolutionClass = run.Resolution; DividedAtMonths = run.DividedAtMonths; GapReason = run.Gap; LastIntervalStart = last; return true; } } /// /// What a meter's stored analysis data was built with (D-16): table meter_rollup_state, one row per meter. /// A reader whose revision or zone differs — or that finds no row — reports the meter's analysis as being /// prepared, never as "no data". /// public sealed class MeterRollupState { public int MeterId { get; set; } /// The normalization revision the rows were built with (NormalizationUpgrade.CurrentRevision). public int Revision { get; set; } /// The timezone id local days and months were cut in. public string Zone { get; set; } = string.Empty; /// The unit every amount of the meter is in (D-20); canonical spelling. public string NormalizedUnit { get; set; } = string.Empty; /// What the meter's amounts measure (D-20). public QuantityKind Kind { get; set; } /// /// The stamp of the meter's latest raw reading when its analysis data was built, or null when it has none /// (A-40). Every write path recomputes the meter inline, so this is the freshness mark of D-18 without a /// query over reading — whose chunks a reader would otherwise have to plan across on every request. /// public DateTimeOffset? LastReadingAt { get; set; } /// /// When the meter's analysis data last changed: a rollup or coverage row was added, changed or removed, or /// one of the columns above changed. A recompute that reproduces the stored data leaves it alone, so it /// doubles as a change marker for anything cached from these tables. /// public DateTimeOffset BuiltAt { get; set; } }