Source code for units.core

# units/core.py
from __future__ import annotations

"""
units.core

Core units implementation.

This file is intentionally dependency-free and small, but still structured:
- UnitDef: linear mapping to base units in a dimension group
- UnitRegistry: add/get/convert units
- Quantity: (value, unit) wrapper with `.to()`

Design goals:
- deterministic conversions
- tiny surface area
- no parser logic here
- suitable as the stable "math + registry" layer for the rest of TDPy
"""

from dataclasses import dataclass
from typing import Any, Dict, Optional


# -----------------------------------------------------------------------------
# Exceptions
# -----------------------------------------------------------------------------

[docs] class UnitError(ValueError): """Raised when units are unknown or incompatible."""
# ----------------------------------------------------------------------------- # Core: linear unit conversions within a dimension group # -----------------------------------------------------------------------------
[docs] @dataclass(frozen=True) class UnitDef: """ Linear mapping to a dimension's base unit: base = value * factor + offset For most units offset = 0. Temperatures are handled via offsets (C, F, R). """ dim: str factor: float offset: float = 0.0 canonical: Optional[str] = None
def _norm(unit: str) -> str: """Normalize a unit token.""" return str(unit).strip().replace("°", "").replace(" ", "")
[docs] class UnitRegistry: """ Registry of unit definitions. Each unit belongs to a dimension group; conversions are only allowed within the same dimension group. """ def __init__(self) -> None: self._units: Dict[str, UnitDef] = {} # ----- registration -----
[docs] def add( self, unit: str, dim: str, factor: float, offset: float = 0.0, canonical: Optional[str] = None, ) -> None: key = _norm(unit) if not key: raise UnitError("Unit token cannot be empty.") self._units[key] = UnitDef( dim=dim, factor=float(factor), offset=float(offset), canonical=canonical or key, )
# ----- lookup -----
[docs] def has(self, unit: str) -> bool: return _norm(unit) in self._units
[docs] def get(self, unit: str) -> UnitDef: key = _norm(unit) if key not in self._units: raise UnitError(f"Unknown unit: {unit!r}") return self._units[key]
[docs] def dim(self, unit: str) -> str: return self.get(unit).dim
# ----- conversions -----
[docs] def to_base(self, value: float, from_unit: str) -> float: u = self.get(from_unit) return float(value) * u.factor + u.offset
[docs] def from_base(self, base_value: float, to_unit: str) -> float: u = self.get(to_unit) if u.factor == 0.0: raise UnitError(f"Invalid unit factor for {to_unit!r}") return (float(base_value) - u.offset) / u.factor
[docs] def convert(self, value: float, from_unit: str, to_unit: str) -> float: uf = self.get(from_unit) ut = self.get(to_unit) if uf.dim != ut.dim: raise UnitError( f"Incompatible units: {from_unit!r} ({uf.dim}) -> {to_unit!r} ({ut.dim})" ) base = self.to_base(float(value), from_unit) return self.from_base(base, to_unit)
# ----- introspection -----
[docs] def list_units(self, dim: Optional[str] = None) -> Dict[str, UnitDef]: if dim is None: return dict(self._units) return {k: v for k, v in self._units.items() if v.dim == dim}
[docs] @dataclass(frozen=True) class Quantity: value: float unit: str registry: UnitRegistry @property def dim(self) -> str: return self.registry.dim(self.unit)
[docs] def to(self, unit: str) -> "Quantity": v = self.registry.convert(self.value, self.unit, unit) return Quantity(v, unit, self.registry)
[docs] def base_value(self) -> float: return self.registry.to_base(self.value, self.unit)
[docs] def as_dict(self) -> Dict[str, Any]: return {"value": self.value, "unit": self.unit, "dim": self.dim}
__all__ = [ "UnitError", "UnitDef", "UnitRegistry", "Quantity", ]