Add SG-Ready startup grace period and daily PV yield tracking
SG-Ready startup grace period (sg_ready_startup_min: 10): Heat pump compressor takes ~9 minutes to start after receiving the SG-Ready signal. Previously, idle detection fired after only 3×2min=6min, turning off SG-Ready before the compressor had time to respond. The grace period suppresses idle detection for the configured duration after activation. 0 = disabled (tests and unconfigured deployments are unaffected). Daily PV yield (photovoltaic_production_cumulated_currentDay): Reads actual kWh produced today from Prometheus (Wh → kWh, ÷1000). Logged as pv_today_kwh each cycle. Shown on status page in two places: - PV card sub-line: "Heute: 31.9 kWh" - Forecast card alongside forecast: "Ist: 31.9 kWh" Immediately makes forecast vs reality visible (today: forecast 15.9, actual 31.9 kWh — explains why SOC bypass triggered). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
<div class="card">
|
||||
<div class="card-label">PV-Leistung</div>
|
||||
<div class="card-value" style="color:#d97706">{{formatW .PVProductionW}}</div>
|
||||
{{if gt .PVYieldTodayKWh 0.0}}<div class="card-sub">Heute: {{printf "%.1f" .PVYieldTodayKWh}} kWh</div>{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Temperature -->
|
||||
@@ -865,6 +868,7 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
||||
<div class="forecast-row">
|
||||
<span class="forecast-icon">{{.Forecast.Icon}}</span>
|
||||
<span class="card-value" style="color:#d97706">{{printf "%.1f" .Forecast.KWh}} kWh</span>
|
||||
{{if gt .PVYieldTodayKWh 0.0}}<span class="card-sub" style="margin-left:1rem">Ist: {{printf "%.1f" .PVYieldTodayKWh}} kWh</span>{{end}}
|
||||
</div>
|
||||
<div class="forecast-quality">{{.Forecast.Quality}}</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user