using Flurl.Http;
using InnovEnergy.App.Backend.Database;
using InnovEnergy.App.Backend.DataTypes;
using Newtonsoft.Json;
namespace InnovEnergy.App.Backend.Services;
public static class WeeklyReportService
{
private static readonly string TmpReportDir = Environment.CurrentDirectory + "/tmp_report/";
// ── Calendar Week Helpers ──────────────────────────────────────────
///
/// Returns the last completed calendar week (Mon–Sun).
/// Example: called on Wednesday 2026-03-11 → returns (2026-03-02, 2026-03-08).
///
public static (DateOnly Monday, DateOnly Sunday) LastCalendarWeek()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
// DayOfWeek: Sun=0, Mon=1, ..., Sat=6
// daysSinceMonday: Mon=0, Tue=1, ..., Sun=6
var daysSinceMonday = ((Int32)today.DayOfWeek + 6) % 7;
var thisMonday = today.AddDays(-daysSinceMonday);
var lastMonday = thisMonday.AddDays(-7);
var lastSunday = thisMonday.AddDays(-1);
return (lastMonday, lastSunday);
}
///
/// Returns the calendar week before last (for comparison).
///
private static (DateOnly Monday, DateOnly Sunday) PreviousCalendarWeek()
{
var (lastMon, _) = LastCalendarWeek();
return (lastMon.AddDays(-7), lastMon.AddDays(-1));
}
// ── Report Generation ──────────────────────────────────────────────
///
/// Generates a full weekly report for the given installation.
/// Data source priority:
/// 1. DailyEnergyRecord rows in SQLite (populated by DailyIngestionService)
/// 2. xlsx file fallback (if DB not yet populated for the target week)
/// Cache is keyed to the calendar week — invalidated when the week changes.
/// Pass weekStartOverride to generate a report for a specific historical week (disables cache).
///
public static async Task GenerateReportAsync(
long installationId, string installationName, string language = "en",
DateOnly? weekStartOverride = null)
{
DateOnly curMon, curSun, prevMon, prevSun;
if (weekStartOverride.HasValue)
{
// Debug/backfill mode: use the provided Monday as the week start
curMon = weekStartOverride.Value;
curSun = curMon.AddDays(6);
prevMon = curMon.AddDays(-7);
prevSun = curMon.AddDays(-1);
}
else
{
(curMon, curSun) = LastCalendarWeek();
(prevMon, prevSun) = PreviousCalendarWeek();
}
// Skip file cache when a specific week is requested (avoid stale or wrong-week hits)
var cachePath = weekStartOverride.HasValue
? null
: TmpReportDir + $"{installationId}_{language}_{curMon:yyyy-MM-dd}.cache.json";
// Use cache if it exists and is less than 6 hours old (skipped in override mode)
if (cachePath != null && File.Exists(cachePath))
{
var cacheAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(cachePath);
if (cacheAge.TotalHours < 6)
{
try
{
var cached = JsonConvert.DeserializeObject(
await File.ReadAllTextAsync(cachePath));
if (cached != null)
{
Console.WriteLine($"[WeeklyReportService] Returning cached report for installation {installationId} ({language}), week {curMon:yyyy-MM-dd}.");
return cached;
}
}
catch
{
// Cache corrupt — regenerate
}
}
}
// 1. Try to load daily records from SQLite for the calendar weeks
var currentWeekDays = Db.GetDailyRecords(installationId, curMon, curSun)
.Select(r => ToDailyEnergyData(r)).ToList();
var previousWeekDays = Db.GetDailyRecords(installationId, prevMon, prevSun)
.Select(r => ToDailyEnergyData(r)).ToList();
// 2. Fallback: if DB empty for current week, parse all xlsx files on the fly
var xlsxFiles = Directory.Exists(TmpReportDir)
? Directory.GetFiles(TmpReportDir, $"{installationId}*.xlsx").OrderBy(f => f).ToList()
: new List();
if (currentWeekDays.Count == 0 && xlsxFiles.Count > 0)
{
Console.WriteLine($"[WeeklyReportService] DB empty for week {curMon:yyyy-MM-dd}, falling back to xlsx.");
var allDaysParsed = xlsxFiles.SelectMany(p => ExcelDataParser.Parse(p)).ToList();
currentWeekDays = allDaysParsed
.Where(d => { var date = DateOnly.Parse(d.Date); return date >= curMon && date <= curSun; })
.ToList();
previousWeekDays = allDaysParsed
.Where(d => { var date = DateOnly.Parse(d.Date); return date >= prevMon && date <= prevSun; })
.ToList();
}
if (currentWeekDays.Count == 0)
throw new InvalidOperationException(
$"No energy data available for week {curMon:yyyy-MM-dd}–{curSun:yyyy-MM-dd}. " +
"Upload an xlsx file or wait for daily ingestion.");
// 3. Load hourly data from ALL xlsx files for behavioral analysis (current week only).
// Combine all files so any week can find its hourly data regardless of file split.
// Future: replace with S3 hourly fetch.
var allHourly = xlsxFiles
.SelectMany(p => { try { return ExcelDataParser.ParseHourly(p); } catch { return Enumerable.Empty(); } })
.ToList();
var curMonDt = curMon.ToDateTime(TimeOnly.MinValue);
var curSunDt = curSun.ToDateTime(TimeOnly.MaxValue);
var currentHourlyData = allHourly
.Where(h => h.DateTime >= curMonDt && h.DateTime <= curSunDt)
.ToList();
var report = await GenerateReportFromDataAsync(
currentWeekDays, previousWeekDays, currentHourlyData, installationName, language,
curMon, curSun);
// Write cache (skipped in override mode)
if (cachePath != null)
{
try
{
await File.WriteAllTextAsync(cachePath, JsonConvert.SerializeObject(report));
}
catch (Exception ex)
{
Console.Error.WriteLine($"[WeeklyReportService] Could not write cache: {ex.Message}");
}
}
return report;
}
// ── Conversion helpers ─────────────────────────────────────────────
private static DailyEnergyData ToDailyEnergyData(DailyEnergyRecord r) => new()
{
Date = r.Date,
PvProduction = r.PvProduction,
LoadConsumption = r.LoadConsumption,
GridImport = r.GridImport,
GridExport = r.GridExport,
BatteryCharged = r.BatteryCharged,
BatteryDischarged = r.BatteryDischarged,
};
///
/// Core report generation. Accepts pre-split current/previous week data and hourly intervals.
/// weekStart/weekEnd are used to set PeriodStart/PeriodEnd on the response.
///
public static async Task GenerateReportFromDataAsync(
List currentWeekDays,
List previousWeekDays,
List currentHourlyData,
string installationName,
string language = "en",
DateOnly? weekStart = null,
DateOnly? weekEnd = null)
{
currentWeekDays = currentWeekDays.OrderBy(d => d.Date).ToList();
previousWeekDays = previousWeekDays.OrderBy(d => d.Date).ToList();
var currentSummary = Summarize(currentWeekDays);
var previousSummary = previousWeekDays.Count > 0 ? Summarize(previousWeekDays) : null;
// Key ratios for current week
var selfSufficiency = currentSummary.TotalConsumption > 0
? Math.Round((currentSummary.TotalConsumption - currentSummary.TotalGridImport) / currentSummary.TotalConsumption * 100, 1)
: 0;
var selfConsumption = currentSummary.TotalPvProduction > 0
? Math.Round((currentSummary.TotalPvProduction - currentSummary.TotalGridExport) / currentSummary.TotalPvProduction * 100, 1)
: 0;
var batteryEfficiency = currentSummary.TotalBatteryCharged > 0
? Math.Round(currentSummary.TotalBatteryDischarged / currentSummary.TotalBatteryCharged * 100, 1)
: 0;
var gridDependency = currentSummary.TotalConsumption > 0
? Math.Round(currentSummary.TotalGridImport / currentSummary.TotalConsumption * 100, 1)
: 0;
// Week-over-week changes
var pvChange = PercentChange(previousSummary?.TotalPvProduction, currentSummary.TotalPvProduction);
var consumptionChange = PercentChange(previousSummary?.TotalConsumption, currentSummary.TotalConsumption);
var gridImportChange = PercentChange(previousSummary?.TotalGridImport, currentSummary.TotalGridImport);
// Behavioral pattern from hourly data (pure C# — no AI)
var behavior = BehaviorAnalyzer.Analyze(currentHourlyData);
// Pre-computed savings — single source of truth for UI and AI
const double ElectricityPriceCHF = 0.39;
var totalEnergySaved = Math.Round(currentSummary.TotalConsumption - currentSummary.TotalGridImport, 1);
var totalSavingsCHF = Math.Round(totalEnergySaved * ElectricityPriceCHF, 0);
var avgDailyConsumption = currentWeekDays.Count > 0 ? currentSummary.TotalConsumption / currentWeekDays.Count : 0;
var daysEquivalent = avgDailyConsumption > 0 ? Math.Round(totalEnergySaved / avgDailyConsumption, 1) : 0;
// AI insight combining daily facts + behavioral pattern
var aiInsight = await GetAiInsightAsync(
currentWeekDays, currentSummary, previousSummary,
selfSufficiency, totalEnergySaved, totalSavingsCHF,
behavior, installationName, language);
return new WeeklyReportResponse
{
InstallationName = installationName,
PeriodStart = currentWeekDays.First().Date,
PeriodEnd = currentWeekDays.Last().Date,
CurrentWeek = currentSummary,
PreviousWeek = previousSummary,
TotalEnergySaved = totalEnergySaved,
TotalSavingsCHF = totalSavingsCHF,
DaysEquivalent = daysEquivalent,
SelfSufficiencyPercent = selfSufficiency,
SelfConsumptionPercent = selfConsumption,
BatteryEfficiencyPercent = batteryEfficiency,
GridDependencyPercent = gridDependency,
PvChangePercent = pvChange,
ConsumptionChangePercent = consumptionChange,
GridImportChangePercent = gridImportChange,
DailyData = currentWeekDays,
Behavior = behavior,
AiInsight = aiInsight,
};
}
private static WeeklySummary Summarize(List days) => new()
{
TotalPvProduction = Math.Round(days.Sum(d => d.PvProduction), 1),
TotalConsumption = Math.Round(days.Sum(d => d.LoadConsumption), 1),
TotalGridImport = Math.Round(days.Sum(d => d.GridImport), 1),
TotalGridExport = Math.Round(days.Sum(d => d.GridExport), 1),
TotalBatteryCharged = Math.Round(days.Sum(d => d.BatteryCharged), 1),
TotalBatteryDischarged = Math.Round(days.Sum(d => d.BatteryDischarged), 1),
};
private static double PercentChange(double? previous, double current)
{
if (previous is null or 0) return 0;
return Math.Round((current - previous.Value) / previous.Value * 100, 1);
}
// ── Mistral AI Insight ──────────────────────────────────────────────
private static readonly string MistralUrl = "https://api.mistral.ai/v1/chat/completions";
private static string LanguageName(string code) => code switch
{
"de" => "German",
"fr" => "French",
"it" => "Italian",
_ => "English"
};
private static string FormatHour(int hour) => $"{hour:D2}:00";
private static string FormatHourSlot(int hour) => $"{hour:D2}:00–{hour + 1:D2}:00";
private static async Task GetAiInsightAsync(
List currentWeek,
WeeklySummary current,
WeeklySummary? previous,
double selfSufficiency,
double totalEnergySaved,
double totalSavingsCHF,
BehavioralPattern behavior,
string installationName,
string language = "en")
{
var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY");
if (string.IsNullOrWhiteSpace(apiKey))
{
Console.WriteLine("[WeeklyReportService] MISTRAL_API_KEY not set — skipping AI insight.");
return "AI insight unavailable (API key not configured).";
}
const double ElectricityPriceCHF = 0.39;
// Detect which components are present
var hasPv = currentWeek.Sum(d => d.PvProduction) > 0.5;
var hasBattery = currentWeek.Sum(d => d.BatteryCharged) > 0.5
|| currentWeek.Sum(d => d.BatteryDischarged) > 0.5;
var hasGrid = currentWeek.Sum(d => d.GridImport) > 0.5;
var bestDay = currentWeek.OrderByDescending(d => d.PvProduction).First();
var worstDay = currentWeek.OrderBy(d => d.PvProduction).First();
var bestDayName = DateTime.Parse(bestDay.Date).ToString("dddd");
var worstDayName = DateTime.Parse(worstDay.Date).ToString("dddd");
var topBattDay = currentWeek.OrderByDescending(d => d.BatteryCharged).First();
var topBattDayName = DateTime.Parse(topBattDay.Date).ToString("dddd");
// Behavioral facts as compact lines
var peakSolarWindow = FormatHour(behavior.PeakSolarHour) + "–" + FormatHour(behavior.PeakSolarEndHour);
var avoidableSavingsCHF = Math.Round(behavior.AvoidableGridKwh * ElectricityPriceCHF, 0);
var battDepleteLine = hasBattery
? (behavior.AvgBatteryDepletedHour >= 0
? $"Battery typically depletes below 20% during {FormatHourSlot(behavior.AvgBatteryDepletedHour)}."
: "Battery stayed above 20% SoC every night this week.")
: "";
var weekdayWeekendLine = behavior.WeekendAvgDailyLoad > 0
? $"Weekday avg load: {behavior.WeekdayAvgDailyLoad} kWh/day. Weekend avg: {behavior.WeekendAvgDailyLoad} kWh/day."
: $"Weekday avg load: {behavior.WeekdayAvgDailyLoad} kWh/day.";
// Build conditional fact lines
var pvDailyFact = hasPv
? $"- PV: total {current.TotalPvProduction:F1} kWh this week. Best day: {bestDayName} ({bestDay.PvProduction:F1} kWh), worst: {worstDayName} ({worstDay.PvProduction:F1} kWh). Solar covered {selfSufficiency}% of consumption."
: "";
var battDailyFact = hasBattery
? $"- Battery: {current.TotalBatteryCharged:F1} kWh charged, {current.TotalBatteryDischarged:F1} kWh discharged. Most active day: {topBattDayName} ({topBattDay.BatteryCharged:F1} kWh charged)."
: "";
var gridDailyFact = hasGrid
? $"- Grid import: {current.TotalGridImport:F1} kWh total this week."
: "";
var pvBehaviorLines = hasPv ? $@"
- Solar active window: {peakSolarWindow}; peak hour: {FormatHourSlot(behavior.PeakSolarHour)}, avg {behavior.AvgPeakSolarKwh} kWh during that hour
- Grid imported while solar was active: {behavior.AvoidableGridKwh} kWh = {avoidableSavingsCHF} CHF that could have been avoided" : "";
var gridBehaviorLine = hasGrid
? $"- Highest grid-import hour: {FormatHourSlot(behavior.HighestGridImportHour)}, avg {behavior.AvgGridImportAtPeakHour} kWh during that hour"
: "";
var battBehaviorLine = !string.IsNullOrEmpty(battDepleteLine) ? $"- {battDepleteLine}" : "";
// Build conditional instructions
var instruction1 = $"1. Energy savings: Write 1–2 sentences. Say that this week, thanks to sodistore home, the customer avoided buying {totalEnergySaved} kWh from the grid, saving {totalSavingsCHF} CHF (at {ElectricityPriceCHF} CHF/kWh). Use these exact numbers — do not recalculate or change them.";
var instruction2 = hasPv
? $"2. Solar performance: Comment on the best and worst solar day this week and the likely weather reason."
: hasGrid
? $"2. Grid usage: Comment on the {current.TotalGridImport:F1} kWh drawn from the grid this week and what time of day drives it most ({FormatHourSlot(behavior.HighestGridImportHour)})."
: "2. Consumption pattern: Comment on the weekday vs weekend load pattern.";
var instruction3 = hasBattery
? $"3. Battery performance: Use the daily facts. Keep it simple for a homeowner."
: "3. Consumption pattern: Comment on the peak load time and weekday vs weekend usage.";
var instruction4 = hasPv
? $"4. Smart action for next week: Write exactly 2 sentences. Sentence 1: point out the timing mismatch using exact numbers — peak household load is during {FormatHourSlot(behavior.PeakLoadHour)} ({behavior.AvgPeakLoadKwh} kWh) but solar peaks during {FormatHourSlot(behavior.PeakSolarHour)} ({behavior.AvgPeakSolarKwh} kWh), with solar active from {peakSolarWindow}. Sentence 2: suggest shifting energy-intensive appliances (such as washing machine, dishwasher, heat pump, or EV charger if applicable) to run during the solar window {peakSolarWindow} — do not assume which specific device the customer has."
: hasGrid
? $"4. Smart action for next week: Write exactly 2 sentences. Sentence 1: state that the peak grid-import hour is {FormatHourSlot(behavior.HighestGridImportHour)} ({behavior.AvgGridImportAtPeakHour} kWh avg). Sentence 2: suggest one action to reduce grid use during that hour — shifting energy-intensive appliances (washing machine, dishwasher, heat pump, EV charger) away from that time."
: "4. Smart action for next week: Give one practical tip to reduce energy consumption based on the peak load time and weekday/weekend pattern.";
var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"".
Write 4 bullet points (each on its own line starting with ""- ""). No bold markers, no asterisks, no markdown — plain text only.
IMPORTANT FORMAT RULE: Each bullet MUST start with a short title followed by a colon, then the description. Example: ""- Title label: Description text here."" Translate the title label into {LanguageName(language)} but always keep the ""Title: description"" structure.
CRITICAL: All numbers below are pre-calculated. Use these values as-is — do not recalculate, round differently, or change any number.
SYSTEM COMPONENTS: PV={hasPv}, Battery={hasBattery}, Grid={hasGrid}
DAILY FACTS:
- Total consumption: {current.TotalConsumption:F1} kWh this week. Self-sufficiency: {selfSufficiency}%.
{pvDailyFact}
{battDailyFact}
{gridDailyFact}
BEHAVIORAL PATTERN (from hourly data this week):
- Peak household load: {FormatHourSlot(behavior.PeakLoadHour)}, avg {behavior.AvgPeakLoadKwh} kWh during that hour
- {weekdayWeekendLine}{pvBehaviorLines}
{gridBehaviorLine}
{battBehaviorLine}
INSTRUCTIONS:
{instruction1}
{instruction2}
{instruction3}
{instruction4}
Rules: Write for a homeowner, not an engineer. Do NOT use asterisks or any formatting marks. Only describe components that exist (PV={hasPv}, Battery={hasBattery}, Grid={hasGrid}). Do NOT add any closing remark, summary sentence, or motivational phrase after the 4 bullet points (e.g. no 'Das ist ein guter Fortschritt', 'Keep it up', 'Good progress', etc.). Write exactly 4 bullet points — nothing before, nothing after.
IMPORTANT: Write your entire response in {LanguageName(language)}.";
try
{
var requestBody = new
{
model = "mistral-small-latest",
messages = new[] { new { role = "user", content = prompt } },
max_tokens = 400,
temperature = 0.3
};
var responseText = await MistralUrl
.WithHeader("Authorization", $"Bearer {apiKey}")
.PostJsonAsync(requestBody)
.ReceiveString();
var envelope = JsonConvert.DeserializeObject(responseText);
var content = (string?)envelope?.choices?[0]?.message?.content;
if (!string.IsNullOrWhiteSpace(content))
{
var insight = content.Trim();
Console.WriteLine($"[WeeklyReportService] AI insight generated ({insight.Length} chars).");
return insight;
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[WeeklyReportService] Mistral error: {ex.Message}");
}
return "AI insight could not be generated at this time.";
}
}