Compare commits

...

4 Commits

19 changed files with 2967 additions and 220 deletions

View File

@ -901,7 +901,8 @@ public class Controller : ControllerBase
/// Returns JSON with daily data, weekly totals, ratios, and AI insight.
/// </summary>
[HttpGet(nameof(GetWeeklyReport))]
public async Task<ActionResult<WeeklyReportResponse>> GetWeeklyReport(Int64 installationId, Token authToken, string? language = null)
public async Task<ActionResult<WeeklyReportResponse>> GetWeeklyReport(
Int64 installationId, Token authToken, String? language = null, String? weekStart = null)
{
var user = Db.GetSession(authToken)?.User;
if (user == null)
@ -911,14 +912,24 @@ 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 and seed AiInsightCache for this language
ReportAggregationService.SaveWeeklySummary(installationId, report, lang);
return Ok(report);
}
catch (Exception ex)
@ -956,6 +967,329 @@ public class Controller : ControllerBase
}
}
// ── Monthly & Yearly Reports ─────────────────────────────────────
[HttpGet(nameof(GetPendingMonthlyAggregations))]
public ActionResult<List<PendingMonth>> 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<List<PendingYear>> 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 async Task<ActionResult<List<MonthlyReportSummary>>> GetMonthlyReports(
Int64 installationId, Token authToken, String? language = null)
{
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 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 async Task<ActionResult<List<YearlyReportSummary>>> GetYearlyReports(
Int64 installationId, Token authToken, String? language = null)
{
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 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);
}
/// <summary>
/// Manually trigger monthly aggregation for an installation.
/// Computes monthly report from daily records for the specified year/month.
/// </summary>
[HttpPost(nameof(TriggerMonthlyAggregation))]
public async Task<ActionResult> 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 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 {dayCount} daily records for {year}-{month:D2}." });
}
catch (Exception ex)
{
Console.Error.WriteLine($"[TriggerMonthlyAggregation] Error: {ex.Message}");
return BadRequest($"Failed to aggregate: {ex.Message}");
}
}
/// <summary>
/// Manually trigger yearly aggregation for an installation.
/// Aggregates monthly reports for the specified year into a yearly report.
/// </summary>
[HttpPost(nameof(TriggerYearlyAggregation))]
public async Task<ActionResult> 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}");
}
}
/// <summary>
/// Manually trigger xlsx ingestion for all SodioHome installations.
/// Scans tmp_report/ for all matching xlsx files and ingests any new days.
/// </summary>
[HttpPost(nameof(IngestAllDailyData))]
public async Task<ActionResult> 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}");
}
}
/// <summary>
/// Manually trigger xlsx ingestion for one installation.
/// Parses tmp_report/{installationId}*.xlsx and stores any new days in DailyEnergyRecord.
/// </summary>
[HttpPost(nameof(IngestDailyData))]
public async Task<ActionResult> 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 ──────────────────────────────────
/// <summary>
/// Returns the stored DailyEnergyRecord rows for an installation and date range.
/// Use this to verify that xlsx ingestion worked correctly before generating reports.
/// </summary>
[HttpGet(nameof(GetDailyRecords))]
public ActionResult<List<DailyEnergyRecord>> 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 });
}
/// <summary>
/// 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.
/// </summary>
[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<ActionResult> 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";
report.AiInsight = await ReportAggregationService.GetOrGenerateMonthlyInsightAsync(report, lang);
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<ActionResult> 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";
report.AiInsight = await ReportAggregationService.GetOrGenerateYearlyInsightAsync(report, lang);
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 async Task<ActionResult<List<WeeklyReportSummary>>> GetWeeklyReportSummaries(
Int64 installationId, Int32 year, Int32 month, Token authToken, String? language = null)
{
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 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))]
public ActionResult<Folder> UpdateFolder([FromBody] Folder folder, Token authToken)
{
@ -963,7 +1297,7 @@ public class Controller : ControllerBase
if (!session.Update(folder))
return Unauthorized();
return folder.HideParentIfUserHasNoAccessToParent(session!.User);
}

View File

@ -0,0 +1,29 @@
using SQLite;
namespace InnovEnergy.App.Backend.DataTypes;
/// <summary>
/// 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.
/// </summary>
public class AiInsightCache
{
[PrimaryKey, AutoIncrement]
public Int64 Id { get; set; }
/// <summary>"weekly" | "monthly" | "yearly"</summary>
[Indexed]
public String ReportType { get; set; } = "";
/// <summary>FK to WeeklyReportSummary.Id / MonthlyReportSummary.Id / YearlyReportSummary.Id</summary>
[Indexed]
public Int64 ReportId { get; set; }
/// <summary>ISO 639-1 language code: "en" | "de" | "fr" | "it"</summary>
public String Language { get; set; } = "en";
public String InsightText { get; set; } = "";
public String CreatedAt { get; set; } = "";
}

View File

@ -0,0 +1,31 @@
using SQLite;
namespace InnovEnergy.App.Backend.DataTypes;
/// <summary>
/// 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).
/// </summary>
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; } = "";
}

View File

@ -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; } = "";
// 023
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; } = "";
}

View File

@ -0,0 +1,149 @@
using SQLite;
namespace InnovEnergy.App.Backend.DataTypes;
/// <summary>
/// Stored summary for a weekly report period.
/// Created when GetWeeklyReport is called. Consumed and deleted by monthly aggregation.
/// </summary>
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; } = "";
}
/// <summary>
/// Aggregated monthly report. Created from weekly summaries at month-end.
/// Consumed and deleted by yearly aggregation.
/// </summary>
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; } = "";
}
/// <summary>
/// Aggregated yearly report. Created from monthly summaries at year-end.
/// Kept indefinitely.
/// </summary>
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; }
}

View File

@ -67,6 +67,13 @@ 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 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)
{

View File

@ -25,7 +25,13 @@ public static partial class Db
public static TableQuery<Error> Errors => Connection.Table<Error>();
public static TableQuery<Warning> Warnings => Connection.Table<Warning>();
public static TableQuery<UserAction> UserActions => Connection.Table<UserAction>();
public static TableQuery<WeeklyReportSummary> WeeklyReports => Connection.Table<WeeklyReportSummary>();
public static TableQuery<MonthlyReportSummary> MonthlyReports => Connection.Table<MonthlyReportSummary>();
public static TableQuery<YearlyReportSummary> YearlyReports => Connection.Table<YearlyReportSummary>();
public static TableQuery<DailyEnergyRecord> DailyRecords => Connection.Table<DailyEnergyRecord>();
public static TableQuery<HourlyEnergyRecord> HourlyRecords => Connection.Table<HourlyEnergyRecord>();
public static TableQuery<AiInsightCache> AiInsightCaches => Connection.Table<AiInsightCache>();
public static void Init()
{
@ -51,6 +57,12 @@ public static partial class Db
Connection.CreateTable<Error>();
Connection.CreateTable<Warning>();
Connection.CreateTable<UserAction>();
Connection.CreateTable<WeeklyReportSummary>();
Connection.CreateTable<MonthlyReportSummary>();
Connection.CreateTable<YearlyReportSummary>();
Connection.CreateTable<DailyEnergyRecord>();
Connection.CreateTable<HourlyEnergyRecord>();
Connection.CreateTable<AiInsightCache>();
});
// One-time migration: normalize legacy long-form language values to ISO codes
@ -88,6 +100,12 @@ public static partial class Db
fileConnection.CreateTable<Error>();
fileConnection.CreateTable<Warning>();
fileConnection.CreateTable<UserAction>();
fileConnection.CreateTable<WeeklyReportSummary>();
fileConnection.CreateTable<MonthlyReportSummary>();
fileConnection.CreateTable<YearlyReportSummary>();
fileConnection.CreateTable<DailyEnergyRecord>();
fileConnection.CreateTable<HourlyEnergyRecord>();
fileConnection.CreateTable<AiInsightCache>();
return fileConnection;
//return CopyDbToMemory(fileConnection);

View File

@ -141,4 +141,106 @@ 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();
}
/// <summary>
/// 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).
/// </summary>
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);
// 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()
.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, {oldHourlyIds.Count} hourly, {oldWeeklyIds.Count} weekly, {oldMonthlyIds.Count} monthly, {oldCacheIds.Count} insight cache records deleted (cutoff {cutoff}).");
}
}

View File

@ -56,4 +56,104 @@ public static partial class Db
return session;
}
// ── Report Queries ────────────────────────────────────────────────
public static List<WeeklyReportSummary> GetWeeklyReports(Int64 installationId)
=> WeeklyReports
.Where(r => r.InstallationId == installationId)
.OrderByDescending(r => r.PeriodStart)
.ToList();
public static List<WeeklyReportSummary> 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<MonthlyReportSummary> GetMonthlyReports(Int64 installationId)
=> MonthlyReports
.Where(r => r.InstallationId == installationId)
.OrderByDescending(r => r.Year)
.ThenByDescending(r => r.Month)
.ToList();
public static List<MonthlyReportSummary> GetMonthlyReportsForYear(Int64 installationId, Int32 year)
=> MonthlyReports
.Where(r => r.InstallationId == installationId && r.Year == year)
.ToList();
public static List<YearlyReportSummary> GetYearlyReports(Int64 installationId)
=> YearlyReports
.Where(r => r.InstallationId == installationId)
.OrderByDescending(r => r.Year)
.ToList();
// ── DailyEnergyRecord Queries ──────────────────────────────────────
/// <summary>
/// Returns daily records for an installation within [from, to] inclusive, ordered by date.
/// </summary>
public static List<DailyEnergyRecord> 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();
}
/// <summary>
/// Returns true if a daily record already exists for this installation+date (idempotency check).
/// </summary>
public static Boolean DailyRecordExists(Int64 installationId, String date)
=> DailyRecords
.Any(r => r.InstallationId == installationId && r.Date == date);
// ── HourlyEnergyRecord Queries ─────────────────────────────────────
/// <summary>
/// Returns hourly records for an installation within [from, to] inclusive, ordered by date+hour.
/// </summary>
public static List<HourlyEnergyRecord> 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();
}
/// <summary>
/// Returns true if an hourly record already exists for this installation+dateHour (idempotency check).
/// </summary>
public static Boolean HourlyRecordExists(Int64 installationId, String dateHour)
=> HourlyRecords
.Any(r => r.InstallationId == installationId && r.DateHour == dateHour);
// ── AiInsightCache Queries ─────────────────────────────────────────
/// <summary>
/// Returns the cached AI insight text for (reportType, reportId, language), or null on miss.
/// </summary>
public static String? GetCachedInsight(String reportType, Int64 reportId, String language)
=> AiInsightCaches
.FirstOrDefault(c => c.ReportType == reportType
&& c.ReportId == reportId
&& c.Language == language)
?.InsightText;
}

View File

@ -28,6 +28,8 @@ public static class Program
LoadEnvFile();
DiagnosticService.Initialize();
AlarmReviewService.StartDailyScheduler();
// 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();

View File

@ -0,0 +1,176 @@
using InnovEnergy.App.Backend.Database;
using InnovEnergy.App.Backend.DataTypes;
namespace InnovEnergy.App.Backend.Services;
/// <summary>
/// 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.
/// </summary>
public static class DailyIngestionService
{
private static readonly String TmpReportDir =
Environment.CurrentDirectory + "/tmp_report/";
private static Timer? _dailyTimer;
/// <summary>
/// Starts the daily scheduler. Call once on app startup.
/// Ingests xlsx data at 01:00 UTC every day.
/// </summary>
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");
}
/// <summary>
/// Ingests xlsx data for all SodioHome installations. Safe to call manually.
/// </summary>
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.");
}
/// <summary>
/// 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.
/// </summary>
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 newDailyCount = 0;
var newHourlyCount = 0;
var totalParsed = 0;
foreach (var xlsxPath in xlsxFiles.OrderBy(f => f))
{
// Ingest daily records
List<DailyEnergyData> days;
try
{
days = ExcelDataParser.Parse(xlsxPath);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[DailyIngestion] Failed to parse daily {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"),
});
newDailyCount++;
}
// Ingest hourly records
List<HourlyEnergyData> 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}: {newDailyCount} new day(s), {newHourlyCount} new hour(s) ingested ({totalParsed} days across {xlsxFiles.Length} file(s)).");
await Task.CompletedTask;
}
}

View File

@ -0,0 +1,773 @@
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 (SundaySaturday) 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 SundaySaturday)
// 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, String language = "en")
{
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);
// 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)
{
Console.Error.WriteLine($"[ReportAggregation] Failed to save weekly summary for installation {installationId}: {ex.Message}");
}
}
// ── Pending Aggregation Queries ─────────────────────────────────
/// <summary>
/// Returns months that have weekly data but no monthly report yet.
/// Each entry: { Year, Month, WeekCount }.
/// </summary>
public static List<PendingMonth> 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();
}
/// <summary>
/// Returns years that have monthly data but no yearly report yet.
/// Each entry: { Year, MonthCount }.
/// </summary>
public static List<PendingYear> 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 ─────────────────────────────────────────
/// <summary>
/// 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).
/// </summary>
public static async Task<Int32> TriggerMonthlyAggregationAsync(Int64 installationId, Int32 year, Int32 month, String language = "en")
{
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 days.Count;
}
/// <summary>
/// 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).
/// </summary>
public static async Task<Int32> 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;
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}...");
// 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()
.ToList();
foreach (var installationId in installationIds)
{
try
{
await AggregateMonthForInstallation(installationId, year, month);
}
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")
{
// 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
Db.DeleteMonthlyReport(installationId, year, month);
// 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
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;
// 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, days.Count,
totalPv, totalConsump, totalGridIn, totalGridOut,
totalBattChg, totalBattDis, energySaved, savingsCHF,
selfSufficiency, batteryEff, language);
var monthlySummary = new MonthlyReportSummary
{
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 = 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);
// 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 ──────────────────────────────────────────
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);
}
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")
{
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);
// 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 ──────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
public static async Task<String> GetOrGenerateInsightAsync(
String reportType, Int64 reportId, String language, Func<Task<String>> 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;
}
/// <summary>Cached-or-generated AI insight for a stored WeeklyReportSummary.</summary>
public static Task<String> 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));
}
/// <summary>Cached-or-generated AI insight for a stored MonthlyReportSummary.</summary>
public static Task<String> 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));
}
/// <summary>Cached-or-generated AI insight for a stored YearlyReportSummary.</summary>
public static Task<String> 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 ────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
private static void CleanupOldRecords()
{
try
{
Db.CleanupOldData();
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ReportAggregation] Cleanup error: {ex.Message}");
}
}
// ── AI Insight Generation ─────────────────────────────────────────
/// <summary>
/// Simplified weekly AI insight generated from the stored WeeklyReportSummary numerical fields.
/// Used for historical weeks where the original hourly data is no longer available.
/// </summary>
private static async Task<String> 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",
"fr" => "French",
"it" => "Italian",
_ => "English"
};
private static async Task<String> 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);
// 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} days of data):
- PV production: {totalPv:F1} kWh
- Total consumption: {totalConsump:F1} kWh
- 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. ""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.
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);
}
private static async Task<String> 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<String> 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<dynamic>(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.";
}
}

View File

@ -217,10 +217,10 @@ public static class ReportEmailService
var cur = r.CurrentWeek;
var prev = r.PreviousWeek;
// Parse AI insight into <li> bullet points (split on newlines, strip leading "- " or "1. ")
// Parse AI insight into <li> 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();
@ -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 <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
("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 <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
("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 <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
("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 <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
("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 <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
("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 <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
(_, "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 <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
_ => 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 <strong style=\"color:#666\">inesco Energy Monitor</strong>")
};
// ── 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*", "").Replace("**", ""))
.Where(l => l.Length > 0)
.ToList();
var insightHtml = insightLines.Count > 1
? "<ul style=\"margin:0;padding-left:20px\">" +
string.Join("", insightLines.Select(l => $"<li style=\"margin-bottom:8px;line-height:1.6\">{FormatInsightLine(l)}</li>")) +
"</ul>"
: $"<p style=\"margin:0;line-height:1.6\">{FormatInsightLine(aiInsight)}</p>";
return $@"
<!DOCTYPE html>
<html>
<head><meta charset=""utf-8""></head>
<body style=""margin:0;padding:0;background:#f4f4f4;font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#333"">
<table width=""100%"" cellpadding=""0"" cellspacing=""0"" style=""background:#f4f4f4;padding:20px 0"">
<tr><td align=""center"">
<table width=""600"" cellpadding=""0"" cellspacing=""0"" style=""background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08)"">
<!-- Header -->
<tr>
<td style=""background:#2c3e50;padding:24px 30px;color:#ffffff"">
<div style=""font-size:20px;font-weight:bold"">{s.Title}</div>
<div style=""font-size:14px;margin-top:6px;opacity:0.9"">{installationName}</div>
<div style=""font-size:13px;margin-top:2px;opacity:0.7"">{periodStart} {periodEnd}</div>
<div style=""font-size:12px;margin-top:2px;opacity:0.5"">{countLabel}</div>
</td>
</tr>
<!-- Insights -->
<tr>
<td style=""padding:24px 30px 0"">
<div style=""font-size:16px;font-weight:bold;margin-bottom:12px;color:#2c3e50"">{s.Insights}</div>
<div style=""background:#fef9e7;border-left:4px solid #f39c12;padding:14px 18px;border-radius:0 6px 6px 0;font-size:14px;color:#333"">
{insightHtml}
</div>
</td>
</tr>
<!-- Summary Table -->
<tr>
<td style=""padding:24px 30px"">
<div style=""font-size:16px;font-weight:bold;margin-bottom:12px;color:#2c3e50"">{s.Summary}</div>
<table width=""100%"" cellpadding=""0"" cellspacing=""0"" style=""border:1px solid #eee;border-radius:4px"">
<tr style=""background:#f8f9fa"">
<th style=""padding:8px 12px;text-align:left"">{s.Metric}</th>
<th style=""padding:8px 12px;text-align:right"">{s.Total}</th>
</tr>
<tr><td style=""padding:8px 12px;border-bottom:1px solid #eee"">{s.PvProduction}</td><td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{pvProduction:F1} kWh</td></tr>
<tr><td style=""padding:8px 12px;border-bottom:1px solid #eee"">{s.Consumption}</td><td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{consumption:F1} kWh</td></tr>
<tr><td style=""padding:8px 12px;border-bottom:1px solid #eee"">{s.GridImport}</td><td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{gridImport:F1} kWh</td></tr>
<tr><td style=""padding:8px 12px;border-bottom:1px solid #eee"">{s.GridExport}</td><td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{gridExport:F1} kWh</td></tr>
<tr><td style=""padding:8px 12px"">{s.BatteryInOut}</td><td style=""padding:8px 12px;text-align:right;font-weight:bold"">{batteryCharged:F1} / {batteryDischarged:F1} kWh</td></tr>
</table>
</td>
</tr>
<!-- Key Ratios -->
<tr>
<td style=""padding:0 30px 24px"">
<div style=""font-size:16px;font-weight:bold;margin-bottom:12px;color:#2c3e50"">{s.SavingsHeader}</div>
<table width=""100%"" cellpadding=""0"" cellspacing=""8"">
<tr>
{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")}
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td style=""background:#f8f9fa;padding:16px 30px;text-align:center;font-size:12px;color:#999;border-top:1px solid #eee"">
{s.Footer}
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>";
}
}

View File

@ -1,4 +1,5 @@
using Flurl.Http;
using InnovEnergy.App.Backend.Database;
using InnovEnergy.App.Backend.DataTypes;
using Newtonsoft.Json;
@ -8,88 +9,145 @@ public static class WeeklyReportService
{
private static readonly string TmpReportDir = Environment.CurrentDirectory + "/tmp_report/";
// ── Calendar Week Helpers ──────────────────────────────────────────
/// <summary>
/// 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.
/// Returns the last completed calendar week (MonSun).
/// Example: called on Wednesday 2026-03-11 → returns (2026-03-02, 2026-03-08).
/// </summary>
public static async Task<WeeklyReportResponse> GenerateReportAsync(long installationId, string installationName, string language = "en")
public static (DateOnly Monday, DateOnly Sunday) LastCalendarWeek()
{
var xlsxPath = TmpReportDir + installationId + ".xlsx";
var cachePath = TmpReportDir + $"{installationId}_{language}.cache.json";
// Use cached report if xlsx hasn't changed since cache was written
if (File.Exists(cachePath) && File.Exists(xlsxPath))
{
var xlsxModified = File.GetLastWriteTimeUtc(xlsxPath);
var cacheModified = File.GetLastWriteTimeUtc(cachePath);
if (cacheModified > xlsxModified)
{
try
{
var cached = JsonConvert.DeserializeObject<WeeklyReportResponse>(
await File.ReadAllTextAsync(cachePath));
if (cached != null)
{
Console.WriteLine($"[WeeklyReportService] Returning cached report for installation {installationId} ({language}).");
return cached;
}
}
catch
{
// Cache corrupt — regenerate
}
}
}
// 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);
// Write cache
try
{
await File.WriteAllTextAsync(cachePath, JsonConvert.SerializeObject(report));
}
catch (Exception ex)
{
Console.Error.WriteLine($"[WeeklyReportService] Could not write cache: {ex.Message}");
}
return report;
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);
}
/// <summary>
/// Core report generation. Accepts both daily summaries and hourly intervals.
/// Returns the calendar week before last (for comparison).
/// </summary>
public static async Task<WeeklyReportResponse> GenerateReportFromDataAsync(
List<DailyEnergyData> allDays,
List<HourlyEnergyData> allHourly,
string installationName,
string language = "en")
private static (DateOnly Monday, DateOnly Sunday) PreviousCalendarWeek()
{
// Sort by date
allDays = allDays.OrderBy(d => d.Date).ToList();
var (lastMon, _) = LastCalendarWeek();
return (lastMon.AddDays(-7), lastMon.AddDays(-1));
}
// Split into previous week and current week (daily)
List<DailyEnergyData> previousWeekDays;
List<DailyEnergyData> currentWeekDays;
// ── Report Generation ──────────────────────────────────────────────
if (allDays.Count > 7)
/// <summary>
/// Generates a full weekly report for the given installation.
/// 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).
/// </summary>
public static async Task<WeeklyReportResponse> GenerateReportAsync(
long installationId, string installationName, string language = "en",
DateOnly? weekStartOverride = null)
{
DateOnly curMon, curSun, prevMon, prevSun;
if (weekStartOverride.HasValue)
{
previousWeekDays = allDays.Take(allDays.Count - 7).ToList();
currentWeekDays = allDays.Skip(allDays.Count - 7).ToList();
// 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
{
previousWeekDays = new List<DailyEnergyData>();
currentWeekDays = allDays;
(curMon, curSun) = LastCalendarWeek();
(prevMon, prevSun) = PreviousCalendarWeek();
}
// 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();
// 1. Load daily records from SQLite
var currentWeekDays = Db.GetDailyRecords(installationId, curMon, curSun)
.Select(ToDailyEnergyData).ToList();
var previousWeekDays = Db.GetDailyRecords(installationId, prevMon, prevSun)
.Select(ToDailyEnergyData).ToList();
// 2. Fallback: if DB empty, parse xlsx on the fly (pre-ingestion scenario)
if (currentWeekDays.Count == 0)
{
var xlsxFiles = Directory.Exists(TmpReportDir)
? Directory.GetFiles(TmpReportDir, $"{installationId}*.xlsx").OrderBy(f => f).ToList()
: new List<String>();
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)
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 records from SQLite for behavioral analysis
var currentHourlyData = Db.GetHourlyRecords(installationId, curMon, curSun)
.Select(ToHourlyEnergyData).ToList();
return await GenerateReportFromDataAsync(
currentWeekDays, previousWeekDays, currentHourlyData, installationName, language,
curMon, curSun);
}
// ── 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,
};
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,
};
/// <summary>
/// Core report generation. Accepts pre-split current/previous week data and hourly intervals.
/// weekStart/weekEnd are used to set PeriodStart/PeriodEnd on the response.
/// </summary>
public static async Task<WeeklyReportResponse> GenerateReportFromDataAsync(
List<DailyEnergyData> currentWeekDays,
List<DailyEnergyData> previousWeekDays,
List<HourlyEnergyData> currentHourlyData,
string installationName,
string language = "en",
DateOnly? weekStart = null,
DateOnly? weekEnd = null)
{
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 +207,7 @@ public static class WeeklyReportService
PvChangePercent = pvChange,
ConsumptionChangePercent = consumptionChange,
GridImportChangePercent = gridImportChange,
DailyData = allDays,
DailyData = currentWeekDays,
Behavior = behavior,
AiInsight = aiInsight,
};

View File

@ -133,6 +133,28 @@
"confirmBatteryLogDownload": "Möchten Sie das Batterieprotokoll wirklich herunterladen?",
"downloadBatteryLogFailed": "Herunterladen des Batterieprotokolls fehlgeschlagen, bitte versuchen Sie es erneut.",
"noReportData": "Keine Berichtsdaten gefunden.",
"weeklyTab": "Wöchentlich",
"monthlyTab": "Monatlich",
"yearlyTab": "Jährlich",
"monthlyReportTitle": "Monatlicher Leistungsbericht",
"yearlyReportTitle": "Jährlicher Leistungsbericht",
"monthlyInsights": "Monatliche Einblicke",
"yearlyInsights": "Jährliche Einblicke",
"monthlySavings": "Ihre Einsparungen diesen Monat",
"yearlySavings": "Ihre Einsparungen dieses Jahr",
"monthlySummary": "Monatliche Zusammenfassung",
"yearlySummary": "Jährliche Zusammenfassung",
"total": "Gesamt",
"weeksAggregated": "{count} Wochen aggregiert",
"monthsAggregated": "{count} Monate aggregiert",
"noMonthlyData": "Noch keine monatlichen Berichte verfügbar. Wöchentliche Berichte erscheinen hier zur Aggregation.",
"noYearlyData": "Noch keine jährlichen Berichte verfügbar. Monatliche Berichte erscheinen hier zur Aggregation.",
"availableForGeneration": "Zur Generierung verfügbar",
"generateMonth": "{month} {year} generieren ({count} Wochen)",
"generateYear": "{year} generieren ({count} Monate)",
"generatingMonthly": "Wird generiert...",
"generatingYearly": "Wird generiert...",
"thisMonthWeeklyReports": "Wöchentliche Berichte dieses Monats",
"ai_analyzing": "KI analysiert...",
"ai_show_details": "Details anzeigen",
"ai_show_less": "Weniger anzeigen",

View File

@ -115,6 +115,28 @@
"confirmBatteryLogDownload": "Do you really want to download battery log?",
"downloadBatteryLogFailed": "Download battery log failed, please try again.",
"noReportData": "No report data found.",
"weeklyTab": "Weekly",
"monthlyTab": "Monthly",
"yearlyTab": "Yearly",
"monthlyReportTitle": "Monthly Performance Report",
"yearlyReportTitle": "Annual Performance Report",
"monthlyInsights": "Monthly Insights",
"yearlyInsights": "Annual Insights",
"monthlySavings": "Your Savings This Month",
"yearlySavings": "Your Savings This Year",
"monthlySummary": "Monthly Summary",
"yearlySummary": "Annual Summary",
"total": "Total",
"weeksAggregated": "{count} weeks aggregated",
"monthsAggregated": "{count} months aggregated",
"noMonthlyData": "No monthly reports available yet. Weekly reports will appear here for aggregation once generated.",
"noYearlyData": "No yearly reports available yet. Monthly reports will appear here for aggregation once generated.",
"availableForGeneration": "Available for Generation",
"generateMonth": "Generate {month} {year} ({count} weeks)",
"generateYear": "Generate {year} ({count} months)",
"generatingMonthly": "Generating...",
"generatingYearly": "Generating...",
"thisMonthWeeklyReports": "This Month's Weekly Reports",
"ai_analyzing": "AI is analyzing...",
"ai_show_details": "Show details",
"ai_show_less": "Show less",

View File

@ -127,6 +127,28 @@
"confirmBatteryLogDownload": "Voulez-vous vraiment télécharger le journal de la batterie?",
"downloadBatteryLogFailed": "Échec du téléchargement du journal de la batterie, veuillez réessayer.",
"noReportData": "Aucune donnée de rapport trouvée.",
"weeklyTab": "Hebdomadaire",
"monthlyTab": "Mensuel",
"yearlyTab": "Annuel",
"monthlyReportTitle": "Rapport de performance mensuel",
"yearlyReportTitle": "Rapport de performance annuel",
"monthlyInsights": "Aperçus mensuels",
"yearlyInsights": "Aperçus annuels",
"monthlySavings": "Vos économies ce mois",
"yearlySavings": "Vos économies cette année",
"monthlySummary": "Résumé mensuel",
"yearlySummary": "Résumé annuel",
"total": "Total",
"weeksAggregated": "{count} semaines agrégées",
"monthsAggregated": "{count} mois agrégés",
"noMonthlyData": "Aucun rapport mensuel disponible. Les rapports hebdomadaires apparaîtront ici pour l'agrégation.",
"noYearlyData": "Aucun rapport annuel disponible. Les rapports mensuels apparaîtront ici pour l'agrégation.",
"availableForGeneration": "Disponible pour génération",
"generateMonth": "Générer {month} {year} ({count} semaines)",
"generateYear": "Générer {year} ({count} mois)",
"generatingMonthly": "Génération en cours...",
"generatingYearly": "Génération en cours...",
"thisMonthWeeklyReports": "Rapports hebdomadaires de ce mois",
"ai_analyzing": "L'IA analyse...",
"ai_show_details": "Afficher les détails",
"ai_show_less": "Afficher moins",

View File

@ -138,6 +138,28 @@
"confirmBatteryLogDownload": "Vuoi davvero scaricare il registro della batteria?",
"downloadBatteryLogFailed": "Download del registro della batteria fallito, riprovare.",
"noReportData": "Nessun dato del rapporto trovato.",
"weeklyTab": "Settimanale",
"monthlyTab": "Mensile",
"yearlyTab": "Annuale",
"monthlyReportTitle": "Rapporto mensile sulle prestazioni",
"yearlyReportTitle": "Rapporto annuale sulle prestazioni",
"monthlyInsights": "Approfondimenti mensili",
"yearlyInsights": "Approfondimenti annuali",
"monthlySavings": "I tuoi risparmi questo mese",
"yearlySavings": "I tuoi risparmi quest'anno",
"monthlySummary": "Riepilogo mensile",
"yearlySummary": "Riepilogo annuale",
"total": "Totale",
"weeksAggregated": "{count} settimane aggregate",
"monthsAggregated": "{count} mesi aggregati",
"noMonthlyData": "Nessun rapporto mensile ancora disponibile. I rapporti settimanali appariranno qui per l'aggregazione.",
"noYearlyData": "Nessun rapporto annuale ancora disponibile. I rapporti mensili appariranno qui per l'aggregazione.",
"availableForGeneration": "Disponibile per la generazione",
"generateMonth": "Genera {month} {year} ({count} settimane)",
"generateYear": "Genera {year} ({count} mesi)",
"generatingMonthly": "Generazione in corso...",
"generatingYearly": "Generazione in corso...",
"thisMonthWeeklyReports": "Rapporti settimanali di questo mese",
"ai_analyzing": "L'IA sta analizzando...",
"ai_show_details": "Mostra dettagli",
"ai_show_less": "Mostra meno",