ucon 0.3.4__py3-none-any.whl → 0.3.5__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.
- tests/ucon/__init__.py +3 -0
- tests/ucon/test_algebra.py +3 -1
- tests/ucon/test_core.py +202 -48
- tests/ucon/test_quantity.py +26 -19
- tests/ucon/test_units.py +3 -1
- ucon/__init__.py +7 -1
- ucon/algebra.py +4 -0
- ucon/core.py +167 -155
- ucon/quantity.py +24 -77
- ucon/units.py +4 -0
- {ucon-0.3.4.dist-info → ucon-0.3.5.dist-info}/METADATA +45 -34
- ucon-0.3.5.dist-info/RECORD +16 -0
- {ucon-0.3.4.dist-info → ucon-0.3.5.dist-info}/WHEEL +1 -1
- ucon-0.3.5.dist-info/licenses/LICENSE +202 -0
- ucon-0.3.5.dist-info/licenses/NOTICE +28 -0
- ucon-0.3.4.dist-info/RECORD +0 -15
- ucon-0.3.4.dist-info/licenses/LICENSE +0 -21
- {ucon-0.3.4.dist-info → ucon-0.3.5.dist-info}/top_level.txt +0 -0
ucon/core.py
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
# © 2025 The Radiativity Company
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
# See the LICENSE file for details.
|
|
4
|
+
|
|
1
5
|
"""
|
|
2
6
|
ucon.core
|
|
3
7
|
==========
|
|
@@ -11,7 +15,7 @@ Classes
|
|
|
11
15
|
- :class:`Scale` — Enumerates SI and binary magnitude prefixes with algebraic closure over *, /
|
|
12
16
|
and with nearest-prefix lookup.
|
|
13
17
|
- :class:`Unit` — Measurable quantity descriptor with algebraic closure over *, /.
|
|
14
|
-
- :class:`
|
|
18
|
+
- :class:`UnitProduct` — Product/quotient of Units with simplification and readable rendering.
|
|
15
19
|
"""
|
|
16
20
|
from __future__ import annotations
|
|
17
21
|
|
|
@@ -236,19 +240,12 @@ class Scale(Enum):
|
|
|
236
240
|
|
|
237
241
|
def __mul__(self, other):
|
|
238
242
|
# --- Case 1: applying Scale to simple Unit --------------------
|
|
239
|
-
if isinstance(other, Unit)
|
|
240
|
-
|
|
241
|
-
raise ValueError(f"Cannot apply {self} to already scaled unit {other}")
|
|
242
|
-
return Unit(
|
|
243
|
-
*other.aliases,
|
|
244
|
-
name=other.name,
|
|
245
|
-
dimension=other.dimension,
|
|
246
|
-
scale=self,
|
|
247
|
-
)
|
|
243
|
+
if isinstance(other, Unit):
|
|
244
|
+
return UnitProduct({UnitFactor(unit=other, scale=self): 1})
|
|
248
245
|
|
|
249
246
|
# --- Case 2: other cases are NOT handled here -----------------
|
|
250
|
-
#
|
|
251
|
-
if isinstance(other,
|
|
247
|
+
# UnitProduct scaling is handled solely by UnitProduct.__rmul__
|
|
248
|
+
if isinstance(other, UnitProduct):
|
|
252
249
|
return NotImplemented
|
|
253
250
|
|
|
254
251
|
# --- Case 3: Scale * Scale algebra ----------------------------
|
|
@@ -297,6 +294,9 @@ class Unit:
|
|
|
297
294
|
"""
|
|
298
295
|
Represents a **unit of measure** associated with a :class:`Dimension`.
|
|
299
296
|
|
|
297
|
+
A Unit is an atomic symbol with no scale information. Scale is handled
|
|
298
|
+
separately by UnitFactor, which pairs a Unit with a Scale.
|
|
299
|
+
|
|
300
300
|
Parameters
|
|
301
301
|
----------
|
|
302
302
|
*aliases : str
|
|
@@ -305,20 +305,16 @@ class Unit:
|
|
|
305
305
|
Canonical name of the unit (e.g., "meter").
|
|
306
306
|
dimension : Dimension
|
|
307
307
|
The physical dimension this unit represents.
|
|
308
|
-
scale : Scale
|
|
309
|
-
Magnitude prefix (kilo, milli, etc.).
|
|
310
308
|
"""
|
|
311
309
|
def __init__(
|
|
312
310
|
self,
|
|
313
311
|
*aliases: str,
|
|
314
312
|
name: str = "",
|
|
315
313
|
dimension: Dimension = Dimension.none,
|
|
316
|
-
scale: Scale = Scale.one,
|
|
317
314
|
):
|
|
318
315
|
self.aliases = aliases
|
|
319
316
|
self.name = name
|
|
320
317
|
self.dimension = dimension
|
|
321
|
-
self.scale = scale
|
|
322
318
|
|
|
323
319
|
# ----------------- symbolic helpers -----------------
|
|
324
320
|
|
|
@@ -329,60 +325,41 @@ class Unit:
|
|
|
329
325
|
@property
|
|
330
326
|
def shorthand(self) -> str:
|
|
331
327
|
"""
|
|
332
|
-
Symbol used in expressions (e.g., '
|
|
328
|
+
Symbol used in expressions (e.g., 'm', 's').
|
|
333
329
|
For dimensionless units, returns ''.
|
|
330
|
+
|
|
331
|
+
Note: Scale prefixes are handled by UnitFactor.shorthand, not here.
|
|
334
332
|
"""
|
|
335
333
|
if self.dimension == Dimension.none:
|
|
336
334
|
return ""
|
|
337
|
-
prefix = getattr(self.scale, "shorthand", "") or ""
|
|
338
335
|
base = (self.aliases[0] if self.aliases else self.name) or ""
|
|
339
|
-
return
|
|
336
|
+
return base.strip()
|
|
340
337
|
|
|
341
338
|
# ----------------- algebra -----------------
|
|
342
339
|
|
|
343
340
|
def __mul__(self, other):
|
|
344
341
|
"""
|
|
345
|
-
Unit * Unit ->
|
|
346
|
-
Unit *
|
|
342
|
+
Unit * Unit -> UnitProduct
|
|
343
|
+
Unit * UnitProduct -> UnitProduct
|
|
347
344
|
"""
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
if isinstance(other, CompositeUnit):
|
|
351
|
-
# let CompositeUnit handle merging
|
|
345
|
+
if isinstance(other, UnitProduct):
|
|
346
|
+
# let UnitProduct handle merging
|
|
352
347
|
return other.__rmul__(self)
|
|
353
348
|
|
|
354
349
|
if isinstance(other, Unit):
|
|
355
|
-
return
|
|
356
|
-
|
|
357
|
-
return NotImplemented
|
|
358
|
-
|
|
359
|
-
def __rmul__(self, other):
|
|
360
|
-
"""
|
|
361
|
-
Scale * Unit -> scaled Unit
|
|
350
|
+
return UnitProduct({self: 1, other: 1})
|
|
362
351
|
|
|
363
|
-
NOTE:
|
|
364
|
-
- Only allow applying a Scale to an unscaled Unit.
|
|
365
|
-
- CompositeUnit scale handling is done in CompositeUnit.__rmul__.
|
|
366
|
-
"""
|
|
367
|
-
if isinstance(other, Scale):
|
|
368
|
-
if self.scale is not Scale.one:
|
|
369
|
-
raise ValueError(f"Cannot apply {other} to already scaled unit {self}")
|
|
370
|
-
return Unit(
|
|
371
|
-
*self.aliases,
|
|
372
|
-
name=self.name,
|
|
373
|
-
dimension=self.dimension,
|
|
374
|
-
scale=other,
|
|
375
|
-
)
|
|
376
352
|
return NotImplemented
|
|
377
353
|
|
|
378
354
|
def __truediv__(self, other):
|
|
379
355
|
"""
|
|
380
|
-
Unit / Unit
|
|
381
|
-
- If same unit => dimensionless Unit()
|
|
382
|
-
- If denominator is dimensionless => self
|
|
383
|
-
- Else => CompositeUnit
|
|
356
|
+
Unit / Unit or Unit / UnitProduct => UnitProduct
|
|
384
357
|
"""
|
|
385
|
-
|
|
358
|
+
if isinstance(other, UnitProduct):
|
|
359
|
+
combined = {self: 1.0}
|
|
360
|
+
for u, exp in other.factors.items():
|
|
361
|
+
combined[u] = combined.get(u, 0.0) - exp
|
|
362
|
+
return UnitProduct(combined)
|
|
386
363
|
|
|
387
364
|
if not isinstance(other, Unit):
|
|
388
365
|
return NotImplemented
|
|
@@ -390,7 +367,6 @@ class Unit:
|
|
|
390
367
|
# same physical unit → cancel to dimensionless
|
|
391
368
|
if (
|
|
392
369
|
self.dimension == other.dimension
|
|
393
|
-
and self.scale == other.scale
|
|
394
370
|
and self.name == other.name
|
|
395
371
|
and self._norm(self.aliases) == self._norm(other.aliases)
|
|
396
372
|
):
|
|
@@ -401,24 +377,23 @@ class Unit:
|
|
|
401
377
|
return self
|
|
402
378
|
|
|
403
379
|
# general case: form composite (self^1 * other^-1)
|
|
404
|
-
return
|
|
380
|
+
return UnitProduct({self: 1, other: -1})
|
|
405
381
|
|
|
406
382
|
def __pow__(self, power):
|
|
407
383
|
"""
|
|
408
|
-
Unit ** n =>
|
|
384
|
+
Unit ** n => UnitProduct with that exponent.
|
|
409
385
|
"""
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
return CompositeUnit({self: power})
|
|
386
|
+
return UnitProduct({self: power})
|
|
413
387
|
|
|
414
388
|
# ----------------- equality & hashing -----------------
|
|
415
389
|
|
|
416
390
|
def __eq__(self, other):
|
|
391
|
+
if isinstance(other, UnitProduct):
|
|
392
|
+
return other.__eq__(self)
|
|
417
393
|
if not isinstance(other, Unit):
|
|
418
394
|
return NotImplemented
|
|
419
395
|
return (
|
|
420
396
|
self.dimension == other.dimension
|
|
421
|
-
and self.scale == other.scale
|
|
422
397
|
and self.name == other.name
|
|
423
398
|
and self._norm(self.aliases) == self._norm(other.aliases)
|
|
424
399
|
)
|
|
@@ -429,7 +404,6 @@ class Unit:
|
|
|
429
404
|
self.name,
|
|
430
405
|
self._norm(self.aliases),
|
|
431
406
|
self.dimension,
|
|
432
|
-
self.scale,
|
|
433
407
|
)
|
|
434
408
|
)
|
|
435
409
|
|
|
@@ -446,26 +420,24 @@ class Unit:
|
|
|
446
420
|
return f"<Unit | {self.dimension.name}>"
|
|
447
421
|
|
|
448
422
|
|
|
449
|
-
from dataclasses import dataclass
|
|
450
|
-
|
|
451
423
|
@dataclass(frozen=True)
|
|
452
|
-
class
|
|
424
|
+
class UnitFactor:
|
|
453
425
|
"""
|
|
454
|
-
A structural pair (unit, scale) used as the *key* inside
|
|
426
|
+
A structural pair (unit, scale) used as the *key* inside UnitProduct.
|
|
455
427
|
|
|
456
428
|
- `unit` is a plain Unit (no extra meaning beyond dimension + aliases + name).
|
|
457
429
|
- `scale` is the *expression-level* Scale attached by the user (e.g. milli in mL).
|
|
458
430
|
|
|
459
|
-
Two
|
|
431
|
+
Two UnitFactors are equal iff both `unit` and `scale` are equal, so components
|
|
460
432
|
with the same base unit and same scale truly merge.
|
|
461
433
|
|
|
462
434
|
NOTE: We also implement equality / hashing in a way that allows lookups
|
|
463
435
|
by the underlying Unit to keep working:
|
|
464
436
|
|
|
465
|
-
m in
|
|
466
|
-
|
|
437
|
+
m in product.factors
|
|
438
|
+
product.factors[m]
|
|
467
439
|
|
|
468
|
-
still work even though the actual keys are
|
|
440
|
+
still work even though the actual keys are UnitFactor instances.
|
|
469
441
|
"""
|
|
470
442
|
|
|
471
443
|
unit: "Unit"
|
|
@@ -488,8 +460,8 @@ class FactoredUnit:
|
|
|
488
460
|
@property
|
|
489
461
|
def shorthand(self) -> str:
|
|
490
462
|
"""
|
|
491
|
-
Render something like 'mg' for
|
|
492
|
-
or 'L' for
|
|
463
|
+
Render something like 'mg' for UnitFactor(gram, milli),
|
|
464
|
+
or 'L' for UnitFactor(liter, one).
|
|
493
465
|
"""
|
|
494
466
|
base = ""
|
|
495
467
|
if self.aliases:
|
|
@@ -503,38 +475,35 @@ class FactoredUnit:
|
|
|
503
475
|
# ------------- Identity & hashing -------------------------------------
|
|
504
476
|
|
|
505
477
|
def __repr__(self) -> str:
|
|
506
|
-
return f"
|
|
478
|
+
return f"UnitFactor(unit={self.unit!r}, scale={self.scale!r})"
|
|
507
479
|
|
|
508
480
|
def __hash__(self) -> int:
|
|
509
481
|
# Important: share hash space with the underlying Unit so that
|
|
510
|
-
# lookups by Unit (e.g.,
|
|
482
|
+
# lookups by Unit (e.g., factors[unit]) work against UnitFactor keys.
|
|
511
483
|
return hash(self.unit)
|
|
512
484
|
|
|
513
485
|
def __eq__(self, other):
|
|
514
|
-
#
|
|
515
|
-
if isinstance(other,
|
|
486
|
+
# UnitFactor vs UnitFactor → structural equality
|
|
487
|
+
if isinstance(other, UnitFactor):
|
|
516
488
|
return (self.unit == other.unit) and (self.scale == other.scale)
|
|
517
489
|
|
|
518
|
-
#
|
|
519
|
-
#
|
|
520
|
-
# work when `
|
|
490
|
+
# UnitFactor vs Unit → equal iff underlying unit matches AND
|
|
491
|
+
# this UnitFactor has Scale.one (since Unit has no scale).
|
|
492
|
+
# This lets `unit in factors` work when `factors` is keyed by UnitFactor.
|
|
521
493
|
if isinstance(other, Unit):
|
|
522
|
-
return
|
|
523
|
-
self.unit == other
|
|
524
|
-
and getattr(other, "scale", Scale.one) == self.scale
|
|
525
|
-
)
|
|
494
|
+
return self.unit == other and self.scale is Scale.one
|
|
526
495
|
|
|
527
496
|
return NotImplemented
|
|
528
497
|
|
|
529
498
|
|
|
530
|
-
class
|
|
499
|
+
class UnitProduct:
|
|
531
500
|
"""
|
|
532
501
|
Represents a product or quotient of Units.
|
|
533
502
|
|
|
534
503
|
Key properties:
|
|
535
|
-
-
|
|
536
|
-
- Nested
|
|
537
|
-
- Identical
|
|
504
|
+
- factors is a dict[UnitFactor, float] mapping (unit, scale) pairs to exponents.
|
|
505
|
+
- Nested UnitProduct instances are flattened.
|
|
506
|
+
- Identical UnitFactors (same underlying unit and same scale) merge exponents.
|
|
538
507
|
- Units with net exponent ~0 are dropped.
|
|
539
508
|
- Dimensionless units (Dimension.none) are dropped.
|
|
540
509
|
- Scaled variants of the same base unit (e.g. L and mL) are grouped by
|
|
@@ -544,58 +513,57 @@ class CompositeUnit(Unit):
|
|
|
544
513
|
|
|
545
514
|
_SUPERSCRIPTS = str.maketrans("0123456789-.", "⁰¹²³⁴⁵⁶⁷⁸⁹⁻·")
|
|
546
515
|
|
|
547
|
-
def __init__(self,
|
|
516
|
+
def __init__(self, factors: dict[Unit, float]):
|
|
548
517
|
"""
|
|
549
|
-
Build a
|
|
518
|
+
Build a UnitProduct with UnitFactor keys, preserving user-provided scales.
|
|
550
519
|
|
|
551
520
|
Key principles:
|
|
552
521
|
- Never canonicalize scale (no implicit preference for Scale.one).
|
|
553
522
|
- Only collapse scaled variants of the same base unit when total exponent == 0.
|
|
554
523
|
- If only one scale variant exists in a group, preserve it exactly.
|
|
555
524
|
- If multiple variants exist and the group exponent != 0, preserve the FIRST
|
|
556
|
-
encountered
|
|
525
|
+
encountered UnitFactor (keeps user-intent scale).
|
|
557
526
|
"""
|
|
558
527
|
|
|
559
|
-
|
|
560
|
-
super().__init__(name="", dimension=Dimension.none, scale=Scale.one)
|
|
528
|
+
self.name = ""
|
|
561
529
|
self.aliases = ()
|
|
562
530
|
|
|
563
|
-
merged: dict[
|
|
531
|
+
merged: dict[UnitFactor, float] = {}
|
|
564
532
|
|
|
565
533
|
# -----------------------------------------------------
|
|
566
|
-
# Helper: normalize Units or
|
|
534
|
+
# Helper: normalize Units or UnitFactors to UnitFactor
|
|
567
535
|
# -----------------------------------------------------
|
|
568
536
|
def to_factored(unit_or_fu):
|
|
569
|
-
if isinstance(unit_or_fu,
|
|
537
|
+
if isinstance(unit_or_fu, UnitFactor):
|
|
570
538
|
return unit_or_fu
|
|
571
|
-
|
|
572
|
-
return
|
|
539
|
+
# Plain Unit has no scale - wrap with Scale.one
|
|
540
|
+
return UnitFactor(unit_or_fu, Scale.one)
|
|
573
541
|
|
|
574
542
|
# -----------------------------------------------------
|
|
575
|
-
# Helper: merge
|
|
543
|
+
# Helper: merge UnitFactors by full (unit, scale) identity
|
|
576
544
|
# -----------------------------------------------------
|
|
577
|
-
def merge_fu(fu:
|
|
545
|
+
def merge_fu(fu: UnitFactor, exponent: float):
|
|
578
546
|
for existing in merged:
|
|
579
|
-
if existing == fu: #
|
|
547
|
+
if existing == fu: # UnitFactor.__eq__ handles scale & unit compare
|
|
580
548
|
merged[existing] += exponent
|
|
581
549
|
return
|
|
582
550
|
merged[fu] = merged.get(fu, 0.0) + exponent
|
|
583
551
|
|
|
584
552
|
# -----------------------------------------------------
|
|
585
|
-
# Step 1 — Flatten sources into
|
|
553
|
+
# Step 1 — Flatten sources into UnitFactors
|
|
586
554
|
# -----------------------------------------------------
|
|
587
|
-
for key, exp in
|
|
588
|
-
if isinstance(key,
|
|
589
|
-
# Flatten nested
|
|
590
|
-
for inner_fu, inner_exp in key.
|
|
555
|
+
for key, exp in factors.items():
|
|
556
|
+
if isinstance(key, UnitProduct):
|
|
557
|
+
# Flatten nested UnitProducts
|
|
558
|
+
for inner_fu, inner_exp in key.factors.items():
|
|
591
559
|
merge_fu(inner_fu, inner_exp * exp)
|
|
592
560
|
else:
|
|
593
561
|
merge_fu(to_factored(key), exp)
|
|
594
562
|
|
|
595
563
|
# -----------------------------------------------------
|
|
596
|
-
# Step 2 — Remove exponent-zero & dimensionless
|
|
564
|
+
# Step 2 — Remove exponent-zero & dimensionless UnitFactors
|
|
597
565
|
# -----------------------------------------------------
|
|
598
|
-
simplified: dict[
|
|
566
|
+
simplified: dict[UnitFactor, float] = {}
|
|
599
567
|
for fu, exp in merged.items():
|
|
600
568
|
if abs(exp) < 1e-12:
|
|
601
569
|
continue
|
|
@@ -606,7 +574,7 @@ class CompositeUnit(Unit):
|
|
|
606
574
|
# -----------------------------------------------------
|
|
607
575
|
# Step 3 — Group by base-unit identity (ignoring scale)
|
|
608
576
|
# -----------------------------------------------------
|
|
609
|
-
groups: dict[tuple, dict[
|
|
577
|
+
groups: dict[tuple, dict[UnitFactor, float]] = {}
|
|
610
578
|
|
|
611
579
|
for fu, exp in simplified.items():
|
|
612
580
|
alias_key = tuple(sorted(a for a in fu.aliases if a))
|
|
@@ -617,13 +585,25 @@ class CompositeUnit(Unit):
|
|
|
617
585
|
# -----------------------------------------------------
|
|
618
586
|
# Step 4 — Resolve groups while preserving user scale
|
|
619
587
|
# -----------------------------------------------------
|
|
620
|
-
final: dict[
|
|
588
|
+
final: dict[UnitFactor, float] = {}
|
|
589
|
+
|
|
590
|
+
# Track residual scale NUMERICALLY from cancelled units.
|
|
591
|
+
# This accumulates scale factors when units cancel dimensionally
|
|
592
|
+
# but have different scales (e.g., gram / decagram = factor of 0.1).
|
|
593
|
+
# We use a numeric value rather than Scale to preserve precision
|
|
594
|
+
# for arbitrary combinations (especially binary scales like kibi).
|
|
595
|
+
residual_scale_factor: float = 1.0
|
|
621
596
|
|
|
622
597
|
for group_key, bucket in groups.items():
|
|
623
598
|
total_exp = sum(bucket.values())
|
|
624
599
|
|
|
625
|
-
# 4A — Full cancellation
|
|
600
|
+
# 4A — Full cancellation (dimensionally)
|
|
601
|
+
# BUT: we must preserve the NET SCALE from the cancelled units!
|
|
626
602
|
if abs(total_exp) < 1e-12:
|
|
603
|
+
# Compute the scale contribution from this cancelled group
|
|
604
|
+
# Each factor contributes: factor.scale.value.evaluated ** exponent
|
|
605
|
+
for fu, exp in bucket.items():
|
|
606
|
+
residual_scale_factor *= fu.scale.value.evaluated ** exp
|
|
627
607
|
continue
|
|
628
608
|
|
|
629
609
|
# 4B — Only one scale variant → preserve exactly
|
|
@@ -633,22 +613,32 @@ class CompositeUnit(Unit):
|
|
|
633
613
|
continue
|
|
634
614
|
|
|
635
615
|
# 4C — Multiple scale variants, exponent != 0:
|
|
636
|
-
# preserve FIRST encountered
|
|
616
|
+
# preserve FIRST encountered UnitFactor.
|
|
637
617
|
# This ensures user scale is preserved.
|
|
618
|
+
# BUT: also accumulate scale from the OTHER variants
|
|
638
619
|
first_fu = next(iter(bucket.keys()))
|
|
639
620
|
final[first_fu] = total_exp
|
|
640
621
|
|
|
641
|
-
|
|
622
|
+
# The first_fu will be kept with total_exp, so its scale^total_exp
|
|
623
|
+
# will be folded normally. We need to account for the OTHER factors'
|
|
624
|
+
# scale contributions that are being "absorbed" into this representative.
|
|
625
|
+
for fu, exp in bucket.items():
|
|
626
|
+
if fu is not first_fu:
|
|
627
|
+
# This factor is being absorbed; its scale contribution
|
|
628
|
+
# relative to first_fu needs to be captured
|
|
629
|
+
residual_scale_factor *= fu.scale.value.evaluated ** exp
|
|
642
630
|
|
|
643
|
-
|
|
644
|
-
|
|
631
|
+
self.factors = final
|
|
632
|
+
|
|
633
|
+
# Store the residual scale factor from cancellations (numeric)
|
|
634
|
+
self._residual_scale_factor = residual_scale_factor
|
|
645
635
|
|
|
646
636
|
# -----------------------------------------------------
|
|
647
637
|
# Step 5 — Derive dimension via exponent algebra
|
|
648
638
|
# -----------------------------------------------------
|
|
649
639
|
self.dimension = reduce(
|
|
650
640
|
lambda acc, item: acc * (item[0].dimension ** item[1]),
|
|
651
|
-
self.
|
|
641
|
+
self.factors.items(),
|
|
652
642
|
Dimension.none,
|
|
653
643
|
)
|
|
654
644
|
|
|
@@ -658,7 +648,7 @@ class CompositeUnit(Unit):
|
|
|
658
648
|
def _append(cls, unit: Unit, power: float, num: list[str], den: list[str]) -> None:
|
|
659
649
|
"""
|
|
660
650
|
Append a unit^power into numerator or denominator list. Works with
|
|
661
|
-
both Unit and
|
|
651
|
+
both Unit and UnitFactor, since UnitFactor exposes dimension,
|
|
662
652
|
shorthand, name, and aliases.
|
|
663
653
|
"""
|
|
664
654
|
if unit.dimension == Dimension.none:
|
|
@@ -682,13 +672,13 @@ class CompositeUnit(Unit):
|
|
|
682
672
|
"""
|
|
683
673
|
Human-readable composite unit string, e.g. 'kg·m/s²'.
|
|
684
674
|
"""
|
|
685
|
-
if not self.
|
|
675
|
+
if not self.factors:
|
|
686
676
|
return ""
|
|
687
677
|
|
|
688
678
|
num: list[str] = []
|
|
689
679
|
den: list[str] = []
|
|
690
680
|
|
|
691
|
-
for u, power in self.
|
|
681
|
+
for u, power in self.factors.items():
|
|
692
682
|
self._append(u, power, num, den)
|
|
693
683
|
|
|
694
684
|
numerator = "·".join(num) or "1"
|
|
@@ -697,19 +687,45 @@ class CompositeUnit(Unit):
|
|
|
697
687
|
return numerator
|
|
698
688
|
return f"{numerator}/{denominator}"
|
|
699
689
|
|
|
690
|
+
def fold_scale(self) -> float:
|
|
691
|
+
"""
|
|
692
|
+
Compute the overall numeric scale factor of this UnitProduct by folding
|
|
693
|
+
together the scales of each UnitFactor raised to its exponent,
|
|
694
|
+
plus any residual scale factor from cancelled units.
|
|
695
|
+
|
|
696
|
+
Returns
|
|
697
|
+
-------
|
|
698
|
+
float
|
|
699
|
+
The combined numeric scale factor.
|
|
700
|
+
"""
|
|
701
|
+
result = getattr(self, '_residual_scale_factor', 1.0)
|
|
702
|
+
for factor, power in self.factors.items():
|
|
703
|
+
result *= factor.scale.value.evaluated ** power
|
|
704
|
+
return result
|
|
705
|
+
|
|
706
|
+
# ------------- Helpers ---------------------------------------------------
|
|
707
|
+
|
|
708
|
+
def _norm(self, aliases: tuple[str, ...]) -> tuple[str, ...]:
|
|
709
|
+
"""Normalize alias bag: drop empty/whitespace-only aliases."""
|
|
710
|
+
return tuple(a for a in aliases if a.strip())
|
|
711
|
+
|
|
712
|
+
def __pow__(self, power):
|
|
713
|
+
"""UnitProduct ** n => new UnitProduct with scaled exponents."""
|
|
714
|
+
return UnitProduct({u: exp * power for u, exp in self.factors.items()})
|
|
715
|
+
|
|
700
716
|
# ------------- Algebra ---------------------------------------------------
|
|
701
717
|
|
|
702
718
|
def __mul__(self, other):
|
|
703
719
|
if isinstance(other, Unit):
|
|
704
|
-
combined = self.
|
|
720
|
+
combined = self.factors.copy()
|
|
705
721
|
combined[other] = combined.get(other, 0.0) + 1.0
|
|
706
|
-
return
|
|
722
|
+
return UnitProduct(combined)
|
|
707
723
|
|
|
708
|
-
if isinstance(other,
|
|
709
|
-
combined = self.
|
|
710
|
-
for u, exp in other.
|
|
724
|
+
if isinstance(other, UnitProduct):
|
|
725
|
+
combined = self.factors.copy()
|
|
726
|
+
for u, exp in other.factors.items():
|
|
711
727
|
combined[u] = combined.get(u, 0.0) + exp
|
|
712
|
-
return
|
|
728
|
+
return UnitProduct(combined)
|
|
713
729
|
|
|
714
730
|
if isinstance(other, Scale):
|
|
715
731
|
# respect the convention: Scale * Unit, not Unit * Scale
|
|
@@ -718,24 +734,22 @@ class CompositeUnit(Unit):
|
|
|
718
734
|
return NotImplemented
|
|
719
735
|
|
|
720
736
|
def __rmul__(self, other):
|
|
721
|
-
# Scale *
|
|
737
|
+
# Scale * UnitProduct → apply scale to a canonical sink unit
|
|
722
738
|
if isinstance(other, Scale):
|
|
723
|
-
if not self.
|
|
739
|
+
if not self.factors:
|
|
724
740
|
return self
|
|
725
741
|
|
|
726
742
|
# heuristic: choose unit with positive exponent first, else first unit
|
|
727
|
-
items = list(self.
|
|
743
|
+
items = list(self.factors.items())
|
|
728
744
|
positives = [(u, e) for (u, e) in items if e > 0]
|
|
729
745
|
sink, _ = (positives or items)[0]
|
|
730
746
|
|
|
731
|
-
# Normalize sink into a
|
|
732
|
-
if isinstance(sink,
|
|
747
|
+
# Normalize sink into a UnitFactor
|
|
748
|
+
if isinstance(sink, UnitFactor):
|
|
733
749
|
sink_fu = sink
|
|
734
750
|
else:
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
scale=getattr(sink, "scale", Scale.one),
|
|
738
|
-
)
|
|
751
|
+
# Plain Unit has no scale
|
|
752
|
+
sink_fu = UnitFactor(unit=sink, scale=Scale.one)
|
|
739
753
|
|
|
740
754
|
# Combine scales (expression-level)
|
|
741
755
|
if sink_fu.scale is not Scale.one:
|
|
@@ -743,64 +757,62 @@ class CompositeUnit(Unit):
|
|
|
743
757
|
else:
|
|
744
758
|
new_scale = other
|
|
745
759
|
|
|
746
|
-
scaled_sink =
|
|
760
|
+
scaled_sink = UnitFactor(
|
|
747
761
|
unit=sink_fu.unit,
|
|
748
762
|
scale=new_scale,
|
|
749
763
|
)
|
|
750
764
|
|
|
751
|
-
combined: dict[
|
|
752
|
-
for u, exp in self.
|
|
753
|
-
# Normalize each key into
|
|
754
|
-
if isinstance(u,
|
|
765
|
+
combined: dict[UnitFactor, float] = {}
|
|
766
|
+
for u, exp in self.factors.items():
|
|
767
|
+
# Normalize each key into UnitFactor as we go
|
|
768
|
+
if isinstance(u, UnitFactor):
|
|
755
769
|
fu = u
|
|
756
770
|
else:
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
scale=getattr(u, "scale", Scale.one),
|
|
760
|
-
)
|
|
771
|
+
# Plain Unit has no scale
|
|
772
|
+
fu = UnitFactor(unit=u, scale=Scale.one)
|
|
761
773
|
|
|
762
774
|
if fu is sink_fu:
|
|
763
775
|
combined[scaled_sink] = combined.get(scaled_sink, 0.0) + exp
|
|
764
776
|
else:
|
|
765
777
|
combined[fu] = combined.get(fu, 0.0) + exp
|
|
766
778
|
|
|
767
|
-
return
|
|
779
|
+
return UnitProduct(combined)
|
|
768
780
|
|
|
769
781
|
if isinstance(other, Unit):
|
|
770
782
|
combined: dict[Unit, float] = {other: 1.0}
|
|
771
|
-
for u, e in self.
|
|
783
|
+
for u, e in self.factors.items():
|
|
772
784
|
combined[u] = combined.get(u, 0.0) + e
|
|
773
|
-
return
|
|
785
|
+
return UnitProduct(combined)
|
|
774
786
|
|
|
775
787
|
return NotImplemented
|
|
776
788
|
|
|
777
789
|
def __truediv__(self, other):
|
|
778
790
|
if isinstance(other, Unit):
|
|
779
|
-
combined = self.
|
|
791
|
+
combined = self.factors.copy()
|
|
780
792
|
combined[other] = combined.get(other, 0.0) - 1.0
|
|
781
|
-
return
|
|
793
|
+
return UnitProduct(combined)
|
|
782
794
|
|
|
783
|
-
if isinstance(other,
|
|
784
|
-
combined = self.
|
|
785
|
-
for u, exp in other.
|
|
795
|
+
if isinstance(other, UnitProduct):
|
|
796
|
+
combined = self.factors.copy()
|
|
797
|
+
for u, exp in other.factors.items():
|
|
786
798
|
combined[u] = combined.get(u, 0.0) - exp
|
|
787
|
-
return
|
|
799
|
+
return UnitProduct(combined)
|
|
788
800
|
|
|
789
801
|
return NotImplemented
|
|
790
802
|
|
|
791
803
|
# ------------- Identity & hashing ---------------------------------------
|
|
792
804
|
|
|
793
805
|
def __repr__(self):
|
|
794
|
-
return f"<
|
|
806
|
+
return f"<{self.__class__.__name__} {self.shorthand}>"
|
|
795
807
|
|
|
796
808
|
def __eq__(self, other):
|
|
797
|
-
if isinstance(other, Unit)
|
|
809
|
+
if isinstance(other, Unit):
|
|
798
810
|
# Only equal to a plain Unit if we have exactly that unit^1
|
|
799
|
-
# Here, the tuple comparison will invoke
|
|
800
|
-
# on the key when
|
|
801
|
-
return len(self.
|
|
802
|
-
return isinstance(other,
|
|
811
|
+
# Here, the tuple comparison will invoke UnitFactor.__eq__(Unit)
|
|
812
|
+
# on the key when factors are keyed by UnitFactor.
|
|
813
|
+
return len(self.factors) == 1 and list(self.factors.items()) == [(other, 1.0)]
|
|
814
|
+
return isinstance(other, UnitProduct) and self.factors == other.factors
|
|
803
815
|
|
|
804
816
|
def __hash__(self):
|
|
805
|
-
# Sort by name;
|
|
806
|
-
return hash(tuple(sorted(self.
|
|
817
|
+
# Sort by name; UnitFactor exposes .name, so this is stable.
|
|
818
|
+
return hash(tuple(sorted(self.factors.items(), key=lambda x: x[0].name)))
|