Source code for quantium.core.quantity

"""
quantium.core.quantity
======================

Defines the `Unit` and `Quantity` classes for representing and manipulating
physical quantities with units and dimensions in a consistent, SI-based system.

This module provides:
- A `Unit` class for defining physical units (e.g., meter, second, kilogram)
  with their corresponding scaling factors to SI base units and dimensional
  representation.
- A `Quantity` class for representing values with both magnitude and units,
  enabling dimensional arithmetic and automatic unit consistency checks.

The system supports:
- Dimensional analysis and arithmetic operations between quantities.
- Conversion between compatible units.
- Creation of derived quantities via multiplication, division, and exponentiation.
"""



from __future__ import annotations

from math import isclose
from typing import Union

from quantium.core.dimensions import DIM_0, Dim, dim_div, dim_mul, dim_pow
from quantium.core.unit import UNIT_SIMPLIFIER, Unit
from quantium.io.unit_simplifier import SymbolComponents
from quantium.units.parser import extract_unit_expr

Number = Union[int, float]


[docs] class Quantity: """ Represents a physical quantity with magnitude, dimension, and unit, supporting arithmetic operations and unit conversions while maintaining dimensional consistency. Attributes ---------- _mag_si : float The magnitude of the quantity expressed in SI base units. dim : dict or custom dimension object The physical dimension of the quantity (e.g., length, time, mass). unit : Unit The unit in which the quantity is currently represented. """ __slots__ = ["_mag_si", "dim", "unit"] def __init__(self, value : float, unit : Unit): self._mag_si = float(value) * unit.scale_to_si self.dim = unit.dim self.unit = unit def _symbol_component_map(self, priority: int) -> SymbolComponents: return UNIT_SIMPLIFIER.unit_symbol_map(self.unit, priority) def _check_dim_compatible(self, other: object) -> None: """Internal helper to raise TypeError on dimension mismatch.""" if not isinstance(other, Quantity): # Allow comparison with 0 (dimensionless) if isinstance(other, (int, float)) and other == 0: if self.dim != DIM_0: raise TypeError("Cannot compare a dimensioned quantity to 0") return # It's a 0 dimensionless quantity, OK raise TypeError(f"Cannot compare Quantity with type {type(other)}") if self.dim != other.dim: raise TypeError( f"Cannot compare quantities with different dimensions: " f"'{self.unit.name}' and '{other.unit.name}'" ) def _is_close(self, other_si_mag: float) -> bool: """Internal helper for fuzzy equality.""" return isclose(self._mag_si, other_si_mag, rel_tol=1e-12, abs_tol=0.0) def __eq__(self, other: object) -> bool: if not isinstance(other, Quantity): return NotImplemented # Same physical dimension; SI magnitudes equal within tolerance. return ( self.dim == other.dim and isclose(self._mag_si, other._mag_si, rel_tol=1e-12, abs_tol=0.0) ) def __ne__(self, other: object) -> bool: if not isinstance(other, Quantity): return NotImplemented if self.dim != other.dim: return True # Not equal if dims don't match return not self._is_close(other._mag_si) def __lt__(self, other: object) -> bool: self._check_dim_compatible(other) other_si_mag = getattr(other, '_mag_si', 0.0) # Strictly less than AND not fuzzy-equal return self._mag_si < other_si_mag and not self._is_close(other_si_mag) def __le__(self, other: object) -> bool: self._check_dim_compatible(other) other_si_mag = getattr(other, '_mag_si', 0.0) # Less than OR fuzzy-equal return self._mag_si < other_si_mag or self._is_close(other_si_mag) def __gt__(self, other: object) -> bool: self._check_dim_compatible(other) other_si_mag = getattr(other, '_mag_si', 0.0) # Strictly greater than AND not fuzzy-equal return self._mag_si > other_si_mag and not self._is_close(other_si_mag) def __ge__(self, other: object) -> bool: self._check_dim_compatible(other) other_si_mag = getattr(other, '_mag_si', 0.0) # Greater than OR fuzzy-equal return self._mag_si > other_si_mag or self._is_close(other_si_mag) # --- Hashing Solution ---
[docs] def as_key(self, precision: int = 12) -> tuple: """ Returns a hashable, discretized key for this quantity. This is the recommended way to use Quantities in dictionaries or sets, as it forces the user to choose a precision level for "fuzzy" hashing. The standard `__hash__` is not implemented because `__eq__` uses `isclose`, which would violate the Python hash contract. Usage: >>> my_dict = {} >>> q1 = (1.0 + 1e-13) * u.m >>> q2 = (1.0 - 1e-13) * u.m >>> >>> # q1 and q2 are "equal" but not hash-equal >>> q1 == q2 # True >>> >>> # Using as_key forces them to be hash-equal >>> my_dict[q1.as_key(precision=9)] = "value" >>> print(my_dict[q2.as_key(precision=9)]) "value" Parameters ---------- precision : int, optional The number of decimal places to round the *SI magnitude* to for hashing, by default 12 (which is typically near 64-bit float precision limits). Returns ------- tuple A hashable tuple of (dimension, rounded_si_magnitude). """ # Round the SI magnitude to the specified precision rounded_mag_si = round(self._mag_si, precision) # We must also handle -0.0 vs 0.0, which round identically # but have different hashes. if rounded_mag_si == 0.0: rounded_mag_si = 0.0 return (self.dim, rounded_mag_si)
[docs] def to(self, new_unit: "Unit|str") -> Quantity: if(isinstance(new_unit, str)): from quantium.units.registry import DEFAULT_REGISTRY new_unit = extract_unit_expr(new_unit, DEFAULT_REGISTRY) # This proves to mypy that new_unit is a Unit, not a str. if not isinstance(new_unit, Unit): raise TypeError( "Internal error: unit expression did not resolve to a Unit object." ) if new_unit.dim != self.dim: raise TypeError("Dimension mismatch in conversion") # Optimization: Avoid re-allocating if the target unit is # *already* our current unit (same name AND dim). # We must check name, as 'V/m' == 'W/(A·m)' is True physically, # but the user's intent in to() is to get the new name. # The dim check has already passed at this point. if new_unit.name == self.unit.name: return self components = UNIT_SIMPLIFIER.unit_symbol_map(new_unit) value, unit = UNIT_SIMPLIFIER.si_to_value_unit( self._mag_si, self.dim, components, new_unit, ) return Quantity(value, unit)
[docs] def to_si(self) -> Quantity: """ Return an equivalent Quantity expressed in SI with a preferred symbol when possible. Strategy: 1) If the current unit clearly belongs to a specific SI family (atomic symbol with scale 1, or a prefixed form of one), keep that family in SI (e.g., kBq → Bq). 2) Otherwise, use the dimension's preferred symbol (A, N, W, Pa, Hz, …). 3) If no preferred symbol exists, compose the base-SI name from the dimension. """ # Local imports avoid circular import at module load time. from quantium.core.utils import format_dim, preferred_symbol_for_dim from quantium.units.registry import DEFAULT_REGISTRY as _ureg cur_name = self.unit.name # --- (1) Preserve the "family" if we can (Hz vs Bq, Gy vs Sv, …) --- # Grab all atomic SI heads (scale==1, same dim) registered in the system. si_heads = [name for name, u in _ureg.all().items() if u.scale_to_si == 1.0 and u.dim == self.dim] # If our current unit is exactly one of those heads (e.g., "Bq"), or is a prefixed # form ending with the head (e.g., "kBq"), keep that head as the SI symbol. for head in si_heads: if cur_name == head or cur_name.endswith(head): si_unit = Unit(head, 1.0, self.dim) return Quantity(self._mag_si, si_unit) # already SI magnitude # --- (2) Fall back to the global preferred symbol for this dimension --- sym = preferred_symbol_for_dim(self.dim) # e.g., "A", "N", "W", "Pa", "Hz", … if sym: si_unit = Unit(sym, 1.0, self.dim) return Quantity(self._mag_si, si_unit) # --- (3) Compose from base SI if no named symbol exists --- si_name = format_dim(self.dim) # e.g., "kg·m/s²", "1/s", "m" si_unit = Unit(si_name, 1.0, self.dim) return Quantity(self._mag_si, si_unit)
@property def si(self) -> Quantity: return self.to_si() @property def value(self) -> float: return self._mag_si / self.unit.scale_to_si # arithmetic def __add__(self, other: Quantity) -> Quantity: if self.dim != other.dim: raise TypeError("Add requires same dimensions") # return in left operand's unit return Quantity((self._mag_si + other._mag_si)/self.unit.scale_to_si, self.unit) def __sub__(self, other: Quantity) -> Quantity: if self.dim != other.dim: raise TypeError("Sub requires same dimensions") return Quantity((self._mag_si - other._mag_si)/self.unit.scale_to_si, self.unit) def __mul__(self, other: "Quantity | Unit | Number") -> "Quantity": # scalar × quantity if isinstance(other, (int, float)): return Quantity((self._mag_si * float(other)) / self.unit.scale_to_si, self.unit) # quantity × unit if isinstance(other, Unit): result_mag_si = self._mag_si * other.scale_to_si result_dim = dim_mul(self.dim, other.dim) # Merge the symbol–exponent maps of self (priority 0) and other (priority 1) # into a combined unit representation components = UNIT_SIMPLIFIER.combine_symbol_maps( self._symbol_component_map(0), UNIT_SIMPLIFIER.unit_symbol_map(other, 1), ) value, unit = UNIT_SIMPLIFIER.si_to_value_unit(result_mag_si, result_dim, components) return Quantity(value, unit) # quantity × quantity result_mag_si = self._mag_si * other._mag_si result_dim = dim_mul(self.dim, other.dim) components = UNIT_SIMPLIFIER.combine_symbol_maps( self._symbol_component_map(0), other._symbol_component_map(1), ) value, unit = UNIT_SIMPLIFIER.si_to_value_unit(result_mag_si, result_dim, components) return Quantity(value, unit) def __rmul__(self, other: float | int) -> "Quantity": # allows 3 * (2 m) -> 6 m return self.__mul__(other) def __truediv__(self, other: "Quantity | Unit | Number") -> "Quantity": # quantity / scalar if isinstance(other, (int, float)): return Quantity((self._mag_si / float(other)) / self.unit.scale_to_si, self.unit) # quantity / unit if isinstance(other, Unit): result_mag_si = self._mag_si / other.scale_to_si result_dim = dim_div(self.dim, other.dim) components = UNIT_SIMPLIFIER.combine_symbol_maps( self._symbol_component_map(0), UNIT_SIMPLIFIER.scale_symbol_map(UNIT_SIMPLIFIER.unit_symbol_map(other, 1), -1), ) value, unit = UNIT_SIMPLIFIER.si_to_value_unit(result_mag_si, result_dim, components) return Quantity(value, unit) # quantity / quantity result_mag_si = self._mag_si / other._mag_si result_dim = dim_div(self.dim, other.dim) components = UNIT_SIMPLIFIER.combine_symbol_maps( self._symbol_component_map(0), UNIT_SIMPLIFIER.scale_symbol_map(other._symbol_component_map(1), -1), ) value, unit = UNIT_SIMPLIFIER.si_to_value_unit(result_mag_si, result_dim, components) return Quantity(value, unit) def __rtruediv__(self, other: float | int) -> "Quantity": # scalar / quantity -> returns Quantity with inverse dimension if not isinstance(other, (int, float)): return NotImplemented result_dim = dim_div(DIM_0, self.dim) result_mag_si = float(other) / self._mag_si components = UNIT_SIMPLIFIER.scale_symbol_map(self._symbol_component_map(0), -1) value, unit = UNIT_SIMPLIFIER.si_to_value_unit(result_mag_si, result_dim, components) return Quantity(value, unit) def __pow__(self, n: int) -> "Quantity": new_unit = self.unit ** n return Quantity((self._mag_si ** n) / new_unit.scale_to_si, new_unit) def __repr__(self) -> str: # Local imports avoid cyclic imports; modules are cached after the first time. from quantium.core.utils import ( preferred_symbol_for_dim, prettify_unit_name_supers, ) from quantium.units.registry import PREFIXES from math import log10, floor # Added import for math functions # Numeric magnitude in the *current* unit mag = self._mag_si / self.unit.scale_to_si # Dimensionless: print bare number if self.dim == DIM_0: return f"{mag:.15g}" # Start from the user’s unit name (keeps cm/ms etc.), with superscripts & cancellation # This is CRITICAL: it cancels "kg·mg/kg" to "mg" *before* we check composition. pretty = prettify_unit_name_supers(self.unit.name, cancel=True) # CRITICAL: Check if the *prettified* name is composed. # This check prevents re-formatting of simple units like "cm", "mg", "Pa", "Bq", # which fixes regressions. is_composed = any(ch in pretty for ch in ("/", "·", "^")) if is_composed: # Respect the stored composed unit name for representation. # We deliberately avoid performing an additional canonicalisation # here so that `Quantity.unit.name` matches the printed output. return f"{mag:.15g}" if pretty == "1" else f"{mag:.15g} {pretty}" # If the pretty name reduces to "1", show just the number # This also handles all non-composed units that skipped the `if` block. return f"{mag:.15g}" if pretty == "1" else f"{mag:.15g} {pretty}" def __format__(self, spec: str) -> str: """ Custom string formatting for Quantity objects. The format specifier controls whether the quantity is shown in its current unit or converted to SI units before printing. Supported specifiers -------------------- "" (empty), or "native" Display the quantity in its current unit (default). "si" Display the quantity converted to SI units. Examples -------- >>> v = 1000 @ (ureg.get("cm") / ureg.get("s")) >>> f"{v}" # default: show in current unit (cm/s) '1000 cm/s' >>> f"{v:native}" # explicit but same as above '1000 cm/s' >>> f"{v:si}" # convert and show in SI (m/s) '10 m/s' Raises ------ ValueError If the format specifier is not one of "", "native", or "si". """ spec = (spec or "").strip().lower() if spec in ("", "native"): return repr(self) # current native (default) if spec == "si": return repr(self.to_si()) # force SI raise ValueError("Unknown format spec; use '', 'native', or 'si'")