Initial Commit
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
"""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]
|
||||
Reference in New Issue
Block a user