290 lines
14 KiB
C#
290 lines
14 KiB
C#
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.27;
|
||
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 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.27;
|
||
|
||
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 = behavior.AvgBatteryDepletedHour >= 0
|
||
? $"Battery typically depletes below 20% around {FormatHour(behavior.AvgBatteryDepletedHour)}."
|
||
: "Battery SoC data not available.";
|
||
|
||
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.";
|
||
|
||
var prompt = $@"You are an energy advisor for a SodistoreHome 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.
|
||
|
||
DAILY FACTS:
|
||
- Total energy saved: {totalEnergySaved} kWh (solar + battery), saving {totalSavingsCHF} CHF at {ElectricityPriceCHF} CHF/kWh. Self-sufficient {selfSufficiency}% of the time.
|
||
- Best solar day: {bestDayName} with {bestDay.PvProduction:F1} kWh. Worst: {worstDayName} with {worstDay.PvProduction:F1} kWh.
|
||
- Battery: {current.TotalBatteryCharged:F1} kWh charged, {current.TotalBatteryDischarged:F1} kWh discharged. Most active day: {topBattDayName} ({topBattDay.BatteryCharged:F1} kWh charged).
|
||
|
||
BEHAVIORAL PATTERN (from hourly data this week):
|
||
- Peak household load: {FormatHour(behavior.PeakLoadHour)} avg {behavior.AvgPeakLoadKwh} kWh/hr
|
||
- Peak solar window: {peakSolarWindow}, avg {behavior.AvgPeakSolarKwh} kWh/hr
|
||
- Grid imported while solar was active this week: {behavior.AvoidableGridKwh} kWh total = {avoidableSavingsCHF} CHF that could have been avoided
|
||
- Highest single grid-import hour: {FormatHour(behavior.HighestGridImportHour)}, avg {behavior.AvgGridImportAtPeakHour} kWh/hr
|
||
- {weekdayWeekendLine}
|
||
- {battDepleteLine}
|
||
|
||
INSTRUCTIONS:
|
||
1. Energy savings: Use the daily facts. State {totalEnergySaved} kWh and {totalSavingsCHF} CHF. Use these exact numbers — do not recalculate or substitute any of them.
|
||
2. Best vs worst solar day: Use the daily facts. Mention likely weather reason.
|
||
3. Battery performance: Use the daily facts. Keep it simple for a homeowner.
|
||
4. Smart action for next week: Write exactly 2 sentences. Sentence 1: state the pattern using these exact numbers — peak load at {FormatHour(behavior.PeakLoadHour)} ({behavior.AvgPeakLoadKwh} kWh/hr) vs solar peak at {peakSolarWindow} ({behavior.AvgPeakSolarKwh} kWh/hr), and that {behavior.AvoidableGridKwh} kWh ({avoidableSavingsCHF} CHF) was drawn from the grid while solar was active. Sentence 2: give ONE concrete action (what appliance to shift, to which hours) and state it would recover the {avoidableSavingsCHF} CHF. Use all these exact numbers — do not substitute or omit any.
|
||
|
||
Rules: Write for a homeowner, not an engineer. Do NOT use asterisks or any formatting marks.
|
||
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.";
|
||
}
|
||
}
|