Units with unchained
In the talk Sailing Seas of Scientific
Nim,
Island 5 shows metaprogramming with
unchained,
a library by Vindaar (Sebastian Schmidt) for physical
units checked at compile time.
On two slides we see how its declareUnits macro
declares units and lets us add our own,
and how Kepler's third law reads with units.
This page goes through what happens behind those
slides: what a unit is in unchained, what its macros
do, how to add units, and the pitfalls we met.
All the code on this page runs when the page is built,
and the output below each block is its real output.
A unit is a distinct float
In unchained a value with a unit is just a float,
with a type that carries the unit.
A number followed by . and a unit makes one:
let mass = 80.kg
echo mass
echo typeof(mass)
echo sizeof(mass)
80āÆkg
KiloGram
8
KiloGram is a distinct type: it has the same
representation as a float, so there is no cost at run
time, but none of the procedures of float.
kg is an alias of KiloGram, and 80.kg is a call
of unchained's . macro, which reads the unit after
the dot when the program is compiled.
Units with powers and products are written with
superscripts and ā¢, which are valid in Nim
identifiers (with ASCII spellings too, see the
README):
let g = 9.81.mā¢sā»Ā²
let weight = mass * g
echo weight
echo typeof(weight)
echo quantityOf(weight)
784.8āÆN
Newton
Timeā»Ā²ā¢Lengthā¢Mass
The product of a mass and an acceleration is a force,
and unchained knows that a kgā¢mā¢sā»Ā² is a Newton.
quantityOf shows the unit in base quantities.
Operators are macros
In Nim *, /, + and - are procedures, so a
library can define them for its own types.
unchained defines them as macros: code that runs in
the compiler, reads the types of the two arguments,
and writes the right expression with the right result
type.
For * it adds the powers of the two units, simplifies,
and finds the type of the result, defining it on the
spot if nobody wrote it before (like māµ below);
conversion factors between prefixes and non-SI units
are computed at compile time too.
echo 1.m * 1.m * 1.m * 1.m * 1.m
echo 5.kg + 500.g
echo 1.km / 20.min
echo (1.km / 20.min).to(mā¢sā»Ā¹)
1āÆmāµ
5.5āÆkg
0.05āÆkmā¢minā»Ā¹
0.833333āÆmā¢sā»Ā¹
Adding two different quantities is not a wrong result,
it is a program that does not compile.
The + macro stops the compiler with the message
Different quantities cannot be added!, and functions
like sqrt only accept units that are perfect squares:
echo compiles(5.kg + 3.m)
echo compiles(sqrt(4.m²))
echo compiles(sqrt(4.m))
false
true
false
Quantities like Length or Mass are
concepts:
type classes that match any unit of that quantity.
A function can take any length and any mass,
but they have to be explicit generic parameters:
func density[M: Mass, L: Length](m: M, side: L): auto =
m / side^3
echo density(1.kg, 10.cm)
echo density(1.kg, 10.cm).to(kgā¢mā»Ā³)
0.001āÆkgā¢cmā»Ā³
1000āÆkgā¢mā»Ā³
Declaring quantities and units
unchained itself is built with two macros.
declareQuantities lists the base quantities and the
derived ones, with their powers of the base quantities;
this is the start of unchained's si_units.nim:
declareQuantities:
Base:
Time
Length
Mass
Current
Temperature
AmountOfSubstance
Luminosity
Derived:
Frequency: [(Time, -1)]
Velocity: [(Length, 1), (Time, -1)]
Acceleration: [(Length, 1), (Time, -2)]
Area: [(Length, 2)]
Volume: [(Length, 3)]
Momentum: [(Mass, 1), (Length, 1), (Time, -1)]
Force: [(Mass, 1), (Length, 1), (Time, -2)]
Energy: [(Mass, 1), (Length, 2), (Time, -2)]
...
unchained's si_units.nim (excerpt)
declareUnits then gives each quantity its units: base
units first, then derived units, which can need a
conversion to the base ones:
declareUnits:
BaseUnits:
Gram:
short: g
prefix: siKilo
quantity: Mass
Meter:
short: m
quantity: Length
...
AstronomicalUnit:
short: AU
quantity: Length
conversion: 149_597_870_700.0.m
unchained's si_units.nim (excerpt)
From these few lines the macros generate, at compile
time, the types and their aliases (Meter, m), the
SI prefixes (km, mm...), the concepts for the
quantities, and the tables that *, / and .to use
to compute units and conversion factors.
It is the same macro, declareUnits, that the talk
uses to add two units unchained does not have:
declareUnits:
Derived:
SolarMass:
short: Msun
quantity: Mass
conversion: 1.98847e30.kg
EarthMass:
short: Mearth
quantity: Mass
conversion: 5.9722e24.kg
slides/island5_units.nim
echo 1.Msun.to(kg)
echo 1.Msun / 1.Mearth
echo typeof(1.Msun / 1.Mearth)
1.98847e+30āÆkg
332954āÆUnitLess
UnitLess
The ratio of two masses has no unit: UnitLess,
which converts to a plain float when needed.
A custom unit system can even start from scratch, with
its own quantities, as in unchained's
example.
Kepler's third law
A planet (or a moon) on an orbit with semi-major axis
$a$ around a body of mass $M$, much heavier than it,
goes around in a time
$$T = 2 \pi \sqrt{\frac{a^3}{G M}}$$
where $G$ is Newton's gravitational constant, in
unchained G_Newton (not G, which is the Giga
prefix).
Under the square root the units are
$\mathrm{m}^3 / (\mathrm{m}^3,\mathrm{kg}^{-1},\mathrm{s}^{-2} \cdot \mathrm{kg}) = \mathrm{s}^2$,
so sqrt compiles and gives seconds:
func period[L: Length, M: Mass](a: L, mass: M): Second =
(2 * PI * sqrt(a^3 / (G_Newton * mass))).to(Second)
echo "Earth: ", period(1.AU, 1.Msun).to(day)
echo "Mars: ", period(1.524.AU, 1.Msun).to(yr)
echo "Jupiter: ", period(5.204.AU, 1.Msun).to(yr)
echo "Moon: ", period(384_400.km, 1.Mearth).to(day)
Earth: 365.251āÆday
Mars: 1.88139āÆyr
Jupiter: 11.8716āÆyr
Moon: 27.4518āÆday
Astronomical units and kilometers, solar and Earth
masses, and Newton's constant in SI units all meet in
one formula, and the conversions are worked out by the
compiler.
The Earth comes out at about 365.25 days.
The Moon comes out at about 27.45 days, a bit more than
its real 27.32 days, because the formula leaves out the
mass of the Moon itself, which is not that small compared
to the Earth: with $G (M + m)$ in place of $G M$ the
match is better.
Pitfalls
Nim identifiers are case insensitive after the first
letter, and ignore underscores.
With units that is a trap: the short name kn for a
knot would be the same identifier as kN, the
kilonewton, and on slide 6.3 our pt for points is the
same identifier as pT, the picotesla.
The slides import unchained without it
(import unchained except pT).
declareUnits also generates again the types of the
compound SI units (like Meterā¢Secondā»Ā¹).
If those and unchained's are both visible, every use is
ambiguous, so our units live in their own module,
slides/island5_units.nim, and the slides import only
the four new names from it
(from island5_units import SolarMass, Msun, EarthMass, Mearth).
The conversion: of a unit has to be a literal
(1.98847e30.kg), not an expression.
These are the honest costs of a library that pushes
the compiler this far, and of a small ecosystem: few
people hit the corners before you.
Try it
Add the mass of Jupiter, or the light year as a length
in your own unit system, and compute the period of
Io around Jupiter (421 700 km).
Clone the
repository
and run nim r slides/island5_units_explainer.nim:
it builds this page in docs/island5_units_explainer.html.
The
README
explains how to install the dependencies.
import std / [math, strutils, strformat, json]
import nimib
import nimib / highlight
import unchained
from island5_units import SolarMass, Msun, EarthMass, Mearth
nbInit(thisFileRel = "island5_units_explainer.nim")
const ownSource = staticRead("island5_units_explainer.nim")
nb.doc.source = ownSource
nb.doc.context["source"] = %nb.doc.source
nb.doc.context["source_highlighted"] = %highlightNim(nb.doc.source)
nb.useLatex
nbRawHtml("<style>\n" & """
pre { overflow-x: auto; }
.lines { font-size: 0.8em; color: #6b7280; margin-top: -0.5em; }
""" & "</style>")
const
siUnitsSource = staticRead("../deps/unchained/src/unchained/si_units.nim")
newUnitsSource = staticRead("island5_units.nim")
proc between(source, first, last: string): string =
let lines = source.splitLines
var start, stop = -1
for i, line in lines:
if start < 0 and line.startsWith(first): start = i
elif start >= 0 and line.startsWith(last):
stop = i - 1
break
doAssert start >= 0 and stop >= start,
"explainer: code not found from '" & first & "' to '" & last & "'"
lines[start .. stop].join("\n").strip(leading = false)
proc showCode(code, caption: string) =
nbRawHtml &"""<pre><code class="nim hljs">{highlightNim(code)}</code></pre>
<p class="lines">{caption}</p>"""
nbText """
# Units with unchained
In the talk [Sailing Seas of Scientific
Nim](https://pietroppeter.github.io/talk-sailing-seas-scientific-nim/),
Island 5 shows metaprogramming with
[unchained](https://github.com/SciNim/unchained),
a library by Vindaar (Sebastian Schmidt) for physical
units checked at compile time.
On two slides we see how its `declareUnits` macro
declares units and lets us add our own,
and how Kepler's third law reads with units.
This page goes through what happens behind those
slides: what a unit is in unchained, what its macros
do, how to add units, and the pitfalls we met.
All the code on this page runs when the page is built,
and the output below each block is its real output.
"""
nbText """
## A unit is a distinct float
In unchained a value with a unit is just a float,
with a type that carries the unit.
A number followed by `.` and a unit makes one:
"""
nbCode:
let mass = 80.kg
echo mass
echo typeof(mass)
echo sizeof(mass)
nbText """
`KiloGram` is a `distinct` type: it has the same
representation as a float, so there is no cost at run
time, but none of the procedures of float.
`kg` is an alias of `KiloGram`, and `80.kg` is a call
of unchained's `.` macro, which reads the unit after
the dot when the program is compiled.
Units with powers and products are written with
superscripts and `ā¢`, which are valid in Nim
identifiers (with ASCII spellings too, see the
[README](https://github.com/SciNim/unchained#readme)):
"""
nbCode:
let g = 9.81.mā¢sā»Ā²
let weight = mass * g
echo weight
echo typeof(weight)
echo quantityOf(weight)
nbText """
The product of a mass and an acceleration is a force,
and unchained knows that a `kgā¢mā¢sā»Ā²` is a Newton.
`quantityOf` shows the unit in base quantities.
"""
nbText """
## Operators are macros
In Nim `*`, `/`, `+` and `-` are procedures, so a
library can define them for its own types.
unchained defines them as **macros**: code that runs in
the compiler, reads the types of the two arguments,
and writes the right expression with the right result
type.
For `*` it adds the powers of the two units, simplifies,
and finds the type of the result, defining it on the
spot if nobody wrote it before (like `māµ` below);
conversion factors between prefixes and non-SI units
are computed at compile time too.
"""
nbCode:
echo 1.m * 1.m * 1.m * 1.m * 1.m
echo 5.kg + 500.g
echo 1.km / 20.min
echo (1.km / 20.min).to(mā¢sā»Ā¹)
nbText """
Adding two different quantities is not a wrong result,
it is a program that does not compile.
The `+` macro stops the compiler with the message
*Different quantities cannot be added!*, and functions
like `sqrt` only accept units that are perfect squares:
"""
nbCode:
echo compiles(5.kg + 3.m)
echo compiles(sqrt(4.m²))
echo compiles(sqrt(4.m))
nbText """
Quantities like `Length` or `Mass` are
[concepts](https://nim-lang.org/docs/manual_experimental.html#concepts):
type classes that match any unit of that quantity.
A function can take any length and any mass,
but they have to be explicit generic parameters:
"""
nbCode:
func density[M: Mass, L: Length](m: M, side: L): auto =
m / side^3
echo density(1.kg, 10.cm)
echo density(1.kg, 10.cm).to(kgā¢mā»Ā³)
nbText """
## Declaring quantities and units
unchained itself is built with two macros.
`declareQuantities` lists the base quantities and the
derived ones, with their powers of the base quantities;
this is the start of unchained's `si_units.nim`:
"""
showCode(siUnitsSource.between("declareQuantities:", " Torque:") &
"\n ...", "unchained's si_units.nim (excerpt)")
nbText """
`declareUnits` then gives each quantity its units: base
units first, then derived units, which can need a
conversion to the base ones:
"""
showCode(siUnitsSource.between("declareUnits:", " Ampere:") &
"\n ...\n" &
siUnitsSource.between(" AstronomicalUnit:", " LightYear:") &
" ...", "unchained's si_units.nim (excerpt)")
nbText """
From these few lines the macros generate, at compile
time, the types and their aliases (`Meter`, `m`), the
SI prefixes (`km`, `mm`...), the concepts for the
quantities, and the tables that `*`, `/` and `.to` use
to compute units and conversion factors.
It is the same macro, `declareUnits`, that the talk
uses to add two units unchained does not have:
"""
showCode(newUnitsSource.between("declareUnits:", "# <<< new units"),
"slides/island5_units.nim")
nbCode:
echo 1.Msun.to(kg)
echo 1.Msun / 1.Mearth
echo typeof(1.Msun / 1.Mearth)
nbText """
The ratio of two masses has no unit: `UnitLess`,
which converts to a plain float when needed.
A custom unit system can even start from scratch, with
its own quantities, as in unchained's
[example](https://github.com/SciNim/unchained/blob/master/examples/custom_unit_system.nim).
"""
nbText """
## Kepler's third law
A planet (or a moon) on an orbit with semi-major axis
$a$ around a body of mass $M$, much heavier than it,
goes around in a time
$$T = 2 \pi \sqrt{\frac{a^3}{G M}}$$
where $G$ is Newton's gravitational constant, in
unchained `G_Newton` (not `G`, which is the Giga
prefix).
Under the square root the units are
$\mathrm{m}^3 / (\mathrm{m}^3\,\mathrm{kg}^{-1}\,\mathrm{s}^{-2} \cdot \mathrm{kg}) = \mathrm{s}^2$,
so `sqrt` compiles and gives seconds:
"""
nbCode:
func period[L: Length, M: Mass](a: L, mass: M): Second =
(2 * PI * sqrt(a^3 / (G_Newton * mass))).to(Second)
echo "Earth: ", period(1.AU, 1.Msun).to(day)
echo "Mars: ", period(1.524.AU, 1.Msun).to(yr)
echo "Jupiter: ", period(5.204.AU, 1.Msun).to(yr)
echo "Moon: ", period(384_400.km, 1.Mearth).to(day)
nbText """
Astronomical units and kilometers, solar and Earth
masses, and Newton's constant in SI units all meet in
one formula, and the conversions are worked out by the
compiler.
The Earth comes out at about 365.25 days.
The Moon comes out at about 27.45 days, a bit more than
its real 27.32 days, because the formula leaves out the
mass of the Moon itself, which is not that small compared
to the Earth: with $G (M + m)$ in place of $G M$ the
match is better.
"""
nbText """
## Pitfalls
Nim identifiers are case insensitive after the first
letter, and ignore underscores.
With units that is a trap: the short name `kn` for a
knot would be the same identifier as `kN`, the
kilonewton, and on slide 6.3 our `pt` for points is the
same identifier as `pT`, the picotesla.
The slides import unchained without it
(`import unchained except pT`).
`declareUnits` also generates again the types of the
compound SI units (like `Meterā¢Secondā»Ā¹`).
If those and unchained's are both visible, every use is
ambiguous, so our units live in their own module,
`slides/island5_units.nim`, and the slides import only
the four new names from it
(`from island5_units import SolarMass, Msun, EarthMass, Mearth`).
The `conversion:` of a unit has to be a literal
(`1.98847e30.kg`), not an expression.
These are the honest costs of a library that pushes
the compiler this far, and of a small ecosystem: few
people hit the corners before you.
"""
nbText """
## Try it
Add the mass of Jupiter, or the light year as a length
in your own unit system, and compute the period of
Io around Jupiter (421 700 km).
Clone [the
repository](https://github.com/pietroppeter/talk-sailing-seas-scientific-nim)
and run `nim r slides/island5_units_explainer.nim`:
it builds this page in `docs/island5_units_explainer.html`.
The
[README](https://github.com/pietroppeter/talk-sailing-seas-scientific-nim#readme)
explains how to install the dependencies.
"""
nbSave