Files
EMS/configs/ems-config.yaml
Lutz Finsterle 971e22c4be 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>
2026-04-07 21:23:04 +02:00

147 lines
7.1 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# EMS Configuration
# All thresholds and timing parameters are configurable here.
prometheus:
url: "http://192.168.0.23:9090"
# Metric names from the custom Viessmann exporter
metrics:
grid_power_exchange: "pcc_transfer_power_exchange_value" # positive = import, negative = export
battery_soc: "ess_stateOfCharge_value" # 0-100%
pv_production: "photovoltaic_production_current_value" # watts
battery_power: "ess_power_value" # watts
compressor_power: "heating_compressors_0_sensors_power_value * heating_compressors_0_power_value * 10" # actual watts: sensors(%) × rated_kW(10) × 10
ambient_temp: "heating_sensors_temperature_outside_value" # °C
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)
# Shelly actuators
shelly:
sg_ready:
ip: "192.168.42.90"
gen: 1 # Gen1: /relay/0?turn=on|off
wallbox_a:
ip: "192.168.42.185"
gen: 2 # Gen2: /rpc/Switch.Set
power_w: 2000
password: "" # set Shelly admin password if auth is enabled
wallbox_b:
ip: "192.168.42.51"
gen: 2 # Gen2: /rpc/Switch.Set
power_w: 4000
password: "" # set Shelly admin password if auth is enabled
# Viessmann API (write access for WW temperature)
viessmann:
token_file: "/etc/ems/viessmann-token.json" # OAuth2 refresh token
client_id: "5d1235548dfaa01ce37db8f865d2df4d"
installation_id: "1253124"
gateway_serial: "7637415006796210"
device_id: "0"
# SOC thresholds — which consumers are allowed at which battery level
soc_thresholds:
block_all: 50 # below 50%: no consumers
sg_ready_only: 70 # 50-70%: only SG-Ready (heating period)
plus_wallbox_a: 90 # 70-90%: + Wallbox A (2kW)
all_consumers: 90 # above 90%: all consumers
# Hysteresis settings
hysteresis:
export_on_duration: "4m" # export must persist for 4 min before switching on
import_off_duration: "6m" # import must persist for 6 min before switching off
min_runtime_wallbox: "15m"
min_runtime_sg_ready: "30m"
# Power thresholds (grid_power_exchange is positive=import, so we check for negative values)
# These are the NEGATIVE thresholds (export) that trigger consumers
thresholds:
sg_ready_export_w: -500 # export > 500W → SG-Ready
ww_export_w: -500 # export > 500W → WW boost
wallbox_a_export_w: -1800 # export > 1800W → Wallbox A (total, fallback if no phase data)
wallbox_a_phase_export_w: -1500 # per-phase export needed for single-phase Wallbox A
wallbox_b_export_w: -3800 # export > 3800W → Wallbox B
import_off_w: 200 # import > 200W → start shutdown timer
# Strategic layer (PV forecast based, daily)
strategic:
forecast_high_kwh: 25
forecast_mid_kwh: 15
ww_base_c: 48 # normal WW setpoint (°C)
ww_max_setpoint_c: 60 # absolute maximum WW setpoint (°C)
ww_hysteresis_c: 5 # Viessmann switchOn = setpoint - hysteresis (°C)
ww_boost_high_c: 5 # +5°C on high-forecast days (>25 kWh)
ww_boost_mid_c: 3 # +3°C on medium-forecast days (>15 kWh)
ww_window_start: "12:30" # WW boost only allowed from 12:30
ww_window_end: "18:00" # ... until 18:00
schedule_on: "08:00"
schedule_off: "20:00"
# Consumer behavior — idle detection thresholds
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)
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)
# Season detection
season:
heating_start_month: 10 # October
heating_end_month: 4 # April
heating_min_ambient_c: 15 # above 15°C outdoor temp: no SG-Ready even within heating months
# PV forecast — forecast.solar (free, no API key required)
forecast:
enabled: true
lat: 48.7839
lon: 9.3889
declination: 20 # panel tilt in degrees
azimuth: 5 # degrees from south (south=0, west=90, east=-90)
kwp: 7.0 # installed peak power
fetch_interval: "4h" # re-fetch during the day (free tier: 12 req/day → 3 fetches)
fetch_window_start: "07:00"
fetch_window_end: "19:00"
base_load_w: 450 # steady-state house consumption (W) — spikes from kettle/dishwasher are hourly averages and negligible
min_surplus_w: 1800 # min export surplus to trigger wallbox — matches wallbox_a threshold
# EMS operational settings
ems:
poll_interval: "2m"
listen_addr: ":9099" # Prometheus metrics endpoint
log_level: "info"
state_file: "/run/ems/heartbeat" # written each cycle for state recovery
recovery_timeout: "1h" # ignore Shelly state if EMS was down longer
override_timeout: "1h" # default lockout when EMS detects external Shelly change
override_max_import_w: 800 # cancel override immediately if importing more than this (0 = disabled)
monitor_only_file: "/etc/ems/monitor-only" # presence of this file = monitor-only mode active
ww_boost_disable_file: "/etc/ems/ww-boost-off" # presence of this file = WW boost disabled
trip_goal_file: "/var/lib/ems/trip-goal.json" # persisted active trip goal
session_log_file: "/var/lib/ems/sessions.jsonl" # JSONL log of completed charge sessions
# Battery physical properties (for EOD soft-stop calculation)
battery:
capacity_kwh: 8.0 # usable battery capacity (kWh)
# Proactive car charging strategy (forecast-driven, no export threshold required)
car_charging:
min_soc: 35 # start car charging only if battery SOC ≥ this (%)
soc_floor: 5 # emergency brake floor — stop if SOC drops below this (%)
eod_soc_target: 90 # desired battery SOC at end of day / sunset (%)
eod_time: "16:00" # start EOD soft-stop checks after this time
no_car_retry_min: 30 # minutes before retrying after no-car detection
pv_threshold_a_w: 1000 # min PV production to start WallboxA (W) — 50% of 2kW rated
pv_threshold_b_w: 2000 # min PV production to start WallboxB (W) — 50% of 4kW rated
grid_delta_thresh_w: 2000 # min grid power shift after WallboxB activation = car detected (W)
# Known car profiles for trip mode
cars:
mini:
name: "Mini Cooper SE"
battery_kwh: 50.0
bmw:
name: "BMW ix2 eDrive20"
battery_kwh: 63.0