jeffliulab commited on
Commit
308474b
·
verified ·
1 Parent(s): 1da57ac

Initial deploy: live ISO-NE demand fetch + baseline inference

Browse files
README.md CHANGED
@@ -1,12 +1,58 @@
1
  ---
2
- title: Predict Power
3
- emoji: 🏢
4
- colorFrom: red
5
- colorTo: purple
6
  sdk: gradio
7
- sdk_version: 6.14.0
8
  app_file: app.py
9
  pinned: false
 
 
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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