mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-15 10:31:57 +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. |
|
| 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. |
|
| 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) |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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.
|
Static settings belong in configuration; query overrides are rejected.
|
||||||
Forecast arrays start at local
|
Forecast arrays start at local
|
||||||
midnight and contain Wh per configured GENETIC slot; prices are currency/Wh.
|
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.
|
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
|
The new request accepts `soc`, `forecasts`, `start_solution` and
|
||||||
`start_solution_datetime`. Hardware, device limits and schedules belong in configuration.
|
`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.
|
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.
|
The legacy endpoint remains available, but the surrounding configuration has changed.
|
||||||
That is not a promise that every old script works unchanged.
|
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
|
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
|
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
|
key, by default `<device_id>-soc-factor`. For example, 50% is `50` in the request and
|
||||||
`0.5` in that measurement channel.
|
`0.5` in that measurement channel.
|
||||||
|
|
||||||
Without a request override, a missing, future, invalid or stale SoC cancels the run.
|
Explicit request SoC values take precedence over measurements. Without an override,
|
||||||
The default freshness limit is 300 seconds, controlled by
|
EOS uses the latest valid fresh measurement. If the measurement is missing, future,
|
||||||
`optimization.genetic.measurement_max_age_seconds`. Check the measurement update rate.
|
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
|
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
|
continuation is allowed and reported. Failed calls return an error; they no longer look
|
||||||
|
|||||||
+4
-4
@@ -3024,7 +3024,7 @@
|
|||||||
"optimize"
|
"optimize"
|
||||||
],
|
],
|
||||||
"summary": "Fastapi Optimize Config",
|
"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",
|
"operationId": "fastapi_optimize_config_v1_optimize_post",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"content": {
|
"content": {
|
||||||
@@ -4163,7 +4163,7 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "ConfigOptimizationRequest",
|
"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": {
|
"ConfigSaveMode": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -8883,7 +8883,7 @@
|
|||||||
"type": "integer",
|
"type": "integer",
|
||||||
"exclusiveMinimum": 0.0,
|
"exclusiveMinimum": 0.0,
|
||||||
"title": "Measurement Max Age Seconds",
|
"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
|
"default": 300
|
||||||
},
|
},
|
||||||
"tail_horizon_hours": {
|
"tail_horizon_hours": {
|
||||||
@@ -9077,7 +9077,7 @@
|
|||||||
"type": "integer",
|
"type": "integer",
|
||||||
"exclusiveMinimum": 0.0,
|
"exclusiveMinimum": 0.0,
|
||||||
"title": "Measurement Max Age Seconds",
|
"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
|
"default": 300
|
||||||
},
|
},
|
||||||
"tail_horizon_hours": {
|
"tail_horizon_hours": {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import math
|
import math
|
||||||
from typing import Annotated, Any, Optional
|
from typing import Annotated, Any, Optional
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
from pydantic import AliasChoices, Field, field_validator
|
from pydantic import AliasChoices, Field, field_validator
|
||||||
|
|
||||||
from akkudoktoreos.core.coreabc import (
|
from akkudoktoreos.core.coreabc import (
|
||||||
@@ -48,7 +49,11 @@ class RuntimeForecasts(GeneticParametersBaseModel):
|
|||||||
class ConfigOptimizationRequest(
|
class ConfigOptimizationRequest(
|
||||||
ConfigMixin, MeasurementMixin, PredictionMixin, GeneticParametersBaseModel
|
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)
|
soc: dict[str, Annotated[int, Field(ge=0, le=100)]] = Field(default_factory=dict)
|
||||||
forecasts: RuntimeForecasts = Field(default_factory=RuntimeForecasts)
|
forecasts: RuntimeForecasts = Field(default_factory=RuntimeForecasts)
|
||||||
@@ -115,9 +120,13 @@ class ConfigOptimizationRequest(
|
|||||||
raise ValueError("Invalid or stale SoC factor")
|
raise ValueError("Invalid or stale SoC factor")
|
||||||
return int(value * 100)
|
return int(value * 100)
|
||||||
except (ValueError, KeyError) as exc:
|
except (ValueError, KeyError) as exc:
|
||||||
raise ValueError(
|
logger.warning(
|
||||||
f"Fresh SoC missing for {device.device_id}; supply soc or a measurement."
|
"No usable fresh SoC for {}; assuming 0% initial SoC for this "
|
||||||
) from exc
|
"optimization (not a measurement): {}",
|
||||||
|
device.device_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
battery = None
|
battery = None
|
||||||
if groups["batteries"]:
|
if groups["batteries"]:
|
||||||
|
|||||||
@@ -154,7 +154,10 @@ class GeneticCommonSettings(SettingsBaseModel):
|
|||||||
measurement_max_age_seconds: int = Field(
|
measurement_max_age_seconds: int = Field(
|
||||||
default=300,
|
default=300,
|
||||||
gt=0,
|
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(
|
tail_horizon_hours: int = Field(
|
||||||
|
|||||||
@@ -2224,7 +2224,9 @@ async def fastapi_optimize_config(
|
|||||||
Static settings belong in configuration; query overrides are rejected.
|
Static settings belong in configuration; query overrides are rejected.
|
||||||
Forecast arrays start at local
|
Forecast arrays start at local
|
||||||
midnight and contain Wh per configured GENETIC slot; prices are currency/Wh.
|
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.
|
The deprecated /optimize endpoint continues to run hourly GENETIC0.
|
||||||
"""
|
"""
|
||||||
if request.query_params:
|
if request.query_params:
|
||||||
@@ -2241,7 +2243,7 @@ async def fastapi_optimize_config(
|
|||||||
raise EOSProblem(
|
raise EOSProblem(
|
||||||
status=503,
|
status=503,
|
||||||
title="GENETIC optimization failed",
|
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):
|
if not isinstance(solution, GeneticSolution):
|
||||||
raise EOSProblem(
|
raise EOSProblem(
|
||||||
|
|||||||
@@ -115,8 +115,11 @@ def test_warmstart_timestamp_retains_explicit_zone_and_json_instant(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize("age,value", [(301, 0.45), (-1, 0.45), (1, None), (1, np.nan), (1, 1.1)])
|
@pytest.mark.parametrize(
|
||||||
async def test_missing_stale_future_or_invalid_soc_never_becomes_zero(
|
"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
|
configured_request, age, value
|
||||||
):
|
):
|
||||||
_, ems, measurement, data = configured_request
|
_, 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)],
|
[ems.observation_datetime.subtract(seconds=age)],
|
||||||
[value],
|
[value],
|
||||||
)
|
)
|
||||||
with pytest.raises(ValueError, match="Fresh SoC missing"):
|
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||||
await ConfigOptimizationRequest.model_validate(data).resolve()
|
assert parameters.pv_battery is not None
|
||||||
|
assert parameters.pv_battery.initial_soc_percentage == 0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -147,7 +151,7 @@ async def test_soc_freshness_uses_actual_run_time_inside_quarter_hour(configured
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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
|
_, ems, measurement, data = configured_request
|
||||||
data["soc"] = {}
|
data["soc"] = {}
|
||||||
ems.observation_datetime = to_datetime("2026-10-25T02:45:00+02:00", in_timezone="Europe/Berlin")
|
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")],
|
[to_datetime("2026-10-25T02:15:00+01:00", in_timezone="Europe/Berlin")],
|
||||||
[0.8],
|
[0.8],
|
||||||
)
|
)
|
||||||
with pytest.raises(ValueError, match="Fresh SoC missing"):
|
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||||
await ConfigOptimizationRequest.model_validate(data).resolve()
|
assert parameters.pv_battery is not None
|
||||||
|
assert parameters.pv_battery.initial_soc_percentage == 0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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.asyncio
|
||||||
|
@pytest.mark.parametrize("missing_soc", [False, True])
|
||||||
async def test_http_real_genetic_result_serializes_native_quarter_hour_contract(
|
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
|
from akkudoktoreos.server import eos
|
||||||
|
|
||||||
_, fake, _, data = configured_request
|
_, fake, _, data = configured_request
|
||||||
|
if missing_soc:
|
||||||
|
data["soc"] = {}
|
||||||
ems = EnergyManagement()
|
ems = EnergyManagement()
|
||||||
monkeypatch.setattr(configrequest, "get_ems", lambda: ems)
|
monkeypatch.setattr(configrequest, "get_ems", lambda: ems)
|
||||||
monkeypatch.setattr(EnergyManagement, "_genetic_solution", None)
|
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 result["controls_start_at_now"] is True
|
||||||
assert len(result["ac_charge"]) == 4
|
assert len(result["ac_charge"]) == 4
|
||||||
assert result["parameters"]["pv_battery"]["device_id"] == "storage"
|
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