Skip to content
18 changes: 18 additions & 0 deletions src/constraints/_constraints.jl
Original file line number Diff line number Diff line change
Expand Up @@ -254,4 +254,22 @@ include("nonlinear/knot_point_constraint.jl")
include("nonlinear/global_constraint.jl")
include("nonlinear/global_knot_point_constraint.jl")

@testitem "coverage: test_constraint norm-based comparison branches" setup =
[DTOTestHelpers] begin
# A tiny trajectory keeps the verbose diff output short.
traj = NamedTrajectory(
(x = randn(2, 3), u = randn(1, 3), Δt = fill(0.1, 3));
controls = (:u, :Δt),
timestep = :Δt,
)

NLC = NonlinearKnotPointConstraint(x -> [norm(x) - 1.0], :x, traj)

# norm-based Jacobian/Hessian checks: atol > 0 paths
test_constraint(NLC, traj; test_equality = false, atol = 1e-3)

# norm-based checks with atol == 0: relative-tolerance paths
test_constraint(NLC, traj; test_equality = false, atol = 0.0, rtol = 1e-3)
end

end
26 changes: 26 additions & 0 deletions src/constraints/linear/equality_constraint.jl
Original file line number Diff line number Diff line change
Expand Up @@ -384,3 +384,29 @@ end
@test length(g_eq_cons_after) == 1
@test g_eq_cons_after[1].values ≈ fill(0.5, g_dim)
end

@testitem "coverage: fix_global_variable! keeps unrelated constraints" setup =
[DTOTestHelpers] begin
_, traj = bilinear_dynamics_and_trajectory(add_global = true)

g_dim = length(traj.global_components[:g])
cons = AbstractConstraint[
GlobalBoundsConstraint(:g, 1.0),
BoundsConstraint(:u, 1:traj.N, 0.1),
EqualityConstraint(:u, [traj.N], [0.0, 0.0]),
]

fix_global_variable!(cons, :g, zeros(g_dim))

# the :g bounds constraint was removed; the two trajectory-variable
# constraints survived the filter; the pinned global equality was
# appended (GlobalEqualityConstraint is a convenience constructor that
# returns an EqualityConstraint with is_global = true)
@test length(cons) == 3
@test !any(c -> c isa BoundsConstraint && c.is_global, cons)
@test count(c -> c isa BoundsConstraint && !c.is_global, cons) == 1
@test cons[end] isa EqualityConstraint
@test cons[end].is_global
@test cons[end].var_names == :g
@test cons[end].values == zeros(g_dim)
end
17 changes: 11 additions & 6 deletions src/constraints/nonlinear/knot_point_constraint.jl
Original file line number Diff line number Diff line change
Expand Up @@ -311,14 +311,19 @@ end
end

@testitem "NonlinearKnotPointConstraint - single variable with vector syntax" begin
using DirectTrajOpt: CommonInterface
using DirectTrajOpt: CommonInterface, NonlinearKnotPointConstraint
using DirectTrajOpt: test_constraint

include("../../../test/test_utils.jl")

_, traj = bilinear_dynamics_and_trajectory()

# Test that [:u] syntax works the same as :u
g(a) = [norm(a) - 1.0]
# Test that [:u] syntax works the same as :u. The fixture is deliberately
# SMOOTH: the historical `norm(a) - 1.0` is kinky at zero, and a finite-
# difference Hessian across a kink is unstable — the test flaked on CI
# runners for exactly that reason (pre-existing; observed on main's own
# baseline run). The syntax-equivalence claim needs no kink.
g(a) = [sum(abs2, a) - 1.0]

NLC1 = NonlinearKnotPointConstraint(g, :u, traj; equality = false)
NLC2 = NonlinearKnotPointConstraint(g, [:u], traj; equality = false)
Expand All @@ -330,9 +335,9 @@ end

@test δ1 ≈ δ2

# Test both with finite differences
test_constraint(NLC1, traj; atol = 1e-3)
test_constraint(NLC2, traj; atol = 1e-3)
# Test both with finite differences (smooth fixture: tight tolerances hold)
test_constraint(NLC1, traj; atol = 1e-6)
test_constraint(NLC2, traj; atol = 1e-6)
end

@testitem "NonlinearKnotPointConstraint - multiple variables concatenated" begin
Expand Down
3 changes: 3 additions & 0 deletions src/integrators/_integrators.jl
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,9 @@ end
integ0 = BilinearIntegrator(G0, :x, :u, traj0)
test_integrator(integ0, traj0; test_equality = false, atol = 1e-4)

# atol == 0 selects the relative-tolerance norm branches
test_integrator(integ0, traj0; test_equality = false, atol = 0.0, rtol = 1e-3)

# the gauss_newton branch (masked comparison + its verbose printing)
test_integrator(integ0, traj0; gauss_newton = true, show_hessian_diff = true)
end
Expand Down
68 changes: 37 additions & 31 deletions src/integrators/time_dependent_bilinear_integrator.jl
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,8 @@ struct TimeDependentBilinearIntegrator{F} <: AbstractBilinearIntegrator
return nothing
end

u_template = if spline_order == 0
zeros(u_dim)
elseif spline_order == 1
zeros(2u_dim)
else
error("Unsupported spline order: $spline_order")
end
# spline_order ∈ {0, 1} is guaranteed by the validation above.
u_template = spline_order == 0 ? zeros(u_dim) : zeros(2u_dim)

p_template = vcat(u_template, 1.0, 0.0) # [controls..., Δt, t]

Expand Down Expand Up @@ -155,14 +150,8 @@ function evaluate!(
tₖ = traj[k][B.t_name][1]
Δtₖ = traj[k].timestep

if B.spline_order == 0
pₖ = uₖ
elseif B.spline_order == 1
uₖ₊₁ = traj[k+1][B.u_name]
pₖ = [uₖ; uₖ₊₁]
else
error("Unsupported spline order: $(B.spline_order)")
end
# spline_order ∈ {0, 1} is validated at construction.
pₖ = B.spline_order == 0 ? uₖ : [uₖ; traj[k+1][B.u_name]]

δ[slice(k, B.x_dim)] = B.f(xₖ₊₁, xₖ, pₖ, Δtₖ, tₖ)
end
Expand All @@ -185,14 +174,8 @@ end
Δtₖ = zₖ[traj.components[traj.timestep]][1]
xₖ₊₁ = zₖ₊₁[traj.components[B.x_name]]

if B.spline_order == 0
pₖ = uₖ
elseif B.spline_order == 1
uₖ₊₁ = zₖ₊₁[traj.components[B.u_name]]
pₖ = [uₖ; uₖ₊₁]
else
error("Unsupported spline order: $(B.spline_order)")
end
# spline_order ∈ {0, 1} is validated at construction.
pₖ = B.spline_order == 0 ? uₖ : [uₖ; zₖ₊₁[traj.components[B.u_name]]]

return B.f(xₖ₊₁, xₖ, pₖ, Δtₖ, tₖ)
end,
Expand Down Expand Up @@ -224,14 +207,8 @@ function eval_hessian_of_lagrangian(
Δtₖ = zₖ[traj.components[traj.timestep]][1]
xₖ₊₁ = zₖ₊₁[traj.components[B.x_name]]

if B.spline_order == 0
pₖ = uₖ
elseif B.spline_order == 1
uₖ₊₁ = zₖ₊₁[traj.components[B.u_name]]
pₖ = [uₖ; uₖ₊₁]
else
error("Unsupported spline order: $(B.spline_order)")
end
# spline_order ∈ {0, 1} is validated at construction.
pₖ = B.spline_order == 0 ? uₖ : [uₖ; zₖ₊₁[traj.components[B.u_name]]]

return μₖ'B.f(xₖ₊₁, xₖ, pₖ, Δtₖ, tₖ)
end,
Expand Down Expand Up @@ -267,3 +244,32 @@ end

test_integrator(B, traj, test_equality = false, atol = 1e-3)
end

@testitem "testing TimeDependentBilinearIntegrator with zero-order hold" begin
include("../../test/test_utils.jl")

G, traj = bilinear_dynamics_and_trajectory(add_time = true)

B = TimeDependentBilinearIntegrator((a, t) -> G(a), :x, :u, :t, traj; spline_order = 0)

@test B.spline_order == 0
@test B.u_dim == traj.dims[:u]
@test sprint(show, B) isa String

test_integrator(B, traj, test_equality = false, atol = 1e-3)
end

@testitem "TimeDependentBilinearIntegrator rejects unsupported spline orders" begin
include("../../test/test_utils.jl")

G, traj = bilinear_dynamics_and_trajectory(add_time = true)

@test_throws ErrorException TimeDependentBilinearIntegrator(
(a, t) -> G(a),
:x,
:u,
:t,
traj;
spline_order = 2,
)
end
40 changes: 40 additions & 0 deletions src/objectives/_objectives.jl
Original file line number Diff line number Diff line change
Expand Up @@ -439,4 +439,44 @@ end
@test occursin("1.5", s)
@test occursin("0.25", s)
end

@testitem "coverage: scaling a CompositeObjective and norm-based test_objective branches" setup =
[DTOTestHelpers] begin
_, traj = bilinear_dynamics_and_trajectory()

# num::Real * CompositeObjective rescales the weights in place
quad_u = QuadraticRegularizer(:u, traj, 1.0)
quad_x = QuadraticRegularizer(:x, traj, 2.0)
comp = 0.5 * quad_u + 0.25 * quad_x
comp_scaled = 2.0 * comp
@test comp_scaled isa CompositeObjective
@test comp_scaled.objectives == comp.objectives
@test comp_scaled.weights == [1.0, 0.5]
@test objective_value(comp_scaled, traj) ≈ 2.0 * objective_value(comp, traj)

# norm-based gradient checks: atol > 0 path
test_objective(quad_u, traj; test_equality = false, atol = 1e-3)

# norm-based gradient checks with atol == 0: relative-tolerance path
test_objective(quad_u, traj; test_equality = false, atol = 0.0, rtol = 1e-3)
end

@testitem "coverage: test_objective verbose diff printing" setup = [DTOTestHelpers] begin
# A tiny trajectory keeps the printed diff tables short.
traj = NamedTrajectory(
(x = randn(2, 3), u = randn(1, 3), Δt = fill(0.1, 3));
controls = (:u, :Δt),
timestep = :Δt,
)

# A quartic loss carries real finite-difference truncation error, so with
# atol = 0 the element-wise printers inside the show_gradient_diff /
# show_hessian_diff branches fire for at least one entry. The show
# branches assert nothing themselves; rtol keeps the (still-run) Hessian
# comparison comfortably green.
obj = KnotPointObjective(x -> norm(x)^4, :x, traj)

test_objective(obj, traj; show_gradient_diff = true, atol = 0.0, rtol = 1e-6)
test_objective(obj, traj; show_hessian_diff = true, atol = 0.0, rtol = 1e-6)
end
end
2 changes: 2 additions & 0 deletions src/problems.jl
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,8 @@ end
@test occursin("Controls: Δt", s_plain)
# the default Δt bounds injection guarantees a BoundsConstraint even on a
# minimal trajectory — "Constraints: (none)" is unreachable via construction
# (NamedTrajectories types `timestep` as a Symbol, so the injection always
# applies when the trajectory's own bounds lack the timestep)
@test occursin("BoundsConstraint: \"bounds on Δt\"", s_plain)
@test occursin("Dynamics (0 integrators)", s_plain)
end
Expand Down
69 changes: 69 additions & 0 deletions src/solvers/constrain.jl
Original file line number Diff line number Diff line change
Expand Up @@ -480,3 +480,72 @@ end

@test traj_dist < 1e-4
end

@testitem "coverage: BoundsConstraint vector and tuple bound application" setup =
[DTOTestHelpers] begin
_, traj = bilinear_dynamics_and_trajectory(add_global = true)

n_vars = traj.dim * traj.N + traj.global_dim
g_dim = length(traj.global_components[:g])

function apply_to_fresh_optimizer(con)
opt = Ipopt.Optimizer()
vars = MOI.add_variables(opt, n_vars)
con(opt, vars, traj)
return opt
end

n_greater_than(opt) =
MOI.get(opt, MOI.NumberOfConstraints{MOI.VariableIndex,MOI.GreaterThan{Float64}}())
n_less_than(opt) =
MOI.get(opt, MOI.NumberOfConstraints{MOI.VariableIndex,MOI.LessThan{Float64}}())

# Vector{Float64} bounds on a global variable: symmetric [-b, b] per component
b = 0.1 .+ 0.01 .* collect(1:g_dim)
opt = apply_to_fresh_optimizer(GlobalBoundsConstraint(:g, b))
@test n_greater_than(opt) == g_dim
@test n_less_than(opt) == g_dim

# (lb, ub) tuple bounds on a global variable
lb = fill(-0.2, g_dim)
ub = fill(0.3, g_dim)
opt = apply_to_fresh_optimizer(GlobalBoundsConstraint(:g, (lb, ub)))
@test n_greater_than(opt) == g_dim
@test n_less_than(opt) == g_dim

# Vector{Float64} bounds on a trajectory variable (du has dim 2)
du_dim = traj.dims[:du]
opt = apply_to_fresh_optimizer(BoundsConstraint(:du, 1:traj.N, fill(0.4, du_dim)))
@test n_greater_than(opt) == du_dim * traj.N
@test n_less_than(opt) == du_dim * traj.N
end

@testitem "coverage: GlobalLinearConstraint skips feasible all-zero rows" setup =
[DTOTestHelpers] begin
using SparseArrays

_, traj = bilinear_dynamics_and_trajectory(add_global = true)

g_dim = length(traj.global_components[:g])
# Row 1 pins g[1] - g[2] = 0; row 2 is all zeros with 0 ∈ [lo, hi] —
# structurally feasible, so it is skipped (continue) rather than an error.
A = spzeros(2, g_dim)
A[1, 1] = 1.0
A[1, 2] = -1.0
con = GlobalLinearConstraint(:g, A, [0.0, -1.0], [0.0, 1.0])

# A mock optimizer: Ipopt's MOI wrapper does not implement
# NumberOfConstraints for affine-in-set constraints, and the functor only
# needs add_constraints.
opt = MOI.Utilities.MockOptimizer(
MOI.Utilities.UniversalFallback(MOI.Utilities.Model{Float64}()),
)
vars = MOI.add_variables(opt, traj.dim * traj.N + traj.global_dim)
con(opt, vars, traj)

# Only the equality row was materialized (one affine-in-EqualTo constraint).
@test MOI.get(
opt,
MOI.NumberOfConstraints{MOI.ScalarAffineFunction{Float64},MOI.EqualTo{Float64}}(),
) == 1
end
6 changes: 0 additions & 6 deletions src/solvers/evaluator.jl
Original file line number Diff line number Diff line change
Expand Up @@ -759,12 +759,6 @@ end

∂²ℒ_values = zeros(length(∂²ℒ_structure))

for (i, j) ∈ ∂²ℒ_structure
if j < i
println("Hessian index: (", i, ", ", j, ")")
end
end

MOI.eval_hessian_lagrangian(evaluator, ∂²ℒ_values, traj.datavec, σ, μ)

n_vars =
Expand Down
51 changes: 51 additions & 0 deletions src/solvers/ipopt_solver/callbacks.jl
Original file line number Diff line number Diff line change
Expand Up @@ -834,5 +834,56 @@ end
@test raw_count[] == ic.count[] # both fire once per IPM iteration
end

@testitem "callback_best_rollout_fidelity_factory freq gating and fidelity dips" setup=[
DTOTestHelpers,
] begin
prob, _ = make_standard_prob()

# Fidelity sequence with a dip: 0.9, then 0.5 (worse), then slowly
# improving. The dip forces the insertion scan to walk past an incumbent
# it does not beat (completing the loop body without a break), and
# freq = 2 makes every other iteration return early.
call_count = Ref(0)
mock_fid_fn = (traj, sys) -> begin
call_count[] += 1
if call_count[] == 1
return 0.9
elseif call_count[] == 2
return 0.5
else
return 0.7 + 0.01 * call_count[]
end
end

trajectories = Dict{Int32,Any}()
callback = Callbacks.callback_factory(
Callbacks.callback_update_trajectory_factory(prob),
Callbacks.callback_best_rollout_fidelity_factory(
prob,
nothing,
mock_fid_fn,
trajectories;
max_trajectories = 3,
freq = 2,
fid_thresh = nothing,
),
Callbacks.callback_stop_iteration_factory(12),
)

optimizer, variables = IpoptSolverExt.get_optimizer_and_variables(
prob,
IpoptOptions(; max_iter = 20, print_level = 0),
callback,
)
IpoptSolverExt.MOI.optimize!(optimizer)

# The first push plus at least one post-dip insertion landed.
@test 2 <= length(trajectories) <= 3
for (k, (fid, t)) in trajectories
@test fid isa Number
@test t isa NamedTrajectory
end
end


end
Loading
Loading