mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-10 08:26:38 +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
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Verify fallback JSON loading does not lose records through the singleton."""
|
||||
from unittest.mock import AsyncMock
|
||||
import pytest
|
||||
from akkudoktoreos.core.coreabc import get_measurement
|
||||
from akkudoktoreos.core.dataabc import DataSequence
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_measurement_json_roundtrip(config_eos, tmp_path, monkeypatch):
|
||||
m = get_measurement()
|
||||
config_eos.measurement.load_emr_keys = ["meter"]
|
||||
config_eos.general.data_folder_path = tmp_path
|
||||
config_eos.database.provider = None
|
||||
m._db_reset_state()
|
||||
try:
|
||||
await m.update_value("2026-09-16T08:00:00Z", "meter", 123.5)
|
||||
monkeypatch.setattr(DataSequence, "save", AsyncMock(return_value=False))
|
||||
monkeypatch.setattr(DataSequence, "load", AsyncMock(return_value=False))
|
||||
assert await m.save()
|
||||
m._db_reset_state()
|
||||
assert await m.load()
|
||||
assert len(m.records) == 1
|
||||
assert m.records[0]["meter"] == 123.5
|
||||
finally:
|
||||
m._db_reset_state()
|
||||
@@ -0,0 +1,105 @@
|
||||
from unittest.mock import AsyncMock
|
||||
"""Contracts for typed channels sharing the existing measurement storage."""
|
||||
|
||||
# ruff: noqa: S101
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from akkudoktoreos.core.coreabc import get_measurement
|
||||
from akkudoktoreos.measurement.measurement import (
|
||||
MeasurementChannelSettings,
|
||||
MeasurementCommonSettings,
|
||||
MeasurementDataRecord,
|
||||
)
|
||||
|
||||
|
||||
CHANNELS = {
|
||||
"house_power": dict(quantity="power", unit="W", integration_method="hold", max_gap_seconds=120),
|
||||
"house_meter": dict(quantity="cumulative_energy", unit="kWh"),
|
||||
"house_interval": dict(
|
||||
quantity="interval_energy", unit="Wh", interval_seconds=900, timestamp_reference="start"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"definition",
|
||||
[
|
||||
dict(quantity="power", unit="kWh", integration_method="hold", max_gap_seconds=60),
|
||||
dict(quantity="power", unit="W"),
|
||||
dict(quantity="power", unit="W", integration_method="hold", max_gap_seconds=0),
|
||||
dict(quantity="cumulative_energy", unit="W"),
|
||||
dict(quantity="cumulative_energy", unit="kWh", timestamp_reference="end"),
|
||||
dict(quantity="interval_energy", unit="Wh", interval_seconds=900),
|
||||
dict(
|
||||
quantity="interval_energy", unit="Wh", interval_seconds=True, timestamp_reference="end"
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_reject_ambiguous_channel(definition):
|
||||
with pytest.raises(ValidationError):
|
||||
MeasurementChannelSettings(**definition)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"legacy_field",
|
||||
["load_emr_keys", "grid_import_emr_keys", "grid_export_emr_keys", "pv_production_emr_keys"],
|
||||
)
|
||||
def test_legacy_keys_keep_meter_semantics(legacy_field):
|
||||
settings = MeasurementCommonSettings(**{legacy_field: ["legacy"]}, channels=CHANNELS)
|
||||
assert settings.keys == sorted(["legacy", *CHANNELS])
|
||||
compatible = MeasurementCommonSettings(
|
||||
**{legacy_field: ["legacy"]}, channels={"legacy": CHANNELS["house_meter"]}
|
||||
)
|
||||
assert compatible.keys == ["legacy"]
|
||||
with pytest.raises(ValidationError, match="must remain"):
|
||||
MeasurementCommonSettings(
|
||||
**{legacy_field: ["legacy"]}, channels={"legacy": CHANNELS["house_power"]}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["", " x", "date_time", "configured_data", "keys", "_private"])
|
||||
def test_reject_reserved_key(key):
|
||||
with pytest.raises(ValidationError):
|
||||
MeasurementCommonSettings(channels={key: CHANNELS["house_meter"]})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_existing_import_and_file_reload(config_eos, tmp_path, monkeypatch):
|
||||
"""All three quantities retain their raw values through the existing file path."""
|
||||
from akkudoktoreos.core.dataabc import DataSequence
|
||||
|
||||
measurement = get_measurement()
|
||||
previous_settings = config_eos.measurement
|
||||
previous_records = measurement.records
|
||||
previous_folder = config_eos.general.data_folder_path
|
||||
try:
|
||||
config_eos.measurement = MeasurementCommonSettings(channels=CHANNELS)
|
||||
config_eos.general.data_folder_path = tmp_path
|
||||
measurement._db_reset_state()
|
||||
values = dict(house_power=800.0, house_meter=12345.6, house_interval=200.0)
|
||||
for key, value in values.items():
|
||||
(await measurement.update_value("2026-09-10T18:00:00Z", key, value))
|
||||
assert set(values).issubset(measurement.record_keys)
|
||||
monkeypatch.setattr(DataSequence, "save", AsyncMock(return_value=False))
|
||||
monkeypatch.setattr(DataSequence, "load", AsyncMock(return_value=False))
|
||||
assert (await measurement.save())
|
||||
measurement._db_reset_state()
|
||||
assert (await measurement.load())
|
||||
assert len(measurement.records) == 1
|
||||
for key, value in values.items():
|
||||
assert measurement.records[0][key] == value
|
||||
restored = MeasurementDataRecord.model_validate_json(
|
||||
measurement.records[0].model_dump_json()
|
||||
)
|
||||
assert restored.configured_data == values
|
||||
settings = MeasurementCommonSettings.model_validate_json(
|
||||
config_eos.measurement.model_dump_json()
|
||||
)
|
||||
assert settings.model_dump() == config_eos.measurement.model_dump()
|
||||
finally:
|
||||
measurement._db_reset_state()
|
||||
measurement.records = previous_records
|
||||
config_eos.measurement = previous_settings
|
||||
config_eos.general.data_folder_path = previous_folder
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Physical and temporal contracts for interval energy."""
|
||||
|
||||
# ruff: noqa: S101
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from akkudoktoreos.measurement.energy import energy_intervals
|
||||
from akkudoktoreos.measurement.measurement import MeasurementChannelSettings
|
||||
|
||||
|
||||
START = datetime(2026, 9, 10, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def channel(quantity="power", **kwargs):
|
||||
defaults = {
|
||||
"power": dict(unit="W", integration_method="hold", max_gap_seconds=900),
|
||||
"cumulative_energy": dict(unit="kWh"),
|
||||
"interval_energy": dict(unit="Wh", interval_seconds=900, timestamp_reference="start"),
|
||||
}
|
||||
return MeasurementChannelSettings(quantity=quantity, **(defaults[quantity] | kwargs))
|
||||
|
||||
|
||||
def convert(points, config=None, seconds=900):
|
||||
return energy_intervals(
|
||||
[(START + timedelta(seconds=t), v) for t, v in points],
|
||||
config or channel(),
|
||||
START,
|
||||
START + timedelta(seconds=seconds),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config,points",
|
||||
[
|
||||
(channel(), [(0, 800), (900, 800)]),
|
||||
(channel(unit="kW"), [(0, 0.8), (900, 0.8)]),
|
||||
(channel("cumulative_energy"), [(0, 10), (900, 10.2)]),
|
||||
(channel("interval_energy"), [(0, 200)]),
|
||||
(channel("interval_energy", timestamp_reference="end"), [(900, 200)]),
|
||||
],
|
||||
)
|
||||
def test_equivalent_measurements(config, points):
|
||||
result = convert(points, config)[0]
|
||||
assert result.energy_wh == pytest.approx(200)
|
||||
assert result.coverage_seconds == 900
|
||||
assert result.coverage_status == "complete"
|
||||
|
||||
|
||||
def test_time_weighting_and_linear_interpolation():
|
||||
assert convert([(0, 0), (600, 1200), (900, 1200)])[0].energy_wh == 100
|
||||
assert convert([(0, 0), (900, 1600)], channel(integration_method="linear"))[0].energy_wh == 200
|
||||
|
||||
|
||||
def test_gap_and_no_extrapolation():
|
||||
result = convert([(0, 800), (300, 800)])[0]
|
||||
assert result.energy_wh is None
|
||||
assert result.observed_energy_wh == pytest.approx(800 / 12)
|
||||
assert result.coverage_status == "partial"
|
||||
assert convert([(0, 800), (900, 800)], channel(max_gap_seconds=60))[0].energy_wh is None
|
||||
assert convert([])[0].coverage_status == "missing"
|
||||
|
||||
|
||||
def test_null_breaks_hold_at_outage():
|
||||
result = convert([(0, 800), (300, None), (600, 800), (900, 800)])[0]
|
||||
assert result.coverage_seconds == 600
|
||||
assert result.coverage_status == "partial"
|
||||
assert len(result.coverage_ranges) == 2
|
||||
|
||||
|
||||
def test_reset_does_not_create_negative_consumption():
|
||||
result = convert([(0, 10), (450, 0), (900, 0.1)], channel("cumulative_energy"))[0]
|
||||
assert result.energy_wh is None
|
||||
assert result.observed_energy_wh == pytest.approx(100)
|
||||
assert "meter_reset" in result.flags
|
||||
|
||||
|
||||
def test_hour_allocation_conserves_energy_and_is_labelled():
|
||||
result = convert([(0, 1000)], channel("interval_energy", interval_seconds=3600), 3600)
|
||||
assert sum(r.energy_wh for r in result) == 1000
|
||||
assert all("allocated_energy" in r.methods for r in result)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"quantity,points",
|
||||
[
|
||||
("power", [(0, 1), (0, 2)]),
|
||||
("interval_energy", [(0, 100), (450, 100)]),
|
||||
],
|
||||
)
|
||||
def test_ambiguous_time_support_rejected(quantity, points):
|
||||
with pytest.raises(ValueError):
|
||||
convert(points, channel(quantity))
|
||||
|
||||
|
||||
def test_nan_and_signed_power():
|
||||
assert convert([(0, float("nan")), (900, 800)])[0].energy_wh is None
|
||||
assert convert([(0, -800), (900, -800)])[0].energy_wh == -200
|
||||
|
||||
|
||||
@pytest.mark.parametrize("month,day,hours", [(3, 29, 23), (10, 25, 25)])
|
||||
def test_dst_calendar_day(month, day, hours):
|
||||
start = datetime(2026, month, day, tzinfo=ZoneInfo("Europe/Berlin"))
|
||||
end = start + timedelta(days=1)
|
||||
result = energy_intervals([(start, 0), (end, hours)], channel("cumulative_energy"), start, end)
|
||||
assert len(result) == hours * 4
|
||||
assert sum(r.energy_wh for r in result) == pytest.approx(hours * 1000)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_measurement_wrapper_preserves_asynchronous_channels(config_eos):
|
||||
from akkudoktoreos.core.coreabc import get_measurement
|
||||
from akkudoktoreos.measurement.measurement import MeasurementCommonSettings
|
||||
|
||||
measurement = get_measurement()
|
||||
previous, records = config_eos.measurement, measurement.records
|
||||
try:
|
||||
config_eos.measurement = MeasurementCommonSettings(
|
||||
channels={"p": channel(), "other": channel()}
|
||||
)
|
||||
measurement._db_reset_state()
|
||||
(await measurement.update_value(START, "p", 800))
|
||||
(await measurement.update_value(START + timedelta(seconds=450), "other", 1))
|
||||
(await measurement.update_value(START + timedelta(seconds=900), "p", 800))
|
||||
result = (await measurement.energy_intervals("p", START, START + timedelta(seconds=900)))
|
||||
assert result[0].energy_wh == 200
|
||||
finally:
|
||||
measurement._db_reset_state()
|
||||
measurement.records = records
|
||||
config_eos.measurement = previous
|
||||
@@ -0,0 +1,366 @@
|
||||
"""API, persistence and physical balance contracts for typed measurements."""
|
||||
|
||||
# ruff: noqa: S101
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import ValidationError
|
||||
|
||||
from akkudoktoreos.core.coreabc import get_database, get_measurement
|
||||
from akkudoktoreos.measurement.measurement import MeasurementCommonSettings
|
||||
from akkudoktoreos.measurement.quality import MeasurementSample
|
||||
from akkudoktoreos.server.rest.measurement import router
|
||||
|
||||
START = datetime(2026, 9, 10, tzinfo=timezone.utc)
|
||||
END = START + timedelta(seconds=900)
|
||||
POWER = dict(quantity="power", unit="W", integration_method="hold", max_gap_seconds=900)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_measurement(config_eos):
|
||||
measurement = get_measurement()
|
||||
settings, records = config_eos.measurement, measurement.records
|
||||
measurement._db_reset_state()
|
||||
|
||||
def configure(topology="direct", inputs=None, channels=None):
|
||||
inputs = inputs or [dict(key="site", branch="house", role="site")]
|
||||
config_eos.measurement = MeasurementCommonSettings(
|
||||
channels=channels or {item["key"]: POWER for item in inputs},
|
||||
household=dict(topology=topology, inputs=inputs),
|
||||
)
|
||||
return measurement
|
||||
|
||||
yield configure
|
||||
measurement._db_reset_state()
|
||||
measurement.records = records
|
||||
config_eos.measurement = settings
|
||||
|
||||
|
||||
async def write(measurement, key, points):
|
||||
(await measurement.import_samples(
|
||||
[
|
||||
MeasurementSample(
|
||||
date_time=START + timedelta(seconds=t),
|
||||
key=key,
|
||||
value=value,
|
||||
quality=quality if quality else {},
|
||||
)
|
||||
for t, value, quality in points
|
||||
]
|
||||
))
|
||||
|
||||
|
||||
async def constant(measurement, key, watts):
|
||||
(await write(measurement, key, [(0, watts, None), (900, watts, None)]))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"topology,values",
|
||||
[
|
||||
("direct", {"site": 4000}),
|
||||
("separate_ac", {"grid": 1000, "pv": 2000, "battery": 1000}),
|
||||
("hybrid_ac", {"grid": 1000, "inverter": 3000}),
|
||||
],
|
||||
)
|
||||
async def test_topologies_and_device_subtraction(setup_measurement, topology, values):
|
||||
values = values | {"ev": 2000, "device": 800}
|
||||
m = setup_measurement(topology, [dict(key=k, branch=k, role=k) for k in values])
|
||||
for key, value in values.items():
|
||||
(await constant(m, key, value))
|
||||
rows = (await m.household_intervals(START, END))
|
||||
assert rows["site"][0].energy_wh == 1000
|
||||
assert rows["household"][0].energy_wh == 500
|
||||
assert rows["base"][0].energy_wh == 300
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_charge_and_polarity(setup_measurement):
|
||||
m = setup_measurement(
|
||||
"separate_ac",
|
||||
[
|
||||
dict(key="grid", branch="grid", role="grid"),
|
||||
dict(key="solar", branch="solar", role="pv"),
|
||||
dict(key="charge", branch="battery", role="battery", polarity=-1),
|
||||
],
|
||||
)
|
||||
for key, watts in dict(grid=-1000, solar=4000, charge=1000).items():
|
||||
(await constant(m, key, watts))
|
||||
assert (await m.household_intervals(START, END))["site"][0].energy_wh == 500
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("grid,inverter", [(-6000, 7200), (4200, -3000)])
|
||||
async def test_hybrid_with_independent_ac_pv_and_ev(setup_measurement, grid, inverter):
|
||||
inputs = [dict(key=k, branch=k, role=k) for k in ("grid", "inverter", "pv", "ev")]
|
||||
m = setup_measurement("hybrid_ac", inputs)
|
||||
for key, watts in dict(grid=grid, inverter=inverter, pv=400, ev=600).items():
|
||||
(await constant(m, key, watts))
|
||||
rows = (await m.household_intervals(START, END))
|
||||
assert rows["site"][0].energy_wh == 400
|
||||
assert rows["household"][0].energy_wh == 250
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hybrid_missing_ac_pv_is_not_zero(setup_measurement):
|
||||
m = setup_measurement(
|
||||
"hybrid_ac", [dict(key=k, branch=k, role=k) for k in ("grid", "inverter", "pv")]
|
||||
)
|
||||
(await constant(m, "grid", -6000))
|
||||
(await constant(m, "inverter", 7200))
|
||||
row = (await m.household_intervals(START, END))["site"][0]
|
||||
assert row.energy_wh is None
|
||||
assert row.coverage_seconds == 0
|
||||
|
||||
|
||||
def test_hybrid_rejects_separate_battery_to_avoid_double_counting(setup_measurement):
|
||||
with pytest.raises(ValidationError):
|
||||
setup_measurement(
|
||||
"hybrid_ac",
|
||||
[dict(key=k, branch=k, role=k) for k in ("grid", "inverter", "battery")],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_ev_does_not_destroy_site(setup_measurement):
|
||||
m = setup_measurement(inputs=[dict(key=k, branch=k, role=k) for k in ("site", "ev")])
|
||||
(await constant(m, "site", 800))
|
||||
rows = (await m.household_intervals(START, END))
|
||||
assert rows["site"][0].energy_wh == 200
|
||||
assert rows["household"][0].energy_wh is None
|
||||
assert rows["household"][0].coverage_seconds == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_intersection_integrates_actual_shape(setup_measurement):
|
||||
m = setup_measurement(inputs=[dict(key=k, branch=k, role=k) for k in ("site", "ev")])
|
||||
(await write(m, "site", [(0, 1000, None), (300, 2000, None), (600, None, None)]))
|
||||
(await write(m, "ev", [(300, 500, None), (900, 500, None)]))
|
||||
row = (await m.household_intervals(START, END))["household"][0]
|
||||
assert row.coverage_seconds == 300
|
||||
assert row.energy_wh is None
|
||||
assert row.observed_energy_wh == pytest.approx(125)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nonoverlapping_coverage_is_missing(setup_measurement):
|
||||
m = setup_measurement(inputs=[dict(key=k, branch=k, role=k) for k in ("site", "ev")])
|
||||
(await write(m, "site", [(0, 1000, None), (300, 1000, None)]))
|
||||
(await write(m, "ev", [(600, 500, None), (900, 500, None)]))
|
||||
row = (await m.household_intervals(START, END))["household"][0]
|
||||
assert row.observed_energy_wh is None
|
||||
assert row.coverage_seconds == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"inputs",
|
||||
[
|
||||
[dict(key="p", branch="x", role="grid"), dict(key="p", branch="y", role="pv")],
|
||||
[dict(key="p", branch="x", role="grid"), dict(key="q", branch="x", role="pv")],
|
||||
[dict(key="p", branch="x", role="site"), dict(key="q", branch="y", role="pv")],
|
||||
[dict(key="unknown", branch="x", role="grid")],
|
||||
],
|
||||
)
|
||||
def test_invalid_balance_configuration(setup_measurement, inputs):
|
||||
with pytest.raises(ValidationError):
|
||||
setup_measurement("separate_ac", inputs, {"p": POWER, "q": POWER})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quality_reset_even_with_increasing_meter(setup_measurement):
|
||||
m = setup_measurement(channels={"site": dict(quantity="cumulative_energy", unit="Wh")})
|
||||
(await write(
|
||||
m,
|
||||
"site",
|
||||
[
|
||||
(0, 0, {"generation": "old"}),
|
||||
(450, 1000, {"generation": "new", "reset": True}),
|
||||
(900, 1100, {"generation": "new", "status": "estimated"}),
|
||||
],
|
||||
))
|
||||
row = (await m.energy_intervals("site", START, END))[0]
|
||||
assert row.energy_wh is None
|
||||
assert row.observed_energy_wh == 100
|
||||
assert set(row.flags) == {"meter_reset", "estimated"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_upsert_outage_validation_and_readback(setup_measurement):
|
||||
m = setup_measurement()
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
with TestClient(app) as client:
|
||||
payload = [
|
||||
dict(
|
||||
date_time=START.isoformat(), key="site", value=800, quality={"status": "estimated"}
|
||||
),
|
||||
dict(date_time=END.isoformat(), key="site", value=800),
|
||||
]
|
||||
assert client.put("/v1/measurement/samples", json=payload).status_code == 200
|
||||
params = dict(key="site", start=START.isoformat(), end=END.isoformat())
|
||||
row = client.get("/v1/measurement/energy", params=params).json()[0]
|
||||
assert row["energy_wh"] == 200
|
||||
assert "estimated" in row["flags"]
|
||||
raw = client.get("/v1/measurement/samples", params=params).json()
|
||||
assert raw[0]["quality"]["status"] == "estimated"
|
||||
payload[0]["value"] = None
|
||||
payload[0]["quality"] = {"status": "unavailable"}
|
||||
assert client.put("/v1/measurement/samples", json=payload[:1]).status_code == 200
|
||||
row = client.get("/v1/measurement/energy", params=params).json()[0]
|
||||
assert row["energy_wh"] is None
|
||||
assert "unavailable" in row["flags"]
|
||||
assert len(m.records) == 2
|
||||
payload[0]["value"] = 0
|
||||
payload[0]["quality"] = {}
|
||||
assert client.put("/v1/measurement/samples", json=payload[:1]).status_code == 200
|
||||
assert client.get("/v1/measurement/energy", params=params).json()[0]["energy_wh"] == 0
|
||||
assert (
|
||||
client.get("/v1/measurement/household", params=params).json()["site"][0]["energy_wh"]
|
||||
== 0
|
||||
)
|
||||
# Validate a whole batch before changing any value.
|
||||
bad = [payload[0] | {"value": 999}, payload[0] | {"key": "unknown"}]
|
||||
assert client.put("/v1/measurement/samples", json=bad).status_code == 422
|
||||
assert (await m.energy_intervals("site", START, END))[0].energy_wh == 0
|
||||
assert (
|
||||
client.get(
|
||||
"/v1/measurement/energy", params=params | {"interval_seconds": 0}
|
||||
).status_code
|
||||
== 422
|
||||
)
|
||||
assert (
|
||||
client.get(
|
||||
"/v1/measurement/energy", params=params | {"start": "2026-09-10T00:00:00"}
|
||||
).status_code
|
||||
== 422
|
||||
)
|
||||
assert (
|
||||
client.get(
|
||||
"/v1/measurement/energy",
|
||||
params=params | {"end": (START + timedelta(days=32)).isoformat()},
|
||||
).status_code
|
||||
== 422
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("provider", [None, "SQLite", "LMDB"])
|
||||
async def test_quality_persists_through_storage_restart(
|
||||
setup_measurement, config_eos, tmp_path, provider
|
||||
):
|
||||
m = setup_measurement()
|
||||
folder, previous_db = config_eos.general.data_folder_path, config_eos.database.provider
|
||||
try:
|
||||
config_eos.general.data_folder_path = tmp_path
|
||||
config_eos.database.provider = provider
|
||||
(await write(
|
||||
m,
|
||||
"site",
|
||||
[
|
||||
(0, 800, {"status": "estimated", "generation": "meter1"}),
|
||||
(450, None, {"status": "unavailable"}),
|
||||
(900, 800, None),
|
||||
],
|
||||
))
|
||||
before = (await m.energy_intervals("site", START, END))
|
||||
assert (await m.save())
|
||||
if provider:
|
||||
(await get_database().close())
|
||||
m._db_reset_state()
|
||||
if not provider:
|
||||
assert (await m.load())
|
||||
# DB loads on demand via the bounded query, without a full-history load.
|
||||
assert (await m.energy_intervals("site", START, END)) == before
|
||||
assert m.records[0].sample_quality["site"].generation == "meter1"
|
||||
finally:
|
||||
if provider:
|
||||
(await get_database().close())
|
||||
m._db_reset_state()
|
||||
config_eos.database.provider = previous_db
|
||||
config_eos.general.data_folder_path = folder
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_passes_bounded_storage_window(setup_measurement, monkeypatch):
|
||||
m = setup_measurement()
|
||||
calls = []
|
||||
original = type(m).db_iterate_records
|
||||
|
||||
def record_window(self, start_timestamp=None, end_timestamp=None):
|
||||
calls.append((start_timestamp, end_timestamp))
|
||||
return original(self, start_timestamp, end_timestamp)
|
||||
|
||||
monkeypatch.setattr(type(m), "db_iterate_records", record_window)
|
||||
(await m.energy_intervals("site", START, END))
|
||||
assert calls and all(a is not None and b is not None for a, b in calls)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_actual_server_routes_and_legacy_value_api(setup_measurement):
|
||||
setup_measurement()
|
||||
from akkudoktoreos.server.eos import app
|
||||
|
||||
# No lifespan: this test must not start schedulers or write application state.
|
||||
client = TestClient(app)
|
||||
try:
|
||||
for time in (START, END):
|
||||
response = client.put(
|
||||
"/v1/measurement/value",
|
||||
params={
|
||||
"datetime": time.isoformat(),
|
||||
"key": "site",
|
||||
"value": 800,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
response = client.get(
|
||||
"/v1/measurement/energy",
|
||||
params={
|
||||
"key": "site",
|
||||
"start": START.isoformat(),
|
||||
"end": END.isoformat(),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()[0]["energy_wh"] == 200
|
||||
assert "sample_quality" not in get_measurement().record_keys
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quality_merge_keeps_other_channels(setup_measurement):
|
||||
from akkudoktoreos.measurement.measurement import MeasurementDataRecord
|
||||
|
||||
m = setup_measurement(inputs=[dict(key=k, branch=k, role=k) for k in ("site", "ev")])
|
||||
(await write(m, "site", [(0, 800, {"status": "estimated"})]))
|
||||
(await write(m, "ev", [(0, None, {"status": "unavailable"})]))
|
||||
(await m.insert_by_datetime(
|
||||
MeasurementDataRecord(date_time=START, sample_quality={"site": {"status": "measured"}})
|
||||
))
|
||||
assert m.records[0].sample_quality["site"].status == "measured"
|
||||
assert m.records[0].sample_quality["ev"].status == "unavailable"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hour_energy_allocation_and_short_last_interval(setup_measurement):
|
||||
m = setup_measurement(
|
||||
channels={
|
||||
"site": dict(
|
||||
quantity="interval_energy",
|
||||
unit="Wh",
|
||||
interval_seconds=3600,
|
||||
timestamp_reference="start",
|
||||
)
|
||||
}
|
||||
)
|
||||
(await write(m, "site", [(0, 1000, None)]))
|
||||
rows = (await m.household_intervals(START, START + timedelta(seconds=1800)))["site"]
|
||||
assert [row.energy_wh for row in rows] == [250, 250]
|
||||
assert all("allocated_energy" in row.methods for row in rows)
|
||||
row = (await m.energy_intervals("site", START, START + timedelta(seconds=60)))[0]
|
||||
assert row.coverage_seconds == 60
|
||||
assert row.energy_wh == pytest.approx(1000 / 60)
|
||||
Reference in New Issue
Block a user