unitx 0.6.2__tar.gz → 0.6.3__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 (47) hide show
  1. {unitx-0.6.2 → unitx-0.6.3}/CHANGELOG.md +25 -0
  2. {unitx-0.6.2 → unitx-0.6.3}/PKG-INFO +92 -22
  3. {unitx-0.6.2 → unitx-0.6.3}/README.md +91 -21
  4. {unitx-0.6.2 → unitx-0.6.3}/pyproject.toml +1 -1
  5. {unitx-0.6.2 → unitx-0.6.3}/src/unitx/_numerics.py +16 -5
  6. {unitx-0.6.2 → unitx-0.6.3}/src/unitx/dimension.py +5 -4
  7. {unitx-0.6.2 → unitx-0.6.3}/src/unitx/factor.py +30 -11
  8. {unitx-0.6.2 → unitx-0.6.3}/src/unitx/monomial.py +5 -5
  9. unitx-0.6.3/src/unitx/quantity.py +682 -0
  10. {unitx-0.6.2 → unitx-0.6.3}/src/unitx/si/constants.py +10 -6
  11. {unitx-0.6.2 → unitx-0.6.3}/src/unitx/unit.py +6 -5
  12. {unitx-0.6.2 → unitx-0.6.3}/tests/test_dimension.py +10 -8
  13. {unitx-0.6.2 → unitx-0.6.3}/tests/test_factor.py +87 -5
  14. {unitx-0.6.2 → unitx-0.6.3}/tests/test_monomial.py +8 -11
  15. unitx-0.6.3/tests/test_quantity.py +1158 -0
  16. {unitx-0.6.2 → unitx-0.6.3}/tests/test_si.py +10 -1
  17. {unitx-0.6.2 → unitx-0.6.3}/tests/test_unit.py +36 -9
  18. {unitx-0.6.2 → unitx-0.6.3}/tests/test_warnings.py +19 -19
  19. {unitx-0.6.2 → unitx-0.6.3}/tests/typing/check_quantity.py +72 -15
  20. {unitx-0.6.2 → unitx-0.6.3}/tests/typing/check_si.py +2 -3
  21. unitx-0.6.2/src/unitx/quantity.py +0 -393
  22. unitx-0.6.2/tests/test_quantity.py +0 -505
  23. {unitx-0.6.2 → unitx-0.6.3}/.github/workflows/ci.yml +0 -0
  24. {unitx-0.6.2 → unitx-0.6.3}/.github/workflows/release.yml +0 -0
  25. {unitx-0.6.2 → unitx-0.6.3}/.gitignore +0 -0
  26. {unitx-0.6.2 → unitx-0.6.3}/LICENSE +0 -0
  27. {unitx-0.6.2 → unitx-0.6.3}/conftest.py +0 -0
  28. {unitx-0.6.2 → unitx-0.6.3}/pyrightconfig.json +0 -0
  29. {unitx-0.6.2 → unitx-0.6.3}/src/unitx/__init__.py +0 -0
  30. {unitx-0.6.2 → unitx-0.6.3}/src/unitx/_constclass.py +0 -0
  31. {unitx-0.6.2 → unitx-0.6.3}/src/unitx/_unicode.py +0 -0
  32. {unitx-0.6.2 → unitx-0.6.3}/src/unitx/_unit_data.py +0 -0
  33. {unitx-0.6.2 → unitx-0.6.3}/src/unitx/exceptions.py +0 -0
  34. {unitx-0.6.2 → unitx-0.6.3}/src/unitx/py.typed +0 -0
  35. {unitx-0.6.2 → unitx-0.6.3}/src/unitx/si/__init__.py +0 -0
  36. {unitx-0.6.2 → unitx-0.6.3}/src/unitx/si/units.py +0 -0
  37. {unitx-0.6.2 → unitx-0.6.3}/src/unitx/singleunit.py +0 -0
  38. {unitx-0.6.2 → unitx-0.6.3}/src/unitx/unitparser.py +0 -0
  39. {unitx-0.6.2 → unitx-0.6.3}/tests/__init__.py +0 -0
  40. {unitx-0.6.2 → unitx-0.6.3}/tests/test_import.py +0 -0
  41. {unitx-0.6.2 → unitx-0.6.3}/tests/test_numeric.py +0 -0
  42. {unitx-0.6.2 → unitx-0.6.3}/tests/test_singleunit.py +0 -0
  43. {unitx-0.6.2 → unitx-0.6.3}/tests/test_typing.py +0 -0
  44. {unitx-0.6.2 → unitx-0.6.3}/tests/test_unicode.py +0 -0
  45. {unitx-0.6.2 → unitx-0.6.3}/tests/test_unit_data.py +0 -0
  46. {unitx-0.6.2 → unitx-0.6.3}/tests/test_unit_definition.py +0 -0
  47. {unitx-0.6.2 → unitx-0.6.3}/tests/test_unitparser.py +0 -0
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.3] - 2026-10-18
4
+
5
+ ### Added
6
+
7
+ - Add `Quantity.value_in(unit)` to return the same magnitude and concrete type as `.to(unit).value` without constructing a temporary quantity. The target unit is required and accepts a string or Unit, including ScaledUnit.
8
+ - Add `Quantity.isclose()` for symmetric real scalar comparisons with relative and absolute tolerances in coherent SI units. Nonzero absolute tolerances for dimensional quantities must carry matching units. Rational coefficients remain exact; residual irrational factors are approximated. Zero tolerances use exact equality.
9
+ - Make quantity truth values follow their magnitudes, so zero quantities are false regardless of unit.
10
+
11
+ ### Changed
12
+
13
+ - Apply native magnitude operators in operand order for arithmetic, unary operations, and numerical powers, preserving numerical promotion, Decimal contexts, signals, and exceptions. Support the scalar operand types accepted by the magnitude, including complex values and custom scalars.
14
+ - Keep Decimal conversions and arithmetic in Decimal. Rational conversions combine the magnitude and scale exactly before rounding once in the active context; identity conversions preserve the original magnitude. Addition and subtraction align integer operands in the Decimal domain when paired with Decimal magnitudes. Derived SI constants use Decimal arithmetic and `Decimal(str(math.pi))` where an approximate pi is required, with evaluation in the context active at import.
15
+ - Interpret float formal exponents by their shortest decimal text, without a denominator limit, consistently across Monomial, Dimension, Factor, SymbolicConstant, Unit, and Quantity. For example, `0.1` represents `1/10` and `1e-10` remains nonzero. Numeric scale inputs to Factor retain their existing denominator-limit and warning rules.
16
+ - Cache rationalized Factor decompositions lazily, independently of numerical value evaluation, while preserving equality, hashing, copying, and serialization.
17
+ - Update public type hints, examples, and regression coverage for conversion, native arithmetic, comparisons, and formal exponents.
18
+
19
+ ### Fixed
20
+
21
+ - Preserve native comparisons, including independent `!=`, Decimal signals, and complex ordering errors, when unit factors agree. Across different factors, compare builtin real values and complex components using exact coefficients, retaining consistent hashes for equal quantities and native unhashability for custom magnitudes.
22
+
23
+ ### Migration
24
+
25
+ - Decimal arithmetic no longer converts operands to Fraction automatically. Mixing Decimal with float or Fraction in arithmetic now raises the native TypeError; explicitly choose a common numerical type when needed. Decimal conversion and derived SI constants can therefore have different result types and rounding from earlier releases.
26
+ - Quantity numerical powers use the original exponent object; for example, an integer magnitude raised to `2.0` produces a float. Use `Fraction(1, 3)` for an exactly one-third formal exponent: the float `1 / 3` now represents the decimal `0.3333333333333333` and does not simplify as exactly one third.
27
+
3
28
  ## [0.6.2] - 2026-10-18
4
29
 
5
30
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: unitx
3
- Version: 0.6.2
3
+ Version: 0.6.3
4
4
  Summary: A modern, intuitive Python library for units, quantities, and dimensional analysis.
5
5
  Project-URL: Homepage, https://github.com/DaitTHU/unitx
6
6
  Project-URL: Issues, https://github.com/DaitTHU/unitx/issues
@@ -107,8 +107,9 @@ text spellings without adding an attribute to `si`.
107
107
 
108
108
  SI defining constants retain their exact decimal values. Measured constants
109
109
  are stored nominal values; measurement uncertainties are not propagated.
110
- Derived constants involving numerical approximations, such as `math.pi`, are
111
- evaluated numerically.
110
+ Derived Decimal constants are evaluated in the Decimal context active at
111
+ import. Formulas involving pi use `Decimal(str(math.pi))` as an approximation;
112
+ additional Decimal precision does not add precision to pi.
112
113
 
113
114
  ## Constructing units and quantities
114
115
 
@@ -192,16 +193,23 @@ grouped denominator factors and multiplication dots between adjacent numbers.
192
193
 
193
194
  ## Conversion and arithmetic
194
195
 
195
- Use `.to()` to convert between units with the same physical dimensions.
196
+ Use `.to()` to obtain a new quantity in a unit with the same physical dimensions,
197
+ or `.value_in(unit)` to obtain only its numerical value. The target unit is
198
+ required and accepts a string or `Unit`, including `ScaledUnit`.
199
+ `.value_in(unit)` returns the same value and concrete type as `.to(unit).value`,
200
+ without constructing a temporary quantity. Both follow the conversion rules
201
+ below. A conversion with scale one preserves the original magnitude; Decimal
202
+ magnitudes stay Decimal and other conversions can change the numerical type.
203
+ `.value` reads the original stored magnitude.
196
204
  Addition and subtraction convert the right operand to the left operand's unit:
197
205
 
198
206
  ```pycon
199
207
  >>> length = Quantity(1, 'm')
200
208
  >>> print(length + Quantity(50, 'cm'))
201
209
  3/2 m
202
- >>> length.to('cm').value
210
+ >>> length.value_in('cm')
203
211
  100
204
- >>> Quantity(1, 'cm').to('m').value
212
+ >>> Quantity(1, 'cm').value_in('m')
205
213
  Fraction(1, 100)
206
214
 
207
215
  ```
@@ -368,29 +376,90 @@ Fraction(1001, 1)
368
376
 
369
377
  Exact unit algebra and numerical magnitude evaluation have different rules:
370
378
 
371
- - Rational unit scales and exponents remain exact. Prefer `Fraction` or
372
- `Decimal` for exact exponents; float exponents may be approximated with an
373
- `InexactFloatWarning`.
374
- - Conversion and arithmetic can change the magnitude's type. Finite `Decimal`
375
- operands are converted exactly to `Fraction`, without Decimal context
376
- rounding.
377
- - Other magnitudes use native numerical arithmetic. In particular, integer
378
- division produces a float, and nonintegral magnitude powers can round or
379
- produce complex values.
379
+ - Formal exponents use one rule in Monomial, Dimension, Factor, Unit, and
380
+ Quantity: int/Fraction/Decimal values stay exact; floats use their shortest
381
+ decimal text without a denominator limit. Thus `0.1` means `1/10` and
382
+ `1e-10` remains nonzero. Use `Fraction(1, 3)` for exactly one third:
383
+ the float `1 / 3` means the decimal `0.3333333333333333` in formal exponents.
384
+ Factor's numeric scale inputs retain their separate denominator-limit rules.
385
+ - Quantity handles units; the magnitudes determine numerical arithmetic.
386
+ Operators use the magnitudes in operand order and preserve native promotion,
387
+ rounding, signals, and exceptions. Decimal arithmetic returns Decimal and
388
+ follows its active context. Mixing Decimal with float or Fraction raises
389
+ `TypeError`, as it does for the bare numbers; choose a common numerical type
390
+ explicitly when needed.
391
+ - Conversion with a rational scale keeps int/Fraction values exact. Decimal
392
+ conversion combines the value and scale exactly internally, then rounds once
393
+ to Decimal in the active context. Irrational unit scales are approximated as
394
+ floats, using their shortest decimal representations for Decimal conversion.
395
+ A scale of one preserves the magnitude without arithmetic or rounding.
396
+ Direct `Factor(value)` retains its separate exact Fraction evaluation rules.
397
+ - Addition and subtraction align the right operand with the left quantity's
398
+ unit before numerical arithmetic. Integers being aligned with a Decimal
399
+ magnitude are scaled in the Decimal domain, so `Decimal('1') * si.m + 50 *
400
+ si.cm` returns a Decimal magnitude of `1.5`. Explicit Fraction/float operands
401
+ keep their own numerical domains. Finite-precision conversion and arithmetic
402
+ can round; changing the result unit can therefore change rounding errors.
403
+ - Unary `+` and `-` apply the magnitude's native operators and preserve the unit.
404
+ Decimal results remain Decimal and follow its context precision, rounding,
405
+ signals, and exceptions; unary `+` can therefore change the numerical value.
406
+ - Integer division produces a float; Fraction arithmetic remains rational.
407
+ Use Fraction magnitudes explicitly for exact rational results. Powers pass
408
+ the original exponent to the magnitude: `2 ** 2.0` returns float and
409
+ `2 ** Decimal('0.5')` returns Decimal. Decimal magnitudes require int or
410
+ Decimal exponents, as in Python. The unit follows the common formal exponent
411
+ rule above; numerical evaluation still uses the original exponent object.
412
+ Exponents must be finite real scalars other than bool. Numerical powers can
413
+ round or produce complex values, following the magnitude's rules.
414
+ - Truth values follow the magnitude, so zero quantities are false regardless
415
+ of unit. Comparisons with equal unit factors use native magnitude operators,
416
+ including independent `!=`, Decimal signals/exceptions and complex ordering
417
+ errors. For different unit factors, builtin real values and complex
418
+ components are aligned exactly to avoid rounding-dependent equality or
419
+ hashes. Ordinary numeric subclasses use the same alignment as their base
420
+ types. Decimal/float signals apply to native comparisons with equal factors;
421
+ cross-factor alignment compares exact rational coefficients and does not
422
+ perform an additional comparison merely to reproduce signals. Decimal/Fraction
423
+ comparisons are supported natively.
380
424
  - Equality is exact, without a tolerance. Finite floats are compared as their
381
425
  stored binary rational values. Floating-point conversions and irrational
382
- powers may round; compare their values approximately in a common unit when
383
- appropriate.
426
+ powers may round; use `isclose()` for approximate real scalar comparisons.
427
+ It uses the symmetric condition
428
+ `abs(a - b) <= max(rel_tol * max(abs(a), abs(b)), abs_tol)` in coherent SI
429
+ units, with defaults `rel_tol=1e-9` and `abs_tol=0`. Swapping quantities
430
+ preserves the result. Nonzero absolute tolerances for dimensional quantities
431
+ must carry matching units; plain operands and plain absolute tolerances
432
+ represent unscaled dimensionless values. Tolerances must be finite and
433
+ nonnegative. Different dimensions raise `DimensionError`. NaNs are never
434
+ close, and infinities are close only to infinities of the same sign.
435
+ Finite rational coefficients stay exact; residual irrational unit factors
436
+ are approximated. With both tolerances zero, `isclose()` uses exact equality.
384
437
 
385
438
  ```pycon
386
- >>> from decimal import Decimal
439
+ >>> from decimal import Decimal, localcontext
387
440
  >>> from fractions import Fraction
441
+ >>> with localcontext() as context:
442
+ ... context.prec = 2
443
+ ... q = Quantity(Decimal('1.2345'), 'm')
444
+ ... print((+q).value, (-q).value, (q * 2).value)
445
+ 1.2 -1.2 2.5
388
446
  >>> Quantity(Decimal('0.1'), 'm').to('cm').value
389
- 10
447
+ Decimal('10')
390
448
  >>> (Quantity(Fraction(1), 'm') / Quantity(3, 's')).value
391
449
  Fraction(1, 3)
450
+ >>> (Quantity(2, 'm') ** 2.0).value
451
+ 4.0
452
+ >>> bool(Quantity(0, 'm'))
453
+ False
392
454
  >>> Quantity(0.1, '') == Quantity(Fraction(1, 10), '')
393
455
  False
456
+ >>> Quantity(0.1 + 0.2, 'm').isclose(Quantity(0.3, 'm'))
457
+ True
458
+ >>> a, b = Quantity(1, 'm'), Quantity(100.05, 'cm')
459
+ >>> a.isclose(b, rel_tol=0, abs_tol=Quantity(1, 'mm'))
460
+ True
461
+ >>> b.isclose(a, rel_tol=0, abs_tol=Quantity(1, 'mm'))
462
+ True
394
463
 
395
464
  ```
396
465
 
@@ -410,9 +479,10 @@ python -m pip install sympy
410
479
  - Custom magnitudes must support scalar arithmetic, return a Python `bool`
411
480
  from equality, and defer raw operations with `Unit` operands to its reflected
412
481
  operators.
413
- - Scalar multiplication and division of quantities currently accept `int`,
414
- `float`, `Fraction`, and `Decimal`. Complex magnitudes are supported, including
415
- results of fractional powers; ordering follows the magnitude's native rules.
482
+ - Scalar operations use the magnitude's supported operand types, including
483
+ complex numbers and custom scalar magnitudes. Custom magnitudes must also
484
+ defer operations with Quantity operands so that Quantity can handle units.
485
+ Ordering follows the magnitude's native rules.
416
486
  - Unit conversion is multiplicative. Offset temperature scales such as Celsius
417
487
  and Fahrenheit are not supported.
418
488
 
@@ -85,8 +85,9 @@ text spellings without adding an attribute to `si`.
85
85
 
86
86
  SI defining constants retain their exact decimal values. Measured constants
87
87
  are stored nominal values; measurement uncertainties are not propagated.
88
- Derived constants involving numerical approximations, such as `math.pi`, are
89
- evaluated numerically.
88
+ Derived Decimal constants are evaluated in the Decimal context active at
89
+ import. Formulas involving pi use `Decimal(str(math.pi))` as an approximation;
90
+ additional Decimal precision does not add precision to pi.
90
91
 
91
92
  ## Constructing units and quantities
92
93
 
@@ -170,16 +171,23 @@ grouped denominator factors and multiplication dots between adjacent numbers.
170
171
 
171
172
  ## Conversion and arithmetic
172
173
 
173
- Use `.to()` to convert between units with the same physical dimensions.
174
+ Use `.to()` to obtain a new quantity in a unit with the same physical dimensions,
175
+ or `.value_in(unit)` to obtain only its numerical value. The target unit is
176
+ required and accepts a string or `Unit`, including `ScaledUnit`.
177
+ `.value_in(unit)` returns the same value and concrete type as `.to(unit).value`,
178
+ without constructing a temporary quantity. Both follow the conversion rules
179
+ below. A conversion with scale one preserves the original magnitude; Decimal
180
+ magnitudes stay Decimal and other conversions can change the numerical type.
181
+ `.value` reads the original stored magnitude.
174
182
  Addition and subtraction convert the right operand to the left operand's unit:
175
183
 
176
184
  ```pycon
177
185
  >>> length = Quantity(1, 'm')
178
186
  >>> print(length + Quantity(50, 'cm'))
179
187
  3/2 m
180
- >>> length.to('cm').value
188
+ >>> length.value_in('cm')
181
189
  100
182
- >>> Quantity(1, 'cm').to('m').value
190
+ >>> Quantity(1, 'cm').value_in('m')
183
191
  Fraction(1, 100)
184
192
 
185
193
  ```
@@ -346,29 +354,90 @@ Fraction(1001, 1)
346
354
 
347
355
  Exact unit algebra and numerical magnitude evaluation have different rules:
348
356
 
349
- - Rational unit scales and exponents remain exact. Prefer `Fraction` or
350
- `Decimal` for exact exponents; float exponents may be approximated with an
351
- `InexactFloatWarning`.
352
- - Conversion and arithmetic can change the magnitude's type. Finite `Decimal`
353
- operands are converted exactly to `Fraction`, without Decimal context
354
- rounding.
355
- - Other magnitudes use native numerical arithmetic. In particular, integer
356
- division produces a float, and nonintegral magnitude powers can round or
357
- produce complex values.
357
+ - Formal exponents use one rule in Monomial, Dimension, Factor, Unit, and
358
+ Quantity: int/Fraction/Decimal values stay exact; floats use their shortest
359
+ decimal text without a denominator limit. Thus `0.1` means `1/10` and
360
+ `1e-10` remains nonzero. Use `Fraction(1, 3)` for exactly one third:
361
+ the float `1 / 3` means the decimal `0.3333333333333333` in formal exponents.
362
+ Factor's numeric scale inputs retain their separate denominator-limit rules.
363
+ - Quantity handles units; the magnitudes determine numerical arithmetic.
364
+ Operators use the magnitudes in operand order and preserve native promotion,
365
+ rounding, signals, and exceptions. Decimal arithmetic returns Decimal and
366
+ follows its active context. Mixing Decimal with float or Fraction raises
367
+ `TypeError`, as it does for the bare numbers; choose a common numerical type
368
+ explicitly when needed.
369
+ - Conversion with a rational scale keeps int/Fraction values exact. Decimal
370
+ conversion combines the value and scale exactly internally, then rounds once
371
+ to Decimal in the active context. Irrational unit scales are approximated as
372
+ floats, using their shortest decimal representations for Decimal conversion.
373
+ A scale of one preserves the magnitude without arithmetic or rounding.
374
+ Direct `Factor(value)` retains its separate exact Fraction evaluation rules.
375
+ - Addition and subtraction align the right operand with the left quantity's
376
+ unit before numerical arithmetic. Integers being aligned with a Decimal
377
+ magnitude are scaled in the Decimal domain, so `Decimal('1') * si.m + 50 *
378
+ si.cm` returns a Decimal magnitude of `1.5`. Explicit Fraction/float operands
379
+ keep their own numerical domains. Finite-precision conversion and arithmetic
380
+ can round; changing the result unit can therefore change rounding errors.
381
+ - Unary `+` and `-` apply the magnitude's native operators and preserve the unit.
382
+ Decimal results remain Decimal and follow its context precision, rounding,
383
+ signals, and exceptions; unary `+` can therefore change the numerical value.
384
+ - Integer division produces a float; Fraction arithmetic remains rational.
385
+ Use Fraction magnitudes explicitly for exact rational results. Powers pass
386
+ the original exponent to the magnitude: `2 ** 2.0` returns float and
387
+ `2 ** Decimal('0.5')` returns Decimal. Decimal magnitudes require int or
388
+ Decimal exponents, as in Python. The unit follows the common formal exponent
389
+ rule above; numerical evaluation still uses the original exponent object.
390
+ Exponents must be finite real scalars other than bool. Numerical powers can
391
+ round or produce complex values, following the magnitude's rules.
392
+ - Truth values follow the magnitude, so zero quantities are false regardless
393
+ of unit. Comparisons with equal unit factors use native magnitude operators,
394
+ including independent `!=`, Decimal signals/exceptions and complex ordering
395
+ errors. For different unit factors, builtin real values and complex
396
+ components are aligned exactly to avoid rounding-dependent equality or
397
+ hashes. Ordinary numeric subclasses use the same alignment as their base
398
+ types. Decimal/float signals apply to native comparisons with equal factors;
399
+ cross-factor alignment compares exact rational coefficients and does not
400
+ perform an additional comparison merely to reproduce signals. Decimal/Fraction
401
+ comparisons are supported natively.
358
402
  - Equality is exact, without a tolerance. Finite floats are compared as their
359
403
  stored binary rational values. Floating-point conversions and irrational
360
- powers may round; compare their values approximately in a common unit when
361
- appropriate.
404
+ powers may round; use `isclose()` for approximate real scalar comparisons.
405
+ It uses the symmetric condition
406
+ `abs(a - b) <= max(rel_tol * max(abs(a), abs(b)), abs_tol)` in coherent SI
407
+ units, with defaults `rel_tol=1e-9` and `abs_tol=0`. Swapping quantities
408
+ preserves the result. Nonzero absolute tolerances for dimensional quantities
409
+ must carry matching units; plain operands and plain absolute tolerances
410
+ represent unscaled dimensionless values. Tolerances must be finite and
411
+ nonnegative. Different dimensions raise `DimensionError`. NaNs are never
412
+ close, and infinities are close only to infinities of the same sign.
413
+ Finite rational coefficients stay exact; residual irrational unit factors
414
+ are approximated. With both tolerances zero, `isclose()` uses exact equality.
362
415
 
363
416
  ```pycon
364
- >>> from decimal import Decimal
417
+ >>> from decimal import Decimal, localcontext
365
418
  >>> from fractions import Fraction
419
+ >>> with localcontext() as context:
420
+ ... context.prec = 2
421
+ ... q = Quantity(Decimal('1.2345'), 'm')
422
+ ... print((+q).value, (-q).value, (q * 2).value)
423
+ 1.2 -1.2 2.5
366
424
  >>> Quantity(Decimal('0.1'), 'm').to('cm').value
367
- 10
425
+ Decimal('10')
368
426
  >>> (Quantity(Fraction(1), 'm') / Quantity(3, 's')).value
369
427
  Fraction(1, 3)
428
+ >>> (Quantity(2, 'm') ** 2.0).value
429
+ 4.0
430
+ >>> bool(Quantity(0, 'm'))
431
+ False
370
432
  >>> Quantity(0.1, '') == Quantity(Fraction(1, 10), '')
371
433
  False
434
+ >>> Quantity(0.1 + 0.2, 'm').isclose(Quantity(0.3, 'm'))
435
+ True
436
+ >>> a, b = Quantity(1, 'm'), Quantity(100.05, 'cm')
437
+ >>> a.isclose(b, rel_tol=0, abs_tol=Quantity(1, 'mm'))
438
+ True
439
+ >>> b.isclose(a, rel_tol=0, abs_tol=Quantity(1, 'mm'))
440
+ True
372
441
 
373
442
  ```
374
443
 
@@ -388,9 +457,10 @@ python -m pip install sympy
388
457
  - Custom magnitudes must support scalar arithmetic, return a Python `bool`
389
458
  from equality, and defer raw operations with `Unit` operands to its reflected
390
459
  operators.
391
- - Scalar multiplication and division of quantities currently accept `int`,
392
- `float`, `Fraction`, and `Decimal`. Complex magnitudes are supported, including
393
- results of fractional powers; ordering follows the magnitude's native rules.
460
+ - Scalar operations use the magnitude's supported operand types, including
461
+ complex numbers and custom scalar magnitudes. Custom magnitudes must also
462
+ defer operations with Quantity operands so that Quantity can handle units.
463
+ Ordering follows the magnitude's native rules.
394
464
  - Unit conversion is multiplicative. Offset temperature scales such as Celsius
395
465
  and Fahrenheit are not supported.
396
466
 
@@ -4,7 +4,7 @@ build-backend = 'hatchling.build'
4
4
 
5
5
  [project]
6
6
  name = 'unitx'
7
- version = '0.6.2'
7
+ version = '0.6.3'
8
8
  description = 'A modern, intuitive Python library for units, quantities, and dimensional analysis.'
9
9
  readme = 'README.md'
10
10
  requires-python = '>=3.12'
@@ -39,17 +39,18 @@ class Magnitude(Protocol):
39
39
  can produce complex values. NumPy scalars, arrays, and operation dispatch
40
40
  are not supported yet; NumPy integration is planned.
41
41
 
42
- Forward multiplication and division have an unspecified operand domain at
42
+ Forward binary arithmetic has an unspecified operand domain at
43
43
  this protocol boundary. `Never` lets concrete numeric methods satisfy the
44
44
  protocol without claiming that they accept every operand. In particular,
45
- raw magnitudes must defer Unit operands to Unit's reflected operators,
46
- which construct quantities and preserve the magnitude's type.
45
+ raw magnitudes must defer Unit operands to Unit's reflected multiplication
46
+ and division, which construct quantities and preserve the magnitude's type.
47
+ They must also defer Quantity operands so that Quantity can handle units.
47
48
  """
48
49
 
49
50
  def __pos__(self, /) -> Magnitude: ...
50
51
  def __neg__(self, /) -> Magnitude: ...
51
- def __add__(self, other: Any, /) -> Magnitude: ...
52
- def __sub__(self, other: Any, /) -> Magnitude: ...
52
+ def __add__(self, other: Never, /) -> Magnitude: ...
53
+ def __sub__(self, other: Never, /) -> Magnitude: ...
53
54
  def __mul__(self, other: Never, /) -> Magnitude: ...
54
55
  def __truediv__(self, other: Never, /) -> Magnitude: ...
55
56
  def __pow__(self, other: Any, /) -> Magnitude: ...
@@ -120,6 +121,16 @@ def as_int_if_integral(value: Fraction, /) -> int | Fraction:
120
121
  return value.numerator if value.denominator == 1 else value
121
122
 
122
123
 
124
+ def as_exponent(value: Fractionable, /) -> Fraction:
125
+ """Normalize formal exponents; floats use their shortest decimal text."""
126
+ if not isinstance(value, Fractionable) or isinstance(value, bool):
127
+ raise TypeError(f'Cannot convert {type(value).__name__} to Fraction.')
128
+ # Nonfinite floats still use Fraction's standard ValueError/OverflowError.
129
+ return Fraction(
130
+ str(float(value)) if isinstance(value, float) and math.isfinite(value) else value
131
+ )
132
+
133
+
123
134
  # Number theory functions: prime factorization and primality testing.
124
135
  # These are implemented here to avoid a dependency on `sympy`.
125
136
  # However, the implementation is limited and may not be as efficient or
@@ -4,7 +4,7 @@ from collections.abc import Iterable
4
4
  from fractions import Fraction
5
5
  from typing import Literal
6
6
 
7
- from ._numerics import Fractionable, as_fraction, as_int_if_integral
7
+ from ._numerics import Fractionable, as_exponent, as_int_if_integral
8
8
  from ._unicode import superscript
9
9
 
10
10
  __all__ = ['DIMENSIONLESS', 'Dimension']
@@ -26,7 +26,8 @@ class Dimension:
26
26
  - `J`: luminous
27
27
 
28
28
  Zero exponents represent the `DIMENSIONLESS` singleton. Use Fraction or
29
- Decimal for exact exponents; floats may be approximated with a warning.
29
+ Decimal for exact exponents; floats use their shortest decimal text,
30
+ without a denominator limit, consistently with Monomial, Factor, and Unit.
30
31
 
31
32
  >>> dim_velocity = Dimension(L=1, T=-1)
32
33
  >>> dim_force = Dimension(M=1, L=1, T=-2)
@@ -58,7 +59,7 @@ class Dimension:
58
59
  N: Fractionable = 0,
59
60
  J: Fractionable = 0,
60
61
  ) -> Dimension:
61
- exponents = tuple(as_fraction(exp) for exp in (T, L, M, I, Theta, N, J))
62
+ exponents = tuple(as_exponent(exp) for exp in (T, L, M, I, Theta, N, J))
62
63
  if not any(exponents):
63
64
  return DIMENSIONLESS
64
65
  obj = object.__new__(cls)
@@ -125,7 +126,7 @@ class Dimension:
125
126
  return self._from_iter(a - b for a, b in zip(self._exponents, other._exponents))
126
127
 
127
128
  def __pow__(self, power: Fractionable) -> Dimension:
128
- power_as_fraction = as_fraction(power)
129
+ power_as_fraction = as_exponent(power)
129
130
  return self._from_iter(exp * power_as_fraction for exp in self._exponents)
130
131
 
131
132
  def __rtruediv__(self, other: Literal[1]) -> Dimension:
@@ -9,7 +9,14 @@ from decimal import MAX_EMAX, MIN_EMIN, Context, Decimal, Overflow, localcontext
9
9
  from fractions import Fraction
10
10
  from typing import Any, overload
11
11
 
12
- from ._numerics import Fractionable, Magnitude, UnfactoredInteger, as_fraction, as_int_if_integral
12
+ from ._numerics import (
13
+ Fractionable,
14
+ Magnitude,
15
+ UnfactoredInteger,
16
+ as_exponent,
17
+ as_fraction,
18
+ as_int_if_integral,
19
+ )
13
20
  from .exceptions import _WARNING_SKIP_PREFIXES, IncompleteFactorWarning
14
21
  from .monomial import Monomial
15
22
 
@@ -162,7 +169,7 @@ class SymbolicConstant:
162
169
  def __pow__(self, other: Fractionable) -> Factor:
163
170
  if not isinstance(other, Fractionable):
164
171
  return NotImplemented
165
- return Factor._from_monomial(Monomial({self: as_fraction(other)}))
172
+ return Factor._from_monomial(Monomial({self: as_exponent(other)}))
166
173
 
167
174
  @property
168
175
  def symbol(self) -> str:
@@ -198,11 +205,13 @@ class Factor:
198
205
  and classification may be incomplete, and equivalent expressions may compare
199
206
  unequal. Install SymPy for more complete support.
200
207
 
201
- Construction and arithmetic convert float inputs and exponents to Fractions
202
- with denominators at most 1,000,000. Inexact conversions emit
203
- InexactFloatWarning. Small positive floats can round to zero and fail the
204
- positivity check: Factor(1e-10) warns and raises ValueError. Use Decimal or
205
- Fraction for exact inputs and exponents.
208
+ Construction and ordinary multiplication/division convert float scales to
209
+ Fractions with denominators at most 1,000,000. Inexact conversions emit
210
+ InexactFloatWarning. Small positive float scales can round to zero and fail
211
+ the positivity check: Factor(1e-10) warns and raises ValueError. Use Decimal
212
+ or Fraction for exact scales. Exponents, including from_dict(), instead use
213
+ a float's shortest decimal text without a denominator limit, consistently
214
+ with Monomial, Dimension, and Unit. Use Fraction(1, 3) for exactly one third.
206
215
 
207
216
  Factor equality compares stored bases and exponents. Numeric operands are
208
217
  compared as exact rational values, including the stored value of floats.
@@ -226,11 +235,13 @@ class Factor:
226
235
  Fraction(127, 50)
227
236
  """
228
237
 
229
- __slots__ = ('_monomial', '_value_cache')
238
+ __slots__ = ('_monomial', '_value_cache', '_decompose_cache')
230
239
  _monomial: Monomial[int | SymbolicConstant]
231
240
  _value_cache: int | Fraction | float | None
241
+ _decompose_cache: tuple[Fraction, Factor] | None
232
242
 
233
243
  def __init__(self, value: Fractionable | SymbolicConstant, /) -> None:
244
+ self._decompose_cache = None
234
245
  if isinstance(value, SymbolicConstant):
235
246
  self._monomial = Monomial({value: 1})
236
247
  self._value_cache = float(value)
@@ -264,6 +275,7 @@ class Factor:
264
275
  obj = object.__new__(cls)
265
276
  obj._monomial = monomial
266
277
  obj._value_cache = None
278
+ obj._decompose_cache = None
267
279
  return obj
268
280
 
269
281
  @classmethod
@@ -279,7 +291,7 @@ class Factor:
279
291
  raise TypeError(error_message)
280
292
  normalized: dict[int | SymbolicConstant, Fraction] = {}
281
293
  for base, exponent in components.items():
282
- normalized[base] = as_fraction(exponent)
294
+ normalized[base] = as_exponent(exponent)
283
295
  monomial = Monomial(normalized)
284
296
  if not all(isinstance(base, (int, SymbolicConstant)) for base in monomial.bases()):
285
297
  raise TypeError('All bases must be int or SymbolicConstant.')
@@ -317,6 +329,8 @@ class Factor:
317
329
  The rational part is a Fraction, and the irrational part is a Factor
318
330
  containing only SymbolicConstant bases and integer bases with *proper*
319
331
  fraction exponents. The product of the two parts equals the original factor.
332
+ Rationalized decompositions are cached lazily on this factor; default
333
+ decompositions are computed on each call, independently of the value cache.
320
334
 
321
335
  Parameters
322
336
  ----------
@@ -337,6 +351,8 @@ class Factor:
337
351
  >>> (Factor(2) ** Fraction(-3, 2)).decompose(rationalize_denominator=True)
338
352
  (Fraction(1, 4), Factor.from_dict({2: Fraction(1, 2)}))
339
353
  """
354
+ if rationalize_denominator and self._decompose_cache is not None:
355
+ return self._decompose_cache
340
356
  rational = Fraction(1)
341
357
  irrational: dict[int | SymbolicConstant, Fraction] = {}
342
358
  for base, exponent in self._monomial.components():
@@ -348,7 +364,10 @@ class Factor:
348
364
  integer_part = math.floor(exponent) if rationalize_denominator else int(exponent)
349
365
  rational *= Fraction(base) ** integer_part
350
366
  irrational[base] = exponent - integer_part
351
- return rational, Factor._from_monomial(Monomial._from_dict(irrational))
367
+ result = rational, Factor._from_monomial(Monomial._from_dict(irrational))
368
+ if rationalize_denominator:
369
+ self._decompose_cache = result
370
+ return result
352
371
 
353
372
  @property
354
373
  def value(self) -> int | Fraction | float:
@@ -490,7 +509,7 @@ class Factor:
490
509
  return NotImplemented
491
510
 
492
511
  def __pow__(self, other: Fractionable) -> Factor:
493
- return Factor._from_monomial(self._monomial ** as_fraction(other))
512
+ return Factor._from_monomial(self._monomial ** as_exponent(other))
494
513
 
495
514
  @overload
496
515
  def __call__(self, magnitude: Fraction) -> Fraction | float: ...
@@ -5,7 +5,7 @@ from fractions import Fraction
5
5
  from types import MappingProxyType
6
6
  from typing import Generic, Literal, TypeVar
7
7
 
8
- from ._numerics import Fractionable, as_fraction
8
+ from ._numerics import Fractionable, as_exponent
9
9
  from ._unicode import DOT, superscript
10
10
 
11
11
  __all__ = ['Monomial']
@@ -31,8 +31,8 @@ class Monomial(Generic[BaseT]):
31
31
  components : Mapping[BaseT, Fractionable], optional
32
32
  Bases and exponents. The mapping is copied and zero exponents are
33
33
  removed. Defaults to an empty monomial. Use int, Fraction, or Decimal
34
- for exact exponents. Float exponents may be approximated with an
35
- `InexactFloatWarning`.
34
+ for exact exponents. Float exponents use their shortest decimal text,
35
+ so 0.1 means Fraction(1, 10); no denominator limit is applied.
36
36
 
37
37
  Notes
38
38
  -----
@@ -76,7 +76,7 @@ class Monomial(Generic[BaseT]):
76
76
  raise TypeError(error_message)
77
77
  normalized: dict[BaseT, Fraction] = {}
78
78
  for base, exp in components.items():
79
- if exponent := as_fraction(exp):
79
+ if exponent := as_exponent(exp):
80
80
  normalized[base] = exponent
81
81
  obj = super().__new__(cls)
82
82
  object.__setattr__(obj, '_components', MappingProxyType(normalized))
@@ -193,7 +193,7 @@ class Monomial(Generic[BaseT]):
193
193
  def __pow__(self, other: Fractionable) -> Monomial[BaseT]:
194
194
  if not isinstance(other, Fractionable):
195
195
  return NotImplemented
196
- other_as_fraction = as_fraction(other)
196
+ other_as_fraction = as_exponent(other)
197
197
  if other_as_fraction == 0:
198
198
  return self._from_dict({})
199
199
  return self._from_dict({base: exp * other_as_fraction for base, exp in self.components()})