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; }