exupero's blog
RSSApps

Manipulating nomographic matrices symbolically

In the previous post we created a simple rate, time, and distance nomogram by manipulating matrices. In this post, we'll write a small set of Clojure functions to serve as a DSL for manipulating nomographic matrices.

We'll use this representation of our initial matrix:

(def rate-time-distance
  '[[(log r) 0 1]
    [(log t) 1 0]
    [(log d) 1 1]])

Notice that we're working symbolically. Rather than choosing ranges for our variables at the outset and making calculations on arrays of matrices, we'll manipulate the matrix in symbolic terms and only generate concrete values as a last step.

To add one column to another, we'll combine forms in a list whose first form is +:

(defn add-column-to-column [matrix column1 column2]
  (mapv (fn [row]
          (update row column2 #(list '+ % (row column1))))
        matrix))

which gives us

(-> rate-time-distance
  (add-column-to-column 1 2))
[[(log r) 0 (+ 1 0)] [(log t) 1 (+ 0 1)] [(log d) 1 (+ 1 1)]]

Since some forms have no unknowns, we can simplify the matrix by evaluating whatever we can:

(defn simplify [matrix]
  (clojure.walk/postwalk
    (fn [n]
      (if (symbol? n)
        n
        (try
          (eval n)
          (catch Exception e
            n))))
    matrix))
(-> rate-time-distance
  (add-column-to-column 1 2)
  simplify)
[[(log r) 0 1] [(log t) 1 1] [(log d) 1 2]]

To divide the final row by 2, we'll wrap values in a list again, this time with /:

(defn divide-row-by [matrix rowN n]
  (update matrix rowN
          (fn [row]
            (mapv (fn [column]
                    (list '/ column n))
                  row))))
(-> rate-time-distance
  (add-column-to-column 1 2)
  (divide-row-by 2 2)
  simplify)
[[(log r) 0 1] [(log t) 1 1] [(/ (log d) 2) 1/2 1]]

Swapping columns is straight-forward:

(defn swap-columns [matrix column1 column2]
  (mapv (fn [row]
          (assoc row
                 column1 (row column2)
                 column2 (row column1)))
        matrix))

Writing (and reading) the projection transformation is easier using syntax quote than list:

(defn project-from [matrix [px py pz]]
  (mapv (fn [[x y z]]
          `[(/ (* ~x ~pz)
               (- ~x ~px))
            (/ (- (* ~x ~py) (* ~px ~y))
               (- ~x ~px))
            ~z])
        matrix))

Inconveniently for our case, syntax quote fully qualifies symbols, which clutters the output:

(-> rate-time-distance
  (add-column-to-column 1 2)
  (divide-row-by 2 2)
  (swap-columns 0 1)
  (project-from [4 0 -3])
  simplify)
[[0
  (clojure.core// (clojure.core/- 0 (clojure.core/* 4 (log r))) -4)
  1]
 [1
  (clojure.core// (clojure.core/- 0 (clojure.core/* 4 (log t))) -3)
  1]
 [3/7
  (clojure.core//
   (clojure.core/- 0N (clojure.core/* 4 (/ (log d) 2)))
   -7/2)
  1]]

Let's clean this up by having simplify remove namespaces from symbols:

(defn simplify [matrix]
  (clojure.walk/postwalk
    (fn [n]
      (if (symbol? n)
        (symbol (name n))
        (try
          (eval n)
          (catch Exception e
            n))))
    matrix))
(-> rate-time-distance
  (add-column-to-column 1 2)
  (divide-row-by 2 2)
  (swap-columns 0 1)
  (project-from [4 0 -3])
  simplify)
[[0 (/ (- 0 (* 4 (log r))) -4) 1]
 [1 (/ (- 0 (* 4 (log t))) -3) 1]
 [3/7 (/ (- 0N (* 4 (/ (log d) 2))) -7/2) 1]]

Before we can evaluate our unknowns, we need to specify our log function:

(-> rate-time-distance
  (add-column-to-column 1 2)
  (divide-row-by 2 2)
  (swap-columns 0 1)
  (project-from [4 0 -3])
  simplify
  (->> (clojure.walk/postwalk-replace '{log Math/log10})))
[[0 (/ (- 0 (* 4 (Math/log10 r))) -4) 1]
 [1 (/ (- 0 (* 4 (Math/log10 t))) -3) 1]
 [3/7 (/ (- 0N (* 4 (/ (Math/log10 d) 2))) -7/2) 1]]

Now we can write a function which generates a sequence of x-y coordinates from the first two columns of each row:

(defn scales [matrix & args]
  (mapv (fn [[x y] [sym values]]
          (eval `(for [~sym ~(vec values)]
                   [~sym [~x ~y]])))
        matrix
        (partition 2 args)))

Add it into our pipeline like this:

(-> rate-time-distance
  (add-column-to-column 1 2)
  (divide-row-by 2 2)
  (swap-columns 0 1)
  (project-from [4 0 -3])
  simplify
  (->> (clojure.walk/postwalk-replace '{log Math/log10}))
  (scales
    'r (range 1 5)
    't (range 1 5)
    'd (range 1 5)))
[([1 [0 -0.0]]
  [2 [0 0.3010299956639812]]
  [3 [0 0.47712125471966244]]
  [4 [0 0.6020599913279624]])
 ([1 [1 -0.0]]
  [2 [1 0.4013733275519749]]
  [3 [1 0.63616167295955]]
  [4 [1 0.8027466551039498]])
 ([1 [3/7 -0.0]]
  [2 [3/7 0.17201714037941782]]
  [3 [3/7 0.27264071698266423]]
  [4 [3/7 0.34403428075883563]])]

Using the same domains as the scales in the previous post, we can draw the nomogram to check our manipulations:

123456789102030405060708090100123456789102030123456789102030405060708090100200300400500600700800900100020003000

Looks right, if a bit crowded on the middle scale.