units.convert
Convert length, mass, area, volume, temperature and energy between units, exactly, as decimal text.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 31 tests, run in TypeScript, Python and Rust.
What it does
Converts a quantity between two units of the same dimension: length, mass, area, volume, temperature or energy. `convertUnits("1", "in", "mm", 6)` is `"25.4"`.
## Why decimal text and fractions
For example
convert_units(1, in, mm, 6)→ 25.4 one inch is 25.4 mm exactlyconvert_units(1, ft, m, 12)→ 0.3048 one foot is 0.3048 m exactlyconvert_units(100, lb, kg, 8)→ 45.359237 100 lb is 45.359237 kg exactly
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
def convert_units(value: str, from_unit: str, to_unit: str, decimals: int) -> str
| value | string | plain decimal text such as "12.5" or "-40"; at most 15 significant digits and 15 decimal places |
| from_unit | string | unit symbol from the table, case-sensitive: "in", "lb", "gal_us", "degF", "kWh" |
| to_unit | string | unit symbol of the same dimension |
| decimals | int | 0 to 12; the result is rounded half away from zero to this many places |
| returns | string | decimal text with trailing zeros trimmed: "25.4", "-40", "0" |
Your code names it in one line, in the file that uses it
from fune.units.convert import convert_units # units.convert@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import re
from math import gcd
from .units_convert_data import UNITS, UnitDefinition ← this capability’s own data, compiled from data/units.json into the same file by fune build
DECIMAL = re.compile(r"^-?[0-9]+(\.[0-9]+)?$")
MAX_DIGITS = 15
def _unit(symbol: str) -> UnitDefinition:
for u in UNITS:
if u.symbol == symbol:
return u
raise ValueError('unknown unit "%s"' % (symbol,))
def convert_units(value: str, from_unit: str, to_unit: str, decimals: int) -> str:
"""Convert a quantity between units of the same dimension, exactly.
The value travels as decimal text and every factor is an exact fraction
(1 in = 254/10000 m, 1 lb = 45359237/100000000 kg), so the only rounding is
the one at the end, to ``decimals`` places. Multiplying floats instead gets
0.75 in = 19.049999999999997 mm, which rounds to 19.0 rather than 19.1.
"""
if not isinstance(value, str) or not DECIMAL.fullmatch(value):
raise ValueError('value must be a plain decimal like "12.5", received "%s"' % (value,))
negative = value.startswith("-")
body = value[1:] if negative else value
whole, _, raw_fraction = body.partition(".")
fraction = raw_fraction.rstrip("0")
digits = (whole + fraction).lstrip("0")
if len(digits) > MAX_DIGITS or len(fraction) > MAX_DIGITS:
raise ValueError(
'value "%s" has too many digits: at most 15 significant digits and 15 decimal places' % (value,)
)
src = _unit(from_unit)
dst = _unit(to_unit)
if src.dimension != dst.dimension:
raise ValueError(
"cannot convert %s (%s) to %s (%s)" % (from_unit, src.dimension, to_unit, dst.dimension)
)
if isinstance(decimals, bool) or not isinstance(decimals, int) or decimals < 0 or decimals > 12:
raise ValueError("decimals must be a whole number from 0 to 12, received %s" % (decimals,))
# base = v * fa + oa, result = (base - ob) / fb
# = v * P/Q + R/S, with P/Q = fa/fb and R/S = (oa - ob)/fb, each reduced.
p = src.factor_numerator * dst.factor_denominator
q = src.factor_denominator * dst.factor_numerator
g = gcd(p, q)
p //= g
q //= g
r = (src.offset_numerator * dst.offset_denominator - dst.offset_numerator * src.offset_denominator) * dst.factor_denominator
s = src.offset_denominator * dst.offset_denominator * dst.factor_numerator
g = gcd(r, s)
if g != 0:
r //= g
s //= g
scale = 10 ** len(fraction)
magnitude = int(digits) if digits else 0
numerator = (-magnitude if negative else magnitude) * p * s + r * scale * q
denominator = scale * q * s
# Long division one digit at a time keeps every intermediate below 10 x the
# denominator, which is what lets the Rust port do the same in i128.
result_negative = numerator < 0
absolute = -numerator if result_negative else numerator
int_part, remainder = divmod(absolute, denominator)
frac_part = 0
for _ in range(decimals):
remainder *= 10
frac_part = frac_part * 10 + remainder // denominator
remainder %= denominator
if remainder * 2 >= denominator:
frac_part += 1
if frac_part == 10 ** decimals:
frac_part = 0
int_part += 1
text = str(int_part)
if decimals > 0:
shown = str(frac_part).rjust(decimals, "0").rstrip("0")
if shown:
text += "." + shown
if result_negative and (int_part != 0 or frac_part != 0):
text = "-" + text
return textInstall
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add units.convert
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./units.convert-1.0.0-python.fune, or fetch it from a terminal with fune pull units.convert@1.0.0:python.
The whole function, every language, is one file too: units.convert-1.0.0.fune, 40,187 bytes, sha256 b696daf01ee5529e23321dbd3ab00989ebbf892d891a029f312f8065b201a871. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
# fune: before units.convert
after — your function gets the result and the arguments, and returns the final result.
# fune: after units.convert
replace — it requires no other capability, so there is no dependency to replace.
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show units.convert --steps.
# fune: step units.convert after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| one inch is 25.4 mm exactly | 1, in, mm, 6 | → | 25.4 |
| one foot is 0.3048 m exactly | 1, ft, m, 12 | → | 0.3048 |
| 100 lb is 45.359237 kg exactly | 100, lb, kg, 8 | → | 45.359237 |
| a half at the last place rounds away from zero, where float multiplication gets 19.049999999999997 | 0.75, in, mm, 1 | → | 19.1 |
| an exact half rounds away from zero, not to even | 1.25, cm, mm, 0 | → | 13 |
| a negative exact half rounds away from zero | -1.25, cm, mm, 0 | → | -13 |
| a small negative result that rounds to zero is 0, not -0 | -0.4, mm, m, 0 | → | 0 |
| minus 40 is the same in Celsius and Fahrenheit | -40, degC, degF, 6 | → | -40 |
| absolute zero in Celsius | 0, K, degC, 2 | → | -273.15 |
| absolute zero in Fahrenheit | 0, K, degF, 2 | → | -459.67 |
Show the other 21 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| boiling water in Fahrenheit | 100, degC, degF, 0 | → | 212 |
| 98.6 F is exactly 37 C | 98.6, degF, degC, 3 | → | 37 |
| Rankine to Kelvin is a plain 5/9 | 491.67, degR, K, 2 | → | 273.15 |
| US gallon is 3.785411784 L exactly | 1, gal_us, L, 9 | → | 3.785411784 |
| US gallon to 6 places rounds up | 1, gal_us, L, 6 | → | 3.785412 |
| imperial gallon in US gallons | 1, gal_imp, gal_us, 5 | → | 1.20095 |
| international acre in square metres | 1, acre, m2, 7 | → | 4046.8564224 |
| a square mile is 640 acres | 1, mi2, acre, 0 | → | 640 |
| a stone is 14 pounds | 1, st, lb, 3 | → | 14 |
| kilowatt hour in IT British thermal units | 1, kWh, Btu, 3 | → | 3412.142 |
| a thermochemical kilocalorie is 4184 J | 1, kcal, J, 0 | → | 4184 |
| the largest factor in the table at full input precision stays exact | 999999999999999, MWh, Btu, 2 | → | 3412141633127938507904.39 |
| trailing zeros in the input do not count as digits | 12.500000000000000000, m, cm, 0 | → | 1250 |
| thousands separators are refused | 1,200, m, km, 3 | → | error: value must be a plain decimal like "12.5", received "1,200" |
| exponents are refused | 1e3, m, km, 3 | → | error: value must be a plain decimal |
| more than 15 significant digits is refused | 1234567890123456, m, km, 3 | → | error: has too many digits |
| an ambiguous gallon is an unknown unit | 1, gal, L, 3 | → | error: unknown unit "gal" |
| symbols are case-sensitive | 1, MM, m, 3 | → | error: unknown unit "MM" |
| mass cannot become length | 1, kg, m, 3 | → | error: cannot convert kg (mass) to m (length) |
| decimals above 12 are refused | 1, m, cm, 13 | → | error: decimals must be a whole number from 0 to 12, received 13 |
| negative decimals are refused | 1, m, cm, -1 | → | error: decimals must be a whole number from 0 to 12, received -1 |
More from the author
Every unit in `data/units.json` is defined by an exact fraction of the base unit (metre, kilogram, square metre, cubic metre, kelvin, joule), and the value arrives as decimal text, so the whole conversion is exact rational arithmetic. The one rounding step is at the end, half away from zero, to `decimals` places. The answer is therefore the same in TypeScript, Python and Rust, and it is the answer you would get by hand. Multiplying floats is not: 0.75 in is 19.05 mm exactly, but `0.75 * 25.4` is 19.049999999999997, which rounds to 19.0 instead of 19.1.
The result has trailing zeros trimmed (`"25.4"`, not `"25.400000"`), and a negative value that rounds to zero is `"0"`, never `"-0"`.
## Temperatures are affine
A temperature unit is `K = value x factor + offset`: degC has offset 273.15, degF has factor 5/9 and offset 459.67 x 5/9, degR has factor 5/9. This converts absolute temperatures (a reading on a thermometer). A temperature difference (a rise of 10 degC is a rise of 18 degF) has no offset, so do not convert differences with this function.
## Units
Symbols are case-sensitive and exact. Ambiguous names are deliberately not symbols: there is `gal_us` and `gal_imp`, `ton_us` (short, 2000 lb) and `ton_imp` (long, 2240 lb), but no `gal`, `pt` or `ton`. Use `units.parse-quantity` to accept friendly spellings such as "lbs" or "feet".
- length: mm cm m km in ft yd mi nmi - mass: mg g kg t gr oz lb st ton_us ton_imp - area: mm2 cm2 m2 ha km2 in2 ft2 yd2 acre mi2 - volume: mL cm3 L m3 in3 ft3 floz_us cup_us pt_us qt_us gal_us floz_imp pt_imp qt_imp gal_imp - temperature: K degC degF degR - energy: J kJ MJ Wh kWh MWh cal kcal Btu
## Limits
`value` is `-?digits(.digits)?`: no thousands separators, no exponent, no leading `+`. It may have at most 15 significant digits and 15 decimal places (trailing zeros do not count). `decimals` is 0 to 12.
Those limits are what keep the Rust port exact in `i128`. The largest reduced factor between any two units in the table is 1.8e17 (MWh to Btu), so the numerator of the exact result is below 1e15 x 1.8e17 = 1.8e32 and the denominator is below 1e15 x 1.8e17 too. The rounding step is long division one digit at a time, which never holds more than ten times the denominator (1.8e33), well inside `i128` (1.7e38). Adding a unit with a bigger factor would need that bound checked again; the Rust arithmetic is checked and panics rather than wrapping.
## Sources
- NIST Special Publication 811 (2008 edition), *Guide for the Use of the International System of Units*, Appendix B.8 and B.9, conversion factors: https://www.nist.gov/pml/special-publication-811 . Exact (boldface) there: inch 0.0254 m, foot 0.3048 m, yard 0.9144 m, mile 1609.344 m, nautical mile 1852 m, grain 64.79891 mg, imperial gallon 4.54609 L, square foot 0.09290304 m2, thermochemical calorie 4.184 J, kilowatt hour 3.6 MJ, and the temperature relations T/K = t/degC + 273.15, t/degC = (t/degF - 32)/1.8, T/K = (T/degR)/1.8. - International Yard and Pound Agreement, 1959 (published in the US Federal Register as "Refinement of values for the yard and the pound"): 1 yd = 0.9144 m and 1 lb = 0.45359237 kg exactly. The pound also follows from SP 811's exact grain: 7000 gr x 64.79891 mg = 453.59237 g. - NIST, "U.S. Survey Foot: Revised Unit Conversion Factors" (https://www.nist.gov/pml/us-surveyfoot/revised-unit-conversion-factors): the survey foot is retired from 2023-01-01, and the acre is 43560 international square feet = 4046.8564224 m2. SP 811 (2008) still lists the survey-foot acre, 4046.873 m2; this table uses the international one. - The US gallon is 231 cubic inches (3.785411784 L); pints, quarts, cups and fluid ounces are 1/8, 1/4, 1/16 and 1/128 of it. Imperial pints, quarts and fluid ounces are 1/8, 1/4 and 1/160 of the imperial gallon. - The International Table Btu is derived from the IT calorie (4.1868 J, exact in SP 811): 1 Btu = 4186.8 J/(kg K) x 0.45359237 kg x 5/9 K = 1055.05585262 J. - 1 L = 1 dm3 exactly (12th CGPM, 1964).
Files
| Path | Bytes |
|---|---|
| README.md | 4,248 |
| data/units.json | 14,219 |
| impl/python.py | 3,452 |
| impl/rust.rs | 5,516 |
| impl/typescript.ts | 3,758 |
| vectors.json | 3,644 |