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.
Files changed (98) hide show
  1. sdata/__init__.py +140 -0
  2. sdata/base.py +684 -0
  3. sdata/contrib/__init__.py +0 -0
  4. sdata/contrib/piexif/__init__.py +12 -0
  5. sdata/contrib/piexif/_common.py +94 -0
  6. sdata/contrib/piexif/_dump.py +346 -0
  7. sdata/contrib/piexif/_exceptions.py +2 -0
  8. sdata/contrib/piexif/_exif.py +638 -0
  9. sdata/contrib/piexif/_insert.py +60 -0
  10. sdata/contrib/piexif/_load.py +270 -0
  11. sdata/contrib/piexif/_remove.py +56 -0
  12. sdata/contrib/piexif/_transplant.py +45 -0
  13. sdata/contrib/piexif/_webp.py +240 -0
  14. sdata/contrib/piexif/helper.py +66 -0
  15. sdata/contrib/ranger/__init__.py +8 -0
  16. sdata/contrib/ranger/collections/__init__.py +0 -0
  17. sdata/contrib/ranger/collections/rangebucketmap.py +346 -0
  18. sdata/contrib/ranger/collections/rangemap.py +396 -0
  19. sdata/contrib/ranger/collections/rangeset.py +483 -0
  20. sdata/contrib/ranger/range/__init__.py +0 -0
  21. sdata/contrib/ranger/range/cut.py +285 -0
  22. sdata/contrib/ranger/range/range.py +644 -0
  23. sdata/contrib/sobol_seq.py +394 -0
  24. sdata/contrib/sortedcontainers/LICENSE +13 -0
  25. sdata/contrib/sortedcontainers/__init__.py +74 -0
  26. sdata/contrib/sortedcontainers/sorteddict.py +789 -0
  27. sdata/contrib/sortedcontainers/sortedlist.py +2646 -0
  28. sdata/contrib/sortedcontainers/sortedset.py +733 -0
  29. sdata/contrib/sqlitedict.py +598 -0
  30. sdata/contrib/timeflake/__init__.py +40 -0
  31. sdata/contrib/timeflake/flake.py +69 -0
  32. sdata/contrib/timeflake/utils.py +47 -0
  33. sdata/deprecated/__init__.py +10 -0
  34. sdata/deprecated/data.py +1503 -0
  35. sdata/did/__init__.py +129 -0
  36. sdata/did/_http.py +67 -0
  37. sdata/did/base58btc.py +49 -0
  38. sdata/did/did_github.py +152 -0
  39. sdata/did/did_peer4.py +155 -0
  40. sdata/did/did_web.py +222 -0
  41. sdata/did/diddoc.py +75 -0
  42. sdata/did/ed25519.py +225 -0
  43. sdata/did/eddsa.py +107 -0
  44. sdata/did/errors.py +40 -0
  45. sdata/did/issue_vc.py +193 -0
  46. sdata/did/jose.py +126 -0
  47. sdata/did/keys.py +104 -0
  48. sdata/did/utils_didkey.py +161 -0
  49. sdata/did/verify_vp.py +158 -0
  50. sdata/doe.py +101 -0
  51. sdata/dtypes.py +510 -0
  52. sdata/experiments/__init__.py +331 -0
  53. sdata/experiments/blob0.py +131 -0
  54. sdata/experiments/file.py +42 -0
  55. sdata/experiments/filegroup.py +144 -0
  56. sdata/experiments/image_old.py +313 -0
  57. sdata/experiments/ks2.py +15 -0
  58. sdata/experiments/lapshear.py +8 -0
  59. sdata/experiments/material.py +7 -0
  60. sdata/experiments/parameters_deprecated.py +394 -0
  61. sdata/experiments/workbook.py +226 -0
  62. sdata/format.py +125 -0
  63. sdata/imagemeta.py +533 -0
  64. sdata/interactive.py +118 -0
  65. sdata/iolib/__init__.py +51 -0
  66. sdata/iolib/hashtree.py +264 -0
  67. sdata/iolib/json1sqlitestore.py +554 -0
  68. sdata/iolib/jsonsqlitestore.py +704 -0
  69. sdata/iolib/pud.py +242 -0
  70. sdata/iolib/reader.py +262 -0
  71. sdata/iolib/writer.py +419 -0
  72. sdata/metadata.py +882 -0
  73. sdata/parameter.py +414 -0
  74. sdata/schema.py +273 -0
  75. sdata/sclass/__init__.py +111 -0
  76. sdata/sclass/bfo_classes.py +362 -0
  77. sdata/sclass/blob.py +362 -0
  78. sdata/sclass/content.py +87 -0
  79. sdata/sclass/dataframe.py +1251 -0
  80. sdata/sclass/dataframegroup.py +187 -0
  81. sdata/sclass/dependency_graph.py +85 -0
  82. sdata/sclass/filereference.py +124 -0
  83. sdata/sclass/image.py +218 -0
  84. sdata/sclass/lazy_namespace.py +155 -0
  85. sdata/sclass/process.py +383 -0
  86. sdata/sclass/process_graph.py +221 -0
  87. sdata/sclass/zipfile.py +135 -0
  88. sdata/semantic.py +346 -0
  89. sdata/suuid.py +136 -0
  90. sdata/timestamp.py +336 -0
  91. sdata/tools.py +14 -0
  92. sdata/units.py +602 -0
  93. sdata/vocab.py +149 -0
  94. sdata-1.5.0.dist-info/METADATA +264 -0
  95. sdata-1.5.0.dist-info/RECORD +98 -0
  96. sdata-1.5.0.dist-info/WHEEL +5 -0
  97. sdata-1.5.0.dist-info/licenses/LICENSE-MIT +21 -0
  98. 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) Über­bestimmung.
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})"