fbmc-chronos2 / notebooks /forecast_validation_oct2024.py
Evgueni Poloukarov
fix: correct Polish border targets - use directional flows (132 targets)
dcc56de
import marimo
__generated_with = "0.9.30"
app = marimo.App(width="medium")
@app.cell
def imports_and_setup():
"""Import libraries and set up paths."""
import marimo as mo
import polars as pl
import altair as alt
from pathlib import Path
from datetime import datetime
# Set up absolute paths
project_root = Path(__file__).parent.parent
return mo, pl, alt, Path, datetime, project_root
@app.cell
def load_september_2025_data(pl, project_root):
"""Load September 2025 forecast results and actuals."""
# Load actuals from HuggingFace dataset (ground truth)
print('[INFO] Loading actuals from HuggingFace dataset...')
from datasets import load_dataset
import os
dataset = load_dataset('evgueni-p/fbmc-features-24month', split='train', token=os.environ.get('HF_TOKEN'))
df_actuals_full = pl.from_arrow(dataset.data.table)
print(f'[INFO] HF dataset loaded: {df_actuals_full.shape}')
# Load forecast results
forecast_path = project_root / 'results' / 'september_2025_forecast_504h.parquet'
if not forecast_path.exists():
raise FileNotFoundError(f'Forecast file not found: {forecast_path}. Run September 2025 forecast first.')
df_forecast_full = pl.read_parquet(forecast_path)
print(f'[INFO] Forecast loaded: {df_forecast_full.shape}')
print(f'[INFO] Forecast dates: {df_forecast_full["timestamp"].min()} to {df_forecast_full["timestamp"].max()}')
# Filter actuals to September 2025 period (Aug 18 - Sept 15)
start_date = datetime(2025, 8, 18) # 2 weeks before forecast
end_date = datetime(2025, 9, 16) # Through end of forecast period
df_actuals_filtered = df_actuals_full.filter(
(pl.col('timestamp') >= start_date) &
(pl.col('timestamp') < end_date)
)
print(f'[INFO] Actuals filtered: {df_actuals_filtered.shape[0]} hours (Aug 18 - Sept 15, 2025)')
return df_actuals_full, df_actuals_filtered, df_forecast_full, start_date, end_date
@app.cell
def prepare_unified_dataframe(pl, df_actuals_filtered, df_forecast_full):
"""Prepare unified dataframe with forecast and actual pairs for all borders."""
# Extract border names from forecast columns
forecast_cols_list = [col for col in df_forecast_full.columns if col.endswith('_median')]
border_names_list = [col.replace('_median', '') for col in forecast_cols_list]
print(f'[INFO] Processing {len(border_names_list)} borders...')
# Start with timestamp from actuals
df_unified_data = df_actuals_filtered.select('timestamp')
# Add actual and forecast for each border
for border in border_names_list:
actual_col_source = f'target_border_{border}'
forecast_col_source = f'{border}_median'
# Add actuals
if actual_col_source in df_actuals_filtered.columns:
df_unified_data = df_unified_data.with_columns(
df_actuals_filtered[actual_col_source].alias(f'actual_{border}')
)
else:
print(f'[WARNING] Actual column missing: {actual_col_source}')
df_unified_data = df_unified_data.with_columns(pl.lit(None).alias(f'actual_{border}'))
# Add forecasts (join on timestamp)
if forecast_col_source in df_forecast_full.columns:
df_forecast_subset = df_forecast_full.select(['timestamp', forecast_col_source])
df_unified_data = df_unified_data.join(
df_forecast_subset,
on='timestamp',
how='left'
).rename({forecast_col_source: f'forecast_{border}'})
else:
print(f'[WARNING] Forecast column missing: {forecast_col_source}')
df_unified_data = df_unified_data.with_columns(pl.lit(None).alias(f'forecast_{border}'))
print(f'[INFO] Unified data prepared: {df_unified_data.shape}')
# Validate no data leakage - check that forecasts don't perfectly match actuals
sample_border = border_names_list[0]
forecast_col_check = f'forecast_{sample_border}'
actual_col_check = f'actual_{sample_border}'
if forecast_col_check in df_unified_data.columns and actual_col_check in df_unified_data.columns:
_forecast_start_check = datetime(2025, 9, 2)
_df_forecast_check = df_unified_data.filter(pl.col('timestamp') >= _forecast_start_check)
if len(_df_forecast_check) > 0:
mae_check = (_df_forecast_check[forecast_col_check] - _df_forecast_check[actual_col_check]).abs().mean()
if mae_check == 0:
raise ValueError(f'DATA LEAKAGE DETECTED: Forecasts perfectly match actuals (MAE=0) for {sample_border}!')
print('[INFO] Data leakage check passed - forecasts differ from actuals')
return df_unified_data, border_names_list
@app.cell
def create_border_selector(mo, border_names_list):
"""Create interactive border selection dropdown."""
border_selector_widget = mo.ui.dropdown(
options={border: border for border in sorted(border_names_list)},
value='AT_CZ',
label='Select Border:'
)
return border_selector_widget,
@app.cell
def display_border_selector(mo, border_selector_widget):
"""Display the border selector UI."""
mo.md(f"""
## Forecast Validation: September 2025
**Select a border to view:**
{border_selector_widget}
Chart shows:
- **2 weeks historical** (Aug 18-31, 2025): Actual flows only
- **2 weeks forecast** (Sept 2-15, 2025): Forecast vs Actual comparison
- **Context**: 504 hours (21 days)
""")
@app.cell
def filter_data_for_selected_border(pl, df_unified_data, border_selector_widget, start_date):
"""Filter data for the selected border."""
selected_border_name = border_selector_widget.value
# Extract columns for selected border
actual_col_name = f'actual_{selected_border_name}'
forecast_col_name = f'forecast_{selected_border_name}'
# Check if columns exist
if actual_col_name not in df_unified_data.columns:
df_selected_border = None
print(f'[ERROR] Actual column {actual_col_name} not found')
else:
df_selected_border = df_unified_data.select([
'timestamp',
pl.col(actual_col_name).alias('actual'),
pl.col(forecast_col_name).alias('forecast') if forecast_col_name in df_unified_data.columns else pl.lit(None).alias('forecast')
])
# Add period marker (historical vs forecast)
forecast_start = datetime(2025, 9, 2)
df_selected_border = df_selected_border.with_columns(
pl.when(pl.col('timestamp') >= forecast_start)
.then(pl.lit('Forecast Period'))
.otherwise(pl.lit('Historical'))
.alias('period')
)
return df_selected_border, selected_border_name, forecast_start
@app.cell
def create_time_series_chart(alt, df_selected_border, selected_border_name, forecast_start):
"""Create Altair time series visualization."""
if df_selected_border is None:
chart_time_series = alt.Chart().mark_text(text='No data available', size=20)
else:
# Convert to pandas for Altair (CLAUDE.md Rule #37)
df_plot = df_selected_border.to_pandas()
# Create base chart
base = alt.Chart(df_plot).encode(
x=alt.X('timestamp:T', title='Date', axis=alt.Axis(format='%b %d'))
)
# Actual line (blue, solid)
line_actual = base.mark_line(color='blue', strokeWidth=2).encode(
y=alt.Y('actual:Q', title='Flow (MW)', scale=alt.Scale(zero=False)),
tooltip=[
alt.Tooltip('timestamp:T', title='Time', format='%Y-%m-%d %H:%M'),
alt.Tooltip('actual:Q', title='Actual (MW)', format='.1f')
]
)
# Forecast line (orange, dashed) - only for forecast period
df_plot_forecast = df_plot[df_plot['period'] == 'Forecast Period']
if len(df_plot_forecast) > 0 and df_plot_forecast['forecast'].notna().any():
line_forecast = alt.Chart(df_plot_forecast).mark_line(
color='orange',
strokeWidth=2,
strokeDash=[5, 5]
).encode(
x=alt.X('timestamp:T'),
y=alt.Y('forecast:Q'),
tooltip=[
alt.Tooltip('timestamp:T', title='Time', format='%Y-%m-%d %H:%M'),
alt.Tooltip('forecast:Q', title='Forecast (MW)', format='.1f'),
alt.Tooltip('actual:Q', title='Actual (MW)', format='.1f')
]
)
else:
line_forecast = alt.Chart().mark_point() # Empty chart
# Vertical line at forecast start
rule_forecast_start = alt.Chart(
alt.Data(values=[{'x': forecast_start}])
).mark_rule(color='red', strokeDash=[3, 3], strokeWidth=1).encode(
x='x:T'
)
# Combine layers
chart_time_series = (line_actual + line_forecast + rule_forecast_start).properties(
width=800,
height=400,
title=f'Border: {selected_border_name} | Hourly Flows (Aug 18 - Sept 15, 2025)'
).configure_axis(
labelFontSize=12,
titleFontSize=14
).configure_title(
fontSize=16
)
return chart_time_series,
@app.cell
def calculate_summary_statistics(pl, df_selected_border, selected_border_name, forecast_start):
"""Calculate summary statistics for the selected border."""
if df_selected_border is None:
stats_summary_text = 'No data available'
else:
# Filter to forecast period only
df_forecast_period = df_selected_border.filter(
pl.col('timestamp') >= forecast_start
)
if len(df_forecast_period) == 0 or df_forecast_period['forecast'].is_null().all():
stats_summary_text = 'No forecast data available for this period'
else:
# Calculate MAE
mae_value = (
(df_forecast_period['forecast'] - df_forecast_period['actual']).abs().mean()
)
# Forecast variation
forecast_values = df_forecast_period['forecast'].drop_nulls()
unique_count = forecast_values.n_unique()
std_value = forecast_values.std()
# Actual variation (for reference)
actual_values = df_forecast_period['actual'].drop_nulls()
actual_std = actual_values.std()
stats_summary_text = f"""
### Forecast Quality Statistics
**Border**: {selected_border_name}
**Period**: September 2-15, 2025 (336 hours)
**Context**: 504 hours (21 days)
**Accuracy Metrics:**
- **MAE**: {mae_value:.2f} MW
- Forecast variation: {unique_count} unique values, StdDev = {std_value:.2f} MW
- Actual variation: StdDev = {actual_std:.2f} MW
**Interpretation:**
- MAE < 50 MW: Excellent
- MAE 50-100 MW: Good
- MAE > 100 MW: Needs improvement
"""
return stats_summary_text,
@app.cell
def display_chart_and_stats(mo, chart_time_series, stats_summary_text):
"""Display the chart and statistics."""
mo.vstack([
chart_time_series,
mo.md(stats_summary_text)
])
if __name__ == "__main__":
app.run()