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,84 @@
1
+ """aemo-mdff-reader — fast streaming reader for AEMO NEM12 / NEM13 files.
2
+
3
+ Recommended entry points:
4
+
5
+ >>> from aemo_mdff_reader import parse # NEM12 300 records
6
+ >>> from aemo_mdff_reader import parse_accumulations # NEM13 250 records
7
+ >>> from aemo_mdff_reader import parse_all # both, in file order
8
+
9
+ Each iterator is lazy (O(1) memory). For pandas DataFrames, see
10
+ :func:`to_dataframe` (NEM12) and :func:`to_accumulations_dataframe` (NEM13).
11
+
12
+ Spec compatibility: AEMO MDFF v2.6 (effective 29 September 2024). The
13
+ allowed-value tables — quality flags, transaction codes, reason codes,
14
+ units of measure — are exposed as constants in :mod:`aemo_mdff_reader.spec`.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from . import aggregate, spec
20
+ from .parser import (
21
+ NEM12ParseError,
22
+ iter_columns_chunks,
23
+ iter_dataframes,
24
+ nmi_checksum,
25
+ parse,
26
+ parse_accumulations,
27
+ parse_accumulations_to_columns,
28
+ parse_all,
29
+ parse_b2b,
30
+ parse_events,
31
+ parse_header,
32
+ parse_to_columns,
33
+ to_accumulations_dataframe,
34
+ to_columns,
35
+ to_dataframe,
36
+ validate_file,
37
+ validate_nmi,
38
+ write_accumulations_csv,
39
+ write_csv,
40
+ )
41
+ from .reader import INTERVAL_DATA_OUTPUT_HEADERS, NEMReader
42
+ from .types import (
43
+ AccumulationReading,
44
+ B2BDetails,
45
+ Header,
46
+ IntervalEvent,
47
+ IntervalReading,
48
+ NMIDetails,
49
+ )
50
+
51
+ __version__ = "2.0.2"
52
+
53
+ __all__ = [
54
+ "INTERVAL_DATA_OUTPUT_HEADERS",
55
+ "AccumulationReading",
56
+ "B2BDetails",
57
+ "Header",
58
+ "IntervalEvent",
59
+ "IntervalReading",
60
+ "NEM12ParseError",
61
+ "NEMReader",
62
+ "NMIDetails",
63
+ "__version__",
64
+ "aggregate",
65
+ "iter_columns_chunks",
66
+ "iter_dataframes",
67
+ "nmi_checksum",
68
+ "parse",
69
+ "parse_accumulations",
70
+ "parse_accumulations_to_columns",
71
+ "parse_all",
72
+ "parse_b2b",
73
+ "parse_events",
74
+ "parse_header",
75
+ "parse_to_columns",
76
+ "spec",
77
+ "to_accumulations_dataframe",
78
+ "to_columns",
79
+ "to_dataframe",
80
+ "validate_file",
81
+ "validate_nmi",
82
+ "write_accumulations_csv",
83
+ "write_csv",
84
+ ]
@@ -0,0 +1,168 @@
1
+ """Post-parse aggregation helpers.
2
+
3
+ Lightweight composable iterators for the most common downstream
4
+ operations on parsed NEM12 data: grouping by NMI/channel, and rolling
5
+ intervals up to daily totals. Each function takes any iterable of
6
+ :class:`IntervalReading` objects, so they compose with :func:`parse`,
7
+ ``parse_to_columns``, and the buffered :class:`NEMReader` facade.
8
+
9
+ >>> from aemo_mdff_reader import parse
10
+ >>> from aemo_mdff_reader.aggregate import daily_totals
11
+ >>> for d in daily_totals(parse("metering.csv")):
12
+ ... print(d.nmi, d.register_id, d.interval_date, d.total)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import itertools
18
+ from datetime import datetime
19
+ from typing import Iterable, Iterator, List, NamedTuple, Optional, Tuple, TypeVar
20
+
21
+ from .types import IntervalReading
22
+
23
+ _T = TypeVar("_T")
24
+
25
+
26
+ def iter_chunks(items: Iterable[_T], size: int) -> Iterator[List[_T]]:
27
+ """Yield successive lists of ``size`` items from ``items``.
28
+
29
+ Useful for batching streaming reads when the downstream consumer
30
+ works in fixed-size lots — DB ``executemany`` / batch HTTP / etc. —
31
+ without materialising the whole stream in memory. Memory cost is
32
+ O(size), not O(file).
33
+
34
+ The final batch may be shorter than ``size`` if the input is not a
35
+ multiple of it. Empty input yields no batches.
36
+
37
+ >>> from aemo_mdff_reader import parse
38
+ >>> from aemo_mdff_reader.aggregate import iter_chunks
39
+ >>> for batch in iter_chunks(parse("metering.csv"), 5_000):
40
+ ... db.executemany(INSERT_SQL, [r.to_dict() for r in batch])
41
+ """
42
+ if size <= 0:
43
+ raise ValueError(f"chunk size must be positive, got {size}")
44
+ it = iter(items)
45
+ while True:
46
+ chunk = list(itertools.islice(it, size))
47
+ if not chunk:
48
+ return
49
+ yield chunk
50
+
51
+
52
+ class ChannelKey(NamedTuple):
53
+ """Composite key identifying a single metering channel."""
54
+
55
+ nmi: str
56
+ register_id: str
57
+ nmi_suffix: str
58
+
59
+
60
+ class DailyTotal(NamedTuple):
61
+ """A single channel's total energy / quantity for a calendar day.
62
+
63
+ ``total`` is the sum of ``IntervalReading.value`` for every reading
64
+ that fell on ``interval_date``. ``interval_count`` is the number of
65
+ readings summed (useful for spotting partial days). ``unique_quality_flags``
66
+ is the set of distinct 1-character quality flags seen — a single
67
+ ``{"A"}`` means the day is fully Actual, anything else hints at
68
+ substituted or estimated data.
69
+ """
70
+
71
+ nmi: str
72
+ register_id: str
73
+ nmi_suffix: str
74
+ uom: str
75
+ interval_date: datetime
76
+ total: float
77
+ interval_count: int
78
+ unique_quality_flags: frozenset[str]
79
+
80
+
81
+ def group_by_nmi(
82
+ readings: Iterable[IntervalReading],
83
+ ) -> Iterator[Tuple[ChannelKey, Iterator[IntervalReading]]]:
84
+ """Yield ``(ChannelKey, group)`` pairs, one per (NMI, register, suffix).
85
+
86
+ NEM12 files are emitted in 200-record blocks: every 300 row inside
87
+ a block shares the same NMI / register / suffix, and a new 200 row
88
+ starts a new block. We use :func:`itertools.groupby` over those
89
+ fields, which means each ``group`` iterator must be consumed before
90
+ the next pair is requested — exactly the same contract as
91
+ ``itertools.groupby``.
92
+
93
+ For a materialised list per group, wrap the inner iterator::
94
+
95
+ for key, group in group_by_nmi(parse(path)):
96
+ rows = list(group)
97
+ ...
98
+ """
99
+
100
+ def _key(r: IntervalReading) -> ChannelKey:
101
+ return ChannelKey(r.nmi, r.register_id, r.nmi_suffix)
102
+
103
+ yield from itertools.groupby(readings, key=_key)
104
+
105
+
106
+ def daily_totals(readings: Iterable[IntervalReading]) -> Iterator[DailyTotal]:
107
+ """Yield one :class:`DailyTotal` per (channel, calendar day).
108
+
109
+ Within each 300 row, all readings share the same ``interval_date``
110
+ and the parser emits them contiguously, so the rollup is a streaming
111
+ O(1)-memory operation: we accumulate while ``(channel, date)`` stays
112
+ constant and emit when it changes.
113
+
114
+ Empty cells in the source are coerced to ``0.0`` upstream (see
115
+ :class:`IntervalReading`); they are summed as zero.
116
+ """
117
+ current_key: Optional[Tuple[ChannelKey, datetime]] = None
118
+ current_uom: str = ""
119
+ total = 0.0
120
+ count = 0
121
+ flags: set[str] = set()
122
+
123
+ for r in readings:
124
+ key = (ChannelKey(r.nmi, r.register_id, r.nmi_suffix), r.interval_date)
125
+ if current_key is None:
126
+ current_key = key
127
+ current_uom = r.uom
128
+ elif key != current_key:
129
+ yield DailyTotal(
130
+ nmi=current_key[0].nmi,
131
+ register_id=current_key[0].register_id,
132
+ nmi_suffix=current_key[0].nmi_suffix,
133
+ uom=current_uom,
134
+ interval_date=current_key[1],
135
+ total=total,
136
+ interval_count=count,
137
+ unique_quality_flags=frozenset(flags),
138
+ )
139
+ current_key = key
140
+ current_uom = r.uom
141
+ total = 0.0
142
+ count = 0
143
+ flags = set()
144
+ total += r.value
145
+ count += 1
146
+ if r.quality_method:
147
+ flags.add(r.quality_method[:1])
148
+
149
+ if current_key is not None:
150
+ yield DailyTotal(
151
+ nmi=current_key[0].nmi,
152
+ register_id=current_key[0].register_id,
153
+ nmi_suffix=current_key[0].nmi_suffix,
154
+ uom=current_uom,
155
+ interval_date=current_key[1],
156
+ total=total,
157
+ interval_count=count,
158
+ unique_quality_flags=frozenset(flags),
159
+ )
160
+
161
+
162
+ __all__ = [
163
+ "ChannelKey",
164
+ "DailyTotal",
165
+ "daily_totals",
166
+ "group_by_nmi",
167
+ "iter_chunks",
168
+ ]
@@ -0,0 +1,231 @@
1
+ """Command-line interface for aemo-mdff-reader.
2
+
3
+ Installed as the ``aemo-mdff-reader`` console script via the project's
4
+ ``[project.scripts]`` entry in ``pyproject.toml``.
5
+
6
+ Usage::
7
+
8
+ aemo-mdff-reader INPUT [-o OUTPUT] [--records intervals|accumulations]
9
+ [--format csv|parquet]
10
+ [--nmi NMI [--nmi NMI ...]]
11
+ [--start YYYY-MM-DD] [--end YYYY-MM-DD]
12
+ [--validate]
13
+
14
+ If ``OUTPUT`` is omitted, results are written to stdout.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import sys
21
+ from datetime import date, datetime
22
+ from typing import Iterable, Iterator, Optional, Sequence
23
+
24
+ from . import __version__
25
+ from .parser import (
26
+ parse,
27
+ parse_accumulations,
28
+ parse_accumulations_to_columns,
29
+ parse_to_columns,
30
+ validate_file,
31
+ write_accumulations_csv,
32
+ write_csv,
33
+ )
34
+ from .types import AccumulationReading, IntervalReading
35
+
36
+
37
+ def _iso_date(value: str) -> date:
38
+ """Parse a YYYY-MM-DD argparse value, raising ArgumentTypeError on bad input."""
39
+ try:
40
+ return datetime.strptime(value, "%Y-%m-%d").date()
41
+ except ValueError as exc:
42
+ raise argparse.ArgumentTypeError(f"expected YYYY-MM-DD date, got {value!r}") from exc
43
+
44
+
45
+ def _build_parser() -> argparse.ArgumentParser:
46
+ p = argparse.ArgumentParser(
47
+ prog="aemo-mdff-reader",
48
+ description=(
49
+ "Read an AEMO NEM12 / NEM13 file and emit a flat CSV. Streams in O(1) memory."
50
+ ),
51
+ )
52
+ p.add_argument("input", help="Path to the NEM12 / NEM13 input file (CSV).")
53
+ p.add_argument(
54
+ "-o",
55
+ "--output",
56
+ default="-",
57
+ help="Output path. Use '-' (the default) for stdout.",
58
+ )
59
+ p.add_argument(
60
+ "--records",
61
+ choices=("intervals", "accumulations"),
62
+ default="intervals",
63
+ help=(
64
+ "Which record type to emit. 'intervals' = NEM12 300 records "
65
+ "(default), 'accumulations' = NEM13 250 records."
66
+ ),
67
+ )
68
+ p.add_argument(
69
+ "--format",
70
+ choices=("csv", "parquet"),
71
+ default="csv",
72
+ help="Output format. 'parquet' requires pandas + pyarrow.",
73
+ )
74
+ p.add_argument(
75
+ "--nmi",
76
+ action="append",
77
+ default=None,
78
+ metavar="NMI",
79
+ help=(
80
+ "Restrict output to the given NMI. Repeat the flag to keep "
81
+ "more than one (e.g. --nmi A --nmi B). Default: all NMIs."
82
+ ),
83
+ )
84
+ p.add_argument(
85
+ "--start",
86
+ type=_iso_date,
87
+ default=None,
88
+ metavar="YYYY-MM-DD",
89
+ help="Drop rows whose interval / current-read date is before this date.",
90
+ )
91
+ p.add_argument(
92
+ "--end",
93
+ type=_iso_date,
94
+ default=None,
95
+ metavar="YYYY-MM-DD",
96
+ help="Drop rows whose interval / current-read date is after this date.",
97
+ )
98
+ p.add_argument(
99
+ "--validate",
100
+ action="store_true",
101
+ help=(
102
+ "Run structural validation against the AEMO MDFF spec and exit. "
103
+ "Returns 0 if the file is valid, 1 if issues were found."
104
+ ),
105
+ )
106
+ p.add_argument(
107
+ "--version",
108
+ action="version",
109
+ version=f"aemo-mdff-reader {__version__}",
110
+ )
111
+ return p
112
+
113
+
114
+ def _filter_intervals(
115
+ readings: Iterable[IntervalReading],
116
+ *,
117
+ nmis: Optional[Sequence[str]],
118
+ start: Optional[date],
119
+ end: Optional[date],
120
+ ) -> Iterator[IntervalReading]:
121
+ nmi_set = set(nmis) if nmis else None
122
+ for r in readings:
123
+ if nmi_set is not None and r.nmi not in nmi_set:
124
+ continue
125
+ d = r.interval_date.date()
126
+ if start is not None and d < start:
127
+ continue
128
+ if end is not None and d > end:
129
+ continue
130
+ yield r
131
+
132
+
133
+ def _filter_accumulations(
134
+ readings: Iterable[AccumulationReading],
135
+ *,
136
+ nmis: Optional[Sequence[str]],
137
+ start: Optional[date],
138
+ end: Optional[date],
139
+ ) -> Iterator[AccumulationReading]:
140
+ nmi_set = set(nmis) if nmis else None
141
+ for r in readings:
142
+ if nmi_set is not None and r.nmi not in nmi_set:
143
+ continue
144
+ if r.current_register_read_datetime is not None:
145
+ d = r.current_register_read_datetime.date()
146
+ if start is not None and d < start:
147
+ continue
148
+ if end is not None and d > end:
149
+ continue
150
+ yield r
151
+
152
+
153
+ def _emit_parquet(args: argparse.Namespace) -> int:
154
+ try:
155
+ import pandas as pd
156
+ except ImportError:
157
+ print(
158
+ "parquet output requires pandas + pyarrow: pip install aemo-mdff-reader[parquet]",
159
+ file=sys.stderr,
160
+ )
161
+ return 2
162
+ if args.records == "accumulations":
163
+ cols = parse_accumulations_to_columns(args.input)
164
+ df = pd.DataFrame(cols)
165
+ else:
166
+ cols = parse_to_columns(args.input)
167
+ df = pd.DataFrame(cols)
168
+
169
+ # Apply --nmi / --start / --end filters in-DataFrame so the parquet
170
+ # output matches what the streaming CSV path would emit.
171
+ if args.nmi:
172
+ df = df[df["NMI"].isin(args.nmi)]
173
+ if args.records == "accumulations":
174
+ date_col = "CurrentRegisterReadDatetime"
175
+ else:
176
+ date_col = "IntervalDate"
177
+ if args.start is not None:
178
+ df = df[df[date_col] >= pd.Timestamp(args.start)]
179
+ if args.end is not None:
180
+ df = df[df[date_col] <= pd.Timestamp(args.end)]
181
+
182
+ df.to_parquet(args.output)
183
+ return 0
184
+
185
+
186
+ def main(argv: Optional[Sequence[str]] = None) -> int:
187
+ args = _build_parser().parse_args(argv)
188
+
189
+ if args.validate:
190
+ issues = validate_file(args.input)
191
+ if issues:
192
+ for msg in issues:
193
+ print(msg, file=sys.stderr)
194
+ return 1
195
+ print("OK", file=sys.stderr)
196
+ return 0
197
+
198
+ if args.format == "parquet":
199
+ return _emit_parquet(args)
200
+
201
+ out_target = sys.stdout if args.output == "-" else args.output
202
+
203
+ try:
204
+ if args.records == "accumulations":
205
+ acc_stream = _filter_accumulations(
206
+ parse_accumulations(args.input),
207
+ nmis=args.nmi,
208
+ start=args.start,
209
+ end=args.end,
210
+ )
211
+ write_accumulations_csv(acc_stream, out_target)
212
+ else:
213
+ int_stream = _filter_intervals(
214
+ parse(args.input),
215
+ nmis=args.nmi,
216
+ start=args.start,
217
+ end=args.end,
218
+ )
219
+ write_csv(int_stream, out_target)
220
+ except BrokenPipeError:
221
+ # Downstream consumer (e.g. ``head``) closed the pipe early —
222
+ # this is normal, not an error.
223
+ try:
224
+ sys.stdout.flush()
225
+ except BrokenPipeError:
226
+ pass
227
+ return 0
228
+
229
+
230
+ if __name__ == "__main__": # pragma: no cover
231
+ sys.exit(main())