Evgueni Poloukarov Claude commited on
Commit
7f2c237
·
1 Parent(s): de602fd

feat: add integer rounding + validation notebook for all 132 borders

Browse files

Session 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 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