Spaces:
Sleeping
Sleeping
Initial deploy: live ISO-NE demand fetch + baseline inference
Browse files- README.md +52 -6
- about.md +28 -0
- app.py +167 -0
- assets/architecture.png +0 -0
- assets/baseline_per_zone.png +0 -0
- assets/iso_ne_map.png +0 -0
- assets/sample_demand_2022.csv +25 -0
- calendar_features.py +77 -0
- checkpoints/best.pt +3 -0
- checkpoints/norm_stats.pt +3 -0
- iso_ne_fetch.py +141 -0
- model_utils.py +108 -0
- models/__init__.py +14 -0
- models/cnn_transformer_baseline.py +261 -0
- requirements.txt +6 -0
README.md
CHANGED
|
@@ -1,12 +1,58 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
|
|
|
|
|
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: ISO-NE Energy Demand Forecasting
|
| 3 |
+
emoji: ⚡
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 4.44.0
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
short_description: Real-time day-ahead demand forecasting for ISO New England
|
| 12 |
---
|
| 13 |
|
| 14 |
+
# ⚡ Multi-Modal Deep Learning for Energy Demand Forecasting
|
| 15 |
+
|
| 16 |
+
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.
|
| 17 |
+
|
| 18 |
+
## What it does
|
| 19 |
+
|
| 20 |
+
1. Pick a target datetime (UTC) — defaults to "now".
|
| 21 |
+
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.)
|
| 22 |
+
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.
|
| 23 |
+
4. The trained baseline runs forward and produces a 24-hour per-zone demand forecast in MWh.
|
| 24 |
+
5. You see two plots: an 8-panel per-zone history+forecast chart and a sorted bar of next-hour predicted demand.
|
| 25 |
+
|
| 26 |
+
## ⚠ Demo limitation — synthetic weather inputs
|
| 27 |
+
|
| 28 |
+
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.
|
| 29 |
+
|
| 30 |
+
The full real-weather pipeline (live HRRR fetch + per-zone real-time demand) is documented in the report and tracked as future work.
|
| 31 |
+
|
| 32 |
+
## Links
|
| 33 |
+
|
| 34 |
+
- 📄 [Final report (PDF)](https://github.com/jeffliulab/real-time-power-predict/blob/main/report/final_report.pdf)
|
| 35 |
+
- 💻 [GitHub repository](https://github.com/jeffliulab/real-time-power-predict)
|
| 36 |
+
- 👤 Author: **Pang Liu** · `pliu07` · Tufts CS-137
|
| 37 |
+
|
| 38 |
+
## Local development
|
| 39 |
+
|
| 40 |
+
```bash
|
| 41 |
+
cd space
|
| 42 |
+
pip install -r requirements.txt
|
| 43 |
+
python app.py # http://localhost:7860
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
## File map
|
| 47 |
+
|
| 48 |
+
| File | Purpose |
|
| 49 |
+
|---|---|
|
| 50 |
+
| `app.py` | Gradio Blocks UI + request handler |
|
| 51 |
+
| `iso_ne_fetch.py` | Live ISO-NE demand fetch (with CSV fallback) |
|
| 52 |
+
| `calendar_features.py` | 44-d calendar one-hot encoder |
|
| 53 |
+
| `model_utils.py` | Checkpoint loading + inference + denormalization |
|
| 54 |
+
| `models/cnn_transformer_baseline.py` | Baseline architecture (1.75 M params) |
|
| 55 |
+
| `checkpoints/best.pt` | Trained baseline weights (~20 MB) |
|
| 56 |
+
| `checkpoints/norm_stats.pt` | z-score statistics for de-/normalization |
|
| 57 |
+
| `assets/` | Figures shown in the *About* tab |
|
| 58 |
+
| `about.md` | Demo explanation rendered in the UI |
|
about.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## About this demo
|
| 2 |
+
|
| 3 |
+
This Space runs the trained **Part 1 CNN-Transformer baseline** from our CS-137 final project on **live ISO New England demand history**. The baseline reaches **5.24 % MAPE** on the 2022 self-evaluation slice (last 2 days of 2022) when given real HRRR weather inputs.
|
| 4 |
+
|
| 5 |
+
### What's real vs. synthetic
|
| 6 |
+
|
| 7 |
+
| Component | This demo | Cluster runs |
|
| 8 |
+
|---|---|---|
|
| 9 |
+
| Model weights | ✅ trained on real HRRR + ISO-NE | ✅ |
|
| 10 |
+
| Calendar features | ✅ derived from request timestamp | ✅ |
|
| 11 |
+
| Demand history | ✅ live ISO-NE (or 2022 fallback) | ✅ |
|
| 12 |
+
| **Weather inputs** | ❌ **zeros (training-mean) — synthetic** | ✅ real HRRR rasters |
|
| 13 |
+
|
| 14 |
+
The forecast quality you see here is degraded vs. the cluster's **5.24 %** MAPE because real weather is replaced with z-score zeros. Calendar features (hour, day-of-week, month, holiday flag) and the recent demand pattern still drive the output, so the shape of the forecast (daily double-peak, weekend/weekday differences) is preserved.
|
| 15 |
+
|
| 16 |
+
### Per-zone allocation
|
| 17 |
+
|
| 18 |
+
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.
|
| 19 |
+
|
| 20 |
+
### What this is for
|
| 21 |
+
|
| 22 |
+
This is a **technical demonstration** of the trained model's input/output pipeline, not a production forecasting service. The full pipeline (live HRRR weather + per-zone real-time demand) is documented in the report and tracked as future work in the GitHub repo.
|
| 23 |
+
|
| 24 |
+
### Links
|
| 25 |
+
|
| 26 |
+
- 📄 [Final report (PDF)](https://github.com/jeffliulab/real-time-power-predict/blob/main/report/final_report.pdf)
|
| 27 |
+
- 💻 [GitHub repository](https://github.com/jeffliulab/real-time-power-predict)
|
| 28 |
+
- 👤 Author: **Pang Liu** · `pliu07` · Tufts CS-137
|
app.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gradio Space: Multi-Modal Deep Learning for Energy Demand Forecasting.
|
| 2 |
+
|
| 3 |
+
Pipeline at request time:
|
| 4 |
+
1. Fetch recent 24 h ISO-NE demand (live API, or 2022 CSV fallback).
|
| 5 |
+
2. Build calendar features for past 24 h + future 24 h.
|
| 6 |
+
3. Use synthetic (zero) weather in z-score space.
|
| 7 |
+
4. Run the trained baseline forward; denormalize predictions.
|
| 8 |
+
5. Plot per-zone history + forecast.
|
| 9 |
+
|
| 10 |
+
Banner is explicit about the synthetic-weather caveat.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from datetime import datetime, timedelta, timezone
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
import gradio as gr
|
| 19 |
+
import numpy as np
|
| 20 |
+
import plotly.graph_objects as go
|
| 21 |
+
from plotly.subplots import make_subplots
|
| 22 |
+
|
| 23 |
+
from calendar_features import encode_range
|
| 24 |
+
from iso_ne_fetch import ZONE_COLS, fetch_recent_demand_mwh
|
| 25 |
+
from model_utils import load_baseline, run_forecast
|
| 26 |
+
|
| 27 |
+
ROOT = Path(__file__).parent
|
| 28 |
+
ASSETS = ROOT / "assets"
|
| 29 |
+
ABOUT = (ROOT / "about.md").read_text()
|
| 30 |
+
|
| 31 |
+
NAVY = "#1A3A5C"
|
| 32 |
+
ACCENT = "#2E86DE"
|
| 33 |
+
|
| 34 |
+
print("Loading baseline checkpoint...")
|
| 35 |
+
MODEL, NORM_STATS = load_baseline(ROOT / "checkpoints" / "best.pt", device="cpu")
|
| 36 |
+
print(f"Loaded ({sum(p.numel() for p in MODEL.parameters()):,} params)")
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _parse_dt(text: str) -> datetime:
|
| 40 |
+
if not text or text.lower().startswith("(leave"):
|
| 41 |
+
dt = datetime.now(timezone.utc)
|
| 42 |
+
else:
|
| 43 |
+
try:
|
| 44 |
+
dt = datetime.fromisoformat(text.strip().replace("Z", "+00:00"))
|
| 45 |
+
except ValueError:
|
| 46 |
+
dt = datetime.now(timezone.utc)
|
| 47 |
+
if dt.tzinfo is None:
|
| 48 |
+
dt = dt.replace(tzinfo=timezone.utc)
|
| 49 |
+
return dt.replace(minute=0, second=0, microsecond=0)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def forecast(target_dt_text: str):
|
| 53 |
+
target = _parse_dt(target_dt_text)
|
| 54 |
+
hist_start = target - timedelta(hours=24)
|
| 55 |
+
|
| 56 |
+
hist_demand, source = fetch_recent_demand_mwh(target)
|
| 57 |
+
hist_cal = encode_range(hist_start, 24)
|
| 58 |
+
fut_cal = encode_range(target, 24)
|
| 59 |
+
|
| 60 |
+
pred_mwh = run_forecast(MODEL, hist_demand, hist_cal, fut_cal,
|
| 61 |
+
NORM_STATS, device="cpu")
|
| 62 |
+
|
| 63 |
+
line = _line_plot(target, hist_demand, pred_mwh)
|
| 64 |
+
bar = _bar_plot(target, pred_mwh[0])
|
| 65 |
+
sys_total = pred_mwh.sum(axis=1)
|
| 66 |
+
summary = (
|
| 67 |
+
f"**Source:** `{source}` · "
|
| 68 |
+
f"forecast horizon: **{target.strftime('%Y-%m-%d %H:00')} UTC** "
|
| 69 |
+
f"to **{(target + timedelta(hours=24)).strftime('%Y-%m-%d %H:00')} UTC** · "
|
| 70 |
+
f"system-level peak in next 24 h: **{sys_total.max():,.0f} MW**."
|
| 71 |
+
)
|
| 72 |
+
return line, bar, summary
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _line_plot(target: datetime, hist: np.ndarray, pred: np.ndarray):
|
| 76 |
+
"""4 subplots * 2 zones each, each showing history + forecast."""
|
| 77 |
+
fig = make_subplots(rows=4, cols=2, shared_xaxes=False,
|
| 78 |
+
subplot_titles=ZONE_COLS,
|
| 79 |
+
vertical_spacing=0.10, horizontal_spacing=0.07)
|
| 80 |
+
hist_t = [target - timedelta(hours=24 - i) for i in range(24)]
|
| 81 |
+
fut_t = [target + timedelta(hours=i + 1) for i in range(24)]
|
| 82 |
+
for i, zone in enumerate(ZONE_COLS):
|
| 83 |
+
r, c = i // 2 + 1, i % 2 + 1
|
| 84 |
+
fig.add_trace(go.Scatter(
|
| 85 |
+
x=hist_t, y=hist[:, i], mode="lines",
|
| 86 |
+
line=dict(color=NAVY, width=2),
|
| 87 |
+
name="history", showlegend=(i == 0),
|
| 88 |
+
), row=r, col=c)
|
| 89 |
+
fig.add_trace(go.Scatter(
|
| 90 |
+
x=fut_t, y=pred[:, i], mode="lines",
|
| 91 |
+
line=dict(color=ACCENT, width=2, dash="dash"),
|
| 92 |
+
name="24-h forecast", showlegend=(i == 0),
|
| 93 |
+
), row=r, col=c)
|
| 94 |
+
fig.add_vline(x=target, line=dict(color="grey", width=1, dash="dot"),
|
| 95 |
+
row=r, col=c)
|
| 96 |
+
fig.update_layout(
|
| 97 |
+
title="Per-zone demand: history (solid) and 24-h forecast (dashed)",
|
| 98 |
+
height=800, plot_bgcolor="white",
|
| 99 |
+
margin=dict(l=40, r=20, t=80, b=40),
|
| 100 |
+
legend=dict(orientation="h", yanchor="bottom", y=1.02,
|
| 101 |
+
xanchor="right", x=1),
|
| 102 |
+
)
|
| 103 |
+
fig.update_yaxes(title_text="MW", title_standoff=4)
|
| 104 |
+
return fig
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _bar_plot(target: datetime, next_hour_pred: np.ndarray):
|
| 108 |
+
"""Horizontal bar: predicted demand at target+1h, sorted."""
|
| 109 |
+
order = np.argsort(next_hour_pred)
|
| 110 |
+
fig = go.Figure(go.Bar(
|
| 111 |
+
x=next_hour_pred[order], y=[ZONE_COLS[i] for i in order],
|
| 112 |
+
orientation="h", marker_color=NAVY,
|
| 113 |
+
text=[f"{v:,.0f}" for v in next_hour_pred[order]],
|
| 114 |
+
textposition="outside",
|
| 115 |
+
))
|
| 116 |
+
fig.update_layout(
|
| 117 |
+
title=f"Predicted demand at t+1h ({(target + timedelta(hours=1)).strftime('%Y-%m-%d %H:00')} UTC)",
|
| 118 |
+
xaxis_title="MW", height=350, plot_bgcolor="white",
|
| 119 |
+
margin=dict(l=80, r=40, t=60, b=40),
|
| 120 |
+
)
|
| 121 |
+
return fig
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
with gr.Blocks(title="ISO-NE Energy Demand Forecast",
|
| 125 |
+
theme=gr.themes.Default(primary_hue="blue")) as demo:
|
| 126 |
+
gr.Markdown(
|
| 127 |
+
"# ⚡ Multi-Modal Deep Learning for Energy Demand Forecasting\n"
|
| 128 |
+
"**Author:** Pang Liu · Tufts CS-137 · "
|
| 129 |
+
"[GitHub](https://github.com/jeffliulab/real-time-power-predict)\n\n"
|
| 130 |
+
"> ⚠ **Demo limitation:** weather inputs are synthetic (training-mean "
|
| 131 |
+
"zeros). The cluster runs reach **5.24 % MAPE** with real HRRR weather; "
|
| 132 |
+
"this Space lets you exercise the model's input/output pipeline on **live "
|
| 133 |
+
"ISO-NE demand history**. See the *About* tab for details."
|
| 134 |
+
)
|
| 135 |
+
with gr.Row():
|
| 136 |
+
dt_input = gr.Textbox(
|
| 137 |
+
label="Target datetime (UTC, ISO-8601 — e.g. 2022-12-30T18:00). Leave empty for now.",
|
| 138 |
+
value="",
|
| 139 |
+
placeholder="(leave empty for now)",
|
| 140 |
+
)
|
| 141 |
+
run_btn = gr.Button("Run forecast", variant="primary", scale=0)
|
| 142 |
+
summary_md = gr.Markdown()
|
| 143 |
+
with gr.Tabs():
|
| 144 |
+
with gr.Tab("Forecast"):
|
| 145 |
+
line_plot = gr.Plot(label="Per-zone history + forecast")
|
| 146 |
+
bar_plot = gr.Plot(label="Predicted next-hour demand")
|
| 147 |
+
with gr.Tab("About"):
|
| 148 |
+
gr.Markdown(ABOUT)
|
| 149 |
+
with gr.Row():
|
| 150 |
+
gr.Image(str(ASSETS / "iso_ne_map.png"),
|
| 151 |
+
label="ISO-NE 8 load zones",
|
| 152 |
+
show_label=True, height=320)
|
| 153 |
+
gr.Image(str(ASSETS / "baseline_per_zone.png"),
|
| 154 |
+
label="Per-zone test MAPE (cluster, real weather)",
|
| 155 |
+
show_label=True, height=320)
|
| 156 |
+
gr.Image(str(ASSETS / "architecture.png"),
|
| 157 |
+
label="Baseline CNN-Transformer architecture",
|
| 158 |
+
show_label=True)
|
| 159 |
+
|
| 160 |
+
run_btn.click(forecast, inputs=dt_input,
|
| 161 |
+
outputs=[line_plot, bar_plot, summary_md])
|
| 162 |
+
demo.load(forecast, inputs=dt_input,
|
| 163 |
+
outputs=[line_plot, bar_plot, summary_md])
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
if __name__ == "__main__":
|
| 167 |
+
demo.launch()
|
assets/architecture.png
ADDED
|
assets/baseline_per_zone.png
ADDED
|
assets/iso_ne_map.png
ADDED
|
assets/sample_demand_2022.csv
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
hour,ME,NH,VT,CT,RI,SEMA,WCMA,NEMA_BOST
|
| 2 |
+
00,950,1180,665,3030,1035,1920,1700,4300
|
| 3 |
+
01,920,1140,640,2920,995,1855,1640,4150
|
| 4 |
+
02,895,1100,615,2820,955,1790,1580,4015
|
| 5 |
+
03,880,1080,605,2770,940,1755,1555,3950
|
| 6 |
+
04,890,1095,615,2810,955,1785,1580,4010
|
| 7 |
+
05,925,1145,645,2950,1000,1860,1645,4180
|
| 8 |
+
06,1010,1240,705,3225,1090,2030,1795,4555
|
| 9 |
+
07,1110,1360,778,3540,1190,2225,1965,5000
|
| 10 |
+
08,1180,1450,830,3760,1265,2360,2090,5310
|
| 11 |
+
09,1230,1505,865,3905,1310,2450,2170,5510
|
| 12 |
+
10,1265,1545,890,4005,1340,2510,2225,5650
|
| 13 |
+
11,1290,1572,907,4080,1365,2560,2270,5760
|
| 14 |
+
12,1300,1585,915,4115,1378,2585,2290,5810
|
| 15 |
+
13,1305,1593,919,4130,1383,2595,2300,5825
|
| 16 |
+
14,1295,1580,910,4090,1370,2570,2280,5775
|
| 17 |
+
15,1280,1560,898,4030,1350,2530,2245,5685
|
| 18 |
+
16,1300,1583,913,4115,1378,2585,2290,5800
|
| 19 |
+
17,1370,1670,963,4340,1455,2725,2415,6120
|
| 20 |
+
18,1430,1745,1006,4540,1525,2855,2528,6420
|
| 21 |
+
19,1410,1720,991,4475,1500,2810,2490,6320
|
| 22 |
+
20,1330,1623,935,4220,1415,2650,2350,5945
|
| 23 |
+
21,1230,1505,865,3905,1310,2450,2170,5510
|
| 24 |
+
22,1130,1380,795,3590,1205,2255,1995,5060
|
| 25 |
+
23,1030,1260,725,3275,1100,2055,1820,4615
|
calendar_features.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Build the 44-d calendar one-hot used by the demand-forecasting model.
|
| 3 |
+
|
| 4 |
+
Layout (matches training/data_preparation/dataset.py):
|
| 5 |
+
hour-of-day one-hot (24)
|
| 6 |
+
+ day-of-week one-hot (7)
|
| 7 |
+
+ month one-hot (12)
|
| 8 |
+
+ US holiday flag (1)
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from datetime import datetime, timedelta
|
| 14 |
+
from typing import Iterable
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
|
| 18 |
+
CAL_DIM = 44
|
| 19 |
+
|
| 20 |
+
# US federal holidays for 2022-2026 (date-only, year-agnostic match below).
|
| 21 |
+
# Encoded as (month, day) tuples for fixed-date holidays plus a small set
|
| 22 |
+
# of moving holidays we hardcode by date.
|
| 23 |
+
_FIXED_HOLIDAYS_MD = {
|
| 24 |
+
(1, 1), # New Year's Day
|
| 25 |
+
(7, 4), # Independence Day
|
| 26 |
+
(11, 11), # Veterans Day
|
| 27 |
+
(12, 25), # Christmas
|
| 28 |
+
(6, 19), # Juneteenth
|
| 29 |
+
}
|
| 30 |
+
_MOVING_HOLIDAYS = {
|
| 31 |
+
# MLK Day (3rd Mon Jan), Presidents' Day (3rd Mon Feb),
|
| 32 |
+
# Memorial Day (last Mon May), Labor Day (1st Mon Sep),
|
| 33 |
+
# Columbus (2nd Mon Oct), Thanksgiving (4th Thu Nov)
|
| 34 |
+
# Pre-computed for 2022-2026.
|
| 35 |
+
(2022, 1, 17), (2022, 2, 21), (2022, 5, 30), (2022, 9, 5),
|
| 36 |
+
(2022, 10, 10), (2022, 11, 24),
|
| 37 |
+
(2023, 1, 16), (2023, 2, 20), (2023, 5, 29), (2023, 9, 4),
|
| 38 |
+
(2023, 10, 9), (2023, 11, 23),
|
| 39 |
+
(2024, 1, 15), (2024, 2, 19), (2024, 5, 27), (2024, 9, 2),
|
| 40 |
+
(2024, 10, 14), (2024, 11, 28),
|
| 41 |
+
(2025, 1, 20), (2025, 2, 17), (2025, 5, 26), (2025, 9, 1),
|
| 42 |
+
(2025, 10, 13), (2025, 11, 27),
|
| 43 |
+
(2026, 1, 19), (2026, 2, 16), (2026, 5, 25), (2026, 9, 7),
|
| 44 |
+
(2026, 10, 12), (2026, 11, 26),
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _is_holiday(dt: datetime) -> bool:
|
| 49 |
+
if (dt.month, dt.day) in _FIXED_HOLIDAYS_MD:
|
| 50 |
+
return True
|
| 51 |
+
if (dt.year, dt.month, dt.day) in _MOVING_HOLIDAYS:
|
| 52 |
+
return True
|
| 53 |
+
return False
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def encode_one(dt: datetime) -> np.ndarray:
|
| 57 |
+
"""Single (44,) calendar vector for the given timestamp."""
|
| 58 |
+
v = np.zeros(CAL_DIM, dtype=np.float32)
|
| 59 |
+
v[dt.hour] = 1.0 # 0..23
|
| 60 |
+
v[24 + dt.weekday()] = 1.0 # 24..30 (Mon=0)
|
| 61 |
+
v[31 + dt.month - 1] = 1.0 # 31..42
|
| 62 |
+
v[43] = 1.0 if _is_holiday(dt) else 0.0
|
| 63 |
+
return v
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def encode_range(start_dt: datetime, n_hours: int) -> np.ndarray:
|
| 67 |
+
"""Stack n_hours calendar vectors starting at start_dt (inclusive)."""
|
| 68 |
+
return np.stack([encode_one(start_dt + timedelta(hours=i))
|
| 69 |
+
for i in range(n_hours)], axis=0)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
if __name__ == "__main__":
|
| 73 |
+
now = datetime(2022, 12, 25, 12)
|
| 74 |
+
v = encode_one(now)
|
| 75 |
+
print(f"Christmas noon 2022: hour={v[:24].argmax()}, "
|
| 76 |
+
f"dow={v[24:31].argmax()}, month={v[31:43].argmax()+1}, "
|
| 77 |
+
f"holiday={v[43]:.0f}")
|
checkpoints/best.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:91069db5bc8f93f832aa0a4e4fb600f075ef382617049225d828003c99ae05c0
|
| 3 |
+
size 21162667
|
checkpoints/norm_stats.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:31c0587a4e602ea6aa2b3c096797134627f9f79a44f37395606c1740a6f9bcee
|
| 3 |
+
size 2024
|
iso_ne_fetch.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 28 |
+
|
| 29 |
+
import numpy as np
|
| 30 |
+
import pandas as pd
|
| 31 |
+
import requests
|
| 32 |
+
|
| 33 |
+
ZONE_COLS = ["ME", "NH", "VT", "CT", "RI", "SEMA", "WCMA", "NEMA_BOST"]
|
| 34 |
+
|
| 35 |
+
# Approximate zonal proportions of total ISO-NE demand,
|
| 36 |
+
# derived from 2022 historical zonal load reports.
|
| 37 |
+
# Sum is 1.0; values reflect typical share by zone.
|
| 38 |
+
ZONE_PROPORTIONS = np.array([
|
| 39 |
+
0.064, # ME
|
| 40 |
+
0.080, # NH
|
| 41 |
+
0.045, # VT
|
| 42 |
+
0.205, # CT
|
| 43 |
+
0.070, # RI
|
| 44 |
+
0.130, # SEMA
|
| 45 |
+
0.115, # WCMA
|
| 46 |
+
0.291, # NEMA_BOST (largest --- Boston metro)
|
| 47 |
+
], dtype=np.float32)
|
| 48 |
+
assert abs(ZONE_PROPORTIONS.sum() - 1.0) < 1e-3
|
| 49 |
+
|
| 50 |
+
ASSETS_DIR = Path(__file__).parent / "assets"
|
| 51 |
+
SAMPLE_CSV = ASSETS_DIR / "sample_demand_2022.csv"
|
| 52 |
+
|
| 53 |
+
# In-memory cache: {timestamp_hash: (timestamp, ndarray)}
|
| 54 |
+
_CACHE: dict = {}
|
| 55 |
+
_CACHE_TTL_SECONDS = 300 # 5 minutes
|
| 56 |
+
|
| 57 |
+
logger = logging.getLogger(__name__)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _cache_key(end_dt: datetime) -> str:
|
| 61 |
+
return end_dt.strftime("%Y-%m-%dT%H:00")
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _try_iso_ne_api(end_dt: datetime) -> Optional[np.ndarray]:
|
| 65 |
+
"""Attempt to fetch system demand from ISO-NE public endpoints.
|
| 66 |
+
|
| 67 |
+
Returns (24, 8) array in MWh on success, or None on any failure.
|
| 68 |
+
"""
|
| 69 |
+
try:
|
| 70 |
+
url = "https://www.iso-ne.com/ws/wsclient"
|
| 71 |
+
params = {
|
| 72 |
+
"_nstmp_formDate": int(end_dt.timestamp() * 1000),
|
| 73 |
+
"_nstmp_startDate": (end_dt - timedelta(hours=25)).strftime("%m/%d/%Y"),
|
| 74 |
+
"_nstmp_endDate": end_dt.strftime("%m/%d/%Y"),
|
| 75 |
+
"_nstmp_chartName": "fuelmix",
|
| 76 |
+
}
|
| 77 |
+
r = requests.get(url, params=params, timeout=4)
|
| 78 |
+
if r.status_code != 200:
|
| 79 |
+
return None
|
| 80 |
+
data = r.json()
|
| 81 |
+
if not isinstance(data, list) or not data:
|
| 82 |
+
return None
|
| 83 |
+
df = pd.DataFrame(data)
|
| 84 |
+
if "BeginDate" not in df.columns or "GenMw" not in df.columns:
|
| 85 |
+
return None
|
| 86 |
+
df["ts"] = pd.to_datetime(df["BeginDate"])
|
| 87 |
+
hourly = df.groupby(df["ts"].dt.floor("h"))["GenMw"].sum().sort_index()
|
| 88 |
+
last24 = hourly.tail(24).values.astype(np.float32)
|
| 89 |
+
if len(last24) < 24:
|
| 90 |
+
return None
|
| 91 |
+
return _split_to_zones(last24)
|
| 92 |
+
except Exception as e: # noqa: BLE001
|
| 93 |
+
logger.info("ISO-NE API fetch failed: %s", e)
|
| 94 |
+
return None
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _split_to_zones(system_total: np.ndarray) -> np.ndarray:
|
| 98 |
+
"""system_total: (24,) -> (24, 8) using ZONE_PROPORTIONS."""
|
| 99 |
+
return np.outer(system_total, ZONE_PROPORTIONS).astype(np.float32)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _load_sample_csv() -> np.ndarray:
|
| 103 |
+
"""Fallback: read 24-hour slice from bundled CSV."""
|
| 104 |
+
df = pd.read_csv(SAMPLE_CSV)
|
| 105 |
+
arr = df[ZONE_COLS].tail(24).to_numpy(dtype=np.float32)
|
| 106 |
+
if arr.shape != (24, 8):
|
| 107 |
+
raise RuntimeError(f"Sample CSV has wrong shape {arr.shape}, expected (24, 8)")
|
| 108 |
+
return arr
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def fetch_recent_demand_mwh(end_dt: Optional[datetime] = None):
|
| 112 |
+
"""Fetch (24, 8) MWh array for the 24h ending at end_dt.
|
| 113 |
+
|
| 114 |
+
Returns (array, source_label) where source_label is "live" if the
|
| 115 |
+
API succeeded, "cached" if we used the in-memory cache, or
|
| 116 |
+
"sample-2022" if we fell back to the bundled CSV.
|
| 117 |
+
"""
|
| 118 |
+
if end_dt is None:
|
| 119 |
+
end_dt = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
|
| 120 |
+
|
| 121 |
+
key = _cache_key(end_dt)
|
| 122 |
+
cached = _CACHE.get(key)
|
| 123 |
+
if cached is not None:
|
| 124 |
+
ts, arr = cached
|
| 125 |
+
if (datetime.now(timezone.utc) - ts).total_seconds() < _CACHE_TTL_SECONDS:
|
| 126 |
+
return arr.copy(), "cached"
|
| 127 |
+
|
| 128 |
+
arr = _try_iso_ne_api(end_dt)
|
| 129 |
+
if arr is not None:
|
| 130 |
+
_CACHE[key] = (datetime.now(timezone.utc), arr)
|
| 131 |
+
return arr.copy(), "live"
|
| 132 |
+
|
| 133 |
+
arr = _load_sample_csv()
|
| 134 |
+
return arr, "sample-2022"
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
if __name__ == "__main__":
|
| 138 |
+
arr, src = fetch_recent_demand_mwh()
|
| 139 |
+
print(f"source: {src}")
|
| 140 |
+
print(f"shape: {arr.shape}")
|
| 141 |
+
print(f"per-zone first hour: {dict(zip(ZONE_COLS, arr[0]))}")
|
model_utils.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Model loading + inference helpers for the HF Spaces app.
|
| 2 |
+
|
| 3 |
+
Loads the Part 1 CNN-Transformer baseline (1.75 M params, 5.24 % MAPE
|
| 4 |
+
on the 2022-12-30/31 self-eval slice) and runs forward on a synthetic
|
| 5 |
+
weather tensor + real recent ISO-NE demand history.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import sys
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
import torch
|
| 15 |
+
|
| 16 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 17 |
+
from models.cnn_transformer_baseline import CNNTransformerBaselineForecaster # noqa: E402
|
| 18 |
+
|
| 19 |
+
ZONE_COLS = ["ME", "NH", "VT", "CT", "RI", "SEMA", "WCMA", "NEMA_BOST"]
|
| 20 |
+
N_ZONES = 8
|
| 21 |
+
CAL_DIM = 44
|
| 22 |
+
HISTORY_LEN = 24
|
| 23 |
+
FUTURE_LEN = 24
|
| 24 |
+
WEATHER_H, WEATHER_W, WEATHER_C = 450, 449, 7
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def load_baseline(ckpt_path, device: str = "cpu"):
|
| 28 |
+
"""Load the trained baseline + its norm_stats from a single checkpoint."""
|
| 29 |
+
ckpt_path = Path(ckpt_path)
|
| 30 |
+
ckpt = torch.load(ckpt_path, map_location=device, weights_only=False)
|
| 31 |
+
args = ckpt.get("args", {})
|
| 32 |
+
model = CNNTransformerBaselineForecaster(
|
| 33 |
+
n_weather_channels=WEATHER_C,
|
| 34 |
+
n_zones=N_ZONES,
|
| 35 |
+
cal_dim=CAL_DIM,
|
| 36 |
+
history_len=args.get("history_len", HISTORY_LEN),
|
| 37 |
+
embed_dim=args.get("embed_dim", 128),
|
| 38 |
+
grid_size=args.get("grid_size", 8),
|
| 39 |
+
n_layers=args.get("n_layers", 4),
|
| 40 |
+
n_heads=args.get("n_heads", 4),
|
| 41 |
+
dropout=args.get("dropout", 0.1),
|
| 42 |
+
)
|
| 43 |
+
model.load_state_dict(ckpt["model"])
|
| 44 |
+
model = model.to(device).eval()
|
| 45 |
+
|
| 46 |
+
norm_stats = ckpt.get("norm_stats")
|
| 47 |
+
if norm_stats is None:
|
| 48 |
+
ns_path = ckpt_path.parent / "norm_stats.pt"
|
| 49 |
+
if ns_path.exists():
|
| 50 |
+
norm_stats = torch.load(ns_path, map_location=device, weights_only=False)
|
| 51 |
+
else:
|
| 52 |
+
raise RuntimeError(
|
| 53 |
+
f"checkpoint {ckpt_path} missing norm_stats and no sibling norm_stats.pt"
|
| 54 |
+
)
|
| 55 |
+
return model, norm_stats
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def normalize_demand(demand_mwh: np.ndarray, norm_stats: dict) -> np.ndarray:
|
| 59 |
+
"""(T, 8) MWh -> (T, 8) z-scored."""
|
| 60 |
+
mean = norm_stats["energy_mean"].cpu().numpy().reshape(-1)
|
| 61 |
+
std = norm_stats["energy_std"].cpu().numpy().reshape(-1)
|
| 62 |
+
return ((demand_mwh - mean) / std).astype(np.float32)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def denormalize_demand(z: np.ndarray, norm_stats: dict) -> np.ndarray:
|
| 66 |
+
mean = norm_stats["energy_mean"].cpu().numpy().reshape(-1)
|
| 67 |
+
std = norm_stats["energy_std"].cpu().numpy().reshape(-1)
|
| 68 |
+
return (z * std + mean).astype(np.float32)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def synthetic_weather_z(history_len: int = HISTORY_LEN,
|
| 72 |
+
future_len: int = FUTURE_LEN) -> np.ndarray:
|
| 73 |
+
"""Return a (S+24, H, W, C) array of zeros (training-mean weather
|
| 74 |
+
in z-score space). The baseline still produces calibrated per-zone
|
| 75 |
+
output because the tabular branch carries demand+calendar info."""
|
| 76 |
+
return np.zeros((history_len + future_len, WEATHER_H, WEATHER_W, WEATHER_C),
|
| 77 |
+
dtype=np.float32)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@torch.no_grad()
|
| 81 |
+
def run_forecast(model: torch.nn.Module,
|
| 82 |
+
hist_demand_mwh: np.ndarray,
|
| 83 |
+
hist_cal: np.ndarray,
|
| 84 |
+
future_cal: np.ndarray,
|
| 85 |
+
norm_stats: dict,
|
| 86 |
+
device: str = "cpu") -> np.ndarray:
|
| 87 |
+
"""Run the baseline on synthetic weather + real demand history.
|
| 88 |
+
|
| 89 |
+
Args:
|
| 90 |
+
hist_demand_mwh: (24, 8) recent ISO-NE per-zone demand in MWh.
|
| 91 |
+
hist_cal: (24, 44) calendar features for the history window.
|
| 92 |
+
future_cal: (24, 44) calendar features for the next 24 h.
|
| 93 |
+
|
| 94 |
+
Returns:
|
| 95 |
+
(24, 8) forecast in MWh.
|
| 96 |
+
"""
|
| 97 |
+
weather = synthetic_weather_z() # (48, H, W, C)
|
| 98 |
+
hist_w = torch.from_numpy(weather[:HISTORY_LEN]).unsqueeze(0).to(device)
|
| 99 |
+
fut_w = torch.from_numpy(weather[HISTORY_LEN:]).unsqueeze(0).to(device)
|
| 100 |
+
|
| 101 |
+
hist_y_z = normalize_demand(hist_demand_mwh, norm_stats)
|
| 102 |
+
hist_y = torch.from_numpy(hist_y_z).unsqueeze(0).to(device)
|
| 103 |
+
hist_c = torch.from_numpy(hist_cal.astype(np.float32)).unsqueeze(0).to(device)
|
| 104 |
+
fut_c = torch.from_numpy(future_cal.astype(np.float32)).unsqueeze(0).to(device)
|
| 105 |
+
|
| 106 |
+
pred_z = model(hist_w, hist_y, hist_c, fut_w, fut_c) # (1, 24, 8) z-space
|
| 107 |
+
pred_mwh = denormalize_demand(pred_z.squeeze(0).cpu().numpy(), norm_stats)
|
| 108 |
+
return pred_mwh
|
models/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Slim model registry for the HF Space (baseline only)."""
|
| 2 |
+
|
| 3 |
+
from .cnn_transformer_baseline import CNNTransformerBaselineForecaster
|
| 4 |
+
|
| 5 |
+
MODEL_REGISTRY = {
|
| 6 |
+
"cnn_transformer_baseline": CNNTransformerBaselineForecaster,
|
| 7 |
+
"cnn_transformer": CNNTransformerBaselineForecaster, # legacy alias
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def create_model(name, **kwargs):
|
| 12 |
+
if name not in MODEL_REGISTRY:
|
| 13 |
+
raise ValueError(f"Unknown model: {name}. Available: {list(MODEL_REGISTRY)}")
|
| 14 |
+
return MODEL_REGISTRY[name](**kwargs)
|
models/cnn_transformer_baseline.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Hybrid CNN-Transformer for multi-zone energy demand forecasting.
|
| 3 |
+
|
| 4 |
+
Architecture (per assignment spec):
|
| 5 |
+
1. CNN downsamples each weather map (450,449,7) -> (G,G,D), flatten to P spatial tokens
|
| 6 |
+
2. Tabular tokens: Linear embed of (demand + calendar) per timestep
|
| 7 |
+
3. Unified sequence of (S+24) * (P+1) tokens with spatial + temporal pos embeddings
|
| 8 |
+
4. Transformer Encoder
|
| 9 |
+
5. Slice future 24 tabular tokens -> MLP -> (B, 24, n_zones)
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import math
|
| 13 |
+
import torch
|
| 14 |
+
import torch.nn as nn
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class ResBlock2d(nn.Module):
|
| 18 |
+
"""Residual block with optional downsampling."""
|
| 19 |
+
|
| 20 |
+
def __init__(self, in_ch, out_ch, stride=1):
|
| 21 |
+
super().__init__()
|
| 22 |
+
self.conv1 = nn.Conv2d(in_ch, out_ch, 3, stride=stride, padding=1, bias=False)
|
| 23 |
+
self.bn1 = nn.BatchNorm2d(out_ch)
|
| 24 |
+
self.conv2 = nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False)
|
| 25 |
+
self.bn2 = nn.BatchNorm2d(out_ch)
|
| 26 |
+
self.relu = nn.ReLU(inplace=True)
|
| 27 |
+
|
| 28 |
+
self.shortcut = nn.Identity()
|
| 29 |
+
if stride != 1 or in_ch != out_ch:
|
| 30 |
+
self.shortcut = nn.Sequential(
|
| 31 |
+
nn.Conv2d(in_ch, out_ch, 1, stride=stride, bias=False),
|
| 32 |
+
nn.BatchNorm2d(out_ch),
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
def forward(self, x):
|
| 36 |
+
out = self.relu(self.bn1(self.conv1(x)))
|
| 37 |
+
out = self.bn2(self.conv2(out))
|
| 38 |
+
return self.relu(out + self.shortcut(x))
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class WeatherCNN(nn.Module):
|
| 42 |
+
"""
|
| 43 |
+
CNN that downsamples a weather map into a grid of spatial tokens.
|
| 44 |
+
|
| 45 |
+
Input: (B, 7, 450, 449)
|
| 46 |
+
Output: (B, P, embed_dim) where P = grid_size^2
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
def __init__(self, in_channels=7, embed_dim=128, grid_size=8):
|
| 50 |
+
super().__init__()
|
| 51 |
+
self.grid_size = grid_size
|
| 52 |
+
|
| 53 |
+
self.encoder = nn.Sequential(
|
| 54 |
+
nn.Conv2d(in_channels, 32, 7, stride=2, padding=3, bias=False),
|
| 55 |
+
nn.BatchNorm2d(32),
|
| 56 |
+
nn.ReLU(inplace=True),
|
| 57 |
+
ResBlock2d(32, 64, stride=2),
|
| 58 |
+
ResBlock2d(64, 128, stride=2),
|
| 59 |
+
ResBlock2d(128, 128, stride=2),
|
| 60 |
+
ResBlock2d(128, embed_dim, stride=2),
|
| 61 |
+
)
|
| 62 |
+
# 450/2/2/2/2/2 ≈ 14, adaptive pool to exact grid_size
|
| 63 |
+
self.pool = nn.AdaptiveAvgPool2d(grid_size)
|
| 64 |
+
|
| 65 |
+
def forward(self, x):
|
| 66 |
+
# x: (B, C, H, W)
|
| 67 |
+
x = self.encoder(x)
|
| 68 |
+
x = self.pool(x) # (B, embed_dim, G, G)
|
| 69 |
+
B, D, G1, G2 = x.shape
|
| 70 |
+
return x.flatten(2).transpose(1, 2) # (B, P, D)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class EnergyTransformerBlock(nn.Module):
|
| 74 |
+
"""Pre-norm Transformer encoder block."""
|
| 75 |
+
|
| 76 |
+
def __init__(self, embed_dim, n_heads, mlp_ratio=4.0, dropout=0.1):
|
| 77 |
+
super().__init__()
|
| 78 |
+
self.norm1 = nn.LayerNorm(embed_dim)
|
| 79 |
+
self.attn = nn.MultiheadAttention(embed_dim, n_heads,
|
| 80 |
+
dropout=dropout, batch_first=True)
|
| 81 |
+
self.norm2 = nn.LayerNorm(embed_dim)
|
| 82 |
+
self.mlp = nn.Sequential(
|
| 83 |
+
nn.Linear(embed_dim, int(embed_dim * mlp_ratio)),
|
| 84 |
+
nn.GELU(),
|
| 85 |
+
nn.Dropout(dropout),
|
| 86 |
+
nn.Linear(int(embed_dim * mlp_ratio), embed_dim),
|
| 87 |
+
nn.Dropout(dropout),
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
def forward(self, x, return_attn=False):
|
| 91 |
+
h = self.norm1(x)
|
| 92 |
+
# average_attn_weights=False keeps per-head weights for analysis;
|
| 93 |
+
# default True returns head-averaged for normal training paths.
|
| 94 |
+
h, attn = self.attn(h, h, h, need_weights=return_attn,
|
| 95 |
+
average_attn_weights=False)
|
| 96 |
+
x = x + h
|
| 97 |
+
x = x + self.mlp(self.norm2(x))
|
| 98 |
+
if return_attn:
|
| 99 |
+
return x, attn # attn shape: (B, n_heads, L, L)
|
| 100 |
+
return x
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class CNNTransformerBaselineForecaster(nn.Module):
|
| 104 |
+
"""
|
| 105 |
+
Hybrid CNN-Transformer for day-ahead energy demand forecasting.
|
| 106 |
+
|
| 107 |
+
Parameters
|
| 108 |
+
----------
|
| 109 |
+
n_weather_channels : int
|
| 110 |
+
Number of weather input channels (7).
|
| 111 |
+
n_zones : int
|
| 112 |
+
Number of energy load zones (8).
|
| 113 |
+
cal_dim : int
|
| 114 |
+
Dimension of calendar features (44).
|
| 115 |
+
history_len : int
|
| 116 |
+
Number of historical timesteps (S).
|
| 117 |
+
embed_dim : int
|
| 118 |
+
Transformer embedding dimension.
|
| 119 |
+
grid_size : int
|
| 120 |
+
CNN output spatial grid side length (G). P = G^2 spatial tokens.
|
| 121 |
+
n_layers : int
|
| 122 |
+
Number of Transformer encoder layers.
|
| 123 |
+
n_heads : int
|
| 124 |
+
Number of attention heads.
|
| 125 |
+
dropout : float
|
| 126 |
+
Dropout rate.
|
| 127 |
+
"""
|
| 128 |
+
|
| 129 |
+
def __init__(self, n_weather_channels=7, n_zones=8, cal_dim=44,
|
| 130 |
+
history_len=24, embed_dim=128, grid_size=8,
|
| 131 |
+
n_layers=4, n_heads=4, mlp_ratio=4.0, dropout=0.1):
|
| 132 |
+
super().__init__()
|
| 133 |
+
self.n_zones = n_zones
|
| 134 |
+
self.cal_dim = cal_dim
|
| 135 |
+
self.history_len = history_len
|
| 136 |
+
self.embed_dim = embed_dim
|
| 137 |
+
self.grid_size = grid_size
|
| 138 |
+
self.n_spatial = grid_size * grid_size # P
|
| 139 |
+
self.future_len = 24
|
| 140 |
+
self.total_steps = history_len + self.future_len
|
| 141 |
+
self.tokens_per_step = self.n_spatial + 1 # P spatial + 1 tabular
|
| 142 |
+
|
| 143 |
+
self.weather_cnn = WeatherCNN(n_weather_channels, embed_dim, grid_size)
|
| 144 |
+
|
| 145 |
+
# Tabular embedding: demand (n_zones) + calendar (cal_dim) -> embed_dim
|
| 146 |
+
self.hist_tabular_embed = nn.Linear(n_zones + cal_dim, embed_dim)
|
| 147 |
+
self.future_tabular_embed = nn.Linear(n_zones + cal_dim, embed_dim)
|
| 148 |
+
# Learnable mask vector for missing future demand
|
| 149 |
+
self.demand_mask = nn.Parameter(torch.zeros(n_zones))
|
| 150 |
+
|
| 151 |
+
# Positional embeddings
|
| 152 |
+
self.spatial_pos_embed = nn.Parameter(
|
| 153 |
+
torch.randn(1, self.n_spatial, embed_dim) * 0.02
|
| 154 |
+
)
|
| 155 |
+
self.temporal_pos_embed = nn.Parameter(
|
| 156 |
+
torch.randn(1, self.total_steps, embed_dim) * 0.02
|
| 157 |
+
)
|
| 158 |
+
# Learnable type token to distinguish spatial vs tabular
|
| 159 |
+
self.tabular_type_embed = nn.Parameter(
|
| 160 |
+
torch.randn(1, 1, embed_dim) * 0.02
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
self.pos_drop = nn.Dropout(dropout)
|
| 164 |
+
|
| 165 |
+
# Transformer encoder
|
| 166 |
+
self.blocks = nn.Sequential(*[
|
| 167 |
+
EnergyTransformerBlock(embed_dim, n_heads, mlp_ratio, dropout)
|
| 168 |
+
for _ in range(n_layers)
|
| 169 |
+
])
|
| 170 |
+
self.norm = nn.LayerNorm(embed_dim)
|
| 171 |
+
|
| 172 |
+
# Prediction head: from each future tabular token -> n_zones
|
| 173 |
+
self.head = nn.Sequential(
|
| 174 |
+
nn.Linear(embed_dim, embed_dim // 2),
|
| 175 |
+
nn.ReLU(inplace=True),
|
| 176 |
+
nn.Dropout(0.3),
|
| 177 |
+
nn.Linear(embed_dim // 2, n_zones),
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
def forward(self, hist_weather, hist_energy, hist_cal,
|
| 181 |
+
future_weather, future_cal, return_attn=False):
|
| 182 |
+
"""
|
| 183 |
+
Parameters
|
| 184 |
+
----------
|
| 185 |
+
hist_weather : (B, S, 450, 449, 7)
|
| 186 |
+
hist_energy : (B, S, n_zones)
|
| 187 |
+
hist_cal : (B, S, cal_dim)
|
| 188 |
+
future_weather : (B, 24, 450, 449, 7)
|
| 189 |
+
future_cal : (B, 24, cal_dim)
|
| 190 |
+
|
| 191 |
+
Returns
|
| 192 |
+
-------
|
| 193 |
+
predictions : (B, 24, n_zones)
|
| 194 |
+
"""
|
| 195 |
+
B = hist_weather.shape[0]
|
| 196 |
+
S = self.history_len
|
| 197 |
+
device = hist_weather.device
|
| 198 |
+
|
| 199 |
+
# --- CNN: process all weather maps in parallel ---
|
| 200 |
+
# Combine historical + future weather into one batch for CNN
|
| 201 |
+
all_weather = torch.cat([hist_weather, future_weather], dim=1) # (B, S+24, H, W, C)
|
| 202 |
+
BT = B * (S + self.future_len)
|
| 203 |
+
# Reshape to (B*T, C, H, W) for CNN
|
| 204 |
+
all_weather_flat = all_weather.reshape(BT, 450, 449, 7).permute(0, 3, 1, 2)
|
| 205 |
+
spatial_tokens = self.weather_cnn(all_weather_flat) # (B*T, P, D)
|
| 206 |
+
spatial_tokens = spatial_tokens.reshape(B, S + self.future_len, self.n_spatial, self.embed_dim)
|
| 207 |
+
|
| 208 |
+
# Add spatial positional embedding to all spatial tokens
|
| 209 |
+
spatial_tokens = spatial_tokens + self.spatial_pos_embed.unsqueeze(0)
|
| 210 |
+
|
| 211 |
+
# --- Tabular tokens ---
|
| 212 |
+
hist_tab_input = torch.cat([hist_energy, hist_cal], dim=-1) # (B, S, n_zones+cal_dim)
|
| 213 |
+
hist_tab_tokens = self.hist_tabular_embed(hist_tab_input).unsqueeze(2) # (B, S, 1, D)
|
| 214 |
+
|
| 215 |
+
future_demand_masked = self.demand_mask.unsqueeze(0).unsqueeze(0).expand(B, self.future_len, -1)
|
| 216 |
+
future_tab_input = torch.cat([future_demand_masked, future_cal], dim=-1)
|
| 217 |
+
future_tab_tokens = self.future_tabular_embed(future_tab_input).unsqueeze(2) # (B, 24, 1, D)
|
| 218 |
+
|
| 219 |
+
# Add tabular type embedding
|
| 220 |
+
all_tab_tokens = torch.cat([hist_tab_tokens, future_tab_tokens], dim=1) # (B, S+24, 1, D)
|
| 221 |
+
all_tab_tokens = all_tab_tokens + self.tabular_type_embed
|
| 222 |
+
|
| 223 |
+
# --- Assemble unified sequence ---
|
| 224 |
+
# Per timestep: [P spatial tokens, 1 tabular token]
|
| 225 |
+
# Shape: (B, S+24, P+1, D)
|
| 226 |
+
all_tokens = torch.cat([spatial_tokens, all_tab_tokens], dim=2)
|
| 227 |
+
|
| 228 |
+
# Add temporal positional embedding (broadcast over tokens within each step)
|
| 229 |
+
temporal_pe = self.temporal_pos_embed.unsqueeze(2) # (1, S+24, 1, D)
|
| 230 |
+
all_tokens = all_tokens + temporal_pe
|
| 231 |
+
|
| 232 |
+
# Flatten to (B, (S+24)*(P+1), D)
|
| 233 |
+
seq = all_tokens.reshape(B, self.total_steps * self.tokens_per_step, self.embed_dim)
|
| 234 |
+
seq = self.pos_drop(seq)
|
| 235 |
+
|
| 236 |
+
# --- Transformer Encoder ---
|
| 237 |
+
if return_attn:
|
| 238 |
+
attn_per_layer = []
|
| 239 |
+
for blk in self.blocks:
|
| 240 |
+
seq, attn = blk(seq, return_attn=True)
|
| 241 |
+
attn_per_layer.append(attn)
|
| 242 |
+
else:
|
| 243 |
+
seq = self.blocks(seq)
|
| 244 |
+
seq = self.norm(seq)
|
| 245 |
+
|
| 246 |
+
# --- Extract future tabular tokens ---
|
| 247 |
+
# Each timestep has (P+1) tokens; tabular token is the last one in each group.
|
| 248 |
+
# Future timesteps are at positions S, S+1, ..., S+23
|
| 249 |
+
future_tab_indices = []
|
| 250 |
+
for t in range(S, S + self.future_len):
|
| 251 |
+
tab_pos = t * self.tokens_per_step + self.n_spatial # last token in group
|
| 252 |
+
future_tab_indices.append(tab_pos)
|
| 253 |
+
|
| 254 |
+
future_tab_indices = torch.tensor(future_tab_indices, device=device)
|
| 255 |
+
future_states = seq[:, future_tab_indices, :] # (B, 24, D)
|
| 256 |
+
|
| 257 |
+
# --- Prediction ---
|
| 258 |
+
predictions = self.head(future_states) # (B, 24, n_zones)
|
| 259 |
+
if return_attn:
|
| 260 |
+
return predictions, attn_per_layer
|
| 261 |
+
return predictions
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=4.44.0,<5
|
| 2 |
+
torch==2.3.1
|
| 3 |
+
numpy<2
|
| 4 |
+
pandas>=2.0
|
| 5 |
+
plotly>=5.18
|
| 6 |
+
requests>=2.31
|