Files
MeterVault/src/Infrastructure/Import/ReferenceDataImporter.cs
T
schmidt.florian 92438348e7
ci / build-test (push) Successful in 1m14s
Flow: virtual "sum" meters that aggregate upstreams + demo chain
Adds the "sum meter" the user asked for: a meter that doesn't physically exist
but represents the sum of other meters in the flow view.

- FlowService: a Virtual-mode meter has no readings of its own; its flow value is
  the sum of its upstream meters, resolved in topological order (so "Summe Solar"
  = Solar 1 + Solar 2, and Grid + Summe Solar → House with the remainder as
  "Other" = export / battery / inverter losses).
- Meters editor: a hint when Mode = Virtual explaining the sum-meter behaviour.
- Reference data seeds the full demo chain: Solar 1 + Solar 2 → Summe Solar;
  Netz + Summe Solar → Haus → Auto + Other — so /energy/1 shows a multi-level
  Sankey out of the box.
- Fix: SankeyChart Unit was passed as the literal string "_graph.Unit" (missing
  @) so node labels read "_graph.Unit" instead of "kWh". Caught by screenshotting
  the live page.

Test: Virtual_sum_meter_aggregates_its_upstreams. 69 Core + 50 Integration =
119 green. Live-verified with a browser screenshot of the multi-level flow.

Claude-Session: https://claude.ai/code/session_01Lz2RqAsnQhetqWNoCDfexK
2026-07-14 15:48:50 +02:00

201 lines
11 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.Normalization;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Infrastructure.Import;
/// <summary>
/// Loads the four bundled <em>Energiebilanz</em> sheets as a ready-made demo/starter dataset:
/// creates the reference meters, tank, tariff history and category memberships, then imports each
/// sheet through the normal <see cref="ImportService"/>. Idempotent — a marker meter guards reruns.
/// </summary>
public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService importService, CsvImporter csvImporter)
{
public const string ElectricityFile = "Energiebilanz - Strom Verbrauch.csv";
public const string WaterFile = "Energiebilanz - Wasser.csv";
public const string OilFile = "Energiebilanz - Heizöl Verbrauch.csv";
public const string CostsFile = "Energiebilanz - Kosten.csv";
private const string MarkerName = "Zähler Haus";
private readonly MeterVaultDbContext _db = db;
private readonly ImportService _importService = importService;
private readonly CsvImporter _csvImporter = csvImporter;
public async Task<bool> IsLoadedAsync(CancellationToken cancellationToken = default) =>
await _db.Meters.AnyAsync(m => m.Name == MarkerName, cancellationToken).ConfigureAwait(false);
public async Task LoadAsync(string sampleDataDirectory, CancellationToken cancellationToken = default)
{
if (await IsLoadedAsync(cancellationToken).ConfigureAwait(false))
{
return;
}
// Fail fast BEFORE creating the marker meter: if the CSVs are missing (e.g. not shipped in
// the image) we must not seed a half-loaded dataset that IsLoadedAsync then reports as done.
EnsureSampleFilesPresent(sampleDataDirectory);
await DatabaseSeeder.SeedAsync(_db, cancellationToken).ConfigureAwait(false);
var electricity = await EnergyTypeIdAsync("electricity", cancellationToken).ConfigureAwait(false);
var water = await EnergyTypeIdAsync("water", cancellationToken).ConfigureAwait(false);
var oil = await EnergyTypeIdAsync("heating_oil", cancellationToken).ConfigureAwait(false);
var haus = Meter(MarkerName, electricity, MeterMode.CumulativeCounter, "kWh");
var netz = Meter("Zähler Netz", electricity, MeterMode.CumulativeCounter, "kWh");
var auto = Meter("Zähler Auto", electricity, MeterMode.CumulativeCounter, "kWh");
var solar1 = Meter("Zähler Solar 1", electricity, MeterMode.GenerationCounter, "kWh");
var solar2 = Meter("Zähler Solar 2", electricity, MeterMode.GenerationCounter, "kWh");
var wasser = Meter("Zähler Wasser", water, MeterMode.CumulativeCounter, "m3", initialBaseline: 820);
var oilTank = Meter("Öltank", oil, MeterMode.ConsumableBalance, "L");
var burner = Meter("Brenner", oil, MeterMode.RuntimeCounter, "h");
// A virtual "sum" meter: no readings of its own — in the flow view it equals Solar 1 + Solar 2.
var sumSolar = Meter("Summe Solar", electricity, MeterMode.Virtual, "kWh");
// Tag the PV meters' roles (config, not hardcoded names) so the Solar panel can derive
// self-consumption = total_load grid_import and savings generically (SDD §8.4).
haus.Meta = MeterMeta.WithRole(haus.Meta, MeterRoles.TotalLoad);
netz.Meta = MeterMeta.WithRole(netz.Meta, MeterRoles.GridImport);
_db.Meters.AddRange(haus, netz, auto, solar1, solar2, wasser, oilTank, burner, sumSolar);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
_db.Tanks.Add(new Tank
{
MeterId = oilTank.Id,
Capacity = 7000,
Unit = "L",
Calibration = $"{{\"volumePerUnit\":{ReferenceProfiles.OilLitresPerCm.ToString(System.Globalization.CultureInfo.InvariantCulture)}}}",
});
// Demo flow chain (electricity /energy view): Solar 1 + Solar 2 → Summe Solar; then
// Grid + Summe Solar → Haus → Auto + "Other". The remainder under Grid/Summe Solar is the
// input that didn't reach the house load (solar export / battery / inverter losses).
_db.MeterLinks.AddRange(
new MeterLink { FromMeterId = solar1.Id, ToMeterId = sumSolar.Id },
new MeterLink { FromMeterId = solar2.Id, ToMeterId = sumSolar.Id },
new MeterLink { FromMeterId = netz.Id, ToMeterId = haus.Id },
new MeterLink { FromMeterId = sumSolar.Id, ToMeterId = haus.Id },
new MeterLink { FromMeterId = haus.Id, ToMeterId = auto.Id });
AddElectricityTariffs(electricity);
AddTariff(TariffScope.EnergyType, water, 5.00, "EUR/m3", new DateOnly(2022, 11, 1));
// Strom and Wasser costs are computed from meters + tariffs; only Heizung comes from the
// Kosten sheet (oil has no tariff). Linking a meter AND importing its Kosten column into the
// same category would double-count, so we keep exactly one cost source per category.
await LinkStromAndWasserAsync(haus.Id, netz.Id, auto.Id, solar1.Id, solar2.Id, wasser.Id, cancellationToken).ConfigureAwait(false);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
var meterIds = new ReferenceMeterIds(haus.Id, netz.Id, auto.Id, solar1.Id, solar2.Id, wasser.Id, oilTank.Id, burner.Id);
var categoryIds = await CategoryIdsAsync(cancellationToken).ConfigureAwait(false);
await ImportSheetAsync(sampleDataDirectory, ElectricityFile, ReferenceProfiles.Electricity(meterIds), cancellationToken).ConfigureAwait(false);
await ImportSheetAsync(sampleDataDirectory, WaterFile, ReferenceProfiles.Water(meterIds), cancellationToken).ConfigureAwait(false);
await ImportSheetAsync(sampleDataDirectory, OilFile, ReferenceProfiles.HeatingOil(meterIds), cancellationToken).ConfigureAwait(false);
await ImportSheetAsync(sampleDataDirectory, CostsFile, HeizungCostsProfile(categoryIds.Heizung), cancellationToken).ConfigureAwait(false);
}
/// <summary>Kosten profile that imports only the Heizung column — Strom/Wasser are metered.</summary>
private static MappingProfile HeizungCostsProfile(int heizungCategoryId) => new()
{
Name = "Energiebilanz — Kosten (Heizung)",
DateColumn = 0,
DateKind = DateKind.MonthName,
FirstDataRowIndex = 1,
Columns = [new ColumnMapping { Index = 3, Role = MappingRole.ManualCost, CategoryId = heizungCategoryId }],
};
private static readonly string[] RequiredFiles = [ElectricityFile, WaterFile, OilFile, CostsFile];
/// <summary>Throws a clear error if the sample directory or any reference CSV is missing, so a
/// failed load surfaces to the user instead of silently seeding meters with no data.</summary>
private static void EnsureSampleFilesPresent(string sampleDataDirectory)
{
if (!Directory.Exists(sampleDataDirectory))
{
throw new DirectoryNotFoundException(
$"Reference-data directory not found: '{sampleDataDirectory}'. The bundled Energiebilanz CSVs are missing from this deployment.");
}
var missing = RequiredFiles.Where(f => !File.Exists(Path.Combine(sampleDataDirectory, f))).ToList();
if (missing.Count > 0)
{
throw new FileNotFoundException(
$"Reference CSV(s) missing from '{sampleDataDirectory}': {string.Join(", ", missing)}.");
}
}
private async Task ImportSheetAsync(string dir, string file, MappingProfile profile, CancellationToken cancellationToken)
{
var path = Path.Combine(dir, file);
if (!File.Exists(path))
{
throw new FileNotFoundException($"Reference CSV disappeared during import: '{path}'.", path);
}
StagedImport staged;
using (var reader = new StreamReader(path))
{
staged = _csvImporter.Stage(profile, reader);
}
var mappingJson = System.Text.Json.JsonSerializer.Serialize(new { profile = profile.Name });
await _importService.CommitAsync(staged, file, mappingJson, cancellationToken).ConfigureAwait(false);
}
private static Meter Meter(string name, short energyTypeId, MeterMode mode, string unit, double initialBaseline = 0) => new()
{
Name = name,
EnergyTypeId = energyTypeId,
Mode = mode,
Unit = unit,
InitialBaseline = initialBaseline,
};
private void AddElectricityTariffs(short energyTypeId)
{
AddTariff(TariffScope.EnergyType, energyTypeId, 0.16, "EUR/kWh", new DateOnly(2022, 9, 1));
AddTariff(TariffScope.EnergyType, energyTypeId, 0.44, "EUR/kWh", new DateOnly(2023, 1, 1));
AddTariff(TariffScope.EnergyType, energyTypeId, 0.37, "EUR/kWh", new DateOnly(2023, 5, 1));
AddTariff(TariffScope.EnergyType, energyTypeId, 0.27, "EUR/kWh", new DateOnly(2023, 11, 1));
AddTariff(TariffScope.EnergyType, energyTypeId, 0.36, "EUR/kWh", new DateOnly(2025, 1, 1));
AddTariff(TariffScope.EnergyType, energyTypeId, 0.27, "EUR/kWh", new DateOnly(2026, 1, 1));
}
private void AddTariff(TariffScope scope, int scopeId, double value, string unit, DateOnly validFrom) =>
_db.Tariffs.Add(new Tariff
{
ScopeType = scope,
ScopeId = scopeId,
Component = TariffComponent.UnitPrice,
Value = value,
Unit = unit,
ValidFrom = validFrom,
});
private async Task LinkStromAndWasserAsync(
int haus, int netz, int auto, int solar1, int solar2, int wasser, CancellationToken cancellationToken)
{
var categories = await CategoryIdsAsync(cancellationToken).ConfigureAwait(false);
foreach (var meterId in new[] { haus, netz, auto, solar1, solar2 })
{
_db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = categories.Strom, MeterId = meterId });
}
_db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = categories.Wasser, MeterId = wasser });
// Heizung (oil) cost comes from the imported Kosten column, not a meter — no link (avoids double-count).
}
private async Task<ReferenceCategoryIds> CategoryIdsAsync(CancellationToken cancellationToken)
{
var categories = await _db.CostCategories.ToListAsync(cancellationToken).ConfigureAwait(false);
int Find(string name) => categories.FirstOrDefault(c => c.Name == name)?.Id ?? 0;
return new ReferenceCategoryIds(Find("Heizung"), Find("Strom"), Find("Wasser"), Find("Pool Betrieb"));
}
private async Task<short> EnergyTypeIdAsync(string key, CancellationToken cancellationToken) =>
(await _db.EnergyTypes.FirstAsync(t => t.Key == key, cancellationToken).ConfigureAwait(false)).Id;
}