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:
@@ -127,8 +127,9 @@ battery:
|
|||||||
|
|
||||||
# Proactive car charging strategy (forecast-driven, no export threshold required)
|
# Proactive car charging strategy (forecast-driven, no export threshold required)
|
||||||
car_charging:
|
car_charging:
|
||||||
min_soc: 35 # start car charging only if battery SOC ≥ this (%)
|
min_soc: 25 # start car charging only if battery SOC ≥ this (%)
|
||||||
soc_floor: 5 # emergency brake floor — stop if SOC drops below this (%)
|
soc_floor: 5 # emergency brake floor — stop if SOC drops below this (%)
|
||||||
|
soc_high_bypass_pct: 90 # activate without forecast check when SOC ≥ this % (battery full → always charge)
|
||||||
eod_soc_target: 90 # desired battery SOC at end of day / sunset (%)
|
eod_soc_target: 90 # desired battery SOC at end of day / sunset (%)
|
||||||
eod_time: "16:00" # start EOD soft-stop checks after this time
|
eod_time: "16:00" # start EOD soft-stop checks after this time
|
||||||
no_car_retry_min: 30 # minutes before retrying after no-car detection
|
no_car_retry_min: 30 # minutes before retrying after no-car detection
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ func (c *Collector) Collect(ctx context.Context) (SystemState, error) {
|
|||||||
"soc", state.BatterySOC,
|
"soc", state.BatterySOC,
|
||||||
"pv_w", state.PVProductionW,
|
"pv_w", state.PVProductionW,
|
||||||
"compressor_w", state.CompressorPowerW,
|
"compressor_w", state.CompressorPowerW,
|
||||||
|
"ambient_c", state.AmbientTempC,
|
||||||
"l1_w", state.PhaseL1PowerW,
|
"l1_w", state.PhaseL1PowerW,
|
||||||
"l2_w", state.PhaseL2PowerW,
|
"l2_w", state.PhaseL2PowerW,
|
||||||
"l3_w", state.PhaseL3PowerW,
|
"l3_w", state.PhaseL3PowerW,
|
||||||
|
|||||||
@@ -33,14 +33,15 @@ type BatteryConfig struct {
|
|||||||
|
|
||||||
// CarChargingConfig holds parameters for the proactive car charging strategy.
|
// CarChargingConfig holds parameters for the proactive car charging strategy.
|
||||||
type CarChargingConfig struct {
|
type CarChargingConfig struct {
|
||||||
MinSOC int `yaml:"min_soc"` // minimum SOC% to start car charging (e.g. 35)
|
MinSOC int `yaml:"min_soc"` // minimum SOC% to start car charging (e.g. 25)
|
||||||
SOCFloor int `yaml:"soc_floor"` // never drain battery below this % (e.g. 5)
|
SOCFloor int `yaml:"soc_floor"` // never drain battery below this % (e.g. 5)
|
||||||
EODSOCTarget int `yaml:"eod_soc_target"` // target SOC% to reach by sunset (e.g. 90)
|
SOCHighBypassPct int `yaml:"soc_high_bypass_pct"` // activate without forecast check when SOC ≥ this % (0 → default 90%)
|
||||||
EODTime string `yaml:"eod_time"` // soft-stop check starts at this time (e.g. "16:00")
|
EODSOCTarget int `yaml:"eod_soc_target"` // target SOC% to reach by sunset (e.g. 90)
|
||||||
NoCarRetryMin int `yaml:"no_car_retry_min"` // minutes before retrying after no-car detection
|
EODTime string `yaml:"eod_time"` // soft-stop check starts at this time (e.g. "16:00")
|
||||||
PVThresholdAW float64 `yaml:"pv_threshold_a_w"` // min PV production to start WallboxA (W)
|
NoCarRetryMin int `yaml:"no_car_retry_min"` // minutes before retrying after no-car detection
|
||||||
PVThresholdBW float64 `yaml:"pv_threshold_b_w"` // min PV production to start WallboxB (W)
|
PVThresholdAW float64 `yaml:"pv_threshold_a_w"` // min PV production to start WallboxA (W)
|
||||||
GridDeltaThreshW float64 `yaml:"grid_delta_thresh_w"` // min grid power shift after WallboxB activation = car detected (W)
|
PVThresholdBW float64 `yaml:"pv_threshold_b_w"` // min PV production to start WallboxB (W)
|
||||||
|
GridDeltaThreshW float64 `yaml:"grid_delta_thresh_w"` // min grid power shift after WallboxB activation = car detected (W)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CarChargingConfig) EODTimeParsed(ref time.Time) time.Time {
|
func (c *CarChargingConfig) EODTimeParsed(ref time.Time) time.Time {
|
||||||
|
|||||||
@@ -260,8 +260,22 @@ func (e *Engine) evaluateCarCharging(state collector.SystemState, forecastKWh fl
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forecast must be available and meet the minimum threshold (mid = worthwhile day)
|
// Determine activation gate: either forecast is good, or SOC is already high
|
||||||
if forecastKWh == 0 || forecastKWh < float64(e.cfg.Strategic.ForecastMidKWh) {
|
// (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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,10 +311,14 @@ func (e *Engine) evaluateCarCharging(state collector.SystemState, forecastKWh fl
|
|||||||
csA.ProactiveCharging = true
|
csA.ProactiveCharging = true
|
||||||
csA.LowPowerCycles = 0
|
csA.LowPowerCycles = 0
|
||||||
delete(e.hyst.ExportSinceAbove, ConsumerWallboxA)
|
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,
|
Consumer: ConsumerWallboxA,
|
||||||
TurnOn: true,
|
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.ProbeStartGridW = state.GridPowerW
|
||||||
csB.LowPowerCycles = 0
|
csB.LowPowerCycles = 0
|
||||||
delete(e.hyst.ExportSinceAbove, ConsumerWallboxB)
|
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{{
|
return []Action{{
|
||||||
Consumer: ConsumerWallboxB,
|
Consumer: ConsumerWallboxB,
|
||||||
TurnOn: true,
|
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
|
// All conditions met — turn on
|
||||||
e.logger.Info("turning on consumer",
|
e.logger.Info("turning on consumer",
|
||||||
"consumer", consumer,
|
"consumer", consumer.String(),
|
||||||
"grid_w", gridW,
|
"grid_w", gridW,
|
||||||
"threshold", threshold,
|
"threshold", threshold,
|
||||||
"export_duration", exportDuration,
|
"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
|
// Manually overridden to ON — don't shut down until override expires
|
||||||
if cs.ManualOverride && now.Before(cs.OverrideUntil) {
|
if cs.ManualOverride && now.Before(cs.OverrideUntil) {
|
||||||
e.logger.Debug("skipping shutdown, consumer is manually overridden",
|
e.logger.Debug("skipping shutdown, consumer is manually overridden",
|
||||||
"consumer", c,
|
"consumer", c.String(),
|
||||||
"override_until", cs.OverrideUntil.Format("15:04"),
|
"override_until", cs.OverrideUntil.Format("15:04"),
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -590,7 +612,7 @@ func (e *Engine) shutdownLastConsumer(now time.Time, gridW float64) *Action {
|
|||||||
// Proactive wallboxes are not shut down by import hysteresis.
|
// Proactive wallboxes are not shut down by import hysteresis.
|
||||||
// Their stops are handled by EOD soft stop, no-car probe, and idle cycles.
|
// Their stops are handled by EOD soft stop, no-car probe, and idle cycles.
|
||||||
if cs.ProactiveCharging && (c == ConsumerWallboxA || c == ConsumerWallboxB) {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,7 +627,7 @@ func (e *Engine) shutdownLastConsumer(now time.Time, gridW float64) *Action {
|
|||||||
}
|
}
|
||||||
if acceptedImportW > 0 && gridW <= acceptedImportW {
|
if acceptedImportW > 0 && gridW <= acceptedImportW {
|
||||||
e.logger.Debug("skipping shutdown, import within accepted tolerance",
|
e.logger.Debug("skipping shutdown, import within accepted tolerance",
|
||||||
"consumer", c,
|
"consumer", c.String(),
|
||||||
"grid_w", gridW,
|
"grid_w", gridW,
|
||||||
"accepted_import_w", acceptedImportW,
|
"accepted_import_w", acceptedImportW,
|
||||||
)
|
)
|
||||||
@@ -616,7 +638,7 @@ func (e *Engine) shutdownLastConsumer(now time.Time, gridW float64) *Action {
|
|||||||
runtime := now.Sub(cs.ActivatedAt)
|
runtime := now.Sub(cs.ActivatedAt)
|
||||||
if runtime < minRuntime {
|
if runtime < minRuntime {
|
||||||
e.logger.Debug("skipping shutdown, min runtime not reached",
|
e.logger.Debug("skipping shutdown, min runtime not reached",
|
||||||
"consumer", c,
|
"consumer", c.String(),
|
||||||
"runtime", runtime,
|
"runtime", runtime,
|
||||||
"min_runtime", minRuntime,
|
"min_runtime", minRuntime,
|
||||||
)
|
)
|
||||||
@@ -624,7 +646,7 @@ func (e *Engine) shutdownLastConsumer(now time.Time, gridW float64) *Action {
|
|||||||
}
|
}
|
||||||
|
|
||||||
e.logger.Info("shutting down consumer",
|
e.logger.Info("shutting down consumer",
|
||||||
"consumer", c,
|
"consumer", c.String(),
|
||||||
"runtime", runtime,
|
"runtime", runtime,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -669,7 +691,7 @@ func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action {
|
|||||||
}
|
}
|
||||||
|
|
||||||
e.logger.Warn("SOC emergency brake",
|
e.logger.Warn("SOC emergency brake",
|
||||||
"consumer", c,
|
"consumer", c.String(),
|
||||||
"soc", soc,
|
"soc", soc,
|
||||||
"was_override", cs.ManualOverride,
|
"was_override", cs.ManualOverride,
|
||||||
)
|
)
|
||||||
@@ -708,7 +730,7 @@ func (e *Engine) overrideHardStop(gridW float64) []Action {
|
|||||||
}
|
}
|
||||||
|
|
||||||
e.logger.Warn("override hard stop: import exceeds limit",
|
e.logger.Warn("override hard stop: import exceeds limit",
|
||||||
"consumer", c,
|
"consumer", c.String(),
|
||||||
"grid_w", gridW,
|
"grid_w", gridW,
|
||||||
"limit_w", limit,
|
"limit_w", limit,
|
||||||
)
|
)
|
||||||
@@ -748,7 +770,7 @@ func (e *Engine) ApplyOverride(consumer Consumer, on bool, duration time.Duratio
|
|||||||
cs.ActivatedAt = time.Time{}
|
cs.ActivatedAt = time.Time{}
|
||||||
}
|
}
|
||||||
e.logger.Info("manual override applied",
|
e.logger.Info("manual override applied",
|
||||||
"consumer", consumer,
|
"consumer", consumer.String(),
|
||||||
"on", on,
|
"on", on,
|
||||||
"duration", duration,
|
"duration", duration,
|
||||||
"until", cs.OverrideUntil.Format("15:04"),
|
"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 {
|
if cs.Active != status.On {
|
||||||
// External change detected
|
// External change detected
|
||||||
e.logger.Info("manual override detected — external state change",
|
e.logger.Info("manual override detected — external state change",
|
||||||
"consumer", c,
|
"consumer", c.String(),
|
||||||
"engine_state", cs.Active,
|
"engine_state", cs.Active,
|
||||||
"hardware_state", status.On,
|
"hardware_state", status.On,
|
||||||
"override_until", now.Add(overrideTimeout).Format("15:04"),
|
"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
|
// Override expired and state matches — resume EMS control
|
||||||
cs.ManualOverride = false
|
cs.ManualOverride = false
|
||||||
cs.OverrideUntil = time.Time{}
|
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).
|
// 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) {
|
if cs.Active && status.PowerW < float64(e.cfg.Consumers.WallboxMinChargeW) {
|
||||||
cs.LowPowerCycles++
|
cs.LowPowerCycles++
|
||||||
e.logger.Debug("wallbox low power cycle",
|
e.logger.Debug("wallbox low power cycle",
|
||||||
"consumer", c,
|
"consumer", c.String(),
|
||||||
"power_w", status.PowerW,
|
"power_w", status.PowerW,
|
||||||
"low_power_cycles", cs.LowPowerCycles,
|
"low_power_cycles", cs.LowPowerCycles,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ var consumerMeta = map[engine.Consumer]struct{ Label, Icon string }{
|
|||||||
engine.ConsumerWallboxB: {"Wallbox B (4 kW)", "🔌"},
|
engine.ConsumerWallboxB: {"Wallbox B (4 kW)", "🔌"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// consumerOrder defines the display order of consumers.
|
// consumerOrder defines the display order of consumers — highest EMS priority first.
|
||||||
var consumerOrder = []engine.Consumer{
|
var consumerOrder = []engine.Consumer{
|
||||||
engine.ConsumerSGReady,
|
|
||||||
engine.ConsumerWW,
|
|
||||||
engine.ConsumerWallboxA,
|
engine.ConsumerWallboxA,
|
||||||
engine.ConsumerWallboxB,
|
engine.ConsumerWallboxB,
|
||||||
|
engine.ConsumerWW,
|
||||||
|
engine.ConsumerSGReady,
|
||||||
}
|
}
|
||||||
|
|
||||||
// consumerRecord tracks the state of a single consumer across cycles.
|
// consumerRecord tracks the state of a single consumer across cycles.
|
||||||
|
|||||||
Reference in New Issue
Block a user