The math of unit conversion (and the temperature trap)
On this page
Unit conversion looks trivial. You have inches, you want centimeters, you multiply by 2.54. The math is one line.
Until you hit Fahrenheit. Then it’s not one line — and the difference between “scale” units and “affine” units has caused enough production incidents that NASA crashed a spacecraft into Mars over it.
This is a short tour of the math, with the trap explained.
Scale units — multiplication only
Most units are linear scale factors: you multiply by a constant to convert.
1 inch = 2.54 cm
1 km = 1000 m
1 lb = 0.453592 kg
1 mile/h = 0.44704 m/s
The pattern: a single conversion factor relative to a base unit. A length library only needs to store factors-to-meters; everything else is multiplication.
const TO_METERS = {
km: 1000,
m: 1,
cm: 0.01,
in: 0.0254,
ft: 0.3048,
mile: 1609.344,
};
function convert(value, fromUnit, toUnit) {
const meters = value * TO_METERS[fromUnit];
return meters / TO_METERS[toUnit];
}
Three lines of math, every length unit covered. Same shape works for weight (base = kg), area (base = m²), volume (base = liter), speed (base = m/s).
The temperature exception
Temperature breaks the pattern because the zero points don’t agree.
- 0°C is the freezing point of water.
- 0°F is a brine-and-ice mixture (Daniel Fahrenheit’s reference).
- 0 K is absolute zero (no thermal energy at all).
These zeros are different physical states. So you can’t convert between scales by multiplication alone — you have to shift the zero, then scale.
°F = °C × 9/5 + 32
°C = (°F - 32) × 5/9
K = °C + 273.15
°R = (°C + 273.15) × 9/5
The pattern is output = input × scale + offset. This is called an
affine transform (vs the linear transform of the other
units).
The implication for code: you can’t store temperature as
“factor-to-Celsius.” You need to store both a factor and an offset,
or implement two functions per unit (toBase, fromBase).
const TEMP = {
C: { toBase: (v) => v, fromBase: (v) => v },
F: { toBase: (v) => (v - 32) * 5 / 9, fromBase: (v) => v * 9 / 5 + 32 },
K: { toBase: (v) => v - 273.15, fromBase: (v) => v + 273.15 },
};
function tempConvert(value, from, to) {
const baseC = TEMP[from].toBase(value);
return TEMP[to].fromBase(baseC);
}
A bit more code, but you can’t shortcut it.
The Mars Climate Orbiter
In 1999, NASA’s Mars Climate Orbiter burned up in the Martian atmosphere because two pieces of software — Lockheed Martin’s ground-side trajectory code and Goddard Space Flight Center’s flight-side code — disagreed on units.
The ground software output force in pound-seconds (imperial). The flight software expected newton-seconds (SI). Multiplier mismatch: about 4.45.
Lost spacecraft: $327 million. Lost mission: 16 months of work. Cost of the missing line of code that should have applied the unit conversion: zero.
The investigation report named the proximate cause as “failure to use metric units” but the deeper cause is more useful: a system can exchange numbers without exchanging units, and when that happens, the receiving software has to assume what the units are. Whenever the assumption is wrong, you get a bug. Sometimes the bug burns up in an atmosphere.
The fix is to attach units to numbers — either via the type system
(in a typed language) or via wrapper objects (in JS/Python). When
both sides of an interface speak in Quantity { value: 12.5, units: "m/s" } instead of bare 12.5, the conversion happens at
the boundary and mismatches surface as type errors.
The pandemic bicycle problem
A smaller-scale example. In 2020, multiple US fitness apps started displaying riders’ speeds wildly above what was actually achieved. Investigations traced it to a unit-conversion bug: the apps were storing speed internally in m/s but displaying mph by multiplying by 2.237 (correct factor) — but for distance traveled, they were dividing by 2.237 (which is wrong, you’d divide by 1609 to get miles from meters, then divide by hours).
The category of bug: confusing “units of distance per units of time” with “compound conversion.” It’s easy when you have a single conversion factor sitting in a constants file to use it in the wrong direction or for the wrong derived unit.
The fix in such code is the same as for the Mars Orbiter: stop treating numbers as bare floats, attach units explicitly. Or at the very least, write tests where the assertions look like “speed.toUnit(MPH) === expectedMPH” rather than “speed * 2.237 === expectedMPH.”
What our converter does
The unit converter implements the math above:
- Length, weight, volume, area, speed: single-factor scale conversions through a base unit.
- Temperature: affine
toBase/fromBaseper unit, with the base being Celsius. - All math in plain JS doubles. Floating-point precision is fine for any real-world value; the limits only show up at extreme scales.
When affine math also matters elsewhere
Temperature is the most-encountered affine-units case, but not the only one:
- Date/time math. Adding hours to a timestamp is scale; finding the calendar-day-difference between two dates can require zero-point gymnastics around midnights, especially across DST.
- Pressure relative to atmospheric. “Gauge pressure” (gauges read 0 at sea-level atmosphere) and “absolute pressure” (zero at vacuum) differ by ~101 kPa. Confusing them is common in HVAC bugs.
- Currency with reference rates. Mid-market vs your bank’s retail rate differ by an offset (the bank’s spread) plus a scale (the bank’s percentage cut). Skip the offset and your conversion is too generous.
When in doubt, ask: “is the zero of unit A the same physical quantity as the zero of unit B?” If yes, scale. If no, you need both scale and offset.
Try it
The unit converter handles all of this —
including the Fahrenheit/Celsius/Kelvin/Rankine matrix —
correctly. The full math is in
sites/unit/src/lib/units.ts if you want to verify.