diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index c7dd071fb..9de49dbe5 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -982,7 +982,7 @@ public class Controller : ControllerBase { var lang = user.Language ?? "en"; 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}" }); } catch (Exception ex) @@ -1257,7 +1257,7 @@ public class Controller : ControllerBase { var lang = user.Language ?? "en"; 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}" }); } catch (Exception ex) @@ -1286,7 +1286,7 @@ public class Controller : ControllerBase { var lang = user.Language ?? "en"; 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}" }); } catch (Exception ex) @@ -1922,7 +1922,5 @@ public class Controller : ControllerBase }; } + } - - - diff --git a/csharp/App/Backend/Services/ReportAggregationService.cs b/csharp/App/Backend/Services/ReportAggregationService.cs index d94ce7206..7ca43b156 100644 --- a/csharp/App/Backend/Services/ReportAggregationService.cs +++ b/csharp/App/Backend/Services/ReportAggregationService.cs @@ -378,7 +378,8 @@ public static class ReportAggregationService installationName, monthName, days.Count, totalPv, totalConsump, totalGridIn, totalGridOut, totalBattChg, totalBattDis, energySaved, savingsCHF, - selfSufficiency, batteryEff, language); + selfSufficiency, batteryEff, language, + installation?.Location, installation?.Country, installation?.Region); var monthlySummary = new MonthlyReportSummary { @@ -577,7 +578,8 @@ public static class ReportAggregationService public static Task GetOrGenerateMonthlyInsightAsync( MonthlyReportSummary report, String language) { - var installationName = Db.GetInstallationById(report.InstallationId)?.InstallationName + var installation = Db.GetInstallationById(report.InstallationId); + var installationName = installation?.InstallationName ?? $"Installation {report.InstallationId}"; var monthName = new DateTime(report.Year, report.Month, 1).ToString("MMMM yyyy"); return GetOrGenerateInsightAsync("monthly", report.Id, language, @@ -587,7 +589,8 @@ public static class ReportAggregationService report.TotalGridImport, report.TotalGridExport, report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF, - report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, language)); + report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, language, + installation?.Location, installation?.Country, installation?.Region)); } /// Cached-or-generated AI insight for a stored YearlyReportSummary. @@ -670,7 +673,8 @@ Exactly 4 bullet points. Each starts with ""- Title: description"" format."; Double totalBattChg, Double totalBattDis, Double energySaved, Double savingsCHF, 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"); 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 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}"". 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) - 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) - +{weatherBlock} INSTRUCTIONS: 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. 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."; diff --git a/csharp/App/Backend/Services/ReportEmailService.cs b/csharp/App/Backend/Services/ReportEmailService.cs index 4744b9a1a..17ba54397 100644 --- a/csharp/App/Backend/Services/ReportEmailService.cs +++ b/csharp/App/Backend/Services/ReportEmailService.cs @@ -13,11 +13,12 @@ public static class ReportEmailService /// 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. /// - 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 subject = $"{strings.Title} — {report.InstallationName} ({report.PeriodStart} to {report.PeriodEnd})"; - var html = BuildHtmlEmail(report, strings); + var nameSegment = !string.IsNullOrWhiteSpace(customerName) ? $" — {customerName}" : ""; + var subject = $"{strings.Title} — {report.InstallationName}{nameSegment} ({report.PeriodStart} to {report.PeriodEnd})"; + var html = BuildHtmlEmail(report, strings, customerName); var config = await ReadMailerConfig(); @@ -209,10 +210,10 @@ public static class ReportEmailService // ── HTML email template ───────────────────────────────────────────── - public static string BuildHtmlEmail(WeeklyReportResponse r, string language = "en") - => BuildHtmlEmail(r, GetStrings(language)); + public static string BuildHtmlEmail(WeeklyReportResponse r, string language = "en", string customerName = null) + => 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 prev = r.PreviousWeek; @@ -331,6 +332,7 @@ public static class ReportEmailService
{s.Title}
{r.InstallationName}
+ {(!string.IsNullOrWhiteSpace(customerName) ? $@"
{customerName}
" : "")}
{r.PeriodStart} — {r.PeriodEnd}
@@ -396,6 +398,7 @@ public static class ReportEmailService {s.Footer} +
View your reports anytime at monitor.inesco.ch
@@ -455,17 +458,19 @@ public static class ReportEmailService MonthlyReportSummary report, string installationName, string recipientEmail, - string language = "en") + string language = "en", + string customerName = null) { 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 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, report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport, report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF, report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight, - $"{report.WeekCount} {s.CountLabel}", s); + $"{report.WeekCount} {s.CountLabel}", s, customerName); await SendHtmlEmailAsync(subject, html, recipientEmail); } @@ -474,15 +479,17 @@ public static class ReportEmailService YearlyReportSummary report, string installationName, string recipientEmail, - string language = "en") + string language = "en", + string customerName = null) { 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, report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport, report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF, report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight, - $"{report.MonthCount} {s.CountLabel}", s); + $"{report.MonthCount} {s.CountLabel}", s, customerName); await SendHtmlEmailAsync(subject, html, recipientEmail); } @@ -580,7 +587,7 @@ public static class ReportEmailService double pvProduction, double consumption, double gridImport, double gridExport, double batteryCharged, double batteryDischarged, double energySaved, double savingsCHF, double selfSufficiency, double batteryEfficiency, string aiInsight, - string countLabel, AggregatedEmailStrings s) + string countLabel, AggregatedEmailStrings s, string customerName = null) { var insightLines = aiInsight .Split('\n', StringSplitOptions.RemoveEmptyEntries) @@ -608,6 +615,7 @@ public static class ReportEmailService
{s.Title}
{installationName}
+ {(!string.IsNullOrWhiteSpace(customerName) ? $@"
{customerName}
" : "")}
{periodStart} — {periodEnd}
{countLabel}
@@ -660,6 +668,7 @@ public static class ReportEmailService {s.Footer} +
View your reports anytime at monitor.inesco.ch
diff --git a/csharp/App/Backend/Services/WeatherService.cs b/csharp/App/Backend/Services/WeatherService.cs new file mode 100644 index 000000000..d90e7c3fb --- /dev/null +++ b/csharp/App/Backend/Services/WeatherService.cs @@ -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 GeoCache = new(); + + /// + /// Returns a 7-day weather forecast for the given city, or null on any failure. + /// + public static async Task?> 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; + } + } + + /// + /// Formats a forecast list into a compact text block for AI prompt injection. + /// + public static string FormatForPrompt(List 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); + } + + /// + /// 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. + /// + private static IEnumerable 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(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?> 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(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(); + 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" + }; +} diff --git a/csharp/App/Backend/Services/WeeklyReportService.cs b/csharp/App/Backend/Services/WeeklyReportService.cs index 0557987cf..b2fcde817 100644 --- a/csharp/App/Backend/Services/WeeklyReportService.cs +++ b/csharp/App/Backend/Services/WeeklyReportService.cs @@ -1,3 +1,4 @@ +using System.Text.RegularExpressions; using Flurl.Http; using InnovEnergy.App.Backend.Database; using InnovEnergy.App.Backend.DataTypes; @@ -9,6 +10,54 @@ public static class WeeklyReportService { private static readonly string TmpReportDir = Environment.CurrentDirectory + "/tmp_report/"; + /// + /// 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. + /// + private static List GetRelevantXlsxFiles(long installationId, DateOnly rangeStart, DateOnly rangeEnd) + { + if (!Directory.Exists(TmpReportDir)) + return new List(); + + 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(); + + 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 ────────────────────────────────────────── /// @@ -75,14 +124,13 @@ public static class WeeklyReportService // 2. Fallback: if DB empty, parse xlsx on the fly (pre-ingestion scenario) if (currentWeekDays.Count == 0) { - var xlsxFiles = Directory.Exists(TmpReportDir) - ? Directory.GetFiles(TmpReportDir, $"{installationId}*.xlsx").OrderBy(f => f).ToList() - : new List(); + // Only parse xlsx files whose date range overlaps the needed weeks + var relevantFiles = GetRelevantXlsxFiles(installationId, prevMon, curSun); - if (xlsxFiles.Count > 0) + if (relevantFiles.Count > 0) { - Console.WriteLine($"[WeeklyReportService] DB empty for week {curMon:yyyy-MM-dd}, falling back to xlsx."); - var allDaysParsed = xlsxFiles.SelectMany(p => ExcelDataParser.Parse(p)).ToList(); + Console.WriteLine($"[WeeklyReportService] DB empty for week {curMon:yyyy-MM-dd}, falling back to {relevantFiles.Count} xlsx file(s)."); + var allDaysParsed = relevantFiles.SelectMany(p => ExcelDataParser.Parse(p)).ToList(); currentWeekDays = allDaysParsed .Where(d => { var date = DateOnly.Parse(d.Date); return date >= curMon && date <= curSun; }) .ToList(); @@ -101,9 +149,32 @@ public static class WeeklyReportService var currentHourlyData = Db.GetHourlyRecords(installationId, curMon, curSun) .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( currentWeekDays, previousWeekDays, currentHourlyData, installationName, language, - curMon, curSun); + curMon, curSun, location, country, region); } // ── Conversion helpers ───────────────────────────────────────────── @@ -144,7 +215,10 @@ public static class WeeklyReportService string installationName, string language = "en", 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(); previousWeekDays = previousWeekDays.OrderBy(d => d.Date).ToList(); @@ -188,7 +262,7 @@ public static class WeeklyReportService var aiInsight = await GetAiInsightAsync( currentWeekDays, currentSummary, previousSummary, selfSufficiency, totalEnergySaved, totalSavingsCHF, - behavior, installationName, language); + behavior, installationName, language, location, country, region); return new WeeklyReportResponse { @@ -253,7 +327,10 @@ public static class WeeklyReportService double totalSavingsCHF, BehavioralPattern behavior, string installationName, - string language = "en") + string language = "en", + string? location = null, + string? country = null, + string? region = null) { var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY"); if (string.IsNullOrWhiteSpace(apiKey)) @@ -262,6 +339,12 @@ public static class WeeklyReportService 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; // Detect which components are present @@ -278,7 +361,10 @@ public static class WeeklyReportService var topBattDay = currentWeek.OrderByDescending(d => d.BatteryCharged).First(); 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 avoidableSavingsCHF = Math.Round(behavior.AvoidableGridKwh * ElectricityPriceCHF, 0); @@ -303,15 +389,27 @@ public static class WeeklyReportService ? $"- Grid import: {current.TotalGridImport:F1} kWh total this week." : ""; - var pvBehaviorLines = hasPv ? $@" + // Behavioral section — only include when hourly data exists + var behavioralSection = ""; + if (hasBehavior) + { + var pvBehaviorLines = hasPv ? $@" - 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" : ""; - var gridBehaviorLine = hasGrid - ? $"- Highest grid-import hour: {FormatHourSlot(behavior.HighestGridImportHour)}, avg {behavior.AvgGridImportAtPeakHour} kWh during that hour" - : ""; + var gridBehaviorLine = hasGrid + ? $"- Highest grid-import hour: {FormatHourSlot(behavior.HighestGridImportHour)}, avg {behavior.AvgGridImportAtPeakHour} kWh during that hour" + : ""; - 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 var instruction1 = $"1. Energy savings: Write 1–2 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."; @@ -319,22 +417,55 @@ public static class WeeklyReportService var instruction2 = hasPv ? $"2. Solar performance: Comment on the best and worst solar day this week and the likely weather reason." : 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."; var instruction3 = hasBattery ? $"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."; - var instruction4 = hasPv - ? $"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." - : hasGrid - ? $"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." - : "4. Smart action for next week: Give one practical tip to reduce energy consumption based on the peak load time and weekday/weekend pattern."; + // Instruction 4 — adapts based on whether we have behavioral data + string instruction4; + if (hasBehavior && hasPv) + 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."; + 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}"". -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. @@ -347,20 +478,16 @@ DAILY FACTS: {pvDailyFact} {battDailyFact} {gridDailyFact} - -BEHAVIORAL PATTERN (from hourly data this week): -- Peak household load: {FormatHourSlot(behavior.PeakLoadHour)}, avg {behavior.AvgPeakLoadKwh} kWh during that hour -- {weekdayWeekendLine}{pvBehaviorLines} -{gridBehaviorLine} -{battBehaviorLine} - +{behavioralSection} +{weatherBlock} INSTRUCTIONS: {instruction1} {instruction2} {instruction3} {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)}."; try @@ -369,7 +496,7 @@ IMPORTANT: Write your entire response in {LanguageName(language)}."; { model = "mistral-small-latest", messages = new[] { new { role = "user", content = prompt } }, - max_tokens = 400, + max_tokens = 600, temperature = 0.3 }; diff --git a/docs/plans/2026-03-09-provider-pricing-reports.md b/docs/plans/2026-03-09-provider-pricing-reports.md new file mode 100644 index 000000000..27471b606 --- /dev/null +++ b/docs/plans/2026-03-09-provider-pricing-reports.md @@ -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; + +/// +/// 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. +/// +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 ProviderTariffs => Connection.Table(); +``` + +Add `CreateTable` call inside the `RunInTransaction` block (after line 77, after TicketTimelineEvent): +```csharp +Connection.CreateTable(); +``` + +**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; + +/// +/// 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. +/// +public static class PricingService +{ + private const double FallbackPricePerKwh = 0.39; + private const string SparqlEndpoint = "https://ld.admin.ch/query"; + + /// + /// 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. + /// + public static async Task 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; + } + + /// + /// Synchronous convenience wrapper for use in report generation. + /// + public static double GetElectricityPrice(string providerName, int year) + { + return GetElectricityPriceAsync(providerName, year).GetAwaiter().GetResult(); + } + + private static async Task 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: +PREFIX cube: +PREFIX elcom: + +SELECT ?gridusage ?energy ?charge +FROM +WHERE {{ + ?obs a cube:Observation ; + elcom:operator ?op ; + elcom:period ; + elcom:category ; + elcom:product ; + 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; + } + } + + /// + /// 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. + /// + 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: PREFIX cube: PREFIX elcom: SELECT ?gridusage ?energy ?charge FROM WHERE { ?obs a cube:Observation ; elcom:operator ?op ; elcom:period ; elcom:category ; elcom:product ; 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 `` 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> 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) diff --git a/typescript/frontend-marios2/src/content/dashboards/Overview/overview.tsx b/typescript/frontend-marios2/src/content/dashboards/Overview/overview.tsx index 530a22e42..f413f20bc 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Overview/overview.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Overview/overview.tsx @@ -524,22 +524,20 @@ function Overview(props: OverviewProps) { > - {product !== 2 && ( - - )} + {/*{aggregatedData && (*/} + + + + d.toISOString().split('T')[0]; + + const [selectedDate, setSelectedDate] = useState(formatDate(yesterday)); + const [dailyRecords, setDailyRecords] = useState([]); + 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) => { + 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 */} + + + + + + + + {loading && ( + + + + )} + + {!loading && noData && ( + + + + )} + + {!loading && record && ( + <> + {/* Header */} + + + + + + {dateLabel} + + + + {/* Savings Cards */} + + + + + {/* Daily Summary Table */} + + + + + + + + + + + + + + + {record.pvProduction.toFixed(1)} kWh + + + + {record.loadConsumption.toFixed(1)} kWh + + + + {record.gridImport.toFixed(1)} kWh + + + + {record.gridExport.toFixed(1)} kWh + + + + {record.batteryCharged.toFixed(1)} kWh + + + + {record.batteryDischarged.toFixed(1)} kWh + + + + + + )} + + ); +} + // ── Weekly Section (existing weekly report content) ──────────── function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installationId: number; latestMonthlyPeriodEnd: string | null }) { diff --git a/typescript/frontend-marios2/src/lang/de.json b/typescript/frontend-marios2/src/lang/de.json index 83c4c746a..8361c146b 100644 --- a/typescript/frontend-marios2/src/lang/de.json +++ b/typescript/frontend-marios2/src/lang/de.json @@ -140,6 +140,15 @@ "weeklyTab": "Wöchentlich", "monthlyTab": "Monatlich", "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", "yearlyReportTitle": "Jährlicher Leistungsbericht", "monthlyInsights": "Monatliche Einblicke", diff --git a/typescript/frontend-marios2/src/lang/en.json b/typescript/frontend-marios2/src/lang/en.json index c9c909b62..65adae667 100644 --- a/typescript/frontend-marios2/src/lang/en.json +++ b/typescript/frontend-marios2/src/lang/en.json @@ -122,6 +122,15 @@ "weeklyTab": "Weekly", "monthlyTab": "Monthly", "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", "yearlyReportTitle": "Annual Performance Report", "monthlyInsights": "Monthly Insights", diff --git a/typescript/frontend-marios2/src/lang/fr.json b/typescript/frontend-marios2/src/lang/fr.json index 1fcba5814..8a699b1fc 100644 --- a/typescript/frontend-marios2/src/lang/fr.json +++ b/typescript/frontend-marios2/src/lang/fr.json @@ -134,6 +134,15 @@ "weeklyTab": "Hebdomadaire", "monthlyTab": "Mensuel", "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", "yearlyReportTitle": "Rapport de performance annuel", "monthlyInsights": "Aperçus mensuels", diff --git a/typescript/frontend-marios2/src/lang/it.json b/typescript/frontend-marios2/src/lang/it.json index 0ed40875f..1311b6681 100644 --- a/typescript/frontend-marios2/src/lang/it.json +++ b/typescript/frontend-marios2/src/lang/it.json @@ -145,6 +145,15 @@ "weeklyTab": "Settimanale", "monthlyTab": "Mensile", "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", "yearlyReportTitle": "Rapporto annuale sulle prestazioni", "monthlyInsights": "Approfondimenti mensili",