From 78b9c2dc72da1bbaf397e8e29daf9fa66d9a5cb6 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Fri, 27 Feb 2026 11:48:05 +0100 Subject: [PATCH 01/64] generate monthly and yearly report based on xlsx files --- csharp/App/Backend/Controller.cs | 199 +++- csharp/App/Backend/DataTypes/ReportSummary.cs | 149 +++ csharp/App/Backend/Database/Create.cs | 4 + csharp/App/Backend/Database/Db.cs | 11 +- csharp/App/Backend/Database/Delete.cs | 39 + csharp/App/Backend/Database/Read.cs | 38 + csharp/App/Backend/Program.cs | 1 + .../Services/ReportAggregationService.cs | 627 +++++++++++++ .../Backend/Services/ReportEmailService.cs | 226 +++++ .../SodiohomeInstallations/WeeklyReport.tsx | 860 +++++++++++++++--- typescript/frontend-marios2/src/lang/de.json | 22 + typescript/frontend-marios2/src/lang/en.json | 22 + typescript/frontend-marios2/src/lang/fr.json | 22 + typescript/frontend-marios2/src/lang/it.json | 22 + 14 files changed, 2099 insertions(+), 143 deletions(-) create mode 100644 csharp/App/Backend/DataTypes/ReportSummary.cs create mode 100644 csharp/App/Backend/Services/ReportAggregationService.cs diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index 46b4e3ad8..9ddc14614 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -919,6 +919,10 @@ public class Controller : ControllerBase { var lang = language ?? user.Language ?? "en"; var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.InstallationName, lang); + + // Persist weekly summary for future monthly aggregation (idempotent) + ReportAggregationService.SaveWeeklySummary(installationId, report); + return Ok(report); } catch (Exception ex) @@ -956,6 +960,199 @@ public class Controller : ControllerBase } } + // ── Monthly & Yearly Reports ───────────────────────────────────── + + [HttpGet(nameof(GetPendingMonthlyAggregations))] + public ActionResult> GetPendingMonthlyAggregations(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(); + + return Ok(ReportAggregationService.GetPendingMonthlyAggregations(installationId)); + } + + [HttpGet(nameof(GetPendingYearlyAggregations))] + public ActionResult> GetPendingYearlyAggregations(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(); + + return Ok(ReportAggregationService.GetPendingYearlyAggregations(installationId)); + } + + [HttpGet(nameof(GetMonthlyReports))] + public ActionResult> GetMonthlyReports(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(); + + return Ok(Db.GetMonthlyReports(installationId)); + } + + [HttpGet(nameof(GetYearlyReports))] + public ActionResult> GetYearlyReports(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(); + + return Ok(Db.GetYearlyReports(installationId)); + } + + /// + /// Manually trigger monthly aggregation for an installation. + /// Aggregates weekly reports for the specified year/month into a monthly report. + /// + [HttpPost(nameof(TriggerMonthlyAggregation))] + public async Task TriggerMonthlyAggregation(Int64 installationId, Int32 year, Int32 month, 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(); + + if (month < 1 || month > 12) + return BadRequest("Month must be between 1 and 12."); + + try + { + var lang = user.Language ?? "en"; + var weekCount = await ReportAggregationService.TriggerMonthlyAggregationAsync(installationId, year, month, lang); + if (weekCount == 0) + return NotFound($"No weekly reports found for {year}-{month:D2}."); + + return Ok(new { message = $"Monthly report created from {weekCount} weekly reports for {year}-{month:D2}." }); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[TriggerMonthlyAggregation] Error: {ex.Message}"); + return BadRequest($"Failed to aggregate: {ex.Message}"); + } + } + + /// + /// Manually trigger yearly aggregation for an installation. + /// Aggregates monthly reports for the specified year into a yearly report. + /// + [HttpPost(nameof(TriggerYearlyAggregation))] + public async Task TriggerYearlyAggregation(Int64 installationId, Int32 year, 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 lang = user.Language ?? "en"; + var monthCount = await ReportAggregationService.TriggerYearlyAggregationAsync(installationId, year, lang); + if (monthCount == 0) + return NotFound($"No monthly reports found for {year}."); + + return Ok(new { message = $"Yearly report created from {monthCount} monthly reports for {year}." }); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[TriggerYearlyAggregation] Error: {ex.Message}"); + return BadRequest($"Failed to aggregate: {ex.Message}"); + } + } + + [HttpPost(nameof(SendMonthlyReportEmail))] + public async Task SendMonthlyReportEmail(Int64 installationId, Int32 year, Int32 month, 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(); + + var report = Db.GetMonthlyReports(installationId).FirstOrDefault(r => r.Year == year && r.Month == month); + if (report == null) + return BadRequest($"No monthly report found for {year}-{month:D2}."); + + try + { + var lang = user.Language ?? "en"; + await ReportEmailService.SendMonthlyReportEmailAsync(report, installation.InstallationName, emailAddress, lang); + return Ok(new { message = $"Monthly report sent to {emailAddress}" }); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[SendMonthlyReportEmail] Error: {ex.Message}"); + return BadRequest($"Failed to send report: {ex.Message}"); + } + } + + [HttpPost(nameof(SendYearlyReportEmail))] + public async Task SendYearlyReportEmail(Int64 installationId, Int32 year, 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(); + + var report = Db.GetYearlyReports(installationId).FirstOrDefault(r => r.Year == year); + if (report == null) + return BadRequest($"No yearly report found for {year}."); + + try + { + var lang = user.Language ?? "en"; + await ReportEmailService.SendYearlyReportEmailAsync(report, installation.InstallationName, emailAddress, lang); + return Ok(new { message = $"Yearly report sent to {emailAddress}" }); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[SendYearlyReportEmail] Error: {ex.Message}"); + return BadRequest($"Failed to send report: {ex.Message}"); + } + } + + [HttpGet(nameof(GetWeeklyReportSummaries))] + public ActionResult> GetWeeklyReportSummaries(Int64 installationId, Int32 year, Int32 month, 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(); + + return Ok(Db.GetWeeklyReportsForMonth(installationId, year, month)); + } + [HttpPut(nameof(UpdateFolder))] public ActionResult UpdateFolder([FromBody] Folder folder, Token authToken) { @@ -963,7 +1160,7 @@ public class Controller : ControllerBase if (!session.Update(folder)) return Unauthorized(); - + return folder.HideParentIfUserHasNoAccessToParent(session!.User); } diff --git a/csharp/App/Backend/DataTypes/ReportSummary.cs b/csharp/App/Backend/DataTypes/ReportSummary.cs new file mode 100644 index 000000000..04cc93c76 --- /dev/null +++ b/csharp/App/Backend/DataTypes/ReportSummary.cs @@ -0,0 +1,149 @@ +using SQLite; + +namespace InnovEnergy.App.Backend.DataTypes; + +/// +/// Stored summary for a weekly report period. +/// Created when GetWeeklyReport is called. Consumed and deleted by monthly aggregation. +/// +public class WeeklyReportSummary +{ + [PrimaryKey, AutoIncrement] + public Int64 Id { get; set; } + + [Indexed] + public Int64 InstallationId { get; set; } + + // Period boundaries (ISO date strings: "2026-02-10") + public String PeriodStart { get; set; } = ""; + public String PeriodEnd { get; set; } = ""; + + // Energy totals (kWh) + 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; } + + // Derived metrics + public Double TotalEnergySaved { get; set; } + public Double TotalSavingsCHF { get; set; } + public Double SelfSufficiencyPercent { get; set; } + public Double SelfConsumptionPercent { get; set; } + public Double BatteryEfficiencyPercent { get; set; } + public Double GridDependencyPercent { get; set; } + + // Behavioral highlights + public Int32 PeakLoadHour { get; set; } + public Int32 PeakSolarHour { get; set; } + public Double WeekdayAvgDailyLoad { get; set; } + public Double WeekendAvgDailyLoad { get; set; } + + // AI insight for this week + public String AiInsight { get; set; } = ""; + + public String CreatedAt { get; set; } = ""; +} + +/// +/// Aggregated monthly report. Created from weekly summaries at month-end. +/// Consumed and deleted by yearly aggregation. +/// +public class MonthlyReportSummary +{ + [PrimaryKey, AutoIncrement] + public Int64 Id { get; set; } + + [Indexed] + public Int64 InstallationId { get; set; } + + public Int32 Year { get; set; } + public Int32 Month { get; set; } + public String PeriodStart { get; set; } = ""; + public String PeriodEnd { get; set; } = ""; + + // Aggregated energy totals + 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; } + + // Re-derived from aggregated totals + public Double TotalEnergySaved { get; set; } + public Double TotalSavingsCHF { get; set; } + public Double SelfSufficiencyPercent { get; set; } + public Double SelfConsumptionPercent { get; set; } + public Double BatteryEfficiencyPercent { get; set; } + public Double GridDependencyPercent { get; set; } + + // Averaged behavioral highlights + public Int32 AvgPeakLoadHour { get; set; } + public Int32 AvgPeakSolarHour { get; set; } + public Double AvgWeekdayDailyLoad { get; set; } + public Double AvgWeekendDailyLoad { get; set; } + + public Int32 WeekCount { get; set; } + public String AiInsight { get; set; } = ""; + public String CreatedAt { get; set; } = ""; +} + +/// +/// Aggregated yearly report. Created from monthly summaries at year-end. +/// Kept indefinitely. +/// +public class YearlyReportSummary +{ + [PrimaryKey, AutoIncrement] + public Int64 Id { get; set; } + + [Indexed] + public Int64 InstallationId { get; set; } + + public Int32 Year { get; set; } + public String PeriodStart { get; set; } = ""; + public String PeriodEnd { get; set; } = ""; + + // Aggregated energy totals + 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; } + + // Re-derived from aggregated totals + public Double TotalEnergySaved { get; set; } + public Double TotalSavingsCHF { get; set; } + public Double SelfSufficiencyPercent { get; set; } + public Double SelfConsumptionPercent { get; set; } + public Double BatteryEfficiencyPercent { get; set; } + public Double GridDependencyPercent { get; set; } + + // Averaged behavioral highlights + public Int32 AvgPeakLoadHour { get; set; } + public Int32 AvgPeakSolarHour { get; set; } + public Double AvgWeekdayDailyLoad { get; set; } + public Double AvgWeekendDailyLoad { get; set; } + + public Int32 MonthCount { get; set; } + public String AiInsight { get; set; } = ""; + public String CreatedAt { get; set; } = ""; +} + +// ── DTOs for pending aggregation queries (not stored in DB) ── + +public class PendingMonth +{ + public Int32 Year { get; set; } + public Int32 Month { get; set; } + public Int32 WeekCount { get; set; } +} + +public class PendingYear +{ + public Int32 Year { get; set; } + public Int32 MonthCount { get; set; } +} diff --git a/csharp/App/Backend/Database/Create.cs b/csharp/App/Backend/Database/Create.cs index 9263b0793..04fe7d631 100644 --- a/csharp/App/Backend/Database/Create.cs +++ b/csharp/App/Backend/Database/Create.cs @@ -67,6 +67,10 @@ public static partial class Db { return Insert(action); } + + public static Boolean Create(WeeklyReportSummary report) => Insert(report); + public static Boolean Create(MonthlyReportSummary report) => Insert(report); + public static Boolean Create(YearlyReportSummary report) => Insert(report); public static void HandleAction(UserAction newAction) { diff --git a/csharp/App/Backend/Database/Db.cs b/csharp/App/Backend/Database/Db.cs index 5f10d3091..52c82b53e 100644 --- a/csharp/App/Backend/Database/Db.cs +++ b/csharp/App/Backend/Database/Db.cs @@ -25,7 +25,10 @@ public static partial class Db public static TableQuery Errors => Connection.Table(); public static TableQuery Warnings => Connection.Table(); public static TableQuery UserActions => Connection.Table(); - + public static TableQuery WeeklyReports => Connection.Table(); + public static TableQuery MonthlyReports => Connection.Table(); + public static TableQuery YearlyReports => Connection.Table(); + public static void Init() { @@ -51,6 +54,9 @@ public static partial class Db Connection.CreateTable(); Connection.CreateTable(); Connection.CreateTable(); + Connection.CreateTable(); + Connection.CreateTable(); + Connection.CreateTable(); }); // One-time migration: normalize legacy long-form language values to ISO codes @@ -88,6 +94,9 @@ public static partial class Db fileConnection.CreateTable(); fileConnection.CreateTable(); fileConnection.CreateTable(); + fileConnection.CreateTable(); + fileConnection.CreateTable(); + fileConnection.CreateTable(); return fileConnection; //return CopyDbToMemory(fileConnection); diff --git a/csharp/App/Backend/Database/Delete.cs b/csharp/App/Backend/Database/Delete.cs index a4dd5a748..f39eb6dd0 100644 --- a/csharp/App/Backend/Database/Delete.cs +++ b/csharp/App/Backend/Database/Delete.cs @@ -141,4 +141,43 @@ public static partial class Db { OrderNumber2Installation.Delete(s => s.InstallationId == relation.InstallationId && s.OrderNumber == relation.OrderNumber); } + + public static void DeleteWeeklyReportsForMonth(Int64 installationId, Int32 year, Int32 month) + { + var monthStart = $"{year:D4}-{month:D2}-01"; + var monthEnd = month == 12 ? $"{year + 1:D4}-01-01" : $"{year:D4}-{month + 1:D2}-01"; + + // SQLite-net doesn't support string comparison in Delete lambda, + // so fetch matching IDs first, then delete by ID. + var ids = WeeklyReports + .Where(r => r.InstallationId == installationId) + .ToList() + .Where(r => String.Compare(r.PeriodStart, monthStart, StringComparison.Ordinal) >= 0 + && String.Compare(r.PeriodStart, monthEnd, StringComparison.Ordinal) < 0) + .Select(r => r.Id) + .ToList(); + + foreach (var id in ids) + WeeklyReports.Delete(r => r.Id == id); + + if (ids.Count > 0) Backup(); + } + + public static void DeleteMonthlyReportsForYear(Int64 installationId, Int32 year) + { + MonthlyReports.Delete(r => r.InstallationId == installationId && r.Year == year); + Backup(); + } + + public static void DeleteMonthlyReport(Int64 installationId, Int32 year, Int32 month) + { + var count = MonthlyReports.Delete(r => r.InstallationId == installationId && r.Year == year && r.Month == month); + if (count > 0) Backup(); + } + + public static void DeleteYearlyReport(Int64 installationId, Int32 year) + { + var count = YearlyReports.Delete(r => r.InstallationId == installationId && r.Year == year); + if (count > 0) Backup(); + } } \ No newline at end of file diff --git a/csharp/App/Backend/Database/Read.cs b/csharp/App/Backend/Database/Read.cs index f47298cb3..68bfa2015 100644 --- a/csharp/App/Backend/Database/Read.cs +++ b/csharp/App/Backend/Database/Read.cs @@ -56,4 +56,42 @@ public static partial class Db return session; } + + // ── Report Queries ──────────────────────────────────────────────── + + public static List GetWeeklyReports(Int64 installationId) + => WeeklyReports + .Where(r => r.InstallationId == installationId) + .OrderByDescending(r => r.PeriodStart) + .ToList(); + + public static List GetWeeklyReportsForMonth(Int64 installationId, Int32 year, Int32 month) + { + var monthStart = $"{year:D4}-{month:D2}-01"; + var monthEnd = month == 12 ? $"{year + 1:D4}-01-01" : $"{year:D4}-{month + 1:D2}-01"; + return WeeklyReports + .Where(r => r.InstallationId == installationId) + .ToList() + .Where(r => String.Compare(r.PeriodStart, monthStart, StringComparison.Ordinal) >= 0 + && String.Compare(r.PeriodStart, monthEnd, StringComparison.Ordinal) < 0) + .ToList(); + } + + public static List GetMonthlyReports(Int64 installationId) + => MonthlyReports + .Where(r => r.InstallationId == installationId) + .OrderByDescending(r => r.Year) + .ThenByDescending(r => r.Month) + .ToList(); + + public static List GetMonthlyReportsForYear(Int64 installationId, Int32 year) + => MonthlyReports + .Where(r => r.InstallationId == installationId && r.Year == year) + .ToList(); + + public static List GetYearlyReports(Int64 installationId) + => YearlyReports + .Where(r => r.InstallationId == installationId) + .OrderByDescending(r => r.Year) + .ToList(); } \ No newline at end of file diff --git a/csharp/App/Backend/Program.cs b/csharp/App/Backend/Program.cs index 0ae6dc02e..73350b51c 100644 --- a/csharp/App/Backend/Program.cs +++ b/csharp/App/Backend/Program.cs @@ -28,6 +28,7 @@ public static class Program LoadEnvFile(); DiagnosticService.Initialize(); AlarmReviewService.StartDailyScheduler(); + // ReportAggregationService.StartScheduler(); // TODO: uncomment to enable automatic monthly/yearly aggregation var builder = WebApplication.CreateBuilder(args); RabbitMqManager.InitializeEnvironment(); diff --git a/csharp/App/Backend/Services/ReportAggregationService.cs b/csharp/App/Backend/Services/ReportAggregationService.cs new file mode 100644 index 000000000..9100bcab0 --- /dev/null +++ b/csharp/App/Backend/Services/ReportAggregationService.cs @@ -0,0 +1,627 @@ +using Flurl.Http; +using InnovEnergy.App.Backend.Database; +using InnovEnergy.App.Backend.DataTypes; +using Newtonsoft.Json; + +namespace InnovEnergy.App.Backend.Services; + +public static class ReportAggregationService +{ + private static Timer? _monthEndTimer; + private static Timer? _yearEndTimer; + // private static Timer? _sundayReportTimer; + + private const Double ElectricityPriceCHF = 0.39; + private static readonly String MistralUrl = "https://api.mistral.ai/v1/chat/completions"; + + // ── Scheduler ───────────────────────────────────────────────────── + + public static void StartScheduler() + { + // ScheduleSundayWeeklyReport(); + ScheduleMonthEndJob(); + ScheduleYearEndJob(); + Console.WriteLine("[ReportAggregation] Scheduler started."); + } + + private static void ScheduleMonthEndJob() + { + // Run daily at 02:00, but only act on the 1st of the month + var now = DateTime.Now; + var next = now.Date.AddHours(2); + if (now >= next) next = next.AddDays(1); + + _monthEndTimer = new Timer( + _ => + { + try + { + if (DateTime.Now.Day == 1) + RunMonthEndAggregation().GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[ReportAggregation] Month-end error: {ex.Message}"); + } + }, + null, next - now, TimeSpan.FromDays(1)); + + Console.WriteLine($"[ReportAggregation] Month-end timer scheduled (daily 02:00, acts on 1st). Next check: {next:yyyy-MM-dd HH:mm}"); + } + + private static void ScheduleYearEndJob() + { + // Run daily at 03:00, but only act on Jan 2nd + var now = DateTime.Now; + var next = now.Date.AddHours(3); + if (now >= next) next = next.AddDays(1); + + _yearEndTimer = new Timer( + _ => + { + try + { + if (DateTime.Now.Month == 1 && DateTime.Now.Day == 2) + RunYearEndAggregation().GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[ReportAggregation] Year-end error: {ex.Message}"); + } + }, + null, next - now, TimeSpan.FromDays(1)); + + Console.WriteLine($"[ReportAggregation] Year-end timer scheduled (daily 03:00, acts on Jan 2). Next check: {next:yyyy-MM-dd HH:mm}"); + } + + // ── Sunday Weekly Report Automation ───────────────────────────── + // Generates weekly reports (Sunday–Saturday) for all SodiStoreHome + // installations every Sunday at 06:00, saves summary to DB, and + // emails the report to all users who have access to the installation. + // + // TODO: uncomment ScheduleSundayWeeklyReport() in StartScheduler() to enable. + + // private static void ScheduleSundayWeeklyReport() + // { + // // Calculate delay until next Sunday 06:00 + // var now = DateTime.Now; + // var daysUntil = ((int)DayOfWeek.Sunday - (int)now.DayOfWeek + 7) % 7; + // var nextSunday = now.Date.AddDays(daysUntil == 0 && now.Hour >= 6 ? 7 : daysUntil).AddHours(6); + // + // _sundayReportTimer = new Timer( + // _ => + // { + // try + // { + // if (DateTime.Now.DayOfWeek == DayOfWeek.Sunday) + // RunSundayWeeklyReports().GetAwaiter().GetResult(); + // } + // catch (Exception ex) + // { + // Console.Error.WriteLine($"[ReportAggregation] Sunday report error: {ex.Message}"); + // } + // }, + // null, nextSunday - now, TimeSpan.FromDays(7)); + // + // Console.WriteLine($"[ReportAggregation] Sunday weekly report scheduled. Next run: {nextSunday:yyyy-MM-dd HH:mm}"); + // } + // + // private static async Task RunSundayWeeklyReports() + // { + // Console.WriteLine("[ReportAggregation] Running Sunday weekly report generation..."); + // + // // Find all SodiStoreHome installations + // var installations = Db.Installations + // .Where(i => i.Product == (int)ProductType.SodioHome) + // .ToList(); + // + // foreach (var installation in installations) + // { + // try + // { + // // Generate the weekly report (covers last Sunday–Saturday) + // var report = await WeeklyReportService.GenerateReportAsync( + // installation.Id, installation.InstallationName, "en"); + // + // // Save summary to DB for future monthly aggregation + // SaveWeeklySummary(installation.Id, report); + // + // // Email the report to all users who have access to this installation + // var userIds = Db.InstallationAccess + // .Where(a => a.InstallationId == installation.Id) + // .Select(a => a.UserId) + // .ToList(); + // + // foreach (var userId in userIds) + // { + // var user = Db.GetUserById(userId); + // if (user == null || String.IsNullOrWhiteSpace(user.Email)) + // continue; + // + // try + // { + // var lang = user.Language ?? "en"; + // // Regenerate with user's language if different from "en" + // var localizedReport = lang == "en" + // ? report + // : await WeeklyReportService.GenerateReportAsync( + // installation.Id, installation.InstallationName, lang); + // + // await ReportEmailService.SendReportEmailAsync(localizedReport, user.Email, lang); + // Console.WriteLine($"[ReportAggregation] Weekly report emailed to {user.Email} for installation {installation.Id}"); + // } + // catch (Exception emailEx) + // { + // Console.Error.WriteLine($"[ReportAggregation] Failed to email {user.Email}: {emailEx.Message}"); + // } + // } + // } + // catch (Exception ex) + // { + // Console.Error.WriteLine($"[ReportAggregation] Failed weekly report for installation {installation.Id}: {ex.Message}"); + // } + // } + // + // Console.WriteLine("[ReportAggregation] Sunday weekly report generation complete."); + // } + + // ── Save Weekly Summary ─────────────────────────────────────────── + + public static void SaveWeeklySummary(Int64 installationId, WeeklyReportResponse report) + { + try + { + // Remove any existing weekly records whose date range overlaps with this report. + // Two periods overlap when: existingStart < newEnd AND newStart < existingEnd. + // This prevents double-counting when the same days appear in different report windows + // (e.g., report for days 1-7, then later 2-8 — the old 1-7 record is removed). + var overlapping = Db.WeeklyReports + .Where(r => r.InstallationId == installationId) + .ToList() + .Where(r => String.Compare(r.PeriodStart, report.PeriodEnd, StringComparison.Ordinal) < 0 + && String.Compare(report.PeriodStart, r.PeriodEnd, StringComparison.Ordinal) < 0) + .ToList(); + + foreach (var old in overlapping) + Db.WeeklyReports.Delete(r => r.Id == old.Id); + + var summary = new WeeklyReportSummary + { + InstallationId = installationId, + PeriodStart = report.PeriodStart, + PeriodEnd = report.PeriodEnd, + TotalPvProduction = report.CurrentWeek.TotalPvProduction, + TotalConsumption = report.CurrentWeek.TotalConsumption, + TotalGridImport = report.CurrentWeek.TotalGridImport, + TotalGridExport = report.CurrentWeek.TotalGridExport, + TotalBatteryCharged = report.CurrentWeek.TotalBatteryCharged, + TotalBatteryDischarged = report.CurrentWeek.TotalBatteryDischarged, + TotalEnergySaved = report.TotalEnergySaved, + TotalSavingsCHF = report.TotalSavingsCHF, + SelfSufficiencyPercent = report.SelfSufficiencyPercent, + SelfConsumptionPercent = report.SelfConsumptionPercent, + BatteryEfficiencyPercent = report.BatteryEfficiencyPercent, + GridDependencyPercent = report.GridDependencyPercent, + PeakLoadHour = report.Behavior?.PeakLoadHour ?? 0, + PeakSolarHour = report.Behavior?.PeakSolarHour ?? 0, + WeekdayAvgDailyLoad = report.Behavior?.WeekdayAvgDailyLoad ?? 0, + WeekendAvgDailyLoad = report.Behavior?.WeekendAvgDailyLoad ?? 0, + AiInsight = report.AiInsight, + CreatedAt = DateTime.UtcNow.ToString("o"), + }; + + Db.Create(summary); + Console.WriteLine($"[ReportAggregation] Saved weekly summary for installation {installationId}, period {report.PeriodStart}–{report.PeriodEnd} (replaced {overlapping.Count} overlapping record(s))"); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[ReportAggregation] Failed to save weekly summary for installation {installationId}: {ex.Message}"); + } + } + + // ── Pending Aggregation Queries ───────────────────────────────── + + /// + /// Returns months that have weekly data but no monthly report yet. + /// Each entry: { Year, Month, WeekCount }. + /// + public static List GetPendingMonthlyAggregations(Int64 installationId) + { + var weeklies = Db.GetWeeklyReports(installationId); + var existingMonths = Db.GetMonthlyReports(installationId) + .Select(m => (m.Year, m.Month)) + .ToHashSet(); + + return weeklies + .GroupBy(w => + { + var date = DateTime.Parse(w.PeriodStart); + return (Year: date.Year, Month: date.Month); + }) + .Where(g => !existingMonths.Contains(g.Key)) + .Select(g => new PendingMonth { Year = g.Key.Year, Month = g.Key.Month, WeekCount = g.Count() }) + .OrderByDescending(p => p.Year).ThenByDescending(p => p.Month) + .ToList(); + } + + /// + /// Returns years that have monthly data but no yearly report yet. + /// Each entry: { Year, MonthCount }. + /// + public static List GetPendingYearlyAggregations(Int64 installationId) + { + var monthlies = Db.GetMonthlyReports(installationId); + var existingYears = Db.GetYearlyReports(installationId) + .Select(y => y.Year) + .ToHashSet(); + + return monthlies + .GroupBy(m => m.Year) + .Where(g => !existingYears.Contains(g.Key)) + .Select(g => new PendingYear { Year = g.Key, MonthCount = g.Count() }) + .OrderByDescending(p => p.Year) + .ToList(); + } + + // ── Month-End Aggregation ───────────────────────────────────────── + + /// + /// Trigger monthly aggregation for a specific installation and month. + /// Re-generates if a monthly report already exists. Weekly records are kept. + /// Returns the number of weekly records aggregated (0 = no data). + /// + public static async Task TriggerMonthlyAggregationAsync(Int64 installationId, Int32 year, Int32 month, String language = "en") + { + var weeklies = Db.GetWeeklyReportsForMonth(installationId, year, month); + if (weeklies.Count == 0) + return 0; + + await AggregateMonthForInstallation(installationId, year, month, language); + return weeklies.Count; + } + + /// + /// Trigger yearly aggregation for a specific installation and year. + /// Re-generates if a yearly report already exists. Monthly records are kept. + /// Returns the number of monthly records aggregated (0 = no data). + /// + public static async Task TriggerYearlyAggregationAsync(Int64 installationId, Int32 year, String language = "en") + { + var monthlies = Db.GetMonthlyReportsForYear(installationId, year); + if (monthlies.Count == 0) + return 0; + + await AggregateYearForInstallation(installationId, year, language); + return monthlies.Count; + } + + private static async Task RunMonthEndAggregation() + { + var previousMonth = DateTime.Now.AddMonths(-1); + var year = previousMonth.Year; + var month = previousMonth.Month; + + Console.WriteLine($"[ReportAggregation] Running month-end aggregation for {year}-{month:D2}..."); + + var installationIds = Db.WeeklyReports + .Select(r => r.InstallationId) + .ToList() + .Distinct() + .ToList(); + + foreach (var installationId in installationIds) + { + try + { + await AggregateMonthForInstallation(installationId, year, month); + + // Scheduler mode: clean up weekly records after successful aggregation. + // The monthly report is now the source of truth for this month. + Db.DeleteWeeklyReportsForMonth(installationId, year, month); + Console.WriteLine($"[ReportAggregation] Cleaned up weekly records for installation {installationId}, {year}-{month:D2}."); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[ReportAggregation] Month aggregation failed for installation {installationId}: {ex.Message}"); + } + } + } + + private static async Task AggregateMonthForInstallation(Int64 installationId, Int32 year, Int32 month, String language = "en") + { + var weeklies = Db.GetWeeklyReportsForMonth(installationId, year, month); + if (weeklies.Count == 0) + return; + + // If monthly report already exists, delete it so we can re-generate with latest weekly data. + // This supports partial months and re-aggregation when new weekly data arrives. + Db.DeleteMonthlyReport(installationId, year, month); + + // Sum energy totals + var totalPv = Math.Round(weeklies.Sum(w => w.TotalPvProduction), 1); + var totalConsump = Math.Round(weeklies.Sum(w => w.TotalConsumption), 1); + var totalGridIn = Math.Round(weeklies.Sum(w => w.TotalGridImport), 1); + var totalGridOut = Math.Round(weeklies.Sum(w => w.TotalGridExport), 1); + var totalBattChg = Math.Round(weeklies.Sum(w => w.TotalBatteryCharged), 1); + var totalBattDis = Math.Round(weeklies.Sum(w => w.TotalBatteryDischarged), 1); + + // Re-derive ratios from aggregated totals + var energySaved = Math.Round(totalConsump - totalGridIn, 1); + var savingsCHF = Math.Round(energySaved * ElectricityPriceCHF, 0); + var selfSufficiency = totalConsump > 0 ? Math.Round((totalConsump - totalGridIn) / totalConsump * 100, 1) : 0; + var selfConsumption = totalPv > 0 ? Math.Round((totalPv - totalGridOut) / totalPv * 100, 1) : 0; + var batteryEff = totalBattChg > 0 ? Math.Round(totalBattDis / totalBattChg * 100, 1) : 0; + var gridDependency = totalConsump > 0 ? Math.Round(totalGridIn / totalConsump * 100, 1) : 0; + + // Average behavioral highlights + var avgPeakLoad = (Int32)Math.Round(weeklies.Average(w => w.PeakLoadHour)); + var avgPeakSolar = (Int32)Math.Round(weeklies.Average(w => w.PeakSolarHour)); + var avgWeekdayLoad = Math.Round(weeklies.Average(w => w.WeekdayAvgDailyLoad), 1); + var avgWeekendLoad = Math.Round(weeklies.Average(w => w.WeekendAvgDailyLoad), 1); + + // Get installation name for AI insight + var installation = Db.GetInstallationById(installationId); + var installationName = installation?.InstallationName ?? $"Installation {installationId}"; + var monthName = new DateTime(year, month, 1).ToString("MMMM yyyy"); + + var aiInsight = await GenerateMonthlyAiInsightAsync( + installationName, monthName, weeklies.Count, + totalPv, totalConsump, totalGridIn, totalGridOut, + totalBattChg, totalBattDis, energySaved, savingsCHF, + selfSufficiency, batteryEff, language); + + var monthlySummary = new MonthlyReportSummary + { + InstallationId = installationId, + Year = year, + Month = month, + PeriodStart = weeklies.Min(w => w.PeriodStart), + PeriodEnd = weeklies.Max(w => w.PeriodEnd), + TotalPvProduction = totalPv, + TotalConsumption = totalConsump, + TotalGridImport = totalGridIn, + TotalGridExport = totalGridOut, + TotalBatteryCharged = totalBattChg, + TotalBatteryDischarged = totalBattDis, + TotalEnergySaved = energySaved, + TotalSavingsCHF = savingsCHF, + SelfSufficiencyPercent = selfSufficiency, + SelfConsumptionPercent = selfConsumption, + BatteryEfficiencyPercent = batteryEff, + GridDependencyPercent = gridDependency, + AvgPeakLoadHour = avgPeakLoad, + AvgPeakSolarHour = avgPeakSolar, + AvgWeekdayDailyLoad = avgWeekdayLoad, + AvgWeekendDailyLoad = avgWeekendLoad, + WeekCount = weeklies.Count, + AiInsight = aiInsight, + CreatedAt = DateTime.UtcNow.ToString("o"), + }; + + Db.Create(monthlySummary); + // Weekly records are kept — allows re-generation if new weekly data arrives. + // Cleanup can be done later by the automated scheduler or manually. + + Console.WriteLine($"[ReportAggregation] Monthly report created for installation {installationId}, {year}-{month:D2} ({weeklies.Count} weeks aggregated)."); + } + + // ── Year-End Aggregation ────────────────────────────────────────── + + private static async Task RunYearEndAggregation() + { + var previousYear = DateTime.Now.Year - 1; + + Console.WriteLine($"[ReportAggregation] Running year-end aggregation for {previousYear}..."); + + var installationIds = Db.MonthlyReports + .Where(r => r.Year == previousYear) + .Select(r => r.InstallationId) + .ToList() + .Distinct() + .ToList(); + + foreach (var installationId in installationIds) + { + try + { + await AggregateYearForInstallation(installationId, previousYear); + + // Scheduler mode: clean up monthly records after successful aggregation. + // The yearly report is now the source of truth for this year. + Db.DeleteMonthlyReportsForYear(installationId, previousYear); + Console.WriteLine($"[ReportAggregation] Cleaned up monthly records for installation {installationId}, {previousYear}."); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[ReportAggregation] Year aggregation failed for installation {installationId}: {ex.Message}"); + } + } + } + + private static async Task AggregateYearForInstallation(Int64 installationId, Int32 year, String language = "en") + { + var monthlies = Db.GetMonthlyReportsForYear(installationId, year); + if (monthlies.Count == 0) + return; + + // If yearly report already exists, delete it so we can re-generate with latest monthly data. + Db.DeleteYearlyReport(installationId, year); + + // Sum energy totals + var totalPv = Math.Round(monthlies.Sum(m => m.TotalPvProduction), 1); + var totalConsump = Math.Round(monthlies.Sum(m => m.TotalConsumption), 1); + var totalGridIn = Math.Round(monthlies.Sum(m => m.TotalGridImport), 1); + var totalGridOut = Math.Round(monthlies.Sum(m => m.TotalGridExport), 1); + var totalBattChg = Math.Round(monthlies.Sum(m => m.TotalBatteryCharged), 1); + var totalBattDis = Math.Round(monthlies.Sum(m => m.TotalBatteryDischarged), 1); + + // Re-derive ratios + var energySaved = Math.Round(totalConsump - totalGridIn, 1); + var savingsCHF = Math.Round(energySaved * ElectricityPriceCHF, 0); + var selfSufficiency = totalConsump > 0 ? Math.Round((totalConsump - totalGridIn) / totalConsump * 100, 1) : 0; + var selfConsumption = totalPv > 0 ? Math.Round((totalPv - totalGridOut) / totalPv * 100, 1) : 0; + var batteryEff = totalBattChg > 0 ? Math.Round(totalBattDis / totalBattChg * 100, 1) : 0; + var gridDependency = totalConsump > 0 ? Math.Round(totalGridIn / totalConsump * 100, 1) : 0; + + var avgPeakLoad = (Int32)Math.Round(monthlies.Average(m => m.AvgPeakLoadHour)); + var avgPeakSolar = (Int32)Math.Round(monthlies.Average(m => m.AvgPeakSolarHour)); + var avgWeekdayLoad = Math.Round(monthlies.Average(m => m.AvgWeekdayDailyLoad), 1); + var avgWeekendLoad = Math.Round(monthlies.Average(m => m.AvgWeekendDailyLoad), 1); + + var installation = Db.GetInstallationById(installationId); + var installationName = installation?.InstallationName ?? $"Installation {installationId}"; + + var aiInsight = await GenerateYearlyAiInsightAsync( + installationName, year, monthlies.Count, + totalPv, totalConsump, totalGridIn, totalGridOut, + totalBattChg, totalBattDis, energySaved, savingsCHF, + selfSufficiency, batteryEff, language); + + var yearlySummary = new YearlyReportSummary + { + InstallationId = installationId, + Year = year, + PeriodStart = monthlies.Min(m => m.PeriodStart), + PeriodEnd = monthlies.Max(m => m.PeriodEnd), + TotalPvProduction = totalPv, + TotalConsumption = totalConsump, + TotalGridImport = totalGridIn, + TotalGridExport = totalGridOut, + TotalBatteryCharged = totalBattChg, + TotalBatteryDischarged = totalBattDis, + TotalEnergySaved = energySaved, + TotalSavingsCHF = savingsCHF, + SelfSufficiencyPercent = selfSufficiency, + SelfConsumptionPercent = selfConsumption, + BatteryEfficiencyPercent = batteryEff, + GridDependencyPercent = gridDependency, + AvgPeakLoadHour = avgPeakLoad, + AvgPeakSolarHour = avgPeakSolar, + AvgWeekdayDailyLoad = avgWeekdayLoad, + AvgWeekendDailyLoad = avgWeekendLoad, + MonthCount = monthlies.Count, + AiInsight = aiInsight, + CreatedAt = DateTime.UtcNow.ToString("o"), + }; + + Db.Create(yearlySummary); + // Monthly records are kept — allows re-generation if new monthly data arrives. + + Console.WriteLine($"[ReportAggregation] Yearly report created for installation {installationId}, {year} ({monthlies.Count} months aggregated)."); + } + + // ── AI Insight Generation ───────────────────────────────────────── + + private static String GetLanguageName(String code) => code switch + { + "de" => "German", + "fr" => "French", + "it" => "Italian", + _ => "English" + }; + + private static async Task GenerateMonthlyAiInsightAsync( + String installationName, String monthName, Int32 weekCount, + Double totalPv, Double totalConsump, Double totalGridIn, Double totalGridOut, + Double totalBattChg, Double totalBattDis, + Double energySaved, Double savingsCHF, + Double selfSufficiency, Double batteryEff, + String language = "en") + { + var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY"); + if (String.IsNullOrWhiteSpace(apiKey)) + return "AI insight unavailable (API key not configured)."; + + var langName = GetLanguageName(language); + 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). + +MONTHLY FACTS for {monthName} ({weekCount} weeks): +- Total PV production: {totalPv:F1} kWh +- Total consumption: {totalConsump:F1} kWh +- Total grid import: {totalGridIn:F1} kWh, export: {totalGridOut:F1} kWh +- Battery: {totalBattChg:F1} kWh charged, {totalBattDis:F1} kWh discharged +- Energy saved from grid: {energySaved:F1} kWh = ~{savingsCHF:F0} CHF (at {ElectricityPriceCHF} CHF/kWh) +- Self-sufficiency: {selfSufficiency:F1}% +- Battery efficiency: {batteryEff:F1}% + +INSTRUCTIONS: +1. Monthly savings summary: total energy saved and money saved this month. Use the exact numbers provided. +2. Solar & battery performance: comment on PV production and battery utilization. +3. Grid dependency: note grid import relative to total consumption. +4. Recommendation for next month: one actionable suggestion. + +Rules: Write in {langName}. Write for a homeowner. 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."; + + return await CallMistralAsync(apiKey, prompt); + } + + private static async Task GenerateYearlyAiInsightAsync( + String installationName, Int32 year, Int32 monthCount, + Double totalPv, Double totalConsump, Double totalGridIn, Double totalGridOut, + Double totalBattChg, Double totalBattDis, + Double energySaved, Double savingsCHF, + Double selfSufficiency, Double batteryEff, + String language = "en") + { + var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY"); + if (String.IsNullOrWhiteSpace(apiKey)) + return "AI insight unavailable (API key not configured)."; + + var langName = GetLanguageName(language); + var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"". + +Write a concise annual performance summary in {langName} (4 bullet points, plain text, no markdown). + +ANNUAL FACTS for {year} ({monthCount} months of data): +- Total PV production: {totalPv:F1} kWh +- Total consumption: {totalConsump:F1} kWh +- Total grid import: {totalGridIn:F1} kWh, export: {totalGridOut:F1} kWh +- Battery: {totalBattChg:F1} kWh charged, {totalBattDis:F1} kWh discharged +- Energy saved from grid: {energySaved:F1} kWh = ~{savingsCHF:F0} CHF (at {ElectricityPriceCHF} CHF/kWh) +- Self-sufficiency: {selfSufficiency:F1}% +- Battery efficiency: {batteryEff:F1}% + +INSTRUCTIONS: +1. Annual savings highlight: total energy and money saved for the year. Use the exact numbers provided. +2. System performance: comment on PV production and battery health indicators. +3. Year-over-year readiness: note any trends or areas of improvement. +4. Looking ahead: one strategic recommendation for the coming year. + +Rules: Write in {langName}. Write for a homeowner. 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."; + + return await CallMistralAsync(apiKey, prompt); + } + + private static async Task CallMistralAsync(String apiKey, String prompt) + { + try + { + var requestBody = new + { + model = "mistral-small-latest", + messages = new[] { new { role = "user", content = prompt } }, + max_tokens = 400, + 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)) + return content.Trim(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[ReportAggregation] Mistral error: {ex.Message}"); + } + + return "AI insight could not be generated at this time."; + } +} diff --git a/csharp/App/Backend/Services/ReportEmailService.cs b/csharp/App/Backend/Services/ReportEmailService.cs index 230f7c365..a00a74f6a 100644 --- a/csharp/App/Backend/Services/ReportEmailService.cs +++ b/csharp/App/Backend/Services/ReportEmailService.cs @@ -443,4 +443,230 @@ public static class ReportEmailService private static string ChangeColor(double pct) => pct > 0 ? "#27ae60" : pct < 0 ? "#e74c3c" : "#888"; + + // ── Monthly / Yearly Report Emails ──────────────────────────────────── + + private static readonly string[] MonthNamesEn = { "", "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" }; + private static readonly string[] MonthNamesDe = { "", "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember" }; + private static readonly string[] MonthNamesFr = { "", "Janvier", "Février", "Mars", "Avril", "Mai", "Juin", "Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre" }; + private static readonly string[] MonthNamesIt = { "", "Gennaio", "Febbraio", "Marzo", "Aprile", "Maggio", "Giugno", "Luglio", "Agosto", "Settembre", "Ottobre", "Novembre", "Dicembre" }; + + public static async Task SendMonthlyReportEmailAsync( + MonthlyReportSummary report, + string installationName, + string recipientEmail, + string language = "en") + { + 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 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); + + await SendHtmlEmailAsync(subject, html, recipientEmail); + } + + public static async Task SendYearlyReportEmailAsync( + YearlyReportSummary report, + string installationName, + string recipientEmail, + string language = "en") + { + var s = GetAggregatedStrings(language, "yearly"); + var subject = $"{s.Title} — {installationName} ({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); + + await SendHtmlEmailAsync(subject, html, recipientEmail); + } + + private static async Task SendHtmlEmailAsync(string subject, string html, string recipientEmail) + { + 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 } + }; + + 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}"); + } + + // ── Aggregated report translation strings ───────────────────────────── + + private record AggregatedEmailStrings( + string Title, string Insights, string Summary, string SavingsHeader, + string Metric, string Total, string PvProduction, string Consumption, + string GridImport, string GridExport, string BatteryInOut, + string SolarEnergyUsed, string StayedAtHome, string EstMoneySaved, + string AtRate, string SolarCoverage, string FromSolar, + string BatteryEff, string OutVsIn, string CountLabel, string Footer + ); + + private static AggregatedEmailStrings GetAggregatedStrings(string language, string type) => (language, type) switch + { + ("de", "monthly") => new AggregatedEmailStrings( + "Monatlicher Leistungsbericht", "Monatliche Erkenntnisse", "Monatliche Zusammenfassung", "Ihre Ersparnisse diesen Monat", + "Kennzahl", "Gesamt", "PV-Produktion", "Verbrauch", "Netzbezug", "Netzeinspeisung", "Batterie Laden / Entladen", + "Energie gespart", "Solar + Batterie, nicht vom Netz", "Geschätzte Ersparnis", "bei 0.39 CHF/kWh", + "Eigenversorgung", "aus Solar + Batterie", "Batterie-Eff.", "Entladung vs. Ladung", + "Wochen aggregiert", "Erstellt von inesco Energy Monitor"), + ("de", "yearly") => new AggregatedEmailStrings( + "Jährlicher Leistungsbericht", "Jährliche Erkenntnisse", "Jährliche Zusammenfassung", "Ihre Ersparnisse dieses Jahr", + "Kennzahl", "Gesamt", "PV-Produktion", "Verbrauch", "Netzbezug", "Netzeinspeisung", "Batterie Laden / Entladen", + "Energie gespart", "Solar + Batterie, nicht vom Netz", "Geschätzte Ersparnis", "bei 0.39 CHF/kWh", + "Eigenversorgung", "aus Solar + Batterie", "Batterie-Eff.", "Entladung vs. Ladung", + "Monate aggregiert", "Erstellt von inesco Energy Monitor"), + ("fr", "monthly") => new AggregatedEmailStrings( + "Rapport de performance mensuel", "Aperçus du mois", "Résumé du mois", "Vos économies ce mois", + "Indicateur", "Total", "Production PV", "Consommation", "Import réseau", "Export réseau", "Batterie Charge / Décharge", + "Énergie économisée", "solaire + batterie, non achetée au réseau", "Économies estimées", "à 0.39 CHF/kWh", + "Autosuffisance", "du solaire + batterie", "Eff. batterie", "décharge vs charge", + "semaines agrégées", "Généré par inesco Energy Monitor"), + ("fr", "yearly") => new AggregatedEmailStrings( + "Rapport de performance annuel", "Aperçus de l'année", "Résumé de l'année", "Vos économies cette année", + "Indicateur", "Total", "Production PV", "Consommation", "Import réseau", "Export réseau", "Batterie Charge / Décharge", + "Énergie économisée", "solaire + batterie, non achetée au réseau", "Économies estimées", "à 0.39 CHF/kWh", + "Autosuffisance", "du solaire + batterie", "Eff. batterie", "décharge vs charge", + "mois agrégés", "Généré par inesco Energy Monitor"), + ("it", "monthly") => new AggregatedEmailStrings( + "Rapporto mensile delle prestazioni", "Approfondimenti mensili", "Riepilogo mensile", "I tuoi risparmi questo mese", + "Metrica", "Totale", "Produzione PV", "Consumo", "Import dalla rete", "Export nella rete", "Batteria Carica / Scarica", + "Energia risparmiata", "solare + batteria, non acquistata dalla rete", "Risparmio stimato", "a 0.39 CHF/kWh", + "Autosufficienza", "da solare + batteria", "Eff. batteria", "scarica vs carica", + "settimane aggregate", "Generato da inesco Energy Monitor"), + ("it", "yearly") => new AggregatedEmailStrings( + "Rapporto annuale delle prestazioni", "Approfondimenti annuali", "Riepilogo annuale", "I tuoi risparmi quest'anno", + "Metrica", "Totale", "Produzione PV", "Consumo", "Import dalla rete", "Export nella rete", "Batteria Carica / Scarica", + "Energia risparmiata", "solare + batteria, non acquistata dalla rete", "Risparmio stimato", "a 0.39 CHF/kWh", + "Autosufficienza", "da solare + batteria", "Eff. batteria", "scarica vs carica", + "mesi aggregati", "Generato da inesco Energy Monitor"), + (_, "monthly") => new AggregatedEmailStrings( + "Monthly Performance Report", "Monthly Insights", "Monthly Summary", "Your Savings This Month", + "Metric", "Total", "PV Production", "Consumption", "Grid Import", "Grid Export", "Battery Charge / Discharge", + "Energy Saved", "solar + battery, not bought from grid", "Est. Money Saved", "at 0.39 CHF/kWh", + "Self-Sufficiency", "from solar + battery", "Battery Eff.", "discharge vs charge", + "weeks aggregated", "Generated by inesco Energy Monitor"), + _ => new AggregatedEmailStrings( + "Annual Performance Report", "Annual Insights", "Annual Summary", "Your Savings This Year", + "Metric", "Total", "PV Production", "Consumption", "Grid Import", "Grid Export", "Battery Charge / Discharge", + "Energy Saved", "solar + battery, not bought from grid", "Est. Money Saved", "at 0.39 CHF/kWh", + "Self-Sufficiency", "from solar + battery", "Battery Eff.", "discharge vs charge", + "months aggregated", "Generated by inesco Energy Monitor") + }; + + // ── Aggregated HTML email template ──────────────────────────────────── + + private static string BuildAggregatedHtmlEmail( + string periodStart, string periodEnd, string installationName, + 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) + { + var insightLines = 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(aiInsight)}

"; + + return $@" + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
{s.Title}
+
{installationName}
+
{periodStart} — {periodEnd}
+
{countLabel}
+
+
{s.Insights}
+
+ {insightHtml} +
+
+
{s.Summary}
+ + + + + + + + + + +
{s.Metric}{s.Total}
{s.PvProduction}{pvProduction:F1} kWh
{s.Consumption}{consumption:F1} kWh
{s.GridImport}{gridImport:F1} kWh
{s.GridExport}{gridExport:F1} kWh
{s.BatteryInOut}{batteryCharged:F1} / {batteryDischarged:F1} kWh
+
+
{s.SavingsHeader}
+ + + {SavingsBox(s.SolarEnergyUsed, $"{energySaved:F1} kWh", s.StayedAtHome, "#27ae60")} + {SavingsBox(s.EstMoneySaved, $"~{savingsCHF:F0} CHF", s.AtRate, "#2980b9")} + {SavingsBox(s.SolarCoverage, $"{selfSufficiency:F0}%", s.FromSolar, "#8e44ad")} + {SavingsBox(s.BatteryEff, $"{batteryEfficiency:F0}%", s.OutVsIn, "#e67e22")} + +
+
+ {s.Footer} +
+
+ +"; + } } diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/WeeklyReport.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/WeeklyReport.tsx index de375f997..aeddfcbc2 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/WeeklyReport.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/WeeklyReport.tsx @@ -1,17 +1,25 @@ import { useEffect, useState } from 'react'; import { useIntl, FormattedMessage } from 'react-intl'; import { + Accordion, + AccordionDetails, + AccordionSummary, + Alert, Box, Button, CircularProgress, Container, Grid, + MenuItem, Paper, + Select, + Tab, + Tabs, TextField, - Typography, - Alert + Typography } from '@mui/material'; import SendIcon from '@mui/icons-material/Send'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import axiosConfig from 'src/Resources/axiosConfig'; interface WeeklyReportProps { @@ -57,40 +65,259 @@ interface WeeklyReportResponse { aiInsight: string; } +interface ReportSummary { + installationId: number; + periodStart: string; + periodEnd: string; + totalPvProduction: number; + totalConsumption: number; + totalGridImport: number; + totalGridExport: number; + totalBatteryCharged: number; + totalBatteryDischarged: number; + totalEnergySaved: number; + totalSavingsCHF: number; + selfSufficiencyPercent: number; + selfConsumptionPercent: number; + batteryEfficiencyPercent: number; + gridDependencyPercent: number; + aiInsight: string; +} + +interface MonthlyReport extends ReportSummary { + year: number; + month: number; + weekCount: number; + avgPeakLoadHour: number; + avgPeakSolarHour: number; + avgWeekdayDailyLoad: number; + avgWeekendDailyLoad: number; +} + +interface YearlyReport extends ReportSummary { + year: number; + monthCount: number; + avgPeakLoadHour: number; + avgPeakSolarHour: number; + avgWeekdayDailyLoad: number; + avgWeekendDailyLoad: number; +} + +interface PendingMonth { + year: number; + month: number; + weekCount: number; +} + +interface PendingYear { + year: number; + monthCount: number; +} + +interface WeeklyReportSummaryRecord { + id: number; + installationId: number; + periodStart: string; + periodEnd: string; + totalPvProduction: number; + totalConsumption: number; + totalGridImport: number; + totalGridExport: number; + totalBatteryCharged: number; + totalBatteryDischarged: number; + totalEnergySaved: number; + totalSavingsCHF: number; + selfSufficiencyPercent: number; + selfConsumptionPercent: number; + batteryEfficiencyPercent: number; + gridDependencyPercent: number; + peakLoadHour: number; + peakSolarHour: number; + weekdayAvgDailyLoad: number; + weekendAvgDailyLoad: number; + aiInsight: string; + createdAt: string; +} + // Matches: time ranges (14:00–18:00), times (09:00), decimals (126.4 / 1,3), integers (34) -// Any number in any language gets bolded — no unit matching needed const BOLD_PATTERN = /(\d{1,2}:\d{2}(?:[–\-]\d{1,2}:\d{2})?|\d+[.,]\d+|\d+)/g; const isBold = (s: string) => /\d/.test(s); -// Renders a bullet line: bolds the "Title" part before the first colon, and numbers with units function FormattedBullet({ text }: { text: string }) { const colonIdx = text.indexOf(':'); if (colonIdx > 0) { const title = text.slice(0, colonIdx); - const rest = text.slice(colonIdx + 1); // e.g. " This week, your system saved 120.9 kWh..." + const rest = text.slice(colonIdx + 1); const restParts = rest.split(BOLD_PATTERN).map((p, i) => isBold(p) ? {p} : {p} ); return <>{title}:{restParts}; } - // No colon — just bold figures const parts = text.split(BOLD_PATTERN).map((p, i) => isBold(p) ? {p} : {p} ); return <>{parts}; } +const MONTH_NAMES = ['', 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; + +// ── Email Bar (shared) ────────────────────────────────────────── + +function EmailBar({ onSend, disabled }: { onSend: (email: string) => Promise; disabled?: boolean }) { + const intl = useIntl(); + const [email, setEmail] = useState(''); + const [sending, setSending] = useState(false); + const [sendStatus, setSendStatus] = useState<{ message: string; severity: 'success' | 'error' } | null>(null); + + const handleSend = async () => { + if (!email.trim()) return; + setSending(true); + try { + await onSend(email.trim()); + setSendStatus({ message: intl.formatMessage({ id: 'reportSentTo' }, { email }), severity: 'success' }); + } catch { + setSendStatus({ message: intl.formatMessage({ id: 'reportSendError' }), severity: 'error' }); + } finally { + setSending(false); + } + }; + + return ( + + + { setEmail(e.target.value); setSendStatus(null); }} + sx={{ width: 280 }} + onKeyDown={(e) => e.key === 'Enter' && handleSend()} + /> + + + {sendStatus && ( + + {sendStatus.message} + + )} + + ); +} + +// ── Main Component ───────────────────────────────────────────── + function WeeklyReport({ installationId }: WeeklyReportProps) { + const intl = useIntl(); + const [activeTab, setActiveTab] = useState(0); + const [monthlyReports, setMonthlyReports] = useState([]); + const [yearlyReports, setYearlyReports] = useState([]); + const [pendingMonths, setPendingMonths] = useState([]); + const [pendingYears, setPendingYears] = useState([]); + const [generating, setGenerating] = useState(null); + + const fetchReportData = () => { + axiosConfig.get('/GetMonthlyReports', { params: { installationId } }) + .then(res => setMonthlyReports(res.data)) + .catch(() => {}); + axiosConfig.get('/GetYearlyReports', { params: { installationId } }) + .then(res => setYearlyReports(res.data)) + .catch(() => {}); + axiosConfig.get('/GetPendingMonthlyAggregations', { params: { installationId } }) + .then(res => setPendingMonths(res.data)) + .catch(() => {}); + axiosConfig.get('/GetPendingYearlyAggregations', { params: { installationId } }) + .then(res => setPendingYears(res.data)) + .catch(() => {}); + }; + + useEffect(() => { fetchReportData(); }, [installationId]); + + const handleGenerateMonthly = async (year: number, month: number) => { + setGenerating(`monthly-${year}-${month}`); + try { + await axiosConfig.post('/TriggerMonthlyAggregation', null, { + params: { installationId, year, month } + }); + fetchReportData(); + } catch (err) { + console.error('Monthly aggregation failed', err); + } finally { + setGenerating(null); + } + }; + + const handleGenerateYearly = async (year: number) => { + setGenerating(`yearly-${year}`); + try { + await axiosConfig.post('/TriggerYearlyAggregation', null, { + params: { installationId, year } + }); + fetchReportData(); + } catch (err) { + console.error('Yearly aggregation failed', err); + } finally { + setGenerating(null); + } + }; + + const tabs = [ + { 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' } + ]; + + const safeTab = Math.min(activeTab, tabs.length - 1); + + return ( + + setActiveTab(v)} + sx={{ mb: 2, '& .MuiTab-root': { fontWeight: 'bold' } }} + > + {tabs.map(t => )} + + + {tabs[safeTab]?.key === 'weekly' && ( + + )} + {tabs[safeTab]?.key === 'monthly' && ( + + )} + {tabs[safeTab]?.key === 'yearly' && ( + + )} + + ); +} + +// ── Weekly Section (existing weekly report content) ──────────── + +function WeeklySection({ installationId }: { installationId: number }) { const intl = useIntl(); 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(); @@ -115,19 +342,10 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { } }; - const handleSendEmail = async () => { - if (!email.trim()) return; - setSending(true); - try { - await axiosConfig.post('/SendWeeklyReportEmail', null, { - params: { installationId, emailAddress: email.trim() } - }); - setSendStatus({ message: intl.formatMessage({ id: 'reportSentTo' }, { email }), severity: 'success' }); - } catch (err: any) { - setSendStatus({ message: intl.formatMessage({ id: 'reportSendError' }), severity: 'error' }); - } finally { - setSending(false); - } + const handleSendEmail = async (emailAddress: string) => { + await axiosConfig.post('/SendWeeklyReportEmail', null, { + params: { installationId, emailAddress } + }); }; if (loading) { @@ -162,7 +380,6 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { const cur = report.currentWeek; const prev = report.previousWeek; - // Backend: currentWeek = last 7 days, previousWeek = everything before const currentWeekDayCount = Math.min(7, report.dailyData.length); const previousWeekDayCount = Math.max(1, report.dailyData.length - currentWeekDayCount); @@ -174,51 +391,23 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { 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); - // Read pre-computed values from backend — no arithmetic in the frontend const totalEnergySavedKwh = report.totalEnergySaved; const totalSavingsCHF = report.totalSavingsCHF; - // Find max value for daily bar chart scaling const maxDailyValue = Math.max( ...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 Insights (was AI Insights) */} + {/* Weekly Insights */} - - {insightBullets.length > 1 ? ( - - {insightBullets.map((bullet, i) => ( -
  • - ))} -
    - ) : ( - - - - )} -
    +
    {/* Your Savings This Week */} @@ -273,41 +443,14 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { - - - 0 ? `≈ ${report.daysEquivalent} ${intl.formatMessage({ id: 'daysOfYourUsage' })}` : undefined} - /> - - - - - - - - - - - + 0 ? `≈ ${report.daysEquivalent} ${intl.formatMessage({ id: 'daysOfYourUsage' })}` : undefined} + /> {/* Weekly Summary Table */} @@ -371,13 +514,12 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
    - {/* Daily Breakdown - CSS bar chart */} + {/* Daily Breakdown */} {report.dailyData.length > 0 && ( - {/* Legend */} @@ -389,7 +531,6 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { - {/* Bars */} {report.dailyData.map((d, i) => { const dt = new Date(d.date); const dayLabel = dt.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); @@ -406,32 +547,9 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { - 0 ? '2px' : 0, - transition: 'width 0.3s' - }} - /> - 0 ? '2px' : 0, - transition: 'width 0.3s' - }} - /> - 0 ? '2px' : 0, - transition: 'width 0.3s' - }} - /> + 0 ? '2px' : 0, transition: 'width 0.3s' }} /> + 0 ? '2px' : 0, transition: 'width 0.3s' }} /> + 0 ? '2px' : 0, transition: 'width 0.3s' }} /> ); @@ -439,10 +557,470 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { )} + {/* Weekly History for current month */} + + + ); +} + +// ── Weekly History (saved weekly reports for current month) ───── + +function WeeklyHistory({ installationId }: { installationId: number }) { + const intl = useIntl(); + const [records, setRecords] = useState([]); + + useEffect(() => { + const now = new Date(); + axiosConfig.get('/GetWeeklyReportSummaries', { + params: { installationId, year: now.getFullYear(), month: now.getMonth() + 1 } + }) + .then(res => setRecords(res.data)) + .catch(() => {}); + }, [installationId]); + + if (records.length === 0) return null; + + return ( + + + + + {records.map((rec) => ( + + } sx={{ bgcolor: '#f8f9fa', borderRadius: 1 }}> + + + {rec.periodStart} — {rec.periodEnd} + + + PV {rec.totalPvProduction.toFixed(1)} kWh | {intl.formatMessage({ id: 'consumption', defaultMessage: 'Consumption' })} {rec.totalConsumption.toFixed(1)} kWh + + + + + + + + {rec.totalEnergySaved.toFixed(1)} kWh + + + + + + ~{rec.totalSavingsCHF.toFixed(0)} CHF + + + + + + {rec.selfSufficiencyPercent.toFixed(0)}% + + + + + + {rec.batteryEfficiencyPercent.toFixed(0)}% + + + + + + + + + {rec.totalGridImport.toFixed(1)} kWh + + + {rec.totalGridExport.toFixed(1)} kWh + + + + {rec.totalBatteryCharged.toFixed(1)} / {rec.totalBatteryDischarged.toFixed(1)} kWh + + + + {rec.aiInsight && rec.aiInsight.length > 10 && ( + + {rec.aiInsight.split(/\n+/).filter(l => l.trim()).slice(0, 2).map((line, i) => ( + + {line.replace(/^[-*]\s*/, '').replace(/^[\d]+[.)]\s*/, '')} + + ))} + + )} + + + ))} + + ); +} + +// ── Monthly Section ───────────────────────────────────────────── + +function MonthlySection({ + installationId, + reports, + pendingMonths, + generating, + onGenerate +}: { + installationId: number; + reports: MonthlyReport[]; + pendingMonths: PendingMonth[]; + generating: string | null; + onGenerate: (year: number, month: number) => void; +}) { + const intl = useIntl(); + + return ( + <> + {/* Generate buttons for pending months */} + {pendingMonths.length > 0 && ( + + + + + + {pendingMonths.map(p => { + const key = `monthly-${p.year}-${p.month}`; + const isGenerating = generating === key; + return ( + + ); + })} + + + )} + + {/* Existing monthly reports */} + {reports.length > 0 ? ( + `${MONTH_NAMES[r.month]} ${r.year}`} + countLabelId="weeksAggregated" + countFn={(r: MonthlyReport) => r.weekCount} + sendEndpoint="/SendMonthlyReportEmail" + sendParamsFn={(r: MonthlyReport) => ({ installationId, year: r.year, month: r.month })} + /> + ) : pendingMonths.length === 0 ? ( + + + + ) : null} + + ); +} + +// ── Yearly Section ────────────────────────────────────────────── + +function YearlySection({ + installationId, + reports, + pendingYears, + generating, + onGenerate +}: { + installationId: number; + reports: YearlyReport[]; + pendingYears: PendingYear[]; + generating: string | null; + onGenerate: (year: number) => void; +}) { + const intl = useIntl(); + + return ( + <> + {/* Generate buttons for pending years */} + {pendingYears.length > 0 && ( + + + + + + {pendingYears.map(p => { + const key = `yearly-${p.year}`; + const isGenerating = generating === key; + return ( + + ); + })} + + + )} + + {/* Existing yearly reports */} + {reports.length > 0 ? ( + `${r.year}`} + countLabelId="monthsAggregated" + countFn={(r: YearlyReport) => r.monthCount} + sendEndpoint="/SendYearlyReportEmail" + sendParamsFn={(r: YearlyReport) => ({ installationId, year: r.year })} + /> + ) : pendingYears.length === 0 ? ( + + + + ) : null} + + ); +} + +// ── Aggregated Section (Monthly / Yearly) ────────────────────── + +function AggregatedSection({ + reports, + type, + labelFn, + countLabelId, + countFn, + sendEndpoint, + sendParamsFn +}: { + reports: T[]; + type: 'monthly' | 'yearly'; + labelFn: (r: T) => string; + countLabelId: string; + countFn: (r: T) => number; + sendEndpoint: string; + sendParamsFn: (r: T) => object; +}) { + const intl = useIntl(); + const [selectedIdx, setSelectedIdx] = useState(0); + + if (reports.length === 0) { + return ( + + + + ); + } + + const r = reports[selectedIdx]; + const insightsId = type === 'monthly' ? 'monthlyInsights' : 'yearlyInsights'; + const savingsId = type === 'monthly' ? 'monthlySavings' : 'yearlySavings'; + const summaryId = type === 'monthly' ? 'monthlySummary' : 'yearlySummary'; + const titleId = type === 'monthly' ? 'monthlyReportTitle' : 'yearlyReportTitle'; + + const insightBullets = r.aiInsight + .split(/\n+/) + .map((line) => line.replace(/^[\d]+[.)]\s*/, '').replace(/^[-*]\s*/, '').trim()) + .filter((line) => line.length > 0); + + const handleSendEmail = async (emailAddress: string) => { + await axiosConfig.post(sendEndpoint, null, { + params: { ...sendParamsFn(r), emailAddress } + }); + }; + + return ( + <> + {/* Period selector + Email bar */} + + {reports.length > 1 && ( + + )} + + + + + + {/* Header */} + + + + + + {r.periodStart} — {r.periodEnd} + + + + + + + {/* AI Insights */} + + + + + + + + {/* Savings Cards */} + + + + + + + + {/* Summary Table */} + + + + + + + + + + + + + + + {r.totalPvProduction.toFixed(1)} kWh + + + + {r.totalConsumption.toFixed(1)} kWh + + + + {r.totalGridImport.toFixed(1)} kWh + + + + {r.totalGridExport.toFixed(1)} kWh + + + + {r.totalBatteryCharged.toFixed(1)} / {r.totalBatteryDischarged.toFixed(1)} kWh + + + + + + ); +} + +// ── Shared Components ────────────────────────────────────────── + +function InsightBox({ text, bullets }: { text: string; bullets: string[] }) { + return ( + + {bullets.length > 1 ? ( + + {bullets.map((bullet, i) => ( +
  • + ))} +
    + ) : ( + + + + )}
    ); } +function SavingsCards({ intl, energySaved, savingsCHF, selfSufficiency, batteryEfficiency, hint }: { + intl: any; + energySaved: number; + savingsCHF: number; + selfSufficiency: number; + batteryEfficiency: number; + hint?: string; +}) { + return ( + + + + + + + + + + + + + + + ); +} + function SavingsCard({ label, value, subtitle, color, hint }: { label: string; value: string; subtitle: string; color: string; hint?: string }) { return ( Date: Mon, 2 Mar 2026 12:49:46 +0100 Subject: [PATCH 02/64] restructured data pipeline for report system and updated the way to create monthly and yearly report --- csharp/App/Backend/Controller.cs | 173 +++++++++-- .../App/Backend/DataTypes/AiInsightCache.cs | 29 ++ .../Backend/DataTypes/DailyEnergyRecord.cs | 31 ++ csharp/App/Backend/Database/Create.cs | 2 + csharp/App/Backend/Database/Db.cs | 5 + csharp/App/Backend/Database/Delete.cs | 53 ++++ csharp/App/Backend/Database/Read.cs | 37 +++ csharp/App/Backend/Program.cs | 3 +- .../Backend/Services/DailyIngestionService.cs | 137 ++++++++ .../Services/ReportAggregationService.cs | 292 +++++++++++++----- .../Backend/Services/ReportEmailService.cs | 6 +- .../Backend/Services/WeeklyReportService.cs | 184 ++++++++--- .../SodiohomeInstallations/WeeklyReport.tsx | 111 ++++--- 13 files changed, 886 insertions(+), 177 deletions(-) create mode 100644 csharp/App/Backend/DataTypes/AiInsightCache.cs create mode 100644 csharp/App/Backend/DataTypes/DailyEnergyRecord.cs create mode 100644 csharp/App/Backend/Services/DailyIngestionService.cs diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index 9ddc14614..b9278ccb6 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -901,7 +901,8 @@ public class Controller : ControllerBase /// Returns JSON with daily data, weekly totals, ratios, and AI insight. /// [HttpGet(nameof(GetWeeklyReport))] - public async Task> GetWeeklyReport(Int64 installationId, Token authToken, string? language = null) + public async Task> GetWeeklyReport( + Int64 installationId, Token authToken, String? language = null, String? weekStart = null) { var user = Db.GetSession(authToken)?.User; if (user == null) @@ -911,17 +912,23 @@ public class Controller : ControllerBase 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"); + // Parse optional weekStart override (must be a Monday; any valid date accepted for flexibility) + DateOnly? weekStartDate = null; + if (!String.IsNullOrEmpty(weekStart)) + { + if (!DateOnly.TryParseExact(weekStart, "yyyy-MM-dd", out var parsed)) + return BadRequest("weekStart must be in yyyy-MM-dd format."); + weekStartDate = parsed; + } try { var lang = language ?? user.Language ?? "en"; - var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.InstallationName, lang); + var report = await WeeklyReportService.GenerateReportAsync( + installationId, installation.InstallationName, lang, weekStartDate); - // Persist weekly summary for future monthly aggregation (idempotent) - ReportAggregationService.SaveWeeklySummary(installationId, report); + // Persist weekly summary and seed AiInsightCache for this language + ReportAggregationService.SaveWeeklySummary(installationId, report, lang); return Ok(report); } @@ -991,7 +998,8 @@ public class Controller : ControllerBase } [HttpGet(nameof(GetMonthlyReports))] - public ActionResult> GetMonthlyReports(Int64 installationId, Token authToken) + public async Task>> GetMonthlyReports( + Int64 installationId, Token authToken, String? language = null) { var user = Db.GetSession(authToken)?.User; if (user == null) @@ -1001,11 +1009,16 @@ public class Controller : ControllerBase if (installation is null || !user.HasAccessTo(installation)) return Unauthorized(); - return Ok(Db.GetMonthlyReports(installationId)); + var lang = language ?? user.Language ?? "en"; + var reports = Db.GetMonthlyReports(installationId); + foreach (var report in reports) + report.AiInsight = await ReportAggregationService.GetOrGenerateMonthlyInsightAsync(report, lang); + return Ok(reports); } [HttpGet(nameof(GetYearlyReports))] - public ActionResult> GetYearlyReports(Int64 installationId, Token authToken) + public async Task>> GetYearlyReports( + Int64 installationId, Token authToken, String? language = null) { var user = Db.GetSession(authToken)?.User; if (user == null) @@ -1015,12 +1028,16 @@ public class Controller : ControllerBase if (installation is null || !user.HasAccessTo(installation)) return Unauthorized(); - return Ok(Db.GetYearlyReports(installationId)); + var lang = language ?? user.Language ?? "en"; + var reports = Db.GetYearlyReports(installationId); + foreach (var report in reports) + report.AiInsight = await ReportAggregationService.GetOrGenerateYearlyInsightAsync(report, lang); + return Ok(reports); } /// /// Manually trigger monthly aggregation for an installation. - /// Aggregates weekly reports for the specified year/month into a monthly report. + /// Computes monthly report from daily records for the specified year/month. /// [HttpPost(nameof(TriggerMonthlyAggregation))] public async Task TriggerMonthlyAggregation(Int64 installationId, Int32 year, Int32 month, Token authToken) @@ -1039,11 +1056,11 @@ public class Controller : ControllerBase try { var lang = user.Language ?? "en"; - var weekCount = await ReportAggregationService.TriggerMonthlyAggregationAsync(installationId, year, month, lang); - if (weekCount == 0) - return NotFound($"No weekly reports found for {year}-{month:D2}."); + var dayCount = await ReportAggregationService.TriggerMonthlyAggregationAsync(installationId, year, month, lang); + if (dayCount == 0) + return NotFound($"No daily records found for {year}-{month:D2}."); - return Ok(new { message = $"Monthly report created from {weekCount} weekly reports for {year}-{month:D2}." }); + return Ok(new { message = $"Monthly report created from {dayCount} daily records for {year}-{month:D2}." }); } catch (Exception ex) { @@ -1083,6 +1100,119 @@ public class Controller : ControllerBase } } + /// + /// Manually trigger xlsx ingestion for all SodioHome installations. + /// Scans tmp_report/ for all matching xlsx files and ingests any new days. + /// + [HttpPost(nameof(IngestAllDailyData))] + public async Task IngestAllDailyData(Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user == null) + return Unauthorized(); + + try + { + await DailyIngestionService.IngestAllInstallationsAsync(); + return Ok(new { message = "Daily data ingestion triggered for all installations." }); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[IngestAllDailyData] Error: {ex.Message}"); + return BadRequest($"Failed to ingest: {ex.Message}"); + } + } + + /// + /// Manually trigger xlsx ingestion for one installation. + /// Parses tmp_report/{installationId}*.xlsx and stores any new days in DailyEnergyRecord. + /// + [HttpPost(nameof(IngestDailyData))] + public async Task IngestDailyData(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(); + + try + { + await DailyIngestionService.IngestInstallationAsync(installationId); + return Ok(new { message = $"Daily data ingestion triggered for installation {installationId}." }); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[IngestDailyData] Error: {ex.Message}"); + return BadRequest($"Failed to ingest: {ex.Message}"); + } + } + + // ── Debug / Inspection Endpoints ────────────────────────────────── + + /// + /// Returns the stored DailyEnergyRecord rows for an installation and date range. + /// Use this to verify that xlsx ingestion worked correctly before generating reports. + /// + [HttpGet(nameof(GetDailyRecords))] + public ActionResult> GetDailyRecords( + Int64 installationId, String from, String to, 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(); + + if (!DateOnly.TryParseExact(from, "yyyy-MM-dd", out var fromDate) || + !DateOnly.TryParseExact(to, "yyyy-MM-dd", out var toDate)) + return BadRequest("from and to must be in yyyy-MM-dd format."); + + var records = Db.GetDailyRecords(installationId, fromDate, toDate); + return Ok(new { count = records.Count, records }); + } + + /// + /// Deletes DailyEnergyRecord rows for an installation in the given date range. + /// Safe to use during testing — only removes daily records, not report summaries. + /// Allows re-ingesting the same xlsx files after correcting data. + /// + [HttpDelete(nameof(DeleteDailyRecords))] + public ActionResult DeleteDailyRecords( + Int64 installationId, String from, String to, 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(); + + if (!DateOnly.TryParseExact(from, "yyyy-MM-dd", out var fromDate) || + !DateOnly.TryParseExact(to, "yyyy-MM-dd", out var toDate)) + return BadRequest("from and to must be in yyyy-MM-dd format."); + + var fromStr = fromDate.ToString("yyyy-MM-dd"); + var toStr = toDate.ToString("yyyy-MM-dd"); + var toDelete = Db.DailyRecords + .Where(r => r.InstallationId == installationId) + .ToList() + .Where(r => String.Compare(r.Date, fromStr, StringComparison.Ordinal) >= 0 + && String.Compare(r.Date, toStr, StringComparison.Ordinal) <= 0) + .ToList(); + + foreach (var record in toDelete) + Db.DailyRecords.Delete(r => r.Id == record.Id); + + Console.WriteLine($"[DeleteDailyRecords] Deleted {toDelete.Count} records for installation {installationId} ({from}–{to})."); + return Ok(new { deleted = toDelete.Count, from, to }); + } + [HttpPost(nameof(SendMonthlyReportEmail))] public async Task SendMonthlyReportEmail(Int64 installationId, Int32 year, Int32 month, String emailAddress, Token authToken) { @@ -1101,6 +1231,7 @@ public class Controller : ControllerBase try { var lang = user.Language ?? "en"; + report.AiInsight = await ReportAggregationService.GetOrGenerateMonthlyInsightAsync(report, lang); await ReportEmailService.SendMonthlyReportEmailAsync(report, installation.InstallationName, emailAddress, lang); return Ok(new { message = $"Monthly report sent to {emailAddress}" }); } @@ -1129,6 +1260,7 @@ public class Controller : ControllerBase try { var lang = user.Language ?? "en"; + report.AiInsight = await ReportAggregationService.GetOrGenerateYearlyInsightAsync(report, lang); await ReportEmailService.SendYearlyReportEmailAsync(report, installation.InstallationName, emailAddress, lang); return Ok(new { message = $"Yearly report sent to {emailAddress}" }); } @@ -1140,7 +1272,8 @@ public class Controller : ControllerBase } [HttpGet(nameof(GetWeeklyReportSummaries))] - public ActionResult> GetWeeklyReportSummaries(Int64 installationId, Int32 year, Int32 month, Token authToken) + public async Task>> GetWeeklyReportSummaries( + Int64 installationId, Int32 year, Int32 month, Token authToken, String? language = null) { var user = Db.GetSession(authToken)?.User; if (user == null) @@ -1150,7 +1283,11 @@ public class Controller : ControllerBase if (installation is null || !user.HasAccessTo(installation)) return Unauthorized(); - return Ok(Db.GetWeeklyReportsForMonth(installationId, year, month)); + var lang = language ?? user.Language ?? "en"; + var summaries = Db.GetWeeklyReportsForMonth(installationId, year, month); + foreach (var s in summaries) + s.AiInsight = await ReportAggregationService.GetOrGenerateWeeklyInsightAsync(s, lang); + return Ok(summaries); } [HttpPut(nameof(UpdateFolder))] diff --git a/csharp/App/Backend/DataTypes/AiInsightCache.cs b/csharp/App/Backend/DataTypes/AiInsightCache.cs new file mode 100644 index 000000000..ea411e9d7 --- /dev/null +++ b/csharp/App/Backend/DataTypes/AiInsightCache.cs @@ -0,0 +1,29 @@ +using SQLite; + +namespace InnovEnergy.App.Backend.DataTypes; + +/// +/// Per-language AI insight cache for weekly, monthly, and yearly report summaries. +/// Keyed by (ReportType, ReportId, Language) — generated once on first request per language, +/// then reused for UI reads and email sends. Never store language-specific text in the +/// summary tables themselves; always go through this cache. +/// +public class AiInsightCache +{ + [PrimaryKey, AutoIncrement] + public Int64 Id { get; set; } + + /// "weekly" | "monthly" | "yearly" + [Indexed] + public String ReportType { get; set; } = ""; + + /// FK to WeeklyReportSummary.Id / MonthlyReportSummary.Id / YearlyReportSummary.Id + [Indexed] + public Int64 ReportId { get; set; } + + /// ISO 639-1 language code: "en" | "de" | "fr" | "it" + public String Language { get; set; } = "en"; + + public String InsightText { get; set; } = ""; + public String CreatedAt { get; set; } = ""; +} diff --git a/csharp/App/Backend/DataTypes/DailyEnergyRecord.cs b/csharp/App/Backend/DataTypes/DailyEnergyRecord.cs new file mode 100644 index 000000000..713f7c224 --- /dev/null +++ b/csharp/App/Backend/DataTypes/DailyEnergyRecord.cs @@ -0,0 +1,31 @@ +using SQLite; + +namespace InnovEnergy.App.Backend.DataTypes; + +/// +/// Raw daily energy totals for one installation and calendar day. +/// Source of truth for weekly and monthly report generation. +/// Populated by DailyIngestionService from xlsx (current) or S3 (future). +/// Retention: 1 year (cleaned up annually on Jan 2). +/// +public class DailyEnergyRecord +{ + [PrimaryKey, AutoIncrement] + public Int64 Id { get; set; } + + [Indexed] + public Int64 InstallationId { get; set; } + + // ISO date string: "YYYY-MM-DD" + public String Date { get; set; } = ""; + + // Energy totals (kWh) — cumulative for the full calendar day + public Double PvProduction { get; set; } + public Double LoadConsumption { get; set; } + public Double GridImport { get; set; } + public Double GridExport { get; set; } + public Double BatteryCharged { get; set; } + public Double BatteryDischarged { get; set; } + + public String CreatedAt { get; set; } = ""; +} diff --git a/csharp/App/Backend/Database/Create.cs b/csharp/App/Backend/Database/Create.cs index 04fe7d631..051fcfb31 100644 --- a/csharp/App/Backend/Database/Create.cs +++ b/csharp/App/Backend/Database/Create.cs @@ -71,6 +71,8 @@ public static partial class Db public static Boolean Create(WeeklyReportSummary report) => Insert(report); public static Boolean Create(MonthlyReportSummary report) => Insert(report); public static Boolean Create(YearlyReportSummary report) => Insert(report); + public static Boolean Create(DailyEnergyRecord record) => Insert(record); + public static Boolean Create(AiInsightCache cache) => Insert(cache); public static void HandleAction(UserAction newAction) { diff --git a/csharp/App/Backend/Database/Db.cs b/csharp/App/Backend/Database/Db.cs index 52c82b53e..3b1233219 100644 --- a/csharp/App/Backend/Database/Db.cs +++ b/csharp/App/Backend/Database/Db.cs @@ -28,6 +28,8 @@ public static partial class Db public static TableQuery WeeklyReports => Connection.Table(); public static TableQuery MonthlyReports => Connection.Table(); public static TableQuery YearlyReports => Connection.Table(); + public static TableQuery DailyRecords => Connection.Table(); + public static TableQuery AiInsightCaches => Connection.Table(); public static void Init() @@ -57,6 +59,8 @@ public static partial class Db Connection.CreateTable(); Connection.CreateTable(); Connection.CreateTable(); + Connection.CreateTable(); + Connection.CreateTable(); }); // One-time migration: normalize legacy long-form language values to ISO codes @@ -97,6 +101,7 @@ public static partial class Db fileConnection.CreateTable(); fileConnection.CreateTable(); fileConnection.CreateTable(); + fileConnection.CreateTable(); return fileConnection; //return CopyDbToMemory(fileConnection); diff --git a/csharp/App/Backend/Database/Delete.cs b/csharp/App/Backend/Database/Delete.cs index f39eb6dd0..9e613743f 100644 --- a/csharp/App/Backend/Database/Delete.cs +++ b/csharp/App/Backend/Database/Delete.cs @@ -180,4 +180,57 @@ public static partial class Db var count = YearlyReports.Delete(r => r.InstallationId == installationId && r.Year == year); if (count > 0) Backup(); } + + /// + /// Deletes all report records older than 1 year. Called annually on Jan 2 + /// after yearly reports are created. Uses fetch-then-delete for string-compared + /// date fields (SQLite-net doesn't support string comparisons in Delete lambdas). + /// + public static void CleanupOldData() + { + var cutoff = DateOnly.FromDateTime(DateTime.UtcNow).AddYears(-1).ToString("yyyy-MM-dd"); + var prevYear = DateTime.UtcNow.Year - 1; + + // Daily records older than 1 year + var oldDailyIds = DailyRecords + .ToList() + .Where(r => String.Compare(r.Date, cutoff, StringComparison.Ordinal) < 0) + .Select(r => r.Id) + .ToList(); + foreach (var id in oldDailyIds) + DailyRecords.Delete(r => r.Id == id); + + // Weekly summaries older than 1 year + var oldWeeklyIds = WeeklyReports + .ToList() + .Where(r => String.Compare(r.PeriodEnd, cutoff, StringComparison.Ordinal) < 0) + .Select(r => r.Id) + .ToList(); + foreach (var id in oldWeeklyIds) + WeeklyReports.Delete(r => r.Id == id); + + // Monthly summaries older than 1 year + var oldMonthlyIds = MonthlyReports + .ToList() + .Where(r => String.Compare(r.PeriodEnd, cutoff, StringComparison.Ordinal) < 0) + .Select(r => r.Id) + .ToList(); + foreach (var id in oldMonthlyIds) + MonthlyReports.Delete(r => r.Id == id); + + // Yearly summaries — keep current and previous year only + YearlyReports.Delete(r => r.Year < prevYear); + + // AI insight cache entries older than 1 year + var oldCacheIds = AiInsightCaches + .ToList() + .Where(c => String.Compare(c.CreatedAt, cutoff, StringComparison.Ordinal) < 0) + .Select(c => c.Id) + .ToList(); + foreach (var id in oldCacheIds) + AiInsightCaches.Delete(c => c.Id == id); + + Backup(); + Console.WriteLine($"[Db] Cleanup: {oldDailyIds.Count} daily, {oldWeeklyIds.Count} weekly, {oldMonthlyIds.Count} monthly, {oldCacheIds.Count} insight cache records deleted (cutoff {cutoff})."); + } } \ No newline at end of file diff --git a/csharp/App/Backend/Database/Read.cs b/csharp/App/Backend/Database/Read.cs index 68bfa2015..a9eb013c3 100644 --- a/csharp/App/Backend/Database/Read.cs +++ b/csharp/App/Backend/Database/Read.cs @@ -94,4 +94,41 @@ public static partial class Db .Where(r => r.InstallationId == installationId) .OrderByDescending(r => r.Year) .ToList(); + + // ── DailyEnergyRecord Queries ────────────────────────────────────── + + /// + /// Returns daily records for an installation within [from, to] inclusive, ordered by date. + /// + public static List GetDailyRecords(Int64 installationId, DateOnly from, DateOnly to) + { + var fromStr = from.ToString("yyyy-MM-dd"); + var toStr = to.ToString("yyyy-MM-dd"); + return DailyRecords + .Where(r => r.InstallationId == installationId) + .ToList() + .Where(r => String.Compare(r.Date, fromStr, StringComparison.Ordinal) >= 0 + && String.Compare(r.Date, toStr, StringComparison.Ordinal) <= 0) + .OrderBy(r => r.Date) + .ToList(); + } + + /// + /// Returns true if a daily record already exists for this installation+date (idempotency check). + /// + public static Boolean DailyRecordExists(Int64 installationId, String date) + => DailyRecords + .Any(r => r.InstallationId == installationId && r.Date == date); + + // ── AiInsightCache Queries ───────────────────────────────────────── + + /// + /// Returns the cached AI insight text for (reportType, reportId, language), or null on miss. + /// + public static String? GetCachedInsight(String reportType, Int64 reportId, String language) + => AiInsightCaches + .FirstOrDefault(c => c.ReportType == reportType + && c.ReportId == reportId + && c.Language == language) + ?.InsightText; } \ No newline at end of file diff --git a/csharp/App/Backend/Program.cs b/csharp/App/Backend/Program.cs index 73350b51c..499bbdeea 100644 --- a/csharp/App/Backend/Program.cs +++ b/csharp/App/Backend/Program.cs @@ -28,7 +28,8 @@ public static class Program LoadEnvFile(); DiagnosticService.Initialize(); AlarmReviewService.StartDailyScheduler(); - // ReportAggregationService.StartScheduler(); // TODO: uncomment to enable automatic monthly/yearly aggregation + // DailyIngestionService.StartScheduler(); // Phase 2: enable when S3 auto-push is ready + // ReportAggregationService.StartScheduler(); // Phase 2: enable when scheduler should auto-run monthly/yearly var builder = WebApplication.CreateBuilder(args); RabbitMqManager.InitializeEnvironment(); diff --git a/csharp/App/Backend/Services/DailyIngestionService.cs b/csharp/App/Backend/Services/DailyIngestionService.cs new file mode 100644 index 000000000..e8d0f8ae9 --- /dev/null +++ b/csharp/App/Backend/Services/DailyIngestionService.cs @@ -0,0 +1,137 @@ +using InnovEnergy.App.Backend.Database; +using InnovEnergy.App.Backend.DataTypes; + +namespace InnovEnergy.App.Backend.Services; + +/// +/// Ingests daily energy totals from xlsx files into the DailyEnergyRecord SQLite table. +/// This is the source-of-truth population step for the report pipeline. +/// +/// Current data source: xlsx files placed in tmp_report/{installationId}.xlsx +/// Future data source: S3 raw records (replace ExcelDataParser call with S3DailyExtractor) +/// +/// Runs automatically at 01:00 UTC daily. Can also be triggered manually via the +/// IngestDailyData API endpoint. +/// +public static class DailyIngestionService +{ + private static readonly String TmpReportDir = + Environment.CurrentDirectory + "/tmp_report/"; + + private static Timer? _dailyTimer; + + /// + /// Starts the daily scheduler. Call once on app startup. + /// Ingests xlsx data at 01:00 UTC every day. + /// + public static void StartScheduler() + { + var now = DateTime.UtcNow; + var next = now.Date.AddDays(1).AddHours(1); // 01:00 UTC tomorrow + + _dailyTimer = new Timer( + _ => + { + try + { + IngestAllInstallationsAsync().GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[DailyIngestion] Scheduler error: {ex.Message}"); + } + }, + null, next - now, TimeSpan.FromDays(1)); + + Console.WriteLine($"[DailyIngestion] Scheduler started. Next run: {next:yyyy-MM-dd HH:mm} UTC"); + } + + /// + /// Ingests xlsx data for all SodioHome installations. Safe to call manually. + /// + public static async Task IngestAllInstallationsAsync() + { + Console.WriteLine($"[DailyIngestion] Starting ingestion for all SodioHome installations..."); + + var installations = Db.Installations + .Where(i => i.Product == (Int32)ProductType.SodioHome) + .ToList(); + + foreach (var installation in installations) + { + try + { + await IngestInstallationAsync(installation.Id); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[DailyIngestion] Failed for installation {installation.Id}: {ex.Message}"); + } + } + + Console.WriteLine($"[DailyIngestion] Ingestion complete."); + } + + /// + /// Parses all xlsx files matching {installationId}*.xlsx in tmp_report/ and stores + /// any new days as DailyEnergyRecord rows. Supports multiple time-ranged files per + /// installation (e.g. 123_0203_0803.xlsx, 123_0901_1231.xlsx). Idempotent. + /// + public static async Task IngestInstallationAsync(Int64 installationId) + { + if (!Directory.Exists(TmpReportDir)) + { + Console.WriteLine($"[DailyIngestion] tmp_report directory not found, skipping."); + return; + } + + var xlsxFiles = Directory.GetFiles(TmpReportDir, $"{installationId}*.xlsx"); + if (xlsxFiles.Length == 0) + { + Console.WriteLine($"[DailyIngestion] No xlsx found for installation {installationId}, skipping."); + return; + } + + var newCount = 0; + var totalParsed = 0; + + foreach (var xlsxPath in xlsxFiles.OrderBy(f => f)) + { + List days; + try + { + days = ExcelDataParser.Parse(xlsxPath); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[DailyIngestion] Failed to parse {Path.GetFileName(xlsxPath)}: {ex.Message}"); + continue; + } + + totalParsed += days.Count; + + foreach (var day in days) + { + if (Db.DailyRecordExists(installationId, day.Date)) + continue; + + Db.Create(new DailyEnergyRecord + { + InstallationId = installationId, + Date = day.Date, + PvProduction = day.PvProduction, + LoadConsumption = day.LoadConsumption, + GridImport = day.GridImport, + GridExport = day.GridExport, + BatteryCharged = day.BatteryCharged, + BatteryDischarged = day.BatteryDischarged, + CreatedAt = DateTime.UtcNow.ToString("o"), + }); + newCount++; + } + } + + Console.WriteLine($"[DailyIngestion] Installation {installationId}: {newCount} new day(s) ingested ({totalParsed} total across {xlsxFiles.Length} file(s))."); + await Task.CompletedTask; + } +} diff --git a/csharp/App/Backend/Services/ReportAggregationService.cs b/csharp/App/Backend/Services/ReportAggregationService.cs index 9100bcab0..3dbc40e62 100644 --- a/csharp/App/Backend/Services/ReportAggregationService.cs +++ b/csharp/App/Backend/Services/ReportAggregationService.cs @@ -167,7 +167,7 @@ public static class ReportAggregationService // ── Save Weekly Summary ─────────────────────────────────────────── - public static void SaveWeeklySummary(Int64 installationId, WeeklyReportResponse report) + public static void SaveWeeklySummary(Int64 installationId, WeeklyReportResponse report, String language = "en") { try { @@ -211,6 +211,18 @@ public static class ReportAggregationService }; Db.Create(summary); + + // Seed AiInsightCache so historical reads for this language are free + if (!String.IsNullOrEmpty(summary.AiInsight)) + Db.Create(new AiInsightCache + { + ReportType = "weekly", + ReportId = summary.Id, + Language = language, + InsightText = summary.AiInsight, + CreatedAt = summary.CreatedAt, + }); + Console.WriteLine($"[ReportAggregation] Saved weekly summary for installation {installationId}, period {report.PeriodStart}–{report.PeriodEnd} (replaced {overlapping.Count} overlapping record(s))"); } catch (Exception ex) @@ -272,12 +284,14 @@ public static class ReportAggregationService /// public static async Task TriggerMonthlyAggregationAsync(Int64 installationId, Int32 year, Int32 month, String language = "en") { - var weeklies = Db.GetWeeklyReportsForMonth(installationId, year, month); - if (weeklies.Count == 0) + var first = new DateOnly(year, month, 1); + var last = first.AddMonths(1).AddDays(-1); + var days = Db.GetDailyRecords(installationId, first, last); + if (days.Count == 0) return 0; await AggregateMonthForInstallation(installationId, year, month, language); - return weeklies.Count; + return days.Count; } /// @@ -300,10 +314,15 @@ public static class ReportAggregationService var previousMonth = DateTime.Now.AddMonths(-1); var year = previousMonth.Year; var month = previousMonth.Month; + var first = new DateOnly(year, month, 1); + var last = first.AddMonths(1).AddDays(-1); Console.WriteLine($"[ReportAggregation] Running month-end aggregation for {year}-{month:D2}..."); - var installationIds = Db.WeeklyReports + // Find all installations that have daily records for the previous month + var installationIds = Db.DailyRecords + .Where(r => String.Compare(r.Date, first.ToString("yyyy-MM-dd"), StringComparison.Ordinal) >= 0 + && String.Compare(r.Date, last.ToString("yyyy-MM-dd"), StringComparison.Ordinal) <= 0) .Select(r => r.InstallationId) .ToList() .Distinct() @@ -314,11 +333,6 @@ public static class ReportAggregationService try { await AggregateMonthForInstallation(installationId, year, month); - - // Scheduler mode: clean up weekly records after successful aggregation. - // The monthly report is now the source of truth for this month. - Db.DeleteWeeklyReportsForMonth(installationId, year, month); - Console.WriteLine($"[ReportAggregation] Cleaned up weekly records for installation {installationId}, {year}-{month:D2}."); } catch (Exception ex) { @@ -329,23 +343,25 @@ public static class ReportAggregationService private static async Task AggregateMonthForInstallation(Int64 installationId, Int32 year, Int32 month, String language = "en") { - var weeklies = Db.GetWeeklyReportsForMonth(installationId, year, month); - if (weeklies.Count == 0) + // Compute from daily records for the full calendar month + var first = new DateOnly(year, month, 1); + var last = first.AddMonths(1).AddDays(-1); + var days = Db.GetDailyRecords(installationId, first, last); + if (days.Count == 0) return; - // If monthly report already exists, delete it so we can re-generate with latest weekly data. - // This supports partial months and re-aggregation when new weekly data arrives. + // If monthly report already exists, delete it so we can re-generate Db.DeleteMonthlyReport(installationId, year, month); - // Sum energy totals - var totalPv = Math.Round(weeklies.Sum(w => w.TotalPvProduction), 1); - var totalConsump = Math.Round(weeklies.Sum(w => w.TotalConsumption), 1); - var totalGridIn = Math.Round(weeklies.Sum(w => w.TotalGridImport), 1); - var totalGridOut = Math.Round(weeklies.Sum(w => w.TotalGridExport), 1); - var totalBattChg = Math.Round(weeklies.Sum(w => w.TotalBatteryCharged), 1); - var totalBattDis = Math.Round(weeklies.Sum(w => w.TotalBatteryDischarged), 1); + // Sum energy totals directly from daily records + var totalPv = Math.Round(days.Sum(d => d.PvProduction), 1); + var totalConsump = Math.Round(days.Sum(d => d.LoadConsumption), 1); + var totalGridIn = Math.Round(days.Sum(d => d.GridImport), 1); + var totalGridOut = Math.Round(days.Sum(d => d.GridExport), 1); + var totalBattChg = Math.Round(days.Sum(d => d.BatteryCharged), 1); + var totalBattDis = Math.Round(days.Sum(d => d.BatteryDischarged), 1); - // Re-derive ratios from aggregated totals + // Re-derive ratios var energySaved = Math.Round(totalConsump - totalGridIn, 1); var savingsCHF = Math.Round(energySaved * ElectricityPriceCHF, 0); var selfSufficiency = totalConsump > 0 ? Math.Round((totalConsump - totalGridIn) / totalConsump * 100, 1) : 0; @@ -353,56 +369,59 @@ public static class ReportAggregationService var batteryEff = totalBattChg > 0 ? Math.Round(totalBattDis / totalBattChg * 100, 1) : 0; var gridDependency = totalConsump > 0 ? Math.Round(totalGridIn / totalConsump * 100, 1) : 0; - // Average behavioral highlights - var avgPeakLoad = (Int32)Math.Round(weeklies.Average(w => w.PeakLoadHour)); - var avgPeakSolar = (Int32)Math.Round(weeklies.Average(w => w.PeakSolarHour)); - var avgWeekdayLoad = Math.Round(weeklies.Average(w => w.WeekdayAvgDailyLoad), 1); - var avgWeekendLoad = Math.Round(weeklies.Average(w => w.WeekendAvgDailyLoad), 1); - // Get installation name for AI insight var installation = Db.GetInstallationById(installationId); var installationName = installation?.InstallationName ?? $"Installation {installationId}"; var monthName = new DateTime(year, month, 1).ToString("MMMM yyyy"); var aiInsight = await GenerateMonthlyAiInsightAsync( - installationName, monthName, weeklies.Count, + installationName, monthName, days.Count, totalPv, totalConsump, totalGridIn, totalGridOut, totalBattChg, totalBattDis, energySaved, savingsCHF, selfSufficiency, batteryEff, language); var monthlySummary = new MonthlyReportSummary { - InstallationId = installationId, - Year = year, - Month = month, - PeriodStart = weeklies.Min(w => w.PeriodStart), - PeriodEnd = weeklies.Max(w => w.PeriodEnd), - TotalPvProduction = totalPv, - TotalConsumption = totalConsump, - TotalGridImport = totalGridIn, - TotalGridExport = totalGridOut, - TotalBatteryCharged = totalBattChg, - TotalBatteryDischarged = totalBattDis, - TotalEnergySaved = energySaved, - TotalSavingsCHF = savingsCHF, - SelfSufficiencyPercent = selfSufficiency, - SelfConsumptionPercent = selfConsumption, + InstallationId = installationId, + Year = year, + Month = month, + PeriodStart = first.ToString("yyyy-MM-dd"), + PeriodEnd = last.ToString("yyyy-MM-dd"), + TotalPvProduction = totalPv, + TotalConsumption = totalConsump, + TotalGridImport = totalGridIn, + TotalGridExport = totalGridOut, + TotalBatteryCharged = totalBattChg, + TotalBatteryDischarged = totalBattDis, + TotalEnergySaved = energySaved, + TotalSavingsCHF = savingsCHF, + SelfSufficiencyPercent = selfSufficiency, + SelfConsumptionPercent = selfConsumption, BatteryEfficiencyPercent = batteryEff, - GridDependencyPercent = gridDependency, - AvgPeakLoadHour = avgPeakLoad, - AvgPeakSolarHour = avgPeakSolar, - AvgWeekdayDailyLoad = avgWeekdayLoad, - AvgWeekendDailyLoad = avgWeekendLoad, - WeekCount = weeklies.Count, - AiInsight = aiInsight, - CreatedAt = DateTime.UtcNow.ToString("o"), + GridDependencyPercent = gridDependency, + AvgPeakLoadHour = 0, // Not available without hourly data; Phase 3 will add + AvgPeakSolarHour = 0, + AvgWeekdayDailyLoad = 0, + AvgWeekendDailyLoad = 0, + WeekCount = days.Count, // repurposed as day count + AiInsight = aiInsight, + CreatedAt = DateTime.UtcNow.ToString("o"), }; Db.Create(monthlySummary); - // Weekly records are kept — allows re-generation if new weekly data arrives. - // Cleanup can be done later by the automated scheduler or manually. - Console.WriteLine($"[ReportAggregation] Monthly report created for installation {installationId}, {year}-{month:D2} ({weeklies.Count} weeks aggregated)."); + // Seed AiInsightCache so the generating language is pre-cached + if (!String.IsNullOrEmpty(monthlySummary.AiInsight)) + Db.Create(new AiInsightCache + { + ReportType = "monthly", + ReportId = monthlySummary.Id, + Language = language, + InsightText = monthlySummary.AiInsight, + CreatedAt = monthlySummary.CreatedAt, + }); + + Console.WriteLine($"[ReportAggregation] Monthly report created for installation {installationId}, {year}-{month:D2} ({days.Count} days, {first}–{last})."); } // ── Year-End Aggregation ────────────────────────────────────────── @@ -425,17 +444,15 @@ public static class ReportAggregationService try { await AggregateYearForInstallation(installationId, previousYear); - - // Scheduler mode: clean up monthly records after successful aggregation. - // The yearly report is now the source of truth for this year. - Db.DeleteMonthlyReportsForYear(installationId, previousYear); - Console.WriteLine($"[ReportAggregation] Cleaned up monthly records for installation {installationId}, {previousYear}."); } catch (Exception ex) { Console.Error.WriteLine($"[ReportAggregation] Year aggregation failed for installation {installationId}: {ex.Message}"); } } + + // Time-based cleanup: delete records older than 1 year, runs after yearly report is created + CleanupOldRecords(); } private static async Task AggregateYearForInstallation(Int64 installationId, Int32 year, String language = "en") @@ -505,13 +522,140 @@ public static class ReportAggregationService }; Db.Create(yearlySummary); - // Monthly records are kept — allows re-generation if new monthly data arrives. + + // Seed AiInsightCache so the generating language is pre-cached + if (!String.IsNullOrEmpty(yearlySummary.AiInsight)) + Db.Create(new AiInsightCache + { + ReportType = "yearly", + ReportId = yearlySummary.Id, + Language = language, + InsightText = yearlySummary.AiInsight, + CreatedAt = yearlySummary.CreatedAt, + }); Console.WriteLine($"[ReportAggregation] Yearly report created for installation {installationId}, {year} ({monthlies.Count} months aggregated)."); } + // ── AI Insight Cache ────────────────────────────────────────────── + + /// + /// Returns cached AI insight for (reportType, reportId, language). + /// On cache miss: calls generate(), stores the result, and returns it. + /// This is the single entry-point for all per-language insight reads. + /// + public static async Task GetOrGenerateInsightAsync( + String reportType, Int64 reportId, String language, Func> generate) + { + var cached = Db.GetCachedInsight(reportType, reportId, language); + if (!String.IsNullOrEmpty(cached)) + return cached; + + var insight = await generate(); + Db.Create(new AiInsightCache + { + ReportType = reportType, + ReportId = reportId, + Language = language, + InsightText = insight, + CreatedAt = DateTime.UtcNow.ToString("o"), + }); + return insight; + } + + /// Cached-or-generated AI insight for a stored WeeklyReportSummary. + public static Task GetOrGenerateWeeklyInsightAsync( + WeeklyReportSummary report, String language) + { + var installationName = Db.GetInstallationById(report.InstallationId)?.InstallationName + ?? $"Installation {report.InstallationId}"; + return GetOrGenerateInsightAsync("weekly", report.Id, language, + () => GenerateWeeklySummaryAiInsightAsync(report, installationName, language)); + } + + /// Cached-or-generated AI insight for a stored MonthlyReportSummary. + public static Task GetOrGenerateMonthlyInsightAsync( + MonthlyReportSummary report, String language) + { + var installationName = Db.GetInstallationById(report.InstallationId)?.InstallationName + ?? $"Installation {report.InstallationId}"; + var monthName = new DateTime(report.Year, report.Month, 1).ToString("MMMM yyyy"); + return GetOrGenerateInsightAsync("monthly", report.Id, language, + () => GenerateMonthlyAiInsightAsync( + installationName, monthName, report.WeekCount, + report.TotalPvProduction, report.TotalConsumption, + report.TotalGridImport, report.TotalGridExport, + report.TotalBatteryCharged, report.TotalBatteryDischarged, + report.TotalEnergySaved, report.TotalSavingsCHF, + report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, language)); + } + + /// Cached-or-generated AI insight for a stored YearlyReportSummary. + public static Task GetOrGenerateYearlyInsightAsync( + YearlyReportSummary report, String language) + { + var installationName = Db.GetInstallationById(report.InstallationId)?.InstallationName + ?? $"Installation {report.InstallationId}"; + return GetOrGenerateInsightAsync("yearly", report.Id, language, + () => GenerateYearlyAiInsightAsync( + installationName, report.Year, report.MonthCount, + report.TotalPvProduction, report.TotalConsumption, + report.TotalGridImport, report.TotalGridExport, + report.TotalBatteryCharged, report.TotalBatteryDischarged, + report.TotalEnergySaved, report.TotalSavingsCHF, + report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, language)); + } + + // ── Time-Based Cleanup ──────────────────────────────────────────── + + /// + /// Deletes records older than 1 year. Called annually on Jan 2 after + /// yearly reports are created, so monthly summaries are still available + /// when yearly is computed. + /// + private static void CleanupOldRecords() + { + try + { + Db.CleanupOldData(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[ReportAggregation] Cleanup error: {ex.Message}"); + } + } + // ── AI Insight Generation ───────────────────────────────────────── + /// + /// Simplified weekly AI insight generated from the stored WeeklyReportSummary numerical fields. + /// Used for historical weeks where the original hourly data is no longer available. + /// + private static async Task GenerateWeeklySummaryAiInsightAsync( + WeeklyReportSummary summary, String installationName, String language = "en") + { + var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY"); + if (String.IsNullOrWhiteSpace(apiKey)) + return "AI insight unavailable (API key not configured)."; + + var langName = GetLanguageName(language); + var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"". + +Write a concise weekly performance summary in {langName} (4 bullet points starting with ""- ""). + +WEEKLY FACTS for {summary.PeriodStart} to {summary.PeriodEnd}: +- PV production: {summary.TotalPvProduction:F1} kWh | Consumption: {summary.TotalConsumption:F1} kWh +- Grid import: {summary.TotalGridImport:F1} kWh | Grid export: {summary.TotalGridExport:F1} kWh +- Battery: {summary.TotalBatteryCharged:F1} kWh charged, {summary.TotalBatteryDischarged:F1} kWh discharged +- Energy saved: {summary.TotalEnergySaved:F1} kWh = ~{summary.TotalSavingsCHF:F0} CHF +- Self-sufficiency: {summary.SelfSufficiencyPercent:F1}% | Grid dependency: {summary.GridDependencyPercent:F1}% + +Rules: Write in {langName}. Write for a homeowner. No asterisks or formatting marks. +Exactly 4 bullet points. Each starts with ""- Title: description"" format."; + + return await CallMistralAsync(apiKey, prompt); + } + private static String GetLanguageName(String code) => code switch { "de" => "German", @@ -533,26 +677,28 @@ public static class ReportAggregationService return "AI insight unavailable (API key not configured)."; var langName = GetLanguageName(language); + + // Determine which metric is weakest so the tip can be targeted + var weakMetric = batteryEff < 80 ? "battery" : selfSufficiency < 40 ? "self-sufficiency" : "general"; + 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). -MONTHLY FACTS for {monthName} ({weekCount} weeks): -- Total PV production: {totalPv:F1} kWh +MONTHLY FACTS for {monthName} ({weekCount} days of data): +- PV production: {totalPv:F1} kWh - Total consumption: {totalConsump:F1} kWh -- Total grid import: {totalGridIn:F1} kWh, export: {totalGridOut:F1} kWh -- Battery: {totalBattChg:F1} kWh charged, {totalBattDis:F1} kWh discharged -- Energy saved from grid: {energySaved:F1} kWh = ~{savingsCHF:F0} CHF (at {ElectricityPriceCHF} CHF/kWh) -- Self-sufficiency: {selfSufficiency:F1}% -- Battery efficiency: {batteryEff:F1}% +- Self-sufficiency: {selfSufficiency:F1}% (powered by solar + battery, not 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) INSTRUCTIONS: -1. Monthly savings summary: total energy saved and money saved this month. Use the exact numbers provided. -2. Solar & battery performance: comment on PV production and battery utilization. -3. Grid dependency: note grid import relative to total consumption. -4. Recommendation for next month: one actionable suggestion. +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. ""X% of your home ran on solar + battery""). 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. -Rules: Write in {langName}. Write for a homeowner. No asterisks or formatting marks. No closing remarks. Exactly 4 bullet points starting with ""- "". Each bullet must have a short title followed by colon then description."; +Rules: Write in {langName}. Write for a homeowner, not a technician. No asterisks or formatting marks. No closing remarks. Exactly 4 bullet points starting with ""- "". Each bullet must have a short title followed by colon then description."; return await CallMistralAsync(apiKey, prompt); } diff --git a/csharp/App/Backend/Services/ReportEmailService.cs b/csharp/App/Backend/Services/ReportEmailService.cs index a00a74f6a..1dbec7ec4 100644 --- a/csharp/App/Backend/Services/ReportEmailService.cs +++ b/csharp/App/Backend/Services/ReportEmailService.cs @@ -217,10 +217,10 @@ public static class ReportEmailService var cur = r.CurrentWeek; var prev = r.PreviousWeek; - // Parse AI insight into
  • bullet points (split on newlines, strip leading "- " or "1. ") + // Parse AI insight into
  • bullet points (split on newlines, strip leading "- " or "1. ", strip ** markdown) var insightLines = r.AiInsight .Split('\n', StringSplitOptions.RemoveEmptyEntries) - .Select(l => System.Text.RegularExpressions.Regex.Replace(l.Trim(), @"^[\d]+[.)]\s*|^[-*]\s*", "")) + .Select(l => System.Text.RegularExpressions.Regex.Replace(l.Trim(), @"^[\d]+[.)]\s*|^[-*]\s*", "").Replace("**", "")) .Where(l => l.Length > 0) .ToList(); @@ -584,7 +584,7 @@ public static class ReportEmailService { var insightLines = aiInsight .Split('\n', StringSplitOptions.RemoveEmptyEntries) - .Select(l => System.Text.RegularExpressions.Regex.Replace(l.Trim(), @"^[\d]+[.)]\s*|^[-*]\s*", "")) + .Select(l => System.Text.RegularExpressions.Regex.Replace(l.Trim(), @"^[\d]+[.)]\s*|^[-*]\s*", "").Replace("**", "")) .Where(l => l.Length > 0) .ToList(); diff --git a/csharp/App/Backend/Services/WeeklyReportService.cs b/csharp/App/Backend/Services/WeeklyReportService.cs index 9c9e4a3a9..28a1d2833 100644 --- a/csharp/App/Backend/Services/WeeklyReportService.cs +++ b/csharp/App/Backend/Services/WeeklyReportService.cs @@ -1,4 +1,5 @@ using Flurl.Http; +using InnovEnergy.App.Backend.Database; using InnovEnergy.App.Backend.DataTypes; using Newtonsoft.Json; @@ -8,22 +9,73 @@ public static class WeeklyReportService { private static readonly string TmpReportDir = Environment.CurrentDirectory + "/tmp_report/"; + // ── Calendar Week Helpers ────────────────────────────────────────── + + /// + /// Returns the last completed calendar week (Mon–Sun). + /// Example: called on Wednesday 2026-03-11 → returns (2026-03-02, 2026-03-08). + /// + public static (DateOnly Monday, DateOnly Sunday) LastCalendarWeek() + { + var today = DateOnly.FromDateTime(DateTime.UtcNow); + // DayOfWeek: Sun=0, Mon=1, ..., Sat=6 + // daysSinceMonday: Mon=0, Tue=1, ..., Sun=6 + var daysSinceMonday = ((Int32)today.DayOfWeek + 6) % 7; + var thisMonday = today.AddDays(-daysSinceMonday); + var lastMonday = thisMonday.AddDays(-7); + var lastSunday = thisMonday.AddDays(-1); + return (lastMonday, lastSunday); + } + + /// + /// Returns the calendar week before last (for comparison). + /// + private static (DateOnly Monday, DateOnly Sunday) PreviousCalendarWeek() + { + var (lastMon, _) = LastCalendarWeek(); + return (lastMon.AddDays(-7), lastMon.AddDays(-1)); + } + + // ── Report Generation ────────────────────────────────────────────── + /// /// Generates a full weekly report for the given installation. - /// Cache is invalidated automatically when the xlsx file is newer than the cache. - /// To force regeneration (e.g. after a prompt change), simply delete the cache files. + /// Data source priority: + /// 1. DailyEnergyRecord rows in SQLite (populated by DailyIngestionService) + /// 2. xlsx file fallback (if DB not yet populated for the target week) + /// Cache is keyed to the calendar week — invalidated when the week changes. + /// Pass weekStartOverride to generate a report for a specific historical week (disables cache). /// - public static async Task GenerateReportAsync(long installationId, string installationName, string language = "en") + public static async Task GenerateReportAsync( + long installationId, string installationName, string language = "en", + DateOnly? weekStartOverride = null) { - var xlsxPath = TmpReportDir + installationId + ".xlsx"; - var cachePath = TmpReportDir + $"{installationId}_{language}.cache.json"; + DateOnly curMon, curSun, prevMon, prevSun; - // Use cached report if xlsx hasn't changed since cache was written - if (File.Exists(cachePath) && File.Exists(xlsxPath)) + if (weekStartOverride.HasValue) { - var xlsxModified = File.GetLastWriteTimeUtc(xlsxPath); - var cacheModified = File.GetLastWriteTimeUtc(cachePath); - if (cacheModified > xlsxModified) + // Debug/backfill mode: use the provided Monday as the week start + curMon = weekStartOverride.Value; + curSun = curMon.AddDays(6); + prevMon = curMon.AddDays(-7); + prevSun = curMon.AddDays(-1); + } + else + { + (curMon, curSun) = LastCalendarWeek(); + (prevMon, prevSun) = PreviousCalendarWeek(); + } + + // Skip file cache when a specific week is requested (avoid stale or wrong-week hits) + var cachePath = weekStartOverride.HasValue + ? null + : TmpReportDir + $"{installationId}_{language}_{curMon:yyyy-MM-dd}.cache.json"; + + // Use cache if it exists and is less than 6 hours old (skipped in override mode) + if (cachePath != null && File.Exists(cachePath)) + { + var cacheAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(cachePath); + if (cacheAge.TotalHours < 6) { try { @@ -31,7 +83,7 @@ public static class WeeklyReportService await File.ReadAllTextAsync(cachePath)); if (cached != null) { - Console.WriteLine($"[WeeklyReportService] Returning cached report for installation {installationId} ({language})."); + Console.WriteLine($"[WeeklyReportService] Returning cached report for installation {installationId} ({language}), week {curMon:yyyy-MM-dd}."); return cached; } } @@ -42,54 +94,94 @@ public static class WeeklyReportService } } - // Parse both daily summaries and hourly intervals from the same xlsx - var allDays = ExcelDataParser.Parse(xlsxPath); - var allHourly = ExcelDataParser.ParseHourly(xlsxPath); - var report = await GenerateReportFromDataAsync(allDays, allHourly, installationName, language); + // 1. Try to load daily records from SQLite for the calendar weeks + var currentWeekDays = Db.GetDailyRecords(installationId, curMon, curSun) + .Select(r => ToDailyEnergyData(r)).ToList(); + var previousWeekDays = Db.GetDailyRecords(installationId, prevMon, prevSun) + .Select(r => ToDailyEnergyData(r)).ToList(); - // Write cache - try + // 2. Fallback: if DB empty for current week, parse all xlsx files on the fly + var xlsxFiles = Directory.Exists(TmpReportDir) + ? Directory.GetFiles(TmpReportDir, $"{installationId}*.xlsx").OrderBy(f => f).ToList() + : new List(); + + if (currentWeekDays.Count == 0 && xlsxFiles.Count > 0) { - await File.WriteAllTextAsync(cachePath, JsonConvert.SerializeObject(report)); + Console.WriteLine($"[WeeklyReportService] DB empty for week {curMon:yyyy-MM-dd}, falling back to xlsx."); + var allDaysParsed = xlsxFiles.SelectMany(p => ExcelDataParser.Parse(p)).ToList(); + currentWeekDays = allDaysParsed + .Where(d => { var date = DateOnly.Parse(d.Date); return date >= curMon && date <= curSun; }) + .ToList(); + previousWeekDays = allDaysParsed + .Where(d => { var date = DateOnly.Parse(d.Date); return date >= prevMon && date <= prevSun; }) + .ToList(); } - catch (Exception ex) + + if (currentWeekDays.Count == 0) + throw new InvalidOperationException( + $"No energy data available for week {curMon:yyyy-MM-dd}–{curSun:yyyy-MM-dd}. " + + "Upload an xlsx file or wait for daily ingestion."); + + // 3. Load hourly data from ALL xlsx files for behavioral analysis (current week only). + // Combine all files so any week can find its hourly data regardless of file split. + // Future: replace with S3 hourly fetch. + var allHourly = xlsxFiles + .SelectMany(p => { try { return ExcelDataParser.ParseHourly(p); } catch { return Enumerable.Empty(); } }) + .ToList(); + var curMonDt = curMon.ToDateTime(TimeOnly.MinValue); + var curSunDt = curSun.ToDateTime(TimeOnly.MaxValue); + var currentHourlyData = allHourly + .Where(h => h.DateTime >= curMonDt && h.DateTime <= curSunDt) + .ToList(); + + var report = await GenerateReportFromDataAsync( + currentWeekDays, previousWeekDays, currentHourlyData, installationName, language, + curMon, curSun); + + // Write cache (skipped in override mode) + if (cachePath != null) { - Console.Error.WriteLine($"[WeeklyReportService] Could not write cache: {ex.Message}"); + try + { + await File.WriteAllTextAsync(cachePath, JsonConvert.SerializeObject(report)); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[WeeklyReportService] Could not write cache: {ex.Message}"); + } } return report; } + // ── Conversion helpers ───────────────────────────────────────────── + + private static DailyEnergyData ToDailyEnergyData(DailyEnergyRecord r) => new() + { + Date = r.Date, + PvProduction = r.PvProduction, + LoadConsumption = r.LoadConsumption, + GridImport = r.GridImport, + GridExport = r.GridExport, + BatteryCharged = r.BatteryCharged, + BatteryDischarged = r.BatteryDischarged, + }; + /// - /// Core report generation. Accepts both daily summaries and hourly intervals. + /// Core report generation. Accepts pre-split current/previous week data and hourly intervals. + /// weekStart/weekEnd are used to set PeriodStart/PeriodEnd on the response. /// public static async Task GenerateReportFromDataAsync( - List allDays, - List allHourly, + List currentWeekDays, + List previousWeekDays, + List currentHourlyData, string installationName, - string language = "en") + string language = "en", + DateOnly? weekStart = null, + DateOnly? weekEnd = null) { - // Sort by date - allDays = allDays.OrderBy(d => d.Date).ToList(); - - // Split into previous week and current week (daily) - 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; - } - - // Restrict hourly data to current week only for behavioral analysis - var currentWeekStart = DateTime.Parse(currentWeekDays.First().Date); - var currentHourlyData = allHourly.Where(h => h.DateTime.Date >= currentWeekStart.Date).ToList(); + currentWeekDays = currentWeekDays.OrderBy(d => d.Date).ToList(); + previousWeekDays = previousWeekDays.OrderBy(d => d.Date).ToList(); var currentSummary = Summarize(currentWeekDays); var previousSummary = previousWeekDays.Count > 0 ? Summarize(previousWeekDays) : null; @@ -149,7 +241,7 @@ public static class WeeklyReportService PvChangePercent = pvChange, ConsumptionChangePercent = consumptionChange, GridImportChangePercent = gridImportChange, - DailyData = allDays, + DailyData = currentWeekDays, Behavior = behavior, AiInsight = aiInsight, }; diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/WeeklyReport.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/WeeklyReport.tsx index aeddfcbc2..b166364ea 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/WeeklyReport.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/WeeklyReport.tsx @@ -224,10 +224,11 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { const [generating, setGenerating] = useState(null); const fetchReportData = () => { - axiosConfig.get('/GetMonthlyReports', { params: { installationId } }) + const lang = intl.locale; + axiosConfig.get('/GetMonthlyReports', { params: { installationId, language: lang } }) .then(res => setMonthlyReports(res.data)) .catch(() => {}); - axiosConfig.get('/GetYearlyReports', { params: { installationId } }) + axiosConfig.get('/GetYearlyReports', { params: { installationId, language: lang } }) .then(res => setYearlyReports(res.data)) .catch(() => {}); axiosConfig.get('/GetPendingMonthlyAggregations', { params: { installationId } }) @@ -238,7 +239,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { .catch(() => {}); }; - useEffect(() => { fetchReportData(); }, [installationId]); + useEffect(() => { fetchReportData(); }, [installationId, intl.locale]); const handleGenerateMonthly = async (year: number, month: number) => { setGenerating(`monthly-${year}-${month}`); @@ -277,7 +278,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { const safeTab = Math.min(activeTab, tabs.length - 1); return ( - + setActiveTab(v)} @@ -286,10 +287,17 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { {tabs.map(t => )} - {tabs[safeTab]?.key === 'weekly' && ( - - )} - {tabs[safeTab]?.key === 'monthly' && ( + + 0 + ? monthlyReports.reduce((a, b) => a.periodEnd > b.periodEnd ? a : b).periodEnd + : null + } + /> + + - )} - {tabs[safeTab]?.key === 'yearly' && ( + + - )} + ); } // ── Weekly Section (existing weekly report content) ──────────── -function WeeklySection({ installationId }: { installationId: number }) { +function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installationId: number; latestMonthlyPeriodEnd: string | null }) { const intl = useIntl(); const [report, setReport] = useState(null); const [loading, setLoading] = useState(true); @@ -391,9 +399,13 @@ function WeeklySection({ installationId }: { installationId: number }) { return effective > 0 ? '#27ae60' : effective < 0 ? '#e74c3c' : '#888'; }; + // Compute change % from two raw values; returns 0 (shown as —) if prev is 0 + const calcChange = (curVal: number, prevVal: number) => + prevVal === 0 ? 0 : ((curVal - prevVal) / prevVal) * 100; + const insightBullets = report.aiInsight .split(/\n+/) - .map((line) => line.replace(/^[\d]+[.)]\s*/, '').replace(/^[-*]\s*/, '').trim()) + .map((line) => line.replace(/\*\*/g, '').replace(/^[\d]+[.)]\s*/, '').replace(/^[-*]\s*/, '').trim()) .filter((line) => line.length > 0); const totalEnergySavedKwh = report.totalEnergySaved; @@ -502,13 +514,13 @@ function WeeklySection({ installationId }: { installationId: number }) { {cur.totalGridExport.toFixed(1)} kWh {prev && {prev.totalGridExport.toFixed(1)} kWh} - {prev && —} + {prev && {formatChange(calcChange(cur.totalGridExport, prev.totalGridExport))}} {cur.totalBatteryCharged.toFixed(1)} / {cur.totalBatteryDischarged.toFixed(1)} kWh {prev && {prev.totalBatteryCharged.toFixed(1)} / {prev.totalBatteryDischarged.toFixed(1)} kWh} - {prev && —} + {prev && {formatChange(calcChange(cur.totalBatteryCharged, prev.totalBatteryCharged))}} @@ -557,33 +569,69 @@ function WeeklySection({ installationId }: { installationId: number }) { )} - {/* Weekly History for current month */} - + {/* Weekly History — weeks not yet captured in a monthly report, excluding the current week shown above */} + ); } // ── Weekly History (saved weekly reports for current month) ───── -function WeeklyHistory({ installationId }: { installationId: number }) { +function WeeklyHistory({ installationId, latestMonthlyPeriodEnd, currentReportPeriodEnd }: { + installationId: number; + latestMonthlyPeriodEnd: string | null; + currentReportPeriodEnd: string | null; +}) { const intl = useIntl(); const [records, setRecords] = useState([]); useEffect(() => { - const now = new Date(); - axiosConfig.get('/GetWeeklyReportSummaries', { - params: { installationId, year: now.getFullYear(), month: now.getMonth() + 1 } - }) - .then(res => setRecords(res.data)) - .catch(() => {}); - }, [installationId]); + const now = new Date(); + const year = now.getFullYear(); + const month = now.getMonth() + 1; + const lang = intl.locale; + + // Fetch current month and previous month to cover cross-month weeks + const prevMonth = month === 1 ? 12 : month - 1; + const prevYear = month === 1 ? year - 1 : year; + + Promise.all([ + axiosConfig.get('/GetWeeklyReportSummaries', { params: { installationId, year, month, language: lang } }) + .then(r => r.data as WeeklyReportSummaryRecord[]).catch(() => [] as WeeklyReportSummaryRecord[]), + axiosConfig.get('/GetWeeklyReportSummaries', { params: { installationId, year: prevYear, month: prevMonth, language: lang } }) + .then(r => r.data as WeeklyReportSummaryRecord[]).catch(() => [] as WeeklyReportSummaryRecord[]) + ]).then(([cur, prev]) => { + // Combine, deduplicate by id, sort descending by periodEnd + const all = [...cur, ...prev]; + const unique = Array.from(new Map(all.map(r => [r.id, r])).values()) + .sort((a, b) => b.periodEnd.localeCompare(a.periodEnd)); + + // Exclude the current week already shown as the main report above + const withoutCurrent = currentReportPeriodEnd + ? unique.filter(r => r.periodEnd !== currentReportPeriodEnd) + : unique; + + // Keep only weeks not yet covered by a monthly report + const uncovered = latestMonthlyPeriodEnd + ? withoutCurrent.filter(r => r.periodEnd > latestMonthlyPeriodEnd) + : withoutCurrent; + + // Always show at least the latest non-current week even if all are covered + const toShow = uncovered.length > 0 ? uncovered : withoutCurrent.slice(0, 1); + setRecords(toShow); + }); + }, [installationId, latestMonthlyPeriodEnd, currentReportPeriodEnd, intl.locale]); if (records.length === 0) return null; return ( - + {records.map((rec) => ( @@ -639,15 +687,6 @@ function WeeklyHistory({ installationId }: { installationId: number }) { - {rec.aiInsight && rec.aiInsight.length > 10 && ( - - {rec.aiInsight.split(/\n+/).filter(l => l.trim()).slice(0, 2).map((line, i) => ( - - {line.replace(/^[-*]\s*/, '').replace(/^[\d]+[.)]\s*/, '')} - - ))} - - )} ))} @@ -842,7 +881,7 @@ function AggregatedSection({ const insightBullets = r.aiInsight .split(/\n+/) - .map((line) => line.replace(/^[\d]+[.)]\s*/, '').replace(/^[-*]\s*/, '').trim()) + .map((line) => line.replace(/\*\*/g, '').replace(/^[\d]+[.)]\s*/, '').replace(/^[-*]\s*/, '').trim()) .filter((line) => line.length > 0); const handleSendEmail = async (emailAddress: string) => { From 35b64c33186df2877d86fe5538c838b1c61a0719 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Mon, 2 Mar 2026 18:52:36 +0100 Subject: [PATCH 03/64] store hourly energy records in SQLite and remove file cache --- .../Backend/DataTypes/HourlyEnergyRecord.cs | 37 ++++++ csharp/App/Backend/Database/Create.cs | 1 + csharp/App/Backend/Database/Db.cs | 4 + csharp/App/Backend/Database/Delete.cs | 12 +- csharp/App/Backend/Database/Read.cs | 25 ++++ .../Backend/Services/DailyIngestionService.cs | 49 +++++++- .../Services/ReportAggregationService.cs | 4 +- .../Backend/Services/WeeklyReportService.cs | 110 ++++++------------ 8 files changed, 162 insertions(+), 80 deletions(-) create mode 100644 csharp/App/Backend/DataTypes/HourlyEnergyRecord.cs diff --git a/csharp/App/Backend/DataTypes/HourlyEnergyRecord.cs b/csharp/App/Backend/DataTypes/HourlyEnergyRecord.cs new file mode 100644 index 000000000..47899212e --- /dev/null +++ b/csharp/App/Backend/DataTypes/HourlyEnergyRecord.cs @@ -0,0 +1,37 @@ +using SQLite; + +namespace InnovEnergy.App.Backend.DataTypes; + +public class HourlyEnergyRecord +{ + [PrimaryKey, AutoIncrement] + public Int64 Id { get; set; } + + [Indexed] + public Int64 InstallationId { get; set; } + + // "YYYY-MM-DD" — used for range queries (same pattern as DailyEnergyRecord) + [Indexed] + public String Date { get; set; } = ""; + + // 0–23 + public Int32 Hour { get; set; } + + // "YYYY-MM-DD HH" — used for idempotency check + public String DateHour { get; set; } = ""; + + public String DayOfWeek { get; set; } = ""; + public Boolean IsWeekend { get; set; } + + // Energy for this hour (kWh) + public Double PvKwh { get; set; } + public Double LoadKwh { get; set; } + public Double GridImportKwh { get; set; } + public Double BatteryChargedKwh { get; set; } + public Double BatteryDischargedKwh { get; set; } + + // Instantaneous state of charge at snapshot time (%) + public Double BattSoC { get; set; } + + public String CreatedAt { get; set; } = ""; +} diff --git a/csharp/App/Backend/Database/Create.cs b/csharp/App/Backend/Database/Create.cs index 051fcfb31..98fee3b32 100644 --- a/csharp/App/Backend/Database/Create.cs +++ b/csharp/App/Backend/Database/Create.cs @@ -72,6 +72,7 @@ public static partial class Db public static Boolean Create(MonthlyReportSummary report) => Insert(report); public static Boolean Create(YearlyReportSummary report) => Insert(report); public static Boolean Create(DailyEnergyRecord record) => Insert(record); + public static Boolean Create(HourlyEnergyRecord record) => Insert(record); public static Boolean Create(AiInsightCache cache) => Insert(cache); public static void HandleAction(UserAction newAction) diff --git a/csharp/App/Backend/Database/Db.cs b/csharp/App/Backend/Database/Db.cs index 3b1233219..69c61b50c 100644 --- a/csharp/App/Backend/Database/Db.cs +++ b/csharp/App/Backend/Database/Db.cs @@ -29,6 +29,7 @@ public static partial class Db public static TableQuery MonthlyReports => Connection.Table(); public static TableQuery YearlyReports => Connection.Table(); public static TableQuery DailyRecords => Connection.Table(); + public static TableQuery HourlyRecords => Connection.Table(); public static TableQuery AiInsightCaches => Connection.Table(); @@ -60,6 +61,7 @@ public static partial class Db Connection.CreateTable(); Connection.CreateTable(); Connection.CreateTable(); + Connection.CreateTable(); Connection.CreateTable(); }); @@ -102,6 +104,8 @@ public static partial class Db fileConnection.CreateTable(); fileConnection.CreateTable(); fileConnection.CreateTable(); + fileConnection.CreateTable(); + fileConnection.CreateTable(); return fileConnection; //return CopyDbToMemory(fileConnection); diff --git a/csharp/App/Backend/Database/Delete.cs b/csharp/App/Backend/Database/Delete.cs index 9e613743f..438fa995c 100644 --- a/csharp/App/Backend/Database/Delete.cs +++ b/csharp/App/Backend/Database/Delete.cs @@ -200,6 +200,16 @@ public static partial class Db foreach (var id in oldDailyIds) DailyRecords.Delete(r => r.Id == id); + // Hourly records older than 3 months (sufficient for pattern detection, 24x more rows than daily) + var hourlyCutoff = DateOnly.FromDateTime(DateTime.UtcNow).AddMonths(-3).ToString("yyyy-MM-dd"); + var oldHourlyIds = HourlyRecords + .ToList() + .Where(r => String.Compare(r.Date, hourlyCutoff, StringComparison.Ordinal) < 0) + .Select(r => r.Id) + .ToList(); + foreach (var id in oldHourlyIds) + HourlyRecords.Delete(r => r.Id == id); + // Weekly summaries older than 1 year var oldWeeklyIds = WeeklyReports .ToList() @@ -231,6 +241,6 @@ public static partial class Db AiInsightCaches.Delete(c => c.Id == id); Backup(); - Console.WriteLine($"[Db] Cleanup: {oldDailyIds.Count} daily, {oldWeeklyIds.Count} weekly, {oldMonthlyIds.Count} monthly, {oldCacheIds.Count} insight cache records deleted (cutoff {cutoff})."); + Console.WriteLine($"[Db] Cleanup: {oldDailyIds.Count} daily, {oldHourlyIds.Count} hourly, {oldWeeklyIds.Count} weekly, {oldMonthlyIds.Count} monthly, {oldCacheIds.Count} insight cache records deleted (cutoff {cutoff})."); } } \ No newline at end of file diff --git a/csharp/App/Backend/Database/Read.cs b/csharp/App/Backend/Database/Read.cs index a9eb013c3..0dccf3f78 100644 --- a/csharp/App/Backend/Database/Read.cs +++ b/csharp/App/Backend/Database/Read.cs @@ -120,6 +120,31 @@ public static partial class Db => DailyRecords .Any(r => r.InstallationId == installationId && r.Date == date); + // ── HourlyEnergyRecord Queries ───────────────────────────────────── + + /// + /// Returns hourly records for an installation within [from, to] inclusive, ordered by date+hour. + /// + public static List GetHourlyRecords(Int64 installationId, DateOnly from, DateOnly to) + { + var fromStr = from.ToString("yyyy-MM-dd"); + var toStr = to.ToString("yyyy-MM-dd"); + return HourlyRecords + .Where(r => r.InstallationId == installationId) + .ToList() + .Where(r => String.Compare(r.Date, fromStr, StringComparison.Ordinal) >= 0 + && String.Compare(r.Date, toStr, StringComparison.Ordinal) <= 0) + .OrderBy(r => r.Date).ThenBy(r => r.Hour) + .ToList(); + } + + /// + /// Returns true if an hourly record already exists for this installation+dateHour (idempotency check). + /// + public static Boolean HourlyRecordExists(Int64 installationId, String dateHour) + => HourlyRecords + .Any(r => r.InstallationId == installationId && r.DateHour == dateHour); + // ── AiInsightCache Queries ───────────────────────────────────────── /// diff --git a/csharp/App/Backend/Services/DailyIngestionService.cs b/csharp/App/Backend/Services/DailyIngestionService.cs index e8d0f8ae9..b7e5a3798 100644 --- a/csharp/App/Backend/Services/DailyIngestionService.cs +++ b/csharp/App/Backend/Services/DailyIngestionService.cs @@ -92,11 +92,13 @@ public static class DailyIngestionService return; } - var newCount = 0; - var totalParsed = 0; + var newDailyCount = 0; + var newHourlyCount = 0; + var totalParsed = 0; foreach (var xlsxPath in xlsxFiles.OrderBy(f => f)) { + // Ingest daily records List days; try { @@ -104,7 +106,7 @@ public static class DailyIngestionService } catch (Exception ex) { - Console.Error.WriteLine($"[DailyIngestion] Failed to parse {Path.GetFileName(xlsxPath)}: {ex.Message}"); + Console.Error.WriteLine($"[DailyIngestion] Failed to parse daily {Path.GetFileName(xlsxPath)}: {ex.Message}"); continue; } @@ -127,11 +129,48 @@ public static class DailyIngestionService BatteryDischarged = day.BatteryDischarged, CreatedAt = DateTime.UtcNow.ToString("o"), }); - newCount++; + newDailyCount++; + } + + // Ingest hourly records + List hours; + try + { + hours = ExcelDataParser.ParseHourly(xlsxPath); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[DailyIngestion] Failed to parse hourly {Path.GetFileName(xlsxPath)}: {ex.Message}"); + continue; + } + + foreach (var hour in hours) + { + var dateHour = $"{hour.DateTime:yyyy-MM-dd HH}"; + if (Db.HourlyRecordExists(installationId, dateHour)) + continue; + + Db.Create(new HourlyEnergyRecord + { + InstallationId = installationId, + Date = hour.DateTime.ToString("yyyy-MM-dd"), + Hour = hour.Hour, + DateHour = dateHour, + DayOfWeek = hour.DayOfWeek, + IsWeekend = hour.IsWeekend, + PvKwh = hour.PvKwh, + LoadKwh = hour.LoadKwh, + GridImportKwh = hour.GridImportKwh, + BatteryChargedKwh = hour.BatteryChargedKwh, + BatteryDischargedKwh = hour.BatteryDischargedKwh, + BattSoC = hour.BattSoC, + CreatedAt = DateTime.UtcNow.ToString("o"), + }); + newHourlyCount++; } } - Console.WriteLine($"[DailyIngestion] Installation {installationId}: {newCount} new day(s) ingested ({totalParsed} total across {xlsxFiles.Length} file(s))."); + Console.WriteLine($"[DailyIngestion] Installation {installationId}: {newDailyCount} new day(s), {newHourlyCount} new hour(s) ingested ({totalParsed} days across {xlsxFiles.Length} file(s))."); await Task.CompletedTask; } } diff --git a/csharp/App/Backend/Services/ReportAggregationService.cs b/csharp/App/Backend/Services/ReportAggregationService.cs index 3dbc40e62..d94ce7206 100644 --- a/csharp/App/Backend/Services/ReportAggregationService.cs +++ b/csharp/App/Backend/Services/ReportAggregationService.cs @@ -688,13 +688,13 @@ Write a concise monthly performance summary in {langName} (4 bullet points, plai MONTHLY FACTS for {monthName} ({weekCount} days of data): - PV production: {totalPv:F1} kWh - Total consumption: {totalConsump:F1} kWh -- Self-sufficiency: {selfSufficiency:F1}% (powered by solar + battery, not grid) +- Self-sufficiency: {selfSufficiency:F1}% (share of energy covered by solar, without drawing from grid) - Battery: {totalBattChg:F1} kWh charged, {totalBattDis:F1} kWh discharged, efficiency {batteryEff:F1}% - Energy saved: {energySaved:F1} kWh = ~{savingsCHF:F0} CHF saved (at {ElectricityPriceCHF} CHF/kWh) 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. ""X% of your home ran on solar + battery""). Do NOT mention raw grid import kWh. +2. Solar performance: how much the solar system produced and what self-sufficiency % means for the homeowner (e.g. ""Your solar system covered X% of your home's energy needs""). Do NOT mention battery here. Do NOT mention raw grid import kWh. 3. Battery: comment on battery utilization and efficiency. If efficiency < 80%, note it may need attention. 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. diff --git a/csharp/App/Backend/Services/WeeklyReportService.cs b/csharp/App/Backend/Services/WeeklyReportService.cs index 28a1d2833..0557987cf 100644 --- a/csharp/App/Backend/Services/WeeklyReportService.cs +++ b/csharp/App/Backend/Services/WeeklyReportService.cs @@ -66,55 +66,30 @@ public static class WeeklyReportService (prevMon, prevSun) = PreviousCalendarWeek(); } - // Skip file cache when a specific week is requested (avoid stale or wrong-week hits) - var cachePath = weekStartOverride.HasValue - ? null - : TmpReportDir + $"{installationId}_{language}_{curMon:yyyy-MM-dd}.cache.json"; - - // Use cache if it exists and is less than 6 hours old (skipped in override mode) - if (cachePath != null && File.Exists(cachePath)) - { - var cacheAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(cachePath); - if (cacheAge.TotalHours < 6) - { - try - { - var cached = JsonConvert.DeserializeObject( - await File.ReadAllTextAsync(cachePath)); - if (cached != null) - { - Console.WriteLine($"[WeeklyReportService] Returning cached report for installation {installationId} ({language}), week {curMon:yyyy-MM-dd}."); - return cached; - } - } - catch - { - // Cache corrupt — regenerate - } - } - } - - // 1. Try to load daily records from SQLite for the calendar weeks + // 1. Load daily records from SQLite var currentWeekDays = Db.GetDailyRecords(installationId, curMon, curSun) - .Select(r => ToDailyEnergyData(r)).ToList(); + .Select(ToDailyEnergyData).ToList(); var previousWeekDays = Db.GetDailyRecords(installationId, prevMon, prevSun) - .Select(r => ToDailyEnergyData(r)).ToList(); + .Select(ToDailyEnergyData).ToList(); - // 2. Fallback: if DB empty for current week, parse all xlsx files on the fly - var xlsxFiles = Directory.Exists(TmpReportDir) - ? Directory.GetFiles(TmpReportDir, $"{installationId}*.xlsx").OrderBy(f => f).ToList() - : new List(); - - if (currentWeekDays.Count == 0 && xlsxFiles.Count > 0) + // 2. Fallback: if DB empty, parse xlsx on the fly (pre-ingestion scenario) + if (currentWeekDays.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(); - currentWeekDays = allDaysParsed - .Where(d => { var date = DateOnly.Parse(d.Date); return date >= curMon && date <= curSun; }) - .ToList(); - previousWeekDays = allDaysParsed - .Where(d => { var date = DateOnly.Parse(d.Date); return date >= prevMon && date <= prevSun; }) - .ToList(); + var xlsxFiles = Directory.Exists(TmpReportDir) + ? Directory.GetFiles(TmpReportDir, $"{installationId}*.xlsx").OrderBy(f => f).ToList() + : new List(); + + if (xlsxFiles.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(); + currentWeekDays = allDaysParsed + .Where(d => { var date = DateOnly.Parse(d.Date); return date >= curMon && date <= curSun; }) + .ToList(); + previousWeekDays = allDaysParsed + .Where(d => { var date = DateOnly.Parse(d.Date); return date >= prevMon && date <= prevSun; }) + .ToList(); + } } if (currentWeekDays.Count == 0) @@ -122,36 +97,13 @@ public static class WeeklyReportService $"No energy data available for week {curMon:yyyy-MM-dd}–{curSun:yyyy-MM-dd}. " + "Upload an xlsx file or wait for daily ingestion."); - // 3. Load hourly data from ALL xlsx files for behavioral analysis (current week only). - // Combine all files so any week can find its hourly data regardless of file split. - // Future: replace with S3 hourly fetch. - var allHourly = xlsxFiles - .SelectMany(p => { try { return ExcelDataParser.ParseHourly(p); } catch { return Enumerable.Empty(); } }) - .ToList(); - var curMonDt = curMon.ToDateTime(TimeOnly.MinValue); - var curSunDt = curSun.ToDateTime(TimeOnly.MaxValue); - var currentHourlyData = allHourly - .Where(h => h.DateTime >= curMonDt && h.DateTime <= curSunDt) - .ToList(); + // 3. Load hourly records from SQLite for behavioral analysis + var currentHourlyData = Db.GetHourlyRecords(installationId, curMon, curSun) + .Select(ToHourlyEnergyData).ToList(); - var report = await GenerateReportFromDataAsync( + return await GenerateReportFromDataAsync( currentWeekDays, previousWeekDays, currentHourlyData, installationName, language, curMon, curSun); - - // Write cache (skipped in override mode) - if (cachePath != null) - { - try - { - await File.WriteAllTextAsync(cachePath, JsonConvert.SerializeObject(report)); - } - catch (Exception ex) - { - Console.Error.WriteLine($"[WeeklyReportService] Could not write cache: {ex.Message}"); - } - } - - return report; } // ── Conversion helpers ───────────────────────────────────────────── @@ -167,6 +119,20 @@ public static class WeeklyReportService BatteryDischarged = r.BatteryDischarged, }; + private static HourlyEnergyData ToHourlyEnergyData(HourlyEnergyRecord r) => new() + { + DateTime = DateTime.ParseExact(r.DateHour, "yyyy-MM-dd HH", null), + Hour = r.Hour, + DayOfWeek = r.DayOfWeek, + IsWeekend = r.IsWeekend, + PvKwh = r.PvKwh, + LoadKwh = r.LoadKwh, + GridImportKwh = r.GridImportKwh, + BatteryChargedKwh = r.BatteryChargedKwh, + BatteryDischargedKwh = r.BatteryDischargedKwh, + BattSoC = r.BattSoC, + }; + /// /// Core report generation. Accepts pre-split current/previous week data and hourly intervals. /// weekStart/weekEnd are used to set PeriodStart/PeriodEnd on the response. From 25b961dc930fa3c0105ac66fe45082b973a347a6 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Wed, 4 Mar 2026 09:37:01 +0100 Subject: [PATCH 04/64] fixed folder name and SodistoreMax name --- csharp/App/Backend/Database/Db.cs | 4 ++++ csharp/App/Backend/Websockets/RabbitMQManager.cs | 4 ++-- .../frontend-marios2/src/components/LogoSign/index.tsx | 2 +- .../src/content/dashboards/Tree/Information.tsx | 6 +++++- .../src/content/pages/Status/Status500/index.tsx | 2 +- .../src/layouts/SidebarLayout/Sidebar/SidebarMenu/index.tsx | 2 +- 6 files changed, 14 insertions(+), 6 deletions(-) diff --git a/csharp/App/Backend/Database/Db.cs b/csharp/App/Backend/Database/Db.cs index 69c61b50c..05048f8c3 100644 --- a/csharp/App/Backend/Database/Db.cs +++ b/csharp/App/Backend/Database/Db.cs @@ -71,6 +71,10 @@ public static partial class Db Connection.Execute("UPDATE User SET Language = 'fr' WHERE Language = 'french'"); Connection.Execute("UPDATE User SET Language = 'it' WHERE Language = 'italian'"); + // One-time migration: rebrand folder names + Connection.Execute("UPDATE Folder SET Name = 'inesco Energy' WHERE Name = 'InnovEnergy'"); + Connection.Execute("UPDATE Folder SET Name = 'Sodistore Max Installations' WHERE Name = 'SodistoreMax Installations'"); + //UpdateKeys(); CleanupSessions().SupressAwaitWarning(); DeleteSnapshots().SupressAwaitWarning(); diff --git a/csharp/App/Backend/Websockets/RabbitMQManager.cs b/csharp/App/Backend/Websockets/RabbitMQManager.cs index 48d63f116..417a04e86 100644 --- a/csharp/App/Backend/Websockets/RabbitMQManager.cs +++ b/csharp/App/Backend/Websockets/RabbitMQManager.cs @@ -126,7 +126,7 @@ public static class RabbitMqManager Console.WriteLine("Send replace battery email to the support team for installation "+installationId); string recipient = "support@innov.energy"; string subject = $"Battery Alarm from {installation.InstallationName}: 2 or more strings broken"; - string text = $"Dear InnovEnergy Support Team,\n" + + string text = $"Dear inesco Energy Support Team,\n" + $"\n"+ $"Installation Name: {installation.InstallationName}\n"+ $"\n"+ @@ -138,7 +138,7 @@ public static class RabbitMqManager $"\n"+ $"Thank you for your great support:)"; //Disable this function now - //Mailer.Send("InnovEnergy Support Team", recipient, subject, text); + //Mailer.Send("inesco Energy Support Team", recipient, subject, text); } //Create a new error and add it to the database Db.HandleError(newError, installationId); diff --git a/typescript/frontend-marios2/src/components/LogoSign/index.tsx b/typescript/frontend-marios2/src/components/LogoSign/index.tsx index 1a2f6da1d..a23c98200 100644 --- a/typescript/frontend-marios2/src/components/LogoSign/index.tsx +++ b/typescript/frontend-marios2/src/components/LogoSign/index.tsx @@ -98,7 +98,7 @@ function Logo() { const theme = useTheme(); return ( - + = { + 'SodistoreHome': 'Sodistore Home', + 'SodistoreMax': 'Sodistore Max' + }; const isMobile = window.innerWidth <= 1490; @@ -282,7 +286,7 @@ function TreeInformation(props: TreeInformationProps) { > {ProductTypes.map((type) => ( - {type} + {ProductDisplayNames[type] || type} ))} diff --git a/typescript/frontend-marios2/src/content/pages/Status/Status500/index.tsx b/typescript/frontend-marios2/src/content/pages/Status/Status500/index.tsx index 79efb079e..492156a9f 100644 --- a/typescript/frontend-marios2/src/content/pages/Status/Status500/index.tsx +++ b/typescript/frontend-marios2/src/content/pages/Status/Status500/index.tsx @@ -115,7 +115,7 @@ function Status500() { - InnovEnergy{' '} + inesco Energy{' '} From 79f695f9b4813c98f42afcbd6218e9a01c0db969 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Wed, 4 Mar 2026 09:45:15 +0100 Subject: [PATCH 05/64] check master admin account name from innov to inesco --- csharp/App/Backend/Database/Db.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/csharp/App/Backend/Database/Db.cs b/csharp/App/Backend/Database/Db.cs index 05048f8c3..f062ea95f 100644 --- a/csharp/App/Backend/Database/Db.cs +++ b/csharp/App/Backend/Database/Db.cs @@ -71,9 +71,10 @@ public static partial class Db Connection.Execute("UPDATE User SET Language = 'fr' WHERE Language = 'french'"); Connection.Execute("UPDATE User SET Language = 'it' WHERE Language = 'italian'"); - // One-time migration: rebrand folder names + // One-time migration: rebrand to inesco Energy Connection.Execute("UPDATE Folder SET Name = 'inesco Energy' WHERE Name = 'InnovEnergy'"); Connection.Execute("UPDATE Folder SET Name = 'Sodistore Max Installations' WHERE Name = 'SodistoreMax Installations'"); + Connection.Execute("UPDATE User SET Name = 'inesco Energy Master Admin' WHERE Name = 'InnovEnergy Master Admin'"); //UpdateKeys(); CleanupSessions().SupressAwaitWarning(); From 7df484298053fee4205a5c9b78f49bc6012335b2 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Wed, 4 Mar 2026 09:56:58 +0100 Subject: [PATCH 06/64] fixed hard-code English in report system --- .../SodiohomeInstallations/WeeklyReport.tsx | 13 ++++++++----- typescript/frontend-marios2/src/lang/de.json | 1 + typescript/frontend-marios2/src/lang/en.json | 1 + typescript/frontend-marios2/src/lang/fr.json | 1 + typescript/frontend-marios2/src/lang/it.json | 1 + 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/WeeklyReport.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/WeeklyReport.tsx index b166364ea..ba996b8bf 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/WeeklyReport.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/WeeklyReport.tsx @@ -159,7 +159,10 @@ function FormattedBullet({ text }: { text: string }) { return <>{parts}; } -const MONTH_NAMES = ['', 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; +function getMonthName(month: number, locale: string): string { + const date = new Date(2000, month - 1, 1); + return date.toLocaleDateString(locale, { month: 'long' }); +} // ── Email Bar (shared) ────────────────────────────────────────── @@ -545,7 +548,7 @@ function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installatio {report.dailyData.map((d, i) => { const dt = new Date(d.date); - const dayLabel = dt.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); + const dayLabel = dt.toLocaleDateString(intl.locale, { weekday: 'short', month: 'short', day: 'numeric' }); const isCurrentWeek = report.dailyData.length > 7 ? i >= report.dailyData.length - 7 : true; return ( @@ -555,7 +558,7 @@ function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installatio {!isCurrentWeek && } - PV {d.pvProduction.toFixed(1)} | Load {d.loadConsumption.toFixed(1)} | Grid {d.gridImport.toFixed(1)} kWh + {intl.formatMessage({ id: 'pvProduction' })} {d.pvProduction.toFixed(1)} | {intl.formatMessage({ id: 'consumption' })} {d.loadConsumption.toFixed(1)} | {intl.formatMessage({ id: 'gridImport' })} {d.gridImport.toFixed(1)} kWh @@ -737,7 +740,7 @@ function MonthlySection({ ? intl.formatMessage({ id: 'generatingMonthly', defaultMessage: 'Generating...' }) : intl.formatMessage( { id: 'generateMonth', defaultMessage: 'Generate {month} {year} ({count} weeks)' }, - { month: MONTH_NAMES[p.month], year: p.year, count: p.weekCount } + { month: getMonthName(p.month, intl.locale), year: p.year, count: p.weekCount } ) } @@ -752,7 +755,7 @@ function MonthlySection({ `${MONTH_NAMES[r.month]} ${r.year}`} + labelFn={(r: MonthlyReport) => `${getMonthName(r.month, intl.locale)} ${r.year}`} countLabelId="weeksAggregated" countFn={(r: MonthlyReport) => r.weekCount} sendEndpoint="/SendMonthlyReportEmail" diff --git a/typescript/frontend-marios2/src/lang/de.json b/typescript/frontend-marios2/src/lang/de.json index 856eb5ffc..84ee58ea1 100644 --- a/typescript/frontend-marios2/src/lang/de.json +++ b/typescript/frontend-marios2/src/lang/de.json @@ -155,6 +155,7 @@ "generatingMonthly": "Wird generiert...", "generatingYearly": "Wird generiert...", "thisMonthWeeklyReports": "Wöchentliche Berichte dieses Monats", + "recentWeeklyReports": "Letzte Wochenberichte", "ai_analyzing": "KI analysiert...", "ai_show_details": "Details anzeigen", "ai_show_less": "Weniger anzeigen", diff --git a/typescript/frontend-marios2/src/lang/en.json b/typescript/frontend-marios2/src/lang/en.json index d3dba04b9..f019bf494 100644 --- a/typescript/frontend-marios2/src/lang/en.json +++ b/typescript/frontend-marios2/src/lang/en.json @@ -137,6 +137,7 @@ "generatingMonthly": "Generating...", "generatingYearly": "Generating...", "thisMonthWeeklyReports": "This Month's Weekly Reports", + "recentWeeklyReports": "Recent Weekly Reports", "ai_analyzing": "AI is analyzing...", "ai_show_details": "Show details", "ai_show_less": "Show less", diff --git a/typescript/frontend-marios2/src/lang/fr.json b/typescript/frontend-marios2/src/lang/fr.json index c1dbb7fea..a52082f15 100644 --- a/typescript/frontend-marios2/src/lang/fr.json +++ b/typescript/frontend-marios2/src/lang/fr.json @@ -149,6 +149,7 @@ "generatingMonthly": "Génération en cours...", "generatingYearly": "Génération en cours...", "thisMonthWeeklyReports": "Rapports hebdomadaires de ce mois", + "recentWeeklyReports": "Derniers rapports hebdomadaires", "ai_analyzing": "L'IA analyse...", "ai_show_details": "Afficher les détails", "ai_show_less": "Afficher moins", diff --git a/typescript/frontend-marios2/src/lang/it.json b/typescript/frontend-marios2/src/lang/it.json index 05cdf337f..2e55deac4 100644 --- a/typescript/frontend-marios2/src/lang/it.json +++ b/typescript/frontend-marios2/src/lang/it.json @@ -160,6 +160,7 @@ "generatingMonthly": "Generazione in corso...", "generatingYearly": "Generazione in corso...", "thisMonthWeeklyReports": "Rapporti settimanali di questo mese", + "recentWeeklyReports": "Ultimi rapporti settimanali", "ai_analyzing": "L'IA sta analizzando...", "ai_show_details": "Mostra dettagli", "ai_show_less": "Mostra meno", From 7aacddd761bcc9afefc0c0c51708739e090dec04 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Wed, 4 Mar 2026 10:59:08 +0100 Subject: [PATCH 07/64] Added SodistoreGrid as an empty templated and built S3 bucket creation pipeline --- csharp/App/Backend/Controller.cs | 24 +++++- csharp/App/Backend/DataTypes/Configuration.cs | 6 ++ csharp/App/Backend/DataTypes/Installation.cs | 3 +- .../App/Backend/DataTypes/Methods/ExoCmd.cs | 2 + .../Backend/DataTypes/Methods/Installation.cs | 8 +- .../App/Backend/DataTypes/Methods/Session.cs | 8 +- .../DeleteOldData/DeleteOldDataFromS3.cs | 6 +- csharp/App/Backend/Relations/Session.cs | 4 +- .../App/Backend/Websockets/RabbitMQManager.cs | 5 ++ .../Backend/Websockets/WebsockerManager.cs | 3 +- typescript/frontend-marios2/src/App.tsx | 15 +++- .../src/Resources/routes.json | 1 + .../frontend-marios2/src/components/login.tsx | 6 +- .../dashboards/BatteryView/BatteryView.tsx | 6 +- .../dashboards/Information/Information.tsx | 5 +- .../dashboards/Installations/Installation.tsx | 75 ++++++++++++++----- .../dashboards/Installations/index.tsx | 34 +++++++-- .../content/dashboards/Tree/Information.tsx | 8 +- .../dashboards/Tree/InstallationTree.tsx | 2 +- .../contexts/InstallationsContextProvider.tsx | 24 +++++- .../src/contexts/ProductIdContextProvider.tsx | 16 +++- .../frontend-marios2/src/interfaces/Chart.tsx | 7 +- .../Sidebar/SidebarMenu/index.tsx | 24 +++++- 23 files changed, 239 insertions(+), 53 deletions(-) diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index b9278ccb6..bfc787bcc 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -200,6 +200,8 @@ public class Controller : ControllerBase bucketPath = "s3://" + installation.S3BucketId + "-3e5b3069-214a-43ee-8d85-57d72000c19d/" + startTimestamp; else if (installation.Product == (int)ProductType.SodioHome) bucketPath = "s3://" + installation.S3BucketId + "-e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa/" + startTimestamp; + else if (installation.Product == (int)ProductType.SodistoreGrid) + bucketPath = "s3://" + installation.S3BucketId + "-5109c126-e141-43ab-8658-f3c44c838ae8/" + startTimestamp; else bucketPath = "s3://" + installation.S3BucketId + "-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e/" + startTimestamp; Console.WriteLine("Fetching data for "+startTimestamp); @@ -537,16 +539,29 @@ public class Controller : ControllerBase public ActionResult> GetAllSodioHomeInstallations(Token authToken) { var user = Db.GetSession(authToken)?.User; - + if (user is null) return Unauthorized(); - + return user .AccessibleInstallations(product:(int)ProductType.SodioHome) .ToList(); } - - + + [HttpGet(nameof(GetAllSodistoreGridInstallations))] + public ActionResult> GetAllSodistoreGridInstallations(Token authToken) + { + var user = Db.GetSession(authToken)?.User; + + if (user is null) + return Unauthorized(); + + return user + .AccessibleInstallations(product:(int)ProductType.SodistoreGrid) + .ToList(); + } + + [HttpGet(nameof(GetAllFolders))] public ActionResult> GetAllFolders(Token authToken) @@ -1513,6 +1528,7 @@ public class Controller : ControllerBase 0 => config.GetConfigurationSalimax(), // Salimax 3 => config.GetConfigurationSodistoreMax(), // SodiStoreMax 2 => config.GetConfigurationSodistoreHome(), // SodiStoreHome + 4 => config.GetConfigurationSodistoreGrid(), // SodistoreGrid _ => config.GetConfigurationString() // fallback }; diff --git a/csharp/App/Backend/DataTypes/Configuration.cs b/csharp/App/Backend/DataTypes/Configuration.cs index a99492044..5c0726ca5 100644 --- a/csharp/App/Backend/DataTypes/Configuration.cs +++ b/csharp/App/Backend/DataTypes/Configuration.cs @@ -48,6 +48,12 @@ public class Configuration $"BatteriesCount: {BatteriesCount}, ClusterNumber: {ClusterNumber}, PvNumber: {PvNumber}, ControlPermission:{ControlPermission}, "+ $"SinexcelTimeChargeandDischargePower: {TimeChargeandDischargePower}, SinexcelStartTimeChargeandDischargeDayandTime: {StartTimeChargeandDischargeDayandTime}, SinexcelStopTimeChargeandDischargeDayandTime: {StopTimeChargeandDischargeDayandTime}"; } + + // TODO: SodistoreGrid — update configuration fields when defined + public string GetConfigurationSodistoreGrid() + { + return ""; + } } public enum CalibrationChargeType diff --git a/csharp/App/Backend/DataTypes/Installation.cs b/csharp/App/Backend/DataTypes/Installation.cs index fe9cb24b1..70406e08c 100644 --- a/csharp/App/Backend/DataTypes/Installation.cs +++ b/csharp/App/Backend/DataTypes/Installation.cs @@ -7,7 +7,8 @@ public enum ProductType Salimax = 0, Salidomo = 1, SodioHome =2, - SodiStoreMax=3 + SodiStoreMax=3, + SodistoreGrid=4 } public enum StatusType diff --git a/csharp/App/Backend/DataTypes/Methods/ExoCmd.cs b/csharp/App/Backend/DataTypes/Methods/ExoCmd.cs index a0173ada5..ab3526cfe 100644 --- a/csharp/App/Backend/DataTypes/Methods/ExoCmd.cs +++ b/csharp/App/Backend/DataTypes/Methods/ExoCmd.cs @@ -145,6 +145,7 @@ public static class ExoCmd const String method = "iam-role"; String rolename = installation.Product==(int)ProductType.Salimax?Db.Installations.Count(f => f.Product == (int)ProductType.Salimax) + installation.Name: installation.Product==(int)ProductType.SodiStoreMax?Db.Installations.Count(f => f.Product == (int)ProductType.SodiStoreMax) + installation.Name: + installation.Product==(int)ProductType.SodistoreGrid?Db.Installations.Count(f => f.Product == (int)ProductType.SodistoreGrid) + installation.Name: Db.Installations.Count(f => f.Product == (int)ProductType.Salidomo) + installation.Name; @@ -320,6 +321,7 @@ public static class ExoCmd const String method = "iam-role"; String rolename = installation.Product==(int)ProductType.Salimax?Db.Installations.Count(f => f.Product == (int)ProductType.Salimax) + installation.Name: installation.Product==(int)ProductType.SodiStoreMax?Db.Installations.Count(f => f.Product == (int)ProductType.SodiStoreMax) + installation.Name: + installation.Product==(int)ProductType.SodistoreGrid?Db.Installations.Count(f => f.Product == (int)ProductType.SodistoreGrid) + installation.Name: Db.Installations.Count(f => f.Product == (int)ProductType.Salidomo) + installation.Name; var contentString = $$""" diff --git a/csharp/App/Backend/DataTypes/Methods/Installation.cs b/csharp/App/Backend/DataTypes/Methods/Installation.cs index 7072e785a..0bd36b6f6 100644 --- a/csharp/App/Backend/DataTypes/Methods/Installation.cs +++ b/csharp/App/Backend/DataTypes/Methods/Installation.cs @@ -10,6 +10,7 @@ public static class InstallationMethods private static readonly String BucketNameSalt = "3e5b3069-214a-43ee-8d85-57d72000c19d"; private static readonly String SalidomoBucketNameSalt = "c0436b6a-d276-4cd8-9c44-1eae86cf5d0e"; private static readonly String SodioHomeBucketNameSalt = "e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa"; + private static readonly String SodistoreGridBucketNameSalt = "5109c126-e141-43ab-8658-f3c44c838ae8"; public static String BucketName(this Installation installation) { @@ -17,12 +18,17 @@ public static class InstallationMethods { return $"{installation.S3BucketId}-{BucketNameSalt}"; } - + if (installation.Product == (int)ProductType.SodioHome) { return $"{installation.S3BucketId}-{SodioHomeBucketNameSalt}"; } + if (installation.Product == (int)ProductType.SodistoreGrid) + { + return $"{installation.S3BucketId}-{SodistoreGridBucketNameSalt}"; + } + return $"{installation.S3BucketId}-{SalidomoBucketNameSalt}"; } diff --git a/csharp/App/Backend/DataTypes/Methods/Session.cs b/csharp/App/Backend/DataTypes/Methods/Session.cs index 3b62cb6f3..be56b6ead 100644 --- a/csharp/App/Backend/DataTypes/Methods/Session.cs +++ b/csharp/App/Backend/DataTypes/Methods/Session.cs @@ -239,7 +239,7 @@ public static class SessionMethods } - if (installation.Product == (int)ProductType.SodiStoreMax || installation.Product == (int)ProductType.SodioHome) + if (installation.Product == (int)ProductType.SodiStoreMax || installation.Product == (int)ProductType.SodioHome || installation.Product == (int)ProductType.SodistoreGrid) { return user is not null && user.UserType != 0 @@ -295,9 +295,9 @@ public static class SessionMethods .Apply(Db.Update); } - if (installation.Product == (int)ProductType.SodiStoreMax) + if (installation.Product == (int)ProductType.SodiStoreMax || installation.Product == (int)ProductType.SodistoreGrid) { - + return user is not null && installation is not null && original is not null @@ -305,7 +305,7 @@ public static class SessionMethods && user.HasAccessTo(installation) && installation .WithParentOf(original) // prevent moving - .Apply(Db.Update); + .Apply(Db.Update); } diff --git a/csharp/App/Backend/DeleteOldData/DeleteOldDataFromS3.cs b/csharp/App/Backend/DeleteOldData/DeleteOldDataFromS3.cs index d4a6e3835..1588c33a8 100644 --- a/csharp/App/Backend/DeleteOldData/DeleteOldDataFromS3.cs +++ b/csharp/App/Backend/DeleteOldData/DeleteOldDataFromS3.cs @@ -12,7 +12,11 @@ public class DeleteOldDataFromS3 { string configPath = "/home/ubuntu/.s3cfg"; - string bucketPath = installation.Product ==(int)ProductType.Salidomo ? $"s3://{installation.S3BucketId}-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e/{timestamps_to_delete}*" : $"s3://{installation.S3BucketId}-3e5b3069-214a-43ee-8d85-57d72000c19d/{timestamps_to_delete}*" ; + string bucketPath = installation.Product == (int)ProductType.Salidomo + ? $"s3://{installation.S3BucketId}-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e/{timestamps_to_delete}*" + : installation.Product == (int)ProductType.SodistoreGrid + ? $"s3://{installation.S3BucketId}-5109c126-e141-43ab-8658-f3c44c838ae8/{timestamps_to_delete}*" + : $"s3://{installation.S3BucketId}-3e5b3069-214a-43ee-8d85-57d72000c19d/{timestamps_to_delete}*" ; //Console.WriteLine($"Deleting old data from {bucketPath}"); diff --git a/csharp/App/Backend/Relations/Session.cs b/csharp/App/Backend/Relations/Session.cs index 6128ce237..9b54bca5f 100644 --- a/csharp/App/Backend/Relations/Session.cs +++ b/csharp/App/Backend/Relations/Session.cs @@ -16,6 +16,7 @@ public class Session : Relation public Boolean AccessToSalidomo { get; set; } = false; public Boolean AccessToSodistoreMax { get; set; } = false; public Boolean AccessToSodioHome { get; set; } = false; + public Boolean AccessToSodistoreGrid { get; set; } = false; [Ignore] public Boolean Valid => DateTime.Now - LastSeen <=MaxAge ; // Private backing field @@ -49,7 +50,8 @@ public class Session : Relation AccessToSalidomo = user.AccessibleInstallations(product: (int)ProductType.Salidomo).ToList().Count > 0; AccessToSodistoreMax = user.AccessibleInstallations(product: (int)ProductType.SodiStoreMax).ToList().Count > 0; AccessToSodioHome = user.AccessibleInstallations(product: (int)ProductType.SodioHome).ToList().Count > 0; - + AccessToSodistoreGrid = user.AccessibleInstallations(product: (int)ProductType.SodistoreGrid).ToList().Count > 0; + Console.WriteLine("salimax" +user.AccessibleInstallations(product: (int)ProductType.Salimax).ToList().Count); Console.WriteLine("AccessToSodistoreMax" +user.AccessibleInstallations(product: (int)ProductType.SodiStoreMax).ToList().Count); Console.WriteLine("sodio" + user.AccessibleInstallations(product: (int)ProductType.SodioHome).ToList().Count); diff --git a/csharp/App/Backend/Websockets/RabbitMQManager.cs b/csharp/App/Backend/Websockets/RabbitMQManager.cs index 417a04e86..f3d810920 100644 --- a/csharp/App/Backend/Websockets/RabbitMQManager.cs +++ b/csharp/App/Backend/Websockets/RabbitMQManager.cs @@ -100,6 +100,11 @@ public static class RabbitMqManager monitorLink = $"https://monitor.inesco.energy/sodistore_installations/list/installation/{installation.S3BucketId}/batteryview"; } + else if (installation.Product == (int)ProductType.SodistoreGrid) + { + monitorLink = + $"https://monitor.inesco.energy/sodistoregrid_installations/list/installation/{installation.S3BucketId}/batteryview"; + } else { monitorLink = diff --git a/csharp/App/Backend/Websockets/WebsockerManager.cs b/csharp/App/Backend/Websockets/WebsockerManager.cs index 5d521364a..9a6a7dc40 100644 --- a/csharp/App/Backend/Websockets/WebsockerManager.cs +++ b/csharp/App/Backend/Websockets/WebsockerManager.cs @@ -30,7 +30,8 @@ public static class WebsocketManager if ((installationConnection.Value.Product == (int)ProductType.Salimax && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(2)) || (installationConnection.Value.Product == (int)ProductType.Salidomo && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(60)) || (installationConnection.Value.Product == (int)ProductType.SodioHome && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(4)) || - (installationConnection.Value.Product == (int)ProductType.SodiStoreMax && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(2)) + (installationConnection.Value.Product == (int)ProductType.SodiStoreMax && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(2)) || + (installationConnection.Value.Product == (int)ProductType.SodistoreGrid && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(2)) ) { Console.WriteLine("Installation ID is " + installationConnection.Key); diff --git a/typescript/frontend-marios2/src/App.tsx b/typescript/frontend-marios2/src/App.tsx index b03530cb7..7ba5ece03 100644 --- a/typescript/frontend-marios2/src/App.tsx +++ b/typescript/frontend-marios2/src/App.tsx @@ -35,7 +35,8 @@ function App() { setAccessToSalimax, setAccessToSalidomo, setAccessToSodiohome, - setAccessToSodistore + setAccessToSodistore, + setAccessToSodistoreGrid } = useContext(ProductIdContext); const [language, setLanguage] = useState( @@ -102,12 +103,15 @@ function App() { setAccessToSalidomo(response.data.accessToSalidomo); setAccessToSodiohome(response.data.accessToSodioHome); setAccessToSodistore(response.data.accessToSodistoreMax); + setAccessToSodistoreGrid(response.data.accessToSodistoreGrid); if (response.data.accessToSalimax) { navigate(routes.installations); } else if (response.data.accessToSalidomo) { navigate(routes.salidomo_installations); } else if (response.data.accessToSodistoreMax) { navigate(routes.sodistore_installations); + } else if (response.data.accessToSodistoreGrid) { + navigate(routes.sodistoregrid_installations); } else { navigate(routes.sodiohome_installations); } @@ -215,6 +219,15 @@ function App() { } /> + + + + } + /> + } /> Max Cell Voltage )} - {product === 3 && ( + {(product === 3 || product === 4) && ( Voltage Difference )} @@ -469,7 +469,7 @@ function BatteryView(props: BatteryViewProps) { )} - {product === 3 && ( + {(product === 3 || product === 4) && ( <> {(() => { const cellVoltagesString = @@ -524,7 +524,7 @@ function BatteryView(props: BatteryViewProps) { })()} )} - {product === 3 && ( + {(product === 3 || product === 4) && ( <> {(() => { const cellVoltagesString = diff --git a/typescript/frontend-marios2/src/content/dashboards/Information/Information.tsx b/typescript/frontend-marios2/src/content/dashboards/Information/Information.tsx index a0fb5f8f1..acfe3220a 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Information/Information.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Information/Information.tsx @@ -328,9 +328,12 @@ function Information(props: InformationProps) { label="S3 Bucket Name" name="s3bucketname" value={ - product === 0 || product == 3 + formValues.product === 0 || formValues.product == 3 ? formValues.s3BucketId + '-3e5b3069-214a-43ee-8d85-57d72000c19d' + : formValues.product == 4 + ? formValues.s3BucketId + + '-5109c126-e141-43ab-8658-f3c44c838ae8' : formValues.s3BucketId + '-e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa' } diff --git a/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx b/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx index 6092a0670..41aff6831 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx @@ -57,9 +57,13 @@ function Installation(props: singleInstallationProps) { s3BucketId: props.current_installation.s3BucketId }; + // TODO: SodistoreGrid — uses its own bucket salt + const s3BucketSalt = + props.current_installation.product === 4 + ? '5109c126-e141-43ab-8658-f3c44c838ae8' + : '3e5b3069-214a-43ee-8d85-57d72000c19d'; const s3Bucket = - props.current_installation.s3BucketId.toString() + - '-3e5b3069-214a-43ee-8d85-57d72000c19d'; + props.current_installation.s3BucketId.toString() + '-' + s3BucketSalt; const s3Credentials = { s3Bucket, ...S3data }; @@ -427,12 +431,15 @@ function Installation(props: singleInstallationProps) { } > - - } - > + {/* TODO: SodistoreGrid — PV View excluded, add back when data path is ready */} + {props.current_installation.product !== 4 && ( + + } + > + )} + props.current_installation.product === 4 ? ( + // TODO: SodistoreGrid — implement actual topology layout + + + Live view coming soon + + + ) : ( + + ) } /> @@ -470,10 +494,27 @@ function Installation(props: singleInstallationProps) { + props.current_installation.product === 4 ? ( + // TODO: SodistoreGrid — implement actual configuration + + + Configuration not yet available + + + ) : ( + + ) } /> )} diff --git a/typescript/frontend-marios2/src/content/dashboards/Installations/index.tsx b/typescript/frontend-marios2/src/content/dashboards/Installations/index.tsx index 41f138f2a..7ffc7d20f 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Installations/index.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Installations/index.tsx @@ -39,11 +39,18 @@ function InstallationTabs(props: InstallationTabsProps) { const { salimax_or_sodistore_Installations, + sodistoreGridInstallations, fetchAllInstallations, socket, openSocket, closeSocket } = useContext(InstallationsContext); + + // Use the correct installations array based on product + const installations = + props.product === 4 + ? sodistoreGridInstallations + : salimax_or_sodistore_Installations; const { product, setProduct } = useContext(ProductIdContext); useEffect(() => { @@ -93,7 +100,10 @@ function InstallationTabs(props: InstallationTabsProps) { return ret_path; }; - const singleInstallationTabs = + // TODO: SodistoreGrid — PV View excluded for product 4, add back when data path is ready + const hidePvView = props.product === 4; + + const singleInstallationTabs = ( currentUser.userType == UserType.admin ? [ { @@ -204,7 +214,8 @@ function InstallationTabs(props: InstallationTabsProps) { ) } - ]; + ] + ).filter((tab) => !(hidePvView && tab.value === 'pvview')); const tabs = currentTab != 'list' && @@ -372,7 +383,12 @@ function InstallationTabs(props: InstallationTabsProps) { } ]; - return salimax_or_sodistore_Installations.length > 1 ? ( + // Filter out PV View for SodistoreGrid + const filteredTabs = hidePvView + ? tabs.filter((tab) => tab.value !== 'pvview') + : tabs; + + return installations.length > 1 ? ( <> @@ -384,7 +400,7 @@ function InstallationTabs(props: InstallationTabsProps) { textColor="primary" indicatorColor="primary" > - {tabs.map((tab) => ( + {filteredTabs.map((tab) => ( @@ -428,6 +444,10 @@ function InstallationTabs(props: InstallationTabsProps) { element={ props.product === 0 ? ( + ) : props.product === 4 ? ( + ) : (