Skip to content
4 changes: 3 additions & 1 deletion ext/plot_utils.jl
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ Determine which components should be plotted based on the `description` and styl
# Notes
- Duals are only plotted if `sol` contains path constraint dual variables.
- A style must not be `:none` for the component to be included.
- Control is **never** plotted when `control_dimension(sol) == 0`, even if `:control` is
listed in `description`. This supports solutions of problems without a control input.

# Example
```julia-repl
Expand All @@ -73,7 +75,7 @@ function do_plot(
)
do_plot_state = :state ∈ description && state_style != :none
do_plot_costate = :costate ∈ description && costate_style != :none
do_plot_control = :control ∈ description && control_style != :none
do_plot_control = :control ∈ description && control_style != :none && CTModels.control_dimension(sol) > 0
ocp = CTModels.model(sol)
do_plot_path =
:path ∈ description &&
Expand Down
62 changes: 42 additions & 20 deletions src/Display/print.jl
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ Print the mathematical definition of an optimal control problem.
Displays the problem in standard mathematical notation with objective,
dynamics, and constraints.

When `u_dim == 0` (no control input), all control-dependent parts of the
output are suppressed:
- The objective is rendered as `J(x, v)` instead of `J(x, u, v)`.
- Dynamics arguments omit the control: `f(t, x, v)` instead of `f(t, x, u, v)`.
- The "where" clause lists only `x` (and `v` if variable-dependent).
- Box constraints on control are not listed.

# Returns

- `Bool`: `true` if something was printed.
Expand Down Expand Up @@ -91,10 +98,11 @@ function __print_mathematical_definition(
# args
t_ = is_time_dependent ? t_name * ", " : ""
_v = is_variable_dependent ? ", " * v_name : ""
_u = u_dim > 0 ? ", " * u_name * "(" * t_name * ")" : ""

# other names
bounds_args_names = x_name * "(" * t0_name * "), " * x_name * "(" * tf_name * ")" * _v
mixed_args_names = t_ * x_name * "(" * t_name * "), " * u_name * "(" * t_name * ")" * _v
mixed_args_names = t_ * x_name * "(" * t_name * ")" * _u * _v
state_args_names = x_name * "(" * t_name * ")"
control_args_names = u_name * "(" * t_name * ")"
variable_args_names = v_name
Expand All @@ -112,7 +120,9 @@ function __print_mathematical_definition(

# J
printstyled(io, " minimize "; color=:blue)
print(io, "J(" * x_name * ", " * u_name * _v * ") = ")
# Only include control in objective if u_dim > 0
u_in_obj = u_dim > 0 ? ", " * u_name : ""
print(io, "J(" * x_name * u_in_obj * _v * ") = ")

# Mayer
has_a_mayer_cost && print(io, "g(" * bounds_args_names * ")")
Expand Down Expand Up @@ -205,22 +215,26 @@ function __print_mathematical_definition(
end
x_name_space *= " ∈ " * x_space

# control name and space
if u_dim == 1
u_name_space = u_name * "(" * t_name * ")"
else
u_name_space = u_name * "(" * t_name * ")"
if ui_names != [u_name * CTBase.ctindices(i) for i in range(1, u_dim)]
u_name_space *= " = ("
for i in 1:u_dim
u_name_space *= ui_names[i] * "(" * t_name * ")"
i < u_dim && (u_name_space *= ", ")
# control name and space (only if u_dim > 0)
u_name_space = ""
if u_dim > 0
if u_dim == 1
u_name_space = u_name * "(" * t_name * ")"
else
u_name_space = u_name * "(" * t_name * ")"
if ui_names != [u_name * CTBase.ctindices(i) for i in range(1, u_dim)]
u_name_space *= " = ("
for i in 1:u_dim
u_name_space *= ui_names[i] * "(" * t_name * ")"
i < u_dim && (u_name_space *= ", ")
end
u_name_space *= ")"
end
u_name_space *= ")"
end
u_name_space *= " ∈ " * u_space
end
u_name_space *= " ∈ " * u_space

# Build the "where" clause based on what's present
if is_variable_dependent
# space
v_space = "R" * (v_dim == 1 ? "" : CTBase.ctupperscripts(v_dim))
Expand All @@ -239,13 +253,21 @@ function __print_mathematical_definition(
end
end
v_name_space *= " ∈ " * v_space
# print
print(
io, " where ", x_name_space, ", ", u_name_space, " and ", v_name_space, ".\n"
)
# print with or without control
if u_dim > 0
print(
io, " where ", x_name_space, ", ", u_name_space, " and ", v_name_space, ".\n"
)
else
print(io, " where ", x_name_space, " and ", v_name_space, ".\n")
end
else
# print
print(io, " where ", x_name_space, " and ", u_name_space, ".\n")
# print with or without control
if u_dim > 0
print(io, " where ", x_name_space, " and ", u_name_space, ".\n")
else
print(io, " where ", x_name_space, ".\n")
end
end
return true
end
Expand Down
31 changes: 27 additions & 4 deletions src/Init/control.jl
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,17 @@ Throws `Exceptions.IncorrectArgument` if the control dimension is not 1.
"""
function initial_control(ocp::AbstractModel, control::Real)
dim = control_dimension(ocp)
if dim == 1
if dim == 0
throw(
Exceptions.IncorrectArgument(
"Initial control dimension mismatch";
got="scalar value",
expected="no control (dimension 0)",
suggestion="Remove the control argument or set control=nothing",
context="initial_control with scalar input for zero-dimensional control",
),
)
elseif dim == 1
return t -> control
else
throw(
Expand All @@ -41,7 +51,17 @@ Throws `Exceptions.IncorrectArgument` if the vector length does not match the co
"""
function initial_control(ocp::AbstractModel, control::Vector{<:Real})
dim = control_dimension(ocp)
if length(control) != dim
if dim == 0 && !isempty(control)
throw(
Exceptions.IncorrectArgument(
"Initial control dimension mismatch";
got="vector of length $(length(control))",
expected="no control (dimension 0)",
suggestion="Remove the control argument or set control=nothing",
context="initial_control with vector input for zero-dimensional control",
),
)
elseif length(control) != dim
throw(
Exceptions.IncorrectArgument(
"Initial control dimension mismatch";
Expand All @@ -60,11 +80,14 @@ $(TYPEDSIGNATURES)

Return a default control initialisation function when no control is provided.

Returns a constant function yielding `0.1` (scalar) or `fill(0.1, dim)` (vector).
Returns a constant function yielding `Float64[]` (empty) if `dim == 0`,
`0.1` (scalar) if `dim == 1`, or `fill(0.1, dim)` (vector) otherwise.
"""
function initial_control(ocp::AbstractModel, ::Nothing)
dim = control_dimension(ocp)
if dim == 1
if dim == 0
return t -> Float64[]
elseif dim == 1
return t -> 0.1
else
return t -> fill(0.1, dim)
Expand Down
28 changes: 20 additions & 8 deletions src/OCP/Building/model.jl
Original file line number Diff line number Diff line change
Expand Up @@ -258,15 +258,33 @@ $(TYPEDSIGNATURES)

Converts a mutable `PreModel` into an immutable `Model`.

This function finalizes a pre-defined optimal control problem (`PreModel`) by verifying that all necessary components (times, state, control, dynamics) are set. It then constructs a `Model` instance, incorporating optional components like objective and constraints if they are defined.
This function finalizes a pre-defined optimal control problem (`PreModel`) by verifying that all
necessary components (times, state, dynamics, objective) are set. It then constructs a `Model`
instance, incorporating optional components like control, variable, and constraints.

!!! note
Control is **optional**: calling `control!` is not required. When omitted, the model is
built with `control_dimension == 0` (an `EmptyControlModel`). This is useful for problems
where the dynamics depend only on the state, such as pure state-space systems.

# Arguments
- `pre_ocp::PreModel`: The pre-defined optimal control problem to be finalized.

# Returns
- `Model`: A fully constructed model ready for solving.

# Example
# Example without control
```julia-repl
julia> pre_ocp = PreModel()
julia> times!(pre_ocp, 0.0, 1.0, 100)
julia> state!(pre_ocp, 2, "x", ["x1", "x2"])
julia> dynamics!(pre_ocp, (t, x, u) -> [-x[2], x[1]])
julia> objective!(pre_ocp, :min, mayer=(x0, xf) -> xf[1]^2)
julia> model = build(pre_ocp)
julia> control_dimension(model) # 0
```

# Example with control
```julia-repl
julia> pre_ocp = PreModel()
julia> times!(pre_ocp, 0.0, 1.0, 100)
Expand All @@ -289,12 +307,6 @@ function build(pre_ocp::PreModel; build_examodel=nothing)::Model
suggestion="Call state!(pre_ocp, dimension) before building",
context="build function - state validation",
)
@ensure __is_control_set(pre_ocp) Exceptions.PreconditionError(
"Control must be set before building model",
reason="control has not been defined yet",
suggestion="Call control!(pre_ocp, dimension) before building",
context="build function - control validation",
)
@ensure __is_dynamics_set(pre_ocp) Exceptions.PreconditionError(
"Dynamics must be set before building model",
reason="dynamics have not been defined yet",
Expand Down
25 changes: 17 additions & 8 deletions src/OCP/Components/constraints.jl
Original file line number Diff line number Diff line change
Expand Up @@ -278,14 +278,19 @@ julia> constraint!(ocp, :control, rg=1:2, lb=[0.0], ub=[1.0], label=:control_con
# Throws

- `Exceptions.PreconditionError`: If state has not been set
- `Exceptions.PreconditionError`: If control has not been set
- `Exceptions.PreconditionError`: If times has not been set
- `Exceptions.PreconditionError`: If control has not been set **and** `type == :control`
- `Exceptions.PreconditionError`: If variable has not been set (when type=:variable)
- `Exceptions.PreconditionError`: If constraint with same label already exists
- `Exceptions.PreconditionError`: If both lb and ub are nothing
- `Exceptions.IncorrectArgument`: If lb and ub have different lengths
- `Exceptions.IncorrectArgument`: If lb > ub element-wise
- `Exceptions.IncorrectArgument`: If dimensions don't match expected sizes

!!! note
Control is only required for `type == :control` constraints. All other types
(`:state`, `:boundary`, `:path`, `:variable`) are valid even when no control
is defined (control dimension 0).
"""
function constraint!(
ocp::PreModel,
Expand All @@ -298,26 +303,30 @@ function constraint!(
codim_f::Union{Dimension,Nothing}=nothing,
)

# checks: times, state and control must be set before adding constraints
# checks: times and state must be set before adding constraints
@ensure __is_state_set(ocp) Exceptions.PreconditionError(
"State must be set before adding constraints",
reason="state has not been defined yet",
suggestion="Call state!(ocp, dimension) before adding constraints",
context="constraint! function - state validation",
)
@ensure __is_control_set(ocp) Exceptions.PreconditionError(
"Control must be set before adding constraints",
reason="control has not been defined yet",
suggestion="Call control!(ocp, dimension) before adding constraints",
context="constraint! function - control validation",
)
@ensure __is_times_set(ocp) Exceptions.PreconditionError(
"Times must be set before adding constraints",
reason="time horizon has not been defined yet",
suggestion="Call times!(ocp, t0, tf) or times!(ocp, N) before adding constraints",
context="constraint! function - times validation",
)

# checks: control must be set for :control constraint type
if type == :control
@ensure __is_control_set(ocp) Exceptions.PreconditionError(
"Control must be set for type=:control constraints",
reason="control has not been defined yet but constraint type requires it",
suggestion="Call control!(ocp, dimension) before adding :control constraints, or use a different constraint type",
context="constraint! function - control validation for type=:control",
)
end

# checks: variable must be set if using type=:variable
@ensure (type != :variable || __is_variable_set(ocp)) Exceptions.PreconditionError(
"Variable must be set for type=:variable constraints",
Expand Down
27 changes: 27 additions & 0 deletions src/OCP/Components/control.jl
Original file line number Diff line number Diff line change
Expand Up @@ -208,3 +208,30 @@ Get the control function associated with the solution.
function value(model::ControlModelSolution{TS})::TS where {TS<:Function}
return model.value
end

"""
$(TYPEDSIGNATURES)

Return an empty string, since no control is defined.
"""
function name(::EmptyControlModel)::String
return ""
end

"""
$(TYPEDSIGNATURES)

Return an empty vector since there are no control components defined.
"""
function components(::EmptyControlModel)::Vector{String}
return String[]
end

"""
$(TYPEDSIGNATURES)

Return `0` since no control is defined.
"""
function dimension(::EmptyControlModel)::Dimension
return 0
end
23 changes: 6 additions & 17 deletions src/OCP/Components/dynamics.jl
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ Set the full dynamics of the optimal control problem `ocp` using the function `f
- `f::Function`: A function that defines the complete system dynamics.

# Preconditions
- The state, control, and times must be set before calling this function.
- The state and times must be set before calling this function.
- Control is **optional**: problems without control input (dimension 0) are supported.
- No dynamics must have been set previously.

# Behavior
This function assigns `f` as the complete dynamics of the system. It throws an error
if any of the required fields (`state`, `control`, `times`) are not yet set, or if
dynamics have already been set.
if the state or times are not yet set, or if dynamics have already been set.

# Errors
Throws `Exceptions.PreconditionError` if called out of order or in an invalid state.
Expand All @@ -26,12 +26,6 @@ function dynamics!(ocp::PreModel, f::Function)::Nothing
suggestion="Call state!(ocp, dimension) before dynamics!",
context="dynamics! function - state validation",
)
@ensure __is_control_set(ocp) Exceptions.PreconditionError(
"Control must be set before defining dynamics",
reason="control has not been defined yet",
suggestion="Call control!(ocp, dimension) before dynamics!",
context="dynamics! function - control validation",
)
@ensure __is_times_set(ocp) Exceptions.PreconditionError(
"Times must be set before defining dynamics",
reason="time horizon has not been defined yet",
Expand Down Expand Up @@ -63,7 +57,8 @@ subset of state indices specified by the range `rg`.
- `f::Function`: A function describing the dynamics over the specified state indices.

# Preconditions
- The state, control, and times must be set before calling this function.
- The state and times must be set before calling this function.
- Control is **optional**: problems without control input (dimension 0) are supported.
- The full dynamics must not yet be complete.
- No overlap is allowed between `rg` and existing dynamics index ranges.

Expand All @@ -74,7 +69,7 @@ configuration for adding partial dynamics.

# Errors
Throws `Exceptions.PreconditionError` if:
- The state, control, or times are not yet set.
- The state or times are not yet set.
- The dynamics are already defined completely.
- Any index in `rg` overlaps with an existing dynamics range.

Expand All @@ -91,12 +86,6 @@ function dynamics!(ocp::PreModel, rg::AbstractRange{<:Int}, f::Function)::Nothin
suggestion="Call state!(ocp, dimension) before partial dynamics!",
context="partial_dynamics! function - state validation",
)
@ensure __is_control_set(ocp) Exceptions.PreconditionError(
"Control must be set before defining partial dynamics",
reason="control has not been defined yet",
suggestion="Call control!(ocp, dimension) before partial dynamics!",
context="partial_dynamics! function - control validation",
)
@ensure __is_times_set(ocp) Exceptions.PreconditionError(
"Times must be set before defining partial dynamics",
reason="time horizon has not been defined yet",
Expand Down
Loading
Loading