gridshedwatch 1.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. gridshedwatch-1.1.1/LICENSE +21 -0
  2. gridshedwatch-1.1.1/PKG-INFO +170 -0
  3. gridshedwatch-1.1.1/README.md +147 -0
  4. gridshedwatch-1.1.1/gridshedwatch/__init__.py +0 -0
  5. gridshedwatch-1.1.1/gridshedwatch/alerts.py +77 -0
  6. gridshedwatch-1.1.1/gridshedwatch/baselines/__init__.py +14 -0
  7. gridshedwatch-1.1.1/gridshedwatch/baselines/chronos.py +39 -0
  8. gridshedwatch-1.1.1/gridshedwatch/baselines/lstm.py +88 -0
  9. gridshedwatch-1.1.1/gridshedwatch/baselines/oprule.py +32 -0
  10. gridshedwatch-1.1.1/gridshedwatch/baselines/zscore.py +24 -0
  11. gridshedwatch-1.1.1/gridshedwatch/benchmark.py +205 -0
  12. gridshedwatch-1.1.1/gridshedwatch/cli.py +129 -0
  13. gridshedwatch-1.1.1/gridshedwatch/config.py +55 -0
  14. gridshedwatch-1.1.1/gridshedwatch/data.py +131 -0
  15. gridshedwatch-1.1.1/gridshedwatch/events.yaml +105 -0
  16. gridshedwatch-1.1.1/gridshedwatch/operator_notices.yaml +124 -0
  17. gridshedwatch-1.1.1/gridshedwatch/rules.py +121 -0
  18. gridshedwatch-1.1.1/gridshedwatch/snapshot_v1.0/SHA256SUMS +8 -0
  19. gridshedwatch-1.1.1/gridshedwatch/snapshot_v1.0/eia930/CISO.csv.gz +0 -0
  20. gridshedwatch-1.1.1/gridshedwatch/snapshot_v1.0/eia930/CPLE.csv.gz +0 -0
  21. gridshedwatch-1.1.1/gridshedwatch/snapshot_v1.0/eia930/DUK.csv.gz +0 -0
  22. gridshedwatch-1.1.1/gridshedwatch/snapshot_v1.0/eia930/ERCO.csv.gz +0 -0
  23. gridshedwatch-1.1.1/gridshedwatch/snapshot_v1.0/eia930/LGEE.csv.gz +0 -0
  24. gridshedwatch-1.1.1/gridshedwatch/snapshot_v1.0/eia930/MISO.csv.gz +0 -0
  25. gridshedwatch-1.1.1/gridshedwatch/snapshot_v1.0/eia930/SWPP.csv.gz +0 -0
  26. gridshedwatch-1.1.1/gridshedwatch/snapshot_v1.0/eia930/TVA.csv.gz +0 -0
  27. gridshedwatch-1.1.1/gridshedwatch/submission.py +68 -0
  28. gridshedwatch-1.1.1/gridshedwatch.egg-info/PKG-INFO +170 -0
  29. gridshedwatch-1.1.1/gridshedwatch.egg-info/SOURCES.txt +38 -0
  30. gridshedwatch-1.1.1/gridshedwatch.egg-info/dependency_links.txt +1 -0
  31. gridshedwatch-1.1.1/gridshedwatch.egg-info/entry_points.txt +2 -0
  32. gridshedwatch-1.1.1/gridshedwatch.egg-info/requires.txt +13 -0
  33. gridshedwatch-1.1.1/gridshedwatch.egg-info/top_level.txt +1 -0
  34. gridshedwatch-1.1.1/pyproject.toml +35 -0
  35. gridshedwatch-1.1.1/setup.cfg +4 -0
  36. gridshedwatch-1.1.1/tests/test_alerts.py +74 -0
  37. gridshedwatch-1.1.1/tests/test_data.py +52 -0
  38. gridshedwatch-1.1.1/tests/test_regression.py +48 -0
  39. gridshedwatch-1.1.1/tests/test_rules.py +67 -0
  40. gridshedwatch-1.1.1/tests/test_scoring.py +39 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sam Heiserman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,170 @@
1
+ Metadata-Version: 2.4
2
+ Name: gridshedwatch
3
+ Version: 1.1.1
4
+ Summary: A benchmark for early warning of grid load sheds, scored the way forecasters score weather warnings
5
+ Author: Sam Heiserman
6
+ License: MIT
7
+ Project-URL: Homepage, https://gotham29.github.io/gridshedwatch
8
+ Project-URL: Repository, https://github.com/gotham29/gridshedwatch
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: numpy>=1.22
13
+ Requires-Dist: pandas>=1.5
14
+ Requires-Dist: pyyaml>=6
15
+ Provides-Extra: lstm
16
+ Requires-Dist: torch>=2.0; extra == "lstm"
17
+ Provides-Extra: chronos
18
+ Requires-Dist: torch>=2.0; extra == "chronos"
19
+ Requires-Dist: chronos-forecasting>=1.4; extra == "chronos"
20
+ Provides-Extra: test
21
+ Requires-Dist: pytest>=7; extra == "test"
22
+ Dynamic: license-file
23
+
24
+ # GridShedWatch
25
+
26
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23626223.svg)](https://doi.org/10.5281/zenodo.23626223) [![tests](https://github.com/gotham29/gridshedwatch/actions/workflows/tests.yml/badge.svg)](https://github.com/gotham29/gridshedwatch/actions/workflows/tests.yml)
27
+
28
+ **A benchmark for early warning of grid load sheds, scored the way forecasters score weather warnings.**
29
+
30
+ Website: **https://gotham29.github.io/gridshedwatch** (built from this repo by `scripts/build_site.py`).
31
+
32
+ Since 2020, US grid operators have had to cut power for lack of supply eight times. The public
33
+ was warned beforehand in four of them, and in none of the four during Winter Storm Elliott.
34
+ Plenty of methods claim to detect grid trouble early, but there has been no shared test of
35
+ whether a warning would actually have come in time. GridShedWatch is that test: every one of
36
+ those eight load sheds, public hourly data, and one fixed scoring procedure that any method can
37
+ be run through.
38
+
39
+ The National Weather Service scores every warning it issues on three numbers read together:
40
+ how many events were warned in advance, how much lead time people got, and how often warnings
41
+ were false alarms. GridShedWatch asks the same of grid warnings, at two levels: a **watch**
42
+ (about one a month per grid: something unusual is building) and a **warning** (two a year: act).
43
+
44
+ ## What's in it
45
+
46
+ | | |
47
+ |---|---|
48
+ | **Events** | 8 supply-shortfall load sheds of at least 250 MW, Jan 2020 to Sep 2026, chosen by a stated rule from NERC's annual lists ([docs/events.md](https://github.com/gotham29/gridshedwatch/blob/main/docs/events.md)) |
49
+ | **Data** | A frozen snapshot of EIA-930 hourly demand, day-ahead forecast and net generation for the 8 grids, 2019-2026, and the five signals built from it ([gridshedwatch/snapshot_v1.0/](https://github.com/gotham29/gridshedwatch/tree/main/gridshedwatch/snapshot_v1.0/), [gridshedwatch/data.py](https://github.com/gotham29/gridshedwatch/blob/main/gridshedwatch/data.py)) |
50
+ | **Rules** | One shared rule that turns per-signal scores into watches and warnings, so methods differ only in how they score each hour ([gridshedwatch/rules.py](https://github.com/gotham29/gridshedwatch/blob/main/gridshedwatch/rules.py)) |
51
+ | **Scoring** | Same alert budget for every method, chosen without looking at the events; events caught, lead time and false alarm ratio, with bootstrap intervals and a chance baseline ([docs/scoring.md](https://github.com/gotham29/gridshedwatch/blob/main/docs/scoring.md)) |
52
+ | **Status quo** | Every operator notice in the week before each event, with times and sources ([gridshedwatch/operator_notices.yaml](https://github.com/gotham29/gridshedwatch/blob/main/gridshedwatch/operator_notices.yaml)) |
53
+ | **Reference methods** | An operator-style rule, a seasonal z-score, an LSTM forecaster, Chronos-Bolt (zero-shot), and HTM ([gridshedwatch/baselines/](https://github.com/gotham29/gridshedwatch/tree/main/gridshedwatch/baselines/)) |
54
+ | **Datasheet** | Motivation, composition, collection, intended uses, known biases and maintenance ([docs/datasheet.md](https://github.com/gotham29/gridshedwatch/blob/main/docs/datasheet.md)) |
55
+
56
+ | Event | Grid | Shed at its worst | Split |
57
+ |---|---|---|---|
58
+ | Heat wave, Aug 2020 | California (CAISO) | about 1,000 MW | development |
59
+ | Winter Storm Uri, Feb 2021 | Texas (ERCOT) | 20,000 MW | development |
60
+ | Uri | Southwest Power Pool | 2,700 MW | held-out |
61
+ | Uri | MISO (MISO South) | 700 MW | held-out |
62
+ | Winter Storm Elliott, Dec 2022 | Tennessee Valley Authority | 3,000 MW | development |
63
+ | Elliott | Duke Energy Carolinas | up to 1,000 MW | held-out |
64
+ | Elliott | Duke Energy Progress | about 960 MW | held-out |
65
+ | Elliott | LG&E and KU (Kentucky) | about 300 MW | held-out |
66
+
67
+ ## Results, v1.0
68
+
69
+ <!-- leaderboard:start -->
70
+ | Method | Watches: caught (of 8) | held-out (of 5) | by chance | median lead (h) | Warnings: caught (of 8) | held-out (of 5) | by chance | False alarm ratio (watch / warning) |
71
+ |---|---|---|---|---|---|---|---|---|
72
+ | Operators (public notices) | 4 | 2 | - | 20 | - | - | - | - |
73
+ | Operator-style rule | 5 [3.0-7.0] | 4 | 1.2 | 85 | 2 [0.0-4.0] | 1 | 0.2 | 98% / 94% |
74
+ | Seasonal z-score | 5 [3.0-7.0] | 4 | 1.1 | 105 | 6 [4.0-8.0] | 4 | 0.2 | 98% / 92% |
75
+ | Chronos-Bolt (zero-shot) | 1 [0.0-3.0] | 1 | 0.9 | 9 | 3 [1.0-5.0] | 1 | 0.3 | 100% / 96% |
76
+ | LSTM forecaster | 6.4 [4.6-8.0] | 4.4 | 1.3 | 8 | 4.8 [2.8-6.8] | 2.8 | 0.3 | 99% / 94% |
77
+ | HTM (htm-monitor) | 6 [4.6-7.2] | 4 | 1.4 | 56 | 5.6 [3.8-7.2] | 3.6 | 0.3 | 98% / 93% |
78
+
79
+ All five reference methods are in the scores track; no alerts-track entries yet. Mean over 5 seeds for the LSTM and HTM. [a-b]: 5th-95th percentile with events resampled. Operators: public notices in the 7 days before onset, scored as watches.
80
+ <!-- leaderboard:end -->
81
+
82
+ Every method except Chronos's watches catches far more than chance, and none leads at both
83
+ levels. The differences are by kind of emergency: the simple rules caught all three Uri events
84
+ days ahead, the LSTM and HTM caught the Elliott events hours ahead (when the public got no
85
+ warning), and only the operators warned California. With eight events, differences of about one
86
+ event are not meaningful. Per-event results: `results/v1.0/<method>_events.csv`.
87
+
88
+ ## Quick start
89
+
90
+ ```bash
91
+ pip install gridshedwatch # or, to work on the code:
92
+ git clone https://github.com/gotham29/gridshedwatch && cd gridshedwatch
93
+ pip install -e ".[test]"
94
+ gridshedwatch info # the eight events and their timelines
95
+ gridshedwatch score --oprule # score the built-in operator-style rule
96
+ python examples/my_method.py # scores track template: writes my_scores/
97
+ gridshedwatch score my_scores --name "My method"
98
+ python examples/my_alerts.py # alerts track template: writes my_alerts.csv
99
+ gridshedwatch score-alerts my_alerts.csv --name "My rule"
100
+ ```
101
+
102
+ ## Add your method
103
+
104
+ There are two ways in.
105
+
106
+ **Scores track** (the controlled comparison). Your method says, for every grid, every hour and
107
+ each of the five signals, how unusual that signal is now, as a score in [0, 1], using data up to
108
+ that hour only. Write the scores in the submission layout (`my_scores/s<seed>/<grid>.csv`, see
109
+ [gridshedwatch/submission.py](https://github.com/gotham29/gridshedwatch/blob/main/gridshedwatch/submission.py)) and run `gridshedwatch score`. The
110
+ shared rules, the label-free budget step and the scoring are done for you, so your method
111
+ cannot be tuned toward the eight events. Template: [examples/my_method.py](https://github.com/gotham29/gridshedwatch/blob/main/examples/my_method.py).
112
+
113
+ **Alerts track** (any method). If your method decides on its own when to raise a watch or a
114
+ warning (a rule, a classifier, a weather-based model, a human forecaster), list when each alert
115
+ started:
116
+
117
+ ```
118
+ grid,level,start_utc,seed
119
+ tx,watch,2021-02-11 03:00,0
120
+ ```
121
+
122
+ and run `gridshedwatch score-alerts alerts.csv --name "My method"`. It is scored exactly like
123
+ the scores track, at the same budget (median grid at most 12 watches and 2 warnings a year, no
124
+ grid above 3 times that). Format: [gridshedwatch/alerts.py](https://github.com/gotham29/gridshedwatch/blob/main/gridshedwatch/alerts.py); template:
125
+ [examples/my_alerts.py](https://github.com/gotham29/gridshedwatch/blob/main/examples/my_alerts.py); worked examples: the five reference methods'
126
+ alerts in [results/v1.0/alerts/](https://github.com/gotham29/gridshedwatch/tree/main/results/v1.0/alerts/), which reproduce the table above.
127
+
128
+ - Scores track: use the five signals provided (and 2019 for calibration). Alerts track: any data
129
+ available at the time of each alert. Methods that use data beyond the five signals are listed
130
+ separately.
131
+ - If your method is random, submit several seeds; results are averaged.
132
+ - To add a result to the table, open a pull request with your scores or alerts (or a link to
133
+ them), the code that produced them, the output of `--json`, and short answers on the data used
134
+ and, for the alerts track, which events you looked at while developing
135
+ ([docs/scoring.md](https://github.com/gotham29/gridshedwatch/blob/main/docs/scoring.md#listing-a-result)). Every result is added, including ones
136
+ that beat the reference methods.
137
+
138
+ ## Reproducing v1.0
139
+
140
+ ```bash
141
+ python -m pytest -q # rebuilds the rule and z-score results from the data
142
+ python scripts/fetch_reference.py # reference scores for LSTM, Chronos, HTM (about 100 MB)
143
+ python -m pytest -q # now also checks those three against results/v1.0/
144
+ python scripts/reproduce_v1.py # writes results/v1.0/ from scratch
145
+ docker build -t gridshedwatch . && docker run --rm gridshedwatch
146
+ ```
147
+
148
+ The LSTM and Chronos can be rerun with `gridshedwatch baseline lstm|chronos -o DIR` (needs
149
+ `pip install -e ".[lstm]"` or `".[chronos]"`). Neural-network training is not bit-identical
150
+ across platforms and library versions, so a rerun gives close but not identical scores; the
151
+ reference scores are the official v1.0 results. HTM's scores come from
152
+ [htm-monitor](https://github.com/gotham29/htm-monitor), scored here like any other submission.
153
+
154
+ ## Limits
155
+
156
+ - Eight events, seven from two storms (Uri and Elliott). One storm of a new kind could change
157
+ the picture.
158
+ - Three events were used to design the shared rules; five were held out and scored once.
159
+ - Reference methods see only five signals from EIA-930: no weather, outage or market data. The
160
+ alerts track is open to methods that use more.
161
+ - The status quo row covers operators' public notices; their notices to the industry and their
162
+ false alarms in ordinary years are not yet compiled.
163
+
164
+ GridShedWatch grows as new events happen: each load shed that meets the rule is added in a new
165
+ version and scored with the rules frozen in v1.0.
166
+
167
+ ## Citation and license
168
+
169
+ DOI: [10.5281/zenodo.23626223](https://doi.org/10.5281/zenodo.23626223) (archived on Zenodo with every release). See [CITATION.cff](https://github.com/gotham29/gridshedwatch/blob/main/CITATION.cff). Code: MIT. Event list, timelines, documentation and results:
170
+ CC BY 4.0. EIA-930 data: U.S. public domain ([DATA_LICENSE.md](https://github.com/gotham29/gridshedwatch/blob/main/DATA_LICENSE.md)).
@@ -0,0 +1,147 @@
1
+ # GridShedWatch
2
+
3
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23626223.svg)](https://doi.org/10.5281/zenodo.23626223) [![tests](https://github.com/gotham29/gridshedwatch/actions/workflows/tests.yml/badge.svg)](https://github.com/gotham29/gridshedwatch/actions/workflows/tests.yml)
4
+
5
+ **A benchmark for early warning of grid load sheds, scored the way forecasters score weather warnings.**
6
+
7
+ Website: **https://gotham29.github.io/gridshedwatch** (built from this repo by `scripts/build_site.py`).
8
+
9
+ Since 2020, US grid operators have had to cut power for lack of supply eight times. The public
10
+ was warned beforehand in four of them, and in none of the four during Winter Storm Elliott.
11
+ Plenty of methods claim to detect grid trouble early, but there has been no shared test of
12
+ whether a warning would actually have come in time. GridShedWatch is that test: every one of
13
+ those eight load sheds, public hourly data, and one fixed scoring procedure that any method can
14
+ be run through.
15
+
16
+ The National Weather Service scores every warning it issues on three numbers read together:
17
+ how many events were warned in advance, how much lead time people got, and how often warnings
18
+ were false alarms. GridShedWatch asks the same of grid warnings, at two levels: a **watch**
19
+ (about one a month per grid: something unusual is building) and a **warning** (two a year: act).
20
+
21
+ ## What's in it
22
+
23
+ | | |
24
+ |---|---|
25
+ | **Events** | 8 supply-shortfall load sheds of at least 250 MW, Jan 2020 to Sep 2026, chosen by a stated rule from NERC's annual lists ([docs/events.md](https://github.com/gotham29/gridshedwatch/blob/main/docs/events.md)) |
26
+ | **Data** | A frozen snapshot of EIA-930 hourly demand, day-ahead forecast and net generation for the 8 grids, 2019-2026, and the five signals built from it ([gridshedwatch/snapshot_v1.0/](https://github.com/gotham29/gridshedwatch/tree/main/gridshedwatch/snapshot_v1.0/), [gridshedwatch/data.py](https://github.com/gotham29/gridshedwatch/blob/main/gridshedwatch/data.py)) |
27
+ | **Rules** | One shared rule that turns per-signal scores into watches and warnings, so methods differ only in how they score each hour ([gridshedwatch/rules.py](https://github.com/gotham29/gridshedwatch/blob/main/gridshedwatch/rules.py)) |
28
+ | **Scoring** | Same alert budget for every method, chosen without looking at the events; events caught, lead time and false alarm ratio, with bootstrap intervals and a chance baseline ([docs/scoring.md](https://github.com/gotham29/gridshedwatch/blob/main/docs/scoring.md)) |
29
+ | **Status quo** | Every operator notice in the week before each event, with times and sources ([gridshedwatch/operator_notices.yaml](https://github.com/gotham29/gridshedwatch/blob/main/gridshedwatch/operator_notices.yaml)) |
30
+ | **Reference methods** | An operator-style rule, a seasonal z-score, an LSTM forecaster, Chronos-Bolt (zero-shot), and HTM ([gridshedwatch/baselines/](https://github.com/gotham29/gridshedwatch/tree/main/gridshedwatch/baselines/)) |
31
+ | **Datasheet** | Motivation, composition, collection, intended uses, known biases and maintenance ([docs/datasheet.md](https://github.com/gotham29/gridshedwatch/blob/main/docs/datasheet.md)) |
32
+
33
+ | Event | Grid | Shed at its worst | Split |
34
+ |---|---|---|---|
35
+ | Heat wave, Aug 2020 | California (CAISO) | about 1,000 MW | development |
36
+ | Winter Storm Uri, Feb 2021 | Texas (ERCOT) | 20,000 MW | development |
37
+ | Uri | Southwest Power Pool | 2,700 MW | held-out |
38
+ | Uri | MISO (MISO South) | 700 MW | held-out |
39
+ | Winter Storm Elliott, Dec 2022 | Tennessee Valley Authority | 3,000 MW | development |
40
+ | Elliott | Duke Energy Carolinas | up to 1,000 MW | held-out |
41
+ | Elliott | Duke Energy Progress | about 960 MW | held-out |
42
+ | Elliott | LG&E and KU (Kentucky) | about 300 MW | held-out |
43
+
44
+ ## Results, v1.0
45
+
46
+ <!-- leaderboard:start -->
47
+ | Method | Watches: caught (of 8) | held-out (of 5) | by chance | median lead (h) | Warnings: caught (of 8) | held-out (of 5) | by chance | False alarm ratio (watch / warning) |
48
+ |---|---|---|---|---|---|---|---|---|
49
+ | Operators (public notices) | 4 | 2 | - | 20 | - | - | - | - |
50
+ | Operator-style rule | 5 [3.0-7.0] | 4 | 1.2 | 85 | 2 [0.0-4.0] | 1 | 0.2 | 98% / 94% |
51
+ | Seasonal z-score | 5 [3.0-7.0] | 4 | 1.1 | 105 | 6 [4.0-8.0] | 4 | 0.2 | 98% / 92% |
52
+ | Chronos-Bolt (zero-shot) | 1 [0.0-3.0] | 1 | 0.9 | 9 | 3 [1.0-5.0] | 1 | 0.3 | 100% / 96% |
53
+ | LSTM forecaster | 6.4 [4.6-8.0] | 4.4 | 1.3 | 8 | 4.8 [2.8-6.8] | 2.8 | 0.3 | 99% / 94% |
54
+ | HTM (htm-monitor) | 6 [4.6-7.2] | 4 | 1.4 | 56 | 5.6 [3.8-7.2] | 3.6 | 0.3 | 98% / 93% |
55
+
56
+ All five reference methods are in the scores track; no alerts-track entries yet. Mean over 5 seeds for the LSTM and HTM. [a-b]: 5th-95th percentile with events resampled. Operators: public notices in the 7 days before onset, scored as watches.
57
+ <!-- leaderboard:end -->
58
+
59
+ Every method except Chronos's watches catches far more than chance, and none leads at both
60
+ levels. The differences are by kind of emergency: the simple rules caught all three Uri events
61
+ days ahead, the LSTM and HTM caught the Elliott events hours ahead (when the public got no
62
+ warning), and only the operators warned California. With eight events, differences of about one
63
+ event are not meaningful. Per-event results: `results/v1.0/<method>_events.csv`.
64
+
65
+ ## Quick start
66
+
67
+ ```bash
68
+ pip install gridshedwatch # or, to work on the code:
69
+ git clone https://github.com/gotham29/gridshedwatch && cd gridshedwatch
70
+ pip install -e ".[test]"
71
+ gridshedwatch info # the eight events and their timelines
72
+ gridshedwatch score --oprule # score the built-in operator-style rule
73
+ python examples/my_method.py # scores track template: writes my_scores/
74
+ gridshedwatch score my_scores --name "My method"
75
+ python examples/my_alerts.py # alerts track template: writes my_alerts.csv
76
+ gridshedwatch score-alerts my_alerts.csv --name "My rule"
77
+ ```
78
+
79
+ ## Add your method
80
+
81
+ There are two ways in.
82
+
83
+ **Scores track** (the controlled comparison). Your method says, for every grid, every hour and
84
+ each of the five signals, how unusual that signal is now, as a score in [0, 1], using data up to
85
+ that hour only. Write the scores in the submission layout (`my_scores/s<seed>/<grid>.csv`, see
86
+ [gridshedwatch/submission.py](https://github.com/gotham29/gridshedwatch/blob/main/gridshedwatch/submission.py)) and run `gridshedwatch score`. The
87
+ shared rules, the label-free budget step and the scoring are done for you, so your method
88
+ cannot be tuned toward the eight events. Template: [examples/my_method.py](https://github.com/gotham29/gridshedwatch/blob/main/examples/my_method.py).
89
+
90
+ **Alerts track** (any method). If your method decides on its own when to raise a watch or a
91
+ warning (a rule, a classifier, a weather-based model, a human forecaster), list when each alert
92
+ started:
93
+
94
+ ```
95
+ grid,level,start_utc,seed
96
+ tx,watch,2021-02-11 03:00,0
97
+ ```
98
+
99
+ and run `gridshedwatch score-alerts alerts.csv --name "My method"`. It is scored exactly like
100
+ the scores track, at the same budget (median grid at most 12 watches and 2 warnings a year, no
101
+ grid above 3 times that). Format: [gridshedwatch/alerts.py](https://github.com/gotham29/gridshedwatch/blob/main/gridshedwatch/alerts.py); template:
102
+ [examples/my_alerts.py](https://github.com/gotham29/gridshedwatch/blob/main/examples/my_alerts.py); worked examples: the five reference methods'
103
+ alerts in [results/v1.0/alerts/](https://github.com/gotham29/gridshedwatch/tree/main/results/v1.0/alerts/), which reproduce the table above.
104
+
105
+ - Scores track: use the five signals provided (and 2019 for calibration). Alerts track: any data
106
+ available at the time of each alert. Methods that use data beyond the five signals are listed
107
+ separately.
108
+ - If your method is random, submit several seeds; results are averaged.
109
+ - To add a result to the table, open a pull request with your scores or alerts (or a link to
110
+ them), the code that produced them, the output of `--json`, and short answers on the data used
111
+ and, for the alerts track, which events you looked at while developing
112
+ ([docs/scoring.md](https://github.com/gotham29/gridshedwatch/blob/main/docs/scoring.md#listing-a-result)). Every result is added, including ones
113
+ that beat the reference methods.
114
+
115
+ ## Reproducing v1.0
116
+
117
+ ```bash
118
+ python -m pytest -q # rebuilds the rule and z-score results from the data
119
+ python scripts/fetch_reference.py # reference scores for LSTM, Chronos, HTM (about 100 MB)
120
+ python -m pytest -q # now also checks those three against results/v1.0/
121
+ python scripts/reproduce_v1.py # writes results/v1.0/ from scratch
122
+ docker build -t gridshedwatch . && docker run --rm gridshedwatch
123
+ ```
124
+
125
+ The LSTM and Chronos can be rerun with `gridshedwatch baseline lstm|chronos -o DIR` (needs
126
+ `pip install -e ".[lstm]"` or `".[chronos]"`). Neural-network training is not bit-identical
127
+ across platforms and library versions, so a rerun gives close but not identical scores; the
128
+ reference scores are the official v1.0 results. HTM's scores come from
129
+ [htm-monitor](https://github.com/gotham29/htm-monitor), scored here like any other submission.
130
+
131
+ ## Limits
132
+
133
+ - Eight events, seven from two storms (Uri and Elliott). One storm of a new kind could change
134
+ the picture.
135
+ - Three events were used to design the shared rules; five were held out and scored once.
136
+ - Reference methods see only five signals from EIA-930: no weather, outage or market data. The
137
+ alerts track is open to methods that use more.
138
+ - The status quo row covers operators' public notices; their notices to the industry and their
139
+ false alarms in ordinary years are not yet compiled.
140
+
141
+ GridShedWatch grows as new events happen: each load shed that meets the rule is added in a new
142
+ version and scored with the rules frozen in v1.0.
143
+
144
+ ## Citation and license
145
+
146
+ DOI: [10.5281/zenodo.23626223](https://doi.org/10.5281/zenodo.23626223) (archived on Zenodo with every release). See [CITATION.cff](https://github.com/gotham29/gridshedwatch/blob/main/CITATION.cff). Code: MIT. Event list, timelines, documentation and results:
147
+ CC BY 4.0. EIA-930 data: U.S. public domain ([DATA_LICENSE.md](https://github.com/gotham29/gridshedwatch/blob/main/DATA_LICENSE.md)).
File without changes
@@ -0,0 +1,77 @@
1
+ """Alerts track: read, check and write a method's watch and warning start times.
2
+
3
+ File format (CSV), one row per alert episode:
4
+ grid,level,start_utc[,seed]
5
+ tx,watch,2021-02-11 03:00,0
6
+ grid: one of gridshedwatch.data.grids(); level: watch or warning; start_utc: when the alert
7
+ episode began, UTC (ISO format; minutes allowed); seed: optional, for methods with randomness
8
+ (default 0). Starts must fall in the scored period: after the first week of the run (warm-up)
9
+ and before the end of the run. A grid with no rows has no alerts.
10
+
11
+ An episode is one alert, however long it lasts: if your method raises an alert and keeps it on,
12
+ list the time it came on. Alerts must be issued using data up to that time only.
13
+ """
14
+ from pathlib import Path
15
+
16
+ import pandas as pd
17
+
18
+ from . import data
19
+
20
+ LEVELS = ("watch", "warning")
21
+ COLUMNS = ["grid", "level", "start_utc"]
22
+
23
+
24
+ def scored_period(grid: str) -> tuple:
25
+ tb = data.timebase(grid)
26
+ s = tb.loc[tb.in_warmup == 0, "timestamp"]
27
+ return s.iloc[0], s.iloc[-1]
28
+
29
+
30
+ def check(df: pd.DataFrame) -> pd.DataFrame:
31
+ missing = [c for c in COLUMNS if c not in df]
32
+ if missing:
33
+ raise ValueError(f"missing columns {missing}; need grid, level, start_utc (and optionally seed)")
34
+ df = df.copy()
35
+ if "seed" not in df:
36
+ df["seed"] = 0
37
+ bad = sorted(set(df.grid) - set(data.grids()))
38
+ if bad:
39
+ raise ValueError(f"unknown grids {bad}; use {data.grids()}")
40
+ bad = sorted(set(df.level) - set(LEVELS))
41
+ if bad:
42
+ raise ValueError(f"unknown levels {bad}; use watch or warning")
43
+ try:
44
+ df["start_utc"] = pd.to_datetime(df.start_utc, utc=True).dt.tz_localize(None)
45
+ except (ValueError, TypeError) as e:
46
+ raise ValueError(f"start_utc must be UTC timestamps: {e}") from None
47
+ df["seed"] = df.seed.astype(int)
48
+ for g, x in df.groupby("grid"):
49
+ lo, hi = scored_period(g)
50
+ out = x[(x.start_utc < lo) | (x.start_utc > hi)]
51
+ if len(out):
52
+ raise ValueError(f"{g}: {len(out)} starts outside the scored period {lo} to {hi}, e.g. {out.start_utc.iloc[0]}")
53
+ dup = df.duplicated(["seed", "grid", "level", "start_utc"])
54
+ if dup.any():
55
+ raise ValueError(f"{dup.sum()} duplicate rows (same seed, grid, level and start)")
56
+ return df
57
+
58
+
59
+ def load(path) -> dict:
60
+ """{seed: {grid: {level: [Timestamp, ...]}}}"""
61
+ df = check(pd.read_csv(path))
62
+ out = {}
63
+ for (k, g, lv), x in df.groupby(["seed", "grid", "level"]):
64
+ out.setdefault(int(k), {}).setdefault(g, {})[lv] = sorted(x.start_utc)
65
+ return out or {0: {}}
66
+
67
+
68
+ def from_rows(rows: pd.DataFrame) -> pd.DataFrame:
69
+ """Alert file rows from a scored Result's rows (to export a scores-track method's alerts)."""
70
+ recs = [dict(grid=r.grid, level=r.level, start_utc=pd.Timestamp(t).strftime("%Y-%m-%d %H:%M"), seed=int(r.seed))
71
+ for r in rows.itertuples() for t in str(r.starts).split("|") if t and t != "nan"]
72
+ return pd.DataFrame(recs, columns=COLUMNS + ["seed"]).sort_values(["seed", "grid", "level", "start_utc"])
73
+
74
+
75
+ def write(df: pd.DataFrame, path) -> None:
76
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
77
+ df.to_csv(path, index=False)
@@ -0,0 +1,14 @@
1
+ """Reference methods. Each turns the five signals into per-signal scores p in [0, 1], except the
2
+ operator-style rule, which raises alerts directly from demand and its day-ahead forecast."""
3
+ import bisect
4
+
5
+ import numpy as np
6
+
7
+
8
+ def to_p(score: np.ndarray) -> np.ndarray:
9
+ """Causal empirical CDF: share of strictly earlier scores that are <= the current one."""
10
+ seen, p = [], np.zeros(len(score))
11
+ for t, s in enumerate(score):
12
+ p[t] = bisect.bisect_right(seen, s) / len(seen) if seen else 0.0
13
+ bisect.insort(seen, s)
14
+ return p
@@ -0,0 +1,39 @@
1
+ """Chronos-Bolt, zero-shot (Ansari et al., 2024). Needs: pip install gridshedwatch[chronos].
2
+
3
+ Per grid and signal, the pretrained model forecasts the next hour from the previous 512 (the 2019
4
+ tail supplies the context for the first run hours); score = |actual - median forecast|. No
5
+ training or tuning, so it is deterministic. Caveat: the pretraining corpus includes public
6
+ electricity data, so overlap with 2020-2026 EIA-930 cannot be ruled out.
7
+ """
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+ from .. import data
12
+ from ..config import SIGNALS
13
+ from . import to_p
14
+
15
+ CTX = 512
16
+ MODEL = "amazon/chronos-bolt-small"
17
+
18
+
19
+ def scores(grid: str, model: str = MODEL, device: str = "cpu", batch: int = 1024) -> pd.DataFrame:
20
+ try:
21
+ import torch
22
+ from chronos import BaseChronosPipeline
23
+ except ImportError as e: # pragma: no cover
24
+ raise ImportError("the Chronos baseline needs: pip install gridshedwatch[chronos]") from e
25
+ pipe = BaseChronosPipeline.from_pretrained(model, device_map=device, torch_dtype=torch.float32)
26
+ X = data.signals(grid)
27
+ cal_all = data.calibration(grid)
28
+ out = {}
29
+ for f in SIGNALS:
30
+ cal = cal_all[f].dropna().to_numpy(float)
31
+ run = np.nan_to_num(X[f].to_numpy(float), nan=float(np.nanmedian(cal)))
32
+ full = np.concatenate([cal[-CTX:], run]).astype(np.float32)
33
+ W = np.lib.stride_tricks.sliding_window_view(full, CTX + 1)
34
+ raw = np.empty(len(W), dtype=np.float32)
35
+ for a in range(0, len(W), batch):
36
+ q, _ = pipe.predict_quantiles(torch.tensor(W[a:a + batch, :CTX]), prediction_length=1, quantile_levels=[0.5])
37
+ raw[a:a + batch] = np.abs(W[a:a + batch, CTX] - q[:, 0, 0].numpy())
38
+ out[f"p_{f}"] = np.round(to_p(raw), 6)
39
+ return pd.DataFrame(out)
@@ -0,0 +1,88 @@
1
+ """LSTM forecaster (LSTM-AD, Malhotra et al., ESANN 2015). Needs: pip install gridshedwatch[lstm].
2
+
3
+ Per grid and signal, a 2-layer, 64-unit LSTM reads the previous 24 hours and predicts the next;
4
+ the score is the absolute error. Signals are scaled by their 2019 0.1-99.9th percentile range.
5
+ Trained on 2019 only (Adam 1e-3, batch 64, up to 50 epochs, early stopping on the last 10% of
6
+ 2019), then frozen. Seed k uses torch seed 42 + 1000 k. Settings are common defaults, not tuned
7
+ on events. Expect about an hour per seed on a laptop CPU.
8
+ """
9
+ import numpy as np
10
+ import pandas as pd
11
+
12
+ from .. import data
13
+ from ..config import SIGNALS
14
+ from . import to_p
15
+
16
+ L = 24
17
+ SEED_BASE = 42
18
+
19
+
20
+ def _torch():
21
+ try:
22
+ import torch
23
+ from torch import nn
24
+ except ImportError as e: # pragma: no cover
25
+ raise ImportError("the LSTM baseline needs torch: pip install gridshedwatch[lstm]") from e
26
+ return torch, nn
27
+
28
+
29
+ def scores(grid: str, seed: int = 0, threads: int = 1) -> pd.DataFrame:
30
+ torch, nn = _torch()
31
+ torch.set_num_threads(threads)
32
+
33
+ class Net(nn.Module):
34
+ def __init__(self, hidden=64, layers=2):
35
+ super().__init__()
36
+ self.lstm = nn.LSTM(1, hidden, num_layers=layers, batch_first=True)
37
+ self.head = nn.Linear(hidden, 1)
38
+
39
+ def forward(self, x):
40
+ out, _ = self.lstm(x)
41
+ return self.head(out[:, -1]).squeeze(-1)
42
+
43
+ def windows(x):
44
+ W = np.lib.stride_tricks.sliding_window_view(x, L + 1)
45
+ return torch.tensor(W[:, :L, None], dtype=torch.float32), torch.tensor(W[:, L], dtype=torch.float32)
46
+
47
+ def fit(model, X, y, s, max_epochs=50, patience=5):
48
+ torch.manual_seed(s)
49
+ n_val = max(1, len(X) // 10)
50
+ Xtr, ytr, Xva, yva = X[:-n_val], y[:-n_val], X[-n_val:], y[-n_val:]
51
+ opt = torch.optim.Adam(model.parameters(), lr=1e-3)
52
+ best, state, bad = np.inf, None, 0
53
+ g = torch.Generator().manual_seed(s)
54
+ for _ in range(max_epochs):
55
+ model.train()
56
+ for idx in torch.randperm(len(Xtr), generator=g).split(64):
57
+ opt.zero_grad()
58
+ nn.functional.mse_loss(model(Xtr[idx]), ytr[idx]).backward()
59
+ opt.step()
60
+ model.eval()
61
+ with torch.no_grad():
62
+ v = nn.functional.mse_loss(model(Xva), yva).item()
63
+ if v < best - 1e-6:
64
+ best, state, bad = v, {k: t.clone() for k, t in model.state_dict().items()}, 0
65
+ else:
66
+ bad += 1
67
+ if bad >= patience:
68
+ break
69
+ model.load_state_dict(state)
70
+ return model
71
+
72
+ X = data.signals(grid)
73
+ cal_all = data.calibration(grid)
74
+ b = data.bounds_2019(grid)
75
+ out = {}
76
+ for f in SIGNALS:
77
+ lo, hi = b[f]
78
+ cal = (cal_all[f].dropna().to_numpy(float) - lo) / (hi - lo)
79
+ run = np.nan_to_num((X[f].to_numpy(float) - lo) / (hi - lo), nan=0.0)
80
+ net = fit(Net(), *windows(cal), SEED_BASE + 1000 * seed)
81
+ full = np.concatenate([cal, run])
82
+ Xw, yw = windows(full[len(cal) - L:])
83
+ net.eval()
84
+ with torch.no_grad():
85
+ pred = torch.cat([net(x) for x in Xw.split(4096)])
86
+ raw = (pred - yw).abs().numpy()
87
+ out[f"p_{f}"] = np.round(to_p(raw), 6)
88
+ return pd.DataFrame(out)
@@ -0,0 +1,32 @@
1
+ """Operator-style rule: no per-signal model and no groups. An hour is hot when demand runs above
2
+ the day-ahead forecast by more than the trailing year's q-th percentile of percent forecast error
3
+ (q = the benchmark threshold); an alert is on after `persistence` hot hours in a row."""
4
+ from functools import lru_cache
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+
9
+ from .. import data
10
+
11
+
12
+ @lru_cache(maxsize=None)
13
+ def hot(grid: str, q: float) -> np.ndarray:
14
+ w = data.clean_eia930(grid)
15
+ pct = ((w["D"] - w["DF"]) / w["DF"].where(w["DF"] > 0) * 100).asfreq("h")
16
+ thr = pct.shift(1).rolling(8760, min_periods=24 * 90).quantile(q)
17
+ ts = data.timebase(grid)["timestamp"]
18
+ p, th = pct.reindex(ts).to_numpy(), thr.reindex(ts).to_numpy()
19
+ return np.nan_to_num(p, nan=-np.inf) > np.nan_to_num(th, nan=np.inf)
20
+
21
+
22
+ def alerts(grid: str, level: str, threshold: float, persistence: int, seed: int = 0) -> np.ndarray:
23
+ h = hot(grid, threshold)
24
+ warm = data.timebase(grid)["in_warmup"].to_numpy()
25
+ a, run = np.zeros(len(h), dtype=int), 0
26
+ for i, x in enumerate(h):
27
+ if warm[i]:
28
+ run = 0
29
+ continue
30
+ run = run + 1 if x else 0
31
+ a[i] = int(run >= persistence)
32
+ return a
@@ -0,0 +1,24 @@
1
+ """Seasonal z-score: how far each signal sits from the median of its past 30 days, in robust
2
+ standard deviations (median absolute deviation x 1.4826). Deterministic."""
3
+ import numpy as np
4
+ import pandas as pd
5
+
6
+ from .. import data
7
+ from ..config import SIGNALS
8
+ from . import to_p
9
+
10
+ WINDOW_H = 720
11
+
12
+
13
+ def robust_z(x: np.ndarray, W: int = WINDOW_H) -> np.ndarray:
14
+ s = pd.Series(x)
15
+ past = s.shift(1)
16
+ med = past.rolling(W, min_periods=24).median()
17
+ mad = (past - med).abs().rolling(W, min_periods=24).median()
18
+ z = (s - med).abs() / (1.4826 * mad.replace(0, np.nan))
19
+ return z.fillna(0).to_numpy()
20
+
21
+
22
+ def scores(grid: str) -> pd.DataFrame:
23
+ X = data.signals(grid)
24
+ return pd.DataFrame({f"p_{f}": to_p(robust_z(X[f].to_numpy(float))) for f in SIGNALS})