jeffliulab commited on
Commit
a4ad2d3
·
verified ·
1 Parent(s): a58073a

Real-time mode + 7-day backtest tab + EIA API integration

Browse files
Files changed (5) hide show
  1. README.md +18 -13
  2. about.md +10 -2
  3. app.py +181 -49
  4. assets/backtest_2022_last7d.json +0 -0
  5. iso_ne_fetch.py +88 -19
README.md CHANGED
@@ -14,21 +14,19 @@ short_description: Real-time day-ahead demand forecasting for ISO New England
14
 
15
  # ⚡ Multi-Modal Deep Learning for Energy Demand Forecasting
16
 
17
- Live demo of the trained **CNN-Transformer baseline** from CS-137 Assignment 3 (Tufts University, Spring 2026). The baseline reaches **5.24 % MAPE** on the 2022 self-evaluation slice (last 2 days of 2022) when given real HRRR weather inputs.
18
 
19
- ## What it does
 
20
 
21
- 1. Pick a target datetime (UTC) — defaults to "now".
22
- 2. The Space fetches the last 24 hours of ISO New England system demand from the public ISO Express data feed and splits it into the 8 load zones using a fixed proportion vector estimated from 2022 historical zonal reports. (If the live feed is unreachable, it falls back to a bundled 24-hour CSV from 2022.)
23
- 3. Calendar features (hour-of-day, day-of-week, month, US-holiday flag) are computed for the past 24 h and the next 24 h.
24
- 4. The trained baseline runs forward and produces a 24-hour per-zone demand forecast in MWh.
25
- 5. You see two plots: an 8-panel per-zone history+forecast chart and a sorted bar of next-hour predicted demand.
26
 
27
- ## Demo limitation synthetic weather inputs
 
28
 
29
- This Space substitutes **zeros** (training-mean weather in z-score space) for the model's weather raster channel. The cluster runs that hit **5.24 % MAPE** used real HRRR rasters. Calendar features and the recent demand pattern still drive the output, so the forecast shape is preserved, but absolute accuracy is degraded vs. the cluster.
30
 
31
- The full real-weather pipeline (live HRRR fetch + per-zone real-time demand) is documented in the report and tracked as future work.
32
 
33
  ## Links
34
 
@@ -48,12 +46,19 @@ python app.py # http://localhost:7860
48
 
49
  | File | Purpose |
50
  |---|---|
51
- | `app.py` | Gradio Blocks UI + request handler |
52
- | `iso_ne_fetch.py` | Live ISO-NE demand fetch (with CSV fallback) |
53
  | `calendar_features.py` | 44-d calendar one-hot encoder |
54
- | `model_utils.py` | Checkpoint loading + inference + denormalization |
55
  | `models/cnn_transformer_baseline.py` | Baseline architecture (1.75 M params) |
56
  | `checkpoints/best.pt` | Trained baseline weights (~20 MB) |
57
  | `checkpoints/norm_stats.pt` | z-score statistics for de-/normalization |
 
58
  | `assets/` | Figures shown in the *About* tab |
59
  | `about.md` | Demo explanation rendered in the UI |
 
 
 
 
 
 
 
14
 
15
  # ⚡ Multi-Modal Deep Learning for Energy Demand Forecasting
16
 
17
+ Live demo of two models from our CS-137 final project (Tufts, Spring 2026):
18
 
19
+ 1. **Part 1 baseline** — CNN-Transformer (1.75 M params), reaches **5.24 % MAPE** with real HRRR weather on the 2022 self-eval slice.
20
+ 2. **Ensemble (Baseline ⊕ Chronos-Bolt-mini, zero-shot, per-zone α)** — adds the 21 M-param Amazon foundation model on demand history alone (no weather, no fine-tuning) and reaches **4.21 % MAPE** in offline evaluation.
21
 
22
+ ## What it does
 
 
 
 
23
 
24
+ 1. **Real-time tab**: every click pulls the most recent 24 h of ISO New England system demand from the [EIA Open Data API](https://www.eia.gov/opendata/) (`respondent=ISNE`, `type=D`), splits it into the 8 ISO-NE zones via fixed proportions, and runs the chosen model on it. The Space holds a personal `EIA_API_KEY` as a Secret; if EIA is unreachable we fall through to an ISO-NE legacy endpoint and finally to a bundled 2022 sample.
25
+ 2. **Backtest tab**: 7 pre-computed daily forecasts (Dec 25–31, 2022 at 00:00 UTC) with all three models side-by-side and a per-zone MAPE table. The baseline curves there were computed on the Tufts HPC cluster with **real HRRR weather**, so this tab reaches the headline accuracy that the live tab can't get without weather inputs.
26
 
27
+ ## Demo limitation synthetic weather inputs (live tab)
28
 
29
+ The live tab substitutes **zeros** (training-mean weather in z-score space) for the baseline's weather raster channels because real-time HRRR isn't accessible from the Space. Calendar features (hour-of-day, day-of-week, month, holiday flag) and the recent demand pattern still drive the output, so the forecast shape is preserved, but absolute accuracy is lower than the cluster's 5.24 %. **Ensemble** mode largely closes the gap because Chronos-Bolt-mini doesn't need weather at all.
30
 
31
  ## Links
32
 
 
46
 
47
  | File | Purpose |
48
  |---|---|
49
+ | `app.py` | Gradio Blocks UI + Real-time / Backtest / About tabs |
50
+ | `iso_ne_fetch.py` | Live demand fetch: EIA API → ISO-NE legacy bundled CSV |
51
  | `calendar_features.py` | 44-d calendar one-hot encoder |
52
+ | `model_utils.py` | Baseline + Chronos-Bolt-mini loading, inference, per-zone ensemble |
53
  | `models/cnn_transformer_baseline.py` | Baseline architecture (1.75 M params) |
54
  | `checkpoints/best.pt` | Trained baseline weights (~20 MB) |
55
  | `checkpoints/norm_stats.pt` | z-score statistics for de-/normalization |
56
+ | `assets/backtest_2022_last7d.json` | 7-day cached forecasts shown in the Backtest tab |
57
  | `assets/` | Figures shown in the *About* tab |
58
  | `about.md` | Demo explanation rendered in the UI |
59
+
60
+ ## Secrets
61
+
62
+ | Name | Purpose |
63
+ |---|---|
64
+ | `EIA_API_KEY` | Personal EIA Open Data key for live ISO-NE demand. Free; register at https://www.eia.gov/opendata/register.php. Without this secret the Space still works — it just falls through to the ISO-NE legacy endpoint and (if that also fails) a bundled 2022 sample. |
about.md CHANGED
@@ -5,7 +5,11 @@ This Space runs two models from our CS-137 final project on **live ISO New Engla
5
  1. **Baseline only** — the Part 1 CNN-Transformer (1.75 M params). Reaches **5.24 % MAPE** with real HRRR weather on the 2022 self-evaluation slice; in this Space the weather inputs are synthetic so accuracy is degraded.
6
  2. **Ensemble (Baseline + Chronos-Bolt-mini)** — late-fusion of the baseline with [Chronos-Bolt-mini](https://huggingface.co/amazon/chronos-bolt-mini) (Amazon, 21 M params, Apache-2.0), used **zero-shot on demand history only** — no weather, no fine-tuning. Reaches **4.21 % MAPE** on the same offline slice and is the recommended path for this demo.
7
 
8
- The Model selector at the top of the page switches between them.
 
 
 
 
9
 
10
  ### What's real vs. synthetic
11
 
@@ -21,9 +25,13 @@ In Baseline-only mode, the forecast is degraded vs. the cluster's **5.24 %** MAP
21
 
22
  In Ensemble mode, Chronos-Bolt-mini receives 720 hours (4 weeks) of recent per-zone demand and outputs a zero-shot 24-hour forecast for each zone. Per-zone weights $\alpha_z$ (shown beneath the chart) control the blend: $\alpha_z = 1$ keeps only the baseline; $\alpha_z = 0$ keeps only Chronos. The values come from a grid search on a 14-day validation window (2022-12-16 → 12-29) and are hard-coded in this Space — see Table 10 of the report for the underlying ablation.
23
 
 
 
 
 
24
  ### Per-zone allocation
25
 
26
- ISO-NE's public data feed publishes *system-level* demand at 5-minute granularity. We split that total into 8 zones using fixed proportions estimated from 2022 historical zonal load reports. Per-zone real-time data requires an authenticated ISO Express account.
27
 
28
  ### What this is for
29
 
 
5
  1. **Baseline only** — the Part 1 CNN-Transformer (1.75 M params). Reaches **5.24 % MAPE** with real HRRR weather on the 2022 self-evaluation slice; in this Space the weather inputs are synthetic so accuracy is degraded.
6
  2. **Ensemble (Baseline + Chronos-Bolt-mini)** — late-fusion of the baseline with [Chronos-Bolt-mini](https://huggingface.co/amazon/chronos-bolt-mini) (Amazon, 21 M params, Apache-2.0), used **zero-shot on demand history only** — no weather, no fine-tuning. Reaches **4.21 % MAPE** on the same offline slice and is the recommended path for this demo.
7
 
8
+ The Model selector at the top of the page switches between them. The Real-time tab always issues a forecast for *now*; the Backtest tab shows 7 pre-computed forecasts over the last week of 2022 with cluster-quality (real-HRRR) baselines so you can see the headline accuracy.
9
+
10
+ ### Real-time data path
11
+
12
+ Each click on **Forecast next 24 h** pulls the most recent 24 hours of ISO-NE system demand from the [EIA Open Data API](https://www.eia.gov/opendata/) (`respondent=ISNE`, `type=D`). The Space holds my personal API key as a Secret named `EIA_API_KEY`; if EIA is unreachable we fall back to an ISO-NE legacy endpoint and finally to the bundled 2022 sample. The status line above the plots tells you which source served the request (`live (EIA)`, `live (ISO-NE)`, `cached`, or `sample-2022`).
13
 
14
  ### What's real vs. synthetic
15
 
 
25
 
26
  In Ensemble mode, Chronos-Bolt-mini receives 720 hours (4 weeks) of recent per-zone demand and outputs a zero-shot 24-hour forecast for each zone. Per-zone weights $\alpha_z$ (shown beneath the chart) control the blend: $\alpha_z = 1$ keeps only the baseline; $\alpha_z = 0$ keeps only Chronos. The values come from a grid search on a 14-day validation window (2022-12-16 → 12-29) and are hard-coded in this Space — see Table 10 of the report for the underlying ablation.
27
 
28
+ ### Backtest tab
29
+
30
+ The **Backtest** tab plays back 7 daily forecasts (Dec 25–31, 2022 at 00:00 UTC) from the `space/assets/backtest_2022_last7d.json` cache. The baseline curves there were computed on the Tufts HPC cluster with real HRRR weather inputs, so this tab demonstrates the headline accuracy that the live tab can't reach without weather. The Chronos and Ensemble curves are computed locally with the same code paths the live tab uses.
31
+
32
  ### Per-zone allocation
33
 
34
+ ISO-NE's public data feed publishes *system-level* demand at hourly granularity. We split that total into 8 zones using fixed proportions estimated from 2022 historical zonal load reports. Per-zone real-time data requires an authenticated ISO Express account.
35
 
36
  ### What this is for
37
 
app.py CHANGED
@@ -1,18 +1,34 @@
1
  """Gradio Space: Multi-Modal Deep Learning for Energy Demand Forecasting.
2
 
 
 
 
 
 
 
 
 
3
  Two model modes:
4
- - Baseline only: Part 1 CNN-Transformer (1.75 M params) on synthetic weather
5
- + real demand history.
6
- - Ensemble (Baseline + Chronos-Bolt-mini): weather-aware baseline blended
7
- per-zone with the 21 M-parameter foundation model used
8
- zero-shot on demand history. Per Table 10 of the report,
9
- the mini variant of Chronos-Bolt gives the best per-zone
10
- ensemble (4.21 % test MAPE) and is small enough to run on
11
- the HF Spaces free CPU tier.
 
 
 
 
 
 
 
12
  """
13
 
14
  from __future__ import annotations
15
 
 
16
  from datetime import datetime, timedelta, timezone
17
  from pathlib import Path
18
 
@@ -35,10 +51,13 @@ from model_utils import (
35
  ROOT = Path(__file__).parent
36
  ASSETS = ROOT / "assets"
37
  ABOUT = (ROOT / "about.md").read_text()
 
38
 
39
  NAVY = "#1A3A5C"
40
  ACCENT = "#2E86DE"
41
  AMBER = "#C97B12"
 
 
42
 
43
  print("Loading baseline checkpoint...")
44
  MODEL, NORM_STATS = load_baseline(ROOT / "checkpoints" / "best.pt", device="cpu")
@@ -56,34 +75,27 @@ def _get_chronos():
56
  return _CHRONOS["pipeline"]
57
 
58
 
59
- def _parse_dt(text: str) -> datetime:
60
- if not text or text.lower().startswith("(leave"):
61
- dt = datetime.now(timezone.utc)
62
- else:
63
- try:
64
- dt = datetime.fromisoformat(text.strip().replace("Z", "+00:00"))
65
- except ValueError:
66
- dt = datetime.now(timezone.utc)
67
- if dt.tzinfo is None:
68
- dt = dt.replace(tzinfo=timezone.utc)
69
- return dt.replace(minute=0, second=0, microsecond=0)
70
 
71
 
72
- def forecast(target_dt_text: str, model_choice: str):
73
- target = _parse_dt(target_dt_text)
 
 
 
 
 
74
  hist_start = target - timedelta(hours=24)
75
 
76
- # Always need 24 h history for the baseline.
77
  hist_demand, source = fetch_recent_demand_mwh(target)
78
  hist_cal = encode_range(hist_start, 24)
79
  fut_cal = encode_range(target, 24)
80
 
81
- # Run baseline on synthetic weather.
82
  baseline_pred = run_forecast(MODEL, hist_demand, hist_cal, fut_cal,
83
  NORM_STATS, device="cpu")
84
 
85
  if model_choice == "Ensemble (Baseline + Chronos-Bolt-mini)":
86
- # Need 720 h of demand history for Chronos.
87
  long_history, long_source = fetch_long_history_mwh(target, hours=720)
88
  pipeline = _get_chronos()
89
  chronos_pred = run_chronos_zeroshot(pipeline, long_history)
@@ -104,11 +116,11 @@ def forecast(target_dt_text: str, model_choice: str):
104
  bar = _bar_plot(target, pred_mwh[0])
105
  sys_total = pred_mwh.sum(axis=1)
106
  summary = (
107
- f"{active_label} · "
108
- f"Demand history: `{source}` · "
109
- f"forecast: **{target.strftime('%Y-%m-%d %H:00')} UTC** "
110
- f"to **{(target + timedelta(hours=24)).strftime('%Y-%m-%d %H:00')} UTC** · "
111
- f"system-level peak in next 24 h: **{sys_total.max():,.0f} MW**."
112
  )
113
  return line, bar, summary
114
 
@@ -122,23 +134,20 @@ def _line_plot(target: datetime, hist: np.ndarray, pred: np.ndarray,
122
  hist_t = [target - timedelta(hours=24 - i) for i in range(24)]
123
  fut_t = [target + timedelta(hours=i + 1) for i in range(24)]
124
  overlay = overlay or {}
125
- overlay_palette = ["#7F8C8D", "#16A085", AMBER] # gray, teal, amber for overlays
126
 
127
  for i, zone in enumerate(ZONE_COLS):
128
  r, c = i // 2 + 1, i % 2 + 1
129
- # History (always solid navy)
130
  fig.add_trace(go.Scatter(
131
  x=hist_t, y=hist[:, i], mode="lines",
132
  line=dict(color=NAVY, width=2),
133
  name="history", showlegend=(i == 0),
134
  ), row=r, col=c)
135
- # Active forecast (dashed accent blue, thicker)
136
  fig.add_trace(go.Scatter(
137
  x=fut_t, y=pred[:, i], mode="lines",
138
  line=dict(color=ACCENT, width=2.5, dash="dash"),
139
  name="forecast (active)", showlegend=(i == 0),
140
  ), row=r, col=c)
141
- # Optional overlays (component models when in ensemble mode)
142
  for k, (label, arr) in enumerate(overlay.items()):
143
  colour = overlay_palette[k % len(overlay_palette)]
144
  fig.add_trace(go.Scatter(
@@ -178,43 +187,166 @@ def _bar_plot(target: datetime, next_hour_pred: np.ndarray):
178
 
179
 
180
  def _alpha_table_md() -> str:
181
- """Markdown table of per-zone alpha values for the ensemble path."""
182
  rows = " | ".join(f"{z}: {ALPHA_PER_ZONE_MINI[z]:.2f}" for z in ZONE_COLS)
183
  return f"**Per-zone α (weight on Baseline; 1−α goes to Chronos):** {rows}"
184
 
185
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  with gr.Blocks(title="ISO-NE Energy Demand Forecast",
187
  theme=gr.themes.Default(primary_hue="blue")) as demo:
188
  gr.Markdown(
189
  "# ⚡ Multi-Modal Deep Learning for Energy Demand Forecasting\n"
190
  "**Author:** Pang Liu · Tufts CS-137 · "
191
  "[GitHub](https://github.com/jeffliulab/real-time-power-predict)\n\n"
192
- "> **Demo limitation:** weather inputs are synthetic (training-mean "
193
- "zeros). The cluster runs reach **5.24 % MAPE** with real HRRR weather; "
194
- "the **Ensemble** path adds Chronos-Bolt-mini (foundation model, "
195
- "zero-shot on demand history) which does not need weather and reaches "
196
- "**4.21 % MAPE** in our offline evaluation. See the *About* tab for details."
 
 
 
 
197
  )
198
  with gr.Row():
199
- dt_input = gr.Textbox(
200
- label="Target datetime (UTC, ISO-8601 — e.g. 2022-12-30T18:00). Leave empty for now.",
201
- value="",
202
- placeholder="(leave empty for now)",
203
- )
204
  model_choice = gr.Radio(
205
  choices=["Baseline only",
206
  "Ensemble (Baseline + Chronos-Bolt-mini)"],
207
  value="Ensemble (Baseline + Chronos-Bolt-mini)",
208
  label="Model",
209
- scale=1,
210
  )
211
- run_btn = gr.Button("Run forecast", variant="primary", scale=0)
 
212
  summary_md = gr.Markdown()
213
  with gr.Tabs():
214
- with gr.Tab("Forecast"):
215
  line_plot = gr.Plot(label="Per-zone history + forecast")
216
  bar_plot = gr.Plot(label="Predicted next-hour demand")
217
  gr.Markdown(_alpha_table_md())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  with gr.Tab("About"):
219
  gr.Markdown(ABOUT)
220
  with gr.Row():
@@ -228,9 +360,9 @@ with gr.Blocks(title="ISO-NE Energy Demand Forecast",
228
  label="Baseline CNN-Transformer architecture",
229
  show_label=True)
230
 
231
- run_btn.click(forecast, inputs=[dt_input, model_choice],
232
  outputs=[line_plot, bar_plot, summary_md])
233
- demo.load(forecast, inputs=[dt_input, model_choice],
234
  outputs=[line_plot, bar_plot, summary_md])
235
 
236
 
 
1
  """Gradio Space: Multi-Modal Deep Learning for Energy Demand Forecasting.
2
 
3
+ Real-time mode (always-now, no user-supplied datetime):
4
+ - Pulls the most recent 24 h of ISO-NE system demand from the EIA Open
5
+ Data API (free key, exposed to this Space as the `EIA_API_KEY`
6
+ secret), splits it into the 8 ISO-NE zones via fixed proportions,
7
+ and runs the chosen model on it.
8
+ - Falls back to the bundled 2022 sample window when the live API is
9
+ unreachable.
10
+
11
  Two model modes:
12
+ - Baseline only: Part 1 CNN-Transformer (1.75 M params) on synthetic
13
+ weather + real demand history.
14
+ - Ensemble (Baseline + Chronos-Bolt-mini): weather-aware baseline
15
+ blended per-zone with the 21 M-param foundation
16
+ model used zero-shot on demand history. Per Table 10
17
+ of the report, mini gives the best per-zone ensemble
18
+ (4.21 % test MAPE) and is small enough to run on the
19
+ HF Spaces free CPU tier.
20
+
21
+ Backtest tab:
22
+ - Pre-computed 7-day backtest (Dec 25-31, 2022) showing all three
23
+ models' forecasts vs. ground truth, with per-zone and overall MAPE.
24
+ - The baseline forecasts in this cache use REAL HRRR weather (computed
25
+ on the cluster), so this tab demonstrates the headline accuracy
26
+ that the live tab can't reach without weather inputs.
27
  """
28
 
29
  from __future__ import annotations
30
 
31
+ import json
32
  from datetime import datetime, timedelta, timezone
33
  from pathlib import Path
34
 
 
51
  ROOT = Path(__file__).parent
52
  ASSETS = ROOT / "assets"
53
  ABOUT = (ROOT / "about.md").read_text()
54
+ BACKTEST_JSON = ASSETS / "backtest_2022_last7d.json"
55
 
56
  NAVY = "#1A3A5C"
57
  ACCENT = "#2E86DE"
58
  AMBER = "#C97B12"
59
+ TEAL = "#16A085"
60
+ GREY = "#7F8C8D"
61
 
62
  print("Loading baseline checkpoint...")
63
  MODEL, NORM_STATS = load_baseline(ROOT / "checkpoints" / "best.pt", device="cpu")
 
75
  return _CHRONOS["pipeline"]
76
 
77
 
78
+ def _now_utc_hour() -> datetime:
79
+ return datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
 
 
 
 
 
 
 
 
 
80
 
81
 
82
+ # =====================================================================
83
+ # Real-time forecast
84
+ # =====================================================================
85
+
86
+ def forecast(model_choice: str):
87
+ """Always-now real-time forecast: pulls live demand, runs the chosen model."""
88
+ target = _now_utc_hour()
89
  hist_start = target - timedelta(hours=24)
90
 
 
91
  hist_demand, source = fetch_recent_demand_mwh(target)
92
  hist_cal = encode_range(hist_start, 24)
93
  fut_cal = encode_range(target, 24)
94
 
 
95
  baseline_pred = run_forecast(MODEL, hist_demand, hist_cal, fut_cal,
96
  NORM_STATS, device="cpu")
97
 
98
  if model_choice == "Ensemble (Baseline + Chronos-Bolt-mini)":
 
99
  long_history, long_source = fetch_long_history_mwh(target, hours=720)
100
  pipeline = _get_chronos()
101
  chronos_pred = run_chronos_zeroshot(pipeline, long_history)
 
116
  bar = _bar_plot(target, pred_mwh[0])
117
  sys_total = pred_mwh.sum(axis=1)
118
  summary = (
119
+ f"{active_label} \n"
120
+ f"Demand history source: `{source}` · "
121
+ f"forecast issued at **{target.strftime('%Y-%m-%d %H:00')} UTC** · "
122
+ f"covers next 24 h to **{(target + timedelta(hours=24)).strftime('%Y-%m-%d %H:00')} UTC** · "
123
+ f"system-level peak: **{sys_total.max():,.0f} MW**."
124
  )
125
  return line, bar, summary
126
 
 
134
  hist_t = [target - timedelta(hours=24 - i) for i in range(24)]
135
  fut_t = [target + timedelta(hours=i + 1) for i in range(24)]
136
  overlay = overlay or {}
137
+ overlay_palette = [GREY, TEAL, AMBER]
138
 
139
  for i, zone in enumerate(ZONE_COLS):
140
  r, c = i // 2 + 1, i % 2 + 1
 
141
  fig.add_trace(go.Scatter(
142
  x=hist_t, y=hist[:, i], mode="lines",
143
  line=dict(color=NAVY, width=2),
144
  name="history", showlegend=(i == 0),
145
  ), row=r, col=c)
 
146
  fig.add_trace(go.Scatter(
147
  x=fut_t, y=pred[:, i], mode="lines",
148
  line=dict(color=ACCENT, width=2.5, dash="dash"),
149
  name="forecast (active)", showlegend=(i == 0),
150
  ), row=r, col=c)
 
151
  for k, (label, arr) in enumerate(overlay.items()):
152
  colour = overlay_palette[k % len(overlay_palette)]
153
  fig.add_trace(go.Scatter(
 
187
 
188
 
189
  def _alpha_table_md() -> str:
 
190
  rows = " | ".join(f"{z}: {ALPHA_PER_ZONE_MINI[z]:.2f}" for z in ZONE_COLS)
191
  return f"**Per-zone α (weight on Baseline; 1−α goes to Chronos):** {rows}"
192
 
193
 
194
+ # =====================================================================
195
+ # Backtest tab (cached: 7 forecasts, Dec 25-31, 2022, real HRRR weather)
196
+ # =====================================================================
197
+
198
+ if BACKTEST_JSON.exists():
199
+ BACKTEST = json.loads(BACKTEST_JSON.read_text())
200
+ else:
201
+ BACKTEST = None
202
+ print(f"WARNING: backtest cache not found at {BACKTEST_JSON}")
203
+
204
+
205
+ def _backtest_overview_plot():
206
+ """One row per zone, showing 7-day truth vs. each model's forecast."""
207
+ if BACKTEST is None:
208
+ return go.Figure()
209
+ forecasts = BACKTEST["forecasts"]
210
+ fig = make_subplots(rows=4, cols=2, shared_xaxes=False,
211
+ subplot_titles=ZONE_COLS,
212
+ vertical_spacing=0.10, horizontal_spacing=0.07)
213
+ for i, zone in enumerate(ZONE_COLS):
214
+ r, c = i // 2 + 1, i % 2 + 1
215
+ for f in forecasts:
216
+ start = datetime.fromisoformat(f["start"]).replace(tzinfo=timezone.utc)
217
+ t = [start + timedelta(hours=h) for h in range(24)]
218
+ truth = np.asarray(f["truth_24h"])[:, i]
219
+ base = np.asarray(f["baseline"])[:, i]
220
+ chron = np.asarray(f["chronos"])[:, i]
221
+ ens = np.asarray(f["ensemble"])[:, i]
222
+ show = (i == 0 and f is forecasts[0])
223
+ fig.add_trace(go.Scatter(
224
+ x=t, y=truth, mode="lines",
225
+ line=dict(color=NAVY, width=2),
226
+ name="actual demand", showlegend=show,
227
+ ), row=r, col=c)
228
+ fig.add_trace(go.Scatter(
229
+ x=t, y=base, mode="lines",
230
+ line=dict(color=GREY, width=1, dash="dot"),
231
+ name="baseline (real HRRR)", showlegend=show,
232
+ opacity=0.85,
233
+ ), row=r, col=c)
234
+ fig.add_trace(go.Scatter(
235
+ x=t, y=chron, mode="lines",
236
+ line=dict(color=TEAL, width=1, dash="dot"),
237
+ name="chronos zero-shot", showlegend=show,
238
+ opacity=0.85,
239
+ ), row=r, col=c)
240
+ fig.add_trace(go.Scatter(
241
+ x=t, y=ens, mode="lines",
242
+ line=dict(color=ACCENT, width=2, dash="dash"),
243
+ name="ensemble", showlegend=show,
244
+ ), row=r, col=c)
245
+ fig.update_layout(
246
+ title="7-day backtest, Dec 25-31 2022 — actual demand vs. 3 model variants",
247
+ height=900, plot_bgcolor="white",
248
+ margin=dict(l=40, r=20, t=80, b=40),
249
+ legend=dict(orientation="h", yanchor="bottom", y=1.02,
250
+ xanchor="right", x=1),
251
+ )
252
+ fig.update_yaxes(title_text="MW", title_standoff=4)
253
+ return fig
254
+
255
+
256
+ def _backtest_summary_md() -> str:
257
+ if BACKTEST is None:
258
+ return "_Backtest cache missing — re-run `scripts/build_space_backtest.py`._"
259
+ s = BACKTEST["summary"]
260
+ rows = []
261
+ rows.append("| Model | " + " | ".join(ZONE_COLS) + " | **Overall** |")
262
+ rows.append("|---|" + "|".join(["---"] * (len(ZONE_COLS) + 1)) + "|")
263
+ for key, label in (("baseline", "Baseline (real HRRR)"),
264
+ ("chronos", "Chronos-Bolt-mini (zero-shot)"),
265
+ ("ensemble", "Ensemble (per-zone α)")):
266
+ per_zone = " | ".join(f"{s[key]['per_zone'][z]:.2f}" for z in ZONE_COLS)
267
+ rows.append(f"| {label} | {per_zone} | **{s[key]['overall']:.2f}** |")
268
+ table = "\n".join(rows)
269
+ return (
270
+ f"### 7-day average MAPE (%) over {BACKTEST['n_forecasts']} forecasts (Dec 25–31, 2022)\n\n"
271
+ f"{table}\n\n"
272
+ f"_Each forecast is a 24-hour prediction starting at 00:00 UTC. The "
273
+ f"baseline numbers in this table use **real HRRR weather** (computed "
274
+ f"on the cluster), so they reflect the headline 5.24 % test MAPE setup. "
275
+ f"The live tab above uses synthetic weather, so its accuracy is lower; "
276
+ f"the **Ensemble** path closes most of that gap because Chronos-Bolt-mini "
277
+ f"doesn't need weather at all._"
278
+ )
279
+
280
+
281
+ def _backtest_bars():
282
+ """Bar chart: overall MAPE per model (averaged over 7 forecasts)."""
283
+ if BACKTEST is None:
284
+ return go.Figure()
285
+ s = BACKTEST["summary"]
286
+ labels = ["Baseline\n(real HRRR)", "Chronos-Bolt-mini\n(zero-shot)", "Ensemble\n(per-zone α)"]
287
+ values = [s["baseline"]["overall"], s["chronos"]["overall"], s["ensemble"]["overall"]]
288
+ fig = go.Figure(go.Bar(
289
+ x=labels, y=values, marker_color=[GREY, TEAL, ACCENT],
290
+ text=[f"{v:.2f}%" for v in values], textposition="outside",
291
+ ))
292
+ fig.update_layout(
293
+ title="Overall MAPE on the 7-day backtest (lower is better)",
294
+ yaxis_title="MAPE (%)", height=350, plot_bgcolor="white",
295
+ margin=dict(l=60, r=40, t=60, b=60),
296
+ )
297
+ return fig
298
+
299
+
300
+ # =====================================================================
301
+ # Gradio layout
302
+ # =====================================================================
303
+
304
  with gr.Blocks(title="ISO-NE Energy Demand Forecast",
305
  theme=gr.themes.Default(primary_hue="blue")) as demo:
306
  gr.Markdown(
307
  "# ⚡ Multi-Modal Deep Learning for Energy Demand Forecasting\n"
308
  "**Author:** Pang Liu · Tufts CS-137 · "
309
  "[GitHub](https://github.com/jeffliulab/real-time-power-predict)\n\n"
310
+ "> 🔴 **Real-time mode**: every click pulls the most recent ISO-NE system "
311
+ "demand from the EIA Open Data API and forecasts the next 24 h.\n"
312
+ "> **Demo limitation**: weather inputs are synthetic (training-mean "
313
+ "zeros) since real-time HRRR rasters aren't available in this Space. "
314
+ "The cluster runs reach **5.24 % MAPE** with real HRRR weather; the "
315
+ "**Ensemble** path adds Chronos-Bolt-mini (zero-shot on demand history "
316
+ "only — no weather) and reaches **4.21 % MAPE** in our offline "
317
+ "evaluation. See the **Backtest** tab for a 7-day side-by-side "
318
+ "comparison and the **About** tab for full details."
319
  )
320
  with gr.Row():
 
 
 
 
 
321
  model_choice = gr.Radio(
322
  choices=["Baseline only",
323
  "Ensemble (Baseline + Chronos-Bolt-mini)"],
324
  value="Ensemble (Baseline + Chronos-Bolt-mini)",
325
  label="Model",
326
+ scale=2,
327
  )
328
+ run_btn = gr.Button("Forecast next 24 h (now)",
329
+ variant="primary", scale=1)
330
  summary_md = gr.Markdown()
331
  with gr.Tabs():
332
+ with gr.Tab("Real-time forecast"):
333
  line_plot = gr.Plot(label="Per-zone history + forecast")
334
  bar_plot = gr.Plot(label="Predicted next-hour demand")
335
  gr.Markdown(_alpha_table_md())
336
+ with gr.Tab("Backtest (last 7 days of 2022)"):
337
+ gr.Markdown(
338
+ "These are 7 daily forecasts on the held-out 2022-12-25 → "
339
+ "12-31 window, each issued at 00:00 UTC for the next 24 h. "
340
+ "The **baseline** column uses real HRRR weather (computed "
341
+ "offline on the cluster); **Chronos-Bolt-mini** is zero-shot; "
342
+ "the **ensemble** is the per-zone weighted blend reported in "
343
+ "the paper."
344
+ )
345
+ backtest_plot = gr.Plot(value=_backtest_overview_plot(),
346
+ label="7-day per-zone comparison")
347
+ backtest_bars = gr.Plot(value=_backtest_bars(),
348
+ label="Overall MAPE")
349
+ gr.Markdown(_backtest_summary_md())
350
  with gr.Tab("About"):
351
  gr.Markdown(ABOUT)
352
  with gr.Row():
 
360
  label="Baseline CNN-Transformer architecture",
361
  show_label=True)
362
 
363
+ run_btn.click(forecast, inputs=[model_choice],
364
  outputs=[line_plot, bar_plot, summary_md])
365
+ demo.load(forecast, inputs=[model_choice],
366
  outputs=[line_plot, bar_plot, summary_md])
367
 
368
 
assets/backtest_2022_last7d.json ADDED
The diff for this file is too large to render. See raw diff
 
iso_ne_fetch.py CHANGED
@@ -1,27 +1,33 @@
1
  """
2
  Fetch the past 24 hours of ISO-NE per-zone demand for the live demo.
3
 
4
- Two sources, in priority order:
5
-
6
- 1. **Public genfuelmix API** at https://www.iso-ne.com/ws/wsclient
7
- (returns system-level demand). We split the total into the 8
8
- load zones using a fixed zonal-proportion vector estimated from
9
- the 2022 historical zonal data.
10
-
11
- 2. **Bundled CSV fallback** at `assets/sample_demand_2022.csv`.
12
- Used when the API is unreachable (CORS, rate limit, HF networking
13
- restrictions). Returns a representative 24-hour slice from
14
- 2022-12-30/31 (the same window we use for self-eval).
15
-
16
- Public API access for true per-zone real-time data requires an
17
- authenticated ISO-NE account. The proportional split is a reasonable
18
- approximation for a demo --- the model still sees real recent
19
- ISO-NE-wide demand patterns; only the per-zone allocation is fixed.
 
 
 
 
 
20
  """
21
 
22
  from __future__ import annotations
23
 
24
  import logging
 
25
  from datetime import datetime, timedelta, timezone
26
  from pathlib import Path
27
  from typing import Optional
@@ -62,10 +68,68 @@ def _cache_key(end_dt: datetime) -> str:
62
  return end_dt.strftime("%Y-%m-%dT%H:00")
63
 
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  def _try_iso_ne_api(end_dt: datetime) -> Optional[np.ndarray]:
66
- """Attempt to fetch system demand from ISO-NE public endpoints.
67
 
68
- Returns (24, 8) array in MWh on success, or None on any failure.
 
69
  """
70
  try:
71
  url = "https://www.iso-ne.com/ws/wsclient"
@@ -126,10 +190,15 @@ def fetch_recent_demand_mwh(end_dt: Optional[datetime] = None):
126
  if (datetime.now(timezone.utc) - ts).total_seconds() < _CACHE_TTL_SECONDS:
127
  return arr.copy(), "cached"
128
 
 
 
 
 
 
129
  arr = _try_iso_ne_api(end_dt)
130
  if arr is not None:
131
  _CACHE[key] = (datetime.now(timezone.utc), arr)
132
- return arr.copy(), "live"
133
 
134
  arr = _load_sample_csv()
135
  return arr, "sample-2022"
 
1
  """
2
  Fetch the past 24 hours of ISO-NE per-zone demand for the live demo.
3
 
4
+ Three sources, in priority order:
5
+
6
+ 1. **EIA Open Data API** at https://api.eia.gov/v2/electricity/rto/region-data
7
+ (system-level hourly demand, respondent=ISNE). Free, requires a
8
+ personal API key registered via https://www.eia.gov/opendata/register.php
9
+ and exposed to the Space as the secret `EIA_API_KEY`. We split the
10
+ system total into the 8 ISO-NE zones using a fixed proportion
11
+ vector estimated from 2022 zonal load reports.
12
+
13
+ 2. **ISO-NE legacy `wsclient` endpoint**. Tried as a backup; in
14
+ practice it currently returns HTTP 500 from outside the IETF
15
+ network, so it almost always falls through.
16
+
17
+ 3. **Bundled CSV fallback** at `assets/sample_demand_2022.csv` (24 h)
18
+ and `assets/sample_demand_2022_long.csv` (720 h). Used when both
19
+ live paths fail (no key configured, network down, rate-limited).
20
+
21
+ True per-zone real-time data requires an authenticated ISO Express
22
+ account. The proportional split is a reasonable demo approximation:
23
+ the model sees real recent ISO-NE-wide demand patterns; only the
24
+ per-zone allocation is fixed.
25
  """
26
 
27
  from __future__ import annotations
28
 
29
  import logging
30
+ import os
31
  from datetime import datetime, timedelta, timezone
32
  from pathlib import Path
33
  from typing import Optional
 
68
  return end_dt.strftime("%Y-%m-%dT%H:00")
69
 
70
 
71
+ EIA_API_URL = "https://api.eia.gov/v2/electricity/rto/region-data/data/"
72
+
73
+
74
+ def _try_eia_api(end_dt: datetime, hours: int = 24) -> Optional[np.ndarray]:
75
+ """Fetch ISO-NE system demand from EIA Open Data.
76
+
77
+ Requires the env var ``EIA_API_KEY`` (registered free at
78
+ https://www.eia.gov/opendata/register.php and exposed to this
79
+ Space as a Secret).
80
+
81
+ Returns ``(hours, 8)`` MWh array on success, ``None`` on any failure
82
+ (no key, HTTP error, missing rows, parse error).
83
+ """
84
+ key = os.environ.get("EIA_API_KEY", "").strip()
85
+ if not key:
86
+ return None
87
+ try:
88
+ # EIA returns data on hour-ending convention; pull a generous
89
+ # window so we can clip the freshest `hours` hours.
90
+ start = (end_dt - timedelta(hours=hours + 6)).strftime("%Y-%m-%dT%H")
91
+ end = end_dt.strftime("%Y-%m-%dT%H")
92
+ params = {
93
+ "api_key": key,
94
+ "frequency": "hourly",
95
+ "data[0]": "value",
96
+ "facets[respondent][]": "ISNE",
97
+ "facets[type][]": "D", # 'D' = demand
98
+ "start": start,
99
+ "end": end,
100
+ "sort[0][column]": "period",
101
+ "sort[0][direction]": "desc",
102
+ "length": hours + 24,
103
+ }
104
+ r = requests.get(EIA_API_URL, params=params, timeout=8)
105
+ if r.status_code != 200:
106
+ logger.info("EIA API HTTP %d: %s", r.status_code, r.text[:200])
107
+ return None
108
+ payload = r.json()
109
+ rows = payload.get("response", {}).get("data", [])
110
+ if not rows:
111
+ return None
112
+ df = pd.DataFrame(rows)
113
+ if "period" not in df.columns or "value" not in df.columns:
114
+ return None
115
+ df["ts"] = pd.to_datetime(df["period"], utc=True, errors="coerce")
116
+ df = df.dropna(subset=["ts"]).sort_values("ts")
117
+ df["value"] = pd.to_numeric(df["value"], errors="coerce")
118
+ df = df.dropna(subset=["value"])
119
+ if len(df) < hours:
120
+ return None
121
+ last = df.tail(hours)["value"].to_numpy(dtype=np.float32)
122
+ return _split_to_zones(last)
123
+ except Exception as e: # noqa: BLE001
124
+ logger.info("EIA API fetch failed: %s", e)
125
+ return None
126
+
127
+
128
  def _try_iso_ne_api(end_dt: datetime) -> Optional[np.ndarray]:
129
+ """Backup: ISO-NE legacy wsclient endpoint.
130
 
131
+ Frequently returns HTTP 500 from outside their network, so this
132
+ is mostly a fallback after EIA. Returns ``(24, 8)`` MWh or ``None``.
133
  """
134
  try:
135
  url = "https://www.iso-ne.com/ws/wsclient"
 
190
  if (datetime.now(timezone.utc) - ts).total_seconds() < _CACHE_TTL_SECONDS:
191
  return arr.copy(), "cached"
192
 
193
+ arr = _try_eia_api(end_dt, hours=24)
194
+ if arr is not None:
195
+ _CACHE[key] = (datetime.now(timezone.utc), arr)
196
+ return arr.copy(), "live (EIA)"
197
+
198
  arr = _try_iso_ne_api(end_dt)
199
  if arr is not None:
200
  _CACHE[key] = (datetime.now(timezone.utc), arr)
201
+ return arr.copy(), "live (ISO-NE)"
202
 
203
  arr = _load_sample_csv()
204
  return arr, "sample-2022"