Spaces:
Running
Add FPPMW monthly archive support (Mar 2025–Jan 2026) + 24h day coverage
Browse filesaemo_fetcher.py — complete rework:
- Add _is_month_end(): FPPMW archive files whose embedded date is an
end-of-month date are monthly bundles (e.g. 20250228 = March 2025
bundle); non-month-end FPPMW dates are individual daily archive copies.
- _find_file_in() now returns (filename, url, kind) where kind is one of
'fppmw_monthly', 'fppmw_daily', or 'fpp_bundle'.
- Bundle candidates now include FPPMW month-end files alongside FPP files,
with FPPMW preferred over FPP for the same date (metered data > forecast).
- _fetch_fppmw_monthly_csv(): uses remotezip (HTTP Range requests) to
extract only the target day's inner ZIP from the multi-GB monthly bundle
without downloading the whole file.
- fetch_csv_for_date() dispatches to _fetch_fppmw_monthly_csv for
'fppmw_monthly' kind; downloads normally for 'fpp_bundle'/'fppmw_daily'.
- New skip_future_check parameter for D+1 secondary fetches.
data_processor.py:
- _find_duid_col(): accepts both 'FPP_UNITID' (FPP format, ≤Jan 10 2026)
and 'DUID' (FPPMW archive format, Mar 2025–Jan 2026).
- filter_and_process() now accepts optional csv_bytes_next (next day's
CSV) and target_date; merges both, then filters to the 24-hour NEM
market day window: 04:00 UTC target_date → 04:00 UTC target_date+1.
- Datetime output format: 'YYYY-MM-DD HH:MM:SS' (space not T, no µs).
config.py:
- Remove FPP_UNITID from REQUIRED_COLUMNS (filter key, not output column).
api.py:
- _fetch_day_pair(): fetches CSV for D and D+1 concurrently; D+1 failure
is non-fatal (partial day data is returned gracefully).
- All three data endpoints (/data, /download/csv, /download/parquet) now
call _fetch_day_pair + filter_and_process with both CSVs.
requirements.txt: remotezip==0.12.2 already added.
https://claude.ai/code/session_01Vm5dTYdRf99LZ9MfB86vjx
- app/config.py +2 -2
- app/routers/api.py +32 -7
- app/services/aemo_fetcher.py +198 -137
- app/services/data_processor.py +85 -28
- requirements.txt +1 -0
|
@@ -19,11 +19,11 @@ AEMO_READ_TIMEOUT = 60
|
|
| 19 |
ANALYTICS_TOKEN = os.environ.get("ANALYTICS_TOKEN", "changeme")
|
| 20 |
ANALYTICS_DB_PATH = "/tmp/analytics.db"
|
| 21 |
|
| 22 |
-
# Columns to keep from the raw CSV
|
|
|
|
| 23 |
REQUIRED_COLUMNS = [
|
| 24 |
"INTERVAL_DATETIME",
|
| 25 |
"MEASUREMENT_DATETIME",
|
| 26 |
-
"FPP_UNITID",
|
| 27 |
"MEASURED_MW",
|
| 28 |
"MW_QUALITY_FLAG",
|
| 29 |
]
|
|
|
|
| 19 |
ANALYTICS_TOKEN = os.environ.get("ANALYTICS_TOKEN", "changeme")
|
| 20 |
ANALYTICS_DB_PATH = "/tmp/analytics.db"
|
| 21 |
|
| 22 |
+
# Columns to keep from the raw CSV (unit identifier used only for filtering,
|
| 23 |
+
# not included in output)
|
| 24 |
REQUIRED_COLUMNS = [
|
| 25 |
"INTERVAL_DATETIME",
|
| 26 |
"MEASUREMENT_DATETIME",
|
|
|
|
| 27 |
"MEASURED_MW",
|
| 28 |
"MW_QUALITY_FLAG",
|
| 29 |
]
|
|
@@ -2,9 +2,12 @@
|
|
| 2 |
REST API endpoints for BESS SCADA data.
|
| 3 |
"""
|
| 4 |
import json
|
| 5 |
-
|
|
|
|
| 6 |
from pathlib import Path
|
| 7 |
|
|
|
|
|
|
|
| 8 |
from fastapi import APIRouter, HTTPException, Query, Request
|
| 9 |
from fastapi.responses import Response
|
| 10 |
|
|
@@ -54,6 +57,28 @@ def get_quality_flags():
|
|
| 54 |
return json.loads(flags_file.read_text())
|
| 55 |
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
@router.get("/data")
|
| 58 |
async def get_data(
|
| 59 |
request: Request,
|
|
@@ -68,8 +93,8 @@ async def get_data(
|
|
| 68 |
ip = _get_ip(request)
|
| 69 |
|
| 70 |
try:
|
| 71 |
-
csv_bytes = await
|
| 72 |
-
df = filter_and_process(csv_bytes, duid)
|
| 73 |
summary = compute_summary(df)
|
| 74 |
records = to_json_records(df, max_rows=5000)
|
| 75 |
log_request(ip, duid, date, "view")
|
|
@@ -98,8 +123,8 @@ async def download_csv(
|
|
| 98 |
ip = _get_ip(request)
|
| 99 |
|
| 100 |
try:
|
| 101 |
-
csv_bytes = await
|
| 102 |
-
df = filter_and_process(csv_bytes, duid)
|
| 103 |
output = to_csv_bytes(df)
|
| 104 |
log_request(ip, duid, date, "download_csv")
|
| 105 |
filename = f"BESS_SCADA_{duid}_{date}.csv"
|
|
@@ -125,8 +150,8 @@ async def download_parquet(
|
|
| 125 |
ip = _get_ip(request)
|
| 126 |
|
| 127 |
try:
|
| 128 |
-
csv_bytes = await
|
| 129 |
-
df = filter_and_process(csv_bytes, duid)
|
| 130 |
output = to_parquet_bytes(df)
|
| 131 |
log_request(ip, duid, date, "download_parquet")
|
| 132 |
filename = f"BESS_SCADA_{duid}_{date}.parquet"
|
|
|
|
| 2 |
REST API endpoints for BESS SCADA data.
|
| 3 |
"""
|
| 4 |
import json
|
| 5 |
+
import logging
|
| 6 |
+
from datetime import date, datetime, timedelta
|
| 7 |
from pathlib import Path
|
| 8 |
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
from fastapi import APIRouter, HTTPException, Query, Request
|
| 12 |
from fastapi.responses import Response
|
| 13 |
|
|
|
|
| 57 |
return json.loads(flags_file.read_text())
|
| 58 |
|
| 59 |
|
| 60 |
+
async def _fetch_day_pair(target_date: date, duid: str) -> tuple[bytes, bytes | None]:
|
| 61 |
+
"""
|
| 62 |
+
Fetch CSV bytes for target_date and, if available, for target_date + 1 day.
|
| 63 |
+
|
| 64 |
+
The NEM market day (04:00 UTC → 04:00 UTC next day) spans two calendar-day
|
| 65 |
+
files, so both are needed for complete 24-hour coverage. The next-day file
|
| 66 |
+
is fetched with skip_future_check=True (it may be today's date) and any
|
| 67 |
+
error is silently swallowed — partial data is acceptable.
|
| 68 |
+
"""
|
| 69 |
+
import asyncio as _asyncio
|
| 70 |
+
csv_bytes = await fetch_csv_for_date(target_date, duid)
|
| 71 |
+
|
| 72 |
+
next_date = target_date + timedelta(days=1)
|
| 73 |
+
csv_next: bytes | None = None
|
| 74 |
+
try:
|
| 75 |
+
csv_next = await fetch_csv_for_date(next_date, duid, skip_future_check=True)
|
| 76 |
+
except Exception as exc:
|
| 77 |
+
logger.debug("Next-day CSV not available for %s (%s): %s", duid, next_date, exc)
|
| 78 |
+
|
| 79 |
+
return csv_bytes, csv_next
|
| 80 |
+
|
| 81 |
+
|
| 82 |
@router.get("/data")
|
| 83 |
async def get_data(
|
| 84 |
request: Request,
|
|
|
|
| 93 |
ip = _get_ip(request)
|
| 94 |
|
| 95 |
try:
|
| 96 |
+
csv_bytes, csv_next = await _fetch_day_pair(target_date, duid)
|
| 97 |
+
df = filter_and_process(csv_bytes, duid, target_date, csv_next)
|
| 98 |
summary = compute_summary(df)
|
| 99 |
records = to_json_records(df, max_rows=5000)
|
| 100 |
log_request(ip, duid, date, "view")
|
|
|
|
| 123 |
ip = _get_ip(request)
|
| 124 |
|
| 125 |
try:
|
| 126 |
+
csv_bytes, csv_next = await _fetch_day_pair(target_date, duid)
|
| 127 |
+
df = filter_and_process(csv_bytes, duid, target_date, csv_next)
|
| 128 |
output = to_csv_bytes(df)
|
| 129 |
log_request(ip, duid, date, "download_csv")
|
| 130 |
filename = f"BESS_SCADA_{duid}_{date}.csv"
|
|
|
|
| 150 |
ip = _get_ip(request)
|
| 151 |
|
| 152 |
try:
|
| 153 |
+
csv_bytes, csv_next = await _fetch_day_pair(target_date, duid)
|
| 154 |
+
df = filter_and_process(csv_bytes, duid, target_date, csv_next)
|
| 155 |
output = to_parquet_bytes(df)
|
| 156 |
log_request(ip, duid, date, "download_parquet")
|
| 157 |
filename = f"BESS_SCADA_{duid}_{date}.parquet"
|
|
@@ -5,37 +5,46 @@ File naming conventions on NEMWEB:
|
|
| 5 |
|
| 6 |
Up to 10 Jan 2026 — FPP format:
|
| 7 |
PUBLIC_NEXT_DAY_FPP_YYYYMMDD[_<suffix>].zip
|
| 8 |
-
|
| 9 |
|
| 10 |
From 11 Jan 2026 — FPPMW format (nested ZIP):
|
| 11 |
PUBLIC_NEXT_DAY_FPPMW_YYYYMMDD[_<suffix>].zip
|
| 12 |
-
|
| 13 |
-
Both are named with the FPPMW prefix.
|
| 14 |
|
| 15 |
Current directory (https://www.nemweb.com.au/REPORTS/Current/FPPDAILY/):
|
| 16 |
-
Individual daily files (FPP or FPPMW), one per market day.
|
|
|
|
| 17 |
|
| 18 |
Archive directory (https://nemweb.com.au/Reports/Archive/FPPDAILY/):
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
"""
|
|
|
|
| 32 |
import io
|
| 33 |
import logging
|
| 34 |
import re
|
| 35 |
import zipfile
|
| 36 |
-
from datetime import date, datetime
|
| 37 |
|
| 38 |
import httpx
|
|
|
|
| 39 |
|
| 40 |
from app.config import (
|
| 41 |
AEMO_ARCHIVE_URL,
|
|
@@ -47,7 +56,6 @@ from app.config import (
|
|
| 47 |
|
| 48 |
logger = logging.getLogger(__name__)
|
| 49 |
|
| 50 |
-
# Mimic a browser to avoid being blocked by NEMWEB
|
| 51 |
_HEADERS = {
|
| 52 |
"User-Agent": (
|
| 53 |
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
@@ -64,13 +72,14 @@ class AEMOFetchError(Exception):
|
|
| 64 |
|
| 65 |
|
| 66 |
def _is_current(target_date: date) -> bool:
|
| 67 |
-
"""
|
| 68 |
-
Files within the last ~7 days live in /Current/; older files in /Archive/.
|
| 69 |
-
We use 9 days to allow for UTC vs AEST timezone differences and publication lag.
|
| 70 |
-
"""
|
| 71 |
return (date.today() - target_date).days <= 9
|
| 72 |
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
async def _list_directory(base_url: str, client: httpx.AsyncClient) -> list[str]:
|
| 75 |
"""Fetch the HTML directory listing and return all ZIP filenames."""
|
| 76 |
logger.info("Listing directory: %s", base_url)
|
|
@@ -100,16 +109,7 @@ async def _list_directory(base_url: str, client: httpx.AsyncClient) -> list[str]
|
|
| 100 |
|
| 101 |
|
| 102 |
def _extract_zip_date(filename: str) -> date | None:
|
| 103 |
-
"""
|
| 104 |
-
Extract the YYYYMMDD date embedded in a NEMWEB ZIP filename.
|
| 105 |
-
|
| 106 |
-
Handles both naming conventions and both formats:
|
| 107 |
-
PUBLIC_NEXT_DAY_FPP_20260110_1234567890123456.zip (Current, FPP, with suffix)
|
| 108 |
-
PUBLIC_NEXT_DAY_FPPMW_20260224_1234567890123456.zip (Current, FPPMW, with suffix)
|
| 109 |
-
PUBLIC_NEXT_DAY_FPP_20250228.zip (Archive bundle, no suffix)
|
| 110 |
-
"""
|
| 111 |
-
# Match 8 consecutive digits that appear after an underscore and before
|
| 112 |
-
# either another underscore, a dot, or end-of-string.
|
| 113 |
m = re.search(r'_(\d{8})(?:[_.]|$)', filename)
|
| 114 |
if m:
|
| 115 |
try:
|
|
@@ -120,7 +120,6 @@ def _extract_zip_date(filename: str) -> date | None:
|
|
| 120 |
|
| 121 |
|
| 122 |
def _date_str(target_date: date) -> str:
|
| 123 |
-
"""Return date as YYYYMMDD string."""
|
| 124 |
return target_date.strftime("%Y%m%d")
|
| 125 |
|
| 126 |
|
|
@@ -129,69 +128,70 @@ async def _find_file_in(
|
|
| 129 |
target_date: date,
|
| 130 |
date_str: str,
|
| 131 |
url: str,
|
| 132 |
-
) -> tuple[str, str] | None:
|
| 133 |
"""
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
|
|
|
|
|
|
| 143 |
"""
|
| 144 |
-
# 1. Exact
|
| 145 |
exact = [f for f in filenames if date_str in f]
|
| 146 |
if exact:
|
| 147 |
-
#
|
| 148 |
-
# FPP files in the same directory. Always prefer FPPMW over FPP so we
|
| 149 |
-
# get the correct metered-data file, not a related forecast/other file.
|
| 150 |
fppmw_exact = [f for f in exact if "FPPMW" in f]
|
| 151 |
chosen = fppmw_exact[0] if fppmw_exact else exact[0]
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
#
|
| 157 |
-
#
|
| 158 |
-
#
|
| 159 |
-
#
|
| 160 |
-
|
|
|
|
| 161 |
candidates: list[tuple[date, str]] = []
|
| 162 |
-
for f in
|
| 163 |
zip_date = _extract_zip_date(f)
|
| 164 |
-
if zip_date is
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
if candidates:
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
| 169 |
best_date, best_file = candidates[0]
|
|
|
|
| 170 |
logger.info(
|
| 171 |
-
"
|
| 172 |
-
url, best_file, best_date, target_date,
|
| 173 |
)
|
| 174 |
-
return best_file, url
|
| 175 |
|
| 176 |
return None
|
| 177 |
|
| 178 |
|
| 179 |
async def _find_file(
|
| 180 |
-
target_date: date, date_str: str,
|
| 181 |
-
) -> tuple[str, str]:
|
| 182 |
"""
|
| 183 |
Search Current then Archive for a ZIP covering target_date.
|
| 184 |
-
Returns (filename,
|
| 185 |
-
Raises AEMOFetchError if nothing is found.
|
| 186 |
"""
|
| 187 |
-
# Current directory keeps a long history (observed: 180+ files going back
|
| 188 |
-
# months), so always try it first — it has single-file exact matches which
|
| 189 |
-
# are faster and more reliable than hunting through archive bundles.
|
| 190 |
-
# Fall back to Archive for older dates not yet deleted from Current.
|
| 191 |
-
urls_to_try = [AEMO_CURRENT_URL, AEMO_ARCHIVE_URL]
|
| 192 |
-
|
| 193 |
last_filenames: list[str] = []
|
| 194 |
-
for url in
|
| 195 |
try:
|
| 196 |
filenames = await _list_directory(url, client)
|
| 197 |
except AEMOFetchError as e:
|
|
@@ -213,26 +213,145 @@ async def _find_file(
|
|
| 213 |
)
|
| 214 |
|
| 215 |
|
| 216 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
"""
|
| 218 |
Download FPPDAILY data for target_date and return raw CSV bytes.
|
| 219 |
Raises AEMOFetchError if unavailable.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
"""
|
| 221 |
if target_date < DATA_START_DATE:
|
| 222 |
raise AEMOFetchError(
|
| 223 |
f"Data is only available from {DATA_START_DATE.strftime('%d %B %Y')} "
|
| 224 |
"when the FPP scheme commenced."
|
| 225 |
)
|
| 226 |
-
if target_date >= date.today():
|
| 227 |
raise AEMOFetchError("Cannot request data for today or future dates.")
|
| 228 |
|
| 229 |
-
base_url = AEMO_CURRENT_URL if _is_current(target_date) else AEMO_ARCHIVE_URL
|
| 230 |
date_str = _date_str(target_date)
|
| 231 |
|
| 232 |
async with httpx.AsyncClient() as client:
|
| 233 |
-
filename, found_url = await _find_file(target_date, date_str,
|
| 234 |
zip_url = found_url + filename
|
| 235 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
logger.info("Downloading: %s", zip_url)
|
| 237 |
try:
|
| 238 |
resp = await client.get(
|
|
@@ -252,67 +371,9 @@ async def fetch_csv_for_date(target_date: date, duid: str) -> bytes:
|
|
| 252 |
f"Could not download data file (HTTP {e.response.status_code})."
|
| 253 |
)
|
| 254 |
|
| 255 |
-
# Extract the correct daily CSV from the ZIP.
|
| 256 |
-
# FPP format (up to Jan 10 2026): CSV lives directly inside the ZIP.
|
| 257 |
-
# FPPMW format (from Jan 11 2026): outer ZIP wraps an inner ZIP which
|
| 258 |
-
# contains the CSV (NEMWEB nested-ZIP convention).
|
| 259 |
-
# Archive FPP ZIPs contain multiple CSVs (one per day); match by date.
|
| 260 |
try:
|
| 261 |
-
|
| 262 |
-
with zipfile.ZipFile(zip_bytes) as outer_zf:
|
| 263 |
-
all_entries = outer_zf.namelist()
|
| 264 |
-
all_csvs = [n for n in all_entries if n.lower().endswith(".csv")]
|
| 265 |
-
|
| 266 |
-
# FPPMW nested-ZIP: outer contains an inner ZIP, not a CSV.
|
| 267 |
-
inner_zf = None
|
| 268 |
-
if not all_csvs:
|
| 269 |
-
inner_zips = [n for n in all_entries if n.lower().endswith(".zip")]
|
| 270 |
-
if not inner_zips:
|
| 271 |
-
raise AEMOFetchError("ZIP file contained no CSV files.")
|
| 272 |
-
logger.info("Nested ZIP (FPPMW format); opening: %s", inner_zips[0])
|
| 273 |
-
inner_zf = zipfile.ZipFile(io.BytesIO(outer_zf.read(inner_zips[0])))
|
| 274 |
-
all_entries = inner_zf.namelist()
|
| 275 |
-
all_csvs = [n for n in all_entries if n.lower().endswith(".csv")]
|
| 276 |
-
if not all_csvs:
|
| 277 |
-
inner_zf.close()
|
| 278 |
-
raise AEMOFetchError("Inner ZIP contained no CSV files.")
|
| 279 |
-
|
| 280 |
-
active_zf = inner_zf if inner_zf is not None else outer_zf
|
| 281 |
-
try:
|
| 282 |
-
# Prefer the CSV whose name contains the target date
|
| 283 |
-
daily_csvs = [n for n in all_csvs if date_str in n]
|
| 284 |
-
if daily_csvs:
|
| 285 |
-
csv_name = daily_csvs[0]
|
| 286 |
-
elif len(all_csvs) == 1:
|
| 287 |
-
# Single-CSV ZIP (Current) — use it directly
|
| 288 |
-
csv_name = all_csvs[0]
|
| 289 |
-
else:
|
| 290 |
-
# Multi-CSV archive but no name match — report available dates
|
| 291 |
-
all_csvs_sorted = sorted(all_csvs, reverse=True)
|
| 292 |
-
logger.warning(
|
| 293 |
-
"No CSV matching %s in %s; available: %s",
|
| 294 |
-
date_str, filename, all_csvs_sorted[:5],
|
| 295 |
-
)
|
| 296 |
-
raise AEMOFetchError(
|
| 297 |
-
f"No data file found for {target_date.strftime('%d %B %Y')} "
|
| 298 |
-
f"inside the archive ZIP. Available dates: "
|
| 299 |
-
+ ", ".join(
|
| 300 |
-
m.group(1)
|
| 301 |
-
for n in all_csvs_sorted[:5]
|
| 302 |
-
for m in [re.search(r'(\d{8})', n)]
|
| 303 |
-
if m
|
| 304 |
-
)
|
| 305 |
-
)
|
| 306 |
-
|
| 307 |
-
logger.info("Extracting CSV: %s", csv_name)
|
| 308 |
-
csv_bytes = active_zf.read(csv_name)
|
| 309 |
-
finally:
|
| 310 |
-
if inner_zf is not None:
|
| 311 |
-
inner_zf.close()
|
| 312 |
-
|
| 313 |
except zipfile.BadZipFile:
|
| 314 |
raise AEMOFetchError(
|
| 315 |
"Downloaded file appears to be corrupt. Please try again."
|
| 316 |
)
|
| 317 |
-
|
| 318 |
-
return csv_bytes
|
|
|
|
| 5 |
|
| 6 |
Up to 10 Jan 2026 — FPP format:
|
| 7 |
PUBLIC_NEXT_DAY_FPP_YYYYMMDD[_<suffix>].zip
|
| 8 |
+
ZIP contains the daily CSV directly.
|
| 9 |
|
| 10 |
From 11 Jan 2026 — FPPMW format (nested ZIP):
|
| 11 |
PUBLIC_NEXT_DAY_FPPMW_YYYYMMDD[_<suffix>].zip
|
| 12 |
+
Outer ZIP → inner ZIP → CSV.
|
|
|
|
| 13 |
|
| 14 |
Current directory (https://www.nemweb.com.au/REPORTS/Current/FPPDAILY/):
|
| 15 |
+
Individual daily files (FPP or FPPMW naming), one per market day.
|
| 16 |
+
Keeps a long rolling history (180+ files observed).
|
| 17 |
|
| 18 |
Archive directory (https://nemweb.com.au/Reports/Archive/FPPDAILY/):
|
| 19 |
+
|
| 20 |
+
FPP monthly bundles (data up to ~Mar 2025):
|
| 21 |
+
PUBLIC_NEXT_DAY_FPP_YYYYMMDD.zip
|
| 22 |
+
YYYYMMDD = bundle start date. Contains daily CSVs directly.
|
| 23 |
+
Select by largest start-date <= target.
|
| 24 |
+
|
| 25 |
+
FPPMW monthly bundles (Mar 2025 – Jan 2026):
|
| 26 |
+
PUBLIC_NEXT_DAY_FPPMW_YYYYMMDD.zip
|
| 27 |
+
YYYYMMDD = last day of the PREVIOUS month
|
| 28 |
+
e.g. 20250228 → bundle covering all of March 2025
|
| 29 |
+
20251231 → bundle covering all of January 2026
|
| 30 |
+
Contains per-day inner ZIPs; each inner ZIP contains the daily CSV.
|
| 31 |
+
These files are several GB — HTTP Range requests (remotezip) are used
|
| 32 |
+
to fetch only the target day's inner ZIP without downloading the whole
|
| 33 |
+
bundle.
|
| 34 |
+
|
| 35 |
+
Individual FPPMW daily archive copies (recent dates, 2026+):
|
| 36 |
+
PUBLIC_NEXT_DAY_FPPMW_YYYYMMDD.zip where YYYYMMDD is NOT an end-of-month
|
| 37 |
+
date. Treated as individual daily files, not bundles.
|
| 38 |
"""
|
| 39 |
+
import asyncio
|
| 40 |
import io
|
| 41 |
import logging
|
| 42 |
import re
|
| 43 |
import zipfile
|
| 44 |
+
from datetime import date, datetime, timedelta
|
| 45 |
|
| 46 |
import httpx
|
| 47 |
+
from remotezip import RemoteZip
|
| 48 |
|
| 49 |
from app.config import (
|
| 50 |
AEMO_ARCHIVE_URL,
|
|
|
|
| 56 |
|
| 57 |
logger = logging.getLogger(__name__)
|
| 58 |
|
|
|
|
| 59 |
_HEADERS = {
|
| 60 |
"User-Agent": (
|
| 61 |
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
|
|
| 72 |
|
| 73 |
|
| 74 |
def _is_current(target_date: date) -> bool:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
return (date.today() - target_date).days <= 9
|
| 76 |
|
| 77 |
|
| 78 |
+
def _is_month_end(d: date) -> bool:
|
| 79 |
+
"""True if d is the last calendar day of its month."""
|
| 80 |
+
return (d + timedelta(days=1)).month != d.month
|
| 81 |
+
|
| 82 |
+
|
| 83 |
async def _list_directory(base_url: str, client: httpx.AsyncClient) -> list[str]:
|
| 84 |
"""Fetch the HTML directory listing and return all ZIP filenames."""
|
| 85 |
logger.info("Listing directory: %s", base_url)
|
|
|
|
| 109 |
|
| 110 |
|
| 111 |
def _extract_zip_date(filename: str) -> date | None:
|
| 112 |
+
"""Extract the YYYYMMDD date embedded in a NEMWEB ZIP filename."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
m = re.search(r'_(\d{8})(?:[_.]|$)', filename)
|
| 114 |
if m:
|
| 115 |
try:
|
|
|
|
| 120 |
|
| 121 |
|
| 122 |
def _date_str(target_date: date) -> str:
|
|
|
|
| 123 |
return target_date.strftime("%Y%m%d")
|
| 124 |
|
| 125 |
|
|
|
|
| 128 |
target_date: date,
|
| 129 |
date_str: str,
|
| 130 |
url: str,
|
| 131 |
+
) -> tuple[str, str, str] | None:
|
| 132 |
"""
|
| 133 |
+
Return (filename, url, kind) for the best ZIP to use, or None.
|
| 134 |
+
|
| 135 |
+
kind values
|
| 136 |
+
-----------
|
| 137 |
+
"fppmw_daily" Individual FPPMW file (Current or non-bundle Archive).
|
| 138 |
+
Structure: outer ZIP → inner ZIP → CSV.
|
| 139 |
+
"fppmw_monthly" FPPMW monthly archive bundle (multi-GB).
|
| 140 |
+
Structure: outer ZIP → per-day inner ZIPs → CSV.
|
| 141 |
+
Uses HTTP Range requests to avoid full download.
|
| 142 |
+
"fpp_bundle" FPP monthly archive bundle.
|
| 143 |
+
Structure: outer ZIP → per-day CSVs directly.
|
| 144 |
"""
|
| 145 |
+
# 1. Exact date match
|
| 146 |
exact = [f for f in filenames if date_str in f]
|
| 147 |
if exact:
|
| 148 |
+
# Prefer FPPMW over FPP when both exist for the same date
|
|
|
|
|
|
|
| 149 |
fppmw_exact = [f for f in exact if "FPPMW" in f]
|
| 150 |
chosen = fppmw_exact[0] if fppmw_exact else exact[0]
|
| 151 |
+
kind = "fppmw_daily" if "FPPMW" in chosen else "fpp_bundle"
|
| 152 |
+
logger.info("Exact match at %s: %s (kind=%s)", url, chosen, kind)
|
| 153 |
+
return chosen, url, kind
|
| 154 |
+
|
| 155 |
+
# 2. Archive bundle match: largest embedded date <= target_date.
|
| 156 |
+
# FPP files: always treated as monthly bundles.
|
| 157 |
+
# FPPMW files: only treated as a monthly bundle when their embedded
|
| 158 |
+
# date is an end-of-month date (= last day of previous month
|
| 159 |
+
# convention). Non-month-end FPPMW dates are individual daily
|
| 160 |
+
# archive copies and must not be used as bundles.
|
| 161 |
candidates: list[tuple[date, str]] = []
|
| 162 |
+
for f in filenames:
|
| 163 |
zip_date = _extract_zip_date(f)
|
| 164 |
+
if zip_date is None or zip_date > target_date:
|
| 165 |
+
continue
|
| 166 |
+
if "FPPMW" in f and not _is_month_end(zip_date):
|
| 167 |
+
continue # individual daily archive copy, not a monthly bundle
|
| 168 |
+
candidates.append((zip_date, f))
|
| 169 |
|
| 170 |
if candidates:
|
| 171 |
+
# Primary sort: latest date first.
|
| 172 |
+
# Secondary: prefer FPPMW over FPP for the same date (FPPMW has
|
| 173 |
+
# actual metered data; FPP has forecast/price data).
|
| 174 |
+
candidates.sort(key=lambda x: (x[0], "FPPMW" in x[1]), reverse=True)
|
| 175 |
best_date, best_file = candidates[0]
|
| 176 |
+
kind = "fppmw_monthly" if "FPPMW" in best_file else "fpp_bundle"
|
| 177 |
logger.info(
|
| 178 |
+
"Bundle match at %s: %s (start %s covers %s, kind=%s)",
|
| 179 |
+
url, best_file, best_date, target_date, kind,
|
| 180 |
)
|
| 181 |
+
return best_file, url, kind
|
| 182 |
|
| 183 |
return None
|
| 184 |
|
| 185 |
|
| 186 |
async def _find_file(
|
| 187 |
+
target_date: date, date_str: str, client: httpx.AsyncClient
|
| 188 |
+
) -> tuple[str, str, str]:
|
| 189 |
"""
|
| 190 |
Search Current then Archive for a ZIP covering target_date.
|
| 191 |
+
Returns (filename, base_url, kind).
|
|
|
|
| 192 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
last_filenames: list[str] = []
|
| 194 |
+
for url in [AEMO_CURRENT_URL, AEMO_ARCHIVE_URL]:
|
| 195 |
try:
|
| 196 |
filenames = await _list_directory(url, client)
|
| 197 |
except AEMOFetchError as e:
|
|
|
|
| 213 |
)
|
| 214 |
|
| 215 |
|
| 216 |
+
def _extract_csv_from_zip(
|
| 217 |
+
zip_content: bytes,
|
| 218 |
+
filename: str,
|
| 219 |
+
date_str: str,
|
| 220 |
+
target_date: date,
|
| 221 |
+
) -> bytes:
|
| 222 |
+
"""
|
| 223 |
+
Extract the daily CSV from a downloaded ZIP.
|
| 224 |
+
|
| 225 |
+
fpp_bundle: outer ZIP contains per-day CSVs directly.
|
| 226 |
+
fppmw_daily: outer ZIP → inner ZIP → CSV (nested structure).
|
| 227 |
+
"""
|
| 228 |
+
zip_bytes = io.BytesIO(zip_content)
|
| 229 |
+
with zipfile.ZipFile(zip_bytes) as outer_zf:
|
| 230 |
+
all_entries = outer_zf.namelist()
|
| 231 |
+
all_csvs = [n for n in all_entries if n.lower().endswith(".csv")]
|
| 232 |
+
|
| 233 |
+
inner_zf = None
|
| 234 |
+
if not all_csvs:
|
| 235 |
+
# FPPMW daily: outer ZIP wraps an inner ZIP
|
| 236 |
+
inner_zips = [n for n in all_entries if n.lower().endswith(".zip")]
|
| 237 |
+
if not inner_zips:
|
| 238 |
+
raise AEMOFetchError("ZIP file contained no CSV files.")
|
| 239 |
+
logger.info("Nested ZIP (FPPMW daily); opening: %s", inner_zips[0])
|
| 240 |
+
inner_zf = zipfile.ZipFile(io.BytesIO(outer_zf.read(inner_zips[0])))
|
| 241 |
+
all_entries = inner_zf.namelist()
|
| 242 |
+
all_csvs = [n for n in all_entries if n.lower().endswith(".csv")]
|
| 243 |
+
if not all_csvs:
|
| 244 |
+
inner_zf.close()
|
| 245 |
+
raise AEMOFetchError("Inner ZIP contained no CSV files.")
|
| 246 |
+
|
| 247 |
+
active_zf = inner_zf if inner_zf is not None else outer_zf
|
| 248 |
+
try:
|
| 249 |
+
daily_csvs = [n for n in all_csvs if date_str in n]
|
| 250 |
+
if daily_csvs:
|
| 251 |
+
csv_name = daily_csvs[0]
|
| 252 |
+
elif len(all_csvs) == 1:
|
| 253 |
+
csv_name = all_csvs[0]
|
| 254 |
+
else:
|
| 255 |
+
all_csvs_sorted = sorted(all_csvs, reverse=True)
|
| 256 |
+
logger.warning(
|
| 257 |
+
"No CSV matching %s in %s; available: %s",
|
| 258 |
+
date_str, filename, all_csvs_sorted[:5],
|
| 259 |
+
)
|
| 260 |
+
raise AEMOFetchError(
|
| 261 |
+
f"No data file found for {target_date.strftime('%d %B %Y')} "
|
| 262 |
+
"inside the archive ZIP. Available dates: "
|
| 263 |
+
+ ", ".join(
|
| 264 |
+
m.group(1)
|
| 265 |
+
for n in all_csvs_sorted[:5]
|
| 266 |
+
for m in [re.search(r'(\d{8})', n)]
|
| 267 |
+
if m
|
| 268 |
+
)
|
| 269 |
+
)
|
| 270 |
+
logger.info("Extracting CSV: %s", csv_name)
|
| 271 |
+
return active_zf.read(csv_name)
|
| 272 |
+
finally:
|
| 273 |
+
if inner_zf is not None:
|
| 274 |
+
inner_zf.close()
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
async def _fetch_fppmw_monthly_csv(bundle_url: str, date_str: str) -> bytes:
|
| 278 |
+
"""
|
| 279 |
+
Extract one day's CSV from a large FPPMW monthly archive bundle.
|
| 280 |
+
|
| 281 |
+
Uses HTTP Range requests via remotezip so only the ZIP central directory
|
| 282 |
+
and the specific daily ZIP entry are transferred — not the full multi-GB
|
| 283 |
+
bundle.
|
| 284 |
+
|
| 285 |
+
Bundle structure:
|
| 286 |
+
PUBLIC_NEXT_DAY_FPPMW_YYYYMMDD.zip ← several GB
|
| 287 |
+
PUBLIC_NEXT_DAY_FPPMW_YYYYMMDD_XXXXXXXX.ZIP ← daily ZIP (~MB)
|
| 288 |
+
PUBLIC_NEXT_DAY_FPPMW_YYYYMMDD_XXXXXXXX.CSV
|
| 289 |
+
"""
|
| 290 |
+
def _sync_extract() -> bytes:
|
| 291 |
+
logger.info("Opening FPPMW monthly bundle via HTTP Range: %s", bundle_url)
|
| 292 |
+
with RemoteZip(bundle_url, headers=_HEADERS) as rz:
|
| 293 |
+
daily_zips = [
|
| 294 |
+
name for name in rz.namelist()
|
| 295 |
+
if date_str in name and name.upper().endswith(".ZIP")
|
| 296 |
+
]
|
| 297 |
+
if not daily_zips:
|
| 298 |
+
raise AEMOFetchError(
|
| 299 |
+
f"No entry found for {date_str} in the FPPMW monthly archive."
|
| 300 |
+
)
|
| 301 |
+
daily_zip_name = daily_zips[0]
|
| 302 |
+
logger.info("Downloading daily ZIP entry from bundle: %s", daily_zip_name)
|
| 303 |
+
daily_zip_bytes = io.BytesIO(rz.read(daily_zip_name))
|
| 304 |
+
|
| 305 |
+
with zipfile.ZipFile(daily_zip_bytes) as daily_zf:
|
| 306 |
+
csvs = [n for n in daily_zf.namelist() if n.lower().endswith(".csv")]
|
| 307 |
+
if not csvs:
|
| 308 |
+
raise AEMOFetchError("Daily ZIP from monthly bundle contains no CSV.")
|
| 309 |
+
logger.info("Extracting CSV: %s", csvs[0])
|
| 310 |
+
return daily_zf.read(csvs[0])
|
| 311 |
+
|
| 312 |
+
try:
|
| 313 |
+
return await asyncio.to_thread(_sync_extract)
|
| 314 |
+
except AEMOFetchError:
|
| 315 |
+
raise
|
| 316 |
+
except Exception as exc:
|
| 317 |
+
raise AEMOFetchError(
|
| 318 |
+
f"Failed to read FPPMW monthly archive: {exc}"
|
| 319 |
+
) from exc
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
async def fetch_csv_for_date(
|
| 323 |
+
target_date: date,
|
| 324 |
+
duid: str,
|
| 325 |
+
*,
|
| 326 |
+
skip_future_check: bool = False,
|
| 327 |
+
) -> bytes:
|
| 328 |
"""
|
| 329 |
Download FPPDAILY data for target_date and return raw CSV bytes.
|
| 330 |
Raises AEMOFetchError if unavailable.
|
| 331 |
+
|
| 332 |
+
skip_future_check: set True when fetching D+1 for 24-hour coverage;
|
| 333 |
+
suppresses the "today or future" guard so the call can proceed if the
|
| 334 |
+
next-day file happens to be published already.
|
| 335 |
"""
|
| 336 |
if target_date < DATA_START_DATE:
|
| 337 |
raise AEMOFetchError(
|
| 338 |
f"Data is only available from {DATA_START_DATE.strftime('%d %B %Y')} "
|
| 339 |
"when the FPP scheme commenced."
|
| 340 |
)
|
| 341 |
+
if not skip_future_check and target_date >= date.today():
|
| 342 |
raise AEMOFetchError("Cannot request data for today or future dates.")
|
| 343 |
|
|
|
|
| 344 |
date_str = _date_str(target_date)
|
| 345 |
|
| 346 |
async with httpx.AsyncClient() as client:
|
| 347 |
+
filename, found_url, kind = await _find_file(target_date, date_str, client)
|
| 348 |
zip_url = found_url + filename
|
| 349 |
|
| 350 |
+
# FPPMW monthly bundles are several GB — extract via HTTP Range only.
|
| 351 |
+
if kind == "fppmw_monthly":
|
| 352 |
+
return await _fetch_fppmw_monthly_csv(zip_url, date_str)
|
| 353 |
+
|
| 354 |
+
# fpp_bundle and fppmw_daily: download the (daily-sized) ZIP file.
|
| 355 |
logger.info("Downloading: %s", zip_url)
|
| 356 |
try:
|
| 357 |
resp = await client.get(
|
|
|
|
| 371 |
f"Could not download data file (HTTP {e.response.status_code})."
|
| 372 |
)
|
| 373 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
try:
|
| 375 |
+
return _extract_csv_from_zip(resp.content, filename, date_str, target_date)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
except zipfile.BadZipFile:
|
| 377 |
raise AEMOFetchError(
|
| 378 |
"Downloaded file appears to be corrupt. Please try again."
|
| 379 |
)
|
|
|
|
|
|
|
@@ -2,6 +2,7 @@
|
|
| 2 |
Filters and transforms raw AEMO FPPDAILY CSV bytes using Polars.
|
| 3 |
"""
|
| 4 |
import io
|
|
|
|
| 5 |
|
| 6 |
import polars as pl
|
| 7 |
import pyarrow.parquet as pq
|
|
@@ -14,13 +15,16 @@ class DataProcessingError(Exception):
|
|
| 14 |
pass
|
| 15 |
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
def _parse_aemo_csv(csv_bytes: bytes) -> pl.DataFrame:
|
| 18 |
"""
|
| 19 |
AEMO CSV files have a non-standard header format:
|
| 20 |
-
-
|
| 21 |
-
-
|
| 22 |
-
-
|
| 23 |
-
We need to skip the metadata rows and parse only data rows.
|
| 24 |
"""
|
| 25 |
text = csv_bytes.decode("utf-8", errors="replace")
|
| 26 |
lines = text.splitlines()
|
|
@@ -32,14 +36,11 @@ def _parse_aemo_csv(csv_bytes: bytes) -> pl.DataFrame:
|
|
| 32 |
if not line.strip():
|
| 33 |
continue
|
| 34 |
if line.startswith("I,"):
|
| 35 |
-
# Column header row — strip the leading "I," and use remaining fields
|
| 36 |
parts = line.split(",")
|
| 37 |
-
#
|
| 38 |
-
# The actual column names start at index 3 (after I, table, version)
|
| 39 |
if len(parts) > 3:
|
| 40 |
header = parts[3:]
|
| 41 |
elif line.startswith("D,"):
|
| 42 |
-
# Data row — strip leading "D," and the table/version fields
|
| 43 |
parts = line.split(",")
|
| 44 |
if len(parts) > 3:
|
| 45 |
data_lines.append(parts[3:])
|
|
@@ -49,8 +50,6 @@ def _parse_aemo_csv(csv_bytes: bytes) -> pl.DataFrame:
|
|
| 49 |
if not data_lines:
|
| 50 |
raise DataProcessingError("CSV file contained no data rows.")
|
| 51 |
|
| 52 |
-
# Build a simple CSV string for Polars to parse
|
| 53 |
-
# Pad/trim rows to match header length
|
| 54 |
n_cols = len(header)
|
| 55 |
padded = []
|
| 56 |
for row in data_lines:
|
|
@@ -60,42 +59,101 @@ def _parse_aemo_csv(csv_bytes: bytes) -> pl.DataFrame:
|
|
| 60 |
padded.append(row + [""] * (n_cols - len(row)))
|
| 61 |
|
| 62 |
csv_content = ",".join(header) + "\n" + "\n".join(",".join(r) for r in padded)
|
| 63 |
-
|
| 64 |
-
return df
|
| 65 |
|
| 66 |
|
| 67 |
-
def
|
| 68 |
"""
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
| 70 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
df = _parse_aemo_csv(csv_bytes)
|
| 72 |
|
| 73 |
-
# Check required columns exist
|
| 74 |
missing = [c for c in REQUIRED_COLUMNS if c not in df.columns]
|
| 75 |
if missing:
|
| 76 |
raise DataProcessingError(
|
| 77 |
f"CSV missing expected columns: {missing}. "
|
| 78 |
-
|
| 79 |
)
|
| 80 |
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
if df.is_empty():
|
| 85 |
raise DataProcessingError(
|
| 86 |
f"No data found for DUID '{duid}' on this date. "
|
| 87 |
-
|
| 88 |
)
|
| 89 |
|
| 90 |
-
# Cast types
|
| 91 |
df = df.with_columns([
|
| 92 |
-
pl.col("INTERVAL_DATETIME").str.to_datetime(
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
pl.col("MEASURED_MW").cast(pl.Float64, strict=False),
|
| 95 |
pl.col("MW_QUALITY_FLAG").cast(pl.Int32, strict=False),
|
| 96 |
-
])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
-
return df
|
| 99 |
|
| 100 |
|
| 101 |
def compute_summary(df: pl.DataFrame) -> dict:
|
|
@@ -138,11 +196,10 @@ def to_parquet_bytes(df: pl.DataFrame) -> bytes:
|
|
| 138 |
def to_json_records(df: pl.DataFrame, max_rows: int = 5000) -> list[dict]:
|
| 139 |
"""
|
| 140 |
Return data as a list of dicts for JSON response.
|
| 141 |
-
|
| 142 |
-
Datetimes serialized as ISO strings.
|
| 143 |
"""
|
| 144 |
display_df = df.head(max_rows).with_columns([
|
| 145 |
-
pl.col("INTERVAL_DATETIME").dt.strftime("%Y-%m-%
|
| 146 |
-
pl.col("MEASUREMENT_DATETIME").dt.strftime("%Y-%m-%
|
| 147 |
])
|
| 148 |
return display_df.to_dicts()
|
|
|
|
| 2 |
Filters and transforms raw AEMO FPPDAILY CSV bytes using Polars.
|
| 3 |
"""
|
| 4 |
import io
|
| 5 |
+
from datetime import date, datetime, time as dtime, timedelta
|
| 6 |
|
| 7 |
import polars as pl
|
| 8 |
import pyarrow.parquet as pq
|
|
|
|
| 15 |
pass
|
| 16 |
|
| 17 |
|
| 18 |
+
# NEM market day boundary: 04:00 UTC
|
| 19 |
+
_DAY_START_HOUR = 4
|
| 20 |
+
|
| 21 |
+
|
| 22 |
def _parse_aemo_csv(csv_bytes: bytes) -> pl.DataFrame:
|
| 23 |
"""
|
| 24 |
AEMO CSV files have a non-standard header format:
|
| 25 |
+
- "C,..." rows: comment / metadata (skipped)
|
| 26 |
+
- "I,..." row: column headers (format: I, TABLE, SUBTABLE, col1, col2, …)
|
| 27 |
+
- "D,..." rows: data
|
|
|
|
| 28 |
"""
|
| 29 |
text = csv_bytes.decode("utf-8", errors="replace")
|
| 30 |
lines = text.splitlines()
|
|
|
|
| 36 |
if not line.strip():
|
| 37 |
continue
|
| 38 |
if line.startswith("I,"):
|
|
|
|
| 39 |
parts = line.split(",")
|
| 40 |
+
# Strip the leading "I, TABLE_NAME, SUBTABLE" — columns start at index 3
|
|
|
|
| 41 |
if len(parts) > 3:
|
| 42 |
header = parts[3:]
|
| 43 |
elif line.startswith("D,"):
|
|
|
|
| 44 |
parts = line.split(",")
|
| 45 |
if len(parts) > 3:
|
| 46 |
data_lines.append(parts[3:])
|
|
|
|
| 50 |
if not data_lines:
|
| 51 |
raise DataProcessingError("CSV file contained no data rows.")
|
| 52 |
|
|
|
|
|
|
|
| 53 |
n_cols = len(header)
|
| 54 |
padded = []
|
| 55 |
for row in data_lines:
|
|
|
|
| 59 |
padded.append(row + [""] * (n_cols - len(row)))
|
| 60 |
|
| 61 |
csv_content = ",".join(header) + "\n" + "\n".join(",".join(r) for r in padded)
|
| 62 |
+
return pl.read_csv(io.StringIO(csv_content), infer_schema_length=None)
|
|
|
|
| 63 |
|
| 64 |
|
| 65 |
+
def _find_duid_col(df: pl.DataFrame) -> str:
|
| 66 |
"""
|
| 67 |
+
Return the column name used as the unit identifier.
|
| 68 |
+
|
| 69 |
+
FPP format (up to Jan 10 2026) uses 'FPP_UNITID'.
|
| 70 |
+
FPPMW format (from Jan 11 2026 / Mar 2025 archive) uses 'DUID'.
|
| 71 |
"""
|
| 72 |
+
for candidate in ("FPP_UNITID", "DUID"):
|
| 73 |
+
if candidate in df.columns:
|
| 74 |
+
return candidate
|
| 75 |
+
raise DataProcessingError(
|
| 76 |
+
"Cannot find unit identifier column (expected 'FPP_UNITID' or 'DUID'). "
|
| 77 |
+
"AEMO may have changed the file format."
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _parse_and_filter_duid(csv_bytes: bytes, duid: str) -> pl.DataFrame:
|
| 82 |
+
"""Parse CSV bytes, filter to the given DUID, return raw (uncast) DataFrame."""
|
| 83 |
df = _parse_aemo_csv(csv_bytes)
|
| 84 |
|
|
|
|
| 85 |
missing = [c for c in REQUIRED_COLUMNS if c not in df.columns]
|
| 86 |
if missing:
|
| 87 |
raise DataProcessingError(
|
| 88 |
f"CSV missing expected columns: {missing}. "
|
| 89 |
+
"AEMO may have changed the file format."
|
| 90 |
)
|
| 91 |
|
| 92 |
+
duid_col = _find_duid_col(df)
|
| 93 |
+
return df.filter(pl.col(duid_col) == duid).select(REQUIRED_COLUMNS)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def filter_and_process(
|
| 97 |
+
csv_bytes: bytes,
|
| 98 |
+
duid: str,
|
| 99 |
+
target_date: date,
|
| 100 |
+
csv_bytes_next: bytes | None = None,
|
| 101 |
+
) -> pl.DataFrame:
|
| 102 |
+
"""
|
| 103 |
+
Parse and filter CSV data to the requested DUID, covering the full NEM
|
| 104 |
+
market day: 04:00 UTC on target_date → 04:00 UTC on target_date + 1 day.
|
| 105 |
+
|
| 106 |
+
csv_bytes_next: optional CSV for target_date + 1 day, used to fill the
|
| 107 |
+
second half of the market day (16:00 UTC – 04:00 UTC next day) which
|
| 108 |
+
lives in the following day's file.
|
| 109 |
+
"""
|
| 110 |
+
df = _parse_and_filter_duid(csv_bytes, duid)
|
| 111 |
+
|
| 112 |
+
if csv_bytes_next is not None:
|
| 113 |
+
try:
|
| 114 |
+
df_next = _parse_and_filter_duid(csv_bytes_next, duid)
|
| 115 |
+
df = pl.concat([df, df_next])
|
| 116 |
+
except Exception as exc:
|
| 117 |
+
# Non-fatal: proceed with partial data if next-day file is
|
| 118 |
+
# unavailable or malformed.
|
| 119 |
+
import logging
|
| 120 |
+
logging.getLogger(__name__).warning(
|
| 121 |
+
"Could not merge next-day CSV; using partial data: %s", exc
|
| 122 |
+
)
|
| 123 |
|
| 124 |
if df.is_empty():
|
| 125 |
raise DataProcessingError(
|
| 126 |
f"No data found for DUID '{duid}' on this date. "
|
| 127 |
+
"This unit may not have been operational or eligible for FPP on this date."
|
| 128 |
)
|
| 129 |
|
| 130 |
+
# Cast to proper types
|
| 131 |
df = df.with_columns([
|
| 132 |
+
pl.col("INTERVAL_DATETIME").str.to_datetime(
|
| 133 |
+
format="%Y/%m/%d %H:%M:%S", strict=False
|
| 134 |
+
),
|
| 135 |
+
pl.col("MEASUREMENT_DATETIME").str.to_datetime(
|
| 136 |
+
format="%Y/%m/%d %H:%M:%S", strict=False
|
| 137 |
+
),
|
| 138 |
pl.col("MEASURED_MW").cast(pl.Float64, strict=False),
|
| 139 |
pl.col("MW_QUALITY_FLAG").cast(pl.Int32, strict=False),
|
| 140 |
+
])
|
| 141 |
+
|
| 142 |
+
# Filter to the 24-hour NEM market day window: 04:00 UTC → 04:00 UTC D+1
|
| 143 |
+
day_start = datetime.combine(target_date, dtime(_DAY_START_HOUR, 0, 0))
|
| 144 |
+
day_end = datetime.combine(target_date + timedelta(days=1), dtime(_DAY_START_HOUR, 0, 0))
|
| 145 |
+
df = df.filter(
|
| 146 |
+
(pl.col("MEASUREMENT_DATETIME") >= day_start) &
|
| 147 |
+
(pl.col("MEASUREMENT_DATETIME") < day_end)
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
if df.is_empty():
|
| 151 |
+
raise DataProcessingError(
|
| 152 |
+
f"No data found for DUID '{duid}' in the 04:00–04:00 UTC window for "
|
| 153 |
+
f"this date. The unit may not have been operational on this date."
|
| 154 |
+
)
|
| 155 |
|
| 156 |
+
return df.sort("MEASUREMENT_DATETIME")
|
| 157 |
|
| 158 |
|
| 159 |
def compute_summary(df: pl.DataFrame) -> dict:
|
|
|
|
| 196 |
def to_json_records(df: pl.DataFrame, max_rows: int = 5000) -> list[dict]:
|
| 197 |
"""
|
| 198 |
Return data as a list of dicts for JSON response.
|
| 199 |
+
Datetimes formatted as 'YYYY-MM-DD HH:MM:SS' (no T, no microseconds).
|
|
|
|
| 200 |
"""
|
| 201 |
display_df = df.head(max_rows).with_columns([
|
| 202 |
+
pl.col("INTERVAL_DATETIME").dt.strftime("%Y-%m-%d %H:%M:%S"),
|
| 203 |
+
pl.col("MEASUREMENT_DATETIME").dt.strftime("%Y-%m-%d %H:%M:%S"),
|
| 204 |
])
|
| 205 |
return display_df.to_dicts()
|
|
@@ -6,3 +6,4 @@ pyarrow==18.1.0
|
|
| 6 |
python-multipart==0.0.12
|
| 7 |
aiofiles==24.1.0
|
| 8 |
openpyxl==3.1.5
|
|
|
|
|
|
| 6 |
python-multipart==0.0.12
|
| 7 |
aiofiles==24.1.0
|
| 8 |
openpyxl==3.1.5
|
| 9 |
+
remotezip==0.12.2
|