Spaces:
Running
Rewrite archive ZIP selection: find largest start-date <= target date
Browse filesNEMWEB 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
- app/services/aemo_fetcher.py +149 -57
|
@@ -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 |
-
"""
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
|
| 43 |
async def _list_directory(base_url: str, client: httpx.AsyncClient) -> list[str]:
|
| 44 |
-
"""Fetch the HTML directory listing and
|
| 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
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
| 79 |
"""
|
| 80 |
-
|
| 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.
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
"""
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 111 |
-
|
| 112 |
-
if
|
| 113 |
-
|
| 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
|
| 125 |
-
date_str,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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
|
| 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 |
-
#
|
|
|
|
| 178 |
try:
|
| 179 |
zip_bytes = io.BytesIO(resp.content)
|
| 180 |
with zipfile.ZipFile(zip_bytes) as zf:
|
| 181 |
-
|
|
|
|
|
|
|
| 182 |
if not all_csvs:
|
| 183 |
raise AEMOFetchError("ZIP file contained no CSV files.")
|
| 184 |
|
| 185 |
-
# Prefer the CSV
|
| 186 |
daily_csvs = [n for n in all_csvs if date_str in n]
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
logger.info("Extracting CSV: %s", csv_name)
|
| 189 |
csv_bytes = zf.read(csv_name)
|
|
|
|
| 190 |
except zipfile.BadZipFile:
|
| 191 |
-
raise AEMOFetchError(
|
|
|
|
|
|
|
| 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
|