Source code for saiunit._base_unit

# Copyright 2026 BrainX Ecosystem Limited. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================

import re
from copy import deepcopy

import jax

from ._base_dimension import (
    Dimension,
    DIMENSIONLESS,
    _is_tracer,
)

__all__ = [
    'Unit',
    'UNITLESS',
    'add_standard_unit',
    'parse_unit',
]

# SI unit _prefixes as integer exponents of 10, see table at end of file.
_siprefixes = {
    "y": -24,
    "z": -21,
    "a": -18,
    "f": -15,
    "p": -12,
    "n": -9,
    "u": -6,
    "m": -3,
    "c": -2,
    "d": -1,
    "": 0,
    "da": 1,
    "h": 2,
    "k": 3,
    "M": 6,
    "G": 9,
    "T": 12,
    "P": 15,
    "E": 18,
    "Z": 21,
    "Y": 24,
}


# ---------------------------------------------------------------------------
# Display-parts helpers – canonical, sorted factored-unit representation
# ---------------------------------------------------------------------------

def _assert_same_base(u1, u2):
    if not u1.has_same_base(u2):
        raise TypeError(f"Cannot operate on units with different bases. Got {u1.base} != {u2.base}.")


def _find_standard_unit(
    dim: Dimension,
    base,
    scale,
    factor,
    for_composition: bool = False,
) -> tuple[str | None, str | None, bool, bool]:
    """
    Find a standard unit for the given dimension, base, scale, and factor.

    Parameters
    ----------
    for_composition : bool
        When True, keys that are *ambiguous* (i.e. have >=2 registered
        aliases with distinct display names, e.g. hertz vs becquerel)
        are skipped so that they are never auto-substituted during
        unit arithmetic.  This is detected automatically at
        registration time—no hardcoded list.

    Returns
    -------
    (name, dispname, is_fullname, is_dimensionless)
    """
    if dim == DIMENSIONLESS:
        return None, None, False, True
    if isinstance(base, (int, float)):
        if isinstance(scale, (int, float)):
            if isinstance(factor, (int, float)):
                key = (dim, scale, base, factor)
                if key in _standard_units:
                    if for_composition and key in _ambiguous_keys:
                        pass  # skip – ambiguous, fall through
                    else:
                        u = _standard_units[key]
                        return u.name, u.dispname, True, False

        key = (dim, 0, base, 1.0)
        if key in _standard_units:
            if for_composition and key in _ambiguous_keys:
                return None, None, False, False
            u = _standard_units[key]
            return u.name, u.dispname, False, False
    return None, None, False, False


def _find_a_name(dim: Dimension, base, scale, factor) -> tuple[str | None, bool]:
    if dim == DIMENSIONLESS:
        u_name = f"Unit({base}^{scale})"
        return u_name, False

    if isinstance(base, (int, float)):
        if isinstance(scale, (int, float)):
            if isinstance(factor, (int, float)):
                key = (dim, scale, base, factor)
                if key in _standard_units:
                    u_name = _standard_units[key].name
                    return u_name, True

        if isinstance(factor, (int, float)):
            key = (dim, 0, base, factor)
            if key in _standard_units:
                u_name = _standard_units[key].name
                if factor == 1.:
                    return f"{base}^{scale} * {u_name}", False
                else:
                    return f"{factor} * {base}^{scale} * {u_name}", False

        key = (dim, 0, base, 1.)
        if key in _standard_units:
            u_name = _standard_units[key].name
            if _is_tracer(scale):
                return u_name, False
            else:
                return f"{base}^{scale} * {u_name}", False
    return None, True


_standard_units: 'dict[tuple, Unit]' = {}
_standard_unit_aliases: 'dict[tuple, list[Unit]]' = {}
_unit_name_registry: 'dict[str, Unit]' = {}

# ---------------------------------------------------------------------------
# Ambiguous-key detection
#
# A dimension key is "ambiguous" when >=2 registered aliases have
# **different display names** (dispname).  Spelling variants like
# meter/metre share the same dispname ("m") so they are NOT flagged.
# Genuine semantic collisions like hertz/becquerel ("Hz" vs "Bq") ARE
# flagged automatically—no hardcoded list required.
#
# Ambiguous keys are never auto-substituted during unit composition
# (mul / div / pow / reverse) so that e.g. joule/kg never silently
# becomes sievert.
# ---------------------------------------------------------------------------
_ambiguous_keys: set = set()


def _standard_unit_preference_score(unit: 'Unit') -> int:
    """
    Return a preference score for choosing canonical display aliases.

    Lower is better.  Deterministic: on ties the name that sorts first
    alphabetically wins (via ``_select_preferred_standard_unit``).
    """
    name = unit.name.lower() if isinstance(unit.name, str) else ""
    score = 0
    # Prefer frequency over radioactivity for s^-1
    if "hertz" in name:
        score -= 10
    return score


def _select_preferred_standard_unit(units: 'list[Unit]') -> 'Unit':
    """Pick the preferred alias – deterministic (score, then alpha)."""
    return min(
        units,
        key=lambda u: (
            _standard_unit_preference_score(u),
            u.name.lower() if isinstance(u.name, str) else "",
        ),
    )


[docs] def add_standard_unit(u: 'Unit'): """ Register a unit as a standard unit for display purposes. Once registered, this unit will be used when formatting quantities whose dimensions, scale, base, and factor match. If multiple units are registered for the same key, the preferred alias is selected automatically. Keys with two or more distinct display names are flagged as *ambiguous* and will not be auto-substituted during unit composition. Parameters ---------- u : Unit The unit to register. Its ``base``, ``scale``, and ``factor`` must all be plain Python ``int`` or ``float`` values (not JAX tracers) for registration to take effect. Examples -------- .. code-block:: python >>> import saiunit as u >>> my_unit = u.Unit( ... dim=u.joule.dim, ... name='my_energy', ... dispname='myE', ... is_fullname=True, ... ) >>> u.add_standard_unit(my_unit) """ if ( isinstance(u.base, (int, float)) and isinstance(u.scale, (int, float)) and isinstance(u.factor, (int, float)) ): key = (u.dim, u.scale, u.base, u.factor) aliases = _standard_unit_aliases.setdefault(key, []) aliases.append(u) _standard_units[key] = _select_preferred_standard_unit(aliases) # Auto-detect ambiguity: >=2 distinct display names → ambiguous dispnames = {a.dispname for a in aliases if isinstance(a.dispname, str)} if len(dispnames) >= 2: _ambiguous_keys.add(key) # Register by dispname and name for string-based lookup if isinstance(u.dispname, str) and u.dispname: _unit_name_registry.setdefault(u.dispname, u) if isinstance(u.name, str) and u.name: _unit_name_registry.setdefault(u.name, u)
def _get_display_parts(unit: 'Unit'): """Return the display-parts list for *unit*. Each element is ``(name, dispname, exponent)``. """ if getattr(unit, '_display_parts', None) is not None: return list(unit._display_parts) return [(unit.name, unit.dispname, 1)] def _merge_display_parts(parts_a, parts_b): """Merge two part-lists, combine same-name entries, drop zeros, sort.""" merged: dict[str, tuple] = {} for name, disp, exp in list(parts_a) + list(parts_b): if name in merged: _, old_disp, old_exp = merged[name] merged[name] = (name, disp, old_exp + exp) else: merged[name] = (name, disp, exp) result = [(n, d, e) for n, d, e in merged.values() if e != 0] # positive exponents first (alphabetical), then negative (alphabetical) result.sort(key=lambda x: (0 if x[2] > 0 else 1, x[0].lower())) return result _RE_DISPNAME_EXP = re.compile(r'^(.+)\^(-?\d+(?:\.\d+)?)$') def _normalise_display_parts(parts): """Normalise display parts: decompose stacked exponents, drop zeros, sort. If a dispname already contains an exponent (e.g. ``'m^2'``), fold that exponent into the part's own exponent so that ``('meter2', 'm^2', 3)`` becomes ``('meter2', 'm', 6)`` instead of rendering as ``m^2^3``. """ result = [] for name, disp, exp in parts: if exp == 0: continue m = _RE_DISPNAME_EXP.match(disp) if m: base_disp = m.group(1) inner_exp = float(m.group(2)) disp = base_disp exp = inner_exp * exp result.append((name, disp, exp)) # Merge entries that now share the same base dispname merged: dict[str, tuple] = {} for name, disp, exp in result: if disp in merged: _, old_disp, old_exp = merged[disp] merged[disp] = (name, disp, old_exp + exp) else: merged[disp] = (name, disp, exp) result = [(n, d, e) for n, d, e in merged.values() if e != 0] result.sort(key=lambda x: (0 if x[2] > 0 else 1, x[0].lower())) return result def _fmt_exp(exp): """Format an exponent value, using int form when possible.""" return str(int(exp)) if exp == int(exp) else str(exp) def _format_display_parts(parts) -> str: """Render a parts-list as a canonical unit string. The canonical format uses dispname symbols (e.g. ``mV``, ``Hz``), ``^`` for exponentiation, `` * `` for multiplication, and `` / `` for division. This single format is both human-readable and machine-parseable: mV J / kg nA / cm^2 mS * nA / cm^2 m / (kg * s^2) """ if not parts: return "1" numerator = [(n, d, e) for n, d, e in parts if e > 0] denominator = [(n, d, -e) for n, d, e in parts if e < 0] def _fmt_term(name, dispname, exp): if exp == 1: return dispname return f"{dispname}^{_fmt_exp(exp)}" num_str = " * ".join(_fmt_term(n, d, e) for n, d, e in numerator) if numerator else "1" if not denominator: return num_str if len(denominator) == 1: den_str = _fmt_term(*denominator[0]) else: inner = " * ".join(_fmt_term(n, d, e) for n, d, e in denominator) den_str = f"({inner})" return f"{num_str} / {den_str}" # --------------------------------------------------------------------------- # String → Unit parser # --------------------------------------------------------------------------- def _split_fraction(s: str): """Split ``'A / B'`` into ``('A', 'B')``, respecting parentheses. Returns ``(s, None)`` when there is no top-level ``/``. """ depth = 0 i = 0 while i < len(s): ch = s[i] if ch == '(': depth += 1 elif ch == ')': depth -= 1 elif depth == 0 and s[i:i + 3] == ' / ': num = s[:i].strip() den = s[i + 3:].strip() if den.startswith('(') and den.endswith(')'): den = den[1:-1].strip() return num, den i += 1 return s, None def _split_product(s: str): """Split on ``' * '`` respecting parentheses.""" parts = [] depth = 0 start = 0 i = 0 while i < len(s): ch = s[i] if ch == '(': depth += 1 elif ch == ')': depth -= 1 elif depth == 0 and s[i:i + 3] == ' * ': parts.append(s[start:i]) start = i + 3 i = start continue i += 1 parts.append(s[start:]) return parts def _parse_product(s: str): """Parse ``'A * B * C'`` into a product of :class:`Unit` objects.""" terms = _split_product(s) result = None for term in terms: u = _parse_term(term.strip()) result = u if result is None else result * u return result def _parse_term(s: str): """Parse a single term like ``'cm^2'``, ``'mV'``, or ``'10^3'``.""" s = s.strip() caret_idx = s.rfind('^') if caret_idx > 0: atom = s[:caret_idx].strip() exp_str = s[caret_idx + 1:].strip() try: exp = float(exp_str) if exp == int(exp): exp = int(exp) except ValueError: raise ValueError(f"Invalid exponent in unit string: {s!r}") # Numeric base → dimensionless scaled unit (e.g. "10^3") try: base_num = float(atom) return Unit(DIMENSIONLESS, scale=exp, base=base_num) except ValueError: pass if atom in _unit_name_registry: return _unit_name_registry[atom] ** exp raise ValueError(f"Unknown unit token: {atom!r} in {s!r}") # No exponent — direct lookup if s in _unit_name_registry: return _unit_name_registry[s] # Numeric literal (rare: anonymous factor) try: num = float(s) return Unit(DIMENSIONLESS, scale=0, base=10., factor=num) except ValueError: pass raise ValueError( f"Unknown unit: {s!r}. Use a registered unit name or display name." ) def parse_unit(s: str) -> 'Unit': """Parse a canonical unit string into a :class:`Unit`. Accepts strings in the format produced by ``str(unit)`` or ``repr(unit)``, e.g. ``"mV"``, ``"J / kg"``, ``"nA / cm^2"``. Both display names (``"mV"``) and full names (``"mvolt"``) are recognised. Parameters ---------- s : str The unit string to parse. Returns ------- Unit Raises ------ ValueError If the string cannot be parsed into a known unit. Examples -------- >>> parse_unit("mV") Unit("mV") >>> parse_unit("J / kg") Unit("J / kg") """ s = s.strip() # Strip the Unit("...") repr wrapper if present if s.startswith('Unit("') and s.endswith('")'): s = s[6:-2] elif s.startswith("Unit('") and s.endswith("')"): s = s[6:-2] if not s: raise ValueError("Cannot parse an empty unit string.") # Dimensionless if s == '1': return UNITLESS # Fast path: direct registry lookup if s in _unit_name_registry: return _unit_name_registry[s] # Compound expression num_str, den_str = _split_fraction(s) numerator = _parse_product(num_str) if den_str is not None: denominator = _parse_product(den_str) return numerator / denominator return numerator class Unit: r""" A physical unit. Basically, a unit is just a number with given dimensions, e.g. mvolt = 0.001 with the dimensions of voltage. The units module defines a large number of standard units, and you can also define your own (see below). Mathematically, a unit represents: .. math:: \text{{factor}} \times \text{{base}}^{\text{{scale}}} \times \text{{dimension}} where the ``factor`` is the conversion factor of the unit (e.g. ``1 calorie = 4.18400 Joule``, so the factor is 4.18400), the ``base`` is the base of the exponent (e.g. 10 for the kilo prefix), the ``scale`` is the exponent of the base (e.g. 3 for the kilo prefix), and the ``dimension`` is the physical dimensions of the unit (e.g. ``joule`` for energy). The unit class also keeps track of various things that were used to define it so as to generate a nice string representation of it. See below. Parameters ---------- dim : Dimension, optional The physical dimensions of the unit. Defaults to ``DIMENSIONLESS``. scale : array_like, optional The scale exponent, e.g. 3 for a "k" (kilo) prefix. Defaults to 0. base : array_like, optional The base of the exponent, e.g. 10 for SI prefixes. Defaults to 10. factor : array_like, optional The conversion factor of the unit. Defaults to 1. name : str, optional The full name of the unit, e.g. ``'volt'``. dispname : str, optional The display name, e.g. ``'V'``. is_fullname : bool, optional Whether ``name`` is the canonical full name. Defaults to ``True``. display_parts : list of tuple, optional Canonical display components for compound units. Notes ----- When creating scaled units, you can use the following prefixes: ====== ====== ============== Factor Name Prefix ====== ====== ============== 10^24 yotta Y 10^21 zetta Z 10^18 exa E 10^15 peta P 10^12 tera T 10^9 giga G 10^6 mega M 10^3 kilo k 10^2 hecto h 10^1 deka da 1 10^-1 deci d 10^-2 centi c 10^-3 milli m 10^-6 micro u (\mu in SI) 10^-9 nano n 10^-12 pico p 10^-15 femto f 10^-18 atto a 10^-21 zepto z 10^-24 yocto y ====== ====== ============== **Defining your own** It can be useful to define your own units for printing purposes. So for example, to define the newton metre, you write: .. code-block:: python >>> import saiunit as u >>> Nm = u.newton * u.metre You can then do: .. code-block:: python >>> (1 * Nm).in_unit(Nm) '1. N m' New "compound units", i.e. units that are composed of other units will be automatically registered and from then on used for display. For example, imagine you define total conductance for a membrane, and the total area of that membrane: .. code-block:: python >>> import saiunit as u >>> conductance = 10. * u.nS >>> area = 20000 * u.um ** 2 If you now ask for the conductance density, you will get an "ugly" display in basic SI dimensions, as saiunit does not know of a corresponding unit: .. code-block:: python >>> conductance / area 0.5 * metre ** -4 * kilogram ** -1 * second ** 3 * amp ** 2 By using an appropriate unit once, it will be registered and from then on used for display when appropriate: .. code-block:: python >>> u.usiemens / u.cm ** 2 usiemens / (cmetre ** 2) >>> conductance / area # same as before, but now knows about uS/cm^2 50. * usiemens / (cmetre ** 2) Note that user-defined units cannot override the standard units (``volt``, ``second``, etc.) that are predefined. For example, the unit ``Nm`` has the dimensions "length^2 * mass / time^2", and therefore the same dimensions as the standard unit ``joule``. The latter will be used for display purposes: .. code-block:: python >>> 3 * u.joule 3. * joule >>> 3 * Nm 3. * joule Examples -------- Create a simple unit: .. code-block:: python >>> import saiunit as u >>> u.volt Unit("V") >>> u.mvolt Unit("mV") Combine units: .. code-block:: python >>> import saiunit as u >>> u.volt / u.amp Unit("V / A") """ __module__ = "saiunit" __slots__ = ["_dim", "_base", "_scale", "_factor", "_dispname", "_name", "is_fullname", "_hash", "_display_parts"] __array_priority__ = 1000 def __init__( self, dim: 'Dimension | str' = None, scale: jax.typing.ArrayLike = 0, base: jax.typing.ArrayLike = 10., factor: jax.typing.ArrayLike = 1., name: str = None, dispname: str = None, is_fullname: bool = True, display_parts=None, ): # String-based construction: Unit("mV"), Unit("J / kg"), etc. if isinstance(dim, str): parsed = parse_unit(dim) self._base = parsed._base self._scale = parsed._scale self._factor = parsed._factor self._dim = parsed._dim self._name = parsed._name self._dispname = parsed._dispname self.is_fullname = parsed.is_fullname self._hash = None self._display_parts = parsed._display_parts return # The base for this unit (as the base of the exponent), i.e. # a base of 10 means 10^3, for a "k" prefix. self._base = base # The scale for this unit (as the integer exponent of 10), i.e. # a scale of 3 means base^3, for a "k" prefix. self._scale = scale # The factor for this unit (as the conversion factor), i.e. # a factor of cal = 4.18400 means 1 cal = 4.18400 J, # where 4.18400 is the factor. self._factor = factor # The physical unit dimensions of this unit if dim is None: dim = DIMENSIONLESS if not isinstance(dim, Dimension): raise TypeError(f'Expected instance of Dimension, but got {dim}') self._dim = dim # The name of this unit if name is None: is_fullname = False if dim == DIMENSIONLESS: name = f"Unit({base}^{scale})" else: name = dim.__repr__() dispname = dim.__str__() self._name = name # The display name of this unit self._dispname = (name if dispname is None else dispname) # whether the name is the full name self.is_fullname = is_fullname # cached hash (computed lazily) self._hash = None # Canonical display components: list of (name, dispname, exponent). # None for simple (non-compound) units. self._display_parts = display_parts @property def factor(self) -> float: """ Return the conversion factor of the unit. The factor represents a multiplicative constant that converts a quantity expressed in this unit to its base-unit equivalent. For example, 1 calorie = 4.184 joule, so ``calorie.factor == 4.184``. Returns ------- float The conversion factor. Examples -------- .. code-block:: python >>> import saiunit as u >>> u.volt.factor 1.0 """ return self._factor @factor.setter def factor(self, factor): raise NotImplementedError( "Cannot set the factor of a Unit object directly," "Please create a new Unit object with the factor you want." ) @property def base(self) -> float: """ Return the base of the unit's scale exponent. The base is the number that is raised to the ``scale`` power to produce the unit's magnitude. For SI-prefixed units this is 10 (e.g. ``kilo`` means ``10 ** 3``). Returns ------- float The base of the exponent. Examples -------- .. code-block:: python >>> import saiunit as u >>> u.kvolt.base 10.0 """ return self._base @base.setter def base(self, base): raise NotImplementedError( "Cannot set the base of a Unit object directly," "Please create a new Unit object with the base you want." ) @property def scale(self) -> float | int: """ Return the scale exponent of the unit. The scale is the integer exponent applied to :attr:`base` to produce the unit's magnitude relative to the base unit. For example, ``mvolt`` has ``scale == -3`` (i.e. ``10 ** -3``). Returns ------- float or int The scale exponent. Examples -------- .. code-block:: python >>> import saiunit as u >>> u.mvolt.scale -3 """ return self._scale @scale.setter def scale(self, scale): raise NotImplementedError( "Cannot set the scale of a Unit object directly," "Please create a new Unit object with the scale you want." ) @property def magnitude(self) -> float: """ Return the absolute magnitude of the unit. The magnitude is computed as ``factor * base ** scale`` and represents the overall multiplicative factor that converts a value in this unit to the corresponding base-unit value. Returns ------- float The absolute magnitude of the unit. Examples -------- .. code-block:: python >>> import saiunit as u >>> u.mvolt.magnitude 0.001 >>> u.kvolt.magnitude 1000.0 """ # magnitude = factor * base ** scale return self.factor * self.base ** self.scale @magnitude.setter def magnitude(self, scale): raise NotImplementedError( "Cannot set the magnitude of a Unit object." ) @property def dim(self) -> Dimension: """ Return the physical unit dimensions of this unit. Returns ------- Dimension The :class:`~saiunit.Dimension` instance describing the physical dimensions (e.g. length, mass, time, ...). Examples -------- .. code-block:: python >>> import saiunit as u >>> u.volt.dim metre ** 2 * kilogram * second ** -3 * amp ** -1 """ return self._dim @dim.setter def dim(self, value): # Do not support setting the unit directly raise NotImplementedError( "Cannot set the dimension of a Quantity object directly," "Please create a new Quantity object with the dimension you want." ) @property def is_unitless(self) -> bool: """ Whether the unit is dimensionless with no scaling. A unit is considered unitless when its dimension is dimensionless, its scale exponent is 0, and its factor is 1.0. Returns ------- bool ``True`` if the unit is unitless, ``False`` otherwise. Examples -------- .. code-block:: python >>> import saiunit as u >>> u.UNITLESS.is_unitless True >>> u.volt.is_unitless False """ return self.dim.is_dimensionless and self.scale == 0 and self.factor == 1.0 @property def should_display_unit(self) -> bool: """ Whether the unit should be shown in formatted output. Returns ``True`` for all non-unitless units, and also for dimensionless units that carry a meaningful registered name (e.g. radian, steradian). Returns ------- bool ``True`` if the unit should be displayed, ``False`` otherwise. Examples -------- .. code-block:: python >>> import saiunit as u >>> u.volt.should_display_unit True >>> u.UNITLESS.should_display_unit False """ if not self.is_unitless: return True # Dimensionless but with a registered display name (e.g. rad, sr) return self.is_fullname and self._canonical_str() != '1' @property def name(self): """ Return the full name of the unit. Returns ------- str or None The full name of the unit (e.g. ``'volt'``, ``'mvolt'``), or ``None`` if no name was assigned. Examples -------- .. code-block:: python >>> import saiunit as u >>> u.volt.name 'volt' >>> u.mvolt.name 'mvolt' """ return self._name @name.setter def name(self, name): raise NotImplementedError( "Cannot set the name of a Unit object directly," "Please create a new Unit object with the name you want." ) @property def dispname(self): """ Return the display name of the unit. The display name is the short symbol used when rendering the unit in string output (e.g. ``'V'`` for volt, ``'mV'`` for millivolt). Returns ------- str or None The display name of the unit. Examples -------- .. code-block:: python >>> import saiunit as u >>> u.volt.dispname 'V' >>> u.mvolt.dispname 'mV' """ return self._dispname @dispname.setter def dispname(self, dispname): raise NotImplementedError( "Cannot set the dispname of a Unit object directly," "Please create a new Unit object with the dispname you want." )
[docs] def factorless(self) -> 'Unit': """ Return a copy of this Unit with the factor set to 1. Returns ------- Unit A new Unit object with the factor set to 1. Examples -------- .. code-block:: python >>> import saiunit as u >>> u = u.Unit.create(u.Dimension(kg=1), 'pound', 'lb', factor=0.453592) >>> u.factor 0.453592 >>> u.factorless().factor 1.0 """ # using standard units key = (self.dim, self.scale, self.base, 1.) if key in _standard_units: return _standard_units[key] # using temporary units name, dispname, is_fullname, dimless = _find_standard_unit(self.dim, self.base, self.scale, 1.0) return Unit( dim=self.dim, scale=self.scale, base=self.base, factor=1., name=name, dispname=dispname, is_fullname=is_fullname, )
[docs] def copy(self): """ Return a copy of this Unit. Returns ------- Unit A new Unit object with the same attributes. Examples -------- .. code-block:: python >>> import saiunit as u >>> u = u.volt.copy() >>> u == u.volt True >>> u is u.volt False """ return Unit( dim=self.dim, scale=self.scale, base=self.base, factor=self.factor, name=self.name, dispname=self.dispname, is_fullname=self.is_fullname, )
def __deepcopy__(self, memodict): return Unit( dim=self.dim.__deepcopy__(memodict), scale=deepcopy(self.scale), base=deepcopy(self.base), factor=deepcopy(self.factor), name=deepcopy(self.name), dispname=deepcopy(self.dispname), is_fullname=deepcopy(self.is_fullname), ) def __hash__(self): if self._hash is None: self._hash = hash( ( self.dim, self.factor, self.base, self.scale, self.name, self.dispname, ) ) return self._hash
[docs] def has_same_magnitude(self, other: 'Unit') -> bool: """ Whether this Unit has the same magnitude as another Unit. Two units have the same magnitude when they share the same ``scale``, ``base``, and ``factor``. Parameters ---------- other : Unit The other Unit to compare with. Returns ------- bool Whether the two Units have the same magnitude. Examples -------- .. code-block:: python >>> import saiunit as u >>> u.mvolt.has_same_magnitude(u.mamp) True >>> u.mvolt.has_same_magnitude(u.volt) False """ return self.scale == other.scale and self.base == other.base and self.factor == other.factor
[docs] def has_same_base(self, other: 'Unit') -> bool: """ Whether this Unit has the same ``base`` as another Unit. Parameters ---------- other : Unit The other Unit to compare with. Returns ------- bool Whether the two Units have the same base. Examples -------- .. code-block:: python >>> import saiunit as u >>> u.volt.has_same_base(u.amp) True >>> u.volt.has_same_base(u.mvolt) True """ return self.base == other.base
[docs] def has_same_dim(self, other: 'Unit') -> bool: """ Whether this Unit has the same unit dimensions as another Unit. Parameters ---------- other : Unit The other Unit to compare with. Returns ------- bool Whether the two Units have the same unit dimensions. Examples -------- .. code-block:: python >>> import saiunit as u >>> u.volt.has_same_dim(u.mvolt) True >>> u.volt.has_same_dim(u.amp) False """ from ._base_getters import get_dim other_dim = get_dim(other) return get_dim(self) == other_dim
[docs] @staticmethod def create( dim: Dimension, name: str, dispname: str, scale: int = 0, base: float = 10., factor: float = 1., ) -> 'Unit': """ Create a new named unit. Parameters ---------- dim : Dimension The dimensions of the unit. name : `str` The full name of the unit, e.g. ``'volt'`` dispname : `str` The display name, e.g. ``'V'`` scale : int, optional The scale of this unit as an exponent of 10, e.g. -3 for a unit that is 1/1000 of the base scale. Defaults to 0 (i.e. a base unit). base: float, optional The base for this unit (as the base of the exponent), i.e. a base of 10 means 10^3, for a "k" prefix. Defaults to 10. factor: float, optional The factor for this unit (as the conversion factor), e.g. a factor of 1 cal = 4.18400 J, where 4.18400 is the factor. Defaults to 1. Returns ------- u : Unit The new unit. Examples -------- .. code-block:: python >>> import saiunit as u >>> from saiunit import Dimension >>> energy_dim = u.joule.dim >>> cal = u.Unit.create(energy_dim, 'calorie', 'cal', factor=4.184) >>> cal Unit("cal") """ u = Unit( dim=dim, scale=scale, base=base, factor=factor, name=name, dispname=dispname, is_fullname=True, ) add_standard_unit(u) return u
[docs] @staticmethod def create_scaled_unit(baseunit: 'Unit', scalefactor: str) -> 'Unit': """ Create a scaled unit from a base unit. Parameters ---------- baseunit : `Unit` The unit of which to create a scaled version, e.g. ``volt``, ``amp``. scalefactor : `str` The scaling factor, e.g. ``"m"`` for mvolt, mamp Returns ------- u : Unit The new unit. Examples -------- .. code-block:: python >>> import saiunit as u >>> uvolt = u.Unit.create_scaled_unit(u.volt, 'u') >>> uvolt.name 'uvolt' >>> uvolt.scale -6 """ if scalefactor not in _siprefixes: raise ValueError( f"Unknown SI prefix {scalefactor!r}. " f"Valid prefixes are: {list(_siprefixes.keys())}" ) name = scalefactor + baseunit.name dispname = scalefactor + baseunit.dispname scale = _siprefixes[scalefactor] + baseunit.scale u = Unit( dim=baseunit.dim, name=name, dispname=dispname, scale=scale, base=baseunit.base, is_fullname=True, ) add_standard_unit(u) return u
def _canonical_str(self) -> str: """Return the canonical display string for this unit. Uses dispname symbols (``mV``, ``Hz``, ``kg``), ``^`` for exponentiation, `` * `` for multiplication, and `` / `` for division. The result is both human-readable and machine-parseable. """ if self._display_parts is not None: # Check if this compound unit matches a known derived unit # (e.g. mA * ohm → mV, volt * amp → W) _, dispname, is_fullname, _ = _find_standard_unit( self.dim, self.base, self.scale, self.factor, for_composition=True, ) if is_fullname: return dispname return _format_display_parts(self._display_parts) if self.is_fullname: return self.dispname if self.dim.is_dimensionless: if self.scale == 0 and self.factor == 1.: return '1' elif self.factor == 1.: return f'{self.base}^{_fmt_exp(self.scale)}' elif self.scale == 0: return str(self.factor) else: return f'{self.factor} * {self.base}^{_fmt_exp(self.scale)}' # Anonymous unit — build a descriptive string from components if self.factor == 1.: if self.scale == 0: return f'{self.dispname}' else: return f'{self.base}^{self.scale} * {self.dispname}' else: if self.scale == 0: return f'{self.factor} * {self.dispname}' else: return f'{self.factor} * {self.base}^{self.scale} * {self.dispname}' def __repr__(self) -> str: s = self._canonical_str() return f"Unit(\"{s}\")" def __str__(self) -> str: return self._canonical_str() def __mul__(self, other) -> 'Unit | Quantity': # self * other if isinstance(other, Unit): _assert_same_base(self, other) scale = self.scale + other.scale dim = self.dim * other.dim factor = self.factor * other.factor # Dimensionless → no compound display if dim == DIMENSIONLESS: return Unit(dim, scale=scale, base=self.base, factor=factor) # Both named → deterministic compound via display_parts if self.is_fullname and other.is_fullname: parts = _merge_display_parts( _get_display_parts(self), _get_display_parts(other), ) canonical = _format_display_parts(parts) return Unit( dim, scale=scale, base=self.base, factor=factor, name=canonical, dispname=canonical, is_fullname=True, display_parts=parts, ) # Fallback: standard-unit lookup name, dispname, is_fullname, _ = _find_standard_unit( dim, self.base, scale, factor ) return Unit( dim, scale=scale, base=self.base, factor=factor, name=name, dispname=dispname, is_fullname=is_fullname, ) elif isinstance(other, Dimension): raise TypeError(f"unit {self} cannot multiply by a Dimension {other}.") else: from ._base_quantity import Quantity if isinstance(other, Quantity): return Quantity(other.mantissa, unit=(self * other.unit)) return Quantity(other, unit=self) def __rmul__(self, other) -> 'Unit | Quantity': # other * self if isinstance(other, Unit): return other.__mul__(self) from ._base_quantity import Quantity if isinstance(other, Quantity): return Quantity(other.mantissa, unit=(other.unit * self)) return Quantity(other, unit=self) def __imul__(self, other): raise NotImplementedError("Units cannot be modified in-place") def __div__(self, other) -> 'Unit': # self / other if isinstance(other, Unit): _assert_same_base(self, other) scale = self.scale - other.scale dim = self.dim / other.dim factor = self.factor / other.factor # Dimensionless → no compound display if dim == DIMENSIONLESS: return Unit(dim, scale=scale, base=self.base, factor=factor) # Both named → deterministic compound via display_parts if self.is_fullname and other.is_fullname: other_parts = [(n, d, -e) for n, d, e in _get_display_parts(other)] parts = _merge_display_parts( _get_display_parts(self), other_parts, ) canonical = _format_display_parts(parts) return Unit( dim, base=self.base, scale=scale, factor=factor, name=canonical, dispname=canonical, is_fullname=True, display_parts=parts, ) # Fallback: standard-unit lookup name, dispname, is_fullname, _ = _find_standard_unit( dim, self.base, scale, factor ) return Unit( dim, base=self.base, scale=scale, factor=factor, name=name, dispname=dispname, is_fullname=is_fullname, ) else: raise TypeError(f"unit {self} cannot divide by a non-unit {other}") def __rdiv__(self, other) -> 'Unit | Quantity': # other / self if isinstance(other, Unit): return other.__div__(self) from ._base_quantity import Quantity if isinstance(other, Quantity): return Quantity(other.mantissa, unit=(other.unit / self)) return Quantity(other, unit=self.reverse())
[docs] def reverse(self): """ Return the multiplicative inverse of this unit. Computes ``1 / self``, producing a new unit with negated scale, inverted factor, and reciprocal dimensions. Returns ------- Unit A new Unit representing the reciprocal of this unit. Examples -------- .. code-block:: python >>> import saiunit as u >>> u.second.reverse() Unit("Hz") >>> u.metre.reverse() Unit("1 / m") """ dim = self.dim ** -1 scale = -self.scale factor = 1. / self.factor # Standard-unit lookup — allowed for reverse() because it is a # single-operand transform where the preference system correctly # picks hertz over becquerel, etc. name, dispname, is_fullname, dimless = _find_standard_unit( dim, self.base, scale, factor ) if is_fullname: return Unit( dim, base=self.base, scale=scale, factor=factor, name=name, dispname=dispname, is_fullname=True, ) # Build from display_parts (negate exponents) if self.is_fullname: parts = [(n, d, -e) for n, d, e in _get_display_parts(self)] parts = _normalise_display_parts(parts) canonical = _format_display_parts(parts) return Unit( dim, base=self.base, scale=scale, factor=factor, name=canonical, dispname=canonical, is_fullname=True, display_parts=parts, ) return Unit( dim, base=self.base, scale=scale, factor=factor, name=name, dispname=dispname, is_fullname=is_fullname, )
def __idiv__(self, other): raise NotImplementedError("Units cannot be modified in-place") def __truediv__(self, oc): # self / oc return self.__div__(oc) def __rtruediv__(self, oc): # oc / self return self.__rdiv__(oc) def __itruediv__(self, other): raise NotImplementedError("Units cannot be modified in-place") def __floordiv__(self, oc): raise NotImplementedError("Units cannot be performed floor division") def __rfloordiv__(self, oc): raise NotImplementedError("Units cannot be performed floor division") def __ifloordiv__(self, other): raise NotImplementedError("Units cannot be modified in-place") def __pow__(self, other): # self ** other from ._base_getters import is_scalar_type if is_scalar_type(other): dim = self.dim ** other scale = self.scale * other factor = self.factor ** other if dim == DIMENSIONLESS: return Unit(dim, scale=scale, base=self.base, factor=factor) # Named source → build from display_parts (multiply exponents). # This avoids ambiguous standard-unit aliases (e.g. m^3→kl, # (m/s)^2→Gy) and keeps display consistent with __mul__/__div__. if self.is_fullname: src_parts = _get_display_parts(self) parts = [(n, d, e * other) for n, d, e in src_parts] parts = _normalise_display_parts(parts) canonical = _format_display_parts(parts) return Unit( dim, base=self.base, scale=scale, factor=factor, name=canonical, dispname=canonical, is_fullname=True, display_parts=parts, ) # Fallback: standard-unit lookup (for anonymous units) name, dispname, is_fullname, dimless = _find_standard_unit( dim, self.base, scale, factor ) return Unit( dim, base=self.base, scale=scale, factor=factor, name=name, dispname=dispname, is_fullname=is_fullname, ) else: raise TypeError( f"unit cannot perform an exponentiation (unit ** other) with a non-scalar, " f"since one unit cannot contain multiple units. \n" f"But we got unit={self}, other={other}" ) def __ipow__(self, other, modulo=None): raise NotImplementedError("Units cannot be modified in-place") def __add__(self, other: 'Unit') -> 'Unit': # self + other if not isinstance(other, Unit): raise TypeError(f"Expected a Unit, but got {other}") if self.has_same_dim(other): if self.has_same_magnitude(other): return self.copy() else: raise TypeError(f"Units {self} and {other} have different units.") else: raise TypeError(f"Units {self} and {other} have different dimensions.") def __radd__(self, oc: 'Unit') -> 'Unit': return self.__add__(oc) def __iadd__(self, other): raise NotImplementedError("Units cannot be modified in-place") def __sub__(self, other: 'Unit') -> 'Unit': # self - other if not isinstance(other, Unit): raise TypeError(f"Expected a Unit, but got {other}") if self.has_same_dim(other): if self.has_same_magnitude(other): return self.copy() else: raise TypeError(f"Units {self} and {other} have different units.") else: raise TypeError(f"Units {self} and {other} have different dimensions.") def __rsub__(self, oc: 'Unit') -> 'Unit': return self.__sub__(oc) def __isub__(self, other): raise NotImplementedError("Units cannot be modified in-place") def __mod__(self, oc): raise NotImplementedError("Units cannot be performed modulo") def __rmod__(self, oc): raise NotImplementedError("Units cannot be performed modulo") def __imod__(self, other): raise NotImplementedError("Units cannot be modified in-place") def __eq__(self, other) -> bool: if isinstance(other, Unit): return ( (other.dim == self.dim) and (other.scale == self.scale) and (other.base == self.base) and (other.factor == self.factor) # and (other.name == self.name) # and (other.dispname == self.dispname) ) else: return False def __ne__(self, other) -> bool: return not self.__eq__(other) def __abs__(self) -> 'Unit': """Return the unit itself — units are always non-negative.""" return self def __reduce__(self): # For pickling return ( _to_unit, ( self.dim, self.scale, self.base, self.factor, self.name, self.dispname, self.is_fullname ) ) def _to_unit(*args): """Private pickle reconstruction shim for Unit. """ return Unit(*args) _to_unit.__module__ = 'saiunit._base_unit' UNITLESS = Unit() """ The canonical unitless (dimensionless) unit. ``UNITLESS`` is a singleton-like :class:`Unit` with no physical dimensions, a scale of 0, a base of 10, and a factor of 1. It is returned by default when a :class:`Unit` is constructed with no arguments, and is used internally as the neutral element of unit arithmetic. .. code-block:: python >>> import saiunit as u >>> u.UNITLESS.is_unitless True >>> u.UNITLESS.dim.is_dimensionless True """