mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-09-11 10:26:38 +00:00
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:
co-authored by
dr-dimitri
Normann
parent
5b584cbb57
commit
1abdd345c4
+5
-5
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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:00–10:00
|
||||
make_window(14, 3), # 14:00–17: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:00–10:00
|
||||
make_window(14, 3), # 14:00–17: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,
|
||||
)
|
||||
))
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 == {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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)),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
))
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
@@ -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]
|
||||
@@ -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:
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user