# units/__init__.py
from __future__ import annotations
"""
units
Lightweight units and conversion helpers.
Design goals:
- Simple, dependency-free
- Explicit coverage of common thermo / fluids engineering units
- Friendly parsing for CLI/JSON usage
- Stable convenience API for the rest of TDPy
Public API
----------
- UnitError
- UnitDef
- UnitRegistry
- Quantity
- DEFAULT_REGISTRY
- default_registry()
- parse_quantity(text, default_unit=None, to_unit=None) -> Quantity
- convert(value, from_unit, to_unit) -> float
- convert_value(value, from_unit, to_unit, registry) -> float
"""
import re
from typing import Optional
from .core import Quantity, UnitDef, UnitError, UnitRegistry
# ------------------------------ normalization helpers ------------------------------
def _clean_unit_expr(u: str) -> str:
"""
Normalize a unit expression conservatively.
- lowercases
- removes spaces and degree symbol
- strips surrounding brackets/parentheses
- keeps separators '/', '-', '*' because we use them for curated composite lookups
"""
s = str(u).strip().lower()
if not s:
return ""
s = s.replace("°", "")
if (s.startswith("[") and s.endswith("]")) or (s.startswith("(") and s.endswith(")")):
s = s[1:-1].strip().lower()
s = s.replace(" ", "")
# common entropy cleanup
if s.endswith("kgk") and (s.startswith("j/") or s.startswith("kj/") or s.startswith("mj/")):
s = s.replace("kgk", "kg-k")
s = s.replace("lbmr", "lbm-r")
return s
def _norm_unit(u: str | None) -> str:
if u is None:
return ""
return _clean_unit_expr(u)
# ------------------------------ default registry ------------------------------
[docs]
def default_registry() -> UnitRegistry:
"""
Create the default registry covering common thermo / fluids units.
Base units by dimension:
temperature -> K
pressure -> Pa
length -> m
area -> m2
volume -> m3
mass -> kg
time -> s
velocity -> m/s
energy -> J
power -> W
spec_energy -> J/kg
spec_entropy -> J/(kg*K)
density -> kg/m3
mass_flow -> kg/s
volumetric_flow -> m3/s
"""
r = UnitRegistry()
# --- temperature (base: K)
r.add("k", "temperature", 1.0, 0.0, canonical="K")
r.add("kelvin", "temperature", 1.0, 0.0, canonical="K")
r.add("c", "temperature", 1.0, 273.15, canonical="C")
r.add("degc", "temperature", 1.0, 273.15, canonical="C")
r.add("celsius", "temperature", 1.0, 273.15, canonical="C")
r.add("f", "temperature", 5.0 / 9.0, 255.3722222222222, canonical="F")
r.add("degf", "temperature", 5.0 / 9.0, 255.3722222222222, canonical="F")
r.add("fahrenheit", "temperature", 5.0 / 9.0, 255.3722222222222, canonical="F")
r.add("r", "temperature", 5.0 / 9.0, 0.0, canonical="R")
r.add("degr", "temperature", 5.0 / 9.0, 0.0, canonical="R")
r.add("rankine", "temperature", 5.0 / 9.0, 0.0, canonical="R")
# --- pressure (base: Pa)
r.add("pa", "pressure", 1.0, 0.0, canonical="Pa")
r.add("pascal", "pressure", 1.0, 0.0, canonical="Pa")
r.add("kpa", "pressure", 1e3, 0.0, canonical="kPa")
r.add("mpa", "pressure", 1e6, 0.0, canonical="MPa")
r.add("gpa", "pressure", 1e9, 0.0, canonical="GPa")
r.add("bar", "pressure", 1e5, 0.0, canonical="bar")
r.add("mbar", "pressure", 1e2, 0.0, canonical="mbar")
r.add("atm", "pressure", 101325.0, 0.0, canonical="atm")
r.add("torr", "pressure", 133.32236842105263, 0.0, canonical="torr")
r.add("mmhg", "pressure", 133.32236842105263, 0.0, canonical="mmHg")
r.add("psi", "pressure", 6894.757293168361, 0.0, canonical="psi")
r.add("psia", "pressure", 6894.757293168361, 0.0, canonical="psia")
r.add("psig", "pressure", 6894.757293168361, 0.0, canonical="psig")
# --- length (base: m)
r.add("m", "length", 1.0, 0.0, canonical="m")
r.add("meter", "length", 1.0, 0.0, canonical="m")
r.add("metre", "length", 1.0, 0.0, canonical="m")
r.add("mm", "length", 1e-3, 0.0, canonical="mm")
r.add("cm", "length", 1e-2, 0.0, canonical="cm")
r.add("km", "length", 1e3, 0.0, canonical="km")
r.add("in", "length", 0.0254, 0.0, canonical="in")
r.add("inch", "length", 0.0254, 0.0, canonical="in")
r.add("ft", "length", 0.3048, 0.0, canonical="ft")
r.add("feet", "length", 0.3048, 0.0, canonical="ft")
# --- area (base: m2)
r.add("m2", "area", 1.0, 0.0, canonical="m2")
r.add("cm2", "area", 1e-4, 0.0, canonical="cm2")
r.add("mm2", "area", 1e-6, 0.0, canonical="mm2")
r.add("in2", "area", 0.00064516, 0.0, canonical="in2")
r.add("ft2", "area", 0.09290304, 0.0, canonical="ft2")
# --- volume (base: m3)
r.add("m3", "volume", 1.0, 0.0, canonical="m3")
r.add("l", "volume", 1e-3, 0.0, canonical="L")
r.add("cm3", "volume", 1e-6, 0.0, canonical="cm3")
r.add("mm3", "volume", 1e-9, 0.0, canonical="mm3")
r.add("in3", "volume", 1.6387064e-5, 0.0, canonical="in3")
r.add("ft3", "volume", 0.028316846592, 0.0, canonical="ft3")
# --- mass (base: kg)
r.add("kg", "mass", 1.0, 0.0, canonical="kg")
r.add("g", "mass", 1e-3, 0.0, canonical="g")
r.add("lbm", "mass", 0.45359237, 0.0, canonical="lbm")
r.add("lb", "mass", 0.45359237, 0.0, canonical="lbm")
# --- time (base: s)
r.add("s", "time", 1.0, 0.0, canonical="s")
r.add("sec", "time", 1.0, 0.0, canonical="s")
r.add("min", "time", 60.0, 0.0, canonical="min")
r.add("hr", "time", 3600.0, 0.0, canonical="hr")
r.add("h", "time", 3600.0, 0.0, canonical="hr")
# --- velocity (base: m/s)
r.add("m/s", "velocity", 1.0, 0.0, canonical="m/s")
r.add("ft/s", "velocity", 0.3048, 0.0, canonical="ft/s")
# --- energy (base: J)
r.add("j", "energy", 1.0, 0.0, canonical="J")
r.add("kj", "energy", 1e3, 0.0, canonical="kJ")
r.add("mj", "energy", 1e6, 0.0, canonical="MJ")
r.add("btu", "energy", 1055.05585262, 0.0, canonical="Btu")
# --- power (base: W)
r.add("w", "power", 1.0, 0.0, canonical="W")
r.add("kw", "power", 1e3, 0.0, canonical="kW")
r.add("mw", "power", 1e6, 0.0, canonical="MW")
r.add("hp", "power", 745.6998715822702, 0.0, canonical="hp")
# --- specific energy (base: J/kg)
r.add("j/kg", "spec_energy", 1.0, 0.0, canonical="J/kg")
r.add("j/kgm", "spec_energy", 1.0, 0.0, canonical="J/kg")
r.add("kj/kg", "spec_energy", 1e3, 0.0, canonical="kJ/kg")
r.add("mj/kg", "spec_energy", 1e6, 0.0, canonical="MJ/kg")
r.add("btu/lbm", "spec_energy", 1055.05585262 / 0.45359237, 0.0, canonical="Btu/lbm")
# --- specific entropy (base: J/kg-K)
r.add("j/kg-k", "spec_entropy", 1.0, 0.0, canonical="J/(kg*K)")
r.add("j/kg/k", "spec_entropy", 1.0, 0.0, canonical="J/(kg*K)")
r.add("j/(kg*k)", "spec_entropy", 1.0, 0.0, canonical="J/(kg*K)")
r.add("kj/kg-k", "spec_entropy", 1e3, 0.0, canonical="kJ/(kg*K)")
r.add("kj/kg/k", "spec_entropy", 1e3, 0.0, canonical="kJ/(kg*K)")
r.add("kj/(kg*k)", "spec_entropy", 1e3, 0.0, canonical="kJ/(kg*K)")
r.add("btu/lbm-r", "spec_entropy", 1055.05585262 / (0.45359237 * (5.0 / 9.0)), 0.0, canonical="Btu/(lbm*R)")
r.add("btu/lbm/degr", "spec_entropy", 1055.05585262 / (0.45359237 * (5.0 / 9.0)), 0.0, canonical="Btu/(lbm*R)")
# --- density (base: kg/m3)
r.add("kg/m3", "density", 1.0, 0.0, canonical="kg/m3")
r.add("g/cm3", "density", 1000.0, 0.0, canonical="g/cm3")
# --- mass flow (base: kg/s)
r.add("kg/s", "mass_flow", 1.0, 0.0, canonical="kg/s")
r.add("kg/min", "mass_flow", 1.0 / 60.0, 0.0, canonical="kg/min")
r.add("lbm/s", "mass_flow", 0.45359237, 0.0, canonical="lbm/s")
r.add("lbm/min", "mass_flow", 0.45359237 / 60.0, 0.0, canonical="lbm/min")
# --- volumetric flow (base: m3/s)
r.add("m3/s", "volumetric_flow", 1.0, 0.0, canonical="m3/s")
r.add("l/s", "volumetric_flow", 1e-3, 0.0, canonical="L/s")
r.add("l/min", "volumetric_flow", 1e-3 / 60.0, 0.0, canonical="L/min")
r.add("cfm", "volumetric_flow", 0.028316846592 / 60.0, 0.0, canonical="cfm")
return r
DEFAULT_REGISTRY = default_registry()
# ------------------------------ conversion convenience ------------------------------
[docs]
def convert(value: float, from_unit: str, to_unit: str) -> float:
"""
Convert a scalar using DEFAULT_REGISTRY.
This is the convenience function most of the rest of TDPy expects.
"""
fu = _norm_unit(from_unit)
tu = _norm_unit(to_unit)
if not fu and not tu:
return float(value)
if fu == tu:
return float(value)
return DEFAULT_REGISTRY.convert(float(value), fu, tu)
[docs]
def convert_value(value: float, from_unit: str, to_unit: str, registry: UnitRegistry) -> float:
"""
Convenience wrapper for an explicit registry.
"""
return registry.convert(float(value), _norm_unit(from_unit), _norm_unit(to_unit))
# ------------------------------ parsing ------------------------------
_Q_RE = re.compile(
r"""^\s*
(?P<val>[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)\s*
(?:
(?:\[\s*(?P<u1>[^\]]+)\s*\]) |
(?P<u2>[^#;!]+)
)?
\s*$""",
re.VERBOSE,
)
[docs]
def parse_quantity(
text: str,
*,
default_unit: str | None = None,
to_unit: str | None = None,
registry: UnitRegistry = DEFAULT_REGISTRY,
) -> Quantity:
"""
Parse a numeric string with optional unit.
Examples:
- "101.3 kPa"
- "5bar"
- "300[K]"
- "-10 C"
- "70 kJ/kg"
- "1.2e5 Pa"
Behavior:
- If no unit is provided:
* if default_unit is given: uses it
* else: returns unit="" (no unit)
- If to_unit is provided: converts to that unit (requires a known from-unit).
"""
s = str(text).strip()
m = _Q_RE.match(s)
if not m:
raise UnitError(f"Could not parse quantity: {text!r}")
val = float(m.group("val"))
unit_raw = (m.group("u1") or m.group("u2") or "").strip()
if unit_raw:
unit_raw = unit_raw.split("#", 1)[0].split(";", 1)[0].split("!", 1)[0].strip()
unit = _norm_unit(unit_raw) if unit_raw else ""
if not unit:
unit = _norm_unit(default_unit) if default_unit else ""
q = Quantity(val, unit, registry)
if to_unit:
if not q.unit:
raise UnitError(
f"Cannot convert {text!r} to {to_unit!r} because no unit was provided "
"and no default_unit was specified."
)
return q.to(_norm_unit(to_unit))
return q
q = Quantity(val, unit, registry)
return q.to(_norm_unit(to_unit)) if to_unit else q
__all__ = [
"UnitError",
"UnitDef",
"UnitRegistry",
"Quantity",
"DEFAULT_REGISTRY",
"default_registry",
"parse_quantity",
"convert",
"convert_value",
]