Flow: count generation meters as sources (grid + solar → house)
ci / build-test (push) Successful in 1m14s
ci / build-test (push) Successful in 1m14s
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
This commit is contained in:
@@ -33,7 +33,7 @@ else
|
|||||||
<MudGrid Class="mb-2">
|
<MudGrid Class="mb-2">
|
||||||
<MudItem xs="12" sm="4">
|
<MudItem xs="12" sm="4">
|
||||||
<MudPaper Class="pa-4" Elevation="2">
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Top-level consumption</MudText>
|
<MudText Typo="Typo.overline" Color="Color.Secondary">Top-level throughput</MudText>
|
||||||
<MudText Typo="Typo.h5">@Format.Number(_graph.Total, 0) @_graph.Unit</MudText>
|
<MudText Typo="Typo.h5">@Format.Number(_graph.Total, 0) @_graph.Unit</MudText>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
|
|||||||
@@ -33,12 +33,16 @@ public sealed class FlowService(IDbContextFactory<MeterVaultDbContext> contextFa
|
|||||||
var fromUtc = ToUtc(from);
|
var fromUtc = ToUtc(from);
|
||||||
var toUtc = ToUtc(to);
|
var toUtc = ToUtc(to);
|
||||||
|
|
||||||
|
// A meter's flow value is its throughput: consumption OR generation output — so a generation
|
||||||
|
// meter (solar) can act as a source feeding downstream meters (grid + solar → house). A meter
|
||||||
|
// is normally one kind, so summing both kinds is that meter's flow. Negatives (savings/balance
|
||||||
|
// virtual meters) are clamped to 0 — a flow ribbon can't be negative.
|
||||||
var sums = await db.Consumption.AsNoTracking()
|
var sums = await db.Consumption.AsNoTracking()
|
||||||
.Where(c => c.Time >= fromUtc && c.Time < toUtc && c.Kind == ConsumptionKind.Consumption)
|
.Where(c => c.Time >= fromUtc && c.Time < toUtc)
|
||||||
.GroupBy(c => c.MeterId)
|
.GroupBy(c => c.MeterId)
|
||||||
.Select(g => new { MeterId = g.Key, Total = g.Sum(x => x.Amount) })
|
.Select(g => new { MeterId = g.Key, Total = g.Sum(x => x.Amount) })
|
||||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
var value = sums.Where(s => meterIds.Contains(s.MeterId)).ToDictionary(s => s.MeterId, s => s.Total);
|
var value = sums.Where(s => meterIds.Contains(s.MeterId)).ToDictionary(s => s.MeterId, s => Math.Max(0, s.Total));
|
||||||
double V(int id) => value.GetValueOrDefault(id);
|
double V(int id) => value.GetValueOrDefault(id);
|
||||||
|
|
||||||
var links = await db.MeterLinks.AsNoTracking()
|
var links = await db.MeterLinks.AsNoTracking()
|
||||||
|
|||||||
@@ -72,6 +72,38 @@ public sealed class FlowServiceTests(TimescaleFixture fx)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[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)
|
private static async Task<short> SeedTypeAsync(MeterVaultDbContext db, string key)
|
||||||
{
|
{
|
||||||
var type = new EnergyType { Key = key, DisplayName = key, BaseUnit = "kWh", DefaultMode = MeterMode.CumulativeCounter };
|
var type = new EnergyType { Key = key, DisplayName = key, BaseUnit = "kWh", DefaultMode = MeterMode.CumulativeCounter };
|
||||||
@@ -88,14 +120,14 @@ public sealed class FlowServiceTests(TimescaleFixture fx)
|
|||||||
return meter;
|
return meter;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task AddConsumptionAsync(MeterVaultDbContext db, int meterId, double amount)
|
private static async Task AddConsumptionAsync(MeterVaultDbContext db, int meterId, double amount, ConsumptionKind kind = ConsumptionKind.Consumption)
|
||||||
{
|
{
|
||||||
db.Consumption.Add(new Consumption
|
db.Consumption.Add(new Consumption
|
||||||
{
|
{
|
||||||
MeterId = meterId,
|
MeterId = meterId,
|
||||||
Time = new DateTimeOffset(2024, 6, 15, 0, 0, 0, TimeSpan.Zero),
|
Time = new DateTimeOffset(2024, 6, 15, 0, 0, 0, TimeSpan.Zero),
|
||||||
Amount = amount,
|
Amount = amount,
|
||||||
Kind = ConsumptionKind.Consumption,
|
Kind = kind,
|
||||||
Quality = ReadingQuality.Manual,
|
Quality = ReadingQuality.Manual,
|
||||||
});
|
});
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|||||||
Reference in New Issue
Block a user