Claude commited on
Commit
c38e61e
·
1 Parent(s): 36d7e96

Fix multi-segment CSV parsing so both half-day files use their own schema

Browse files

Root 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

Files changed (1) hide show
  1. app/services/data_processor.py +80 -20
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
  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
- header = None
34
- data_lines = []
 
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 = parts[3:]
 
 
 
 
44
  elif line.startswith("D,"):
45
  parts = line.split(",")
46
  if len(parts) > 3:
47
- data_lines.append(parts[3:])
 
 
 
 
48
 
49
- if header is None:
50
- raise DataProcessingError("Could not find column header row in CSV file.")
51
- if not data_lines:
52
  raise DataProcessingError("CSV file contained no data rows.")
53
 
54
- n_cols = len(header)
55
- padded = []
56
- for row in data_lines:
57
- if len(row) >= n_cols:
58
- padded.append(row[:n_cols])
59
- else:
60
- padded.append(row + [""] * (n_cols - len(row)))
 
 
 
 
 
 
 
 
 
 
 
 
61
 
62
- csv_content = ",".join(header) + "\n" + "\n".join(",".join(r) for r in padded)
63
- return pl.read_csv(io.StringIO(csv_content), infer_schema_length=None)
 
 
 
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
- import logging
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(