adwitiyashukla commited on
Commit
d3de4d0
·
verified ·
1 Parent(s): d2cb649

Sync from GitHub 1e278fe

Browse files
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.771%** | **+25.0%** |
21
- | **LightGBM** (global, from first principles) | 3.676% | +0.6% |
22
- | _EIA official forecast_ | 3.696% | benchmark |
23
- | Seasonal naive (24h) | 5.677% | -53.6% |
24
- | Weekly naive (168h) | 9.117% | -146.7% |
25
 
26
- Measured over 25,927 out-of-sample hours across 12 balancing authorities, using a
27
- strictly chronological split.
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
- ### The problem
649
-
650
- Electricity cannot really be stored at grid scale, so the companies that run the
651
- grid have to decide today how much power to generate tomorrow. Guess too high and
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
- ### Why I built it this way
699
-
700
- Why DuckDB and not Postgres or Spark. It handles hundreds of millions of rows
701
- inside a single file with no server to run. I built this on a laptop with 8 GB of
702
- RAM, and that was the difference between a pipeline that finishes and one that runs
703
- out of memory. The SQL would still run on Snowflake or BigQuery unchanged.
704
-
705
- Why one model for all 12 regions. The regions behave similarly, so training
706
- together lets the bigger ones help the smaller ones. It also means one model file
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.771%** | **+25.0%** |
21
- | **LightGBM** (global, from first principles) | 3.676% | +0.6% |
22
- | _EIA official forecast_ | 3.696% | benchmark |
23
- | Seasonal naive (24h) | 5.677% | -53.6% |
24
- | Weekly naive (168h) | 9.117% | -146.7% |
25
 
26
- Measured over 25,927 out-of-sample hours across 12 balancing authorities, using a
27
- strictly chronological split.
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"> Trained without ever seeing the test window. The EIA benchmark is the "
77
- f"forecast the US government actually published and grid operators actually "
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)