sdata 1.5.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.
- sdata/__init__.py +140 -0
- sdata/base.py +684 -0
- sdata/contrib/__init__.py +0 -0
- sdata/contrib/piexif/__init__.py +12 -0
- sdata/contrib/piexif/_common.py +94 -0
- sdata/contrib/piexif/_dump.py +346 -0
- sdata/contrib/piexif/_exceptions.py +2 -0
- sdata/contrib/piexif/_exif.py +638 -0
- sdata/contrib/piexif/_insert.py +60 -0
- sdata/contrib/piexif/_load.py +270 -0
- sdata/contrib/piexif/_remove.py +56 -0
- sdata/contrib/piexif/_transplant.py +45 -0
- sdata/contrib/piexif/_webp.py +240 -0
- sdata/contrib/piexif/helper.py +66 -0
- sdata/contrib/ranger/__init__.py +8 -0
- sdata/contrib/ranger/collections/__init__.py +0 -0
- sdata/contrib/ranger/collections/rangebucketmap.py +346 -0
- sdata/contrib/ranger/collections/rangemap.py +396 -0
- sdata/contrib/ranger/collections/rangeset.py +483 -0
- sdata/contrib/ranger/range/__init__.py +0 -0
- sdata/contrib/ranger/range/cut.py +285 -0
- sdata/contrib/ranger/range/range.py +644 -0
- sdata/contrib/sobol_seq.py +394 -0
- sdata/contrib/sortedcontainers/LICENSE +13 -0
- sdata/contrib/sortedcontainers/__init__.py +74 -0
- sdata/contrib/sortedcontainers/sorteddict.py +789 -0
- sdata/contrib/sortedcontainers/sortedlist.py +2646 -0
- sdata/contrib/sortedcontainers/sortedset.py +733 -0
- sdata/contrib/sqlitedict.py +598 -0
- sdata/contrib/timeflake/__init__.py +40 -0
- sdata/contrib/timeflake/flake.py +69 -0
- sdata/contrib/timeflake/utils.py +47 -0
- sdata/deprecated/__init__.py +10 -0
- sdata/deprecated/data.py +1503 -0
- sdata/did/__init__.py +129 -0
- sdata/did/_http.py +67 -0
- sdata/did/base58btc.py +49 -0
- sdata/did/did_github.py +152 -0
- sdata/did/did_peer4.py +155 -0
- sdata/did/did_web.py +222 -0
- sdata/did/diddoc.py +75 -0
- sdata/did/ed25519.py +225 -0
- sdata/did/eddsa.py +107 -0
- sdata/did/errors.py +40 -0
- sdata/did/issue_vc.py +193 -0
- sdata/did/jose.py +126 -0
- sdata/did/keys.py +104 -0
- sdata/did/utils_didkey.py +161 -0
- sdata/did/verify_vp.py +158 -0
- sdata/doe.py +101 -0
- sdata/dtypes.py +510 -0
- sdata/experiments/__init__.py +331 -0
- sdata/experiments/blob0.py +131 -0
- sdata/experiments/file.py +42 -0
- sdata/experiments/filegroup.py +144 -0
- sdata/experiments/image_old.py +313 -0
- sdata/experiments/ks2.py +15 -0
- sdata/experiments/lapshear.py +8 -0
- sdata/experiments/material.py +7 -0
- sdata/experiments/parameters_deprecated.py +394 -0
- sdata/experiments/workbook.py +226 -0
- sdata/format.py +125 -0
- sdata/imagemeta.py +533 -0
- sdata/interactive.py +118 -0
- sdata/iolib/__init__.py +51 -0
- sdata/iolib/hashtree.py +264 -0
- sdata/iolib/json1sqlitestore.py +554 -0
- sdata/iolib/jsonsqlitestore.py +704 -0
- sdata/iolib/pud.py +242 -0
- sdata/iolib/reader.py +262 -0
- sdata/iolib/writer.py +419 -0
- sdata/metadata.py +882 -0
- sdata/parameter.py +414 -0
- sdata/schema.py +273 -0
- sdata/sclass/__init__.py +111 -0
- sdata/sclass/bfo_classes.py +362 -0
- sdata/sclass/blob.py +362 -0
- sdata/sclass/content.py +87 -0
- sdata/sclass/dataframe.py +1251 -0
- sdata/sclass/dataframegroup.py +187 -0
- sdata/sclass/dependency_graph.py +85 -0
- sdata/sclass/filereference.py +124 -0
- sdata/sclass/image.py +218 -0
- sdata/sclass/lazy_namespace.py +155 -0
- sdata/sclass/process.py +383 -0
- sdata/sclass/process_graph.py +221 -0
- sdata/sclass/zipfile.py +135 -0
- sdata/semantic.py +346 -0
- sdata/suuid.py +136 -0
- sdata/timestamp.py +336 -0
- sdata/tools.py +14 -0
- sdata/units.py +602 -0
- sdata/vocab.py +149 -0
- sdata-1.5.0.dist-info/METADATA +264 -0
- sdata-1.5.0.dist-info/RECORD +98 -0
- sdata-1.5.0.dist-info/WHEEL +5 -0
- sdata-1.5.0.dist-info/licenses/LICENSE-MIT +21 -0
- sdata-1.5.0.dist-info/top_level.txt +1 -0
sdata/units.py
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Einheiten-Vokabular: kuratierte Abbildung Einheit → QUDT-IRI / UCUM-Code.
|
|
3
|
+
|
|
4
|
+
Reine-Python-Tabelle (keine Abhängigkeit). Optionales Extra ``[units]`` = pint
|
|
5
|
+
erweitert (a) die **Validierung** (:func:`validate_unit`) auf beliebige parsebare
|
|
6
|
+
Einheiten und (b) die **Umrechnung** (:func:`dimension_of`/:func:`convert`/
|
|
7
|
+
:class:`UnitSystem`): Einheiten außerhalb der kuratierten Tabelle (imperiale,
|
|
8
|
+
abgeleitete …) werden über pint auf denselben Dimensionsvektor abgebildet. Die
|
|
9
|
+
kuratierte Tabelle hat dabei stets Vorrang; ohne pint greift ausschließlich sie.
|
|
10
|
+
"""
|
|
11
|
+
__all__ = [
|
|
12
|
+
"UNIT_MAP", "normalize_symbol", "qudt_iri", "ucum_code", "unit_node",
|
|
13
|
+
"validate_unit", "has_pint",
|
|
14
|
+
"UnitConversionError", "quantity_of", "dimension_of", "convert",
|
|
15
|
+
"convert_factor", "UnitSystem",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
try: # optionales Backend
|
|
19
|
+
import pint as _pint
|
|
20
|
+
except ImportError: # pragma: no cover - abhängig vom Environment
|
|
21
|
+
_pint = None
|
|
22
|
+
|
|
23
|
+
#: gespeichertes Einheiten-Symbol -> (QUDT-IRI-CURIE | None, UCUM-Code)
|
|
24
|
+
UNIT_MAP = {
|
|
25
|
+
"km": ("unit:KiloM", "km"),
|
|
26
|
+
"m": ("unit:M", "m"),
|
|
27
|
+
"dm": ("unit:DeciM", "dm"),
|
|
28
|
+
"cm": ("unit:CentiM", "cm"),
|
|
29
|
+
"mm": ("unit:MilliM", "mm"),
|
|
30
|
+
"um": ("unit:MicroM", "um"),
|
|
31
|
+
"nm": ("unit:NanoM", "nm"),
|
|
32
|
+
"m2": ("unit:M2", "m2"),
|
|
33
|
+
"mm2": ("unit:MilliM2", "mm2"),
|
|
34
|
+
"m3": ("unit:M3", "m3"),
|
|
35
|
+
"mm3": ("unit:MilliM3", "mm3"),
|
|
36
|
+
"MN": ("unit:MegaN", "MN"),
|
|
37
|
+
"kN": ("unit:KiloN", "kN"),
|
|
38
|
+
"N": ("unit:N", "N"),
|
|
39
|
+
"mN": ("unit:MilliN", "mN"),
|
|
40
|
+
"GPa": ("unit:GigaPA", "GPa"),
|
|
41
|
+
"MPa": ("unit:MegaPA", "MPa"),
|
|
42
|
+
"kPa": ("unit:KiloPA", "kPa"),
|
|
43
|
+
"Pa": ("unit:PA", "Pa"),
|
|
44
|
+
"kJ": ("unit:KiloJ", "kJ"),
|
|
45
|
+
"J": ("unit:J", "J"),
|
|
46
|
+
"mJ": ("unit:MilliJ", "mJ"),
|
|
47
|
+
"kW": ("unit:KiloW", "kW"),
|
|
48
|
+
"W": ("unit:W", "W"),
|
|
49
|
+
"degC": ("unit:DEG_C", "Cel"),
|
|
50
|
+
"K": ("unit:K", "K"),
|
|
51
|
+
"h": ("unit:HR", "h"),
|
|
52
|
+
"min": ("unit:MIN", "min"),
|
|
53
|
+
"s": ("unit:SEC", "s"),
|
|
54
|
+
"ms": ("unit:MilliSEC", "ms"),
|
|
55
|
+
"us": ("unit:MicroSEC", "us"),
|
|
56
|
+
"ns": ("unit:NanoSEC", "ns"),
|
|
57
|
+
"m/s": ("unit:M-PER-SEC", "m.s-1"),
|
|
58
|
+
"mm/s": ("unit:MilliM-PER-SEC", "mm.s-1"),
|
|
59
|
+
"m/s2": ("unit:M-PER-SEC2", "m.s-2"),
|
|
60
|
+
"1/s": ("unit:PER-SEC", "/s"),
|
|
61
|
+
"1/ms": ("unit:PER-MilliSEC", "ms-1"),
|
|
62
|
+
"Hz": ("unit:HZ", "Hz"),
|
|
63
|
+
"kHz": ("unit:KiloHZ", "kHz"),
|
|
64
|
+
"MHz": ("unit:MegaHZ", "MHz"),
|
|
65
|
+
"GHz": ("unit:GigaHZ", "GHz"),
|
|
66
|
+
"t": ("unit:TONNE", "t"),
|
|
67
|
+
"kg": ("unit:KiloGM", "kg"),
|
|
68
|
+
"g": ("unit:GM", "g"),
|
|
69
|
+
"mg": ("unit:MilliGM", "mg"),
|
|
70
|
+
"rad": ("unit:RAD", "rad"),
|
|
71
|
+
"mrad": ("unit:MilliRAD","mrad"),
|
|
72
|
+
"deg": ("unit:DEG", "deg"),
|
|
73
|
+
"gon": ("unit:GON", "gon"),
|
|
74
|
+
"%": ("unit:PERCENT", "%"),
|
|
75
|
+
"-": (None, "1"), # dimensionslos
|
|
76
|
+
"": (None, "1"),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#: Symbol-Aliasse -> kanonisches UNIT_MAP-Symbol
|
|
80
|
+
_ALIASES = {
|
|
81
|
+
"°C": "degC", "celsius": "degC",
|
|
82
|
+
"µm": "um", "μm": "um", "µs": "us", "μs": "us",
|
|
83
|
+
"mm²": "mm2", "m²": "m2", "mm³": "mm3", "m³": "m3",
|
|
84
|
+
"mpa": "MPa", "gpa": "GPa", "kn": "kN",
|
|
85
|
+
"°": "deg", "degree": "deg", "degrees": "deg",
|
|
86
|
+
"radian": "rad", "radians": "rad", "gradian": "gon",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def has_pint():
|
|
91
|
+
"""True, falls das optionale pint-Backend verfügbar ist."""
|
|
92
|
+
return _pint is not None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def normalize_symbol(symbol):
|
|
96
|
+
"""Trimme/normalisiere ein Einheiten-Symbol auf einen kanonischen Schlüssel."""
|
|
97
|
+
if symbol is None:
|
|
98
|
+
return "-"
|
|
99
|
+
text = str(symbol).strip()
|
|
100
|
+
if text in UNIT_MAP:
|
|
101
|
+
return text
|
|
102
|
+
return _ALIASES.get(text, _ALIASES.get(text.lower(), text))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def qudt_iri(symbol):
|
|
106
|
+
"""QUDT-Einheiten-IRI-CURIE für ein Symbol oder ``None`` (unbekannt/dimensionslos)."""
|
|
107
|
+
entry = UNIT_MAP.get(normalize_symbol(symbol))
|
|
108
|
+
return entry[0] if entry else None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def ucum_code(symbol):
|
|
112
|
+
"""UCUM-Code für ein Symbol; Fallback ist das (getrimmte) Symbol selbst."""
|
|
113
|
+
entry = UNIT_MAP.get(normalize_symbol(symbol))
|
|
114
|
+
if entry:
|
|
115
|
+
return entry[1]
|
|
116
|
+
return str(symbol).strip() if symbol is not None else "1"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def unit_node(symbol):
|
|
120
|
+
"""JSON-LD-Fragment für eine Einheit: ``{"unitRef": <iri>, "symbol": <sym>}``.
|
|
121
|
+
|
|
122
|
+
``unitRef`` entfällt, wenn keine QUDT-IRI bekannt ist (z.B. ``"-"`` oder
|
|
123
|
+
unbekannte Einheit) – das rohe ``symbol`` bleibt stets erhalten.
|
|
124
|
+
"""
|
|
125
|
+
node = {}
|
|
126
|
+
iri = qudt_iri(symbol)
|
|
127
|
+
if iri is not None:
|
|
128
|
+
node["unitRef"] = iri
|
|
129
|
+
raw = "" if symbol is None else str(symbol).strip()
|
|
130
|
+
node["symbol"] = raw
|
|
131
|
+
return node
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def validate_unit(symbol):
|
|
135
|
+
"""True, wenn die Einheit bekannt (kuratierte Tabelle) oder – mit pint – parsebar ist."""
|
|
136
|
+
if normalize_symbol(symbol) in UNIT_MAP:
|
|
137
|
+
return True
|
|
138
|
+
if _pint is not None:
|
|
139
|
+
try:
|
|
140
|
+
_pint.Unit(str(symbol))
|
|
141
|
+
return True
|
|
142
|
+
except Exception:
|
|
143
|
+
return False
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# --------------------------------------------------------------- Konvertierung
|
|
148
|
+
#
|
|
149
|
+
# Dimensions-Algebra (RFC 0006): jede Einheit trägt einen Dimensionsvektor über den
|
|
150
|
+
# Basis-Dimensionen (Länge L, Masse M, Zeit T, Temperatur Θ, ebener Winkel A). Ein
|
|
151
|
+
# :class:`UnitSystem` wird aus seinen Basis-Einheiten *gelöst*, sodass jede abgeleitete
|
|
152
|
+
# Einheit (Spannung, Energie, Geschwindigkeit, …) hergeleitet werden kann. Reine
|
|
153
|
+
# Standardbibliothek (``fractions`` für exakte Exponenten); das optionale ``pint``
|
|
154
|
+
# bleibt der Validierung.
|
|
155
|
+
#
|
|
156
|
+
# Der Winkel ist eine *eigene* Achse (nicht dimensionslos), damit rad/deg/gon
|
|
157
|
+
# untereinander konvertieren, aber nicht mit reinen Zahlen/Prozent (``-``/``%``)
|
|
158
|
+
# verwechselt werden. Logarithmische „Einheiten" (dB) bleiben bewusst außen vor:
|
|
159
|
+
# sie passen nicht in das lineare Faktor-Modell.
|
|
160
|
+
|
|
161
|
+
import math
|
|
162
|
+
from fractions import Fraction
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class UnitConversionError(ValueError):
|
|
166
|
+
"""Eine Einheit ist unbekannt oder die Dimensionen sind inkompatibel (z. B. mm → kN)."""
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
#: Basis-Dimensionen: Länge, Masse, Zeit, Temperatur, ebener Winkel.
|
|
170
|
+
_DIM_NAMES = ("L", "M", "T", "Theta", "A")
|
|
171
|
+
|
|
172
|
+
#: Einheit -> (Dimvektor ``(L, M, T, Θ, A)``, Faktor zur SI-kohärenten Einheit, Offset).
|
|
173
|
+
#: ``si = wert * faktor + offset``; ``offset != 0`` nur bei reiner Temperatur.
|
|
174
|
+
_UNITS = {
|
|
175
|
+
# Länge (1,0,0,0,0) – SI m
|
|
176
|
+
"km": ((1, 0, 0, 0, 0), 1e3, 0.0), "m": ((1, 0, 0, 0, 0), 1.0, 0.0),
|
|
177
|
+
"dm": ((1, 0, 0, 0, 0), 1e-1, 0.0), "cm": ((1, 0, 0, 0, 0), 1e-2, 0.0),
|
|
178
|
+
"mm": ((1, 0, 0, 0, 0), 1e-3, 0.0), "um": ((1, 0, 0, 0, 0), 1e-6, 0.0),
|
|
179
|
+
"nm": ((1, 0, 0, 0, 0), 1e-9, 0.0),
|
|
180
|
+
# Masse (0,1,0,0,0) – SI kg
|
|
181
|
+
"t": ((0, 1, 0, 0, 0), 1e3, 0.0), "kg": ((0, 1, 0, 0, 0), 1.0, 0.0),
|
|
182
|
+
"g": ((0, 1, 0, 0, 0), 1e-3, 0.0), "mg": ((0, 1, 0, 0, 0), 1e-6, 0.0),
|
|
183
|
+
# Zeit (0,0,1,0,0) – SI s
|
|
184
|
+
"h": ((0, 0, 1, 0, 0), 3600.0, 0.0), "min": ((0, 0, 1, 0, 0), 60.0, 0.0),
|
|
185
|
+
"s": ((0, 0, 1, 0, 0), 1.0, 0.0), "ms": ((0, 0, 1, 0, 0), 1e-3, 0.0),
|
|
186
|
+
"us": ((0, 0, 1, 0, 0), 1e-6, 0.0), "ns": ((0, 0, 1, 0, 0), 1e-9, 0.0),
|
|
187
|
+
# Temperatur (0,0,0,1,0) – SI K, mit Offset
|
|
188
|
+
"K": ((0, 0, 0, 1, 0), 1.0, 0.0), "degC": ((0, 0, 0, 1, 0), 1.0, 273.15),
|
|
189
|
+
# Kraft (1,1,-2,0,0) – SI N
|
|
190
|
+
"MN": ((1, 1, -2, 0, 0), 1e6, 0.0), "kN": ((1, 1, -2, 0, 0), 1e3, 0.0),
|
|
191
|
+
"N": ((1, 1, -2, 0, 0), 1.0, 0.0), "mN": ((1, 1, -2, 0, 0), 1e-3, 0.0),
|
|
192
|
+
# Druck / Spannung (-1,1,-2,0,0) – SI Pa
|
|
193
|
+
"GPa": ((-1, 1, -2, 0, 0), 1e9, 0.0), "MPa": ((-1, 1, -2, 0, 0), 1e6, 0.0),
|
|
194
|
+
"kPa": ((-1, 1, -2, 0, 0), 1e3, 0.0), "Pa": ((-1, 1, -2, 0, 0), 1.0, 0.0),
|
|
195
|
+
# Energie (2,1,-2,0,0) – SI J
|
|
196
|
+
"kJ": ((2, 1, -2, 0, 0), 1e3, 0.0), "J": ((2, 1, -2, 0, 0), 1.0, 0.0),
|
|
197
|
+
"mJ": ((2, 1, -2, 0, 0), 1e-3, 0.0),
|
|
198
|
+
# Leistung (2,1,-3,0,0) – SI W
|
|
199
|
+
"kW": ((2, 1, -3, 0, 0), 1e3, 0.0), "W": ((2, 1, -3, 0, 0), 1.0, 0.0),
|
|
200
|
+
# Geschwindigkeit (1,0,-1,0,0), Beschleunigung (1,0,-2,0,0)
|
|
201
|
+
"m/s": ((1, 0, -1, 0, 0), 1.0, 0.0), "mm/s": ((1, 0, -1, 0, 0), 1e-3, 0.0),
|
|
202
|
+
"m/s2": ((1, 0, -2, 0, 0), 1.0, 0.0),
|
|
203
|
+
# Fläche (2,0,0,0,0), Volumen (3,0,0,0,0)
|
|
204
|
+
"m2": ((2, 0, 0, 0, 0), 1.0, 0.0), "mm2": ((2, 0, 0, 0, 0), 1e-6, 0.0),
|
|
205
|
+
"m3": ((3, 0, 0, 0, 0), 1.0, 0.0), "mm3": ((3, 0, 0, 0, 0), 1e-9, 0.0),
|
|
206
|
+
# Rate (0,0,-1,0,0) – Dehnrate / Frequenz
|
|
207
|
+
"1/s": ((0, 0, -1, 0, 0), 1.0, 0.0), "1/ms": ((0, 0, -1, 0, 0), 1e3, 0.0),
|
|
208
|
+
# Frequenz: Hz/kHz/MHz/GHz als benannte Eingabe-/Konvertier-Einheiten (gleiche
|
|
209
|
+
# Dimension wie Rate). Die Rück-Benennung bleibt neutral bei 1/s, weil (0,0,-1,0,0)
|
|
210
|
+
# auch die Dehnrate ist – siehe _CANON_SYMBOLS.
|
|
211
|
+
"Hz": ((0, 0, -1, 0, 0), 1.0, 0.0), "kHz": ((0, 0, -1, 0, 0), 1e3, 0.0),
|
|
212
|
+
"MHz": ((0, 0, -1, 0, 0), 1e6, 0.0), "GHz": ((0, 0, -1, 0, 0), 1e9, 0.0),
|
|
213
|
+
# Ebener Winkel (0,0,0,0,1) – SI rad, eigene Achse „A". So konvertieren rad/deg/gon
|
|
214
|
+
# untereinander, sind aber gegen dimensionslos (-/%) abgegrenzt. 1 deg = π/180 rad,
|
|
215
|
+
# 1 gon = π/200 rad. (dB als logarithmische Größe bleibt bewusst außen vor.)
|
|
216
|
+
"rad": ((0, 0, 0, 0, 1), 1.0, 0.0), "mrad": ((0, 0, 0, 0, 1), 1e-3, 0.0),
|
|
217
|
+
"deg": ((0, 0, 0, 0, 1), math.pi / 180.0, 0.0),
|
|
218
|
+
"gon": ((0, 0, 0, 0, 1), math.pi / 200.0, 0.0),
|
|
219
|
+
# dimensionslos (0,0,0,0,0)
|
|
220
|
+
"-": ((0, 0, 0, 0, 0), 1.0, 0.0), "": ((0, 0, 0, 0, 0), 1.0, 0.0),
|
|
221
|
+
"%": ((0, 0, 0, 0, 0), 1e-2, 0.0),
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
#: Symbol-Aliasse speziell für die Konvertierung (verlustfrei; ``µm`` ist wie in
|
|
225
|
+
#: :data:`_ALIASES` Mikrometer, nicht ``mm``).
|
|
226
|
+
_CONVERT_ALIASES = {
|
|
227
|
+
"µm": "um", "μm": "um", "µs": "us", "μs": "us",
|
|
228
|
+
"mm²": "mm2", "m²": "m2", "mm³": "mm3", "m³": "m3",
|
|
229
|
+
"°C": "degC", "celsius": "degC",
|
|
230
|
+
"sec": "s", "second": "s", "seconds": "s", "minute": "min", "hour": "h",
|
|
231
|
+
"hertz": "Hz", "Hertz": "Hz", "khz": "kHz", "mhz": "MHz", "ghz": "GHz",
|
|
232
|
+
# Winkel: „grad" bleibt bewusst *nicht* gemappt (mehrdeutig: engl. gradian vs.
|
|
233
|
+
# dt. „Grad" = deg) – nur eindeutige Aliasse.
|
|
234
|
+
"°": "deg", "degree": "deg", "degrees": "deg",
|
|
235
|
+
"radian": "rad", "radians": "rad", "gradian": "gon",
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
#: Vorzugs-Symbol-Tabelle für die kanonische Rück-Benennung hergeleiteter Einheiten:
|
|
239
|
+
#: für ein ``(Dimension, Faktor)`` gewinnt das **erste** passende Symbol. Nur
|
|
240
|
+
#: Offset-freie Einheiten. Bewusste Entscheidungen bei mehrdeutigen Dimensionen:
|
|
241
|
+
#: * Energie ``(2,1,-2,0)`` -> ``J`` (statt einer zusammengesetzten ``N*m``-Form).
|
|
242
|
+
#: * Leistung ``(2,1,-3,0)`` -> ``W``.
|
|
243
|
+
#: * Rate/Frequenz ``(0,0,-1,0)`` -> neutral ``1/s``/``1/ms``, **nicht** ``Hz``:
|
|
244
|
+
#: die Dimension bezeichnet auch die Dehnrate. ``Hz``/``kHz``/``MHz``/``GHz`` sind
|
|
245
|
+
#: als Einheiten erkannt (Eingabe/Konvertierung/QUDT), werden aber nicht
|
|
246
|
+
#: automatisch zugewiesen, um eine Dehnrate nicht fälschlich als Frequenz zu benennen.
|
|
247
|
+
#: * Winkel ``(0,0,0,0,1)`` -> ``rad`` (SI-kohärent); ``deg``/``gon`` nur, wenn das
|
|
248
|
+
#: System explizit darauf basiert (eindeutig über den Faktor, keine Kollision).
|
|
249
|
+
_CANON_SYMBOLS = (
|
|
250
|
+
"m", "mm", "cm", "km", "um", "nm",
|
|
251
|
+
"kg", "g", "t",
|
|
252
|
+
"s", "ms", "us",
|
|
253
|
+
"K",
|
|
254
|
+
"N", "kN", "MN",
|
|
255
|
+
"Pa", "kPa", "MPa", "GPa",
|
|
256
|
+
"J", "kJ", "W", "kW",
|
|
257
|
+
"m/s", "mm/s", "m/s2",
|
|
258
|
+
"1/s", "1/ms",
|
|
259
|
+
"rad", "mrad", "deg", "gon",
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
#: Dimvektor -> Größenname (Komfort/Backward-Compat). Dimensionslos ``(0,0,0,0)`` ist
|
|
263
|
+
#: absichtlich *nicht* benannt, damit ``quantity_of("-")`` wie bisher ``None`` liefert.
|
|
264
|
+
_QUANTITY_BY_DIM = {
|
|
265
|
+
(1, 0, 0, 0, 0): "length", (0, 1, 0, 0, 0): "mass", (0, 0, 1, 0, 0): "time",
|
|
266
|
+
(0, 0, 0, 1, 0): "temperature", (1, 1, -2, 0, 0): "force",
|
|
267
|
+
(-1, 1, -2, 0, 0): "pressure", (2, 1, -2, 0, 0): "energy",
|
|
268
|
+
(2, 1, -3, 0, 0): "power", (1, 0, -1, 0, 0): "velocity",
|
|
269
|
+
(1, 0, -2, 0, 0): "acceleration", (2, 0, 0, 0, 0): "area",
|
|
270
|
+
(3, 0, 0, 0, 0): "volume", (0, 0, -1, 0, 0): "rate", (0, 0, 0, 0, 1): "angle",
|
|
271
|
+
}
|
|
272
|
+
_DIM_BY_QUANTITY = {name: dim for dim, name in _QUANTITY_BY_DIM.items()}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _norm_convert(symbol):
|
|
276
|
+
"""Normalisiere ein Symbol auf einen Schlüssel der Einheitentabelle."""
|
|
277
|
+
if symbol is None:
|
|
278
|
+
return "-"
|
|
279
|
+
text = str(symbol).strip()
|
|
280
|
+
if text in _UNITS:
|
|
281
|
+
return text
|
|
282
|
+
return _CONVERT_ALIASES.get(text, _CONVERT_ALIASES.get(text.lower(), text))
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
#: pint-Dimensionsname -> Index in unserem ``(L, M, T, Θ, A)``-Vektor. Achsen außerhalb
|
|
286
|
+
#: (Strom/Stoffmenge/Lichtstärke) sind nicht darstellbar; der Winkel ist in pint
|
|
287
|
+
#: dimensionslos (dafür die kuratierten rad/deg/gon nutzen).
|
|
288
|
+
_PINT_AXIS = {"[length]": 0, "[mass]": 1, "[time]": 2, "[temperature]": 3}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _pint_dimvector(dimensionality):
|
|
292
|
+
"""pint-``dimensionality`` (Mapping Name->Exponent) -> 5-Achsen-Tupel, oder ``None``.
|
|
293
|
+
|
|
294
|
+
``None``, sobald eine Achse außerhalb ``(L, M, T, Θ, A)`` mit Exponent ≠ 0 auftaucht
|
|
295
|
+
(z. B. elektrischer Strom) – solche Einheiten kann unser Modell nicht darstellen.
|
|
296
|
+
"""
|
|
297
|
+
vec = [0, 0, 0, 0, 0]
|
|
298
|
+
for name, exp in dimensionality.items():
|
|
299
|
+
idx = _PINT_AXIS.get(name)
|
|
300
|
+
if idx is None:
|
|
301
|
+
return None
|
|
302
|
+
vec[idx] = int(exp) if float(exp).is_integer() else exp
|
|
303
|
+
return tuple(vec)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _pint_entry(symbol):
|
|
307
|
+
"""``(dimvektor, factor, offset)`` einer **nicht kuratierten** Einheit via pint.
|
|
308
|
+
|
|
309
|
+
``None``, wenn pint fehlt, das Symbol nicht parsebar ist oder seine Dimension
|
|
310
|
+
außerhalb der fünf Achsen liegt. ``factor`` überführt in die SI-kohärente Einheit
|
|
311
|
+
(pints Basiseinheiten sind m/kg/s/K – deckungsgleich mit der kuratierten Tabelle);
|
|
312
|
+
affine Einheiten (nur Temperatur, z. B. ``degF``) liefern zusätzlich den Offset.
|
|
313
|
+
"""
|
|
314
|
+
if _pint is None:
|
|
315
|
+
return None
|
|
316
|
+
try:
|
|
317
|
+
unit = _pint.Unit(str(symbol).strip())
|
|
318
|
+
except Exception:
|
|
319
|
+
return None
|
|
320
|
+
dim = _pint_dimvector(unit.dimensionality)
|
|
321
|
+
if dim is None:
|
|
322
|
+
return None
|
|
323
|
+
try:
|
|
324
|
+
factor = float(_pint.Quantity(1.0, unit).to_base_units().magnitude)
|
|
325
|
+
offset = 0.0
|
|
326
|
+
except _pint.OffsetUnitCalculusError:
|
|
327
|
+
# affine Einheit: zwei Stützpunkte in Kelvin -> (Steigung, Offset)
|
|
328
|
+
k0 = float(_pint.Quantity(0.0, unit).to("kelvin").magnitude)
|
|
329
|
+
k1 = float(_pint.Quantity(1.0, unit).to("kelvin").magnitude)
|
|
330
|
+
factor, offset = k1 - k0, k0
|
|
331
|
+
return dim, factor, offset
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _lookup(symbol):
|
|
335
|
+
"""``(dim, factor, offset)`` einer Einheit – erst kuratierte Tabelle, dann pint.
|
|
336
|
+
|
|
337
|
+
Die kuratierte Tabelle hat **Vorrang** (feste Faktoren, eigene Winkel-Achse);
|
|
338
|
+
das optionale pint füllt nur Lücken (imperiale/abgeleitete Einheiten außerhalb der
|
|
339
|
+
Tabelle). Ohne pint bleibt das Verhalten unverändert (``None`` für Unbekanntes).
|
|
340
|
+
"""
|
|
341
|
+
entry = _UNITS.get(_norm_convert(symbol))
|
|
342
|
+
if entry is not None:
|
|
343
|
+
return entry
|
|
344
|
+
return _pint_entry(symbol)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def dimension_of(symbol):
|
|
348
|
+
"""Dimensionsvektor ``(L, M, T, Θ, A)`` einer Einheit als Tupel, oder ``None``.
|
|
349
|
+
|
|
350
|
+
:param symbol: Einheiten-Symbol (z. B. ``"MPa"``).
|
|
351
|
+
:return: das Dimvektor-Tupel, oder ``None`` bei unbekannter Einheit.
|
|
352
|
+
"""
|
|
353
|
+
entry = _lookup(symbol)
|
|
354
|
+
return entry[0] if entry else None
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def quantity_of(symbol):
|
|
358
|
+
"""Physikalische Größe einer Einheit (``"force"``/``"length"``/…) oder ``None``.
|
|
359
|
+
|
|
360
|
+
Komfort-Name, aus dem Dimensionsvektor abgeleitet; dimensionslose Einheiten
|
|
361
|
+
(``"-"``/``""``/``"%"``) liefern ``None``.
|
|
362
|
+
|
|
363
|
+
:param symbol: Einheiten-Symbol (z. B. ``"kN"``).
|
|
364
|
+
:return: Name der Größe, oder ``None``.
|
|
365
|
+
"""
|
|
366
|
+
dim = dimension_of(symbol)
|
|
367
|
+
return _QUANTITY_BY_DIM.get(dim) if dim is not None else None
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _entry(symbol, role):
|
|
371
|
+
"""Tabellen-Eintrag für ``symbol`` holen oder mit klarer Meldung scheitern."""
|
|
372
|
+
entry = _lookup(symbol)
|
|
373
|
+
if entry is None:
|
|
374
|
+
raise UnitConversionError(f"unknown {role} unit {symbol!r}")
|
|
375
|
+
return entry
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def convert(value, from_unit, to_unit):
|
|
379
|
+
"""Rechne ``value`` von ``from_unit`` in ``to_unit`` um.
|
|
380
|
+
|
|
381
|
+
Funktioniert für Skalare, Listen/Tupel, NumPy-Arrays und pandas-Series (alle
|
|
382
|
+
elementweise). Beide Einheiten müssen dieselbe **Dimension** haben.
|
|
383
|
+
|
|
384
|
+
:param value: Zahl(en) in ``from_unit``.
|
|
385
|
+
:param from_unit: Quell-Einheit (z. B. ``"N"``).
|
|
386
|
+
:param to_unit: Ziel-Einheit (z. B. ``"kN"``).
|
|
387
|
+
:return: der umgerechnete Wert (gleiche Containerform wie ``value``; Listen/
|
|
388
|
+
Tupel werden als Liste zurückgegeben).
|
|
389
|
+
:raises UnitConversionError: bei unbekannter Einheit oder inkompatibler Dimension.
|
|
390
|
+
"""
|
|
391
|
+
df, ff, fo = _entry(from_unit, "source")
|
|
392
|
+
dt, tf, to = _entry(to_unit, "target")
|
|
393
|
+
if df != dt:
|
|
394
|
+
raise UnitConversionError(
|
|
395
|
+
f"incompatible units: {from_unit!r} -> {to_unit!r} (different dimension)")
|
|
396
|
+
# si = wert*ff + fo ; ziel = (si - to) / tf
|
|
397
|
+
if isinstance(value, (list, tuple)):
|
|
398
|
+
return [(v * ff + fo - to) / tf for v in value]
|
|
399
|
+
return (value * ff + fo - to) / tf
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def convert_factor(from_unit, to_unit):
|
|
403
|
+
"""Reiner Skalierungsfaktor ``from_unit`` → ``to_unit`` (nur Offset-freie Einheiten).
|
|
404
|
+
|
|
405
|
+
Praktisch, um eine ganze Spalte/Reihe mit *einem* Faktor zu multiplizieren.
|
|
406
|
+
|
|
407
|
+
:raises UnitConversionError: bei unbekannten/inkompatiblen Einheiten oder wenn
|
|
408
|
+
eine der Einheiten einen Offset trägt (z. B. ``degC`` – dann ``convert`` nutzen).
|
|
409
|
+
"""
|
|
410
|
+
df, ff, fo = _entry(from_unit, "source")
|
|
411
|
+
dt, tf, to = _entry(to_unit, "target")
|
|
412
|
+
if df != dt:
|
|
413
|
+
raise UnitConversionError(
|
|
414
|
+
f"incompatible units: {from_unit!r} -> {to_unit!r} (different dimension)")
|
|
415
|
+
if fo or to:
|
|
416
|
+
raise UnitConversionError(
|
|
417
|
+
f"offset units need convert(): {from_unit!r} -> {to_unit!r}")
|
|
418
|
+
return ff / tf
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _solve_linear(rows, rhs):
|
|
422
|
+
"""Löse ``rows @ c = rhs`` exakt über ``Fraction`` (Gauß-Elimination).
|
|
423
|
+
|
|
424
|
+
:param rows: Liste von Gleichungen (je eine Liste von Koeffizienten, Länge ``n_unk``).
|
|
425
|
+
:param rhs: rechte Seite (Länge ``n_eq``).
|
|
426
|
+
:return: partikuläre Lösung (freie Variablen ``0``) als Liste ``Fraction``, oder
|
|
427
|
+
``None``, wenn das System keine Lösung hat.
|
|
428
|
+
"""
|
|
429
|
+
n_eq = len(rows)
|
|
430
|
+
n_unk = len(rows[0])
|
|
431
|
+
aug = [[Fraction(x) for x in rows[r]] + [Fraction(rhs[r])] for r in range(n_eq)]
|
|
432
|
+
prow = 0
|
|
433
|
+
pivots = []
|
|
434
|
+
for col in range(n_unk):
|
|
435
|
+
sel = next((r for r in range(prow, n_eq) if aug[r][col] != 0), None)
|
|
436
|
+
if sel is None:
|
|
437
|
+
continue
|
|
438
|
+
aug[prow], aug[sel] = aug[sel], aug[prow]
|
|
439
|
+
pv = aug[prow][col]
|
|
440
|
+
aug[prow] = [x / pv for x in aug[prow]]
|
|
441
|
+
for r in range(n_eq):
|
|
442
|
+
if r != prow and aug[r][col] != 0:
|
|
443
|
+
f = aug[r][col]
|
|
444
|
+
aug[r] = [a - f * b for a, b in zip(aug[r], aug[prow])]
|
|
445
|
+
pivots.append((col, prow))
|
|
446
|
+
prow += 1
|
|
447
|
+
for r in range(prow, n_eq):
|
|
448
|
+
if aug[r][n_unk] != 0:
|
|
449
|
+
return None
|
|
450
|
+
coeffs = [Fraction(0)] * n_unk
|
|
451
|
+
for col, row in pivots:
|
|
452
|
+
coeffs[col] = aug[row][n_unk]
|
|
453
|
+
return coeffs
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _solve_over_basis(basis_vecs, target):
|
|
457
|
+
"""Koeffizienten ``c`` mit ``Σ c_j · basis_vecs[j] = target`` (oder ``None``)."""
|
|
458
|
+
ndim = len(_DIM_NAMES)
|
|
459
|
+
rows = [[v[d] for v in basis_vecs] for d in range(ndim)]
|
|
460
|
+
if not basis_vecs:
|
|
461
|
+
rows = [[] for _ in range(ndim)]
|
|
462
|
+
return _solve_linear(rows, [target[d] for d in range(ndim)])
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _factor_from(basis, coeffs):
|
|
466
|
+
"""System-Faktor ``Π base_factor ** coeff`` – exakt über ``Fraction`` bei ganzzahligen
|
|
467
|
+
Exponenten (vermeidet ``log``/``exp``-Drift), sonst float für rationale Exponenten."""
|
|
468
|
+
frac = Fraction(1)
|
|
469
|
+
fval = 1.0
|
|
470
|
+
for c, b in zip(coeffs, basis):
|
|
471
|
+
if c.denominator == 1:
|
|
472
|
+
frac *= Fraction(b[1]).limit_denominator(10 ** 12) ** c.numerator
|
|
473
|
+
else:
|
|
474
|
+
fval *= b[1] ** float(c)
|
|
475
|
+
return float(frac) * fval
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _canonical(dim, factor):
|
|
479
|
+
"""Bekanntes Vorzugs-Symbol mit exakt ``(dim, factor)`` (offset-frei), sonst ``None``."""
|
|
480
|
+
for sym in _CANON_SYMBOLS:
|
|
481
|
+
d, f, off = _UNITS[sym]
|
|
482
|
+
if off == 0.0 and d == dim and math.isclose(f, factor, rel_tol=1e-9):
|
|
483
|
+
return sym
|
|
484
|
+
return None
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _compose(symbols, coeffs):
|
|
488
|
+
"""Komponiere ein Einheiten-Label aus Basis-Symbolen und Exponenten ``coeffs``."""
|
|
489
|
+
num, den = [], []
|
|
490
|
+
for sym, c in zip(symbols, coeffs):
|
|
491
|
+
if c == 0:
|
|
492
|
+
continue
|
|
493
|
+
exp = abs(c)
|
|
494
|
+
term = sym if exp == 1 else f"{sym}^{exp}"
|
|
495
|
+
(num if c > 0 else den).append(term)
|
|
496
|
+
numerator = "*".join(num) if num else "1"
|
|
497
|
+
return numerator + "/" + "/".join(den) if den else numerator
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
class UnitSystem:
|
|
501
|
+
"""Konsistentes Einheitensystem über Dimensions-Algebra (RFC 0006).
|
|
502
|
+
|
|
503
|
+
Aus den **Basis-Einheiten** (z. B. ``["kN", "mm", "ms"]``) werden die Skalen der
|
|
504
|
+
Basis-Dimensionen gelöst, sodass **jede** abgeleitete Einheit hergeleitet werden
|
|
505
|
+
kann (Spannung → ``GPa``, Energie → ``J``, Geschwindigkeit → ``m/s`` …).
|
|
506
|
+
:meth:`target_for`/:meth:`convert_value` liefern für eine gegebene Einheit die
|
|
507
|
+
System-Einheit derselben Dimension – ideal, um einen ganzen
|
|
508
|
+
:class:`~sdata.sclass.dataframe.DataFrame` in ein gemeinsames System umzurechnen.
|
|
509
|
+
|
|
510
|
+
:param units: iterierbare Basis-Einheiten-Symbole. Redundante, *konsistente*
|
|
511
|
+
Angaben sind erlaubt (z. B. zusätzlich ``"GPa"``); widersprüchliche nicht.
|
|
512
|
+
:raises UnitConversionError: bei unbekannter Einheit, Offset-Einheit als Basis
|
|
513
|
+
oder widersprüchlicher (inkonsistenter) Überbestimmung.
|
|
514
|
+
"""
|
|
515
|
+
|
|
516
|
+
def __init__(self, units):
|
|
517
|
+
self.units = []
|
|
518
|
+
self._basis = [] # Liste von (dimvec, log(factor), symbol) für die Basis
|
|
519
|
+
for symbol in units:
|
|
520
|
+
entry = _lookup(symbol)
|
|
521
|
+
if entry is None:
|
|
522
|
+
raise UnitConversionError(f"unknown unit in system: {symbol!r}")
|
|
523
|
+
dim, factor, offset = entry
|
|
524
|
+
if offset:
|
|
525
|
+
raise UnitConversionError(
|
|
526
|
+
f"offset unit not allowed as system base: {symbol!r}")
|
|
527
|
+
sym = str(symbol).strip()
|
|
528
|
+
self.units.append(sym)
|
|
529
|
+
coeffs = _solve_over_basis([b[0] for b in self._basis], dim)
|
|
530
|
+
if coeffs is None:
|
|
531
|
+
self._basis.append((dim, factor, sym))
|
|
532
|
+
else:
|
|
533
|
+
derived = _factor_from(self._basis, coeffs)
|
|
534
|
+
if not math.isclose(derived, factor, rel_tol=1e-9, abs_tol=1e-12):
|
|
535
|
+
raise UnitConversionError(
|
|
536
|
+
f"inconsistent unit in system: {symbol!r}")
|
|
537
|
+
|
|
538
|
+
def _resolve(self, dim):
|
|
539
|
+
"""``(factor, label)`` für einen Dimensionsvektor, oder ``None`` (nicht abgedeckt)."""
|
|
540
|
+
if not any(dim):
|
|
541
|
+
return None # dimensionslos: nicht anfassen
|
|
542
|
+
coeffs = _solve_over_basis([b[0] for b in self._basis], dim)
|
|
543
|
+
if coeffs is None:
|
|
544
|
+
return None
|
|
545
|
+
factor = _factor_from(self._basis, coeffs)
|
|
546
|
+
label = _canonical(dim, factor) or _compose([b[2] for b in self._basis], coeffs)
|
|
547
|
+
return factor, label
|
|
548
|
+
|
|
549
|
+
def factor_for(self, dim):
|
|
550
|
+
"""Numerischer Faktor der System-Einheit für einen Dimensionsvektor (oder ``None``)."""
|
|
551
|
+
res = self._resolve(tuple(dim))
|
|
552
|
+
return res[0] if res else None
|
|
553
|
+
|
|
554
|
+
def unit_for(self, quantity):
|
|
555
|
+
"""System-Einheiten-Symbol für eine Größe (``"force"``/``"pressure"``/…) oder ``None``."""
|
|
556
|
+
dim = _DIM_BY_QUANTITY.get(quantity)
|
|
557
|
+
if dim is None:
|
|
558
|
+
return None
|
|
559
|
+
res = self._resolve(dim)
|
|
560
|
+
return res[1] if res else None
|
|
561
|
+
|
|
562
|
+
def target_for(self, symbol):
|
|
563
|
+
"""System-Einheiten-Symbol derselben Dimension wie ``symbol`` – sonst ``None``.
|
|
564
|
+
|
|
565
|
+
``None`` heißt: die Einheit ist unbekannt/dimensionslos, oder ihre Dimension
|
|
566
|
+
wird vom System nicht aufgespannt (die Spalte bleibt dann unverändert).
|
|
567
|
+
"""
|
|
568
|
+
dim = dimension_of(symbol)
|
|
569
|
+
if dim is None:
|
|
570
|
+
return None
|
|
571
|
+
res = self._resolve(dim)
|
|
572
|
+
return res[1] if res else None
|
|
573
|
+
|
|
574
|
+
def convert_value(self, value, from_unit):
|
|
575
|
+
"""``(umgerechneter Wert, Ziel-Label)`` für ``value`` in ``from_unit``.
|
|
576
|
+
|
|
577
|
+
Rechnet in die System-Einheit derselben Dimension um (inkl. hergeleiteter
|
|
578
|
+
Einheiten). Skalare, Listen/Tupel, NumPy-Arrays und pandas-Series.
|
|
579
|
+
|
|
580
|
+
:return: Tupel ``(wert, label)``, oder ``None`` wenn ``from_unit`` unbekannt
|
|
581
|
+
ist oder ihre Dimension nicht vom System abgedeckt wird.
|
|
582
|
+
"""
|
|
583
|
+
entry = _lookup(from_unit)
|
|
584
|
+
if entry is None:
|
|
585
|
+
return None
|
|
586
|
+
dim, factor_cur, offset_cur = entry
|
|
587
|
+
res = self._resolve(dim)
|
|
588
|
+
if res is None:
|
|
589
|
+
return None
|
|
590
|
+
sys_factor, label = res
|
|
591
|
+
if isinstance(value, (list, tuple)):
|
|
592
|
+
return [(v * factor_cur + offset_cur) / sys_factor for v in value], label
|
|
593
|
+
return (value * factor_cur + offset_cur) / sys_factor, label
|
|
594
|
+
|
|
595
|
+
def __eq__(self, other):
|
|
596
|
+
return isinstance(other, UnitSystem) and self.units == other.units
|
|
597
|
+
|
|
598
|
+
def __hash__(self):
|
|
599
|
+
return hash(tuple(self.units))
|
|
600
|
+
|
|
601
|
+
def __repr__(self):
|
|
602
|
+
return f"UnitSystem({self.units!r})"
|