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:
SchaafAlexander
2026-10-30 08:03:19 +01:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 7554e2dc47
commit 527c26d424
13 changed files with 2266 additions and 315 deletions
+1
View File
@@ -9,6 +9,7 @@
../_generated/configelecfee.md ../_generated/configelecfee.md
../_generated/configelecprice.md ../_generated/configelecprice.md
../_generated/configems.md ../_generated/configems.md
../_generated/configeosdash.md
../_generated/configfeedintariff.md ../_generated/configfeedintariff.md
../_generated/configgeneral.md ../_generated/configgeneral.md
../_generated/configload.md ../_generated/configload.md
+107
View File
@@ -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 -->
+15
View File
@@ -183,6 +183,21 @@
"mode": "OPTIMIZATION", "mode": "OPTIMIZATION",
"notify_url": null "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": { "feedintariff": {
"direct_marketing_enabled": false, "direct_marketing_enabled": false,
"provider": "FeedInTariffFixed", "provider": "FeedInTariffFixed",
+1 -1
View File
@@ -1,6 +1,6 @@
# Akkudoktor-EOS # Akkudoktor-EOS
**Version**: `v0.3.0.dev2610291327175670` **Version**: `v0.3.0.dev2610292046449086`
<!-- pyml disable line-length --> <!-- 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. **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
View File
@@ -8,7 +8,7 @@
"name": "Apache 2.0", "name": "Apache 2.0",
"url": "https://www.apache.org/licenses/LICENSE-2.0.html" "url": "https://www.apache.org/licenses/LICENSE-2.0.html"
}, },
"version": "v0.3.0.dev2610291327175670" "version": "v0.3.0.dev2610292046449086"
}, },
"paths": { "paths": {
"/v1/measurement/battery-capacity/{battery_id}": { "/v1/measurement/battery-capacity/{battery_id}": {
@@ -4126,6 +4126,9 @@
}, },
"adapter": { "adapter": {
"$ref": "#/components/schemas/AdapterCommonSettings-Output" "$ref": "#/components/schemas/AdapterCommonSettings-Output"
},
"eosdash": {
"$ref": "#/components/schemas/EOSdashCommonSettings"
} }
}, },
"additionalProperties": false, "additionalProperties": false,
@@ -4989,6 +4992,155 @@
"title": "DevicesCommonSettings", "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." "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": { "ElecFeeCommonSettings-Input": {
"properties": { "properties": {
"provider": { "provider": {
@@ -15719,6 +15871,17 @@
} }
], ],
"description": "Adapter Settings" "description": "Adapter Settings"
},
"eosdash": {
"anyOf": [
{
"$ref": "#/components/schemas/EOSdashCommonSettings"
},
{
"type": "null"
}
],
"description": "EOS-Dash Settings"
} }
}, },
"additionalProperties": false, "additionalProperties": false,
+5
View File
@@ -52,6 +52,7 @@ from akkudoktoreos.prediction.load import LoadCommonSettings
from akkudoktoreos.prediction.prediction import PredictionCommonSettings from akkudoktoreos.prediction.prediction import PredictionCommonSettings
from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings
from akkudoktoreos.prediction.weather import WeatherCommonSettings 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.rest.cli import cli_argument_parser
from akkudoktoreos.server.server import ServerCommonSettings from akkudoktoreos.server.server import ServerCommonSettings
from akkudoktoreos.utils.datetimeutil import ( from akkudoktoreos.utils.datetimeutil import (
@@ -331,6 +332,9 @@ class SettingsEOS(pydantic_settings.BaseSettings, PydanticModelNestedValueMixin)
adapter: Optional[AdapterCommonSettings] = Field( adapter: Optional[AdapterCommonSettings] = Field(
default=None, json_schema_extra={"description": "Adapter Settings"} 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( model_config = pydantic_settings.SettingsConfigDict(
env_nested_delimiter="__", env_nested_delimiter="__",
@@ -364,6 +368,7 @@ class SettingsEOSDefaults(SettingsEOS):
server: ServerCommonSettings = Field(default_factory=ServerCommonSettings) server: ServerCommonSettings = Field(default_factory=ServerCommonSettings)
utils: UtilsCommonSettings = Field(default_factory=UtilsCommonSettings) utils: UtilsCommonSettings = Field(default_factory=UtilsCommonSettings)
adapter: AdapterCommonSettings = Field(default_factory=AdapterCommonSettings) adapter: AdapterCommonSettings = Field(default_factory=AdapterCommonSettings)
eosdash: EOSdashCommonSettings = Field(default_factory=EOSdashCommonSettings)
def __hash__(self) -> int: def __hash__(self) -> int:
# Just for usage in configmigrate, finally overwritten when used by ConfigEOS. # Just for usage in configmigrate, finally overwritten when used by ConfigEOS.
+5 -2
View File
@@ -7,7 +7,7 @@ from bokeh.models import Plot
from bokeh.models.annotations import Title from bokeh.models.annotations import Title
from bokeh.plotting import figure from bokeh.plotting import figure
from bokeh.resources import INLINE 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 # Bokeh accepts FigureOptions as constructor keywords, but its generated
# constructor signature only lists model properties. Preserve the typed result. # 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) script, div = components(plot)
if header: if header:
header = H4(header, cls="mt-2") 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( return Card(
NotStr(div), Div(NotStr(div), style=f"min-height: {height}px") if height else NotStr(div),
NotStr(script), NotStr(script),
header=header, header=header,
) )
+1 -1
View File
@@ -1293,5 +1293,5 @@ def Page(
DashboardTabs(dashboard_items), DashboardTabs(dashboard_items),
DashboardContent(content), DashboardContent(content),
DashboardFooter(footer_content, path=footer_path), 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."},
)
File diff suppressed because it is too large Load Diff
+34 -1
View File
@@ -6,7 +6,7 @@ from pathlib import Path
import psutil import psutil
import uvicorn import uvicorn
from fasthtml.common import Base, FileResponse, JSONResponse from fasthtml.common import Base, FileResponse, JSONResponse, Script
from loguru import logger from loguru import logger
from monsterui.core import FastHTML, Theme from monsterui.core import FastHTML, Theme
from starlette.middleware import Middleware from starlette.middleware import Middleware
@@ -197,6 +197,39 @@ if not favicon_filepath.exists():
hdrs = ( hdrs = (
*BokehJS, *BokehJS,
Theme.green.headers(highlightjs=True), 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 # The EOSdash application
+200
View File
@@ -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
View File
@@ -9,6 +9,7 @@
../_generated/configelecfee.md ../_generated/configelecfee.md
../_generated/configelecprice.md ../_generated/configelecprice.md
../_generated/configems.md ../_generated/configems.md
../_generated/configeosdash.md
../_generated/configfeedintariff.md ../_generated/configfeedintariff.md
../_generated/configgeneral.md ../_generated/configgeneral.md
../_generated/configload.md ../_generated/configload.md