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 math
import numbers
import re
import threading
from copy import deepcopy
from typing import TYPE_CHECKING, Any

from ._base_dimension import (
    Dimension,
    DIMENSIONLESS,
)
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)


_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]
# Guards all registry mutation in ``add_standard_unit`` so concurrent
# registration (e.g. user threads importing unit-defining modules) cannot
# interleave alias-list updates with preferred-unit selection.
_registry_lock = threading.Lock()

# ---------------------------------------------------------------------------
# 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)) ): with _registry_lock: key = (u.dim, u.scale, u.base, u.factor) aliases = _standard_unit_aliases.setdefault(key, []) # Dedup by identity *and* by (name, dispname): re-running the # same ``Unit.create(...)`` call produces a new instance each # time; without value-based dedup, repeated registration grows # the alias list unboundedly and poisons the ambiguity # heuristic below. if not any( existing is u or (existing.name == u.name and existing.dispname == u.dispname) 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_top_level(s: str, sep: str): """Split *s* on every top-level occurrence of *sep* (outside parens).""" parts = [] depth = 0 start = 0 i = 0 n = len(sep) while i < len(s): ch = s[i] if ch == '(': depth += 1 elif ch == ')': depth -= 1 elif depth == 0 and s[i:i + n] == sep: parts.append(s[start:i]) start = i + n i = start continue i += 1 parts.append(s[start:]) return parts def _split_product(s: str): """Split on ``' * '`` respecting parentheses.""" return _split_top_level(s, ' * ') 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_expression(s: str): """Parse a full expression with left-associative top-level division. ``'a / b / c'`` parses as ``(a / b) / c``, matching ordinary mathematical convention. """ segments = _split_top_level(s.strip(), ' / ') result = _parse_product(segments[0].strip()) for seg in segments[1:]: result = result / _parse_product(seg.strip()) 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}") return _parse_expression(inner) 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) except ValueError: pass else: # Outside the ``try`` so that Unit's own validation errors # (e.g. non-positive factor) surface instead of being # swallowed and reported as "unknown unit". if base_num == 10.0: return Unit(DIMENSIONLESS, scale=exp) return Unit(DIMENSIONLESS, factor=base_num ** exp) 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) except ValueError: pass else: return Unit(DIMENSIONLESS, scale=0, base=10., factor=num) 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 (also covers registered names # containing spaces, e.g. "troy pound", before any normalisation) if s in _unit_name_registry: return _unit_name_registry[s] # Normalise spacing so users may write "J/kg" or "mS*nA" in addition # to the canonical "J / kg" / "mS * nA". ``**`` (Python power, not # part of the canonical grammar) is deliberately left untouched. s = ' '.join(s.split()) s = re.sub(r'\s*/\s*', ' / ', s) s = re.sub(r'(?<!\*)\s*\*\s*(?!\*)', ' * ', s) if s in _unit_name_registry: return _unit_name_registry[s] # Compound expression (left-associative division) return _parse_expression(s) def _normalise_scalar(x: Any) -> Any: """Coerce numpy/generic real scalars to plain ``int``/``float``. Standard-unit registration and lookup require plain Python numbers, so e.g. ``np.int64(3)`` must behave the same as ``3``. Non-Real objects (JAX tracers, arrays) pass through untouched. """ if isinstance(x, (int, float)): return x if isinstance(x, numbers.Integral): return int(x) if isinstance(x, numbers.Real): return float(x) return x def _validate_scale_and_factor(scale: Any, factor: Any) -> None: """Reject scale/factor values that poison Unit arithmetic. Non-finite scales break ``__eq__``/``__hash__`` (NaN is unequal to itself) and display; non-positive factors cannot represent a physical conversion and crash ``reverse()``/division (factor 0) or produce complex factors under fractional powers (negative factor). Values that are not plain real numbers (e.g. JAX tracers) are not checked. """ if isinstance(scale, complex) or isinstance(factor, complex): raise TypeError( f"Unit scale and factor must be real numbers; " f"got scale={scale!r}, factor={factor!r}." ) if isinstance(scale, (int, float)) and not math.isfinite(scale): raise ValueError( f"Unit scale must be a finite real number; got scale={scale!r}." ) if isinstance(factor, (int, float)): if not math.isfinite(factor): raise ValueError( f"Unit factor must be a finite real number; got factor={factor!r}." ) if factor <= 0: raise ValueError( f"Unit factor must be positive; got factor={factor!r}." ) 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. Must be 10 (the only supported base); other values raise ``ValueError``. factor : array_like, optional The conversion factor of the unit. Must be positive. 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 is_fullname is not True: extras.append("is_fullname") 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." ) scale = _normalise_scalar(scale) factor = _normalise_scalar(factor) _validate_scale_and_factor(scale, factor) 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. Extreme scales whose magnitude exceeds the float range yield ``inf`` (IEEE-754 overflow semantics) rather than raising. Examples -------- .. code-block:: python >>> import saiunit as u >>> u.mvolt.magnitude 0.001 >>> u.kvolt.magnitude 1000.0 """ # magnitude = factor * base ** scale try: return self.factor * self.base ** self.scale except OverflowError: # Python float pow raises on overflow instead of following # IEEE-754; treat extreme scales as infinity like every other # float operation in the library. return float('inf') @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 an equivalent Unit with the factor set to 1. If this unit already has ``factor == 1``, the unit itself is returned unchanged. (Substituting a registry-preferred alias here would silently rename units, e.g. becquerel → hertz or steradian → radian.) Returns ------- Unit This unit if its factor is already 1, otherwise a Unit with the factor set to 1 (the registered standard unit for the same dimension/scale when one exists). Examples -------- .. code-block:: python >>> import saiunit as u >>> pound = u.Unit.create( ... u.get_or_create_dimension(kg=1), 'pound', 'lb', factor=0.453592) >>> pound.factor 0.453592 >>> pound.factorless().factor 1.0 """ # Already factorless — nothing to strip. if isinstance(self.factor, (int, float)) and self.factor == 1.: return self # 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 >>> v = u.volt.copy() >>> v == u.volt True >>> v 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``. Note that the comparison is component-wise, not on the computed product ``factor * base**scale``: a unit encoded as ``scale=3`` and one encoded as ``factor=1000.`` have equal magnitude products but compare unequal here. 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). Must be 10, the only supported base; other values raise ``ValueError``. 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, factor=baseunit.factor, 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 | 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 — 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, ) elif isinstance(other, Dimension): raise TypeError(f"unit {self} cannot divide by a Dimension {other}.") else: # Mirror ``__mul__``: dividing a unit by a number or quantity # yields a Quantity (e.g. ``mV / 2`` == 0.5 mV), keeping the # operator surface consistent with ``mV * 2`` and ``2 / mV``. from ._base_quantity import Quantity if isinstance(other, Quantity): return Quantity(1.0 / other.mantissa, unit=(self / other.unit)) return Quantity(1.0 / other, unit=self) 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 have the same dim/scale/base/factor # *components*. 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. Note the comparison is component-wise, # not magnitude-based: ``Unit(scale=3)`` and ``Unit(factor=1000.)`` # have the same magnitude but compare unequal. Factor-1 # dimensionless units (radian, steradian) compare equal to # ``UNITLESS`` — physically they are 1. # # Non-Unit operands return ``NotImplemented`` so that the other # operand's reflected comparison runs (e.g. Quantity.__eq__), # keeping ``unit == quantity`` and ``quantity == unit`` symmetric. if not isinstance(other, Unit): return NotImplemented 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: result = self.__eq__(other) if result is NotImplemented: return result return not result 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 """