2026-10-28 20:15:25 +01:00
|
|
|
import json
|
|
|
|
|
from unittest.mock import AsyncMock
|
|
|
|
|
|
2024-12-29 18:42:49 +01:00
|
|
|
import numpy as np
|
|
|
|
|
import pytest
|
2026-07-15 16:38:53 +02:00
|
|
|
import pytest_asyncio
|
2024-12-29 18:42:49 +01:00
|
|
|
from pendulum import datetime, duration
|
2026-10-28 20:15:25 +01:00
|
|
|
from pydantic import ValidationError
|
2024-12-29 18:42:49 +01:00
|
|
|
|
2026-02-22 14:12:42 +01:00
|
|
|
from akkudoktoreos.core.coreabc import get_measurement
|
2026-10-28 20:15:25 +01:00
|
|
|
from akkudoktoreos.core.dataabc import DataSequence
|
2025-01-12 05:19:37 +01:00
|
|
|
from akkudoktoreos.measurement.measurement import (
|
2026-10-28 20:15:25 +01:00
|
|
|
MeasurementChannelSettings,
|
2025-01-12 05:19:37 +01:00
|
|
|
MeasurementCommonSettings,
|
|
|
|
|
MeasurementDataRecord,
|
2026-02-22 14:12:42 +01:00
|
|
|
)
|
|
|
|
|
from akkudoktoreos.utils.datetimeutil import (
|
|
|
|
|
compare_datetimes,
|
|
|
|
|
to_datetime,
|
2025-01-12 05:19:37 +01:00
|
|
|
)
|
2024-12-29 18:42:49 +01:00
|
|
|
|
|
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
class TestMeasurementDataRecord:
|
2026-10-28 20:15:25 +01:00
|
|
|
"""Measurement-specific keys, values, and serialization.
|
2025-10-28 02:50:31 +01:00
|
|
|
|
2026-10-28 20:15:25 +01:00
|
|
|
Generic native-field access is covered by TestDataRecord in test_dataabcrecord.py.
|
2025-10-28 02:50:31 +01:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def sample_config(self, config_eos):
|
|
|
|
|
"""Fixture to configure the measurement keys on the global config."""
|
|
|
|
|
config_eos.measurement.load_emr_keys = ["dish_washer_mr", "temp"]
|
|
|
|
|
config_eos.measurement.pv_production_emr_keys = ["solar_power"]
|
|
|
|
|
return config_eos
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def record(self, sample_config):
|
|
|
|
|
"""Fixture to create a sample MeasurementDataRecord with some measurements set."""
|
|
|
|
|
rec = MeasurementDataRecord(date_time=None)
|
|
|
|
|
rec.configured_data = {"dish_washer_mr": 123.0, "solar_power": 456.0}
|
|
|
|
|
return rec
|
|
|
|
|
|
|
|
|
|
def test_record_keys_includes_measurement_keys(self, record):
|
|
|
|
|
"""Ensure record_keys includes all configured measurement keys."""
|
|
|
|
|
assert set(record.record_keys()) >= set(record.config.measurement.keys)
|
|
|
|
|
|
|
|
|
|
def test_record_keys_writable_includes_measurement_keys(self, record):
|
|
|
|
|
"""Ensure record_keys_writable includes all configured measurement keys."""
|
|
|
|
|
assert set(record.record_keys_writable()) >= set(record.config.measurement.keys)
|
|
|
|
|
|
|
|
|
|
def test_getitem_existing_field(self, record):
|
|
|
|
|
"""Test that __getitem__ returns correct value for existing native field."""
|
2026-02-22 14:12:42 +01:00
|
|
|
date_time = "2024-01-01T00:00:00+00:00"
|
|
|
|
|
record.date_time = date_time
|
|
|
|
|
assert compare_datetimes(record["date_time"], to_datetime(date_time)).equal
|
2025-10-28 02:50:31 +01:00
|
|
|
|
|
|
|
|
def test_getitem_existing_measurement(self, record):
|
|
|
|
|
"""Test that __getitem__ retrieves existing measurement values."""
|
|
|
|
|
assert record["dish_washer_mr"] == 123.0
|
|
|
|
|
assert record["solar_power"] == 456.0
|
|
|
|
|
|
|
|
|
|
def test_getitem_missing_measurement_returns_none(self, record):
|
|
|
|
|
"""Test that __getitem__ returns None for missing but known measurement keys."""
|
|
|
|
|
assert record["temp"] is None
|
|
|
|
|
|
|
|
|
|
def test_getitem_raises_keyerror(self, record):
|
|
|
|
|
"""Test that __getitem__ raises KeyError for completely unknown keys."""
|
|
|
|
|
with pytest.raises(KeyError):
|
|
|
|
|
_ = record["nonexistent"]
|
|
|
|
|
|
|
|
|
|
def test_setitem_measurement(self, record):
|
|
|
|
|
"""Test setting a known measurement key using __setitem__."""
|
|
|
|
|
record["temp"] = 25.5
|
|
|
|
|
assert record["temp"] == 25.5
|
|
|
|
|
|
|
|
|
|
def test_setitem_invalid_key_raises(self, record):
|
|
|
|
|
"""Test that __setitem__ raises KeyError for unknown keys."""
|
|
|
|
|
with pytest.raises(KeyError):
|
|
|
|
|
record["unknown_key"] = 123
|
|
|
|
|
|
|
|
|
|
def test_delitem_measurement(self, record):
|
|
|
|
|
"""Test deleting a known measurement key using __delitem__."""
|
|
|
|
|
del record["solar_power"]
|
|
|
|
|
assert record["solar_power"] is None
|
|
|
|
|
|
|
|
|
|
def test_delitem_unknown_raises(self, record):
|
|
|
|
|
"""Test that __delitem__ raises KeyError for unknown keys."""
|
|
|
|
|
with pytest.raises(KeyError):
|
|
|
|
|
del record["nonexistent"]
|
|
|
|
|
|
|
|
|
|
def test_attribute_get_existing_measurement(self, record):
|
|
|
|
|
"""Test accessing an existing measurement via attribute."""
|
|
|
|
|
assert record.dish_washer_mr == 123.0
|
|
|
|
|
|
|
|
|
|
def test_attribute_get_missing_measurement(self, record):
|
|
|
|
|
"""Test accessing a missing but known measurement returns None."""
|
|
|
|
|
assert record.temp is None
|
|
|
|
|
|
|
|
|
|
def test_attribute_get_invalid_raises(self, record):
|
|
|
|
|
"""Test accessing an unknown attribute raises AttributeError."""
|
|
|
|
|
with pytest.raises(AttributeError):
|
|
|
|
|
_ = record.nonexistent
|
|
|
|
|
|
|
|
|
|
def test_attribute_set_existing_measurement(self, record):
|
|
|
|
|
"""Test setting a known measurement key via attribute."""
|
|
|
|
|
record.temp = 99.9
|
|
|
|
|
assert record["temp"] == 99.9
|
|
|
|
|
|
|
|
|
|
def test_attribute_set_invalid_raises(self, record):
|
|
|
|
|
"""Test setting an unknown attribute raises AttributeError."""
|
|
|
|
|
with pytest.raises(AttributeError):
|
|
|
|
|
record.invalid = 123
|
|
|
|
|
|
|
|
|
|
def test_delattr_measurement(self, record):
|
|
|
|
|
"""Test deleting a known measurement key via attribute."""
|
|
|
|
|
record.temp = 88.0
|
|
|
|
|
del record.temp
|
|
|
|
|
assert record.temp is None
|
|
|
|
|
|
|
|
|
|
def test_delattr_ignored_missing_measurement_key(self, record):
|
|
|
|
|
"""Test deleting a known measurement key that was never set is a no-op."""
|
|
|
|
|
del record.temp
|
|
|
|
|
assert record.temp is None
|
|
|
|
|
|
|
|
|
|
def test_len_and_iter(self, record):
|
|
|
|
|
"""Test that __len__ and __iter__ behave as expected."""
|
|
|
|
|
keys = list(iter(record))
|
|
|
|
|
assert set(record.record_keys_writable()) == set(keys)
|
|
|
|
|
assert len(record) == len(keys)
|
|
|
|
|
|
|
|
|
|
def test_hasattr_behavior(self, record):
|
|
|
|
|
"""Test that hasattr returns True for fields and known measurements."""
|
|
|
|
|
assert hasattr(record, "date_time")
|
|
|
|
|
assert hasattr(record, "dish_washer_mr")
|
|
|
|
|
assert hasattr(record, "temp") # allowed, even if not yet set
|
|
|
|
|
assert not hasattr(record, "nonexistent")
|
|
|
|
|
|
|
|
|
|
def test_model_validate_roundtrip(self, record):
|
|
|
|
|
"""Test that MeasurementDataRecord can be serialized and revalidated."""
|
|
|
|
|
dumped = record.model_dump()
|
|
|
|
|
restored = MeasurementDataRecord.model_validate(dumped)
|
|
|
|
|
assert restored.dish_washer_mr == 123.0
|
|
|
|
|
assert restored.solar_power == 456.0
|
|
|
|
|
assert restored.temp is None # not set
|
|
|
|
|
|
|
|
|
|
def test_copy_preserves_measurements(self, record):
|
|
|
|
|
"""Test that copying preserves measurement values."""
|
|
|
|
|
record.temp = 22.2
|
|
|
|
|
copied = record.model_copy()
|
|
|
|
|
assert copied.dish_washer_mr == 123.0
|
|
|
|
|
assert copied.temp == 22.2
|
|
|
|
|
assert copied is not record
|
|
|
|
|
|
|
|
|
|
def test_inequality_differs_with_measurements(self, record):
|
|
|
|
|
"""Test that records with different measurements are not equal."""
|
|
|
|
|
other = record.model_copy(deep=True)
|
|
|
|
|
# Modify one measurement value in the copy
|
|
|
|
|
other["dish_washer_mr"] = 999.9
|
|
|
|
|
assert record != other
|
|
|
|
|
|
|
|
|
|
def test_in_operator_for_measurements_and_fields(self, record):
|
|
|
|
|
"""Ensure 'in' works for both fields and configured measurement keys."""
|
|
|
|
|
assert "dish_washer_mr" in record
|
|
|
|
|
assert "solar_power" in record
|
|
|
|
|
assert "date_time" in record # standard field
|
|
|
|
|
assert "temp" in record # allowed but not yet set
|
|
|
|
|
assert "unknown" not in record
|
|
|
|
|
|
|
|
|
|
def test_dir_includes_measurement_keys(self, record):
|
|
|
|
|
"""`dir(record)` should include measurement keys for introspection.
|
|
|
|
|
It shall not include the internal 'measurements' attribute.
|
|
|
|
|
"""
|
|
|
|
|
keys = dir(record)
|
|
|
|
|
assert "measurements" not in keys
|
|
|
|
|
for key in record.config.measurement.keys:
|
|
|
|
|
assert key in keys
|
|
|
|
|
|
|
|
|
|
|
2026-07-15 16:38:53 +02:00
|
|
|
@pytest.mark.asyncio
|
2025-10-28 02:50:31 +01:00
|
|
|
class TestMeasurement:
|
|
|
|
|
"""Test suite for the Measuremen class."""
|
|
|
|
|
|
2026-07-15 16:38:53 +02:00
|
|
|
@pytest_asyncio.fixture
|
|
|
|
|
async def measurement_eos(self, config_eos):
|
2025-10-28 02:50:31 +01:00
|
|
|
"""Fixture to create a Measurement instance."""
|
2026-01-01 12:26:29 +01:00
|
|
|
# Load meter readings are in kWh
|
2025-10-28 02:50:31 +01:00
|
|
|
config_eos.measurement.load_emr_keys = ["load0_mr", "load1_mr", "load2_mr", "load3_mr"]
|
|
|
|
|
measurement = get_measurement()
|
2026-07-15 16:38:53 +02:00
|
|
|
await measurement.delete_by_datetime(None, None)
|
2025-10-28 02:50:31 +01:00
|
|
|
record0 = MeasurementDataRecord(
|
2024-12-29 18:42:49 +01:00
|
|
|
date_time=datetime(2023, 1, 1, hour=0),
|
2025-01-18 14:26:34 +01:00
|
|
|
load0_mr=100,
|
|
|
|
|
load1_mr=200,
|
2025-10-28 02:50:31 +01:00
|
|
|
)
|
|
|
|
|
assert record0.load0_mr == 100
|
|
|
|
|
assert record0.load1_mr == 200
|
2026-02-22 14:12:42 +01:00
|
|
|
records = [
|
2025-10-28 02:50:31 +01:00
|
|
|
MeasurementDataRecord(
|
2026-02-22 14:12:42 +01:00
|
|
|
date_time=to_datetime("2023-01-01T00:00:00"),
|
2025-10-28 02:50:31 +01:00
|
|
|
load0_mr=100,
|
|
|
|
|
load1_mr=200,
|
|
|
|
|
),
|
|
|
|
|
MeasurementDataRecord(
|
2026-02-22 14:12:42 +01:00
|
|
|
date_time=to_datetime("2023-01-01T01:00:00"),
|
2025-10-28 02:50:31 +01:00
|
|
|
load0_mr=150,
|
|
|
|
|
load1_mr=250,
|
|
|
|
|
),
|
|
|
|
|
MeasurementDataRecord(
|
2026-02-22 14:12:42 +01:00
|
|
|
date_time=to_datetime("2023-01-01T02:00:00"),
|
2025-10-28 02:50:31 +01:00
|
|
|
load0_mr=200,
|
|
|
|
|
load1_mr=300,
|
|
|
|
|
),
|
|
|
|
|
MeasurementDataRecord(
|
2026-02-22 14:12:42 +01:00
|
|
|
date_time=to_datetime("2023-01-01T03:00:00"),
|
2025-10-28 02:50:31 +01:00
|
|
|
load0_mr=250,
|
|
|
|
|
load1_mr=350,
|
|
|
|
|
),
|
|
|
|
|
MeasurementDataRecord(
|
2026-02-22 14:12:42 +01:00
|
|
|
date_time=to_datetime("2023-01-01T04:00:00"),
|
2025-10-28 02:50:31 +01:00
|
|
|
load0_mr=300,
|
|
|
|
|
load1_mr=400,
|
|
|
|
|
),
|
|
|
|
|
MeasurementDataRecord(
|
2026-02-22 14:12:42 +01:00
|
|
|
date_time=to_datetime("2023-01-01T05:00:00"),
|
2025-10-28 02:50:31 +01:00
|
|
|
load0_mr=350,
|
|
|
|
|
load1_mr=450,
|
|
|
|
|
),
|
|
|
|
|
]
|
2026-02-22 14:12:42 +01:00
|
|
|
for record in records:
|
2026-07-15 16:38:53 +02:00
|
|
|
await measurement.insert_by_datetime(record)
|
2025-10-28 02:50:31 +01:00
|
|
|
return measurement
|
2024-12-29 18:42:49 +01:00
|
|
|
|
2026-07-15 16:38:53 +02:00
|
|
|
async def test_interval_count(self, measurement_eos):
|
2025-10-28 02:50:31 +01:00
|
|
|
"""Test interval count calculation."""
|
2026-02-22 14:12:42 +01:00
|
|
|
start = to_datetime("2023-01-01T00:00:00")
|
|
|
|
|
end = to_datetime("2023-01-01T03:00:00")
|
2025-10-28 02:50:31 +01:00
|
|
|
interval = duration(hours=1)
|
2024-12-29 18:42:49 +01:00
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
assert measurement_eos._interval_count(start, end, interval) == 3
|
2024-12-29 18:42:49 +01:00
|
|
|
|
2026-07-15 16:38:53 +02:00
|
|
|
async def test_interval_count_invalid_end_before_start(self, measurement_eos):
|
2025-10-28 02:50:31 +01:00
|
|
|
"""Test interval count raises ValueError when end_datetime is before start_datetime."""
|
2026-02-22 14:12:42 +01:00
|
|
|
start = to_datetime("2023-01-01T03:00:00")
|
|
|
|
|
end = to_datetime("2023-01-01T00:00:00")
|
2025-10-28 02:50:31 +01:00
|
|
|
interval = duration(hours=1)
|
2024-12-29 18:42:49 +01:00
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
with pytest.raises(ValueError, match="end_datetime must be after start_datetime"):
|
|
|
|
|
measurement_eos._interval_count(start, end, interval)
|
2024-12-29 18:42:49 +01:00
|
|
|
|
2026-07-15 16:38:53 +02:00
|
|
|
async def test_interval_count_invalid_non_positive_interval(self, measurement_eos):
|
2025-10-28 02:50:31 +01:00
|
|
|
"""Test interval count raises ValueError when interval is non-positive."""
|
2026-02-22 14:12:42 +01:00
|
|
|
start = to_datetime("2023-01-01T00:00:00")
|
|
|
|
|
end = to_datetime("2023-01-01T03:00:00")
|
2024-12-29 18:42:49 +01:00
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
with pytest.raises(ValueError, match="interval must be positive"):
|
|
|
|
|
measurement_eos._interval_count(start, end, duration(hours=0))
|
2024-12-29 18:42:49 +01:00
|
|
|
|
2026-07-15 16:38:53 +02:00
|
|
|
async def test_energy_from_meter_readings_valid_input(self, measurement_eos):
|
2025-10-28 02:50:31 +01:00
|
|
|
"""Test _energy_from_meter_readings with valid inputs and proper alignment of load data."""
|
|
|
|
|
key = "load0_mr"
|
2026-02-22 14:12:42 +01:00
|
|
|
start_datetime = to_datetime("2023-01-01T00:00:00")
|
|
|
|
|
end_datetime = to_datetime("2023-01-01T05:00:00")
|
2025-10-28 02:50:31 +01:00
|
|
|
interval = duration(hours=1)
|
2024-12-29 18:42:49 +01:00
|
|
|
|
2026-07-15 16:38:53 +02:00
|
|
|
load_array = await measurement_eos._energy_from_meter_readings(
|
2024-12-29 18:42:49 +01:00
|
|
|
key, start_datetime, end_datetime, interval
|
|
|
|
|
)
|
|
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
expected_load_array = np.array([50, 50, 50, 50, 50]) # Differences between consecutive readings
|
|
|
|
|
np.testing.assert_array_equal(load_array, expected_load_array)
|
2024-12-29 18:42:49 +01:00
|
|
|
|
2026-07-15 16:38:53 +02:00
|
|
|
async def test_energy_from_meter_readings_empty_array(self, measurement_eos):
|
2025-10-28 02:50:31 +01:00
|
|
|
"""Test _energy_from_meter_readings with no data (empty array)."""
|
|
|
|
|
key = "load0_mr"
|
2026-02-22 14:12:42 +01:00
|
|
|
start_datetime = to_datetime("2023-01-01T00:00:00")
|
|
|
|
|
end_datetime = to_datetime("2023-01-01T05:00:00")
|
2025-10-28 02:50:31 +01:00
|
|
|
interval = duration(hours=1)
|
2024-12-29 18:42:49 +01:00
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
# Use empyt records array
|
2026-07-15 16:38:53 +02:00
|
|
|
await measurement_eos.delete_by_datetime(start_datetime, end_datetime)
|
2024-12-29 18:42:49 +01:00
|
|
|
|
2026-07-15 16:38:53 +02:00
|
|
|
load_array = await measurement_eos._energy_from_meter_readings(
|
2025-10-28 02:50:31 +01:00
|
|
|
key, start_datetime, end_datetime, interval
|
2025-01-12 05:19:37 +01:00
|
|
|
)
|
2024-12-29 18:42:49 +01:00
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
# Expected: an array of zeros with one less than the number of intervals
|
|
|
|
|
expected_size = (
|
|
|
|
|
measurement_eos._interval_count(start_datetime, end_datetime + interval, interval) - 1
|
2025-01-12 05:19:37 +01:00
|
|
|
)
|
2025-10-28 02:50:31 +01:00
|
|
|
expected_load_array = np.zeros(expected_size)
|
|
|
|
|
np.testing.assert_array_equal(load_array, expected_load_array)
|
2024-12-29 18:42:49 +01:00
|
|
|
|
2026-07-15 16:38:53 +02:00
|
|
|
async def test_energy_from_meter_readings_misaligned_array(self, measurement_eos):
|
2025-10-28 02:50:31 +01:00
|
|
|
"""Test _energy_from_meter_readings with misaligned array size."""
|
|
|
|
|
key = "load1_mr"
|
|
|
|
|
interval = duration(hours=1)
|
2026-02-22 14:12:42 +01:00
|
|
|
start_datetime = to_datetime("2023-01-01T00:00:00")
|
|
|
|
|
end_datetime = to_datetime("2023-01-01T05:00:00")
|
2024-12-29 18:42:49 +01:00
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
# Use misaligned array, latest interval set to 2 hours (instead of 1 hour)
|
2026-02-22 14:12:42 +01:00
|
|
|
latest_record_datetime = to_datetime("2023-01-01T05:00:00")
|
|
|
|
|
new_record_datetime = to_datetime("2023-01-01T06:00:00")
|
2026-07-15 16:38:53 +02:00
|
|
|
record = await measurement_eos.get_by_datetime(latest_record_datetime)
|
2026-02-22 14:12:42 +01:00
|
|
|
assert record is not None
|
2026-07-15 16:38:53 +02:00
|
|
|
await measurement_eos.delete_by_datetime(start_datetime = latest_record_datetime,
|
|
|
|
|
end_datetime = new_record_datetime)
|
2026-02-22 14:12:42 +01:00
|
|
|
record.date_time = new_record_datetime
|
2026-07-15 16:38:53 +02:00
|
|
|
await measurement_eos.insert_by_datetime(record)
|
2026-02-22 14:12:42 +01:00
|
|
|
|
|
|
|
|
# Check test setup
|
2026-07-15 16:38:53 +02:00
|
|
|
dates, values = await measurement_eos.key_to_lists(key, start_datetime, None)
|
2026-02-22 14:12:42 +01:00
|
|
|
assert dates == [
|
|
|
|
|
to_datetime("2023-01-01T00:00:00"),
|
|
|
|
|
to_datetime("2023-01-01T01:00:00"),
|
|
|
|
|
to_datetime("2023-01-01T02:00:00"),
|
|
|
|
|
to_datetime("2023-01-01T03:00:00"),
|
|
|
|
|
to_datetime("2023-01-01T04:00:00"),
|
|
|
|
|
to_datetime("2023-01-01T06:00:00"),
|
|
|
|
|
]
|
|
|
|
|
assert values == [200, 250, 300, 350, 400, 450]
|
2026-07-15 16:38:53 +02:00
|
|
|
array = await measurement_eos.key_to_array(key, start_datetime, end_datetime + interval, interval=interval)
|
2026-02-22 14:12:42 +01:00
|
|
|
np.testing.assert_array_equal(array, [200, 250, 300, 350, 400, 425])
|
2024-12-29 18:42:49 +01:00
|
|
|
|
2026-07-15 16:38:53 +02:00
|
|
|
load_array = await measurement_eos._energy_from_meter_readings(
|
2025-10-28 02:50:31 +01:00
|
|
|
key, start_datetime, end_datetime, interval
|
|
|
|
|
)
|
2024-12-29 18:42:49 +01:00
|
|
|
|
2026-02-22 14:12:42 +01:00
|
|
|
expected_load_array = np.array([50., 50., 50., 50., 25.]) # Differences between consecutive readings
|
2025-10-28 02:50:31 +01:00
|
|
|
np.testing.assert_array_equal(load_array, expected_load_array)
|
|
|
|
|
|
2026-07-15 16:38:53 +02:00
|
|
|
async def test_energy_from_meter_readings_partial_data(self, measurement_eos, caplog):
|
2025-10-28 02:50:31 +01:00
|
|
|
"""Test _energy_from_meter_readings with partial data (misaligned but empty array)."""
|
|
|
|
|
key = "load2_mr"
|
2026-02-22 14:12:42 +01:00
|
|
|
start_datetime = to_datetime("2023-01-01T00:00:00")
|
|
|
|
|
end_datetime = to_datetime("2023-01-01T05:00:00")
|
2025-10-28 02:50:31 +01:00
|
|
|
interval = duration(hours=1)
|
|
|
|
|
|
|
|
|
|
with caplog.at_level("DEBUG"):
|
2026-07-15 16:38:53 +02:00
|
|
|
load_array = await measurement_eos._energy_from_meter_readings(
|
2025-10-28 02:50:31 +01:00
|
|
|
key, start_datetime, end_datetime, interval
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
expected_size = (
|
|
|
|
|
measurement_eos._interval_count(start_datetime, end_datetime + interval, interval) - 1
|
|
|
|
|
)
|
|
|
|
|
expected_load_array = np.zeros(expected_size)
|
|
|
|
|
np.testing.assert_array_equal(load_array, expected_load_array)
|
|
|
|
|
|
2026-07-15 16:38:53 +02:00
|
|
|
async def test_energy_from_meter_readings_negative_interval(self, measurement_eos):
|
2025-10-28 02:50:31 +01:00
|
|
|
"""Test _energy_from_meter_readings with a negative interval."""
|
|
|
|
|
key = "load3_mr"
|
2026-02-22 14:12:42 +01:00
|
|
|
start_datetime = to_datetime("2023-01-01T00:00:00")
|
|
|
|
|
end_datetime = to_datetime("2023-01-01T05:00:00")
|
2025-10-28 02:50:31 +01:00
|
|
|
interval = duration(hours=-1)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValueError, match="interval must be positive"):
|
2026-07-15 16:38:53 +02:00
|
|
|
await measurement_eos._energy_from_meter_readings(key, start_datetime, end_datetime, interval)
|
2025-10-28 02:50:31 +01:00
|
|
|
|
2026-07-15 16:38:53 +02:00
|
|
|
async def test_load_total_kwh(self, measurement_eos):
|
2025-10-28 02:50:31 +01:00
|
|
|
"""Test total load calculation."""
|
2026-02-22 14:12:42 +01:00
|
|
|
start_datetime = to_datetime("2023-01-01T03:00:00")
|
|
|
|
|
end_datetime = to_datetime("2023-01-01T05:00:00")
|
2025-10-28 02:50:31 +01:00
|
|
|
interval = duration(hours=1)
|
|
|
|
|
|
2026-07-15 16:38:53 +02:00
|
|
|
result = await measurement_eos.load_total_kwh(start_datetime=start_datetime, end_datetime=end_datetime, interval=interval)
|
2025-10-28 02:50:31 +01:00
|
|
|
|
|
|
|
|
# Expected total load per interval
|
|
|
|
|
expected = np.array([100, 100]) # Differences between consecutive meter readings
|
|
|
|
|
np.testing.assert_array_equal(result, expected)
|
|
|
|
|
|
2026-07-15 16:38:53 +02:00
|
|
|
async def test_load_total_kwh_no_data(self, measurement_eos):
|
2025-10-28 02:50:31 +01:00
|
|
|
"""Test total load calculation with no data."""
|
|
|
|
|
measurement_eos.records = []
|
2026-02-22 14:12:42 +01:00
|
|
|
start_datetime = to_datetime("2023-01-01T00:00:00")
|
|
|
|
|
end_datetime = to_datetime("2023-01-01T03:00:00")
|
2025-10-28 02:50:31 +01:00
|
|
|
interval = duration(hours=1)
|
|
|
|
|
|
2026-07-15 16:38:53 +02:00
|
|
|
result = await measurement_eos.load_total_kwh(start_datetime=start_datetime, end_datetime=end_datetime, interval=interval)
|
2025-10-28 02:50:31 +01:00
|
|
|
expected = np.zeros(3) # No data, so all intervals are zero
|
|
|
|
|
np.testing.assert_array_equal(result, expected)
|
|
|
|
|
|
2026-07-15 16:38:53 +02:00
|
|
|
async def test_load_total_kwh_partial_intervals(self, measurement_eos):
|
2025-10-28 02:50:31 +01:00
|
|
|
"""Test total load calculation with partial intervals."""
|
2026-02-22 14:12:42 +01:00
|
|
|
start_datetime = to_datetime("2023-01-01T00:30:00") # Start in the middle of an interval
|
|
|
|
|
end_datetime = to_datetime("2023-01-01T01:30:00") # End in the middle of another interval
|
2025-10-28 02:50:31 +01:00
|
|
|
interval = duration(hours=1)
|
|
|
|
|
|
2026-07-15 16:38:53 +02:00
|
|
|
result = await measurement_eos.load_total_kwh(start_datetime=start_datetime, end_datetime=end_datetime, interval=interval)
|
2025-10-28 02:50:31 +01:00
|
|
|
expected = np.array([100]) # Only one complete interval covered
|
|
|
|
|
np.testing.assert_array_equal(result, expected)
|
2026-10-28 20:15:25 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestMeasurementFileRestore:
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def file_measurements(self, config_eos, tmp_path, monkeypatch):
|
|
|
|
|
measurement = get_measurement()
|
|
|
|
|
config_eos.measurement.load_emr_keys = ["meter"]
|
|
|
|
|
config_eos.general.data_folder_path = tmp_path
|
|
|
|
|
config_eos.database.provider = None
|
|
|
|
|
measurement._db_reset_state()
|
|
|
|
|
monkeypatch.setattr(DataSequence, "save", AsyncMock(return_value=False))
|
|
|
|
|
monkeypatch.setattr(DataSequence, "load", AsyncMock(return_value=False))
|
|
|
|
|
try:
|
|
|
|
|
yield measurement
|
|
|
|
|
finally:
|
|
|
|
|
measurement._db_reset_state()
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_json_roundtrip_restores_records_into_existing_singleton(self, file_measurements):
|
|
|
|
|
measurement = file_measurements
|
|
|
|
|
await measurement.update_value(to_datetime("2026-09-16T08:00:00Z"), "meter", 123.5)
|
|
|
|
|
await measurement.update_value("2026-09-16T09:00:00Z", "meter", 124.0)
|
|
|
|
|
assert await measurement.save()
|
|
|
|
|
measurement._db_reset_state()
|
|
|
|
|
|
|
|
|
|
assert await measurement.load()
|
|
|
|
|
assert get_measurement() is measurement
|
|
|
|
|
assert [record["meter"] for record in measurement.records] == [123.5, 124.0]
|
|
|
|
|
restored_hours = []
|
|
|
|
|
for record in measurement.records:
|
|
|
|
|
assert record.date_time is not None
|
|
|
|
|
restored_hours.append(record.date_time.in_timezone("UTC").hour)
|
|
|
|
|
assert restored_hours == [8, 9]
|
|
|
|
|
# Reloading must update matching timestamps, without duplicating them.
|
|
|
|
|
assert await measurement.load()
|
|
|
|
|
assert len(measurement.records) == 2
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_json_restore_preserves_other_in_memory_timestamps(self, file_measurements):
|
|
|
|
|
measurement = file_measurements
|
|
|
|
|
await measurement.update_value("2026-09-16T08:00:00Z", "meter", 123.5)
|
|
|
|
|
assert await measurement.save()
|
|
|
|
|
measurement._db_reset_state()
|
|
|
|
|
await measurement.update_value("2026-09-16T09:00:00Z", "meter", 124.0)
|
|
|
|
|
assert await measurement.load()
|
|
|
|
|
assert [record["meter"] for record in measurement.records] == [123.5, 124.0]
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
@pytest.mark.parametrize("payload", [
|
|
|
|
|
"invalid json",
|
|
|
|
|
json.dumps({"records": [
|
|
|
|
|
{"date_time": "2026-09-16T08:00:00Z", "meter": 123.5},
|
|
|
|
|
{"date_time": "not a date", "meter": 124.0},
|
|
|
|
|
]}),
|
|
|
|
|
])
|
|
|
|
|
async def test_invalid_json_does_not_partially_restore(self, file_measurements, payload):
|
|
|
|
|
measurement = file_measurements
|
|
|
|
|
measurement._measurement_file_path().write_text(payload, encoding="utf-8")
|
|
|
|
|
assert await measurement.load() is False
|
|
|
|
|
assert measurement.records == []
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_database_success_does_not_read_json(self, file_measurements, monkeypatch):
|
|
|
|
|
measurement = file_measurements
|
|
|
|
|
measurement._measurement_file_path().write_text("invalid json", encoding="utf-8")
|
|
|
|
|
monkeypatch.setattr(DataSequence, "load", AsyncMock(return_value=True))
|
|
|
|
|
assert await measurement.load()
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_missing_json_reports_not_loaded(self, file_measurements):
|
|
|
|
|
assert await file_measurements.load() is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestMeasurementChannels:
|
|
|
|
|
@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(self, 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(self, legacy_field):
|
|
|
|
|
settings = MeasurementCommonSettings.model_validate(dict(**{legacy_field: ["legacy"]}, channels=CHANNELS))
|
|
|
|
|
assert settings.keys == sorted(["legacy", *CHANNELS])
|
|
|
|
|
compatible = MeasurementCommonSettings.model_validate(dict(
|
|
|
|
|
**{legacy_field: ["legacy"]}, channels={"legacy": CHANNELS["house_meter"]}
|
|
|
|
|
))
|
|
|
|
|
assert compatible.keys == ["legacy"]
|
|
|
|
|
with pytest.raises(ValidationError, match="must remain"):
|
|
|
|
|
MeasurementCommonSettings.model_validate(dict(
|
|
|
|
|
**{legacy_field: ["legacy"]}, channels={"legacy": CHANNELS["house_power"]}
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("key", ["", " x", "date_time", "configured_data", "keys", "_private"])
|
|
|
|
|
def test_reject_reserved_key(self, key):
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
MeasurementCommonSettings.model_validate(dict(channels={key: CHANNELS["house_meter"]}))
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_existing_import_and_file_reload(self, config_eos, tmp_path, monkeypatch):
|
|
|
|
|
"""All three quantities retain their raw values through the existing file path."""
|
|
|
|
|
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.model_validate(dict(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(to_datetime("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
|