Spaces:
Running
Running
| """ | |
| Filters and transforms raw AEMO FPPDAILY CSV bytes using Polars. | |
| """ | |
| import io | |
| import logging | |
| from datetime import date, datetime, time as dtime, timedelta | |
| import polars as pl | |
| import pyarrow.parquet as pq | |
| import pyarrow as pa | |
| from app.config import REQUIRED_COLUMNS | |
| logger = logging.getLogger(__name__) | |
| class DataProcessingError(Exception): | |
| pass | |
| # NEM market day boundary: 04:00 AEST (UTC+10, no daylight saving). | |
| # All AEMO NEMWEB timestamps are in AEST. | |
| _DAY_START_HOUR = 4 | |
| def _segment_to_df(header: list[str], data_lines: list[list[str]]) -> pl.DataFrame: | |
| """Build a Polars DataFrame from one parsed AEMO CSV segment.""" | |
| n_cols = len(header) | |
| padded = [] | |
| for row in data_lines: | |
| if len(row) >= n_cols: | |
| padded.append(row[:n_cols]) | |
| else: | |
| padded.append(row + [""] * (n_cols - len(row))) | |
| csv_content = ",".join(header) + "\n" + "\n".join(",".join(r) for r in padded) | |
| return pl.read_csv(io.StringIO(csv_content), infer_schema_length=None) | |
| def _parse_aemo_csv(csv_bytes: bytes) -> pl.DataFrame: | |
| """ | |
| AEMO CSV files have a non-standard header format: | |
| - "C,..." rows: comment / metadata (skipped) | |
| - "I,..." row: column headers (format: I, TABLE, SUBTABLE, col1, col2, …) | |
| - "D,..." rows: data | |
| When csv_bytes is the concatenation of two half-day files, there will be | |
| TWO "I," lines. The two files may have different column schemas (e.g. | |
| FPPMW first-half vs. FPPMW_2 second-half formats differ). To avoid | |
| silently misaligning rows from the first file under the second file's | |
| header, we treat every "I," line as the start of a new segment, parse | |
| each segment independently, and then reconcile the schemas via a | |
| diagonal concat (missing columns are filled with null). | |
| """ | |
| text = csv_bytes.decode("utf-8", errors="replace") | |
| lines = text.splitlines() | |
| segments: list[tuple[list[str], list[list[str]]]] = [] | |
| current_header: list[str] | None = None | |
| current_data: list[list[str]] = [] | |
| for line in lines: | |
| if not line.strip(): | |
| continue | |
| if line.startswith("I,"): | |
| parts = line.split(",") | |
| # Strip the leading "I, TABLE_NAME, SUBTABLE" — columns start at index 3 | |
| if len(parts) > 3: | |
| # Flush accumulated data for the previous header before switching | |
| if current_header is not None and current_data: | |
| segments.append((current_header, current_data)) | |
| current_data = [] | |
| current_header = parts[3:] | |
| elif line.startswith("D,"): | |
| parts = line.split(",") | |
| if len(parts) > 3: | |
| current_data.append(parts[3:]) | |
| # Flush the final segment | |
| if current_header is not None and current_data: | |
| segments.append((current_header, current_data)) | |
| if not segments: | |
| if current_header is None: | |
| raise DataProcessingError("Could not find column header row in CSV file.") | |
| raise DataProcessingError("CSV file contained no data rows.") | |
| if len(segments) == 1: | |
| return _segment_to_df(*segments[0]) | |
| # Multiple segments (concatenated files): parse each independently so that | |
| # each file's own column header governs its own data rows. | |
| dfs = [] | |
| for i, (hdr, rows) in enumerate(segments): | |
| logger.debug("CSV segment %d: %d columns, %d rows — %s", i, len(hdr), len(rows), hdr) | |
| dfs.append(_segment_to_df(hdr, rows)) | |
| col_sets = [set(df.columns) for df in dfs] | |
| if len(set(frozenset(s) for s in col_sets)) > 1: | |
| logger.warning( | |
| "Multi-segment CSV has differing schemas across %d segments; " | |
| "using diagonal concat (missing columns filled with null). " | |
| "Segment column sets: %s", | |
| len(dfs), | |
| [sorted(s) for s in col_sets], | |
| ) | |
| try: | |
| return pl.concat(dfs, how="diagonal") | |
| except Exception as exc: | |
| logger.warning("diagonal concat failed (%s); falling back to first segment only", exc) | |
| return dfs[0] | |
| def _find_duid_col(df: pl.DataFrame) -> str: | |
| """ | |
| Return the column name used as the unit identifier. | |
| FPP format (up to Jan 10 2026) uses 'FPP_UNITID'. | |
| FPPMW format (from Jan 11 2026 / Mar 2025 archive) uses 'DUID'. | |
| """ | |
| for candidate in ("FPP_UNITID", "DUID"): | |
| if candidate in df.columns: | |
| return candidate | |
| raise DataProcessingError( | |
| "Cannot find unit identifier column (expected 'FPP_UNITID' or 'DUID'). " | |
| "AEMO may have changed the file format." | |
| ) | |
| def _prefilter_bytes_by_duid(csv_bytes: bytes, duid: str) -> bytes: | |
| """ | |
| Pre-filter raw AEMO CSV bytes, keeping only header/comment lines and | |
| data rows that contain the target DUID. | |
| Era 1 files are ~1 GB with 9.3M rows for ALL units. Parsing the full | |
| file into a DataFrame before filtering is extremely expensive (~6 GB | |
| peak RAM). By scanning raw bytes first we reduce to ~1-2 MB, making | |
| subsequent parsing fast and lightweight. | |
| """ | |
| duid_bytes = duid.encode("utf-8") | |
| kept: list[bytes] = [] | |
| for line in csv_bytes.split(b"\n"): | |
| if not line: | |
| continue | |
| if line[:2] in (b"I,", b"C,"): | |
| kept.append(line) | |
| elif line[:2] == b"D," and duid_bytes in line: | |
| kept.append(line) | |
| return b"\n".join(kept) | |
| def _parse_and_filter_duid(csv_bytes: bytes, duid: str) -> pl.DataFrame: | |
| """Parse CSV bytes, filter to the given DUID, return raw (uncast) DataFrame.""" | |
| # Pre-filter raw bytes to drastically reduce memory for large Era 1 files | |
| filtered_bytes = _prefilter_bytes_by_duid(csv_bytes, duid) | |
| df = _parse_aemo_csv(filtered_bytes) | |
| missing = [c for c in REQUIRED_COLUMNS if c not in df.columns] | |
| if missing: | |
| raise DataProcessingError( | |
| f"CSV missing expected columns: {missing}. " | |
| "AEMO may have changed the file format." | |
| ) | |
| duid_col = _find_duid_col(df) | |
| return df.filter(pl.col(duid_col) == duid).select(REQUIRED_COLUMNS) | |
| def filter_and_process( | |
| csv_bytes_or_chunks: bytes | list[bytes], | |
| duid: str, | |
| target_date: date, | |
| csv_bytes_next: bytes | None = None, | |
| ) -> pl.DataFrame: | |
| """ | |
| Parse and filter CSV data to the requested DUID, covering exactly one NEM | |
| market day: 04:00 AEST on target_date → 04:00 AEST on target_date + 1 day. | |
| csv_bytes_or_chunks can be a single bytes object or a list of byte chunks. | |
| When a list is provided, each chunk is parsed and DUID-filtered independently | |
| so that large files (Era 1: ~1 GB each) are never all in memory at once. | |
| AEMO NEMWEB timestamps are in AEST (UTC+10, no daylight saving). | |
| The strict [04:00 AEST D, 04:00 AEST D+1) boundary filter is applied, | |
| so we never include data from a neighbouring market day. | |
| """ | |
| # Normalise input to a list of byte chunks | |
| if isinstance(csv_bytes_or_chunks, bytes): | |
| chunks = [csv_bytes_or_chunks] | |
| if csv_bytes_next is not None: | |
| chunks.append(csv_bytes_next) | |
| else: | |
| chunks = csv_bytes_or_chunks | |
| # Parse and DUID-filter each chunk independently to limit peak memory | |
| dfs: list[pl.DataFrame] = [] | |
| for i, chunk in enumerate(chunks): | |
| try: | |
| df_chunk = _parse_and_filter_duid(chunk, duid) | |
| logger.info("Chunk %d: %d rows after DUID filter (%s)", i, len(df_chunk), duid) | |
| if not df_chunk.is_empty(): | |
| dfs.append(df_chunk) | |
| except Exception as exc: | |
| logger.warning("Could not process chunk %d; skipping: %s", i, exc) | |
| if not dfs: | |
| raise DataProcessingError( | |
| f"No data found for DUID '{duid}' on this date. " | |
| "This unit may not have been operational or eligible for FPP on this date." | |
| ) | |
| df = pl.concat(dfs) if len(dfs) > 1 else dfs[0] | |
| # Cast to proper types | |
| df = df.with_columns([ | |
| pl.col("INTERVAL_DATETIME").str.to_datetime( | |
| format="%Y/%m/%d %H:%M:%S", strict=False | |
| ), | |
| pl.col("MEASUREMENT_DATETIME").str.to_datetime( | |
| format="%Y/%m/%d %H:%M:%S", strict=False | |
| ), | |
| pl.col("MEASURED_MW").cast(pl.Float64, strict=False), | |
| pl.col("MW_QUALITY_FLAG").cast(pl.Int32, strict=False), | |
| ]) | |
| # Log how many MEASUREMENT_DATETIME values parsed successfully | |
| n_parsed = df["MEASUREMENT_DATETIME"].drop_nulls().len() | |
| logger.info( | |
| "MEASUREMENT_DATETIME parsed: %d/%d rows (format '%%Y/%%m/%%d %%H:%%M:%%S')", | |
| n_parsed, len(df), | |
| ) | |
| # Apply the NEM market day boundary in AEST. | |
| # Timestamps in the CSVs are naive AEST datetimes; we compare directly. | |
| day_start = datetime.combine(target_date, dtime(_DAY_START_HOUR, 0, 0)) | |
| day_end = datetime.combine(target_date + timedelta(days=1), dtime(_DAY_START_HOUR, 0, 0)) | |
| df = df.filter( | |
| (pl.col("MEASUREMENT_DATETIME") >= day_start) & | |
| (pl.col("MEASUREMENT_DATETIME") < day_end) | |
| ) | |
| logger.info( | |
| "After date-window filter [%s, %s): %d rows", | |
| day_start.strftime("%Y-%m-%d %H:%M"), | |
| day_end.strftime("%Y-%m-%d %H:%M"), | |
| len(df), | |
| ) | |
| if df.is_empty(): | |
| raise DataProcessingError( | |
| f"No data found for DUID '{duid}' within the NEM market day " | |
| f"({day_start.strftime('%d %b %Y %H:%M')}–" | |
| f"{day_end.strftime('%d %b %Y %H:%M')} AEST). " | |
| "This unit may not have been operational or eligible for FPP on this date." | |
| ) | |
| # Deduplicate on MEASUREMENT_DATETIME (boundary rows may appear in both | |
| # the D and D+1 files) then sort chronologically. | |
| return ( | |
| df.unique(subset=["MEASUREMENT_DATETIME"], keep="first") | |
| .sort("MEASUREMENT_DATETIME") | |
| ) | |
| def compute_summary(df: pl.DataFrame) -> dict: | |
| """Compute summary statistics for the filtered data.""" | |
| # fill_nan converts float NaN → null before drop_nulls so that NaN values | |
| # (which Polars treats as distinct from null) don't silently corrupt stats. | |
| mw = df["MEASURED_MW"].fill_nan(None).drop_nulls() | |
| flags = df["MW_QUALITY_FLAG"].value_counts().sort("MW_QUALITY_FLAG") | |
| total = len(df) | |
| flag_breakdown = {} | |
| for row in flags.iter_rows(named=True): | |
| flag = str(row["MW_QUALITY_FLAG"]) | |
| count = row["count"] | |
| flag_breakdown[flag] = { | |
| "count": count, | |
| "pct": round(100 * count / total, 1) if total > 0 else 0, | |
| } | |
| def _safe(val) -> float | None: | |
| return round(float(val), 3) if val is not None else None | |
| return { | |
| "total_rows": total, | |
| "min_mw": _safe(mw.min() if len(mw) > 0 else None), | |
| "max_mw": _safe(mw.max() if len(mw) > 0 else None), | |
| "mean_mw": _safe(mw.mean() if len(mw) > 0 else None), | |
| "std_mw": _safe(mw.std() if len(mw) > 0 else None), | |
| "flag_breakdown": flag_breakdown, | |
| } | |
| def to_csv_bytes(df: pl.DataFrame) -> bytes: | |
| """Serialize DataFrame to CSV bytes with space-separated datetimes (no T).""" | |
| out = df.with_columns([ | |
| pl.col("INTERVAL_DATETIME").dt.strftime("%Y-%m-%d %H:%M:%S"), | |
| pl.col("MEASUREMENT_DATETIME").dt.strftime("%Y-%m-%d %H:%M:%S"), | |
| ]) | |
| return out.write_csv().encode("utf-8") | |
| def to_parquet_bytes(df: pl.DataFrame) -> bytes: | |
| """Serialize DataFrame to Parquet bytes.""" | |
| buf = io.BytesIO() | |
| df.write_parquet(buf) | |
| return buf.getvalue() | |
| def to_json_records(df: pl.DataFrame, max_rows: int = 25_000) -> list[dict]: | |
| """ | |
| Return data as a list of dicts for JSON response. | |
| Datetimes formatted as 'YYYY-MM-DD HH:MM:SS' (no T, no microseconds). | |
| Default cap of 25 000 rows covers a full 24-hour NEM day at 4-second | |
| resolution (21 600 rows) with headroom. | |
| """ | |
| display_df = df.head(max_rows).with_columns([ | |
| pl.col("INTERVAL_DATETIME").dt.strftime("%Y-%m-%d %H:%M:%S"), | |
| pl.col("MEASUREMENT_DATETIME").dt.strftime("%Y-%m-%d %H:%M:%S"), | |
| # Polars float NaN is not JSON-serializable; normalise to null (→ JSON null). | |
| pl.col("MEASURED_MW").fill_nan(None), | |
| ]) | |
| return display_df.to_dicts() | |