2024-12-03

A Scalable Approach to Unit Conversion

How we built browser-side unit conversion at CoolPlanet, from SI dimensions to scales, offsets, and the Project Haystack unit database.

UnitsTypeScript
Try itSkip ahead to the live converter and have a play.

At CoolPlanet, we needed unit conversion in the browser. Similar story to porting psychrometric calculations to JavaScript: the back end already knew how to convert between units, and the front end needed to mirror those conversions exactly.

Doing the work client-side opened up a lot of flexibility. The unit our APIs responded in didn't really matter any more; we could transform on the fly. User preferences could be applied per user without round-tripping to the back end. A senior executive in North America might want imperial; an engineer in Germany might want metric. Same data, different presentation.

The breadth of quantities ruled out hardcoded conversions. Power, energy, mass flow, volumetric flow, temperature, pressure, each with metric and imperial variants. We needed a system that understood units from first principles.

Dimensions

There are seven SI base units:

sTimesecond
mLengthmetre
kgMasskilogram
AElectric Currentampere
KTemperaturekelvin
molAmount of Substancemole
cdLuminous Intensitycandela

A unit's dimension is its ratio of the seven base units. Joule is kg·m²·s⁻²; Pascal is kg·m⁻¹·s⁻². Have a poke at the explorer below. Try switching between Celsius and Fahrenheit (same dimensions), or pick Watt to see what shares its dimensional fingerprint.

Dimension explorer

Every unit reduces to a ratio of the seven base units. Pick one to see the breakdown.

Or pick any unit
Wwatt
kg·m2·s-3
kg1
m2
s-3
A·
K·
mol·
cd·

Other units with these same dimensions:

Some units don't fit this model (percent, for example) and are represented as dimensionless: every base unit exponent is zero.

One thing worth flagging before we go further: matching dimensions is necessary for a conversion to make sense, but it isn't sufficient. Several quantities share dimensional fingerprints while describing entirely different things.

Same dimensions, different quantities

Dimensional analysis can't catch these collisions on its own.

kg·m2·s-3
Real Power
  • Wwatt
  • kWkilowatt
  • MWmegawatt
  • BTU/hBTU per hour
  • hphorsepower
Apparent Power
  • VAvolt-ampere
  • kVAkilovolt-ampere
  • mVAmegavolt-ampere
Reactive Power
  • VARvolt-ampere reactive
  • kVARkilovolt-ampere reactive
  • MVARmegavolt-ampere reactive

Same dimensional fingerprint, but converting kW to kVA isn't physically meaningful. Each describes a different aspect of an AC system.

Dimensional analysis on its own can't catch these collisions. The fix is to group units by quantity (power, apparent power, reactive power, energy, apparent energy, reactive energy…) and only allow conversions within a quantity.

Unit Conversion

If units have the same dimensions, you can convert between them using scales and offsets. Most units I've come across only need to be scaled; some, such as temperature, also need an offset.

Start with a simple expression we know to be true:

1m=100cm1m = 100cm

We need to express this as pure arithmetic, since the system can only see numbers, not unit strings. Strip the units away and the equation falls apart:

11001 \neq 100

A scale closes the gap. Calling metres A and centimetres B, with subscripts for the numeric value (n) and scale (s), we can rebalance the equation:

AnAs=BnBsA_n \cdot A_s = B_n \cdot B_s
11=1000.011 \cdot 1 = 100 \cdot 0.01

Where do the scales come from? For each set of dimensions, one unit is chosen as the base and assigned a scale of 1. Every other unit with those dimensions is defined relative to it. Above, metre is the base, and the centimetre's scale of 0.01 says "one centimetre is 0.01 metres".

A scale on its own covers most conversions, but not temperature. Zero degrees Celsius is 32 degrees Fahrenheit:

0C=32F0^\circ C = 32^\circ F
0320 \neq 32

This time a scale can't close the gap on its own; zero multiplied by anything is still zero. We need an offset too. Adding a subscript o for offset to each side gives the full balance equation:

AnAs+Ao=BnBs+BoA_n \cdot A_s + A_o = B_n \cdot B_s + B_o

The base unit for temperature is Kelvin, so scales and offsets are defined relative to it. Celsius has scale 1 and offset 273.15, since 0°C = 273.15K. Fahrenheit has scale 59\tfrac{5}{9} and offset 255.37, since a 1°F step is 59\tfrac{5}{9} of a Kelvin step and 0°F = 255.37K:

01+273.15=3259+255.370 \cdot 1 + 273.15 = 32 \cdot \tfrac{5}{9} + 255.37

A little algebra rearranges the balance equation into a formula for converting any numeric value in B into A:

An=BnBs+BoAoAsA_n = \frac{B_n \cdot B_s + B_o - A_o}{A_s}

Toggle between length and temperature in the walkthrough below to see the scale-only and scale-plus-offset cases resolve.

Worked example

Plug in a value and watch the formula resolve.

UnitScale (s)Offset (o)
KKelvin10
°CCelsius1273.15
°FFahrenheit0.55556255.37222
An=201+273.15255.372220.55556A_n = \frac{\textcolor{#22d3ee}{20} \cdot \textcolor{#22d3ee}{1} + \textcolor{#22d3ee}{273.15} - \textcolor{#fbbf24}{255.37222}}{\textcolor{#fbbf24}{0.55556}}
20 °C = 67.999 °F

Unit Database

We need every unit defined: its dimensions, scale, and offset. Thankfully, the good people of Project Haystack maintain exactly this. Their unit system documentation is where I learnt the conversion methodology above.

The database is a simple txt file. Each row defines a single unit using this format, with semicolons separating the structural fields and commas separating the identifiers within the first field:

Anatomy of a unit row

Pick a row and see how each part maps to the format.

<name>, <alias>, <symbol>; <dimension>; <scale>; <offset>
fahrenheit, °F; K1; 0.5555555555555556; 255.37222222222223
Name
fahrenheitDescriptive form, words separated by underscores.
Aliases
(none)Alternative comma-separated symbols.
Symbol
°FThe default abbreviation. Always present.
Dimension
K1SI base units with their powers.
Scale
0.5555555555555556Multiplier relative to the base unit.
Offset
255.37222222222223Constant added relative to the base unit.

The full case: every field present, including an offset.

At the time of writing, this was the full units.txt file.

Converting Across Time

Most of the data we deal with is time-series, so there's one extra wrinkle worth covering: converting between a rate and its integral.

Take power and energy. Power is in kg·m²·s⁻³; energy is in kg·m²·s⁻². They differ by one power of seconds, and a duration provides exactly that factor. The same relationship holds for several other pairs:

Rate
Power
kW
Integral
Energy
kWh
Rate
Mass flow
kg/s
Integral
Mass
kg
Rate
Volumetric flow
m³/s
Integral
Volume

When a rate is constant, the integral is just rate × time: 5 kW held for 2 hours is 10 kWh. When the rate changes, you sum the contributions of each segment, which is all an "area under the curve" calculation reduces to. The live converter directly below visualises this for any rate quantity.

Try It Yourself

The widget below puts the formula and a subset of the Haystack unit database to work. Pick a quantity, change the value, and watch every other unit in the same quantity update. For rate quantities (power, mass flow, volumetric flow) the across-time section appears, integrating segment-by-segment.

Live converter

Pick a quantity and a value. Every other unit in the same quantity is computed in the browser.

Wwatt5,000
MWmegawatt0.005000
BTU/hBTU per hour17,072.13
hphorsepower6.7051

Across time: kWEnergy

Define one or more constant-rate segments. Area under the curve is the integral.

for
for
for

Total energy across 6 h (area under the curve):

Jjoule1.296e+8
kJkilojoule129,600
MJmegajoule129.6
kWhkilowatt-hour36
BTUBritish thermal unit122,860.84

Conclusion

With the conversion formula above and Project Haystack's unit database, it's fairly straightforward to build your own unit converter. That's the foundation we landed on at CoolPlanet: a TypeScript implementation fed by the Haystack units file, mirroring the back-end conversions exactly. From that point on, the unit our APIs returned was decoupled from the unit a user saw. Convert client-side, instantly, against whatever preference is set. Same data, in whatever units make sense to whoever's looking at it.