Claude commited on
Commit
ff812da
·
1 Parent(s): 0950d2d

Rewrite archive ZIP selection: find largest start-date <= target date

Browse files

NEMWEB archive ZIPs are named PUBLIC_NEXT_DAY_FPP_YYYYMMDD.zip where the
date is the FIRST day covered by that bundle (not end-of-month). A single
archive ZIP spans multiple days (e.g. 20250228.zip covers Feb 28 - Mar 30).

Previous approach (month-string matching) was wrong in two ways:
- It matched FPPMW daily files as 'monthly archives'
- It didn't account for bundles crossing month boundaries

New approach:
- Extract the 8-digit date from every filename in the archive listing
- Select the ZIP whose date is the largest date that is still <= target date
- This correctly navigates any bundle boundary

Also:
- Current dir files (with 16-digit suffix) still matched by exact date_str
- _find_file now raises AEMOFetchError directly (cleaner call site)
- Better error message lists which dates ARE available in the archive ZIP
when the exact date CSV is missing
- Extended _is_current window to 9 days for UTC/AEST lag tolerance
- Added module docstring explaining both file naming conventions and the
UTC vs NEM-time note for timestamps inside CSVs

https://claude.ai/code/session_01Vm5dTYdRf99LZ9MfB86vjx

Files changed (1) hide show
  1. app/services/aemo_fetcher.py +149 -57
app/services/aemo_fetcher.py CHANGED
@@ -1,11 +1,33 @@
1
  """
2
  Downloads and extracts FPPDAILY ZIP files from AEMO NEMWEB.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  """
4
  import io
5
  import logging
6
  import re
7
  import zipfile
8
- from datetime import date
9
 
10
  import httpx
11
 
@@ -36,12 +58,15 @@ class AEMOFetchError(Exception):
36
 
37
 
38
  def _is_current(target_date: date) -> bool:
39
- """Files within the last ~7 days are in /Current/, older in /Archive/."""
40
- return (date.today() - target_date).days <= 7
 
 
 
41
 
42
 
43
  async def _list_directory(base_url: str, client: httpx.AsyncClient) -> list[str]:
44
- """Fetch the HTML directory listing and extract all ZIP filenames."""
45
  logger.info("Listing directory: %s", base_url)
46
  try:
47
  resp = await client.get(
@@ -68,63 +93,109 @@ async def _list_directory(base_url: str, client: httpx.AsyncClient) -> list[str]
68
  return filenames
69
 
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  def _date_str(target_date: date) -> str:
72
  """Return date as YYYYMMDD string."""
73
  return target_date.strftime("%Y%m%d")
74
 
75
 
76
- async def _find_file(
77
- target_date: date, date_str: str, base_url: str, client: httpx.AsyncClient
78
- ) -> tuple[list[str], str]:
 
 
 
79
  """
80
- Try multiple locations to find a ZIP for the target date.
81
-
82
- NEMWEB FPPDAILY archive has two file types:
83
- - Daily: PUBLIC_NEXT_DAY_FPPMW_YYYYMMDD.zip (individual day, MW metering data)
84
- - Monthly: PUBLIC_NEXT_DAY_FPP_YYYYMMDD.zip (month-end date, no "MW" in name,
85
- contains all daily CSVs for that month)
86
 
87
  Strategy:
88
- 1. Look for an exact daily FPPMW match (date_str in filename)
89
- 2. Look for a monthly FPP archive (month_str in filename, "FPPMW" NOT in filename)
90
- 3. Also try Current URL as fallback — NEMWEB may keep files longer than 7 days
91
-
92
- Returns (matching_filenames, base_url_where_found).
93
  """
94
- month_str = target_date.strftime("%Y%m")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
- # Always try archive subdirectory and Current as fallbacks regardless of date age
97
- urls_to_try = [base_url, AEMO_ARCHIVE_URL, AEMO_CURRENT_URL]
98
- # Deduplicate while preserving order
99
- seen: set[str] = set()
100
- unique_urls = [u for u in urls_to_try if u not in seen and not seen.add(u)] # type: ignore[func-returns-value]
101
 
102
- filenames: list[str] = []
103
- for url in unique_urls:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  try:
105
  filenames = await _list_directory(url, client)
106
  except AEMOFetchError as e:
107
  logger.warning("Could not list %s: %s", url, e)
108
  continue
109
 
110
- # 1. Exact daily match (e.g. PUBLIC_NEXT_DAY_FPPMW_20260224.zip)
111
- matching = [f for f in filenames if date_str in f]
112
- if matching:
113
- logger.info("Found daily match at %s: %s", url, matching[0])
114
- return matching, url
115
-
116
- # 2. Monthly archive match: PUBLIC_NEXT_DAY_FPP_YYYYMMDD.zip (no "MW" in name).
117
- # Excludes daily FPPMW files which also contain the month string.
118
- monthly = [f for f in filenames if month_str in f and "FPPMW" not in f]
119
- if monthly:
120
- logger.info("Found monthly archive at %s: %s", url, monthly[0])
121
- return monthly, url
122
 
123
  logger.error(
124
- "No file found for date %s. Searched: %s. Last listing sample: %s",
125
- date_str, unique_urls, filenames[:10],
 
 
 
 
126
  )
127
- return [], base_url
128
 
129
 
130
  async def fetch_csv_for_date(target_date: date, duid: str) -> bytes:
@@ -135,7 +206,7 @@ async def fetch_csv_for_date(target_date: date, duid: str) -> bytes:
135
  if target_date < DATA_START_DATE:
136
  raise AEMOFetchError(
137
  f"Data is only available from {DATA_START_DATE.strftime('%d %B %Y')} "
138
- f"when the FPP scheme commenced."
139
  )
140
  if target_date >= date.today():
141
  raise AEMOFetchError("Cannot request data for today or future dates.")
@@ -144,14 +215,7 @@ async def fetch_csv_for_date(target_date: date, duid: str) -> bytes:
144
  date_str = _date_str(target_date)
145
 
146
  async with httpx.AsyncClient() as client:
147
- matching, found_url = await _find_file(target_date, date_str, base_url, client)
148
- if not matching:
149
- raise AEMOFetchError(
150
- f"No FPPDAILY data found for {target_date.strftime('%d %B %Y')}. "
151
- f"The file may not yet be published or the date may not have data."
152
- )
153
-
154
- filename = matching[0]
155
  zip_url = found_url + filename
156
 
157
  logger.info("Downloading: %s", zip_url)
@@ -166,28 +230,56 @@ async def fetch_csv_for_date(target_date: date, duid: str) -> bytes:
166
  except httpx.TimeoutException:
167
  raise AEMOFetchError(
168
  "AEMO server timed out while downloading the data file. "
169
- "The file may be very large. Please try again."
170
  )
171
  except httpx.HTTPStatusError as e:
172
  raise AEMOFetchError(
173
  f"Could not download data file (HTTP {e.response.status_code})."
174
  )
175
 
176
- # Extract CSV from ZIP.
177
- # If this is a monthly archive ZIP, find the CSV matching the specific date.
 
178
  try:
179
  zip_bytes = io.BytesIO(resp.content)
180
  with zipfile.ZipFile(zip_bytes) as zf:
181
- all_csvs = [n for n in zf.namelist() if n.lower().endswith(".csv")]
 
 
182
  if not all_csvs:
183
  raise AEMOFetchError("ZIP file contained no CSV files.")
184
 
185
- # Prefer the CSV that matches the target date; fall back to first
186
  daily_csvs = [n for n in all_csvs if date_str in n]
187
- csv_name = daily_csvs[0] if daily_csvs else all_csvs[0]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  logger.info("Extracting CSV: %s", csv_name)
189
  csv_bytes = zf.read(csv_name)
 
190
  except zipfile.BadZipFile:
191
- raise AEMOFetchError("Downloaded file appears to be corrupt. Please try again.")
 
 
192
 
193
  return csv_bytes
 
1
  """
2
  Downloads and extracts FPPDAILY ZIP files from AEMO NEMWEB.
3
+
4
+ File naming conventions on NEMWEB:
5
+
6
+ Current directory (https://www.nemweb.com.au/REPORTS/Current/FPPDAILY/):
7
+ PUBLIC_NEXT_DAY_FPP_YYYYMMDD_<16-digit-suffix>.zip
8
+ One file per day, contains the single day's CSV.
9
+
10
+ Archive directory (https://nemweb.com.au/Reports/Archive/FPPDAILY/):
11
+ PUBLIC_NEXT_DAY_FPP_YYYYMMDD.zip
12
+ Each ZIP bundles multiple days of CSVs. The YYYYMMDD in the filename is the
13
+ START date of the bundle, NOT an end-of-month date.
14
+ Example: PUBLIC_NEXT_DAY_FPP_20250228.zip covers Feb 28 – Mar 30 2025;
15
+ PUBLIC_NEXT_DAY_FPP_20250331.zip covers Mar 31 2025 onwards.
16
+ To find data for a given date, download the archive whose start date is the
17
+ largest date that is <= the target date.
18
+
19
+ Timezone note:
20
+ INTERVAL_DATETIME / MEASUREMENT_DATETIME inside CSVs are in UTC (or NEM time
21
+ which is UTC+10 without DST). An Australian market day D runs from
22
+ D-1 14:00 UTC to D 13:30 UTC in five-minute intervals. The CSV file itself
23
+ is named with the Australian market date, so file selection by date is
24
+ correct without timezone conversion.
25
  """
26
  import io
27
  import logging
28
  import re
29
  import zipfile
30
+ from datetime import date, datetime
31
 
32
  import httpx
33
 
 
58
 
59
 
60
  def _is_current(target_date: date) -> bool:
61
+ """
62
+ Files within the last ~7 days live in /Current/; older files in /Archive/.
63
+ We use 9 days to allow for UTC vs AEST timezone differences and publication lag.
64
+ """
65
+ return (date.today() - target_date).days <= 9
66
 
67
 
68
  async def _list_directory(base_url: str, client: httpx.AsyncClient) -> list[str]:
69
+ """Fetch the HTML directory listing and return all ZIP filenames."""
70
  logger.info("Listing directory: %s", base_url)
71
  try:
72
  resp = await client.get(
 
93
  return filenames
94
 
95
 
96
+ def _extract_zip_date(filename: str) -> date | None:
97
+ """
98
+ Extract the YYYYMMDD date embedded in a NEMWEB ZIP filename.
99
+
100
+ Handles both:
101
+ PUBLIC_NEXT_DAY_FPP_20260224_1234567890123456.zip (Current, with suffix)
102
+ PUBLIC_NEXT_DAY_FPP_20260228.zip (Archive, no suffix)
103
+ """
104
+ # Match 8 consecutive digits that appear after an underscore and before
105
+ # either another underscore, a dot, or end-of-string.
106
+ m = re.search(r'_(\d{8})(?:[_.]|$)', filename)
107
+ if m:
108
+ try:
109
+ return datetime.strptime(m.group(1), "%Y%m%d").date()
110
+ except ValueError:
111
+ return None
112
+ return None
113
+
114
+
115
  def _date_str(target_date: date) -> str:
116
  """Return date as YYYYMMDD string."""
117
  return target_date.strftime("%Y%m%d")
118
 
119
 
120
+ async def _find_file_in(
121
+ filenames: list[str],
122
+ target_date: date,
123
+ date_str: str,
124
+ url: str,
125
+ ) -> tuple[str, str] | None:
126
  """
127
+ Given a directory listing, return (filename, url) for the best ZIP to use
128
+ for target_date, or None if nothing suitable is found.
 
 
 
 
129
 
130
  Strategy:
131
+ 1. Exact date match — filename contains YYYYMMDD of the target date.
132
+ Covers Current files (PUBLIC_NEXT_DAY_FPP_20260224_XXXXXXXXXXXXXXXX.zip)
133
+ and any archive file whose start date happens to equal target_date.
134
+ 2. Archive bundle match — find the ZIP whose embedded date is the largest
135
+ date that is still <= target_date. That bundle covers target_date.
136
  """
137
+ # 1. Exact match (e.g. Current dir or lucky archive hit)
138
+ exact = [f for f in filenames if date_str in f]
139
+ if exact:
140
+ logger.info("Exact match at %s: %s", url, exact[0])
141
+ return exact[0], url
142
+
143
+ # 2. Archive bundle: largest start-date <= target_date
144
+ candidates: list[tuple[date, str]] = []
145
+ for f in filenames:
146
+ zip_date = _extract_zip_date(f)
147
+ if zip_date is not None and zip_date <= target_date:
148
+ candidates.append((zip_date, f))
149
+
150
+ if candidates:
151
+ candidates.sort(reverse=True) # descending by date
152
+ best_date, best_file = candidates[0]
153
+ logger.info(
154
+ "Archive bundle match at %s: %s (bundle start %s covers %s)",
155
+ url, best_file, best_date, target_date,
156
+ )
157
+ return best_file, url
158
+
159
+ return None
160
 
 
 
 
 
 
161
 
162
+ async def _find_file(
163
+ target_date: date, date_str: str, base_url: str, client: httpx.AsyncClient
164
+ ) -> tuple[str, str]:
165
+ """
166
+ Search Current then Archive for a ZIP covering target_date.
167
+ Returns (filename, base_url_of_that_file).
168
+ Raises AEMOFetchError if nothing is found.
169
+ """
170
+ # Always try Current first, then Archive, regardless of age.
171
+ # This handles publication lag and UTC/AEST edge cases.
172
+ urls_to_try: list[str] = []
173
+ if base_url == AEMO_CURRENT_URL:
174
+ urls_to_try = [AEMO_CURRENT_URL, AEMO_ARCHIVE_URL]
175
+ else:
176
+ urls_to_try = [AEMO_ARCHIVE_URL, AEMO_CURRENT_URL]
177
+
178
+ last_filenames: list[str] = []
179
+ for url in urls_to_try:
180
  try:
181
  filenames = await _list_directory(url, client)
182
  except AEMOFetchError as e:
183
  logger.warning("Could not list %s: %s", url, e)
184
  continue
185
 
186
+ last_filenames = filenames
187
+ result = await _find_file_in(filenames, target_date, date_str, url)
188
+ if result:
189
+ return result
 
 
 
 
 
 
 
 
190
 
191
  logger.error(
192
+ "No file found for %s. Last directory listing sample: %s",
193
+ date_str, last_filenames[:10],
194
+ )
195
+ raise AEMOFetchError(
196
+ f"No FPPDAILY data found for {target_date.strftime('%d %B %Y')}. "
197
+ "The file may not yet be published by AEMO."
198
  )
 
199
 
200
 
201
  async def fetch_csv_for_date(target_date: date, duid: str) -> bytes:
 
206
  if target_date < DATA_START_DATE:
207
  raise AEMOFetchError(
208
  f"Data is only available from {DATA_START_DATE.strftime('%d %B %Y')} "
209
+ "when the FPP scheme commenced."
210
  )
211
  if target_date >= date.today():
212
  raise AEMOFetchError("Cannot request data for today or future dates.")
 
215
  date_str = _date_str(target_date)
216
 
217
  async with httpx.AsyncClient() as client:
218
+ filename, found_url = await _find_file(target_date, date_str, base_url, client)
 
 
 
 
 
 
 
219
  zip_url = found_url + filename
220
 
221
  logger.info("Downloading: %s", zip_url)
 
230
  except httpx.TimeoutException:
231
  raise AEMOFetchError(
232
  "AEMO server timed out while downloading the data file. "
233
+ "The file may be large. Please try again."
234
  )
235
  except httpx.HTTPStatusError as e:
236
  raise AEMOFetchError(
237
  f"Could not download data file (HTTP {e.response.status_code})."
238
  )
239
 
240
+ # Extract the correct daily CSV from the ZIP.
241
+ # Archive ZIPs contain multiple CSVs (one per day); match by date_str.
242
+ # Current ZIPs contain a single CSV.
243
  try:
244
  zip_bytes = io.BytesIO(resp.content)
245
  with zipfile.ZipFile(zip_bytes) as zf:
246
+ all_entries = zf.namelist()
247
+ all_csvs = [n for n in all_entries if n.lower().endswith(".csv")]
248
+
249
  if not all_csvs:
250
  raise AEMOFetchError("ZIP file contained no CSV files.")
251
 
252
+ # Prefer the CSV whose name contains the target date
253
  daily_csvs = [n for n in all_csvs if date_str in n]
254
+ if daily_csvs:
255
+ csv_name = daily_csvs[0]
256
+ elif len(all_csvs) == 1:
257
+ # Single-CSV ZIP (Current) — use it directly
258
+ csv_name = all_csvs[0]
259
+ else:
260
+ # Multi-CSV archive but no name match — log and use newest by name
261
+ all_csvs_sorted = sorted(all_csvs, reverse=True)
262
+ logger.warning(
263
+ "No CSV matching %s in %s; available: %s",
264
+ date_str, filename, all_csvs_sorted[:5],
265
+ )
266
+ raise AEMOFetchError(
267
+ f"No data file found for {target_date.strftime('%d %B %Y')} "
268
+ f"inside the archive ZIP. Available dates: "
269
+ + ", ".join(
270
+ m.group(1)
271
+ for n in all_csvs_sorted[:5]
272
+ for m in [re.search(r'(\d{8})', n)]
273
+ if m
274
+ )
275
+ )
276
+
277
  logger.info("Extracting CSV: %s", csv_name)
278
  csv_bytes = zf.read(csv_name)
279
+
280
  except zipfile.BadZipFile:
281
+ raise AEMOFetchError(
282
+ "Downloaded file appears to be corrupt. Please try again."
283
+ )
284
 
285
  return csv_bytes