526 lines
27 KiB
C#
526 lines
27 KiB
C#
using System.Text.RegularExpressions;
|
||
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/";
|
||
|
||
/// <summary>
|
||
/// Returns xlsx files for the given installation whose date range overlaps [rangeStart, rangeEnd].
|
||
/// Filename pattern: {installationId}_MMDD_MMDD.xlsx (e.g. "848_0302_0308.xlsx")
|
||
/// Falls back to all files if filenames can't be parsed.
|
||
/// </summary>
|
||
public static List<string> GetRelevantXlsxFiles(long installationId, DateOnly rangeStart, DateOnly rangeEnd)
|
||
{
|
||
if (!Directory.Exists(TmpReportDir))
|
||
return new List<string>();
|
||
|
||
var allFiles = Directory.GetFiles(TmpReportDir, $"{installationId}*.xlsx").OrderBy(f => f).ToList();
|
||
if (allFiles.Count == 0)
|
||
return allFiles;
|
||
|
||
// Try to filter by filename date range; fall back to all files if parsing fails
|
||
var year = rangeStart.Year;
|
||
var filtered = new List<string>();
|
||
|
||
foreach (var file in allFiles)
|
||
{
|
||
var name = Path.GetFileNameWithoutExtension(file);
|
||
// Match pattern: {id}_MMDD_MMDD
|
||
var match = Regex.Match(name, @"_(\d{4})_(\d{4})$");
|
||
if (!match.Success)
|
||
{
|
||
// Can't parse filename — include it to be safe
|
||
filtered.Add(file);
|
||
continue;
|
||
}
|
||
|
||
var startStr = match.Groups[1].Value; // "0302"
|
||
var endStr = match.Groups[2].Value; // "0308"
|
||
|
||
if (!DateOnly.TryParseExact($"{year}-{startStr[..2]}-{startStr[2..]}", "yyyy-MM-dd", out var fileStart) ||
|
||
!DateOnly.TryParseExact($"{year}-{endStr[..2]}-{endStr[2..]}", "yyyy-MM-dd", out var fileEnd))
|
||
{
|
||
filtered.Add(file); // Can't parse — include to be safe
|
||
continue;
|
||
}
|
||
|
||
// Include if date ranges overlap
|
||
if (fileStart <= rangeEnd && fileEnd >= rangeStart)
|
||
filtered.Add(file);
|
||
}
|
||
|
||
return filtered;
|
||
}
|
||
|
||
// ── Calendar Week Helpers ──────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Returns the last completed calendar week (Mon–Sun).
|
||
/// 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();
|
||
}
|
||
|
||
// 1. Load daily records from SQLite
|
||
var currentWeekDays = Db.GetDailyRecords(installationId, curMon, curSun)
|
||
.Select(ToDailyEnergyData).ToList();
|
||
var previousWeekDays = Db.GetDailyRecords(installationId, prevMon, prevSun)
|
||
.Select(ToDailyEnergyData).ToList();
|
||
|
||
// 2. Fallback: if DB empty, parse xlsx on the fly (pre-ingestion scenario)
|
||
if (currentWeekDays.Count == 0)
|
||
{
|
||
// Only parse xlsx files whose date range overlaps the needed weeks
|
||
var relevantFiles = GetRelevantXlsxFiles(installationId, prevMon, curSun);
|
||
|
||
if (relevantFiles.Count > 0)
|
||
{
|
||
Console.WriteLine($"[WeeklyReportService] DB empty for week {curMon:yyyy-MM-dd}, falling back to {relevantFiles.Count} xlsx file(s).");
|
||
var allDaysParsed = relevantFiles.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 records from SQLite for behavioral analysis
|
||
var currentHourlyData = Db.GetHourlyRecords(installationId, curMon, curSun)
|
||
.Select(ToHourlyEnergyData).ToList();
|
||
|
||
// 3b. Fallback: if DB empty, parse hourly data from xlsx
|
||
if (currentHourlyData.Count == 0)
|
||
{
|
||
var relevantFiles = GetRelevantXlsxFiles(installationId, curMon, curSun);
|
||
|
||
if (relevantFiles.Count > 0)
|
||
{
|
||
Console.WriteLine($"[WeeklyReportService] Hourly DB empty for week {curMon:yyyy-MM-dd}, falling back to {relevantFiles.Count} xlsx file(s).");
|
||
currentHourlyData = relevantFiles
|
||
.SelectMany(p => ExcelDataParser.ParseHourly(p))
|
||
.Where(h => { var date = DateOnly.FromDateTime(h.DateTime); return date >= curMon && date <= curSun; })
|
||
.ToList();
|
||
Console.WriteLine($"[WeeklyReportService] Parsed {currentHourlyData.Count} hourly records from xlsx.");
|
||
}
|
||
}
|
||
|
||
// 4. Get installation location for weather forecast
|
||
var installation = Db.GetInstallationById(installationId);
|
||
var location = installation?.Location;
|
||
var country = installation?.Country;
|
||
var region = installation?.Region;
|
||
Console.WriteLine($"[WeeklyReportService] Installation {installationId}: Location='{location}', Region='{region}', Country='{country}', HourlyRecords={currentHourlyData.Count}");
|
||
|
||
return await GenerateReportFromDataAsync(
|
||
currentWeekDays, previousWeekDays, currentHourlyData, installationName, language,
|
||
curMon, curSun, location, country, region);
|
||
}
|
||
|
||
// ── 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,
|
||
};
|
||
|
||
private static HourlyEnergyData ToHourlyEnergyData(HourlyEnergyRecord r) => new()
|
||
{
|
||
DateTime = DateTime.ParseExact(r.DateHour, "yyyy-MM-dd HH", null),
|
||
Hour = r.Hour,
|
||
DayOfWeek = r.DayOfWeek,
|
||
IsWeekend = r.IsWeekend,
|
||
PvKwh = r.PvKwh,
|
||
LoadKwh = r.LoadKwh,
|
||
GridImportKwh = r.GridImportKwh,
|
||
BatteryChargedKwh = r.BatteryChargedKwh,
|
||
BatteryDischargedKwh = r.BatteryDischargedKwh,
|
||
BattSoC = r.BattSoC,
|
||
};
|
||
|
||
/// <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,
|
||
string? location = null,
|
||
string? country = null,
|
||
string? region = 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, location, country, region);
|
||
|
||
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",
|
||
string? location = null,
|
||
string? country = null,
|
||
string? region = null)
|
||
{
|
||
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).";
|
||
}
|
||
|
||
// Fetch weather forecast for the installation's location
|
||
var forecast = await WeatherService.GetForecastAsync(location, country, region);
|
||
var weatherBlock = forecast != null ? "\n" + WeatherService.FormatForPrompt(forecast) + "\n" : "";
|
||
Console.WriteLine($"[WeeklyReportService] Weather forecast: {(forecast != null ? $"{forecast.Count} days fetched" : "SKIPPED (no location or API error)")}");
|
||
if (forecast != null) Console.WriteLine($"[WeeklyReportService] Weather block:\n{weatherBlock}");
|
||
|
||
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");
|
||
|
||
// Check if we have meaningful hourly/behavioral data
|
||
var hasBehavior = behavior.AvgPeakLoadKwh > 0 || behavior.AvgPeakSolarKwh > 0;
|
||
|
||
// Behavioral facts as compact lines (only when hourly data exists)
|
||
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."
|
||
: "";
|
||
|
||
// Behavioral section — only include when hourly data exists
|
||
var behavioralSection = "";
|
||
if (hasBehavior)
|
||
{
|
||
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}" : "";
|
||
|
||
behavioralSection = $@"
|
||
BEHAVIORAL PATTERN (from hourly data this week):
|
||
- Peak household load: {FormatHourSlot(behavior.PeakLoadHour)}, avg {behavior.AvgPeakLoadKwh} kWh during that hour
|
||
- {weekdayWeekendLine}{pvBehaviorLines}
|
||
{gridBehaviorLine}
|
||
{battBehaviorLine}";
|
||
}
|
||
|
||
// Build conditional instructions
|
||
var instruction1 = $"1. Energy savings: Write 1–2 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."
|
||
: "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.";
|
||
|
||
// Instruction 4 — adapts based on whether we have behavioral data
|
||
string instruction4;
|
||
if (hasBehavior && hasPv)
|
||
instruction4 = $"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.";
|
||
else if (hasBehavior && hasGrid)
|
||
instruction4 = $"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.";
|
||
else
|
||
instruction4 = "4. Smart action for next week: Based on this week's daily energy patterns, suggest one practical tip to maximize self-consumption and reduce grid dependency.";
|
||
|
||
// Instruction 5 — weather outlook with pattern-based predictions
|
||
var hasWeather = forecast != null;
|
||
var bulletCount = hasWeather ? 5 : 4;
|
||
var instruction5 = "";
|
||
if (hasWeather && hasPv)
|
||
{
|
||
// Compute avg daily PV production this week for reference
|
||
var avgDailyPv = currentWeek.Count > 0 ? Math.Round(currentWeek.Average(d => d.PvProduction), 1) : 0;
|
||
var bestDayPv = Math.Round(bestDay.PvProduction, 1);
|
||
var worstDayPv = Math.Round(worstDay.PvProduction, 1);
|
||
|
||
// Classify forecast days by sunshine potential
|
||
var sunnyDays = forecast.Where(d => d.SunshineHours >= 7).Select(d => DateTime.Parse(d.Date).ToString("dddd")).ToList();
|
||
var cloudyDays = forecast.Where(d => d.SunshineHours < 3).Select(d => DateTime.Parse(d.Date).ToString("dddd")).ToList();
|
||
var totalForecastSunshine = Math.Round(forecast.Sum(d => d.SunshineHours), 1);
|
||
|
||
var patternContext = $"This week the installation averaged {avgDailyPv} kWh/day PV (best: {bestDayPv} kWh on {bestDayName}, worst: {worstDayPv} kWh on {worstDayName}). ";
|
||
if (sunnyDays.Count > 0)
|
||
patternContext += $"Next week, sunny days ({string.Join(", ", sunnyDays)}) should produce above average (~{avgDailyPv}+ kWh/day). ";
|
||
if (cloudyDays.Count > 0)
|
||
patternContext += $"Cloudy/rainy days ({string.Join(", ", cloudyDays)}) will likely produce below average (~{worstDayPv} kWh/day or less). ";
|
||
patternContext += $"Total forecast sunshine next week: {totalForecastSunshine}h.";
|
||
|
||
instruction5 = $@"5. Weather outlook: {patternContext} Write 2-3 concise sentences. (1) Name the best solar days next week and estimate production based on this week's pattern. (2) Recommend which specific days are best for running energy-heavy appliances (washing machine, dishwasher, EV charging) to maximize free solar energy. (3) If rainy days follow sunny days, suggest front-loading heavy usage onto the sunny days before the rain arrives. IMPORTANT: Do NOT suggest ""reducing consumption"" in the evening — people need electricity for cooking, lighting, and daily life. Instead, focus on SHIFTING discretionary loads (laundry, dishwasher, EV) to the best solar days. The battery system handles grid charging automatically — do not tell the customer to manage battery charging.";
|
||
}
|
||
else if (hasWeather)
|
||
{
|
||
instruction5 = @"5. Weather outlook: Summarize the coming week's weather in 1-2 sentences. Mention which days have the most sunshine and suggest those as the best days to run energy-heavy appliances (laundry, dishwasher). Do NOT suggest reducing evening consumption — focus on shifting discretionary loads to sunny days.";
|
||
}
|
||
|
||
var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"".
|
||
|
||
Write {bulletCount} 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}
|
||
{behavioralSection}
|
||
{weatherBlock}
|
||
INSTRUCTIONS:
|
||
{instruction1}
|
||
{instruction2}
|
||
{instruction3}
|
||
{instruction4}
|
||
{instruction5}
|
||
|
||
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 {bulletCount} bullet points (e.g. no 'Das ist ein guter Fortschritt', 'Keep it up', 'Good progress', etc.). Write exactly {bulletCount} 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 = 600,
|
||
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.";
|
||
}
|
||
}
|