mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-16 11:01:56 +00:00
feat(measurement): integrate typed energy quality and capacity APIs
Port the locally backed-up measurement extensions to main async storage and PR #1256 device maps. Preserve runtime capacity estimates across #1305 bulk updates. Confirm JSON singleton restore defect on unchanged main and add regression. No production configuration or measurements included. Co-authored-by: Andreas <drbacke@gmx.de>
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
import pytest_asyncio
|
||||
"""Capacity fits must preserve energy direction, coverage and independent anchors."""
|
||||
|
||||
# ruff: noqa: S101
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from akkudoktoreos.measurement.batterycapacity import (
|
||||
BatteryCapacityEstimationSettings,
|
||||
BatteryCapacityRequest,
|
||||
estimate_capacity,
|
||||
)
|
||||
from akkudoktoreos.measurement.measurement import MeasurementChannelSettings
|
||||
from akkudoktoreos.measurement.quality import SampleQuality
|
||||
|
||||
START = datetime(2026, 9, 10, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def fit(
|
||||
points,
|
||||
*,
|
||||
start_soc=20,
|
||||
end_soc=100,
|
||||
efficiency=1,
|
||||
method="hold",
|
||||
polarity="charging",
|
||||
unit="W",
|
||||
end_seconds=3600,
|
||||
quality=None,
|
||||
max_gap=3600,
|
||||
):
|
||||
request = BatteryCapacityRequest(
|
||||
start=START,
|
||||
end=START + timedelta(seconds=end_seconds),
|
||||
start_soc_percentage=start_soc,
|
||||
end_soc_percentage=end_soc,
|
||||
soc_reference="voltage_current_anchor",
|
||||
)
|
||||
settings = BatteryCapacityEstimationSettings(power_key="dc", positive_power=polarity)
|
||||
channel = MeasurementChannelSettings(
|
||||
quantity="power",
|
||||
unit=unit,
|
||||
integration_method=method,
|
||||
max_gap_seconds=max_gap,
|
||||
)
|
||||
return estimate_capacity(
|
||||
request,
|
||||
settings,
|
||||
channel,
|
||||
[
|
||||
(START + timedelta(seconds=t), v, (quality or {}).get(t, SampleQuality()))
|
||||
for t, v in points
|
||||
],
|
||||
battery_id="battery",
|
||||
capacity_wh=12000,
|
||||
charging_efficiency=efficiency,
|
||||
discharging_efficiency=efficiency,
|
||||
)
|
||||
|
||||
|
||||
def test_charge_fit_and_unclipped_model_error():
|
||||
result = fit([(0, 8000), (3600, 8000)])
|
||||
assert result.estimated_capacity_wh == pytest.approx(10000)
|
||||
assert result.configured_capacity_wh == 12000
|
||||
assert result.model_end_soc_percentage_unclipped == pytest.approx(86.6666667)
|
||||
assert result.model_soc_error_percentage_points == pytest.approx(-13.3333333)
|
||||
assert result.charge_energy_wh == 8000
|
||||
assert result.coverage_seconds == 3600
|
||||
|
||||
|
||||
def test_efficiency_is_applied_once_on_dc_boundary():
|
||||
result = fit([(0, 10000), (3600, 10000)], efficiency=0.8)
|
||||
assert result.estimated_capacity_wh == pytest.approx(10000)
|
||||
assert result.stored_energy_change_wh == 8000
|
||||
|
||||
|
||||
def test_discharge_fit_and_reversed_sensor_sign():
|
||||
result = fit(
|
||||
[(0, 6400), (3600, 6400)], start_soc=100, end_soc=20, polarity="discharging", efficiency=0.8
|
||||
)
|
||||
assert result.discharge_energy_wh == 6400
|
||||
assert result.estimated_capacity_wh == pytest.approx(10000)
|
||||
|
||||
|
||||
def test_linear_zero_crossing_is_split_before_losses():
|
||||
result = fit([(0, -4000), (3600, 12000)], method="linear", efficiency=0.8)
|
||||
assert result.charge_energy_wh == pytest.approx(4500)
|
||||
assert result.discharge_energy_wh == pytest.approx(500)
|
||||
assert result.stored_energy_change_wh == pytest.approx(4500 * 0.8 - 500 / 0.8)
|
||||
|
||||
|
||||
def test_kw_and_clipped_boundary_interpolation():
|
||||
result = fit([(-3600, 0), (3600, 16)], method="linear", unit="kW", max_gap=7200)
|
||||
assert result.charge_energy_wh == pytest.approx(12000)
|
||||
# Deliberately do not saturate the old model at 100%.
|
||||
assert result.model_end_soc_percentage_unclipped == pytest.approx(120)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"points", [[], [(0, 8000)], [(60, 8000), (3600, 8000)], [(0, 8000), (3500, 8000)]]
|
||||
)
|
||||
def test_missing_coverage_is_not_extrapolated(points):
|
||||
with pytest.raises(ValueError, match="coverage"):
|
||||
fit(points)
|
||||
|
||||
|
||||
def test_gap_is_rejected():
|
||||
with pytest.raises(ValueError, match="gap"):
|
||||
fit([(0, 8000), (3600, 8000)], max_gap=300)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [None, float("nan"), float("inf"), True])
|
||||
def test_bad_power_is_rejected(value):
|
||||
with pytest.raises(ValueError, match="finite measured"):
|
||||
fit([(0, value), (3600, 8000)])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["estimated", "invalid", "unavailable"])
|
||||
def test_nonmeasured_quality_is_rejected(status):
|
||||
with pytest.raises(ValueError, match="finite measured"):
|
||||
fit([(0, 8000), (3600, 8000)], quality={0: SampleQuality(status=status)})
|
||||
|
||||
|
||||
def test_reset_and_sensor_change_are_rejected():
|
||||
for quality in (SampleQuality(reset=True), SampleQuality(generation="replacement")):
|
||||
with pytest.raises(ValueError, match="reset or generation"):
|
||||
fit([(0, 8000), (3600, 8000)], quality={3600: quality})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("start_soc", [100, 99, 85])
|
||||
def test_full_to_full_and_small_soc_span_do_not_produce_estimates(start_soc):
|
||||
with pytest.raises(ValueError, match="SoC change is too small"):
|
||||
fit([(0, 8000), (3600, 8000)], start_soc=start_soc)
|
||||
|
||||
|
||||
def test_wrong_polarity_is_rejected():
|
||||
with pytest.raises(ValueError, match="disagrees"):
|
||||
fit([(0, -8000), (3600, -8000)])
|
||||
|
||||
|
||||
def test_hidden_saturation_cannot_be_fixed_by_end_point_fitting():
|
||||
with pytest.raises(ValueError, match="Fitted SoC leaves"):
|
||||
fit([(0, 24000), (1800, -8000), (3600, -8000)])
|
||||
|
||||
|
||||
def test_model_soc_is_not_an_accepted_reference():
|
||||
with pytest.raises(ValidationError):
|
||||
BatteryCapacityRequest(
|
||||
start=START,
|
||||
end=START + timedelta(hours=1),
|
||||
start_soc_percentage=20,
|
||||
soc_reference="calculated_soc",
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def database_case(config_eos):
|
||||
from akkudoktoreos.core.coreabc import get_measurement
|
||||
from akkudoktoreos.measurement.quality import MeasurementSample
|
||||
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"devices": {
|
||||
"batteries": {
|
||||
"battery": {
|
||||
"device_id": "battery",
|
||||
"capacity_wh": 12000,
|
||||
"charging_efficiency": 1,
|
||||
"discharging_efficiency": 1,
|
||||
"capacity_estimation": {
|
||||
"power_key": "battery_dc",
|
||||
"positive_power": "charging",
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
"measurement": {
|
||||
"channels": {
|
||||
"battery_dc": {
|
||||
"quantity": "power",
|
||||
"unit": "W",
|
||||
"integration_method": "hold",
|
||||
"max_gap_seconds": 3600,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
(await get_measurement().import_samples(
|
||||
[
|
||||
MeasurementSample(
|
||||
date_time=START + timedelta(seconds=t), key="battery_dc", value=8000.0
|
||||
)
|
||||
for t in (0, 1800, 3600)
|
||||
]
|
||||
))
|
||||
return config_eos
|
||||
|
||||
|
||||
def test_http_reads_database_and_stores_only_explicit_estimate(database_case):
|
||||
from fastapi.testclient import TestClient
|
||||
from akkudoktoreos.server.eos import app
|
||||
|
||||
client = TestClient(app)
|
||||
body = {
|
||||
"start": START.isoformat(),
|
||||
"end": (START + timedelta(hours=1)).isoformat(),
|
||||
"start_soc_percentage": 20,
|
||||
"soc_reference": "voltage_current_anchor",
|
||||
}
|
||||
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 battery.capacity_estimate is None
|
||||
assert battery.capacity_wh == 12000
|
||||
body["store_estimate"] = True
|
||||
response = client.post("/v1/measurement/battery-capacity/battery", json=body)
|
||||
assert response.status_code == 200, response.text
|
||||
assert battery.capacity_estimate.estimated_capacity_wh == pytest.approx(10000)
|
||||
assert battery.capacity_wh == 12000
|
||||
database_case.merge_settings_from_dict({"optimization": {"genetic": {"individuals": 100}}})
|
||||
battery = database_case.devices.batteries["battery"]
|
||||
assert battery.capacity_estimate.estimated_capacity_wh == pytest.approx(10000)
|
||||
assert battery.capacity_wh == 12000
|
||||
|
||||
# The estimate survives normal config serialization without becoming capacity_wh.
|
||||
data = database_case.to_config_json()
|
||||
assert '"estimated_capacity_wh": 10000.0' in data
|
||||
assert '"capacity_wh": 12000' in data
|
||||
previous = battery.capacity_estimate
|
||||
body["start_soc_percentage"] = 100
|
||||
assert client.post("/v1/measurement/battery-capacity/battery", json=body).status_code == 422
|
||||
assert battery.capacity_estimate is previous
|
||||
Reference in New Issue
Block a user