From 49ea062dc89d1619f4cd124478838f47132eb862 Mon Sep 17 00:00:00 2001 From: Andreas <35328755+drbacke@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:02:19 +0200 Subject: [PATCH] fix(genetic): allow optimization without fresh SoC (#1403) --- docs/_generated/configoptimization.md | 2 +- docs/_generated/openapi.md | 4 +- docs/akkudoktoreos/upgrade-genetic.md | 16 +++-- openapi.json | 8 +-- .../optimization/genetic/configrequest.py | 17 +++-- .../optimization/genetic/geneticsettings.py | 5 +- src/akkudoktoreos/server/eos.py | 6 +- tests/test_config_optimization_request.py | 71 ++++++++++++++++--- 8 files changed, 103 insertions(+), 26 deletions(-) diff --git a/docs/_generated/configoptimization.md b/docs/_generated/configoptimization.md index 0fa95e78..131b46ac 100644 --- a/docs/_generated/configoptimization.md +++ b/docs/_generated/configoptimization.md @@ -188,7 +188,7 @@ | horizon_hours | `int` | `rw` | `24` | The general time window within which the energy optimization goal shall be achieved [h]. Defaults to 24 hours. | | individuals | `Optional[int]` | `rw` | `300` | Number of individuals (solutions) in the population [>= 10]. Defaults to 300. | | interval_sec | `Literal[900, 3600]` | `rw` | `3600` | The optimization interval [sec]. Defaults to 3600 seconds (1 hour) | -| measurement_max_age_seconds | `int` | `rw` | `300` | Maximum age of SoC measurements for configuration-based optimization [s]. | +| measurement_max_age_seconds | `int` | `rw` | `300` | Maximum age of SoC measurements used for configuration-based optimization [s]. Without an explicit SoC or usable measurement, the initial SoC defaults to 0%. | | penalties | `dict[str, Union[float, int, str]]` | `rw` | `required` | Penalty parameters used in fitness evaluation. | | pin_workers | `bool` | `rw` | `True` | Pin the evaluation workers to the last 'workers' CPU cores. The server process stays unpinned and keeps a core that is not busy with the optimization, so it answers requests during a run, and the run never uses more than 'workers' cores. Needs at least one core more than workers; Linux only. | | seed | `Optional[int]` | `rw` | `None` | Random seed for reproducibility. None = random. | diff --git a/docs/_generated/openapi.md b/docs/_generated/openapi.md index 6f5584f1..f4d73b25 100644 --- a/docs/_generated/openapi.md +++ b/docs/_generated/openapi.md @@ -1385,7 +1385,9 @@ Optimize GENETIC using configured devices and optional fresh runtime inputs. Static settings belong in configuration; query overrides are rejected. Forecast arrays start at local midnight and contain Wh per configured GENETIC slot; prices are currency/Wh. -An empty body uses configured providers and fresh measured states of charge. +An empty body uses configured providers. Explicit SoC values take precedence over +fresh measured states of charge; missing usable measurements default to an assumed +initial SoC of 0% without blocking a plan. The deprecated /optimize endpoint continues to run hourly GENETIC0. """ ``` diff --git a/docs/akkudoktoreos/upgrade-genetic.md b/docs/akkudoktoreos/upgrade-genetic.md index 54eee24f..ccbfc777 100644 --- a/docs/akkudoktoreos/upgrade-genetic.md +++ b/docs/akkudoktoreos/upgrade-genetic.md @@ -53,22 +53,28 @@ the old feature branch migrate into the GENETIC section; explicit nested setting The new request accepts `soc`, `forecasts`, `start_solution` and `start_solution_datetime`. Hardware, device limits and schedules belong in configuration. -An empty JSON object uses configured providers and fresh measurements. Hardware overrides +An empty JSON object uses configured providers and measured or assumed initial SoC. Hardware overrides in the body and query-string overrides are rejected. Do not migrate by changing only the URL. The legacy endpoint remains available, but the surrounding configuration has changed. That is not a promise that every old script works unchanged. -### 3. Fresh measurements and complete forecasts are required +### 3. SoC measurements are optional; complete forecasts are required Request SoC values are integer percentages from 0 to 100, keyed by device ID. Automatic measurement lookup instead reads a factor from 0 to 1 using the configured measurement key, by default `-soc-factor`. For example, 50% is `50` in the request and `0.5` in that measurement channel. -Without a request override, a missing, future, invalid or stale SoC cancels the run. -The default freshness limit is 300 seconds, controlled by -`optimization.genetic.measurement_max_age_seconds`. Check the measurement update rate. +Explicit request SoC values take precedence over measurements. Without an override, +EOS uses the latest valid fresh measurement. If the measurement is missing, future, +invalid or stale, the run assumes **0% initial SoC** for that battery or EV and continues. +The fallback is logged as an assumption and is never written as a measurement or a +configuration change. SoC updates are therefore optional for planning. + +The default measurement freshness limit is 300 seconds, controlled by +`optimization.genetic.measurement_max_age_seconds`. This limit determines whether a +measurement can be used; it does not require an update before every run. Missing forecasts within the control period also stop the run. A shorter forecast continuation is allowed and reported. Failed calls return an error; they no longer look diff --git a/openapi.json b/openapi.json index 09e64a4f..187d065b 100644 --- a/openapi.json +++ b/openapi.json @@ -3024,7 +3024,7 @@ "optimize" ], "summary": "Fastapi Optimize Config", - "description": "Optimize GENETIC using configured devices and optional fresh runtime inputs.\n\nStatic settings belong in configuration; query overrides are rejected.\nForecast arrays start at local\nmidnight and contain Wh per configured GENETIC slot; prices are currency/Wh.\nAn empty body uses configured providers and fresh measured states of charge.\nThe deprecated /optimize endpoint continues to run hourly GENETIC0.", + "description": "Optimize GENETIC using configured devices and optional fresh runtime inputs.\n\nStatic settings belong in configuration; query overrides are rejected.\nForecast arrays start at local\nmidnight and contain Wh per configured GENETIC slot; prices are currency/Wh.\nAn empty body uses configured providers. Explicit SoC values take precedence over\nfresh measured states of charge; missing usable measurements default to an assumed\ninitial SoC of 0% without blocking a plan.\nThe deprecated /optimize endpoint continues to run hourly GENETIC0.", "operationId": "fastapi_optimize_config_v1_optimize_post", "requestBody": { "content": { @@ -4163,7 +4163,7 @@ "additionalProperties": false, "type": "object", "title": "ConfigOptimizationRequest", - "description": "Supply observations while hardware, tariffs and scheduling remain in config." + "description": "Supply observations while hardware, tariffs and scheduling remain in config.\n\nExplicit SoC values take precedence over fresh measurements. Without a usable\nmeasurement, assume 0% initial SoC for the affected device and continue." }, "ConfigSaveMode": { "type": "string", @@ -8883,7 +8883,7 @@ "type": "integer", "exclusiveMinimum": 0.0, "title": "Measurement Max Age Seconds", - "description": "Maximum age of SoC measurements for configuration-based optimization [s].", + "description": "Maximum age of SoC measurements used for configuration-based optimization [s]. Without an explicit SoC or usable measurement, the initial SoC defaults to 0%.", "default": 300 }, "tail_horizon_hours": { @@ -9077,7 +9077,7 @@ "type": "integer", "exclusiveMinimum": 0.0, "title": "Measurement Max Age Seconds", - "description": "Maximum age of SoC measurements for configuration-based optimization [s].", + "description": "Maximum age of SoC measurements used for configuration-based optimization [s]. Without an explicit SoC or usable measurement, the initial SoC defaults to 0%.", "default": 300 }, "tail_horizon_hours": { diff --git a/src/akkudoktoreos/optimization/genetic/configrequest.py b/src/akkudoktoreos/optimization/genetic/configrequest.py index e72a5f41..5710be2d 100644 --- a/src/akkudoktoreos/optimization/genetic/configrequest.py +++ b/src/akkudoktoreos/optimization/genetic/configrequest.py @@ -3,6 +3,7 @@ import math from typing import Annotated, Any, Optional +from loguru import logger from pydantic import AliasChoices, Field, field_validator from akkudoktoreos.core.coreabc import ( @@ -48,7 +49,11 @@ class RuntimeForecasts(GeneticParametersBaseModel): class ConfigOptimizationRequest( ConfigMixin, MeasurementMixin, PredictionMixin, GeneticParametersBaseModel ): - """Supply observations while hardware, tariffs and scheduling remain in config.""" + """Supply observations while hardware, tariffs and scheduling remain in config. + + Explicit SoC values take precedence over fresh measurements. Without a usable + measurement, assume 0% initial SoC for the affected device and continue. + """ soc: dict[str, Annotated[int, Field(ge=0, le=100)]] = Field(default_factory=dict) forecasts: RuntimeForecasts = Field(default_factory=RuntimeForecasts) @@ -115,9 +120,13 @@ class ConfigOptimizationRequest( raise ValueError("Invalid or stale SoC factor") return int(value * 100) except (ValueError, KeyError) as exc: - raise ValueError( - f"Fresh SoC missing for {device.device_id}; supply soc or a measurement." - ) from exc + logger.warning( + "No usable fresh SoC for {}; assuming 0% initial SoC for this " + "optimization (not a measurement): {}", + device.device_id, + exc, + ) + return 0 battery = None if groups["batteries"]: diff --git a/src/akkudoktoreos/optimization/genetic/geneticsettings.py b/src/akkudoktoreos/optimization/genetic/geneticsettings.py index b4d7ae61..f811d0a0 100644 --- a/src/akkudoktoreos/optimization/genetic/geneticsettings.py +++ b/src/akkudoktoreos/optimization/genetic/geneticsettings.py @@ -154,7 +154,10 @@ class GeneticCommonSettings(SettingsBaseModel): measurement_max_age_seconds: int = Field( default=300, gt=0, - description="Maximum age of SoC measurements for configuration-based optimization [s].", + description=( + "Maximum age of SoC measurements used for configuration-based optimization [s]. " + "Without an explicit SoC or usable measurement, the initial SoC defaults to 0%." + ), ) tail_horizon_hours: int = Field( diff --git a/src/akkudoktoreos/server/eos.py b/src/akkudoktoreos/server/eos.py index dd749164..d8e53d54 100755 --- a/src/akkudoktoreos/server/eos.py +++ b/src/akkudoktoreos/server/eos.py @@ -2224,7 +2224,9 @@ async def fastapi_optimize_config( Static settings belong in configuration; query overrides are rejected. Forecast arrays start at local midnight and contain Wh per configured GENETIC slot; prices are currency/Wh. - An empty body uses configured providers and fresh measured states of charge. + An empty body uses configured providers. Explicit SoC values take precedence over + fresh measured states of charge; missing usable measurements default to an assumed + initial SoC of 0% without blocking a plan. The deprecated /optimize endpoint continues to run hourly GENETIC0. """ if request.query_params: @@ -2241,7 +2243,7 @@ async def fastapi_optimize_config( raise EOSProblem( status=503, title="GENETIC optimization failed", - detail="No new solution was produced. Check configured devices, fresh SoC and forecast coverage.", + detail="No new solution was produced. Check configured devices and forecast coverage.", ) if not isinstance(solution, GeneticSolution): raise EOSProblem( diff --git a/tests/test_config_optimization_request.py b/tests/test_config_optimization_request.py index 477e971d..14c5d847 100644 --- a/tests/test_config_optimization_request.py +++ b/tests/test_config_optimization_request.py @@ -115,8 +115,11 @@ def test_warmstart_timestamp_retains_explicit_zone_and_json_instant( @pytest.mark.asyncio -@pytest.mark.parametrize("age,value", [(301, 0.45), (-1, 0.45), (1, None), (1, np.nan), (1, 1.1)]) -async def test_missing_stale_future_or_invalid_soc_never_becomes_zero( +@pytest.mark.parametrize( + "age,value", + [(301, 0.45), (-1, 0.45), (1, None), (1, np.nan), (1, np.inf), (1, -0.1), (1, 1.1)], +) +async def test_stale_future_or_invalid_soc_defaults_to_zero( configured_request, age, value ): _, ems, measurement, data = configured_request @@ -125,8 +128,9 @@ async def test_missing_stale_future_or_invalid_soc_never_becomes_zero( [ems.observation_datetime.subtract(seconds=age)], [value], ) - with pytest.raises(ValueError, match="Fresh SoC missing"): - await ConfigOptimizationRequest.model_validate(data).resolve() + parameters = await ConfigOptimizationRequest.model_validate(data).resolve() + assert parameters.pv_battery is not None + assert parameters.pv_battery.initial_soc_percentage == 0 @pytest.mark.asyncio @@ -147,7 +151,7 @@ async def test_soc_freshness_uses_actual_run_time_inside_quarter_hour(configured @pytest.mark.asyncio -async def test_future_soc_in_repeated_hour_is_rejected(configured_request): +async def test_future_soc_in_repeated_hour_defaults_to_zero(configured_request): _, ems, measurement, data = configured_request data["soc"] = {} ems.observation_datetime = to_datetime("2026-10-25T02:45:00+02:00", in_timezone="Europe/Berlin") @@ -155,8 +159,9 @@ async def test_future_soc_in_repeated_hour_is_rejected(configured_request): [to_datetime("2026-10-25T02:15:00+01:00", in_timezone="Europe/Berlin")], [0.8], ) - with pytest.raises(ValueError, match="Fresh SoC missing"): - await ConfigOptimizationRequest.model_validate(data).resolve() + parameters = await ConfigOptimizationRequest.model_validate(data).resolve() + assert parameters.pv_battery is not None + assert parameters.pv_battery.initial_soc_percentage == 0 @pytest.mark.asyncio @@ -370,12 +375,15 @@ async def test_automatic_run_reads_real_resolver_provider_units_and_measured_soc @pytest.mark.asyncio +@pytest.mark.parametrize("missing_soc", [False, True]) async def test_http_real_genetic_result_serializes_native_quarter_hour_contract( - configured_request, monkeypatch + configured_request, monkeypatch, missing_soc ): from akkudoktoreos.server import eos _, fake, _, data = configured_request + if missing_soc: + data["soc"] = {} ems = EnergyManagement() monkeypatch.setattr(configrequest, "get_ems", lambda: ems) monkeypatch.setattr(EnergyManagement, "_genetic_solution", None) @@ -396,3 +404,50 @@ async def test_http_real_genetic_result_serializes_native_quarter_hour_contract( assert result["controls_start_at_now"] is True assert len(result["ac_charge"]) == 4 assert result["parameters"]["pv_battery"]["device_id"] == "storage" + assert result["parameters"]["pv_battery"]["initial_soc_percentage"] == ( + 0 if missing_soc else 42 + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("missing_key", [False, True]) +async def test_missing_soc_defaults_to_zero_without_mutating_inputs( + configured_request, missing_key, caplog +): + config, _, measurement, data = configured_request + data["soc"] = {} + before = config.model_dump_json() + if missing_key: + measurement.key_to_lists.side_effect = KeyError("SoC channel unavailable") + parameters = await ConfigOptimizationRequest.model_validate(data).resolve() + assert parameters.pv_battery is not None + assert parameters.pv_battery.initial_soc_percentage == 0 + assert config.model_dump_json() == before + assert "assuming 0% initial SoC" in caplog.text + assert "not a measurement" in caplog.text + measurement.update_value.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("soc", [0, 42, 100]) +async def test_explicit_soc_overrides_measurements(configured_request, soc): + _, _, measurement, data = configured_request + data["soc"] = {"storage": soc} + parameters = await ConfigOptimizationRequest.model_validate(data).resolve() + assert parameters.pv_battery is not None + assert parameters.pv_battery.initial_soc_percentage == soc + measurement.key_to_lists.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_missing_ev_soc_does_not_replace_explicit_battery_soc(configured_request): + config, _, _, data = configured_request + config.merge_settings_from_dict( + {"devices": {"max_electric_vehicles": 1, + "electric_vehicles": {"car": {"capacity_wh": 60000}}}} + ) + parameters = await ConfigOptimizationRequest.model_validate(data).resolve() + assert parameters.pv_battery is not None + assert parameters.ev is not None + assert parameters.pv_battery.initial_soc_percentage == 42 + assert parameters.ev.initial_soc_percentage == 0