Skill Tracer
Skills › Scientific Agent Skills › uncertainty-and-units › convert_units.py

convert_units.py

305 lines · skills/uncertainty-and-units/scripts/convert_units.py at 5f2bc03

1#!/usr/bin/env python3
2"""Convert a quantity between units, including the conversions that need a context.
3
4Wavelength to photon energy, mass to amount of substance, and energy to
5temperature are not dimensional conversions - they are physical relations that
6pint only performs inside a named context. This CLI makes the context explicit,
7carries an uncertainty through the conversion's local derivative, and refuses
8to hide the two unit families whose arithmetic does not mean what it looks
9like: offset temperatures and logarithmic ratios.
10"""
12from __future__ import annotations
14import argparse
15import math
16import sys
17from typing import Any
19import _common
20from _common import CliError
23MAX_UNIT_CHARS = 120
24OFFSET_HINT = (
25 "an offset unit measures a point on a scale, not an amount; differences and "
26 "uncertainties belong in the matching delta_ unit"
28LOGARITHMIC_HINT = (
29 "adding two quantities in a logarithmic unit multiplies the underlying linear "
30 "quantities, and pint returns the product in squared base units"
34def checked_unit(text: str, *, label: str) -> str:
35 """Reject unit strings that are empty, oversized, or obviously not units."""
37 if not isinstance(text, str) or not text.strip():
38 raise CliError(f"{label} must be a non-empty unit string")
39 cleaned = text.strip()
40 if len(cleaned) > MAX_UNIT_CHARS:
41 raise CliError(f"{label} is longer than {MAX_UNIT_CHARS} characters")
42 forbidden = set(";#\n\r\\'\"")
43 if forbidden & set(cleaned):
44 raise CliError(f"{label} contains characters that are not part of a unit")
45 if "__" in cleaned or "lambda" in cleaned:
46 raise CliError(f"{label} is not a valid unit expression")
47 return cleaned
50def parse_context_parameter(text: str, registry: Any) -> tuple[str, Any]:
51 """Parse `name=value[ unit]` into a context keyword argument."""
53 name, _, payload = text.partition("=")
54 name = name.strip()
55 if not name.isidentifier() or not payload.strip():
56 raise CliError(f"context parameter {text!r} must look like mw=180.16 g/mol")
57 fields = payload.strip().split(None, 1)
58 magnitude = _common.finite_float(fields[0])
59 if len(fields) == 1:
60 return name, magnitude
61 return name, registry.Quantity(magnitude, checked_unit(fields[1], label=name))
64def build_registry() -> Any:
65 """Create a default pint registry."""
67 try:
68 import pint
69 except ImportError as exc:
70 raise CliError(
71 f"pint is unavailable; install with `{_common.PINNED_INSTALL}`"
72 ) from exc
73 return pint.UnitRegistry()
76def is_multiplicative(registry: Any, unit: str) -> bool:
77 """Report whether a unit can take part in ordinary arithmetic."""
79 import pint
81 try:
82 registry.Quantity(1.0, unit) * 2.0
83 except pint.errors.OffsetUnitCalculusError:
84 return False
85 except pint.errors.UndefinedUnitError as exc:
86 raise CliError(f"unknown unit {unit!r}") from exc
87 return True
90def convert(
91 registry: Any,
92 value: float,
93 unit: str,
94 target: str,
95 contexts: list[str],
96 parameters: dict[str, Any],
97) -> Any:
98 """Convert one magnitude, applying any requested contexts."""
100 import pint
102 try:
103 quantity = registry.Quantity(value, unit)
104 except pint.errors.UndefinedUnitError as exc:
105 raise CliError(f"unknown source unit {unit!r}") from exc
106 try:
107 return quantity.to(target, *contexts, **parameters)
108 except pint.errors.UndefinedUnitError as exc:
109 raise CliError(f"unknown target unit {target!r}") from exc
110 except pint.errors.DimensionalityError as exc:
111 raise CliError(
112 f"{exc}. If the two units are related by a physical law rather than by "
113 "dimensional analysis, name the context: --context spectroscopy for "
114 "wavelength, frequency, wavenumber, and photon energy; "
115 "--context chemistry --context-parameter 'mw=<molar mass>' for mass and "
116 "amount of substance; --context boltzmann for energy and temperature"
117 ) from exc
118 except pint.errors.PintError as exc:
119 raise CliError(f"pint could not perform the conversion: {exc}") from exc
120 except (KeyError, TypeError, ValueError, ArithmeticError) as exc:
121 raise CliError(f"invalid conversion or context parameters: {exc}") from exc
124def propagate(
125 registry: Any,
126 value: float,
127 uncertainty: float,
128 unit: str,
129 target: str,
130 contexts: list[str],
131 parameters: dict[str, Any],
132) -> float:
133 """Carry an uncertainty through the conversion's local derivative.
135 A central difference is exact for the affine conversions (including offset
136 temperatures) and accurate to second order for the reciprocal relations a
137 context introduces.
138 """
140 if uncertainty == 0:
141 return 0.0
142 # Keep the perturbation relative to the supplied magnitude: a fixed floor
143 # of 1 made 532e-9 metres differ from 532 nanometres for reciprocal contexts.
144 step = (math.ulp(1.0) ** (1.0 / 3.0)) * (abs(value) if value else 1.0)
145 if step == 0 or value + step == value or value - step == value:
146 raise CliError("magnitude is too small to resolve a conversion derivative")
147 high = convert(registry, value + step, unit, target, contexts, parameters)
148 low = convert(registry, value - step, unit, target, contexts, parameters)
149 # audit-units: ignore UNIT003 -- both quantities were just converted to `target`
150 derivative = (high.magnitude - low.magnitude) / (2.0 * step)
151 if not math.isfinite(derivative):
152 raise CliError("the conversion is not differentiable at this value")
153 propagated = abs(derivative) * uncertainty
154 if not math.isfinite(propagated):
155 raise CliError("propagated uncertainty is not finite")
156 return propagated
159def run(arguments: argparse.Namespace) -> dict[str, Any]:
160 """Perform the requested conversion and describe its hazards."""
162 registry = build_registry()
163 if arguments.list_contexts:
164 defined = getattr(registry, "_contexts", {})
165 return {
166 "available_contexts": sorted(str(name) for name in defined),
167 "note": (
168 "spectroscopy (sp) relates wavelength, frequency, wavenumber, and "
169 "photon energy; chemistry (chem) relates mass and amount of "
170 "substance and needs mw; boltzmann relates energy and temperature"
171 ),
172 }
174 if arguments.value is None or arguments.unit is None or arguments.to is None:
175 raise CliError("--value, --unit, and --to are all required")
177 unit = checked_unit(arguments.unit, label="--unit")
178 target = checked_unit(arguments.to, label="--to")
179 contexts = [checked_unit(item, label="--context") for item in arguments.context]
180 parameters = dict(
181 parse_context_parameter(item, registry)
182 for item in arguments.context_parameter
183 )
185 result = convert(registry, arguments.value, unit, target, contexts, parameters)
186 document: dict[str, Any] = {
187 "input": {"value": arguments.value, "unit": unit},
188 "target_unit": target,
189 "contexts": contexts,
190 # audit-units: ignore UNIT003 -- `result` is already in `target`
191 "value": float(result.magnitude),
192 "unit": str(result.units),
193 "warnings": [],
194 }
195 if not math.isfinite(document["value"]):
196 raise CliError("converted value is not finite; check the conversion domain")
198 source_multiplicative = is_multiplicative(registry, unit)
199 target_multiplicative = is_multiplicative(registry, target)
200 if source_multiplicative and target_multiplicative and not contexts:
201 one = convert(registry, 1.0, unit, target, contexts, parameters)
202 # audit-units: ignore UNIT003 -- `one` is already in `target`
203 document["conversion_factor"] = float(one.magnitude)
205 offset_temperature = (
206 (not source_multiplicative or not target_multiplicative)
207 and registry.Quantity(1, unit).check("[temperature]")
208 and registry.Quantity(1, target).check("[temperature]")
209 )
210 if offset_temperature:
211 document["warnings"].append(f"{OFFSET_HINT} (in {unit} or {target})")
212 for candidate in (unit, target):
213 # audit-units: ignore UNIT004 -- this line is the detector, not a usage
214 if "dB" in candidate or "decibel" in candidate:
215 document["warnings"].append(f"{LOGARITHMIC_HINT} (in {candidate})")
217 if arguments.uncertainty is not None:
218 converted = propagate(
219 registry,
220 arguments.value,
221 arguments.uncertainty,
222 unit,
223 target,
224 contexts,
225 parameters,
226 )
227 document["input"]["uncertainty"] = arguments.uncertainty
228 document["uncertainty"] = converted
229 note = "propagated through the local derivative of the conversion"
230 document["uncertainty_unit"] = target
231 if offset_temperature:
232 difference_unit = f"delta_{target}" if not target_multiplicative else target
233 document["uncertainty_unit"] = difference_unit
234 note += (
235 "; for an offset temperature that derivative is the scale factor "
236 f"alone, so the result is {converted:.6g} {difference_unit}, not a "
237 "point on the scale"
238 )
239 if "dB" in target or "decibel" in target: # audit-units: ignore UNIT004 -- detector
240 document["uncertainty_unit"] = "dB" # audit-units: ignore UNIT004 -- label
241 note += "; uncertainty on a logarithmic level is a level difference in dB" # audit-units: ignore UNIT004 -- label
242 document["uncertainty_note"] = note
243 if contexts or not source_multiplicative or not target_multiplicative:
244 document["warnings"].append(
245 "a context conversion can be nonlinear, so the propagated "
246 "uncertainty is a first-order approximation valid only while the "
247 "conversion is locally linear across the input uncertainty; "
248 "context parameters such as mw and n are treated as exact"
249 )
250 return document
253def build_parser() -> argparse.ArgumentParser:
254 parser = argparse.ArgumentParser(
255 description=(
256 "Convert a quantity between units with pint, including context-only "
257 "conversions, and carry an uncertainty through the conversion."
258 )
259 )
260 parser.add_argument("--value", type=_common.finite_float, help="magnitude")
261 parser.add_argument("--unit", help="unit of the supplied magnitude")
262 parser.add_argument("--to", help="target unit")
263 parser.add_argument(
264 "--uncertainty",
265 type=_common.non_negative_float,
266 help="standard uncertainty in the source unit",
267 )
268 parser.add_argument(
269 "--context",
270 action="append",
271 default=[],
272 help="pint context enabling the conversion, e.g. spectroscopy; repeatable",
273 )
274 parser.add_argument(
275 "--context-parameter",
276 action="append",
277 default=[],
278 metavar="NAME=VALUE[ UNIT]",
279 help="context keyword such as 'mw=180.16 g/mol'; repeatable",
280 )
281 parser.add_argument(
282 "--list-contexts",
283 action="store_true",
284 help="list the contexts the registry defines and exit",
285 )
286 parser.add_argument("--output", help="write JSON output to this file")
287 parser.add_argument(
288 "--force", action="store_true", help="overwrite an existing output file"
289 )
290 return parser
293def main(argv: list[str] | None = None) -> int:
294 arguments = build_parser().parse_args(argv)
295 try:
296 document = run(arguments)
297 _common.emit_json(document, output=arguments.output, force=arguments.force)
298 except CliError as error:
299 print(f"error: {error}", file=sys.stderr)
300 return 2
301 return 0
304if __name__ == "__main__":
305 raise SystemExit(main())