add behavioral pattern detection using AI to enrich weekly performance report's insight to customers

This commit is contained in:
Yinyin Liu 2026-02-23 07:40:22 +01:00
parent 4d0d446686
commit 25280afb8f
14 changed files with 441 additions and 130 deletions

View File

@ -0,0 +1,28 @@
namespace InnovEnergy.App.Backend.DataTypes;
/// <summary>
/// Pre-computed behavioral facts derived from hourly data.
/// All heavy analysis is done in C# — the AI only gets these clean conclusions.
/// </summary>
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
}

View File

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

View File

@ -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<DailyEnergyData> DailyData { get; set; } = new();
public List<DailyEnergyData> DailyData { get; set; } = new();
public BehavioralPattern? Behavior { get; set; }
public string AiInsight { get; set; } = "";
}

View File

@ -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
/// <summary>
/// Derives behavioral facts from hourly data for the current week only.
/// All computation is pure C# — no AI involved.
/// </summary>
public static BehavioralPattern Analyze(List<HourlyEnergyData> 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,
};
}
}

View File

@ -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 %
/// <summary>
/// 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();
}
/// <summary>
/// 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.
/// </summary>
public static List<HourlyEnergyData> 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<string, int>();
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<HourlyEnergyData>();
// 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<HourlyEnergyData>();
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);

View File

@ -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
<div style=""font-size:16px;font-weight:bold;margin-bottom:12px;color:#2c3e50"">{s.SavingsHeader}</div>
<table width=""100%"" cellpadding=""0"" cellspacing=""8"">
<tr>
{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")}
</tr>
@ -407,10 +407,10 @@ public static class ReportEmailService
{
result = line;
}
// Bold numbers followed by units
// Bold all numbers: time ranges (14:0018: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+)",
"<strong>$1</strong>");
return result;
}

View File

@ -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<string, string> InsightCache = new();
// Bump this version when the AI prompt changes to automatically invalidate old cache files
private const string CacheVersion = "v2";
/// <summary>
/// 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.
/// </summary>
public static async Task<WeeklyReportResponse> 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
}
/// <summary>
/// Core report generation from daily data. Data-source agnostic.
/// Core report generation. Accepts both daily summaries and hourly intervals.
/// </summary>
public static async Task<WeeklyReportResponse> GenerateReportFromDataAsync(
List<DailyEnergyData> allDays, string installationName, string language = "en")
List<DailyEnergyData> allDays,
List<HourlyEnergyData> 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<DailyEnergyData> previousWeekDays;
List<DailyEnergyData> 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<string> GetAiInsightAsync(
List<DailyEnergyData> 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;
}

View File

@ -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<string>();
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);

View File

@ -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:0018: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)
? <strong key={i}>{p}</strong>
: <span key={i}>{p}</span>
const restParts = rest.split(BOLD_PATTERN).map((p, i) =>
isBold(p) ? <strong key={i}>{p}</strong> : <span key={i}>{p}</span>
);
return <><strong>{title}</strong>:{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)
? <strong key={i}>{p}</strong>
: <span key={i}>{p}</span>
// No colon — just bold figures
const parts = text.split(BOLD_PATTERN).map((p, i) =>
isBold(p) ? <strong key={i}>{p}</strong> : <span key={i}>{p}</span>
);
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) {
<Grid item xs={6} sm={3}>
<SavingsCard
label={intl.formatMessage({ id: 'solarEnergyUsed' })}
value={`${solarSavingsKwh} kWh`}
value={`${totalEnergySavedKwh} kWh`}
subtitle={intl.formatMessage({ id: 'solarStayedHome' })}
color="#27ae60"
hint={report.daysEquivalent > 0 ? `${report.daysEquivalent} ${intl.formatMessage({ id: 'daysOfYourUsage' })}` : undefined}
/>
</Grid>
<Grid item xs={6} sm={3}>
<SavingsCard
label={intl.formatMessage({ id: 'estMoneySaved' })}
value={`~${estimatedSavingsCHF} CHF`}
value={`~${totalSavingsCHF} CHF`}
subtitle={intl.formatMessage({ id: 'atCHFRate' })}
color="#2980b9"
/>
@ -329,6 +337,18 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
{prev && <td style={{ textAlign: 'right', color: '#888' }}>{prev.totalConsumption.toFixed(1)} kWh</td>}
{prev && <td style={{ textAlign: 'right', color: changeColor(report.consumptionChangePercent, true) }}>{formatChange(report.consumptionChangePercent)}</td>}
</tr>
<tr style={{ background: '#fafafa' }}>
<td style={{ color: '#888', paddingLeft: '20px', fontSize: '13px' }}>
<FormattedMessage id="avgDailyConsumption" defaultMessage="Avg Daily Consumption" />
</td>
<td style={{ textAlign: 'right', color: '#888', fontSize: '13px' }}>
{(cur.totalConsumption / currentWeekDayCount).toFixed(1)} kWh/day
</td>
{prev && <td style={{ textAlign: 'right', color: '#bbb', fontSize: '13px' }}>
{(prev.totalConsumption / previousWeekDayCount).toFixed(1)} kWh/day
</td>}
{prev && <td />}
</tr>
<tr>
<td><FormattedMessage id="gridImport" defaultMessage="Grid Import" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{cur.totalGridImport.toFixed(1)} kWh</td>
@ -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 (
<Box
sx={{
@ -442,6 +462,11 @@ function SavingsCard({ label, value, subtitle, color }: { label: string; value:
<Typography variant="caption" sx={{ color: '#888' }}>
{subtitle}
</Typography>
{hint && (
<Typography variant="caption" sx={{ display: 'block', color, fontWeight: 'bold', mt: 0.5 }}>
{hint}
</Typography>
)}
</Box>
);
}

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",