diff --git a/csharp/App/Backend/Backend.csproj b/csharp/App/Backend/Backend.csproj index 3021dc3a3..a89f47afa 100644 --- a/csharp/App/Backend/Backend.csproj +++ b/csharp/App/Backend/Backend.csproj @@ -8,6 +8,7 @@ + diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index 08cba57c2..5ab5041ec 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -862,6 +862,66 @@ public class Controller : ControllerBase }); } + // ── Weekly Performance Report ────────────────────────────────────── + + /// + /// Generates a weekly performance report from an Excel file in tmp_report/{installationId}.xlsx + /// Returns JSON with daily data, weekly totals, ratios, and AI insight. + /// + [HttpGet(nameof(GetWeeklyReport))] + public async Task> GetWeeklyReport(Int64 installationId, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user == null) + return Unauthorized(); + + var installation = Db.GetInstallationById(installationId); + if (installation is null || !user.HasAccessTo(installation)) + return Unauthorized(); + + var filePath = Environment.CurrentDirectory + "/tmp_report/" + installationId + ".xlsx"; + if (!System.IO.File.Exists(filePath)) + return NotFound($"No report data file found. Please place the Excel export at: tmp_report/{installationId}.xlsx"); + + try + { + var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.InstallationName); + return Ok(report); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[GetWeeklyReport] Error: {ex.Message}"); + return BadRequest($"Failed to generate report: {ex.Message}"); + } + } + + /// + /// Sends the weekly report as a formatted HTML email. + /// + [HttpPost(nameof(SendWeeklyReportEmail))] + public async Task SendWeeklyReportEmail(Int64 installationId, string emailAddress, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user == null) + return Unauthorized(); + + var installation = Db.GetInstallationById(installationId); + if (installation is null || !user.HasAccessTo(installation)) + return Unauthorized(); + + try + { + var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.InstallationName); + await ReportEmailService.SendReportEmailAsync(report, emailAddress); + return Ok(new { message = $"Report sent to {emailAddress}" }); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[SendWeeklyReportEmail] Error: {ex.Message}"); + return BadRequest($"Failed to send report: {ex.Message}"); + } + } + [HttpPut(nameof(UpdateFolder))] public ActionResult UpdateFolder([FromBody] Folder folder, Token authToken) { diff --git a/csharp/App/Backend/DataTypes/DailyEnergyData.cs b/csharp/App/Backend/DataTypes/DailyEnergyData.cs new file mode 100644 index 000000000..80930ac73 --- /dev/null +++ b/csharp/App/Backend/DataTypes/DailyEnergyData.cs @@ -0,0 +1,12 @@ +namespace InnovEnergy.App.Backend.DataTypes; + +public class DailyEnergyData +{ + public string Date { get; set; } = ""; // "2026-02-10" + public double PvProduction { get; set; } // kWh + public double LoadConsumption { get; set; } // kWh + public double GridImport { get; set; } // kWh + public double GridExport { get; set; } // kWh + public double BatteryCharged { get; set; } // kWh + public double BatteryDischarged { get; set; } // kWh +} diff --git a/csharp/App/Backend/DataTypes/WeeklyReportResponse.cs b/csharp/App/Backend/DataTypes/WeeklyReportResponse.cs new file mode 100644 index 000000000..c3187be49 --- /dev/null +++ b/csharp/App/Backend/DataTypes/WeeklyReportResponse.cs @@ -0,0 +1,35 @@ +namespace InnovEnergy.App.Backend.DataTypes; + +public class WeeklyReportResponse +{ + public string InstallationName { get; set; } = ""; + public string PeriodStart { get; set; } = ""; // current week start + public string PeriodEnd { get; set; } = ""; // current week end + + public WeeklySummary CurrentWeek { get; set; } = new(); + public WeeklySummary? PreviousWeek { get; set; } + + // Key ratios (current week) + public double SelfSufficiencyPercent { get; set; } + public double SelfConsumptionPercent { get; set; } + public double BatteryEfficiencyPercent { get; set; } + public double GridDependencyPercent { get; set; } + + // Week-over-week change percentages + public double PvChangePercent { get; set; } + public double ConsumptionChangePercent { get; set; } + public double GridImportChangePercent { get; set; } + + public List DailyData { get; set; } = new(); + public string AiInsight { get; set; } = ""; +} + +public class WeeklySummary +{ + public double TotalPvProduction { get; set; } + public double TotalConsumption { get; set; } + public double TotalGridImport { get; set; } + public double TotalGridExport { get; set; } + public double TotalBatteryCharged { get; set; } + public double TotalBatteryDischarged { get; set; } +} diff --git a/csharp/App/Backend/MailerConfig.json b/csharp/App/Backend/MailerConfig.json index 80840398f..430e85f31 100644 --- a/csharp/App/Backend/MailerConfig.json +++ b/csharp/App/Backend/MailerConfig.json @@ -1,8 +1,8 @@ { - "SmtpServerUrl" : "smtp.gmail.com", - "SmtpUsername" : "angelis@inesco.energy", - "SmtpPassword" : "huvu pkqd kakz hqtm ", + "SmtpServerUrl" : "mail.agenturserver.de", + "SmtpUsername" : "no-reply@inesco.ch", + "SmtpPassword" : "1ci4vi%+bfccIp", "SmtpPort" : 587, "SenderName" : "Inesco Energy", - "SenderAddress" : "noreply@inesco.energy" + "SenderAddress" : "no-reply@inesco.ch" } diff --git a/csharp/App/Backend/Services/ExcelDataParser.cs b/csharp/App/Backend/Services/ExcelDataParser.cs new file mode 100644 index 000000000..949dbc1ea --- /dev/null +++ b/csharp/App/Backend/Services/ExcelDataParser.cs @@ -0,0 +1,88 @@ +using ClosedXML.Excel; +using InnovEnergy.App.Backend.DataTypes; + +namespace InnovEnergy.App.Backend.Services; + +public static class ExcelDataParser +{ + // Column headers from the ESS Link Cloud Excel export + private const string ColDateTime = "Data time"; + private const string ColPvToday = "PV Generated Energy Today"; + private const string ColLoadToday = "Load Consumption Today"; + private const string ColGridImportToday = "Purchased Energy Today"; + private const string ColGridExportToday = "Feed in energy Today"; + private const string ColBattChargedToday = "Daily Battery Charged"; + private const string ColBattDischargedToday = "Battery Discharged Today"; + + /// + /// Parses an ESS Link Cloud Excel export file and returns one DailyEnergyData per day. + /// Takes the last row of each day (where "Today" cumulative values are highest). + /// + public static List Parse(string filePath) + { + if (!File.Exists(filePath)) + throw new FileNotFoundException($"Excel file not found: {filePath}"); + + using var workbook = new XLWorkbook(filePath); + var worksheet = workbook.Worksheet(1); + var lastRow = worksheet.LastRowUsed()?.RowNumber() ?? 0; + + if (lastRow < 2) + throw new InvalidOperationException("Excel file has no data rows."); + + // Find column indices by header name (row 1) + var headerRow = worksheet.Row(1); + var colMap = new Dictionary(); + + for (var col = 1; col <= worksheet.LastColumnUsed()?.ColumnNumber(); col++) + { + var header = headerRow.Cell(col).GetString().Trim(); + if (!string.IsNullOrEmpty(header)) + colMap[header] = col; + } + + // Validate required columns exist + var requiredCols = new[] { ColDateTime, ColPvToday, ColLoadToday, ColGridImportToday, ColGridExportToday, ColBattChargedToday, ColBattDischargedToday }; + foreach (var rc in requiredCols) + { + if (!colMap.ContainsKey(rc)) + throw new InvalidOperationException($"Required column '{rc}' not found in Excel file. Available: {string.Join(", ", colMap.Keys)}"); + } + + // Read all rows, group by date, keep last row per day + var dailyLastRows = new SortedDictionary(); + + for (var row = 2; row <= lastRow; row++) + { + var dateTimeStr = worksheet.Row(row).Cell(colMap[ColDateTime]).GetString().Trim(); + if (string.IsNullOrEmpty(dateTimeStr)) continue; + + // Extract date portion (first 10 chars: "2026-02-10") + var date = dateTimeStr.Length >= 10 ? dateTimeStr[..10] : dateTimeStr; + + var data = new DailyEnergyData + { + Date = date, + PvProduction = GetDouble(worksheet, row, colMap[ColPvToday]), + LoadConsumption = GetDouble(worksheet, row, colMap[ColLoadToday]), + GridImport = GetDouble(worksheet, row, colMap[ColGridImportToday]), + GridExport = GetDouble(worksheet, row, colMap[ColGridExportToday]), + BatteryCharged = GetDouble(worksheet, row, colMap[ColBattChargedToday]), + BatteryDischarged = GetDouble(worksheet, row, colMap[ColBattDischargedToday]), + }; + + // Always overwrite — last row of the day has the final cumulative values + dailyLastRows[date] = data; + } + + Console.WriteLine($"[ExcelDataParser] Parsed {dailyLastRows.Count} days from {filePath}"); + return dailyLastRows.Values.ToList(); + } + + private static double GetDouble(IXLWorksheet ws, int row, int col) + { + var cell = ws.Row(row).Cell(col); + if (cell.IsEmpty()) return 0; + return cell.TryGetValue(out var val) ? Math.Round(val, 4) : 0; + } +} diff --git a/csharp/App/Backend/Services/ReportEmailService.cs b/csharp/App/Backend/Services/ReportEmailService.cs new file mode 100644 index 000000000..39af3d4ec --- /dev/null +++ b/csharp/App/Backend/Services/ReportEmailService.cs @@ -0,0 +1,262 @@ +using System.Text.Json; +using InnovEnergy.App.Backend.DataTypes; +using InnovEnergy.Lib.Mailer; +using MailKit.Net.Smtp; +using MailKit.Security; +using MimeKit; + +namespace InnovEnergy.App.Backend.Services; + +public static class ReportEmailService +{ + /// + /// Sends the weekly report as a nicely formatted HTML email. + /// Uses MailKit directly (same config as existing Mailer library) but with HTML support. + /// + public static async Task SendReportEmailAsync(WeeklyReportResponse report, string recipientEmail) + { + var subject = $"Weekly Energy Report — {report.InstallationName} ({report.PeriodStart} to {report.PeriodEnd})"; + var html = BuildHtmlEmail(report); + + var config = await ReadMailerConfig(); + + var from = new MailboxAddress(config.SenderName, config.SenderAddress); + var to = new MailboxAddress(recipientEmail, recipientEmail); + + var msg = new MimeMessage + { + From = { from }, + To = { to }, + Subject = subject, + Body = new TextPart("html") { Text = html } + }; + + Console.WriteLine($"[ReportEmailService] SMTP: {config.SmtpUsername}@{config.SmtpServerUrl}:{config.SmtpPort}"); + + using var smtp = new SmtpClient(); + await smtp.ConnectAsync(config.SmtpServerUrl, config.SmtpPort, SecureSocketOptions.StartTls); + await smtp.AuthenticateAsync(config.SmtpUsername, config.SmtpPassword); + await smtp.SendAsync(msg); + await smtp.DisconnectAsync(true); + + Console.WriteLine($"[ReportEmailService] Report sent to {recipientEmail}"); + } + + private static async Task ReadMailerConfig() + { + await using var fileStream = File.OpenRead(MailerConfig.DefaultFile); + var config = await JsonSerializer.DeserializeAsync(fileStream); + return config ?? throw new InvalidOperationException("Failed to read MailerConfig.json"); + } + + // ── HTML email template ───────────────────────────────────────────── + + public static string BuildHtmlEmail(WeeklyReportResponse r) + { + var cur = r.CurrentWeek; + var prev = r.PreviousWeek; + + // Parse AI insight into
  • bullet points (split on newlines, strip leading "- " or "1. ") + var insightLines = r.AiInsight + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(l => System.Text.RegularExpressions.Regex.Replace(l.Trim(), @"^[\d]+[.)]\s*|^[-*]\s*", "")) + .Where(l => l.Length > 0) + .ToList(); + + var insightHtml = insightLines.Count > 1 + ? "
      " + + string.Join("", insightLines.Select(l => $"
    • {FormatInsightLine(l)}
    • ")) + + "
    " + : $"

    {FormatInsightLine(r.AiInsight)}

    "; + + // Daily rows + var dailyRows = ""; + foreach (var d in r.DailyData) + { + var dayName = DateTime.Parse(d.Date).ToString("ddd"); + var isCurrentWeek = string.Compare(d.Date, r.PeriodStart, StringComparison.Ordinal) >= 0; + var bgColor = isCurrentWeek ? "#ffffff" : "#f9f9f9"; + dailyRows += $@" + + {dayName} {d.Date} + {d.PvProduction:F1} + {d.LoadConsumption:F1} + {d.GridImport:F1} + {d.GridExport:F1} + {d.BatteryCharged:F1}/{d.BatteryDischarged:F1} + "; + } + + // Week-over-week comparison rows + var comparisonHtml = prev != null + ? $@" + + PV Production + {cur.TotalPvProduction:F1} kWh + {prev.TotalPvProduction:F1} kWh + {FormatChange(r.PvChangePercent)} + + + Consumption + {cur.TotalConsumption:F1} kWh + {prev.TotalConsumption:F1} kWh + {FormatChange(r.ConsumptionChangePercent)} + + + Grid Import + {cur.TotalGridImport:F1} kWh + {prev.TotalGridImport:F1} kWh + {FormatChange(r.GridImportChangePercent)} + + + Grid Export + {cur.TotalGridExport:F1} kWh + {prev.TotalGridExport:F1} kWh + — + + + Battery In/Out + {cur.TotalBatteryCharged:F1}/{cur.TotalBatteryDischarged:F1} kWh + {prev.TotalBatteryCharged:F1}/{prev.TotalBatteryDischarged:F1} kWh + — + " + : $@" + PV Production{cur.TotalPvProduction:F1} kWh + Consumption{cur.TotalConsumption:F1} kWh + Grid Import{cur.TotalGridImport:F1} kWh + Grid Export{cur.TotalGridExport:F1} kWh + Battery In/Out{cur.TotalBatteryCharged:F1}/{cur.TotalBatteryDischarged:F1} kWh"; + + var comparisonHeaders = prev != null + ? @"This Week + Last Week + Change" + : @"This Week"; + + return $@" + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    Weekly Performance Report
    +
    {r.InstallationName}
    +
    {r.PeriodStart} — {r.PeriodEnd}
    +
    +
    Weekly Insights
    +
    + {insightHtml} +
    +
    +
    Weekly Summary
    + + + + {comparisonHeaders} + + {comparisonHtml} +
    Metric
    +
    +
    Your Savings This Week
    + + + {SavingsBox("Solar Energy Used", $"{r.CurrentWeek.TotalPvProduction - r.CurrentWeek.TotalGridExport:F1} kWh", "stayed at home", "#27ae60")} + {SavingsBox("Est. Money Saved", $"~{(r.CurrentWeek.TotalPvProduction - r.CurrentWeek.TotalGridExport) * 0.27:F1} CHF", "at 0.27 CHF/kWh", "#2980b9")} + {SavingsBox("Solar Coverage", $"{r.SelfSufficiencyPercent:F0}%", "from solar", "#8e44ad")} + {SavingsBox("Battery Eff.", $"{r.BatteryEfficiencyPercent:F0}%", "out vs in", "#e67e22")} + +
    +
    +
    Daily Breakdown (kWh)
    + + + + + + + + + + {dailyRows} +
    DayPVLoadGrid InGrid OutBatt In/Out
    +
    + Generated by Inesco Energy Monitor Platform · Powered by Mistral AI +
    +
    + +"; + } + + private static string SavingsBox(string label, string value, string subtitle, string color) => + $@" +
    +
    {value}
    +
    {label}
    +
    {subtitle}
    +
    + "; + + // Bolds "Title" before first colon, and numbers+units in the rest + private static string FormatInsightLine(string line) + { + var colonIdx = line.IndexOf(':'); + string result; + if (colonIdx > 0) + { + var title = line[..colonIdx]; + var rest = line[colonIdx..]; // includes the colon + result = $"{title}{rest}"; + } + else + { + result = line; + } + // Bold numbers followed by units + result = System.Text.RegularExpressions.Regex.Replace( + result, + @"(\d+[\d,.]*\s*(?:kWh|CHF|%|days?))", + "$1"); + return result; + } + + private static string FormatChange(double pct) => + pct == 0 ? "—" : pct > 0 ? $"+{pct:F1}%" : $"{pct:F1}%"; + + private static string ChangeColor(double pct) => + pct > 0 ? "#27ae60" : pct < 0 ? "#e74c3c" : "#888"; +} diff --git a/csharp/App/Backend/Services/WeeklyReportService.cs b/csharp/App/Backend/Services/WeeklyReportService.cs new file mode 100644 index 000000000..3d735fa58 --- /dev/null +++ b/csharp/App/Backend/Services/WeeklyReportService.cs @@ -0,0 +1,241 @@ +using System.Collections.Concurrent; +using Flurl.Http; +using InnovEnergy.App.Backend.DataTypes; +using Newtonsoft.Json; + +namespace InnovEnergy.App.Backend.Services; + +public static class WeeklyReportService +{ + private static readonly string TmpReportDir = Environment.CurrentDirectory + "/tmp_report/"; + + private static readonly ConcurrentDictionary InsightCache = new(); + + /// + /// Generates a full weekly report for the given installation. + /// Caches the full report as JSON next to the xlsx. Cache is invalidated when xlsx is updated. + /// + public static async Task GenerateReportAsync(long installationId, string installationName) + { + var xlsxPath = TmpReportDir + installationId + ".xlsx"; + var cachePath = TmpReportDir + installationId + ".cache.json"; + + // Use cached report if xlsx hasn't changed since cache was written + if (File.Exists(cachePath) && File.Exists(xlsxPath)) + { + var xlsxModified = File.GetLastWriteTimeUtc(xlsxPath); + var cacheModified = File.GetLastWriteTimeUtc(cachePath); + if (cacheModified > xlsxModified) + { + try + { + var cached = JsonConvert.DeserializeObject( + await File.ReadAllTextAsync(cachePath)); + if (cached != null) + { + Console.WriteLine($"[WeeklyReportService] Returning cached report for installation {installationId}."); + return cached; + } + } + catch + { + // Cache corrupt — regenerate + } + } + } + + var allDays = ExcelDataParser.Parse(xlsxPath); + var report = await GenerateReportFromDataAsync(allDays, installationName); + + // Write cache + try + { + await File.WriteAllTextAsync(cachePath, JsonConvert.SerializeObject(report)); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[WeeklyReportService] Could not write cache: {ex.Message}"); + } + + return report; + } + + /// + /// Core report generation from daily data. Data-source agnostic. + /// + public static async Task GenerateReportFromDataAsync( + List allDays, string installationName) + { + // Sort by date + allDays = allDays.OrderBy(d => d.Date).ToList(); + + // Split into previous week and current week + List previousWeekDays; + List currentWeekDays; + + if (allDays.Count > 7) + { + previousWeekDays = allDays.Take(allDays.Count - 7).ToList(); + currentWeekDays = allDays.Skip(allDays.Count - 7).ToList(); + } + else + { + previousWeekDays = new List(); + currentWeekDays = allDays; + } + + var currentSummary = Summarize(currentWeekDays); + var previousSummary = previousWeekDays.Count > 0 ? Summarize(previousWeekDays) : null; + + // Calculate key ratios for current week + var selfSufficiency = currentSummary.TotalConsumption > 0 + ? Math.Round((currentSummary.TotalConsumption - currentSummary.TotalGridImport) / currentSummary.TotalConsumption * 100, 1) + : 0; + + var selfConsumption = currentSummary.TotalPvProduction > 0 + ? Math.Round((currentSummary.TotalPvProduction - currentSummary.TotalGridExport) / currentSummary.TotalPvProduction * 100, 1) + : 0; + + var batteryEfficiency = currentSummary.TotalBatteryCharged > 0 + ? Math.Round(currentSummary.TotalBatteryDischarged / currentSummary.TotalBatteryCharged * 100, 1) + : 0; + + var gridDependency = currentSummary.TotalConsumption > 0 + ? Math.Round(currentSummary.TotalGridImport / currentSummary.TotalConsumption * 100, 1) + : 0; + + // Week-over-week changes + var pvChange = PercentChange(previousSummary?.TotalPvProduction, currentSummary.TotalPvProduction); + var consumptionChange = PercentChange(previousSummary?.TotalConsumption, currentSummary.TotalConsumption); + var gridImportChange = PercentChange(previousSummary?.TotalGridImport, currentSummary.TotalGridImport); + + // AI insight + var aiInsight = await GetAiInsightAsync(currentWeekDays, currentSummary, previousSummary, + selfSufficiency, gridDependency, batteryEfficiency, installationName); + + return new WeeklyReportResponse + { + InstallationName = installationName, + PeriodStart = currentWeekDays.First().Date, + PeriodEnd = currentWeekDays.Last().Date, + CurrentWeek = currentSummary, + PreviousWeek = previousSummary, + SelfSufficiencyPercent = selfSufficiency, + SelfConsumptionPercent = selfConsumption, + BatteryEfficiencyPercent = batteryEfficiency, + GridDependencyPercent = gridDependency, + PvChangePercent = pvChange, + ConsumptionChangePercent = consumptionChange, + GridImportChangePercent = gridImportChange, + DailyData = allDays, + AiInsight = aiInsight, + }; + } + + private static WeeklySummary Summarize(List days) => new() + { + TotalPvProduction = Math.Round(days.Sum(d => d.PvProduction), 1), + TotalConsumption = Math.Round(days.Sum(d => d.LoadConsumption), 1), + TotalGridImport = Math.Round(days.Sum(d => d.GridImport), 1), + TotalGridExport = Math.Round(days.Sum(d => d.GridExport), 1), + TotalBatteryCharged = Math.Round(days.Sum(d => d.BatteryCharged), 1), + TotalBatteryDischarged = Math.Round(days.Sum(d => d.BatteryDischarged), 1), + }; + + private static double PercentChange(double? previous, double current) + { + if (previous is null or 0) return 0; + return Math.Round((current - previous.Value) / previous.Value * 100, 1); + } + + // ── Mistral AI Insight ────────────────────────────────────────────── + + private static readonly string MistralUrl = "https://api.mistral.ai/v1/chat/completions"; + + private static async Task GetAiInsightAsync( + List currentWeek, + WeeklySummary current, + WeeklySummary? previous, + double selfSufficiency, + double gridDependency, + double batteryEfficiency, + string installationName) + { + var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY"); + if (string.IsNullOrWhiteSpace(apiKey)) + { + Console.WriteLine("[WeeklyReportService] MISTRAL_API_KEY not set — skipping AI insight."); + return "AI insight unavailable (API key not configured)."; + } + + // Cache key: installation + period + var cacheKey = $"{installationName}_{currentWeek.Last().Date}"; + if (InsightCache.TryGetValue(cacheKey, out var cached)) + return cached; + + // Build compact prompt + var dayLines = string.Join("\n", currentWeek.Select(d => + { + var dayName = DateTime.Parse(d.Date).ToString("ddd"); + return $"{dayName} {d.Date}: PV={d.PvProduction:F1} Load={d.LoadConsumption:F1} GridIn={d.GridImport:F1} GridOut={d.GridExport:F1} BattIn={d.BatteryCharged:F1} BattOut={d.BatteryDischarged:F1}"; + })); + + var comparison = previous != null + ? $"vs Last week: PV {current.TotalPvProduction} vs {previous.TotalPvProduction}, Grid Import {current.TotalGridImport} vs {previous.TotalGridImport}, Consumption {current.TotalConsumption} vs {previous.TotalConsumption}" + : "No previous week data available."; + + var solarSavings = Math.Round(current.TotalPvProduction - current.TotalGridExport, 1); + + var prompt = $@"You are an energy advisor for a SodistoreHome installation: ""{installationName}"". + +Write exactly 4 bullet points (each on its own line starting with ""- ""). No bold markers, no asterisks, no markdown — plain text only. + +1. Solar savings: this week the system saved {solarSavings} kWh from the grid. Explain what this means in simple terms (e.g. equivalent to X days of average household use, or roughly X CHF saved at ~0.27 CHF/kWh). +2. Best vs worst solar day: name the best and worst days with their PV kWh values. Mention likely weather reason. +3. Battery performance: was the battery well-utilized this week? Mention charge/discharge totals and any standout days. +4. Tip of the week: one specific, practical recommendation based on THIS week's patterns to save more energy or money. + +Rules: Use actual day names and numbers. Keep each bullet to 1-2 sentences. Write for a homeowner, not an engineer. Do NOT use asterisks or any formatting marks. + +Daily data (kWh): +{dayLines} + +Totals: PV={current.TotalPvProduction:F1} Load={current.TotalConsumption:F1} GridIn={current.TotalGridImport:F1} GridOut={current.TotalGridExport:F1} BattIn={current.TotalBatteryCharged:F1} BattOut={current.TotalBatteryDischarged:F1} +Solar used at home={solarSavings} kWh ({selfSufficiency}% of consumption covered by solar) +Battery-eff={batteryEfficiency}% +{comparison}"; + + try + { + var requestBody = new + { + model = "mistral-small-latest", + messages = new[] { new { role = "user", content = prompt } }, + max_tokens = 350, + temperature = 0.3 + }; + + var responseText = await MistralUrl + .WithHeader("Authorization", $"Bearer {apiKey}") + .PostJsonAsync(requestBody) + .ReceiveString(); + + var envelope = JsonConvert.DeserializeObject(responseText); + var content = (string?)envelope?.choices?[0]?.message?.content; + + if (!string.IsNullOrWhiteSpace(content)) + { + var insight = content.Trim(); + InsightCache.TryAdd(cacheKey, insight); + Console.WriteLine($"[WeeklyReportService] AI insight generated ({insight.Length} chars)."); + return insight; + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"[WeeklyReportService] Mistral error: {ex.Message}"); + } + + return "AI insight could not be generated at this time."; + } +} diff --git a/csharp/App/Backend/tmp_report/848.xlsx b/csharp/App/Backend/tmp_report/848.xlsx new file mode 100644 index 000000000..921dd890c Binary files /dev/null and b/csharp/App/Backend/tmp_report/848.xlsx differ diff --git a/typescript/frontend-marios2/src/Resources/routes.json b/typescript/frontend-marios2/src/Resources/routes.json index 8629e04cd..215866358 100644 --- a/typescript/frontend-marios2/src/Resources/routes.json +++ b/typescript/frontend-marios2/src/Resources/routes.json @@ -20,5 +20,6 @@ "configuration": "configuration", "history": "history", "mainstats": "mainstats", - "detailed_view": "detailed_view/" + "detailed_view": "detailed_view/", + "report": "report" } diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx index 6a24ec99b..3aaf93f02 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx @@ -27,6 +27,7 @@ import BatteryViewSodioHome from '../BatteryView/BatteryViewSodioHome'; import SodistoreHomeConfiguration from './SodistoreHomeConfiguration'; import TopologySodistoreHome from '../Topology/TopologySodistoreHome'; import Overview from '../Overview/overview'; +import WeeklyReport from './WeeklyReport'; interface singleInstallationProps { current_installation?: I_Installation; @@ -430,7 +431,8 @@ function SodioHomeInstallation(props: singleInstallationProps) { currentTab != 'overview' && currentTab != 'manage' && currentTab != 'history' && - currentTab != 'log' && ( + currentTab != 'log' && + currentTab != 'report' && ( + {currentUser.userType == UserType.admin && ( + + } + /> + )} + } diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/WeeklyReport.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/WeeklyReport.tsx new file mode 100644 index 000000000..77971df7e --- /dev/null +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/WeeklyReport.tsx @@ -0,0 +1,447 @@ +import { useEffect, useState } from 'react'; +import { + Box, + Button, + CircularProgress, + Container, + Grid, + Paper, + TextField, + Typography, + Alert +} from '@mui/material'; +import SendIcon from '@mui/icons-material/Send'; +import axiosConfig from 'src/Resources/axiosConfig'; + +interface WeeklyReportProps { + installationId: number; +} + +interface DailyEnergyData { + date: string; + pvProduction: number; + loadConsumption: number; + gridImport: number; + gridExport: number; + batteryCharged: number; + batteryDischarged: number; +} + +interface WeeklySummary { + totalPvProduction: number; + totalConsumption: number; + totalGridImport: number; + totalGridExport: number; + totalBatteryCharged: number; + totalBatteryDischarged: number; +} + +interface WeeklyReportResponse { + installationName: string; + periodStart: string; + periodEnd: string; + currentWeek: WeeklySummary; + previousWeek: WeeklySummary | null; + selfSufficiencyPercent: number; + selfConsumptionPercent: number; + batteryEfficiencyPercent: number; + gridDependencyPercent: number; + pvChangePercent: number; + consumptionChangePercent: number; + gridImportChangePercent: number; + dailyData: DailyEnergyData[]; + aiInsight: string; +} + +// Renders a bullet line: bolds the "Title" part before the first colon, and numbers with units +function FormattedBullet({ text }: { text: string }) { + const colonIdx = text.indexOf(':'); + if (colonIdx > 0) { + const title = text.slice(0, colonIdx); + const rest = text.slice(colonIdx + 1); // e.g. " This week, your system saved 120.9 kWh..." + // Bold numbers+units in the rest + const restParts = rest.split(/(\d+[\d,.]*\s*(?:kWh|CHF|%|days?))/g).map((p, i) => + /\d+[\d,.]*\s*(?:kWh|CHF|%|days?)/.test(p) + ? {p} + : {p} + ); + return <>{title}:{restParts}; + } + // No colon — just bold numbers + const parts = text.split(/(\d+[\d,.]*\s*(?:kWh|CHF|%|days?))/g).map((p, i) => + /\d+[\d,.]*\s*(?:kWh|CHF|%|days?)/.test(p) + ? {p} + : {p} + ); + return <>{parts}; +} + +function WeeklyReport({ installationId }: WeeklyReportProps) { + const [report, setReport] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [email, setEmail] = useState(''); + const [sending, setSending] = useState(false); + const [sendStatus, setSendStatus] = useState<{ + message: string; + severity: 'success' | 'error'; + } | null>(null); + + useEffect(() => { + fetchReport(); + }, [installationId]); + + const fetchReport = async () => { + setLoading(true); + setError(null); + try { + const res = await axiosConfig.get('/GetWeeklyReport', { + params: { installationId } + }); + setReport(res.data); + } catch (err: any) { + const msg = + err.response?.data || + err.message || + 'Failed to load report. Make sure the Excel file is placed in tmp_report/'; + setError(typeof msg === 'string' ? msg : JSON.stringify(msg)); + } finally { + setLoading(false); + } + }; + + const handleSendEmail = async () => { + if (!email.trim()) return; + setSending(true); + try { + await axiosConfig.post('/SendWeeklyReportEmail', null, { + params: { installationId, emailAddress: email.trim() } + }); + setSendStatus({ message: `Report sent to ${email}`, severity: 'success' }); + } catch (err: any) { + setSendStatus({ message: 'Failed to send. Please check the email address and try again.', severity: 'error' }); + } finally { + setSending(false); + } + }; + + if (loading) { + return ( + + + + Generating weekly report... + + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (!report) return null; + + const cur = report.currentWeek; + const prev = report.previousWeek; + + const formatChange = (pct: number) => + pct === 0 ? '—' : pct > 0 ? `+${pct.toFixed(1)}%` : `${pct.toFixed(1)}%`; + + const changeColor = (pct: number, invert = false) => { + const effective = invert ? -pct : pct; + return effective > 0 ? '#27ae60' : effective < 0 ? '#e74c3c' : '#888'; + }; + + // Parse AI insight into bullet points + const insightBullets = report.aiInsight + .split(/\n+/) + .map((line) => line.replace(/^[\d]+[.)]\s*/, '').replace(/^[-*]\s*/, '').trim()) + .filter((line) => line.length > 0); + + // Savings-focused KPI values + const solarSavingsKwh = Math.round((cur.totalPvProduction - cur.totalGridExport) * 10) / 10; + const estimatedSavingsCHF = Math.round(solarSavingsKwh * 0.27 * 10) / 10; + + // Find max value for daily bar chart scaling + const maxDailyValue = Math.max( + ...report.dailyData.map((d) => Math.max(d.pvProduction, d.loadConsumption, d.gridImport)), + 1 + ); + + return ( + + {/* Email bar */} + + + { setEmail(e.target.value); setSendStatus(null); }} + sx={{ width: 280 }} + onKeyDown={(e) => e.key === 'Enter' && handleSendEmail()} + /> + + + {sendStatus && ( + + {sendStatus.message} + + )} + + + {/* Report Header */} + + + Weekly Performance Report + + + {report.installationName} + + + {report.periodStart} — {report.periodEnd} + + + + {/* Weekly Insights (was AI Insights) */} + + + Weekly Insights + + + {insightBullets.length > 1 ? ( + + {insightBullets.map((bullet, i) => ( +
  • + ))} + + ) : ( + + + + )} + + + + {/* Your Savings This Week */} + + + Your Savings This Week + + + + + + + + + + + + + + + + + + {/* Weekly Summary Table */} + + + Weekly Summary + + + + + Metric + This Week + {prev && Last Week} + {prev && Change} + + + + + PV Production + {cur.totalPvProduction.toFixed(1)} kWh + {prev && {prev.totalPvProduction.toFixed(1)} kWh} + {prev && {formatChange(report.pvChangePercent)}} + + + Consumption + {cur.totalConsumption.toFixed(1)} kWh + {prev && {prev.totalConsumption.toFixed(1)} kWh} + {prev && {formatChange(report.consumptionChangePercent)}} + + + Grid Import + {cur.totalGridImport.toFixed(1)} kWh + {prev && {prev.totalGridImport.toFixed(1)} kWh} + {prev && {formatChange(report.gridImportChangePercent)}} + + + Grid Export + {cur.totalGridExport.toFixed(1)} kWh + {prev && {prev.totalGridExport.toFixed(1)} kWh} + {prev && —} + + + Battery In / Out + {cur.totalBatteryCharged.toFixed(1)} / {cur.totalBatteryDischarged.toFixed(1)} kWh + {prev && {prev.totalBatteryCharged.toFixed(1)} / {prev.totalBatteryDischarged.toFixed(1)} kWh} + {prev && —} + + + + + + {/* Daily Breakdown - CSS bar chart */} + {report.dailyData.length > 0 && ( + + + Daily Breakdown + + {/* Legend */} + + + PV Production + + + Consumption + + + Grid Import + + + {/* Bars */} + {report.dailyData.map((d, i) => { + const dt = new Date(d.date); + const dayLabel = dt.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); + const isCurrentWeek = report.dailyData.length > 7 ? i >= report.dailyData.length - 7 : true; + return ( + + + + {dayLabel} + {!isCurrentWeek && (prev week)} + + + PV {d.pvProduction.toFixed(1)} | Load {d.loadConsumption.toFixed(1)} | Grid {d.gridImport.toFixed(1)} kWh + + + + 0 ? '2px' : 0, + transition: 'width 0.3s' + }} + /> + 0 ? '2px' : 0, + transition: 'width 0.3s' + }} + /> + 0 ? '2px' : 0, + transition: 'width 0.3s' + }} + /> + + + ); + })} + + )} + + + ); +} + +function SavingsCard({ label, value, subtitle, color }: { label: string; value: string; subtitle: string; color: string }) { + return ( + + + {value} + + + {label} + + + {subtitle} + + + ); +} + +export default WeeklyReport; diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/index.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/index.tsx index a538b196e..feee7a4ab 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/index.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/index.tsx @@ -30,7 +30,8 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) { 'manage', 'log', 'history', - 'configuration' + 'configuration', + 'report' ]; const [currentTab, setCurrentTab] = useState(undefined); @@ -152,6 +153,15 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) { defaultMessage="History Of Actions" /> ) + }, + { + value: 'report', + label: ( + + ) } ] : [ @@ -240,6 +250,15 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) { defaultMessage="History Of Actions" /> ) + }, + { + value: 'report', + label: ( + + ) } ] : currentTab != 'list' &&