Normalization: implement instant_rate mode (rate integrated over time)
The instant_rate mode existed in the enum but had no normalizer, so normalizing a power/flow sensor threw NotSupportedException. InstantRateNormalizer integrates the rate over time (trapezoidal, attributed to each interval's end reading); a per-hour rate in the meter's unit yields the consumption unit (kW->kWh, L/h->L). Registered in the engine; meter editor shows a mode hint. +4 Core tests. Claude-Session: https://claude.ai/code/session_01Kib2MniVFbD95fkgLgBBnB
This commit is contained in:
@@ -85,6 +85,14 @@ else
|
||||
upstream meters you select below (e.g. Sum Solar = Solar 1 + Solar 2).
|
||||
</MudAlert>
|
||||
}
|
||||
else if (_working.Mode == MeterMode.InstantRate)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
|
||||
Power/flow sensor — readings are an instantaneous rate, integrated over time into consumption.
|
||||
Store the value as a <b>per-hour</b> 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.
|
||||
</MudAlert>
|
||||
}
|
||||
<MudTextField @bind-Value="_working.Unit" Label="Unit" Required="true" Class="mb-2" />
|
||||
<MudNumericField T="double" @bind-Value="_working.InitialBaseline" Label="Initial register baseline" Class="mb-2" />
|
||||
<MudSelect T="string" @bind-Value="_working.Role" Label="PV role (optional)" Class="mb-2">
|
||||
|
||||
@@ -21,7 +21,7 @@ public enum MeterMode
|
||||
/// <summary>Source already reports increments. The value is the increment.</summary>
|
||||
DirectDelta,
|
||||
|
||||
/// <summary>Power/flow sensor (schema-supported; worker deferred post-v1). Integrate rate over time.</summary>
|
||||
/// <summary>Power/flow sensor. Consumption = rate integrated over time (trapezoidal).</summary>
|
||||
InstantRate,
|
||||
|
||||
/// <summary>Computed from other meters via a user-defined expression.</summary>
|
||||
|
||||
@@ -24,6 +24,7 @@ public sealed class NormalizationEngine : INormalizationEngine
|
||||
new Normalizers.RuntimeCounterNormalizer(),
|
||||
new Normalizers.ConsumableBalanceNormalizer(),
|
||||
new Normalizers.DirectDeltaNormalizer(),
|
||||
new Normalizers.InstantRateNormalizer(),
|
||||
new Normalizers.VirtualNormalizer(),
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using MeterVault.Core.Domain;
|
||||
|
||||
namespace MeterVault.Core.Normalization.Normalizers;
|
||||
|
||||
/// <summary>
|
||||
/// Power/flow sensor (SDD §5.2 <c>instant_rate</c>): 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.
|
||||
/// <para>
|
||||
/// The value is treated as a rate expressed <em>per hour</em> in the meter's own unit, i.e.
|
||||
/// <c>rate × Δhours = consumption</c>. 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).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class InstantRateNormalizer : IMeterNormalizer
|
||||
{
|
||||
public MeterMode Mode => MeterMode.InstantRate;
|
||||
|
||||
public IEnumerable<Consumption> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Core.Normalization;
|
||||
using static MeterVault.Core.Tests.TestData;
|
||||
|
||||
namespace MeterVault.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user