added weather API to generate predition for weekly report
This commit is contained in:
parent
57ee8be520
commit
8cd602c5cd
|
|
@ -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
|
|||
};
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String> 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));
|
||||
}
|
||||
|
||||
/// <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 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.";
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
/// </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 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
|
|||
<td style=""background:#2c3e50;padding:24px 30px;color:#ffffff"">
|
||||
<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>
|
||||
{(!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>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -396,6 +398,7 @@ public static class ReportEmailService
|
|||
<tr>
|
||||
<td style=""background:#f8f9fa;padding:16px 30px;text-align:center;font-size:12px;color:#999;border-top:1px solid #eee"">
|
||||
{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>
|
||||
</tr>
|
||||
|
||||
|
|
@ -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
|
|||
<td style=""background:#2c3e50;padding:24px 30px;color:#ffffff"">
|
||||
<div style=""font-size:20px;font-weight:bold"">{s.Title}</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:12px;margin-top:2px;opacity:0.5"">{countLabel}</div>
|
||||
</td>
|
||||
|
|
@ -660,6 +668,7 @@ public static class ReportEmailService
|
|||
<tr>
|
||||
<td style=""background:#f8f9fa;padding:16px 30px;text-align:center;font-size:12px;color:#999;border-top:1px solid #eee"">
|
||||
{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>
|
||||
</tr>
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
};
|
||||
}
|
||||
|
|
@ -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/";
|
||||
|
||||
/// <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 ──────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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<String>();
|
||||
// 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
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -524,22 +524,20 @@ function Overview(props: OverviewProps) {
|
|||
>
|
||||
<FormattedMessage id="24_hours" defaultMessage="24-hours" />
|
||||
</Button>
|
||||
{product !== 2 && (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleWeekData}
|
||||
disabled={loading}
|
||||
sx={{
|
||||
marginTop: '20px',
|
||||
marginLeft: '10px',
|
||||
backgroundColor: aggregatedData ? '#808080' : '#ffc04d',
|
||||
color: '#000000',
|
||||
'&:hover': { bgcolor: '#f7b34d' }
|
||||
}}
|
||||
>
|
||||
<FormattedMessage id="lastweek" defaultMessage="Last week" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleWeekData}
|
||||
disabled={loading}
|
||||
sx={{
|
||||
marginTop: '20px',
|
||||
marginLeft: '10px',
|
||||
backgroundColor: aggregatedData ? '#808080' : '#ffc04d',
|
||||
color: '#000000',
|
||||
'&:hover': { bgcolor: '#f7b34d' }
|
||||
}}
|
||||
>
|
||||
<FormattedMessage id="lastweek" defaultMessage="Last week" />
|
||||
</Button>
|
||||
|
||||
{/*{aggregatedData && (*/}
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
Typography
|
||||
} from '@mui/material';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import axiosConfig from 'src/Resources/axiosConfig';
|
||||
|
||||
|
|
@ -273,6 +274,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
|
|||
};
|
||||
|
||||
const tabs = [
|
||||
{ label: intl.formatMessage({ id: 'dailyTab', defaultMessage: 'Daily' }), key: 'daily' },
|
||||
{ label: intl.formatMessage({ id: 'weeklyTab', defaultMessage: 'Weekly' }), key: 'weekly' },
|
||||
{ label: intl.formatMessage({ id: 'monthlyTab', defaultMessage: 'Monthly' }), key: 'monthly' },
|
||||
{ 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);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 2, maxWidth: 900, mx: 'auto' }}>
|
||||
<Tabs
|
||||
value={safeTab}
|
||||
onChange={(_, v) => setActiveTab(v)}
|
||||
sx={{ mb: 2, '& .MuiTab-root': { fontWeight: 'bold' } }}
|
||||
>
|
||||
{tabs.map(t => <Tab key={t.key} label={t.label} />)}
|
||||
</Tabs>
|
||||
<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
|
||||
value={safeTab}
|
||||
onChange={(_, v) => setActiveTab(v)}
|
||||
sx={{ flex: 1, '& .MuiTab-root': { fontWeight: 'bold' } }}
|
||||
>
|
||||
{tabs.map(t => <Tab key={t.key} label={t.label} />)}
|
||||
</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' }}>
|
||||
<WeeklySection
|
||||
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) ────────────
|
||||
|
||||
function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installationId: number; latestMonthlyPeriodEnd: string | null }) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue