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

341 lines
17 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.DataTypes;
using Newtonsoft.Json;
namespace InnovEnergy.App.Backend.Services;
public static class WeeklyReportService
{
private static readonly string TmpReportDir = Environment.CurrentDirectory + "/tmp_report/";
/// <summary>
/// Generates a full weekly report for the given installation.
/// Cache is invalidated automatically when the xlsx file is newer than the cache.
/// To force regeneration (e.g. after a prompt change), simply delete the cache files.
/// </summary>
public static async Task<WeeklyReportResponse> GenerateReportAsync(long installationId, string installationName, string language = "en")
{
var xlsxPath = TmpReportDir + installationId + ".xlsx";
var cachePath = TmpReportDir + $"{installationId}_{language}.cache.json";
// Use cached report if xlsx hasn't changed since cache was written
if (File.Exists(cachePath) && File.Exists(xlsxPath))
{
var xlsxModified = File.GetLastWriteTimeUtc(xlsxPath);
var cacheModified = File.GetLastWriteTimeUtc(cachePath);
if (cacheModified > xlsxModified)
{
try
{
var cached = JsonConvert.DeserializeObject<WeeklyReportResponse>(
await File.ReadAllTextAsync(cachePath));
if (cached != null)
{
Console.WriteLine($"[WeeklyReportService] Returning cached report for installation {installationId} ({language}).");
return cached;
}
}
catch
{
// Cache corrupt — regenerate
}
}
}
// Parse both daily summaries and hourly intervals from the same xlsx
var allDays = ExcelDataParser.Parse(xlsxPath);
var allHourly = ExcelDataParser.ParseHourly(xlsxPath);
var report = await GenerateReportFromDataAsync(allDays, allHourly, installationName, language);
// Write cache
try
{
await File.WriteAllTextAsync(cachePath, JsonConvert.SerializeObject(report));
}
catch (Exception ex)
{
Console.Error.WriteLine($"[WeeklyReportService] Could not write cache: {ex.Message}");
}
return report;
}
/// <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
{
previousWeekDays = new List<DailyEnergyData>();
currentWeekDays = allDays;
}
// Restrict hourly data to current week only for behavioral analysis
var currentWeekStart = DateTime.Parse(currentWeekDays.First().Date);
var currentHourlyData = allHourly.Where(h => h.DateTime.Date >= currentWeekStart.Date).ToList();
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 = allDays,
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.";
}
}