Skip to content

SciNim/Unchained

Repository files navigation

Unchained - Compile time only units checking

https://github.com/SciNim/unchained/workflows/unchained%20CI/badge.svg

Unchained is a fully type safe, compile time only units library. There is absolutely no performance loss over pure float based code (aside from insertion of possible conversion factors, but those would have to be written by hand otherwise of course).

It supports:

  • all base SI units and (most) compound SI units
  • units as short and long name:
    import unchained
    let x = 10.m
    let y = 10.Meter
    doAssert x == y
        
  • some imperial units
  • all SI prefixes
    import unchained
    let x = 10.Mm # mega meter
    let y = 5.ng # nano gram
    let z = 10.aT # atto tesla 
        
  • arbitrary math with units composing to new units, e.g. (which do not have to be defined previously!),
    import unchained
    let x = 10.m * 10.m * 10.m * 10.m * 10.m
    doAssert typeof(x) is Meter

    without having to predefine a Meter⁵ type

  • automatic conversion between SI prefixes if a mix is used
    import unchained
    let x = 5.kg + 5.lbs
    doAssert typeof(x) is kg
    doAssert x == 7.26796.kg
        
  • manual conversion of units to compatible other units via to (e.g.
    import unchained
    let x = 5.m•s⁻¹
    defUnit(km•h⁻¹) # needs to be defined to be able to convert to
                    # `to` could be a macro that defines it for us 
    doAssert x.to(km•h⁻¹) == 18.km•h⁻¹
    # the `toDef` macro can be used to both define and convert a unit,
    # but under certain use cases it can break (see its documentation)
        
  • comparisons between units compare real value taking into account SI prefixes and even different units of the same quantity:
import unchained
let x = 10.Mm # mega meter
doAssert x == 10_000_000.m
let y = 5.ng # nano gram
doAssert y == 5e-9.g
let z = 10.aT # atto tesla
doAssert z == 10e-18.T
# and even different units of same quantity
let a = 5000.inch•s⁻¹
let b = a.toDef(km•h⁻¹) # defines the unit and convers `a` to it
doAssert b == 457.2.km•h⁻¹
doAssert typeof(a) is inch•s⁻¹ # SI units have higher precedence than non SI
doAssert typeof(b) is km•h⁻¹
doAssert a == b # comparison is true, as the effective value is the same!

Note: comparison between units is performed using an almostEqual implementation. By default it uses ε = 1e-8. The power can be changed at CT by using the -d:UnitCompareEpsilon=<integer> where the given integer is the negative power used.

  • all quantities (e.g. Length, Mass, …) defined as a concept to allow matching different units of same quantity in function argument
    import unchained
    proc force[M: Mass, A: Acceleration](m: M, a: A): Force = m * a
    let m = 80.kg
    let g = 9.81.m•s⁻²
    let f = force(m, g)
    doAssert typeof(f) is Newton
    doAssert f == 784.8.N
        
  • define your own custom unit systems, see examples/custom_unit_system.nim

A longer snippet showing different features below. See also examples/bethe_bloch.nim for a more complicated use case.

import unchained
block:
  # defining simple units
  let mass = 5.kg
  let a = 9.81.m•s⁻²
block:
  # addition and subtraction of same units
  let a = 5.kg
  let b = 10.kg
  doAssert typeof(a + b) is KiloGram
  doAssert a + b == 15.kg
  doAssert typeof(a - b) is KiloGram
  doAssert a - b == -5.kg
block:
  # addition and subtraction of units of the same ``quantity`` but different scale
  let a = 5.kg
  let b = 500.g
  doAssert typeof(a + b) is KiloGram
  doAssert a + b == 5.5.kg
  # if units do not match, the SI unit is used!
block:
  # product of prefixed SI unit keeps same prefix unless multiple units of same quantity involved
  let a = 1.m•s⁻²
  let b = 500.g
  doAssert typeof(a * b) is GramMeterSecond⁻²
  doAssert typeof((a * b).to(MilliNewton)) is MilliNewton
  doAssert a * b == 500.g•m•s⁻²
block:
  let mass = 5.kg
  let a = 9.81.m•s⁻²
  # unit multiplication has to be commutative
  let F: Newton = mass * a
  let F2: Newton = a * mass
  # unit division works as expected
  doAssert typeof(F / mass) is N•kg⁻¹
  doAssert typeof((F / mass).to(MeterSecond⁻²)) is MeterSecond⁻²
  doAssert F / mass == a
block:
  # automatic deduction of compound units for simple cases
  let force = 1.kg * 1.m * 1.s⁻²
  echo force # 1 Newton
  doAssert typeof(force) is Newton
block:
  # conversion between units of the same quantity
  let f = 10.N
  doAssert typeof(f.to(kN)) is KiloNewton
  doAssert f.to(kN) == 0.01.kN
block:
  # pre-defined physical constants
  let E_e⁻_rest: Joule = m_e * c*c # math operations `*cannot*` use superscripts!
  # m_e = electron mass in kg
  # c = speed of light in vacuum in m/s
from std/math import sin  
block:
  # automatic CT error if argument of e.g. sin, ln are not unit less
  let x = 5.kg
  let y = 10.kg
  discard sin(x / y) ## compiles gives correct result (~0.48)
  let x2 = 10.m
  # sin(x2 / y) ## errors at CT due to non unit less argument
block:
  # imperial units
  let mass = 100.lbs
  let distance = 100.inch
block:
  # mixing of non SI and SI units (via conversion to SI units)
  let m1 = 100.lbs
  let m2 = 10.kg
  doAssert typeof(m1 + m2) is KiloGram
  doAssert m1 + m2 == 55.359237.KiloGram
block:
  # natural unit conversions
  let speed = (0.1 * c).toNaturalUnit() # fraction of c, defined in `constants`
  let m_e = 9.1093837015e-31.kg.toNaturalUnit()
  # math between natural units remains natural
  let p = speed * m_e # result will be in `eV`
  doAssert p.to(keV) == 51.099874.keV

## If there is demand the following kind of syntax may be implemented in the future
when false:
  # units using english language (using accented quotes)
  let a = 10.`meter per second squared`
  let b = 5.`kilogram meter per second squared`
  check typeof(a) is MeterSecond⁻²
  check typeof(b) is Newton
  check a == 10.m•s⁻²
  check b == 5.N

Things to note:

  • real units use capital letters and are verbose
  • shorthands defined for all typical units using their common abbreviation (upper or lower case depending on the unit, e.g. s (second) and N (Newton)
  • conversion of numbers to units done using `.` call and using shorthand names
  • `•` symbol is product of units to allow unambiguous parsing of units -> specific unicode symbol may become user customizable in the future
  • no division of units, but negative exponents
  • exponents are in superscript
  • usage of `•` and superscript is to circumvent Nim’s identifier rules!
  • SI units are the base. If ambiguous operation that can be solved by unit conversion, SI units are used (in the default SI unit system predefined when simply importing unchained)
  • math operations cannot use superscripts!
  • some physical constants are defined, more likely in the future
  • conversion from prefixed SI unit to non prefixed SI unit only happens if multiple prefixed units of same quantity involved
  • UnitLess is a distinct float unit that has a converter to float (such that UnitLess magically works with math functions expecting floats).

Why “Unchained”?

Un = Unit Chain = A unit

You shall be unchained from the shackles of dealing with painful errors due to unit mismatches by using this lib! Tada!

Hint: The unit Chain does not exist in this library…

Units and cligen

cligen is arguably the most powerful and at the same time convenient to use command line argument parser in Nim land (and likely across languages…; plus a lot of other things!).

For that reason it is a common desire to combine Unchained units as an command line argument to a program that uses cligen to parse the arguments. Thanks to cligen's extensive options to expand its features, we now provide a simple submodule you can import in order to support Unchained units in your program. Here’s a short example useful for the runners among you, a simple script to convert a given speed (in mph, km/h or m/s) to a time per minute / per mile / 5K / 10K / … distance or vice versa:

import unchained, math, strutils
defUnit(mi•h⁻¹)
defUnit(km•h⁻¹)
defUnit(m•s⁻¹)
proc timeStr[T: Time](t: T): string =
  let (h, mr) = splitDecimal(t.to(Hour).float)
  let (m, s)  = splitDecimal(mr.Hour.to(Minute).float)
  result =
    align(pretty(h.Hour, 0, true, ffDecimal), 6, ' ') &
    " " & align(pretty(m.Minute, 0, true, ffDecimal), 8, ' ') &
    " " & align(pretty(s.Minute.to(Second), 0, true, ffDecimal), 6, ' ')
template print(d, x) = echo "$#: $#" % [alignLeft(d, 9), align(x, 10)]
proc echoTimes[V: Velocity](v: V) =
  print("1K",       timeStr 1.0 / (v / 1.km))
  print("1 mile",   timeStr 1.0 / (v / 1.Mile))
  print("5K",       timeStr 1.0 / (v / 5.km))
  print("10K",      timeStr 1.0 / (v / 10.km))
  print("Half",     timeStr 1.0 / (v / (42.195.km / 2.0)))
  print("Marathon", timeStr 1.0 / (v / 42.195.km))
  print("50K",      timeStr 1.0 / (v / 50.km))
  print("100K",     timeStr 1.0 / (v / 100.km))   # maybe a bit aspirational at the same pace, huh?
  print("100 mile", timeStr 1.0 / (v / 100.Mile)) # let's hope it's not Leadville
proc mph(v: mi•h⁻¹) = echoTimes(v)
proc kmh(v: km•h⁻¹) = echoTimes(v)
proc mps(v:  m•s⁻¹) = echoTimes(v)
proc speed(d: km, hour = 0.0.h, min = 0.0.min, sec = 0.0.s) =
  let t = hour + min + sec
  print("km/h", pretty((d / t).to(km•h⁻¹), 2, true))
  print("mph",  pretty((d / t).to(mi•h⁻¹), 2, true))
  print("m/s",  pretty((d / t).to( m•s⁻¹), 2, true))
when isMainModule:
  import unchained / cligenParseUnits # just import this and then you can use `unchained` units as parameters!
  import cligen
  dispatchMulti([mph], [kmh], [mps], [speed])
nim c examples/speed_tool
examples/speed_tool mph -v 7.0 # without unit, assumed is m•h⁻¹
echo "----------------------------------------"
examples/speed_tool kmh -v 12.5.km•h⁻¹ # with explicit unit
echo "----------------------------------------"
examples/speed_tool speed -d 11.24.km --min 58 --sec 4

which outputs:

1K       :  0 h  5 min 20 s
1 mile   :  0 h  8 min 34 s
5K       :  0 h 26 min 38 s
10K      :  0 h 53 min 16 s
Half     :  1 h 52 min 22 s
Marathon :  3 h 44 min 44 s
50K      :  4 h 26 min 18 s
100K     :  8 h 52 min 36 s
100 mile : 14 h 17 min  9 s
----------------------------------------
1K       :  0 h  4 min 48 s
1 mile   :  0 h  7 min 43 s
5K       :  0 h 24 min  0 s
10K      :  0 h 48 min  0 s
Half     :  1 h 41 min 16 s
Marathon :  3 h 22 min 32 s
50K      :  4 h  0 min  0 s
100K     :  8 h  0 min  0 s
100 mile : 12 h 52 min 29 s
----------------------------------------
km/h     : 12 km•h⁻¹
mph      : 7.2 mi•h⁻¹
m/s      : 3.2 m•s⁻¹