mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-11-13 09:13:30 +01:00
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)
179 lines
7.8 KiB
Python
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"}
|