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
+1 -1
View File
@@ -104,7 +104,7 @@ class ElecFeeDataRecord(PredictionRecord):
return self.elecfee_feedin_amt_wh * 1000.0
class ElecFeeProvider(PredictionProvider):
class ElecFeeProvider(PredictionProvider[ElecFeeDataRecord]):
"""Abstract base class for electricity fee providers.
Electricity fee providers predict fees on consumed and feed-in electricity to be used by
+1 -1
View File
@@ -49,7 +49,7 @@ class ElecPriceDataRecord(PredictionRecord):
return self.elecprice_marketprice_wh * 1000.0
class ElecPriceProvider(PricePredictionProviderBase):
class ElecPriceProvider(PricePredictionProviderBase[ElecPriceDataRecord]):
"""Abstract base class for electricity price providers.
ElecPriceProvider is a thread-safe singleton, ensuring only one instance of this class is created.
@@ -49,7 +49,7 @@ class FeedInTariffDataRecord(PredictionRecord):
return self.feed_in_tariff_wh * 1000.0
class FeedInTariffProvider(PricePredictionProviderBase):
class FeedInTariffProvider(PricePredictionProviderBase[FeedInTariffDataRecord]):
"""Abstract base class for feed in tariff providers.
FeedInTariffProvider is a thread-safe singleton, ensuring only one instance of this class is created.
@@ -123,10 +123,10 @@ class FeedInTariffAkkudoktor(FeedInTariffProvider):
history = np.asarray(
await self.key_to_array(
key="feed_in_tariff_wh",
end_datetime=self.highest_orig_datetime,
end_datetime=to_datetime(self.highest_orig_datetime),
fill_method="linear",
),
dtype=float,
dtype=np.float64,
)
covered_hours = (
int((self.highest_orig_datetime - self.ems_start_datetime).total_seconds() // 3600) + 1
@@ -279,7 +279,7 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider):
# above, so ETS/median always trains on the true wholesale-price signal.
history = await self.key_to_array(
key="feed_in_tariff_raw_wh",
end_datetime=self.highest_orig_datetime,
end_datetime=to_datetime(self.highest_orig_datetime),
interval=to_duration(f"{resolution_seconds} seconds"),
fill_method="linear",
)
@@ -137,11 +137,11 @@ class FeedInTariffTibber(FeedInTariffProvider):
history = np.asarray(
await self.key_to_array(
key="feed_in_tariff_wh",
end_datetime=self.highest_orig_datetime,
end_datetime=to_datetime(self.highest_orig_datetime),
interval=to_duration(f"{interval_seconds} seconds"),
fill_method="linear",
),
dtype=float,
dtype=np.float64,
)
covered_slots = 0
if self.highest_orig_datetime >= self.ems_start_datetime:
+6 -3
View File
@@ -5,7 +5,7 @@ Notes:
"""
from abc import abstractmethod
from typing import List, Optional
from typing import Generic, List, Optional, TypeVar
from pydantic import Field
@@ -20,7 +20,10 @@ class LoadDataRecord(PredictionRecord):
)
class LoadProvider(PredictionProvider):
LoadDataRecordT = TypeVar("LoadDataRecordT", bound=LoadDataRecord)
class LoadProvider(PredictionProvider[LoadDataRecordT], Generic[LoadDataRecordT]):
"""Abstract base class for load providers.
LoadProvider is a thread-safe singleton, ensuring only one instance of this class is created.
@@ -41,7 +44,7 @@ class LoadProvider(PredictionProvider):
"""
# overload
records: List[LoadDataRecord] = Field(
records: List[LoadDataRecordT] = Field(
default_factory=list, json_schema_extra={"description": "List of LoadDataRecord records"}
)
@@ -32,7 +32,7 @@ class LoadAkkudoktorDataRecord(LoadDataRecord):
)
class LoadAkkudoktor(LoadProvider):
class LoadAkkudoktor(LoadProvider[LoadAkkudoktorDataRecord]):
"""Fetch Load forecast data from Akkudoktor load profiles."""
records: list[LoadAkkudoktorDataRecord] = Field(
+38 -70
View File
@@ -119,40 +119,42 @@ weather_openmeteo = WeatherOpenMeteo()
weather_import = WeatherImport()
def prediction_providers() -> list[
Union[
ElecFeeFixed,
ElecFeeImport,
ElecPriceAkkudoktor,
ElecPriceEnergyCharts,
ElecPriceFixed,
ElecPriceImport,
ElecPriceSMARD,
ElecPriceTibber,
FeedInTariffAkkudoktor,
FeedInTariffDvhubOnline,
FeedInTariffEnergyCharts,
FeedInTariffFixed,
FeedInTariffImport,
FeedInTariffSMARD,
FeedInTariffTibber,
LoadAkkudoktor,
LoadAkkudoktorAdjusted,
LoadImport,
LoadVrm,
PVForecastAkkudoktor,
PVForecastForecastSolar,
PVForecastImport,
PVForecastPVLib,
PVForecastPVNode,
PVForecastSolcast,
PVForecastVrm,
WeatherBrightSky,
WeatherClearOutside,
WeatherImport,
WeatherOpenMeteo,
]
]:
PredictionProviderType = Union[
ElecFeeFixed,
ElecFeeImport,
ElecPriceAkkudoktor,
ElecPriceEnergyCharts,
ElecPriceFixed,
ElecPriceImport,
ElecPriceSMARD,
ElecPriceTibber,
FeedInTariffAkkudoktor,
FeedInTariffDvhubOnline,
FeedInTariffEnergyCharts,
FeedInTariffFixed,
FeedInTariffImport,
FeedInTariffSMARD,
FeedInTariffTibber,
LoadAkkudoktor,
LoadAkkudoktorAdjusted,
LoadImport,
LoadVrm,
PVForecastAkkudoktor,
PVForecastForecastSolar,
PVForecastHomeAssistant,
PVForecastImport,
PVForecastPVLib,
PVForecastPVNode,
PVForecastSolcast,
PVForecastVrm,
WeatherBrightSky,
WeatherClearOutside,
WeatherImport,
WeatherOpenMeteo,
]
def prediction_providers() -> list[PredictionProviderType]:
"""Return list of prediction providers.
Factory for prediction container.
@@ -229,44 +231,10 @@ def prediction_providers() -> list[
]
class Prediction(PredictionContainer):
class Prediction(PredictionContainer[PredictionProviderType]):
"""Prediction container to manage multiple prediction providers."""
providers: list[
Union[
ElecFeeFixed,
ElecFeeImport,
ElecPriceAkkudoktor,
ElecPriceEnergyCharts,
ElecPriceFixed,
ElecPriceImport,
ElecPriceSMARD,
ElecPriceTibber,
FeedInTariffAkkudoktor,
FeedInTariffDvhubOnline,
FeedInTariffEnergyCharts,
FeedInTariffFixed,
FeedInTariffImport,
FeedInTariffSMARD,
FeedInTariffTibber,
LoadAkkudoktor,
LoadAkkudoktorAdjusted,
LoadImport,
LoadVrm,
PVForecastAkkudoktor,
PVForecastForecastSolar,
PVForecastHomeAssistant,
PVForecastImport,
PVForecastPVLib,
PVForecastPVNode,
PVForecastSolcast,
PVForecastVrm,
WeatherBrightSky,
WeatherClearOutside,
WeatherImport,
WeatherOpenMeteo,
]
] = Field(
providers: list[PredictionProviderType] = Field(
default_factory=prediction_providers,
json_schema_extra={"description": "List of prediction providers"},
)
+20 -7
View File
@@ -8,7 +8,7 @@ This module is designed for use in predictive modeling workflows, facilitating t
and manipulation of configuration and prediction data in a clear, scalable, and structured manner.
"""
from typing import List, Optional
from typing import Generic, List, Optional, TypeVar
from loguru import logger
from pydantic import Field, computed_field
@@ -20,6 +20,7 @@ from akkudoktoreos.core.dataabc import (
DataImportProvider,
DataProvider,
DataRecord,
DataRecordT,
DataSequence,
)
from akkudoktoreos.utils.datetimeutil import DateTime, Duration, to_duration
@@ -52,7 +53,10 @@ class PredictionRecord(DataRecord):
pass
class PredictionSequence(DataSequence):
PredictionRecordT = TypeVar("PredictionRecordT", bound=PredictionRecord)
class PredictionSequence(DataSequence[PredictionRecordT], Generic[PredictionRecordT]):
"""A managed sequence of PredictionRecord instances with list-like behavior.
The PredictionSequence class provides an ordered, mutable collection of PredictionRecord
@@ -90,7 +94,7 @@ class PredictionSequence(DataSequence):
"""
# To be overloaded by derived classes.
records: List[PredictionRecord] = Field(
records: List[PredictionRecordT] = Field(
default_factory=list, json_schema_extra={"description": "List of prediction records"}
)
@@ -185,7 +189,9 @@ class PredictionStartEndKeepMixin(PredictionABC):
return int(duration.total_hours())
class PredictionProvider(PredictionStartEndKeepMixin, DataProvider):
class PredictionProvider(
PredictionStartEndKeepMixin, DataProvider[DataRecordT], Generic[DataRecordT]
):
"""Abstract base class for prediction providers with singleton thread-safety and configurable prediction parameters.
This class serves as a base for managing prediction data, providing an interface for derived
@@ -249,7 +255,9 @@ class PredictionProvider(PredictionStartEndKeepMixin, DataProvider):
await self._update_data(force_update=force_update)
class PredictionImportProvider(PredictionProvider, DataImportProvider):
class PredictionImportProvider(
PredictionProvider[DataRecordT], DataImportProvider[DataRecordT], Generic[DataRecordT]
):
"""Abstract base class for prediction providers that import prediction data.
This class is designed to handle prediction data provided in the form of a key-value dictionary.
@@ -264,7 +272,12 @@ class PredictionImportProvider(PredictionProvider, DataImportProvider):
pass
class PredictionContainer(PredictionStartEndKeepMixin, DataContainer):
PredictionProviderT = TypeVar("PredictionProviderT", bound=PredictionProvider)
class PredictionContainer(
PredictionStartEndKeepMixin, DataContainer[PredictionProviderT], Generic[PredictionProviderT]
):
"""A container for managing multiple PredictionProvider instances.
This class enables access to data from multiple prediction providers, supporting retrieval and
@@ -277,6 +290,6 @@ class PredictionContainer(PredictionStartEndKeepMixin, DataContainer):
"""
# To be overloaded by derived classes.
providers: List[PredictionProvider] = Field(
providers: List[PredictionProviderT] = Field(
default_factory=list, json_schema_extra={"description": "List of prediction providers"}
)
+5 -2
View File
@@ -1,7 +1,7 @@
"""Shared base for price-like predictions (electricity price, feed-in tariff)."""
from abc import abstractmethod
from typing import cast
from typing import Generic, cast
import numpy as np
import pandas as pd
@@ -9,11 +9,14 @@ from loguru import logger
from statsmodels.tsa.holtwinters import ExponentialSmoothing
from akkudoktoreos.core.coreabc import PredictionMixin
from akkudoktoreos.core.dataabc import DataRecordT
from akkudoktoreos.prediction.predictionabc import PredictionProvider
from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime, to_duration
class PricePredictionProviderBase(PredictionMixin, PredictionProvider):
class PricePredictionProviderBase(
PredictionMixin, PredictionProvider[DataRecordT], Generic[DataRecordT]
):
"""Common forecasting + fee-application logic shared by price-like providers.
Subclasses must supply the raw/gross record keys, the fee keys to pull from
@@ -5,7 +5,7 @@ Notes:
"""
from abc import abstractmethod
from typing import List, Optional
from typing import Generic, List, Optional, TypeVar
from loguru import logger
from pydantic import Field
@@ -24,7 +24,10 @@ class PVForecastDataRecord(PredictionRecord):
)
class PVForecastProvider(PredictionProvider):
PVForecastDataRecordT = TypeVar("PVForecastDataRecordT", bound=PVForecastDataRecord)
class PVForecastProvider(PredictionProvider[PVForecastDataRecordT], Generic[PVForecastDataRecordT]):
"""Abstract base class for pvforecast providers.
PVForecastProvider is a thread-safe singleton, ensuring only one instance of this class is created.
@@ -45,7 +48,7 @@ class PVForecastProvider(PredictionProvider):
"""
# overload
records: List[PVForecastDataRecord] = Field(
records: List[PVForecastDataRecordT] = Field(
default_factory=list,
json_schema_extra={"description": "List of PVForecastDataRecord records"},
)
@@ -188,7 +188,7 @@ class PVForecastAkkudoktorDataRecord(PVForecastDataRecord):
return self.pvforecast_ac_power
class PVForecastAkkudoktor(PVForecastProvider):
class PVForecastAkkudoktor(PVForecastProvider[PVForecastAkkudoktorDataRecord]):
"""Fetch and process PV forecast data from akkudoktor.net.
PVForecastAkkudoktor is a singleton-based class that retrieves weather forecast data
@@ -72,6 +72,8 @@ class PVForecastForecastSolar(PVForecastProvider):
return to_datetime(s)
tz = iana_tz or str(self.config.general.timezone)
dt = pendulum.parse(s, tz=tz)
if not isinstance(dt, pendulum.DateTime):
raise ValueError(f"Expected a datetime, got {local_ts!r}")
return to_datetime(dt.isoformat())
def _plane_url(self, plane: Any) -> str:
@@ -100,6 +100,8 @@ class PVForecastPVNode(PVForecastProvider):
tz = iana_tz or str(self.config.general.timezone)
# Interpret the naive wall-clock string AS local time in tz, then resolve.
dt = pendulum.parse(s, tz=tz)
if not isinstance(dt, pendulum.DateTime):
raise ValueError(f"Expected a datetime, got {local_ts!r}")
return to_datetime(dt.isoformat())
def _extract_values(self, body: Any) -> list[tuple[Any, float]]:
+1 -1
View File
@@ -112,7 +112,7 @@ class WeatherDataRecord(PredictionRecord):
)
class WeatherProvider(PredictionProvider):
class WeatherProvider(PredictionProvider[WeatherDataRecord]):
"""Abstract base class for weather providers.
WeatherProvider is a thread-safe singleton, ensuring only one instance of this class is created.
@@ -246,12 +246,15 @@ class WeatherBrightSky(WeatherProvider):
logger.debug(debug_msg)
return
data = pvlib.atmosphere.gueymard94_pw(temperature, humidity)
end_datetime = self.end_datetime
if end_datetime is None:
raise ValueError("Prediction end datetime is not available")
pwat = pd.Series(
data=data,
index=pd.DatetimeIndex(
pd.date_range(
start=self.ems_start_datetime,
end=self.end_datetime,
end=end_datetime,
freq="1h",
inclusive="left",
)
@@ -13,7 +13,7 @@ Notes:
"""
import re
from typing import Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional, Tuple
import pandas as pd
import requests
@@ -232,7 +232,7 @@ class WeatherClearOutside(WeatherProvider):
p_detail_tables.pop(0)
# Create clearout data
clearout_data = {}
clearout_data: dict[str, Any] = {}
# Number of detail values. On last day may be less than 24.
detail_values_count = None
# Add data values
@@ -258,7 +258,7 @@ class WeatherClearOutside(WeatherProvider):
raise ValueError(error_msg)
# Scrape the detail values
detail_data = []
detail_data: list[float | str] = []
extra_detail_name = None
extra_detail_data = []
for p_detail_value in p_detail_values:
@@ -281,9 +281,10 @@ class WeatherClearOutside(WeatherProvider):
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:
@@ -336,9 +337,9 @@ class WeatherClearOutside(WeatherProvider):
if key is None:
continue
if detail_name in clearout_data:
value = clearout_data[detail_name][row_index]
record_value = clearout_data[detail_name][row_index]
corr_factor = clearoutside_key_mapping[detail_name][1]
if corr_factor:
value = value * corr_factor
setattr(weather_record, key, value)
record_value = record_value * corr_factor
setattr(weather_record, key, record_value)
await self.insert_by_datetime(weather_record)
@@ -342,12 +342,15 @@ class WeatherOpenMeteo(WeatherProvider):
return
data = pvlib.atmosphere.gueymard94_pw(temperature, humidity)
end_datetime = self.end_datetime
if end_datetime is None:
raise ValueError("Prediction end datetime is not available")
pwat = pd.Series(
data=data,
index=pd.DatetimeIndex(
pd.date_range(
start=self.ems_start_datetime,
end=self.end_datetime,
end=end_datetime,
freq="1h",
inclusive="left",
)