mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-14 10:01:59 +00:00
fix(genetic): allow optimization without fresh SoC (#1403)
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
```
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user