mdq-models 0.1.0__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.
- mdq_models/__init__.py +34 -0
- mdq_models/_base.py +137 -0
- mdq_models/_version.py +1 -0
- mdq_models/enums.py +71 -0
- mdq_models/malo.py +66 -0
- mdq_models/messwert.py +46 -0
- mdq_models/polars.py +235 -0
- mdq_models/py.typed +0 -0
- mdq_models/scalars.py +132 -0
- mdq_models/zaehlerstand.py +17 -0
- mdq_models-0.1.0.dist-info/METADATA +284 -0
- mdq_models-0.1.0.dist-info/RECORD +14 -0
- mdq_models-0.1.0.dist-info/WHEEL +4 -0
- mdq_models-0.1.0.dist-info/licenses/LICENSE +21 -0
mdq_models/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Hand-written data models of the canonical meter-reading schema.
|
|
2
|
+
|
|
3
|
+
The contract is the JSON form of these models, pinned by the shared fixtures in ``testdata/`` that
|
|
4
|
+
the Go and the Python models both round-trip. Nothing here is generated.
|
|
5
|
+
|
|
6
|
+
Read JSON with ``Messwert.model_validate_json`` (it rejects duplicate keys, which plain pydantic would
|
|
7
|
+
silently resolve) and write it with ``model_dump_json()``.
|
|
8
|
+
|
|
9
|
+
Limit: only ``model_validate_json`` of ``Messwert`` and ``Zaehlerstand`` rejects duplicate keys.
|
|
10
|
+
``TypeAdapter(...).validate_json``, a model that contains one of them and ``__pydantic_validator__`` go straight to
|
|
11
|
+
pydantic's parser and keep the last duplicate; call :func:`check_json` on the raw document first. All parse
|
|
12
|
+
failures are ``pydantic.ValidationError``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from mdq_models._base import check_json
|
|
16
|
+
from mdq_models._version import version as __version__
|
|
17
|
+
from mdq_models.enums import Herkunft, Profiltyp, Richtung, Status
|
|
18
|
+
from mdq_models.malo import herkunft_passt_zu_status, is_valid_malo_id, malo_check_digit
|
|
19
|
+
from mdq_models.messwert import Messwert
|
|
20
|
+
from mdq_models.zaehlerstand import Zaehlerstand
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Herkunft",
|
|
24
|
+
"Messwert",
|
|
25
|
+
"Profiltyp",
|
|
26
|
+
"Richtung",
|
|
27
|
+
"Status",
|
|
28
|
+
"Zaehlerstand",
|
|
29
|
+
"__version__",
|
|
30
|
+
"check_json",
|
|
31
|
+
"herkunft_passt_zu_status",
|
|
32
|
+
"is_valid_malo_id",
|
|
33
|
+
"malo_check_digit",
|
|
34
|
+
]
|
mdq_models/_base.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Base class of the models: strict reading (duplicate and unknown keys are rejected) and frozen instances."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any, NoReturn, Self
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, ValidationError
|
|
8
|
+
from pydantic_core import InitErrorDetails, PydanticCustomError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _DuplicateKeyError(ValueError):
|
|
12
|
+
"""A JSON object repeats a key (internal: it is turned into a ValidationError)."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _reject_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
16
|
+
seen: set[str] = set()
|
|
17
|
+
for key, _ in pairs:
|
|
18
|
+
if key in seen:
|
|
19
|
+
raise _DuplicateKeyError(f"duplicate key {key!r}")
|
|
20
|
+
seen.add(key)
|
|
21
|
+
return dict(pairs)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _reject_constant(name: str) -> NoReturn:
|
|
25
|
+
# json.loads accepts NaN and Infinity by default; they are not JSON.
|
|
26
|
+
raise ValueError(f"{name} is not valid JSON")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
MAX_DEPTH = 100
|
|
30
|
+
"""The deepest nesting of arrays and objects a document may have. The schema is flat; the limit exists so that the
|
|
31
|
+
answer to a hostile document does not depend on the interpreter's recursion limit, which differs between Python
|
|
32
|
+
versions (3.13 and later report a stack overflow, 3.11 a recursion error) and between builds."""
|
|
33
|
+
|
|
34
|
+
# A string (so brackets inside it are skipped, including escaped quotes) or a bracket.
|
|
35
|
+
_NESTING = re.compile(r'"(?:[^"\\]|\\.)*"|[\[\]{}]', re.DOTALL)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _too_deep(text: str) -> bool:
|
|
39
|
+
depth = 0
|
|
40
|
+
for match in _NESTING.finditer(text):
|
|
41
|
+
token = match.group()
|
|
42
|
+
if token in "[{":
|
|
43
|
+
depth += 1
|
|
44
|
+
if depth > MAX_DEPTH:
|
|
45
|
+
return True
|
|
46
|
+
elif token in "]}":
|
|
47
|
+
depth -= 1
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _fail(title: str, code: str, message: str) -> NoReturn:
|
|
52
|
+
"""Raise a ``ValidationError``, the one error type callers (for example web frameworks) already handle.
|
|
53
|
+
|
|
54
|
+
The input is left out of the error: a document can be megabytes long.
|
|
55
|
+
"""
|
|
56
|
+
detail = InitErrorDetails(type=PydanticCustomError(code, message), loc=(), input=None)
|
|
57
|
+
raise ValidationError.from_exception_data(title, [detail])
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _read(data: str | bytes | bytearray, title: str) -> str:
|
|
61
|
+
"""Decode ``data`` as UTF-8 without BOM and check it is JSON without repeated keys; return the text.
|
|
62
|
+
|
|
63
|
+
RFC 8259 says JSON is UTF-8 and that parsers must not add a BOM. ``json.loads`` would silently accept
|
|
64
|
+
UTF-16 and UTF-32 and skip a BOM, so a document the Go models reject would pass here; decode strictly instead.
|
|
65
|
+
"""
|
|
66
|
+
if isinstance(data, str):
|
|
67
|
+
text = data
|
|
68
|
+
else:
|
|
69
|
+
try:
|
|
70
|
+
text = bytes(data).decode("utf-8")
|
|
71
|
+
except UnicodeDecodeError as exc:
|
|
72
|
+
_fail(title, "not_utf8", f"the document is not UTF-8: {exc.reason}")
|
|
73
|
+
if text.startswith("\ufeff"):
|
|
74
|
+
_fail(title, "byte_order_mark", "a byte order mark is not allowed")
|
|
75
|
+
if _too_deep(text):
|
|
76
|
+
_fail(title, "too_deep", f"the document is nested more than {MAX_DEPTH} levels deep")
|
|
77
|
+
try:
|
|
78
|
+
json.loads(
|
|
79
|
+
text,
|
|
80
|
+
object_pairs_hook=_reject_duplicates,
|
|
81
|
+
parse_constant=_reject_constant,
|
|
82
|
+
# Numbers only matter to the duplicate-key scan: keep their text, so 1e1000 does not become inf and a
|
|
83
|
+
# very long integer does not hit Python's int-digit limit. The model then rejects them as non-strings.
|
|
84
|
+
parse_float=str,
|
|
85
|
+
parse_int=str,
|
|
86
|
+
)
|
|
87
|
+
except _DuplicateKeyError as exc:
|
|
88
|
+
_fail(title, "duplicate_key", str(exc))
|
|
89
|
+
except RecursionError:
|
|
90
|
+
# Fallback: the depth check above should make this unreachable.
|
|
91
|
+
_fail(title, "too_deep", "the document is nested too deeply")
|
|
92
|
+
except ValueError as exc: # JSONDecodeError and the NaN/Infinity constants
|
|
93
|
+
_fail(title, "json_invalid", f"the document is not valid JSON: {exc}")
|
|
94
|
+
return text
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def check_json(data: str | bytes | bytearray) -> None:
|
|
98
|
+
"""Check that ``data`` is UTF-8 JSON (no BOM) without a repeated key at any nesting level.
|
|
99
|
+
|
|
100
|
+
``Messwert.model_validate_json`` and ``Zaehlerstand.model_validate_json`` call this themselves. Call it
|
|
101
|
+
first when you validate in another way (``TypeAdapter(...).validate_json``, a model that contains a
|
|
102
|
+
``Messwert``, ``__pydantic_validator__``): those paths go straight to pydantic's parser, which keeps the
|
|
103
|
+
last of two equal keys without a word. Other problems (wrong types, unknown keys) are left to validation.
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
pydantic.ValidationError: with the error type ``not_utf8``, ``byte_order_mark``, ``json_invalid``,
|
|
107
|
+
``duplicate_key`` or ``too_deep``.
|
|
108
|
+
"""
|
|
109
|
+
_read(data, "check_json")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Model(BaseModel):
|
|
113
|
+
"""Frozen, strict model.
|
|
114
|
+
|
|
115
|
+
``extra="forbid"``: a typo must fail loudly, and a new field means all consumers update together.
|
|
116
|
+
|
|
117
|
+
Limits, as with Go's ``json.Unmarshal`` (which a wrapper cannot guard either): only
|
|
118
|
+
:meth:`model_validate_json` of a model class rejects duplicate keys. Other entry points do not; run
|
|
119
|
+
:func:`mdq_models.check_json` first. ``model_copy(update=...)`` does not validate (pydantic's default),
|
|
120
|
+
so build a new instance with ``model_validate`` when the rules matter.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
model_config = ConfigDict(frozen=True, extra="forbid", strict=True)
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def model_validate_json(cls, json_data: str | bytes | bytearray, **kwargs: Any) -> Self:
|
|
127
|
+
"""Like :meth:`pydantic.BaseModel.model_validate_json`, but a repeated key is an error.
|
|
128
|
+
|
|
129
|
+
Pydantic keeps the last of two equal keys without a word. The contract rejects them (a duplicate is
|
|
130
|
+
always a bug in the producer), so the document is read once with a hook that raises on a repeated key,
|
|
131
|
+
and only then handed to pydantic.
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
pydantic.ValidationError: for every problem, including invalid JSON and duplicate keys.
|
|
135
|
+
"""
|
|
136
|
+
text = _read(json_data, cls.__name__)
|
|
137
|
+
return super().model_validate_json(text, **kwargs)
|
mdq_models/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
version = "0.1.0"
|
mdq_models/enums.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""The four enumerations of the schema. Values are the JSON strings, lower case and case-sensitive."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from typing import Any, TypeVar
|
|
6
|
+
|
|
7
|
+
_E = TypeVar("_E", bound=StrEnum)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def enum_from_str(enum: type[_E]) -> Callable[[Any], _E]:
|
|
11
|
+
"""Before-validator for an enum field: turn a ``str`` into the member; refuse everything else.
|
|
12
|
+
|
|
13
|
+
The models are strict (``strict=True``), and strict mode refuses the plain string ``"wahr"`` for an enum field
|
|
14
|
+
when validating a Python ``dict`` such as ``json.loads`` output. Converting here keeps that input working,
|
|
15
|
+
while ``bytes`` and numbers (which lax enum conversion would accept) stay rejected. Matching is exact:
|
|
16
|
+
``"Wahr"`` is not ``"wahr"``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def convert(value: Any) -> _E:
|
|
20
|
+
if not isinstance(value, str):
|
|
21
|
+
raise ValueError(f"must be a string, not {type(value).__name__}")
|
|
22
|
+
try:
|
|
23
|
+
return enum(value)
|
|
24
|
+
except ValueError:
|
|
25
|
+
raise ValueError(f"{value!r} is not one of {[member.value for member in enum]}") from None
|
|
26
|
+
|
|
27
|
+
return convert
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Status(StrEnum):
|
|
31
|
+
"""Quality of a measured value."""
|
|
32
|
+
|
|
33
|
+
WAHR = "wahr"
|
|
34
|
+
"""Confirmed value; its herkunft is always ``fremd``."""
|
|
35
|
+
VORLAEUFIG = "vorlaeufig"
|
|
36
|
+
"""Provisional value, may still be replaced."""
|
|
37
|
+
ERSATZWERT = "ersatzwert"
|
|
38
|
+
"""Substitute value that was calculated instead of measured."""
|
|
39
|
+
FEHLEND = "fehlend"
|
|
40
|
+
"""No value; ``value`` and ``herkunft`` are ``None``."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Herkunft(StrEnum):
|
|
44
|
+
"""Where a value comes from."""
|
|
45
|
+
|
|
46
|
+
FREMD = "fremd"
|
|
47
|
+
"""Not formed by the meter data quality agent, e.g. delivered from the source system."""
|
|
48
|
+
MQA = "mqa"
|
|
49
|
+
"""Formed by the meter data quality agent (MQA, "Messdatenqualitätsagent") in an earlier run."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Profiltyp(StrEnum):
|
|
53
|
+
"""Load profile type of the market location."""
|
|
54
|
+
|
|
55
|
+
HAUSHALT = "haushalt"
|
|
56
|
+
"""Household."""
|
|
57
|
+
GEWERBE = "gewerbe"
|
|
58
|
+
"""Commerce."""
|
|
59
|
+
INDUSTRIE = "industrie"
|
|
60
|
+
"""Industry."""
|
|
61
|
+
UNBEKANNT = "unbekannt"
|
|
62
|
+
"""Unknown."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Richtung(StrEnum):
|
|
66
|
+
"""Energy direction."""
|
|
67
|
+
|
|
68
|
+
BEZUG = "bezug"
|
|
69
|
+
"""Consumption (energy taken from the grid)."""
|
|
70
|
+
EINSPEISUNG = "einspeisung"
|
|
71
|
+
"""Feed-in (energy delivered to the grid)."""
|
mdq_models/malo.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""MaLo ids and the check digit (BDEW algorithm), plus the herkunft/status rule."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
from pydantic import AfterValidator, StrictStr
|
|
7
|
+
|
|
8
|
+
from mdq_models.enums import Herkunft, Status
|
|
9
|
+
|
|
10
|
+
# re.ASCII: without it \d and friends would accept other scripts' digits; fullmatch: `$` would match before a
|
|
11
|
+
# trailing newline. Go uses the same expression.
|
|
12
|
+
_MALO = re.compile(r"[1-9][0-9]{10}", re.ASCII)
|
|
13
|
+
_TEN_DIGITS = re.compile(r"[0-9]{10}", re.ASCII)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def malo_check_digit(first_ten: str) -> int:
|
|
17
|
+
"""Check digit for the first ten digits of a MaLo id.
|
|
18
|
+
|
|
19
|
+
The digits at odd positions (1st, 3rd, ...) count once, those at even positions twice;
|
|
20
|
+
the check digit is ``(10 - total % 10) % 10``.
|
|
21
|
+
|
|
22
|
+
Raises:
|
|
23
|
+
ValueError: ``first_ten`` is not exactly ten ASCII digits (a leading 0 is fine here; the id rule in
|
|
24
|
+
:func:`is_valid_malo_id` is stricter).
|
|
25
|
+
"""
|
|
26
|
+
if not _TEN_DIGITS.fullmatch(first_ten):
|
|
27
|
+
raise ValueError(f"{first_ten!r} is not ten ASCII digits")
|
|
28
|
+
total = sum(int(digit) * (2 if index % 2 else 1) for index, digit in enumerate(first_ten))
|
|
29
|
+
return (10 - total % 10) % 10
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_valid_malo_id(value: str) -> bool:
|
|
33
|
+
"""Whether ``value`` is 11 ASCII digits, does not start with 0 and ends in the correct check digit."""
|
|
34
|
+
return _MALO.fullmatch(value) is not None and malo_check_digit(value[:10]) == int(value[10])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _check_malo(value: str) -> str:
|
|
38
|
+
if not _MALO.fullmatch(value):
|
|
39
|
+
raise ValueError(f"{value!r} is not 11 ASCII digits without a leading 0")
|
|
40
|
+
if not is_valid_malo_id(value):
|
|
41
|
+
raise ValueError(f"{value!r} has a wrong check digit")
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
MaLo = Annotated[StrictStr, AfterValidator(_check_malo)]
|
|
46
|
+
"""A market location id. Strict string: a JSON number is rejected rather than converted."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def herkunft_passt_zu_status(status: Status | str, herkunft: Herkunft | str | None) -> bool:
|
|
50
|
+
"""Whether the combination is allowed.
|
|
51
|
+
|
|
52
|
+
``fehlend`` has no herkunft, ``wahr`` needs ``fremd``, ``vorlaeufig`` and ``ersatzwert`` may have either.
|
|
53
|
+
Only ``None`` means "no herkunft"; the empty string is an unknown value, not "unset" (Go takes a pointer
|
|
54
|
+
for the same reason). An unknown status or herkunft, including a wrong case (``"Wahr"``), is never allowed:
|
|
55
|
+
a rule that says "anything else goes" would let a typo through.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
known_status = Status(status)
|
|
59
|
+
known_herkunft = None if herkunft is None else Herkunft(herkunft)
|
|
60
|
+
except ValueError:
|
|
61
|
+
return False
|
|
62
|
+
if known_status == Status.FEHLEND:
|
|
63
|
+
return known_herkunft is None
|
|
64
|
+
if known_herkunft is None:
|
|
65
|
+
return False
|
|
66
|
+
return known_status != Status.WAHR or known_herkunft == Herkunft.FREMD
|
mdq_models/messwert.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""One row of the canonical schema: a quarter-hour meter value."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Literal, Self
|
|
4
|
+
|
|
5
|
+
from pydantic import BeforeValidator, Field, model_validator
|
|
6
|
+
|
|
7
|
+
from mdq_models._base import Model
|
|
8
|
+
from mdq_models.enums import Herkunft, Profiltyp, Richtung, Status, enum_from_str
|
|
9
|
+
from mdq_models.malo import MaLo, herkunft_passt_zu_status
|
|
10
|
+
from mdq_models.scalars import CanonicalDecimal, QuarterHourStart
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Messwert(Model):
|
|
14
|
+
"""A meter value of one market location for one quarter hour.
|
|
15
|
+
|
|
16
|
+
Field order is the key order of the JSON output (the Go models write the same order). All eight keys are
|
|
17
|
+
required; ``value`` and ``herkunft`` are required but nullable, and always written (``null`` when unset).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
malo: MaLo = Field(description="Market location id: 11 ASCII digits, no leading 0, valid check digit.")
|
|
21
|
+
timestamp: QuarterHourStart = Field(
|
|
22
|
+
description="Start of the quarter hour, UTC (00:00 is the first of a day, 23:45 the last). JSON uses 'Z'."
|
|
23
|
+
)
|
|
24
|
+
resolution: Literal["PT15M"] = Field(description="Raster of the series; only PT15M in v1.")
|
|
25
|
+
value: CanonicalDecimal | None = Field(
|
|
26
|
+
description="Energy value with at most 6 decimal places, written as a canonical string; None iff fehlend."
|
|
27
|
+
)
|
|
28
|
+
status: Annotated[Status, BeforeValidator(enum_from_str(Status))] = Field(description="Quality of the value.")
|
|
29
|
+
herkunft: Annotated[Herkunft, BeforeValidator(enum_from_str(Herkunft))] | None = Field(
|
|
30
|
+
description="Origin of the value; None iff fehlend; wahr requires fremd."
|
|
31
|
+
)
|
|
32
|
+
profiltyp: Annotated[Profiltyp, BeforeValidator(enum_from_str(Profiltyp))] = Field(
|
|
33
|
+
description="Load profile type of the market location."
|
|
34
|
+
)
|
|
35
|
+
richtung: Annotated[Richtung, BeforeValidator(enum_from_str(Richtung))] = Field(description="Energy direction.")
|
|
36
|
+
|
|
37
|
+
@model_validator(mode="after")
|
|
38
|
+
def _check_missing_and_herkunft(self) -> Self:
|
|
39
|
+
# `fehlend` means there is nothing to report, so a value or an origin would contradict it;
|
|
40
|
+
# every other status needs both, or a consumer could not tell a missing value from a zero.
|
|
41
|
+
missing = self.status == Status.FEHLEND
|
|
42
|
+
if (self.value is None) != missing:
|
|
43
|
+
raise ValueError("value must be None if and only if status is fehlend")
|
|
44
|
+
if not herkunft_passt_zu_status(self.status, self.herkunft):
|
|
45
|
+
raise ValueError(f"herkunft {self.herkunft!r} does not fit status {self.status.value!r}")
|
|
46
|
+
return self
|
mdq_models/polars.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Polars table schema of the canonical meter-reading schema (extra ``polars``).
|
|
2
|
+
|
|
3
|
+
The table form is what Python consumers compute with: seven columns in fixed order, **without** ``resolution``
|
|
4
|
+
(consumers only know the 15-minute raster; the column is added when another raster is allowed). Install it with
|
|
5
|
+
``pip install mdq-models[polars]``. ``mdq_models`` itself never imports polars.
|
|
6
|
+
|
|
7
|
+
The names below cover everything a consumer needs from one place: column names, dtypes, the enum value tuples, the
|
|
8
|
+
enums and the MaLo helpers (re-exported from :mod:`mdq_models`).
|
|
9
|
+
|
|
10
|
+
Timestamps are ``Europe/Berlin`` in the table on purpose: consumers do local calendar logic (weekdays, days).
|
|
11
|
+
UTC is only the wire format of :class:`~mdq_models.Messwert`; :func:`to_frame` and :func:`from_frame` convert.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import math
|
|
17
|
+
from collections.abc import Iterable
|
|
18
|
+
from decimal import ROUND_HALF_EVEN, Context, Decimal, InvalidOperation, localcontext
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
import polars as pl
|
|
23
|
+
except ImportError as exc:
|
|
24
|
+
raise ImportError(
|
|
25
|
+
"mdq_models.polars needs the optional dependency polars; install it with: pip install 'mdq-models[polars]'"
|
|
26
|
+
) from exc
|
|
27
|
+
|
|
28
|
+
from mdq_models.enums import Herkunft, Profiltyp, Richtung, Status
|
|
29
|
+
from mdq_models.malo import herkunft_passt_zu_status, is_valid_malo_id, malo_check_digit
|
|
30
|
+
from mdq_models.messwert import Messwert
|
|
31
|
+
from mdq_models.scalars import canonical_decimal
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"BERLIN_TZ",
|
|
35
|
+
"CANONICAL_COLUMNS",
|
|
36
|
+
"CANONICAL_DTYPES",
|
|
37
|
+
"COL_HERKUNFT",
|
|
38
|
+
"COL_MALO",
|
|
39
|
+
"COL_PROFILTYP",
|
|
40
|
+
"COL_RICHTUNG",
|
|
41
|
+
"COL_STATUS",
|
|
42
|
+
"COL_TIMESTAMP",
|
|
43
|
+
"COL_VALUE",
|
|
44
|
+
"HERKUNFT_VALUES",
|
|
45
|
+
"MALO_ID_LENGTH",
|
|
46
|
+
"MAX_FLOAT_VALUE",
|
|
47
|
+
"PROFILTYP_VALUES",
|
|
48
|
+
"RESOLUTION_MINUTES",
|
|
49
|
+
"RICHTUNG_VALUES",
|
|
50
|
+
"STATUS_VALUES",
|
|
51
|
+
"TIMESTAMP_DTYPE",
|
|
52
|
+
"Herkunft",
|
|
53
|
+
"Profiltyp",
|
|
54
|
+
"Richtung",
|
|
55
|
+
"Status",
|
|
56
|
+
"from_frame",
|
|
57
|
+
"herkunft_passt_zu_status",
|
|
58
|
+
"is_valid_malo_id",
|
|
59
|
+
"malo_check_digit",
|
|
60
|
+
"to_frame",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
BERLIN_TZ = "Europe/Berlin"
|
|
64
|
+
"""Time zone of all timestamps in the table."""
|
|
65
|
+
RESOLUTION_MINUTES = 15
|
|
66
|
+
"""Raster of the table in minutes. There is only this raster, which is why the table has no ``resolution`` column."""
|
|
67
|
+
MALO_ID_LENGTH = 11
|
|
68
|
+
"""Length of a MaLo id including the check digit."""
|
|
69
|
+
|
|
70
|
+
COL_MALO = "malo"
|
|
71
|
+
COL_TIMESTAMP = "timestamp"
|
|
72
|
+
COL_VALUE = "value"
|
|
73
|
+
COL_STATUS = "status"
|
|
74
|
+
COL_HERKUNFT = "herkunft"
|
|
75
|
+
COL_PROFILTYP = "profiltyp"
|
|
76
|
+
COL_RICHTUNG = "richtung"
|
|
77
|
+
|
|
78
|
+
CANONICAL_COLUMNS: tuple[str, ...] = (
|
|
79
|
+
COL_MALO,
|
|
80
|
+
COL_TIMESTAMP,
|
|
81
|
+
COL_VALUE,
|
|
82
|
+
COL_STATUS,
|
|
83
|
+
COL_HERKUNFT,
|
|
84
|
+
COL_PROFILTYP,
|
|
85
|
+
COL_RICHTUNG,
|
|
86
|
+
)
|
|
87
|
+
"""Column names in the canonical order. ``resolution`` is left out on purpose (see the module docstring)."""
|
|
88
|
+
|
|
89
|
+
TIMESTAMP_DTYPE = pl.Datetime("us", BERLIN_TZ)
|
|
90
|
+
"""Dtype of the timestamp column: time zone aware, microseconds, local time of Germany."""
|
|
91
|
+
|
|
92
|
+
CANONICAL_DTYPES: dict[str, pl.DataType | type[pl.DataType]] = {
|
|
93
|
+
COL_MALO: pl.String,
|
|
94
|
+
COL_TIMESTAMP: TIMESTAMP_DTYPE,
|
|
95
|
+
COL_VALUE: pl.Float64,
|
|
96
|
+
COL_STATUS: pl.String,
|
|
97
|
+
COL_HERKUNFT: pl.String,
|
|
98
|
+
COL_PROFILTYP: pl.String,
|
|
99
|
+
COL_RICHTUNG: pl.String,
|
|
100
|
+
}
|
|
101
|
+
"""Dtype per column. ``value`` is Float64 (missing = null); everything but the timestamp is a string."""
|
|
102
|
+
|
|
103
|
+
STATUS_VALUES: tuple[str, ...] = tuple(member.value for member in Status)
|
|
104
|
+
HERKUNFT_VALUES: tuple[str, ...] = tuple(member.value for member in Herkunft)
|
|
105
|
+
PROFILTYP_VALUES: tuple[str, ...] = tuple(member.value for member in Profiltyp)
|
|
106
|
+
RICHTUNG_VALUES: tuple[str, ...] = tuple(member.value for member in Richtung)
|
|
107
|
+
|
|
108
|
+
MAX_FLOAT_VALUE = Decimal(10**9)
|
|
109
|
+
"""``to_frame`` accepts only ``|value| < MAX_FLOAT_VALUE``. Why: Float64 keeps a decimal exactly only while it has at
|
|
110
|
+
most 15 significant digits; with at most 6 decimals, anything below 10^9 has at most 15."""
|
|
111
|
+
|
|
112
|
+
_SIX_PLACES = Decimal("0.000001")
|
|
113
|
+
_FLOAT_BOUND = 10.0**9
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def to_frame(rows: Iterable[Messwert]) -> pl.DataFrame:
|
|
117
|
+
"""Convert ``Messwert`` rows into the table form (seven columns, canonical dtypes).
|
|
118
|
+
|
|
119
|
+
The timestamp is converted from UTC to ``Europe/Berlin`` and ``resolution`` is dropped (it is always PT15M).
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
ValueError: if a value has ``|value| >= 10^9``. Float64 could not hold it exactly, and nothing is rounded
|
|
123
|
+
silently.
|
|
124
|
+
"""
|
|
125
|
+
rows = list(rows) # an iterator would be used up by the check below and leave the table empty
|
|
126
|
+
for row in rows:
|
|
127
|
+
if row.value is not None and row.value.copy_abs() >= MAX_FLOAT_VALUE:
|
|
128
|
+
raise ValueError(f"{row.malo} {row.timestamp}: |value| {row.value} is not below 10^9, Float64 is inexact")
|
|
129
|
+
data: dict[str, Any] = {
|
|
130
|
+
COL_MALO: [row.malo for row in rows],
|
|
131
|
+
COL_TIMESTAMP: pl.Series(COL_TIMESTAMP, [row.timestamp for row in rows], dtype=pl.Datetime("us", "UTC")),
|
|
132
|
+
COL_VALUE: [None if row.value is None else float(row.value) for row in rows],
|
|
133
|
+
COL_STATUS: [row.status.value for row in rows],
|
|
134
|
+
COL_HERKUNFT: [None if row.herkunft is None else row.herkunft.value for row in rows],
|
|
135
|
+
COL_PROFILTYP: [row.profiltyp.value for row in rows],
|
|
136
|
+
COL_RICHTUNG: [row.richtung.value for row in rows],
|
|
137
|
+
}
|
|
138
|
+
frame = pl.DataFrame(data, schema_overrides=CANONICAL_DTYPES, schema=list(CANONICAL_COLUMNS))
|
|
139
|
+
return frame.with_columns(pl.col(COL_TIMESTAMP).dt.convert_time_zone(BERLIN_TZ))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _float_to_decimal(number: float) -> Decimal:
|
|
143
|
+
"""Convert a float to a canonical ``Decimal``; ``|number| >= 10^9`` (and inf) is refused.
|
|
144
|
+
|
|
145
|
+
The bound is the same as in :func:`to_frame`: above it a Float64 cannot be trusted to 6 decimals, so reading it
|
|
146
|
+
back would invent digits.
|
|
147
|
+
|
|
148
|
+
Shortest representation first, not ``Decimal(number)``: that shows the binary expansion
|
|
149
|
+
(``1234567890123.45`` would become ``1234567890123.449951``). Below 10^9 the shortest representation has at most
|
|
150
|
+
6 decimals, so the half-even rounding never decides a tie; it only cleans up values from other producers.
|
|
151
|
+
"""
|
|
152
|
+
if not math.isfinite(number):
|
|
153
|
+
raise ValueError(f"{number!r} is not a finite number")
|
|
154
|
+
if abs(number) >= _FLOAT_BOUND: # a float comparison: no decimal context involved
|
|
155
|
+
raise ValueError(f"|value| {number!r} is not below 10^9, a Float64 cannot be trusted to 6 decimals")
|
|
156
|
+
# An explicit context, so the result does not depend on the caller's precision or traps (a precision of 5 would
|
|
157
|
+
# round 999999999.999999 to 1.0000E+9). 40 digits are plenty: below 10^9 the number has at most 9 + 6 digits.
|
|
158
|
+
with localcontext(Context(prec=40, rounding=ROUND_HALF_EVEN, traps=[InvalidOperation])):
|
|
159
|
+
quantized = Decimal(repr(number)).quantize(_SIX_PLACES, rounding=ROUND_HALF_EVEN)
|
|
160
|
+
return Decimal(canonical_decimal(quantized))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _check_schema(df: pl.DataFrame) -> None:
|
|
164
|
+
"""Strict like the JSON contract: no unknown columns, exactly the canonical dtypes (``Null`` allowed if all null).
|
|
165
|
+
|
|
166
|
+
``cast`` would turn a ``Decimal(20, 6)`` into a float (losing digits), a string ``"1.5"`` into 1.5 and a boolean
|
|
167
|
+
into 1.0 without a word, so the dtypes are compared instead. The timestamp may be in any time zone, but must be
|
|
168
|
+
time zone aware with microsecond precision.
|
|
169
|
+
"""
|
|
170
|
+
missing = [name for name in CANONICAL_COLUMNS if name not in df.columns]
|
|
171
|
+
if missing:
|
|
172
|
+
raise ValueError(f"missing columns: {missing}")
|
|
173
|
+
extra = [name for name in df.columns if name not in CANONICAL_DTYPES]
|
|
174
|
+
if extra:
|
|
175
|
+
raise ValueError(
|
|
176
|
+
f"unexpected columns: {extra}; the table form has exactly {list(CANONICAL_COLUMNS)} "
|
|
177
|
+
"(no resolution column, it is always PT15M)"
|
|
178
|
+
)
|
|
179
|
+
for name, actual in df.schema.items():
|
|
180
|
+
expected = CANONICAL_DTYPES[name]
|
|
181
|
+
if actual == pl.Null:
|
|
182
|
+
continue
|
|
183
|
+
if name == COL_TIMESTAMP:
|
|
184
|
+
ok = isinstance(actual, pl.Datetime) and actual.time_zone is not None and actual.time_unit == "us"
|
|
185
|
+
expected = pl.Datetime("us", "<any time zone>")
|
|
186
|
+
else:
|
|
187
|
+
ok = actual == expected
|
|
188
|
+
if not ok:
|
|
189
|
+
raise ValueError(f"column {name!r} has dtype {actual}, expected {expected}")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def from_frame(df: pl.DataFrame) -> list[Messwert]:
|
|
193
|
+
"""Convert a table in the canonical form back into validated ``Messwert`` rows.
|
|
194
|
+
|
|
195
|
+
The frame must be a ``DataFrame`` (not a ``LazyFrame``: ``collect()`` it first), have exactly the seven canonical
|
|
196
|
+
columns with the canonical dtypes (the timestamp may be in any time zone; it is converted to UTC). Extra columns
|
|
197
|
+
are rejected, a ``resolution`` column too: the table has no resolution, it is always PT15M and is set here. Why
|
|
198
|
+
strict: like the JSON contract, which rejects unknown keys, a typo or a new field must fail loudly.
|
|
199
|
+
|
|
200
|
+
A float ``value`` is converted through its shortest representation and quantized to 6 decimals (half-even). The
|
|
201
|
+
bound of :func:`to_frame` applies here too: ``|value| >= 10^9`` raises, because a Float64 that large cannot be
|
|
202
|
+
trusted to 6 decimals.
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
TypeError: if ``df`` is not a ``DataFrame``.
|
|
206
|
+
ValueError: if a column is missing, unexpected or has the wrong dtype, the timestamp is naive, or a float is
|
|
207
|
+
not below 10^9 in absolute value.
|
|
208
|
+
pydantic.ValidationError: if a row violates the contract (bad MaLo, status/herkunft mismatch, ...).
|
|
209
|
+
"""
|
|
210
|
+
if not isinstance(df, pl.DataFrame):
|
|
211
|
+
raise TypeError(f"expected a polars DataFrame, got {type(df).__name__}; collect() a LazyFrame first")
|
|
212
|
+
_check_schema(df)
|
|
213
|
+
# Only an all-null (Null dtype) column needs a cast; every other dtype was checked to be the canonical one.
|
|
214
|
+
timestamp = pl.col(COL_TIMESTAMP)
|
|
215
|
+
if df.schema[COL_TIMESTAMP] == pl.Null:
|
|
216
|
+
timestamp = timestamp.cast(pl.Datetime("us", "UTC"))
|
|
217
|
+
table = df.select(
|
|
218
|
+
pl.col(COL_MALO).cast(pl.String),
|
|
219
|
+
timestamp.dt.convert_time_zone("UTC"),
|
|
220
|
+
pl.col(COL_VALUE).cast(pl.Float64),
|
|
221
|
+
*(pl.col(name).cast(pl.String) for name in CANONICAL_COLUMNS[3:]),
|
|
222
|
+
)
|
|
223
|
+
return [
|
|
224
|
+
Messwert(
|
|
225
|
+
malo=row[COL_MALO],
|
|
226
|
+
timestamp=row[COL_TIMESTAMP],
|
|
227
|
+
resolution="PT15M",
|
|
228
|
+
value=None if row[COL_VALUE] is None else _float_to_decimal(row[COL_VALUE]),
|
|
229
|
+
status=row[COL_STATUS],
|
|
230
|
+
herkunft=row[COL_HERKUNFT],
|
|
231
|
+
profiltyp=row[COL_PROFILTYP],
|
|
232
|
+
richtung=row[COL_RICHTUNG],
|
|
233
|
+
)
|
|
234
|
+
for row in table.iter_rows(named=True)
|
|
235
|
+
]
|
mdq_models/py.typed
ADDED
|
File without changes
|
mdq_models/scalars.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Scalar types with the contract's syntax rules: canonical decimals and UTC timestamps."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
from typing import Annotated, Any
|
|
7
|
+
|
|
8
|
+
from pydantic import PlainSerializer, PlainValidator
|
|
9
|
+
|
|
10
|
+
MAX_DECIMAL_PLACES = 6
|
|
11
|
+
"""At most this many digits after the point, counted after trailing zeros are removed."""
|
|
12
|
+
|
|
13
|
+
MAX_INTEGER_DIGITS = 12
|
|
14
|
+
"""At most this many digits before the point (|value| < 10^12). Why: a common storage type for such values is
|
|
15
|
+
a numeric(18,6) database column (12 digits before, 6 after the point), so a larger value would pass validation and
|
|
16
|
+
only fail on write. It also bounds the memory a Decimal built in code like ``1E+999999999`` could use. Go checks the
|
|
17
|
+
same."""
|
|
18
|
+
|
|
19
|
+
# [0-9], not \d, and fullmatch, not `$`: Python's `$` matches before a trailing newline and \d matches other
|
|
20
|
+
# scripts' digits. Go uses the same expression.
|
|
21
|
+
_CANONICAL_DECIMAL = re.compile(r"-?(0|[1-9][0-9]{0,11})(\.[0-9]{0,5}[1-9])?", re.ASCII)
|
|
22
|
+
_TIMESTAMP = re.compile(r"([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})Z", re.ASCII)
|
|
23
|
+
|
|
24
|
+
MIN_YEAR = 1900
|
|
25
|
+
MAX_YEAR = 9999
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _decimal_error(value: object, rule: str) -> ValueError:
|
|
29
|
+
return ValueError(f"{value!r}: {rule}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def canonical_decimal(value: Decimal) -> str:
|
|
33
|
+
"""Write ``value`` in the shortest canonical form, or raise ``ValueError`` if it is not allowed.
|
|
34
|
+
|
|
35
|
+
Not ``Decimal.normalize()``: that rounds to the context precision (28 digits by default) and would silently
|
|
36
|
+
change long values. The text is built with ``format(value, "f")`` and stripped as a string instead.
|
|
37
|
+
"""
|
|
38
|
+
if not value.is_finite(): # NaN, sNaN and the infinities have no decimal spelling
|
|
39
|
+
raise _decimal_error(value, "must be a finite number")
|
|
40
|
+
_sign, digits, exponent = value.as_tuple()
|
|
41
|
+
assert isinstance(exponent, int) # finite
|
|
42
|
+
# Count places and integer digits without formatting, so 1E-999999999 is rejected before any string is built.
|
|
43
|
+
# Trailing zero digits are dropped first: Decimal("1.5000000") has 7 places but is 1.5.
|
|
44
|
+
kept = "".join(map(str, digits)).rstrip("0")
|
|
45
|
+
if not kept: # zero in any spelling (0, 0.0, 0E-10, -0)
|
|
46
|
+
return "0"
|
|
47
|
+
exponent += len(digits) - len(kept)
|
|
48
|
+
if -exponent > MAX_DECIMAL_PLACES:
|
|
49
|
+
raise _decimal_error(value, f"has more than {MAX_DECIMAL_PLACES} decimal places (it is rejected, not rounded)")
|
|
50
|
+
if len(kept) + exponent > MAX_INTEGER_DIGITS:
|
|
51
|
+
raise _decimal_error(value, f"has more than {MAX_INTEGER_DIGITS} integer digits")
|
|
52
|
+
text = format(value, "f")
|
|
53
|
+
if "." in text:
|
|
54
|
+
text = text.rstrip("0").removesuffix(".")
|
|
55
|
+
return text
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _to_decimal(value: Any) -> Decimal:
|
|
59
|
+
"""Accept a canonical string (JSON) or a ``Decimal`` (code, normalised); nothing else.
|
|
60
|
+
|
|
61
|
+
No ``float`` or ``int``: a float is already inexact, and a JSON number would let ``1.0`` and ``1`` both through.
|
|
62
|
+
"""
|
|
63
|
+
if isinstance(value, Decimal):
|
|
64
|
+
return Decimal(canonical_decimal(value))
|
|
65
|
+
if isinstance(value, str):
|
|
66
|
+
if not _CANONICAL_DECIMAL.fullmatch(value) or value == "-0":
|
|
67
|
+
raise _decimal_error(
|
|
68
|
+
value, "is not a decimal in shortest canonical form with at most 12 integer and 6 decimal digits"
|
|
69
|
+
)
|
|
70
|
+
return Decimal(value)
|
|
71
|
+
raise ValueError(f"{value!r}: must be a canonical decimal string or a Decimal, not {type(value).__name__}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
CanonicalDecimal = Annotated[
|
|
75
|
+
Decimal,
|
|
76
|
+
PlainValidator(_to_decimal),
|
|
77
|
+
PlainSerializer(canonical_decimal, return_type=str, when_used="json"),
|
|
78
|
+
]
|
|
79
|
+
"""A decimal number with at most 12 integer and 6 decimal digits.
|
|
80
|
+
JSON carries the shortest canonical string (``"1.5"``)."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _utc_timestamp(value: Any, *, quarter_hour: bool) -> datetime:
|
|
84
|
+
if isinstance(value, str):
|
|
85
|
+
match = _TIMESTAMP.fullmatch(value)
|
|
86
|
+
if not match:
|
|
87
|
+
raise ValueError(f"{value!r}: must look like 2026-01-15T10:15:00Z (upper-case T and Z, whole seconds)")
|
|
88
|
+
try: # a real calendar parse: rejects 02-30, hour 24, second 60 and 1900-02-29
|
|
89
|
+
year, month, day, hour, minute, second = (int(part) for part in match.groups())
|
|
90
|
+
parsed = datetime(year, month, day, hour, minute, second, tzinfo=UTC)
|
|
91
|
+
except ValueError as exc:
|
|
92
|
+
raise ValueError(f"{value!r}: {exc}") from exc
|
|
93
|
+
elif isinstance(value, datetime):
|
|
94
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
95
|
+
raise ValueError(f"{value!r}: naive datetimes are not allowed, attach a time zone")
|
|
96
|
+
try:
|
|
97
|
+
parsed = value.astimezone(UTC)
|
|
98
|
+
except OverflowError as exc: # the instant lies outside years 1 to 9999 in UTC
|
|
99
|
+
raise ValueError(f"{value!r}: outside the years {MIN_YEAR} to {MAX_YEAR} in UTC") from exc
|
|
100
|
+
# Checked after the conversion, not before: an offset with a sub-second part (timedelta(microseconds=1))
|
|
101
|
+
# turns a whole-second local time into a UTC time with microseconds, which the JSON form cannot carry,
|
|
102
|
+
# so the output would name a different instant.
|
|
103
|
+
if parsed.microsecond:
|
|
104
|
+
raise ValueError(f"{value!r}: only whole seconds are allowed (in UTC)")
|
|
105
|
+
else:
|
|
106
|
+
raise ValueError(f"{value!r}: must be an RFC 3339 string or an aware datetime")
|
|
107
|
+
if not MIN_YEAR <= parsed.year <= MAX_YEAR:
|
|
108
|
+
raise ValueError(f"{value!r}: outside the years {MIN_YEAR} to {MAX_YEAR} in UTC")
|
|
109
|
+
# The raster is checked on the UTC value, so an input with another offset is judged by the instant it means.
|
|
110
|
+
if quarter_hour and (parsed.minute % 15 or parsed.second):
|
|
111
|
+
raise ValueError(f"{value!r}: must be the start of a quarter hour (minute 0, 15, 30 or 45, second 0)")
|
|
112
|
+
return parsed
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _format_utc(value: datetime) -> str:
|
|
116
|
+
"""Always ``Z``; never ``+00:00``, so the wire form has exactly one spelling."""
|
|
117
|
+
return value.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
QuarterHourStart = Annotated[
|
|
121
|
+
datetime,
|
|
122
|
+
PlainValidator(lambda v: _utc_timestamp(v, quarter_hour=True)),
|
|
123
|
+
PlainSerializer(_format_utc, return_type=str, when_used="json"),
|
|
124
|
+
]
|
|
125
|
+
"""UTC instant on the 15-minute raster: the start of a quarter hour. Aware datetimes are converted to UTC."""
|
|
126
|
+
|
|
127
|
+
UtcSecond = Annotated[
|
|
128
|
+
datetime,
|
|
129
|
+
PlainValidator(lambda v: _utc_timestamp(v, quarter_hour=False)),
|
|
130
|
+
PlainSerializer(_format_utc, return_type=str, when_used="json"),
|
|
131
|
+
]
|
|
132
|
+
"""UTC instant with whole seconds and no raster. Aware datetimes are converted to UTC."""
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""A meter reading (register value) at a point in time."""
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
|
|
5
|
+
from mdq_models._base import Model
|
|
6
|
+
from mdq_models.malo import MaLo
|
|
7
|
+
from mdq_models.scalars import CanonicalDecimal, UtcSecond
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Zaehlerstand(Model):
|
|
11
|
+
"""A meter reading of one market location. Field order is the key order of the JSON output."""
|
|
12
|
+
|
|
13
|
+
malo: MaLo = Field(description="Market location id: 11 ASCII digits, no leading 0, valid check digit.")
|
|
14
|
+
timestamp: UtcSecond = Field(
|
|
15
|
+
description="Point in time of the reading, UTC, whole seconds, no raster. JSON uses 'Z'."
|
|
16
|
+
)
|
|
17
|
+
value: CanonicalDecimal = Field(description="Register value with at most 6 decimal places; never null.")
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mdq-models
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Hand-written data models of the canonical meter-reading schema (Messwert, Zaehlerstand)
|
|
5
|
+
Project-URL: Changelog, https://github.com/Hochfrequenz/mdq-models/releases
|
|
6
|
+
Project-URL: Homepage, https://github.com/Hochfrequenz/mdq-models
|
|
7
|
+
Project-URL: Repository, https://github.com/Hochfrequenz/mdq-models
|
|
8
|
+
Author-email: Hochfrequenz Unternehmensberatung GmbH <info+github@hochfrequenz.de>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: energy,malo,meter reading,pydantic,time series
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Requires-Dist: pydantic>=2.11
|
|
26
|
+
Provides-Extra: polars
|
|
27
|
+
Requires-Dist: polars<3,>=2.0; extra == 'polars'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# mdq-models
|
|
31
|
+
|
|
32
|
+

|
|
33
|
+

|
|
34
|
+
[](https://pkg.go.dev/github.com/hochfrequenz/mdq-models)
|
|
35
|
+

|
|
36
|
+

|
|
37
|
+

|
|
38
|
+

|
|
39
|
+

|
|
40
|
+

|
|
41
|
+
|
|
42
|
+
Data models of the **canonical meter-reading schema, version 1**: `Messwert` (one value per market
|
|
43
|
+
location and quarter hour) and `Zaehlerstand` (a meter reading, the second input of the schema).
|
|
44
|
+
|
|
45
|
+
This repository holds
|
|
46
|
+
|
|
47
|
+
- **Go** types (package `mdqmodels`, module `github.com/hochfrequenz/mdq-models`),
|
|
48
|
+
- **Python** frozen pydantic v2 models (package `mdq_models`) and an optional **Polars** table schema
|
|
49
|
+
(`mdq_models.polars`, extra `polars`),
|
|
50
|
+
- the shared **fixtures** in [`testdata/`](https://github.com/Hochfrequenz/mdq-models/blob/main/testdata) that define the JSON contract, and
|
|
51
|
+
- the **gRPC contract** in [`proto/mdq/v1`](https://github.com/Hochfrequenz/mdq-models/blob/main/proto/mdq/v1).
|
|
52
|
+
|
|
53
|
+
Everything is **written by hand**. No code is generated and no generated code is committed: every
|
|
54
|
+
type, validator and (un)marshaller is ordinary, readable code. Field names and enum values are
|
|
55
|
+
German (`malo`, `herkunft`, `vorlaeufig`, ...) and are part of the contract. Both languages parse every
|
|
56
|
+
valid fixture, write it back byte-for-byte as pinned in `testdata/serialized.golden` and reject every
|
|
57
|
+
invalid fixture, so Go and Python agree on the contract by construction.
|
|
58
|
+
|
|
59
|
+
## The contract in short
|
|
60
|
+
|
|
61
|
+
| Topic | Rule |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `Messwert` keys | In this order on output: `malo`, `timestamp`, `resolution`, `value`, `status`, `herkunft`, `profiltyp`, `richtung`. All eight are **required**; `value` and `herkunft` are required-but-nullable (always written, `null` when unset). A missing key is rejected, also when its value would be `null`. |
|
|
64
|
+
| `Zaehlerstand` keys | `malo`, `timestamp`, `value`, all required; `value` is never `null`. |
|
|
65
|
+
| Strict reading | **Unknown keys and duplicate keys are rejected** (at every nesting level), so a typo fails loudly and two parsers cannot read different data from the same bytes. Key order matters only for output; parsing accepts any order. The top level must be a JSON object. Invalid UTF-8, a byte order mark and UTF-16 are rejected. |
|
|
66
|
+
| `malo` | `^[1-9][0-9]{10}$` (ASCII digits only) with a valid BDEW check digit. |
|
|
67
|
+
| `timestamp` | RFC 3339, upper-case `T`, offset exactly `Z`, whole seconds, years 1900-9999, ASCII digits only. For `Messwert` it is the **start of the quarter hour** (`00:00` is the first of a day, `23:45` the last; it equals the start of a `[from, to)` interval) and must be on the raster (minute divisible by 15, second 0). For `Zaehlerstand` it is a point in time with any whole second. Built in code, a timestamp in any time zone is accepted and written in UTC; a naive timestamp is rejected. |
|
|
68
|
+
| `resolution` | Only `"PT15M"` in v1. |
|
|
69
|
+
| `value` | A decimal **string** in its shortest canonical form (`"160"`, `"0.022"`, `"-3.5"`), never a JSON number. At most 12 integer digits and at most 6 decimals (counted after stripping trailing zeros). No exponent, no plus sign, no leading zeros, no trailing zeros after the point (`"1.0"` is rejected), no negative zero, no separators. Negative values are allowed. Producers that compute in floating point must round to 6 decimals first: `"0.1234567"` is rejected, not rounded. |
|
|
70
|
+
| `status` | `wahr`, `vorlaeufig`, `ersatzwert`, `fehlend`. Case-sensitive; the retired `gemessen` is rejected. |
|
|
71
|
+
| `herkunft` | `fremd`, `mqa`. `null` **iff** `status` is `fehlend`; `wahr` requires `fremd`. |
|
|
72
|
+
| `value` and `status` | `value` is `null` **iff** `status` is `fehlend`. |
|
|
73
|
+
| `profiltyp` | `haushalt`, `gewerbe`, `industrie`, `unbekannt`. |
|
|
74
|
+
| `richtung` | `bezug`, `einspeisung`. |
|
|
75
|
+
| Decimals built in code | Go `NewDecimal` and Python's `Decimal` input normalise trailing zeros (`"1.5000000"` becomes `"1.5"`) and reject more than 6 decimals, more than 12 integer digits, `NaN` and `Infinity`; they never round. |
|
|
76
|
+
| JSON vs. proto | The `.proto` files are the gRPC contract only. The JSON form is produced by the hand-written models, **never by protojson** (which would write `"STATUS_VORLAEUFIG"` and omit unset `optional` fields instead of writing `null`). |
|
|
77
|
+
|
|
78
|
+
## Install
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pip install "mdq-models[polars]" # the extra is only needed for mdq_models.polars
|
|
82
|
+
go get github.com/hochfrequenz/mdq-models
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Python 3.11 or newer; `mdq_models` itself depends only on pydantic and never imports polars.
|
|
86
|
+
|
|
87
|
+
## Go
|
|
88
|
+
|
|
89
|
+
Read untrusted bytes with `ParseMesswert` / `ParseZaehlerstand`: every failure is a
|
|
90
|
+
`*ValidationError`. Plain `json.Unmarshal` reports syntax errors as `*json.SyntaxError` and leaves a
|
|
91
|
+
`*Messwert` nil on a top-level `null`.
|
|
92
|
+
|
|
93
|
+
```go
|
|
94
|
+
raw := []byte(`{"malo":"68283561554","timestamp":"2026-03-29T00:45:00Z","resolution":"PT15M",` +
|
|
95
|
+
`"value":"1.5","status":"wahr","herkunft":"fremd","profiltyp":"haushalt","richtung":"bezug"}`)
|
|
96
|
+
m, err := mdq.ParseMesswert(raw) // every failure is a *mdq.ValidationError
|
|
97
|
+
if err != nil {
|
|
98
|
+
log.Fatal(err)
|
|
99
|
+
}
|
|
100
|
+
fmt.Println(m.Malo, m.Timestamp.Format(time.RFC3339), *m.Value, m.Status)
|
|
101
|
+
|
|
102
|
+
// Build a row in code. NewDecimal drops trailing zeros: "2.50" becomes "2.5".
|
|
103
|
+
value, err := mdq.NewDecimal("2.50")
|
|
104
|
+
if err != nil {
|
|
105
|
+
log.Fatal(err)
|
|
106
|
+
}
|
|
107
|
+
mqa := mdq.HerkunftMqa
|
|
108
|
+
built := mdq.Messwert{
|
|
109
|
+
Malo: "68283561554",
|
|
110
|
+
Timestamp: time.Date(2026, 3, 29, 3, 0, 0, 0, time.FixedZone("CEST", 2*3600)), // written as UTC
|
|
111
|
+
Resolution: mdq.PT15M,
|
|
112
|
+
Value: &value,
|
|
113
|
+
Status: mdq.StatusErsatzwert,
|
|
114
|
+
Herkunft: &mqa,
|
|
115
|
+
Profiltyp: mdq.ProfiltypHaushalt,
|
|
116
|
+
Richtung: mdq.RichtungBezug,
|
|
117
|
+
}
|
|
118
|
+
out, err := built.MarshalJSON() // validates; an invalid row does not marshal
|
|
119
|
+
if err != nil {
|
|
120
|
+
log.Fatal(err)
|
|
121
|
+
}
|
|
122
|
+
fmt.Println(string(out))
|
|
123
|
+
|
|
124
|
+
// Reading is strict: duplicate (and unknown) keys are rejected.
|
|
125
|
+
_, err = mdq.ParseMesswert([]byte(`{"malo":"68283561554","malo":"68283561554"}`))
|
|
126
|
+
var invalid *mdq.ValidationError
|
|
127
|
+
fmt.Println(errors.As(err, &invalid))
|
|
128
|
+
// Output:
|
|
129
|
+
// 68283561554 2026-03-29T00:45:00Z 1.5 wahr
|
|
130
|
+
// {"malo":"68283561554","timestamp":"2026-03-29T01:00:00Z","resolution":"PT15M","value":"2.5","status":"ersatzwert","herkunft":"mqa","profiltyp":"haushalt","richtung":"bezug"}
|
|
131
|
+
// true
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
(with `import mdq "github.com/hochfrequenz/mdq-models"`). This snippet is the body of
|
|
135
|
+
`ExampleParseMesswert` in [`example_test.go`](https://github.com/Hochfrequenz/mdq-models/blob/main/example_test.go); a test fails if the two drift apart.
|
|
136
|
+
`Zaehlerstand` works the same way. Every type validates itself in `UnmarshalJSON` and refuses to
|
|
137
|
+
marshal when invalid; `Validate()` checks values built in code.
|
|
138
|
+
|
|
139
|
+
## Python
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from datetime import datetime, timedelta, timezone
|
|
143
|
+
from decimal import Decimal
|
|
144
|
+
|
|
145
|
+
from pydantic import ValidationError
|
|
146
|
+
|
|
147
|
+
from mdq_models import Herkunft, Messwert, Profiltyp, Richtung, Status
|
|
148
|
+
|
|
149
|
+
raw = (
|
|
150
|
+
'{"malo":"68283561554","timestamp":"2026-03-29T00:45:00Z","resolution":"PT15M",'
|
|
151
|
+
'"value":"1.5","status":"wahr","herkunft":"fremd","profiltyp":"haushalt","richtung":"bezug"}'
|
|
152
|
+
)
|
|
153
|
+
m = Messwert.model_validate_json(raw) # rejects duplicate and unknown keys
|
|
154
|
+
print(m.malo, m.timestamp.isoformat(), m.value, m.status.value) # 68283561554 2026-03-29T00:45:00+00:00 1.5 wahr
|
|
155
|
+
|
|
156
|
+
# Build a row in code. Trailing zeros are dropped: Decimal("2.50") is written as "2.5".
|
|
157
|
+
built = Messwert(
|
|
158
|
+
malo="68283561554",
|
|
159
|
+
timestamp=datetime(2026, 3, 29, 3, 0, tzinfo=timezone(timedelta(hours=2))), # written as UTC
|
|
160
|
+
resolution="PT15M",
|
|
161
|
+
value=Decimal("2.50"),
|
|
162
|
+
status=Status.ERSATZWERT,
|
|
163
|
+
herkunft=Herkunft.MQA,
|
|
164
|
+
profiltyp=Profiltyp.HAUSHALT,
|
|
165
|
+
richtung=Richtung.BEZUG,
|
|
166
|
+
)
|
|
167
|
+
print(built.model_dump_json()) # the same bytes the Go models write
|
|
168
|
+
|
|
169
|
+
# Reading is strict: duplicate keys are rejected. Only Messwert.model_validate_json checks this by itself; for
|
|
170
|
+
# TypeAdapter and nested models call check_json(raw) on the raw document first.
|
|
171
|
+
try:
|
|
172
|
+
Messwert.model_validate_json('{"malo":"68283561554","malo":"68283561554"}')
|
|
173
|
+
except ValidationError:
|
|
174
|
+
print("rejected")
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Plain pydantic keeps the last of two duplicate keys; `mdq_models.check_json(raw)` is the explicit check for
|
|
178
|
+
paths that bypass `model_validate_json`. Use `Messwert.model_validate_json` (or
|
|
179
|
+
`Zaehlerstand.model_validate_json`) for raw JSON. All parse failures are
|
|
180
|
+
`pydantic.ValidationError`.
|
|
181
|
+
|
|
182
|
+
### Polars
|
|
183
|
+
|
|
184
|
+
`mdq_models.polars` is the table form Python consumers compute with: seven columns (`malo`,
|
|
185
|
+
`timestamp`, `value`, `status`, `herkunft`, `profiltyp`, `richtung`, **without** `resolution`, which is
|
|
186
|
+
always `PT15M`), `value` as `Float64` and `timestamp` in `Europe/Berlin` (UTC is only the wire format).
|
|
187
|
+
The schema is available as `CANONICAL_COLUMNS`, `CANONICAL_DTYPES` and `TIMESTAMP_DTYPE`.
|
|
188
|
+
|
|
189
|
+
```python polars
|
|
190
|
+
from mdq_models.polars import from_frame, to_frame
|
|
191
|
+
|
|
192
|
+
frame = to_frame([m, built]) # seven columns, timestamps in Europe/Berlin
|
|
193
|
+
print(frame["timestamp"][0]) # 2026-03-29 01:45:00+01:00
|
|
194
|
+
assert from_frame(frame) == [m, built] # lossless for |value| < 10^9
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`Float64` keeps a decimal exactly only up to 15 significant digits, so the table form is bounded:
|
|
198
|
+
`to_frame` raises `ValueError` for `|value| >= 10^9` and `from_frame` for a float of that size; neither
|
|
199
|
+
rounds silently. `from_frame` converts each float through its shortest representation and quantizes to
|
|
200
|
+
6 decimals. Both are strict about the table: unknown columns and wrong dtypes raise `ValueError`.
|
|
201
|
+
Importing `mdq_models.polars` without the extra raises an `ImportError` that names it.
|
|
202
|
+
|
|
203
|
+
The Python snippets above are run by [`unittests/test_readme.py`](https://github.com/Hochfrequenz/mdq-models/blob/main/unittests/test_readme.py), so they
|
|
204
|
+
cannot rot.
|
|
205
|
+
|
|
206
|
+
## Fixtures
|
|
207
|
+
|
|
208
|
+
```
|
|
209
|
+
testdata/
|
|
210
|
+
messwert/{valid,invalid}/ one JSON document per file (DST days, boundaries, every rejected rule, ...)
|
|
211
|
+
zaehlerstand/{valid,invalid}/
|
|
212
|
+
invalid_reasons.json why each invalid fixture is rejected
|
|
213
|
+
malo_ids.json valid and invalid MaLo IDs, check digits
|
|
214
|
+
herkunft_status.json all status x herkunft combinations
|
|
215
|
+
proto_enums.json proto enum constants, numbers and JSON values
|
|
216
|
+
proto_fields.json proto messages, field labels, types and numbers
|
|
217
|
+
serialized.golden the exact bytes both languages write for every valid fixture
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
[`testdata/README.md`](https://github.com/Hochfrequenz/mdq-models/blob/main/testdata/README.md) documents every file. Consumers in other languages should
|
|
221
|
+
run the same fixtures.
|
|
222
|
+
|
|
223
|
+
`serialized.golden` pins the output bytes (`<fixture>\t<JSON>` per line), so Go and Python output is
|
|
224
|
+
identical: compact JSON, keys in contract order, timestamps in UTC with `Z`.
|
|
225
|
+
Every invalid fixture has an entry in `testdata/invalid_reasons.json`, so a fixture cannot pass by being
|
|
226
|
+
rejected for the wrong reason.
|
|
227
|
+
|
|
228
|
+
## How CI keeps Go, Python and proto in step
|
|
229
|
+
|
|
230
|
+
- **Same fixtures.** `fixtures_test.go` and `unittests/test_fixtures.py` read the same files: every valid
|
|
231
|
+
fixture must parse and write back the exact bytes of its `serialized.golden` line, every invalid one
|
|
232
|
+
must be rejected. Go and Python tests run on every pull request (Python 3.11-3.15, with and without the
|
|
233
|
+
`polars` extra).
|
|
234
|
+
- **Golden file.** Only Go writes it (`go test -run Golden -update .`); Python compares against it.
|
|
235
|
+
- **Proto drift tests.** `proto_contract_test.go` and `unittests/test_proto_contract.py` fail when a proto
|
|
236
|
+
field, its number or label, or an enum constant differs from `testdata/proto_fields.json` /
|
|
237
|
+
`proto_enums.json`, or when proto and models disagree on field names or JSON enum values.
|
|
238
|
+
- **`buf`.** The Proto workflow runs `buf build`, `buf lint`, `buf format --diff --exit-code` and, on pull
|
|
239
|
+
requests, `buf breaking` against the base branch.
|
|
240
|
+
- **Lint, format, types, spelling.** golangci-lint, ruff, mypy (strict) and codespell.
|
|
241
|
+
- **Packaging.** The wheel is built and imported in a fresh environment, with and without the extra.
|
|
242
|
+
|
|
243
|
+
## Adding a field
|
|
244
|
+
|
|
245
|
+
A new field must reach every consumer at once, because reading rejects unknown keys. Do it in **one
|
|
246
|
+
pull request**:
|
|
247
|
+
|
|
248
|
+
1. Add the field to the `.proto` message (next free number; never reuse or renumber) and to
|
|
249
|
+
`testdata/proto_fields.json`.
|
|
250
|
+
2. Add it to the Go struct (`Validate`, `MarshalJSON`, `UnmarshalJSON`, the key list) and to the Python
|
|
251
|
+
model, in contract key order.
|
|
252
|
+
3. Add valid and invalid fixtures (each rule one fixture, plus an entry in `invalid_reasons.json`).
|
|
253
|
+
4. Regenerate the golden file with `go test -run Golden -update .`, review the diff and check that
|
|
254
|
+
`uv run pytest` writes the same bytes.
|
|
255
|
+
5. If it is a table column, extend `mdq_models.polars` and its tests.
|
|
256
|
+
6. Update this README and `testdata/README.md`.
|
|
257
|
+
|
|
258
|
+
## Versioning
|
|
259
|
+
|
|
260
|
+
While the major version is 0, minor releases may contain breaking changes (announced in the release
|
|
261
|
+
notes). From 1.0 on the JSON and proto contract of `v1` is frozen: a breaking change needs a new proto
|
|
262
|
+
package version (`mdq.v2`) and, for Go, a new module path `github.com/hochfrequenz/mdq-models/v2`.
|
|
263
|
+
Readers are strict on purpose: unknown and duplicate JSON keys are rejected, so a typo fails
|
|
264
|
+
loudly. The price: **adding a JSON field is a breaking change** for every consumer still on an older
|
|
265
|
+
version, even though `buf breaking` accepts the additive proto change. Consumers update together;
|
|
266
|
+
from 1.0 on, a new field needs a new major version. Releases are tags
|
|
267
|
+
(`vX.Y.Z`); the Python package takes its version from the same tags.
|
|
268
|
+
|
|
269
|
+
## Development
|
|
270
|
+
|
|
271
|
+
```bash
|
|
272
|
+
go test ./... # Go
|
|
273
|
+
golangci-lint run && golangci-lint fmt --diff
|
|
274
|
+
uv sync --group dev --extra polars # Python (the extra is needed for the Polars module and mypy)
|
|
275
|
+
uv run pytest
|
|
276
|
+
uv run ruff check . && uv run ruff format --check .
|
|
277
|
+
uv run mypy src unittests
|
|
278
|
+
uv run codespell --ignore-words=domain-specific-terms.txt src unittests README.md doc.go
|
|
279
|
+
buf build && buf lint && buf format --diff --exit-code
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
## License
|
|
283
|
+
|
|
284
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
mdq_models/__init__.py,sha256=xCeSgPUlpAOIqZbxfGRAOSdjwDUXZMLwPyY0mXnoXvM,1345
|
|
2
|
+
mdq_models/_base.py,sha256=8XCTjEAhr6inA_PodPRaWGwEqjW5agLlojD7Kvx9lac,5898
|
|
3
|
+
mdq_models/_version.py,sha256=aOHawL1zuHMfBWKXqwUkXcW96oXLNCY-CXdHDqkz4g4,18
|
|
4
|
+
mdq_models/enums.py,sha256=-342sbtY5jStda9arxQUB6330mhfMooPUYZKbPGZE-4,2296
|
|
5
|
+
mdq_models/malo.py,sha256=_WX7b5oqpuXAkRwm3LpUTxNXqzASpFubjqO0xJzBv44,2730
|
|
6
|
+
mdq_models/messwert.py,sha256=0JBxOfHcIntyDrAbzBlRgiS9NoaKLxL__VWDXzNcMEg,2490
|
|
7
|
+
mdq_models/polars.py,sha256=qep6xEHImWo1wjiRORy4qxfuiKjTJEJ0hGlebuRVV6E,10418
|
|
8
|
+
mdq_models/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
mdq_models/scalars.py,sha256=6g5V69OGl90h2Y50vHuVdkgFGRG0wdjRoV9Upwq7r34,6425
|
|
10
|
+
mdq_models/zaehlerstand.py,sha256=8uO2d_MDmLsa_05BmuhJrqCGXa-1G5Q0l_DXOo8WC9Q,699
|
|
11
|
+
mdq_models-0.1.0.dist-info/METADATA,sha256=fhXWDin2mxgmhGLzCO80k5J-eagFth9juUrgSxSiyfQ,15748
|
|
12
|
+
mdq_models-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
13
|
+
mdq_models-0.1.0.dist-info/licenses/LICENSE,sha256=AiXS9N7D_LipXnym-hsOrq7OJZ9sSJi6ycYQNNuvFxE,1095
|
|
14
|
+
mdq_models-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hochfrequenz Unternehmensberatung GmbH
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|