aemo-mdff-reader 3.0.0__tar.gz → 4.0.1__tar.gz
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-3.0.0 → aemo_mdff_reader-4.0.1}/CHANGELOG.md +63 -0
- {aemo_mdff_reader-3.0.0/aemo_mdff_reader.egg-info → aemo_mdff_reader-4.0.1}/PKG-INFO +16 -11
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/README.md +15 -8
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/aemo_mdff_reader/__init__.py +1 -1
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/aemo_mdff_reader/aggregate.py +22 -9
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/aemo_mdff_reader/parser.py +153 -37
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/aemo_mdff_reader/spec.py +2 -2
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/aemo_mdff_reader/types.py +11 -9
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1/aemo_mdff_reader.egg-info}/PKG-INFO +16 -11
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/aemo_mdff_reader.egg-info/SOURCES.txt +0 -4
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/aemo_mdff_reader.egg-info/requires.txt +0 -3
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/pyproject.toml +2 -12
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/tests/test_edge_cases.py +44 -3
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/tests/test_ergonomics.py +14 -0
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/tests/test_events_b2b.py +50 -0
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/tests/test_nem13.py +36 -0
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/tests/test_parser.py +96 -0
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/tests/test_spec.py +12 -0
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/tests/test_validation.py +37 -0
- aemo_mdff_reader-3.0.0/aemo_mdff_reader/sql/__init__.py +0 -28
- aemo_mdff_reader-3.0.0/aemo_mdff_reader/sql/query.py +0 -102
- aemo_mdff_reader-3.0.0/aemo_mdff_reader/sql/store.py +0 -186
- aemo_mdff_reader-3.0.0/tests/test_sql.py +0 -55
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/CONTRIBUTING.md +0 -0
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/LICENSE +0 -0
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/MANIFEST.in +0 -0
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/aemo_mdff_reader/cli.py +0 -0
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/aemo_mdff_reader/py.typed +0 -0
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/aemo_mdff_reader/reader.py +0 -0
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/aemo_mdff_reader.egg-info/dependency_links.txt +0 -0
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/aemo_mdff_reader.egg-info/entry_points.txt +0 -0
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/aemo_mdff_reader.egg-info/top_level.txt +0 -0
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/benchmarks/bench_parser.py +0 -0
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/setup.cfg +0 -0
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/setup.py +0 -0
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/tests/__init__.py +0 -0
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/tests/fixtures/generate.py +0 -0
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/tests/fixtures/sample_nem12.csv +0 -0
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/tests/fixtures/sample_nem13.csv +0 -0
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/tests/test_cli.py +0 -0
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/tests/test_datetime_formats.py +0 -0
- {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/tests/test_large_files.py +0 -0
|
@@ -4,6 +4,69 @@ All notable changes are documented here. The format is based on
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the
|
|
5
5
|
project follows [Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [4.0.1](https://github.com/Utilified/aemo-mdff-reader/compare/v4.0.0...v4.0.1) (2026-11-02)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### Bug Fixes
|
|
11
|
+
|
|
12
|
+
* **deps:** bump certifi to 2026.10.19 for PYSEC-2026-4320 ([#145](https://github.com/Utilified/aemo-mdff-reader/issues/145)) ([7b66f2a](https://github.com/Utilified/aemo-mdff-reader/commit/7b66f2a4cd2b5e333c5258dcfd41277909c9ac62))
|
|
13
|
+
* **deps:** bump msgpack to 1.2.4 for CVE-2026-114613 ([#148](https://github.com/Utilified/aemo-mdff-reader/issues/148)) ([06eb1ac](https://github.com/Utilified/aemo-mdff-reader/commit/06eb1ace6e8a94d4a87cad33211713b917a946e0))
|
|
14
|
+
|
|
15
|
+
## [4.0.0](https://github.com/Utilified/aemo-mdff-reader/compare/v3.0.1...v4.0.0) (2026-09-01)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### ⚠ BREAKING CHANGES
|
|
19
|
+
|
|
20
|
+
* interval and accumulation reading values are now `float | None`. An
|
|
21
|
+
empty cell parses to `None` (missing) instead of `0.0` — a missing
|
|
22
|
+
reading must not be indistinguishable from a genuine zero in a
|
|
23
|
+
billing input. This affects `IntervalReading.value`, the `Value`
|
|
24
|
+
column in `to_columns` / `parse_to_columns` / the pandas and parquet
|
|
25
|
+
paths, and NEM13 `previous_register_read`, `current_register_read`
|
|
26
|
+
and `quantity`. `aggregate.daily_totals` skips `None` readings rather
|
|
27
|
+
than summing them as zero, excludes them from `interval_count`, and
|
|
28
|
+
reports them in the new `DailyTotal.missing_count` field.
|
|
29
|
+
* the `aemo_mdff_reader.sql` subpackage and the `[mysql]` extra are
|
|
30
|
+
removed, along with `QueryBuilder`, `COLUMNS`, `Storer` and
|
|
31
|
+
`DBCredentials`. The subpackage referenced `NEMReader` attributes that
|
|
32
|
+
have not existed since v2, so every entry point raised
|
|
33
|
+
`AttributeError` on call, and its query builder interpolated
|
|
34
|
+
file-derived values into SQL unescaped. There is no replacement:
|
|
35
|
+
persist via `to_columns()` / `iter_columns_chunks()` and your own
|
|
36
|
+
parameterised inserts.
|
|
37
|
+
* files that previously parsed silently now raise `NEM12ParseError`: a
|
|
38
|
+
300 row carrying more interval values than its parent 200 record's
|
|
39
|
+
IntervalLength allows, a non-numeric interval value or reason code, an
|
|
40
|
+
impossible date, and a 400 row whose StartInterval / EndInterval falls
|
|
41
|
+
outside the NMI's interval count. Trailing empty cells on a 300 row
|
|
42
|
+
(Excel padding) are tolerated and do not count against the field
|
|
43
|
+
bound.
|
|
44
|
+
|
|
45
|
+
### Bug Fixes
|
|
46
|
+
|
|
47
|
+
* raise on over-long 300 rows instead of silently truncating them. A
|
|
48
|
+
15-minute file under a stale 30-minute 200 header emitted 48 readings,
|
|
49
|
+
dropped the remaining 48, and took `quality_method` from a dropped
|
|
50
|
+
*value*. `validate_file` now checks 300 field counts too — it
|
|
51
|
+
previously returned clean for a 30-minute NMI carrying 288 values.
|
|
52
|
+
* wrap the remaining raw `ValueError`s at the parse boundary as
|
|
53
|
+
`NEM12ParseError` (`_parse_int`, `_parse_float`, the columnar value
|
|
54
|
+
path, the 400 record's interval bounds, and the fixed-width
|
|
55
|
+
`datetime()` construction), each naming the offending field and value.
|
|
56
|
+
* bound-check 400 StartInterval / EndInterval against the NMI's
|
|
57
|
+
intervals per day — `400,1,999,S` previously parsed against a
|
|
58
|
+
48-interval day.
|
|
59
|
+
* correct the `spec.DIRECTION_INDICATORS` descriptions, which had Import
|
|
60
|
+
and Export the wrong way round.
|
|
61
|
+
|
|
62
|
+
## [3.0.1](https://github.com/Utilified/aemo-mdff-reader/compare/v3.0.0...v3.0.1) (2026-07-18)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
### Bug Fixes
|
|
66
|
+
|
|
67
|
+
* **fuzz:** install with --ignore-requires-python on the 3.11 OSS-Fuzz image ([#75](https://github.com/Utilified/aemo-mdff-reader/issues/75)) ([28605fe](https://github.com/Utilified/aemo-mdff-reader/commit/28605fe6c408891656bf106d7facdab1afe7dd8e))
|
|
68
|
+
* tolerate unparseable 100-record DateTime instead of failing the file ([#73](https://github.com/Utilified/aemo-mdff-reader/issues/73)) ([ec84dd9](https://github.com/Utilified/aemo-mdff-reader/commit/ec84dd9a42c8c92445b0f6ef6fab7a1417ece220)), closes [#72](https://github.com/Utilified/aemo-mdff-reader/issues/72)
|
|
69
|
+
|
|
7
70
|
## [3.0.0](https://github.com/Utilified/aemo-mdff-reader/compare/v2.2.1...v3.0.0) (2026-07-16)
|
|
8
71
|
|
|
9
72
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aemo-mdff-reader
|
|
3
|
-
Version:
|
|
3
|
+
Version: 4.0.1
|
|
4
4
|
Summary: Fast streaming reader for AEMO MDFF NEM12 / NEM13 metering files. Zero required dependencies.
|
|
5
5
|
Author: Utilified Holdings Pty Ltd
|
|
6
6
|
License-Expression: MIT
|
|
@@ -28,8 +28,6 @@ Requires-Dist: pandas>=2; extra == "pandas"
|
|
|
28
28
|
Provides-Extra: parquet
|
|
29
29
|
Requires-Dist: pandas>=2; extra == "parquet"
|
|
30
30
|
Requires-Dist: pyarrow>=10; extra == "parquet"
|
|
31
|
-
Provides-Extra: mysql
|
|
32
|
-
Requires-Dist: PyMySQL>=1.0; extra == "mysql"
|
|
33
31
|
Provides-Extra: dev
|
|
34
32
|
Requires-Dist: pytest>=7; extra == "dev"
|
|
35
33
|
Requires-Dist: pytest-cov>=4; extra == "dev"
|
|
@@ -55,7 +53,7 @@ Fast, zero-dependency streaming reader for AEMO **NEM12** and **NEM13**
|
|
|
55
53
|
metering files. Implements AEMO MDFF (Meter Data File Format) v2.6.
|
|
56
54
|
|
|
57
55
|
- O(1) memory — iterate through millions of intervals.
|
|
58
|
-
- Pure stdlib core; pandas /
|
|
56
|
+
- Pure stdlib core; pandas / pyarrow are opt-in extras.
|
|
59
57
|
- ~2 M readings/sec on the columnar fast path.
|
|
60
58
|
- Includes an `aemo-mdff-reader` CLI.
|
|
61
59
|
|
|
@@ -65,8 +63,8 @@ metering files. Implements AEMO MDFF (Meter Data File Format) v2.6.
|
|
|
65
63
|
pip install aemo-mdff-reader
|
|
66
64
|
|
|
67
65
|
# optional extras
|
|
68
|
-
pip install aemo-mdff-reader[pandas] # to_dataframe()
|
|
69
|
-
pip install aemo-mdff-reader[
|
|
66
|
+
pip install aemo-mdff-reader[pandas] # to_dataframe()
|
|
67
|
+
pip install aemo-mdff-reader[parquet] # parquet output (pandas + pyarrow)
|
|
70
68
|
```
|
|
71
69
|
|
|
72
70
|
## Use
|
|
@@ -83,8 +81,8 @@ Or as a flat CSV / DataFrame:
|
|
|
83
81
|
```python
|
|
84
82
|
from aemo_mdff_reader import parse, write_csv, to_dataframe
|
|
85
83
|
|
|
86
|
-
write_csv(parse("metering.csv"), "out.csv")
|
|
87
|
-
df = to_dataframe("metering.csv")
|
|
84
|
+
write_csv(parse("metering.csv"), "out.csv") # no pandas
|
|
85
|
+
df = to_dataframe("metering.csv") # needs [pandas]
|
|
88
86
|
```
|
|
89
87
|
|
|
90
88
|
From the command line:
|
|
@@ -103,7 +101,7 @@ Each parsed record is a slots-based class with named attributes plus a
|
|
|
103
101
|
|
|
104
102
|
```python
|
|
105
103
|
for r in parse("metering.csv"):
|
|
106
|
-
payload = r.to_dict()
|
|
104
|
+
payload = r.to_dict() # {"nmi": "...", "value": 0.12, ...}
|
|
107
105
|
print(r.quality_flag, r.method_flag) # split of the QMM field, e.g. "S", "52"
|
|
108
106
|
```
|
|
109
107
|
|
|
@@ -155,6 +153,13 @@ Each `parse(...)` yields an `IntervalReading` with `nmi`,
|
|
|
155
153
|
the type stubs (`from aemo_mdff_reader import IntervalReading`) for the
|
|
156
154
|
exact signatures.
|
|
157
155
|
|
|
156
|
+
`value` is `float | None`: an empty cell in the source file is a
|
|
157
|
+
*missing* reading and stays `None` rather than becoming `0.0`, which
|
|
158
|
+
would be indistinguishable from a genuine zero. The same holds for the
|
|
159
|
+
NEM13 `previous_register_read`, `current_register_read` and `quantity`
|
|
160
|
+
fields. `aggregate.daily_totals` skips missing readings and reports how
|
|
161
|
+
many it saw in `DailyTotal.missing_count`.
|
|
162
|
+
|
|
158
163
|
## Notes
|
|
159
164
|
|
|
160
165
|
- **Spec**: AEMO Meter Data File Format Specification NEM12 & NEM13,
|
|
@@ -165,8 +170,8 @@ exact signatures.
|
|
|
165
170
|
as constants in `aemo_mdff_reader.spec` for callers that want stricter
|
|
166
171
|
validation than the parser performs.
|
|
167
172
|
- **Tolerant**: UTF-8 BOM is consumed silently, LF and CRLF both work,
|
|
168
|
-
and empty interval cells
|
|
169
|
-
|
|
173
|
+
and empty interval cells parse to `None` (missing), never `0.0`.
|
|
174
|
+
Datetime fields accept the spec
|
|
170
175
|
forms (`YYYYMMDD`, `YYYYMMDDhhmmss`) and a few common non-spec
|
|
171
176
|
variants (`YYYY-MM-DD`, ISO `YYYY-MM-DDTHH:MM:SS`, with or without a
|
|
172
177
|
`Z` / `±HH:MM` / `±HHMM` timezone suffix — the suffix is stripped
|
|
@@ -11,7 +11,7 @@ Fast, zero-dependency streaming reader for AEMO **NEM12** and **NEM13**
|
|
|
11
11
|
metering files. Implements AEMO MDFF (Meter Data File Format) v2.6.
|
|
12
12
|
|
|
13
13
|
- O(1) memory — iterate through millions of intervals.
|
|
14
|
-
- Pure stdlib core; pandas /
|
|
14
|
+
- Pure stdlib core; pandas / pyarrow are opt-in extras.
|
|
15
15
|
- ~2 M readings/sec on the columnar fast path.
|
|
16
16
|
- Includes an `aemo-mdff-reader` CLI.
|
|
17
17
|
|
|
@@ -21,8 +21,8 @@ metering files. Implements AEMO MDFF (Meter Data File Format) v2.6.
|
|
|
21
21
|
pip install aemo-mdff-reader
|
|
22
22
|
|
|
23
23
|
# optional extras
|
|
24
|
-
pip install aemo-mdff-reader[pandas] # to_dataframe()
|
|
25
|
-
pip install aemo-mdff-reader[
|
|
24
|
+
pip install aemo-mdff-reader[pandas] # to_dataframe()
|
|
25
|
+
pip install aemo-mdff-reader[parquet] # parquet output (pandas + pyarrow)
|
|
26
26
|
```
|
|
27
27
|
|
|
28
28
|
## Use
|
|
@@ -39,8 +39,8 @@ Or as a flat CSV / DataFrame:
|
|
|
39
39
|
```python
|
|
40
40
|
from aemo_mdff_reader import parse, write_csv, to_dataframe
|
|
41
41
|
|
|
42
|
-
write_csv(parse("metering.csv"), "out.csv")
|
|
43
|
-
df = to_dataframe("metering.csv")
|
|
42
|
+
write_csv(parse("metering.csv"), "out.csv") # no pandas
|
|
43
|
+
df = to_dataframe("metering.csv") # needs [pandas]
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
From the command line:
|
|
@@ -59,7 +59,7 @@ Each parsed record is a slots-based class with named attributes plus a
|
|
|
59
59
|
|
|
60
60
|
```python
|
|
61
61
|
for r in parse("metering.csv"):
|
|
62
|
-
payload = r.to_dict()
|
|
62
|
+
payload = r.to_dict() # {"nmi": "...", "value": 0.12, ...}
|
|
63
63
|
print(r.quality_flag, r.method_flag) # split of the QMM field, e.g. "S", "52"
|
|
64
64
|
```
|
|
65
65
|
|
|
@@ -111,6 +111,13 @@ Each `parse(...)` yields an `IntervalReading` with `nmi`,
|
|
|
111
111
|
the type stubs (`from aemo_mdff_reader import IntervalReading`) for the
|
|
112
112
|
exact signatures.
|
|
113
113
|
|
|
114
|
+
`value` is `float | None`: an empty cell in the source file is a
|
|
115
|
+
*missing* reading and stays `None` rather than becoming `0.0`, which
|
|
116
|
+
would be indistinguishable from a genuine zero. The same holds for the
|
|
117
|
+
NEM13 `previous_register_read`, `current_register_read` and `quantity`
|
|
118
|
+
fields. `aggregate.daily_totals` skips missing readings and reports how
|
|
119
|
+
many it saw in `DailyTotal.missing_count`.
|
|
120
|
+
|
|
114
121
|
## Notes
|
|
115
122
|
|
|
116
123
|
- **Spec**: AEMO Meter Data File Format Specification NEM12 & NEM13,
|
|
@@ -121,8 +128,8 @@ exact signatures.
|
|
|
121
128
|
as constants in `aemo_mdff_reader.spec` for callers that want stricter
|
|
122
129
|
validation than the parser performs.
|
|
123
130
|
- **Tolerant**: UTF-8 BOM is consumed silently, LF and CRLF both work,
|
|
124
|
-
and empty interval cells
|
|
125
|
-
|
|
131
|
+
and empty interval cells parse to `None` (missing), never `0.0`.
|
|
132
|
+
Datetime fields accept the spec
|
|
126
133
|
forms (`YYYYMMDD`, `YYYYMMDDhhmmss`) and a few common non-spec
|
|
127
134
|
variants (`YYYY-MM-DD`, ISO `YYYY-MM-DDTHH:MM:SS`, with or without a
|
|
128
135
|
`Z` / `±HH:MM` / `±HHMM` timezone suffix — the suffix is stripped
|
|
@@ -61,11 +61,15 @@ class DailyTotal(NamedTuple):
|
|
|
61
61
|
"""A single channel's total energy / quantity for a calendar day.
|
|
62
62
|
|
|
63
63
|
``total`` is the sum of ``IntervalReading.value`` for every reading
|
|
64
|
-
that fell on ``interval_date``. ``
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
``
|
|
68
|
-
|
|
64
|
+
that fell on ``interval_date``. Readings with a ``None`` value are
|
|
65
|
+
*missing*, not zero: they are excluded from ``total`` and from
|
|
66
|
+
``interval_count``, and counted in ``missing_count`` instead — so a
|
|
67
|
+
day with any ``missing_count > 0`` is an incomplete total, not a
|
|
68
|
+
lower one. ``interval_count`` is the number of readings summed
|
|
69
|
+
(useful for spotting partial days). ``unique_quality_flags`` is the
|
|
70
|
+
set of distinct 1-character quality flags seen — a single ``{"A"}``
|
|
71
|
+
means the day is fully Actual, anything else hints at substituted or
|
|
72
|
+
estimated data.
|
|
69
73
|
"""
|
|
70
74
|
|
|
71
75
|
nmi: str
|
|
@@ -76,6 +80,7 @@ class DailyTotal(NamedTuple):
|
|
|
76
80
|
total: float
|
|
77
81
|
interval_count: int
|
|
78
82
|
unique_quality_flags: frozenset[str]
|
|
83
|
+
missing_count: int
|
|
79
84
|
|
|
80
85
|
|
|
81
86
|
def group_by_nmi(
|
|
@@ -111,13 +116,15 @@ def daily_totals(readings: Iterable[IntervalReading]) -> Iterator[DailyTotal]:
|
|
|
111
116
|
O(1)-memory operation: we accumulate while ``(channel, date)`` stays
|
|
112
117
|
constant and emit when it changes.
|
|
113
118
|
|
|
114
|
-
Empty cells in the source
|
|
115
|
-
:class:`IntervalReading`)
|
|
119
|
+
Empty cells in the source parse to ``None`` (see
|
|
120
|
+
:class:`IntervalReading`). They are skipped rather than summed as
|
|
121
|
+
zero, and reported per day via ``DailyTotal.missing_count``.
|
|
116
122
|
"""
|
|
117
123
|
current_key: Optional[Tuple[ChannelKey, datetime]] = None
|
|
118
124
|
current_uom: str = ""
|
|
119
125
|
total = 0.0
|
|
120
126
|
count = 0
|
|
127
|
+
missing = 0
|
|
121
128
|
flags: set[str] = set()
|
|
122
129
|
|
|
123
130
|
for r in readings:
|
|
@@ -135,14 +142,19 @@ def daily_totals(readings: Iterable[IntervalReading]) -> Iterator[DailyTotal]:
|
|
|
135
142
|
total=total,
|
|
136
143
|
interval_count=count,
|
|
137
144
|
unique_quality_flags=frozenset(flags),
|
|
145
|
+
missing_count=missing,
|
|
138
146
|
)
|
|
139
147
|
current_key = key
|
|
140
148
|
current_uom = r.uom
|
|
141
149
|
total = 0.0
|
|
142
150
|
count = 0
|
|
151
|
+
missing = 0
|
|
143
152
|
flags = set()
|
|
144
|
-
|
|
145
|
-
|
|
153
|
+
if r.value is None:
|
|
154
|
+
missing += 1
|
|
155
|
+
else:
|
|
156
|
+
total += r.value
|
|
157
|
+
count += 1
|
|
146
158
|
if r.quality_method:
|
|
147
159
|
flags.add(r.quality_method[:1])
|
|
148
160
|
|
|
@@ -156,6 +168,7 @@ def daily_totals(readings: Iterable[IntervalReading]) -> Iterator[DailyTotal]:
|
|
|
156
168
|
total=total,
|
|
157
169
|
interval_count=count,
|
|
158
170
|
unique_quality_flags=frozenset(flags),
|
|
171
|
+
missing_count=missing,
|
|
159
172
|
)
|
|
160
173
|
|
|
161
174
|
|
|
@@ -45,6 +45,12 @@ END_RECORD = "900"
|
|
|
45
45
|
# Field counts per AEMO MDFF v2.6 (NEM12 / NEM13).
|
|
46
46
|
ACCUMULATION_FIELDS = 23 # 250 record
|
|
47
47
|
|
|
48
|
+
# A 300 record carries at most five trailing fields after the interval
|
|
49
|
+
# values: QualityMethod, ReasonCode, ReasonDescription, UpdateDateTime,
|
|
50
|
+
# MSATSLoadDateTime (MDFF v2.6 §4.4). Anything beyond that means the row
|
|
51
|
+
# holds more values than the parent 200 record's IntervalLength implies.
|
|
52
|
+
INTERVAL_TRAILER_FIELDS = 5
|
|
53
|
+
|
|
48
54
|
MINUTES_PER_DAY = 24 * 60
|
|
49
55
|
|
|
50
56
|
# Path-like is anything ``os.fspath`` can convert. Streams are anything
|
|
@@ -87,27 +93,33 @@ def _parse_datetime(value: str) -> Optional[datetime]:
|
|
|
87
93
|
value = value[: m.start()]
|
|
88
94
|
|
|
89
95
|
n = len(value)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
96
|
+
# ``datetime(...)`` raises a bare ValueError on impossible components
|
|
97
|
+
# (e.g. ``20049999`` → "month must be in 1..12"); surface it as the
|
|
98
|
+
# advertised NEM12ParseError instead.
|
|
99
|
+
try:
|
|
100
|
+
if n == 14:
|
|
101
|
+
return datetime(
|
|
102
|
+
int(value[0:4]),
|
|
103
|
+
int(value[4:6]),
|
|
104
|
+
int(value[6:8]),
|
|
105
|
+
int(value[8:10]),
|
|
106
|
+
int(value[10:12]),
|
|
107
|
+
int(value[12:14]),
|
|
108
|
+
)
|
|
109
|
+
if n == 12: # YYYYMMDDhhmm — seen in some retailer exports
|
|
110
|
+
return datetime(
|
|
111
|
+
int(value[0:4]),
|
|
112
|
+
int(value[4:6]),
|
|
113
|
+
int(value[6:8]),
|
|
114
|
+
int(value[8:10]),
|
|
115
|
+
int(value[10:12]),
|
|
116
|
+
)
|
|
117
|
+
if n == 8:
|
|
118
|
+
return datetime(int(value[0:4]), int(value[4:6]), int(value[6:8]))
|
|
119
|
+
if n == 10 and value[4] == "-" and value[7] == "-":
|
|
120
|
+
return datetime(int(value[0:4]), int(value[5:7]), int(value[8:10]))
|
|
121
|
+
except ValueError as exc:
|
|
122
|
+
raise NEM12ParseError(f"Invalid date/time value: {value!r} ({exc})") from exc
|
|
111
123
|
|
|
112
124
|
# ISO 8601 date-time fall-through, e.g. ``2024-01-01T00:00:00`` or
|
|
113
125
|
# ``2024-01-01 00:00:00`` (with optional microseconds). datetime's
|
|
@@ -128,21 +140,26 @@ def _parse_datetime(value: str) -> Optional[datetime]:
|
|
|
128
140
|
def _parse_int(value: str) -> Optional[int]:
|
|
129
141
|
if value == "" or value is None:
|
|
130
142
|
return None
|
|
131
|
-
|
|
143
|
+
try:
|
|
144
|
+
return int(value)
|
|
145
|
+
except ValueError as exc:
|
|
146
|
+
raise NEM12ParseError(f"Expected an integer field, got {value!r}") from exc
|
|
132
147
|
|
|
133
148
|
|
|
134
|
-
def _parse_float(value: str) -> float:
|
|
135
|
-
"""Parse a numeric NEM12 cell.
|
|
149
|
+
def _parse_float(value: str) -> Optional[float]:
|
|
150
|
+
"""Parse a numeric NEM12 / NEM13 cell.
|
|
136
151
|
|
|
137
|
-
NEM12 uses decimal numbers without thousands separators.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
reading — see :class:`IntervalReading` for the surfaced fields.
|
|
152
|
+
NEM12 uses decimal numbers without thousands separators. An empty
|
|
153
|
+
cell is a *missing* reading and returns ``None`` — never ``0.0``,
|
|
154
|
+
which would be indistinguishable from a genuine zero reading in a
|
|
155
|
+
billing input.
|
|
142
156
|
"""
|
|
143
157
|
if value == "" or value is None:
|
|
144
|
-
return
|
|
145
|
-
|
|
158
|
+
return None
|
|
159
|
+
try:
|
|
160
|
+
return float(value)
|
|
161
|
+
except ValueError as exc:
|
|
162
|
+
raise NEM12ParseError(f"Expected a numeric field, got {value!r}") from exc
|
|
146
163
|
|
|
147
164
|
|
|
148
165
|
class _ParserState:
|
|
@@ -286,13 +303,33 @@ def parse_header(source: RowSource) -> Optional[Header]:
|
|
|
286
303
|
return None
|
|
287
304
|
|
|
288
305
|
|
|
289
|
-
def _parse_header(row: Sequence[str]) -> Header:
|
|
306
|
+
def _parse_header(row: Sequence[str], *, strict: bool = False) -> Header:
|
|
307
|
+
"""Parse a 100 record.
|
|
308
|
+
|
|
309
|
+
The DateTime field is file provenance: nothing in this library reads
|
|
310
|
+
it back, and interval timestamps come from the 300 records. Files
|
|
311
|
+
round-tripped through a spreadsheet routinely arrive with it mangled
|
|
312
|
+
— Excel reads the 12/14-digit value as a number and rewrites it as
|
|
313
|
+
``2.02607E+11``, which is lossy beyond recovery. Failing the whole
|
|
314
|
+
file over an unread field is the wrong trade, so an unparseable
|
|
315
|
+
value degrades to ``None`` here.
|
|
316
|
+
|
|
317
|
+
``strict=True`` restores the raise, for :func:`validate_file` — the
|
|
318
|
+
opt-in pass whose job is to report exactly this kind of damage.
|
|
319
|
+
"""
|
|
290
320
|
# 100, VersionHeader, DateTime, FromParticipant, ToParticipant
|
|
291
321
|
if len(row) < 3:
|
|
292
322
|
raise NEM12ParseError("100 header row missing required fields")
|
|
323
|
+
file_datetime: Optional[datetime] = None
|
|
324
|
+
if len(row) > 2:
|
|
325
|
+
try:
|
|
326
|
+
file_datetime = _parse_datetime(row[2])
|
|
327
|
+
except NEM12ParseError:
|
|
328
|
+
if strict:
|
|
329
|
+
raise
|
|
293
330
|
return Header(
|
|
294
331
|
version=row[1] if len(row) > 1 else "",
|
|
295
|
-
datetime=
|
|
332
|
+
datetime=file_datetime,
|
|
296
333
|
from_participant=row[3] if len(row) > 3 else "",
|
|
297
334
|
to_participant=row[4] if len(row) > 4 else "",
|
|
298
335
|
)
|
|
@@ -330,6 +367,37 @@ def _parse_nmi(row: Sequence[str]) -> NMIDetails:
|
|
|
330
367
|
)
|
|
331
368
|
|
|
332
369
|
|
|
370
|
+
def _effective_row_len(row: Sequence[str], minimum: int) -> int:
|
|
371
|
+
"""Length of *row* ignoring trailing empty cells, floored at *minimum*.
|
|
372
|
+
|
|
373
|
+
Excel round-trips pad every row to the widest row's width with empty
|
|
374
|
+
cells; those must not count against the field-count upper bound.
|
|
375
|
+
"""
|
|
376
|
+
effective = len(row)
|
|
377
|
+
while effective > minimum and row[effective - 1] == "":
|
|
378
|
+
effective -= 1
|
|
379
|
+
return effective
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _check_interval_value_count(row: Sequence[str], nmi: NMIDetails, n: int) -> None:
|
|
383
|
+
"""Reject a 300 row carrying more values than its 200 record allows.
|
|
384
|
+
|
|
385
|
+
Only checking the lower bound lets a 15-minute row sitting under a
|
|
386
|
+
stale 30-minute 200 header parse as 48 readings, silently dropping
|
|
387
|
+
the rest and picking up a *reading* as the QualityMethod trailer.
|
|
388
|
+
Trailing empty cells (Excel padding) are ignored.
|
|
389
|
+
"""
|
|
390
|
+
max_fields = 2 + n + INTERVAL_TRAILER_FIELDS
|
|
391
|
+
effective = _effective_row_len(row, 2 + n)
|
|
392
|
+
if effective > max_fields:
|
|
393
|
+
raise NEM12ParseError(
|
|
394
|
+
f"300 row for NMI {nmi.nmi} on {row[1]} has {effective - 2} fields "
|
|
395
|
+
f"after IntervalDate, expected {n} interval values plus at most "
|
|
396
|
+
f"{INTERVAL_TRAILER_FIELDS} trailer fields "
|
|
397
|
+
f"for IntervalLength={nmi.interval_length}"
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
333
401
|
def _emit_intervals(row: Sequence[str], state: _ParserState) -> Iterator[IntervalReading]:
|
|
334
402
|
nmi = state.current_nmi
|
|
335
403
|
if nmi is None:
|
|
@@ -342,6 +410,7 @@ def _emit_intervals(row: Sequence[str], state: _ParserState) -> Iterator[Interva
|
|
|
342
410
|
f"300 row has {len(row)} fields, expected at least {expected_min} "
|
|
343
411
|
f"for IntervalLength={nmi.interval_length}"
|
|
344
412
|
)
|
|
413
|
+
_check_interval_value_count(row, nmi, n)
|
|
345
414
|
|
|
346
415
|
interval_date = _parse_datetime(row[1])
|
|
347
416
|
if interval_date is None:
|
|
@@ -453,6 +522,7 @@ def parse_to_columns(source: RowSource) -> Columns:
|
|
|
453
522
|
raise NEM12ParseError(
|
|
454
523
|
f"300 row has {len(row)} fields, expected at least {expected_min}"
|
|
455
524
|
)
|
|
525
|
+
_check_interval_value_count(row, nmi_obj, n)
|
|
456
526
|
interval_date = _parse_datetime(row[1])
|
|
457
527
|
if interval_date is None:
|
|
458
528
|
raise NEM12ParseError("300 row missing IntervalDate")
|
|
@@ -484,7 +554,13 @@ def parse_to_columns(source: RowSource) -> Columns:
|
|
|
484
554
|
a_iend(end)
|
|
485
555
|
a_idx(i + 1)
|
|
486
556
|
cell = row[base + i]
|
|
487
|
-
|
|
557
|
+
if cell == "":
|
|
558
|
+
a_val(None)
|
|
559
|
+
else:
|
|
560
|
+
try:
|
|
561
|
+
a_val(float(cell))
|
|
562
|
+
except ValueError as exc:
|
|
563
|
+
raise NEM12ParseError(f"Expected a numeric field, got {cell!r}") from exc
|
|
488
564
|
a_q(quality)
|
|
489
565
|
a_rc(reason_code)
|
|
490
566
|
a_rd(reason_desc)
|
|
@@ -1023,13 +1099,38 @@ def _parse_interval_event(
|
|
|
1023
1099
|
def _get(i: int) -> str:
|
|
1024
1100
|
return row[i] if i < len(row) else ""
|
|
1025
1101
|
|
|
1102
|
+
# An IntervalLength that does not divide the day has no well-defined interval
|
|
1103
|
+
# count, so the ceiling is unknowable; the 1.. floor still applies. Mirrors the
|
|
1104
|
+
# guard used wherever else intervals-per-day is derived.
|
|
1105
|
+
il = nmi.interval_length
|
|
1106
|
+
intervals_per_day = MINUTES_PER_DAY // il if il > 0 and MINUTES_PER_DAY % il == 0 else None
|
|
1107
|
+
|
|
1108
|
+
bounds = []
|
|
1109
|
+
for idx, name in ((1, "StartInterval"), (2, "EndInterval")):
|
|
1110
|
+
try:
|
|
1111
|
+
bound = int(row[idx])
|
|
1112
|
+
except ValueError as exc:
|
|
1113
|
+
raise NEM12ParseError(f"400 event row has non-integer {name}: {row[idx]!r}") from exc
|
|
1114
|
+
if bound < 1 or (intervals_per_day is not None and bound > intervals_per_day):
|
|
1115
|
+
ceiling = "" if intervals_per_day is None else f"..{intervals_per_day}"
|
|
1116
|
+
raise NEM12ParseError(
|
|
1117
|
+
f"400 event row has {name}={bound}; must be in 1{ceiling} for IntervalLength={il}"
|
|
1118
|
+
)
|
|
1119
|
+
bounds.append(bound)
|
|
1120
|
+
|
|
1121
|
+
if bounds[0] > bounds[1]:
|
|
1122
|
+
raise NEM12ParseError(
|
|
1123
|
+
f"400 event row has StartInterval={bounds[0]} after EndInterval={bounds[1]}; "
|
|
1124
|
+
"the range is inverted"
|
|
1125
|
+
)
|
|
1126
|
+
|
|
1026
1127
|
return IntervalEvent(
|
|
1027
1128
|
nmi=nmi.nmi,
|
|
1028
1129
|
meter_serial_number=nmi.meter_serial_number,
|
|
1029
1130
|
register_id=nmi.register_id,
|
|
1030
1131
|
interval_date=interval_date,
|
|
1031
|
-
start_interval=
|
|
1032
|
-
end_interval=
|
|
1132
|
+
start_interval=bounds[0],
|
|
1133
|
+
end_interval=bounds[1],
|
|
1033
1134
|
quality_method=_get(3),
|
|
1034
1135
|
reason_code=_parse_int(_get(4)),
|
|
1035
1136
|
reason_description=_get(5),
|
|
@@ -1189,7 +1290,7 @@ def validate_file(source: RowSource) -> List[str]:
|
|
|
1189
1290
|
if line_no != 1:
|
|
1190
1291
|
issues.append(f"line {line_no}: 100 header should be the first row")
|
|
1191
1292
|
try:
|
|
1192
|
-
_parse_header(row)
|
|
1293
|
+
_parse_header(row, strict=True)
|
|
1193
1294
|
except NEM12ParseError as exc:
|
|
1194
1295
|
issues.append(f"line {line_no}: {exc}")
|
|
1195
1296
|
elif rec == NMI_RECORD:
|
|
@@ -1203,6 +1304,21 @@ def validate_file(source: RowSource) -> List[str]:
|
|
|
1203
1304
|
elif rec == INTERVAL_RECORD:
|
|
1204
1305
|
if current_nmi is None:
|
|
1205
1306
|
issues.append(f"line {line_no}: 300 row encountered before any 200 NMI row")
|
|
1307
|
+
elif MINUTES_PER_DAY % current_nmi.interval_length == 0:
|
|
1308
|
+
n = MINUTES_PER_DAY // current_nmi.interval_length
|
|
1309
|
+
if len(row) < 2 + n:
|
|
1310
|
+
issues.append(
|
|
1311
|
+
f"line {line_no}: 300 row has {len(row) - 2} interval values, "
|
|
1312
|
+
f"expected {n} for IntervalLength={current_nmi.interval_length}"
|
|
1313
|
+
)
|
|
1314
|
+
elif _effective_row_len(row, 2 + n) > 2 + n + INTERVAL_TRAILER_FIELDS:
|
|
1315
|
+
issues.append(
|
|
1316
|
+
f"line {line_no}: 300 row has "
|
|
1317
|
+
f"{_effective_row_len(row, 2 + n) - 2} fields after "
|
|
1318
|
+
f"IntervalDate, expected {n} interval values plus at most "
|
|
1319
|
+
f"{INTERVAL_TRAILER_FIELDS} trailer fields "
|
|
1320
|
+
f"for IntervalLength={current_nmi.interval_length}"
|
|
1321
|
+
)
|
|
1206
1322
|
elif rec == ACCUMULATION_RECORD:
|
|
1207
1323
|
if len(row) < 20:
|
|
1208
1324
|
issues.append(
|
|
@@ -41,8 +41,8 @@ ALLOWED_INTERVAL_LENGTHS: FrozenSet[int] = frozenset({5, 15, 30})
|
|
|
41
41
|
|
|
42
42
|
#: DirectionIndicator allowed values per the spec.
|
|
43
43
|
DIRECTION_INDICATORS: Dict[str, str] = {
|
|
44
|
-
"I": "Import (energy flows from the
|
|
45
|
-
"E": "Export (energy flows from the
|
|
44
|
+
"I": "Import (energy flows from the grid to the connection point)",
|
|
45
|
+
"E": "Export (energy flows from the connection point to the grid)",
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
|
|
@@ -118,11 +118,11 @@ class IntervalReading:
|
|
|
118
118
|
|
|
119
119
|
Notes
|
|
120
120
|
-----
|
|
121
|
-
``value`` is
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
``"
|
|
125
|
-
|
|
121
|
+
``value`` is ``None`` when the source cell was empty — a missing
|
|
122
|
+
reading, which must not be confused with a genuine ``0.0``. Callers
|
|
123
|
+
handling billing data should decide explicitly what to do with it;
|
|
124
|
+
``quality_method`` (e.g. ``"S"`` for substituted, ``"F"`` for
|
|
125
|
+
failed) says why the reading is what it is.
|
|
126
126
|
"""
|
|
127
127
|
|
|
128
128
|
__slots__ = (
|
|
@@ -156,7 +156,7 @@ class IntervalReading:
|
|
|
156
156
|
interval_start: datetime,
|
|
157
157
|
interval_end: datetime,
|
|
158
158
|
interval_index: int,
|
|
159
|
-
value: float,
|
|
159
|
+
value: Optional[float],
|
|
160
160
|
quality_method: str,
|
|
161
161
|
reason_code: Optional[int],
|
|
162
162
|
reason_description: str,
|
|
@@ -262,6 +262,8 @@ class AccumulationReading:
|
|
|
262
262
|
|
|
263
263
|
Each 250 record represents a previous + current register read pair
|
|
264
264
|
plus a calculated quantity (consumption between the two reads).
|
|
265
|
+
``previous_register_read``, ``current_register_read`` and
|
|
266
|
+
``quantity`` are ``None`` when the source cell was empty.
|
|
265
267
|
|
|
266
268
|
.. note::
|
|
267
269
|
``direction_indicator`` is stored as the raw string from the
|
|
@@ -306,17 +308,17 @@ class AccumulationReading:
|
|
|
306
308
|
mdm_data_stream_identifier: str,
|
|
307
309
|
meter_serial_number: str,
|
|
308
310
|
direction_indicator: str,
|
|
309
|
-
previous_register_read: float,
|
|
311
|
+
previous_register_read: Optional[float],
|
|
310
312
|
previous_register_read_datetime: Optional[datetime],
|
|
311
313
|
previous_quality_method: str,
|
|
312
314
|
previous_reason_code: Optional[int],
|
|
313
315
|
previous_reason_description: str,
|
|
314
|
-
current_register_read: float,
|
|
316
|
+
current_register_read: Optional[float],
|
|
315
317
|
current_register_read_datetime: Optional[datetime],
|
|
316
318
|
current_quality_method: str,
|
|
317
319
|
current_reason_code: Optional[int],
|
|
318
320
|
current_reason_description: str,
|
|
319
|
-
quantity: float,
|
|
321
|
+
quantity: Optional[float],
|
|
320
322
|
uom: str,
|
|
321
323
|
next_scheduled_read_date: Optional[datetime],
|
|
322
324
|
update_datetime: Optional[datetime],
|