diff --git a/README.md b/README.md index 7dc325b1..e14c11ad 100755 --- a/README.md +++ b/README.md @@ -137,43 +137,63 @@ python tutorials/multi_robot_3d_reachavoid.py Ellipsoidal-obstacle CBF
- Ellipsoidal-obstacle CBF
Unicycle reach-goal with linear class-K
+ Ellipsoidal-obstacle CBF ๐Ÿ”—
Unicycle reach-goal with linear class-K
Stochastic CBF
- Stochastic CBF (SDE)
Safety under Brownian disturbance
+ Stochastic CBF (SDE) ๐Ÿ”—
Safety under Brownian disturbance
Robust CBF
- Robust CBF
Worst-case bounded disturbance
+ Robust CBF ๐Ÿ”—
Worst-case bounded disturbance
MPPI rollouts
- MPPI rollout sampling
Sampling-based planning
+ MPPI rollout sampling ๐Ÿ”—
Sampling-based planning
MPPI reach-avoid
- MPPI reach-avoid
Sampling-based planning with goal + obstacle cost
+ MPPI reach-avoid ๐Ÿ”—
Sampling-based planning with goal + obstacle cost
Multi-robot 2D coordination
- Multi-robot 2D
Coordination via shared CBFs
+ Multi-robot 2D ๐Ÿ”—
Coordination via shared CBFs
Fixed-wing aerial 3D
- Fixed-wing aerial 3D
UAV reach-drop-point in 3D
+ Fixed-wing aerial 3D ๐Ÿ”—
UAV reach-drop-point in 3D
Pedestrian head-on
- Pedestrian head-on
Dynamic-agent avoidance
+ Pedestrian head-on ๐Ÿ”—
Dynamic-agent avoidance
EKF state estimation
- EKF state estimation
CBF over noisy estimates
+ EKF state estimation ๐Ÿ”—
Unicycle reach-goal under measurement noise
+ + + + + Van der Pol CLF
+ Van der Pol (CLF) ๐Ÿ”—
Nonlinear regulation to the origin
+ + + Model Predictive Control
+ Model Predictive Control ๐Ÿ”—
Receding-horizon LTI tracking
+ + + Quadrotor 6-DOF geometric tracking
+ Quadrotor 6-DOF ๐Ÿ”—
Geometric SE(3) tracking + altitude CBF
+ + + + + Monte Carlo safety verification
+ Monte Carlo safety verification ๐Ÿ”—
200 stochastic CBF rollouts (jax.vmap), live empirical risk
diff --git a/examples/unicycle/reach_goal/ekf.py b/examples/unicycle/reach_goal/ekf.py index 381f0e96..96564da0 100755 --- a/examples/unicycle/reach_goal/ekf.py +++ b/examples/unicycle/reach_goal/ekf.py @@ -104,7 +104,8 @@ def dhdx(x): x_lim=(-5, 5), y_lim=(-5, 5), dt=dt, - title="System Behavior", + title="EKF State Estimation", save_animation=save, animation_filename="examples/unicycle/reach_goal/results/ekf_estimation.gif", + backend="matplotlib", ) diff --git a/examples/unicycle/reach_goal/mppi_cbf.py b/examples/unicycle/reach_goal/mppi_cbf.py index 85856588..3ee68b50 100644 --- a/examples/unicycle/reach_goal/mppi_cbf.py +++ b/examples/unicycle/reach_goal/mppi_cbf.py @@ -219,8 +219,8 @@ def terminal_cost(state: Array, action: Array) -> Array: title="System Behavior (MPPI + CBF)", obstacles=obstacles, ellipsoids=ellipsoids, - save_animation=False, - animation_filename=file_path + "bh_mppi_cbf_control", + save_animation=save, + animation_filename=file_path + "mppi_rollouts.gif", ) final_pos = x[-1, :2] diff --git a/media/showcase/ekf_estimation.gif b/media/showcase/ekf_estimation.gif index be08a74a..9818451d 100644 Binary files a/media/showcase/ekf_estimation.gif and b/media/showcase/ekf_estimation.gif differ diff --git a/media/showcase/monte_carlo_safety.gif b/media/showcase/monte_carlo_safety.gif new file mode 100644 index 00000000..af7a34f7 Binary files /dev/null and b/media/showcase/monte_carlo_safety.gif differ diff --git a/media/showcase/mpc_double_integrator.gif b/media/showcase/mpc_double_integrator.gif new file mode 100644 index 00000000..5e9f6768 Binary files /dev/null and b/media/showcase/mpc_double_integrator.gif differ diff --git a/media/showcase/mppi_rollouts.gif b/media/showcase/mppi_rollouts.gif index 0ba2fcf0..4e395497 100644 Binary files a/media/showcase/mppi_rollouts.gif and b/media/showcase/mppi_rollouts.gif differ diff --git a/media/showcase/pedestrian_head_on.gif b/media/showcase/pedestrian_head_on.gif index cfb47c29..a8592bbb 100644 Binary files a/media/showcase/pedestrian_head_on.gif and b/media/showcase/pedestrian_head_on.gif differ diff --git a/media/showcase/quadrotor_6dof.gif b/media/showcase/quadrotor_6dof.gif new file mode 100644 index 00000000..bcea4043 Binary files /dev/null and b/media/showcase/quadrotor_6dof.gif differ diff --git a/media/showcase/van_der_pol_clf.gif b/media/showcase/van_der_pol_clf.gif new file mode 100644 index 00000000..f8740511 Binary files /dev/null and b/media/showcase/van_der_pol_clf.gif differ diff --git a/scripts/render_showcase.py b/scripts/render_showcase.py index eec41655..989047e8 100644 --- a/scripts/render_showcase.py +++ b/scripts/render_showcase.py @@ -1023,5 +1023,507 @@ def update(i): return str(out) +@register("van_der_pol_clf") +def render_van_der_pol_clf() -> str: + """Van der Pol: Lyapunov-based regulation of a nonlinear oscillator to the origin.""" + import jax.numpy as jnp + import matplotlib.pyplot as plt + import numpy as np + from jax import jit + from matplotlib.animation import FuncAnimation, PillowWriter + + import cbfkit.simulation.simulator as sim + from cbfkit.estimators import naive as estimator + from cbfkit.integration import runge_kutta_4 as integrator + from cbfkit.sensors import perfect as sensor + from cbfkit.systems import van_der_pol + from cbfkit.utils.user_types import ControllerData + + epsilon = 0.2 + dyn = van_der_pol.reverse_van_der_pol_oscillator(epsilon=epsilon) + + # Lyapunov-based regulation law. The plant's input matrix g = [0, 1/x2] is singular, + # so the control is formed as u = x2 * (...) to cancel the 1/x2 amplification โ€” which is + # exactly why the packaged closed-form FxTS law cannot be dropped onto this model directly. + def regulation_controller(eps, k1=4.0, k2=4.0): + @jit + def controller(_t, x, _key, _xd=None): + x1, x2 = x + u = x2 * ((k1 - 1.0) * x1 - k2 * x2 + eps * (1.0 - x1**2) * x2) + return jnp.array([u]), ControllerData() + + return controller + + x0 = jnp.array([2.0, 2.0]) + dt, tf = 1e-3, 5.0 + n = int(tf / dt) + res = sim.execute( + x0=x0, + dt=dt, + num_steps=n, + dynamics=dyn, + integrator=integrator, + nominal_controller=regulation_controller(epsilon), + sensor=sensor, + estimator=estimator, + use_jit=True, + ) + states = np.asarray(res["states"]) + + fig, ax = plt.subplots(figsize=(6, 6)) + # Open-loop Van der Pol vector field: shows the nonlinearity the Lyapunov law tames. + gx = np.linspace(-3.0, 3.0, 22) + GX, GY = np.meshgrid(gx, gx) + FX = -GY + FY = GX - epsilon * (1.0 - GX**2) * GY + mag = np.hypot(FX, FY) + 1e-9 + ax.quiver(GX, GY, FX / mag, FY / mag, color="gray", alpha=0.35, width=0.003) + ax.add_patch(plt.Circle((0, 0), 0.1, color="green", alpha=0.3)) + ax.plot(0, 0, "g*", markersize=18, label="Origin (goal)") + (line,) = ax.plot([], [], "b-", lw=2) + dot = ax.scatter([], [], s=80, color="blue", zorder=5) + ax.set_xlim(-3, 3) + ax.set_ylim(-3, 3) + ax.set_aspect("equal") + ax.set_xlabel("$x_1$") + ax.set_ylabel("$x_2$") + ax.set_title("Van der Pol โ€” Lyapunov regulation to the origin", fontsize=10) + ax.legend(loc="upper right", fontsize=9) + ax.grid(True, alpha=0.3) + + def update(i): + line.set_data(states[: i + 1, 0], states[: i + 1, 1]) + dot.set_offsets([[states[i, 0], states[i, 1]]]) + return line, dot + + stride = max(1, len(states) // 70) + anim = FuncAnimation(fig, update, frames=range(0, len(states), stride), interval=100, blit=True) + out = OUT / "van_der_pol_clf.gif" + anim.save(out, writer=PillowWriter(fps=10)) + plt.close(fig) + return str(out) + + +@register("mpc_double_integrator") +def render_mpc_double_integrator() -> str: + """Classical receding-horizon MPC: LTI double-integrator tracking to a goal. + + Honest framing: this solver carries only equality (dynamics) constraints, so it is + reference tracking, not a safety filter. Driven as a standalone receding-horizon loop. + """ + import jax.numpy as jnp + import matplotlib.pyplot as plt + import numpy as np + from matplotlib.animation import FuncAnimation, PillowWriter + + from cbfkit.optimization.mpc.quadratic_cost_linear_dynamics import ( + generate_mpc_solver_quadratic_cost_linear_dynamics, + ) + + dt = 0.1 + # Discrete-time double integrator: state [px, py, vx, vy], control [ax, ay]. + A = jnp.array( + [[1.0, 0.0, dt, 0.0], [0.0, 1.0, 0.0, dt], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]] + ) + B = jnp.array([[0.0, 0.0], [0.0, 0.0], [dt, 0.0], [0.0, dt]]) + Q = jnp.diag(jnp.array([10.0, 10.0, 1.0, 1.0])) + R = 0.1 * jnp.eye(2) + Qn = 50.0 * Q + N = 20 + solve = generate_mpc_solver_quadratic_cost_linear_dynamics(A, B, Q, R, Qn, N) + + goal = jnp.array([4.0, 4.0, 0.0, 0.0]) + ref_horizon = jnp.tile(goal, (N, 1)) # (N, 4) constant reference over the horizon + x = jnp.array([0.0, 0.0, 0.0, 0.0]) + n_steps = 40 + + xs = [np.asarray(x)] + preds = [] + for _ in range(n_steps): + concatenated_x_xr = jnp.vstack([x.reshape(1, -1), ref_horizon]) # (N+1, 4) + x_opt, u_opt = solve(concatenated_x_xr) # x_opt (4, N+1), u_opt (2, N) + u = u_opt[:, 0] + x = A @ x + B @ u + xs.append(np.asarray(x)) + preds.append(np.asarray(x_opt.T)) # (N+1, 4) predicted state horizon + xs = np.array(xs) + + fig, ax = plt.subplots(figsize=(6, 6)) + ax.plot(float(goal[0]), float(goal[1]), "g*", markersize=18, label="Goal") + (realized,) = ax.plot([], [], "b-", lw=2, label="Realized") + (pred,) = ax.plot( + [], [], color="orange", ls="--", lw=1.5, alpha=0.85, label="Predicted horizon" + ) + dot = ax.scatter([], [], s=80, color="blue", zorder=5) + ax.set_xlim(-0.5, 4.5) + ax.set_ylim(-0.5, 4.5) + ax.set_aspect("equal") + ax.set_xlabel("x") + ax.set_ylabel("y") + ax.set_title("Model Predictive Control โ€” receding-horizon LTI tracking", fontsize=10) + ax.legend(loc="lower right", fontsize=9) + ax.grid(True, alpha=0.3) + + def update(i): + realized.set_data(xs[: i + 1, 0], xs[: i + 1, 1]) + dot.set_offsets([[xs[i, 0], xs[i, 1]]]) + p = preds[min(i, len(preds) - 1)] + pred.set_data(p[:, 0], p[:, 1]) + return realized, pred, dot + + anim = FuncAnimation(fig, update, frames=len(xs), interval=100, blit=True) + out = OUT / "mpc_double_integrator.gif" + anim.save(out, writer=PillowWriter(fps=10)) + plt.close(fig) + return str(out) + + +@register("quadrotor_6dof") +def render_quadrotor_6dof() -> str: + """6-DOF quadrotor: geometric SE(3) tracking + live CBF altitude-envelope value. + + Honest framing: we drive the quadrotor through 3D space with the geometric + controller (Lee-Leok-McClamroch) and display the altitude-CBF barrier value + h(z) alongside, demonstrating the available CBF certificate without claiming + an active barrier-projection filter (which the geometric controller doesn't + natively expose). + """ + import jax.numpy as jnp + import matplotlib.pyplot as plt + import numpy as np + from matplotlib.animation import FuncAnimation, PillowWriter + from mpl_toolkits.mplot3d import Axes3D # noqa: F401 โ€” registers 3D projection + + import cbfkit.simulation.simulator as sim + from cbfkit.estimators import naive as estimator + from cbfkit.integration import runge_kutta_4 as integrator + from cbfkit.sensors import perfect as sensor + from cbfkit.systems.quadrotor_6dof.certificates.barrier_functions import h_alt + from cbfkit.systems.quadrotor_6dof.controllers.geometric import geometric_controller + from cbfkit.systems.quadrotor_6dof.models.quadrotor_6dof_dynamics import ( + quadrotor_6dof_dynamics, + ) + + # Mass/inertia must be consistent between plant and controller: geometric_controller's + # default gains are tuned for mโ‰ˆ4.34 kg, while quadrotor_6dof_dynamics defaults to + # m=0.25 kg. Mismatch -> instant integration NaN. Use the heavier plant. + m, jx, jy, jz = 4.34, 0.0820, 0.0845, 0.1377 + three_tuple = quadrotor_6dof_dynamics(m=m, jx=jx, jy=jy, jz=jz) + + def dyn(x): + f, g, _s = three_tuple(x) + return f, g + + desired = jnp.array([2.0, 1.5, 3.0]) # target (pn, pe, h) + dt = 0.01 + tf = 6.0 + n = int(tf / dt) + + # state layout: [pn, pe, h, u, v, w, phi, theta, psi, p, q, r] + x0 = jnp.array([0.0, 0.0, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]) + + nominal = geometric_controller( + dynamics=dyn, desired_state=desired, dt=dt, m=m, jx=jx, jy=jy, jz=jz + ) + + res = sim.execute( + x0=x0, + dt=dt, + num_steps=n, + dynamics=dyn, + integrator=integrator, + nominal_controller=nominal, + sensor=sensor, + estimator=estimator, + use_jit=True, + ) + states = np.asarray(res["states"]) # (n+1, 12) + + # Altitude-CBF barrier value h_alt(z, alt_limit). z = hstack([x, t]). + # alt_limit must comfortably exceed our setpoint altitude (3 m) โ€” pick 5 m. + alt_limit = 5.0 + n_states_full = states.shape[0] + ts = np.linspace(0.0, tf, n_states_full) + h_vals = np.array( + [ + float(h_alt(jnp.hstack([jnp.asarray(states[i]), jnp.asarray(ts[i])]), alt_limit)) + for i in range(n_states_full) + ] + ) + + # Subsample frames for a compact GIF. + stride = max(1, n_states_full // 80) + idx = np.arange(0, n_states_full, stride) + pn, pe, h_alt_traj = states[idx, 0], states[idx, 1], states[idx, 2] + + fig = plt.figure(figsize=(10, 5)) + ax3d = fig.add_subplot(1, 2, 1, projection="3d") + ax_h = fig.add_subplot(1, 2, 2) + + ax3d.scatter( + [float(desired[0])], + [float(desired[1])], + [float(desired[2])], + color="green", + s=120, + marker="*", + label="Goal", + zorder=10, + ) + (line3d,) = ax3d.plot([], [], [], "b-", lw=2, label="Quadrotor") + dot3d = ax3d.scatter([], [], [], s=60, color="blue", zorder=11) + pad = 0.5 + ax3d.set_xlim(min(pn.min(), float(desired[0])) - pad, max(pn.max(), float(desired[0])) + pad) + ax3d.set_ylim(min(pe.min(), float(desired[1])) - pad, max(pe.max(), float(desired[1])) + pad) + ax3d.set_zlim(0, alt_limit + 0.5) + ax3d.set_xlabel("pn [m]") + ax3d.set_ylabel("pe [m]") + ax3d.set_zlabel("h [m]") + ax3d.set_title("Quadrotor 6-DOF โ€” geometric SE(3) tracking", fontsize=10) + ax3d.legend(loc="upper right", fontsize=8) + ax3d.view_init(elev=22, azim=-60) + + # h(z) trace: stays >0 โ‡’ altitude envelope satisfied. + ax_h.plot(ts, h_vals, color="purple", lw=1.5) + (h_dot,) = ax_h.plot([], [], "o", color="purple", markersize=7) + ax_h.axhline(0.0, color="red", ls="--", lw=1, alpha=0.7, label="Safety boundary h=0") + ax_h.set_xlim(0, tf) + ax_h.set_ylim(min(0.0, float(h_vals.min())) - 0.1, max(1.0, float(h_vals.max())) + 0.1) + ax_h.set_xlabel("t [s]") + ax_h.set_ylabel("$h_{\\rm alt}(z)$") + ax_h.set_title("Altitude-CBF barrier value (positive โ‡’ safe)", fontsize=10) + ax_h.legend(loc="lower right", fontsize=8) + ax_h.grid(True, alpha=0.3) + + def update(i): + line3d.set_data(pn[: i + 1], pe[: i + 1]) + line3d.set_3d_properties(h_alt_traj[: i + 1]) + dot3d._offsets3d = ([pn[i]], [pe[i]], [h_alt_traj[i]]) + # Map subsampled index back to full-resolution h_vals index for the dot. + full_i = idx[i] + h_dot.set_data([ts[full_i]], [h_vals[full_i]]) + return line3d, dot3d, h_dot + + anim = FuncAnimation(fig, update, frames=len(idx), interval=100, blit=False) + out = OUT / "quadrotor_6dof.gif" + anim.save(out, writer=PillowWriter(fps=10)) + plt.close(fig) + return str(out) + + +@register("monte_carlo_safety") +def render_monte_carlo_safety() -> str: + """GPU/vmap Monte Carlo safety funnel: N stochastic single-integrator rollouts kept + safe around an obstacle by a CBF-QP filter, with a live empirical violation-rate counter. + + Each of the N trials gets its own initial state (Gaussian funnel-mouth) and its own + Brownian process noise (Euler-Maruyama), all executed as one ``jax.vmap`` kernel via + ``conduct_monte_carlo_gpu``. The empirical risk = fraction of trials that have entered + the obstacle by the current frame; the CBF holds it at ~0. + """ + import contextlib + import os + + import jax.numpy as jnp + import matplotlib.pyplot as plt + import numpy as np + from jax import random + from matplotlib.animation import FuncAnimation, PillowWriter + from matplotlib.collections import LineCollection + + from cbfkit.controllers.cbf_clf.cbf_clf_qp_generator import cbf_clf_qp_generator + from cbfkit.controllers.cbf_clf.generate_constraints import ( + generate_compute_vanilla_clf_constraints, + generate_compute_zeroing_cbf_constraints, + ) + from cbfkit.integration import forward_euler + from cbfkit.modeling.additive_disturbances import generate_stochastic_perturbation + from cbfkit.simulation.monte_carlo_gpu import MonteCarloSetup, conduct_monte_carlo_gpu + from cbfkit.utils.user_types import CertificateCollection, ControllerData, PlannerData + + # --- Scenario (verified-clean: low alpha keeps the jaxopt QP stable under vmap) --- + GOAL = jnp.array([4.0, 4.0]) + OBS = jnp.array([2.0, 2.0]) + R = 0.6 + ALPHA = 1.0 + NOISE = 0.4 + DT, NSTEPS, N_TRIALS = 0.05, 100, 200 + + def dynamics(x): + return jnp.zeros(2), jnp.eye(2) + + # h(x) = ||x - c||^2 - r^2, relative-degree-1 zeroing barrier (single integrator). + f_h = lambda _t, x: jnp.sum((x - OBS) ** 2) - R**2 # noqa: E731 + j_h = lambda _t, x: 2.0 * (x - OBS) # noqa: E731 + h_h = lambda _t, _x: 2.0 * jnp.eye(2) # noqa: E731 + p_h = lambda _t, _x: 0.0 # noqa: E731 + a_h = lambda h: ALPHA * h # noqa: E731 + barriers = CertificateCollection([f_h], [j_h], [h_h], [p_h], [a_h]) + + controller = cbf_clf_qp_generator( + generate_compute_zeroing_cbf_constraints, + generate_compute_vanilla_clf_constraints, + )( + control_limits=jnp.array([8.0, 8.0]), + dynamics_func=dynamics, + barriers=barriers, + relaxable_cbf=False, + relaxable_clf=True, + ) + + def nominal_controller(t, x, _key, _ref): + return 2.0 * (GOAL - x), None + + def initial_state_sampler(key): + return jnp.array([0.0, 0.0]) + 0.18 * random.normal(key, (2,)) + + def _sensor(t, x, *, sigma=None, key=None): + return x + + def _estimator(t, y, z, u, c): + return y, (c if c is not None else jnp.zeros((len(y), len(y)))) + + # Pass the perturbation UNWRAPPED so its `.is_increment` flag survives (Euler-Maruyama). + perturbation = generate_stochastic_perturbation(sigma=lambda x: NOISE * jnp.eye(2), dt=DT) + + _, c_data = controller(0.0, jnp.zeros(2), jnp.zeros(2), random.PRNGKey(0), ControllerData()) + setup = MonteCarloSetup( + dt=DT, + num_steps=NSTEPS, + dynamics=dynamics, + integrator=forward_euler, + initial_state_sampler=initial_state_sampler, + nominal_controller=nominal_controller, + controller=controller, + sensor=_sensor, + estimator=_estimator, + perturbation=perturbation, + sigma=jnp.zeros(0), + controller_data=c_data, + planner=None, + planner_data=PlannerData(), + ) + + # The CBF-QP controller emits batched jax.debug.print spam under vmap (every branch of + # its status lax.switch fires); silence it at the fd level around the kernel run. + @contextlib.contextmanager + def _silence_fds(): + saved = os.dup(1), os.dup(2) + devnull = os.open(os.devnull, os.O_WRONLY) + os.dup2(devnull, 1) + os.dup2(devnull, 2) + try: + yield + finally: + os.dup2(saved[0], 1) + os.dup2(saved[1], 2) + os.close(devnull) + os.close(saved[0]) + os.close(saved[1]) + + print(f"[monte_carlo_safety] running {N_TRIALS} vmap'd stochastic rollouts...") + with _silence_fds(): + results = conduct_monte_carlo_gpu(setup, n_trials=N_TRIALS, seed=0) + states = np.asarray(results.states) # (N_TRIALS, NSTEPS, 2) + print(f"[monte_carlo_safety] kernel wall time: {results.wall_time_s:.2f}s") + + # Geometric safety check (independent of the controller's internal barrier bookkeeping). + dist = np.linalg.norm(states - np.asarray(OBS), axis=-1) # (N, NSTEPS) + inside = dist < R # (N, NSTEPS) + ever_inside = inside.any(axis=1) # (N,) + # Cumulative empirical violation rate up to each step. + cum_viol_rate = np.array([float(inside[:, : k + 1].any(axis=1).mean()) for k in range(NSTEPS)]) + overall_rate = float(ever_inside.mean()) + print( + f"[monte_carlo_safety] overall empirical violation rate: {overall_rate:.3f} " + f"(min dist to obstacle center {dist.min():.3f}, R={R})" + ) + + # Draw a representative subset to keep the GIF small (the full 200-line translucent tangle + # bloats the palette), but ALWAYS include every breaching trial so the red paths shown stay + # consistent with the empirical-risk counter, which is computed over ALL N_TRIALS. + from matplotlib.lines import Line2D + + N_DRAW = 60 + rng = np.random.default_rng(0) + viol_idx = np.flatnonzero(ever_inside) + safe_idx = np.flatnonzero(~ever_inside) + n_safe_draw = min(len(safe_idx), max(0, N_DRAW - len(viol_idx))) + safe_draw = rng.choice(safe_idx, size=n_safe_draw, replace=False) + draw_idx = np.concatenate([safe_draw, viol_idx]).astype(int) + draw_states = states[draw_idx] # (N_DRAW, NSTEPS, 2) + draw_colors = ["tab:red" if ever_inside[i] else "tab:blue" for i in draw_idx] + + fig, ax = plt.subplots(figsize=(5.0, 5.0)) + ax.add_patch(plt.Circle((float(OBS[0]), float(OBS[1])), R, color="red", alpha=0.3, zorder=1)) + ax.add_patch( + plt.Circle((float(OBS[0]), float(OBS[1])), R, fill=False, color="red", lw=1.5, zorder=2) + ) + ax.plot(float(GOAL[0]), float(GOAL[1]), "g*", markersize=18, zorder=6) + ax.plot(0.0, 0.0, "ks", markersize=6, zorder=6) + + lc = LineCollection([], colors=draw_colors, linewidths=0.5, alpha=0.3, zorder=3) + ax.add_collection(lc) + dots = ax.scatter( + draw_states[:, 0, 0], draw_states[:, 0, 1], s=6, c=draw_colors, alpha=0.7, zorder=4 + ) + txt = ax.text( + 0.03, + 0.97, + "", + transform=ax.transAxes, + va="top", + ha="left", + fontsize=9, + family="monospace", + bbox=dict(boxstyle="round", facecolor="white", alpha=0.85), + ) + + legend_handles = [ + Line2D([0], [0], color="tab:blue", lw=1.5, label="safe rollout"), + Line2D( + [0], [0], marker="*", color="w", markerfacecolor="g", markersize=12, lw=0, label="Goal" + ), + Line2D( + [0], [0], marker="s", color="w", markerfacecolor="k", markersize=7, lw=0, label="Start" + ), + ] + if len(viol_idx) > 0: + legend_handles.insert(1, Line2D([0], [0], color="tab:red", lw=1.5, label="breached")) + + ax.set_xlim(-1.0, 5.0) + ax.set_ylim(-1.0, 5.0) + ax.set_aspect("equal") + ax.set_xlabel("x") + ax.set_ylabel("y") + ax.set_title( + f"Monte Carlo safety verification โ€” {N_TRIALS} stochastic CBF rollouts", fontsize=9 + ) + ax.legend(handles=legend_handles, loc="lower right", fontsize=8) + ax.grid(True, alpha=0.3) + + stride = max(1, NSTEPS // 30) + frame_idx = list(range(0, NSTEPS, stride)) + + def update(k): + lc.set_segments([draw_states[i, : k + 1, :] for i in range(len(draw_idx))]) + dots.set_offsets(draw_states[:, k, :]) + rate = cum_viol_rate[k] + n_viol = int(round(rate * N_TRIALS)) + txt.set_text( + f"step {k + 1:3d}/{NSTEPS}\n" + f"trials {N_TRIALS}\n" + f"violations {n_viol}\n" + f"empirical risk {rate * 100:4.1f}%" + ) + return lc, dots, txt + + anim = FuncAnimation(fig, update, frames=frame_idx, interval=100, blit=False) + out = OUT / "monte_carlo_safety.gif" + anim.save(out, writer=PillowWriter(fps=10), dpi=80) + plt.close(fig) + return str(out) + + if __name__ == "__main__": sys.exit(main()) diff --git a/src/cbfkit/optimization/mpc/quadratic_cost_linear_dynamics.py b/src/cbfkit/optimization/mpc/quadratic_cost_linear_dynamics.py index 167faa78..8e16764c 100644 --- a/src/cbfkit/optimization/mpc/quadratic_cost_linear_dynamics.py +++ b/src/cbfkit/optimization/mpc/quadratic_cost_linear_dynamics.py @@ -116,8 +116,13 @@ def mpc_to_qp( p_bar = jnp.hstack( [ - -Q1a @ concatenated_x_xr[1:, :n_states].T.flatten(), - -Q2a @ concatenated_x_xr[-1, :n_states].T.flatten(), + # Reference must be time-major ([state@t0, state@t1, ...]) to match the + # kron(I_N, Q) cost block and the time-major decision-vector layout. + # A prior `.T` here made it state-major, scrambling per-dimension weights. + # Factor of 2: the solver minimizes xแต€Hx + fแต€x (no ยฝ), so tracking cost + # (xโˆ’r)แต€Q(xโˆ’r) โ‡’ f = โˆ’2Qr; without it the optimum collapses to r/2. + -2.0 * Q1a @ concatenated_x_xr[1:, :n_states].flatten(), + -2.0 * Q2a @ concatenated_x_xr[-1, :n_states].flatten(), jnp.zeros(horizon * n_inputs), ] ) diff --git a/src/cbfkit/systems/quadrotor_6dof/controllers/geometric.py b/src/cbfkit/systems/quadrotor_6dof/controllers/geometric.py index 1e27b340..0e8c750e 100644 --- a/src/cbfkit/systems/quadrotor_6dof/controllers/geometric.py +++ b/src/cbfkit/systems/quadrotor_6dof/controllers/geometric.py @@ -4,7 +4,7 @@ from jax import Array, jit from cbfkit.utils.lqr import compute_lqr_gain -from cbfkit.utils.matrix_vector_operations import hat, normalize, vee +from cbfkit.utils.matrix_vector_operations import normalize, vee from cbfkit.utils.user_types import ( ControllerCallable, ControllerCallableReturns, @@ -12,47 +12,20 @@ DynamicsCallable, ) -from ..certificates.lyapunov_functions import V_pv as V from ..models.quadrotor_6dof_dynamics import g_accel as g from ..utils.rotations import rotation_body_frame_to_inertial_frame -def _extract_drift(dynamics_output: Tuple[Array, ...]) -> Array: - """Extract drift vector from dynamics outputs supporting 2- or 3-tuples. - - Some dynamics call sites return ``(f, g)`` while older/stochastic variants - return ``(f, g, s)``. Geometric control only uses the drift component. - """ - if len(dynamics_output) == 2: - f_val, _ = dynamics_output - return f_val - if len(dynamics_output) == 3: - f_val, _, _ = dynamics_output - return f_val - raise ValueError( - "Expected dynamics callable to return (f, g) or (f, g, s). " - f"Received tuple of length {len(dynamics_output)}." - ) - - def geometric_controller( dynamics: DynamicsCallable, desired_state: Array, dt: float, - # m: float = 0.5, - # jx: float = 0.25, - # jy: float = 0.25, - # jz: float = 0.1, - # kx: float = 1.0, - # kv: float = 2.05, - # kr: float = 0.35, - # ko: float = 0.15, m: float = 4.34, jx: float = 0.0820, jy: float = 0.0845, jz: float = 0.1377, - kx: float = 16.0, - kv: float = 5.6, + kx: float = 8.0, + kv: float = 8.0, kr: float = 8.81, ko: float = 2.54, ) -> ControllerCallable: @@ -84,18 +57,16 @@ def geometric_controller( j_vec = jnp.array([jx, jy, jz]) _b1_d = jnp.array([1.0, 0.0, 0.0]) - # # Flatness-based control -- LQR - # get_desired_pos_vel_acc = lqr_control(desired_state, dt) - - # Flatness-based control -- FxTS - tg = 10.0 - c1, e1, e2 = 0.5, 0.5, 1.5 - c2 = 1 / ((e2 - 1) * (tg - 1 / (c1 * (1 - e1)))) - - def fV(x): - return -c1 * V(x, desired_state) ** e1 - c2 * V(x, desired_state) ** e2 - - get_desired_pos_vel_acc = lyapunov_control(desired_state, dt, fV) + # The packaged body->inertial matrix is orthogonal but *improper* (det = -1): its + # third row is the standard ZYX rotation's third row negated, encoding the model's + # "body z-down, inertial h-up" convention. Geometric SE(3) tracking requires a + # proper rotation, so we left-multiply by S = diag(1, 1, -1) to flip that row back. + # The result is a true SO(3) matrix expressed in the z-down ("NED") inertial frame + # y = S @ p -- exactly the frame the plant's own velocity/gravity terms live in. + # (Left-multiplying S negates the 3rd row and preserves Rdot = R @ hat([p, q, r]); + # a right-multiply would silently remap the body rates to [-p, -q, r] and diverge.) + S = jnp.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, -1.0]]) + pos_d = jnp.matmul(S, desired_state[:3]) @jit def controller( @@ -116,74 +87,32 @@ def controller( u (Array): computed control inputs data (dict): requisite dictionary return """ - nonlocal _b1_d, e3, j_vec - - _, _, _, _, _, _, _, theta, psi, _, _, _ = x - # dynamics returns (f, g), we unpack the first element which is f? - # The original code was: dynamics(x)[0]. This seems to imply dynamics(x) - # returns a tuple/list and the first element is f. - # Assuming dynamics(x) -> (f, g) or similar. - # And unpacking f: _, _, _, _, _, _, phi_dot, theta_dot, psi_dot, _, _, _ = f - f_val = _extract_drift(dynamics(x)) - _, _, _, _, _, _, phi_dot, theta_dot, psi_dot, _, _, _ = f_val - - # Get rotation matrix - body_to_inertial_rotation = rotation_body_frame_to_inertial_frame(x) - - # Compute desired position, velocity, acceleration - pos_d, vel_d, acc_d = get_desired_pos_vel_acc(t, x) - vel_d = jnp.zeros((3,)) - acc_d = jnp.zeros((3,)) - - # Define tracking errors - e_pos = x[:3] - pos_d - e_vel = jnp.matmul(body_to_inertial_rotation, x[3:6]) - vel_d - - # Compute desired attitude and attitude tracking error - b3_d = -normalize(-kx * e_pos - kv * e_vel + m * g * e3 + m * acc_d) + # Proper SO(3) rotation, body frame -> z-down ("NED") inertial frame. + rotation = jnp.matmul(S, rotation_body_frame_to_inertial_frame(x)) + + # Translational tracking errors in the z-down inertial frame (vel_d = 0). + e_pos = jnp.matmul(S, x[:3]) - pos_d + e_vel = jnp.matmul(rotation, x[3:6]) + + # Desired thrust direction (Lee et al. 2010, NED form: gravity acts along +e3). + thrust_vec = -kx * e_pos - kv * e_vel - m * g * e3 + b3_d = -normalize(thrust_vec) b2_d = normalize(jnp.cross(b3_d, _b1_d)) b1_d = normalize(jnp.cross(b2_d, b3_d)) rot_d = jnp.array([b1_d, b2_d, b3_d]).T - e_rot = ( - 1 - / 2 - * vee( - jnp.matmul(rot_d.T, body_to_inertial_rotation) - - jnp.matmul(body_to_inertial_rotation.T, rot_d) - ) - ) - # Compute angular velocity tracking error - wx_b = phi_dot * jnp.sin(theta) * jnp.sin(psi) + theta_dot * jnp.cos(psi) - wy_b = phi_dot * jnp.sin(theta) * jnp.cos(psi) - theta_dot * jnp.sin(psi) - wz_b = phi_dot * jnp.cos(theta) + psi_dot - omega = jnp.array([wx_b, wy_b, wz_b]) - - # Compute rotation tracking error - omega_d = jnp.zeros((3,)) - omega_d_dot = jnp.zeros((3,)) - e_ome = omega - jnp.matmul(body_to_inertial_rotation.T, jnp.matmul(rot_d, omega_d)) - - # Compute force input - f = -jnp.dot( - -kx * e_pos - kv * e_vel + m * g * e3 + m * acc_d, - jnp.matmul(body_to_inertial_rotation, e3), - ) + # Attitude error on SO(3): the vee map is only valid because `rotation` is proper. + e_rot = 1 / 2 * vee(jnp.matmul(rot_d.T, rotation) - jnp.matmul(rotation.T, rot_d)) - # Compute moment inputs - #! need to double check this - moments = ( - -kr * e_rot - - ko * e_ome - + jnp.cross(omega, j_vec * omega) - - j_vec - * ( - jnp.matmul( - jnp.matmul(hat(omega), body_to_inertial_rotation.T), jnp.matmul(rot_d, omega_d) - ) - - jnp.matmul(body_to_inertial_rotation.T, jnp.matmul(rot_d, omega_d_dot)) - ) - ) + # Body angular velocity [p, q, r] is part of the state; omega_d = 0 for a setpoint. + omega = x[9:12] + e_ome = omega + + # Thrust magnitude: project the desired force onto the body-down axis. + f = -jnp.dot(thrust_vec, jnp.matmul(rotation, e3)) + + # Moment inputs (omega_d = omega_d_dot = 0 for setpoint regulation). + moments = -kr * e_rot - ko * e_ome + jnp.cross(omega, j_vec * omega) inputs = jnp.hstack([f, moments]) diff --git a/tests/test_optimization/test_mpc_tracking.py b/tests/test_optimization/test_mpc_tracking.py new file mode 100644 index 00000000..73dfd501 --- /dev/null +++ b/tests/test_optimization/test_mpc_tracking.py @@ -0,0 +1,79 @@ +"""Regression tests for ``generate_mpc_solver_quadratic_cost_linear_dynamics``. + +History: this solver had two latent cost-formulation bugs (the module had no tests +or examples exercising it): + +1. The reference horizon was flattened state-major (``.T.flatten()``) while the + Hessian block ``kron(I_N, Q)`` and the decision-vector unpack are time-major. + That scrambled per-state-dimension weights against per-state-dimension references. + +2. The linear cost term was ``-Q @ r`` instead of ``-2*Q @ r``. The jaxopt QP solver + minimizes ``xแต€Hx + fแต€x`` with no implicit 1/2, so a tracking cost + ``(x-r)แต€Q(x-r)`` produces ``f = -2Q r``. The missing factor of 2 made the QP + optimum collapse to ``r/2``: a goal of (4, 4) parked the system at (2, 2). + +These tests pin both corrections by driving a discrete-time double-integrator and +asserting the realized trajectory actually reaches the goal at the correct location. +""" +from __future__ import annotations + +import jax.numpy as jnp +import numpy as np +import pytest + +from cbfkit.optimization.mpc.quadratic_cost_linear_dynamics import ( + generate_mpc_solver_quadratic_cost_linear_dynamics, +) + + +def _double_integrator_2d(dt: float = 0.1): + A = jnp.array( + [[1.0, 0.0, dt, 0.0], [0.0, 1.0, 0.0, dt], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]] + ) + B = jnp.array([[0.0, 0.0], [0.0, 0.0], [dt, 0.0], [0.0, dt]]) + return A, B + + +def _run_mpc(goal_xy, n_steps: int = 40, horizon: int = 20, dt: float = 0.1): + A, B = _double_integrator_2d(dt) + Q = jnp.diag(jnp.array([10.0, 10.0, 1.0, 1.0])) + R = 0.1 * jnp.eye(2) + Qn = 50.0 * Q + solve = generate_mpc_solver_quadratic_cost_linear_dynamics(A, B, Q, R, Qn, horizon) + + goal = jnp.array([goal_xy[0], goal_xy[1], 0.0, 0.0]) + ref_horizon = jnp.tile(goal, (horizon, 1)) + x = jnp.zeros(4) + for _ in range(n_steps): + concatenated_x_xr = jnp.vstack([x.reshape(1, -1), ref_horizon]) + _x_opt, u_opt = solve(concatenated_x_xr) + x = A @ x + B @ u_opt[:, 0] + return np.asarray(x) + + +def test_mpc_reaches_goal_no_factor_of_two_collapse(): + """Goal (4, 4) must produce x_T โ‰ˆ (4, 4) โ€” pre-fix it parked at (2, 2).""" + x_final = _run_mpc((4.0, 4.0)) + assert np.allclose(x_final[:2], [4.0, 4.0], atol=1e-2), ( + f"MPC final position {x_final[:2]} did not reach goal (4, 4). " + "If it landed near (2, 2) the linear-cost factor-of-2 regressed." + ) + # Terminal velocity should also be ~0 (system is fully regulated). + assert np.allclose( + x_final[2:], [0.0, 0.0], atol=1e-2 + ), f"Terminal velocity {x_final[2:]} non-zero; MPC failed to settle." + + +@pytest.mark.parametrize("goal_xy", [(3.0, 1.0), (1.0, 3.0), (-2.0, 2.5)]) +def test_mpc_tracks_asymmetric_goal_no_axis_scrambling(goal_xy): + """Asymmetric goals catch state-major vs time-major reference ordering. + + If the cost vector reverts to state-major (the old ``.T.flatten()``), + px-weights pair with py-references and the realized x/y will swap or smear. + Symmetric goals like (4, 4) cannot detect this; these asymmetric goals can. + """ + x_final = _run_mpc(goal_xy, n_steps=60) + assert np.allclose(x_final[:2], goal_xy, atol=2e-2), ( + f"MPC reached {x_final[:2]} for goal {goal_xy}. " + "If x and y are swapped/smeared, the reference time/state ordering regressed." + ) diff --git a/tests/test_quadrotor_geometric_tracking.py b/tests/test_quadrotor_geometric_tracking.py new file mode 100644 index 00000000..1158ea82 --- /dev/null +++ b/tests/test_quadrotor_geometric_tracking.py @@ -0,0 +1,159 @@ +"""Closed-loop regression tests for the 6-DOF quadrotor ``geometric_controller``. + +History: the controller had two latent bugs that the pre-existing tests +(``test_quadrotor_geometric_controller.py``) could not catch, because they only +asserted that a *single* control evaluation was finite and shape ``(4,)`` โ€” never +that a closed-loop simulation actually tracks a setpoint: + +1. **Improper rotation (det = -1).** Both ``rotation_body_frame_to_inertial_frame`` + and the plant's ``rotation_body_to_inertial`` return an orthogonal but *improper* + matrix (a reflection): the standard ZYX 3rd row is negated to encode the model's + "body z-down, inertial h-up" convention. The geometric attitude error + ``vee(Rdแต€ R - Rแต€ Rd)`` assumes ``R โˆˆ SO(3)``; feeding it a reflection loads the + lateral attitude error onto the wrong axis, so the east position ``pe`` diverged + (to โ‰ˆ -8 m for a +1.5 m setpoint) while ``pn`` and altitude tracked. The fix builds + a proper rotation ``S @ R`` (``S = diag(1, 1, -1)``) inside the controller and runs + the Lee 2010 law in the z-down frame โ€” the plant itself is left untouched. + +2. **Wrong body angular velocity.** The controller reconstructed ``omega`` from + Euler-angle rates with a transform that is wrong even at hover (it placed the roll + rate into the yaw slot), leaving roll undamped โ†’ integration NaN within ~1 s. The + body angular velocity ``[p, q, r]`` is already the state slice ``x[9:12]``; the fix + uses it directly. + +These tests pin both corrections: convergence to an asymmetric setpoint (catches the +lateral-axis sign bug), absence of NaN/divergence, and that a pure roll rate produces +a roll-damping moment (catches the omega-axis bug). +""" +from __future__ import annotations + +import jax.numpy as jnp +import numpy as np +import pytest + +import cbfkit.simulation.simulator as sim +from cbfkit.estimators import naive as estimator +from cbfkit.integration import runge_kutta_4 as integrator +from cbfkit.sensors import perfect as sensor +from cbfkit.systems.quadrotor_6dof.controllers.geometric import geometric_controller +from cbfkit.systems.quadrotor_6dof.models.quadrotor_6dof_dynamics import ( + quadrotor_6dof_dynamics, + rotation_body_to_inertial, +) +from cbfkit.systems.quadrotor_6dof.utils.rotations import ( + rotation_body_frame_to_inertial_frame, +) + +# Mass/inertia consistent between plant and the controller's default gains. +_M, _JX, _JY, _JZ = 4.34, 0.0820, 0.0845, 0.1377 + + +def _plant(): + three_tuple = quadrotor_6dof_dynamics(m=_M, jx=_JX, jy=_JY, jz=_JZ) + + def dyn(x): + f_val, g_mat, _s = three_tuple(x) + return f_val, g_mat + + return dyn + + +def _run(desired, x0=None, dt: float = 0.01, tf: float = 6.0, use_jit: bool = False): + dyn = _plant() + if x0 is None: + x0 = jnp.array([0.0, 0.0, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]) + controller = geometric_controller( + dynamics=dyn, desired_state=jnp.asarray(desired), dt=dt, m=_M, jx=_JX, jy=_JY, jz=_JZ + ) + res = sim.execute( + x0=x0, + dt=dt, + num_steps=int(tf / dt), + dynamics=dyn, + integrator=integrator, + nominal_controller=controller, + sensor=sensor, + estimator=estimator, + use_jit=use_jit, + ) + return np.asarray(res["states"]) + + +def test_geometric_controller_converges_without_nan(): + """Free-running setpoint regulation must reach the goal โ€” pre-fix it NaN'd at ~0.95 s.""" + desired = jnp.array([2.0, 1.5, 3.0]) + states = _run(desired) + + assert not np.isnan(states).any(), "Integration produced NaN (omega-reconstruction bug?)." + final_err = np.linalg.norm(states[-1, :3] - np.asarray(desired)) + assert ( + final_err < 0.1 + ), f"Final position error {final_err:.3f} m too large; controller did not track." + + +def test_no_lateral_divergence_on_asymmetric_setpoint(): + """An asymmetric (pn != pe) setpoint catches the improper-rotation lateral-axis sign bug. + + Pre-fix, the east position ``pe`` diverged to โ‰ˆ -8 m (wrong sign, growing) for a + +1.5 m target while ``pn``/altitude tracked. A symmetric target cannot detect this. + """ + desired = jnp.array([2.0, 1.5, 3.0]) + states = _run(desired) + + pe = states[:, 1] + # pe must stay bounded and finish near +1.5 (not run away to large negative). + assert np.abs(pe).max() < 3.0, f"|pe| peaked at {np.abs(pe).max():.2f} m โ€” lateral divergence." + assert ( + abs(float(states[-1, 1]) - 1.5) < 0.1 + ), f"Final pe={float(states[-1, 1]):.3f} did not reach +1.5; attitude-error axis sign regressed." + # Attitude stays well away from gimbal/flip throughout. + assert np.abs(states[:, 6:9]).max() < 1.0, "Attitude excursion too large; controller unstable." + + +def test_jit_and_nonjit_paths_agree(): + desired = jnp.array([2.0, 1.5, 3.0]) + s_nojit = _run(desired, use_jit=False) + s_jit = _run(desired, use_jit=True) + assert np.allclose(s_nojit[-1], s_jit[-1], atol=1e-3), "JIT and non-JIT trajectories diverge." + + +def test_pure_roll_rate_produces_roll_damping_moment(): + """A pure body roll rate p>0 (at the setpoint, level attitude) must yield a *roll*-damping + moment M_x < 0 โ€” pinning ``omega = x[9:12]``. + + Pre-fix, the Euler-rate reconstruction routed the roll rate into the yaw channel, so + M_x โ‰ˆ 0 and M_z absorbed the (mis-attributed) damping. This isolates that bug from the + rotation bug by placing the quadrotor exactly at its setpoint with level attitude. + """ + desired = jnp.array([0.0, 0.0, 1.0]) + # At the setpoint, level, with only a roll rate p = 0.3 rad/s. + x = jnp.array([0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.3, 0.0, 0.0]) + controller = geometric_controller(dynamics=_plant(), desired_state=desired, dt=0.01) + control, _ = controller(0.0, x) + + m_x, m_y, m_z = float(control[1]), float(control[2]), float(control[3]) + assert ( + m_x < -0.1 + ), f"M_x={m_x:.3f} should be negative (damp roll); omega axis-mapping regressed." + # The roll rate must NOT leak into the yaw moment. + assert abs(m_z) < 1e-6, f"M_z={m_z:.3e} nonzero for a pure roll rate; omega routed to yaw." + + +def test_packaged_rotation_is_improper_but_controller_fix_is_proper(): + """Document the root cause and pin the fix's premise. + + The packaged body->inertial matrices are reflections (det = -1); the controller's + ``S @ R`` correction (S = diag(1, 1, -1)) restores a proper SO(3) rotation. + """ + S = np.diag([1.0, 1.0, -1.0]) + for phi, theta, psi in [(0.0, 0.0, 0.0), (0.3, -0.2, 0.5), (-0.4, 0.6, -0.7)]: + x = jnp.array([0, 0, 0, 0, 0, 0, phi, theta, psi, 0, 0, 0.0]) + R = np.asarray(rotation_body_frame_to_inertial_frame(x)) + R_dyn = np.asarray(rotation_body_to_inertial(phi, theta, psi)) + assert np.allclose(R, R_dyn, atol=1e-9), "utils and dynamics rotation matrices must match." + assert np.isclose( + np.linalg.det(R), -1.0, atol=1e-6 + ), "packaged rotation should be improper." + R_proper = S @ R + assert np.isclose(np.linalg.det(R_proper), 1.0, atol=1e-6), "S @ R must be proper SO(3)." + assert np.allclose(R_proper @ R_proper.T, np.eye(3), atol=1e-6), "S @ R must be orthogonal."