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.
Files changed (42) hide show
  1. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/CHANGELOG.md +63 -0
  2. {aemo_mdff_reader-3.0.0/aemo_mdff_reader.egg-info → aemo_mdff_reader-4.0.1}/PKG-INFO +16 -11
  3. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/README.md +15 -8
  4. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/aemo_mdff_reader/__init__.py +1 -1
  5. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/aemo_mdff_reader/aggregate.py +22 -9
  6. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/aemo_mdff_reader/parser.py +153 -37
  7. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/aemo_mdff_reader/spec.py +2 -2
  8. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/aemo_mdff_reader/types.py +11 -9
  9. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1/aemo_mdff_reader.egg-info}/PKG-INFO +16 -11
  10. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/aemo_mdff_reader.egg-info/SOURCES.txt +0 -4
  11. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/aemo_mdff_reader.egg-info/requires.txt +0 -3
  12. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/pyproject.toml +2 -12
  13. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/tests/test_edge_cases.py +44 -3
  14. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/tests/test_ergonomics.py +14 -0
  15. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/tests/test_events_b2b.py +50 -0
  16. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/tests/test_nem13.py +36 -0
  17. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/tests/test_parser.py +96 -0
  18. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/tests/test_spec.py +12 -0
  19. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/tests/test_validation.py +37 -0
  20. aemo_mdff_reader-3.0.0/aemo_mdff_reader/sql/__init__.py +0 -28
  21. aemo_mdff_reader-3.0.0/aemo_mdff_reader/sql/query.py +0 -102
  22. aemo_mdff_reader-3.0.0/aemo_mdff_reader/sql/store.py +0 -186
  23. aemo_mdff_reader-3.0.0/tests/test_sql.py +0 -55
  24. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/CONTRIBUTING.md +0 -0
  25. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/LICENSE +0 -0
  26. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/MANIFEST.in +0 -0
  27. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/aemo_mdff_reader/cli.py +0 -0
  28. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/aemo_mdff_reader/py.typed +0 -0
  29. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/aemo_mdff_reader/reader.py +0 -0
  30. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/aemo_mdff_reader.egg-info/dependency_links.txt +0 -0
  31. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/aemo_mdff_reader.egg-info/entry_points.txt +0 -0
  32. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/aemo_mdff_reader.egg-info/top_level.txt +0 -0
  33. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/benchmarks/bench_parser.py +0 -0
  34. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/setup.cfg +0 -0
  35. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/setup.py +0 -0
  36. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/tests/__init__.py +0 -0
  37. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/tests/fixtures/generate.py +0 -0
  38. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/tests/fixtures/sample_nem12.csv +0 -0
  39. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/tests/fixtures/sample_nem13.csv +0 -0
  40. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/tests/test_cli.py +0 -0
  41. {aemo_mdff_reader-3.0.0 → aemo_mdff_reader-4.0.1}/tests/test_datetime_formats.py +0 -0
  42. {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.0.0
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 / PyMySQL are opt-in extras.
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() / parquet
69
- pip install aemo-mdff-reader[mysql] # SQL persistence
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") # no pandas
87
- df = to_dataframe("metering.csv") # needs [pandas]
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() # {"nmi": "...", "value": 0.12, ...}
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 are coerced to `0.0` (use `quality_method`
169
- to distinguish missing from zero). Datetime fields accept the spec
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 / PyMySQL are opt-in extras.
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() / parquet
25
- pip install aemo-mdff-reader[mysql] # SQL persistence
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") # no pandas
43
- df = to_dataframe("metering.csv") # needs [pandas]
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() # {"nmi": "...", "value": 0.12, ...}
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 are coerced to `0.0` (use `quality_method`
125
- to distinguish missing from zero). Datetime fields accept the spec
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
@@ -48,7 +48,7 @@ from .types import (
48
48
  NMIDetails,
49
49
  )
50
50
 
51
- __version__ = "3.0.0" # x-release-please-version
51
+ __version__ = "4.0.1" # x-release-please-version
52
52
 
53
53
  __all__ = [
54
54
  "INTERVAL_DATA_OUTPUT_HEADERS",
@@ -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``. ``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.
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 are coerced to ``0.0`` upstream (see
115
- :class:`IntervalReading`); they are summed as zero.
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
- total += r.value
145
- count += 1
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
- if n == 14:
91
- return datetime(
92
- int(value[0:4]),
93
- int(value[4:6]),
94
- int(value[6:8]),
95
- int(value[8:10]),
96
- int(value[10:12]),
97
- int(value[12:14]),
98
- )
99
- if n == 12: # YYYYMMDDhhmm — seen in some retailer exports
100
- return datetime(
101
- int(value[0:4]),
102
- int(value[4:6]),
103
- int(value[6:8]),
104
- int(value[8:10]),
105
- int(value[10:12]),
106
- )
107
- if n == 8:
108
- return datetime(int(value[0:4]), int(value[4:6]), int(value[6:8]))
109
- if n == 10 and value[4] == "-" and value[7] == "-":
110
- return datetime(int(value[0:4]), int(value[5:7]), int(value[8:10]))
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
- return int(value)
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. Empty cells
138
- (sometimes seen for missing intervals) are coerced to ``0.0`` so a
139
- single missing cell doesn't fail an entire row. Use the row's
140
- ``QualityMethod`` flag to distinguish a real zero from a missing
141
- reading — see :class:`IntervalReading` for the surfaced fields.
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 0.0
145
- return float(value)
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=_parse_datetime(row[2]) if len(row) > 2 else None,
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
- a_val(0.0 if cell == "" else float(cell))
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=int(row[1]),
1032
- end_interval=int(row[2]),
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 connection point to the grid)",
45
- "E": "Export (energy flows from the grid to the connection point)",
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 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.
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],