Types and traits

This page explains the type architecture of the CTModels OCP layer submodule, following the package tenet:

One abstract type per *noun*, one trait-parameter per *axis*

Conceptual variants ("is this a state model or a control model?") are encoded as types. Orthogonal yes/no axes ("autonomous?", "free final time?") are encoded as traits carried in a type parameter, and selected by dispatch through an extractor.

Noun families

Each noun of an OCP has one abstract supertype and a small family of concrete subtypes. The pattern is uniform: a definition type (structure only) and a solution type (structure + a numerical value), plus an empty sentinel where a component may be absent.

Abstract typeDefinitionSolutionEmpty sentinel
AbstractStateModelStateModelStateModelSolution
AbstractControlModelControlModelControlModelSolutionEmptyControlModel
AbstractVariableModelVariableModelVariableModelSolutionEmptyVariableModel
AbstractTimeModelFixedTimeModel / FreeTimeModel
AbstractObjectiveModelMayerObjectiveModel / LagrangeObjectiveModel / BolzaObjectiveModel
AbstractDefinitionDefinitionEmptyDefinition

The empty sentinel lets dispatch stay total: a control-free problem carries an EmptyControlModel rather than a nothing, so accessors like control_dimension return 0 without a special case.

using CTModels

sm  = CTModels.StateModel("x", ["x₁", "x₂"])
evm = CTModels.EmptyVariableModel()

(CTModels.dimension(sm), CTModels.name(sm), evm isa CTModels.Components.AbstractVariableModel)
(2, "x", true)

The two trait axes

Two orthogonal yes/no axes are not modelled as separate types but as traits.

Time dependence

TimeDependence has the two values Autonomous and NonAutonomous. It is carried as the first type parameter of Model, so the distinction between $\dot{x} = f(x,u)$ and $\dot{x} = f(t,x,u)$ is available at compile time. The extractor is is_autonomous:

pre = CTModels.PreModel()
CTModels.variable!(pre, 0)
CTModels.time!(pre; t0=0.0, tf=1.0)
CTModels.state!(pre, 1)
CTModels.control!(pre, 1)
CTModels.dynamics!(pre, (r, t, x, u, v) -> (r[1] = u[1]; nothing))
CTModels.objective!(pre, :min; lagrange=(t, x, u, v) -> u[1]^2)
CTModels.time_dependence!(pre; autonomous=true)
ocp = CTModels.build(pre)

CTModels.is_autonomous(ocp)
true

Time structure

Whether each end of the interval is fixed or free is the type of the corresponding AbstractTimeModel inside the TimesModel. The extractors read the structure without exposing the concrete type:

QuestionExtractor
Is $t_0$ fixed / free?has_fixed_initial_time / has_free_initial_time
Is $t_f$ fixed / free?has_fixed_final_time / has_free_final_time
(CTModels.has_fixed_initial_time(ocp), CTModels.has_fixed_final_time(ocp))
(true, true)

A FreeTimeModel stores the index into the optimisation variable $v$ where the free time lives, rather than a value — see Components.

Why traits, not twin types

Modelling "autonomous vs non-autonomous" as two unrelated Model types would duplicate every method and break as soon as a third axis appears (the combinatorial explosion of 2 × 2 × … types). Keeping each axis a trait-parameter means:

  • methods are written once on the abstract type and dispatch only where the axis matters;
  • adding an axis adds a parameter, not a new type hierarchy;
  • the public surface stays the nouns (StateModel, Model, …) and the extractors (is_autonomous, has_free_final_time), never the raw parameters.

This mirrors the ecosystem-wide design described in the package philosophy (dev/philosophy/types-traits-interfaces.md).