187 lines
8.4 KiB
C#
187 lines
8.4 KiB
C#
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 %
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
public static List<DailyEnergyData> 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<string, int>();
|
|
|
|
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<string, DailyEnergyData>();
|
|
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public static List<HourlyEnergyData> 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<string, int>();
|
|
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<HourlyEnergyData>();
|
|
|
|
// 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<HourlyEnergyData>();
|
|
|
|
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<double>(out var val) ? Math.Round(val, 4) : 0;
|
|
}
|
|
}
|