diff --git a/src/App/Components/Pages/Meters.razor b/src/App/Components/Pages/Meters.razor index ca58939..0dba6d1 100644 --- a/src/App/Components/Pages/Meters.razor +++ b/src/App/Components/Pages/Meters.razor @@ -85,6 +85,14 @@ else upstream meters you select below (e.g. Sum Solar = Solar 1 + Solar 2). } + else if (_working.Mode == MeterMode.InstantRate) + { + + Power/flow sensor — readings are an instantaneous rate, integrated over time into consumption. + Store the value as a per-hour rate in this meter's unit (e.g. kW for kWh, L/h for L): a + source reporting W or L/min should carry a scale factor to convert it first. + + } diff --git a/src/Core/Domain/Enums.cs b/src/Core/Domain/Enums.cs index 17cdc0b..6c91f66 100644 --- a/src/Core/Domain/Enums.cs +++ b/src/Core/Domain/Enums.cs @@ -21,7 +21,7 @@ public enum MeterMode /// Source already reports increments. The value is the increment. DirectDelta, - /// Power/flow sensor (schema-supported; worker deferred post-v1). Integrate rate over time. + /// Power/flow sensor. Consumption = rate integrated over time (trapezoidal). InstantRate, /// Computed from other meters via a user-defined expression. diff --git a/src/Core/Normalization/NormalizationEngine.cs b/src/Core/Normalization/NormalizationEngine.cs index ea590ad..d9e70e3 100644 --- a/src/Core/Normalization/NormalizationEngine.cs +++ b/src/Core/Normalization/NormalizationEngine.cs @@ -24,6 +24,7 @@ public sealed class NormalizationEngine : INormalizationEngine new Normalizers.RuntimeCounterNormalizer(), new Normalizers.ConsumableBalanceNormalizer(), new Normalizers.DirectDeltaNormalizer(), + new Normalizers.InstantRateNormalizer(), new Normalizers.VirtualNormalizer(), ]); diff --git a/src/Core/Normalization/Normalizers/InstantRateNormalizer.cs b/src/Core/Normalization/Normalizers/InstantRateNormalizer.cs new file mode 100644 index 0000000..7fc5d81 --- /dev/null +++ b/src/Core/Normalization/Normalizers/InstantRateNormalizer.cs @@ -0,0 +1,55 @@ +using MeterVault.Core.Domain; + +namespace MeterVault.Core.Normalization.Normalizers; + +/// +/// Power/flow sensor (SDD §5.2 instant_rate): the reading value is an instantaneous rate, not +/// a register. Consumption is the rate integrated over time (trapezoidal rule between consecutive +/// samples), attributed to the interval's end reading — so the first reading only seeds the integral +/// and N readings produce N−1 consumption rows. +/// +/// The value is treated as a rate expressed per hour in the meter's own unit, i.e. +/// rate × Δhours = consumption. Power in kW integrated over hours yields kWh; a flow in L/h +/// yields L; m³/h yields m³. A source that reports a native unit (W, L/min) should carry a +/// scale/offset that converts it to this canonical per-hour rate before it is stored as a reading. +/// Signs are preserved (a bidirectional power sensor may go negative on export). +/// +/// +public sealed class InstantRateNormalizer : IMeterNormalizer +{ + public MeterMode Mode => MeterMode.InstantRate; + + public IEnumerable Normalize(NormalizationContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var readings = context.Readings.OrderBy(r => r.Time).ToList(); + + Reading? previous = null; + foreach (var reading in readings) + { + if (previous is not null) + { + var hours = (reading.Time - previous.Time).TotalHours; + if (hours > 0) + { + // Trapezoidal integral of the rate over [previous, reading]; the linear mean of + // the two samples is exact for a rate that varies linearly between them. + var amount = (previous.Value + reading.Value) / 2d * hours; + + yield return new Consumption + { + MeterId = context.Meter.MeterId, + Time = reading.Time, + Amount = amount, + Kind = ConsumptionKind.Consumption, + Quality = reading.Quality, + ImportBatchId = reading.ImportBatchId, + }; + } + } + + previous = reading; + } + } +} diff --git a/tests/Core.Tests/InstantRateNormalizerTests.cs b/tests/Core.Tests/InstantRateNormalizerTests.cs new file mode 100644 index 0000000..99ca961 --- /dev/null +++ b/tests/Core.Tests/InstantRateNormalizerTests.cs @@ -0,0 +1,79 @@ +using MeterVault.Core.Domain; +using MeterVault.Core.Normalization; +using static MeterVault.Core.Tests.TestData; + +namespace MeterVault.Core.Tests; + +/// +/// instant_rate: the reading value is an instantaneous rate (e.g. kW) integrated over time into +/// consumption (kWh). Uses the trapezoidal rule between consecutive samples, attributed to the +/// interval's end reading — the first reading only seeds the integral. +/// +public sealed class InstantRateNormalizerTests +{ + private readonly INormalizationEngine _engine = NormalizationEngine.CreateDefault(); + + private static DateTimeOffset At(int hour) => new(new DateTime(2024, 6, 1, 0, 0, 0, DateTimeKind.Utc).AddHours(hour)); + + [Fact] + public void Constant_rate_integrates_to_rate_times_hours() + { + // 2 kW held steady for 3 hours → 6 kWh, booked at the end of the interval. + var ctx = new NormalizationContext + { + Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.InstantRate, Unit = "kWh" }, + Readings = [Reading(1, At(0), 2), Reading(1, At(3), 2)], + }; + + var result = _engine.Normalize(ctx); + + var row = Assert.Single(result); + Assert.Equal(6d, row.Amount, 6); + Assert.Equal(At(3), row.Time); + Assert.Equal(ConsumptionKind.Consumption, row.Kind); + } + + [Fact] + public void Linear_ramp_integrates_trapezoidally_per_interval() + { + // 0 kW → 2 kW → 4 kW at 1-hour steps. Intervals: (0+2)/2·1 = 1, (2+4)/2·1 = 3. + var ctx = new NormalizationContext + { + Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.InstantRate, Unit = "kWh" }, + Readings = [Reading(1, At(0), 0), Reading(1, At(1), 2), Reading(1, At(2), 4)], + }; + + var result = _engine.Normalize(ctx); + + // First reading only seeds the integral: N readings → N−1 rows. + Assert.Equal([1d, 3d], result.Select(c => c.Amount)); + Assert.Equal([At(1), At(2)], result.Select(c => c.Time)); + } + + [Fact] + public void Negative_rate_is_preserved_as_export() + { + // A bidirectional power sensor reading −4 kW for an hour → −4 kWh (net export), not clamped. + var ctx = new NormalizationContext + { + Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.InstantRate, Unit = "kWh" }, + Readings = [Reading(1, At(0), -4), Reading(1, At(1), -4)], + }; + + var result = _engine.Normalize(ctx); + + Assert.Equal(-4d, Assert.Single(result).Amount, 6); + } + + [Fact] + public void Single_reading_produces_no_consumption() + { + var ctx = new NormalizationContext + { + Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.InstantRate, Unit = "kWh" }, + Readings = [Reading(1, At(0), 5)], + }; + + Assert.Empty(_engine.Normalize(ctx)); + } +}