diff --git a/.gitignore b/.gitignore
index f58f9ed..c96c4aa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,9 @@ neural_models/
examples/c_generated_code/
examples/*_acados_ocp.json
+# Generated demo output (gifs, json dumps)
+examples/out_ipddp_mpcc_rc/
+
# PDF images
pdf_images/
diff --git a/CMakeLists.txt b/CMakeLists.txt
index d4c90c0..80a0893 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -20,7 +20,7 @@ set(CMAKE_POLICY_VERSION_MINIMUM "3.15" CACHE STRING "Minimum CMake version for
project(
cddp
- VERSION 0.5.1
+ VERSION 0.5.2
DESCRIPTION "CDDP: A C++ library for Trajectory Optimization and MPC"
HOMEPAGE_URL "https://github.com/astomodynamics/cddp-cpp"
)
@@ -147,7 +147,9 @@ set(cddp_core_srcs
src/cddp_core/dynamical_system.cpp
src/cddp_core/objective.cpp
src/cddp_core/constraint.cpp
+ src/cddp_core/cddp_context_utils.cpp
src/cddp_core/helper.cpp
+ src/cddp_core/interior_point_utils.cpp
src/cddp_core/boxqp.cpp
src/cddp_core/qp_solver.cpp
src/cddp_core/cddp_core.cpp
diff --git a/README.md b/README.md
index 00c3231..6547421 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,10 @@ $$
\quad \mathbf{g}(\mathbf{x}_k,\mathbf{u})_k\leq 0
$$
+$$
+\quad \mathbf{h}(\mathbf{x}_N)=0,\;\mathbf{g}_N(\mathbf{x}_N)\leq 0
+$$
+
$$
\quad {\mathbf{x}}_{0} = \mathbf{x}{(t_0)}
$$
@@ -28,6 +32,13 @@ $$
\quad k = 0,1,\cdots N-1
$$
+For interior-point solves, `IPDDP` supports path inequalities, terminal
+equalities, terminal inequalities, and mixed path/terminal constrained
+problems with warm-started multiplier and slack state. In the C++ API,
+terminal constraints are attached with `CDDP::addTerminalConstraint(...)`
+and should use the built-in `TerminalEqualityConstraint` or
+`TerminalInequalityConstraint` classes.
+
## Examples
The maintained example surface is now split:
@@ -81,6 +92,10 @@ The MPCC portfolio example is a lightweight kinematic contouring-control demo.
Its vendored track data in `examples/data/` is derived from the
[`alexliniger/MPCC`](https://github.com/alexliniger/MPCC) project.
+The portfolio currently emphasizes animated path-constrained examples. The
+terminal-constraint cases added to IPDDP are covered by the regression suite
+rather than by a dedicated gallery animation.
+
## Installation
### Dependencies
* [CMake](https://cmake.org/) (Build System)
diff --git a/docs/assets/python_portfolio/mpcc_racing_line.gif b/docs/assets/python_portfolio/mpcc_racing_line.gif
index cf443cf..9ac7fb7 100644
Binary files a/docs/assets/python_portfolio/mpcc_racing_line.gif and b/docs/assets/python_portfolio/mpcc_racing_line.gif differ
diff --git a/docs/cpp.md b/docs/cpp.md
index 4b5a46b..b4bd09d 100644
--- a/docs/cpp.md
+++ b/docs/cpp.md
@@ -30,3 +30,17 @@ That lets the repository serve both as:
- a source build for C++ consumers
- the native backend for the `pycddp` wheel
+
+## Constraint coverage
+
+`IPDDP` is the interior-point single-shooting solver in the C++ API. The
+current implementation supports:
+
+- path inequality constraints
+- terminal equality constraints via `TerminalEqualityConstraint`
+- terminal inequality constraints via `TerminalInequalityConstraint`
+- mixed path and terminal constrained problems
+
+Attach terminal constraints on `CDDP` with `addTerminalConstraint(...)`. IPDDP
+rejects unsupported terminal-constraint subclasses instead of silently ignoring
+them.
diff --git a/docs/index.md b/docs/index.md
index 398fd5c..cca4090 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -5,6 +5,7 @@
- a C++17 core library for trajectory optimization and MPC
- `pycddp` Python bindings built with `pybind11`
- a small animation-oriented Python portfolio for demos and regression checks
+- IPDDP support for path constraints plus terminal equality and terminal inequality constraints
Use the navigation to get started with installation, local development, and
the release workflow for PyPI and GitHub Pages.
diff --git a/docs/python.md b/docs/python.md
index bc9d000..65dc3a0 100644
--- a/docs/python.md
+++ b/docs/python.md
@@ -7,6 +7,22 @@ The Python package is published as `pycddp`.
- the native extension module `_pycddp_core`
- the public package namespace `pycddp`
- version metadata from `pycddp._version`
+- `CDDP` problem bindings with `add_constraint(...)` and `add_terminal_constraint(...)`
+
+## Solver coverage
+
+The Python package follows the same solver capability as the C++ core. For
+`IPDDP`, the regression suite now covers:
+
+- path-only constrained problems
+- terminal-inequality-only problems
+- terminal-equality-only problems
+- mixed path + terminal-equality problems
+- warm-start reuse of path, terminal-inequality, and terminal-equality state
+
+The current Python portfolio focuses on animated demos rather than terminal
+constraint examples, so terminal-constrained IPDDP coverage is documented here
+and exercised in tests.
## Local validation
diff --git a/docs/python_portfolio.md b/docs/python_portfolio.md
index 107f0d7..3590d35 100644
--- a/docs/python_portfolio.md
+++ b/docs/python_portfolio.md
@@ -13,6 +13,11 @@ Matplotlib.
actually produce: constrained solves, animated trajectories, and compact
reproducible workflows.
+
+ The gallery is intentionally path-focused. Terminal-equality and
+ terminal-inequality support in IPDDP is covered in the solver
+ regression suite rather than in a separate animation card.
+
Python package guide
Browse examples
diff --git a/examples/ipddp_mpcc_rc.py b/examples/ipddp_mpcc_rc.py
new file mode 100644
index 0000000..c6cff23
--- /dev/null
+++ b/examples/ipddp_mpcc_rc.py
@@ -0,0 +1,933 @@
+"""Closed-loop IPDDP MPCC on the bundled RC racing track.
+
+This module is a pycddp IPDDP port of AIRCoM's acados kinematic MPCC
+(``control_pkg/control_pkg/mpcc/solver.py``). It drives a 6 cm wheelbase
+RC car around the same ``examples/data/mpcc_racing_track.csv`` that the
+acados demo consumes, using a receding-horizon MPC loop with cold-seeded
+solves.
+
+Model — :class:`IpddpKinematicBicycle7`
+ 7-state augmented kinematic bicycle:
+ ``[x, y, psi, theta, v_prev, delta_prev, v_theta_prev]`` with control
+ ``[v_w, delta, v_theta]``. The last three states are control-history
+ latches implemented via the continuous expression
+ ``d(v_prev)/dt = (v_w - v_prev)/dt``; under Euler integration with
+ step size ``dt`` this latches ``v_prev_next = v_w`` exactly, giving
+ AIRCoM's rate-of-change residuals without having to override
+ ``get_discrete_dynamics`` from Python (which the pycddp binding does
+ not allow).
+
+Cost — :class:`IpddpRcMpccObjective`
+ 11-residual cost mirroring AIRCoM's ``NONLINEAR_LS`` shape (contour,
+ lag, speed, control regs, pose regs, three rate penalties) plus a
+ smooth one-sided quadratic boundary penalty that stands in for
+ AIRCoM's L1+quadratic slack on the soft track-boundary constraint
+ (pycddp's IPDDP has no slack variables). Weight tuning — notably
+ ``w_contour = 200`` and ``w_dv = 300`` — was chosen by sweep; see
+ the git log for the experiment that drove the numbers.
+
+What diverges from AIRCoM, and why
+ * **No per-stage parameter vector.** AIRCoM sets ``p[0..8]`` per
+ stage to hand the solver a time-varying reference; pycddp has no
+ stage-parameter slot, so the reference is derived from ``theta``
+ (state[3]) inside the cost via ``track.interpolate``.
+ * **No soft-constraint slack.** See the ``w_boundary`` note above.
+ * **Terminal regulariser zeroed.** AIRCoM's
+ ``base_terminal_velocity_weight = 40`` is a *parking-mode* term
+ that pulls the terminal ``v_prev`` toward zero; over 20 stages of
+ the ``w_dv`` rate-penalty chain, that quietly bleeds the cruise
+ speed every MPC tick. We anchor ``v_w`` directly via an
+ ``w_speed_w * (v_w - v_target)^2`` term on the *running* cost
+ instead, which has no backward-propagation artefact.
+ * **Cold-seeded every tick.** A primal-warm-start from the previous
+ IPDDP solution sounds like a good idea but in practice drags the
+ new solve into a stale basin near the old terminal — iter counts
+ climb and the first-stage control drifts off the line. A fresh
+ forward-roll along the reference converges in ~15-40 iterations
+ and produces clean tracking.
+
+Entry points
+ * :func:`solve_ipddp_mpcc_rc` — single-shot open-loop trajectory
+ optimisation against the track.
+ * :func:`run_ipddp_mpc` — closed-loop receding-horizon MPC loop that
+ feeds a ``DemoResult``-compatible history object to the existing
+ ``_animate_mpcc`` portfolio renderer.
+ * CLI: ``python ipddp_mpcc_rc.py --mpc --lap`` runs a full lap and
+ writes ``examples/out_ipddp_mpcc_rc/ipddp_mpcc_rc.gif``.
+"""
+
+from __future__ import annotations
+
+import sys
+import time
+from dataclasses import dataclass, field
+from pathlib import Path
+
+import numpy as np
+
+# Make ``python_portfolio_lib`` importable regardless of how this file is
+# loaded (direct CLI invocation or lazy import from ``python_portfolio_lib``
+# itself).
+_EXAMPLES_DIR = Path(__file__).resolve().parent
+if str(_EXAMPLES_DIR) not in sys.path:
+ sys.path.insert(0, str(_EXAMPLES_DIR))
+
+import pycddp # noqa: E402
+
+if __package__:
+ from .python_portfolio_lib import ( # noqa: E402
+ TrackData,
+ _load_track_csv,
+ _portfolio_track_path,
+ _wrap_angle,
+ )
+else:
+ from python_portfolio_lib import ( # noqa: E402
+ TrackData,
+ _load_track_csv,
+ _portfolio_track_path,
+ _wrap_angle,
+ )
+
+
+# ---------------------------------------------------------------------------
+# 7-state augmented kinematic bicycle (AIRCoM-style)
+# ---------------------------------------------------------------------------
+
+
+class IpddpKinematicBicycle7(pycddp.DynamicalSystem):
+ """7-state augmented kinematic bicycle that mirrors AIRCoM's MPCC model.
+
+ State (7-D): ``[x, y, psi, theta, v_prev, delta_prev, v_theta_prev]``
+ Control (3-D): ``[v_w, delta, v_theta]``
+
+ The first four states are the standard MPCC kinematic states (planar
+ pose plus the progress variable). The last three are *latches* that
+ hold the previously-applied control. They exist purely so the cost
+ can penalise rate-of-change residuals
+ ``(v_w - v_prev), (delta - delta_prev), (v_theta - v_theta_prev)`` —
+ exactly the smoothing AIRCoM uses to keep the controls calm.
+
+ The latch is implemented at the **continuous-dynamics** level via
+ ``d(v_prev)/dt = (v_w - v_prev) / dt``. Under Euler integration with
+ step size equal to ``dt``, this expression yields ``v_prev_next = v_w``
+ *exactly*, so we get the AIRCoM latching behaviour without having to
+ override ``get_discrete_dynamics`` from Python (which the pycddp
+ binding does not allow). Use ``"euler"`` as the integrator type or
+ the latch will only be approximate.
+ """
+
+ def __init__(self, timestep: float, wheelbase: float = 0.20) -> None:
+ super().__init__(7, 3, timestep, "euler")
+ self.dt = float(timestep)
+ self.wheelbase = float(wheelbase)
+ self._inv_dt = 1.0 / float(timestep)
+
+ def get_continuous_dynamics(
+ self,
+ state: np.ndarray,
+ control: np.ndarray,
+ time: float = 0.0,
+ ) -> np.ndarray:
+ psi = float(state[2])
+ v_prev = float(state[4])
+ delta_prev = float(state[5])
+ v_theta_prev = float(state[6])
+ v_w = float(control[0])
+ delta = float(control[1])
+ v_theta = float(control[2])
+ return np.array(
+ [
+ v_w * np.cos(psi),
+ v_w * np.sin(psi),
+ v_w * np.tan(delta) / self.wheelbase,
+ v_theta,
+ (v_w - v_prev) * self._inv_dt,
+ (delta - delta_prev) * self._inv_dt,
+ (v_theta - v_theta_prev) * self._inv_dt,
+ ],
+ dtype=float,
+ )
+
+ def get_state_jacobian(
+ self,
+ state: np.ndarray,
+ control: np.ndarray,
+ time: float = 0.0,
+ ) -> np.ndarray:
+ psi = float(state[2])
+ v_w = float(control[0])
+ A = np.zeros((7, 7), dtype=float)
+ A[0, 2] = -v_w * np.sin(psi)
+ A[1, 2] = v_w * np.cos(psi)
+ A[4, 4] = -self._inv_dt
+ A[5, 5] = -self._inv_dt
+ A[6, 6] = -self._inv_dt
+ return A
+
+ def get_control_jacobian(
+ self,
+ state: np.ndarray,
+ control: np.ndarray,
+ time: float = 0.0,
+ ) -> np.ndarray:
+ psi = float(state[2])
+ v_w = float(control[0])
+ delta = float(control[1])
+ sec_sq = 1.0 / (np.cos(delta) ** 2)
+ B = np.zeros((7, 3), dtype=float)
+ B[0, 0] = np.cos(psi)
+ B[1, 0] = np.sin(psi)
+ B[2, 0] = np.tan(delta) / self.wheelbase
+ B[2, 1] = v_w * sec_sq / self.wheelbase
+ B[3, 2] = 1.0
+ B[4, 0] = self._inv_dt
+ B[5, 1] = self._inv_dt
+ B[6, 2] = self._inv_dt
+ return B
+
+ def get_state_hessian(
+ self,
+ state: np.ndarray,
+ control: np.ndarray,
+ time: float = 0.0,
+ ) -> list[np.ndarray]:
+ return [np.zeros((7, 7), dtype=float) for _ in range(7)]
+
+ def get_control_hessian(
+ self,
+ state: np.ndarray,
+ control: np.ndarray,
+ time: float = 0.0,
+ ) -> list[np.ndarray]:
+ return [np.zeros((3, 3), dtype=float) for _ in range(7)]
+
+ def get_cross_hessian(
+ self,
+ state: np.ndarray,
+ control: np.ndarray,
+ time: float = 0.0,
+ ) -> list[np.ndarray]:
+ return [np.zeros((3, 7), dtype=float) for _ in range(7)]
+
+
+# State indices for the 7-state augmented model.
+IDX_X = 0
+IDX_Y = 1
+IDX_PSI = 2
+IDX_THETA = 3
+IDX_V_PREV = 4
+IDX_DELTA_PREV = 5
+IDX_V_THETA_PREV = 6
+
+
+# ---------------------------------------------------------------------------
+# Configuration
+# ---------------------------------------------------------------------------
+
+
+@dataclass(frozen=True)
+class IpddpRcMpccConfig:
+ """Knobs for the IPDDP MPCC problem (AIRCoM kinematic shape).
+
+ Cost weights mirror the AIRCoM ``MpccConfig`` defaults for the
+ *kinematic* MPCC (the simpler model in
+ ``control_pkg/control_pkg/mpcc/solver.py``), with one departure: the
+ soft track-boundary constraint is replaced by a smooth quadratic
+ penalty in the cost because pycddp's IPDDP has no slack variables.
+ """
+
+ dt: float = 0.05
+ # Horizon 20 × dt = 1 s lookahead, ~1.5 m at the 1.5 m/s cruise we
+ # actually execute. A grid probe (H∈{20,25} × band∈{0.85,0.90}) showed
+ # that longer horizon costs ~20% more wall-clock per step and doesn't
+ # improve the trajectory at all — the rate-penalty smoothing already
+ # sees as far forward as it needs to.
+ horizon: int = 20
+ # AIRCoM RC car: wheelbase = lf + lr = 0.029 + 0.033 = 0.062 m. With
+ # delta_max = 0.6 rad the bicycle's min turning radius is
+ # L/tan(0.6) ≈ 0.091 m, ~2x tighter than the 0.185 m min radius of the
+ # bundled CSV track.
+ wheelbase: float = 0.062
+ # Floor used when the track's curvature-scaled ``v_ref`` drops below
+ # this. AIRCoM keeps this at 1.0 m/s. The *upper* end of the speed
+ # profile comes from the CSV (which caps at 2.2 m/s on straights) via
+ # ``v_target = max(ref.v_ref, reference_speed)`` in the cost.
+ reference_speed: float = 1.0
+
+ # Control bounds (hard, via pycddp.ControlConstraint). AIRCoM's acados
+ # version caps v_max at 1.5 m/s because the Pacejka tire model gets
+ # numerically stiff above that for a 6 cm wheelbase. The kinematic
+ # bicycle has no such issue, so we let v_w follow the full 2.2 m/s
+ # track-speed profile on the straights for a proper racing line.
+ speed_min: float = 0.1
+ speed_max: float = 2.2
+ delta_max: float = 0.60
+ v_theta_min: float = 0.0
+ v_theta_max: float = 2.2
+
+ # 11-residual cost weights (NONLINEAR_LS diagonal W). Names match
+ # AIRCoM's ``control_pkg/.../mpcc/solver.py`` but the magnitudes are
+ # tuned for IPDDP on this track — see the weight-sweep in the commit
+ # message for the experimental justification.
+ #
+ # Notable deviations from AIRCoM's defaults:
+ # * ``w_contour = 200`` (up from 100): tightens tracking AND makes the
+ # cost Hessian more strictly convex in position, so IPDDP converges
+ # ~12% faster per step.
+ # * ``w_dv = 300`` (down from 1000): over-strong rate penalty was the
+ # main per-step cost driver without meaningfully improving
+ # smoothness. Lowering it speeds solves up ~14%.
+ w_contour: float = 200.0 # (n . d)^2
+ w_lag: float = 100.0 # (t . d)^2
+ w_speed: float = 5.0 # (v_theta - v_ref)^2
+ w_control: float = 0.1 # v_w^2 and delta^2
+ w_x: float = 0.0 # dx_err^2 (zero by default like AIRCoM)
+ w_y: float = 0.0 # dy_err^2
+ w_yaw: float = 0.0 # e_yaw^2
+ # Extra: explicit v_w tracking on every stage. AIRCoM doesn't have this
+ # (their v_w comes for free through lag-error coupling + terminal v_prev
+ # regulariser), but here it anchors v_w directly so the rate-penalty
+ # chain has nothing to bleed against. Cheaper and more stable for IPDDP
+ # than a pure terminal anchor.
+ w_speed_w: float = 10.0 # (v_w - v_target)^2 on every stage
+ # Rate-of-change weights — the key smoothing terms.
+ w_dv: float = 300.0
+ w_ddelta: float = 1000.0
+ w_dv_theta: float = 100.0
+
+ # Smooth track-boundary penalty (replaces AIRCoM's L1+quadratic slack).
+ # The grid probe showed the band doesn't get reached on this track —
+ # w_contour (100) already pulls the car within 0.12 m of the
+ # centerline, well inside 0.85 * 0.18 = 0.153 m. This is a safety
+ # margin, not an active shaping term.
+ w_boundary: float = 200.0
+ boundary_band: float = 0.85
+
+ # Terminal weights — AIRCoM W_e diagonal, but with the velocity/steering
+ # anchors *zeroed out*. AIRCoM uses those for parking mode (pull the car
+ # to a stop at the goal); for racing-line MPC they propagate backward
+ # through 20 stages of the rate-penalty chain and bleed the forward
+ # speed every tick. We replace their role with ``w_speed_w`` on the
+ # running cost, which anchors v_w directly at every stage.
+ w_terminal: float = 50.0
+ base_terminal_velocity_weight: float = 0.0
+ base_terminal_steering_weight: float = 0.0
+ # Linear reward for advancing the progress variable. Small — just a
+ # tiebreaker so the terminal cost prefers reaching farther ``theta``.
+ w_terminal_progress: float = 2.0
+
+ # IPDDP solver options.
+ max_iterations: int = 100
+ tolerance: float = 1e-4
+ acceptable_tolerance: float = 5e-4
+ initial_regularization: float = 1e-4
+ line_search_iters: int = 12
+
+
+# ---------------------------------------------------------------------------
+# Cost
+# ---------------------------------------------------------------------------
+
+
+class IpddpRcMpccObjective(pycddp.NonlinearObjective):
+ """11-residual MPCC cost matching AIRCoM's NONLINEAR_LS shape.
+
+ Operates on the 7-state augmented kinematic bicycle:
+ ``state = [x, y, psi, theta, v_prev, delta_prev, v_theta_prev]`` and
+ ``control = [v_w, delta, v_theta]``.
+
+ The running cost is ``dt * sum_i w_i * r_i^2`` over the same eleven
+ residuals AIRCoM uses (`solver.py:240`):
+
+ 1. contour error ``e_c = n . (p - p_ref)``
+ 2. lag error ``e_l = t . (p - p_ref)``
+ 3. speed-tracking ``v_theta - v_ref``
+ 4. control regulariser on ``v_w``
+ 5. control regulariser on ``delta``
+ 6. position error ``dx`` (zero weight by default — AIRCoM)
+ 7. position error ``dy`` (zero weight by default — AIRCoM)
+ 8. yaw error ``e_yaw`` (zero weight by default — AIRCoM)
+ 9. rate ``v_w - v_prev``
+ 10. rate ``delta - delta_prev``
+ 11. rate ``v_theta - v_theta_prev``
+
+ Plus a smooth quadratic boundary penalty
+ ``w_boundary * max(0, |e_c| - boundary_band * half_width)^2``
+ that stands in for AIRCoM's L1+quadratic slack on the soft
+ ``-half_width <= e_c <= half_width`` constraint (pycddp IPDDP has no
+ slack variables).
+
+ The reference parameters AIRCoM passes via ``p[stage]`` are looked
+ up from ``theta`` (state[3]) on the track instead — that turns the
+ time-varying reference into a function of state, which is what IPDDP
+ needs.
+ """
+
+ def __init__(self, cfg: IpddpRcMpccConfig, track: TrackData) -> None:
+ super().__init__(cfg.dt)
+ self.cfg = cfg
+ self.track = track
+ self.dt = float(cfg.dt)
+ self._half_width = float(track.width)
+
+ def _tracking(self, state: np.ndarray) -> tuple[float, float, float, float, float, float]:
+ theta = float(state[IDX_THETA])
+ ref = self.track.interpolate(theta)
+ dx = float(state[IDX_X]) - ref.x
+ dy = float(state[IDX_Y]) - ref.y
+ e_c = ref.normal[0] * dx + ref.normal[1] * dy
+ e_l = ref.tangent[0] * dx + ref.tangent[1] * dy
+ e_yaw = _wrap_angle(float(state[IDX_PSI]) - ref.heading)
+ return e_c, e_l, e_yaw, ref.v_ref, dx, dy
+
+ def running_cost(
+ self,
+ state: np.ndarray,
+ control: np.ndarray,
+ index: int,
+ ) -> float:
+ cfg = self.cfg
+ e_c, e_l, e_yaw, v_ref_track, dx, dy = self._tracking(state)
+ v_prev = float(state[IDX_V_PREV])
+ delta_prev = float(state[IDX_DELTA_PREV])
+ v_theta_prev = float(state[IDX_V_THETA_PREV])
+ v_w = float(control[0])
+ delta = float(control[1])
+ v_theta = float(control[2])
+
+ v_target = max(v_ref_track, cfg.reference_speed)
+ boundary = max(0.0, abs(e_c) - cfg.boundary_band * self._half_width)
+
+ return float(
+ self.dt
+ * (
+ cfg.w_contour * e_c * e_c
+ + cfg.w_lag * e_l * e_l
+ + cfg.w_speed * (v_theta - v_target) ** 2
+ + cfg.w_speed_w * (v_w - v_target) ** 2
+ + cfg.w_control * v_w * v_w
+ + cfg.w_control * delta * delta
+ + cfg.w_x * dx * dx
+ + cfg.w_y * dy * dy
+ + cfg.w_yaw * e_yaw * e_yaw
+ + cfg.w_dv * (v_w - v_prev) ** 2
+ + cfg.w_ddelta * (delta - delta_prev) ** 2
+ + cfg.w_dv_theta * (v_theta - v_theta_prev) ** 2
+ + cfg.w_boundary * boundary * boundary
+ )
+ )
+
+ def terminal_cost(self, final_state: np.ndarray) -> float:
+ cfg = self.cfg
+ e_c, e_l, _e_yaw, _v_ref, _dx, _dy = self._tracking(final_state)
+ theta = float(final_state[IDX_THETA])
+ return float(
+ cfg.w_terminal * e_c * e_c
+ + cfg.w_terminal * e_l * e_l
+ - cfg.w_terminal_progress * theta
+ )
+
+
+# ---------------------------------------------------------------------------
+# Result container
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class IpddpRcMpccResult:
+ """One-shot IPDDP MPCC solution."""
+
+ solution: pycddp.CDDPSolution
+ states: np.ndarray
+ controls: np.ndarray
+ time_points: np.ndarray
+ track: TrackData
+ cfg: IpddpRcMpccConfig
+
+ @property
+ def lap_progress(self) -> float:
+ if self.states.size == 0:
+ return 0.0
+ return float(self.states[-1, IDX_THETA] - self.states[0, IDX_THETA])
+
+ @property
+ def contour_errors(self) -> np.ndarray:
+ out = np.empty(self.states.shape[0], dtype=float)
+ for i, state in enumerate(self.states):
+ ref = self.track.interpolate(float(state[IDX_THETA]))
+ d = state[:2] - np.array([ref.x, ref.y], dtype=float)
+ out[i] = float(ref.normal @ d)
+ return out
+
+ @property
+ def lag_errors(self) -> np.ndarray:
+ out = np.empty(self.states.shape[0], dtype=float)
+ for i, state in enumerate(self.states):
+ ref = self.track.interpolate(float(state[IDX_THETA]))
+ d = state[:2] - np.array([ref.x, ref.y], dtype=float)
+ out[i] = float(ref.tangent @ d)
+ return out
+
+
+# ---------------------------------------------------------------------------
+# Solver setup + run
+# ---------------------------------------------------------------------------
+
+
+def _initial_state(track: TrackData, cfg: IpddpRcMpccConfig) -> np.ndarray:
+ """Place the car on the centerline at theta = s[0], aligned heading.
+
+ The control-latch components are seeded at the cruise speed so the very
+ first stage's rate residuals (``v_w - v_prev`` etc.) start near zero —
+ important for the strong AIRCoM rate-penalty weights.
+ """
+ ref = track.interpolate(float(track.s[0]))
+ cruise = float(np.clip(cfg.reference_speed, cfg.speed_min, cfg.speed_max))
+ v_theta = float(np.clip(cruise, cfg.v_theta_min, cfg.v_theta_max))
+ return np.array(
+ [
+ ref.x,
+ ref.y,
+ ref.heading,
+ float(track.s[0]),
+ cruise, # v_prev
+ 0.0, # delta_prev
+ v_theta, # v_theta_prev
+ ],
+ dtype=float,
+ )
+
+
+def _seed_controls(
+ track: TrackData,
+ cfg: IpddpRcMpccConfig,
+ initial_progress: float,
+) -> list[np.ndarray]:
+ """Cold-start control seed: roll forward along the reference at v_ref."""
+ seeds: list[np.ndarray] = []
+ progress = float(initial_progress)
+ wheelbase = cfg.wheelbase
+ for _ in range(cfg.horizon):
+ ref = track.interpolate(progress)
+ v_target = float(np.clip(max(ref.v_ref, cfg.reference_speed), cfg.speed_min, cfg.speed_max))
+ steer_guess = float(
+ np.clip(np.arctan(wheelbase * ref.curvature), -cfg.delta_max, cfg.delta_max)
+ )
+ v_theta = float(np.clip(v_target, cfg.v_theta_min, cfg.v_theta_max))
+ seeds.append(np.array([v_target, steer_guess, v_theta], dtype=float))
+ progress += cfg.dt * v_theta
+ return seeds
+
+
+def _rollout(
+ model: IpddpKinematicBicycle7, x0: np.ndarray, controls: list[np.ndarray]
+) -> tuple[list[np.ndarray], list[np.ndarray]]:
+ state = x0.copy()
+ states = [state.copy()]
+ rolled: list[np.ndarray] = []
+ for u in controls:
+ u_arr = np.asarray(u, dtype=float).copy()
+ rolled.append(u_arr)
+ state = np.asarray(model.get_discrete_dynamics(state, u_arr), dtype=float)
+ states.append(state.copy())
+ return states, rolled
+
+
+def build_solver(
+ cfg: IpddpRcMpccConfig,
+ track: TrackData,
+) -> tuple[pycddp.CDDP, IpddpKinematicBicycle7, IpddpRcMpccObjective, np.ndarray]:
+ """Build a fully configured pycddp CDDP solver for the simplified MPCC."""
+ model = IpddpKinematicBicycle7(cfg.dt, wheelbase=cfg.wheelbase)
+ objective = IpddpRcMpccObjective(cfg, track)
+
+ opts = pycddp.CDDPOptions()
+ opts.max_iterations = cfg.max_iterations
+ opts.tolerance = cfg.tolerance
+ opts.acceptable_tolerance = cfg.acceptable_tolerance
+ opts.regularization.initial_value = cfg.initial_regularization
+ opts.line_search.max_iterations = cfg.line_search_iters
+ opts.use_ilqr = True # avoid relying on dynamics Hessians
+ opts.verbose = False
+ opts.print_solver_header = False
+ opts.return_iteration_info = True
+
+ x0 = _initial_state(track, cfg)
+ # Reference state argument is consumed by the QuadraticObjective path; for
+ # NonlinearObjective the solver only uses it as bookkeeping, so we point it
+ # at the same starting reference.
+ x_ref = x0.copy()
+
+ solver = pycddp.CDDP(x0, x_ref, cfg.horizon, cfg.dt, opts)
+ solver.set_dynamical_system(model)
+ solver.set_objective(objective)
+ solver.add_constraint(
+ "control_limits",
+ pycddp.ControlConstraint(
+ np.array([cfg.speed_min, -cfg.delta_max, cfg.v_theta_min], dtype=float),
+ np.array([cfg.speed_max, cfg.delta_max, cfg.v_theta_max], dtype=float),
+ ),
+ )
+
+ return solver, model, objective, x0
+
+
+def solve_ipddp_mpcc_rc(
+ cfg: IpddpRcMpccConfig | None = None,
+ track: TrackData | None = None,
+) -> IpddpRcMpccResult:
+ """Run a single open-loop IPDDP solve of the simplified MPCC."""
+ if cfg is None:
+ cfg = IpddpRcMpccConfig()
+ if track is None:
+ track = _load_track_csv(_portfolio_track_path())
+
+ solver, model, _objective, x0 = build_solver(cfg, track)
+
+ seed_u = _seed_controls(track, cfg, float(x0[3]))
+ seed_x, seed_u = _rollout(model, x0, seed_u)
+ solver.set_initial_trajectory(seed_x, seed_u)
+
+ solution = solver.solve(pycddp.SolverType.IPDDP)
+ if not solution.state_trajectory or not solution.control_trajectory:
+ raise RuntimeError("IPDDP MPCC RC solve produced an empty trajectory.")
+
+ states = np.vstack([np.asarray(x, dtype=float) for x in solution.state_trajectory])
+ controls = np.vstack([np.asarray(u, dtype=float) for u in solution.control_trajectory])
+ time_points = np.asarray(solution.time_points, dtype=float)
+
+ return IpddpRcMpccResult(
+ solution=solution,
+ states=states,
+ controls=controls,
+ time_points=time_points,
+ track=track,
+ cfg=cfg,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Closed-loop MPC runner
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class IpddpRcMpccHistory:
+ """Closed-loop history captured per MPC step."""
+
+ executed_states: list[np.ndarray] = field(default_factory=list)
+ executed_controls: list[np.ndarray] = field(default_factory=list)
+ contour_errors: list[float] = field(default_factory=list)
+ lag_errors: list[float] = field(default_factory=list)
+ solve_times_ms: list[float] = field(default_factory=list)
+ predicted_paths: list[np.ndarray] = field(default_factory=list)
+ reference_windows: list[np.ndarray] = field(default_factory=list)
+ failures: int = 0
+
+
+def _reference_window_from_predicted(
+ track: TrackData, predicted_states: list[np.ndarray]
+) -> np.ndarray:
+ """Sample the track centerline at the progress of each predicted stage.
+
+ The visualization overlays this alongside the IPDDP prediction, so the
+ two lines need to span the **same** stages — sampling at a fixed
+ ``reference_speed`` stride makes the orange line shorter than the red
+ one whenever the solver is running above that cruise speed (the case
+ on straights). Keying off ``predicted_states[k][IDX_THETA]`` keeps the
+ orange line aligned with what the solver is actually aiming at.
+ """
+ pts = np.empty((len(predicted_states), 2), dtype=float)
+ for stage, x in enumerate(predicted_states):
+ ref = track.interpolate(float(x[IDX_THETA]))
+ pts[stage, 0] = ref.x
+ pts[stage, 1] = ref.y
+ return pts
+
+
+def run_ipddp_mpc(
+ cfg: IpddpRcMpccConfig | None = None,
+ track: TrackData | None = None,
+ *,
+ simulation_steps: int = 60,
+ stop_at_progress: float | None = None,
+ show_progress: bool = False,
+) -> IpddpRcMpccHistory:
+ """Run closed-loop receding-horizon IPDDP MPC.
+
+ At each step we re-solve the IPDDP problem from the current plant state,
+ apply the first control to the kinematic bicycle, and cold-seed the
+ next solve from a forward-roll along the reference. The whole history
+ is captured in a form compatible with ``_animate_mpcc``.
+
+ If ``stop_at_progress`` is given, the simulation breaks as soon as the
+ executed ``theta`` crosses that threshold — use
+ ``stop_at_progress=track.length`` to stop after one full lap.
+ ``simulation_steps`` still acts as an upper bound so a wandering run
+ cannot loop forever.
+ """
+ if cfg is None:
+ cfg = IpddpRcMpccConfig()
+ if track is None:
+ track = _load_track_csv(_portfolio_track_path())
+ if simulation_steps < 1:
+ raise ValueError("simulation_steps must be >= 1")
+
+ solver, model, _objective, x0 = build_solver(cfg, track)
+
+ state = x0.copy()
+ history = IpddpRcMpccHistory(executed_states=[state.copy()])
+
+ last_control = np.zeros(3, dtype=float)
+
+ for step_index in range(simulation_steps):
+ # Track-relative metrics at the current state.
+ ref_now = track.interpolate(float(state[IDX_THETA]))
+ d_pos = state[:2] - np.array([ref_now.x, ref_now.y], dtype=float)
+ history.contour_errors.append(float(ref_now.normal @ d_pos))
+ history.lag_errors.append(float(ref_now.tangent @ d_pos))
+
+ # Cold-seed every step. The shifted warm-start from the previous
+ # IPDDP solution sounds like it should be a good idea but in
+ # practice it tends to drag the new solve into a stale basin near
+ # the old terminal: iter counts climb and the first-stage control
+ # drifts off the line. A fresh forward-roll along the reference
+ # happens to be both faster and far more stable for this problem.
+ seed_u = _seed_controls(track, cfg, float(state[IDX_THETA]))
+ seed_x, seed_u = _rollout(model, state, seed_u)
+ solver.set_initial_state(state)
+ solver.set_initial_trajectory(seed_x, seed_u)
+
+ t0 = time.perf_counter()
+ sol = solver.solve(pycddp.SolverType.IPDDP)
+ history.solve_times_ms.append((time.perf_counter() - t0) * 1000.0)
+
+ if not sol.state_trajectory or not sol.control_trajectory:
+ history.failures += 1
+ control = last_control.copy()
+ predicted_states = [state.copy() for _ in range(cfg.horizon + 1)]
+ predicted_controls = [last_control.copy() for _ in range(cfg.horizon)]
+ else:
+ predicted_states = [
+ np.asarray(x, dtype=float).copy() for x in sol.state_trajectory
+ ]
+ predicted_controls = [
+ np.asarray(u, dtype=float).copy() for u in sol.control_trajectory
+ ]
+ control = predicted_controls[0].copy()
+ if not np.all(np.isfinite(control)):
+ history.failures += 1
+ control = last_control.copy()
+ else:
+ last_control = control.copy()
+
+ history.predicted_paths.append(
+ np.vstack([s[:2] for s in predicted_states])
+ )
+ history.reference_windows.append(
+ _reference_window_from_predicted(track, predicted_states)
+ )
+
+ # Hard-clip the control (defensive — IPDDP should already respect bounds).
+ control[0] = float(np.clip(control[0], cfg.speed_min, cfg.speed_max))
+ control[1] = float(np.clip(control[1], -cfg.delta_max, cfg.delta_max))
+ control[2] = float(np.clip(control[2], cfg.v_theta_min, cfg.v_theta_max))
+
+ # Advance the plant.
+ next_state = np.asarray(model.get_discrete_dynamics(state, control), dtype=float)
+ next_state[IDX_PSI] = _wrap_angle(float(next_state[IDX_PSI]))
+ history.executed_controls.append(control.copy())
+ state = next_state
+ history.executed_states.append(state.copy())
+
+ if show_progress and (
+ step_index == 0
+ or step_index + 1 == simulation_steps
+ or (step_index + 1) % 5 == 0
+ ):
+ print(
+ f"[ipddp_mpc] step {step_index + 1:3d}/{simulation_steps} "
+ f"solve={history.solve_times_ms[-1]:6.1f} ms iter={sol.iterations_completed:3d} "
+ f"contour={history.contour_errors[-1]:+.3f} progress={float(state[IDX_THETA]):.2f} m",
+ flush=True,
+ )
+
+ if stop_at_progress is not None and float(state[IDX_THETA]) >= stop_at_progress:
+ if show_progress:
+ print(
+ f"[ipddp_mpc] lap finished at step {step_index + 1}, "
+ f"progress={float(state[IDX_THETA]):.2f} m",
+ flush=True,
+ )
+ break
+
+ # Final tracking pair so the time series matches len(executed_states).
+ final_ref = track.interpolate(float(state[3]))
+ final_d = state[:2] - np.array([final_ref.x, final_ref.y], dtype=float)
+ history.contour_errors.append(float(final_ref.normal @ final_d))
+ history.lag_errors.append(float(final_ref.tangent @ final_d))
+
+ return history
+
+
+def history_to_demo_result(
+ history: IpddpRcMpccHistory,
+ track: TrackData,
+ cfg: IpddpRcMpccConfig,
+) -> "object":
+ """Wrap the MPC history as a ``DemoResult`` so ``save_animation`` works."""
+ if not history.executed_controls:
+ raise ValueError("history must contain at least one executed control")
+ if __package__:
+ from .python_portfolio_lib import DemoResult # local import
+ else:
+ from python_portfolio_lib import DemoResult # local import
+
+ full_states = np.vstack(history.executed_states)
+ # The animator destructures (x, y, heading, progress) from each state row,
+ # so project the 7-state augmented vector down to its first four
+ # components before handing it over.
+ states = full_states[:, :4]
+ controls = np.vstack(history.executed_controls)
+ time_points_arr = np.arange(len(history.executed_states), dtype=float) * cfg.dt
+ final_ref = track.interpolate(float(full_states[-1, IDX_THETA]))
+ target_state = np.array(
+ [final_ref.x, final_ref.y, final_ref.heading, float(full_states[-1, IDX_THETA])],
+ dtype=float,
+ )
+
+ # Synthesize a minimal CDDPSolution-shaped object — _animate_mpcc only
+ # touches result.metadata + result.states/controls/time_points/target_state,
+ # not the solution itself, so a sentinel with the right attributes works.
+ stub = type(
+ "_StubSolution",
+ (),
+ {
+ "solver_name": "IPDDP MPC",
+ "final_objective": 0.0,
+ "final_primal_infeasibility": 0.0,
+ "iterations_completed": 0,
+ "solve_time_ms": float(np.sum(history.solve_times_ms)),
+ "state_trajectory": list(history.executed_states),
+ "control_trajectory": list(history.executed_controls),
+ "time_points": time_points_arr,
+ },
+ )()
+
+ return DemoResult(
+ slug="mpcc_racing_line",
+ title="IPDDP MPCC RC",
+ solver_name="IPDDP MPC",
+ solution=stub,
+ states=states,
+ controls=controls,
+ time_points=time_points_arr,
+ target_state=target_state,
+ metadata={
+ "timestep": cfg.dt,
+ "track": track,
+ "track_width": track.width,
+ "contour_errors": np.asarray(history.contour_errors, dtype=float),
+ "lag_errors": np.asarray(history.lag_errors, dtype=float),
+ "solve_times_ms": np.asarray(history.solve_times_ms, dtype=float),
+ "predicted_paths": history.predicted_paths,
+ "reference_windows": history.reference_windows,
+ "subtitle": "Closed-loop IPDDP receding-horizon MPC on the bundled racing track",
+ "final_error_override": float(
+ np.hypot(history.contour_errors[-1], history.lag_errors[-1])
+ ),
+ },
+ )
+
+
+# ---------------------------------------------------------------------------
+# CLI entry
+# ---------------------------------------------------------------------------
+
+
+def main() -> None:
+ import argparse
+
+ parser = argparse.ArgumentParser(description="IPDDP MPCC RC demo.")
+ parser.add_argument("--mpc", action="store_true", help="Run closed-loop MPC and save a GIF.")
+ parser.add_argument("--steps", type=int, default=60, help="Closed-loop simulation steps (MPC mode). Ignored if --lap.")
+ parser.add_argument("--lap", action="store_true",
+ help="Run until a full lap is completed (theta >= track.length).")
+ parser.add_argument("--max-steps", type=int, default=600,
+ help="Safety cap on simulation steps when running a full lap.")
+ parser.add_argument("--horizon", type=int, default=12, help="MPC horizon length.")
+ parser.add_argument("--cap", type=int, default=60, help="Per-solve IPDDP iteration cap (MPC mode).")
+ parser.add_argument("--gif", type=str, default="examples/out_ipddp_mpcc_rc/ipddp_mpcc_rc.gif",
+ help="Output GIF path.")
+ parser.add_argument("--fps", type=int, default=12)
+ parser.add_argument("--dpi", type=int, default=110)
+ args = parser.parse_args()
+
+ if args.steps < 1:
+ parser.error("--steps must be >= 1")
+ if args.max_steps < 1:
+ parser.error("--max-steps must be >= 1")
+ if args.horizon < 1:
+ parser.error("--horizon must be >= 1")
+ if args.cap < 1:
+ parser.error("--cap must be >= 1")
+ if args.fps < 1:
+ parser.error("--fps must be >= 1")
+ if args.dpi < 1:
+ parser.error("--dpi must be >= 1")
+
+ if args.mpc:
+ cfg = IpddpRcMpccConfig(
+ horizon=args.horizon,
+ max_iterations=args.cap,
+ dt=0.05,
+ )
+ track = _load_track_csv(_portfolio_track_path())
+ if args.lap:
+ steps = args.max_steps
+ stop_at = float(track.length)
+ else:
+ steps = args.steps
+ stop_at = None
+ history = run_ipddp_mpc(
+ cfg,
+ track,
+ simulation_steps=steps,
+ stop_at_progress=stop_at,
+ show_progress=True,
+ )
+ print(
+ f"[ipddp_mpc] total solve = {sum(history.solve_times_ms):.0f} ms, "
+ f"failures = {history.failures}"
+ )
+
+ if __package__:
+ from .python_portfolio_lib import save_animation # local import
+ else:
+ from python_portfolio_lib import save_animation # local import
+ result = history_to_demo_result(history, track, cfg)
+ out = save_animation(result, args.gif, fps=args.fps, dpi=args.dpi, frame_step=1)
+ print(f"[ipddp_mpc] saved gif -> {out}")
+ return
+
+ cfg = IpddpRcMpccConfig()
+ result = solve_ipddp_mpcc_rc(cfg)
+ sol = result.solution
+ print(f"[ipddp_mpcc_rc] solver = {sol.solver_name}")
+ print(f"[ipddp_mpcc_rc] iterations = {sol.iterations_completed}")
+ print(f"[ipddp_mpcc_rc] solve_time_ms = {sol.solve_time_ms:.1f}")
+ print(f"[ipddp_mpcc_rc] objective = {sol.final_objective:+.4f}")
+ print(
+ f"[ipddp_mpcc_rc] primal_inf = {sol.final_primal_infeasibility:+.3e}"
+ )
+ print(f"[ipddp_mpcc_rc] lap_progress = {result.lap_progress:.3f} m")
+ contour = result.contour_errors
+ print(
+ f"[ipddp_mpcc_rc] |contour|: max={np.max(np.abs(contour)):.3f}, "
+ f"rms={float(np.sqrt(np.mean(contour ** 2))):.3f}"
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/python_portfolio_lib.py b/examples/python_portfolio_lib.py
index 2b5cbc1..781dbea 100644
--- a/examples/python_portfolio_lib.py
+++ b/examples/python_portfolio_lib.py
@@ -243,166 +243,6 @@ def _load_track_csv(
)
-class MpccBicycle(pycddp.DynamicalSystem):
- def __init__(self, timestep: float, wheelbase: float = 0.33) -> None:
- super().__init__(4, 3, timestep, "euler")
- self.wheelbase = float(wheelbase)
-
- def get_continuous_dynamics(
- self,
- state: np.ndarray,
- control: np.ndarray,
- time: float = 0.0,
- ) -> np.ndarray:
- speed, steering_angle, progress_rate = np.asarray(control, dtype=float)
- heading = float(state[2])
- beta = float(np.arctan(0.5 * np.tan(steering_angle)))
- return np.array(
- [
- speed * np.cos(heading + beta),
- speed * np.sin(heading + beta),
- 2.0 * speed * np.sin(beta) / self.wheelbase,
- progress_rate,
- ],
- dtype=float,
- )
-
- def get_state_jacobian(
- self,
- state: np.ndarray,
- control: np.ndarray,
- time: float = 0.0,
- ) -> np.ndarray:
- speed = float(control[0])
- heading = float(state[2])
- steering_angle = float(control[1])
- beta = float(np.arctan(0.5 * np.tan(steering_angle)))
- return np.array(
- [
- [0.0, 0.0, -speed * np.sin(heading + beta), 0.0],
- [0.0, 0.0, speed * np.cos(heading + beta), 0.0],
- [0.0, 0.0, 0.0, 0.0],
- [0.0, 0.0, 0.0, 0.0],
- ],
- dtype=float,
- )
-
- def get_control_jacobian(
- self,
- state: np.ndarray,
- control: np.ndarray,
- time: float = 0.0,
- ) -> np.ndarray:
- heading = float(state[2])
- speed = float(control[0])
- steering_angle = float(control[1])
- tan_delta = np.tan(steering_angle)
- sec_sq = 1.0 / (np.cos(steering_angle) ** 2)
- beta = float(np.arctan(0.5 * tan_delta))
- beta_prime = 0.5 * sec_sq / (1.0 + 0.25 * tan_delta * tan_delta)
- return np.array(
- [
- [np.cos(heading + beta), -speed * np.sin(heading + beta) * beta_prime, 0.0],
- [np.sin(heading + beta), speed * np.cos(heading + beta) * beta_prime, 0.0],
- [
- 2.0 * np.sin(beta) / self.wheelbase,
- 2.0 * speed * np.cos(beta) * beta_prime / self.wheelbase,
- 0.0,
- ],
- [0.0, 0.0, 1.0],
- ],
- dtype=float,
- )
-
- def get_state_hessian(
- self,
- state: np.ndarray,
- control: np.ndarray,
- time: float = 0.0,
- ) -> list[np.ndarray]:
- return [np.zeros((4, 4), dtype=float) for _ in range(4)]
-
- def get_control_hessian(
- self,
- state: np.ndarray,
- control: np.ndarray,
- time: float = 0.0,
- ) -> list[np.ndarray]:
- return [np.zeros((3, 3), dtype=float) for _ in range(4)]
-
- def get_cross_hessian(
- self,
- state: np.ndarray,
- control: np.ndarray,
- time: float = 0.0,
- ) -> list[np.ndarray]:
- return [np.zeros((3, 4), dtype=float) for _ in range(4)]
-
-
-class ContouringObjective(pycddp.NonlinearObjective):
- def __init__(self, timestep: float, track: TrackData) -> None:
- super().__init__(timestep)
- self.dt = float(timestep)
- self.track = track
- self.w_contour = 220.0
- self.w_lag = 28.0
- self.w_heading = 8.0
- self.w_progress = 4.5
- self.w_sync = 10.0
- self.w_steer = 0.25
- self.w_boundary = 320.0
- self.w_terminal = 6.0
-
- def tracking_terms(
- self,
- state: np.ndarray,
- ) -> tuple[float, float, float, TrackReference]:
- ref = self.track.interpolate(float(state[3]))
- delta_position = np.asarray(state[:2], dtype=float) - np.array([ref.x, ref.y])
- contour_error = float(ref.normal @ delta_position)
- lag_error = float(ref.tangent @ delta_position)
- heading_error = _wrap_angle(float(state[2]) - ref.heading)
- return contour_error, lag_error, heading_error, ref
-
- def running_cost(
- self,
- state: np.ndarray,
- control: np.ndarray,
- index: int,
- ) -> float:
- contour_error, lag_error, heading_error, ref = self.tracking_terms(state)
- speed, steering_angle, progress_rate = np.asarray(control, dtype=float)
- boundary_error = max(0.0, abs(contour_error) - 0.72 * self.track.width)
- progress_error = progress_rate - ref.v_ref
- sync_error = speed - progress_rate
- return float(
- self.dt
- * (
- self.w_contour * contour_error**2
- + self.w_lag * lag_error**2
- + self.w_heading * heading_error**2
- + self.w_progress * progress_error**2
- + self.w_sync * sync_error**2
- + self.w_steer * steering_angle**2
- + self.w_boundary * boundary_error**2
- - 1.6 * progress_rate
- )
- )
-
- def terminal_cost(self, final_state: np.ndarray) -> float:
- contour_error, lag_error, heading_error, _ = self.tracking_terms(final_state)
- boundary_error = max(0.0, abs(contour_error) - 0.75 * self.track.width)
- return float(
- self.w_terminal
- * (
- 220.0 * contour_error**2
- + 42.0 * lag_error**2
- + 18.0 * heading_error**2
- + 180.0 * boundary_error**2
- )
- )
-
-
def _default_options(max_iterations: int = 120) -> pycddp.CDDPOptions:
opts = pycddp.CDDPOptions()
opts.max_iterations = max_iterations
@@ -633,169 +473,61 @@ def solve_unicycle_demo() -> DemoResult:
)
-def _reference_seed_controls(track: TrackData, start_progress: float, horizon: int, dt: float, wheelbase: float) -> list[np.ndarray]:
- controls: list[np.ndarray] = []
- progress = float(start_progress)
- for _ in range(horizon):
- ref = track.interpolate(progress)
- steering = float(np.clip(np.arctan(wheelbase * ref.curvature), -0.42, 0.42))
- speed = float(np.clip(ref.v_ref, 1.0, 2.2))
- progress_rate = float(np.clip(speed + 0.12, 1.0, 2.4))
- controls.append(np.array([speed, steering, progress_rate], dtype=float))
- progress = progress + dt * progress_rate
- return controls
-
-
-def _rollout_mpcc_seed(
- model: MpccBicycle,
- track: TrackData,
- initial_state: np.ndarray,
- controls: list[np.ndarray],
-) -> tuple[list[np.ndarray], list[np.ndarray]]:
- state = np.asarray(initial_state, dtype=float).copy()
- states = [state.copy()]
- rolled_controls: list[np.ndarray] = []
- for control in controls:
- control_array = np.asarray(control, dtype=float).copy()
- rolled_controls.append(control_array)
- state = np.asarray(model.get_discrete_dynamics(state, control_array), dtype=float)
- states.append(state.copy())
- return states, rolled_controls
-
+def solve_mpcc_demo(
+ simulation_steps: int | None = None,
+ horizon: int = 20,
+) -> DemoResult:
+ """Closed-loop IPDDP MPCC on the bundled RC racing track.
+
+ This is a thin wrapper around the IPDDP runner in ``ipddp_mpcc_rc.py``.
+ The kinematic bicycle + 11-residual AIRCoM-style cost lives in that
+ module; this function just drives it to produce a ``DemoResult`` that
+ the existing portfolio animator (``_animate_mpcc``) consumes.
+
+ Parameters
+ ----------
+ simulation_steps:
+ Number of closed-loop MPC ticks. If ``None``, runs until the
+ executed ``theta`` crosses ``track.length`` (one full lap).
+ horizon:
+ MPC prediction horizon in stages.
+ """
+ # Lazy import to avoid a circular module load: ``ipddp_mpcc_rc`` imports
+ # ``TrackData`` / ``_load_track_csv`` / ``_wrap_angle`` from this module,
+ # so it must not be a top-level import here.
+ if __package__:
+ from .ipddp_mpcc_rc import (
+ IpddpRcMpccConfig,
+ history_to_demo_result,
+ run_ipddp_mpc,
+ )
+ else:
+ from ipddp_mpcc_rc import (
+ IpddpRcMpccConfig,
+ history_to_demo_result,
+ run_ipddp_mpc,
+ )
-def solve_mpcc_demo(simulation_steps: int | None = None, horizon: int = 16) -> DemoResult:
- dt = 0.10
- wheelbase = 0.20
track = _load_track_csv(_portfolio_track_path())
- if simulation_steps is None:
- nominal_progress = max(float(np.mean(track.v_ref)) + 0.15, 1.55)
- simulation_steps = int(np.ceil(1.08 * track.length / (dt * nominal_progress)))
- model = MpccBicycle(dt, wheelbase=wheelbase)
- objective = ContouringObjective(dt, track)
-
- opts = _default_options(max_iterations=12)
- opts.use_ilqr = True
- opts.enable_parallel = False
- opts.return_iteration_info = False
- opts.tolerance = 2e-3
- opts.acceptable_tolerance = 6e-3
- opts.regularization.initial_value = 1e-5
- opts.line_search.max_iterations = 9
+ cfg = IpddpRcMpccConfig(horizon=int(horizon), max_iterations=100)
- start_ref = track.interpolate(0.0)
- initial_state = np.array(
- [
- start_ref.x + 0.28 * track.width * start_ref.normal[0],
- start_ref.y + 0.28 * track.width * start_ref.normal[1],
- start_ref.heading - 0.08,
- 0.0,
- ],
- dtype=float,
- )
-
- solver = pycddp.CDDP(
- initial_state,
- track.reference_state(0.0),
- horizon,
- dt,
- opts,
- )
- solver.set_dynamical_system(model)
- solver.set_objective(objective)
- solver.add_constraint(
- "control_limits",
- pycddp.ControlConstraint(
- np.array([0.8, -0.55, 0.8], dtype=float),
- np.array([2.4, 0.55, 2.6], dtype=float),
- ),
- )
-
- seed_controls = _reference_seed_controls(track, initial_state[3], horizon, dt, wheelbase)
- seed_states, seed_controls = _rollout_mpcc_seed(model, track, initial_state, seed_controls)
-
- state = initial_state.copy()
- executed_states = [state.copy()]
- contour_errors: list[float] = []
- lag_errors: list[float] = []
- heading_errors: list[float] = []
- solve_times_ms: list[float] = []
- executed_controls: list[np.ndarray] = []
- predicted_paths: list[np.ndarray] = []
- reference_windows: list[np.ndarray] = []
- last_solution: pycddp.CDDPSolution | None = None
-
- for _ in range(simulation_steps):
- contour_error, lag_error, heading_error, _ = objective.tracking_terms(state)
- contour_errors.append(contour_error)
- lag_errors.append(lag_error)
- heading_errors.append(heading_error)
-
- solver.set_initial_state(state)
- solver.set_reference_state(track.reference_state(state[3]))
- solver.set_initial_trajectory(seed_states, seed_controls)
- solution = solver.solve(pycddp.SolverType.CLDDP)
- _ensure_solution_has_trajectory(solution, "MPCC portfolio solve")
- last_solution = solution
-
- predicted_states, predicted_controls, _ = _solution_arrays(solution)
- if predicted_controls.size == 0:
- raise RuntimeError("MPCC demo produced an empty control trajectory.")
-
- predicted_paths.append(predicted_states[:, :2].copy())
- reference_windows.append(
- np.vstack([track.reference_state(progress)[0:2] for progress in predicted_states[:, 3]])
- )
- solve_times_ms.append(float(solution.solve_time_ms))
-
- control = predicted_controls[0].copy()
- executed_controls.append(control)
- state = np.asarray(model.get_discrete_dynamics(state, control), dtype=float)
- state[2] = _wrap_angle(state[2])
- executed_states.append(state.copy())
-
- shifted_controls = [u.copy() for u in predicted_controls[1:]]
- if shifted_controls:
- shifted_controls.append(shifted_controls[-1].copy())
- else:
- shifted_controls = _reference_seed_controls(track, state[3], horizon, dt, wheelbase)
- if len(shifted_controls) < horizon:
- fallback = _reference_seed_controls(track, state[3], horizon - len(shifted_controls), dt, wheelbase)
- shifted_controls.extend(fallback)
- seed_states, seed_controls = _rollout_mpcc_seed(model, track, state, shifted_controls[:horizon])
-
- final_contour, final_lag, final_heading, final_ref = objective.tracking_terms(executed_states[-1])
- contour_errors.append(final_contour)
- lag_errors.append(final_lag)
- heading_errors.append(final_heading)
-
- if last_solution is None:
- raise RuntimeError("MPCC demo did not produce any MPC solves.")
-
- closed_loop_states = np.vstack(executed_states)
- closed_loop_controls = np.vstack(executed_controls)
- time_points = np.arange(len(executed_states), dtype=float) * dt
-
- return _make_result(
- slug="mpcc_racing_line",
- title="MPCC Racing Line",
- target_state=np.array([final_ref.x, final_ref.y, final_ref.heading, final_ref.s], dtype=float),
- solution=last_solution,
- states=closed_loop_states,
- controls=closed_loop_controls,
- time_points=time_points,
- solver_name=f"{last_solution.solver_name} MPC",
- timestep=dt,
- track=track,
- track_width=track.width,
- contour_errors=np.asarray(contour_errors, dtype=float),
- lag_errors=np.asarray(lag_errors, dtype=float),
- heading_errors=np.asarray(heading_errors, dtype=float),
- solve_times_ms=np.asarray(solve_times_ms, dtype=float),
- predicted_paths=predicted_paths,
- reference_windows=reference_windows,
- final_error_override=float(np.hypot(final_contour, final_lag)),
- subtitle="Full-lap receding-horizon contouring control on a closed circuit",
+ if simulation_steps is None:
+ stop_at = float(track.length)
+ max_steps = 500
+ else:
+ if int(simulation_steps) < 1:
+ raise ValueError("simulation_steps must be >= 1")
+ stop_at = None
+ max_steps = int(simulation_steps)
+
+ history = run_ipddp_mpc(
+ cfg,
+ track,
+ simulation_steps=max_steps,
+ stop_at_progress=stop_at,
+ show_progress=False,
)
+ return history_to_demo_result(history, track, cfg)
DEMO_BUILDERS: dict[str, Callable[[], DemoResult]] = {
@@ -1267,12 +999,19 @@ def _animate_mpcc(
closed_centerline = np.vstack([np.column_stack((track.x, track.y)), [track.x[0], track.y[0]]])
closed_inner = np.vstack([track.inner_boundary, track.inner_boundary[0]])
closed_outer = np.vstack([track.outer_boundary, track.outer_boundary[0]])
- ax_map.plot(closed_outer[:, 0], closed_outer[:, 1], color=_GRID, linewidth=1.3)
- ax_map.plot(closed_inner[:, 0], closed_inner[:, 1], color=_GRID, linewidth=1.3)
- ax_map.plot(closed_centerline[:, 0], closed_centerline[:, 1], color=_MUTED, linewidth=1.1, linestyle="--")
+ ax_map.plot(closed_outer[:, 0], closed_outer[:, 1], color=_GRID, linewidth=1.3, zorder=1)
+ ax_map.plot(closed_inner[:, 0], closed_inner[:, 1], color=_GRID, linewidth=1.3, zorder=1)
+ ax_map.plot(
+ closed_centerline[:, 0], closed_centerline[:, 1],
+ color=_MUTED, linewidth=1.1, linestyle="--", zorder=1,
+ )
start_ref = track.interpolate(0.0)
- ax_map.scatter([start_ref.x], [start_ref.y], color=_SUCCESS, s=54, zorder=5)
+ ax_map.scatter(
+ [start_ref.x], [start_ref.y],
+ color=_SUCCESS, s=64, zorder=5,
+ edgecolors="white", linewidths=1.2,
+ )
ax_map.annotate(
"start",
(start_ref.x, start_ref.y),
@@ -1280,22 +1019,47 @@ def _animate_mpcc(
textcoords="offset points",
color=_SUCCESS,
fontsize=9,
+ fontweight="bold",
)
- reference_preview, = ax_map.plot([], [], color=_SECONDARY, linewidth=2.0, alpha=0.9)
- prediction_preview, = ax_map.plot([], [], color=_TERTIARY, linewidth=2.0, alpha=0.9)
- driven_path, = ax_map.plot([], [], color=_ACCENT, linewidth=2.8)
- vehicle_marker, = ax_map.plot([], [], marker="o", color=_ACCENT, markersize=8)
+ reference_preview, = ax_map.plot(
+ [], [], color=_SECONDARY, linewidth=2.4, alpha=0.95,
+ zorder=3, label="reference",
+ )
+ prediction_preview, = ax_map.plot(
+ [], [], color=_TERTIARY, linewidth=2.4, alpha=0.95,
+ zorder=3, label="prediction",
+ )
+ driven_path, = ax_map.plot(
+ [], [], color=_ACCENT, linewidth=3.0,
+ solid_capstyle="round", zorder=4, label="driven",
+ )
+ vehicle_marker, = ax_map.plot(
+ [], [], marker="o", color=_ACCENT, markersize=10,
+ markeredgecolor="white", markeredgewidth=1.4, zorder=6,
+ )
heading_arrow = FancyArrowPatch(
(0.0, 0.0),
(0.0, 0.0),
arrowstyle="-|>",
- mutation_scale=14,
+ mutation_scale=16,
color=_ACCENT,
- linewidth=2.2,
+ linewidth=2.4,
+ zorder=6,
)
ax_map.add_patch(heading_arrow)
+ map_legend = ax_map.legend(
+ loc="upper right",
+ frameon=True,
+ facecolor="#fff7ea",
+ edgecolor=_GRID,
+ fontsize=8,
+ framealpha=0.92,
+ )
+ for text in map_legend.get_texts():
+ text.set_color(_TEXT)
+
metric_times = time_points
metric_end = metric_times[-1] if len(metric_times) > 1 else 1.0
ax_error.set_xlim(metric_times[0], metric_end)
@@ -1319,8 +1083,11 @@ def _animate_mpcc(
text.set_color(_TEXT)
control_times = time_points[:-1]
+ control_start = float(time_points[0]) if len(time_points) > 0 else 0.0
control_end = control_times[-1] if len(control_times) > 0 else 1.0
- ax_control.set_xlim(time_points[0], control_end)
+ if np.isclose(control_start, control_end):
+ control_end = control_start + 1.0
+ ax_control.set_xlim(control_start, control_end)
combined_controls = np.concatenate([controls[:, 0], controls[:, 1], solve_times_ms / 40.0])
control_min, control_max = _metric_bounds(combined_controls, minimum=0.8)
ax_control.set_ylim(control_min, control_max)
@@ -1352,14 +1119,21 @@ def _animate_mpcc(
status_text = ax_map.text(
0.03,
- 0.96,
+ 0.985,
"",
transform=ax_map.transAxes,
ha="left",
va="top",
fontsize=10,
color=_TEXT,
- bbox={"boxstyle": "round,pad=0.35", "facecolor": "#fff7ea", "edgecolor": _GRID},
+ zorder=6,
+ bbox={
+ "boxstyle": "round,pad=0.45",
+ "facecolor": "#fff7ea",
+ "edgecolor": _GRID,
+ "linewidth": 1.0,
+ "alpha": 0.92,
+ },
)
def update(frame_index: int) -> tuple[Any, ...]:
diff --git a/include/cddp-cpp/cddp.hpp b/include/cddp-cpp/cddp.hpp
index e1b2cf5..ac96b92 100644
--- a/include/cddp-cpp/cddp.hpp
+++ b/include/cddp-cpp/cddp.hpp
@@ -25,6 +25,7 @@
#include "cddp_core/dynamical_system.hpp"
#include "cddp_core/objective.hpp"
#include "cddp_core/constraint.hpp"
+#include "cddp_core/terminal_constraint.hpp"
#include "cddp_core/barrier.hpp"
#include "cddp_core/options.hpp"
#include "cddp_core/cddp_core.hpp"
diff --git a/include/cddp-cpp/cddp_core/cddp_core.hpp b/include/cddp-cpp/cddp_core/cddp_core.hpp
index 4ae0210..e162eeb 100644
--- a/include/cddp-cpp/cddp_core/cddp_core.hpp
+++ b/include/cddp-cpp/cddp_core/cddp_core.hpp
@@ -119,6 +119,9 @@ struct ForwardPassResult {
bool success = false;
double constraint_violation = 0.0;
+ double theta = 0.0; // Filter violation metric
+ double inf_pr = 0.0; // Primal infeasibility
+ double inf_comp = 0.0; // Complementary infeasibility
// Optional: Only relevant for certain solver strategies during their forward
// pass
diff --git a/include/cddp-cpp/cddp_core/ipddp_solver.hpp b/include/cddp-cpp/cddp_core/ipddp_solver.hpp
index de3341a..497be19 100644
--- a/include/cddp-cpp/cddp_core/ipddp_solver.hpp
+++ b/include/cddp-cpp/cddp_core/ipddp_solver.hpp
@@ -25,12 +25,16 @@
namespace cddp
{
+ class IPDDPSolverTestAccess;
/**
* @brief Interior Point Differential Dynamic Programming (IPDDP) solver.
*
* Inherits from CDDPSolverBase and overrides virtual hooks
* for primal-dual interior point method with logarithmic barrier.
+ * Supports path constraints, terminal equality constraints, and
+ * terminal inequality constraints via a single-shooting formulation
+ * with explicit costate variables.
*/
class IPDDPSolver : public CDDPSolverBase
{
@@ -44,11 +48,15 @@ namespace cddp
// === CDDPSolverBase virtual hook overrides ===
void preIterationSetup(CDDP &context) override;
bool backwardPass(CDDP &context) override;
+ bool checkEarlyConvergence(CDDP &context, int iter,
+ std::string &reason) override;
ForwardPassResult forwardPass(CDDP &context, double alpha) override;
void applyForwardPassResult(CDDP &context, const ForwardPassResult &result) override;
bool checkConvergence(CDDP &context, double dJ, double dL, int iter,
std::string &reason) override;
void postIterationUpdate(CDDP &context, bool forward_pass_success) override;
+ bool handleForwardPassFailure(CDDP &context,
+ std::string &termination_reason) override;
void recordIterationHistory(const CDDP &context) override;
void populateSolverSpecificSolution(CDDPSolution &solution,
const CDDP &context) override;
@@ -56,6 +64,8 @@ namespace cddp
void printSolutionSummary(const CDDPSolution &solution) const override;
private:
+ friend class IPDDPSolverTestAccess;
+
// Constraint derivatives
std::map> G_x_; ///< State gradients
std::map> G_u_; ///< Control gradients
@@ -67,6 +77,7 @@ namespace cddp
G_ux_; ///< Constraint mixed hessians
// Interior point variables
+ std::map> G_raw_; ///< Raw constraint values
std::map> G_; ///< Constraint values
std::map> Y_; ///< Dual variables
std::map> S_; ///< Slack variables
@@ -77,42 +88,41 @@ namespace cddp
std::map> k_s_; ///< Slack feedforward
std::map> K_s_; ///< Slack feedback
+ // Single-shooting costate variables and gains
+ std::vector
+ Lambda_; ///< Costate variables (Lagrange multipliers for dynamics)
+ std::vector
+ k_lambda_; ///< Feedforward gains for costate variables
+ std::vector
+ K_lambda_; ///< Feedback gains for costate variables
+
+ // Terminal equality constraint variables
+ Eigen::VectorXd Lambda_T_eq_; ///< Terminal equality multipliers
+ Eigen::VectorXd dLambda_T_eq_; ///< Terminal equality multiplier direction
+
+ // Terminal inequality constraint variables
+ std::map G_T_; ///< Terminal inequality values
+ std::map Y_T_; ///< Terminal inequality duals
+ std::map S_T_; ///< Terminal inequality slacks
+ std::map dY_T_; ///< Terminal inequality dual directions
+ std::map dS_T_; ///< Terminal inequality slack directions
+
+ // Search directions
+ std::vector dX_; ///< State search directions
+ std::vector dU_; ///< Control search directions
+ std::map>
+ dY_; ///< Dual search directions
+ std::map>
+ dS_; ///< Slack search directions
+
// Barrier method parameters
double mu_; ///< Barrier parameter
std::vector filter_; ///< Filter for line search
-
- // Pre-allocated workspace for performance optimization
- struct Workspace {
- // Backward pass workspace
- std::vector A_matrices;
- std::vector B_matrices;
- std::vector Q_xx_matrices;
- std::vector Q_ux_matrices;
- std::vector Q_uu_matrices;
- std::vector Q_x_vectors;
- std::vector Q_u_vectors;
-
- // LDLT solver cache
- std::vector> ldlt_solvers;
- std::vector ldlt_valid;
-
- // Constraint workspace
- Eigen::VectorXd y_combined;
- Eigen::VectorXd s_combined;
- Eigen::VectorXd g_combined;
- Eigen::MatrixXd Q_yu_combined;
- Eigen::MatrixXd Q_yx_combined;
- Eigen::MatrixXd YSinv;
- Eigen::MatrixXd bigRHS;
-
- // Forward pass workspace
- std::vector delta_x_vectors;
-
- bool initialized = false;
- } workspace_;
+ double phi_ = 0.0; ///< Current filter merit value
+ double theta_ = 0.0; ///< Current filter violation metric
+ double filter_theta_ = 0.0; ///< Current unfloored filter violation metric
// === Private helper methods ===
- void precomputeDynamicsDerivatives(CDDP &context);
void precomputeConstraintGradients(CDDP &context);
void evaluateTrajectory(CDDP &context);
void evaluateTrajectoryWarmStart(CDDP &context);
@@ -126,6 +136,40 @@ namespace cddp
double computeMaxConstraintViolation(const CDDP &context) const;
double computeScaledDualInfeasibility(const CDDP &context) const;
+ // Filter and merit function methods
+ bool acceptFilterEntry(double merit_function, double constraint_violation);
+ bool isFilterAcceptable(double merit_function,
+ double constraint_violation) const;
+
+ double computeTheta(
+ const CDDPOptions &options,
+ const std::map> &constraints,
+ const std::map> &slacks,
+ const std::map *terminal_constraints = nullptr,
+ const std::map *terminal_slacks = nullptr,
+ const Eigen::VectorXd *terminal_equality_residual = nullptr) const;
+
+ double computeBarrierMerit(
+ const CDDP &context,
+ const std::map> &slacks,
+ double cost,
+ const std::map *terminal_slacks = nullptr,
+ const Eigen::VectorXd *terminal_equality_multipliers = nullptr,
+ const Eigen::VectorXd *terminal_equality_residual = nullptr) const;
+
+ std::pair computePrimalAndComplementarity(
+ const CDDP &context,
+ const std::map> &constraints,
+ const std::map> &slacks,
+ const std::map> &duals,
+ double mu,
+ const std::map *terminal_constraints = nullptr,
+ const std::map *terminal_slacks = nullptr,
+ const std::map *terminal_duals = nullptr,
+ const Eigen::VectorXd *terminal_equality_residual = nullptr) const;
+
+ std::pair computeMaxStepSizes(const CDDP &context) const;
+
// Legacy print helper (used by printIteration override)
void printIterationLegacy(int iter, double objective, double inf_pr,
double inf_du, double inf_comp, double mu,
diff --git a/include/cddp-cpp/cddp_core/options.hpp b/include/cddp-cpp/cddp_core/options.hpp
index 850fd6f..a12bfbb 100644
--- a/include/cddp-cpp/cddp_core/options.hpp
+++ b/include/cddp-cpp/cddp_core/options.hpp
@@ -143,9 +143,46 @@ namespace cddp
};
/**
- * @brief Options for the IPDDP solver.
+ * @brief Comprehensive options for the IPDDP (Interior-Point DDP) solver.
*/
- struct IPDDPAlgorithmOptions : InteriorPointOptions {};
+ struct IPDDPAlgorithmOptions
+ {
+ double dual_var_init_scale = 1e-1; ///< Initial scale for dual variables.
+ double slack_var_init_scale = 1e-2; ///< Initial scale for slack variables.
+
+ double barrier_tol_mult =
+ 0.1; ///< Barrier-scaled inner tolerance multiplier.
+ double barrier_update_dual_weight =
+ 0.01; ///< Down-weight inf_du for IPOPT-style barrier updates.
+ double mu_kappa_epsilon =
+ 10.0; ///< IPOPT-style KKT trigger multiplier for non-adaptive updates.
+ bool check_state_stationarity =
+ false; ///< Include state-stationarity in inf_du when true.
+ std::string theta_norm =
+ "l1"; ///< Constraint violation norm used by the filter ("l1" or "l2").
+ int max_filter_size =
+ 5; ///< Maximum number of active non-dominated filter entries.
+ double theta_0_floor =
+ 1.0; ///< Minimum theta_0 value used for filter initialization.
+
+ bool warmstart_repair =
+ false; ///< Repair warm-started slack/dual variables into the strict interior.
+ double warmstart_s_min =
+ 1e-4; ///< Minimum slack value for warm-start repair.
+ double warmstart_y_min =
+ 1e-4; ///< Minimum dual value for warm-start repair.
+ double warmstart_interior_factor =
+ 1.1; ///< Multiplicative safety margin when warm-start values are too close to the boundary.
+ double warmstart_reset_x0_threshold =
+ -1.0; ///< Reset warm-start arrays when |x0 - X[0]| exceeds this threshold (<=0 disables).
+
+ double jacobian_regularization_value =
+ 1e-8; ///< Base reduced-system regularization floor for terminal equality LQR.
+ double jacobian_regularization_exponent =
+ 0.25; ///< Exponent for mu-dependent reduced-system regularization.
+
+ SolverSpecificBarrierOptions barrier; ///< Barrier method parameters for IPDDP.
+ };
/**
* @brief Options for the MSIPDDP solver (interior-point + multi-shooting).
diff --git a/include/cddp-cpp/cddp_core/terminal_constraint.hpp b/include/cddp-cpp/cddp_core/terminal_constraint.hpp
index c27c814..ee83677 100644
--- a/include/cddp-cpp/cddp_core/terminal_constraint.hpp
+++ b/include/cddp-cpp/cddp_core/terminal_constraint.hpp
@@ -33,7 +33,8 @@ namespace cddp
Eigen::MatrixXd getControlJacobian(
const Eigen::VectorXd &/*state*/,
- const Eigen::VectorXd &control // control.size() gives control_dim
+ const Eigen::VectorXd &control, // control.size() gives control_dim
+ int /*index*/ = 0
) const override
{
return Eigen::MatrixXd::Zero(getDualDim(), control.size());
@@ -41,7 +42,8 @@ namespace cddp
std::vector getControlHessian(
const Eigen::VectorXd &/*state*/,
- const Eigen::VectorXd &control
+ const Eigen::VectorXd &control,
+ int /*index*/ = 0
) const override
{
return {};
@@ -49,7 +51,8 @@ namespace cddp
std::vector getCrossHessian(
const Eigen::VectorXd &state,
- const Eigen::VectorXd &control
+ const Eigen::VectorXd &control,
+ int /*index*/ = 0
) const override
{
return {};
@@ -70,7 +73,8 @@ namespace cddp
}
Eigen::VectorXd evaluate(const Eigen::VectorXd &final_state,
- const Eigen::VectorXd &/*control_is_ignored*/) const override
+ const Eigen::VectorXd &/*control_is_ignored*/,
+ int /*index*/ = 0) const override
{
if (final_state.size() != target_state_.size()) {
throw std::invalid_argument("TerminalEqualityConstraint: final_state dimension mismatch.");
@@ -95,7 +99,8 @@ namespace cddp
}
Eigen::MatrixXd getStateJacobian(const Eigen::VectorXd &final_state,
- const Eigen::VectorXd &/*control_is_ignored*/) const override
+ const Eigen::VectorXd &/*control_is_ignored*/,
+ int /*index*/ = 0) const override
{
if (final_state.size() != target_state_.size()) {
throw std::invalid_argument("TerminalEqualityConstraint: final_state dimension mismatch for Jacobian.");
@@ -109,7 +114,8 @@ namespace cddp
}
double computeViolation(const Eigen::VectorXd &final_state,
- const Eigen::VectorXd &control) const override
+ const Eigen::VectorXd &control,
+ int /*index*/ = 0) const override
{
Eigen::VectorXd g = evaluate(final_state, control);
return computeViolationFromValue(g);
@@ -130,7 +136,8 @@ namespace cddp
// Hessians for g(x_N) = x_N - target are zero
std::vector getStateHessian(
const Eigen::VectorXd &state,
- const Eigen::VectorXd &/*control_is_ignored*/
+ const Eigen::VectorXd &/*control_is_ignored*/,
+ int /*index*/ = 0
) const override
{
std::vector Hxx_list;
@@ -171,7 +178,8 @@ namespace cddp
}
Eigen::VectorXd evaluate(const Eigen::VectorXd &final_state,
- const Eigen::VectorXd &/*control_is_ignored*/) const override
+ const Eigen::VectorXd &/*control_is_ignored*/,
+ int /*index*/ = 0) const override
{
if (final_state.size() != A_N_.cols()) {
throw std::invalid_argument("TerminalInequalityConstraint: final_state dimension and A_N columns mismatch.");
@@ -196,7 +204,8 @@ namespace cddp
}
Eigen::MatrixXd getStateJacobian(const Eigen::VectorXd &final_state,
- const Eigen::VectorXd &/*control_is_ignored*/) const override
+ const Eigen::VectorXd &/*control_is_ignored*/,
+ int /*index*/ = 0) const override
{
if (final_state.size() != A_N_.cols()) {
throw std::invalid_argument("TerminalStateInequalityConstraint: final_state dimension for Jacobian and A_N columns mismatch.");
@@ -210,7 +219,8 @@ namespace cddp
}
double computeViolation(const Eigen::VectorXd &final_state,
- const Eigen::VectorXd &control) const override
+ const Eigen::VectorXd &control,
+ int /*index*/ = 0) const override
{
Eigen::VectorXd g = evaluate(final_state, control);
return computeViolationFromValue(g);
@@ -231,7 +241,8 @@ namespace cddp
// Hessians for g(x_N) = A_N * x_N - b_N are zero
std::vector getStateHessian(
const Eigen::VectorXd &state,
- const Eigen::VectorXd &/*control_is_ignored*/
+ const Eigen::VectorXd &/*control_is_ignored*/,
+ int /*index*/ = 0
) const override
{
std::vector Hxx_list;
@@ -251,4 +262,4 @@ namespace cddp
Eigen::VectorXd b_N_;
};
} // namespace cddp
-#endif // CDDP_TERMINAL_CONSTRAINT_HPP
\ No newline at end of file
+#endif // CDDP_TERMINAL_CONSTRAINT_HPP
diff --git a/pyproject.toml b/pyproject.toml
index 47052dc..95fe971 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build"
[project]
name = "pycddp"
-version = "0.5.1"
+version = "0.5.2"
description = "Python bindings for CDDP trajectory optimization"
readme = "README.md"
authors = [{name = "Tomo Sasaki"}]
diff --git a/python/pycddp/_version.py b/python/pycddp/_version.py
index dd9b22c..7225152 100644
--- a/python/pycddp/_version.py
+++ b/python/pycddp/_version.py
@@ -1 +1 @@
-__version__ = "0.5.1"
+__version__ = "0.5.2"
diff --git a/python/tests/test_portfolio.py b/python/tests/test_portfolio.py
index 3e0d366..9471c9e 100644
--- a/python/tests/test_portfolio.py
+++ b/python/tests/test_portfolio.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import importlib
import sys
from pathlib import Path
@@ -67,3 +68,23 @@ def test_portfolio_animation_rejects_invalid_settings(tmp_path):
with pytest.raises(ValueError, match="frame_step must be >= 1"):
portfolio.save_animation(result, tmp_path / "bad.gif", frame_step=0)
+
+
+def test_mpcc_demo_supports_package_style_import():
+ package_portfolio = importlib.import_module("examples.python_portfolio_lib")
+
+ result = package_portfolio.solve_mpcc_demo(simulation_steps=1, horizon=12)
+
+ assert result.slug == "mpcc_racing_line"
+ assert result.solver_name == "IPDDP MPC"
+
+
+def test_mpcc_demo_rejects_nonpositive_steps():
+ with pytest.raises(ValueError, match="simulation_steps must be >= 1"):
+ portfolio.solve_mpcc_demo(simulation_steps=0)
+
+ package_mpcc = importlib.import_module("examples.ipddp_mpcc_rc")
+ track = portfolio._load_track_csv(portfolio._portfolio_track_path())
+
+ with pytest.raises(ValueError, match="simulation_steps must be >= 1"):
+ package_mpcc.run_ipddp_mpc(track=track, simulation_steps=0)
diff --git a/src/cddp_core/cddp_context_utils.cpp b/src/cddp_core/cddp_context_utils.cpp
new file mode 100644
index 0000000..147cba6
--- /dev/null
+++ b/src/cddp_core/cddp_context_utils.cpp
@@ -0,0 +1,109 @@
+/*
+ Copyright 2026 Tomo Sasaki
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ https://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+#include "cddp_context_utils.hpp"
+
+#include
+#include
+
+namespace cddp::detail {
+
+namespace {
+
+bool hasCompatibleVectorLayout(const std::vector &trajectory,
+ int expected_size, int vector_dim) {
+ return static_cast(trajectory.size()) == expected_size &&
+ std::all_of(trajectory.begin(), trajectory.end(),
+ [vector_dim](const Eigen::VectorXd &value) {
+ return value.size() == vector_dim;
+ });
+}
+
+} // namespace
+
+std::vector buildLineSearchAlphas(const LineSearchOptions &options) {
+ std::vector alphas;
+ alphas.reserve(static_cast(std::max(1, options.max_iterations)));
+
+ double current_alpha = options.initial_step_size;
+ for (int i = 0; i < options.max_iterations; ++i) {
+ alphas.push_back(current_alpha);
+ current_alpha *= options.step_reduction_factor;
+ if (current_alpha < options.min_step_size &&
+ i < options.max_iterations - 1) {
+ alphas.push_back(options.min_step_size);
+ break;
+ }
+ }
+
+ if (alphas.empty()) {
+ alphas.push_back(options.initial_step_size);
+ }
+
+ return alphas;
+}
+
+bool hasCompatibleWarmStartTrajectories(
+ const CDDPOptions &options, const std::vector &states,
+ const std::vector &controls, int horizon, int state_dim,
+ int control_dim) {
+ return options.warm_start &&
+ hasCompatibleVectorLayout(states, horizon + 1, state_dim) &&
+ hasCompatibleVectorLayout(controls, horizon, control_dim);
+}
+
+void ensureTrajectoryShape(std::vector &trajectory,
+ int expected_size, int vector_dim) {
+ if (hasCompatibleVectorLayout(trajectory, expected_size, vector_dim)) {
+ return;
+ }
+
+ trajectory.assign(static_cast(expected_size),
+ Eigen::VectorXd::Zero(vector_dim));
+}
+
+void addOrReplaceConstraint(
+ std::map> &constraint_set,
+ std::string constraint_name, std::unique_ptr constraint,
+ int &total_dual_dim) {
+ if (!constraint) {
+ throw std::runtime_error("Cannot add null constraint.");
+ }
+
+ const int dual_dim = constraint->getDualDim();
+ auto existing_constraint = constraint_set.find(constraint_name);
+ if (existing_constraint != constraint_set.end()) {
+ total_dual_dim -= existing_constraint->second->getDualDim();
+ }
+
+ constraint_set[constraint_name] = std::move(constraint);
+ total_dual_dim += dual_dim;
+}
+
+bool removeConstraint(
+ std::map> &constraint_set,
+ const std::string &constraint_name, int &total_dual_dim) {
+ auto it = constraint_set.find(constraint_name);
+ if (it == constraint_set.end()) {
+ return false;
+ }
+
+ total_dual_dim -= it->second->getDualDim();
+ constraint_set.erase(it);
+ return true;
+}
+
+} // namespace cddp::detail
diff --git a/src/cddp_core/cddp_context_utils.hpp b/src/cddp_core/cddp_context_utils.hpp
new file mode 100644
index 0000000..d4313ed
--- /dev/null
+++ b/src/cddp_core/cddp_context_utils.hpp
@@ -0,0 +1,53 @@
+/*
+ Copyright 2026 Tomo Sasaki
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ https://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+#ifndef CDDP_CONTEXT_UTILS_HPP
+#define CDDP_CONTEXT_UTILS_HPP
+
+#include