using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Costing; using MeterVault.Core.Analysis.Virtual; using MeterVault.Core.Domain; using MeterVault.Core.Normalization; using MeterVault.Infrastructure.Analysis; using MeterVault.Infrastructure.Costing; using MeterVault.Infrastructure.Normalization; using MeterVault.Infrastructure.Options; using MeterVault.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; namespace MeterVault.Integration.Tests.Costing; /// /// Builds what a cost test prices — its own energy types, meters with readings normalized in Berlin, tariffs, manual /// costs and categories — on a frozen clock of 19 September 2026, 14:37 Berlin, and removes all of it again. /// internal sealed class CostSandbox(TimescaleFixture fx) : IAsyncDisposable { public const string BerlinId = "Europe/Berlin"; public static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById(BerlinId); /// The frozen "now" of every request (D-01), after the reference data ends (31 May 2026). public static readonly DateTimeOffset Now = new DateTimeOffset(2026, 9, 19, 14, 37, 0, TimeSpan.FromHours(2)).ToUniversalTime(); private readonly List _meters = []; private readonly List _types = []; private readonly List _tariffs = []; private readonly List _manualCosts = []; private readonly List _categories = []; public TimescaleFixture Fixture => fx; public static ResolvedPeriod Custom(DateOnly first, DateOnly last) => PeriodResolver.Resolve(PeriodPreset.Custom, first, last, Now, Berlin); public static ResolvedPeriod Year(int year) => Custom(new DateOnly(year, 1, 1), new DateOnly(year, 12, 31)); public static ResolvedPeriod Month(int year, int month) => Custom(new DateOnly(year, month, 1), new DateOnly(year, month, DateTime.DaysInMonth(year, month))); public static ResolvedPeriod Preset(PeriodPreset preset) => PeriodResolver.Resolve(preset, null, null, Now, Berlin); public static DateTimeOffset Midnight(int year, int month, int day) => GapAttribution.LocalMidnight(new DateOnly(year, month, day), Berlin); public static DateOnly D(int year, int month, int day) => new(year, month, day); public CostReader Reader(string currency = "EUR") { var options = Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = BerlinId, Currency = currency }); return new CostReader(fx, new AnalysisReader(fx, options), options); } public async Task TypeAsync(string unit = "kWh") { await using var db = fx.CreateContext(); var type = new EnergyType { Key = $"cost-{Guid.NewGuid():N}", DisplayName = "Cost test", BaseUnit = unit, DefaultMode = MeterMode.CumulativeCounter, }; db.EnergyTypes.Add(type); await db.SaveChangesAsync(); _types.Add(type.Id); return type.Id; } public async Task MeterAsync( short type, MeterMode mode, string unit, DateOnly? installedAt = null, string meta = "{}", DateOnly? retiredAt = null, string? name = null) { await using var db = fx.CreateContext(); var meter = new Meter { Name = name ?? $"cost-{Guid.NewGuid():N}", EnergyTypeId = type, Mode = mode, Unit = unit, InstalledAt = installedAt, RetiredAt = retiredAt, Meta = meta, }; db.Meters.Add(meter); await db.SaveChangesAsync(); _meters.Add(meter.Id); return meter.Id; } /// /// A counter installed on the 1st of whose consecutive local months book the given /// amounts: one reading at each following local midnight of the 1st. /// public async Task MonthlyAsync(short type, MeterMode mode, DateOnly firstMonth, params double[] months) { var meter = await MeterAsync(type, mode, "kWh", installedAt: firstMonth); await MonthlyReadingsAsync(meter, firstMonth, months); return meter; } /// Monthly readings on an existing meter, as writes them. public async Task MonthlyReadingsAsync(int meter, DateOnly firstMonth, params double[] months) { var register = 0d; var readings = new List<(DateTimeOffset, double)>(); for (var i = 0; i < months.Length; i++) { register += months[i]; var next = firstMonth.AddMonths(i + 1); readings.Add((Midnight(next.Year, next.Month, 1), register)); } await ReadingsAsync(meter, [.. readings]); } /// A counter installed on that rises by at every local midnight up to . public async Task DailyAsync(short type, DateOnly from, DateOnly to, double perDay) { var meter = await MeterAsync(type, MeterMode.CumulativeCounter, "kWh", installedAt: from); var readings = new List<(DateTimeOffset, double)>(); var register = 0d; for (var day = from.AddDays(1); day <= to; day = day.AddDays(1)) { register += perDay; readings.Add((Midnight(day.Year, day.Month, day.Day), register)); } await ReadingsAsync(meter, [.. readings]); return meter; } public async Task VirtualAsync(short type, string expression, QuantityKind kind, string unit, VirtualCostRule rule, string meta = "{}") { meta = VirtualDefinitionJson.Write(meta, new VirtualDefinition(expression, kind, unit, rule)); var meter = await MeterAsync(type, MeterMode.Virtual, unit, meta: meta); await RecomputeAsync(meter); return meter; } public async Task LinkAsync(int from, int to) { await using var db = fx.CreateContext(); db.MeterLinks.Add(new MeterLink { FromMeterId = from, ToMeterId = to }); await db.SaveChangesAsync(); } public async Task TariffAsync( TariffScope scope, int? scopeId, TariffComponent component, double value, string unit, DateOnly from, DateOnly? to = null) { await using var db = fx.CreateContext(); var tariff = new Tariff { ScopeType = scope, ScopeId = scopeId, Component = component, Value = value, Unit = unit, ValidFrom = from, ValidTo = to, }; db.Tariffs.Add(tariff); await db.SaveChangesAsync(); _tariffs.Add(tariff.Id); return tariff.Id; } public Task TypePriceAsync(short type, double value, DateOnly from, string unit = "EUR/kWh", DateOnly? to = null) => TariffAsync(TariffScope.EnergyType, type, TariffComponent.UnitPrice, value, unit, from, to); public Task MeterPriceAsync(int meter, double value, DateOnly from, string unit = "EUR/kWh") => TariffAsync(TariffScope.Meter, meter, TariffComponent.UnitPrice, value, unit, from); public async Task ManualCostAsync(DateOnly start, double amount, int? meterId = null, int? categoryId = null, string currency = "EUR") { await using var db = fx.CreateContext(); var cost = new ManualCost { MeterId = meterId, CategoryId = categoryId, PeriodStart = start, PeriodEnd = start.AddMonths(1).AddDays(-1), Amount = amount, Currency = currency, }; db.ManualCosts.Add(cost); await db.SaveChangesAsync(); _manualCosts.Add(cost.Id); return cost.Id; } public async Task CategoryAsync(string name, int sort, int[]? meters = null, short[]? types = null) { await using var db = fx.CreateContext(); var category = new CostCategory { Name = name, Sort = sort }; foreach (var meter in meters ?? []) { category.Members.Add(new CostCategoryMember { MeterId = meter }); } foreach (var type in types ?? []) { category.Members.Add(new CostCategoryMember { EnergyTypeId = type }); } db.CostCategories.Add(category); await db.SaveChangesAsync(); _categories.Add(category.Id); return category.Id; } public async Task ReadingsAsync(int meterId, params (DateTimeOffset Time, double Value)[] readings) { await using (var db = fx.CreateContext()) { db.Readings.AddRange(readings.Select(r => new Reading { MeterId = meterId, Time = r.Time.ToUniversalTime(), Value = r.Value, Quality = ReadingQuality.Manual, })); await db.SaveChangesAsync(); } await RecomputeAsync(meterId); } public async Task RecomputeAsync(params int[] meterIds) { await using var db = fx.CreateContext(); await using var tx = await db.Database.BeginTransactionAsync(); foreach (var id in meterIds) { await Normalization(db).RecomputeMeterAsync(id, null); } await db.SaveChangesAsync(); await tx.CommitAsync(); } public static NormalizationService Normalization(MeterVaultDbContext db) => new(db, NormalizationEngine.CreateDefault(), Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = BerlinId }), new FixedTimeProvider(Now)); public async ValueTask DisposeAsync() { await using var db = fx.CreateContext(); var ids = _meters.ToArray(); await db.ManualCosts.Where(c => _manualCosts.Contains(c.Id)).ExecuteDeleteAsync(); await db.CostCategories.Where(c => _categories.Contains(c.Id)).ExecuteDeleteAsync(); await db.Tariffs.Where(t => _tariffs.Contains(t.Id)).ExecuteDeleteAsync(); await db.MeterLinks.Where(l => ids.Contains(l.FromMeterId) || ids.Contains(l.ToMeterId)).ExecuteDeleteAsync(); await db.Consumption.Where(c => ids.Contains(c.MeterId)).ExecuteDeleteAsync(); await db.Readings.Where(r => ids.Contains(r.MeterId)).ExecuteDeleteAsync(); await db.MeterEvents.Where(e => ids.Contains(e.MeterId)).ExecuteDeleteAsync(); await db.Meters.Where(m => ids.Contains(m.Id)).ExecuteDeleteAsync(); var types = _types.ToArray(); await db.EnergyTypes.Where(t => types.Contains(t.Id)).ExecuteDeleteAsync(); } } /// Assertions on cost figures. internal static class CostAssert { public static void Cost(double expected, CostAmount amount, int precision = 6) { Assert.NotNull(amount.Cost); Assert.Equal(expected, amount.Cost!.Value, precision); } /// Priced, with a value, and nothing unavailable in it (not-priced components may have been left out). public static void Priced(double expected, CostAmount amount, double tolerance = 1e-6) { Assert.Equal(CostStatus.Priced, amount.Status); Assert.NotNull(amount.Cost); Assert.InRange(amount.Cost!.Value, expected - tolerance, expected + tolerance); Assert.DoesNotContain(amount.MissingPrices, m => m.Reason is CostStatus.PriceGap or CostStatus.UnitMismatch); } }