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

257 lines
11 KiB
C#

using System.Collections.Concurrent;
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/";
private static readonly ConcurrentDictionary<string, string> InsightCache = new();
// Bump this version when the AI prompt changes to automatically invalidate old cache files
private const string CacheVersion = "v2";
/// <summary>
/// Generates a full weekly report for the given installation.
/// Caches the full report as JSON next to the xlsx. Cache is invalidated when xlsx is updated or CacheVersion changes.
/// </summary>
public static async Task<WeeklyReportResponse> GenerateReportAsync(long installationId, string installationName, string language = "en")
{
var xlsxPath = TmpReportDir + installationId + ".xlsx";
var cachePath = TmpReportDir + $"{installationId}_{language}_{CacheVersion}.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
}
}
}
var allDays = ExcelDataParser.Parse(xlsxPath);
var report = await GenerateReportFromDataAsync(allDays, 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 from daily data. Data-source agnostic.
/// </summary>
public static async Task<WeeklyReportResponse> GenerateReportFromDataAsync(
List<DailyEnergyData> allDays, string installationName, string language = "en")
{
// Sort by date
allDays = allDays.OrderBy(d => d.Date).ToList();
// Split into previous week and current week
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;
}
var currentSummary = Summarize(currentWeekDays);
var previousSummary = previousWeekDays.Count > 0 ? Summarize(previousWeekDays) : null;
// Calculate 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);
// AI insight
var aiInsight = await GetAiInsightAsync(currentWeekDays, currentSummary, previousSummary,
selfSufficiency, gridDependency, batteryEfficiency, installationName, language);
return new WeeklyReportResponse
{
InstallationName = installationName,
PeriodStart = currentWeekDays.First().Date,
PeriodEnd = currentWeekDays.Last().Date,
CurrentWeek = currentSummary,
PreviousWeek = previousSummary,
SelfSufficiencyPercent = selfSufficiency,
SelfConsumptionPercent = selfConsumption,
BatteryEfficiencyPercent = batteryEfficiency,
GridDependencyPercent = gridDependency,
PvChangePercent = pvChange,
ConsumptionChangePercent = consumptionChange,
GridImportChangePercent = gridImportChange,
DailyData = allDays,
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 async Task<string> GetAiInsightAsync(
List<DailyEnergyData> currentWeek,
WeeklySummary current,
WeeklySummary? previous,
double selfSufficiency,
double gridDependency,
double batteryEfficiency,
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).";
}
// Cache key: installation + period + language
var cacheKey = $"{installationName}_{currentWeek.Last().Date}_{language}";
if (InsightCache.TryGetValue(cacheKey, out var cached))
return cached;
// Build compact prompt
var dayLines = string.Join("\n", currentWeek.Select(d =>
{
var dayName = DateTime.Parse(d.Date).ToString("ddd");
return $"{dayName} {d.Date}: PV={d.PvProduction:F1} Load={d.LoadConsumption:F1} GridIn={d.GridImport:F1} GridOut={d.GridExport:F1} BattIn={d.BatteryCharged:F1} BattOut={d.BatteryDischarged:F1}";
}));
var comparison = previous != null
? $"vs Last week: PV {current.TotalPvProduction} vs {previous.TotalPvProduction}, Grid Import {current.TotalGridImport} vs {previous.TotalGridImport}, Consumption {current.TotalConsumption} vs {previous.TotalConsumption}"
: "No previous week data available.";
var solarSavings = Math.Round(current.TotalPvProduction - current.TotalGridExport, 1);
var prompt = $@"You are an energy advisor for a SodistoreHome installation: ""{installationName}"".
Write exactly 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.
1. Solar savings: this week the system saved {solarSavings} kWh from the grid. Explain what this means in simple terms (e.g. equivalent to X days of average household use, or roughly X CHF saved at ~0.27 CHF/kWh).
2. Best vs worst solar day: name the best and worst days with their PV kWh values. Mention likely weather reason.
3. Battery performance: was the battery well-utilized this week? Mention charge/discharge totals and any standout days.
4. Tip of the week: one specific, practical recommendation based on THIS week's patterns to save more energy or money.
Rules: Use actual day names and numbers. Keep each bullet to 1-2 sentences. Write for a homeowner, not an engineer. Do NOT use asterisks or any formatting marks.
IMPORTANT: Write your entire response in {LanguageName(language)}.
Daily data (kWh):
{dayLines}
Totals: PV={current.TotalPvProduction:F1} Load={current.TotalConsumption:F1} GridIn={current.TotalGridImport:F1} GridOut={current.TotalGridExport:F1} BattIn={current.TotalBatteryCharged:F1} BattOut={current.TotalBatteryDischarged:F1}
Solar used at home={solarSavings} kWh ({selfSufficiency}% of consumption covered by solar)
Battery-eff={batteryEfficiency}%
{comparison}";
try
{
var requestBody = new
{
model = "mistral-small-latest",
messages = new[] { new { role = "user", content = prompt } },
max_tokens = 350,
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();
InsightCache.TryAdd(cacheKey, insight);
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.";
}
}