mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-09-11 10:26:38 +00:00
The isolated pre-commit mypy hook previously omitted runtime type information that make mypy used, hiding errors involving dependencies such as Pydantic and Pendulum. Makefile, pre-commit and CI now run the same full-project typing policy in the development environment defined by uv.lock. - Use uv run --locked --exact --extra dev and the same mypy arguments for Makefile and the local hook. Check all of src and tests, including on configuration-only changes. - Pin Python 3.13 for local development and the pre-commit CI job, and install the locked pre-commit version in CI. - Disable incremental analysis because existing Pendulum cache state changes mypy 2.3.1 diagnostics. Document the policy, the performance tradeoff and the existing typing debt. - Add a regression test that exercises Makefile, the hook and the CI command in a temporary project, accepting valid dependency types and detecting deliberate Pydantic/Pendulum assignment errors. Resolve the newly detected mypy diagnostics. - Enable the numpydantic and Pydantic mypy plugins, retaining strict Pydantic constructor typing with init_typed = true. Validate raw/coercible payloads through model_validate. - Propagate concrete record, provider and time-window types through generic collections, factories and lookup methods. Preserve runtime field inspection and generated time-window documentation. - Align Pendulum annotations with actual factory/arithmetic results while retaining Pydantic validation adapters at runtime. Correct optional values, array boundaries, REST handlers and plotting interfaces. - Add pinned scipy-stubs and types-psutil, update uv.lock, and supply the plugins' dependencies. - Add runtime regression coverage for validated path defaults, normalized time-series metadata, generic field inspection, invalid timestamps and unsupported provider imports. Runtime and compatibility details: - Validate path defaults as Path objects while retaining raw string defaults needed by migration serialization with exclude_defaults. - Normalize feed-in tariff lists and default charge rates to NumPy arrays; reject missing timestamps/uninitialized values explicitly. Importing into a provider without import support returns HTTP 400. - Public JSON schemas and OpenAPI structure match main (excluding the generated version). Signed-off-by: dr-dimitry Signed-off-by: dr-dimitry Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com> Co-authored-by: dr-dimitri <87113560+dr-dimitri@users.noreply.github.com> Co-authored-by: Normann <github@koldrack.com>
278 lines
10 KiB
Python
278 lines
10 KiB
Python
"""Tests for fixed electricity price prediction module."""
|
||
|
||
import asyncio
|
||
import json
|
||
from pathlib import Path
|
||
from unittest.mock import Mock, patch
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
import pytest
|
||
|
||
from akkudoktoreos.config.configabc import ValueTimeWindow, ValueTimeWindowSequence
|
||
from akkudoktoreos.core.cache import CacheFileStore
|
||
from akkudoktoreos.core.coreabc import get_ems
|
||
from akkudoktoreos.prediction.elecpricefixed import (
|
||
ElecPriceFixed,
|
||
ElecPriceFixedCommonSettings,
|
||
)
|
||
from akkudoktoreos.utils.datetimeutil import Duration, to_datetime
|
||
|
||
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
|
||
FILE_TESTDATA_ELECPRICEFIXED_CONFIG_JSON = DIR_TESTDATA.joinpath("elecpricefixed_config.json")
|
||
|
||
|
||
class TestElecPriceFixedCommonSettings:
|
||
"""Tests for ElecPriceFixedCommonSettings model."""
|
||
|
||
def test_create_settings_with_windows(self):
|
||
"""Test creating settings with time windows."""
|
||
settings_dict = {
|
||
"elecprice_marketprice_amt_kwh": {
|
||
"windows": [
|
||
{
|
||
"start_time": "00:00",
|
||
"duration": "8 hours",
|
||
"value": 0.288
|
||
},
|
||
{
|
||
"start_time": "08:00",
|
||
"duration": "16 hours",
|
||
"value": 0.34
|
||
}
|
||
]
|
||
}
|
||
}
|
||
|
||
settings = ElecPriceFixedCommonSettings.model_validate(settings_dict)
|
||
assert settings is not None
|
||
assert settings.elecprice_marketprice_amt_kwh is not None
|
||
assert settings.elecprice_marketprice_amt_kwh.windows is not None
|
||
assert len(settings.elecprice_marketprice_amt_kwh.windows) == 2
|
||
|
||
def test_create_settings_without_windows(self):
|
||
"""Test creating settings without time windows."""
|
||
settings = ElecPriceFixedCommonSettings()
|
||
assert settings.elecprice_marketprice_amt_kwh is not None
|
||
assert settings.elecprice_marketprice_amt_kwh.windows == []
|
||
|
||
|
||
@pytest.fixture
|
||
def provider(config_eos):
|
||
"""Fixture to create a ElecPriceFixed provider instance."""
|
||
# Create settings and assign to config
|
||
config_eos.merge_settings_from_dict(
|
||
{
|
||
"elecprice": {
|
||
"provider": "ElecPriceFixed",
|
||
},
|
||
}
|
||
)
|
||
# Create time windows
|
||
elecprice_marketprice_amt_kwh = ValueTimeWindowSequence(
|
||
windows=[
|
||
ValueTimeWindow.model_validate(dict(
|
||
start_time="00:00",
|
||
duration="8 hours",
|
||
value=0.288
|
||
)),
|
||
ValueTimeWindow.model_validate(dict(
|
||
start_time="08:00",
|
||
duration="16 hours",
|
||
value=0.34
|
||
))
|
||
]
|
||
)
|
||
config_eos.elecprice.elecpricefixed = ElecPriceFixedCommonSettings(elecprice_marketprice_amt_kwh=elecprice_marketprice_amt_kwh)
|
||
provider = ElecPriceFixed()
|
||
assert provider.enabled()
|
||
provider._db_reset_state()
|
||
return provider
|
||
|
||
|
||
@pytest.fixture
|
||
def cache_store():
|
||
"""A pytest fixture that creates a new CacheFileStore instance for testing."""
|
||
return CacheFileStore()
|
||
|
||
|
||
class TestElecPriceFixed:
|
||
"""Tests for ElecPriceFixed provider."""
|
||
|
||
def test_provider_id(self, provider):
|
||
"""Test provider ID returns correct value."""
|
||
assert provider.provider_id() == "ElecPriceFixed"
|
||
|
||
def test_singleton_instance(self, provider):
|
||
"""Test that ElecPriceFixed behaves as a singleton."""
|
||
another_instance = ElecPriceFixed()
|
||
assert provider is another_instance
|
||
|
||
def test_invalid_provider(self, provider, monkeypatch):
|
||
"""Test requesting an unsupported provider."""
|
||
monkeypatch.setenv("EOS_ELECPRICE__ELECPRICE_PROVIDER", "<invalid>")
|
||
provider.config.reset_settings()
|
||
assert not provider.enabled()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_update_data_15min_intervals(self, provider, config_eos):
|
||
"""Test updating data with 15-minute intervals (900s)."""
|
||
ems_eos = get_ems()
|
||
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
|
||
ems_eos.set_start_datetime(start_dt)
|
||
|
||
config_eos.prediction.hours = 10 # spans both windows: 00:00–10:00 = 40 intervals
|
||
|
||
await provider.update_data(force_enable=True, force_update=True)
|
||
|
||
# 10 hours * 4 intervals per hour = 40 intervals
|
||
assert len(provider) == 40
|
||
|
||
records = provider.records
|
||
|
||
# Check timestamps are on 15-minute boundaries
|
||
for record in records:
|
||
assert record.date_time.minute in (0, 15, 30, 45)
|
||
assert record.date_time.second == 0
|
||
|
||
# First 32 intervals: 00:00–08:00, night rate (8h * 4 = 32)
|
||
for i in range(32):
|
||
assert abs(records[i].elecprice_marketprice_wh - 0.000288) < 1e-6, (
|
||
f"Expected night rate at interval {i}, got {records[i].elecprice_marketprice_wh}"
|
||
)
|
||
|
||
# Remaining 8 intervals: 08:00–10:00, day rate (2h * 4 = 8)
|
||
for i in range(32, 40):
|
||
assert abs(records[i].elecprice_marketprice_wh - 0.00034) < 1e-6, (
|
||
f"Expected day rate at interval {i}, got {records[i].elecprice_marketprice_wh}"
|
||
)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_update_data_without_config(self, caplog, provider, config_eos):
|
||
"""Test update_data fails without configuration."""
|
||
# Remove elecpricefixed settings
|
||
config_eos.elecprice.elecpricefixed = {}
|
||
|
||
with caplog.at_level("WARNING"):
|
||
await provider.update_data(force_enable=True, force_update=True)
|
||
assert "No time windows configured for `elecprice_marketprice_raw_wh`" in caplog.text
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_update_data_without_elecprice_marketprice_amt_kwh(self, caplog, provider, config_eos):
|
||
"""Test update_data fails without time windows."""
|
||
# Set empty time windows
|
||
empty_settings = ElecPriceFixedCommonSettings(elecprice_marketprice_amt_kwh=ValueTimeWindowSequence(windows=[]))
|
||
config_eos.elecprice.elecpricefixed = empty_settings
|
||
|
||
with caplog.at_level("WARNING"):
|
||
await provider.update_data(force_enable=True, force_update=True)
|
||
assert "No time windows configured for `elecprice_marketprice_raw_wh`" in caplog.text
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_key_to_array_resampling(self, provider, config_eos):
|
||
"""Test that key_to_array can resample to different intervals."""
|
||
# Provider provides 15-minutes data
|
||
ems_eos = get_ems()
|
||
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
|
||
ems_eos.set_start_datetime(start_dt)
|
||
|
||
config_eos.prediction.hours = 24
|
||
|
||
await provider.update_data(force_enable=True, force_update=True)
|
||
|
||
# Get data as hourly array (original)
|
||
hourly_array = await provider.key_to_array(
|
||
key="elecprice_marketprice_wh",
|
||
start_datetime=start_dt,
|
||
end_datetime=start_dt.add(hours=24),
|
||
fill_method="ffill",
|
||
)
|
||
|
||
assert len(hourly_array) == 24
|
||
assert abs(hourly_array[0] - 0.000288) < 1e-6 # Night rate
|
||
assert abs(hourly_array[8] - 0.00034) < 1e-6 # Day rate
|
||
|
||
# Resample to 15-minute intervals
|
||
quarter_hour_array = await provider.key_to_array(
|
||
key="elecprice_marketprice_wh",
|
||
start_datetime=start_dt,
|
||
end_datetime=start_dt.add(hours=24),
|
||
interval="15 minutes",
|
||
fill_method="ffill",
|
||
)
|
||
|
||
assert len(quarter_hour_array) == 96 # 24 * 4
|
||
# First 4 15-min intervals should be night rate
|
||
for i in range(4):
|
||
assert abs(quarter_hour_array[i] - 0.000288) < 1e-6
|
||
|
||
# Resample to 30-minute intervals
|
||
half_hour_array = await provider.key_to_array(
|
||
key="elecprice_marketprice_wh",
|
||
start_datetime=start_dt,
|
||
end_datetime=start_dt.add(hours=24),
|
||
interval="30 minutes",
|
||
fill_method="ffill",
|
||
)
|
||
|
||
assert len(half_hour_array) == 48 # 24 * 2
|
||
# First 2 30-min intervals should be night rate
|
||
for i in range(2):
|
||
assert abs(half_hour_array[i] - 0.000288) < 1e-6
|
||
|
||
|
||
class TestElecPriceFixedIntegration:
|
||
"""Integration tests for ElecPriceFixed."""
|
||
|
||
@pytest.mark.skip(reason="For development only")
|
||
async def test_fixed_price_development(self, config_eos):
|
||
"""Test fixed price provider with real configuration."""
|
||
# Create provider with config
|
||
provider = ElecPriceFixed()
|
||
|
||
# Setup realistic test scenario
|
||
ems_eos = get_ems()
|
||
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
|
||
ems_eos.set_start_datetime(start_dt)
|
||
|
||
# Configure with realistic German electricity prices (2024)
|
||
elecprice_marketprice_amt_kwh = ValueTimeWindowSequence(
|
||
windows=[
|
||
ValueTimeWindow.model_validate(dict(
|
||
start_time="00:00",
|
||
duration="8 hours",
|
||
value=0.288 # Night rate
|
||
)),
|
||
ValueTimeWindow.model_validate(dict(
|
||
start_time="08:00",
|
||
duration="16 hours",
|
||
value=0.34 # Day rate
|
||
))
|
||
]
|
||
)
|
||
|
||
config_eos.elecprice.elecpricefixed = ElecPriceFixedCommonSettings(elecprice_marketprice_amt_kwh=elecprice_marketprice_amt_kwh)
|
||
config_eos.prediction.hours = 168 # 7 days
|
||
|
||
# Update data
|
||
await provider.update_data(force_enable=True, force_update=True)
|
||
|
||
# Verify data
|
||
expected_intervals = 168 * 4 # 7 days * 24h * 4 intervals
|
||
assert len(provider) == expected_intervals
|
||
|
||
# Save configuration for documentation
|
||
config_data = {
|
||
"elecprice_marketprice_amt_kwh": [
|
||
{
|
||
"start_time": str(window.start_time),
|
||
"duration": str(window.duration),
|
||
"value": window.value
|
||
}
|
||
for window in config_eos.elecprice.elecpricefixed.elecprice_marketprice_amt_kwh.windows
|
||
]
|
||
}
|
||
|
||
with FILE_TESTDATA_ELECPRICEFIXED_CONFIG_JSON.open("w", encoding="utf-8") as f:
|
||
json.dump(config_data, f, indent=4)
|