Safely dealing with scientific units of variables at compile time (a gentle introduction to Compile-Time Computing — part 3)

struct double-ish {
double number;
enum unit;
}
  • write enough code so that when you enter literals into source code you can write down the unit *today*, although you don’t have time to do something smart about the unit today. But every time you write something in the source code you want to put the unit *today*. So that later you can do something smart about the unit *mechanism* without having to edit *all* of your previously entered numbers.
(defconstant +c+ (unit 299792458 m/s))
(defconstant +h+ (unit 6.62607015e-34 Js))
(defconstant +e+ (unit 2.7182882845 :none))
(defconstant +up-quark-mass+ (unit 2.3 MeV/c^2 +0.7 -0.5))
[...]
(defmacro unit (value unit &rest more)
(declare (ignore unit more))
value)
  • requires that a unit is given
  • prepares for additional metadata to come in, which we will do something about in a future version of the macro. Right now we just accept more arguments and throw them away
(defmacro unit (value unit &rest more)
(declare (ignore more))
(case unit
(m/s)
(Js)
(:none)
(t (error "unknown unit ~a" unit)))
value)
(defconstant +c+ (unit 299792458 m/s))
(defconstant +h+ (unit 6.62607015e-34 Js))
(defconstant +e+ (unit 2.7182882845 :none))
(defconstant +up-quark-mass+ (unit 2.3 MeV/c^2 +0.7 -0.5))
unknown unit MEV/C^2
[Condition of type SIMPLE-ERROR]
(defmacro unit (value unit &rest more)
(declare (ignore more))
(case unit
(m/s value)
(Js value)
(:none value)
(MeV/c^2 (* value 1.78266191e-30))
(t (error "unknown unit ~a" unit))))
Yes, Master? CL-USER> +up-quark-mass+
4.100122e-30
(defun testfun ()
(+ +up-quark-mass+ +c+))
;; compile in SBCL, then disassemble:
Yes, Master? CL-USER> (disassemble 'testfun)

; disassembly for TESTFUN
; Size: 13 bytes. Origin: #x52E3B9B6
; B6: 488B15B3FFFFFF MOV RDX, [RIP-77] ; no-arg-parsing entry point
; 2.9979245e8
; BD: 488BE5 MOV RSP, RBP
; C0: F8 CLC
; C1: 5D POP RBP
; C2: C3 RET
NIL
Yes, Master? CL-USER>
  • instead of putting every combined unit (such as m/s) into the macro you can, at compile time, walk the unit declaration as a string and resolve it into the fundamental, non-combined units (such as parsing it out into meters, the / operator and seconds). That’s like actual work writing an evaluator. It would add safety since it reduces the numbers of conversion constants you have to enter into the macro. I want to do something else first though.
  • SI units kinda suck. Not as hard as imperial units but still. If I’m messing around with Quarks anyway I can use Planck units internally. They have a number of advantages, including that you don’t need to enter all those floating point values into variables with fixed precision. Planck units are robust against “changes” in e.g. c, the speed of light. In Planck units c is a fundamental building block with a value of 1.0. Nifty, eh?
(defmacro unittmp (value unit)
;; helper to work around recursive
;; macro call. See unit2 below
;; for proper fix
(ecase unit
(kg (/ value 2.176470e-8))))
(defmacro unit (value unit &rest more)
(declare (ignore more))
(case unit
(m/s (/ value 2.99792458e+8))
(Js (/ value 1.054571800e-34))
(m (/ value 1.616229e-35))
(s (/ value 5.39116e-44))
(kg (/ value 2.176470e-8))
(:none value)
;; this gives an error:
(MeV/c^2 (unittmp (* value 1.78266191e-30) kg))
The value                                                                                                                                                                                                    
(* VALUE 1.7826618e-30)
is not of type
NUMBER
when binding SB-KERNEL::X
  • we asked the compiler to do the actual arithmetic at compile time
  • but what we are passing to the arithmetic operator is one number, and a piece of code. Since we asked to do the math right now it can’t do it.
(MeV/c^2 (* value (unittmp 1.78266191e-30 kg)))
(MeV/c^2 (unittmp (* value 1.78266191e-30) kg))
;; to be defined
(defmacro unit-test ...)
(defun testfun2 ()
(unit-test 1 kg))
(defmacro unit-test (value unit &rest more)
(declare (ignore more))
(format t "~%value is '~a'~%" value)
(format t "unit is '~a'~%" unit)
value)
(defun testfun2 ()
(unit-test 1 kg))
value is '1'                                                                    
unit is 'KG'
(defmacro unit-test (value unit &rest more)
(declare (ignore more))
(format t "~%value is '~a'~%" value)
(format t "unit is '~a'~%" unit)
value)
(defun testfun3 ()
(unit-test (* 1 1) kg))
; compiling (DEFUN TESTFUN3 ...)                                                
value is '(* 1 1)'
unit is 'KG'
(defmacro unit-test2 (value unit &rest more)
(declare (ignore more))
(format t "~%value is '~a'~%" value)
(format t "unit is '~a'~%" unit)
(when (listp value)
(dolist (element value)
(format t "list element is '~a'~%" element)))
value)
(defun testfun3 ()
(unit-test2 (* 1 1) kg))
value is '(* 1 1)'                                                              
unit is 'KG'
list element is '*'
list element is '1'
list element is '1'
(defmacro unit-test3 (value unit &rest more)
(declare (ignore more))
(format t "~%value is '~a'~%" value)
(format t "unit is '~a'~%" unit)
(when (listp value)
(dolist (element value)
(if (and (numberp element) (= element 42))
(format t "Looks like the answer to everything~%")
(format t "list element is '~a'~%" element))))
value)
(defun testfun3 ()
(unit-test3 (* 42 1) kg))
; compiling (DEFUN TESTFUN3 ...)
value is '(* 42 1)'
unit is 'KG'
list element is '*'
Looks like the answer to everything
list element is '1'
(defmacro unit-test4 (value unit &rest more)
(declare (ignore more))
(when (listp value)
(dolist (element value)
(when (and (numberp element) (= element 42))
(return-from unit-test4 `(progn
(dotimes (i 4)
(format t "hello, world~%"))
,value)))))
value)
(defun testfun4a ()
(unit-test4 (* 41 1) kg))
(defun testfun4b ()
(unit-test4 (* 42 1) kg))
Yes, Master? CL-USER> (testfun4a)
41
Yes, Master? CL-USER> (testfun4b)
hello, world
hello, world
hello, world
hello, world
42
Yes, Master? CL-USER>
Yes, Master? CL-USER> (macroexpand '(unit-test4 (* 41 1) kg))
(* 41 1)
T
Yes, Master? CL-USER> (macroexpand '(unit-test4 (* 42 1) kg))
(PROGN (DOTIMES (I 4) (FORMAT T "hello, world~%")) (* 42 1))
T
Yes, Master? CL-USER>
(defmacro plus-with-units (val1 val2)
;; fancy code here
(+ val1 val2))
;; this should work
(defun testfun5a ()
(plus-with-units (unit 5 m/s) (unit 6 m/s)))
;; this should *not* work
(defun testfun5b ()
(plus-with-units (unit 5 m/s) (unit 6 m)))
;; this can be made to work later
(defun testfun5c ()
(plus-with-units (unit 5 m/s) (unit 6 km/h)))
  • if the units are available, they should be checked. In the first version for being equal, in the more fancy version for being compatible. Either way we want to catch errors.
  • we don’t want to spend an entire night implementing this.
  • the check should happen at compile time. The compiled code should have nothing except one compiled out number in Planck units.
(defmacro plus-with-units (val1 val2)
(let (firstunit)
(dolist (thing (list val1 val2))
(when (listp thing)
(if (not firstunit)
(setf firstunit (third thing))
(unless (equal firstunit (third thing))
;; print a clear error message. Not something people
;; need to copy into a web page to translate to human
(error "Incompatible units: ~a ~a~%"
firstunit (third thing)))))))
;; delay evaluation
`(+ ,val1 ,val2))
;; works:
(defun testfun5a ()
(plus-with-units (unit 5 m/s) (unit 6 m/s)))
;; error:
(defun testfun5b ()
(plus-with-units (unit 5 m/s) (unit 6 m)))
crachem.lisp:209:3:                                                                                                                                                                                               
error:
during macroexpansion of (PLUS-WITH-UNITS (UNIT 5 M/S) (UNIT 6 M)). Use
*BREAK-ON-SIGNALS* to intercept.

Incompatible units: M/S M
Compilation failed.
;; this unit knower returns three values:
;; - the converted value
;; - the unit
;; - what kind of unit is it?
(eval-when (:compile-toplevel)
(defun unit2-helper (value unit)
(let* (whatkind
(newvalue
(case unit
(m/s (setf whatkind 'speed) (/ value 2.99792458e+8))
(Js (setf whatkind 'energy-time) (/ value 1.054571800e-34))
(m (setf whatkind 'length) (/ value 1.616229e-35))
(s (setf whatkind 'time) (/ value 5.39116e-44))
(kg (setf whatkind 'mass) (/ value 2.176470e-8))
(:none (setf whatkind 'none) value)
(MeV/c^2 (setf whatkind 'mass)
(* value (unit2-helper 1.78266191e-30 'kg)))
(t (error "unknown unit ~a" unit)))))
(values newvalue unit whatkind))))
;; this is the dumb frontend you call from regular code
(defmacro unit2 (value unit &rest more)
(declare (ignore more))
(unit2-helper value unit))
(defmacro plus-with-units2 (val1 val2)
(let (firstkind)
(dolist (thing (list val1 val2))
(when (and (listp thing) (equal (first thing) 'UNIT2))
(multiple-value-bind (newvalue unit whatkind)
(unit2-helper (second thing) (third thing))
(print whatkind)
(if (not firstkind)
(setf firstkind whatkind)
(unless (equal firstkind whatkind)
;; print a clear error message. Not something people
;; need to copy into a web page to translate to human
(error "Incompatible units: ~a ~a~%"
firstkind whatkind)))))))
;; delay evaluation until later in compilation
`(+ ,val1 ,val2))
;; this now works, the code recognizes that kg and MeV/c^2
;; are both units of the same kind - mass
(defun testfun6 ()
(plus-with-units2 (unit2 5 kg) (unit2 6 MeV/c^2)))
Yes, Master? CL-USER> (disassemble 'testfun6)
; disassembly for TESTFUN6
; Size: 13 bytes. Origin: #x52E3B9B6
; B6: 488B15B3FFFFFF MOV RDX, [RIP-77]; no-arg-parsing entry point
; 2.297298e8
; BD: 488BE5 MOV RSP, RBP
; C0: F8 CLC
; C1: 5D POP RBP
; C2: C3 RET
NIL
Yes, Master? CL-USER>
(with-unit-file-field ("foo.txt" ((mass :column 1 :unit kg))
(+ *blah* mass)) ; variable mass is converted to Planck unit
;; if you are willing to walk the body of code:
(with-unit-file-field ("foo.txt" ((mass :column 1 :unit kg))
(+ *blah* (* (unit 8 kg) mass)))
;; that would throw an error if mass wasn't a mass unit

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Martin Cracauer

Martin Cracauer

Writing about programmer productivity, teambuilding and enabling a wide variety of different engineering personalities. CTO at www.thirdlaw.tech #lisp #freebsd