aemo-mdff-reader 2.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aemo_mdff_reader/__init__.py +84 -0
- aemo_mdff_reader/aggregate.py +168 -0
- aemo_mdff_reader/cli.py +231 -0
- aemo_mdff_reader/parser.py +1241 -0
- aemo_mdff_reader/py.typed +0 -0
- aemo_mdff_reader/reader.py +107 -0
- aemo_mdff_reader/spec.py +242 -0
- aemo_mdff_reader/sql/__init__.py +22 -0
- aemo_mdff_reader/sql/query.py +102 -0
- aemo_mdff_reader/sql/store.py +178 -0
- aemo_mdff_reader/types.py +437 -0
- aemo_mdff_reader-2.0.2.dist-info/METADATA +247 -0
- aemo_mdff_reader-2.0.2.dist-info/RECORD +17 -0
- aemo_mdff_reader-2.0.2.dist-info/WHEEL +5 -0
- aemo_mdff_reader-2.0.2.dist-info/entry_points.txt +2 -0
- aemo_mdff_reader-2.0.2.dist-info/licenses/LICENSE +21 -0
- aemo_mdff_reader-2.0.2.dist-info/top_level.txt +1 -0
|
File without changes
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Backward-compatible NEMReader facade.
|
|
2
|
+
|
|
3
|
+
The original :class:`NEMReader` class built an in-memory tree of records
|
|
4
|
+
and walked it twice (once to build, once to flatten). The new
|
|
5
|
+
implementation streams through :mod:`aemo_mdff_reader.parser` and only
|
|
6
|
+
materialises results when the user asks for them — typically to a
|
|
7
|
+
DataFrame or CSV.
|
|
8
|
+
|
|
9
|
+
The legacy method names are preserved so existing code continues to
|
|
10
|
+
work; new code should prefer :func:`aemo_mdff_reader.parser.parse` directly.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Iterable, Iterator, List, Optional, Sequence
|
|
16
|
+
|
|
17
|
+
from .parser import Columns, parse, to_columns, to_dataframe, write_csv
|
|
18
|
+
from .types import IntervalReading
|
|
19
|
+
|
|
20
|
+
# Kept for backward compatibility with the old INTERVAL_DATA_OUTPUT_HEADERS export.
|
|
21
|
+
INTERVAL_DATA_OUTPUT_HEADERS = [
|
|
22
|
+
"NMI",
|
|
23
|
+
"MeterSerial",
|
|
24
|
+
"Register",
|
|
25
|
+
"Date",
|
|
26
|
+
"Interval",
|
|
27
|
+
"IntervalLength",
|
|
28
|
+
"UOM",
|
|
29
|
+
"IntervalValue",
|
|
30
|
+
"Quality",
|
|
31
|
+
"UpdateDatetime",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class NEMReader:
|
|
36
|
+
"""Reads NEM12 / NEM13 files using the AEMO MDFF specification (v2.6).
|
|
37
|
+
|
|
38
|
+
See :mod:`aemo_mdff_reader.spec` for the spec version, effective date,
|
|
39
|
+
and the allowed-value enumerations referenced in the spec.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
INTERVAL_DATA_OUTPUT_HEADERS = INTERVAL_DATA_OUTPUT_HEADERS
|
|
43
|
+
|
|
44
|
+
def __init__(self) -> None:
|
|
45
|
+
self._readings: Optional[List[IntervalReading]] = None
|
|
46
|
+
self._source_filename: Optional[str] = None
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def filename(self) -> Optional[str]:
|
|
50
|
+
return self._source_filename
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def readings(self) -> List[IntervalReading]:
|
|
54
|
+
if self._readings is None:
|
|
55
|
+
raise RuntimeError("Call read_from_file() or read_from_array() first.")
|
|
56
|
+
return self._readings
|
|
57
|
+
|
|
58
|
+
def read_from_array(self, array: Iterable[Sequence[str]]) -> None:
|
|
59
|
+
"""Reads from an iterable of pre-split rows."""
|
|
60
|
+
self._readings = list(parse(array))
|
|
61
|
+
|
|
62
|
+
def read_from_file(self, filename: str) -> None:
|
|
63
|
+
"""Read a NEM12 CSV file from disk and buffer every interval.
|
|
64
|
+
|
|
65
|
+
Memory note: this materialises the full reading list, so peak
|
|
66
|
+
usage scales with the file. For files larger than a few hundred
|
|
67
|
+
MiB use :func:`aemo_mdff_reader.parse` (streaming, O(1) memory) or
|
|
68
|
+
:func:`aemo_mdff_reader.iter_dataframes` (bounded, O(chunk_size))
|
|
69
|
+
instead.
|
|
70
|
+
"""
|
|
71
|
+
self._source_filename = filename
|
|
72
|
+
self._readings = list(parse(filename))
|
|
73
|
+
|
|
74
|
+
def __iter__(self) -> Iterator[IntervalReading]:
|
|
75
|
+
"""Iterate over the buffered readings.
|
|
76
|
+
|
|
77
|
+
Lets callers write ``for r in reader: ...`` after
|
|
78
|
+
:meth:`read_from_file` or :meth:`read_from_array` instead of
|
|
79
|
+
going through ``reader.readings``.
|
|
80
|
+
"""
|
|
81
|
+
return iter(self.readings)
|
|
82
|
+
|
|
83
|
+
def __len__(self) -> int:
|
|
84
|
+
"""Number of buffered readings (after ``read_from_file``)."""
|
|
85
|
+
return len(self.readings)
|
|
86
|
+
|
|
87
|
+
def to_dataframe(self) -> Any:
|
|
88
|
+
"""Materialise readings into a pandas DataFrame.
|
|
89
|
+
|
|
90
|
+
Requires the ``pandas`` extra. When the buffered file path is
|
|
91
|
+
known, this re-parses via the columnar fast path for a 2–4×
|
|
92
|
+
speedup over iterating the in-memory readings list.
|
|
93
|
+
"""
|
|
94
|
+
if self._source_filename is not None:
|
|
95
|
+
return to_dataframe(self._source_filename)
|
|
96
|
+
return to_dataframe(self.readings)
|
|
97
|
+
|
|
98
|
+
def to_csv(self, filename: str) -> int:
|
|
99
|
+
"""Write readings as a flat CSV file. Returns the row count."""
|
|
100
|
+
return write_csv(self.readings, filename)
|
|
101
|
+
|
|
102
|
+
def to_columns(self) -> Columns:
|
|
103
|
+
"""Return readings as a dict of column lists (no pandas required)."""
|
|
104
|
+
return to_columns(self.readings)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
__all__ = ["INTERVAL_DATA_OUTPUT_HEADERS", "NEMReader"]
|
aemo_mdff_reader/spec.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Constants from the AEMO MDFF specification (NEM12 / NEM13).
|
|
2
|
+
|
|
3
|
+
This module mirrors the enumerations and allowed-value lists in the
|
|
4
|
+
AEMO Meter Data File Format Specification — NEM12 & NEM13, v2.6,
|
|
5
|
+
effective 29 September 2024. The spec is the source of truth; this
|
|
6
|
+
module is a convenience surface for callers who want to validate
|
|
7
|
+
parsed records against it.
|
|
8
|
+
|
|
9
|
+
The parser itself does not enforce these enumerations strictly — it
|
|
10
|
+
accepts any well-formed CSV — so downstream code is free to apply as
|
|
11
|
+
much or as little validation as it needs.
|
|
12
|
+
|
|
13
|
+
Reference (always points at the current AEMO publication):
|
|
14
|
+
https://www.aemo.com.au/energy-systems/electricity/national-electricity-market-nem/market-operations/retail-and-metering/metering-procedures-guidelines-and-processes
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Dict, FrozenSet
|
|
20
|
+
|
|
21
|
+
SPEC_VERSION = "2.6"
|
|
22
|
+
SPEC_EFFECTIVE_DATE = "2024-09-29"
|
|
23
|
+
SPEC_DOCUMENT_TITLE = "Meter Data File Format Specification NEM12 & NEM13"
|
|
24
|
+
SPEC_URL = (
|
|
25
|
+
"https://www.aemo.com.au/energy-systems/electricity/"
|
|
26
|
+
"national-electricity-market-nem/market-operations/retail-and-metering/"
|
|
27
|
+
"metering-procedures-guidelines-and-processes"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# -- Section 4.3, NMI data details (200) ------------------------------------
|
|
32
|
+
|
|
33
|
+
#: IntervalLength values permitted by the spec, in minutes.
|
|
34
|
+
#: The parser also accepts other divisors of 1440 (e.g. 1, 60) for tolerance
|
|
35
|
+
#: with non-conforming files; pass through this constant if you want to
|
|
36
|
+
#: reject anything outside the spec.
|
|
37
|
+
ALLOWED_INTERVAL_LENGTHS: FrozenSet[int] = frozenset({5, 15, 30})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# -- Section 5.3, Accumulation meter data (250) -----------------------------
|
|
41
|
+
|
|
42
|
+
#: DirectionIndicator allowed values per the spec.
|
|
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)",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# -- Appendix A, Transaction code flags (500 / 550) -------------------------
|
|
50
|
+
|
|
51
|
+
TRANSACTION_CODES: Dict[str, str] = {
|
|
52
|
+
"A": "Alteration",
|
|
53
|
+
"C": "Meter Reconfiguration",
|
|
54
|
+
"G": "Re-energisation",
|
|
55
|
+
"D": "De-energisation",
|
|
56
|
+
"E": "Estimate",
|
|
57
|
+
"N": "Normal Read",
|
|
58
|
+
"O": "Other (use when the original TransCode is unavailable, e.g. Historical Data)",
|
|
59
|
+
"S": "Special Read",
|
|
60
|
+
"R": "Removal of meter",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# -- Appendix B, allowed UOM values -----------------------------------------
|
|
65
|
+
|
|
66
|
+
#: Units of measure allowed in the UOM field. Comparison is case-insensitive
|
|
67
|
+
#: per the spec, so callers should normalise before lookup.
|
|
68
|
+
UOM_VALUES: FrozenSet[str] = frozenset(
|
|
69
|
+
{
|
|
70
|
+
"MWh",
|
|
71
|
+
"kWh",
|
|
72
|
+
"Wh",
|
|
73
|
+
"MVArh",
|
|
74
|
+
"kVArh",
|
|
75
|
+
"VArh",
|
|
76
|
+
"MVAr",
|
|
77
|
+
"kVAr",
|
|
78
|
+
"VAr",
|
|
79
|
+
"MW",
|
|
80
|
+
"kW",
|
|
81
|
+
"W",
|
|
82
|
+
"MVAh",
|
|
83
|
+
"kVAh",
|
|
84
|
+
"VAh",
|
|
85
|
+
"MVA",
|
|
86
|
+
"kVA",
|
|
87
|
+
"VA",
|
|
88
|
+
"kV",
|
|
89
|
+
"V",
|
|
90
|
+
"kA",
|
|
91
|
+
"A",
|
|
92
|
+
"pf",
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# -- Appendix C, Quality flags ----------------------------------------------
|
|
98
|
+
|
|
99
|
+
QUALITY_FLAGS: Dict[str, str] = {
|
|
100
|
+
"A": "Actual Metering Data (no method flag required)",
|
|
101
|
+
"E": "Forward estimated data (method flag required, no reason code)",
|
|
102
|
+
"F": "Final substituted data (method flag and reason code required)",
|
|
103
|
+
"S": "Substituted data (method flag and reason code required)",
|
|
104
|
+
"V": (
|
|
105
|
+
"Variable data — only valid as the QualityMethod of a 300 record "
|
|
106
|
+
"to indicate that 400 rows carry per-interval flags. Not valid in "
|
|
107
|
+
"NEM13 files."
|
|
108
|
+
),
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# -- Appendix E, current reason codes ---------------------------------------
|
|
113
|
+
|
|
114
|
+
REASON_CODES: Dict[int, str] = {
|
|
115
|
+
0: "Free text description",
|
|
116
|
+
1: "Meter/equipment changed",
|
|
117
|
+
2: "Extreme weather conditions",
|
|
118
|
+
3: "Quarantined premises",
|
|
119
|
+
5: "Blank screen",
|
|
120
|
+
6: "De-energised premises",
|
|
121
|
+
7: "Unable to locate meter",
|
|
122
|
+
8: "Vacant premises",
|
|
123
|
+
9: "Under investigation",
|
|
124
|
+
10: "Lock damaged unable to open",
|
|
125
|
+
11: "In wrong route",
|
|
126
|
+
12: "Locked premises",
|
|
127
|
+
13: "Locked gate",
|
|
128
|
+
14: "Locked meter box",
|
|
129
|
+
15: "Overgrown vegetation",
|
|
130
|
+
17: "Unsafe equipment/location",
|
|
131
|
+
18: "Read less than previous",
|
|
132
|
+
20: "Damaged equipment/panel",
|
|
133
|
+
21: "Main switch off",
|
|
134
|
+
22: "Meter/equipment seals missing",
|
|
135
|
+
23: "Reader error",
|
|
136
|
+
24: "Substituted/replaced data (data correction)",
|
|
137
|
+
25: "Unable to locate premises",
|
|
138
|
+
26: "Negative consumption (generation)",
|
|
139
|
+
27: "RoLR",
|
|
140
|
+
28: "CT/VT fault",
|
|
141
|
+
29: "Relay faulty/damaged",
|
|
142
|
+
31: "Not all meters read",
|
|
143
|
+
32: "Re-energised without readings",
|
|
144
|
+
33: "De-energised without readings",
|
|
145
|
+
34: "Meter not in handheld",
|
|
146
|
+
35: "Timeswitch faulty/reset required",
|
|
147
|
+
36: "Meter high/ladder required",
|
|
148
|
+
37: "Meter under churn",
|
|
149
|
+
38: "Unmarried lock",
|
|
150
|
+
39: "Reverse energy observed",
|
|
151
|
+
40: "Unrestrained livestock",
|
|
152
|
+
41: "Faulty Meter display/dials",
|
|
153
|
+
42: "Channel added/removed",
|
|
154
|
+
43: "Power outage",
|
|
155
|
+
44: "Meter testing",
|
|
156
|
+
45: "Readings failed to validate",
|
|
157
|
+
47: "Refused access",
|
|
158
|
+
48: "Dog on premises",
|
|
159
|
+
51: "Installation demolished",
|
|
160
|
+
52: "Access — blocked",
|
|
161
|
+
53: "Pests in meter box",
|
|
162
|
+
54: "Meter box damaged/faulty",
|
|
163
|
+
55: "Dials obscured",
|
|
164
|
+
60: "Illegal connection",
|
|
165
|
+
61: "Equipment tampered",
|
|
166
|
+
62: "NSRD window expired",
|
|
167
|
+
64: "Key required",
|
|
168
|
+
65: "Wrong key provided",
|
|
169
|
+
67: "Transfer",
|
|
170
|
+
68: "Zero consumption",
|
|
171
|
+
69: "Reading exceeds Substitute",
|
|
172
|
+
71: "Probe read error",
|
|
173
|
+
72: "Re-calculated based on Actual Metering Data",
|
|
174
|
+
73: "Low consumption",
|
|
175
|
+
74: "High consumption",
|
|
176
|
+
75: "Customer read",
|
|
177
|
+
76: "Communications fault",
|
|
178
|
+
77: "Estimation Forecast",
|
|
179
|
+
78: "Null Data",
|
|
180
|
+
79: "Power Outage Alarm",
|
|
181
|
+
80: "Short Interval Alarm",
|
|
182
|
+
81: "Long Interval Alarm",
|
|
183
|
+
87: "Reset occurred",
|
|
184
|
+
89: "Time reset occurred",
|
|
185
|
+
# Added in v2.6 (ICF_054 substitution type review, July 2023 REMP):
|
|
186
|
+
100: "Incorrect Meter Multiplier",
|
|
187
|
+
101: "Temporarily Connection Point unmetered",
|
|
188
|
+
102: "Customer By-Pass",
|
|
189
|
+
103: "Network By-Pass",
|
|
190
|
+
104: "Transposed Channel",
|
|
191
|
+
105: "Transposed Channel — UoM Correction",
|
|
192
|
+
106: "Transposed Channel — Reverse Polarity",
|
|
193
|
+
107: "Transposed Meter",
|
|
194
|
+
108: "Network by-pass extreme weather",
|
|
195
|
+
109: "Defined load method",
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# -- Appendix F, obsolete reason codes (Historical Data only) ---------------
|
|
200
|
+
|
|
201
|
+
OBSOLETE_REASON_CODES: Dict[int, str] = {
|
|
202
|
+
4: "Dangerous dog",
|
|
203
|
+
16: "Noxious weeds at premises preventing access",
|
|
204
|
+
19: "Consumer wanted",
|
|
205
|
+
30: "Meter stop switch on",
|
|
206
|
+
46: "Extreme weather/hot",
|
|
207
|
+
49: "Wet paint",
|
|
208
|
+
50: "Wrong tariff",
|
|
209
|
+
58: "Meter ok — supply failure",
|
|
210
|
+
70: "Probe reports tampering",
|
|
211
|
+
82: "CRC error",
|
|
212
|
+
83: "RAM checksum error",
|
|
213
|
+
84: "ROM checksum error",
|
|
214
|
+
85: "Data missing alarm",
|
|
215
|
+
86: "Clock error alarm",
|
|
216
|
+
88: "Watchdog timeout alarm",
|
|
217
|
+
90: "Test mode",
|
|
218
|
+
91: "Load control",
|
|
219
|
+
92: "Added interval (data correction)",
|
|
220
|
+
93: "Replaced interval (data correction)",
|
|
221
|
+
94: "Estimated interval (data correction)",
|
|
222
|
+
95: "Pulse overflow alarm",
|
|
223
|
+
96: "Data out of limits",
|
|
224
|
+
97: "Excluded data",
|
|
225
|
+
98: "Parity error",
|
|
226
|
+
99: "Energy type (register changed)",
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
__all__ = [
|
|
231
|
+
"ALLOWED_INTERVAL_LENGTHS",
|
|
232
|
+
"DIRECTION_INDICATORS",
|
|
233
|
+
"OBSOLETE_REASON_CODES",
|
|
234
|
+
"QUALITY_FLAGS",
|
|
235
|
+
"REASON_CODES",
|
|
236
|
+
"SPEC_DOCUMENT_TITLE",
|
|
237
|
+
"SPEC_EFFECTIVE_DATE",
|
|
238
|
+
"SPEC_URL",
|
|
239
|
+
"SPEC_VERSION",
|
|
240
|
+
"TRANSACTION_CODES",
|
|
241
|
+
"UOM_VALUES",
|
|
242
|
+
]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Optional SQL persistence layer.
|
|
2
|
+
|
|
3
|
+
Importing this subpackage does NOT eagerly import :mod:`pymysql`.
|
|
4
|
+
The dependency is only required when you actually instantiate
|
|
5
|
+
:class:`Storer`. Install with ``pip install aemo-mdff-reader[mysql]``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .query import COLUMNS, QueryBuilder
|
|
11
|
+
|
|
12
|
+
__all__ = ["COLUMNS", "DBCredentials", "QueryBuilder", "Storer"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def __getattr__(name):
|
|
16
|
+
# Lazy import so that ``import aemo_mdff_reader.sql`` does not require pymysql
|
|
17
|
+
# to be installed unless the caller actually uses the Storer.
|
|
18
|
+
if name in ("Storer", "DBCredentials"):
|
|
19
|
+
from .store import DBCredentials, Storer
|
|
20
|
+
|
|
21
|
+
return {"Storer": Storer, "DBCredentials": DBCredentials}[name]
|
|
22
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Contains classes and functions used for building queries in SQL"""
|
|
2
|
+
# import statements
|
|
3
|
+
|
|
4
|
+
IMPORT_TABLE = "imports"
|
|
5
|
+
NMI_TABLE = "nmi"
|
|
6
|
+
CHANNEL_TABLE = "channel"
|
|
7
|
+
INTERVAL_TYPE_TABLE = "interval_type"
|
|
8
|
+
INTERVAL_TABLE = "interval"
|
|
9
|
+
INTERVAL_DATA_TABLE = "interval_data"
|
|
10
|
+
INTERVAL_EVENT_TABLE = "interval_event"
|
|
11
|
+
INTERVAL_B2B_TABLE = "interval_b2b"
|
|
12
|
+
ACCUMULATION_DATA_TABLE = "accumulation_data"
|
|
13
|
+
ACCUMULATION_B2B_TABLE = "accumulation_b2b"
|
|
14
|
+
|
|
15
|
+
COLUMNS = {
|
|
16
|
+
IMPORT_TABLE: ["filename", "VersionHeader", "DateTime", "FromParticipant", "ToParticipant"],
|
|
17
|
+
NMI_TABLE: ["NMI", "NMIConfiguration"],
|
|
18
|
+
CHANNEL_TABLE: [
|
|
19
|
+
"NMI",
|
|
20
|
+
"RegisterID",
|
|
21
|
+
"NMISuffix",
|
|
22
|
+
"MDMDataStreamIdentifier",
|
|
23
|
+
"MeterSerialNumber",
|
|
24
|
+
],
|
|
25
|
+
INTERVAL_TYPE_TABLE: [
|
|
26
|
+
"UOM",
|
|
27
|
+
"IntervalLength",
|
|
28
|
+
"NextScheduledReadDate",
|
|
29
|
+
"channelID",
|
|
30
|
+
"importID",
|
|
31
|
+
],
|
|
32
|
+
INTERVAL_TABLE: [
|
|
33
|
+
"IntervalDate",
|
|
34
|
+
"QualityMethod",
|
|
35
|
+
"ReasonCode",
|
|
36
|
+
"ReasonDescription",
|
|
37
|
+
"UpdateDateTime",
|
|
38
|
+
"MSATSLoadDateTime",
|
|
39
|
+
"typeID",
|
|
40
|
+
],
|
|
41
|
+
INTERVAL_DATA_TABLE: ["valueID", "value", "intervalID"],
|
|
42
|
+
INTERVAL_EVENT_TABLE: [
|
|
43
|
+
"StartInterval",
|
|
44
|
+
"EndInterval",
|
|
45
|
+
"QualityMethod",
|
|
46
|
+
"ReasonCode",
|
|
47
|
+
"ReasonDescription",
|
|
48
|
+
"intervalID",
|
|
49
|
+
],
|
|
50
|
+
INTERVAL_B2B_TABLE: ["TransCode", "RetServiceOrder", "ReadDateTime", "IndexRead", "eventID"],
|
|
51
|
+
ACCUMULATION_DATA_TABLE: [],
|
|
52
|
+
ACCUMULATION_B2B_TABLE: [],
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class QueryBuilder:
|
|
57
|
+
"""
|
|
58
|
+
Builds a query from given information.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
COLUMN_GEN = 'NULLIF("%s", "")'
|
|
62
|
+
NUM_WRAPPER = "CAST(%s AS %s)"
|
|
63
|
+
DECIMAL_TYPE_1 = "DECIMAL(18, 3)"
|
|
64
|
+
DECIMAL_TYPE_2 = "DECIMAL(22, 5)"
|
|
65
|
+
INT_TYPE = "INT"
|
|
66
|
+
INSERT_QUERY = """INSERT INTO %s
|
|
67
|
+
(%s)
|
|
68
|
+
"""
|
|
69
|
+
VALUES_QUERY = """VALUES (%s)"""
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def insert_query(table, data):
|
|
73
|
+
"""
|
|
74
|
+
Insert query builder
|
|
75
|
+
"""
|
|
76
|
+
query = "INSERT IGNORE INTO `%s`\n" % table
|
|
77
|
+
query += "(" + ",".join(["`" + item + "`" for item in COLUMNS[table]]) + ")" + "\n"
|
|
78
|
+
query += "VALUES "
|
|
79
|
+
for instance in data:
|
|
80
|
+
list_value = [
|
|
81
|
+
(
|
|
82
|
+
QueryBuilder.COLUMN_GEN % value
|
|
83
|
+
if "@" not in value and "STR" not in value
|
|
84
|
+
else value
|
|
85
|
+
)
|
|
86
|
+
for value in instance
|
|
87
|
+
]
|
|
88
|
+
query += "("
|
|
89
|
+
for value in list_value:
|
|
90
|
+
query += value + ", "
|
|
91
|
+
query = query[:-2] + "),\n"
|
|
92
|
+
query = query[:-2] + ";"
|
|
93
|
+
return query
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def get_id_query():
|
|
97
|
+
"""
|
|
98
|
+
Most recent ID retriever
|
|
99
|
+
"""
|
|
100
|
+
query = "LAST_INSERT_ID();"
|
|
101
|
+
|
|
102
|
+
return query
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Storage for NEM12-13 within SQL Schema as specified."""
|
|
2
|
+
|
|
3
|
+
# import statements
|
|
4
|
+
|
|
5
|
+
import pymysql.cursors
|
|
6
|
+
|
|
7
|
+
from .query import *
|
|
8
|
+
|
|
9
|
+
# Load queries once session is imported
|
|
10
|
+
QUERY_FOLDER = "queries"
|
|
11
|
+
QUERIES = {}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Storer:
|
|
15
|
+
"""
|
|
16
|
+
Stores Reader into the DB
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, credentials):
|
|
20
|
+
if credentials is not None:
|
|
21
|
+
self.__connection = self.connect(**credentials)
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def connect(host, port, user, password, schema, directory):
|
|
25
|
+
"""
|
|
26
|
+
Connects to a MySQL database with the
|
|
27
|
+
given credentials.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
db_credentials (DBCredentials): The credentials
|
|
31
|
+
of the DB to connect to
|
|
32
|
+
Returns:
|
|
33
|
+
pymysql.connect. A connection to the DB
|
|
34
|
+
"""
|
|
35
|
+
# assert isinstance(db_credentials, DBCredentials)
|
|
36
|
+
# connect to server
|
|
37
|
+
connection = pymysql.connect(
|
|
38
|
+
host=host,
|
|
39
|
+
user=user,
|
|
40
|
+
password=password,
|
|
41
|
+
db=schema,
|
|
42
|
+
charset="utf8mb4",
|
|
43
|
+
autocommit=True,
|
|
44
|
+
cursorclass=pymysql.cursors.Cursor, # TODO: check if suitable cursor
|
|
45
|
+
)
|
|
46
|
+
return connection
|
|
47
|
+
|
|
48
|
+
def load_reader(self, reader):
|
|
49
|
+
"""
|
|
50
|
+
loads reader
|
|
51
|
+
"""
|
|
52
|
+
print(reader.filename)
|
|
53
|
+
with self.__connection.cursor() as cursor:
|
|
54
|
+
# first store header and filename
|
|
55
|
+
data = [
|
|
56
|
+
[
|
|
57
|
+
reader.filename.replace("\\", "/"),
|
|
58
|
+
reader.data.VersionHeader,
|
|
59
|
+
f'STR_TO_DATE("{reader.data.DateTime}", "%Y%m%d%k%i%s")',
|
|
60
|
+
reader.data.FromParticipant,
|
|
61
|
+
reader.data.ToParticipant,
|
|
62
|
+
]
|
|
63
|
+
]
|
|
64
|
+
query = QueryBuilder.insert_query(IMPORT_TABLE, data)
|
|
65
|
+
resultset = cursor.execute(query)
|
|
66
|
+
if resultset == 0:
|
|
67
|
+
return None
|
|
68
|
+
cursor.execute("SELECT importID FROM imports WHERE filename LIKE %s", (data[0][0]))
|
|
69
|
+
importID = str(cursor.fetchone()[0])
|
|
70
|
+
print(importID)
|
|
71
|
+
channelID = None
|
|
72
|
+
typeID = None
|
|
73
|
+
intervalID = None
|
|
74
|
+
# now store nmi, channels and interval type
|
|
75
|
+
data_nmi = []
|
|
76
|
+
data_channel = []
|
|
77
|
+
data_interval_type = []
|
|
78
|
+
data_intervals = []
|
|
79
|
+
for subrecord in reader.data.subrecords:
|
|
80
|
+
if isinstance(subrecord, list):
|
|
81
|
+
continue
|
|
82
|
+
if subrecord.RecordIndicator == "200":
|
|
83
|
+
data_interval = []
|
|
84
|
+
data_nmi.append([subrecord.NMI, subrecord.NMIConfiguration])
|
|
85
|
+
data_channel.append(
|
|
86
|
+
[
|
|
87
|
+
subrecord.NMI,
|
|
88
|
+
subrecord.RegisterID,
|
|
89
|
+
subrecord.NMISuffix,
|
|
90
|
+
subrecord.MDMDataStreamIdentifier,
|
|
91
|
+
subrecord.MeterSerialNumber,
|
|
92
|
+
]
|
|
93
|
+
)
|
|
94
|
+
data_interval_type.append(
|
|
95
|
+
[
|
|
96
|
+
subrecord.UOM,
|
|
97
|
+
subrecord.IntervalLength,
|
|
98
|
+
f'STR_TO_DATE("{subrecord.NextScheduledReadDate}", "%Y%m%d%k%i%s")',
|
|
99
|
+
channelID,
|
|
100
|
+
importID,
|
|
101
|
+
]
|
|
102
|
+
)
|
|
103
|
+
# add each interval
|
|
104
|
+
for subsubrecord in subrecord.subrecords:
|
|
105
|
+
if subsubrecord.RecordIndicator == "300":
|
|
106
|
+
interval_data = [
|
|
107
|
+
(
|
|
108
|
+
key[0].replace("IntervalValue", ""),
|
|
109
|
+
subsubrecord.__getattr__(key[0]),
|
|
110
|
+
intervalID,
|
|
111
|
+
)
|
|
112
|
+
for key in subsubrecord.intervals
|
|
113
|
+
]
|
|
114
|
+
data_interval.append(
|
|
115
|
+
[
|
|
116
|
+
[
|
|
117
|
+
f'STR_TO_DATE("{subsubrecord.IntervalDate}", "%Y%m%d")',
|
|
118
|
+
subsubrecord.QualityMethod,
|
|
119
|
+
subsubrecord.ReasonCode,
|
|
120
|
+
subsubrecord.ReasonDescription,
|
|
121
|
+
f'STR_TO_DATE("{subsubrecord.UpdateDateTime}", "%Y%m%d%k%i%s")',
|
|
122
|
+
f'STR_TO_DATE("{subsubrecord.MSATSLoadDateTime}", "%Y%m%d%k%i%s")',
|
|
123
|
+
typeID,
|
|
124
|
+
],
|
|
125
|
+
interval_data,
|
|
126
|
+
]
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
data_intervals.append(data_interval)
|
|
130
|
+
query = QueryBuilder.insert_query(NMI_TABLE, data_nmi)
|
|
131
|
+
resultset = cursor.execute(query)
|
|
132
|
+
|
|
133
|
+
for row in range(len(data_channel)):
|
|
134
|
+
query = QueryBuilder.insert_query(CHANNEL_TABLE, [data_channel[row]])
|
|
135
|
+
resultset = cursor.execute(query)
|
|
136
|
+
query = "SELECT channelID FROM `nem`.`channel` WHERE `NMI` = %s AND `NMISuffix` = %s AND `MeterSerialNumber` = %s"
|
|
137
|
+
cursor.execute(
|
|
138
|
+
query, (data_channel[row][0], data_channel[row][2], data_channel[row][4])
|
|
139
|
+
)
|
|
140
|
+
channelID = str(cursor.fetchone()[0])
|
|
141
|
+
data_interval_type[row][-2] = channelID
|
|
142
|
+
cursor.execute(
|
|
143
|
+
QueryBuilder.insert_query(INTERVAL_TYPE_TABLE, [data_interval_type[row]])
|
|
144
|
+
)
|
|
145
|
+
cursor.execute(
|
|
146
|
+
"""SELECT typeID FROM interval_type WHERE channelID = %s AND importID = %s""",
|
|
147
|
+
(channelID, importID),
|
|
148
|
+
)
|
|
149
|
+
typeID = str(cursor.fetchone()[0])
|
|
150
|
+
for interval_row in data_intervals[row]:
|
|
151
|
+
interval_row[0][-1] = typeID
|
|
152
|
+
query = QueryBuilder.insert_query(INTERVAL_TABLE, [interval_row[0]])
|
|
153
|
+
cursor.execute(query)
|
|
154
|
+
cursor.execute(
|
|
155
|
+
"SELECT intervalID FROM `interval` WHERE typeID = %s AND IntervalDate = %s"
|
|
156
|
+
% (typeID, interval_row[0][0])
|
|
157
|
+
)
|
|
158
|
+
intervalID = str(cursor.fetchone()[0])
|
|
159
|
+
interval_row[1] = [(val[0], val[1], intervalID) for val in interval_row[1]]
|
|
160
|
+
query = QueryBuilder.insert_query(INTERVAL_DATA_TABLE, list(interval_row[1]))
|
|
161
|
+
cursor.execute(query)
|
|
162
|
+
|
|
163
|
+
def close(self):
|
|
164
|
+
"""
|
|
165
|
+
Closes the current connection
|
|
166
|
+
"""
|
|
167
|
+
self.__connection.close()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class DBCredentials:
|
|
171
|
+
"""Credentials for SQL Database"""
|
|
172
|
+
|
|
173
|
+
def __init__(self, host, port, schema, username, password):
|
|
174
|
+
self.host = host
|
|
175
|
+
self.port = port
|
|
176
|
+
self.schema = schema
|
|
177
|
+
self.username = username
|
|
178
|
+
self.password = password
|