aemo-mdff-reader 2.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1241 @@
1
+ """Streaming NEM12 / NEM13 parser.
2
+
3
+ This module parses NEM12 and the structurally similar NEM13 files
4
+ defined by AEMO. It is a single-pass, allocation-light implementation
5
+ that yields :class:`IntervalReading` (300) and :class:`AccumulationReading`
6
+ (250) records without constructing an intermediate parse tree.
7
+
8
+ Spec target: AEMO Meter Data File Format Specification NEM12 & NEM13,
9
+ v2.6, effective 29 September 2024. Allowed-value enumerations are
10
+ exposed via :mod:`aemo_mdff_reader.spec`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import csv
16
+ import itertools
17
+ import os
18
+ import re
19
+ from datetime import datetime, timedelta
20
+ from typing import IO, Any, Dict, Iterable, Iterator, List, Optional, Sequence, Union
21
+
22
+ from .types import (
23
+ AccumulationReading,
24
+ B2BDetails,
25
+ Header,
26
+ IntervalEvent,
27
+ IntervalReading,
28
+ NMIDetails,
29
+ )
30
+
31
+ # Trailing timezone offset in ISO 8601 form: ``+HH:MM`` or ``+HHMM``.
32
+ # Anchored to the end of the string and requires at least 4 digits so we
33
+ # never accidentally match the dash inside an ISO date like ``2024-01-01``.
34
+ _TZ_OFFSET_RE = re.compile(r"([+-]\d{2}:\d{2}|[+-]\d{4})$")
35
+
36
+ HEADER_RECORD = "100"
37
+ NMI_RECORD = "200"
38
+ ACCUMULATION_RECORD = "250"
39
+ INTERVAL_RECORD = "300"
40
+ EVENT_RECORD = "400"
41
+ B2B_RECORD = "500"
42
+ ACCUMULATION_B2B_RECORD = "550"
43
+ END_RECORD = "900"
44
+
45
+ # Field counts per AEMO MDFF v2.6 (NEM12 / NEM13).
46
+ ACCUMULATION_FIELDS = 23 # 250 record
47
+
48
+ MINUTES_PER_DAY = 24 * 60
49
+
50
+ # Path-like is anything ``os.fspath`` can convert. Streams are anything
51
+ # yielding lines / iterable rows.
52
+ PathLike = Union[str, "os.PathLike[str]"]
53
+ RowSource = Union[PathLike, Iterable[Sequence[str]], Iterable[str], IO[str]]
54
+ Columns = Dict[str, List[Any]]
55
+
56
+
57
+ class NEM12ParseError(ValueError):
58
+ """Raised when a NEM12 file violates the format in a way we cannot recover from."""
59
+
60
+
61
+ def _parse_datetime(value: str) -> Optional[datetime]:
62
+ """Parse a NEM12 date/datetime field.
63
+
64
+ Accepted forms (all return naive ``datetime`` objects — any timezone
65
+ suffix on the input is silently stripped, matching the existing
66
+ contract that callers attach the local AEMO market timezone
67
+ themselves):
68
+
69
+ * ``YYYYMMDD`` (spec: Date(8))
70
+ * ``YYYYMMDDhhmmss`` (spec: DateTime(14))
71
+ * ``YYYYMMDDhhmm`` (DateTime(12) — seen in some retailer exports)
72
+ * ``YYYY-MM-DD`` (ISO 8601 date — tolerance for non-spec exports)
73
+ * ``YYYY-MM-DDTHH:MM:SS`` and ``YYYY-MM-DD HH:MM:SS`` (ISO 8601
74
+ date-time, with or without microseconds)
75
+ * Any of the above followed by ``Z``, ``+HH:MM``, ``+HHMM``,
76
+ ``-HH:MM``, or ``-HHMM`` — the timezone suffix is dropped.
77
+ """
78
+ if not value:
79
+ return None
80
+
81
+ # Strip trailing timezone suffix if present. The result is fed
82
+ # straight back through the fixed-width or ISO branches below.
83
+ if value.endswith("Z"):
84
+ value = value[:-1]
85
+ m = _TZ_OFFSET_RE.search(value)
86
+ if m:
87
+ value = value[: m.start()]
88
+
89
+ n = len(value)
90
+ if n == 14:
91
+ return datetime(
92
+ int(value[0:4]),
93
+ int(value[4:6]),
94
+ int(value[6:8]),
95
+ int(value[8:10]),
96
+ int(value[10:12]),
97
+ int(value[12:14]),
98
+ )
99
+ if n == 12: # YYYYMMDDhhmm — seen in some retailer exports
100
+ return datetime(
101
+ int(value[0:4]),
102
+ int(value[4:6]),
103
+ int(value[6:8]),
104
+ int(value[8:10]),
105
+ int(value[10:12]),
106
+ )
107
+ if n == 8:
108
+ return datetime(int(value[0:4]), int(value[4:6]), int(value[6:8]))
109
+ if n == 10 and value[4] == "-" and value[7] == "-":
110
+ return datetime(int(value[0:4]), int(value[5:7]), int(value[8:10]))
111
+
112
+ # ISO 8601 date-time fall-through, e.g. ``2024-01-01T00:00:00`` or
113
+ # ``2024-01-01 00:00:00`` (with optional microseconds). datetime's
114
+ # native ``fromisoformat`` is strict about the leading dashes /
115
+ # colons; the basic form ``YYYYMMDDTHHMMSS`` is only recognised on
116
+ # Python 3.11+, so we don't promise it.
117
+ if "T" in value or " " in value:
118
+ try:
119
+ dt = datetime.fromisoformat(value)
120
+ except ValueError:
121
+ pass
122
+ else:
123
+ return dt.replace(tzinfo=None) if dt.tzinfo is not None else dt
124
+
125
+ raise NEM12ParseError(f"Unrecognised date/time value: {value!r}")
126
+
127
+
128
+ def _parse_int(value: str) -> Optional[int]:
129
+ if value == "" or value is None:
130
+ return None
131
+ return int(value)
132
+
133
+
134
+ def _parse_float(value: str) -> float:
135
+ """Parse a numeric NEM12 cell.
136
+
137
+ NEM12 uses decimal numbers without thousands separators. Empty cells
138
+ (sometimes seen for missing intervals) are coerced to ``0.0`` so a
139
+ single missing cell doesn't fail an entire row. Use the row's
140
+ ``QualityMethod`` flag to distinguish a real zero from a missing
141
+ reading — see :class:`IntervalReading` for the surfaced fields.
142
+ """
143
+ if value == "" or value is None:
144
+ return 0.0
145
+ return float(value)
146
+
147
+
148
+ class _ParserState:
149
+ """Mutable state held across rows during a single parse."""
150
+
151
+ __slots__ = ("current_nmi", "header", "interval_delta", "intervals_per_day")
152
+
153
+ def __init__(self) -> None:
154
+ self.header: Optional[Header] = None
155
+ self.current_nmi: Optional[NMIDetails] = None
156
+ self.intervals_per_day: int = 0
157
+ self.interval_delta: Optional[timedelta] = None
158
+
159
+
160
+ _BOM = ""
161
+
162
+
163
+ def _strip_bom(row: Sequence[str]) -> Sequence[str]:
164
+ """Strip a UTF-8 BOM from the first cell of the first row, if present.
165
+
166
+ Some retailer NEM12/NEM13 exports begin with a UTF-8 BOM. When read
167
+ via plain UTF-8 the BOM survives into ``row[0]``, turning the record
168
+ indicator ``"100"`` into ``"100"`` and silently breaking parsing.
169
+ """
170
+ if row and isinstance(row[0], str) and row[0].startswith(_BOM):
171
+ # Sequence may be tuple or list; rebuild as list to keep mutability semantics.
172
+ first = row[0].lstrip(_BOM)
173
+ return [first, *row[1:]]
174
+ return row
175
+
176
+
177
+ def _open_rows(source: RowSource) -> Iterator[Sequence[str]]:
178
+ """Yield rows from a path, file-like, or already-iterable row source.
179
+
180
+ For paths, the file is opened with the ``utf-8-sig`` encoding so a
181
+ leading UTF-8 BOM is consumed transparently. For file-like and
182
+ iterable sources, a BOM on the first row's first cell is stripped
183
+ explicitly.
184
+ """
185
+ # Path-like — handle BOM via utf-8-sig.
186
+ if isinstance(source, (str, os.PathLike)):
187
+ # newline='' is required for csv per the docs.
188
+ with open(os.fspath(source), encoding="utf-8-sig", newline="") as f:
189
+ yield from csv.reader(f)
190
+ return
191
+
192
+ # File-like (has .read but is not already an iterator of rows).
193
+ if hasattr(source, "read") and not hasattr(source, "__next__"):
194
+ first = True
195
+ for row in csv.reader(source): # type: ignore[arg-type]
196
+ yield _strip_bom(row) if first else row
197
+ first = False
198
+ return
199
+
200
+ # Iterable: peek at the first element to decide if it's raw lines or rows.
201
+ it: Iterator[Any] = iter(source)
202
+ try:
203
+ first = next(it)
204
+ except StopIteration:
205
+ return
206
+ chained: Iterator[Any] = itertools.chain([first], it)
207
+ if isinstance(first, str):
208
+ seen_first = False
209
+ for row in csv.reader(chained):
210
+ if not seen_first:
211
+ seen_first = True
212
+ yield _strip_bom(row)
213
+ else:
214
+ yield row
215
+ else:
216
+ # Already-split rows. Strip BOM from first row's first cell.
217
+ seen_first = False
218
+ for row in chained:
219
+ if not seen_first:
220
+ seen_first = True
221
+ yield _strip_bom(row)
222
+ else:
223
+ yield row
224
+
225
+
226
+ def parse(source: RowSource) -> Iterator[IntervalReading]:
227
+ """Parse a NEM12 file and yield :class:`IntervalReading` objects.
228
+
229
+ ``source`` accepts any of:
230
+
231
+ * a filesystem path (``str`` or ``os.PathLike``);
232
+ * an open text stream / file-like object;
233
+ * an iterable of raw CSV lines (``Iterable[str]``);
234
+ * an iterable of already-split rows (``Iterable[Sequence[str]]``,
235
+ e.g. a ``list[list[str]]`` you've built from
236
+ ``csv.reader(...).splitlines()``).
237
+
238
+ The pre-split-rows form is convenient when the data is already in
239
+ memory — say, decoded from a multipart upload — without forcing it
240
+ back through ``csv.reader``::
241
+
242
+ rows = list(csv.reader(StringIO(payload)))
243
+ for r in parse(rows):
244
+ ...
245
+
246
+ Iteration is lazy: memory use is O(1) in file size when ``source``
247
+ is a path or a stream. Pre-loaded sources keep whatever memory the
248
+ caller already paid for them, but the parser itself adds no extra.
249
+ """
250
+ state = _ParserState()
251
+ rows = iter(_open_rows(source))
252
+
253
+ for row in rows:
254
+ if not row:
255
+ continue
256
+ record = row[0]
257
+
258
+ if record == INTERVAL_RECORD:
259
+ yield from _emit_intervals(row, state)
260
+ elif record == NMI_RECORD:
261
+ state.current_nmi = _parse_nmi(row)
262
+ il = state.current_nmi.interval_length
263
+ if il <= 0 or MINUTES_PER_DAY % il != 0:
264
+ raise NEM12ParseError(f"IntervalLength {il} does not divide a 24-hour day")
265
+ state.intervals_per_day = MINUTES_PER_DAY // il
266
+ state.interval_delta = timedelta(minutes=il)
267
+ elif record == HEADER_RECORD:
268
+ state.header = _parse_header(row)
269
+ elif record == EVENT_RECORD or record == B2B_RECORD:
270
+ # 400 quality / 500 B2B rows attach to the most recent 300
271
+ # row. The default streaming reader skips them — call
272
+ # ``parse_events`` or ``parse_b2b`` to surface them.
273
+ continue
274
+ elif record == END_RECORD:
275
+ return
276
+ # Unknown record indicators (250, 550, enem12 600/610/700, ...)
277
+ # are ignored here. Use ``parse_accumulations`` for 250 records,
278
+ # ``parse_b2b`` for 500/550, or ``parse_all`` for 300+250 mixed.
279
+
280
+
281
+ def parse_header(source: RowSource) -> Optional[Header]:
282
+ """Return only the file header, then stop reading."""
283
+ for row in _open_rows(source):
284
+ if row and row[0] == HEADER_RECORD:
285
+ return _parse_header(row)
286
+ return None
287
+
288
+
289
+ def _parse_header(row: Sequence[str]) -> Header:
290
+ # 100, VersionHeader, DateTime, FromParticipant, ToParticipant
291
+ if len(row) < 3:
292
+ raise NEM12ParseError("100 header row missing required fields")
293
+ return Header(
294
+ version=row[1] if len(row) > 1 else "",
295
+ datetime=_parse_datetime(row[2]) if len(row) > 2 else None,
296
+ from_participant=row[3] if len(row) > 3 else "",
297
+ to_participant=row[4] if len(row) > 4 else "",
298
+ )
299
+
300
+
301
+ def _parse_nmi(row: Sequence[str]) -> NMIDetails:
302
+ # 200, NMI, NMIConfiguration, RegisterID, NMISuffix,
303
+ # MDMDataStreamIdentifier, MeterSerialNumber, UOM, IntervalLength,
304
+ # NextScheduledReadDate (per AEMO MDFF v2.6 §4.3).
305
+ #
306
+ # IntervalLength: the spec restricts this to 5, 15, or 30 minutes. We
307
+ # accept any divisor of 1440 for tolerance with non-conforming files;
308
+ # callers wanting strict validation can compare against
309
+ # ``aemo_mdff_reader.spec.ALLOWED_INTERVAL_LENGTHS``.
310
+ if len(row) < 9:
311
+ raise NEM12ParseError("200 NMI row missing required fields")
312
+ try:
313
+ interval_length = int(row[8])
314
+ except ValueError as exc:
315
+ raise NEM12ParseError(f"200 NMI row has non-integer IntervalLength: {row[8]!r}") from exc
316
+ if interval_length <= 0 or interval_length > MINUTES_PER_DAY:
317
+ raise NEM12ParseError(
318
+ f"200 NMI row has IntervalLength={interval_length}; must be in 1..{MINUTES_PER_DAY}"
319
+ )
320
+ return NMIDetails(
321
+ nmi=row[1],
322
+ nmi_configuration=row[2],
323
+ register_id=row[3] if len(row) > 3 else "",
324
+ nmi_suffix=row[4],
325
+ mdm_data_stream_identifier=row[5] if len(row) > 5 else "",
326
+ meter_serial_number=row[6] if len(row) > 6 else "",
327
+ uom=row[7],
328
+ interval_length=interval_length,
329
+ next_scheduled_read_date=_parse_datetime(row[9]) if len(row) > 9 else None,
330
+ )
331
+
332
+
333
+ def _emit_intervals(row: Sequence[str], state: _ParserState) -> Iterator[IntervalReading]:
334
+ nmi = state.current_nmi
335
+ if nmi is None:
336
+ raise NEM12ParseError("300 interval row encountered before any 200 NMI row")
337
+ n = state.intervals_per_day
338
+ delta = state.interval_delta
339
+ expected_min = 2 + n # 300 + IntervalDate + n values, then trailers (optional)
340
+ if len(row) < expected_min:
341
+ raise NEM12ParseError(
342
+ f"300 row has {len(row)} fields, expected at least {expected_min} "
343
+ f"for IntervalLength={nmi.interval_length}"
344
+ )
345
+
346
+ interval_date = _parse_datetime(row[1])
347
+ if interval_date is None:
348
+ raise NEM12ParseError("300 row missing IntervalDate")
349
+
350
+ # Trailers: QualityMethod, ReasonCode, ReasonDescription,
351
+ # UpdateDateTime, MSATSLoadDateTime — all optional in practice.
352
+ tail = row[2 + n :]
353
+ quality_method = tail[0] if len(tail) > 0 else ""
354
+ reason_code = _parse_int(tail[1]) if len(tail) > 1 else None
355
+ reason_description = tail[2] if len(tail) > 2 else ""
356
+ update_datetime = _parse_datetime(tail[3]) if len(tail) > 3 else None
357
+ msats_load_datetime = _parse_datetime(tail[4]) if len(tail) > 4 else None
358
+
359
+ # Pre-bind locals for inner loop speed.
360
+ _Reading = IntervalReading
361
+ _nmi = nmi.nmi
362
+ _meter = nmi.meter_serial_number
363
+ _reg = nmi.register_id
364
+ _suffix = nmi.nmi_suffix
365
+ _uom = nmi.uom
366
+ _il = nmi.interval_length
367
+
368
+ start = interval_date
369
+ base = 2
370
+ for i in range(n):
371
+ end = start + delta # type: ignore[operator]
372
+ yield _Reading(
373
+ nmi=_nmi,
374
+ meter_serial_number=_meter,
375
+ register_id=_reg,
376
+ nmi_suffix=_suffix,
377
+ uom=_uom,
378
+ interval_length=_il,
379
+ interval_date=interval_date,
380
+ interval_start=start,
381
+ interval_end=end,
382
+ interval_index=i + 1,
383
+ value=_parse_float(row[base + i]),
384
+ quality_method=quality_method,
385
+ reason_code=reason_code,
386
+ reason_description=reason_description,
387
+ update_datetime=update_datetime,
388
+ msats_load_datetime=msats_load_datetime,
389
+ )
390
+ start = end
391
+
392
+
393
+ def parse_to_columns(source: RowSource) -> Columns:
394
+ """Single-pass columnar parse — skips per-cell row object allocation.
395
+
396
+ This is the fast path for building a pandas DataFrame or writing a
397
+ flat CSV from a path. It is typically 2–4× faster than
398
+ ``to_columns(parse(path))`` because each interval cell becomes a
399
+ primitive append into pre-bound list locals, with no
400
+ :class:`IntervalReading` object created.
401
+
402
+ The returned dict has the same column layout as :func:`to_columns`.
403
+ """
404
+ cols: Columns = {
405
+ "NMI": [],
406
+ "MeterSerial": [],
407
+ "Register": [],
408
+ "Suffix": [],
409
+ "UOM": [],
410
+ "IntervalLength": [],
411
+ "IntervalDate": [],
412
+ "IntervalStart": [],
413
+ "IntervalEnd": [],
414
+ "Interval": [],
415
+ "Value": [],
416
+ "Quality": [],
417
+ "ReasonCode": [],
418
+ "ReasonDescription": [],
419
+ "UpdateDatetime": [],
420
+ "MSATSLoadDatetime": [],
421
+ }
422
+ a_nmi = cols["NMI"].append
423
+ a_meter = cols["MeterSerial"].append
424
+ a_reg = cols["Register"].append
425
+ a_suf = cols["Suffix"].append
426
+ a_uom = cols["UOM"].append
427
+ a_il = cols["IntervalLength"].append
428
+ a_idate = cols["IntervalDate"].append
429
+ a_istart = cols["IntervalStart"].append
430
+ a_iend = cols["IntervalEnd"].append
431
+ a_idx = cols["Interval"].append
432
+ a_val = cols["Value"].append
433
+ a_q = cols["Quality"].append
434
+ a_rc = cols["ReasonCode"].append
435
+ a_rd = cols["ReasonDescription"].append
436
+ a_ud = cols["UpdateDatetime"].append
437
+ a_mlt = cols["MSATSLoadDatetime"].append
438
+
439
+ state = _ParserState()
440
+ nmi_obj: Optional[NMIDetails] = None
441
+ n = 0
442
+ delta = None # type: Optional[timedelta]
443
+
444
+ for row in _open_rows(source):
445
+ if not row:
446
+ continue
447
+ rec = row[0]
448
+ if rec == INTERVAL_RECORD:
449
+ if nmi_obj is None:
450
+ raise NEM12ParseError("300 interval row encountered before any 200 NMI row")
451
+ expected_min = 2 + n
452
+ if len(row) < expected_min:
453
+ raise NEM12ParseError(
454
+ f"300 row has {len(row)} fields, expected at least {expected_min}"
455
+ )
456
+ interval_date = _parse_datetime(row[1])
457
+ if interval_date is None:
458
+ raise NEM12ParseError("300 row missing IntervalDate")
459
+ tail = row[2 + n :]
460
+ quality = tail[0] if len(tail) > 0 else ""
461
+ reason_code = _parse_int(tail[1]) if len(tail) > 1 else None
462
+ reason_desc = tail[2] if len(tail) > 2 else ""
463
+ update_dt = _parse_datetime(tail[3]) if len(tail) > 3 else None
464
+ msats_dt = _parse_datetime(tail[4]) if len(tail) > 4 else None
465
+
466
+ _nmi_v = nmi_obj.nmi
467
+ _meter_v = nmi_obj.meter_serial_number
468
+ _reg_v = nmi_obj.register_id
469
+ _suf_v = nmi_obj.nmi_suffix
470
+ _uom_v = nmi_obj.uom
471
+ _il_v = nmi_obj.interval_length
472
+ start = interval_date
473
+ base = 2
474
+ for i in range(n):
475
+ end = start + delta # type: ignore[operator]
476
+ a_nmi(_nmi_v)
477
+ a_meter(_meter_v)
478
+ a_reg(_reg_v)
479
+ a_suf(_suf_v)
480
+ a_uom(_uom_v)
481
+ a_il(_il_v)
482
+ a_idate(interval_date)
483
+ a_istart(start)
484
+ a_iend(end)
485
+ a_idx(i + 1)
486
+ cell = row[base + i]
487
+ a_val(0.0 if cell == "" else float(cell))
488
+ a_q(quality)
489
+ a_rc(reason_code)
490
+ a_rd(reason_desc)
491
+ a_ud(update_dt)
492
+ a_mlt(msats_dt)
493
+ start = end
494
+ elif rec == NMI_RECORD:
495
+ nmi_obj = _parse_nmi(row)
496
+ il = nmi_obj.interval_length
497
+ if il <= 0 or MINUTES_PER_DAY % il != 0:
498
+ raise NEM12ParseError(f"IntervalLength {il} does not divide a 24-hour day")
499
+ n = MINUTES_PER_DAY // il
500
+ delta = timedelta(minutes=il)
501
+ elif rec == HEADER_RECORD:
502
+ state.header = _parse_header(row)
503
+ elif rec == END_RECORD:
504
+ break
505
+ # 400/500/other records are skipped here.
506
+ return cols
507
+
508
+
509
+ def to_columns(readings: Iterable[IntervalReading]) -> Columns:
510
+ """Materialise an iterable of readings into a dict of column lists.
511
+
512
+ Use :func:`parse_to_columns` directly when you want to skip the
513
+ per-cell :class:`IntervalReading` object allocation — it parses
514
+ straight into columns in one pass.
515
+ """
516
+ cols: Columns = {
517
+ "NMI": [],
518
+ "MeterSerial": [],
519
+ "Register": [],
520
+ "Suffix": [],
521
+ "UOM": [],
522
+ "IntervalLength": [],
523
+ "IntervalDate": [],
524
+ "IntervalStart": [],
525
+ "IntervalEnd": [],
526
+ "Interval": [],
527
+ "Value": [],
528
+ "Quality": [],
529
+ "ReasonCode": [],
530
+ "ReasonDescription": [],
531
+ "UpdateDatetime": [],
532
+ "MSATSLoadDatetime": [],
533
+ }
534
+ # Pre-bind list.append to avoid attribute lookups in the hot loop.
535
+ app_nmi = cols["NMI"].append
536
+ app_meter = cols["MeterSerial"].append
537
+ app_reg = cols["Register"].append
538
+ app_suf = cols["Suffix"].append
539
+ app_uom = cols["UOM"].append
540
+ app_il = cols["IntervalLength"].append
541
+ app_idate = cols["IntervalDate"].append
542
+ app_istart = cols["IntervalStart"].append
543
+ app_iend = cols["IntervalEnd"].append
544
+ app_idx = cols["Interval"].append
545
+ app_val = cols["Value"].append
546
+ app_q = cols["Quality"].append
547
+ app_rc = cols["ReasonCode"].append
548
+ app_rd = cols["ReasonDescription"].append
549
+ app_ud = cols["UpdateDatetime"].append
550
+ app_md = cols["MSATSLoadDatetime"].append
551
+ for r in readings:
552
+ app_nmi(r.nmi)
553
+ app_meter(r.meter_serial_number)
554
+ app_reg(r.register_id)
555
+ app_suf(r.nmi_suffix)
556
+ app_uom(r.uom)
557
+ app_il(r.interval_length)
558
+ app_idate(r.interval_date)
559
+ app_istart(r.interval_start)
560
+ app_iend(r.interval_end)
561
+ app_idx(r.interval_index)
562
+ app_val(r.value)
563
+ app_q(r.quality_method)
564
+ app_rc(r.reason_code)
565
+ app_rd(r.reason_description)
566
+ app_ud(r.update_datetime)
567
+ app_md(r.msats_load_datetime)
568
+ return cols
569
+
570
+
571
+ def to_dataframe(source: Union[RowSource, Iterable[IntervalReading]]) -> Any:
572
+ """Build a pandas DataFrame from a NEM12 source or reading iterable.
573
+
574
+ Accepts a path, file-like, or an iterable of :class:`IntervalReading`
575
+ objects. When given a path/file, takes the columnar fast-path for
576
+ a 2–4× speedup over iterating through Python row objects.
577
+
578
+ .. note::
579
+ This materialises every reading. For files larger than a few
580
+ hundred MiB use :func:`iter_dataframes` instead — it yields
581
+ fixed-size DataFrame chunks and stays bounded in memory.
582
+
583
+ Requires ``pandas`` (``pip install aemo-mdff-reader[pandas]``).
584
+ """
585
+ try:
586
+ import pandas as pd
587
+ except ImportError as exc: # pragma: no cover
588
+ raise ImportError(
589
+ "to_dataframe requires pandas. Install it with "
590
+ "'pip install aemo-mdff-reader[pandas]' or 'pip install pandas'."
591
+ ) from exc
592
+
593
+ # Fast path: path-like or file-like — parse straight into columns.
594
+ if isinstance(source, (str, os.PathLike)) or hasattr(source, "read"):
595
+ return pd.DataFrame(parse_to_columns(source)) # type: ignore[arg-type]
596
+ # Iterable of IntervalReading — use the row-by-row builder.
597
+ return pd.DataFrame(to_columns(source)) # type: ignore[arg-type]
598
+
599
+
600
+ def iter_dataframes(
601
+ source: Union[RowSource, Iterable[IntervalReading]],
602
+ chunk_size: int = 100_000,
603
+ ) -> Iterator[Any]:
604
+ """Yield pandas DataFrames in chunks of ``chunk_size`` interval readings.
605
+
606
+ Memory cost is O(chunk_size), independent of the source file size
607
+ — letting a single workflow process arbitrarily large NEM12 files
608
+ without OOM. ``chunk_size`` defaults to 100,000 readings, which is
609
+ roughly 30–60 MiB of DataFrame depending on string fields.
610
+
611
+ >>> import pandas as pd
612
+ >>> from aemo_mdff_reader import iter_dataframes
613
+ >>> for df in iter_dataframes("huge.csv", chunk_size=50_000):
614
+ ... df.groupby("NMI")["Value"].sum().to_csv("partial.csv", mode="a")
615
+
616
+ For columnar processing without pandas, see
617
+ :func:`iter_columns_chunks`.
618
+ """
619
+ try:
620
+ import pandas as pd
621
+ except ImportError as exc: # pragma: no cover
622
+ raise ImportError(
623
+ "iter_dataframes requires pandas. Install it with "
624
+ "'pip install aemo-mdff-reader[pandas]' or 'pip install pandas'."
625
+ ) from exc
626
+
627
+ # Always go through the row-by-row generator so the function
628
+ # accepts both a source (path/file/iterable of rows) and an
629
+ # iterable of pre-built IntervalReading objects.
630
+ if isinstance(source, (str, os.PathLike)) or hasattr(source, "read"):
631
+ readings: Iterable[IntervalReading] = parse(source) # type: ignore[arg-type]
632
+ else:
633
+ readings = source # type: ignore[assignment]
634
+
635
+ from .aggregate import iter_chunks # local import to avoid cycle
636
+
637
+ for batch in iter_chunks(readings, chunk_size):
638
+ yield pd.DataFrame(to_columns(batch))
639
+
640
+
641
+ def iter_columns_chunks(
642
+ source: Union[RowSource, Iterable[IntervalReading]],
643
+ chunk_size: int = 100_000,
644
+ ) -> Iterator[Columns]:
645
+ """Yield :data:`Columns` dicts in chunks of ``chunk_size`` rows.
646
+
647
+ Pandas-free counterpart to :func:`iter_dataframes`. Memory bound to
648
+ O(chunk_size). Useful for chunked parquet writes via pyarrow, or
649
+ feeding a database in batches with the ``executemany`` API.
650
+ """
651
+ if isinstance(source, (str, os.PathLike)) or hasattr(source, "read"):
652
+ readings: Iterable[IntervalReading] = parse(source) # type: ignore[arg-type]
653
+ else:
654
+ readings = source # type: ignore[assignment]
655
+
656
+ from .aggregate import iter_chunks # local import to avoid cycle
657
+
658
+ for batch in iter_chunks(readings, chunk_size):
659
+ yield to_columns(batch)
660
+
661
+
662
+ def write_csv(readings: Iterable[IntervalReading], output: Union[PathLike, IO[str]]) -> int:
663
+ """Write readings as a flat CSV. Returns the number of rows written.
664
+
665
+ Pure-stdlib path that does not require pandas.
666
+ """
667
+ columns = [
668
+ "NMI",
669
+ "MeterSerial",
670
+ "Register",
671
+ "Suffix",
672
+ "UOM",
673
+ "IntervalLength",
674
+ "IntervalDate",
675
+ "IntervalStart",
676
+ "IntervalEnd",
677
+ "Interval",
678
+ "Value",
679
+ "Quality",
680
+ "ReasonCode",
681
+ "ReasonDescription",
682
+ "UpdateDatetime",
683
+ "MSATSLoadDatetime",
684
+ ]
685
+ if isinstance(output, (str, os.PathLike)):
686
+ # Force UTF-8 — without an explicit encoding Python falls back to
687
+ # the platform default (cp1252 on Windows) which may misencode
688
+ # any non-ASCII characters carried over from the source file.
689
+ f = open(os.fspath(output), "w", encoding="utf-8", newline="")
690
+ close = True
691
+ else:
692
+ f = output # type: ignore[assignment]
693
+ close = False
694
+ try:
695
+ w = csv.writer(f)
696
+ w.writerow(columns)
697
+ count = 0
698
+ for r in readings:
699
+ w.writerow(
700
+ [
701
+ r.nmi,
702
+ r.meter_serial_number,
703
+ r.register_id,
704
+ r.nmi_suffix,
705
+ r.uom,
706
+ r.interval_length,
707
+ r.interval_date.strftime("%Y-%m-%d") if r.interval_date else "",
708
+ r.interval_start.isoformat() if r.interval_start else "",
709
+ r.interval_end.isoformat() if r.interval_end else "",
710
+ r.interval_index,
711
+ r.value,
712
+ r.quality_method,
713
+ "" if r.reason_code is None else r.reason_code,
714
+ r.reason_description,
715
+ r.update_datetime.isoformat() if r.update_datetime else "",
716
+ r.msats_load_datetime.isoformat() if r.msats_load_datetime else "",
717
+ ]
718
+ )
719
+ count += 1
720
+ return count
721
+ finally:
722
+ if close:
723
+ f.close()
724
+
725
+
726
+ # --------------------------------------------------------------------------
727
+ # NEM13 — 250 accumulation records
728
+ # --------------------------------------------------------------------------
729
+
730
+
731
+ def _parse_accumulation(row: Sequence[str]) -> AccumulationReading:
732
+ """Parse a single 250 (NEM13) accumulation row.
733
+
734
+ Field order follows AEMO MDFF v2.6 §5.3 (NEM13):
735
+
736
+ 1. RecordIndicator (250)
737
+ 2. NMI 13. PreviousReasonDescription
738
+ 3. NMIConfiguration 14. CurrentRegisterRead
739
+ 4. RegisterID 15. CurrentRegisterReadDateTime
740
+ 5. NMISuffix 16. CurrentQualityMethod
741
+ 6. MDMDataStreamIdentifier 17. CurrentReasonCode
742
+ 7. MeterSerialNumber 18. CurrentReasonDescription
743
+ 8. DirectionIndicator 19. Quantity
744
+ 9. PreviousRegisterRead 20. UOM
745
+ 10. PreviousRegisterReadDateTime 21. NextScheduledReadDate
746
+ 11. PreviousQualityMethod 22. UpdateDateTime
747
+ 12. PreviousReasonCode 23. MSATSLoadDateTime
748
+ """
749
+ if len(row) < 20:
750
+ raise NEM12ParseError(
751
+ f"250 accumulation row has {len(row)} fields; spec requires at least 20"
752
+ )
753
+
754
+ def _get(i: int) -> str:
755
+ return row[i] if i < len(row) else ""
756
+
757
+ return AccumulationReading(
758
+ nmi=row[1],
759
+ nmi_configuration=row[2],
760
+ register_id=row[3],
761
+ nmi_suffix=row[4],
762
+ mdm_data_stream_identifier=_get(5),
763
+ meter_serial_number=row[6],
764
+ direction_indicator=_get(7),
765
+ previous_register_read=_parse_float(row[8]),
766
+ previous_register_read_datetime=_parse_datetime(_get(9)),
767
+ previous_quality_method=_get(10),
768
+ previous_reason_code=_parse_int(_get(11)),
769
+ previous_reason_description=_get(12),
770
+ current_register_read=_parse_float(row[13]),
771
+ current_register_read_datetime=_parse_datetime(_get(14)),
772
+ current_quality_method=_get(15),
773
+ current_reason_code=_parse_int(_get(16)),
774
+ current_reason_description=_get(17),
775
+ quantity=_parse_float(row[18]),
776
+ uom=row[19],
777
+ next_scheduled_read_date=_parse_datetime(_get(20)),
778
+ update_datetime=_parse_datetime(_get(21)),
779
+ msats_load_datetime=_parse_datetime(_get(22)),
780
+ )
781
+
782
+
783
+ def parse_accumulations(source: RowSource) -> Iterator[AccumulationReading]:
784
+ """Yield NEM13 250 (accumulation / manual-read) records as a stream.
785
+
786
+ NEM13 250 records are self-contained — unlike NEM12 300 records they
787
+ do not depend on a parent 200 row. Iteration is lazy; memory is O(1).
788
+ """
789
+ for row in _open_rows(source):
790
+ if not row:
791
+ continue
792
+ rec = row[0]
793
+ if rec == ACCUMULATION_RECORD:
794
+ yield _parse_accumulation(row)
795
+ elif rec == END_RECORD:
796
+ return
797
+
798
+
799
+ def parse_all(
800
+ source: RowSource,
801
+ ) -> Iterator[Union[IntervalReading, AccumulationReading]]:
802
+ """Yield NEM12 300 interval readings and NEM13 250 accumulation
803
+ records together, in file order.
804
+
805
+ Use this for files that interleave 300 and 250 rows. Other record
806
+ types are not surfaced — call :func:`parse_events` for 400 quality
807
+ flags or :func:`parse_b2b` for 500 / 550 transactions.
808
+ """
809
+ state = _ParserState()
810
+ nmi_obj: Optional[NMIDetails] = None
811
+ n = 0
812
+ delta: Optional[timedelta] = None
813
+
814
+ for row in _open_rows(source):
815
+ if not row:
816
+ continue
817
+ rec = row[0]
818
+ if rec == INTERVAL_RECORD:
819
+ if nmi_obj is None:
820
+ raise NEM12ParseError("300 interval row encountered before any 200 NMI row")
821
+ yield from _emit_intervals_with(row, nmi_obj, n, delta)
822
+ elif rec == ACCUMULATION_RECORD:
823
+ yield _parse_accumulation(row)
824
+ elif rec == NMI_RECORD:
825
+ nmi_obj = _parse_nmi(row)
826
+ il = nmi_obj.interval_length
827
+ if il <= 0 or MINUTES_PER_DAY % il != 0:
828
+ raise NEM12ParseError(f"IntervalLength {il} does not divide a 24-hour day")
829
+ n = MINUTES_PER_DAY // il
830
+ delta = timedelta(minutes=il)
831
+ elif rec == HEADER_RECORD:
832
+ state.header = _parse_header(row)
833
+ elif rec == END_RECORD:
834
+ return
835
+
836
+
837
+ def _emit_intervals_with(
838
+ row: Sequence[str],
839
+ nmi_obj: NMIDetails,
840
+ n: int,
841
+ delta: Optional[timedelta],
842
+ ) -> Iterator[IntervalReading]:
843
+ """Variant of :func:`_emit_intervals` that takes its NMI context as args.
844
+
845
+ Used by :func:`parse_all` so it doesn't need a shared mutable state object.
846
+ """
847
+ state = _ParserState()
848
+ state.current_nmi = nmi_obj
849
+ state.intervals_per_day = n
850
+ state.interval_delta = delta
851
+ yield from _emit_intervals(row, state)
852
+
853
+
854
+ # --------------------------------------------------------------------------
855
+ # Columnar helpers for accumulations
856
+ # --------------------------------------------------------------------------
857
+
858
+
859
+ def parse_accumulations_to_columns(source: RowSource) -> Columns:
860
+ """Single-pass columnar parse of NEM13 250 records."""
861
+ cols: Columns = {
862
+ "NMI": [],
863
+ "NMIConfiguration": [],
864
+ "Register": [],
865
+ "Suffix": [],
866
+ "MDMDataStreamIdentifier": [],
867
+ "MeterSerial": [],
868
+ "DirectionIndicator": [],
869
+ "PreviousRegisterRead": [],
870
+ "PreviousRegisterReadDatetime": [],
871
+ "PreviousQualityMethod": [],
872
+ "PreviousReasonCode": [],
873
+ "PreviousReasonDescription": [],
874
+ "CurrentRegisterRead": [],
875
+ "CurrentRegisterReadDatetime": [],
876
+ "CurrentQualityMethod": [],
877
+ "CurrentReasonCode": [],
878
+ "CurrentReasonDescription": [],
879
+ "Quantity": [],
880
+ "UOM": [],
881
+ "NextScheduledReadDate": [],
882
+ "UpdateDatetime": [],
883
+ "MSATSLoadDatetime": [],
884
+ }
885
+ for r in parse_accumulations(source):
886
+ cols["NMI"].append(r.nmi)
887
+ cols["NMIConfiguration"].append(r.nmi_configuration)
888
+ cols["Register"].append(r.register_id)
889
+ cols["Suffix"].append(r.nmi_suffix)
890
+ cols["MDMDataStreamIdentifier"].append(r.mdm_data_stream_identifier)
891
+ cols["MeterSerial"].append(r.meter_serial_number)
892
+ cols["DirectionIndicator"].append(r.direction_indicator)
893
+ cols["PreviousRegisterRead"].append(r.previous_register_read)
894
+ cols["PreviousRegisterReadDatetime"].append(r.previous_register_read_datetime)
895
+ cols["PreviousQualityMethod"].append(r.previous_quality_method)
896
+ cols["PreviousReasonCode"].append(r.previous_reason_code)
897
+ cols["PreviousReasonDescription"].append(r.previous_reason_description)
898
+ cols["CurrentRegisterRead"].append(r.current_register_read)
899
+ cols["CurrentRegisterReadDatetime"].append(r.current_register_read_datetime)
900
+ cols["CurrentQualityMethod"].append(r.current_quality_method)
901
+ cols["CurrentReasonCode"].append(r.current_reason_code)
902
+ cols["CurrentReasonDescription"].append(r.current_reason_description)
903
+ cols["Quantity"].append(r.quantity)
904
+ cols["UOM"].append(r.uom)
905
+ cols["NextScheduledReadDate"].append(r.next_scheduled_read_date)
906
+ cols["UpdateDatetime"].append(r.update_datetime)
907
+ cols["MSATSLoadDatetime"].append(r.msats_load_datetime)
908
+ return cols
909
+
910
+
911
+ def to_accumulations_dataframe(source: RowSource) -> Any:
912
+ """Build a pandas DataFrame of NEM13 250 records.
913
+
914
+ Requires ``pandas`` (``pip install aemo-mdff-reader[pandas]``).
915
+ """
916
+ try:
917
+ import pandas as pd
918
+ except ImportError as exc: # pragma: no cover
919
+ raise ImportError(
920
+ "to_accumulations_dataframe requires pandas. Install it with "
921
+ "'pip install aemo-mdff-reader[pandas]' or 'pip install pandas'."
922
+ ) from exc
923
+ return pd.DataFrame(parse_accumulations_to_columns(source))
924
+
925
+
926
+ def write_accumulations_csv(
927
+ accumulations: Iterable[AccumulationReading], output: Union[PathLike, IO[str]]
928
+ ) -> int:
929
+ """Write NEM13 250 records as a flat CSV. Returns the row count."""
930
+ columns = [
931
+ "NMI",
932
+ "NMIConfiguration",
933
+ "Register",
934
+ "Suffix",
935
+ "MDMDataStreamIdentifier",
936
+ "MeterSerial",
937
+ "DirectionIndicator",
938
+ "PreviousRegisterRead",
939
+ "PreviousRegisterReadDatetime",
940
+ "PreviousQualityMethod",
941
+ "PreviousReasonCode",
942
+ "PreviousReasonDescription",
943
+ "CurrentRegisterRead",
944
+ "CurrentRegisterReadDatetime",
945
+ "CurrentQualityMethod",
946
+ "CurrentReasonCode",
947
+ "CurrentReasonDescription",
948
+ "Quantity",
949
+ "UOM",
950
+ "NextScheduledReadDate",
951
+ "UpdateDatetime",
952
+ "MSATSLoadDatetime",
953
+ ]
954
+ if isinstance(output, (str, os.PathLike)):
955
+ f = open(os.fspath(output), "w", encoding="utf-8", newline="")
956
+ close = True
957
+ else:
958
+ f = output # type: ignore[assignment]
959
+ close = False
960
+ try:
961
+ w = csv.writer(f)
962
+ w.writerow(columns)
963
+ count = 0
964
+ for r in accumulations:
965
+ w.writerow(
966
+ [
967
+ r.nmi,
968
+ r.nmi_configuration,
969
+ r.register_id,
970
+ r.nmi_suffix,
971
+ r.mdm_data_stream_identifier,
972
+ r.meter_serial_number,
973
+ r.direction_indicator,
974
+ r.previous_register_read,
975
+ r.previous_register_read_datetime.isoformat()
976
+ if r.previous_register_read_datetime
977
+ else "",
978
+ r.previous_quality_method,
979
+ "" if r.previous_reason_code is None else r.previous_reason_code,
980
+ r.previous_reason_description,
981
+ r.current_register_read,
982
+ r.current_register_read_datetime.isoformat()
983
+ if r.current_register_read_datetime
984
+ else "",
985
+ r.current_quality_method,
986
+ "" if r.current_reason_code is None else r.current_reason_code,
987
+ r.current_reason_description,
988
+ r.quantity,
989
+ r.uom,
990
+ r.next_scheduled_read_date.isoformat() if r.next_scheduled_read_date else "",
991
+ r.update_datetime.isoformat() if r.update_datetime else "",
992
+ r.msats_load_datetime.isoformat() if r.msats_load_datetime else "",
993
+ ]
994
+ )
995
+ count += 1
996
+ return count
997
+ finally:
998
+ if close:
999
+ f.close()
1000
+
1001
+
1002
+ # --------------------------------------------------------------------------
1003
+ # 400 Interval events / 500 + 550 B2B records
1004
+ # --------------------------------------------------------------------------
1005
+
1006
+
1007
+ def _parse_interval_event(
1008
+ row: Sequence[str],
1009
+ nmi: NMIDetails,
1010
+ interval_date: datetime,
1011
+ ) -> IntervalEvent:
1012
+ """Parse a single 400 (Interval Event) row.
1013
+
1014
+ Field order per AEMO MDFF v2.6 §4.5:
1015
+ 1. RecordIndicator (400)
1016
+ 2. StartInterval 4. QualityMethod
1017
+ 3. EndInterval 5. ReasonCode
1018
+ 6. ReasonDescription
1019
+ """
1020
+ if len(row) < 4:
1021
+ raise NEM12ParseError(f"400 event row has {len(row)} fields; spec requires at least 4")
1022
+
1023
+ def _get(i: int) -> str:
1024
+ return row[i] if i < len(row) else ""
1025
+
1026
+ return IntervalEvent(
1027
+ nmi=nmi.nmi,
1028
+ meter_serial_number=nmi.meter_serial_number,
1029
+ register_id=nmi.register_id,
1030
+ interval_date=interval_date,
1031
+ start_interval=int(row[1]),
1032
+ end_interval=int(row[2]),
1033
+ quality_method=_get(3),
1034
+ reason_code=_parse_int(_get(4)),
1035
+ reason_description=_get(5),
1036
+ )
1037
+
1038
+
1039
+ def _parse_b2b(row: Sequence[str]) -> B2BDetails:
1040
+ """Parse a 500 or 550 B2B detail row.
1041
+
1042
+ 500 fields: 500, TransCode, RetServiceOrder, ReadDateTime, IndexRead.
1043
+ 550 fields: 550, PreviousTransCode, PreviousRetServiceOrder,
1044
+ CurrentTransCode, CurrentRetServiceOrder.
1045
+ """
1046
+ rec = row[0]
1047
+
1048
+ def _get(i: int) -> str:
1049
+ return row[i] if i < len(row) else ""
1050
+
1051
+ if rec == B2B_RECORD: # 500
1052
+ return B2BDetails(
1053
+ record_kind=rec,
1054
+ trans_code=_get(1),
1055
+ ret_service_order=_get(2),
1056
+ read_datetime=_parse_datetime(_get(3)),
1057
+ index_read=_get(4),
1058
+ )
1059
+ if rec == ACCUMULATION_B2B_RECORD: # 550
1060
+ return B2BDetails(
1061
+ record_kind=rec,
1062
+ previous_trans_code=_get(1),
1063
+ previous_ret_service_order=_get(2),
1064
+ current_trans_code=_get(3),
1065
+ current_ret_service_order=_get(4),
1066
+ )
1067
+ raise NEM12ParseError(f"Unsupported B2B record indicator: {rec!r}")
1068
+
1069
+
1070
+ def parse_events(source: RowSource) -> Iterator[IntervalEvent]:
1071
+ """Yield NEM12 400 (interval event) rows attached to their parent
1072
+ 200 / 300 context.
1073
+
1074
+ Iteration is lazy and O(1) memory.
1075
+ """
1076
+ state = _ParserState()
1077
+ last_interval_date: Optional[datetime] = None
1078
+ for row in _open_rows(source):
1079
+ if not row:
1080
+ continue
1081
+ rec = row[0]
1082
+ if rec == NMI_RECORD:
1083
+ state.current_nmi = _parse_nmi(row)
1084
+ elif rec == INTERVAL_RECORD:
1085
+ if state.current_nmi is None:
1086
+ raise NEM12ParseError("300 interval row encountered before any 200 NMI row")
1087
+ last_interval_date = _parse_datetime(row[1])
1088
+ elif rec == EVENT_RECORD:
1089
+ if state.current_nmi is None or last_interval_date is None:
1090
+ raise NEM12ParseError("400 event row encountered without a parent 300 row")
1091
+ yield _parse_interval_event(row, state.current_nmi, last_interval_date)
1092
+ elif rec == END_RECORD:
1093
+ return
1094
+
1095
+
1096
+ def parse_b2b(source: RowSource) -> Iterator[B2BDetails]:
1097
+ """Yield 500 (NEM12 B2B) and 550 (NEM13 B2B) records as a stream."""
1098
+ for row in _open_rows(source):
1099
+ if not row:
1100
+ continue
1101
+ rec = row[0]
1102
+ if rec in (B2B_RECORD, ACCUMULATION_B2B_RECORD):
1103
+ yield _parse_b2b(row)
1104
+ elif rec == END_RECORD:
1105
+ return
1106
+
1107
+
1108
+ # --------------------------------------------------------------------------
1109
+ # Validation utilities
1110
+ # --------------------------------------------------------------------------
1111
+
1112
+
1113
+ def nmi_checksum(nmi: str) -> int:
1114
+ """Compute the AEMO NMI checksum digit (0-9).
1115
+
1116
+ Implements the AEMO NMI Checksum Algorithm: for the 10-character NMI,
1117
+ each character is converted to its ASCII value; characters at *odd*
1118
+ positions counting from the right (starting at 1) are doubled; the
1119
+ decimal digits of all values are summed; the checksum is
1120
+ ``(10 - (sum % 10)) % 10``.
1121
+
1122
+ Iterating left-to-right (``i = 0..9``), position-from-the-right is
1123
+ ``10 - i``, which is odd exactly when ``i`` is even — so we double
1124
+ when ``i % 2 == 0`` (left-to-right indices 0, 2, 4, 6, 8).
1125
+
1126
+ Raises :class:`ValueError` if ``nmi`` is not exactly 10 characters
1127
+ or contains a non-printable / non-ASCII character.
1128
+ """
1129
+ if len(nmi) != 10:
1130
+ raise ValueError(f"NMI must be exactly 10 characters, got {nmi!r}")
1131
+ if any(not (32 < ord(c) < 127) for c in nmi):
1132
+ raise ValueError(f"NMI contains non-printable characters: {nmi!r}")
1133
+ total = 0
1134
+ for i, ch in enumerate(nmi):
1135
+ v = ord(ch)
1136
+ if i % 2 == 0: # odd position from the right
1137
+ v *= 2
1138
+ while v:
1139
+ total += v % 10
1140
+ v //= 10
1141
+ return (10 - (total % 10)) % 10
1142
+
1143
+
1144
+ def validate_nmi(nmi: str, checksum_digit: Optional[str] = None) -> bool:
1145
+ """Return True if the NMI is structurally valid.
1146
+
1147
+ A NEM12/13 NMI is exactly 10 printable ASCII characters. If
1148
+ ``checksum_digit`` is provided, it is also compared against the
1149
+ computed AEMO NMI checksum.
1150
+ """
1151
+ try:
1152
+ computed = nmi_checksum(nmi)
1153
+ except ValueError:
1154
+ return False
1155
+ if checksum_digit is None:
1156
+ return True
1157
+ return str(computed) == checksum_digit
1158
+
1159
+
1160
+ def validate_file(source: RowSource) -> List[str]:
1161
+ """Run lightweight structural checks against a NEM12 / NEM13 file.
1162
+
1163
+ Returns a list of human-readable problem descriptions; an empty list
1164
+ means the file passed all structural checks. This does not replace
1165
+ full parsing — it surfaces common issues early (missing 100 header,
1166
+ missing 900 footer, 300 row outside any 200 context, wrong 250 field
1167
+ count, NMI structural validity) so callers can fail fast.
1168
+
1169
+ .. note::
1170
+ ``source`` is consumed once. Pass a path or a fresh file handle if
1171
+ you intend to also call :func:`parse` afterward — passing a
1172
+ generator or single-pass iterator will leave it exhausted.
1173
+ """
1174
+ issues: List[str] = []
1175
+ saw_header = False
1176
+ saw_footer = False
1177
+ current_nmi: Optional[NMIDetails] = None
1178
+ seen_nmis: set[str] = set()
1179
+ line_no = 0
1180
+ for row in _open_rows(source):
1181
+ line_no += 1
1182
+ if not row:
1183
+ continue
1184
+ rec = row[0]
1185
+ if rec == HEADER_RECORD:
1186
+ if saw_header:
1187
+ issues.append(f"line {line_no}: duplicate 100 header")
1188
+ saw_header = True
1189
+ if line_no != 1:
1190
+ issues.append(f"line {line_no}: 100 header should be the first row")
1191
+ try:
1192
+ _parse_header(row)
1193
+ except NEM12ParseError as exc:
1194
+ issues.append(f"line {line_no}: {exc}")
1195
+ elif rec == NMI_RECORD:
1196
+ try:
1197
+ current_nmi = _parse_nmi(row)
1198
+ if not validate_nmi(current_nmi.nmi):
1199
+ issues.append(f"line {line_no}: NMI {current_nmi.nmi!r} has invalid structure")
1200
+ seen_nmis.add(current_nmi.nmi)
1201
+ except NEM12ParseError as exc:
1202
+ issues.append(f"line {line_no}: {exc}")
1203
+ elif rec == INTERVAL_RECORD:
1204
+ if current_nmi is None:
1205
+ issues.append(f"line {line_no}: 300 row encountered before any 200 NMI row")
1206
+ elif rec == ACCUMULATION_RECORD:
1207
+ if len(row) < 20:
1208
+ issues.append(
1209
+ f"line {line_no}: 250 row has {len(row)} fields, spec requires at least 20"
1210
+ )
1211
+ elif rec == END_RECORD:
1212
+ saw_footer = True
1213
+ if not saw_header:
1214
+ issues.append("missing 100 header record")
1215
+ if not saw_footer:
1216
+ issues.append("missing 900 footer record")
1217
+ return issues
1218
+
1219
+
1220
+ __all__ = [
1221
+ "ACCUMULATION_FIELDS",
1222
+ "NEM12ParseError",
1223
+ "iter_columns_chunks",
1224
+ "iter_dataframes",
1225
+ "nmi_checksum",
1226
+ "parse",
1227
+ "parse_accumulations",
1228
+ "parse_accumulations_to_columns",
1229
+ "parse_all",
1230
+ "parse_b2b",
1231
+ "parse_events",
1232
+ "parse_header",
1233
+ "parse_to_columns",
1234
+ "to_accumulations_dataframe",
1235
+ "to_columns",
1236
+ "to_dataframe",
1237
+ "validate_file",
1238
+ "validate_nmi",
1239
+ "write_accumulations_csv",
1240
+ "write_csv",
1241
+ ]