Replies: 1 comment
-
CTDirect.jl — Architecture Review (SOLID & Design Principles)
S — Single Responsibility Principle
|
| Principle | Location | Issue | Proposal |
|---|---|---|---|
| SRP | DOCP_data.jl |
Mixes data layout, accessors, and post-processing | Split into DOCP_data, DOCP_accessors, DOCP_solution |
| SRP | ode/common.jl |
Sparsity helpers mixed with ODE logic | Move add_nonzero_block! to sparsity_utils.jl |
| OCP | DOCP_data.jl constructor |
Adding a scheme requires modifying the constructor | Scheme registry dict populated by each scheme file |
| LSP | direct_shooting.jl |
Empty Exa builders silently break the discretizer contract | Abstract-level fallback error in AbstractDiscretizer |
| ISP | collocation.jl |
DiscretizedModel bundles all backends unconditionally |
Lazy or keyword-based builder registration (if CTSolvers API allows) |
| DIP | DOCP_data.jl |
build_OCP_solution depends on SolverCore field layout |
Extract RawNLPResult adapter struct |
| Dispatch | ode/common.jl |
Symbol-based getter bypasses multiple dispatch |
Typed singleton structs + dispatch |
| Primitives | collocation.jl, DOCP_data.jl |
Symbol carries scheme identity beyond the API boundary |
Convert symbol to type at API boundary via registry |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
CTDirect.jl — Constructive Code Review
1. Architecture & Design
1.1 Scheme dispatch via a large
if-elsechainIssue. The
DOCPconstructor contains a longif-elsechain on aSymbolto select the discretization scheme:This is the classic anti-pattern that Julia's multiple dispatch is specifically designed to avoid. Adding a new scheme requires modifying the constructor, and the mapping from symbol to type is implicit.
Proposal. Define a small trait/helper that maps a symbol to a concrete type, or better, let each scheme register itself. A clean approach is a two-argument constructor that directly accepts the scheme type:
A lookup
Dict{Symbol, Function}populated by each scheme file (or ascheme_from_symbolfunction defined inode/common.jl) would centralize the mapping and make it open for extension without touchingDOCP_data.jl.1.2
DOCPis declaredmutablebut onlyboundsneeds mutationIssue. The
DOCPstruct is declaredmutable struct, but inspection shows that onlybounds.var_l/bounds.var_uandbounds.con_l/bounds.con_uare ever mutated (via__variables_bounds!and__constraints_bounds!), and even those are mutated insideDOCPbounds, which is already a heap-allocated struct. MakingDOCPmutable forces the Julia compiler to heap-allocate it and inhibits some optimisations.Proposal. Keep
DOCPimmutable and, if mutation of the bounds really is required post-construction, makeDOCPboundsa mutable sub-struct (it effectively already is, since it holdsVectors). TheDOCPstruct itself can then bestructrather thanmutable struct.1.3 Asymmetric override of
__variables_bounds!and__initial_guessIssue. The general
__variables_bounds!(inDOCP_variables.jl) loops over time steps and callsset_control_at_time_step!.Gauss_Legendre_2_Stagewise/Gauss_Legendre_3_Stagewiseoverride this with a separate__variables_bounds!inirk_stagewise.jlbecause their control layout is different. The same asymmetry holds for__initial_guess. This creates a hidden coupling: a new scheme that has a non-standard variable layout silently falls back to the wrong general method unless the developer remembers to override it.Proposal. Make
__variables_bounds!and__initial_guessdispatch explicitly on the discretization type:The dispatch is already implicit (it works today because the override exists), but making the intent explicit—and adding a comment pointing to the contract—prevents future regressions. Consider also whether
__initial_guessshould be a method on the discretizer rather than onDOCP, since the initial guess layout is entirely determined by the scheme.1.4 Closure mutation for
exa_getterIssue. In
collocation.jl:build_exa_solutionsilently depends onbuild_exa_modelhaving been called first. This is an implicit temporal coupling that cannot be expressed in the type system and produces a runtime error if the call order is violated. The pattern is also impossible to test in isolation.Proposal. Return
exa_getterfrombuild_exa_modeland pair it with the solution explicitly:If the
DiscretizedModelAPI doesn't allow this yet, at minimum document the ordering constraint with an@assertand a clear error message.1.5
VariableStepODEis half-finished and silently excludedIssue. In
CTDirect.jlthe include is commented out:#include("ode/variable.jl")Meanwhile, the file itself contains:
These are clearly debug remnants. The scheme is listed in the user-facing error message in the
DOCPconstructor (under the valid options string), which is misleading.Proposal.
variable.jlor move it to aWIP/orext/directory with a clear comment.@warn "VariableStepODE is experimental..."if you want to expose it as an opt-in.2. Code Quality
2.1 Symbol-dispatched getter in
common.jlIssue.
getter(nlp_solution, docp; val::Symbol)is a ~80-line function that dispatches on aSymbolvalue at runtime:Problems:
occursin("_l", String(val))is fragile: any symbol that happens to contain_l(e.g.,:nl_mult) would be misrouted. The test should at least beendswith.ifbranches.ExaModelsgetter is a parallel function with a different API, leading toif isnothing(exa_getter)guards scattered throughbuild_OCP_solution.Proposal. Replace the symbol-dispatch approach with typed accessor functions:
Each can dispatch on the solver backend (ADNLPModels vs ExaModels) via a trait or a wrapper type. This is idiomatic Julia and completely eliminates the string/symbol matching.
2.2
DOCPdimsdocstring example has wrong arityIssue. The docstring for
DOCPdimsshows:But the struct has only 5 fields (
NLP_x,NLP_u,NLP_v,path_cons,boundary_cons). The example passes 6 arguments and would fail at runtime.Proposal. Fix the docstring. Consider enabling
Documenter.jldoctest execution in CI to catch this class of error automatically.2.3
Gauss_Legendre_1is marked "test only" but is publicIssue.
The struct is exported (implicitly, since
CTDirectdoes not appear to have an explicitexportlist) and accessible to users, despite the "test only" disclaimer.Proposal. Either:
_(_Gauss_Legendre_1) to signal private status, ortest/directory), orMidpointis already the canonical implementation.2.4
stepPathConstraints!offset computation is fragileIssue. In
DOCP_functions.jl:The call-site must pass
iin[1, docp.time.steps+1]. The conditioni <= docp.time.stepssilently handles the final-time edge case by not adding the dynamics block offset. This implicit behaviour is not explained and could easily be broken by a future refactor.Proposal. Add an inline comment explaining the convention, or split into two clearly named helpers:
2.5
build_OCP_solutionis too longIssue.
build_OCP_solutioninDOCP_data.jlis ~80 lines mixing:CTModels.build_solutionThis makes it hard to test each concern in isolation and hard to read.
Proposal. Extract helpers:
2.6 Magic constant
0.1in initial guessesIssue. All
__initial_guessfunctions start with:The value
0.1is not explained. Is it intended to be nonzero to avoid degenerate Jacobians? Is it arbitrary?Proposal. Extract a named constant or a configurable default:
and document the rationale in the function docstring.
3. Performance Concerns
3.1 Allocations in
integralforGenericIRKandGenericIRKStagewiseIssue. In
ode/irk.jl,integralallocates two work vectors per call:and similarly in
irk_stagewise.jl. These are called inside the NLP solver's objective evaluation, which runs at every iteration. ForN = 250steps withAD, this can add up.Proposal. Pre-allocate these in
setWorkArrayand pass them throughwork, which already exists for this purpose. TheEulerandMidpointschemes already follow this pattern.3.2 Allocation in
stepStateConstraints!forGenericIRKIssue.
The comment in the code itself says
# +++ still some allocations here. Thekil[1:dims.NLP_x]slice creates a copy. Sinceget_stagevars_at_time_stepalready returns a@view, the slicingkil[1:dims.NLP_x]is redundant and should be removed.Proposal.
3.3
integralforTrapezeevaluatesfat every point independentlyIssue. The trapeze
integralcallsf(ti, xi, ui, v)for each time step in a fresh loop, without reusing the dynamics already computed insetWorkArray. In contrast,stepStateConstraints!does use the work array. The inconsistency means the Lagrange cost evaluates the dynamics redundantly (once in the work array setup, once inintegral).Proposal. Extend the work array layout to also cache the Lagrange integrand, or have
integralread from the same work array used for the dynamics. This is already partially done forEuler—the same approach can be applied toTrapeze.4. Robustness & Testing
4.1
time_gridvalidation only checks strict monotonicityIssue. In
DOCPtime:The
return nothingafterthrowis dead code. More importantly, there is no check that the first and last elements are finite, or that the grid has at least 2 points.Proposal.
return nothing.4.2
add_nonzero_block!with symmetric flag can produce duplicate entriesIssue.
add_nonzero_block!(Is, Js, i, j; sym=true)pushes both(i, j)and(j, i). When called for diagonal blocks (wherei == j, or ranges overlap), this produces duplicate entries in the sparse pattern, whichSparseArrays.sparsewill silently sum. For a boolean pattern matrix this is harmless (1+1=2, which is truthy), but it is semantically wrong and may cause issues if the pattern is used for anything beyond boolean detection.Proposal. Add a guard:
(and the equivalent for ranges: only add the symmetric block if it doesn't overlap the original).
4.3
DirectShootinghas emptybuild_exa_model/build_exa_solutionIssue.
These silently return
nothing, which will produce confusing errors downstream if a user requests ExaModels with DirectShooting.Proposal. Add explicit
erroror@warn+return nothingwith a message:5. Documentation
5.1
DOCPtimehas an undocumentedcontrol_stepsfieldThe
DOCPtimedocstring lists fieldssteps,normalized_grid,fixed_gridbut notcontrol_steps, which was added later. Similarly the constructor example shows the 3-argument formDOCPtime(ocp, 10, nothing)but the actual signature isDOCPtime(ocp, grid_size, control_steps, time_grid).5.2 The relationship between
DirectShooting.control_stepsandDOCPtime.control_stepsis not documentedIt is not immediately obvious to a reader what "multiple controls per time step" means in the context of direct shooting, how the controls are interpolated, or how they interact with the Lagrange cost evaluation in
Midpoint. A top-level doc page (or at minimum a block comment indirect_shooting.jl) explaining the parametrisation would help contributors.5.3 No module-level docstring
CTDirect.jl(the module file) has no@docor"""..."""module docstring. Adding one would make?CTDirectuseful in the REPL.Summary Table
DOCP_data.jlif-elsescheme dispatch with a lookup table or typed dispatchDOCP_data.jlDOCPimmutable;DOCPboundsvectors are already mutableDOCP_variables.jl/irk_stagewise.jl__variables_bounds!/__initial_guessdispatch explicitcollocation.jlexa_getterode/variable.jlprintln/error; hide unfinished scheme from public APIode/common.jlgetterwith typed accessor functionsDOCP_data.jlDOCPdimsdocstring (6 args shown, 5 in struct)ode/irk.jlGauss_Legendre_1(marked "test only" but public)DOCP_functions.jlstepPathConstraints!offset logicDOCP_data.jlbuild_OCP_solutioninto smaller helpers0.1default initial guess valueode/irk.jl,ode/irk_stagewise.jlwork_xij/work_sumbkinsetWorkArrayode/irk.jlkil[1:NLP_x]slicing (already a view)ode/trapeze.jlintegralDOCP_data.jlreturn nothing; add finite/length checks on time gridode/common.jladd_nonzero_block!sym path against duplicate diagonal entriesdirect_shooting.jlerrorin emptybuild_exa_model/build_exa_solutionDOCP_data.jlDOCPtimedocstring and constructor exampledirect_shooting.jlcontrol_stepssemantics and control interpolationCTDirect.jlBeta Was this translation helpful? Give feedback.
All reactions