Claude commited on
Commit
ead45e6
·
1 Parent(s): 540b503

Source BESS list dynamically from AEMO generation information XLSX

Browse files

Replace static bess_list.json with live data from the AEMO NEM Generation
Information spreadsheet:

https://www.aemo.com.au/.../nem-generation-information-jan-2026.xlsx

New service: app/services/gen_info_fetcher.py
- Downloads the XLSX via httpx on first request
- Parses the 'Generator Information' sheet using openpyxl
- Filters rows: Technology Type = 'Battery Storage'
Commitment Status = 'In Service'
- Groups results by NEM region → state key (QLD1→QLD, NSW1→NSW, etc.)
- Extracts DUID, Station Name, Reg Cap (MW), Region per unit
- Caches the parsed result for 24 hours in memory
- Falls back to stale cache, then to static bess_list.json, on any error

Updated /api/bess endpoint to call fetch_bess_list() (now async).
Added openpyxl==3.1.5 to requirements.txt.

https://claude.ai/code/session_01Vm5dTYdRf99LZ9MfB86vjx

app/routers/api.py CHANGED
@@ -11,6 +11,7 @@ from fastapi.responses import Response
11
  from app.config import ANALYTICS_TOKEN, DATA_START_DATE, MAX_DAYS_PER_REQUEST
12
  from app.services.aemo_fetcher import AEMOFetchError, fetch_csv_for_date
13
  from app.services.analytics import get_stats, log_request
 
14
  from app.services.data_processor import (
15
  DataProcessingError,
16
  compute_summary,
@@ -40,10 +41,10 @@ def _parse_date(date_str: str) -> date:
40
 
41
 
42
  @router.get("/bess")
43
- def get_bess_list():
44
- """Return BESS list grouped by state."""
45
- bess_file = DATA_DIR / "bess_list.json"
46
- return json.loads(bess_file.read_text())
47
 
48
 
49
  @router.get("/quality-flags")
 
11
  from app.config import ANALYTICS_TOKEN, DATA_START_DATE, MAX_DAYS_PER_REQUEST
12
  from app.services.aemo_fetcher import AEMOFetchError, fetch_csv_for_date
13
  from app.services.analytics import get_stats, log_request
14
+ from app.services.gen_info_fetcher import fetch_bess_list
15
  from app.services.data_processor import (
16
  DataProcessingError,
17
  compute_summary,
 
41
 
42
 
43
  @router.get("/bess")
44
+ async def get_bess_list():
45
+ """Return in-service battery storage units grouped by state, sourced from the
46
+ AEMO NEM Generation Information XLSX (cached 24 h; falls back to static JSON)."""
47
+ return await fetch_bess_list()
48
 
49
 
50
  @router.get("/quality-flags")
app/services/gen_info_fetcher.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
16
+ import httpx
17
+ import openpyxl
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ XLSX_URL = (
22
+ "https://www.aemo.com.au/-/media/files/electricity/nem/planning_and_forecasting"
23
+ "/generation_information/2026/nem-generation-information-jan-2026.xlsx"
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
30
+ REGION_TO_STATE: dict[str, str] = {
31
+ "QLD1": "QLD",
32
+ "NSW1": "NSW",
33
+ "VIC1": "VIC",
34
+ "SA1": "SA",
35
+ "TAS1": "TAS",
36
+ }
37
+
38
+ BATTERY_TECH = "Battery Storage"
39
+ IN_SERVICE = "In Service"
40
+
41
+ # Column names to search for (case-insensitive substring match as fallback)
42
+ _COL_DUID = "DUID"
43
+ _COL_NAME = "Station Name"
44
+ _COL_TECH = "Technology Type"
45
+ _COL_STATUS = "Commitment Status"
46
+ _COL_REGION = "Region"
47
+ _COL_CAPACITY = "Reg Cap" # "Reg Cap (MW)" — matched as prefix
48
+
49
+ CACHE_TTL = timedelta(hours=24)
50
+
51
+ # In-memory cache: (parsed_result, fetched_at)
52
+ _cache: tuple[dict, datetime] | None = None
53
+
54
+
55
+ def _find_col(headers: list[str], target: str) -> int | None:
56
+ """
57
+ Return the 0-based index of the column whose header matches target.
58
+ Tries exact match first, then case-insensitive prefix match.
59
+ """
60
+ target_lower = target.lower()
61
+ for i, h in enumerate(headers):
62
+ if h == target:
63
+ return i
64
+ for i, h in enumerate(headers):
65
+ if h.lower().startswith(target_lower):
66
+ return i
67
+ return None
68
+
69
+
70
+ def _parse_xlsx(xlsx_bytes: bytes) -> dict[str, list[dict]]:
71
+ """
72
+ Parse the XLSX bytes and return BESS list grouped by state.
73
+
74
+ Returns:
75
+ { "QLD": [...], "NSW": [...], "VIC": [...], "SA": [...], "TAS": [...] }
76
+ Each entry: { "duid": str, "name": str, "capacity_mw": float|None, "region": str }
77
+ """
78
+ wb = openpyxl.load_workbook(io.BytesIO(xlsx_bytes), read_only=True, data_only=True)
79
+
80
+ if SHEET_NAME not in wb.sheetnames:
81
+ available = ", ".join(wb.sheetnames)
82
+ raise ValueError(f"Sheet '{SHEET_NAME}' not found. Available: {available}")
83
+
84
+ ws = wb[SHEET_NAME]
85
+ rows = list(ws.iter_rows(values_only=True))
86
+
87
+ if not rows:
88
+ raise ValueError("Sheet is empty.")
89
+
90
+ # First row is the header
91
+ raw_headers = [str(c).strip() if c is not None else "" for c in rows[0]]
92
+
93
+ col_duid = _find_col(raw_headers, _COL_DUID)
94
+ col_name = _find_col(raw_headers, _COL_NAME)
95
+ col_tech = _find_col(raw_headers, _COL_TECH)
96
+ col_status = _find_col(raw_headers, _COL_STATUS)
97
+ col_region = _find_col(raw_headers, _COL_REGION)
98
+ col_capacity = _find_col(raw_headers, _COL_CAPACITY)
99
+
100
+ missing = [
101
+ name for name, idx in [
102
+ (_COL_DUID, col_duid), (_COL_TECH, col_tech),
103
+ (_COL_STATUS, col_status), (_COL_REGION, col_region),
104
+ ] if idx is None
105
+ ]
106
+ if missing:
107
+ raise ValueError(
108
+ f"Required columns not found: {missing}. "
109
+ f"Headers seen: {raw_headers[:20]}"
110
+ )
111
+
112
+ result: dict[str, list[dict]] = {s: [] for s in REGION_TO_STATE.values()}
113
+
114
+ for row in rows[1:]:
115
+ def cell(idx: int | None) -> str:
116
+ if idx is None or idx >= len(row):
117
+ return ""
118
+ v = row[idx]
119
+ return str(v).strip() if v is not None else ""
120
+
121
+ tech = cell(col_tech)
122
+ status = cell(col_status)
123
+ region = cell(col_region)
124
+
125
+ if tech != BATTERY_TECH:
126
+ continue
127
+ if status != IN_SERVICE:
128
+ continue
129
+ state = REGION_TO_STATE.get(region)
130
+ if state is None:
131
+ continue
132
+
133
+ duid = cell(col_duid)
134
+ if not duid:
135
+ continue
136
+
137
+ name = cell(col_name) or duid
138
+
139
+ capacity_mw: float | None = None
140
+ if col_capacity is not None:
141
+ raw_cap = row[col_capacity] if col_capacity < len(row) else None
142
+ if raw_cap is not None:
143
+ try:
144
+ capacity_mw = float(raw_cap)
145
+ except (ValueError, TypeError):
146
+ pass
147
+
148
+ result[state].append({
149
+ "duid": duid,
150
+ "name": name,
151
+ "capacity_mw": capacity_mw,
152
+ "region": region,
153
+ })
154
+
155
+ total = sum(len(v) for v in result.values())
156
+ logger.info(
157
+ "Parsed XLSX: %d in-service battery storage units across %d states",
158
+ total, sum(1 for v in result.values() if v),
159
+ )
160
+ return result
161
+
162
+
163
+ async def fetch_bess_list() -> dict[str, list[dict]]:
164
+ """
165
+ Return the BESS list from the AEMO generation information XLSX.
166
+
167
+ Uses a 24-hour in-memory cache. Falls back to the static bess_list.json
168
+ if the download or parse fails and no cached data is available.
169
+ """
170
+ global _cache
171
+
172
+ now = datetime.utcnow()
173
+
174
+ # Return cached data if still fresh
175
+ if _cache is not None:
176
+ parsed, fetched_at = _cache
177
+ if now - fetched_at < CACHE_TTL:
178
+ logger.debug("Returning cached BESS list (age %s)", now - fetched_at)
179
+ return parsed
180
+
181
+ # Attempt download
182
+ try:
183
+ logger.info("Downloading AEMO generation information XLSX from %s", XLSX_URL)
184
+ async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
185
+ resp = await client.get(XLSX_URL)
186
+ resp.raise_for_status()
187
+
188
+ parsed = _parse_xlsx(resp.content)
189
+ _cache = (parsed, now)
190
+ return parsed
191
+
192
+ except Exception as exc:
193
+ logger.warning("Failed to fetch/parse AEMO gen info XLSX: %s", exc)
194
+
195
+ # Return stale cache rather than nothing
196
+ if _cache is not None:
197
+ logger.warning("Returning stale BESS list cache (age %s)", now - _cache[1])
198
+ return _cache[0]
199
+
200
+ # Last resort: return static fallback file
201
+ logger.warning("Falling back to static bess_list.json")
202
+ import json
203
+ fallback = Path(__file__).parent.parent / "data" / "bess_list.json"
204
+ return json.loads(fallback.read_text())
requirements.txt CHANGED
@@ -5,3 +5,4 @@ httpx==0.27.2
5
  pyarrow==18.1.0
6
  python-multipart==0.0.12
7
  aiofiles==24.1.0
 
 
5
  pyarrow==18.1.0
6
  python-multipart==0.0.12
7
  aiofiles==24.1.0
8
+ openpyxl==3.1.5