ci / build-test (push) Successful in 1m14s
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
201 lines
11 KiB
C#
201 lines
11 KiB
C#
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;
|
||
}
|