Spaces:
Running
Running
Commit Β·
6250d00
1
Parent(s): c6457f4
Fix OOM on Era 1 dates: chunk CSV output and pre-filter by DUID
Browse filesEra 1 files are ~1 GB each (9.3M rows for all units). Loading two
and parsing into DataFrames before filtering peaked at 6.8 GB RAM,
causing 500 errors on HuggingFace Spaces.
Two changes:
- _fetch_fppmw_monthly_csv now returns list[bytes] (one per inner ZIP)
instead of concatenating everything, so each chunk is processed and
freed independently
- _prefilter_bytes_by_duid scans raw bytes keeping only rows containing
the target DUID before any DataFrame parsing, reducing 1 GB β ~2 MB
Peak memory: 6.8 GB β 3.5 GB. Result unchanged (21,600 rows, 24h).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- app/routers/api.py +3 -3
- app/services/aemo_fetcher.py +17 -11
- app/services/data_processor.py +51 -21
app/routers/api.py
CHANGED
|
@@ -103,7 +103,7 @@ async def get_data(
|
|
| 103 |
t0 = time.monotonic()
|
| 104 |
try:
|
| 105 |
result = await _fetch_day(target_date, duid)
|
| 106 |
-
df = filter_and_process(result.
|
| 107 |
duration_ms = int((time.monotonic() - t0) * 1000)
|
| 108 |
summary = compute_summary(df)
|
| 109 |
records = to_json_records(df)
|
|
@@ -139,7 +139,7 @@ async def download_csv(
|
|
| 139 |
t0 = time.monotonic()
|
| 140 |
try:
|
| 141 |
result = await _fetch_day(target_date, duid)
|
| 142 |
-
df = filter_and_process(result.
|
| 143 |
duration_ms = int((time.monotonic() - t0) * 1000)
|
| 144 |
output = to_csv_bytes(df)
|
| 145 |
log_request(ip, duid, date, "download_csv", duration_ms=duration_ms, source_type=src)
|
|
@@ -168,7 +168,7 @@ async def download_parquet(
|
|
| 168 |
t0 = time.monotonic()
|
| 169 |
try:
|
| 170 |
result = await _fetch_day(target_date, duid)
|
| 171 |
-
df = filter_and_process(result.
|
| 172 |
duration_ms = int((time.monotonic() - t0) * 1000)
|
| 173 |
output = to_parquet_bytes(df)
|
| 174 |
log_request(ip, duid, date, "download_parquet", duration_ms=duration_ms, source_type=src)
|
|
|
|
| 103 |
t0 = time.monotonic()
|
| 104 |
try:
|
| 105 |
result = await _fetch_day(target_date, duid)
|
| 106 |
+
df = filter_and_process(result.csv_chunks, duid, target_date)
|
| 107 |
duration_ms = int((time.monotonic() - t0) * 1000)
|
| 108 |
summary = compute_summary(df)
|
| 109 |
records = to_json_records(df)
|
|
|
|
| 139 |
t0 = time.monotonic()
|
| 140 |
try:
|
| 141 |
result = await _fetch_day(target_date, duid)
|
| 142 |
+
df = filter_and_process(result.csv_chunks, duid, target_date)
|
| 143 |
duration_ms = int((time.monotonic() - t0) * 1000)
|
| 144 |
output = to_csv_bytes(df)
|
| 145 |
log_request(ip, duid, date, "download_csv", duration_ms=duration_ms, source_type=src)
|
|
|
|
| 168 |
t0 = time.monotonic()
|
| 169 |
try:
|
| 170 |
result = await _fetch_day(target_date, duid)
|
| 171 |
+
df = filter_and_process(result.csv_chunks, duid, target_date)
|
| 172 |
duration_ms = int((time.monotonic() - t0) * 1000)
|
| 173 |
output = to_parquet_bytes(df)
|
| 174 |
log_request(ip, duid, date, "download_parquet", duration_ms=duration_ms, source_type=src)
|
app/services/aemo_fetcher.py
CHANGED
|
@@ -73,8 +73,8 @@ from app.config import (
|
|
| 73 |
|
| 74 |
@dataclass
|
| 75 |
class FetchResult:
|
| 76 |
-
"""Result of a fetch operation: CSV
|
| 77 |
-
|
| 78 |
warnings: list[str] = field(default_factory=list)
|
| 79 |
|
| 80 |
logger = logging.getLogger(__name__)
|
|
@@ -382,10 +382,14 @@ def _extract_csv_from_zip(
|
|
| 382 |
|
| 383 |
async def _fetch_fppmw_monthly_csv(
|
| 384 |
bundle_url: str, search_strs: list[str]
|
| 385 |
-
) -> bytes:
|
| 386 |
"""
|
| 387 |
Extract one NEM market day's CSV data from an FPPMW archive bundle.
|
| 388 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
Uses HTTP Range requests via remotezip so only the ZIP central directory
|
| 390 |
and the required daily ZIP entries are transferred β not the full bundle.
|
| 391 |
|
|
@@ -393,7 +397,7 @@ async def _fetch_fppmw_monthly_csv(
|
|
| 393 |
bundle. For Era 1 this is [D, D+1] (settlement dates); for Era 2+ it
|
| 394 |
is [D+1] (publication date).
|
| 395 |
"""
|
| 396 |
-
def _sync_extract() -> bytes:
|
| 397 |
logger.info("Opening FPPMW archive bundle via HTTP Range: %s", bundle_url)
|
| 398 |
with RemoteZip(bundle_url, headers=_HEADERS) as rz:
|
| 399 |
all_names = rz.namelist()
|
|
@@ -410,7 +414,7 @@ async def _fetch_fppmw_monthly_csv(
|
|
| 410 |
"Downloading %d inner ZIP(s) from bundle: %s", len(daily_zips), daily_zips
|
| 411 |
)
|
| 412 |
|
| 413 |
-
|
| 414 |
for daily_zip_name in daily_zips:
|
| 415 |
daily_zip_bytes = io.BytesIO(rz.read(daily_zip_name))
|
| 416 |
with zipfile.ZipFile(daily_zip_bytes) as daily_zf:
|
|
@@ -424,14 +428,15 @@ async def _fetch_fppmw_monthly_csv(
|
|
| 424 |
)
|
| 425 |
continue
|
| 426 |
logger.info("Extracting CSV(s) from %s: %s", daily_zip_name, csvs)
|
| 427 |
-
|
| 428 |
-
|
|
|
|
| 429 |
|
| 430 |
-
if not
|
| 431 |
raise AEMOFetchError(
|
| 432 |
"All inner ZIPs from monthly bundle contained no CSV files."
|
| 433 |
)
|
| 434 |
-
return
|
| 435 |
|
| 436 |
try:
|
| 437 |
return await asyncio.to_thread(_sync_extract)
|
|
@@ -500,8 +505,9 @@ async def fetch_csv_for_date(
|
|
| 500 |
|
| 501 |
# FPPMW archive bundles: extract via HTTP Range using search_strs
|
| 502 |
# to locate the target day's inner ZIP(s) within the bundle.
|
|
|
|
| 503 |
if kind == "fppmw_monthly":
|
| 504 |
-
csv_parts.
|
| 505 |
await _fetch_fppmw_monthly_csv(zip_url, search_strs)
|
| 506 |
)
|
| 507 |
continue
|
|
@@ -539,4 +545,4 @@ async def fetch_csv_for_date(
|
|
| 539 |
"Downloaded file appears to be corrupt. Please try again."
|
| 540 |
)
|
| 541 |
|
| 542 |
-
return FetchResult(
|
|
|
|
| 73 |
|
| 74 |
@dataclass
|
| 75 |
class FetchResult:
|
| 76 |
+
"""Result of a fetch operation: CSV byte chunks plus any warnings."""
|
| 77 |
+
csv_chunks: list[bytes] = field(default_factory=list)
|
| 78 |
warnings: list[str] = field(default_factory=list)
|
| 79 |
|
| 80 |
logger = logging.getLogger(__name__)
|
|
|
|
| 382 |
|
| 383 |
async def _fetch_fppmw_monthly_csv(
|
| 384 |
bundle_url: str, search_strs: list[str]
|
| 385 |
+
) -> list[bytes]:
|
| 386 |
"""
|
| 387 |
Extract one NEM market day's CSV data from an FPPMW archive bundle.
|
| 388 |
|
| 389 |
+
Returns a list of CSV byte chunks β one per inner ZIP β so that the
|
| 390 |
+
caller can process each independently without holding all data in memory
|
| 391 |
+
at once. This is critical for Era 1 where each inner ZIP is ~1 GB.
|
| 392 |
+
|
| 393 |
Uses HTTP Range requests via remotezip so only the ZIP central directory
|
| 394 |
and the required daily ZIP entries are transferred β not the full bundle.
|
| 395 |
|
|
|
|
| 397 |
bundle. For Era 1 this is [D, D+1] (settlement dates); for Era 2+ it
|
| 398 |
is [D+1] (publication date).
|
| 399 |
"""
|
| 400 |
+
def _sync_extract() -> list[bytes]:
|
| 401 |
logger.info("Opening FPPMW archive bundle via HTTP Range: %s", bundle_url)
|
| 402 |
with RemoteZip(bundle_url, headers=_HEADERS) as rz:
|
| 403 |
all_names = rz.namelist()
|
|
|
|
| 414 |
"Downloading %d inner ZIP(s) from bundle: %s", len(daily_zips), daily_zips
|
| 415 |
)
|
| 416 |
|
| 417 |
+
chunks: list[bytes] = []
|
| 418 |
for daily_zip_name in daily_zips:
|
| 419 |
daily_zip_bytes = io.BytesIO(rz.read(daily_zip_name))
|
| 420 |
with zipfile.ZipFile(daily_zip_bytes) as daily_zf:
|
|
|
|
| 428 |
)
|
| 429 |
continue
|
| 430 |
logger.info("Extracting CSV(s) from %s: %s", daily_zip_name, csvs)
|
| 431 |
+
# Each inner ZIP becomes one chunk (concatenate its CSVs)
|
| 432 |
+
chunk_parts = [daily_zf.read(csv_name) for csv_name in csvs]
|
| 433 |
+
chunks.append(b"".join(chunk_parts))
|
| 434 |
|
| 435 |
+
if not chunks:
|
| 436 |
raise AEMOFetchError(
|
| 437 |
"All inner ZIPs from monthly bundle contained no CSV files."
|
| 438 |
)
|
| 439 |
+
return chunks
|
| 440 |
|
| 441 |
try:
|
| 442 |
return await asyncio.to_thread(_sync_extract)
|
|
|
|
| 505 |
|
| 506 |
# FPPMW archive bundles: extract via HTTP Range using search_strs
|
| 507 |
# to locate the target day's inner ZIP(s) within the bundle.
|
| 508 |
+
# Returns a list of chunks (one per inner ZIP) to limit memory.
|
| 509 |
if kind == "fppmw_monthly":
|
| 510 |
+
csv_parts.extend(
|
| 511 |
await _fetch_fppmw_monthly_csv(zip_url, search_strs)
|
| 512 |
)
|
| 513 |
continue
|
|
|
|
| 545 |
"Downloaded file appears to be corrupt. Please try again."
|
| 546 |
)
|
| 547 |
|
| 548 |
+
return FetchResult(csv_chunks=csv_parts, warnings=warnings)
|
app/services/data_processor.py
CHANGED
|
@@ -127,9 +127,33 @@ def _find_duid_col(df: pl.DataFrame) -> str:
|
|
| 127 |
)
|
| 128 |
|
| 129 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
def _parse_and_filter_duid(csv_bytes: bytes, duid: str) -> pl.DataFrame:
|
| 131 |
"""Parse CSV bytes, filter to the given DUID, return raw (uncast) DataFrame."""
|
| 132 |
-
|
|
|
|
|
|
|
| 133 |
|
| 134 |
missing = [c for c in REQUIRED_COLUMNS if c not in df.columns]
|
| 135 |
if missing:
|
|
@@ -143,7 +167,7 @@ def _parse_and_filter_duid(csv_bytes: bytes, duid: str) -> pl.DataFrame:
|
|
| 143 |
|
| 144 |
|
| 145 |
def filter_and_process(
|
| 146 |
-
|
| 147 |
duid: str,
|
| 148 |
target_date: date,
|
| 149 |
csv_bytes_next: bytes | None = None,
|
|
@@ -152,35 +176,41 @@ def filter_and_process(
|
|
| 152 |
Parse and filter CSV data to the requested DUID, covering exactly one NEM
|
| 153 |
market day: 04:00 AEST on target_date β 04:00 AEST on target_date + 1 day.
|
| 154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
AEMO NEMWEB timestamps are in AEST (UTC+10, no daylight saving).
|
| 156 |
-
The
|
| 157 |
-
|
| 158 |
-
β’ 04:00β16:00 AEST β file dated D (target_date)
|
| 159 |
-
β’ 16:00β04:00 AEST β file dated D+1 (csv_bytes_next)
|
| 160 |
-
|
| 161 |
-
Both files are merged and then the strict [04:00 AEST D, 04:00 AEST D+1)
|
| 162 |
-
boundary filter is applied, so we never include data from a neighbouring
|
| 163 |
-
market day regardless of what the fetched files contain.
|
| 164 |
"""
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
try:
|
| 170 |
-
|
| 171 |
-
logger.info("
|
| 172 |
-
|
|
|
|
| 173 |
except Exception as exc:
|
| 174 |
-
|
| 175 |
-
# unavailable or malformed.
|
| 176 |
-
logger.warning("Could not merge next-day CSV; using partial data: %s", exc)
|
| 177 |
|
| 178 |
-
if
|
| 179 |
raise DataProcessingError(
|
| 180 |
f"No data found for DUID '{duid}' on this date. "
|
| 181 |
"This unit may not have been operational or eligible for FPP on this date."
|
| 182 |
)
|
| 183 |
|
|
|
|
|
|
|
| 184 |
# Cast to proper types
|
| 185 |
df = df.with_columns([
|
| 186 |
pl.col("INTERVAL_DATETIME").str.to_datetime(
|
|
|
|
| 127 |
)
|
| 128 |
|
| 129 |
|
| 130 |
+
def _prefilter_bytes_by_duid(csv_bytes: bytes, duid: str) -> bytes:
|
| 131 |
+
"""
|
| 132 |
+
Pre-filter raw AEMO CSV bytes, keeping only header/comment lines and
|
| 133 |
+
data rows that contain the target DUID.
|
| 134 |
+
|
| 135 |
+
Era 1 files are ~1 GB with 9.3M rows for ALL units. Parsing the full
|
| 136 |
+
file into a DataFrame before filtering is extremely expensive (~6 GB
|
| 137 |
+
peak RAM). By scanning raw bytes first we reduce to ~1-2 MB, making
|
| 138 |
+
subsequent parsing fast and lightweight.
|
| 139 |
+
"""
|
| 140 |
+
duid_bytes = duid.encode("utf-8")
|
| 141 |
+
kept: list[bytes] = []
|
| 142 |
+
for line in csv_bytes.split(b"\n"):
|
| 143 |
+
if not line:
|
| 144 |
+
continue
|
| 145 |
+
if line[:2] in (b"I,", b"C,"):
|
| 146 |
+
kept.append(line)
|
| 147 |
+
elif line[:2] == b"D," and duid_bytes in line:
|
| 148 |
+
kept.append(line)
|
| 149 |
+
return b"\n".join(kept)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
def _parse_and_filter_duid(csv_bytes: bytes, duid: str) -> pl.DataFrame:
|
| 153 |
"""Parse CSV bytes, filter to the given DUID, return raw (uncast) DataFrame."""
|
| 154 |
+
# Pre-filter raw bytes to drastically reduce memory for large Era 1 files
|
| 155 |
+
filtered_bytes = _prefilter_bytes_by_duid(csv_bytes, duid)
|
| 156 |
+
df = _parse_aemo_csv(filtered_bytes)
|
| 157 |
|
| 158 |
missing = [c for c in REQUIRED_COLUMNS if c not in df.columns]
|
| 159 |
if missing:
|
|
|
|
| 167 |
|
| 168 |
|
| 169 |
def filter_and_process(
|
| 170 |
+
csv_bytes_or_chunks: bytes | list[bytes],
|
| 171 |
duid: str,
|
| 172 |
target_date: date,
|
| 173 |
csv_bytes_next: bytes | None = None,
|
|
|
|
| 176 |
Parse and filter CSV data to the requested DUID, covering exactly one NEM
|
| 177 |
market day: 04:00 AEST on target_date β 04:00 AEST on target_date + 1 day.
|
| 178 |
|
| 179 |
+
csv_bytes_or_chunks can be a single bytes object or a list of byte chunks.
|
| 180 |
+
When a list is provided, each chunk is parsed and DUID-filtered independently
|
| 181 |
+
so that large files (Era 1: ~1 GB each) are never all in memory at once.
|
| 182 |
+
|
| 183 |
AEMO NEMWEB timestamps are in AEST (UTC+10, no daylight saving).
|
| 184 |
+
The strict [04:00 AEST D, 04:00 AEST D+1) boundary filter is applied,
|
| 185 |
+
so we never include data from a neighbouring market day.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
"""
|
| 187 |
+
# Normalise input to a list of byte chunks
|
| 188 |
+
if isinstance(csv_bytes_or_chunks, bytes):
|
| 189 |
+
chunks = [csv_bytes_or_chunks]
|
| 190 |
+
if csv_bytes_next is not None:
|
| 191 |
+
chunks.append(csv_bytes_next)
|
| 192 |
+
else:
|
| 193 |
+
chunks = csv_bytes_or_chunks
|
| 194 |
+
|
| 195 |
+
# Parse and DUID-filter each chunk independently to limit peak memory
|
| 196 |
+
dfs: list[pl.DataFrame] = []
|
| 197 |
+
for i, chunk in enumerate(chunks):
|
| 198 |
try:
|
| 199 |
+
df_chunk = _parse_and_filter_duid(chunk, duid)
|
| 200 |
+
logger.info("Chunk %d: %d rows after DUID filter (%s)", i, len(df_chunk), duid)
|
| 201 |
+
if not df_chunk.is_empty():
|
| 202 |
+
dfs.append(df_chunk)
|
| 203 |
except Exception as exc:
|
| 204 |
+
logger.warning("Could not process chunk %d; skipping: %s", i, exc)
|
|
|
|
|
|
|
| 205 |
|
| 206 |
+
if not dfs:
|
| 207 |
raise DataProcessingError(
|
| 208 |
f"No data found for DUID '{duid}' on this date. "
|
| 209 |
"This unit may not have been operational or eligible for FPP on this date."
|
| 210 |
)
|
| 211 |
|
| 212 |
+
df = pl.concat(dfs) if len(dfs) > 1 else dfs[0]
|
| 213 |
+
|
| 214 |
# Cast to proper types
|
| 215 |
df = df.with_columns([
|
| 216 |
pl.col("INTERVAL_DATETIME").str.to_datetime(
|