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:
Bobby Noelte
2026-11-11 22:08:18 +01:00
co-authored by Claude Opus 5.5
parent dd41e5d57c
commit e255f987bf
56 changed files with 11381 additions and 619 deletions
+105 -1
View File
@@ -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)
+243
View File
@@ -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
+472
View File
@@ -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
+33
View File
@@ -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
+59
View File
@@ -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
+112 -3
View File
@@ -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
)
+239 -4
View File
@@ -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})
+139 -13
View File
@@ -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
# ---------------------------------------------------------------------------
+13 -9
View File
@@ -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:
+1 -1
View File
@@ -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
+34 -6
View File
@@ -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
)
+42
View File
@@ -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
+115
View File
@@ -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
+8 -8
View File
@@ -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]
+1 -1
View File
@@ -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
+16 -13
View File
@@ -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
+6 -2
View File
@@ -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": {
+178
View File
@@ -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
View File
@@ -15,7 +15,9 @@
"batteries": {
"pv_akku": {
"device_id": "pv_akku",
"capacity_wh": 30000
"profile_adjustment": {
"nominal_capacity_wh": 30000
}
}
},
"electric_vehicles": {
+3 -1
View File
@@ -19,7 +19,9 @@
"electric_vehicles": {
"ev11": {
"device_id": "ev11",
"capacity_wh": 50000,
"profile_adjustment": {
"nominal_capacity_wh": 50000
},
"min_soc_percentage": 70
}
},