feat: add pvlib pv forecast provider (#1214)

Add a PV forecast provider that calculates the forecast using a PVLib system model
and weather forecast from the EOS weather forecast provider.

Additional module and inverter models can be easily added as the database
is build from PVLib and SAM databases and a bundled csv file.

The module model and inververt model names are provided by new endpoints
to be used in configuration.

The provider is based on the fantastic work of EMHASS. See
https://github.com/davidusb-geek/emhass/blob/master/src/emhass/forecast.py

A short description of the provider is added to the documentation.

Besides the new features there are the fixes and improvements:

* feat: improve EOSdash config page

* fix: kex_to_series for start_datetime

  Make key_to_series always start the series at start_datetime.

* fix: default provider for GENETIC and GENETIC0 optimization

  To make the default less dependent on internet servers (with API changes and
  availability issues) the default for PVForecast is set to PVForecastPVLib
  and for ElecPrice to ElecPriceFixed. The default weather provider is changed
  to OpenMeteo.

* fix: EOSdash display resampled prediction values

  Make EOSdash display resampled prediction values where  resampling fits to
  the prediction value type. Use bar width that fits to 15 minutes value samples.

* chore: add a UI hints system to EOSdash

  The UI hints system eases the definition of forms for configuration items.
  There are also forms for items in maps and lists. These forms allow to add and delete
  items to/ from  maps and lists. The forms ensure that all required fields of newly
  added items are filled.

* chore: Create an enum for valid optimization algorithms

* chore. Make config also provide the available energy management modes.

  Used for configuration hints.

* chore: Randomize default device id in configuration

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
This commit is contained in:
Bobby Noelte
2026-08-07 13:13:17 +02:00
committed by GitHub
parent 9189fc890e
commit 894790f577
49 changed files with 29242 additions and 667 deletions
+44 -33
View File
@@ -1258,9 +1258,8 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
- 15-minute interval → buckets on :00, :15, :30, :45
- 1-hour interval → buckets on the hour
When False (default), the origin is ``query_start`` (or ``"start_day"`` when
no start is given), preserving the existing behaviour where buckets are
aligned to the query window rather than the clock.
When False (default), the origin is the requested start_datetime, or the timestamp
of the first returned sample if no start time was specified.
Set to True when storing compacted records back to the database so that the
resulting timestamps are predictable and human-readable. Leave False for
@@ -1323,25 +1322,33 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
key=key, start_datetime=query_start, end_datetime=query_end, dropna=dropna
)
# Determine the resampling start to be used to calculate resample origin
if start_datetime is not None:
# Use user supplied start datetime for resampling start
resample_start = start_datetime
elif not series.empty:
# Use first data sample to define the resampling start
resample_start = to_datetime(series.index[0])
else:
# No explicit start and no data available.
resample_start = None
# Ensure we have at least one value
if series.empty:
dummy_time = (
query_start - interval if query_start is not None else to_datetime(to_maxtime=False)
)
dummy_time = start_datetime or end_datetime or to_datetime(to_maxtime=False)
series = pd.Series(
[None],
index=pd.DatetimeIndex([dummy_time], tz="UTC"),
name=key,
)
# prepend context samples
if query_start is not None:
idx = series.index
# Number of samples before query_start
start_index = idx.searchsorted(pd.Timestamp(query_start), side="left")
if start_index == 0:
# No value before query_start -> prepend dummy
prepend = pd.Series(
[series.iloc[0]],
index=pd.DatetimeIndex([query_start - interval], tz="UTC"),
@@ -1350,30 +1357,9 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
series = pd.concat([prepend, series])
elif start_index > 1:
# Keep only the last sample before query_start
series = series.iloc[start_index - 1 :]
# Determine resample origin
if align_to_interval:
# Snap to nearest UTC epoch-aligned floor of the interval so that bucket
# timestamps land on wall-clock-round boundaries (:00, :15, :30, :45 etc.)
# regardless of sub-second jitter in query_start.
interval_sec = int(interval.total_seconds())
if interval_sec > 0:
start_epoch = int(query_start.timestamp())
floored_epoch = (start_epoch // interval_sec) * interval_sec
resample_origin: Union[str, pd.Timestamp] = pd.Timestamp(
floored_epoch, unit="s", tz="UTC"
)
else:
resample_origin = query_start
else:
# Original behaviour: align to the query window start.
resample_origin = query_start
else:
# We do not have a query_start, align resample buckets to midnight of first day
resample_origin = "start_day"
# append context samples
if query_end is not None:
if compare_datetimes(to_datetime(series.index[-1]), query_end).lt:
append = pd.Series(
@@ -1383,6 +1369,26 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
)
series = pd.concat([series, append])
# Determine resampling origin
if align_to_interval and resample_start:
interval_sec = int(interval.total_seconds())
if interval_sec > 0:
start_epoch = int(resample_start.timestamp())
floored_epoch = (start_epoch // interval_sec) * interval_sec
resample_origin: Union[pd.Timestamp, str] = pd.Timestamp(
floored_epoch, unit="s", tz="UTC"
)
else:
resample_origin = resample_start
else:
# Preserve original behaviour: buckets start at the resample start.
resample_origin = resample_start
if resample_origin is None:
# We have no resample origin - take start of day as default
resample_origin = "start_day"
# Check for numeric values
numeric_series = pd.to_numeric(series, errors="coerce") # ensures float64, not object dtype
is_numeric = numeric_series.dropna().notna().all()
@@ -2611,7 +2617,8 @@ class DataContainer(SingletonMixin, DataABC):
continue
if series is None:
raise KeyError(f"No data found for key '{key}'.")
provider_ids = [provider.provider_id() for provider in self.enabled_providers]
raise KeyError(f"No data found for key '{key}' in enabled providers '{provider_ids}'.")
return series
@@ -2804,7 +2811,6 @@ class DataContainer(SingletonMixin, DataABC):
if end_datetime:
end_datetime = end_datetime.add(seconds=1)
# Create a DatetimeIndex based on start, end, and interval
if start_datetime is None or end_datetime is None:
raise ValueError(
f"Can not determine datetime range. Got '{start_datetime}'..'{end_datetime}'."
@@ -2832,8 +2838,13 @@ class DataContainer(SingletonMixin, DataABC):
)
if reference_index is None:
reference_index = series.index
reference_index = series.index.copy()
elif not series.index.equals(reference_index):
logger.error(
f"keys_to_dataframe: Time index mismatch for key '{key}'.\n"
f"ref: {reference_index},\n"
f"index: {series.index}"
)
raise ValueError(f"Time index mismatch for key '{key}'.")
data[key] = series
+8 -5
View File
@@ -27,7 +27,10 @@ from akkudoktoreos.optimization.genetic.geneticparams import (
GeneticOptimizationParameters,
)
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSolution
from akkudoktoreos.optimization.optimization import OptimizationSolution
from akkudoktoreos.optimization.optimization import (
OptimizationAlgorithm,
OptimizationSolution,
)
from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime
# The executor to execute the CPU heavy energy management run
@@ -168,7 +171,7 @@ class EnergyManagement(
self,
start_datetime: Optional[DateTime] = None,
mode: Optional[EnergyManagementMode] = None,
algorithm: Optional[str] = None,
algorithm: Optional[OptimizationAlgorithm] = None,
genetic_parameters: Optional[GeneticOptimizationParameters] = None,
genetic_generations: Optional[int] = None,
genetic_seed: Optional[int] = None,
@@ -192,7 +195,7 @@ class EnergyManagement(
- "DISABLED": Does not run.
Defaults to the mode defined in the current configuration.
algorithm (str, optional):
algorithm (OptimizationAlgorithm, optional):
The algorithm to use. Must be one of:
- "GENETIC": Optimization uses the `GENETIC` optimization algorithm.
- "GENETIC0": Optimization uses the `GENETIC0` optimization algorithm.
@@ -276,7 +279,7 @@ class EnergyManagement(
algorithm = self.config.optimization.algorithm
# --- GENETIC algorithm ---
if algorithm == "GENETIC":
if algorithm == OptimizationAlgorithm.GENETIC:
# Prepare optimization parameters
# This also creates default configurations for missing values and updates the predictions
logger.info(f"{algorithm}: Starting optimzation parameter preparation.")
@@ -342,7 +345,7 @@ class EnergyManagement(
)
# --- GENETIC0 algorithm ---
elif algorithm == "GENETIC0":
elif algorithm == OptimizationAlgorithm.GENETIC0:
# Prepare optimization parameters
# This also creates default configurations for missing values and updates the predictions
logger.info(f"{algorithm}: Starting optimzation parameter preparation.")
+13 -3
View File
@@ -5,7 +5,7 @@ Kept in an extra module to avoid cyclic dependencies on package import.
from enum import StrEnum
from pydantic import Field
from pydantic import Field, computed_field
from akkudoktoreos.config.configabc import SettingsBaseModel, is_home_assistant_addon
@@ -51,7 +51,17 @@ class EnergyManagementCommonSettings(SettingsBaseModel):
mode: EnergyManagementMode = Field(
default_factory=ems_default_mode,
json_schema_extra={
"description": "Energy management mode [DISABLED | OPTIMIZATION | PREDICTION].",
"examples": ["OPTIMIZATION", "PREDICTION"],
"description": (
f"Energy management mode "
f"[{' | '.join(mode.value for mode in EnergyManagementMode)}]. "
f"Defaults to {ems_default_mode()}."
),
"examples": ["OPTIMIZATION"],
},
)
@computed_field # type: ignore[prop-decorator]
@property
def modes(self) -> list[str]:
"""Available energy management modes."""
return [mode.value for mode in EnergyManagementMode]