diff --git a/ext/plot_utils.jl b/ext/plot_utils.jl index 49ec789d..95632b2f 100644 --- a/ext/plot_utils.jl +++ b/ext/plot_utils.jl @@ -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 @@ -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 && diff --git a/src/Display/print.jl b/src/Display/print.jl index 9c7611c4..ffa44978 100644 --- a/src/Display/print.jl +++ b/src/Display/print.jl @@ -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. @@ -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 @@ -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 * ")") @@ -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)) @@ -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 diff --git a/src/Init/control.jl b/src/Init/control.jl index 02c3dee2..b1e52d7e 100644 --- a/src/Init/control.jl +++ b/src/Init/control.jl @@ -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( @@ -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"; @@ -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) diff --git a/src/OCP/Building/model.jl b/src/OCP/Building/model.jl index d0bd0c51..92609795 100644 --- a/src/OCP/Building/model.jl +++ b/src/OCP/Building/model.jl @@ -258,7 +258,14 @@ $(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. @@ -266,7 +273,18 @@ This function finalizes a pre-defined optimal control problem (`PreModel`) by ve # 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) @@ -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", diff --git a/src/OCP/Components/constraints.jl b/src/OCP/Components/constraints.jl index 6977eb1e..d6a08f93 100644 --- a/src/OCP/Components/constraints.jl +++ b/src/OCP/Components/constraints.jl @@ -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, @@ -298,19 +303,13 @@ 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", @@ -318,6 +317,16 @@ function constraint!( 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", diff --git a/src/OCP/Components/control.jl b/src/OCP/Components/control.jl index 2d09b07d..0c6ab2f4 100644 --- a/src/OCP/Components/control.jl +++ b/src/OCP/Components/control.jl @@ -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 diff --git a/src/OCP/Components/dynamics.jl b/src/OCP/Components/dynamics.jl index dc773eff..d30a9d87 100644 --- a/src/OCP/Components/dynamics.jl +++ b/src/OCP/Components/dynamics.jl @@ -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. @@ -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", @@ -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. @@ -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. @@ -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", diff --git a/src/OCP/Components/objective.jl b/src/OCP/Components/objective.jl index 1037f2c8..3ed196bc 100644 --- a/src/OCP/Components/objective.jl +++ b/src/OCP/Components/objective.jl @@ -12,7 +12,8 @@ Set the objective of the optimal control problem. !!! note - - The state, control and variable must be set before the objective. + - The state and times must be set before the objective. + - Control is **optional**: problems without control input (dimension 0) are fully supported. - The objective must not be set before. - At least one of the two functions must be given. Please provide a Mayer or a Lagrange function. @@ -31,7 +32,6 @@ julia> objective!(ocp, :min, mayer=mayer, lagrange=lagrange) # 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 objective has already been set - `Exceptions.IncorrectArgument`: If criterion is not :min, :max, :MIN, or :MAX @@ -44,19 +44,13 @@ function objective!( lagrange::Union{Function,Nothing}=nothing, )::Nothing - # checks: times, state, and control must be set before the objective + # checks: times and state must be set before the objective @ensure __is_state_set(ocp) Exceptions.PreconditionError( "State must be set before objective", reason="state has not been defined yet", suggestion="Call state!(ocp, dimension) before objective!(ocp, ...)", context="objective! function - state validation", ) - @ensure __is_control_set(ocp) Exceptions.PreconditionError( - "Control must be set before objective", - reason="control has not been defined yet", - suggestion="Call control!(ocp, dimension) before objective!(ocp, ...)", - context="objective! function - control validation", - ) @ensure __is_times_set(ocp) Exceptions.PreconditionError( "Times must be set before objective", reason="time horizon has not been defined yet", diff --git a/src/OCP/OCP.jl b/src/OCP/OCP.jl index 77944b48..19e99ba0 100644 --- a/src/OCP/OCP.jl +++ b/src/OCP/OCP.jl @@ -82,7 +82,7 @@ export Dimension, ctNumber, Time, ctVector, Times, TimesDisc, ConstraintsDictTyp export Model, PreModel, AbstractModel export Solution, AbstractSolution export FixedTimeModel, FreeTimeModel, TimesModel, AbstractTimeModel -export StateModel, ControlModel, VariableModel, EmptyVariableModel +export StateModel, ControlModel, EmptyControlModel, VariableModel, EmptyVariableModel export MayerObjectiveModel, LagrangeObjectiveModel, BolzaObjectiveModel export DualModel, AbstractDualModel export SolverInfos, AbstractSolverInfos diff --git a/src/OCP/Types/components.jl b/src/OCP/Types/components.jl index 88bdd1c3..f1696cc6 100644 --- a/src/OCP/Types/components.jl +++ b/src/OCP/Types/components.jl @@ -172,6 +172,35 @@ struct ControlModelSolution{TS<:Function} <: AbstractControlModel value::TS end +""" +$(TYPEDEF) + +Sentinel type representing the absence of a control input in an optimal control problem. + +Used when the problem has no control variable (control dimension 0). An `EmptyControlModel` +is the default value of the `control` field in [`PreModel`](@ref): it is automatically +substituted when the user does not call `control!` before [`build`](@ref). + +The methods `name`, `components`, and `dimension` are defined for this type and return +`""`, `String[]`, and `0` respectively. + +# Example + +```julia-repl +julia> using CTModels + +julia> pre = CTModels.PreModel() +julia> CTModels.OCP.__is_control_set(pre) # false — still EmptyControlModel +false + +julia> CTModels.OCP.control_dimension(pre) # 0 +0 +``` + +See also: [`ControlModel`](@ref), [`PreModel`](@ref), [`build`](@ref). +""" +struct EmptyControlModel <: AbstractControlModel end + # ------------------------------------------------------------------------------ # """ $(TYPEDEF) diff --git a/src/OCP/Types/model.jl b/src/OCP/Types/model.jl index 54c86268..cecd3fdf 100644 --- a/src/OCP/Types/model.jl +++ b/src/OCP/Types/model.jl @@ -116,7 +116,12 @@ __is_state_set(ocp::Model)::Bool = true """ $(TYPEDSIGNATURES) -Return `true` since control is always set in a built [`Model`](@ref CTModels.OCP.Model). +Return `true` since the control field is always structurally set in a built +[`Model`](@ref CTModels.OCP.Model) (i.e. it is never `nothing`). + +Note: this does not imply a positive control dimension. The model may hold an +[`EmptyControlModel`](@ref) when the user did not call `control!`, in which case +`control_dimension(ocp) == 0`. """ __is_control_set(ocp::Model)::Bool = true @@ -161,7 +166,7 @@ and the model is validated before building. - `times::Union{AbstractTimesModel,Nothing}`: Initial and final time specification. - `state::Union{AbstractStateModel,Nothing}`: State variable structure. -- `control::Union{AbstractControlModel,Nothing}`: Control variable structure. +- `control::AbstractControlModel`: Control variable structure (defaults to `EmptyControlModel()`, i.e. no control). - `variable::AbstractVariableModel`: Optimisation variable (defaults to empty). - `dynamics::Union{Function,Vector,Nothing}`: System dynamics (function or component-wise). - `objective::Union{AbstractObjectiveModel,Nothing}`: Cost functional. @@ -181,7 +186,7 @@ julia> # Set fields incrementally... @with_kw mutable struct PreModel <: AbstractModel times::Union{AbstractTimesModel,Nothing} = nothing state::Union{AbstractStateModel,Nothing} = nothing - control::Union{AbstractControlModel,Nothing} = nothing + control::AbstractControlModel = EmptyControlModel() variable::AbstractVariableModel = EmptyVariableModel() dynamics::Union{Function,Vector{<:Tuple{<:AbstractRange{<:Int},<:Function}},Nothing} = nothing @@ -222,9 +227,16 @@ __is_state_set(ocp::PreModel)::Bool = __is_set(ocp.state) """ $(TYPEDSIGNATURES) -Return `true` if control has been set in the `PreModel`. +Return `true` if `c` is an `EmptyControlModel`. +""" +__is_control_empty(c) = c isa EmptyControlModel + +""" +$(TYPEDSIGNATURES) + +Return `true` if a non-empty control has been set in the `PreModel`. """ -__is_control_set(ocp::PreModel)::Bool = __is_set(ocp.control) +__is_control_set(ocp::PreModel)::Bool = !__is_control_empty(ocp.control) """ $(TYPEDSIGNATURES) @@ -334,7 +346,6 @@ Return true if all the required fields are set in the PreModel. function __is_consistent(ocp::PreModel)::Bool return __is_times_set(ocp) && __is_state_set(ocp) && - __is_control_set(ocp) && __is_dynamics_complete(ocp) && __is_objective_set(ocp) && __is_autonomous_set(ocp) @@ -348,7 +359,6 @@ Return true if the PreModel can be built into a Model. function __is_complete(ocp::PreModel)::Bool return __is_times_set(ocp) && __is_state_set(ocp) && - __is_control_set(ocp) && __is_dynamics_complete(ocp) && __is_objective_set(ocp) && __is_definition_set(ocp) && diff --git a/test/suite/exceptions/test_ocp_integration.jl b/test/suite/exceptions/test_ocp_integration.jl index a9e2a656..c5610f4f 100644 --- a/test/suite/exceptions/test_ocp_integration.jl +++ b/test/suite/exceptions/test_ocp_integration.jl @@ -150,18 +150,18 @@ function test_ocp_exception_integration() Test.@test occursin("state validation", e.context) end - # Test with state set but not control + # Test with state set but not times (control is now optional) CTModels.state!(ocp, 2) try CTModels.objective!(ocp, :min, mayer=(x0, xf, v) -> x0[1]) catch e Test.@test e isa Exceptions.PreconditionError - Test.@test e.msg == "Control must be set before objective" - Test.@test occursin("control has not been defined yet", e.reason) + Test.@test e.msg == "Times must be set before objective" + Test.@test occursin("time horizon has not been defined yet", e.reason) Test.@test occursin( - "Call control!(ocp, dimension) before objective!", e.suggestion + "Call time!(ocp, t0, tf) before objective!", e.suggestion ) - Test.@test occursin("control validation", e.context) + Test.@test occursin("objective! function - times validation", e.context) end end diff --git a/test/suite/ocp/test_control.jl b/test/suite/ocp/test_control.jl index 3b0a0acc..a9ecdd7b 100644 --- a/test/suite/ocp/test_control.jl +++ b/test/suite/ocp/test_control.jl @@ -25,7 +25,7 @@ function test_control() # some checks ocp = CTModels.PreModel() - Test.@test isnothing(ocp.control) + Test.@test ocp.control isa CTModels.OCP.EmptyControlModel Test.@test !CTModels.OCP.__is_control_set(ocp) CTModels.control!(ocp, 1) Test.@test CTModels.OCP.__is_control_set(ocp) diff --git a/test/suite/ocp/test_control_zero.jl b/test/suite/ocp/test_control_zero.jl new file mode 100644 index 00000000..33359416 --- /dev/null +++ b/test/suite/ocp/test_control_zero.jl @@ -0,0 +1,561 @@ +module TestControlZero + +import Test +import CTBase.Exceptions +import CTModels.OCP +import CTModels.Init +import CTModels +import Plots +import JLD2 +import JSON3 + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +function test_control_zero() + Test.@testset "Control Zero Dimension Tests" verbose=VERBOSE showtiming=SHOWTIMING begin + + # ==================================================================== + # UNIT TESTS - EmptyControlModel + # ==================================================================== + + Test.@testset "EmptyControlModel - Type and Construction" begin + ecm = OCP.EmptyControlModel() + Test.@test ecm isa OCP.EmptyControlModel + Test.@test ecm isa OCP.AbstractControlModel + end + + Test.@testset "EmptyControlModel - Getters" begin + ecm = OCP.EmptyControlModel() + Test.@test OCP.name(ecm) == "" + Test.@test OCP.components(ecm) == String[] + Test.@test OCP.dimension(ecm) == 0 + end + + # ==================================================================== + # UNIT TESTS - PreModel Default + # ==================================================================== + + Test.@testset "PreModel - Default Control" begin + pre = OCP.PreModel() + Test.@test pre.control isa OCP.EmptyControlModel + Test.@test OCP.dimension(pre.control) == 0 + end + + # ==================================================================== + # UNIT TESTS - Helper Functions + # ==================================================================== + + Test.@testset "Helper Functions - __is_control_empty" begin + # EmptyControlModel should be empty + ecm = OCP.EmptyControlModel() + Test.@test OCP.__is_control_empty(ecm) == true + + # ControlModel should not be empty + cm = OCP.ControlModel("u", ["u₁"]) + Test.@test OCP.__is_control_empty(cm) == false + end + + Test.@testset "Helper Functions - __is_control_set" begin + # PreModel with EmptyControlModel should return false + pre = OCP.PreModel() + Test.@test OCP.__is_control_set(pre) == false + + # PreModel after control! should return true + pre2 = OCP.PreModel() + OCP.control!(pre2, 1) + Test.@test OCP.__is_control_set(pre2) == true + end + + # ==================================================================== + # UNIT TESTS - Phase 2: Relaxed Preconditions + # ==================================================================== + + Test.@testset "dynamics! without control" begin + # Should not throw error when control is not set + pre = OCP.PreModel() + OCP.state!(pre, 2) + OCP.time!(pre, t0=0, tf=1) + + # This should work without calling control! + OCP.dynamics!(pre, (x, u) -> [x[2], -x[1]]) + Test.@test OCP.__is_dynamics_set(pre) + end + + Test.@testset "objective! without control" begin + # Should not throw error when control is not set + pre = OCP.PreModel() + OCP.state!(pre, 2) + OCP.time!(pre, t0=0, tf=1) + + # This should work without calling control! + OCP.objective!(pre, :min, mayer=(x0, xf) -> xf[1]^2) + Test.@test OCP.__is_objective_set(pre) + end + + Test.@testset "constraint! - boundary type without control" begin + # Boundary constraints should work without control + pre = OCP.PreModel() + OCP.state!(pre, 2) + OCP.time!(pre, t0=0, tf=1) + + # This should work without calling control! + OCP.constraint!(pre, :boundary, f=(x0, xf) -> x0[1], lb=0, ub=0) + Test.@test length(pre.constraints) == 1 + end + + Test.@testset "constraint! - path type without control" begin + # Path constraints should work without control (e.g., state-only path constraints) + pre = OCP.PreModel() + OCP.state!(pre, 2) + OCP.time!(pre, t0=0, tf=1) + + # This should work without calling control! + OCP.constraint!(pre, :path, f=(t, x, u) -> x[1], lb=0, ub=1) + Test.@test length(pre.constraints) == 1 + end + + Test.@testset "constraint! - control type requires control" begin + # Only :control type constraints require control to be set + pre = OCP.PreModel() + OCP.state!(pre, 2) + OCP.time!(pre, t0=0, tf=1) + + # This should throw a PreconditionError because control is not set + exception_thrown = false + try + OCP.constraint!(pre, :control, rg=1, lb=-1, ub=1) + catch e + exception_thrown = true + Test.@test e isa Exceptions.PreconditionError + end + Test.@test exception_thrown + end + + # ==================================================================== + # UNIT TESTS - Phase 3: Building without control + # ==================================================================== + + Test.@testset "build() - Model without control" begin + # Build a complete Model without control + pre = OCP.PreModel() + OCP.time!(pre, t0=0, tf=1) + OCP.state!(pre, 2) + OCP.dynamics!(pre, (x, u) -> [x[2], -x[1]]) + OCP.objective!(pre, :min, mayer=(x0, xf) -> xf[1]^2) + OCP.time_dependence!(pre, autonomous=false) + OCP.definition!(pre, quote end) + + # This should work without calling control! + model = OCP.build(pre) + Test.@test model isa OCP.Model + Test.@test OCP.control_dimension(model) == 0 + Test.@test OCP.control_name(model) == "" + Test.@test OCP.control_components(model) == String[] + end + + # ==================================================================== + # UNIT TESTS - Phase 4: Display without control + # ==================================================================== + + Test.@testset "Display - Model without control" begin + # Build a Model without control + pre = OCP.PreModel() + OCP.time!(pre, t0=0, tf=1) + OCP.state!(pre, 2) + OCP.dynamics!(pre, (x, u) -> [x[2], -x[1]]) + OCP.objective!(pre, :min, mayer=(x0, xf) -> xf[1]^2) + OCP.time_dependence!(pre, autonomous=false) + OCP.definition!(pre, quote end) + model = OCP.build(pre) + + # Test that display works without error + io = IOBuffer() + Test.@test_nowarn show(io, MIME"text/plain"(), model) + output = String(take!(io)) + + # Verify control is not mentioned in output + Test.@test !occursin("u(", output) + Test.@test occursin("x(", output) # State should be present + Test.@test occursin("J(x", output) # Objective should have only state + end + + # ==================================================================== + # UNIT TESTS - Phase 5: Initialization without control + # ==================================================================== + + Test.@testset "Init - initial_control with nothing" begin + # Build a Model without control + pre = OCP.PreModel() + OCP.time!(pre, t0=0, tf=1) + OCP.state!(pre, 2) + OCP.dynamics!(pre, (x, u) -> [x[2], -x[1]]) + OCP.objective!(pre, :min, mayer=(x0, xf) -> xf[1]^2) + OCP.time_dependence!(pre, autonomous=false) + OCP.definition!(pre, quote end) + model = OCP.build(pre) + + # Test that initial_control with nothing returns empty vector function + u_init = Init.initial_control(model, nothing) + Test.@test u_init isa Function + Test.@test u_init(0.5) == Float64[] + end + + Test.@testset "Init - initial_control with scalar throws error" begin + # Build a Model without control + pre = OCP.PreModel() + OCP.time!(pre, t0=0, tf=1) + OCP.state!(pre, 2) + OCP.dynamics!(pre, (x, u) -> [x[2], -x[1]]) + OCP.objective!(pre, :min, mayer=(x0, xf) -> xf[1]^2) + OCP.time_dependence!(pre, autonomous=false) + OCP.definition!(pre, quote end) + model = OCP.build(pre) + + # This should throw an error + exception_thrown = false + try + Init.initial_control(model, 0.5) + catch e + exception_thrown = true + Test.@test e isa Exceptions.IncorrectArgument + end + Test.@test exception_thrown + end + + Test.@testset "Init - initial_control with non-empty vector throws error" begin + # Build a Model without control + pre = OCP.PreModel() + OCP.time!(pre, t0=0, tf=1) + OCP.state!(pre, 2) + OCP.dynamics!(pre, (x, u) -> [x[2], -x[1]]) + OCP.objective!(pre, :min, mayer=(x0, xf) -> xf[1]^2) + OCP.time_dependence!(pre, autonomous=false) + OCP.definition!(pre, quote end) + model = OCP.build(pre) + + # This should throw an error + exception_thrown = false + try + Init.initial_control(model, [0.5]) + catch e + exception_thrown = true + Test.@test e isa Exceptions.IncorrectArgument + end + Test.@test exception_thrown + end + + Test.@testset "Init - initial_guess without control" begin + # Build a Model without control + pre = OCP.PreModel() + OCP.time!(pre, t0=0, tf=1) + OCP.state!(pre, 2) + OCP.dynamics!(pre, (x, u) -> [x[2], -x[1]]) + OCP.objective!(pre, :min, mayer=(x0, xf) -> xf[1]^2) + OCP.time_dependence!(pre, autonomous=false) + OCP.definition!(pre, quote end) + model = OCP.build(pre) + + # Test that initial_guess works without control + init = Init.initial_guess(model) + Test.@test init isa Init.InitialGuess + Test.@test init.control(0.5) == Float64[] + end + + # ==================================================================== + # UNIT TESTS - Phase 6: Name validation without control + # ==================================================================== + + Test.@testset "Validation - Name conflicts without control" begin + # Build a PreModel without control + pre = OCP.PreModel() + OCP.time!(pre, t0=0, tf=1) + OCP.state!(pre, 2, "x", ["x1", "x2"]) + + # Verify that control names are not collected + used_names = OCP.__collect_used_names(pre) + Test.@test "t" ∈ used_names # time should be present + Test.@test "x" ∈ used_names # state should be present + Test.@test "x1" ∈ used_names + Test.@test "x2" ∈ used_names + Test.@test !("u" ∈ used_names) # control should NOT be present + + # Should be able to use "u" as a state component name since control is not set + pre2 = OCP.PreModel() + OCP.time!(pre2, t0=0, tf=1) + OCP.state!(pre2, 2, "x", ["u", "v"]) # "u" is allowed as state component + Test.@test OCP.__is_state_set(pre2) + end + + # ==================================================================== + # INTEGRATION TESTS - Phase 7: Serialization without control + # ==================================================================== + + Test.@testset "Serialization - Solution building without control" begin + # Build a Model without control + pre = OCP.PreModel() + OCP.time!(pre, t0=0, tf=1) + OCP.state!(pre, 2) + OCP.dynamics!(pre, (x, u) -> [x[2], -x[1]]) + OCP.objective!(pre, :min, mayer=(x0, xf) -> xf[1]^2) + OCP.time_dependence!(pre, autonomous=false) + OCP.definition!(pre, quote end) + model = OCP.build(pre) + + # Create a solution without control + T = collect(range(0, 1, length=10)) + x_data = hcat(sin.(T), cos.(T)) # (10, 2) matrix + u_data = Matrix{Float64}(undef, 10, 0) # Empty control matrix (10×0) + p_data = hcat(cos.(T), -sin.(T)) # (10, 2) matrix + v_data = Float64[] + + sol = OCP.build_solution( + model, + T, T, T, T, + x_data, u_data, v_data, p_data; + objective=1.0, + iterations=10, + constraints_violation=0.0, + message="Test solution", + status=:success, + successful=true + ) + + # Test that control_dimension is 0 + Test.@test OCP.control_dimension(sol) == 0 + + # Test that control function returns empty vector + u_func = OCP.control(sol) + Test.@test u_func(0.5) == Float64[] + + # Test that solution properties are correct + Test.@test OCP.state_dimension(sol) == 2 + Test.@test OCP.objective(sol) == 1.0 + end + + # ==================================================================== + # INTEGRATION TESTS - Phase 8: Plotting without control + # ==================================================================== + + Test.@testset "Plotting - Verify subplot count without control" begin + # Build a Model without control + pre = OCP.PreModel() + OCP.time!(pre, t0=0, tf=1) + OCP.state!(pre, 2) + OCP.dynamics!(pre, (x, u) -> [x[2], -x[1]]) + OCP.objective!(pre, :min, mayer=(x0, xf) -> xf[1]^2) + OCP.time_dependence!(pre, autonomous=false) + OCP.definition!(pre, quote end) + model = OCP.build(pre) + + # Create a solution without control + T = collect(range(0, 1, length=10)) + x_data = hcat(sin.(T), cos.(T)) + u_data = Matrix{Float64}(undef, 10, 0) + p_data = hcat(cos.(T), -sin.(T)) + v_data = Float64[] + + sol = OCP.build_solution( + model, + T, T, T, T, + x_data, u_data, v_data, p_data; + objective=1.0, + iterations=10, + constraints_violation=0.0, + message="Test solution", + status=:success, + successful=true + ) + + # Test plotting with :group layout + p = Plots.plot(sol, layout=:group) + # Without control: state + costate = 2 subplots + Test.@test length(p.subplots) == 2 + + # Test plotting with :split layout + p_split = Plots.plot(sol, layout=:split) + # Without control: 2 state components + 2 costate components = 4 subplots + Test.@test length(p_split.subplots) == 4 + + # Test explicit :control in description - should be ignored when dim=0 + # (:state,:control) with :group → state+costate subplots (control skipped since dim=0) + p_ctrl = Plots.plot(sol, layout=:group, description=(:state, :control)) + Test.@test length(p_ctrl.subplots) == 2 # state+costate (control silently skipped) + + # Test that plot does not error with state-only description + # (:state,) with :split → 4 subplots (2 state + 2 costate, costate included by default) + p_state = Plots.plot(sol, layout=:split, description=(:state,)) + Test.@test length(p_state.subplots) == 4 # 2 state + 2 costate + end + + # ==================================================================== + # UNIT TESTS - Phase 5 supplement: initial_control with empty vector + # ==================================================================== + + Test.@testset "Init - initial_control with empty vector accepted" begin + # Build a Model without control + pre = OCP.PreModel() + OCP.time!(pre, t0=0, tf=1) + OCP.state!(pre, 2) + OCP.dynamics!(pre, (x, u) -> [x[2], -x[1]]) + OCP.objective!(pre, :min, mayer=(x0, xf) -> xf[1]^2) + OCP.time_dependence!(pre, autonomous=false) + OCP.definition!(pre, quote end) + model = OCP.build(pre) + + # Empty vector should be accepted when dim=0 + u_init = Init.initial_control(model, Float64[]) + Test.@test u_init isa Function + Test.@test u_init(0.5) == Float64[] + end + + # ==================================================================== + # INTEGRATION TESTS - Phase 3 supplement: build with variable + no control + # ==================================================================== + + Test.@testset "build() - Model without control but with variable" begin + pre = OCP.PreModel() + OCP.time!(pre, t0=0, tf=1) + OCP.state!(pre, 2) + OCP.variable!(pre, 1) + OCP.dynamics!(pre, (x, u) -> [x[2], -x[1]]) + OCP.objective!(pre, :min, mayer=(x0, xf, v) -> xf[1]^2 + v[1]) + OCP.time_dependence!(pre, autonomous=false) + OCP.definition!(pre, quote end) + model = OCP.build(pre) + Test.@test OCP.control_dimension(model) == 0 + Test.@test OCP.variable_dimension(model) == 1 + Test.@test OCP.state_dimension(model) == 2 + end + + # ==================================================================== + # UNIT TESTS - Phase 4 supplement: display with variable but no control + # ==================================================================== + + Test.@testset "Display - Model without control but with variable" begin + pre = OCP.PreModel() + OCP.time!(pre, t0=0, tf=1) + OCP.state!(pre, 2) + OCP.variable!(pre, 1) + OCP.dynamics!(pre, (x, u) -> [x[2], -x[1]]) + OCP.objective!(pre, :min, mayer=(x0, xf, v) -> xf[1]^2 + v[1]) + OCP.time_dependence!(pre, autonomous=false) + OCP.definition!(pre, quote end) + model = OCP.build(pre) + + io = IOBuffer() + Test.@test_nowarn show(io, MIME"text/plain"(), model) + output = String(take!(io)) + + # Control should not appear in output + Test.@test !occursin("u(", output) + # Variable should appear + Test.@test occursin("v", output) + # Objective should mention x and v but not u + Test.@test occursin("J(x", output) + Test.@test !occursin("J(x, u", output) + end + + # ==================================================================== + # INTEGRATION TESTS - Phase 7: Serialization round-trip JSON + # ==================================================================== + + Test.@testset "Serialization - Round-trip JSON export/import" begin + # Build a Model without control + pre = OCP.PreModel() + OCP.time!(pre, t0=0, tf=1) + OCP.state!(pre, 2) + OCP.dynamics!(pre, (x, u) -> [x[2], -x[1]]) + OCP.objective!(pre, :min, mayer=(x0, xf) -> xf[1]^2) + OCP.time_dependence!(pre, autonomous=false) + OCP.definition!(pre, quote end) + model = OCP.build(pre) + + T = collect(range(0, 1, length=10)) + x_data = hcat(sin.(T), cos.(T)) + u_data = Matrix{Float64}(undef, 10, 0) + p_data = hcat(cos.(T), -sin.(T)) + v_data = Float64[] + + sol = OCP.build_solution( + model, + T, T, T, T, + x_data, u_data, v_data, p_data; + objective=0.5, + iterations=5, + constraints_violation=0.0, + message="zero control test", + status=:success, + successful=true + ) + + mktempdir() do dir + filename = joinpath(dir, "sol_zero_ctrl") + CTModels.export_ocp_solution(sol; format=:JSON, filename=filename) + sol2 = CTModels.import_ocp_solution(model; format=:JSON, filename=filename) + + Test.@test CTModels.control_dimension(sol2) == 0 + Test.@test CTModels.state_dimension(sol2) == 2 + Test.@test CTModels.objective(sol2) ≈ CTModels.objective(sol) atol=1e-10 + Test.@test CTModels.iterations(sol2) == CTModels.iterations(sol) + Test.@test CTModels.successful(sol2) == CTModels.successful(sol) + # Control function should return empty vector after round-trip + Test.@test CTModels.control(sol2)(0.5) == Float64[] + end + end + + # ==================================================================== + # INTEGRATION TESTS - Phase 7: Serialization round-trip JLD2 + # ==================================================================== + + Test.@testset "Serialization - Round-trip JLD2 export/import" begin + # Build a Model without control + pre = OCP.PreModel() + OCP.time!(pre, t0=0, tf=1) + OCP.state!(pre, 2) + OCP.dynamics!(pre, (x, u) -> [x[2], -x[1]]) + OCP.objective!(pre, :min, mayer=(x0, xf) -> xf[1]^2) + OCP.time_dependence!(pre, autonomous=false) + OCP.definition!(pre, quote end) + model = OCP.build(pre) + + T = collect(range(0, 1, length=10)) + x_data = hcat(sin.(T), cos.(T)) + u_data = Matrix{Float64}(undef, 10, 0) + p_data = hcat(cos.(T), -sin.(T)) + v_data = Float64[] + + sol = OCP.build_solution( + model, + T, T, T, T, + x_data, u_data, v_data, p_data; + objective=0.5, + iterations=5, + constraints_violation=0.0, + message="zero control test", + status=:success, + successful=true + ) + + mktempdir() do dir + filename = joinpath(dir, "sol_zero_ctrl") + CTModels.export_ocp_solution(sol; format=:JLD, filename=filename) + sol2 = CTModels.import_ocp_solution(model; format=:JLD, filename=filename) + + Test.@test CTModels.control_dimension(sol2) == 0 + Test.@test CTModels.state_dimension(sol2) == 2 + Test.@test CTModels.objective(sol2) ≈ CTModels.objective(sol) atol=1e-10 + Test.@test CTModels.iterations(sol2) == CTModels.iterations(sol) + Test.@test CTModels.successful(sol2) == CTModels.successful(sol) + # Control function should return empty vector after round-trip + Test.@test CTModels.control(sol2)(0.5) == Float64[] + end + end + + end +end + +end # module + +# CRITICAL: Redefine in outer scope for TestRunner +test_control_zero() = TestControlZero.test_control_zero() diff --git a/test/suite/ocp/test_dynamics.jl b/test/suite/ocp/test_dynamics.jl index 6dbd0fd6..601f2cb6 100644 --- a/test/suite/ocp/test_dynamics.jl +++ b/test/suite/ocp/test_dynamics.jl @@ -196,12 +196,13 @@ function test_partial_dynamics() ocp_missing, 1:1, partial_dyn_1! ) + # Control is now optional, so this should NOT throw an error ocp_missing = CTModels.PreModel() CTModels.time!(ocp_missing; t0=0.0, tf=10.0) CTModels.state!(ocp_missing, 1) - Test.@test_throws Exceptions.PreconditionError CTModels.dynamics!( - ocp_missing, 1:1, partial_dyn_1! - ) + # This should succeed now that control is optional + CTModels.dynamics!(ocp_missing, 1:1, partial_dyn_1!) + Test.@test CTModels.OCP.__is_dynamics_set(ocp_missing) ocp_missing = CTModels.PreModel() CTModels.state!(ocp_missing, 1) @@ -250,13 +251,15 @@ function test_full_dynamics() Test.@test_throws Exceptions.PreconditionError CTModels.dynamics!(ocp2, dynamics!) ###### - # 4. Error: control must be set before dynamics + # 4. Control is now optional - this should succeed ###### ocp3 = CTModels.PreModel() CTModels.time!(ocp3; t0=0.0, tf=10.0) CTModels.state!(ocp3, 1) CTModels.variable!(ocp3, 1) - Test.@test_throws Exceptions.PreconditionError CTModels.dynamics!(ocp3, dynamics!) + # This should succeed now that control is optional + CTModels.dynamics!(ocp3, dynamics!) + Test.@test CTModels.OCP.__is_dynamics_set(ocp3) ###### # 5. Error: time must be set before dynamics diff --git a/test/suite/ocp/test_objective.jl b/test/suite/ocp/test_objective.jl index f6bf0ae8..009eccf3 100644 --- a/test/suite/ocp/test_objective.jl +++ b/test/suite/ocp/test_objective.jl @@ -99,14 +99,14 @@ function test_objective() ocp, :min, mayer=mayer ) - # control not set + # control is now optional - this should succeed ocp = CTModels.PreModel() CTModels.time!(ocp; t0=0.0, tf=10.0) CTModels.state!(ocp, 1) CTModels.variable!(ocp, 1) - Test.@test_throws Exceptions.PreconditionError CTModels.objective!( - ocp, :min, mayer=mayer - ) + # This should succeed now that control is optional + CTModels.objective!(ocp, :min, mayer=mayer) + Test.@test CTModels.OCP.__is_objective_set(ocp) # times not set ocp = CTModels.PreModel()