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