From a6d307378785aacfb1966007004838a0684721b5 Mon Sep 17 00:00:00 2001 From: Andreas Date: Wed, 16 Sep 2026 18:25:05 +0200 Subject: [PATCH] fix(devices): constrain the physics port and validate export levels Defer inactive EV deadline fields to the optimizer port, reject nonfinite export rates, and document the hourly Optimize boundary. Verify converter IDs, rates and LCOS, separate GENETIC0 interpolation, physical boundary flows and independent GENETIC repricing. --- docs/development/device-physics.md | 41 ++++++++++++++++ src/akkudoktoreos/devices/genetic/battery.py | 48 ++----------------- .../devices/settings/batterysettings.py | 22 ++++----- tests/test_battery.py | 19 +++++++- tests/test_interpolator.py | 35 ++++++++++++++ 5 files changed, 110 insertions(+), 55 deletions(-) create mode 100644 docs/development/device-physics.md diff --git a/docs/development/device-physics.md b/docs/development/device-physics.md new file mode 100644 index 00000000..93a3da1f --- /dev/null +++ b/docs/development/device-physics.md @@ -0,0 +1,41 @@ +# Slot-aware GENETIC device physics + +This package depends on the device-map and algorithm-converter configuration work +from PR #1256 and the runtime settings foundation from PR #1305. Parameter classes +remain in `devices/genetic`; settings remain in `devices/settings`. + +Battery and inverter models accept a slot duration, apply power limits as energy +per slot, and preserve the total battery charge/discharge budget across multiple +calls within that slot. Battery export is an explicit device-simulation operation; +setting export rates does not by itself activate optimizer export states. The +converter carries stable device IDs, charge/export levels and LCOS unchanged. +Configuration LCOS uses amount/kWh; no Wh conversion is applied by the converter. + +The GENETIC inverter now computes expected direct PV-to-load power from the +minute-load distribution, then converts power to interval energy. This changes +GENETIC simulation economics even at the default hourly interval. The probability +table was calibrated with hourly mean loads: use with quarter-hour means remains +an approximation, not a separately calibrated quarter-hour model. Its legacy +cumulative-probability API now clamps values at the table boundary and to [0, 1]. +The GENETIC0 inverter uses a separate implementation and separate interpolation +data file; this package does not change either. Existing GENETIC0 optimization +and PDF golden tests remain required. + +The shared in-memory cache must include callable identity in each key. Otherwise +the legacy cumulative probability and new direct-power method on the same object +can return each other's cached results for identical arguments. This package +includes that prerequisite fix and verifies both call orders and cache reuse. + +This package alone does **not** make an Optimize mode quarter-hour capable. +GENETIC parameter preparation still forces the interval to 3600 seconds, and +`/optimize` still selects the separate GENETIC0 algorithm. Quarter-hour scheduling, +warm-start alignment, forecast/control horizons, terminal values, export states, +EV deadlines and flexible-consumer planning require the later optimizer port. +No inactive EV-deadline fields are introduced here. + +Validation covers charge/discharge budgets, inverter/export limits, efficiencies, +energy conservation, interpolation boundaries, converter fields, both algorithms' +short optimization/PDF runs and unchanged GENETIC0 monetary goldens. GENETIC runs +use independent repricing of grid energy, schema checks and physical bounds rather +than requiring the previous direct-consumption model's monetary golden. Long +`--finalize` optimization runs and pinned CI environments remain separate checks. diff --git a/src/akkudoktoreos/devices/genetic/battery.py b/src/akkudoktoreos/devices/genetic/battery.py index a257a672..bb45e52c 100644 --- a/src/akkudoktoreos/devices/genetic/battery.py +++ b/src/akkudoktoreos/devices/genetic/battery.py @@ -1,11 +1,10 @@ from typing import Any, Iterator, Optional import numpy as np -from pydantic import Field, field_validator +from pydantic import Field from akkudoktoreos.devices.settings.batterysettings import BATTERY_DEFAULT_CHARGE_RATES from akkudoktoreos.optimization.genetic.geneticdevices import DeviceParameters -from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime def max_charging_power_field(description: Optional[str] = None) -> float: @@ -86,9 +85,9 @@ class BaseBatteryParameters(DeviceParameters): json_schema_extra={ "description": ( "Battery-to-grid export rates as factor of maximum discharge " - "power ]0.00 ... 1.00]. Only used with direct marketing. None " - "falls back to the configured devices.batteries[0]." - "grid_export_rates." + "power ]0.00 ... 1.00]. These levels are available to algorithms " + "that explicitly enable battery-to-grid export. None leaves the " + "choice of export levels to the caller." ), "examples": [[0.25, 0.5, 0.75, 1.0], [1.0], None], }, @@ -113,13 +112,7 @@ class SolarPanelBatteryParameters(BaseBatteryParameters): class ElectricVehicleParameters(BaseBatteryParameters): - """Battery Electric Vehicle Device Simulation Configuration. - - ``min_soc_percentage`` is the charging target. By default it only has to be - reached by the end of the optimization horizon; a deadline - (``min_soc_deadline_datetime`` and/or ``min_soc_max_duration_h``) moves that - requirement forward, for example to the next departure. - """ + """Battery Electric Vehicle Device Simulation Configuration.""" device_id: str = Field( json_schema_extra={"description": "ID of electric vehicle", "examples": ["ev1"]} @@ -128,37 +121,6 @@ class ElectricVehicleParameters(BaseBatteryParameters): initial_soc_percentage: int = initial_soc_percentage_field( "An integer representing the current state of charge (SOC) of the battery in percentage." ) - min_soc_deadline_datetime: Optional[DateTime] = Field( - default=None, - json_schema_extra={ - "description": ( - "Absolute moment by which 'min_soc_percentage' has to be " - "reached (departure time). A date time without timezone is read " - "as local time. None means end of the optimization horizon." - ), - "examples": [None, "2026-07-16T07:00:00+02:00"], - }, - ) - min_soc_max_duration_h: Optional[float] = Field( - default=None, - gt=0, - json_schema_extra={ - "description": ( - "Maximum time from the start of the optimization until " - "'min_soc_percentage' has to be reached [h]. Combined with " - "'min_soc_deadline_datetime' the earlier of the two applies." - ), - "examples": [None, 6.0], - }, - ) - - @field_validator("min_soc_deadline_datetime", mode="before") - @classmethod - def transform_deadline_to_datetime(cls, value: Any) -> Optional[DateTime]: - """Accept the usual date time representations, naive input is local time.""" - if value is None: - return None - return to_datetime(value) class Battery: diff --git a/src/akkudoktoreos/devices/settings/batterysettings.py b/src/akkudoktoreos/devices/settings/batterysettings.py index 0e84a67c..fd2aebeb 100644 --- a/src/akkudoktoreos/devices/settings/batterysettings.py +++ b/src/akkudoktoreos/devices/settings/batterysettings.py @@ -135,19 +135,18 @@ class BatteriesCommonSettings(DevicesBaseSettings): json_schema_extra={ "description": ( "Battery-to-grid export rates as factor of maximum discharge " - "power ]0.00 ... 1.00]. Only used with direct marketing " - "(feedintariff.direct_marketing_enabled). Each rate is one " - "additional optimizer state; [1.0] restores all-or-nothing " - "export. None triggers fallback to default export-rates." + "power ]0.00 ... 1.00]. Available to algorithms that explicitly " + "enable battery-to-grid export; configuring rates alone does not " + "enable export. [1.0] selects full-power export. None uses the " + "default export rates." ), "examples": [[0.25, 0.5, 0.75, 1.0], [1.0], None], }, ) - @field_validator("grid_export_rates", mode="before") - def validate_and_sort_grid_export_rates(cls, v: Any) -> NDArray[Shape["*"], float]: - """Normalize the export rates to a sorted, duplicate-free array in ]0, 1].""" + def validate_and_sort_grid_export_rates(cls, v: Any) -> list[float]: + """Normalize export rates to a finite, sorted, duplicate-free list in ]0, 1].""" # None means fallback to default values if v is None: return BATTERY_DEFAULT_GRID_EXPORT_RATES.copy() @@ -158,8 +157,10 @@ class BatteriesCommonSettings(DevicesBaseSettings): else: arr = np.array(v, dtype=float) - if arr.size == 0: - raise ValueError("grid_export_rates must contain at least one value.") + if arr.ndim != 1 or arr.size == 0: + raise ValueError("grid_export_rates must be a nonempty one-dimensional list.") + if not np.isfinite(arr).all(): + raise ValueError("grid_export_rates must contain finite values.") # A rate of 0.0 is not an export level - "no export" is expressed by the # other battery states - so the lower bound is exclusive. @@ -169,8 +170,7 @@ class BatteriesCommonSettings(DevicesBaseSettings): arr = np.unique(arr) arr.sort() - return arr - + return arr.tolist() def to_genetic_pv_bat_param(self) -> "SolarPanelBatteryParameters": """Return SolarPanelBatteryParameters for the GENETIC optimizer.""" diff --git a/tests/test_battery.py b/tests/test_battery.py index b849b30b..2e7f8411 100644 --- a/tests/test_battery.py +++ b/tests/test_battery.py @@ -371,7 +371,7 @@ def test_grid_export_rates_default_and_override(): ) == [1.0] -@pytest.mark.parametrize("rates", [[0.0, 0.5], [1.5], [-0.25], []]) +@pytest.mark.parametrize("rates", [[0.0, 0.5], [1.5], [-0.25], [], [np.nan], [np.inf], [[0.5]], 0.5]) def test_grid_export_rates_reject_invalid_values(rates): """0.0 is not an export level, and rates above the rated power are rejected.""" with pytest.raises(ValidationError): @@ -383,3 +383,20 @@ def test_rated_discharge_energy_scales_with_slot_duration(setup_pv_battery): battery = setup_pv_battery expected = battery.max_charge_power_w * battery.slot_duration_h * battery.discharging_efficiency assert battery.rated_discharge_energy_wh() == pytest.approx(expected) + + +def test_pv_converter_preserves_id_lcos_charge_and_export_rates(): + settings = BatteriesCommonSettings( + device_id="house", + charge_rates=[0.0, 0.5, 1.0], + grid_export_rates=[1.0, 0.25], + levelized_cost_of_storage_amt_kwh=0.123, + ) + assert isinstance(BatteriesCommonSettings.validate_and_sort_charge_rates(None), np.ndarray) + parameters = settings.to_genetic_pv_bat_param() + assert parameters.device_id == "house" + assert parameters.charge_rates == [0.0, 0.5, 1.0] + assert parameters.grid_export_rates == [0.25, 1.0] + assert parameters.levelized_cost_of_storage_kwh == pytest.approx(0.123) + battery = Battery(parameters, prediction_hours=4, slot_duration_h=0.25) + assert battery.levelized_cost_of_storage_kwh == pytest.approx(0.123) diff --git a/tests/test_interpolator.py b/tests/test_interpolator.py index 8e746153..df3cbfb5 100644 --- a/tests/test_interpolator.py +++ b/tests/test_interpolator.py @@ -1,3 +1,4 @@ +import numpy as np import pytest from akkudoktoreos.prediction.interpolator import get_eos_load_interpolator @@ -68,3 +69,37 @@ def test_expected_direct_consumption_preserves_forecast_mean_at_high_pv(): direct_power_w = interpolator.calculate_expected_direct_consumption(3000.0, 10000.0) assert direct_power_w == pytest.approx(3000.0) + + +@pytest.mark.parametrize("load,pv", [(4000.0, 5000.0), (10000.0, 20000.0), (0.0, 0.0)]) +def test_genetic_interpolator_boundaries_are_finite_and_physical(load, pv): + interpolator = get_eos_load_interpolator() + fraction = interpolator.calculate_self_consumption(load, pv) + direct = interpolator.calculate_expected_direct_consumption(load, pv) + assert np.isfinite(fraction) + assert 0.0 <= fraction <= 1.0 + assert np.isfinite(direct) + assert 0.0 <= direct <= min(load, pv) + + +def test_genetic0_inverter_keeps_its_independent_interpolator(): + from akkudoktoreos.devices.genetic0.genetic0inverter import ( + Genetic0Inverter, + Genetic0InverterParameters, + ) + from akkudoktoreos.optimization.genetic0.genetic0loadinterpolator import ( + get_genetic0_load_interpolator, + ) + + inverter = Genetic0Inverter(Genetic0InverterParameters(device_id="legacy", max_power_wh=10000)) + assert inverter.self_consumption_predictor is get_genetic0_load_interpolator() + assert inverter.self_consumption_predictor is not get_eos_load_interpolator() + + +@pytest.mark.parametrize("load,pv", [(4000.0, 5000.0), (10000.0, 20000.0)]) +def test_genetic_inverter_boundary_flows_remain_nonnegative(load, pv): + from akkudoktoreos.devices.genetic.inverter import Inverter, InverterParameters + + inverter = Inverter(InverterParameters(device_id="boundary", max_power_wh=25000)) + flows = inverter.process_energy(generation=pv, consumption=load, hour=0) + assert all(np.isfinite(value) and value >= 0.0 for value in flows)