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.
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:`CompositeUnit` — Product/quotient of Units with simplification and readable rendering.
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) and not isinstance(other, CompositeUnit):
240
- if getattr(other, "scale", Scale.one) is not Scale.one:
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
- # CompositeUnit scaling is handled solely by CompositeUnit.__rmul__
251
- if isinstance(other, CompositeUnit):
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., 'kg', 'm', 's').
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 f"{prefix}{base}".strip()
336
+ return base.strip()
340
337
 
341
338
  # ----------------- algebra -----------------
342
339
 
343
340
  def __mul__(self, other):
344
341
  """
345
- Unit * Unit -> CompositeUnit
346
- Unit * CompositeUnit -> CompositeUnit
342
+ Unit * Unit -> UnitProduct
343
+ Unit * UnitProduct -> UnitProduct
347
344
  """
348
- from ucon.core import CompositeUnit # local import to avoid circulars
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 CompositeUnit({self: 1, other: 1})
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
- from ucon.core import CompositeUnit # local import
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 CompositeUnit({self: 1, other: -1})
380
+ return UnitProduct({self: 1, other: -1})
405
381
 
406
382
  def __pow__(self, power):
407
383
  """
408
- Unit ** n => CompositeUnit with that exponent.
384
+ Unit ** n => UnitProduct with that exponent.
409
385
  """
410
- from ucon.core import CompositeUnit # local import
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 FactoredUnit:
424
+ class UnitFactor:
453
425
  """
454
- A structural pair (unit, scale) used as the *key* inside CompositeUnit.
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 FactoredUnits are equal iff both `unit` and `scale` are equal, so components
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 composite.components
466
- composite.components[m]
437
+ m in product.factors
438
+ product.factors[m]
467
439
 
468
- still work even though the actual keys are FactoredUnit instances.
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 FactoredUnit(gram, milli),
492
- or 'L' for FactoredUnit(liter, one).
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"FactoredUnit(unit={self.unit!r}, scale={self.scale!r})"
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., components[unit]) work against FactoredUnit keys.
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
- # FactoredUnit vs FactoredUnit → structural equality
515
- if isinstance(other, FactoredUnit):
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
- # FactoredUnit vs Unit → equal iff underlying unit matches and the
519
- # Unit's own scale matches our scale. This lets `unit in components`
520
- # work when `components` is keyed by FactoredUnit.
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 CompositeUnit(Unit):
499
+ class UnitProduct:
531
500
  """
532
501
  Represents a product or quotient of Units.
533
502
 
534
503
  Key properties:
535
- - components is a dict[FactoredUnit, float] mapping (unit, scale) pairs to exponents.
536
- - Nested CompositeUnit instances are flattened.
537
- - Identical factored units (same underlying unit and same scale) merge exponents.
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, components: dict[Unit, float]):
516
+ def __init__(self, factors: dict[Unit, float]):
548
517
  """
549
- Build a CompositeUnit with FactoredUnit keys, preserving user-provided scales.
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 FactoredUnit (keeps user-intent scale).
525
+ encountered UnitFactor (keeps user-intent scale).
557
526
  """
558
527
 
559
- # CompositeUnit always starts dimensionless & unscaled
560
- super().__init__(name="", dimension=Dimension.none, scale=Scale.one)
528
+ self.name = ""
561
529
  self.aliases = ()
562
530
 
563
- merged: dict[FactoredUnit, float] = {}
531
+ merged: dict[UnitFactor, float] = {}
564
532
 
565
533
  # -----------------------------------------------------
566
- # Helper: normalize Units or FactoredUnits to FactoredUnit
534
+ # Helper: normalize Units or UnitFactors to UnitFactor
567
535
  # -----------------------------------------------------
568
536
  def to_factored(unit_or_fu):
569
- if isinstance(unit_or_fu, FactoredUnit):
537
+ if isinstance(unit_or_fu, UnitFactor):
570
538
  return unit_or_fu
571
- scale = getattr(unit_or_fu, "scale", Scale.one)
572
- return FactoredUnit(unit_or_fu, scale)
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 FactoredUnits by full (unit, scale) identity
543
+ # Helper: merge UnitFactors by full (unit, scale) identity
576
544
  # -----------------------------------------------------
577
- def merge_fu(fu: FactoredUnit, exponent: float):
545
+ def merge_fu(fu: UnitFactor, exponent: float):
578
546
  for existing in merged:
579
- if existing == fu: # FactoredUnit.__eq__ handles scale & unit compare
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 FactoredUnits
553
+ # Step 1 — Flatten sources into UnitFactors
586
554
  # -----------------------------------------------------
587
- for key, exp in components.items():
588
- if isinstance(key, CompositeUnit):
589
- # Flatten nested composites
590
- for inner_fu, inner_exp in key.components.items():
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 FactoredUnits
564
+ # Step 2 — Remove exponent-zero & dimensionless UnitFactors
597
565
  # -----------------------------------------------------
598
- simplified: dict[FactoredUnit, float] = {}
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[FactoredUnit, float]] = {}
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[FactoredUnit, float] = {}
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 FactoredUnit.
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
- self.components = final
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
- # CompositeUnit itself has no global scale
644
- self.scale = Scale.one
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.components.items(),
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 FactoredUnit, since FactoredUnit exposes dimension,
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.components:
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.components.items():
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.components.copy()
720
+ combined = self.factors.copy()
705
721
  combined[other] = combined.get(other, 0.0) + 1.0
706
- return CompositeUnit(combined)
722
+ return UnitProduct(combined)
707
723
 
708
- if isinstance(other, CompositeUnit):
709
- combined = self.components.copy()
710
- for u, exp in other.components.items():
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 CompositeUnit(combined)
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 * CompositeUnit → apply scale to a canonical sink unit
737
+ # Scale * UnitProduct → apply scale to a canonical sink unit
722
738
  if isinstance(other, Scale):
723
- if not self.components:
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.components.items())
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 FactoredUnit
732
- if isinstance(sink, FactoredUnit):
747
+ # Normalize sink into a UnitFactor
748
+ if isinstance(sink, UnitFactor):
733
749
  sink_fu = sink
734
750
  else:
735
- sink_fu = FactoredUnit(
736
- unit=sink,
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 = FactoredUnit(
760
+ scaled_sink = UnitFactor(
747
761
  unit=sink_fu.unit,
748
762
  scale=new_scale,
749
763
  )
750
764
 
751
- combined: dict[FactoredUnit, float] = {}
752
- for u, exp in self.components.items():
753
- # Normalize each key into FactoredUnit as we go
754
- if isinstance(u, FactoredUnit):
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
- fu = FactoredUnit(
758
- unit=u,
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 CompositeUnit(combined)
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.components.items():
783
+ for u, e in self.factors.items():
772
784
  combined[u] = combined.get(u, 0.0) + e
773
- return CompositeUnit(combined)
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.components.copy()
791
+ combined = self.factors.copy()
780
792
  combined[other] = combined.get(other, 0.0) - 1.0
781
- return CompositeUnit(combined)
793
+ return UnitProduct(combined)
782
794
 
783
- if isinstance(other, CompositeUnit):
784
- combined = self.components.copy()
785
- for u, exp in other.components.items():
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 CompositeUnit(combined)
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"<CompositeUnit {self.shorthand}>"
806
+ return f"<{self.__class__.__name__} {self.shorthand}>"
795
807
 
796
808
  def __eq__(self, other):
797
- if isinstance(other, Unit) and not isinstance(other, CompositeUnit):
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 FactoredUnit.__eq__(Unit)
800
- # on the key when components are keyed by FactoredUnit.
801
- return len(self.components) == 1 and list(self.components.items()) == [(other, 1.0)]
802
- return isinstance(other, CompositeUnit) and self.components == other.components
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; FactoredUnit exposes .name, so this is stable.
806
- return hash(tuple(sorted(self.components.items(), key=lambda x: x[0].name)))
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)))