Files
EOS/tests/test_serverrestprofile.py
T
Bobby NoelteandClaude Opus 5.5 e255f987bf 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)
2026-11-11 22:08:18 +01:00

179 lines
7.8 KiB
Python

"""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"}