Skip to content

Moving Safe Columns

6DoF quadrotor with STL moving safe columns.

Lift of examples/double_integrator/moving_safe_zones.py: the vehicle must stay inside at least one of several moving vertical columns (extruded circles) while flying from start to goal. Each column center follows a time-varying xy path (PCHIP spline in physical time); the specification is

Always( Or(in_column_0, ..., in_column_N) )

over the full horizon. Columns are infinite in z — only the horizontal offset from the moving center is constrained.

The columns are a time-segmented relay: each covers a different time window along a feasible hand-off path. Only column 0 covers the start and only column 5 covers the goal, so the vehicle must switch columns to finish.

Run::

python examples/drone/moving_safe_columns.py

File: examples/drone/moving_safe_columns.py

from __future__ import annotations

import os
import sys

import jax.numpy as jnp
import numpy as np

current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(os.path.dirname(current_dir))
if project_root not in sys.path:
    sys.path.append(project_root)

import openscvx as ox
from examples.drone._plotting import create_moving_safe_columns_server
from openscvx import Problem

# Discretization — same scale as the 2D moving-safe-zones example
N = 30
TOTAL_TIME = 10.0

Z_CRUISE = 2.0
START = np.array([-4.0, 0.0, Z_CRUISE])
GOAL = np.array([4.0, 0.0, Z_CRUISE])

COLUMN_RADIUS = 0.6

T_KNOTS = np.array([0.0, 2.0, 3.5, 5.0, 7.5, 10.0])

RELAY_KEYFRAMES_XY = [
    START[:2],
    np.array([-2.0, 0.9]),
    np.array([-0.8, 0.2]),
    np.array([0.5, -1.0]),
    np.array([2.5, -0.7]),
    GOAL[:2],
]
RELAY_KEYFRAMES = [np.array([*xy, Z_CRUISE]) for xy in RELAY_KEYFRAMES_XY]
RELAY_NODES = [0, 6, 10, 15, 22, N - 1]

# Same xy knot paths as BALL_PATHS in moving_safe_zones.py
COLUMN_PATHS = [
    (
        np.array([-4.0, -2.0, -0.5, 1.0, 2.2, 2.8]),
        np.array([0.0, 0.9, 1.1, 1.3, 1.4, 1.5]),
    ),
    (
        np.array([-3.5, -2.2, -0.8, 0.2, 1.3, 2.2]),
        np.array([-0.4, 0.55, 0.2, -0.15, -0.35, -0.55]),
    ),
    (
        np.array([-2.8, -1.4, 0.3, 1.6, 2.8, 3.4]),
        np.array([1.25, 1.15, 1.0, 0.9, 0.8, 0.7]),
    ),
    (
        np.array([-2.5, -1.4, 0.0, 0.5, 2.5, 3.1]),
        np.array([-1.1, -1.0, -0.95, -1.0, -0.7, -0.65]),
    ),
    (
        np.array([-1.5, -0.2, 1.2, 2.3, 3.1, 3.5]),
        np.array([1.15, 1.05, 0.95, 0.75, 0.55, 0.5]),
    ),
    (
        np.array([-1.2, 0.3, 1.4, 2.4, 3.4, 4.0]),
        np.array([-0.55, -0.45, -0.35, -0.25, -0.1, 0.0]),
    ),
]

MASS = 1.0
G_CONST = -9.18
GRAVITY = np.array([0.0, 0.0, G_CONST], dtype=np.float64)
THRUST_MAX = 4.179446268 * 9.81
HOVER_THRUST = MASS * abs(G_CONST)

# States
position = ox.State("position", shape=(3,))
position.min = np.array([-6.0, -2.5, 0.5])
position.max = np.array([6.0, 2.5, 4.0])
position.initial = START
position.final = GOAL

velocity = ox.State("velocity", shape=(3,))
velocity.min = np.array([-8.0, -8.0, -4.0])
velocity.max = np.array([8.0, 8.0, 4.0])
velocity.initial = np.array([0.0, 0.0, 0.0])
velocity.final = [ox.Free(0.0), ox.Free(0.0), ox.Free(0.0)]

attitude = ox.State("attitude", shape=(4,))
attitude.max = np.array([1.0, 1.0, 1.0, 1.0])
attitude.min = np.array([-1.0, -1.0, -1.0, -1.0])
attitude.initial = [ox.Free(1.0), ox.Free(0.0), ox.Free(0.0), ox.Free(0.0)]
attitude.final = [ox.Free(1.0), ox.Free(0.0), ox.Free(0.0), ox.Free(0.0)]

angular_velocity = ox.State("angular_velocity", shape=(3,))
angular_velocity.max = np.array([10.0, 10.0, 10.0])
angular_velocity.min = np.array([-10.0, -10.0, -10.0])
angular_velocity.initial = np.array([0.0, 0.0, 0.0])
angular_velocity.final = [ox.Free(0.0), ox.Free(0.0), ox.Free(0.0)]

# Fixed final time so column centers stay synchronized with physical time.
time = ox.Time(
    initial=0.0,
    final=TOTAL_TIME,
    min=0.0,
    max=TOTAL_TIME,
    uniform_time_grid=True,
)

thrust_force = ox.Control("thrust_force", shape=(3,))
thrust_force.max = np.array([0.0, 0.0, THRUST_MAX])
thrust_force.min = np.array([0.0, 0.0, 0.0])

torque = ox.Control("torque", shape=(3,))
torque.max = np.array([18.665, 18.665, 0.55562])
torque.min = np.array([-18.665, -18.665, -0.55562])
torque.guess = np.zeros((N, 3))

states = [position, velocity, attitude, angular_velocity, time]
controls = [thrust_force, torque]

column_radius = ox.Parameter("column_radius", shape=(), value=COLUMN_RADIUS)

in_column_predicates = []
for col_x, col_y in COLUMN_PATHS:
    center_xy = ox.Concat(
        ox.Cinterp(time[0], T_KNOTS, col_x, method="pchip"),
        ox.Cinterp(time[0], T_KNOTS, col_y, method="pchip"),
    )
    delta_xy = position[:2] - center_xy
    in_column_predicates.append(ox.Sum(delta_xy * delta_xy) <= column_radius * column_radius)

in_some_column = ox.stl.Or(*in_column_predicates)

constraints = []
for state in states:
    constraints.extend([ox.ctcs(state <= state.max), ox.ctcs(state.min <= state)])

constraints.append(ox.stl.Always(in_some_column, (0, N - 1)).over())

J_b = jnp.array([1.0, 1.0, 1.0])
J_b_inv = 1.0 / J_b
J_b_diag = ox.linalg.Diag(J_b)
q_norm = ox.linalg.Norm(attitude)
attitude_normalized = attitude / q_norm

dynamics = {
    "position": velocity,
    "velocity": (1.0 / MASS) * ox.spatial.QDCM(attitude_normalized) @ thrust_force
    + np.array([0.0, 0.0, G_CONST], dtype=np.float64),
    "attitude": 0.5 * ox.spatial.SSMP(angular_velocity) @ attitude_normalized,
    "angular_velocity": ox.linalg.Diag(J_b_inv)
    @ (torque - ox.spatial.SSM(angular_velocity) @ J_b_diag @ angular_velocity),
    "time": 1.0,
}

# Position / velocity seed along the relay; attitude / thrust from differential flatness
# (identity attitude cannot produce horizontal accel with body-z-only thrust).
dt = TOTAL_TIME / (N - 1)
position.guess = ox.init.linspace(keyframes=RELAY_KEYFRAMES, nodes=RELAY_NODES)
velocity.guess = np.gradient(position.guess, dt, axis=0)
accel_guess = np.gradient(velocity.guess, dt, axis=0)


def _orientation_from_accel(accel: np.ndarray) -> np.ndarray:
    """Unit quaternion aligning body +z with specific thrust (diff. flatness)."""
    thrust_dir = accel - GRAVITY
    norm = float(np.linalg.norm(thrust_dir))
    if norm < 1e-6:
        return np.array([1.0, 0.0, 0.0, 0.0])
    z_des = thrust_dir / norm
    z_body = np.array([0.0, 0.0, 1.0])
    cross = np.cross(z_body, z_des)
    dot = float(np.dot(z_body, z_des))
    if dot < -0.999:
        return np.array([0.0, 1.0, 0.0, 0.0])
    q = np.array([1.0 + dot, cross[0], cross[1], cross[2]])
    return q / np.linalg.norm(q)


att_guess = np.array([_orientation_from_accel(accel_guess[k]) for k in range(N)])
att_guess /= np.linalg.norm(att_guess, axis=1, keepdims=True)
attitude.guess = att_guess
angular_velocity.guess = np.zeros((N, 3))

thrust_mag = MASS * np.linalg.norm(accel_guess - GRAVITY[None, :], axis=1)
thrust_mag = np.clip(thrust_mag, 0.0, THRUST_MAX)
thrust_force.guess = np.column_stack([np.zeros(N), np.zeros(N), thrust_mag])

problem = Problem(
    dynamics=dynamics,
    states=states,
    controls=controls,
    time=time,
    constraints=constraints,
    N=N,
    algorithm={
        "autotuner": ox.ConstantProximalWeight(),
        "lam_prox": 5e-3,
        "lam_vc": 2e1,
        "lam_cost": 1e-3,
    },
    float_dtype="float64",
)

plotting_dict = {
    "column_radius": COLUMN_RADIUS,
    "t_knots": T_KNOTS,
    "column_paths": COLUMN_PATHS,
    "column_interp_method": "pchip",
    "start": START,
    "goal": GOAL,
}

if __name__ == "__main__":
    n_cols = len(COLUMN_PATHS)
    print("6DoF Quadrotor — Moving Safe Columns (STL Or + Always)")
    print("=" * 60)
    print(f"Start: {START}, Goal: {GOAL}")
    print(f"Columns: {n_cols}, radius: {COLUMN_RADIUS} (infinite height, xy constraint)")
    print("Design: time-segmented relay (mandatory column hand-offs)")
    print(f"Spec: Always( Or(in_column_0, ..., in_column_{n_cols - 1}) ) over the horizon")
    print("=" * 60)

    problem.initialize()
    results = problem.solve()
    results = problem.post_process()
    results.update(plotting_dict)

    converged = getattr(results, "converged", "?")
    print(f"Converged: {converged}")

    server = create_moving_safe_columns_server(results)
    server.sleep_forever()