Fix proactive charging, logging, and status page ordering

1. SOC high bypass: when SOC >= soc_high_bypass_pct (default 90%), activate
   car charging without requiring a good forecast. Solves today's missed
   charging window where 4kW was exported for 6h with a full battery because
   the intraday forecast dropped from 15.4 to 13.3 kWh.

2. Log ambient_c in every cycle: makes it diagnosable why isHeatingPeriod
   suppresses SG-Ready on warm days (heating_min_ambient_c: 15°C gate).

3. Consumer names in logs: replace slog integer Consumer values with
   .String() so logs show "wallbox_a" instead of "2".

4. Status page order: reorder consumers by EMS priority (WallboxA →
   WallboxB → WW → SG-Ready) instead of the old reversed order.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 20:47:56 +02:00
parent f45d23df97
commit 2728755738
5 changed files with 54 additions and 29 deletions

View File

@@ -260,8 +260,22 @@ func (e *Engine) evaluateCarCharging(state collector.SystemState, forecastKWh fl
return nil
}
// Forecast must be available and meet the minimum threshold (mid = worthwhile day)
if forecastKWh == 0 || forecastKWh < float64(e.cfg.Strategic.ForecastMidKWh) {
// Determine activation gate: either forecast is good, or SOC is already high
// (battery full → activate regardless of forecast; any PV surplus should charge the car).
socHighBypass := float64(cc.SOCHighBypassPct)
if socHighBypass == 0 {
socHighBypass = 90 // default: bypass forecast check when battery ≥ 90%
}
forecastOK := forecastKWh > 0 && forecastKWh >= float64(e.cfg.Strategic.ForecastMidKWh)
socHigh := state.BatterySOC >= socHighBypass
if !forecastOK && !socHigh {
e.logger.Debug("proactive car charging: skipped",
"forecast_kwh", forecastKWh,
"forecast_threshold", e.cfg.Strategic.ForecastMidKWh,
"soc", state.BatterySOC,
"soc_bypass_pct", socHighBypass,
)
return nil
}
@@ -297,10 +311,14 @@ func (e *Engine) evaluateCarCharging(state collector.SystemState, forecastKWh fl
csA.ProactiveCharging = true
csA.LowPowerCycles = 0
delete(e.hyst.ExportSinceAbove, ConsumerWallboxA)
return []Action{{
trigger := fmt.Sprintf("forecast %.1fkWh", forecastKWh)
if socHigh && !forecastOK {
trigger = fmt.Sprintf("SOC %.0f%% ≥ %.0f%% (bypass)", state.BatterySOC, socHighBypass)
}
return []Action{{
Consumer: ConsumerWallboxA,
TurnOn: true,
Reason: fmt.Sprintf("proactive: PV %.0fW ≥ %.0fW, forecast %.1fkWh, SOC %.0f%%", state.PVProductionW, cc.PVThresholdAW, forecastKWh, state.BatterySOC),
Reason: fmt.Sprintf("proactive: PV %.0fW ≥ %.0fW, %s", state.PVProductionW, cc.PVThresholdAW, trigger),
}}
}
}
@@ -330,10 +348,14 @@ func (e *Engine) evaluateCarCharging(state collector.SystemState, forecastKWh fl
csB.ProbeStartGridW = state.GridPowerW
csB.LowPowerCycles = 0
delete(e.hyst.ExportSinceAbove, ConsumerWallboxB)
trigger := fmt.Sprintf("forecast %.1fkWh", forecastKWh)
if socHigh && !forecastOK {
trigger = fmt.Sprintf("SOC %.0f%% ≥ %.0f%% (bypass)", state.BatterySOC, socHighBypass)
}
return []Action{{
Consumer: ConsumerWallboxB,
TurnOn: true,
Reason: fmt.Sprintf("proactive: PV %.0fW ≥ %.0fW, forecast %.1fkWh, SOC %.0f%%", state.PVProductionW, cc.PVThresholdBW, forecastKWh, state.BatterySOC),
Reason: fmt.Sprintf("proactive: PV %.0fW ≥ %.0fW, %s", state.PVProductionW, cc.PVThresholdBW, trigger),
}}
}
}
@@ -545,7 +567,7 @@ func (e *Engine) evaluateTurnOn(
// All conditions met — turn on
e.logger.Info("turning on consumer",
"consumer", consumer,
"consumer", consumer.String(),
"grid_w", gridW,
"threshold", threshold,
"export_duration", exportDuration,
@@ -581,7 +603,7 @@ func (e *Engine) shutdownLastConsumer(now time.Time, gridW float64) *Action {
// Manually overridden to ON — don't shut down until override expires
if cs.ManualOverride && now.Before(cs.OverrideUntil) {
e.logger.Debug("skipping shutdown, consumer is manually overridden",
"consumer", c,
"consumer", c.String(),
"override_until", cs.OverrideUntil.Format("15:04"),
)
continue
@@ -590,7 +612,7 @@ func (e *Engine) shutdownLastConsumer(now time.Time, gridW float64) *Action {
// Proactive wallboxes are not shut down by import hysteresis.
// Their stops are handled by EOD soft stop, no-car probe, and idle cycles.
if cs.ProactiveCharging && (c == ConsumerWallboxA || c == ConsumerWallboxB) {
e.logger.Debug("skipping shutdown, proactive car charging active", "consumer", c)
e.logger.Debug("skipping shutdown, proactive car charging active", "consumer", c.String())
continue
}
@@ -605,7 +627,7 @@ func (e *Engine) shutdownLastConsumer(now time.Time, gridW float64) *Action {
}
if acceptedImportW > 0 && gridW <= acceptedImportW {
e.logger.Debug("skipping shutdown, import within accepted tolerance",
"consumer", c,
"consumer", c.String(),
"grid_w", gridW,
"accepted_import_w", acceptedImportW,
)
@@ -616,7 +638,7 @@ func (e *Engine) shutdownLastConsumer(now time.Time, gridW float64) *Action {
runtime := now.Sub(cs.ActivatedAt)
if runtime < minRuntime {
e.logger.Debug("skipping shutdown, min runtime not reached",
"consumer", c,
"consumer", c.String(),
"runtime", runtime,
"min_runtime", minRuntime,
)
@@ -624,7 +646,7 @@ func (e *Engine) shutdownLastConsumer(now time.Time, gridW float64) *Action {
}
e.logger.Info("shutting down consumer",
"consumer", c,
"consumer", c.String(),
"runtime", runtime,
)
@@ -669,7 +691,7 @@ func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action {
}
e.logger.Warn("SOC emergency brake",
"consumer", c,
"consumer", c.String(),
"soc", soc,
"was_override", cs.ManualOverride,
)
@@ -708,7 +730,7 @@ func (e *Engine) overrideHardStop(gridW float64) []Action {
}
e.logger.Warn("override hard stop: import exceeds limit",
"consumer", c,
"consumer", c.String(),
"grid_w", gridW,
"limit_w", limit,
)
@@ -748,7 +770,7 @@ func (e *Engine) ApplyOverride(consumer Consumer, on bool, duration time.Duratio
cs.ActivatedAt = time.Time{}
}
e.logger.Info("manual override applied",
"consumer", consumer,
"consumer", consumer.String(),
"on", on,
"duration", duration,
"until", cs.OverrideUntil.Format("15:04"),
@@ -943,7 +965,7 @@ func (e *Engine) SyncHardwareState(states map[Consumer]DeviceStatus, now time.Ti
if cs.Active != status.On {
// External change detected
e.logger.Info("manual override detected — external state change",
"consumer", c,
"consumer", c.String(),
"engine_state", cs.Active,
"hardware_state", status.On,
"override_until", now.Add(overrideTimeout).Format("15:04"),
@@ -961,7 +983,7 @@ func (e *Engine) SyncHardwareState(states map[Consumer]DeviceStatus, now time.Ti
// Override expired and state matches — resume EMS control
cs.ManualOverride = false
cs.OverrideUntil = time.Time{}
e.logger.Info("manual override expired, resuming EMS control", "consumer", c)
e.logger.Info("manual override expired, resuming EMS control", "consumer", c.String())
}
// Track low-power cycles for car-not-charging detection (PM devices only).
@@ -970,7 +992,7 @@ func (e *Engine) SyncHardwareState(states map[Consumer]DeviceStatus, now time.Ti
if cs.Active && status.PowerW < float64(e.cfg.Consumers.WallboxMinChargeW) {
cs.LowPowerCycles++
e.logger.Debug("wallbox low power cycle",
"consumer", c,
"consumer", c.String(),
"power_w", status.PowerW,
"low_power_cycles", cs.LowPowerCycles,
)