mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-11-13 09:13:30 +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
@@ -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."},
|
||||
)
|
||||
+1613
-309
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
|
||||
|
||||
Reference in New Issue
Block a user