unitx 0.13.2__tar.gz → 0.13.4__tar.gz

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 (106) hide show
  1. {unitx-0.13.2 → unitx-0.13.4}/CHANGELOG.md +26 -0
  2. {unitx-0.13.2 → unitx-0.13.4}/PKG-INFO +5 -5
  3. {unitx-0.13.2 → unitx-0.13.4}/README.md +4 -4
  4. {unitx-0.13.2 → unitx-0.13.4}/docs/README.md +2 -1
  5. {unitx-0.13.2 → unitx-0.13.4}/docs/display.md +20 -0
  6. unitx-0.13.4/docs/serialization.md +225 -0
  7. {unitx-0.13.2 → unitx-0.13.4}/docs/typing.md +5 -0
  8. {unitx-0.13.2 → unitx-0.13.4}/examples/README.md +4 -3
  9. unitx-0.13.4/examples/arrhenius_fit.ipynb +236 -0
  10. {unitx-0.13.2 → unitx-0.13.4}/pyproject.toml +1 -1
  11. {unitx-0.13.2 → unitx-0.13.4}/pyrightconfig.json +2 -0
  12. {unitx-0.13.2 → unitx-0.13.4}/src/unitx/_display.py +21 -0
  13. {unitx-0.13.2 → unitx-0.13.4}/src/unitx/affineunit.py +1 -1
  14. {unitx-0.13.2 → unitx-0.13.4}/src/unitx/exceptions.py +10 -0
  15. unitx-0.13.4/src/unitx/serialization.py +354 -0
  16. {unitx-0.13.2 → unitx-0.13.4}/tests/test_display.py +40 -0
  17. unitx-0.13.4/tests/test_serialization.py +548 -0
  18. {unitx-0.13.2 → unitx-0.13.4}/tests/test_typing.py +25 -0
  19. unitx-0.13.4/tests/typing/check_serialization.py +32 -0
  20. {unitx-0.13.2 → unitx-0.13.4}/.github/workflows/ci.yml +0 -0
  21. {unitx-0.13.2 → unitx-0.13.4}/.github/workflows/release.yml +0 -0
  22. {unitx-0.13.2 → unitx-0.13.4}/.gitignore +0 -0
  23. {unitx-0.13.2 → unitx-0.13.4}/CONTRIBUTING.md +0 -0
  24. {unitx-0.13.2 → unitx-0.13.4}/LICENSE +0 -0
  25. {unitx-0.13.2 → unitx-0.13.4}/benchmarks/numpy_writes.py +0 -0
  26. {unitx-0.13.2 → unitx-0.13.4}/conftest.py +0 -0
  27. {unitx-0.13.2 → unitx-0.13.4}/docs/affine.md +0 -0
  28. {unitx-0.13.2 → unitx-0.13.4}/docs/catalogue.md +0 -0
  29. {unitx-0.13.2 → unitx-0.13.4}/docs/custom-units.md +0 -0
  30. {unitx-0.13.2 → unitx-0.13.4}/docs/images/blackbody.svg +0 -0
  31. {unitx-0.13.2 → unitx-0.13.4}/docs/migration.md +0 -0
  32. {unitx-0.13.2 → unitx-0.13.4}/docs/numpy.md +0 -0
  33. {unitx-0.13.2 → unitx-0.13.4}/docs/scalars.md +0 -0
  34. {unitx-0.13.2 → unitx-0.13.4}/docs/stdlib.md +0 -0
  35. {unitx-0.13.2 → unitx-0.13.4}/docs/units.md +0 -0
  36. {unitx-0.13.2 → unitx-0.13.4}/examples/blackbody.ipynb +0 -0
  37. {unitx-0.13.2 → unitx-0.13.4}/examples/damped_oscillator.ipynb +0 -0
  38. {unitx-0.13.2 → unitx-0.13.4}/examples/gaussian_wavepacket.ipynb +0 -0
  39. {unitx-0.13.2 → unitx-0.13.4}/examples/optical_attenuation.ipynb +0 -0
  40. {unitx-0.13.2 → unitx-0.13.4}/examples/pendulum_uncertainty.ipynb +0 -0
  41. {unitx-0.13.2 → unitx-0.13.4}/src/unitx/__init__.py +0 -0
  42. {unitx-0.13.2 → unitx-0.13.4}/src/unitx/_constnamespace.py +0 -0
  43. {unitx-0.13.2 → unitx-0.13.4}/src/unitx/_conversion.py +0 -0
  44. {unitx-0.13.2 → unitx-0.13.4}/src/unitx/_immutable.py +0 -0
  45. {unitx-0.13.2 → unitx-0.13.4}/src/unitx/_numerics.py +0 -0
  46. {unitx-0.13.2 → unitx-0.13.4}/src/unitx/_numpy.py +0 -0
  47. {unitx-0.13.2 → unitx-0.13.4}/src/unitx/_numpy_typing.py +0 -0
  48. {unitx-0.13.2 → unitx-0.13.4}/src/unitx/_unicode.py +0 -0
  49. {unitx-0.13.2 → unitx-0.13.4}/src/unitx/affinequantity.py +0 -0
  50. {unitx-0.13.2 → unitx-0.13.4}/src/unitx/dimension.py +0 -0
  51. {unitx-0.13.2 → unitx-0.13.4}/src/unitx/factor.py +0 -0
  52. {unitx-0.13.2 → unitx-0.13.4}/src/unitx/math.py +0 -0
  53. {unitx-0.13.2 → unitx-0.13.4}/src/unitx/monomial.py +0 -0
  54. {unitx-0.13.2 → unitx-0.13.4}/src/unitx/py.typed +0 -0
  55. {unitx-0.13.2 → unitx-0.13.4}/src/unitx/quantity.py +0 -0
  56. {unitx-0.13.2 → unitx-0.13.4}/src/unitx/si/__init__.py +0 -0
  57. {unitx-0.13.2 → unitx-0.13.4}/src/unitx/si/bases.py +0 -0
  58. {unitx-0.13.2 → unitx-0.13.4}/src/unitx/si/constants.py +0 -0
  59. {unitx-0.13.2 → unitx-0.13.4}/src/unitx/si/units.py +0 -0
  60. {unitx-0.13.2 → unitx-0.13.4}/src/unitx/singleunit.py +0 -0
  61. {unitx-0.13.2 → unitx-0.13.4}/src/unitx/time.py +0 -0
  62. {unitx-0.13.2 → unitx-0.13.4}/src/unitx/unit.py +0 -0
  63. {unitx-0.13.2 → unitx-0.13.4}/src/unitx/unitparser.py +0 -0
  64. {unitx-0.13.2 → unitx-0.13.4}/src/unitx/unitregistry.py +0 -0
  65. {unitx-0.13.2 → unitx-0.13.4}/tests/__init__.py +0 -0
  66. {unitx-0.13.2 → unitx-0.13.4}/tests/_notebooks.py +0 -0
  67. {unitx-0.13.2 → unitx-0.13.4}/tests/example_typing/check_blackbody.py +0 -0
  68. {unitx-0.13.2 → unitx-0.13.4}/tests/numpy_typing/check_math.py +0 -0
  69. {unitx-0.13.2 → unitx-0.13.4}/tests/numpy_typing/check_numpy.py +0 -0
  70. {unitx-0.13.2 → unitx-0.13.4}/tests/test_affine.py +0 -0
  71. {unitx-0.13.2 → unitx-0.13.4}/tests/test_affinequantity.py +0 -0
  72. {unitx-0.13.2 → unitx-0.13.4}/tests/test_builtin_definitions.py +0 -0
  73. {unitx-0.13.2 → unitx-0.13.4}/tests/test_dimension.py +0 -0
  74. {unitx-0.13.2 → unitx-0.13.4}/tests/test_examples.py +0 -0
  75. {unitx-0.13.2 → unitx-0.13.4}/tests/test_factor.py +0 -0
  76. {unitx-0.13.2 → unitx-0.13.4}/tests/test_immutable.py +0 -0
  77. {unitx-0.13.2 → unitx-0.13.4}/tests/test_import.py +0 -0
  78. {unitx-0.13.2 → unitx-0.13.4}/tests/test_math.py +0 -0
  79. {unitx-0.13.2 → unitx-0.13.4}/tests/test_math_numpy.py +0 -0
  80. {unitx-0.13.2 → unitx-0.13.4}/tests/test_monomial.py +0 -0
  81. {unitx-0.13.2 → unitx-0.13.4}/tests/test_numeric.py +0 -0
  82. {unitx-0.13.2 → unitx-0.13.4}/tests/test_numpy.py +0 -0
  83. {unitx-0.13.2 → unitx-0.13.4}/tests/test_numpy_affine.py +0 -0
  84. {unitx-0.13.2 → unitx-0.13.4}/tests/test_numpy_differentiation.py +0 -0
  85. {unitx-0.13.2 → unitx-0.13.4}/tests/test_numpy_integration.py +0 -0
  86. {unitx-0.13.2 → unitx-0.13.4}/tests/test_numpy_multi_outputs.py +0 -0
  87. {unitx-0.13.2 → unitx-0.13.4}/tests/test_numpy_mutation.py +0 -0
  88. {unitx-0.13.2 → unitx-0.13.4}/tests/test_numpy_outputs.py +0 -0
  89. {unitx-0.13.2 → unitx-0.13.4}/tests/test_numpy_products.py +0 -0
  90. {unitx-0.13.2 → unitx-0.13.4}/tests/test_numpy_statistics.py +0 -0
  91. {unitx-0.13.2 → unitx-0.13.4}/tests/test_quantity.py +0 -0
  92. {unitx-0.13.2 → unitx-0.13.4}/tests/test_si.py +0 -0
  93. {unitx-0.13.2 → unitx-0.13.4}/tests/test_singleunit.py +0 -0
  94. {unitx-0.13.2 → unitx-0.13.4}/tests/test_siunitx_display.py +0 -0
  95. {unitx-0.13.2 → unitx-0.13.4}/tests/test_time.py +0 -0
  96. {unitx-0.13.2 → unitx-0.13.4}/tests/test_unicode.py +0 -0
  97. {unitx-0.13.2 → unitx-0.13.4}/tests/test_unicode_units.py +0 -0
  98. {unitx-0.13.2 → unitx-0.13.4}/tests/test_unit.py +0 -0
  99. {unitx-0.13.2 → unitx-0.13.4}/tests/test_unit_definition.py +0 -0
  100. {unitx-0.13.2 → unitx-0.13.4}/tests/test_unitparser.py +0 -0
  101. {unitx-0.13.2 → unitx-0.13.4}/tests/test_unitregistry.py +0 -0
  102. {unitx-0.13.2 → unitx-0.13.4}/tests/test_warnings.py +0 -0
  103. {unitx-0.13.2 → unitx-0.13.4}/tests/typing/check_affine.py +0 -0
  104. {unitx-0.13.2 → unitx-0.13.4}/tests/typing/check_math_time.py +0 -0
  105. {unitx-0.13.2 → unitx-0.13.4}/tests/typing/check_quantity.py +0 -0
  106. {unitx-0.13.2 → unitx-0.13.4}/tests/typing/check_si.py +0 -0
@@ -2,6 +2,32 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.13.4] - 2026-10-23
6
+
7
+ ### Fixed
8
+
9
+ - Preserve exact extra scales and structural unit formatting for affine points
10
+ in anonymous zero-origin linear coordinates across text, LaTeX, HTML and siunitx.
11
+ Stored readings and named coordinate labels are unchanged.
12
+
13
+ ### Added
14
+
15
+ - Add an Arrhenius fitting notebook with absolute temperatures, activation-energy
16
+ estimates and standard errors, a fitted curve and first-order rate predictions.
17
+
18
+ ## [0.13.3] - 2026-10-22
19
+
20
+ ### Added
21
+
22
+ - `unitx.serialization.to_dict()` / `from_dict()` exchange scalar Quantities,
23
+ affine points and their units as versioned JSON-compatible records. Preserve
24
+ int/float/Fraction/Decimal magnitudes, exact extra scales, formal unit identity
25
+ and coordinate references; validate existing registry meanings without
26
+ registering units. NumPy, complex magnitudes and subclasses are not supported.
27
+ - Add `SerializationError` for invalid records and failed restoration, and an
28
+ independent `VersionError` for unsupported explicit versions. Both derive
29
+ from `UnitxError` and `ValueError`; decoding preserves underlying causes.
30
+
5
31
  ## [0.13.2] - 2026-10-22
6
32
 
7
33
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: unitx
3
- Version: 0.13.2
3
+ Version: 0.13.4
4
4
  Summary: Physical units, quantities, and unit conversion with exact unit algebra, type hints, and NumPy integration.
5
5
  Project-URL: Homepage, https://github.com/DaitTHU/unitx
6
6
  Project-URL: Documentation, https://github.com/DaitTHU/unitx/blob/main/docs/README.md
@@ -195,9 +195,9 @@ Use `.value` or `.value_in(unit)` at plotting and external-library boundaries.
195
195
  implicit unit removal. NumPy's type stubs do not describe every wrapped
196
196
  operation; see the [typing guide](https://github.com/DaitTHU/unitx/blob/main/docs/typing.md).
197
197
 
198
- The [physics notebooks](https://github.com/DaitTHU/unitx/blob/main/examples/README.md)
199
- cover blackbody radiation, damped motion, wave-packet spreading, attenuation,
200
- and measurement uncertainty.
198
+ The [scientific examples](https://github.com/DaitTHU/unitx/blob/main/examples/README.md)
199
+ cover reaction-rate fitting, blackbody radiation, damped motion, wave-packet
200
+ spreading, attenuation, and measurement uncertainty.
201
201
 
202
202
  ## Blackbody radiation
203
203
 
@@ -213,7 +213,7 @@ from matplotlib import pyplot as plt
213
213
  from unitx.si import SI, si
214
214
 
215
215
  wavelength = np.linspace(0.01, 3, 100)[:, None] * si.um
216
- temperature = np.array([3000, 4000, 5000]) * si.K
216
+ temperature = np.asarray([3000, 4000, 5000]) * si.K
217
217
  exponent = SI.h * SI.c / (wavelength * SI.kB * temperature)
218
218
  radiance = 2 * SI.h * SI.c**2 / (wavelength**5 * np.expm1(exponent))
219
219
  radiance = radiance.to('kW/sr/m2/nm')
@@ -167,9 +167,9 @@ Use `.value` or `.value_in(unit)` at plotting and external-library boundaries.
167
167
  implicit unit removal. NumPy's type stubs do not describe every wrapped
168
168
  operation; see the [typing guide](https://github.com/DaitTHU/unitx/blob/main/docs/typing.md).
169
169
 
170
- The [physics notebooks](https://github.com/DaitTHU/unitx/blob/main/examples/README.md)
171
- cover blackbody radiation, damped motion, wave-packet spreading, attenuation,
172
- and measurement uncertainty.
170
+ The [scientific examples](https://github.com/DaitTHU/unitx/blob/main/examples/README.md)
171
+ cover reaction-rate fitting, blackbody radiation, damped motion, wave-packet
172
+ spreading, attenuation, and measurement uncertainty.
173
173
 
174
174
  ## Blackbody radiation
175
175
 
@@ -185,7 +185,7 @@ from matplotlib import pyplot as plt
185
185
  from unitx.si import SI, si
186
186
 
187
187
  wavelength = np.linspace(0.01, 3, 100)[:, None] * si.um
188
- temperature = np.array([3000, 4000, 5000]) * si.K
188
+ temperature = np.asarray([3000, 4000, 5000]) * si.K
189
189
  exponent = SI.h * SI.c / (wavelength * SI.kB * temperature)
190
190
  radiance = 2 * SI.h * SI.c**2 / (wavelength**5 * np.expm1(exponent))
191
191
  radiance = radiance.to('kW/sr/m2/nm')
@@ -16,9 +16,10 @@ reading about an older version.
16
16
  | Call unit-aware mathematical functions or read a clock | [Standard-library enhancements](stdlib.md) |
17
17
  | Calculate with arrays, masks, reductions and output buffers | [NumPy support](numpy.md) |
18
18
  | Format text or export LaTeX, HTML and siunitx | [Display and formatting](display.md) |
19
+ | Save and restore scalar quantities as JSON-compatible records | [Scalar data exchange](serialization.md) |
19
20
  | Understand annotations and NumPy inference limits | [Typing](typing.md) |
20
21
  | Update code written for an earlier release | [Migration notes](migration.md) |
21
- | Run scientific examples | [Physics notebooks](../examples/README.md) |
22
+ | Run scientific examples | [Scientific examples](../examples/README.md) |
22
23
  | Test or contribute a change | [Contributing](../CONTRIBUTING.md) |
23
24
 
24
25
  For a first read, follow units and quantities, then the catalogue. Read the
@@ -17,6 +17,26 @@ markup or raw TeX commands. Unicode symbols remain Unicode and require font
17
17
  support in the renderer. These strings are for presentation, not serialization.
18
18
  `repr()` and `Unit.expression()` keep their existing reconstruction contracts.
19
19
 
20
+ An AffineQuantity in an anonymous zero-origin linear coordinate displays its
21
+ complete difference Unit, including any extra scale and rational powers. The
22
+ stored reading is unchanged. Named coordinates keep their own labels:
23
+
24
+ ```pycon
25
+ >>> from unitx.unit import ScaledUnit
26
+ >>> from unitx.factor import Factor
27
+ >>> point = 3 @ ScaledUnit('m', scale=Factor(2))
28
+ >>> str(point)
29
+ '3⋅2⋅m'
30
+ >>> format(point, '.2f')
31
+ '3.00 (2⋅m)'
32
+ >>> point.to_quantity('m').value
33
+ Fraction(6, 1)
34
+
35
+ ```
36
+
37
+ LaTeX, HTML and siunitx use the same structural unit rendering as a linear
38
+ Quantity with that complete Unit; formatting never converts the reading.
39
+
20
40
  ## Quantity format syntax
21
41
 
22
42
  `format(q, 'numeric_spec;style')`, or `f'{q:numeric_spec;style}'`, selects:
@@ -0,0 +1,225 @@
1
+ # Scalar data exchange
2
+
3
+ `unitx.serialization.to_dict()` returns a versioned record made of dictionaries,
4
+ lists, strings, null and an integer format version. `from_dict()` restores it.
5
+ Use Python's JSON functions, or another transport for the same data structure:
6
+
7
+ ```pycon
8
+ >>> import json
9
+ >>> from decimal import Decimal
10
+ >>> from fractions import Fraction
11
+ >>> from unitx import AffineQuantity, AffineUnit, Quantity, Unit
12
+ >>> from unitx.serialization import from_dict, to_dict
13
+ >>> original = Quantity(Fraction(1, 3), 'cm')
14
+ >>> text = json.dumps(to_dict(original), allow_nan=False)
15
+ >>> restored = from_dict(json.loads(text))
16
+ >>> type(restored.value) is Fraction
17
+ True
18
+ >>> restored.value, restored.unit
19
+ (Fraction(1, 3), Unit('cm'))
20
+ >>> restored == original
21
+ True
22
+
23
+ ```
24
+
25
+ The module supports `Unit`, `ScaledUnit`, `AffineUnit`, scalar `Quantity` and
26
+ scalar `AffineQuantity`. Import the two functions from `unitx.serialization`;
27
+ they are not package-root exports. No optional dependency is required.
28
+
29
+ ## Stored numbers
30
+
31
+ Magnitudes must be exact instances of Python `int`, `float`, `Fraction` or
32
+ `Decimal`. Booleans, complex numbers, numeric subclasses, custom magnitudes,
33
+ NumPy scalars and arrays are rejected with TypeError. A round trip preserves
34
+ the stored magnitude's type and value without converting its unit.
35
+
36
+ | Type tag | `value` field | Meaning |
37
+ | --- | --- | --- |
38
+ | `int` | A canonical decimal integer string | Retains integers beyond the exact range of a JSON consumer's floating-point numbers |
39
+ | `fraction` | Two integer strings, `[numerator, denominator]` | Exact rational, with positive denominator |
40
+ | `float` | `float.hex()` text | Exact finite binary value, including subnormals and signed zero; `inf`, `-inf` and `nan` are also supported |
41
+ | `decimal` | Decimal text | Retains sign, coefficient, trailing zeros and exponent; infinities and NaNs are supported |
42
+
43
+ Float NaNs preserve their category; NaN payload bits and sign are not part of
44
+ the format. Decimal NaN payloads and signaling status are retained. Export
45
+ and import do not round a Decimal magnitude in the active context. Invalid
46
+ Decimal strings are rejected even when the caller disables InvalidOperation
47
+ traps, without setting flags in the caller's context.
48
+
49
+ All numerical data except the format version use strings. Consequently strict
50
+ JSON encoding with `allow_nan=False` works even for nonfinite magnitudes.
51
+ Python's normal integer-string conversion and Decimal representation limits
52
+ still apply.
53
+
54
+ ## Units and points
55
+
56
+ The format preserves the formal product, including prefixes and rational
57
+ exponents. Equal physical scale does not merge distinct units such as Hz and
58
+ Bq, and `km/m` retains its nontrivial dimensionless scale. A ScaledUnit stores
59
+ its additional Factor separately, including when that Factor is one:
60
+
61
+ ```pycon
62
+ >>> from unitx import pi
63
+ >>> from unitx.unit import ScaledUnit
64
+ >>> scaled = ScaledUnit('m', pi ** Fraction(1, 3))
65
+ >>> rebuilt = from_dict(json.loads(json.dumps(to_dict(scaled))))
66
+ >>> type(rebuilt) is ScaledUnit and rebuilt.scale == scaled.scale
67
+ True
68
+ >>> rebuilt**3 == scaled**3
69
+ True
70
+
71
+ ```
72
+
73
+ An AffineUnit stores its physical origin, difference unit and logarithmic kind.
74
+ Registered coordinates also retain their canonical registered symbol;
75
+ anonymous coordinates remain anonymous. Points stay AffineQuantity objects:
76
+
77
+ ```pycon
78
+ >>> point = AffineQuantity(Decimal('20.00'), 'degC')
79
+ >>> rebuilt = from_dict(json.loads(json.dumps(to_dict(point))))
80
+ >>> type(rebuilt) is AffineQuantity and rebuilt.unit == point.unit
81
+ True
82
+ >>> rebuilt.value.as_tuple() == point.value.as_tuple()
83
+ True
84
+ >>> epoch = AffineUnit(Quantity(946684800, 's'), delta_unit='s')
85
+ >>> custom = AffineQuantity(Fraction(1, 2), epoch)
86
+ >>> from_dict(to_dict(custom)).unit == epoch
87
+ True
88
+
89
+ ```
90
+
91
+ This preserves the existing model's information. It does not add clock identity,
92
+ timezone or source labels to time points. Point arithmetic and the existing
93
+ hashability rules are unchanged.
94
+
95
+ ## Registry requirements
96
+
97
+ Import resolves spellings against the receiving process's existing registry.
98
+ Register custom units explicitly during application initialization before
99
+ decoding records that refer to them. Unknown spellings raise SerializationError
100
+ with the original UnitSymbolError retained as `__cause__`.
101
+ Decoding never registers or replaces definitions, and neither function changes
102
+ the input object or record.
103
+
104
+ A linear unit record contains its canonical formal identity and its combined
105
+ dimension, SI Factor and logarithmic degree. Import checks these against the
106
+ resolved unit. A registered coordinate must also match the saved physical
107
+ origin, its formal reference and difference units, and logarithmic kind.
108
+ Conflicts raise SerializationError rather than silently changing the result.
109
+
110
+ These checks describe the resulting unit, not a complete registry snapshot.
111
+ They do not compare names, aliases, prefix policies or each individual unit's
112
+ conversion metadata when changes cancel within a product. The receiving
113
+ application remains responsible for compatible unit definitions. Factor checks
114
+ compare exact formal state: a changed symbolic approximation or a different
115
+ unresolved-integer decomposition can cause rejection even if physical values
116
+ are approximately equal. No floating-point tolerance is used to infer equality.
117
+
118
+ ## Version 1 record format
119
+
120
+ Only the outermost object has `"version": 1`; every object record has a `type`.
121
+ For example, an integer length is represented as:
122
+
123
+ ```json
124
+ {
125
+ "version": 1,
126
+ "type": "quantity",
127
+ "value": {"type": "int", "value": "3"},
128
+ "unit": {
129
+ "type": "unit",
130
+ "expression": "m",
131
+ "identity": [["", "m", "1", "1"]],
132
+ "dimension": [["0", "1"], ["1", "1"], ["0", "1"], ["0", "1"], ["0", "1"], ["0", "1"], ["0", "1"]],
133
+ "factor": [],
134
+ "log_degree": ["0", "1"]
135
+ }
136
+ }
137
+ ```
138
+
139
+ The object records have these exact fields:
140
+
141
+ | `type` | Fields in addition to `type` |
142
+ | --- | --- |
143
+ | `quantity`, `affine_quantity` | `value` scalar record, `unit` linear/affine unit record respectively |
144
+ | `unit` | `expression`, `identity`, `dimension`, `factor`, `log_degree` |
145
+ | `scaled_unit` | The unit fields plus `scale`, a Factor component list |
146
+ | `affine_unit` | `origin` linear Quantity record, `delta_unit` linear unit record, `kind` null/`power`/`amplitude`, `symbol` canonical registered string or null |
147
+
148
+ `identity` is a sorted list of `[prefix_symbol, unprefixed_symbol, numerator,
149
+ denominator]` terms; the last two strings encode the nonzero exponent.
150
+ `dimension` lists seven rational pairs in T, L, M, I, Theta, N, J order.
151
+ `log_degree` is another rational pair.
152
+
153
+ Factor component lists preserve the formal integer/symbolic bases and rational
154
+ exponents. The empty list represents one. Each component has an `exponent`
155
+ pair and one of these base descriptions:
156
+
157
+ | Component `type` | Other fields |
158
+ | --- | --- |
159
+ | `prime` | `value`: prime integer string |
160
+ | `symbolic` | `symbol`: literal label, `value`: positive finite float.hex() approximation |
161
+ | `unfactored` | `value`: unresolved integer string, `limit`: stored factorization-limit integer string |
162
+
163
+ Symbolic labels are reconstructed as SymbolicConstant objects, never evaluated
164
+ as code. Their mathematical construction requirements remain the caller's
165
+ responsibility. Unresolved bases keep the existing incomplete-factorization
166
+ limitations and emit IncompleteFactorWarning on reconstruction.
167
+
168
+ Unknown versions, tags, missing or extra fields and invalid nested records are
169
+ rejected. The version is independent of the package release number; future
170
+ incompatible record changes require a new format version. This is a data
171
+ format, not a Python class/subclass reconstruction protocol. No `eval`, dynamic
172
+ class imports, JSON encoder subclasses, file I/O or configurable codec registry
173
+ are involved. Applications parsing external input should apply their own size
174
+ and resource limits.
175
+
176
+ ## Errors
177
+
178
+ Import the error classes from `unitx.exceptions`. The public boundaries are:
179
+
180
+ | Operation and failure | Exception |
181
+ | --- | --- |
182
+ | `to_dict()` receives an unsupported object or magnitude | TypeError |
183
+ | `from_dict()` receives a non-dictionary top-level input | TypeError |
184
+ | A record specifies an unsupported integer format version | VersionError |
185
+ | A record has no version or the version is not an integer | SerializationError |
186
+ | Invalid fields, missing definitions, conflicting unit meanings or failed reconstruction | SerializationError |
187
+
188
+ VersionError and SerializationError both inherit UnitxError and ValueError.
189
+ They are independent classes: VersionError is available for explicit version
190
+ checks elsewhere in unitx as well. It currently describes the record format
191
+ version, not the installed package version. Catch the two classes separately
192
+ for different recovery actions, or catch UnitxError for both:
193
+
194
+ ```pycon
195
+ >>> from unitx.exceptions import SerializationError, UnitxError, VersionError
196
+ >>> try:
197
+ ... from_dict({'version': 2})
198
+ ... except VersionError:
199
+ ... print('Unsupported record version')
200
+ Unsupported record version
201
+ >>> try:
202
+ ... from_dict({'version': 1, 'type': 'unknown'})
203
+ ... except SerializationError:
204
+ ... print('Invalid record')
205
+ Invalid record
206
+
207
+ ```
208
+
209
+ Underlying numeric, parsing and coordinate exceptions, including
210
+ UnitSymbolError, UnitSyntaxError and DimensionError, are retained as
211
+ `error.__cause__` when wrapped. Explicit record-validation errors use
212
+ SerializationError directly. Exceptions outside the expected validation
213
+ families, such as RuntimeError, are not wrapped.
214
+
215
+ The return type of `from_dict()` is a union of the supported object classes;
216
+ use `isinstance` when code requires a specific class. Its scalar magnitude type
217
+ is the supported union, since arbitrary input data cannot statically determine
218
+ the original numerical type.
219
+
220
+ Numerical encodings follow the Python documentation for
221
+ [float hexadecimal conversion](https://docs.python.org/3/library/stdtypes.html#float.hex),
222
+ [Decimal construction](https://docs.python.org/3/library/decimal.html#decimal.Decimal)
223
+ and [JSON data exchange](https://docs.python.org/3/library/json.html).
224
+
225
+ [Documentation index](README.md) · [Scalar behavior](scalars.md) · [Custom units](custom-units.md)
@@ -26,6 +26,11 @@ for its array overloads. `unitx.time` clocks return `AffineQuantity[int]`;
26
26
  duration adapters use ordinary Quantity objects. `time.from_datetime()` returns
27
27
  `AffineQuantity[int]`; `time.to_datetime()` returns an aware datetime.
28
28
 
29
+ `unitx.serialization.from_dict()` returns a union of the supported unit and
30
+ scalar quantity classes. Use `isinstance` to narrow it. The magnitude type is
31
+ `int | float | Fraction | Decimal`; the input record determines its runtime type.
32
+ See [scalar data exchange](serialization.md).
33
+
29
34
  A signature such as `Quantity[float]` does not require metres rather than
30
35
  seconds. An `Annotated` label alone does not teach a type checker dimensional
31
36
  algebra. unitx does not currently supply dimension-parameterized quantities
@@ -1,4 +1,4 @@
1
- # Physics notebooks
1
+ # Scientific examples
2
2
 
3
3
  These notebooks use unitx for dimensional calculations and NumPy for numerical
4
4
  work. Each contains a model, assumptions, plots, and numerical checks. The
@@ -11,6 +11,7 @@ material parameters and measurement draws are illustrative, not experimental dat
11
11
  | [Gaussian wave packet](gaussian_wavepacket.ipynb) | Free electron propagation and uncertainty | Probability-density units, quantum constants, unit-aware integration |
12
12
  | [Optical attenuation](optical_attenuation.ipynb) | Transmission through absorbing layers | prod/multiply.reduce, input masks, Quantity initial, fixed-unit out |
13
13
  | [Pendulum uncertainty](pendulum_uncertainty.ipynb) | Correlated input uncertainties and Monte Carlo propagation | Mean/std/var units, mixed measurement units, explicit quantiles |
14
+ | [Arrhenius fit](arrhenius_fit.ipynb) | Estimate activation energy from synthetic reaction rates | Absolute temperatures, regression boundary, parameter units and model-based standard errors |
14
15
 
15
16
  ## Run
16
17
 
@@ -22,8 +23,8 @@ python -m pip install -e '.[numpy]' matplotlib jupyterlab
22
23
  jupyter lab examples/
23
24
  ```
24
25
 
25
- Open a notebook and run all cells from the beginning. These notebooks require
26
- unitx 0.8.4 or newer for product reductions and unit-aware integration.
26
+ Open a notebook and run all cells from the beginning. Use the current checkout
27
+ so that all demonstrated APIs are available.
27
28
  The examples do not download datasets or write files. Keep physical quantities in calculations;
28
29
  plot their `.value` and derive labels from their `.unit`. Convert only to choose
29
30
  a useful display scale or to align units at a numerical boundary. Quantiles use
@@ -0,0 +1,236 @@
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "arrhenius_fit-00",
6
+ "metadata": {},
7
+ "source": [
8
+ "# Estimate an activation energy from reaction-rate data\n",
9
+ "\n",
10
+ "Fit a temperature-dependent first-order rate constant, estimate its molar\n",
11
+ "activation energy, and predict a rate and half-life inside the observed interval.\n",
12
+ "Run the setup in [examples/README.md](README.md) first.\n",
13
+ "\n",
14
+ "The [IUPAC Arrhenius equation](https://goldbook.iupac.org/terms/view/A00446) is\n",
15
+ "$$\n",
16
+ "k(T)=A\\exp\\!\\left(-\\frac{E_a}{RT}\\right).\n",
17
+ "$$\n",
18
+ "Here $T$ is absolute temperature, $R$ is the molar gas constant, $E_a$ is the\n",
19
+ "molar activation energy, and $A$ has the same units as the rate constant $k$.\n",
20
+ "We assume $E_a$ and $A$ remain constant over this temperature interval.\n",
21
+ "\n",
22
+ "These are **synthetic data**, not measurements of a named reaction. They were\n",
23
+ "generated with $E_a=52\\,\\mathrm{kJ/mol}$ and $A=2\\times10^7\\,\\mathrm{s^{-1}}$,\n",
24
+ "with fixed log-rate perturbations $[0.020,-0.025,0.010,-0.015,0.025,-0.015]$,\n",
25
+ "then rounded. The known generating parameters provide a numerical check;\n",
26
+ "they do not validate the model for a real reaction."
27
+ ]
28
+ },
29
+ {
30
+ "cell_type": "code",
31
+ "id": "arrhenius_fit-01",
32
+ "metadata": {},
33
+ "source": [
34
+ "import numpy as np\n",
35
+ "from matplotlib import pyplot as plt\n",
36
+ "\n",
37
+ "from unitx import math\n",
38
+ "from unitx.si import SI, si\n",
39
+ "\n",
40
+ "# Recorded Celsius temperatures are points, not temperature differences.\n",
41
+ "temperature_readings = np.asarray([15, 25, 35, 45, 55, 65]) @ si.degC\n",
42
+ "temperature = temperature_readings.to_quantity(si.K)\n",
43
+ "rate = np.asarray([0.458888, 0.908486, 1.858462, 3.430419, 6.499562, 10.972105]) / si.min\n",
44
+ "reference_rate = 1 / si.min"
45
+ ],
46
+ "execution_count": null,
47
+ "outputs": []
48
+ },
49
+ {
50
+ "cell_type": "markdown",
51
+ "id": "arrhenius_fit-02",
52
+ "metadata": {},
53
+ "source": [
54
+ "## Fit at an explicit numerical boundary\n",
55
+ "\n",
56
+ "Normalize the rate before taking its logarithm:\n",
57
+ "$$\n",
58
+ "x=\\frac{1}{T},\\qquad y=\\ln\\!\\left(\\frac{k}{k_{\\rm ref}}\\right),\\qquad\n",
59
+ " y=mx+b.\n",
60
+ "$$\n",
61
+ "Thus $m=-E_a/R$ has unit K, $b=\\ln(A/k_{\\rm ref})$ is dimensionless, and\n",
62
+ "$$\n",
63
+ "E_a=-mR,\\qquad A=k_{\\rm ref}\\exp(b).\n",
64
+ "$$\n",
65
+ "\n",
66
+ "The temperatures have already been converted to K, so `(1 / temperature).value`\n",
67
+ "contains inverse temperatures in K$^{-1}$. `np.polyfit` receives these numerical\n",
68
+ "values and the dimensionless log rates. Attach K to its fitted slope before\n",
69
+ "continuing the physical calculation. Keep the fitted scalars in NumPy's\n",
70
+ "floating-point domain; unitx's NumPy adapter evaluates catalogue constants in\n",
71
+ "that domain without changing `SI.R`."
72
+ ]
73
+ },
74
+ {
75
+ "cell_type": "code",
76
+ "id": "arrhenius_fit-03",
77
+ "metadata": {},
78
+ "source": [
79
+ "log_rate = math.log(rate / reference_rate)\n",
80
+ "coefficients, covariance = np.polyfit(\n",
81
+ " (1 / temperature).value,\n",
82
+ " log_rate.value,\n",
83
+ " deg=1,\n",
84
+ " cov=True,\n",
85
+ ")\n",
86
+ "slope = np.float64(coefficients[0]) * si.K\n",
87
+ "slope_standard_error = np.sqrt(np.float64(covariance[0, 0])) * slope.unit\n",
88
+ "activation_energy = (-slope * SI.R).to('kJ/mol')\n",
89
+ "energy_standard_error = (slope_standard_error * SI.R).to(activation_energy.unit)\n",
90
+ "prefactor = (reference_rate * math.exp(np.float64(coefficients[1]))).to('s^-1')\n",
91
+ "\n",
92
+ "print(f'Activation energy: {activation_energy:.3f}')\n",
93
+ "print(f'Standard error from residual scatter: {energy_standard_error:.3f}')\n",
94
+ "print(f'Prefactor: {prefactor:.4g}')"
95
+ ],
96
+ "execution_count": null,
97
+ "outputs": []
98
+ },
99
+ {
100
+ "cell_type": "markdown",
101
+ "id": "arrhenius_fit-04",
102
+ "metadata": {},
103
+ "source": [
104
+ "## Interpret and plot the fit\n",
105
+ "\n",
106
+ "Ordinary least squares here assumes negligible temperature error and independent\n",
107
+ "log-rate errors with a common variance. The covariance returned by `polyfit`\n",
108
+ "estimates that variance from residual scatter, using $6-2=4$ residual degrees\n",
109
+ "of freedom. Therefore $R\\sqrt{\\operatorname{Cov}_{00}}$ is a model-based standard\n",
110
+ "error for $E_a$, not a confidence interval or a measure of systematic error.\n",
111
+ "The estimates of $A$ and $E_a$ are correlated; no prediction uncertainty band is\n",
112
+ "computed here.\n",
113
+ "\n",
114
+ "Expect an activation energy near $51.9\\,\\mathrm{kJ/mol}$ with standard error\n",
115
+ "near $0.45\\,\\mathrm{kJ/mol}$. Plot a dimensionless inverse-temperature coordinate,\n",
116
+ "$1000\\,\\mathrm{K}/T$. The label is derived from the same scale used in the plot.\n",
117
+ "The curve spans only the observed temperature range."
118
+ ]
119
+ },
120
+ {
121
+ "cell_type": "code",
122
+ "id": "arrhenius_fit-05",
123
+ "metadata": {},
124
+ "source": [
125
+ "curve_temperature = (\n",
126
+ " np.linspace(temperature.value.min(), temperature.value.max(), 200, dtype=np.float64) * si.K\n",
127
+ ")\n",
128
+ "curve_rate = prefactor * math.exp(-activation_energy / (SI.R * curve_temperature))\n",
129
+ "temperature_scale = 1000 * si.K\n",
130
+ "plot_temperature = temperature_scale / temperature\n",
131
+ "curve_coordinate = temperature_scale / curve_temperature\n",
132
+ "\n",
133
+ "fig, ax = plt.subplots(figsize=(6, 4), layout='constrained')\n",
134
+ "ax.scatter(plot_temperature.value, log_rate.value, label='Synthetic observations')\n",
135
+ "ax.plot(\n",
136
+ " curve_coordinate.value,\n",
137
+ " math.log(curve_rate / reference_rate).value,\n",
138
+ " label='Arrhenius fit',\n",
139
+ ")\n",
140
+ "ax.set(\n",
141
+ " xlabel=f'{temperature_scale} / T',\n",
142
+ " ylabel='ln(k / k_ref)',\n",
143
+ " title='Activation-energy fit',\n",
144
+ ")\n",
145
+ "ax.legend()\n",
146
+ "ax.grid(True, alpha=0.25)\n",
147
+ "plt.show()"
148
+ ],
149
+ "execution_count": null,
150
+ "outputs": []
151
+ },
152
+ {
153
+ "cell_type": "markdown",
154
+ "id": "arrhenius_fit-06",
155
+ "metadata": {},
156
+ "source": [
157
+ "## Predict inside the observed interval\n",
158
+ "\n",
159
+ "At a fixed temperature, a first-order decay obeys $c(t)=c(0)\\exp(-kt)$, hence\n",
160
+ "$$\n",
161
+ "t_{1/2}=\\frac{\\ln 2}{k}.\n",
162
+ "$$\n",
163
+ "Use the fitted parameters to predict at $40\\,{}^\\circ\\mathrm C$, inside the\n",
164
+ "15--65 $^\\circ\\mathrm C$ data interval. Convert the temperature point to a Kelvin\n",
165
+ "quantity before evaluating the exponent. This is a prediction under the stated\n",
166
+ "Arrhenius and first-order models, with no allowance for model error."
167
+ ]
168
+ },
169
+ {
170
+ "cell_type": "code",
171
+ "id": "arrhenius_fit-07",
172
+ "metadata": {},
173
+ "source": [
174
+ "prediction_reading = np.float64(40) @ si.degC\n",
175
+ "prediction_temperature = prediction_reading.to_quantity(si.K)\n",
176
+ "predicted_rate = prefactor * math.exp(-activation_energy / (SI.R * prediction_temperature))\n",
177
+ "half_life = (math.log(2) / predicted_rate).to(si.s)\n",
178
+ "\n",
179
+ "print(f'Predicted rate at {prediction_reading}: {predicted_rate.to(rate.unit):.4f}')\n",
180
+ "print(f'First-order half-life: {half_life:.2f}')"
181
+ ],
182
+ "execution_count": null,
183
+ "outputs": []
184
+ },
185
+ {
186
+ "cell_type": "markdown",
187
+ "id": "arrhenius_fit-08",
188
+ "metadata": {},
189
+ "source": [
190
+ "## Numerical checks\n",
191
+ "\n",
192
+ "Compare the estimates with the known generating parameters. Also check that\n",
193
+ "converting the input rate storage unit does not alter the normalized fit data,\n",
194
+ "and that the reconstructed physical model agrees with the fitted straight line.\n",
195
+ "The half-life identity checks the prediction's time/rate units together."
196
+ ]
197
+ },
198
+ {
199
+ "cell_type": "code",
200
+ "id": "arrhenius_fit-09",
201
+ "metadata": {},
202
+ "source": [
203
+ "assert activation_energy.isclose(52 * si.kJ / si.mol, rel_tol=0.02)\n",
204
+ "assert prefactor.isclose(2e7 / si.s, rel_tol=0.1)\n",
205
+ "assert (predicted_rate * half_life).isclose(math.log(2), rel_tol=1e-12)\n",
206
+ "np.testing.assert_allclose(\n",
207
+ " math.log(rate.to('s^-1') / reference_rate).value,\n",
208
+ " log_rate.value,\n",
209
+ " rtol=1e-12,\n",
210
+ " atol=1e-12,\n",
211
+ ")\n",
212
+ "fitted_rate = prefactor * math.exp(-activation_energy / (SI.R * temperature))\n",
213
+ "np.testing.assert_allclose(\n",
214
+ " math.log(fitted_rate / reference_rate).value,\n",
215
+ " coefficients[0] * (1 / temperature).value + coefficients[1],\n",
216
+ " rtol=1e-12,\n",
217
+ " atol=1e-12,\n",
218
+ ")"
219
+ ],
220
+ "execution_count": null,
221
+ "outputs": []
222
+ }
223
+ ],
224
+ "metadata": {
225
+ "kernelspec": {
226
+ "display_name": "Python 3",
227
+ "language": "python",
228
+ "name": "python3"
229
+ },
230
+ "language_info": {
231
+ "name": "python"
232
+ }
233
+ },
234
+ "nbformat": 4,
235
+ "nbformat_minor": 5
236
+ }
@@ -4,7 +4,7 @@ build-backend = 'hatchling.build'
4
4
 
5
5
  [project]
6
6
  name = 'unitx'
7
- version = '0.13.2'
7
+ version = '0.13.4'
8
8
  description = 'Physical units, quantities, and unit conversion with exact unit algebra, type hints, and NumPy integration.'
9
9
  readme = 'README.md'
10
10
  requires-python = '>=3.12'
@@ -3,6 +3,7 @@
3
3
  "src/unitx/_display.py",
4
4
  "src/unitx/math.py",
5
5
  "src/unitx/time.py",
6
+ "src/unitx/serialization.py",
6
7
  "src/unitx/affineunit.py",
7
8
  "src/unitx/affinequantity.py",
8
9
  "src/unitx/_conversion.py",
@@ -18,6 +19,7 @@
18
19
  "tests/typing"
19
20
  ],
20
21
  "strict": [
22
+ "src/unitx/serialization.py",
21
23
  "src/unitx/quantity.py",
22
24
  "tests/typing",
23
25
  "tests/numpy_typing",