mmmapms's picture
Update app.py
8a87804 verified
# dashboard_app.py
# Streamlit dashboard for DA price forecasting (BE) β€” Deterministic only
from __future__ import annotations
import io
import os
from pathlib import Path
from typing import Optional, Tuple, List
import numpy as np
import pandas as pd
import requests
import streamlit as st
import plotly.graph_objs as go
from sklearn.metrics import mean_absolute_error
# ==============================================================================
# Page config + CSS
# ==============================================================================
data = "operational" # or "historical"
st.set_page_config(
page_title="Day-Ahead Electricity Price Forecasting | Belgium",
page_icon="πŸ“Š",
layout="wide",
initial_sidebar_state="collapsed",
)
st.markdown(
"""
<style>
:root {
--bg: #f5f6f8;
--card: #ffffff;
--text: #1f2937;
--muted: #6b7280;
--line: #d6dbe3;
--soft: #eef1f5;
--soft-hover: #e5e9ef;
}
.stApp {
background-color: var(--bg);
}
.main, .block-container {
background-color: var(--bg);
}
.block-container {
padding-top: 3.2rem;
padding-bottom: 2rem;
max-width: 1500px;
}
h1, h2, h3, h4 {
color: var(--text);
letter-spacing: -0.02em;
font-weight: 700;
}
p, li, label, .stMarkdown, .stCaption {
color: var(--text);
font-size: 15px;
}
.app-header {
background: linear-gradient(135deg, #ffffff 0%, #f4f7fb 100%);
border: 1px solid var(--line);
border-radius: 18px;
padding: 1.2rem 1.4rem;
margin-bottom: 1.25rem;
box-shadow: 0 4px 18px rgba(15, 23, 42, 0.04);
}
.small-muted {
color: var(--muted);
font-size: 0.92rem;
}
/* Main cards / containers */
div[data-testid="stVerticalBlockBorderWrapper"] {
background: #ffffff;
border: 1px solid var(--line) !important;
border-radius: 18px !important;
padding: 1rem 1rem 0.75rem 1rem !important;
box-shadow: 0 4px 18px rgba(15, 23, 42, 0.04);
margin-bottom: 1rem;
}
/* Dataframes */
div[data-testid="stDataFrame"] {
border-radius: 16px;
overflow: hidden;
border: 1px solid var(--line);
background: #ffffff;
}
.stDataFrame table, .stDataFrame th, .stDataFrame td {
font-size: 14px !important;
}
/* Alerts */
div[data-testid="stInfo"] {
border-radius: 14px;
background-color: #f3f4f6 !important;
color: var(--text) !important;
border: 1px solid var(--line) !important;
}
/* SELECTBOX */
div[data-baseweb="select"] > div {
background: var(--soft) !important;
border: 1px solid var(--line) !important;
border-radius: 12px !important;
min-height: 44px !important;
box-shadow: none !important;
}
div[data-baseweb="select"] * {
color: var(--text) !important;
}
div[data-baseweb="popover"] {
background: #ffffff !important;
border-radius: 12px !important;
}
ul[role="listbox"] {
background: #ffffff !important;
border: 1px solid var(--line) !important;
border-radius: 12px !important;
padding: 0.25rem !important;
}
li[role="option"] {
background: #ffffff !important;
color: var(--text) !important;
border-radius: 8px !important;
}
li[role="option"]:hover {
background: var(--soft-hover) !important;
}
/* INPUTS */
div[data-testid="stNumberInput"] input,
div[data-testid="stTextInput"] input {
background-color: var(--soft) !important;
color: var(--text) !important;
border: 1px solid var(--line) !important;
border-radius: 12px !important;
}
/* SLIDER */
.stSlider > div[data-baseweb="slider"] {
padding-top: 0.4rem;
}
.stSlider [data-baseweb="slider"] > div > div {
color: var(--text) !important;
}
/* Make slider value bubble / labels lighter if shown */
.stSlider * {
color: var(--text) !important;
}
/* Tabs, toggles, pills-like controls */
button[kind="secondary"],
button[data-baseweb="tab"] {
background-color: var(--soft) !important;
color: var(--text) !important;
border: 1px solid var(--line) !important;
border-radius: 10px !important;
}
/* General buttons */
.stButton > button {
background-color: var(--soft) !important;
color: var(--text) !important;
border: 1px solid var(--line) !important;
border-radius: 12px !important;
}
.stButton > button:hover {
background-color: var(--soft-hover) !important;
border-color: #c8d0da !important;
}
</style>
""",
unsafe_allow_html=True,
)
# ==============================================================================
# Config
# ==============================================================================
OWNER = "margaridamascarenhas"
REPO = "DAM_Forecast_V4"
BRANCH = "main"
FORECAST_PATH = "Forecast"
DATASETS_PATH = "datasets"
if data == "historical":
LEAR_FILE = "hist_LEAR_forecasts_10AM.csv"
XGB_FILE = "hist_XGB_forecasts_10AM.csv"
else:
LEAR_FILE = "LEAR_forecasts_10AM.csv"
XGB_FILE = "XGB_forecasts_10AM.csv"
ACTUAL_FILE = "Data_BE_UTC.csv"
TOKEN = os.getenv("GitHub_Token_Margarida")
if not TOKEN:
st.error("Missing required secret: GitHub_Token_Margarida")
st.stop()
# ==============================================================================
# Helpers: IO + coercion
# ==============================================================================
def fetch_csv_from_github(
owner: str,
repo: str,
branch: str,
path: str,
fname: str,
token: Optional[str],
) -> pd.DataFrame:
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{path}/{fname}"
headers = {"Authorization": f"Bearer {token}"} if token else {}
resp = requests.get(raw_url, headers=headers, timeout=30)
resp.raise_for_status()
return pd.read_csv(io.StringIO(resp.text))
def force_float_series(s: pd.Series) -> pd.Series:
out = pd.to_numeric(s, errors="coerce")
return out.astype(float)
def ensure_dt_index(df: pd.DataFrame, dt_col: str = "DateTime") -> pd.DataFrame:
tmp = df.copy()
if dt_col not in tmp.columns:
raise ValueError(f"Missing '{dt_col}' column.")
tmp[dt_col] = pd.to_datetime(tmp[dt_col], errors="coerce")
tmp = tmp.dropna(subset=[dt_col]).set_index(dt_col)
tmp = tmp[~tmp.index.duplicated(keep="last")].sort_index()
tmp = tmp.apply(pd.to_numeric, errors="coerce")
return tmp
def process_actual_be_from_utc(df_utc: pd.DataFrame) -> pd.Series:
if "Date" not in df_utc.columns or "Price" not in df_utc.columns:
raise ValueError("Actual CSV must contain columns ['Date', 'Price'].")
tmp = df_utc.copy()
tmp["Date"] = pd.to_datetime(tmp["Date"], utc=True, errors="coerce")
tmp = tmp.dropna(subset=["Date"])
tmp["Date_BE"] = tmp["Date"].dt.tz_convert("Europe/Brussels").dt.tz_localize(None)
s = pd.Series(tmp["Price"].values, index=tmp["Date_BE"], name="actual")
s = force_float_series(s).dropna()
s = s[~s.index.duplicated(keep="last")].sort_index()
return s
def load_all_fixed() -> Tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
if data == "historical":
df_lear = pd.read_csv(LEAR_FILE)
df_xgb = pd.read_csv(XGB_FILE)
else:
df_lear = fetch_csv_from_github(OWNER, REPO, BRANCH, FORECAST_PATH, LEAR_FILE, TOKEN)
df_xgb = fetch_csv_from_github(OWNER, REPO, BRANCH, FORECAST_PATH, XGB_FILE, TOKEN)
df_act = fetch_csv_from_github(OWNER, REPO, BRANCH, DATASETS_PATH, ACTUAL_FILE, TOKEN)
return df_lear, df_xgb, df_act
# ==============================================================================
# Metrics helpers
# ==============================================================================
def naive_7d_mae_on_eval_index(actual_full: pd.Series, eval_index: pd.DatetimeIndex) -> float:
if actual_full.empty or len(eval_index) == 0:
return np.nan
a_full = force_float_series(actual_full).dropna().sort_index()
y_true = a_full.reindex(eval_index)
y_pred_vals = a_full.reindex(eval_index - pd.Timedelta(days=7)).to_numpy()
y_pred = pd.Series(y_pred_vals, index=eval_index, name="pred")
tmp = pd.concat([y_true.rename("true"), y_pred], axis=1).dropna()
if tmp.empty:
return np.nan
return mean_absolute_error(tmp["true"], tmp["pred"])
def get_eval_end_ts(actual: pd.Series, forecast_df: pd.DataFrame) -> pd.Timestamp:
if actual.empty or forecast_df.empty:
return pd.NaT
return min(actual.index.max(), forecast_df.index.max())
def evaluate_det_forecasts(
det_df: pd.DataFrame,
actual: pd.Series,
start_ts: pd.Timestamp,
end_ts: pd.Timestamp
) -> Tuple[pd.DataFrame, float]:
if pd.isna(end_ts):
out = pd.DataFrame(index=det_df.columns, data={"MAE": np.nan, "rMAE": np.nan})
return out, np.nan
a = actual.loc[(actual.index >= start_ts) & (actual.index <= end_ts)]
tmp = det_df.loc[(det_df.index >= start_ts) & (det_df.index <= end_ts)]
merged = tmp.join(a.rename("actual").to_frame(), how="inner")
if merged.empty:
out = pd.DataFrame(index=det_df.columns, data={"MAE": np.nan, "rMAE": np.nan})
return out, np.nan
merged = merged.apply(pd.to_numeric, errors="coerce")
nmae = naive_7d_mae_on_eval_index(actual, merged.index)
rows = []
for col in tmp.columns:
pair = merged[[col, "actual"]].dropna()
mae = mean_absolute_error(pair["actual"], pair[col]) if len(pair) else np.nan
rmae = mae / nmae if (np.isfinite(nmae) and nmae != 0) else np.nan
rows.append((col, mae, rmae))
metrics = pd.DataFrame(rows, columns=["Model", "MAE", "rMAE"]).set_index("Model")
return metrics, nmae
def build_inverse_mse_ensemble(det_df_list: List[pd.DataFrame], actual: pd.Series) -> pd.Series:
"""
Preserve the original daily inverse-MSE ensemble logic on the historical overlap,
then extend the ensemble to future forecast timestamps (e.g. tomorrow) using
the latest available weight vector.
This guarantees:
- historical ensemble values remain the same as before
- future timestamps are retained
"""
df_all = pd.concat(det_df_list, axis=1).sort_index()
if df_all.empty:
return pd.Series(dtype=float, name="Ensemble")
df_all = df_all.apply(pd.to_numeric, errors="coerce")
actual = force_float_series(actual).dropna().sort_index()
# ------------------------------------------------------------------
# 1) Historical part: EXACTLY the same logic as your original function
# ------------------------------------------------------------------
idx_hist = df_all.index.intersection(actual.index)
df_hist = df_all.loc[idx_hist]
a_hist = actual.loc[idx_hist]
if df_hist.empty:
n_models = df_all.shape[1]
return (df_all * (1.0 / n_models)).sum(axis=1).rename("Ensemble")
se = (df_hist.sub(a_hist, axis=0)).pow(2)
prev_date_key = se.index.normalize() - pd.Timedelta(days=1)
mse_by_prev_day = se.groupby(prev_date_key).mean()
prev_dates = pd.Series(prev_date_key, index=se.index)
mse_for_ts = mse_by_prev_day.reindex(prev_dates.values).set_index(prev_dates.index)
inv = 1.0 / mse_for_ts
inv = inv.replace([np.inf, -np.inf], np.nan).fillna(1.0)
w_hist = inv.div(inv.sum(axis=1), axis=0)
ensemble_hist = (w_hist * df_hist).sum(axis=1)
# ------------------------------------------------------------------
# 2) Future part: extend beyond actual horizon using latest weights
# ------------------------------------------------------------------
future_idx = df_all.index.difference(idx_hist)
df_future = df_all.loc[future_idx]
if df_future.empty:
return ensemble_hist.rename("Ensemble").sort_index()
# use the most recent historical weight vector
last_weights = w_hist.iloc[-1].copy()
# fallback if something weird happened
if last_weights.isna().all() or last_weights.sum() == 0:
last_weights = pd.Series(1.0 / df_all.shape[1], index=df_all.columns)
ensemble_future = (df_future * last_weights).sum(axis=1)
# ------------------------------------------------------------------
# 3) Combine historical + future
# ------------------------------------------------------------------
ensemble = pd.concat([ensemble_hist, ensemble_future]).sort_index().rename("Ensemble")
return ensemble
# ==============================================================================
# Styling / plotting helpers
# ==============================================================================
ACADEMIC_COLORS = {
"actual": "#2B2D42",
"lear": "#5C7DA5",
"xgb": "#8DA0B3",
"ensemble": "#748C70",
}
def apply_academic_layout(fig, title: str, yaxis_title: str = "EUR/MWh"):
fig.update_layout(
title=dict(
text=title,
x=0.01,
xanchor="left",
font=dict(size=20, family="Arial"),
),
template="plotly_white",
hovermode="x unified",
margin=dict(t=70, b=40, l=30, r=20),
paper_bgcolor="white",
plot_bgcolor="white",
legend=dict(
orientation="h",
yanchor="bottom",
y=1.02,
xanchor="right",
x=1,
bgcolor="rgba(255,255,255,0.7)",
),
xaxis=dict(
title="DateTime",
type="date",
showgrid=True,
gridcolor="rgba(148,163,184,0.18)",
zeroline=False,
rangeslider=dict(visible=False),
),
yaxis=dict(
title=yaxis_title,
showgrid=True,
gridcolor="rgba(148,163,184,0.18)",
zeroline=False,
),
)
return fig
def render_header():
col1, col2, col3 = st.columns([1.2, 4.8, 1.2])
with col1:
if Path("KU_Leuven_logo.png").exists():
st.image("KU_Leuven_logo.png")
with col2:
st.markdown(
"""
<div class="app-header">
<h1 style="margin-bottom:0.2rem;">Day-Ahead Electricity Price Forecasting</h1>
<p class="small-muted" style="margin-bottom:0.35rem;">
Belgium β€’ Deterministic forecast monitoring
</p>
<p style="margin-bottom:0;">
Interactive dashboard for comparing LEAR, XGB, and ensemble-based forecasts
against realized day-ahead electricity prices.
</p>
</div>
""",
unsafe_allow_html=True,
)
with col3:
if Path("energyville_logo.png").exists():
st.image("energyville_logo.png")
def _plot_empty(msg: str):
st.info(msg)
def get_plot_window_from_forecast(forecast_index: pd.DatetimeIndex, last_days: int) -> Tuple[pd.Timestamp, pd.Timestamp]:
if len(forecast_index) == 0:
return pd.NaT, pd.NaT
plot_end = forecast_index.max()
plot_start = plot_end - pd.Timedelta(days=last_days)
return plot_start, plot_end
def plot_two_series_allow_missing_actual(title: str, actual_s: pd.Series, forecast_s: pd.Series):
actual_s = force_float_series(actual_s).sort_index()
forecast_s = force_float_series(forecast_s).dropna().sort_index()
if forecast_s.empty:
_plot_empty("No forecast points available to plot.")
return
fig = go.Figure()
actual_non_na = actual_s.dropna()
if not actual_non_na.empty:
fig.add_trace(
go.Scatter(
x=actual_non_na.index.tolist(),
y=actual_non_na.values.tolist(),
mode="lines",
name="Actual",
line=dict(width=2.4, color=ACADEMIC_COLORS["actual"]),
)
)
forecast_name = str(forecast_s.name).lower()
if "lear" in forecast_name:
forecast_color = ACADEMIC_COLORS["lear"]
elif "xgb" in forecast_name:
forecast_color = ACADEMIC_COLORS["xgb"]
elif "ensemble" in forecast_name:
forecast_color = ACADEMIC_COLORS["ensemble"]
else:
forecast_color = "#6B7280"
fig.add_trace(
go.Scatter(
x=forecast_s.index.tolist(),
y=forecast_s.values.tolist(),
mode="lines",
name=str(forecast_s.name),
line=dict(width=2.2, color=forecast_color),
)
)
fig = apply_academic_layout(fig, title, "EUR/MWh")
fig.update_xaxes(rangeslider_visible=True)
st.plotly_chart(fig, use_container_width=True)
def plot_scatter_actual_vs_pred(title: str, actual_s: pd.Series, pred_s: pd.Series):
a = force_float_series(actual_s)
p = force_float_series(pred_s)
tmp = pd.concat([a.rename("a"), p.rename("p")], axis=1, join="inner").dropna()
if tmp.empty:
_plot_empty(f"{title}: no overlap points.")
return
pred_name = str(pred_s.name).lower()
if "lear" in pred_name:
point_color = ACADEMIC_COLORS["lear"]
elif "xgb" in pred_name:
point_color = ACADEMIC_COLORS["xgb"]
elif "ensemble" in pred_name:
point_color = ACADEMIC_COLORS["ensemble"]
else:
point_color = "#6B7280"
fig = go.Figure()
fig.add_trace(
go.Scatter(
x=tmp["a"].values.tolist(),
y=tmp["p"].values.tolist(),
mode="markers",
name="Points",
marker=dict(
size=7,
color=point_color,
opacity=0.65,
),
)
)
fig.update_layout(
title=title,
xaxis=dict(title="Actual (EUR/MWh)"),
yaxis=dict(title="Forecast (EUR/MWh)"),
margin=dict(t=60, b=40),
template="plotly_white",
paper_bgcolor="white",
plot_bgcolor="white",
)
st.plotly_chart(fig, use_container_width=True)
# ==============================================================================
# Table styling
# ==============================================================================
def _styler_base(df: pd.DataFrame) -> pd.io.formats.style.Styler:
fmt = {}
for c in df.columns:
if pd.api.types.is_numeric_dtype(df[c]):
if "rMAE" in c:
fmt[c] = "{:.3f}"
elif "MAE" in c:
fmt[c] = "{:.3f}"
else:
fmt[c] = "{:.3f}"
sty = df.style.format(fmt, na_rep="β€”")
sty = sty.set_table_styles(
[
{"selector": "th", "props": [("font-weight", "900"), ("text-align", "left"), ("font-size", "17px")]},
{"selector": "td", "props": [("padding", "10px 12px"), ("font-size", "17px")]},
{"selector": "table", "props": [("border-collapse", "collapse"), ("width", "100%")]},
]
)
return sty
def style_det_metrics(metrics: pd.DataFrame) -> pd.io.formats.style.Styler:
df = metrics.copy()
sty = _styler_base(df)
if "MAE" in df.columns and df["MAE"].notna().any():
best_idx = df["MAE"].astype(float).idxmin()
def highlight_best(row):
return ["background-color: rgba(34,197,94,0.14)" if row.name == best_idx else "" for _ in row]
sty = sty.apply(highlight_best, axis=1)
for col in ["MAE", "rMAE"]:
if col in df.columns and df[col].notna().any():
sty = sty.bar(subset=[col], align="mid")
return sty
# ==============================================================================
# Model-selection helper
# ==============================================================================
def get_selected_forecast_series(
model_family: str,
cw: Optional[str],
lear: pd.DataFrame,
xgb: pd.DataFrame,
ensemble: pd.Series,
) -> Tuple[pd.Series, str]:
if model_family == "Ensemble":
return ensemble.rename("Ensemble"), "Ensemble vs Actual"
df_det = lear if model_family == "LEAR" else xgb
colname = f"{model_family}_{cw}" if cw != "expanding" else f"{model_family}_expanding"
if colname not in df_det.columns:
raise ValueError(f"Column '{colname}' not found in {model_family} file.")
return df_det[colname].rename(colname), f"{colname} vs Actual"
# ==============================================================================
# Load + process
# ==============================================================================
render_header()
try:
df_lear_raw, df_xgb_raw, df_act_utc = load_all_fixed()
actual = process_actual_be_from_utc(df_act_utc)
lear = ensure_dt_index(df_lear_raw)
xgb = ensure_dt_index(df_xgb_raw)
except Exception as e:
st.error(f"Failed to load/process data: {e}")
st.stop()
forecast_start = min(lear.index.min(), xgb.index.min())
det_union = pd.concat([lear, xgb], axis=1)
forecast_end = get_eval_end_ts(actual, det_union)
ensemble = build_inverse_mse_ensemble([lear, xgb], actual)
# ==============================================================================
# Section 1: Main forecast plot
# ==============================================================================
with st.container(border=True):
st.subheader("Deterministic forecast analysis")
st.caption("Compare realized Belgian day-ahead prices against the selected forecast model.")
st.markdown(
"""
This view compares realized prices with the selected forecast trajectory.
For LEAR and XGB, the calibration window selects the corresponding trained variant.
The ensemble is shown as a single combined forecast, so no calibration-window choice is needed.
"""
)
c1, c2, c3 = st.columns([1.2, 1.2, 1.0])
with c1:
model_family_main = st.selectbox(
"Model family",
["Ensemble", "LEAR", "XGB"],
index=0,
key="model_family_main",
)
with c2:
if model_family_main == "Ensemble":
st.caption("Calibration window not applicable for Ensemble")
cw_main = None
else:
cw_main = st.selectbox(
"Calibration window",
["expanding", "56", "112"],
index=0,
key="cw_main",
)
with c3:
last_days = st.slider(
"Plot last N days",
1, 60, 14,
key="last_days_main",
)
try:
forecast_series_main, title_main = get_selected_forecast_series(
model_family=model_family_main,
cw=cw_main,
lear=lear,
xgb=xgb,
ensemble=ensemble,
)
except Exception as e:
st.error(str(e))
st.stop()
plot_start, plot_end = get_plot_window_from_forecast(forecast_series_main.index, last_days)
forecast_last = forecast_series_main.loc[
(forecast_series_main.index >= plot_start) & (forecast_series_main.index <= plot_end)
].rename(forecast_series_main.name)
actual_last = actual.reindex(forecast_last.index).rename("Actual")
plot_two_series_allow_missing_actual(title_main, actual_last, forecast_last)
# ==============================================================================
# Section 2: Scatter diagnostics
# ==============================================================================
with st.container(border=True):
st.subheader("Scatter diagnostics")
st.caption("Assess how closely the selected forecast aligns with realized prices.")
st.markdown(
"""
Each point represents one timestamp. A tighter point cloud indicates stronger agreement
between forecasted and realized prices, while wider dispersion suggests larger forecast error or bias.
"""
)
s1, s2, s3 = st.columns([1.2, 1.2, 1.0])
with s1:
model_family_scatter = st.selectbox(
"Model family",
["Ensemble", "LEAR", "XGB"],
index=0,
key="model_family_scatter",
)
with s2:
if model_family_scatter == "Ensemble":
st.caption("Calibration window not applicable for Ensemble")
cw_scatter = None
else:
cw_scatter = st.selectbox(
"Calibration window",
["expanding", "56", "112"],
index=0,
key="cw_scatter",
)
with s3:
diag_last_days = st.slider(
"Scatter: last N days",
7, 90, 30,
key="diag_last_days",
)
try:
forecast_series_scatter, _ = get_selected_forecast_series(
model_family=model_family_scatter,
cw=cw_scatter,
lear=lear,
xgb=xgb,
ensemble=ensemble,
)
except Exception as e:
st.error(str(e))
st.stop()
a_diag = actual.last(f"{diag_last_days}D")
tmp_scatter = pd.concat(
[
force_float_series(a_diag).rename("actual"),
force_float_series(forecast_series_scatter).rename("pred").loc[a_diag.index.min():],
],
axis=1,
join="inner",
).dropna()
if tmp_scatter.empty:
st.info("Scatter diagnostics: no overlap / all NaNs in selected window.")
else:
plot_scatter_actual_vs_pred(
f"Actual vs Forecast scatter β€” {forecast_series_scatter.name}",
tmp_scatter["actual"],
tmp_scatter["pred"],
)
# ==============================================================================
# Section 3: Metrics
# ==============================================================================
lear_metrics, naive_mae = evaluate_det_forecasts(lear, actual, start_ts=forecast_start, end_ts=forecast_end)
xgb_metrics, _ = evaluate_det_forecasts(xgb, actual, start_ts=forecast_start, end_ts=forecast_end)
a_eval = actual.loc[(actual.index >= forecast_start) & (actual.index <= forecast_end)]
e_eval = ensemble.loc[a_eval.index]
idx_eval = a_eval.dropna().index.intersection(e_eval.dropna().index)
if len(idx_eval) > 0:
ens_mae = mean_absolute_error(a_eval.loc[idx_eval], e_eval.loc[idx_eval])
ens_rmae = ens_mae / naive_mae if (np.isfinite(naive_mae) and naive_mae != 0) else np.nan
else:
ens_mae, ens_rmae = np.nan, np.nan
ens_metrics = pd.DataFrame({"MAE": [ens_mae], "rMAE": [ens_rmae]}, index=["Ensemble"])
with st.container(border=True):
st.subheader("Model accuracy summary")
st.caption(f"Evaluation window: {forecast_start} β†’ {forecast_end}")
st.markdown(
"""
The tables below report mean absolute error and relative mean absolute error with respect
to a naive 7-day persistence benchmark. Lower values indicate better predictive performance.
"""
)
A, B, C = st.columns(3)
with A:
st.markdown("#### LEAR")
st.dataframe(style_det_metrics(lear_metrics), use_container_width=True)
with B:
st.markdown("#### XGB")
st.dataframe(style_det_metrics(xgb_metrics), use_container_width=True)
with C:
st.markdown("#### Ensemble")
st.dataframe(style_det_metrics(ens_metrics), use_container_width=True)
if np.isfinite(naive_mae):
st.markdown(
f"""
<p class="small-muted">
The relative MAE benchmark is based on a naive 7-day persistence forecast,
with a reference MAE of <b>{naive_mae:.3f} EUR/MWh</b>.
</p>
""",
unsafe_allow_html=True,
)
else:
st.markdown(
'<p class="small-muted">The naive benchmark could not be computed because of insufficient overlap in the evaluation window.</p>',
unsafe_allow_html=True,
)
st.write("##### Access Predictions")
st.write("All forecasts are provided on an 'AS IS' BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. If you are interested in accessing the predictions made by the models, the models themselves or how these can be incorporated into your workflows, please contact Margarida Mascarenhas (PhD Student, KU Leuven) at margarida.mascarenhas@kuleuven.be or Hussain Kazmi (assistant professor, KU Leuven) at hussain.kazmi@kuleuven.be.")