Created
November 19, 2021 05:11
-
-
Save sritchie/fa16d9714ee0661b36fac3fbc6e69d05 to your computer and use it in GitHub Desktop.
Double Pendulum in Clerk
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
;; here were the deps I needed for this... I don't know enough vega-lite to go without the Hanami example I had | |
;; already built, so excuse me there! | |
{:paths ["dev"] | |
:deps {io.github.nextjournal/clerk {:local/root "../clerk"} | |
notespace-sicmutils {:mvn/version "0.16.2"} | |
aerial.hanami/aerial.hanami {:mvn/version "0.12.7"} | |
sicmutils/sicmutils {:git/url "https://github.com/sicmutils/sicmutils" | |
:sha "8658c0c8883b8225a742b9422061f40b852f375d"}}} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
;; # Exercise 1.44: The double pendulum | |
;; This namespace explores [Exercise | |
;; 1.44](https://tgvaughan.github.io/sicm/chapter001.html#Exe_1-44) from Sussman | |
;; and Wisdom's [Structure and Interpretation of Classical | |
;; Mechanics](https://tgvaughan.github.io/sicm/), using | |
;; the [SICMUtils](https://github.com/sicmutils/sicmutils) Clojure library and | |
;; the Clerk rendering environment. | |
(ns double-pendulum | |
(:refer-clojure | |
:exclude [+ - * / partial ref zero? numerator denominator compare = run!]) | |
(:require [aerial.hanami.common :as hanami-common] | |
[aerial.hanami.templates :as hanami-templates] | |
[notespace-sicmutils.hanami-extras :as hanami-extras] | |
[nextjournal.clerk :as clerk] | |
[sicmutils.env :as e :refer :all])) | |
;; ## Lagrangian | |
;; | |
;; Start with a coordinate transformation from `theta1`, `theta2` to rectangular | |
;; coordinates. We'll generate our Lagrangian by composing this with an rectangular | |
;; Lagrangian with the familiar form of `T - V`. | |
(defn angles->rect [l1 l2] | |
(fn [[t [theta1 theta2]]] | |
(let [x1 (* l1 (sin theta1)) | |
y1 (- (* l1 (cos theta1))) | |
x2 (+ x1 (* l2 (sin (+ theta1 theta2)))) | |
y2 (- y1 (* l2 (cos (+ theta1 theta2))))] | |
(up x1 y1 x2 y2)))) | |
;; `T` describes the sum of the kinetic energy of two particles in rectangular | |
;; coordinates. | |
(defn T [m1 m2] | |
(fn [[_ _ [xdot1 ydot1 xdot2 ydot2]]] | |
(+ (* (/ 1 2) m1 (+ (square xdot1) | |
(square ydot1))) | |
(* (/ 1 2) m2 (+ (square xdot2) | |
(square ydot2)))))) | |
;; `V` describes a uniform gravitational potential with coefficient `g`, acting | |
;; on two particles with masses of, respectively, `m1` and `m2`. Again, this is | |
;; written in rectangular coordinates. | |
(defn V [m1 m2 g] | |
(fn [[_ [_ y1 _ y2]]] | |
(+ (* m1 g y1) | |
(* m2 g y2)))) | |
;; Form the rectangular Lagrangian `L` by subtracting `(V m1 m2 g)` from `(T m1 m2)`: | |
(defn L-rect [m1 m2 g] | |
(- (T m1 m2) | |
(V m1 m2 g))) | |
;; Form the final Langrangian in generalized coordinates (the angles of each | |
;; segment) by composing `L-rect` with a properly transformed `angles->rect` | |
;; coordinate transform! | |
(defn L-double-pendulum [m1 m2 l1 l2 g] | |
(compose (L-rect m1 m2 g) | |
(F->C | |
(angles->rect l1 l2)))) | |
;; The Lagrangian is big and hairy: | |
(def symbolic-L | |
((L-double-pendulum 'm_1 'm_2 'l_1 'l_2 'g) | |
(up 't | |
(up 'theta_1 'theta_2) | |
(up 'theta_1dot 'theta_2dot)))) | |
;; Let's simplify that: | |
(simplify symbolic-L) | |
;; Better yet, let's render it as LaTeX, and create a helper function, | |
;; `render-eq` to make it easier to render simplified equations: | |
(def render-eq | |
(comp clerk/tex ->TeX simplify)) | |
(render-eq symbolic-L) | |
;; And here are the equations of motion for the system: | |
(let [L (L-double-pendulum 'm_1 'm_2 'l_1 'l_2 'g)] | |
(binding [sicmutils.expression.render/*TeX-vertical-down-tuples* true] | |
(render-eq | |
(((Lagrange-equations L) | |
(up (literal-function 'theta_1) | |
(literal-function 'theta_2))) | |
't)))) | |
;; What do these mean? | |
;; | |
;; - the system has two degrees of freedom: $\theta_1$ and $\theta_2$. | |
;; - at any point `t` in time, the two equations above, full of first and second | |
;; - order derivatives of the position functions, will stay true | |
;; - the system can use these equations to simulate the system, one tick at a time. | |
;; ## Simulation | |
;; | |
;; Next, let's run a simulation using those equations of motion and collect data | |
;; on each coordinate's evolution. | |
;; | |
;; Here are the constants specified in exercise 1.44: | |
;; | |
;; masses in kg: | |
(def m1 1.0) | |
(def m2 3.0) | |
;; lengths in meters: | |
(def l1 1.0) | |
(def l2 0.9) | |
;; `g` in units of m/s^2: | |
(def g 9.8) | |
;; And two sets of initial pairs of `theta1`, `theta2` angles corresponding to | |
;; chaotic and regular initial conditions: | |
(def chaotic-initial-q (up (/ Math/PI 2) Math/PI)) | |
(def regular-initial-q (up (/ Math/PI 2) 0.0)) | |
;; Composing `Lagrangian->state-derivative` with `L-double-pendulum` produces | |
;; a state derivative that we can use with our ODE solver: | |
(def state-derivative | |
(compose | |
Lagrangian->state-derivative | |
L-double-pendulum)) | |
;; Finally, two default parameters for our simulation. We'll record data in | |
;; steps of 0.01 seconds, and simulate to a horizon of 50 seconds. | |
(def step 0.01) | |
(def horizon 50) | |
;; `run!` will return a sequence of 5001 states, one for each measured point in | |
;; the simulation. The smaller-arity version simply passes in default masses and | |
;; lengths, but you can override those with the larger arity version if you like. | |
;; (The interface here could use some work: `integrate-state-derivative` tidies | |
;; this up a bit, but I want it out in the open for now.) | |
(defn run! | |
([step horizon initial-coords] | |
(run! step horizon l1 l2 m1 m2 g initial-coords)) | |
([step horizon l1 l2 m1 m2 g initial-coords] | |
(let [collector (atom (transient [])) | |
initial-state (up 0.0 | |
initial-coords | |
(up 0.0 0.0))] | |
((evolve state-derivative m1 m2 l1 l2 g) | |
initial-state | |
step | |
horizon | |
{:compile? true | |
:epsilon 1.0e-13 | |
:observe (fn [t state] | |
(swap! | |
collector conj! state))}) | |
(persistent! @collector)))) | |
;; Run the simulation for each set of initial conditions and show the final | |
;; state. Chaotic first: | |
(def raw-chaotic-data | |
(run! step horizon chaotic-initial-q)) | |
;; Looks good: | |
(peek raw-chaotic-data) | |
;; Next, the regular initial condition: | |
(def raw-regular-data | |
(run! step horizon regular-initial-q)) | |
;; Peek at the final state: | |
(peek raw-regular-data) | |
;; ## Measurements, Data Transformation | |
;; Next we'll chart the measurements trapped in those sequences of state tuples. | |
;; The exercise asks us to graph the energy of the system as a function of time. | |
;; Composing `Lagrangian->energy` with `L-double-pendulum` gives us a new | |
;; function (of a state tuple!) that will return the current energy in the | |
;; system.: | |
(def L-energy | |
(compose | |
Lagrangian->energy | |
L-double-pendulum)) | |
;; `energy-monitor` returns a function of `state` that stores an initial energy | |
;; value in its closure, and returns the delta of each new state's energy as | |
;; compared to the original. | |
(defn energy-monitor [energy-fn initial-state] | |
(let [initial-energy (energy-fn initial-state)] | |
(fn [state] | |
(- (energy-fn state) initial-energy)))) | |
;; Finally, the large `transform-data` function. The charting library we'll use | |
;; likes Clojure dictionaries; `transform-data` turns our raw data into a | |
;; sequence of dictionary with all values we might care to explore. This | |
;; includes: | |
;; - the generalized coordinate angles | |
;; - the generalized velocities of those angles | |
;; - the rectangular coordinates of each pendulum bob | |
;; - `:d-energy`, the error in energy at each point in the simulation | |
;; Here is `transform-data`: | |
(defn transform-data [xs] | |
(let [energy-fn (L-energy m1 m2 l1 l2 g) | |
monitor (energy-monitor energy-fn (first xs)) | |
xform (angles->rect l1 l2) | |
pv (principal-value Math/PI)] | |
(map (fn [[t [theta1 theta2] [thetadot1 thetadot2] :as state]] | |
(let [[x1 y1 x2 y2] (xform state)] | |
{:t t | |
:theta1 (pv theta1) | |
:x1 x1 | |
:y1 y1 | |
:theta2 (pv theta2) | |
:x2 x2 | |
:y2 y2 | |
:thetadot1 thetadot1 | |
:thetadot2 thetadot2 | |
:d-energy (monitor state)})) | |
xs))) | |
;; The following forms transform the raw data for each initial condition and | |
;; bind the results to `chaotic-data` and `regular-data` for exploration. | |
(def chaotic-data | |
(doall | |
(transform-data raw-chaotic-data))) | |
(def regular-data | |
(doall | |
(transform-data raw-regular-data))) | |
;; Here's the final, transformed chaotic state: | |
(last chaotic-data) | |
;; And the similar regular state: | |
(last regular-data) | |
;; ## Data Visualization Utilities | |
;; [Hanami](https://github.com/jsa-aerial/hanami)'s templates allow us to create | |
;; a [Vega-Lite](https://vega.github.io/vega-lite/) spec for visualizing the | |
;; system. | |
;; I am not a pro here, but this does the trick for now. | |
;; First, a function to transform the dictionaries we generated above into a | |
;; sequence of `x, y` coordinates tagged with distinct IDs for each pendulum | |
;; bob's points: | |
(defn points-data [data] | |
(mapcat (fn [{:keys [t x1 y1 x2 y2]}] | |
[{:t t | |
:x x1 | |
:y y1 | |
:id :p1} | |
{:t t | |
:x x2 | |
:y y2 | |
:id :p2}]) | |
data)) | |
;; `segments-data` generates endpoints of the pendulum segments, bob-to-bob: | |
(defn segments-data [data] | |
(mapcat (fn [{:keys [t x1 y1 x2 y2]}] | |
[{:t t | |
:x 0 | |
:y 0 | |
:x2 x1 | |
:y2 y1 | |
:id :p1} | |
{:t t | |
:x x1 | |
:y y1 | |
:x2 x2 | |
:y2 y2 | |
:id :p2}]) | |
data)) | |
;; `animation-spec` returns a chart with an attached `t` animation slider that | |
;; graphs the position of each pendulum bob through time, with the actual | |
;; pendulum itself overlaid. Scroll down a bit to see it in action. | |
(defn animation-spec [data] | |
(hanami-common/xform | |
hanami-templates/layer-chart | |
:LAYER [(hanami-common/xform | |
hanami-templates/point-chart | |
:DATA (points-data data) | |
:COLOR {:field :id :type :nominal} | |
:SIZE | |
{:condition {:test "abs(selected_t - datum['t']) < 0.00001" | |
:value 200} | |
:value 5} | |
:OPACITY | |
{:condition {:test "abs(selected_t - datum['t']) < 0.00001" | |
:value 1} | |
:value 0.3} | |
:SELECTION {:selected {:fields [:t] | |
:type :single | |
:bind {:t {:min step | |
:max (- horizon step) | |
:input :range | |
:step step}}}}) | |
(hanami-common/xform | |
hanami-extras/rule-chart | |
:DATA (segments-data data) | |
:COLOR {:field :id :type :nominal} | |
:OPACITY {:condition {:test "abs(selected_t - datum['t']) < 0.00001" | |
:value 1} | |
:value 0})])) | |
;; `k-vs-time` generates a chart spec that monitors some particular key in the | |
;; transformed state values as, you guessed it, a function of time: | |
(defn k-vs-time | |
"Returns a chart of the specified `k` vs `:t` in the supplied sequence of | |
points." | |
([data k] | |
(k-vs-time data k false)) | |
([data k animated?] | |
(let [size (if animated? | |
{:condition | |
{:test "abs(selected_t - datum['t']) < 0.00001" | |
:value 200} | |
:value 5} | |
{:value 5})] | |
(apply hanami-common/xform | |
hanami-templates/point-chart | |
[:DATA data | |
:X :t | |
:Y k | |
:SIZE size])))) | |
;; ## Visualizations | |
;; With those tools in hand, let's make some charts. I'll call this first chart | |
;; the `system-inspector`; this function will return a chart that will let us | |
;; evolve the system with a time slider and monitor both angles, the energy | |
;; error, and the pendulum bob itself as it evolves through time. | |
(defn with-domain [chart domain] | |
(update-in chart | |
[:encoding :y] | |
assoc | |
:scale {:domain domain})) | |
(defn system-inspector [data] | |
(hanami-common/xform | |
hanami-templates/vconcat-chart | |
:VCONCAT [(hanami-common/xform | |
hanami-templates/hconcat-chart | |
:HCONCAT [(animation-spec data) | |
(k-vs-time data :d-energy true)]) | |
(hanami-common/xform | |
hanami-templates/hconcat-chart | |
:HCONCAT [(-> (k-vs-time data :theta1 true) | |
(with-domain [(- Math/PI) Math/PI])) | |
(-> (k-vs-time data :theta2 true) | |
(with-domain [(- Math/PI) Math/PI]))])])) | |
;; Here's a system monitor for the chaotic initial condition: | |
(clerk/vl | |
(system-inspector chaotic-data)) | |
;; And again for the regular initial condition: | |
(clerk/vl | |
(system-inspector regular-data)) | |
;; ## Generalized coordinates, velocities | |
;; `angles-plot` generates a plot, with no animation, showing both theta angles | |
;; and their associated velocities: | |
(defn angles-plot [data] | |
(hanami-common/xform | |
hanami-templates/hconcat-chart | |
:HCONCAT [(hanami-common/xform | |
hanami-templates/vconcat-chart | |
:VCONCAT | |
[(-> (k-vs-time data :theta1) | |
(with-domain [(- Math/PI) Math/PI])) | |
(-> (k-vs-time data :theta2) | |
(with-domain [(- Math/PI) Math/PI]))]) | |
(hanami-common/xform | |
hanami-templates/vconcat-chart | |
:VCONCAT | |
[(k-vs-time data :thetadot1) | |
(k-vs-time data :thetadot2)])])) | |
;; Here are the angles for the chaotic initial condition: | |
(clerk/vl | |
(angles-plot chaotic-data)) | |
;; And the regular initial condition: | |
(clerk/vl | |
(angles-plot regular-data)) | |
;; ## Double Double Pendulum! | |
;; No visualizations here yet, but the code works well. | |
(defn L-double-double-pendulum [m1 m2 l1 l2 g] | |
(fn [[t [thetas1 thetas2] [qdots1 qdots2]]] | |
(let [s1 (up t thetas1 qdots1) | |
s2 (up t thetas2 qdots2)] | |
(+ ((L-double-pendulum m1 m2 l1 l2 g) s1) | |
((L-double-pendulum m1 m2 l1 l2 g) s2))))) | |
(def dd-state-derivative | |
(compose | |
Lagrangian->state-derivative | |
L-double-double-pendulum)) | |
(defn safe-log [x] | |
(if (< x 1e-60) | |
-138.0 | |
(Math/log x))) | |
(defn divergence-monitor [] | |
(let [pv (principal-value Math/PI)] | |
(fn [[t [thetas1 thetas2]]] | |
(safe-log | |
(Math/abs | |
(pv | |
(- (nth thetas1 1) | |
(nth thetas2 1)))))))) | |
(defn run-double-double! | |
"Two different initializations, slightly kicked" | |
[step horizon initial-q1] | |
(let [initial-q2 (+ initial-q1 (up 0.0 1e-10)) | |
initial-qdot (up 0.0 0.0) | |
initial-state (up 0.0 | |
(up initial-q1 initial-q2) | |
(up initial-qdot initial-qdot)) | |
collector (atom (transient []))] | |
((evolve dd-state-derivative m1 m2 l1 l2 g) | |
initial-state | |
step | |
horizon | |
{:compile? true | |
:epsilon 1.0e-13 ; = (max-norm 1.e-13) | |
:observe (fn [t state] | |
(swap! collector conj! state))}) | |
(persistent! @collector))) | |
(def raw-dd-chaotic-data | |
(run-double-double! step horizon chaotic-initial-q)) | |
;; Looks good: | |
(peek raw-dd-chaotic-data) | |
;; Next, the regular initial condition: | |
(def raw-dd-regular-data | |
(run-double-double! step horizon regular-initial-q)) | |
;; Peek at the final state: | |
(peek raw-dd-regular-data) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment