From 3bcc9021045748c520a7949c31b7687b75dfd366 Mon Sep 17 00:00:00 2001 From: Bobby Noelte Date: Tue, 10 Nov 2026 17:59:03 +0000 Subject: [PATCH] feat(eosdash): add the profile page for battery and inverter profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new Profiles page of EOSdash lists the user and packaged battery and inverter profiles and edits them as JSON: preview without saving, save, save as and delete, or revert a user profile to the packaged one. Saved profiles apply at once. One toolbar row holds the profile kind and profile selection, the Edit button, the Share link and the status of the last action. A compact grid summarizes the profile. A battery profile shows its charge and discharge limit maps as heat maps as EOS evaluates them (with the exact hard cutoffs) and its efficiency curves; an inverter profile its power at 45 degC, the DC→AC and AC→DC efficiency curves over the load fraction and the power derating over the ambient temperature with 45 degC marked. Both show the profile as highlighted JSON with a copy button. The sections are collapsible and keep their state across actions. The editor is shown only in edit mode: Edit in the toolbar opens it above the plots, Close in the editor or Close editor in the toolbar ends it. Edit mode is kept after preview, save and save as and across profile changes. Share opens a new issue with the battery or inverter profile issue form, filled in with the profile, so users can propose their profiles for packaging with EOS; each form asks for consent to publish the values under Apache-2.0. EOS sends nothing itself. Additional changes - Add a compact variant to ConfigSection and status messages in the theme colors, readable in dark mode, to the EOSdash components. - Use the muted theme color for explanatory texts. - Check profile names before saving. - Show the paste instruction for profiles too large for the share URL as text, not only as tooltip; name the selections for screen readers. - Keep the toolbar layout when the profiles cannot be listed. Co-Authored-By: Claude Opus 5.5 Assisted-by: Claude Code (claude-opus-5-5) --- .github/ISSUE_TEMPLATE/battery-profile.yml | 79 ++ .github/ISSUE_TEMPLATE/inverter-profile.yml | 80 ++ src/akkudoktoreos/server/dash/components.py | 89 +- src/akkudoktoreos/server/dash/profile.py | 1375 +++++++++++++++++++ src/akkudoktoreos/server/eosdash.py | 30 + tests/test_eosdashcomponents.py | 43 + tests/test_eosdashprofile.py | 837 +++++++++++ 7 files changed, 2506 insertions(+), 27 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/battery-profile.yml create mode 100644 .github/ISSUE_TEMPLATE/inverter-profile.yml create mode 100644 src/akkudoktoreos/server/dash/profile.py create mode 100644 tests/test_eosdashcomponents.py create mode 100644 tests/test_eosdashprofile.py diff --git a/.github/ISSUE_TEMPLATE/battery-profile.yml b/.github/ISSUE_TEMPLATE/battery-profile.yml new file mode 100644 index 00000000..8e76f255 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/battery-profile.yml @@ -0,0 +1,79 @@ +name: Battery profile +description: Share a battery profile so that it can be packaged with EOS for other users. +title: "[PROFILE]: ..." +labels: [battery-profile] + + +body: +- type: markdown + attributes: + value: > + Thank you for sharing a battery profile. EOSdash fills this form for you: + open **Profiles**, select the profile and press **Share**. + The profile is reviewed before it is packaged with EOS. See the + [battery profile documentation](https://akkudoktor-eos.readthedocs.io/en/latest/akkudoktoreos/resource.html) + for the meaning of the values. + +- type: input + id: battery_model + attributes: + label: "Battery model" + description: Manufacturer and model of the battery, as on the name plate. + placeholder: "e.g. BYD Battery-Box Premium HVS 7.7" + validations: + required: true + +- type: input + id: profile_name + attributes: + label: "Profile name" + description: > + Suggested name of the packaged profile: letters, digits, '.', '_' and '-', + starting with a letter or digit. + placeholder: "e.g. byd-hvs-7.7" + validations: + required: true + +- type: dropdown + id: data_source + attributes: + label: "Source of the values" + description: Where do the limits and efficiencies of the profile come from? + options: + - Manufacturer datasheet + - Own measurements + - Datasheet and own measurements + - Estimated + validations: + required: true + +- type: textarea + id: profile + attributes: + label: "Profile document" + description: > + The profile as JSON. If the field is empty, copy the JSON from the profile + page of EOSdash and paste it here. + render: json + validations: + required: true + +- type: textarea + id: notes + attributes: + label: "Notes" + description: > + Anything reviewers should know, e.g. links to the datasheet, how you + measured, or values you are unsure about. + validations: + required: false + +- type: checkboxes + id: consent + attributes: + label: "Publication" + options: + - label: > + I may share these values, and they may be published with EOS under its + license (Apache-2.0). + required: true diff --git a/.github/ISSUE_TEMPLATE/inverter-profile.yml b/.github/ISSUE_TEMPLATE/inverter-profile.yml new file mode 100644 index 00000000..d0ed0dfe --- /dev/null +++ b/.github/ISSUE_TEMPLATE/inverter-profile.yml @@ -0,0 +1,80 @@ +name: Inverter profile +description: Share an inverter profile so that it can be packaged with EOS for other users. +title: "[PROFILE]: ..." +labels: [inverter-profile] + + +body: +- type: markdown + attributes: + value: > + Thank you for sharing an inverter profile. EOSdash fills this form for you: + open **Profiles**, choose the inverter profiles, select the profile and press + **Share**. + The profile is reviewed before it is packaged with EOS. See the + [inverter profile documentation](https://akkudoktor-eos.readthedocs.io/en/latest/akkudoktoreos/resource.html) + for the meaning of the values. + +- type: input + id: inverter_model + attributes: + label: "Inverter model" + description: Manufacturer and model of the inverter, as on the name plate. + placeholder: "e.g. Fronius Symo GEN24 10.0 Plus" + validations: + required: true + +- type: input + id: profile_name + attributes: + label: "Profile name" + description: > + Suggested name of the packaged profile: letters, digits, '.', '_' and '-', + starting with a letter or digit. + placeholder: "e.g. fronius-gen24-10" + validations: + required: true + +- type: dropdown + id: data_source + attributes: + label: "Source of the values" + description: Where do the efficiencies and the power derating of the profile come from? + options: + - Manufacturer datasheet + - Own measurements + - Datasheet and own measurements + - Estimated + validations: + required: true + +- type: textarea + id: profile + attributes: + label: "Profile document" + description: > + The profile as JSON. If the field is empty, copy the JSON from the profile + page of EOSdash and paste it here. + render: json + validations: + required: true + +- type: textarea + id: notes + attributes: + label: "Notes" + description: > + Anything reviewers should know, e.g. links to the datasheet, how you + measured, or values you are unsure about. + validations: + required: false + +- type: checkboxes + id: consent + attributes: + label: "Publication" + options: + - label: > + I may share these values, and they may be published with EOS under its + license (Apache-2.0). + required: true diff --git a/src/akkudoktoreos/server/dash/components.py b/src/akkudoktoreos/server/dash/components.py index 2e1b8fad..7cd05c30 100644 --- a/src/akkudoktoreos/server/dash/components.py +++ b/src/akkudoktoreos/server/dash/components.py @@ -3,13 +3,10 @@ import re from typing import Any, Callable, Optional, Union from fasthtml.common import H1, Button, Div, Li, Select -from monsterui.daisy import ( - Alert, - AlertT, -) from monsterui.foundations import stringify from monsterui.franken import ( # Select: Does not work - using Select from FastHTML instead;; Button: Does not pass hx_vals - using Button from FastHTML instead H3, + H4, ButtonT, Card, Code, @@ -150,24 +147,48 @@ def TextView(*c: Any, cls: Optional[Union[str, tuple]] = None, **kwargs: Any) -> return Pre(*c, **kwargs) -def Success(*c: Any) -> Alert: - return Alert( +# Colors of the status messages from the theme of EOSdash, readable in light and +# dark mode: (box, icon). daisyUI's alert colors come from its own color theme and +# give dark text on a green or gray background in dark mode. +STATUS_TONES: dict[str, tuple[str, str]] = { + "success": ("bg-primary/10 border-primary/40", "text-primary"), + "info": ("bg-muted border-border", "text-muted-foreground"), + "error": ("bg-destructive/10 border-destructive/40", "text-destructive"), +} + + +def StatusMessage(tone: str, icon: str, *c: Any) -> Div: + """A status message box in the colors of the EOSdash theme. + + Args: + tone: ``"success"``, ``"info"`` or ``"error"``, see ``STATUS_TONES``. + icon: Name of the icon. + *c: Content of the message. + + Returns: + The message box, with ``role="alert"``. + """ + box_cls, icon_cls = STATUS_TONES[tone] + return Div( DivLAligned( - UkIcon("check"), - TextView(*c), + UkIcon(icon, cls=icon_cls), + *c, ), - cls=AlertT.success, + role="alert", + cls=f"rounded-lg border p-3 text-foreground {box_cls}", ) -def Error(*c: Any) -> Alert: - return Alert( - DivLAligned( - UkIcon("triangle-alert"), - TextView(*c), - ), - cls=AlertT.error, - ) +def Success(*c: Any) -> Div: + return StatusMessage("success", "check", TextView(*c)) + + +def Info(*c: Any) -> Div: + return StatusMessage("info", "info", TextView(*c)) + + +def Error(*c: Any) -> Div: + return StatusMessage("error", "triangle-alert", TextView(*c)) def ConfigButton(*c: Any, cls: Optional[Union[str, tuple]] = None, **kwargs: Any) -> Button: @@ -188,7 +209,7 @@ def ConfigButton(*c: Any, cls: Optional[Union[str, tuple]] = None, **kwargs: Any return Button(*c, submit=False, **kwargs) -def UpdateError(error_text: str) -> Alert: +def UpdateError(error_text: str) -> Div: """Renders a compact error with collapsible full detail. Extracts the short pydantic validation message (text after @@ -199,7 +220,7 @@ def UpdateError(error_text: str) -> Alert: error_text: The full error string from a config update failure. Returns: - Alert: A collapsible error element with error styling. + Div: A collapsible error element with error styling. """ short = None match = re.search(r"validation error for [^\n]+\n([^\n]+)\n\s+([^\[]+)", error_text) @@ -208,11 +229,12 @@ def UpdateError(error_text: str) -> Alert: if not short: short = error_text.splitlines()[0].strip() - return Alert( + box_cls, icon_cls = STATUS_TONES["error"] + return Div( Details( Summary( DivLAligned( - UkIcon("triangle-alert"), + UkIcon("triangle-alert", cls=icon_cls), P(short, cls="text-sm ml-2"), ), cls="list-none cursor-pointer", @@ -222,7 +244,8 @@ def UpdateError(error_text: str) -> Alert: cls="rounded-lg bg-muted p-3 mt-2 max-h-[30vh] overflow-y-auto overflow-x-hidden whitespace-pre-wrap text-xs", ), ), - cls=AlertT.error, + role="alert", + cls=f"rounded-lg border p-3 text-foreground {box_cls}", ) @@ -1158,20 +1181,32 @@ def ConfigCard( ) -def ConfigSection(title: str, *content: Any, open: bool = False) -> Details: - """Collapsible section for grouping configuration entries.""" +def ConfigSection( + title: str, *content: Any, open: bool = False, compact: bool = False, **kwargs: Any +) -> Details: + """Collapsible section for grouping configuration entries. + + Args: + title: Title shown in the summary. + *content: Content of the section. + open: Whether the section is open. + compact: Smaller title and less padding, e.g. for pages with plots. + **kwargs: Further attributes of the ``details`` element, e.g. ``data_*``. + """ + heading = H4(title, cls="text-base") if compact else H3(title, cls="ml-2") return Details( Summary( Div( UkIcon("chevron-right", cls="transition-transform group-open:rotate-90"), - H3(title, cls="ml-2"), + heading, cls="flex items-center gap-2 cursor-pointer", ), cls="list-none", ), - Div(*content, cls="space-y-3 mt-3"), + Div(*content, cls="space-y-2 mt-2" if compact else "space-y-3 mt-3"), open=open, - cls="group border rounded-lg p-2", + cls="group border rounded-md px-3 py-2" if compact else "group border rounded-lg p-2", + **kwargs, ) diff --git a/src/akkudoktoreos/server/dash/profile.py b/src/akkudoktoreos/server/dash/profile.py new file mode 100644 index 00000000..e704abee --- /dev/null +++ b/src/akkudoktoreos/server/dash/profile.py @@ -0,0 +1,1375 @@ +"""Device profile editor for EOSdash. + +Lists the battery and inverter profiles of the EOS profile store and lets the +user view, edit, save, copy and delete them. The profile kind is selected at the +top of the page. All access goes through the REST API of EOS +(``/v1/profile/``); EOS validates every document before it is saved. + +Packaged profiles (shipped with EOS) are read only. Saving one stores a user +profile of the same name that overrides it; deleting that user profile reverts +to the packaged one. Saved profiles take effect at once in EOS, also for +running device controllers. + +The document is edited as JSON. The summary and the Bokeh plots show the +stored profile, after a save the saved one. *Preview* draws them from the +editor text instead, without saving, so changes can be checked before they +reach EOS and the device controllers. The plots show: + +- heat maps of the charge and discharge limit maps (SoC versus temperature), + drawn from the limit maps compiled by EOS (``compile_battery_profile``), i.e. + the dense lookup table EOS uses at runtime: the interpolation of the profile + (bilinear, nearest, none), the hard temperature cutoffs (inhibited cells) and + the C-rate unit are all as EOS applies them. The breakpoints of the document + are marked; their values are also listed in a table, +- the charge and discharge efficiency curves. + +The efficiency plot draws each curve the way EOS evaluates it +(``PrecomputedEfficiencyCurve.lookup``), by mapping the interpolation of the +profile to a Bokeh glyph: + +- ``linear``: a line through the breakpoints, +- ``nearest``: a step that changes at the midpoints between breakpoints + (``step(mode="center")``), +- ``none``: only the breakpoints (other C-rates have no efficiency), + +and the out-of-range behavior to the parts left and right of the curve: +``clamp_to_nearest`` as dashed lines at the end values, ``reject`` as nothing. + +For an inverter profile the page shows the DC→AC and AC→DC efficiency curves +over the load fraction and the power derating over the ambient temperature, +with the default temperature of 45 degC marked. These curves are read linearly +and clamped at their ends, which the dashed parts show. +""" + +import json +from typing import Any, Optional, Union +from urllib.parse import quote, urlencode + +import numpy as np +import requests +from bokeh.models import ColumnDataSource, HoverTool +from bokeh.models import Image as ImageGlyph +from bokeh.models import LinearColorMapper +from bokeh.models.annotations import ColorBar, Span +from bokeh.models.renderers import DataRenderer +from bokeh.palettes import Viridis256 +from fasthtml.common import Option, Select +from loguru import logger +from monsterui.franken import ( + H4, + A, + ButtonT, + Details, + Div, + DivHStacked, + Form, + Grid, + Input, + P, + Summary, + Table, + Tbody, + Td, + TextArea, + Th, + Thead, + Tr, +) + +from akkudoktoreos.devices.profile.batteryprofile import ( + BatteryProfile, + BatteryProfileSettings, + EfficiencyCurve, + EfficiencyInterpolation, + OutOfRangeBehavior, +) +from akkudoktoreos.devices.profile.inverterprofile import ( + InverterEfficiencyCurve, + InverterProfileSettings, + PowerDeratingCurve, +) +from akkudoktoreos.devices.profile.profilestore import ( + PROFILE_NAME_PATTERN, + RESERVED_PROFILE_NAMES, + ProfileDocument, +) +from akkudoktoreos.devices.settings.inverterresolution import ( + INVERTER_DEFAULT_TEMPERATURE_DEGC, +) +from akkudoktoreos.server.dash.bokeh import ( + Bokeh, + bokey_apply_theme_to_plot, + create_figure, +) +from akkudoktoreos.server.dash.components import ( + ConfigButton, + ConfigSection, + Error, + Info, + JsonView, + Success, +) +from akkudoktoreos.server.dash.context import request_url_for + +PAGE_PATH = "/eosdash/profile" +PROFILE_SELECT_ID = "profile-select" +EDITOR_FORM_ID = "profile-editor" + +# Profile kinds of the page: kind -> (label, default profile, document type). +PROFILE_KINDS: dict[str, tuple[str, str, type[ProfileDocument[Any]]]] = { + "battery": ("Battery", "generic-lfp", BatteryProfileSettings), + "inverter": ("Inverter", "generic-hybrid", InverterProfileSettings), +} +DEFAULT_KIND = "battery" +REQUEST_TIMEOUT_S = 10 + +# Plot heights [px], kept low for a condensed page. +LIMIT_MAP_HEIGHT_PX = 320 +CURVE_PLOT_HEIGHT_PX = 280 + +# Explanatory text: the muted text color of the theme, readable in light and dark mode. +HINT_CLS = "text-sm text-muted-foreground" + +# Collapsible sections of the profile page: (id, open on the first load). +SECTION_EDITOR = "editor" +SECTION_LIMITS = "limits" +SECTION_EFFICIENCY = "efficiency" +SECTION_JSON = "json" +SECTION_DERATING = "derating" +SECTION_DEFAULT_OPEN: dict[str, bool] = { + SECTION_EDITOR: False, + SECTION_DERATING: True, + SECTION_LIMITS: True, + SECTION_EFFICIENCY: True, + SECTION_JSON: False, +} + +# Sharing a profile: a new issue with the battery profile form of the EOS repository, +# filled in through URL parameters (one per form field id). +SHARE_ISSUE_URL = "https://github.com/Akkudoktor-EOS/EOS/issues/new" +# Issue form per profile kind: .github/ISSUE_TEMPLATE/-profile.yml, with the +# fields "_model", "profile_name" and "profile". +SHARE_ISSUE_TEMPLATE = "{kind}-profile.yml" +# Longer URLs are not reliably accepted by GitHub; the profile is then pasted by hand. +SHARE_URL_MAX_LENGTH = 8000 + +# Efficiency curves of a profile document: (document key, label, color). +EFFICIENCY_CURVES: tuple[tuple[str, str, str], ...] = ( + ("charge_efficiency", "Charge", "#2b83ba"), + ("discharge_efficiency", "Discharge", "#d7191c"), +) +# Background of plots and their annotations in dark mode (as bokey_apply_theme_to_plot). +DARK_BACKGROUND = "#1e1e1e" +# The plot extends this much beyond the last breakpoint, to show the out-of-range behavior. +EFFICIENCY_PLOT_MARGIN = 0.25 + +# Fields shown in the summary: (document key, label, unit). +SUMMARY_FIELDS: tuple[tuple[str, str, str], ...] = ( + ("battery_model", "Model", ""), + ("chemistry", "Chemistry", ""), + ("nominal_capacity_wh", "Nominal capacity", "Wh"), + ("nominal_voltage_v", "Nominal voltage", "V"), + ("max_charge_power_w", "Max. charge power", "W"), + ("max_discharge_power_w", "Max. discharge power", "W"), + ("technical_min_soc_percent", "Technical min. SoC", "%"), + ("technical_max_soc_percent", "Technical max. SoC", "%"), + ("full_charge_interval_days", "Full charge interval", "d"), + ("full_charge_detect_percentage", "Full charge detected from", "%"), +) + +# Fields shown in the summary of an inverter profile: (document key, label, unit). +INVERTER_SUMMARY_FIELDS: tuple[tuple[str, str, str], ...] = ( + ("inverter_model", "Model", ""), + ("nominal_power_w", "Nominal power", "W"), + ("max_ac_charge_power_w", "Max. AC charge power", "W"), + ("ac_charge_limits_total_charge", "AC charge limit also limits PV charging", ""), +) +# Efficiency curves of an inverter profile document: (document key, label, color). +INVERTER_EFFICIENCY_CURVES: tuple[tuple[str, str, str], ...] = ( + ("dc_to_ac_efficiency", "DC→AC (discharge)", "#d7191c"), + ("ac_to_dc_efficiency", "AC→DC (grid charge)", "#2b83ba"), +) + + +class ProfileApiError(Exception): + """An EOS profile API request failed; the message is shown to the user.""" + + +def _detail(response: Any) -> str: + """Return the error detail of an EOS response.""" + try: + detail = response.json().get("detail", "") + except Exception: + detail = getattr(response, "text", "") + if isinstance(detail, list): # FastAPI request validation errors + detail = "; ".join(str(item.get("msg", item)) for item in detail) + return str(detail) + + +def _request(method: str, url: str, **kwargs: Any) -> Any: + """Send a request to EOS and return the decoded JSON body. + + Raises: + ProfileApiError: If EOS cannot be reached or answers with an error. + """ + try: + response = getattr(requests, method)(url, timeout=REQUEST_TIMEOUT_S, **kwargs) + except Exception as exc: + raise ProfileApiError(f"EOS server not reachable: {exc}") from exc + if response.status_code >= 400: + raise ProfileApiError(f"{response.status_code}: {_detail(response)}") + return response.json() + + +def _profile_url(server: str, kind: str, name: Optional[str] = None) -> str: + url = f"{server}/v1/profile/{kind}" + return url if name is None else f"{url}/{quote(name, safe='')}" + + +def _entry_label(entry: dict[str, Any]) -> str: + """Label of a profile in the selection.""" + if entry["source"] == "package": + return f"{entry['name']} (packaged)" + if entry["packaged"]: + return f"{entry['name']} (user, overrides packaged)" + return f"{entry['name']} (user)" + + +def _format_value(value: Any, unit: str) -> str: + if value is None: + return "-" + return f"{value} {unit}".strip() + + +def ProfileSection( + title: str, *content: Any, open: bool = False, data_profile_section: str +) -> Details: + """Compact collapsible section of the profile page (``ConfigSection``, compact). + + The section id is reported with every page action, so the page keeps the + section open or closed when it is replaced. + + Args: + title: Title shown in the summary. + *content: Content of the section. + open: Whether the section is open. + data_profile_section: Section id, e.g. ``SECTION_LIMITS``. + """ + return ConfigSection( + title, *content, open=open, compact=True, data_profile_section=data_profile_section + ) + + +def _summary_item(label: str, value: Any) -> Div: + """One compact label/value pair in the profile summary.""" + return Div( + P(label, cls="text-xs text-muted-foreground leading-tight"), + P(str(value), cls="text-sm font-medium leading-tight"), + cls="min-w-0 px-3 py-2 border -mt-px -ml-px", + ) + + +def _summary_strip(items: list[tuple[str, Any]]) -> Div: + """Render profile metadata as a compact, wrapping information grid. + + Every cell has a full border; the negative margins (and the grid padding + that compensates them) let neighbouring borders overlap into single lines, + also between wrapped rows and around a last row that is not full. + ``auto-fit`` leaves no empty columns. Neither the grid nor the cells have a + background of their own: the tab background shows. + """ + return Div( + *(_summary_item(label, value) for label, value in items), + cls="grid grid-cols-[repeat(auto-fit,minmax(11rem,1fr))] pt-px pl-px", + ) + + +def ProfileSummary(document: dict[str, Any]) -> Div: + """Compact key data of a battery profile document.""" + cutoffs = document.get("hard_cutoffs") or {} + items = [(label, _format_value(document.get(key), unit)) for key, label, unit in SUMMARY_FIELDS] + items.extend( + [ + ( + "Charge temperature", + f"{_format_value(cutoffs.get('charge_allowed_below_degC'), 'degC')} – " + f"{_format_value(cutoffs.get('charge_allowed_above_degC'), 'degC')}", + ), + ( + "Discharge temperature", + f"{_format_value(cutoffs.get('discharge_allowed_below_degC'), 'degC')} – " + f"{_format_value(cutoffs.get('discharge_allowed_above_degC'), 'degC')}", + ), + ] + ) + return _summary_strip(items) + + +def LimitMapTable(title: str, document: dict[str, Any], key: str) -> Div: + """Read-only table of a limit map: temperature rows, SoC columns.""" + axes = document.get("axes") or {} + temperatures = axes.get("temperature_degC") or [] + socs = axes.get("soc_percent") or [] + values = (document.get(key) or {}).get("values") or [] + unit = (document.get("units") or {}).get("limit", "") + if not temperatures or not socs or len(values) != len(temperatures): + return Div(H4(title), P("No limit map to show.")) + return Div( + H4(f"{title} [{unit}]"), + Div( + Table( + Thead(Tr(Th("degC \\ SoC %"), *(Th(str(soc)) for soc in socs))), + Tbody( + *( + Tr(Th(str(temperature)), *(Td(str(value)) for value in row)) + for temperature, row in zip(temperatures, values) + ) + ), + cls="text-xs", + ), + cls="overflow-x-auto", + ), + ) + + +# Limit maps of a profile: (attribute of BatteryProfile, document key, label). +# Temperature resolution of the drawn heat maps at the hard cutoffs [degC]. +HEATMAP_TEMPERATURE_RESOLUTION_DEGC: float = 0.1 + +LIMIT_MAPS: tuple[tuple[str, str, str], ...] = ( + ("charge_map", "charge_limit_crate", "Charge limit"), + ("discharge_map", "discharge_limit_crate", "Discharge limit"), +) + + +def limit_heatmap_image(profile: BatteryProfile, attribute: str) -> dict[str, Any]: + """Map a compiled limit map to the parameters of a Bokeh ``image`` glyph. + + The compiled map is the lookup table EOS uses: a query is rounded to the + nearest cell (``PrecomputedChargeDischargeLimitMap.lookup``). Each cell is + therefore drawn centered on its grid point, ``t_step`` x ``s_step`` wide. + The hard cutoffs are checked on the exact temperature, so every cell row is + drawn as rows of ``HEATMAP_TEMPERATURE_RESOLUTION_DEGC``; rows outside the + cutoffs are blank. + + Args: + profile: Compiled battery profile. + attribute: ``"charge_map"`` or ``"discharge_map"``. + + Returns: + ``image`` (rows: temperature, columns: SoC; NaN where EOS inhibits the + direction), ``x``/``y`` (lower left corner, SoC % / degC), ``dw``/``dh`` + (width / height). + """ + limit_map = getattr(profile, attribute) + grid = np.asarray(limit_map.grid, dtype=float) + rows_per_cell = max(1, round(limit_map.t_step / HEATMAP_TEMPERATURE_RESOLUTION_DEGC)) + grid = np.repeat(grid, rows_per_cell, axis=0) + row_height = limit_map.t_step / rows_per_cell + bottom = limit_map.t_min - limit_map.t_step / 2.0 + row_centers = bottom + (np.arange(grid.shape[0]) + 0.5) * row_height + inhibited = np.zeros(grid.shape[0], dtype=bool) + if limit_map.allowed_min_degC is not None: + inhibited |= row_centers < limit_map.allowed_min_degC + if limit_map.allowed_max_degC is not None: + inhibited |= row_centers > limit_map.allowed_max_degC + grid[inhibited, :] = np.nan + return { + "image": grid, + "x": limit_map.s_min - limit_map.s_step / 2.0, + "y": limit_map.t_min - limit_map.t_step / 2.0, + "dw": limit_map.n_soc * limit_map.s_step, + "dh": limit_map.n_temp * limit_map.t_step, + } + + +def LimitHeatMap( + profile: BatteryProfile, + document: dict[str, Any], + attribute: str, + key: str, + title: str, + high: float, + dark: bool, +) -> Any: + """Bokeh heat map of one limit map, with the breakpoints of the document.""" + unit = profile.limit_unit + image = limit_heatmap_image(profile, attribute) + mapper = LinearColorMapper( + palette=Viridis256, low=0.0, high=high, nan_color="#555555" if dark else "#c8c8c8" + ) + plot = create_figure( + title=f"{title} [{unit}] (as evaluated by EOS)", + x_axis_label="SoC [%]", + y_axis_label="Temperature [degC]", + x_range=(image["x"], image["x"] + image["dw"]), + y_range=(image["y"], image["y"] + image["dh"]), + sizing_mode="stretch_width", + height=LIMIT_MAP_HEIGHT_PX, + tools="pan,wheel_zoom,box_zoom,reset,save", + ) + image_renderer = plot.add_glyph( + ColumnDataSource({"image": [image["image"]]}), + ImageGlyph( + image="image", + x=image["x"], + y=image["y"], + dw=image["dw"], + dh=image["dh"], + color_mapper=mapper, + ), + ) + plot.add_tools( + HoverTool( + renderers=[image_renderer], + tooltips=[ + ("SoC", "$x{0} %"), + ("temperature", "$y{0.0} degC"), + (f"limit [{unit}]", "@image{0.00}"), + ], + ) + ) + + axes = document.get("axes") or {} + temperatures = axes.get("temperature_degC") or [] + socs = axes.get("soc_percent") or [] + values = (document.get(key) or {}).get("values") or [] + if temperatures and socs and len(values) == len(temperatures): + points = ColumnDataSource( + { + "soc": [soc for _ in temperatures for soc in socs], + "temperature": [t for t in temperatures for _ in socs], + "value": [v for row in values for v in row], + } + ) + point_renderer = plot.scatter( + "soc", + "temperature", + source=points, + size=6, + fill_color="white", + line_color="black", + ) + plot.add_tools( + HoverTool( + renderers=[point_renderer], + tooltips=[ + ("breakpoint", "@soc %, @temperature degC"), + (f"limit [{unit}]", "@value"), + ], + ) + ) + color_bar = ColorBar(color_mapper=mapper, title=unit) + if dark: + # The theme of the plot does not reach annotations: the color bar has its own colors. + color_bar.background_fill_color = DARK_BACKGROUND + color_bar.background_fill_alpha = 1.0 + color_bar.title_text_color = "white" + color_bar.major_label_text_color = "white" + color_bar.major_tick_line_color = "white" + plot.add_layout(color_bar, "right") + plot.toolbar.autohide = True + bokey_apply_theme_to_plot(plot, dark) + return Bokeh(plot) + + +def LimitMaps(document: dict[str, Any], dark: bool = False, open: bool = True) -> Any: + """Heat maps of the charge and discharge limit maps, and their breakpoint values. + + The document is compiled like EOS compiles it; a document that does not + compile (e.g. while editing) is reported instead of drawn. + """ + tables = Details( + Summary("Breakpoint values", cls="cursor-pointer"), + Grid( + *(LimitMapTable(label, document, key) for _, key, label in LIMIT_MAPS), + cols_max=2, + ), + ) + try: + profile = BatteryProfileSettings.model_validate(document).to_limits() + except ValueError as exc: + return ProfileSection( + "Charge and discharge limits", + Error(f"The limit maps can not be compiled: {exc}"), + tables, + open=open, + data_profile_section=SECTION_LIMITS, + ) + grids = [ + np.asarray(getattr(profile, attribute).grid, dtype=float) for attribute, _, _ in LIMIT_MAPS + ] + finite = [grid[np.isfinite(grid)] for grid in grids] + high = max((float(values.max()) for values in finite if values.size), default=1.0) or 1.0 + return ProfileSection( + "Charge and discharge limits", + Grid( + *( + LimitHeatMap(profile, document, attribute, key, label, high, dark) + for attribute, key, label in LIMIT_MAPS + ), + cols_max=2, + ), + P( + "Colors: the limit EOS uses at each SoC and temperature (both maps share the color " + "scale). Gray: the direction is inhibited (hard temperature cutoff). Circles: the " + "breakpoints of the profile document.", + cls=HINT_CLS, + ), + tables, + open=open, + data_profile_section=SECTION_LIMITS, + ) + + +def efficiency_glyphs(curve: EfficiencyCurve, c_rate_max: float) -> dict[str, Any]: + """Map an efficiency curve to the Bokeh glyphs that draw it as EOS evaluates it. + + Args: + curve: Validated efficiency curve. + c_rate_max: Right end of the plot [C-rate]; the left end is 0. + + Returns: + A dict with: + + - ``points``: ``(c_rates, efficiencies)`` of the breakpoints, always drawn + as markers. + - ``inside``: ``(glyph, c_rates, efficiencies)`` between the first and last + breakpoint: ``glyph`` is ``"line"`` (linear), ``"step"`` (nearest, step + at the midpoints) or None (none: breakpoints only). + - ``outside``: list of ``(c_rates, efficiencies)`` dashed segments left of + the first and right of the last breakpoint (clamp to nearest); empty if + out-of-range C-rates are rejected. + """ + c_rates = list(curve.c_rate) + efficiencies = list(curve.efficiency) + glyph: Optional[str] = { + EfficiencyInterpolation.LINEAR: "line", + EfficiencyInterpolation.NEAREST: "step", + EfficiencyInterpolation.NONE: None, + }[curve.interpolation] + outside: list[tuple[list[float], list[float]]] = [] + if curve.out_of_range_behavior == OutOfRangeBehavior.CLAMP_TO_NEAREST: + if c_rates[0] > 0.0: + outside.append(([0.0, c_rates[0]], [efficiencies[0]] * 2)) + if c_rate_max > c_rates[-1]: + outside.append(([c_rates[-1], c_rate_max], [efficiencies[-1]] * 2)) + return { + "points": (c_rates, efficiencies), + "inside": (glyph, c_rates, efficiencies), + "outside": outside, + } + + +def _interpolation_label(curve: EfficiencyCurve) -> str: + mode = { + EfficiencyInterpolation.LINEAR: "linear", + EfficiencyInterpolation.NEAREST: "nearest", + EfficiencyInterpolation.NONE: "breakpoints only", + }[curve.interpolation] + outside = ( + "clamped" + if curve.out_of_range_behavior == OutOfRangeBehavior.CLAMP_TO_NEAREST + else "rejected" + ) + return f"{mode}, out of range {outside}" + + +def EfficiencyPlot(document: dict[str, Any], dark: bool = False, open: bool = True) -> Any: + """Bokeh plot of the charge and discharge efficiency curves of a profile document. + + Curves the document does not have (e.g. while editing) are reported as an + error below the plot, as the profile requires both; invalid curves are + reported instead of drawn. + """ + curves: list[tuple[str, str, EfficiencyCurve]] = [] + notes: list[Any] = [] + for key, label, color in EFFICIENCY_CURVES: + raw = document.get(key) + if raw is None: + notes.append( + Error( + f"No {label.lower()} efficiency curve: the profile requires " + f"'{key}' and cannot be saved without it." + ) + ) + continue + try: + curves.append((label, color, EfficiencyCurve.model_validate(raw))) + except ValueError as exc: + notes.append(Error(f"Invalid {label.lower()} efficiency curve: {exc}")) + if not curves: + return ProfileSection( + "Efficiency", *notes, open=open, data_profile_section=SECTION_EFFICIENCY + ) + + c_rate_max = max(curve.c_rate[-1] for _, _, curve in curves) * (1.0 + EFFICIENCY_PLOT_MARGIN) + capacity_wh = document.get("nominal_capacity_wh") + tooltips = [ + ("", "@label"), + ("C-rate", "@c_rate{0.00} C"), + ("efficiency", "@efficiency_pct{0.0} %"), + ] + if capacity_wh: + tooltips.insert(2, ("power", "@power_w{0} W")) + + plot = create_figure( + title="Battery efficiency versus C-rate (as evaluated by EOS)", + x_axis_label="C-rate [1/h]", + y_axis_label="Efficiency [%]", + x_range=(0.0, c_rate_max), + sizing_mode="stretch_width", + height=CURVE_PLOT_HEIGHT_PX, + tools="pan,wheel_zoom,box_zoom,reset,save", + ) + point_renderers: list[DataRenderer] = [] + for label, color, curve in curves: + glyphs = efficiency_glyphs(curve, c_rate_max) + legend = f"{label} ({_interpolation_label(curve)})" + glyph, xs, ys = glyphs["inside"] + ys_pct = [100.0 * y for y in ys] + if glyph == "line": + plot.line(xs, ys_pct, color=color, line_width=2, legend_label=legend) + elif glyph == "step": + plot.step(xs, ys_pct, mode="center", color=color, line_width=2, legend_label=legend) + for seg_x, seg_y in glyphs["outside"]: + plot.line( + seg_x, + [100.0 * y for y in seg_y], + color=color, + line_width=2, + line_dash="dashed", + line_alpha=0.6, + legend_label=legend, + ) + pxs, pys = glyphs["points"] + source = ColumnDataSource( + { + "c_rate": pxs, + "efficiency_pct": [100.0 * y for y in pys], + "power_w": [c * capacity_wh for c in pxs] if capacity_wh else [None] * len(pxs), + "label": [label] * len(pxs), + } + ) + point_renderers.append( + plot.scatter( + "c_rate", "efficiency_pct", source=source, size=8, color=color, legend_label=legend + ) + ) + plot.add_tools(HoverTool(renderers=point_renderers, tooltips=tooltips)) + plot.legend.location = "bottom_left" + plot.legend.click_policy = "hide" + plot.toolbar.autohide = True + bokey_apply_theme_to_plot(plot, dark) + if dark: + plot.legend.background_fill_color = DARK_BACKGROUND + plot.legend.label_text_color = "white" + return ProfileSection( + "Efficiency", + Bokeh(plot), + P( + "Dashed: C-rates outside the curve, clamped to its end value. Hover over a " + "breakpoint for its values" + + (f" (power for {capacity_wh} Wh)." if capacity_wh else "."), + cls=HINT_CLS, + ), + *notes, + open=open, + data_profile_section=SECTION_EFFICIENCY, + ) + + +def _clamped_segments( + xs: list[float], ys: list[float], x_min: float, x_max: float +) -> list[tuple[list[float], list[float]]]: + """Dashed extensions of a clamped curve to the left and right plot edges.""" + segments: list[tuple[list[float], list[float]]] = [] + if x_min < xs[0]: + segments.append(([x_min, xs[0]], [ys[0], ys[0]])) + if x_max > xs[-1]: + segments.append(([xs[-1], x_max], [ys[-1], ys[-1]])) + return segments + + +def _style_legend(plot: Any, dark: bool) -> None: + plot.legend.location = "bottom_left" + plot.legend.click_policy = "hide" + plot.toolbar.autohide = True + bokey_apply_theme_to_plot(plot, dark) + if dark: + plot.legend.background_fill_color = DARK_BACKGROUND + plot.legend.label_text_color = "white" + + +def _derating_fraction(document: dict[str, Any], temperature_degC: float) -> Optional[float]: + """Allowed power fraction of an inverter document at a temperature, None if invalid.""" + raw = document.get("power_derating") + if raw is None: + return 1.0 + try: + return PowerDeratingCurve.model_validate(raw).to_curve().lookup(temperature_degC) + except ValueError: + return None + + +def InverterSummary(document: dict[str, Any]) -> Div: + """Compact key data of an inverter profile document.""" + items: list[tuple[str, Any]] = [] + for key, label, unit in INVERTER_SUMMARY_FIELDS: + value = document.get(key) + text = ("yes" if value else "no") if isinstance(value, bool) else _format_value(value, unit) + items.append((label, text)) + fraction = _derating_fraction(document, INVERTER_DEFAULT_TEMPERATURE_DEGC) + items.append( + ( + f"Power at {INVERTER_DEFAULT_TEMPERATURE_DEGC:g} degC", + "-" if fraction is None else f"{100.0 * fraction:.0f} %", + ) + ) + return _summary_strip(items) + + +def InverterEfficiencyPlot(document: dict[str, Any], dark: bool = False, open: bool = True) -> Any: + """Bokeh plot of the DC→AC and AC→DC efficiency curves of an inverter profile. + + Invalid curves (e.g. while editing) are reported instead of drawn. + """ + curves: list[tuple[str, str, InverterEfficiencyCurve]] = [] + notes: list[Any] = [] + for key, label, color in INVERTER_EFFICIENCY_CURVES: + raw = document.get(key) + if raw is None: + notes.append(Error(f"The {label} efficiency curve is missing.")) + continue + try: + curves.append((label, color, InverterEfficiencyCurve.model_validate(raw))) + except ValueError as exc: + notes.append(Error(f"Invalid {label} efficiency curve: {exc}")) + if not curves: + return ProfileSection( + "Efficiency", *notes, open=open, data_profile_section=SECTION_EFFICIENCY + ) + + x_max = max(1.0, max(curve.load_fraction[-1] for _, _, curve in curves)) * ( + 1.0 + EFFICIENCY_PLOT_MARGIN + ) + nominal_power_w = document.get("nominal_power_w") + tooltips = [ + ("", "@label"), + ("load fraction", "@load_fraction{0.00}"), + ("efficiency", "@efficiency_pct{0.0} %"), + ] + if nominal_power_w: + tooltips.insert(2, ("AC power", "@power_w{0} W")) + plot = create_figure( + title="Inverter efficiency versus load fraction (as evaluated by EOS)", + x_axis_label="Load fraction (AC power / nominal power)", + y_axis_label="Efficiency [%]", + x_range=(0.0, x_max), + sizing_mode="stretch_width", + height=CURVE_PLOT_HEIGHT_PX, + tools="pan,wheel_zoom,box_zoom,reset,save", + ) + point_renderers: list[DataRenderer] = [] + for label, color, curve in curves: + xs = list(curve.load_fraction) + ys = [100.0 * y for y in curve.efficiency] + plot.line(xs, ys, color=color, line_width=2, legend_label=label) + for seg_x, seg_y in _clamped_segments(xs, ys, 0.0, x_max): + plot.line( + seg_x, + seg_y, + color=color, + line_width=2, + line_dash="dashed", + line_alpha=0.6, + legend_label=label, + ) + source = ColumnDataSource( + { + "load_fraction": xs, + "efficiency_pct": ys, + "power_w": [x * nominal_power_w for x in xs] + if nominal_power_w + else [None] * len(xs), + "label": [label] * len(xs), + } + ) + point_renderers.append( + plot.scatter( + "load_fraction", + "efficiency_pct", + source=source, + size=8, + color=color, + legend_label=label, + ) + ) + plot.add_tools(HoverTool(renderers=point_renderers, tooltips=tooltips)) + _style_legend(plot, dark) + return ProfileSection( + "Efficiency", + Bokeh(plot), + P( + "Linear between the breakpoints. Dashed: load fractions outside the curve, " + "clamped to its end value.", + cls=HINT_CLS, + ), + *notes, + open=open, + data_profile_section=SECTION_EFFICIENCY, + ) + + +def DeratingPlot(document: dict[str, Any], dark: bool = False, open: bool = True) -> Any: + """Bokeh plot of the power derating of an inverter profile over the temperature.""" + raw = document.get("power_derating") + if raw is None: + return ProfileSection( + "Power derating", + P("No power derating: the full nominal power applies at every temperature."), + open=open, + data_profile_section=SECTION_DERATING, + ) + try: + derating = PowerDeratingCurve.model_validate(raw) + except ValueError as exc: + return ProfileSection( + "Power derating", + Error(f"Invalid power derating: {exc}"), + open=open, + data_profile_section=SECTION_DERATING, + ) + + xs = list(derating.temperature_degC) + ys = [100.0 * y for y in derating.power_fraction] + x_min = min(xs[0], INVERTER_DEFAULT_TEMPERATURE_DEGC) - 10.0 + x_max = max(xs[-1], INVERTER_DEFAULT_TEMPERATURE_DEGC) + 10.0 + plot = create_figure( + title="Allowed power versus ambient temperature (as evaluated by EOS)", + x_axis_label="Ambient temperature [degC]", + y_axis_label="Allowed power [% of nominal]", + x_range=(x_min, x_max), + y_range=(0.0, 105.0), + sizing_mode="stretch_width", + height=CURVE_PLOT_HEIGHT_PX, + tools="pan,wheel_zoom,box_zoom,reset,save", + ) + color = "#1a9641" + plot.line(xs, ys, color=color, line_width=2, legend_label="Derating") + for seg_x, seg_y in _clamped_segments(xs, ys, x_min, x_max): + plot.line( + seg_x, + seg_y, + color=color, + line_width=2, + line_dash="dashed", + line_alpha=0.6, + legend_label="Derating", + ) + source = ColumnDataSource({"temperature": xs, "power_pct": ys}) + points = plot.scatter("temperature", "power_pct", source=source, size=8, color=color) + plot.add_tools( + HoverTool( + renderers=[points], + tooltips=[("temperature", "@temperature{0.0} degC"), ("power", "@power_pct{0} %")], + ) + ) + plot.add_layout( + Span( + location=INVERTER_DEFAULT_TEMPERATURE_DEGC, + dimension="height", + line_color="gray", + line_dash="dotted", + line_width=2, + ) + ) + _style_legend(plot, dark) + return ProfileSection( + "Power derating", + Bokeh(plot), + P( + f"Dotted: {INVERTER_DEFAULT_TEMPERATURE_DEGC:g} degC, the temperature EOS assumes " + "without a measurement of '-temperature-degc'. Dashed: temperatures " + "outside the curve, clamped to its end value.", + cls=HINT_CLS, + ), + open=open, + data_profile_section=SECTION_DERATING, + ) + + +def _hx_vals(action: str, kind: Optional[str] = None) -> str: + """Htmx values of a page action. + + Besides the action: the profile kind, the dark mode of the browser, and the + sections that are open now, so the page keeps them open or closed when it + is replaced. A section that is no ``details`` element (the editor) counts + as open while it is on the page. + """ + kind_value = f'"kind": "{kind}", ' if kind is not None else "" + return ( + f'js:{{ "action": "{action}", {kind_value}' + '"dark": window.matchMedia("(prefers-color-scheme: dark)").matches, ' + '"open_sections": Array.from(document.querySelectorAll(' + '"#page-content [data-profile-section]"))' + '.filter(d => d.tagName !== "DETAILS" || d.open)' + '.map(d => d.dataset.profileSection).join(",") }' + ) + + +# Actions that use the editor: it stays open after them. +EDITOR_ACTIONS = ("edit", "preview", "save", "save_as") + + +def editor_open(data: dict[str, Any]) -> bool: + """Return whether the page shows the editor (edit mode). + + Edit mode starts with the Edit button and ends with Close; it is kept across + the other actions, e.g. a profile change, through the reported open + sections. + + Args: + data: Values of the page request. + """ + action = data.get("action") + if action == "close": + return False + return action in EDITOR_ACTIONS or section_open(data, SECTION_EDITOR) + + +def EditButton(kind: str, editing: bool) -> Any: + """Toolbar button that opens or closes the editor of the selected profile.""" + return ConfigButton( + "Close editor" if editing else "Edit", + hx_post=request_url_for(PAGE_PATH), + hx_target="#page-content", + hx_swap="innerHTML", + hx_include=f"#{PROFILE_SELECT_ID}", + hx_vals=_hx_vals("close" if editing else "edit", kind), + ) + + +def section_open(data: dict[str, Any], section: str) -> bool: + """Return whether a section of the page is open. + + As the user left it if the request reports the open sections, otherwise + the default of the first load. + + Args: + data: Values of the page request. + section: Section id, e.g. ``SECTION_LIMITS``. + """ + if "open_sections" not in data: + return SECTION_DEFAULT_OPEN[section] + return section in str(data["open_sections"]).split(",") + + +def ProfileEditor( + kind: str, + name: str, + entry: Optional[dict[str, Any]], + document_text: str, +) -> Div: + """JSON editor with a compact action bar, shown in edit mode only. + + The text area takes 40 % of the window height, so a plot below stays + visible while editing; it can be resized. Without edit mode the page has + no editor at all, not even a collapsed line. + + Args: + kind: Profile kind, e.g. ``"battery"``. + name: Profile name. + entry: List entry of the profile, None if it is not stored. + document_text: Text of the editor. + """ + is_user = entry is not None and entry["source"] == "user" + overrides_packaged = entry is not None and entry["packaged"] + if entry is None or is_user: + save_hint = f"Save to the user profile '{name}'." + else: + save_hint = ( + f"'{name}' is packaged with EOS and read only: saving stores a user profile of the " + "same name that overrides it." + ) + delete_label = "Revert to packaged" if overrides_packaged else "Delete" + + def action_button(label: str, action: str, **kwargs: Any) -> Any: + return ConfigButton( + label, + hx_post=request_url_for(PAGE_PATH), + hx_target="#page-content", + hx_swap="innerHTML", + hx_include=f"#{EDITOR_FORM_ID}", + hx_vals=_hx_vals(action, kind), + **kwargs, + ) + + buttons: list[Any] = [action_button("Preview", "preview"), action_button("Save", "save")] + if is_user: + buttons.append( + action_button( + delete_label, + "delete", + hx_confirm=( + f"Revert '{name}' to the packaged profile?" + if overrides_packaged + else f"Delete the {PROFILE_KINDS[kind][0].lower()} profile '{name}'?" + ), + ) + ) + return Div( + H4(f"Edit '{name}'", cls="text-base"), + P( + f"{save_hint} Preview updates the summary and plots without saving; saved " + "profiles apply at once, also to running device controllers.", + cls=HINT_CLS, + ), + Form( + Input(type="hidden", name="profile_name", value=name), + TextArea( + document_text, + name="document", + spellcheck="false", + cls="font-mono text-xs w-full", + style="height: 40vh; min-height: 12rem; resize: vertical; tab-size: 2;", + ), + DivHStacked( + *buttons, + Input( + name="new_name", + placeholder="new profile name", + cls="uk-input w-64", + ), + action_button("Save as", "save_as"), + action_button("Close", "close"), + cls="flex flex-wrap items-center gap-2", + ), + id=EDITOR_FORM_ID, + cls="space-y-2", + ), + cls="border rounded-md px-3 py-2 space-y-2", + data_profile_section=SECTION_EDITOR, + ) + + +def share_issue_url(kind: str, name: str, document: dict[str, Any]) -> tuple[str, bool]: + """Return the URL of a new GitHub issue that shares a profile. + + The issue uses the profile form of the kind in the EOS repository (see + ``SHARE_ISSUE_TEMPLATE``); the URL fills in its fields. The profile document is indented for the reviewers, compact + if that is too long, and left out if the URL would still get longer than + ``SHARE_URL_MAX_LENGTH``. + + Args: + kind: Profile kind, e.g. ``"battery"``. + name: Profile name. + document: Profile document. + + Returns: + ``(url, includes_document)``. + """ + model_key = f"{kind}_model" + model = str(document.get(model_key) or name) + params = { + "template": SHARE_ISSUE_TEMPLATE.format(kind=kind), + "title": f"[PROFILE]: {model}", + model_key: model, + "profile_name": name, + } + for text in ( + json.dumps(document, indent=1, ensure_ascii=False), + json.dumps(document, separators=(",", ":"), ensure_ascii=False), + ): + url = f"{SHARE_ISSUE_URL}?{urlencode({**params, 'profile': text})}" + if len(url) <= SHARE_URL_MAX_LENGTH: + return url, True + return f"{SHARE_ISSUE_URL}?{urlencode(params)}", False + + +def ProfileJson(name: str, document: dict[str, Any], preview: bool, open: bool = False) -> Details: + """The profile document as highlighted JSON, in a collapsible section. + + Shows the same document as the summary and the plots: the stored profile, + or the edited text during a preview. The copy button comes with the + highlight.js setup of EOSdash, as for the JSON values of the configuration. + """ + title = ( + f"Preview of '{name}' as JSON (not saved)" + if preview + else f"Stored profile '{name}' as JSON" + ) + return ProfileSection(title, JsonView(document), open=open, data_profile_section=SECTION_JSON) + + +def ShareButton(kind: str, name: str, document: dict[str, Any], preview: bool) -> Div: + """Link that shares the shown profile as a GitHub issue. + + Opens a new issue with the profile form of the kind in the browser, filled + in with the document; nothing is sent by EOS. The hint is the tooltip of + the link; if the profile is too large for the URL, it is also shown as + text, because the user has to paste the profile by hand. + + Returns: + The link, followed by the visible hint if the profile has to be pasted. + """ + url, includes_document = share_issue_url(kind, name, document) + if not includes_document: + hint = ( + "The profile is too large to include automatically. Copy it from the JSON view " + "and paste it into the GitHub issue." + ) + elif preview: + hint = "Opens a GitHub issue containing the previewed, unsaved profile." + else: + hint = "Opens a GitHub issue containing this profile." + link = A( + "Share", + href=url, + target="_blank", + rel="noopener noreferrer", + title=hint, + aria_label=f"Share on GitHub. {hint}", + # Same size as the Edit button next to it (ConfigButton). + cls=f"px-4 py-2 rounded {ButtonT.primary}", + ) + if includes_document: + return Div(link, cls="flex items-center") + return Div(link, P(hint, cls=HINT_CLS), cls="flex flex-wrap items-center gap-2") + + +def profile_name_error(name: str) -> Optional[str]: + """Return why a profile name is invalid, None if it is valid. + + Checks the rule of the profile store before a request is sent, so a name + like 'vendor/model' gets a clear message instead of a path EOS does not route. + """ + if name in RESERVED_PROFILE_NAMES: + return f"The profile name '{name}' is reserved." + if not PROFILE_NAME_PATTERN.fullmatch(name): + return ( + f"Invalid profile name '{name}': use letters, digits, '.', '_' and '-', " + "starting with a letter or digit (at most 100 characters)." + ) + return None + + +def _apply_action( + server: str, kind: str, data: dict[str, Any] +) -> tuple[Optional[Any], Optional[str], Optional[str]]: + """Run the requested action against EOS. + + Returns: + ``(status, selected, document_text)``: the status alert (or None), the + profile to select (or None for the default) and the editor text to show + instead of the stored document (kept after a failed save, so edits are + not lost). + """ + action = data.get("action") + name = str(data.get("profile_name") or "").strip() + text = str(data.get("document") or "") + label, _, document_type = PROFILE_KINDS[kind] + label = label.lower() + + if action == "kind": + return None, None, None + + if action == "select": + return None, name or None, None + + if action == "preview": + try: + document = json.loads(text) + except json.JSONDecodeError as exc: + return Error(f"The document is not valid JSON: {exc}"), name or None, text + if not isinstance(document, dict): + return Error("The document must be a JSON object."), name or None, text + try: + document_type.model_validate(document) + except ValueError as exc: + return ( + Error(f"Preview of unsaved changes to '{name}', the document is invalid: {exc}"), + name or None, + text, + ) + return ( + Info(f"Preview of unsaved changes to '{name}'. Save to apply them."), + name or None, + text, + ) + + if action in ("save", "save_as"): + target = name if action == "save" else str(data.get("new_name") or "").strip() + if not target: + return Error("Enter a name for the new profile."), name or None, text + name_error = profile_name_error(target) + if name_error: + return Error(name_error), name or None, text + try: + document = json.loads(text) + except json.JSONDecodeError as exc: + return Error(f"The document is not valid JSON: {exc}"), name or None, text + try: + _request("put", _profile_url(server, kind, target), json=document) + except ProfileApiError as exc: + return ( + Error(f"{label.capitalize()} profile '{target}' not saved: {exc}"), + name or None, + text, + ) + return Success(f"Saved {label} profile '{target}'."), target, None + + if action == "delete": + try: + restored = _request("delete", _profile_url(server, kind, name)) + except ProfileApiError as exc: + return ( + Error(f"{label.capitalize()} profile '{name}' not deleted: {exc}"), + name or None, + None, + ) + if restored: + return Success(f"Reverted '{name}' to the packaged profile."), name, None + return Success(f"Deleted {label} profile '{name}'."), None, None + + return None, name or None, None + + +def Profile(eos_host: str, eos_port: Union[str, int], data: Optional[dict] = None) -> Div: + """Generate the device profile page. + + Args: + eos_host: Host name of the EOS server. + eos_port: Port of the EOS server. + data: Action of the page: ``kind`` (``battery``, ``inverter``), + ``action`` (``kind``, ``select``, ``preview``, ``save``, ``save_as``, + ``delete``), ``profile_name``, ``new_name``, ``document`` (JSON) and + ``dark`` (``"true"`` for the dark theme of the plots). + + Returns: + The page content. + """ + server = f"http://{eos_host}:{eos_port}" + data = data or {} + dark = str(data.get("dark", "")).lower() == "true" + kind = str(data.get("kind") or DEFAULT_KIND) + if kind not in PROFILE_KINDS: + kind = DEFAULT_KIND + label, default_profile, _ = PROFILE_KINDS[kind] + status, selected, document_text = _apply_action(server, kind, data) + + kind_selection = Div( + Select( + *( + Option(f"{kind_label} profiles", value=key, selected=key == kind) + for key, (kind_label, _, _) in PROFILE_KINDS.items() + ), + name="kind", + hx_post=request_url_for(PAGE_PATH), + hx_target="#page-content", + hx_swap="innerHTML", + hx_trigger="change", + hx_vals=_hx_vals("kind"), + cls="border rounded px-3 py-2", + title="Select profile kind", + aria_label="Profile kind", + ), + cls="flex items-center", + ) + + try: + entries: list[dict[str, Any]] = _request("get", _profile_url(server, kind)) + except ProfileApiError as exc: + message = f"Can not retrieve {label.lower()} profiles from {server}: {exc}" + logger.warning(message) + return Div( + Div( + kind_selection, + Div(status, cls="min-w-0 flex-1") if status is not None else None, + cls="flex flex-wrap items-center gap-2", + ), + Error(message), + cls="space-y-3", + ) + + names = [entry["name"] for entry in entries] + if selected not in names: + if document_text is None or not selected: + selected = default_profile if default_profile in names else (names or [None])[0] + entry = next((e for e in entries if e["name"] == selected), None) + + document: dict[str, Any] = {} + load_error = None + if selected is not None and entry is not None: + try: + document = _request("get", _profile_url(server, kind, selected)) + except ProfileApiError as exc: + load_error = Error(f"Can not load {label.lower()} profile '{selected}': {exc}") + preview = False + if document_text is None: + document_text = json.dumps(document, indent=2) if document else "" + else: + # Show the preview of the edited text if it is a JSON object. + try: + edited = json.loads(document_text) + except json.JSONDecodeError: + edited = None + if isinstance(edited, dict): + document = edited + preview = True + + editing = selected is not None and editor_open(data) + selection = Div( + Select( + *( + Option(_entry_label(e), value=e["name"], selected=e["name"] == selected) + for e in entries + ), + name="profile_name", + id=PROFILE_SELECT_ID, + hx_post=request_url_for(PAGE_PATH), + hx_target="#page-content", + hx_swap="innerHTML", + hx_trigger="change", + hx_vals=_hx_vals("select", kind), + cls="border rounded px-3 py-2 min-w-64", + title="Select profile", + aria_label=f"{label} profile", + ), + EditButton(kind, editing) if selected else None, + ShareButton(kind, selected, document, preview) if selected and document else None, + cls="flex flex-wrap items-center gap-2", + ) + + # One toolbar: the selections name the profile kind, the status of the last + # action follows the share button and takes the rest of the row. + content: list[Any] = [ + Div( + kind_selection, + selection, + Div(status, cls="min-w-0 flex-1") if status is not None else None, + cls="flex flex-wrap items-center gap-2", + ), + load_error, + ] + if selected is None: + content.append(P(f"No {label.lower()} profiles available.")) + else: + summary = InverterSummary if kind == "inverter" else ProfileSummary + content.append(summary(document) if document else None) + if editing: + content.append(ProfileEditor(kind, selected, entry, document_text)) + if document: + efficiency_open = section_open(data, SECTION_EFFICIENCY) + if kind == "inverter": + content.append(InverterEfficiencyPlot(document, dark, open=efficiency_open)) + content.append( + DeratingPlot(document, dark, open=section_open(data, SECTION_DERATING)) + ) + else: + content.append(LimitMaps(document, dark, open=section_open(data, SECTION_LIMITS))) + content.append(EfficiencyPlot(document, dark, open=efficiency_open)) + content.append( + ProfileJson(selected, document, preview, open=section_open(data, SECTION_JSON)) + ) + return Div(*(c for c in content if c is not None), cls="space-y-3") diff --git a/src/akkudoktoreos/server/eosdash.py b/src/akkudoktoreos/server/eosdash.py index 7c30ee8e..5dc363ed 100644 --- a/src/akkudoktoreos/server/eosdash.py +++ b/src/akkudoktoreos/server/eosdash.py @@ -32,6 +32,7 @@ from akkudoktoreos.server.dash.context import ( from akkudoktoreos.server.dash.footer import Footer from akkudoktoreos.server.dash.plan import Plan from akkudoktoreos.server.dash.prediction import Prediction +from akkudoktoreos.server.dash.profile import Profile from akkudoktoreos.server.server import ( drop_root_privileges, get_default_host, @@ -293,6 +294,7 @@ def get_eosdash(request: Request): # type: ignore "Plan": "/eosdash/plan", "Prediction": "/eosdash/prediction", "Config": "/eosdash/configuration", + "Profiles": "/eosdash/profile", "Admin": "/eosdash/admin", "About": "/eosdash/about", }, @@ -410,6 +412,34 @@ def post_eosdash_configuration(request: Request, data: dict): # type: ignore return Configuration(*eos_server(), data) +@app.get("/eosdash/profile") +def get_eosdash_profile(request: Request, data: dict): # type: ignore + """Serve the EOSdash battery profile page. + + Args: + request (Request): The incoming FastHTML request. + data (dict): Optional query data (e.g. dark mode). + + Returns: + Profile: The battery profile page component. + """ + return Profile(*eos_server(), data) + + +@app.post("/eosdash/profile") +def post_eosdash_profile(request: Request, data: dict): # type: ignore + """Run an action of the battery profile page (select, save, save as, delete). + + Args: + request (Request): The incoming FastHTML request. + data (dict): User-submitted data from the battery profile page. + + Returns: + Profile: The battery profile page component. + """ + return Profile(*eos_server(), data) + + @app.get("/eosdash/plan") def get_eosdash_plan(request: Request, data: dict): # type: ignore """Serve the EOSdash Plan page. diff --git a/tests/test_eosdashcomponents.py b/tests/test_eosdashcomponents.py new file mode 100644 index 00000000..5bc51c1a --- /dev/null +++ b/tests/test_eosdashcomponents.py @@ -0,0 +1,43 @@ +"""Tests for the shared EOSdash components.""" + +import pytest +from fasthtml.common import to_xml + +from akkudoktoreos.server.dash.components import ( + STATUS_TONES, + Error, + Info, + Success, + UpdateError, +) + + +class TestStatusMessages: + """Status messages use the colors of the EOSdash theme, readable in light and dark mode.""" + + @pytest.mark.parametrize( + ("component", "tone"), + [(Success, "success"), (Info, "info"), (Error, "error")], + ) + def test_message_uses_the_theme_colors(self, component, tone): + html = to_xml(component("Saved battery profile 'x'.")) + box_cls, icon_cls = STATUS_TONES[tone] + assert 'role="alert"' in html + assert "text-foreground" in html + assert box_cls in html + assert icon_cls in html + assert "Saved battery profile" in html + + @pytest.mark.parametrize("component", [Success, Info, Error, UpdateError]) + def test_no_daisy_alert_colors(self, component): + """daisyUI's alert colors come from its own theme: dark text on green or gray.""" + html = to_xml(component("message")) + assert "alert-success" not in html + assert "alert-info" not in html + assert "alert-error" not in html + + def test_update_error_keeps_the_details(self): + html = to_xml(UpdateError("first line\nsecond line")) + assert " None: + self.client = client + self.calls: list[tuple[str, str]] = [] + + def _send(self, method: str, url: str, **kwargs: Any) -> Any: + path = urlsplit(url).path + self.calls.append((method, path)) + kwargs.pop("timeout", None) + return getattr(self.client, method)(path, **kwargs) + + def get(self, url: str, **kwargs: Any) -> Any: + return self._send("get", url, **kwargs) + + def put(self, url: str, **kwargs: Any) -> Any: + return self._send("put", url, **kwargs) + + def delete(self, url: str, **kwargs: Any) -> Any: + return self._send("delete", url, **kwargs) + + +@pytest.fixture +def rest(config_eos, monkeypatch) -> Iterator[RestShim]: + ProfileStore.reset_instance() + app = FastAPI() + register_problem_handlers(app) + app.include_router(router) + with TestClient(app) as client: + shim = RestShim(client) + monkeypatch.setattr(page, "requests", shim) + yield shim + ProfileStore.reset_instance() + + +def render(data: dict | None = None) -> str: + return to_xml(page.Profile("localhost", 8503, data)) + + +def render_editing(profile_name: str = "generic-lfp", **data: Any) -> str: + """Render the page in edit mode (after the Edit button).""" + return render({"action": "edit", "profile_name": profile_name, **data}) + + +def generic_lfp() -> dict[str, Any]: + path = get_profile_store().package_dir(BatteryProfileSettings) / "generic-lfp.json" + return json.loads(path.read_text(encoding="utf-8")) + + +def user_profile(name: str) -> Any: + return get_profile_store().load(BatteryProfileSettings, name) + + +class TestView: + def test_default_profile_is_shown(self, rest): + html = render() + assert "Battery profiles" in html + assert "generic-lfp (packaged)" in html + assert "Generic LFP home battery" in html # summary + assert "Charge limit [C-rate]" in html and "Discharge limit [C-rate]" in html + # The editor is shown only in edit mode. + assert 'name="document"' not in html + assert ">Edit<" in html + assert "read only" in render_editing() # packaged hint of the editor + + def test_packaged_profile_has_no_delete_button(self, rest): + assert ">Delete<" not in render() + assert "Revert to packaged" not in render() + + def test_select_shows_another_profile(self, rest): + get_profile_store().save( + "my-battery", + BatteryProfileSettings.model_validate({**generic_lfp(), "battery_model": "Mine"}), + ) + html = render({"action": "select", "profile_name": "my-battery"}) + assert "my-battery (user)" in html + assert "Mine" in html + assert ">Delete<" in render_editing("my-battery") + + def test_unreachable_eos_is_reported(self, rest, monkeypatch): + def fail(url: str, **kwargs: Any) -> Any: + raise ConnectionError("refused") + + monkeypatch.setattr(rest, "get", fail) + html = render() + assert "Can not retrieve battery profiles" in html and "refused" in html + # Same toolbar layout as the normal page: the kind selection stays usable. + assert 'aria-label="Profile kind"' in html + assert html.index('name="kind"') < html.index("Can not retrieve battery profiles") + + +class TestSave: + def test_save_of_a_packaged_profile_creates_a_user_override(self, rest): + document = {**generic_lfp(), "nominal_capacity_wh": 10240.0} + html = render( + {"action": "save", "profile_name": "generic-lfp", "document": json.dumps(document)} + ) + assert "Saved battery profile 'generic-lfp'" in html or "Saved battery profile 'generic-lfp'" in html + assert user_profile("generic-lfp").nominal_capacity_wh == 10240.0 + assert "generic-lfp (user, overrides packaged)" in html + assert "Revert to packaged" in html + + def test_save_as_creates_a_new_profile_and_selects_it(self, rest): + html = render( + { + "action": "save_as", + "profile_name": "generic-lfp", + "new_name": "copy", + "document": json.dumps({**generic_lfp(), "battery_model": "Copy"}), + } + ) + assert user_profile("copy").battery_model == "Copy" + assert "copy (user)" in html + assert "Edit 'copy'" in html or "Edit 'copy'" in html + + def test_save_as_without_a_name_is_refused(self, rest): + html = render( + {"action": "save_as", "profile_name": "generic-lfp", "document": "{}", "new_name": " "} + ) + assert "Enter a name for the new profile." in html + assert ("put", "/v1/profile/battery/") not in rest.calls + + @pytest.mark.parametrize( + ("new_name", "message"), + [("vendor/model", "Invalid profile name"), ("C:evil", "Invalid profile name"), + ("schema", "is reserved")], + ) + def test_save_as_with_an_invalid_name_is_reported_without_request(self, rest, new_name, message): + """The name rule of the store is checked before EOS is asked to save.""" + html = render( + { + "action": "save_as", + "profile_name": "generic-lfp", + "document": json.dumps(generic_lfp()), + "new_name": new_name, + } + ) + assert message in html + assert not any(method == "put" for method, _ in rest.calls) + + def test_invalid_json_keeps_the_edited_text(self, rest): + html = render({"action": "save", "profile_name": "generic-lfp", "document": '{"broken": '}) + assert "not valid JSON" in html + assert "{"broken": " in html or '{"broken": ' in html + assert not any(method == "put" for method, _ in rest.calls) + + def test_invalid_document_is_refused_by_eos_and_kept(self, rest): + document = {**generic_lfp(), "nominal_capacity_wh": -5} + html = render( + {"action": "save", "profile_name": "generic-lfp", "document": json.dumps(document)} + ) + assert "not saved" in html and "422" in html and "nominal_capacity_wh" in html + assert "-5" in html # the edited text is still in the editor + assert get_profile_store().path(BatteryProfileSettings, "generic-lfp")[0].parent == ( + get_profile_store().package_dir(BatteryProfileSettings) + ) + + + +class TestJsonView: + """The profile as highlighted JSON, which EOSdash gives a copy button.""" + + @staticmethod + def json_block(html: str) -> str: + """Return the content of the highlighted JSON code block.""" + start = html.index('') + len('') + return html[start : html.index("", start)] + + def test_stored_profile_is_shown_as_json(self, rest): + html = render() + assert "Stored profile 'generic-lfp' as JSON" in html or ( + "Stored profile 'generic-lfp' as JSON" in html + ) + block = self.json_block(html) + assert "battery_model" in block + assert generic_lfp()["battery_model"] in block + + def test_preview_shows_the_edited_document_as_json(self, rest): + document = {**generic_lfp(), "battery_model": "Edited Model"} + html = render( + {"action": "preview", "profile_name": "generic-lfp", "document": json.dumps(document)} + ) + assert "as JSON (not saved)" in html + assert "Edited Model" in self.json_block(html) + + +class TestShareOnGitHub: + """Share a profile as a GitHub issue with the profile form of its kind.""" + + # (kind, packaged profile name) + KINDS = [("battery", "generic-lfp"), ("inverter", "generic-hybrid")] + + @staticmethod + def document(kind: str) -> dict[str, Any]: + return generic_lfp() if kind == "battery" else generic_hybrid() + + @staticmethod + def query(url: str) -> dict[str, str]: + from urllib.parse import parse_qs, urlsplit + + parts = urlsplit(url) + assert f"{parts.scheme}://{parts.netloc}{parts.path}" == page.SHARE_ISSUE_URL + return {key: values[0] for key, values in parse_qs(parts.query).items()} + + @pytest.mark.parametrize(("kind", "name"), KINDS) + def test_url_fills_in_the_issue_form(self, kind, name): + document = self.document(kind) + url, includes_document = page.share_issue_url(kind, name, document) + assert includes_document + assert len(url) <= page.SHARE_URL_MAX_LENGTH + query = self.query(url) + model = document[f"{kind}_model"] + assert query["template"] == f"{kind}-profile.yml" + assert query["profile_name"] == name + assert query[f"{kind}_model"] == model + assert query["title"] == f"[PROFILE]: {model}" + assert json.loads(query["profile"]) == document + + @pytest.mark.parametrize(("kind", "name"), KINDS) + def test_url_fields_match_the_issue_form(self, kind, name): + """The URL parameters are ids of fields in the issue form of the kind.""" + import yaml + + template = page.SHARE_ISSUE_TEMPLATE.format(kind=kind) + form_path = Path(__file__).parent.parent / ".github" / "ISSUE_TEMPLATE" / template + form = yaml.safe_load(form_path.read_text(encoding="utf-8")) + field_ids = {item.get("id") for item in form["body"]} + query = self.query(page.share_issue_url(kind, name, self.document(kind))[0]) + assert set(query) - {"template", "title"} <= field_ids + + def test_large_profile_is_left_out(self, monkeypatch): + monkeypatch.setattr(page, "SHARE_URL_MAX_LENGTH", 600) + url, includes_document = page.share_issue_url("battery", "generic-lfp", generic_lfp()) + assert not includes_document + assert "profile" not in self.query(url) + assert len(url) <= 600 + + def test_compact_json_is_used_if_indented_is_too_long(self, monkeypatch): + document = generic_lfp() + indented, _ = page.share_issue_url("battery", "generic-lfp", document) + monkeypatch.setattr(page, "SHARE_URL_MAX_LENGTH", len(indented) - 1) + url, includes_document = page.share_issue_url("battery", "generic-lfp", document) + assert includes_document + assert json.loads(self.query(url)["profile"]) == document + + def test_page_links_to_the_issue(self, rest): + html = render() + link = html[html.rindex("")] + assert ">Share" in link + assert 'target="_blank"' in link + assert 'title="Opens a GitHub issue' in link + + def test_inverter_page_shares_with_the_inverter_form(self, rest): + html = render({"action": "kind", "kind": "inverter"}) + assert "inverter-profile.yml" in html + assert "battery-profile.yml" not in html + + +class TestLayout: + """Share button at the top, collapsible sections for the details.""" + + @staticmethod + def section(html: str, title: str) -> str: + """Return the opening
tag of the section with the title.""" + end = html.index(title) + start = html.rindex("", start) + 1] + + def test_share_button_is_in_the_profile_selection(self, rest): + html = render() + share = html.index(page.SHARE_ISSUE_URL) + assert html.index(">Edit<") < share + assert share < html.index("as JSON") + assert share < html.index("Charge and discharge limits") + # Styled like the other buttons of EOSdash. + assert str(ButtonT.primary) in html[html.rindex("", share)] + + def test_no_editor_without_edit_mode(self, rest): + """Without editing the editor takes no space, not even a collapsed line.""" + html = render() + assert 'name="document"' not in html + assert 'data-profile-section="editor"' not in html + + @pytest.mark.parametrize("action", ["edit", "preview", "save"]) + def test_editor_is_shown_after_edit_preview_and_save(self, rest, action): + html = render( + { + "action": action, + "profile_name": "generic-lfp", + "document": json.dumps(generic_lfp()), + "open_sections": "", + } + ) + assert 'name="document"' in html + assert ">Close editor<" in html + + def test_editor_stays_open_on_a_profile_change(self, rest): + html = render({"action": "select", "profile_name": "generic-lfp", "open_sections": "editor"}) + assert 'name="document"' in html + + @pytest.mark.parametrize("open_sections", ["json", "editor,json"]) + def test_close_ends_edit_mode(self, rest, open_sections): + """Close in the editor and Close editor in the toolbar end edit mode.""" + html = render( + {"action": "close", "profile_name": "generic-lfp", "open_sections": open_sections} + ) + assert 'name="document"' not in html + assert ">Edit<" in html + + def test_edit_button_sends_the_selected_profile(self, rest): + html = render() + button = html[html.rindex("Edit<")) :] + button = button[: button.index(">")] + assert f'hx-include="#{page.PROFILE_SELECT_ID}"' in button + assert f'id="{page.PROFILE_SELECT_ID}"' in html + + def test_editor_leaves_room_for_a_plot(self, rest): + """The text area takes 40 % of the window height, not the whole window.""" + html = render_editing() + area = html[html.rindex("")] + + def test_summary_has_lines_between_all_cells(self, rest): + """Cell borders draw lines also between wrapped rows, on the tab background.""" + html = render() + summary = html[: html.index("Charge temperature")] + grid = summary[summary.rindex('
")] + cell_tag = grid[grid.index("")] + # Full cell borders that overlap into single lines; no empty columns. + assert "auto-fit" in grid_tag + assert " border " in f"{cell_tag} " and "-mt-px" in cell_tag and "-ml-px" in cell_tag + # No own background: the tab background shows (bg-background is black in dark mode). + assert "bg-" not in grid_tag and "bg-" not in cell_tag + + def test_editor_action_bar_has_no_own_background(self, rest): + html = render_editing() + save = html.index("Save as") + bar = html[html.rindex('
")] + + def test_selections_have_accessible_names(self, rest): + html = render() + assert 'aria-label="Profile kind"' in html + assert 'aria-label="Battery profile"' in html + + def test_large_profile_shows_the_paste_hint_as_text(self, rest, monkeypatch): + """Tooltips are not shown on touch devices: the paste instruction is visible text.""" + monkeypatch.setattr(page, "SHARE_URL_MAX_LENGTH", 600) + html = render() + assert f'

The profile is too large to include' in html + + def test_saved_profiles_apply_at_once_is_explained(self, rest): + assert "saved profiles apply at once, also to running device controllers" in render_editing() + + def test_toolbar_has_no_heading_and_starts_with_the_selection(self, rest): + """The selections name the profile kind; no extra heading.""" + html = render() + assert " html.index("Efficiency") + assert html.index("as JSON") > html.index("Charge and discharge limits") + + def test_preview_keeps_closed_sections_closed(self, rest): + """The page reports the open sections with the action; a preview keeps them.""" + html = render( + { + "action": "preview", + "profile_name": "generic-lfp", + "document": json.dumps(generic_lfp()), + "open_sections": "", + } + ) + assert " open" not in self.section(html, "Charge and discharge limits") + assert " open" not in self.section(html, "Efficiency") + assert " open" not in self.section(html, "as JSON") + + def test_selection_keeps_the_open_sections(self, rest): + html = render( + {"action": "select", "profile_name": "generic-lfp", "open_sections": "json,limits"} + ) + assert " open" in self.section(html, "as JSON") + assert " open" in self.section(html, "Charge and discharge limits") + assert " open" not in self.section(html, "Efficiency") + + def test_actions_report_the_open_sections(self, rest): + html = render() + assert "open_sections" in html + assert 'data-profile-section="limits"' in html + + def test_json_section_is_closed(self, rest): + assert " open" not in self.section(render(), "as JSON") + + +class TestEfficiencyHints: + def test_missing_curve_is_reported_as_required(self, rest): + document = {**generic_lfp()} + del document["discharge_efficiency"] + html = render( + {"action": "preview", "profile_name": "generic-lfp", "document": json.dumps(document)} + ) + assert "the profile requires 'discharge_efficiency'" in html or ( + "the profile requires 'discharge_efficiency'" in html + ) + assert "battery settings applies" not in html + + +class TestPreview: + def test_preview_draws_the_edited_text_without_saving(self, rest): + document = {**generic_lfp(), "battery_model": "Edited Model", "nominal_capacity_wh": 7777.0} + html = render( + {"action": "preview", "profile_name": "generic-lfp", "document": json.dumps(document)} + ) + assert "Preview of unsaved changes" in html + assert ">Edited Model

" in html # summary of the edited document + assert ">7777.0 Wh

" in html + assert "Charge and discharge limits" in html and "Bokeh" in html + assert not any(method in ("put", "delete") for method, _ in rest.calls) + assert user_profile("generic-lfp").battery_model != "Edited Model" + assert get_profile_store().path(BatteryProfileSettings, "generic-lfp")[0].parent == ( + get_profile_store().package_dir(BatteryProfileSettings) + ) + + def test_preview_of_invalid_json_is_reported_and_kept(self, rest): + html = render( + {"action": "preview", "profile_name": "generic-lfp", "document": '{"broken": '} + ) + assert "not valid JSON" in html + assert "{"broken": " in html or '{"broken": ' in html + + @pytest.mark.parametrize("text", ["[1, 2]", "42", "null"]) + def test_preview_of_a_non_object_is_reported(self, rest, text): + html = render({"action": "preview", "profile_name": "generic-lfp", "document": text}) + assert "must be a JSON object" in html + + def test_preview_of_an_invalid_document_is_reported_and_drawn(self, rest): + document = {**generic_lfp(), "nominal_capacity_wh": -5} + html = render( + {"action": "preview", "profile_name": "generic-lfp", "document": json.dumps(document)} + ) + assert "the document is invalid" in html and "nominal_capacity_wh" in html + assert "-5" in html # the edited text is still in the editor + assert not any(method == "put" for method, _ in rest.calls) + + def test_editor_has_a_preview_button_and_explains_it(self, rest): + html = render_editing() + assert ""preview"" in html or '"preview"' in html + assert "Preview updates the summary and plots without saving" in html + + +class TestDelete: + def test_delete_of_a_user_profile(self, rest): + get_profile_store().save("my-battery", BatteryProfileSettings.model_validate(generic_lfp())) + html = render({"action": "delete", "profile_name": "my-battery"}) + assert "Deleted battery profile" in html + assert "my-battery (user)" not in html # no longer in the selection + assert "generic-lfp (packaged)" in html + + def test_delete_of_an_override_reverts_to_the_packaged_profile(self, rest): + document = {**generic_lfp(), "nominal_capacity_wh": 10240.0} + get_profile_store().save("generic-lfp", BatteryProfileSettings.model_validate(document)) + html = render({"action": "delete", "profile_name": "generic-lfp"}) + assert "Reverted" in html + assert "generic-lfp (packaged)" in html + assert get_profile_store().load(BatteryProfileSettings, "generic-lfp").nominal_capacity_wh is None + + def test_delete_of_a_packaged_profile_is_reported(self, rest): + html = render({"action": "delete", "profile_name": "generic-lfp"}) + assert "not deleted" in html and "409" in html + + +def test_page_is_served_by_eosdash(): + from akkudoktoreos.server import eosdash + + paths = {getattr(route, "path", None) for route in eosdash.app.routes} + assert "/eosdash/profile" in paths + + +# --- efficiency plot ------------------------------------------------------ + +from akkudoktoreos.devices.profile.batteryprofile import ( # noqa: E402 + EfficiencyCurve, + PrecomputedEfficiencyCurve, +) + +CURVE_POINTS = {"c_rate": [0.1, 0.25, 0.5, 1.0], "efficiency": [0.90, 0.96, 0.95, 0.91]} + + +def make_curve(**changes: Any) -> EfficiencyCurve: + return EfficiencyCurve.model_validate({**CURVE_POINTS, **changes}) + + +def drawn_value(glyphs: dict[str, Any], c_rate: float) -> float | None: + """The efficiency the drawn glyphs show at a C-rate, None where nothing is drawn.""" + xs, ys = glyphs["points"] + for x, y in zip(xs, ys): + if c_rate == x: + return y # a marker + if xs[0] < c_rate < xs[-1]: + glyph = glyphs["inside"][0] + i = max(k for k in range(len(xs)) if xs[k] < c_rate) + if glyph == "line": + return ys[i] + (ys[i + 1] - ys[i]) * (c_rate - xs[i]) / (xs[i + 1] - xs[i]) + if glyph == "step": # bokeh step(mode="center"): changes halfway between points + return ys[i] if c_rate < (xs[i] + xs[i + 1]) / 2 else ys[i + 1] + return None + for seg_x, seg_y in glyphs["outside"]: + if seg_x[0] <= c_rate <= seg_x[1]: + return seg_y[0] + return None + + +class TestEfficiencyGlyphs: + @pytest.mark.parametrize("interpolation", ["linear", "nearest", "none"]) + @pytest.mark.parametrize("out_of_range", ["clamp_to_nearest", "reject"]) + def test_drawn_curve_is_the_lookup_of_eos(self, interpolation, out_of_range): + curve = make_curve(interpolation=interpolation, out_of_range_behavior=out_of_range) + lookup = PrecomputedEfficiencyCurve.from_curve(curve).lookup + glyphs = page.efficiency_glyphs(curve, c_rate_max=1.25) + midpoints = {(a + b) / 2 for a, b in zip(curve.c_rate, curve.c_rate[1:])} + samples = [round(0.005 * k, 3) for k in range(0, 251)] + list(curve.c_rate) + for c_rate in samples: + if c_rate in midpoints and interpolation == "nearest": + continue # the step is vertical there + expected = lookup(c_rate) + actual = drawn_value(glyphs, c_rate) + if expected is None: + assert actual is None, c_rate + else: + assert actual == pytest.approx(expected), c_rate + + def test_glyph_per_interpolation(self): + for interpolation, glyph in (("linear", "line"), ("nearest", "step"), ("none", None)): + curve = make_curve(interpolation=interpolation) + assert page.efficiency_glyphs(curve, 1.25)["inside"][0] == glyph + + def test_clamped_curve_is_extended_to_both_ends(self): + curve = make_curve(out_of_range_behavior="clamp_to_nearest") + assert page.efficiency_glyphs(curve, 1.25)["outside"] == [ + ([0.0, 0.1], [0.90, 0.90]), + ([1.0, 1.25], [0.91, 0.91]), + ] + + def test_rejected_curve_is_not_extended(self): + curve = make_curve(out_of_range_behavior="reject") + assert page.efficiency_glyphs(curve, 1.25)["outside"] == [] + + +class TestEfficiencyPlot: + def test_page_shows_the_efficiency_plot(self, rest): + html = render() + assert "Battery efficiency versus C-rate" in html + assert "Charge (linear, out of range clamped)" in html + assert "Discharge (linear, out of range clamped)" in html + + def test_missing_curve_is_noted(self): + document = {**generic_lfp(), "discharge_efficiency": None} + html = to_xml(page.EfficiencyPlot(document)) + assert "No discharge efficiency curve" in html + assert "Charge (linear" in html and "Discharge (linear" not in html + + def test_without_curves_there_is_no_plot(self): + document = {**generic_lfp(), "charge_efficiency": None, "discharge_efficiency": None} + html = to_xml(page.EfficiencyPlot(document)) + assert "Battery efficiency versus C-rate" not in html + assert "No charge efficiency curve" in html + + def test_invalid_curve_while_editing_is_reported(self): + document = {**generic_lfp(), "charge_efficiency": {"c_rate": [0.5, 0.1], "efficiency": [1, 1]}} + html = to_xml(page.EfficiencyPlot(document)) + assert "Invalid charge efficiency curve" in html + assert "Discharge (linear" in html + + def test_power_tooltip_with_a_capacity(self): + document = {**generic_lfp(), "nominal_capacity_wh": 10240.0} + assert "@power_w" in to_xml(page.EfficiencyPlot(document)) + assert "@power_w" not in to_xml(page.EfficiencyPlot(generic_lfp())) + + def test_dark_theme(self, rest): + assert "#1e1e1e" in render({"dark": "true"}) + assert "#1e1e1e" not in render({"dark": "false"}) + + +# --- limit map heat maps -------------------------------------------------- + + +def heatmap_value(image: dict[str, Any], temperature: float, soc: float) -> float: + """The value the heat map shows at an operating point (cell under the point).""" + grid = image["image"] + n_temp, n_soc = grid.shape + row = int((temperature - image["y"]) / image["dh"] * n_temp) + col = int((soc - image["x"]) / image["dw"] * n_soc) + return float(grid[row, col]) + + +class TestLimitHeatMaps: + @pytest.mark.parametrize("attribute, lookup", [("charge_map", "max_charge_crate"), ("discharge_map", "max_discharge_crate")]) + @pytest.mark.parametrize("interpolation", ["bilinear", "nearest"]) + def test_heatmap_shows_the_lookup_of_eos(self, attribute, lookup, interpolation): + profile = BatteryProfileSettings.model_validate( + {**generic_lfp(), "interpolation": interpolation} + ).to_limits() + image = page.limit_heatmap_image(profile, attribute) + import math + + # Not exactly on a cell edge (half a step from a grid point): EOS rounds + # half to even there, the drawing shows the edge. + for temperature in [t / 4 + 0.01 for t in range(-40, 240, 3)]: + for soc in [s / 2 + 0.01 for s in range(0, 200, 7)]: + expected = getattr(profile, lookup)(temperature, soc) + shown = heatmap_value(image, temperature, soc) + if expected is None: + assert math.isnan(shown), (temperature, soc) + else: + assert shown == pytest.approx(expected), (temperature, soc) + + def test_page_shows_both_heat_maps_and_the_breakpoint_table(self, rest): + html = render() + assert "Charge limit [C-rate] (as evaluated by EOS)" in html + assert "Discharge limit [C-rate] (as evaluated by EOS)" in html + assert "Breakpoint values" in html and "degC \\ SoC %" in html + + def test_document_that_does_not_compile_is_reported(self): + document = {**generic_lfp(), "charge_limit_crate": {"values": [[0.5]]}} + html = to_xml(page.LimitMaps(document)) + assert "The limit maps can not be compiled" in html + assert "(as evaluated by EOS)" not in html + + +class TestDarkMode: + @staticmethod + def color_bars(dark: bool, monkeypatch: pytest.MonkeyPatch) -> list[Any]: + """The color bars added to a heat map plot.""" + import bokeh.plotting + from bokeh.models.annotations import ColorBar + + added: list[Any] = [] + original = bokeh.plotting.figure.add_layout + + def capture(self: Any, obj: Any, place: Any = "center") -> None: + added.append(obj) + original(self, obj, place) + + monkeypatch.setattr(bokeh.plotting.figure, "add_layout", capture) + profile = BatteryProfileSettings.model_validate(generic_lfp()).to_limits() + page.LimitHeatMap( + profile, generic_lfp(), "charge_map", "charge_limit_crate", "Charge", 1.0, dark + ) + return [obj for obj in added if isinstance(obj, ColorBar)] + + def test_color_bar_is_dark_in_dark_mode(self, monkeypatch): + (bar,) = self.color_bars(True, monkeypatch) + assert bar.background_fill_color == page.DARK_BACKGROUND + assert bar.major_label_text_color == "white" + assert bar.title_text_color == "white" + + def test_color_bar_keeps_the_light_default_in_light_mode(self, monkeypatch): + (bar,) = self.color_bars(False, monkeypatch) + assert bar.background_fill_color == "#ffffff" + + +# --------------------------------------------------------------------------- +# Inverter profiles +# --------------------------------------------------------------------------- + + +def generic_hybrid() -> dict[str, Any]: + path = get_profile_store().package_dir(InverterProfileSettings) / "generic-hybrid.json" + return json.loads(path.read_text(encoding="utf-8")) + + +class TestInverterProfiles: + def test_kind_selector_on_the_battery_page(self, rest): + html = render() + assert 'name="kind"' in html + assert "Inverter profiles" in html + assert "Battery profiles" in html + + def test_inverter_kind_shows_the_generic_profile(self, rest): + html = render({"action": "kind", "kind": "inverter"}) + assert "Inverter profiles" in html + assert "generic-hybrid (packaged)" in html + assert "Generic hybrid inverter" in html + assert ("get", "/v1/profile/inverter") in rest.calls + # Inverter views, no battery limit maps. + assert "Inverter efficiency versus load fraction" in html + assert "Allowed power versus ambient temperature" in html + assert "Charge and discharge limits" not in html + + def test_summary_shows_the_power_at_45_degc(self, rest): + html = render({"action": "kind", "kind": "inverter"}) + assert "Power at 45 degC" in html + assert "100 %" in html + + def test_unknown_kind_falls_back_to_battery(self, rest): + html = render({"kind": "heatpump"}) + assert "Battery profiles" in html + assert "generic-lfp (packaged)" in html + + def test_save_as_stores_an_inverter_profile(self, rest): + document = generic_hybrid() + document["nominal_power_w"] = 8000.0 + html = render( + { + "action": "save_as", + "kind": "inverter", + "profile_name": "generic-hybrid", + "new_name": "my-inverter", + "document": json.dumps(document), + } + ) + assert "Saved inverter profile" in html + assert ("put", "/v1/profile/inverter/my-inverter") in rest.calls + stored = get_profile_store().load(InverterProfileSettings, "my-inverter") + assert stored.nominal_power_w == 8000.0 + + def test_preview_validates_as_inverter_profile(self, rest): + document = generic_hybrid() + document["dc_to_ac_efficiency"]["efficiency"][0] = 1.5 + html = render( + { + "action": "preview", + "kind": "inverter", + "profile_name": "generic-hybrid", + "document": json.dumps(document), + } + ) + assert "the document is invalid" in html + assert ("put", "/v1/profile/inverter/generic-hybrid") not in rest.calls + + def test_actions_carry_the_kind(self, rest): + html = render({"action": "kind", "kind": "inverter"}) + assert ""kind": "inverter"" in html or '"kind": "inverter"' in html + + def test_without_derating_the_full_power_is_noted(self): + document = generic_hybrid() + del document["power_derating"] + html = to_xml(page.DeratingPlot(document)) + assert "No power derating" in html + + def test_invalid_curves_while_editing_are_reported(self): + document = generic_hybrid() + document["ac_to_dc_efficiency"] = {"load_fraction": [0.5, 0.1], "efficiency": [0.9, 0.9]} + document["power_derating"] = {"temperature_degC": [60.0, 40.0], "power_fraction": [1, 1]} + assert "Invalid AC→DC (grid charge) efficiency curve" in to_xml( + page.InverterEfficiencyPlot(document) + ) + assert "Invalid power derating" in to_xml(page.DeratingPlot(document)) + + +class TestInverterLayout: + """The inverter profiles use the same collapsible sections as the battery profiles.""" + + @staticmethod + def section(html: str, title: str) -> str: + end = html.index(title) + start = html.rindex("", start) + 1] + + def test_sections_are_collapsible_with_ids(self, rest): + html = render({"action": "kind", "kind": "inverter"}) + for section_id in ("efficiency", "derating", "json"): + assert f'data-profile-section="{section_id}"' in html + editing = render( + {"action": "edit", "kind": "inverter", "profile_name": "generic-hybrid"} + ) + assert 'data-profile-section="editor"' in editing + assert html.index("as JSON") > html.index("Power derating") + assert "opacity-70" not in html + + def test_closed_sections_stay_closed(self, rest): + html = render( + { + "action": "select", + "kind": "inverter", + "profile_name": "generic-hybrid", + "open_sections": "json", + } + ) + assert " open" not in self.section(html, "Efficiency") + assert " open" not in self.section(html, "Power derating") + assert " open" in self.section(html, "as JSON")