Files
EOS/src/akkudoktoreos/server/dash/mapcard.py
T
Bobby Noelte 6ebd343047 feat: adapt configuration for multi optimization algorithms
Decouple configuration from optimization algorithm parameters. Add to_[algorithm]_param() methods
to the configuration that derive optimization algorithm specific parameters from the configuration.
Add x-scope tags to the configuration options that describe for which specific algorithms the
configuration option is for.

The whole device settings are restructured. There are now general settings for the device classes
with the afore mentioned to_[algorithm]_param() methods. The general device settings got their own
directory `devices/settings`. By this the parameter class also does not have to be a pydantic model
which can be used for future optimization/ simulations speed up.

Also the parameter class for a device is now part of the device module. This better decouples and
also is the natural place for parameters of a device.

Besides this feature there are also fixes and improvements:

* feat: extend home appliance time window settings and simulation

  Home appliance can now be configured for multiple runs with per-cycle allowed time windows. The
  number of remaining cycles to plan is determined at runtime by reading the
  ``cycles_completed_measurement_key`` from the measurement store.

* feat: specialiced CycleTimeWindowSequence for time window sequences

  Sequence of time windows associated to cycles.

  This model specializes ``ValueTimeWindowSequence`` so that the ``value``
  field of each ``ValueTimeWindow`` encodes the **cycle index** (0-based
  integer) the window belongs to.

  Typical use: an appliance that must run ``n`` times per day, each run
  constrained to a distinct time window.  Assign ``value=0`` to windows
  for the first cycle, ``value=1`` for the second, and so on.  Multiple
  windows may share the same cycle index (their allowed regions are unioned).
  Windows with ``value=None`` are silently ignored by all cycle-aware methods.

* fix: Make test_configmigrate also regard the _ANY_SENTENIEL in key values

* chore: Make devices configurations a map instead of a list

  This makes config paths stable regardless of declaration order and lets each device settings
  class build its own config path from ``self.device_id`` without needing an external index.
  Tests are adapted likewise.

  Devices configurations are automatically migrated from lists to maps.

* chore: rename levelized_cost_of_storage_kwh to levelized_cost_of_storage_amt kwh

  This better fits in the naming scheme and also makes clear the costs are money.

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
2026-08-25 15:18:00 +02:00

573 lines
21 KiB
Python

"""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)
if item_model is None:
raise ValueError(f"Hint for {config_name} needs item_model to be listed. Got {hint}")
item_path = hint.item_path # e.g. "devices.batteries"
if item_path is None:
raise ValueError(f"Hint for {config_name} needs item_path to be listed. 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,
)