mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-09 16:06:40 +00:00
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.
This commit is contained in:
@@ -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.
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
from typing import Any, Iterator, Optional
|
from typing import Any, Iterator, Optional
|
||||||
|
|
||||||
import numpy as np
|
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.devices.settings.batterysettings import BATTERY_DEFAULT_CHARGE_RATES
|
||||||
from akkudoktoreos.optimization.genetic.geneticdevices import DeviceParameters
|
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:
|
def max_charging_power_field(description: Optional[str] = None) -> float:
|
||||||
@@ -86,9 +85,9 @@ class BaseBatteryParameters(DeviceParameters):
|
|||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"description": (
|
"description": (
|
||||||
"Battery-to-grid export rates as factor of maximum discharge "
|
"Battery-to-grid export rates as factor of maximum discharge "
|
||||||
"power ]0.00 ... 1.00]. Only used with direct marketing. None "
|
"power ]0.00 ... 1.00]. These levels are available to algorithms "
|
||||||
"falls back to the configured devices.batteries[0]."
|
"that explicitly enable battery-to-grid export. None leaves the "
|
||||||
"grid_export_rates."
|
"choice of export levels to the caller."
|
||||||
),
|
),
|
||||||
"examples": [[0.25, 0.5, 0.75, 1.0], [1.0], None],
|
"examples": [[0.25, 0.5, 0.75, 1.0], [1.0], None],
|
||||||
},
|
},
|
||||||
@@ -113,13 +112,7 @@ class SolarPanelBatteryParameters(BaseBatteryParameters):
|
|||||||
|
|
||||||
|
|
||||||
class ElectricVehicleParameters(BaseBatteryParameters):
|
class ElectricVehicleParameters(BaseBatteryParameters):
|
||||||
"""Battery Electric Vehicle Device Simulation Configuration.
|
"""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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
device_id: str = Field(
|
device_id: str = Field(
|
||||||
json_schema_extra={"description": "ID of electric vehicle", "examples": ["ev1"]}
|
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(
|
initial_soc_percentage: int = initial_soc_percentage_field(
|
||||||
"An integer representing the current state of charge (SOC) of the battery in percentage."
|
"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:
|
class Battery:
|
||||||
|
|||||||
@@ -128,19 +128,18 @@ class BatteriesCommonSettings(DevicesBaseSettings):
|
|||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"description": (
|
"description": (
|
||||||
"Battery-to-grid export rates as factor of maximum discharge "
|
"Battery-to-grid export rates as factor of maximum discharge "
|
||||||
"power ]0.00 ... 1.00]. Only used with direct marketing "
|
"power ]0.00 ... 1.00]. Available to algorithms that explicitly "
|
||||||
"(feedintariff.direct_marketing_enabled). Each rate is one "
|
"enable battery-to-grid export; configuring rates alone does not "
|
||||||
"additional optimizer state; [1.0] restores all-or-nothing "
|
"enable export. [1.0] selects full-power export. None uses the "
|
||||||
"export. None triggers fallback to default export-rates."
|
"default export rates."
|
||||||
),
|
),
|
||||||
"examples": [[0.25, 0.5, 0.75, 1.0], [1.0], None],
|
"examples": [[0.25, 0.5, 0.75, 1.0], [1.0], None],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@field_validator("grid_export_rates", mode="before")
|
@field_validator("grid_export_rates", mode="before")
|
||||||
def validate_and_sort_grid_export_rates(cls, v: Any) -> NDArray[Shape["*"], float]:
|
def validate_and_sort_grid_export_rates(cls, v: Any) -> list[float]:
|
||||||
"""Normalize the export rates to a sorted, duplicate-free array in ]0, 1]."""
|
"""Normalize export rates to a finite, sorted, duplicate-free list in ]0, 1]."""
|
||||||
# None means fallback to default values
|
# None means fallback to default values
|
||||||
if v is None:
|
if v is None:
|
||||||
return BATTERY_DEFAULT_GRID_EXPORT_RATES.copy()
|
return BATTERY_DEFAULT_GRID_EXPORT_RATES.copy()
|
||||||
@@ -151,8 +150,10 @@ class BatteriesCommonSettings(DevicesBaseSettings):
|
|||||||
else:
|
else:
|
||||||
arr = np.array(v, dtype=float)
|
arr = np.array(v, dtype=float)
|
||||||
|
|
||||||
if arr.size == 0:
|
if arr.ndim != 1 or arr.size == 0:
|
||||||
raise ValueError("grid_export_rates must contain at least one value.")
|
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
|
# 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.
|
# other battery states - so the lower bound is exclusive.
|
||||||
@@ -162,8 +163,7 @@ class BatteriesCommonSettings(DevicesBaseSettings):
|
|||||||
arr = np.unique(arr)
|
arr = np.unique(arr)
|
||||||
arr.sort()
|
arr.sort()
|
||||||
|
|
||||||
return arr
|
return arr.tolist()
|
||||||
|
|
||||||
|
|
||||||
def to_genetic_pv_bat_param(self) -> "SolarPanelBatteryParameters":
|
def to_genetic_pv_bat_param(self) -> "SolarPanelBatteryParameters":
|
||||||
"""Return SolarPanelBatteryParameters for the GENETIC optimizer."""
|
"""Return SolarPanelBatteryParameters for the GENETIC optimizer."""
|
||||||
|
|||||||
+18
-1
@@ -371,7 +371,7 @@ def test_grid_export_rates_default_and_override():
|
|||||||
) == [1.0]
|
) == [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):
|
def test_grid_export_rates_reject_invalid_values(rates):
|
||||||
"""0.0 is not an export level, and rates above the rated power are rejected."""
|
"""0.0 is not an export level, and rates above the rated power are rejected."""
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
@@ -383,3 +383,20 @@ def test_rated_discharge_energy_scales_with_slot_duration(setup_pv_battery):
|
|||||||
battery = setup_pv_battery
|
battery = setup_pv_battery
|
||||||
expected = battery.max_charge_power_w * battery.slot_duration_h * battery.discharging_efficiency
|
expected = battery.max_charge_power_w * battery.slot_duration_h * battery.discharging_efficiency
|
||||||
assert battery.rated_discharge_energy_wh() == pytest.approx(expected)
|
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)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from pypdf import PdfReader
|
from pypdf import PdfReader
|
||||||
@@ -140,14 +141,29 @@ async def test_optimize(
|
|||||||
f"cp {TESTDATA_FILE} {solution_file}\n"
|
f"cp {TESTDATA_FILE} {solution_file}\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert genetic_solution.result.Gesamtbilanz_Euro == pytest.approx(
|
# Keep the output contract, but do not demand an identical stochastic
|
||||||
expected_result.result.Gesamtbilanz_Euro
|
# schedule or monetary golden from the previous direct-consumption model.
|
||||||
)
|
assert set(genetic_solution.model_dump()) == set(expected_result.model_dump())
|
||||||
|
result = genetic_solution.result
|
||||||
# Assert that the output contains all expected entries.
|
expected_slots = len(input_data.ems.pv_forecast_wh) - fixed_start_hour
|
||||||
# This does not assert that the optimization always gives the same result!
|
assert len(result.grid_consumption_wh_per_hour) == expected_slots
|
||||||
# Reproducibility and mathematical accuracy should be tested on the level of individual components.
|
assert len(result.grid_feed_in_wh_per_hour) == expected_slots
|
||||||
compare_dict(genetic_solution.model_dump(), expected_result.model_dump())
|
prices = np.asarray(genetic_solution.parameters.ems.electricity_price_per_wh)[fixed_start_hour:]
|
||||||
|
tariffs = genetic_solution.parameters.ems.feed_in_tariff_per_wh
|
||||||
|
if isinstance(tariffs, list):
|
||||||
|
tariffs = np.asarray(tariffs)[fixed_start_hour:]
|
||||||
|
expected_costs = np.asarray(result.grid_consumption_wh_per_hour) * prices
|
||||||
|
expected_revenues = np.asarray(result.grid_feed_in_wh_per_hour) * tariffs
|
||||||
|
np.testing.assert_allclose(result.costs_per_hour, expected_costs)
|
||||||
|
np.testing.assert_allclose(result.revenue_per_hour, expected_revenues)
|
||||||
|
assert result.total_costs == pytest.approx(sum(expected_costs))
|
||||||
|
assert result.total_revenue == pytest.approx(sum(expected_revenues))
|
||||||
|
assert result.total_balance == pytest.approx(sum(expected_costs) - sum(expected_revenues))
|
||||||
|
assert result.total_losses == pytest.approx(sum(result.losses_per_hour))
|
||||||
|
assert all(value >= 0 for value in result.grid_consumption_wh_per_hour)
|
||||||
|
assert all(value >= 0 for value in result.grid_feed_in_wh_per_hour)
|
||||||
|
assert all(0 <= value <= 100 for value in result.battery_soc_per_hour)
|
||||||
|
assert all(0 <= value <= 100 for value in result.ev_soc_per_hour)
|
||||||
|
|
||||||
# Check the correct generic optimization solution is created
|
# Check the correct generic optimization solution is created
|
||||||
optimization_solution = await genetic_solution.optimization_solution()
|
optimization_solution = await genetic_solution.optimization_solution()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from akkudoktoreos.prediction.interpolator import get_eos_load_interpolator
|
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)
|
direct_power_w = interpolator.calculate_expected_direct_consumption(3000.0, 10000.0)
|
||||||
|
|
||||||
assert direct_power_w == pytest.approx(3000.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)
|
||||||
|
|||||||
Reference in New Issue
Block a user