using Dapper; using MeterVault.Core.Costing; using MeterVault.Core.Domain; using MeterVault.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; namespace MeterVault.Infrastructure.Dashboard; /// /// Read model for the PV / solar panel (SDD §8.4). Generation comes from every /// meter; self-consumption / autarky / savings are derived /// from the meters tagged and /// — so nothing is hardcoded by meter name. Reads only the aggregated consumption hypertable /// (monthly, Europe/Berlin) via Dapper; safe from a Blazor circuit via a DbContext factory. /// public sealed class SolarService(IDbContextFactory contextFactory) { private readonly IDbContextFactory _contextFactory = contextFactory; public async Task GetSummaryAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default) { await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); var meters = await db.Meters.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false); var generationMeters = meters.Where(m => m.Mode == MeterMode.GenerationCounter).ToList(); var loadMeter = meters.FirstOrDefault(m => MeterMeta.Role(m.Meta) == MeterRoles.TotalLoad); var gridMeter = meters.FirstOrDefault(m => MeterMeta.Role(m.Meta) == MeterRoles.GridImport); var fromUtc = ToUtc(from); var toUtc = ToUtc(to); // Monthly generation per generation meter. var genByMeter = new Dictionary>(); foreach (var meter in generationMeters) { genByMeter[meter.Id] = await MonthlyAsync(db, meter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false); } var loadByMonth = loadMeter is null ? null : await MonthlyAsync(db, loadMeter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false); var gridByMonth = gridMeter is null ? null : await MonthlyAsync(db, gridMeter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false); var tariffs = gridMeter is null ? [] : await db.Tariffs.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false); // Union of all months that carry any data. var periods = new SortedSet(); foreach (var series in genByMeter.Values) { periods.UnionWith(series.Keys); } if (loadByMonth is not null) { periods.UnionWith(loadByMonth.Keys); } var months = new List(); foreach (var period in periods) { var generation = genByMeter.Values.Sum(s => s.GetValueOrDefault(period)); double? load = loadByMonth?.GetValueOrDefault(period); double? grid = gridByMonth?.GetValueOrDefault(period); double? self = load is not null && grid is not null ? load - grid : null; double? savings = null; if (self is { } selfValue && gridMeter is not null) { var price = TariffResolver.ResolveValue( tariffs, TariffComponent.UnitPrice, gridMeter.Id, gridMeter.EnergyTypeId, new DateOnly(period.Year, period.Month, 15)); savings = selfValue * price; } months.Add(new SolarMonth(period, generation, self, grid, load, savings)); } var meterRows = generationMeters .Select(m => new GenerationMeterRow(m.Id, m.Name, genByMeter[m.Id].Values.Sum())) .OrderByDescending(r => r.Generation) .ToList(); var totalGeneration = meterRows.Sum(r => r.Generation); double? totalLoad = loadByMonth?.Values.Sum(); double? totalGrid = gridByMonth?.Values.Sum(); double? totalSelf = totalLoad is not null && totalGrid is not null ? totalLoad - totalGrid : null; double? autarky = totalSelf is not null && totalLoad is > 0 ? totalSelf / totalLoad : null; double? selfRatio = totalSelf is not null && totalGeneration > 0 ? totalSelf / totalGeneration : null; double? totalSavings = months.Any(m => m.Savings is not null) ? months.Sum(m => m.Savings ?? 0) : null; return new SolarSummary( totalGeneration, totalLoad, totalGrid, totalSelf, autarky, selfRatio, totalSavings, meterRows, months); } private static async Task> MonthlyAsync( MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken) { const string sql = "SELECT (time_bucket(INTERVAL '1 month', \"time\", 'Europe/Berlin') AT TIME ZONE 'Europe/Berlin')::date AS period, " + "sum(amount) AS amount " + "FROM consumption WHERE meter_id = @meterId AND \"time\" >= @from AND \"time\" < @to " + "GROUP BY period"; var connection = db.Database.GetDbConnection(); var command = new CommandDefinition(sql, new { meterId, from, to }, cancellationToken: cancellationToken); var rows = await connection.QueryAsync<(DateOnly Period, double Amount)>(command).ConfigureAwait(false); return rows.ToDictionary(r => r.Period, r => r.Amount); } private static DateTimeOffset ToUtc(DateOnly date) => new(date.Year, date.Month, date.Day, 0, 0, 0, TimeSpan.Zero); }