diff --git a/scheduler/data.go b/scheduler/data.go index fddc4a6..98b0891 100644 --- a/scheduler/data.go +++ b/scheduler/data.go @@ -44,12 +44,13 @@ func (w *WeatherForecastCache) Set(forecast *meteo.METJSONForecast) { // DataSample represents a single measurement of power and battery data. type DataSample struct { - pvPower float64 - gridPower float64 // positive = import, negative = export - batteryPower float64 // positive = charging, negative = discharging - evdcPower float64 - batterySoc float64 // % - ts time.Time + pvPower float64 + gridPower float64 // positive = import, negative = export + batteryPower float64 // positive = charging, negative = discharging + evdcPower float64 + batterySoc float64 // % + batteryAvgCellTemp float64 // °C + ts time.Time } // DataSamples is a thread-safe collection of power measurement samples. @@ -59,16 +60,17 @@ type DataSamples struct { } // AddSample adds a new power measurement sample to the collection. -func (d *DataSamples) AddSample(pvPower, gridPower, batteryPower, evdcPower, batterySoc float64, ts time.Time) { +func (d *DataSamples) AddSample(pvPower, gridPower, batteryPower, evdcPower, batterySoc, batteryAvgCellTemp float64, ts time.Time) { d.mu.Lock() defer d.mu.Unlock() d.samples = append(d.samples, DataSample{ - pvPower: pvPower, - gridPower: gridPower, - batteryPower: batteryPower, - evdcPower: evdcPower, - batterySoc: batterySoc, - ts: ts, + pvPower: pvPower, + gridPower: gridPower, + batteryPower: batteryPower, + evdcPower: evdcPower, + batterySoc: batterySoc, + batteryAvgCellTemp: batteryAvgCellTemp, + ts: ts, }) } @@ -82,6 +84,7 @@ type IntegratedData struct { evdcChargePower float64 loadPower float64 batterySoc float64 + batteryAvgCellTemp float64 timestamp time.Time sampleCount int // Number of samples integrated } @@ -125,8 +128,9 @@ func (d *DataSamples) IntegrateSamples(pollInterval time.Duration, cutoffTime ti // EV DC charging power result.evdcChargePower += sample.evdcPower * energyKWh - // Keep the last battery SOC + // Keep the last battery SOC and temperature result.batterySoc = sample.batterySoc + result.batteryAvgCellTemp = sample.batteryAvgCellTemp } // Calculate load: Load = PV + Battery Discharge + Grid Import - Battery Charge - Grid Export - EV Charge @@ -190,6 +194,7 @@ func (s *MinerScheduler) runDataPoll(samples *DataSamples) error { info.ESSPower, info.DCChargerOutputPower, info.ESSSOC, + info.ESSAvgCellTemperature, time.Now(), ) return nil @@ -258,8 +263,8 @@ func (s *MinerScheduler) runDataIntegration(samples *DataSamples, pollInterval t deviceID, timestamp.Format(time.RFC3339), data.sampleCount) s.logger.Printf(" PV: %.3f kWh, Grid Import: %.3f kWh (%.3f), Grid Export: %.3f kWh (%.3f)", data.pvTotalPower, data.gridImportPower, gridImportCost, data.gridExportPower, gridExportCost) - s.logger.Printf(" Battery Charge: %.3f kWh, Battery Discharge: %.3f kWh, SOC: %.1f%%", - data.batteryChargePower, data.batteryDischargePower, data.batterySoc) + s.logger.Printf(" Battery Charge: %.3f kWh, Battery Discharge: %.3f kWh, SOC: %.1f%%, Temp: %.1f°C", + data.batteryChargePower, data.batteryDischargePower, data.batterySoc, data.batteryAvgCellTemp) s.logger.Printf(" EV Charge: %.3f kWh, Load: %.3f kWh", data.evdcChargePower, data.loadPower) if cloudCoverage != nil { s.logger.Printf(" Cloud Coverage: %.1f%%", *cloudCoverage) @@ -277,14 +282,16 @@ func (s *MinerScheduler) runDataIntegration(samples *DataSamples, pollInterval t grid_export_power, grid_import_power, battery_charge_power, battery_discharge_power, battery_soc, evdc_charge_power, load_power, - grid_export_cost, grid_import_cost - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`, + grid_export_cost, grid_import_cost, + battery_avg_cell_temperature + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`, timestamp, deviceID, "energy_flow", data.pvTotalPower, cloudCoverage, weatherSymbol, data.gridExportPower, data.gridImportPower, data.batteryChargePower, data.batteryDischargePower, data.batterySoc, data.evdcChargePower, data.loadPower, gridExportCost, gridImportCost, + data.batteryAvgCellTemp, ) if err != nil { s.logger.Printf("Data integration: failed to insert metrics: %v", err) @@ -298,8 +305,8 @@ func (s *MinerScheduler) runDataIntegration(samples *DataSamples, pollInterval t deviceID, timestamp.Format(time.RFC3339), data.sampleCount) s.logger.Printf(" PV: %.3f kWh, Grid Import: %.3f kWh (%.3f), Grid Export: %.3f kWh (%.3f)", data.pvTotalPower, data.gridImportPower, gridImportCost, data.gridExportPower, gridExportCost) - s.logger.Printf(" Battery Charge: %.3f kWh, Battery Discharge: %.3f kWh, SOC: %.1f%%", - data.batteryChargePower, data.batteryDischargePower, data.batterySoc) + s.logger.Printf(" Battery Charge: %.3f kWh, Battery Discharge: %.3f kWh, SOC: %.1f%%, Temp: %.1f°C", + data.batteryChargePower, data.batteryDischargePower, data.batterySoc, data.batteryAvgCellTemp) s.logger.Printf(" EV Charge: %.3f kWh, Load: %.3f kWh", data.evdcChargePower, data.loadPower) } return nil diff --git a/scheduler/data_test.go b/scheduler/data_test.go index 48edee9..5fc18b5 100644 --- a/scheduler/data_test.go +++ b/scheduler/data_test.go @@ -17,14 +17,14 @@ func TestDataSamples_IntegrateSamplesWithPeriodBoundary(t *testing.T) { // 6 samples: 0s, 10s, 20s, 30s, 40s, 50s for i := range 6 { ts := baseTime.Add(time.Duration(i) * pollInterval) - samples.AddSample(100.0, 50.0, 30.0, 10.0, 80.0, ts) + samples.AddSample(100.0, 50.0, 30.0, 10.0, 80.0, 25.0, ts) } // Add samples for second period (12:01:00 to 12:01:50) // 6 samples: 60s, 70s, 80s, 90s, 100s, 110s for i := range 6 { ts := baseTime.Add(integrationPeriod).Add(time.Duration(i) * pollInterval) - samples.AddSample(200.0, 100.0, 60.0, 20.0, 75.0, ts) + samples.AddSample(200.0, 100.0, 60.0, 20.0, 75.0, 26.0, ts) } // Integrate first period only (cutoff at 12:01:00) @@ -64,7 +64,7 @@ func TestDataSamples_ClearBefore(t *testing.T) { // 12 samples: 0s, 10s, 20s, 30s, 40s, 50s, 60s, 70s, 80s, 90s, 100s, 110s for i := range 12 { ts := baseTime.Add(time.Duration(i) * 10 * time.Second) - samples.AddSample(100.0, 50.0, 30.0, 10.0, 80.0, ts) + samples.AddSample(100.0, 50.0, 30.0, 10.0, 80.0, 25.0, ts) } // Clear samples before and at 1 minute mark (60s) @@ -96,7 +96,7 @@ func TestDataSamples_IntegrationPreservesForRetry(t *testing.T) { // Add 6 samples for first period (0s to 50s) for i := range 6 { ts := baseTime.Add(time.Duration(i) * pollInterval) - samples.AddSample(100.0, 50.0, 30.0, 10.0, 80.0, ts) + samples.AddSample(100.0, 50.0, 30.0, 10.0, 80.0, 25.0, ts) } // Cutoff at 50s (not 60s) to avoid including second period samples @@ -110,7 +110,7 @@ func TestDataSamples_IntegrationPreservesForRetry(t *testing.T) { // Add more samples for second period (60s to 110s) for i := range 6 { ts := baseTime.Add(60 * time.Second).Add(time.Duration(i) * pollInterval) - samples.AddSample(200.0, 100.0, 60.0, 20.0, 75.0, ts) + samples.AddSample(200.0, 100.0, 60.0, 20.0, 75.0, 26.0, ts) } // Retry integration for first period (same cutoff) @@ -181,10 +181,10 @@ func TestDataSamples_BoundaryConditions(t *testing.T) { baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) // Add sample exactly at boundary - samples.AddSample(100.0, 50.0, 30.0, 10.0, 80.0, baseTime) + samples.AddSample(100.0, 50.0, 30.0, 10.0, 80.0, 25.0, baseTime) // Add sample 1 nanosecond after boundary - samples.AddSample(200.0, 100.0, 60.0, 20.0, 75.0, baseTime.Add(1)) + samples.AddSample(200.0, 100.0, 60.0, 20.0, 75.0, 26.0, baseTime.Add(1)) // Integrate with cutoff at baseTime data := samples.IntegrateSamples(pollInterval, baseTime) @@ -221,6 +221,7 @@ func TestIntegratedData_EnergyCalculations(t *testing.T) { 300.0, // Battery charge: 300W 100.0, // EVDC: 100W 85.0, // SOC: 85% + 25.0, // Battery temp: 25°C baseTime, ) @@ -230,6 +231,7 @@ func TestIntegratedData_EnergyCalculations(t *testing.T) { -150.0, // Battery discharge: 150W 0.0, // EVDC: 0W 83.0, // SOC: 83% + 26.0, // Battery temp: 26°C baseTime.Add(pollInterval), ) diff --git a/sigenergy/info.go b/sigenergy/info.go index c5b8775..419fb04 100644 --- a/sigenergy/info.go +++ b/sigenergy/info.go @@ -74,6 +74,7 @@ func ShowPlantInfo(plantModbusAddress string) error { fmt.Println("--------------------------------------------------") fmt.Printf(" ESS SOC: %.1f %%\n", info.ESSSOC) fmt.Printf(" ESS SOH: %.1f %%\n", info.ESSSOH) + fmt.Printf(" ESS Average Cell Temperature: %.1f °C\n", info.ESSAvgCellTemperature) fmt.Printf(" ESS Rated Energy Capacity: %.2f kWh\n", info.ESSRatedEnergyCapacity) fmt.Printf(" ESS Charge Off SOC: %.1f %%\n", info.ESSChargeOffSOC) fmt.Printf(" ESS Discharge Off SOC: %.1f %%\n", info.ESSDischargeOffSOC) diff --git a/sigenergy/modbus_client.go b/sigenergy/modbus_client.go index 3401306..e3429c8 100644 --- a/sigenergy/modbus_client.go +++ b/sigenergy/modbus_client.go @@ -153,6 +153,7 @@ type PlantRunningInfo struct { ESSChargeOffSOC float64 // % ESSDischargeOffSOC float64 // % ESSSOH float64 // % + ESSAvgCellTemperature float64 // °C DCChargerOutputPower float64 // kW DCChargerVehicleSOC float64 // % } @@ -217,6 +218,16 @@ func (c *SigenModbusClient) ReadPlantRunningInfo() (*PlantRunningInfo, error) { info.DCChargerVehicleSOC = float64(bytesToU16(data3[4:6])) / 10.0 } + // Read ESS Average Cell Temperature from first inverter (slave address 1, register 30603) + // Note: This assumes at least one hybrid inverter is present with slave ID 1 + c.SetSlaveID(1) + data4, err := c.client.ReadInputRegisters(30603, 1) + if err == nil { + info.ESSAvgCellTemperature = float64(bytesToS16(data4[0:2])) / 10.0 + } + // Reset to plant address + c.SetSlaveID(PlantAddress) + return info, nil } diff --git a/sql/metrics.sql b/sql/metrics.sql index f530a76..fb242ce 100644 --- a/sql/metrics.sql +++ b/sql/metrics.sql @@ -13,6 +13,7 @@ CREATE TABLE metrics ( grid_export_cost NUMERIC, grid_import_cost NUMERIC, battery_soc NUMERIC, + battery_avg_cell_temperature NUMERIC, weather_symbol VARCHAR(100), PRIMARY KEY (timestamp, device_id, metric_name) ); @@ -36,6 +37,7 @@ CREATE INDEX idx_metrics_time ON metrics USING BRIN (timestamp); -- grid_export_cost: Total cost of power exported to grid in EUR -- grid_import_cost: Total cost of power imported from grid in EUR -- battery_soc: Battery state of charge in % (snapshot at end of period) +-- battery_avg_cell_temperature: Battery average cell temperature in °C (snapshot at end of period) -- weather_symbol: Weather condition symbol code (e.g., 'clearsky_day', 'rain', etc.) -- Migration for existing tables: @@ -48,4 +50,5 @@ CREATE INDEX idx_metrics_time ON metrics USING BRIN (timestamp); -- ALTER TABLE metrics ADD COLUMN grid_export_cost NUMERIC; -- ALTER TABLE metrics ADD COLUMN grid_import_cost NUMERIC; -- ALTER TABLE metrics ADD COLUMN battery_soc NUMERIC; +-- ALTER TABLE metrics ADD COLUMN battery_avg_cell_temperature NUMERIC; -- ALTER TABLE metrics ADD COLUMN weather_symbol VARCHAR(100);