fix: unify mypy environments for local checks and CI (#1291)

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>
This commit is contained in:
Bobby Noelte
2026-09-10 23:20:35 +02:00
committed by GitHub
co-authored by dr-dimitri Normann
parent 5b584cbb57
commit 1abdd345c4
114 changed files with 2217 additions and 1322 deletions
+5 -5
View File
@@ -14,7 +14,7 @@ from contextlib import contextmanager
from fnmatch import fnmatch
from http import HTTPStatus
from pathlib import Path
from typing import Generator, Optional, Union
from typing import Callable, Generator, Optional, Union, cast
from unittest.mock import PropertyMock, patch
import pandas as pd
@@ -377,7 +377,7 @@ def config_eos_factory(
# Check user data directory pathes (config_default_dirs[-1] == data_default_dir_user)
assert config_eos.general.data_folder_path == data_folder_path
assert config_eos.general.data_output_subpath == Path("output")
assert config_eos.cache.subpath == "cache"
assert config_eos.cache.subpath == Path("cache")
assert config_eos.cache.path() == config_default_dirs[-1] / "data/cache"
assert config_eos.logging.file_path == config_default_dirs[-1] / "data/output/eos.log"
@@ -446,7 +446,7 @@ def cleanup_eos_eosdash(
pids: list[int] = []
for _ in range(int(server_timeout / 3)):
for conn in psutil.net_connections(kind="inet"):
if conn.laddr.port == port and conn.pid is not None:
if conn.laddr and conn.laddr.port == port and conn.pid is not None:
try:
process = psutil.Process(conn.pid)
cmdline = process.as_dict(attrs=["cmdline"])["cmdline"]
@@ -497,7 +497,7 @@ def cleanup_eos_eosdash(
pids = []
for _ in range(int(server_timeout / 3)):
for conn in psutil.net_connections(kind="inet"):
if conn.laddr.port in (eosdash_port, 8504, 8555) and conn.pid is not None:
if conn.laddr and conn.laddr.port in (eosdash_port, 8504, 8555) and conn.pid is not None:
try:
process = psutil.Process(conn.pid)
cmdline = process.as_dict(attrs=["cmdline"])["cmdline"]
@@ -766,5 +766,5 @@ def set_other_timezone():
yield _set_timezone
# Restore the original timezone
pendulum.set_local_timezone(original_timezone)
cast(Callable[[pendulum.Timezone | pendulum.FixedTimezone], None], pendulum.set_local_timezone)(original_timezone)
assert pendulum.local_timezone() == original_timezone
+4 -8
View File
@@ -170,8 +170,7 @@ async def prepare_optimization_real_parameters() -> Genetic0OptimizationParamete
print(f"start_solution: {start_solution}")
# Define parameters for the optimization problem
return Genetic0OptimizationParameters(
**{
return Genetic0OptimizationParameters.model_validate({
"ems": {
"price_per_wh_battery": 0e-05,
"feed_in_tariff_per_wh": 7e-05,
@@ -200,8 +199,7 @@ async def prepare_optimization_real_parameters() -> Genetic0OptimizationParamete
},
"temperature_forecast": temperature_forecast,
"start_solution": start_solution,
}
)
})
def prepare_optimization_parameters() -> Genetic0OptimizationParameters:
@@ -366,8 +364,7 @@ def prepare_optimization_parameters() -> Genetic0OptimizationParameters:
start_solution = None
# Define parameters for the optimization problem
return Genetic0OptimizationParameters(
**{
return Genetic0OptimizationParameters.model_validate({
"ems": {
"price_per_wh_battery": 0e-05,
"feed_in_tariff_per_wh": 7e-05,
@@ -396,8 +393,7 @@ def prepare_optimization_parameters() -> Genetic0OptimizationParameters:
},
"temperature_forecast": temperature_forecast,
"start_solution": start_solution,
}
)
})
def run_optimization(
+4 -8
View File
@@ -171,8 +171,7 @@ async def prepare_optimization_real_parameters() -> GeneticOptimizationParameter
print(f"start_solution: {start_solution}")
# Define parameters for the optimization problem
return GeneticOptimizationParameters(
**{
return GeneticOptimizationParameters.model_validate({
"ems": {
"price_per_wh_battery": 0e-05,
"feed_in_tariff_per_wh": 7e-05,
@@ -201,8 +200,7 @@ async def prepare_optimization_real_parameters() -> GeneticOptimizationParameter
},
"temperature_forecast": temperature_forecast,
"start_solution": start_solution,
}
)
})
def prepare_optimization_parameters() -> GeneticOptimizationParameters:
@@ -367,8 +365,7 @@ def prepare_optimization_parameters() -> GeneticOptimizationParameters:
start_solution = None
# Define parameters for the optimization problem
return GeneticOptimizationParameters(
**{
return GeneticOptimizationParameters.model_validate({
"ems": {
"price_per_wh_battery": 0e-05,
"feed_in_tariff_per_wh": 7e-05,
@@ -397,8 +394,7 @@ def prepare_optimization_parameters() -> GeneticOptimizationParameters:
},
"temperature_forecast": temperature_forecast,
"start_solution": start_solution,
}
)
})
def run_optimization(
+1
View File
@@ -62,6 +62,7 @@ class TestNodeREDAdapter:
await adapter.update_data(force_enable=True)
mock_get.assert_called_once()
assert adapter.update_datetime is not None
assert compare_datetimes(adapter.update_datetime, now).approximately_equal
@pytest.mark.asyncio
+7 -2
View File
@@ -17,7 +17,12 @@ from akkudoktoreos.core.cache import (
cache_energy_management,
cache_in_file,
)
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration
from akkudoktoreos.utils.datetimeutil import (
Duration,
compare_datetimes,
to_datetime,
to_duration,
)
# ---------------------------------
# In-Memory Caching Functionality
@@ -257,7 +262,7 @@ class TestCacheFileStore:
assert ttl_duration is None
# -- From now on we expect a until_datetime in one hour
ttl_duration_expected = to_duration("1 hour")
ttl_duration_expected: Duration | None = to_duration("1 hour")
# Test with with_ttl as timedelta
until_datetime_expected = to_datetime().add(hours=1)
+2 -2
View File
@@ -256,11 +256,11 @@ def test_config_common_settings_invalid(field_name, invalid_value, expected_erro
"latitude": 40.7128,
"longitude": -74.0060,
}
assert GeneralSettings(**valid_data) is not None
assert GeneralSettings.model_validate(valid_data) is not None
valid_data[field_name] = invalid_value
with pytest.raises(ValidationError, match=expected_error):
GeneralSettings(**valid_data)
GeneralSettings.model_validate(valid_data)
def test_config_common_settings_no_location():
+51 -51
View File
@@ -55,11 +55,11 @@ def aware_dt(year, month, day, hour=0, minute=0, second=0, tz="Europe/Berlin"):
def make_window(start_h, duration_h, **kwargs):
"""Build a TimeWindow with a naive start_time at ``start_h:00``."""
return TimeWindow(
return TimeWindow.model_validate(dict(
start_time=f"{start_h:02d}:00:00",
duration=f"{duration_h} hours",
**kwargs,
)
))
# ===========================================================================
@@ -73,10 +73,10 @@ class TestTimeWindowConstruction:
def test_aware_start_time_stripped_to_naive(self):
"""An aware start_time is silently stripped to naive (to_time may add a tz)."""
w = TimeWindow(
w = TimeWindow.model_validate(dict(
start_time=Time(8, 0, 0, tzinfo=pendulum.timezone("Europe/Berlin")),
duration="2 hours",
)
))
assert w.start_time.tzinfo is None
assert w.start_time.hour == 8
@@ -375,7 +375,7 @@ class TestFitAndAvailable:
class TestTimeWindowSequence:
def setup_method(self, method):
self.seq = TimeWindowSequence(
self.seq = TimeWindowSequence[TimeWindow](
windows=[
make_window(8, 2), # 08:0010:00
make_window(14, 3), # 14:0017:00
@@ -417,15 +417,15 @@ class TestTimeWindowSequence:
assert result == pendulum.duration(hours=5)
def test_empty_sequence_contains_false(self):
seq = TimeWindowSequence()
seq = TimeWindowSequence[TimeWindow]()
assert not seq.contains(naive_dt(2024, 6, 15, 9, 0, 0))
def test_empty_sequence_earliest_none(self):
seq = TimeWindowSequence()
seq = TimeWindowSequence[TimeWindow]()
assert seq.earliest_start_time(pendulum.duration(hours=1), naive_dt(2024, 6, 15)) is None
def test_empty_sequence_available_none(self):
seq = TimeWindowSequence()
seq = TimeWindowSequence[TimeWindow]()
assert seq.available_duration(naive_dt(2024, 6, 15)) is None
def test_get_applicable_windows(self):
@@ -445,7 +445,7 @@ class TestTimeWindowSequence:
assert fits[0].start_time.hour == 14
def test_sort_windows_by_start_time(self):
seq = TimeWindowSequence(
seq = TimeWindowSequence[TimeWindow](
windows=[make_window(14, 1), make_window(8, 1)]
)
ref = naive_dt(2024, 6, 15)
@@ -454,7 +454,7 @@ class TestTimeWindowSequence:
assert seq.windows[1].start_time.hour == 14
def test_add_and_remove_window(self):
seq = TimeWindowSequence()
seq = TimeWindowSequence[TimeWindow]()
w = make_window(10, 1)
seq.add_window(w)
assert len(seq) == 1
@@ -463,7 +463,7 @@ class TestTimeWindowSequence:
assert len(seq) == 0
def test_remove_from_empty_raises(self):
seq = TimeWindowSequence()
seq = TimeWindowSequence[TimeWindow]()
with pytest.raises(IndexError):
seq.remove_window(0)
@@ -487,20 +487,20 @@ class TestTimeWindowSequence:
class TestValueTimeWindow:
def test_value_stored(self):
w = ValueTimeWindow(start_time="08:00:00", duration="2 hours", value=0.288)
w = ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="2 hours", value=0.288))
assert w.value == pytest.approx(0.288)
def test_value_default_none(self):
w = ValueTimeWindow(start_time="08:00:00", duration="2 hours")
w = ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="2 hours"))
assert w.value is None
def test_inherits_aware_start_time_stripped(self):
"""ValueTimeWindow inherits the strip-to-naive behaviour from TimeWindow."""
w = ValueTimeWindow(
w = ValueTimeWindow.model_validate(dict(
start_time=Time(8, 0, 0, tzinfo=pendulum.timezone("UTC")),
duration="2 hours",
value=0.1,
)
))
assert w.start_time.tzinfo is None
assert w.start_time.hour == 8
@@ -509,8 +509,8 @@ class TestValueTimeWindowSequence:
def setup_method(self, method):
self.seq = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="08:00:00", duration="4 hours", value=0.25),
ValueTimeWindow(start_time="18:00:00", duration="4 hours", value=0.35),
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="4 hours", value=0.25)),
ValueTimeWindow.model_validate(dict(start_time="18:00:00", duration="4 hours", value=0.35)),
]
)
@@ -528,7 +528,7 @@ class TestValueTimeWindowSequence:
def test_get_value_none_value_returns_zero(self):
seq = ValueTimeWindowSequence(
windows=[ValueTimeWindow(start_time="08:00:00", duration="4 hours", value=None)]
windows=[ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="4 hours", value=None))]
)
assert seq.get_value_for_datetime(naive_dt(2024, 6, 15, 9, 0, 0)) == pytest.approx(0.0)
@@ -552,7 +552,7 @@ class TestTimeWindowSequenceToArray:
"""
def setup_method(self, method):
self.seq = TimeWindowSequence(
self.seq = TimeWindowSequence[TimeWindow](
windows=[
make_window(8, 2), # 08:0010:00
make_window(14, 3), # 14:0017:00
@@ -697,7 +697,7 @@ class TestTimeWindowSequenceToArray:
# ------------------------------------------------------------------
def test_empty_sequence_all_zeros(self):
seq = TimeWindowSequence()
seq = TimeWindowSequence[TimeWindow]()
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 15, 4)
arr = seq.to_array(start, end, pendulum.duration(hours=1))
@@ -710,7 +710,7 @@ class TestTimeWindowSequenceToArray:
def test_day_of_week_constraint_respected(self):
# Monday-only window; 2024-06-17 is Monday, 2024-06-18 is Tuesday
seq = TimeWindowSequence(windows=[make_window(8, 2, day_of_week=0)])
seq = TimeWindowSequence[TimeWindow](windows=[make_window(8, 2, day_of_week=0)])
monday_start = naive_dt(2024, 6, 17, 7)
tuesday_start = naive_dt(2024, 6, 18, 7)
end_offset = pendulum.duration(hours=4)
@@ -737,7 +737,7 @@ class TestTimeWindowSequenceToSeries:
"""
def setup_method(self, method):
self.seq = TimeWindowSequence(
self.seq = TimeWindowSequence[TimeWindow](
windows=[
make_window(8, 2),
make_window(14, 3),
@@ -858,7 +858,7 @@ class TestTimeWindowSequenceToSeries:
)
def test_empty_sequence_all_zeros(self):
seq = TimeWindowSequence()
seq = TimeWindowSequence[TimeWindow]()
start = naive_dt(2024, 6, 15, 0)
end = naive_dt(2024, 6, 15, 4)
@@ -885,8 +885,8 @@ class TestValueTimeWindowSequenceToArray:
def setup_method(self, method):
self.seq = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="08:00:00", duration="4 hours", value=0.25),
ValueTimeWindow(start_time="18:00:00", duration="4 hours", value=0.35),
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="4 hours", value=0.25)),
ValueTimeWindow.model_validate(dict(start_time="18:00:00", duration="4 hours", value=0.35)),
]
)
@@ -939,8 +939,8 @@ class TestValueTimeWindowSequenceToArray:
def test_dropna_false_none_value_emits_nan(self):
seq = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="08:00:00", duration="2 hours", value=None),
ValueTimeWindow(start_time="12:00:00", duration="2 hours", value=0.5),
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="2 hours", value=None)),
ValueTimeWindow.model_validate(dict(start_time="12:00:00", duration="2 hours", value=0.5)),
]
)
start = naive_dt(2024, 6, 15, 8)
@@ -956,8 +956,8 @@ class TestValueTimeWindowSequenceToArray:
def test_dropna_true_none_value_step_omitted(self):
seq = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="08:00:00", duration="2 hours", value=None),
ValueTimeWindow(start_time="12:00:00", duration="2 hours", value=0.5),
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="2 hours", value=None)),
ValueTimeWindow.model_validate(dict(start_time="12:00:00", duration="2 hours", value=0.5)),
]
)
start = naive_dt(2024, 6, 15, 8)
@@ -1018,8 +1018,8 @@ class TestValueTimeWindowSequenceToArray:
def test_overlapping_windows_first_wins(self):
seq = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="08:00:00", duration="4 hours", value=0.10),
ValueTimeWindow(start_time="09:00:00", duration="4 hours", value=0.99),
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="4 hours", value=0.10)),
ValueTimeWindow.model_validate(dict(start_time="09:00:00", duration="4 hours", value=0.99)),
]
)
start = naive_dt(2024, 6, 15, 9)
@@ -1040,16 +1040,16 @@ class TestValueTimeWindowSequenceToSeries:
def setup_method(self, method):
self.seq = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(
ValueTimeWindow.model_validate(dict(
start_time="08:00:00",
duration="4 hours",
value=0.25,
),
ValueTimeWindow(
)),
ValueTimeWindow.model_validate(dict(
start_time="18:00:00",
duration="4 hours",
value=0.35,
),
)),
]
)
@@ -1096,16 +1096,16 @@ class TestValueTimeWindowSequenceToSeries:
def test_dropna_false_none_value_emits_nan(self):
seq = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(
ValueTimeWindow.model_validate(dict(
start_time="08:00:00",
duration="2 hours",
value=None,
),
ValueTimeWindow(
)),
ValueTimeWindow.model_validate(dict(
start_time="12:00:00",
duration="2 hours",
value=0.5,
),
)),
]
)
@@ -1134,16 +1134,16 @@ class TestValueTimeWindowSequenceToSeries:
def test_dropna_true_none_value_omits_timestamp(self):
seq = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(
ValueTimeWindow.model_validate(dict(
start_time="08:00:00",
duration="2 hours",
value=None,
),
ValueTimeWindow(
)),
ValueTimeWindow.model_validate(dict(
start_time="12:00:00",
duration="2 hours",
value=0.5,
),
)),
]
)
@@ -1254,16 +1254,16 @@ class TestValueTimeWindowSequenceToSeries:
def test_overlapping_windows_first_wins(self):
seq = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(
ValueTimeWindow.model_validate(dict(
start_time="08:00:00",
duration="4 hours",
value=0.10,
),
ValueTimeWindow(
)),
ValueTimeWindow.model_validate(dict(
start_time="09:00:00",
duration="4 hours",
value=0.99,
),
)),
]
)
@@ -1452,7 +1452,7 @@ class TestAlignToIntervalTimezoneInvariance:
def test_vtws_naive_floor_utc(self, set_other_timezone):
set_other_timezone("UTC")
seq = ValueTimeWindowSequence(windows=[
ValueTimeWindow(start_time="08:00:00", duration="2 hours", value=0.25)
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="2 hours", value=0.25))
])
start = naive_dt(2024, 6, 15, 8, 10)
end = naive_dt(2024, 6, 15, 10, 10)
@@ -1465,7 +1465,7 @@ class TestAlignToIntervalTimezoneInvariance:
def test_vtws_naive_floor_non_utc(self, set_other_timezone):
set_other_timezone()
seq = ValueTimeWindowSequence(windows=[
ValueTimeWindow(start_time="08:00:00", duration="2 hours", value=0.25)
ValueTimeWindow.model_validate(dict(start_time="08:00:00", duration="2 hours", value=0.25))
])
start = naive_dt(2024, 6, 15, 8, 10)
end = naive_dt(2024, 6, 15, 10, 10)
@@ -1480,11 +1480,11 @@ class TestAlignToIntervalTimezoneInvariance:
seq = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(
ValueTimeWindow.model_validate(dict(
start_time="08:00:00",
duration="2 hours",
value=0.25,
)
))
]
)
+8 -7
View File
@@ -15,6 +15,7 @@ from typing import List, Optional, Type
import numpy as np
import pytest
import pytest_asyncio
from pendulum import UTC
from pydantic import Field
from akkudoktoreos.core.coreabc import get_database
@@ -44,7 +45,7 @@ class EnergyRecord(DataRecord):
)
class EnergySequence(DataSequence):
class EnergySequence(DataSequence[EnergyRecord]):
records: List[EnergyRecord] = Field(
default_factory=list,
json_schema_extra={"description": "List of energy records"},
@@ -58,7 +59,7 @@ class EnergySequence(DataSequence):
return "energy_test"
class PriceSequence(DataSequence):
class PriceSequence(DataSequence[EnergyRecord]):
"""Price data — overrides tiers to keep 15-min resolution for 2 weeks."""
records: List[EnergyRecord] = Field(
@@ -78,7 +79,7 @@ class PriceSequence(DataSequence):
return [(to_duration("14 days"), to_duration("1 hour"))]
class EnergyProvider(DataProvider):
class EnergyProvider(DataProvider[EnergyRecord]):
records: List[EnergyRecord] = Field(
default_factory=list,
json_schema_extra={"description": "List of energy records"},
@@ -101,7 +102,7 @@ class EnergyProvider(DataProvider):
return self.provider_id()
class PriceProvider(DataProvider):
class PriceProvider(DataProvider[EnergyRecord]):
records: List[EnergyRecord] = Field(
default_factory=list,
json_schema_extra={"description": "List of price records"},
@@ -181,7 +182,7 @@ def _reset_singletons() -> None:
"""
for cls in (EnergySequence, PriceSequence, EnergyProvider, PriceProvider, EnergyContainer):
try:
cls.reset_instance()
getattr(cls, "reset_instance")()
except Exception:
pass
@@ -691,7 +692,7 @@ class TestDataSequenceCompactIntegrity:
# DatabaseTimestamp already imported at top of file
db_max_epoch = int(DatabaseTimestamp.to_datetime(db_max_ts).timestamp())
two_weeks_cutoff_epoch = ((db_max_epoch - 14*24*3600) // 3600) * 3600
two_weeks_cutoff_dt = DateTime.fromtimestamp(two_weeks_cutoff_epoch, tz="UTC")
two_weeks_cutoff_dt = DateTime.fromtimestamp(two_weeks_cutoff_epoch, tz=UTC)
old_records = [r for r in seq.records if r.date_time and r.date_time < two_weeks_cutoff_dt]
@@ -986,7 +987,7 @@ class TestDataSequenceSparseGuard:
margin_sec = (max_offset + 2 * interval_minutes + 1) * 60
raw_base_epoch = window_end_epoch - margin_sec
base_epoch = (raw_base_epoch // interval_sec) * interval_sec
base = DateTime.fromtimestamp(base_epoch, tz="UTC")
base = DateTime.fromtimestamp(base_epoch, tz=UTC)
dts = []
for off in offsets_minutes:
+1 -1
View File
@@ -37,7 +37,7 @@ class DerivedRecord(DataRecord):
return ["dish_washer_emr", "solar_power", "temp"]
class DerivedDataProvider(DataProvider):
class DerivedDataProvider(DataProvider[DerivedRecord]):
"""Concrete DataProvider for testing."""
records: List[DerivedRecord] = Field(
+4 -4
View File
@@ -51,7 +51,7 @@ class DerivedRecord(DataRecord):
return ["dish_washer_emr", "solar_power", "temp"]
class DerivedSequence(DataSequence):
class DerivedSequence(DataSequence[DerivedRecord]):
# overload
records: List[DerivedRecord] = Field(
default_factory=list, description="List of DerivedRecord records"
@@ -65,7 +65,7 @@ class DerivedSequence(DataSequence):
return "DerivedSequence"
class DerivedSequence2(DataSequence):
class DerivedSequence2(DataSequence[DerivedRecord]):
# overload
records: List[DerivedRecord] = Field(
default_factory=list, description="List of DerivedRecord records"
@@ -79,7 +79,7 @@ class DerivedSequence2(DataSequence):
return "DerivedSequence2"
class DerivedDataProvider(DataProvider):
class DerivedDataProvider(DataProvider[DerivedRecord]):
"""A concrete subclass of DataProvider for testing purposes."""
# overload
@@ -108,7 +108,7 @@ class DerivedDataProvider(DataProvider):
DerivedDataProvider.provider_updated = True
class DerivedDataImportProvider(DataImportProvider):
class DerivedDataImportProvider(DataImportProvider[DerivedRecord]):
"""A concrete subclass of DataImportProvider for testing purposes."""
# overload
+2 -2
View File
@@ -296,13 +296,13 @@ class TestDataRecord:
def test_init_configured_field_like_data_applies_before_model_init(self):
"""Test that keys listed in `_configured_data_keys` are moved to `configured_data` at init time."""
record = DerivedRecord(
record = DerivedRecord.model_validate(dict(
date_time="2024-01-03T00:00:00+00:00",
data_value=42.0,
dish_washer_emr=111.1,
solar_power=222.2,
temp=333.3 # assume `temp` is also a valid configured key
)
))
assert record.data_value == 42.0
assert record.configured_data == {
+2 -2
View File
@@ -52,7 +52,7 @@ class DerivedRecord(DataRecord):
return ["dish_washer_emr", "solar_power", "temp"]
class DerivedSequence(DataSequence):
class DerivedSequence(DataSequence[DerivedRecord]):
# overload
records: List[DerivedRecord] = Field(
default_factory=list, description="List of DerivedRecord records"
@@ -66,7 +66,7 @@ class DerivedSequence(DataSequence):
return "DerivedSequence"
class DerivedSequence2(DataSequence):
class DerivedSequence2(DataSequence[DerivedRecord]):
# overload
records: List[DerivedRecord] = Field(
default_factory=list, description="List of DerivedRecord records"
+3 -3
View File
@@ -111,7 +111,7 @@ class SampleDataRecord(DataRecord):
pressure: float = Field(default=0.0)
class SampleDataSequence(DataSequence):
class SampleDataSequence(DataSequence[SampleDataRecord]):
"""DataSequence subclass with database support."""
records: list[SampleDataRecord] = Field(default_factory=list)
@@ -123,7 +123,7 @@ class SampleDataSequence(DataSequence):
return "SampleDataSequence"
class SampleDataProvider(DataProvider):
class SampleDataProvider(DataProvider[SampleDataRecord]):
"""DataProvider subclass with database support."""
records: list[SampleDataRecord] = Field(default_factory=list)
@@ -317,7 +317,7 @@ class TestDataSequenceDatabaseProtocol:
db_end = DatabaseTimestamp.from_datetime(base_time.add(hours=5))
records = [record async for record in sequence.db_iterate_records(start_timestamp=db_start, end_timestamp=db_end)]
assert len(records) == 3
assert all(base_time.add(hours=2) <= r.date_time < base_time.add(hours=5) for r in records)
assert all(r.date_time is not None and base_time.add(hours=2) <= r.date_time < base_time.add(hours=5) for r in records)
async def test_delete_records(self, async_database_instance):
sequence = SampleDataSequence()
+2 -2
View File
@@ -680,7 +680,7 @@ class SampleDataRecord(DataRecord):
pressure: float = Field(default=0.0)
class SampleDataSequence(DataSequence):
class SampleDataSequence(DataSequence[SampleDataRecord]):
"""DataSequence subclass with database support."""
records: list[SampleDataRecord] = Field(default_factory=list)
@@ -692,7 +692,7 @@ class SampleDataSequence(DataSequence):
return "SampleDataSequence"
class SampleDataProvider(DataProvider):
class SampleDataProvider(DataProvider[SampleDataRecord]):
"""DataProvider subclass with database support."""
records: list[SampleDataRecord] = Field(default_factory=list)
+8 -5
View File
@@ -18,6 +18,7 @@ from typing import Any, AsyncIterator, Iterator, Literal, Optional, Type, cast
import pytest
import pytest_asyncio
from numpydantic import NDArray, Shape
from pendulum import UTC
from pydantic import BaseModel, Field
from akkudoktoreos.core.databaseabc import (
@@ -57,7 +58,7 @@ class SampleRecord(BaseModel):
return self.value
raise KeyError(key)
def model_dump(self) -> dict:
def model_dump(self, **kwargs: Any) -> dict:
return {"date_time": self.date_time, "value": self.value}
@@ -303,7 +304,7 @@ class SampleSequence(DatabaseRecordProtocolMixin[SampleRecord]):
if end_datetime is not None:
resampled = resampled.truncate(after=end_datetime)
return resampled.values
return resampled.to_numpy()
# ---------------------------------------------------------------------------
@@ -381,7 +382,7 @@ class TestDatabaseRecordProtocolMixin:
self, seq, start_str, value_count, interval_seconds
):
start_dt = to_datetime(start_str, in_timezone="Europe/Berlin")
assert start_dt.tz.name == "Europe/Berlin"
assert start_dt.timezone_name == "Europe/Berlin"
db_start = DatabaseTimestamp.from_datetime(start_dt)
generated = list(seq.db_generate_timestamps(db_start, value_count))
@@ -390,7 +391,7 @@ class TestDatabaseRecordProtocolMixin:
for db_dt in generated:
dt = DatabaseTimestamp.to_datetime(db_dt)
assert dt.tz.name == "UTC"
assert dt.timezone_name == "UTC"
assert len(generated) == len(set(generated)), "Duplicate UTC datetimes found"
@@ -1047,7 +1048,9 @@ class TestCompactDataIntegrity:
interval_sec = 15 * 60
expected_window_start = DateTime.fromtimestamp(
(int(base.timestamp()) // interval_sec) * interval_sec,
tz="UTC",
tz=UTC,
)
assert compacted[0].date_time is not None
assert compacted[-1].date_time is not None
assert compacted[0].date_time >= expected_window_start
assert compacted[-1].date_time < cutoff
+17 -17
View File
@@ -7,7 +7,7 @@ including edge cases, error handling, and timezone behavior.
import datetime
import json
import re
from typing import Any
from typing import Any, cast
from unittest.mock import MagicMock, patch
import babel
@@ -621,7 +621,7 @@ class TestToTime:
def test_to_time_invalid_input_type(self):
"""Test to_time with invalid input type."""
with pytest.raises(ValueError, match="Unsupported type"):
to_time({"invalid": "input"})
to_time(cast(Any, {"invalid": "input"}))
def test_to_time_invalid_hour_integer(self):
"""Test to_time with invalid hour as integer."""
@@ -657,7 +657,7 @@ class TestToTime:
def test_to_time_invalid_timezone_type(self):
"""Test to_time with invalid timezone type."""
with pytest.raises(ValueError, match="Invalid timezone"):
to_time("14:30", in_timezone=123)
to_time("14:30", in_timezone=cast(Any, 123))
def test_to_time_microseconds_precision(self):
"""Test to_time preserves microsecond precision."""
@@ -727,7 +727,7 @@ class TestTimeUtilityIntegration:
test_time: Time
# Test with string input
model = TestModel(test_time="14:30:45")
model = TestModel.model_validate(dict(test_time="14:30:45"))
assert isinstance(model.test_time, Time)
assert model.test_time.hour == 14
@@ -748,8 +748,8 @@ class TestTimeUtilityIntegration:
for case in test_cases:
# Both should produce the same result
direct_result = to_time(case)
model_result = TestModel(test_time=case).test_time
direct_result = to_time(cast(Any, case))
model_result = TestModel.model_validate(dict(test_time=case)).test_time
assert direct_result.hour == model_result.hour
assert direct_result.minute == model_result.minute
@@ -770,12 +770,12 @@ class ScheduleModel(PydanticBaseModel):
class TestPendulumTypes:
def test_valid_schedule_model(self):
model = ScheduleModel(
model = ScheduleModel.model_validate(dict(
start_time="14:30:00",
run_duration=to_duration("PT2H"),
scheduled_at=to_datetime("2025-07-04T09:00:00+02:00"),
run_on=to_datetime("2025-07-04")
)
))
assert isinstance(model.start_time, pendulum.Time)
assert isinstance(model.run_duration, pendulum.Duration)
@@ -788,12 +788,12 @@ class TestPendulumTypes:
assert model.run_on.to_date_string() == "2025-07-04"
def test_json_serialization(self):
model = ScheduleModel(
model = ScheduleModel.model_validate(dict(
start_time=pendulum.time(6, 15),
run_duration=pendulum.duration(minutes=45),
scheduled_at=pendulum.datetime(2025, 7, 4, 6, 15, tz="Europe/Berlin"),
run_on=pendulum.date(2025, 7, 4)
)
))
json_data = model.model_dump(mode="json")
assert "06:15:00" in json_data["start_time"]
@@ -809,30 +809,30 @@ class TestPendulumTypes:
def test_invalid_start_time(self):
with pytest.raises(ValidationError):
ScheduleModel(
ScheduleModel.model_validate(dict(
start_time="invalid",
run_duration="PT1H",
scheduled_at="2025-07-04T09:00:00+02:00",
run_on="2025-07-04"
)
))
def test_invalid_duration(self):
with pytest.raises(ValidationError):
ScheduleModel(
ScheduleModel.model_validate(dict(
start_time="10:00:00",
run_duration="2 hours", # invalid ISO 8601 duration
scheduled_at="2025-07-04T09:00:00+02:00",
run_on="2025-07-04"
)
))
def test_type_coercion(self):
dt = pendulum.datetime(2025, 7, 4, 12, 0)
model = ScheduleModel(
model = ScheduleModel.model_validate(dict(
start_time=pendulum.time(12, 0),
run_duration=pendulum.duration(hours=3),
scheduled_at=dt,
run_on=dt.date()
)
))
assert model.scheduled_at.hour == 12
assert model.run_duration.total_minutes() == 180
@@ -1424,7 +1424,7 @@ def test_hours_in_day(set_other_timezone, local_timezone, date, in_timezone, exp
"""Test the `test_hours_in_day` function."""
set_other_timezone(local_timezone)
date_input = to_datetime(date, in_timezone=in_timezone)
assert date_input.timezone.name == in_timezone
assert date_input.timezone_name == in_timezone
assert hours_in_day(date_input) == expected_hours
+31
View File
@@ -3,6 +3,7 @@ import os
import shutil
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pytest
@@ -16,6 +17,36 @@ DIR_TEST_GENERATED = DIR_TESTDATA / "docs" / "_generated"
GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS")
def test_config_documentation_requires_a_timezone_name(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.syspath_prepend(str(DIR_PROJECT_ROOT))
from scripts import generate_config_md
monkeypatch.setattr(generate_config_md, "to_datetime", lambda: SimpleNamespace(timezone_name=None))
output = tmp_path / "config.md"
with pytest.raises(RuntimeError, match="Documentation generation requires a timezone name"):
generate_config_md.write_to_file(output, "Configuration documentation")
assert not output.exists()
def test_generic_time_windows_keep_nested_documentation(monkeypatch):
from akkudoktoreos.config.configabc import TimeWindowSequence
monkeypatch.syspath_prepend(str(DIR_PROJECT_ROOT))
from scripts import generate_config_md
monkeypatch.setattr(generate_config_md, "documented_types", set())
monkeypatch.setattr(generate_config_md, "undocumented_types", {})
markdown = generate_config_md.generate_config_table_md(
TimeWindowSequence, ["time_windows"], "", toplevel=True, extra_config=True
)
assert "`list[akkudoktoreos.config.configabc.TimeWindow]`" in markdown
assert ":::{table} time_windows::windows::list" in markdown
assert "| start_time | `Time`" in markdown
assert "| duration | `Duration`" in markdown
@pytest.mark.skipif(GITHUB_ACTIONS == "true", reason="Skipped on GitHub Actions - TODO!")
def test_openapi_spec_current(config_eos, set_other_timezone):
"""Verify the openapi spec hasn´t changed."""
+2 -1
View File
@@ -13,6 +13,7 @@ from docutils.core import publish_parts
from docutils.frontend import get_default_settings
from docutils.parsers.rst import Directive, Parser, directives
from docutils.utils import Reporter, new_document
from sphinx.config import Config as SphinxConfig
from sphinx.ext.napoleon import Config as NapoleonConfig
from sphinx.ext.napoleon.docstring import GoogleDocstring
@@ -341,7 +342,7 @@ def test_all_docstrings_rst_compliant():
continue
# convert like sphinx napoleon does
doc_converted = str(GoogleDocstring(doc, napoleon_config))
doc_converted = str(GoogleDocstring(doc, cast(SphinxConfig, napoleon_config)))
# Register directives that sphinx knows - just to avaid errors
prepare_docutils_for_sphinx()
+19 -19
View File
@@ -36,7 +36,7 @@ class TestElecFeeFixedCommonSettings:
}
}
settings = ElecFeeFixedCommonSettings(**settings_dict)
settings = ElecFeeFixedCommonSettings.model_validate(settings_dict)
assert settings is not None
assert settings.consumption_amt_kwh is not None
assert settings.consumption_amt_kwh.windows is not None
@@ -52,7 +52,7 @@ class TestElecFeeFixedCommonSettings:
}
}
settings = ElecFeeFixedCommonSettings(**settings_dict)
settings = ElecFeeFixedCommonSettings.model_validate(settings_dict)
assert settings is not None
assert settings.consumption_percent_amt is not None
assert len(settings.consumption_percent_amt.windows) == 1
@@ -68,7 +68,7 @@ class TestElecFeeFixedCommonSettings:
}
}
settings = ElecFeeFixedCommonSettings(**settings_dict)
settings = ElecFeeFixedCommonSettings.model_validate(settings_dict)
assert settings is not None
assert settings.feedin_amt_kwh is not None
assert len(settings.feedin_amt_kwh.windows) == 2
@@ -83,7 +83,7 @@ class TestElecFeeFixedCommonSettings:
}
}
settings = ElecFeeFixedCommonSettings(**settings_dict)
settings = ElecFeeFixedCommonSettings.model_validate(settings_dict)
assert settings is not None
assert settings.feedin_percent_amt is not None
assert len(settings.feedin_percent_amt.windows) == 1
@@ -111,24 +111,24 @@ def elecfeefixed_settings():
"""
consumption_amt_kwh = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="8 hours", value=0.288),
ValueTimeWindow(start_time="08:00", duration="16 hours", value=0.34),
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)),
]
)
consumption_percent_amt = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="24 hours", value=19.0),
ValueTimeWindow.model_validate(dict(start_time="00:00", duration="24 hours", value=19.0)),
]
)
feedin_amt_kwh = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="8 hours", value=0.08),
ValueTimeWindow(start_time="08:00", duration="16 hours", value=0.10),
ValueTimeWindow.model_validate(dict(start_time="00:00", duration="8 hours", value=0.08)),
ValueTimeWindow.model_validate(dict(start_time="08:00", duration="16 hours", value=0.10)),
]
)
feedin_percent_amt = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="24 hours", value=5.0),
ValueTimeWindow.model_validate(dict(start_time="00:00", duration="24 hours", value=5.0)),
]
)
@@ -285,18 +285,18 @@ class TestElecFeeFixed:
partial_settings = ElecFeeFixedCommonSettings(
consumption_amt_kwh=ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="24 hours", value=0.3),
ValueTimeWindow.model_validate(dict(start_time="00:00", duration="24 hours", value=0.3)),
]
),
consumption_percent_amt=ValueTimeWindowSequence(windows=[]),
feedin_amt_kwh=ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="24 hours", value=0.1),
ValueTimeWindow.model_validate(dict(start_time="00:00", duration="24 hours", value=0.1)),
]
),
feedin_percent_amt=ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="24 hours", value=5.0),
ValueTimeWindow.model_validate(dict(start_time="00:00", duration="24 hours", value=5.0)),
]
),
)
@@ -387,24 +387,24 @@ class TestElecFeeFixedIntegration:
# Configure with realistic German electricity fees (2024)
consumption_amt_kwh = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="8 hours", value=0.288),
ValueTimeWindow(start_time="08:00", duration="16 hours", value=0.34),
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)),
]
)
consumption_percent_amt = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="24 hours", value=19.0),
ValueTimeWindow.model_validate(dict(start_time="00:00", duration="24 hours", value=19.0)),
]
)
feedin_amt_kwh = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="8 hours", value=0.08),
ValueTimeWindow(start_time="08:00", duration="16 hours", value=0.10),
ValueTimeWindow.model_validate(dict(start_time="00:00", duration="8 hours", value=0.08)),
ValueTimeWindow.model_validate(dict(start_time="08:00", duration="16 hours", value=0.10)),
]
)
feedin_percent_amt = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(start_time="00:00", duration="24 hours", value=5.0),
ValueTimeWindow.model_validate(dict(start_time="00:00", duration="24 hours", value=5.0)),
]
)
+9 -9
View File
@@ -44,7 +44,7 @@ class TestElecPriceFixedCommonSettings:
}
}
settings = ElecPriceFixedCommonSettings(**settings_dict)
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
@@ -71,16 +71,16 @@ def provider(config_eos):
# Create time windows
elecprice_marketprice_amt_kwh = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(
ValueTimeWindow.model_validate(dict(
start_time="00:00",
duration="8 hours",
value=0.288
),
ValueTimeWindow(
)),
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)
@@ -238,16 +238,16 @@ class TestElecPriceFixedIntegration:
# Configure with realistic German electricity prices (2024)
elecprice_marketprice_amt_kwh = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(
ValueTimeWindow.model_validate(dict(
start_time="00:00",
duration="8 hours",
value=0.288 # Night rate
),
ValueTimeWindow(
)),
ValueTimeWindow.model_validate(dict(
start_time="08:00",
duration="16 hours",
value=0.34 # Day rate
)
))
]
)
+3 -1
View File
@@ -6,6 +6,7 @@ from akkudoktoreos.core.emplan import (
BaseInstruction,
CommodityQuantity,
DDBCInstruction,
EnergyManagementInstruction,
EnergyManagementPlan,
FRBCInstruction,
OMBCInstruction,
@@ -30,6 +31,7 @@ class TestEnergyManagementPlan:
# Helpers (only used inside the class)
# ----------------------------------------------------------------------
def _make_instr(self, resource_id, execution_time, duration=None):
instr: OMBCInstruction | PEBCInstruction
if duration is None:
instr = OMBCInstruction(
id=resource_id,
@@ -169,7 +171,7 @@ class TestEnergyManagementPlan:
generated_at=fixed_now,
instructions=[]
)
instrs = [
instrs: list[EnergyManagementInstruction] = [
DDBCInstruction(
id="actuatorA@123",
execution_time=fixed_now,
+2 -2
View File
@@ -265,13 +265,13 @@ class TestAcChargingInSimulation:
simulation = Genetic0Simulation()
simulation.prepare(
Genetic0EnergyManagementParameters(
Genetic0EnergyManagementParameters.model_validate(dict(
pv_prognose_wh=[0.0] * prediction_hours, # No PV
strompreis_euro_pro_wh=[0.0003] * prediction_hours, # ~30ct/kWh
einspeiseverguetung_euro_pro_wh=0.00008,
preis_euro_pro_wh_akku=0.0001,
gesamtlast=[1000.0] * prediction_hours, # 1 kW constant load
),
)),
optimization_hours=config_eos.optimization.genetic0.horizon_hours,
prediction_hours=prediction_hours,
inverter=inverter,
+4 -4
View File
@@ -239,13 +239,13 @@ def genetic0_simulation(config_eos) -> Genetic0Simulation:
# Initialize the energy management system with the respective parameters
genetic0_simulation = Genetic0Simulation()
genetic0_simulation.prepare(
Genetic0EnergyManagementParameters(
Genetic0EnergyManagementParameters.model_validate(dict(
pv_prognose_wh=pv_prognose_wh,
strompreis_euro_pro_wh=strompreis_euro_pro_wh,
einspeiseverguetung_euro_pro_wh=einspeiseverguetung_euro_pro_wh,
preis_euro_pro_wh_akku=preis_euro_pro_wh_akku,
gesamtlast=gesamtlast,
),
)),
optimization_hours = config_eos.optimization.genetic0.horizon_hours,
prediction_hours = config_eos.prediction.hours,
inverter=inverter,
@@ -387,13 +387,13 @@ def genetic0_simulation_2(config_eos) -> Genetic0Simulation:
# Initialize the energy management system with the respective parameters
simulation = Genetic0Simulation()
simulation.prepare(
Genetic0EnergyManagementParameters(
Genetic0EnergyManagementParameters.model_validate(dict(
pv_prognose_wh=pv_prognose_wh,
strompreis_euro_pro_wh=strompreis_euro_pro_wh,
einspeiseverguetung_euro_pro_wh=einspeiseverguetung_euro_pro_wh,
preis_euro_pro_wh_akku=preis_euro_pro_wh_akku,
gesamtlast=gesamtlast,
),
)),
optimization_hours = config_eos.optimization.genetic0.horizon_hours,
prediction_hours = config_eos.prediction.hours,
inverter=inverter,
+8 -8
View File
@@ -18,7 +18,7 @@ def test_genetic0_params_german_input():
"preis_euro_pro_wh_akku": 0.0001,
"gesamtlast": [500.0, 600.0],
}
params = Genetic0EnergyManagementParameters(**data_de)
params = Genetic0EnergyManagementParameters.model_validate(data_de)
assert params.pv_forecast_wh == [100.0, 200.0]
print("✅ German input accepted")
@@ -31,7 +31,7 @@ def test_genetic0_params_english_input():
"price_per_wh_battery": 0.0001,
"total_load": [500.0, 600.0],
}
params = Genetic0EnergyManagementParameters(**data_en)
params = Genetic0EnergyManagementParameters.model_validate(data_en)
assert params.pv_forecast_wh == [100.0, 200.0]
print("✅ English input accepted")
@@ -44,7 +44,7 @@ def test_genetic0_params_english_output():
"preis_euro_pro_wh_akku": 0.0001,
"gesamtlast": [500.0, 600.0],
}
params = Genetic0EnergyManagementParameters(**data_de)
params = Genetic0EnergyManagementParameters.model_validate(data_de)
json_output = json.loads(params.model_dump_json(by_alias=True))
# English names should be in output
@@ -84,7 +84,7 @@ def test_genetic0_simulation_result_translations():
"akku_soc_pro_stunde": [80.0, 90.0],
"Electricity_price": [0.0003, 0.0003],
}
result = Genetic0SimulationResult(**data_de)
result = Genetic0SimulationResult.model_validate(data_de)
json_output = json.loads(result.model_dump_json(by_alias=True))
# Check English field names in output
@@ -140,12 +140,12 @@ def test_genetic0_optimization_parameters_device_translations():
"preis_euro_pro_wh_akku": 0.0001,
"gesamtlast": [500.0, 600.0],
}
params = Genetic0OptimizationParameters(
params = Genetic0OptimizationParameters.model_validate(dict(
ems=ems_de,
pv_akku={"device_id": "battery1", "capacity_wh": 8000},
inverter=None,
eauto={"device_id": "ev1", "capacity_wh": 60000},
)
))
# English attributes are populated from the German input names
assert params.pv_battery is not None
assert params.pv_battery.capacity_wh == 8000
@@ -153,12 +153,12 @@ def test_genetic0_optimization_parameters_device_translations():
assert params.ev.capacity_wh == 60000
# English input names work as well
params_en = Genetic0OptimizationParameters(
params_en = Genetic0OptimizationParameters.model_validate(dict(
ems=ems_de,
pv_battery={"device_id": "battery1", "capacity_wh": 8000},
inverter=None,
ev={"device_id": "ev1", "capacity_wh": 60000},
)
))
assert params_en.pv_battery is not None
assert params_en.ev is not None
+2 -2
View File
@@ -231,13 +231,13 @@ def genetic_simulation(config_eos) -> GeneticSimulation:
# Initialize the energy management system with the respective parameters
simulation = GeneticSimulation()
simulation.prepare(
GeneticEnergyManagementParameters(
GeneticEnergyManagementParameters.model_validate(dict(
pv_prognose_wh=pv_prognose_wh,
strompreis_euro_pro_wh=strompreis_euro_pro_wh,
einspeiseverguetung_euro_pro_wh=einspeiseverguetung_euro_pro_wh,
preis_euro_pro_wh_akku=preis_euro_pro_wh_akku,
gesamtlast=gesamtlast,
),
)),
optimization_hours = config_eos.optimization.genetic.horizon_hours,
prediction_hours = config_eos.prediction.hours,
inverter=inverter,
+2 -2
View File
@@ -137,13 +137,13 @@ def genetic_simulation_2(config_eos) -> GeneticSimulation:
# Initialize the energy management system with the respective parameters
simulation = GeneticSimulation()
simulation.prepare(
GeneticEnergyManagementParameters(
GeneticEnergyManagementParameters.model_validate(dict(
pv_prognose_wh=pv_prognose_wh,
strompreis_euro_pro_wh=strompreis_euro_pro_wh,
einspeiseverguetung_euro_pro_wh=einspeiseverguetung_euro_pro_wh,
preis_euro_pro_wh_akku=preis_euro_pro_wh_akku,
gesamtlast=gesamtlast,
),
)),
optimization_hours = config_eos.optimization.genetic.horizon_hours,
prediction_hours = config_eos.prediction.hours,
inverter=inverter,
+2 -2
View File
@@ -259,13 +259,13 @@ class TestAcChargingInSimulation:
simulation = GeneticSimulation()
simulation.prepare(
GeneticEnergyManagementParameters(
GeneticEnergyManagementParameters.model_validate(dict(
pv_prognose_wh=[0.0] * prediction_hours, # No PV
strompreis_euro_pro_wh=[0.0003] * prediction_hours, # ~30ct/kWh
einspeiseverguetung_euro_pro_wh=0.00008,
preis_euro_pro_wh_akku=0.0001,
gesamtlast=[1000.0] * prediction_hours, # 1 kW constant load
),
)),
optimization_hours=config_eos.optimization.genetic.horizon_hours,
prediction_hours=prediction_hours,
inverter=inverter,
+2
View File
@@ -74,6 +74,8 @@ async def measurement_eos():
load1_mr += 0.05
min_dt = await measurement.min_datetime()
max_dt = await measurement.max_datetime()
assert min_dt is not None
assert max_dt is not None
assert compare_datetimes(min_dt, to_datetime("2024-01-01T00:00:00")).equal
assert compare_datetimes(max_dt, to_datetime("2024-01-02T00:00:00")).equal
return measurement
+2 -2
View File
@@ -38,7 +38,7 @@ class DerivedRecord(PredictionRecord):
prediction_value: Optional[float] = Field(default=None, description="Prediction Value")
class DerivedSequence(PredictionSequence):
class DerivedSequence(PredictionSequence[DerivedRecord]):
# overload
records: List[DerivedRecord] = Field(
default_factory=list, description="List of DerivedRecord records"
@@ -49,7 +49,7 @@ class DerivedSequence(PredictionSequence):
return DerivedRecord
class DerivedPredictionProvider(PredictionProvider):
class DerivedPredictionProvider(PredictionProvider[DerivedRecord]):
"""A concrete subclass of PredictionProvider for testing purposes."""
# overload
+1 -1
View File
@@ -23,7 +23,7 @@ from akkudoktoreos.prediction.priceabc import PricePredictionProviderBase
from akkudoktoreos.utils.datetimeutil import to_datetime
class _PriceProviderForTest(PricePredictionProviderBase):
class _PriceProviderForTest(PricePredictionProviderBase[PredictionRecord]):
"""Minimal concrete subclass to exercise PricePredictionProviderBase directly.
Implements `_compute_gross` with the same add-then-percent formula as
+16 -13
View File
@@ -81,7 +81,7 @@ class TestMergeModels:
def test_flat_override(self):
"""Top-level fields in update_dict override those in source, including None."""
source = SampleModel(name="Test", count=10, config={"threshold": 5})
source = SampleModel.model_validate(dict(name="Test", count=10, config={"threshold": 5}))
update = {"name": "Updated"}
result = merge_models(source, update)
@@ -91,7 +91,7 @@ class TestMergeModels:
def test_flat_override_with_none(self):
"""Update with None value should override source value."""
source = SampleModel(name="Test", count=10, config={"threshold": 5}, optional="keep me")
source = SampleModel.model_validate(dict(name="Test", count=10, config={"threshold": 5}, optional="keep me"))
update = {"optional": None}
result = merge_models(source, update)
@@ -99,7 +99,7 @@ class TestMergeModels:
def test_nested_override(self):
"""Nested fields in update_dict override nested fields in source, including None."""
source = SampleModel(name="Test", count=10, config={"threshold": 5, "enabled": True})
source = SampleModel.model_validate(dict(name="Test", count=10, config={"threshold": 5, "enabled": True}))
update = {"config": {"threshold": 99, "enabled": False}}
result = merge_models(source, update)
@@ -108,7 +108,7 @@ class TestMergeModels:
def test_nested_override_with_none(self):
"""Nested update with None should override nested source values."""
source = SampleModel(name="Test", count=10, config={"threshold": 5, "enabled": True})
source = SampleModel.model_validate(dict(name="Test", count=10, config={"threshold": 5, "enabled": True}))
update = {"config": {"threshold": None}}
result = merge_models(source, update)
@@ -117,7 +117,7 @@ class TestMergeModels:
def test_preserve_source_values(self):
"""Source values are preserved if not overridden in update_dict."""
source = SampleModel(name="Source", count=7, config={"threshold": 1})
source = SampleModel.model_validate(dict(name="Source", count=7, config={"threshold": 1}))
update: dict[str, Any] = {}
result = merge_models(source, update)
@@ -127,7 +127,7 @@ class TestMergeModels:
def test_update_extends_source(self):
"""Optional fields in update_dict are added to result."""
source = SampleModel(name="Test", count=10, config={"threshold": 5})
source = SampleModel.model_validate(dict(name="Test", count=10, config={"threshold": 5}))
update = {"optional": "new value"}
result = merge_models(source, update)
@@ -135,7 +135,7 @@ class TestMergeModels:
def test_update_extends_source_with_none(self):
"""Optional field with None in update_dict is added and overrides source."""
source = SampleModel(name="Test", count=10, config={"threshold": 5}, optional="value")
source = SampleModel.model_validate(dict(name="Test", count=10, config={"threshold": 5}, optional="value"))
update = {"optional": None}
result = merge_models(source, update)
@@ -143,7 +143,7 @@ class TestMergeModels:
def test_deep_merge_behavior(self):
"""Nested updates merge with source, overriding only specified subkeys."""
source = SampleModel(name="Model", count=3, config={"threshold": 1, "enabled": False})
source = SampleModel.model_validate(dict(name="Model", count=3, config={"threshold": 1, "enabled": False}))
update = {"config": {"enabled": True}}
result = merge_models(source, update)
@@ -152,7 +152,7 @@ class TestMergeModels:
def test_override_all(self):
"""All fields in update_dict override all fields in source, including None."""
source = SampleModel(name="Orig", count=1, config={"threshold": 10, "enabled": True})
source = SampleModel.model_validate(dict(name="Orig", count=1, config={"threshold": 10, "enabled": True}))
update = {
"name": "New",
"count": None,
@@ -376,7 +376,7 @@ class TestPydanticBaseModel:
def test_invalid_datetime_string(self):
with pytest.raises(ValueError):
PydanticTestModel(datetime_field="invalid_datetime")
PydanticTestModel.model_validate(dict(datetime_field="invalid_datetime"))
def test_iso8601_serialization(self):
dt = pendulum.datetime(2024, 12, 21, 15, 0, 0)
@@ -444,8 +444,11 @@ class TestPydanticDateTimeData:
"timestamps": ["2024-12-21T15:00:00+00:00"],
"values": [100],
}
model = PydanticDateTimeData(root=data)
assert pendulum.parse(model.root["timestamps"][0]) == pendulum.parse(
model = PydanticDateTimeData.model_validate(data)
timestamps = model.root["timestamps"]
assert isinstance(timestamps, list)
assert isinstance(timestamps[0], str)
assert pendulum.parse(timestamps[0]) == pendulum.parse(
"2024-12-21T15:00:00+00:00"
)
@@ -457,7 +460,7 @@ class TestPydanticDateTimeData:
with pytest.raises(
ValidationError, match="All lists in the dictionary must have the same length"
):
PydanticDateTimeData(root=data)
PydanticDateTimeData.model_validate(data)
class TestPydanticDateTimeDataFrame:
+188
View File
@@ -0,0 +1,188 @@
"""Regression coverage for model typing and its runtime validation boundaries."""
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import numpy as np
import pendulum
import pytest
from akkudoktoreos.adapter import homeassistant
from akkudoktoreos.adapter.homeassistant import (
HomeAssistantAdapter,
HomeAssistantAdapterCommonSettings,
)
from akkudoktoreos.adapter.nodered import NodeREDAdapter
from akkudoktoreos.config.config import ConfigEOS, GeneralSettings
from akkudoktoreos.config.configabc import TimeWindow, TimeWindowSequence
from akkudoktoreos.core.cachesettings import CacheCommonSettings
from akkudoktoreos.core.dataabc import DataRecord, DataSequence
from akkudoktoreos.core.databaseabc import DatabaseTimestamp
from akkudoktoreos.core.ems import EnergyManagement
from akkudoktoreos.core.pydantic import PydanticDateTimeData
from akkudoktoreos.devices.devices import (
BATTERY_DEFAULT_CHARGE_RATES,
BatteriesCommonSettings,
)
from akkudoktoreos.measurement.measurement import Measurement, MeasurementDataRecord
from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixed
from akkudoktoreos.prediction.pvforecastforecastsolar import PVForecastForecastSolar
from akkudoktoreos.prediction.pvforecastpvnode import PVForecastPVNode
from akkudoktoreos.server import eos
from akkudoktoreos.server.rest.error import EOSProblem
from akkudoktoreos.utils.datetimeutil import to_datetime
def test_path_defaults_keep_the_json_representation() -> None:
cache = CacheCommonSettings()
general = GeneralSettings()
assert cache.subpath == Path("cache")
assert general.data_output_subpath == Path("output")
assert cache.model_dump(mode="json")["subpath"] == "cache"
assert general.model_dump(mode="json")["data_output_subpath"] == "output"
assert general.model_dump(mode="json", exclude_defaults=True)["data_output_subpath"] == "output"
def test_generic_collections_keep_runtime_field_types() -> None:
assert DataSequence._get_key_types(DataSequence, "records") == [list, DataRecord]
assert TimeWindowSequence._get_key_types(TimeWindowSequence, "windows") == [list, TimeWindow]
def test_default_charge_rates_are_an_independent_float_array() -> None:
rates = BatteriesCommonSettings.validate_and_sort_charge_rates(None)
assert isinstance(rates, np.ndarray)
np.testing.assert_array_equal(rates, BATTERY_DEFAULT_CHARGE_RATES)
rates[0] = 0.5
assert BATTERY_DEFAULT_CHARGE_RATES[0] == 0.0
def test_time_series_metadata_is_normalized_without_changing_wire_types() -> None:
model = PydanticDateTimeData.model_validate(
{"start_datetime": "2024-01-01T00:00:00Z", "interval": "1 hour", "values": [1, 2]}
)
assert isinstance(model.root["start_datetime"], pendulum.DateTime)
assert isinstance(model.root["interval"], pendulum.Duration)
schema = PydanticDateTimeData.model_json_schema()
assert schema["additionalProperties"]["anyOf"][0] == {"type": "string"}
def test_database_timestamp_requires_a_datetime() -> None:
with pytest.raises(ValueError, match="Timezone-aware datetime required"):
DatabaseTimestamp.from_datetime(None) # type: ignore[arg-type] # Invalid input on purpose.
@pytest.mark.asyncio
async def test_database_insert_rejects_a_record_without_a_timestamp() -> None:
sequence = DataSequence[DataRecord]()
with pytest.raises(ValueError, match="Database records require a datetime"):
await sequence.db_insert_record(DataRecord())
assert sequence.records == []
@pytest.mark.parametrize(
"property_name",
["homeassistant_entity_ids", "eos_solution_entity_ids", "eos_device_instruction_entity_ids"],
)
def test_homeassistant_settings_reject_a_misregistered_provider(
monkeypatch: pytest.MonkeyPatch, property_name: str
) -> None:
adapter = MagicMock()
adapter.provider_by_id.return_value = NodeREDAdapter()
monkeypatch.setattr(homeassistant, "get_adapter", lambda: adapter)
settings = HomeAssistantAdapterCommonSettings()
with pytest.raises(TypeError, match="HomeAssistant provider must be a HomeAssistantAdapter"):
getattr(settings, property_name)
@pytest.mark.parametrize(
"property_name",
["homeassistant_entity_ids", "eos_solution_entity_ids", "eos_device_instruction_entity_ids"],
)
def test_homeassistant_settings_preserve_entity_lookup_and_unavailability(
monkeypatch: pytest.MonkeyPatch, property_name: str
) -> None:
provider = HomeAssistantAdapter()
lookup = MagicMock(return_value=["sensor.eos_example"])
monkeypatch.setattr(HomeAssistantAdapter, f"get_{property_name}", lookup)
adapter = MagicMock()
adapter.provider_by_id.return_value = provider
monkeypatch.setattr(homeassistant, "get_adapter", lambda: adapter)
settings = HomeAssistantAdapterCommonSettings()
assert getattr(settings, property_name) == ["sensor.eos_example"]
lookup.side_effect = ConnectionError("Home Assistant is unavailable")
assert getattr(settings, property_name) == []
adapter.provider_by_id.side_effect = ValueError("Provider unavailable during initialization")
assert getattr(settings, property_name) == []
@pytest.mark.parametrize("operation", ["revert_settings", "list_backups"])
def test_backup_operations_report_an_uninitialized_path_as_an_invariant_failure(
config_eos: ConfigEOS, monkeypatch: pytest.MonkeyPatch, operation: str
) -> None:
monkeypatch.setattr(ConfigEOS, "_config_file_path", None)
with pytest.raises(AssertionError, match="Configuration file path is not initialized"):
if operation == "revert_settings":
config_eos.revert_settings("missing")
else:
config_eos.list_backups()
def test_energy_management_initializes_its_start_datetime_once(
monkeypatch: pytest.MonkeyPatch,
) -> None:
clock = MagicMock(return_value=to_datetime("2024-01-01T12:34:56+01:00"))
monkeypatch.setattr("akkudoktoreos.core.ems.to_datetime", clock)
monkeypatch.setattr(EnergyManagement, "_start_datetime", None)
ems = EnergyManagement()
first = ems.start_datetime
assert first == to_datetime("2024-01-01T12:00:00+01:00")
assert ems.start_datetime is first
clock.assert_called_once_with()
def test_dashboard_details_accept_top_level_settings_and_nested_eos_models(
config_eos: ConfigEOS,
) -> None:
from akkudoktoreos.server.dash.configuration import create_config_details
values = config_eos.model_dump(mode="json")
top_level = create_config_details(ConfigEOS, values)
nested = create_config_details(GeneralSettings, values, ["general"])
assert top_level["general.latitude"] == nested["general.latitude"]
@pytest.mark.asyncio
async def test_energy_calculation_requires_resolvable_record_times(
monkeypatch: pytest.MonkeyPatch,
) -> None:
measurement = Measurement()
measurement.records = [MeasurementDataRecord()]
monkeypatch.setattr(Measurement, "min_datetime", AsyncMock(return_value=None))
monkeypatch.setattr(Measurement, "max_datetime", AsyncMock(return_value=None))
with pytest.raises(ValueError, match="Start and end datetimes are required"):
await measurement.load_total_kwh()
@pytest.mark.asyncio
async def test_prediction_import_rejects_a_provider_without_import_support(
monkeypatch: pytest.MonkeyPatch,
) -> None:
provider = MagicMock(spec=ElecPriceFixed)
provider.enabled.return_value = True
prediction = MagicMock()
prediction.provider_by_id.return_value = provider
monkeypatch.setattr(eos, "get_prediction", lambda: prediction)
with pytest.raises(EOSProblem, match="does not support data imports") as exc_info:
await eos.fastapi_prediction_import_provider(
provider_id="ElecPriceFixed", data={"values": [1]}
)
assert exc_info.value.status == 400
@pytest.mark.parametrize("provider_type", [PVForecastPVNode, PVForecastForecastSolar])
def test_pv_timestamp_parsing_rejects_durations(provider_type: type) -> None:
with pytest.raises(ValueError, match="Expected a datetime"):
provider_type()._to_utc_datetime("PT1H", "UTC")
+115
View File
@@ -0,0 +1,115 @@
"""Exercise the typing entry points with real runtime dependency types."""
import os
import re
import shlex
import shutil
import subprocess
from pathlib import Path
import pytest
import yaml
PROJECT_ROOT = Path(__file__).resolve().parents[1]
def test_mypy_entry_points(tmp_path: Path, is_finalize: bool) -> None:
"""Check valid and invalid dependency types in a fresh locked environment.
This integration test installs the locked development dependencies with uv.
Only the mypy hook is copied, so unrelated formatting hooks cannot modify the probe.
The project checkout, virtual environment and pre-commit/mypy caches are temporary.
"""
if not is_finalize:
pytest.skip("Typing toolchain integration requires --finalize (installs dependencies).")
if shutil.which("uv") is None or shutil.which("make") is None:
pytest.skip("Typing entry point integration requires uv and make on PATH.")
for name in ("Makefile", "pyproject.toml", "uv.lock", ".python-version", "README.md", "LICENSE"):
shutil.copy2(PROJECT_ROOT / name, tmp_path / name)
(tmp_path / "version.txt").write_text("0.0.0", encoding="utf-8")
(tmp_path / "src").mkdir()
(tmp_path / "tests").mkdir()
source_probe = tmp_path / "src" / "typing_probe.py"
test_probe = tmp_path / "tests" / "test_typing_probe.py"
config = yaml.safe_load((PROJECT_ROOT / ".pre-commit-config.yaml").read_text(encoding="utf-8"))
mypy_repos = []
for repo in config["repos"]:
hooks = [hook for hook in repo["hooks"] if hook["id"] == "mypy"]
if hooks:
mypy_repos.append({**repo, "hooks": hooks})
assert mypy_repos, "The pre-commit mypy hook must exist."
(tmp_path / ".pre-commit-config.yaml").write_text(
yaml.safe_dump({**config, "repos": mypy_repos}), encoding="utf-8"
)
workflow = yaml.safe_load(
(PROJECT_ROOT / ".github/workflows/pre-commit.yml").read_text(encoding="utf-8")
)
ci_command = next(
step["run"]
for step in workflow["jobs"]["pre-commit"]["steps"]
if "pre-commit run" in step.get("run", "")
)
env = os.environ.copy()
env.pop("VIRTUAL_ENV", None)
env["UV_PROJECT_ENVIRONMENT"] = str(tmp_path / ".venv")
env["PRE_COMMIT_HOME"] = str(tmp_path / "pre-commit-cache")
env["MYPY_CACHE_DIR"] = str(tmp_path / "mypy-cache")
env["NO_COLOR"] = "1"
def run(command: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(
command, cwd=tmp_path, env=env, text=True, capture_output=True, timeout=300
)
initialized = run(["git", "init", "--quiet"])
assert initialized.returncode == 0, initialized.stderr
# A configuration-only hook run must still check the complete src/tests scope.
commands = [
["make", "mypy"],
[
"uv",
"run",
"--locked",
"--extra",
"dev",
"pre-commit",
"run",
"mypy",
"--files",
"pyproject.toml",
],
shlex.split(ci_command),
]
source_probe.write_text(
"from pydantic import BaseModel\nmodel: BaseModel = BaseModel()\n", encoding="utf-8"
)
test_probe.write_text(
"from pendulum import DateTime\ninstant: DateTime = DateTime(2026, 1, 1)\n",
encoding="utf-8",
)
for command in commands:
result = run(command)
assert result.returncode == 0, result.stdout + result.stderr
source_probe.write_text(
"from pydantic import BaseModel\nmodel: BaseModel = 1\n", encoding="utf-8"
)
test_probe.write_text(
'from pendulum import DateTime\ninstant: DateTime = "invalid"\n', encoding="utf-8"
)
outputs = []
for command in commands:
result = run(command)
output = result.stdout + result.stderr
assert result.returncode != 0, output
diagnostics = sorted(line for line in output.splitlines() if re.search(r":\d+: error:", line))
assert len(diagnostics) == 2, output
assert all("[assignment]" in line for line in diagnostics), output
assert any('variable has type "BaseModel"' in line for line in diagnostics), output
assert any('variable has type "DateTime"' in line for line in diagnostics), output
outputs.append(diagnostics)
assert outputs[0] == outputs[1] == outputs[2]
+6 -4
View File
@@ -1,6 +1,7 @@
import re
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from unittest.mock import Mock, patch
import numpy as np
@@ -12,7 +13,7 @@ from bs4 import BeautifulSoup
from akkudoktoreos.core.cache import CacheFileStore
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_timezone
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
@@ -269,7 +270,7 @@ def test_clearoutsides_development_scraper(provider, sample_clearout_1_html):
assert minutes == 0
# Create the timezone object using timedelta for the offset
forecast_timezone = timezone(timedelta(hours=hours, minutes=minutes))
forecast_timezone = to_timezone(utc_offset=hours + minutes / 60, as_string=False)
else:
assert False
@@ -315,7 +316,7 @@ def test_clearoutsides_development_scraper(provider, sample_clearout_1_html):
p_detail_tables.pop(0)
# Create clearout data
clearout_data = {}
clearout_data: dict[str, Any] = {}
# Add data values
for i, detail_name in enumerate(detail_names):
p_detail_values = p_detail_tables[i].find_all("li")
@@ -326,9 +327,10 @@ def test_clearoutsides_development_scraper(provider, sample_clearout_1_html):
and hasattr(p_detail_value, "title")
and p_detail_value.title
):
value_str = p_detail_value.title.string
value_str = p_detail_value.title.get_text()
else:
value_str = p_detail_value.get_text()
value: float | str
try:
value = float(value_str)
except ValueError:
+9
View File
@@ -244,6 +244,15 @@
"token": "your-token",
"site_id": 12345
},
"homeassistant": {
"entity_id": "sensor.pv_forecast",
"attribute": "forecast",
"datetime_key": "datetime",
"value_key": "watts",
"value_unit": "W",
"base_url": null,
"token": null
},
"pvlib": {},
"pvnode": {
"api_key": "",
+61
View File
@@ -8,6 +8,7 @@
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| forecastsolar | `EOS_PVFORECAST__FORECASTSOLAR` | `PVForecastForecastSolarCommonSettings` | `rw` | `required` | ForecastSolar provider settings |
| homeassistant | `EOS_PVFORECAST__HOMEASSISTANT` | `PVForecastHomeAssistantCommonSettings` | `rw` | `required` | Home Assistant provider settings |
| max_planes | `EOS_PVFORECAST__MAX_PLANES` | `int | None` | `rw` | `0` | Maximum number of planes that can be set |
| planes | `EOS_PVFORECAST__PLANES` | `list[akkudoktoreos.prediction.pvforecast.PVForecastPlaneSetting] | None` | `rw` | `None` | Plane configuration. |
| planes_azimuth | | `List[float]` | `ro` | `N/A` | Compute a list of the azimuths per active planes. |
@@ -42,6 +43,15 @@
"token": "your-token",
"site_id": 12345
},
"homeassistant": {
"entity_id": "sensor.pv_forecast",
"attribute": "forecast",
"datetime_key": "datetime",
"value_key": "watts",
"value_unit": "W",
"base_url": null,
"token": null
},
"pvlib": {},
"pvnode": {
"api_key": "",
@@ -124,6 +134,15 @@
"token": "your-token",
"site_id": 12345
},
"homeassistant": {
"entity_id": "sensor.pv_forecast",
"attribute": "forecast",
"datetime_key": "datetime",
"value_key": "watts",
"value_unit": "W",
"base_url": null,
"token": null
},
"pvlib": {},
"pvnode": {
"api_key": "",
@@ -187,6 +206,7 @@
"providers": [
"PVForecastAkkudoktor",
"PVForecastForecastSolar",
"PVForecastHomeAssistant",
"PVForecastImport",
"PVForecastPVLib",
"PVForecastPVNode",
@@ -443,6 +463,47 @@
```
<!-- pyml enable line-length -->
### Common settings for pvforecast data from a Home Assistant entity
<!-- pyml disable line-length -->
:::{table} pvforecast::homeassistant
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| attribute | `str` | `rw` | `forecast` | Entity attribute holding the forecast list. |
| base_url | `str | None` | `rw` | `None` | Base URL of the Home Assistant instance. Only required when EOS is not running as a Home Assistant add-on (no SUPERVISOR_TOKEN available). |
| datetime_key | `str` | `rw` | `datetime` | Key for the timestamp in each forecast entry. |
| entity_id | `str` | `rw` | `sensor.pv_forecast` | Home Assistant entity providing the PV forecast. |
| token | `str | None` | `rw` | `None` | Long-lived access token for the Home Assistant instance. Only required when EOS is not running as a Home Assistant add-on. |
| value_key | `str` | `rw` | `watts` | Key for the AC power value in each forecast entry. |
| value_unit | `Literal['W', 'kW']` | `rw` | `W` | Unit of the forecast value. Converted to W internally. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"pvforecast": {
"homeassistant": {
"entity_id": "sensor.pv1_power_now",
"attribute": "forecast",
"datetime_key": "datetime",
"value_key": "watts",
"value_unit": "W",
"base_url": "http://homeassistant.local:8123",
"token": null
}
}
}
```
<!-- pyml enable line-length -->
### Common settings for the Forecast.Solar PV forecast provider
<!-- pyml disable line-length -->