using ClosedXML.Excel; using InnovEnergy.App.Backend.DataTypes; namespace InnovEnergy.App.Backend.Services; public static class ExcelDataParser { // Column headers from the ESS Link Cloud Excel export private const string ColDateTime = "Data time"; private const string ColPvToday = "PV Generated Energy Today"; private const string ColLoadToday = "Load Consumption Today"; private const string ColGridImportToday = "Purchased Energy Today"; private const string ColGridExportToday = "Feed in energy Today"; private const string ColBattChargedToday = "Daily Battery Charged"; private const string ColBattDischargedToday = "Battery Discharged Today"; private const string ColBattSoC = "Battery 1 SoC"; // instantaneous % /// /// Parses an ESS Link Cloud Excel export file and returns one DailyEnergyData per day. /// Takes the last row of each day (where "Today" cumulative values are highest). /// public static List Parse(string filePath) { if (!File.Exists(filePath)) throw new FileNotFoundException($"Excel file not found: {filePath}"); using var workbook = new XLWorkbook(filePath); var worksheet = workbook.Worksheet(1); var lastRow = worksheet.LastRowUsed()?.RowNumber() ?? 0; if (lastRow < 2) throw new InvalidOperationException("Excel file has no data rows."); // Find column indices by header name (row 1) var headerRow = worksheet.Row(1); var colMap = new Dictionary(); for (var col = 1; col <= worksheet.LastColumnUsed()?.ColumnNumber(); col++) { var header = headerRow.Cell(col).GetString().Trim(); if (!string.IsNullOrEmpty(header)) colMap[header] = col; } // Validate required columns exist var requiredCols = new[] { ColDateTime, ColPvToday, ColLoadToday, ColGridImportToday, ColGridExportToday, ColBattChargedToday, ColBattDischargedToday }; foreach (var rc in requiredCols) { if (!colMap.ContainsKey(rc)) throw new InvalidOperationException($"Required column '{rc}' not found in Excel file. Available: {string.Join(", ", colMap.Keys)}"); } // Read all rows, group by date, keep last row per day var dailyLastRows = new SortedDictionary(); for (var row = 2; row <= lastRow; row++) { var dateTimeStr = worksheet.Row(row).Cell(colMap[ColDateTime]).GetString().Trim(); if (string.IsNullOrEmpty(dateTimeStr)) continue; // Extract date portion (first 10 chars: "2026-02-10") var date = dateTimeStr.Length >= 10 ? dateTimeStr[..10] : dateTimeStr; var data = new DailyEnergyData { Date = date, PvProduction = GetDouble(worksheet, row, colMap[ColPvToday]), LoadConsumption = GetDouble(worksheet, row, colMap[ColLoadToday]), GridImport = GetDouble(worksheet, row, colMap[ColGridImportToday]), GridExport = GetDouble(worksheet, row, colMap[ColGridExportToday]), BatteryCharged = GetDouble(worksheet, row, colMap[ColBattChargedToday]), BatteryDischarged = GetDouble(worksheet, row, colMap[ColBattDischargedToday]), }; // Always overwrite — last row of the day has the final cumulative values dailyLastRows[date] = data; } Console.WriteLine($"[ExcelDataParser] Parsed {dailyLastRows.Count} days from {filePath}"); return dailyLastRows.Values.ToList(); } /// /// Parses hourly energy snapshots from the xlsx. /// For each hour of each day, finds the row nearest HH:00:00 and records the /// cumulative "Today" values at that moment. The caller (BehaviorAnalyzer) then /// diffs consecutive snapshots to get per-hour energy. /// public static List ParseHourly(string filePath) { if (!File.Exists(filePath)) throw new FileNotFoundException($"Excel file not found: {filePath}"); using var workbook = new XLWorkbook(filePath); var worksheet = workbook.Worksheet(1); var lastRow = worksheet.LastRowUsed()?.RowNumber() ?? 0; if (lastRow < 2) throw new InvalidOperationException("Excel file has no data rows."); // Build column map var headerRow = worksheet.Row(1); var colMap = new Dictionary(); for (var col = 1; col <= worksheet.LastColumnUsed()?.ColumnNumber(); col++) { var header = headerRow.Cell(col).GetString().Trim(); if (!string.IsNullOrEmpty(header)) colMap[header] = col; } // SoC column is optional — not all exports include it var hasSoC = colMap.ContainsKey(ColBattSoC); // Read all rows into memory as (DateTime, cumulative values) pairs var rawRows = new List<(DateTime Dt, double Pv, double Load, double GridIn, double BattChg, double BattDis, double SoC)>(); for (var row = 2; row <= lastRow; row++) { var dtStr = worksheet.Row(row).Cell(colMap[ColDateTime]).GetString().Trim(); if (string.IsNullOrEmpty(dtStr)) continue; if (!DateTime.TryParse(dtStr, out var dt)) continue; rawRows.Add(( dt, GetDouble(worksheet, row, colMap[ColPvToday]), GetDouble(worksheet, row, colMap[ColLoadToday]), GetDouble(worksheet, row, colMap[ColGridImportToday]), GetDouble(worksheet, row, colMap[ColBattChargedToday]), GetDouble(worksheet, row, colMap[ColBattDischargedToday]), hasSoC ? GetDouble(worksheet, row, colMap[ColBattSoC]) : 0 )); } if (rawRows.Count == 0) return new List(); // For each calendar hour that exists in the data, find the nearest row to HH:00:00 // Group rows by (date, hour) and pick the one closest to the round hour var byHour = rawRows .GroupBy(r => new DateTime(r.Dt.Year, r.Dt.Month, r.Dt.Day, r.Dt.Hour, 0, 0)) .OrderBy(g => g.Key) .Select(g => { var roundHour = g.Key; var nearest = g.OrderBy(r => Math.Abs((r.Dt - roundHour).TotalSeconds)).First(); return (RoundHour: roundHour, Row: nearest); }) .ToList(); // Diff consecutive snapshots within the same day to get per-hour energy var result = new List(); for (var i = 1; i < byHour.Count; i++) { var prev = byHour[i - 1]; var curr = byHour[i]; // Only diff within the same day — don't carry over across midnight if (curr.RoundHour.Date != prev.RoundHour.Date) continue; // Cumulative "Today" values reset at midnight, so diff is always >= 0 within a day result.Add(new HourlyEnergyData { DateTime = curr.RoundHour, Hour = curr.RoundHour.Hour, DayOfWeek = curr.RoundHour.DayOfWeek.ToString(), IsWeekend = curr.RoundHour.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday, PvKwh = Math.Max(0, Math.Round(curr.Row.Pv - prev.Row.Pv, 3)), LoadKwh = Math.Max(0, Math.Round(curr.Row.Load - prev.Row.Load, 3)), GridImportKwh = Math.Max(0, Math.Round(curr.Row.GridIn - prev.Row.GridIn, 3)), BatteryChargedKwh = Math.Max(0, Math.Round(curr.Row.BattChg - prev.Row.BattChg, 3)), BatteryDischargedKwh = Math.Max(0, Math.Round(curr.Row.BattDis - prev.Row.BattDis, 3)), BattSoC = curr.Row.SoC, }); } Console.WriteLine($"[ExcelDataParser] Parsed {result.Count} hourly records from {filePath}"); return result; } private static double GetDouble(IXLWorksheet ws, int row, int col) { var cell = ws.Row(row).Cell(col); if (cell.IsEmpty()) return 0; return cell.TryGetValue(out var val) ? Math.Round(val, 4) : 0; } }