Files
MeterVault/tests/Integration.Tests/FlowServiceTests.cs
T
schmidt.florian ecadbe1477
ci / build-test (push) Successful in 1m14s
Flow: count generation meters as sources (grid + solar → house)
A meter's flow value is now its throughput — consumption OR generation output —
so a generation meter (solar) acts as a source that can feed downstream meters.
Setting a load meter's upstream to {grid, solar} now splits its consumption
across both proportionally, and the remainder under the sources (grid + solar −
load) surfaces as "Other" = export + battery/inverter losses. Negative values
(savings/balance virtuals) are clamped to 0 (a ribbon can't be negative). The
per-type KPI is relabelled "Top-level throughput" since it now spans generation.

Test: Generation_meter_counts_as_source (grid 75 + solar-gen 30 → house 40 →
28.57/11.43 split, 65 remainder). 69 Core + 48 Integration = 117 green.
Live-verified: electricity flow now shows Solar 1/2 as source nodes.

Claude-Session: https://claude.ai/code/session_01Lz2RqAsnQhetqWNoCDfexK
2026-07-14 14:33:29 +02:00

144 lines
6.0 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.Infrastructure.Dashboard;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Integration.Tests;
/// <summary>
/// The per-energy-type flow graph (Sankey): a single-parent chain attributes the child's full
/// consumption to its parent and shows the remainder as "Other"; a two-parent merge splits the
/// child's consumption proportionally to the parents' own consumption.
/// </summary>
[Collection("Timescale")]
public sealed class FlowServiceTests(TimescaleFixture fx)
{
[Fact]
public async Task Single_parent_chain_makes_other_remainder()
{
await using var db = fx.CreateContext();
try
{
var type = await SeedTypeAsync(db, "flow_elec_a");
var main = await AddMeterAsync(db, "Main", type);
var car = await AddMeterAsync(db, "Car", type);
db.MeterLinks.Add(new MeterLink { FromMeterId = main.Id, ToMeterId = car.Id });
await db.SaveChangesAsync();
await AddConsumptionAsync(db, main.Id, 100);
await AddConsumptionAsync(db, car.Id, 30);
var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31));
Assert.Equal(100, graph.Total, 1);
var link = Assert.Single(graph.Links, l => l.To == $"m{car.Id}");
Assert.Equal(30, link.Value, 1); // full child consumption flows from its single parent
var other = Assert.Single(graph.Nodes, n => n.IsOther);
Assert.Equal(70, other.Value, 1); // 100 30
}
finally
{
await ClearAsync(db);
}
}
[Fact]
public async Task Two_parents_split_child_proportionally()
{
await using var db = fx.CreateContext();
try
{
var type = await SeedTypeAsync(db, "flow_elec_b");
var grid = await AddMeterAsync(db, "Grid", type);
var solar = await AddMeterAsync(db, "Solar draw", type);
var house = await AddMeterAsync(db, "House", type);
db.MeterLinks.Add(new MeterLink { FromMeterId = grid.Id, ToMeterId = house.Id });
db.MeterLinks.Add(new MeterLink { FromMeterId = solar.Id, ToMeterId = house.Id });
await db.SaveChangesAsync();
await AddConsumptionAsync(db, grid.Id, 75);
await AddConsumptionAsync(db, solar.Id, 25);
await AddConsumptionAsync(db, house.Id, 40);
var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31));
// House (40) splits 75:25 → 30 from grid, 10 from solar.
Assert.Equal(30, graph.Links.Single(l => l.From == $"m{grid.Id}" && l.To == $"m{house.Id}").Value, 1);
Assert.Equal(10, graph.Links.Single(l => l.From == $"m{solar.Id}" && l.To == $"m{house.Id}").Value, 1);
}
finally
{
await ClearAsync(db);
}
}
[Fact]
public async Task Generation_meter_counts_as_source()
{
await using var db = fx.CreateContext();
try
{
var type = await SeedTypeAsync(db, "flow_elec_c");
var grid = await AddMeterAsync(db, "Grid", type);
var solar = await AddMeterAsync(db, "Solar", type);
var house = await AddMeterAsync(db, "House", type);
db.MeterLinks.Add(new MeterLink { FromMeterId = grid.Id, ToMeterId = house.Id });
db.MeterLinks.Add(new MeterLink { FromMeterId = solar.Id, ToMeterId = house.Id });
await db.SaveChangesAsync();
await AddConsumptionAsync(db, grid.Id, 75); // grid import
await AddConsumptionAsync(db, solar.Id, 30, ConsumptionKind.Generation); // solar generation
await AddConsumptionAsync(db, house.Id, 40); // house load
var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31));
// 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.Id}" && l.To == $"m{house.Id}").Value, 1);
Assert.Equal(40.0 * 30 / 105, graph.Links.Single(l => l.From == $"m{solar.Id}" && l.To == $"m{house.Id}").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);
}
finally
{
await ClearAsync(db);
}
}
private static async Task<short> SeedTypeAsync(MeterVaultDbContext db, string key)
{
var type = new EnergyType { Key = key, DisplayName = key, BaseUnit = "kWh", DefaultMode = MeterMode.CumulativeCounter };
db.EnergyTypes.Add(type);
await db.SaveChangesAsync();
return type.Id;
}
private static async Task<Meter> AddMeterAsync(MeterVaultDbContext db, string name, short type)
{
var meter = new Meter { Name = name, EnergyTypeId = type, Mode = MeterMode.DirectDelta, Unit = "kWh" };
db.Meters.Add(meter);
await db.SaveChangesAsync();
return meter;
}
private static async Task AddConsumptionAsync(MeterVaultDbContext db, int meterId, double amount, ConsumptionKind kind = ConsumptionKind.Consumption)
{
db.Consumption.Add(new Consumption
{
MeterId = meterId,
Time = new DateTimeOffset(2024, 6, 15, 0, 0, 0, TimeSpan.Zero),
Amount = amount,
Kind = kind,
Quality = ReadingQuality.Manual,
});
await db.SaveChangesAsync();
}
private static async Task ClearAsync(MeterVaultDbContext db)
{
await db.MeterLinks.ExecuteDeleteAsync();
await db.Consumption.ExecuteDeleteAsync();
await db.Meters.ExecuteDeleteAsync();
await db.EnergyTypes.Where(t => t.Key.StartsWith("flow_elec_")).ExecuteDeleteAsync();
}
}