Files
owui-tools/app/owuitools/units.py
T
2026-09-15 15:24:03 -04:00

119 lines
4.5 KiB
Python

"""Engineering value parsing and formatting.
Nobody writes 0.0000047 for a capacitor, they write 4u7 or 4.7uF, and a
model asked for a float will happily send the string "10k". Rejecting that
wastes a whole tool-call round trip to re-ask for the same number in a
worse format, so parse what engineers actually write.
"""
from __future__ import annotations
import math
import re
SI_PREFIXES = {
"q": 1e-30, "r": 1e-27, "y": 1e-24, "z": 1e-21, "a": 1e-18, "f": 1e-15,
"p": 1e-12, "n": 1e-9, "u": 1e-6, "µ": 1e-6, "μ": 1e-6, "m": 1e-3,
"": 1.0,
"k": 1e3, "K": 1e3, "M": 1e6, "G": 1e9, "T": 1e12, "P": 1e15, "E": 1e18,
}
# Unit suffixes stripped before prefix parsing. Order matters: longest first,
# so "farad" is consumed before "f" is mistaken for femto.
_UNIT_WORDS = (
"ohms", "ohm", "Ω", "farad", "farads", "henry", "henries", "hertz",
"volts", "volt", "amps", "amp", "amperes", "ampere", "watts", "watt",
"seconds", "second", "meters", "metres", "meter", "metre",
"F", "H", "Hz", "V", "A", "W", "s",
)
# Note the absence of a bare "m". In an electronics context milli beats
# metres by a wide margin - "2.2m" is 2.2 milli, not 2.2 metres - and
# treating it as a unit made every millivolt and milliamp parse 1000x high.
# The spelled-out forms above still work for length.
_VALUE_RE = re.compile(r"^([+-]?\d*\.?\d+(?:[eE][+-]?\d+)?)\s*([a-zA-Zµμ]*)$")
# "4k7" / "4u7" / "1R5" - the prefix stands in for the decimal point.
_INFIX_RE = re.compile(r"^([+-]?\d+)\s*([RrkKMmuµμnpGT])\s*(\d+)$")
class UnitError(ValueError):
"""Raised with a message intended for the model."""
def parse(value, name: str, *, unit: str = "") -> float:
"""Parse an engineering quantity into a plain float in base units."""
if value is None:
raise UnitError(f"{name} is required for this calculation but was not provided.")
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip().replace(",", "")
if not text:
raise UnitError(f"{name} is required for this calculation but was empty.")
if infix := _INFIX_RE.match(text):
whole, prefix, frac = infix.groups()
multiplier = 1.0 if prefix in ("R", "r") else SI_PREFIXES.get(prefix, 1.0)
return float(f"{whole}.{frac}") * multiplier
for word in _UNIT_WORDS:
if len(text) > len(word) and text.endswith(word):
candidate = text[: -len(word)].strip()
if _VALUE_RE.match(candidate):
text = candidate
break
match = _VALUE_RE.match(text)
if not match:
raise UnitError(
f"Could not read '{value}' as a value for {name}. Write it as a plain number "
f"(4700), with an SI prefix (4.7k), or in the shorthand engineers use (4k7)."
)
number, suffix = match.groups()
if suffix and suffix not in SI_PREFIXES:
# A trailing unit letter that survived stripping, e.g. "10kΩ" -> "k".
suffix = suffix[0]
if suffix and suffix not in SI_PREFIXES:
raise UnitError(f"'{suffix}' in '{value}' is not an SI prefix I recognise for {name}.")
return float(number) * SI_PREFIXES.get(suffix, 1.0)
def eng(value: float, unit: str = "", *, digits: int = 4) -> str:
"""Format a number in engineering notation, the way a datasheet would."""
if value == 0:
return f"0 {unit}".strip()
if math.isnan(value) or math.isinf(value):
return f"{value} {unit}".strip()
magnitude = math.floor(math.log10(abs(value)) / 3) * 3
magnitude = max(-30, min(magnitude, 18))
scaled = value / (10.0 ** magnitude)
symbol = {
-30: "q", -27: "r", -24: "y", -21: "z", -18: "a", -15: "f", -12: "p",
-9: "n", -6: "µ", -3: "m", 0: "", 3: "k", 6: "M", 9: "G", 12: "T",
15: "P", 18: "E",
}[magnitude]
formatted = f"{scaled:.{digits}g}"
return f"{formatted} {symbol}{unit}".strip()
def require(values: dict, *names: str) -> list[float]:
"""Pull required parameters, naming everything missing at once.
Reporting all of them together matters: the error text goes straight back
to the model, and one round trip beats three.
"""
missing = [n for n in names if values.get(n) is None]
if missing:
raise UnitError(
f"This calculation needs {', '.join(missing)}, which "
f"{'was' if len(missing) == 1 else 'were'} not provided. "
f"Ask the user for {'it' if len(missing) == 1 else 'them'} if you do not know."
)
return [parse(values[n], n) for n in names]