diff --git a/docs/_generated/config.md b/docs/_generated/config.md index 522b0069..7d89c5cb 100644 --- a/docs/_generated/config.md +++ b/docs/_generated/config.md @@ -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 diff --git a/docs/_generated/configeosdash.md b/docs/_generated/configeosdash.md new file mode 100644 index 00000000..3c964dc0 --- /dev/null +++ b/docs/_generated/configeosdash.md @@ -0,0 +1,107 @@ +## EOS-Dash Configuration + + +:::{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. | +::: + + + +**Example Input/Output** + + + +```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 + } + } + } +``` + + +### 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. + + +:::{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. | +::: + + + +**Example Input/Output** + + + +```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 + } + } + } +``` + diff --git a/docs/_generated/configexample.md b/docs/_generated/configexample.md index 8da18eed..27e57346 100644 --- a/docs/_generated/configexample.md +++ b/docs/_generated/configexample.md @@ -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", diff --git a/docs/_generated/openapi.md b/docs/_generated/openapi.md index cb54b3f8..88006b26 100644 --- a/docs/_generated/openapi.md +++ b/docs/_generated/openapi.md @@ -1,6 +1,6 @@ # Akkudoktor-EOS -**Version**: `v0.3.0.dev2610291327175670` +**Version**: `v0.3.0.dev2610292046449086` **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. diff --git a/openapi.json b/openapi.json index 0e6b64c6..9f981877 100644 --- a/openapi.json +++ b/openapi.json @@ -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, ]`` 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, diff --git a/src/akkudoktoreos/config/config.py b/src/akkudoktoreos/config/config.py index 90d0fcad..5f695f70 100644 --- a/src/akkudoktoreos/config/config.py +++ b/src/akkudoktoreos/config/config.py @@ -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. diff --git a/src/akkudoktoreos/server/dash/bokeh.py b/src/akkudoktoreos/server/dash/bokeh.py index 9df3c404..2e60648a 100644 --- a/src/akkudoktoreos/server/dash/bokeh.py +++ b/src/akkudoktoreos/server/dash/bokeh.py @@ -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, ) diff --git a/src/akkudoktoreos/server/dash/components.py b/src/akkudoktoreos/server/dash/components.py index 6f86ad56..2e1b8fad 100644 --- a/src/akkudoktoreos/server/dash/components.py +++ b/src/akkudoktoreos/server/dash/components.py @@ -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), ) diff --git a/src/akkudoktoreos/server/dash/dashsettings.py b/src/akkudoktoreos/server/dash/dashsettings.py new file mode 100644 index 00000000..f53bb6a2 --- /dev/null +++ b/src/akkudoktoreos/server/dash/dashsettings.py @@ -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."}, + ) diff --git a/src/akkudoktoreos/server/dash/plan.py b/src/akkudoktoreos/server/dash/plan.py index 84f1a601..c3becc92 100644 --- a/src/akkudoktoreos/server/dash/plan.py +++ b/src/akkudoktoreos/server/dash/plan.py @@ -1,17 +1,33 @@ -from typing import Optional, Union +import html +import json +import re +from typing import Any, Optional, Union import pandas as pd import requests -from bokeh.models import ColumnDataSource, LinearAxis, Range1d +from babel.core import get_global +from babel.numbers import get_currency_precision, get_territory_currencies +from bokeh.core.properties import value as bokeh_value +from bokeh.models import ( + ColumnDataSource, + CustomJS, + FactorRange, + HoverTool, + LinearAxis, + Range1d, +) +from bokeh.models.renderers import GlyphRenderer +from fasthtml.common import Button as FhButton +from fasthtml.common import Form, Input, Script, Style from loguru import logger from monsterui.franken import ( Card, - CardTitle, + CheckboxX, Details, Div, DivLAligned, + FormLabel, Grid, - LabelCheckboxX, P, Summary, UkIcon, @@ -32,8 +48,14 @@ from akkudoktoreos.server.dash.bokeh import ( bokey_apply_theme_to_plot, create_figure, ) -from akkudoktoreos.server.dash.components import Error +from akkudoktoreos.server.dash.components import ConfigSection, Error from akkudoktoreos.server.dash.context import request_url_for +from akkudoktoreos.server.dash.dashsettings import ( + EOSDASH_ALIAS_MAX_LEN, + EOSDASH_LINE_WIDTH_MAX, + EOSDASH_MODE_KEY_PREFIX, + EOSdashPlanSettings, +) from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime # bar width for 1 hour bars (time given in millseconds) @@ -74,14 +96,588 @@ solution_excludes = [ "_frequency_regulation_", ] -# Current state of solution displayed -solution_visible: dict[str, bool] = { - "pvforecast_power_w": True, +# Solution columns displayed by default +solution_visible_default: dict[str, bool] = { + "pvforecast_ac_energy_wh": True, "elec_price_amt_kwh": True, - "feed_in_tariff_amt_kwh": True, + "loadforecast_energy_wh": True, + "grid_consumption_energy_wh": True, + "grid_feedin_energy_wh": True, +} +# Solution columns with these suffixes are displayed by default (e.g. battery1_soc_factor) +solution_visible_default_suffixes = ("_soc_factor",) +# Solution columns displayed as bar chart by default +solution_style_default: dict[str, str] = { + "pvforecast_ac_energy_wh": "bar", +} +# Solution columns plotted below the zero axis (energy flowing out of the house) +solution_negative_columns = ("grid_feedin_energy_wh",) +# Line width of the solution chart lines +SOLUTION_LINE_WIDTH = 2 +SOLUTION_LINE_WIDTHS = tuple(range(1, EOSDASH_LINE_WIDTH_MAX + 1)) +# Line dash of the solution chart lines: by default predictions are dotted, +# SoC factors dashed and everything else solid, except for these columns +SOLUTION_LINE_DASHES = ("solid", "dashed", "dotted") +solution_dash_default: dict[str, str] = { + "loadforecast_energy_wh": "solid", } solution_color: dict[str, str] = {} +# Default colors of solution columns, following the openWB colors in Home Assistant +solution_color_default: dict[str, str] = { + # Prediction + "elec_price_amt_kwh": "#C084FC", # light purple + "feed_in_tariff_amt_kwh": "#B2DF8A", # light green (feed-in) + "loadforecast_energy_wh": "#7F7F7F", # grey (house) + "pvforecast_ac_energy_wh": "#33A02C", # green (PV) + # Solution + "costs_amt": "#FF4D4D", # light red + "revenue_amt": "#10B981", # emerald + "grid_consumption_energy_wh": "#B22222", # red (grid) + "grid_feedin_energy_wh": "#B2DF8A", # light green (feed-in) + "load_energy_wh": "#7F7F7F", # grey (house) + "losses_energy_wh": "#6366F1", # indigo + # Instruction + "genetic_ac_charge_factor": "#B22222", # red (grid) + "genetic_battery_grid_export_allowed_factor": "#84CC16", # lime + "genetic_dc_charge_factor": "#33A02C", # green (PV) + "genetic_discharge_allowed_factor": "#F5D061", # light gold (storage) + "genetic_ev_charge_factor": "#0EA5E9", # sky +} +# Default colors of device columns, by device type +solution_color_battery_soc = "#DAA520" # gold (storage) +solution_color_ev_soc = "#1F78B4" # blue (charging) +solution_color_appliance_energy = "#FFFFFF" # white + +# Colors of the plan timeline, by operation mode +# The first four follow the EMS plan chart of the Home Assistant integration +plan_mode_color: dict[str, str] = { + "SELF_CONSUMPTION": "#3A9A3A", # green + "IDLE": "#7A7A7A", # grey + "RUN": "#6F8EE0", # blue + "OFF": "#A12525", # red + "NON_EXPORT": "#D4A017", # amber + "FORCED_CHARGE": "#14B8A6", # teal + "FORCED_DISCHARGE": "#8B5CF6", # violet + "PEAK_SHAVING": "#F97316", # orange + "GRID_SUPPORT_EXPORT": "#EC4899", # pink + "GRID_SUPPORT_IMPORT": "#F43F5E", # rose + "FREQUENCY_REGULATION": "#0EA5E9", # sky + "RAMP_RATE_CONTROL": "#06B6D4", # cyan + "RESERVE_BACKUP": "#84CC16", # lime + "OUTAGE_SUPPLY": "#EAB308", # yellow + "FORCED_RUN": "#3B5BDB", # dark blue + "LIMIT_POWER": "#9AA5CE", # light blue grey + "DEFER": "#B08D57", # brown + "PAUSE": "#B08D57", # brown + "RESUME": "#6F8EE0", # blue + "FAULT": "#E11D48", # crimson +} +plan_mode_color_default = "#A1A1AA" # zinc-400 + +# White is not visible on the light theme, it is displayed dark grey there +LIGHT_THEME_WHITE_REPLACEMENT = "#4B5563" # gray-600 + + +# Charts use the system font like the rest of the page. Bokeh's default "helvetica" +# falls back to Arial on most systems, which is hard to read in small sizes. +CHART_FONT = 'system-ui, "Segoe UI", Roboto, sans-serif' +CHART_FONT_SIZE = "12px" +# Font of the HTML tooltips (inside a double quoted style attribute) +CHART_TOOLTIP_STYLE = f"font-family: {CHART_FONT.replace(chr(34), chr(39))}; font-size: 12px;" + + +def apply_chart_font(plot: Any) -> None: + """Use the chart font for the title, the axes and the legend of a plot.""" + plot.title.text_font = CHART_FONT + for axis in plot.xaxis + plot.yaxis: + axis.axis_label_text_font = CHART_FONT + axis.axis_label_text_font_size = CHART_FONT_SIZE + axis.axis_label_text_font_style = "normal" + axis.major_label_text_font = CHART_FONT + axis.major_label_text_font_size = CHART_FONT_SIZE + for legend in plot.legend: + legend.label_text_font = CHART_FONT + legend.label_text_font_size = CHART_FONT_SIZE + + +def theme_color(color: str, dark: bool) -> str: + """Color as displayed on the current theme.""" + if not dark and color.upper() == "#FFFFFF": + return LIGHT_THEME_WHITE_REPLACEMENT + return color + + +# Display names of the devices chosen by the user (device_id -> alias), used on the +# whole plan page instead of the device ids +DEVICE_ALIAS_MAX_LEN = EOSDASH_ALIAS_MAX_LEN + +# Bokeh renders tooltips in a shadow DOM that the page theme does not reach. On the +# dark theme this hover callback adds a dark style sheet to the tooltips of the page. +DARK_TOOLTIP_JS = """ +const now = Date.now(); +if (window.eosdashDarkTooltip && now - window.eosdashDarkTooltip < 100) return; +window.eosdashDarkTooltip = now; +const css = ":host { --tooltip-color: #262626; --tooltip-text: #e5e5e5; " + + "--tooltip-border: #555555; --tooltip-arrow-color: #555555; }"; +function walk(root) { + root.querySelectorAll("*").forEach((e) => { + if (!e.shadowRoot) return; + if (e.classList.contains("bk-Tooltip") && !e.shadowRoot.eosdashDark) { + const sheet = new CSSStyleSheet(); + sheet.replaceSync(css); + e.shadowRoot.adoptedStyleSheets = [...e.shadowRoot.adoptedStyleSheets, sheet]; + e.shadowRoot.eosdashDark = true; + } + walk(e.shadowRoot); + }); +} +walk(document); +""" + + +def tooltip_callback(dark: bool) -> Optional[CustomJS]: + """Hover callback that styles the tooltips for the current theme.""" + return CustomJS(code=DARK_TOOLTIP_JS) if dark else None + + +def tooltip_text(text: str) -> str: + """Text for a Bokeh HTML tooltip template, without template fields.""" + return html.escape(text).replace("@", "@").replace("$", "$") + + +# Visibility, aliases, colors and styles ("line" or "bar") of the solution columns chosen +# by the user are stored in the EOS configuration (eosdash/plan). The EMS plan commands +# (operation modes) are stored under mode keys ("mode_SELF_CONSUMPTION"): they apply to +# the plan timeline and to the operation mode factor columns of all devices in the +# solution chart. +MODE_KEY_PREFIX = EOSDASH_MODE_KEY_PREFIX + +# Collapsible elements of the plan page (marked with data-keep-open); their open state +# is sent with every action so they stay open when the page is rendered again. +plan_open_sections: set[str] = set() + +# EMS plan table with the instruction cards, collapsed by default. The user can choose +# to have it open by default (option in the gear menu of the table). The units of the +# solution chart are options in the gear menu of the chart. +PLAN_TABLE_ID = "ems-plan-table" +PLAN_OPTIONS = ( + "plan_table_open", + # Units of the solution chart (gear menu of the chart) + "energy_kwh", + "factor_percent", + "price_minor_unit", +) +# The gear menu of the plan table is only shown while the table is open +PLAN_TABLE_STYLE = f"#{PLAN_TABLE_ID}:not([open]) + .plan-table-options {{ display: none; }}" + +# Close open popover menus (details marked with data-popover) on a click outside of them. +# Registered once per browser page, the plan page content is rendered again and again. +PLAN_POPOVER_SCRIPT = """ +if (!window.eosdashPopoverClose) { + window.eosdashPopoverClose = true; + document.addEventListener("click", (evt) => { + document.querySelectorAll("details[data-popover][open]").forEach((menu) => { + if (!menu.contains(evt.target)) menu.open = false; + }); + }); +} +""" + + +# Charts of the plan page with a common time axis (Bokeh figure name) +TIME_AXIS_FIGURE_NAME = "eosdash-time-axis" + +# Align the time axes of the plan page charts: the frames (plot areas) of all figures +# named TIME_AXIS_FIGURE_NAME get the same left and right edge on the page, by +# enlarging the smaller borders. Elements marked with data-time-axis (the KPIs) get +# margins so they span the same edges, the legends below the charts get columns over +# the full width of the time axis. Runs after the charts are rendered and on resize. +# Zoom and pan of the solution chart are applied to the other charts. +PLAN_ALIGN_SCRIPT = ( + """ +if (!window.eosdashAlign) { + const figures = () => { + const views = []; + if (window.Bokeh && Bokeh.index) { + for (const view of Bokeh.index) { + if (view.model && view.model.name === "%s" && view.frame && view.el.isConnected) { + views.push(view); + } + } + } + return views; + }; + window.eosdashAlign = (pass = 0) => { + const views = figures(); + if (!views.length) return; + const edges = views.map((view) => { + const box = view.el.getBoundingClientRect(); + return { + width: box.width, + left: box.left + view.frame.bbox.left, + right: box.left + view.frame.bbox.right, + }; + }); + const left = Math.max(...edges.map((e) => e.left)); + const right = Math.min(...edges.map((e) => e.right)); + let changed = false; + views.forEach((view, i) => { + const grow_left = left - edges[i].left; + const grow_right = edges[i].right - right; + if (grow_left > 0.5) { + view.model.min_border_left = Math.round(view.frame.bbox.left + grow_left); + changed = true; + } + if (grow_right > 0.5) { + const border = + view.model.min_border_right ?? edges[i].width - view.frame.bbox.right; + view.model.min_border_right = Math.round(border + grow_right); + changed = true; + } + }); + // Check again after Bokeh has done the new layout + if (changed && pass < 4) { + setTimeout(() => window.eosdashAlign(pass + 1), 150); + return; + } + for (const view of views) { + const legend = view.model.below.find((model) => model.type === "Legend"); + if (!legend || !legend.items.length) continue; + const items = legend.items.length; + const ncols = legend.ncols === "auto" ? items : Math.min(legend.ncols, items); + const width = view.frame.bbox.width - 2 * legend.margin - 2 * legend.padding; + const column = (width - (ncols - 1) * legend.spacing) / ncols; + const label_width = Math.floor(column - legend.glyph_width - legend.label_standoff); + if (label_width > 0 && legend.label_width !== label_width) { + legend.label_width = label_width; + } + } + document.querySelectorAll("[data-time-axis]").forEach((el) => { + const parent = el.parentElement.getBoundingClientRect(); + const style = getComputedStyle(el.parentElement); + const inner_left = parent.left + parseFloat(style.paddingLeft); + const inner_right = parent.right - parseFloat(style.paddingRight); + el.style.marginLeft = Math.max(0, Math.round(left - inner_left)) + "px"; + el.style.marginRight = Math.max(0, Math.round(inner_right - right)) + "px"; + }); + }; + window.eosdashSyncRange = (range) => { + for (const view of figures()) { + if (view.model.x_range !== range) { + view.model.x_range.start = range.start; + view.model.x_range.end = range.end; + } + } + }; + // Wait until the charts are rendered + window.eosdashAlignSchedule = () => { + let tries = 0; + const timer = setInterval(() => { + tries += 1; + const views = figures(); + const rendered = views.every((view) => view.frame.bbox.width > 0); + // Both charts, or only one if the other one is missing (e.g. no plan) + if (rendered && (views.length >= 2 || (views.length && tries > 12))) { + clearInterval(timer); + window.eosdashAlign(); + } else if (tries > 40) { + clearInterval(timer); + } + }, 250); + }; + // Elements that become visible (totals view) are aligned too + document.addEventListener("toggle", () => window.eosdashAlign(), true); + let resize_timer = null; + window.addEventListener("resize", () => { + clearTimeout(resize_timer); + resize_timer = setTimeout(() => window.eosdashAlign(), 250); + }); +} +window.eosdashAlignSchedule(); +""" + % TIME_AXIS_FIGURE_NAME +) + + +def plan_hx_vals(**vals: str) -> str: + """Htmx values of a plan page control, including dark mode and open sections.""" + js_vals = "".join(f"{json.dumps(k)}: {json.dumps(v)}, " for k, v in vals.items()) + return ( + "js:{ " + + js_vals + + '"dark": window.matchMedia("(prefers-color-scheme: dark)").matches, ' + + '"open": Array.from(document.querySelectorAll("details[data-keep-open][open]"))' + + '.map((e) => e.id).join(",") }' + ) + + +def plan_settings() -> EOSdashPlanSettings: + """Plan page settings from the EOS configuration, defaults if there are none.""" + config = eosstatus.eos_config + if config is None or config.eosdash is None: + return EOSdashPlanSettings() + return config.eosdash.plan + + +def plan_settings_put(server: str, key: str, value: Any) -> None: + """Store a plan page setting in the EOS configuration (eosdash/plan/). + + The configuration returned by EOS replaces the one of EOSdash, so the setting + applies at once. + """ + try: + result = requests.put(f"{server}/v1/config/eosdash/plan/{key}", json=value, timeout=10) + result.raise_for_status() + eosstatus.eos_config = SettingsEOS(**result.json()) + except Exception as e: + logger.warning(f"Can not store plan setting {key}: {e}") + + +def solution_default_color(col: str, config: Optional[SettingsEOS]) -> Optional[str]: + """Default color of a solution column, None if there is no specific default. + + Device columns get their color by device type. + """ + if col in solution_color_default: + return solution_color_default[col] + devices = config.devices if config else None + if devices: + for device_configs, suffix, color in ( + (devices.batteries, "_soc_factor", solution_color_battery_soc), + (devices.electric_vehicles, "_soc_factor", solution_color_ev_soc), + (devices.home_appliances, "_energy_wh", solution_color_appliance_energy), + ): + for device in (device_configs or {}).values(): + if col == f"{device.device_id}{suffix}": + return color + return None + + +def mode_key(mode: str) -> str: + """Settings key of an EMS plan command (operation mode).""" + return f"{MODE_KEY_PREFIX}{mode}" + + +def solution_column_color(col: str) -> str: + """Color of a solution column or mode key, user choice first.""" + color = plan_settings().color.get(col) + if color: + return color + if col.startswith(MODE_KEY_PREFIX): + return plan_mode_color.get(col[len(MODE_KEY_PREFIX) :], plan_mode_color_default) + return solution_color.get(col, plan_mode_color_default) + + +def solution_column_style(col: str) -> str: + """Style of a solution column: "line" or "bar", user choice first.""" + return plan_settings().style.get(col) or solution_style_default.get(col, "line") + + +def solution_column_width(col: str) -> int: + """Line width of a solution column, user choice first.""" + return int(plan_settings().width.get(col) or SOLUTION_LINE_WIDTH) + + +def solution_column_dash(col: str, is_prediction: bool) -> str: + """Line dash of a solution column: "solid", "dashed" or "dotted", user choice first.""" + dash = plan_settings().line_dash.get(col) or solution_dash_default.get(col) + if dash: + return dash + if col.endswith("_soc_factor"): + return "dashed" + if is_prediction: + return "dotted" + return "solid" + + +# Value axes of the solution chart in display order: energy is the main axis +SOLUTION_AXES = ("energy", "power", "factor", "amt_kwh", "amt") +SOLUTION_AXES_LEFT = ("energy", "power") + + +# Totals view of the solution chart: display order and icon by column name prefix +SOLUTION_TOTALS_ICONS = ( + ("grid_consumption", "factory"), + ("pvforecast", "sun"), + ("grid_feedin", "utility-pole"), + ("load", "house"), + ("losses", "flame"), + ("costs", "banknote"), + ("revenue", "coins"), +) +SOLUTION_TOTALS_ID = "solution-totals" +# Show the icon of the view that a click on the toggle opens +SOLUTION_TOTALS_STYLE = ( + f"#{SOLUTION_TOTALS_ID}:not([open]) .totals-hide, " + f"#{SOLUTION_TOTALS_ID}[open] .totals-show {{ display: none; }}" +) + + +def solution_total_icon(col: str, config: Optional[SettingsEOS]) -> tuple[float, str]: + """Display order and icon of a column in the totals view.""" + for rank, (prefix, icon) in enumerate(SOLUTION_TOTALS_ICONS): + if col.startswith(prefix): + return rank, icon + devices = config.devices if config else None + if devices: + # Devices after the grid feed-in + for device_configs, icon in ( + (devices.batteries, "battery-charging"), + (devices.electric_vehicles, "car"), + (devices.home_appliances, "washing-machine"), + ): + for device in (device_configs or {}).values(): + if col.startswith(f"{device.device_id}_"): + return 2.5, icon + return len(SOLUTION_TOTALS_ICONS), "zap" + + +def config_currency(config: Optional[SettingsEOS]) -> Optional[str]: + """ISO 4217 code of the currency at the configured location, if known. + + The configuration has no currency setting. The currency is derived from the country + of the configured timezone (e.g. Europe/Berlin -> DE -> EUR). + """ + timezone = config.general.timezone if config and config.general else None + territory = get_global("zone_territories").get(timezone) if timezone else None + currencies = get_territory_currencies(territory) if territory else [] + return currencies[0] if currencies else None + + +# Symbols of the minor unit of some currencies, other currencies are shown as e.g. "1/100 SEK" +CURRENCY_MINOR_UNIT_SYMBOLS = { + "EUR": "ct", + "CHF": "Rp.", + "GBP": "p", + "USD": "ยข", +} + + +def currency_minor_unit(currency: Optional[str]) -> Optional[tuple[str, int]]: + """Symbol and factor of the minor unit of a currency, None if it has none or is unknown. + + The number of minor unit digits comes from CLDR (e.g. EUR -> 2, JPY -> 0). + """ + if not currency: + return None + digits = get_currency_precision(currency) + if digits <= 0: + return None + factor = 10**digits + return CURRENCY_MINOR_UNIT_SYMBOLS.get(currency, f"1/{factor} {currency}"), factor + + +def column_axis(col: str) -> Optional[str]: + """Value axis of a solution column, None for an unknown unit.""" + if col.endswith("power_w"): + return "power" + if col.endswith("energy_wh"): + return "energy" + if col.endswith("factor") or col.endswith("mode"): + return "factor" + if col.endswith("amt_kwh"): + return "amt_kwh" + if col.endswith("amt"): + return "amt" + return None + + +def plan_option(option: str) -> bool: + """Value of a plan page option, user choice first.""" + return bool(getattr(plan_settings(), option)) + + +def solution_column_visible(col: str) -> bool: + """Visibility of a solution column, user choice first.""" + visible = plan_settings().visible.get(col) + if visible is not None: + return bool(visible) + return solution_visible_default.get(col, False) or col.endswith( + solution_visible_default_suffixes + ) + + +def alias_clean(alias: str) -> str: + """Alias without control characters and repeated whitespace, limited in length.""" + alias = "".join(c for c in alias if c.isprintable()) + return " ".join(alias.split())[:DEVICE_ALIAS_MAX_LEN].strip() + + +def device_alias(device_id: str) -> str: + """Display name of a device: the user chosen alias or the device id.""" + return alias_clean(plan_settings().device_alias.get(device_id, "")) or device_id + + +def config_device_ids(config: Optional[SettingsEOS]) -> list[str]: + """Device ids of the configuration: batteries, cars, appliances.""" + device_ids: list[str] = [] + if config and config.devices: + for devices in ( + config.devices.batteries, + config.devices.electric_vehicles, + config.devices.home_appliances, + ): + if devices: + device_ids.extend(device.device_id for device in devices.values()) + return device_ids + + +def op_factor_device_mode(col: str, device_ids: list[str]) -> Optional[tuple[str, str]]: + """Device id and operation mode of an operation mode factor column, else None.""" + suffix = "_op_factor" + if not col.endswith(suffix): + return None + # Longest id first, so "ev11" is not taken for "ev1" + for device_id in sorted(device_ids, key=len, reverse=True): + if col.startswith(f"{device_id}_"): + return device_id, col[len(device_id) + 1 : -len(suffix)].upper() + # Unknown device: longest mode first, so "forced_run" is not taken for "run" + for mode in sorted(plan_mode_color, key=len, reverse=True): + if col.endswith(f"_{mode.lower()}{suffix}"): + return col[: -len(f"_{mode.lower()}{suffix}")], mode + return None + + +def column_key(col: str, device_ids: list[str]) -> str: + """Settings key of a solution column: the mode key for operation mode factors.""" + device_mode = op_factor_device_mode(col, device_ids) + return mode_key(device_mode[1]) if device_mode else col + + +def plan_mode_label(mode: str) -> str: + """Display name of an EMS plan command: the user chosen alias or the mode.""" + return alias_clean(plan_settings().alias.get(mode_key(mode), "")) or mode + + +def column_label(col: str, device_ids: list[str]) -> str: + """Display name of a solution column. + + The user chosen alias first. Otherwise the device id prefix is replaced by the + device alias, operation mode factors are named " ". + """ + alias = alias_clean(plan_settings().alias.get(col, "")) + if alias: + return alias + device_mode = op_factor_device_mode(col, device_ids) + if device_mode: + return f"{device_alias(device_mode[0])} {plan_mode_label(device_mode[1])}" + # Longest id first, so "ev11" is not taken for "ev1" + for device_id in sorted(device_ids, key=len, reverse=True): + if col.startswith(f"{device_id}_"): + alias = alias_clean(plan_settings().device_alias.get(device_id, "")) + if alias: + return f"{alias} {col[len(device_id) + 1 :]}" + break + return col + + +# Approximate pixel sizes to decide whether a bar label fits into its bar +PLAN_CHART_WIDTH_PX = 1000 +PLAN_LABEL_CHAR_PX = 6.5 + +HEX_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$") + def validate_source(source: ColumnDataSource, x_col: str = "date_time") -> None: data = source.data @@ -111,6 +707,46 @@ def validate_source(source: ColumnDataSource, x_col: str = "date_time") -> None: raise ValueError(f"Column '{col}' contains only None/NaN or is empty.") +def solution_settings_action(server: str, data: dict) -> None: + """Store a setting changed with the controls of a solution column or command.""" + action = data.get("action", None) + col = data.get("renderer", None) + if not col: + return + settings = plan_settings() + if action == "visible": + plan_settings_put( + server, "visible", {**settings.visible, col: bool(data.get(f"{col}-visible", False))} + ) + elif action == "color": + color = str(data.get("color", "")) + if HEX_COLOR_RE.match(color): + plan_settings_put(server, "color", {**settings.color, col: color.upper()}) + elif action == "style": + style = str(data.get("style", "")) + if style in ("line", "bar"): + plan_settings_put(server, "style", {**settings.style, col: style}) + elif action == "width": + try: + width = int(data.get("width", 0)) + except (TypeError, ValueError): + width = 0 + if width in SOLUTION_LINE_WIDTHS: + plan_settings_put(server, "width", {**settings.width, col: width}) + elif action == "dash": + dash = str(data.get("dash", "")) + if dash in SOLUTION_LINE_DASHES: + plan_settings_put(server, "line_dash", {**settings.line_dash, col: dash}) + elif action == "alias": + alias = alias_clean(str(data.get("alias", ""))) + aliases = {k: v for k, v in settings.alias.items() if k != col} + if alias: + aliases[col] = alias + plan_settings_put(server, "alias", aliases) + elif action == "reset": + plan_settings_put(server, "color", {k: v for k, v in settings.color.items() if k != col}) + + def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Optional[dict]) -> Grid: """Creates a optimization solution card. @@ -118,14 +754,7 @@ def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Opti data (Optional[dict]): Incoming data containing action and category for processing. """ global colors, color_palette - category = "solution" dark = False - if data and data.get("category", None) == category: - # This data is for us - if data.get("action", None) == "visible": - renderer = data.get("renderer", None) - if renderer: - solution_visible[renderer] = bool(data.get(f"{renderer}-visible", False)) if data and data.get("dark", None) == "true": dark = True @@ -162,8 +791,13 @@ def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Opti prediction_columns_to_join = prediction_df.columns.difference(df.columns) df = df.join(prediction_df[prediction_columns_to_join], how="inner") + # Show energy flowing out of the house below the zero axis + for col in solution_negative_columns: + if col in df.columns: + df[col] = 0.0 - df[col] # 0.0 - x avoids -0.0 in the mouseover + # Exclude columns that currently do not have a value - excludes = solution_excludes + excludes = list(solution_excludes) for instruction in instruction_columns: if instruction.endswith("op_mode") and df[instruction].eq(0).all(): # Exclude op_mode and op_factor if all op_mode is 0 @@ -171,8 +805,6 @@ def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Opti excludes.append(f"{instruction[:-4]}factor") # Make bokey plot in local time at location - # Determine daylight saving time change - dst_offsets = df.index.map(lambda x: x.dst().total_seconds() / 3600) # Determine desired timezone if config.general is None or config.general.timezone is None: date_time_tz = "Europe/Berlin" @@ -183,136 +815,65 @@ def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Opti pd.to_datetime(df["date_time"], utc=True).dt.tz_convert(date_time_tz).dt.tz_localize(None) ) - # There is a special case if we have daylight saving time change in the time series - if dst_offsets.nunique() > 1: - date_time_tz += " + DST change" + # Units chosen in the gear menu of the chart + energy_kwh = plan_option("energy_kwh") + factor_percent = plan_option("factor_percent") + currency = config_currency(config) + currency_unit = currency or "Amount" + minor_unit = currency_minor_unit(currency) + price_minor = minor_unit if plan_option("price_minor_unit") else None + for col in df.columns: + if energy_kwh and col.endswith("energy_wh"): + df[col] = df[col] / 1000.0 + elif factor_percent and col.endswith("factor"): + df[col] = df[col] * 100.0 + elif price_minor and col.endswith("amt_kwh"): + df[col] = df[col] * price_minor[1] + price_unit = f"{price_minor[0] if price_minor else currency_unit}/kWh" + axis_labels = { + "energy": "Energy [kWh]" if energy_kwh else "Energy [Wh]", + "power": "Power [W]", + "factor": "Factor [%]" if factor_percent else "Factor [0.0..1.0]", + "amt_kwh": f"Electricity Price [{price_unit}]", + "amt": f"Amount [{currency_unit}]", + } + # Unit and number format in the mouseover + axis_units = { + "energy": ("kWh", "0.00") if energy_kwh else ("Wh", "0"), + "power": ("W", "0"), + "factor": ("%", "0") if factor_percent else ("", "0.00"), + "amt_kwh": (price_unit, "0.0") if price_minor else (f"{currency or ''}/kWh", "0.00"), + "amt": (currency or "", "0.00"), + } source = ColumnDataSource(df) validate_source(source) - # Calculate minimum and maximum Range - power_w_min = 0.0 - power_w_max = 0.0 - energy_wh_min = 0.0 - energy_wh_max = 0.0 - amt_kwh_min = 0.0 - amt_kwh_max = 0.0 - amt_min = 0.0 - amt_max = 0.0 - factor_min = 0.0 - factor_max = 1.0 - for col in df.columns: - if col.endswith("power_w"): - power_w_min = min(power_w_min, float(df[col].min())) - power_w_max = max(power_w_max, float(df[col].max())) - if col.endswith("energy_wh"): - energy_wh_min = min(energy_wh_min, float(df[col].min())) - energy_wh_max = max(energy_wh_max, float(df[col].max())) - elif col.endswith("amt_kwh"): - amt_kwh_min = min(amt_kwh_min, float(df[col].min())) - amt_kwh_max = max(amt_kwh_max, float(df[col].max())) - elif col.endswith("amt"): - amt_min = min(amt_min, float(df[col].min())) - amt_max = max(amt_max, float(df[col].max())) - else: - continue - # Adjust to similar y-axis 0-point - values_min_max = [ - (power_w_min, power_w_max), - (energy_wh_min, energy_wh_max), - (amt_kwh_min, amt_kwh_max), - (amt_min, amt_max), - (factor_min, factor_max), - ] - # First get the maximum factor for the min value related the maximum value - min_max_factor = 0.0 - for value_min, value_max in values_min_max: - if value_max > 0: - value_factor = (value_min * -1.0) / value_max - if value_factor > min_max_factor: - min_max_factor = value_factor - - # Adapt the min values to have the same relative min/max factor on all y-axis - power_w_min = min_max_factor * power_w_max * -1.0 - energy_wh_min = min_max_factor * energy_wh_max * -1.0 - amt_kwh_min = min_max_factor * amt_kwh_max * -1.0 - amt_min = min_max_factor * amt_max * -1.0 - factor_min = min_max_factor * factor_max * -1.0 - # add 5% to min and max values for better display - power_w_range_orig = power_w_max - power_w_min - power_w_max += 0.05 * power_w_range_orig - power_w_min -= 0.05 * power_w_range_orig - energy_wh_range_orig = energy_wh_max - energy_wh_min - energy_wh_max += 0.05 * energy_wh_range_orig - energy_wh_min -= 0.05 * energy_wh_range_orig - amt_kwh_range_orig = amt_kwh_max - amt_kwh_min - amt_kwh_max += 0.05 * amt_kwh_range_orig - amt_kwh_min -= 0.05 * amt_kwh_range_orig - amt_range_orig = amt_max - amt_min - amt_max += 0.05 * amt_range_orig - amt_min -= 0.05 * amt_range_orig - factor_range_orig = factor_max - factor_min - factor_max += 0.05 * factor_range_orig - factor_min -= 0.05 * factor_range_orig - if eosstatus.eos_health is not None: last_run_datetime = eosstatus.eos_health["energy-management"]["last_run_datetime"] - start_datetime = eosstatus.eos_health["energy-management"]["start_datetime"] else: last_run_datetime = "unknown" - start_datetime = "unknown" - plot = create_figure( - title=f"Optimization Solution - last run: {last_run_datetime}", - x_axis_type="datetime", - x_axis_label=f"Datetime [localtime {date_time_tz}] - start: {start_datetime}", - y_axis_label="Power [W]", - sizing_mode="stretch_width", - y_range=Range1d(start=power_w_min, end=power_w_max), - height=400, - ) + # Step of the solution, bars cover the interval that starts at date_time + step = df["date_time_local"].diff().median() + step_ms = pd.Timedelta(step).total_seconds() * 1000 if pd.notna(step) else 0.0 + if not step_ms: + step_ms = BAR_WIDTH_1HOUR - plot.extra_y_ranges = { - "energy": Range1d(start=energy_wh_min, end=energy_wh_max), # y2 - "factor": Range1d(start=factor_min, end=factor_max), # y3 - "amt_kwh": Range1d(start=amt_kwh_min, end=amt_kwh_max), # y4 - "amt": Range1d(start=amt_min, end=amt_max), # y5 - } - # y2 axis - y2_axis = LinearAxis(y_range_name="energy", axis_label="Energy [Wh]") - plot.add_layout(y2_axis, "left") - # y3 axis - y3_axis = LinearAxis(y_range_name="factor", axis_label="Factor [0.0..1.0]") - plot.add_layout(y3_axis, "left") - # y4 axis - y4_axis = LinearAxis(y_range_name="amt_kwh", axis_label="Electricty Price [Amount/kWh]") - y4_axis.axis_label_text_color = "red" - plot.add_layout(y4_axis, "right") - # y5 axis - y5_axis = LinearAxis(y_range_name="amt", axis_label="Amount [Amount]") - plot.add_layout(y5_axis, "right") - - plot.toolbar.autohide = True - - # Create line renderers for each column - renderers = {} + # Settings, color and value axis of the columns + device_ids = config_device_ids(config) + renderer_colors: dict[str, str] = {} + column_settings: dict[str, tuple[str, Optional[str], bool]] = {} # Have an index for the colors of predictions, solutions and instructions. prediction_color_idx = 0 solution_color_idx = int(len(colors) * 0.33) + 1 instruction_color_idx = int(len(colors) * 0.66) + 1 - for i, col in enumerate(sorted(df.columns)): + for col in sorted(df.columns): # Exclude some columns that are currently not used or are covered by others if any(exclude in col for exclude in excludes): continue - if col in solution_visible: - visible = solution_visible[col] - else: - visible = False - solution_visible[col] = visible - if col in solution_color: - color = solution_color[col] - else: + if col not in solution_color: if col in prediction_columns: color = colors[prediction_color_idx % len(colors)] prediction_color_idx += 3 @@ -322,188 +883,878 @@ def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Opti else: color = colors[instruction_color_idx % len(colors)] instruction_color_idx += 3 - # Remember the color of this column - solution_color[col] = color - if col in prediction_columns: - line_dash = "dotted" - else: - line_dash = "solid" - if visible: - if col.endswith("power_w"): - r = plot.step( - x="date_time_local", - y=col, - mode="after", + # Remember the default color of this column + solution_color[col] = solution_default_color(col, config) or color_palette[color] + # Operation mode factors share the settings of their EMS plan command + key = column_key(col, device_ids) + axis = column_axis(col) + if axis is None: + # Skip columns with unrecognized suffix rather than raising + logger.warning(f"Skipping column with unrecognized suffix: {col}") + renderer_colors[col] = theme_color(solution_column_color(key), dark) + column_settings[col] = (key, axis, axis is not None and solution_column_visible(key)) + + # Only the axes of the displayed columns. The zero line is at the same height on all + # axes, so values below zero (feed-in) read correctly on every axis. + axis_ranges: dict[str, list[float]] = {} + for col, (key, axis, visible) in column_settings.items(): + if visible and axis is not None: + low, high = axis_ranges.get(axis, [0.0, 0.0]) + axis_ranges[axis] = [min(low, float(df[col].min())), max(high, float(df[col].max()))] + if "factor" in axis_ranges: + axis_ranges["factor"][1] = max(axis_ranges["factor"][1], 100.0 if factor_percent else 1.0) + if not axis_ranges: + axis_ranges["energy"] = [0.0, 1.0] + for value_range in axis_ranges.values(): + if value_range[1] <= 0.0: + # Only values below zero, leave some room above + value_range[1] = -value_range[0] * 0.1 or 1.0 + below = max(-low / (high - low) for low, high in axis_ranges.values()) + for value_range in axis_ranges.values(): + low = -below / (1.0 - below) * value_range[1] + span = value_range[1] - low + # add 5% to min and max values for better display + value_range[0], value_range[1] = low - 0.05 * span, value_range[1] + 0.05 * span + shown_axes = [axis for axis in SOLUTION_AXES if axis in axis_ranges] + main_axis = shown_axes[0] + + plot = create_figure( + title=f"Optimization Solution - last run: {last_run_datetime}", + name=TIME_AXIS_FIGURE_NAME, + x_range=Range1d( + start=df["date_time_local"].min(), + end=df["date_time_local"].max() + pd.Timedelta(milliseconds=step_ms), + ), + x_axis_type="datetime", + y_axis_label=axis_labels[main_axis], + y_axis_location="left" if main_axis in SOLUTION_AXES_LEFT else "right", + sizing_mode="stretch_width", + y_range=Range1d(start=axis_ranges[main_axis][0], end=axis_ranges[main_axis][1]), + height=400, + ) + # Energy and power on the left, factors and amounts on the right + range_names = {main_axis: "default"} + plot.extra_y_ranges = { + axis: Range1d(start=axis_ranges[axis][0], end=axis_ranges[axis][1]) + for axis in shown_axes[1:] + } + for axis in shown_axes[1:]: + range_names[axis] = axis + plot.add_layout( + LinearAxis(y_range_name=axis, axis_label=axis_labels[axis]), + "left" if axis in SOLUTION_AXES_LEFT else "right", + ) + + plot.toolbar.autohide = True + + # Zoom and pan also move the plan timeline + sync_range = CustomJS(code="if (window.eosdashSyncRange) window.eosdashSyncRange(cb_obj);") + plot.x_range.js_on_change("start", sync_range) + plot.x_range.js_on_change("end", sync_range) + + # Bars cover the interval that starts at date_time (like step mode "after") + source.data["date_time_bar"] = df["date_time_local"] + pd.Timedelta(milliseconds=step_ms / 2) + + # Create line renderers for each column + renderers = {} + for col, (key, axis, visible) in column_settings.items(): + label = column_label(col, device_ids) + color = renderer_colors[col] + r: Optional[GlyphRenderer[Any]] + if visible and axis is not None: + if solution_column_style(key) == "bar": + r = plot.vbar( + x="date_time_bar", + top=col, + width=step_ms * 0.8, source=source, - legend_label=col, - color=color_palette[color], - line_dash=line_dash, - ) - elif col.endswith("energy_wh"): - r = plot.step( - x="date_time_local", - y=col, - mode="after", - source=source, - legend_label=col, - color=color_palette[color], - line_dash=line_dash, - y_range_name="energy", - ) - elif col.endswith("factor"): - r = plot.step( - x="date_time_local", - y=col, - mode="after", - source=source, - legend_label=col, - color=color_palette[color], - line_dash=line_dash, - y_range_name="factor", - ) - elif col.endswith("mode"): - r = plot.step( - x="date_time_local", - y=col, - mode="after", - source=source, - legend_label=col, - color=color_palette[color], - line_dash=line_dash, - y_range_name="factor", - ) - elif col.endswith("amt_kwh"): - r = plot.step( - x="date_time_local", - y=col, - mode="after", - source=source, - legend_label=col, - color=color_palette[color], - line_dash=line_dash, - y_range_name="amt_kwh", - ) - elif col.endswith("amt"): - r = plot.step( - x="date_time_local", - y=col, - mode="after", - source=source, - legend_label=col, - color=color_palette[color], - line_dash=line_dash, - y_range_name="amt", + legend_label=label, + fill_color=color, + fill_alpha=0.5, + line_color=color, + y_range_name=range_names[axis], ) else: - # Skip columns with unrecognized suffix rather than raising - logger.warning(f"Skipping column with unrecognized suffix: {col}") - r = None - + r = plot.step( + x="date_time_local", + y=col, + mode="after", + source=source, + legend_label=label, + color=color, + line_dash=solution_column_dash(key, col in prediction_columns), + line_width=solution_column_width(key), + y_range_name=range_names[axis], + ) else: r = None renderers[col] = r - plot.legend.visible = False # no legend at plot - bokey_apply_theme_to_plot(plot, dark) - # --- CheckboxGroup to toggle datasets --- - Checkbox = Grid( - Card( - Grid( - *[ - LabelCheckboxX( - label=renderer, - id=f"{renderer}-visible", - name=f"{renderer}-visible", - value="true", - checked=solution_visible[renderer], - hx_post=request_url_for("/eosdash/plan"), - hx_target="#page-content", - hx_swap="innerHTML", - hx_vals='js:{ "category": "solution", "action": "visible", "renderer": ' - + '"' - + f"{renderer}" - + '", ' - + '"dark": window.matchMedia("(prefers-color-scheme: dark)").matches ' - + "}", - lbl_cls=f"text-{solution_color[renderer]}", - ) - for renderer in list(renderers.keys()) - if renderer in prediction_columns - ], - cols=2, - ), - header=CardTitle("Prediction"), - ), - Card( - Grid( - *[ - LabelCheckboxX( - label=renderer, - id=f"{renderer}-visible", - name=f"{renderer}-visible", - value="true", - checked=solution_visible[renderer], - hx_post=request_url_for("/eosdash/plan"), - hx_target="#page-content", - hx_swap="innerHTML", - hx_vals='js:{ "category": "solution", "action": "visible", "renderer": ' - + '"' - + f"{renderer}" - + '", ' - + '"dark": window.matchMedia("(prefers-color-scheme: dark)").matches ' - + "}", - lbl_cls=f"text-{solution_color[renderer]}", - ) - for renderer in list(renderers.keys()) - if renderer in solution_columns - ], - cols=2, - ), - header=CardTitle("Solution"), - ), - Card( - Grid( - *[ - LabelCheckboxX( - label=renderer, - id=f"{renderer}-visible", - name=f"{renderer}-visible", - value="true", - checked=solution_visible[renderer], - hx_post=request_url_for("/eosdash/plan"), - hx_target="#page-content", - hx_swap="innerHTML", - hx_vals='js:{ "category": "solution", "action": "visible", "renderer": ' - + '"' - + f"{renderer}" - + '", ' - + '"dark": window.matchMedia("(prefers-color-scheme: dark)").matches ' - + "}", - lbl_cls=f"text-{solution_color[renderer]}", - ) - for renderer in list(renderers.keys()) - if renderer in instruction_columns - ], - cols=2, - ), - header=CardTitle("Instruction"), - ), - cols=1, + # Legend below the plot, with the aliases and colors of the displayed columns + if plot.legend: + legend = plot.legend[0] + plot.center.remove(legend) + legend.location = "center_left" + legend.orientation = "horizontal" + legend.ncols = 4 + legend.glyph_height = 10 + legend.glyph_width = 20 + legend.spacing = 4 + legend.border_line_color = None + plot.add_layout(legend, "below") + + # Mouseover shows the values of all displayed columns at the hovered time. + # An invisible line on the zero axis does the hit testing for all of them. + visible_columns = [col for col, r in renderers.items() if r is not None] + if visible_columns: + source.data["hover_y"] = [0.0] * len(df) + hover_renderer = plot.line( + x="date_time_local", + y="hover_y", + source=source, + line_alpha=0.0, + ) + + def tooltip_value(col: str) -> str: + unit, number_format = axis_units[str(column_settings[col][1])] + return f"@{{{col}}}{{{number_format}}} {unit}".rstrip() + + # Labels in the color of their graph, devices by alias, values with unit + tooltips = ( + f'
' + '
@date_time_local{%d.%m. %H:%M}
' + + "".join( + f'
' + f"{tooltip_text(column_label(col, device_ids))}: " + f"{tooltip_value(col)}
" + for col in visible_columns + ) + + "
" + ) + plot.add_tools( + HoverTool( + renderers=[hover_renderer], + mode="vline", + tooltips=tooltips, + formatters={"@date_time_local": "datetime"}, + callback=tooltip_callback(dark), + ) + ) + bokey_apply_theme_to_plot(plot, dark) + apply_chart_font(plot) + if plot.legend: + plot.legend[0].background_fill_color = "#1e1e1e" if dark else "white" + plot.legend[0].label_text_color = "white" if dark else "black" + # An axis with a single displayed column gets the color of that column + for y_axis in plot.yaxis: + axis_columns = [ + col + for col in visible_columns + if range_names.get(str(column_settings[col][1])) == y_axis.y_range_name + ] + if len(axis_columns) == 1: + color = renderer_colors[axis_columns[0]] + y_axis.axis_label_text_color = color + y_axis.major_label_text_color = color + y_axis.axis_line_color = color + y_axis.major_tick_line_color = color + y_axis.minor_tick_line_color = color + + # Totals of the displayed energy and amount columns over the time range of the chart + column_totals: list[tuple[float, str, str, str, float]] = [] + for col in visible_columns: + axis = column_settings[col][1] + if axis not in ("energy", "amt"): + # Factors and prices can not be added up + continue + total = float(df[col].sum()) + if col in solution_negative_columns: + # Shown below the zero axis in the chart, as a positive total here + total = 0.0 - total + rank, icon = solution_total_icon(col, config) + column_totals.append((rank, col, str(axis), icon, total)) + column_totals.sort(key=lambda t: t[0]) + total_max = { + axis: max([abs(t[4]) for t in column_totals if t[2] == axis] + [0.0]) + for axis in ("energy", "amt") + } + total_rows = [] + for rank, col, axis, icon, total in column_totals: + if axis == "energy": + value_text = f"{total:.2f} kWh" if energy_kwh else f"{total:.0f} Wh" + else: + value_text = f"{total:.2f} {currency}" if currency else f"{total:.2f}" + percent = abs(total) / total_max[axis] * 100.0 if total_max[axis] else 0.0 + total_rows.append( + (icon, column_label(col, device_ids), renderer_colors[col], value_text, percent) + ) + period_start = df["date_time_local"].min() + period_end = df["date_time_local"].max() + pd.Timedelta(milliseconds=step_ms) + totals_view = SolutionTotals( + total_rows, f"{period_start:%d.%m. %H:%M} - {period_end:%d.%m. %H:%M}", dark ) - return Grid( - Grid( - Bokeh(plot), - Card( - P(f"Total revenues: {solution.total_revenues_amt}"), - P(f"Total costs: {solution.total_costs_amt}"), - P(f"Total losses: {solution.total_losses_energy_wh / 1000} kWh"), - P(f"Fitness score: {solution.fitness_score}"), - ), - cols=1, + def amount(value: Any, scale: float = 1.0) -> str: + if value is None: + return "-" + if isinstance(value, (set, list, tuple)): + # fitness score is a set of fitness values + return ", ".join(amount(v, scale) for v in sorted(value)) or "-" + return f"{value * scale:.2f}" + + def money(value: Any) -> str: + text = amount(value) + return f"{text} {currency}" if currency and text != "-" else text + + totals = Card( + Div( + *[ + Div( + P(label, cls="text-xs opacity-70"), + P(value, cls="font-bold"), + cls="uk-text-center", + ) + for label, value in ( + ("Total revenues", money(solution.total_revenues_amt)), + ("Total costs", money(solution.total_costs_amt)), + ("Total losses", f"{amount(solution.total_losses_energy_wh, 0.001)} kWh"), + ("Fitness score", amount(solution.fitness_score)), + ) + ], + # Spans the time axis of the chart (PLAN_ALIGN_SCRIPT) + data_time_axis="true", + cls="grid grid-cols-4 gap-3", ), - Checkbox, - cls="w-full space-y-3 space-x-3", + ) + + # --- Collapsible sections to toggle, name, color and style the datasets --- + def column_rows(columns: list[str]) -> list[Div]: + return [ + SolutionColumnRow(col, column_label(col, device_ids), dark, col in prediction_columns) + for col in renderers + if col in columns + ] + + # Instruction: the EMS plan commands (operation modes) of the plan and of the + # operation mode factor columns, then the values of the optimizer + mode_columns: dict[str, list[str]] = {} + for col in renderers: + device_mode = op_factor_device_mode(col, device_ids) + if device_mode: + mode_columns.setdefault(device_mode[1], []).append(col) + plan_modes = [ + str(mode) + for mode in ( + getattr(instruction, "operation_mode_id", None) + for instruction in (eosstatus.eos_plan.instructions if eosstatus.eos_plan else []) + ) + if mode + ] + modes = list(dict.fromkeys(plan_modes + list(mode_columns))) + other_modes = [mode for mode in plan_mode_color if mode not in modes] + + def mode_row(mode: str) -> Div: + return SolutionColumnRow( + mode_key(mode), + plan_mode_label(mode), + dark, + False, + name=mode, + enabled=mode in mode_columns, + ) + + instruction_rows: list[Any] = [mode_row(mode) for mode in modes] + if other_modes: + instruction_rows.append( + Details( + Summary("More commands", cls="cursor-pointer text-xs opacity-70"), + Div(*[mode_row(mode) for mode in other_modes], cls="space-y-2 mt-2"), + id="solution-more-modes", + open="solution-more-modes" in plan_open_sections, + data_keep_open="true", + ) + ) + optimizer_rows = column_rows( + [col for col in instruction_columns if not op_factor_device_mode(col, device_ids)] + ) + if optimizer_rows: + instruction_rows += [P("Optimizer", cls="text-xs font-bold opacity-70 pt-2")] + instruction_rows += optimizer_rows + + devices = config_device_ids(config) + if eosstatus.eos_plan: + devices += [ + instruction.resource_id + for instruction in eosstatus.eos_plan.instructions + if instruction.id is not None + ] + devices = list(dict.fromkeys(devices)) + + sections = [ + ("solution-prediction", "Prediction", column_rows(prediction_columns)), + ("solution-solution", "Solution", column_rows(solution_columns)), + ("solution-instruction", "Instruction", instruction_rows), + ("solution-devices", "Devices", [DeviceRow(device_id) for device_id in devices]), + ] + # Commands under "More commands" are not counted + counts = {"solution-instruction": len(modes) + len(optimizer_rows)} + + chart_option_items = [("energy_kwh", "Energy in kWh"), ("factor_percent", "Factors in %")] + if minor_unit: + chart_option_items.append(("price_minor_unit", f"Electricity Price in {minor_unit[0]}/kWh")) + chart_options = PlanOptionsMenu("solution-chart-options", chart_option_items) + return Div( + totals, + Div( + Bokeh(plot), + totals_view, + # Top right corner of the chart background, left of the Bokeh toolbar + Div(chart_options, style="position: absolute; top: 21px; right: 48px; z-index: 20;"), + cls="relative", + ), + Div( + *[ + ConfigSection( + f"{title} ({counts.get(section_id, len(rows))})", + *rows, + open=section_id in plan_open_sections, + )(id=section_id, data_keep_open="true") + for section_id, title, rows in sections + ], + cls="grid grid-cols-4 gap-3 items-start", + ), + Script(PLAN_POPOVER_SCRIPT), + Script(PLAN_ALIGN_SCRIPT), + cls="w-full space-y-3", + ) + + +def SolutionTotals( + rows: list[tuple[str, str, str, str, float]], period: str, dark: bool +) -> Details: + """Totals of the solution chart as bars, shown instead of the chart. + + A click on the icon at the top left corner of the chart switches between the chart + and the totals. + + Args: + rows (list[tuple[str, str, str, str, float]]): Icon, label, color, value text and + bar length in percent of each displayed column. + period (str): Time range of the totals. + dark (bool): Dark theme. + """ + + def total_row(icon: str, label: str, color: str, value: str, percent: float) -> Div: + return Div( + Div( + DivLAligned( + UkIcon(icon=icon, height=20, width=20, style=f"color: {color};"), + P(label, title=label, style=f"color: {color};", cls="text-xs truncate"), + cls="gap-2 min-w-0", + ), + P(value, cls="text-sm font-bold uk-text-center"), + Div(), + cls="grid grid-cols-3 items-center", + ), + Div( + style=f"width: {max(percent, 0.5):.1f}%; height: 6px; border-radius: 3px; " + f"background: {color};" + ), + cls="space-y-1", + ) + + background = "#1e1e1e" if dark else "white" + return Details( + Summary( + UkIcon(icon="chart-bar", title="Show totals", cls="totals-show"), + UkIcon(icon="chart-line", title="Show chart", cls="totals-hide"), + # Top left corner of the chart background + style="position: absolute; top: 21px; left: 21px; z-index: 20; padding: 6px;", + cls="list-none cursor-pointer opacity-70 hover:opacity-100 border border-secondary " + "rounded-lg", + ), + Div( + P(f"Totals {period}", cls="text-xs font-bold opacity-70 uk-text-center"), + Div( + *[total_row(*row) for row in rows] + or [P("No energy or amount values displayed.", cls="text-xs uk-text-center")], + # Spans the time axis of the chart (PLAN_ALIGN_SCRIPT) + data_time_axis="true", + cls="space-y-3", + ), + # Covers the chart background + style=f"position: absolute; inset: 17px; z-index: 10; padding: 1.75rem 0 1rem 0; " + f"background: {background}; overflow-y: auto;", + cls="space-y-4", + ), + Style(SOLUTION_TOTALS_STYLE), + id=SOLUTION_TOTALS_ID, + open=SOLUTION_TOTALS_ID in plan_open_sections, + data_keep_open="true", + style="border: none;", + ) + + +def SolutionStyleMenu(col: str, is_prediction: bool) -> Details: + """Icon of the current display style; a click opens a menu for style, width and dash.""" + style = solution_column_style(col) + width = solution_column_width(col) + dash = solution_column_dash(col, is_prediction) + menu_id = f"{col}-style-menu" + + def option(action: str, value: Any, content: Any, selected: bool, title: str) -> FhButton: + return FhButton( + content, + title=title, + hx_post=request_url_for("/eosdash/plan"), + hx_target="#page-content", + hx_swap="innerHTML", + hx_vals=plan_hx_vals( + category="solution", action=action, renderer=col, **{action: str(value)} + ), + cls="px-2 py-1 rounded text-xs border " + + ("border-current font-bold" if selected else "border-transparent opacity-70"), + ) + + def dash_sample(value: str) -> Div: + return Div(style=f"width: 28px; border-top: 2px {value} currentColor;") + + rows = [ + DivLAligned( + option("style", "line", UkIcon(icon="chart-line"), style == "line", "Line"), + option("style", "bar", UkIcon(icon="chart-column"), style == "bar", "Bar"), + cls="gap-1", + ) + ] + if style == "line": + rows += [ + P("Width", cls="text-xs opacity-70 mt-2"), + DivLAligned( + *[ + option("width", w, str(w), w == width, f"Width {w}") + for w in SOLUTION_LINE_WIDTHS + ], + cls="gap-1", + ), + P("Line", cls="text-xs opacity-70 mt-2"), + DivLAligned( + *[ + option("dash", d, dash_sample(d), d == dash, d.capitalize()) + for d in SOLUTION_LINE_DASHES + ], + cls="gap-1", + ), + ] + return Details( + Summary( + UkIcon(icon="chart-line" if style == "line" else "chart-column"), + title="Display style", + cls="list-none cursor-pointer opacity-80 hover:opacity-100", + ), + Div( + *rows, + cls="absolute right-0 top-full z-50 mt-1 p-2 rounded-md border shadow-lg bg-background", + ), + id=menu_id, + open=menu_id in plan_open_sections, + data_keep_open="true", + data_popover="true", + cls="relative shrink-0", + ) + + +def AliasMenu(menu_id: str, name: str, alias: str, vals: dict[str, str]) -> Details: + """Alias icon; a click opens a dialog to enter the display name. + + Args: + menu_id (str): Id of the dialog element. + name (str): The id that gets the alias (column, mode or device id). + alias (str): The current alias, empty if there is none. + vals (dict[str, str]): htmx values that address the alias action. + """ + return Details( + Summary( + UkIcon(icon="tag", height=16, width=16), + title=f"Alias: {alias}" if alias else "Alias", + cls="list-none cursor-pointer " + + ("opacity-100" if alias else "opacity-50 hover:opacity-100"), + ), + Div( + P(f"Alias for {name}", cls="text-xs opacity-70 truncate"), + Form( + Input( + type="text", + name="alias", + value=alias, + placeholder=name, + maxlength=str(DEVICE_ALIAS_MAX_LEN), + cls="uk-input h-7 px-2 text-xs w-full", + ), + DivLAligned( + FhButton( + "Remove", + type="button", + hx_post=request_url_for("/eosdash/plan"), + hx_target="#page-content", + hx_swap="innerHTML", + hx_params="not alias", + hx_vals=plan_hx_vals(close=menu_id, **vals), + cls="text-xs underline opacity-70 hover:opacity-100" + + ("" if alias else " invisible"), + ), + FhButton("Save", type="submit", cls="uk-btn uk-btn-primary uk-btn-sm"), + cls="justify-between", + ), + hx_post=request_url_for("/eosdash/plan"), + hx_target="#page-content", + hx_swap="innerHTML", + hx_vals=plan_hx_vals(close=menu_id, **vals), + cls="space-y-2", + ), + cls="absolute right-0 top-full z-50 mt-1 p-2 w-64 space-y-2 rounded-md border " + "shadow-lg bg-background", + ), + id=menu_id, + open=menu_id in plan_open_sections, + data_keep_open="true", + data_popover="true", + cls="relative shrink-0", + ) + + +def DeviceRow(device_id: str) -> Div: + """Creates the alias control of one device.""" + alias = plan_settings().device_alias.get(device_id, "") + return Div( + P(alias or device_id, title=device_id, cls="text-xs flex-1 min-w-0 truncate"), + P(device_id, cls="text-xs opacity-50 shrink-0") if alias else None, + AliasMenu( + f"device-{device_id}-alias-menu", + device_id, + alias, + {"category": "device_alias", "action": "alias", "device": device_id}, + ), + cls="flex items-center gap-2", + ) + + +def SolutionColumnRow( + col: str, + label: str, + dark: bool, + is_prediction: bool, + name: Optional[str] = None, + enabled: bool = True, +) -> Div: + """Creates the controls of one solution column or EMS plan command. + + Visibility, alias, color and display style. + + Args: + col (str): Settings key, the column name or the mode key of a command. + label (str): Display name. + dark (bool): Dark theme. + is_prediction (bool): Prediction columns are dotted by default. + name (Optional[str]): The id that gets the alias, defaults to the column. + enabled (bool): Whether there is something to display in the solution chart. + """ + name = name or col + is_user_color = col in plan_settings().color + color = solution_column_color(col) + return Div( + CheckboxX( + id=f"{col}-visible", + name=f"{col}-visible", + value="true", + checked=enabled and solution_column_visible(col), + disabled=not enabled, + title=None if enabled else "Not in the current solution", + hx_post=request_url_for("/eosdash/plan"), + hx_target="#page-content", + hx_swap="innerHTML", + hx_vals=plan_hx_vals(category="solution", action="visible", renderer=col), + ), + FormLabel( + label, + fr=f"{col}-visible", + title=name, + style=f"color: {theme_color(color, dark)}", + cls="text-xs flex-1 min-w-0 truncate", + ), + AliasMenu( + f"{col}-alias-menu", + name, + plan_settings().alias.get(col, ""), + {"category": "solution", "action": "alias", "renderer": col}, + ), + Input( + type="color", + name="color", + value=color, + title="Color", + hx_post=request_url_for("/eosdash/plan"), + hx_trigger="change", + hx_target="#page-content", + hx_swap="innerHTML", + hx_vals=plan_hx_vals(category="solution", action="color", renderer=col), + cls="h-6 w-8 shrink-0 cursor-pointer border-0 bg-transparent p-0", + ), + SolutionStyleMenu(col, is_prediction), + FhButton( + UkIcon(icon="refresh-ccw", height=14, width=14), + title="Reset color to default", + hx_post=request_url_for("/eosdash/plan"), + hx_target="#page-content", + hx_swap="innerHTML", + hx_vals=plan_hx_vals(category="solution", action="reset", renderer=col), + cls="shrink-0 opacity-60 hover:opacity-100" + ("" if is_user_color else " invisible"), + ), + cls="flex items-center gap-2", + ) + + +def plan_color(mode: str) -> str: + """Color of an operation mode, user choice first.""" + return solution_column_color(mode_key(mode)) + + +def PlanChartCard( + plan: EnergyManagementPlan, + solution: OptimizationSolution, + config: SettingsEOS, + data: Optional[dict], +) -> Div: + """Creates a timeline chart of the plan instructions, one row per resource. + + Each instruction is active from its execution time until the next instruction + of the same resource. The last instruction of a resource is active until the + end of the optimization solution. + + Args: + plan (EnergyManagementPlan): The energy management plan. + solution (OptimizationSolution): The optimization solution (gives the plan end). + config (SettingsEOS): The EOS configuration. + data (Optional[dict]): Incoming data containing the dark mode flag. + + Returns: + Div: The plan timeline chart. + """ + dark = bool(data and data.get("dark", None) == "true") + + # Determine desired timezone + if config.general is None or config.general.timezone is None: + date_time_tz = "Europe/Berlin" + else: + date_time_tz = config.general.timezone + + def to_local(dt: Any) -> pd.Timestamp: + return pd.Timestamp(to_datetime(dt)).tz_convert(date_time_tz).tz_localize(None) + + # Collect the instructions per resource, the latest instruction wins on equal times + resource_steps: dict[str, dict[pd.Timestamp, EnergyManagementInstruction]] = {} + for instruction in plan.instructions: + if instruction.id is None: + continue + resource_id = instruction.resource_id + resource_steps.setdefault(resource_id, {})[to_local(instruction.execution_time)] = ( + instruction + ) + if not resource_steps: + raise ValueError("Plan has no instructions.") + + # The timeline shows the time range of the solution chart, so both time axes align + solution_times = pd.to_datetime(solution.solution.to_dataframe()["date_time"], utc=True) + solution_times = solution_times.dt.tz_convert(date_time_tz).dt.tz_localize(None) + step = solution_times.diff().median() + if pd.isna(step): + step = pd.Timedelta(hours=1) + plan_start = solution_times.min() + plan_end = solution_times.max() + step + + # Order the rows like the devices in the configuration: batteries, cars, appliances + device_order = config_device_ids(config) + resources = [r for r in device_order if r in resource_steps] + resources += sorted(r for r in resource_steps if r not in resources) + + # Rows are named by alias, the names must be unique + resource_labels: dict[str, str] = {} + for resource_id in resources: + label = device_alias(resource_id) + if label in resource_labels.values(): + label = f"{label} ({resource_id})" + resource_labels[resource_id] = label + + total_ms = (plan_end - plan_start).total_seconds() * 1000 + bars: dict[str, list] = { + k: [] for k in ("resource", "start", "end", "mode", "mode_label", "text", "color", "factor") + } + for resource_id in resources: + steps = sorted(resource_steps[resource_id].items()) + for idx, (start, instruction) in enumerate(steps): + end = steps[idx + 1][0] if idx + 1 < len(steps) else plan_end + # Cut to the displayed time range + start, end = max(start, plan_start), min(end, plan_end) + if end <= start: + continue + mode = str(getattr(instruction, "operation_mode_id", None) or instruction.type) + factor = getattr(instruction, "operation_mode_factor", None) + mode_label = plan_mode_label(mode) + label = mode_label if mode_label != mode else mode.lower() + if factor is not None and 0.0 < factor < 1.0: + label += f" {factor * 100:.0f}%" + # Only print the label if it fits into the bar + bar_px = (end - start).total_seconds() * 1000 / total_ms * PLAN_CHART_WIDTH_PX + text = label if bar_px > len(label) * PLAN_LABEL_CHAR_PX + 12 else "" + bars["resource"].append(resource_labels[resource_id]) + bars["start"].append(start) + bars["end"].append(end) + bars["mode"].append(mode) + bars["mode_label"].append(mode_label) + bars["text"].append(text) + bars["color"].append(theme_color(plan_color(mode), dark)) + bars["factor"].append("" if factor is None else f"{factor:.2f}") + source = ColumnDataSource(pd.DataFrame(bars)) + + background = "#1e1e1e" if dark else "white" + plot = create_figure( + title="EMS Plan", + name=TIME_AXIS_FIGURE_NAME, + x_axis_type="datetime", + y_range=FactorRange(factors=[resource_labels[r] for r in reversed(resources)]), + x_range=Range1d(start=plan_start, end=plan_end), + sizing_mode="stretch_width", + height=90 + 45 * len(resources), + toolbar_location=None, + ) + bars_renderer = plot.hbar( + y="resource", + left="start", + right="end", + height=0.75, + color="color", + line_color=background, + line_width=3, + legend_group="mode_label", + source=source, + ) + bars_renderer.glyph.update(border_radius=5) + plot.text( + x="start", + y="resource", + text="text", + x_offset=8, + text_baseline="middle", + text_color="white", + text_font=bokeh_value(CHART_FONT), # a plain string would be read as a column name + text_font_size="12px", + source=source, + ) + plot.add_tools( + HoverTool( + tooltips=( + f'
' + '
@resource
' + '
■ @mode_label @factor
' + '
@start{%d.%m. %H:%M} - @end{%d.%m. %H:%M}
' + "
" + ), + formatters={"@start": "datetime", "@end": "datetime"}, + callback=tooltip_callback(dark), + ) + ) + plot.ygrid.grid_line_color = None + plot.yaxis.axis_line_color = None + plot.yaxis.major_tick_line_color = None + plot.outline_line_color = None + + # Move the legend below the plot + legend = plot.legend[0] + plot.center.remove(legend) + legend.orientation = "horizontal" + legend.location = "center_left" + legend.glyph_height = 10 + legend.glyph_width = 20 + legend.border_line_color = None + plot.add_layout(legend, "below") + + bokey_apply_theme_to_plot(plot, dark) + apply_chart_font(plot) + # Device names larger than the axis numbers + plot.yaxis.major_label_text_font_size = "14px" + legend.background_fill_color = background + legend.label_text_color = "white" if dark else "black" + + return Div(Bokeh(plot), cls="w-full") + + +def PlanOptionsMenu(menu_id: str, options: list[tuple[str, str]]) -> Details: + """Gear icon; a click opens a menu with plan page options. + + Args: + menu_id (str): Id of the menu element. + options (list[tuple[str, str]]): Option names (PLAN_OPTIONS) and their labels. + """ + return Details( + Summary( + UkIcon(icon="settings", height=16, width=16), + title="Options", + style="padding: 6px;", + cls="list-none cursor-pointer opacity-70 hover:opacity-100", + ), + Div( + P("Options", cls="text-sm font-bold"), + *[ + DivLAligned( + CheckboxX( + id=option, + name=option, + value="true", + checked=plan_option(option), + hx_post=request_url_for("/eosdash/plan"), + hx_target="#page-content", + hx_swap="innerHTML", + hx_vals=plan_hx_vals(category="plan_option", option=option), + ), + FormLabel(label, fr=option, cls="text-xs"), + cls="gap-2", + ) + for option, label in options + ], + cls="absolute right-0 top-full z-50 mt-1 p-2 w-56 space-y-2 rounded-md border " + "shadow-lg bg-background", + ), + id=menu_id, + open=menu_id in plan_open_sections, + data_keep_open="true", + data_popover="true", + cls="relative", + ) + + +def PlanTable(cards: list[Any], open: bool) -> Div: + """Creates the collapsible EMS plan table with the instruction cards. + + While the table is open a gear menu at the top right offers the table options. + + Args: + cards (list[Any]): The instruction cards. + open (bool): Whether the table is rendered open. + + Returns: + Div: The collapsible plan table with its options menu. + """ + options = PlanOptionsMenu(f"{PLAN_TABLE_ID}-options", [("plan_table_open", "Open by default")]) + return Div( + Style(PLAN_TABLE_STYLE), + ConfigSection(f"Energy Management Plan ({len(cards)})", *cards, open=open)( + id=PLAN_TABLE_ID, data_keep_open="true" + ), + Div( + options, + cls="plan-table-options", + style="position: absolute; top: 1.25rem; right: 1rem;", + ), + cls="relative", ) @@ -571,11 +1822,14 @@ def InstructionCard( summary = summary or "" summary_detail = "" + mode = "" if isinstance(instruction, OMBCInstruction): - summary = f"{instruction.operation_mode_id}" + mode = f"{instruction.operation_mode_id}" + summary = plan_mode_label(mode) summary_detail = f"{instruction.operation_mode_factor:.2f}" elif isinstance(instruction, (DDBCInstruction, FRBCInstruction)): - summary = f"{instruction.operation_mode_id}" + mode = f"{instruction.operation_mode_id}" + summary = plan_mode_label(mode) summary_detail = f"{instruction.operation_mode_factor}" return Card( @@ -588,10 +1842,10 @@ def InstructionCard( P(execution_time), ), DivLAligned( - P(resource_id), + P(device_alias(resource_id), title=resource_id), ), ), - P(summary), + P(summary, title=mode or None), P(summary_detail), ), cls="list-none", @@ -616,10 +1870,21 @@ def Plan(eos_host: str, eos_port: Union[str, int], data: Optional[dict] = None) Returns: Div: A `Div` component containing the assembled admin interface. """ + global plan_open_sections server = f"http://{eos_host}:{eos_port}" print("Plan: ", data) + # Collapsible elements that the user has open (sent by the plan page controls). + # Without (page load) the elements get their default open state. + keep_open_state = bool(data and "open" in data) + if data and "open" in data: + plan_open_sections = {s for s in str(data["open"]).split(",") if s} + elif data: + plan_open_sections = set() + if data and data.get("close"): + plan_open_sections.discard(str(data["close"])) + if ( eosstatus.eos_config is None or eosstatus.eos_solution is None @@ -673,11 +1938,50 @@ def Plan(eos_host: str, eos_port: Union[str, int], data: Optional[dict] = None) return Error(warning_msg) eosstatus.eos_plan = EnergyManagementPlan(**plan_json, data=data) + # Device alias changed in the plan settings menu + if data and data.get("category", None) == "device_alias" and data.get("action") == "alias": + device_id = str(data.get("device", "")) + known = set(config_device_ids(eosstatus.eos_config)) | { + i.resource_id for i in eosstatus.eos_plan.instructions if i.id is not None + } + if device_id in known: + alias = alias_clean(str(data.get("alias", ""))) + aliases = {k: v for k, v in plan_settings().device_alias.items() if k != device_id} + if alias and alias != device_id: + aliases[device_id] = alias + plan_settings_put(server, "device_alias", aliases) + + # Setting of a solution column or command changed + if data and data.get("category", None) == "solution": + solution_settings_action(server, data) + + # Option changed in a gear menu + if data and data.get("category", None) == "plan_option": + option = str(data.get("option", "")) + if option in PLAN_OPTIONS: + plan_settings_put(server, option, bool(data.get(option, False))) + rows = [ SolutionCard(eosstatus.eos_solution, eosstatus.eos_config, data=data), ] - for instruction in eosstatus.eos_plan.instructions: - rows.append(InstructionCard(instruction, eosstatus.eos_config, data=data)) + try: + rows.append( + PlanChartCard( + eosstatus.eos_plan, eosstatus.eos_solution, eosstatus.eos_config, data=data + ) + ) + except Exception as e: + logger.warning(f"Can not create plan chart: {e}") + rows.append(Error(f"Can not create plan chart: {e}")) + if keep_open_state: + table_open = PLAN_TABLE_ID in plan_open_sections + else: + table_open = plan_option("plan_table_open") + cards = [ + InstructionCard(instruction, eosstatus.eos_config, data=data) + for instruction in eosstatus.eos_plan.instructions + ] + rows.append(PlanTable(cards, table_open)) return Div(*rows, cls="space-y-4") # return Div(f"Plan:\n{json.dumps(plan_json, indent=4)}") diff --git a/src/akkudoktoreos/server/eosdash.py b/src/akkudoktoreos/server/eosdash.py index bacc90ef..7c30ee8e 100644 --- a/src/akkudoktoreos/server/eosdash.py +++ b/src/akkudoktoreos/server/eosdash.py @@ -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 diff --git a/tests/test_eosdashsettings.py b/tests/test_eosdashsettings.py new file mode 100644 index 00000000..143e4de1 --- /dev/null +++ b/tests/test_eosdashsettings.py @@ -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" diff --git a/tests/testdata/docs/_generated/config.md b/tests/testdata/docs/_generated/config.md index 522b0069..7d89c5cb 100644 --- a/tests/testdata/docs/_generated/config.md +++ b/tests/testdata/docs/_generated/config.md @@ -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