Compare commits
4 Commits
0db9406b9c
...
a86dc963b2
| Author | SHA1 | Date |
|---|---|---|
|
|
a86dc963b2 | |
|
|
35b64c3318 | |
|
|
1761914f24 | |
|
|
78b9c2dc72 |
|
|
@ -901,7 +901,8 @@ public class Controller : ControllerBase
|
||||||
/// Returns JSON with daily data, weekly totals, ratios, and AI insight.
|
/// Returns JSON with daily data, weekly totals, ratios, and AI insight.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet(nameof(GetWeeklyReport))]
|
[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;
|
var user = Db.GetSession(authToken)?.User;
|
||||||
if (user == null)
|
if (user == null)
|
||||||
|
|
@ -911,14 +912,24 @@ public class Controller : ControllerBase
|
||||||
if (installation is null || !user.HasAccessTo(installation))
|
if (installation is null || !user.HasAccessTo(installation))
|
||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
|
|
||||||
var filePath = Environment.CurrentDirectory + "/tmp_report/" + installationId + ".xlsx";
|
// Parse optional weekStart override (must be a Monday; any valid date accepted for flexibility)
|
||||||
if (!System.IO.File.Exists(filePath))
|
DateOnly? weekStartDate = null;
|
||||||
return NotFound($"No report data file found. Please place the Excel export at: tmp_report/{installationId}.xlsx");
|
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
|
try
|
||||||
{
|
{
|
||||||
var lang = language ?? user.Language ?? "en";
|
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);
|
return Ok(report);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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))]
|
[HttpPut(nameof(UpdateFolder))]
|
||||||
public ActionResult<Folder> UpdateFolder([FromBody] Folder folder, Token authToken)
|
public ActionResult<Folder> UpdateFolder([FromBody] Folder folder, Token authToken)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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; } = "";
|
||||||
|
}
|
||||||
|
|
@ -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; } = "";
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
using SQLite;
|
||||||
|
|
||||||
|
namespace InnovEnergy.App.Backend.DataTypes;
|
||||||
|
|
||||||
|
public class HourlyEnergyRecord
|
||||||
|
{
|
||||||
|
[PrimaryKey, AutoIncrement]
|
||||||
|
public Int64 Id { get; set; }
|
||||||
|
|
||||||
|
[Indexed]
|
||||||
|
public Int64 InstallationId { get; set; }
|
||||||
|
|
||||||
|
// "YYYY-MM-DD" — used for range queries (same pattern as DailyEnergyRecord)
|
||||||
|
[Indexed]
|
||||||
|
public String Date { get; set; } = "";
|
||||||
|
|
||||||
|
// 0–23
|
||||||
|
public Int32 Hour { get; set; }
|
||||||
|
|
||||||
|
// "YYYY-MM-DD HH" — used for idempotency check
|
||||||
|
public String DateHour { get; set; } = "";
|
||||||
|
|
||||||
|
public String DayOfWeek { get; set; } = "";
|
||||||
|
public Boolean IsWeekend { get; set; }
|
||||||
|
|
||||||
|
// Energy for this hour (kWh)
|
||||||
|
public Double PvKwh { get; set; }
|
||||||
|
public Double LoadKwh { get; set; }
|
||||||
|
public Double GridImportKwh { get; set; }
|
||||||
|
public Double BatteryChargedKwh { get; set; }
|
||||||
|
public Double BatteryDischargedKwh { get; set; }
|
||||||
|
|
||||||
|
// Instantaneous state of charge at snapshot time (%)
|
||||||
|
public Double BattSoC { get; set; }
|
||||||
|
|
||||||
|
public String CreatedAt { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -68,6 +68,13 @@ public static partial class Db
|
||||||
return Insert(action);
|
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)
|
public static void HandleAction(UserAction newAction)
|
||||||
{
|
{
|
||||||
//Find the total number of actions for this installation
|
//Find the total number of actions for this installation
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,12 @@ public static partial class Db
|
||||||
public static TableQuery<Error> Errors => Connection.Table<Error>();
|
public static TableQuery<Error> Errors => Connection.Table<Error>();
|
||||||
public static TableQuery<Warning> Warnings => Connection.Table<Warning>();
|
public static TableQuery<Warning> Warnings => Connection.Table<Warning>();
|
||||||
public static TableQuery<UserAction> UserActions => Connection.Table<UserAction>();
|
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()
|
public static void Init()
|
||||||
|
|
@ -51,6 +57,12 @@ public static partial class Db
|
||||||
Connection.CreateTable<Error>();
|
Connection.CreateTable<Error>();
|
||||||
Connection.CreateTable<Warning>();
|
Connection.CreateTable<Warning>();
|
||||||
Connection.CreateTable<UserAction>();
|
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
|
// 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<Error>();
|
||||||
fileConnection.CreateTable<Warning>();
|
fileConnection.CreateTable<Warning>();
|
||||||
fileConnection.CreateTable<UserAction>();
|
fileConnection.CreateTable<UserAction>();
|
||||||
|
fileConnection.CreateTable<WeeklyReportSummary>();
|
||||||
|
fileConnection.CreateTable<MonthlyReportSummary>();
|
||||||
|
fileConnection.CreateTable<YearlyReportSummary>();
|
||||||
|
fileConnection.CreateTable<DailyEnergyRecord>();
|
||||||
|
fileConnection.CreateTable<HourlyEnergyRecord>();
|
||||||
|
fileConnection.CreateTable<AiInsightCache>();
|
||||||
|
|
||||||
return fileConnection;
|
return fileConnection;
|
||||||
//return CopyDbToMemory(fileConnection);
|
//return CopyDbToMemory(fileConnection);
|
||||||
|
|
|
||||||
|
|
@ -141,4 +141,106 @@ public static partial class Db
|
||||||
{
|
{
|
||||||
OrderNumber2Installation.Delete(s => s.InstallationId == relation.InstallationId && s.OrderNumber == relation.OrderNumber);
|
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}).");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -56,4 +56,104 @@ public static partial class Db
|
||||||
|
|
||||||
return session;
|
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;
|
||||||
}
|
}
|
||||||
|
|
@ -28,6 +28,8 @@ public static class Program
|
||||||
LoadEnvFile();
|
LoadEnvFile();
|
||||||
DiagnosticService.Initialize();
|
DiagnosticService.Initialize();
|
||||||
AlarmReviewService.StartDailyScheduler();
|
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);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
RabbitMqManager.InitializeEnvironment();
|
RabbitMqManager.InitializeEnvironment();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 (Sunday–Saturday) for all SodiStoreHome
|
||||||
|
// installations every Sunday at 06:00, saves summary to DB, and
|
||||||
|
// emails the report to all users who have access to the installation.
|
||||||
|
//
|
||||||
|
// TODO: uncomment ScheduleSundayWeeklyReport() in StartScheduler() to enable.
|
||||||
|
|
||||||
|
// private static void ScheduleSundayWeeklyReport()
|
||||||
|
// {
|
||||||
|
// // Calculate delay until next Sunday 06:00
|
||||||
|
// var now = DateTime.Now;
|
||||||
|
// var daysUntil = ((int)DayOfWeek.Sunday - (int)now.DayOfWeek + 7) % 7;
|
||||||
|
// var nextSunday = now.Date.AddDays(daysUntil == 0 && now.Hour >= 6 ? 7 : daysUntil).AddHours(6);
|
||||||
|
//
|
||||||
|
// _sundayReportTimer = new Timer(
|
||||||
|
// _ =>
|
||||||
|
// {
|
||||||
|
// try
|
||||||
|
// {
|
||||||
|
// if (DateTime.Now.DayOfWeek == DayOfWeek.Sunday)
|
||||||
|
// RunSundayWeeklyReports().GetAwaiter().GetResult();
|
||||||
|
// }
|
||||||
|
// catch (Exception ex)
|
||||||
|
// {
|
||||||
|
// Console.Error.WriteLine($"[ReportAggregation] Sunday report error: {ex.Message}");
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// null, nextSunday - now, TimeSpan.FromDays(7));
|
||||||
|
//
|
||||||
|
// Console.WriteLine($"[ReportAggregation] Sunday weekly report scheduled. Next run: {nextSunday:yyyy-MM-dd HH:mm}");
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private static async Task RunSundayWeeklyReports()
|
||||||
|
// {
|
||||||
|
// Console.WriteLine("[ReportAggregation] Running Sunday weekly report generation...");
|
||||||
|
//
|
||||||
|
// // Find all SodiStoreHome installations
|
||||||
|
// var installations = Db.Installations
|
||||||
|
// .Where(i => i.Product == (int)ProductType.SodioHome)
|
||||||
|
// .ToList();
|
||||||
|
//
|
||||||
|
// foreach (var installation in installations)
|
||||||
|
// {
|
||||||
|
// try
|
||||||
|
// {
|
||||||
|
// // Generate the weekly report (covers last Sunday–Saturday)
|
||||||
|
// var report = await WeeklyReportService.GenerateReportAsync(
|
||||||
|
// installation.Id, installation.InstallationName, "en");
|
||||||
|
//
|
||||||
|
// // Save summary to DB for future monthly aggregation
|
||||||
|
// SaveWeeklySummary(installation.Id, report);
|
||||||
|
//
|
||||||
|
// // Email the report to all users who have access to this installation
|
||||||
|
// var userIds = Db.InstallationAccess
|
||||||
|
// .Where(a => a.InstallationId == installation.Id)
|
||||||
|
// .Select(a => a.UserId)
|
||||||
|
// .ToList();
|
||||||
|
//
|
||||||
|
// foreach (var userId in userIds)
|
||||||
|
// {
|
||||||
|
// var user = Db.GetUserById(userId);
|
||||||
|
// if (user == null || String.IsNullOrWhiteSpace(user.Email))
|
||||||
|
// continue;
|
||||||
|
//
|
||||||
|
// try
|
||||||
|
// {
|
||||||
|
// var lang = user.Language ?? "en";
|
||||||
|
// // Regenerate with user's language if different from "en"
|
||||||
|
// var localizedReport = lang == "en"
|
||||||
|
// ? report
|
||||||
|
// : await WeeklyReportService.GenerateReportAsync(
|
||||||
|
// installation.Id, installation.InstallationName, lang);
|
||||||
|
//
|
||||||
|
// await ReportEmailService.SendReportEmailAsync(localizedReport, user.Email, lang);
|
||||||
|
// Console.WriteLine($"[ReportAggregation] Weekly report emailed to {user.Email} for installation {installation.Id}");
|
||||||
|
// }
|
||||||
|
// catch (Exception emailEx)
|
||||||
|
// {
|
||||||
|
// Console.Error.WriteLine($"[ReportAggregation] Failed to email {user.Email}: {emailEx.Message}");
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// catch (Exception ex)
|
||||||
|
// {
|
||||||
|
// Console.Error.WriteLine($"[ReportAggregation] Failed weekly report for installation {installation.Id}: {ex.Message}");
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Console.WriteLine("[ReportAggregation] Sunday weekly report generation complete.");
|
||||||
|
// }
|
||||||
|
|
||||||
|
// ── Save Weekly Summary ───────────────────────────────────────────
|
||||||
|
|
||||||
|
public static void SaveWeeklySummary(Int64 installationId, WeeklyReportResponse report, 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.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -217,10 +217,10 @@ public static class ReportEmailService
|
||||||
var cur = r.CurrentWeek;
|
var cur = r.CurrentWeek;
|
||||||
var prev = r.PreviousWeek;
|
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
|
var insightLines = r.AiInsight
|
||||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
.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)
|
.Where(l => l.Length > 0)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
|
@ -443,4 +443,230 @@ public static class ReportEmailService
|
||||||
|
|
||||||
private static string ChangeColor(double pct) =>
|
private static string ChangeColor(double pct) =>
|
||||||
pct > 0 ? "#27ae60" : pct < 0 ? "#e74c3c" : "#888";
|
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>";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Flurl.Http;
|
using Flurl.Http;
|
||||||
|
using InnovEnergy.App.Backend.Database;
|
||||||
using InnovEnergy.App.Backend.DataTypes;
|
using InnovEnergy.App.Backend.DataTypes;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
|
@ -8,88 +9,145 @@ public static class WeeklyReportService
|
||||||
{
|
{
|
||||||
private static readonly string TmpReportDir = Environment.CurrentDirectory + "/tmp_report/";
|
private static readonly string TmpReportDir = Environment.CurrentDirectory + "/tmp_report/";
|
||||||
|
|
||||||
|
// ── Calendar Week Helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the last completed calendar week (Mon–Sun).
|
||||||
|
/// Example: called on Wednesday 2026-03-11 → returns (2026-03-02, 2026-03-08).
|
||||||
|
/// </summary>
|
||||||
|
public static (DateOnly Monday, DateOnly Sunday) LastCalendarWeek()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
// DayOfWeek: Sun=0, Mon=1, ..., Sat=6
|
||||||
|
// daysSinceMonday: Mon=0, Tue=1, ..., Sun=6
|
||||||
|
var daysSinceMonday = ((Int32)today.DayOfWeek + 6) % 7;
|
||||||
|
var thisMonday = today.AddDays(-daysSinceMonday);
|
||||||
|
var lastMonday = thisMonday.AddDays(-7);
|
||||||
|
var lastSunday = thisMonday.AddDays(-1);
|
||||||
|
return (lastMonday, lastSunday);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the calendar week before last (for comparison).
|
||||||
|
/// </summary>
|
||||||
|
private static (DateOnly Monday, DateOnly Sunday) PreviousCalendarWeek()
|
||||||
|
{
|
||||||
|
var (lastMon, _) = LastCalendarWeek();
|
||||||
|
return (lastMon.AddDays(-7), lastMon.AddDays(-1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Report Generation ──────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generates a full weekly report for the given installation.
|
/// Generates a full weekly report for the given installation.
|
||||||
/// Cache is invalidated automatically when the xlsx file is newer than the cache.
|
/// Data source priority:
|
||||||
/// To force regeneration (e.g. after a prompt change), simply delete the cache files.
|
/// 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>
|
/// </summary>
|
||||||
public static async Task<WeeklyReportResponse> GenerateReportAsync(long installationId, string installationName, string language = "en")
|
public static async Task<WeeklyReportResponse> GenerateReportAsync(
|
||||||
|
long installationId, string installationName, string language = "en",
|
||||||
|
DateOnly? weekStartOverride = null)
|
||||||
{
|
{
|
||||||
var xlsxPath = TmpReportDir + installationId + ".xlsx";
|
DateOnly curMon, curSun, prevMon, prevSun;
|
||||||
var cachePath = TmpReportDir + $"{installationId}_{language}.cache.json";
|
|
||||||
|
|
||||||
// Use cached report if xlsx hasn't changed since cache was written
|
if (weekStartOverride.HasValue)
|
||||||
if (File.Exists(cachePath) && File.Exists(xlsxPath))
|
|
||||||
{
|
{
|
||||||
var xlsxModified = File.GetLastWriteTimeUtc(xlsxPath);
|
// Debug/backfill mode: use the provided Monday as the week start
|
||||||
var cacheModified = File.GetLastWriteTimeUtc(cachePath);
|
curMon = weekStartOverride.Value;
|
||||||
if (cacheModified > xlsxModified)
|
curSun = curMon.AddDays(6);
|
||||||
{
|
prevMon = curMon.AddDays(-7);
|
||||||
try
|
prevSun = curMon.AddDays(-1);
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Core report generation. Accepts both daily summaries and hourly intervals.
|
|
||||||
/// </summary>
|
|
||||||
public static async Task<WeeklyReportResponse> GenerateReportFromDataAsync(
|
|
||||||
List<DailyEnergyData> allDays,
|
|
||||||
List<HourlyEnergyData> allHourly,
|
|
||||||
string installationName,
|
|
||||||
string language = "en")
|
|
||||||
{
|
|
||||||
// Sort by date
|
|
||||||
allDays = allDays.OrderBy(d => d.Date).ToList();
|
|
||||||
|
|
||||||
// Split into previous week and current week (daily)
|
|
||||||
List<DailyEnergyData> previousWeekDays;
|
|
||||||
List<DailyEnergyData> currentWeekDays;
|
|
||||||
|
|
||||||
if (allDays.Count > 7)
|
|
||||||
{
|
|
||||||
previousWeekDays = allDays.Take(allDays.Count - 7).ToList();
|
|
||||||
currentWeekDays = allDays.Skip(allDays.Count - 7).ToList();
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
previousWeekDays = new List<DailyEnergyData>();
|
(curMon, curSun) = LastCalendarWeek();
|
||||||
currentWeekDays = allDays;
|
(prevMon, prevSun) = PreviousCalendarWeek();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restrict hourly data to current week only for behavioral analysis
|
// 1. Load daily records from SQLite
|
||||||
var currentWeekStart = DateTime.Parse(currentWeekDays.First().Date);
|
var currentWeekDays = Db.GetDailyRecords(installationId, curMon, curSun)
|
||||||
var currentHourlyData = allHourly.Where(h => h.DateTime.Date >= currentWeekStart.Date).ToList();
|
.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 currentSummary = Summarize(currentWeekDays);
|
||||||
var previousSummary = previousWeekDays.Count > 0 ? Summarize(previousWeekDays) : null;
|
var previousSummary = previousWeekDays.Count > 0 ? Summarize(previousWeekDays) : null;
|
||||||
|
|
@ -149,7 +207,7 @@ public static class WeeklyReportService
|
||||||
PvChangePercent = pvChange,
|
PvChangePercent = pvChange,
|
||||||
ConsumptionChangePercent = consumptionChange,
|
ConsumptionChangePercent = consumptionChange,
|
||||||
GridImportChangePercent = gridImportChange,
|
GridImportChangePercent = gridImportChange,
|
||||||
DailyData = allDays,
|
DailyData = currentWeekDays,
|
||||||
Behavior = behavior,
|
Behavior = behavior,
|
||||||
AiInsight = aiInsight,
|
AiInsight = aiInsight,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -133,6 +133,28 @@
|
||||||
"confirmBatteryLogDownload": "Möchten Sie das Batterieprotokoll wirklich herunterladen?",
|
"confirmBatteryLogDownload": "Möchten Sie das Batterieprotokoll wirklich herunterladen?",
|
||||||
"downloadBatteryLogFailed": "Herunterladen des Batterieprotokolls fehlgeschlagen, bitte versuchen Sie es erneut.",
|
"downloadBatteryLogFailed": "Herunterladen des Batterieprotokolls fehlgeschlagen, bitte versuchen Sie es erneut.",
|
||||||
"noReportData": "Keine Berichtsdaten gefunden.",
|
"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_analyzing": "KI analysiert...",
|
||||||
"ai_show_details": "Details anzeigen",
|
"ai_show_details": "Details anzeigen",
|
||||||
"ai_show_less": "Weniger anzeigen",
|
"ai_show_less": "Weniger anzeigen",
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,28 @@
|
||||||
"confirmBatteryLogDownload": "Do you really want to download battery log?",
|
"confirmBatteryLogDownload": "Do you really want to download battery log?",
|
||||||
"downloadBatteryLogFailed": "Download battery log failed, please try again.",
|
"downloadBatteryLogFailed": "Download battery log failed, please try again.",
|
||||||
"noReportData": "No report data found.",
|
"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_analyzing": "AI is analyzing...",
|
||||||
"ai_show_details": "Show details",
|
"ai_show_details": "Show details",
|
||||||
"ai_show_less": "Show less",
|
"ai_show_less": "Show less",
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,28 @@
|
||||||
"confirmBatteryLogDownload": "Voulez-vous vraiment télécharger le journal de la batterie?",
|
"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.",
|
"downloadBatteryLogFailed": "Échec du téléchargement du journal de la batterie, veuillez réessayer.",
|
||||||
"noReportData": "Aucune donnée de rapport trouvée.",
|
"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_analyzing": "L'IA analyse...",
|
||||||
"ai_show_details": "Afficher les détails",
|
"ai_show_details": "Afficher les détails",
|
||||||
"ai_show_less": "Afficher moins",
|
"ai_show_less": "Afficher moins",
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,28 @@
|
||||||
"confirmBatteryLogDownload": "Vuoi davvero scaricare il registro della batteria?",
|
"confirmBatteryLogDownload": "Vuoi davvero scaricare il registro della batteria?",
|
||||||
"downloadBatteryLogFailed": "Download del registro della batteria fallito, riprovare.",
|
"downloadBatteryLogFailed": "Download del registro della batteria fallito, riprovare.",
|
||||||
"noReportData": "Nessun dato del rapporto trovato.",
|
"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_analyzing": "L'IA sta analizzando...",
|
||||||
"ai_show_details": "Mostra dettagli",
|
"ai_show_details": "Mostra dettagli",
|
||||||
"ai_show_less": "Mostra meno",
|
"ai_show_less": "Mostra meno",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue