added weather API to generate predition for weekly report

This commit is contained in:
Yinyin Liu 2026-03-09 16:24:29 +01:00
parent 57ee8be520
commit 8cd602c5cd
12 changed files with 1241 additions and 81 deletions

View File

@ -982,7 +982,7 @@ public class Controller : ControllerBase
{ {
var lang = user.Language ?? "en"; var lang = user.Language ?? "en";
var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.InstallationName, lang); var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.InstallationName, lang);
await ReportEmailService.SendReportEmailAsync(report, emailAddress, lang); await ReportEmailService.SendReportEmailAsync(report, emailAddress, lang, user.Name);
return Ok(new { message = $"Report sent to {emailAddress}" }); return Ok(new { message = $"Report sent to {emailAddress}" });
} }
catch (Exception ex) catch (Exception ex)
@ -1257,7 +1257,7 @@ public class Controller : ControllerBase
{ {
var lang = user.Language ?? "en"; var lang = user.Language ?? "en";
report.AiInsight = await ReportAggregationService.GetOrGenerateMonthlyInsightAsync(report, lang); report.AiInsight = await ReportAggregationService.GetOrGenerateMonthlyInsightAsync(report, lang);
await ReportEmailService.SendMonthlyReportEmailAsync(report, installation.InstallationName, emailAddress, lang); await ReportEmailService.SendMonthlyReportEmailAsync(report, installation.InstallationName, emailAddress, lang, user.Name);
return Ok(new { message = $"Monthly report sent to {emailAddress}" }); return Ok(new { message = $"Monthly report sent to {emailAddress}" });
} }
catch (Exception ex) catch (Exception ex)
@ -1286,7 +1286,7 @@ public class Controller : ControllerBase
{ {
var lang = user.Language ?? "en"; var lang = user.Language ?? "en";
report.AiInsight = await ReportAggregationService.GetOrGenerateYearlyInsightAsync(report, lang); report.AiInsight = await ReportAggregationService.GetOrGenerateYearlyInsightAsync(report, lang);
await ReportEmailService.SendYearlyReportEmailAsync(report, installation.InstallationName, emailAddress, lang); await ReportEmailService.SendYearlyReportEmailAsync(report, installation.InstallationName, emailAddress, lang, user.Name);
return Ok(new { message = $"Yearly report sent to {emailAddress}" }); return Ok(new { message = $"Yearly report sent to {emailAddress}" });
} }
catch (Exception ex) catch (Exception ex)
@ -1922,7 +1922,5 @@ public class Controller : ControllerBase
}; };
} }
} }

View File

@ -378,7 +378,8 @@ public static class ReportAggregationService
installationName, monthName, days.Count, installationName, monthName, days.Count,
totalPv, totalConsump, totalGridIn, totalGridOut, totalPv, totalConsump, totalGridIn, totalGridOut,
totalBattChg, totalBattDis, energySaved, savingsCHF, totalBattChg, totalBattDis, energySaved, savingsCHF,
selfSufficiency, batteryEff, language); selfSufficiency, batteryEff, language,
installation?.Location, installation?.Country, installation?.Region);
var monthlySummary = new MonthlyReportSummary var monthlySummary = new MonthlyReportSummary
{ {
@ -577,7 +578,8 @@ public static class ReportAggregationService
public static Task<String> GetOrGenerateMonthlyInsightAsync( public static Task<String> GetOrGenerateMonthlyInsightAsync(
MonthlyReportSummary report, String language) MonthlyReportSummary report, String language)
{ {
var installationName = Db.GetInstallationById(report.InstallationId)?.InstallationName var installation = Db.GetInstallationById(report.InstallationId);
var installationName = installation?.InstallationName
?? $"Installation {report.InstallationId}"; ?? $"Installation {report.InstallationId}";
var monthName = new DateTime(report.Year, report.Month, 1).ToString("MMMM yyyy"); var monthName = new DateTime(report.Year, report.Month, 1).ToString("MMMM yyyy");
return GetOrGenerateInsightAsync("monthly", report.Id, language, return GetOrGenerateInsightAsync("monthly", report.Id, language,
@ -587,7 +589,8 @@ public static class ReportAggregationService
report.TotalGridImport, report.TotalGridExport, report.TotalGridImport, report.TotalGridExport,
report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalBatteryCharged, report.TotalBatteryDischarged,
report.TotalEnergySaved, report.TotalSavingsCHF, report.TotalEnergySaved, report.TotalSavingsCHF,
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, language)); report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, language,
installation?.Location, installation?.Country, installation?.Region));
} }
/// <summary>Cached-or-generated AI insight for a stored YearlyReportSummary.</summary> /// <summary>Cached-or-generated AI insight for a stored YearlyReportSummary.</summary>
@ -670,7 +673,8 @@ Exactly 4 bullet points. Each starts with ""- Title: description"" format.";
Double totalBattChg, Double totalBattDis, Double totalBattChg, Double totalBattDis,
Double energySaved, Double savingsCHF, Double energySaved, Double savingsCHF,
Double selfSufficiency, Double batteryEff, Double selfSufficiency, Double batteryEff,
String language = "en") String language = "en",
String? location = null, String? country = null, String? region = null)
{ {
var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY"); var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY");
if (String.IsNullOrWhiteSpace(apiKey)) if (String.IsNullOrWhiteSpace(apiKey))
@ -681,6 +685,15 @@ Exactly 4 bullet points. Each starts with ""- Title: description"" format.";
// Determine which metric is weakest so the tip can be targeted // Determine which metric is weakest so the tip can be targeted
var weakMetric = batteryEff < 80 ? "battery" : selfSufficiency < 40 ? "self-sufficiency" : "general"; var weakMetric = batteryEff < 80 ? "battery" : selfSufficiency < 40 ? "self-sufficiency" : "general";
// Fetch weather forecast for the installation's location
var forecast = await WeatherService.GetForecastAsync(location, country, region);
var weatherBlock = forecast != null
? "\n" + WeatherService.FormatForPrompt(forecast) + "\n"
: "";
var weatherTipHint = forecast != null
? " Consider the upcoming 7-day weather forecast when suggesting the tip."
: "";
var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"". var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"".
Write a concise monthly performance summary in {langName} (4 bullet points, plain text, no markdown). Write a concise monthly performance summary in {langName} (4 bullet points, plain text, no markdown).
@ -691,12 +704,12 @@ MONTHLY FACTS for {monthName} ({weekCount} days of data):
- Self-sufficiency: {selfSufficiency:F1}% (share of energy covered by solar, without drawing from grid) - Self-sufficiency: {selfSufficiency:F1}% (share of energy covered by solar, without drawing from grid)
- Battery: {totalBattChg:F1} kWh charged, {totalBattDis:F1} kWh discharged, efficiency {batteryEff:F1}% - Battery: {totalBattChg:F1} kWh charged, {totalBattDis:F1} kWh discharged, efficiency {batteryEff:F1}%
- Energy saved: {energySaved:F1} kWh = ~{savingsCHF:F0} CHF saved (at {ElectricityPriceCHF} CHF/kWh) - Energy saved: {energySaved:F1} kWh = ~{savingsCHF:F0} CHF saved (at {ElectricityPriceCHF} CHF/kWh)
{weatherBlock}
INSTRUCTIONS: INSTRUCTIONS:
1. Savings: state exactly how much energy and money was saved this month. Positive framing. 1. Savings: state exactly how much energy and money was saved this month. Positive framing.
2. Solar performance: how much the solar system produced and what self-sufficiency % means for the homeowner (e.g. ""Your solar system covered X% of your home's energy needs""). Do NOT mention battery here. Do NOT mention raw grid import kWh. 2. Solar performance: how much the solar system produced and what self-sufficiency % means for the homeowner (e.g. ""Your solar system covered X% of your home's energy needs""). Do NOT mention battery here. Do NOT mention raw grid import kWh.
3. Battery: comment on battery utilization and efficiency. If efficiency < 80%, note it may need attention. 3. Battery: comment on battery utilization and efficiency. If efficiency < 80%, note it may need attention.
4. Tip: one specific actionable suggestion based on the weakest metric: {weakMetric}. If general, suggest the most impactful habit change based on the numbers above. 4. Tip: one specific actionable suggestion based on the weakest metric: {weakMetric}.{weatherTipHint} If general, suggest the most impactful habit change based on the numbers above.
Rules: Write in {langName}. Write for a homeowner, not a technician. No asterisks or formatting marks. No closing remarks. Exactly 4 bullet points starting with ""- "". Each bullet must have a short title followed by colon then description."; Rules: Write in {langName}. Write for a homeowner, not a technician. No asterisks or formatting marks. No closing remarks. Exactly 4 bullet points starting with ""- "". Each bullet must have a short title followed by colon then description.";

View File

@ -13,11 +13,12 @@ public static class ReportEmailService
/// Sends the weekly report as a nicely formatted HTML email in the user's language. /// Sends the weekly report as a nicely formatted HTML email in the user's language.
/// Uses MailKit directly (same config as existing Mailer library) but with HTML support. /// Uses MailKit directly (same config as existing Mailer library) but with HTML support.
/// </summary> /// </summary>
public static async Task SendReportEmailAsync(WeeklyReportResponse report, string recipientEmail, string language = "en") public static async Task SendReportEmailAsync(WeeklyReportResponse report, string recipientEmail, string language = "en", string customerName = null)
{ {
var strings = GetStrings(language); var strings = GetStrings(language);
var subject = $"{strings.Title} — {report.InstallationName} ({report.PeriodStart} to {report.PeriodEnd})"; var nameSegment = !string.IsNullOrWhiteSpace(customerName) ? $" — {customerName}" : "";
var html = BuildHtmlEmail(report, strings); var subject = $"{strings.Title} — {report.InstallationName}{nameSegment} ({report.PeriodStart} to {report.PeriodEnd})";
var html = BuildHtmlEmail(report, strings, customerName);
var config = await ReadMailerConfig(); var config = await ReadMailerConfig();
@ -209,10 +210,10 @@ public static class ReportEmailService
// ── HTML email template ───────────────────────────────────────────── // ── HTML email template ─────────────────────────────────────────────
public static string BuildHtmlEmail(WeeklyReportResponse r, string language = "en") public static string BuildHtmlEmail(WeeklyReportResponse r, string language = "en", string customerName = null)
=> BuildHtmlEmail(r, GetStrings(language)); => BuildHtmlEmail(r, GetStrings(language), customerName);
private static string BuildHtmlEmail(WeeklyReportResponse r, EmailStrings s) private static string BuildHtmlEmail(WeeklyReportResponse r, EmailStrings s, string customerName = null)
{ {
var cur = r.CurrentWeek; var cur = r.CurrentWeek;
var prev = r.PreviousWeek; var prev = r.PreviousWeek;
@ -331,6 +332,7 @@ public static class ReportEmailService
<td style=""background:#2c3e50;padding:24px 30px;color:#ffffff""> <td style=""background:#2c3e50;padding:24px 30px;color:#ffffff"">
<div style=""font-size:20px;font-weight:bold"">{s.Title}</div> <div style=""font-size:20px;font-weight:bold"">{s.Title}</div>
<div style=""font-size:14px;margin-top:6px;opacity:0.9"">{r.InstallationName}</div> <div style=""font-size:14px;margin-top:6px;opacity:0.9"">{r.InstallationName}</div>
{(!string.IsNullOrWhiteSpace(customerName) ? $@"<div style=""font-size:13px;margin-top:2px;opacity:0.8"">{customerName}</div>" : "")}
<div style=""font-size:13px;margin-top:2px;opacity:0.7"">{r.PeriodStart} {r.PeriodEnd}</div> <div style=""font-size:13px;margin-top:2px;opacity:0.7"">{r.PeriodStart} {r.PeriodEnd}</div>
</td> </td>
</tr> </tr>
@ -396,6 +398,7 @@ public static class ReportEmailService
<tr> <tr>
<td style=""background:#f8f9fa;padding:16px 30px;text-align:center;font-size:12px;color:#999;border-top:1px solid #eee""> <td style=""background:#f8f9fa;padding:16px 30px;text-align:center;font-size:12px;color:#999;border-top:1px solid #eee"">
{s.Footer} {s.Footer}
<div style=""margin-top:10px""><a href=""https://monitor.inesco.ch"" style=""color:#999;text-decoration:underline"">View your reports anytime at monitor.inesco.ch</a></div>
</td> </td>
</tr> </tr>
@ -455,17 +458,19 @@ public static class ReportEmailService
MonthlyReportSummary report, MonthlyReportSummary report,
string installationName, string installationName,
string recipientEmail, string recipientEmail,
string language = "en") string language = "en",
string customerName = null)
{ {
var monthNames = language switch { "de" => MonthNamesDe, "fr" => MonthNamesFr, "it" => MonthNamesIt, _ => MonthNamesEn }; var monthNames = language switch { "de" => MonthNamesDe, "fr" => MonthNamesFr, "it" => MonthNamesIt, _ => MonthNamesEn };
var monthName = report.Month >= 1 && report.Month <= 12 ? monthNames[report.Month] : report.Month.ToString(); var monthName = report.Month >= 1 && report.Month <= 12 ? monthNames[report.Month] : report.Month.ToString();
var s = GetAggregatedStrings(language, "monthly"); var s = GetAggregatedStrings(language, "monthly");
var subject = $"{s.Title} — {installationName} ({monthName} {report.Year})"; var nameSegment = !string.IsNullOrWhiteSpace(customerName) ? $" — {customerName}" : "";
var subject = $"{s.Title} — {installationName}{nameSegment} ({monthName} {report.Year})";
var html = BuildAggregatedHtmlEmail(report.PeriodStart, report.PeriodEnd, installationName, var html = BuildAggregatedHtmlEmail(report.PeriodStart, report.PeriodEnd, installationName,
report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport, report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport,
report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF, report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF,
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight, report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight,
$"{report.WeekCount} {s.CountLabel}", s); $"{report.WeekCount} {s.CountLabel}", s, customerName);
await SendHtmlEmailAsync(subject, html, recipientEmail); await SendHtmlEmailAsync(subject, html, recipientEmail);
} }
@ -474,15 +479,17 @@ public static class ReportEmailService
YearlyReportSummary report, YearlyReportSummary report,
string installationName, string installationName,
string recipientEmail, string recipientEmail,
string language = "en") string language = "en",
string customerName = null)
{ {
var s = GetAggregatedStrings(language, "yearly"); var s = GetAggregatedStrings(language, "yearly");
var subject = $"{s.Title} — {installationName} ({report.Year})"; var nameSegment = !string.IsNullOrWhiteSpace(customerName) ? $" — {customerName}" : "";
var subject = $"{s.Title} — {installationName}{nameSegment} ({report.Year})";
var html = BuildAggregatedHtmlEmail(report.PeriodStart, report.PeriodEnd, installationName, var html = BuildAggregatedHtmlEmail(report.PeriodStart, report.PeriodEnd, installationName,
report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport, report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport,
report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF, report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF,
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight, report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight,
$"{report.MonthCount} {s.CountLabel}", s); $"{report.MonthCount} {s.CountLabel}", s, customerName);
await SendHtmlEmailAsync(subject, html, recipientEmail); await SendHtmlEmailAsync(subject, html, recipientEmail);
} }
@ -580,7 +587,7 @@ public static class ReportEmailService
double pvProduction, double consumption, double gridImport, double gridExport, double pvProduction, double consumption, double gridImport, double gridExport,
double batteryCharged, double batteryDischarged, double energySaved, double savingsCHF, double batteryCharged, double batteryDischarged, double energySaved, double savingsCHF,
double selfSufficiency, double batteryEfficiency, string aiInsight, double selfSufficiency, double batteryEfficiency, string aiInsight,
string countLabel, AggregatedEmailStrings s) string countLabel, AggregatedEmailStrings s, string customerName = null)
{ {
var insightLines = aiInsight var insightLines = aiInsight
.Split('\n', StringSplitOptions.RemoveEmptyEntries) .Split('\n', StringSplitOptions.RemoveEmptyEntries)
@ -608,6 +615,7 @@ public static class ReportEmailService
<td style=""background:#2c3e50;padding:24px 30px;color:#ffffff""> <td style=""background:#2c3e50;padding:24px 30px;color:#ffffff"">
<div style=""font-size:20px;font-weight:bold"">{s.Title}</div> <div style=""font-size:20px;font-weight:bold"">{s.Title}</div>
<div style=""font-size:14px;margin-top:6px;opacity:0.9"">{installationName}</div> <div style=""font-size:14px;margin-top:6px;opacity:0.9"">{installationName}</div>
{(!string.IsNullOrWhiteSpace(customerName) ? $@"<div style=""font-size:13px;margin-top:2px;opacity:0.8"">{customerName}</div>" : "")}
<div style=""font-size:13px;margin-top:2px;opacity:0.7"">{periodStart} {periodEnd}</div> <div style=""font-size:13px;margin-top:2px;opacity:0.7"">{periodStart} {periodEnd}</div>
<div style=""font-size:12px;margin-top:2px;opacity:0.5"">{countLabel}</div> <div style=""font-size:12px;margin-top:2px;opacity:0.5"">{countLabel}</div>
</td> </td>
@ -660,6 +668,7 @@ public static class ReportEmailService
<tr> <tr>
<td style=""background:#f8f9fa;padding:16px 30px;text-align:center;font-size:12px;color:#999;border-top:1px solid #eee""> <td style=""background:#f8f9fa;padding:16px 30px;text-align:center;font-size:12px;color:#999;border-top:1px solid #eee"">
{s.Footer} {s.Footer}
<div style=""margin-top:10px""><a href=""https://monitor.inesco.ch"" style=""color:#999;text-decoration:underline"">View your reports anytime at monitor.inesco.ch</a></div>
</td> </td>
</tr> </tr>

View File

@ -0,0 +1,178 @@
using Flurl.Http;
using Newtonsoft.Json;
namespace InnovEnergy.App.Backend.Services;
public static class WeatherService
{
public record DailyWeather(
string Date,
double TempMin,
double TempMax,
double SunshineHours,
double PrecipitationMm,
string Description
);
private static readonly Dictionary<string, (double Lat, double Lon)> GeoCache = new();
/// <summary>
/// Returns a 7-day weather forecast for the given city, or null on any failure.
/// </summary>
public static async Task<List<DailyWeather>?> GetForecastAsync(string? city, string? country, string? region = null)
{
if (string.IsNullOrWhiteSpace(city))
return null;
try
{
var coords = await GeocodeAsync(city, region);
if (coords == null)
return null;
var (lat, lon) = coords.Value;
return await FetchForecastAsync(lat, lon);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[WeatherService] Error fetching forecast for '{city}': {ex.Message}");
return null;
}
}
/// <summary>
/// Formats a forecast list into a compact text block for AI prompt injection.
/// </summary>
public static string FormatForPrompt(List<DailyWeather> forecast)
{
var lines = forecast.Select(d =>
{
var date = DateTime.Parse(d.Date);
var dayName = date.ToString("ddd dd MMM");
return $"- {dayName}: {d.TempMin:F0}{d.TempMax:F0}°C, {d.Description}, {d.SunshineHours:F1}h sunshine, {d.PrecipitationMm:F0}mm rain";
});
return "WEATHER FORECAST (next 7 days):\n" + string.Join("\n", lines);
}
/// <summary>
/// Extracts a geocodable city name from a Location field that may contain a full address.
/// Handles Swiss formats like "Street 5, 8604 Volketswil" → "Volketswil"
/// Also tries the Region field as fallback.
/// </summary>
private static IEnumerable<string> ExtractSearchTerms(string city, string? region)
{
// If it contains a comma, try the part after the last comma (often "PostalCode City")
if (city.Contains(','))
{
var afterComma = city.Split(',').Last().Trim();
// Strip leading postal code (digits) → "8604 Volketswil" → "Volketswil"
var withoutPostal = System.Text.RegularExpressions.Regex.Replace(afterComma, @"^\d+\s*", "").Trim();
if (!string.IsNullOrEmpty(withoutPostal))
yield return withoutPostal;
if (!string.IsNullOrEmpty(afterComma))
yield return afterComma;
}
// Try the raw value as-is
yield return city;
// Fallback to Region
if (!string.IsNullOrWhiteSpace(region))
yield return region;
}
private static async Task<(double Lat, double Lon)?> GeocodeAsync(string city, string? region = null)
{
if (GeoCache.TryGetValue(city, out var cached))
return cached;
foreach (var term in ExtractSearchTerms(city, region))
{
var url = $"https://geocoding-api.open-meteo.com/v1/search?name={Uri.EscapeDataString(term)}&count=1&language=en";
var json = await url.GetStringAsync();
var data = JsonConvert.DeserializeObject<dynamic>(json);
if (data?.results != null && data.results.Count > 0)
{
var lat = (double)data.results[0].latitude;
var lon = (double)data.results[0].longitude;
GeoCache[city] = (lat, lon);
Console.WriteLine($"[WeatherService] Geocoded '{city}' (matched on '{term}') → ({lat:F4}, {lon:F4})");
return (lat, lon);
}
}
Console.WriteLine($"[WeatherService] Could not geocode location: '{city}'");
return null;
}
private static async Task<List<DailyWeather>?> FetchForecastAsync(double lat, double lon)
{
var url = $"https://api.open-meteo.com/v1/forecast"
+ $"?latitude={lat}&longitude={lon}"
+ "&daily=temperature_2m_max,temperature_2m_min,sunshine_duration,precipitation_sum,weathercode"
+ "&timezone=Europe/Zurich&forecast_days=7";
var json = await url.GetStringAsync();
var data = JsonConvert.DeserializeObject<dynamic>(json);
if (data?.daily == null)
return null;
var dates = data.daily.time;
var tempMax = data.daily.temperature_2m_max;
var tempMin = data.daily.temperature_2m_min;
var sun = data.daily.sunshine_duration;
var precip = data.daily.precipitation_sum;
var codes = data.daily.weathercode;
var forecast = new List<DailyWeather>();
for (int i = 0; i < dates.Count; i++)
{
forecast.Add(new DailyWeather(
Date: (string)dates[i],
TempMin: (double)tempMin[i],
TempMax: (double)tempMax[i],
SunshineHours: Math.Round((double)sun[i] / 3600.0, 1),
PrecipitationMm: (double)precip[i],
Description: WeatherCodeToDescription((int)codes[i])
));
}
Console.WriteLine($"[WeatherService] Fetched {forecast.Count}-day forecast.");
return forecast;
}
private static string WeatherCodeToDescription(int code) => code switch
{
0 => "Clear sky",
1 => "Mainly clear",
2 => "Partly cloudy",
3 => "Overcast",
45 => "Fog",
48 => "Depositing rime fog",
51 => "Light drizzle",
53 => "Moderate drizzle",
55 => "Dense drizzle",
61 => "Slight rain",
63 => "Moderate rain",
65 => "Heavy rain",
66 => "Light freezing rain",
67 => "Heavy freezing rain",
71 => "Slight snow",
73 => "Moderate snow",
75 => "Heavy snow",
77 => "Snow grains",
80 => "Slight showers",
81 => "Moderate showers",
82 => "Violent showers",
85 => "Slight snow showers",
86 => "Heavy snow showers",
95 => "Thunderstorm",
96 => "Thunderstorm with slight hail",
99 => "Thunderstorm with heavy hail",
_ => "Unknown"
};
}

View File

@ -1,3 +1,4 @@
using System.Text.RegularExpressions;
using Flurl.Http; using Flurl.Http;
using InnovEnergy.App.Backend.Database; using InnovEnergy.App.Backend.Database;
using InnovEnergy.App.Backend.DataTypes; using InnovEnergy.App.Backend.DataTypes;
@ -9,6 +10,54 @@ public static class WeeklyReportService
{ {
private static readonly string TmpReportDir = Environment.CurrentDirectory + "/tmp_report/"; private static readonly string TmpReportDir = Environment.CurrentDirectory + "/tmp_report/";
/// <summary>
/// Returns xlsx files for the given installation whose date range overlaps [rangeStart, rangeEnd].
/// Filename pattern: {installationId}_MMDD_MMDD.xlsx (e.g. "848_0302_0308.xlsx")
/// Falls back to all files if filenames can't be parsed.
/// </summary>
private static List<string> GetRelevantXlsxFiles(long installationId, DateOnly rangeStart, DateOnly rangeEnd)
{
if (!Directory.Exists(TmpReportDir))
return new List<string>();
var allFiles = Directory.GetFiles(TmpReportDir, $"{installationId}*.xlsx").OrderBy(f => f).ToList();
if (allFiles.Count == 0)
return allFiles;
// Try to filter by filename date range; fall back to all files if parsing fails
var year = rangeStart.Year;
var filtered = new List<string>();
foreach (var file in allFiles)
{
var name = Path.GetFileNameWithoutExtension(file);
// Match pattern: {id}_MMDD_MMDD
var match = Regex.Match(name, @"_(\d{4})_(\d{4})$");
if (!match.Success)
{
// Can't parse filename — include it to be safe
filtered.Add(file);
continue;
}
var startStr = match.Groups[1].Value; // "0302"
var endStr = match.Groups[2].Value; // "0308"
if (!DateOnly.TryParseExact($"{year}-{startStr[..2]}-{startStr[2..]}", "yyyy-MM-dd", out var fileStart) ||
!DateOnly.TryParseExact($"{year}-{endStr[..2]}-{endStr[2..]}", "yyyy-MM-dd", out var fileEnd))
{
filtered.Add(file); // Can't parse — include to be safe
continue;
}
// Include if date ranges overlap
if (fileStart <= rangeEnd && fileEnd >= rangeStart)
filtered.Add(file);
}
return filtered;
}
// ── Calendar Week Helpers ────────────────────────────────────────── // ── Calendar Week Helpers ──────────────────────────────────────────
/// <summary> /// <summary>
@ -75,14 +124,13 @@ public static class WeeklyReportService
// 2. Fallback: if DB empty, parse xlsx on the fly (pre-ingestion scenario) // 2. Fallback: if DB empty, parse xlsx on the fly (pre-ingestion scenario)
if (currentWeekDays.Count == 0) if (currentWeekDays.Count == 0)
{ {
var xlsxFiles = Directory.Exists(TmpReportDir) // Only parse xlsx files whose date range overlaps the needed weeks
? Directory.GetFiles(TmpReportDir, $"{installationId}*.xlsx").OrderBy(f => f).ToList() var relevantFiles = GetRelevantXlsxFiles(installationId, prevMon, curSun);
: new List<String>();
if (xlsxFiles.Count > 0) if (relevantFiles.Count > 0)
{ {
Console.WriteLine($"[WeeklyReportService] DB empty for week {curMon:yyyy-MM-dd}, falling back to xlsx."); Console.WriteLine($"[WeeklyReportService] DB empty for week {curMon:yyyy-MM-dd}, falling back to {relevantFiles.Count} xlsx file(s).");
var allDaysParsed = xlsxFiles.SelectMany(p => ExcelDataParser.Parse(p)).ToList(); var allDaysParsed = relevantFiles.SelectMany(p => ExcelDataParser.Parse(p)).ToList();
currentWeekDays = allDaysParsed currentWeekDays = allDaysParsed
.Where(d => { var date = DateOnly.Parse(d.Date); return date >= curMon && date <= curSun; }) .Where(d => { var date = DateOnly.Parse(d.Date); return date >= curMon && date <= curSun; })
.ToList(); .ToList();
@ -101,9 +149,32 @@ public static class WeeklyReportService
var currentHourlyData = Db.GetHourlyRecords(installationId, curMon, curSun) var currentHourlyData = Db.GetHourlyRecords(installationId, curMon, curSun)
.Select(ToHourlyEnergyData).ToList(); .Select(ToHourlyEnergyData).ToList();
// 3b. Fallback: if DB empty, parse hourly data from xlsx
if (currentHourlyData.Count == 0)
{
var relevantFiles = GetRelevantXlsxFiles(installationId, curMon, curSun);
if (relevantFiles.Count > 0)
{
Console.WriteLine($"[WeeklyReportService] Hourly DB empty for week {curMon:yyyy-MM-dd}, falling back to {relevantFiles.Count} xlsx file(s).");
currentHourlyData = relevantFiles
.SelectMany(p => ExcelDataParser.ParseHourly(p))
.Where(h => { var date = DateOnly.FromDateTime(h.DateTime); return date >= curMon && date <= curSun; })
.ToList();
Console.WriteLine($"[WeeklyReportService] Parsed {currentHourlyData.Count} hourly records from xlsx.");
}
}
// 4. Get installation location for weather forecast
var installation = Db.GetInstallationById(installationId);
var location = installation?.Location;
var country = installation?.Country;
var region = installation?.Region;
Console.WriteLine($"[WeeklyReportService] Installation {installationId}: Location='{location}', Region='{region}', Country='{country}', HourlyRecords={currentHourlyData.Count}");
return await GenerateReportFromDataAsync( return await GenerateReportFromDataAsync(
currentWeekDays, previousWeekDays, currentHourlyData, installationName, language, currentWeekDays, previousWeekDays, currentHourlyData, installationName, language,
curMon, curSun); curMon, curSun, location, country, region);
} }
// ── Conversion helpers ───────────────────────────────────────────── // ── Conversion helpers ─────────────────────────────────────────────
@ -144,7 +215,10 @@ public static class WeeklyReportService
string installationName, string installationName,
string language = "en", string language = "en",
DateOnly? weekStart = null, DateOnly? weekStart = null,
DateOnly? weekEnd = null) DateOnly? weekEnd = null,
string? location = null,
string? country = null,
string? region = null)
{ {
currentWeekDays = currentWeekDays.OrderBy(d => d.Date).ToList(); currentWeekDays = currentWeekDays.OrderBy(d => d.Date).ToList();
previousWeekDays = previousWeekDays.OrderBy(d => d.Date).ToList(); previousWeekDays = previousWeekDays.OrderBy(d => d.Date).ToList();
@ -188,7 +262,7 @@ public static class WeeklyReportService
var aiInsight = await GetAiInsightAsync( var aiInsight = await GetAiInsightAsync(
currentWeekDays, currentSummary, previousSummary, currentWeekDays, currentSummary, previousSummary,
selfSufficiency, totalEnergySaved, totalSavingsCHF, selfSufficiency, totalEnergySaved, totalSavingsCHF,
behavior, installationName, language); behavior, installationName, language, location, country, region);
return new WeeklyReportResponse return new WeeklyReportResponse
{ {
@ -253,7 +327,10 @@ public static class WeeklyReportService
double totalSavingsCHF, double totalSavingsCHF,
BehavioralPattern behavior, BehavioralPattern behavior,
string installationName, string installationName,
string language = "en") string language = "en",
string? location = null,
string? country = null,
string? region = null)
{ {
var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY"); var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY");
if (string.IsNullOrWhiteSpace(apiKey)) if (string.IsNullOrWhiteSpace(apiKey))
@ -262,6 +339,12 @@ public static class WeeklyReportService
return "AI insight unavailable (API key not configured)."; return "AI insight unavailable (API key not configured).";
} }
// Fetch weather forecast for the installation's location
var forecast = await WeatherService.GetForecastAsync(location, country, region);
var weatherBlock = forecast != null ? "\n" + WeatherService.FormatForPrompt(forecast) + "\n" : "";
Console.WriteLine($"[WeeklyReportService] Weather forecast: {(forecast != null ? $"{forecast.Count} days fetched" : "SKIPPED (no location or API error)")}");
if (forecast != null) Console.WriteLine($"[WeeklyReportService] Weather block:\n{weatherBlock}");
const double ElectricityPriceCHF = 0.39; const double ElectricityPriceCHF = 0.39;
// Detect which components are present // Detect which components are present
@ -278,7 +361,10 @@ public static class WeeklyReportService
var topBattDay = currentWeek.OrderByDescending(d => d.BatteryCharged).First(); var topBattDay = currentWeek.OrderByDescending(d => d.BatteryCharged).First();
var topBattDayName = DateTime.Parse(topBattDay.Date).ToString("dddd"); var topBattDayName = DateTime.Parse(topBattDay.Date).ToString("dddd");
// Behavioral facts as compact lines // Check if we have meaningful hourly/behavioral data
var hasBehavior = behavior.AvgPeakLoadKwh > 0 || behavior.AvgPeakSolarKwh > 0;
// Behavioral facts as compact lines (only when hourly data exists)
var peakSolarWindow = FormatHour(behavior.PeakSolarHour) + "" + FormatHour(behavior.PeakSolarEndHour); var peakSolarWindow = FormatHour(behavior.PeakSolarHour) + "" + FormatHour(behavior.PeakSolarEndHour);
var avoidableSavingsCHF = Math.Round(behavior.AvoidableGridKwh * ElectricityPriceCHF, 0); var avoidableSavingsCHF = Math.Round(behavior.AvoidableGridKwh * ElectricityPriceCHF, 0);
@ -303,6 +389,10 @@ public static class WeeklyReportService
? $"- Grid import: {current.TotalGridImport:F1} kWh total this week." ? $"- Grid import: {current.TotalGridImport:F1} kWh total this week."
: ""; : "";
// Behavioral section — only include when hourly data exists
var behavioralSection = "";
if (hasBehavior)
{
var pvBehaviorLines = hasPv ? $@" var pvBehaviorLines = hasPv ? $@"
- Solar active window: {peakSolarWindow}; peak hour: {FormatHourSlot(behavior.PeakSolarHour)}, avg {behavior.AvgPeakSolarKwh} kWh during that hour - Solar active window: {peakSolarWindow}; peak hour: {FormatHourSlot(behavior.PeakSolarHour)}, avg {behavior.AvgPeakSolarKwh} kWh during that hour
- Grid imported while solar was active: {behavior.AvoidableGridKwh} kWh = {avoidableSavingsCHF} CHF that could have been avoided" : ""; - Grid imported while solar was active: {behavior.AvoidableGridKwh} kWh = {avoidableSavingsCHF} CHF that could have been avoided" : "";
@ -313,28 +403,69 @@ public static class WeeklyReportService
var battBehaviorLine = !string.IsNullOrEmpty(battDepleteLine) ? $"- {battDepleteLine}" : ""; var battBehaviorLine = !string.IsNullOrEmpty(battDepleteLine) ? $"- {battDepleteLine}" : "";
behavioralSection = $@"
BEHAVIORAL PATTERN (from hourly data this week):
- Peak household load: {FormatHourSlot(behavior.PeakLoadHour)}, avg {behavior.AvgPeakLoadKwh} kWh during that hour
- {weekdayWeekendLine}{pvBehaviorLines}
{gridBehaviorLine}
{battBehaviorLine}";
}
// Build conditional instructions // Build conditional instructions
var instruction1 = $"1. Energy savings: Write 12 sentences. Say that this week, thanks to sodistore home, the customer avoided buying {totalEnergySaved} kWh from the grid, saving {totalSavingsCHF} CHF (at {ElectricityPriceCHF} CHF/kWh). Use these exact numbers — do not recalculate or change them."; var instruction1 = $"1. Energy savings: Write 12 sentences. Say that this week, thanks to sodistore home, the customer avoided buying {totalEnergySaved} kWh from the grid, saving {totalSavingsCHF} CHF (at {ElectricityPriceCHF} CHF/kWh). Use these exact numbers — do not recalculate or change them.";
var instruction2 = hasPv var instruction2 = hasPv
? $"2. Solar performance: Comment on the best and worst solar day this week and the likely weather reason." ? $"2. Solar performance: Comment on the best and worst solar day this week and the likely weather reason."
: hasGrid : hasGrid
? $"2. Grid usage: Comment on the {current.TotalGridImport:F1} kWh drawn from the grid this week and what time of day drives it most ({FormatHourSlot(behavior.HighestGridImportHour)})." ? $"2. Grid usage: Comment on the {current.TotalGridImport:F1} kWh drawn from the grid this week."
: "2. Consumption pattern: Comment on the weekday vs weekend load pattern."; : "2. Consumption pattern: Comment on the weekday vs weekend load pattern.";
var instruction3 = hasBattery var instruction3 = hasBattery
? $"3. Battery performance: Use the daily facts. Keep it simple for a homeowner." ? $"3. Battery performance: Use the daily facts. Keep it simple for a homeowner."
: "3. Consumption pattern: Comment on the peak load time and weekday vs weekend usage."; : "3. Consumption pattern: Comment on the peak load time and weekday vs weekend usage.";
var instruction4 = hasPv // Instruction 4 — adapts based on whether we have behavioral data
? $"4. Smart action for next week: Write exactly 2 sentences. Sentence 1: point out the timing mismatch using exact numbers — peak household load is during {FormatHourSlot(behavior.PeakLoadHour)} ({behavior.AvgPeakLoadKwh} kWh) but solar peaks during {FormatHourSlot(behavior.PeakSolarHour)} ({behavior.AvgPeakSolarKwh} kWh), with solar active from {peakSolarWindow}. Sentence 2: suggest shifting energy-intensive appliances (such as washing machine, dishwasher, heat pump, or EV charger if applicable) to run during the solar window {peakSolarWindow} — do not assume which specific device the customer has." string instruction4;
: hasGrid if (hasBehavior && hasPv)
? $"4. Smart action for next week: Write exactly 2 sentences. Sentence 1: state that the peak grid-import hour is {FormatHourSlot(behavior.HighestGridImportHour)} ({behavior.AvgGridImportAtPeakHour} kWh avg). Sentence 2: suggest one action to reduce grid use during that hour — shifting energy-intensive appliances (washing machine, dishwasher, heat pump, EV charger) away from that time." instruction4 = $"4. Smart action for next week: Write exactly 2 sentences. Sentence 1: point out the timing mismatch using exact numbers — peak household load is during {FormatHourSlot(behavior.PeakLoadHour)} ({behavior.AvgPeakLoadKwh} kWh) but solar peaks during {FormatHourSlot(behavior.PeakSolarHour)} ({behavior.AvgPeakSolarKwh} kWh), with solar active from {peakSolarWindow}. Sentence 2: suggest shifting energy-intensive appliances (such as washing machine, dishwasher, heat pump, or EV charger if applicable) to run during the solar window {peakSolarWindow} — do not assume which specific device the customer has.";
: "4. Smart action for next week: Give one practical tip to reduce energy consumption based on the peak load time and weekday/weekend pattern."; else if (hasBehavior && hasGrid)
instruction4 = $"4. Smart action for next week: Write exactly 2 sentences. Sentence 1: state that the peak grid-import hour is {FormatHourSlot(behavior.HighestGridImportHour)} ({behavior.AvgGridImportAtPeakHour} kWh avg). Sentence 2: suggest one action to reduce grid use during that hour — shifting energy-intensive appliances (washing machine, dishwasher, heat pump, EV charger) away from that time.";
else
instruction4 = "4. Smart action for next week: Based on this week's daily energy patterns, suggest one practical tip to maximize self-consumption and reduce grid dependency.";
// Instruction 5 — weather outlook with pattern-based predictions
var hasWeather = forecast != null;
var bulletCount = hasWeather ? 5 : 4;
var instruction5 = "";
if (hasWeather && hasPv)
{
// Compute avg daily PV production this week for reference
var avgDailyPv = currentWeek.Count > 0 ? Math.Round(currentWeek.Average(d => d.PvProduction), 1) : 0;
var bestDayPv = Math.Round(bestDay.PvProduction, 1);
var worstDayPv = Math.Round(worstDay.PvProduction, 1);
// Classify forecast days by sunshine potential
var sunnyDays = forecast.Where(d => d.SunshineHours >= 7).Select(d => DateTime.Parse(d.Date).ToString("dddd")).ToList();
var cloudyDays = forecast.Where(d => d.SunshineHours < 3).Select(d => DateTime.Parse(d.Date).ToString("dddd")).ToList();
var totalForecastSunshine = Math.Round(forecast.Sum(d => d.SunshineHours), 1);
var patternContext = $"This week the installation averaged {avgDailyPv} kWh/day PV (best: {bestDayPv} kWh on {bestDayName}, worst: {worstDayPv} kWh on {worstDayName}). ";
if (sunnyDays.Count > 0)
patternContext += $"Next week, sunny days ({string.Join(", ", sunnyDays)}) should produce above average (~{avgDailyPv}+ kWh/day). ";
if (cloudyDays.Count > 0)
patternContext += $"Cloudy/rainy days ({string.Join(", ", cloudyDays)}) will likely produce below average (~{worstDayPv} kWh/day or less). ";
patternContext += $"Total forecast sunshine next week: {totalForecastSunshine}h.";
instruction5 = $@"5. Weather outlook: {patternContext} Write 2-3 concise sentences. (1) Name the best solar days next week and estimate production based on this week's pattern. (2) Recommend which specific days are best for running energy-heavy appliances (washing machine, dishwasher, EV charging) to maximize free solar energy. (3) If rainy days follow sunny days, suggest front-loading heavy usage onto the sunny days before the rain arrives. IMPORTANT: Do NOT suggest ""reducing consumption"" in the evening — people need electricity for cooking, lighting, and daily life. Instead, focus on SHIFTING discretionary loads (laundry, dishwasher, EV) to the best solar days. The battery system handles grid charging automatically — do not tell the customer to manage battery charging.";
}
else if (hasWeather)
{
instruction5 = @"5. Weather outlook: Summarize the coming week's weather in 1-2 sentences. Mention which days have the most sunshine and suggest those as the best days to run energy-heavy appliances (laundry, dishwasher). Do NOT suggest reducing evening consumption — focus on shifting discretionary loads to sunny days.";
}
var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"". var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"".
Write 4 bullet points (each on its own line starting with ""- ""). No bold markers, no asterisks, no markdown plain text only. Write {bulletCount} bullet points (each on its own line starting with ""- ""). No bold markers, no asterisks, no markdown plain text only.
IMPORTANT FORMAT RULE: Each bullet MUST start with a short title followed by a colon, then the description. Example: ""- Title label: Description text here."" Translate the title label into {LanguageName(language)} but always keep the ""Title: description"" structure. 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.
@ -347,20 +478,16 @@ DAILY FACTS:
{pvDailyFact} {pvDailyFact}
{battDailyFact} {battDailyFact}
{gridDailyFact} {gridDailyFact}
{behavioralSection}
BEHAVIORAL PATTERN (from hourly data this week): {weatherBlock}
- Peak household load: {FormatHourSlot(behavior.PeakLoadHour)}, avg {behavior.AvgPeakLoadKwh} kWh during that hour
- {weekdayWeekendLine}{pvBehaviorLines}
{gridBehaviorLine}
{battBehaviorLine}
INSTRUCTIONS: INSTRUCTIONS:
{instruction1} {instruction1}
{instruction2} {instruction2}
{instruction3} {instruction3}
{instruction4} {instruction4}
{instruction5}
Rules: Write for a homeowner, not an engineer. Do NOT use asterisks or any formatting marks. Only describe components that exist (PV={hasPv}, Battery={hasBattery}, Grid={hasGrid}). Do NOT add any closing remark, summary sentence, or motivational phrase after the 4 bullet points (e.g. no 'Das ist ein guter Fortschritt', 'Keep it up', 'Good progress', etc.). Write exactly 4 bullet points nothing before, nothing after. Rules: Write for a homeowner, not an engineer. Do NOT use asterisks or any formatting marks. Only describe components that exist (PV={hasPv}, Battery={hasBattery}, Grid={hasGrid}). Do NOT add any closing remark, summary sentence, or motivational phrase after the {bulletCount} bullet points (e.g. no 'Das ist ein guter Fortschritt', 'Keep it up', 'Good progress', etc.). Write exactly {bulletCount} bullet points nothing before, nothing after.
IMPORTANT: Write your entire response in {LanguageName(language)}."; IMPORTANT: Write your entire response in {LanguageName(language)}.";
try try
@ -369,7 +496,7 @@ IMPORTANT: Write your entire response in {LanguageName(language)}.";
{ {
model = "mistral-small-latest", model = "mistral-small-latest",
messages = new[] { new { role = "user", content = prompt } }, messages = new[] { new { role = "user", content = prompt } },
max_tokens = 400, max_tokens = 600,
temperature = 0.3 temperature = 0.3
}; };

View File

@ -0,0 +1,621 @@
# Provider-Specific Pricing for Reports — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Replace the hard-coded 0.39 CHF/kWh electricity price with real per-provider tariffs from ELCOM, so report savings reflect each installation's actual network provider rate.
**Architecture:** New `ProviderTariff` SQLite model stores ELCOM tariff data (total + breakdown) per provider per year. New `PricingService` fetches tariffs via SPARQL, caches in DB, and exposes a lookup method. All three report services (`WeeklyReportService`, `ReportAggregationService`, `ReportEmailService`) replace the hard-coded constant with a dynamic lookup using the installation's `NetworkProvider` field.
**Tech Stack:** C#/.NET, SQLite-net ORM, SPARQL (ELCOM/LINDAS endpoint), Flurl HTTP
**Decisions made during brainstorming:**
- Fetch total price now, store breakdown components for future use
- Fetch once per provider+year, store in SQLite (ELCOM tariffs are fixed per year)
- Only new reports going forward use real tariffs (existing stored reports untouched)
- Fallback to 0.39 CHF/kWh if provider has no tariff data
- Scope: accurate savings only (no provider comparison or ROI features yet)
---
## Task 1: Create ProviderTariff Data Model
**Files:**
- Create: `csharp/App/Backend/DataTypes/ProviderTariff.cs`
**Step 1: Create the data model**
```csharp
using SQLite;
namespace InnovEnergy.App.Backend.DataTypes;
/// <summary>
/// Cached ELCOM electricity tariff for a network provider and year.
/// Fetched from lindas.admin.ch/elcom/electricityprice via SPARQL.
/// Tariffs are fixed per year — fetched once and stored permanently.
/// </summary>
public class ProviderTariff
{
[PrimaryKey, AutoIncrement]
public Int64 Id { get; set; }
[Indexed]
public String ProviderName { get; set; } = "";
[Indexed]
public Int32 Year { get; set; }
// Total electricity price (CHF/kWh) — used for savings calculation
public Double TotalPricePerKwh { get; set; }
// Breakdown components (CHF/kWh) — stored for future use
public Double GridUsagePerKwh { get; set; } // Netznutzung
public Double EnergyPerKwh { get; set; } // Energielieferung
public Double FeesPerKwh { get; set; } // Abgaben an Gemeinwesen + KEV/SDL
public String FetchedAt { get; set; } = "";
}
```
**Step 2: Register table in Db.cs**
In `csharp/App/Backend/Database/Db.cs`:
Add table accessor after line 39 (after TicketTimelineEvents):
```csharp
public static TableQuery<ProviderTariff> ProviderTariffs => Connection.Table<ProviderTariff>();
```
Add `CreateTable` call inside the `RunInTransaction` block (after line 77, after TicketTimelineEvent):
```csharp
Connection.CreateTable<ProviderTariff>();
```
**Step 3: Build to verify**
Run: `cd csharp/App/Backend && dotnet build`
Expected: Build succeeds
**Step 4: Commit**
```bash
git add csharp/App/Backend/DataTypes/ProviderTariff.cs csharp/App/Backend/Database/Db.cs
git commit -m "feat: add ProviderTariff data model for ELCOM pricing"
```
---
## Task 2: Add Database Read/Create Methods for ProviderTariff
**Files:**
- Modify: `csharp/App/Backend/Database/Read.cs`
- Modify: `csharp/App/Backend/Database/Create.cs`
**Step 1: Add read method in Read.cs**
Add at the end of the file (before closing brace):
```csharp
public static ProviderTariff? GetProviderTariff(string providerName, int year)
{
return ProviderTariffs
.FirstOrDefault(t => t.ProviderName == providerName && t.Year == year);
}
```
**Step 2: Add create method in Create.cs**
Add at the end of the file (before closing brace):
```csharp
public static void InsertProviderTariff(ProviderTariff tariff)
{
Connection.Insert(tariff);
}
```
**Step 3: Build to verify**
Run: `cd csharp/App/Backend && dotnet build`
Expected: Build succeeds
**Step 4: Commit**
```bash
git add csharp/App/Backend/Database/Read.cs csharp/App/Backend/Database/Create.cs
git commit -m "feat: add DB read/create methods for ProviderTariff"
```
---
## Task 3: Create PricingService
**Files:**
- Create: `csharp/App/Backend/Services/PricingService.cs`
**Step 1: Create the service**
This service:
1. Exposes `GetElectricityPrice(providerName, year)` -> returns CHF/kWh (double)
2. Checks SQLite cache first
3. If not cached, fetches from ELCOM SPARQL and stores
4. Falls back to 0.39 if provider not found or fetch fails
```csharp
using Flurl.Http;
using InnovEnergy.App.Backend.Database;
using InnovEnergy.App.Backend.DataTypes;
using Newtonsoft.Json.Linq;
namespace InnovEnergy.App.Backend.Services;
/// <summary>
/// Provides electricity tariffs per network provider and year.
/// Source: ELCOM/LINDAS SPARQL endpoint (Swiss electricity price database).
/// Caches results in SQLite — tariffs are fixed per year.
/// Falls back to 0.39 CHF/kWh if provider data is unavailable.
/// </summary>
public static class PricingService
{
private const double FallbackPricePerKwh = 0.39;
private const string SparqlEndpoint = "https://ld.admin.ch/query";
/// <summary>
/// Get the total electricity price for a provider in a given year.
/// Returns cached value if available, otherwise fetches from ELCOM.
/// Falls back to 0.39 CHF/kWh if data is unavailable.
/// </summary>
public static async Task<double> GetElectricityPriceAsync(string providerName, int year)
{
if (string.IsNullOrWhiteSpace(providerName))
return FallbackPricePerKwh;
// Check DB cache first
var cached = Db.GetProviderTariff(providerName, year);
if (cached is not null)
return cached.TotalPricePerKwh;
// Fetch from ELCOM
var tariff = await FetchTariffFromElcomAsync(providerName, year);
if (tariff is null)
{
Console.WriteLine($"[PricingService] No ELCOM data for '{providerName}' year {year}, using fallback {FallbackPricePerKwh} CHF/kWh.");
return FallbackPricePerKwh;
}
// Cache in DB
Db.InsertProviderTariff(tariff);
Console.WriteLine($"[PricingService] Cached tariff for '{providerName}' year {year}: {tariff.TotalPricePerKwh:F4} CHF/kWh.");
return tariff.TotalPricePerKwh;
}
/// <summary>
/// Synchronous convenience wrapper for use in report generation.
/// </summary>
public static double GetElectricityPrice(string providerName, int year)
{
return GetElectricityPriceAsync(providerName, year).GetAwaiter().GetResult();
}
private static async Task<ProviderTariff?> FetchTariffFromElcomAsync(string providerName, int year)
{
try
{
// ELCOM SPARQL query for H4 household profile (standard household 4500 kWh/year)
// H4 is the most common reference category for residential installations.
// The query fetches tariff components: gridusage, energy, charge (fees).
// Total = gridusage + energy + charge
var sparqlQuery = $@"
PREFIX schema: <http://schema.org/>
PREFIX cube: <https://cube.link/>
PREFIX elcom: <https://energy.ld.admin.ch/elcom/electricityprice/dimension/>
SELECT ?gridusage ?energy ?charge
FROM <https://lindas.admin.ch/elcom/electricityprice>
WHERE {{
?obs a cube:Observation ;
elcom:operator ?op ;
elcom:period <https://ld.admin.ch/time/year/{year}> ;
elcom:category <https://energy.ld.admin.ch/elcom/electricityprice/category/H4> ;
elcom:product <https://energy.ld.admin.ch/elcom/electricityprice/product/total> ;
elcom:gridusage ?gridusage ;
elcom:energy ?energy ;
elcom:charge ?charge .
?op schema:name ""{EscapeSparql(providerName)}"" .
}}
LIMIT 1";
var response = await SparqlEndpoint
.WithHeader("Accept", "application/sparql-results+json")
.PostUrlEncodedAsync(new { query = sparqlQuery });
var json = await response.GetStringAsync();
var parsed = JObject.Parse(json);
var bindings = parsed["results"]?["bindings"];
if (bindings is null || !bindings.Any())
return null;
var first = bindings.First();
var gridUsage = ParseRpToChf(first["gridusage"]?["value"]?.ToString());
var energy = ParseRpToChf(first["energy"]?["value"]?.ToString());
var charge = ParseRpToChf(first["charge"]?["value"]?.ToString());
var total = gridUsage + energy + charge;
if (total <= 0)
return null;
return new ProviderTariff
{
ProviderName = providerName,
Year = year,
TotalPricePerKwh = Math.Round(total, 4),
GridUsagePerKwh = Math.Round(gridUsage, 4),
EnergyPerKwh = Math.Round(energy, 4),
FeesPerKwh = Math.Round(charge, 4),
FetchedAt = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss")
};
}
catch (Exception ex)
{
Console.Error.WriteLine($"[PricingService] ELCOM fetch failed for '{providerName}' year {year}: {ex.Message}");
return null;
}
}
/// <summary>
/// ELCOM values may be in Rp./kWh (centimes) or CHF/kWh.
/// Values > 1 are likely Rp./kWh and need /100 conversion.
/// Values <= 1 are already CHF/kWh.
/// </summary>
private static double ParseRpToChf(string? value)
{
if (!double.TryParse(value, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var parsed))
return 0;
// ELCOM typically returns Rp./kWh (centimes), convert to CHF
return parsed > 1 ? parsed / 100.0 : parsed;
}
private static string EscapeSparql(string value)
{
return value.Replace("\\", "\\\\").Replace("\"", "\\\"");
}
}
```
**Important: SPARQL query validation required**
- The exact SPARQL predicates (`elcom:gridusage`, `elcom:energy`, `elcom:charge`) need verification against the live endpoint before implementing.
- Test manually first:
```bash
curl -X POST https://ld.admin.ch/query \
-H "Accept: application/sparql-results+json" \
-d "query=PREFIX schema: <http://schema.org/> PREFIX cube: <https://cube.link/> PREFIX elcom: <https://energy.ld.admin.ch/elcom/electricityprice/dimension/> SELECT ?gridusage ?energy ?charge FROM <https://lindas.admin.ch/elcom/electricityprice> WHERE { ?obs a cube:Observation ; elcom:operator ?op ; elcom:period <https://ld.admin.ch/time/year/2025> ; elcom:category <https://energy.ld.admin.ch/elcom/electricityprice/category/H4> ; elcom:product <https://energy.ld.admin.ch/elcom/electricityprice/product/total> ; elcom:gridusage ?gridusage ; elcom:energy ?energy ; elcom:charge ?charge . ?op schema:name \"BKW Energie AG\" . } LIMIT 1"
```
- If predicates or URIs differ, adjust `PricingService.cs` accordingly.
- The H4 category (standard household, 4500 kWh/year) is the best default for residential Sodistore installations.
- ELCOM docs: https://www.elcom.admin.ch/elcom/en/home/open-data-api.html
**Step 2: Build to verify**
Run: `cd csharp/App/Backend && dotnet build`
Expected: Build succeeds
**Step 3: Commit**
```bash
git add csharp/App/Backend/Services/PricingService.cs
git commit -m "feat: add PricingService for ELCOM tariff lookup with DB caching"
```
---
## Task 4: Verify SPARQL Query Against Live Endpoint
**This is a critical validation step before integrating into reports.**
**Step 1: Test the SPARQL query**
Run the curl command from Task 3 notes against the live ELCOM endpoint with a known provider name (e.g., one from the existing `NetworkProviderService` list).
**Step 2: Inspect the response**
Check:
- Are the predicate names correct (`elcom:gridusage`, `elcom:energy`, `elcom:charge`)?
- What units are the values in (Rp./kWh or CHF/kWh)?
- Does the H4 category exist?
- Does the year URI format `<https://ld.admin.ch/time/year/2025>` work?
**Step 3: Adjust PricingService if needed**
If predicates, URIs, or units differ from what's in the code, update the SPARQL query and unit conversion logic in `PricingService.cs`.
**Step 4: Commit any fixes**
```bash
git add csharp/App/Backend/Services/PricingService.cs
git commit -m "fix: adjust SPARQL query to match live ELCOM endpoint"
```
---
## Task 5: Integrate Pricing into WeeklyReportService
**Files:**
- Modify: `csharp/App/Backend/Services/WeeklyReportService.cs`
**Step 1: Replace hard-coded price in savings calculation (around line 181)**
Before:
```csharp
const double ElectricityPriceCHF = 0.39;
var totalEnergySaved = Math.Round(currentSummary.TotalConsumption - currentSummary.TotalGridImport, 1);
var totalSavingsCHF = Math.Round(totalEnergySaved * ElectricityPriceCHF, 0);
```
After:
```csharp
var installation = Db.GetInstallationById(installationId);
var reportYear = DateTime.Parse(currentWeekDays.First().Date).Year;
var electricityPrice = await PricingService.GetElectricityPriceAsync(
installation?.NetworkProvider ?? "", reportYear);
var totalEnergySaved = Math.Round(currentSummary.TotalConsumption - currentSummary.TotalGridImport, 1);
var totalSavingsCHF = Math.Round(totalEnergySaved * electricityPrice, 0);
```
Note: The `installation` variable may already exist earlier in the method. If so, reuse it instead of fetching again. Check the method context.
**Step 2: Replace hard-coded price in AI prompt section (around line 265)**
Before:
```csharp
const double ElectricityPriceCHF = 0.39;
```
After: Remove this line. Use the `electricityPrice` variable from step 1 (pass it as a parameter to the AI insight method, or compute it there).
The key point: wherever `ElectricityPriceCHF` appears, replace with the dynamic value.
**Step 3: Build to verify**
Run: `cd csharp/App/Backend && dotnet build`
Expected: Build succeeds
**Step 4: Commit**
```bash
git add csharp/App/Backend/Services/WeeklyReportService.cs
git commit -m "feat: use provider-specific tariff in weekly report savings"
```
---
## Task 6: Integrate Pricing into ReportAggregationService
**Files:**
- Modify: `csharp/App/Backend/Services/ReportAggregationService.cs`
**Step 1: Remove the class-level constant (line 14)**
Before:
```csharp
private const Double ElectricityPriceCHF = 0.39;
```
After: Remove this line entirely.
**Step 2: Update monthly aggregation (around line 366)**
The method already has `installationId` and fetches the installation. Add pricing lookup:
Before:
```csharp
var savingsCHF = Math.Round(energySaved * ElectricityPriceCHF, 0);
```
After:
```csharp
var electricityPrice = await PricingService.GetElectricityPriceAsync(
installation?.NetworkProvider ?? "", year);
var savingsCHF = Math.Round(energySaved * electricityPrice, 0);
```
Note: `installation` is already fetched at line 373 (`Db.GetInstallationById(installationId)`). Move the fetch before the savings calculation if needed, or reuse.
**Step 3: Update yearly aggregation (around line 477)**
Before:
```csharp
var savingsCHF = Math.Round(energySaved * ElectricityPriceCHF, 0);
```
After:
```csharp
var electricityPrice = await PricingService.GetElectricityPriceAsync(
installation?.NetworkProvider ?? "", year);
var savingsCHF = Math.Round(energySaved * electricityPrice, 0);
```
Note: `installation` is already fetched at line 488. Same pattern as monthly.
**Step 4: Update AI prompt references**
Search for any remaining `ElectricityPriceCHF` references in the file (AI prompt strings around lines 693, 728). Replace with the dynamic value passed into the AI generation methods.
**Step 5: Build to verify**
Run: `cd csharp/App/Backend && dotnet build`
Expected: Build succeeds
**Step 6: Commit**
```bash
git add csharp/App/Backend/Services/ReportAggregationService.cs
git commit -m "feat: use provider-specific tariff in monthly/yearly report savings"
```
---
## Task 7: Update Email Templates with Dynamic Price
**Files:**
- Modify: `csharp/App/Backend/Services/ReportEmailService.cs`
**Step 1: Make `AtRate` field dynamic**
The `EmailStrings` and `AggregatedEmailStrings` records have an `AtRate` field with hard-coded "bei 0.39 CHF/kWh" / "a 0.39 CHF/kWh" / etc.
Change the approach: instead of hard-coding the rate in the language strings, pass the rate as a parameter and format it dynamically.
Update the `GetWeeklyStrings` method to accept a `double electricityPrice` parameter:
Before (example for German, line 106):
```csharp
AtRate: "bei 0.39 CHF/kWh",
```
After:
```csharp
AtRate: $"bei {electricityPrice:F2} CHF/kWh",
```
Apply the same pattern for all 4 languages (de, fr, it, en) in both:
- `GetWeeklyStrings()` — 4 language variants (lines ~90-200)
- `GetAggregatedStrings()` — 8 language+type variants (lines ~524-574)
The preposition varies by language:
- German: `"bei {price:F2} CHF/kWh"`
- French: `"a {price:F2} CHF/kWh"`
- Italian: `"a {price:F2} CHF/kWh"`
- English: `"at {price:F2} CHF/kWh"`
**Step 2: Update callers to pass the price**
Wherever `GetWeeklyStrings(language)` or `GetAggregatedStrings(language, type)` is called, pass the electricity price as an additional parameter. The price should come from the report generation context (already computed in Tasks 5-6).
**Step 3: Build to verify**
Run: `cd csharp/App/Backend && dotnet build`
Expected: Build succeeds
**Step 4: Commit**
```bash
git add csharp/App/Backend/Services/ReportEmailService.cs
git commit -m "feat: display actual provider tariff rate in report emails"
```
---
## Task 8: Add GetProviderTariff API Endpoint (Optional but Recommended)
**Files:**
- Modify: `csharp/App/Backend/Controller.cs`
**Step 1: Add endpoint for frontend to query tariff**
This allows the frontend Information tab to display the current tariff next to the provider selector.
Add in Controller.cs (near the existing `GetNetworkProviders` endpoint around line 756):
```csharp
[HttpGet(nameof(GetProviderTariff))]
public async Task<ActionResult<object>> GetProviderTariff(Token authToken, string providerName, int year)
{
var session = Db.GetSession(authToken);
if (session is null)
return Unauthorized();
var price = await PricingService.GetElectricityPriceAsync(providerName, year);
var tariff = Db.GetProviderTariff(providerName, year);
return Ok(new
{
providerName,
year,
totalPricePerKwh = price,
gridUsagePerKwh = tariff?.GridUsagePerKwh ?? 0,
energyPerKwh = tariff?.EnergyPerKwh ?? 0,
feesPerKwh = tariff?.FeesPerKwh ?? 0,
isFallback = tariff is null
});
}
```
**Step 2: Build to verify**
Run: `cd csharp/App/Backend && dotnet build`
Expected: Build succeeds
**Step 3: Commit**
```bash
git add csharp/App/Backend/Controller.cs
git commit -m "feat: add GetProviderTariff API endpoint"
```
---
## Task 9: End-to-End Verification
**Step 1: Full build**
Run: `cd csharp/App/Backend && dotnet build`
Expected: Build succeeds with no warnings related to our changes
**Step 2: Test SPARQL query manually**
Pick 2-3 known provider names from the ELCOM list and verify the pricing query returns sensible values (typical Swiss residential rates are 0.20-0.45 CHF/kWh).
**Step 3: Verify fallback behavior**
Test with:
- Empty provider name -> should return 0.39
- Unknown provider name -> should return 0.39
- Valid provider -> should return actual ELCOM rate
**Step 4: Review all changes**
```bash
git diff main --stat
git log --oneline main..HEAD
```
Verify:
- No remaining hard-coded 0.39 in report calculation code
- Email templates use dynamic formatting
- ProviderTariff table is registered in Db.cs
- PricingService has proper error handling and fallback
---
## Summary of All Files Changed
| File | Action | Purpose |
|------|--------|---------|
| `DataTypes/ProviderTariff.cs` | Create | SQLite model for cached tariffs |
| `Database/Db.cs` | Modify | Register table + accessor |
| `Database/Read.cs` | Modify | Add `GetProviderTariff()` query |
| `Database/Create.cs` | Modify | Add `InsertProviderTariff()` |
| `Services/PricingService.cs` | Create | ELCOM fetch + cache + fallback logic |
| `Services/WeeklyReportService.cs` | Modify | Use dynamic price (2 places) |
| `Services/ReportAggregationService.cs` | Modify | Use dynamic price (monthly + yearly) |
| `Services/ReportEmailService.cs` | Modify | Dynamic rate in 12 language strings |
| `Controller.cs` | Modify | Optional: GetProviderTariff endpoint |
## Fallback Behavior
| Scenario | Behavior |
|----------|----------|
| No NetworkProvider set on installation | Uses 0.39 CHF/kWh |
| Provider not found in ELCOM | Uses 0.39 CHF/kWh, logs warning |
| ELCOM endpoint unavailable | Uses 0.39 CHF/kWh, logs error |
| Tariff already cached in DB | Returns cached value (no network call) |
| New year, same provider | Fetches new year's tariff from ELCOM |
## Future Extensions (Not In Scope)
- Provider comparison ("your rate vs. average")
- ROI/payback calculation
- Time-of-use / dynamic spot pricing
- Tariff breakdown display in reports
- Category selection (H1-H8 profiles beyond default H4)

View File

@ -524,7 +524,6 @@ function Overview(props: OverviewProps) {
> >
<FormattedMessage id="24_hours" defaultMessage="24-hours" /> <FormattedMessage id="24_hours" defaultMessage="24-hours" />
</Button> </Button>
{product !== 2 && (
<Button <Button
variant="contained" variant="contained"
onClick={handleWeekData} onClick={handleWeekData}
@ -539,7 +538,6 @@ function Overview(props: OverviewProps) {
> >
<FormattedMessage id="lastweek" defaultMessage="Last week" /> <FormattedMessage id="lastweek" defaultMessage="Last week" />
</Button> </Button>
)}
{/*{aggregatedData && (*/} {/*{aggregatedData && (*/}
<Button <Button

View File

@ -19,6 +19,7 @@ import {
Typography Typography
} from '@mui/material'; } from '@mui/material';
import SendIcon from '@mui/icons-material/Send'; import SendIcon from '@mui/icons-material/Send';
import DownloadIcon from '@mui/icons-material/Download';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import axiosConfig from 'src/Resources/axiosConfig'; import axiosConfig from 'src/Resources/axiosConfig';
@ -273,6 +274,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
}; };
const tabs = [ const tabs = [
{ label: intl.formatMessage({ id: 'dailyTab', defaultMessage: 'Daily' }), key: 'daily' },
{ label: intl.formatMessage({ id: 'weeklyTab', defaultMessage: 'Weekly' }), key: 'weekly' }, { label: intl.formatMessage({ id: 'weeklyTab', defaultMessage: 'Weekly' }), key: 'weekly' },
{ label: intl.formatMessage({ id: 'monthlyTab', defaultMessage: 'Monthly' }), key: 'monthly' }, { label: intl.formatMessage({ id: 'monthlyTab', defaultMessage: 'Monthly' }), key: 'monthly' },
{ label: intl.formatMessage({ id: 'yearlyTab', defaultMessage: 'Yearly' }), key: 'yearly' } { label: intl.formatMessage({ id: 'yearlyTab', defaultMessage: 'Yearly' }), key: 'yearly' }
@ -281,15 +283,36 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
const safeTab = Math.min(activeTab, tabs.length - 1); const safeTab = Math.min(activeTab, tabs.length - 1);
return ( return (
<Box sx={{ p: 2, maxWidth: 900, mx: 'auto' }}> <Box sx={{ p: 2, maxWidth: 900, mx: 'auto' }} className="report-container">
<style>{`
@media print {
body * { visibility: hidden; }
.report-container, .report-container * { visibility: visible; }
.report-container { position: absolute; left: 0; top: 0; width: 100%; padding: 20px; }
.no-print { display: none !important; }
}
`}</style>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }} className="no-print">
<Tabs <Tabs
value={safeTab} value={safeTab}
onChange={(_, v) => setActiveTab(v)} onChange={(_, v) => setActiveTab(v)}
sx={{ mb: 2, '& .MuiTab-root': { fontWeight: 'bold' } }} sx={{ flex: 1, '& .MuiTab-root': { fontWeight: 'bold' } }}
> >
{tabs.map(t => <Tab key={t.key} label={t.label} />)} {tabs.map(t => <Tab key={t.key} label={t.label} />)}
</Tabs> </Tabs>
<Button
variant="outlined"
startIcon={<DownloadIcon />}
onClick={() => window.print()}
sx={{ ml: 2, whiteSpace: 'nowrap' }}
>
<FormattedMessage id="downloadPdf" defaultMessage="Download PDF" />
</Button>
</Box>
<Box sx={{ display: tabs[safeTab]?.key === 'daily' ? 'block' : 'none' }}>
<DailySection installationId={installationId} />
</Box>
<Box sx={{ display: tabs[safeTab]?.key === 'weekly' ? 'block' : 'none' }}> <Box sx={{ display: tabs[safeTab]?.key === 'weekly' ? 'block' : 'none' }}>
<WeeklySection <WeeklySection
installationId={installationId} installationId={installationId}
@ -322,6 +345,163 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
); );
} }
// ── Daily Section ──────────────────────────────────────────────
function DailySection({ installationId }: { installationId: number }) {
const intl = useIntl();
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const formatDate = (d: Date) => d.toISOString().split('T')[0];
const [selectedDate, setSelectedDate] = useState(formatDate(yesterday));
const [dailyRecords, setDailyRecords] = useState<DailyEnergyData[]>([]);
const [loading, setLoading] = useState(false);
const [noData, setNoData] = useState(false);
const fetchDailyData = async (date: string) => {
setLoading(true);
setNoData(false);
try {
const res = await axiosConfig.get('/GetDailyRecords', {
params: { installationId, from: date, to: date }
});
const records = res.data?.records ?? res.data ?? [];
if (Array.isArray(records) && records.length > 0) {
setDailyRecords(records);
} else {
setDailyRecords([]);
setNoData(true);
}
} catch {
setDailyRecords([]);
setNoData(true);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchDailyData(selectedDate);
}, [installationId]);
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newDate = e.target.value;
setSelectedDate(newDate);
fetchDailyData(newDate);
};
const record = dailyRecords.length > 0 ? dailyRecords[0] : null;
const energySaved = record ? Math.max(0, record.loadConsumption - record.gridImport) : 0;
const savingsCHF = +(energySaved * 0.39).toFixed(2);
const selfSufficiency = record && record.loadConsumption > 0
? Math.min(100, ((1 - record.gridImport / record.loadConsumption) * 100))
: 0;
const batteryEfficiency = record && record.batteryCharged > 0
? Math.min(100, (record.batteryDischarged / record.batteryCharged) * 100)
: 0;
const dt = new Date(selectedDate);
const dateLabel = dt.toLocaleDateString(intl.locale, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
return (
<>
{/* Date Picker */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Typography variant="body1" fontWeight="bold">
<FormattedMessage id="selectDate" defaultMessage="Select Date" />
</Typography>
<TextField
type="date"
size="small"
value={selectedDate}
onChange={handleDateChange}
inputProps={{ max: formatDate(new Date()) }}
sx={{ width: 200 }}
/>
</Box>
{loading && (
<Container sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', height: '30vh' }}>
<CircularProgress size={50} style={{ color: '#ffc04d' }} />
</Container>
)}
{!loading && noData && (
<Alert severity="info" sx={{ mb: 3 }}>
<FormattedMessage id="noDataForDate" defaultMessage="No data available for the selected date." />
</Alert>
)}
{!loading && record && (
<>
{/* Header */}
<Paper sx={{ bgcolor: '#2c3e50', color: '#fff', p: 3, mb: 3, borderRadius: 2 }}>
<Typography variant="h5" fontWeight="bold">
<FormattedMessage id="dailyReportTitle" defaultMessage="Daily Energy Summary" />
</Typography>
<Typography variant="body2" sx={{ opacity: 0.8, mt: 0.5 }}>
{dateLabel}
</Typography>
</Paper>
{/* Savings Cards */}
<Paper sx={{ p: 3, mb: 3 }}>
<SavingsCards
intl={intl}
energySaved={+energySaved.toFixed(1)}
savingsCHF={savingsCHF}
selfSufficiency={selfSufficiency}
batteryEfficiency={batteryEfficiency}
/>
</Paper>
{/* Daily Summary Table */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
<FormattedMessage id="dailySummary" defaultMessage="Daily Summary" />
</Typography>
<Box component="table" sx={{ width: '100%', borderCollapse: 'collapse', '& td, & th': { p: 1.5, borderBottom: '1px solid #eee' } }}>
<thead>
<tr style={{ background: '#f8f9fa' }}>
<th style={{ textAlign: 'left' }}><FormattedMessage id="metric" defaultMessage="Metric" /></th>
<th style={{ textAlign: 'right' }}><FormattedMessage id="total" defaultMessage="Total" /></th>
</tr>
</thead>
<tbody>
<tr>
<td><FormattedMessage id="pvProduction" defaultMessage="PV Production" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{record.pvProduction.toFixed(1)} kWh</td>
</tr>
<tr>
<td><FormattedMessage id="consumption" defaultMessage="Consumption" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{record.loadConsumption.toFixed(1)} kWh</td>
</tr>
<tr>
<td><FormattedMessage id="gridImport" defaultMessage="Grid Import" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{record.gridImport.toFixed(1)} kWh</td>
</tr>
<tr>
<td><FormattedMessage id="gridExport" defaultMessage="Grid Export" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{record.gridExport.toFixed(1)} kWh</td>
</tr>
<tr style={{ background: '#f0f7ff' }}>
<td><strong><FormattedMessage id="batteryCharged" defaultMessage="Battery Charged" /></strong></td>
<td style={{ textAlign: 'right', fontWeight: 'bold', color: '#2980b9' }}>{record.batteryCharged.toFixed(1)} kWh</td>
</tr>
<tr style={{ background: '#f0f7ff' }}>
<td><strong><FormattedMessage id="batteryDischarged" defaultMessage="Battery Discharged" /></strong></td>
<td style={{ textAlign: 'right', fontWeight: 'bold', color: '#e67e22' }}>{record.batteryDischarged.toFixed(1)} kWh</td>
</tr>
</tbody>
</Box>
</Paper>
</>
)}
</>
);
}
// ── Weekly Section (existing weekly report content) ──────────── // ── Weekly Section (existing weekly report content) ────────────
function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installationId: number; latestMonthlyPeriodEnd: string | null }) { function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installationId: number; latestMonthlyPeriodEnd: string | null }) {

View File

@ -140,6 +140,15 @@
"weeklyTab": "Wöchentlich", "weeklyTab": "Wöchentlich",
"monthlyTab": "Monatlich", "monthlyTab": "Monatlich",
"yearlyTab": "Jährlich", "yearlyTab": "Jährlich",
"dailyTab": "Täglich",
"dailyReportTitle": "Tägliche Energieübersicht",
"dailySummary": "Tagesübersicht",
"selectDate": "Datum wählen",
"noDataForDate": "Keine Daten für das gewählte Datum verfügbar.",
"batteryActivity": "Batterieaktivität",
"batteryCharged": "Batterie geladen",
"batteryDischarged": "Batterie entladen",
"downloadPdf": "PDF herunterladen",
"monthlyReportTitle": "Monatlicher Leistungsbericht", "monthlyReportTitle": "Monatlicher Leistungsbericht",
"yearlyReportTitle": "Jährlicher Leistungsbericht", "yearlyReportTitle": "Jährlicher Leistungsbericht",
"monthlyInsights": "Monatliche Einblicke", "monthlyInsights": "Monatliche Einblicke",

View File

@ -122,6 +122,15 @@
"weeklyTab": "Weekly", "weeklyTab": "Weekly",
"monthlyTab": "Monthly", "monthlyTab": "Monthly",
"yearlyTab": "Yearly", "yearlyTab": "Yearly",
"dailyTab": "Daily",
"dailyReportTitle": "Daily Energy Summary",
"dailySummary": "Daily Summary",
"selectDate": "Select Date",
"noDataForDate": "No data available for the selected date.",
"batteryActivity": "Battery Activity",
"batteryCharged": "Battery Charged",
"batteryDischarged": "Battery Discharged",
"downloadPdf": "Download PDF",
"monthlyReportTitle": "Monthly Performance Report", "monthlyReportTitle": "Monthly Performance Report",
"yearlyReportTitle": "Annual Performance Report", "yearlyReportTitle": "Annual Performance Report",
"monthlyInsights": "Monthly Insights", "monthlyInsights": "Monthly Insights",

View File

@ -134,6 +134,15 @@
"weeklyTab": "Hebdomadaire", "weeklyTab": "Hebdomadaire",
"monthlyTab": "Mensuel", "monthlyTab": "Mensuel",
"yearlyTab": "Annuel", "yearlyTab": "Annuel",
"dailyTab": "Quotidien",
"dailyReportTitle": "Résumé énergétique quotidien",
"dailySummary": "Résumé du jour",
"selectDate": "Sélectionner la date",
"noDataForDate": "Aucune donnée disponible pour la date sélectionnée.",
"batteryActivity": "Activité de la batterie",
"batteryCharged": "Batterie chargée",
"batteryDischarged": "Batterie déchargée",
"downloadPdf": "Télécharger PDF",
"monthlyReportTitle": "Rapport de performance mensuel", "monthlyReportTitle": "Rapport de performance mensuel",
"yearlyReportTitle": "Rapport de performance annuel", "yearlyReportTitle": "Rapport de performance annuel",
"monthlyInsights": "Aperçus mensuels", "monthlyInsights": "Aperçus mensuels",

View File

@ -145,6 +145,15 @@
"weeklyTab": "Settimanale", "weeklyTab": "Settimanale",
"monthlyTab": "Mensile", "monthlyTab": "Mensile",
"yearlyTab": "Annuale", "yearlyTab": "Annuale",
"dailyTab": "Giornaliero",
"dailyReportTitle": "Riepilogo energetico giornaliero",
"dailySummary": "Riepilogo del giorno",
"selectDate": "Seleziona data",
"noDataForDate": "Nessun dato disponibile per la data selezionata.",
"batteryActivity": "Attività della batteria",
"batteryCharged": "Batteria caricata",
"batteryDischarged": "Batteria scaricata",
"downloadPdf": "Scarica PDF",
"monthlyReportTitle": "Rapporto mensile sulle prestazioni", "monthlyReportTitle": "Rapporto mensile sulle prestazioni",
"yearlyReportTitle": "Rapporto annuale sulle prestazioni", "yearlyReportTitle": "Rapporto annuale sulle prestazioni",
"monthlyInsights": "Approfondimenti mensili", "monthlyInsights": "Approfondimenti mensili",