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.

julia
t0 = 0.0
tf = 1.0
x0 = [-1.0, 0.0]
VMAX = 1.2

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

Building the constrained flow

julia
g(x) = VMAX - x[2]      # ≥ 0 while the constraint holds
μ(x, p) = p[1]           # multiplier in feedback form on the boundary
law(x, p) = 0.0           # control on the boundary arc

f_boundary = Flow(ocp, law; constraint=(x, u) -> g(x), multiplier=μ)
xf, pf = f_boundary(t0, x0, [12.0, 6.0], tf)
xf
2-element Vector{Float64}:
 0.19999999999999973
 0.0

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

julia
julia> Flow(ocp, law; constraint=:vmax)
IncorrectArgument  top-level scope, REPL[1]:2

`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(t0, x0, [12.0, 6.0], tf)[1]  xf
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##1072#27", Main.var"#fun##1077#28"}, PathConstraint{CTBase.Core.var"#f#3"{CTBase.Core.var"#f#2#4"{Type{Float64}, CTModels.Models.SubPathConstraint{Tuple{Vector{Float64}, Main.var"#fun##1065#26", 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##1072#27", CTModels.Components.LagrangeObjectiveModel{Main.var"#fun##1077#28"}, CTModels.Components.ConstraintsModel{Tuple{Vector{Float64}, Main.var"#fun##1065#26", Vector{Float64}, Vector{Symbol}}, Tuple{Vector{Float64}, Main.var"#fun##1060#25", 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"#29#30"}, 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  top-level scope, REPL[1]:2

│  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.

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.

Positional form is gone

julia
julia> Flow(ocp, law, (x, u) -> g(x), μ)
PreconditionError  top-level scope, REPL[1]:2

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     call Flow(ocp; kwargs) — the OCP flow takes no positional argument beyond the model
└─

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  top-level scope, REPL[2]:2

│  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