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