pyconvertx 1.0.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.
pyconvert/registry.py ADDED
@@ -0,0 +1,759 @@
1
+ """Central conversion registry: the data PyConvert converts with.
2
+
3
+ Every unit is stored as its size **relative to its category's base unit**, so a
4
+ conversion is a single ratio::
5
+
6
+ y = x * f_source / f_target
7
+
8
+ A registry entry is either
9
+
10
+ * a plain ``float`` -- a purely multiplicative unit (14 of the 15 categories), or
11
+ * a ``(scale, offset)`` tuple -- an affine unit, where
12
+ ``base_value = x * scale + offset``.
13
+
14
+ Only temperature needs offsets. Its scale and offset are written as
15
+ :class:`fractions.Fraction` so that ``5/9`` stays an exact ratio and
16
+ ``100 celsius -> fahrenheit`` returns exactly ``212.0`` rather than
17
+ ``211.99999999999997``. See :mod:`pyconvert.units` for the two code paths.
18
+
19
+ **On accuracy.** Many of these factors are exact by international definition
20
+ (one inch *is* 25.4 mm; it is not a measurement). Those definitions are written
21
+ below as exact rationals and rounded to ``float`` exactly once, at the end. The
22
+ difference is small but real: computing the US gallon as ``231 * 0.0254 ** 3``
23
+ in floating point yields ``0.0037854117839999997``, three steps of rounding
24
+ away from the correctly rounded ``0.003785411784``. Factors that genuinely are
25
+ measurements (particle masses, the solar mass) are marked as such.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from fractions import Fraction
31
+ from math import pi
32
+
33
+ from .exceptions import RegistryError, UnknownCategoryError, UnknownUnitError
34
+
35
+ __all__ = [
36
+ "ALIASES",
37
+ "AMBIGUOUS",
38
+ "BASE_UNITS",
39
+ "CATEGORIES",
40
+ "all_units",
41
+ "build_index",
42
+ "category_of",
43
+ "resolve",
44
+ "units_in",
45
+ ]
46
+
47
+ # --------------------------------------------------------------------------
48
+ # Exact defined constants, as rationals, shared so they cannot disagree.
49
+ # The ``_Q`` suffix marks an exact quantity; ``float()`` is applied once, where
50
+ # the value enters a table.
51
+ # --------------------------------------------------------------------------
52
+ _IN_Q = Fraction(254, 10_000) # inch -> metre
53
+ _FT_Q = 12 * _IN_Q # foot -> metre
54
+ _YD_Q = 3 * _FT_Q # yard -> metre
55
+ _MI_Q = 1760 * _YD_Q # statute mile -> metre
56
+ _LB_Q = Fraction(45_359_237, 100_000_000) # pound -> kilogram
57
+ _LBF_Q = Fraction(44_482_216_152_605, 10 ** 13) # pound-force -> newton
58
+ _GAL_US_Q = 231 * _IN_Q ** 3 # US liquid gallon -> m3
59
+ _GAL_IMP_Q = Fraction(454_609, 100_000_000) # imperial gallon -> m3
60
+ _BTU_Q = Fraction(105_505_585_262, 100_000_000) # BTU_IT -> joule
61
+ _MMHG_Q = Fraction(133_322_387_415, 10 ** 9) # mmHg -> pascal (conventional)
62
+ _HP_Q = 550 * _FT_Q * _LBF_Q # mechanical horsepower -> watt
63
+
64
+ # --------------------------------------------------------------------------
65
+ # 1. Length -- base: metre
66
+ # --------------------------------------------------------------------------
67
+ LENGTH = {
68
+ "m": 1.0,
69
+ "km": 1000.0,
70
+ "hm": 100.0,
71
+ "dam": 10.0,
72
+ "dm": 0.1,
73
+ "cm": 0.01,
74
+ "mm": 1e-3,
75
+ "um": 1e-6,
76
+ "nm": 1e-9,
77
+ "pm": 1e-12,
78
+ "angstrom": 1e-10, # exact
79
+ "in": float(_IN_Q), # exact
80
+ "ft": float(_FT_Q), # exact
81
+ "yd": float(_YD_Q), # exact
82
+ "mi": float(_MI_Q), # exact
83
+ "nmi": 1852.0, # exact (nautical mile)
84
+ "thou": float(_IN_Q / 1000), # exact
85
+ "fathom": float(6 * _FT_Q), # exact
86
+ "rod": float(Fraction(33, 2) * _FT_Q), # exact (16.5 ft)
87
+ "chain": float(66 * _FT_Q), # exact
88
+ "furlong": float(660 * _FT_Q), # exact
89
+ "ly": float(299_792_458 * Fraction(36_525, 100) * 86_400), # exact: c * Julian year
90
+ "au": 149_597_870_700.0, # exact (IAU 2012)
91
+ "pc": 648_000 / pi * 149_597_870_700.0, # exact definition, irrational value
92
+ }
93
+
94
+ # --------------------------------------------------------------------------
95
+ # 2. Mass -- base: kilogram
96
+ # --------------------------------------------------------------------------
97
+ MASS = {
98
+ "kg": 1.0,
99
+ "g": 1e-3,
100
+ "mg": 1e-6,
101
+ "ug": 1e-9,
102
+ "t": 1000.0, # tonne (metric ton), exact
103
+ "quintal": 100.0, # exact -- v0.1.0 had 1000, wrong by 10x
104
+ "lb": float(_LB_Q), # exact
105
+ "oz": float(_LB_Q / 16), # exact -- avoirdupois ounce, NOT fluid
106
+ "st": float(14 * _LB_Q), # exact (stone)
107
+ "ton_us": float(2000 * _LB_Q), # exact (short ton)
108
+ "ton_uk": float(2240 * _LB_Q), # exact (long ton)
109
+ "grain": float(Fraction(6_479_891, 10 ** 11)), # exact
110
+ "carat": 2e-4, # exact (metric carat)
111
+ "u": 1.66053906892e-27, # measured, CODATA 2022
112
+ "m_e": 9.1093837139e-31, # measured, CODATA 2022 (electron)
113
+ "m_earth": 5.9722e24, # measured, IAU nominal
114
+ "m_sun": 1.98841e30, # measured, IAU nominal
115
+ }
116
+
117
+ # --------------------------------------------------------------------------
118
+ # 3. Speed -- base: metre per second
119
+ # --------------------------------------------------------------------------
120
+ SPEED = {
121
+ "m/s": 1.0,
122
+ "km/h": float(Fraction(1000, 3600)), # exact
123
+ "cm/s": 0.01,
124
+ "mm/s": 1e-3,
125
+ "ft/s": float(_FT_Q), # exact
126
+ "mph": float(_MI_Q / 3600), # exact
127
+ "kn": float(Fraction(1852, 3600)), # exact (knot)
128
+ "c": 299_792_458.0, # exact (speed of light in vacuum)
129
+ }
130
+
131
+ # --------------------------------------------------------------------------
132
+ # 4. Area -- base: square metre
133
+ # --------------------------------------------------------------------------
134
+ AREA = {
135
+ "m2": 1.0,
136
+ "km2": 1e6,
137
+ "cm2": 1e-4,
138
+ "mm2": 1e-6,
139
+ "ha": 1e4, # hectare, exact
140
+ "are": 100.0, # exact
141
+ "in2": float(_IN_Q ** 2), # exact
142
+ "ft2": float(_FT_Q ** 2), # exact
143
+ "yd2": float(_YD_Q ** 2), # exact
144
+ "mi2": float(_MI_Q ** 2), # exact
145
+ "ac": float(4840 * _YD_Q ** 2), # exact (acre = 4840 square yards)
146
+ }
147
+
148
+ # --------------------------------------------------------------------------
149
+ # 5. Volume -- base: cubic metre
150
+ # --------------------------------------------------------------------------
151
+ VOLUME = {
152
+ "m3": 1.0,
153
+ "cm3": 1e-6,
154
+ "mm3": 1e-9,
155
+ "km3": 1e9,
156
+ "L": 1e-3, # exact
157
+ "mL": 1e-6,
158
+ "uL": 1e-9,
159
+ "cL": 1e-5,
160
+ "dL": 1e-4,
161
+ "kL": 1.0,
162
+ "in3": float(_IN_Q ** 3), # exact
163
+ "ft3": float(_FT_Q ** 3), # exact
164
+ "yd3": float(_YD_Q ** 3), # exact
165
+ "gal_us": float(_GAL_US_Q), # exact (US liquid gallon)
166
+ "qt_us": float(_GAL_US_Q / 4),
167
+ "pt_us": float(_GAL_US_Q / 8),
168
+ "cup_us": float(_GAL_US_Q / 16),
169
+ "floz_us": float(_GAL_US_Q / 128),
170
+ "tbsp_us": float(_GAL_US_Q / 256),
171
+ "tsp_us": float(_GAL_US_Q / 768),
172
+ "gal_imp": float(_GAL_IMP_Q), # exact (imperial gallon)
173
+ "qt_imp": float(_GAL_IMP_Q / 4),
174
+ "pt_imp": float(_GAL_IMP_Q / 8),
175
+ "floz_imp": float(_GAL_IMP_Q / 160),
176
+ "bbl": float(42 * _GAL_US_Q), # exact (oil barrel)
177
+ }
178
+
179
+ # --------------------------------------------------------------------------
180
+ # 6. Energy -- base: joule
181
+ # --------------------------------------------------------------------------
182
+ ENERGY = {
183
+ "J": 1.0,
184
+ "mJ": 1e-3,
185
+ "kJ": 1e3,
186
+ "MJ": 1e6,
187
+ "GJ": 1e9,
188
+ "cal": 4.184, # exact (thermochemical calorie)
189
+ "kcal": 4184.0, # exact
190
+ "cal_it": 4.1868, # exact (International Table calorie)
191
+ "Wh": 3600.0, # exact
192
+ "kWh": 3.6e6, # exact
193
+ "MWh": 3.6e9, # exact
194
+ "eV": 1.602176634e-19, # exact since the 2019 SI redefinition
195
+ "keV": 1.602176634e-16,
196
+ "MeV": 1.602176634e-13,
197
+ "GeV": 1.602176634e-10,
198
+ "erg": 1e-7, # exact
199
+ "BTU": float(_BTU_Q), # exact (BTU_IT)
200
+ "therm": 1.054804e8, # exact (US therm)
201
+ "ft_lbf": float(_FT_Q * _LBF_Q), # exact
202
+ }
203
+
204
+ # --------------------------------------------------------------------------
205
+ # 7. Power -- base: watt
206
+ # --------------------------------------------------------------------------
207
+ POWER = {
208
+ "W": 1.0,
209
+ "uW": 1e-6,
210
+ "mW": 1e-3, # milliwatt -- lowercase m
211
+ "kW": 1e3,
212
+ "MW": 1e6, # megawatt -- uppercase M
213
+ "GW": 1e9,
214
+ "TW": 1e12,
215
+ "hp": float(_HP_Q), # exact (mechanical/imperial horsepower)
216
+ "hp_metric": 735.49875, # exact (metric horsepower, a.k.a. PS)
217
+ "BTU/h": float(_BTU_Q / 3600), # exact
218
+ }
219
+
220
+ # --------------------------------------------------------------------------
221
+ # 8. Temperature -- base: kelvin. The only affine category.
222
+ #
223
+ # Entries are ``(scale, offset)`` with ``kelvin = x * scale + offset``, written
224
+ # as exact rationals. Fahrenheit's offset is 273.15 - 32 * 5/9, i.e. the kelvin
225
+ # value of 0 degrees Fahrenheit, kept as the arithmetic that defines it.
226
+ # --------------------------------------------------------------------------
227
+ TEMPERATURE = {
228
+ "kelvin": (Fraction(1), Fraction(0)),
229
+ "celsius": (Fraction(1), Fraction(27315, 100)),
230
+ "fahrenheit": (Fraction(5, 9), Fraction(27315, 100) - 32 * Fraction(5, 9)),
231
+ "rankine": (Fraction(5, 9), Fraction(0)),
232
+ }
233
+
234
+ # --------------------------------------------------------------------------
235
+ # 9. Time -- base: second
236
+ # --------------------------------------------------------------------------
237
+ TIME = {
238
+ "s": 1.0,
239
+ "ms": 1e-3,
240
+ "us": 1e-6,
241
+ "ns": 1e-9,
242
+ "ps": 1e-12, # picosecond -- lowercase; see "PS" alias
243
+ "min": 60.0,
244
+ "h": 3600.0,
245
+ "d": 86_400.0,
246
+ "week": 604_800.0,
247
+ "fortnight": 1_209_600.0,
248
+ "yr": 31_557_600.0, # Julian year, exactly 365.25 days
249
+ }
250
+
251
+ # --------------------------------------------------------------------------
252
+ # 10. Pressure -- base: pascal
253
+ # --------------------------------------------------------------------------
254
+ PRESSURE = {
255
+ "Pa": 1.0,
256
+ "hPa": 100.0,
257
+ "kPa": 1e3,
258
+ "MPa": 1e6,
259
+ "GPa": 1e9,
260
+ "bar": 1e5, # exact
261
+ "mbar": 100.0, # exact
262
+ "atm": 101_325.0, # exact
263
+ "torr": float(Fraction(101_325, 760)), # exact (1/760 atm)
264
+ "mmHg": float(_MMHG_Q), # exact by convention
265
+ "inHg": float(_MMHG_Q * _IN_Q / Fraction(1, 1000)), # 25.4 mmHg, exact
266
+ "psi": float(_LBF_Q / _IN_Q ** 2), # exact
267
+ }
268
+
269
+ # --------------------------------------------------------------------------
270
+ # 11. Frequency -- base: hertz
271
+ # --------------------------------------------------------------------------
272
+ FREQUENCY = {
273
+ "Hz": 1.0,
274
+ "mHz": 1e-3, # millihertz -- lowercase m
275
+ "kHz": 1e3,
276
+ "MHz": 1e6, # megahertz -- uppercase M
277
+ "GHz": 1e9,
278
+ "THz": 1e12,
279
+ "rpm": float(Fraction(1, 60)), # exact (revolutions per minute)
280
+ }
281
+
282
+ # --------------------------------------------------------------------------
283
+ # 12. Digital storage -- base: byte
284
+ #
285
+ # Decimal prefixes are powers of 1000 (kB, MB, ...); binary prefixes are powers
286
+ # of 1024 (KiB, MiB, ...). Both are present and they are NOT interchangeable.
287
+ # Bit units are spelled out ("kbit", not "kb") so they can never be confused
288
+ # with byte units by case alone.
289
+ # --------------------------------------------------------------------------
290
+ STORAGE = {
291
+ "B": 1.0,
292
+ "bit": 0.125, # exact
293
+ "kB": 1e3,
294
+ "MB": 1e6,
295
+ "GB": 1e9,
296
+ "TB": 1e12,
297
+ "PB": 1e15,
298
+ "KiB": 1024.0,
299
+ "MiB": 1024.0 ** 2,
300
+ "GiB": 1024.0 ** 3,
301
+ "TiB": 1024.0 ** 4,
302
+ "PiB": 1024.0 ** 5,
303
+ "kbit": 125.0, # 1000 bits
304
+ "Mbit": 1.25e5,
305
+ "Gbit": 1.25e8,
306
+ }
307
+
308
+ # --------------------------------------------------------------------------
309
+ # 13. Angle -- base: radian
310
+ # --------------------------------------------------------------------------
311
+ ANGLE = {
312
+ "rad": 1.0,
313
+ "mrad": 1e-3,
314
+ "deg": pi / 180,
315
+ "grad": pi / 200, # gradian / gon
316
+ "arcmin": pi / 10_800,
317
+ "arcsec": pi / 648_000,
318
+ "turn": 2 * pi, # full revolution
319
+ }
320
+
321
+ # --------------------------------------------------------------------------
322
+ # 14. Electric current -- base: ampere
323
+ # --------------------------------------------------------------------------
324
+ CURRENT = {
325
+ "A": 1.0,
326
+ "kA": 1e3,
327
+ "mA": 1e-3,
328
+ "uA": 1e-6,
329
+ "nA": 1e-9,
330
+ "pA": 1e-12,
331
+ }
332
+
333
+ # --------------------------------------------------------------------------
334
+ # 15. Electric potential -- base: volt
335
+ # --------------------------------------------------------------------------
336
+ POTENTIAL = {
337
+ "V": 1.0,
338
+ "MV": 1e6,
339
+ "kV": 1e3,
340
+ "mV": 1e-3,
341
+ "uV": 1e-6,
342
+ "nV": 1e-9,
343
+ }
344
+
345
+ # --------------------------------------------------------------------------
346
+ # Category table and base units
347
+ # --------------------------------------------------------------------------
348
+ CATEGORIES = {
349
+ "length": LENGTH,
350
+ "mass": MASS,
351
+ "speed": SPEED,
352
+ "area": AREA,
353
+ "volume": VOLUME,
354
+ "energy": ENERGY,
355
+ "power": POWER,
356
+ "temperature": TEMPERATURE,
357
+ "time": TIME,
358
+ "pressure": PRESSURE,
359
+ "frequency": FREQUENCY,
360
+ "storage": STORAGE,
361
+ "angle": ANGLE,
362
+ "current": CURRENT,
363
+ "potential": POTENTIAL,
364
+ }
365
+
366
+ BASE_UNITS = {
367
+ "length": "m",
368
+ "mass": "kg",
369
+ "speed": "m/s",
370
+ "area": "m2",
371
+ "volume": "m3",
372
+ "energy": "J",
373
+ "power": "W",
374
+ "temperature": "kelvin",
375
+ "time": "s",
376
+ "pressure": "Pa",
377
+ "frequency": "Hz",
378
+ "storage": "B",
379
+ "angle": "rad",
380
+ "current": "A",
381
+ "potential": "V",
382
+ }
383
+
384
+ # --------------------------------------------------------------------------
385
+ # Aliases: alternative spellings that resolve to a canonical symbol.
386
+ #
387
+ # Lookup is case-sensitive, because it has to be: mW is a milliwatt and MW a
388
+ # megawatt, mHz is a millihertz and MHz a megahertz, and KB and KiB differ by
389
+ # 2.4%. Folding case would make those indistinguishable. Aliases below cover
390
+ # the spellings people actually type instead.
391
+ #
392
+ # Deliberately absent: bare "C", "F" and "R" (they are the SI symbols for
393
+ # coulomb, farad and roentgen, so claiming them for temperature would block
394
+ # those categories later); bare "pt" (point or pint); bare "ton" (three
395
+ # different tons differing by up to 12%). Those must be spelled out.
396
+ # --------------------------------------------------------------------------
397
+ ALIASES = {
398
+ # -- length ----------------------------------------------------------
399
+ "metre": "m", "meter": "m", "metres": "m", "meters": "m",
400
+ "kilometre": "km", "kilometer": "km", "kilometres": "km", "kilometers": "km",
401
+ "centimetre": "cm", "centimeter": "cm", "centimetres": "cm", "centimeters": "cm",
402
+ "millimetre": "mm", "millimeter": "mm",
403
+ "micrometre": "um", "micrometer": "um", "micron": "um",
404
+ "nanometre": "nm", "nanometer": "nm", "picometre": "pm", "picometer": "pm",
405
+ "decimetre": "dm", "decimeter": "dm", "hectometre": "hm", "hectometer": "hm",
406
+ "decametre": "dam", "decameter": "dam",
407
+ "inch": "in", "inches": "in",
408
+ "foot": "ft", "feet": "ft",
409
+ "yard": "yd", "yards": "yd",
410
+ "mile": "mi", "miles": "mi",
411
+ "nautical_mile": "nmi", "nautical_miles": "nmi",
412
+ "mil": "thou",
413
+ "fathoms": "fathom", "rods": "rod", "chains": "chain", "furlongs": "furlong",
414
+ "lightyear": "ly", "light_year": "ly", "lightyears": "ly", "light_years": "ly",
415
+ "astronomical_unit": "au", "parsec": "pc", "parsecs": "pc",
416
+ "Å": "angstrom", "angstroms": "angstrom",
417
+
418
+ # -- mass ------------------------------------------------------------
419
+ "kilogram": "kg", "kilogramme": "kg", "kilograms": "kg", "kgs": "kg",
420
+ "gram": "g", "gramme": "g", "grams": "g",
421
+ "milligram": "mg", "milligrams": "mg", "microgram": "ug", "micrograms": "ug",
422
+ "tonne": "t", "tonnes": "t", "metric_ton": "t", "metric_tonne": "t",
423
+ "quintals": "quintal",
424
+ "pound": "lb", "pounds": "lb", "lbs": "lb",
425
+ "ounce": "oz", "ounces": "oz",
426
+ "stone": "st", "stones": "st",
427
+ "short_ton": "ton_us", "us_ton": "ton_us",
428
+ "long_ton": "ton_uk", "imperial_ton": "ton_uk", "uk_ton": "ton_uk",
429
+ "grains": "grain", "carats": "carat",
430
+ "amu": "u", "dalton": "u", "Da": "u", "atomic_mass_unit": "u",
431
+ "electron_mass": "m_e",
432
+ "earth_mass": "m_earth", "earthmass": "m_earth",
433
+ "solar_mass": "m_sun", "solarmass": "m_sun", "solar_masses": "m_sun",
434
+
435
+ # -- speed -----------------------------------------------------------
436
+ "mps": "m/s", "m_s": "m/s", "meters_per_second": "m/s", "metres_per_second": "m/s",
437
+ "kmh": "km/h", "kph": "km/h", "km_h": "km/h",
438
+ "kilometers_per_hour": "km/h", "kilometres_per_hour": "km/h",
439
+ "mi/h": "mph", "miles_per_hour": "mph",
440
+ "fps": "ft/s", "ft_s": "ft/s", "feet_per_second": "ft/s",
441
+ "knot": "kn", "knots": "kn",
442
+ "speed_of_light": "c",
443
+
444
+ # -- area ------------------------------------------------------------
445
+ "m^2": "m2", "m²": "m2", "sqm": "m2", "square_meter": "m2", "square_metre": "m2",
446
+ "km^2": "km2", "km²": "km2", "square_kilometer": "km2", "square_kilometre": "km2",
447
+ "cm^2": "cm2", "cm²": "cm2", "square_centimeter": "cm2", "square_centimetre": "cm2",
448
+ "mm^2": "mm2", "mm²": "mm2",
449
+ "in^2": "in2", "in²": "in2", "sqin": "in2", "square_inch": "in2",
450
+ "ft^2": "ft2", "ft²": "ft2", "sqft": "ft2",
451
+ "square_foot": "ft2", "square_feet": "ft2",
452
+ "yd^2": "yd2", "yd²": "yd2", "square_yard": "yd2",
453
+ "mi^2": "mi2", "mi²": "mi2", "square_mile": "mi2",
454
+ "hectare": "ha", "hectares": "ha",
455
+ "acre": "ac", "acres": "ac",
456
+ "ares": "are",
457
+
458
+ # -- volume ----------------------------------------------------------
459
+ "m^3": "m3", "m³": "m3", "cubic_meter": "m3", "cubic_metre": "m3",
460
+ "cm^3": "cm3", "cm³": "cm3", "cc": "cm3", "cubic_centimeter": "cm3",
461
+ "mm^3": "mm3", "mm³": "mm3", "km^3": "km3", "km³": "km3",
462
+ "in^3": "in3", "in³": "in3", "cubic_inch": "in3",
463
+ "ft^3": "ft3", "ft³": "ft3", "cuft": "ft3",
464
+ "cubic_foot": "ft3", "cubic_feet": "ft3",
465
+ "yd^3": "yd3", "yd³": "yd3", "cubic_yard": "yd3",
466
+ "l": "L", "liter": "L", "litre": "L", "liters": "L", "litres": "L",
467
+ "ml": "mL", "milliliter": "mL", "millilitre": "mL",
468
+ "cl": "cL", "centiliter": "cL", "dl": "dL", "deciliter": "dL",
469
+ "kl": "kL", "kiloliter": "kL", "ul": "uL", "microliter": "uL", "microlitre": "uL",
470
+ "gal": "gal_us", "gallon": "gal_us", "gallons": "gal_us", "gallon_us": "gal_us",
471
+ "quart": "qt_us", "quarts": "qt_us",
472
+ "pint": "pt_us", "pints": "pt_us",
473
+ "cup": "cup_us", "cups": "cup_us",
474
+ "floz": "floz_us", "fluid_ounce": "floz_us", "fl_oz": "floz_us",
475
+ "tbsp": "tbsp_us", "tablespoon": "tbsp_us",
476
+ "tsp": "tsp_us", "teaspoon": "tsp_us",
477
+ "gallon_imp": "gal_imp", "imperial_gallon": "gal_imp",
478
+ "imperial_pint": "pt_imp", "imperial_quart": "qt_imp",
479
+ "imperial_fluid_ounce": "floz_imp",
480
+ "barrel": "bbl", "barrels": "bbl", "oil_barrel": "bbl",
481
+
482
+ # -- energy ----------------------------------------------------------
483
+ "joule": "J", "joules": "J",
484
+ "kilojoule": "kJ", "megajoule": "MJ", "gigajoule": "GJ", "millijoule": "mJ",
485
+ "calorie": "cal", "calories": "cal",
486
+ "kilocalorie": "kcal", "kilocalories": "kcal", "Cal": "kcal",
487
+ "watt_hour": "Wh", "watthour": "Wh",
488
+ "kilowatt_hour": "kWh", "kilowatthour": "kWh", "kwh": "kWh",
489
+ "megawatt_hour": "MWh",
490
+ "electronvolt": "eV", "electron_volt": "eV",
491
+ "ergs": "erg",
492
+ "btu": "BTU", "Btu": "BTU", "british_thermal_unit": "BTU",
493
+ "therms": "therm",
494
+ "foot_pound": "ft_lbf", "ft_lb": "ft_lbf", "ftlb": "ft_lbf",
495
+
496
+ # -- power -----------------------------------------------------------
497
+ "watt": "W", "watts": "W",
498
+ "kilowatt": "kW", "megawatt": "MW", "gigawatt": "GW", "terawatt": "TW",
499
+ "milliwatt": "mW", "microwatt": "uW",
500
+ "horsepower": "hp", "HP": "hp", "mechanical_horsepower": "hp",
501
+ "PS": "hp_metric", "cv": "hp_metric", "metric_horsepower": "hp_metric",
502
+ "btu/h": "BTU/h", "BTU/hr": "BTU/h", "btu_per_hour": "BTU/h",
503
+
504
+ # -- temperature -----------------------------------------------------
505
+ "°C": "celsius", "degC": "celsius", "deg_c": "celsius", "centigrade": "celsius",
506
+ "°F": "fahrenheit", "degF": "fahrenheit", "deg_f": "fahrenheit",
507
+ "K": "kelvin", "°K": "kelvin", "kelvins": "kelvin",
508
+ "°R": "rankine", "degR": "rankine", "deg_r": "rankine",
509
+
510
+ # -- time ------------------------------------------------------------
511
+ "sec": "s", "secs": "s", "second": "s", "seconds": "s",
512
+ "msec": "ms", "millisecond": "ms", "milliseconds": "ms",
513
+ "usec": "us", "microsecond": "us", "microseconds": "us",
514
+ "nsec": "ns", "nanosecond": "ns", "picosecond": "ps",
515
+ "minute": "min", "minutes": "min", "mins": "min",
516
+ "hour": "h", "hours": "h", "hr": "h", "hrs": "h",
517
+ "day": "d", "days": "d",
518
+ "weeks": "week", "fortnights": "fortnight",
519
+ "year": "yr", "years": "yr", "julian_year": "yr",
520
+
521
+ # -- pressure --------------------------------------------------------
522
+ "pascal": "Pa", "pascals": "Pa",
523
+ "kilopascal": "kPa", "megapascal": "MPa", "gigapascal": "GPa",
524
+ "hectopascal": "hPa",
525
+ "bars": "bar", "millibar": "mbar", "millibars": "mbar",
526
+ "atmosphere": "atm", "atmospheres": "atm",
527
+ "Torr": "torr",
528
+ "mmhg": "mmHg", "mm_hg": "mmHg",
529
+ "inhg": "inHg", "in_hg": "inHg",
530
+ "PSI": "psi", "lbf/in2": "psi", "pounds_per_square_inch": "psi",
531
+
532
+ # -- frequency -------------------------------------------------------
533
+ "hertz": "Hz", "hz": "Hz",
534
+ "kilohertz": "kHz", "megahertz": "MHz", "gigahertz": "GHz",
535
+ "terahertz": "THz", "millihertz": "mHz",
536
+ "RPM": "rpm", "rev/min": "rpm", "revolutions_per_minute": "rpm",
537
+
538
+ # -- storage ---------------------------------------------------------
539
+ "byte": "B", "bytes": "B", "bits": "bit",
540
+ "KB": "kB", "kilobyte": "kB", "kilobytes": "kB",
541
+ "megabyte": "MB", "megabytes": "MB",
542
+ "gigabyte": "GB", "gigabytes": "GB",
543
+ "terabyte": "TB", "terabytes": "TB", "petabyte": "PB", "petabytes": "PB",
544
+ "kibibyte": "KiB", "mebibyte": "MiB", "gibibyte": "GiB",
545
+ "tebibyte": "TiB", "pebibyte": "PiB",
546
+ "kilobit": "kbit", "megabit": "Mbit", "gigabit": "Gbit",
547
+
548
+ # -- angle -----------------------------------------------------------
549
+ "°": "deg", "degree": "deg", "degrees": "deg",
550
+ "radian": "rad", "radians": "rad", "milliradian": "mrad",
551
+ "gradian": "grad", "gradians": "grad", "gon": "grad",
552
+ "arcminute": "arcmin", "arcminutes": "arcmin",
553
+ "arcsecond": "arcsec", "arcseconds": "arcsec",
554
+ "revolution": "turn", "revolutions": "turn", "rev": "turn", "turns": "turn",
555
+
556
+ # -- current ---------------------------------------------------------
557
+ "ampere": "A", "amperes": "A", "amp": "A", "amps": "A",
558
+ "kiloampere": "kA", "milliampere": "mA", "microampere": "uA",
559
+ "nanoampere": "nA", "picoampere": "pA",
560
+
561
+ # -- potential -------------------------------------------------------
562
+ "volt": "V", "volts": "V",
563
+ "megavolt": "MV", "kilovolt": "kV",
564
+ "millivolt": "mV", "microvolt": "uV", "nanovolt": "nV",
565
+ }
566
+
567
+ # --------------------------------------------------------------------------
568
+ # Micro-sign aliases, generated rather than typed.
569
+ #
570
+ # "u" is written two ways that look identical on screen: MICRO SIGN (U+00B5),
571
+ # which is what a Mac's Option-M produces, and GREEK SMALL LETTER MU (U+03BC).
572
+ # A dict treats them as different keys, so "microseconds" typed by one user
573
+ # would silently fail for another. Both map to the ASCII spelling.
574
+ # Generated, so a new micro-prefixed unit picks this up automatically.
575
+ # --------------------------------------------------------------------------
576
+ _MICRO_SIGNS = ("\u00b5", "\u03bc")
577
+
578
+
579
+ def add_micro_aliases(categories: dict, aliases: dict) -> None:
580
+ """Map both micro signs onto the ASCII ``u`` spelling of each unit.
581
+
582
+ ``len > 1`` excludes ``"u"`` itself, the atomic mass unit, which is a unit
583
+ and not a prefix.
584
+ """
585
+ for table in categories.values():
586
+ for symbol in table:
587
+ if len(symbol) > 1 and symbol.startswith("u"):
588
+ for sign in _MICRO_SIGNS:
589
+ aliases.setdefault(sign + symbol[1:], symbol)
590
+
591
+
592
+ def build_index(
593
+ categories: dict, aliases: dict, base_units: dict
594
+ ) -> tuple[dict, list[str]]:
595
+ """Build the symbol index and collect every inconsistency found.
596
+
597
+ Returns ``(index, problems)``, where ``index`` maps a canonical symbol to
598
+ ``(category, entry)`` and ``problems`` is empty when the registry is sound.
599
+ Problems are collected rather than raised one at a time, so editing the
600
+ registry surfaces every mistake in a single run.
601
+
602
+ The inputs are arguments rather than module globals on purpose: that is what
603
+ lets a test hand it a deliberately broken registry and assert on the report.
604
+
605
+ >>> build_index({"a": {"x": 1.0}, "b": {"x": 1.0}},
606
+ ... {}, {"a": "x", "b": "x"})[1]
607
+ ["symbol 'x' is defined in both 'a' and 'b'"]
608
+ >>> build_index({"a": {"x": 1.0}}, {"q": "nope"}, {"a": "x"})[1]
609
+ ["alias 'q' points at unknown unit 'nope'"]
610
+ """
611
+ index: dict[str, tuple[str, object]] = {}
612
+ problems: list[str] = []
613
+
614
+ for category, table in categories.items():
615
+ for symbol, entry in table.items():
616
+ if symbol in index:
617
+ problems.append(
618
+ f"symbol {symbol!r} is defined in both "
619
+ f"{index[symbol][0]!r} and {category!r}"
620
+ )
621
+ index[symbol] = (category, entry)
622
+
623
+ for alias, target in aliases.items():
624
+ if alias in index:
625
+ problems.append(
626
+ f"alias {alias!r} shadows the canonical symbol {alias!r} "
627
+ f"in {index[alias][0]!r}"
628
+ )
629
+ if target not in index:
630
+ problems.append(f"alias {alias!r} points at unknown unit {target!r}")
631
+
632
+ for category, base in base_units.items():
633
+ if category not in categories:
634
+ problems.append(f"BASE_UNITS has category {category!r} with no table")
635
+ elif base not in categories[category]:
636
+ problems.append(f"base unit {base!r} is not present in {category!r}")
637
+
638
+ for category in categories:
639
+ if category not in base_units:
640
+ problems.append(f"category {category!r} has no declared base unit")
641
+ continue
642
+ base = base_units[category]
643
+ if base not in categories[category]:
644
+ continue # already reported by the loop above; don't crash on it
645
+ entry = categories[category][base]
646
+ scale, offset = entry if isinstance(entry, tuple) else (entry, 0)
647
+ if scale != 1 or offset != 0:
648
+ problems.append(
649
+ f"base unit {base!r} of {category!r} must have "
650
+ f"scale 1 and offset 0, has {entry!r}"
651
+ )
652
+
653
+ return index, problems
654
+
655
+
656
+ add_micro_aliases(CATEGORIES, ALIASES)
657
+ _INDEX, _problems = build_index(CATEGORIES, ALIASES, BASE_UNITS)
658
+
659
+ if _problems:
660
+ raise RegistryError(
661
+ "the unit registry is inconsistent:\n - " + "\n - ".join(_problems)
662
+ )
663
+
664
+ del _problems
665
+
666
+ #: Every spelling the registry accepts: canonical symbols plus aliases.
667
+ _SPELLINGS = sorted(set(_INDEX) | set(ALIASES))
668
+
669
+
670
+ # --------------------------------------------------------------------------
671
+ # Lookup API
672
+ # --------------------------------------------------------------------------
673
+ #: Spellings the registry refuses on purpose, each with the fix.
674
+ #:
675
+ #: These are not typos, they are genuinely ambiguous: resolving them to one
676
+ #: arbitrary meaning would return a confidently wrong number. Fuzzy matching is
677
+ #: no help here either -- it answers "C" with "c", the speed of light -- so each
678
+ #: one carries an explicit message instead.
679
+ AMBIGUOUS = {
680
+ "C": "use 'celsius' or 'degC' (bare 'C' is the SI symbol for coulomb)",
681
+ "F": "use 'fahrenheit' or 'degF' (bare 'F' is the SI symbol for farad)",
682
+ "R": "use 'rankine' or 'degR' (bare 'R' is the symbol for roentgen)",
683
+ "ton": (
684
+ "'ton' means three different masses: use 't' (tonne, 1000 kg), "
685
+ "'ton_us' (short, 907.18474 kg) or 'ton_uk' (long, 1016.0469088 kg)"
686
+ ),
687
+ "tons": "see 'ton': use 't', 'ton_us' or 'ton_uk'",
688
+ "pt": "'pt' is both point and pint: use 'pt_us' or 'pt_imp' for pints",
689
+ "b": "use 'bit' or 'B' (byte) -- 'b' and 'B' differ only by case",
690
+ "kb": "use 'kbit' (1000 bits) or 'kB' (1000 bytes)",
691
+ "mb": "use 'Mbit' (10**6 bits) or 'MB' (10**6 bytes)",
692
+ "gb": "use 'Gbit' (10**9 bits) or 'GB' (10**9 bytes)",
693
+ "tb": "use 'TB' (10**12 bytes) or 'TiB' (2**40 bytes)",
694
+ "cal_cap": "use 'kcal' for the food Calorie, or 'cal' for the gram calorie",
695
+ "fl_ounce": "use 'floz_us' or 'floz_imp'",
696
+ "hp_imp": "use 'hp' (mechanical) or 'hp_metric' (PS)",
697
+ }
698
+
699
+
700
+ def as_affine(entry: object) -> tuple[object, object]:
701
+ """Return ``(scale, offset)`` for a registry entry.
702
+
703
+ Plain numeric entries are purely multiplicative, so their offset is zero.
704
+ """
705
+ if isinstance(entry, tuple):
706
+ return entry
707
+ return entry, 0
708
+
709
+
710
+ def resolve(unit: object) -> tuple[str, str, object]:
711
+ """Resolve any accepted spelling to ``(symbol, category, entry)``.
712
+
713
+ >>> resolve("kilometre")[:2]
714
+ ('km', 'length')
715
+ >>> resolve("MiB")[:2]
716
+ ('MiB', 'storage')
717
+
718
+ Raises :class:`~pyconvert.exceptions.UnknownUnitError` otherwise, with a
719
+ suggestion where one is close enough to be useful.
720
+ """
721
+ if not isinstance(unit, str):
722
+ raise UnknownUnitError(unit, _SPELLINGS)
723
+ symbol = ALIASES.get(unit, unit)
724
+ try:
725
+ category, entry = _INDEX[symbol]
726
+ except KeyError:
727
+ if unit in AMBIGUOUS:
728
+ raise UnknownUnitError(unit, hint=AMBIGUOUS[unit]) from None
729
+ raise UnknownUnitError(unit, _SPELLINGS) from None
730
+ return symbol, category, entry
731
+
732
+
733
+ def category_of(unit: object) -> str:
734
+ """Return the category a unit belongs to.
735
+
736
+ >>> category_of("psi")
737
+ 'pressure'
738
+ """
739
+ return resolve(unit)[1]
740
+
741
+
742
+ def units_in(category: str) -> list[str]:
743
+ """Return the canonical symbols of one category, sorted.
744
+
745
+ >>> units_in("temperature")
746
+ ['celsius', 'fahrenheit', 'kelvin', 'rankine']
747
+ """
748
+ try:
749
+ table = CATEGORIES[category]
750
+ except KeyError:
751
+ raise UnknownCategoryError(category, list(CATEGORIES)) from None
752
+ return sorted(table)
753
+
754
+
755
+ def all_units(include_aliases: bool = False) -> list[str]:
756
+ """Return every canonical symbol, sorted; with aliases if asked."""
757
+ if include_aliases:
758
+ return list(_SPELLINGS)
759
+ return sorted(_INDEX)