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.
- aemo_mdff_reader/__init__.py +84 -0
- aemo_mdff_reader/aggregate.py +168 -0
- aemo_mdff_reader/cli.py +231 -0
- aemo_mdff_reader/parser.py +1241 -0
- aemo_mdff_reader/py.typed +0 -0
- aemo_mdff_reader/reader.py +107 -0
- aemo_mdff_reader/spec.py +242 -0
- aemo_mdff_reader/sql/__init__.py +22 -0
- aemo_mdff_reader/sql/query.py +102 -0
- aemo_mdff_reader/sql/store.py +178 -0
- aemo_mdff_reader/types.py +437 -0
- aemo_mdff_reader-2.0.2.dist-info/METADATA +247 -0
- aemo_mdff_reader-2.0.2.dist-info/RECORD +17 -0
- aemo_mdff_reader-2.0.2.dist-info/WHEEL +5 -0
- aemo_mdff_reader-2.0.2.dist-info/entry_points.txt +2 -0
- aemo_mdff_reader-2.0.2.dist-info/licenses/LICENSE +21 -0
- aemo_mdff_reader-2.0.2.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|