b3b8b92520
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
80 lines
2.8 KiB
C#
80 lines
2.8 KiB
C#
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));
|
||
}
|
||
}
|