From 4290b24d6e086b5460f214b0b6278842a1156a6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mauricio=20Sch=C3=A4pers?= Date: Tue, 28 Apr 2026 07:54:38 +0200 Subject: [PATCH 1/3] docs(study_cases): fix incorrect default-value claims and SOC pairing wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five small corrections to the study_cases pages merged via #812 and #813: 1. reference_configs.md §1 — `prediction_horizon` is a runtime parameter, not a static config field. Replaced the misleading line in the static YAML block with a clarifying comment. 2. basic_pv.md — the original stated the scenario "matches the default configuration shipped in config_defaults.json — no parameter changes required". Defaults are actually `set_use_pv: false` and `set_use_battery: false`, so PV scenarios do require a config change. Replaced with explicit instructions. 3. good_practices.md §4 — softened the SOC auto-pair wording. The code only auto-pairs `soc_final = soc_init` when one is omitted; passing both with different values is also valid and used by some setups (e.g. systems that compute a dynamic end-of-horizon target). Now covers both cases. 4. mpc.md note — same softening as good_practices.md §4 for consistency. 5. dhw_walkthrough.md — `heating_rate` example value 4.0 was a fabrication on my part. The thermal_model.md reference docs use 5.0 (werdnum's reasonable starting value). Aligned the DHW example to match. No behavior changes, just documentation accuracy. Co-Authored-By: Claude Opus 4.7 --- docs/study_cases/basic_pv.md | 2 +- docs/study_cases/dhw_walkthrough.md | 2 +- docs/study_cases/good_practices.md | 4 ++-- docs/study_cases/mpc.md | 2 +- docs/study_cases/reference_configs.md | 3 ++- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/study_cases/basic_pv.md b/docs/study_cases/basic_pv.md index 0e6766288..caf8b65ff 100644 --- a/docs/study_cases/basic_pv.md +++ b/docs/study_cases/basic_pv.md @@ -15,7 +15,7 @@ This scenario adds a 5 kWp PV installation to the previous tutorial. No battery | Optimization modes | perfect-optim (backtest), dayahead-optim | | Cost function | profit | -This matches the default configuration shipped in `config_defaults.json` — no parameter changes required. +To enable PV in EMHASS, set `set_use_pv: true` (default is `false`) and configure your PV plant via `solar_forecast_kwp` (for the `solar.forecast` method) or one of the other `weather_forecast_method` options. The two deferrable loads use the default `nominal_power_of_deferrable_loads: [3000.0, 750.0]` from `config_defaults.json`. ## Perfect optimization (7-day historical backtest) diff --git a/docs/study_cases/dhw_walkthrough.md b/docs/study_cases/dhw_walkthrough.md index 8b85cc61f..df2ddf156 100644 --- a/docs/study_cases/dhw_walkthrough.md +++ b/docs/study_cases/dhw_walkthrough.md @@ -96,7 +96,7 @@ rest_command: {}, { "thermal_config": { - "heating_rate": 4.0, + "heating_rate": 5.0, "cooling_constant": 0.02, "start_temperature": {{ states('sensor.dhw_tank_temperature') | float }}, "sense": "heat", diff --git a/docs/study_cases/good_practices.md b/docs/study_cases/good_practices.md index 7f8e2bdf4..a964f2c14 100644 --- a/docs/study_cases/good_practices.md +++ b/docs/study_cases/good_practices.md @@ -45,9 +45,9 @@ If your downstream automation or display does need a different convention (e.g. For day-ahead optimization, setting `soc_final` to a desired end-of-day SOC (e.g. 0.6) ensures you don't end the day empty. EMHASS uses `battery_target_state_of_charge` (default 0.6) as the fallback when neither `soc_init` nor `soc_final` is passed. -For rolling MPC, the often-cited concern is that a fixed `soc_final` reserves capacity at the trailing edge of every horizon and biases the optimizer toward conservative mid-day behavior. This is real, but the EMHASS code already handles the common case: when you pass only `soc_init` at runtime, EMHASS auto-sets `soc_final = soc_init`. So **passing only `soc_init`** in your MPC payload is the standard rolling-MPC recipe. +For rolling MPC, the often-cited concern is that a fixed `soc_final` reserves capacity at the trailing edge of every horizon and biases the optimizer toward conservative mid-day behavior. The EMHASS code helps here for the simple case: if you pass only `soc_init` at runtime (and omit `soc_final`), EMHASS sets `soc_final = soc_init` for you, so passing just `soc_init` is enough for a basic rolling-MPC setup. -Pass `soc_final` explicitly only when you have a hard end-of-horizon target (e.g. "must be at 60% by tomorrow 06:00 to absorb morning PV"). In that case you may also want to extend the horizon so the constraint sits at the actual deadline, not 24 h after the current re-run. +If you pass both `soc_init` and `soc_final` explicitly with different values, EMHASS uses them as-is. That is appropriate when you have a hard end-of-horizon target (e.g. "must be at 60% by tomorrow 06:00 to absorb morning PV"), or when your runtime layer computes a target SOC dynamically. In that case you may also want to extend the horizon so the constraint sits at the actual deadline rather than at the trailing edge of a fixed 24 h window. A common new-user trap is the opposite: starting with very low actual SOC where `soc_init` is below `battery_minimum_state_of_charge`. The optimization becomes infeasible because the initial state already violates a constraint. See Section 5 below (item 4) for the full triage and [discussion #359](https://github.com/davidusb-geek/emhass/discussions/359) for the canonical thread. diff --git a/docs/study_cases/mpc.md b/docs/study_cases/mpc.md index 072e0391c..27cd582cc 100644 --- a/docs/study_cases/mpc.md +++ b/docs/study_cases/mpc.md @@ -30,7 +30,7 @@ In addition to the parameters from [Basic — PV + Battery](basic_pv_battery.md) For the full list of runtime keys, see [Passing data](../passing_data.md). ```{note} -`soc_init` and `soc_final` defaults: EMHASS already defaults `soc_final` to `soc_init` (and vice versa) when only one is passed at runtime; if neither is passed, both fall back to `battery_target_state_of_charge` from the static config. So for typical rolling MPC, passing only `soc_init` is sufficient: the optimizer will not impose a terminal-SOC bias inside the horizon. Pass `soc_final` explicitly only when you have a hard end-of-horizon target (e.g. ensure 60% before tomorrow morning). +`soc_init` and `soc_final` defaults: if only one is passed at runtime, EMHASS sets the other equal to it; if neither is passed, both fall back to `battery_target_state_of_charge` from the static config. Passing both with different values is also valid: EMHASS uses them as-is. For a basic rolling-MPC setup, passing only `soc_init` is enough; for systems that compute a dynamic end-of-horizon target (e.g. "must be at 60% by tomorrow 06:00"), pass both explicitly. ``` ## Run diff --git a/docs/study_cases/reference_configs.md b/docs/study_cases/reference_configs.md index a6d9e85df..e3ba1a30b 100644 --- a/docs/study_cases/reference_configs.md +++ b/docs/study_cases/reference_configs.md @@ -17,7 +17,8 @@ The most common modern setup: ~150 m² home, 8–12 kWp PV, 10–15 kWh battery, set_use_pv: true solar_forecast_kwp: 10 optimization_time_step: 30 -prediction_horizon: 48 # used by MPC +# Note: prediction_horizon is a runtime parameter (passed in the MPC payload), +# not a static config field. With this 30-min step a 24 h horizon is 48 timesteps. set_use_battery: true battery_nominal_energy_capacity: 12000 # Wh (= 12 kWh) From 86e176e9b93142fdee24d857a1f945f0e94cbf8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mauricio=20Sch=C3=A4pers?= Date: Tue, 28 Apr 2026 08:04:27 +0200 Subject: [PATCH 2/3] docs(study_cases): correct SOC runtime semantics (independent fallback, no mirror) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Earlier commit 4290b24 still asserted that EMHASS auto-mirrors soc_final = soc_init when only one is passed. That is wrong for the real REST-API code path: - src/emhass/utils.py lines 925-983 parses naive-mpc-optim runtimeparams. Missing soc_init falls back to battery_target_state_of_charge (0.6) independently of soc_final, and vice versa. No mirroring. - The defensive mirror-block in src/emhass/optimization.py lines 2477-2487 only fires if perform_optimization is called directly with one None value — never reached through the normal /action/ REST entry point. - dayahead-optim and perfect-optim do not read soc_init/soc_final from runtimeparams at all (utils.py line 980 else branch); both always use battery_target_state_of_charge. Rewrote good_practices.md §4 and mpc.md note to describe the actual behavior per action, with file:line references into the code. Co-Authored-By: Claude Opus 4.7 --- docs/study_cases/good_practices.md | 10 +++++----- docs/study_cases/mpc.md | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/study_cases/good_practices.md b/docs/study_cases/good_practices.md index a964f2c14..216bb2395 100644 --- a/docs/study_cases/good_practices.md +++ b/docs/study_cases/good_practices.md @@ -41,15 +41,15 @@ Source: `optimization.py` constraints `min_energy = battery_minimum_state_of_cha If your downstream automation or display does need a different convention (e.g. percentage of *usable* range), apply the transform yourself in a HA template. Don't assume EMHASS already did it. -## 4. `soc_init` and `soc_final` defaults are forgiving +## 4. `soc_init` and `soc_final` runtime semantics -For day-ahead optimization, setting `soc_final` to a desired end-of-day SOC (e.g. 0.6) ensures you don't end the day empty. EMHASS uses `battery_target_state_of_charge` (default 0.6) as the fallback when neither `soc_init` nor `soc_final` is passed. +The two SOC parameters behave differently across optimization actions, so it pays to know exactly what EMHASS does in each path. -For rolling MPC, the often-cited concern is that a fixed `soc_final` reserves capacity at the trailing edge of every horizon and biases the optimizer toward conservative mid-day behavior. The EMHASS code helps here for the simple case: if you pass only `soc_init` at runtime (and omit `soc_final`), EMHASS sets `soc_final = soc_init` for you, so passing just `soc_init` is enough for a basic rolling-MPC setup. +**`naive-mpc-optim`:** EMHASS reads `soc_init` and `soc_final` from `runtimeparams` independently. If a value is missing from `runtimeparams`, EMHASS falls back to `battery_target_state_of_charge` (default `0.6`) for that value alone — it does **not** mirror one to the other. So passing only `soc_init = 0.45` and omitting `soc_final` yields `soc_init = 0.45, soc_final = 0.6`, which still imposes a terminal-SOC constraint. To avoid trailing-edge bias in rolling MPC, pass both explicitly (typically `soc_init` from your battery sensor, `soc_final` to whatever target your runtime layer computes — equal to `soc_init` for a neutral trailing edge, or a fixed end-of-horizon target like `0.6` for an end-of-day reserve). See `src/emhass/utils.py` lines 925-983 for the parsing logic. -If you pass both `soc_init` and `soc_final` explicitly with different values, EMHASS uses them as-is. That is appropriate when you have a hard end-of-horizon target (e.g. "must be at 60% by tomorrow 06:00 to absorb morning PV"), or when your runtime layer computes a target SOC dynamically. In that case you may also want to extend the horizon so the constraint sits at the actual deadline rather than at the trailing edge of a fixed 24 h window. +**`dayahead-optim` and `perfect-optim`:** runtime `soc_init` and `soc_final` are **not** read from `runtimeparams`; both fall back to `battery_target_state_of_charge` unconditionally. Use day-ahead when `battery_target_state_of_charge` is the right answer for both ends of the horizon, and switch to MPC when you need runtime control of either value. -A common new-user trap is the opposite: starting with very low actual SOC where `soc_init` is below `battery_minimum_state_of_charge`. The optimization becomes infeasible because the initial state already violates a constraint. See Section 5 below (item 4) for the full triage and [discussion #359](https://github.com/davidusb-geek/emhass/discussions/359) for the canonical thread. +A common new-user trap is starting with very low actual SOC where `soc_init` is below `battery_minimum_state_of_charge`. The optimization becomes infeasible because the initial state already violates a constraint. See Section 5 below (item 4) for the full triage and [discussion #359](https://github.com/davidusb-geek/emhass/discussions/359) for the canonical thread. ## 5. `optim_status: Infeasible` triage order diff --git a/docs/study_cases/mpc.md b/docs/study_cases/mpc.md index 27cd582cc..f6319e92d 100644 --- a/docs/study_cases/mpc.md +++ b/docs/study_cases/mpc.md @@ -30,7 +30,7 @@ In addition to the parameters from [Basic — PV + Battery](basic_pv_battery.md) For the full list of runtime keys, see [Passing data](../passing_data.md). ```{note} -`soc_init` and `soc_final` defaults: if only one is passed at runtime, EMHASS sets the other equal to it; if neither is passed, both fall back to `battery_target_state_of_charge` from the static config. Passing both with different values is also valid: EMHASS uses them as-is. For a basic rolling-MPC setup, passing only `soc_init` is enough; for systems that compute a dynamic end-of-horizon target (e.g. "must be at 60% by tomorrow 06:00"), pass both explicitly. +`soc_init` and `soc_final` are read from `runtimeparams` **independently**. If one is omitted, EMHASS substitutes `battery_target_state_of_charge` (default `0.6`) for that one value — it does **not** mirror the passed value onto the missing one. To avoid an unintended terminal-SOC constraint in rolling MPC, pass both explicitly: typically `soc_init` from your battery sensor and `soc_final` to whatever target your runtime layer computes (equal to `soc_init` for a neutral trailing edge, or a fixed end-of-horizon target). See `src/emhass/utils.py` lines 925-983 for the parsing logic. ``` ## Run From f1919254490fe8fa8ee2c73c256f870f8d776557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mauricio=20Sch=C3=A4pers?= Date: Tue, 28 Apr 2026 08:20:18 +0200 Subject: [PATCH 3/3] docs(study_cases): drop utils.py line refs, add rolling-MPC SOC recipes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two improvements to the SOC runtime semantics description: 1. Removed the explicit "src/emhass/utils.py lines 925-983" pointer. Line numbers drift with future code changes and shouldn't be hard-coded in user-facing docs. 2. Added two production-tested rolling-MPC recipes: - soc_final = soc_init (current SOC for both, neutral trailing edge) - soc_final = 0 (target is never reached because each 24 h horizon is replaced before the deadline arrives; behaves the same in practice) Both patterns require passing both values explicitly per call, since omitting one falls back to battery_target_state_of_charge for that value alone (no mirroring). Same edits applied to good_practices.md §4 and the mpc.md note for consistency. Co-Authored-By: Claude Opus 4.7 --- docs/study_cases/good_practices.md | 15 ++++++++++++--- docs/study_cases/mpc.md | 2 +- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/study_cases/good_practices.md b/docs/study_cases/good_practices.md index 216bb2395..e1e7f9954 100644 --- a/docs/study_cases/good_practices.md +++ b/docs/study_cases/good_practices.md @@ -43,11 +43,20 @@ If your downstream automation or display does need a different convention (e.g. ## 4. `soc_init` and `soc_final` runtime semantics -The two SOC parameters behave differently across optimization actions, so it pays to know exactly what EMHASS does in each path. +The two SOC parameters behave differently across optimization actions, so it pays to know exactly what EMHASS does with each. -**`naive-mpc-optim`:** EMHASS reads `soc_init` and `soc_final` from `runtimeparams` independently. If a value is missing from `runtimeparams`, EMHASS falls back to `battery_target_state_of_charge` (default `0.6`) for that value alone — it does **not** mirror one to the other. So passing only `soc_init = 0.45` and omitting `soc_final` yields `soc_init = 0.45, soc_final = 0.6`, which still imposes a terminal-SOC constraint. To avoid trailing-edge bias in rolling MPC, pass both explicitly (typically `soc_init` from your battery sensor, `soc_final` to whatever target your runtime layer computes — equal to `soc_init` for a neutral trailing edge, or a fixed end-of-horizon target like `0.6` for an end-of-day reserve). See `src/emhass/utils.py` lines 925-983 for the parsing logic. +**`naive-mpc-optim`** reads `soc_init` and `soc_final` from `runtimeparams` independently. If one is missing, EMHASS substitutes `battery_target_state_of_charge` (default `0.6`) for that value alone; it does **not** mirror the passed value onto the missing one. So passing only `soc_init = 0.45` yields `soc_init = 0.45, soc_final = 0.6`, which still imposes a terminal-SOC constraint at every solve. Always pass both explicitly. -**`dayahead-optim` and `perfect-optim`:** runtime `soc_init` and `soc_final` are **not** read from `runtimeparams`; both fall back to `battery_target_state_of_charge` unconditionally. Use day-ahead when `battery_target_state_of_charge` is the right answer for both ends of the horizon, and switch to MPC when you need runtime control of either value. +**`dayahead-optim`** and **`perfect-optim`** do not read `soc_init` or `soc_final` from `runtimeparams` at all. Both fall back to `battery_target_state_of_charge`. Use day-ahead when that single value is the right answer for both ends of the horizon, and switch to MPC when you need runtime control of either. + +### Practical recipes for rolling MPC + +Two patterns work well in production. Both pass *both* values explicitly per MPC call, just with different `soc_final`: + +- **`soc_final = soc_init`** (pass current measured SOC for both). The trailing-edge constraint becomes neutral: the battery's end-of-horizon SOC is allowed to land wherever it started, so the optimizer is free to use it inside the horizon. Good for systems with no hard end-of-day target. +- **`soc_final = 0`** (or `battery_minimum_state_of_charge` if you prefer to stay above the floor). With a 48-step (24 h) rolling horizon and re-runs every 30 min, the deadline at step 48 is always 24 h ahead. Each run replaces the schedule before that deadline ever arrives, so the trailing target is never actually reached. In practice the optimizer behaves the same as the neutral-edge case, just expressed differently. Useful when your runtime layer wants a single static `soc_final` value rather than tracking the live sensor. + +If you do have a real end-of-horizon deadline (for example "must be at 60% before tomorrow 06:00 to absorb morning PV"), pass that target as `soc_final` and extend `prediction_horizon` so the deadline sits at the actual point in time, not at the trailing edge of a fixed 24 h window. A common new-user trap is starting with very low actual SOC where `soc_init` is below `battery_minimum_state_of_charge`. The optimization becomes infeasible because the initial state already violates a constraint. See Section 5 below (item 4) for the full triage and [discussion #359](https://github.com/davidusb-geek/emhass/discussions/359) for the canonical thread. diff --git a/docs/study_cases/mpc.md b/docs/study_cases/mpc.md index f6319e92d..22f5656fd 100644 --- a/docs/study_cases/mpc.md +++ b/docs/study_cases/mpc.md @@ -30,7 +30,7 @@ In addition to the parameters from [Basic — PV + Battery](basic_pv_battery.md) For the full list of runtime keys, see [Passing data](../passing_data.md). ```{note} -`soc_init` and `soc_final` are read from `runtimeparams` **independently**. If one is omitted, EMHASS substitutes `battery_target_state_of_charge` (default `0.6`) for that one value — it does **not** mirror the passed value onto the missing one. To avoid an unintended terminal-SOC constraint in rolling MPC, pass both explicitly: typically `soc_init` from your battery sensor and `soc_final` to whatever target your runtime layer computes (equal to `soc_init` for a neutral trailing edge, or a fixed end-of-horizon target). See `src/emhass/utils.py` lines 925-983 for the parsing logic. +`soc_init` and `soc_final` are read from `runtimeparams` independently. If one is omitted, EMHASS substitutes `battery_target_state_of_charge` (default `0.6`) for that single value; it does **not** mirror the passed value onto the missing one. Always pass both explicitly. Two production-tested rolling-MPC patterns: `soc_final = soc_init` (current SOC for both, neutral trailing edge) or `soc_final = 0` (with a 24 h horizon re-run every 30 min, the deadline is always 24 h away and never reached, so this behaves the same in practice). For a hard end-of-horizon target, pass that value and extend `prediction_horizon` so the deadline sits at the real point in time. ``` ## Run