Spaces:
Sleeping
Sleeping
Real-time mode + 7-day backtest tab + EIA API integration
Browse files- README.md +18 -13
- about.md +10 -2
- app.py +181 -49
- assets/backtest_2022_last7d.json +0 -0
- 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
|
| 18 |
|
| 19 |
-
|
|
|
|
| 20 |
|
| 21 |
-
|
| 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 |
-
|
|
|
|
| 28 |
|
| 29 |
-
|
| 30 |
|
| 31 |
-
The
|
| 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 +
|
| 52 |
-
| `iso_ne_fetch.py` | Live ISO-NE
|
| 53 |
| `calendar_features.py` | 44-d calendar one-hot encoder |
|
| 54 |
-
| `model_utils.py` |
|
| 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
|
| 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
|
| 5 |
-
+ real demand history.
|
| 6 |
-
- Ensemble (Baseline + Chronos-Bolt-mini):
|
| 7 |
-
per-zone with the 21 M-
|
| 8 |
-
zero-shot on demand history. Per Table 10
|
| 9 |
-
the mini
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 60 |
-
|
| 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 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 110 |
-
f"to **{(target + timedelta(hours=24)).strftime('%Y-%m-%d %H:00')} UTC**
|
| 111 |
-
f"system-level peak
|
| 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 = [
|
| 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 |
-
">
|
| 193 |
-
"
|
| 194 |
-
"
|
| 195 |
-
"
|
| 196 |
-
"**
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 210 |
)
|
| 211 |
-
run_btn = gr.Button("
|
|
|
|
| 212 |
summary_md = gr.Markdown()
|
| 213 |
with gr.Tabs():
|
| 214 |
-
with gr.Tab("
|
| 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=[
|
| 232 |
outputs=[line_plot, bar_plot, summary_md])
|
| 233 |
-
demo.load(forecast, inputs=[
|
| 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 |
-
|
| 5 |
-
|
| 6 |
-
1. **
|
| 7 |
-
(
|
| 8 |
-
|
| 9 |
-
the
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 67 |
|
| 68 |
-
|
|
|
|
| 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"
|