Skip to content

Commit 30c7801

Browse files
authored
Merge pull request #246 from control-toolbox/dim-zero-control
feat: support optimal control problems with zero-dimensional control
2 parents 7a41466 + 1871282 commit 30c7801

5 files changed

Lines changed: 304 additions & 13 deletions

File tree

Project.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name = "CTParser"
22
uuid = "32681960-a1b1-40db-9bff-a1ca817385d1"
3-
version = "0.8.10-beta"
3+
version = "0.8.12-beta"
44
authors = ["Jean-Baptiste Caillau <jean-baptiste.caillau@univ-cotedazur.fr>"]
55

66
[deps]

src/onepass.jl

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,8 @@ function p_control!(
560560
p, p_ocp, u, m; components_names=nothing, log=false, backend=__default_parsing_backend()
561561
)
562562
log && println("control: $u, dim: $m")
563+
(p.is_global_dyn || p.is_coord_dyn) && return __throw("control must be declared before dynamics", p.lnum, p.line)
564+
!isnothing(p.criterion) && return __throw("control must be declared before cost criterion", p.lnum, p.line)
563565
u isa Symbol || return __throw("forbidden control name: $u", p.lnum, p.line)
564566
uu = QuoteNode(u)
565567
if m == 1
@@ -848,7 +850,7 @@ function p_dynamics!(
848850
log && println("dynamics: ∂($x)($t) == $e")
849851
isnothing(label) || return __throw("dynamics cannot be labelled", p.lnum, p.line)
850852
isnothing(p.x) && return __throw("state not yet declared", p.lnum, p.line)
851-
isnothing(p.u) && return __throw("control not yet declared", p.lnum, p.line)
853+
# isnothing(p.u) && return __throw("control not yet declared", p.lnum, p.line)
852854
isnothing(p.t) && return __throw("time not yet declared", p.lnum, p.line)
853855
x p.x && return __throw("wrong state $x for dynamics", p.lnum, p.line)
854856
t p.t && return __throw("wrong time $t for dynamics", p.lnum, p.line)
@@ -948,7 +950,7 @@ function p_dynamics_coord!(
948950
log && println("dynamics: ∂($x[$i])($t) == $e")
949951
isnothing(label) || return __throw("dynamics cannot be labelled", p.lnum, p.line)
950952
isnothing(p.x) && return __throw("state not yet declared", p.lnum, p.line)
951-
isnothing(p.u) && return __throw("control not yet declared", p.lnum, p.line)
953+
# isnothing(p.u) && return __throw("control not yet declared", p.lnum, p.line)
952954
isnothing(p.t) && return __throw("time not yet declared", p.lnum, p.line)
953955
x p.x && return __throw("wrong state $x for dynamics", p.lnum, p.line)
954956
t p.t && return __throw("wrong time $t for dynamics", p.lnum, p.line)
@@ -1038,7 +1040,7 @@ end
10381040
function p_lagrange!(p, p_ocp, e, type; log=false, backend=__default_parsing_backend())
10391041
log && println("objective (Lagrange): ∫($e) → $type")
10401042
isnothing(p.x) && return __throw("state not yet declared", p.lnum, p.line)
1041-
isnothing(p.u) && return __throw("control not yet declared", p.lnum, p.line)
1043+
# isnothing(p.u) && return __throw("control not yet declared", p.lnum, p.line)
10421044
isnothing(p.t) && return __throw("time not yet declared", p.lnum, p.line)
10431045
xut = __symgen(:xut)
10441046
ee = replace_call(e, [p.x, p.u], p.t, [xut, xut])
@@ -1159,7 +1161,7 @@ function p_bolza!(p, p_ocp, e1, e2, type; log=false, backend=__default_parsing_b
11591161
isnothing(p.x) && return __throw("state not yet declared", p.lnum, p.line)
11601162
isnothing(p.t0) && return __throw("time not yet declared", p.lnum, p.line)
11611163
isnothing(p.tf) && return __throw("time not yet declared", p.lnum, p.line)
1162-
isnothing(p.u) && return __throw("control not yet declared", p.lnum, p.line)
1164+
# isnothing(p.u) && return __throw("control not yet declared", p.lnum, p.line)
11631165
isnothing(p.t) && return __throw("time not yet declared", p.lnum, p.line)
11641166
xut = __symgen(:xut)
11651167
ee2 = replace_call(e2, [p.x, p.u], p.t, [xut, xut])
@@ -1421,7 +1423,7 @@ function def_exa(e; log=false)
14211423
res = if val == :state # if as @match is not exported from CTParser or OptimalControl
14221424
$pref.solution(sol, $(p.x))
14231425
elseif val == :control
1424-
$pref.solution(sol, $(p.u))
1426+
isnothing($(p.dim_u)) ? base_type[] : $pref.solution(sol, $(p.u))
14251427
elseif val == :variable
14261428
isnothing($(p.dim_v)) ? base_type[] : $pref.solution(sol, $(p.v))
14271429
elseif val == :costate
@@ -1435,9 +1437,9 @@ function def_exa(e; log=false)
14351437
elseif val == :state_u
14361438
$pref.multipliers_U(sol, $(p.x))
14371439
elseif val == :control_l
1438-
$pref.multipliers_L(sol, $(p.u))
1440+
isnothing($(p.dim_u)) ? base_type[] : $pref.multipliers_L(sol, $(p.u))
14391441
elseif val == :control_u
1440-
$pref.multipliers_U(sol, $(p.u))
1442+
isnothing($(p.dim_u)) ? base_type[] : $pref.multipliers_U(sol, $(p.u))
14411443
elseif val == :variable_l
14421444
if isnothing($(p.dim_v))
14431445
base_type[]

src/utils.jl

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -264,16 +264,16 @@ julia> e = :( ((x^2)(t0) + u[1])(t) ); replace_call(e, [ x, u ], t , [ :xx, :uu
264264
:((xx ^ 2)(t0) + uu[1])
265265
```
266266
"""
267-
function replace_call(e, x::Vector{Symbol}, t, y)
267+
function replace_call(e, x::Vector{<:Union{Nothing, Symbol}}, t, y)
268268
@assert length(x) == length(y)
269269
foo(x, t, y) =
270270
(h, args...) -> begin
271271
ee = Expr(h, args...)
272272
@match ee begin
273273
:($eee($tt)) && if tt == t
274274
end => let ch = false
275-
for i in 1:length(x)
276-
if has(eee, x[i])
275+
for i in eachindex(x)
276+
if !isnothing(x[i]) && has(eee, x[i]) # skip Nothing symbols
277277
eee = subs(eee, x[i], y[i])
278278
ch = true # todo: unnecessary (as subs can be idempotent)?
279279
end

test/runtests.jl

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,12 @@ using NLPModels
6161

6262
include("utils.jl")
6363

64-
const VERBOSE = true
65-
const SHOWTIMING = true
64+
# Controls nested testset output formatting (used by individual test files)
65+
module TestData
66+
const VERBOSE = true
67+
const SHOWTIMING = true
68+
end
69+
using .TestData: VERBOSE, SHOWTIMING
6670

6771
# Run tests using the TestRunner extension
6872
CTBase.run_tests(;

test/test_control_zero.jl

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
module TestControlZero
2+
3+
using Test: Test
4+
import CTParser
5+
import CTBase.Exceptions
6+
import CTModels.OCP
7+
import CTModels.Init
8+
9+
# for the @def and @init macros
10+
import CTBase
11+
import CTModels
12+
import ExaModels
13+
import MadNLP
14+
15+
include(joinpath(@__DIR__, "utils.jl"))
16+
17+
const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true
18+
const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true
19+
20+
function test_control_zero()
21+
Test.@testset "Control Zero Dimension Tests" verbose=VERBOSE showtiming=SHOWTIMING begin
22+
23+
# Build a Model without control
24+
function get_model(; variable=false)
25+
if variable
26+
return CTParser.@def begin
27+
v R, variable
28+
t [0, 1], time
29+
x R², state
30+
(t) == [x₂(t), -x₁(t)]
31+
x₁(1)^2 + v min
32+
end
33+
else
34+
return CTParser.@def begin
35+
t [0, 1], time
36+
x R², state
37+
(t) == [x₂(t), -x₁(t)]
38+
x₁(1)^2 min
39+
end
40+
end
41+
end
42+
43+
# ====================================================================
44+
# UNIT TESTS - Building without control
45+
# ====================================================================
46+
47+
Test.@testset "build() - Model without control" begin
48+
o = get_model()
49+
Test.@test o isa OCP.Model
50+
Test.@test OCP.control_dimension(o) == 0
51+
Test.@test OCP.control_name(o) == ""
52+
Test.@test OCP.control_components(o) == String[]
53+
end
54+
55+
Test.@testset "build() - Model without control but with variable" begin
56+
# build a model with a variable
57+
ov = get_model(variable=true)
58+
Test.@test OCP.control_dimension(ov) == 0
59+
Test.@test OCP.variable_dimension(ov) == 1
60+
Test.@test OCP.state_dimension(ov) == 2
61+
end
62+
63+
# ====================================================================
64+
# UNIT TESTS - Declaration Order Validation
65+
# ====================================================================
66+
67+
Test.@testset "Control declaration order validation" begin
68+
# Control after dynamics should fail
69+
Test.@test_throws Exceptions.ParsingError begin
70+
CTParser.@def begin
71+
t [0, 1], time
72+
x R², state
73+
(t) == [x₂(t), -x₁(t)]
74+
u R, control # ❌ After dynamics
75+
x₁(1)^2 min
76+
end
77+
end
78+
79+
# Control after cost should fail
80+
Test.@test_throws Exceptions.ParsingError begin
81+
CTParser.@def begin
82+
t [0, 1], time
83+
x R², state
84+
x₁(1)^2 min
85+
u R, control # ❌ After cost
86+
end
87+
end
88+
end
89+
90+
# ====================================================================
91+
# UNIT TESTS - Coordinate Dynamics Without Control
92+
# ====================================================================
93+
94+
Test.@testset "Coordinate dynamics without control" begin
95+
o = CTParser.@def begin
96+
t [0, 1], time
97+
x R², state
98+
(x₁)(t) == x₂(t)
99+
(x₂)(t) == -x₁(t)
100+
x₁(1)^2 min
101+
end
102+
Test.@test OCP.control_dimension(o) == 0
103+
Test.@test OCP.state_dimension(o) == 2
104+
end
105+
106+
# ====================================================================
107+
# UNIT TESTS - Advanced Cost Criteria Without Control
108+
# ====================================================================
109+
110+
Test.@testset "Advanced cost criteria without control" begin
111+
# Lagrange cost
112+
o1 = CTParser.@def begin
113+
t [0, 1], time
114+
x R², state
115+
(t) == [x₂(t), -x₁(t)]
116+
(x₁(t)^2 + x₂(t)^2) min
117+
end
118+
Test.@test OCP.control_dimension(o1) == 0
119+
120+
# Bolza cost
121+
o2 = CTParser.@def begin
122+
t [0, 1], time
123+
x R², state
124+
(t) == [x₂(t), -x₁(t)]
125+
x₁(0)^2 + (x₂(t)^2) min
126+
end
127+
Test.@test OCP.control_dimension(o2) == 0
128+
end
129+
130+
# ====================================================================
131+
# UNIT TESTS - Constraints Without Control
132+
# ====================================================================
133+
134+
Test.@testset "Constraints without control" begin
135+
o = CTParser.@def begin
136+
t [0, 1], time
137+
x R², state
138+
(t) == [x₂(t), -x₁(t)]
139+
x₁(0) == 1
140+
x₂(0) == 0
141+
x₁(1) + x₂(1) 1
142+
x₁(1)^2 min
143+
end
144+
Test.@test OCP.control_dimension(o) == 0
145+
Test.@test OCP.state_dimension(o) == 2
146+
end
147+
148+
# ====================================================================
149+
# UNIT TESTS - Initialization without control
150+
# ====================================================================
151+
152+
Test.@testset "Init - initial_control with scalar throws error" begin
153+
o = get_model()
154+
Test.@test_throws Exceptions.IncorrectArgument begin
155+
ig = CTParser.@init o begin
156+
u(t) := 0.5
157+
end
158+
end
159+
end
160+
161+
Test.@testset "Init - initial_control with non-empty vector throws error" begin
162+
o = get_model()
163+
Test.@test_throws Exceptions.IncorrectArgument begin
164+
ig = CTParser.@init o begin
165+
u(t) := [0.5]
166+
end
167+
end
168+
end
169+
170+
Test.@testset "Init - initial_guess without control" begin
171+
o = get_model()
172+
ig = CTParser.@init o begin end
173+
u_init = OCP.control(ig)
174+
Test.@test ig isa Init.InitialGuess
175+
Test.@test u_init isa Function
176+
Test.@test u_init(0.5) == Float64[]
177+
end
178+
179+
Test.@testset "Advanced initialization without control" begin
180+
# Test with state initialization only
181+
o = get_model()
182+
ig = CTParser.@init o begin
183+
x(t) := [sin(t), cos(t)]
184+
end
185+
Test.@test ig isa Init.InitialGuess
186+
187+
# Test with variable initialization
188+
o = get_model(; variable=true)
189+
ig2 = CTParser.@init o begin
190+
x(t) := [sin(t), cos(t)]
191+
v := 1.0
192+
end
193+
Test.@test ig2 isa Init.InitialGuess
194+
Test.@test OCP.variable(ig2) == 1.0
195+
end
196+
197+
# ====================================================================
198+
# INTEGRATION TESTS - Getter Function Without Control
199+
# ====================================================================
200+
201+
Test.@testset "Getter function without control" begin
202+
o = get_model()
203+
204+
# Define grid size explicitly
205+
N = 10
206+
207+
# Build NLP and getter using discretise_exa_full
208+
m, exa_getter = discretise_exa_full(o; grid_size=N)
209+
Test.@test m isa ExaModels.ExaModel
210+
211+
# Solve the NLP to get a solution
212+
sol = MadNLP.madnlp(m; tol=1e-6, print_level=MadNLP.ERROR)
213+
Test.@test sol.status == MadNLP.SOLVE_SUCCEEDED
214+
215+
# Test getter function - this should work without throwing errors
216+
# Test state retrieval (should work)
217+
state_result = exa_getter(sol, val=:state)
218+
Test.@test state_result isa Matrix
219+
Test.@test size(state_result) == (2, N+1) # N grid points
220+
221+
# Test control retrieval (should return empty array, not throw error)
222+
control_result = exa_getter(sol, val=:control)
223+
Test.@test control_result isa Vector
224+
Test.@test length(control_result) == 0
225+
226+
# Test control multipliers (should return empty array, not throw error)
227+
control_l_result = exa_getter(sol, val=:control_l)
228+
Test.@test control_l_result isa Vector
229+
Test.@test length(control_l_result) == 0
230+
231+
control_u_result = exa_getter(sol, val=:control_u)
232+
Test.@test control_u_result isa Vector
233+
Test.@test length(control_u_result) == 0
234+
end
235+
236+
# ====================================================================
237+
# INTEGRATION TESTS - Serialization without control
238+
# ====================================================================
239+
240+
Test.@testset "Serialization - Solution building without control" begin
241+
o = get_model()
242+
# Create a solution without control
243+
T = collect(range(0, 1, length=10))
244+
x_data = hcat(sin.(T), cos.(T)) # (10, 2) matrix
245+
u_data = Matrix{Float64}(undef, 10, 0) # Empty control matrix (10×0)
246+
p_data = hcat(cos.(T), -sin.(T)) # (10, 2) matrix
247+
v_data = Float64[]
248+
249+
sol = OCP.build_solution(
250+
o,
251+
T,
252+
T,
253+
T,
254+
T,
255+
x_data,
256+
u_data,
257+
v_data,
258+
p_data;
259+
objective=1.0,
260+
iterations=10,
261+
constraints_violation=0.0,
262+
message="Test solution",
263+
status=:success,
264+
successful=true,
265+
)
266+
267+
# Test that control_dimension is 0
268+
Test.@test OCP.control_dimension(sol) == 0
269+
270+
# Test that control function returns empty vector
271+
u_func = OCP.control(sol)
272+
Test.@test u_func(0.5) == Float64[]
273+
274+
# Test that solution properties are correct
275+
Test.@test OCP.state_dimension(sol) == 2
276+
Test.@test OCP.objective(sol) == 1.0
277+
end
278+
279+
end
280+
end
281+
282+
end # module
283+
284+
# CRITICAL: Redefine in outer scope for TestRunner
285+
test_control_zero() = TestControlZero.test_control_zero()

0 commit comments

Comments
 (0)