add behavioral pattern detection using AI to enrich weekly performance report's insight to customers
This commit is contained in:
parent
4d0d446686
commit
25280afb8f
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,11 @@ public class WeeklyReportResponse
|
||||||
public WeeklySummary CurrentWeek { get; set; } = new();
|
public WeeklySummary CurrentWeek { get; set; } = new();
|
||||||
public WeeklySummary? PreviousWeek { get; set; }
|
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)
|
// Key ratios (current week)
|
||||||
public double SelfSufficiencyPercent { get; set; }
|
public double SelfSufficiencyPercent { get; set; }
|
||||||
public double SelfConsumptionPercent { get; set; }
|
public double SelfConsumptionPercent { get; set; }
|
||||||
|
|
@ -21,6 +26,7 @@ public class WeeklyReportResponse
|
||||||
public double GridImportChangePercent { 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; } = "";
|
public string AiInsight { get; set; } = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ public static class ExcelDataParser
|
||||||
private const string ColGridExportToday = "Feed in energy Today";
|
private const string ColGridExportToday = "Feed in energy Today";
|
||||||
private const string ColBattChargedToday = "Daily Battery Charged";
|
private const string ColBattChargedToday = "Daily Battery Charged";
|
||||||
private const string ColBattDischargedToday = "Battery Discharged Today";
|
private const string ColBattDischargedToday = "Battery Discharged Today";
|
||||||
|
private const string ColBattSoC = "Battery 1 SoC"; // instantaneous %
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses an ESS Link Cloud Excel export file and returns one DailyEnergyData per day.
|
/// 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();
|
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)
|
private static double GetDouble(IXLWorksheet ws, int row, int col)
|
||||||
{
|
{
|
||||||
var cell = ws.Row(row).Cell(col);
|
var cell = ws.Row(row).Cell(col);
|
||||||
|
|
|
||||||
|
|
@ -100,12 +100,12 @@ public static class ReportEmailService
|
||||||
GridImport: "Netzbezug",
|
GridImport: "Netzbezug",
|
||||||
GridExport: "Netzeinspeisung",
|
GridExport: "Netzeinspeisung",
|
||||||
BatteryInOut: "Batterie Ein/Aus",
|
BatteryInOut: "Batterie Ein/Aus",
|
||||||
SolarEnergyUsed: "Genutzte Solarenergie",
|
SolarEnergyUsed: "Energie gespart",
|
||||||
StayedAtHome: "direkt genutzt",
|
StayedAtHome: "Solar + Batterie, nicht vom Netz",
|
||||||
EstMoneySaved: "Geschätzte Ersparnis",
|
EstMoneySaved: "Geschätzte Ersparnis",
|
||||||
AtRate: "bei 0.27 CHF/kWh",
|
AtRate: "bei 0.27 CHF/kWh",
|
||||||
SolarCoverage: "Solare Deckung",
|
SolarCoverage: "Eigenversorgung",
|
||||||
FromSolar: "durch Solar",
|
FromSolar: "aus Solar + Batterie",
|
||||||
BatteryEff: "Batterie-Eff.",
|
BatteryEff: "Batterie-Eff.",
|
||||||
OutVsIn: "Aus vs. Ein",
|
OutVsIn: "Aus vs. Ein",
|
||||||
Day: "Tag",
|
Day: "Tag",
|
||||||
|
|
@ -130,12 +130,12 @@ public static class ReportEmailService
|
||||||
GridImport: "Import réseau",
|
GridImport: "Import réseau",
|
||||||
GridExport: "Export réseau",
|
GridExport: "Export réseau",
|
||||||
BatteryInOut: "Batterie Entrée/Sortie",
|
BatteryInOut: "Batterie Entrée/Sortie",
|
||||||
SolarEnergyUsed: "Énergie solaire utilisée",
|
SolarEnergyUsed: "Énergie économisée",
|
||||||
StayedAtHome: "autoconsommée",
|
StayedAtHome: "solaire + batterie, non achetée au réseau",
|
||||||
EstMoneySaved: "Économies estimées",
|
EstMoneySaved: "Économies estimées",
|
||||||
AtRate: "à 0.27 CHF/kWh",
|
AtRate: "à 0.27 CHF/kWh",
|
||||||
SolarCoverage: "Couverture solaire",
|
SolarCoverage: "Autosuffisance",
|
||||||
FromSolar: "depuis le solaire",
|
FromSolar: "du solaire + batterie",
|
||||||
BatteryEff: "Eff. batterie",
|
BatteryEff: "Eff. batterie",
|
||||||
OutVsIn: "sortie vs entrée",
|
OutVsIn: "sortie vs entrée",
|
||||||
Day: "Jour",
|
Day: "Jour",
|
||||||
|
|
@ -160,12 +160,12 @@ public static class ReportEmailService
|
||||||
GridImport: "Import dalla rete",
|
GridImport: "Import dalla rete",
|
||||||
GridExport: "Export nella rete",
|
GridExport: "Export nella rete",
|
||||||
BatteryInOut: "Batteria Ent./Usc.",
|
BatteryInOut: "Batteria Ent./Usc.",
|
||||||
SolarEnergyUsed: "Energia solare utilizzata",
|
SolarEnergyUsed: "Energia risparmiata",
|
||||||
StayedAtHome: "rimasta in casa",
|
StayedAtHome: "solare + batteria, non acquistata dalla rete",
|
||||||
EstMoneySaved: "Risparmio stimato",
|
EstMoneySaved: "Risparmio stimato",
|
||||||
AtRate: "a 0.27 CHF/kWh",
|
AtRate: "a 0.27 CHF/kWh",
|
||||||
SolarCoverage: "Copertura solare",
|
SolarCoverage: "Autosufficienza",
|
||||||
FromSolar: "dal solare",
|
FromSolar: "da solare + batteria",
|
||||||
BatteryEff: "Eff. batteria",
|
BatteryEff: "Eff. batteria",
|
||||||
OutVsIn: "uscita vs entrata",
|
OutVsIn: "uscita vs entrata",
|
||||||
Day: "Giorno",
|
Day: "Giorno",
|
||||||
|
|
@ -190,12 +190,12 @@ public static class ReportEmailService
|
||||||
GridImport: "Grid Import",
|
GridImport: "Grid Import",
|
||||||
GridExport: "Grid Export",
|
GridExport: "Grid Export",
|
||||||
BatteryInOut: "Battery In/Out",
|
BatteryInOut: "Battery In/Out",
|
||||||
SolarEnergyUsed: "Solar Energy Used",
|
SolarEnergyUsed: "Energy Saved",
|
||||||
StayedAtHome: "stayed at home",
|
StayedAtHome: "solar + battery, not bought from grid",
|
||||||
EstMoneySaved: "Est. Money Saved",
|
EstMoneySaved: "Est. Money Saved",
|
||||||
AtRate: "at 0.27 CHF/kWh",
|
AtRate: "at 0.27 CHF/kWh",
|
||||||
SolarCoverage: "Solar Coverage",
|
SolarCoverage: "Self-Sufficiency",
|
||||||
FromSolar: "from solar",
|
FromSolar: "from solar + battery",
|
||||||
BatteryEff: "Battery Eff.",
|
BatteryEff: "Battery Eff.",
|
||||||
OutVsIn: "out vs in",
|
OutVsIn: "out vs in",
|
||||||
Day: "Day",
|
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>
|
<div style=""font-size:16px;font-weight:bold;margin-bottom:12px;color:#2c3e50"">{s.SavingsHeader}</div>
|
||||||
<table width=""100%"" cellpadding=""0"" cellspacing=""8"">
|
<table width=""100%"" cellpadding=""0"" cellspacing=""8"">
|
||||||
<tr>
|
<tr>
|
||||||
{SavingsBox(s.SolarEnergyUsed, $"{r.CurrentWeek.TotalPvProduction - r.CurrentWeek.TotalGridExport:F1} kWh", s.StayedAtHome, "#27ae60")}
|
{SavingsBox(s.SolarEnergyUsed, $"{r.TotalEnergySaved:F1} kWh", s.StayedAtHome, "#27ae60")}
|
||||||
{SavingsBox(s.EstMoneySaved, $"~{(r.CurrentWeek.TotalPvProduction - r.CurrentWeek.TotalGridExport) * 0.27:F1} CHF", s.AtRate, "#2980b9")}
|
{SavingsBox(s.EstMoneySaved, $"~{r.TotalSavingsCHF:F0} CHF", s.AtRate, "#2980b9")}
|
||||||
{SavingsBox(s.SolarCoverage, $"{r.SelfSufficiencyPercent:F0}%", s.FromSolar, "#8e44ad")}
|
{SavingsBox(s.SolarCoverage, $"{r.SelfSufficiencyPercent:F0}%", s.FromSolar, "#8e44ad")}
|
||||||
{SavingsBox(s.BatteryEff, $"{r.BatteryEfficiencyPercent:F0}%", s.OutVsIn, "#e67e22")}
|
{SavingsBox(s.BatteryEff, $"{r.BatteryEfficiencyPercent:F0}%", s.OutVsIn, "#e67e22")}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -407,10 +407,10 @@ public static class ReportEmailService
|
||||||
{
|
{
|
||||||
result = line;
|
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 = System.Text.RegularExpressions.Regex.Replace(
|
||||||
result,
|
result,
|
||||||
@"(\d+[\d,.]*\s*(?:kWh|CHF|%|days?))",
|
@"(\d{1,2}:\d{2}(?:[–\-]\d{1,2}:\d{2})?|\d+[.,]\d+|\d+)",
|
||||||
"<strong>$1</strong>");
|
"<strong>$1</strong>");
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using Flurl.Http;
|
using Flurl.Http;
|
||||||
using InnovEnergy.App.Backend.DataTypes;
|
using InnovEnergy.App.Backend.DataTypes;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
@ -9,19 +8,15 @@ public static class WeeklyReportService
|
||||||
{
|
{
|
||||||
private static readonly string TmpReportDir = Environment.CurrentDirectory + "/tmp_report/";
|
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>
|
/// <summary>
|
||||||
/// Generates a full weekly report for the given installation.
|
/// 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>
|
/// </summary>
|
||||||
public static async Task<WeeklyReportResponse> GenerateReportAsync(long installationId, string installationName, string language = "en")
|
public static async Task<WeeklyReportResponse> GenerateReportAsync(long installationId, string installationName, string language = "en")
|
||||||
{
|
{
|
||||||
var xlsxPath = TmpReportDir + installationId + ".xlsx";
|
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
|
// Use cached report if xlsx hasn't changed since cache was written
|
||||||
if (File.Exists(cachePath) && File.Exists(xlsxPath))
|
if (File.Exists(cachePath) && File.Exists(xlsxPath))
|
||||||
|
|
@ -47,8 +42,10 @@ public static class WeeklyReportService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse both daily summaries and hourly intervals from the same xlsx
|
||||||
var allDays = ExcelDataParser.Parse(xlsxPath);
|
var allDays = ExcelDataParser.Parse(xlsxPath);
|
||||||
var report = await GenerateReportFromDataAsync(allDays, installationName, language);
|
var allHourly = ExcelDataParser.ParseHourly(xlsxPath);
|
||||||
|
var report = await GenerateReportFromDataAsync(allDays, allHourly, installationName, language);
|
||||||
|
|
||||||
// Write cache
|
// Write cache
|
||||||
try
|
try
|
||||||
|
|
@ -64,15 +61,18 @@ public static class WeeklyReportService
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Core report generation from daily data. Data-source agnostic.
|
/// Core report generation. Accepts both daily summaries and hourly intervals.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static async Task<WeeklyReportResponse> GenerateReportFromDataAsync(
|
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
|
// Sort by date
|
||||||
allDays = allDays.OrderBy(d => d.Date).ToList();
|
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> previousWeekDays;
|
||||||
List<DailyEnergyData> currentWeekDays;
|
List<DailyEnergyData> currentWeekDays;
|
||||||
|
|
||||||
|
|
@ -87,10 +87,14 @@ public static class WeeklyReportService
|
||||||
currentWeekDays = allDays;
|
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 currentSummary = Summarize(currentWeekDays);
|
||||||
var previousSummary = previousWeekDays.Count > 0 ? Summarize(previousWeekDays) : null;
|
var previousSummary = previousWeekDays.Count > 0 ? Summarize(previousWeekDays) : null;
|
||||||
|
|
||||||
// Calculate key ratios for current week
|
// Key ratios for current week
|
||||||
var selfSufficiency = currentSummary.TotalConsumption > 0
|
var selfSufficiency = currentSummary.TotalConsumption > 0
|
||||||
? Math.Round((currentSummary.TotalConsumption - currentSummary.TotalGridImport) / currentSummary.TotalConsumption * 100, 1)
|
? Math.Round((currentSummary.TotalConsumption - currentSummary.TotalGridImport) / currentSummary.TotalConsumption * 100, 1)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
@ -112,9 +116,21 @@ public static class WeeklyReportService
|
||||||
var consumptionChange = PercentChange(previousSummary?.TotalConsumption, currentSummary.TotalConsumption);
|
var consumptionChange = PercentChange(previousSummary?.TotalConsumption, currentSummary.TotalConsumption);
|
||||||
var gridImportChange = PercentChange(previousSummary?.TotalGridImport, currentSummary.TotalGridImport);
|
var gridImportChange = PercentChange(previousSummary?.TotalGridImport, currentSummary.TotalGridImport);
|
||||||
|
|
||||||
// AI insight
|
// Behavioral pattern from hourly data (pure C# — no AI)
|
||||||
var aiInsight = await GetAiInsightAsync(currentWeekDays, currentSummary, previousSummary,
|
var behavior = BehaviorAnalyzer.Analyze(currentHourlyData);
|
||||||
selfSufficiency, gridDependency, batteryEfficiency, installationName, language);
|
|
||||||
|
// 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
|
return new WeeklyReportResponse
|
||||||
{
|
{
|
||||||
|
|
@ -123,6 +139,9 @@ public static class WeeklyReportService
|
||||||
PeriodEnd = currentWeekDays.Last().Date,
|
PeriodEnd = currentWeekDays.Last().Date,
|
||||||
CurrentWeek = currentSummary,
|
CurrentWeek = currentSummary,
|
||||||
PreviousWeek = previousSummary,
|
PreviousWeek = previousSummary,
|
||||||
|
TotalEnergySaved = totalEnergySaved,
|
||||||
|
TotalSavingsCHF = totalSavingsCHF,
|
||||||
|
DaysEquivalent = daysEquivalent,
|
||||||
SelfSufficiencyPercent = selfSufficiency,
|
SelfSufficiencyPercent = selfSufficiency,
|
||||||
SelfConsumptionPercent = selfConsumption,
|
SelfConsumptionPercent = selfConsumption,
|
||||||
BatteryEfficiencyPercent = batteryEfficiency,
|
BatteryEfficiencyPercent = batteryEfficiency,
|
||||||
|
|
@ -131,6 +150,7 @@ public static class WeeklyReportService
|
||||||
ConsumptionChangePercent = consumptionChange,
|
ConsumptionChangePercent = consumptionChange,
|
||||||
GridImportChangePercent = gridImportChange,
|
GridImportChangePercent = gridImportChange,
|
||||||
DailyData = allDays,
|
DailyData = allDays,
|
||||||
|
Behavior = behavior,
|
||||||
AiInsight = aiInsight,
|
AiInsight = aiInsight,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -163,13 +183,16 @@ public static class WeeklyReportService
|
||||||
_ => "English"
|
_ => "English"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static string FormatHour(int hour) => $"{hour:D2}:00";
|
||||||
|
|
||||||
private static async Task<string> GetAiInsightAsync(
|
private static async Task<string> GetAiInsightAsync(
|
||||||
List<DailyEnergyData> currentWeek,
|
List<DailyEnergyData> currentWeek,
|
||||||
WeeklySummary current,
|
WeeklySummary current,
|
||||||
WeeklySummary? previous,
|
WeeklySummary? previous,
|
||||||
double selfSufficiency,
|
double selfSufficiency,
|
||||||
double gridDependency,
|
double totalEnergySaved,
|
||||||
double batteryEfficiency,
|
double totalSavingsCHF,
|
||||||
|
BehavioralPattern behavior,
|
||||||
string installationName,
|
string installationName,
|
||||||
string language = "en")
|
string language = "en")
|
||||||
{
|
{
|
||||||
|
|
@ -180,45 +203,56 @@ public static class WeeklyReportService
|
||||||
return "AI insight unavailable (API key not configured).";
|
return "AI insight unavailable (API key not configured).";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache key: installation + period + language
|
const double ElectricityPriceCHF = 0.27;
|
||||||
var cacheKey = $"{installationName}_{currentWeek.Last().Date}_{language}";
|
|
||||||
if (InsightCache.TryGetValue(cacheKey, out var cached))
|
|
||||||
return cached;
|
|
||||||
|
|
||||||
// Build compact prompt
|
var bestDay = currentWeek.OrderByDescending(d => d.PvProduction).First();
|
||||||
var dayLines = string.Join("\n", currentWeek.Select(d =>
|
var worstDay = currentWeek.OrderBy(d => d.PvProduction).First();
|
||||||
{
|
var bestDayName = DateTime.Parse(bestDay.Date).ToString("dddd");
|
||||||
var dayName = DateTime.Parse(d.Date).ToString("ddd");
|
var worstDayName = DateTime.Parse(worstDay.Date).ToString("dddd");
|
||||||
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 comparison = previous != null
|
var topBattDay = currentWeek.OrderByDescending(d => d.BatteryCharged).First();
|
||||||
? $"vs Last week: PV {current.TotalPvProduction} vs {previous.TotalPvProduction}, Grid Import {current.TotalGridImport} vs {previous.TotalGridImport}, Consumption {current.TotalConsumption} vs {previous.TotalConsumption}"
|
var topBattDayName = DateTime.Parse(topBattDay.Date).ToString("dddd");
|
||||||
: "No previous week data available.";
|
|
||||||
|
|
||||||
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}"".
|
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.
|
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).
|
CRITICAL: All numbers below are pre-calculated. Use these values as-is — do not recalculate, round differently, or change any number.
|
||||||
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.
|
|
||||||
|
|
||||||
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.
|
DAILY FACTS:
|
||||||
IMPORTANT: Write your entire response in {LanguageName(language)}.
|
- 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):
|
BEHAVIORAL PATTERN (from hourly data this week):
|
||||||
{dayLines}
|
- 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}
|
INSTRUCTIONS:
|
||||||
Solar used at home={solarSavings} kWh ({selfSufficiency}% of consumption covered by solar)
|
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.
|
||||||
Battery-eff={batteryEfficiency}%
|
2. Best vs worst solar day: Use the daily facts. Mention likely weather reason.
|
||||||
{comparison}";
|
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
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -226,7 +260,7 @@ Battery-eff={batteryEfficiency}%
|
||||||
{
|
{
|
||||||
model = "mistral-small-latest",
|
model = "mistral-small-latest",
|
||||||
messages = new[] { new { role = "user", content = prompt } },
|
messages = new[] { new { role = "user", content = prompt } },
|
||||||
max_tokens = 350,
|
max_tokens = 400,
|
||||||
temperature = 0.3
|
temperature = 0.3
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -241,7 +275,6 @@ Battery-eff={batteryEfficiency}%
|
||||||
if (!string.IsNullOrWhiteSpace(content))
|
if (!string.IsNullOrWhiteSpace(content))
|
||||||
{
|
{
|
||||||
var insight = content.Trim();
|
var insight = content.Trim();
|
||||||
InsightCache.TryAdd(cacheKey, insight);
|
|
||||||
Console.WriteLine($"[WeeklyReportService] AI insight generated ({insight.Length} chars).");
|
Console.WriteLine($"[WeeklyReportService] AI insight generated ({insight.Length} chars).");
|
||||||
return insight;
|
return insight;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -100,15 +100,8 @@ function Log(props: LogProps) {
|
||||||
});
|
});
|
||||||
}, [updateCount]);
|
}, [updateCount]);
|
||||||
|
|
||||||
// fetch AI diagnosis for the latest 3 unique errors/warnings
|
// fetch AI diagnosis for all unique errors/warnings from the last 24 hours
|
||||||
// only when installation status is red (2) or orange (1)
|
|
||||||
useEffect(() => {
|
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
|
// filter to last 24 hours only
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const cutoff = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
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));
|
.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 seen = new Set<string>();
|
||||||
const targets: ErrorMessage[] = [];
|
const targets: ErrorMessage[] = [];
|
||||||
for (const item of all) {
|
for (const item of all) {
|
||||||
if (!seen.has(item.description)) {
|
if (!seen.has(item.description)) {
|
||||||
seen.add(item.description);
|
seen.add(item.description);
|
||||||
targets.push(item);
|
targets.push(item);
|
||||||
if (targets.length >= 3) break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -158,7 +151,7 @@ function Log(props: LogProps) {
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
setDiagnosisLoading(false);
|
setDiagnosisLoading(false);
|
||||||
});
|
});
|
||||||
}, [errors, warnings, props.status]);
|
}, [errors, warnings]);
|
||||||
|
|
||||||
const handleErrorButtonPressed = () => {
|
const handleErrorButtonPressed = () => {
|
||||||
setErrorButtonPressed(!errorButtonPressed);
|
setErrorButtonPressed(!errorButtonPressed);
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,9 @@ interface WeeklyReportResponse {
|
||||||
periodEnd: string;
|
periodEnd: string;
|
||||||
currentWeek: WeeklySummary;
|
currentWeek: WeeklySummary;
|
||||||
previousWeek: WeeklySummary | null;
|
previousWeek: WeeklySummary | null;
|
||||||
|
totalEnergySaved: number;
|
||||||
|
totalSavingsCHF: number;
|
||||||
|
daysEquivalent: number;
|
||||||
selfSufficiencyPercent: number;
|
selfSufficiencyPercent: number;
|
||||||
selfConsumptionPercent: number;
|
selfConsumptionPercent: number;
|
||||||
batteryEfficiencyPercent: number;
|
batteryEfficiencyPercent: number;
|
||||||
|
|
@ -54,25 +57,25 @@ interface WeeklyReportResponse {
|
||||||
aiInsight: string;
|
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
|
// Renders a bullet line: bolds the "Title" part before the first colon, and numbers with units
|
||||||
function FormattedBullet({ text }: { text: string }) {
|
function FormattedBullet({ text }: { text: string }) {
|
||||||
const colonIdx = text.indexOf(':');
|
const colonIdx = text.indexOf(':');
|
||||||
if (colonIdx > 0) {
|
if (colonIdx > 0) {
|
||||||
const title = text.slice(0, colonIdx);
|
const title = text.slice(0, colonIdx);
|
||||||
const rest = text.slice(colonIdx + 1); // e.g. " This week, your system saved 120.9 kWh..."
|
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(BOLD_PATTERN).map((p, i) =>
|
||||||
const restParts = rest.split(/(\d+[\d,.]*\s*(?:kWh|CHF|%|days?))/g).map((p, i) =>
|
isBold(p) ? <strong key={i}>{p}</strong> : <span key={i}>{p}</span>
|
||||||
/\d+[\d,.]*\s*(?:kWh|CHF|%|days?)/.test(p)
|
|
||||||
? <strong key={i}>{p}</strong>
|
|
||||||
: <span key={i}>{p}</span>
|
|
||||||
);
|
);
|
||||||
return <><strong>{title}</strong>:{restParts}</>;
|
return <><strong>{title}</strong>:{restParts}</>;
|
||||||
}
|
}
|
||||||
// No colon — just bold numbers
|
// No colon — just bold figures
|
||||||
const parts = text.split(/(\d+[\d,.]*\s*(?:kWh|CHF|%|days?))/g).map((p, i) =>
|
const parts = text.split(BOLD_PATTERN).map((p, i) =>
|
||||||
/\d+[\d,.]*\s*(?:kWh|CHF|%|days?)/.test(p)
|
isBold(p) ? <strong key={i}>{p}</strong> : <span key={i}>{p}</span>
|
||||||
? <strong key={i}>{p}</strong>
|
|
||||||
: <span key={i}>{p}</span>
|
|
||||||
);
|
);
|
||||||
return <>{parts}</>;
|
return <>{parts}</>;
|
||||||
}
|
}
|
||||||
|
|
@ -159,6 +162,10 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
|
||||||
const cur = report.currentWeek;
|
const cur = report.currentWeek;
|
||||||
const prev = report.previousWeek;
|
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) =>
|
const formatChange = (pct: number) =>
|
||||||
pct === 0 ? '—' : pct > 0 ? `+${pct.toFixed(1)}%` : `${pct.toFixed(1)}%`;
|
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())
|
.map((line) => line.replace(/^[\d]+[.)]\s*/, '').replace(/^[-*]\s*/, '').trim())
|
||||||
.filter((line) => line.length > 0);
|
.filter((line) => line.length > 0);
|
||||||
|
|
||||||
// Savings-focused KPI values
|
// Read pre-computed values from backend — no arithmetic in the frontend
|
||||||
const solarSavingsKwh = Math.round((cur.totalPvProduction - cur.totalGridExport) * 10) / 10;
|
const totalEnergySavedKwh = report.totalEnergySaved;
|
||||||
const estimatedSavingsCHF = Math.round(solarSavingsKwh * 0.27 * 10) / 10;
|
const totalSavingsCHF = report.totalSavingsCHF;
|
||||||
|
|
||||||
// Find max value for daily bar chart scaling
|
// Find max value for daily bar chart scaling
|
||||||
const maxDailyValue = Math.max(
|
const maxDailyValue = Math.max(
|
||||||
|
|
@ -270,15 +277,16 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
|
||||||
<Grid item xs={6} sm={3}>
|
<Grid item xs={6} sm={3}>
|
||||||
<SavingsCard
|
<SavingsCard
|
||||||
label={intl.formatMessage({ id: 'solarEnergyUsed' })}
|
label={intl.formatMessage({ id: 'solarEnergyUsed' })}
|
||||||
value={`${solarSavingsKwh} kWh`}
|
value={`${totalEnergySavedKwh} kWh`}
|
||||||
subtitle={intl.formatMessage({ id: 'solarStayedHome' })}
|
subtitle={intl.formatMessage({ id: 'solarStayedHome' })}
|
||||||
color="#27ae60"
|
color="#27ae60"
|
||||||
|
hint={report.daysEquivalent > 0 ? `≈ ${report.daysEquivalent} ${intl.formatMessage({ id: 'daysOfYourUsage' })}` : undefined}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={6} sm={3}>
|
<Grid item xs={6} sm={3}>
|
||||||
<SavingsCard
|
<SavingsCard
|
||||||
label={intl.formatMessage({ id: 'estMoneySaved' })}
|
label={intl.formatMessage({ id: 'estMoneySaved' })}
|
||||||
value={`~${estimatedSavingsCHF} CHF`}
|
value={`~${totalSavingsCHF} CHF`}
|
||||||
subtitle={intl.formatMessage({ id: 'atCHFRate' })}
|
subtitle={intl.formatMessage({ id: 'atCHFRate' })}
|
||||||
color="#2980b9"
|
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: '#888' }}>{prev.totalConsumption.toFixed(1)} kWh</td>}
|
||||||
{prev && <td style={{ textAlign: 'right', color: changeColor(report.consumptionChangePercent, true) }}>{formatChange(report.consumptionChangePercent)}</td>}
|
{prev && <td style={{ textAlign: 'right', color: changeColor(report.consumptionChangePercent, true) }}>{formatChange(report.consumptionChangePercent)}</td>}
|
||||||
</tr>
|
</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>
|
<tr>
|
||||||
<td><FormattedMessage id="gridImport" defaultMessage="Grid Import" /></td>
|
<td><FormattedMessage id="gridImport" defaultMessage="Grid Import" /></td>
|
||||||
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{cur.totalGridImport.toFixed(1)} kWh</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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -442,6 +462,11 @@ function SavingsCard({ label, value, subtitle, color }: { label: string; value:
|
||||||
<Typography variant="caption" sx={{ color: '#888' }}>
|
<Typography variant="caption" sx={{ color: '#888' }}>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{hint && (
|
||||||
|
<Typography variant="caption" sx={{ display: 'block', color, fontWeight: 'bold', mt: 0.5 }}>
|
||||||
|
{hint}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -98,12 +98,14 @@
|
||||||
"reportTitle": "Wöchentlicher Leistungsbericht",
|
"reportTitle": "Wöchentlicher Leistungsbericht",
|
||||||
"weeklyInsights": "Wöchentliche Einblicke",
|
"weeklyInsights": "Wöchentliche Einblicke",
|
||||||
"weeklySavings": "Ihre Einsparungen diese Woche",
|
"weeklySavings": "Ihre Einsparungen diese Woche",
|
||||||
"solarEnergyUsed": "Genutzte Solarenergie",
|
"solarEnergyUsed": "Energie gespart",
|
||||||
"solarStayedHome": "Ihrer Solarenergie blieb zu Hause",
|
"solarStayedHome": "Solar + Batterie, nicht vom Netz",
|
||||||
|
"daysOfYourUsage": "Tage Ihres Verbrauchs",
|
||||||
"estMoneySaved": "Geschätzte Ersparnisse",
|
"estMoneySaved": "Geschätzte Ersparnisse",
|
||||||
"atCHFRate": "bei 0,27 CHF/kWh Ø",
|
"atCHFRate": "bei 0,27 CHF/kWh Ø",
|
||||||
"solarCoverage": "Solarabdeckung",
|
"solarCoverage": "Eigenversorgung",
|
||||||
"fromSolarSub": "des Verbrauchs aus Solar",
|
"fromSolarSub": "aus Solar + Batterie",
|
||||||
|
"avgDailyConsumption": "Ø Tagesverbrauch",
|
||||||
"batteryEfficiency": "Batterieeffizienz",
|
"batteryEfficiency": "Batterieeffizienz",
|
||||||
"batteryEffSub": "Energie aus vs. Energie ein",
|
"batteryEffSub": "Energie aus vs. Energie ein",
|
||||||
"weeklySummary": "Wöchentliche Zusammenfassung",
|
"weeklySummary": "Wöchentliche Zusammenfassung",
|
||||||
|
|
|
||||||
|
|
@ -80,12 +80,14 @@
|
||||||
"reportTitle": "Weekly Performance Report",
|
"reportTitle": "Weekly Performance Report",
|
||||||
"weeklyInsights": "Weekly Insights",
|
"weeklyInsights": "Weekly Insights",
|
||||||
"weeklySavings": "Your Savings This Week",
|
"weeklySavings": "Your Savings This Week",
|
||||||
"solarEnergyUsed": "Solar Energy Used",
|
"solarEnergyUsed": "Energy Saved",
|
||||||
"solarStayedHome": "of your solar stayed at home",
|
"solarStayedHome": "solar + battery, not bought from grid",
|
||||||
|
"daysOfYourUsage": "days of your usage",
|
||||||
"estMoneySaved": "Est. Money Saved",
|
"estMoneySaved": "Est. Money Saved",
|
||||||
"atCHFRate": "at 0.27 CHF/kWh avg.",
|
"atCHFRate": "at 0.27 CHF/kWh avg.",
|
||||||
"solarCoverage": "Solar Coverage",
|
"solarCoverage": "Self-Sufficiency",
|
||||||
"fromSolarSub": "of consumption from solar",
|
"fromSolarSub": "from solar + battery",
|
||||||
|
"avgDailyConsumption": "Avg Daily Consumption",
|
||||||
"batteryEfficiency": "Battery Efficiency",
|
"batteryEfficiency": "Battery Efficiency",
|
||||||
"batteryEffSub": "energy out vs energy in",
|
"batteryEffSub": "energy out vs energy in",
|
||||||
"weeklySummary": "Weekly Summary",
|
"weeklySummary": "Weekly Summary",
|
||||||
|
|
|
||||||
|
|
@ -92,12 +92,14 @@
|
||||||
"reportTitle": "Rapport de performance hebdomadaire",
|
"reportTitle": "Rapport de performance hebdomadaire",
|
||||||
"weeklyInsights": "Aperçus hebdomadaires",
|
"weeklyInsights": "Aperçus hebdomadaires",
|
||||||
"weeklySavings": "Vos économies cette semaine",
|
"weeklySavings": "Vos économies cette semaine",
|
||||||
"solarEnergyUsed": "Énergie solaire utilisée",
|
"solarEnergyUsed": "Énergie économisée",
|
||||||
"solarStayedHome": "de votre solaire est resté à la maison",
|
"solarStayedHome": "solaire + batterie, non achetée au réseau",
|
||||||
|
"daysOfYourUsage": "jours de votre consommation",
|
||||||
"estMoneySaved": "Économies estimées",
|
"estMoneySaved": "Économies estimées",
|
||||||
"atCHFRate": "à 0,27 CHF/kWh moy.",
|
"atCHFRate": "à 0,27 CHF/kWh moy.",
|
||||||
"solarCoverage": "Couverture solaire",
|
"solarCoverage": "Autosuffisance",
|
||||||
"fromSolarSub": "de la consommation provenant du solaire",
|
"fromSolarSub": "du solaire + batterie",
|
||||||
|
"avgDailyConsumption": "Conso. quotidienne moy.",
|
||||||
"batteryEfficiency": "Efficacité de la batterie",
|
"batteryEfficiency": "Efficacité de la batterie",
|
||||||
"batteryEffSub": "énergie sortante vs énergie entrante",
|
"batteryEffSub": "énergie sortante vs énergie entrante",
|
||||||
"weeklySummary": "Résumé hebdomadaire",
|
"weeklySummary": "Résumé hebdomadaire",
|
||||||
|
|
|
||||||
|
|
@ -103,12 +103,14 @@
|
||||||
"reportTitle": "Rapporto settimanale sulle prestazioni",
|
"reportTitle": "Rapporto settimanale sulle prestazioni",
|
||||||
"weeklyInsights": "Approfondimenti settimanali",
|
"weeklyInsights": "Approfondimenti settimanali",
|
||||||
"weeklySavings": "I tuoi risparmi questa settimana",
|
"weeklySavings": "I tuoi risparmi questa settimana",
|
||||||
"solarEnergyUsed": "Energia solare utilizzata",
|
"solarEnergyUsed": "Energia risparmiata",
|
||||||
"solarStayedHome": "della tua energia solare è rimasta a casa",
|
"solarStayedHome": "solare + batteria, non acquistata dalla rete",
|
||||||
|
"daysOfYourUsage": "giorni del tuo consumo",
|
||||||
"estMoneySaved": "Risparmio stimato",
|
"estMoneySaved": "Risparmio stimato",
|
||||||
"atCHFRate": "a 0,27 CHF/kWh media",
|
"atCHFRate": "a 0,27 CHF/kWh media",
|
||||||
"solarCoverage": "Copertura solare",
|
"solarCoverage": "Autosufficienza",
|
||||||
"fromSolarSub": "del consumo da fonte solare",
|
"fromSolarSub": "da solare + batteria",
|
||||||
|
"avgDailyConsumption": "Consumo medio giornaliero",
|
||||||
"batteryEfficiency": "Efficienza della batteria",
|
"batteryEfficiency": "Efficienza della batteria",
|
||||||
"batteryEffSub": "energia in uscita vs energia in entrata",
|
"batteryEffSub": "energia in uscita vs energia in entrata",
|
||||||
"weeklySummary": "Riepilogo settimanale",
|
"weeklySummary": "Riepilogo settimanale",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue