mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-11-09 23:33:20 +01:00
feat(eosdash): plan page with EMS plan timeline and persisted settings (#1460)
* feat(eosdash): plan page with EMS plan timeline and persisted settings Fixes: - SolutionCard no longer appends to the module-level solution_excludes list on every render (the list grew without limit). - All EOSdash pages: keep the scroll position after htmx actions instead of jumping to the bottom; Bokeh() reserves the plot height. - Default visible PV column pvforecast_power_w -> pvforecast_ac_energy_wh (the old column no longer exists). - All EOSdash pages: remove w-screen from components.Page (horizontal scrollbar). - Solution chart: energy is the main axis; the always empty power axis is only shown with power columns. Typo "Electricty" fixed. Features: - EMS plan timeline per device, plan table as collapsible section. - Solution chart rework: KPI row, legend, totals view, value axes for the displayed columns only, units menu, currency from the timezone, aligned time axes with the timeline, themed tooltips, system font. - Per-column and per-command settings: visibility, alias, color, style; device aliases. - New config section eosdash.plan (server/dash/dashsettings.py) stores the plan page settings in the EOS configuration; docs regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: SchaafAlexander <86607129+SchaafAlexander@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7554e2dc47
commit
527c26d424
@@ -9,6 +9,7 @@
|
||||
../_generated/configelecfee.md
|
||||
../_generated/configelecprice.md
|
||||
../_generated/configems.md
|
||||
../_generated/configeosdash.md
|
||||
../_generated/configfeedintariff.md
|
||||
../_generated/configgeneral.md
|
||||
../_generated/configload.md
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
## EOS-Dash Configuration
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
:::{table} eosdash
|
||||
:widths: 10 20 10 5 5 30
|
||||
:align: left
|
||||
|
||||
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||
| plan | `EOS_EOSDASH__PLAN` | `EOSdashPlanSettings` | `rw` | `required` | Plan page settings. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
<!-- pyml disable no-emphasis-as-heading -->
|
||||
**Example Input/Output**
|
||||
<!-- pyml enable no-emphasis-as-heading -->
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
```json
|
||||
{
|
||||
"eosdash": {
|
||||
"plan": {
|
||||
"visible": {},
|
||||
"alias": {},
|
||||
"color": {},
|
||||
"style": {},
|
||||
"width": {},
|
||||
"line_dash": {},
|
||||
"device_alias": {},
|
||||
"plan_table_open": false,
|
||||
"energy_kwh": true,
|
||||
"factor_percent": true,
|
||||
"price_minor_unit": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
### EOSdash plan page settings
|
||||
|
||||
Keys are the columns of the optimization solution chart (e.g. ``battery1_soc_factor``)
|
||||
or EMS plan commands (operation modes) with the prefix ``mode_``
|
||||
(e.g. ``mode_SELF_CONSUMPTION``). A command applies to the plan timeline and to the
|
||||
operation mode factor columns of all devices. Only choices of the user are stored,
|
||||
everything else uses the defaults of EOSdash.
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
:::{table} eosdash::plan
|
||||
:widths: 10 10 5 5 30
|
||||
:align: left
|
||||
|
||||
| Name | Type | Read-Only | Default | Description |
|
||||
| ---- | ---- | --------- | ------- | ----------- |
|
||||
| alias | `dict[str, Annotated[str, StringConstraints(strip_whitespace=True, to_upper=None, to_lower=None, strict=None, min_length=1, max_length=32, pattern=None, ascii_only=None)]]` | `rw` | `required` | Display names of solution chart columns and commands. |
|
||||
| color | `dict[str, Annotated[str, StringConstraints(strip_whitespace=None, to_upper=None, to_lower=None, strict=None, min_length=None, max_length=None, pattern='^#[0-9A-Fa-f]{6}$', ascii_only=None)]]` | `rw` | `required` | Colors of solution chart columns and commands (#RRGGBB). |
|
||||
| device_alias | `dict[str, Annotated[str, StringConstraints(strip_whitespace=True, to_upper=None, to_lower=None, strict=None, min_length=1, max_length=32, pattern=None, ascii_only=None)]]` | `rw` | `required` | Display names of devices (device id -> name) on the plan page. |
|
||||
| energy_kwh | `bool` | `rw` | `True` | Energy in kWh instead of Wh in the solution chart. Defaults to True. |
|
||||
| factor_percent | `bool` | `rw` | `True` | Factors in % instead of 0.0..1.0 in the solution chart. Defaults to True. |
|
||||
| line_dash | `dict[str, Literal['solid', 'dashed', 'dotted']]` | `rw` | `required` | Line dash of solution chart columns and commands. |
|
||||
| plan_table_open | `bool` | `rw` | `False` | Energy management plan table open on page load. Defaults to False. |
|
||||
| price_minor_unit | `bool` | `rw` | `True` | Electricity price in the minor unit of the currency per kWh (e.g. ct/kWh for EUR, Rp./kWh for CHF) instead of currency/kWh in the solution chart. Has no effect for currencies without a minor unit. Defaults to True. |
|
||||
| style | `dict[str, Literal['line', 'bar']]` | `rw` | `required` | Chart type of solution chart columns and commands. |
|
||||
| visible | `dict[str, bool]` | `rw` | `required` | Visibility of solution chart columns and commands. |
|
||||
| width | `dict[str, Annotated[int, FieldInfo(annotation=NoneType, required=True, metadata=[Ge(ge=1), Le(le=5)])]]` | `rw` | `required` | Line width (1..5) of solution chart columns and commands. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
<!-- pyml disable no-emphasis-as-heading -->
|
||||
**Example Input/Output**
|
||||
<!-- pyml enable no-emphasis-as-heading -->
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
```json
|
||||
{
|
||||
"eosdash": {
|
||||
"plan": {
|
||||
"visible": {
|
||||
"grid_feedin_energy_wh": false
|
||||
},
|
||||
"alias": {
|
||||
"mode_SELF_CONSUMPTION": "Self consumption"
|
||||
},
|
||||
"color": {
|
||||
"battery1_soc_factor": "#DAA520"
|
||||
},
|
||||
"style": {
|
||||
"pvforecast_ac_energy_wh": "bar"
|
||||
},
|
||||
"width": {
|
||||
"loadforecast_energy_wh": 3
|
||||
},
|
||||
"line_dash": {
|
||||
"elec_price_amt_kwh": "dotted"
|
||||
},
|
||||
"device_alias": {
|
||||
"battery1": "House battery"
|
||||
},
|
||||
"plan_table_open": false,
|
||||
"energy_kwh": true,
|
||||
"factor_percent": true,
|
||||
"price_minor_unit": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
<!-- pyml enable line-length -->
|
||||
@@ -183,6 +183,21 @@
|
||||
"mode": "OPTIMIZATION",
|
||||
"notify_url": null
|
||||
},
|
||||
"eosdash": {
|
||||
"plan": {
|
||||
"visible": {},
|
||||
"alias": {},
|
||||
"color": {},
|
||||
"style": {},
|
||||
"width": {},
|
||||
"line_dash": {},
|
||||
"device_alias": {},
|
||||
"plan_table_open": false,
|
||||
"energy_kwh": true,
|
||||
"factor_percent": true,
|
||||
"price_minor_unit": true
|
||||
}
|
||||
},
|
||||
"feedintariff": {
|
||||
"direct_marketing_enabled": false,
|
||||
"provider": "FeedInTariffFixed",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Akkudoktor-EOS
|
||||
|
||||
**Version**: `v0.3.0.dev2610291327175670`
|
||||
**Version**: `v0.3.0.dev2610292046449086`
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
**Description**: This project provides a comprehensive solution for simulating and optimizing an energy system based on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), load management (consumer requirements), heat pumps, electric vehicles, and consideration of electricity price data, this system enables forecasting and optimization of energy flow and costs over a specified period.
|
||||
|
||||
+164
-1
@@ -8,7 +8,7 @@
|
||||
"name": "Apache 2.0",
|
||||
"url": "https://www.apache.org/licenses/LICENSE-2.0.html"
|
||||
},
|
||||
"version": "v0.3.0.dev2610291327175670"
|
||||
"version": "v0.3.0.dev2610292046449086"
|
||||
},
|
||||
"paths": {
|
||||
"/v1/measurement/battery-capacity/{battery_id}": {
|
||||
@@ -4126,6 +4126,9 @@
|
||||
},
|
||||
"adapter": {
|
||||
"$ref": "#/components/schemas/AdapterCommonSettings-Output"
|
||||
},
|
||||
"eosdash": {
|
||||
"$ref": "#/components/schemas/EOSdashCommonSettings"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
@@ -4989,6 +4992,155 @@
|
||||
"title": "DevicesCommonSettings",
|
||||
"description": "Configuration for all controllable devices in the simulation.\n\nEvery device collection is a ``dict[str, <Settings>]`` keyed by\n``device_id``. This makes config paths stable regardless of\ndeclaration order and lets each device settings class build its own\nconfig path from ``self.device_id`` without needing an external index."
|
||||
},
|
||||
"EOSdashCommonSettings": {
|
||||
"properties": {
|
||||
"plan": {
|
||||
"$ref": "#/components/schemas/EOSdashPlanSettings",
|
||||
"description": "Plan page settings."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"title": "EOSdashCommonSettings",
|
||||
"description": "EOS-Dash Configuration."
|
||||
},
|
||||
"EOSdashPlanSettings": {
|
||||
"properties": {
|
||||
"visible": {
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"type": "object",
|
||||
"title": "Visible",
|
||||
"description": "Visibility of solution chart columns and commands.",
|
||||
"examples": [
|
||||
{
|
||||
"grid_feedin_energy_wh": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"alias": {
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"maxLength": 32,
|
||||
"minLength": 1
|
||||
},
|
||||
"type": "object",
|
||||
"title": "Alias",
|
||||
"description": "Display names of solution chart columns and commands.",
|
||||
"examples": [
|
||||
{
|
||||
"mode_SELF_CONSUMPTION": "Self consumption"
|
||||
}
|
||||
]
|
||||
},
|
||||
"color": {
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9A-Fa-f]{6}$"
|
||||
},
|
||||
"type": "object",
|
||||
"title": "Color",
|
||||
"description": "Colors of solution chart columns and commands (#RRGGBB).",
|
||||
"examples": [
|
||||
{
|
||||
"battery1_soc_factor": "#DAA520"
|
||||
}
|
||||
]
|
||||
},
|
||||
"style": {
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"line",
|
||||
"bar"
|
||||
]
|
||||
},
|
||||
"type": "object",
|
||||
"title": "Style",
|
||||
"description": "Chart type of solution chart columns and commands.",
|
||||
"examples": [
|
||||
{
|
||||
"pvforecast_ac_energy_wh": "bar"
|
||||
}
|
||||
]
|
||||
},
|
||||
"width": {
|
||||
"additionalProperties": {
|
||||
"type": "integer",
|
||||
"maximum": 5.0,
|
||||
"minimum": 1.0
|
||||
},
|
||||
"type": "object",
|
||||
"title": "Width",
|
||||
"description": "Line width (1..5) of solution chart columns and commands.",
|
||||
"examples": [
|
||||
{
|
||||
"loadforecast_energy_wh": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
"line_dash": {
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted"
|
||||
]
|
||||
},
|
||||
"type": "object",
|
||||
"title": "Line Dash",
|
||||
"description": "Line dash of solution chart columns and commands.",
|
||||
"examples": [
|
||||
{
|
||||
"elec_price_amt_kwh": "dotted"
|
||||
}
|
||||
]
|
||||
},
|
||||
"device_alias": {
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"maxLength": 32,
|
||||
"minLength": 1
|
||||
},
|
||||
"type": "object",
|
||||
"title": "Device Alias",
|
||||
"description": "Display names of devices (device id -> name) on the plan page.",
|
||||
"examples": [
|
||||
{
|
||||
"battery1": "House battery"
|
||||
}
|
||||
]
|
||||
},
|
||||
"plan_table_open": {
|
||||
"type": "boolean",
|
||||
"title": "Plan Table Open",
|
||||
"description": "Energy management plan table open on page load. Defaults to False.",
|
||||
"default": false
|
||||
},
|
||||
"energy_kwh": {
|
||||
"type": "boolean",
|
||||
"title": "Energy Kwh",
|
||||
"description": "Energy in kWh instead of Wh in the solution chart. Defaults to True.",
|
||||
"default": true
|
||||
},
|
||||
"factor_percent": {
|
||||
"type": "boolean",
|
||||
"title": "Factor Percent",
|
||||
"description": "Factors in % instead of 0.0..1.0 in the solution chart. Defaults to True.",
|
||||
"default": true
|
||||
},
|
||||
"price_minor_unit": {
|
||||
"type": "boolean",
|
||||
"title": "Price Minor Unit",
|
||||
"description": "Electricity price in the minor unit of the currency per kWh (e.g. ct/kWh for EUR, Rp./kWh for CHF) instead of currency/kWh in the solution chart. Has no effect for currencies without a minor unit. Defaults to True.",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"title": "EOSdashPlanSettings",
|
||||
"description": "EOSdash plan page settings.\n\nKeys are the columns of the optimization solution chart (e.g. ``battery1_soc_factor``)\nor EMS plan commands (operation modes) with the prefix ``mode_``\n(e.g. ``mode_SELF_CONSUMPTION``). A command applies to the plan timeline and to the\noperation mode factor columns of all devices. Only choices of the user are stored,\neverything else uses the defaults of EOSdash."
|
||||
},
|
||||
"ElecFeeCommonSettings-Input": {
|
||||
"properties": {
|
||||
"provider": {
|
||||
@@ -15719,6 +15871,17 @@
|
||||
}
|
||||
],
|
||||
"description": "Adapter Settings"
|
||||
},
|
||||
"eosdash": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/EOSdashCommonSettings"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "EOS-Dash Settings"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -52,6 +52,7 @@ from akkudoktoreos.prediction.load import LoadCommonSettings
|
||||
from akkudoktoreos.prediction.prediction import PredictionCommonSettings
|
||||
from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings
|
||||
from akkudoktoreos.prediction.weather import WeatherCommonSettings
|
||||
from akkudoktoreos.server.dash.dashsettings import EOSdashCommonSettings
|
||||
from akkudoktoreos.server.rest.cli import cli_argument_parser
|
||||
from akkudoktoreos.server.server import ServerCommonSettings
|
||||
from akkudoktoreos.utils.datetimeutil import (
|
||||
@@ -331,6 +332,9 @@ class SettingsEOS(pydantic_settings.BaseSettings, PydanticModelNestedValueMixin)
|
||||
adapter: Optional[AdapterCommonSettings] = Field(
|
||||
default=None, json_schema_extra={"description": "Adapter Settings"}
|
||||
)
|
||||
eosdash: Optional[EOSdashCommonSettings] = Field(
|
||||
default=None, json_schema_extra={"description": "EOS-Dash Settings"}
|
||||
)
|
||||
|
||||
model_config = pydantic_settings.SettingsConfigDict(
|
||||
env_nested_delimiter="__",
|
||||
@@ -364,6 +368,7 @@ class SettingsEOSDefaults(SettingsEOS):
|
||||
server: ServerCommonSettings = Field(default_factory=ServerCommonSettings)
|
||||
utils: UtilsCommonSettings = Field(default_factory=UtilsCommonSettings)
|
||||
adapter: AdapterCommonSettings = Field(default_factory=AdapterCommonSettings)
|
||||
eosdash: EOSdashCommonSettings = Field(default_factory=EOSdashCommonSettings)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
# Just for usage in configmigrate, finally overwritten when used by ConfigEOS.
|
||||
|
||||
@@ -7,7 +7,7 @@ from bokeh.models import Plot
|
||||
from bokeh.models.annotations import Title
|
||||
from bokeh.plotting import figure
|
||||
from bokeh.resources import INLINE
|
||||
from monsterui.franken import H4, Card, NotStr
|
||||
from monsterui.franken import H4, Card, Div, NotStr
|
||||
|
||||
# Bokeh accepts FigureOptions as constructor keywords, but its generated
|
||||
# constructor signature only lists model properties. Preserve the typed result.
|
||||
@@ -69,8 +69,11 @@ def Bokeh(plot: Plot, header: Optional[str] = None) -> Card:
|
||||
script, div = components(plot)
|
||||
if header:
|
||||
header = H4(header, cls="mt-2")
|
||||
# Reserve the plot height before BokehJS has rendered the plot, so the page
|
||||
# layout (and scroll position) does not jump when the content is replaced.
|
||||
height = getattr(plot, "height", None)
|
||||
return Card(
|
||||
NotStr(div),
|
||||
Div(NotStr(div), style=f"min-height: {height}px") if height else NotStr(div),
|
||||
NotStr(script),
|
||||
header=header,
|
||||
)
|
||||
|
||||
@@ -1293,5 +1293,5 @@ def Page(
|
||||
DashboardTabs(dashboard_items),
|
||||
DashboardContent(content),
|
||||
DashboardFooter(footer_content, path=footer_path),
|
||||
cls=("bg-background text-foreground w-screen p-4 space-y-4", ContainerT.xl),
|
||||
cls=("bg-background text-foreground p-4 space-y-4", ContainerT.xl),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Settings for EOSdash.
|
||||
|
||||
Kept in an extra module to avoid cyclic dependencies on package import.
|
||||
"""
|
||||
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import Field, StringConstraints
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
|
||||
# Settings key prefix of an EMS plan command (operation mode), e.g. "mode_SELF_CONSUMPTION"
|
||||
EOSDASH_MODE_KEY_PREFIX = "mode_"
|
||||
EOSDASH_ALIAS_MAX_LEN = 32
|
||||
EOSDASH_LINE_WIDTH_MAX = 5
|
||||
|
||||
HexColor = Annotated[str, StringConstraints(pattern=r"^#[0-9A-Fa-f]{6}$")]
|
||||
Alias = Annotated[
|
||||
str, StringConstraints(strip_whitespace=True, min_length=1, max_length=EOSDASH_ALIAS_MAX_LEN)
|
||||
]
|
||||
LineWidth = Annotated[int, Field(ge=1, le=EOSDASH_LINE_WIDTH_MAX)]
|
||||
|
||||
|
||||
class EOSdashPlanSettings(SettingsBaseModel):
|
||||
"""EOSdash plan page settings.
|
||||
|
||||
Keys are the columns of the optimization solution chart (e.g. ``battery1_soc_factor``)
|
||||
or EMS plan commands (operation modes) with the prefix ``mode_``
|
||||
(e.g. ``mode_SELF_CONSUMPTION``). A command applies to the plan timeline and to the
|
||||
operation mode factor columns of all devices. Only choices of the user are stored,
|
||||
everything else uses the defaults of EOSdash.
|
||||
"""
|
||||
|
||||
visible: dict[str, bool] = Field(
|
||||
default_factory=dict,
|
||||
json_schema_extra={
|
||||
"description": "Visibility of solution chart columns and commands.",
|
||||
"examples": [{"grid_feedin_energy_wh": False}],
|
||||
},
|
||||
)
|
||||
alias: dict[str, Alias] = Field(
|
||||
default_factory=dict,
|
||||
json_schema_extra={
|
||||
"description": "Display names of solution chart columns and commands.",
|
||||
"examples": [{"mode_SELF_CONSUMPTION": "Self consumption"}],
|
||||
},
|
||||
)
|
||||
color: dict[str, HexColor] = Field(
|
||||
default_factory=dict,
|
||||
json_schema_extra={
|
||||
"description": "Colors of solution chart columns and commands (#RRGGBB).",
|
||||
"examples": [{"battery1_soc_factor": "#DAA520"}],
|
||||
},
|
||||
)
|
||||
style: dict[str, Literal["line", "bar"]] = Field(
|
||||
default_factory=dict,
|
||||
json_schema_extra={
|
||||
"description": "Chart type of solution chart columns and commands.",
|
||||
"examples": [{"pvforecast_ac_energy_wh": "bar"}],
|
||||
},
|
||||
)
|
||||
width: dict[str, LineWidth] = Field(
|
||||
default_factory=dict,
|
||||
json_schema_extra={
|
||||
"description": "Line width (1..5) of solution chart columns and commands.",
|
||||
"examples": [{"loadforecast_energy_wh": 3}],
|
||||
},
|
||||
)
|
||||
line_dash: dict[str, Literal["solid", "dashed", "dotted"]] = Field(
|
||||
default_factory=dict,
|
||||
json_schema_extra={
|
||||
"description": "Line dash of solution chart columns and commands.",
|
||||
"examples": [{"elec_price_amt_kwh": "dotted"}],
|
||||
},
|
||||
)
|
||||
device_alias: dict[str, Alias] = Field(
|
||||
default_factory=dict,
|
||||
json_schema_extra={
|
||||
"description": "Display names of devices (device id -> name) on the plan page.",
|
||||
"examples": [{"battery1": "House battery"}],
|
||||
},
|
||||
)
|
||||
plan_table_open: bool = Field(
|
||||
default=False,
|
||||
json_schema_extra={
|
||||
"description": "Energy management plan table open on page load. Defaults to False."
|
||||
},
|
||||
)
|
||||
energy_kwh: bool = Field(
|
||||
default=True,
|
||||
json_schema_extra={
|
||||
"description": "Energy in kWh instead of Wh in the solution chart. Defaults to True."
|
||||
},
|
||||
)
|
||||
factor_percent: bool = Field(
|
||||
default=True,
|
||||
json_schema_extra={
|
||||
"description": "Factors in % instead of 0.0..1.0 in the solution chart. Defaults to True."
|
||||
},
|
||||
)
|
||||
price_minor_unit: bool = Field(
|
||||
default=True,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Electricity price in the minor unit of the currency per kWh (e.g. ct/kWh for "
|
||||
"EUR, Rp./kWh for CHF) instead of currency/kWh in the solution chart. Has no "
|
||||
"effect for currencies without a minor unit. Defaults to True."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class EOSdashCommonSettings(SettingsBaseModel):
|
||||
"""EOS-Dash Configuration."""
|
||||
|
||||
plan: EOSdashPlanSettings = Field(
|
||||
default_factory=EOSdashPlanSettings,
|
||||
json_schema_extra={"description": "Plan page settings."},
|
||||
)
|
||||
+1609
-305
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
|
||||
import psutil
|
||||
import uvicorn
|
||||
from fasthtml.common import Base, FileResponse, JSONResponse
|
||||
from fasthtml.common import Base, FileResponse, JSONResponse, Script
|
||||
from loguru import logger
|
||||
from monsterui.core import FastHTML, Theme
|
||||
from starlette.middleware import Middleware
|
||||
@@ -197,6 +197,39 @@ if not favicon_filepath.exists():
|
||||
hdrs = (
|
||||
*BokehJS,
|
||||
Theme.green.headers(highlightjs=True),
|
||||
# Keep the scroll position when a control on the page replaces the page content.
|
||||
# Without this the content is shorter for a moment (plots not yet rendered,
|
||||
# sections closed) and the browser clamps the scroll position to the bottom.
|
||||
Script("""
|
||||
(function () {
|
||||
let saved = null;
|
||||
function scroller(el) {
|
||||
for (let e = el.parentElement; e; e = e.parentElement) {
|
||||
const overflowY = getComputedStyle(e).overflowY;
|
||||
if (overflowY === "auto" || overflowY === "scroll") return e;
|
||||
}
|
||||
return document.scrollingElement;
|
||||
}
|
||||
document.addEventListener("htmx:beforeSwap", (evt) => {
|
||||
const target = evt.detail.target;
|
||||
const elt = (evt.detail.requestConfig && evt.detail.requestConfig.elt) || evt.detail.elt;
|
||||
saved = null;
|
||||
// Only for controls inside the page content, navigation still starts at the top
|
||||
if (!target || target.id !== "page-content" || !elt || !target.contains(elt)) return;
|
||||
const s = scroller(target);
|
||||
saved = { target: target, s: s, top: s.scrollTop };
|
||||
target.style.minHeight = target.offsetHeight + "px";
|
||||
});
|
||||
document.addEventListener("htmx:afterSettle", () => {
|
||||
if (!saved) return;
|
||||
const { target, s, top } = saved;
|
||||
saved = null;
|
||||
s.scrollTop = top;
|
||||
// Give the plots time to render before the content may shrink again
|
||||
setTimeout(() => { target.style.minHeight = ""; }, 1500);
|
||||
});
|
||||
})();
|
||||
"""),
|
||||
)
|
||||
|
||||
# The EOSdash application
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Tests for the EOSdash settings in the EOS configuration (eosdash/plan)."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
import akkudoktoreos.server.dash.eosstatus as eosstatus
|
||||
from akkudoktoreos.config.config import SettingsEOS
|
||||
from akkudoktoreos.server.dash import plan
|
||||
from akkudoktoreos.server.dash.dashsettings import (
|
||||
EOSdashCommonSettings,
|
||||
EOSdashPlanSettings,
|
||||
)
|
||||
|
||||
SERVER = "http://127.0.0.1:8503"
|
||||
|
||||
|
||||
class TestEOSdashPlanSettings:
|
||||
def test_defaults(self):
|
||||
settings = EOSdashPlanSettings()
|
||||
assert settings.visible == {}
|
||||
assert settings.color == {}
|
||||
assert settings.device_alias == {}
|
||||
assert settings.plan_table_open is False
|
||||
assert settings.energy_kwh is True
|
||||
assert settings.factor_percent is True
|
||||
assert settings.price_minor_unit is True
|
||||
assert EOSdashCommonSettings().plan.model_dump() == settings.model_dump()
|
||||
|
||||
def test_valid_values(self):
|
||||
settings = EOSdashPlanSettings(
|
||||
color={"battery1_soc_factor": "#DAA520"},
|
||||
style={"pvforecast_ac_energy_wh": "bar"},
|
||||
width={"loadforecast_energy_wh": 5},
|
||||
line_dash={"mode_SELF_CONSUMPTION": "dotted"},
|
||||
alias={"mode_SELF_CONSUMPTION": " Self consumption "},
|
||||
)
|
||||
assert settings.alias["mode_SELF_CONSUMPTION"] == "Self consumption"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field, value",
|
||||
[
|
||||
("color", {"col": "red"}),
|
||||
("color", {"col": "#12345"}),
|
||||
("style", {"col": "area"}),
|
||||
("width", {"col": 0}),
|
||||
("width", {"col": 6}),
|
||||
("line_dash", {"col": "dashdot"}),
|
||||
("alias", {"col": ""}),
|
||||
("alias", {"col": "x" * 33}),
|
||||
("device_alias", {"battery1": " "}),
|
||||
],
|
||||
)
|
||||
def test_invalid_values(self, field, value):
|
||||
with pytest.raises(ValidationError):
|
||||
EOSdashPlanSettings(**{field: value})
|
||||
|
||||
|
||||
class TestEOSdashConfig:
|
||||
def test_config_has_eosdash_section(self, config_eos):
|
||||
assert config_eos.eosdash.plan.model_dump() == EOSdashPlanSettings().model_dump()
|
||||
|
||||
def test_set_nested_value(self, config_eos):
|
||||
config_eos.set_nested_value("eosdash/plan/color", {"battery1_soc_factor": "#FF0000"})
|
||||
config_eos.set_nested_value("eosdash/plan/price_minor_unit", False)
|
||||
assert config_eos.eosdash.plan.color == {"battery1_soc_factor": "#FF0000"}
|
||||
assert config_eos.eosdash.plan.price_minor_unit is False
|
||||
with pytest.raises(ValueError):
|
||||
config_eos.set_nested_value("eosdash/plan/color", {"battery1_soc_factor": "red"})
|
||||
|
||||
def test_merge_settings_from_dict(self, config_eos):
|
||||
config_eos.merge_settings_from_dict(
|
||||
{"eosdash": {"plan": {"device_alias": {"battery1": "House battery"}}}}
|
||||
)
|
||||
assert config_eos.eosdash.plan.device_alias == {"battery1": "House battery"}
|
||||
# Other plan settings keep their defaults
|
||||
assert config_eos.eosdash.plan.energy_kwh is True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def eosdash_config():
|
||||
"""EOSdash copy of the EOS configuration, restored after the test."""
|
||||
saved = eosstatus.eos_config
|
||||
eosstatus.eos_config = SettingsEOS(
|
||||
eosdash=EOSdashCommonSettings(
|
||||
plan=EOSdashPlanSettings(
|
||||
color={"battery1_soc_factor": "#00FF00"},
|
||||
alias={"mode_IDLE": "Idle"},
|
||||
device_alias={"battery1": "House battery"},
|
||||
price_minor_unit=False,
|
||||
)
|
||||
)
|
||||
)
|
||||
yield eosstatus.eos_config
|
||||
eosstatus.eos_config = saved
|
||||
|
||||
|
||||
def put_response(config: SettingsEOS) -> MagicMock:
|
||||
"""Response of EOS to a configuration update."""
|
||||
response = MagicMock()
|
||||
response.json.return_value = config.model_dump(mode="json")
|
||||
return response
|
||||
|
||||
|
||||
class TestPlanSettings:
|
||||
def test_defaults_without_config(self):
|
||||
saved = eosstatus.eos_config
|
||||
eosstatus.eos_config = None
|
||||
try:
|
||||
assert plan.plan_settings().model_dump() == EOSdashPlanSettings().model_dump()
|
||||
assert plan.plan_option("price_minor_unit") is True
|
||||
finally:
|
||||
eosstatus.eos_config = saved
|
||||
|
||||
def test_settings_from_config(self, eosdash_config):
|
||||
assert plan.solution_column_color("battery1_soc_factor") == "#00FF00"
|
||||
assert plan.plan_mode_label("IDLE") == "Idle"
|
||||
assert plan.device_alias("battery1") == "House battery"
|
||||
assert plan.device_alias("ev11") == "ev11"
|
||||
assert plan.column_label("battery1_soc_factor", ["battery1"]) == "House battery soc_factor"
|
||||
assert plan.plan_option("price_minor_unit") is False
|
||||
assert plan.plan_option("energy_kwh") is True
|
||||
|
||||
def test_put_replaces_config(self, eosdash_config):
|
||||
updated = eosdash_config.model_copy(deep=True)
|
||||
updated.eosdash.plan.price_minor_unit = True
|
||||
with patch.object(plan.requests, "put", return_value=put_response(updated)) as put:
|
||||
plan.plan_settings_put(SERVER, "price_minor_unit", True)
|
||||
put.assert_called_once_with(
|
||||
f"{SERVER}/v1/config/eosdash/plan/price_minor_unit", json=True, timeout=10
|
||||
)
|
||||
assert plan.plan_option("price_minor_unit") is True
|
||||
|
||||
def test_put_failure_keeps_config(self, eosdash_config):
|
||||
with patch.object(plan.requests, "put", side_effect=OSError("no connection")):
|
||||
plan.plan_settings_put(SERVER, "price_minor_unit", True)
|
||||
assert plan.plan_option("price_minor_unit") is False
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data, key, value",
|
||||
[
|
||||
(
|
||||
{"action": "color", "renderer": "load", "color": "#abcdef"},
|
||||
"color",
|
||||
{"battery1_soc_factor": "#00FF00", "load": "#ABCDEF"},
|
||||
),
|
||||
({"action": "reset", "renderer": "battery1_soc_factor"}, "color", {}),
|
||||
({"action": "visible", "renderer": "load", "load-visible": "true"}, "visible", {"load": True}),
|
||||
({"action": "visible", "renderer": "load"}, "visible", {"load": False}),
|
||||
({"action": "style", "renderer": "load", "style": "bar"}, "style", {"load": "bar"}),
|
||||
({"action": "width", "renderer": "load", "width": "3"}, "width", {"load": 3}),
|
||||
({"action": "dash", "renderer": "load", "dash": "dotted"}, "line_dash", {"load": "dotted"}),
|
||||
(
|
||||
{"action": "alias", "renderer": "load", "alias": " House load\x00 "},
|
||||
"alias",
|
||||
{"mode_IDLE": "Idle", "load": "House load"},
|
||||
),
|
||||
({"action": "alias", "renderer": "mode_IDLE", "alias": ""}, "alias", {}),
|
||||
],
|
||||
)
|
||||
def test_solution_settings_action(self, eosdash_config, data, key, value):
|
||||
with patch.object(plan, "plan_settings_put") as put:
|
||||
plan.solution_settings_action(SERVER, data)
|
||||
put.assert_called_once_with(SERVER, key, value)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data",
|
||||
[
|
||||
{"action": "color", "renderer": "load", "color": "red"},
|
||||
{"action": "style", "renderer": "load", "style": "area"},
|
||||
{"action": "width", "renderer": "load", "width": "9"},
|
||||
{"action": "dash", "renderer": "load", "dash": "dashdot"},
|
||||
{"action": "color", "color": "#ABCDEF"},
|
||||
],
|
||||
)
|
||||
def test_solution_settings_action_invalid(self, eosdash_config, data):
|
||||
with patch.object(plan, "plan_settings_put") as put:
|
||||
plan.solution_settings_action(SERVER, data)
|
||||
put.assert_not_called()
|
||||
|
||||
|
||||
class TestCurrencyMinorUnit:
|
||||
@pytest.mark.parametrize(
|
||||
"currency, expected",
|
||||
[
|
||||
("EUR", ("ct", 100)),
|
||||
("CHF", ("Rp.", 100)),
|
||||
("SEK", ("1/100 SEK", 100)),
|
||||
("KWD", ("1/1000 KWD", 1000)),
|
||||
("JPY", None),
|
||||
(None, None),
|
||||
],
|
||||
)
|
||||
def test_currency_minor_unit(self, currency, expected):
|
||||
assert plan.currency_minor_unit(currency) == expected
|
||||
|
||||
def test_config_currency(self):
|
||||
config = SettingsEOS.model_validate({"general": {"timezone_override": "Europe/Zurich"}})
|
||||
assert plan.config_currency(config) == "CHF"
|
||||
+1
@@ -9,6 +9,7 @@
|
||||
../_generated/configelecfee.md
|
||||
../_generated/configelecprice.md
|
||||
../_generated/configems.md
|
||||
../_generated/configeosdash.md
|
||||
../_generated/configfeedintariff.md
|
||||
../_generated/configgeneral.md
|
||||
../_generated/configload.md
|
||||
|
||||
Reference in New Issue
Block a user