Extracting Costates and Constraint Multipliers¶
pockit.optimizer.ipopt.solve returns the raw dictionary produced by CyIpopt as
its second result. The dictionary includes NLP multipliers, but these values are
not yet continuous-time costates or path-constraint multiplier densities. The
conversion depends on Pockit's actual defect equations, quadrature weights, and
constraint order.
This guide derives that conversion for the current Lobatto and Radau transcriptions. It also explains why formulas written for a differentiation-matrix transcription cannot be copied directly into Pockit.
Ipopt sign convention¶
For decision variables \(z\), constraint functions \(c(z)\), and variable bounds, Ipopt uses the stationarity convention
The returned arrays have the following meanings:
| Entry | Meaning |
|---|---|
info["mult_g"] |
Signed multipliers \(\nu\) for system.constraints(z) |
info["mult_x_L"] |
Nonnegative lower-variable-bound multipliers \(z_L\) |
info["mult_x_U"] |
Nonnegative upper-variable-bound multipliers \(z_U\) |
For a constraint stored as \(c_L\le c(z)\le c_U\), an active upper bound normally has \(\nu>0\), while an active lower bound normally has \(\nu<0\). An equality multiplier has no prescribed sign. Replacing \(c\) by \(-c\) reverses its multiplier, so the modeled expression must always be recorded with the dual.
Pockit's transcription¶
Let
and let \(w_j=\texttt{phase.w_m[j]}\) be a quadrature weight on the normalized phase interval \([0,1]\). Pockit approximates a Bolza objective as
For state component \(i\), Pockit stores the integral defect equations as
where \(I=\texttt{phase.I_m}\). Both schemes integrate backward from each
subinterval's right endpoint. This T*x - dt*I*f orientation determines the
minus sign below.
Let \(\alpha_{i,r}\) be the corresponding entries of mult_g, and let
\(\beta_{a,j}\) multiply a general path expression \(g_a(x_j,u_j,t_j)\). Matching
the discrete NLP Lagrangian to the quadrature approximation of
gives
There is no \(\Delta t\) in the costate formula: the same factor multiplies the
dynamics term in both the NLP defect and the continuous Hamiltonian. A general
path constraint is not quadrature-weighted in system.constraints, so its raw
NLP multiplier must be divided by \(\Delta t w_j\).
Use phase.w_m directly. For a multi-interval Lobatto phase, Pockit has already
added the left- and right-interval quadrature contributions at every shared mesh
node. Reconstructing a reference-element weight by hand gives the wrong scale at
interfaces.
Constraint order¶
info["mult_g"] follows the exact order returned by system.constraints:
- all finite-dimensional system constraints, with length
system.n_c; - for each phase in
system.porder: - dynamic defects, grouped by state and indexed by
phase.l_d/phase.r_d; - general path constraints, grouped by expression, with
phase.L_mvalues per expression.
The third item has total length phase.r_d[-1]. The fourth has length
phase.n_c * phase.L_m. Here, phase.n_c counts only general expressions.
Pockit recognizes a bare state, control, time, or static-parameter symbol in
set_phase_constraint and converts it to a variable bound instead; such a bound
does not occupy any mult_g rows.
The leading system-constraint multipliers are finite-dimensional duals. Return them as stored; do not divide them by a quadrature weight or phase duration.
The following helper decodes all phases without hard-coding Lobatto or Radau matrix dimensions:
import numpy as np
def extract_continuous_multipliers(system, solution, info):
"""Decode system duals, defect multipliers, costates, and path densities."""
mult_g = np.asarray(info["mult_g"], dtype=float)
phase_values = solution if isinstance(solution, (list, tuple)) else [solution]
phase_values = phase_values[: system.n_p]
if len(phase_values) != system.n_p:
raise ValueError("solution does not contain every phase")
result = {
"system": mult_g[: system.n_c].copy(),
"phases": [],
}
offset = system.n_c
for phase, value in zip(system.p, phase_values):
dynamic_size = int(phase.r_d[-1]) if phase.n_x else 0
dynamic_raw = mult_g[offset : offset + dynamic_size]
offset += dynamic_size
alpha = [
dynamic_raw[phase.l_d[i] : phase.r_d[i]].copy()
for i in range(phase.n_x)
]
defect_multiplier = (
np.vstack(alpha) if alpha else np.empty((0, 0), dtype=float)
)
costate = (
np.vstack(
[
-np.asarray(phase.I_m.T @ alpha_i).ravel() / phase.w_m
for alpha_i in alpha
]
)
if alpha
else np.empty((0, phase.L_m), dtype=float)
)
path_size = phase.n_c * phase.L_m
path_nlp = mult_g[offset : offset + path_size].reshape(
phase.n_c, phase.L_m
)
offset += path_size
dt = value.t_f - value.t_0
if dt <= 0.0:
raise ValueError("phase duration must be positive")
path_density = path_nlp / (dt * phase.w_m[None, :])
time = value.t_0 + dt * phase.t_m
result["phases"].append(
{
"time": time,
"defect_multiplier": defect_multiplier,
"costate": costate,
"path_nlp": path_nlp.copy(),
"path_density": path_density,
}
)
if offset != len(mult_g):
raise RuntimeError("multiplier layout does not match the configured system")
return result
Always pass solution, info, and system.p from the same, final solve. Mesh
refinement changes every affected slice and invalidates multipliers from the
previous NLP.
Lobatto and Radau endpoints¶
For Lobatto, phase.t_m contains both phase endpoints. The boxed costate formula
therefore returns endpoint costates directly. At an internal mesh point,
phase.w_m and phase.I_m.T @ alpha both combine the adjacent subintervals.
For Radau, the right endpoint of a subinterval is a state node but not a control,
quadrature, or general-path node. It is absent from phase.t_m. If
\(\alpha_i^{(k)}\) denotes the defect multipliers of subinterval \(k\), its right
endpoint costate is
In code, Radau's defect-row partition matches phase.l_m / phase.r_m:
alpha_i = decoded["phases"][phase_index]["defect_multiplier"][state_index]
right_costate = np.array(
[alpha_i[left:right].sum() for left, right in zip(phase.l_m, phase.r_m)]
)
right_time = phase_value.t_0 + (
phase_value.t_f - phase_value.t_0
) * phase.t_x[phase.r_x - 1]
At an internal interface, compare this right-limit value with the mapped costate at the next interval's left Radau node. A significant jump can indicate an under-resolved solution, an actual interior event, or a missing linkage condition.
Fixed entries supplied to phase.set_boundary_condition are substituted by
Pockit; they are not extra rows in mult_g. Consequently, there is no separate
\"fixed boundary multiplier\" to slice from that array. Use the endpoint costate
and transversality conditions, or model the required finite-dimensional relation
as a system constraint when its multiplier is itself needed.
Direct variable bounds¶
When the path expression is a bare control symbol, Pockit promotes it to NLP variable bounds. For a control, the continuous nonnegative lower and upper multiplier densities are
The signed multiplier for the raw bounded expression is \(\widetilde\mu=\mu_U-\mu_L\). This is positive on an active upper bound and negative on an active lower bound.
def extract_control_bound_density(
system, phase_value, info, phase_index, control_index
):
phase = system.p[phase_index]
phase_offset = sum(system.p[i].L for i in range(phase_index))
local_left = phase.l_v[phase.n_x + control_index]
local_right = phase.r_v[phase.n_x + control_index]
selection = slice(phase_offset + local_left, phase_offset + local_right)
dt = phase_value.t_f - phase_value.t_0
scale = dt * phase.w_m
lower = np.asarray(info["mult_x_L"])[selection] / scale
upper = np.asarray(info["mult_x_U"])[selection] / scale
return lower, upper, upper - lower
This helper is intentionally limited to controls, whose optimization nodes match
phase.w_m in both schemes. A Radau state has an additional right-endpoint node
with no quadrature weight; a bound multiplier at that node is an endpoint atom,
not a path-density value to divide by w_m.
Reproducible costate check¶
The notebooks that motivated this guide study
Its solution and costate for \(H=\lambda f\) are
The following script checks the same multi-interval problem with both schemes:
import numpy as np
from pockit.lobatto import System as LobattoSystem
from pockit.lobatto import linear_guess as lobatto_guess
from pockit.optimizer import ipopt
from pockit.radau import System as RadauSystem
from pockit.radau import linear_guess as radau_guess
def exact_y(t):
return 4.0 / (1.0 + 3.0 * np.exp(2.5 * t))
def exact_u(t):
return exact_y(t) / 2.0
def exact_costate(t):
a = 1.0 + 3.0 * np.exp(2.5 * t)
b = np.exp(-5.0) + 6.0 + 9.0 * np.exp(5.0)
return -(a**2) * np.exp(-2.5 * t) / b
def run(System, make_guess):
system = System(["y_final"])
(y_final,) = system.s
phase = system.new_phase(["y"], ["u"])
(y,) = phase.x
(u,) = phase.u
phase.set_dynamics([2.5 * (-y + y * u - u**2)])
phase.set_boundary_condition([1.0], [y_final], 0.0, 2.0)
phase.set_discretization([0.0, 0.2, 0.55, 1.0], [8, 9, 10])
system.set_phase([phase])
system.set_objective(-y_final)
guess = make_guess(phase, 0.0)
guess.x[0] = exact_y(guess.t_x)
guess.u[0] = exact_u(guess.t_u)
solution, info = ipopt.solve(
system,
[guess, [exact_y(2.0)]],
optimizer_options={
"print_level": 0,
"sb": "yes",
"tol": 1.0e-11,
"acceptable_tol": 1.0e-11,
"bound_relax_factor": 0.0,
},
)
if int(info["status"]) not in (0, 1):
raise RuntimeError(info["status_msg"])
decoded = extract_continuous_multipliers(system, solution, info)
dual = decoded["phases"][0]
error = np.max(np.abs(dual["costate"][0] - exact_costate(dual["time"])))
assert error < 1.0e-6
return error
print("Lobatto:", run(LobattoSystem, lobatto_guess))
print("Radau: ", run(RadauSystem, radau_guess))
With the displayed mesh, the maximum errors were \(2.8\times10^{-8}\) for Lobatto and \(3.5\times10^{-9}\) for Radau. Reversing the boxed costate sign gave an error of about \(2\), which directly checks the defect orientation. Radau's interval-right formula agreed with the analytical endpoint costate to \(1.4\times10^{-15}\); the largest left/right interface mismatch was \(3.5\times10^{-9}\).
Reproducible path-multiplier check¶
To check the \(\Delta t w_j\) scaling independently, minimize
subject to \(g(u)=u+0.1u^3\le g(0.5)\). The optimum is \(u^*=0.5\), and stationarity gives the constant upper-bound density
from pockit.lobatto import System, constant_guess
system = System(0)
phase = system.new_phase(["x"], ["u"])
(x,) = phase.x
(u,) = phase.u
g = u + 0.1 * u**3
g_upper = 0.5 + 0.1 * 0.5**3
phase.set_dynamics([0.0 * x])
phase.set_integral([(u - 2.0) ** 2 / 2.0])
phase.set_phase_constraint([g], [-np.inf], [g_upper])
phase.set_boundary_condition([0.0], [0.0], 0.0, 3.7)
phase.set_discretization([0.0, 0.2, 0.55, 1.0], [7, 8, 9])
system.set_phase([phase])
system.set_objective(phase.I[0])
guess = constant_guess(phase, 0.0)
guess.u[0] = 0.5
solution, info = ipopt.solve(
system,
guess,
optimizer_options={
"print_level": 0,
"sb": "yes",
"tol": 1.0e-11,
"acceptable_tol": 1.0e-11,
"bound_relax_factor": 0.0,
},
)
if int(info["status"]) not in (0, 1):
raise RuntimeError(info["status_msg"])
decoded = extract_continuous_multipliers(system, solution, info)
mu = decoded["phases"][0]["path_density"][0]
expected = 1.5 / 1.075
np.testing.assert_allclose(mu, expected, rtol=0.0, atol=1.0e-8)
Repeating this test with a lower bound reverses the signed mult_g density.
Tests with Lobatto and Radau, upper and lower bounds, and both general constraints
and promoted variable bounds all recovered the analytical density within
\(5.3\times10^{-11}\).
Reliability checks¶
Treat a reconstructed multiplier as a numerical result, not as an exact certificate:
- verify Ipopt's status before reading duals;
- check primal feasibility and the KKT stationarity residual;
- compare multiplier histories after increasing the polynomial degree or refining the mesh;
- expect inactive inequality multipliers to be zero only to solver tolerance;
- avoid interpreting individual duals when constraints are redundant or the active-set gradients are linearly dependent;
- retain separate left and right limits near switches, impacts, and other genuine discontinuities;
- account explicitly for any manual objective or constraint rescaling.
The raw NLP multipliers generally change as the mesh changes. The reconstructed costates and multiplier densities are the quantities that should converge to a mesh-independent continuous-time limit.