Spaces:
Running
Running
Sync from GitHub 1e278fe
Browse files- README.md +7 -7
- app.py +13 -36
- deploy/README_SPACE.md +7 -7
- scripts/update_readme.py +3 -4
- src/gridpulse/features/build.py +0 -3
- src/gridpulse/warehouse/build.py +0 -6
README.md
CHANGED
|
@@ -17,14 +17,14 @@ benchmarked against the **EIA's own published day-ahead forecast**.
|
|
| 17 |
|
| 18 |
| Model | MAPE | vs EIA |
|
| 19 |
|---|---|---|
|
| 20 |
-
| **LightGBM hybrid** (+ EIA forecast as input) | **2.
|
| 21 |
-
| **LightGBM** (global,
|
| 22 |
-
| _EIA official forecast_ | 3.
|
| 23 |
-
| Seasonal naive (24h) | 5.
|
| 24 |
-
| Weekly naive (168h) | 9.
|
| 25 |
|
| 26 |
-
Measured over 25,
|
| 27 |
-
|
| 28 |
|
| 29 |
## What you can do here
|
| 30 |
|
|
|
|
| 17 |
|
| 18 |
| Model | MAPE | vs EIA |
|
| 19 |
|---|---|---|
|
| 20 |
+
| **LightGBM hybrid** (+ EIA forecast as input) | **2.797%** | **+24.1%** |
|
| 21 |
+
| **LightGBM** (global, weather and calendar only) | 3.683% | +0.1% |
|
| 22 |
+
| _EIA official forecast_ | 3.686% | benchmark |
|
| 23 |
+
| Seasonal naive (24h) | 5.657% | -53.5% |
|
| 24 |
+
| Weekly naive (168h) | 9.328% | -153.1% |
|
| 25 |
|
| 26 |
+
Measured over 25,925 out-of-sample hours across 12 balancing authorities, split by
|
| 27 |
+
date so the model never sees the test window.
|
| 28 |
|
| 29 |
## What you can do here
|
| 30 |
|
app.py
CHANGED
|
@@ -645,20 +645,10 @@ with tabs[6]:
|
|
| 645 |
|
| 646 |
st.markdown(
|
| 647 |
"""
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
they burn fuel making electricity nobody uses. Guess too low and they have to buy
|
| 653 |
-
the shortfall at emergency prices, or cut power to customers. On a large grid,
|
| 654 |
-
being off by one percent costs millions of dollars a year.
|
| 655 |
-
|
| 656 |
-
### What I compare against
|
| 657 |
-
|
| 658 |
-
The EIA publishes each region's own day-ahead forecast next to what actually
|
| 659 |
-
happened. So instead of making up an easy baseline, every model here is scored
|
| 660 |
-
against the forecast grid operators really published and really used, which means
|
| 661 |
-
anyone can check whether these results hold up.
|
| 662 |
|
| 663 |
### The pipeline
|
| 664 |
"""
|
|
@@ -695,28 +685,15 @@ Open-Meteo --+ |
|
|
| 695 |
|
| 696 |
st.markdown(
|
| 697 |
"""
|
| 698 |
-
###
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
to deploy and monitor instead of twelve.
|
| 708 |
-
|
| 709 |
-
Why I flag bad data instead of deleting it. A meter stuck on the same value is
|
| 710 |
-
proof that the meter broke. Quietly dropping that row also drops the only record
|
| 711 |
-
that anything went wrong.
|
| 712 |
-
|
| 713 |
-
Why I only split by date. If you split time-series data randomly, rows from the
|
| 714 |
-
future sit next to rows from the past and the model effectively sees answers it
|
| 715 |
-
should not have. The scores look great and mean nothing.
|
| 716 |
-
|
| 717 |
-
Why using tomorrow's weather is not cheating. A real grid operator also has
|
| 718 |
-
tomorrow's weather forecast and knows what day of the week it is. Leaving it out
|
| 719 |
-
would mean solving a harder problem than the real one.
|
| 720 |
|
| 721 |
### What it is built with
|
| 722 |
|
|
|
|
| 645 |
|
| 646 |
st.markdown(
|
| 647 |
"""
|
| 648 |
+
Grid operators decide today how much power to generate tomorrow, so a day-ahead
|
| 649 |
+
demand forecast has real money attached to it. The EIA publishes each region's own
|
| 650 |
+
day-ahead forecast next to what actually happened, so every model here is scored
|
| 651 |
+
against that instead of a baseline I made up.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 652 |
|
| 653 |
### The pipeline
|
| 654 |
"""
|
|
|
|
| 685 |
|
| 686 |
st.markdown(
|
| 687 |
"""
|
| 688 |
+
### Some choices
|
| 689 |
+
|
| 690 |
+
- One LightGBM model across all 12 regions, with the region code as a categorical
|
| 691 |
+
feature, so the bigger regions help the smaller ones and there is one model file
|
| 692 |
+
to deploy instead of twelve.
|
| 693 |
+
- Bad readings are flagged, not deleted, so a broken meter leaves evidence behind.
|
| 694 |
+
- Every split is by date. Splitting time series randomly puts future rows next to
|
| 695 |
+
past ones and the scores stop meaning anything.
|
| 696 |
+
- The models use tomorrow's weather forecast, which a real grid operator also has.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 697 |
|
| 698 |
### What it is built with
|
| 699 |
|
deploy/README_SPACE.md
CHANGED
|
@@ -17,14 +17,14 @@ benchmarked against the **EIA's own published day-ahead forecast**.
|
|
| 17 |
|
| 18 |
| Model | MAPE | vs EIA |
|
| 19 |
|---|---|---|
|
| 20 |
-
| **LightGBM hybrid** (+ EIA forecast as input) | **2.
|
| 21 |
-
| **LightGBM** (global,
|
| 22 |
-
| _EIA official forecast_ | 3.
|
| 23 |
-
| Seasonal naive (24h) | 5.
|
| 24 |
-
| Weekly naive (168h) | 9.
|
| 25 |
|
| 26 |
-
Measured over 25,
|
| 27 |
-
|
| 28 |
|
| 29 |
## What you can do here
|
| 30 |
|
|
|
|
| 17 |
|
| 18 |
| Model | MAPE | vs EIA |
|
| 19 |
|---|---|---|
|
| 20 |
+
| **LightGBM hybrid** (+ EIA forecast as input) | **2.797%** | **+24.1%** |
|
| 21 |
+
| **LightGBM** (global, weather and calendar only) | 3.683% | +0.1% |
|
| 22 |
+
| _EIA official forecast_ | 3.686% | benchmark |
|
| 23 |
+
| Seasonal naive (24h) | 5.657% | -53.5% |
|
| 24 |
+
| Weekly naive (168h) | 9.328% | -153.1% |
|
| 25 |
|
| 26 |
+
Measured over 25,925 out-of-sample hours across 12 balancing authorities, split by
|
| 27 |
+
date so the model never sees the test window.
|
| 28 |
|
| 29 |
## What you can do here
|
| 30 |
|
scripts/update_readme.py
CHANGED
|
@@ -18,7 +18,7 @@ PRETTY = {
|
|
| 18 |
"lstm": "**LSTM** encoder",
|
| 19 |
"transformer": "**Transformer** encoder",
|
| 20 |
"ensemble": "**Ensemble** (GBM + LSTM)",
|
| 21 |
-
"eia_official": "_EIA official forecast_
|
| 22 |
"seasonal_naive": "Seasonal naive (24h)",
|
| 23 |
"weekly_naive": "Weekly naive (168h)",
|
| 24 |
"drift_naive": "Drift naive",
|
|
@@ -73,9 +73,8 @@ def main() -> int:
|
|
| 73 |
f"**{head['test_observations']:,}** out-of-sample hours across 12 balancing "
|
| 74 |
f"authorities.\n"
|
| 75 |
f">\n"
|
| 76 |
-
f">
|
| 77 |
-
f"forecast the US government actually published
|
| 78 |
-
f"operated against.\n\n" + table
|
| 79 |
)
|
| 80 |
|
| 81 |
table += (
|
|
|
|
| 18 |
"lstm": "**LSTM** encoder",
|
| 19 |
"transformer": "**Transformer** encoder",
|
| 20 |
"ensemble": "**Ensemble** (GBM + LSTM)",
|
| 21 |
+
"eia_official": "_EIA official forecast_",
|
| 22 |
"seasonal_naive": "Seasonal naive (24h)",
|
| 23 |
"weekly_naive": "Weekly naive (168h)",
|
| 24 |
"drift_naive": "Drift naive",
|
|
|
|
| 73 |
f"**{head['test_observations']:,}** out-of-sample hours across 12 balancing "
|
| 74 |
f"authorities.\n"
|
| 75 |
f">\n"
|
| 76 |
+
f"> The model never sees the test window during training, and the EIA "
|
| 77 |
+
f"benchmark is the forecast the US government actually published.\n\n" + table
|
|
|
|
| 78 |
)
|
| 79 |
|
| 80 |
table += (
|
src/gridpulse/features/build.py
CHANGED
|
@@ -44,9 +44,6 @@ def load_modelling_frame(ba_codes: list[str] | None = None) -> pd.DataFrame:
|
|
| 44 |
|
| 45 |
frame = query(f"""
|
| 46 |
SELECT period_utc, ba_code, date_local, hour_local,
|
| 47 |
-
-- The warehouse is the single source of truth for what counts as a
|
| 48 |
-
-- trustworthy reading. Modelling consumes demand_clean_mwh so the
|
| 49 |
-
-- cleaning rules live in one place rather than being reimplemented here.
|
| 50 |
demand_clean_mwh AS demand_mwh, demand_forecast_mwh,
|
| 51 |
temperature_2m, apparent_temperature, relative_humidity_2m,
|
| 52 |
dew_point_2m, cloud_cover, wind_speed_10m, shortwave_radiation,
|
|
|
|
| 44 |
|
| 45 |
frame = query(f"""
|
| 46 |
SELECT period_utc, ba_code, date_local, hour_local,
|
|
|
|
|
|
|
|
|
|
| 47 |
demand_clean_mwh AS demand_mwh, demand_forecast_mwh,
|
| 48 |
temperature_2m, apparent_temperature, relative_humidity_2m,
|
| 49 |
dew_point_2m, cloud_cover, wind_speed_10m, shortwave_radiation,
|
src/gridpulse/warehouse/build.py
CHANGED
|
@@ -173,7 +173,6 @@ def build_warehouse(rebuild: bool = False) -> dict[str, int]:
|
|
| 173 |
e.total_interchange_mwh,
|
| 174 |
{weather_select},
|
| 175 |
|
| 176 |
-
-- Data quality flags. Diagnosis, not deletion.
|
| 177 |
(e.demand_mwh IS NULL) AS flag_missing_demand,
|
| 178 |
(e.demand_mwh IS NOT NULL
|
| 179 |
AND e.demand_mwh < {MIN_PLAUSIBLE_MWH}) AS flag_nonpositive_demand,
|
|
@@ -239,8 +238,6 @@ def build_warehouse(rebuild: bool = False) -> dict[str, int]:
|
|
| 239 |
s.total_interchange_mwh,
|
| 240 |
{", ".join("s." + v for v in WEATHER_VARIABLES)},
|
| 241 |
|
| 242 |
-
-- Interpolated demand: a modelling-ready series with short gaps bridged.
|
| 243 |
-
-- The raw column is retained untouched alongside it.
|
| 244 |
CASE WHEN s.demand_mwh IS NOT NULL
|
| 245 |
AND NOT s.flag_nonpositive_demand
|
| 246 |
AND s.demand_mwh BETWEEN b.lower_bound AND b.upper_bound
|
|
@@ -258,13 +255,10 @@ def build_warehouse(rebuild: bool = False) -> dict[str, int]:
|
|
| 258 |
/ nullif(lag(s.demand_mwh) OVER w, 0) * 100
|
| 259 |
> {MAX_HOURLY_RAMP_PCT}) AS flag_extreme_ramp,
|
| 260 |
|
| 261 |
-
-- Physically impossible magnitude. Flagged, never deleted: the reading
|
| 262 |
-
-- is the evidence that upstream telemetry failed.
|
| 263 |
(s.demand_mwh IS NOT NULL
|
| 264 |
AND (s.demand_mwh < b.lower_bound
|
| 265 |
OR s.demand_mwh > b.upper_bound)) AS flag_implausible_magnitude,
|
| 266 |
|
| 267 |
-
-- Isolated excursion: departs sharply from the local 5-hour median.
|
| 268 |
(s.demand_mwh IS NOT NULL
|
| 269 |
AND n.local_median IS NOT NULL
|
| 270 |
AND abs(s.demand_mwh - n.local_median)
|
|
|
|
| 173 |
e.total_interchange_mwh,
|
| 174 |
{weather_select},
|
| 175 |
|
|
|
|
| 176 |
(e.demand_mwh IS NULL) AS flag_missing_demand,
|
| 177 |
(e.demand_mwh IS NOT NULL
|
| 178 |
AND e.demand_mwh < {MIN_PLAUSIBLE_MWH}) AS flag_nonpositive_demand,
|
|
|
|
| 238 |
s.total_interchange_mwh,
|
| 239 |
{", ".join("s." + v for v in WEATHER_VARIABLES)},
|
| 240 |
|
|
|
|
|
|
|
| 241 |
CASE WHEN s.demand_mwh IS NOT NULL
|
| 242 |
AND NOT s.flag_nonpositive_demand
|
| 243 |
AND s.demand_mwh BETWEEN b.lower_bound AND b.upper_bound
|
|
|
|
| 255 |
/ nullif(lag(s.demand_mwh) OVER w, 0) * 100
|
| 256 |
> {MAX_HOURLY_RAMP_PCT}) AS flag_extreme_ramp,
|
| 257 |
|
|
|
|
|
|
|
| 258 |
(s.demand_mwh IS NOT NULL
|
| 259 |
AND (s.demand_mwh < b.lower_bound
|
| 260 |
OR s.demand_mwh > b.upper_bound)) AS flag_implausible_magnitude,
|
| 261 |
|
|
|
|
| 262 |
(s.demand_mwh IS NOT NULL
|
| 263 |
AND n.local_median IS NOT NULL
|
| 264 |
AND abs(s.demand_mwh - n.local_median)
|