diff --git a/CHANGELOG.md b/CHANGELOG.md index 031af27..37b658c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### πŸ› Bug Fixes +- **A constraint bound that depends on `v`, the state, the control or the time is now rejected with a clear `ParsingError`** ([#343](https://github.com/control-toolbox/CTParser.jl/issues/343)). Previously `xβ‚‚(0) == v` (with `v` the optimization variable) failed with an internal `UndefVarError: v##NNNN` leaked from generated code. Such a relation must be written as a functional constraint by moving the term to the constrained side, e.g. `xβ‚‚(0) - v == 0`. - **Trace mode (`@def name … end true`) no longer prints the parsed model twice** ([#344](https://github.com/control-toolbox/CTParser.jl/issues/344)). When the `:exa` backend is active, `def_fun` parses the definition a second time to build the ExaModels artifact; that second pass was inheriting the `log` flag and re-emitting the whole trace. It now runs with `log=false`. ## [0.9.4-beta] - 2026-08-30 diff --git a/src/onepass.jl b/src/onepass.jl index 62f3801..d0d7bf0 100644 --- a/src/onepass.jl +++ b/src/onepass.jl @@ -641,6 +641,26 @@ function p_constraint!( log && println("constraint ($c_type): $e1 ≀ $e2 ≀ $e3, ($label)") label isa Int && (label = Symbol(:eq, label)) label isa Symbol || return __throw("forbidden label: $label", p.lnum, p.line) + # a constraint bound must be "effective": lb/ub are evaluated once at build time, + # so they cannot reference v/x/u/t (doing so leaks an internal gensym β€” see #343) + for (b, side) in ((e1, "lower"), (e3, "upper")) + isnothing(b) && continue + for (sym, what) in ( + (p.v, "the variable"), + (p.x, "the state"), + (p.u, "the control"), + (p.t, "the time"), + ) + isnothing(sym) && continue + has(b, sym) && return __throw( + "the $side bound of a constraint must not depend on $what; " * + "write a functional constraint instead by moving the term to the " * + "constrained side (e.g. `xβ‚‚(0) - v == 0` rather than `xβ‚‚(0) == v`)", + p.lnum, + p.line, + ) + end + end xut = __symgen(:xut) ee2 = replace_call(e2, [p.x, p.u], p.t, [xut, xut]) has(ee2, p.t) && (p.is_autonomous = false) diff --git a/test/test_onepass_exa.jl b/test/test_onepass_exa.jl index 60c86a9..d86f06b 100644 --- a/test/test_onepass_exa.jl +++ b/test/test_onepass_exa.jl @@ -1081,6 +1081,21 @@ function __test_onepass_exa( end @test_throws ParsingError o(; backend=backend) + # a constraint bound must not depend on the variable (#343) + o = @def_exa begin + v ∈ R, variable + t ∈ [0, 1], time + x ∈ R⁴, state + u ∈ R⁡, control + xβ‚‚(0) == v + βˆ‚(x₁)(t) == x₁(t) + βˆ‚(xβ‚‚)(t) == x₁(t) + βˆ‚(x₃)(t) == x₁(t) + βˆ‚(xβ‚„)(t) == x₁(t) + x₁(0) + 2cos(xβ‚‚(1)) β†’ min + end + @test_throws ParsingError o(; backend=backend) + o = @def_exa begin t ∈ [0, 1], time x ∈ R⁴, state diff --git a/test/test_onepass_fun.jl b/test/test_onepass_fun.jl index 5f2519e..5e67a86 100644 --- a/test/test_onepass_fun.jl +++ b/test/test_onepass_fun.jl @@ -2833,6 +2833,37 @@ function test_onepass_fun() αΊ‹(t) == A * x(t) + B * u(t) ∫(u(t)^2) / 2 β†’ min # forbidden end + + # a constraint bound must be effective: it must not depend on the variable (#343) + @test_throws ParsingError @def o begin + v ∈ R, variable + t ∈ [0, 1], time + x ∈ RΒ², state + u ∈ R, control + xβ‚‚(0) == v + αΊ‹(t) == [xβ‚‚(t), u(t)] + end + + # ... nor on the state (bound side) + @test_throws ParsingError @def o begin + t ∈ [0, 1], time + x ∈ RΒ², state + u ∈ R, control + x₁(0) ≀ xβ‚‚(0) + αΊ‹(t) == [xβ‚‚(t), u(t)] + end + + # the documented work-around still builds fine + o = @def begin + v ∈ R, variable + t ∈ [0, 1], time + x ∈ RΒ², state + u ∈ R, control + xβ‚‚(0) - v == 0 + αΊ‹(t) == [xβ‚‚(t), u(t)] + ∫(0.5u(t)^2) β†’ min + end + @test o isa Model end # ---------------------------------------------------------------