Spaces:
Running
Fetch ALL half-day ZIP files for a settlement date, not just the first
Browse filesThe 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
- app/services/aemo_fetcher.py +70 -56
|
@@ -129,34 +129,36 @@ def _date_str(target_date: date) -> str:
|
|
| 129 |
return target_date.strftime("%Y%m%d")
|
| 130 |
|
| 131 |
|
| 132 |
-
async def
|
| 133 |
filenames: list[str],
|
| 134 |
target_date: date,
|
| 135 |
date_str: str,
|
| 136 |
url: str,
|
| 137 |
-
) -> tuple[str, str, str]
|
| 138 |
"""
|
| 139 |
-
Return (filename, url, kind) for
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 155 |
-
fppmw_exact =
|
| 156 |
if fppmw_exact:
|
| 157 |
-
|
| 158 |
-
|
| 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
|
| 186 |
|
| 187 |
|
| 188 |
-
async def
|
| 189 |
target_date: date, date_str: str, client: httpx.AsyncClient
|
| 190 |
-
) -> tuple[str, str, str]:
|
| 191 |
"""
|
| 192 |
-
Search Current then Archive for
|
| 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 |
-
|
| 205 |
-
if
|
| 206 |
-
return
|
| 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
|
| 342 |
-
Raises AEMOFetchError if unavailable.
|
| 343 |
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 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 |
-
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|