mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-11-13 09:13:30 +01:00
feat(devices): add battery profiles
A battery profile describes a battery model: the charge and discharge limit
maps over temperature and SoC, the charge and discharge efficiency curves
over C-rate, the technical SoC bounds, the hard temperature cutoffs and,
for a model, its nominal capacity and power limits. EOS packages the
generic profile generic-lfp; users add their own profiles.
A battery or electric vehicle selects its profile by battery_profile and
describes how the installation differs from it in profile_adjustment
(capacity, power limits of the installation, periodic full charge). The
effective values are resolved from both at the measured operating point
(temperature, SoC, power) and shown as effective_* fields.
The GENETIC simulation uses the limits and efficiencies of the profile over
SoC, with precomputed tables as a fast path; GENETIC0 gets the values at the
operating point. Optimization parameters are always valid: a battery whose
profile is missing, unreadable or invalid uses the packaged generic-lfp
profile, and the former defaults only if that is not usable either; each is
logged as error, and an optimization run is never cancelled because of a
battery.
Additional changes
- Add a generic device profile store: user and packaged profiles, atomic
saves, compiled profiles cached per file and checked again at most once a
second, plain file names as profile names ('schema' is reserved). On
Windows, a save or read that collides with the other is retried briefly.
- Add the REST API /v1/profile to list the profile kinds and to list, read
(document and JSON schema), save and delete profiles.
- Check the hard temperature cutoffs on the exact temperature.
- Resolve inhibited charging or discharging to a power of 0, e.g. for a cold
battery; give GENETIC0 its own discharge limit for that.
- Limit generic profiles without own power limits to the former 5000 W.
- Accept only the implemented units; current limits need the nominal
voltage.
- Migrate legacy battery settings into the profile adjustment, keep the
former charge power limit as discharge limit (also for a Home Assistant
entity on it) and report dropped efficiencies as errors.
- Limit GENETIC battery discharge by the discharge power.
- Allow battery profile lookups while the configuration is created.
- Resolve a battery once per configuration dump.
- Package the bundled profiles, document battery profiles and regenerate
the configuration and OpenAPI documentation.
- Ignore the report PDFs written by the visualization tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Assisted-by: Claude Code (claude-opus-5-5)
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
dd41e5d57c
commit
e255f987bf
+105
-1
@@ -844,7 +844,7 @@ class TestConfigEOSToConfigFile:
|
||||
|
||||
class TestDeviceSettings:
|
||||
def test_device_map_supplies_stable_identity(self):
|
||||
raw = {"batteries": {"house": {"capacity_wh": 12000}}}
|
||||
raw = {"batteries": {"house": {"profile_adjustment": {"nominal_capacity_wh": 12000}}}}
|
||||
a = DevicesCommonSettings.model_validate(raw)
|
||||
b = DevicesCommonSettings.model_validate(raw)
|
||||
assert a.batteries is not None and b.batteries is not None
|
||||
@@ -855,6 +855,43 @@ class TestDeviceSettings:
|
||||
with pytest.raises(ValidationError, match="device_id"):
|
||||
DevicesCommonSettings.model_validate({"batteries": {"house": {"device_id": "other"}}})
|
||||
|
||||
def test_check_battery_profiles_names_unresolvable_devices(self, config_eos):
|
||||
# generic-lfp provides no capacity: the house battery lacks it, the car has it.
|
||||
devices = DevicesCommonSettings.model_validate(
|
||||
{
|
||||
"batteries": {"house": {}},
|
||||
"electric_vehicles": {"car": {"profile_adjustment": {"nominal_capacity_wh": 60000}}},
|
||||
}
|
||||
)
|
||||
errors, warnings = devices.check_battery_profiles()
|
||||
assert len(errors) == 1
|
||||
assert "Battery 'house'" in errors[0]
|
||||
assert warnings == []
|
||||
|
||||
def test_config_logs_battery_profile_problems_once(self, config_eos, monkeypatch):
|
||||
from loguru import logger
|
||||
|
||||
monkeypatch.setattr(ConfigEOS, "_battery_profile_problems", ((), ()))
|
||||
messages: list[str] = []
|
||||
sink_id = logger.add(
|
||||
lambda message: messages.append(message.record["message"]), level="WARNING"
|
||||
)
|
||||
try:
|
||||
config_eos.merge_settings_from_dict({"devices": {"batteries": {"house": {}}}})
|
||||
config_eos.merge_settings_from_dict({"devices": {"max_batteries": 1}})
|
||||
unresolvable = [m for m in messages if "Battery 'house'" in m]
|
||||
# Logged on the change only, not again on the unrelated update.
|
||||
assert len(unresolvable) == 1
|
||||
|
||||
messages.clear()
|
||||
config_eos.set_nested_value(
|
||||
"devices/batteries/house/profile_adjustment/nominal_capacity_wh", 10000
|
||||
)
|
||||
assert not [m for m in messages if "Battery 'house'" in m]
|
||||
assert ConfigEOS._battery_profile_problems == ((), ())
|
||||
finally:
|
||||
logger.remove(sink_id)
|
||||
|
||||
|
||||
class TestConfigProviderValidation:
|
||||
"""Provider ids are validated while the configuration is created."""
|
||||
@@ -924,3 +961,70 @@ class TestDocumentationMode:
|
||||
assert settings.keys == ["a", "b"]
|
||||
monkeypatch.setattr(ConfigEOS, "_force_documentation_mode", True)
|
||||
assert settings.keys == []
|
||||
|
||||
|
||||
class TestConfigSetup:
|
||||
"""The configuration can be created when its computed fields need it.
|
||||
|
||||
A battery's computed fields resolve the battery profile, and the profile
|
||||
store reads its directories from the configuration. Rendering the
|
||||
configuration while ``ConfigEOS()`` is still being created must not fail.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def uncreated_config_with_battery(self, uncreated_config: Path) -> None:
|
||||
"""Write a config file with a battery; get_config(init=True) creates the configuration."""
|
||||
uncreated_config.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"devices": {
|
||||
"batteries": {
|
||||
"battery1": {
|
||||
"device_id": "battery1",
|
||||
"profile_adjustment": {"nominal_capacity_wh": 10240},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def test_config_with_battery_is_created(self, uncreated_config_with_battery):
|
||||
from akkudoktoreos.core.coreabc import get_config
|
||||
|
||||
config = get_config(init=True)
|
||||
|
||||
assert config.devices.batteries is not None
|
||||
assert config.devices.batteries["battery1"].effective_nominal_capacity_wh == 10240
|
||||
|
||||
def test_config_with_battery_is_created_with_trace_logging(
|
||||
self, uncreated_config_with_battery
|
||||
):
|
||||
"""With trace logging the configuration is rendered during its creation."""
|
||||
from loguru import logger
|
||||
|
||||
from akkudoktoreos.core.coreabc import get_config
|
||||
|
||||
messages: list[str] = []
|
||||
sink_id = logger.add(
|
||||
lambda message: messages.append(message.record["message"]), level="TRACE"
|
||||
)
|
||||
try:
|
||||
config = get_config(init=True)
|
||||
finally:
|
||||
logger.remove(sink_id)
|
||||
|
||||
assert config.devices.batteries is not None
|
||||
assert "battery1" in config.devices.batteries
|
||||
setup_messages = [m for m in messages if m.startswith("Config setup:")]
|
||||
assert len(setup_messages) == 1
|
||||
assert "effective_nominal_capacity_wh=10240" in setup_messages[0]
|
||||
|
||||
def test_access_before_settings_are_loaded_raises(self, uncreated_config, monkeypatch):
|
||||
"""During creation the configuration is usable only once its settings are loaded."""
|
||||
from akkudoktoreos.core import coreabc
|
||||
|
||||
monkeypatch.setattr(coreabc, "_config_eos_setup_running", True)
|
||||
|
||||
with pytest.raises(RuntimeError, match="before its settings are loaded"):
|
||||
coreabc.get_config(init=True)
|
||||
|
||||
@@ -265,6 +265,249 @@ class TestDeviceMigration:
|
||||
assert migrated.devices.batteries["house"].levelized_cost_of_storage_amt_kwh == 0.123
|
||||
assert raw == original
|
||||
|
||||
@pytest.mark.parametrize("devices_key", ["batteries", "electric_vehicles"])
|
||||
@pytest.mark.parametrize("as_list", [False, True])
|
||||
def test_migration_of_legacy_battery_fields(self, devices_key, as_list):
|
||||
"""Legacy battery fields migrate to the profile based settings."""
|
||||
battery = {
|
||||
"device_id": "house",
|
||||
"capacity_wh": 12000,
|
||||
"charging_efficiency": 0.9,
|
||||
"discharging_efficiency": 0.9,
|
||||
"max_charge_power_w": 3000,
|
||||
}
|
||||
raw = {"devices": {devices_key: [battery] if as_list else {"house": battery}}}
|
||||
migrated = migrate_config_data(raw)
|
||||
devices = getattr(migrated.devices, devices_key)
|
||||
assert devices is not None
|
||||
settings = devices["house"]
|
||||
assert settings.profile_adjustment.nominal_capacity_wh == 12000
|
||||
# The configured charge power limit of the installation still applies.
|
||||
assert settings.profile_adjustment.max_charge_power_w == 3000
|
||||
assert settings.effective_max_charge_power_w <= 3000
|
||||
dumped = settings.model_dump(exclude_computed_fields=True)
|
||||
for legacy in ("capacity_wh", "charging_efficiency", "discharging_efficiency",
|
||||
"max_charge_power_w"):
|
||||
assert legacy not in dumped
|
||||
|
||||
def test_migration_writes_former_defaults_of_a_battery_before_profiles(self):
|
||||
"""Former defaults were left out of files; the battery keeps them."""
|
||||
battery = {"device_id": "house", "min_soc_percentage": 10}
|
||||
migrated = migrate_config_data({"devices": {"batteries": {"house": battery}}})
|
||||
assert migrated.devices.batteries is not None
|
||||
adjustment = migrated.devices.batteries["house"].profile_adjustment
|
||||
assert adjustment.nominal_capacity_wh == 8000
|
||||
assert adjustment.max_charge_power_w == 5000
|
||||
# The former charge power limit also limited discharging.
|
||||
assert adjustment.max_discharge_power_w == 5000
|
||||
assert migrated.devices.batteries["house"].min_soc_percentage == 10
|
||||
|
||||
def test_migration_keeps_the_charge_limit_as_discharge_limit(self):
|
||||
"""Before battery profiles, max_charge_power_w also capped discharging."""
|
||||
battery = {"device_id": "house", "capacity_wh": 8000, "max_charge_power_w": 3000}
|
||||
migrated = migrate_config_data({"devices": {"batteries": {"house": battery}}})
|
||||
assert migrated.devices.batteries is not None
|
||||
settings = migrated.devices.batteries["house"]
|
||||
assert settings.profile_adjustment.max_discharge_power_w == 3000
|
||||
assert settings.effective_max_discharge_power_w is not None
|
||||
assert settings.effective_max_discharge_power_w <= 3000
|
||||
|
||||
def test_migration_logs_dropped_efficiencies(self):
|
||||
"""Efficiencies are no setting anymore; dropping them is logged as error."""
|
||||
from loguru import logger
|
||||
|
||||
messages: list[str] = []
|
||||
sink_id = logger.add(lambda message: messages.append(message.record["message"]), level="ERROR")
|
||||
try:
|
||||
battery = {"device_id": "house", "charging_efficiency": 0.9, "discharging_efficiency": 0.85}
|
||||
migrate_config_data({"devices": {"batteries": {"house": battery}}})
|
||||
finally:
|
||||
logger.remove(sink_id)
|
||||
assert len(messages) == 2
|
||||
assert all("Battery 'house'" in message for message in messages)
|
||||
assert any("'charging_efficiency' 0.9" in message for message in messages)
|
||||
assert any("'discharging_efficiency' 0.85" in message for message in messages)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"legacy_name, adjustment_name",
|
||||
[
|
||||
("nominal_capacity_wh", "nominal_capacity_wh"),
|
||||
("max_charge_power_limit_w", "max_charge_power_w"),
|
||||
("max_discharge_power_limit_w", "max_discharge_power_w"),
|
||||
],
|
||||
)
|
||||
def test_migration_of_interim_profile_fields(self, legacy_name, adjustment_name):
|
||||
"""Interim fields of the settings with profiles move into the adjustment.
|
||||
|
||||
Such a battery is not from before battery profiles, so it gets no former
|
||||
defaults.
|
||||
"""
|
||||
battery = {"device_id": "house", legacy_name: 4000}
|
||||
migrated = migrate_config_data({"devices": {"batteries": {"house": battery}}})
|
||||
assert migrated.devices.batteries is not None
|
||||
adjustment = migrated.devices.batteries["house"].profile_adjustment
|
||||
assert getattr(adjustment, adjustment_name) == 4000
|
||||
for name, value in adjustment.model_dump().items():
|
||||
if name != adjustment_name:
|
||||
assert value is None
|
||||
|
||||
def test_migration_of_full_charge_settings(self):
|
||||
"""Full charge interval and threshold move into the adjustment, chemistry is dropped."""
|
||||
battery = {
|
||||
"device_id": "house",
|
||||
"cell_chemistry": "LFP",
|
||||
"full_charge_interval_days": 21,
|
||||
"full_charge_detect_percentage": 98,
|
||||
"full_charge_soc_percentage": 100,
|
||||
"full_charge_allow_grid": True,
|
||||
}
|
||||
migrated = migrate_config_data({"devices": {"batteries": {"house": battery}}})
|
||||
assert migrated.devices.batteries is not None
|
||||
settings = migrated.devices.batteries["house"]
|
||||
assert settings.profile_adjustment.full_charge_interval_days == 21
|
||||
assert settings.profile_adjustment.full_charge_detect_percentage == 98
|
||||
assert settings.full_charge_soc_percentage == 100
|
||||
assert settings.full_charge_allow_grid is True
|
||||
dumped = settings.model_dump(exclude_computed_fields=True)
|
||||
for legacy in ("cell_chemistry", "full_charge_interval_days",
|
||||
"full_charge_detect_percentage"):
|
||||
assert legacy not in dumped
|
||||
|
||||
def test_migration_drops_home_assistant_config_path_of_cell_chemistry(self):
|
||||
raw = {
|
||||
"adapter": {
|
||||
"homeassistant": {
|
||||
"config_entity_ids": {
|
||||
"devices/batteries/house/cell_chemistry": "sensor.chemistry",
|
||||
"devices/batteries/house/full_charge_interval_days": "sensor.interval",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
migrated = migrate_config_data(raw)
|
||||
assert migrated.adapter.homeassistant.config_entity_ids == {
|
||||
"devices/batteries/house/profile_adjustment/full_charge_interval_days": (
|
||||
"sensor.interval"
|
||||
),
|
||||
}
|
||||
|
||||
def test_migration_maps_a_charge_limit_entity_to_both_limits(self):
|
||||
"""Before battery profiles, one max_charge_power_w entity also capped discharging."""
|
||||
raw = {
|
||||
"adapter": {
|
||||
"homeassistant": {
|
||||
"config_entity_ids": {
|
||||
"devices/batteries/house/max_charge_power_w": "sensor.limit",
|
||||
"devices/batteries/garage/max_charge_power_w": "sensor.charge",
|
||||
"devices/batteries/garage/profile_adjustment/max_discharge_power_w": (
|
||||
"sensor.discharge"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
migrated = migrate_config_data(raw)
|
||||
assert migrated.adapter.homeassistant.config_entity_ids == {
|
||||
"devices/batteries/house/profile_adjustment/max_charge_power_w": "sensor.limit",
|
||||
"devices/batteries/house/profile_adjustment/max_discharge_power_w": "sensor.limit",
|
||||
"devices/batteries/garage/profile_adjustment/max_charge_power_w": "sensor.charge",
|
||||
"devices/batteries/garage/profile_adjustment/max_discharge_power_w": (
|
||||
"sensor.discharge"
|
||||
),
|
||||
}
|
||||
|
||||
def test_migration_keeps_a_migrated_battery(self):
|
||||
"""A battery in the current layout is not changed, e.g. on a version bump."""
|
||||
battery = {
|
||||
"device_id": "house",
|
||||
"battery_profile": "my-battery",
|
||||
"profile_adjustment": {"max_discharge_power_w": 3000},
|
||||
}
|
||||
migrated = migrate_config_data({"devices": {"batteries": {"house": battery}}})
|
||||
assert migrated.devices.batteries is not None
|
||||
settings = migrated.devices.batteries["house"]
|
||||
assert settings.battery_profile == "my-battery"
|
||||
assert settings.profile_adjustment.max_discharge_power_w == 3000
|
||||
assert settings.profile_adjustment.nominal_capacity_wh is None
|
||||
assert settings.profile_adjustment.max_charge_power_w is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"legacy_name, adjustment_name",
|
||||
[
|
||||
("capacity_wh", "nominal_capacity_wh"),
|
||||
("max_charge_power_w", "max_charge_power_w"),
|
||||
("nominal_capacity_wh", "nominal_capacity_wh"),
|
||||
("max_charge_power_limit_w", "max_charge_power_w"),
|
||||
],
|
||||
)
|
||||
def test_migration_keeps_present_adjustment(self, legacy_name, adjustment_name):
|
||||
"""A value already in the profile adjustment wins over its legacy field."""
|
||||
battery = {
|
||||
"device_id": "house",
|
||||
legacy_name: 12000,
|
||||
"profile_adjustment": {adjustment_name: 10000},
|
||||
}
|
||||
migrated = migrate_config_data({"devices": {"batteries": {"house": battery}}})
|
||||
assert migrated.devices.batteries is not None
|
||||
adjustment = migrated.devices.batteries["house"].profile_adjustment
|
||||
assert getattr(adjustment, adjustment_name) == 10000
|
||||
|
||||
def test_migration_of_home_assistant_config_paths(self):
|
||||
"""Config paths of moved battery fields follow them into the adjustment."""
|
||||
raw = {
|
||||
"adapter": {
|
||||
"homeassistant": {
|
||||
"config_entity_ids": {
|
||||
"devices/batteries/house/capacity_wh": "sensor.capacity",
|
||||
"devices/electric_vehicles/car/max_charge_power_w": "sensor.ev_limit",
|
||||
"devices/batteries/house/charging_efficiency": "sensor.efficiency",
|
||||
"devices/batteries/house/min_soc_percentage": "sensor.min_soc",
|
||||
"general/latitude": "sensor.latitude",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
migrated = migrate_config_data(raw)
|
||||
assert migrated.adapter.homeassistant.config_entity_ids == {
|
||||
"devices/batteries/house/profile_adjustment/nominal_capacity_wh": "sensor.capacity",
|
||||
"devices/electric_vehicles/car/profile_adjustment/max_charge_power_w": (
|
||||
"sensor.ev_limit"
|
||||
),
|
||||
# The former charge limit also capped discharging.
|
||||
"devices/electric_vehicles/car/profile_adjustment/max_discharge_power_w": (
|
||||
"sensor.ev_limit"
|
||||
),
|
||||
# charging_efficiency is computed now and cannot be set.
|
||||
"devices/batteries/house/min_soc_percentage": "sensor.min_soc",
|
||||
"general/latitude": "sensor.latitude",
|
||||
}
|
||||
|
||||
def test_migrate_config_file_keeps_charge_power_limit(self, tmp_path: Path):
|
||||
"""The legacy charge power limit survives the file migration."""
|
||||
config_file = tmp_path / "EOS.config.json"
|
||||
battery = {"device_id": "house", "capacity_wh": 12000, "max_charge_power_w": 3000}
|
||||
config_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"general": {"version": "0.0.0-old"},
|
||||
"devices": {"batteries": {"house": battery}},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert configmigrate.migrate_config_file(config_file, tmp_path / "EOS.config.bak")
|
||||
|
||||
migrated = json.loads(config_file.read_text(encoding="utf-8"))
|
||||
house = migrated["devices"]["batteries"]["house"]
|
||||
assert house["profile_adjustment"] == {
|
||||
"nominal_capacity_wh": 12000,
|
||||
"max_charge_power_w": 3000,
|
||||
"max_discharge_power_w": 3000,
|
||||
}
|
||||
assert "max_charge_power_w" not in house
|
||||
assert "capacity_wh" not in house
|
||||
|
||||
|
||||
class TestSlashKeyedDictMigration:
|
||||
"""Dict entries whose keys contain '/' must survive migration unchanged."""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,472 @@
|
||||
"""Tests for the profilestore module.
|
||||
|
||||
Uses minimal, non-battery profile types to show that one store serves any
|
||||
profile type given by parameter.
|
||||
"""
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import cachebox
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from akkudoktoreos.devices.profile import profilestore as profilestore_module
|
||||
from akkudoktoreos.devices.profile.profilestore import (
|
||||
ProfileDocument,
|
||||
ProfileEntry,
|
||||
ProfileStore,
|
||||
get_profile_store,
|
||||
profile_kind,
|
||||
profile_kinds,
|
||||
profile_type,
|
||||
)
|
||||
|
||||
|
||||
class DummyProfile(ProfileDocument[dict[str, Any]]):
|
||||
"""Minimal profile document of a test profile kind."""
|
||||
|
||||
profile_kind: ClassVar[str] = "dummy"
|
||||
|
||||
model: str
|
||||
max_power_w: float
|
||||
|
||||
def compile_profile(self, scale: float = 1.0, **options: Any) -> dict[str, Any]:
|
||||
"""Compile into a plain dict."""
|
||||
return {"model": self.model, "power_w": self.max_power_w * scale}
|
||||
|
||||
|
||||
class OtherProfile(ProfileDocument[str]):
|
||||
"""Second test profile kind, to check that kinds are kept apart."""
|
||||
|
||||
profile_kind: ClassVar[str] = "other"
|
||||
|
||||
model: str
|
||||
|
||||
def compile_profile(self, **options: Any) -> str:
|
||||
"""Compile into the model name."""
|
||||
return self.model
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(config_eos, tmp_path, monkeypatch):
|
||||
"""Return a fresh singleton store with the packaged directories in tmp_path.
|
||||
|
||||
The packaged directories are moved so the package is not modified. The
|
||||
singleton is reset afterwards, so other tests get an unpatched store.
|
||||
"""
|
||||
ProfileStore.reset_instance()
|
||||
store = get_profile_store()
|
||||
monkeypatch.setattr(
|
||||
store, "package_dir", lambda document_type: tmp_path / "package" / document_type.profile_kind
|
||||
)
|
||||
yield store
|
||||
ProfileStore.reset_instance()
|
||||
|
||||
|
||||
def write_packaged(store: ProfileStore, name: str, document: ProfileDocument[Any]) -> Path:
|
||||
"""Write a document to the packaged directory of its type."""
|
||||
path = store.package_dir(type(document)) / f"{name}.json"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(document.model_dump_json(), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
class TestSingleton:
|
||||
def test_store_is_a_singleton(self, store):
|
||||
assert get_profile_store() is store
|
||||
assert ProfileStore() is store
|
||||
|
||||
def test_reset_gives_a_fresh_store_with_an_empty_cache(self, store):
|
||||
store.save("model-a", DummyProfile(model="a", max_power_w=1.0))
|
||||
before = store.get(DummyProfile, "model-a")
|
||||
ProfileStore.reset_instance()
|
||||
fresh = get_profile_store()
|
||||
assert fresh is not store
|
||||
assert fresh._compiled == {}
|
||||
assert fresh.get(DummyProfile, "model-a") == before
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
def test_profile_types_are_registered_by_kind(self):
|
||||
assert profile_type("dummy") is DummyProfile
|
||||
assert profile_type("other") is OtherProfile
|
||||
assert {"dummy", "other"} <= set(profile_kinds())
|
||||
|
||||
def test_battery_profile_is_registered(self):
|
||||
from akkudoktoreos.devices.profile.batteryprofile import BatteryProfileSettings
|
||||
|
||||
assert profile_type("battery") is BatteryProfileSettings
|
||||
|
||||
def test_unknown_kind_raises(self):
|
||||
with pytest.raises(KeyError, match="Unknown profile kind 'nope'"):
|
||||
profile_type("nope")
|
||||
|
||||
def test_kind_of_another_type_cannot_be_taken(self):
|
||||
with pytest.raises(ValueError, match="already used by"):
|
||||
|
||||
class Imposter(ProfileDocument[str]):
|
||||
profile_kind: ClassVar[str] = "dummy"
|
||||
|
||||
assert profile_type("dummy") is DummyProfile
|
||||
|
||||
def test_subclass_without_own_kind_is_not_registered(self):
|
||||
class Derived(DummyProfile):
|
||||
pass
|
||||
|
||||
assert profile_type("dummy") is DummyProfile
|
||||
assert profile_kind(Derived) == "dummy"
|
||||
|
||||
|
||||
class TestProfileKind:
|
||||
def test_directories_follow_kind(self, config_eos):
|
||||
store = get_profile_store()
|
||||
assert store.user_dir(DummyProfile) == (
|
||||
config_eos.general.data_folder_path / "profile" / "dummy"
|
||||
)
|
||||
assert store.package_dir(DummyProfile) == (
|
||||
config_eos.package_root_path / "data" / "profile" / "dummy"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("kind", ["", "Battery", "inverter-x", "../x", "1battery", 3])
|
||||
def test_invalid_kind_is_rejected_when_the_type_is_defined(self, kind):
|
||||
with pytest.raises(ValueError, match="Invalid profile kind"):
|
||||
|
||||
class BadProfile(DummyProfile):
|
||||
profile_kind: ClassVar[Any] = kind
|
||||
|
||||
def test_missing_kind_is_rejected(self, store):
|
||||
class NoKindProfile(ProfileDocument[str]):
|
||||
model: str
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid profile kind"):
|
||||
profile_kind(NoKindProfile)
|
||||
with pytest.raises(ValueError, match="Invalid profile kind"):
|
||||
store.save("model-a", NoKindProfile(model="a"))
|
||||
|
||||
def test_kinds_are_kept_apart(self, store):
|
||||
store.save("model-a", DummyProfile(model="dummy", max_power_w=1.0))
|
||||
assert store.path(OtherProfile, "model-a")[1] is False
|
||||
store.save("model-a", OtherProfile(model="other"))
|
||||
assert store.get(OtherProfile, "model-a") == "other"
|
||||
assert store.get(DummyProfile, "model-a")["model"] == "dummy"
|
||||
|
||||
def test_missing_compile_raises(self, store):
|
||||
class NoCompileProfile(ProfileDocument[str]):
|
||||
profile_kind: ClassVar[str] = "nocompile"
|
||||
model: str
|
||||
|
||||
store.save("model-a", NoCompileProfile(model="a"))
|
||||
with pytest.raises(NotImplementedError, match="compile_profile"):
|
||||
store.get(NoCompileProfile, "model-a")
|
||||
|
||||
|
||||
class TestProfileNames:
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
[
|
||||
"../escape",
|
||||
"a/../../b",
|
||||
"/etc/passwd",
|
||||
"..\\escape",
|
||||
"vendor/model-a",
|
||||
# Drive relative on Windows: not absolute for PurePosixPath.
|
||||
"C:evil",
|
||||
".hidden",
|
||||
"-dash",
|
||||
"name\n",
|
||||
"x" * 101,
|
||||
],
|
||||
)
|
||||
def test_invalid_names_are_rejected(self, store, name):
|
||||
with pytest.raises(ValueError, match="Invalid dummy profile name"):
|
||||
store.path(DummyProfile, name)
|
||||
with pytest.raises(ValueError, match="Invalid dummy profile name"):
|
||||
store.save(name, DummyProfile(model="x", max_power_w=1.0))
|
||||
|
||||
def test_empty_name_is_rejected(self, store):
|
||||
with pytest.raises(ValueError, match="Invalid dummy profile name"):
|
||||
store.path(DummyProfile, "")
|
||||
|
||||
def test_reserved_name_is_rejected(self, store):
|
||||
"""'schema' is the path of the JSON schema in the REST API."""
|
||||
with pytest.raises(ValueError, match="reserved"):
|
||||
store.save("schema", DummyProfile(model="x", max_power_w=1.0))
|
||||
|
||||
@pytest.mark.parametrize("name", ["model-a", "Vendor_Model.v2", "9kwh"])
|
||||
def test_valid_names_are_files_in_the_profile_directory(self, store, name):
|
||||
path = store.save(name, DummyProfile(model="a", max_power_w=1.0))
|
||||
assert path == store.user_dir(DummyProfile) / f"{name}.json"
|
||||
assert store.path(DummyProfile, name) == (path, True)
|
||||
|
||||
|
||||
class TestProfileDocuments:
|
||||
def test_missing_profile_names_kind_and_path(self, store):
|
||||
path, exists = store.path(DummyProfile, "missing")
|
||||
assert not exists
|
||||
assert path == store.user_dir(DummyProfile) / "missing.json"
|
||||
with pytest.raises(FileNotFoundError, match=r"No dummy profile for 'missing'.*missing\.json"):
|
||||
store.load(DummyProfile, "missing")
|
||||
|
||||
def test_packaged_profile_is_found(self, store):
|
||||
packaged = write_packaged(store, "model-a", DummyProfile(model="a", max_power_w=1.0))
|
||||
assert store.path(DummyProfile, "model-a") == (packaged, True)
|
||||
assert store.load(DummyProfile, "model-a").model == "a"
|
||||
|
||||
def test_user_profile_overrides_packaged_profile(self, store):
|
||||
write_packaged(store, "model-a", DummyProfile(model="packaged", max_power_w=1.0))
|
||||
user = store.save("model-a", DummyProfile(model="user", max_power_w=2.0))
|
||||
assert store.path(DummyProfile, "model-a") == (user, True)
|
||||
assert store.load(DummyProfile, "model-a").model == "user"
|
||||
|
||||
def test_invalid_document_raises(self, store):
|
||||
path = store.user_dir(DummyProfile) / "broken.json"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text('{"model": "x"}', encoding="utf-8")
|
||||
with pytest.raises(ValidationError):
|
||||
store.load(DummyProfile, "broken")
|
||||
|
||||
|
||||
class TestCompiledCache:
|
||||
def test_compiled_profile_is_cached(self, store):
|
||||
store.save("model-a", DummyProfile(model="a", max_power_w=100.0))
|
||||
first = store.get(DummyProfile, "model-a")
|
||||
assert first == {"model": "a", "power_w": 100.0}
|
||||
assert store.get(DummyProfile, "model-a") is first
|
||||
|
||||
def test_options_are_passed_and_part_of_cache_key(self, store):
|
||||
store.save("model-a", DummyProfile(model="a", max_power_w=100.0))
|
||||
default = store.get(DummyProfile, "model-a")
|
||||
scaled = store.get(DummyProfile, "model-a", scale=0.5)
|
||||
assert scaled["power_w"] == pytest.approx(50.0)
|
||||
assert store.get(DummyProfile, "model-a", scale=0.5) is scaled
|
||||
assert store.get(DummyProfile, "model-a") is default
|
||||
# Passing the default value explicitly is the same compilation.
|
||||
assert store.get(DummyProfile, "model-a", scale=1.0) is default
|
||||
|
||||
def test_changed_file_is_recompiled(self, store):
|
||||
store.save("model-a", DummyProfile(model="a", max_power_w=100.0))
|
||||
before = store.get(DummyProfile, "model-a")
|
||||
store.save("model-a", DummyProfile(model="a", max_power_w=2500.0))
|
||||
after = store.get(DummyProfile, "model-a")
|
||||
assert after is not before
|
||||
assert after["power_w"] == pytest.approx(2500.0)
|
||||
|
||||
def test_file_edited_outside_eos_applies_after_the_recheck_interval(self, store, monkeypatch):
|
||||
recheck_s = 0.2
|
||||
# cachebox expires with its own clock: use a short interval and wait.
|
||||
monkeypatch.setattr(store, "_recent", cachebox.TTLCache(16, recheck_s))
|
||||
path = store.save("model-a", DummyProfile(model="a", max_power_w=100.0))
|
||||
before = store.get(DummyProfile, "model-a")
|
||||
path.write_text(
|
||||
DummyProfile(model="a", max_power_w=2500.0).model_dump_json(), encoding="utf-8"
|
||||
)
|
||||
os.utime(path, ns=(path.stat().st_mtime_ns + 10**9,) * 2)
|
||||
# Checked recently: the file is not looked at again.
|
||||
assert store.get(DummyProfile, "model-a") is before
|
||||
time.sleep(recheck_s * 1.5)
|
||||
assert store.get(DummyProfile, "model-a")["power_w"] == pytest.approx(2500.0)
|
||||
|
||||
def test_saved_override_of_a_packaged_profile_applies_at_once(self, store):
|
||||
write_packaged(store, "model-a", DummyProfile(model="packaged", max_power_w=1.0))
|
||||
assert store.get(DummyProfile, "model-a")["model"] == "packaged"
|
||||
store.save("model-a", DummyProfile(model="user", max_power_w=2.0))
|
||||
assert store.get(DummyProfile, "model-a")["model"] == "user"
|
||||
|
||||
def test_clear_cache_forces_recompile(self, store):
|
||||
store.save("model-a", DummyProfile(model="a", max_power_w=100.0))
|
||||
before = store.get(DummyProfile, "model-a")
|
||||
store.clear_cache()
|
||||
assert store.get(DummyProfile, "model-a") is not before
|
||||
|
||||
def test_clear_cache_of_one_type_keeps_others(self, store):
|
||||
store.save("model-a", DummyProfile(model="a", max_power_w=100.0))
|
||||
store.save("model-a", OtherProfile(model="a"))
|
||||
dummy = store.get(DummyProfile, "model-a")
|
||||
store.get(OtherProfile, "model-a")
|
||||
store.clear_cache(OtherProfile)
|
||||
assert store.get(DummyProfile, "model-a") is dummy
|
||||
assert [key.document_type for key in store._compiled] == [DummyProfile]
|
||||
|
||||
|
||||
class TestListAndDelete:
|
||||
def test_list_shows_user_and_packaged_profiles(self, store):
|
||||
write_packaged(store, "model-a", DummyProfile(model="packaged", max_power_w=1.0))
|
||||
write_packaged(store, "model-b", DummyProfile(model="packaged", max_power_w=1.0))
|
||||
store.save("model-a", DummyProfile(model="user", max_power_w=2.0))
|
||||
store.save("model-c", DummyProfile(model="user", max_power_w=3.0))
|
||||
# Files that are no valid profile, e.g. in a subdirectory, are not listed.
|
||||
(store.user_dir(DummyProfile) / "vendor").mkdir()
|
||||
(store.user_dir(DummyProfile) / "vendor" / "model-d.json").write_text("{}", encoding="utf-8")
|
||||
(store.user_dir(DummyProfile) / "schema.json").write_text("{}", encoding="utf-8")
|
||||
assert store.list_profiles(DummyProfile) == [
|
||||
ProfileEntry(name="model-a", source="user", packaged=True),
|
||||
ProfileEntry(name="model-b", source="package", packaged=True),
|
||||
ProfileEntry(name="model-c", source="user", packaged=False),
|
||||
]
|
||||
|
||||
def test_list_of_a_kind_without_profiles_is_empty(self, store):
|
||||
assert store.list_profiles(OtherProfile) == []
|
||||
|
||||
def test_delete_user_profile_makes_the_packaged_one_apply(self, store):
|
||||
write_packaged(store, "model-a", DummyProfile(model="packaged", max_power_w=1.0))
|
||||
store.save("model-a", DummyProfile(model="user", max_power_w=2.0))
|
||||
assert store.get(DummyProfile, "model-a")["model"] == "user"
|
||||
store.delete(DummyProfile, "model-a")
|
||||
assert store.get(DummyProfile, "model-a")["model"] == "packaged"
|
||||
assert store.list_profiles(DummyProfile) == [
|
||||
ProfileEntry(name="model-a", source="package", packaged=True)
|
||||
]
|
||||
|
||||
def test_packaged_profile_cannot_be_deleted(self, store):
|
||||
packaged = write_packaged(store, "model-a", DummyProfile(model="a", max_power_w=1.0))
|
||||
with pytest.raises(PermissionError, match="packaged"):
|
||||
store.delete(DummyProfile, "model-a")
|
||||
assert packaged.is_file()
|
||||
|
||||
def test_delete_of_a_missing_profile_raises(self, store):
|
||||
with pytest.raises(FileNotFoundError, match="No dummy profile for 'missing'"):
|
||||
store.delete(DummyProfile, "missing")
|
||||
|
||||
def test_delete_rejects_unsafe_names(self, store):
|
||||
with pytest.raises(ValueError, match="Invalid dummy profile name"):
|
||||
store.delete(DummyProfile, "../escape")
|
||||
|
||||
def test_delete_refuses_a_directory(self, store):
|
||||
"""A directory named like a profile file is no profile: ValueError, not OSError."""
|
||||
(store.user_dir(DummyProfile) / "model-a.json").mkdir(parents=True)
|
||||
with pytest.raises(ValueError, match="is not a file"):
|
||||
store.delete(DummyProfile, "model-a")
|
||||
|
||||
|
||||
class TestConcurrency:
|
||||
def test_save_replaces_atomically_and_leaves_no_temp_files(self, store):
|
||||
path = store.save("model-a", DummyProfile(model="a", max_power_w=1.0))
|
||||
store.save("model-a", DummyProfile(model="b", max_power_w=2.0))
|
||||
assert [p.name for p in path.parent.iterdir()] == ["model-a.json"]
|
||||
|
||||
def test_failed_save_keeps_the_old_document(self, store, monkeypatch):
|
||||
import os
|
||||
|
||||
path = store.save("model-a", DummyProfile(model="old", max_power_w=1.0))
|
||||
|
||||
def broken_replace(src: Any, dst: Any) -> None:
|
||||
raise OSError("disk full")
|
||||
|
||||
monkeypatch.setattr(os, "replace", broken_replace)
|
||||
with pytest.raises(OSError, match="disk full"):
|
||||
store.save("model-a", DummyProfile(model="new", max_power_w=2.0))
|
||||
assert store.load(DummyProfile, "model-a").model == "old"
|
||||
assert [p.name for p in path.parent.iterdir()] == ["model-a.json"]
|
||||
|
||||
def test_save_drops_the_cache_of_the_file(self, store, monkeypatch):
|
||||
store.save("model-a", DummyProfile(model="a", max_power_w=100.0))
|
||||
store.get(DummyProfile, "model-a")
|
||||
assert store._compiled
|
||||
store.save("model-a", DummyProfile(model="a", max_power_w=100.0))
|
||||
assert store._compiled == {}
|
||||
|
||||
def test_save_during_a_lookup_is_not_hidden_by_the_cache(self, store, monkeypatch):
|
||||
"""A lookup that read the old file must not cache it after a concurrent save.
|
||||
|
||||
The saved file has the same size and, as on file systems with coarse
|
||||
timestamps, the same modification time as the old one.
|
||||
"""
|
||||
path = store.save("model-a", DummyProfile(model="a", max_power_w=100.0))
|
||||
old_mtime_ns = path.stat().st_mtime_ns
|
||||
original_load = store.load_file
|
||||
saved: list[bool] = []
|
||||
|
||||
def load_then_save(document_type, file_path):
|
||||
document = original_load(document_type, file_path)
|
||||
if not saved:
|
||||
saved.append(True)
|
||||
store.save("model-a", DummyProfile(model="a", max_power_w=200.0))
|
||||
os.utime(path, ns=(old_mtime_ns, old_mtime_ns))
|
||||
return document
|
||||
|
||||
monkeypatch.setattr(store, "load_file", load_then_save)
|
||||
assert store.get(DummyProfile, "model-a")["power_w"] == pytest.approx(100.0)
|
||||
assert store.get(DummyProfile, "model-a")["power_w"] == pytest.approx(200.0)
|
||||
|
||||
def test_readers_never_see_a_partial_document(self, store):
|
||||
store.save("model-a", DummyProfile(model="a", max_power_w=0.0))
|
||||
errors: list[BaseException] = []
|
||||
stop = threading.Event()
|
||||
|
||||
def write() -> None:
|
||||
try:
|
||||
for i in range(200):
|
||||
store.save("model-a", DummyProfile(model="a" * (i % 50 + 1), max_power_w=i))
|
||||
except BaseException as exc: # noqa: BLE001 - collect for the assertion
|
||||
errors.append(exc)
|
||||
finally:
|
||||
stop.set()
|
||||
|
||||
def read() -> None:
|
||||
while not stop.is_set():
|
||||
try:
|
||||
store.get(DummyProfile, "model-a")
|
||||
except BaseException as exc: # noqa: BLE001 - collect for the assertion
|
||||
errors.append(exc)
|
||||
return
|
||||
|
||||
threads = [threading.Thread(target=write)] + [
|
||||
threading.Thread(target=read) for _ in range(4)
|
||||
]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join(timeout=30)
|
||||
assert errors == []
|
||||
assert store.get(DummyProfile, "model-a")["power_w"] == 199.0
|
||||
|
||||
|
||||
class TestSharingViolation:
|
||||
"""On Windows, replacing or reading a file fails while another handle has it open."""
|
||||
|
||||
@pytest.fixture
|
||||
def windows(self, monkeypatch):
|
||||
monkeypatch.setattr(profilestore_module, "_RETRY_SHARING_VIOLATIONS", True)
|
||||
monkeypatch.setattr(profilestore_module, "SHARING_VIOLATION_RETRY_DELAY_S", 0.0)
|
||||
|
||||
@staticmethod
|
||||
def failing_first(original, failures: int):
|
||||
calls: list[int] = []
|
||||
|
||||
def operation(*args: Any, **kwargs: Any) -> Any:
|
||||
calls.append(1)
|
||||
if len(calls) <= failures:
|
||||
raise PermissionError("[WinError 5] Access denied")
|
||||
return original(*args, **kwargs)
|
||||
|
||||
return operation
|
||||
|
||||
def test_save_retries_a_replace_blocked_by_a_reader(self, store, windows, monkeypatch):
|
||||
store.save("model-a", DummyProfile(model="old", max_power_w=1.0))
|
||||
monkeypatch.setattr(os, "replace", self.failing_first(os.replace, 3))
|
||||
path = store.save("model-a", DummyProfile(model="new", max_power_w=2.0))
|
||||
assert store.load(DummyProfile, "model-a").model == "new"
|
||||
assert [p.name for p in path.parent.iterdir()] == ["model-a.json"]
|
||||
|
||||
def test_load_retries_a_read_blocked_by_a_replace(self, store, windows, monkeypatch):
|
||||
store.save("model-a", DummyProfile(model="a", max_power_w=1.0))
|
||||
monkeypatch.setattr(Path, "read_text", self.failing_first(Path.read_text, 3))
|
||||
assert store.load(DummyProfile, "model-a").model == "a"
|
||||
|
||||
def test_lasting_permission_error_is_raised(self, store, windows, monkeypatch):
|
||||
store.save("model-a", DummyProfile(model="a", max_power_w=1.0))
|
||||
monkeypatch.setattr(Path, "read_text", self.failing_first(Path.read_text, 10**6))
|
||||
with pytest.raises(PermissionError):
|
||||
store.load(DummyProfile, "model-a")
|
||||
|
||||
def test_permission_error_is_not_retried_elsewhere(self, store, monkeypatch):
|
||||
monkeypatch.setattr(profilestore_module, "_RETRY_SHARING_VIOLATIONS", False)
|
||||
store.save("model-a", DummyProfile(model="a", max_power_w=1.0))
|
||||
read_text = self.failing_first(Path.read_text, 1)
|
||||
monkeypatch.setattr(Path, "read_text", read_text)
|
||||
with pytest.raises(PermissionError):
|
||||
store.load(DummyProfile, "model-a")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -311,3 +311,36 @@ def test_car_and_pv_battery_discharge_and_max_charge_power(setup_pv_battery, set
|
||||
assert car_battery.parameters.max_charge_power_w == 7000, (
|
||||
"Car battery max charge power should remain as defined"
|
||||
)
|
||||
|
||||
|
||||
def _battery_with_limits(max_charge_power_w, max_discharge_power_w=None) -> Genetic0Battery:
|
||||
params = Genetic0SolarPanelBatteryParameters(
|
||||
device_id="battery1",
|
||||
capacity_wh=10000,
|
||||
initial_soc_percentage=50,
|
||||
min_soc_percentage=10,
|
||||
max_soc_percentage=90,
|
||||
max_charge_power_w=max_charge_power_w,
|
||||
max_discharge_power_w=max_discharge_power_w,
|
||||
)
|
||||
battery = Genetic0Battery(params, prediction_hours=24)
|
||||
battery.reset()
|
||||
battery.discharge_array = np.full(battery.prediction_hours, 1)
|
||||
battery.charge_array = np.full(battery.prediction_hours, 1)
|
||||
return battery
|
||||
|
||||
|
||||
def test_battery_that_may_not_charge_still_discharges():
|
||||
"""A cold battery gets 0 W charge power but may still discharge up to its discharge limit."""
|
||||
battery = _battery_with_limits(max_charge_power_w=0.0, max_discharge_power_w=3000.0)
|
||||
delivered_wh, _ = battery.discharge_energy(5000.0, 0)
|
||||
assert 0.0 < delivered_wh <= 3000.0
|
||||
charged_wh, _ = battery.charge_energy(None, 0, charge_factor=1.0)
|
||||
assert charged_wh == 0.0
|
||||
|
||||
|
||||
def test_discharge_limit_defaults_to_the_charge_power():
|
||||
battery = _battery_with_limits(max_charge_power_w=2000.0)
|
||||
assert battery.max_discharge_power_w == 2000.0
|
||||
delivered_wh, _ = battery.discharge_energy(5000.0, 0)
|
||||
assert delivered_wh <= 2000.0
|
||||
|
||||
@@ -169,3 +169,62 @@ async def test_optimize(
|
||||
|
||||
# Everything passed, remove generated files
|
||||
TESTDATA_FILE.unlink()
|
||||
|
||||
|
||||
class TestGenetic0SocClampedOperationFactors:
|
||||
"""The SOC clamping uses the optimizer battery parameters, not the settings.
|
||||
|
||||
Battery settings no longer hold capacity / efficiency / power fields (they
|
||||
come from the battery profile), so reading them from the settings fails.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def solution(self, config_eos: ConfigEOS) -> Genetic0Solution:
|
||||
# A configured battery whose settings differ from the solution parameters.
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"devices": {
|
||||
"max_batteries": 1,
|
||||
"batteries": {
|
||||
"battery1": {"device_id": "battery1", "profile_adjustment": {"nominal_capacity_wh": 8000}}
|
||||
},
|
||||
"max_inverters": 0,
|
||||
"inverters": None,
|
||||
}
|
||||
}
|
||||
)
|
||||
data = json.loads((DIR_TESTDATA / "optimize_result_1.json").read_text())
|
||||
return Genetic0Solution.model_validate(data)
|
||||
|
||||
def test_uses_solution_battery_parameters(self, solution):
|
||||
bat = solution.parameters.pv_battery
|
||||
assert bat is not None
|
||||
assert bat.capacity_wh == 26400
|
||||
# 1 % headroom of 26400 Wh against one hour at 5000 W and the charging
|
||||
# efficiency of the parameters (no inverter configured).
|
||||
headroom_wh = 0.01 * bat.capacity_wh
|
||||
expected = headroom_wh / (bat.max_charge_power_w * bat.charging_efficiency)
|
||||
ac, dc, dis = solution._soc_clamped_operation_factors(
|
||||
1.0, 1.0, True, float(bat.max_soc_percentage) - 1.0
|
||||
)
|
||||
assert ac == pytest.approx(expected)
|
||||
assert dc == pytest.approx(1.0)
|
||||
assert dis is True
|
||||
|
||||
def test_full_and_empty_battery(self, solution):
|
||||
bat = solution.parameters.pv_battery
|
||||
assert solution._soc_clamped_operation_factors(
|
||||
1.0, 1.0, True, float(bat.max_soc_percentage)
|
||||
) == (0.0, 0.0, True)
|
||||
assert solution._soc_clamped_operation_factors(
|
||||
0.0, 0.0, True, float(bat.min_soc_percentage)
|
||||
) == (0.0, 0.0, False)
|
||||
|
||||
def test_without_battery_parameters_factors_are_unchanged(self, solution):
|
||||
solution.parameters.pv_battery = None
|
||||
assert solution._soc_clamped_operation_factors(1.0, 0.5, True, 50.0) == (1.0, 0.5, True)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_optimization_solution_with_configured_battery(self, solution):
|
||||
optimization_solution = await solution.optimization_solution()
|
||||
assert optimization_solution is not None
|
||||
|
||||
@@ -158,7 +158,9 @@ class TestGenetic0MeasuredSOC:
|
||||
"max_electric_vehicles": 0,
|
||||
"max_inverters": 1,
|
||||
"max_home_appliances": 0,
|
||||
"batteries": {"storage": {"capacity_wh": 2000, "max_charge_power_w": 1000}},
|
||||
"batteries": {
|
||||
"storage": {"profile_adjustment": {"nominal_capacity_wh": 2000, "max_charge_power_w": 1000}}
|
||||
},
|
||||
"inverters": {"inverter": {"battery_id": "storage", "max_power_w": 1000}},
|
||||
},
|
||||
}
|
||||
@@ -203,7 +205,9 @@ class TestGenetic0MeasuredSOC:
|
||||
"max_electric_vehicles": 0,
|
||||
"max_inverters": 1,
|
||||
"max_home_appliances": 0,
|
||||
"batteries": {"storage": {"capacity_wh": 2000, "max_charge_power_w": 1000}},
|
||||
"batteries": {
|
||||
"storage": {"profile_adjustment": {"nominal_capacity_wh": 2000, "max_charge_power_w": 1000}}
|
||||
},
|
||||
"inverters": {"inverter": {"battery_id": "storage", "max_power_w": 1000}},
|
||||
},
|
||||
}
|
||||
@@ -247,7 +251,9 @@ class TestGenetic0MeasuredSOC:
|
||||
"max_electric_vehicles": 0,
|
||||
"max_inverters": 1,
|
||||
"max_home_appliances": 0,
|
||||
"batteries": {"storage": {"capacity_wh": 2000, "max_charge_power_w": 1000}},
|
||||
"batteries": {
|
||||
"storage": {"profile_adjustment": {"nominal_capacity_wh": 2000, "max_charge_power_w": 1000}}
|
||||
},
|
||||
"inverters": {"inverter": {"battery_id": "storage", "max_power_w": 1000}},
|
||||
},
|
||||
}
|
||||
@@ -277,3 +283,106 @@ class TestGenetic0MeasuredSOC:
|
||||
assert parameters is not None
|
||||
assert parameters.pv_battery is not None
|
||||
assert parameters.pv_battery.initial_soc_percentage == 42
|
||||
|
||||
|
||||
class TestGenetic0DemoData:
|
||||
"""Demo device data used when no battery / electric vehicle is configured."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_demo_devices_use_nominal_capacity(self, config_eos):
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": 48},
|
||||
"optimization": {"genetic0": {"horizon_hours": 24}},
|
||||
"elecfee": {"provider": None},
|
||||
"devices": {
|
||||
"max_batteries": 1,
|
||||
"max_electric_vehicles": 1,
|
||||
"max_inverters": 0,
|
||||
"max_home_appliances": 0,
|
||||
"batteries": None,
|
||||
"electric_vehicles": None,
|
||||
},
|
||||
}
|
||||
)
|
||||
get_ems().set_start_datetime(to_datetime("2026-08-01T14:00:00+00:00"))
|
||||
tariff = pd.Series(
|
||||
0.00008, index=pd.date_range("2026-08-01T00:00:00Z", periods=72 * 4, freq="15min")
|
||||
)
|
||||
|
||||
async def read_array(**kwargs):
|
||||
return np.full(48, 0.0003)
|
||||
|
||||
prediction = Mock(
|
||||
update_data=AsyncMock(),
|
||||
key_to_array=AsyncMock(side_effect=read_array),
|
||||
key_to_raw_series=AsyncMock(return_value=tariff),
|
||||
)
|
||||
with patch.object(Genetic0OptimizationParameters, "prediction", prediction):
|
||||
parameters = await Genetic0OptimizationParameters.prepare()
|
||||
|
||||
assert parameters is not None
|
||||
# The demo capacities must reach the profile adjustment of the settings;
|
||||
# the generic default profile provides no capacity of its own.
|
||||
assert config_eos.devices.batteries["battery1"].profile_adjustment.nominal_capacity_wh == 8000
|
||||
assert (
|
||||
config_eos.devices.electric_vehicles["ev1"].profile_adjustment.nominal_capacity_wh
|
||||
== 50000
|
||||
)
|
||||
assert parameters.pv_battery is not None
|
||||
assert parameters.pv_battery.capacity_wh == 8000
|
||||
assert parameters.ev is not None
|
||||
assert parameters.ev.capacity_wh == 50000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_battery_configuration_error_uses_defaults_not_demo_data(self, config_eos):
|
||||
"""A misconfigured battery keeps its configuration, with defaults for the rest."""
|
||||
from loguru import logger
|
||||
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": 48},
|
||||
"optimization": {"genetic0": {"horizon_hours": 24}},
|
||||
"elecfee": {"provider": None},
|
||||
"devices": {
|
||||
"max_batteries": 1,
|
||||
"max_electric_vehicles": 0,
|
||||
"max_inverters": 0,
|
||||
"max_home_appliances": 0,
|
||||
# generic-lfp provides no capacity and the adjustment none either.
|
||||
"batteries": {"storage": {"min_soc_percentage": 20}},
|
||||
"electric_vehicles": None,
|
||||
},
|
||||
}
|
||||
)
|
||||
get_ems().set_start_datetime(to_datetime("2026-08-01T14:00:00+00:00"))
|
||||
tariff = pd.Series(
|
||||
0.00008, index=pd.date_range("2026-08-01T00:00:00Z", periods=72 * 4, freq="15min")
|
||||
)
|
||||
|
||||
async def read_array(**kwargs):
|
||||
return np.full(48, 0.0003)
|
||||
|
||||
prediction = Mock(
|
||||
update_data=AsyncMock(),
|
||||
key_to_array=AsyncMock(side_effect=read_array),
|
||||
key_to_raw_series=AsyncMock(return_value=tariff),
|
||||
)
|
||||
messages: list[str] = []
|
||||
sink_id = logger.add(
|
||||
lambda message: messages.append(message.record["message"]), level="ERROR"
|
||||
)
|
||||
try:
|
||||
with patch.object(Genetic0OptimizationParameters, "prediction", prediction):
|
||||
parameters = await Genetic0OptimizationParameters.prepare()
|
||||
finally:
|
||||
logger.remove(sink_id)
|
||||
assert parameters is not None
|
||||
assert parameters.pv_battery is not None
|
||||
# The configured battery, not the demo battery: its own SoC minimum.
|
||||
assert parameters.pv_battery.device_id == "storage"
|
||||
assert parameters.pv_battery.min_soc_percentage == 20
|
||||
assert parameters.pv_battery.capacity_wh == 8000
|
||||
assert any(
|
||||
"Battery 'storage'" in m and "nominal capacity 8000 Wh" in m for m in messages
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ from akkudoktoreos.devices.genetic.battery import (
|
||||
ElectricVehicleParameters,
|
||||
SolarPanelBatteryParameters,
|
||||
)
|
||||
from akkudoktoreos.devices.settings.batterysettings import BatteryProfileAdjustment
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -417,6 +418,7 @@ def test_rated_discharge_energy_scales_with_slot_duration(setup_pv_battery):
|
||||
def test_pv_converter_preserves_id_lcos_charge_and_export_rates():
|
||||
settings = BatteriesCommonSettings(
|
||||
device_id="house",
|
||||
profile_adjustment=BatteryProfileAdjustment(nominal_capacity_wh=8000),
|
||||
charge_rates=[0.0, 0.5, 1.0],
|
||||
grid_export_rates=[1.0, 0.25],
|
||||
levelized_cost_of_storage_amt_kwh=0.123,
|
||||
@@ -434,7 +436,8 @@ def test_pv_converter_preserves_id_lcos_charge_and_export_rates():
|
||||
@pytest.mark.parametrize("target", [0, 50, 100])
|
||||
def test_equal_soc_limits_are_valid(target):
|
||||
settings = BatteriesCommonSettings(device_id="ev", min_soc_percentage=target,
|
||||
max_soc_percentage=target)
|
||||
max_soc_percentage=target,
|
||||
profile_adjustment=BatteryProfileAdjustment(nominal_capacity_wh=8000))
|
||||
assert settings.to_genetic_ev_bat_param().min_soc_percentage == target
|
||||
assert settings.to_genetic0_ev_bat_param().min_soc_percentage == target
|
||||
|
||||
@@ -447,9 +450,11 @@ def test_inverted_or_out_of_range_limits_still_fail(minimum, maximum):
|
||||
|
||||
|
||||
def test_full_target_charges_to_capacity_without_overcharge():
|
||||
settings = BatteriesCommonSettings(device_id="ev", capacity_wh=1000,
|
||||
min_soc_percentage=100, max_soc_percentage=100, max_charge_power_w=1000,
|
||||
charging_efficiency=1)
|
||||
settings = BatteriesCommonSettings(device_id="ev",
|
||||
profile_adjustment=BatteryProfileAdjustment(
|
||||
nominal_capacity_wh=1000, max_charge_power_w=1000
|
||||
),
|
||||
min_soc_percentage=100, max_soc_percentage=100)
|
||||
params = settings.to_genetic_ev_bat_param()
|
||||
params.initial_soc_percentage = 70
|
||||
battery = Battery(params, prediction_hours=4, slot_duration_h=0.25)
|
||||
@@ -525,3 +530,233 @@ class TestEVMinimumCurrent(unittest.TestCase):
|
||||
self.assertAlmostEqual(value.soc_wh, value.max_soc_wh)
|
||||
self.assertAlmostEqual(losses, missing / 0.95 - missing)
|
||||
self.assertEqual(value.charge_array[0], factor)
|
||||
|
||||
|
||||
class TestMaxDischargePower:
|
||||
"""The discharge power limit is separate from the charge power limit."""
|
||||
|
||||
@staticmethod
|
||||
def _battery(max_discharge_power_w=None, slot_duration_h=1.0):
|
||||
params = SolarPanelBatteryParameters(
|
||||
device_id="battery1",
|
||||
capacity_wh=10_000,
|
||||
initial_soc_percentage=100,
|
||||
min_soc_percentage=0,
|
||||
max_charge_power_w=8_000,
|
||||
max_discharge_power_w=max_discharge_power_w,
|
||||
charging_efficiency=1.0,
|
||||
discharging_efficiency=1.0,
|
||||
)
|
||||
battery = Battery(params, prediction_hours=4, slot_duration_h=slot_duration_h)
|
||||
battery.set_discharge_per_hour(np.ones(4))
|
||||
return battery
|
||||
|
||||
def test_defaults_to_max_charge_power(self):
|
||||
battery = self._battery()
|
||||
assert battery.max_discharge_power_w == 8_000
|
||||
delivered, _ = battery.discharge_energy(9_000.0, 0)
|
||||
assert delivered == pytest.approx(8_000.0)
|
||||
|
||||
def test_limits_discharge_not_charge(self):
|
||||
battery = self._battery(max_discharge_power_w=4_000)
|
||||
delivered, _ = battery.discharge_energy(9_000.0, 0)
|
||||
assert delivered == pytest.approx(4_000.0)
|
||||
assert battery.remaining_discharge_energy_wh(0) == pytest.approx(0.0)
|
||||
assert battery.rated_discharge_energy_wh() == pytest.approx(4_000.0)
|
||||
|
||||
empty = self._battery(max_discharge_power_w=4_000)
|
||||
empty.soc_wh = 0.0
|
||||
empty.set_charge_per_hour(np.ones(4))
|
||||
stored, _ = empty.charge_energy(9_000.0, 0)
|
||||
assert stored == pytest.approx(8_000.0)
|
||||
|
||||
def test_quarter_hour_budget(self):
|
||||
battery = self._battery(max_discharge_power_w=4_000, slot_duration_h=0.25)
|
||||
assert battery.remaining_discharge_energy_wh(0) == pytest.approx(1_000.0)
|
||||
delivered, _ = battery.discharge_energy(2_000.0, 0)
|
||||
assert delivered == pytest.approx(1_000.0)
|
||||
|
||||
def test_zero_inhibits_discharge(self):
|
||||
battery = self._battery(max_discharge_power_w=0)
|
||||
assert battery.discharge_energy(1_000.0, 0) == (0.0, 0.0)
|
||||
assert battery.rated_discharge_energy_wh() == 0.0
|
||||
|
||||
def test_negative_is_rejected(self):
|
||||
with pytest.raises(ValidationError):
|
||||
SolarPanelBatteryParameters(
|
||||
device_id="battery1", capacity_wh=10_000, max_discharge_power_w=-1
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("converter", ["to_genetic_pv_bat_param", "to_genetic_ev_bat_param"])
|
||||
def test_converter_passes_profile_discharge_limit(self, converter):
|
||||
"""generic-lfp allows only 0.5C continuous discharge.
|
||||
|
||||
Without an installation limit the generic profile is capped at the former
|
||||
default of 5000 W, so 1C charging of 8 kWh gives 5000 W.
|
||||
"""
|
||||
settings = BatteriesCommonSettings(
|
||||
device_id="battery1", profile_adjustment=BatteryProfileAdjustment(nominal_capacity_wh=8_000)
|
||||
)
|
||||
parameters = getattr(settings, converter)()
|
||||
assert parameters.max_charge_power_w == pytest.approx(5_000.0)
|
||||
assert parameters.max_discharge_power_w == pytest.approx(4_000.0)
|
||||
|
||||
@pytest.mark.parametrize("converter", ["to_genetic_pv_bat_param", "to_genetic_ev_bat_param"])
|
||||
def test_installation_limit_lifts_the_generic_default(self, converter):
|
||||
"""An explicit installation limit replaces the 5000 W default; the profile C-rate applies."""
|
||||
settings = BatteriesCommonSettings(
|
||||
device_id="battery1",
|
||||
profile_adjustment=BatteryProfileAdjustment(
|
||||
nominal_capacity_wh=8_000, max_charge_power_w=10_000, max_discharge_power_w=10_000
|
||||
),
|
||||
)
|
||||
parameters = getattr(settings, converter)()
|
||||
assert parameters.max_charge_power_w == pytest.approx(8_000.0)
|
||||
assert parameters.max_discharge_power_w == pytest.approx(4_000.0)
|
||||
|
||||
|
||||
class TestPowerCurves:
|
||||
"""SoC dependent power limits and power dependent efficiencies."""
|
||||
|
||||
@staticmethod
|
||||
def _battery(soc_percentage=50, slot_duration_h=1.0, **curves):
|
||||
params = SolarPanelBatteryParameters(
|
||||
device_id="battery1",
|
||||
capacity_wh=10_000,
|
||||
initial_soc_percentage=soc_percentage,
|
||||
min_soc_percentage=0,
|
||||
max_soc_percentage=100,
|
||||
max_charge_power_w=10_000,
|
||||
max_discharge_power_w=10_000,
|
||||
charging_efficiency=1.0,
|
||||
discharging_efficiency=1.0,
|
||||
**curves,
|
||||
)
|
||||
battery = Battery(params, prediction_hours=4, slot_duration_h=slot_duration_h)
|
||||
battery.set_charge_per_hour(np.ones(4))
|
||||
battery.set_discharge_per_hour(np.ones(4))
|
||||
return battery
|
||||
|
||||
def test_plain_battery_has_no_power_curves(self):
|
||||
battery = self._battery()
|
||||
assert not battery.has_power_curves
|
||||
|
||||
@pytest.mark.parametrize("with_curves", [False, True])
|
||||
def test_instances_stay_below_the_attribute_threshold(self, with_curves):
|
||||
"""CPython reads attributes of instances with 30 or more of them slower.
|
||||
|
||||
The simulation reads battery attributes for every slot of every
|
||||
candidate plan, so a battery must stay below that threshold.
|
||||
"""
|
||||
curves = (
|
||||
dict(
|
||||
charge_power_limit_w_by_soc=[1_000.0] * 101,
|
||||
discharge_power_limit_w_by_soc=[1_000.0] * 101,
|
||||
charging_efficiency_curve=[(0.0, 0.9), (1.0, 0.9)],
|
||||
discharging_efficiency_curve=[(0.0, 0.9), (1.0, 0.9)],
|
||||
)
|
||||
if with_curves
|
||||
else {}
|
||||
)
|
||||
battery = self._battery(**curves)
|
||||
battery.reset()
|
||||
battery._charge_rates_descending()
|
||||
battery.charge_energy(None, 0, charge_factor=0.5)
|
||||
battery.discharge_energy(100.0, 1)
|
||||
assert len(vars(battery)) < 30
|
||||
|
||||
@pytest.mark.parametrize("soc, expected_wh", [(10, 3_000.0), (30, 1_000.0)])
|
||||
def test_charge_limit_at_current_soc(self, soc, expected_wh):
|
||||
table = [3_000.0] * 21 + [1_000.0] * 80
|
||||
battery = self._battery(soc_percentage=soc, charge_power_limit_w_by_soc=table)
|
||||
assert battery.has_power_curves
|
||||
stored, _ = battery.charge_energy(None, 0, charge_factor=0.5)
|
||||
assert stored == pytest.approx(expected_wh)
|
||||
# The limit caps the energy; it does not lower the charge factor of the plan.
|
||||
assert battery.charge_array[0] == 1.0
|
||||
|
||||
def test_charge_limit_is_shared_within_a_slot(self):
|
||||
battery = self._battery(
|
||||
slot_duration_h=0.25, charge_power_limit_w_by_soc=[4_000.0] * 101
|
||||
)
|
||||
first, _ = battery.charge_energy(600.0, 0)
|
||||
second, _ = battery.charge_energy(600.0, 0)
|
||||
assert first == pytest.approx(600.0)
|
||||
assert second == pytest.approx(400.0)
|
||||
|
||||
def test_discharge_limit_at_current_soc(self):
|
||||
table = [500.0] * 51 + [3_000.0] * 50
|
||||
high = self._battery(soc_percentage=80, discharge_power_limit_w_by_soc=table)
|
||||
low = self._battery(soc_percentage=30, discharge_power_limit_w_by_soc=table)
|
||||
assert high.discharge_energy(9_000.0, 0)[0] == pytest.approx(3_000.0)
|
||||
assert high.remaining_discharge_energy_wh(0) == pytest.approx(0.0)
|
||||
assert low.remaining_discharge_energy_wh(0) == pytest.approx(500.0)
|
||||
assert low.discharge_energy(9_000.0, 0)[0] == pytest.approx(500.0)
|
||||
|
||||
def test_charging_efficiency_at_slot_power(self):
|
||||
battery = self._battery(
|
||||
soc_percentage=0, charging_efficiency_curve=[(0.0, 1.0), (1.0, 0.5)]
|
||||
)
|
||||
stored, losses = battery.charge_energy(5_000.0, 0)
|
||||
# Half the rated power: efficiency 0.75.
|
||||
assert stored == pytest.approx(3_750.0)
|
||||
assert losses == pytest.approx(1_250.0)
|
||||
assert battery.charged_energy_wh(0) == pytest.approx(3_750.0)
|
||||
|
||||
def test_discharging_efficiency_at_slot_power(self):
|
||||
battery = self._battery(
|
||||
soc_percentage=100, discharging_efficiency_curve=[(0.0, 1.0), (1.0, 0.5)]
|
||||
)
|
||||
delivered, losses = battery.discharge_energy(5_000.0, 0)
|
||||
assert delivered == pytest.approx(5_000.0)
|
||||
assert losses == pytest.approx(5_000.0 / 0.75 - 5_000.0)
|
||||
assert battery.discharged_energy_wh(0) == pytest.approx(5_000.0)
|
||||
assert battery.soc_wh == pytest.approx(10_000.0 - 5_000.0 / 0.75)
|
||||
|
||||
def test_rated_discharge_energy_uses_efficiency_at_rated_power(self):
|
||||
battery = self._battery(discharging_efficiency_curve=[(0.0, 1.0), (1.0, 0.8)])
|
||||
assert battery.rated_discharge_energy_wh() == pytest.approx(8_000.0)
|
||||
|
||||
def test_reset_clears_slot_energies(self):
|
||||
battery = self._battery(charging_efficiency_curve=[(0.0, 0.9), (1.0, 0.9)])
|
||||
battery.charge_energy(1_000.0, 0)
|
||||
battery.discharge_energy(1_000.0, 1)
|
||||
battery.reset()
|
||||
assert battery.charged_energy_wh(0) == 0.0
|
||||
assert battery.discharged_energy_wh(1) == 0.0
|
||||
|
||||
def test_flat_curves_match_plain_battery(self):
|
||||
"""Constant tables and curves reproduce the plain battery exactly."""
|
||||
plain = self._battery(soc_percentage=30)
|
||||
flat = self._battery(
|
||||
soc_percentage=30,
|
||||
charge_power_limit_w_by_soc=[10_000.0] * 101,
|
||||
discharge_power_limit_w_by_soc=[10_000.0] * 101,
|
||||
charging_efficiency_curve=[(0.0, 1.0), (1.0, 1.0)],
|
||||
discharging_efficiency_curve=[(0.0, 1.0), (1.0, 1.0)],
|
||||
)
|
||||
for battery in (plain, flat):
|
||||
battery.charge_energy(2_500.0, 0)
|
||||
battery.charge_energy(None, 1, charge_factor=0.5)
|
||||
battery.discharge_energy(4_000.0, 2)
|
||||
assert flat.soc_wh == pytest.approx(plain.soc_wh)
|
||||
for hour in range(3):
|
||||
assert flat.charged_energy_wh(hour) == pytest.approx(plain.charged_energy_wh(hour))
|
||||
assert flat.discharged_energy_wh(hour) == pytest.approx(
|
||||
plain.discharged_energy_wh(hour)
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field, value",
|
||||
[
|
||||
("charge_power_limit_w_by_soc", [1_000.0] * 100),
|
||||
("discharge_power_limit_w_by_soc", [1_000.0] * 100 + [-1.0]),
|
||||
("charge_power_limit_w_by_soc", [1_000.0] * 100 + [float("nan")]),
|
||||
("charging_efficiency_curve", [(0.0, 0.9)]),
|
||||
("discharging_efficiency_curve", [(0.5, 0.9), (0.2, 0.95)]),
|
||||
("charging_efficiency_curve", [(0.0, 0.9), (1.0, 1.2)]),
|
||||
],
|
||||
)
|
||||
def test_invalid_curves_are_rejected(self, field, value):
|
||||
with pytest.raises(ValidationError):
|
||||
SolarPanelBatteryParameters(device_id="battery1", capacity_wh=10_000, **{field: value})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Periodic full charge of a stationary battery (e.g. LFP recalibration)."""
|
||||
|
||||
import dataclasses
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Optional
|
||||
@@ -13,7 +14,18 @@ from akkudoktoreos.config.config import ConfigEOS
|
||||
from akkudoktoreos.core.cache import CacheEnergyManagementStore
|
||||
from akkudoktoreos.core.coreabc import get_ems
|
||||
from akkudoktoreos.devices.genetic.battery import Battery, SolarPanelBatteryParameters
|
||||
from akkudoktoreos.devices.settings.batterysettings import BatteriesCommonSettings
|
||||
from akkudoktoreos.devices.profile.batteryprofile import BatteryProfileSettings
|
||||
from akkudoktoreos.devices.profile.batteryprofileruntime import BatteryProfile
|
||||
from akkudoktoreos.devices.profile.profilestore import get_profile_store
|
||||
from akkudoktoreos.devices.settings.batteryresolution import (
|
||||
BatteryFullCharge,
|
||||
BatteryOperatingPoint,
|
||||
resolve_full_charge,
|
||||
)
|
||||
from akkudoktoreos.devices.settings.batterysettings import (
|
||||
BatteriesCommonSettings,
|
||||
BatteryProfileAdjustment,
|
||||
)
|
||||
from akkudoktoreos.optimization.genetic import configrequest
|
||||
from akkudoktoreos.optimization.genetic.configrequest import ConfigOptimizationRequest
|
||||
from akkudoktoreos.optimization.genetic.fullcharge import (
|
||||
@@ -103,24 +115,41 @@ def test_fractional_interval():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_settings_defaults_disable_the_feature():
|
||||
def test_settings_defaults_disable_the_feature(config_eos):
|
||||
settings = BatteriesCommonSettings(device_id="battery1")
|
||||
assert settings.full_charge_interval_days is None
|
||||
assert settings.cell_chemistry is None
|
||||
assert settings.profile_adjustment.full_charge_interval_days is None
|
||||
assert settings.profile_adjustment.full_charge_detect_percentage is None
|
||||
assert settings.full_charge_soc_percentage == 100
|
||||
assert settings.full_charge_detect_percentage == 99
|
||||
assert settings.full_charge_allow_grid is False
|
||||
# The default profile sets no interval.
|
||||
assert settings.effective_full_charge_interval_days is None
|
||||
assert settings.effective_full_charge_detect_percentage == 99
|
||||
|
||||
|
||||
def test_profile_adjustment_sets_the_full_charge(config_eos):
|
||||
settings = BatteriesCommonSettings(
|
||||
device_id="battery1",
|
||||
profile_adjustment=BatteryProfileAdjustment(
|
||||
full_charge_interval_days=21, full_charge_detect_percentage=98
|
||||
),
|
||||
)
|
||||
assert settings.effective_full_charge_interval_days == 21
|
||||
assert settings.effective_full_charge_detect_percentage == 98
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"extra",
|
||||
[
|
||||
{"full_charge_interval_days": 0},
|
||||
{"full_charge_interval_days": -1},
|
||||
{"profile_adjustment": {"full_charge_interval_days": 0}},
|
||||
{"profile_adjustment": {"full_charge_interval_days": -1}},
|
||||
{"profile_adjustment": {"full_charge_detect_percentage": 0}},
|
||||
{"profile_adjustment": {"full_charge_detect_percentage": 101}},
|
||||
{"full_charge_soc_percentage": 0},
|
||||
{"full_charge_soc_percentage": 101},
|
||||
{"full_charge_soc_percentage": 95, "full_charge_detect_percentage": 99},
|
||||
{"cell_chemistry": "lithium"},
|
||||
{
|
||||
"full_charge_soc_percentage": 95,
|
||||
"profile_adjustment": {"full_charge_detect_percentage": 99},
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_invalid_settings_are_rejected(extra):
|
||||
@@ -128,6 +157,53 @@ def test_invalid_settings_are_rejected(extra):
|
||||
BatteriesCommonSettings(device_id="battery1", **extra)
|
||||
|
||||
|
||||
def _profile(**changes: Any) -> BatteryProfile:
|
||||
"""The generic LFP profile with changed fields."""
|
||||
return dataclasses.replace(
|
||||
get_profile_store().get(BatteryProfileSettings, "generic-lfp"), **changes
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_full_charge_from_profile(config_eos):
|
||||
settings = BatteriesCommonSettings(device_id="battery1")
|
||||
profile = _profile(full_charge_interval_days=14.0, full_charge_detect_percentage=97)
|
||||
assert resolve_full_charge(settings, profile) == BatteryFullCharge(
|
||||
interval_days=14.0, soc_percentage=100, detect_percentage=97
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_full_charge_adjustment_wins_over_profile(config_eos):
|
||||
settings = BatteriesCommonSettings(
|
||||
device_id="battery1",
|
||||
profile_adjustment=BatteryProfileAdjustment(
|
||||
full_charge_interval_days=30, full_charge_detect_percentage=98
|
||||
),
|
||||
)
|
||||
profile = _profile(full_charge_interval_days=14.0, full_charge_detect_percentage=97)
|
||||
assert resolve_full_charge(settings, profile) == BatteryFullCharge(
|
||||
interval_days=30, soc_percentage=100, detect_percentage=98
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_full_charge_without_profile_uses_adjustment_and_defaults():
|
||||
settings = BatteriesCommonSettings(
|
||||
device_id="battery1",
|
||||
profile_adjustment=BatteryProfileAdjustment(full_charge_interval_days=21),
|
||||
)
|
||||
assert resolve_full_charge(settings, None) == BatteryFullCharge(
|
||||
interval_days=21, soc_percentage=100, detect_percentage=99
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_full_charge_caps_target_and_threshold(config_eos):
|
||||
"""The target stays within the technical bounds, the threshold below the target."""
|
||||
settings = BatteriesCommonSettings(device_id="battery1")
|
||||
profile = _profile(full_charge_interval_days=21.0, technical_max_soc_percent=95.5)
|
||||
assert resolve_full_charge(settings, profile) == BatteryFullCharge(
|
||||
interval_days=21.0, soc_percentage=95, detect_percentage=95
|
||||
)
|
||||
|
||||
|
||||
def test_parameters_reject_target_above_max_soc():
|
||||
with pytest.raises(ValidationError):
|
||||
SolarPanelBatteryParameters(
|
||||
@@ -181,10 +257,12 @@ def full_charge_request(config_eos, monkeypatch):
|
||||
"max_home_appliances": 0,
|
||||
"batteries": {
|
||||
"storage": {
|
||||
"capacity_wh": 10000,
|
||||
"max_charge_power_w": 5000,
|
||||
"max_soc_percentage": 90,
|
||||
"full_charge_interval_days": 21,
|
||||
"profile_adjustment": {
|
||||
"nominal_capacity_wh": 10000,
|
||||
"max_charge_power_w": 5000,
|
||||
"full_charge_interval_days": 21,
|
||||
},
|
||||
}
|
||||
},
|
||||
"electric_vehicles": {},
|
||||
@@ -212,6 +290,25 @@ def full_charge_request(config_eos, monkeypatch):
|
||||
return config_eos, start, measurement, data
|
||||
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cold_battery_does_not_cancel_the_request(full_charge_request, monkeypatch):
|
||||
"""A battery the profile may not charge at its temperature still gets valid parameters."""
|
||||
config, _, _, data = full_charge_request
|
||||
config.devices.batteries["storage"].profile_adjustment.full_charge_interval_days = None
|
||||
|
||||
async def cold(self, target_datetime=None):
|
||||
return BatteryOperatingPoint(temperature_degC=-5.0)
|
||||
|
||||
monkeypatch.setattr(BatteriesCommonSettings, "measure_operating_point", cold)
|
||||
|
||||
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
|
||||
assert parameters.pv_battery is not None
|
||||
assert parameters.pv_battery.max_charge_power_w == 0.0
|
||||
assert parameters.pv_battery.max_discharge_power_w is not None
|
||||
assert parameters.pv_battery.max_discharge_power_w > 0.0
|
||||
|
||||
def _history(start: DateTime, full_days_ago: Optional[float]) -> tuple[list[DateTime], list[float]]:
|
||||
"""Hourly SoC history over 30 days, full once ``full_days_ago`` (if given)."""
|
||||
dates = [start.subtract(hours=hour) for hour in range(30 * 24, 0, -1)]
|
||||
@@ -225,7 +322,7 @@ def _history(start: DateTime, full_days_ago: Optional[float]) -> tuple[list[Date
|
||||
@pytest.mark.asyncio
|
||||
async def test_feature_disabled_reads_no_history(full_charge_request):
|
||||
config, _, measurement, data = full_charge_request
|
||||
config.devices.batteries["storage"].full_charge_interval_days = None
|
||||
config.devices.batteries["storage"].profile_adjustment.full_charge_interval_days = None
|
||||
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
assert parameters.pv_battery is not None
|
||||
assert parameters.pv_battery.max_soc_percentage == 90
|
||||
@@ -303,6 +400,35 @@ async def test_missing_history_does_not_schedule_a_full_charge(full_charge_reque
|
||||
assert parameters.pv_battery.full_charge_soc_percentage is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_profile_interval_schedules_the_full_charge(full_charge_request, monkeypatch):
|
||||
"""The interval of the profile applies without a profile adjustment."""
|
||||
config, start, measurement, data = full_charge_request
|
||||
device = config.devices.batteries["storage"]
|
||||
device.profile_adjustment.full_charge_interval_days = None
|
||||
device.full_charge_allow_grid = True
|
||||
profile = _profile(full_charge_interval_days=21.0)
|
||||
monkeypatch.setattr(BatteriesCommonSettings, "get_battery_profile", lambda self: profile)
|
||||
measurement.key_to_lists.return_value = _history(start, full_days_ago=None)
|
||||
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
assert parameters.pv_battery is not None
|
||||
assert parameters.pv_battery.max_soc_percentage == 100
|
||||
assert parameters.pv_battery.full_charge_soc_percentage == 100
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_charge_target_stays_within_technical_bounds(full_charge_request, monkeypatch):
|
||||
config, start, measurement, data = full_charge_request
|
||||
config.devices.batteries["storage"].full_charge_allow_grid = True
|
||||
profile = _profile(technical_max_soc_percent=95.0)
|
||||
monkeypatch.setattr(BatteriesCommonSettings, "get_battery_profile", lambda self: profile)
|
||||
measurement.key_to_lists.return_value = _history(start, full_days_ago=None)
|
||||
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
assert parameters.pv_battery is not None
|
||||
assert parameters.pv_battery.max_soc_percentage == 95
|
||||
assert parameters.pv_battery.full_charge_soc_percentage == 95
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Optimizer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -227,9 +227,7 @@ def test_ev_deadlines_roundtrip_without_changing_slot_physics() -> None:
|
||||
settings = BatteriesCommonSettings.model_validate(
|
||||
{
|
||||
"device_id": "car",
|
||||
"capacity_wh": 10000,
|
||||
"charging_efficiency": 0.8,
|
||||
"max_charge_power_w": 4000,
|
||||
"profile_adjustment": {"nominal_capacity_wh": 10000, "max_charge_power_w": 4000},
|
||||
"min_soc_deadline_datetime": "2026-09-16T07:30:00+02:00",
|
||||
"min_soc_max_duration_h": 2.5,
|
||||
}
|
||||
@@ -238,21 +236,27 @@ def test_ev_deadlines_roundtrip_without_changing_slot_physics() -> None:
|
||||
assert params.min_soc_max_duration_h == 2.5
|
||||
assert params.min_soc_deadline_datetime is not None
|
||||
assert params.min_soc_deadline_datetime.in_timezone("Europe/Berlin").hour == 7
|
||||
assert params.capacity_wh == 10000
|
||||
assert params.max_charge_power_w == pytest.approx(4000)
|
||||
battery = Battery(params, prediction_hours=16, slot_duration_h=0.25)
|
||||
battery.charge_array[0] = 1
|
||||
# 4000 W for a 0.25 h slot caps the 2000 Wh request at 1000 Wh; the charging
|
||||
# efficiency comes from the battery profile.
|
||||
charged, losses = battery.charge_energy(2000, hour=0)
|
||||
assert charged == pytest.approx(800)
|
||||
assert losses == pytest.approx(200)
|
||||
assert charged == pytest.approx(1000 * params.charging_efficiency)
|
||||
assert losses == pytest.approx(1000 * (1 - params.charging_efficiency))
|
||||
assert "min_soc_deadline_datetime" not in settings.to_genetic0_ev_bat_param().model_dump()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("duration", [0.0, -1.0, float("inf"), float("nan")])
|
||||
def test_ev_invalid_departure_durations_rejected(duration: float) -> None:
|
||||
for model in (ElectricVehicleParameters, BatteriesCommonSettings):
|
||||
# Optimizer parameters take "capacity_wh", settings a profile adjustment.
|
||||
for model, capacity in (
|
||||
(ElectricVehicleParameters, {"capacity_wh": 10000}),
|
||||
(BatteriesCommonSettings, {"profile_adjustment": {"nominal_capacity_wh": 10000}}),
|
||||
):
|
||||
with pytest.raises(ValidationError):
|
||||
model.model_validate(
|
||||
{"device_id": "car", "capacity_wh": 10000, "min_soc_max_duration_h": duration}
|
||||
)
|
||||
model.model_validate({"device_id": "car", **capacity, "min_soc_max_duration_h": duration})
|
||||
|
||||
|
||||
def test_completed_cycles_parameter_initializes_remaining_global_ids() -> None:
|
||||
|
||||
@@ -17,7 +17,7 @@ def test_battery_discharge_allowed_remains_local_load_mode(config_eos):
|
||||
discharge_allowed=True,
|
||||
)
|
||||
|
||||
assert operation_mode == BatteryOperationMode.PEAK_SHAVING
|
||||
assert operation_mode == BatteryOperationMode.SELF_CONSUMPTION
|
||||
assert operation_mode_factor == 1.0
|
||||
|
||||
|
||||
|
||||
@@ -61,8 +61,8 @@ def configured_request(config_eos, monkeypatch):
|
||||
"max_home_appliances": 2,
|
||||
"batteries": {
|
||||
"storage": {
|
||||
"capacity_wh": 2000,
|
||||
"max_charge_power_w": 1000,
|
||||
"profile_adjustment": {"nominal_capacity_wh": 2000,
|
||||
"max_charge_power_w": 1000},
|
||||
"levelized_cost_of_storage_amt_kwh": 0.01,
|
||||
}
|
||||
},
|
||||
@@ -534,7 +534,7 @@ async def test_missing_ev_soc_does_not_replace_explicit_battery_soc(configured_r
|
||||
config, _, _, data = configured_request
|
||||
config.merge_settings_from_dict(
|
||||
{"devices": {"max_electric_vehicles": 1,
|
||||
"electric_vehicles": {"car": {"capacity_wh": 60000}}}}
|
||||
"electric_vehicles": {"car": {"profile_adjustment": {"nominal_capacity_wh": 60000}}}}}
|
||||
)
|
||||
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
assert parameters.pv_battery is not None
|
||||
@@ -551,8 +551,8 @@ async def test_http_config_and_real_optimizer_allow_full_ev_target(configured_re
|
||||
|
||||
config, fake, _, data = configured_request
|
||||
config.merge_settings_from_dict({"devices": {"max_electric_vehicles": 1,
|
||||
"electric_vehicles": {"ev": {"capacity_wh": 1000, "max_charge_power_w": 1000,
|
||||
"charging_efficiency": 1, "min_soc_percentage": 0,
|
||||
"electric_vehicles": {"ev": {"profile_adjustment": {"nominal_capacity_wh": 1000,
|
||||
"max_charge_power_w": 1000}, "min_soc_percentage": 0,
|
||||
"max_soc_percentage": 100}}}})
|
||||
data["soc"]["ev"] = soc
|
||||
ems = EnergyManagement()
|
||||
@@ -589,7 +589,7 @@ async def test_explicit_ev_soc_has_no_measurement_age_limit(configured_request,
|
||||
config, ems, measurement, data = configured_request
|
||||
config.merge_settings_from_dict(
|
||||
{"devices": {"max_electric_vehicles": 1,
|
||||
"electric_vehicles": {"car": {"capacity_wh": 60000,
|
||||
"electric_vehicles": {"car": {"profile_adjustment": {"nominal_capacity_wh": 60000},
|
||||
"min_soc_percentage": 100,
|
||||
"max_soc_percentage": 100}}}}
|
||||
)
|
||||
@@ -730,3 +730,31 @@ async def test_missing_newest_soc_value_falls_back_to_older_fresh_sample(
|
||||
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
assert parameters.pv_battery is not None
|
||||
assert parameters.pv_battery.initial_soc_percentage == 42
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_battery_configuration_error_uses_defaults_and_logs_error(configured_request):
|
||||
"""A battery that cannot be resolved does not cancel the parameter preparation."""
|
||||
from loguru import logger
|
||||
|
||||
config, _, _, data = configured_request
|
||||
config.merge_settings_from_dict(
|
||||
{"devices": {"batteries": {"storage": {"battery_profile": "does-not-exist"}}}}
|
||||
)
|
||||
messages: list[str] = []
|
||||
sink_id = logger.add(lambda message: messages.append(message.record["message"]), level="ERROR")
|
||||
try:
|
||||
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
finally:
|
||||
logger.remove(sink_id)
|
||||
assert parameters.pv_battery is not None
|
||||
# The packaged generic profile applies with the adjustment.
|
||||
assert parameters.pv_battery.capacity_wh == 2000
|
||||
assert parameters.pv_battery.max_charge_power_w == pytest.approx(1000.0)
|
||||
assert parameters.pv_battery.charge_power_limit_w_by_soc is not None
|
||||
assert any(
|
||||
"Battery 'storage'" in m
|
||||
and "does-not-exist" in m
|
||||
and "Using defaults for the optimization: battery profile 'generic-lfp'" in m
|
||||
for m in messages
|
||||
)
|
||||
|
||||
@@ -498,3 +498,45 @@ class TestInverterSettings:
|
||||
getattr(settings, converter)()
|
||||
settings.max_power_w = 4200
|
||||
assert getattr(settings, converter)().max_power_wh == 4200
|
||||
|
||||
|
||||
def test_battery_discharge_limit_caps_load_and_grid_export():
|
||||
"""The inverter's inlined discharge uses the battery's discharge power limit."""
|
||||
battery = Battery(
|
||||
SolarPanelBatteryParameters(
|
||||
device_id="battery",
|
||||
capacity_wh=10000,
|
||||
charging_efficiency=1.0,
|
||||
discharging_efficiency=1.0,
|
||||
max_charge_power_w=7000,
|
||||
max_discharge_power_w=4000,
|
||||
initial_soc_percentage=100,
|
||||
),
|
||||
prediction_hours=1,
|
||||
slot_duration_h=0.25,
|
||||
)
|
||||
battery.set_discharge_per_hour(np.array([1]))
|
||||
quarter_hour_inverter = Inverter(
|
||||
InverterParameters(
|
||||
device_id="inverter",
|
||||
max_power_wh=10000,
|
||||
battery_id="battery",
|
||||
dc_to_ac_efficiency=1.0,
|
||||
ac_to_dc_efficiency=1.0,
|
||||
),
|
||||
battery=battery,
|
||||
slot_duration_h=0.25,
|
||||
)
|
||||
initial_soc_wh = battery.soc_wh
|
||||
|
||||
grid_export, grid_import, _, _ = quarter_hour_inverter.process_energy(
|
||||
generation=0.0,
|
||||
consumption=1500.0,
|
||||
hour=0,
|
||||
allow_battery_grid_export=True,
|
||||
)
|
||||
|
||||
# 4000 W for a quarter hour: 1000 Wh, all of it for the load.
|
||||
assert initial_soc_wh - battery.soc_wh == pytest.approx(1000.0)
|
||||
assert grid_import == pytest.approx(500.0)
|
||||
assert grid_export == 0.0
|
||||
|
||||
@@ -431,3 +431,118 @@ def test_simulation(genetic_simulation):
|
||||
), "The sum of 'Home_appliance_wh_per_hour' should be 2000."
|
||||
|
||||
print("All tests passed successfully.")
|
||||
|
||||
|
||||
def _profile_simulation(
|
||||
battery_curves: dict, initial_soc_percentage: int = 50
|
||||
) -> tuple[GeneticSimulation, Battery]:
|
||||
"""48 slots with a battery that has the given profile curves, if any."""
|
||||
hours = 48
|
||||
battery = Battery(
|
||||
SolarPanelBatteryParameters(
|
||||
device_id="battery1",
|
||||
capacity_wh=8000,
|
||||
initial_soc_percentage=initial_soc_percentage,
|
||||
min_soc_percentage=10,
|
||||
max_charge_power_w=6000,
|
||||
max_discharge_power_w=4000,
|
||||
charging_efficiency=0.95,
|
||||
discharging_efficiency=0.9,
|
||||
levelized_cost_of_storage_kwh=0.05,
|
||||
**battery_curves,
|
||||
),
|
||||
prediction_hours=hours,
|
||||
)
|
||||
inverter = Inverter(
|
||||
InverterParameters(device_id="inverter1", max_power_wh=10000, battery_id="battery1"),
|
||||
battery=battery,
|
||||
)
|
||||
rng = np.random.default_rng(7)
|
||||
simulation = GeneticSimulation()
|
||||
simulation.prepare(
|
||||
GeneticEnergyManagementParameters.model_validate(
|
||||
dict(
|
||||
pv_prognose_wh=(
|
||||
np.clip(np.sin(np.linspace(0, 4 * np.pi, hours)), 0, None) * 6000
|
||||
).tolist(),
|
||||
strompreis_euro_pro_wh=rng.uniform(0.0002, 0.0004, hours).tolist(),
|
||||
einspeiseverguetung_euro_pro_wh=0.00007,
|
||||
preis_euro_pro_wh_akku=0.0001,
|
||||
gesamtlast=rng.uniform(300, 2500, hours).tolist(),
|
||||
)
|
||||
),
|
||||
optimization_hours=24,
|
||||
prediction_hours=hours,
|
||||
inverter=inverter,
|
||||
)
|
||||
return simulation, battery
|
||||
|
||||
|
||||
def _run_plans(simulation: GeneticSimulation, battery: Battery) -> list[dict]:
|
||||
rng = np.random.default_rng(11)
|
||||
hours = battery.prediction_hours
|
||||
results = []
|
||||
for _ in range(5):
|
||||
ac = rng.choice([0.0, 0.5, 1.0], hours)
|
||||
dc = rng.choice([0.0, 1.0], hours)
|
||||
discharge = rng.choice([0, 1], hours)
|
||||
battery.reset()
|
||||
simulation.ac_charge_hours = ac
|
||||
simulation.dc_charge_hours = dc
|
||||
simulation.bat_discharge_hours = discharge
|
||||
battery.set_charge_per_hour(dc)
|
||||
battery.set_discharge_per_hour(discharge)
|
||||
results.append(simulation.simulate(start_hour=0))
|
||||
return results
|
||||
|
||||
|
||||
def test_flat_profile_curves_reproduce_plain_simulation(config_eos):
|
||||
"""Constant profile tables and curves give exactly the plain battery result.
|
||||
|
||||
The battery with curves takes the generic discharge path of the inverter
|
||||
and its own energy bookkeeping (LCOS); both must match the inlined path.
|
||||
"""
|
||||
config_eos.merge_settings_from_dict(
|
||||
{"prediction": {"hours": 48}, "optimization": {"hours": 24}}
|
||||
)
|
||||
plain = _run_plans(*_profile_simulation({}))
|
||||
flat = _run_plans(
|
||||
*_profile_simulation(
|
||||
dict(
|
||||
charge_power_limit_w_by_soc=[6000.0] * 101,
|
||||
discharge_power_limit_w_by_soc=[4000.0] * 101,
|
||||
charging_efficiency_curve=[(0.0, 0.95), (1.0, 0.95)],
|
||||
discharging_efficiency_curve=[(0.0, 0.9), (1.0, 0.9)],
|
||||
)
|
||||
)
|
||||
)
|
||||
for plain_result, flat_result in zip(plain, flat):
|
||||
for key in ("akku_soc_pro_stunde", "Netzbezug_Wh_pro_Stunde", "Kosten_Euro_pro_Stunde"):
|
||||
assert flat_result[key] == pytest.approx(plain_result[key]), key
|
||||
assert flat_result["Gesamt_Verluste"] == pytest.approx(plain_result["Gesamt_Verluste"])
|
||||
assert flat_result["Gesamtbilanz_Euro"] == pytest.approx(plain_result["Gesamtbilanz_Euro"])
|
||||
|
||||
|
||||
def test_soc_taper_slows_charging_near_full(config_eos):
|
||||
"""Above 80 % SoC the tapered charge limit applies, below it does not."""
|
||||
config_eos.merge_settings_from_dict(
|
||||
{"prediction": {"hours": 48}, "optimization": {"hours": 24}}
|
||||
)
|
||||
taper = [6000.0] * 81 + [500.0] * 20
|
||||
results = {}
|
||||
for name, table in (("flat", [6000.0] * 101), ("taper", taper)):
|
||||
simulation, battery = _profile_simulation(
|
||||
dict(charge_power_limit_w_by_soc=table), initial_soc_percentage=82
|
||||
)
|
||||
hours = battery.prediction_hours
|
||||
battery.reset()
|
||||
simulation.ac_charge_hours = np.ones(hours)
|
||||
simulation.dc_charge_hours = np.ones(hours)
|
||||
simulation.bat_discharge_hours = np.zeros(hours)
|
||||
battery.set_charge_per_hour(np.ones(hours))
|
||||
battery.set_discharge_per_hour(np.zeros(hours))
|
||||
results[name] = simulation.simulate(start_hour=0)["akku_soc_pro_stunde"]
|
||||
|
||||
# The tapered battery takes at most 500 W x 1 h (before efficiency) per slot.
|
||||
assert results["taper"][1] < results["flat"][1]
|
||||
assert results["taper"][1] - results["taper"][0] <= 500.0 * 0.95 / 8000 * 100 + 1e-9
|
||||
|
||||
@@ -458,7 +458,7 @@ class TestHomeAssistantConfigEntityIds:
|
||||
"batteries": {
|
||||
"battery1": {
|
||||
"device_id": "battery1",
|
||||
"capacity_wh": 8000,
|
||||
"profile_adjustment": {"nominal_capacity_wh": 8000},
|
||||
"min_soc_percentage": 10,
|
||||
}
|
||||
}
|
||||
@@ -476,10 +476,10 @@ class TestHomeAssistantConfigEntityIds:
|
||||
async def test_sets_value_by_device_id(self, config) -> None:
|
||||
await self._sync(
|
||||
config,
|
||||
{"devices/batteries/battery1/capacity_wh": "sensor.capacity"},
|
||||
{"devices/batteries/battery1/profile_adjustment/nominal_capacity_wh": "sensor.capacity"},
|
||||
{"sensor.capacity": "12000"},
|
||||
)
|
||||
assert config.devices.batteries["battery1"].capacity_wh == 12000
|
||||
assert config.devices.batteries["battery1"].profile_adjustment.nominal_capacity_wh == 12000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_applies_zero(self, config) -> None:
|
||||
@@ -496,17 +496,17 @@ class TestHomeAssistantConfigEntityIds:
|
||||
async def test_keeps_value_of_unavailable_entity(self, config, state) -> None:
|
||||
await self._sync(
|
||||
config,
|
||||
{"devices/batteries/battery1/capacity_wh": "sensor.capacity"},
|
||||
{"devices/batteries/battery1/profile_adjustment/nominal_capacity_wh": "sensor.capacity"},
|
||||
{"sensor.capacity": state},
|
||||
)
|
||||
assert config.devices.batteries["battery1"].capacity_wh == 8000
|
||||
assert config.devices.batteries["battery1"].profile_adjustment.nominal_capacity_wh == 8000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"config_key",
|
||||
[
|
||||
"devices/batteries/0/capacity_wh",
|
||||
"devices/batteries/battery2/capacity_wh",
|
||||
"devices/batteries/0/profile_adjustment/nominal_capacity_wh",
|
||||
"devices/batteries/battery2/profile_adjustment/nominal_capacity_wh",
|
||||
"devices/batteries/battery1/no_such_setting",
|
||||
],
|
||||
)
|
||||
@@ -518,6 +518,6 @@ class TestHomeAssistantConfigEntityIds:
|
||||
await self._sync(config, {config_key: "sensor.capacity"}, {"sensor.capacity": "12000"})
|
||||
|
||||
assert list(config.devices.batteries) == ["battery1"]
|
||||
assert config.devices.batteries["battery1"].capacity_wh == 8000
|
||||
assert config.devices.batteries["battery1"].profile_adjustment.nominal_capacity_wh == 8000
|
||||
mock_logger.error.assert_called_once()
|
||||
assert config_key in mock_logger.error.call_args.args[0]
|
||||
|
||||
@@ -265,7 +265,7 @@ async def empty_measurement(config_eos):
|
||||
measurement = get_measurement()
|
||||
await measurement.delete_by_datetime(None, None)
|
||||
config_eos.merge_settings_from_dict(
|
||||
{"devices": {"max_batteries": 1, "batteries": {"storage": {"capacity_wh": 2000}}}}
|
||||
{"devices": {"max_batteries": 1, "batteries": {"storage": {"profile_adjustment": {"nominal_capacity_wh": 2000}}}}}
|
||||
)
|
||||
try:
|
||||
yield measurement
|
||||
|
||||
@@ -4,6 +4,7 @@ import pytest_asyncio
|
||||
|
||||
# ruff: noqa: S101
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
@@ -169,9 +170,7 @@ async def database_case(config_eos):
|
||||
"batteries": {
|
||||
"battery": {
|
||||
"device_id": "battery",
|
||||
"capacity_wh": 12000,
|
||||
"charging_efficiency": 1,
|
||||
"discharging_efficiency": 1,
|
||||
"profile_adjustment": {"nominal_capacity_wh": 12000},
|
||||
"capacity_estimation": {
|
||||
"power_key": "battery_dc",
|
||||
"positive_power": "charging",
|
||||
@@ -217,29 +216,33 @@ def test_http_reads_database_and_stores_only_explicit_estimate(database_case):
|
||||
"start_soc_percentage": 20,
|
||||
"soc_reference": "voltage_current_anchor",
|
||||
}
|
||||
# The charging efficiency comes from the battery profile; at efficiency 1 the
|
||||
# samples give 10000 Wh.
|
||||
battery = database_case.devices.batteries["battery"]
|
||||
expected_wh = 10000 * battery.effective_charging_efficiency
|
||||
response = client.post("/v1/measurement/battery-capacity/battery", json=body)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["estimated_capacity_wh"] == pytest.approx(10000)
|
||||
battery = database_case.devices.batteries["battery"]
|
||||
assert response.json()["estimated_capacity_wh"] == pytest.approx(expected_wh)
|
||||
assert battery.capacity_estimate is None
|
||||
assert battery.capacity_wh == 12000
|
||||
assert battery.effective_nominal_capacity_wh == 12000
|
||||
body["store_estimate"] = True
|
||||
response = client.post("/v1/measurement/battery-capacity/battery", json=body)
|
||||
assert response.status_code == 200, response.text
|
||||
battery = database_case.devices.batteries["battery"]
|
||||
assert battery.capacity_estimate is not None
|
||||
assert battery.capacity_estimate.estimated_capacity_wh == pytest.approx(10000)
|
||||
assert battery.capacity_wh == 12000
|
||||
assert battery.capacity_estimate.estimated_capacity_wh == pytest.approx(expected_wh)
|
||||
assert battery.effective_nominal_capacity_wh == 12000
|
||||
database_case.merge_settings_from_dict({"optimization": {"genetic": {"individuals": 100}}})
|
||||
battery = database_case.devices.batteries["battery"]
|
||||
assert battery.capacity_estimate is not None
|
||||
assert battery.capacity_estimate.estimated_capacity_wh == pytest.approx(10000)
|
||||
assert battery.capacity_wh == 12000
|
||||
assert battery.capacity_estimate.estimated_capacity_wh == pytest.approx(expected_wh)
|
||||
assert battery.effective_nominal_capacity_wh == 12000
|
||||
|
||||
# The estimate survives normal config serialization without becoming capacity_wh.
|
||||
# The estimate survives normal config serialization without replacing the nominal capacity.
|
||||
data = database_case.to_config_json()
|
||||
assert '"estimated_capacity_wh": 10000.0' in data
|
||||
assert '"capacity_wh": 12000' in data
|
||||
stored = json.loads(data)["devices"]["batteries"]["battery"]
|
||||
assert stored["capacity_estimate"]["estimated_capacity_wh"] == pytest.approx(expected_wh)
|
||||
assert stored["profile_adjustment"]["nominal_capacity_wh"] == 12000
|
||||
previous = battery.capacity_estimate
|
||||
body["start_soc_percentage"] = 100
|
||||
assert client.post("/v1/measurement/battery-capacity/battery", json=body).status_code == 422
|
||||
|
||||
@@ -75,8 +75,12 @@ def test_device_maps_feed_algorithm_specific_converters(algorithm):
|
||||
|
||||
settings = DevicesCommonSettings.model_validate(
|
||||
{
|
||||
"batteries": {"storage": {"capacity_wh": 12000, "max_charge_power_w": 3200}},
|
||||
"electric_vehicles": {"car": {"capacity_wh": 60000, "max_charge_power_w": 7000}},
|
||||
"batteries": {
|
||||
"storage": {"profile_adjustment": {"nominal_capacity_wh": 12000, "max_charge_power_w": 3200}}
|
||||
},
|
||||
"electric_vehicles": {
|
||||
"car": {"profile_adjustment": {"nominal_capacity_wh": 60000, "max_charge_power_w": 7000}}
|
||||
},
|
||||
"inverters": {"inverter": {"battery_id": "storage", "max_power_w": 5000}},
|
||||
"home_appliances": {
|
||||
"washer": {
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Tests for the REST API of the device profile store (/v1/profile)."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from akkudoktoreos.devices.profile.batteryprofile import BatteryProfileSettings
|
||||
from akkudoktoreos.devices.profile.profilestore import ProfileStore, get_profile_store
|
||||
from akkudoktoreos.server.rest.error import register_problem_handlers
|
||||
from akkudoktoreos.server.rest.profile import router
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(config_eos) -> Iterator[TestClient]:
|
||||
"""Client of an app with only the profile router, on a fresh store."""
|
||||
ProfileStore.reset_instance()
|
||||
app = FastAPI()
|
||||
register_problem_handlers(app)
|
||||
app.include_router(router)
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
ProfileStore.reset_instance()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def generic_lfp() -> dict:
|
||||
"""The document of the packaged generic LFP battery profile."""
|
||||
store = get_profile_store()
|
||||
path = store.package_dir(BatteryProfileSettings) / "generic-lfp.json"
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def user_path(name: str) -> Path:
|
||||
return get_profile_store().user_dir(BatteryProfileSettings) / f"{name}.json"
|
||||
|
||||
|
||||
class TestRead:
|
||||
def test_kinds(self, client):
|
||||
response = client.get("/v1/profile")
|
||||
assert response.status_code == 200
|
||||
assert "battery" in response.json()
|
||||
|
||||
def test_list_contains_the_packaged_profile(self, client):
|
||||
response = client.get("/v1/profile/battery")
|
||||
assert response.status_code == 200
|
||||
assert {"name": "generic-lfp", "source": "package", "packaged": True} in response.json()
|
||||
|
||||
def test_schema(self, client):
|
||||
response = client.get("/v1/profile/battery/schema")
|
||||
assert response.status_code == 200
|
||||
assert "charge_limit_crate" in response.json()["properties"]
|
||||
|
||||
def test_get_document(self, client):
|
||||
response = client.get("/v1/profile/battery/generic-lfp")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["chemistry"] == "LFP"
|
||||
BatteryProfileSettings.model_validate(response.json())
|
||||
|
||||
def test_errors_are_problem_details(self, client):
|
||||
response = client.get("/v1/profile/nope")
|
||||
assert response.headers["content-type"] == "application/problem+json"
|
||||
assert response.json() == {
|
||||
"type": "about:blank",
|
||||
"title": "Unknown profile kind",
|
||||
"status": 404,
|
||||
"detail": "Unknown profile kind 'nope'.",
|
||||
"instance": "/v1/profile/nope",
|
||||
}
|
||||
|
||||
def test_unknown_kind_is_404(self, client):
|
||||
assert client.get("/v1/profile/nope").status_code == 404
|
||||
assert client.get("/v1/profile/nope/x").status_code == 404
|
||||
assert client.put("/v1/profile/nope/x", json={}).status_code == 404
|
||||
|
||||
def test_missing_profile_is_404(self, client):
|
||||
response = client.get("/v1/profile/battery/does-not-exist")
|
||||
assert response.status_code == 404
|
||||
assert "does-not-exist" in response.json()["detail"]
|
||||
|
||||
|
||||
class TestWrite:
|
||||
def test_put_saves_a_user_profile_and_returns_it(self, client, generic_lfp):
|
||||
document = {**generic_lfp, "battery_model": "My battery", "nominal_capacity_wh": 10240.0}
|
||||
response = client.put("/v1/profile/battery/my-battery", json=document)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["nominal_capacity_wh"] == 10240.0
|
||||
assert user_path("my-battery").is_file()
|
||||
assert client.get("/v1/profile/battery/my-battery").json()["battery_model"] == "My battery"
|
||||
assert {"name": "my-battery", "source": "user", "packaged": False} in client.get(
|
||||
"/v1/profile/battery"
|
||||
).json()
|
||||
|
||||
def test_put_takes_effect_for_compiled_profile_users(self, client, generic_lfp):
|
||||
store = get_profile_store()
|
||||
before = store.get(BatteryProfileSettings, "generic-lfp")
|
||||
assert before.nominal_capacity_wh is None
|
||||
response = client.put(
|
||||
"/v1/profile/battery/generic-lfp", json={**generic_lfp, "nominal_capacity_wh": 5000.0}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
after = store.get(BatteryProfileSettings, "generic-lfp")
|
||||
assert after is not before
|
||||
assert after.nominal_capacity_wh == 5000.0
|
||||
|
||||
def test_put_of_an_invalid_document_is_422_and_saves_nothing(self, client, generic_lfp):
|
||||
response = client.put(
|
||||
"/v1/profile/battery/broken", json={**generic_lfp, "nominal_capacity_wh": -1}
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert "nominal_capacity_wh" in response.json()["detail"]
|
||||
assert not user_path("broken").exists()
|
||||
|
||||
def test_put_with_an_unsafe_name_is_422(self, client, generic_lfp):
|
||||
response = client.put("/v1/profile/battery/..%2F..%2Fescape", json=generic_lfp)
|
||||
assert response.status_code in (404, 422) # rejected by the router or the store
|
||||
data_dir = get_profile_store().user_dir(BatteryProfileSettings).parent.parent
|
||||
assert not (data_dir / "escape.json").exists()
|
||||
|
||||
def test_names_with_subdirectories_are_not_routed(self, client, generic_lfp):
|
||||
"""Profile names are flat; a path below the name matches no route."""
|
||||
assert client.put("/v1/profile/battery/vendor/model-a", json=generic_lfp).status_code == 404
|
||||
assert not user_path("vendor/model-a").exists()
|
||||
|
||||
@pytest.mark.parametrize("name", ["C:evil", ".hidden"])
|
||||
def test_invalid_names_are_rejected(self, client, generic_lfp, name):
|
||||
response = client.put(f"/v1/profile/battery/{name}", json=generic_lfp)
|
||||
assert response.status_code == 422
|
||||
assert "Invalid battery profile name" in response.json()["detail"]
|
||||
|
||||
def test_reserved_name_is_rejected(self, client, generic_lfp):
|
||||
"""PUT .../schema would save a profile that GET can never return."""
|
||||
response = client.put("/v1/profile/battery/schema", json=generic_lfp)
|
||||
assert response.status_code == 422
|
||||
assert "reserved" in response.json()["detail"]
|
||||
|
||||
|
||||
class TestDelete:
|
||||
def test_delete_of_a_directory_is_an_eos_problem(self, client):
|
||||
user_path("model-a").mkdir(parents=True)
|
||||
response = client.delete("/v1/profile/battery/model-a")
|
||||
assert response.status_code == 422
|
||||
assert "is not a file" in response.json()["detail"]
|
||||
|
||||
def test_delete_user_profile(self, client, generic_lfp):
|
||||
client.put("/v1/profile/battery/my-battery", json=generic_lfp)
|
||||
response = client.delete("/v1/profile/battery/my-battery")
|
||||
assert response.status_code == 200
|
||||
assert response.json() is None # no packaged profile of that name
|
||||
assert client.get("/v1/profile/battery/my-battery").status_code == 404
|
||||
|
||||
def test_delete_of_an_override_restores_the_packaged_profile(self, client, generic_lfp):
|
||||
client.put(
|
||||
"/v1/profile/battery/generic-lfp", json={**generic_lfp, "nominal_capacity_wh": 5000.0}
|
||||
)
|
||||
response = client.delete("/v1/profile/battery/generic-lfp")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"name": "generic-lfp", "source": "package", "packaged": True}
|
||||
assert client.get("/v1/profile/battery/generic-lfp").json()["nominal_capacity_wh"] is None
|
||||
|
||||
def test_delete_of_a_packaged_profile_is_409(self, client):
|
||||
response = client.delete("/v1/profile/battery/generic-lfp")
|
||||
assert response.status_code == 409
|
||||
assert client.get("/v1/profile/battery/generic-lfp").status_code == 200
|
||||
|
||||
def test_delete_of_a_missing_profile_is_404(self, client):
|
||||
assert client.delete("/v1/profile/battery/does-not-exist").status_code == 404
|
||||
|
||||
|
||||
def test_eos_app_serves_the_profile_api(config_eos):
|
||||
from akkudoktoreos.server.eos import app
|
||||
|
||||
paths = app.openapi()["paths"]
|
||||
assert "/v1/profile/{kind}/{name}" in paths
|
||||
assert set(paths["/v1/profile/{kind}/{name}"]) == {"get", "put", "delete"}
|
||||
+3
-1
@@ -15,7 +15,9 @@
|
||||
"batteries": {
|
||||
"pv_akku": {
|
||||
"device_id": "pv_akku",
|
||||
"capacity_wh": 30000
|
||||
"profile_adjustment": {
|
||||
"nominal_capacity_wh": 30000
|
||||
}
|
||||
}
|
||||
},
|
||||
"electric_vehicles": {
|
||||
|
||||
+3
-1
@@ -19,7 +19,9 @@
|
||||
"electric_vehicles": {
|
||||
"ev11": {
|
||||
"device_id": "ev11",
|
||||
"capacity_wh": 50000,
|
||||
"profile_adjustment": {
|
||||
"nominal_capacity_wh": 50000
|
||||
},
|
||||
"min_soc_percentage": 70
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user