diff --git a/csharp/App/Backend/DataTypes/BehavioralPattern.cs b/csharp/App/Backend/DataTypes/BehavioralPattern.cs new file mode 100644 index 000000000..a2639cb0f --- /dev/null +++ b/csharp/App/Backend/DataTypes/BehavioralPattern.cs @@ -0,0 +1,28 @@ +namespace InnovEnergy.App.Backend.DataTypes; + +/// +/// Pre-computed behavioral facts derived from hourly data. +/// All heavy analysis is done in C# — the AI only gets these clean conclusions. +/// +public class BehavioralPattern +{ + // Peak hours + public int PeakLoadHour { get; set; } // 0-23, hour of day with highest avg load + public int PeakSolarHour { get; set; } // 0-23, hour with highest avg PV output + public int PeakSolarEndHour { get; set; } // last hour of meaningful solar window + public int HighestGridImportHour { get; set; } // 0-23, hour with most avg grid import + + // kWh figures + public double AvgPeakLoadKwh { get; set; } // avg load at peak hour (per day) + public double AvgPeakSolarKwh { get; set; } // avg PV at peak solar hour (per day) + public double AvoidableGridKwh { get; set; } // grid import during hours solar was active + public double AvgGridImportAtPeakHour { get; set; } // avg grid import at worst hour + + // Weekday vs weekend + public double WeekdayAvgDailyLoad { get; set; } // avg kWh/day Mon-Fri + public double WeekendAvgDailyLoad { get; set; } // avg kWh/day Sat-Sun + + // Battery + public int AvgBatteryDepletedHour { get; set; } // avg hour when SoC first drops below 20% + public bool BatteryDepletesOvernight { get; set; } // true if battery regularly hits low SoC at night +} diff --git a/csharp/App/Backend/DataTypes/HourlyEnergyData.cs b/csharp/App/Backend/DataTypes/HourlyEnergyData.cs new file mode 100644 index 000000000..4b6463fe9 --- /dev/null +++ b/csharp/App/Backend/DataTypes/HourlyEnergyData.cs @@ -0,0 +1,19 @@ +namespace InnovEnergy.App.Backend.DataTypes; + +public class HourlyEnergyData +{ + public DateTime DateTime { get; set; } // e.g. 2026-02-14 08:00:00 + public int Hour { get; set; } // 0-23 + public string DayOfWeek { get; set; } = ""; // "Monday" etc. + public bool IsWeekend { get; set; } + + // Energy for this hour (kWh) — derived from diff of consecutive "Today" cumulative snapshots + public double PvKwh { get; set; } + public double LoadKwh { get; set; } + public double GridImportKwh { get; set; } + public double BatteryChargedKwh { get; set; } + public double BatteryDischargedKwh { get; set; } + + // Instantaneous state at snapshot time + public double BattSoC { get; set; } // % (0-100) +} diff --git a/csharp/App/Backend/DataTypes/WeeklyReportResponse.cs b/csharp/App/Backend/DataTypes/WeeklyReportResponse.cs index c3187be49..8e2660c98 100644 --- a/csharp/App/Backend/DataTypes/WeeklyReportResponse.cs +++ b/csharp/App/Backend/DataTypes/WeeklyReportResponse.cs @@ -9,6 +9,11 @@ public class WeeklyReportResponse public WeeklySummary CurrentWeek { get; set; } = new(); public WeeklySummary? PreviousWeek { get; set; } + // Pre-computed savings — single source of truth for UI and AI + public double TotalEnergySaved { get; set; } // kWh = Consumption - GridImport + public double TotalSavingsCHF { get; set; } // CHF = TotalEnergySaved * 0.27 + public double DaysEquivalent { get; set; } // TotalEnergySaved / avg daily consumption + // Key ratios (current week) public double SelfSufficiencyPercent { get; set; } public double SelfConsumptionPercent { get; set; } @@ -20,7 +25,8 @@ public class WeeklyReportResponse public double ConsumptionChangePercent { get; set; } public double GridImportChangePercent { get; set; } - public List DailyData { get; set; } = new(); + public List DailyData { get; set; } = new(); + public BehavioralPattern? Behavior { get; set; } public string AiInsight { get; set; } = ""; } diff --git a/csharp/App/Backend/Services/BehaviorAnalyzer.cs b/csharp/App/Backend/Services/BehaviorAnalyzer.cs new file mode 100644 index 000000000..695a2da1c --- /dev/null +++ b/csharp/App/Backend/Services/BehaviorAnalyzer.cs @@ -0,0 +1,101 @@ +using InnovEnergy.App.Backend.DataTypes; + +namespace InnovEnergy.App.Backend.Services; + +public static class BehaviorAnalyzer +{ + private const double SolarActiveThresholdKwh = 0.1; // min PV kWh in an hour to count as "solar active" + private const double LowSoCThreshold = 20.0; // % below which battery is considered depleted + + /// + /// Derives behavioral facts from hourly data for the current week only. + /// All computation is pure C# — no AI involved. + /// + public static BehavioralPattern Analyze(List hourlyData) + { + if (hourlyData.Count == 0) + return new BehavioralPattern(); + + // ── Per-hour averages across the week ────────────────────────────── + // Group by hour-of-day (0-23), average each metric across all days + var byHour = Enumerable.Range(0, 24).Select(h => + { + var rows = hourlyData.Where(r => r.Hour == h).ToList(); + if (rows.Count == 0) + return (Hour: h, AvgPv: 0.0, AvgLoad: 0.0, AvgGridImport: 0.0); + return ( + Hour: h, + AvgPv: rows.Average(r => r.PvKwh), + AvgLoad: rows.Average(r => r.LoadKwh), + AvgGridImport: rows.Average(r => r.GridImportKwh) + ); + }).ToList(); + + // ── Peak load hour ───────────────────────────────────────────────── + var peakLoadEntry = byHour.OrderByDescending(h => h.AvgLoad).First(); + + // ── Peak solar hour and end of solar window ──────────────────────── + var peakSolarEntry = byHour.OrderByDescending(h => h.AvgPv).First(); + + // Solar window: last hour in the day where avg PV > threshold + var solarActiveHours = byHour.Where(h => h.AvgPv >= SolarActiveThresholdKwh).ToList(); + var peakSolarEndHour = solarActiveHours.Count > 0 + ? solarActiveHours.Max(h => h.Hour) + : peakSolarEntry.Hour; + + // ── Highest grid-import hour ──────────────────────────────────────── + var worstGridEntry = byHour.OrderByDescending(h => h.AvgGridImport).First(); + + // ── Avoidable grid imports: grid drawn during hours when solar was active ── + // For each actual hourly record: if solar > threshold AND grid import > 0 → avoidable + var avoidableGridKwh = Math.Round( + hourlyData + .Where(r => r.PvKwh >= SolarActiveThresholdKwh && r.GridImportKwh > 0) + .Sum(r => r.GridImportKwh), + 1); + + // ── Weekday vs weekend average daily load ────────────────────────── + var weekdayDays = hourlyData + .Where(r => !r.IsWeekend) + .GroupBy(r => r.DateTime.Date) + .Select(g => g.Sum(r => r.LoadKwh)) + .ToList(); + + var weekendDays = hourlyData + .Where(r => r.IsWeekend) + .GroupBy(r => r.DateTime.Date) + .Select(g => g.Sum(r => r.LoadKwh)) + .ToList(); + + var weekdayAvg = weekdayDays.Count > 0 ? Math.Round(weekdayDays.Average(), 1) : 0; + var weekendAvg = weekendDays.Count > 0 ? Math.Round(weekendDays.Average(), 1) : 0; + + // ── Battery depletion hour ───────────────────────────────────────── + // For each day, find the first evening hour (after 18:00) where SoC < threshold + // Average that hour across days where it occurs + var depletionHours = hourlyData + .Where(r => r.Hour >= 18 && r.BattSoC > 0 && r.BattSoC < LowSoCThreshold) + .GroupBy(r => r.DateTime.Date) + .Select(g => g.OrderBy(r => r.Hour).First().Hour) + .ToList(); + + var avgDepletedHour = depletionHours.Count > 0 ? (int)Math.Round(depletionHours.Average()) : -1; + var batteryDepletsNight = depletionHours.Count >= 3; // happens on 3+ nights = consistent pattern + + return new BehavioralPattern + { + PeakLoadHour = peakLoadEntry.Hour, + AvgPeakLoadKwh = Math.Round(peakLoadEntry.AvgLoad, 2), + PeakSolarHour = peakSolarEntry.Hour, + PeakSolarEndHour = peakSolarEndHour, + AvgPeakSolarKwh = Math.Round(peakSolarEntry.AvgPv, 2), + HighestGridImportHour = worstGridEntry.Hour, + AvgGridImportAtPeakHour = Math.Round(worstGridEntry.AvgGridImport, 2), + AvoidableGridKwh = avoidableGridKwh, + WeekdayAvgDailyLoad = weekdayAvg, + WeekendAvgDailyLoad = weekendAvg, + AvgBatteryDepletedHour = avgDepletedHour, + BatteryDepletesOvernight = batteryDepletsNight, + }; + } +} diff --git a/csharp/App/Backend/Services/ExcelDataParser.cs b/csharp/App/Backend/Services/ExcelDataParser.cs index 949dbc1ea..eff88a0eb 100644 --- a/csharp/App/Backend/Services/ExcelDataParser.cs +++ b/csharp/App/Backend/Services/ExcelDataParser.cs @@ -6,13 +6,14 @@ namespace InnovEnergy.App.Backend.Services; public static class ExcelDataParser { // Column headers from the ESS Link Cloud Excel export - private const string ColDateTime = "Data time"; - private const string ColPvToday = "PV Generated Energy Today"; - private const string ColLoadToday = "Load Consumption Today"; - private const string ColGridImportToday = "Purchased Energy Today"; - private const string ColGridExportToday = "Feed in energy Today"; - private const string ColBattChargedToday = "Daily Battery Charged"; + private const string ColDateTime = "Data time"; + private const string ColPvToday = "PV Generated Energy Today"; + private const string ColLoadToday = "Load Consumption Today"; + private const string ColGridImportToday = "Purchased Energy Today"; + private const string ColGridExportToday = "Feed in energy Today"; + private const string ColBattChargedToday = "Daily Battery Charged"; private const string ColBattDischargedToday = "Battery Discharged Today"; + private const string ColBattSoC = "Battery 1 SoC"; // instantaneous % /// /// Parses an ESS Link Cloud Excel export file and returns one DailyEnergyData per day. @@ -79,6 +80,103 @@ public static class ExcelDataParser return dailyLastRows.Values.ToList(); } + /// + /// Parses hourly energy snapshots from the xlsx. + /// For each hour of each day, finds the row nearest HH:00:00 and records the + /// cumulative "Today" values at that moment. The caller (BehaviorAnalyzer) then + /// diffs consecutive snapshots to get per-hour energy. + /// + public static List ParseHourly(string filePath) + { + if (!File.Exists(filePath)) + throw new FileNotFoundException($"Excel file not found: {filePath}"); + + using var workbook = new XLWorkbook(filePath); + var worksheet = workbook.Worksheet(1); + var lastRow = worksheet.LastRowUsed()?.RowNumber() ?? 0; + + if (lastRow < 2) + throw new InvalidOperationException("Excel file has no data rows."); + + // Build column map + var headerRow = worksheet.Row(1); + var colMap = new Dictionary(); + for (var col = 1; col <= worksheet.LastColumnUsed()?.ColumnNumber(); col++) + { + var header = headerRow.Cell(col).GetString().Trim(); + if (!string.IsNullOrEmpty(header)) + colMap[header] = col; + } + + // SoC column is optional — not all exports include it + var hasSoC = colMap.ContainsKey(ColBattSoC); + + // Read all rows into memory as (DateTime, cumulative values) pairs + var rawRows = new List<(DateTime Dt, double Pv, double Load, double GridIn, double BattChg, double BattDis, double SoC)>(); + + for (var row = 2; row <= lastRow; row++) + { + var dtStr = worksheet.Row(row).Cell(colMap[ColDateTime]).GetString().Trim(); + if (string.IsNullOrEmpty(dtStr)) continue; + if (!DateTime.TryParse(dtStr, out var dt)) continue; + + rawRows.Add(( + dt, + GetDouble(worksheet, row, colMap[ColPvToday]), + GetDouble(worksheet, row, colMap[ColLoadToday]), + GetDouble(worksheet, row, colMap[ColGridImportToday]), + GetDouble(worksheet, row, colMap[ColBattChargedToday]), + GetDouble(worksheet, row, colMap[ColBattDischargedToday]), + hasSoC ? GetDouble(worksheet, row, colMap[ColBattSoC]) : 0 + )); + } + + if (rawRows.Count == 0) return new List(); + + // For each calendar hour that exists in the data, find the nearest row to HH:00:00 + // Group rows by (date, hour) and pick the one closest to the round hour + var byHour = rawRows + .GroupBy(r => new DateTime(r.Dt.Year, r.Dt.Month, r.Dt.Day, r.Dt.Hour, 0, 0)) + .OrderBy(g => g.Key) + .Select(g => + { + var roundHour = g.Key; + var nearest = g.OrderBy(r => Math.Abs((r.Dt - roundHour).TotalSeconds)).First(); + return (RoundHour: roundHour, Row: nearest); + }) + .ToList(); + + // Diff consecutive snapshots within the same day to get per-hour energy + var result = new List(); + + for (var i = 1; i < byHour.Count; i++) + { + var prev = byHour[i - 1]; + var curr = byHour[i]; + + // Only diff within the same day — don't carry over across midnight + if (curr.RoundHour.Date != prev.RoundHour.Date) continue; + + // Cumulative "Today" values reset at midnight, so diff is always >= 0 within a day + result.Add(new HourlyEnergyData + { + DateTime = curr.RoundHour, + Hour = curr.RoundHour.Hour, + DayOfWeek = curr.RoundHour.DayOfWeek.ToString(), + IsWeekend = curr.RoundHour.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday, + PvKwh = Math.Max(0, Math.Round(curr.Row.Pv - prev.Row.Pv, 3)), + LoadKwh = Math.Max(0, Math.Round(curr.Row.Load - prev.Row.Load, 3)), + GridImportKwh = Math.Max(0, Math.Round(curr.Row.GridIn - prev.Row.GridIn, 3)), + BatteryChargedKwh = Math.Max(0, Math.Round(curr.Row.BattChg - prev.Row.BattChg, 3)), + BatteryDischargedKwh = Math.Max(0, Math.Round(curr.Row.BattDis - prev.Row.BattDis, 3)), + BattSoC = curr.Row.SoC, + }); + } + + Console.WriteLine($"[ExcelDataParser] Parsed {result.Count} hourly records from {filePath}"); + return result; + } + private static double GetDouble(IXLWorksheet ws, int row, int col) { var cell = ws.Row(row).Cell(col); diff --git a/csharp/App/Backend/Services/ReportEmailService.cs b/csharp/App/Backend/Services/ReportEmailService.cs index d3d08647c..bd68807dc 100644 --- a/csharp/App/Backend/Services/ReportEmailService.cs +++ b/csharp/App/Backend/Services/ReportEmailService.cs @@ -100,12 +100,12 @@ public static class ReportEmailService GridImport: "Netzbezug", GridExport: "Netzeinspeisung", BatteryInOut: "Batterie Ein/Aus", - SolarEnergyUsed: "Genutzte Solarenergie", - StayedAtHome: "direkt genutzt", + SolarEnergyUsed: "Energie gespart", + StayedAtHome: "Solar + Batterie, nicht vom Netz", EstMoneySaved: "Geschätzte Ersparnis", AtRate: "bei 0.27 CHF/kWh", - SolarCoverage: "Solare Deckung", - FromSolar: "durch Solar", + SolarCoverage: "Eigenversorgung", + FromSolar: "aus Solar + Batterie", BatteryEff: "Batterie-Eff.", OutVsIn: "Aus vs. Ein", Day: "Tag", @@ -130,12 +130,12 @@ public static class ReportEmailService GridImport: "Import réseau", GridExport: "Export réseau", BatteryInOut: "Batterie Entrée/Sortie", - SolarEnergyUsed: "Énergie solaire utilisée", - StayedAtHome: "autoconsommée", + SolarEnergyUsed: "Énergie économisée", + StayedAtHome: "solaire + batterie, non achetée au réseau", EstMoneySaved: "Économies estimées", AtRate: "à 0.27 CHF/kWh", - SolarCoverage: "Couverture solaire", - FromSolar: "depuis le solaire", + SolarCoverage: "Autosuffisance", + FromSolar: "du solaire + batterie", BatteryEff: "Eff. batterie", OutVsIn: "sortie vs entrée", Day: "Jour", @@ -160,12 +160,12 @@ public static class ReportEmailService GridImport: "Import dalla rete", GridExport: "Export nella rete", BatteryInOut: "Batteria Ent./Usc.", - SolarEnergyUsed: "Energia solare utilizzata", - StayedAtHome: "rimasta in casa", + SolarEnergyUsed: "Energia risparmiata", + StayedAtHome: "solare + batteria, non acquistata dalla rete", EstMoneySaved: "Risparmio stimato", AtRate: "a 0.27 CHF/kWh", - SolarCoverage: "Copertura solare", - FromSolar: "dal solare", + SolarCoverage: "Autosufficienza", + FromSolar: "da solare + batteria", BatteryEff: "Eff. batteria", OutVsIn: "uscita vs entrata", Day: "Giorno", @@ -190,12 +190,12 @@ public static class ReportEmailService GridImport: "Grid Import", GridExport: "Grid Export", BatteryInOut: "Battery In/Out", - SolarEnergyUsed: "Solar Energy Used", - StayedAtHome: "stayed at home", + SolarEnergyUsed: "Energy Saved", + StayedAtHome: "solar + battery, not bought from grid", EstMoneySaved: "Est. Money Saved", AtRate: "at 0.27 CHF/kWh", - SolarCoverage: "Solar Coverage", - FromSolar: "from solar", + SolarCoverage: "Self-Sufficiency", + FromSolar: "from solar + battery", BatteryEff: "Battery Eff.", OutVsIn: "out vs in", Day: "Day", @@ -342,8 +342,8 @@ public static class ReportEmailService
{s.SavingsHeader}
- {SavingsBox(s.SolarEnergyUsed, $"{r.CurrentWeek.TotalPvProduction - r.CurrentWeek.TotalGridExport:F1} kWh", s.StayedAtHome, "#27ae60")} - {SavingsBox(s.EstMoneySaved, $"~{(r.CurrentWeek.TotalPvProduction - r.CurrentWeek.TotalGridExport) * 0.27:F1} CHF", s.AtRate, "#2980b9")} + {SavingsBox(s.SolarEnergyUsed, $"{r.TotalEnergySaved:F1} kWh", s.StayedAtHome, "#27ae60")} + {SavingsBox(s.EstMoneySaved, $"~{r.TotalSavingsCHF:F0} CHF", s.AtRate, "#2980b9")} {SavingsBox(s.SolarCoverage, $"{r.SelfSufficiencyPercent:F0}%", s.FromSolar, "#8e44ad")} {SavingsBox(s.BatteryEff, $"{r.BatteryEfficiencyPercent:F0}%", s.OutVsIn, "#e67e22")} @@ -407,10 +407,10 @@ public static class ReportEmailService { result = line; } - // Bold numbers followed by units + // Bold all numbers: time ranges (14:00–18:00), times (09:00), decimals, integers result = System.Text.RegularExpressions.Regex.Replace( result, - @"(\d+[\d,.]*\s*(?:kWh|CHF|%|days?))", + @"(\d{1,2}:\d{2}(?:[–\-]\d{1,2}:\d{2})?|\d+[.,]\d+|\d+)", "$1"); return result; } diff --git a/csharp/App/Backend/Services/WeeklyReportService.cs b/csharp/App/Backend/Services/WeeklyReportService.cs index 195bcba5a..d536d4bac 100644 --- a/csharp/App/Backend/Services/WeeklyReportService.cs +++ b/csharp/App/Backend/Services/WeeklyReportService.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using Flurl.Http; using InnovEnergy.App.Backend.DataTypes; using Newtonsoft.Json; @@ -9,19 +8,15 @@ public static class WeeklyReportService { private static readonly string TmpReportDir = Environment.CurrentDirectory + "/tmp_report/"; - private static readonly ConcurrentDictionary InsightCache = new(); - - // Bump this version when the AI prompt changes to automatically invalidate old cache files - private const string CacheVersion = "v2"; - /// /// Generates a full weekly report for the given installation. - /// Caches the full report as JSON next to the xlsx. Cache is invalidated when xlsx is updated or CacheVersion changes. + /// Cache is invalidated automatically when the xlsx file is newer than the cache. + /// To force regeneration (e.g. after a prompt change), simply delete the cache files. /// public static async Task GenerateReportAsync(long installationId, string installationName, string language = "en") { var xlsxPath = TmpReportDir + installationId + ".xlsx"; - var cachePath = TmpReportDir + $"{installationId}_{language}_{CacheVersion}.cache.json"; + var cachePath = TmpReportDir + $"{installationId}_{language}.cache.json"; // Use cached report if xlsx hasn't changed since cache was written if (File.Exists(cachePath) && File.Exists(xlsxPath)) @@ -47,8 +42,10 @@ public static class WeeklyReportService } } - var allDays = ExcelDataParser.Parse(xlsxPath); - var report = await GenerateReportFromDataAsync(allDays, installationName, language); + // Parse both daily summaries and hourly intervals from the same xlsx + var allDays = ExcelDataParser.Parse(xlsxPath); + var allHourly = ExcelDataParser.ParseHourly(xlsxPath); + var report = await GenerateReportFromDataAsync(allDays, allHourly, installationName, language); // Write cache try @@ -64,15 +61,18 @@ public static class WeeklyReportService } /// - /// Core report generation from daily data. Data-source agnostic. + /// Core report generation. Accepts both daily summaries and hourly intervals. /// public static async Task GenerateReportFromDataAsync( - List allDays, string installationName, string language = "en") + List allDays, + List allHourly, + string installationName, + string language = "en") { // Sort by date allDays = allDays.OrderBy(d => d.Date).ToList(); - // Split into previous week and current week + // Split into previous week and current week (daily) List previousWeekDays; List currentWeekDays; @@ -87,11 +87,15 @@ public static class WeeklyReportService currentWeekDays = allDays; } + // Restrict hourly data to current week only for behavioral analysis + var currentWeekStart = DateTime.Parse(currentWeekDays.First().Date); + var currentHourlyData = allHourly.Where(h => h.DateTime.Date >= currentWeekStart.Date).ToList(); + var currentSummary = Summarize(currentWeekDays); var previousSummary = previousWeekDays.Count > 0 ? Summarize(previousWeekDays) : null; - // Calculate key ratios for current week - var selfSufficiency = currentSummary.TotalConsumption > 0 + // Key ratios for current week + var selfSufficiency = currentSummary.TotalConsumption > 0 ? Math.Round((currentSummary.TotalConsumption - currentSummary.TotalGridImport) / currentSummary.TotalConsumption * 100, 1) : 0; @@ -112,26 +116,42 @@ public static class WeeklyReportService var consumptionChange = PercentChange(previousSummary?.TotalConsumption, currentSummary.TotalConsumption); var gridImportChange = PercentChange(previousSummary?.TotalGridImport, currentSummary.TotalGridImport); - // AI insight - var aiInsight = await GetAiInsightAsync(currentWeekDays, currentSummary, previousSummary, - selfSufficiency, gridDependency, batteryEfficiency, installationName, language); + // 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.27; + 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, - SelfSufficiencyPercent = selfSufficiency, - SelfConsumptionPercent = selfConsumption, + 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, + GridDependencyPercent = gridDependency, PvChangePercent = pvChange, ConsumptionChangePercent = consumptionChange, GridImportChangePercent = gridImportChange, - DailyData = allDays, - AiInsight = aiInsight, + DailyData = allDays, + Behavior = behavior, + AiInsight = aiInsight, }; } @@ -163,13 +183,16 @@ public static class WeeklyReportService _ => "English" }; + private static string FormatHour(int hour) => $"{hour:D2}:00"; + private static async Task GetAiInsightAsync( List currentWeek, WeeklySummary current, WeeklySummary? previous, double selfSufficiency, - double gridDependency, - double batteryEfficiency, + double totalEnergySaved, + double totalSavingsCHF, + BehavioralPattern behavior, string installationName, string language = "en") { @@ -180,53 +203,64 @@ public static class WeeklyReportService return "AI insight unavailable (API key not configured)."; } - // Cache key: installation + period + language - var cacheKey = $"{installationName}_{currentWeek.Last().Date}_{language}"; - if (InsightCache.TryGetValue(cacheKey, out var cached)) - return cached; + const double ElectricityPriceCHF = 0.27; - // Build compact prompt - var dayLines = string.Join("\n", currentWeek.Select(d => - { - var dayName = DateTime.Parse(d.Date).ToString("ddd"); - return $"{dayName} {d.Date}: PV={d.PvProduction:F1} Load={d.LoadConsumption:F1} GridIn={d.GridImport:F1} GridOut={d.GridExport:F1} BattIn={d.BatteryCharged:F1} BattOut={d.BatteryDischarged:F1}"; - })); + 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 comparison = previous != null - ? $"vs Last week: PV {current.TotalPvProduction} vs {previous.TotalPvProduction}, Grid Import {current.TotalGridImport} vs {previous.TotalGridImport}, Consumption {current.TotalConsumption} vs {previous.TotalConsumption}" - : "No previous week data available."; + var topBattDay = currentWeek.OrderByDescending(d => d.BatteryCharged).First(); + var topBattDayName = DateTime.Parse(topBattDay.Date).ToString("dddd"); - var solarSavings = Math.Round(current.TotalPvProduction - current.TotalGridExport, 1); + // Behavioral facts as compact lines + var peakSolarWindow = FormatHour(behavior.PeakSolarHour) + "–" + FormatHour(behavior.PeakSolarEndHour); + var avoidableSavingsCHF = Math.Round(behavior.AvoidableGridKwh * ElectricityPriceCHF, 0); + var battDepleteLine = behavior.AvgBatteryDepletedHour >= 0 + ? $"Battery typically depletes below 20% around {FormatHour(behavior.AvgBatteryDepletedHour)}." + : "Battery SoC data not available."; + + 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."; var prompt = $@"You are an energy advisor for a SodistoreHome installation: ""{installationName}"". -Write exactly 4 bullet points (each on its own line starting with ""- ""). No bold markers, no asterisks, no markdown — plain text only. +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. -1. Solar savings: this week the system saved {solarSavings} kWh from the grid. Explain what this means in simple terms (e.g. equivalent to X days of average household use, or roughly X CHF saved at ~0.27 CHF/kWh). -2. Best vs worst solar day: name the best and worst days with their PV kWh values. Mention likely weather reason. -3. Battery performance: was the battery well-utilized this week? Mention charge/discharge totals and any standout days. -4. Tip of the week: one specific, practical recommendation based on THIS week's patterns to save more energy or money. +CRITICAL: All numbers below are pre-calculated. Use these values as-is — do not recalculate, round differently, or change any number. -Rules: Use actual day names and numbers. Keep each bullet to 1-2 sentences. Write for a homeowner, not an engineer. Do NOT use asterisks or any formatting marks. -IMPORTANT: Write your entire response in {LanguageName(language)}. +DAILY FACTS: +- Total energy saved: {totalEnergySaved} kWh (solar + battery), saving {totalSavingsCHF} CHF at {ElectricityPriceCHF} CHF/kWh. Self-sufficient {selfSufficiency}% of the time. +- Best solar day: {bestDayName} with {bestDay.PvProduction:F1} kWh. Worst: {worstDayName} with {worstDay.PvProduction:F1} kWh. +- Battery: {current.TotalBatteryCharged:F1} kWh charged, {current.TotalBatteryDischarged:F1} kWh discharged. Most active day: {topBattDayName} ({topBattDay.BatteryCharged:F1} kWh charged). -Daily data (kWh): -{dayLines} +BEHAVIORAL PATTERN (from hourly data this week): +- Peak household load: {FormatHour(behavior.PeakLoadHour)} avg {behavior.AvgPeakLoadKwh} kWh/hr +- Peak solar window: {peakSolarWindow}, avg {behavior.AvgPeakSolarKwh} kWh/hr +- Grid imported while solar was active this week: {behavior.AvoidableGridKwh} kWh total = {avoidableSavingsCHF} CHF that could have been avoided +- Highest single grid-import hour: {FormatHour(behavior.HighestGridImportHour)}, avg {behavior.AvgGridImportAtPeakHour} kWh/hr +- {weekdayWeekendLine} +- {battDepleteLine} -Totals: PV={current.TotalPvProduction:F1} Load={current.TotalConsumption:F1} GridIn={current.TotalGridImport:F1} GridOut={current.TotalGridExport:F1} BattIn={current.TotalBatteryCharged:F1} BattOut={current.TotalBatteryDischarged:F1} -Solar used at home={solarSavings} kWh ({selfSufficiency}% of consumption covered by solar) -Battery-eff={batteryEfficiency}% -{comparison}"; +INSTRUCTIONS: +1. Energy savings: Use the daily facts. State {totalEnergySaved} kWh and {totalSavingsCHF} CHF. Use these exact numbers — do not recalculate or substitute any of them. +2. Best vs worst solar day: Use the daily facts. Mention likely weather reason. +3. Battery performance: Use the daily facts. Keep it simple for a homeowner. +4. Smart action for next week: Write exactly 2 sentences. Sentence 1: state the pattern using these exact numbers — peak load at {FormatHour(behavior.PeakLoadHour)} ({behavior.AvgPeakLoadKwh} kWh/hr) vs solar peak at {peakSolarWindow} ({behavior.AvgPeakSolarKwh} kWh/hr), and that {behavior.AvoidableGridKwh} kWh ({avoidableSavingsCHF} CHF) was drawn from the grid while solar was active. Sentence 2: give ONE concrete action (what appliance to shift, to which hours) and state it would recover the {avoidableSavingsCHF} CHF. Use all these exact numbers — do not substitute or omit any. + +Rules: Write for a homeowner, not an engineer. Do NOT use asterisks or any formatting marks. +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 = 350, + model = "mistral-small-latest", + messages = new[] { new { role = "user", content = prompt } }, + max_tokens = 400, temperature = 0.3 }; @@ -241,7 +275,6 @@ Battery-eff={batteryEfficiency}% if (!string.IsNullOrWhiteSpace(content)) { var insight = content.Trim(); - InsightCache.TryAdd(cacheKey, insight); Console.WriteLine($"[WeeklyReportService] AI insight generated ({insight.Length} chars)."); return insight; } diff --git a/csharp/App/Backend/tmp_report/848.xlsx b/csharp/App/Backend/tmp_report/848.xlsx index 921dd890c..e729511ce 100644 Binary files a/csharp/App/Backend/tmp_report/848.xlsx and b/csharp/App/Backend/tmp_report/848.xlsx differ diff --git a/typescript/frontend-marios2/src/content/dashboards/Log/Log.tsx b/typescript/frontend-marios2/src/content/dashboards/Log/Log.tsx index 80587ffa7..e23adf777 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Log/Log.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Log/Log.tsx @@ -100,15 +100,8 @@ function Log(props: LogProps) { }); }, [updateCount]); - // fetch AI diagnosis for the latest 3 unique errors/warnings - // only when installation status is red (2) or orange (1) + // fetch AI diagnosis for all unique errors/warnings from the last 24 hours useEffect(() => { - // skip diagnosis if status is not alarm (2) or warning (1) - if (props.status !== 1 && props.status !== 2) { - setDiagnoses([]); - return; - } - // filter to last 24 hours only const now = new Date(); const cutoff = new Date(now.getTime() - 24 * 60 * 60 * 1000); @@ -122,13 +115,13 @@ function Log(props: LogProps) { }) .sort((a, b) => (b.date + ' ' + b.time).localeCompare(a.date + ' ' + a.time)); + // deduplicate — keep the most-recent occurrence of each unique description const seen = new Set(); const targets: ErrorMessage[] = []; for (const item of all) { if (!seen.has(item.description)) { seen.add(item.description); targets.push(item); - if (targets.length >= 3) break; } } @@ -158,7 +151,7 @@ function Log(props: LogProps) { }).finally(() => { setDiagnosisLoading(false); }); - }, [errors, warnings, props.status]); + }, [errors, warnings]); const handleErrorButtonPressed = () => { setErrorButtonPressed(!errorButtonPressed); diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/WeeklyReport.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/WeeklyReport.tsx index c28d0de8f..2e2314610 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/WeeklyReport.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/WeeklyReport.tsx @@ -43,6 +43,9 @@ interface WeeklyReportResponse { periodEnd: string; currentWeek: WeeklySummary; previousWeek: WeeklySummary | null; + totalEnergySaved: number; + totalSavingsCHF: number; + daysEquivalent: number; selfSufficiencyPercent: number; selfConsumptionPercent: number; batteryEfficiencyPercent: number; @@ -54,25 +57,25 @@ interface WeeklyReportResponse { aiInsight: string; } +// Matches: time ranges (14:00–18:00), times (09:00), decimals (126.4 / 1,3), integers (34) +// Any number in any language gets bolded — no unit matching needed +const BOLD_PATTERN = /(\d{1,2}:\d{2}(?:[–\-]\d{1,2}:\d{2})?|\d+[.,]\d+|\d+)/g; +const isBold = (s: string) => /\d/.test(s); + // Renders a bullet line: bolds the "Title" part before the first colon, and numbers with units function FormattedBullet({ text }: { text: string }) { const colonIdx = text.indexOf(':'); if (colonIdx > 0) { const title = text.slice(0, colonIdx); const rest = text.slice(colonIdx + 1); // e.g. " This week, your system saved 120.9 kWh..." - // Bold numbers+units in the rest - const restParts = rest.split(/(\d+[\d,.]*\s*(?:kWh|CHF|%|days?))/g).map((p, i) => - /\d+[\d,.]*\s*(?:kWh|CHF|%|days?)/.test(p) - ? {p} - : {p} + const restParts = rest.split(BOLD_PATTERN).map((p, i) => + isBold(p) ? {p} : {p} ); return <>{title}:{restParts}; } - // No colon — just bold numbers - const parts = text.split(/(\d+[\d,.]*\s*(?:kWh|CHF|%|days?))/g).map((p, i) => - /\d+[\d,.]*\s*(?:kWh|CHF|%|days?)/.test(p) - ? {p} - : {p} + // No colon — just bold figures + const parts = text.split(BOLD_PATTERN).map((p, i) => + isBold(p) ? {p} : {p} ); return <>{parts}; } @@ -159,6 +162,10 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { const cur = report.currentWeek; const prev = report.previousWeek; + // Backend: currentWeek = last 7 days, previousWeek = everything before + const currentWeekDayCount = Math.min(7, report.dailyData.length); + const previousWeekDayCount = Math.max(1, report.dailyData.length - currentWeekDayCount); + const formatChange = (pct: number) => pct === 0 ? '—' : pct > 0 ? `+${pct.toFixed(1)}%` : `${pct.toFixed(1)}%`; @@ -173,9 +180,9 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { .map((line) => line.replace(/^[\d]+[.)]\s*/, '').replace(/^[-*]\s*/, '').trim()) .filter((line) => line.length > 0); - // Savings-focused KPI values - const solarSavingsKwh = Math.round((cur.totalPvProduction - cur.totalGridExport) * 10) / 10; - const estimatedSavingsCHF = Math.round(solarSavingsKwh * 0.27 * 10) / 10; + // Read pre-computed values from backend — no arithmetic in the frontend + const totalEnergySavedKwh = report.totalEnergySaved; + const totalSavingsCHF = report.totalSavingsCHF; // Find max value for daily bar chart scaling const maxDailyValue = Math.max( @@ -270,15 +277,16 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { 0 ? `≈ ${report.daysEquivalent} ${intl.formatMessage({ id: 'daysOfYourUsage' })}` : undefined} /> @@ -329,6 +337,18 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { {prev && } {prev && } + + + + {prev && } + {prev && @@ -423,7 +443,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { ); } -function SavingsCard({ label, value, subtitle, color }: { label: string; value: string; subtitle: string; color: string }) { +function SavingsCard({ label, value, subtitle, color, hint }: { label: string; value: string; subtitle: string; color: string; hint?: string }) { return ( {subtitle} + {hint && ( + + {hint} + + )} ); } diff --git a/typescript/frontend-marios2/src/lang/de.json b/typescript/frontend-marios2/src/lang/de.json index bb930fd35..404c7dbb5 100644 --- a/typescript/frontend-marios2/src/lang/de.json +++ b/typescript/frontend-marios2/src/lang/de.json @@ -98,12 +98,14 @@ "reportTitle": "Wöchentlicher Leistungsbericht", "weeklyInsights": "Wöchentliche Einblicke", "weeklySavings": "Ihre Einsparungen diese Woche", - "solarEnergyUsed": "Genutzte Solarenergie", - "solarStayedHome": "Ihrer Solarenergie blieb zu Hause", + "solarEnergyUsed": "Energie gespart", + "solarStayedHome": "Solar + Batterie, nicht vom Netz", + "daysOfYourUsage": "Tage Ihres Verbrauchs", "estMoneySaved": "Geschätzte Ersparnisse", "atCHFRate": "bei 0,27 CHF/kWh Ø", - "solarCoverage": "Solarabdeckung", - "fromSolarSub": "des Verbrauchs aus Solar", + "solarCoverage": "Eigenversorgung", + "fromSolarSub": "aus Solar + Batterie", + "avgDailyConsumption": "Ø Tagesverbrauch", "batteryEfficiency": "Batterieeffizienz", "batteryEffSub": "Energie aus vs. Energie ein", "weeklySummary": "Wöchentliche Zusammenfassung", diff --git a/typescript/frontend-marios2/src/lang/en.json b/typescript/frontend-marios2/src/lang/en.json index c265a4bc7..b13ce9e7e 100644 --- a/typescript/frontend-marios2/src/lang/en.json +++ b/typescript/frontend-marios2/src/lang/en.json @@ -80,12 +80,14 @@ "reportTitle": "Weekly Performance Report", "weeklyInsights": "Weekly Insights", "weeklySavings": "Your Savings This Week", - "solarEnergyUsed": "Solar Energy Used", - "solarStayedHome": "of your solar stayed at home", + "solarEnergyUsed": "Energy Saved", + "solarStayedHome": "solar + battery, not bought from grid", + "daysOfYourUsage": "days of your usage", "estMoneySaved": "Est. Money Saved", "atCHFRate": "at 0.27 CHF/kWh avg.", - "solarCoverage": "Solar Coverage", - "fromSolarSub": "of consumption from solar", + "solarCoverage": "Self-Sufficiency", + "fromSolarSub": "from solar + battery", + "avgDailyConsumption": "Avg Daily Consumption", "batteryEfficiency": "Battery Efficiency", "batteryEffSub": "energy out vs energy in", "weeklySummary": "Weekly Summary", diff --git a/typescript/frontend-marios2/src/lang/fr.json b/typescript/frontend-marios2/src/lang/fr.json index 86dab1ddc..72bea9b5e 100644 --- a/typescript/frontend-marios2/src/lang/fr.json +++ b/typescript/frontend-marios2/src/lang/fr.json @@ -92,12 +92,14 @@ "reportTitle": "Rapport de performance hebdomadaire", "weeklyInsights": "Aperçus hebdomadaires", "weeklySavings": "Vos économies cette semaine", - "solarEnergyUsed": "Énergie solaire utilisée", - "solarStayedHome": "de votre solaire est resté à la maison", + "solarEnergyUsed": "Énergie économisée", + "solarStayedHome": "solaire + batterie, non achetée au réseau", + "daysOfYourUsage": "jours de votre consommation", "estMoneySaved": "Économies estimées", "atCHFRate": "à 0,27 CHF/kWh moy.", - "solarCoverage": "Couverture solaire", - "fromSolarSub": "de la consommation provenant du solaire", + "solarCoverage": "Autosuffisance", + "fromSolarSub": "du solaire + batterie", + "avgDailyConsumption": "Conso. quotidienne moy.", "batteryEfficiency": "Efficacité de la batterie", "batteryEffSub": "énergie sortante vs énergie entrante", "weeklySummary": "Résumé hebdomadaire", diff --git a/typescript/frontend-marios2/src/lang/it.json b/typescript/frontend-marios2/src/lang/it.json index c24227d15..0f7bda20f 100644 --- a/typescript/frontend-marios2/src/lang/it.json +++ b/typescript/frontend-marios2/src/lang/it.json @@ -103,12 +103,14 @@ "reportTitle": "Rapporto settimanale sulle prestazioni", "weeklyInsights": "Approfondimenti settimanali", "weeklySavings": "I tuoi risparmi questa settimana", - "solarEnergyUsed": "Energia solare utilizzata", - "solarStayedHome": "della tua energia solare è rimasta a casa", + "solarEnergyUsed": "Energia risparmiata", + "solarStayedHome": "solare + batteria, non acquistata dalla rete", + "daysOfYourUsage": "giorni del tuo consumo", "estMoneySaved": "Risparmio stimato", "atCHFRate": "a 0,27 CHF/kWh media", - "solarCoverage": "Copertura solare", - "fromSolarSub": "del consumo da fonte solare", + "solarCoverage": "Autosufficienza", + "fromSolarSub": "da solare + batteria", + "avgDailyConsumption": "Consumo medio giornaliero", "batteryEfficiency": "Efficienza della batteria", "batteryEffSub": "energia in uscita vs energia in entrata", "weeklySummary": "Riepilogo settimanale",
{prev.totalConsumption.toFixed(1)} kWh{formatChange(report.consumptionChangePercent)}
+ + + {(cur.totalConsumption / currentWeekDayCount).toFixed(1)} kWh/day + + {(prev.totalConsumption / previousWeekDayCount).toFixed(1)} kWh/day + } +
{cur.totalGridImport.toFixed(1)} kWh