Ilqgames Three Agent Lqr¶
Three-agent iLQGames with original lqrax soft collision cost.
Same scenario as ilqgames_three_agent.py, but collision avoidance uses the
lqrax notebook's Gaussian repulsion in the running cost instead of CTCS
separation constraints:
10 * exp(-5 * ||p_i - p_j||^2)
per unordered agent pair. Navigation and control penalties match the notebook.
Compare with ilqgames_three_agent.py for the CTCS hard-separation variant.
Use :func:evaluate_lqrax_reference_costs to score an OpenSCvx solution with the
notebook's per-agent dt * sum(runtime_loss) metrics (nav / collision / control).
File: examples/multi_agent/ilqgames_three_agent_lqr.py
from __future__ import annotations
import os
import sys
from dataclasses import dataclass
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.multi_agent.ilqgames_three_agent import (
BICYCLE_GOAL,
BICYCLE_WHEELBASE,
DIFFDRIVE_GOAL,
POINT_GOAL,
TF,
N,
_show_plot,
_trajectory_arrays,
animate_ilqgames_three_agent,
plot_ilqgames_three_agent,
)
from openscvx import Problem
from openscvx.algorithms.optimization_results import OptimizationResults
# ── Cost weights (match lqrax ilqgames_example.ipynb) ────────────────────────
COLLISION_WEIGHT = 10.0
COLLISION_GAIN = 5.0
CTRL_WEIGHT = 0.1
# Notebook discretization (dt=0.05, 100 control steps → T=5 s)
LQRAX_DT = 0.05
LQRAX_STEPS = 100
# Reported notebook costs at iteration 200 (for quick comparison)
LQRAX_NOTEBOOK_BASELINE = {
"diffdrive": 1.06,
"point": 1.41,
"bicycle": 1.45,
}
def _sq_dist2(a0, a1, b0, b1) -> ox.Expr:
return (a0 - b0) ** 2 + (a1 - b1) ** 2
def _collision_penalty(ax0, ax1, bx0, bx1) -> ox.Expr:
return ox.Constant(COLLISION_WEIGHT) * ox.Exp(
-ox.Constant(COLLISION_GAIN) * _sq_dist2(ax0, ax1, bx0, bx1)
)
def build_problem() -> Problem:
# ── Diff-drive agent ─────────────────────────────────────────────────────
dd_pos = ox.State("diffdrive_pos", shape=(2,))
dd_pos.min = np.array([-3.0, -3.0])
dd_pos.max = np.array([3.0, 3.0])
dd_pos.initial = np.array([-2.0, -0.1])
dd_pos.final = DIFFDRIVE_GOAL.tolist()
dd_theta = ox.State("diffdrive_theta", shape=(1,))
dd_theta.min = np.array([-2.0 * np.pi])
dd_theta.max = np.array([2.0 * np.pi])
dd_theta.initial = np.array([0.0])
dd_theta.final = [0.0]
dd_v = ox.Control("diffdrive_v", shape=(1,), parameterization="ZOH")
dd_v.min = np.array([0.0])
dd_v.max = np.array([2.0])
dd_omega = ox.Control("diffdrive_omega", shape=(1,), parameterization="ZOH")
dd_omega.min = np.array([-3.0])
dd_omega.max = np.array([3.0])
# ── Point-mass agent ─────────────────────────────────────────────────────
pt_pos = ox.State("point_pos", shape=(2,))
pt_pos.min = np.array([-3.0, -3.0])
pt_pos.max = np.array([3.0, 3.0])
pt_pos.initial = np.array([2.0, 0.1])
pt_pos.final = POINT_GOAL.tolist()
pt_vel = ox.State("point_vel", shape=(2,))
pt_vel.min = np.array([-3.0, -3.0])
pt_vel.max = np.array([3.0, 3.0])
pt_vel.initial = np.array([-0.8, 0.0])
pt_vel.final = [ox.Free(0.0), ox.Free(0.0)]
pt_ax = ox.Control("point_ax", shape=(1,), parameterization="ZOH")
pt_ax.min = np.array([-2.0])
pt_ax.max = np.array([2.0])
pt_ay = ox.Control("point_ay", shape=(1,), parameterization="ZOH")
pt_ay.min = np.array([-2.0])
pt_ay.max = np.array([2.0])
# ── Bicycle agent ────────────────────────────────────────────────────────
bc_pos = ox.State("bicycle_pos", shape=(2,))
bc_pos.min = np.array([-3.0, -3.0])
bc_pos.max = np.array([3.0, 3.0])
bc_pos.initial = np.array([-0.2, -2.0])
bc_pos.final = BICYCLE_GOAL.tolist()
bc_theta = ox.State("bicycle_theta", shape=(1,))
bc_theta.min = np.array([-2.0 * np.pi])
bc_theta.max = np.array([2.0 * np.pi])
bc_theta.initial = [np.pi / 2.0]
bc_theta.final = [np.pi / 2.0]
bc_v = ox.Control("bicycle_v", shape=(1,), parameterization="ZOH")
bc_v.min = np.array([0.0])
bc_v.max = np.array([1.5])
bc_delta = ox.Control("bicycle_delta", shape=(1,), parameterization="ZOH")
bc_delta.min = np.array([-np.pi / 4.0])
bc_delta.max = np.array([np.pi / 4.0])
# ── Integrated running cost ──────────────────────────────────────────────
stage_cost = ox.State("stage_cost", shape=(1,))
stage_cost.min = np.array([0.0])
stage_cost.max = np.array([1e4])
stage_cost.initial = np.array([0.0])
stage_cost.final = [ox.Minimize(0.0)]
states = [
dd_pos,
dd_theta,
pt_pos,
pt_vel,
bc_pos,
bc_theta,
stage_cost,
]
controls = [dd_v, dd_omega, pt_ax, pt_ay, bc_v, bc_delta]
# ── Dynamics ─────────────────────────────────────────────────────────────
dd_nav = _sq_dist2(dd_pos[0], dd_pos[1], DIFFDRIVE_GOAL[0], DIFFDRIVE_GOAL[1])
pt_nav = _sq_dist2(pt_pos[0], pt_pos[1], POINT_GOAL[0], POINT_GOAL[1])
bc_nav = _sq_dist2(bc_pos[0], bc_pos[1], BICYCLE_GOAL[0], BICYCLE_GOAL[1])
dd_ctrl = ox.Constant(CTRL_WEIGHT) * (dd_v[0] ** 2 + ox.Constant(0.01) * dd_omega[0] ** 2)
pt_ctrl = ox.Constant(CTRL_WEIGHT) * (pt_ax[0] ** 2 + ox.Constant(0.5) * pt_ay[0] ** 2)
bc_ctrl = ox.Constant(CTRL_WEIGHT) * (bc_v[0] ** 2 + ox.Constant(0.01) * bc_delta[0] ** 2)
collision = (
_collision_penalty(dd_pos[0], dd_pos[1], pt_pos[0], pt_pos[1])
+ _collision_penalty(dd_pos[0], dd_pos[1], bc_pos[0], bc_pos[1])
+ _collision_penalty(pt_pos[0], pt_pos[1], bc_pos[0], bc_pos[1])
)
dynamics = {
"diffdrive_pos": ox.Concat(
dd_v[0] * ox.Cos(dd_theta[0]),
dd_v[0] * ox.Sin(dd_theta[0]),
),
"diffdrive_theta": dd_omega[0],
"point_pos": pt_vel,
"point_vel": ox.Concat(pt_ax[0], pt_ay[0]),
"bicycle_pos": ox.Concat(
bc_v[0] * ox.Cos(bc_theta[0]),
bc_v[0] * ox.Sin(bc_theta[0]),
),
"bicycle_theta": bc_v[0] * ox.Tan(bc_delta[0]) / ox.Constant(BICYCLE_WHEELBASE),
"stage_cost": dd_nav + pt_nav + bc_nav + collision + dd_ctrl + pt_ctrl + bc_ctrl,
}
constraints: list = []
for state in states:
constraints.extend([ox.ctcs(state <= state.max), ox.ctcs(state.min <= state)])
for control in controls:
constraints.extend([ox.ctcs(control <= control.max), ox.ctcs(control.min <= control)])
# ── Initial guess (notebook warm start) ────────────────────────────────
t_vals = np.linspace(0.0, 1.0, N)
dd_pos.guess = (1.0 - t_vals)[:, None] * dd_pos.initial + t_vals[:, None] * DIFFDRIVE_GOAL
dd_theta.guess = np.zeros((N, 1))
dd_v.guess = np.full((N, 1), 0.8)
dd_omega.guess = np.zeros((N, 1))
pt_pos.guess = (1.0 - t_vals)[:, None] * pt_pos.initial + t_vals[:, None] * POINT_GOAL
pt_vel.guess = np.tile(pt_vel.initial, (N, 1))
pt_ax.guess = np.zeros((N, 1))
pt_ay.guess = np.zeros((N, 1))
bc_pos.guess = (1.0 - t_vals)[:, None] * bc_pos.initial + t_vals[:, None] * BICYCLE_GOAL
bc_theta.guess = np.full((N, 1), np.pi / 2.0)
bc_v.guess = np.full((N, 1), 0.5)
bc_delta.guess = np.zeros((N, 1))
stage_cost.guess = np.zeros((N, 1))
time = ox.Time(
initial=0.0,
final=TF,
min=0.0,
max=TF,
# uniform_time_grid=True,
)
problem = Problem(
dynamics=dynamics,
states=states,
controls=controls,
time=time,
constraints=constraints,
N=N,
algorithm={
# "lam_prox": 2e0,
# "lam_vc": 1e1,
"lam_cost": 4e-1,
"k_max": 1000,
"ep_tr": 1e-6,
# "autotuner": ox.ConstantProximalWeight(),
},
)
problem.settings.prp.dt = 0.05
# problem.settings.dev.printing = False
return problem
@dataclass(frozen=True)
class AgentCostBreakdown:
"""Per-agent lqrax notebook cost J_i = dt * sum_k runtime_loss_i(k)."""
nav: float
collision: float
control: float
@property
def total(self) -> float:
return self.nav + self.collision + self.control
@dataclass(frozen=True)
class LqraxReferenceCosts:
"""Notebook-style costs for all three agents."""
diffdrive: AgentCostBreakdown
point: AgentCostBreakdown
bicycle: AgentCostBreakdown
dt: float
n_steps: int
openscvx_stage_cost: float | None = None
@property
def sum_per_agent(self) -> float:
return self.diffdrive.total + self.point.total + self.bicycle.total
def _lqrax_reference_paths(n_steps: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Straight-line xy references from ``ilqgames_example.ipynb`` (``[1:]`` slice)."""
dd_ref = np.linspace(np.array([-2.0, 0.0]), np.array([2.0, 0.0]), n_steps + 1)[1:]
pt_ref = np.linspace(np.array([2.0, 0.0]), np.array([-2.0, 0.0]), n_steps + 1)[1:]
bc_ref = np.linspace(np.array([0.0, -2.0]), np.array([0.0, 2.0]), n_steps + 1)[1:]
return dd_ref, pt_ref, bc_ref
def _resample_trajectory(t_src: np.ndarray, values: np.ndarray, t_eval: np.ndarray) -> np.ndarray:
"""Linearly resample ``values`` (n_src, dim) onto ``t_eval``."""
t_src = np.asarray(t_src, dtype=np.float64).reshape(-1)
values = np.asarray(values, dtype=np.float64)
if values.ndim == 1:
values = values.reshape(-1, 1)
out = np.empty((len(t_eval), values.shape[1]), dtype=np.float64)
for j in range(values.shape[1]):
out[:, j] = np.interp(t_eval, t_src, values[:, j])
return out
def _trajectory_time_and_series(
results: OptimizationResults, name: str
) -> tuple[np.ndarray, np.ndarray]:
if results.t_full is not None:
t = np.asarray(results.t_full, dtype=np.float64).reshape(-1)
elif results.trajectory and "time" in results.trajectory:
t = np.asarray(results.trajectory["time"], dtype=np.float64).reshape(-1)
else:
n = len(results.nodes[name])
t = np.linspace(0.0, TF, n)
if results.trajectory and name in results.trajectory:
values = np.asarray(results.trajectory[name], dtype=np.float64)
else:
values = np.asarray(results.nodes[name], dtype=np.float64)
return t, values
def evaluate_lqrax_reference_costs(
results: OptimizationResults,
*,
dt: float = LQRAX_DT,
n_steps: int = LQRAX_STEPS,
openscvx_stage_cost: float | None = None,
) -> LqraxReferenceCosts:
"""Evaluate lqrax ``ilqgames_example.ipynb`` per-agent costs on a trajectory.
Resamples ``results.trajectory`` (or node values) onto the notebook grid
``t = dt, 2*dt, ..., n_steps*dt`` and applies the same runtime losses as the
reference notebook (time-varying xy reference, Gaussian collision, weighted
control).
Args:
results: Post-processed optimization results.
dt: Notebook step size (default ``0.05``).
n_steps: Number of control intervals (default ``100``).
openscvx_stage_cost: Optional ``stage_cost(T)`` from OpenSCvx for reporting
alongside the notebook metrics (not directly comparable).
Returns:
:class:`LqraxReferenceCosts` with per-agent nav / collision / control totals.
"""
t_eval = np.arange(1, n_steps + 1, dtype=np.float64) * dt
dd_ref, pt_ref, bc_ref = _lqrax_reference_paths(n_steps)
t_dd, dd_pos = _trajectory_time_and_series(results, "diffdrive_pos")
t_pt, pt_pos = _trajectory_time_and_series(results, "point_pos")
t_bc, bc_pos = _trajectory_time_and_series(results, "bicycle_pos")
t_dd_u, dd_v = _trajectory_time_and_series(results, "diffdrive_v")
_, dd_omega = _trajectory_time_and_series(results, "diffdrive_omega")
t_pt_u, pt_ax = _trajectory_time_and_series(results, "point_ax")
_, pt_ay = _trajectory_time_and_series(results, "point_ay")
t_bc_u, bc_v = _trajectory_time_and_series(results, "bicycle_v")
_, bc_delta = _trajectory_time_and_series(results, "bicycle_delta")
dd_xy = _resample_trajectory(t_dd, dd_pos, t_eval)
pt_xy = _resample_trajectory(t_pt, pt_pos, t_eval)
bc_xy = _resample_trajectory(t_bc, bc_pos, t_eval)
dd_u = _resample_trajectory(
t_dd_u,
np.column_stack([dd_v.reshape(-1, 1), dd_omega.reshape(-1, 1)]),
t_eval,
)
pt_u = _resample_trajectory(
t_pt_u,
np.column_stack([pt_ax.reshape(-1, 1), pt_ay.reshape(-1, 1)]),
t_eval,
)
bc_u = _resample_trajectory(
t_bc_u,
np.column_stack([bc_v.reshape(-1, 1), bc_delta.reshape(-1, 1)]),
t_eval,
)
dd_nav = np.sum(np.sum((dd_xy - dd_ref) ** 2, axis=1))
pt_nav = np.sum(np.sum((pt_xy - pt_ref) ** 2, axis=1))
bc_nav = np.sum(np.sum((bc_xy - bc_ref) ** 2, axis=1))
dd_col = np.sum(
COLLISION_WEIGHT * np.exp(-COLLISION_GAIN * np.sum((dd_xy - pt_xy) ** 2, axis=1))
+ COLLISION_WEIGHT * np.exp(-COLLISION_GAIN * np.sum((dd_xy - bc_xy) ** 2, axis=1))
)
pt_col = np.sum(
COLLISION_WEIGHT * np.exp(-COLLISION_GAIN * np.sum((pt_xy - dd_xy) ** 2, axis=1))
+ COLLISION_WEIGHT * np.exp(-COLLISION_GAIN * np.sum((pt_xy - bc_xy) ** 2, axis=1))
)
bc_col = np.sum(
COLLISION_WEIGHT * np.exp(-COLLISION_GAIN * np.sum((bc_xy - dd_xy) ** 2, axis=1))
+ COLLISION_WEIGHT * np.exp(-COLLISION_GAIN * np.sum((bc_xy - pt_xy) ** 2, axis=1))
)
dd_ctrl = np.sum(CTRL_WEIGHT * np.sum((dd_u * np.array([1.0, 0.01])) ** 2, axis=1))
pt_ctrl = np.sum(CTRL_WEIGHT * np.sum((pt_u * np.array([1.0, 0.5])) ** 2, axis=1))
bc_ctrl = np.sum(CTRL_WEIGHT * np.sum((bc_u * np.array([1.0, 0.01])) ** 2, axis=1))
scale = dt
return LqraxReferenceCosts(
diffdrive=AgentCostBreakdown(dd_nav * scale, dd_col * scale, dd_ctrl * scale),
point=AgentCostBreakdown(pt_nav * scale, pt_col * scale, pt_ctrl * scale),
bicycle=AgentCostBreakdown(bc_nav * scale, bc_col * scale, bc_ctrl * scale),
dt=dt,
n_steps=n_steps,
openscvx_stage_cost=openscvx_stage_cost,
)
def print_lqrax_reference_costs(costs: LqraxReferenceCosts) -> None:
"""Pretty-print notebook costs with per-term breakdown and notebook baseline."""
agents = (
("diff-drive", "diffdrive", costs.diffdrive),
("point mass", "point", costs.point),
("bicycle", "bicycle", costs.bicycle),
)
print("\n── lqrax reference costs (ilqgames_example.ipynb) ──")
print(f"grid: dt={costs.dt:g} s, {costs.n_steps} steps")
for label, key, br in agents:
baseline = LQRAX_NOTEBOOK_BASELINE[key]
print(
f" {label:11s} total={br.total:6.2f} "
f"(nav={br.nav:6.2f}, coll={br.collision:6.2f}, ctrl={br.control:6.2f})"
f" | notebook iter-200: {baseline:.2f}"
)
print(f" sum (3 agents): {costs.sum_per_agent:.2f}")
if costs.openscvx_stage_cost is not None:
print(
f" OpenSCvx stage_cost(T): {costs.openscvx_stage_cost:.2f} "
"(centralized objective; not directly comparable)"
)
if __name__ == "__main__":
plot_solution = os.environ.get("OPENSCVX_NO_PLOT") is None
animation_path = os.environ.get(
"OPENSCVX_ILQGAMES_ANIM",
os.path.join(current_dir, "ilqgames_three_agent_lqr.mp4"),
)
problem = build_problem()
problem.initialize()
results = problem.solve()
results = problem.post_process()
nodes = results.nodes
print(f"converged: {results.converged}")
print(f"integrated cost: {nodes['stage_cost'][-1, 0]:.4f}")
print(f"diff-drive final pos: {nodes['diffdrive_pos'][-1]} (goal {DIFFDRIVE_GOAL})")
print(f"point mass final pos: {nodes['point_pos'][-1]} (goal {POINT_GOAL})")
print(f"bicycle final pos: {nodes['bicycle_pos'][-1]} (goal {BICYCLE_GOAL})")
ref_costs = evaluate_lqrax_reference_costs(
results,
openscvx_stage_cost=float(nodes["stage_cost"][-1, 0]),
)
print_lqrax_reference_costs(ref_costs)
if plot_solution:
_show_plot(plot_ilqgames_three_agent(results))
diffdrive, point, bicycle = _trajectory_arrays(results)
try:
animate_ilqgames_three_agent(
diffdrive,
point,
bicycle,
save_path=animation_path,
show=False,
)
except (RuntimeError, FileNotFoundError) as exc:
gif_path = os.path.splitext(animation_path)[0] + ".gif"
print(f"Could not save MP4 ({exc}); trying GIF at {gif_path}")
animate_ilqgames_three_agent(
diffdrive,
point,
bicycle,
save_path=gif_path,
show=False,
)