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,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
+ [![CI](https://github.com/Utilified/aemo-mdff-reader/actions/workflows/ci.yml/badge.svg)](https://github.com/Utilified/aemo-mdff-reader/actions/workflows/ci.yml)
51
+ [![PyPI](https://img.shields.io/pypi/v/aemo-mdff-reader.svg)](https://pypi.org/project/aemo-mdff-reader/)
52
+ [![Python versions](https://img.shields.io/pypi/pyversions/aemo-mdff-reader.svg)](https://pypi.org/project/aemo-mdff-reader/)
53
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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,,