mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-08-31 20:56:41 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user