242 lines
11 KiB
C#
242 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();
|
|
|
|
/// <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.
|
|
/// </summary>
|
|
public static async Task<WeeklyReportResponse> GenerateReportAsync(long installationId, string installationName)
|
|
{
|
|
var xlsxPath = TmpReportDir + installationId + ".xlsx";
|
|
var cachePath = TmpReportDir + installationId + ".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}.");
|
|
return cached;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Cache corrupt — regenerate
|
|
}
|
|
}
|
|
}
|
|
|
|
var allDays = ExcelDataParser.Parse(xlsxPath);
|
|
var report = await GenerateReportFromDataAsync(allDays, installationName);
|
|
|
|
// 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)
|
|
{
|
|
// 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);
|
|
|
|
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 async Task<string> GetAiInsightAsync(
|
|
List<DailyEnergyData> currentWeek,
|
|
WeeklySummary current,
|
|
WeeklySummary? previous,
|
|
double selfSufficiency,
|
|
double gridDependency,
|
|
double batteryEfficiency,
|
|
string installationName)
|
|
{
|
|
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
|
|
var cacheKey = $"{installationName}_{currentWeek.Last().Date}";
|
|
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.
|
|
|
|
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.
|
|
|
|
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.";
|
|
}
|
|
}
|