Implement proactive forecast-driven car charging strategy

- Replace reactive export-threshold wallbox activation with proactive
  logic: WallboxA/B activate when forecast ≥ mid AND PV ≥ threshold
  AND SOC ≥ 35%, without requiring grid export surplus
- Add WallboxB no-car detection via grid-delta probe (no PM available):
  after probe window, if grid shift < GridDeltaThreshW → no car, retry
  after configured timeout
- Add EOD soft stop: after 16:00, stop proactive car charging if
  remaining PV estimate can't cover battery deficit to 90% by sunset
- WW boost no longer requires export threshold; dynamic setpoint uses
  tank top temp + hysteresis + boost delta, capped at 60°C
- Proactive wallboxes bypass import-hysteresis shutdown; SOC emergency
  brake uses SOCFloor (5%) instead of standard AllConsumers gate
- Add WWTopTempC to SystemState (ww_top_temp metric from DHW cylinder)
- Add BatteryConfig (capacity_kwh), CarChargingConfig to config
- Add WWMaxSetpointC, WWHysteresisC to StrategicConfig
- Update Decide() signature: forecastKWh + sunsetTime parameters
- Update all tests; add proactive charging test cases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 21:23:04 +02:00
parent fc535d395c
commit 971e22c4be
6 changed files with 447 additions and 114 deletions

View File

@@ -10,18 +10,43 @@ import (
// Config is the top-level EMS configuration.
type Config struct {
Prometheus PrometheusConfig `yaml:"prometheus"`
Shelly ShellyConfig `yaml:"shelly"`
Viessmann ViessmannConfig `yaml:"viessmann"`
SOC SOCThresholds `yaml:"soc_thresholds"`
Hysteresis HysteresisConfig `yaml:"hysteresis"`
Thresholds PowerThresholds `yaml:"thresholds"`
Consumers ConsumersConfig `yaml:"consumers"`
Strategic StrategicConfig `yaml:"strategic"`
Season SeasonConfig `yaml:"season"`
Forecast ForecastConfig `yaml:"forecast"`
Cars map[string]CarProfile `yaml:"cars"`
EMS EMSConfig `yaml:"ems"`
Prometheus PrometheusConfig `yaml:"prometheus"`
Shelly ShellyConfig `yaml:"shelly"`
Viessmann ViessmannConfig `yaml:"viessmann"`
SOC SOCThresholds `yaml:"soc_thresholds"`
Hysteresis HysteresisConfig `yaml:"hysteresis"`
Thresholds PowerThresholds `yaml:"thresholds"`
Consumers ConsumersConfig `yaml:"consumers"`
Strategic StrategicConfig `yaml:"strategic"`
Season SeasonConfig `yaml:"season"`
Forecast ForecastConfig `yaml:"forecast"`
Battery BatteryConfig `yaml:"battery"`
CarCharging CarChargingConfig `yaml:"car_charging"`
Cars map[string]CarProfile `yaml:"cars"`
EMS EMSConfig `yaml:"ems"`
}
// BatteryConfig holds physical battery properties.
type BatteryConfig struct {
CapacityKWh float64 `yaml:"capacity_kwh"` // usable battery capacity in kWh
}
// CarChargingConfig holds parameters for the proactive car charging strategy.
type CarChargingConfig struct {
MinSOC int `yaml:"min_soc"` // minimum SOC% to start car charging (e.g. 35)
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)
EODTime string `yaml:"eod_time"` // soft-stop check starts at this time (e.g. "16:00")
NoCarRetryMin int `yaml:"no_car_retry_min"` // minutes before retrying after no-car detection
PVThresholdAW float64 `yaml:"pv_threshold_a_w"` // min PV production to start WallboxA (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 {
var h, m int
fmt.Sscanf(c.EODTime, "%d:%d", &h, &m)
return time.Date(ref.Year(), ref.Month(), ref.Day(), h, m, 0, 0, ref.Location())
}
// CarProfile holds the display name and battery capacity of a known vehicle.
@@ -122,9 +147,11 @@ type StrategicConfig struct {
ForecastMidKWh float64 `yaml:"forecast_mid_kwh"`
WWBoostHighC float64 `yaml:"ww_boost_high_c"`
WWBoostMidC float64 `yaml:"ww_boost_mid_c"`
WWBaseC float64 `yaml:"ww_base_c"` // normal WW setpoint (°C)
WWWindowStart string `yaml:"ww_window_start"` // e.g. "12:30"
WWWindowEnd string `yaml:"ww_window_end"` // e.g. "18:00"
WWBaseC float64 `yaml:"ww_base_c"` // normal WW setpoint (°C)
WWMaxSetpointC float64 `yaml:"ww_max_setpoint_c"` // absolute maximum WW setpoint (°C), e.g. 60
WWHysteresisC float64 `yaml:"ww_hysteresis_c"` // Viessmann switchOn = setpoint - hysteresis (°C), e.g. 5
WWWindowStart string `yaml:"ww_window_start"` // e.g. "12:30"
WWWindowEnd string `yaml:"ww_window_end"` // e.g. "18:00"
ScheduleOn string `yaml:"schedule_on"`
ScheduleOff string `yaml:"schedule_off"`
}