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
+5
View File
@@ -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.
+5 -2
View File
@@ -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,
)
+1 -1
View File
@@ -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."},
)
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 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