diff --git a/configs/ems-config.yaml b/configs/ems-config.yaml index c64d82f..e91f9a3 100644 --- a/configs/ems-config.yaml +++ b/configs/ems-config.yaml @@ -15,7 +15,8 @@ prometheus: phase_l1_power: "pcc_ac_active_power_phaseOne" # per-phase grid power L1 (W) phase_l2_power: "pcc_ac_active_power_phaseTwo" # per-phase grid power L2 (W) phase_l3_power: "pcc_ac_active_power_phaseThree" # per-phase grid power L3 (W) - ww_top_temp: "heating_dhw_sensors_temperature_dhwCylinder_top_value" # DHW cylinder top temperature (°C) + ww_top_temp: "heating_dhw_sensors_temperature_dhwCylinder_top_value" + pv_yield_today: "photovoltaic_production_cumulated_currentDay" # Wh, converted to kWh by collector (÷1000) # DHW cylinder top temperature (°C) # Shelly actuators shelly: @@ -84,6 +85,7 @@ consumers: compressor_idle_w: 500 # heat pump compressor below this = idle (W); running starts at ~2700W, so 500W cleanly separates idle (0W) from running wallbox_min_charge_w: 50 # wallbox below this = car not charging (W) idle_cycles: 3 # consecutive idle cycles before early release (~6 min at 2-min poll) + sg_ready_startup_min: 10 # grace period after SG-Ready activation before compressor idle check starts (min) wallbox_a_accepted_import_w: 0 # tolerate this much grid import while WallboxA is running (0 = disabled; WallboxA is 2kW, inverter headroom is sufficient) wallbox_b_accepted_import_w: 600 # tolerate up to 600W import while WallboxB runs (4kW wallbox + 450W house > 4.6kW inverter max on PV dips) diff --git a/internal/collector/collector.go b/internal/collector/collector.go index 930ee1f..5245c6d 100644 --- a/internal/collector/collector.go +++ b/internal/collector/collector.go @@ -27,6 +27,7 @@ type SystemState struct { PhaseL2PowerW float64 // per-phase grid power L2 PhaseL3PowerW float64 // per-phase grid power L3 WWTopTempC float64 // DHW cylinder top temperature (°C) + PVYieldTodayKWh float64 // cumulative PV production today (kWh) } // IsExporting returns true if the system is exporting to grid. @@ -93,6 +94,7 @@ func (c *Collector) Collect(ctx context.Context) (SystemState, error) { {"phase_l2_power", &state.PhaseL2PowerW, 1}, {"phase_l3_power", &state.PhaseL3PowerW, 1}, {"ww_top_temp", &state.WWTopTempC, 1}, + {"pv_yield_today", &state.PVYieldTodayKWh, 0.001}, // Wh → kWh } for _, t := range targets { @@ -118,6 +120,7 @@ func (c *Collector) Collect(ctx context.Context) (SystemState, error) { "grid_w", state.GridPowerW, "soc", state.BatterySOC, "pv_w", state.PVProductionW, + "pv_today_kwh", fmt.Sprintf("%.1f", state.PVYieldTodayKWh), "compressor_w", state.CompressorPowerW, "ambient_c", state.AmbientTempC, "l1_w", state.PhaseL1PowerW, diff --git a/internal/config/config.go b/internal/config/config.go index 1d8bc96..5847f75 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -135,9 +135,10 @@ type PowerThresholds struct { // ConsumersConfig holds per-consumer behavior thresholds. type ConsumersConfig struct { - CompressorIdleW int `yaml:"compressor_idle_w"` // below this = heat pump compressor idle (W) - WallboxMinChargeW int `yaml:"wallbox_min_charge_w"` // below this = car not charging (W) - IdleCycles int `yaml:"idle_cycles"` // consecutive idle cycles before early release + CompressorIdleW int `yaml:"compressor_idle_w"` // below this = heat pump compressor idle (W) + WallboxMinChargeW int `yaml:"wallbox_min_charge_w"` // below this = car not charging (W) + IdleCycles int `yaml:"idle_cycles"` // consecutive idle cycles before early release + SGReadyStartupMin int `yaml:"sg_ready_startup_min"` // grace period after SG-Ready activation before idle detection starts (min, 0 = disabled) WallboxAAcceptedImportW float64 `yaml:"wallbox_a_accepted_import_w"` // tolerate this much grid import while WallboxA is running (0 = disabled) WallboxBAcceptedImportW float64 `yaml:"wallbox_b_accepted_import_w"` // tolerate this much grid import while WallboxB is running (0 = disabled) } diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 5091208..fec3633 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -169,8 +169,20 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC, fo } // --- Compressor idle: release SG-Ready early if heat pump stopped --- + // A startup grace period is applied after activation: the heat pump compressor + // takes several minutes to start after receiving the SG-Ready signal, so idle + // detection is suppressed until the grace period has elapsed. if cs := e.consumers[ConsumerSGReady]; cs.Active { - if state.CompressorPowerW < float64(e.cfg.Consumers.CompressorIdleW) { + startupMin := e.cfg.Consumers.SGReadyStartupMin + inGrace := startupMin > 0 && !cs.ActivatedAt.IsZero() && now.Sub(cs.ActivatedAt) < time.Duration(startupMin)*time.Minute + + if inGrace { + e.logger.Debug("SG-Ready: startup grace period, skipping idle check", + "activated_at", cs.ActivatedAt.Format("15:04"), + "grace_min", startupMin, + "elapsed_min", int(now.Sub(cs.ActivatedAt).Minutes()), + ) + } else if state.CompressorPowerW < float64(e.cfg.Consumers.CompressorIdleW) { cs.LowPowerCycles++ e.logger.Debug("SG-Ready: compressor idle cycle", "compressor_w", state.CompressorPowerW, diff --git a/internal/status/status.go b/internal/status/status.go index 51b4219..24c02bf 100644 --- a/internal/status/status.go +++ b/internal/status/status.go @@ -183,6 +183,7 @@ type pageData struct { BatterySOC float64 GridPowerW float64 PVProductionW float64 + PVYieldTodayKWh float64 AmbientTempC float64 CompressorPowerW float64 WWTopTempC float64 @@ -281,6 +282,7 @@ func (s *Store) Handler() http.HandlerFunc { BatterySOC: s.state.BatterySOC, GridPowerW: s.state.GridPowerW, PVProductionW: s.state.PVProductionW, + PVYieldTodayKWh: s.state.PVYieldTodayKWh, AmbientTempC: s.state.AmbientTempC, CompressorPowerW: s.state.CompressorPowerW, WWTopTempC: s.state.WWTopTempC, @@ -833,6 +835,7 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }