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? 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; }
|
||||
|
|
@ -21,6 +26,7 @@ public class WeeklyReportResponse
|
|||
public double GridImportChangePercent { get; set; }
|
||||
|
||||
public List<DailyEnergyData> DailyData { get; set; } = new();
|
||||
public BehavioralPattern? Behavior { 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 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);
|
||||
|
|
|
|||
|
|
@ -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:00–18:00), times (09:00), decimals, integers
|
||||
result = System.Text.RegularExpressions.Regex.Replace(
|
||||
result,
|
||||
@"(\d+[\d,.]*\s*(?:kWh|CHF|%|days?))",
|
||||
@"(\d{1,2}:\d{2}(?:[–\-]\d{1,2}:\d{2})?|\d+[.,]\d+|\d+)",
|
||||
"<strong>$1</strong>");
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|||
}
|
||||
}
|
||||
|
||||
// Parse both daily summaries and hourly intervals from the same xlsx
|
||||
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
|
||||
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,10 +87,14 @@ 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
|
||||
// Key ratios for current week
|
||||
var selfSufficiency = currentSummary.TotalConsumption > 0
|
||||
? Math.Round((currentSummary.TotalConsumption - currentSummary.TotalGridImport) / currentSummary.TotalConsumption * 100, 1)
|
||||
: 0;
|
||||
|
|
@ -112,9 +116,21 @@ 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
|
||||
{
|
||||
|
|
@ -123,6 +139,9 @@ public static class WeeklyReportService
|
|||
PeriodEnd = currentWeekDays.Last().Date,
|
||||
CurrentWeek = currentSummary,
|
||||
PreviousWeek = previousSummary,
|
||||
TotalEnergySaved = totalEnergySaved,
|
||||
TotalSavingsCHF = totalSavingsCHF,
|
||||
DaysEquivalent = daysEquivalent,
|
||||
SelfSufficiencyPercent = selfSufficiency,
|
||||
SelfConsumptionPercent = selfConsumption,
|
||||
BatteryEfficiencyPercent = batteryEfficiency,
|
||||
|
|
@ -131,6 +150,7 @@ public static class WeeklyReportService
|
|||
ConsumptionChangePercent = consumptionChange,
|
||||
GridImportChangePercent = gridImportChange,
|
||||
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,45 +203,56 @@ 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
|
||||
{
|
||||
|
|
@ -226,7 +260,7 @@ Battery-eff={batteryEfficiency}%
|
|||
{
|
||||
model = "mistral-small-latest",
|
||||
messages = new[] { new { role = "user", content = prompt } },
|
||||
max_tokens = 350,
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@ interface WeeklyReportResponse {
|
|||
periodEnd: string;
|
||||
currentWeek: WeeklySummary;
|
||||
previousWeek: WeeklySummary | null;
|
||||
totalEnergySaved: number;
|
||||
totalSavingsCHF: number;
|
||||
daysEquivalent: number;
|
||||
selfSufficiencyPercent: number;
|
||||
selfConsumptionPercent: number;
|
||||
batteryEfficiencyPercent: number;
|
||||
|
|
@ -54,25 +57,25 @@ interface WeeklyReportResponse {
|
|||
aiInsight: string;
|
||||
}
|
||||
|
||||
// Matches: time ranges (14:00–18:00), times (09:00), decimals (126.4 / 1,3), integers (34)
|
||||
// Any number in any language gets bolded — no unit matching needed
|
||||
const BOLD_PATTERN = /(\d{1,2}:\d{2}(?:[–\-]\d{1,2}:\d{2})?|\d+[.,]\d+|\d+)/g;
|
||||
const isBold = (s: string) => /\d/.test(s);
|
||||
|
||||
// Renders a bullet line: bolds the "Title" part before the first colon, and numbers with units
|
||||
function FormattedBullet({ text }: { text: string }) {
|
||||
const colonIdx = text.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
const title = text.slice(0, colonIdx);
|
||||
const rest = text.slice(colonIdx + 1); // e.g. " This week, your system saved 120.9 kWh..."
|
||||
// Bold numbers+units in the rest
|
||||
const restParts = rest.split(/(\d+[\d,.]*\s*(?:kWh|CHF|%|days?))/g).map((p, i) =>
|
||||
/\d+[\d,.]*\s*(?:kWh|CHF|%|days?)/.test(p)
|
||||
? <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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue