mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-08-09 18:36:11 +00:00
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>
420 lines
15 KiB
Python
420 lines
15 KiB
Python
"""UI hint registry for EOSdash configuration forms.
|
|
|
|
This module decouples UI rendering decisions from both the domain models and the
|
|
main ``Configuration()`` render function. Instead of a long if/elif chain that
|
|
maps config field paths to form factories, all those decisions live here as
|
|
structured ``UiHint`` entries in ``UI_HINTS``.
|
|
|
|
Typical usage in ``configuration.py``::
|
|
|
|
from akkudoktoreos.server.dash.uihints import UI_HINTS, resolve_form_factory
|
|
|
|
hint = UI_HINTS.get(config["name"])
|
|
if hint and hint.form == "items":
|
|
rows.append(ConfigItemsCard(config, hint, config_details, config_update_latest))
|
|
elif not config["deprecated"]:
|
|
update_form_factory = resolve_form_factory(hint, config_details) if hint else None
|
|
rows.append(ConfigCard(..., update_form_factory))
|
|
|
|
``ConfigItemsCard`` must live in ``configuration.py`` because it depends on
|
|
``create_config_details`` and ``config_update_latest``. This module only
|
|
carries the *data* that drives it.
|
|
"""
|
|
|
|
import json
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Callable, Literal, Optional
|
|
|
|
from akkudoktoreos.server.dash.components import (
|
|
make_config_update_list_form,
|
|
make_config_update_map_form,
|
|
make_config_update_time_windows_windows_form,
|
|
make_config_update_value_form,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Form type literals
|
|
# ---------------------------------------------------------------------------
|
|
|
|
UiFormType = Literal[
|
|
"text", # plain text input (default)
|
|
"select", # single-value dropdown
|
|
"select_list", # add/delete multi-value list
|
|
"map", # key/value pair editor
|
|
"time_windows", # time-window sequence editor
|
|
"items", # expandable list of sub-model cards
|
|
"map_items", # expandable map of sub-model cards
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# UiHint dataclass
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class UiHint:
|
|
"""Rendering hints for a single configuration field.
|
|
|
|
Attributes:
|
|
form:
|
|
Which form widget to use. Defaults to ``"text"``.
|
|
|
|
options:
|
|
Static allowed values for ``"select"`` / ``"select_list"``.
|
|
|
|
options_from:
|
|
Dotted config-field path whose runtime value provides the
|
|
option list (JSON-encoded ``list[str]``). Takes precedence
|
|
over ``options`` when both are set.
|
|
|
|
param_from:
|
|
Dotted config-field path for a secondary runtime parameter.
|
|
Used by ``"map"`` for the *keys* dropdown.
|
|
|
|
append_none:
|
|
Append ``"None"`` to the resolved option list. Useful for
|
|
nullable single-value selects such as ``*.provider`` fields.
|
|
|
|
value_description:
|
|
Label for the extra numeric column in the ``"time_windows"``
|
|
form (e.g. ``"electricity_price_kwh [Amt/kWh]"``). When
|
|
``None`` no value column is rendered.
|
|
|
|
item_model:
|
|
*``"items"`` only.* The Pydantic model class (or instance)
|
|
whose fields define the per-item sub-cards, e.g.
|
|
``PVForecastPlaneSetting``. Set via ``_ensure_item_models()``
|
|
at first use to avoid circular imports.
|
|
|
|
item_path:
|
|
*``"items"`` only.* Dotted path that locates the list inside
|
|
the synthetic config dict built from the field value. Used to
|
|
construct the ``values_prefix`` for ``create_config_details``.
|
|
|
|
Example: planes are wrapped as
|
|
``{"pvforecast": {"planes": <value>}}`` so ``item_path`` is
|
|
``"pvforecast.planes"``.
|
|
|
|
max_items_from:
|
|
*``"items"`` only.* Dotted config-field path whose integer
|
|
value caps the number of rendered sub-cards (e.g.
|
|
``"pvforecast.max_planes"``). When ``None`` the length of
|
|
the actual list is used instead.
|
|
"""
|
|
|
|
form: UiFormType = "text"
|
|
|
|
# select / select_list / map
|
|
options: list[str] = field(default_factory=list)
|
|
options_from: Optional[str] = None
|
|
param_from: Optional[str] = None
|
|
append_none: bool = False
|
|
|
|
# time_windows
|
|
value_description: Optional[str] = None
|
|
|
|
# items
|
|
item_model: Optional[Any] = None
|
|
item_path: Optional[str] = None
|
|
max_items_from: Optional[str] = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Registry
|
|
# ---------------------------------------------------------------------------
|
|
|
|
UI_HINTS: dict[str, UiHint] = {
|
|
# ------------------------------------------------------------------
|
|
# Adapter - Home Assistant adapter
|
|
# ------------------------------------------------------------------
|
|
"adapter.homeassistant.config_entity_ids": UiHint(
|
|
form="map",
|
|
options_from="adapter.homeassistant.homeassistant_entity_ids",
|
|
),
|
|
"adapter.homeassistant.load_emr_entity_ids": UiHint(
|
|
form="select_list",
|
|
options_from="adapter.homeassistant.homeassistant_entity_ids",
|
|
),
|
|
"adapter.homeassistant.grid_export_emr_entity_ids": UiHint(
|
|
form="select_list",
|
|
options_from="adapter.homeassistant.homeassistant_entity_ids",
|
|
),
|
|
"adapter.homeassistant.grid_import_emr_entity_ids": UiHint(
|
|
form="select_list",
|
|
options_from="adapter.homeassistant.homeassistant_entity_ids",
|
|
),
|
|
"adapter.homeassistant.pv_production_emr_entity_ids": UiHint(
|
|
form="select_list",
|
|
options_from="adapter.homeassistant.homeassistant_entity_ids",
|
|
),
|
|
"adapter.homeassistant.device_measurement_entity_ids": UiHint(
|
|
form="map",
|
|
param_from="devices.measurement_keys",
|
|
options_from="adapter.homeassistant.homeassistant_entity_ids",
|
|
),
|
|
"adapter.homeassistant.device_instruction_entity_ids": UiHint(
|
|
form="select_list",
|
|
options_from="adapter.homeassistant.eos_device_instruction_entity_ids",
|
|
),
|
|
"adapter.homeassistant.solution_entity_ids": UiHint(
|
|
form="select_list",
|
|
options_from="adapter.homeassistant.eos_solution_entity_ids",
|
|
),
|
|
# ------------------------------------------------------------------
|
|
# Devices
|
|
# ------------------------------------------------------------------
|
|
"devices.batteries": UiHint(
|
|
form="items",
|
|
item_path="devices.batteries",
|
|
),
|
|
"devices.electric_vehicles": UiHint(
|
|
form="items",
|
|
item_path="devices.electric_vehicles",
|
|
),
|
|
"devices.home_appliances": UiHint(
|
|
form="items",
|
|
item_path="devices.home_appliances",
|
|
),
|
|
# Sub-field hint for the time_windows field inside each appliance entry
|
|
"devices.home_appliances.cycle_time_windows.windows": UiHint(
|
|
form="time_windows",
|
|
value_description="cycle index (0-based)",
|
|
),
|
|
# ------------------------------------------------------------------
|
|
# Electricity price — fixed time windows
|
|
# ------------------------------------------------------------------
|
|
"elecprice.provider": UiHint(
|
|
form="select",
|
|
options_from="elecprice.providers",
|
|
append_none=True,
|
|
),
|
|
"elecprice.elecpricefixed.time_windows.windows": UiHint(
|
|
form="time_windows",
|
|
value_description="electricity_price_kwh [Amt/kWh]",
|
|
),
|
|
# ------------------------------------------------------------------
|
|
# EMS
|
|
# ------------------------------------------------------------------
|
|
"ems.mode": UiHint(
|
|
form="select",
|
|
options_from="ems.modes",
|
|
),
|
|
# ------------------------------------------------------------------
|
|
# Load
|
|
# ------------------------------------------------------------------
|
|
"load.provider": UiHint(
|
|
form="select",
|
|
options_from="load.providers",
|
|
append_none=True,
|
|
),
|
|
# ------------------------------------------------------------------
|
|
# Optimization
|
|
# ------------------------------------------------------------------
|
|
"optimization.algorithm": UiHint(
|
|
form="select",
|
|
options_from="optimization.algorithms",
|
|
),
|
|
# ------------------------------------------------------------------
|
|
# PV forecast — planes
|
|
# item_model is populated lazily by _ensure_item_models() below.
|
|
# ------------------------------------------------------------------
|
|
"pvforecast.provider": UiHint(
|
|
form="select",
|
|
options_from="pvforecast.providers",
|
|
append_none=True,
|
|
),
|
|
"pvforecast.planes": UiHint(
|
|
form="items",
|
|
item_path="pvforecast.planes",
|
|
max_items_from="pvforecast.max_planes",
|
|
),
|
|
# Per-plane sub-fields; resolved by hint_for_indexed_field()
|
|
"pvforecast.planes.pvtechchoice": UiHint(
|
|
form="select",
|
|
options=["crystSi", "CIS", "CdTe", "Unknown"],
|
|
),
|
|
"pvforecast.planes.mountingplace": UiHint(
|
|
form="select",
|
|
options=["free", "building"],
|
|
),
|
|
# ------------------------------------------------------------------
|
|
# Weather
|
|
# ------------------------------------------------------------------
|
|
"weather.providers": UiHint(
|
|
form="select_list",
|
|
options_from="weather.providers",
|
|
),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lazy item_model resolution (avoids circular imports at module load time)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_item_models_resolved = False
|
|
|
|
|
|
def _ensure_item_models() -> None:
|
|
"""Populate ``item_model`` on any ``"items"`` hints that need it.
|
|
|
|
Domain model imports are deferred to this function so that importing
|
|
``uihints`` early in the boot sequence does not trigger circular imports.
|
|
"""
|
|
if UI_HINTS["pvforecast.planes"].item_model is None:
|
|
from akkudoktoreos.prediction.pvforecast import ( # noqa: PLC0415
|
|
PVForecastPlaneSetting,
|
|
)
|
|
|
|
UI_HINTS["pvforecast.planes"].item_model = PVForecastPlaneSetting
|
|
|
|
if UI_HINTS["devices.batteries"].item_model is None:
|
|
from akkudoktoreos.devices.devices import (
|
|
BatteriesCommonSettings,
|
|
)
|
|
|
|
UI_HINTS["devices.batteries"].item_model = BatteriesCommonSettings
|
|
|
|
if UI_HINTS["devices.electric_vehicles"].item_model is None:
|
|
from akkudoktoreos.devices.devices import (
|
|
BatteriesCommonSettings,
|
|
)
|
|
|
|
UI_HINTS["devices.electric_vehicles"].item_model = BatteriesCommonSettings
|
|
|
|
if UI_HINTS["devices.home_appliances"].item_model is None:
|
|
from akkudoktoreos.devices.devices import (
|
|
HomeApplianceCommonSettings,
|
|
)
|
|
|
|
UI_HINTS["devices.home_appliances"].item_model = HomeApplianceCommonSettings
|
|
|
|
|
|
def resolve_item_model(hint: UiHint) -> Optional[Any]:
|
|
"""Return the ``item_model`` for an ``"items"`` hint, resolving lazily.
|
|
|
|
Args:
|
|
hint: A ``UiHint`` with ``form == "items"``.
|
|
|
|
Returns:
|
|
The model class or instance, or ``None`` if unset.
|
|
"""
|
|
global _item_models_resolved
|
|
if not _item_models_resolved:
|
|
_ensure_item_models()
|
|
_item_models_resolved = True
|
|
return hint.item_model
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Resolver
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def resolve_form_factory(
|
|
hint: UiHint,
|
|
config_details: dict[str, dict],
|
|
) -> Optional[Callable]:
|
|
"""Materialise a ``UiHint`` into a concrete ``update_form_factory`` callable.
|
|
|
|
For ``"items"`` hints this returns ``None`` — the caller must dispatch
|
|
to ``ConfigItemsCard`` separately after checking ``hint.form == "items"``.
|
|
For ``"text"`` this returns ``None`` — the caller uses the default
|
|
plain-text input. All other form types return a callable.
|
|
|
|
Args:
|
|
hint:
|
|
The ``UiHint`` to materialise.
|
|
config_details:
|
|
The fully-resolved config detail dict from
|
|
``create_config_details()``. Used to look up runtime option
|
|
lists via ``options_from`` / ``param_from``.
|
|
|
|
Returns:
|
|
A ``(config_name: str, value: str) -> Grid`` factory, or ``None``.
|
|
"""
|
|
|
|
def _load_list(key: str) -> list[str]:
|
|
try:
|
|
result = json.loads(config_details[key]["value"])
|
|
return result if isinstance(result, list) else []
|
|
except Exception:
|
|
return []
|
|
|
|
if hint.form in ("text", "items", "map_items"):
|
|
return None
|
|
|
|
if hint.form == "select":
|
|
options: list[str] = []
|
|
if hint.options_from:
|
|
options = _load_list(hint.options_from)
|
|
if not options:
|
|
options = list(hint.options)
|
|
if hint.append_none and "None" not in options:
|
|
options.append("None")
|
|
return make_config_update_value_form(options)
|
|
|
|
if hint.form == "select_list":
|
|
options = []
|
|
if hint.options_from:
|
|
options = _load_list(hint.options_from)
|
|
if not options:
|
|
options = list(hint.options)
|
|
return make_config_update_list_form(options)
|
|
|
|
if hint.form == "map":
|
|
available_values: Optional[list[str]] = None
|
|
available_keys: Optional[list[str]] = None
|
|
if hint.options_from:
|
|
available_values = _load_list(hint.options_from) or None
|
|
if hint.param_from:
|
|
available_keys = _load_list(hint.param_from) or None
|
|
return make_config_update_map_form(available_keys, available_values)
|
|
|
|
if hint.form == "time_windows":
|
|
return make_config_update_time_windows_windows_form(
|
|
value_description=hint.value_description,
|
|
)
|
|
|
|
return None # unreachable for valid UiFormType values
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Suffix-based lookup for indexed sub-model fields
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def hint_for_indexed_field(field_name: str, list_path: str) -> Optional[UiHint]:
|
|
"""Return the UiHint for a sub-field inside an 'items' or 'map_items' list.
|
|
|
|
Strips the index segment (numeric for lists, any string for maps) from a
|
|
dotted field name and looks up the canonical hint key.
|
|
|
|
Args:
|
|
field_name:
|
|
Full dotted config name including the index, e.g.
|
|
``"pvforecast.planes.2.mountingplace"`` or
|
|
``"devices.home_appliances.dishwasher1.time_windows"``.
|
|
list_path:
|
|
The ``item_path`` from the parent ``UiHint``, e.g.
|
|
``"pvforecast.planes"`` or ``"devices.home_appliances"``.
|
|
|
|
Returns:
|
|
The matching ``UiHint``, or ``None`` if none is registered.
|
|
"""
|
|
prefix = list_path + "."
|
|
if not field_name.startswith(prefix):
|
|
return None
|
|
remainder = field_name[len(prefix) :] # e.g. "2.mountingplace" or "dishwasher1.time_windows"
|
|
parts = remainder.split(".", 1)
|
|
if len(parts) < 2:
|
|
return None
|
|
# Accept both numeric (list) and string (map) index segments
|
|
canonical = list_path + "." + parts[1]
|
|
return UI_HINTS.get(canonical)
|
|
|
|
|
|
def hint_for_plane_field(field_name: str) -> Optional[UiHint]:
|
|
"""Back-compat wrapper — prefer ``hint_for_indexed_field`` directly."""
|
|
return hint_for_indexed_field(field_name, "pvforecast.planes")
|