Claude commited on
Commit
19b8b03
·
1 Parent(s): 95bc2d3

Add FPPMW monthly archive support (Mar 2025–Jan 2026) + 24h day coverage

Browse files

aemo_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 CHANGED
@@ -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
  ]
app/routers/api.py CHANGED
@@ -2,9 +2,12 @@
2
  REST API endpoints for BESS SCADA data.
3
  """
4
  import json
5
- from datetime import date, datetime
 
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 fetch_csv_for_date(target_date, duid)
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 fetch_csv_for_date(target_date, duid)
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 fetch_csv_for_date(target_date, duid)
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"
app/services/aemo_fetcher.py CHANGED
@@ -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
- The ZIP contains the CSV directly.
9
 
10
  From 11 Jan 2026 — FPPMW format (nested ZIP):
11
  PUBLIC_NEXT_DAY_FPPMW_YYYYMMDD[_<suffix>].zip
12
- The outer ZIP contains an inner ZIP, which contains the CSV.
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
- PUBLIC_NEXT_DAY_FPP_YYYYMMDD.zip — multi-day bundles (one per month).
20
- The YYYYMMDD is the START date of the bundle. Find data for a given
21
- date by picking the bundle whose start date is the largest date <= target.
22
- PUBLIC_NEXT_DAY_FPPMW_YYYYMMDD.zip individual daily files (FPPMW format),
23
- only valid for an exact date match.
24
-
25
- Timezone note:
26
- INTERVAL_DATETIME / MEASUREMENT_DATETIME inside CSVs are in UTC (or NEM time
27
- which is UTC+10 without DST). An Australian market day D runs from
28
- D-1 14:00 UTC to D 13:30 UTC in five-minute intervals. The CSV file itself
29
- is named with the Australian market date, so file selection by date is
30
- correct without timezone conversion.
 
 
 
 
 
 
 
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
- Given a directory listing, return (filename, url) for the best ZIP to use
135
- for target_date, or None if nothing suitable is found.
136
-
137
- Strategy:
138
- 1. Exact date match filename contains YYYYMMDD of the target date.
139
- Covers Current files (FPP or FPPMW naming) and any archive file
140
- whose start date happens to equal target_date.
141
- 2. Archive bundle match — find the ZIP whose embedded date is the largest
142
- date that is still <= target_date. That bundle covers target_date.
 
 
143
  """
144
- # 1. Exact match (e.g. Current dir or lucky archive hit)
145
  exact = [f for f in filenames if date_str in f]
146
  if exact:
147
- # From 11 Jan 2026 AEMO publishes FPPMW (metered) files alongside older
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
- logger.info("Exact match at %s: %s", url, chosen)
153
- return chosen, url
154
-
155
- # 2. Archive bundle: largest start-date <= target_date.
156
- # ONLY consider PUBLIC_NEXT_DAY_FPP_*.zip files (multi-day bundles).
157
- # PUBLIC_NEXT_DAY_FPPMW_*.zip files are individual daily files — they
158
- # only contain data for their own date, so they must not be used as
159
- # a bundle for a different date.
160
- fpp_bundles = [f for f in filenames if "FPPMW" not in f]
 
161
  candidates: list[tuple[date, str]] = []
162
- for f in fpp_bundles:
163
  zip_date = _extract_zip_date(f)
164
- if zip_date is not None and zip_date <= target_date:
165
- candidates.append((zip_date, f))
 
 
 
166
 
167
  if candidates:
168
- candidates.sort(reverse=True) # descending by date
 
 
 
169
  best_date, best_file = candidates[0]
 
170
  logger.info(
171
- "Archive bundle match at %s: %s (bundle start %s covers %s)",
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, base_url: str, client: httpx.AsyncClient
181
- ) -> tuple[str, str]:
182
  """
183
  Search Current then Archive for a ZIP covering target_date.
184
- Returns (filename, base_url_of_that_file).
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 urls_to_try:
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
- async def fetch_csv_for_date(target_date: date, duid: str) -> bytes:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, base_url, client)
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
- zip_bytes = io.BytesIO(resp.content)
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
  )
 
 
app/services/data_processor.py CHANGED
@@ -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
- - First row: "C,..." (comment/metadata)
21
- - Second row: "I,..." (table name/column header info)
22
- - Data rows: "D,..." (data)
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
- # Format: I, TABLE_NAME, VERSION, col1, col2, ...
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
- df = pl.read_csv(io.StringIO(csv_content), infer_schema_length=None)
64
- return df
65
 
66
 
67
- def filter_and_process(csv_bytes: bytes, duid: str) -> pl.DataFrame:
68
  """
69
- Parse raw CSV bytes, filter to the requested DUID, return cleaned DataFrame.
 
 
 
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
- f"AEMO may have changed the file format."
79
  )
80
 
81
- # Filter to selected DUID
82
- df = df.filter(pl.col("FPP_UNITID") == duid).select(REQUIRED_COLUMNS)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
 
84
  if df.is_empty():
85
  raise DataProcessingError(
86
  f"No data found for DUID '{duid}' on this date. "
87
- f"This unit may not have been operational or eligible for FPP on this date."
88
  )
89
 
90
- # Cast types
91
  df = df.with_columns([
92
- pl.col("INTERVAL_DATETIME").str.to_datetime(format="%Y/%m/%d %H:%M:%S", strict=False),
93
- pl.col("MEASUREMENT_DATETIME").str.to_datetime(format="%Y/%m/%d %H:%M:%S", strict=False),
 
 
 
 
94
  pl.col("MEASURED_MW").cast(pl.Float64, strict=False),
95
  pl.col("MW_QUALITY_FLAG").cast(pl.Int32, strict=False),
96
- ]).sort("MEASUREMENT_DATETIME")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- Truncates to max_rows for display (full data available via download).
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-%dT%H:%M:%S"),
146
- pl.col("MEASUREMENT_DATETIME").dt.strftime("%Y-%m-%dT%H:%M:%S"),
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()
requirements.txt CHANGED
@@ -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