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
+570
View File
@@ -0,0 +1,570 @@
"""Generic expandable map-of-sub-model configuration card for EOSdash.
This module provides `ConfigMapCard`, a reusable FastHTML/MonsterUI
card component that renders any ``dict[str, PydanticSubModel]`` config field
as a collapsible outer card containing one collapsible inner card per map
entry, keyed by a user-supplied string name.
It is intentionally free of imports from ``configuration.py`` to avoid
circular dependencies. The one runtime dependency on
``create_config_details`` is injected by the caller.
The structure mirrors ``itemscard.py`` with these key differences:
- The stored value is ``dict[str, dict]`` rather than ``list[dict]``.
- The "Add entry" control includes a text input for the key name.
- Delete removes by key rather than by index.
- Inner card headers display the string key instead of a numeric index.
- ``create_config_details`` is called with the string key as the final
``values_prefix`` segment.
Typical usage in ``configuration.py``::
from akkudoktoreos.server.dash.mapcard import ConfigMapCard
hint = UI_HINTS.get(config["name"])
if hint and hint.form == "map_items" and not config["deprecated"]:
rows.append(
ConfigMapCard(
config=config,
hint=hint,
config_details=config_details,
config_update_latest=config_update_latest,
create_config_details=create_config_details,
)
)
"""
import json
from typing import Any, Callable, Optional
from loguru import logger
from monsterui.franken import (
H4,
Card,
Details,
Div,
DivHStacked,
DivLAligned,
DivRAligned,
Form,
Grid,
Input,
Kbd,
P,
Summary,
UkIcon,
)
from akkudoktoreos.server.dash.carditems import (
item_model_defaults,
required_field_inputs,
)
from akkudoktoreos.server.dash.components import (
ConfigButton,
ConfigCard,
JsonView,
UpdateError,
)
from akkudoktoreos.server.dash.context import request_url_for
from akkudoktoreos.server.dash.markdown import Markdown
from akkudoktoreos.server.dash.uihints import (
UiHint,
hint_for_indexed_field,
resolve_form_factory,
resolve_item_model,
)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _add_control(
config_name: str,
items_map: dict,
item_model: Any,
config_id: str,
read_only: str,
) -> Any:
"""Build the "Add entry" control.
Always includes a text input for the new key name. When the item model
can be fully defaulted, that's the only input needed — clicking "Add
entry" reads the key and submits the map with the new key set to the
model defaults. When required fields have no default (e.g.
`consumption_wh`/`duration_h` on `HomeApplianceCommonSettings`), the
control also renders inputs for those fields, all wrapped in a form so
HTML `required` blocks submission until the key and every required
field are filled in — otherwise clicking "Add entry" would persist an
entry that fails the model's own validation.
If the typed key already exists the existing entry is overwritten — this
is intentional and allows renaming-by-copy when combined with delete.
Args:
config_name: Dotted config key name.
items_map: The current map, used as the base for the JS merge.
item_model: The Pydantic model class or instance for one map entry.
config_id: CSS-safe version of ``config_name`` (dots replaced with
hyphens), used to scope element ids and the key input's name.
Returns:
A ``Form`` containing the key input, any required-field inputs, and
the "Add entry" button.
"""
new_entry_defaults, required_missing = item_model_defaults(item_model)
id_prefix = f"{config_id}-new-entry"
inputs, js_pairs = required_field_inputs(item_model, required_missing, id_prefix)
extra_js = f"{{ {', '.join(js_pairs)} }}" if js_pairs else "{}"
current_json = json.dumps(items_map)
defaults_json = json.dumps(new_entry_defaults)
build_value_expr = f"""(() => {{
const k = document.querySelector("[name='{config_id}_new_key']").value.trim();
if (!k) return {json.dumps(json.dumps(items_map))};
const defaults = {defaults_json};
const extra = {extra_js};
Object.assign(defaults, extra);
if ('device_id' in defaults) defaults.device_id = k;
const updated = Object.assign({{}}, {current_json}, {{ [k]: defaults }});
return JSON.stringify(updated);
}})()"""
return Form(
Grid(
Input(
placeholder="Entry name / key",
name=f"{config_id}_new_key",
id=f"{config_id}-new-key",
required=True,
cls="border rounded px-3 py-2 text-sm",
),
*inputs,
cols=2,
cls="gap-2",
),
ConfigButton(
UkIcon("plus"),
" Add entry",
hx_put=request_url_for("/eosdash/configuration"),
hx_target="#page-content",
hx_swap="innerHTML",
hx_vals=f'js:{{ action: "update", key: "{config_name}", value: {build_value_expr} }}',
cls="mt-2",
),
cls="space-y-2 mt-3",
)
def _delete_control(config_name: str, items_map: dict, key: str) -> Details:
"""Build the two-click delete control for a single inner entry card header.
The first click opens a ``<details>`` panel revealing a red "Confirm
delete" button. Clicking outside collapses it. The second click submits
an ``hx_put`` with the map minus the given key.
Args:
config_name: Dotted config key name, e.g. ``"devices.batteries"``.
items_map: The current full map of entries.
key: The string key of the entry to delete.
Returns:
A ``Details`` component implementing the two-click confirm pattern.
"""
remaining = {k: v for k, v in items_map.items() if k != key}
remaining_json = json.dumps(json.dumps(remaining))
return Details(
Summary(
UkIcon("trash-2", cls="text-muted-foreground hover:text-destructive cursor-pointer"),
cls="list-none",
),
Div(
ConfigButton(
UkIcon("trash-2"),
" Confirm delete",
hx_put=request_url_for("/eosdash/configuration"),
hx_target="#page-content",
hx_swap="innerHTML",
hx_vals=f'js:{{ action: "update", key: "{config_name}", value: {remaining_json} }}',
cls="px-3 py-1 text-sm bg-destructive text-destructive-foreground hover:bg-destructive/90",
),
cls="absolute z-10 mt-1 p-2 rounded-md border bg-background shadow-md",
),
cls="relative",
)
def _inner_card(
config_name: str,
item_path: str,
key: str,
item_value: str,
is_empty: bool,
read_only: str,
item_rows: list,
item_update_open: bool,
delete_control: Optional[Details],
) -> Card:
"""Render a single collapsible inner card for one map entry.
Args:
config_name: Dotted config key of the parent map field.
item_path: Dotted path prefix for this entry type, e.g.
``"devices.batteries"``.
key: The string key identifying this entry in the map.
item_value: JSON-encoded current value of this entry.
is_empty: ``True`` when the entry dict is falsy (empty or ``None``).
read_only: ``"rw"`` or ``"ro"`` inherited from the parent field.
item_rows: Pre-built list of ``ConfigCard`` children for this entry.
item_update_open: Whether this card should start expanded.
delete_control: The two-click delete ``Details`` widget, or ``None``
for read-only fields.
Returns:
A ``Card`` component for this map entry.
"""
return Card(
Details(
Summary(
Grid(
Grid(
DivLAligned(
UkIcon(icon="play"),
H4(
f"{item_path}.{key}",
cls="text-muted-foreground" if is_empty else "",
),
delete_control,
),
DivRAligned(
P(
"empty" if is_empty else read_only,
cls="text-xs text-muted-foreground" if is_empty else "",
),
),
),
JsonView(json.loads(item_value)),
),
cls="list-none",
),
*item_rows,
cls="space-y-4 gap-4",
open=item_update_open,
),
cls=f"w-full {'opacity-60' if is_empty else ''}",
)
def _outer_card(
config_name: str,
config_type: str,
read_only: str,
value: str,
default: str,
description: str,
scope: Optional[list[str]],
num_entries: int,
items_update_value: str,
items_update_error: Optional[str],
items_update_open: bool,
rows: list,
add_control: Optional[Grid],
) -> Card:
"""Render the outer collapsible card for the whole map field.
Args:
config_name: Dotted config key name.
config_type: Human-readable type string from config details.
read_only: ``"rw"`` or ``"ro"``.
value: JSON-encoded current map value.
default: JSON-encoded default value.
description: Field description text.
num_entries: Current number of entries, shown as a badge.
items_update_value: Value to pre-fill the fallback text input.
items_update_error: Error string from the last failed update, or
``None``.
items_update_open: Whether the outer card starts expanded.
rows: Pre-built list of inner ``Card`` components.
add_control: The "Add entry" ``Grid`` widget, or ``None`` for
read-only fields.
Returns:
The outer ``Card`` component.
"""
return Card(
Details(
Summary(
Grid(
Grid(
DivLAligned(
UkIcon(icon="play"),
P(config_name),
P(
f"{num_entries} entr{'ies' if num_entries != 1 else 'y'}",
cls="ml-2 text-xs text-muted-foreground",
),
),
DivRAligned(P(read_only)),
),
JsonView(json.loads(value)),
),
cls="list-none",
),
# Add entry control below summary
add_control,
Grid(
Div(
DivHStacked(*[Kbd(s) for s in scope]) if scope else None,
Markdown(description),
),
P(config_type),
),
# Default value row
Grid(
DivRAligned(P("default")),
P(default),
)
if read_only == "rw"
else None,
# Raw JSON fallback update form
Grid(
DivRAligned(P("update")),
Grid(
Form(
Input(value="update", type="hidden", id="action"),
Input(value=config_name, type="hidden", id="key"),
Input(value=items_update_value, type="text", id="value"),
hx_put=request_url_for("/eosdash/configuration"),
hx_target="#page-content",
hx_swap="innerHTML",
),
),
)
if read_only == "rw"
else None,
# Last update error
Grid(
DivRAligned(P("update error")),
UpdateError(items_update_error),
)
if items_update_error
else None,
# Per-entry inner cards
*rows,
cls="space-y-4 gap-4",
open=items_update_open,
),
cls="w-full",
)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def ConfigMapCard(
config: dict,
hint: UiHint,
config_details: dict[str, dict],
config_update_latest: dict[str, dict],
create_config_details: Callable,
) -> Card:
"""Creates a styled configuration card for a map of Pydantic sub-model entries.
Renders a collapsible outer card representing the map field as a whole,
containing one collapsible inner card per map entry keyed by a string
name. Each inner card expands into individual ``ConfigCard`` rows for
every field of the entry's Pydantic sub-model.
The map contents are driven entirely by user interaction. An "Add entry"
control at the bottom of the outer card accepts a key name and appends a
new entry pre-filled with the sub-model's Pydantic field defaults. Each
inner card header carries a trash icon that arms on first click (showing
a red "Confirm delete" button via a ``<details>`` toggle) and deletes on
the second click, with no modal required.
Per-entry field forms are resolved via ``hint_for_indexed_field`` using
the parent hint's ``item_path``, so per-field UI customisation
(dropdowns, selects, etc.) is driven entirely by ``UI_HINTS`` entries —
no hard-coded field-name checks are needed here.
The outer card always includes a plain-text fallback update form for the
whole map value so that recovery from a validation error is always
possible.
Args:
config: A single entry from the ``config_details`` dict produced by
``create_config_details()``. Must contain the keys ``"name"``,
``"type"``, ``"read-only"``, ``"value"``, ``"default"``,
``"description"``, ``"deprecated"``, and ``"scope"``.
hint: The ``UiHint`` for this field. Must have
``form == "map_items"`` and valid ``item_model`` (resolved via
``resolve_item_model``) and ``item_path`` values.
config_details: The full config detail dict for the current page
render, used to look up per-entry field update state.
config_update_latest: The module-level dict that tracks the most
recent update attempt for each config key, with sub-keys
``"error"``, ``"value"``, and ``"open"``.
create_config_details: The ``create_config_details`` callable from
``configuration.py``, injected to avoid a circular import.
Signature: ``(model, values, values_prefix) -> dict[str, dict]``.
Returns:
Card: A fully rendered outer ``Card`` component containing the map
summary with entry count, description, default value row, a raw-JSON
fallback update form, an optional error row, one collapsible inner
``Card`` per existing entry each with a two-click delete control, and
an "Add entry" control at the bottom.
Raises:
TypeError: If ``update_error``, ``update_value``, or ``update_open``
retrieved from ``config_update_latest`` are not of the expected
types (``str | None``, ``str | None``, ``bool | None``
respectively). This should never trigger in normal operation but
is checked explicitly to satisfy static analysis.
Example:
Typical call from inside the ``Configuration()`` render loop::
from akkudoktoreos.server.dash.mapcard import ConfigMapCard
hint = UI_HINTS.get(config["name"])
if hint and hint.form == "map_items" and not config["deprecated"]:
rows.append(
ConfigMapCard(
config=config,
hint=hint,
config_details=config_details,
config_update_latest=config_update_latest,
create_config_details=create_config_details,
)
)
"""
config_name = config["name"]
config_type = config["type"]
read_only = config["read-only"]
value = config["value"]
default = config["default"]
description = config["description"]
config_id = config_name.lower().replace(".", "-")
item_model = resolve_item_model(hint)
item_path = hint.item_path # e.g. "devices.batteries"
if item_path is None:
raise ValueError(f"Hint needs item_path to be mapped. Got {hint}")
path_parts = item_path.split(".") # e.g. ["devices", "batteries"]
items_map = json.loads(value) or {}
num_entries = len(items_map)
# Outer card update state — resolved once before the inner loop
items_update_error = config_update_latest.get(config_name, {}).get("error")
items_update_value = config_update_latest.get(config_name, {}).get("value") or value
items_update_open = config_update_latest.get(config_name, {}).get("open") or False
# Add entry control (key input + button) shown at the bottom of the card.
# One-click append when the item model is fully defaulted, otherwise an inline form that
# collects required fields before the PUT fires (see _add_control / _item_model_defaults
# docstrings).
add_control = _add_control(
config_name=config_name,
items_map=items_map,
item_model=item_model,
config_id=config_id,
read_only=read_only,
)
# Build inner cards — one per map key, sorted for stable ordering
rows = []
for key in sorted(items_map.keys()):
entry = items_map[key]
# Synthetic wrapper: e.g. {"devices": {"batteries": {"bat1": {...}}}}
wrapped = {key: entry}
for part in reversed(path_parts):
wrapped = {part: wrapped}
item_config = create_config_details(
item_model,
wrapped,
values_prefix=path_parts + [key],
)
item_rows = []
item_update_open = False
item_value = json.dumps(entry) if entry is not None else json.dumps(None)
is_empty = not entry
for field_key in sorted(item_config.keys()):
sub = item_config[field_key]
update_error = config_update_latest.get(sub["name"], {}).get("error")
update_value = config_update_latest.get(sub["name"], {}).get("value")
update_open = config_update_latest.get(sub["name"], {}).get("open")
if update_open:
items_update_open = True # bubble up to outer card
item_update_open = True
# Make mypy happy — should never trigger
if (
not isinstance(update_error, (str, type(None)))
or not isinstance(update_value, (str, type(None)))
or not isinstance(update_open, (bool, type(None)))
):
error_msg = "update_error or update_value or update_open of wrong type."
logger.error(error_msg)
raise TypeError(error_msg)
sub_hint = hint_for_indexed_field(sub["name"], item_path)
update_form_factory = (
resolve_form_factory(sub_hint, config_details) if sub_hint else None
)
item_rows.append(
ConfigCard(
sub["name"],
sub["type"],
sub["read-only"],
sub["value"],
sub["default"],
sub["description"],
sub["deprecated"],
sub["scope"],
update_error,
update_value,
update_open,
update_form_factory,
)
)
rows.append(
_inner_card(
config_name=config_name,
item_path=item_path,
key=key,
item_value=item_value,
is_empty=is_empty,
read_only=read_only,
item_rows=item_rows,
item_update_open=item_update_open,
delete_control=_delete_control(config_name, items_map, key)
if read_only == "rw"
else None,
)
)
return _outer_card(
config_name=config_name,
config_type=config_type,
read_only=read_only,
value=value,
default=default,
description=description,
scope=config.get("scope"),
num_entries=num_entries,
items_update_value=items_update_value,
items_update_error=items_update_error,
items_update_open=items_update_open,
rows=rows,
add_control=add_control,
)