mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-11-13 09:13:30 +01:00
feat(eosdash): add the profile page for battery and inverter profiles
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 <noreply@anthropic.com> Assisted-by: Claude Code (claude-opus-5-5)
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
faf37abfe7
commit
3bcc902104
@@ -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
|
||||||
@@ -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
|
||||||
@@ -3,13 +3,10 @@ import re
|
|||||||
from typing import Any, Callable, Optional, Union
|
from typing import Any, Callable, Optional, Union
|
||||||
|
|
||||||
from fasthtml.common import H1, Button, Div, Li, Select
|
from fasthtml.common import H1, Button, Div, Li, Select
|
||||||
from monsterui.daisy import (
|
|
||||||
Alert,
|
|
||||||
AlertT,
|
|
||||||
)
|
|
||||||
from monsterui.foundations import stringify
|
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
|
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,
|
H3,
|
||||||
|
H4,
|
||||||
ButtonT,
|
ButtonT,
|
||||||
Card,
|
Card,
|
||||||
Code,
|
Code,
|
||||||
@@ -150,24 +147,48 @@ def TextView(*c: Any, cls: Optional[Union[str, tuple]] = None, **kwargs: Any) ->
|
|||||||
return Pre(*c, **kwargs)
|
return Pre(*c, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def Success(*c: Any) -> Alert:
|
# Colors of the status messages from the theme of EOSdash, readable in light and
|
||||||
return Alert(
|
# 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(
|
DivLAligned(
|
||||||
UkIcon("check"),
|
UkIcon(icon, cls=icon_cls),
|
||||||
TextView(*c),
|
*c,
|
||||||
),
|
),
|
||||||
cls=AlertT.success,
|
role="alert",
|
||||||
|
cls=f"rounded-lg border p-3 text-foreground {box_cls}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def Error(*c: Any) -> Alert:
|
def Success(*c: Any) -> Div:
|
||||||
return Alert(
|
return StatusMessage("success", "check", TextView(*c))
|
||||||
DivLAligned(
|
|
||||||
UkIcon("triangle-alert"),
|
|
||||||
TextView(*c),
|
def Info(*c: Any) -> Div:
|
||||||
),
|
return StatusMessage("info", "info", TextView(*c))
|
||||||
cls=AlertT.error,
|
|
||||||
)
|
|
||||||
|
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:
|
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)
|
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.
|
"""Renders a compact error with collapsible full detail.
|
||||||
|
|
||||||
Extracts the short pydantic validation message (text after
|
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.
|
error_text: The full error string from a config update failure.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Alert: A collapsible error element with error styling.
|
Div: A collapsible error element with error styling.
|
||||||
"""
|
"""
|
||||||
short = None
|
short = None
|
||||||
match = re.search(r"validation error for [^\n]+\n([^\n]+)\n\s+([^\[]+)", error_text)
|
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:
|
if not short:
|
||||||
short = error_text.splitlines()[0].strip()
|
short = error_text.splitlines()[0].strip()
|
||||||
|
|
||||||
return Alert(
|
box_cls, icon_cls = STATUS_TONES["error"]
|
||||||
|
return Div(
|
||||||
Details(
|
Details(
|
||||||
Summary(
|
Summary(
|
||||||
DivLAligned(
|
DivLAligned(
|
||||||
UkIcon("triangle-alert"),
|
UkIcon("triangle-alert", cls=icon_cls),
|
||||||
P(short, cls="text-sm ml-2"),
|
P(short, cls="text-sm ml-2"),
|
||||||
),
|
),
|
||||||
cls="list-none cursor-pointer",
|
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="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:
|
def ConfigSection(
|
||||||
"""Collapsible section for grouping configuration entries."""
|
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(
|
return Details(
|
||||||
Summary(
|
Summary(
|
||||||
Div(
|
Div(
|
||||||
UkIcon("chevron-right", cls="transition-transform group-open:rotate-90"),
|
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="flex items-center gap-2 cursor-pointer",
|
||||||
),
|
),
|
||||||
cls="list-none",
|
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,
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,7 @@ from akkudoktoreos.server.dash.context import (
|
|||||||
from akkudoktoreos.server.dash.footer import Footer
|
from akkudoktoreos.server.dash.footer import Footer
|
||||||
from akkudoktoreos.server.dash.plan import Plan
|
from akkudoktoreos.server.dash.plan import Plan
|
||||||
from akkudoktoreos.server.dash.prediction import Prediction
|
from akkudoktoreos.server.dash.prediction import Prediction
|
||||||
|
from akkudoktoreos.server.dash.profile import Profile
|
||||||
from akkudoktoreos.server.server import (
|
from akkudoktoreos.server.server import (
|
||||||
drop_root_privileges,
|
drop_root_privileges,
|
||||||
get_default_host,
|
get_default_host,
|
||||||
@@ -293,6 +294,7 @@ def get_eosdash(request: Request): # type: ignore
|
|||||||
"Plan": "/eosdash/plan",
|
"Plan": "/eosdash/plan",
|
||||||
"Prediction": "/eosdash/prediction",
|
"Prediction": "/eosdash/prediction",
|
||||||
"Config": "/eosdash/configuration",
|
"Config": "/eosdash/configuration",
|
||||||
|
"Profiles": "/eosdash/profile",
|
||||||
"Admin": "/eosdash/admin",
|
"Admin": "/eosdash/admin",
|
||||||
"About": "/eosdash/about",
|
"About": "/eosdash/about",
|
||||||
},
|
},
|
||||||
@@ -410,6 +412,34 @@ def post_eosdash_configuration(request: Request, data: dict): # type: ignore
|
|||||||
return Configuration(*eos_server(), data)
|
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")
|
@app.get("/eosdash/plan")
|
||||||
def get_eosdash_plan(request: Request, data: dict): # type: ignore
|
def get_eosdash_plan(request: Request, data: dict): # type: ignore
|
||||||
"""Serve the EOSdash Plan page.
|
"""Serve the EOSdash Plan page.
|
||||||
|
|||||||
@@ -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 "<details" in html
|
||||||
|
assert "first line" in html
|
||||||
|
assert STATUS_TONES["error"][0] in html
|
||||||
@@ -0,0 +1,837 @@
|
|||||||
|
"""Tests for the EOSdash battery profile page.
|
||||||
|
|
||||||
|
The page talks to EOS by REST. Its requests are sent to the real profile REST
|
||||||
|
router (on a fresh profile store), so the tests cover page, API and store.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterator
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from fasthtml.common import to_xml
|
||||||
|
from monsterui.franken import ButtonT
|
||||||
|
|
||||||
|
from akkudoktoreos.devices.profile.batteryprofile import BatteryProfileSettings
|
||||||
|
from akkudoktoreos.devices.profile.inverterprofile import InverterProfileSettings
|
||||||
|
from akkudoktoreos.devices.profile.profilestore import ProfileStore, get_profile_store
|
||||||
|
from akkudoktoreos.server.dash import profile as page
|
||||||
|
from akkudoktoreos.server.rest.error import register_problem_handlers
|
||||||
|
from akkudoktoreos.server.rest.profile import router
|
||||||
|
|
||||||
|
|
||||||
|
class RestShim:
|
||||||
|
"""Stands in for the ``requests`` module of the page, forwarding to a TestClient."""
|
||||||
|
|
||||||
|
def __init__(self, client: TestClient) -> 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('<code class="language-json">') + len('<code class="language-json">')
|
||||||
|
return html[start : html.index("</code>", 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("<a ", 0, html.index(page.SHARE_ISSUE_URL)) :]
|
||||||
|
link = link[: link.index("</a>")]
|
||||||
|
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 <details> tag of the section with the title."""
|
||||||
|
end = html.index(title)
|
||||||
|
start = html.rindex("<details", 0, end)
|
||||||
|
return html[start : html.index(">", 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("<a ", 0, share) : html.index(">", 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("<button", 0, html.index(">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("<textarea", 0, html.index('name="document"')) :]
|
||||||
|
assert "height: 40vh" in area[: area.index(">")]
|
||||||
|
|
||||||
|
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('<div class="grid') :]
|
||||||
|
grid_tag = grid[: grid.index(">")]
|
||||||
|
cell_tag = grid[grid.index("<div", 1) :]
|
||||||
|
cell_tag = cell_tag[: cell_tag.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('<div class="flex flex-wrap items-center gap-2"', 0, save) :]
|
||||||
|
assert "bg-" not in bar[: bar.index(">")]
|
||||||
|
|
||||||
|
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'<p class="{page.HINT_CLS}">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 "<h3" not in html
|
||||||
|
assert html.index('name="kind"') < html.index('name="profile_name"')
|
||||||
|
assert html.index('name="profile_name"') < html.index(page.SHARE_ISSUE_URL)
|
||||||
|
|
||||||
|
def test_status_follows_the_share_button(self, rest):
|
||||||
|
html = render(
|
||||||
|
{
|
||||||
|
"action": "preview",
|
||||||
|
"profile_name": "generic-lfp",
|
||||||
|
"document": json.dumps(generic_lfp()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
share = html.index(page.SHARE_ISSUE_URL)
|
||||||
|
status = html.index("Preview of unsaved changes")
|
||||||
|
assert share < status < html.index("Edit ")
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("title", ["Charge and discharge limits", "Efficiency"])
|
||||||
|
def test_plot_sections_are_open(self, rest, title):
|
||||||
|
assert " open" in self.section(render(), title)
|
||||||
|
|
||||||
|
def test_hints_use_the_muted_text_color_of_the_theme(self, rest):
|
||||||
|
"""Readable in light and dark mode: no opacity on an inherited text color."""
|
||||||
|
html = render_editing()
|
||||||
|
assert "opacity-70" not in html
|
||||||
|
hint = html.index("Preview updates the summary and plots without saving")
|
||||||
|
assert "text-muted-foreground" in html[html.rindex("<p ", 0, hint) : hint]
|
||||||
|
|
||||||
|
def test_json_section_is_last(self, rest):
|
||||||
|
html = render()
|
||||||
|
assert html.index("as JSON") > 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</p>" in html # summary of the edited document
|
||||||
|
assert ">7777.0 Wh</p>" 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("<details", 0, end)
|
||||||
|
return html[start : html.index(">", 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")
|
||||||
Reference in New Issue
Block a user