Claude commited on
Commit
b7c0a6b
Β·
1 Parent(s): 64bf052

Fetch ALL half-day ZIP files for a settlement date, not just the first

Browse files

The root cause of the persistent data gap: AEMO publishes each 12-hour
half of the NEM market day as a SEPARATE ZIP file, and both halves carry
the same settlement date in their filename (e.g. two files both named
PUBLIC_NEXT_DAY_FPPMW_20260115_*.zip).

The old _find_file_in / _find_file pair always picked fppmw_exact[0],
the lexicographically first match, which is consistently the first half
(04:00–16:00 AEST). The second half was never downloaded.

Fix:
- Rename _find_file_in β†’ _find_files_in: returns a LIST of every FPPMW
file whose name contains date_str (sorted), not just the first one.
- Rename _find_file β†’ _find_files: propagates the list.
- Rewrite fetch_csv_for_date: loops over every file in the list,
downloads and extracts each, concatenates their raw CSV bytes.

The existing _extract_csv_from_zip (opens all inner ZIPs) and the
data_processor boundary filter [04:00 AEST D, 04:00 AEST D+1) together
ensure the full market day is returned with no overlap or gaps, regardless
of whether AEMO uses one or two files per half, or names the second half
with D or D+1 in the filename.

https://claude.ai/code/session_01Vm5dTYdRf99LZ9MfB86vjx

Files changed (1) hide show
  1. app/services/aemo_fetcher.py +70 -56
app/services/aemo_fetcher.py CHANGED
@@ -129,34 +129,36 @@ def _date_str(target_date: date) -> str:
129
  return target_date.strftime("%Y%m%d")
130
 
131
 
132
- async def _find_file_in(
133
  filenames: list[str],
134
  target_date: date,
135
  date_str: str,
136
  url: str,
137
- ) -> tuple[str, str, str] | None:
138
  """
139
- Return (filename, url, kind) for the best ZIP to use, or None.
 
 
 
 
 
140
 
141
  kind values
142
  -----------
143
  "fppmw_daily" Individual FPPMW file (Current or non-bundle Archive).
144
- Structure: outer ZIP β†’ inner ZIP β†’ CSV.
145
  "fppmw_monthly" FPPMW monthly archive bundle (multi-GB).
146
  Structure: outer ZIP β†’ per-day inner ZIPs β†’ CSV.
147
  Uses HTTP Range requests to avoid full download.
148
- "fpp_bundle" FPP monthly archive bundle.
149
- Structure: outer ZIP β†’ per-day CSVs directly.
150
  """
151
  # Only FPPMW files are used. FPP files lack MEASUREMENT_DATETIME,
152
  # MEASURED_MW, and MW_QUALITY_FLAG and cannot supply SCADA data.
153
 
154
- # 1. Exact date match β€” FPPMW files only
155
- fppmw_exact = [f for f in filenames if "FPPMW" in f and date_str in f]
156
  if fppmw_exact:
157
- chosen = fppmw_exact[0]
158
- logger.info("Exact match at %s: %s", url, chosen)
159
- return chosen, url, "fppmw_daily"
160
 
161
  # 2. Archive bundle match: FPPMW files whose embedded date is an
162
  # end-of-month date (= last day of the previous month convention).
@@ -180,17 +182,17 @@ async def _find_file_in(
180
  "Bundle match at %s: %s (start %s covers %s)",
181
  url, best_file, best_date, target_date,
182
  )
183
- return best_file, url, "fppmw_monthly"
184
 
185
- return None
186
 
187
 
188
- async def _find_file(
189
  target_date: date, date_str: str, client: httpx.AsyncClient
190
- ) -> tuple[str, str, str]:
191
  """
192
- Search Current then Archive for a ZIP covering target_date.
193
- Returns (filename, base_url, kind).
194
  """
195
  last_filenames: list[str] = []
196
  for url in [AEMO_CURRENT_URL, AEMO_ARCHIVE_URL]:
@@ -201,9 +203,9 @@ async def _find_file(
201
  continue
202
 
203
  last_filenames = filenames
204
- result = await _find_file_in(filenames, target_date, date_str, url)
205
- if result:
206
- return result
207
 
208
  logger.error(
209
  "No file found for %s. Last directory listing sample: %s",
@@ -338,12 +340,16 @@ async def fetch_csv_for_date(
338
  skip_future_check: bool = False,
339
  ) -> bytes:
340
  """
341
- Download FPPDAILY data for target_date and return raw CSV bytes.
342
- Raises AEMOFetchError if unavailable.
343
 
344
- skip_future_check: set True when fetching D+1 for 24-hour coverage;
345
- suppresses the "today or future" guard so the call can proceed if the
346
- next-day file happens to be published already.
 
 
 
 
 
347
  """
348
  if target_date < DATA_START_DATE:
349
  raise AEMOFetchError(
@@ -356,36 +362,44 @@ async def fetch_csv_for_date(
356
  date_str = _date_str(target_date)
357
 
358
  async with httpx.AsyncClient() as client:
359
- filename, found_url, kind = await _find_file(target_date, date_str, client)
360
- zip_url = found_url + filename
361
-
362
- # FPPMW monthly bundles are several GB β€” extract via HTTP Range only.
363
- if kind == "fppmw_monthly":
364
- return await _fetch_fppmw_monthly_csv(zip_url, date_str)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
 
366
- # fpp_bundle and fppmw_daily: download the (daily-sized) ZIP file.
367
- logger.info("Downloading: %s", zip_url)
368
- try:
369
- resp = await client.get(
370
- zip_url,
371
- timeout=httpx.Timeout(AEMO_CONNECT_TIMEOUT, read=AEMO_READ_TIMEOUT),
372
- follow_redirects=True,
373
- headers=_HEADERS,
374
- )
375
- resp.raise_for_status()
376
- except httpx.TimeoutException:
377
- raise AEMOFetchError(
378
- "AEMO server timed out while downloading the data file. "
379
- "The file may be large. Please try again."
380
- )
381
- except httpx.HTTPStatusError as e:
382
- raise AEMOFetchError(
383
- f"Could not download data file (HTTP {e.response.status_code})."
384
- )
385
 
386
- try:
387
- return _extract_csv_from_zip(resp.content, filename, date_str, target_date)
388
- except zipfile.BadZipFile:
389
- raise AEMOFetchError(
390
- "Downloaded file appears to be corrupt. Please try again."
391
- )
 
129
  return target_date.strftime("%Y%m%d")
130
 
131
 
132
+ async def _find_files_in(
133
  filenames: list[str],
134
  target_date: date,
135
  date_str: str,
136
  url: str,
137
+ ) -> list[tuple[str, str, str]]:
138
  """
139
+ Return a list of (filename, url, kind) for every ZIP that covers target_date.
140
+
141
+ For FPPMW daily files there can be MORE THAN ONE match per date β€” AEMO
142
+ publishes each 12-hour half of the NEM market day as a separate ZIP, and
143
+ both halves carry the same settlement date in their filename. Returning
144
+ all of them lets fetch_csv_for_date download and concatenate every half.
145
 
146
  kind values
147
  -----------
148
  "fppmw_daily" Individual FPPMW file (Current or non-bundle Archive).
149
+ Structure: outer ZIP β†’ inner ZIP(s) β†’ CSV(s).
150
  "fppmw_monthly" FPPMW monthly archive bundle (multi-GB).
151
  Structure: outer ZIP β†’ per-day inner ZIPs β†’ CSV.
152
  Uses HTTP Range requests to avoid full download.
 
 
153
  """
154
  # Only FPPMW files are used. FPP files lack MEASUREMENT_DATETIME,
155
  # MEASURED_MW, and MW_QUALITY_FLAG and cannot supply SCADA data.
156
 
157
+ # 1. Exact date match β€” ALL FPPMW files for this date (may be 2 halves).
158
+ fppmw_exact = sorted(f for f in filenames if "FPPMW" in f and date_str in f)
159
  if fppmw_exact:
160
+ logger.info("Exact match(es) at %s: %s", url, fppmw_exact)
161
+ return [(f, url, "fppmw_daily") for f in fppmw_exact]
 
162
 
163
  # 2. Archive bundle match: FPPMW files whose embedded date is an
164
  # end-of-month date (= last day of the previous month convention).
 
182
  "Bundle match at %s: %s (start %s covers %s)",
183
  url, best_file, best_date, target_date,
184
  )
185
+ return [(best_file, url, "fppmw_monthly")]
186
 
187
+ return []
188
 
189
 
190
+ async def _find_files(
191
  target_date: date, date_str: str, client: httpx.AsyncClient
192
+ ) -> list[tuple[str, str, str]]:
193
  """
194
+ Search Current then Archive for all ZIPs covering target_date.
195
+ Returns list of (filename, base_url, kind).
196
  """
197
  last_filenames: list[str] = []
198
  for url in [AEMO_CURRENT_URL, AEMO_ARCHIVE_URL]:
 
203
  continue
204
 
205
  last_filenames = filenames
206
+ results = await _find_files_in(filenames, target_date, date_str, url)
207
+ if results:
208
+ return results
209
 
210
  logger.error(
211
  "No file found for %s. Last directory listing sample: %s",
 
340
  skip_future_check: bool = False,
341
  ) -> bytes:
342
  """
343
+ Download ALL FPPDAILY files for target_date and return concatenated CSV bytes.
 
344
 
345
+ AEMO publishes each 12-hour half of the NEM market day as a separate ZIP
346
+ file, both carrying the same settlement date. This function finds every
347
+ matching file and downloads them all, so the caller receives the raw bytes
348
+ for the entire market day before the data_processor applies its boundary
349
+ filter.
350
+
351
+ skip_future_check: set True when fetching D+1 as a safety-net for the
352
+ case where the second half is named with the next calendar date.
353
  """
354
  if target_date < DATA_START_DATE:
355
  raise AEMOFetchError(
 
362
  date_str = _date_str(target_date)
363
 
364
  async with httpx.AsyncClient() as client:
365
+ file_list = await _find_files(target_date, date_str, client)
366
+
367
+ csv_parts: list[bytes] = []
368
+ for filename, found_url, kind in file_list:
369
+ zip_url = found_url + filename
370
+
371
+ # FPPMW monthly bundles are several GB β€” extract via HTTP Range only.
372
+ if kind == "fppmw_monthly":
373
+ csv_parts.append(await _fetch_fppmw_monthly_csv(zip_url, date_str))
374
+ continue
375
+
376
+ # fppmw_daily: download the (daily-sized) ZIP file.
377
+ logger.info("Downloading: %s", zip_url)
378
+ try:
379
+ resp = await client.get(
380
+ zip_url,
381
+ timeout=httpx.Timeout(AEMO_CONNECT_TIMEOUT, read=AEMO_READ_TIMEOUT),
382
+ follow_redirects=True,
383
+ headers=_HEADERS,
384
+ )
385
+ resp.raise_for_status()
386
+ except httpx.TimeoutException:
387
+ raise AEMOFetchError(
388
+ "AEMO server timed out while downloading the data file. "
389
+ "The file may be large. Please try again."
390
+ )
391
+ except httpx.HTTPStatusError as e:
392
+ raise AEMOFetchError(
393
+ f"Could not download data file (HTTP {e.response.status_code})."
394
+ )
395
 
396
+ try:
397
+ csv_parts.append(
398
+ _extract_csv_from_zip(resp.content, filename, date_str, target_date)
399
+ )
400
+ except zipfile.BadZipFile:
401
+ raise AEMOFetchError(
402
+ "Downloaded file appears to be corrupt. Please try again."
403
+ )
 
 
 
 
 
 
 
 
 
 
 
404
 
405
+ return b"".join(csv_parts)