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.
# ==============================================================================

from __future__ import annotations

import re
from copy import deepcopy
from typing import TYPE_CHECKING

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

if TYPE_CHECKING:
    from ._base_quantity import Quantity

__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:
        # Dimensionless aliases (radian, steradian, percent, ...) intentionally
        # drop their display name in arithmetic results: ``radian / radian``
        # is bare 1, not "rad". Callers needing to preserve a dimensionless
        # alias (e.g. ``factorless()`` on radian) consult ``_standard_units``
        # directly before invoking this helper.
        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 _format_dim_parser_compatible(dim: Dimension, python_code: bool = False) -> str:
    """Render *dim* in parser-compatible canonical form.

    ``Dimension.__str__`` emits space-separated SI factors (``"m kg s^-2"``)
    which cannot be round-tripped through :func:`parse_unit`.  This helper
    produces the same content with the canonical `` * `` / ``^`` / `` / ``
    grammar used by :func:`_format_display_parts`, so anonymous Units have
    a name/dispname that the parser can read back.

    When ``python_code`` is True, the full-name SI labels (``metre``,
    ``kilogram``, ...) are used in place of the short symbols.
    """
    from ._base_dimension import _ilabel, _iclass_label
    labels = _iclass_label if python_code else _ilabel
    dims = dim._dims
    parts = []
    for i in range(len(dims)):
        if dims[i]:
            parts.append((labels[i], labels[i], dims[i]))
    if not parts:
        return "1"
    return _format_display_parts(parts)


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]' = {}
# Monotonically-increasing registration index for each registered Unit
# identity.  Used by :func:`_select_preferred_standard_unit` to prefer
# units that were registered earlier (i.e. library built-ins) over
# user-added aliases for the same physical key.
_unit_registration_index: 'dict[int, int]' = {}
_next_registration_index: 'list[int]' = [0]

# ---------------------------------------------------------------------------
# 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.

    Order of preference (lower is better):

    1. ``_standard_unit_preference_score`` (e.g. prefer hertz over
       becquerel for s^-1).
    2. Registration index — library built-ins win over user
       additions made via :func:`add_standard_unit` after import,
       so user aliases cannot hijack canonical display.
    3. Alphabetical, as a final deterministic tie-breaker for units
       registered in the same call.
    """
    def _key(u):
        idx = _unit_registration_index.get(id(u), float('inf'))
        return (
            _standard_unit_preference_score(u),
            idx,
            u.name.lower() if isinstance(u.name, str) else "",
        )
    return min(units, key=_key)


[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, []) # Dedup by identity: a Unit instance is registered at most once. # Without this, repeated calls grow the alias list unboundedly # and poison the ambiguity heuristic below. if not any(existing is u for existing in aliases): aliases.append(u) # Stamp a monotonic registration index so that later # additions cannot hijack the canonical display. _unit_registration_index[id(u)] = _next_registration_index[0] _next_registration_index[0] += 1 _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 unit._display_parts 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'``. Parenthesised sub-expressions are recursively parsed as full fraction/product expressions; this lets ``parse_unit("(m * s) / A")`` succeed. """ s = s.strip() # Strip a single outer paren wrapping the whole term and recurse. if s.startswith('(') and s.endswith(')'): depth = 0 balanced_at_zero_only_at_end = True for i, ch in enumerate(s): if ch == '(': depth += 1 elif ch == ')': depth -= 1 if depth == 0 and i != len(s) - 1: balanced_at_zero_only_at_end = False break if balanced_at_zero_only_at_end: inner = s[1:-1].strip() if not inner: raise ValueError(f"Empty parenthesised group in {s!r}") num_str, den_str = _split_fraction(inner) numerator = _parse_product(num_str) if den_str is not None: return numerator / _parse_product(den_str) return numerator 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"). # ``Unit`` is base=10-only, so encode ``base_num ** exp`` either # in ``scale`` (when base_num==10) or in ``factor``. try: base_num = float(atom) if base_num == 10.0: return Unit(DIMENSIONLESS, scale=exp) return Unit(DIMENSIONLESS, factor=float(base_num) ** exp) 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 _dim: 'Dimension' _base: int | float _scale: int | float _factor: int | float _dispname: str _name: str is_fullname: bool _hash: int | None _display_parts: 'list[tuple[str, str, int]] | None' def __init__( self, dim: 'Dimension | str | None' = None, scale: int | float = 0, base: int | float = 10., factor: int | float = 1., name: str | None = None, dispname: str | None = None, is_fullname: bool = True, display_parts=None, ): # String-based construction: Unit("mV"), Unit("J / kg"), etc. if isinstance(dim, str): # The string form ignores every other constructor argument — # silently dropping ``Unit("mV", scale=99, factor=99)`` was a # source of confusing bugs. Reject any non-default secondary # argument explicitly so the caller knows. extras = [] if scale != 0: extras.append("scale") if base != 10.: extras.append("base") if factor != 1.: extras.append("factor") if name is not None: extras.append("name") if dispname is not None: extras.append("dispname") if display_parts is not None: extras.append("display_parts") if extras: raise TypeError( "Unit(str, ...) does not accept additional arguments: " + ", ".join(extras) + ". Use parse_unit() and modify the result, or construct " "the Unit from a Dimension instead." ) 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 # ``base`` is fixed at 10 for now — Units canonicalize to base=10 # internally, and accepting other bases silently rewrote them, # losing information. Raise so callers can not be surprised. if base != 10.: raise ValueError( f"Unit currently only supports base=10; got base={base!r}. " "Encode non-decimal scales in ``factor`` instead." ) # Reject NaN/inf factors — these poison arithmetic downstream and # cannot represent a valid physical conversion. if isinstance(factor, (int, float)): import math if math.isnan(factor) or math.isinf(factor): raise ValueError( f"Unit factor must be a finite real number; got factor={factor!r}." ) self._base = base self._scale = scale 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: # Anonymous Units must produce parser-compatible # name/dispname so that ``parse_unit(repr(u))`` round-trips. # ``Dimension.__str__`` uses space separation which the # parser cannot read. ``_canonical_str`` (called by # ``__repr__``/``__str__``) prefixes the factor/scale on # its own for anonymous units, so we only encode the # dimensional part here. name = _format_dim_parser_compatible(dim, python_code=True) if dispname is None: dispname = _format_dim_parser_compatible(dim, python_code=False) 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, display_parts=( list(self._display_parts) if self._display_parts is not None else None ), )
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), display_parts=deepcopy(self._display_parts, memodict), ) def __hash__(self): if self._hash is None: # Equality is *physical*: two units that resolve to the same # ``(dim, factor, base, scale)`` must hash equal regardless # of name spelling (e.g. ``metre`` vs ``meter``). self._hash = hash( ( self.dim, self.factor, self.base, self.scale, ) ) 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 | float = 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. The standard-unit substitution is resolved eagerly at construction time (in ``__mul__``/``__div__``/``__pow__``/ ``reverse``) and stored on ``_name``/``_dispname``, so this method simply returns the stored canonical name when ``is_fullname`` is set. This keeps ``unit.name`` consistent with ``str(unit)`` and survives pickle/copy. """ if self.is_fullname: return self.dispname if self._display_parts is not None: return _format_display_parts(self._display_parts) 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 result. When neither operand carries a named # dimensionless display (radian, steradian, ...), the result is # bare ``Unit("1")`` as before. When at least one operand is a # named dimensionless unit, merge its display parts so the # name survives ``radian * UNITLESS`` and compounds such as # ``radian * radian`` render as ``rad^2`` rather than ``1``. if dim == DIMENSIONLESS: self_named_dimless = self.is_fullname and self.dim.is_dimensionless other_named_dimless = other.is_fullname and other.dim.is_dimensionless if self_named_dimless or other_named_dimless: parts_a = _get_display_parts(self) if self_named_dimless else [] parts_b = _get_display_parts(other) if other_named_dimless else [] parts = _normalise_display_parts(_merge_display_parts(parts_a, parts_b)) if parts: 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, ) 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), ) parts = _normalise_display_parts(parts) # Eagerly resolve a registered standard name for the # composed quantity so that ``name``/``dispname`` stay in # sync with ``str(self)``. Falls back to the parts-based # canonical string for ambiguous keys or anonymous results. std_name, std_disp, std_is_full, _ = _find_standard_unit( dim, self.base, scale, factor, for_composition=True, ) if std_is_full: name, dispname, is_fullname = std_name, std_disp, True else: canonical = _format_display_parts(parts) name, dispname, is_fullname = canonical, canonical, True return Unit( dim, scale=scale, base=self.base, factor=factor, name=name, dispname=dispname, is_fullname=is_fullname, 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)) # type: ignore[arg-type] 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)) # type: ignore[arg-type] 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 result — preserve named-dimensionless display # (radian, steradian, ...) so ``rad / UNITLESS`` stays ``rad``. if dim == DIMENSIONLESS: self_named_dimless = self.is_fullname and self.dim.is_dimensionless other_named_dimless = other.is_fullname and other.dim.is_dimensionless if self_named_dimless or other_named_dimless: parts_a = _get_display_parts(self) if self_named_dimless else [] parts_b = ( [(n, d, -e) for n, d, e in _get_display_parts(other)] if other_named_dimless else [] ) parts = _normalise_display_parts(_merge_display_parts(parts_a, parts_b)) if 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, 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, ) parts = _normalise_display_parts(parts) std_name, std_disp, std_is_full, _ = _find_standard_unit( dim, self.base, scale, factor, for_composition=True, ) if std_is_full: name, dispname, is_fullname = std_name, std_disp, True else: canonical = _format_display_parts(parts) name, dispname, is_fullname = canonical, canonical, True return Unit( dim, base=self.base, scale=scale, factor=factor, name=name, dispname=dispname, is_fullname=is_fullname, 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) # reverse() already handles the unambiguous standard-unit # case at the top; here we just render the 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: # Preserve a named-dimensionless display (radian, steradian) # through powers: ``radian ** 1`` is ``rad``, ``rad ** 2`` # is ``rad^2``; ``rad ** 0`` collapses to ``1`` naturally. if self.is_fullname and self.dim.is_dimensionless: src_parts = _get_display_parts(self) parts = _normalise_display_parts( [(n, d, e * other) for n, d, e in src_parts] ) if 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, 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) std_name, std_disp, std_is_full, _ = _find_standard_unit( dim, self.base, scale, factor, for_composition=True, ) if std_is_full: name, dispname, is_fullname = std_name, std_disp, True else: canonical = _format_display_parts(parts) name, dispname, is_fullname = canonical, canonical, True return Unit( dim, base=self.base, scale=scale, factor=factor, name=name, dispname=dispname, is_fullname=is_fullname, 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): raise TypeError( "Units cannot be added: addition is defined on quantities, not units. " "To add quantities, attach mantissas first (e.g. 1*ms + 2*ms)." ) def __radd__(self, other): raise TypeError( "Units cannot be added: addition is defined on quantities, not units. " "To add quantities, attach mantissas first (e.g. 1*ms + 2*ms)." ) def __iadd__(self, other): raise NotImplementedError("Units cannot be modified in-place") def __sub__(self, other): raise TypeError( "Units cannot be subtracted: subtraction is defined on quantities, not " "units. To subtract quantities, attach mantissas first (e.g. 2*ms - 1*ms)." ) def __rsub__(self, other): raise TypeError( "Units cannot be subtracted: subtraction is defined on quantities, not " "units. To subtract quantities, attach mantissas first (e.g. 2*ms - 1*ms)." ) 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: # Two Units are equal when they represent the same physical # quantity (matching dim/scale/base/factor). Names and display # strings are *not* part of equality: spelling aliases such as # ``metre`` vs ``meter`` and registry-canonical compounds such # as ``A * ohm`` vs ``V`` compare equal, and the corresponding # hashes collide as required by the ``__hash__`` contract. if not isinstance(other, Unit): return False return ( (other.dim == self.dim) and (other.scale == self.scale) and (other.base == self.base) and (other.factor == self.factor) ) 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. ``display_parts`` is forwarded so that compound # units (e.g. ``mS * nA / cm^2``) preserve their canonical # rendering across pickle round-trips. return ( _to_unit, ( self.dim, self.scale, self.base, self.factor, self.name, self.dispname, self.is_fullname, (list(self._display_parts) if self._display_parts is not None else None), ) ) def _to_unit(dim, scale, base, factor, name, dispname, is_fullname, display_parts=None): """Private pickle reconstruction shim for Unit.""" return Unit( dim=dim, scale=scale, base=base, factor=factor, name=name, dispname=dispname, is_fullname=is_fullname, display_parts=display_parts, ) _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 """