using System.Text.RegularExpressions; 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/"; /// /// Returns xlsx files for the given installation whose date range overlaps [rangeStart, rangeEnd]. /// Filename pattern: {installationId}_MMDD_MMDD.xlsx (e.g. "848_0302_0308.xlsx") /// Falls back to all files if filenames can't be parsed. /// private static List GetRelevantXlsxFiles(long installationId, DateOnly rangeStart, DateOnly rangeEnd) { if (!Directory.Exists(TmpReportDir)) return new List(); var allFiles = Directory.GetFiles(TmpReportDir, $"{installationId}*.xlsx").OrderBy(f => f).ToList(); if (allFiles.Count == 0) return allFiles; // Try to filter by filename date range; fall back to all files if parsing fails var year = rangeStart.Year; var filtered = new List(); foreach (var file in allFiles) { var name = Path.GetFileNameWithoutExtension(file); // Match pattern: {id}_MMDD_MMDD var match = Regex.Match(name, @"_(\d{4})_(\d{4})$"); if (!match.Success) { // Can't parse filename — include it to be safe filtered.Add(file); continue; } var startStr = match.Groups[1].Value; // "0302" var endStr = match.Groups[2].Value; // "0308" if (!DateOnly.TryParseExact($"{year}-{startStr[..2]}-{startStr[2..]}", "yyyy-MM-dd", out var fileStart) || !DateOnly.TryParseExact($"{year}-{endStr[..2]}-{endStr[2..]}", "yyyy-MM-dd", out var fileEnd)) { filtered.Add(file); // Can't parse — include to be safe continue; } // Include if date ranges overlap if (fileStart <= rangeEnd && fileEnd >= rangeStart) filtered.Add(file); } return filtered; } // ── 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(); } // 1. Load daily records from SQLite var currentWeekDays = Db.GetDailyRecords(installationId, curMon, curSun) .Select(ToDailyEnergyData).ToList(); var previousWeekDays = Db.GetDailyRecords(installationId, prevMon, prevSun) .Select(ToDailyEnergyData).ToList(); // 2. Fallback: if DB empty, parse xlsx on the fly (pre-ingestion scenario) if (currentWeekDays.Count == 0) { // Only parse xlsx files whose date range overlaps the needed weeks var relevantFiles = GetRelevantXlsxFiles(installationId, prevMon, curSun); if (relevantFiles.Count > 0) { Console.WriteLine($"[WeeklyReportService] DB empty for week {curMon:yyyy-MM-dd}, falling back to {relevantFiles.Count} xlsx file(s)."); var allDaysParsed = relevantFiles.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 records from SQLite for behavioral analysis var currentHourlyData = Db.GetHourlyRecords(installationId, curMon, curSun) .Select(ToHourlyEnergyData).ToList(); // 3b. Fallback: if DB empty, parse hourly data from xlsx if (currentHourlyData.Count == 0) { var relevantFiles = GetRelevantXlsxFiles(installationId, curMon, curSun); if (relevantFiles.Count > 0) { Console.WriteLine($"[WeeklyReportService] Hourly DB empty for week {curMon:yyyy-MM-dd}, falling back to {relevantFiles.Count} xlsx file(s)."); currentHourlyData = relevantFiles .SelectMany(p => ExcelDataParser.ParseHourly(p)) .Where(h => { var date = DateOnly.FromDateTime(h.DateTime); return date >= curMon && date <= curSun; }) .ToList(); Console.WriteLine($"[WeeklyReportService] Parsed {currentHourlyData.Count} hourly records from xlsx."); } } // 4. Get installation location for weather forecast var installation = Db.GetInstallationById(installationId); var location = installation?.Location; var country = installation?.Country; var region = installation?.Region; Console.WriteLine($"[WeeklyReportService] Installation {installationId}: Location='{location}', Region='{region}', Country='{country}', HourlyRecords={currentHourlyData.Count}"); return await GenerateReportFromDataAsync( currentWeekDays, previousWeekDays, currentHourlyData, installationName, language, curMon, curSun, location, country, region); } // ── 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, }; private static HourlyEnergyData ToHourlyEnergyData(HourlyEnergyRecord r) => new() { DateTime = DateTime.ParseExact(r.DateHour, "yyyy-MM-dd HH", null), Hour = r.Hour, DayOfWeek = r.DayOfWeek, IsWeekend = r.IsWeekend, PvKwh = r.PvKwh, LoadKwh = r.LoadKwh, GridImportKwh = r.GridImportKwh, BatteryChargedKwh = r.BatteryChargedKwh, BatteryDischargedKwh = r.BatteryDischargedKwh, BattSoC = r.BattSoC, }; /// /// 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, string? location = null, string? country = null, string? region = 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, location, country, region); 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", string? location = null, string? country = null, string? region = null) { 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)."; } // Fetch weather forecast for the installation's location var forecast = await WeatherService.GetForecastAsync(location, country, region); var weatherBlock = forecast != null ? "\n" + WeatherService.FormatForPrompt(forecast) + "\n" : ""; Console.WriteLine($"[WeeklyReportService] Weather forecast: {(forecast != null ? $"{forecast.Count} days fetched" : "SKIPPED (no location or API error)")}"); if (forecast != null) Console.WriteLine($"[WeeklyReportService] Weather block:\n{weatherBlock}"); 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"); // Check if we have meaningful hourly/behavioral data var hasBehavior = behavior.AvgPeakLoadKwh > 0 || behavior.AvgPeakSolarKwh > 0; // Behavioral facts as compact lines (only when hourly data exists) 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." : ""; // Behavioral section — only include when hourly data exists var behavioralSection = ""; if (hasBehavior) { 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}" : ""; behavioralSection = $@" BEHAVIORAL PATTERN (from hourly data this week): - Peak household load: {FormatHourSlot(behavior.PeakLoadHour)}, avg {behavior.AvgPeakLoadKwh} kWh during that hour - {weekdayWeekendLine}{pvBehaviorLines} {gridBehaviorLine} {battBehaviorLine}"; } // 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." : "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."; // Instruction 4 — adapts based on whether we have behavioral data string instruction4; if (hasBehavior && hasPv) instruction4 = $"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."; else if (hasBehavior && hasGrid) instruction4 = $"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."; else instruction4 = "4. Smart action for next week: Based on this week's daily energy patterns, suggest one practical tip to maximize self-consumption and reduce grid dependency."; // Instruction 5 — weather outlook with pattern-based predictions var hasWeather = forecast != null; var bulletCount = hasWeather ? 5 : 4; var instruction5 = ""; if (hasWeather && hasPv) { // Compute avg daily PV production this week for reference var avgDailyPv = currentWeek.Count > 0 ? Math.Round(currentWeek.Average(d => d.PvProduction), 1) : 0; var bestDayPv = Math.Round(bestDay.PvProduction, 1); var worstDayPv = Math.Round(worstDay.PvProduction, 1); // Classify forecast days by sunshine potential var sunnyDays = forecast.Where(d => d.SunshineHours >= 7).Select(d => DateTime.Parse(d.Date).ToString("dddd")).ToList(); var cloudyDays = forecast.Where(d => d.SunshineHours < 3).Select(d => DateTime.Parse(d.Date).ToString("dddd")).ToList(); var totalForecastSunshine = Math.Round(forecast.Sum(d => d.SunshineHours), 1); var patternContext = $"This week the installation averaged {avgDailyPv} kWh/day PV (best: {bestDayPv} kWh on {bestDayName}, worst: {worstDayPv} kWh on {worstDayName}). "; if (sunnyDays.Count > 0) patternContext += $"Next week, sunny days ({string.Join(", ", sunnyDays)}) should produce above average (~{avgDailyPv}+ kWh/day). "; if (cloudyDays.Count > 0) patternContext += $"Cloudy/rainy days ({string.Join(", ", cloudyDays)}) will likely produce below average (~{worstDayPv} kWh/day or less). "; patternContext += $"Total forecast sunshine next week: {totalForecastSunshine}h."; instruction5 = $@"5. Weather outlook: {patternContext} Write 2-3 concise sentences. (1) Name the best solar days next week and estimate production based on this week's pattern. (2) Recommend which specific days are best for running energy-heavy appliances (washing machine, dishwasher, EV charging) to maximize free solar energy. (3) If rainy days follow sunny days, suggest front-loading heavy usage onto the sunny days before the rain arrives. IMPORTANT: Do NOT suggest ""reducing consumption"" in the evening — people need electricity for cooking, lighting, and daily life. Instead, focus on SHIFTING discretionary loads (laundry, dishwasher, EV) to the best solar days. The battery system handles grid charging automatically — do not tell the customer to manage battery charging."; } else if (hasWeather) { instruction5 = @"5. Weather outlook: Summarize the coming week's weather in 1-2 sentences. Mention which days have the most sunshine and suggest those as the best days to run energy-heavy appliances (laundry, dishwasher). Do NOT suggest reducing evening consumption — focus on shifting discretionary loads to sunny days."; } var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"". Write {bulletCount} 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} {behavioralSection} {weatherBlock} INSTRUCTIONS: {instruction1} {instruction2} {instruction3} {instruction4} {instruction5} 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 {bulletCount} bullet points (e.g. no 'Das ist ein guter Fortschritt', 'Keep it up', 'Good progress', etc.). Write exactly {bulletCount} 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 = 600, 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."; } }