Merge "Use the host libcuda and libcudart" into main
diff --git a/.bazelignore b/.bazelignore
index 7fa8e45..cce9d62 100644
--- a/.bazelignore
+++ b/.bazelignore
@@ -1,5 +1,6 @@
 external
 node_modules
+frc971/control_loops/swerve/spline_ui/www/node_modules
 scouting/webserver/requests/messages/node_modules
 scouting/www/node_modules
 scouting/www/driver_ranking/node_modules
diff --git a/WORKSPACE b/WORKSPACE
index 6be969d..ad59314 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -944,6 +944,7 @@
     name = "npm",
     data = [
         "//aos/analysis/foxglove_extension:package.json",
+        "//control_loops/swerve/spline_ui/www:package.json",
         "@//:package.json",
         "@//:pnpm-workspace.yaml",
         "@//scouting/webserver/requests/messages:package.json",
diff --git a/frc971/control_loops/swerve/BUILD b/frc971/control_loops/swerve/BUILD
index fcd60a1..5adef05 100644
--- a/frc971/control_loops/swerve/BUILD
+++ b/frc971/control_loops/swerve/BUILD
@@ -208,6 +208,7 @@
         ":physics_test_utils",
         "@pip//casadi",
         "@pip//numpy",
+        "@pip//scipy",
     ],
 )
 
@@ -250,3 +251,17 @@
         "@pip//scipy",
     ],
 )
+
+py_binary(
+    name = "debug_fatrop",
+    srcs = [
+        "debug_fatrop.py",
+    ],
+    deps = [
+        "@pip//casadi",
+        "@pip//matplotlib",
+        "@pip//numpy",
+        "@pip//pygobject",
+        "@pip//scipy",
+    ],
+)
diff --git a/frc971/control_loops/swerve/casadi_velocity_mpc.py b/frc971/control_loops/swerve/casadi_velocity_mpc.py
index 698cce7..0d3e177 100644
--- a/frc971/control_loops/swerve/casadi_velocity_mpc.py
+++ b/frc971/control_loops/swerve/casadi_velocity_mpc.py
@@ -15,7 +15,7 @@
 
 class MPC(object):
 
-    def __init__(self):
+    def __init__(self, solver='fatrop'):
         self.fdot = dynamics.swerve_full_dynamics(
             casadi.SX.sym("X", dynamics.NUM_STATES, 1),
             casadi.SX.sym("U", 8, 1))
@@ -41,9 +41,7 @@
                                 casadi.SX.sym("U", 8, 1)) for i in range(4)
         ]
 
-        self.N = 25
-
-        # TODO(austin): Can we approximate sin/cos/atan for the initial operating point to solve faster if we need it?
+        self.N = 50
 
         # Start with an empty nonlinear program.
         self.w = []
@@ -59,33 +57,42 @@
         # We care about the linear and angular velocities only.
         self.R = casadi.MX.sym('R', 3)
 
-        # Instead of an equality constraint on the goal, what about:
-        # self.w += [Xk]
-        # lbw += [0, 1]
-        # ubw += [0, 1]
-        # w0 += [0, 1]
+        # Make Xn and U for each step.  fatrop wants us to interleave the control variables and
+        # states so that it can produce a banded/structured problem which it can solve a lot
+        # faster.
+        Xn_variables = []
+        U_variables = []
+        for i in range(self.N):
+            U_variables.append(casadi.MX.sym(f'U{i}', 8))
 
-        Xn = casadi.MX.sym('Xn', dynamics.NUM_VELOCITY_STATES, self.N - 1)
-        U = casadi.MX.sym('U', 8, self.N)
+            if i == 0:
+                continue
+
+            Xn_variables.append(
+                casadi.MX.sym(f'X{i}', dynamics.NUM_VELOCITY_STATES))
+
+        Xn = casadi.horzcat(*Xn_variables)
+        U = casadi.horzcat(*U_variables)
 
         Xk_begin = casadi.horzcat(self.X0, Xn)
         Xk_end = self.next_X.map(self.N, "thread")(Xk_begin, U)
         J = casadi.sum2(self.cost.map(self.N, "thread")(Xk_end, U, self.R))
 
-        self.w += [casadi.reshape(U, 8 * self.N, 1)]
-        self.lbw += [-100] * (8 * self.N)
-        self.ubw += [100] * (8 * self.N)
+        # Put U and Xn interleaved into w to go fast.
+        for i in range(self.N):
+            self.w += [U_variables[i]]
+            self.ubw += [100] * 8
+            self.lbw += [-100] * 8
 
-        self.w += [
-            casadi.reshape(Xn, dynamics.NUM_VELOCITY_STATES * (self.N - 1), 1)
-        ]
-        self.ubw += [casadi.inf] * (dynamics.NUM_VELOCITY_STATES *
-                                    (self.N - 1))
-        self.lbw += [-casadi.inf] * (dynamics.NUM_VELOCITY_STATES *
-                                     (self.N - 1))
+            if i == self.N - 1:
+                continue
+
+            self.w += [Xn_variables[i]]
+            self.ubw += [casadi.inf] * dynamics.NUM_VELOCITY_STATES
+            self.lbw += [-casadi.inf] * dynamics.NUM_VELOCITY_STATES
 
         self.g += [
-            casadi.reshape(Xk_end[:, 0:(self.N - 1)] - Xn,
+            casadi.reshape(Xn - Xk_end[:, 0:(self.N - 1)],
                            dynamics.NUM_VELOCITY_STATES * (self.N - 1), 1)
         ]
 
@@ -102,16 +109,43 @@
             'p': casadi.vertcat(self.X0, self.R),
         }
 
-        compiler = "clang"
-        flags = ["-O1"]
-        jit_options = {"flags": flags, "verbose": True, "compiler": compiler}
-        options = {
-            "jit": False,
-            "compiler": "shell",
-            "jit_options": jit_options,
-            "ipopt.linear_solver": "spral",
+        compiler = "ccache clang"
+        flags = ["-O3"]
+        jit_options = {
+            "flags": flags,
+            "verbose": False,
+            "compiler": compiler,
+            "temp_suffix": False,
         }
-        self.solver = casadi.nlpsol('solver', 'ipopt', prob, options)
+
+        if solver == 'fatrop':
+            equality = [
+                True
+                for _ in range(dynamics.NUM_VELOCITY_STATES * (self.N - 1))
+            ]
+            options = {
+                "jit": True,
+                "jit_cleanup": False,
+                "jit_temp_suffix": False,
+                "compiler": "shell",
+                "jit_options": jit_options,
+                "structure_detection": "auto",
+                "fatrop": {
+                    "tol": 1e-7
+                },
+                "debug": True,
+                "equality": equality,
+            }
+            self.solver = casadi.nlpsol('solver', 'fatrop', prob, options)
+        else:
+            options = {
+                "jit": True,
+                "jit_cleanup": False,
+                "jit_temp_suffix": False,
+                "compiler": "shell",
+                "jit_options": jit_options,
+            }
+            self.solver = casadi.nlpsol('solver', 'ipopt', prob, options)
 
     def make_physics(self):
         X0 = casadi.MX.sym('X0', dynamics.NUM_VELOCITY_STATES)
@@ -148,8 +182,8 @@
         vperpx = -vnormy
         vperpy = vnormx
 
-        J += 100 * ((R[0] - X[dynamics.VELOCITY_STATE_VX]) * vnormx +
-                    (R[1] - X[dynamics.VELOCITY_STATE_VY]) * vnormy)**2.0
+        J += 75 * ((R[0] - X[dynamics.VELOCITY_STATE_VX]) * vnormx +
+                   (R[1] - X[dynamics.VELOCITY_STATE_VY]) * vnormy)**2.0
 
         J += 1500 * ((R[0] - X[dynamics.VELOCITY_STATE_VX]) * vperpx +
                      (R[1] - X[dynamics.VELOCITY_STATE_VY]) * vperpy)**2.0
@@ -183,8 +217,10 @@
         if seed is None:
             seed = []
 
-            seed += [0, 0] * 4 * self.N
-            seed += list(p[:dynamics.NUM_VELOCITY_STATES, 0]) * (self.N - 1)
+            for i in range(self.N):
+                seed += [0, 0] * 4
+                if i < self.N - 1:
+                    seed += list(p[:dynamics.NUM_VELOCITY_STATES, 0])
 
         return self.solver(x0=seed,
                            lbx=self.lbw,
@@ -194,33 +230,44 @@
                            p=casadi.DM(p))
 
     def unpack_u(self, sol, i):
-        return sol['x'].full().flatten()[8 * i:8 * (i + 1)]
+        return sol['x'].full().flatten()[
+            (8 + dynamics.NUM_VELOCITY_STATES) *
+            i:((8 + dynamics.NUM_VELOCITY_STATES) * i + 8)]
 
     def unpack_x(self, sol, i):
-        return sol['x'].full().flatten()[8 * self.N +
-                                         dynamics.NUM_VELOCITY_STATES *
-                                         (i - 1):8 * self.N +
-                                         dynamics.NUM_VELOCITY_STATES * (i)]
+        return sol['x'].full().flatten(
+        )[8 + (8 + dynamics.NUM_VELOCITY_STATES) *
+          (i - 1):(8 + dynamics.NUM_VELOCITY_STATES) * i]
 
 
-mpc = MPC()
+mpc = MPC(solver='fatrop')
 
 R_goal = numpy.zeros((3, 1))
 R_goal[0, 0] = 1.0
 R_goal[1, 0] = 1.0
+R_goal[2, 0] = 0.0
+
+module_velocity = 0.0
 
 X_initial = numpy.zeros((25, 1))
 # All the wheels are spinning at the speed needed to hit 1 m/s
-X_initial[3, 0] = 0.0
-X_initial[7, 0] = 0.0
-X_initial[11, 0] = 0.0
-X_initial[15, 0] = 0.0
+X_initial[dynamics.STATE_THETAS0, 0] = 0.0
+X_initial[dynamics.STATE_OMEGAS0, 0] = module_velocity
+
+X_initial[dynamics.STATE_THETAS1, 0] = 0.0
+X_initial[dynamics.STATE_OMEGAS1, 0] = module_velocity
+
+X_initial[dynamics.STATE_THETAS2, 0] = 0.0
+X_initial[dynamics.STATE_OMEGAS2, 0] = module_velocity
+
+X_initial[dynamics.STATE_THETAS3, 0] = 0.0
+X_initial[dynamics.STATE_OMEGAS3, 0] = module_velocity
 
 # Robot is moving at 0 m/s
-X_initial[19, 0] = 0.01
-X_initial[20, 0] = 0.0
+X_initial[dynamics.STATE_VX, 0] = 0.0
+X_initial[dynamics.STATE_VY, 0] = 0.0
 # No angular velocity
-X_initial[21, 0] = 0.0
+X_initial[dynamics.STATE_OMEGA, 0] = 0.0
 
 iterations = 100
 
@@ -236,23 +283,29 @@
 fig1, axs1 = pylab.subplots(2)
 last_time = time.time()
 
-seed = [0, 0] * 4 * mpc.N + list(dynamics.to_velocity_state(X)) * (mpc.N - 1)
+seed = ([0, 0] * 4 + list(dynamics.to_velocity_state(X))) * (mpc.N -
+                                                             1) + [0, 0] * 4
 
+overall_time = 0
 for i in range(iterations):
     t.append(i * mpc.dt)
     print("Current X at", i * mpc.dt, X.transpose())
     print("Goal R at", i * mpc.dt, R_goal)
+    start_time = time.time()
     sol = mpc.solve(
         # TODO(austin): Is this better or worse than constraints on the initial state for convergence?
         p=numpy.vstack((dynamics.to_velocity_state(X), R_goal)),
         seed=seed)
+    end_time = time.time()
+    print(f"Took {end_time - start_time} seconds to solve.")
+    overall_time += end_time - start_time
+
     X_plot[:, i] = X[:, 0]
 
     U = mpc.unpack_u(sol, 0)
-    seed = (list(sol['x'].full().flatten()[8:8 * mpc.N]) +
-            list(sol['x'].full().flatten()[8 * (mpc.N - 1) +
-                                           dynamics.NUM_VELOCITY_STATES:]) +
-            list(sol['x'].full().flatten()[-dynamics.NUM_VELOCITY_STATES:]))
+    seed = (
+        list(sol['x'].full().flatten()[8 + dynamics.NUM_VELOCITY_STATES:]) +
+        list(sol['x'].full().flatten()[-(8 + dynamics.NUM_VELOCITY_STATES):]))
     U_plot[:, i] = U
 
     print('x(0):', X.transpose())
@@ -293,4 +346,6 @@
         pyplot.pause(0.0001)
         last_time = time.time()
 
+print(f"Tool {overall_time} seconds overall to solve.")
+
 pyplot.pause(-1)
diff --git a/frc971/control_loops/swerve/debug_fatrop.py b/frc971/control_loops/swerve/debug_fatrop.py
new file mode 100755
index 0000000..2f7fa92
--- /dev/null
+++ b/frc971/control_loops/swerve/debug_fatrop.py
@@ -0,0 +1,61 @@
+import casadi
+import pylab
+
+# From https://gist.github.com/jgillis/dec56fa16c90a8e4a69465e8422c5459
+
+# Point this to where the files generated by running casadi with solver options of
+# {"debug": True}
+root = "./"
+
+actual = casadi.Sparsity.from_file(root + "debug_fatrop_actual.mtx")
+
+A = casadi.Sparsity.from_file(root + "debug_fatrop_A.mtx")
+B = casadi.Sparsity.from_file(root + "debug_fatrop_B.mtx")
+C = casadi.Sparsity.from_file(root + "debug_fatrop_C.mtx")
+D = casadi.Sparsity.from_file(root + "debug_fatrop_D.mtx")
+I = casadi.Sparsity.from_file(root + "debug_fatrop_I.mtx")
+errors = casadi.Sparsity.from_file(root + "debug_fatrop_errors.mtx").row()
+
+pylab.figure()
+pylab.spy(A,
+          marker='o',
+          color='r',
+          markersize=5,
+          label="expected A",
+          markerfacecolor="white")
+pylab.spy(B,
+          marker='o',
+          color='b',
+          markersize=5,
+          label="expected B",
+          markerfacecolor="white")
+pylab.spy(C,
+          marker='o',
+          color='g',
+          markersize=5,
+          label="expected C",
+          markerfacecolor="white")
+pylab.spy(D,
+          marker='o',
+          color='y',
+          markersize=5,
+          label="expected D",
+          markerfacecolor="white")
+pylab.spy(I,
+          marker='o',
+          color='k',
+          markersize=5,
+          label="expected I",
+          markerfacecolor="white")
+pylab.spy(actual, marker='o', color='k', markersize=2, label="actual")
+
+pylab.hlines(errors,
+             0,
+             A.shape[1],
+             color='gray',
+             linestyle='-',
+             label="offending rows")
+
+pylab.title("Debug view of fatrop interface structure detection")
+pylab.legend()
+pylab.show()
diff --git a/frc971/control_loops/swerve/generate_physics.cc b/frc971/control_loops/swerve/generate_physics.cc
index 2e3fe73..e712a39 100644
--- a/frc971/control_loops/swerve/generate_physics.cc
+++ b/frc971/control_loops/swerve/generate_physics.cc
@@ -44,6 +44,7 @@
 using SymEngine::cos;
 using SymEngine::DenseMatrix;
 using SymEngine::div;
+using SymEngine::exp;
 using SymEngine::Inf;
 using SymEngine::integer;
 using SymEngine::map_basic_basic;
@@ -55,7 +56,6 @@
 using SymEngine::real_double;
 using SymEngine::RealDouble;
 using SymEngine::Set;
-using SymEngine::sign;
 using SymEngine::simplify;
 using SymEngine::sin;
 using SymEngine::solve;
@@ -306,14 +306,6 @@
     result_cc.emplace_back("#include <cmath>");
     result_cc.emplace_back("");
     result_cc.emplace_back("namespace frc971::control_loops::swerve {");
-    result_cc.emplace_back("namespace {");
-    result_cc.emplace_back("");
-    result_cc.emplace_back("double sign(double x) {");
-    result_cc.emplace_back("  return (x > 0) - (x < 0);");
-    result_cc.emplace_back("}");
-
-    result_cc.emplace_back("");
-    result_cc.emplace_back("}  // namespace");
     result_cc.emplace_back("");
     result_cc.emplace_back("Eigen::Matrix<double, 25, 1> SwervePhysics(");
     result_cc.emplace_back(
@@ -410,9 +402,9 @@
 
   void WriteCasadiVariables(std::vector<std::string> *result_py) {
     result_py->emplace_back("    sin = casadi.sin");
-    result_py->emplace_back("    sign = casadi.sign");
     result_py->emplace_back("    cos = casadi.cos");
-    result_py->emplace_back("    atan2 = casadi.atan2");
+    result_py->emplace_back("    exp = casadi.exp");
+    result_py->emplace_back("    atan2 = soft_atan2");
     result_py->emplace_back("    fmax = casadi.fmax");
     result_py->emplace_back("    fabs = casadi.fabs");
 
@@ -454,9 +446,9 @@
 
   void WriteCasadiVelocityVariables(std::vector<std::string> *result_py) {
     result_py->emplace_back("    sin = casadi.sin");
-    result_py->emplace_back("    sign = casadi.sign");
+    result_py->emplace_back("    exp = casadi.exp");
     result_py->emplace_back("    cos = casadi.cos");
-    result_py->emplace_back("    atan2 = casadi.atan2");
+    result_py->emplace_back("    atan2 = soft_atan2");
     result_py->emplace_back("    fmax = casadi.fmax");
     result_py->emplace_back("    fabs = casadi.fabs");
 
@@ -574,6 +566,14 @@
     // result_py.emplace_back("        [X[STATE_MOMENT, 0]],");
     result_py.emplace_back("    ])");
     result_py.emplace_back("");
+    constexpr double kLogGain = 1.0 / 0.05;
+    result_py.emplace_back("def soft_atan2(y, x):");
+    result_py.emplace_back("    return casadi.arctan2(");
+    result_py.emplace_back("        y,");
+    result_py.emplace_back("        casadi.logsumexp(casadi.SX(numpy.array(");
+    result_py.emplace_back(absl::Substitute(
+        "            [1.0, casadi.fabs(x) * $0.0]))) / $0.0)", kLogGain));
+    result_py.emplace_back("");
 
     result_py.emplace_back("# Returns the derivative of our state vector");
     result_py.emplace_back("# [thetas0, thetad0, omegas0, omegad0,");
@@ -839,15 +839,17 @@
     // Velocity of the contact patch in field coordinates
     DenseMatrix temp_matrix = DenseMatrix(2, 1);
     DenseMatrix temp_matrix2 = DenseMatrix(2, 1);
+    DenseMatrix temp_matrix3 = DenseMatrix(2, 1);
     result.contact_patch_velocity = DenseMatrix(2, 1);
 
     mul_dense_dense(R(theta_), result.mounting_location, temp_matrix);
     add_dense_dense(angle_cross(temp_matrix, omega_), robot_velocity,
                     temp_matrix2);
     mul_dense_dense(R(add(theta_, result.thetas)),
-                    DenseMatrix(2, 1, {neg(caster_), integer(0)}), temp_matrix);
+                    DenseMatrix(2, 1, {neg(caster_), integer(0)}),
+                    temp_matrix3);
     add_dense_dense(temp_matrix2,
-                    angle_cross(temp_matrix, add(omega_, result.omegas)),
+                    angle_cross(temp_matrix3, add(omega_, result.omegas)),
                     result.contact_patch_velocity);
 
     VLOG(1);
@@ -894,11 +896,15 @@
     result.Fwy = simplify(mul(Cy_, result.slip_angle));
 
     // The self-aligning moment needs to flip when the module flips direction.
-    result.Ms = mul(neg(result.Fwy),
-                    add(div(mul(sign(result.wheel_ground_velocity.get(0, 0)),
-                                contact_patch_length_),
-                            integer(3)),
-                        caster_));
+    RCP<const Basic> softsign_velocity = add(
+        div(integer(-2),
+            add(integer(1), exp(mul(integer(100),
+                                    result.wheel_ground_velocity.get(0, 0))))),
+        integer(1));
+    result.Ms =
+        mul(neg(result.Fwy),
+            add(div(mul(softsign_velocity, contact_patch_length_), integer(3)),
+                caster_));
     VLOG(1);
     VLOG(1) << "Ms " << result.Ms->__str__();
     VLOG(1);
diff --git a/frc971/control_loops/swerve/physics_test.py b/frc971/control_loops/swerve/physics_test.py
index 2ce82ef..9aa6457 100644
--- a/frc971/control_loops/swerve/physics_test.py
+++ b/frc971/control_loops/swerve/physics_test.py
@@ -3,6 +3,7 @@
 import numpy
 import sys, os
 import casadi
+import scipy
 from numpy.testing import assert_array_equal, assert_array_almost_equal
 import unittest
 
@@ -173,17 +174,23 @@
                 for theta in [0.0, 0.6, -0.4]:
                     module_angle = numpy.pi * wrap + theta
 
-                    # We have redefined the angle to be the sin of the angle.
+                    # We have redefined the angle to be the softened sin of the angle.
                     # That way, when the module flips directions, the slip angle also flips
                     # directions to keep it stable.
                     computed_angle = self.slip_angle[i](
                         utils.state_vector(velocity=velocity,
-                                           module_angle=numpy.pi * wrap +
-                                           theta),
+                                           module_angle=module_angle),
                         self.I,
                     )[0, 0]
 
-                    expected = numpy.sin(numpy.pi * wrap + theta)
+                    # Compute out the expected value directly to confirm we got it right.
+                    loggain = 20.0
+                    vy = 1.5 * numpy.sin(-module_angle)
+                    vx = 1.5 * numpy.cos(-module_angle)
+                    expected = numpy.sin(-numpy.arctan2(
+                        vy,
+                        scipy.special.logsumexp([1.0, abs(vx) * loggain]) /
+                        loggain))
 
                     self.assertAlmostEqual(
                         expected,
diff --git a/frc971/control_loops/swerve/spline_ui/www/BUILD b/frc971/control_loops/swerve/spline_ui/www/BUILD
new file mode 100644
index 0000000..1ecd17c
--- /dev/null
+++ b/frc971/control_loops/swerve/spline_ui/www/BUILD
@@ -0,0 +1,46 @@
+load("@aspect_bazel_lib//lib:copy_file.bzl", "copy_file")
+load("@npm//:defs.bzl", "npm_link_all_packages")
+load("//tools/build_rules:js.bzl", "ng_application")
+load("//tools/build_rules/js:static.bzl", "assemble_static_files")
+
+npm_link_all_packages(name = "node_modules")
+
+ng_application(
+    name = "app",
+    assets = [
+        # Explicitly blank to avoid the default.
+    ],
+    extra_srcs = [
+        "app/common.css",
+    ],
+    html_assets = [
+        "favicon.ico",
+    ],
+    deps = [
+        ":node_modules",
+    ],
+)
+
+assemble_static_files(
+    name = "static_files",
+    srcs = [
+        "//third_party/y2024/field:pictures",
+    ],
+    app_files = ":app",
+    replace_prefixes = {
+        "prod": "",
+        "dev": "",
+        "third_party/y2024": "pictures",
+    },
+    tags = [
+        "no-remote-cache",
+    ],
+    visibility = ["//visibility:public"],
+)
+
+copy_file(
+    name = "app_common_css",
+    src = "common.css",
+    out = "app/common.css",
+    visibility = [":__subpackages__"],
+)
diff --git a/frc971/control_loops/swerve/spline_ui/www/README.md b/frc971/control_loops/swerve/spline_ui/www/README.md
new file mode 100644
index 0000000..17960c7
--- /dev/null
+++ b/frc971/control_loops/swerve/spline_ui/www/README.md
@@ -0,0 +1,10 @@
+Scouting App Frontend
+================================================================================
+
+Run using:
+```console
+$ bazel build //frc971/control_loops/swerve/spline_ui/www:all
+$ (cd bazel-bin/frc971/control_loops/swerve/spline_ui/www/static_files/ && python3 -m http.server)
+```
+
+Then you can navigate to <http://localhost:8000> to look at the web page.
diff --git a/frc971/control_loops/swerve/spline_ui/www/app/app.module.ts b/frc971/control_loops/swerve/spline_ui/www/app/app.module.ts
new file mode 100644
index 0000000..6948041
--- /dev/null
+++ b/frc971/control_loops/swerve/spline_ui/www/app/app.module.ts
@@ -0,0 +1,16 @@
+import {NgModule} from '@angular/core';
+import {BrowserModule} from '@angular/platform-browser';
+import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
+
+import {App} from './app';
+
+@NgModule({
+  declarations: [App],
+  imports: [
+    BrowserModule,
+    BrowserAnimationsModule,
+  ],
+  exports: [App],
+  bootstrap: [App],
+})
+export class AppModule {}
diff --git a/frc971/control_loops/swerve/spline_ui/www/app/app.ng.html b/frc971/control_loops/swerve/spline_ui/www/app/app.ng.html
new file mode 100644
index 0000000..6e43eac
--- /dev/null
+++ b/frc971/control_loops/swerve/spline_ui/www/app/app.ng.html
@@ -0,0 +1 @@
+<h1>Spline UI</h1>
diff --git a/frc971/control_loops/swerve/spline_ui/www/app/app.ts b/frc971/control_loops/swerve/spline_ui/www/app/app.ts
new file mode 100644
index 0000000..dc0cb7c
--- /dev/null
+++ b/frc971/control_loops/swerve/spline_ui/www/app/app.ts
@@ -0,0 +1,9 @@
+import {Component} from '@angular/core';
+
+@Component({
+  selector: 'spline-ui-app',
+  templateUrl: './app.ng.html',
+  styleUrls: ['../app/common.css'],
+})
+export class App {
+}
diff --git a/frc971/control_loops/swerve/spline_ui/www/common.css b/frc971/control_loops/swerve/spline_ui/www/common.css
new file mode 100644
index 0000000..cf58bba
--- /dev/null
+++ b/frc971/control_loops/swerve/spline_ui/www/common.css
@@ -0,0 +1 @@
+/* This CSS is shared between all spline UI pages. */
diff --git a/frc971/control_loops/swerve/spline_ui/www/favicon.ico b/frc971/control_loops/swerve/spline_ui/www/favicon.ico
new file mode 100644
index 0000000..2bf69ea
--- /dev/null
+++ b/frc971/control_loops/swerve/spline_ui/www/favicon.ico
Binary files differ
diff --git a/frc971/control_loops/swerve/spline_ui/www/index.html b/frc971/control_loops/swerve/spline_ui/www/index.html
new file mode 100644
index 0000000..c36936d
--- /dev/null
+++ b/frc971/control_loops/swerve/spline_ui/www/index.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <title>FRC971 Spline UI</title>
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <meta name="theme-color" content="#000000" />
+    <link
+      href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
+      rel="stylesheet"
+      integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
+      crossorigin="anonymous"
+    />
+    <link
+      rel="stylesheet"
+      href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
+    />
+    <script
+      src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
+      integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
+      crossorigin="anonymous"
+    ></script>
+  </head>
+  <body>
+    <spline-ui-app></spline-ui-app>
+    <noscript>
+      Please enable JavaScript to continue using this application.
+    </noscript>
+  </body>
+</html>
diff --git a/frc971/control_loops/swerve/spline_ui/www/main.ts b/frc971/control_loops/swerve/spline_ui/www/main.ts
new file mode 100644
index 0000000..1f8eb00
--- /dev/null
+++ b/frc971/control_loops/swerve/spline_ui/www/main.ts
@@ -0,0 +1,4 @@
+import {platformBrowser} from '@angular/platform-browser';
+import {AppModule} from './app/app.module';
+
+platformBrowser().bootstrapModule(AppModule);
diff --git a/frc971/control_loops/swerve/spline_ui/www/package.json b/frc971/control_loops/swerve/spline_ui/www/package.json
new file mode 100644
index 0000000..54a7f09
--- /dev/null
+++ b/frc971/control_loops/swerve/spline_ui/www/package.json
@@ -0,0 +1,6 @@
+{
+    "private": true,
+    "dependencies": {
+        "@angular/animations": "v16-lts"
+    }
+}
diff --git a/frc971/control_loops/swerve/spline_ui/www/polyfills.ts b/frc971/control_loops/swerve/spline_ui/www/polyfills.ts
new file mode 100644
index 0000000..e4555ed
--- /dev/null
+++ b/frc971/control_loops/swerve/spline_ui/www/polyfills.ts
@@ -0,0 +1,52 @@
+/**
+ * This file includes polyfills needed by Angular and is loaded before the app.
+ * You can add your own extra polyfills to this file.
+ *
+ * This file is divided into 2 sections:
+ *   1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
+ *   2. Application imports. Files imported after ZoneJS that should be loaded before your main
+ *      file.
+ *
+ * The current setup is for so-called "evergreen" browsers; the last versions of browsers that
+ * automatically update themselves. This includes recent versions of Safari, Chrome (including
+ * Opera), Edge on the desktop, and iOS and Chrome on mobile.
+ *
+ * Learn more in https://angular.io/guide/browser-support
+ */
+
+/***************************************************************************************************
+ * BROWSER POLYFILLS
+ */
+
+/**
+ * By default, zone.js will patch all possible macroTask and DomEvents
+ * user can disable parts of macroTask/DomEvents patch by setting following flags
+ * because those flags need to be set before `zone.js` being loaded, and webpack
+ * will put import in the top of bundle, so user need to create a separate file
+ * in this directory (for example: zone-flags.ts), and put the following flags
+ * into that file, and then add the following code before importing zone.js.
+ * import './zone-flags';
+ *
+ * The flags allowed in zone-flags.ts are listed here.
+ *
+ * The following flags will work for all browsers.
+ *
+ * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
+ * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
+ * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
+ *
+ *  in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
+ *  with the following flag, it will bypass `zone.js` patch for IE/Edge
+ *
+ *  (window as any).__Zone_enable_cross_context_check = true;
+ *
+ */
+
+/***************************************************************************************************
+ * Zone JS is required by default for Angular itself.
+ */
+import 'zone.js'; // Included with Angular CLI.
+
+/***************************************************************************************************
+ * APPLICATION IMPORTS
+ */
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d8164ab..c8e5425 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -170,6 +170,12 @@
         specifier: ^5
         version: 5.1.6
 
+  frc971/control_loops/swerve/spline_ui/www:
+    dependencies:
+      '@angular/animations':
+        specifier: v16-lts
+        version: 16.2.12(@angular/core@16.2.12)
+
   scouting/webserver/requests/messages: {}
 
   scouting/www:
@@ -390,7 +396,7 @@
       '@angular/core': 16.2.12
     dependencies:
       '@angular/core': 16.2.12(rxjs@7.5.7)(zone.js@0.13.3)
-      tslib: 2.6.0
+      tslib: 2.6.2
 
   /@angular/cli@16.2.12:
     resolution: {integrity: sha512-Pcbiraoqdw4rR2Ey5Ooy0ESLS1Ffbjkb6sPfinKRkHmAvyqsmlvkfbB/qK8GrzDSFSWvAKMMXRw9l8nbjvQEXg==}
@@ -2200,7 +2206,6 @@
       function-bind: 1.1.2
       get-intrinsic: 1.2.4
       set-function-length: 1.2.2
-    dev: false
 
   /callsites@3.1.0:
     resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
@@ -2639,7 +2644,6 @@
       es-define-property: 1.0.0
       es-errors: 1.3.0
       gopd: 1.0.1
-    dev: false
 
   /define-lazy-prop@2.0.0:
     resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==}
@@ -2878,12 +2882,10 @@
     engines: {node: '>= 0.4'}
     dependencies:
       get-intrinsic: 1.2.4
-    dev: false
 
   /es-errors@1.3.0:
     resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
     engines: {node: '>= 0.4'}
-    dev: false
 
   /es-iterator-helpers@1.0.19:
     resolution: {integrity: sha512-zoMwbCcH5hwUkKJkT8kDIBZSz9I6mVG//+lDCinLCGov4+r7NIy0ld8o03M0cJxl2spVf6ESYVS6/gpIfq1FFw==}
@@ -3567,7 +3569,6 @@
       has-proto: 1.0.1
       has-symbols: 1.0.3
       hasown: 2.0.1
-    dev: false
 
   /get-stream@5.2.0:
     resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
@@ -3708,7 +3709,6 @@
     resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==}
     dependencies:
       get-intrinsic: 1.2.4
-    dev: false
 
   /graceful-fs@4.2.11:
     resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@@ -3734,7 +3734,6 @@
     resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
     dependencies:
       es-define-property: 1.0.0
-    dev: false
 
   /has-proto@1.0.1:
     resolution: {integrity: sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==}
@@ -4977,7 +4976,6 @@
 
   /object-inspect@1.13.1:
     resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==}
-    dev: false
 
   /object-keys@1.1.1:
     resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
@@ -5385,7 +5383,7 @@
     resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==}
     engines: {node: '>=0.6'}
     dependencies:
-      side-channel: 1.0.4
+      side-channel: 1.0.6
 
   /querystringify@2.2.0:
     resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
@@ -5652,7 +5650,7 @@
   /rxjs@7.8.1:
     resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==}
     dependencies:
-      tslib: 2.6.0
+      tslib: 2.6.2
     dev: true
 
   /safe-array-concat@1.1.2:
@@ -5756,7 +5754,6 @@
       get-intrinsic: 1.2.4
       gopd: 1.0.1
       has-property-descriptors: 1.0.2
-    dev: false
 
   /set-function-name@2.0.2:
     resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==}
@@ -5799,7 +5796,6 @@
       es-errors: 1.3.0
       get-intrinsic: 1.2.4
       object-inspect: 1.13.1
-    dev: false
 
   /signal-exit@3.0.7:
     resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
@@ -6293,7 +6289,6 @@
 
   /tslib@2.6.2:
     resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
-    dev: false
 
   /tsutils@3.21.0(typescript@5.1.6):
     resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index f18844b..6675e2f 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,4 +1,5 @@
 packages:
+- frc971/control_loops/swerve/spline_ui/www
 - scouting/webserver/requests/messages
 - scouting/www
 - scouting/www/*
diff --git a/scouting/www/BUILD b/scouting/www/BUILD
index c73f93a..2a34fdf 100644
--- a/scouting/www/BUILD
+++ b/scouting/www/BUILD
@@ -3,7 +3,8 @@
 load("@npm//:@angular/service-worker/package_json.bzl", angular_service_worker = "bin")
 load("@npm//:defs.bzl", "npm_link_all_packages")
 load("//tools/build_rules:js.bzl", "ng_application")
-load(":defs.bzl", "assemble_service_worker_files", "assemble_static_files")
+load("//tools/build_rules/js:static.bzl", "assemble_static_files")
+load(":defs.bzl", "assemble_service_worker_files")
 
 npm_link_all_packages(name = "node_modules")
 
diff --git a/scouting/www/defs.bzl b/scouting/www/defs.bzl
index 86095d7..a8bc775 100644
--- a/scouting/www/defs.bzl
+++ b/scouting/www/defs.bzl
@@ -1,41 +1,3 @@
-load("@aspect_bazel_lib//lib:copy_to_directory.bzl", "copy_to_directory_bin_action")
-
-def _assemble_static_files_impl(ctx):
-    out_dir = ctx.actions.declare_directory(ctx.label.name)
-
-    copy_to_directory_bin = ctx.toolchains["@aspect_bazel_lib//lib:copy_to_directory_toolchain_type"].copy_to_directory_info.bin
-
-    copy_to_directory_bin_action(
-        ctx,
-        dst = out_dir,
-        name = ctx.label.name,
-        copy_to_directory_bin = copy_to_directory_bin,
-        files = ctx.files.srcs + ctx.attr.app_files.files.to_list(),
-        replace_prefixes = ctx.attr.replace_prefixes,
-    )
-
-    return [DefaultInfo(
-        files = depset([out_dir]),
-        runfiles = ctx.runfiles([out_dir]),
-    )]
-
-assemble_static_files = rule(
-    implementation = _assemble_static_files_impl,
-    attrs = {
-        "app_files": attr.label(
-            mandatory = True,
-        ),
-        "srcs": attr.label_list(
-            mandatory = True,
-            allow_files = True,
-        ),
-        "replace_prefixes": attr.string_dict(
-            mandatory = True,
-        ),
-    },
-    toolchains = ["@aspect_bazel_lib//lib:copy_to_directory_toolchain_type"],
-)
-
 def _assemble_service_worker_files_impl(ctx):
     args = ctx.actions.args()
     args.add_all(ctx.attr._package.files, before_each = "--input_dir", expand_directories = False)
diff --git a/scouting/www/test/authorize/BUILD b/scouting/www/test/authorize/BUILD
index af8bc70..e982298 100644
--- a/scouting/www/test/authorize/BUILD
+++ b/scouting/www/test/authorize/BUILD
@@ -1,8 +1,8 @@
 load("@aspect_rules_js//js:defs.bzl", "js_test")
 load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
 load("@npm//:defs.bzl", "npm_link_all_packages")
-load("//scouting/www:defs.bzl", "assemble_static_files")
 load("//tools/build_rules:js.bzl", "ng_application")
+load("//tools/build_rules/js:static.bzl", "assemble_static_files")
 
 npm_link_all_packages(name = "node_modules")
 
diff --git a/tools/build_rules/js.bzl b/tools/build_rules/js.bzl
index d7096a9..a95d5f2 100644
--- a/tools/build_rules/js.bzl
+++ b/tools/build_rules/js.bzl
@@ -84,8 +84,8 @@
       assets: assets to include in the file bundle
       visibility: visibility of the primary targets ({name}, 'test', 'serve')
     """
-    assets = assets if assets else native.glob(["assets/**/*"])
-    html_assets = html_assets if html_assets else []
+    assets = assets if assets != None else native.glob(["assets/**/*"])
+    html_assets = html_assets or []
 
     test_spec_srcs = native.glob(["app/**/*.spec.ts"])
 
diff --git a/tools/build_rules/js/static.bzl b/tools/build_rules/js/static.bzl
new file mode 100644
index 0000000..a2fe2ee
--- /dev/null
+++ b/tools/build_rules/js/static.bzl
@@ -0,0 +1,37 @@
+load("@aspect_bazel_lib//lib:copy_to_directory.bzl", "copy_to_directory_bin_action")
+
+def _assemble_static_files_impl(ctx):
+    out_dir = ctx.actions.declare_directory(ctx.label.name)
+
+    copy_to_directory_bin = ctx.toolchains["@aspect_bazel_lib//lib:copy_to_directory_toolchain_type"].copy_to_directory_info.bin
+
+    copy_to_directory_bin_action(
+        ctx,
+        dst = out_dir,
+        name = ctx.label.name,
+        copy_to_directory_bin = copy_to_directory_bin,
+        files = ctx.files.srcs + ctx.attr.app_files.files.to_list(),
+        replace_prefixes = ctx.attr.replace_prefixes,
+    )
+
+    return [DefaultInfo(
+        files = depset([out_dir]),
+        runfiles = ctx.runfiles([out_dir]),
+    )]
+
+assemble_static_files = rule(
+    implementation = _assemble_static_files_impl,
+    attrs = {
+        "app_files": attr.label(
+            mandatory = True,
+        ),
+        "srcs": attr.label_list(
+            mandatory = True,
+            allow_files = True,
+        ),
+        "replace_prefixes": attr.string_dict(
+            mandatory = True,
+        ),
+    },
+    toolchains = ["@aspect_bazel_lib//lib:copy_to_directory_toolchain_type"],
+)
diff --git a/tools/python/requirements.lock.txt b/tools/python/requirements.lock.txt
index 61a4d62..d991d66 100644
--- a/tools/python/requirements.lock.txt
+++ b/tools/python/requirements.lock.txt
@@ -8,55 +8,56 @@
     --hash=sha256:1e3c502a0a8205338fc74dadbfa321f8a0965441b39501e36796a47b4017b642 \
     --hash=sha256:d824961e4265367b0750ce58b07e564ad0b83ca64b335521cd3421e9b9f10d89
     # via -r tools/python/requirements.txt
-casadi==3.6.5 \
-    --hash=sha256:0118637823e292a9270133e02c9c6d3f3c7f75e8c91a6f6dc5275ade82dd1d9d \
-    --hash=sha256:02d6fb63c460abd99a450e861034d97568a8aec621fc0a4fed22f7494989c682 \
-    --hash=sha256:092e448e05feaed8958d684e896d909e756d199b84d3b9d0182da38cd3deebf6 \
-    --hash=sha256:0a38bf808bf51368607c64307dd77a7363fbe8e5c910cd5c605546be60edfaff \
-    --hash=sha256:0d6ee0558b4ecdd8aa4aa70fd31528b135801f1086c28a9cb78d8e8242b7aedd \
-    --hash=sha256:0e4a4ec2e26ebeb22b0c129f2db3cf90f730cf9fbe98adb9a12720ff6ca1834a \
-    --hash=sha256:1ce199a4ea1d376edbe5399cd622a4564040c83f50c50114fe50a69a8ea81d92 \
-    --hash=sha256:1ddb6e4afdd1da95d7d9d652ed973c1b7f50ef1454965a9170b657e223a2c73e \
-    --hash=sha256:314886ef44bd01f1a98579e7784a3bed6e0584e88f9465cf9596af2523efb0dd \
-    --hash=sha256:32644c47fbfb643d5cf9769c7bbc94c6bdb9a40ea9c12c54af5e2754599c3186 \
-    --hash=sha256:33afd1a4da0c86b4316953fe541635a8a7dc51703282e24a870ada13a46adb53 \
-    --hash=sha256:35b2ff6098e386a4d5e8bc681744e52bcd2f2f15cfa44c09814a8979b51a6794 \
-    --hash=sha256:3a3fb8af868f83d4a4f26d878c49f4acc4ed7ee92e731c73e650e5893418a634 \
-    --hash=sha256:3bdd645151beda013af5fd019fb554756e7dac37541b9f120cdfba90405b2671 \
-    --hash=sha256:409a5f6725eadea40fddfb8ba2321139b5252edac8bc115a72f68e648631d56a \
-    --hash=sha256:5266fc82e39352e26cb1a4e0a5c3deb32d09e6333be637bd78c273fa50f9012b \
-    --hash=sha256:5e8adffe2015cde370fc545b2d0fe731e96e583e4ea4c5f3044e818fea975cfc \
-    --hash=sha256:601b76b7afcb27b11563999f6ad1d9d2a2510ab3d00a6f4ce86a0bee97c9d17a \
-    --hash=sha256:6039081fdd1daf4ef7fa2b52814a954d75bfc03eb0dc62414e02af5d25746e8f \
-    --hash=sha256:7ea8545579872b6f5412985dafec26b906b67bd4639a6c718b7e07f802af4e42 \
-    --hash=sha256:83e3404de4449cb7382e49d811eec79cd370e64b97b5c94b155c604d7c523a40 \
-    --hash=sha256:8bbfb2eb8cb6b9e2384814d6427e48bcf6df049bf7ed05b0a58bb311a1fbf18c \
-    --hash=sha256:a1ae36449adec534125d4af5be912b6fb9dafe74d1fee39f6c82263695e21ca5 \
-    --hash=sha256:af95de5aa5942d627d43312834791623384c2ad6ba87928bf0e3cacc8a6698e8 \
-    --hash=sha256:b5192dfabf6f5266b168b984d124dd3086c1c5a408c0743ff3a82290a8ccf3b5 \
-    --hash=sha256:bceb69bf9f04fded8a564eb64e298d19e945eaf4734f7145a5ee61cf9ac693e7 \
-    --hash=sha256:be40e9897d80fb72a97e750b2143c32f63f8800cfb78f9b396d8ce7a913fca39 \
-    --hash=sha256:bebd3909db24ba711e094aacc0a2329b9903d422d73f61be851873731244b7d1 \
-    --hash=sha256:c661fe88a93b7cc7ea42802aac76a674135cd65e3e564a6f08570dd3bea05201 \
-    --hash=sha256:c6789c8060a99b329bb584d97c1eab6a5e4f3e2d2db391e6c2001c6323774990 \
-    --hash=sha256:c951031e26d987986dbc334492b2e6ef108077f11c00e178ff4007e4a9bf91d8 \
-    --hash=sha256:c98e68023c9e5905d9d6b99ae1fbbfe4b85ba9846b3685408bb498b20509f99a \
-    --hash=sha256:caf395d1e36bfb215b154e8df61583d534a07ddabb18cbe50f259b7692a41ac8 \
-    --hash=sha256:ccb962ea02b7d6d245d5cd40fb52c29e812040a45273c6eed32cb8fcff673dda \
-    --hash=sha256:d12b67d467a5b2b0a909378ef7231fbc9af0da923baa13b1d5464d8471601ac3 \
-    --hash=sha256:dbeb50726603454a1f85323cba7caf72524cd43ca0aeb1f286d07005a967ece9 \
-    --hash=sha256:deb2cb2bee8aba0c2cad03c832965b51ca305d0f8eb15de8b857ba86a76f0db0 \
-    --hash=sha256:e40afb3c062817dd6ce2497cd001f00f107ee1ea41ec4d6ee9f9a5056d219e83 \
-    --hash=sha256:e44af450ce944649932f9ef63ff00d2d21f642b506444418b4b20e69dba3adaf \
-    --hash=sha256:e96ca81b00b9621007d45db1254fcf232d518ebcc802f42853f57b4df977c567 \
-    --hash=sha256:eb311088dca5359acc05aa4d8895bf99afaa16c7c04b27bf640ce4c2361b8cde \
-    --hash=sha256:ee5a4ed50d2becd0bd6d203c7a60ffad27c14a3e0ae357480de11c846a8dd928 \
-    --hash=sha256:f62f779481b30e5ea88392bdb8225e9545a21c4460dc3e96c2b782405b938d04 \
-    --hash=sha256:f6e10b66d6ae8216dab01532f7ad75cc9d66a95125d421b33d078a51ea0fc2a0 \
-    --hash=sha256:f9c1de9a798767c00f89c27677b74059df4c9601d69270967b06d7fcff204b4d \
-    --hash=sha256:f9e82658c910e3317535d769334260e0a24d97bbce68cadb72f592e9fcbafd61 \
-    --hash=sha256:fe2b64d777e36cc3f101220dd1e219a0e11c3e4ee2b5e708b30fea9a27107e41 \
-    --hash=sha256:febc645bcc0aed6d7a2bdb6e58b9a89cb8f74b19bc028c41cc807d75a5d54058
+casadi==3.6.6 \
+    --hash=sha256:0870df9ac7040c14b35fdc82b74578ccfe8f1d9d8615eb79693a560fefb42307 \
+    --hash=sha256:0fd493876c673ad149b03513c4f72275611643f2225f4f5d7c7ff828f75805a1 \
+    --hash=sha256:104601d37ab7ebf897bce7e097823bb090dd7629a7cc4c2e76780f46fc0e59f6 \
+    --hash=sha256:1111aed9afee22c52ba824d60e189a93b8db379d3749ea6d51434c796c7db74f \
+    --hash=sha256:13cdb06c702e1dc666087832516863516a80219fb97923b4da57561454225f0f \
+    --hash=sha256:17c9d369aa55c5410002e038f2d9e010595877e6e7a5671413c468e0f6d0ef54 \
+    --hash=sha256:17cdd6c7beb49f335f554938a39a7d96d329545ac786da277bedf3fa1472bde5 \
+    --hash=sha256:1af055099cf650d5be35dc19fb093b1d6a7e9b11c6ab47746044a3783e268bdf \
+    --hash=sha256:2f6234b9ed2718a7e07b65cfeff9740112664e1d833c867971913be18ebb7a89 \
+    --hash=sha256:3b1fc585faf0d41aa7fd41b0ac1079b8eda5ad8cc8d0cc042cfceb44352827f5 \
+    --hash=sha256:3bab3f398b4b4ea39d861c19a99d95ae3210fa1cb8acee2903538dcdb415b253 \
+    --hash=sha256:45ede033cb063751ffd7c66b29122e6a36b43e9b67c72d4c6f87e8c59506feb4 \
+    --hash=sha256:473f553ac8b94c80e4a88c3373e50910769fc7838f8cd5e2dfb63964657bf585 \
+    --hash=sha256:4a8e0660f19a18bc9c2ebe2da6e76589e464c613045dfd623e373d9638ace507 \
+    --hash=sha256:4bc3d0f461d131407444386e63b2752ae60745f705fb7a587341a84103ac774a \
+    --hash=sha256:4eba456c01575d0f649005902be9a62db71154674caf8f5bd68f7eaa91870bbe \
+    --hash=sha256:55b3b4b1c548f6af8fa0703b6bf848540da26becd8cd5fa1c73887b16240ea5f \
+    --hash=sha256:583f9f7044f7bb235fab5ffff0d2a7ad1e57a1128943e920058211cb213ba68a \
+    --hash=sha256:5d3fb9358a0570b539893b1bd85219ba5398d03cf2f6a7d07ccbc1a053e424c7 \
+    --hash=sha256:6c91ed045236475ceba515a6f9fc37dbb9460647cdbd8f9e94ce79ec1d91c852 \
+    --hash=sha256:6cd3e42e29c1011eb644b5686df309935febec80c2a5893f12f226b3dc710f21 \
+    --hash=sha256:7197e0ba22472227e2e85f4ba6f7f1a8624fb2675c9a0e92da1ac4f3927b603d \
+    --hash=sha256:7ce6dc242809484f36c45685e624919641dbebce54b7437a639d532d323163ee \
+    --hash=sha256:81ef7b8764e9deff7da59ba7d19a107ae3da33e9c4e6420f9a02f290448f9bab \
+    --hash=sha256:826428d2c84b2fffcddaa720ba176d3dba294fe337045a997a7f4febb1030231 \
+    --hash=sha256:84ae0d7416dbcd7167551171aa915346153f0b9a4b15ca52aba63204761721b0 \
+    --hash=sha256:86b23093f3423120e2635b965b8eacae6c45dd22923cb1eaf3c05bfb21db5a32 \
+    --hash=sha256:878ca90794f24467955979a7eec7802f7e11adf40ef390878d211449d7d299a8 \
+    --hash=sha256:90c6cc0afa9ee4c6d9d4f8b3ce72b1bc8d873503227627dd8d645cddc9877fe9 \
+    --hash=sha256:91209bc70764a50ddd1d8fb3f38974baed1419ead64b241541aff47a3dddbeda \
+    --hash=sha256:95c23a2b81399a711ffbf1997856f35f270bbf0ad6f265fe7499fe1e76074d0f \
+    --hash=sha256:9b7c610d67f5452b61e194e224fee679c0e708027821bc90f93548cc805a3744 \
+    --hash=sha256:a63a7d7a74f3242f1cf6e7b51aab01fcf26124809894db9b52ba32e7b8b408a3 \
+    --hash=sha256:aa4840aeb5457728ec70cf6b30142bfb9215378b1b8e622c8d799c03f9c1a0c2 \
+    --hash=sha256:bbfa84129c534e17f4abfeb42b3f6d8d86b8602f93b756b73a9e43febfb9f1f0 \
+    --hash=sha256:bc9a9376ff412eecff0499c521486b7245d98e1022124c664ba72df8df88bde6 \
+    --hash=sha256:cc3594b348f306018b142638d0a8c4026f80e996b4e9798fc504899256a7b029 \
+    --hash=sha256:d1a9d2d7bbd495592d2148031b8a76eaeb096cd49ee6638684dbe3ea59bf1777 \
+    --hash=sha256:da7c8be781a457852810f69167897819ceee96afe061df9f9270b709ddc09e78 \
+    --hash=sha256:dc77c9093cdd642bea58f4e8bd4413e19397d77270b2659f4b0e06f23e575364 \
+    --hash=sha256:dd63609c54f3aaabfbfc4d51948d863ad3dfef55c66f689e9a2b96eef2a0bc96 \
+    --hash=sha256:e2d76092d04847ee6b06adc25cfc8227590cde403cd1fa7a2a621e1accbf5d1a \
+    --hash=sha256:e969f8d4fe41f00474dc7fdc250007a1bb97f6872ccc202cca5211214340ef58 \
+    --hash=sha256:eae6395efafb03509f202ce5f0ff6a35382cf61912bdd65d72f5c246aa1922d9 \
+    --hash=sha256:ed024102d03713db772cb98fe5147c3ff59e0aea3113c2e80b86b4e76dfeb163 \
+    --hash=sha256:ed44f4976f9fd940ca28ccf7e268da44b5e6bfd0ba12734dcd7d49cc749f4325 \
+    --hash=sha256:f0c5593b636980d2c434beecdd2a15de7ae69b1c42bd1fbdb46d1599d40c04b9 \
+    --hash=sha256:f5c0e9312e58f4a35f7b7a009b423bfddd1adc065447cae248dce686cec5b08d \
+    --hash=sha256:ffcba96fe3695223c9f88b9d870cbeb08b7adb92929bfe15bad0e2143e62790d
     # via -r tools/python/requirements.txt
 certifi==2022.9.24 \
     --hash=sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14 \
diff --git a/tools/python/requirements.txt b/tools/python/requirements.txt
index 086b138..48b58ff 100644
--- a/tools/python/requirements.txt
+++ b/tools/python/requirements.txt
@@ -24,4 +24,4 @@
 bokeh
 tabulate
 
-casadi
+casadi>=3.6.6
diff --git a/tools/python/whl_overrides.json b/tools/python/whl_overrides.json
index f19d373..ccb50a1 100644
--- a/tools/python/whl_overrides.json
+++ b/tools/python/whl_overrides.json
@@ -3,9 +3,9 @@
         "sha256": "1e3c502a0a8205338fc74dadbfa321f8a0965441b39501e36796a47b4017b642",
         "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/bokeh-3.4.1-py3-none-any.whl"
     },
-    "casadi==3.6.5": {
-        "sha256": "8bbfb2eb8cb6b9e2384814d6427e48bcf6df049bf7ed05b0a58bb311a1fbf18c",
-        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/casadi-3.6.5-cp39-none-manylinux2014_x86_64.whl"
+    "casadi==3.6.6": {
+        "sha256": "cc3594b348f306018b142638d0a8c4026f80e996b4e9798fc504899256a7b029",
+        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/casadi-3.6.6-cp39-none-manylinux2014_x86_64.whl"
     },
     "certifi==2022.9.24": {
         "sha256": "90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382",