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 dataclasses import dataclass
from math import isclose, isfinite
import math
import re

from quantium.core.dimensions import DIM_0, Dim, dim_div, dim_mul, dim_pow
from quantium.units.parser import extract_unit_expr
from typing import Union

Number = Union[int, float]
_POWER_RE = re.compile(r"^(?P<base>.+?)\^(?P<exp>-?\d+)$")

def _normalize_power_name(name: str) -> str:
    """
    Make names canonical:
    - 'x^1'  -> 'x'
    - 'x^0'  -> '1'   (dimensionless label; adjust if you prefer something else)
    - 'x^-1' stays 'x^-1'
    """
    m = _POWER_RE.match(name)
    if not m:
        return name
    base = m.group("base")
    exp = int(m.group("exp"))
    if exp == 1:
        return base
    if exp == 0:
        return "1"
    return f"{base}^{exp}"


[docs] @dataclass(frozen=True, slots=True) class Unit: """ A physical unit. Attributes ---------- name : str Symbol or name (e.g., "m", "s", "kg", "cm"). scale_to_si : float Multiplicative factor to convert 1 of this unit to SI for its dimension. Examples: m=1.0, cm=0.01, µs=1e-6, ft=0.3048. dim : Dim Dimension vector (L,M,T,I,Θ,N,J). E.g., meters -> (1,0,0,0,0,0,0). system : str Optional tag like "si", "imperial", etc. """ name: str scale_to_si: float dim: Dim def __post_init__(self) -> None: if len(self.dim) != 7: raise ValueError("dim must be a 7-tuple (L,M,T,I,Θ,N,J)") if not (self.scale_to_si > 0 and isfinite(self.scale_to_si)): raise ValueError("scale_to_si must be a positive, finite number") def __eq__(self, other: object) -> bool: if not isinstance(other, Unit): return NotImplemented # dimension must match exactly; scale_to_si can have tiny FP noise return ( self.dim == other.dim and isclose(self.scale_to_si, other.scale_to_si, rel_tol=1e-12, abs_tol=0.0) )
[docs] def __rmul__(self, value: float) -> Quantity: return Quantity(float(value), self)
[docs] def __mul__(self, other: "Unit") -> "Unit": new_dim = dim_mul(self.dim, other.dim) new_scale = self.scale_to_si * other.scale_to_si # If the two units are equivalent (same dim and scale), collapse to a power. # This avoids "K·kelvin" and produces "kelvin^2" (or "K^2" if the LHS was "K"). if ( self.dim == other.dim and isclose(self.scale_to_si, other.scale_to_si, rel_tol=1e-12, abs_tol=0.0) ): base_name = self.name if self.name else other.name new_unit_name = _normalize_power_name(f"{base_name}^2") return Unit(new_unit_name, new_scale, new_dim) # Otherwise compose normally. new_unit_name = f"{self.name}·{other.name}" return Unit(new_unit_name, new_scale, new_dim)
[docs] def __truediv__(self, other: "Unit") -> "Unit": new_dim = dim_div(self.dim, other.dim) new_scale = self.scale_to_si / other.scale_to_si # Parenthesize denominator if it's compound to avoid flattening like "W·s/N·s/m^2" def _needs_paren(name: str) -> bool: # name contains any operator that would change precedence if ungrouped return any(op in name for op in ("·", "*", "/")) and not (name.startswith("(") and name.endswith(")")) right = f"({other.name})" if _needs_paren(other.name) else other.name new_unit_name = f"{self.name}/{right}" return Unit(new_unit_name, new_scale, new_dim)
[docs] def __rtruediv__(self, n: int | float) -> Unit: if n != 1: raise TypeError( f"Invalid operation: cannot divide {n} by a Unit ({self.name}). " "Only 1/unit (reciprocal) is supported." ) new_dim = dim_div(DIM_0, self.dim) name = self.name if name.startswith("1/"): # 1/(1/x) -> x name = name[2:] else: m = _POWER_RE.match(name) if m: base = m.group("base") k = int(m.group("exp")) name = f"{base}^{-k}" # 1/(s^-3) -> s^3, 1/(s^3) -> s^-3 else: name = f"{name}^-1" # 1/s -> s^-1 (key change) normalized_name = _normalize_power_name(name) new_scale = 1 / self.scale_to_si return Unit(normalized_name, new_scale, new_dim)
[docs] def __pow__(self, n: int) -> Unit: new_dim = dim_pow(self.dim, n) # Canonical naming: if n == 0: new_unit_name = f"{self.name}^0" # or maybe a specific "dimensionless" name if you prefer elif n == 1: new_unit_name = self.name else: new_unit_name = f"{self.name}^{n}" # handles negatives like s^-3 normalized_name = _normalize_power_name(new_unit_name) new_scale = self.scale_to_si ** n return Unit(normalized_name, new_scale, new_dim)
[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 _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 return Quantity(self._mag_si / new_unit.scale_to_si, new_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): new_unit = self.unit * other if new_unit.dim == DIM_0: # Result is dimensionless. Calculate the new SI mag and use a scale=1 unit. new_mag_si = self._mag_si * other.scale_to_si unit_dimless = Unit('', 1.0, DIM_0) return Quantity(new_mag_si, unit_dimless) # Pass SI mag as value return Quantity(self.value, new_unit) # quantity × quantity new_unit = self.unit * other.unit # convert SI magnitude back to the composed unit return Quantity((self._mag_si * other._mag_si) / new_unit.scale_to_si, new_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): new_unit = self.unit / other if new_unit.dim == DIM_0: # Result is dimensionless. Calculate the new SI mag and use a scale=1 unit. new_mag_si = self._mag_si / other.scale_to_si unit_dimless = Unit('', 1.0, DIM_0) return Quantity(new_mag_si, unit_dimless) # Pass SI mag as value return Quantity(self.value, new_unit) # quantity / quantity new_unit = self.unit / other.unit if new_unit.dim == DIM_0: # dimensionless quantity has no name new_unit = Unit('', 1.0, DIM_0) return Quantity((self._mag_si / other._mag_si) / new_unit.scale_to_si, new_unit) def __rtruediv__(self, other: float | int) -> "Quantity": # scalar / quantity -> returns Quantity with inverse dimension if not isinstance(other, (int, float)): return NotImplemented new_dim = dim_div(DIM_0, self.dim) # or dim_pow(self.dim, -1) new_unit_name = f"{1}/{self.unit.name}" new_scale = 1.0 / self.unit.scale_to_si new_unit = Unit(new_unit_name, new_scale, new_dim) return Quantity((float(other) / self._mag_si) / new_unit.scale_to_si, new_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: sym = preferred_symbol_for_dim(self.dim) # e.g., "N", "A", "W", "Pa", ... if sym: # --- FIX: Check for zero *before* any scale/prefix logic --- if self._mag_si == 0.0: mag = 0.0 pretty = sym # Show '0 N', '0 Pa', etc. else: # --- All other logic is now nested in this 'else' --- scale = self.unit.scale_to_si # 1. Check for exact SI scale (e.g., N/m²) if abs(scale - 1.0) <= 1e-12: pretty = sym mag = self._mag_si # Use base SI magnitude else: # 2. Check for an exact SI prefix match (e.g., kg·m/ms²) found_prefix = False for p in PREFIXES: if abs(scale - p.factor) <= 1e-12: pretty = f"{p.symbol}{sym}" mag = self._mag_si / p.factor # Use rescaled magnitude found_prefix = True break # 3. NEW LOGIC: If no exact match, *now* use engineering notation # This handles the N/cm² case (scale 10^4). if not found_prefix: mag_si = self._mag_si # Find the exponent in base 10 exponent = log10(abs(mag_si)) # Find the nearest SI prefix exponent (multiple of 3) prefix_exp = int(floor(exponent / 3) * 3) prefix_symbol = "" prefix_factor = 1.0 if prefix_exp == 0: prefix_factor = 1.0 prefix_symbol = "" else: for p in PREFIXES: if abs(p.factor - (10**prefix_exp)) <= 1e-12: prefix_symbol = p.symbol prefix_factor = p.factor break # Calculate the new magnitude mag = mag_si / prefix_factor # Create the new pretty name pretty = f"{prefix_symbol}{sym}" # 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), "unit", or "u" 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:unit}" # 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 "", "unit", "u", or "si". """ spec = (spec or "").strip().lower() if spec in ("", "native"): return repr(self) # current unit (default) if spec == "si": return repr(self.to_si()) # force SI raise ValueError("Unknown format spec; use '', 'unit', or 'si'")