Skip to content

Constrained arcs

A flow along a boundary arc — where a state constraint is active — needs the constraint and its multiplier, not just the control law.

julia
using OptimalControl
using OrdinaryDiffEqTsit5

The setting

A state constraint active on a sub-interval turns the PMP into a three-piece story: unconstrained arc, boundary arc (where the constraint is tight and pins down both the control and the multiplier in feedback form), unconstrained arc again. The boundary arc's flow needs that extra structure.

The full three-arc problem — including the shooting that finds the entry and exit times — is worked end to end in State constraint, on this exact OCP. This page stays with the boundary arc's flow in isolation.

julia
t0 = 0.0
tf = 1.0
x0 = [-1.0, 0.0]
xf = [0.0, 0.0]
VMAX = 1.0        # unconstrained transfer peaks at v = 1.5, so this bites

ocp = @def begin
    t  [t0, tf], time
    x = (q, v)  R², state
    u  R, control
    x(t0) == x0
    x(tf) == xf
    v(t) + 0.0 VMAX, (vmax)
(t) == [v(t), u(t)]
    0.5∫(u(t)^2)  min
end

v(t) + 0.0 ≤ VMAX rather than v(t) ≤ VMAX: a bare v(t) ≤ VMAX parses as a box bound on the state component, which carries no path multiplier and cannot be pulled into a flow by its label. Adding + 0.0 makes it a nonlinear path constraint — it now has a dual, and constraint=:vmax below can reach it.

Building the constrained flow

julia
g(x) = VMAX - x[2]      # ≥ 0 away from the boundary, 0 on it
μ(x, p) = p[1]           # feedback multiplier on the boundary arc: μ = p_q
law(x, p) = 0.0   # boundary control: u = 0 ⟹ v̇ = 0 ⟹ v = VMAX

f_boundary = Flow(ocp, law; constraint=(x, u) -> g(x), multiplier=μ)

# integrate a segment of the boundary arc: enter at t1 with v = VMAX
t1, t2 = 0.3, 0.7
p1 = [1.0, 0.4]
xb, pb = f_boundary(t1, [-0.5, VMAX], p1, t2)
xb, g(xb)                # v held at VMAX along the arc → g(xb) ≈ 0
([-0.10000000000000012, 1.0], 0.0)

constraint=/multiplier= must be given as a pair — one without the other is rejected:

julia
julia> Flow(ocp, law; constraint=:vmax)
IncorrectArgument  _validate_constraint_pair, building.jl:868

`constraint` and `multiplier` must be given together

│  Got       only `constraint`
│  Expected  both `constraint` and `multiplier`, or neither

│  Context   Flow(ocp, law; constraint=…, multiplier=…) — pairing check
└─

Three ways to give the constraint

A plain Function — the form above, (x, u) -> g(x) paired with multiplier=μ.

A typed Data.StateConstraint (or ControlConstraint/MixedConstraint/PathConstraint for the other shapes) — equivalent, more explicit:

julia
f_typed = Flow(ocp, law; constraint=StateConstraint(g), multiplier=μ)
f_typed(t1, [-0.5, VMAX], p1, t2)[1]  xb
true

A Symbol naming a :path constraint already declared in the OCP — the standout capability here, not just a rename:

julia
f_sym = Flow(ocp, law; constraint=:vmax, multiplier=μ)
typeof(f_sym)
CTFlows.Flows.OptimalControlFlow{CTBase.Traits.Autonomous, CTBase.Traits.Fixed, CTFlows.Flows.HamiltonianFlow{CTBase.Traits.Autonomous, CTBase.Traits.Fixed, CTFlows.Systems.HamiltonianSystem{CTBase.Traits.Autonomous, CTBase.Traits.Fixed, ComposedHamiltonian{CTBase.Traits.Autonomous, CTBase.Traits.Fixed, PseudoHamiltonian{CTFlows.Flows.ConstrainedPseudoHamiltonianFunction{CTBase.Traits.Autonomous, CTBase.Traits.Fixed, CTFlows.Flows.OCPPseudoHamiltonianFunction{CTBase.Traits.Autonomous, CTBase.Traits.Fixed, Main.var"#fun##1265#29", Main.var"#fun##1270#30"}, PathConstraint{CTBase.Core.var"#f#3"{CTBase.Core.var"#f#2#4"{Type{Float64}, CTModels.Models.SubPathConstraint{Tuple{Vector{Float64}, Main.var"#fun##1258#28", Vector{Float64}, Vector{Symbol}}, Vector{Int64}}, Int64}}, CTBase.Traits.MixedConstraintKind, CTBase.Traits.NonAutonomous, CTBase.Traits.NonFixed}, Multiplier{typeof(Main.μ), CTBase.Traits.Autonomous, CTBase.Traits.Fixed}}, CTBase.Traits.Autonomous, CTBase.Traits.Fixed}, ControlLaw{typeof(Main.law), CTBase.Traits.DynClosedLoopFeedback, CTBase.Traits.Autonomous, CTBase.Traits.Fixed}}, CTBase.Differentiation.DifferentiationInterface{CPU, CTBase.Strategies.StrategyOptions{@NamedTuple{ad_backend::CTBase.Options.OptionValue{AutoForwardDiff{nothing, Nothing}}}}}}, SciML{CPU, CTBase.Strategies.StrategyOptions{@NamedTuple{internalnorm::CTBase.Options.OptionValue{typeof(CTSolvers.Integrators.real_norm)}, alg::CTBase.Options.OptionValue{Tsit5{typeof(OrdinaryDiffEqCore.trivial_limiter!), typeof(OrdinaryDiffEqCore.trivial_limiter!), FastBroadcast.Serial}}, reltol::CTBase.Options.OptionValue{Float64}, save_everystep::CTBase.Options.OptionValue{Symbol}, abstol::CTBase.Options.OptionValue{Float64}, save_start::CTBase.Options.OptionValue{Symbol}, dense::CTBase.Options.OptionValue{Symbol}}}, Dict{Symbol, Any}, Dict{Symbol, Any}}}, CTModels.Models.Model{CTBase.Traits.Autonomous, CTModels.Components.TimesModel{CTModels.Components.FixedTimeModel{Float64}, CTModels.Components.FixedTimeModel{Float64}}, CTModels.Components.StateModel, CTModels.Components.ControlModel, CTModels.Components.EmptyVariableModel, Main.var"#fun##1265#29", CTModels.Components.LagrangeObjectiveModel{Main.var"#fun##1270#30"}, CTModels.Components.ConstraintsModel{Tuple{Vector{Float64}, Main.var"#fun##1258#28", Vector{Float64}, Vector{Symbol}}, Tuple{Vector{Float64}, CTModels.Building.CompositeConstraint{:boundary, Tuple{Main.var"#fun##1247#26", Main.var"#fun##1253#27"}}, Vector{Float64}, Vector{Symbol}}, Tuple{Vector{Float64}, Vector{Int64}, Vector{Float64}, Vector{Symbol}, Vector{Vector{Symbol}}}, Tuple{Vector{Float64}, Vector{Int64}, Vector{Float64}, Vector{Symbol}, Vector{Vector{Symbol}}}, Tuple{Vector{Float64}, Vector{Int64}, Vector{Float64}, Vector{Symbol}, Vector{Vector{Symbol}}}}, CTModels.Components.Definition, Main.var"#31#32"}, ControlLaw{typeof(Main.law), CTBase.Traits.DynClosedLoopFeedback, CTBase.Traits.Autonomous, CTBase.Traits.Fixed}, Nothing}

An unknown label is rejected, not silently accepted:

julia
julia> Flow(ocp, law; constraint=:nope, multiplier=μ)
IncorrectArgument  constraint, model.jl:1540

│  Constraint label not found

│  Got       label :nope
│  Expected  existing constraint label in the model

│  Context   constraint lookup by label
│  Hint      Check available constraint labels or add a constraint with this label first
└─

The Symbol form uses the model's own sign convention

constraint=:vmax pulls the constraint straight from the OCP — v(t) ≤ V_{max} as written there — rather than whatever hand-derived   you might use in a shooting function. The two are not guaranteed to agree numerically unless you match conventions yourself; pick one style per problem and stay consistent, don't mix a hand-rolled g/μ pair with the model's own label expecting them to be interchangeable.

Several constraints at once

Pass matched tuples of functions (or labels) and multipliers, one per active constraint on the boundary arc, in place of the single values above:

julia
g_q(x) = 10.0 - x[1]      # a second bound, q ≤ 10 — slack on this arc
f_two = Flow(ocp, law;
             constraint = ((x, u) -> g(x), (x, u) -> g_q(x)),
             multiplier = (μ, (x, p) -> 0.0))
f_two(t1, [-0.5, VMAX], p1, t2)[1]  xb   # extra bound is slack
true

Assembling the arcs

A boundary arc is one phase in a larger multi-phase flow: unconstrained arc, then the constrained flow from t1 (constraint activation), then unconstrained again from t2 (exit) — with a jump on the costate at each switch if the constraint order requires one (none here: a first-order constraint keeps the costate continuous).

julia
f_interior = Flow(ocp, (x, p) -> p[2])   # u = p_v away from the boundary
φ = f_interior * (t1, f_boundary) * (t2, f_interior)   # 3 arcs
n_phases(φ)
3

Solving for t1 and t2 rather than fixing them is the shooting problem worked in State constraint.

Positional form is gone

julia
julia> Flow(ocp, law, (x, u) -> g(x), μ)
PreconditionError  #Flow#54, building.jl:1023

Flow(ocp, ) with extra positional arguments is not supported

│  Reason   passing a control law, state constraint or multiplier as a positional argument is not handled by the OCP flow constructor

│  Context  Flow(ocp::CTModels.Models.Model) — positional-argument guard
│  Hint     Flow(ocp, u) takes the control law positionally; pass constraint/multiplier as keywords: Flow(ocp, u; constraint=g, multiplier=μ)
└─

Partial Hamiltonian on a constrained arc

hamiltonian_type=:partial (see From an OCP) is supported here too — the boundary arc's law is stationary for the constrained pseudo-Hamiltonian at the optimum, same as the unconstrained case, so :total and :partial still agree when the law is genuinely optimal.

Control-free flows reject constraints

julia
julia> ocp_cf = @def begin
           t  [t0, tf], time
           x  R, state
           x(t0) == 1.0
(t) == -x(t)
(x(t)^2)  min
       end;

julia> Flow(ocp_cf; constraint=(x, u) -> 1.0, multiplier=(x, p) -> 1.0)
PreconditionError  _reject_control_free_constraint, building.jl:201

│  constrained flows are not supported for control-free problems

│  Reason   a `constraint`/`multiplier` pair augments the pseudo-Hamiltonian H̃ + μ·g with the control; a control-free Flow(ocp) has no control law and no pseudo-Hamiltonian to carry the constraint term

│  Context  Flow(ocp; constraint=…, multiplier=…) — control-free constraint guard
│  Hint     use Flow(ocp, law; constraint=…, multiplier=…) with a control law
└─

See also

  • Multi-phase flows — assembling several arcs, constrained or not, into one callable flow.

  • Shooting — solving for the switching times themselves rather than assuming them known.

  • State constraint — this OCP worked end to end: the three-arc structure, the costate jump, and the shooting for the entry and exit times.