pourmousavi Claude Opus 4.6 (1M context) commited on
Commit
6250d00
Β·
1 Parent(s): c6457f4

Fix OOM on Era 1 dates: chunk CSV output and pre-filter by DUID

Browse files

Era 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 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.csv_bytes, duid, target_date)
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.csv_bytes, 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,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.csv_bytes, 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)
 
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 bytes plus any warnings for the user."""
77
- csv_bytes: bytes = b""
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
- all_csv_bytes: list[bytes] = []
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
- for csv_name in csvs:
428
- all_csv_bytes.append(daily_zf.read(csv_name))
 
429
 
430
- if not all_csv_bytes:
431
  raise AEMOFetchError(
432
  "All inner ZIPs from monthly bundle contained no CSV files."
433
  )
434
- return b"".join(all_csv_bytes)
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.append(
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(csv_bytes=b"".join(csv_parts), warnings=warnings)
 
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
- df = _parse_aemo_csv(csv_bytes)
 
 
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
- csv_bytes: bytes,
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 NEM market day is split into two 12-hour halves that may be packaged
157
- in separate ZIP files:
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
- df = _parse_and_filter_duid(csv_bytes, duid)
166
- logger.info("After DUID filter (%s): %d rows", duid, len(df))
167
-
168
- if csv_bytes_next is not None:
 
 
 
 
 
 
 
169
  try:
170
- df_next = _parse_and_filter_duid(csv_bytes_next, duid)
171
- logger.info("After DUID filter on next-day file (%s): %d rows", duid, len(df_next))
172
- df = pl.concat([df, df_next])
 
173
  except Exception as exc:
174
- # Non-fatal: proceed with partial data if next-day file is
175
- # unavailable or malformed.
176
- logger.warning("Could not merge next-day CSV; using partial data: %s", exc)
177
 
178
- if df.is_empty():
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(