Innovenergy_trunk/csharp/App/Backend/Services/WeeklyReportService.cs

433 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using Flurl.Http;
using InnovEnergy.App.Backend.Database;
using InnovEnergy.App.Backend.DataTypes;
using Newtonsoft.Json;
namespace InnovEnergy.App.Backend.Services;
public static class WeeklyReportService
{
private static readonly string TmpReportDir = Environment.CurrentDirectory + "/tmp_report/";
// ── Calendar Week Helpers ──────────────────────────────────────────
/// <summary>
/// Returns the last completed calendar week (MonSun).
/// 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>
/// Generates a full weekly report for the given installation.
/// Data source priority:
/// 1. DailyEnergyRecord rows in SQLite (populated by DailyIngestionService)
/// 2. xlsx file fallback (if DB not yet populated for the target week)
/// Cache is keyed to the calendar week — invalidated when the week changes.
/// Pass weekStartOverride to generate a report for a specific historical week (disables cache).
/// </summary>
public static async Task<WeeklyReportResponse> GenerateReportAsync(
long installationId, string installationName, string language = "en",
DateOnly? weekStartOverride = null)
{
DateOnly curMon, curSun, prevMon, prevSun;
if (weekStartOverride.HasValue)
{
// Debug/backfill mode: use the provided Monday as the week start
curMon = weekStartOverride.Value;
curSun = curMon.AddDays(6);
prevMon = curMon.AddDays(-7);
prevSun = curMon.AddDays(-1);
}
else
{
(curMon, curSun) = LastCalendarWeek();
(prevMon, prevSun) = PreviousCalendarWeek();
}
// Skip file cache when a specific week is requested (avoid stale or wrong-week hits)
var cachePath = weekStartOverride.HasValue
? null
: TmpReportDir + $"{installationId}_{language}_{curMon:yyyy-MM-dd}.cache.json";
// Use cache if it exists and is less than 6 hours old (skipped in override mode)
if (cachePath != null && File.Exists(cachePath))
{
var cacheAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(cachePath);
if (cacheAge.TotalHours < 6)
{
try
{
var cached = JsonConvert.DeserializeObject<WeeklyReportResponse>(
await File.ReadAllTextAsync(cachePath));
if (cached != null)
{
Console.WriteLine($"[WeeklyReportService] Returning cached report for installation {installationId} ({language}), week {curMon:yyyy-MM-dd}.");
return cached;
}
}
catch
{
// Cache corrupt — regenerate
}
}
}
// 1. Try to load daily records from SQLite for the calendar weeks
var currentWeekDays = Db.GetDailyRecords(installationId, curMon, curSun)
.Select(r => ToDailyEnergyData(r)).ToList();
var previousWeekDays = Db.GetDailyRecords(installationId, prevMon, prevSun)
.Select(r => ToDailyEnergyData(r)).ToList();
// 2. Fallback: if DB empty for current week, parse all xlsx files on the fly
var xlsxFiles = Directory.Exists(TmpReportDir)
? Directory.GetFiles(TmpReportDir, $"{installationId}*.xlsx").OrderBy(f => f).ToList()
: new List<String>();
if (currentWeekDays.Count == 0 && 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 data from ALL xlsx files for behavioral analysis (current week only).
// Combine all files so any week can find its hourly data regardless of file split.
// Future: replace with S3 hourly fetch.
var allHourly = xlsxFiles
.SelectMany(p => { try { return ExcelDataParser.ParseHourly(p); } catch { return Enumerable.Empty<HourlyEnergyData>(); } })
.ToList();
var curMonDt = curMon.ToDateTime(TimeOnly.MinValue);
var curSunDt = curSun.ToDateTime(TimeOnly.MaxValue);
var currentHourlyData = allHourly
.Where(h => h.DateTime >= curMonDt && h.DateTime <= curSunDt)
.ToList();
var report = await GenerateReportFromDataAsync(
currentWeekDays, previousWeekDays, currentHourlyData, installationName, language,
curMon, curSun);
// Write cache (skipped in override mode)
if (cachePath != null)
{
try
{
await File.WriteAllTextAsync(cachePath, JsonConvert.SerializeObject(report));
}
catch (Exception ex)
{
Console.Error.WriteLine($"[WeeklyReportService] Could not write cache: {ex.Message}");
}
}
return report;
}
// ── Conversion helpers ─────────────────────────────────────────────
private static DailyEnergyData ToDailyEnergyData(DailyEnergyRecord r) => new()
{
Date = r.Date,
PvProduction = r.PvProduction,
LoadConsumption = r.LoadConsumption,
GridImport = r.GridImport,
GridExport = r.GridExport,
BatteryCharged = r.BatteryCharged,
BatteryDischarged = r.BatteryDischarged,
};
/// <summary>
/// Core report generation. Accepts pre-split current/previous week data and hourly intervals.
/// weekStart/weekEnd are used to set PeriodStart/PeriodEnd on the response.
/// </summary>
public static async Task<WeeklyReportResponse> GenerateReportFromDataAsync(
List<DailyEnergyData> currentWeekDays,
List<DailyEnergyData> previousWeekDays,
List<HourlyEnergyData> currentHourlyData,
string installationName,
string language = "en",
DateOnly? weekStart = null,
DateOnly? weekEnd = null)
{
currentWeekDays = currentWeekDays.OrderBy(d => d.Date).ToList();
previousWeekDays = previousWeekDays.OrderBy(d => d.Date).ToList();
var currentSummary = Summarize(currentWeekDays);
var previousSummary = previousWeekDays.Count > 0 ? Summarize(previousWeekDays) : null;
// Key ratios for current week
var selfSufficiency = currentSummary.TotalConsumption > 0
? Math.Round((currentSummary.TotalConsumption - currentSummary.TotalGridImport) / currentSummary.TotalConsumption * 100, 1)
: 0;
var selfConsumption = currentSummary.TotalPvProduction > 0
? Math.Round((currentSummary.TotalPvProduction - currentSummary.TotalGridExport) / currentSummary.TotalPvProduction * 100, 1)
: 0;
var batteryEfficiency = currentSummary.TotalBatteryCharged > 0
? Math.Round(currentSummary.TotalBatteryDischarged / currentSummary.TotalBatteryCharged * 100, 1)
: 0;
var gridDependency = currentSummary.TotalConsumption > 0
? Math.Round(currentSummary.TotalGridImport / currentSummary.TotalConsumption * 100, 1)
: 0;
// Week-over-week changes
var pvChange = PercentChange(previousSummary?.TotalPvProduction, currentSummary.TotalPvProduction);
var consumptionChange = PercentChange(previousSummary?.TotalConsumption, currentSummary.TotalConsumption);
var gridImportChange = PercentChange(previousSummary?.TotalGridImport, currentSummary.TotalGridImport);
// Behavioral pattern from hourly data (pure C# — no AI)
var behavior = BehaviorAnalyzer.Analyze(currentHourlyData);
// Pre-computed savings — single source of truth for UI and AI
const double ElectricityPriceCHF = 0.39;
var totalEnergySaved = Math.Round(currentSummary.TotalConsumption - currentSummary.TotalGridImport, 1);
var totalSavingsCHF = Math.Round(totalEnergySaved * ElectricityPriceCHF, 0);
var avgDailyConsumption = currentWeekDays.Count > 0 ? currentSummary.TotalConsumption / currentWeekDays.Count : 0;
var daysEquivalent = avgDailyConsumption > 0 ? Math.Round(totalEnergySaved / avgDailyConsumption, 1) : 0;
// AI insight combining daily facts + behavioral pattern
var aiInsight = await GetAiInsightAsync(
currentWeekDays, currentSummary, previousSummary,
selfSufficiency, totalEnergySaved, totalSavingsCHF,
behavior, installationName, language);
return new WeeklyReportResponse
{
InstallationName = installationName,
PeriodStart = currentWeekDays.First().Date,
PeriodEnd = currentWeekDays.Last().Date,
CurrentWeek = currentSummary,
PreviousWeek = previousSummary,
TotalEnergySaved = totalEnergySaved,
TotalSavingsCHF = totalSavingsCHF,
DaysEquivalent = daysEquivalent,
SelfSufficiencyPercent = selfSufficiency,
SelfConsumptionPercent = selfConsumption,
BatteryEfficiencyPercent = batteryEfficiency,
GridDependencyPercent = gridDependency,
PvChangePercent = pvChange,
ConsumptionChangePercent = consumptionChange,
GridImportChangePercent = gridImportChange,
DailyData = currentWeekDays,
Behavior = behavior,
AiInsight = aiInsight,
};
}
private static WeeklySummary Summarize(List<DailyEnergyData> days) => new()
{
TotalPvProduction = Math.Round(days.Sum(d => d.PvProduction), 1),
TotalConsumption = Math.Round(days.Sum(d => d.LoadConsumption), 1),
TotalGridImport = Math.Round(days.Sum(d => d.GridImport), 1),
TotalGridExport = Math.Round(days.Sum(d => d.GridExport), 1),
TotalBatteryCharged = Math.Round(days.Sum(d => d.BatteryCharged), 1),
TotalBatteryDischarged = Math.Round(days.Sum(d => d.BatteryDischarged), 1),
};
private static double PercentChange(double? previous, double current)
{
if (previous is null or 0) return 0;
return Math.Round((current - previous.Value) / previous.Value * 100, 1);
}
// ── Mistral AI Insight ──────────────────────────────────────────────
private static readonly string MistralUrl = "https://api.mistral.ai/v1/chat/completions";
private static string LanguageName(string code) => code switch
{
"de" => "German",
"fr" => "French",
"it" => "Italian",
_ => "English"
};
private static string FormatHour(int hour) => $"{hour:D2}:00";
private static string FormatHourSlot(int hour) => $"{hour:D2}:00{hour + 1:D2}:00";
private static async Task<string> GetAiInsightAsync(
List<DailyEnergyData> currentWeek,
WeeklySummary current,
WeeklySummary? previous,
double selfSufficiency,
double totalEnergySaved,
double totalSavingsCHF,
BehavioralPattern behavior,
string installationName,
string language = "en")
{
var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY");
if (string.IsNullOrWhiteSpace(apiKey))
{
Console.WriteLine("[WeeklyReportService] MISTRAL_API_KEY not set — skipping AI insight.");
return "AI insight unavailable (API key not configured).";
}
const double ElectricityPriceCHF = 0.39;
// Detect which components are present
var hasPv = currentWeek.Sum(d => d.PvProduction) > 0.5;
var hasBattery = currentWeek.Sum(d => d.BatteryCharged) > 0.5
|| currentWeek.Sum(d => d.BatteryDischarged) > 0.5;
var hasGrid = currentWeek.Sum(d => d.GridImport) > 0.5;
var bestDay = currentWeek.OrderByDescending(d => d.PvProduction).First();
var worstDay = currentWeek.OrderBy(d => d.PvProduction).First();
var bestDayName = DateTime.Parse(bestDay.Date).ToString("dddd");
var worstDayName = DateTime.Parse(worstDay.Date).ToString("dddd");
var topBattDay = currentWeek.OrderByDescending(d => d.BatteryCharged).First();
var topBattDayName = DateTime.Parse(topBattDay.Date).ToString("dddd");
// Behavioral facts as compact lines
var peakSolarWindow = FormatHour(behavior.PeakSolarHour) + "" + FormatHour(behavior.PeakSolarEndHour);
var avoidableSavingsCHF = Math.Round(behavior.AvoidableGridKwh * ElectricityPriceCHF, 0);
var battDepleteLine = hasBattery
? (behavior.AvgBatteryDepletedHour >= 0
? $"Battery typically depletes below 20% during {FormatHourSlot(behavior.AvgBatteryDepletedHour)}."
: "Battery stayed above 20% SoC every night this week.")
: "";
var weekdayWeekendLine = behavior.WeekendAvgDailyLoad > 0
? $"Weekday avg load: {behavior.WeekdayAvgDailyLoad} kWh/day. Weekend avg: {behavior.WeekendAvgDailyLoad} kWh/day."
: $"Weekday avg load: {behavior.WeekdayAvgDailyLoad} kWh/day.";
// Build conditional fact lines
var pvDailyFact = hasPv
? $"- PV: total {current.TotalPvProduction:F1} kWh this week. Best day: {bestDayName} ({bestDay.PvProduction:F1} kWh), worst: {worstDayName} ({worstDay.PvProduction:F1} kWh). Solar covered {selfSufficiency}% of consumption."
: "";
var battDailyFact = hasBattery
? $"- Battery: {current.TotalBatteryCharged:F1} kWh charged, {current.TotalBatteryDischarged:F1} kWh discharged. Most active day: {topBattDayName} ({topBattDay.BatteryCharged:F1} kWh charged)."
: "";
var gridDailyFact = hasGrid
? $"- Grid import: {current.TotalGridImport:F1} kWh total this week."
: "";
var pvBehaviorLines = hasPv ? $@"
- Solar active window: {peakSolarWindow}; peak hour: {FormatHourSlot(behavior.PeakSolarHour)}, avg {behavior.AvgPeakSolarKwh} kWh during that hour
- Grid imported while solar was active: {behavior.AvoidableGridKwh} kWh = {avoidableSavingsCHF} CHF that could have been avoided" : "";
var gridBehaviorLine = hasGrid
? $"- Highest grid-import hour: {FormatHourSlot(behavior.HighestGridImportHour)}, avg {behavior.AvgGridImportAtPeakHour} kWh during that hour"
: "";
var battBehaviorLine = !string.IsNullOrEmpty(battDepleteLine) ? $"- {battDepleteLine}" : "";
// Build conditional instructions
var instruction1 = $"1. Energy savings: Write 12 sentences. Say that this week, thanks to sodistore home, the customer avoided buying {totalEnergySaved} kWh from the grid, saving {totalSavingsCHF} CHF (at {ElectricityPriceCHF} CHF/kWh). Use these exact numbers — do not recalculate or change them.";
var instruction2 = hasPv
? $"2. Solar performance: Comment on the best and worst solar day this week and the likely weather reason."
: hasGrid
? $"2. Grid usage: Comment on the {current.TotalGridImport:F1} kWh drawn from the grid this week and what time of day drives it most ({FormatHourSlot(behavior.HighestGridImportHour)})."
: "2. Consumption pattern: Comment on the weekday vs weekend load pattern.";
var instruction3 = hasBattery
? $"3. Battery performance: Use the daily facts. Keep it simple for a homeowner."
: "3. Consumption pattern: Comment on the peak load time and weekday vs weekend usage.";
var instruction4 = hasPv
? $"4. Smart action for next week: Write exactly 2 sentences. Sentence 1: point out the timing mismatch using exact numbers — peak household load is during {FormatHourSlot(behavior.PeakLoadHour)} ({behavior.AvgPeakLoadKwh} kWh) but solar peaks during {FormatHourSlot(behavior.PeakSolarHour)} ({behavior.AvgPeakSolarKwh} kWh), with solar active from {peakSolarWindow}. Sentence 2: suggest shifting energy-intensive appliances (such as washing machine, dishwasher, heat pump, or EV charger if applicable) to run during the solar window {peakSolarWindow} — do not assume which specific device the customer has."
: hasGrid
? $"4. Smart action for next week: Write exactly 2 sentences. Sentence 1: state that the peak grid-import hour is {FormatHourSlot(behavior.HighestGridImportHour)} ({behavior.AvgGridImportAtPeakHour} kWh avg). Sentence 2: suggest one action to reduce grid use during that hour — shifting energy-intensive appliances (washing machine, dishwasher, heat pump, EV charger) away from that time."
: "4. Smart action for next week: Give one practical tip to reduce energy consumption based on the peak load time and weekday/weekend pattern.";
var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"".
Write 4 bullet points (each on its own line starting with ""- ""). No bold markers, no asterisks, no markdown — plain text only.
IMPORTANT FORMAT RULE: Each bullet MUST start with a short title followed by a colon, then the description. Example: ""- Title label: Description text here."" Translate the title label into {LanguageName(language)} but always keep the ""Title: description"" structure.
CRITICAL: All numbers below are pre-calculated. Use these values as-is — do not recalculate, round differently, or change any number.
SYSTEM COMPONENTS: PV={hasPv}, Battery={hasBattery}, Grid={hasGrid}
DAILY FACTS:
- Total consumption: {current.TotalConsumption:F1} kWh this week. Self-sufficiency: {selfSufficiency}%.
{pvDailyFact}
{battDailyFact}
{gridDailyFact}
BEHAVIORAL PATTERN (from hourly data this week):
- Peak household load: {FormatHourSlot(behavior.PeakLoadHour)}, avg {behavior.AvgPeakLoadKwh} kWh during that hour
- {weekdayWeekendLine}{pvBehaviorLines}
{gridBehaviorLine}
{battBehaviorLine}
INSTRUCTIONS:
{instruction1}
{instruction2}
{instruction3}
{instruction4}
Rules: Write for a homeowner, not an engineer. Do NOT use asterisks or any formatting marks. Only describe components that exist (PV={hasPv}, Battery={hasBattery}, Grid={hasGrid}). Do NOT add any closing remark, summary sentence, or motivational phrase after the 4 bullet points (e.g. no 'Das ist ein guter Fortschritt', 'Keep it up', 'Good progress', etc.). Write exactly 4 bullet points — nothing before, nothing after.
IMPORTANT: Write your entire response in {LanguageName(language)}.";
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))
{
var insight = content.Trim();
Console.WriteLine($"[WeeklyReportService] AI insight generated ({insight.Length} chars).");
return insight;
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[WeeklyReportService] Mistral error: {ex.Message}");
}
return "AI insight could not be generated at this time.";
}
}