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
+123
View File
@@ -0,0 +1,123 @@
"""Shared helpers for list- and map-of-sub-model configuration cards.
Used by both ``itemscard.py`` (``list[PydanticSubModel]`` fields) and
``mapcard.py`` (``dict[str, PydanticSubModel]`` fields) to avoid duplicating
the Pydantic-introspection and required-field-collection logic between the
two card types. Free of imports from ``configuration.py``, ``itemscard.py``,
or ``mapcard.py`` to avoid circular dependencies.
"""
from typing import Any, cast
from monsterui.franken import Div, Input, P
from pydantic import BaseModel
from pydantic_core import PydanticUndefined
def resolve_model_cls(item_model: Any) -> type[BaseModel]:
"""Resolve a Pydantic model class from either a class or an instance.
Args:
item_model: A Pydantic model class or an instance of one.
Returns:
The model class, cast for mypy's benefit (``isinstance(x, type)``
alone narrows to plain ``type``, not ``type[BaseModel]``).
"""
if isinstance(item_model, type):
return cast(type[BaseModel], item_model)
return cast(type[BaseModel], type(item_model))
def item_model_defaults(item_model: Any) -> tuple[dict, list[str]]:
"""Build defaults for a Pydantic sub-model and report required-but-unset fields.
Constructs a model instance using only fields that have defaults (either
``default`` or ``default_factory``), then serialises via
``model_dump(mode="json")`` to produce a fully JSON-safe dict. Fields
without any default are reported separately rather than silently
omitted — a freshly-constructed instance missing them would fail the
model's own validation (e.g. ``consumption_wh``/``duration_h`` on
``HomeApplianceCommonSettings``), so callers must collect values for
them before persisting a new item or entry.
Args:
item_model: A Pydantic model class or instance whose ``model_fields``
will be inspected.
Returns:
A tuple of ``(defaults, required_missing)`` where ``defaults``
contains every field that has a ``default`` or ``default_factory``,
JSON-safe and ready to serialise, and ``required_missing`` lists the
field names that have neither.
"""
model_cls = resolve_model_cls(item_model)
kwargs: dict[str, Any] = {}
required_missing: list[str] = []
for field_name, field_info in model_cls.model_fields.items():
if field_info.default is not PydanticUndefined:
kwargs[field_name] = field_info.default
elif field_info.default_factory is not None:
kwargs[field_name] = field_info.default_factory()
else:
required_missing.append(field_name)
instance = model_cls.model_construct(**kwargs)
defaults = instance.model_dump(mode="json", exclude_unset=True)
return defaults, required_missing
def required_field_inputs(
item_model: Any,
required_missing: list[str],
id_prefix: str,
) -> tuple[list[Div], list[str]]:
"""Build labelled inputs for a sub-model's required-but-undefaulted fields.
Produces one ``Div``-wrapped ``Input`` per field in ``required_missing``,
each carrying HTML ``required`` so the browser blocks form submission
until every field has a value, plus the corresponding JS expressions for
reading those inputs back out at submit time.
Args:
item_model: The Pydantic model class or instance the fields belong
to, used to pick ``type="number"`` vs ``type="text"``.
required_missing: Field names with no default, as returned by
``item_model_defaults``.
id_prefix: A CSS/DOM-safe prefix (e.g. derived from the config name)
used to build unique element ids for each input.
Returns:
A tuple of ``(inputs, js_pairs)`` where ``inputs`` is the list of
rendered ``Div`` components to place in the form, and ``js_pairs``
is a list of ``'"field_name": <js expression>'`` strings suitable
for splicing into a JS object literal that reads the DOM values.
"""
model_cls = resolve_model_cls(item_model)
inputs: list[Div] = []
js_pairs: list[str] = []
for field_name in required_missing:
field_id = f"{id_prefix}-{field_name}".replace(".", "-").replace("_", "-")
annotation = model_cls.model_fields[field_name].annotation
is_numeric = annotation in (int, float)
inputs.append(
Div(
P(field_name, cls="text-xs text-muted-foreground"),
Input(
id=field_id,
type="number" if is_numeric else "text",
required=True,
placeholder=field_name,
),
)
)
value_expr = (
f'Number(document.getElementById("{field_id}").value)'
if is_numeric
else f'document.getElementById("{field_id}").value'
)
js_pairs.append(f'"{field_name}": {value_expr}')
return inputs, js_pairs
+262 -76
View File
@@ -22,6 +22,7 @@ from monsterui.franken import ( # Select: Does not work - using Select from Fas
Form,
Grid,
Input,
Kbd,
Option,
P,
Pre,
@@ -31,6 +32,27 @@ from monsterui.franken import ( # Select: Does not work - using Select from Fas
)
from akkudoktoreos.server.dash.context import request_url_for
from akkudoktoreos.server.dash.markdown import Markdown
# ---------------------------------------------------------------------------
# HTMX CONTEXT
# ---------------------------------------------------------------------------
# All HTMX requests MUST include these elements to preserve UI state.
#
# Currently includes:
# - #config-search → keeps search/filter state across interactions
#
# If you add more global UI state (e.g. filters, toggles), include them here.
# ---------------------------------------------------------------------------
HTMX_STATE_ELEMENTS = [
"#config-search", # search/filter state
# "#config-filter", # future: filter dropdown
# "#config-scope", # future: scope selector
]
HTMX_INCLUDE = ", ".join(HTMX_STATE_ELEMENTS)
scrollbar_viewport_styles = (
"scrollbar-width: none; -ms-overflow-style: none; -webkit-overflow-scrolling: touch;"
@@ -224,6 +246,7 @@ def make_config_update_form() -> Callable[[str, str], Grid]:
hx_put=request_url_for("/eosdash/configuration"),
hx_target="#page-content",
hx_swap="innerHTML",
hx_include=HTMX_INCLUDE,
),
),
id=f"{config_id}-update-form",
@@ -262,6 +285,7 @@ def make_config_update_value_form(
.querySelector("[name='{config_id}_selected_value']")
.value
}}""",
hx_include=HTMX_INCLUDE,
),
Select(
Option("Select a value...", value="", selected=True, disabled=True),
@@ -336,6 +360,7 @@ def make_config_update_list_form(available_values: list[str]) -> Callable[[str,
])].filter(v => v !== "")
)
}}""",
hx_include=HTMX_INCLUDE,
),
Select(
Option("Select a value...", value="", selected=True, disabled=True),
@@ -365,6 +390,7 @@ def make_config_update_list_form(available_values: list[str]) -> Callable[[str,
])].filter(v => v !== document.querySelector("[name='{config_id}_selected_delete_value']").value.trim())
)
}}""",
hx_include=HTMX_INCLUDE,
),
Select(
Option("Select a value...", value="", selected=True, disabled=True),
@@ -427,6 +453,7 @@ def make_config_update_map_form(
)
)
}}""",
hx_include=HTMX_INCLUDE,
),
(
Select(
@@ -478,6 +505,7 @@ def make_config_update_map_form(
)
)
}}""",
hx_include=HTMX_INCLUDE,
),
Select(
Option("Select key...", value="", selected=True, disabled=True),
@@ -500,11 +528,57 @@ def make_config_update_time_windows_windows_form(
) -> Callable[[str, str], Grid]:
"""Factory for a form that edits the windows field of a TimeWindowSequence.
Renders one collapsible row per existing window with inline edit inputs
pre-filled with the current values, a two-click delete control (trash icon
arms on first click, red confirm button on second), and an "Add window"
section at the bottom for appending new entries.
Args:
value_description: If given, a numeric value field is included in the form
and shown in the column header (e.g. "electricity_price_kwh [Amt/kWh]").
If None, no value field is rendered.
value_description: If given, a numeric value field is included in
both the edit rows and the add section, labelled with this string
(e.g. ``"electricity_price_kwh [Amt/kWh]"``). When ``None`` no
value field is rendered.
Returns:
A factory ``(config_name: str, value: str) -> Grid``.
"""
DOW_LABELS = [
"0 Monday",
"1 Tuesday",
"2 Wednesday",
"3 Thursday",
"4 Friday",
"5 Saturday",
"6 Sunday",
]
def _dow_select(name: str, current: Optional[int]) -> Select:
"""Render a day-of-week dropdown pre-selected to *current*."""
return Select(
Option("— any day —", value="", selected=(current is None)),
*[
Option(lbl, value=str(i), selected=(current == i))
for i, lbl in enumerate(DOW_LABELS)
],
name=name,
cls="border rounded px-2 py-1 text-sm",
)
def _window_summary(win: dict) -> str:
"""One-line human-readable label for an existing window."""
parts = [win.get("start_time", ""), win.get("duration", "")]
if value_description is not None:
parts.append(str(win.get("value", "")))
dow = win.get("day_of_week")
if dow is not None:
parts.append(f"dow={dow}")
date_val = win.get("date")
if date_val:
parts.append(f"date={date_val}")
locale_val = win.get("locale")
if locale_val:
parts.append(f"locale={locale_val}")
return " | ".join(p for p in parts if p)
def ConfigUpdateTimeWindowsWindowsForm(config_name: str, value: str) -> Grid:
config_id = config_name.lower().replace(".", "-")
@@ -515,125 +589,209 @@ def make_config_update_time_windows_windows_form(
except (json.JSONDecodeError, AttributeError):
current_windows = []
DOW_LABELS = [
"0 Monday",
"1 Tuesday",
"2 Wednesday",
"3 Thursday",
"4 Friday",
"5 Saturday",
"6 Sunday",
]
num_cols = 5 + (1 if value_description is not None else 0)
# ---- Existing windows rows ----
# ----------------------------------------------------------------
# Existing window rows — each is a collapsible <details> with
# pre-filled edit inputs and a two-click delete control.
# ----------------------------------------------------------------
window_rows = []
for idx, win in enumerate(current_windows):
start_time = win.get("start_time", "")
duration = win.get("duration", "")
dow = win.get("day_of_week")
date_val = win.get("date")
locale_val = win.get("locale")
dow_str = f" dow={dow}" if dow is not None else ""
date_str = f" date={date_val}" if date_val else ""
locale_str = f" locale={locale_val}" if locale_val else ""
if value_description is not None:
val = win.get("value", "")
val_str = f" | {val} {value_description}"
else:
val_str = ""
label = f"{start_time} | {duration}{val_str}{dow_str}{date_str}{locale_str}"
wid = f"{config_id}_w{idx}"
remaining = [w for i, w in enumerate(current_windows) if i != idx]
remaining_json = json.dumps(json.dumps(remaining))
window_rows.append(
DivHStacked(
rem_json = json.dumps(json.dumps(remaining))
# --- Two-click delete ---
delete_ctrl = 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-2 py-1",
hx_vals=f'js:{{ action: "update", key: "{config_name}", value: {rem_json} }}',
hx_include=HTMX_INCLUDE,
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",
)
# --- Save-edit JS: build updated list with this window replaced ---
before_json = json.dumps(current_windows[:idx])
after_json = json.dumps(current_windows[idx + 1 :])
val_js_read = (
f"const val = parseFloat(document.querySelector(\"[name='{wid}_value']\").value);"
if value_description is not None
else ""
)
val_js_guard = "isNaN(val)" if value_description is not None else "false"
val_js_field = "value: val," if value_description is not None else ""
save_button = ConfigButton(
UkIcon("save"),
" Save",
hx_put=request_url_for("/eosdash/configuration"),
hx_target="#page-content",
hx_swap="innerHTML",
hx_vals=f"""js:{{
action: "update",
key: "{config_name}",
value: (() => {{
const start = document.querySelector("[name='{wid}_start_time']").value.trim();
const dur = document.querySelector("[name='{wid}_duration']").value.trim();
{val_js_read}
const dowRaw = document.querySelector("[name='{wid}_dow']").value;
const date = document.querySelector("[name='{wid}_date']").value.trim();
const locale = document.querySelector("[name='{wid}_locale']").value.trim();
if (!start || !dur || {val_js_guard}) return {json.dumps(json.dumps(current_windows))};
const edited = {{
start_time: start,
duration: dur,
{val_js_field}
day_of_week: dowRaw !== "" ? parseInt(dowRaw) : null,
date: date !== "" ? date : null,
locale: locale !== "" ? locale : null,
}};
const updated = [...{before_json}, edited, ...{after_json}];
return JSON.stringify(updated);
}})()
}}""",
hx_include=HTMX_INCLUDE,
)
# --- Edit inputs pre-filled from current window ---
edit_cols = [
Input(
value=win.get("start_time", ""),
name=f"{wid}_start_time",
placeholder="e.g. 08:00",
cls="border rounded px-2 py-1 text-sm",
),
Input(
value=win.get("duration", ""),
name=f"{wid}_duration",
placeholder="e.g. 8 hours",
cls="border rounded px-2 py-1 text-sm",
),
]
if value_description is not None:
edit_cols.append(
Input(
value=str(win.get("value", "")),
name=f"{wid}_value",
type="number",
step="0.001",
cls="border rounded px-2 py-1 text-sm",
)
)
edit_cols += [
_dow_select(f"{wid}_dow", win.get("day_of_week")),
Input(
value=win.get("date") or "",
name=f"{wid}_date",
placeholder="YYYY-MM-DD",
cls="border rounded px-2 py-1 text-sm",
),
Input(
value=win.get("locale") or "",
name=f"{wid}_locale",
placeholder="e.g. de",
cls="border rounded px-2 py-1 text-sm",
),
]
window_rows.append(
Details(
Summary(
DivHStacked(
delete_ctrl,
P(_window_summary(win), cls="ml-2 text-sm font-mono cursor-pointer"),
),
cls="list-none",
),
Grid(
Grid(*edit_cols, cols=num_cols, cls="gap-2 mt-2"),
save_button,
cols=1,
cls="gap-2 mt-1 p-2 border rounded-md bg-muted/30",
),
P(label, cls="ml-2 text-sm font-mono"),
)
)
# ---- Column headers and inputs ----
num_cols = 5 + (1 if value_description is not None else 0)
# ----------------------------------------------------------------
# Add new window section
# ----------------------------------------------------------------
add_wid = f"{config_id}_new"
header_cols = [
P("start_time *", cls="text-xs text-muted-foreground font-semibold"),
P("duration *", cls="text-xs text-muted-foreground font-semibold"),
]
input_cols = [
add_input_cols = [
Input(
placeholder="e.g. 08:00 Europe/Berlin",
name=f"{config_id}_tw_start_time",
name=f"{add_wid}_start_time",
cls="border rounded px-2 py-1 text-sm",
),
Input(
placeholder="e.g. 8 hours",
name=f"{config_id}_tw_duration",
name=f"{add_wid}_duration",
cls="border rounded px-2 py-1 text-sm",
),
]
if value_description is not None:
header_cols.append(
P(f"{value_description} *", cls="text-xs text-muted-foreground font-semibold")
)
input_cols.append(
add_input_cols.append(
Input(
placeholder="e.g. 0.288",
name=f"{config_id}_tw_value",
name=f"{add_wid}_value",
type="number",
step="0.001",
cls="border rounded px-2 py-1 text-sm",
)
)
header_cols += [
P("day_of_week", cls="text-xs text-muted-foreground font-semibold"),
P("date (YYYY-MM-DD)", cls="text-xs text-muted-foreground font-semibold"),
P("locale", cls="text-xs text-muted-foreground font-semibold"),
]
input_cols += [
Select(
Option("— any day —", value="", selected=True),
*[Option(lbl, value=str(i)) for i, lbl in enumerate(DOW_LABELS)],
name=f"{config_id}_tw_dow",
cls="border rounded px-2 py-1 text-sm",
),
add_input_cols += [
_dow_select(f"{add_wid}_dow", None),
Input(
placeholder="e.g. 2025-12-24",
name=f"{config_id}_tw_date",
name=f"{add_wid}_date",
cls="border rounded px-2 py-1 text-sm",
),
Input(
placeholder="e.g. de",
name=f"{config_id}_tw_locale",
name=f"{add_wid}_locale",
cls="border rounded px-2 py-1 text-sm",
),
]
# ---- JS for Add button ----
current_json = json.dumps(json.dumps(current_windows))
if value_description is not None:
val_js_read = f"const val = parseFloat(document.querySelector(\"[name='{config_id}_tw_value']\").value);"
val_js_guard = "isNaN(val)"
val_js_field = "value: val,"
else:
val_js_read = ""
val_js_guard = "false"
val_js_field = ""
add_val_js_read = (
f"const val = parseFloat(document.querySelector(\"[name='{add_wid}_value']\").value);"
if value_description is not None
else ""
)
add_val_js_guard = "isNaN(val)" if value_description is not None else "false"
add_val_js_field = "value: val," if value_description is not None else ""
add_section = Grid(
Grid(*header_cols, cols=num_cols),
Grid(*input_cols, cols=num_cols),
Grid(*add_input_cols, cols=num_cols),
ConfigButton(
UkIcon("plus"),
" Add window",
@@ -644,17 +802,17 @@ def make_config_update_time_windows_windows_form(
action: "update",
key: "{config_name}",
value: (() => {{
const start = document.querySelector("[name='{config_id}_tw_start_time']").value.trim();
const dur = document.querySelector("[name='{config_id}_tw_duration']").value.trim();
{val_js_read}
const dowRaw = document.querySelector("[name='{config_id}_tw_dow']").value;
const date = document.querySelector("[name='{config_id}_tw_date']").value.trim();
const locale = document.querySelector("[name='{config_id}_tw_locale']").value.trim();
if (!start || !dur || {val_js_guard}) return {current_json};
const start = document.querySelector("[name='{add_wid}_start_time']").value.trim();
const dur = document.querySelector("[name='{add_wid}_duration']").value.trim();
{add_val_js_read}
const dowRaw = document.querySelector("[name='{add_wid}_dow']").value;
const date = document.querySelector("[name='{add_wid}_date']").value.trim();
const locale = document.querySelector("[name='{add_wid}_locale']").value.trim();
if (!start || !dur || {add_val_js_guard}) return {current_json};
const newWin = {{
start_time: start,
duration: dur,
{val_js_field}
duration: dur,
{add_val_js_field}
day_of_week: dowRaw !== "" ? parseInt(dowRaw) : null,
date: date !== "" ? date : null,
locale: locale !== "" ? locale : null,
@@ -664,6 +822,7 @@ def make_config_update_time_windows_windows_form(
return JSON.stringify(existing);
}})()
}}""",
hx_include=HTMX_INCLUDE,
),
cols=1,
cls="gap-2 mt-2",
@@ -675,7 +834,7 @@ def make_config_update_time_windows_windows_form(
*window_rows,
P("Add new window", cls="text-sm font-semibold mt-3 mb-1"),
P(
"* required | day_of_week: overridden by date if both set",
"* required | day_of_week overridden by date if both set",
cls="text-xs text-muted-foreground mb-1",
),
add_section,
@@ -696,6 +855,7 @@ def ConfigCard(
default: str,
description: str,
deprecated: Optional[Union[str, bool]],
scope: Optional[list[str]],
update_error: Optional[str],
update_value: Optional[str],
update_open: Optional[bool],
@@ -757,7 +917,14 @@ def ConfigCard(
cls="list-none",
),
Grid(
TextView(description),
Div(
DivHStacked(
*[Kbd(s) for s in scope],
)
if scope
else None,
Markdown(description),
),
P(config_type),
)
if not deprecated
@@ -795,6 +962,23 @@ def ConfigCard(
)
def ConfigSection(title: str, *content: Any, open: bool = False) -> Details:
"""Collapsible section for grouping configuration entries."""
return Details(
Summary(
Div(
UkIcon("chevron-right", cls="transition-transform group-open:rotate-90"),
H3(title, cls="ml-2"),
cls="flex items-center gap-2 cursor-pointer",
),
cls="list-none",
),
Div(*content, cls="space-y-3 mt-3"),
open=open,
cls="group border rounded-lg p-2",
)
def DashboardHeader(title: Optional[str]) -> Div:
"""Creates a styled header with a title.
@@ -826,6 +1010,7 @@ def DashboardFooter(*c: Any, path: str) -> Card:
hx_trigger="every 5s",
hx_target="#footer-content",
hx_swap="innerHTML",
hx_include=HTMX_INCLUDE,
)
@@ -866,6 +1051,7 @@ def DashboardTabs(dashboard_items: dict[str, str]) -> Card:
hx_target="#page-content",
hx_swap="innerHTML",
hx_vals='js:{ "dark": window.matchMedia("(prefers-color-scheme: dark)").matches }',
hx_include=HTMX_INCLUDE,
),
)
for menu, path in dashboard_items.items()
+172 -304
View File
@@ -1,3 +1,4 @@
import enum
import json
from collections.abc import Sequence
from typing import Any, Dict, List, Optional, TypeVar, Union
@@ -5,39 +6,30 @@ from typing import Any, Dict, List, Optional, TypeVar, Union
import requests
from loguru import logger
from monsterui.franken import (
H3,
H4,
Card,
CardTitle,
Details,
Div,
DividerLine,
DivLAligned,
DivRAligned,
Form,
Grid,
Input,
LabelCheckboxX,
P,
Summary,
UkIcon,
)
from pydantic.fields import ComputedFieldInfo, FieldInfo
from pydantic_core import PydanticUndefined
from akkudoktoreos.config.config import ConfigEOS
from akkudoktoreos.core.pydantic import PydanticBaseModel
from akkudoktoreos.prediction.pvforecast import PVForecastPlaneSetting
from akkudoktoreos.server.dash.components import (
HTMX_INCLUDE,
ConfigCard,
JsonView,
TextView,
make_config_update_list_form,
make_config_update_map_form,
make_config_update_time_windows_windows_form,
make_config_update_value_form,
ConfigSection,
Input,
)
from akkudoktoreos.server.dash.context import request_url_for
from akkudoktoreos.server.dash.itemscard import ConfigItemsCard
from akkudoktoreos.server.dash.mapcard import ConfigMapCard
from akkudoktoreos.server.dash.uihints import (
UI_HINTS,
resolve_form_factory,
)
T = TypeVar("T")
@@ -156,23 +148,77 @@ def get_deprecated(
return getattr(subfield_info, "deprecated", None)
def get_scope(
extra: Dict[str, Any],
) -> Optional[list[str]]:
"""Fetch x-scope.
Returns the value of json_schema_extra["x-scope"] as a list of strings, or None if not set.
"""
scope = extra.get("x-scope")
if scope is None:
return None
if isinstance(scope, list):
return [str(s) for s in scope]
return [str(scope)]
def get_default_value(field_info: Union[FieldInfo, ComputedFieldInfo], regular_field: bool) -> Any:
"""Retrieve the default value of a field.
"""Retrieve the default value of a field as a JSON-safe Python object.
Handles both ``default`` and ``default_factory`` fields, and converts the
resulting value to a JSON-safe representation before returning. This
covers all non-primitive default types encountered in the EOS config
models: Pydantic model instances, lists of models, enums, ``Path``
objects, and anything else that plain ``json.dumps`` would reject.
For computed fields or fields with no default of any kind, a sentinel
string is returned instead.
Args:
field_info (Union[FieldInfo, ComputedFieldInfo]): The field metadata from Pydantic.
regular_field (bool): Indicates if the field is a regular field.
field_info: The field metadata from Pydantic.
regular_field: ``True`` for a ``FieldInfo`` (regular field),
``False`` for a ``ComputedFieldInfo``.
Returns:
Any: The default value of the field or "N/A" if not a regular field.
A JSON-safe Python object (dict, list, str, int, float, bool, or
``None``) representing the field default, or ``"N/A"`` when no
meaningful default exists.
"""
default_value = ""
if regular_field:
if (val := field_info.default) is not PydanticUndefined:
default_value = val
import pathlib
if not regular_field:
return "N/A"
# Resolve the raw default — prefer plain default, fall back to factory
if field_info.default is not PydanticUndefined:
val = field_info.default
elif field_info.default_factory is not None:
try:
val = field_info.default_factory()
except Exception:
return ""
else:
default_value = "N/A"
return default_value
return ""
def _to_json_safe(v: Any) -> Any:
"""Recursively convert a value to a JSON-safe type."""
if v is None or isinstance(v, (bool, int, float, str)):
return v
if isinstance(v, PydanticBaseModel):
return v.model_dump(mode="json")
if isinstance(v, enum.Enum):
return v.value
if isinstance(v, pathlib.PurePath):
return str(v)
if isinstance(v, dict):
return {str(k): _to_json_safe(w) for k, w in v.items()}
if isinstance(v, (list, tuple, set, frozenset)):
return [_to_json_safe(item) for item in v]
# Last resort: str() — at minimum json.dumps won't crash
return str(v)
return _to_json_safe(val)
def resolve_nested_types(field_type: Any, parent_types: list[str]) -> list[tuple[Any, list[str]]]:
@@ -252,6 +298,7 @@ def create_config_details(
config["default"] = json.dumps(get_default_value(subfield_info, regular_field))
config["description"] = get_description(subfield_info, extra)
config["deprecated"] = get_deprecated(subfield_info, extra)
config["scope"] = get_scope(extra)
if isinstance(subfield_info, ComputedFieldInfo):
config["read-only"] = "ro"
type_description = str(subfield_info.return_type)
@@ -307,193 +354,15 @@ def get_config(eos_host: str, eos_port: Union[str, int]) -> dict[str, Any]:
return config
def ConfigPlanesCard(
config_name: str,
config_type: str,
read_only: str,
value: str,
default: str,
description: str,
max_planes: int,
update_error: Optional[str],
update_value: Optional[str],
update_open: Optional[bool],
) -> Card:
"""Creates a styled configuration card for PV planes.
def config_matches_search(config: dict, search: str) -> bool:
if not search:
return True
This function generates a configuration card that is displayed in the UI with
various sections such as configuration name, type, description, default value,
current value, and error details. It supports both read-only and editable modes.
Args:
config_name (str): The name of the PV planes configuration.
config_type (str): The type of the PV planes configuration.
read_only (str): Indicates if the PV planes configuration is read-only ("rw" for read-write,
any other value indicates read-only).
value (str): The current value of the PV planes configuration.
default (str): The default value of the PV planes configuration.
description (str): A description of the PV planes configuration.
max_planes (int): Maximum number of planes that can be set
update_error (Optional[str]): The error message, if any, during the update process.
update_value (Optional[str]): The value to be updated, if different from the current value.
update_open (Optional[bool]): A flag indicating whether the update section of the card
should be initially expanded.
Returns:
Card: A styled Card component containing the PV planes configuration details.
"""
config_id = config_name.replace(".", "-")
# Remember overall planes update status
planes_update_error = update_error
planes_update_value = update_value
if not planes_update_value:
planes_update_value = value
planes_update_open = update_open
if not planes_update_open:
planes_update_open = False
# Create EOS planes configuration
eos_planes = json.loads(value)
eos_planes_config = {
"pvforecast": {
"planes": eos_planes,
},
}
# Create cards for all planes
rows = []
for i in range(0, max_planes):
plane_config = create_config_details(
PVForecastPlaneSetting(),
eos_planes_config,
values_prefix=["pvforecast", "planes", str(i)],
)
plane_rows = []
plane_update_open = False
if eos_planes and len(eos_planes) > i:
plane_value = json.dumps(eos_planes[i])
else:
plane_value = json.dumps(None)
for config_key in sorted(plane_config.keys()):
config = plane_config[config_key]
update_error = config_update_latest.get(config["name"], {}).get("error") # type: ignore
update_value = config_update_latest.get(config["name"], {}).get("value") # type: ignore
update_open = config_update_latest.get(config["name"], {}).get("open") # type: ignore
update_form_factory = None
if update_open:
planes_update_open = True
plane_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)
if config["name"].endswith("pvtechchoice"):
update_form_factory = make_config_update_value_form(
["crystSi", "CIS", "CdTe", "Unknown"]
)
elif config["name"].endswith("mountingplace"):
update_form_factory = make_config_update_value_form(["free", "building"])
plane_rows.append(
ConfigCard(
config["name"],
config["type"],
config["read-only"],
config["value"],
config["default"],
config["description"],
config["deprecated"],
update_error,
update_value,
update_open,
update_form_factory,
)
)
rows.append(
Card(
Details(
Summary(
Grid(
Grid(
DivLAligned(
UkIcon(icon="play"),
H4(f"pvforecast.planes.{i}"),
),
DivRAligned(
P(read_only),
),
),
JsonView(json.loads(plane_value)),
),
cls="list-none",
),
*plane_rows,
cls="space-y-4 gap-4",
open=plane_update_open,
),
cls="w-full",
)
)
return Card(
Details(
Summary(
Grid(
Grid(
DivLAligned(
UkIcon(icon="play"),
P(config_name),
),
DivRAligned(
P(read_only),
),
),
JsonView(json.loads(value)),
),
cls="list-none",
),
Grid(
TextView(description),
P(config_type),
),
# Default
Grid(
DivRAligned(P("default")),
P(default),
)
if read_only == "rw"
else None,
# Set value
Grid(
DivRAligned(P("update")),
Grid(
Form(
Input(value="update", type="hidden", id="action"),
Input(value=config_name, type="hidden", id="key"),
Input(value=planes_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 error
Grid(
DivRAligned(P("update error")),
TextView(planes_update_error),
)
if planes_update_error
else None,
# Now come the single element configs
*rows,
cls="space-y-4 gap-4",
open=planes_update_open,
),
cls="w-full",
return (
search in config["name"].lower()
or search in config["description"].lower()
or search in config["type"].lower()
or search in config["value"].lower()
)
@@ -569,6 +438,24 @@ def Configuration(
# Process configuration data
config_details = create_config_details(ConfigEOS, config)
# Configuration search
search_value = (data.get("search", "") if data else "").strip().lower()
SearchBar = Card(
Input(
placeholder="Search configuration… (name, description, type)",
name="search",
value=search_value,
hx_get=request_url_for("/eosdash/configuration"),
hx_push_url="true",
hx_trigger="keyup changed delay:250ms",
hx_target="#page-content",
hx_swap="innerHTML",
hx_vals='js:{ "dark": window.matchMedia("(prefers-color-scheme: dark)").matches }',
cls="w-full border rounded px-3 py-2",
)
)
ConfigMenu = Card(
# CheckboxGroup to toggle config data visibility
Grid(
@@ -588,6 +475,7 @@ def Configuration(
+ '", '
+ '"dark": window.matchMedia("(prefers-color-scheme: dark)").matches '
+ "}",
hx_include=HTMX_INCLUDE,
# lbl_cls=f"text-{solution_color[renderer]}",
)
for renderer in list(config_visible.keys())
@@ -597,8 +485,6 @@ def Configuration(
header=CardTitle("Choose What's Shown"),
)
rows = []
last_category = ""
# find some special configuration values
try:
max_planes = int(config_details["pvforecast.max_planes"]["value"])
@@ -640,16 +526,16 @@ def Configuration(
logger.debug(f"devices_measurement_keys {devices_measurement_keys}")
# build visual representation
sections: dict[str, list[Any]] = {}
for config_key in sorted(config_details.keys()):
config = config_details[config_key]
category = config["name"].split(".")[0]
if category != last_category:
rows.append(H3(category))
rows.append(DividerLine())
last_category = category
update_error = config_update_latest.get(config["name"], {}).get("error")
update_value = config_update_latest.get(config["name"], {}).get("value")
update_open = config_update_latest.get(config["name"], {}).get("open")
# Make mypy happy - should never trigger
if (
not isinstance(update_error, (str, type(None)))
@@ -659,97 +545,79 @@ def Configuration(
error_msg = "update_error or update_value or update_open of wrong type."
logger.error(error_msg)
raise TypeError(error_msg)
if (
# Do not display read only values
not config_visible["config-visible-read-only"]["visible"]
and config["read-only"] != "rw"
):
# Do not display read only values
continue
if (
config["type"]
== "Optional[list[akkudoktoreos.prediction.pvforecast.PVForecastPlaneSetting]]"
and not config["deprecated"]
):
# Special configuration for PV planes
rows.append(
ConfigPlanesCard(
config["name"],
config["type"],
config["read-only"],
config["value"],
config["default"],
config["description"],
max_planes,
update_error,
update_value,
update_open,
)
if not config_matches_search(config, search_value):
# Search value given but does not match
continue
hint = UI_HINTS.get(config["name"])
if hint and hint.form == "items" and not config["deprecated"]:
card = ConfigItemsCard(
config=config,
hint=hint,
config_details=config_details,
config_update_latest=config_update_latest,
create_config_details=create_config_details,
)
elif hint and hint.form == "map_items" and not config["deprecated"]:
card = ConfigMapCard(
config=config,
hint=hint,
config_details=config_details,
config_update_latest=config_update_latest,
create_config_details=create_config_details,
)
elif not config["deprecated"]:
update_form_factory = None
if config["name"].endswith(".provider"):
# Special configuration for prediction provider setting
try:
provider_ids = json.loads(config_details[config["name"] + "s"]["value"])
except Exception:
provider_ids = []
if config["type"].startswith("Optional[list"):
update_form_factory = make_config_update_list_form(provider_ids)
else:
provider_ids.append("None")
update_form_factory = make_config_update_value_form(provider_ids)
elif config["name"].startswith("adapter.homeassistant.config_entity_ids"):
# Home Assistant adapter config entities
update_form_factory = make_config_update_map_form(None, homeassistant_entity_ids)
elif config["name"].startswith("adapter.homeassistant.load_emr_entity_ids"):
# Home Assistant adapter load energy meter readings entities
update_form_factory = make_config_update_list_form(homeassistant_entity_ids)
elif config["name"].startswith("adapter.homeassistant.grid_export_emr_entity_ids"):
# Home Assistant adapter grid export energy meter readings entities
update_form_factory = make_config_update_list_form(homeassistant_entity_ids)
elif config["name"].startswith("adapter.homeassistant.grid_import_emr_entity_ids"):
# Home Assistant adapter grid import energy meter readings entities
update_form_factory = make_config_update_list_form(homeassistant_entity_ids)
elif config["name"].startswith("adapter.homeassistant.pv_production_emr_entity_ids"):
# Home Assistant adapter pv energy meter readings entities
update_form_factory = make_config_update_list_form(homeassistant_entity_ids)
elif config["name"].startswith("adapter.homeassistant.device_measurement_entity_ids"):
# Home Assistant adapter device measurement entities
update_form_factory = make_config_update_map_form(
devices_measurement_keys, homeassistant_entity_ids
)
elif config["name"].startswith("adapter.homeassistant.device_instruction_entity_ids"):
# Home Assistant adapter device instruction entities
update_form_factory = make_config_update_list_form(
eos_device_instruction_entity_ids
)
elif config["name"].startswith("adapter.homeassistant.solution_entity_ids"):
# Home Assistant adapter optimization solution entities
update_form_factory = make_config_update_list_form(eos_solution_entity_ids)
elif config["name"].startswith("ems.mode"):
# Energy management mode
update_form_factory = make_config_update_value_form(
["OPTIMIZATION", "PREDICTION", "DISABLED"]
)
elif config["name"].endswith("elecpricefixed.time_windows.windows"):
update_form_factory = make_config_update_time_windows_windows_form(
value_description="electricity_price_kwh [Amt/kWh]"
)
update_form_factory = resolve_form_factory(hint, config_details) if hint else None
card = ConfigCard(
config["name"],
config["type"],
config["read-only"],
config["value"],
config["default"],
config["description"],
config["deprecated"],
config["scope"],
update_error,
update_value,
update_open,
update_form_factory,
)
else:
continue
rows.append(
ConfigCard(
config["name"],
config["type"],
config["read-only"],
config["value"],
config["default"],
config["description"],
config["deprecated"],
update_error,
update_value,
update_open,
update_form_factory,
)
sections.setdefault(category, []).append(card)
section_components = []
for category in sorted(sections.keys()):
cards = sections[category]
# Open if searching OR if last update was here
open_section = bool(search_value)
if not open_section:
open_section = any(
config_update_latest.get(c["name"], {}).get("open")
for c in config_details.values()
if c["name"].startswith(category)
)
return Div(ConfigMenu, *rows, cls="space-y-3")
section_components.append(ConfigSection(category, *cards, open=open_section))
return Div(
Grid(
ConfigMenu,
SearchBar,
),
*section_components,
cls="space-y-4",
)
+555
View File
@@ -0,0 +1,555 @@
"""Generic expandable list-of-sub-model configuration card for EOSdash.
This module provides `ConfigItemsCard`, a reusable FastHTML/MonsterUI
card component that renders any ``list[PydanticSubModel]`` config field as a
collapsible outer card containing one collapsible inner card per list item.
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.
Typical usage in ``configuration.py``::
from akkudoktoreos.server.dash.itemscard import ConfigItemsCard
hint = UI_HINTS.get(config["name"])
if hint and hint.form == "items" and not config["deprecated"]:
rows.append(
ConfigItemsCard(
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,
item_model: Any,
items_list: list,
read_only: str,
) -> Any:
"""Build the 'Add item' control for the outer card header.
When the item model can be fully defaulted, this is a one-click button
that appends immediately. When required fields have no default (e.g.
`consumption_wh`/`duration_h` on `HomeApplianceCommonSettings`), clicking
would otherwise submit an invalid item, so instead this renders a small
inline form that collects those values first and only builds the PUT
payload once every required input is non-empty (enforced via HTML
`required`).
"""
if read_only != "rw":
return None
new_item_defaults, required_missing = item_model_defaults(item_model)
if not required_missing:
appended_json = json.dumps(json.dumps(items_list + [new_item_defaults]))
return ConfigButton(
UkIcon("plus"),
" Add item",
hx_put=request_url_for("/eosdash/configuration"),
hx_target="#page-content",
hx_swap="innerHTML",
hx_vals=f'js:{{ action: "update", key: "{config_name}", value: {appended_json} }}',
cls="ml-4 px-3 py-1 text-sm",
)
id_prefix = f"new-item-{config_name}"
inputs, js_pairs = required_field_inputs(item_model, required_missing, id_prefix)
defaults_json = json.dumps(new_item_defaults)
items_json = json.dumps(items_list)
build_value_expr = (
"(function(){"
f"var base = {defaults_json};"
f"var extra = {{ {', '.join(js_pairs)} }};"
f"var items = {items_json};"
"return JSON.stringify(items.concat([Object.assign({}, base, extra)]));"
"})()"
)
return Details(
Summary(
UkIcon("plus"),
" Add item",
cls="list-none cursor-pointer inline-flex items-center gap-1 ml-4",
),
Form(
*inputs,
ConfigButton(
"Create",
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 px-3 py-1 text-sm",
),
cls="absolute z-10 mt-1 p-3 rounded-md border bg-background shadow-md space-y-2",
),
cls="relative",
)
def _delete_control(config_name: str, items_list: list, index: int) -> Details:
"""Build the two-click delete control for a single inner item card header.
The first click opens a ``<details>`` panel revealing a red "Confirm
delete" button. Clicking outside collapses it. The second click
(on the confirm button) submits an ``hx_put`` with the list minus the
given index.
Args:
config_name: Dotted config key name, e.g. ``"pvforecast.planes"``.
items_list: The current full list of item dicts.
index: The zero-based index of the item to delete.
Returns:
A ``Details`` component implementing the two-click confirm pattern.
"""
remaining_json = json.dumps(json.dumps([w for j, w in enumerate(items_list) if j != index]))
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,
path_parts: list[str],
index: int,
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 list item.
Args:
config_name: Dotted config key of the parent list field.
item_path: Dotted path prefix for this item type, e.g.
``"pvforecast.planes"``.
path_parts: ``item_path`` split on ``"."``.
index: Zero-based position of this item in the list.
item_value: JSON-encoded current value of this item.
is_empty: ``True`` when the item 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 item.
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 item slot.
"""
return Card(
Details(
Summary(
Grid(
Grid(
DivLAligned(
UkIcon(icon="play"),
H4(
f"{item_path}.{index}",
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_items: int,
add_button: Any,
items_update_value: str,
items_update_error: Optional[str],
items_update_open: bool,
rows: list,
) -> Card:
"""Render the outer collapsible card for the whole list 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 list value.
default: JSON-encoded default value.
description: Field description text.
num_items: Current number of items, shown as a badge.
add_button: The "Add item" control from ``_add_control`` — a
``ConfigButton`` when the item model is fully defaulted, a
``Details``/``Form`` combo when required fields must be
collected first, or ``None`` for read-only fields.
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.
Returns:
The outer ``Card`` component.
"""
return Card(
Details(
Summary(
Grid(
Grid(
DivLAligned(
UkIcon(icon="play"),
P(config_name),
P(
f"{num_items} item{'s' if num_items != 1 else ''}",
cls="ml-2 text-xs text-muted-foreground",
),
add_button,
),
DivRAligned(P(read_only)),
),
JsonView(json.loads(value)),
),
cls="list-none",
),
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-item inner cards
*rows,
cls="space-y-4 gap-4",
open=items_update_open,
),
cls="w-full",
)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def ConfigItemsCard(
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 list of Pydantic sub-model items.
Renders a collapsible outer card representing the list field as a whole,
containing one collapsible inner card per item in the list. Each inner
card expands into individual ``ConfigCard`` rows for every field of the
item's Pydantic sub-model.
The list length is driven entirely by user interaction — there is no fixed
maximum.
An "Add item" control in the outer card header creates a new item
pre-filled with the sub-model's Pydantic field defaults. When every
field has a default, this is a one-click button that appends and PUTs
immediately. When the sub-model has fields with no default (e.g.
``consumption_wh``/``duration_h`` on ``HomeApplianceCommonSettings``),
the control instead expands into a small inline form that collects
those required values first — the PUT is only built, via HTML
``required`` inputs, once every missing field is filled in, so an
invalid item is never persisted. 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-item 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 list 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 == "items"``
and valid ``item_model`` (resolved via ``resolve_item_model``) and
``item_path`` values. ``max_items_from`` is ignored — the list
grows and shrinks freely via Add / Delete.
config_details: The full config detail dict for the current page
render, used to look up per-item 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 list
summary with item count and an "Add item" control (a one-click
button, or an inline required-fields form — see above), description,
default value row, a raw-JSON fallback update form, an optional
error row, and one collapsible inner ``Card`` per existing item each
with a two-click delete control.
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.itemscard import ConfigItemsCard
hint = UI_HINTS.get(config["name"])
if hint and hint.form == "items" and not config["deprecated"]:
rows.append(
ConfigItemsCard(
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"]
item_model = resolve_item_model(hint)
item_path = hint.item_path # e.g. "pvforecast.planes"
if item_path is None:
raise ValueError(f"Hint needs item_path to be listed. Got {hint}")
path_parts = item_path.split(".") # e.g. ["pvforecast", "planes"]
items_list = json.loads(value) or []
num_items = len(items_list)
# Synthetic wrapper dict so create_config_details can traverse the value:
# e.g. {"pvforecast": {"planes": [...]}}
wrapped = json.loads(value)
for key in reversed(path_parts):
wrapped = {key: wrapped}
# 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 button.
# 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_button = _add_control(config_name, item_model, items_list, read_only)
# Build inner cards
rows = []
for i in range(num_items):
item_config = create_config_details(
item_model,
wrapped,
values_prefix=path_parts + [str(i)],
)
item_rows = []
item_update_open = False
item_value = json.dumps(items_list[i]) if items_list[i] is not None else json.dumps(None)
is_empty = not items_list[i]
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,
path_parts=path_parts,
index=i,
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_list, i)
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_items=num_items,
add_button=add_button,
items_update_value=items_update_value,
items_update_error=items_update_error,
items_update_open=items_update_open,
rows=rows,
)
+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,
)
+58 -29
View File
@@ -25,6 +25,7 @@ from akkudoktoreos.core.emplan import (
EnergyManagementInstruction,
EnergyManagementPlan,
FRBCInstruction,
OMBCInstruction,
)
from akkudoktoreos.optimization.optimization import OptimizationSolution
from akkudoktoreos.server.dash.bokeh import Bokeh, bokey_apply_theme_to_plot
@@ -72,7 +73,7 @@ solution_excludes = [
# Current state of solution displayed
solution_visible: dict[str, bool] = {
"pv_energy_wh": True,
"pvforecast_power_w": True,
"elec_price_amt_kwh": True,
"feed_in_tariff_amt_kwh": True,
}
@@ -143,7 +144,7 @@ def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Opti
solution_columns = [x for x in solution_columns if x not in instruction_columns]
prediction_df = solution.prediction.to_dataframe()
if prediction_df.empty or len(prediction_df.columns) <= 1:
if prediction_df.empty:
raise ValueError(
f"Prediction DataFrame is empty or missing plottable columns: {list(prediction_df.columns)}"
)
@@ -151,10 +152,12 @@ def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Opti
raise ValueError(
f"Prediction DataFrame is missing column 'date_time': {list(prediction_df.columns)}"
)
prediction_columns = list(prediction_df.columns)
prediction_columns_to_join = prediction_df.columns.difference(df.columns)
df = df.join(prediction_df[prediction_columns_to_join], how="inner")
# Only plot if there are actual data columns beyond date_time
prediction_columns = [c for c in prediction_df.columns if c != "date_time"]
# No prediction data to plot — skip prediction section silently
if prediction_columns:
prediction_columns_to_join = prediction_df.columns.difference(df.columns)
df = df.join(prediction_df[prediction_columns_to_join], how="inner")
# Exclude columns that currently do not have a value
excludes = solution_excludes
@@ -185,15 +188,20 @@ def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Opti
validate_source(source)
# Calculate minimum and maximum Range
power_w_min = 0.0
power_w_max = 0.0
energy_wh_min = 0.0
energy_wh_max = 0.0
amt_kwh_min = 0.0
amt_kwh_max = 0.0
amt_min = 0.0
amt_max = 0.0
soc_factor_min = 0.0
soc_factor_max = 1.0
factor_min = 0.0
factor_max = 1.0
for col in df.columns:
if col.endswith("power_w"):
power_w_min = min(power_w_min, float(df[col].min()))
power_w_max = max(power_w_max, float(df[col].max()))
if col.endswith("energy_wh"):
energy_wh_min = min(energy_wh_min, float(df[col].min()))
energy_wh_max = max(energy_wh_max, float(df[col].max()))
@@ -207,10 +215,11 @@ def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Opti
continue
# Adjust to similar y-axis 0-point
values_min_max = [
(power_w_min, power_w_max),
(energy_wh_min, energy_wh_max),
(amt_kwh_min, amt_kwh_max),
(amt_min, amt_max),
(soc_factor_min, soc_factor_max),
(factor_min, factor_max),
]
# First get the maximum factor for the min value related the maximum value
min_max_factor = 0.0
@@ -221,11 +230,15 @@ def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Opti
min_max_factor = value_factor
# Adapt the min values to have the same relative min/max factor on all y-axis
power_w_min = min_max_factor * power_w_max * -1.0
energy_wh_min = min_max_factor * energy_wh_max * -1.0
amt_kwh_min = min_max_factor * amt_kwh_max * -1.0
amt_min = min_max_factor * amt_max * -1.0
soc_factor_min = min_max_factor * soc_factor_max * -1.0
factor_min = min_max_factor * factor_max * -1.0
# add 5% to min and max values for better display
power_w_range_orig = power_w_max - power_w_min
power_w_max += 0.05 * power_w_range_orig
power_w_min -= 0.05 * power_w_range_orig
energy_wh_range_orig = energy_wh_max - energy_wh_min
energy_wh_max += 0.05 * energy_wh_range_orig
energy_wh_min -= 0.05 * energy_wh_range_orig
@@ -235,9 +248,9 @@ def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Opti
amt_range_orig = amt_max - amt_min
amt_max += 0.05 * amt_range_orig
amt_min -= 0.05 * amt_range_orig
soc_factor_range_orig = soc_factor_max - soc_factor_min
soc_factor_max += 0.05 * soc_factor_range_orig
soc_factor_min -= 0.05 * soc_factor_range_orig
factor_range_orig = factor_max - factor_min
factor_max += 0.05 * factor_range_orig
factor_min -= 0.05 * factor_range_orig
if eosstatus.eos_health is not None:
last_run_datetime = eosstatus.eos_health["energy-management"]["last_run_datetime"]
@@ -250,27 +263,31 @@ def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Opti
title=f"Optimization Solution - last run: {last_run_datetime}",
x_axis_type="datetime",
x_axis_label=f"Datetime [localtime {date_time_tz}] - start: {start_datetime}",
y_axis_label="Energy [Wh]",
y_axis_label="Power [W]",
sizing_mode="stretch_width",
y_range=Range1d(energy_wh_min, energy_wh_max),
y_range=Range1d(power_w_min, power_w_max),
height=400,
)
plot.extra_y_ranges = {
"factor": Range1d(soc_factor_min, soc_factor_max), # y2
"amt_kwh": Range1d(amt_kwh_min, amt_kwh_max), # y3
"amt": Range1d(amt_min, amt_max), # y4
"energy": Range1d(energy_wh_min, energy_wh_max), # y2
"factor": Range1d(factor_min, factor_max), # y3
"amt_kwh": Range1d(amt_kwh_min, amt_kwh_max), # y4
"amt": Range1d(amt_min, amt_max), # y5
}
# y2 axis
y2_axis = LinearAxis(y_range_name="factor", axis_label="Factor [0.0..1.0]")
y2_axis = LinearAxis(y_range_name="energy", axis_label="Energy [Wh]")
plot.add_layout(y2_axis, "left")
# y3 axis
y3_axis = LinearAxis(y_range_name="amt_kwh", axis_label="Electricity Price [amount/kWh]")
y3_axis.axis_label_text_color = "red"
plot.add_layout(y3_axis, "right")
y3_axis = LinearAxis(y_range_name="factor", axis_label="Factor [0.0..1.0]")
plot.add_layout(y3_axis, "left")
# y4 axis
y4_axis = LinearAxis(y_range_name="amt", axis_label="Amount")
y4_axis = LinearAxis(y_range_name="amt_kwh", axis_label="Electricty Price [Amount/kWh]")
y4_axis.axis_label_text_color = "red"
plot.add_layout(y4_axis, "right")
# y5 axis
y5_axis = LinearAxis(y_range_name="amt", axis_label="Amount [Amount]")
plot.add_layout(y5_axis, "right")
plot.toolbar.autohide = True
@@ -309,7 +326,7 @@ def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Opti
else:
line_dash = "solid"
if visible:
if col.endswith("energy_wh"):
if col.endswith("power_w"):
r = plot.step(
x="date_time_local",
y=col,
@@ -319,15 +336,16 @@ def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Opti
color=color_palette[color],
line_dash=line_dash,
)
elif col.endswith("soc_factor"):
r = plot.line(
elif col.endswith("energy_wh"):
r = plot.step(
x="date_time_local",
y=col,
mode="after",
source=source,
legend_label=col,
color=color_palette[color],
line_dash=line_dash,
y_range_name="factor",
y_range_name="energy",
)
elif col.endswith("factor"):
r = plot.step(
@@ -374,7 +392,9 @@ def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Opti
y_range_name="amt",
)
else:
raise ValueError(f"Unexpected column name: {col}")
# Skip columns with unrecognized suffix rather than raising
logger.warning(f"Skipping column with unrecognized suffix: {col}")
r = None
else:
r = None
@@ -542,9 +562,18 @@ def InstructionCard(
icon = "washing-machine"
else:
icon = "play"
if isinstance(instruction, (DDBCInstruction, FRBCInstruction)):
# Initialize defaults so all code paths are covered
summary = summary or ""
summary_detail = ""
if isinstance(instruction, OMBCInstruction):
summary = f"{instruction.operation_mode_id}"
summary_detail = f"{instruction.operation_mode_factor:.2f}"
elif isinstance(instruction, (DDBCInstruction, FRBCInstruction)):
summary = f"{instruction.operation_mode_id}"
summary_detail = f"{instruction.operation_mode_factor}"
return Card(
Details(
Summary(
+59 -25
View File
@@ -6,12 +6,12 @@ from bokeh.models import ColumnDataSource, LinearAxis, Range1d
from bokeh.plotting import figure
from monsterui.franken import FT, Grid, P
from akkudoktoreos.core.pydantic import PydanticDateTimeDataFrame
from akkudoktoreos.core.pydantic import PydanticDateTimeSeries
from akkudoktoreos.server.dash.bokeh import Bokeh, bokey_apply_theme_to_plot
from akkudoktoreos.server.dash.components import Error
# bar width for 1 hour bars (time given in millseconds)
BAR_WIDTH_1HOUR = 1000 * 60 * 60
# bar width for 15 minutes bars (time given in millseconds)
BAR_WIDTH_15MIN = 1000 * 60 * 15
def PVForecast(predictions: pd.DataFrame, config: dict, date_time_tz: str, dark: bool) -> FT:
@@ -30,7 +30,7 @@ def PVForecast(predictions: pd.DataFrame, config: dict, date_time_tz: str, dark:
x="date_time",
top="pvforecast_ac_power",
source=source,
width=BAR_WIDTH_1HOUR * 0.8,
width=BAR_WIDTH_15MIN * 0.8,
legend_label="AC Power",
color="lightblue",
)
@@ -54,7 +54,7 @@ def ElectricityPriceForecast(
),
title=f"Electricity Price Prediction ({provider})",
x_axis_label=f"Datetime [localtime {date_time_tz}]",
y_axis_label="Price [amount/kWh]",
y_axis_label="Price [Amt./kWh]",
sizing_mode="stretch_width",
height=400,
)
@@ -62,7 +62,7 @@ def ElectricityPriceForecast(
x="date_time",
top="elecprice_marketprice_kwh",
source=source,
width=BAR_WIDTH_1HOUR * 0.8,
width=BAR_WIDTH_15MIN * 0.8,
legend_label="Market Price",
color="lightblue",
)
@@ -208,7 +208,9 @@ def Prediction(eos_host: str, eos_port: Union[str, int], data: Optional[dict] =
if data and data.get("dark", None) == "true":
dark = True
# Get current configuration from server
# ---------------------------------------------------------------------
# Get configuration
# ---------------------------------------------------------------------
try:
result = requests.get(f"{server}/v1/config", timeout=10)
result.raise_for_status()
@@ -220,31 +222,63 @@ def Prediction(eos_host: str, eos_port: Union[str, int], data: Optional[dict] =
)
config = result.json()
# Get Forecasts
# ---------------------------------------------------------------------
# Describe how every prediction should be retrieved.
# ---------------------------------------------------------------------
prediction_requests = [
("pvforecast_ac_power", "first", "ffill"),
("elecprice_marketprice_kwh", "first", "ffill"),
("weather_relative_humidity", "mean", "linear"),
("weather_temp_air", "mean", "linear"),
("weather_ghi", "mean", "linear"),
("weather_dni", "mean", "linear"),
("weather_dhi", "mean", "linear"),
("loadforecast_power_w", "first", "ffill"),
("loadakkudoktor_std_power_w", "first", "ffill"),
("loadakkudoktor_mean_power_w", "first", "ffill"),
]
# ---------------------------------------------------------------------
# Fetch all series
# ---------------------------------------------------------------------
series_list = []
try:
params = {
"keys": [
"pvforecast_ac_power",
"elecprice_marketprice_kwh",
"weather_relative_humidity",
"weather_temp_air",
"weather_ghi",
"weather_dni",
"weather_dhi",
"loadforecast_power_w",
"loadakkudoktor_std_power_w",
"loadakkudoktor_mean_power_w",
],
}
result = requests.get(f"{server}/v1/prediction/dataframe", params=params, timeout=10)
result.raise_for_status()
predictions = PydanticDateTimeDataFrame(**result.json()).to_dataframe()
for options in prediction_requests:
key = options[0]
resample_method = options[1]
fill_method = options[2]
params = {
"key": key,
"interval": "15 minutes",
"processing": "resampled",
"resample_method": resample_method,
"fill_method": fill_method,
}
result = requests.get(
f"{server}/v1/prediction/series",
params=params,
timeout=10,
)
result.raise_for_status()
series = PydanticDateTimeSeries(**result.json()).to_series().rename(key)
series_list.append(series)
except requests.exceptions.HTTPError as err:
detail = result.json()["detail"]
return Error(f"Can not retrieve predictions from {server}: {err}, {detail}")
except Exception as err:
return Error(f"Can not retrieve predictions from {server}: {err}")
# ---------------------------------------------------------------------
# Merge into dataframe
# ---------------------------------------------------------------------
predictions = pd.concat(series_list, axis=1).reset_index()
predictions.rename(columns={"index": "date_time"}, inplace=True)
# Remove time offset from UTC to get naive local time and make bokeh plot in local time
date_time_tz = predictions["date_time"].dt.tz
predictions["date_time"] = pd.to_datetime(predictions["date_time"]).dt.tz_localize(None)
+419
View File
@@ -0,0 +1,419 @@
"""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")
+21 -6
View File
@@ -64,11 +64,15 @@ from akkudoktoreos.optimization.genetic0.genetic0visualize import (
genetic0_prepare_visualize,
)
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSolution
from akkudoktoreos.optimization.optimization import OptimizationSolution
from akkudoktoreos.optimization.optimization import (
OptimizationAlgorithm,
OptimizationSolution,
)
from akkudoktoreos.prediction.elecprice import ElecPriceCommonSettings
from akkudoktoreos.prediction.load import LoadCommonSettings
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings
from akkudoktoreos.prediction.pvforecastpvlib import _cec_inverters, _cec_modules
from akkudoktoreos.server.rest.error import (
EOSProblem,
create_error_page,
@@ -1770,6 +1774,18 @@ async def fastapi_prediction_range_delete(
) from e
@app.get("/v1/prediction/pvforecast/pvlib/modules", tags=["prediction"])
def fastapi_prediction_pvforecast_modules_get() -> list[str]:
"""Get module names supported by PVForecast PVLib provider."""
return _cec_modules().columns.tolist()
@app.get("/v1/prediction/pvforecast/pvlib/inverters", tags=["prediction"])
def fastapi_prediction_pvforecast_inverters_get() -> list[str]:
"""Get inverter names supported by PVForecast PVLib provider."""
return _cec_inverters().columns.tolist()
@app.get("/v1/energy-management/optimization/solution", tags=["energy-management"])
def fastapi_energy_management_optimization_solution_get() -> OptimizationSolution:
"""Get the latest solution of the optimization."""
@@ -1790,7 +1806,7 @@ def fastapi_energy_management_optimization_solution_get() -> OptimizationSolutio
@app.get("/v1/energy-management/optimization/solution/{algorithm}", tags=["energy-management"])
async def fastapi_energy_management_optimization_solution_algorithm_get(
algorithm: str,
algorithm: OptimizationAlgorithm,
) -> Union[GeneticSolution, Genetic0Solution]:
"""Get the latest algorithm specific solution of the optimization.
@@ -1799,7 +1815,6 @@ async def fastapi_energy_management_optimization_solution_algorithm_get(
"""
solution: Optional[Union[GeneticSolution, Genetic0Solution]] = None
algorithm = algorithm.upper()
if algorithm not in get_config().optimization.algorithms:
raise EOSProblem(
status=404,
@@ -1807,9 +1822,9 @@ async def fastapi_energy_management_optimization_solution_algorithm_get(
detail=f"Optimization algorithm '{algorithm}' unknown.",
)
if algorithm == "GENETIC":
if algorithm == OptimizationAlgorithm.GENETIC:
solution = get_ems().genetic_solution()
elif algorithm == "GENETIC0":
elif algorithm == OptimizationAlgorithm.GENETIC0:
solution = get_ems().genetic0_solution()
if solution is None:
@@ -2162,7 +2177,7 @@ async def fastapi_optimize(
await get_ems().run(
start_datetime=start_datetime,
mode=EnergyManagementMode.OPTIMIZATION,
algorithm="GENETIC0",
algorithm=OptimizationAlgorithm.GENETIC0,
genetic0_parameters=parameters,
genetic0_generations=ngen,
)