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