Moving Safe Zones¶
2D double integrator with STL moving safe zones.
The vehicle must stay inside at least one of several moving circular safe zones while traveling from a start point to a goal. Each zone center follows a time-varying path (PCHIP spline in physical time), and the specification is
Always( Or(in_ball_0, ..., in_ball_N) )
over the full horizon, enforced with ox.stl operators.
The balls are designed as a time-segmented relay: each ball covers a different time window along a feasible hand-off path, but no single ball spans the full journey. Only ball 0 covers the start and only ball 5 covers the goal, so the vehicle must switch zones to reach the finish.
File: examples/double_integrator/moving_safe_zones.py
import os
import sys
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.double_integrator._plotting import plot_moving_safe_zones
from openscvx import Problem
# Discretization
N = 30
TOTAL_TIME = 10.0
START = np.array([-4.0, 0.0])
GOAL = np.array([4.0, 0.0])
BALL_RADIUS = 0.6
# Knot times aligned with relay hand-offs (non-uniform so ball paths can
# diverge between segments).
T_KNOTS = np.array([0.0, 2.0, 3.5, 5.0, 7.5, 10.0])
# Feasible relay anchors (each lies inside exactly one ball at that time).
RELAY_KEYFRAMES = [
START,
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,
]
RELAY_NODES = [0, 6, 10, 15, 22, N - 1]
# Six relay balls — each covers part of the journey; no ball spans start→goal.
BALL_PATHS = [
# Ball 0: early high lane (only ball at t=0)
(
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]),
),
# Ball 1: high→mid transition (~2–5 s)
(
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]),
),
# Ball 2: alternate mid-high (never at start/goal)
(
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]),
),
# Ball 3: low trench (~3.5–8 s)
(
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]),
),
# Ball 4: late high option (never at start/goal)
(
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]),
),
# Ball 5: finisher (only ball at t=10)
(
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]),
),
]
# States
position = ox.State("position", shape=(2,))
position.min = np.array([-6.0, -2.5])
position.max = np.array([6.0, 2.5])
position.initial = START
position.final = GOAL
velocity = ox.State("velocity", shape=(2,))
velocity.min = np.array([-8.0, -8.0])
velocity.max = np.array([8.0, 8.0])
velocity.initial = np.array([0.0, 0.0])
velocity.final = [ox.Free(0.0), ox.Free(0.0)]
# Fixed final time so ball 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,
)
force = ox.Control("force", shape=(2,))
A_MAX = 12.0
force.max = np.array([A_MAX, A_MAX])
force.min = np.array([-A_MAX, -A_MAX])
force.guess = np.zeros((N, 2))
states = [position, velocity, time]
controls = [force]
m = 1.0
ball_radius = ox.Parameter("ball_radius", shape=(), value=BALL_RADIUS)
in_ball_predicates = []
for ball_x, ball_y in BALL_PATHS:
center = ox.Concat(
ox.Cinterp(time[0], T_KNOTS, ball_x, method="pchip"),
ox.Cinterp(time[0], T_KNOTS, ball_y, method="pchip"),
)
delta = position - center
in_ball_predicates.append(ox.Sum(delta * delta) <= ball_radius * ball_radius)
in_some_ball = ox.stl.Or(*in_ball_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_ball, (0, N - 1)).over())
dynamics = {
"position": velocity,
"velocity": (1.0 / m) * force,
"time": 1.0,
}
position.guess = ox.init.linspace(keyframes=RELAY_KEYFRAMES, nodes=RELAY_NODES)
velocity.guess = np.gradient(position.guess, TOTAL_TIME / (N - 1), axis=0)
force.guess = m * np.gradient(velocity.guess, TOTAL_TIME / (N - 1), axis=0)
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_data = {
"ball_radius": BALL_RADIUS,
"t_knots": T_KNOTS,
"ball_paths": BALL_PATHS,
"ball_interp_method": "pchip",
"start": START,
"goal": GOAL,
}
if __name__ == "__main__":
n_balls = len(BALL_PATHS)
print("2D Double Integrator — Moving Safe Zones (STL Or + Always)")
print("=" * 60)
print(f"Start: {START}, Goal: {GOAL}")
print(f"Balls: {n_balls}, radius: {BALL_RADIUS}")
print("Design: time-segmented relay (mandatory ball hand-offs)")
print(f"Spec: Always( Or(in_ball_0, ..., in_ball_{n_balls - 1}) ) over the horizon")
print("=" * 60)
problem.initialize()
results = problem.solve()
results = problem.post_process()
results.update(plotting_data)
plot_moving_safe_zones(results).show()