From d7bc10a75ca10f882235b764e8b38938ea852f35 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Tue, 21 Apr 2026 17:11:42 +0200 Subject: [PATCH 1/4] docs: add functional API documentation with mathematical formulation --- docs/make.jl | 5 +- docs/src/assets/custom.css | 39 ++ docs/src/index.md | 51 ++ docs/src/manual-macro-free.md | 874 +++++++++++++++++++++++++++ src/imports/ctmodels.jl | 13 + test/suite/reexport/test_ctmodels.jl | 22 + 6 files changed, 1003 insertions(+), 1 deletion(-) create mode 100644 docs/src/manual-macro-free.md diff --git a/docs/make.jl b/docs/make.jl index b26d47cbd..aed859c6b 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -213,7 +213,10 @@ with_api_reference(src_dir, ext_dir) do api_pages "State constraint" => "example-state-constraint.md", ], "Manual" => [ - "Define a problem" => "manual-abstract.md", + "Define a problem" => [ + "Abstract syntax (@def)" => "manual-abstract.md", + "Functional API (macro-free)" => "manual-macro-free.md", + ], "Use AI" => "manual-ai-llm.md", "Problem characteristics" => "manual-model.md", "Set an initial guess" => "manual-initial-guess.md", diff --git a/docs/src/assets/custom.css b/docs/src/assets/custom.css index 4bc1dbcf5..1f64f008e 100644 --- a/docs/src/assets/custom.css +++ b/docs/src/assets/custom.css @@ -33,4 +33,43 @@ .responsive-columns-left-priority > div:first-child { flex: 1 1 100%; } +} + +/* 30% / 70% two-column layout */ +.responsive-columns-30-70 { + display: flex; + gap: 1rem; + align-items: flex-start; + margin-bottom: 1em; +} + +.responsive-columns-30-70 > div:first-child { + flex: 3; + min-width: 0; + transition: opacity 0.5s ease-in-out, flex 0.5s ease-in-out, max-width 0.5s ease-in-out, max-height 0.5s ease-in-out; +} + +.responsive-columns-30-70 > div:last-child { + flex: 7; + min-width: 0; + opacity: 1; + max-width: 100%; + max-height: none; + transition: opacity 0.5s ease-in-out, flex 0.5s ease-in-out, max-width 0.5s ease-in-out, max-height 0.5s ease-in-out; +} + +@media (max-width: 700px) { + .responsive-columns-30-70 > div:last-child { + opacity: 0; + max-width: 0; + max-height: 0; + flex: 0 0 0; + overflow: hidden; + margin: 0; + padding: 0; + } + + .responsive-columns-30-70 > div:first-child { + flex: 1 1 100%; + } } \ No newline at end of file diff --git a/docs/src/index.md b/docs/src/index.md index 4c26ec6b0..2900080ac 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -43,6 +43,57 @@ plot(sol) - The [`solve`](@ref) function has many options. See the [solve tutorial](@ref manual-solve). - The [`plot`](@ref) function is flexible. See the [plot tutorial](@ref manual-plot). +## [Mathematical formulation](@id math-formulation) + +An optimal control problem (OCP) with fixed initial and final times can be described as minimising the cost functional (in Bolza form) + +```math +J(x, u) = g(x(t_0), x(t_f)) + \int_{t_0}^{t_f} f^{0}(t, x(t), u(t))\,\mathrm{d}t +``` + +where the state $x$ and the control $u$ are functions of time $t$, subject for $t \in [t_0, t_f]$ to the differential constraint + +```math +\dot{x}(t) = f(t, x(t), u(t)) +``` + +and other constraints such as + +```math +\begin{array}{llcll} +x_{\mathrm{lower}} & \le & x(t) & \le & x_{\mathrm{upper}}, \\ +u_{\mathrm{lower}} & \le & u(t) & \le & u_{\mathrm{upper}}, \\ +c_{\mathrm{lower}} & \le & c(t, x(t), u(t)) & \le & c_{\mathrm{upper}}, \\ +b_{\mathrm{lower}} & \le & b(x(t_0), x(t_f)) & \le & b_{\mathrm{upper}}. +\end{array} +``` + +If $g = 0$, the cost is said to be in **Lagrange form**; if $f^0 = 0$, it is in **Mayer form**. + +### Free times and extra variables + +The initial time $t_0$ and the final time $t_f$ may also be free, that is part of the optimisation variables: + +```math +J(x, u, t_0, t_f) \to \min. +``` + +More generally, a vector $v \in \mathbb{R}^k$ of $k$ additional variables can be introduced (it may contain $t_0$, $t_f$, or any other free parameter). The cost, dynamics, and constraints then all depend on $v$: + +```math +J(x, u, v) = g(x(t_0), x(t_f), v) + \int_{t_0}^{t_f} f^{0}(t, x(t), u(t), v)\,\mathrm{d}t \to \min, +``` + +```math +\dot{x}(t) = f(t, x(t), u(t), v), +``` + +with, in addition, box constraints on $v$: + +```math +v_{\mathrm{lower}} \le v \le v_{\mathrm{upper}}. +``` + ## Citing us If you use OptimalControl.jl in your work, please cite us: diff --git a/docs/src/manual-macro-free.md b/docs/src/manual-macro-free.md new file mode 100644 index 000000000..935363df1 --- /dev/null +++ b/docs/src/manual-macro-free.md @@ -0,0 +1,874 @@ +# [Functional API (macro-free)](@id manual-macro-free) + +The [`@def`](@ref manual-abstract-syntax) macro provides a concise DSL to define optimal control problems. An alternative is the **functional API**, which builds the same problem step by step using plain Julia functions. This approach is useful when: + +- generating problems **programmatically** from parameters, data, or loops, +- building **library code** that must not rely on macros, +- interfacing with external tools that process problem structures directly. + +The functional API uses [`OptimalControl.PreModel`](@ref CTModels.PreModel) as a mutable builder, populated by setter calls, then frozen into an immutable [`OptimalControl.Model`](@ref) by [`build`](@ref). + +!!! note + + When a problem is defined with the functional API, [`definition`](@ref)`(ocp)` returns an `EmptyDefinition` — no abstract expression is stored. This contrasts with `@def`, which records the full DSL expression for display and introspection. + +--- + +**Content** + +```@contents +Pages = ["manual-macro-free.md"] +Depth = 3 +``` + +--- + +## Canvas + +The functional API mirrors the [Mathematical formulation](@ref math-formulation). The correspondence is: + +| Math | Functional API | +| :--- | :--- | +| Dynamics $f(t, x, u)$ | `dyn!` passed to [`dynamics!`](@ref) | +| Lagrange integrand $f^0(t, x, u)$ | `lag` passed to [`objective!`](@ref) | +| Mayer terminal cost $g(x_0, x_f)$ | `may` passed to [`objective!`](@ref) | +| Path constraint $c(t, x, u)$ | `p!` passed to [`constraint!`](@ref)`(pre, :path; ...)` | +| Boundary constraint $b(x_0, x_f)$ | `b!` passed to [`constraint!`](@ref)`(pre, :boundary; ...)` | +| Extra variable $v$ | [`variable!`](@ref) (extra argument to all the callbacks above) | + +```julia +using OptimalControl + +pre = OptimalControl.PreModel() + +# ─── Optional: must come before time! when using indf/ind0 ─────────────────── +variable!(pre, q) # q = variable dimension +# ───────────────────────────────────────────────────────────────────────────── + +time!(pre; t0=..., tf=...) # fixed times +# or: time!(pre; t0=..., indf=i) # free final time at variable index i + +state!(pre, n) # n = state dimension + +# ─── Optional: omit entirely for control-free problems ─────────────────────── +control!(pre, m) # m = control dimension +# ───────────────────────────────────────────────────────────────────────────── + +# Dynamics — in-place, signature: dyn!(dx, t, x, u, v) +# dx : output vector (modified in place), length n +# t : current time (scalar) +# x : state (vector of length n; scalar state ↦ x[1]) +# u : control (vector of length m; scalar control ↦ u[1]; unused if control-free) +# v : variable (vector of length q; scalar variable ↦ v[1]; unused if no variable) +function dyn!(dx, t, x, u, v) + dx[1] = ... + dx[2] = ... +end +dynamics!(pre, dyn!) + +# Lagrange integrand — out-of-place, signature: lag(t, x, u, v) → scalar +lag(t, x, u, v) = ... +# Mayer terminal cost — out-of-place, signature: may(x0, xf, v) → scalar +# x0 : initial state (vector of length n; scalar state ↦ x0[1]) +# xf : final state (vector of length n; scalar state ↦ xf[1]) +may(x0, xf, v) = ... + +objective!(pre, :min; lagrange=lag) # Lagrange cost +# or: objective!(pre, :min; mayer=may) # Mayer cost +# or: objective!(pre, :min; mayer=may, lagrange=lag) # Bolza cost + +# ─── Optional: one call per constraint ─────────────────────────────────────── +# Two families of constraints: +# +# (a) Box constraints on components — :state, :control, :variable +# rg selects the component range i:j, with lb ≤ x[rg] ≤ ub (resp. u, v). +constraint!(pre, :state; rg=i:j, lb=..., ub=..., label=:name) +constraint!(pre, :control; rg=i:j, lb=..., ub=..., label=:name) +constraint!(pre, :variable; rg=i:j, lb=..., ub=..., label=:name) +# +# (b) Non-linear constraints defined by a function — :boundary, :path +# The constraint reads: lb ≤ f(...) ≤ ub (use lb=ub for equality). +# +# Boundary — in-place, signature: b!(val, x0, xf, v) (same shape as Mayer) +# val : output vector (modified in place), length = length(lb) = length(ub) +# x0 : initial state (vector of length n; scalar state ↦ x0[1]) +# xf : final state (vector of length n; scalar state ↦ xf[1]) +# v : variable (vector of length q) +function b!(val, x0, xf, v) + val[1] = ... +end +constraint!(pre, :boundary; f=b!, lb=..., ub=..., label=:name) +# +# Path — in-place, signature: p!(val, t, x, u, v) (same shape as dynamics) +# val : output vector (modified in place), length = length(lb) = length(ub) +# t : current time (scalar) +# x : state (vector of length n) +# u : control (vector of length m) +# v : variable (vector of length q) +function p!(val, t, x, u, v) + val[1] = ... +end +constraint!(pre, :path; f=p!, lb=..., ub=..., label=:name) +# ───────────────────────────────────────────────────────────────────────────── + +# autonomous=true ⟺ time t does NOT appear explicitly in the dynamics, +# the Lagrange integrand, nor in any :path constraint. +# autonomous=false ⟺ at least one of them depends explicitly on t. +time_dependence!(pre; autonomous=true) + +ocp = build(pre) +``` + +**Required:** `time!` · `state!` · `dynamics!` · `objective!` · `time_dependence!` · `build` + +**Optional:** `variable!` · `control!` · `constraint!` (repeatable) + +**Ordering constraints:** + +- `variable!` → before `time!` when using free-time indices (`indf`, `ind0`) +- `variable!` → before `dynamics!` and `objective!` +- `dynamics!` and `objective!` → after `time!` and `state!` + +## Examples + +For each problem below, the [`@def`](@ref) abstract syntax is shown on the left and the equivalent functional API on the right. After `build`, both formulations produce an equivalent model and can be passed directly to [`solve`](@ref manual-solve). + +--- + +### [Double integrator: energy minimisation](@id manual-macro-free-energy) + +The simplest case: fixed time interval, boundary constraints, autonomous dynamics, Lagrange cost. +See the [full example](@ref example-double-integrator-energy) for solving and plotting. + +```@example ex-energy +using OptimalControl +t0 = 0.0; tf = 1.0; x0 = [-1.0, 0.0]; xf = [0.0, 0.0] +nothing # hide +``` + +```@raw html +
+
+``` + +**Abstract syntax** + +```@example ex-energy +ocp_macro = @def begin + +t ∈ [t0, tf], time +x = (q, v) ∈ R², state +u ∈ R, control + +x(t0) == x0 +x(tf) == xf + +ẋ(t) == [v(t), u(t)] + +0.5∫( u(t)^2 ) → min + +end +nothing # hide +``` + +```@raw html +
+
+``` + +**Functional API** + +```@example ex-energy +pre = OptimalControl.PreModel() + +time!(pre; t0=t0, tf=tf) +# state "x" with components "q" (position) and "v" (velocity) +state!(pre, 2, "x", ["q", "v"]) +control!(pre, 1) + +function f_energy!(dx, t, x, u, v) + dx[1] = x[2] + dx[2] = u[1] + return nothing +end +dynamics!(pre, f_energy!) + +function boundary_energy!(b, x0_, xf_, v) + b[1] = x0_[1] - x0[1] + b[2] = x0_[2] - x0[2] + b[3] = xf_[1] - xf[1] + b[4] = xf_[2] - xf[2] + return nothing +end +constraint!(pre, + :boundary; + f=boundary_energy!, + lb=zeros(4), ub=zeros(4), + label=:endpoint +) + +lagrange_energy(t, x, u, v) = 0.5 * u[1]^2 +objective!(pre, :min; lagrange=lagrange_energy) + +time_dependence!(pre; autonomous=true) + +ocp_func = build(pre) +nothing # hide +``` + +```@raw html +
+
+``` + +Both formulations produce identical solutions. We solve both and plot them together for verification: + +```@example ex-energy +sol_macro = solve(ocp_macro; display=false) +sol_func = solve(ocp_func; display=false) + +println("Macro: objective = ", objective(sol_macro), ", iterations = ", iterations(sol_macro)) +println("Functional API: objective = ", objective(sol_func), ", iterations = ", iterations(sol_func)) +``` + +```@example ex-energy +plt = plot(sol_macro; label="Macro", color=1, size=(800, 600)) +plot!(plt, sol_func; label="Functional API", color=2, linestyle=:dash) +``` + +The two models are functionally equivalent. The key difference is visible via [`definition`](@ref): the macro records the full DSL expression, whereas the functional API stores an empty definition. + +```@example ex-energy +definition(ocp_macro) +``` + +```@example ex-energy +has_abstract_definition(ocp_func) +``` + +--- + +### [Double integrator: time minimisation](@id manual-macro-free-time) + +Free final time as a variable, Mayer cost, control box constraint. +See the [full example](@ref example-double-integrator-time) for solving and plotting. + +```@example ex-time +using OptimalControl +t0 = 0.0; x0 = [-1.0, 0.0]; xf = [0.0, 0.0] +nothing # hide +``` + +```@raw html +
+
+``` + +**Abstract syntax** + +```@example ex-time +ocp_macro = @def begin + +tf ∈ R, variable +t ∈ [t0, tf], time +x = (q, v) ∈ R², state +u ∈ R, control + +-1 ≤ u(t) ≤ 1 + +x(t0) == x0 +x(tf) == xf + +ẋ(t) == [v(t), u(t)] + +tf → min + +end +nothing # hide +``` + +```@raw html +
+
+``` + +**Functional API** + +```@example ex-time +pre = OptimalControl.PreModel() + +# variable[1] = final time tf +variable!(pre, 1, "tf") +# free final time: tf = variable[1] +time!(pre; t0=t0, indf=1) +# state "x" with components "q" (position) and "v" (velocity) +state!(pre, 2, "x", ["q", "v"]) +control!(pre, 1) + +function f_time!(dx, t, x, u, v) + dx[1] = x[2] + dx[2] = u[1] + return nothing +end +dynamics!(pre, f_time!) + +# control box constraint: -1 ≤ u ≤ 1 +constraint!(pre, + :control; + rg=1:1, lb=[-1.0], ub=[1.0], + label=:u_bounds +) + +function boundary_time!(b, x0_, xf_, v) + b[1] = x0_[1] - x0[1] + b[2] = x0_[2] - x0[2] + b[3] = xf_[1] - xf[1] + b[4] = xf_[2] - xf[2] + return nothing +end +constraint!(pre, + :boundary; + f=boundary_time!, + lb=zeros(4), ub=zeros(4), + label=:endpoint +) + +# Mayer cost: minimise tf = variable[1] +mayer_time(x0_, xf_, v) = v[1] +objective!(pre, :min; mayer=mayer_time) + +time_dependence!(pre; autonomous=true) + +ocp_func = build(pre) +nothing # hide +``` + +```@raw html +
+
+``` + +Both formulations produce identical solutions: + +```@example ex-time +sol_macro = solve(ocp_macro; display=false) +sol_func = solve(ocp_func; display=false) + +println("Macro: objective = ", objective(sol_macro), ", iterations = ", iterations(sol_macro)) +println("Functional API: objective = ", objective(sol_func), ", iterations = ", iterations(sol_func)) +``` + +```@example ex-time +plt = plot(sol_macro; label="Macro", color=1, size=(800, 600)) +plot!(plt, sol_func; label="Functional API", color=2, linestyle=:dash) +``` + +!!! note + `variable!(pre, 1, "tf")` must be called **before** `time!(pre; indf=1)` so that the free-time index refers to a declared variable. + +--- + +### [Control-free problems](@id manual-macro-free-control-free) + +No control variable: `control!` is simply omitted. The dynamics and objective still receive `u` as an argument, but it is a zero-dimensional vector. +See the [full example](@ref example-control-free) for solving and plotting. + +```@example ex-cf +using OptimalControl +λ_true = 0.5 +model_fn(t) = 2 * exp(λ_true * t) +noise_fn(t) = 2e-1 * sin(4π * t) +data_fn(t) = model_fn(t) + noise_fn(t) +t0 = 0.0; tf = 2.0; x0_cf = 2.0 +nothing # hide +``` + +```@raw html +
+
+``` + +**Abstract syntax** + +```@example ex-cf +ocp_macro = @def begin + +λ ∈ R, variable +t ∈ [t0, tf], time +x ∈ R, state + +x(t0) == x0_cf + +ẋ(t) == λ * x(t) + +∫( (x(t) - data_fn(t))^2 ) → min + +end +nothing # hide +``` + +```@raw html +
+
+``` + +**Functional API** + +```@example ex-cf +pre = OptimalControl.PreModel() + +# variable[1] = parameter λ (growth rate) +variable!(pre, 1, "λ") +time!(pre; t0=t0, tf=tf) +# scalar state x +state!(pre, 1, "x") +# no control! — control-free problem + +function f_cf!(dx, t, x, u, v) + # λ = v[1]; u is empty (control-free) + dx[1] = v[1] * x[1] + return nothing +end +dynamics!(pre, f_cf!) + +function boundary_cf!(b, x0_, xf_, v) + b[1] = x0_[1] - x0_cf + return nothing +end +constraint!(pre, + :boundary; + f=boundary_cf!, + lb=[0.0], ub=[0.0], + label=:ic +) + +lagrange_cf(t, x, u, v) = (x[1] - data_fn(t))^2 +objective!(pre, :min; lagrange=lagrange_cf) + +# autonomous=false: data_fn(t) depends on t +time_dependence!(pre; autonomous=false) + +ocp_func = build(pre) +nothing # hide +``` + +```@raw html +
+
+``` + +Both formulations produce identical solutions: + +```@example ex-cf +sol_macro = solve(ocp_macro; display=false) +sol_func = solve(ocp_func; display=false) + +println("Macro: objective = ", objective(sol_macro), ", iterations = ", iterations(sol_macro)) +println("Functional API: objective = ", objective(sol_func), ", iterations = ", iterations(sol_func)) +``` + +```@example ex-cf +plt = plot(sol_macro; label="Macro", color=1, size=(800, 200)) +plot!(plt, sol_func; label="Functional API", color=2, linestyle=:dash) +``` + +!!! note + `time_dependence!(pre; autonomous=false)` is required here because the Lagrange integrand `data_fn(t)` depends explicitly on time `t`. + +--- + +### [Problems mixing control and variable](@id manual-macro-free-control-and-variable) + +A variable parameter and an explicit control are used simultaneously. +See the [full example](@ref example-control-and-variable) for solving and plotting. + +```@example ex-cv +using OptimalControl +λ_true = 0.5 +model_fn2(t) = 2 * exp(λ_true * t) +noise_fn2(t) = 2e-1 * sin(4π * t) +data_fn2(t) = model_fn2(t) + noise_fn2(t) +t0 = 0.0; tf = 2.0; x0_cv = 2.0 +nothing # hide +``` + +```@raw html +
+
+``` + +**Abstract syntax** + +```@example ex-cv +ocp_macro = @def begin + +λ ∈ R, variable +t ∈ [t0, tf], time +x ∈ R, state +u ∈ R, control + +x(t0) == x0_cv + +ẋ(t) == λ * x(t) + u(t) + +∫( (x(t) - data_fn2(t))^2 + 0.5*u(t)^2 ) → min + +end +nothing # hide +``` + +```@raw html +
+
+``` + +**Functional API** + +```@example ex-cv +pre = OptimalControl.PreModel() + +# variable[1] = parameter λ (growth rate) +variable!(pre, 1, "λ") +time!(pre; t0=t0, tf=tf) +# scalar state x +state!(pre, 1, "x") +# scalar control u +control!(pre, 1) + +function f_cv!(dx, t, x, u, v) + # λ = v[1] + dx[1] = v[1] * x[1] + u[1] + return nothing +end +dynamics!(pre, f_cv!) + +function boundary_cv!(b, x0_, xf_, v) + b[1] = x0_[1] - x0_cv + return nothing +end +constraint!(pre, + :boundary; + f=boundary_cv!, + lb=[0.0], ub=[0.0], + label=:ic +) + +lagrange_cv(t, x, u, v) = + (x[1] - data_fn2(t))^2 + 0.5 * u[1]^2 +objective!(pre, :min; lagrange=lagrange_cv) + +# autonomous=false: data_fn2(t) depends on t +time_dependence!(pre; autonomous=false) + +ocp_func = build(pre) +nothing # hide +``` + +```@raw html +
+
+``` + +Both formulations produce identical solutions: + +```@example ex-cv +sol_macro = solve(ocp_macro; display=false) +sol_func = solve(ocp_func; display=false) + +println("Macro: objective = ", objective(sol_macro), ", iterations = ", iterations(sol_macro)) +println("Functional API: objective = ", objective(sol_func), ", iterations = ", iterations(sol_func)) +``` + +```@example ex-cv +plt = plot(sol_macro; label="Macro", color=1, size=(800, 400)) +plot!(plt, sol_func; label="Functional API", color=2, linestyle=:dash) +``` + +--- + +### [Singular control](@id manual-macro-free-singular) + +Three-dimensional state, free final time, state and control box constraints, Mayer cost. +See the [full example](@ref example-singular-control) for solving and plotting. + +```@example ex-singular +using OptimalControl +nothing # hide +``` + +```@raw html +
+
+``` + +**Abstract syntax** + +```@example ex-singular +ocp_macro = @def begin + +tf ∈ R, variable +t ∈ [0, tf], time +q = (x, y, θ) ∈ R³, state +u ∈ R, control + +-1 ≤ u(t) ≤ 1 +-π/2 ≤ θ(t) ≤ π/2 + +x(0) == 0 +y(0) == 0 +x(tf) == 1 +y(tf) == 0 + +∂(q)(t) == [cos(θ(t)), sin(θ(t)) + x(t), u(t)] + +tf → min + +end +nothing # hide +``` + +```@raw html +
+
+``` + +**Functional API** + +```@example ex-singular +pre = OptimalControl.PreModel() + +# variable[1] = final time tf +variable!(pre, 1, "tf") +# free final time: tf = variable[1] +time!(pre; t0=0.0, indf=1) +# state "q" with components "x", "y", "θ" +state!(pre, 3, "q", ["x", "y", "θ"]) +control!(pre, 1) + +function f_singular!(dq, t, q, u, v) + dq[1] = cos(q[3]) + dq[2] = sin(q[3]) + q[1] + dq[3] = u[1] + return nothing +end +dynamics!(pre, f_singular!) + +# control box constraint: -1 ≤ u ≤ 1 +constraint!(pre, + :control; + rg=1:1, lb=[-1.0], ub=[1.0], + label=:u_bounds +) +# state box constraint on θ = q[3]: -π/2 ≤ θ ≤ π/2 +constraint!(pre, + :state; + rg=3:3, lb=[-π/2], ub=[π/2], + label=:theta_bounds +) + +function boundary_singular!(b, q0, qf, v) + b[1] = q0[1] # x(0) = 0 + b[2] = q0[2] # y(0) = 0 + b[3] = qf[1] - 1.0 # x(tf) = 1 + b[4] = qf[2] # y(tf) = 0 + return nothing +end +constraint!(pre, + :boundary; + f=boundary_singular!, + lb=zeros(4), ub=zeros(4), + label=:endpoint +) + +# Mayer cost: minimise tf = variable[1] +mayer_singular(q0, qf, v) = v[1] +objective!(pre, :min; mayer=mayer_singular) + +time_dependence!(pre; autonomous=true) + +ocp_func = build(pre) +nothing # hide +``` + +```@raw html +
+
+``` + +Both formulations produce identical solutions: + +```@example ex-singular +sol_macro = solve(ocp_macro; display=false) +sol_func = solve(ocp_func; display=false) + +println("Macro: objective = ", objective(sol_macro), ", iterations = ", iterations(sol_macro)) +println("Functional API: objective = ", objective(sol_func), ", iterations = ", iterations(sol_func)) +``` + +```@example ex-singular +plt = plot(sol_macro; label="Macro", color=1, size=(800, 800)) +plot!(plt, sol_func; label="Functional API", color=2, linestyle=:dash) +``` + +--- + +### [State constraint](@id manual-macro-free-state-constraint) + +Same double integrator as the energy minimisation example, with an added upper bound on velocity. +See the [full example](@ref example-state-constraint) for solving and plotting. + +```@example ex-state +using OptimalControl +t0 = 0.0; tf = 1.0; x0 = [-1.0, 0.0]; xf = [0.0, 0.0] +nothing # hide +``` + +```@raw html +
+
+``` + +**Abstract syntax** + +```@example ex-state +ocp_macro = @def begin + +t ∈ [t0, tf], time +x = (q, v) ∈ R², state +u ∈ R, control + +x(t0) == x0 +x(tf) == xf + +v(t) ≤ 1.2 + +ẋ(t) == [v(t), u(t)] + +0.5∫( u(t)^2 ) → min + +end +nothing # hide +``` + +```@raw html +
+
+``` + +**Functional API** + +```@example ex-state +pre = OptimalControl.PreModel() + +time!(pre; t0=t0, tf=tf) +# state "x" with components "q" (position) and "v" (velocity) +state!(pre, 2, "x", ["q", "v"]) +control!(pre, 1) + +function f_state!(dx, t, x, u, v) + dx[1] = x[2] + dx[2] = u[1] + return nothing +end +dynamics!(pre, f_state!) + +function boundary_state!(b, x0_, xf_, v) + b[1] = x0_[1] - x0[1] + b[2] = x0_[2] - x0[2] + b[3] = xf_[1] - xf[1] + b[4] = xf_[2] - xf[2] + return nothing +end +constraint!(pre, + :boundary; + f=boundary_state!, + lb=zeros(4), ub=zeros(4), + label=:endpoint +) + +# state box constraint: v(t) ≤ 1.2, i.e. x[2] ≤ 1.2 +constraint!(pre, + :state; + rg=2:2, lb=[-Inf], ub=[1.2], + label=:v_max +) + +lagrange_state(t, x, u, v) = 0.5 * u[1]^2 +objective!(pre, :min; lagrange=lagrange_state) + +time_dependence!(pre; autonomous=true) + +ocp_func = build(pre) +nothing # hide +``` + +```@raw html +
+
+``` + +Both formulations produce identical solutions: + +```@example ex-state +sol_macro = solve(ocp_macro; display=false) +sol_func = solve(ocp_func; display=false) + +println("Macro: objective = ", objective(sol_macro), ", iterations = ", iterations(sol_macro)) +println("Functional API: objective = ", objective(sol_func), ", iterations = ", iterations(sol_func)) +``` + +```@example ex-state +plt = plot(sol_macro; label="Macro", color=1, size=(800, 600)) +plot!(plt, sol_func; label="Functional API", color=2, linestyle=:dash) +``` + +!!! note + The state box constraint `v(t) ≤ 1.2` is expressed as `constraint!(pre, :state; rg=2:2, lb=[-Inf], ub=[1.2], ...)`, where `rg=2:2` selects the second state component `v`. + +--- + +## API Reference + +```@docs; canonical=false +CTModels.PreModel +``` + +```@docs; canonical=false +CTModels.time! +``` + +```@docs; canonical=false +CTModels.state! +``` + +```@docs; canonical=false +CTModels.control! +``` + +```@docs; canonical=false +CTModels.variable! +``` + +```@docs; canonical=false +CTModels.dynamics! +``` + +```@docs; canonical=false +CTModels.objective! +``` + +```@docs; canonical=false +CTModels.constraint! +``` + +```@docs; canonical=false +CTModels.time_dependence! +``` + +```@docs; canonical=false +CTModels.build +``` + +```@docs; canonical=false +CTModels.Model +``` diff --git a/src/imports/ctmodels.jl b/src/imports/ctmodels.jl index 9e2aab40a..a13197188 100644 --- a/src/imports/ctmodels.jl +++ b/src/imports/ctmodels.jl @@ -20,6 +20,7 @@ import CTModels: AbstractInitialGuess, InitialGuess import CTModels: # api types + PreModel, Model, AbstractModel, Solution, @@ -120,3 +121,15 @@ import CTModels: control_constraints_ub_dual, variable_constraints_lb_dual, variable_constraints_ub_dual + +# OCP Builder functions (functional API) +@reexport import CTModels: + time!, + state!, + control!, + variable!, + dynamics!, + objective!, + constraint!, + time_dependence!, + build diff --git a/test/suite/reexport/test_ctmodels.jl b/test/suite/reexport/test_ctmodels.jl index 63eb2b81b..ee9bb236b 100644 --- a/test/suite/reexport/test_ctmodels.jl +++ b/test/suite/reexport/test_ctmodels.jl @@ -64,6 +64,7 @@ function test_ctmodels() Test.@testset "API Types" begin for T in ( + OptimalControl.PreModel, OptimalControl.Model, OptimalControl.AbstractModel, OptimalControl.AbstractModel, @@ -79,6 +80,26 @@ function test_ctmodels() end end + Test.@testset "Builder Functions" begin + for f in ( + :time!, + :state!, + :control!, + :variable!, + :dynamics!, + :objective!, + :constraint!, + :time_dependence!, + :build, + ) + Test.@testset "$f" begin + Test.@test isdefined(OptimalControl, f) + Test.@test isdefined(CurrentModule, f) + Test.@test getfield(OptimalControl, f) isa Function + end + end + end + Test.@testset "Accessors" begin for f in ( :constraint, @@ -204,6 +225,7 @@ function test_ctmodels() Test.@test OptimalControl.Model <: OptimalControl.AbstractModel Test.@test OptimalControl.Solution <: OptimalControl.AbstractSolution Test.@test OptimalControl.InitialGuess <: OptimalControl.AbstractInitialGuess + Test.@test OptimalControl.PreModel isa DataType end Test.@testset "Method Signatures" begin From d0e17953e458df3e189edd773e7c6be1f20fe8bc Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Tue, 21 Apr 2026 17:13:11 +0200 Subject: [PATCH 2/4] foo --- docs/make.jl | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/make.jl b/docs/make.jl index aed859c6b..b58af1259 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -193,6 +193,9 @@ with_api_reference(src_dir, ext_dir) do api_pages format=Documenter.HTML(; repolink="https://" * repo_url, prettyurls=false, + example_size_threshold=1_000_000, + size_threshold_warn=1_000_000, + size_threshold=1_000_000, assets=[ asset("https://control-toolbox.org/assets/css/documentation.css"), asset("https://control-toolbox.org/assets/js/documentation.js"), From 19b7944303759b31c442a52c90e5dd8fe6caa6c3 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Tue, 21 Apr 2026 20:10:45 +0200 Subject: [PATCH 3/4] fix doc --- docs/make.jl | 6 +++--- docs/src/api/public.md | 3 --- docs/src/manual-initial-guess.md | 2 +- docs/src/manual-model.md | 4 ++-- docs/src/manual-plot.md | 2 +- docs/src/manual-solve-gpu.md | 2 +- docs/src/manual-solve.md | 2 +- 7 files changed, 9 insertions(+), 12 deletions(-) diff --git a/docs/make.jl b/docs/make.jl index b58af1259..1e20ddff9 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -193,9 +193,9 @@ with_api_reference(src_dir, ext_dir) do api_pages format=Documenter.HTML(; repolink="https://" * repo_url, prettyurls=false, - example_size_threshold=1_000_000, - size_threshold_warn=1_000_000, - size_threshold=1_000_000, + example_size_threshold=2_000_000, + size_threshold_warn=2_000_000, + size_threshold=2_000_000, assets=[ asset("https://control-toolbox.org/assets/css/documentation.css"), asset("https://control-toolbox.org/assets/js/documentation.js"), diff --git a/docs/src/api/public.md b/docs/src/api/public.md index dcbdc4166..aee986e46 100644 --- a/docs/src/api/public.md +++ b/docs/src/api/public.md @@ -41,7 +41,6 @@ boundary_constraints_dual boundary_constraints_nl bypass components -constraint constraints constraints_violation control @@ -116,7 +115,6 @@ methods model name nlp_model -objective ocp_model ocp_solution option_default @@ -149,7 +147,6 @@ time time_grid time_name times -variable variable_components variable_constraints_box variable_constraints_lb_dual diff --git a/docs/src/manual-initial-guess.md b/docs/src/manual-initial-guess.md index b176d35a8..eb7a7c68a 100644 --- a/docs/src/manual-initial-guess.md +++ b/docs/src/manual-initial-guess.md @@ -565,7 +565,7 @@ nothing # hide !!! tip "Interactions with an optimal control solution" - Please check [`state`](@ref), [`costate`](@ref), [`control`](@ref) and [`variable`](@ref variable(::Solution)) to get data from the solution. The functions `state`, `costate` and `control` return functions of time and `variable` returns a vector. + Please check [`state`](@ref), [`costate`](@ref), [`control`](@ref) and `variable` to get data from the solution. The functions `state`, `costate` and `control` return functions of time and `variable` returns a vector. ## Costate / multipliers diff --git a/docs/src/manual-model.md b/docs/src/manual-model.md index 633e06ccf..72cf08b2a 100644 --- a/docs/src/manual-model.md +++ b/docs/src/manual-model.md @@ -101,9 +101,9 @@ Each field can be accessed directly (`ocp.times`, etc) or by a getter: * [`times`](@ref) * [`state`](@ref) * [`control`](@ref) -* [`variable`](@ref) +* `variable` * [`dynamics`](@ref) -* [`objective`](@ref) +* `objective` * [`constraints`](@ref) * [`definition`](@ref) diff --git a/docs/src/manual-plot.md b/docs/src/manual-plot.md index 796ddc9d4..1eaedc004 100644 --- a/docs/src/manual-plot.md +++ b/docs/src/manual-plot.md @@ -208,7 +208,7 @@ The previous solution of the optimal control problem was obtained using the [`so !!! tip "Interactions with an optimal control solution" - Please check [`state`](@ref), [`costate`](@ref), [`control`](@ref), and [`variable`](@ref variable(::Solution)) to retrieve data from the solution. The functions `state`, `costate`, and `control` return functions of time, while `variable` returns a vector. + Please check [`state`](@ref), [`costate`](@ref), [`control`](@ref), and `variable` to retrieve data from the solution. The functions `state`, `costate`, and `control` return functions of time, while `variable` returns a vector. ```@example main using OrdinaryDiffEq diff --git a/docs/src/manual-solve-gpu.md b/docs/src/manual-solve-gpu.md index 093656be7..a4b1687c4 100644 --- a/docs/src/manual-solve-gpu.md +++ b/docs/src/manual-solve-gpu.md @@ -22,7 +22,7 @@ using CUDA !!! note "Solver requirements" - For complete solver requirements including CPU solvers, see [Solver requirements](@ref manual-solve#solver-requirements) in the main solving manual. + For complete solver requirements including CPU solvers, see [Solver requirements](@ref manual-solve-solver-requirements) in the main solving manual. !!! warning "CUDA required" diff --git a/docs/src/manual-solve.md b/docs/src/manual-solve.md index faf09895c..d6cde900e 100644 --- a/docs/src/manual-solve.md +++ b/docs/src/manual-solve.md @@ -173,7 +173,7 @@ solve(ocp, :collocation, :ipopt) # specify discretizer + solver solve(ocp, :collocation, :adnlp, :ipopt, :cpu) # complete description ``` -## Solver requirements +## [Solver requirements](@id manual-solve-solver-requirements) Each solver requires its package to be loaded to provide the solver implementation: From 9c72060dbee27103aeb6bff79b3c6f5d2d4b92d4 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Tue, 21 Apr 2026 20:23:50 +0200 Subject: [PATCH 4/4] Fix documentation build warnings and HTML size error - Double HTML size thresholds to 2 MB (manual-plot.md was 1.32 MiB) - Qualify constraint/objective/variable/discretize in public.md @docs block - Use qualified @ref for variable/objective in manual files - Add @id anchor to Solver requirements section in manual-solve.md - Fix @ref to use new anchor in manual-solve-gpu.md - Restrict methods() entry to avoid Base.methods docstring leak Resolves CI-blocking HTMLSizeThresholdError and eliminates local undefined binding warnings. Remaining @ref warnings require fixes in upstream packages (CTModels, CTSolvers, CTFlows). --- docs/src/api/public.md | 7 +++++-- docs/src/manual-initial-guess.md | 2 +- docs/src/manual-model.md | 4 ++-- docs/src/manual-plot.md | 2 +- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/src/api/public.md b/docs/src/api/public.md index aee986e46..4681c5161 100644 --- a/docs/src/api/public.md +++ b/docs/src/api/public.md @@ -41,6 +41,7 @@ boundary_constraints_dual boundary_constraints_nl bypass components +CTModels.OCP.constraint constraints constraints_violation control @@ -65,7 +66,7 @@ dim_path_constraints_nl dim_state_constraints_box dim_variable_constraints_box dimension -discretize +CTDirect.discretize dual dynamics export_ocp_solution @@ -111,10 +112,11 @@ lagrange mayer message metadata -methods +methods() model name nlp_model +CTModels.OCP.objective ocp_model ocp_solution option_default @@ -147,6 +149,7 @@ time time_grid time_name times +CTModels.OCP.variable variable_components variable_constraints_box variable_constraints_lb_dual diff --git a/docs/src/manual-initial-guess.md b/docs/src/manual-initial-guess.md index eb7a7c68a..3a1925b0b 100644 --- a/docs/src/manual-initial-guess.md +++ b/docs/src/manual-initial-guess.md @@ -565,7 +565,7 @@ nothing # hide !!! tip "Interactions with an optimal control solution" - Please check [`state`](@ref), [`costate`](@ref), [`control`](@ref) and `variable` to get data from the solution. The functions `state`, `costate` and `control` return functions of time and `variable` returns a vector. + Please check [`state`](@ref), [`costate`](@ref), [`control`](@ref) and [`variable`](@ref CTModels.OCP.variable) to get data from the solution. The functions `state`, `costate` and `control` return functions of time and `variable` returns a vector. ## Costate / multipliers diff --git a/docs/src/manual-model.md b/docs/src/manual-model.md index 72cf08b2a..9afae3a51 100644 --- a/docs/src/manual-model.md +++ b/docs/src/manual-model.md @@ -101,9 +101,9 @@ Each field can be accessed directly (`ocp.times`, etc) or by a getter: * [`times`](@ref) * [`state`](@ref) * [`control`](@ref) -* `variable` +* [`variable`](@ref CTModels.OCP.variable) * [`dynamics`](@ref) -* `objective` +* [`objective`](@ref CTModels.OCP.objective) * [`constraints`](@ref) * [`definition`](@ref) diff --git a/docs/src/manual-plot.md b/docs/src/manual-plot.md index 1eaedc004..988e8957f 100644 --- a/docs/src/manual-plot.md +++ b/docs/src/manual-plot.md @@ -208,7 +208,7 @@ The previous solution of the optimal control problem was obtained using the [`so !!! tip "Interactions with an optimal control solution" - Please check [`state`](@ref), [`costate`](@ref), [`control`](@ref), and `variable` to retrieve data from the solution. The functions `state`, `costate`, and `control` return functions of time, while `variable` returns a vector. + Please check [`state`](@ref), [`costate`](@ref), [`control`](@ref), and [`variable`](@ref CTModels.OCP.variable) to retrieve data from the solution. The functions `state`, `costate`, and `control` return functions of time, while `variable` returns a vector. ```@example main using OrdinaryDiffEq