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;
}
}