Files
EOS/src/akkudoktoreos/server/dash/uihints.py
T
Bobby NoelteandGitHub 894790f577 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>
2026-08-07 13:13:17 +02:00

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")