Skip to content
Merged
28 changes: 28 additions & 0 deletions BREAKING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,34 @@

This document describes breaking changes in CTModels releases and how to migrate your code.

## [Unreleased]

### A time grid with a repeated time is an error

`build_solution` raises an `IncorrectArgument` when a time grid (state, control, costate or
path) holds the same time twice ([#460](https://github.com/control-toolbox/CTModels.jl/issues/460)).
It let such a grid through before; the trajectories then took the first value at the
repeated time, and the plots drew a zero-length segment. A grid that is not increasing is
still sorted, with a warning.

**Migration**: keep each time once. To store a jump at a switching time, give its left and
right limits in a `SwitchingModel`:

```julia
# before: the switching time τ repeated in the grid
T = [0.0, 0.25, 0.5, 0.5, 0.75, 1.0]

# after: τ once, and the jump in the switching structure
T = [0.0, 0.25, 0.5, 0.75, 1.0]
sw = CTModels.SwitchingModel(; times=[0.5], state=(x_left, x_right),
costate=(p_left, p_right), control=(u_left, u_right))
sol = CTModels.build_solution(ocp, T, X, U, v, P; ..., switching=sw)
```

The other changes of this release add the switching structure and are not breaking: the
`switching` keyword defaults to `EmptySwitchingModel()`, and the ten-argument positional
`Solution` constructor is kept.

## [0.19.8] - 2026-10-14

### No Breaking Changes
Expand Down
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,48 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### ⚠️ Breaking

- **A time grid with a repeated time is an error**
([#460](https://github.com/control-toolbox/CTModels.jl/issues/460)). `build_solution`
raises an `IncorrectArgument` when a grid holds the same time twice; it let it through
before, although a trajectory takes one value at each time. A grid that is not increasing
is still sorted, with a warning. See `BREAKING.md`.

### ✨ New Features

- **Switching structure of a solution**
([#460](https://github.com/control-toolbox/CTModels.jl/issues/460)). A `Solution` made of
phases, typically from an indirect method where the structure is fixed, keeps its
switching times, a label per phase, and the left and right limits of the state, costate
and control at every switching time: `build_solution(...; switching=SwitchingModel(...))`
(default `EmptySwitchingModel()`).
- Accessors: `switching_model`, `switching_times`, `phase_labels`, `has_switching`,
`left_limit(sol, component, i)`, `right_limit(sol, component, i)` (a
`PreconditionError` when the limits of `component` were not stored).
- The trajectories stay left-continuous. Sampled (matrix) trajectories are interpolated
with the limits, so the jump is not smeared over the next grid cell.
- Display: `Switching times` and `Phases` rows in the `Solution`; `SwitchingModel` and
`EmptySwitchingModel` have their own displays.
- Plot (Plots and Makie): each jump is a vertical segment; vertical lines at the
switching times (`switching_style`, styled like the time markers by default); phase
labels on the control cell (`phase_labels = :control | :all | :none`,
`phase_labels_style`; an unlabelled phase gets no text); `jumps = :none` breaks the
curve between the two limits instead of drawing the segment. The plot data comes from the
building blocks of `CTBase.Plotting` (`splice_jumps`, `switching_vlines`,
`phase_annotations`), shared with CTFlows.
- Serialization (JLD2 and JSON): the structure is saved and read back
(`format_version` 2); files without it load with an `EmptySwitchingModel`.
- `Solution` gains an eleventh field and type parameter, `switching`; the ten-argument
positional constructor is kept and builds an `EmptySwitchingModel`.

### 📚 Documentation

- New page *Switching structure*; the `build_solution` contract, plotting and
export/import pages describe it.

## [0.19.8] - 2026-10-14

### 🔧 Fixed
Expand Down
2 changes: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ CTModelsPlots = "Plots"
[compat]
Aqua = "0.8"
BenchmarkTools = "1"
CTBase = "0.30"
CTBase = "0.30.11"
CairoMakie = "0.15"
DocStringExtensions = "0.9, 0.10"
JET = "0.9, 0.12"
Expand Down
3 changes: 2 additions & 1 deletion docs/Project.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
[deps]
CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
CTModels = "34c4fa32-2049-4079-8329-de33c2a22e2d"
CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0"
Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
DocumenterInterLinks = "d12716ef-a0f6-4df4-a9f1-a5a34e75c656"
Expand All @@ -12,7 +13,7 @@ MarkdownAST = "d0879d2d-cac2-40c8-9cee-1863dc0c7391"
Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80"

[compat]
CTBase = "0.30"
CTBase = "0.30.11"
CairoMakie = "0.15"
Documenter = "1"
DocumenterInterLinks = "1"
Expand Down
1 change: 1 addition & 0 deletions docs/api_reference.jl
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ function generate_api_reference(src_dir::String, ext_dir::String)
files=src(
joinpath("Solutions", "Solutions.jl"),
joinpath("Solutions", "solution_types.jl"),
joinpath("Solutions", "switching_model.jl"),
joinpath("Solutions", "dual_functors.jl"),
joinpath("Solutions", "build_solution.jl"),
joinpath("Solutions", "dual_model.jl"),
Expand Down
1 change: 1 addition & 0 deletions docs/make.jl
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ with_api_reference(src_dir, ext_dir) do api_pages
"Time Grids" => joinpath("solution", "time_grids.md"),
"Trajectories" => joinpath("solution", "trajectories.md"),
"Duals & Diagnostics" => joinpath("solution", "duals.md"),
"Switching Structure" => joinpath("solution", "switching.md"),
],
"Initial Guesses" => [
"Overview" => joinpath("initial_guess", "overview.md"),
Expand Down
8 changes: 8 additions & 0 deletions docs/src/serialization/export_import.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,11 @@ mechanism used when [building a solution](../solution/trajectories.md).
[`AbstractTag`](@ref CTModels.Serialization.AbstractTag). The extension methods
`export_ocp_solution(CTModels.JLD2Tag(), sol; …)` etc. are what each `ext/` file
implements; the `format=` wrapper is the public, dependency-free entry point.

## Switching structure

The switching structure of a solution (switching times, phase labels, left and right limits,
see [Switching structure](../solution/switching.md)) is saved with it, in both formats. When
the file is read back, the trajectories are rebuilt phase by phase, so they keep their jumps. A file
without switching structure, including one written by an older version, is read with an
[`EmptySwitchingModel`](@ref CTModels.Solutions.EmptySwitchingModel).
9 changes: 9 additions & 0 deletions docs/src/serialization/plotting.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,15 @@ decorations `state_bounds_style`, `control_bounds_style`, `path_bounds_style`.
Plots.plot(sol; state_style=(color=:blue,), costate_style=:none)
```

## Switching times and phase labels

A solution with a switching structure (see [Switching structure](../solution/switching.md))
draws each jump as a vertical segment, marks each switching time with a vertical line
(`switching_style`, by default like the time markers; `:none` hides them) and writes the
phase labels on the control cell (`phase_labels = :control`, `:all` or `:none`;
`phase_labels_style` for their style). With `jumps = :none`, the curve is broken between
the two limits instead of drawing the vertical segment.

## Overlaying solutions

`plot!` adds a solution to an existing figure — handy for comparing two solves on the same
Expand Down
17 changes: 16 additions & 1 deletion docs/src/solution/build_solution.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,18 @@ and serialization.
- State and costate are interpolated linearly. The control is piecewise constant by
default; pass `control_interpolation=:linear` for a control computed from the state and
costate, as in indirect methods. Control box duals follow the control interpolation.
- A grid holds each time once: a repeated time raises an `IncorrectArgument`. A grid that
is not increasing is sorted, with a warning.

## Switching structure

A solution made of phases, typically from an indirect method, passes its structure with the
`switching` keyword: a [`SwitchingModel`](@ref CTModels.Solutions.SwitchingModel) with the
switching times, the phase labels and the left/right limits of the state, costate and
control (default: an [`EmptySwitchingModel`](@ref CTModels.Solutions.EmptySwitchingModel)).
The switching times must lie strictly inside the time span, and each matrix of limits must
have one row per switching time and one column per component, or no column when the limits
are not known. See [Switching structure](switching.md).

## Dual variables

Expand Down Expand Up @@ -130,7 +142,10 @@ flow-based result typically needs; see the display examples in [Solutions](overv
- the number of columns of `X`, `U`, `P`, a path dual or a box dual does not match the
model;
- `v`, `boundary_constraints_dual` or a variable box dual has the wrong length;
- `control_interpolation` is neither `:constant` nor `:linear`.
- `control_interpolation` is neither `:constant` nor `:linear`;
- a time grid holds a repeated time;
- a switching time lies outside the time span, or the limits of the switching model have
the wrong number of columns.

```@repl bs
CTModels.build_solution(ocp, T, X, U, Float64[], P; objective=1.0, successful=true)
Expand Down
134 changes: 134 additions & 0 deletions docs/src/solution/switching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# Switching structure

```@meta
CurrentModule = CTModels
```

```@setup sw
using Plots
```

An indirect method fixes the **structure** of the solution: the times at which the control
law changes, the jumps of the state or costate at these times, the sequence of arcs
(`bang`, `singular`, `boundary`, …). A direct method does not: it returns values on a
grid. A [`Solution`](@ref CTModels.Solutions.Solution) keeps this structure in a
[`SwitchingModel`](@ref CTModels.Solutions.SwitchingModel), given to
[`build_solution`](@ref CTModels.Solutions.build_solution) with the `switching` keyword:

- the switching times, strictly inside the time span;
- one label per phase (`""` for a phase with no label);
- the left and right limits of the state, costate and control at every switching time.

The trajectories of the solution stay single-valued and **left-continuous**: at a switching
time, `state(sol)(tᵢ)` returns the value before the jump. The value after it is read with
[`right_limit`](@ref CTModels.Solutions.right_limit).

## A solution with a jump

The state below jumps by `+1` at `t = 0.5`, where the control switches from `1` to `-1`:

```@example sw
using CTModels

pre = CTModels.PreModel()
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; nothing))
CTModels.objective!(pre, :min; lagrange=(t, x, u, v) -> 0.5 * u^2)
CTModels.time_dependence!(pre; autonomous=true)
ocp = CTModels.build(pre)

τ = 0.5
T = collect(range(0.0, 1.0; length=11))
X = reshape([t <= τ ? t : t + 1 for t in T], :, 1)
U = reshape([t <= τ ? 1.0 : -1.0 for t in T], :, 1)
P = zeros(length(T), 1)

switching = CTModels.SwitchingModel(;
times=[τ],
labels=["bang+", "bang-"],
state=([τ;;], [τ + 1;;]), # (left, right), one row per switching time
costate=([0.0;;], [0.0;;]),
control=([1.0;;], [-1.0;;]),
)
sol = CTModels.build_solution(ocp, T, X, U, Float64[], P;
objective=0.0, successful=true, control_interpolation=:linear, switching=switching)
```

The display lists the switching times and the sequence of phases; an unlabelled phase shows
as `·`.

## Reading the structure

```@repl sw
CTModels.switching_times(sol)
CTModels.phase_labels(sol)
CTModels.state(sol)(τ) # left-continuous: before the jump
CTModels.right_limit(sol, :state, 1) # after the jump
CTModels.left_limit(sol, :control, 1), CTModels.right_limit(sol, :control, 1)
```

A one-dimensional component is a scalar, as everywhere in CTModels.

When the trajectories are given as matrices, the interpolation uses the limits, so that the
jump is not smeared over the grid cell that follows it:

```@repl sw
CTModels.state(sol)(0.55) # between the right limit 1.5 and x(0.6) = 1.6
```

Trajectories given as functions are used as they are.

## Plotting

Each jump is drawn as a vertical segment, from the left to the right limit; a vertical line
marks each switching time, with the look of the initial and final time markers; the phase
labels are written on the control cell:

```@example sw
plot(sol)
```

| Keyword | Values (default first) | Effect |
| --- | --- | --- |
| `switching_style` | `NamedTuple()` / a `NamedTuple` / `:none` | style of the switching-time lines, merged on the time markers' style; `:none` hides them |
| `phase_labels` | `:control` / `:all` / `:none` | write the labels on the control cell (or on the first drawn group when the control is not drawn), on every group, or nowhere |
| `phase_labels_style` | `NamedTuple()` | `color`, `fontsize`, `halign`, `valign` of the labels |
| `jumps` | `:segment` / `:none` | draw a jump as a vertical segment, or break the curve between the two limits |

```@example sw
plot(sol; switching_style=(color=:red,), phase_labels=:all)
```

The vertical segment makes a jump easy to see, and to compare with a solution whose jump
is smoothed, e.g. from a direct method. It does not mean that the trajectory takes the
values in between: with `jumps=:none`, the curve stops at the left limit and starts again
at the right limit.

```@example sw
plot(sol; jumps=:none)
```

The whole trajectory keeps one color: the phases are parts of the same curve, not
separate series. A solution drawn on top with `plot!` takes the next color, as usual.

## Saving and loading

[`export_ocp_solution`](@ref CTModels.Serialization.export_ocp_solution) writes the
switching structure with the solution (JLD2 and JSON), and
[`import_ocp_solution`](@ref CTModels.Serialization.import_ocp_solution) reads it back, so the
imported trajectories keep their jumps. A file without switching structure, including a file
written by an older version, is read with an
[`EmptySwitchingModel`](@ref CTModels.Solutions.EmptySwitchingModel).

## Time grids have no repeated time

A trajectory takes one value at each time: a time grid with a repeated time raises an
`IncorrectArgument`. A jump is stored in the switching model, not as a repeated time with two
values.

```@repl sw
CTModels.build_solution(ocp, [0.0, 0.5, 0.5, 1.0], zeros(4, 1), zeros(4, 1), Float64[],
zeros(4, 1); objective=0.0, successful=true)
```
37 changes: 36 additions & 1 deletion ext/CTModelsJSON.jl
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ const _OPTIONAL_MATRIX_FIELDS = [
"control_constraints_ub_dual",
]

"Switching limits (present when the solution holds a switching structure), converted like the other matrices."
const _SWITCHING_MATRIX_FIELDS = [first(k) for k in CTModels.Solutions._SWITCHING_LIMIT_KEYS]

"""
Convert Matrix fields to Vector{Vector} for JSON3 export.

Expand All @@ -49,6 +52,28 @@ function _convert_matrices_for_json!(blob::Dict)
blob[key] = Core.matrix2vec(blob[key], 1)
end
end

# Switching limits: one row per switching time (rows may be empty)
for key in _SWITCHING_MATRIX_FIELDS
if haskey(blob, key) && blob[key] isa Matrix
M = blob[key]
blob[key] = [Vector{Float64}(M[i, :]) for i in axes(M, 1)]
end
end
end

"""
Convert the rows of a JSON switching-limit field back to a `Matrix{Float64}` with `n` rows
(one per switching time); `0 × 0` when there is no switching time.
"""
function _json_rows_to_matrix(rows, n::Int)::Matrix{Float64}
n == 0 && return zeros(0, 0)
ncols = length(rows[1])
M = zeros(n, ncols)
for i in 1:n
M[i, :] .= Float64.(rows[i])
end
return M
end

# ============================================================================
Expand Down Expand Up @@ -185,7 +210,7 @@ including all primal and dual information, which can be read by external tools.
automatically, unless `filename` already ends with it (case-insensitively).

# Notes
The exported JSON includes the time grid, state, control, costate, objective, solver info, and all constraint duals (if available).
The exported JSON includes the time grid, state, control, costate, objective, solver info, all constraint duals (if available), and the switching structure (switching times, phase labels, left/right limits) when the solution holds one.

# Example
```julia-repl
Expand Down Expand Up @@ -344,6 +369,16 @@ function CTModels.import_ocp_solution(
"control_interpolation" => blob["control_interpolation"],
)

# Switching structure (absent when the solution holds none, and from older files)
if haskey(blob, "switching_times")
n = length(blob["switching_times"])
data["switching_times"] = Vector{Float64}(collect(blob["switching_times"]))
data["phase_labels"] = collect(String, blob["phase_labels"])
for key in _SWITCHING_MATRIX_FIELDS
data[key] = _json_rows_to_matrix(blob[key], n)
end
end

# Model signature (absent from files exported by older versions)
if haskey(blob, "model_signature")
data["model_signature"] = _to_plain(blob["model_signature"])
Expand Down
5 changes: 3 additions & 2 deletions ext/CTModelsMakie.jl
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,9 @@ Plot the components of an optimal control [`CTModels.Solutions.Solution`](@extre
Makie backend.

Same `description` and keyword arguments as the Plots-backend `plot`
(`layout`, `control`, `time`, the `*_style` / `*_bounds_style` keywords, `color`,
`size`). Returns a `Makie.Figure`.
(`layout`, `control`, `time`, the `*_style` / `*_bounds_style` keywords,
`switching_style`, `phase_labels`, `phase_labels_style`, `jumps`, `color`, `size`). Returns a
`Makie.Figure`.

# Example
```julia-repl
Expand Down
Loading
Loading