Files
EOS/src/akkudoktoreos/server/dash/plan.py
T
Bobby NoelteandGitHub 894790f577 feat: add pvlib pv forecast provider (#1214)
Add a PV forecast provider that calculates the forecast using a PVLib system model
and weather forecast from the EOS weather forecast provider.

Additional module and inverter models can be easily added as the database
is build from PVLib and SAM databases and a bundled csv file.

The module model and inververt model names are provided by new endpoints
to be used in configuration.

The provider is based on the fantastic work of EMHASS. See
https://github.com/davidusb-geek/emhass/blob/master/src/emhass/forecast.py

A short description of the provider is added to the documentation.

Besides the new features there are the fixes and improvements:

* feat: improve EOSdash config page

* fix: kex_to_series for start_datetime

  Make key_to_series always start the series at start_datetime.

* fix: default provider for GENETIC and GENETIC0 optimization

  To make the default less dependent on internet servers (with API changes and
  availability issues) the default for PVForecast is set to PVForecastPVLib
  and for ElecPrice to ElecPriceFixed. The default weather provider is changed
  to OpenMeteo.

* fix: EOSdash display resampled prediction values

  Make EOSdash display resampled prediction values where  resampling fits to
  the prediction value type. Use bar width that fits to 15 minutes value samples.

* chore: add a UI hints system to EOSdash

  The UI hints system eases the definition of forms for configuration items.
  There are also forms for items in maps and lists. These forms allow to add and delete
  items to/ from  maps and lists. The forms ensure that all required fields of newly
  added items are filled.

* chore: Create an enum for valid optimization algorithms

* chore. Make config also provide the available energy management modes.

  Used for configuration hints.

* chore: Randomize default device id in configuration

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
2026-08-07 13:13:17 +02:00

680 lines
25 KiB
Python

from typing import Optional, Union
import pandas as pd
import requests
from bokeh.models import ColumnDataSource, LinearAxis, Range1d
from bokeh.plotting import figure
from loguru import logger
from monsterui.franken import (
Card,
CardTitle,
Details,
Div,
DivLAligned,
Grid,
LabelCheckboxX,
P,
Summary,
UkIcon,
)
import akkudoktoreos.server.dash.eosstatus as eosstatus
from akkudoktoreos.config.config import SettingsEOS
from akkudoktoreos.core.emplan import (
DDBCInstruction,
EnergyManagementInstruction,
EnergyManagementPlan,
FRBCInstruction,
OMBCInstruction,
)
from akkudoktoreos.optimization.optimization import OptimizationSolution
from akkudoktoreos.server.dash.bokeh import Bokeh, bokey_apply_theme_to_plot
from akkudoktoreos.server.dash.components import Error
from akkudoktoreos.server.dash.context import request_url_for
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime
# bar width for 1 hour bars (time given in millseconds)
BAR_WIDTH_1HOUR = 1000 * 60 * 60
# Tailwind compatible color palette
color_palette = {
"red-500": "#EF4444", # red-500
"orange-500": "#F97316", # orange-500
"amber-500": "#F59E0B", # amber-500
"yellow-500": "#EAB308", # yellow-500
"lime-500": "#84CC16", # lime-500
"green-500": "#22C55E", # green-500
"emerald-500": "#10B981", # emerald-500
"teal-500": "#14B8A6", # teal-500
"cyan-500": "#06B6D4", # cyan-500
"sky-500": "#0EA5E9", # sky-500
"blue-500": "#3B82F6", # blue-500
"indigo-500": "#6366F1", # indigo-500
"violet-500": "#8B5CF6", # violet-500
"purple-500": "#A855F7", # purple-500
"pink-500": "#EC4899", # pink-500
"rose-500": "#F43F5E", # rose-500
}
# Color names
colors = list(color_palette.keys())
# Colums that are exclude from the the solution card display
# They are currently not used or are covered by others
solution_excludes = [
"date_time",
"_op_mode",
"_fault_",
"_outage_supply_",
"_reserve_backup_",
"_ramp_rate_control_",
"_frequency_regulation_",
]
# Current state of solution displayed
solution_visible: dict[str, bool] = {
"pvforecast_power_w": True,
"elec_price_amt_kwh": True,
"feed_in_tariff_amt_kwh": True,
}
solution_color: dict[str, str] = {}
def validate_source(source: ColumnDataSource, x_col: str = "date_time") -> None:
data = source.data
# 1. Source has data at all
if not data:
raise ValueError("ColumnDataSource has no data.")
# 2. x_col must be present
if x_col not in data:
raise ValueError(f"Missing expected x-axis column '{x_col}' in source.")
# 3. All columns must have equal length
lengths = {len(v) for v in data.values()}
if len(lengths) != 1:
raise ValueError(f"ColumnDataSource columns have mismatched lengths: {lengths}")
# 4. Must have at least one non-x column
y_columns = [c for c in data.keys() if c != x_col]
if not y_columns:
raise ValueError("No y-value columns found for plotting (only x-axis present).")
# 5. Each y-column must have at least one valid value
for col in y_columns:
values = [v for v in data[col] if v is not None]
if not values:
raise ValueError(f"Column '{col}' contains only None/NaN or is empty.")
def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Optional[dict]) -> Grid:
"""Creates a optimization solution card.
Args:
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
df = solution.solution.to_dataframe()
if df.empty or len(df.columns) <= 1:
raise ValueError(
f"Solution DataFrame is empty or missing plottable columns: {list(df.columns)}"
)
if "date_time" not in df.columns:
raise ValueError(f"Solution DataFrame is missing column 'date_time': {list(df.columns)}")
solution_columns = list(df.columns)
instruction_columns = [
instruction
for instruction in solution_columns
if instruction.endswith("op_mode")
or instruction.endswith("op_factor")
or instruction.startswith("genetic_")
]
solution_columns = [x for x in solution_columns if x not in instruction_columns]
prediction_df = solution.prediction.to_dataframe()
if prediction_df.empty:
raise ValueError(
f"Prediction DataFrame is empty or missing plottable columns: {list(prediction_df.columns)}"
)
if "date_time" not in prediction_df.columns:
raise ValueError(
f"Prediction DataFrame is missing column 'date_time': {list(prediction_df.columns)}"
)
# Only plot if there are actual data columns beyond date_time
prediction_columns = [c for c in prediction_df.columns if c != "date_time"]
# No prediction data to plot — skip prediction section silently
if prediction_columns:
prediction_columns_to_join = prediction_df.columns.difference(df.columns)
df = df.join(prediction_df[prediction_columns_to_join], how="inner")
# Exclude columns that currently do not have a value
excludes = 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
excludes.append(instruction)
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"
else:
date_time_tz = config.general.timezone
# Ensure original date_time is parsed as UTC and convert to local time
df["date_time_local"] = (
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"
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 = 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(power_w_min, power_w_max),
height=400,
)
plot.extra_y_ranges = {
"energy": Range1d(energy_wh_min, energy_wh_max), # y2
"factor": Range1d(factor_min, factor_max), # y3
"amt_kwh": Range1d(amt_kwh_min, amt_kwh_max), # y4
"amt": Range1d(amt_min, 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 = {}
# 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)):
# 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 in prediction_columns:
color = colors[prediction_color_idx % len(colors)]
prediction_color_idx += 3
elif col in solution_columns:
color = colors[solution_color_idx % len(colors)]
solution_color_idx += 3
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",
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",
)
else:
# Skip columns with unrecognized suffix rather than raising
logger.warning(f"Skipping column with unrecognized suffix: {col}")
r = None
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,
)
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,
),
Checkbox,
cls="w-full space-y-3 space-x-3",
)
def InstructionCard(
instruction: EnergyManagementInstruction, config: SettingsEOS, data: Optional[dict]
) -> Card:
"""Creates a styled instruction card for displaying instruction details.
This function generates a instruction card that is displayed in the UI with
various sections such as instruction name, type, description, default value,
current value, and error details. It supports both read-only and editable modes.
Args:
instruction (EnergyManagementInstruction): The instruction.
data (Optional[dict]): Incoming data containing action and category for processing.
Returns:
Card: A styled Card component containing the instruction details.
"""
if instruction.id is None:
return Error("Instruction without id encountered. Can not handle")
idx = instruction.id.find("@")
resource_id = instruction.id[:idx] if idx != -1 else instruction.id
execution_time = to_datetime(instruction.execution_time, as_string=True)
description = instruction.type
summary = None
# Search an icon that fits to device_id
if (
config.devices
and config.devices.batteries
and any(
battery_config.device_id == resource_id for battery_config in config.devices.batteries
)
):
# This is a battery
if instruction.operation_mode_id in ("CHARGE",):
icon = "battery-charging"
else:
icon = "battery"
elif (
config.devices
and config.devices.electric_vehicles
and any(
electric_vehicle_config.device_id == resource_id
for electric_vehicle_config in config.devices.electric_vehicles
)
):
# This is a car battery
icon = "car"
elif (
config.devices
and config.devices.home_appliances
and any(
home_appliance.device_id == resource_id
for home_appliance in config.devices.home_appliances
)
):
# This is a home appliance
icon = "washing-machine"
else:
icon = "play"
# Initialize defaults so all code paths are covered
summary = summary or ""
summary_detail = ""
if isinstance(instruction, OMBCInstruction):
summary = f"{instruction.operation_mode_id}"
summary_detail = f"{instruction.operation_mode_factor:.2f}"
elif isinstance(instruction, (DDBCInstruction, FRBCInstruction)):
summary = f"{instruction.operation_mode_id}"
summary_detail = f"{instruction.operation_mode_factor}"
return Card(
Details(
Summary(
Grid(
Grid(
DivLAligned(
UkIcon(icon=icon),
P(execution_time),
),
DivLAligned(
P(resource_id),
),
),
P(summary),
P(summary_detail),
),
cls="list-none",
),
Grid(
P(description),
P("TBD"),
),
),
cls="w-full",
)
def Plan(eos_host: str, eos_port: Union[str, int], data: Optional[dict] = None) -> Div:
"""Generates the plan dashboard layout.
Args:
eos_host (str): The hostname of the EOS server.
eos_port (Union[str, int]): The port of the EOS server.
data (Optional[dict], optional): Incoming data to trigger plan actions. Defaults to None.
Returns:
Div: A `Div` component containing the assembled admin interface.
"""
server = f"http://{eos_host}:{eos_port}"
print("Plan: ", data)
if (
eosstatus.eos_config is None
or eosstatus.eos_solution is None
or eosstatus.eos_plan is None
or eosstatus.eos_health is None
or compare_datetimes(
to_datetime(eosstatus.eos_plan.generated_at),
to_datetime(eosstatus.eos_health["energy-management"]["last_run_datetime"]),
).lt
):
# Get current configuration from server
try:
result = requests.get(f"{server}/v1/config", timeout=10)
result.raise_for_status()
except requests.exceptions.HTTPError as err:
detail = result.json()["detail"]
return Error(f"Can not retrieve configuration from {server}: {err},\n{detail}")
eosstatus.eos_config = SettingsEOS(**result.json())
# Get the optimization solution
try:
result = requests.get(
f"{server}/v1/energy-management/optimization/solution", timeout=10
)
result.raise_for_status()
solution_json = result.json()
except requests.exceptions.HTTPError as e:
detail = result.json()["detail"]
warning_msg = f"Can not retrieve optimization solution from {server}: {e},\n{detail}"
logger.warning(warning_msg)
return Error(warning_msg)
except Exception as e:
warning_msg = f"Can not retrieve optimization solution from {server}: {e}"
logger.warning(warning_msg)
return Error(warning_msg)
eosstatus.eos_solution = OptimizationSolution(**solution_json)
# Get the plan
try:
result = requests.get(f"{server}/v1/energy-management/plan", timeout=10)
result.raise_for_status()
plan_json = result.json()
except requests.exceptions.HTTPError as e:
detail = result.json()["detail"]
warning_msg = f"Can not retrieve plan from {server}: {e},\n{detail}"
logger.warning(warning_msg)
return Error(warning_msg)
except Exception as e:
warning_msg = f"Can not retrieve plan from {server}: {e}"
logger.warning(warning_msg)
return Error(warning_msg)
eosstatus.eos_plan = EnergyManagementPlan(**plan_json, data=data)
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))
return Div(*rows, cls="space-y-4")
# return Div(f"Plan:\n{json.dumps(plan_json, indent=4)}")