"""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]