From ff6df79e0c14bab2f91d20f108aa1818ccd9e7fb Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Tue, 10 Mar 2026 23:02:43 +0100 Subject: [PATCH 1/9] Phase 1 & 2: Add EmptyControlModel and relax control preconditions Phase 1: - Add EmptyControlModel sentinel type - Change PreModel.control default from nothing to EmptyControlModel() - Add __is_control_empty and redefine __is_control_set helpers - Remove control requirement from __is_consistent and __is_complete - Export EmptyControlModel - Add getters for EmptyControlModel (name, components, dimension) Phase 2: - Remove control precondition from dynamics! (full and partial) - Remove control precondition from objective! - Add conditional control precondition in constraint! (only for :control and :path types) - Update regression tests to reflect new behavior All tests pass (128/128 for Phase 2 regression tests) --- src/OCP/Components/constraints.jl | 18 ++-- src/OCP/Components/control.jl | 27 ++++++ src/OCP/Components/dynamics.jl | 12 --- src/OCP/Components/objective.jl | 8 +- src/OCP/OCP.jl | 2 +- src/OCP/Types/components.jl | 17 ++++ src/OCP/Types/model.jl | 15 ++-- test/suite/ocp/test_control.jl | 2 +- test/suite/ocp/test_control_zero.jl | 126 ++++++++++++++++++++++++++++ test/suite/ocp/test_dynamics.jl | 13 +-- test/suite/ocp/test_objective.jl | 8 +- 11 files changed, 206 insertions(+), 42 deletions(-) create mode 100644 test/suite/ocp/test_control_zero.jl diff --git a/src/OCP/Components/constraints.jl b/src/OCP/Components/constraints.jl index 6977eb1e..e954daef 100644 --- a/src/OCP/Components/constraints.jl +++ b/src/OCP/Components/constraints.jl @@ -298,19 +298,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 +312,16 @@ function constraint!( context="constraint! function - times validation", ) + # checks: control must be set for control-dependent constraint types + if type ∈ (:control, :path) + @ensure __is_control_set(ocp) Exceptions.PreconditionError( + "Control must be set for type=:$type constraints", + reason="control has not been defined yet but constraint type requires it", + suggestion="Call control!(ocp, dimension) before adding :$type constraints, or use a different constraint type", + context="constraint! function - control validation for type=:$type", + ) + 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..0913d6f1 100644 --- a/src/OCP/Components/dynamics.jl +++ b/src/OCP/Components/dynamics.jl @@ -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", @@ -91,12 +85,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..99e002d5 100644 --- a/src/OCP/Components/objective.jl +++ b/src/OCP/Components/objective.jl @@ -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..aeb5ce08 100644 --- a/src/OCP/Types/components.jl +++ b/src/OCP/Types/components.jl @@ -172,6 +172,23 @@ 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 (dimension 0). + +# Example + +```julia-repl +julia> using CTModels + +julia> ecm = CTModels.EmptyControlModel() +``` +""" +struct EmptyControlModel <: AbstractControlModel end + # ------------------------------------------------------------------------------ # """ $(TYPEDEF) diff --git a/src/OCP/Types/model.jl b/src/OCP/Types/model.jl index 54c86268..40020a13 100644 --- a/src/OCP/Types/model.jl +++ b/src/OCP/Types/model.jl @@ -181,7 +181,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 +222,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_set(ocp::PreModel)::Bool = __is_set(ocp.control) +__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_control_empty(ocp.control) """ $(TYPEDSIGNATURES) @@ -334,7 +341,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 +354,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/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..71bd319c --- /dev/null +++ b/test/suite/ocp/test_control_zero.jl @@ -0,0 +1,126 @@ +module TestControlZero + +import Test +import CTBase.Exceptions +import CTModels.OCP + +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! - control type requires control" begin + # Control constraints should require control to be set + pre = OCP.PreModel() + OCP.state!(pre, 2) + OCP.time!(pre, t0=0, tf=1) + + # This should throw an exception because control is not set + # We verify that an exception is thrown (the specific type will be PreconditionError) + 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 + + 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() From 1df3be3d5d62b4ccb2c0ae8c5bca4a53a91ca5b2 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Tue, 10 Mar 2026 23:04:52 +0100 Subject: [PATCH 2/9] Fix: Only :control constraints require control precondition, not :path - Change constraint! to only require control for :control type - Path constraints can now work without control (e.g., state-only path constraints) - Add test for path constraint without control - Update test description for control constraint - All tests pass (17/17 for control_zero, 39/39 for constraints) --- src/OCP/Components/constraints.jl | 10 +++++----- test/suite/ocp/test_control_zero.jl | 16 +++++++++++++--- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/OCP/Components/constraints.jl b/src/OCP/Components/constraints.jl index e954daef..c4cb2f86 100644 --- a/src/OCP/Components/constraints.jl +++ b/src/OCP/Components/constraints.jl @@ -312,13 +312,13 @@ function constraint!( context="constraint! function - times validation", ) - # checks: control must be set for control-dependent constraint types - if type ∈ (:control, :path) + # 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=:$type constraints", + "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 :$type constraints, or use a different constraint type", - context="constraint! function - control validation for type=:$type", + suggestion="Call control!(ocp, dimension) before adding :control constraints, or use a different constraint type", + context="constraint! function - control validation for type=:control", ) end diff --git a/test/suite/ocp/test_control_zero.jl b/test/suite/ocp/test_control_zero.jl index 71bd319c..df28d203 100644 --- a/test/suite/ocp/test_control_zero.jl +++ b/test/suite/ocp/test_control_zero.jl @@ -99,14 +99,24 @@ function test_control_zero() 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 - # Control constraints should require control to be set + # 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 an exception because control is not set - # We verify that an exception is thrown (the specific type will be PreconditionError) + # This should throw a PreconditionError because control is not set exception_thrown = false try OCP.constraint!(pre, :control, rg=1, lb=-1, ub=1) From 98fc3988d1acda28712e47a4b34b659d2dfbac52 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Tue, 10 Mar 2026 23:08:13 +0100 Subject: [PATCH 3/9] Phase 3: Remove control precondition from build() - Remove control requirement from build() function - Control is now optional when building a Model - Add test for building Model without control - All tests pass (21/21 for control_zero, 60/60 for model regression) This allows creating optimal control problems without control input (control dimension = 0). --- src/OCP/Building/model.jl | 6 ------ test/suite/ocp/test_control_zero.jl | 22 ++++++++++++++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/OCP/Building/model.jl b/src/OCP/Building/model.jl index d0bd0c51..1945fa72 100644 --- a/src/OCP/Building/model.jl +++ b/src/OCP/Building/model.jl @@ -289,12 +289,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/test/suite/ocp/test_control_zero.jl b/test/suite/ocp/test_control_zero.jl index df28d203..2d9a4d1b 100644 --- a/test/suite/ocp/test_control_zero.jl +++ b/test/suite/ocp/test_control_zero.jl @@ -127,6 +127,28 @@ function test_control_zero() 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 + end end From 4d2818dc2dc8225cd6d8382c62f674d96afcb9ce Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Tue, 10 Mar 2026 23:13:14 +0100 Subject: [PATCH 4/9] Phase 4: Conditional control display in print functions - Make control display conditional based on u_dim > 0 - Skip control in objective function display when u_dim == 0 - Skip control in dynamics arguments when u_dim == 0 - Skip control in 'where' clause when u_dim == 0 - Add test for display without control - All tests pass (25/25 for control_zero, 4/4 for print regression) This allows proper display of optimal control problems without control input. --- src/Display/print.jl | 55 ++++++++++++++++++----------- test/suite/ocp/test_control_zero.jl | 26 ++++++++++++++ 2 files changed, 61 insertions(+), 20 deletions(-) diff --git a/src/Display/print.jl b/src/Display/print.jl index 9c7611c4..ab4b7b29 100644 --- a/src/Display/print.jl +++ b/src/Display/print.jl @@ -91,10 +91,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 +113,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 +208,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 +246,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/test/suite/ocp/test_control_zero.jl b/test/suite/ocp/test_control_zero.jl index 2d9a4d1b..d5adb445 100644 --- a/test/suite/ocp/test_control_zero.jl +++ b/test/suite/ocp/test_control_zero.jl @@ -149,6 +149,32 @@ function test_control_zero() 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 + end end From 47c785a68c891db28587e494b97b199a3494167c Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Tue, 10 Mar 2026 23:18:32 +0100 Subject: [PATCH 5/9] Phase 5: Handle zero control dimension in initialization - Add zero dimension handling in initial_control for Real input - Add zero dimension handling in initial_control for Vector input - Return empty vector function when control dimension is 0 and input is nothing - Throw appropriate errors when trying to initialize zero-dim control with non-empty data - Add comprehensive tests for initialization without control - All tests pass (33/33 for control_zero, 239/239 for initial_guess regression) This allows proper initialization of optimal control problems without control input. --- src/Init/control.jl | 31 +++++++++-- test/suite/ocp/test_control_zero.jl | 83 +++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 4 deletions(-) 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/test/suite/ocp/test_control_zero.jl b/test/suite/ocp/test_control_zero.jl index d5adb445..8157f2fe 100644 --- a/test/suite/ocp/test_control_zero.jl +++ b/test/suite/ocp/test_control_zero.jl @@ -3,6 +3,7 @@ module TestControlZero import Test import CTBase.Exceptions import CTModels.OCP +import CTModels.Init const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true @@ -175,6 +176,88 @@ function test_control_zero() 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 + end end From 3d5a5f834a6c90b3fe347492a1dc5a2421c7aaa2 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Tue, 10 Mar 2026 23:27:51 +0100 Subject: [PATCH 6/9] Phase 6-8: Complete zero control dimension support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6: Validation - No changes needed - Name validation helpers already handle EmptyControlModel correctly via __is_control_set() - Tests pass (39/39) Phase 7: Serialization - No changes needed - Serialization handles zero control dimension correctly (creates n×0 matrices) - Tests pass (1831/1831) Phase 8: Plotting - Skip control plots when dim=0 - Modified do_plot() in ext/plot_utils.jl to check control_dimension(sol) > 0 - Control plots are now skipped when control dimension is zero - Tests pass (33/33) All 8 phases of the refactoring are now complete. The codebase fully supports optimal control problems without control input (control dimension = 0) while maintaining backward compatibility. --- ext/plot_utils.jl | 2 +- test/suite/ocp/test_control_zero.jl | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/ext/plot_utils.jl b/ext/plot_utils.jl index 49ec789d..4c32ebcb 100644 --- a/ext/plot_utils.jl +++ b/ext/plot_utils.jl @@ -73,7 +73,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/test/suite/ocp/test_control_zero.jl b/test/suite/ocp/test_control_zero.jl index 8157f2fe..0f287c22 100644 --- a/test/suite/ocp/test_control_zero.jl +++ b/test/suite/ocp/test_control_zero.jl @@ -258,6 +258,11 @@ function test_control_zero() Test.@test init.control(0.5) == Float64[] end + # Note: Phase 8 (Plotting) is handled by the Plots extension + # The modification in ext/plot_utils.jl ensures that do_plot_control + # returns false when control_dimension(sol) == 0, which prevents + # control plots from being created for models without control. + end end From a072b77bea10f5633f43191fd3c48774675281b0 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Tue, 10 Mar 2026 23:43:40 +0100 Subject: [PATCH 7/9] Add comprehensive tests for phases 6, 7, and 8 Phase 6: Name validation tests - Verify that control names are not collected when control is not set - Test that 'u' can be used as state component name when control is empty Phase 7: Serialization tests - Test solution building with zero control dimension - Verify control function returns empty vector - Test solution properties (state_dimension, objective) Phase 8: Plotting tests - Import Plots at module level - Test subplot count with :group layout (state + costate = 2 subplots) - Test subplot count with :split layout (2 state + 2 costate = 4 subplots) - Verify control plots are correctly skipped when dim=0 All tests pass (45/45) including the new comprehensive tests for zero control dimension support. --- test/suite/ocp/test_control_zero.jl | 122 +++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 4 deletions(-) diff --git a/test/suite/ocp/test_control_zero.jl b/test/suite/ocp/test_control_zero.jl index 0f287c22..ada9ada2 100644 --- a/test/suite/ocp/test_control_zero.jl +++ b/test/suite/ocp/test_control_zero.jl @@ -4,6 +4,7 @@ import Test import CTBase.Exceptions import CTModels.OCP import CTModels.Init +import Plots const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true @@ -258,10 +259,123 @@ function test_control_zero() Test.@test init.control(0.5) == Float64[] end - # Note: Phase 8 (Plotting) is handled by the Plots extension - # The modification in ext/plot_utils.jl ensures that do_plot_control - # returns false when control_dimension(sol) == 0, which prevents - # control plots from being created for models without control. + # ==================================================================== + # 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)) # (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 plotting with :group layout + p = Plots.plot(sol, layout=:group) + # Without control: state + costate = 2 subplots + # With control: state + costate + control = 3 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 + # With control: 2 state + 2 costate + m control = 4 + m subplots + Test.@test length(p_split.subplots) == 4 + end end end From 61685931546b33a89268067ea8dbe3df687445c8 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Tue, 10 Mar 2026 23:46:16 +0100 Subject: [PATCH 8/9] Fix exception tests after removing control precondition from objective --- test/suite/exceptions/test_ocp_integration.jl | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 From 938efe20c8d5be7177e843d06d6f65f0e7abaf7b Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Wed, 11 Mar 2026 00:11:02 +0100 Subject: [PATCH 9/9] Add missing tests and update docstrings for zero control dimension support Tests added to test_control_zero.jl: - Serialization round-trip JSON export/import with control_dimension=0 - Serialization round-trip JLD2 export/import with control_dimension=0 - initial_control with empty vector Float64[] accepted when dim=0 - Plotting: explicit :control in description silently skipped when dim=0 - Plotting: state-only description with :split layout - build() with variable but no control (dimension 0) - Display: model without control but with variable (J(x,v) not J(x,u,v)) Docstrings updated: - __is_control_set(::Model): clarify EmptyControlModel case - PreModel: update control field type annotation - dynamics!(ocp, f): remove outdated control precondition mention - dynamics!(ocp, rg, f): same - objective!(ocp, ...): remove control from preconditions list - constraint!(ocp, ...): document control only needed for :control type - build(pre_ocp): document control as optional, add zero-control example - __print_mathematical_definition: document u_dim=0 behavior - do_plot: document control skipped when control_dimension=0 - EmptyControlModel: enhance with usage context and See also refs --- ext/plot_utils.jl | 2 + src/Display/print.jl | 7 + src/OCP/Building/model.jl | 22 +++- src/OCP/Components/constraints.jl | 7 +- src/OCP/Components/dynamics.jl | 11 +- src/OCP/Components/objective.jl | 4 +- src/OCP/Types/components.jl | 16 ++- src/OCP/Types/model.jl | 9 +- test/suite/ocp/test_control_zero.jl | 197 ++++++++++++++++++++++++++-- 9 files changed, 250 insertions(+), 25 deletions(-) diff --git a/ext/plot_utils.jl b/ext/plot_utils.jl index 4c32ebcb..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 diff --git a/src/Display/print.jl b/src/Display/print.jl index ab4b7b29..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. diff --git a/src/OCP/Building/model.jl b/src/OCP/Building/model.jl index 1945fa72..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) diff --git a/src/OCP/Components/constraints.jl b/src/OCP/Components/constraints.jl index c4cb2f86..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, diff --git a/src/OCP/Components/dynamics.jl b/src/OCP/Components/dynamics.jl index 0913d6f1..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. @@ -57,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. @@ -68,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. diff --git a/src/OCP/Components/objective.jl b/src/OCP/Components/objective.jl index 99e002d5..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 diff --git a/src/OCP/Types/components.jl b/src/OCP/Types/components.jl index aeb5ce08..f1696cc6 100644 --- a/src/OCP/Types/components.jl +++ b/src/OCP/Types/components.jl @@ -177,15 +177,27 @@ $(TYPEDEF) Sentinel type representing the absence of a control input in an optimal control problem. -Used when the problem has no control variable (dimension 0). +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> ecm = CTModels.EmptyControlModel() +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 diff --git a/src/OCP/Types/model.jl b/src/OCP/Types/model.jl index 40020a13..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. diff --git a/test/suite/ocp/test_control_zero.jl b/test/suite/ocp/test_control_zero.jl index ada9ada2..33359416 100644 --- a/test/suite/ocp/test_control_zero.jl +++ b/test/suite/ocp/test_control_zero.jl @@ -4,7 +4,10 @@ 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 @@ -333,7 +336,7 @@ function test_control_zero() # ==================================================================== # INTEGRATION TESTS - Phase 8: Plotting without control # ==================================================================== - + Test.@testset "Plotting - Verify subplot count without control" begin # Build a Model without control pre = OCP.PreModel() @@ -344,14 +347,14 @@ function test_control_zero() 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 + 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, @@ -363,20 +366,192 @@ function test_control_zero() status=:success, successful=true ) - + # Test plotting with :group layout p = Plots.plot(sol, layout=:group) # Without control: state + costate = 2 subplots - # With control: state + costate + control = 3 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 - # With control: 2 state + 2 costate + m control = 4 + m 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