Files
MeterVault/tests/Core.Tests/InstantRateNormalizerTests.cs
T
schmidt.florian b3b8b92520 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
2026-07-17 11:05:01 +02:00

80 lines
2.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 → N1 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));
}
}