Spaces:
Sleeping
Sleeping
Evgueni Poloukarov
Claude
commited on
Commit
·
7f2c237
1
Parent(s):
de602fd
feat: add integer rounding + validation notebook for all 132 borders
Browse filesSession 13 improvements:
- Add integer rounding to forecasts (removes decimal noise: 3531.43 -> 3531 MW)
- Update validation notebook to show ALL 132 FBMC directional borders
- Document Polish border fix and current progress in activity.md
Changes:
- src/forecasting/chronos_inference.py: Round median/q10/q90 to nearest integer
- notebooks/september_2025_validation.py: Show all 132 borders (not just 36)
- doc/activity.md: Added Session 13 documentation
Co-Authored-By: Claude <[email protected]>
- doc/activity.md +161 -0
- notebooks/september_2025_validation.py +358 -0
- src/forecasting/chronos_inference.py +6 -0
doc/activity.md
CHANGED
|
@@ -1086,3 +1086,164 @@ result = client.predict(api_name="/run_diagnostic") # Will show all endpoints w
|
|
| 1086 |
**Next Session**: Run diagnostics, fix identified issues, complete Day 3 validation
|
| 1087 |
|
| 1088 |
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1086 |
**Next Session**: Run diagnostics, fix identified issues, complete Day 3 validation
|
| 1087 |
|
| 1088 |
---
|
| 1089 |
+
|
| 1090 |
+
## Session 13: CRITICAL FIX - Polish Border Target Data Bug
|
| 1091 |
+
**Date**: 2025-11-19
|
| 1092 |
+
**Duration**: ~3 hours
|
| 1093 |
+
**Status**: COMPLETED - Polish border data bug fixed, all 132 directional borders working
|
| 1094 |
+
|
| 1095 |
+
### Critical Issue: Polish Border Targets All Zeros
|
| 1096 |
+
|
| 1097 |
+
**Problem**: Polish border forecasts showed 0.0000X MW instead of expected thousands of MW
|
| 1098 |
+
- User reported: "What's wrong with the Poland flows? They're 0.0000X of a megawatt"
|
| 1099 |
+
- Expected: ~3,000-4,000 MW capacity flows
|
| 1100 |
+
- Actual: 0.00000028 MW (effectively zero)
|
| 1101 |
+
|
| 1102 |
+
**Root Cause**: Feature engineering created targets from WRONG JAO columns
|
| 1103 |
+
- Used: `border_*` columns (LTA allocations) - these are pre-allocated capacity contracts
|
| 1104 |
+
- Should use: Directional flow columns (MaxBEX values) - max capacity in given direction
|
| 1105 |
+
|
| 1106 |
+
**JAO Data Types** (verified against JAO handbook):
|
| 1107 |
+
- **MaxBEX** (directional columns like CZ>PL): Commercial trading capacity = "max capacity in given direction" = CORRECT TARGET
|
| 1108 |
+
- **LTA** (border_* columns): Long-term pre-allocated capacity = FEATURE, NOT TARGET
|
| 1109 |
+
|
| 1110 |
+
### The Fix (src/feature_engineering/engineer_jao_features.py)
|
| 1111 |
+
|
| 1112 |
+
**Changed target creation logic**:
|
| 1113 |
+
```python
|
| 1114 |
+
# OLD (WRONG) - Used border_* columns (LTA allocations)
|
| 1115 |
+
target_cols = [c for c in jao_df.columns if c.startswith('border_')]
|
| 1116 |
+
|
| 1117 |
+
# NEW (CORRECT) - Use directional flow columns (MaxBEX)
|
| 1118 |
+
directional_cols = [c for c in unified.columns if '>' in c]
|
| 1119 |
+
for col in sorted(directional_cols):
|
| 1120 |
+
from_country, to_country = col.split('>')
|
| 1121 |
+
target_name = f'target_border_{from_country}_{to_country}'
|
| 1122 |
+
all_features = all_features.with_columns([
|
| 1123 |
+
unified[col].alias(target_name)
|
| 1124 |
+
])
|
| 1125 |
+
```
|
| 1126 |
+
|
| 1127 |
+
**Impact**:
|
| 1128 |
+
- Before: 38 MaxBEX targets (some Polish borders = 0)
|
| 1129 |
+
- After: 132 directional targets (ALL borders with realistic values)
|
| 1130 |
+
- Polish borders now show correct capacity: CZ_PL = 4,321 MW (was 0.00000028 MW)
|
| 1131 |
+
|
| 1132 |
+
### Dataset Regeneration
|
| 1133 |
+
|
| 1134 |
+
1. **Regenerated JAO features**:
|
| 1135 |
+
- 132 directional targets created (both directions)
|
| 1136 |
+
- File: `data/processed/features_jao_24month.parquet`
|
| 1137 |
+
- Shape: 17,544 rows × 778 columns
|
| 1138 |
+
|
| 1139 |
+
2. **Regenerated unified features**:
|
| 1140 |
+
- Combined JAO (132 targets + 646 features) + Weather + ENTSO-E
|
| 1141 |
+
- File: `data/processed/features_unified_24month.parquet`
|
| 1142 |
+
- Shape: 17,544 rows × 2,647 columns (was 2,553)
|
| 1143 |
+
- Size: 29.7 MB
|
| 1144 |
+
|
| 1145 |
+
3. **Uploaded to HuggingFace**:
|
| 1146 |
+
- Dataset: `evgueni-p/fbmc-features-24month`
|
| 1147 |
+
- Committed: 29.7 MB parquet file
|
| 1148 |
+
- Polish border verification:
|
| 1149 |
+
* target_border_CZ_PL: Mean=3,482 MW (was 0 MW)
|
| 1150 |
+
* target_border_PL_CZ: Mean=2,698 MW (was 0 MW)
|
| 1151 |
+
|
| 1152 |
+
### Secondary Fix: Dtype Mismatch Error
|
| 1153 |
+
|
| 1154 |
+
**Error**: Chronos-2 validation failed with dtype mismatch
|
| 1155 |
+
```
|
| 1156 |
+
ValueError: Column lta_total_allocated in future_df has dtype float64
|
| 1157 |
+
but column in df has dtype int64
|
| 1158 |
+
```
|
| 1159 |
+
|
| 1160 |
+
**Root Cause**: NaN masking converts int64 → float64, but context DataFrame still had int64
|
| 1161 |
+
|
| 1162 |
+
**Fix** (src/forecasting/dynamic_forecast.py):
|
| 1163 |
+
```python
|
| 1164 |
+
# Added dtype alignment between context and future DataFrames
|
| 1165 |
+
common_cols = set(context_data.columns) & set(future_data.columns)
|
| 1166 |
+
for col in common_cols:
|
| 1167 |
+
if col in ['timestamp', 'border']:
|
| 1168 |
+
continue
|
| 1169 |
+
if context_data[col].dtype != future_data[col].dtype:
|
| 1170 |
+
context_data[col] = context_data[col].astype(future_data[col].dtype)
|
| 1171 |
+
```
|
| 1172 |
+
|
| 1173 |
+
### Validation Results
|
| 1174 |
+
|
| 1175 |
+
**Smoke Test** (AT_BE border):
|
| 1176 |
+
- Forecast: Mean=3,531 MW, StdDev=92 MW
|
| 1177 |
+
- Result: SUCCESS - realistic capacity values
|
| 1178 |
+
|
| 1179 |
+
**Full 14-day Forecast** (September 2025):
|
| 1180 |
+
- Run date: 2025-09-01
|
| 1181 |
+
- Forecast period: Sept 2-15, 2025 (336 hours)
|
| 1182 |
+
- Borders: All 132 directional borders
|
| 1183 |
+
- Polish border test (CZ_PL):
|
| 1184 |
+
* Mean: 4,321 MW (SUCCESS!)
|
| 1185 |
+
* StdDev: 112 MW
|
| 1186 |
+
* Range: [4,160 - 4,672] MW
|
| 1187 |
+
* Unique values: 334 (time-varying, not constant)
|
| 1188 |
+
|
| 1189 |
+
**Validation Notebook Created**:
|
| 1190 |
+
- File: `notebooks/september_2025_validation.py`
|
| 1191 |
+
- Features:
|
| 1192 |
+
* Interactive border selection (all 132 borders)
|
| 1193 |
+
* 2 weeks historical + 2 weeks forecast visualization
|
| 1194 |
+
* Comprehensive metrics: MAE, RMSE, MAPE, Bias, Variation
|
| 1195 |
+
* Default border: CZ_PL (showcases Polish border fix)
|
| 1196 |
+
- Running at: http://127.0.0.1:2719
|
| 1197 |
+
|
| 1198 |
+
### Files Modified
|
| 1199 |
+
|
| 1200 |
+
1. **src/feature_engineering/engineer_jao_features.py**:
|
| 1201 |
+
- Changed target creation from border_* to directional columns
|
| 1202 |
+
- Lines 601-619: New target creation logic
|
| 1203 |
+
|
| 1204 |
+
2. **src/forecasting/dynamic_forecast.py**:
|
| 1205 |
+
- Added dtype alignment in prepare_forecast_data()
|
| 1206 |
+
- Lines 86-96: Dtype alignment logic
|
| 1207 |
+
|
| 1208 |
+
3. **notebooks/september_2025_validation.py**:
|
| 1209 |
+
- Created interactive validation notebook
|
| 1210 |
+
- All 132 FBMC directional borders
|
| 1211 |
+
- Comprehensive evaluation metrics
|
| 1212 |
+
|
| 1213 |
+
4. **data/processed/features_unified_24month.parquet**:
|
| 1214 |
+
- Regenerated with corrected targets
|
| 1215 |
+
- 2,647 columns (up from 2,553)
|
| 1216 |
+
- Uploaded to HuggingFace
|
| 1217 |
+
|
| 1218 |
+
### Key Learnings
|
| 1219 |
+
|
| 1220 |
+
1. **Always verify data sources** - Column names can be misleading (border_* ≠ directional flows)
|
| 1221 |
+
2. **Check JAO handbook** - User correctly asked to verify against official documentation
|
| 1222 |
+
3. **Directional vs bidirectional** - MaxBEX provides both directions separately, not netted
|
| 1223 |
+
4. **Dtype alignment matters** - Chronos-2 requires matching dtypes between context and future
|
| 1224 |
+
5. **Test with real borders** - Polish borders exposed the bug that aggregate metrics missed
|
| 1225 |
+
|
| 1226 |
+
### Next Session Actions
|
| 1227 |
+
|
| 1228 |
+
**Priority 1**: Add integer rounding to forecast generation
|
| 1229 |
+
- Remove decimal noise (3531.43 → 3531 MW)
|
| 1230 |
+
- Update chronos_inference.py forecast output
|
| 1231 |
+
|
| 1232 |
+
**Priority 2**: Run full evaluation to measure improvement
|
| 1233 |
+
- Compare vs before fix (78.9% invalid constant forecasts)
|
| 1234 |
+
- Calculate MAE across all 132 borders
|
| 1235 |
+
- Identify which borders still have constant forecast problem
|
| 1236 |
+
|
| 1237 |
+
**Priority 3**: Document results and prepare for handover
|
| 1238 |
+
- Update evaluation metrics
|
| 1239 |
+
- Document Polish border fix impact
|
| 1240 |
+
- Prepare comprehensive results summary
|
| 1241 |
+
|
| 1242 |
+
---
|
| 1243 |
+
|
| 1244 |
+
**Status**: COMPLETED - Polish border bug fixed, all 132 borders operational
|
| 1245 |
+
**Timestamp**: 2025-11-19 18:30 UTC
|
| 1246 |
+
**Next Pickup**: Add integer rounding, run full evaluation
|
| 1247 |
+
|
| 1248 |
+
--- NEXT SESSION BOOKMARK ---
|
| 1249 |
+
|
notebooks/september_2025_validation.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import marimo
|
| 2 |
+
|
| 3 |
+
__generated_with = "0.17.2"
|
| 4 |
+
app = marimo.App(width="medium")
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@app.cell
|
| 8 |
+
def imports_and_setup():
|
| 9 |
+
"""Import libraries and set up paths."""
|
| 10 |
+
import marimo as mo
|
| 11 |
+
import polars as pl
|
| 12 |
+
import altair as alt
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from datetime import datetime
|
| 15 |
+
import numpy as np
|
| 16 |
+
|
| 17 |
+
# Set up absolute paths
|
| 18 |
+
project_root = Path(__file__).parent.parent
|
| 19 |
+
return alt, datetime, mo, pl, project_root
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@app.cell
|
| 23 |
+
def load_september_2025_data(datetime, pl, project_root):
|
| 24 |
+
"""Load September 2025 forecast results and actuals."""
|
| 25 |
+
|
| 26 |
+
# Load actuals from HuggingFace dataset (ground truth)
|
| 27 |
+
print('[INFO] Loading actuals from HuggingFace dataset...')
|
| 28 |
+
from datasets import load_dataset
|
| 29 |
+
import os
|
| 30 |
+
|
| 31 |
+
dataset = load_dataset('evgueni-p/fbmc-features-24month', split='train', token=os.environ.get('HF_TOKEN'))
|
| 32 |
+
df_actuals_full = pl.from_arrow(dataset.data.table)
|
| 33 |
+
print(f'[INFO] HF dataset loaded: {df_actuals_full.shape}')
|
| 34 |
+
|
| 35 |
+
# Load forecast results (full 14-day forecast with 132 borders)
|
| 36 |
+
forecast_path = project_root / 'results' / 'september_2025_forecast_full_14day.parquet'
|
| 37 |
+
|
| 38 |
+
if not forecast_path.exists():
|
| 39 |
+
raise FileNotFoundError(f'Forecast file not found: {forecast_path}. Run September 2025 forecast first.')
|
| 40 |
+
|
| 41 |
+
df_forecast_full = pl.read_parquet(forecast_path)
|
| 42 |
+
print(f'[INFO] Forecast loaded: {df_forecast_full.shape}')
|
| 43 |
+
print(f'[INFO] Forecast dates: {df_forecast_full["timestamp"].min()} to {df_forecast_full["timestamp"].max()}')
|
| 44 |
+
|
| 45 |
+
# Filter actuals to September 2025 period (Aug 18 - Sept 15 for context + forecast period)
|
| 46 |
+
start_date = datetime(2025, 8, 18) # 2 weeks before forecast
|
| 47 |
+
end_date = datetime(2025, 9, 16) # Through end of forecast period
|
| 48 |
+
|
| 49 |
+
df_actuals_filtered = df_actuals_full.filter(
|
| 50 |
+
(pl.col('timestamp') >= start_date) &
|
| 51 |
+
(pl.col('timestamp') < end_date)
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
print(f'[INFO] Actuals filtered: {df_actuals_filtered.shape[0]} hours (Aug 18 - Sept 15, 2025)')
|
| 55 |
+
|
| 56 |
+
# Forecast period for evaluation
|
| 57 |
+
forecast_start = datetime(2025, 9, 2)
|
| 58 |
+
return df_actuals_filtered, df_forecast_full
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@app.cell
|
| 62 |
+
def prepare_unified_dataframe(
|
| 63 |
+
datetime,
|
| 64 |
+
df_actuals_filtered,
|
| 65 |
+
df_forecast_full,
|
| 66 |
+
pl,
|
| 67 |
+
):
|
| 68 |
+
"""Prepare unified dataframe with forecast and actual pairs for ALL FBMC borders."""
|
| 69 |
+
|
| 70 |
+
# Extract ALL border names from forecast columns (132 directional borders)
|
| 71 |
+
# Includes both physical interconnectors and virtual trading paths
|
| 72 |
+
forecast_cols_list = [col for col in df_forecast_full.columns if col.endswith('_median')]
|
| 73 |
+
border_names_list = [col.replace('_median', '') for col in forecast_cols_list]
|
| 74 |
+
|
| 75 |
+
print(f'[INFO] Processing {len(border_names_list)} FBMC borders (all directional trading paths)...')
|
| 76 |
+
print(f'[INFO] Sample borders: {sorted(border_names_list)[:10]}...')
|
| 77 |
+
|
| 78 |
+
# Start with timestamp from actuals
|
| 79 |
+
df_unified_data = df_actuals_filtered.select('timestamp')
|
| 80 |
+
|
| 81 |
+
# Add actual and forecast for each border
|
| 82 |
+
for border in border_names_list:
|
| 83 |
+
actual_col_source = f'target_border_{border}'
|
| 84 |
+
forecast_col_source = f'{border}_median'
|
| 85 |
+
|
| 86 |
+
# Add actuals
|
| 87 |
+
if actual_col_source in df_actuals_filtered.columns:
|
| 88 |
+
df_unified_data = df_unified_data.with_columns(
|
| 89 |
+
df_actuals_filtered[actual_col_source].alias(f'actual_{border}')
|
| 90 |
+
)
|
| 91 |
+
else:
|
| 92 |
+
print(f'[WARNING] Actual column missing: {actual_col_source}')
|
| 93 |
+
df_unified_data = df_unified_data.with_columns(pl.lit(None).alias(f'actual_{border}'))
|
| 94 |
+
|
| 95 |
+
# Add forecasts (join on timestamp)
|
| 96 |
+
if forecast_col_source in df_forecast_full.columns:
|
| 97 |
+
df_forecast_subset = df_forecast_full.select(['timestamp', forecast_col_source])
|
| 98 |
+
df_unified_data = df_unified_data.join(
|
| 99 |
+
df_forecast_subset,
|
| 100 |
+
on='timestamp',
|
| 101 |
+
how='left'
|
| 102 |
+
).rename({forecast_col_source: f'forecast_{border}'})
|
| 103 |
+
else:
|
| 104 |
+
print(f'[WARNING] Forecast column missing: {forecast_col_source}')
|
| 105 |
+
df_unified_data = df_unified_data.with_columns(pl.lit(None).alias(f'forecast_{border}'))
|
| 106 |
+
|
| 107 |
+
print(f'[INFO] Unified data prepared: {df_unified_data.shape}')
|
| 108 |
+
|
| 109 |
+
# Validate no data leakage - check that forecasts don't perfectly match actuals
|
| 110 |
+
sample_border = border_names_list[0]
|
| 111 |
+
forecast_col_check = f'forecast_{sample_border}'
|
| 112 |
+
actual_col_check = f'actual_{sample_border}'
|
| 113 |
+
|
| 114 |
+
if forecast_col_check in df_unified_data.columns and actual_col_check in df_unified_data.columns:
|
| 115 |
+
_forecast_start_check = datetime(2025, 9, 2)
|
| 116 |
+
_df_forecast_check = df_unified_data.filter(pl.col('timestamp') >= _forecast_start_check)
|
| 117 |
+
|
| 118 |
+
if len(_df_forecast_check) > 0:
|
| 119 |
+
mae_check = (_df_forecast_check[forecast_col_check] - _df_forecast_check[actual_col_check]).abs().mean()
|
| 120 |
+
if mae_check == 0:
|
| 121 |
+
raise ValueError(f'DATA LEAKAGE DETECTED: Forecasts perfectly match actuals (MAE=0) for {sample_border}!')
|
| 122 |
+
|
| 123 |
+
print('[INFO] Data leakage check passed - forecasts differ from actuals')
|
| 124 |
+
return border_names_list, df_unified_data
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
@app.cell
|
| 128 |
+
def create_border_selector(border_names_list, mo):
|
| 129 |
+
"""Create interactive border selection dropdown."""
|
| 130 |
+
|
| 131 |
+
border_selector_widget = mo.ui.dropdown(
|
| 132 |
+
options={border: border for border in sorted(border_names_list)},
|
| 133 |
+
value='CZ_PL', # Default to Polish border to showcase fix
|
| 134 |
+
label='Select Border:'
|
| 135 |
+
)
|
| 136 |
+
return (border_selector_widget,)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
@app.cell
|
| 140 |
+
def display_border_selector(border_selector_widget, mo):
|
| 141 |
+
"""Display the border selector UI."""
|
| 142 |
+
mo.md(f"""
|
| 143 |
+
## Forecast Validation: September 2025 (All FBMC Borders)
|
| 144 |
+
|
| 145 |
+
**Select a border to view:**
|
| 146 |
+
{border_selector_widget}
|
| 147 |
+
|
| 148 |
+
Chart shows:
|
| 149 |
+
- **2 weeks historical** (Aug 18 - Sept 1, 2025): Actual flows only
|
| 150 |
+
- **2 weeks forecast** (Sept 2-15, 2025): Forecast vs Actual comparison
|
| 151 |
+
- **Context**: 336 hours forecast period (14 days)
|
| 152 |
+
- **Borders shown**: All 132 FBMC directional borders (66 country pairs x 2 directions)
|
| 153 |
+
- **Note**: Includes both physical interconnectors and virtual trading paths
|
| 154 |
+
""")
|
| 155 |
+
return
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
@app.cell
|
| 159 |
+
def filter_data_for_selected_border(
|
| 160 |
+
border_selector_widget,
|
| 161 |
+
datetime,
|
| 162 |
+
df_unified_data,
|
| 163 |
+
pl,
|
| 164 |
+
):
|
| 165 |
+
"""Filter data for the selected border."""
|
| 166 |
+
|
| 167 |
+
selected_border_name = border_selector_widget.value
|
| 168 |
+
|
| 169 |
+
# Extract columns for selected border
|
| 170 |
+
actual_col_name = f'actual_{selected_border_name}'
|
| 171 |
+
forecast_col_name = f'forecast_{selected_border_name}'
|
| 172 |
+
|
| 173 |
+
# Check if columns exist
|
| 174 |
+
if actual_col_name not in df_unified_data.columns:
|
| 175 |
+
df_selected_border = None
|
| 176 |
+
print(f'[ERROR] Actual column {actual_col_name} not found')
|
| 177 |
+
else:
|
| 178 |
+
df_selected_border = df_unified_data.select([
|
| 179 |
+
'timestamp',
|
| 180 |
+
pl.col(actual_col_name).alias('actual'),
|
| 181 |
+
pl.col(forecast_col_name).alias('forecast') if forecast_col_name in df_unified_data.columns else pl.lit(None).alias('forecast')
|
| 182 |
+
])
|
| 183 |
+
|
| 184 |
+
# Add period marker (historical vs forecast)
|
| 185 |
+
forecast_start_time = datetime(2025, 9, 2)
|
| 186 |
+
df_selected_border = df_selected_border.with_columns(
|
| 187 |
+
pl.when(pl.col('timestamp') >= forecast_start_time)
|
| 188 |
+
.then(pl.lit('Forecast Period'))
|
| 189 |
+
.otherwise(pl.lit('Historical'))
|
| 190 |
+
.alias('period')
|
| 191 |
+
)
|
| 192 |
+
return df_selected_border, forecast_start_time, selected_border_name
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
@app.cell
|
| 196 |
+
def create_time_series_chart(
|
| 197 |
+
alt,
|
| 198 |
+
df_selected_border,
|
| 199 |
+
forecast_start_time,
|
| 200 |
+
selected_border_name,
|
| 201 |
+
):
|
| 202 |
+
"""Create Altair time series visualization."""
|
| 203 |
+
|
| 204 |
+
if df_selected_border is None:
|
| 205 |
+
chart_time_series = alt.Chart().mark_text(text='No data available', size=20)
|
| 206 |
+
else:
|
| 207 |
+
# Convert to pandas for Altair (CLAUDE.md Rule #37)
|
| 208 |
+
df_plot = df_selected_border.to_pandas()
|
| 209 |
+
|
| 210 |
+
# Create base chart
|
| 211 |
+
base = alt.Chart(df_plot).encode(
|
| 212 |
+
x=alt.X('timestamp:T', title='Date', axis=alt.Axis(format='%b %d'))
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
# Actual line (blue, solid)
|
| 216 |
+
line_actual = base.mark_line(color='blue', strokeWidth=2).encode(
|
| 217 |
+
y=alt.Y('actual:Q', title='Flow (MW)', scale=alt.Scale(zero=False)),
|
| 218 |
+
tooltip=[
|
| 219 |
+
alt.Tooltip('timestamp:T', title='Time', format='%Y-%m-%d %H:%M'),
|
| 220 |
+
alt.Tooltip('actual:Q', title='Actual (MW)', format='.0f')
|
| 221 |
+
]
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
# Forecast line (orange, dashed) - only for forecast period
|
| 225 |
+
df_plot_forecast = df_plot[df_plot['period'] == 'Forecast Period']
|
| 226 |
+
|
| 227 |
+
if len(df_plot_forecast) > 0 and df_plot_forecast['forecast'].notna().any():
|
| 228 |
+
line_forecast = alt.Chart(df_plot_forecast).mark_line(
|
| 229 |
+
color='orange',
|
| 230 |
+
strokeWidth=2,
|
| 231 |
+
strokeDash=[5, 5]
|
| 232 |
+
).encode(
|
| 233 |
+
x=alt.X('timestamp:T'),
|
| 234 |
+
y=alt.Y('forecast:Q'),
|
| 235 |
+
tooltip=[
|
| 236 |
+
alt.Tooltip('timestamp:T', title='Time', format='%Y-%m-%d %H:%M'),
|
| 237 |
+
alt.Tooltip('forecast:Q', title='Forecast (MW)', format='.0f'),
|
| 238 |
+
alt.Tooltip('actual:Q', title='Actual (MW)', format='.0f')
|
| 239 |
+
]
|
| 240 |
+
)
|
| 241 |
+
else:
|
| 242 |
+
line_forecast = alt.Chart().mark_point() # Empty chart
|
| 243 |
+
|
| 244 |
+
# Vertical line at forecast start
|
| 245 |
+
rule_forecast_start = alt.Chart(
|
| 246 |
+
alt.Data(values=[{'x': forecast_start_time}])
|
| 247 |
+
).mark_rule(color='red', strokeDash=[3, 3], strokeWidth=1).encode(
|
| 248 |
+
x='x:T'
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
# Combine layers
|
| 252 |
+
chart_time_series = (line_actual + line_forecast + rule_forecast_start).properties(
|
| 253 |
+
width=800,
|
| 254 |
+
height=400,
|
| 255 |
+
title=f'Border: {selected_border_name} | Hourly Flows (Aug 18 - Sept 15, 2025)'
|
| 256 |
+
).configure_axis(
|
| 257 |
+
labelFontSize=12,
|
| 258 |
+
titleFontSize=14
|
| 259 |
+
).configure_title(
|
| 260 |
+
fontSize=16
|
| 261 |
+
)
|
| 262 |
+
return (chart_time_series,)
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
@app.cell
|
| 266 |
+
def calculate_summary_statistics(
|
| 267 |
+
df_selected_border,
|
| 268 |
+
forecast_start_time,
|
| 269 |
+
pl,
|
| 270 |
+
selected_border_name,
|
| 271 |
+
):
|
| 272 |
+
"""Calculate comprehensive evaluation metrics for the selected border."""
|
| 273 |
+
|
| 274 |
+
if df_selected_border is None:
|
| 275 |
+
stats_summary_text = 'No data available'
|
| 276 |
+
else:
|
| 277 |
+
# Filter to forecast period only
|
| 278 |
+
df_forecast_period = df_selected_border.filter(
|
| 279 |
+
pl.col('timestamp') >= forecast_start_time
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
if len(df_forecast_period) == 0 or df_forecast_period['forecast'].is_null().all():
|
| 283 |
+
stats_summary_text = 'No forecast data available for this period'
|
| 284 |
+
else:
|
| 285 |
+
# Calculate accuracy metrics
|
| 286 |
+
forecast_vals = df_forecast_period['forecast'].drop_nulls()
|
| 287 |
+
actual_vals = df_forecast_period['actual'].drop_nulls()
|
| 288 |
+
|
| 289 |
+
# Align forecast and actual (remove any nulls)
|
| 290 |
+
df_eval = df_forecast_period.filter(
|
| 291 |
+
pl.col('forecast').is_not_null() & pl.col('actual').is_not_null()
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
if len(df_eval) == 0:
|
| 295 |
+
stats_summary_text = 'No overlapping forecast and actual data'
|
| 296 |
+
else:
|
| 297 |
+
# Error metrics
|
| 298 |
+
errors = (df_eval['forecast'] - df_eval['actual'])
|
| 299 |
+
abs_errors = errors.abs()
|
| 300 |
+
|
| 301 |
+
mae_value = abs_errors.mean()
|
| 302 |
+
rmse_value = (errors.pow(2).mean() ** 0.5)
|
| 303 |
+
mape_value = (abs_errors / df_eval['actual'].abs()).mean() * 100
|
| 304 |
+
|
| 305 |
+
# Bias metrics
|
| 306 |
+
mean_error = errors.mean()
|
| 307 |
+
|
| 308 |
+
# Forecast quality metrics
|
| 309 |
+
unique_count = forecast_vals.n_unique()
|
| 310 |
+
std_forecast = forecast_vals.std()
|
| 311 |
+
std_actual = actual_vals.std()
|
| 312 |
+
|
| 313 |
+
# Range metrics
|
| 314 |
+
forecast_range = forecast_vals.max() - forecast_vals.min()
|
| 315 |
+
actual_range = actual_vals.max() - actual_vals.min()
|
| 316 |
+
|
| 317 |
+
stats_summary_text = f"""
|
| 318 |
+
### Forecast Quality Metrics
|
| 319 |
+
|
| 320 |
+
**Border**: {selected_border_name}
|
| 321 |
+
**Period**: September 2-15, 2025 (336 hours)
|
| 322 |
+
**Evaluation Points**: {len(df_eval)} hours
|
| 323 |
+
|
| 324 |
+
#### Accuracy Metrics
|
| 325 |
+
- **MAE** (Mean Absolute Error): {mae_value:.0f} MW
|
| 326 |
+
- **RMSE** (Root Mean Squared Error): {rmse_value:.0f} MW
|
| 327 |
+
- **MAPE** (Mean Absolute Percentage Error): {mape_value:.1f}%
|
| 328 |
+
- **Bias** (Mean Error): {mean_error:+.0f} MW
|
| 329 |
+
|
| 330 |
+
#### Forecast Variation
|
| 331 |
+
- **Unique Values**: {unique_count} / {len(df_eval)} ({unique_count/len(df_eval)*100:.0f}%)
|
| 332 |
+
- **Forecast StdDev**: {std_forecast:.0f} MW
|
| 333 |
+
- **Actual StdDev**: {std_actual:.0f} MW
|
| 334 |
+
- **Forecast Range**: {forecast_range:.0f} MW
|
| 335 |
+
- **Actual Range**: {actual_range:.0f} MW
|
| 336 |
+
|
| 337 |
+
#### Interpretation
|
| 338 |
+
- **MAE < 150 MW**: ✓ Excellent (zero-shot baseline target)
|
| 339 |
+
- **MAE 150-300 MW**: Good
|
| 340 |
+
- **MAE > 300 MW**: Needs improvement
|
| 341 |
+
- **Variation**: {unique_count} unique values indicates {'VALID time-varying forecast' if unique_count > 50 else 'LOW VARIATION - may be constant'}
|
| 342 |
+
- **Bias**: {'Overforecasting' if mean_error > 50 else 'Underforecasting' if mean_error < -50 else 'Balanced'}
|
| 343 |
+
"""
|
| 344 |
+
return (stats_summary_text,)
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
@app.cell
|
| 348 |
+
def display_chart_and_stats(chart_time_series, mo, stats_summary_text):
|
| 349 |
+
"""Display the chart and statistics."""
|
| 350 |
+
mo.vstack([
|
| 351 |
+
chart_time_series,
|
| 352 |
+
mo.md(stats_summary_text)
|
| 353 |
+
])
|
| 354 |
+
return
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
if __name__ == "__main__":
|
| 358 |
+
app.run()
|
src/forecasting/chronos_inference.py
CHANGED
|
@@ -230,6 +230,12 @@ class ChronosInferencePipeline:
|
|
| 230 |
else:
|
| 231 |
raise TypeError(f"Expected DataFrame from predict_df(), got {type(forecasts_df)}")
|
| 232 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
inference_time = time.time() - border_start
|
| 234 |
|
| 235 |
# Store results
|
|
|
|
| 230 |
else:
|
| 231 |
raise TypeError(f"Expected DataFrame from predict_df(), got {type(forecasts_df)}")
|
| 232 |
|
| 233 |
+
# Round to nearest integer (capacity values are always whole MW)
|
| 234 |
+
# Removes decimal noise like 3531.4329 -> 3531
|
| 235 |
+
median = np.round(median).astype(int)
|
| 236 |
+
q10 = np.round(q10).astype(int)
|
| 237 |
+
q90 = np.round(q90).astype(int)
|
| 238 |
+
|
| 239 |
inference_time = time.time() - border_start
|
| 240 |
|
| 241 |
# Store results
|