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)