Dual Deputy Inspection Cw¶
Dual-deputy relative-orbit inspection with CW dynamics.
Two deputies inspect a non-maneuvering chief (CW origin) under:
- Circular Earth-orbit Clohessy-Wiltshire translational dynamics
- Impulsive body-frame delta-v at every node (RCS burns), rotated into CW via QDCM(q) so thruster directions follow attitude
- Continuous reaction-wheel torque for full 6DoF attitude / FOV pointing
- Thin annular keep-in band about the chief (min / max range)
- Collinearity of the two deputies with the chief (symbolic cross product)
- Opposite-side constraint so the formation stays dual-sided
- Continuous sensor FOV cones keeping the chief in each camera frame
- Minimum total delta-v objective (accumulated impulse cost state)
- Uniform LoS-sweep heuristic: dual "clocks" drive the inspection axis on S² at constant azimuth φ and colatitude θ rates (equal dwell in each direction), including out-of-plane views — no discrete waypoints
Mission: sweep the dual viewing axis uniformly over the chief's viewing sphere on a fixed horizon while staying in the annulus, keeping LoS / FOV, and limiting total Δv. Antipodal pair (r2 = -r1) means one axis direction n = r1/‖r1‖ covers both sides; sweeping θ ∈ [π/2, 0] and φ ∈ [0, π] traces a hemisphere (π sr).
The CW frame convention:
- x: radial direction (outward from Earth)
- y: along-track direction (velocity direction)
- z: cross-track direction (normal to orbit plane)
File: examples/spacecraft/dual_deputy_inspection_cw.py
from __future__ import annotations
import os
import sys
import jax.numpy as jnp
import numpy as np
import numpy.linalg as la
current_dir = os.path.dirname(os.path.abspath(__file__))
grandparent_dir = os.path.dirname(os.path.dirname(current_dir))
sys.path.append(grandparent_dir)
import openscvx as ox
from examples.plotting_viser import create_animated_plotting_server
from openscvx import Problem
from openscvx.plotting import plot_controls, plot_states
from openscvx.plotting.viser import (
add_animated_trail,
add_animation_controls,
add_attitude_frame,
add_ghost_trajectory,
add_position_marker,
add_viewcone,
compute_velocity_colors,
)
from openscvx.solvers.cvxpy_ptr_solver import CVXPyPTRSolver
# Dual-vehicle CTCS (shared aug state) fills discrete A_d across both blocks, but
# symbolic sparsity stays block-diagonal. Drop sparse Parameter patterns before
# Problem() lowers into CVXPy (create_variables runs at construction time).
# Keep N modest — dense params OOM around N≳20 on typical laptops.
_orig_create_variables = CVXPyPTRSolver.create_variables
def _create_variables_dense(
self, *args, dynamics_sparsity=None, constraint_sparsity=None, **kwargs
):
return _orig_create_variables(
self,
*args,
dynamics_sparsity=None,
constraint_sparsity=None,
**kwargs,
)
CVXPyPTRSolver.create_variables = _create_variables_dense
# =============================================================================
# Problem parameters
# =============================================================================
N = 30
total_time = 3000.0 # Fixed horizon [s] (~0.44 of an ISS orbital period)
# Circular LEO (ISS-like) so CW is valid — matches proxops_cw.py
mu = 3.986004418e14 # Earth gravitational parameter [m^3/s^2]
a_orbit = 6.778e6 # Semi-major axis [m]
n_mean = np.sqrt(mu / a_orbit**3) # Mean motion [rad/s]
R_min = 48.0 # Annular keep-in inner radius [m]
R_max = 52.0 # Annular keep-in outer radius [m]
R_nom = 0.5 * (R_min + R_max)
eps_colinear = 75.0 # ‖r1 × r2‖ tolerance [m^2]; slightly loose for a long tour
dv_max = 0.5 # Per-axis body-frame impulsive delta-v bound [m/s]
cost_ub = 30.0 # Upper bound / minimize guess for total Δv [m/s]
v_max = 2.0 # Relative velocity box [m/s]
w_max = 0.5 # Angular rate box [rad/s]
torque_max = 1.0 # Reaction-wheel torque box [N·m]
J_b = jnp.array([1.0, 1.0, 1.0]) # Deputy inertia diagonal [kg·m^2]
# Spherical uniform-sweep reference rates for deputy-1 axis n = r1/‖r1‖.
# θ = colatitude from +x (radial), φ = azimuth in the yz-plane.
# Antipodal pair: θ sweeps π/2 → 0 and φ sweeps 0 → π covers one hemisphere.
theta_init = 0.5 * np.pi
theta_final = 0.0
phi_sweep = np.pi
omega_phi_ref = phi_sweep / total_time
omega_theta_ref = (theta_final - theta_init) / total_time # negative: toward +x pole
impulse_nodes = list(range(N))
# Sensor / FOV (dr_vp-style cone; half-angle = pi / alpha)
# Wider than the drone racing cone — inspection at ~50 m needs a looser FOV
# for the dual-6DoF NLP to remain tractable.
alpha_x = 16.0
alpha_y = 16.0
A_cone = np.diag(
[
1.0 / np.tan(np.pi / alpha_x),
1.0 / np.tan(np.pi / alpha_y),
0.0,
]
)
c_boresight = jnp.array([0.0, 0.0, 1.0])
norm_type = 2
R_sb = jnp.array([[0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]])
# =============================================================================
# Helpers
# =============================================================================
def cross(a, b):
"""Right-handed cross product (matches examples/rocket/6DoF_pdg.py:196–200)."""
return ox.Concat(
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
)
def _pointing_attitudes(positions: np.ndarray) -> np.ndarray:
"""Quaternions [qw, qx, qy, qz] that point the sensor boresight at the origin."""
b = np.asarray(R_sb @ np.array([0.0, 1.0, 0.0]), dtype=float)
attitudes = np.zeros((positions.shape[0], 4))
for k, pos in enumerate(positions):
a = -np.asarray(pos, dtype=float) # chief (origin) relative to deputy
if la.norm(a) < 1e-9:
attitudes[k] = np.array([1.0, 0.0, 0.0, 0.0])
continue
q_xyz = np.cross(b, a)
q_w = np.sqrt(la.norm(a) ** 2 + la.norm(b) ** 2) + np.dot(a, b)
q = np.hstack((q_w, q_xyz))
attitudes[k] = q / la.norm(q)
return attitudes
def _great_circle_yz(n_nodes: int, radius: float, sweep_rad: float = 2.0 * np.pi) -> np.ndarray:
"""Great-circle path on the yz-plane starting at +y, sweeping about +x."""
theta = np.linspace(0.0, sweep_rad, n_nodes)
return np.stack(
[
np.zeros(n_nodes),
radius * np.cos(theta),
radius * np.sin(theta),
],
axis=1,
)
def _sphere_tour_guess(
n_nodes: int,
radius: float,
*,
theta_start: float = 0.5 * np.pi,
theta_end: float = 0.0,
phi_start: float = 0.0,
phi_end: float = np.pi,
) -> np.ndarray:
"""Spherical tour: θ = colatitude from +x, φ = azimuth in yz-plane.
Default IC matches pos1_init = [0, R, 0] (θ=π/2, φ=0) and sweeps out-of-plane
toward the +x radial while advancing azimuth.
"""
theta = np.linspace(theta_start, theta_end, n_nodes)
phi = np.linspace(phi_start, phi_end, n_nodes)
sin_theta = np.sin(theta)
return np.stack(
[
radius * np.cos(theta),
radius * sin_theta * np.cos(phi),
radius * sin_theta * np.sin(phi),
],
axis=1,
)
def _make_deputy_states(suffix: str, pos_init: np.ndarray):
"""Create position / velocity / attitude / rate states for one deputy.
Terminal position is free so the LoS-sweep objective can drive a full
tour around the chief rather than a fixed 90 deg reorientation.
"""
position = ox.State(f"position_{suffix}", shape=(3,))
position.max = np.array([100.0, 100.0, 100.0])
position.min = np.array([-100.0, -100.0, -100.0])
position.initial = pos_init
position.final = [ox.Free(0.0), ox.Free(0.0), ox.Free(0.0)]
velocity = ox.State(f"velocity_{suffix}", shape=(3,))
velocity.max = np.full(3, v_max)
velocity.min = np.full(3, -v_max)
velocity.initial = [("free", 0.0), ("free", 0.0), ("free", 0.0)]
velocity.final = [("free", 0.0), ("free", 0.0), ("free", 0.0)]
attitude = ox.State(f"attitude_{suffix}", shape=(4,))
attitude.max = np.ones(4)
attitude.min = -np.ones(4)
attitude.initial = [("free", 1.0), ("free", 0.0), ("free", 0.0), ("free", 0.0)]
attitude.final = [("free", 1.0), ("free", 0.0), ("free", 0.0), ("free", 0.0)]
angular_velocity = ox.State(f"angular_velocity_{suffix}", shape=(3,))
angular_velocity.max = np.full(3, w_max)
angular_velocity.min = np.full(3, -w_max)
angular_velocity.initial = [("free", 0.0), ("free", 0.0), ("free", 0.0)]
angular_velocity.final = [("free", 0.0), ("free", 0.0), ("free", 0.0)]
return position, velocity, attitude, angular_velocity
def _yz_azimuth_rate(position, velocity):
"""Signed azimuth rate in the yz-plane: φ̇ = (y v_z − z v_y)/(y²+z²)."""
y = position[1]
z = position[2]
return (y * velocity[2] - z * velocity[1]) / (y * y + z * z + 1e-6)
def _colatitude_rate(position, velocity):
"""Colatitude rate from +x: θ̇ = (ρ̇ x − ρ v_x)/‖r‖² with ρ = ‖(y,z)‖."""
x = position[0]
y = position[1]
z = position[2]
vx = velocity[0]
vy = velocity[1]
vz = velocity[2]
rho = ox.linalg.Norm(position[1:])
rho_dot = (y * vy + z * vz) / (rho + 1e-6)
r_sq = ox.Sum(position * position)
return (rho_dot * x - rho * vx) / (r_sq + 1e-6)
# =============================================================================
# States / controls
# =============================================================================
pos1_init = np.array([0.0, R_nom, 0.0])
pos2_init = -pos1_init
position_1, velocity_1, attitude_1, angular_velocity_1 = _make_deputy_states("1", pos1_init)
position_2, velocity_2, attitude_2, angular_velocity_2 = _make_deputy_states("2", pos2_init)
cost = ox.State("cost", shape=(1,))
cost.initial = np.array([0.0])
cost.final = [("minimize", cost_ub)]
cost.min = np.array([0.0])
cost.max = np.array([cost_ub])
cost.guess = np.zeros((N, 1))
# Unwrapped spherical coordinates of deputy-1 inspection axis (report-only).
view_azim = ox.State("view_azim", shape=(1,))
view_azim.initial = np.array([0.0])
view_azim.final = [ox.Free(0.0)]
view_azim.min = np.array([-0.5])
view_azim.max = np.array([phi_sweep + 0.5])
view_colat = ox.State("view_colat", shape=(1,))
view_colat.initial = np.array([theta_init])
view_colat.final = [ox.Free(0.0)]
view_colat.min = np.array([theta_final - 0.5])
view_colat.max = np.array([theta_init + 0.5])
# Mean squared fractional rate errors for φ̇ and θ̇ about their reference clocks.
uniformity = ox.State("uniformity", shape=(1,))
uniformity.initial = np.array([0.0])
uniformity.final = [ox.Minimize(0.0)]
uniformity.min = np.array([0.0])
uniformity.max = np.array([10.0])
delta_v_1 = ox.Control(
"delta_v_1",
shape=(3,),
parameterization="impulsive",
nodes=impulse_nodes,
)
delta_v_1.min = -dv_max * np.ones(3)
delta_v_1.max = dv_max * np.ones(3)
delta_v_1.guess = np.zeros((N, 3))
delta_v_2 = ox.Control(
"delta_v_2",
shape=(3,),
parameterization="impulsive",
nodes=impulse_nodes,
)
delta_v_2.min = -dv_max * np.ones(3)
delta_v_2.max = dv_max * np.ones(3)
delta_v_2.guess = np.zeros((N, 3))
torque_1 = ox.Control("torque_1", shape=(3,))
torque_1.min = -torque_max * np.ones(3)
torque_1.max = torque_max * np.ones(3)
torque_1.guess = np.zeros((N, 3))
torque_2 = ox.Control("torque_2", shape=(3,))
torque_2.min = -torque_max * np.ones(3)
torque_2.max = torque_max * np.ones(3)
torque_2.guess = np.zeros((N, 3))
states = [
position_1,
velocity_1,
attitude_1,
angular_velocity_1,
position_2,
velocity_2,
attitude_2,
angular_velocity_2,
cost,
view_azim,
view_colat,
uniformity,
]
controls = [delta_v_1, delta_v_2, torque_1, torque_2]
# =============================================================================
# Guesses
# =============================================================================
pos1_guess = _sphere_tour_guess(
N,
R_nom,
theta_start=theta_init,
theta_end=theta_final,
phi_start=0.0,
phi_end=phi_sweep,
)
pos2_guess = -pos1_guess
dt = total_time / max(N - 1, 1)
vel1_guess = np.gradient(pos1_guess, dt, axis=0)
vel2_guess = np.gradient(pos2_guess, dt, axis=0)
att1_guess = _pointing_attitudes(pos1_guess)
att2_guess = _pointing_attitudes(pos2_guess)
position_1.guess = pos1_guess
position_2.guess = pos2_guess
velocity_1.guess = vel1_guess
velocity_2.guess = vel2_guess
attitude_1.guess = att1_guess
attitude_2.guess = att2_guess
angular_velocity_1.guess = np.zeros((N, 3))
angular_velocity_2.guess = np.zeros((N, 3))
phi_guess = np.linspace(0.0, phi_sweep, N)
theta_guess = np.linspace(theta_init, theta_final, N)
view_azim.guess = phi_guess.reshape(-1, 1)
view_colat.guess = theta_guess.reshape(-1, 1)
uniformity.guess = np.zeros((N, 1))
# =============================================================================
# Dynamics
# =============================================================================
J_b_inv = 1.0 / J_b
J_b_diag = ox.linalg.Diag(J_b)
def _cw_accel(position, velocity):
return ox.Concat(
3.0 * n_mean**2 * position[0] + 2.0 * n_mean * velocity[1],
-2.0 * n_mean * velocity[0],
-(n_mean**2) * position[2],
)
def _attitude_dynamics(attitude, angular_velocity, torque):
q_norm = ox.linalg.Norm(attitude)
attitude_normalized = attitude / q_norm
attitude_dot = 0.5 * ox.spatial.SSMP(angular_velocity) @ attitude_normalized
angular_velocity_dot = ox.linalg.Diag(J_b_inv) @ (
torque - cross(angular_velocity, J_b_diag @ angular_velocity)
)
return attitude_dot, angular_velocity_dot
att1_dot, w1_dot = _attitude_dynamics(attitude_1, angular_velocity_1, torque_1)
att2_dot, w2_dot = _attitude_dynamics(attitude_2, angular_velocity_2, torque_2)
_phi_dot_1 = _yz_azimuth_rate(position_1, velocity_1)
_theta_dot_1 = _colatitude_rate(position_1, velocity_1)
_phi_err = (_phi_dot_1 - omega_phi_ref) / omega_phi_ref
_theta_err = (_theta_dot_1 - omega_theta_ref) / omega_theta_ref
dynamics = {
"position_1": velocity_1,
"velocity_1": _cw_accel(position_1, velocity_1),
"attitude_1": att1_dot,
"angular_velocity_1": w1_dot,
"position_2": velocity_2,
"velocity_2": _cw_accel(position_2, velocity_2),
"attitude_2": att2_dot,
"angular_velocity_2": w2_dot,
"cost": 0.0,
"view_azim": _phi_dot_1,
"view_colat": _theta_dot_1,
"uniformity": (_phi_err * _phi_err + _theta_err * _theta_err) / total_time,
}
# Body-frame impulsive Δv → CW: QDCM(q) maps body → CW/LVLH (same convention
# as the drone examples and the FOV residual below).
eps_impulse = 1e-6
att1_n = attitude_1 / ox.linalg.Norm(attitude_1)
att2_n = attitude_2 / ox.linalg.Norm(attitude_2)
dynamics_discrete = {
"position_1": position_1,
"velocity_1": velocity_1 + ox.spatial.QDCM(att1_n) @ delta_v_1,
"attitude_1": attitude_1,
"angular_velocity_1": angular_velocity_1,
"position_2": position_2,
"velocity_2": velocity_2 + ox.spatial.QDCM(att2_n) @ delta_v_2,
"attitude_2": attitude_2,
"angular_velocity_2": angular_velocity_2,
"cost": cost
+ ox.linalg.Norm(delta_v_1 + eps_impulse)
+ ox.linalg.Norm(delta_v_2 + eps_impulse),
"view_azim": view_azim,
"view_colat": view_colat,
"uniformity": uniformity,
}
# =============================================================================
# Constraints
# =============================================================================
# Prefer nodal convex/nonconvex constraints over CTCS for this dual-vehicle
# problem: a single shared CTCS channel + cross-vehicle residuals was numerically
# unstable (huge aug-state growth). Nodal enforcement at every node is enough
# for the example; continuous FOV between nodes can be revisited later.
constraints = []
for state in states:
constraints.append(ox.ctcs((state <= state.max)))
constraints.append(ox.ctcs((state.min <= state)))
for pos in (position_1, position_2):
constraints.append(ox.ctcs((ox.linalg.Norm(pos) <= R_max)))
# Reverse-convex keep-out / min-range (nonconvex)
constraints.append(ox.ctcs(R_min <= ox.linalg.Norm(pos)))
cross_12 = cross(position_1, position_2)
# Squared residual — ‖r1×r2‖ is singular at the antipodal guess (cross=0).
constraints.append(ox.ctcs(ox.Sum(cross_12 * cross_12) <= eps_colinear**2))
# Bilinear opposite-side inequality is nonconvex (not a convex quadratic).
constraints.append(ox.ctcs(ox.Sum(position_1 * position_2) <= 0.0))
def g_vp(p_target_I, x_pos, x_quat):
p_s = R_sb @ ox.spatial.QDCM(x_quat).T @ (p_target_I - x_pos)
# Epsilon avoids ‖·‖ singularity when the chief lies exactly on the boresight.
return ox.linalg.Norm(A_cone @ p_s + 1e-8) - (c_boresight.T @ p_s)
chief = np.zeros(3)
constraints.append(ox.ctcs(g_vp(chief, position_1, attitude_1) <= 0.0))
constraints.append(ox.ctcs(g_vp(chief, position_2, attitude_2) <= 0.0))
for node in impulse_nodes:
constraints.append((ox.linalg.Norm(delta_v_1) <= dv_max).convex().at([node]))
constraints.append((ox.linalg.Norm(delta_v_2) <= dv_max).convex().at([node]))
# =============================================================================
# Problem
# =============================================================================
time = ox.Time(
initial=0.0,
final=total_time,
min=0.0,
max=total_time,
guess=np.linspace(0.0, total_time, N).reshape(-1, 1),
)
problem = Problem(
dynamics=dynamics,
dynamics_discrete=dynamics_discrete,
states=states,
controls=controls,
time=time,
constraints=constraints,
N=N,
float_dtype="float64",
licq_max=1e-10,
algorithm={
"lam_prox": 5e0,
"lam_vc": 1e1,
"lam_cost": {"cost": 1e-1, "uniformity": 2.0},
"autotuner": ox.ConstantProximalWeight(),
# "autotuner": ox.AugmentedLagrangian(eta_lambda=1e2),
},
discretizer={"diffrax_kwargs": {"atol": 1e-6, "rtol": 1e-6}},
)
plotting_dict = {
"mean_motion": n_mean,
"R_min": R_min,
"R_max": R_max,
"R_sb": np.asarray(R_sb),
"alpha_x": alpha_x,
"alpha_y": alpha_y,
"norm_type": norm_type,
"init_poses": np.array([[0.0, 0.0, 0.0]]),
}
def _print_summary(results) -> None:
cost_nodes = np.asarray(results.nodes["cost"], dtype=float).reshape(-1)
azim_nodes = np.asarray(results.nodes["view_azim"], dtype=float).reshape(-1)
colat_nodes = np.asarray(results.nodes["view_colat"], dtype=float).reshape(-1)
unif_nodes = np.asarray(results.nodes["uniformity"], dtype=float).reshape(-1)
dv1 = np.asarray(results.nodes["delta_v_1"], dtype=float)
dv2 = np.asarray(results.nodes["delta_v_2"], dtype=float)
pos1 = np.asarray(results.nodes["position_1"], dtype=float)
pos2 = np.asarray(results.nodes["position_2"], dtype=float)
print(f"Converged: {bool(results.converged)}")
print(f"Final accumulated Δv (cost): {cost_nodes[-1]:.6e} m/s")
print(f"Sum ‖Δv1‖ over nodes: {np.linalg.norm(dv1, axis=1).sum():.6e} m/s")
print(f"Sum ‖Δv2‖ over nodes: {np.linalg.norm(dv2, axis=1).sum():.6e} m/s")
print(
f"LoS azimuth φ: {azim_nodes[-1]:.3f} rad "
f"({np.rad2deg(azim_nodes[-1]):.1f} deg) / target {np.rad2deg(phi_sweep):.1f} deg"
)
print(
f"LoS colatitude θ: {colat_nodes[-1]:.3f} rad "
f"({np.rad2deg(colat_nodes[-1]):.1f} deg) / target {np.rad2deg(theta_final):.1f} deg"
)
print(
f"Spherical sweep uniformity (RMS frac. rate error): "
f"{np.sqrt(max(unif_nodes[-1], 0.0)):.4f} (0 = uniform); "
f"ω_φ={omega_phi_ref:.3e}, ω_θ={omega_theta_ref:.3e} rad/s"
)
x1 = pos1[:, 0]
print(
f"Out-of-plane range |z1|: [{np.abs(pos1[:, 2]).min():.3f}, "
f"{np.abs(pos1[:, 2]).max():.3f}] m; "
f"x1 (radial offset): [{x1.min():.3f}, {x1.max():.3f}] m"
)
print(
f"‖r1‖ range: [{np.linalg.norm(pos1, axis=1).min():.3f}, "
f"{np.linalg.norm(pos1, axis=1).max():.3f}] m"
)
print(
f"‖r2‖ range: [{np.linalg.norm(pos2, axis=1).min():.3f}, "
f"{np.linalg.norm(pos2, axis=1).max():.3f}] m"
)
cross_norm = np.linalg.norm(np.cross(pos1, pos2), axis=1)
print(f"‖r1 × r2‖ max: {cross_norm.max():.6e} m^2 (eps={eps_colinear})")
print(f"max r1·r2: {np.sum(pos1 * pos2, axis=1).max():.6e} m^2")
if __name__ == "__main__":
problem.initialize()
results = problem.solve()
results = problem.post_process()
results.update(plotting_dict)
_print_summary(results)
plot_states(results).show()
plot_controls(results).show()
# Build deputy-1 scene without starting playback, then attach deputy 2 and
# a shared diameter line before wiring the animation GUI.
handle = create_animated_plotting_server(
results,
position_key="position_1",
velocity_key="velocity_1",
attitude_key="attitude_1",
thrust_key="delta_v_1",
show_viewcone=True,
viewcone_scale=R_max,
show_control_norm_plot="delta_v_1",
show_grid=False,
target_radius=2.0,
controls="manual",
)
# Deputy 2 shares the server with deputy 1, so each primitive gets its own scene path.
pos2_traj = np.asarray(results.trajectory["position_2"], dtype=np.float64)
att2_traj = np.asarray(results.trajectory["attitude_2"], dtype=np.float64)
vel2_traj = np.asarray(results.trajectory["velocity_2"], dtype=np.float64)
colors_2 = compute_velocity_colors(vel2_traj)
add_ghost_trajectory(handle.server, pos2_traj, colors_2, opacity=0.35, name="/deputy_2/ghost")
_, update_trail_2 = add_animated_trail(
handle.server, pos2_traj, colors_2, point_size=0.25, name="/deputy_2/trail"
)
_, update_marker_2 = add_position_marker(
handle.server, pos2_traj, radius=1.2, color=(255, 160, 60), name="/deputy_2/marker"
)
_, update_body_2 = add_attitude_frame(
handle.server,
pos2_traj,
att2_traj,
axes_length=3.0,
axes_radius=0.08,
name="/deputy_2/body",
)
_, update_viewcone_2 = add_viewcone(
handle.server,
pos2_traj,
att2_traj,
half_angle_x=np.pi / alpha_x,
half_angle_y=np.pi / alpha_y,
scale=R_max,
norm_type=norm_type,
R_sb=np.asarray(R_sb, dtype=np.float64),
color=(255, 140, 40),
opacity=0.35,
name="/deputy_2/viewcone",
)
handle.update_callbacks += [
update_trail_2,
update_marker_2,
update_body_2,
update_viewcone_2,
]
# Collinear diameter through the chief (deputy 1 ↔ origin ↔ deputy 2)
pos1_traj = np.asarray(results.trajectory["position_1"], dtype=np.float64)
diameter = handle.server.scene.add_line_segments(
"/diameter",
points=np.array([[pos1_traj[0], pos2_traj[0]]], dtype=np.float32),
colors=(220, 220, 220),
line_width=2.5,
)
def update_diameter(frame_idx: int) -> None:
diameter.points = np.array([[pos1_traj[frame_idx], pos2_traj[frame_idx]]], dtype=np.float32)
handle.update_callbacks.append(update_diameter)
# Annular shell (inner / outer); chief is already marked via init_poses
handle.server.scene.add_icosphere(
"/annulus/r_min",
radius=R_min,
color=(80, 180, 120),
opacity=0.08,
)
handle.server.scene.add_icosphere(
"/annulus/r_max",
radius=R_max,
color=(80, 140, 200),
opacity=0.08,
)
add_animation_controls(
handle.server,
handle.traj_time,
handle.update_callbacks,
loop=True,
)
handle.step(0)
handle.server.sleep_forever()