diff --git a/BREAKING.md b/BREAKING.md index 11007602..af4b6488 100644 --- a/BREAKING.md +++ b/BREAKING.md @@ -6,9 +6,35 @@ This document describes breaking changes in CTModels releases and how to migrate ## [0.9.15] - 2026-04-18 -### No Breaking Changes +### Breaking Changes: Dual Dimension Function Renaming + +The following functions have been renamed to clarify that they return dual dimension, not constraint dimension: + +- `dim_variable_constraints_box(sol)` β†’ `dim_dual_variable_constraints_box(sol)` +- `dim_state_constraints_box(sol)` β†’ `dim_dual_state_constraints_box(sol)` +- `dim_control_constraints_box(sol)` β†’ `dim_dual_control_constraints_box(sol)` + +#### Migration Guide + +```julia +# Before (old function names) +dim = dim_state_constraints_box(sol) + +# After (new function names) +dim = dim_dual_state_constraints_box(sol) +``` + +#### Rationale + +The old function names were misleading because they returned the dimension of dual multipliers, not the dimension of constraints declared in the model. The new names clarify this distinction. + +#### Note + +The functions `dim_*_constraints_box(ocp::Model)` (for Model, not Solution) remain unchanged and still refer to constraint dimension in the model. + +### Non-Breaking Changes -This release introduces consistent variable and control checking functions without breaking existing functionality: +This release also introduces consistent variable and control checking functions without breaking existing functionality: #### New Functions (Non-Breaking) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79865bce..d1df0ca6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### πŸš€ Enhancements +#### Box Constraint Aliases for Label Resolution + +- **Unified source of truth**: Removed `original_dict` from `ConstraintsModel` and `original_constraints()` accessor +- **Aliases field**: Added `aliases::Vector{Vector{Symbol}}` as 5th element in box constraint tuples +- **Label resolution**: `constraint(model, label)` and `dual(sol, model, label)` now resolve labels via aliases +- **Effective bounds**: Returns intersected bounds when multiple constraints declared on same component +- **Per-component duals**: Dual matrices sized by state/control/variable dimension, not by number of declarations +- **Deduplication warning**: Single warning per component when duplicate bounds are declared + +#### Dual Dimension Function Clarification + +- **Renamed functions**: `dim_*_constraints_box(sol)` β†’ `dim_dual_*_constraints_box(sol)` for clarity +- **Multiple dispatch**: `_dual_dimension` uses dispatch on `Nothing` (β†’ 0) and `Function` (β†’ length at t0) +- **Display improvement**: Dual variables only displayed if model has declared constraints +- **New exports**: `dim_dual_state_constraints_box`, `dim_dual_control_constraints_box`, `dim_dual_variable_constraints_box` + #### Consistent Variable and Control Checking Functions - **New functions**: Added `is_variable()` and `is_control_free()` for checking problem properties diff --git a/src/OCP/Building/dual_model.jl b/src/OCP/Building/dual_model.jl index bdcaf777..10786464 100644 --- a/src/OCP/Building/dual_model.jl +++ b/src/OCP/Building/dual_model.jl @@ -19,6 +19,20 @@ defined in the model and returns the corresponding dual value from the solution. # Returns A function of time `t` for time-dependent constraints, or a scalar/vector for time-invariant duals. If the label is not found, throws an `IncorrectArgument` exception. + +# Notes +- For path/boundary constraints, duals are indexed per declaration (one column per + row of the stacked nonlinear constraint vector). +- For box constraints (state/control/variable), the dual matrices/vectors stored + in the `Solution` are indexed **by primal component** (i.e. + `state_dimension(model)` columns for state, etc.), following the CTDirect + convention. For a label targeting component indices `rg`, this function + returns `duals_lb[:, rg] - duals_ub[:, rg]` (or the time-independent analogue + for variables). Components never constrained carry a zero multiplier. +- If several labels target the same component, `dual(sol, model, :label)` returns + the (same) per-component multiplier for each: CTModels does not track which + declaration "owns" the multiplier, because the solver only sees the effective + (intersected) bound. """ function dual(sol::Solution, model::Model, label::Symbol) @@ -52,51 +66,59 @@ function dual(sol::Solution, model::Model, label::Symbol) end end - # check if the label is in the state constraints + # Box constraints: resolve `label` via the `aliases` field (cp[5]) of each + # box tuple. `cp[2][k]` gives the primal component index for row `k`, which + # is used to slice the per-component dual matrix/vector. + function _box_idxs(cp) + aliases = cp[5] + out = Int[] + for k in eachindex(aliases) + if label in aliases[k] + push!(out, cp[2][k]) + end + end + return out + end + + # state box cp = state_constraints_box(model) - labels = cp[4] # vector of labels - if label in labels - # get all the indices of the label - indices = findall(x -> x == label, labels) - # get the corresponding dual values + idxs = _box_idxs(cp) + if !isempty(idxs) duals_lb = state_constraints_lb_dual(sol) duals_ub = state_constraints_ub_dual(sol) - if length(indices) == 1 - return t -> (duals_lb(t)[indices[1]] - duals_ub(t)[indices[1]]) + if length(idxs) == 1 + i = idxs[1] + return t -> (duals_lb(t)[i] - duals_ub(t)[i]) else - return t -> (duals_lb(t)[indices] - duals_ub(t)[indices]) + return t -> (duals_lb(t)[idxs] - duals_ub(t)[idxs]) end end - # check if the label is in the control constraints + # control box cp = control_constraints_box(model) - labels = cp[4] # vector of labels - if label in labels - # get all the indices of the label - indices = findall(x -> x == label, labels) - # get the corresponding dual values, either lower or upper bound + idxs = _box_idxs(cp) + if !isempty(idxs) duals_lb = control_constraints_lb_dual(sol) duals_ub = control_constraints_ub_dual(sol) - if length(indices) == 1 - return t -> (duals_lb(t)[indices[1]] - duals_ub(t)[indices[1]]) + if length(idxs) == 1 + i = idxs[1] + return t -> (duals_lb(t)[i] - duals_ub(t)[i]) else - return t -> (duals_lb(t)[indices] - duals_ub(t)[indices]) + return t -> (duals_lb(t)[idxs] - duals_ub(t)[idxs]) end end - # check if the label is in the variable constraints + # variable box cp = variable_constraints_box(model) - labels = cp[4] # vector of labels - if label in labels - # get all the indices of the label - indices = findall(x -> x == label, labels) - # get the corresponding dual values, either lower or upper bound + idxs = _box_idxs(cp) + if !isempty(idxs) duals_lb = variable_constraints_lb_dual(sol) duals_ub = variable_constraints_ub_dual(sol) - if length(indices) == 1 - return duals_lb[indices[1]] - duals_ub[indices[1]] + if length(idxs) == 1 + i = idxs[1] + return duals_lb[i] - duals_ub[i] else - return duals_lb[indices] - duals_ub[indices] + return duals_lb[idxs] - duals_ub[idxs] end end diff --git a/src/OCP/Building/interpolation_helpers.jl b/src/OCP/Building/interpolation_helpers.jl index f7b854d7..fd2f9751 100644 --- a/src/OCP/Building/interpolation_helpers.jl +++ b/src/OCP/Building/interpolation_helpers.jl @@ -67,11 +67,11 @@ function _interpolate_from_data( # Dimension validation if expected_dim provided if !isnothing(expected_dim) && !isnothing(dim) actual_dim = size(data, 2) - @ensure actual_dim >= dim Exceptions.IncorrectArgument( + @ensure actual_dim == dim Exceptions.IncorrectArgument( "Matrix dimension mismatch", got="$actual_dim columns", - expected="at least $dim columns", - suggestion="Provide a matrix with at least $dim columns or adjust expected_dim parameter", + expected="exactly $dim columns", + suggestion="Provide a matrix with exactly $dim columns (pad with zeros for unconstrained components if dual).", context="_interpolate_from_data - validating matrix dimensions", ) end diff --git a/src/OCP/Building/model.jl b/src/OCP/Building/model.jl index 9631deec..55180426 100644 --- a/src/OCP/Building/model.jl +++ b/src/OCP/Building/model.jl @@ -1,39 +1,148 @@ """ $(TYPEDSIGNATURES) -Appends box constraint data to the provided vectors. +Append box constraint data to the provided flat vectors. + +This is an internal helper used by `build(::ConstraintsDictType)`. It simply +accumulates declarations. Deduplication (one entry per component with +intersection semantics) and associated warnings are handled later by +`_dedup_box_constraints!`. # Arguments -- `inds::Vector{Int}`: Vector of indices to which the range `rg` will be appended. -- `lbs::Vector{<:Real}`: Vector of lower bounds to which `lb` will be appended. -- `ubs::Vector{<:Real}`: Vector of upper bounds to which `ub` will be appended. -- `labels::Vector{String}`: Vector of labels to which the `label` will be repeated and appended. -- `rg::AbstractVector{Int}`: Index range corresponding to the constraint variables. +- `inds::Vector{Int}`: Vector of component indices to append to. +- `lbs::Vector{<:Real}`: Vector of lower bounds to append to. +- `ubs::Vector{<:Real}`: Vector of upper bounds to append to. +- `labels::Vector{Symbol}`: Vector of labels (one entry per declared component). +- `rg::AbstractVector{Int}`: Component indices declared by the new constraint. - `lb::AbstractVector{<:Real}`: Lower bounds associated with `rg`. - `ub::AbstractVector{<:Real}`: Upper bounds associated with `rg`. -- `label::String`: Label describing the constraint block (e.g., "state", "control"). +- `label::Symbol`: Label describing the declaration. # Notes -- All input vectors (`rg`, `lb`, `ub`) must have the same length. -- The function modifies the `inds`, `lbs`, `ubs`, and `labels` vectors in-place. -- If a component index already exists in `inds`, a warning is emitted indicating that the - previous bound will be overwritten by the new constraint. The dual variable dimension - remains equal to the state/control/variable dimension, not the number of constraint declarations. +- Modifies `inds`, `lbs`, `ubs`, `labels` in-place. +- No deduplication or warning emitted here; see `_dedup_box_constraints!`. """ function append_box_constraints!(inds, lbs, ubs, labels, rg, lb, ub, label) - # Check for duplicate indices and emit warning - for idx in rg - if idx in inds - @warn "Overwriting bound for component $idx (label: $label). Previous value will be discarded. " * - "Note: dual variable dimension equals the state/control/variable dimension, not the number of constraints." - end - end append!(inds, rg) append!(lbs, lb) append!(ubs, ub) for _ in 1:length(lb) push!(labels, label) end + return nothing +end + +""" +$(TYPEDSIGNATURES) + +Deduplicate box-constraint declarations by component, applying the intersection +of all declared bounds for each repeated component. Produces an `aliases` vector +recording every label that targeted each component. + +After this function returns, the vectors satisfy the invariant: + +- `allunique(inds)` β€” each component appears at most once. +- `lbs[k]` = `max` of all declared lower bounds for component `inds[k]`. +- `ubs[k]` = `min` of all declared upper bounds for component `inds[k]`. +- `labels[k]` = the first label that declared component `inds[k]` (stable order). +- `aliases[k]` = all distinct labels that declared component `inds[k]`, in + first-seen order (always starts with `labels[k]`). +- Vectors are sorted by `inds`. + +A `@warn` is emitted once for each duplicated component, listing all contributing +labels. If the intersection is empty (i.e. `max(lbs_k) > min(ubs_k)`), an +`IncorrectArgument` is thrown. + +# Arguments +- `inds`, `lbs`, `ubs`, `labels`: in-place flat vectors produced by successive + calls to [`append_box_constraints!`](@ref). +- `aliases`: in-place empty `Vector{Vector{Symbol}}` to be populated with the + per-component list of all declaring labels. +- `kind::String`: human-readable descriptor (e.g. "state", "control", + "variable") used in diagnostic messages. + +# Throws +- `Exceptions.IncorrectArgument` if the intersection of declared bounds is + empty for some component. +""" +function _dedup_box_constraints!( + inds::Vector{Int}, + lbs::Vector{T}, + ubs::Vector{T}, + labels::Vector{Symbol}, + aliases::Vector{Vector{Symbol}}, + kind::String, +) where {T<:Real} + if isempty(inds) + empty!(aliases) + return nothing + end + + # group declaration positions by component index, preserving first-seen order + unique_order = Int[] + positions = Dict{Int,Vector{Int}}() + @inbounds for (k, i) in pairs(inds) + if haskey(positions, i) + push!(positions[i], k) + else + positions[i] = [k] + push!(unique_order, i) + end + end + + # build deduped vectors; emit warning for each duplicated component + new_inds = Int[] + new_lbs = T[] + new_ubs = T[] + new_labels = Symbol[] + new_aliases = Vector{Symbol}[] + for i in unique_order + ks = positions[i] + # distinct labels for component i, in first-seen order + dup_labels = Symbol[] + for k in ks + l = labels[k] + if l βˆ‰ dup_labels + push!(dup_labels, l) + end + end + if length(ks) == 1 + k = ks[1] + push!(new_inds, i) + push!(new_lbs, lbs[k]) + push!(new_ubs, ubs[k]) + push!(new_labels, labels[k]) + push!(new_aliases, dup_labels) + else + lb_eff = maximum(lbs[ks]) + ub_eff = minimum(ubs[ks]) + @warn "Multiple bound declarations for $kind component $i " * + "(labels: $(join(dup_labels, ", "))). " * + "Intersection applied: effective lb = $lb_eff, effective ub = $ub_eff." + @ensure lb_eff <= ub_eff Exceptions.IncorrectArgument( + "Empty feasible set for $kind component $i"; + got="max(lbs)=$lb_eff > min(ubs)=$ub_eff", + expected="max(lbs) ≀ min(ubs)", + suggestion="Check the declared bounds for labels $(join(dup_labels, ", ")).", + context="_dedup_box_constraints! - infeasibility check", + ) + push!(new_inds, i) + push!(new_lbs, lb_eff) + push!(new_ubs, ub_eff) + push!(new_labels, dup_labels[1]) + push!(new_aliases, dup_labels) + end + end + + # sort by component index for readability + perm = sortperm(new_inds) + resize!(inds, length(new_inds)); inds .= new_inds[perm] + resize!(lbs, length(new_lbs)); lbs .= new_lbs[perm] + resize!(ubs, length(new_ubs)); ubs .= new_ubs[perm] + resize!(labels, length(new_labels)); labels .= new_labels[perm] + empty!(aliases) + append!(aliases, new_aliases[perm]) + return nothing end """ @@ -77,16 +186,19 @@ function build(constraints::ConstraintsDictType)::ConstraintsModel state_cons_box_lb = Vector{LocalNumber}() state_cons_box_ub = Vector{LocalNumber}() state_cons_box_labels = Vector{Symbol}() + state_cons_box_aliases = Vector{Vector{Symbol}}() control_cons_box_ind = Vector{Int}() # control range control_cons_box_lb = Vector{LocalNumber}() control_cons_box_ub = Vector{LocalNumber}() control_cons_box_labels = Vector{Symbol}() + control_cons_box_aliases = Vector{Vector{Symbol}}() variable_cons_box_ind = Vector{Int}() # variable range variable_cons_box_lb = Vector{LocalNumber}() variable_cons_box_ub = Vector{LocalNumber}() variable_cons_box_labels = Vector{Symbol}() + variable_cons_box_aliases = Vector{Vector{Symbol}}() for (label, c) in constraints type = c[1] @@ -229,6 +341,34 @@ function build(constraints::ConstraintsDictType)::ConstraintsModel length_boundary_cons_nl, boundary_cons_nl_dim, boundary_cons_nl_f... ) + # Enforce the per-component uniqueness invariant for box constraints: + # deduplicate by component, applying intersection (max of lbs, min of ubs) + # and emitting a warning for each duplicated component. + _dedup_box_constraints!( + state_cons_box_ind, + state_cons_box_lb, + state_cons_box_ub, + state_cons_box_labels, + state_cons_box_aliases, + "state", + ) + _dedup_box_constraints!( + control_cons_box_ind, + control_cons_box_lb, + control_cons_box_ub, + control_cons_box_labels, + control_cons_box_aliases, + "control", + ) + _dedup_box_constraints!( + variable_cons_box_ind, + variable_cons_box_lb, + variable_cons_box_ub, + variable_cons_box_labels, + variable_cons_box_aliases, + "variable", + ) + return ConstraintsModel( (path_cons_nl_lb, path_cons_nl!, path_cons_nl_ub, path_cons_nl_labels), ( @@ -237,18 +377,26 @@ function build(constraints::ConstraintsDictType)::ConstraintsModel boundary_cons_nl_ub, boundary_cons_nl_labels, ), - (state_cons_box_lb, state_cons_box_ind, state_cons_box_ub, state_cons_box_labels), + ( + state_cons_box_lb, + state_cons_box_ind, + state_cons_box_ub, + state_cons_box_labels, + state_cons_box_aliases, + ), ( control_cons_box_lb, control_cons_box_ind, control_cons_box_ub, control_cons_box_labels, + control_cons_box_aliases, ), ( variable_cons_box_lb, variable_cons_box_ind, variable_cons_box_ub, variable_cons_box_labels, + variable_cons_box_aliases, ), ) end diff --git a/src/OCP/Building/solution.jl b/src/OCP/Building/solution.jl index a507afce..ed0b212a 100644 --- a/src/OCP/Building/solution.jl +++ b/src/OCP/Building/solution.jl @@ -293,6 +293,8 @@ function build_solution( # nonlinear constraints and dual variables (optional, can be nothing) # Note: dim is set to dim_path_constraints_nl for proper scalar wrapping # Path constraints duals share the path grid (T_path) + # Convention for path/boundary duals: one column per constraint row + # (indexed by declaration, matching cp[4] labels). fpcd = build_interpolated_function( path_constraints_dual, T_path, @@ -302,42 +304,70 @@ function build_solution( ) # box constraints multipliers (optional, can be nothing) - # Note: No expected_dim validation for box constraints because they use - # dim_*_constraints_box which may differ from state/control dimensions + # Convention for box duals: one column per primal component + # (state_dimension / control_dimension / variable_dimension). + # Components without a bound carry a zero multiplier. This matches what + # CTDirect produces natively and guarantees that `dual(sol, m, :label)` + # can return the multiplier of the component(s) targeted by :label. # State box constraint duals share the state grid (T_state) fscbd = build_interpolated_function( state_constraints_lb_dual, T_state, - dim_state_constraints_box(ocp), + dim_x, Union{Matrix{Float64},Nothing}; allow_nothing=true, + expected_dim=dim_x, ) fscud = build_interpolated_function( state_constraints_ub_dual, T_state, - dim_state_constraints_box(ocp), + dim_x, Union{Matrix{Float64},Nothing}; allow_nothing=true, + expected_dim=dim_x, ) # Control box constraint duals share the control grid (T_control) # Note: use same interpolation as control fccbd = build_interpolated_function( control_constraints_lb_dual, T_control, - dim_control_constraints_box(ocp), + dim_u, Union{Matrix{Float64},Nothing}; allow_nothing=true, + expected_dim=dim_u, interpolation=control_interpolation, ) fccud = build_interpolated_function( control_constraints_ub_dual, T_control, - dim_control_constraints_box(ocp), + dim_u, Union{Matrix{Float64},Nothing}; allow_nothing=true, + expected_dim=dim_u, interpolation=control_interpolation, ) + # Variable box constraint duals are (time-independent) vectors. + # Enforce length == variable_dimension(ocp) when provided. + if !isnothing(variable_constraints_lb_dual) + @ensure length(variable_constraints_lb_dual) == dim_v Exceptions.IncorrectArgument( + "variable_constraints_lb_dual length mismatch"; + got="length=$(length(variable_constraints_lb_dual))", + expected="length=$(dim_v) (= variable_dimension)", + suggestion="Provide a vector of length variable_dimension(ocp); pad with zeros for unconstrained components.", + context="build_solution - validating variable dual length", + ) + end + if !isnothing(variable_constraints_ub_dual) + @ensure length(variable_constraints_ub_dual) == dim_v Exceptions.IncorrectArgument( + "variable_constraints_ub_dual length mismatch"; + got="length=$(length(variable_constraints_ub_dual))", + expected="length=$(dim_v) (= variable_dimension)", + suggestion="Provide a vector of length variable_dimension(ocp); pad with zeros for unconstrained components.", + context="build_solution - validating variable dual length", + ) + end + # build Models state = StateModelSolution(state_name(ocp), state_components(ocp), fx) control = ControlModelSolution( @@ -731,10 +761,10 @@ end """ $(TYPEDSIGNATURES) -Return the dimension of the variable box constraints. +Return the dimension of the variable box constraints duals. """ -function dim_variable_constraints_box(sol::Solution)::Dimension +function dim_dual_variable_constraints_box(sol::Solution)::Dimension vc_lb_dual = variable_constraints_lb_dual(sol) return vc_lb_dual === nothing ? 0 : length(vc_lb_dual) end @@ -742,23 +772,29 @@ end """ $(TYPEDSIGNATURES) -Return the dimension of box constraints on state. +Return the dimension of a dual value, evaluating at initial time. +""" +_dual_dimension(::Nothing, ::Solution)::Dimension = 0 +_dual_dimension(dual::Function, sol::Solution)::Dimension = length(dual(initial_time(sol))) + +""" +$(TYPEDSIGNATURES) + +Return the dimension of the box constraints duals on state. """ -function dim_state_constraints_box(sol::Solution)::Dimension - sc_lb_dual = state_constraints_lb_dual(sol) - return sc_lb_dual === nothing ? 0 : state_dimension(sol) +function dim_dual_state_constraints_box(sol::Solution)::Dimension + return _dual_dimension(state_constraints_lb_dual(sol), sol) end """ $(TYPEDSIGNATURES) -Return the dimension of box constraints on control. +Return the dimension of the box constraints duals on control. """ -function dim_control_constraints_box(sol::Solution)::Dimension - cc_lb_dual = control_constraints_lb_dual(sol) - return cc_lb_dual === nothing ? 0 : control_dimension(sol) +function dim_dual_control_constraints_box(sol::Solution)::Dimension + return _dual_dimension(control_constraints_lb_dual(sol), sol) end """ @@ -1289,7 +1325,7 @@ $(TYPEDSIGNATURES) Print the solution. """ function Base.show(io::IO, ::MIME"text/plain", sol::Solution) - # RΓ©sumΓ© solveur + # Solver summary println(io, "β€’ Solver:") println(io, " βœ“ Successful : ", successful(sol)) println(io, " β”‚ Status : ", status(sol)) @@ -1298,12 +1334,12 @@ function Base.show(io::IO, ::MIME"text/plain", sol::Solution) println(io, " β”‚ Objective : ", objective(sol)) println(io, " └─ Constraints violation : ", constraints_violation(sol)) - # Variable (si dΓ©finie) + # Variable (if defined) if variable_dimension(sol) > 0 components = variable_components(sol) var_name = variable_name(sol) - # Cas simplifiΓ©: dimension 1 et nom identique + # Simplified case: dimension 1 and name identical if variable_dimension(sol) == 1 && var_name == components[1] println( io, @@ -1323,7 +1359,7 @@ function Base.show(io::IO, ::MIME"text/plain", sol::Solution) variable(sol), ) end - if dim_variable_constraints_box(sol) > 0 + if dim_dual_variable_constraints_box(sol) > 0 && dim_variable_constraints_box(model(sol)) > 0 println(io, " β”‚ Var dual (lb) : ", variable_constraints_lb_dual(sol)) println(io, " └─ Var dual (ub) : ", variable_constraints_ub_dual(sol)) end @@ -1540,20 +1576,20 @@ function _discretize_all_components( path_constraints_dual(sol), T_path, dim_path_constraints_nl(sol) ), "state_constraints_lb_dual" => _discretize_dual( - state_constraints_lb_dual(sol), T_state, dim_state_constraints_box(sol) + state_constraints_lb_dual(sol), T_state, dim_dual_state_constraints_box(sol) ), "state_constraints_ub_dual" => _discretize_dual( - state_constraints_ub_dual(sol), T_state, dim_state_constraints_box(sol) + state_constraints_ub_dual(sol), T_state, dim_dual_state_constraints_box(sol) ), "control_constraints_lb_dual" => _discretize_dual( control_constraints_lb_dual(sol), T_control, - dim_control_constraints_box(sol), + dim_dual_control_constraints_box(sol), ), "control_constraints_ub_dual" => _discretize_dual( control_constraints_ub_dual(sol), T_control, - dim_control_constraints_box(sol), + dim_dual_control_constraints_box(sol), ), "boundary_constraints_dual" => boundary_constraints_dual(sol), "variable_constraints_lb_dual" => variable_constraints_lb_dual(sol), diff --git a/src/OCP/Components/constraints.jl b/src/OCP/Components/constraints.jl index d6a08f93..b7d30b66 100644 --- a/src/OCP/Components/constraints.jl +++ b/src/OCP/Components/constraints.jl @@ -749,78 +749,72 @@ function constraint(model::Model, label::Symbol)::Tuple # not type stable ) end - # check if the label is in the state constraints - cp = state_constraints_box(model) - labels = cp[4] # vector of labels - if label in labels - # get all the indices of the label - indices_state = Int[] - indices_bound = Int[] - for i in eachindex(labels) - if labels[i] == label - push!(indices_state, cp[2][i]) - push!(indices_bound, i) + # Box constraints: each box tuple has the form + # (lb, ind, ub, labels, aliases) + # where `aliases[k]` lists every label that declared component `ind[k]`. + # We look up `label` in that field (rather than in `labels[k]`, which only + # stores the *first* label per component after dedup). The returned bounds + # are the **effective** (intersected) bounds stored in `lb`/`ub`, not the + # bounds as initially declared for that specific label. + function _lookup_box(cp) + aliases = cp[5] + idxs = Int[] + for k in eachindex(aliases) + if label in aliases[k] + push!(idxs, k) end end + return idxs + end + + # state box + cp = state_constraints_box(model) + idxs = _lookup_box(cp) + if !isempty(idxs) + component_idxs = cp[2][idxs] fc = (t, x, u, v) -> begin - length(indices_state) == 1 ? x[indices_state[1]] : x[indices_state] + length(component_idxs) == 1 ? x[component_idxs[1]] : x[component_idxs] end return ( - :state, # type of the constraint + :state, fc, - length(indices_bound)==1 ? cp[1][indices_bound[1]] : cp[1][indices_bound], # lower bound - length(indices_bound) == 1 ? cp[3][indices_bound[1]] : cp[3][indices_bound], # upper bound + length(idxs) == 1 ? cp[1][idxs[1]] : cp[1][idxs], + length(idxs) == 1 ? cp[3][idxs[1]] : cp[3][idxs], ) end - # check if the label is in the control constraints + # control box cp = control_constraints_box(model) - labels = cp[4] # vector of labels - if label in labels - # get all the indices of the label - indices_state = Int[] - indices_bound = Int[] - for i in eachindex(labels) - if labels[i] == label - push!(indices_state, cp[2][i]) - push!(indices_bound, i) - end - end + idxs = _lookup_box(cp) + if !isempty(idxs) + component_idxs = cp[2][idxs] fc = (t, x, u, v) -> begin - length(indices_state) == 1 ? u[indices_state[1]] : u[indices_state] + length(component_idxs) == 1 ? u[component_idxs[1]] : u[component_idxs] end return ( - :control, # type of the constraint + :control, fc, - length(indices_bound)==1 ? cp[1][indices_bound[1]] : cp[1][indices_bound], # lower bound - length(indices_bound) == 1 ? cp[3][indices_bound[1]] : cp[3][indices_bound], # upper bound + length(idxs) == 1 ? cp[1][idxs[1]] : cp[1][idxs], + length(idxs) == 1 ? cp[3][idxs[1]] : cp[3][idxs], ) end - # check if the label is in the variable constraints + # variable box cp = variable_constraints_box(model) - labels = cp[4] # vector of labels - if label in labels - # get all the indices of the label - indices_state = Int[] - indices_bound = Int[] - for i in eachindex(labels) - if labels[i] == label - push!(indices_state, cp[2][i]) - push!(indices_bound, i) - end - end + idxs = _lookup_box(cp) + if !isempty(idxs) + component_idxs = cp[2][idxs] fc = (x0, xf, v) -> begin - length(indices_state) == 1 ? v[indices_state[1]] : v[indices_state] + length(component_idxs) == 1 ? v[component_idxs[1]] : v[component_idxs] end return ( - :variable, # type of the constraint + :variable, fc, - length(indices_bound)==1 ? cp[1][indices_bound[1]] : cp[1][indices_bound], # lower bound - length(indices_bound) == 1 ? cp[3][indices_bound[1]] : cp[3][indices_bound], # upper bound + length(idxs) == 1 ? cp[1][idxs[1]] : cp[1][idxs], + length(idxs) == 1 ? cp[3][idxs[1]] : cp[3][idxs], ) end diff --git a/src/OCP/OCP.jl b/src/OCP/OCP.jl index 0ee05b59..1b972045 100644 --- a/src/OCP/OCP.jl +++ b/src/OCP/OCP.jl @@ -121,6 +121,7 @@ export path_constraints_nl, boundary_constraints_nl export state_constraints_box, control_constraints_box, variable_constraints_box export dim_path_constraints_nl, dim_boundary_constraints_nl export dim_state_constraints_box, dim_control_constraints_box, dim_variable_constraints_box +export dim_dual_state_constraints_box, dim_dual_control_constraints_box, dim_dual_variable_constraints_box export state, control, variable, costate, objective export dynamics, mayer, lagrange export definition, dual diff --git a/src/OCP/Types/components.jl b/src/OCP/Types/components.jl index dac1479c..8c870bb7 100644 --- a/src/OCP/Types/components.jl +++ b/src/OCP/Types/components.jl @@ -496,11 +496,18 @@ Container for all constraints in an optimal control problem. # Fields -- `path_nl::TP`: Tuple of nonlinear path constraints `(t, x, u, v) -> c(t, x, u, v)`. -- `boundary_nl::TB`: Tuple of nonlinear boundary constraints `(x0, xf, v) -> b(x0, xf, v)`. -- `state_box::TS`: Tuple of box constraints on state variables (lower/upper bounds). -- `control_box::TC`: Tuple of box constraints on control variables (lower/upper bounds). -- `variable_box::TV`: Tuple of box constraints on optimisation variables (lower/upper bounds). +- `path_nl::TP`: Tuple of nonlinear path constraints `(lb, f!, ub, labels)`. +- `boundary_nl::TB`: Tuple of nonlinear boundary constraints `(lb, f!, ub, labels)`. +- `state_box::TS`: Tuple of box constraints on state variables, with structure + `(lb, ind, ub, labels, aliases)` where `labels[k]` is the first label that + declared component `ind[k]`, and `aliases[k]` is the list of **all** labels + that declared `ind[k]` (in declaration order). This 5-element form allows + `constraint(model, :label)` / `dual(sol, model, :label)` to resolve labels + merged by the per-component uniqueness invariant. +- `control_box::TC`: Tuple of box constraints on control variables (same + `(lb, ind, ub, labels, aliases)` structure as `state_box`). +- `variable_box::TV`: Tuple of box constraints on optimisation variables (same + `(lb, ind, ub, labels, aliases)` structure as `state_box`). # Example diff --git a/src/OCP/Types/model.jl b/src/OCP/Types/model.jl index cecd3fdf..6889cd1c 100644 --- a/src/OCP/Types/model.jl +++ b/src/OCP/Types/model.jl @@ -30,7 +30,11 @@ and the types of all its components. - `variable::VariableModelType`: Optimisation variable structure (may be empty). - `dynamics::DynamicsModelType`: System dynamics function `(t, x, u, v) -> αΊ‹`. - `objective::ObjectiveModelType`: Cost functional (Mayer, Lagrange, or Bolza). -- `constraints::ConstraintsModelType`: All problem constraints. +- `constraints::ConstraintsModelType`: All problem constraints. Box constraints + satisfy the per-component uniqueness invariant: each component appears at most + once in the stored tuples, bounds are the intersection of all declared bounds, + and every declared label is preserved in the `aliases` field of the box tuples + (see `ConstraintsModel`). - `definition::Expr`: Original symbolic definition of the problem. - `build_examodel::BuildExaModelType`: Optional ExaModels builder function. diff --git a/test/suite/ocp/test_constraints.jl b/test/suite/ocp/test_constraints.jl index 53443853..e4f093ad 100644 --- a/test/suite/ocp/test_constraints.jl +++ b/test/suite/ocp/test_constraints.jl @@ -7,6 +7,29 @@ using CTModels: CTModels const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true +# Top-level helper (module-level to avoid world-age issues) +""" + _make_min_premodel(; state_dim, control_dim=1, variable_dim=0) + +Build a minimal `PreModel` with the given dimensions, ready to receive constraints. +Used by storage / retrieval tests. +""" +function _make_min_premodel(; state_dim::Int, control_dim::Int=1, variable_dim::Int=0) + ocp = CTModels.PreModel() + CTModels.time!(ocp; t0=0.0, tf=1.0) + CTModels.state!(ocp, state_dim) + CTModels.control!(ocp, control_dim) + if variable_dim > 0 + CTModels.variable!(ocp, variable_dim) + end + _dyn!(r, t, x, u, v) = (r .= zero(x)) + CTModels.dynamics!(ocp, _dyn!) + CTModels.objective!(ocp, :min; mayer=(x0, xf, v) -> 0.0) + CTModels.definition!(ocp, quote end) + CTModels.time_dependence!(ocp; autonomous=false) + return ocp +end + """ test_constraints() @@ -14,8 +37,9 @@ Test constraint handling in OCP models. # Note Some tests in this file intentionally generate warnings to verify that the system -correctly warns users about overwriting bounds. If you see warnings like -"Overwriting bound for component X", they are expected and part of the test assertions. +correctly warns users about multiple bound declarations on the same component. +If you see warnings like "Multiple bound declarations for component X", they are +expected and part of the test assertions. """ function test_constraints() Test.@testset "Constraints Tests" verbose=VERBOSE showtiming=SHOWTIMING begin @@ -172,12 +196,14 @@ function test_constraints() # ----------------------------------------------------------------------- # Test duplicate constraint warning (Issue #105) # When multiple constraints are declared on the same component index, - # a warning should be emitted during model build. + # a warning should be emitted during model build. All declarations are + # kept; the effective bound at solver level is the intersection. # Applies to: state, control, and variable constraints. # # NOTE: The warnings displayed during these tests are INTENTIONAL and EXPECTED. - # They verify that the system correctly warns users about overwriting bounds. - # These warnings are part of the test assertions using Test.@test_warn. + # They verify that the system correctly warns users about multiple bound + # declarations. These warnings are part of the test assertions using + # Test.@test_warn. # ----------------------------------------------------------------------- Test.@testset "duplicate constraint warning" begin # --- State constraints --- @@ -196,7 +222,9 @@ function test_constraints() CTModels.constraint!(ocp_dup, :state; rg=1:1, lb=[0.0], ub=[1.0], label=:s1) CTModels.constraint!(ocp_dup, :state; rg=1:1, lb=[0.5], ub=[1.5], label=:s2) - Test.@test_warn "Overwriting bound for component 1" CTModels.build(ocp_dup) + Test.@test_warn "Multiple bound declarations for state component 1" CTModels.build( + ocp_dup + ) end # --- Control constraints --- @@ -219,7 +247,9 @@ function test_constraints() ocp_dup, :control; rg=1:1, lb=[0.5], ub=[1.5], label=:c2 ) - Test.@test_warn "Overwriting bound for component 1" CTModels.build(ocp_dup) + Test.@test_warn "Multiple bound declarations for control component 1" CTModels.build( + ocp_dup + ) end # --- Variable constraints --- @@ -243,8 +273,135 @@ function test_constraints() ocp_dup, :variable; rg=1:1, lb=[0.5], ub=[1.5], label=:v2 ) - Test.@test_warn "Overwriting bound for component 1" CTModels.build(ocp_dup) + Test.@test_warn "Multiple bound declarations for variable component 1" CTModels.build( + ocp_dup + ) + end + end + + # ==================================================================== + # UNIT TESTS - Bound declarations storage in ConstraintsModel + # Verifies the contract of `build(constraints)`: how declared bounds + # are stored in the `state_constraints_box` / `control_constraints_box` + # / `variable_constraints_box` tuples of the resulting Model. + # ==================================================================== + + Test.@testset "bound declarations storage" begin + Test.@testset "single full-range declaration preserves order" begin + ocp = _make_min_premodel(; state_dim=3) + CTModels.constraint!( + ocp, + :state; + rg=1:3, + lb=[0.0, 1.0, 2.0], + ub=[1.0, 2.0, 3.0], + label=:s, + ) + m = CTModels.build(ocp) + cs = CTModels.state_constraints_box(m) + Test.@test cs[1] == [0.0, 1.0, 2.0] + Test.@test cs[2] == [1, 2, 3] + Test.@test cs[3] == [1.0, 2.0, 3.0] + Test.@test cs[4] == [:s, :s, :s] + Test.@test cs[5] == [[:s], [:s], [:s]] + Test.@test CTModels.dim_state_constraints_box(m) == 3 + end + + Test.@testset "partial range not starting at 1" begin + ocp = _make_min_premodel(; state_dim=3) + CTModels.constraint!( + ocp, :state; rg=2:3, lb=[0.0, 1.0], ub=[1.0, 2.0], label=:s + ) + m = CTModels.build(ocp) + cs = CTModels.state_constraints_box(m) + Test.@test cs[1] == [0.0, 1.0] + Test.@test cs[2] == [2, 3] + Test.@test cs[3] == [1.0, 2.0] + Test.@test cs[5] == [[:s], [:s]] + Test.@test CTModels.dim_state_constraints_box(m) == 2 + end + + Test.@testset "duplicate: intersection applied, per-component uniqueness" begin + ocp = _make_min_premodel(; state_dim=2) + CTModels.constraint!( + ocp, :state; rg=1:1, lb=[0.0], ub=[2.0], label=:s1 + ) + CTModels.constraint!( + ocp, :state; rg=1:1, lb=[0.5], ub=[1.5], label=:s2 + ) + # warning emitted once at build; storage holds one entry per component + m = (Test.@test_logs (:warn, r"Multiple bound declarations") CTModels.build( + ocp + )) + cs = CTModels.state_constraints_box(m) + # effective (intersected) bounds + Test.@test cs[1] == [0.5] + Test.@test cs[2] == [1] + Test.@test cs[3] == [1.5] + # first-declared label kept in cs[4]; all labels kept in cs[5] + Test.@test cs[4] == [:s1] + Test.@test cs[5] == [[:s1, :s2]] + # dim counts unique components, not declarations + Test.@test CTModels.dim_state_constraints_box(m) == 1 end + + Test.@testset "overlapping ranges" begin + ocp = _make_min_premodel(; state_dim=3) + CTModels.constraint!( + ocp, + :state; + rg=1:2, + lb=[0.0, 1.0], + ub=[1.0, 2.0], + label=:a, + ) + CTModels.constraint!( + ocp, + :state; + rg=2:3, + lb=[0.5, 1.5], + ub=[1.5, 2.5], + label=:b, + ) + m = (Test.@test_logs (:warn, r"component 2") CTModels.build(ocp)) + cs = CTModels.state_constraints_box(m) + # 3 unique components: 1 (from :a), 2 (intersected from :a,:b), 3 (from :b) + Test.@test length(cs[1]) == 3 + Test.@test cs[2] == [1, 2, 3] + Test.@test cs[1] == [0.0, 1.0, 1.5] # max of lbs per component + Test.@test cs[3] == [1.0, 1.5, 2.5] # min of ubs per component + Test.@test cs[4] == [:a, :a, :b] # first label per component + Test.@test cs[5] == [[:a], [:a, :b], [:b]] + Test.@test CTModels.dim_state_constraints_box(m) == 3 + end + end + + # ==================================================================== + # UNIT TESTS - constraint(model, label) retrieval + # After duplicates are kept, `constraint(m, :label)` must still return + # the originally declared bounds for each individual label. + # ==================================================================== + + Test.@testset "constraint(model, label) retrieval with duplicates" begin + # After dedup+intersection, `constraint(m, :label)` returns the + # **effective** (intersected) bounds, not the bounds as initially + # declared for that specific label. Both :s1 and :s2 target + # component 1, so both retrievals yield the same intersected bound. + ocp = _make_min_premodel(; state_dim=2) + CTModels.constraint!(ocp, :state; rg=1:1, lb=[0.0], ub=[2.0], label=:s1) + CTModels.constraint!(ocp, :state; rg=1:1, lb=[0.5], ub=[1.5], label=:s2) + m = (Test.@test_logs (:warn, r"Multiple bound declarations") CTModels.build( + ocp + )) + + c_s1 = CTModels.constraint(m, :s1) + c_s2 = CTModels.constraint(m, :s2) + Test.@test c_s1[1] === :state + Test.@test c_s1[3] == 0.5 # effective lb = max(0.0, 0.5) + Test.@test c_s1[4] == 1.5 # effective ub = min(2.0, 1.5) + Test.@test c_s2[1] === :state + Test.@test c_s2[3] == 0.5 + Test.@test c_s2[4] == 1.5 end # NEW: lb ≀ ub validation tests diff --git a/test/suite/ocp/test_dual_model.jl b/test/suite/ocp/test_dual_model.jl index cc35656c..6e462ced 100644 --- a/test/suite/ocp/test_dual_model.jl +++ b/test/suite/ocp/test_dual_model.jl @@ -6,6 +6,96 @@ using CTModels: CTModels const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true +# Top-level helper: build a minimal Model and Solution with prescribed dual matrices. +# Used by integration tests for `dual(sol, model, label)`. +function _build_model_with_state_box(; state_dim::Int, constraints::Vector) + ocp = CTModels.PreModel() + CTModels.time!(ocp; t0=0.0, tf=1.0) + CTModels.state!(ocp, state_dim) + CTModels.control!(ocp, 1) + _dyn!(r, t, x, u, v) = (r .= zero(x)) + CTModels.dynamics!(ocp, _dyn!) + CTModels.objective!(ocp, :min; mayer=(x0, xf, v) -> 0.0) + CTModels.definition!(ocp, quote end) + CTModels.time_dependence!(ocp; autonomous=false) + for (rg, lb, ub, label) in constraints + CTModels.constraint!(ocp, :state; rg=rg, lb=lb, ub=ub, label=label) + end + return ocp +end + +function _build_model_with_control_box(; control_dim::Int, constraints::Vector) + ocp = CTModels.PreModel() + CTModels.time!(ocp; t0=0.0, tf=1.0) + CTModels.state!(ocp, 1) + CTModels.control!(ocp, control_dim) + _dyn!(r, t, x, u, v) = (r .= zero(x)) + CTModels.dynamics!(ocp, _dyn!) + CTModels.objective!(ocp, :min; mayer=(x0, xf, v) -> 0.0) + CTModels.definition!(ocp, quote end) + CTModels.time_dependence!(ocp; autonomous=false) + for (rg, lb, ub, label) in constraints + CTModels.constraint!(ocp, :control; rg=rg, lb=lb, ub=ub, label=label) + end + return ocp +end + +function _build_model_with_variable_box(; variable_dim::Int, constraints::Vector) + ocp = CTModels.PreModel() + CTModels.time!(ocp; t0=0.0, tf=1.0) + CTModels.state!(ocp, 1) + CTModels.control!(ocp, 1) + CTModels.variable!(ocp, variable_dim) + _dyn!(r, t, x, u, v) = (r .= zero(x)) + CTModels.dynamics!(ocp, _dyn!) + CTModels.objective!(ocp, :min; mayer=(x0, xf, v) -> 0.0) + CTModels.definition!(ocp, quote end) + CTModels.time_dependence!(ocp; autonomous=false) + for (rg, lb, ub, label) in constraints + CTModels.constraint!(ocp, :variable; rg=rg, lb=lb, ub=ub, label=label) + end + return ocp +end + +function _make_solution( + ocp_model::CTModels.Model; + state_dim::Int=1, + control_dim::Int=1, + variable_dim::Int=0, + state_lb=nothing, + state_ub=nothing, + control_lb=nothing, + control_ub=nothing, + variable_lb=nothing, + variable_ub=nothing, +) + T = [0.0, 0.5, 1.0] + X = zeros(3, state_dim) + U = zeros(3, control_dim) + P = zeros(3, state_dim) + v = zeros(variable_dim) + return CTModels.build_solution( + ocp_model, + T, + X, + U, + v, + P; + objective=0.0, + iterations=0, + constraints_violation=0.0, + message="", + status=:optimal, + successful=true, + state_constraints_lb_dual=state_lb, + state_constraints_ub_dual=state_ub, + control_constraints_lb_dual=control_lb, + control_constraints_ub_dual=control_ub, + variable_constraints_lb_dual=variable_lb, + variable_constraints_ub_dual=variable_ub, + ) +end + function test_dual_model() Test.@testset "Dual Model Tests" verbose=VERBOSE showtiming=SHOWTIMING begin @@ -42,6 +132,163 @@ function test_dual_model() Test.@test CTModels.variable_constraints_lb_dual(dual) === vc_lb Test.@test CTModels.variable_constraints_ub_dual(dual) === vc_ub end + + # ==================================================================== + # INTEGRATION TESTS - dual(sol, model, label) on box constraints + # Verifies that `dual(sol, m, :label)` returns the multiplier(s) + # associated with each bound declaration (one column per declaration + # in the provided dual matrix). + # ==================================================================== + + Test.@testset "INTEGRATION TESTS - dual() box constraints" verbose=VERBOSE showtiming=SHOWTIMING begin + + # --- State box --- + Test.@testset "state - single full-range declaration" begin + ocp = _build_model_with_state_box(; + state_dim=2, + constraints=[(1:2, [0.0, 0.0], [1.0, 1.0], :s)], + ) + m = CTModels.build(ocp) + # 2 declarations β†’ matrix must have 2 columns + lb = [1.0 10.0; 2.0 20.0; 3.0 30.0] + ub = [0.1 0.5; 0.2 0.6; 0.3 0.7] + sol = _make_solution(m; state_dim=2, state_lb=lb, state_ub=ub) + d = CTModels.dual(sol, m, :s) + Test.@test d(0.0) β‰ˆ [1.0 - 0.1, 10.0 - 0.5] + Test.@test d(1.0) β‰ˆ [3.0 - 0.3, 30.0 - 0.7] + end + + Test.@testset "state - partial range not starting at 1" begin + ocp = _build_model_with_state_box(; + state_dim=3, + constraints=[(2:3, [0.0, 0.0], [1.0, 1.0], :s)], + ) + m = CTModels.build(ocp) + # Per-component convention: matrix has state_dim=3 columns. + # Component 1 is unconstrained and carries a zero multiplier. + lb = [0.0 1.0 10.0; 0.0 2.0 20.0; 0.0 3.0 30.0] + ub = zeros(3, 3) + sol = _make_solution(m; state_dim=3, state_lb=lb, state_ub=ub) + d = CTModels.dual(sol, m, :s) + # :s targets components 2,3 β†’ slice cols 2,3 + Test.@test d(0.0) β‰ˆ [1.0, 10.0] + Test.@test d(1.0) β‰ˆ [3.0, 30.0] + end + + Test.@testset "state - two labels on same component: share per-component dual" begin + ocp = _build_model_with_state_box(; + state_dim=1, + constraints=[ + (1:1, [0.0], [2.0], :s1), + (1:1, [0.5], [1.5], :s2), + ], + ) + m = (Test.@test_logs (:warn, r"Multiple bound declarations") CTModels.build( + ocp + )) + # After dedup, 1 unique component β†’ matrix has state_dim=1 column + lb = reshape([1.0, 2.0, 3.0], 3, 1) + ub = zeros(3, 1) + sol = _make_solution(m; state_dim=1, state_lb=lb, state_ub=ub) + # Both labels point to component 1 β†’ same scalar dual + Test.@test CTModels.dual(sol, m, :s1)(0.0) β‰ˆ 1.0 + Test.@test CTModels.dual(sol, m, :s2)(0.0) β‰ˆ 1.0 + Test.@test CTModels.dual(sol, m, :s1)(1.0) β‰ˆ 3.0 + Test.@test CTModels.dual(sol, m, :s2)(1.0) β‰ˆ 3.0 + end + + # --- Control box --- + Test.@testset "control - single full-range declaration" begin + ocp = _build_model_with_control_box(; + control_dim=2, + constraints=[(1:2, [0.0, 0.0], [1.0, 1.0], :c)], + ) + m = CTModels.build(ocp) + lb = [1.0 10.0; 2.0 20.0; 3.0 30.0] + ub = zeros(3, 2) + sol = _make_solution(m; control_dim=2, control_lb=lb, control_ub=ub) + d = CTModels.dual(sol, m, :c) + Test.@test d(0.0) β‰ˆ [1.0, 10.0] + end + + Test.@testset "control - partial range not starting at 1" begin + ocp = _build_model_with_control_box(; + control_dim=3, + constraints=[(2:3, [0.0, 0.0], [1.0, 1.0], :c)], + ) + m = CTModels.build(ocp) + # Per-component: matrix has control_dim=3 columns. + lb = [0.0 1.0 10.0; 0.0 2.0 20.0; 0.0 3.0 30.0] + ub = zeros(3, 3) + sol = _make_solution(m; control_dim=3, control_lb=lb, control_ub=ub) + d = CTModels.dual(sol, m, :c) + Test.@test d(0.0) β‰ˆ [1.0, 10.0] + end + + Test.@testset "control - two labels on same component: share per-component dual" begin + ocp = _build_model_with_control_box(; + control_dim=1, + constraints=[ + (1:1, [0.0], [2.0], :c1), + (1:1, [0.5], [1.5], :c2), + ], + ) + m = (Test.@test_logs (:warn, r"Multiple bound declarations") CTModels.build( + ocp + )) + lb = reshape([1.0, 2.0, 3.0], 3, 1) + ub = zeros(3, 1) + sol = _make_solution(m; control_dim=1, control_lb=lb, control_ub=ub) + Test.@test CTModels.dual(sol, m, :c1)(0.0) β‰ˆ 1.0 + Test.@test CTModels.dual(sol, m, :c2)(0.0) β‰ˆ 1.0 + end + + # --- Variable box --- + Test.@testset "variable - single full-range declaration" begin + ocp = _build_model_with_variable_box(; + variable_dim=2, + constraints=[(1:2, [0.0, 0.0], [1.0, 1.0], :vbl)], + ) + m = CTModels.build(ocp) + lb = [1.0, 10.0] + ub = [0.1, 0.5] + sol = _make_solution(m; variable_dim=2, variable_lb=lb, variable_ub=ub) + d = CTModels.dual(sol, m, :vbl) + Test.@test d β‰ˆ [1.0 - 0.1, 10.0 - 0.5] + end + + Test.@testset "variable - partial range not starting at 1" begin + ocp = _build_model_with_variable_box(; + variable_dim=3, + constraints=[(2:3, [0.0, 0.0], [1.0, 1.0], :vbl)], + ) + m = CTModels.build(ocp) + # Per-component: duals are variable_dim-sized vectors. + lb = [0.0, 1.0, 10.0] + ub = zeros(3) + sol = _make_solution(m; variable_dim=3, variable_lb=lb, variable_ub=ub) + d = CTModels.dual(sol, m, :vbl) + Test.@test d β‰ˆ [1.0, 10.0] + end + + Test.@testset "variable - two labels on same component: share per-component dual" begin + ocp = _build_model_with_variable_box(; + variable_dim=1, + constraints=[ + (1:1, [0.0], [2.0], :v1), + (1:1, [0.5], [1.5], :v2), + ], + ) + m = (Test.@test_logs (:warn, r"Multiple bound declarations") CTModels.build( + ocp + )) + lb = [1.0] + ub = [0.0] + sol = _make_solution(m; variable_dim=1, variable_lb=lb, variable_ub=ub) + Test.@test CTModels.dual(sol, m, :v1) β‰ˆ 1.0 + Test.@test CTModels.dual(sol, m, :v2) β‰ˆ 1.0 + end + end end end diff --git a/test/suite/ocp/test_interpolation_helpers.jl b/test/suite/ocp/test_interpolation_helpers.jl index bab113b5..df89a5b7 100644 --- a/test/suite/ocp/test_interpolation_helpers.jl +++ b/test/suite/ocp/test_interpolation_helpers.jl @@ -74,13 +74,14 @@ function test_interpolation_helpers() end Test.@testset "_interpolate_from_data: dimension validation" begin - # Valid: matrix has 2 columns, we extract 2 + # Valid: matrix has exactly `expected_dim` columns (strict check) func = OCP._interpolate_from_data(X_2d, T, 2, Matrix{Float64}; expected_dim=2) Test.@test !isnothing(func) - # Valid: matrix has 2 columns, we extract 1 - func = OCP._interpolate_from_data(X_2d, T, 1, Matrix{Float64}; expected_dim=1) - Test.@test !isnothing(func) + # Invalid: matrix has 2 columns but expected_dim=1 (strict mismatch) + Test.@test_throws Exceptions.IncorrectArgument OCP._interpolate_from_data( + X_2d, T, 1, Matrix{Float64}; expected_dim=1 + ) # Invalid: matrix has 2 columns, we expect 3 Test.@test_throws Exceptions.IncorrectArgument OCP._interpolate_from_data( diff --git a/test/suite/ocp/test_model.jl b/test/suite/ocp/test_model.jl index 8988504b..eca63055 100644 --- a/test/suite/ocp/test_model.jl +++ b/test/suite/ocp/test_model.jl @@ -120,7 +120,15 @@ function test_model() ) # build the model - model = CTModels.build(pre_ocp) + # Note: the scalar constraints (:state_scalar, :state_scalar_2, and + # their control/variable analogues) intentionally re-declare bounds on + # components already declared by :state/:control/:variable. Under the + # per-component uniqueness invariant, this emits one warning per + # duplicated component (6 warnings here). We redirect stderr to silence + # them so they don't pollute test output. + model = redirect_stderr(devnull) do + CTModels.build(pre_ocp) + end # check the type of the model Test.@test model isa CTModels.Model @@ -167,6 +175,12 @@ function test_model() Test.@test CTModels.constraint(model, :variable_scalar_2)[1] == :variable # test the lower bounds + # For path/boundary constraints (NL), bounds are per-declaration and + # returned as declared. For state/control/variable box constraints + # sharing components, `constraint(m, :label)[3]` returns the + # **effective** (intersected) lower bound. E.g. :state_scalar declares + # lb=-12 on component 1 but :state already declares lb=-4 on that same + # component (tighter), so the effective lb is -4. Test.@test CTModels.constraint(model, :path)[3] == [-0, -1] Test.@test CTModels.constraint(model, :boundary)[3] == [-2, -3] Test.@test CTModels.constraint(model, :state)[3] == [-4, -5] @@ -174,14 +188,14 @@ function test_model() Test.@test CTModels.constraint(model, :variable)[3] == [-8, -9] Test.@test CTModels.constraint(model, :path_scalar)[3] == -10 Test.@test CTModels.constraint(model, :boundary_scalar)[3] == -11 - Test.@test CTModels.constraint(model, :state_scalar)[3] == -12 - Test.@test CTModels.constraint(model, :control_scalar)[3] == -13 - Test.@test CTModels.constraint(model, :variable_scalar)[3] == -14 - Test.@test CTModels.constraint(model, :state_scalar_2)[3] == -15 - Test.@test CTModels.constraint(model, :control_scalar_2)[3] == -16 - Test.@test CTModels.constraint(model, :variable_scalar_2)[3] == -17 - - # test the upper bounds + Test.@test CTModels.constraint(model, :state_scalar)[3] == -4 # intersected with :state + Test.@test CTModels.constraint(model, :control_scalar)[3] == -6 # intersected with :control + Test.@test CTModels.constraint(model, :variable_scalar)[3] == -8 # intersected with :variable + Test.@test CTModels.constraint(model, :state_scalar_2)[3] == -5 + Test.@test CTModels.constraint(model, :control_scalar_2)[3] == -7 + Test.@test CTModels.constraint(model, :variable_scalar_2)[3] == -9 + + # test the upper bounds (same intersection logic applies) Test.@test CTModels.constraint(model, :path)[4] == [1, 2] Test.@test CTModels.constraint(model, :boundary)[4] == [3, 4] Test.@test CTModels.constraint(model, :state)[4] == [5, 6] @@ -189,12 +203,12 @@ function test_model() Test.@test CTModels.constraint(model, :variable)[4] == [9, 10] Test.@test CTModels.constraint(model, :path_scalar)[4] == 11 Test.@test CTModels.constraint(model, :boundary_scalar)[4] == 12 - Test.@test CTModels.constraint(model, :state_scalar)[4] == 13 - Test.@test CTModels.constraint(model, :control_scalar)[4] == 14 - Test.@test CTModels.constraint(model, :variable_scalar)[4] == 15 - Test.@test CTModels.constraint(model, :state_scalar_2)[4] == 16 - Test.@test CTModels.constraint(model, :control_scalar_2)[4] == 17 - Test.@test CTModels.constraint(model, :variable_scalar_2)[4] == 18 + Test.@test CTModels.constraint(model, :state_scalar)[4] == 5 # intersected with :state + Test.@test CTModels.constraint(model, :control_scalar)[4] == 7 # intersected with :control + Test.@test CTModels.constraint(model, :variable_scalar)[4] == 9 # intersected with :variable + Test.@test CTModels.constraint(model, :state_scalar_2)[4] == 6 + Test.@test CTModels.constraint(model, :control_scalar_2)[4] == 8 + Test.@test CTModels.constraint(model, :variable_scalar_2)[4] == 10 # print the premodel (captured, no terminal output) io = IOBuffer() diff --git a/test/suite/ocp/test_ocp.jl b/test/suite/ocp/test_ocp.jl index c3398e95..0499b930 100644 --- a/test/suite/ocp/test_ocp.jl +++ b/test/suite/ocp/test_ocp.jl @@ -85,26 +85,38 @@ function test_ocp() pre_constraints, :boundary, n, m, q; f=f_boundary_b, lb=[3], ub=[3] ) - # state box constraint + # state/control/variable box constraints: + # declare full-range bounds on components 1..2, then a tighter bound on + # component 2 that is consistent with the first declaration (so the + # per-component intersection is non-empty). After dedup: + # comp 1 β†’ lb=0, ub=1 (from first declaration) + # comp 2 β†’ lb=1, ub=1.5 (intersection: max(1, 1)=1, min(2, 1.5)=1.5) + + # state box CTModels.OCP.__constraint!(pre_constraints, :state, n, m, q; lb=[0, 1], ub=[1, 2]) - CTModels.OCP.__constraint!(pre_constraints, :state, n, m, q; rg=1:1, lb=[3], ub=[3]) + CTModels.OCP.__constraint!( + pre_constraints, :state, n, m, q; rg=2:2, lb=[1], ub=[1.5] + ) - # control box constraint + # control box CTModels.OCP.__constraint!(pre_constraints, :control, n, m, q; lb=[0, 1], ub=[1, 2]) CTModels.OCP.__constraint!( - pre_constraints, :control, n, m, q; rg=1:1, lb=[3], ub=[3] + pre_constraints, :control, n, m, q; rg=2:2, lb=[1], ub=[1.5] ) - # variable box constraint + # variable box CTModels.OCP.__constraint!( pre_constraints, :variable, n, m, q; lb=[0, 1], ub=[1, 2] ) CTModels.OCP.__constraint!( - pre_constraints, :variable, n, m, q; rg=1:1, lb=[3], ub=[3] + pre_constraints, :variable, n, m, q; rg=2:2, lb=[1], ub=[1.5] ) - # build constraints - constraints = CTModels.build(pre_constraints) + # build constraints (the duplicate-on-component-2 declarations above + # emit one warning each, which we silence via stderr redirection). + constraints = redirect_stderr(devnull) do + CTModels.build(pre_constraints) + end # Model definition definition = quote @@ -184,9 +196,9 @@ function test_ocp() # dimensions: path, boundary, variable (nonlinear), state, control, variable (box) Test.@test CTModels.dim_path_constraints_nl(ocp) == 3 Test.@test CTModels.dim_boundary_constraints_nl(ocp) == 3 - Test.@test CTModels.dim_state_constraints_box(ocp) == 3 - Test.@test CTModels.dim_control_constraints_box(ocp) == 3 - Test.@test CTModels.dim_variable_constraints_box(ocp) == 3 + Test.@test CTModels.dim_state_constraints_box(ocp) == 2 + Test.@test CTModels.dim_control_constraints_box(ocp) == 2 + Test.@test CTModels.dim_variable_constraints_box(ocp) == 2 # Get all constraints and test. Be careful, the order is not guaranteed. # We will check up to permutations by sorting the results. @@ -228,20 +240,20 @@ function test_ocp() boundary_cons_nl!(r, x0, xf, v) Test.@test sort(r) == sort([ra; rb]) - # state box constraints - Test.@test sort(state_cons_box_lb) == [0, 1, 3] - Test.@test sort(state_cons_box_ub) == [1, 2, 3] - Test.@test sort(state_cons_box_ind) == [1, 1, 2] + # state box constraints (2 unique components after dedup) + Test.@test sort(state_cons_box_lb) == [0, 1] + Test.@test sort(state_cons_box_ub) == [1, 1.5] + Test.@test sort(state_cons_box_ind) == [1, 2] # control box constraints - Test.@test sort(control_cons_box_lb) == [0, 1, 3] - Test.@test sort(control_cons_box_ub) == [1, 2, 3] - Test.@test sort(control_cons_box_ind) == [1, 1, 2] + Test.@test sort(control_cons_box_lb) == [0, 1] + Test.@test sort(control_cons_box_ub) == [1, 1.5] + Test.@test sort(control_cons_box_ind) == [1, 2] # variable box constraints - Test.@test sort(variable_cons_box_lb) == [0, 1, 3] - Test.@test sort(variable_cons_box_ub) == [1, 2, 3] - Test.@test sort(variable_cons_box_ind) == [1, 1, 2] + Test.@test sort(variable_cons_box_lb) == [0, 1] + Test.@test sort(variable_cons_box_ub) == [1, 1.5] + Test.@test sort(variable_cons_box_ind) == [1, 2] # -------------------------------------------------------------------------- # # ocp with fixed times diff --git a/test/suite/ocp/test_solution.jl b/test/suite/ocp/test_solution.jl index e1c8abaa..b611e566 100644 --- a/test/suite/ocp/test_solution.jl +++ b/test/suite/ocp/test_solution.jl @@ -296,8 +296,8 @@ function test_solution() ) Test.@test CTModels.dim_boundary_constraints_nl(sol_bc) == 3 # 3 boundary constraints - # Test dim_variable_constraints_box - Test.@test CTModels.dim_variable_constraints_box(sol) == 0 # no variable constraints + # Test dim_dual_variable_constraints_box + Test.@test CTModels.dim_dual_variable_constraints_box(sol) == 0 # no variable duals variable_constraints_lb_dual = [1.0, 2.0] sol_vc = CTModels.build_solution( ocp, @@ -309,10 +309,10 @@ function test_solution() kwargs..., variable_constraints_lb_dual=variable_constraints_lb_dual, ) - Test.@test CTModels.dim_variable_constraints_box(sol_vc) == 2 # 2 variable constraints + Test.@test CTModels.dim_dual_variable_constraints_box(sol_vc) == 2 # 2 variable duals - # Test dim_state_constraints_box - Test.@test CTModels.dim_state_constraints_box(sol) == 0 # no state constraints + # Test dim_dual_state_constraints_box + Test.@test CTModels.dim_dual_state_constraints_box(sol) == 0 # no state duals state_constraints_lb_dual = [1.0 2.0; 3.0 4.0; 5.0 6.0] sol_sc = CTModels.build_solution( ocp, @@ -324,10 +324,10 @@ function test_solution() kwargs..., state_constraints_lb_dual=state_constraints_lb_dual, ) - Test.@test CTModels.dim_state_constraints_box(sol_sc) == 2 # 2 state constraints (dim_x = 2) + Test.@test CTModels.dim_dual_state_constraints_box(sol_sc) == 2 # 2 state duals (dim_x = 2) - # Test dim_control_constraints_box - Test.@test CTModels.dim_control_constraints_box(sol) == 0 # no control constraints + # Test dim_dual_control_constraints_box + Test.@test CTModels.dim_dual_control_constraints_box(sol) == 0 # no control duals control_constraints_lb_dual = zeros(3, 1) control_constraints_lb_dual[:, 1] = [1.0, 2.0, 3.0] sol_cc = CTModels.build_solution( @@ -340,7 +340,7 @@ function test_solution() kwargs..., control_constraints_lb_dual=control_constraints_lb_dual, ) - Test.@test CTModels.dim_control_constraints_box(sol_cc) == 1 # 1 control constraint (dim_u = 1) + Test.@test CTModels.dim_dual_control_constraints_box(sol_cc) == 1 # 1 control dual (dim_u = 1) end Test.@testset "dual from label" begin path_constraints_dual = [1.0 2.0; 3.0 4.0; 5.0 6.0]