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,437 @@
|
|
|
1
|
+
"""Lightweight data classes for parsed NEM12/NEM13 records.
|
|
2
|
+
|
|
3
|
+
These types form the public surface of the streaming parser. They use
|
|
4
|
+
``__slots__`` to keep per-row memory and allocation cost low when
|
|
5
|
+
processing large files (millions of intervals).
|
|
6
|
+
|
|
7
|
+
Each class exposes a ``to_dict()`` method for cheap conversion to a
|
|
8
|
+
``dict`` — useful for JSON serialisation, dict-comprehension filtering,
|
|
9
|
+
and bridging to systems that expect mappings.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from typing import Any, Dict, Optional
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _slots_to_dict(obj: Any) -> Dict[str, Any]:
|
|
19
|
+
"""Return a ``dict`` of an object's ``__slots__`` values."""
|
|
20
|
+
return {slot: getattr(obj, slot) for slot in obj.__slots__}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _quality_flag(quality_method: str) -> str:
|
|
24
|
+
"""Return the 1-character quality flag from a ``QMM`` field.
|
|
25
|
+
|
|
26
|
+
The AEMO MDFF v2.6 spec defines QualityMethod as a 3-character
|
|
27
|
+
string ``QMM`` where ``Q`` is the 1-char quality flag (one of
|
|
28
|
+
``A``, ``E``, ``F``, ``S``, ``V``) and ``MM`` is the 2-char method
|
|
29
|
+
flag (numeric, e.g. ``"53"``). When ``Q`` is ``A`` or ``V`` the
|
|
30
|
+
method flag is omitted, so the field may be just one character.
|
|
31
|
+
Empty input returns an empty string.
|
|
32
|
+
"""
|
|
33
|
+
return quality_method[:1]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _method_flag(quality_method: str) -> str:
|
|
37
|
+
"""Return the 2-character method flag from a ``QMM`` field.
|
|
38
|
+
|
|
39
|
+
Empty if the underlying QualityMethod has no method component
|
|
40
|
+
(e.g. quality flag ``"A"``).
|
|
41
|
+
"""
|
|
42
|
+
return quality_method[1:3]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Header:
|
|
46
|
+
"""100 record — file header."""
|
|
47
|
+
|
|
48
|
+
__slots__ = ("datetime", "from_participant", "to_participant", "version")
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
version: str,
|
|
53
|
+
datetime: Optional[datetime],
|
|
54
|
+
from_participant: str,
|
|
55
|
+
to_participant: str,
|
|
56
|
+
) -> None:
|
|
57
|
+
self.version = version
|
|
58
|
+
self.datetime = datetime
|
|
59
|
+
self.from_participant = from_participant
|
|
60
|
+
self.to_participant = to_participant
|
|
61
|
+
|
|
62
|
+
def __repr__(self) -> str:
|
|
63
|
+
return (
|
|
64
|
+
f"Header(version={self.version!r}, datetime={self.datetime!r}, "
|
|
65
|
+
f"from_participant={self.from_participant!r}, "
|
|
66
|
+
f"to_participant={self.to_participant!r})"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
70
|
+
"""Return this record as a plain dict keyed by field name."""
|
|
71
|
+
return _slots_to_dict(self)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class NMIDetails:
|
|
75
|
+
"""200 record — NMI / channel details."""
|
|
76
|
+
|
|
77
|
+
__slots__ = (
|
|
78
|
+
"interval_length",
|
|
79
|
+
"mdm_data_stream_identifier",
|
|
80
|
+
"meter_serial_number",
|
|
81
|
+
"next_scheduled_read_date",
|
|
82
|
+
"nmi",
|
|
83
|
+
"nmi_configuration",
|
|
84
|
+
"nmi_suffix",
|
|
85
|
+
"register_id",
|
|
86
|
+
"uom",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
nmi: str,
|
|
92
|
+
nmi_configuration: str,
|
|
93
|
+
register_id: str,
|
|
94
|
+
nmi_suffix: str,
|
|
95
|
+
mdm_data_stream_identifier: str,
|
|
96
|
+
meter_serial_number: str,
|
|
97
|
+
uom: str,
|
|
98
|
+
interval_length: int,
|
|
99
|
+
next_scheduled_read_date: Optional[datetime],
|
|
100
|
+
) -> None:
|
|
101
|
+
self.nmi = nmi
|
|
102
|
+
self.nmi_configuration = nmi_configuration
|
|
103
|
+
self.register_id = register_id
|
|
104
|
+
self.nmi_suffix = nmi_suffix
|
|
105
|
+
self.mdm_data_stream_identifier = mdm_data_stream_identifier
|
|
106
|
+
self.meter_serial_number = meter_serial_number
|
|
107
|
+
self.uom = uom
|
|
108
|
+
self.interval_length = interval_length
|
|
109
|
+
self.next_scheduled_read_date = next_scheduled_read_date
|
|
110
|
+
|
|
111
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
112
|
+
"""Return this record as a plain dict keyed by field name."""
|
|
113
|
+
return _slots_to_dict(self)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class IntervalReading:
|
|
117
|
+
"""A single interval reading — one cell of a 300 record.
|
|
118
|
+
|
|
119
|
+
Notes
|
|
120
|
+
-----
|
|
121
|
+
``value`` is always a ``float``. Empty cells in the source NEM12
|
|
122
|
+
file are coerced to ``0.0`` so a single missing cell does not fail
|
|
123
|
+
an entire row. Use ``quality_method`` (e.g. ``"S"`` for substituted,
|
|
124
|
+
``"F"`` for failed, ``"N"`` for null) to distinguish a real zero
|
|
125
|
+
from a missing reading.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
__slots__ = (
|
|
129
|
+
"interval_date",
|
|
130
|
+
"interval_end",
|
|
131
|
+
"interval_index",
|
|
132
|
+
"interval_length",
|
|
133
|
+
"interval_start",
|
|
134
|
+
"meter_serial_number",
|
|
135
|
+
"msats_load_datetime",
|
|
136
|
+
"nmi",
|
|
137
|
+
"nmi_suffix",
|
|
138
|
+
"quality_method",
|
|
139
|
+
"reason_code",
|
|
140
|
+
"reason_description",
|
|
141
|
+
"register_id",
|
|
142
|
+
"uom",
|
|
143
|
+
"update_datetime",
|
|
144
|
+
"value",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def __init__(
|
|
148
|
+
self,
|
|
149
|
+
nmi: str,
|
|
150
|
+
meter_serial_number: str,
|
|
151
|
+
register_id: str,
|
|
152
|
+
nmi_suffix: str,
|
|
153
|
+
uom: str,
|
|
154
|
+
interval_length: int,
|
|
155
|
+
interval_date: datetime,
|
|
156
|
+
interval_start: datetime,
|
|
157
|
+
interval_end: datetime,
|
|
158
|
+
interval_index: int,
|
|
159
|
+
value: float,
|
|
160
|
+
quality_method: str,
|
|
161
|
+
reason_code: Optional[int],
|
|
162
|
+
reason_description: str,
|
|
163
|
+
update_datetime: Optional[datetime],
|
|
164
|
+
msats_load_datetime: Optional[datetime],
|
|
165
|
+
) -> None:
|
|
166
|
+
self.nmi = nmi
|
|
167
|
+
self.meter_serial_number = meter_serial_number
|
|
168
|
+
self.register_id = register_id
|
|
169
|
+
self.nmi_suffix = nmi_suffix
|
|
170
|
+
self.uom = uom
|
|
171
|
+
self.interval_length = interval_length
|
|
172
|
+
self.interval_date = interval_date
|
|
173
|
+
self.interval_start = interval_start
|
|
174
|
+
self.interval_end = interval_end
|
|
175
|
+
self.interval_index = interval_index
|
|
176
|
+
self.value = value
|
|
177
|
+
self.quality_method = quality_method
|
|
178
|
+
self.reason_code = reason_code
|
|
179
|
+
self.reason_description = reason_description
|
|
180
|
+
self.update_datetime = update_datetime
|
|
181
|
+
self.msats_load_datetime = msats_load_datetime
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def quality_flag(self) -> str:
|
|
185
|
+
"""The 1-character quality flag from ``quality_method`` (e.g. ``"A"``).
|
|
186
|
+
|
|
187
|
+
See AEMO MDFF v2.6 Appendix C and :data:`aemo_mdff_reader.spec.QUALITY_FLAGS`.
|
|
188
|
+
"""
|
|
189
|
+
return _quality_flag(self.quality_method)
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def method_flag(self) -> str:
|
|
193
|
+
"""The 2-character method flag from ``quality_method`` (e.g. ``"53"``).
|
|
194
|
+
|
|
195
|
+
Empty when the quality flag does not require a method (e.g. ``"A"``).
|
|
196
|
+
"""
|
|
197
|
+
return _method_flag(self.quality_method)
|
|
198
|
+
|
|
199
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
200
|
+
"""Return this record as a plain dict keyed by field name.
|
|
201
|
+
|
|
202
|
+
``quality_flag`` and ``method_flag`` are derived from
|
|
203
|
+
``quality_method`` and not included; access them directly.
|
|
204
|
+
"""
|
|
205
|
+
return _slots_to_dict(self)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class IntervalEvent:
|
|
209
|
+
"""400 record — quality/event flag covering a range of intervals."""
|
|
210
|
+
|
|
211
|
+
__slots__ = (
|
|
212
|
+
"end_interval",
|
|
213
|
+
"interval_date",
|
|
214
|
+
"meter_serial_number",
|
|
215
|
+
"nmi",
|
|
216
|
+
"quality_method",
|
|
217
|
+
"reason_code",
|
|
218
|
+
"reason_description",
|
|
219
|
+
"register_id",
|
|
220
|
+
"start_interval",
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def __init__(
|
|
224
|
+
self,
|
|
225
|
+
nmi: str,
|
|
226
|
+
meter_serial_number: str,
|
|
227
|
+
register_id: str,
|
|
228
|
+
interval_date: datetime,
|
|
229
|
+
start_interval: int,
|
|
230
|
+
end_interval: int,
|
|
231
|
+
quality_method: str,
|
|
232
|
+
reason_code: Optional[int],
|
|
233
|
+
reason_description: str,
|
|
234
|
+
) -> None:
|
|
235
|
+
self.nmi = nmi
|
|
236
|
+
self.meter_serial_number = meter_serial_number
|
|
237
|
+
self.register_id = register_id
|
|
238
|
+
self.interval_date = interval_date
|
|
239
|
+
self.start_interval = start_interval
|
|
240
|
+
self.end_interval = end_interval
|
|
241
|
+
self.quality_method = quality_method
|
|
242
|
+
self.reason_code = reason_code
|
|
243
|
+
self.reason_description = reason_description
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def quality_flag(self) -> str:
|
|
247
|
+
"""The 1-character quality flag from ``quality_method``."""
|
|
248
|
+
return _quality_flag(self.quality_method)
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def method_flag(self) -> str:
|
|
252
|
+
"""The 2-character method flag from ``quality_method``."""
|
|
253
|
+
return _method_flag(self.quality_method)
|
|
254
|
+
|
|
255
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
256
|
+
"""Return this record as a plain dict keyed by field name."""
|
|
257
|
+
return _slots_to_dict(self)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class AccumulationReading:
|
|
261
|
+
"""250 record — NEM13 NMI accumulation / manual-read data.
|
|
262
|
+
|
|
263
|
+
Each 250 record represents a previous + current register read pair
|
|
264
|
+
plus a calculated quantity (consumption between the two reads).
|
|
265
|
+
|
|
266
|
+
.. note::
|
|
267
|
+
``direction_indicator`` is stored as the raw string from the
|
|
268
|
+
file. AEMO MDFF v2.6 §5.3 restricts it to ``"I"`` (Import) or
|
|
269
|
+
``"E"`` (Export), but real-world files occasionally contain
|
|
270
|
+
``"B"`` (bi-directional) or ``"N"`` (not applicable). The parser
|
|
271
|
+
accepts either case and leaves enforcement to the caller; see
|
|
272
|
+
:data:`aemo_mdff_reader.spec.DIRECTION_INDICATORS` for the spec set.
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
__slots__ = (
|
|
276
|
+
"current_quality_method",
|
|
277
|
+
"current_reason_code",
|
|
278
|
+
"current_reason_description",
|
|
279
|
+
"current_register_read",
|
|
280
|
+
"current_register_read_datetime",
|
|
281
|
+
"direction_indicator",
|
|
282
|
+
"mdm_data_stream_identifier",
|
|
283
|
+
"meter_serial_number",
|
|
284
|
+
"msats_load_datetime",
|
|
285
|
+
"next_scheduled_read_date",
|
|
286
|
+
"nmi",
|
|
287
|
+
"nmi_configuration",
|
|
288
|
+
"nmi_suffix",
|
|
289
|
+
"previous_quality_method",
|
|
290
|
+
"previous_reason_code",
|
|
291
|
+
"previous_reason_description",
|
|
292
|
+
"previous_register_read",
|
|
293
|
+
"previous_register_read_datetime",
|
|
294
|
+
"quantity",
|
|
295
|
+
"register_id",
|
|
296
|
+
"uom",
|
|
297
|
+
"update_datetime",
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
def __init__(
|
|
301
|
+
self,
|
|
302
|
+
nmi: str,
|
|
303
|
+
nmi_configuration: str,
|
|
304
|
+
register_id: str,
|
|
305
|
+
nmi_suffix: str,
|
|
306
|
+
mdm_data_stream_identifier: str,
|
|
307
|
+
meter_serial_number: str,
|
|
308
|
+
direction_indicator: str,
|
|
309
|
+
previous_register_read: float,
|
|
310
|
+
previous_register_read_datetime: Optional[datetime],
|
|
311
|
+
previous_quality_method: str,
|
|
312
|
+
previous_reason_code: Optional[int],
|
|
313
|
+
previous_reason_description: str,
|
|
314
|
+
current_register_read: float,
|
|
315
|
+
current_register_read_datetime: Optional[datetime],
|
|
316
|
+
current_quality_method: str,
|
|
317
|
+
current_reason_code: Optional[int],
|
|
318
|
+
current_reason_description: str,
|
|
319
|
+
quantity: float,
|
|
320
|
+
uom: str,
|
|
321
|
+
next_scheduled_read_date: Optional[datetime],
|
|
322
|
+
update_datetime: Optional[datetime],
|
|
323
|
+
msats_load_datetime: Optional[datetime],
|
|
324
|
+
) -> None:
|
|
325
|
+
self.nmi = nmi
|
|
326
|
+
self.nmi_configuration = nmi_configuration
|
|
327
|
+
self.register_id = register_id
|
|
328
|
+
self.nmi_suffix = nmi_suffix
|
|
329
|
+
self.mdm_data_stream_identifier = mdm_data_stream_identifier
|
|
330
|
+
self.meter_serial_number = meter_serial_number
|
|
331
|
+
self.direction_indicator = direction_indicator
|
|
332
|
+
self.previous_register_read = previous_register_read
|
|
333
|
+
self.previous_register_read_datetime = previous_register_read_datetime
|
|
334
|
+
self.previous_quality_method = previous_quality_method
|
|
335
|
+
self.previous_reason_code = previous_reason_code
|
|
336
|
+
self.previous_reason_description = previous_reason_description
|
|
337
|
+
self.current_register_read = current_register_read
|
|
338
|
+
self.current_register_read_datetime = current_register_read_datetime
|
|
339
|
+
self.current_quality_method = current_quality_method
|
|
340
|
+
self.current_reason_code = current_reason_code
|
|
341
|
+
self.current_reason_description = current_reason_description
|
|
342
|
+
self.quantity = quantity
|
|
343
|
+
self.uom = uom
|
|
344
|
+
self.next_scheduled_read_date = next_scheduled_read_date
|
|
345
|
+
self.update_datetime = update_datetime
|
|
346
|
+
self.msats_load_datetime = msats_load_datetime
|
|
347
|
+
|
|
348
|
+
@property
|
|
349
|
+
def previous_quality_flag(self) -> str:
|
|
350
|
+
"""1-character quality flag for the *previous* register read."""
|
|
351
|
+
return _quality_flag(self.previous_quality_method)
|
|
352
|
+
|
|
353
|
+
@property
|
|
354
|
+
def previous_method_flag(self) -> str:
|
|
355
|
+
"""2-character method flag for the *previous* register read."""
|
|
356
|
+
return _method_flag(self.previous_quality_method)
|
|
357
|
+
|
|
358
|
+
@property
|
|
359
|
+
def current_quality_flag(self) -> str:
|
|
360
|
+
"""1-character quality flag for the *current* register read."""
|
|
361
|
+
return _quality_flag(self.current_quality_method)
|
|
362
|
+
|
|
363
|
+
@property
|
|
364
|
+
def current_method_flag(self) -> str:
|
|
365
|
+
"""2-character method flag for the *current* register read."""
|
|
366
|
+
return _method_flag(self.current_quality_method)
|
|
367
|
+
|
|
368
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
369
|
+
"""Return this record as a plain dict keyed by field name."""
|
|
370
|
+
return _slots_to_dict(self)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class B2BDetails:
|
|
374
|
+
"""500 (NEM12) / 550 (NEM13) record — B2B transaction details.
|
|
375
|
+
|
|
376
|
+
The 500 record carries a single transaction (TransCode +
|
|
377
|
+
RetServiceOrder + ReadDateTime + IndexRead). The 550 record carries
|
|
378
|
+
a paired previous/current transaction. We surface them with the
|
|
379
|
+
same class and a ``record_kind`` discriminator (``"500"`` or
|
|
380
|
+
``"550"``) so callers can branch when needed.
|
|
381
|
+
|
|
382
|
+
Fields that do not apply to the current ``record_kind`` are set to
|
|
383
|
+
``None`` (not ``""``), so callers can distinguish "structurally
|
|
384
|
+
absent for this record kind" from "present but empty in source"::
|
|
385
|
+
|
|
386
|
+
if b.record_kind == "550":
|
|
387
|
+
assert b.previous_trans_code is not None # set
|
|
388
|
+
assert b.trans_code is None # not in this kind
|
|
389
|
+
"""
|
|
390
|
+
|
|
391
|
+
__slots__ = (
|
|
392
|
+
"current_ret_service_order",
|
|
393
|
+
"current_trans_code",
|
|
394
|
+
"index_read",
|
|
395
|
+
"previous_ret_service_order",
|
|
396
|
+
"previous_trans_code",
|
|
397
|
+
"read_datetime",
|
|
398
|
+
"record_kind",
|
|
399
|
+
"ret_service_order",
|
|
400
|
+
"trans_code",
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
def __init__(
|
|
404
|
+
self,
|
|
405
|
+
record_kind: str,
|
|
406
|
+
trans_code: Optional[str] = None,
|
|
407
|
+
ret_service_order: Optional[str] = None,
|
|
408
|
+
read_datetime: Optional[datetime] = None,
|
|
409
|
+
index_read: Optional[str] = None,
|
|
410
|
+
previous_trans_code: Optional[str] = None,
|
|
411
|
+
previous_ret_service_order: Optional[str] = None,
|
|
412
|
+
current_trans_code: Optional[str] = None,
|
|
413
|
+
current_ret_service_order: Optional[str] = None,
|
|
414
|
+
) -> None:
|
|
415
|
+
self.record_kind = record_kind
|
|
416
|
+
self.trans_code = trans_code
|
|
417
|
+
self.ret_service_order = ret_service_order
|
|
418
|
+
self.read_datetime = read_datetime
|
|
419
|
+
self.index_read = index_read
|
|
420
|
+
self.previous_trans_code = previous_trans_code
|
|
421
|
+
self.previous_ret_service_order = previous_ret_service_order
|
|
422
|
+
self.current_trans_code = current_trans_code
|
|
423
|
+
self.current_ret_service_order = current_ret_service_order
|
|
424
|
+
|
|
425
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
426
|
+
"""Return this record as a plain dict keyed by field name."""
|
|
427
|
+
return _slots_to_dict(self)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
__all__ = [
|
|
431
|
+
"AccumulationReading",
|
|
432
|
+
"B2BDetails",
|
|
433
|
+
"Header",
|
|
434
|
+
"IntervalEvent",
|
|
435
|
+
"IntervalReading",
|
|
436
|
+
"NMIDetails",
|
|
437
|
+
]
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aemo-mdff-reader
|
|
3
|
+
Version: 2.0.2
|
|
4
|
+
Summary: Fast streaming reader for AEMO MDFF NEM12 / NEM13 metering files. Zero required dependencies.
|
|
5
|
+
Author: Utilified Holdings Pty Ltd
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Utilified/aemo-mdff-reader
|
|
8
|
+
Project-URL: Repository, https://github.com/Utilified/aemo-mdff-reader
|
|
9
|
+
Project-URL: Issues, https://github.com/Utilified/aemo-mdff-reader/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/Utilified/aemo-mdff-reader/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: nem12,nem13,aemo,energy,metering,australia,mdff
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Information Technology
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
24
|
+
Classifier: Topic :: Scientific/Engineering
|
|
25
|
+
Classifier: Topic :: Utilities
|
|
26
|
+
Requires-Python: >=3.9
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Provides-Extra: pandas
|
|
30
|
+
Requires-Dist: pandas>=1.3; extra == "pandas"
|
|
31
|
+
Provides-Extra: parquet
|
|
32
|
+
Requires-Dist: pandas>=1.3; extra == "parquet"
|
|
33
|
+
Requires-Dist: pyarrow>=10; extra == "parquet"
|
|
34
|
+
Provides-Extra: mysql
|
|
35
|
+
Requires-Dist: PyMySQL>=1.0; extra == "mysql"
|
|
36
|
+
Provides-Extra: dev
|
|
37
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
38
|
+
Requires-Dist: pytest-cov>=4; extra == "dev"
|
|
39
|
+
Requires-Dist: pandas>=1.3; extra == "dev"
|
|
40
|
+
Requires-Dist: pyarrow>=10; extra == "dev"
|
|
41
|
+
Requires-Dist: mypy>=1.8; extra == "dev"
|
|
42
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
43
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
44
|
+
Requires-Dist: twine>=5.0; extra == "dev"
|
|
45
|
+
Requires-Dist: packaging>=24.2; extra == "dev"
|
|
46
|
+
Dynamic: license-file
|
|
47
|
+
|
|
48
|
+
# aemo-mdff-reader
|
|
49
|
+
|
|
50
|
+
[](https://github.com/Utilified/aemo-mdff-reader/actions/workflows/ci.yml)
|
|
51
|
+
[](https://pypi.org/project/aemo-mdff-reader/)
|
|
52
|
+
[](https://pypi.org/project/aemo-mdff-reader/)
|
|
53
|
+
[](LICENSE)
|
|
54
|
+
|
|
55
|
+
Fast, zero-dependency streaming reader for AEMO **NEM12** and **NEM13**
|
|
56
|
+
metering files. Implements AEMO MDFF (Meter Data File Format) v2.6.
|
|
57
|
+
|
|
58
|
+
- O(1) memory — iterate through millions of intervals.
|
|
59
|
+
- Pure stdlib core; pandas / PyMySQL are opt-in extras.
|
|
60
|
+
- ~2 M readings/sec on the columnar fast path.
|
|
61
|
+
- Includes an `aemo-mdff-reader` CLI.
|
|
62
|
+
|
|
63
|
+
## Install
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install aemo-mdff-reader
|
|
67
|
+
|
|
68
|
+
# optional extras
|
|
69
|
+
pip install aemo-mdff-reader[pandas] # to_dataframe() / parquet
|
|
70
|
+
pip install aemo-mdff-reader[mysql] # SQL persistence
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Use
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from aemo_mdff_reader import parse
|
|
77
|
+
|
|
78
|
+
for r in parse("metering.csv"):
|
|
79
|
+
print(r.nmi, r.interval_start, r.value, r.uom)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Or as a flat CSV / DataFrame:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from aemo_mdff_reader import parse, write_csv, to_dataframe
|
|
86
|
+
|
|
87
|
+
write_csv(parse("metering.csv"), "out.csv") # no pandas
|
|
88
|
+
df = to_dataframe("metering.csv") # needs [pandas]
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
From the command line:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
aemo-mdff-reader metering.csv -o out.csv
|
|
95
|
+
aemo-mdff-reader metering.csv --validate # spec check
|
|
96
|
+
aemo-mdff-reader metering.csv --nmi NMI1234567 --start 2024-01-01 --end 2024-01-31
|
|
97
|
+
aemo-mdff-reader manual.csv --records accumulations # NEM13
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Working with the data
|
|
101
|
+
|
|
102
|
+
Each parsed record is a slots-based class with named attributes plus a
|
|
103
|
+
`to_dict()` for JSON / dict pipelines:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
for r in parse("metering.csv"):
|
|
107
|
+
payload = r.to_dict() # {"nmi": "...", "value": 0.12, ...}
|
|
108
|
+
print(r.quality_flag, r.method_flag) # split of the QMM field, e.g. "S", "52"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
For aggregation, `aemo_mdff_reader.aggregate` provides streaming helpers:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from aemo_mdff_reader import parse
|
|
115
|
+
from aemo_mdff_reader.aggregate import group_by_nmi, daily_totals
|
|
116
|
+
|
|
117
|
+
for key, group in group_by_nmi(parse("metering.csv")):
|
|
118
|
+
# key = ChannelKey(nmi, register_id, nmi_suffix)
|
|
119
|
+
intervals = list(group)
|
|
120
|
+
|
|
121
|
+
for day in daily_totals(parse("metering.csv")):
|
|
122
|
+
# day.total, day.interval_count, day.unique_quality_flags
|
|
123
|
+
print(day.nmi, day.interval_date.date(), day.total, day.uom)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
End-to-end recipes — load + inspect, daily roll-up, filter to pandas,
|
|
127
|
+
spec validation — live in [`examples/`](examples/).
|
|
128
|
+
|
|
129
|
+
## API at a glance
|
|
130
|
+
|
|
131
|
+
| You want | Call |
|
|
132
|
+
| ------------------------------------- | ----------------------------- |
|
|
133
|
+
| 300 interval readings (NEM12) | `parse(src)` |
|
|
134
|
+
| 250 accumulations (NEM13) | `parse_accumulations(src)` |
|
|
135
|
+
| Both, in file order | `parse_all(src)` |
|
|
136
|
+
| 400 quality / event flags | `parse_events(src)` |
|
|
137
|
+
| 500 / 550 B2B transactions | `parse_b2b(src)` |
|
|
138
|
+
| Just the 100 header | `parse_header(src)` |
|
|
139
|
+
| Build a pandas DataFrame | `to_dataframe(src)` |
|
|
140
|
+
| Write a flat CSV (no pandas) | `write_csv(rows, out)` |
|
|
141
|
+
| Validate against AEMO MDFF v2.6 | `validate_file(src)` |
|
|
142
|
+
| Compute / verify an NMI checksum | `nmi_checksum`, `validate_nmi`|
|
|
143
|
+
| Group readings by NMI / channel | `aggregate.group_by_nmi(rows)`|
|
|
144
|
+
| Roll up to daily totals | `aggregate.daily_totals(rows)`|
|
|
145
|
+
| Convert any record to a plain dict | `r.to_dict()` |
|
|
146
|
+
|
|
147
|
+
`src` can be a path, a file-like object, an iterable of CSV lines, or
|
|
148
|
+
an iterable of pre-split rows. The v1 `NEMReader` facade
|
|
149
|
+
(`read_from_file`, `to_dataframe`, `to_csv`) still works.
|
|
150
|
+
|
|
151
|
+
Each `parse(...)` yields an `IntervalReading` with `nmi`,
|
|
152
|
+
`meter_serial_number`, `register_id`, `nmi_suffix`, `uom`,
|
|
153
|
+
`interval_length`, `interval_date`, `interval_start`, `interval_end`,
|
|
154
|
+
`interval_index`, `value`, `quality_method`, `reason_code`,
|
|
155
|
+
`reason_description`, `update_datetime`, `msats_load_datetime`. See
|
|
156
|
+
the type stubs (`from aemo_mdff_reader import IntervalReading`) for the
|
|
157
|
+
exact signatures.
|
|
158
|
+
|
|
159
|
+
## Notes
|
|
160
|
+
|
|
161
|
+
- **Spec**: AEMO Meter Data File Format Specification NEM12 & NEM13,
|
|
162
|
+
**v2.6** (effective 29 September 2024). Records `100`, `200`, `250`,
|
|
163
|
+
`300`, `400`, `500`, `550`, `900` are all surfaced; unknown indicators
|
|
164
|
+
are ignored. Allowed values for quality flags, transaction codes,
|
|
165
|
+
reason codes, units of measure, and direction indicators are exposed
|
|
166
|
+
as constants in `aemo_mdff_reader.spec` for callers that want stricter
|
|
167
|
+
validation than the parser performs.
|
|
168
|
+
- **Tolerant**: UTF-8 BOM is consumed silently, LF and CRLF both work,
|
|
169
|
+
and empty interval cells are coerced to `0.0` (use `quality_method`
|
|
170
|
+
to distinguish missing from zero). Datetime fields accept the spec
|
|
171
|
+
forms (`YYYYMMDD`, `YYYYMMDDhhmmss`) and a few common non-spec
|
|
172
|
+
variants (`YYYY-MM-DD`, ISO `YYYY-MM-DDTHH:MM:SS`, with or without a
|
|
173
|
+
`Z` / `±HH:MM` / `±HHMM` timezone suffix — the suffix is stripped
|
|
174
|
+
and parsed datetimes are returned naive). `direction_indicator` on
|
|
175
|
+
250 records passes through whatever the file emits; the spec set is
|
|
176
|
+
`spec.DIRECTION_INDICATORS = {"I", "E"}` but `B` and `N` appear in
|
|
177
|
+
the wild. The parser also accepts non-spec IntervalLengths (1, 60,
|
|
178
|
+
etc.); strict callers should compare against
|
|
179
|
+
`spec.ALLOWED_INTERVAL_LENGTHS` (= `{5, 15, 30}`).
|
|
180
|
+
- **Migration from v1**: `NEMReader` still works. The internal
|
|
181
|
+
`aemo_mdff_reader.nemstructure` package is gone — see the API table
|
|
182
|
+
above. `pandas` is now opt-in. See `CHANGELOG.md` for details.
|
|
183
|
+
|
|
184
|
+
## Performance
|
|
185
|
+
|
|
186
|
+
420,480 readings (4 NMIs × 365 days × 5-min, 2.8 MiB CSV), Python 3.11:
|
|
187
|
+
|
|
188
|
+
| Operation | Time |
|
|
189
|
+
| ---------------------------------- | -----: |
|
|
190
|
+
| `for r in parse(path): ...` | 0.45 s |
|
|
191
|
+
| `parse_to_columns(path)` | 0.21 s |
|
|
192
|
+
| `to_dataframe(path)` (pandas) | 0.76 s |
|
|
193
|
+
|
|
194
|
+
~2.7× faster than v1 end-to-end; reproduce with
|
|
195
|
+
`python benchmarks/bench_parser.py`.
|
|
196
|
+
|
|
197
|
+
## Large files
|
|
198
|
+
|
|
199
|
+
The parser is built to scale to gigabyte-class NEM12 files without
|
|
200
|
+
loading them into RAM. Measured peak memory delta on a synthetic
|
|
201
|
+
**10.5 M-reading file** (100 NMIs × 365 days × 5-min, 71 MiB CSV),
|
|
202
|
+
Python 3.12:
|
|
203
|
+
|
|
204
|
+
| API | Memory profile | Peak Δ |
|
|
205
|
+
| ---------------------------------------- | -------------- | -----: |
|
|
206
|
+
| `for r in parse(path): ...` | streaming | **1.3 MiB** |
|
|
207
|
+
| `daily_totals(parse(path))` | streaming | **0 MiB** |
|
|
208
|
+
| `write_csv(parse(path), out)` | streaming | **0 MiB** |
|
|
209
|
+
| `iter_dataframes(path, chunk_size=N)` | bounded O(N) | **~30 MiB / 100k** |
|
|
210
|
+
| `iter_columns_chunks(path, chunk_size=N)`| bounded O(N) | **~10 MiB / 100k** |
|
|
211
|
+
| `parse_to_columns(path)` | full materialise | ~600 MiB |
|
|
212
|
+
| `list(parse(path))` / `to_dataframe(path)` / `NEMReader.read_from_file()` | full materialise | ~2.5 GiB |
|
|
213
|
+
|
|
214
|
+
Rule of thumb: stay on the streaming or chunked APIs for any file
|
|
215
|
+
larger than a few hundred MiB. The chunked variants make pandas-based
|
|
216
|
+
workflows safe on arbitrarily large inputs:
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
from aemo_mdff_reader import iter_dataframes
|
|
220
|
+
|
|
221
|
+
# Process a multi-GiB file 50,000 readings at a time.
|
|
222
|
+
for df in iter_dataframes("huge.csv", chunk_size=50_000):
|
|
223
|
+
daily = df.groupby(["NMI", "IntervalDate"])["Value"].sum()
|
|
224
|
+
daily.to_csv("out.csv", mode="a", header=False)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
The `NEMReader` facade and `to_dataframe(path)` materialise their
|
|
228
|
+
inputs by design (so `len(reader)` and `df.iloc[...]` work). Avoid
|
|
229
|
+
them for files that won't fit in RAM.
|
|
230
|
+
|
|
231
|
+
## Development
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
git clone https://github.com/Utilified/aemo-mdff-reader.git
|
|
235
|
+
cd aemo-mdff-reader
|
|
236
|
+
pip install -e .[dev]
|
|
237
|
+
pytest
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
CI runs ruff, mypy --strict, the test matrix on Python 3.9 → 3.12 /
|
|
241
|
+
Linux / macOS / Windows, `pip-audit`, `bandit`, and a wheel-install
|
|
242
|
+
smoke test. Releases are signed with sigstore and published to PyPI
|
|
243
|
+
via Trusted Publishing on tag.
|
|
244
|
+
|
|
245
|
+
## License
|
|
246
|
+
|
|
247
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
aemo_mdff_reader/__init__.py,sha256=eYrHtmlt7NL7TgIFWWbWHYUY8q14RR4iNpMghv6JjNk,2068
|
|
2
|
+
aemo_mdff_reader/aggregate.py,sha256=w5mqjWGuXasY6NHbOs9z5NherLP46BCXlvZcXGPFzcA,5594
|
|
3
|
+
aemo_mdff_reader/cli.py,sha256=8qu9tPYKrCRak8f6-blGapgn6dASsxC4oY0FJd1l7ys,6830
|
|
4
|
+
aemo_mdff_reader/parser.py,sha256=9Z6jXfEQSSlpO5HyTjE6xbeP3yzQV5gkPGP9dzwHZcA,45302
|
|
5
|
+
aemo_mdff_reader/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
aemo_mdff_reader/reader.py,sha256=3uz56syk15j_2Jjt4TpGa-6Oy6NGpDNqshJDHKBTN_Q,3735
|
|
7
|
+
aemo_mdff_reader/spec.py,sha256=xXepAicACY7LrqIkQ6WuixsxoJlneZ6OfjkMQYh8Vqo,7495
|
|
8
|
+
aemo_mdff_reader/types.py,sha256=KylRqgemsF9swhx0n77I1kAxhJU9MPziEk8GRZcE-Ls,14648
|
|
9
|
+
aemo_mdff_reader/sql/__init__.py,sha256=J3ZgIbl6fc8AjLUX9wkp_AhotRdG70jNlQdxt_QUY9w,798
|
|
10
|
+
aemo_mdff_reader/sql/query.py,sha256=X0W4HQYcYAs86N0hJtjEZZ5nTOZtBh2znoB3yk8A-24,2787
|
|
11
|
+
aemo_mdff_reader/sql/store.py,sha256=qcDczgUsoH_sL_YkjYcIXIW1dwlDRR8XqVJZVePIMbY,7080
|
|
12
|
+
aemo_mdff_reader-2.0.2.dist-info/licenses/LICENSE,sha256=yaXP1AOkvdVKOZENv44yCYd-Gp1gMZJsCJ6Vq1lsAr8,1088
|
|
13
|
+
aemo_mdff_reader-2.0.2.dist-info/METADATA,sha256=oBvv_YPQdi4XnSEDBp4QjabC-WYQlK8O2K8DYbi7l-g,10348
|
|
14
|
+
aemo_mdff_reader-2.0.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
15
|
+
aemo_mdff_reader-2.0.2.dist-info/entry_points.txt,sha256=qyHPSMwKLwHcbmzdAnqtsp6uJGcT0dsLx97iLHsGhRY,63
|
|
16
|
+
aemo_mdff_reader-2.0.2.dist-info/top_level.txt,sha256=_qHh4GyOKAePdT0u3oYVX7gz4hJKM_kvHx3NA5eSsAE,17
|
|
17
|
+
aemo_mdff_reader-2.0.2.dist-info/RECORD,,
|