using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Virtual;
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Dashboard;
using MeterVault.Infrastructure.Normalization;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Integration.Tests;
///
/// The per-energy-type flow graph (Sankey) on the shared analysis reader (D-30): node values are the meters' canonical
/// period totals — a virtual meter's through its formula; a single-parent chain attributes the child's full total to
/// its parent and shows the remainder as "Other"; a two-parent merge splits the child proportionally (an estimate);
/// a pure-sum virtual meter is fed by its calculation dependencies; any other virtual meter, and a meter in another
/// unit, is only in the table; links never carry more than the parent measured; a meter without data is never a zero.
///
///
/// Every meter here is installed on 1 January 2024 and read once, at the first instant of 2025 (UTC, the zone of a
/// service built without options), so its whole amount accrues over 2024 and the year's total is fully covered.
/// Each test creates its own energy type and meters and removes them again.
///
[Collection("Timescale")]
public sealed class FlowServiceTests(TimescaleFixture fx) : IAsyncLifetime
{
private static readonly DateOnly Year = new(2024, 1, 1);
private static readonly DateOnly NextYear = new(2025, 1, 1);
private readonly List _meters = [];
private readonly List _types = [];
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
await using var db = fx.CreateContext();
var ids = _meters.ToArray();
await db.Consumption.Where(c => ids.Contains(c.MeterId)).ExecuteDeleteAsync();
await db.Readings.Where(r => ids.Contains(r.MeterId)).ExecuteDeleteAsync();
await db.Meters.Where(m => ids.Contains(m.Id)).ExecuteDeleteAsync();
var types = _types.ToArray();
await db.EnergyTypes.Where(t => types.Contains(t.Id)).ExecuteDeleteAsync();
}
[Fact]
public async Task Single_parent_chain_makes_other_remainder()
{
var type = await TypeAsync();
var main = await MeterAsync(type, "Main", 100);
var car = await MeterAsync(type, "Car", 30);
await LinkAsync(main, car);
var graph = await new FlowService(fx).GetFlowAsync(type, Year, NextYear);
Assert.Equal(100, graph.Total, 1);
var link = Assert.Single(graph.Links, l => l.To == $"m{car}");
Assert.Equal(30, link.Value, 1); // full child consumption flows from its single parent
Assert.False(link.IsEstimated);
Assert.False(link.IsCalculated);
var other = Assert.Single(graph.Nodes, n => n.IsOther);
Assert.Equal(70, other.Value, 1); // 100 − 30
Assert.Equal(BucketStatus.Available, other.Status);
Assert.Equal(BucketStatus.Available, graph.Nodes.Single(n => n.MeterId == main).Status);
}
[Fact]
public async Task Two_parents_split_child_proportionally()
{
var type = await TypeAsync();
var grid = await MeterAsync(type, "Grid", 75);
var solar = await MeterAsync(type, "Solar draw", 25);
var house = await MeterAsync(type, "House", 40);
await LinkAsync(grid, house);
await LinkAsync(solar, house);
var graph = await new FlowService(fx).GetFlowAsync(type, Year, NextYear);
// House (40) splits 75:25 → 30 from grid, 10 from solar — an estimate, and marked so.
var fromGrid = graph.Links.Single(l => l.From == $"m{grid}" && l.To == $"m{house}");
var fromSolar = graph.Links.Single(l => l.From == $"m{solar}" && l.To == $"m{house}");
Assert.Equal(30, fromGrid.Value, 1);
Assert.Equal(10, fromSolar.Value, 1);
Assert.True(fromGrid.IsEstimated);
Assert.True(fromSolar.IsEstimated);
Assert.False(fromGrid.IsCapped);
}
[Fact]
public async Task Generation_meter_counts_as_source()
{
var type = await TypeAsync();
var grid = await MeterAsync(type, "Grid", 75); // grid import
var solar = await MeterAsync(type, "Solar", 30, MeterMode.GenerationCounter); // solar generation
var house = await MeterAsync(type, "House", 40); // house load
await LinkAsync(grid, house);
await LinkAsync(solar, house);
var graph = await new FlowService(fx).GetFlowAsync(type, Year, NextYear);
// Solar's generation makes it a real source: House (40) splits 75:30 across grid+solar.
Assert.Equal(40.0 * 75 / 105, graph.Links.Single(l => l.From == $"m{grid}" && l.To == $"m{house}").Value, 1);
Assert.Equal(40.0 * 30 / 105, graph.Links.Single(l => l.From == $"m{solar}" && l.To == $"m{house}").Value, 1);
// Remainder across grid+solar = (75+30) − 40 = 65 (export + battery/inverter losses).
Assert.Equal(65, graph.Nodes.Where(n => n.IsOther).Sum(n => n.Value), 1);
Assert.Equal(QuantityKind.Generation, graph.MeterFor(solar)!.Kind);
}
[Fact]
public async Task Virtual_sum_meter_is_its_formula()
{
var type = await TypeAsync();
var solar1 = await MeterAsync(type, "Solar 1", 15, MeterMode.GenerationCounter);
var solar2 = await MeterAsync(type, "Solar 2", 15, MeterMode.GenerationCounter);
var solar3 = await MeterAsync(type, "Solar 3", 5, MeterMode.GenerationCounter);
var sumSolar = await VirtualAsync(type, "Sum Solar", $"m{solar1} + m{solar2}", QuantityKind.Generation, VirtualCostRule.SourceCosts);
var grid = await MeterAsync(type, "Grid", 75);
var house = await MeterAsync(type, "House", 40);
// Solar1 + Solar2 → Sum Solar; Grid + Sum Solar → House. Solar 3 is linked into the sum too, but the formula —
// not a link — says what the sum is (D-25).
await LinkAsync(solar1, sumSolar);
await LinkAsync(solar2, sumSolar);
await LinkAsync(solar3, sumSolar);
await LinkAsync(grid, house);
await LinkAsync(sumSolar, house);
var graph = await new FlowService(fx).GetFlowAsync(type, Year, NextYear);
// Sum Solar has no readings but is Solar 1 + Solar 2 = 30, by its formula.
var node = graph.Nodes.Single(n => n.MeterId == sumSolar);
Assert.Equal(30, node.Value, 1);
Assert.True(node.IsVirtual);
Assert.Equal(SeriesBasis.Virtual, graph.MeterFor(sumSolar)!.Basis);
// Its incoming edges are its calculation dependencies, each at the source's value, marked calculated.
var incoming = graph.Links.Where(l => l.To == $"m{sumSolar}").OrderBy(l => l.From, StringComparer.Ordinal).ToList();
Assert.Equal([$"m{solar1}", $"m{solar2}"], incoming.Select(l => l.From).Order(StringComparer.Ordinal));
Assert.All(incoming, l =>
{
Assert.True(l.IsCalculated);
Assert.Equal(15, l.Value, 1);
});
Assert.DoesNotContain(graph.Links, l => l.From == $"m{solar3}");
// House (40) splits across Grid (75) and Sum Solar (30) → 40*30/105 from solar, an estimate.
var fromSum = graph.Links.Single(l => l.From == $"m{sumSolar}" && l.To == $"m{house}");
Assert.Equal(40.0 * 30 / 105, fromSum.Value, 1);
Assert.True(fromSum.IsEstimated);
// No spurious remainder under Solar 1/2 (their whole output is in Sum Solar).
Assert.DoesNotContain(graph.Nodes, n => n.IsOther && n.Id == $"other{solar1}");
// The top-level throughput is the roots: grid and the three solar meters — never the sum on top of its sources.
Assert.Equal(75 + 15 + 15 + 5, graph.Total, 1);
}
[Fact]
public async Task A_legacy_virtual_meter_is_its_implied_sum_until_confirmed()
{
var type = await TypeAsync();
var solar1 = await MeterAsync(type, "Solar 1", 15, MeterMode.GenerationCounter);
var solar2 = await MeterAsync(type, "Solar 2", 20, MeterMode.GenerationCounter);
var legacy = await VirtualAsync(type, "Legacy sum", expression: null, QuantityKind.Generation, VirtualCostRule.SourceCosts);
await LinkAsync(solar1, legacy);
await LinkAsync(solar2, legacy);
var graph = await new FlowService(fx).GetFlowAsync(type, Year, NextYear);
var entry = graph.MeterFor(legacy)!;
Assert.Equal(SeriesBasis.LegacyVirtual, entry.Basis);
Assert.Equal(35, entry.Value!.Value, 6);
Assert.Equal(ValueIssue.LegacyDefinition, entry.Issue);
Assert.Equal(2, graph.Links.Count(l => l.To == $"m{legacy}" && l.IsCalculated));
Assert.Contains(graph.Problems, p => p.Kind == AnalysisProblemKind.LegacyDefinition && p.MeterId == legacy);
}
[Fact]
public async Task A_virtual_meter_that_is_not_a_sum_is_only_in_the_table()
{
var type = await TypeAsync();
var a = await MeterAsync(type, "A", 100, MeterMode.GenerationCounter);
var b = await MeterAsync(type, "B", 150, MeterMode.GenerationCounter);
var difference = await VirtualAsync(type, "A minus B", $"m{a} - m{b}", QuantityKind.Generation, VirtualCostRule.None);
var house = await MeterAsync(type, "House", 40);
await LinkAsync(a, difference);
await LinkAsync(difference, house);
var graph = await new FlowService(fx).GetFlowAsync(type, Year, NextYear);
// Signed, from its formula — not the flow's old sum of its links (100) — and never drawn.
var entry = graph.MeterFor(difference)!;
Assert.Equal(-50, entry.Value!.Value, 6);
Assert.Equal(BucketStatus.Available, entry.Status);
Assert.False(entry.InDiagram);
Assert.DoesNotContain(graph.Nodes, n => n.MeterId == difference);
Assert.DoesNotContain(graph.Links, l => l.From == $"m{difference}" || l.To == $"m{difference}");
// Its sources and the house are still meters of the diagram.
Assert.True(graph.MeterFor(a)!.InDiagram);
Assert.Equal(40, graph.MeterFor(house)!.Value!.Value, 6);
}
[Fact]
public async Task A_link_never_carries_more_than_its_parent_measured()
{
var type = await TypeAsync();
var parent = await MeterAsync(type, "Parent", 50);
var child = await MeterAsync(type, "Child", 80);
await LinkAsync(parent, child);
var graph = await new FlowService(fx).GetFlowAsync(type, Year, NextYear);
var link = Assert.Single(graph.Links);
Assert.Equal(50, link.Value, 6);
Assert.True(link.IsCapped);
Assert.True(link.IsEstimated);
Assert.DoesNotContain(graph.Nodes, n => n.IsOther);
// The child's own total is untouched: capping is a drawing rule, not a correction.
Assert.Equal(80, graph.MeterFor(child)!.Value!.Value, 6);
Assert.Equal(80, graph.Nodes.Single(n => n.MeterId == child).Value, 6);
}
[Fact]
public async Task A_sub_meter_without_data_is_not_a_zero()
{
var type = await TypeAsync();
var main = await MeterAsync(type, "Main", 100);
var silent = await MeterAsync(type, "Silent", amount: null);
await LinkAsync(main, silent);
var graph = await new FlowService(fx).GetFlowAsync(type, Year, NextYear);
var entry = graph.MeterFor(silent)!;
Assert.Null(entry.Value);
Assert.Equal(BucketStatus.Missing, entry.Status);
// It keeps its place in the topology, drawn at zero and saying why, but nothing flows to it and the main meter's
// total does not turn into "Other": what the silent meter used is unknown, not zero.
Assert.Equal(BucketStatus.Missing, graph.Nodes.Single(n => n.MeterId == silent).Status);
Assert.Empty(graph.Links);
Assert.DoesNotContain(graph.Nodes, n => n.IsOther);
}
[Fact]
public async Task Meters_in_another_unit_or_type_stay_out_of_the_diagram()
{
var type = await TypeAsync();
var otherType = await TypeAsync("m3");
var main = await MeterAsync(type, "Main", 100);
var water = await MeterAsync(type, "Stray water meter", 7, unit: "m3");
var foreign = await MeterAsync(otherType, "Foreign", 12, unit: "m3");
await LinkAsync(main, water);
await LinkAsync(foreign, main);
var graph = await new FlowService(fx).GetFlowAsync(type, Year, NextYear);
// A flow never adds units: the m³ meter is in the table, with its own number and unit, but not in the diagram.
var entry = graph.MeterFor(water)!;
Assert.False(entry.InDiagram);
Assert.Equal(7, entry.Value!.Value, 6);
Assert.Equal("kWh", graph.Unit);
Assert.DoesNotContain(graph.Nodes, n => n.MeterId == water);
// A meter of another type is not part of this flow at all.
Assert.Null(graph.MeterFor(foreign));
Assert.DoesNotContain(graph.Nodes, n => n.MeterId == foreign);
Assert.Empty(graph.Links);
Assert.Equal(100, graph.Total, 6);
}
[Fact]
public async Task A_range_reaching_past_now_counts_actuals_only()
{
var type = await TypeAsync();
var main = await MeterAsync(type, "Main", 100);
// In July 2024 the reading that closes 2024 lies in the future: nothing of it is an actual yet (D-04).
var july = new DateTimeOffset(2024, 7, 1, 0, 0, 0, TimeSpan.Zero);
var graph = await new FlowService(fx, time: new FixedTimeProvider(july)).GetFlowAsync(type, Year, NextYear);
Assert.Null(graph.MeterFor(main)!.Value);
Assert.Contains(graph.Problems, p => p.Kind == AnalysisProblemKind.RecordedAfterNow && p.MeterId == main);
Assert.Equal(0, graph.Total, 6);
}
// ------------------------------------------------------------------------------------------------ helpers
private async Task TypeAsync(string unit = "kWh")
{
await using var db = fx.CreateContext();
var type = new EnergyType { Key = $"flow-{Guid.NewGuid():N}", DisplayName = "Flow test", BaseUnit = unit, DefaultMode = MeterMode.CumulativeCounter };
db.EnergyTypes.Add(type);
await db.SaveChangesAsync();
_types.Add(type.Id);
return type.Id;
}
/// A counter installed on 1 January 2024 whose one reading at the start of 2025 books over 2024; none without an amount.
private async Task MeterAsync(short type, string name, double? amount, MeterMode mode = MeterMode.CumulativeCounter, string unit = "kWh")
{
await using var db = fx.CreateContext();
var meter = new Meter { Name = name, EnergyTypeId = type, Mode = mode, Unit = unit, InstalledAt = Year };
db.Meters.Add(meter);
await db.SaveChangesAsync();
_meters.Add(meter.Id);
if (amount is { } value)
{
db.Readings.Add(new Reading
{
MeterId = meter.Id,
Time = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
Value = value,
Quality = ReadingQuality.Manual,
});
await db.SaveChangesAsync();
}
// Also without readings: the rollup state says the (empty) analysis data is current, not being prepared.
await new NormalizationService(db, NormalizationEngine.CreateDefault()).RecomputeMeterAsync(meter.Id, null);
await db.SaveChangesAsync();
return meter.Id;
}
/// A virtual meter with the given formula; without one, a legacy meter (Meta "{}").
private async Task VirtualAsync(short type, string name, string? expression, QuantityKind kind, VirtualCostRule rule)
{
await using var db = fx.CreateContext();
var meta = expression is null ? "{}" : VirtualDefinitionJson.Write("{}", new VirtualDefinition(expression, kind, "kWh", rule));
var meter = new Meter { Name = name, EnergyTypeId = type, Mode = MeterMode.Virtual, Unit = "kWh", Meta = meta };
db.Meters.Add(meter);
await db.SaveChangesAsync();
_meters.Add(meter.Id);
return meter.Id;
}
private async Task LinkAsync(int from, int to)
{
await using var db = fx.CreateContext();
db.MeterLinks.Add(new MeterLink { FromMeterId = from, ToMeterId = to });
await db.SaveChangesAsync();
}
}