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