diff --git a/docs/make.jl b/docs/make.jl
index b26d47cb..1e20ddff 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=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"),
@@ -213,7 +216,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/api/public.md b/docs/src/api/public.md
index dcbdc416..4681c516 100644
--- a/docs/src/api/public.md
+++ b/docs/src/api/public.md
@@ -41,7 +41,7 @@ boundary_constraints_dual
boundary_constraints_nl
bypass
components
-constraint
+CTModels.OCP.constraint
constraints
constraints_violation
control
@@ -66,7 +66,7 @@ dim_path_constraints_nl
dim_state_constraints_box
dim_variable_constraints_box
dimension
-discretize
+CTDirect.discretize
dual
dynamics
export_ocp_solution
@@ -112,11 +112,11 @@ lagrange
mayer
message
metadata
-methods
+methods()
model
name
nlp_model
-objective
+CTModels.OCP.objective
ocp_model
ocp_solution
option_default
@@ -149,7 +149,7 @@ time
time_grid
time_name
times
-variable
+CTModels.OCP.variable
variable_components
variable_constraints_box
variable_constraints_lb_dual
diff --git a/docs/src/assets/custom.css b/docs/src/assets/custom.css
index 4bc1dbcf..1f64f008 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 4c26ec6b..2900080a 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-initial-guess.md b/docs/src/manual-initial-guess.md
index b176d35a..3a1925b0 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`](@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-macro-free.md b/docs/src/manual-macro-free.md
new file mode 100644
index 00000000..935363df
--- /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/docs/src/manual-model.md b/docs/src/manual-model.md
index 633e06cc..9afae3a5 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`](@ref CTModels.OCP.variable)
* [`dynamics`](@ref)
-* [`objective`](@ref)
+* [`objective`](@ref CTModels.OCP.objective)
* [`constraints`](@ref)
* [`definition`](@ref)
diff --git a/docs/src/manual-plot.md b/docs/src/manual-plot.md
index 796ddc9d..988e8957 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`](@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
diff --git a/docs/src/manual-solve-gpu.md b/docs/src/manual-solve-gpu.md
index 093656be..a4b1687c 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 faf09895..d6cde900 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:
diff --git a/src/imports/ctmodels.jl b/src/imports/ctmodels.jl
index 9e2aab40..a1319718 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 63eb2b81..ee9bb236 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