pourmousavi Claude Opus 4.7 (1M context) commited on
Commit
0d542b0
Β·
1 Parent(s): 5a939d7

Fix HF deployment serving stale BESS list with fictional DUIDs

Browse files

The HuggingFace Space was returning a hand-written fallback bess_list.json
on every /api/bess call because AEMO's WAF on www.aemo.com.au returns 403
to requests from the HF egress IP range (verified in the HF container
logs β€” UA-spoofing alone doesn't help, the block is IP-based). The
fallback had stale fictional DUIDs (NESBESS1, HORNSDALE_PWR1, etc.) and no
capacity_mwh field, which caused every BESS to show "? MWh" in the
dropdown and every SCADA/dispatch lookup to 404.

Five fixes shipped together:

1. Send proper browser headers on the AEMO XLSX request (low-confidence
shot β€” the block is IP-based, but no reason not to try).

2. Bake a fresh bess_list.json snapshot into the repo daily via a new
GitHub Action (.github/workflows/refresh_bess_list.yml) running
scripts/refresh_bess_list.py from GH runners (egress NOT on AEMO's
blocklist). The bundled snapshot now has 38 real BESS units with
real DUIDs and capacity_mwh values.

3. Mirror the raw XLSX through raw.githubusercontent.com. gen_info_fetcher
now tries live AEMO -> mirror -> bundled snapshot in order, so a
request only hits the snapshot when both network sources fail.

4. Surface provenance via /api/bess. New response shape:
{states, source, fetched_at, warnings}. Frontend displays an amber
banner when 'source' is not 'live'. BREAKING: callers reading
/api/bess as {state: [...]} directly must read .states.

5. Update DATA_START_DATE 2025-02-28 -> 2025-04-29 and
DISPATCH_START_DATE 2025-02-11 -> 2025-04-01 to reflect the earliest
bundles actually still hosted on NEMWEB. Earlier Era 1 archive
bundles have been rolled off. Error messages and About/README text
updated to match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

.github/workflows/refresh_bess_list.yml ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Refresh BESS list snapshot
2
+
3
+ on:
4
+ schedule:
5
+ # Daily at 02:00 UTC (~12:00 AEST). AEMO publishes Gen Info updates monthly,
6
+ # so most days produce no diff.
7
+ - cron: "0 2 * * *"
8
+ workflow_dispatch: {}
9
+
10
+ permissions:
11
+ contents: write
12
+
13
+ jobs:
14
+ refresh:
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ with:
19
+ ref: master
20
+
21
+ - uses: actions/setup-python@v5
22
+ with:
23
+ python-version: "3.11"
24
+
25
+ - name: Install minimal deps
26
+ run: pip install --no-cache-dir httpx==0.27.* openpyxl==3.1.*
27
+
28
+ - name: Refresh snapshot
29
+ run: python scripts/refresh_bess_list.py
30
+
31
+ - name: Commit if changed
32
+ run: |
33
+ set -e
34
+ # Only commit if the parsed JSON or raw XLSX changed.
35
+ # The meta file changes every run (timestamp); ignore it on its own.
36
+ if git diff --quiet -- app/data/bess_list.json app/data/aemo_gen_info.xlsx; then
37
+ echo "No change in bess_list.json or aemo_gen_info.xlsx; skipping commit."
38
+ exit 0
39
+ fi
40
+ git config user.name "github-actions[bot]"
41
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
42
+ git add app/data/bess_list.json app/data/aemo_gen_info.xlsx app/data/bess_list_meta.json
43
+ git commit -m "Refresh BESS list snapshot from AEMO XLSX"
44
+ git push origin master
README.md CHANGED
@@ -20,7 +20,7 @@ An interactive web app for exploring high-frequency power and energy storage dat
20
  ### 4-second SCADA β€” `PUBLIC_NEXT_DAY_FPPMW`
21
 
22
  - **Source:** [AEMO NEMWEB FPPDAILY](https://www.nemweb.com.au/REPORTS/Current/FPPDAILY/)
23
- - **Available from:** 28 February 2025 (FPP scheme commencement)
24
  - **Granularity:** one row per 4-second grid slot
25
 
26
  | Column | Description |
@@ -33,7 +33,7 @@ An interactive web app for exploring high-frequency power and energy storage dat
33
  ### 5-minute Dispatch β€” `DISPATCH_UNIT_SOLUTION`
34
 
35
  - **Source:** [AEMO NEMWEB Next_Day_Dispatch](https://nemweb.com.au/Reports/Current/Next_Day_Dispatch/)
36
- - **Available from:** 11 February 2025
37
  - **Granularity:** one row per 5-minute dispatch interval
38
 
39
  | Column | Description |
 
20
  ### 4-second SCADA β€” `PUBLIC_NEXT_DAY_FPPMW`
21
 
22
  - **Source:** [AEMO NEMWEB FPPDAILY](https://www.nemweb.com.au/REPORTS/Current/FPPDAILY/)
23
+ - **Available from:** 29 April 2025 (the FPP scheme itself commenced 28 February 2025, but earlier daily bundles have been rolled off the AEMO NEMWEB archive)
24
  - **Granularity:** one row per 4-second grid slot
25
 
26
  | Column | Description |
 
33
  ### 5-minute Dispatch β€” `DISPATCH_UNIT_SOLUTION`
34
 
35
  - **Source:** [AEMO NEMWEB Next_Day_Dispatch](https://nemweb.com.au/Reports/Current/Next_Day_Dispatch/)
36
+ - **Available from:** 1 April 2025 (the `INITIAL_ENERGY_STORAGE` column was added on 11 February 2025, but earlier monthly bundles have been rolled off the AEMO NEMWEB archive)
37
  - **Granularity:** one row per 5-minute dispatch interval
38
 
39
  | Column | Description |
app/config.py CHANGED
@@ -5,8 +5,16 @@ from datetime import date
5
  AEMO_CURRENT_URL = "https://www.nemweb.com.au/REPORTS/Current/FPPDAILY/"
6
  AEMO_ARCHIVE_URL = "https://nemweb.com.au/Reports/Archive/FPPDAILY/"
7
 
8
- # Data availability: first FPPMW SCADA bundle is PUBLIC_NEXT_DAY_FPPMW_20250228.zip
9
- DATA_START_DATE = date(2025, 2, 28)
 
 
 
 
 
 
 
 
10
 
11
  # From this date onward, data is served as individual daily ZIPs from the
12
  # Current directory (faster). Before this date, large archive bundles are
@@ -18,8 +26,11 @@ FPPMW_CUTOVER_DATE = date(2026, 1, 11)
18
  DISPATCH_CURRENT_URL = "https://nemweb.com.au/Reports/Current/Next_Day_Dispatch/"
19
  DISPATCH_ARCHIVE_URL = "https://www.nemweb.com.au/REPORTS/ARCHIVE/Next_Day_Dispatch/"
20
 
21
- # Dispatch data available from 11 Feb 2025 (INITIAL_ENERGY_STORAGE column).
22
- DISPATCH_START_DATE = date(2025, 2, 11)
 
 
 
23
 
24
  # ── Request limits ────────────────────────────────────────────────────────────
25
  MAX_DAYS_PER_REQUEST = 1
 
5
  AEMO_CURRENT_URL = "https://www.nemweb.com.au/REPORTS/Current/FPPDAILY/"
6
  AEMO_ARCHIVE_URL = "https://nemweb.com.au/Reports/Archive/FPPDAILY/"
7
 
8
+ # Data availability: AEMO has rolled off the early Era 1 archive bundles.
9
+ # The oldest archive bundle currently on NEMWEB is PUBLIC_NEXT_DAY_FPP_20250430.zip
10
+ # whose first inner file is publication 30 Apr 2025 = settlement 29 Apr 2025
11
+ # (the first day of Era 2 in our era model). FPPMW Format-1 bundles start the
12
+ # same day. So 29 Apr 2025 is the earliest settlement date for which any
13
+ # FPPDAILY data can still be fetched.
14
+ #
15
+ # The FPP scheme itself commenced on 28 Feb 2025, but the Era 1 monthly
16
+ # bundles (Dec 2024 – 28 Apr 2025) have all been rolled off the archive.
17
+ DATA_START_DATE = date(2025, 4, 29)
18
 
19
  # From this date onward, data is served as individual daily ZIPs from the
20
  # Current directory (faster). Before this date, large archive bundles are
 
26
  DISPATCH_CURRENT_URL = "https://nemweb.com.au/Reports/Current/Next_Day_Dispatch/"
27
  DISPATCH_ARCHIVE_URL = "https://www.nemweb.com.au/REPORTS/ARCHIVE/Next_Day_Dispatch/"
28
 
29
+ # Dispatch data: the oldest archive bundle on NEMWEB is
30
+ # PUBLIC_NEXT_DAY_DISPATCH_20250401.zip (covering settlement dates
31
+ # 1 Apr 2025 – 30 Apr 2025). The INITIAL_ENERGY_STORAGE column has existed
32
+ # since 11 Feb 2025, but earlier monthly bundles have been rolled off.
33
+ DISPATCH_START_DATE = date(2025, 4, 1)
34
 
35
  # ── Request limits ────────────────────────────────────────────────────────────
36
  MAX_DAYS_PER_REQUEST = 1
app/data/bess_list.json CHANGED
@@ -1,32 +1,277 @@
1
  {
2
  "NSW": [
3
- {"duid": "NESBESS1", "name": "New England BESS 1", "capacity_mw": 126, "region": "NSW1"},
4
- {"duid": "NESBESS2", "name": "New England BESS 2", "capacity_mw": 126, "region": "NSW1"},
5
- {"duid": "WARATAHSF1", "name": "Waratah Super Battery 1", "capacity_mw": 850, "region": "NSW1"},
6
- {"duid": "ERARINGSF1", "name": "Eraring Battery 1", "capacity_mw": 460, "region": "NSW1"},
7
- {"duid": "LMOBESS1", "name": "Limondale BESS", "capacity_mw": 50, "region": "NSW1"}
8
- ],
9
- "VIC": [
10
- {"duid": "BNGSF2", "name": "Big Battery (Victorian Big Battery)", "capacity_mw": 300, "region": "VIC1"},
11
- {"duid": "RANGEBANKBESS1", "name": "Rangebank BESS", "capacity_mw": 200, "region": "VIC1"},
12
- {"duid": "MREHABESS1", "name": "Melbourne Renewable Energy Hub BESS", "capacity_mw": 600, "region": "VIC1"},
13
- {"duid": "GANNBESS1", "name": "Gannawarra BESS", "capacity_mw": 25, "region": "VIC1"},
14
- {"duid": "BALLBESS1", "name": "Ballarat BESS", "capacity_mw": 30, "region": "VIC1"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  ],
16
  "QLD": [
17
- {"duid": "WDWBESS1", "name": "Western Downs BESS", "capacity_mw": 255, "region": "QLD1"},
18
- {"duid": "GRNBKBESS1", "name": "Greenbank BESS", "capacity_mw": 200, "region": "QLD1"},
19
- {"duid": "TRNBESS1", "name": "Tarong BESS", "capacity_mw": 300, "region": "QLD1"},
20
- {"duid": "SWANBESS1", "name": "Swanbank BESS", "capacity_mw": 250, "region": "QLD1"},
21
- {"duid": "WANDBESS1", "name": "Wandoan South BESS", "capacity_mw": 100, "region": "QLD1"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  ],
23
  "SA": [
24
- {"duid": "HORNSDALE_PWR1", "name": "Hornsdale Power Reserve 1", "capacity_mw": 100, "region": "SA1"},
25
- {"duid": "HORNSDALE_PWR2", "name": "Hornsdale Power Reserve 2", "capacity_mw": 100, "region": "SA1"},
26
- {"duid": "HORNSDALE_PWR3", "name": "Hornsdale Power Reserve 3", "capacity_mw": 50, "region": "SA1"},
27
- {"duid": "BLYTHBESS1", "name": "Blyth BESS", "capacity_mw": 200, "region": "SA1"},
28
- {"duid": "DALRYMPLE1", "name": "Dalrymple BESS", "capacity_mw": 30, "region": "SA1"},
29
- {"duid": "OSBORNE1", "name": "Osborne BESS", "capacity_mw": 50, "region": "SA1"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  ],
31
- "TAS": []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  }
 
1
  {
2
  "NSW": [
3
+ {
4
+ "capacity_mw": 10.0,
5
+ "capacity_mwh": 20.0,
6
+ "duid": "QBYNB1",
7
+ "name": "QBESS",
8
+ "region": "NSW1"
9
+ },
10
+ {
11
+ "capacity_mw": 60.28,
12
+ "capacity_mwh": 60.28,
13
+ "duid": "BHB1",
14
+ "name": "BKBESS1",
15
+ "region": "NSW1"
16
+ },
17
+ {
18
+ "capacity_mw": 99.99,
19
+ "capacity_mwh": 199.98,
20
+ "duid": "CAPBES1",
21
+ "name": "CBESS",
22
+ "region": "NSW1"
23
+ },
24
+ {
25
+ "capacity_mw": 24.96,
26
+ "capacity_mwh": 50.08,
27
+ "duid": "DPNTB1",
28
+ "name": "Darlington Pnt ESS1",
29
+ "region": "NSW1"
30
+ },
31
+ {
32
+ "capacity_mw": 460.0,
33
+ "capacity_mwh": 1073.0,
34
+ "duid": "ERB01",
35
+ "name": "ERARNGB1",
36
+ "region": "NSW1"
37
+ },
38
+ {
39
+ "capacity_mw": 60.0,
40
+ "capacity_mwh": 120.0,
41
+ "duid": "RESS1",
42
+ "name": "RiverESS",
43
+ "region": "NSW1"
44
+ },
45
+ {
46
+ "capacity_mw": 65.0,
47
+ "capacity_mwh": 130.0,
48
+ "duid": "RIVNB2",
49
+ "name": "Riverina ESS 2",
50
+ "region": "NSW1"
51
+ },
52
+ {
53
+ "capacity_mw": 86.4,
54
+ "capacity_mwh": 129.96,
55
+ "duid": "SMTHBES1",
56
+ "name": "SmithBes",
57
+ "region": "NSW1"
58
+ },
59
+ {
60
+ "capacity_mw": 61.2,
61
+ "capacity_mwh": 2700.0,
62
+ "duid": "WALGRV1",
63
+ "name": "WLGBESS1",
64
+ "region": "NSW1"
65
+ }
66
  ],
67
  "QLD": [
68
+ {
69
+ "capacity_mw": 50.0,
70
+ "capacity_mwh": 100.0,
71
+ "duid": "BBATTERY1",
72
+ "name": "BLDRCMB1",
73
+ "region": "QLD1"
74
+ },
75
+ {
76
+ "capacity_mw": 100.0,
77
+ "capacity_mwh": 200.0,
78
+ "duid": "CHBESS1",
79
+ "name": "Chinch1",
80
+ "region": "QLD1"
81
+ },
82
+ {
83
+ "capacity_mw": 200.0,
84
+ "capacity_mwh": 400.0,
85
+ "duid": "GREENB1",
86
+ "name": "GREENB1",
87
+ "region": "QLD1"
88
+ },
89
+ {
90
+ "capacity_mw": 2.0,
91
+ "capacity_mwh": 4.0,
92
+ "duid": "KEPBG1",
93
+ "name": "KEPBG1",
94
+ "region": "QLD1"
95
+ },
96
+ {
97
+ "capacity_mw": 154.96,
98
+ "capacity_mwh": 297.96,
99
+ "duid": "ULPBESS1",
100
+ "name": "ULINDAB1",
101
+ "region": "QLD1"
102
+ },
103
+ {
104
+ "capacity_mw": 100.0,
105
+ "capacity_mwh": 150.0,
106
+ "duid": "WANDB1",
107
+ "name": "WSBESS1",
108
+ "region": "QLD1"
109
+ }
110
  ],
111
  "SA": [
112
+ {
113
+ "capacity_mw": 7.76,
114
+ "capacity_mwh": 12.6,
115
+ "duid": "ADPBA1",
116
+ "name": "ADPBA1",
117
+ "region": "SA1"
118
+ },
119
+ {
120
+ "capacity_mw": 200.32,
121
+ "capacity_mwh": 400.0,
122
+ "duid": "BLYTHB1",
123
+ "name": "BLYTHWBT",
124
+ "region": "SA1"
125
+ },
126
+ {
127
+ "capacity_mw": 3.08,
128
+ "capacity_mwh": 5.04,
129
+ "duid": "BOWWBA1",
130
+ "name": "BOWWBA1",
131
+ "region": "SA1"
132
+ },
133
+ {
134
+ "capacity_mw": 2.16,
135
+ "capacity_mwh": null,
136
+ "duid": "CBWWBA1",
137
+ "name": "CBWWBA1",
138
+ "region": "SA1"
139
+ },
140
+ {
141
+ "capacity_mw": 30.0,
142
+ "capacity_mwh": 9.0,
143
+ "duid": "DALNTH1",
144
+ "name": "BESS1-12",
145
+ "region": "SA1"
146
+ },
147
+ {
148
+ "capacity_mw": 5.52,
149
+ "capacity_mwh": 8.82,
150
+ "duid": "HVWWBA1",
151
+ "name": "HVWWBA1",
152
+ "region": "SA1"
153
+ },
154
+ {
155
+ "capacity_mw": 50.0,
156
+ "capacity_mwh": 61.3,
157
+ "duid": "HPR1",
158
+ "name": "HPRG EXP",
159
+ "region": "SA1"
160
+ },
161
+ {
162
+ "capacity_mw": 100.0,
163
+ "capacity_mwh": 117.0,
164
+ "duid": "HPR1",
165
+ "name": "HPRG1",
166
+ "region": "SA1"
167
+ },
168
+ {
169
+ "capacity_mw": 28.8,
170
+ "capacity_mwh": 51.84,
171
+ "duid": "LBB1",
172
+ "name": "LBB1",
173
+ "region": "SA1"
174
+ },
175
+ {
176
+ "capacity_mw": 100.0,
177
+ "capacity_mwh": 200.0,
178
+ "duid": "MANNUMB1",
179
+ "name": "MANNUMBT",
180
+ "region": "SA1"
181
+ },
182
+ {
183
+ "capacity_mw": 250.7,
184
+ "capacity_mwh": 249.61,
185
+ "duid": "TIB1",
186
+ "name": "TIPSBESS",
187
+ "region": "SA1"
188
+ }
189
  ],
190
+ "TAS": [],
191
+ "VIC": [
192
+ {
193
+ "capacity_mw": 30.09,
194
+ "capacity_mwh": 30.09,
195
+ "duid": "BALB1",
196
+ "name": "Ballarat Energy Storage System:1",
197
+ "region": "VIC1"
198
+ },
199
+ {
200
+ "capacity_mw": 20.0,
201
+ "capacity_mwh": 34.0,
202
+ "duid": "BULBES1",
203
+ "name": "Bulgana Green Power Hub - BESS:1",
204
+ "region": "VIC1"
205
+ },
206
+ {
207
+ "capacity_mw": 25.5,
208
+ "capacity_mwh": 50.0,
209
+ "duid": "GANNB1",
210
+ "name": "Gannawarra Energy Storage System:1",
211
+ "region": "VIC1"
212
+ },
213
+ {
214
+ "capacity_mw": 200.07,
215
+ "capacity_mwh": 162.0,
216
+ "duid": "HBESS1",
217
+ "name": "HBESS1",
218
+ "region": "VIC1"
219
+ },
220
+ {
221
+ "capacity_mw": 185.0,
222
+ "capacity_mwh": 370.0,
223
+ "duid": "KESSB1",
224
+ "name": "Koorangi",
225
+ "region": "VIC1"
226
+ },
227
+ {
228
+ "capacity_mw": 97.9,
229
+ "capacity_mwh": 195.8,
230
+ "duid": "LVES1",
231
+ "name": "LaTroBES",
232
+ "region": "VIC1"
233
+ },
234
+ {
235
+ "capacity_mw": 216.16,
236
+ "capacity_mwh": 399.84,
237
+ "duid": "MREHA1",
238
+ "name": "MREHA1",
239
+ "region": "VIC1"
240
+ },
241
+ {
242
+ "capacity_mw": 216.16,
243
+ "capacity_mwh": 399.84,
244
+ "duid": "MREHA2",
245
+ "name": "MREHA2",
246
+ "region": "VIC1"
247
+ },
248
+ {
249
+ "capacity_mw": 215.6,
250
+ "capacity_mwh": 800.8,
251
+ "duid": "MREHA3",
252
+ "name": "MREHA3",
253
+ "region": "VIC1"
254
+ },
255
+ {
256
+ "capacity_mw": 5.0,
257
+ "capacity_mwh": 10.0,
258
+ "duid": "PIBESS1",
259
+ "name": "PIBS_GU1",
260
+ "region": "VIC1"
261
+ },
262
+ {
263
+ "capacity_mw": 200.0,
264
+ "capacity_mwh": 400.0,
265
+ "duid": "RANGEB1",
266
+ "name": "Rangebnk",
267
+ "region": "VIC1"
268
+ },
269
+ {
270
+ "capacity_mw": 300.0,
271
+ "capacity_mwh": 470.0,
272
+ "duid": "VBB1",
273
+ "name": "BESS",
274
+ "region": "VIC1"
275
+ }
276
+ ]
277
  }
app/data/bess_list_meta.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "fetched_at": "2026-05-19T02:22:04+00:00",
3
+ "source_url": "https://www.aemo.com.au/-/media/files/electricity/nem/planning_and_forecasting/generation_information/2026/nem-generation-information-jan-2026.xlsx?rev=1f6bccf827284f9fb6d6f3ae56ed3fe9&sc_lang=en",
4
+ "xlsx_bytes": 871234,
5
+ "unit_count": 38
6
+ }
app/routers/api.py CHANGED
@@ -70,8 +70,24 @@ def _source_type(target_date: date) -> str:
70
 
71
  @router.get("/bess")
72
  async def get_bess_list():
73
- """Return in-service battery storage units grouped by state."""
74
- return await fetch_bess_list()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
 
76
 
77
  # ── Quality flags ─────────────────────────────────────────────────────────────
 
70
 
71
  @router.get("/bess")
72
  async def get_bess_list():
73
+ """
74
+ Return in-service battery storage units grouped by state.
75
+
76
+ Response shape:
77
+ {
78
+ "states": { "NSW": [...], "VIC": [...], ... },
79
+ "source": "live" | "mirror" | "snapshot",
80
+ "fetched_at": ISO-8601 timestamp,
81
+ "warnings": list of strings (may be empty)
82
+ }
83
+ """
84
+ result = await fetch_bess_list()
85
+ return {
86
+ "states": result.states,
87
+ "source": result.source,
88
+ "fetched_at": result.fetched_at.isoformat(timespec="seconds") + "Z",
89
+ "warnings": result.warnings,
90
+ }
91
 
92
 
93
  # ── Quality flags ─────────────────────────────────────────────────────────────
app/services/aemo_fetcher.py CHANGED
@@ -492,8 +492,8 @@ async def fetch_csv_for_date(
492
  """
493
  if target_date < DATA_START_DATE:
494
  raise AEMOFetchError(
495
- f"Data is only available from {DATA_START_DATE.strftime('%d %B %Y')} "
496
- "when the FPP scheme commenced."
497
  )
498
  if not skip_future_check and target_date >= date.today():
499
  raise AEMOFetchError("Cannot request data for today or future dates.")
 
492
  """
493
  if target_date < DATA_START_DATE:
494
  raise AEMOFetchError(
495
+ f"Data is only available from {DATA_START_DATE.strftime('%d %B %Y')}. "
496
+ "Earlier daily files have been rolled off the AEMO NEMWEB archive."
497
  )
498
  if not skip_future_check and target_date >= date.today():
499
  raise AEMOFetchError("Cannot request data for today or future dates.")
app/services/dispatch_fetcher.py CHANGED
@@ -273,7 +273,8 @@ async def fetch_dispatch_csv_for_date(
273
  if target_date < DISPATCH_START_DATE:
274
  raise DispatchFetchError(
275
  f"Dispatch energy data is only available from "
276
- f"{DISPATCH_START_DATE.strftime('%d %B %Y')}."
 
277
  )
278
  if target_date >= date.today():
279
  raise DispatchFetchError(
 
273
  if target_date < DISPATCH_START_DATE:
274
  raise DispatchFetchError(
275
  f"Dispatch energy data is only available from "
276
+ f"{DISPATCH_START_DATE.strftime('%d %B %Y')}. "
277
+ "Earlier monthly bundles have been rolled off the AEMO NEMWEB archive."
278
  )
279
  if target_date >= date.today():
280
  raise DispatchFetchError(
app/services/gen_info_fetcher.py CHANGED
@@ -1,15 +1,23 @@
1
  """
2
  Fetches and parses the AEMO NEM Generation Information XLSX.
3
 
4
- Downloads the spreadsheet from AEMO, reads the "Generator Information" sheet,
5
- and returns all in-service battery storage units grouped by NEM region/state.
6
-
7
- The spreadsheet is cached in memory for 24 hours to avoid re-downloading on
8
- every request. On any failure the caller receives the cached data (if any) or
9
- the static fallback bess_list.json.
 
 
 
 
 
 
10
  """
11
  import io
 
12
  import logging
 
13
  from datetime import datetime, timedelta
14
  from pathlib import Path
15
 
@@ -24,6 +32,38 @@ XLSX_URL = (
24
  "?rev=1f6bccf827284f9fb6d6f3ae56ed3fe9&sc_lang=en"
25
  )
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  SHEET_NAME = "Generator Information"
28
 
29
  # Map NEM region codes to the state keys used in the UI
@@ -49,8 +89,24 @@ _COL_CAPACITY_MWH = "Agg Nameplate Storage Capacity (MWh)"
49
 
50
  CACHE_TTL = timedelta(hours=24)
51
 
52
- # In-memory cache: (parsed_result, fetched_at)
53
- _cache: tuple[dict, datetime] | None = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
 
56
  def _find_col(headers: list[str], target: str) -> int | None:
@@ -166,12 +222,39 @@ def _parse_xlsx(xlsx_bytes: bytes) -> dict[str, list[dict]]:
166
  return result
167
 
168
 
169
- async def fetch_bess_list() -> dict[str, list[dict]]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  """
171
- Return the BESS list from the AEMO generation information XLSX.
172
 
173
- Uses a 24-hour in-memory cache. Falls back to the static bess_list.json
174
- if the download or parse fails and no cached data is available.
175
  """
176
  global _cache
177
 
@@ -179,32 +262,64 @@ async def fetch_bess_list() -> dict[str, list[dict]]:
179
 
180
  # Return cached data if still fresh
181
  if _cache is not None:
182
- parsed, fetched_at = _cache
183
- if now - fetched_at < CACHE_TTL:
184
- logger.debug("Returning cached BESS list (age %s)", now - fetched_at)
185
- return parsed
186
-
187
- # Attempt download
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  try:
189
- logger.info("Downloading AEMO generation information XLSX from %s", XLSX_URL)
190
- async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
191
- resp = await client.get(XLSX_URL)
192
- resp.raise_for_status()
193
-
194
- parsed = _parse_xlsx(resp.content)
195
- _cache = (parsed, now)
196
- return parsed
197
-
198
- except Exception as exc:
199
- logger.warning("Failed to fetch/parse AEMO gen info XLSX: %s", exc)
200
-
201
- # Return stale cache rather than nothing
202
- if _cache is not None:
203
- logger.warning("Returning stale BESS list cache (age %s)", now - _cache[1])
204
- return _cache[0]
205
-
206
- # Last resort: return static fallback file
207
- logger.warning("Falling back to static bess_list.json")
208
- import json
209
- fallback = Path(__file__).parent.parent / "data" / "bess_list.json"
210
- return json.loads(fallback.read_text())
 
 
 
1
  """
2
  Fetches and parses the AEMO NEM Generation Information XLSX.
3
 
4
+ Sources tried in order on every cache miss:
5
+
6
+ 1. live β€” direct download from www.aemo.com.au (subject to WAF blocks
7
+ from datacentre IPs; see _BROWSER_HEADERS).
8
+ 2. mirror β€” raw.githubusercontent.com copy committed daily by the
9
+ refresh_bess_list workflow.
10
+ 3. snapshot β€” the parsed bess_list.json bundled in the repo (also
11
+ written by refresh_bess_list).
12
+
13
+ The first successful step is cached in memory for 24 hours. The result
14
+ carries a `source` field and any user-facing `warnings` so the API can
15
+ expose data freshness in the response.
16
  """
17
  import io
18
+ import json
19
  import logging
20
+ from dataclasses import dataclass, field
21
  from datetime import datetime, timedelta
22
  from pathlib import Path
23
 
 
32
  "?rev=1f6bccf827284f9fb6d6f3ae56ed3fe9&sc_lang=en"
33
  )
34
 
35
+ # raw.githubusercontent.com copy of the XLSX, refreshed daily by
36
+ # .github/workflows/refresh_bess_list.yml. Used as the second-choice source
37
+ # when the live AEMO fetch fails (e.g. WAF 403 from the HuggingFace egress IP).
38
+ XLSX_MIRROR_URL = (
39
+ "https://raw.githubusercontent.com/pourmousavi/BESS-SCADA-Data"
40
+ "/master/app/data/aemo_gen_info.xlsx"
41
+ )
42
+
43
+ # AEMO's Azure Front Door WAF on www.aemo.com.au returns 403 to requests that
44
+ # look like default Python clients. Sending a full browser-style header set
45
+ # bypasses the simpler bot-detection rules. (Doesn't help against pure
46
+ # IP-range blocks β€” see gen_info_mirror.py for the raw.githubusercontent.com
47
+ # fallback.)
48
+ _BROWSER_HEADERS = {
49
+ "User-Agent": (
50
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
51
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
52
+ "Chrome/120.0.0.0 Safari/537.36"
53
+ ),
54
+ "Accept": (
55
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,"
56
+ "application/octet-stream;q=0.9,*/*;q=0.8"
57
+ ),
58
+ "Accept-Language": "en-AU,en;q=0.9",
59
+ "Accept-Encoding": "gzip, deflate, br",
60
+ "Referer": "https://www.aemo.com.au/energy-systems/electricity/national-electricity-market-nem/nem-forecasting-and-planning/forecasting-and-planning-data/generation-information",
61
+ "Sec-Fetch-Dest": "document",
62
+ "Sec-Fetch-Mode": "navigate",
63
+ "Sec-Fetch-Site": "same-origin",
64
+ "Upgrade-Insecure-Requests": "1",
65
+ }
66
+
67
  SHEET_NAME = "Generator Information"
68
 
69
  # Map NEM region codes to the state keys used in the UI
 
89
 
90
  CACHE_TTL = timedelta(hours=24)
91
 
92
+
93
+ @dataclass
94
+ class BessListResult:
95
+ """Parsed BESS list plus provenance for the API response."""
96
+ states: dict[str, list[dict]]
97
+ source: str # "live" | "mirror" | "snapshot"
98
+ fetched_at: datetime # for the live/mirror path: now; for snapshot: snapshot timestamp
99
+ warnings: list[str] = field(default_factory=list)
100
+
101
+
102
+ # In-memory cache: (result, cached_at)
103
+ _cache: tuple[BessListResult, datetime] | None = None
104
+
105
+
106
+ # Snapshot files written by scripts/refresh_bess_list.py
107
+ _DATA_DIR = Path(__file__).parent.parent / "data"
108
+ _SNAPSHOT_JSON = _DATA_DIR / "bess_list.json"
109
+ _SNAPSHOT_META = _DATA_DIR / "bess_list_meta.json"
110
 
111
 
112
  def _find_col(headers: list[str], target: str) -> int | None:
 
222
  return result
223
 
224
 
225
+ async def _download_and_parse(url: str, client: httpx.AsyncClient) -> dict[str, list[dict]]:
226
+ resp = await client.get(url, headers=_BROWSER_HEADERS)
227
+ resp.raise_for_status()
228
+ return _parse_xlsx(resp.content)
229
+
230
+
231
+ def _load_snapshot() -> BessListResult:
232
+ """Load the parsed JSON snapshot bundled in the repo. Raises if missing."""
233
+ states = json.loads(_SNAPSHOT_JSON.read_text())
234
+ fetched_at = datetime.utcnow()
235
+ if _SNAPSHOT_META.exists():
236
+ try:
237
+ meta = json.loads(_SNAPSHOT_META.read_text())
238
+ ts = meta.get("fetched_at")
239
+ if ts:
240
+ # strip any trailing 'Z' / timezone offset to a naive UTC datetime
241
+ ts_clean = ts.replace("Z", "+00:00")
242
+ fetched_at = datetime.fromisoformat(ts_clean).replace(tzinfo=None)
243
+ except (ValueError, KeyError) as exc:
244
+ logger.warning("Could not parse snapshot meta %s: %s", _SNAPSHOT_META, exc)
245
+ return BessListResult(
246
+ states=states,
247
+ source="snapshot",
248
+ fetched_at=fetched_at,
249
+ )
250
+
251
+
252
+ async def fetch_bess_list() -> BessListResult:
253
  """
254
+ Return the BESS list, trying live AEMO β†’ GitHub mirror β†’ bundled snapshot.
255
 
256
+ Uses a 24-hour in-memory cache (per source). The result carries provenance
257
+ so the API can surface a warning when serving non-live data.
258
  """
259
  global _cache
260
 
 
262
 
263
  # Return cached data if still fresh
264
  if _cache is not None:
265
+ cached, cached_at = _cache
266
+ if now - cached_at < CACHE_TTL:
267
+ logger.debug("Returning cached BESS list (age %s, source=%s)",
268
+ now - cached_at, cached.source)
269
+ return cached
270
+
271
+ async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
272
+ # 1. Live AEMO
273
+ try:
274
+ logger.info("Downloading AEMO XLSX (live): %s", XLSX_URL)
275
+ states = await _download_and_parse(XLSX_URL, client)
276
+ result = BessListResult(states=states, source="live", fetched_at=now)
277
+ _cache = (result, now)
278
+ return result
279
+ except Exception as exc:
280
+ logger.warning("Live AEMO XLSX fetch failed: %s", exc)
281
+
282
+ # 2. GitHub raw mirror (refreshed daily by CI)
283
+ try:
284
+ logger.info("Downloading AEMO XLSX (mirror): %s", XLSX_MIRROR_URL)
285
+ states = await _download_and_parse(XLSX_MIRROR_URL, client)
286
+ result = BessListResult(
287
+ states=states,
288
+ source="mirror",
289
+ fetched_at=now,
290
+ warnings=[
291
+ "Live AEMO BESS list is unavailable; showing a copy mirrored "
292
+ "from GitHub (updated daily)."
293
+ ],
294
+ )
295
+ _cache = (result, now)
296
+ return result
297
+ except Exception as exc:
298
+ logger.warning("Mirror XLSX fetch failed: %s", exc)
299
+
300
+ # 3. Bundled snapshot β€” last resort. Always serve something rather than 5xx.
301
  try:
302
+ snapshot = _load_snapshot()
303
+ age = now - snapshot.fetched_at
304
+ days = max(age.days, 0)
305
+ snapshot.warnings = [
306
+ f"Live AEMO BESS list and mirror are both unavailable; "
307
+ f"showing bundled snapshot from {snapshot.fetched_at:%Y-%m-%d} "
308
+ f"({days} day{'s' if days != 1 else ''} old)."
309
+ ]
310
+ _cache = (snapshot, now)
311
+ logger.warning("Falling back to bundled snapshot (age %s)", age)
312
+ return snapshot
313
+ except FileNotFoundError:
314
+ logger.error("No bundled snapshot at %s", _SNAPSHOT_JSON)
315
+ # Return an empty result rather than raising β€” frontend handles
316
+ # empty states gracefully and the warning explains why.
317
+ return BessListResult(
318
+ states={s: [] for s in REGION_TO_STATE.values()},
319
+ source="snapshot",
320
+ fetched_at=now,
321
+ warnings=[
322
+ "BESS list is currently unavailable from AEMO and no local "
323
+ "snapshot is bundled. Try again later."
324
+ ],
325
+ )
app/static/css/style.css CHANGED
@@ -303,8 +303,9 @@ select:disabled, input:disabled {
303
  gap: 0.6rem;
304
  align-items: flex-start;
305
  }
306
- .alert-error { background: rgba(239,68,68,0.15); border: 1px solid rgba(239,68,68,0.4); color: #fca5a5; }
307
- .alert-info { background: rgba(56,189,248,0.1); border: 1px solid rgba(56,189,248,0.3); color: var(--accent); }
 
308
 
309
  .hidden { display: none !important; }
310
 
 
303
  gap: 0.6rem;
304
  align-items: flex-start;
305
  }
306
+ .alert-error { background: rgba(239,68,68,0.15); border: 1px solid rgba(239,68,68,0.4); color: #fca5a5; }
307
+ .alert-info { background: rgba(56,189,248,0.1); border: 1px solid rgba(56,189,248,0.3); color: var(--accent); }
308
+ .alert-warning { background: rgba(217,119,6,0.15); border: 1px solid rgba(217,119,6,0.45); color: #fcd34d; }
309
 
310
  .hidden { display: none !important; }
311
 
app/static/index.html CHANGED
@@ -21,6 +21,9 @@
21
 
22
  <main>
23
 
 
 
 
24
  <!-- ── Control panel ── -->
25
  <section class="panel">
26
  <h2>Select Data</h2>
@@ -54,7 +57,7 @@
54
  <input type="checkbox" id="chk-scada" checked />
55
  4-second SCADA
56
  </label>
57
- <button class="about-toggle" data-tip="4-sec power output &amp; quality flags Β· PUBLIC_NEXT_DAY_FPPMW Β· from 28 Feb 2025" data-about="scada">β–Ά About</button>
58
  </div>
59
 
60
  <div class="check-row-header">
@@ -62,7 +65,7 @@
62
  <input type="checkbox" id="chk-energy" checked />
63
  5-minute Dispatch
64
  </label>
65
- <button class="about-toggle" data-tip="5-min energy storage &amp; dispatch MW Β· DISPATCH_UNIT_SOLUTION Β· from 11 Feb 2025" data-about="energy">β–Ά About</button>
66
  </div>
67
 
68
  </div>
@@ -195,7 +198,7 @@
195
  Extracted from AEMO's <strong>DISPATCH_UNIT_SOLUTION</strong> table and published
196
  the following day via NEMWEB. Data is fetched live from:
197
  <a href="https://nemweb.com.au/Reports/Current/Next_Day_Dispatch/" target="_blank" rel="noopener">Current directory</a>
198
- (individual daily files, 11 Feb 2025 – yesterday) or the
199
  <a href="https://www.nemweb.com.au/REPORTS/ARCHIVE/Next_Day_Dispatch/" target="_blank" rel="noopener">Archive directory</a>
200
  (monthly bundles for older dates, accessed via HTTP Range requests).
201
  </p>
@@ -253,13 +256,14 @@
253
  via NEMWEB:<br/><br/>
254
  <strong>4-second SCADA data</strong> β€” from
255
  <a href="https://www.nemweb.com.au/REPORTS/Current/FPPDAILY/" target="_blank" rel="noopener">NEMWEB FPPDAILY</a>,
256
- available from <strong>28 February 2025</strong> when the
257
  <a href="https://aemo.com.au/initiatives/major-programs/frequency-performance-payments-project" target="_blank" rel="noopener">Frequency Performance Payments (FPP)</a>
258
- scheme commenced non-financial operation.<br/><br/>
 
259
  <strong>5-minute dispatch energy data</strong> β€” from
260
  <a href="https://nemweb.com.au/Reports/Current/Next_Day_Dispatch/" target="_blank" rel="noopener">NEMWEB Next_Day_Dispatch</a>,
261
- available from <strong>11 February 2025</strong>. Provides initial energy storage (MWh)
262
- and initial MW dispatch for each 5-minute interval.<br/><br/>
263
  Data is fetched live from AEMO on each request β€” no data is stored on this server.
264
  Maximum request window: <strong>1 day</strong> per query.
265
  </p>
 
21
 
22
  <main>
23
 
24
+ <!-- ── BESS list staleness banner (shown only when /api/bess returns a non-live source) ── -->
25
+ <div id="bess-list-banner" class="alert alert-warning hidden" role="status"></div>
26
+
27
  <!-- ── Control panel ── -->
28
  <section class="panel">
29
  <h2>Select Data</h2>
 
57
  <input type="checkbox" id="chk-scada" checked />
58
  4-second SCADA
59
  </label>
60
+ <button class="about-toggle" data-tip="4-sec power output &amp; quality flags Β· PUBLIC_NEXT_DAY_FPPMW Β· from 29 Apr 2025" data-about="scada">β–Ά About</button>
61
  </div>
62
 
63
  <div class="check-row-header">
 
65
  <input type="checkbox" id="chk-energy" checked />
66
  5-minute Dispatch
67
  </label>
68
+ <button class="about-toggle" data-tip="5-min energy storage &amp; dispatch MW Β· DISPATCH_UNIT_SOLUTION Β· from 1 Apr 2025" data-about="energy">β–Ά About</button>
69
  </div>
70
 
71
  </div>
 
198
  Extracted from AEMO's <strong>DISPATCH_UNIT_SOLUTION</strong> table and published
199
  the following day via NEMWEB. Data is fetched live from:
200
  <a href="https://nemweb.com.au/Reports/Current/Next_Day_Dispatch/" target="_blank" rel="noopener">Current directory</a>
201
+ (individual daily files, recent months) or the
202
  <a href="https://www.nemweb.com.au/REPORTS/ARCHIVE/Next_Day_Dispatch/" target="_blank" rel="noopener">Archive directory</a>
203
  (monthly bundles for older dates, accessed via HTTP Range requests).
204
  </p>
 
256
  via NEMWEB:<br/><br/>
257
  <strong>4-second SCADA data</strong> β€” from
258
  <a href="https://www.nemweb.com.au/REPORTS/Current/FPPDAILY/" target="_blank" rel="noopener">NEMWEB FPPDAILY</a>,
259
+ published as part of the
260
  <a href="https://aemo.com.au/initiatives/major-programs/frequency-performance-payments-project" target="_blank" rel="noopener">Frequency Performance Payments (FPP)</a>
261
+ scheme (commenced 28 February 2025). Currently fetchable from <strong>29 April 2025</strong>;
262
+ earlier daily bundles have been rolled off the AEMO archive.<br/><br/>
263
  <strong>5-minute dispatch energy data</strong> β€” from
264
  <a href="https://nemweb.com.au/Reports/Current/Next_Day_Dispatch/" target="_blank" rel="noopener">NEMWEB Next_Day_Dispatch</a>,
265
+ with the <code>INITIAL_ENERGY_STORAGE</code> column added on 11 February 2025.
266
+ Currently fetchable from <strong>1 April 2025</strong>; earlier monthly bundles have been rolled off.<br/><br/>
267
  Data is fetched live from AEMO on each request β€” no data is stored on this server.
268
  Maximum request window: <strong>1 day</strong> per query.
269
  </p>
app/static/js/app.js CHANGED
@@ -8,8 +8,8 @@ const API = ''; // same origin
8
  let bessList = {};
9
  let qualityFlags = {};
10
  let cutoverDate = '2026-01-11'; // SCADA current/archive cutover (from /api/info)
11
- let scadaStartDate = '2025-02-28'; // from /api/info
12
- let dispatchStartDate = '2025-02-11'; // from /api/info
13
  let appEstimates = {
14
  current: { seconds: 120, sample_count: 0, is_default: true },
15
  archive: { seconds: 480, sample_count: 0, is_default: true },
@@ -61,8 +61,9 @@ async function init() {
61
  } catch (_) {}
62
 
63
  // Load BESS list and quality flags in parallel
 
64
  try {
65
- [bessList, qualityFlags] = await Promise.all([
66
  fetch(`${API}/api/bess`).then(r => r.json()),
67
  fetch(`${API}/api/quality-flags`).then(r => r.json()),
68
  ]);
@@ -70,6 +71,8 @@ async function init() {
70
  showError('Failed to load BESS list. Please refresh the page.');
71
  return;
72
  }
 
 
73
 
74
  // Populate state selector
75
  Object.keys(bessList).sort().forEach(state => {
@@ -97,7 +100,7 @@ async function init() {
97
  html: `
98
  <p class="about-source">
99
  Source: <strong>PUBLIC_NEXT_DAY_FPPMW</strong> (FPP Daily)
100
- &nbsp;Β·&nbsp; Available from <strong>28 Feb 2025</strong>
101
  </p>
102
  <div class="col-defs">
103
  <div class="col-def">
@@ -123,7 +126,7 @@ async function init() {
123
  html: `
124
  <p class="about-source">
125
  Source: <strong>DISPATCH_UNIT_SOLUTION</strong> via Next_Day_Dispatch
126
- &nbsp;Β·&nbsp; Available from <strong>11 Feb 2025</strong>
127
  </p>
128
  <div class="col-defs">
129
  <div class="col-def">
@@ -349,6 +352,31 @@ async function fetchEnergy(duid, date) {
349
  return resp.json();
350
  }
351
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  /* ── Warnings render ── */
353
  function renderWarnings(warnings) {
354
  let box = document.getElementById('warnings-box');
 
8
  let bessList = {};
9
  let qualityFlags = {};
10
  let cutoverDate = '2026-01-11'; // SCADA current/archive cutover (from /api/info)
11
+ let scadaStartDate = '2025-04-29'; // from /api/info (NEMWEB archive starts here)
12
+ let dispatchStartDate = '2025-04-01'; // from /api/info (NEMWEB archive starts here)
13
  let appEstimates = {
14
  current: { seconds: 120, sample_count: 0, is_default: true },
15
  archive: { seconds: 480, sample_count: 0, is_default: true },
 
61
  } catch (_) {}
62
 
63
  // Load BESS list and quality flags in parallel
64
+ let bessResp;
65
  try {
66
+ [bessResp, qualityFlags] = await Promise.all([
67
  fetch(`${API}/api/bess`).then(r => r.json()),
68
  fetch(`${API}/api/quality-flags`).then(r => r.json()),
69
  ]);
 
71
  showError('Failed to load BESS list. Please refresh the page.');
72
  return;
73
  }
74
+ bessList = bessResp.states || {};
75
+ renderBessListBanner(bessResp);
76
 
77
  // Populate state selector
78
  Object.keys(bessList).sort().forEach(state => {
 
100
  html: `
101
  <p class="about-source">
102
  Source: <strong>PUBLIC_NEXT_DAY_FPPMW</strong> (FPP Daily)
103
+ &nbsp;Β·&nbsp; Available from <strong>29 Apr 2025</strong>
104
  </p>
105
  <div class="col-defs">
106
  <div class="col-def">
 
126
  html: `
127
  <p class="about-source">
128
  Source: <strong>DISPATCH_UNIT_SOLUTION</strong> via Next_Day_Dispatch
129
+ &nbsp;Β·&nbsp; Available from <strong>1 Apr 2025</strong>
130
  </p>
131
  <div class="col-defs">
132
  <div class="col-def">
 
352
  return resp.json();
353
  }
354
 
355
+ /* ── BESS-list staleness banner ── */
356
+ function renderBessListBanner(resp) {
357
+ const box = document.getElementById('bess-list-banner');
358
+ if (!box) return;
359
+ const warnings = (resp && resp.warnings) || [];
360
+ if (!warnings.length) {
361
+ box.classList.add('hidden');
362
+ box.innerHTML = '';
363
+ return;
364
+ }
365
+ // One line per warning, prefixed with a warning icon.
366
+ box.innerHTML = warnings.map(w =>
367
+ `<div>⚠️ ${escapeHtml(w)}</div>`
368
+ ).join('');
369
+ box.classList.remove('hidden');
370
+ }
371
+
372
+ function escapeHtml(s) {
373
+ return String(s)
374
+ .replace(/&/g, '&amp;')
375
+ .replace(/</g, '&lt;')
376
+ .replace(/>/g, '&gt;')
377
+ .replace(/"/g, '&quot;');
378
+ }
379
+
380
  /* ── Warnings render ── */
381
  function renderWarnings(warnings) {
382
  let box = document.getElementById('warnings-box');
scripts/refresh_bess_list.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Refresh the bundled BESS list snapshot.
4
+
5
+ Downloads the AEMO Generation Information XLSX, parses it into the same
6
+ shape served by /api/bess, and writes two files into app/data/:
7
+
8
+ bess_list.json β€” parsed snapshot used as runtime fallback when the
9
+ live AEMO fetch is blocked (e.g. WAF 403 from the
10
+ HuggingFace egress IP).
11
+ aemo_gen_info.xlsx β€” the raw XLSX bytes, so gen_info_fetcher.py can also
12
+ fall back to fetching it from raw.githubusercontent.com
13
+ and parse a fresher copy at runtime.
14
+
15
+ Run from the repo root:
16
+ python scripts/refresh_bess_list.py
17
+
18
+ Intended to be invoked by a scheduled GitHub Action (see
19
+ .github/workflows/refresh_bess_list.yml).
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import sys
25
+ from datetime import datetime, timezone
26
+ from pathlib import Path
27
+
28
+ import httpx
29
+
30
+ REPO_ROOT = Path(__file__).resolve().parent.parent
31
+ sys.path.insert(0, str(REPO_ROOT))
32
+
33
+ from app.services.gen_info_fetcher import XLSX_URL, _BROWSER_HEADERS, _parse_xlsx
34
+
35
+ DATA_DIR = REPO_ROOT / "app" / "data"
36
+ JSON_OUT = DATA_DIR / "bess_list.json"
37
+ XLSX_OUT = DATA_DIR / "aemo_gen_info.xlsx"
38
+ META_OUT = DATA_DIR / "bess_list_meta.json"
39
+
40
+
41
+ def main() -> int:
42
+ print(f"Downloading {XLSX_URL}")
43
+ with httpx.Client(timeout=60, follow_redirects=True) as client:
44
+ resp = client.get(XLSX_URL, headers=_BROWSER_HEADERS)
45
+ resp.raise_for_status()
46
+ xlsx_bytes = resp.content
47
+ print(f"Got {len(xlsx_bytes):,} bytes")
48
+
49
+ parsed = _parse_xlsx(xlsx_bytes)
50
+ total = sum(len(v) for v in parsed.values())
51
+ print(f"Parsed {total} BESS units across {sum(1 for v in parsed.values() if v)} states")
52
+ if total == 0:
53
+ print("ERROR: parsed XLSX yielded zero BESS units β€” refusing to overwrite snapshot")
54
+ return 1
55
+
56
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
57
+ XLSX_OUT.write_bytes(xlsx_bytes)
58
+ JSON_OUT.write_text(json.dumps(parsed, indent=2, sort_keys=True) + "\n")
59
+ META_OUT.write_text(json.dumps({
60
+ "fetched_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
61
+ "source_url": XLSX_URL,
62
+ "xlsx_bytes": len(xlsx_bytes),
63
+ "unit_count": total,
64
+ }, indent=2) + "\n")
65
+
66
+ print(f"Wrote {JSON_OUT.relative_to(REPO_ROOT)}")
67
+ print(f"Wrote {XLSX_OUT.relative_to(REPO_ROOT)}")
68
+ print(f"Wrote {META_OUT.relative_to(REPO_ROOT)}")
69
+ return 0
70
+
71
+
72
+ if __name__ == "__main__":
73
+ sys.exit(main())