Spaces:
Running
Fix multi-segment CSV parsing so both half-day files use their own schema
Browse filesRoot cause of "only one row returned" for all dates after Jan 11 2026
=====================================================================
When two FPPMW ZIP files are downloaded (first half and second half of
a NEM market day), their CSV bytes are concatenated and fed to
_parse_aemo_csv as a single byte string. The old parser iterated over
all lines and OVERWROTE the column header (`header = parts[3:]`) every
time it saw a new "I," line. All data rows from BOTH files were
collected under the single (last) header.
If FPPMW and FPPMW_2 have even slightly different column schemas (e.g.
different column count, or columns in a different order — which is the
case for the new two-half-day format introduced Jan 11 2026), every row
from File 1 is silently re-indexed under File 2's column structure:
• datetime strings land in numeric columns → cast to Float64 → null
• MEASUREMENT_DATETIME column is nulled for all File 1 rows
• The date-window filter (on MEASUREMENT_DATETIME) drops those rows
• Only File 2's correctly-aligned rows survive → "only one" half-day
of data (or in the extreme case, only 1 row if the schemas are very
different)
Fix
---
Treat every "I," line as the start of a new segment. Flush the
accumulated rows for the previous header before switching headers, then
parse each segment independently with `_segment_to_df`. When multiple
segments are found, reconcile their schemas with a Polars diagonal
concat (missing columns are filled with null rather than crashing).
If the two files happen to have identical schemas (common case for older
data), diagonal concat is identical to vertical concat — no regression.
Also added
----------
- logger.warning when segment schemas differ (visible in app log)
- logger.debug per-segment column/row counts
- logger.info in filter_and_process for DUID filter counts, datetime
parse success rate, and date-window filter counts — makes it easy to
diagnose future filtering issues without inserting print statements
https://claude.ai/code/session_01TrCrzZmWbQscJiAQaW7Rg8
- app/services/data_processor.py +80 -20
|
@@ -2,6 +2,7 @@
|
|
| 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
|
|
@@ -10,6 +11,8 @@ import pyarrow as pa
|
|
| 10 |
|
| 11 |
from app.config import REQUIRED_COLUMNS
|
| 12 |
|
|
|
|
|
|
|
| 13 |
|
| 14 |
class DataProcessingError(Exception):
|
| 15 |
pass
|
|
@@ -20,18 +23,40 @@ class DataProcessingError(Exception):
|
|
| 20 |
_DAY_START_HOUR = 4
|
| 21 |
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
def _parse_aemo_csv(csv_bytes: bytes) -> pl.DataFrame:
|
| 24 |
"""
|
| 25 |
AEMO CSV files have a non-standard header format:
|
| 26 |
- "C,..." rows: comment / metadata (skipped)
|
| 27 |
- "I,..." row: column headers (format: I, TABLE, SUBTABLE, col1, col2, …)
|
| 28 |
- "D,..." rows: data
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
"""
|
| 30 |
text = csv_bytes.decode("utf-8", errors="replace")
|
| 31 |
lines = text.splitlines()
|
| 32 |
|
| 33 |
-
|
| 34 |
-
|
|
|
|
| 35 |
|
| 36 |
for line in lines:
|
| 37 |
if not line.strip():
|
|
@@ -40,27 +65,50 @@ def _parse_aemo_csv(csv_bytes: bytes) -> pl.DataFrame:
|
|
| 40 |
parts = line.split(",")
|
| 41 |
# Strip the leading "I, TABLE_NAME, SUBTABLE" — columns start at index 3
|
| 42 |
if len(parts) > 3:
|
| 43 |
-
header
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
elif line.startswith("D,"):
|
| 45 |
parts = line.split(",")
|
| 46 |
if len(parts) > 3:
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
-
if
|
| 50 |
-
|
| 51 |
-
|
| 52 |
raise DataProcessingError("CSV file contained no data rows.")
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
|
| 66 |
def _find_duid_col(df: pl.DataFrame) -> str:
|
|
@@ -115,18 +163,17 @@ def filter_and_process(
|
|
| 115 |
market day regardless of what the fetched files contain.
|
| 116 |
"""
|
| 117 |
df = _parse_and_filter_duid(csv_bytes, duid)
|
|
|
|
| 118 |
|
| 119 |
if csv_bytes_next is not None:
|
| 120 |
try:
|
| 121 |
df_next = _parse_and_filter_duid(csv_bytes_next, duid)
|
|
|
|
| 122 |
df = pl.concat([df, df_next])
|
| 123 |
except Exception as exc:
|
| 124 |
# Non-fatal: proceed with partial data if next-day file is
|
| 125 |
# unavailable or malformed.
|
| 126 |
-
|
| 127 |
-
logging.getLogger(__name__).warning(
|
| 128 |
-
"Could not merge next-day CSV; using partial data: %s", exc
|
| 129 |
-
)
|
| 130 |
|
| 131 |
if df.is_empty():
|
| 132 |
raise DataProcessingError(
|
|
@@ -146,6 +193,13 @@ def filter_and_process(
|
|
| 146 |
pl.col("MW_QUALITY_FLAG").cast(pl.Int32, strict=False),
|
| 147 |
])
|
| 148 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
# Apply the NEM market day boundary in AEST.
|
| 150 |
# Timestamps in the CSVs are naive AEST datetimes; we compare directly.
|
| 151 |
day_start = datetime.combine(target_date, dtime(_DAY_START_HOUR, 0, 0))
|
|
@@ -154,6 +208,12 @@ def filter_and_process(
|
|
| 154 |
(pl.col("MEASUREMENT_DATETIME") >= day_start) &
|
| 155 |
(pl.col("MEASUREMENT_DATETIME") < day_end)
|
| 156 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
|
| 158 |
if df.is_empty():
|
| 159 |
raise DataProcessingError(
|
|
|
|
| 2 |
Filters and transforms raw AEMO FPPDAILY CSV bytes using Polars.
|
| 3 |
"""
|
| 4 |
import io
|
| 5 |
+
import logging
|
| 6 |
from datetime import date, datetime, time as dtime, timedelta
|
| 7 |
|
| 8 |
import polars as pl
|
|
|
|
| 11 |
|
| 12 |
from app.config import REQUIRED_COLUMNS
|
| 13 |
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
|
| 17 |
class DataProcessingError(Exception):
|
| 18 |
pass
|
|
|
|
| 23 |
_DAY_START_HOUR = 4
|
| 24 |
|
| 25 |
|
| 26 |
+
def _segment_to_df(header: list[str], data_lines: list[list[str]]) -> pl.DataFrame:
|
| 27 |
+
"""Build a Polars DataFrame from one parsed AEMO CSV segment."""
|
| 28 |
+
n_cols = len(header)
|
| 29 |
+
padded = []
|
| 30 |
+
for row in data_lines:
|
| 31 |
+
if len(row) >= n_cols:
|
| 32 |
+
padded.append(row[:n_cols])
|
| 33 |
+
else:
|
| 34 |
+
padded.append(row + [""] * (n_cols - len(row)))
|
| 35 |
+
csv_content = ",".join(header) + "\n" + "\n".join(",".join(r) for r in padded)
|
| 36 |
+
return pl.read_csv(io.StringIO(csv_content), infer_schema_length=None)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
def _parse_aemo_csv(csv_bytes: bytes) -> pl.DataFrame:
|
| 40 |
"""
|
| 41 |
AEMO CSV files have a non-standard header format:
|
| 42 |
- "C,..." rows: comment / metadata (skipped)
|
| 43 |
- "I,..." row: column headers (format: I, TABLE, SUBTABLE, col1, col2, …)
|
| 44 |
- "D,..." rows: data
|
| 45 |
+
|
| 46 |
+
When csv_bytes is the concatenation of two half-day files, there will be
|
| 47 |
+
TWO "I," lines. The two files may have different column schemas (e.g.
|
| 48 |
+
FPPMW first-half vs. FPPMW_2 second-half formats differ). To avoid
|
| 49 |
+
silently misaligning rows from the first file under the second file's
|
| 50 |
+
header, we treat every "I," line as the start of a new segment, parse
|
| 51 |
+
each segment independently, and then reconcile the schemas via a
|
| 52 |
+
diagonal concat (missing columns are filled with null).
|
| 53 |
"""
|
| 54 |
text = csv_bytes.decode("utf-8", errors="replace")
|
| 55 |
lines = text.splitlines()
|
| 56 |
|
| 57 |
+
segments: list[tuple[list[str], list[list[str]]]] = []
|
| 58 |
+
current_header: list[str] | None = None
|
| 59 |
+
current_data: list[list[str]] = []
|
| 60 |
|
| 61 |
for line in lines:
|
| 62 |
if not line.strip():
|
|
|
|
| 65 |
parts = line.split(",")
|
| 66 |
# Strip the leading "I, TABLE_NAME, SUBTABLE" — columns start at index 3
|
| 67 |
if len(parts) > 3:
|
| 68 |
+
# Flush accumulated data for the previous header before switching
|
| 69 |
+
if current_header is not None and current_data:
|
| 70 |
+
segments.append((current_header, current_data))
|
| 71 |
+
current_data = []
|
| 72 |
+
current_header = parts[3:]
|
| 73 |
elif line.startswith("D,"):
|
| 74 |
parts = line.split(",")
|
| 75 |
if len(parts) > 3:
|
| 76 |
+
current_data.append(parts[3:])
|
| 77 |
+
|
| 78 |
+
# Flush the final segment
|
| 79 |
+
if current_header is not None and current_data:
|
| 80 |
+
segments.append((current_header, current_data))
|
| 81 |
|
| 82 |
+
if not segments:
|
| 83 |
+
if current_header is None:
|
| 84 |
+
raise DataProcessingError("Could not find column header row in CSV file.")
|
| 85 |
raise DataProcessingError("CSV file contained no data rows.")
|
| 86 |
|
| 87 |
+
if len(segments) == 1:
|
| 88 |
+
return _segment_to_df(*segments[0])
|
| 89 |
+
|
| 90 |
+
# Multiple segments (concatenated files): parse each independently so that
|
| 91 |
+
# each file's own column header governs its own data rows.
|
| 92 |
+
dfs = []
|
| 93 |
+
for i, (hdr, rows) in enumerate(segments):
|
| 94 |
+
logger.debug("CSV segment %d: %d columns, %d rows — %s", i, len(hdr), len(rows), hdr)
|
| 95 |
+
dfs.append(_segment_to_df(hdr, rows))
|
| 96 |
+
|
| 97 |
+
col_sets = [set(df.columns) for df in dfs]
|
| 98 |
+
if len(set(frozenset(s) for s in col_sets)) > 1:
|
| 99 |
+
logger.warning(
|
| 100 |
+
"Multi-segment CSV has differing schemas across %d segments; "
|
| 101 |
+
"using diagonal concat (missing columns filled with null). "
|
| 102 |
+
"Segment column sets: %s",
|
| 103 |
+
len(dfs),
|
| 104 |
+
[sorted(s) for s in col_sets],
|
| 105 |
+
)
|
| 106 |
|
| 107 |
+
try:
|
| 108 |
+
return pl.concat(dfs, how="diagonal")
|
| 109 |
+
except Exception as exc:
|
| 110 |
+
logger.warning("diagonal concat failed (%s); falling back to first segment only", exc)
|
| 111 |
+
return dfs[0]
|
| 112 |
|
| 113 |
|
| 114 |
def _find_duid_col(df: pl.DataFrame) -> str:
|
|
|
|
| 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(
|
|
|
|
| 193 |
pl.col("MW_QUALITY_FLAG").cast(pl.Int32, strict=False),
|
| 194 |
])
|
| 195 |
|
| 196 |
+
# Log how many MEASUREMENT_DATETIME values parsed successfully
|
| 197 |
+
n_parsed = df["MEASUREMENT_DATETIME"].drop_nulls().len()
|
| 198 |
+
logger.info(
|
| 199 |
+
"MEASUREMENT_DATETIME parsed: %d/%d rows (format '%%Y/%%m/%%d %%H:%%M:%%S')",
|
| 200 |
+
n_parsed, len(df),
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
# Apply the NEM market day boundary in AEST.
|
| 204 |
# Timestamps in the CSVs are naive AEST datetimes; we compare directly.
|
| 205 |
day_start = datetime.combine(target_date, dtime(_DAY_START_HOUR, 0, 0))
|
|
|
|
| 208 |
(pl.col("MEASUREMENT_DATETIME") >= day_start) &
|
| 209 |
(pl.col("MEASUREMENT_DATETIME") < day_end)
|
| 210 |
)
|
| 211 |
+
logger.info(
|
| 212 |
+
"After date-window filter [%s, %s): %d rows",
|
| 213 |
+
day_start.strftime("%Y-%m-%d %H:%M"),
|
| 214 |
+
day_end.strftime("%Y-%m-%d %H:%M"),
|
| 215 |
+
len(df),
|
| 216 |
+
)
|
| 217 |
|
| 218 |
if df.is_empty():
|
| 219 |
raise DataProcessingError(
|