using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Normalization;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Infrastructure.Import;
///
/// Loads the four bundled Energiebilanz 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 . Idempotent — a marker meter guards reruns.
///
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 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);
}
/// Kosten profile that imports only the Heizung column — Strom/Wasser are metered.
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];
/// 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.
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 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 EnergyTypeIdAsync(string key, CancellationToken cancellationToken) =>
(await _db.EnergyTypes.FirstAsync(t => t.Key == key, cancellationToken).ConfigureAwait(false)).Id;
}