fix(genetic): allow optimization without fresh SoC (#1403)

This commit is contained in:
Andreas
2026-10-13 19:02:19 +02:00
committed by GitHub
parent fab3e17446
commit 49ea062dc8
8 changed files with 103 additions and 26 deletions
+1 -1
View File
@@ -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. |
+3 -1
View File
@@ -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.
"""
```
+11 -5
View File
@@ -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 `<device_id>-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
+4 -4
View File
@@ -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": {
@@ -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"]:
@@ -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(
+4 -2
View File
@@ -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(
+63 -8
View File
@@ -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