From 00636370b901458a4ecaf01b93da982074970aa9 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Sun, 18 Oct 2026 11:03:08 +0200 Subject: [PATCH 1/5] feat(Solutions): switching structure of a solution; strict time grids A Solution keeps the switching structure of a solution made of phases: switching times, phase labels, and left/right limits of the state, costate and control (`build_solution(...; switching=SwitchingModel(...))`). Sampled trajectories are interpolated with the limits; the display lists the switching times and phases; plots draw the jumps as vertical segments, with lines at the switching times and the phase labels; JLD2/JSON save and read the structure back. Breaking: a time grid with a repeated time raises an IncorrectArgument. Refs #460, control-toolbox/CTFlows.jl#443. Co-Authored-By: Claude Opus 5.5 --- BREAKING.md | 28 ++ CHANGELOG.md | 37 ++ docs/api_reference.jl | 1 + docs/make.jl | 1 + docs/src/serialization/export_import.md | 8 + docs/src/serialization/plotting.md | 8 + docs/src/solution/build_solution.md | 17 +- docs/src/solution/switching.md | 121 +++++ ext/CTModelsJSON.jl | 44 +- ext/CTModelsMakie.jl | 5 +- ext/CTModelsPlots.jl | 12 + src/PlotCase/build.jl | 90 +++- src/PlotCase/decorations.jl | 76 ++- src/PlotCase/panels.jl | 84 +++- src/PlotCase/vocabulary.jl | 27 ++ src/Serialization/reconstruction_helpers.jl | 6 + src/Solutions/Solutions.jl | 9 + src/Solutions/build_solution.jl | 193 +++++++- src/Solutions/interpolation_helpers.jl | 16 + src/Solutions/show.jl | 24 +- src/Solutions/solution_types.jl | 50 ++ src/Solutions/switching_model.jl | 453 ++++++++++++++++++ .../extensions/test_plot_switching_render.jl | 100 ++++ test/suite/plotting/test_plot_switching.jl | 267 +++++++++++ .../test_switching_serialization.jl | 174 +++++++ test/suite/solutions/test_switching.jl | 445 +++++++++++++++++ 26 files changed, 2253 insertions(+), 43 deletions(-) create mode 100644 docs/src/solution/switching.md create mode 100644 src/Solutions/switching_model.jl create mode 100644 test/suite/extensions/test_plot_switching_render.jl create mode 100644 test/suite/plotting/test_plot_switching.jl create mode 100644 test/suite/serialization/test_switching_serialization.jl create mode 100644 test/suite/solutions/test_switching.jl diff --git a/BREAKING.md b/BREAKING.md index 19d1f239..80c37208 100644 --- a/BREAKING.md +++ b/BREAKING.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 52ded616..74a5e065 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,43 @@ 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)`. + - 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. + - 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`). + - 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 diff --git a/docs/api_reference.jl b/docs/api_reference.jl index 63f6871c..4f38cbe0 100644 --- a/docs/api_reference.jl +++ b/docs/api_reference.jl @@ -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"), diff --git a/docs/make.jl b/docs/make.jl index b222b5bc..e7f54206 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -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"), diff --git a/docs/src/serialization/export_import.md b/docs/src/serialization/export_import.md index 8f397588..4362fbbf 100644 --- a/docs/src/serialization/export_import.md +++ b/docs/src/serialization/export_import.md @@ -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. On +import, 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). diff --git a/docs/src/serialization/plotting.md b/docs/src/serialization/plotting.md index 8238f807..91e97e98 100644 --- a/docs/src/serialization/plotting.md +++ b/docs/src/serialization/plotting.md @@ -75,6 +75,14 @@ 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). + ## Overlaying solutions `plot!` adds a solution to an existing figure — handy for comparing two solves on the same diff --git a/docs/src/solution/build_solution.md b/docs/src/solution/build_solution.md index 4e008e61..e1a7b510 100644 --- a/docs/src/solution/build_solution.md +++ b/docs/src/solution/build_solution.md @@ -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 @@ -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) diff --git a/docs/src/solution/switching.md b/docs/src/solution/switching.md new file mode 100644 index 00000000..18d56f65 --- /dev/null +++ b/docs/src/solution/switching.md @@ -0,0 +1,121 @@ +# 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 | + +```@example sw +plot(sol; switching_style=(color=:red,), phase_labels=:all) +``` + +## 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) +``` diff --git a/ext/CTModelsJSON.jl b/ext/CTModelsJSON.jl index 526255b3..a84a4d40 100644 --- a/ext/CTModelsJSON.jl +++ b/ext/CTModelsJSON.jl @@ -29,6 +29,16 @@ 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 = [ + "switching_state_left", + "switching_state_right", + "switching_costate_left", + "switching_costate_right", + "switching_control_left", + "switching_control_right", +] + """ Convert Matrix fields to Vector{Vector} for JSON3 export. @@ -49,6 +59,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 # ============================================================================ @@ -185,7 +217,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 @@ -344,6 +376,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"]) diff --git a/ext/CTModelsMakie.jl b/ext/CTModelsMakie.jl index 94d375fc..a1ea29ce 100644 --- a/ext/CTModelsMakie.jl +++ b/ext/CTModelsMakie.jl @@ -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`, `color`, `size`). Returns a +`Makie.Figure`. # Example ```julia-repl diff --git a/ext/CTModelsPlots.jl b/ext/CTModelsPlots.jl index 8598bb14..e23a4a9e 100644 --- a/ext/CTModelsPlots.jl +++ b/ext/CTModelsPlots.jl @@ -107,6 +107,17 @@ group/decoration: `state_style`, `costate_style`, `control_style`, `path_style`, `dual_style`, `time_style` (initial/final time markers), and the bounds decorations `state_bounds_style`, `control_bounds_style`, `path_bounds_style`. +## Switching structure +When the solution holds switching times (see `CTModels.switching_times`), each jump of +the state, costate or control is drawn as a vertical segment, from the left to the right +limit. +- `switching_style`: style of the vertical lines at the switching times; by default they + look like the initial/final time markers; `:none` hides them. +- `phase_labels::Symbol = :control`: where to write the phase labels (see + `CTModels.phase_labels`): `:control` (the control cell, or the first drawn group when + the control is not drawn), `:all` (every group) or `:none`. +- `phase_labels_style`: style of the labels (`color`, `fontsize`, `halign`, `valign`). + # Returns A `Plots.Plot`. All layout and rendering is delegated to `CTBase.Plotting`. @@ -115,6 +126,7 @@ A `Plots.Plot`. All layout and rendering is delegated to `CTBase.Plotting`. julia> plot(sol) julia> plot(sol, :state, :control; layout=:group, control=:all) julia> plot(sol; state_style=(color=:blue,), costate_style=:none) +julia> plot(sol; switching_style=(color=:red,), phase_labels=:all) ``` """ function Plots.plot(sol::CTModels.Solution, description::Symbol...; kwargs...) diff --git a/src/PlotCase/build.jl b/src/PlotCase/build.jl index fb4c0cba..46ad1c60 100644 --- a/src/PlotCase/build.jl +++ b/src/PlotCase/build.jl @@ -49,8 +49,12 @@ function _build_root( state_bounds_style::Union{NamedTuple,Symbol}, control_bounds_style::Union{NamedTuple,Symbol}, path_bounds_style::Union{NamedTuple,Symbol}, + switching_style::Union{NamedTuple,Symbol}=__plot_style(), + phase_labels::Symbol=__phase_labels(), + phase_labels_style::NamedTuple=__plot_style(), ) model = CTModels.model(sol) + _check_phase_labels(phase_labels) desc = clean(isempty(description) ? __description() : description) do_state, do_costate, do_control, do_path, do_dual = do_plot( sol, @@ -69,24 +73,62 @@ function _build_root( path_bounds_style=path_bounds_style, ) tn = _time_name(sol, time) - # Initial/final time markers are shared by every cell (both layouts). + # Initial/final time markers are shared by every cell (both layouts), and so are the + # switching-time markers, styled like the time markers by default. vlines = dec_time ? _time_vlines(sol, model, time, time_style) : Plotting.VLine[] + if switching_style !== :none + sw_style = _switching_style(time_style, switching_style) + vlines = vcat(vlines, _switching_vlines(sol, time, sw_style)) + end + # Phase labels go to the control cell (`:control`, or the first drawn group when the + # control is not drawn) or to every group (`:all`). + texts = if phase_labels === :none + Plotting.Annotation[] + else + _phase_annotations(sol, time, phase_labels_style) + end + label_group = if phase_labels === :all + :all + elseif do_control + :control + else + first( + g for (g, on) in ( + (:state, do_state), + (:costate, do_costate), + (:path, do_path), + (:dual, do_dual), + (:none, true), + ) if on + ) + end + _texts(group) = (label_group === :all || label_group === group) ? texts : Plotting.Annotation[] ncomp(p) = size(p.data, 2) - L(p; hlines=Vector{Plotting.HLine}[]) = Plotting.lower( - p; layout=layout, time=time, time_name=tn, vlines=vlines, hlines=hlines + L(p; hlines=Vector{Plotting.HLine}[], group::Symbol=:none) = Plotting.lower( + p; + layout=layout, + time=time, + time_name=tn, + vlines=vlines, + hlines=hlines, + annotations=_texts(group), ) if layout === :group # No bounds lines in :group (historical); only the time markers, via `vlines`. cells = Plotting.AbstractLayoutNode[] - do_state && push!(cells, L(_state_panel(sol, state_style))) + do_state && push!(cells, L(_state_panel(sol, state_style); group=:state)) do_costate && push!( cells, - L(_costate_panel(sol, costate_style; layout=layout, state_shown=do_state)), + L( + _costate_panel(sol, costate_style; layout=layout, state_shown=do_state); + group=:costate, + ), ) if do_control - for cp in _control_panels(sol, control, control_style, layout) - push!(cells, L(cp)) + # with `control=:all`, the labels go to the first control cell only + for (k, cp) in enumerate(_control_panels(sol, control, control_style, layout)) + push!(cells, L(cp; group=(k == 1 ? :control : :none))) end end isempty(cells) && return nothing @@ -102,10 +144,13 @@ function _build_root( else Vector{Plotting.HLine}[] end - state_col = L(sp; hlines=hl) + state_col = L(sp; hlines=hl, group=:state) end costate_col = if do_costate - L(_costate_panel(sol, costate_style; layout=layout, state_shown=do_state)) + L( + _costate_panel(sol, costate_style; layout=layout, state_shown=do_state); + group=:costate, + ) else nothing end @@ -121,7 +166,7 @@ function _build_root( else Vector{Plotting.HLine}[] end - control_col = L(cp; hlines=hl) + control_col = L(cp; hlines=hl, group=:control) end path_col = nothing if do_path @@ -131,10 +176,10 @@ function _build_root( else Vector{Plotting.HLine}[] end - path_col = L(pp; hlines=hl) + path_col = L(pp; hlines=hl, group=:path) end dual_col = if do_dual - L(_dual_panel(sol, model, dual_style; path_shown=do_path)) + L(_dual_panel(sol, model, dual_style; path_shown=do_path); group=:dual) else nothing end @@ -173,6 +218,18 @@ assembles the layout template and wraps it in a `Figure`. The `CTModelsPlots` / - `size`: figure size; `nothing` defers to the engine heuristic. - the `*_style` / `*_bounds_style` / `time_style` keywords: a `NamedTuple` of attributes or `:none` to hide the group / decoration. +- `switching_style = NamedTuple()`: style of the vertical lines at the switching times + (see [`CTModels.Solutions.switching_times`](@extref)); by default they look like the + initial/final time lines (`time_style` included); `:none` hides them. +- `phase_labels::Symbol = :control`: where to write the phase labels (see + [`CTModels.Solutions.phase_labels`](@extref)): `:control` (the control cell, or the + first drawn group when the control is not drawn), `:all` (every group) or `:none`. +- `phase_labels_style::NamedTuple = NamedTuple()`: style of the phase labels (`color`, + `fontsize`, `halign`, `valign`). + +# Throws +- [`CTBase.Exceptions.IncorrectArgument`](@extref): for an invalid `layout`, `control`, + `time` or `phase_labels`. """ function build_figure( sol::CTModels.Solution, @@ -189,6 +246,9 @@ function build_figure( state_bounds_style::Union{NamedTuple,Symbol}=__plot_style(), control_bounds_style::Union{NamedTuple,Symbol}=__plot_style(), path_bounds_style::Union{NamedTuple,Symbol}=__plot_style(), + switching_style::Union{NamedTuple,Symbol}=__plot_style(), + phase_labels::Symbol=__phase_labels(), + phase_labels_style::NamedTuple=__plot_style(), size=nothing, ) root = _build_root( @@ -206,6 +266,9 @@ function build_figure( state_bounds_style=state_bounds_style, control_bounds_style=control_bounds_style, path_bounds_style=path_bounds_style, + switching_style=switching_style, + phase_labels=phase_labels, + phase_labels_style=phase_labels_style, ) root === nothing && return nothing return Plotting.Figure(root; size=size) @@ -228,6 +291,9 @@ const _BUILD_KEYS = ( :state_bounds_style, :control_bounds_style, :path_bounds_style, + :switching_style, + :phase_labels, + :phase_labels_style, ) """ diff --git a/src/PlotCase/decorations.jl b/src/PlotCase/decorations.jl index 9bf1aa1e..657e9cb5 100644 --- a/src/PlotCase/decorations.jl +++ b/src/PlotCase/decorations.jl @@ -1,10 +1,13 @@ # ============================================================================= -# decorations.jl — reference lines (bounds and initial/final time) as IR decorations. +# decorations.jl — reference lines (bounds, initial/final and switching times) and +# phase labels as IR decorations. # # Box bounds become per-component horizontal lines (`HLine`) attached to their cell; -# the initial/final times become vertical lines (`VLine`) shared by every cell. Base -# styles reproduce the historical appearance; user `*_bounds_style` / `time_style` -# merge on top. Gating lives in `do_decorate` (vocabulary.jl). +# the initial/final times and the switching times become vertical lines (`VLine`) +# shared by every cell; the phase labels become texts (`Annotation`) centred on each +# phase. Base styles reproduce the historical appearance; user `*_bounds_style` / +# `time_style` / `switching_style` / `phase_labels_style` merge on top. Gating lives in +# `do_decorate` (vocabulary.jl). # ============================================================================= """$(TYPEDSIGNATURES) @@ -87,3 +90,68 @@ function _time_vlines(sol, model, time::Symbol, style::NamedTuple) s = merge(_TIME_STYLE, style) return Plotting.VLine[Plotting.VLine(t0; style=s), Plotting.VLine(tf; style=s)] end + +# Plotted abscissa of a time `t`: itself, or rescaled to `[0, 1]` over the state grid +# under time normalisation — the same transform `Plotting.lower` applies to the panels. +""" +$(TYPEDSIGNATURES) + +Return the plotted abscissa of time `t`: `t` itself, or `t` rescaled to `[0, 1]` over the +state time grid when `time` is `:normalize` / `:normalise` (the transform +[`CTBase.Plotting.lower`](@extref) applies to the panels). +""" +function _plotted_time(sol, t::Real, time::Symbol) + (time === :normalize || time === :normalise) || return Float64(t) + T = CTModels.time_grid(sol, :state) + t0, tf = first(T), last(T) + return tf > t0 ? (t - t0) / (tf - t0) : Float64(t - t0) +end + +""" +$(TYPEDSIGNATURES) + +Style of the switching-time lines. By default they look like the initial/final time lines: +the base time style, merged with the user `time_style` when it is a `NamedTuple`; a user +`switching_style` merges on top. +""" +function _switching_style(time_style, switching_style::NamedTuple) + base = time_style isa NamedTuple ? merge(_TIME_STYLE, time_style) : _TIME_STYLE + return merge(base, switching_style) +end + +""" +$(TYPEDSIGNATURES) + +Build the vertical lines at the switching times of `sol`, shared by every cell. Empty when +the solution has no switching time. +""" +function _switching_vlines(sol, time::Symbol, style::NamedTuple) + return Plotting.VLine[ + Plotting.VLine(_plotted_time(sol, τ, time); style=style) for + τ in CTModels.switching_times(sol) + ] +end + +""" +Default style of the phase labels: small grey text. +""" +const _PHASE_LABEL_STYLE = (color=:gray30, fontsize=9) + +""" +$(TYPEDSIGNATURES) + +Build the phase labels of `sol` as texts centred on each phase, near the top of the cell. +A phase with an empty label gets no text; a solution with no label gets none. +""" +function _phase_annotations(sol, time::Symbol, style::NamedTuple) + labels = CTModels.phase_labels(sol) + any(!isempty, labels) || return Plotting.Annotation[] + T = CTModels.time_grid(sol, :state) + bounds = vcat(first(T), CTModels.switching_times(sol), last(T)) + s = merge(_PHASE_LABEL_STYLE, style) + return Plotting.Annotation[ + Plotting.Annotation( + _plotted_time(sol, (bounds[k] + bounds[k + 1]) / 2, time), labels[k]; style=s + ) for k in eachindex(labels) if !isempty(labels[k]) + ] +end diff --git a/src/PlotCase/panels.jl b/src/PlotCase/panels.jl index 28c131c0..281d43f2 100644 --- a/src/PlotCase/panels.jl +++ b/src/PlotCase/panels.jl @@ -18,6 +18,50 @@ Uniform for scalar and vector-valued `f`. """ _sample(f, T) = Matrix{Float64}(reduce(hcat, f.(T))') +# Insert the left and right limits at the switching times into sampled data, so that a +# jump is drawn as a vertical segment. +""" +$(TYPEDSIGNATURES) + +Insert the left and right limits of `component` (`:state`, `:costate` or `:control`) at the +switching times of `sol` into the samples `(T, M)` of that component. + +At every switching time `τ`, the rows `(τ, left)` then `(τ, right)` are inserted at their +place in time, and a sample already at `τ` is replaced, so that a jump is drawn as a +vertical segment. The samples are returned unchanged when the solution has no switching +time or the limits of `component` are not known. + +# Returns +- `(T, M)`: the time grid (with each switching time twice) and the matrix of samples. +""" +function _with_jumps(sol, T::AbstractVector, M::AbstractMatrix, component::Symbol) + taus = CTModels.switching_times(sol) + isempty(taus) && return (T, M) + left, right = Solutions.limits(CTModels.switching_model(sol), component) + size(left, 2) == size(M, 2) || return (T, M) + Tn = Float64[] + rows = Vector{Vector{Float64}}() + i = 1 + for k in eachindex(T) + while i <= length(taus) && taus[i] <= T[k] + push!(Tn, taus[i], taus[i]) + push!(rows, left[i, :], right[i, :]) + i += 1 + end + # a sample exactly at a switching time is replaced by its two limits + (i > 1 && T[k] == taus[i - 1]) && continue + push!(Tn, T[k]) + push!(rows, M[k, :]) + end + while i <= length(taus) # switching times after the last sample + push!(Tn, taus[i], taus[i]) + push!(rows, left[i, :], right[i, :]) + i += 1 + end + Mn = isempty(rows) ? zeros(0, size(M, 2)) : Matrix{Float64}(reduce(hcat, rows)') + return (Tn, reshape(Mn, length(Tn), size(M, 2))) +end + # Euclidean norm of a matrix row. """ $(TYPEDSIGNATURES) @@ -42,8 +86,12 @@ $(TYPEDSIGNATURES) Build a [`CTBase.Plotting.Panel`](@extref) for the state trajectory. """ function _state_panel(sol, style::NamedTuple) - T = CTModels.time_grid(sol, :state) - M = _sample(CTModels.state(sol), T) + T, M = _with_jumps( + sol, + CTModels.time_grid(sol, :state), + _sample(CTModels.state(sol), CTModels.time_grid(sol, :state)), + :state, + ) return Plotting.Panel( T, M; title="state", labels=CTModels.state_components(sol), style=_base_style(style) ) @@ -60,8 +108,8 @@ Build a [`CTBase.Plotting.Panel`](@extref) for the costate trajectory. Labels depend on `layout` and whether the state panel is shown alongside. """ function _costate_panel(sol, style::NamedTuple; layout::Symbol, state_shown::Bool) - T = CTModels.time_grid(sol, :costate) - M = _sample(CTModels.costate(sol), T) + Tc = CTModels.time_grid(sol, :costate) + T, M = _with_jumps(sol, Tc, _sample(CTModels.costate(sol), Tc), :costate) n = size(M, 2) p_labels = "p" .* CTModels.state_components(sol) labels = (layout === :split && state_shown) ? fill("", n) : p_labels @@ -86,8 +134,8 @@ Build one or more [`CTBase.Plotting.Panel`](@extref) objects for the control tra - `Vector{Plotting.Panel}`: The panels to be assembled into the layout. """ function _control_panels(sol, control::Symbol, style::NamedTuple, layout::Symbol) - T = CTModels.time_grid(sol, :control) - U = _sample(CTModels.control(sol), T) + Tu = CTModels.time_grid(sol, :control) + T, U = _with_jumps(sol, Tu, _sample(CTModels.control(sol), Tu), :control) u_labels = CTModels.control_components(sol) u_norm_label = "‖" * CTModels.control_name(sol) * "‖" st = CTModels.control_interpolation(sol) == :constant ? :steppost : :path @@ -133,9 +181,18 @@ $(TYPEDSIGNATURES) Build a [`CTBase.Plotting.Panel`](@extref) for the nonlinear path constraints. """ function _path_panel(sol, model, style::NamedTuple) - T = CTModels.time_grid(sol, :path) - X = CTModels.state(sol).(T) - U = CTModels.control(sol).(T) + Tp = CTModels.time_grid(sol, :path) + X, U = CTModels.state(sol).(Tp), CTModels.control(sol).(Tp) + # at the switching times, evaluate the constraints at both limits of the state and + # control (when both are known) + T, MX = _with_jumps(sol, Tp, _sample(CTModels.state(sol), Tp), :state) + Tu, MU = _with_jumps(sol, Tp, _sample(CTModels.control(sol), Tp), :control) + if length(T) != length(Tp) && T == Tu + X = [_as_value(MX[k, :]) for k in axes(MX, 1)] + U = [_as_value(MU[k, :]) for k in axes(MU, 1)] + else + T = Tp + end v = CTModels.variable(sol) cp = CTModels.path_constraints_nl(model) nc = length(cp[1]) @@ -151,6 +208,15 @@ function _path_panel(sol, model, style::NamedTuple) ) end +# A sampled row as the value of a trajectory: a scalar for a one-dimensional component. +""" +$(TYPEDSIGNATURES) + +Return a sampled row as the value of a trajectory: a scalar for one component (the +"1-D is a scalar" convention), the vector otherwise. +""" +_as_value(row::AbstractVector) = length(row) == 1 ? row[1] : collect(row) + # Duals of the path constraints. As for the costate, the ylabels are dropped when the # path column is shown alongside (paired), otherwise prefixed with "dual ". """ diff --git a/src/PlotCase/vocabulary.jl b/src/PlotCase/vocabulary.jl index adca5feb..ace3482a 100644 --- a/src/PlotCase/vocabulary.jl +++ b/src/PlotCase/vocabulary.jl @@ -38,6 +38,33 @@ __plot_style() = NamedTuple() """ $(TYPEDSIGNATURES) +Return the default placement of the phase labels (`:control`). +""" +__phase_labels() = :control + +""" +$(TYPEDSIGNATURES) + +Check the placement of the phase labels: `:control`, `:all` or `:none`. + +# Throws +- [`CTBase.Exceptions.IncorrectArgument`](@extref): for any other value. +""" +function _check_phase_labels(phase_labels::Symbol) + phase_labels in (:control, :all, :none) || throw( + Exceptions.IncorrectArgument( + "Invalid phase_labels choice"; + got="phase_labels=$phase_labels", + expected=":control, :all or :none", + context="CTModels.PlotCase.build_figure", + ), + ) + return nothing +end + +""" +$(TYPEDSIGNATURES) + Return the default suffix appended to plot labels (empty string). """ __plot_label_suffix() = "" diff --git a/src/Serialization/reconstruction_helpers.jl b/src/Serialization/reconstruction_helpers.jl index 0caeb5ef..91e843ea 100644 --- a/src/Serialization/reconstruction_helpers.jl +++ b/src/Serialization/reconstruction_helpers.jl @@ -24,6 +24,8 @@ keyword argument because its deserialization is format-specific (JSON restores ` # Notes - If `time_grid_state` key exists, assumes multiple time grid format - Otherwise, uses the current unified format (single `time_grid` key) +- The switching structure is rebuilt when the data holds it (`"switching_times"`), so the + trajectories keep their jumps (see [`CTModels.Solutions.build_solution`](@extref)) # Example ```julia-repl @@ -49,6 +51,8 @@ function _reconstruct_solution_from_data(ocp, data; infos=Dict{Symbol,Any}()) variable_constraints_ub_dual = _extract_optional_vector( data["variable_constraints_ub_dual"] ) + # Switching structure: absent from files without one (and from older files) + switching = Solutions._deserialize_switching(data) # Detect format and extract time grids if haskey(data, "time_grid_state") @@ -84,6 +88,7 @@ function _reconstruct_solution_from_data(ocp, data; infos=Dict{Symbol,Any}()) variable_constraints_ub_dual=variable_constraints_ub_dual, infos=infos, control_interpolation=control_interpolation, + switching=switching, ) else # Current unified format: single time grid @@ -121,6 +126,7 @@ function _reconstruct_solution_from_data(ocp, data; infos=Dict{Symbol,Any}()) variable_constraints_ub_dual=variable_constraints_ub_dual, infos=infos, control_interpolation=control_interpolation, + switching=switching, ) end end diff --git a/src/Solutions/Solutions.jl b/src/Solutions/Solutions.jl index 70e9d70c..6e7fe2e0 100644 --- a/src/Solutions/Solutions.jl +++ b/src/Solutions/Solutions.jl @@ -8,6 +8,8 @@ and all solution accessors (trajectories, duals, solver metadata). # Organisation - **solution_types.jl**: `Solution`, `AbstractSolution`, time-grid types, solver infos. +- **switching_model.jl**: `SwitchingModel` (switching times, phase labels, left/right + limits), its accessors, and the jump-aware interpolation of sampled data. - **dual_model.jl**: `DualModel`, `AbstractDualModel`, and dual accessors. - **discretization_utils.jl**: time-grid helpers used by `build_solution`. - **interpolation_helpers.jl**: trajectory interpolation helpers. @@ -21,12 +23,15 @@ Exported types: - `AbstractTimeGridModel`, `UnifiedTimeGridModel`, `MultipleTimeGridModel`, `EmptyTimeGridModel` - `AbstractDualModel`, `DualModel` - `AbstractSolverInfos`, `SolverInfos` +- `AbstractSwitchingModel`, `SwitchingModel`, `EmptySwitchingModel` Exported functions: - `build_solution`: Main constructor for solutions - `clean_component_symbols`: Clean and validate component symbols - `is_empty`: Check if time grid is empty - `time_grid_model`: Get time grid model from solution +- `switching_model`, `switching_times`, `phase_labels`, `has_switching`: Switching structure +- `left_limit`, `right_limit`: Limits of a trajectory at a switching time - `dual`: Get dual by label - `path_constraints_dual`, `boundary_constraints_dual`: Dual accessors - `state_constraints_lb_dual`, `state_constraints_ub_dual`: State dual accessors @@ -70,6 +75,7 @@ using ..Building: __filename_export_import include(joinpath(@__DIR__, "solution_types.jl")) +include(joinpath(@__DIR__, "switching_model.jl")) include(joinpath(@__DIR__, "dual_functors.jl")) include(joinpath(@__DIR__, "dual_model.jl")) include(joinpath(@__DIR__, "discretization_utils.jl")) @@ -83,6 +89,9 @@ export DualModel, AbstractDualModel, EmptyDualModel, has_duals export SolverInfos, AbstractSolverInfos export TimeGridModel, AbstractTimeGridModel, EmptyTimeGridModel export UnifiedTimeGridModel, MultipleTimeGridModel +export AbstractSwitchingModel, SwitchingModel, EmptySwitchingModel +export switching_model, switching_times, phase_labels, has_switching +export left_limit, right_limit # build_solution constructor export build_solution diff --git a/src/Solutions/build_solution.jl b/src/Solutions/build_solution.jl index 73992737..568806f8 100644 --- a/src/Solutions/build_solution.jl +++ b/src/Solutions/build_solution.jl @@ -84,6 +84,9 @@ All dual variable arguments default to `nothing` if not provided: - `variable_constraints_lb_dual::Union{Vector{Float64},Nothing}`: Variable lower bound duals (time-independent) - `variable_constraints_ub_dual::Union{Vector{Float64},Nothing}`: Variable upper bound duals (time-independent) - `infos::Dict{Symbol,Any}`: Additional solver-specific information (default: empty dict) +- `control_interpolation::Symbol`: `:constant` (direct methods) or `:linear` (indirect methods) +- `switching::AbstractSwitchingModel`: Switching structure (default: `EmptySwitchingModel()`), + see the Switching Structure note below # Returns @@ -153,8 +156,22 @@ and emits a warning; the component has a single dual, shared by these labels. ## Grid Validation -Each time grid is expected to be increasing. A grid that is not is sorted, and a warning -is emitted. +Each time grid is expected to be strictly increasing. A grid that is not increasing is +sorted, and a warning is emitted. A grid with a repeated time raises an +`IncorrectArgument`: a trajectory has one value at each time. + +## Switching Structure + +The keyword `switching::AbstractSwitchingModel` (default `EmptySwitchingModel()`) stores +the switching structure of a solution made of phases: the switching times, a label per +phase, and the left/right limits of the state, costate and control at every switching time +(see [`CTModels.Solutions.SwitchingModel`](@extref)). The switching times must lie strictly +inside the time span, and the limits must have one column per component (or none). + +The trajectories stay left-continuous at a switching time. When a trajectory is given as a +matrix, its interpolation uses the limits, so that it keeps its jumps: it joins the last +grid point before a switching time to the left limit, and the right limit to the first grid +point after it. A trajectory given as a function is used as is. ## Automatic Grid Extension @@ -210,6 +227,7 @@ function build_solution( variable_constraints_ub_dual::Union{Vector{Float64},Nothing}=__constraints(), infos::Dict{Symbol,Any}=Dict{Symbol,Any}(), control_interpolation::Symbol=__control_interpolation(), + switching::AbstractSwitchingModel=EmptySwitchingModel(), ) where { TX<:Union{Matrix{Float64},Function}, TU<:Union{Matrix{Float64},Function}, @@ -275,16 +293,34 @@ function build_solution( ) end + # Switching structure: consistent with the grids and the dimensions of the problem + _check_switching(switching, T_state, dim_x, dim_u) + # Build interpolated functions for state, control, and costate # Using unified API with validation and deepcopy+scalar wrapping # Note: costate uses its own grid (T_costate) # Note: control uses configurable interpolation (constant for direct methods, linear for indirect methods) - fx = build_interpolated_function(X, T_state, dim_x, TX; expected_dim=dim_x) + # Note: sampled data keeps its jumps at the switching times (`jumps`) + fx = build_interpolated_function( + X, T_state, dim_x, TX; expected_dim=dim_x, jumps=(switching, :state) + ) fu = build_interpolated_function( - U, T_control, dim_u, TU; expected_dim=dim_u, interpolation=control_interpolation + U, + T_control, + dim_u, + TU; + expected_dim=dim_u, + interpolation=control_interpolation, + jumps=(switching, :control), ) fp = build_interpolated_function( - P, T_costate, dim_x, TP; constant_if_two_points=true, expected_dim=dim_x + P, + T_costate, + dim_x, + TP; + constant_if_two_points=true, + expected_dim=dim_x, + jumps=(switching, :costate), ) Core.@ensure length(v) == dim_v Exceptions.IncorrectArgument( "variable length mismatch"; @@ -450,12 +486,54 @@ function build_solution( objective, dual, solver_infos, + switching, ) end """ $(TYPEDSIGNATURES) +Check that a switching model is consistent with a solution: its switching times lie strictly +inside the time span of the state grid `T`, and its stored limits have one column per +component (`dim_x` for the state and costate, `dim_u` for the control) or none (limits not +known). + +# Throws +- [`CTBase.Exceptions.IncorrectArgument`](@extref): if a switching time is outside + `(first(T), last(T))`, or a matrix of limits has a wrong number of columns. +""" +function _check_switching(::EmptySwitchingModel, T, dim_x, dim_u) + return nothing +end +function _check_switching(sw::SwitchingModel, T::Vector{Float64}, dim_x::Int, dim_u::Int) + t0, tf = first(T), last(T) + for τ in switching_times(sw) + t0 < τ < tf || throw( + Exceptions.IncorrectArgument( + "switching time outside the time span"; + got="switching time $τ", + expected="a time in ($t0, $tf), strictly inside the time grid", + context="build_solution - validating the switching model", + ), + ) + end + for (component, dim) in ((:state, dim_x), (:costate, dim_x), (:control, dim_u)) + ncols = size(limits(sw, component)[1], 2) + ncols in (0, dim) || throw( + Exceptions.IncorrectArgument( + "wrong dimension of the $component limits"; + got="$ncols column(s)", + expected="$dim columns (one per $component component), or 0 if not known", + context="build_solution - validating the switching model", + ), + ) + end + return nothing +end + +""" +$(TYPEDSIGNATURES) + Validate and fix a time grid by ensuring it is strictly increasing. # Arguments @@ -465,10 +543,19 @@ Validate and fix a time grid by ensuring it is strictly increasing. # Returns - `Vector{Float64}`: Validated and potentially reordered time grid +# Throws +- [`CTBase.Exceptions.IncorrectArgument`](@extref): If the grid holds a repeated time. A + trajectory takes one value at each time; a jump at a switching time is stored in the + switching model instead (see [`CTModels.Solutions.SwitchingModel`](@extref)). + # Notes -If the grid is not strictly increasing, it is reordered and a warning is emitted. +If the grid is not increasing, it is reordered and a warning is emitted. """ function _validate_and_fix_time_grid(T::Vector{Float64}, component_name::String) + for i in 1:(length(T) - 1) + # after sorting, a repeated time would be adjacent: check on the sorted grid + T[i] == T[i + 1] && _throw_repeated_time(T[i], component_name) + end if !issorted(T; lt=(<)) # Build appropriate message based on component name components_with_issues = [component_name] # TODO: Collect all components when called multiple times @@ -480,7 +567,11 @@ function _validate_and_fix_time_grid(T::Vector{Float64}, component_name::String) end @warn msg - return sort(T) + Ts = sort(T) + for i in 1:(length(Ts) - 1) + Ts[i] == Ts[i + 1] && _throw_repeated_time(Ts[i], component_name) + end + return Ts end return T end @@ -488,6 +579,27 @@ end """ $(TYPEDSIGNATURES) +Throw the error of a time grid with a repeated time. + +# Throws +- [`CTBase.Exceptions.IncorrectArgument`](@extref): always. +""" +function _throw_repeated_time(t::Float64, component_name::String) + throw( + Exceptions.IncorrectArgument( + "repeated time in the $component_name time grid"; + got="time $t appears more than once", + expected="a strictly increasing time grid (each time once)", + suggestion="Keep each time once. To store a jump at a switching time, give " * + "the left and right limits in `switching=SwitchingModel(...)`.", + context="build_solution - validating the $component_name time grid", + ), + ) +end + +""" +$(TYPEDSIGNATURES) + Extend a target time grid to match a reference grid if the target is a strict prefix. This function checks if `T_target` is missing only the last element of `T_reference` @@ -548,6 +660,8 @@ Build a solution from the optimal control problem, the time grid, the state, con - `variable_constraints_lb_dual::Vector{Float64}`: the lower bound dual of the variable constraints. - `variable_constraints_ub_dual::Vector{Float64}`: the upper bound dual of the variable constraints. - `infos::Dict{Symbol,Any}`: additional solver information dictionary. +- `control_interpolation::Symbol`: `:constant` (direct methods) or `:linear` (indirect methods). +- `switching::AbstractSwitchingModel`: the switching structure (default: `EmptySwitchingModel()`). # Returns @@ -585,6 +699,7 @@ function build_solution( variable_constraints_ub_dual::Union{Vector{Float64},Nothing}=__constraints(), infos::Dict{Symbol,Any}=Dict{Symbol,Any}(), control_interpolation::Symbol=__control_interpolation(), + switching::AbstractSwitchingModel=EmptySwitchingModel(), ) where { TX<:Union{Matrix{Float64},Function}, TU<:Union{Matrix{Float64},Function}, @@ -618,6 +733,7 @@ function build_solution( variable_constraints_ub_dual=variable_constraints_ub_dual, infos=infos, control_interpolation=control_interpolation, + switching=switching, ) end @@ -1600,8 +1716,64 @@ Version of the serialized solution format, stored under the `"format_version"` k Files written before the key existed (no `"format_version"`, no `"model_signature"`) are still readable, with a reduced compatibility check against the model. + +Version history: +- `1`: model signature, for the compatibility check at import time. +- `2`: optional switching structure (`"switching_times"`, `"phase_labels"` and the + `"switching__"` limits), written only for a solution that holds one; + files without these keys are read with an [`CTModels.Solutions.EmptySwitchingModel`](@extref). """ -const SERIALIZATION_FORMAT_VERSION = 1 +const SERIALIZATION_FORMAT_VERSION = 2 + +""" +Keys of the serialized switching limits, as `(key, component, side)` with `side = 1` for +the left limit and `2` for the right limit. +""" +const _SWITCHING_LIMIT_KEYS = Tuple( + ("switching_$(c)_$(side_name)", c, side) for c in (:state, :costate, :control) for + (side, side_name) in ((1, "left"), (2, "right")) +) + +""" +$(TYPEDSIGNATURES) + +Add the switching structure of a solution to its serialized data: nothing for an +[`CTModels.Solutions.EmptySwitchingModel`](@extref); the switching times, the phase labels and +the six matrices of limits for a [`CTModels.Solutions.SwitchingModel`](@extref). +""" +_serialize_switching!(data::Dict{String,Any}, ::EmptySwitchingModel) = data +function _serialize_switching!(data::Dict{String,Any}, sw::SwitchingModel) + data["switching_times"] = copy(switching_times(sw)) + data["phase_labels"] = copy(phase_labels(sw)) + for (key, component, side) in _SWITCHING_LIMIT_KEYS + data[key] = copy(limits(sw, component)[side]) + end + return data +end + +""" +$(TYPEDSIGNATURES) + +Rebuild the switching model from serialized data: an +[`CTModels.Solutions.EmptySwitchingModel`](@extref) when the data holds no `"switching_times"` +(e.g. a file written by an older version), a [`CTModels.Solutions.SwitchingModel`](@extref) +otherwise. The limits are matrices with one row per switching time. +""" +function _deserialize_switching(data)::AbstractSwitchingModel + haskey(data, "switching_times") || return EmptySwitchingModel() + times = Vector{Float64}(collect(data["switching_times"])) + labels = collect(String, data["phase_labels"]) + lim(c, side) = Matrix{Float64}( + data[first(k for (k, cc, ss) in _SWITCHING_LIMIT_KEYS if cc === c && ss == side)] + ) + return SwitchingModel(; + times=times, + labels=labels, + state=(lim(:state, 1), lim(:state, 2)), + costate=(lim(:costate, 1), lim(:costate, 2)), + control=(lim(:control, 1), lim(:control, 2)), + ) +end """ $(TYPEDSIGNATURES) @@ -1739,8 +1911,9 @@ function _serialize_solution(sol::Solution)::Dict{String,Any} dim_x = state_dimension(sol) dim_u = control_dimension(sol) - # Dispatch based on time grid model type - return _serialize_solution(time_grid_model(sol), sol, dim_x, dim_u) + # Dispatch based on time grid model type, then add the switching structure (if any) + data = _serialize_solution(time_grid_model(sol), sol, dim_x, dim_u) + return _serialize_switching!(data, switching_model(sol)) end """ diff --git a/src/Solutions/interpolation_helpers.jl b/src/Solutions/interpolation_helpers.jl index 5f5a3e28..444ba3f1 100644 --- a/src/Solutions/interpolation_helpers.jl +++ b/src/Solutions/interpolation_helpers.jl @@ -164,6 +164,9 @@ This is the main entry point that combines interpolation and wrapping in one cal - `constant_if_two_points`: Return constant function if length(T)==2 (for costate) - `expected_dim`: Validate matrix has this dimension (for robustness) - `interpolation`: Interpolation type (`:linear` or `:constant`) +- `jumps`: `nothing`, or `(switching, component)` to keep the jumps of `component` stored in + the switching model when the data is a matrix (see + [`CTModels.Solutions._JumpInterpolant`](@extref)); function data is left as is. # Returns - Wrapped interpolated function ready for use in Solution @@ -202,6 +205,7 @@ function build_interpolated_function( constant_if_two_points::Bool=false, expected_dim::Union{Int,Nothing}=nothing, interpolation::Symbol=:linear, + jumps::Union{Nothing,Tuple{AbstractSwitchingModel,Symbol}}=nothing, ) # Handle T=nothing case if isnothing(T) @@ -233,6 +237,18 @@ function build_interpolated_function( interpolation=interpolation, ) + # Step 1b: keep the jumps at the switching times of sampled data + if !isnothing(jumps) && data isa AbstractMatrix + sw, component = jumps + func = _with_jumps( + func, + T[1:size(data, 1)], + sw, + component; + constant=(interpolation === :constant), + ) + end + # Step 2: Wrap with deepcopy and scalar extraction return _wrap_scalar_and_deepcopy(func, dim) end diff --git a/src/Solutions/show.jl b/src/Solutions/show.jl index e3eb432e..53975ab9 100644 --- a/src/Solutions/show.jl +++ b/src/Solutions/show.jl @@ -7,9 +7,11 @@ $(TYPEDSIGNATURES) Print the solution to `io` with semantic ANSI formatting. -Displays the objective, the optimisation variable (if defined), boundary constraint -duals (if present), and solver metadata (iterations, status, message, constraints -violation) — each field only when it has been provided (not `NotProvided`). +Displays the objective, the switching times (if any), the sequence of phase labels (if +at least one phase is labelled; an unlabelled phase shows as `·`), the optimisation +variable (if defined), boundary constraint duals (if present), and solver metadata +(iterations, status, message, constraints violation) — each field only when it has been +provided (not `NotProvided`). The display is a single tree rooted at "Solution": - `│ label : value` for interior rows @@ -52,12 +54,20 @@ function Base.show(io::IO, ::MIME"text/plain", sol::Solution) Solutions.dim_dual_variable_constraints_box(sol) > 0 && Components.dim_variable_constraints_box(Solutions.model(sol)) > 0 + # ── Switching structure (optional) ─────────────────────────────────────── + _sw_times = Solutions.switching_times(sol) + _labels = Solutions.phase_labels(sol) + has_sw = !isempty(_sw_times) + has_phases = any(!isempty, _labels) + has_solver_meta = has_iter || has_status || has_msg || has_cv - has_primal_extra = has_var || has_bd + has_primal_extra = has_var || has_bd || has_sw || has_phases # ── Build ordered list of top-level item tags ──────────────────────────── tags = Symbol[] push!(tags, :obj) + has_sw && push!(tags, :sw) + has_phases && push!(tags, :phases) has_var && push!(tags, :var) has_bd && push!(tags, :bd) has_iter && push!(tags, :iter) @@ -90,6 +100,12 @@ function Base.show(io::IO, ::MIME"text/plain", sol::Solution) # ── Objective ──────────────────────────────────────────────────────────── _row(conn(:obj), "Objective", string(Components.objective(sol))) + # ── Switching times and phases (optional) ──────────────────────────────── + has_sw && _row(conn(:sw), "Switching times", string(_sw_times)) + if has_phases + _row(conn(:phases), "Phases", join((isempty(l) ? "·" : l for l in _labels), " → ")) + end + # ── Variable (optional) ────────────────────────────────────────────────── if has_var dim_v = Models.variable_dimension(sol) diff --git a/src/Solutions/solution_types.jl b/src/Solutions/solution_types.jl index e3742bb2..93d9c4b0 100644 --- a/src/Solutions/solution_types.jl +++ b/src/Solutions/solution_types.jl @@ -377,6 +377,19 @@ true """ has_duals(model::AbstractDualModel)::Bool = true +# ------------------------------------------------------------------------------ # +# Switching structure +# ------------------------------------------------------------------------------ # +""" +$(TYPEDEF) + +Abstract base type for the switching structure of a solution: switching times, phase +labels, and left/right limits of the trajectories at every switching time. + +See also: [`CTModels.Solutions.SwitchingModel`](@extref), [`CTModels.Solutions.EmptySwitchingModel`](@extref). +""" +abstract type AbstractSwitchingModel end + # ------------------------------------------------------------------------------ # # Solution # ------------------------------------------------------------------------------ # @@ -412,6 +425,8 @@ variable value, objective value, dual variables, and solver information. - `objective::ObjectiveValueType`: Optimal objective value. - `dual::DualModelType`: Dual variables for all constraints. - `solver_infos::SolverInfosType`: Solver statistics and status. +- `switching::SwitchingModelType`: Switching structure (switching times, phase labels, + left/right limits); an [`CTModels.Solutions.EmptySwitchingModel`](@extref) when none. # Example @@ -434,6 +449,7 @@ struct Solution{ ObjectiveValueType<:ctNumber, DualModelType<:AbstractDualModel, SolverInfosType<:AbstractSolverInfos, + SwitchingModelType<:AbstractSwitchingModel, } <: AbstractSolution time_grid::TimeGridModelType times::TimesModelType @@ -445,6 +461,40 @@ struct Solution{ objective::ObjectiveValueType dual::DualModelType solver_infos::SolverInfosType + switching::SwitchingModelType +end + +""" +$(TYPEDSIGNATURES) + +Build a [`CTModels.Solutions.Solution`](@extref) with no switching structure +([`CTModels.Solutions.EmptySwitchingModel`](@extref)), from its first ten fields. +""" +function Solution( + time_grid::AbstractTimeGridModel, + times::AbstractTimesModel, + state::AbstractStateModel, + control::AbstractControlModel, + variable::AbstractVariableModel, + model::AbstractModel, + costate::Function, + objective::ctNumber, + dual::AbstractDualModel, + solver_infos::AbstractSolverInfos, +) + return Solution( + time_grid, + times, + state, + control, + variable, + model, + costate, + objective, + dual, + solver_infos, + EmptySwitchingModel(), + ) end """ diff --git a/src/Solutions/switching_model.jl b/src/Solutions/switching_model.jl new file mode 100644 index 00000000..097e79b7 --- /dev/null +++ b/src/Solutions/switching_model.jl @@ -0,0 +1,453 @@ +# ------------------------------------------------------------------------------ # +# Switching structure of a solution +# ------------------------------------------------------------------------------ # +# +# An indirect method fixes the structure of the solution: the switching times, the jumps +# of the state or costate, the changes of control law. `SwitchingModel` keeps it. The +# trajectories of a `Solution` stay single-valued (left-continuous at a switching time); +# the left and right limits at every switching time are stored here, so that plots draw +# vertical jumps and a solution rebuilt from sampled data keeps its jumps. + +""" +$(TYPEDEF) + +Switching structure of a solution with no switching information: no switching time and no +phase label. This is the default of [`CTModels.Solutions.build_solution`](@extref). + +See also: [`CTModels.Solutions.SwitchingModel`](@extref). +""" +struct EmptySwitchingModel <: AbstractSwitchingModel end + +""" +$(TYPEDEF) + +Switching structure of a solution made of consecutive phases, typically computed by an +indirect method where the structure is fixed: the switching times, a label per phase, and +the left and right limits of the state, costate and control at every switching time. + +The trajectories of the solution are left-continuous: at a switching time `tᵢ`, they +return the left limit. The right limit, after a jump of the state or costate or a change +of control law, is stored here. + +Build it with the keyword constructor, which checks the shapes. + +# Fields + +- `times::Vector{Float64}`: the switching times, strictly increasing (may be empty). +- `labels::Vector{String}`: one label per phase (`length(times) + 1`); `""` for no label. +- `state::NTuple{2,Matrix{Float64}}`: `(left, right)` limits of the state, one row per + switching time, one column per component. +- `costate::NTuple{2,Matrix{Float64}}`: `(left, right)` limits of the costate. +- `control::NTuple{2,Matrix{Float64}}`: `(left, right)` limits of the control. + +# Example + +```julia-repl +julia> using CTModels + +julia> sw = CTModels.SwitchingModel(; + times=[0.5], + labels=["bang", "singular"], + state=([1.0;;], [2.0;;]), + costate=([0.0;;], [0.0;;]), + control=([1.0;;], [0.5;;]), + ); + +julia> CTModels.switching_times(sw) +1-element Vector{Float64}: + 0.5 +``` + +See also: [`CTModels.Solutions.EmptySwitchingModel`](@extref), [`CTModels.Solutions.switching_times`](@extref), +[`CTModels.Solutions.phase_labels`](@extref), [`CTModels.Solutions.left_limit`](@extref), +[`CTModels.Solutions.right_limit`](@extref). +""" +struct SwitchingModel <: AbstractSwitchingModel + times::Vector{Float64} + labels::Vector{String} + state::NTuple{2,Matrix{Float64}} + costate::NTuple{2,Matrix{Float64}} + control::NTuple{2,Matrix{Float64}} +end + +""" +$(TYPEDSIGNATURES) + +Build a [`CTModels.Solutions.SwitchingModel`](@extref) and check its shapes. + +# Keyword Arguments + +- `times::AbstractVector{<:Real}`: the switching times, strictly increasing. +- `labels::AbstractVector{<:AbstractString}`: one label per phase; default: no label + (`""` for each of the `length(times) + 1` phases). +- `state`, `costate`, `control`: `(left, right)` pairs of matrices with one row per + switching time. Default: matrices with no column, for a solution whose limits are not + known (e.g. a single phase, with no switching time). + +# Throws + +- [`CTBase.Exceptions.IncorrectArgument`](@extref): if the switching times are not strictly + increasing or not finite, if the number of labels is not `length(times) + 1`, or if a + matrix does not have one row per switching time, or its left and right matrices differ in + size. +""" +function SwitchingModel(; + times::AbstractVector{<:Real}, + labels::AbstractVector{<:AbstractString}=fill("", length(times) + 1), + state::Tuple{AbstractMatrix{<:Real},AbstractMatrix{<:Real}}=_no_limits(length(times)), + costate::Tuple{AbstractMatrix{<:Real},AbstractMatrix{<:Real}}=_no_limits( + length(times) + ), + control::Tuple{AbstractMatrix{<:Real},AbstractMatrix{<:Real}}=_no_limits( + length(times) + ), +) + ts = collect(Float64, times) + all(isfinite, ts) || throw( + Exceptions.IncorrectArgument( + "switching times must be finite"; + got="times=$ts", + expected="finite times", + context="SwitchingModel", + ), + ) + all(i -> ts[i] < ts[i + 1], 1:(length(ts) - 1)) || throw( + Exceptions.IncorrectArgument( + "switching times must be strictly increasing"; + got="times=$ts", + expected="t₁ < t₂ < … (no repeated time)", + suggestion="Merge the phases that start at the same time, or sort the times", + context="SwitchingModel", + ), + ) + length(labels) == length(ts) + 1 || throw( + Exceptions.IncorrectArgument( + "one label per phase"; + got="$(length(labels)) label(s) for $(length(ts) + 1) phase(s)", + expected="$(length(ts) + 1) labels (length(times) + 1); use \"\" for no label", + context="SwitchingModel", + ), + ) + return SwitchingModel( + ts, + collect(String, labels), + _checked_limits(state, length(ts), "state"), + _checked_limits(costate, length(ts), "costate"), + _checked_limits(control, length(ts), "control"), + ) +end + +""" +$(TYPEDSIGNATURES) + +Default `(left, right)` limits: two matrices with `n` rows and no column (limits not known). +""" +_no_limits(n::Integer) = (zeros(n, 0), zeros(n, 0)) + +""" +$(TYPEDSIGNATURES) + +Check a `(left, right)` pair of limits for `n` switching times and convert it to +`NTuple{2,Matrix{Float64}}`. + +# Throws + +- [`CTBase.Exceptions.IncorrectArgument`](@extref): if a matrix does not have `n` rows, or the + two matrices differ in size. +""" +function _checked_limits(lr, n::Integer, name::String) + left, right = lr + size(left, 1) == n && size(right, 1) == n || throw( + Exceptions.IncorrectArgument( + "one row of $name limits per switching time"; + got="$(size(left, 1)) and $(size(right, 1)) rows (left, right)", + expected="$n rows (one per switching time)", + context="SwitchingModel - $name limits", + ), + ) + size(left) == size(right) || throw( + Exceptions.IncorrectArgument( + "left and right $name limits must have the same size"; + got="left $(size(left)), right $(size(right))", + expected="two matrices of the same size", + context="SwitchingModel - $name limits", + ), + ) + return (Matrix{Float64}(left), Matrix{Float64}(right)) +end + +# ------------------------------------------------------------------------------ # +# Accessors on the switching model +# ------------------------------------------------------------------------------ # + +""" +$(TYPEDSIGNATURES) + +Return the switching times: empty for an [`CTModels.Solutions.EmptySwitchingModel`](@extref). +""" +switching_times(::EmptySwitchingModel)::Vector{Float64} = Float64[] + +""" +$(TYPEDSIGNATURES) + +Return the switching times, strictly increasing. +""" +switching_times(m::SwitchingModel)::Vector{Float64} = m.times + +""" +$(TYPEDSIGNATURES) + +Return the phase labels: empty for an [`CTModels.Solutions.EmptySwitchingModel`](@extref). +""" +phase_labels(::EmptySwitchingModel)::Vector{String} = String[] + +""" +$(TYPEDSIGNATURES) + +Return the phase labels, one per phase (`""` for a phase with no label). +""" +phase_labels(m::SwitchingModel)::Vector{String} = m.labels + +""" +$(TYPEDSIGNATURES) + +Return whether the model holds at least one switching time. +""" +has_switching(m::AbstractSwitchingModel)::Bool = !isempty(switching_times(m)) + +""" +$(TYPEDSIGNATURES) + +Return the `(left, right)` matrices of limits of `component` (`:state`, `:costate` or +`:control`), one row per switching time. + +# Throws + +- [`CTBase.Exceptions.IncorrectArgument`](@extref): if `component` is not one of these. +""" +function limits(m::SwitchingModel, component::Symbol)::NTuple{2,Matrix{Float64}} + component === :state && return m.state + component === :costate && return m.costate + component === :control && return m.control + throw( + Exceptions.IncorrectArgument( + "unknown component for switching limits"; + got=":$component", + expected=":state, :costate or :control", + context="limits(::SwitchingModel, component)", + ), + ) +end + +""" +$(TYPEDSIGNATURES) + +Return the `(left, right)` limits of `component`: none for an +[`CTModels.Solutions.EmptySwitchingModel`](@extref) (two `0 × 0` matrices). +""" +function limits(::EmptySwitchingModel, component::Symbol)::NTuple{2,Matrix{Float64}} + component in (:state, :costate, :control) || throw( + Exceptions.IncorrectArgument( + "unknown component for switching limits"; + got=":$component", + expected=":state, :costate or :control", + context="limits(::EmptySwitchingModel, component)", + ), + ) + return (zeros(0, 0), zeros(0, 0)) +end + +# ------------------------------------------------------------------------------ # +# Accessors on the solution +# ------------------------------------------------------------------------------ # + +""" +$(TYPEDSIGNATURES) + +Return the switching model of a solution: an [`CTModels.Solutions.EmptySwitchingModel`](@extref) +or a [`CTModels.Solutions.SwitchingModel`](@extref). +""" +switching_model(sol::Solution)::AbstractSwitchingModel = sol.switching + +""" +$(TYPEDSIGNATURES) + +Return the switching times of a solution, strictly increasing; empty if the solution holds +no switching structure. + +# Example + +```julia-repl +julia> CTModels.switching_times(sol) +2-element Vector{Float64}: + 0.31 + 0.72 +``` + +See also: [`CTModels.Solutions.phase_labels`](@extref), [`CTModels.Solutions.left_limit`](@extref), +[`CTModels.Solutions.right_limit`](@extref). +""" +switching_times(sol::Solution)::Vector{Float64} = switching_times(switching_model(sol)) + +""" +$(TYPEDSIGNATURES) + +Return the phase labels of a solution, one per phase (`""` for a phase with no label); +empty if the solution holds no switching structure. + +See also: [`CTModels.Solutions.switching_times`](@extref). +""" +phase_labels(sol::Solution)::Vector{String} = phase_labels(switching_model(sol)) + +""" +$(TYPEDSIGNATURES) + +Return whether the solution holds at least one switching time. + +See also: [`CTModels.Solutions.switching_times`](@extref). +""" +has_switching(sol::Solution)::Bool = has_switching(switching_model(sol)) + +""" +$(TYPEDSIGNATURES) + +Return the left limit of `component` (`:state`, `:costate` or `:control`) at the `i`-th +switching time: the value before the jump, which the trajectory itself returns at that time. +A one-dimensional component is returned as a scalar. + +# Throws + +- [`CTBase.Exceptions.IncorrectArgument`](@extref): if the solution has no `i`-th switching + time, if its limits of `component` are not known, or if `component` is unknown. + +See also: [`CTModels.Solutions.right_limit`](@extref). +""" +left_limit(sol::Solution, component::Symbol, i::Integer) = _limit(sol, component, i, 1) + +""" +$(TYPEDSIGNATURES) + +Return the right limit of `component` (`:state`, `:costate` or `:control`) at the `i`-th +switching time: the value after a jump of the state or costate, or after a change of +control law. A one-dimensional component is returned as a scalar. + +# Throws + +- [`CTBase.Exceptions.IncorrectArgument`](@extref): if the solution has no `i`-th switching + time, if its limits of `component` are not known, or if `component` is unknown. + +See also: [`CTModels.Solutions.left_limit`](@extref). +""" +right_limit(sol::Solution, component::Symbol, i::Integer) = _limit(sol, component, i, 2) + +""" +$(TYPEDSIGNATURES) + +Shared implementation of [`CTModels.Solutions.left_limit`](@extref) (`side = 1`) and +[`CTModels.Solutions.right_limit`](@extref) (`side = 2`). +""" +function _limit(sol::Solution, component::Symbol, i::Integer, side::Int) + m = switching_model(sol) + M = limits(m, component)[side] + n = length(switching_times(m)) + 1 <= i <= n || throw( + Exceptions.IncorrectArgument( + "no switching time with this index"; + got="i = $i", + expected=n == 0 ? "no index: the solution has no switching time" : "1 ≤ i ≤ $n", + context=(side == 1 ? "left_limit" : "right_limit"), + ), + ) + size(M, 2) == 0 && throw( + Exceptions.IncorrectArgument( + "the $component limits at the switching times are not known"; + got="no $component limit stored in the switching model", + expected="limits given to SwitchingModel(; $component=(left, right))", + context=(side == 1 ? "left_limit" : "right_limit"), + ), + ) + return size(M, 2) == 1 ? M[i, 1] : M[i, :] +end + +# ------------------------------------------------------------------------------ # +# Jump-aware interpolation of sampled data +# ------------------------------------------------------------------------------ # + +""" +$(TYPEDEF) + +Interpolant of sampled data that keeps the jumps at the switching times. + +Around a switching time `τ`, a plain interpolant of the grid values would join the last +point before `τ` to the first point after it, smearing the jump. This one interpolates +between the last grid point before `τ` and the left limit at `τ`, then between the right +limit at `τ` and the first grid point after it. With `constant = true` (piecewise-constant +control), it returns the right limit until the next grid point. Elsewhere it returns the +base interpolant. At `τ` it returns the left limit (left continuity). + +# Fields + +- `base::F`: the interpolant of the grid values (returns a vector). +- `grid::Vector{Float64}`: the grid of the data. +- `times::Vector{Float64}`: the switching times. +- `left::Matrix{Float64}`, `right::Matrix{Float64}`: the limits, one row per switching time. +- `constant::Bool`: piecewise-constant interpolation. +""" +struct _JumpInterpolant{F} + base::F + grid::Vector{Float64} + times::Vector{Float64} + left::Matrix{Float64} + right::Matrix{Float64} + constant::Bool +end + +""" +$(TYPEDSIGNATURES) + +Evaluate the jump-aware interpolant at time `t` (see +[`CTModels.Solutions._JumpInterpolant`](@extref)). +""" +function (J::_JumpInterpolant)(t::Real) + T = J.grid + for i in eachindex(J.times) + τ = J.times[i] + t == τ && return J.left[i, :] + if t < τ + k = searchsortedlast(T, t) # T[k] ≤ t < τ + (k >= 1 && k < length(T) && T[k + 1] >= τ) || continue + # last grid point before τ is T[k]: interpolate towards the left limit + J.constant && return J.base(t) + T[k] == t && return J.base(t) + a = J.base(T[k]) + θ = (t - T[k]) / (τ - T[k]) + return a .+ θ .* (J.left[i, :] .- a) + else + k = searchsortedfirst(T, t) # T[k] ≥ t > τ + (k <= length(T) && k >= 2 && T[k - 1] <= τ) || continue + # first grid point after τ is T[k]: interpolate from the right limit + J.constant && return J.right[i, :] + T[k] == t && return J.base(t) + b = J.base(T[k]) + θ = (t - τ) / (T[k] - τ) + return J.right[i, :] .+ θ .* (b .- J.right[i, :]) + end + end + return J.base(t) +end + +""" +$(TYPEDSIGNATURES) + +Wrap the interpolant `base` of sampled data on `grid` so that it keeps the jumps of +`component` stored in the switching model `sw`. Returned unchanged when `sw` holds no +switching time, or no limit of `component`. +""" +function _with_jumps( + base, grid::Vector{Float64}, sw::AbstractSwitchingModel, component::Symbol; constant=false +) + has_switching(sw) || return base + left, right = limits(sw, component) + size(left, 2) == 0 && return base + return _JumpInterpolant(base, grid, switching_times(sw), left, right, constant) +end diff --git a/test/suite/extensions/test_plot_switching_render.jl b/test/suite/extensions/test_plot_switching_render.jl new file mode 100644 index 00000000..a898e93b --- /dev/null +++ b/test/suite/extensions/test_plot_switching_render.jl @@ -0,0 +1,100 @@ +module TestPlotSwitchingRender + +# ============================================================================= +# End-to-end rendering of a solution with a switching structure, with the Plots +# and Makie backends: the vertical jump, the switching-time lines and the phase +# labels reach the rendered figure. +# ============================================================================= + +using Test: Test +using Plots: Plots +using CairoMakie: CairoMakie +using CairoMakie: Makie +using CTModels: CTModels +using CTModels: Building +using CTModels: Solutions + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +const τ = 0.5 + +function _sol() + pre = Building.PreModel() + Building.time_dependence!(pre; autonomous=true) + Building.time!(pre; t0=0.0, tf=1.0) + Building.state!(pre, 1) + Building.control!(pre, 1) + Building.dynamics!(pre, (r, t, x, u, v) -> (r[1] = u; nothing)) + Building.objective!(pre, :min; lagrange=(t, x, u, v) -> 0.5 * u^2) + ocp = Building.build(pre) + T = collect(range(0.0, 1.0; length=11)) + sw = Solutions.SwitchingModel(; + times=[τ], + labels=["bang+", "bang-"], + state=([τ;;], [τ + 1.0;;]), + costate=([0.0;;], [2.0;;]), + control=([1.0;;], [-1.0;;]), + ) + return Solutions.build_solution( + ocp, + T, + reshape([t <= τ ? t : t + 1.0 for t in T], :, 1), + reshape([t <= τ ? 1.0 : -1.0 for t in T], :, 1), + Float64[], + reshape([t <= τ ? 0.0 : 2.0 for t in T], :, 1); + objective=0.0, + successful=true, + switching=sw, + ) +end + +_axes(f) = [x for x in f.content if x isa Makie.Axis] +_count_plots(axis, ::Type{P}) where {P} = count(p -> p isa P, axis.scene.plots) + +function test_plot_switching_render() + Test.@testset "Rendering a solution with switching times" verbose=VERBOSE showtiming=SHOWTIMING begin + sol = _sol() + + Test.@testset "Plots" begin + plt = Plots.plot(sol) + Test.@test plt isa Plots.Plot + # state | costate over control: 3 cells + Test.@test length(plt.subplots) == 3 + # the state series holds the vertical jump at τ + xs = plt.subplots[1].series_list[1][:x] + ys = plt.subplots[1].series_list[1][:y] + k = findfirst(==(τ), xs) + Test.@test xs[k + 1] == τ + Test.@test (ys[k], ys[k + 1]) == (τ, τ + 1.0) + # the phase labels are on the control cell only + Test.@test length(plt.subplots[3][:annotations]) == 2 + Test.@test isempty(plt.subplots[1][:annotations]) + Test.@test sort([a[3].str for a in plt.subplots[3][:annotations]]) == + ["bang+", "bang-"] + # no labels, no switching lines: still renders + Test.@test Plots.plot(sol; phase_labels=:none, switching_style=:none) isa + Plots.Plot + # overlay + Test.@test Plots.plot!(Plots.plot(sol), sol) isa Plots.Plot + end + + Test.@testset "Makie" begin + f = Makie.plot(sol) + Test.@test f isa Makie.Figure + axs = _axes(f) + Test.@test length(axs) == 3 + # t0, tf and τ lines in every axis + Test.@test all(ax -> _count_plots(ax, Makie.VLines) == 3, axs) + # two phase labels, on the control axis only + Test.@test sum(ax -> _count_plots(ax, Makie.Text), axs) == 2 + f2 = Makie.plot(sol; phase_labels=:all, switching_style=:none) + Test.@test all(ax -> _count_plots(ax, Makie.VLines) == 2, _axes(f2)) + Test.@test sum(ax -> _count_plots(ax, Makie.Text), _axes(f2)) == 6 + end + end +end + +end # module + +test_plot_switching_render() = TestPlotSwitchingRender.test_plot_switching_render() diff --git a/test/suite/plotting/test_plot_switching.jl b/test/suite/plotting/test_plot_switching.jl new file mode 100644 index 00000000..5fc30b74 --- /dev/null +++ b/test/suite/plotting/test_plot_switching.jl @@ -0,0 +1,267 @@ +module TestPlotSwitching + +# ============================================================================= +# Unit tests for the switching structure in the backend-free plotting case layer +# `CTModels.PlotCase`: vertical jumps in the panel data, vertical lines at the +# switching times, phase labels. No plotting backend is loaded. +# ============================================================================= + +using Test: Test +using CTBase: Plotting +using CTBase: Exceptions +using CTModels: CTModels +using CTModels: Building +using CTModels: Solutions + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# ẋ = u on [0, 2], 1-D, with the path constraint g = x + u (label :g). +function _ocp() + pre = Building.PreModel() + Building.time_dependence!(pre; autonomous=true) + Building.time!(pre; t0=0.0, tf=2.0) + Building.state!(pre, 1) + Building.control!(pre, 1) + Building.dynamics!(pre, (r, t, x, u, v) -> (r[1] = u; nothing)) + Building.objective!(pre, :min; lagrange=(t, x, u, v) -> 0.5 * u^2) + Building.constraint!( + pre, :path; f=(r, t, x, u, v) -> (r[1] = x + u; nothing), lb=[-10.0], ub=[10.0], label=:g + ) + return Building.build(pre) +end + +const τ = 1.0 +const T = collect(range(0.0, 2.0; length=11)) # holds τ +_x(t) = t <= τ ? t : t + 1.0 +_u(t) = t <= τ ? 1.0 : -1.0 +_p(t) = t <= τ ? 0.0 : 2.0 + +function _sw(; labels=["bang+", "bang-"], limits=true) + limits || return Solutions.SwitchingModel(; times=[τ], labels=labels) + return Solutions.SwitchingModel(; + times=[τ], + labels=labels, + state=([τ;;], [τ + 1.0;;]), + costate=([0.0;;], [2.0;;]), + control=([1.0;;], [-1.0;;]), + ) +end + +function _sol(; switching=_sw(), grid=T) + return Solutions.build_solution( + _ocp(), + grid, + reshape(_x.(grid), :, 1), + reshape(_u.(grid), :, 1), + Float64[], + reshape(_p.(grid), :, 1); + objective=0.0, + successful=true, + switching=switching, + ) +end + +_leaves(fig) = Plotting.leaves(fig.root) +_leaf(fig, title) = only(l for l in _leaves(fig) if l.axes.title == title) +_series(fig, title) = _leaf(fig, title).axes.series[1] +_decos(fig, title, D) = filter(d -> d isa D, _leaf(fig, title).axes.decorations) +_texts(fig) = reduce( + vcat, + (filter(d -> d isa Plotting.Annotation, l.axes.decorations) for l in _leaves(fig)); + init=Plotting.Annotation[], +) + +function test_plot_switching() + Test.@testset "PlotCase: switching structure" verbose=VERBOSE showtiming=SHOWTIMING begin + + # ==================================================================== + # UNIT TESTS - vertical jumps in the panel data + # ==================================================================== + + Test.@testset "_with_jumps: insertion of the limits" begin + sol = _sol() + Ts, M = CTModels.PlotCase._with_jumps(sol, T, reshape(_x.(T), :, 1), :state) + # τ is in the grid: its sample is replaced by (τ, left), (τ, right) + Test.@test count(==(τ), Ts) == 2 + Test.@test length(Ts) == length(T) + 1 + k = findfirst(==(τ), Ts) + Test.@test M[k, 1] ≈ τ + Test.@test M[k + 1, 1] ≈ τ + 1.0 + Test.@test issorted(Ts) + # τ not in the samples: inserted at its place + Tn = [0.0, 0.5, 1.5, 2.0] + Ts2, M2 = CTModels.PlotCase._with_jumps(sol, Tn, reshape(_x.(Tn), :, 1), :state) + Test.@test Ts2 == [0.0, 0.5, τ, τ, 1.5, 2.0] + Test.@test M2[:, 1] ≈ [0.0, 0.5, τ, τ + 1.0, 2.5, 3.0] + # τ after the last sample + Ts3, _ = CTModels.PlotCase._with_jumps(sol, [0.0, 0.5], zeros(2, 1), :state) + Test.@test Ts3 == [0.0, 0.5, τ, τ] + # unchanged when no switching time, or limits not known, or dimension mismatch + plain = _sol(; switching=Solutions.EmptySwitchingModel()) + Test.@test CTModels.PlotCase._with_jumps(plain, T, zeros(11, 1), :state) == + (T, zeros(11, 1)) + nolim = _sol(; switching=_sw(; limits=false)) + Test.@test CTModels.PlotCase._with_jumps(nolim, T, zeros(11, 1), :state)[1] == T + Test.@test CTModels.PlotCase._with_jumps(sol, T, zeros(11, 2), :state)[1] == T + end + + Test.@testset "state, costate and control panels draw vertical jumps" begin + fig = CTModels.PlotCase.build_figure(_sol()) + for (title, left, right) in + (("state", τ, τ + 1.0), ("costate", 0.0, 2.0), ("control", 1.0, -1.0)) + s = _series(fig, title) + k = findfirst(==(τ), s.x) + Test.@test s.x[k + 1] == τ # vertical segment + Test.@test s.y[k] ≈ left + Test.@test s.y[k + 1] ≈ right + end + # control norm (in :split, :all is one column with the norm last) + fig_all = CTModels.PlotCase.build_figure(_sol(); control=:all) + ctrl = _leaves(fig_all) + normcell = only( + l for l in ctrl if !isempty(l.axes.series) && l.axes.ylabel == "‖u‖" + ) + s = normcell.axes.series[1] + k = findfirst(==(τ), s.x) + Test.@test (s.y[k], s.y[k + 1]) == (1.0, 1.0) + end + + Test.@testset "path constraints are evaluated at both limits" begin + fig = CTModels.PlotCase.build_figure(_sol(), :path) + s = _series(fig, "path constraints") + k = findfirst(==(τ), s.x) + Test.@test s.x[k + 1] == τ + Test.@test s.y[k] ≈ τ + 1.0 # x⁻ + u⁻ + Test.@test s.y[k + 1] ≈ (τ + 1.0) - 1.0 # x⁺ + u⁺ + # limits not known: plain samples + fig2 = CTModels.PlotCase.build_figure(_sol(; switching=_sw(; limits=false)), :path) + Test.@test count(==(τ), _series(fig2, "path constraints").x) == 1 + end + + Test.@testset "no switching structure: unchanged panels" begin + fig = CTModels.PlotCase.build_figure(_sol(; switching=Solutions.EmptySwitchingModel())) + Test.@test _series(fig, "state").x == T + Test.@test isempty(_texts(fig)) + Test.@test length(_decos(fig, "state", Plotting.VLine)) == 2 # t0 and tf only + end + + # ==================================================================== + # UNIT TESTS - vertical lines at the switching times + # ==================================================================== + + Test.@testset "switching lines: in every cell, styled like the time lines" begin + fig = CTModels.PlotCase.build_figure(_sol()) + for l in _leaves(fig) + vls = filter(d -> d isa Plotting.VLine, l.axes.decorations) + Test.@test [v.value for v in vls] == [0.0, 2.0, τ] + Test.@test vls[3].style == vls[1].style + end + # the user time style applies to them too + fig2 = CTModels.PlotCase.build_figure(_sol(); time_style=(color=:blue,)) + Test.@test _decos(fig2, "state", Plotting.VLine)[3].style.color === :blue + # their own style merges on top + fig3 = CTModels.PlotCase.build_figure( + _sol(); time_style=(color=:blue,), switching_style=(color=:red, linewidth=2) + ) + v = _decos(fig3, "state", Plotting.VLine)[3] + Test.@test v.style.color === :red + Test.@test v.style.linewidth == 2 + Test.@test v.style.linestyle === :dash + # :none hides them, and only them + fig4 = CTModels.PlotCase.build_figure(_sol(); switching_style=:none) + Test.@test [d.value for d in _decos(fig4, "state", Plotting.VLine)] == [0.0, 2.0] + # they stay when the time lines are hidden + fig5 = CTModels.PlotCase.build_figure(_sol(); time_style=:none) + Test.@test [d.value for d in _decos(fig5, "state", Plotting.VLine)] == [τ] + end + + Test.@testset "switching lines and labels under time normalisation" begin + fig = CTModels.PlotCase.build_figure(_sol(); time=:normalize) + Test.@test [d.value for d in _decos(fig, "state", Plotting.VLine)] == + [0.0, 1.0, 0.5] + Test.@test [a.x for a in _texts(fig)] ≈ [0.25, 0.75] + end + + # ==================================================================== + # UNIT TESTS - phase labels + # ==================================================================== + + Test.@testset "phase labels: on the control cell by default" begin + fig = CTModels.PlotCase.build_figure(_sol()) + ts = _decos(fig, "control", Plotting.Annotation) + Test.@test [a.text for a in ts] == ["bang+", "bang-"] + Test.@test [a.x for a in ts] ≈ [0.5, 1.5] # centred on each phase + Test.@test all(a -> a.style.color === :gray30, ts) # default style + Test.@test length(_texts(fig)) == 2 # nowhere else + end + + Test.@testset "phase labels: :all, :none, style, empty labels" begin + fig = CTModels.PlotCase.build_figure(_sol(); phase_labels=:all) + Test.@test length(_decos(fig, "state", Plotting.Annotation)) == 2 + Test.@test length(_decos(fig, "costate", Plotting.Annotation)) == 2 + Test.@test length(_decos(fig, "control", Plotting.Annotation)) == 2 + Test.@test isempty(_texts(CTModels.PlotCase.build_figure(_sol(); phase_labels=:none))) + st = CTModels.PlotCase.build_figure( + _sol(); phase_labels_style=(color=:red, fontsize=14) + ) + a = first(_texts(st)) + Test.@test a.style.color === :red && a.style.fontsize == 14 + # an empty label gets no text; no label at all: none + fig2 = CTModels.PlotCase.build_figure(_sol(; switching=_sw(; labels=["", "b"]))) + Test.@test [a.text for a in _texts(fig2)] == ["b"] + fig3 = CTModels.PlotCase.build_figure(_sol(; switching=_sw(; labels=["", ""]))) + Test.@test isempty(_texts(fig3)) + end + + Test.@testset "phase labels: fallback when the control is not drawn" begin + fig = CTModels.PlotCase.build_figure(_sol(), :state, :costate) + Test.@test length(_decos(fig, "state", Plotting.Annotation)) == 2 + Test.@test isempty(_decos(fig, "costate", Plotting.Annotation)) + fig2 = CTModels.PlotCase.build_figure(_sol(); control_style=:none) + Test.@test length(_decos(fig2, "state", Plotting.Annotation)) == 2 + fig3 = CTModels.PlotCase.build_figure(_sol(), :costate) + Test.@test length(_decos(fig3, "costate", Plotting.Annotation)) == 2 + end + + Test.@testset "phase labels: group layout" begin + fig = CTModels.PlotCase.build_figure(_sol(); layout=:group, control=:all) + leaves = _leaves(fig) + ctrl = only(l for l in leaves if l.axes.title == "control") + norm = only(l for l in leaves if l.axes.title == "control norm") + Test.@test count(d -> d isa Plotting.Annotation, ctrl.axes.decorations) == 2 + Test.@test !any(d -> d isa Plotting.Annotation, norm.axes.decorations) + end + + Test.@testset "a labelled single phase" begin + one = Solutions.SwitchingModel(; times=Float64[], labels=["bang"]) + fig = CTModels.PlotCase.build_figure(_sol(; switching=one)) + ts = _texts(fig) + Test.@test [a.text for a in ts] == ["bang"] + Test.@test only(ts).x ≈ 1.0 + Test.@test _series(fig, "state").x == T # no jump + end + + # ==================================================================== + # ERROR TESTS + # ==================================================================== + + Test.@testset "invalid phase_labels" begin + Test.@test_throws Exceptions.IncorrectArgument CTModels.PlotCase.build_figure( + _sol(); phase_labels=:everywhere + ) + end + + Test.@testset "keywords reach build_figure, not the backend" begin + build, render = CTModels.PlotCase.split_plot_kwargs(( + switching_style=:none, phase_labels=:all, phase_labels_style=(;), color=:red + )) + Test.@test keys(build) == (:switching_style, :phase_labels, :phase_labels_style) + Test.@test keys(render) == (:color,) + end + end +end + +end # module + +test_plot_switching() = TestPlotSwitching.test_plot_switching() diff --git a/test/suite/serialization/test_switching_serialization.jl b/test/suite/serialization/test_switching_serialization.jl new file mode 100644 index 00000000..f46f2903 --- /dev/null +++ b/test/suite/serialization/test_switching_serialization.jl @@ -0,0 +1,174 @@ +module TestSwitchingSerialization + +using Test: Test +using JLD2: JLD2 +using JSON3: JSON3 +using CTModels: CTModels +using CTModels: Building +using CTModels: Solutions +using CTModels: Serialization + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# ẋ = (x₂, u), 2-D state, 1-D control on [0, 1]: vector limits for the state and costate, +# scalar ones for the control. +function _ocp() + pre = Building.PreModel() + Building.time_dependence!(pre; autonomous=true) + Building.time!(pre; t0=0.0, tf=1.0) + Building.state!(pre, 2) + Building.control!(pre, 1) + Building.dynamics!(pre, (r, t, x, u, v) -> (r[1] = x[2]; r[2] = u; nothing)) + Building.objective!(pre, :min; lagrange=(t, x, u, v) -> 0.5 * u^2) + return Building.build(pre) +end + +const τ1, τ2 = 0.3, 0.7 +_x(t) = t <= τ1 ? [t, 1.0] : t <= τ2 ? [t + 1.0, 1.0] : [t + 1.0, 3.0] +_p(t) = t <= τ2 ? [0.0, 0.0] : [1.0, -1.0] +_u(t) = t <= τ1 ? 1.0 : t <= τ2 ? 0.0 : -1.0 + +function _sw() + return Solutions.SwitchingModel(; + times=[τ1, τ2], + labels=["bang", "", "boundary"], + state=([τ1 1.0; τ2+1.0 1.0], [τ1+1.0 1.0; τ2+1.0 3.0]), + costate=([0.0 0.0; 0.0 0.0], [0.0 0.0; 1.0 -1.0]), + control=([1.0; 0.0;;], [0.0; -1.0;;]), + ) +end + +function _sol(; switching=_sw()) + T = collect(range(0.0, 1.0; length=11)) + return Solutions.build_solution( + _ocp(), + T, + reduce(vcat, (_x(t)' for t in T)), + reshape(_u.(T), :, 1), + Float64[], + reduce(vcat, (_p(t)' for t in T)); + objective=0.0, + iterations=3, + constraints_violation=0.0, + message="ok", + status=:ok, + successful=true, + switching=switching, + ) +end + +function _check_roundtrip(sol, back) + Test.@test CTModels.switching_times(back) == CTModels.switching_times(sol) + Test.@test CTModels.phase_labels(back) == CTModels.phase_labels(sol) + for c in (:state, :costate, :control), i in 1:2 + Test.@test CTModels.left_limit(back, c, i) == CTModels.left_limit(sol, c, i) + Test.@test CTModels.right_limit(back, c, i) == CTModels.right_limit(sol, c, i) + end + # the imported trajectories keep their jumps + for t in (0.25, τ1, 0.35, 0.5, τ2, 0.75, 1.0) + Test.@test CTModels.state(back)(t) ≈ CTModels.state(sol)(t) + Test.@test CTModels.costate(back)(t) ≈ CTModels.costate(sol)(t) + Test.@test CTModels.control(back)(t) ≈ CTModels.control(sol)(t) + end + Test.@test CTModels.state(back)(0.35) ≈ [1.35, 1.0] # after the jump at τ1 + return nothing +end + +function test_switching_serialization() + Test.@testset "Serialization of the switching structure" verbose=VERBOSE showtiming=SHOWTIMING begin + ocp = _ocp() + + Test.@testset "serialized data" begin + data = Solutions._serialize_solution(_sol()) + Test.@test data["format_version"] == 2 + Test.@test data["switching_times"] == [τ1, τ2] + Test.@test data["phase_labels"] == ["bang", "", "boundary"] + Test.@test data["switching_state_right"] == [τ1+1.0 1.0; τ2+1.0 3.0] + Test.@test size(data["switching_control_left"]) == (2, 1) + # no structure: no key + plain = Solutions._serialize_solution( + _sol(; switching=Solutions.EmptySwitchingModel()) + ) + Test.@test !haskey(plain, "switching_times") + Test.@test !any(startswith("switching_"), keys(plain)) + end + + Test.@testset "deserialized model" begin + data = Solutions._serialize_solution(_sol()) + sw = Solutions._deserialize_switching(data) + Test.@test sw isa Solutions.SwitchingModel + Test.@test Solutions.limits(sw, :costate) == Solutions.limits(_sw(), :costate) + Test.@test Solutions._deserialize_switching(Dict{String,Any}()) isa + Solutions.EmptySwitchingModel + end + + mktempdir() do dir + Test.@testset "JLD round trip" begin + f = joinpath(dir, "sw_jld") + sol = _sol() + Serialization.export_ocp_solution(sol; filename=f, format=:JLD) + back = Serialization.import_ocp_solution(ocp; filename=f, format=:JLD) + _check_roundtrip(sol, back) + end + + Test.@testset "JSON round trip" begin + f = joinpath(dir, "sw_json") + sol = _sol() + Serialization.export_ocp_solution(sol; filename=f, format=:JSON) + back = Serialization.import_ocp_solution(ocp; filename=f, format=:JSON) + _check_roundtrip(sol, back) + blob = JSON3.read(read(f * ".json", String)) + Test.@test length(blob["switching_state_left"]) == 2 # one row per τ + end + + Test.@testset "round trip: labels only, limits not known" begin + sw = Solutions.SwitchingModel(; times=[τ1], labels=["a", "b"]) + sol = _sol(; switching=sw) + for fmt in (:JLD, :JSON) + f = joinpath(dir, "sw_nolim_$(fmt)") + Serialization.export_ocp_solution(sol; filename=f, format=fmt) + back = Serialization.import_ocp_solution(ocp; filename=f, format=fmt) + Test.@test CTModels.switching_times(back) == [τ1] + Test.@test CTModels.phase_labels(back) == ["a", "b"] + Test.@test size(Solutions.limits(Solutions.switching_model(back), :state)[1], 2) == 0 + end + end + + Test.@testset "round trip: a labelled single phase" begin + one = Solutions.SwitchingModel(; times=Float64[], labels=["bang"]) + sol = _sol(; switching=one) + for fmt in (:JLD, :JSON) + f = joinpath(dir, "sw_one_$(fmt)") + Serialization.export_ocp_solution(sol; filename=f, format=fmt) + back = Serialization.import_ocp_solution(ocp; filename=f, format=fmt) + Test.@test isempty(CTModels.switching_times(back)) + Test.@test CTModels.phase_labels(back) == ["bang"] + end + end + + Test.@testset "a file without the switching keys loads" begin + sol = _sol(; switching=Solutions.EmptySwitchingModel()) + for fmt in (:JLD, :JSON) + f = joinpath(dir, "sw_none_$(fmt)") + Serialization.export_ocp_solution(sol; filename=f, format=fmt) + back = Serialization.import_ocp_solution(ocp; filename=f, format=fmt) + Test.@test Solutions.switching_model(back) isa + Solutions.EmptySwitchingModel + end + # an older file: the version-1 data, without switching keys + data = Solutions._serialize_solution(_sol()) + for k in collect(keys(data)) + (startswith(k, "switching_") || k == "phase_labels") && delete!(data, k) + end + data["format_version"] = 1 + old = Serialization._reconstruct_solution_from_data(ocp, data) + Test.@test Solutions.switching_model(old) isa Solutions.EmptySwitchingModel + end + end + end +end + +end # module + +test_switching_serialization() = TestSwitchingSerialization.test_switching_serialization() diff --git a/test/suite/solutions/test_switching.jl b/test/suite/solutions/test_switching.jl new file mode 100644 index 00000000..008237d7 --- /dev/null +++ b/test/suite/solutions/test_switching.jl @@ -0,0 +1,445 @@ +module TestSwitching + +using Test: Test +using CTBase: Exceptions +using CTModels: CTModels +using CTModels: Building +using CTModels: Solutions + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# ============================================================================ +# Problems and data (module top level, TST3) +# ============================================================================ + +# ẋ = u, 1-D state and control, t ∈ [0, 1]. +function _ocp_1d() + pre = Building.PreModel() + Building.time_dependence!(pre; autonomous=true) + Building.time!(pre; t0=0.0, tf=1.0) + Building.state!(pre, 1) + Building.control!(pre, 1) + Building.dynamics!(pre, (r, t, x, u, v) -> (r[1] = u; nothing)) + Building.objective!(pre, :min; lagrange=(t, x, u, v) -> 0.5 * u^2) + return Building.build(pre) +end + +# ẋ = (x₂, u), 2-D state, 1-D control, t ∈ [0, 1]. +function _ocp_2d() + pre = Building.PreModel() + Building.time_dependence!(pre; autonomous=true) + Building.time!(pre; t0=0.0, tf=1.0) + Building.state!(pre, 2) + Building.control!(pre, 1) + Building.dynamics!(pre, (r, t, x, u, v) -> (r[1] = x[2]; r[2] = u; nothing)) + Building.objective!(pre, :min; lagrange=(t, x, u, v) -> 0.5 * u^2) + return Building.build(pre) +end + +const τ = 0.5 +const T11 = collect(range(0.0, 1.0; length=11)) +# x(t) = t, then a jump of +1 at τ; u = 1 then -1; p = 0 then a jump to 2. +_x(t) = t <= τ ? t : t + 1.0 +_u(t) = t <= τ ? 1.0 : -1.0 +_p(t) = t <= τ ? 0.0 : 2.0 + +function _sw_1d(; labels=["bang+", "bang-"]) + return Solutions.SwitchingModel(; + times=[τ], + labels=labels, + state=([τ;;], [τ + 1.0;;]), + costate=([0.0;;], [2.0;;]), + control=([1.0;;], [-1.0;;]), + ) +end + +function _sol_1d(; T=T11, switching=_sw_1d(), kw...) + X = reshape(_x.(T), :, 1) + U = reshape(_u.(T), :, 1) + P = reshape(_p.(T), :, 1) + return Solutions.build_solution( + _ocp_1d(), + T, + X, + U, + Float64[], + P; + objective=1.0, + successful=true, + switching=switching, + kw..., + ) +end + +_show(sol) = sprint(show, MIME"text/plain"(), sol) + +function test_switching() + Test.@testset "Switching structure" verbose=VERBOSE showtiming=SHOWTIMING begin + + # ==================================================================== + # UNIT TESTS - SwitchingModel construction + # ==================================================================== + + Test.@testset "SwitchingModel: keyword constructor and defaults" begin + sw = _sw_1d() + Test.@test sw isa Solutions.AbstractSwitchingModel + Test.@test Solutions.switching_times(sw) == [τ] + Test.@test Solutions.phase_labels(sw) == ["bang+", "bang-"] + Test.@test Solutions.has_switching(sw) + Test.@test Solutions.limits(sw, :state) == ([τ;;], [τ + 1.0;;]) + Test.@test Solutions.limits(sw, :control)[2] == [-1.0;;] + # defaults: no labels, limits not known + d = Solutions.SwitchingModel(; times=[0.2, 0.6]) + Test.@test Solutions.phase_labels(d) == ["", "", ""] + Test.@test size(Solutions.limits(d, :state)[1]) == (2, 0) + # conversions: Int times, SubString labels, Int matrices + c = Solutions.SwitchingModel(; + times=[1], + labels=[SubString("ab", 1, 1), "b"], + state=([1;;], [2;;]), + ) + Test.@test Solutions.switching_times(c) == [1.0] + Test.@test Solutions.phase_labels(c) == ["a", "b"] + Test.@test eltype(Solutions.limits(c, :state)[1]) == Float64 + # a labelled single phase: no switching time, one label + one = Solutions.SwitchingModel(; times=Float64[], labels=["bang"]) + Test.@test !Solutions.has_switching(one) + Test.@test Solutions.phase_labels(one) == ["bang"] + end + + Test.@testset "EmptySwitchingModel" begin + e = Solutions.EmptySwitchingModel() + Test.@test Solutions.switching_times(e) == Float64[] + Test.@test Solutions.phase_labels(e) == String[] + Test.@test !Solutions.has_switching(e) + Test.@test Solutions.limits(e, :costate) == (zeros(0, 0), zeros(0, 0)) + end + + # ==================================================================== + # ERROR TESTS - SwitchingModel + # ==================================================================== + + Test.@testset "SwitchingModel: errors" begin + # not strictly increasing + Test.@test_throws Exceptions.IncorrectArgument Solutions.SwitchingModel(; + times=[0.5, 0.5] + ) + Test.@test_throws Exceptions.IncorrectArgument Solutions.SwitchingModel(; + times=[0.6, 0.2] + ) + # not finite + Test.@test_throws Exceptions.IncorrectArgument Solutions.SwitchingModel(; + times=[Inf] + ) + # wrong number of labels + Test.@test_throws Exceptions.IncorrectArgument Solutions.SwitchingModel(; + times=[0.5], labels=["only one"] + ) + # wrong number of rows, or left/right sizes differ + Test.@test_throws Exceptions.IncorrectArgument Solutions.SwitchingModel(; + times=[0.5], state=(zeros(2, 1), zeros(2, 1)) + ) + Test.@test_throws Exceptions.IncorrectArgument Solutions.SwitchingModel(; + times=[0.5], state=(zeros(1, 1), zeros(1, 2)) + ) + # unknown component + Test.@test_throws Exceptions.IncorrectArgument Solutions.limits(_sw_1d(), :foo) + Test.@test_throws Exceptions.IncorrectArgument Solutions.limits( + Solutions.EmptySwitchingModel(), :foo + ) + end + + # ==================================================================== + # UNIT TESTS - build_solution with a switching model + # ==================================================================== + + Test.@testset "build_solution: getters" begin + sol = _sol_1d() + Test.@test Solutions.switching_model(sol) isa Solutions.SwitchingModel + Test.@test CTModels.switching_times(sol) == [τ] + Test.@test CTModels.phase_labels(sol) == ["bang+", "bang-"] + Test.@test CTModels.has_switching(sol) + # 1-D components are scalars (DIM1) + Test.@test CTModels.left_limit(sol, :state, 1) === τ + Test.@test CTModels.right_limit(sol, :state, 1) === τ + 1.0 + Test.@test CTModels.left_limit(sol, :costate, 1) === 0.0 + Test.@test CTModels.right_limit(sol, :costate, 1) === 2.0 + Test.@test CTModels.right_limit(sol, :control, 1) === -1.0 + # default: no switching structure + plain = _sol_1d(; switching=Solutions.EmptySwitchingModel()) + Test.@test Solutions.switching_model(plain) isa Solutions.EmptySwitchingModel + Test.@test isempty(CTModels.switching_times(plain)) + Test.@test isempty(CTModels.phase_labels(plain)) + Test.@test !CTModels.has_switching(plain) + end + + Test.@testset "build_solution: vector limits in dimension 2" begin + T = [0.0, 0.5, 1.0] + sw = Solutions.SwitchingModel(; + times=[0.5], + state=([1.0 2.0], [3.0 4.0]), + costate=([0.0 0.0], [1.0 1.0]), + control=([1.0;;], [-1.0;;]), + ) + sol = Solutions.build_solution( + _ocp_2d(), + T, + zeros(3, 2), + zeros(3, 1), + Float64[], + zeros(3, 2); + objective=0.0, + successful=true, + switching=sw, + ) + Test.@test CTModels.left_limit(sol, :state, 1) == [1.0, 2.0] + Test.@test CTModels.right_limit(sol, :state, 1) == [3.0, 4.0] + Test.@test CTModels.right_limit(sol, :control, 1) === -1.0 + end + + Test.@testset "the trajectories keep their jumps (matrix data)" begin + sol = _sol_1d() + x = CTModels.state(sol) + u = CTModels.control(sol) + p = CTModels.costate(sol) + # left-continuous at τ + Test.@test x(τ) ≈ τ + Test.@test u(τ) ≈ 1.0 + Test.@test p(τ) ≈ 0.0 + # just after τ: the right limit, not a blend across the jump + Test.@test x(0.55) ≈ 1.55 + Test.@test p(0.55) ≈ 2.0 + Test.@test u(0.55) ≈ -1.0 + # away from τ: the plain interpolation + Test.@test x(0.3) ≈ 0.3 + Test.@test x(0.8) ≈ 1.8 + Test.@test x(1.0) ≈ 2.0 + end + + Test.@testset "jumps between grid points (τ not in the grid)" begin + T = [0.0, 0.4, 0.6, 1.0] + sol = _sol_1d(; T=T) + x = CTModels.state(sol) + # between 0.4 and τ: towards the left limit; between τ and 0.6: from the right + Test.@test x(0.45) ≈ 0.45 + Test.@test x(τ) ≈ τ + Test.@test x(0.55) ≈ 1.55 + Test.@test x(0.6) ≈ 1.6 + Test.@test x(0.2) ≈ 0.2 + end + + Test.@testset "piecewise-constant control keeps its switch" begin + sol = _sol_1d(; control_interpolation=:constant) + u = CTModels.control(sol) + Test.@test u(0.45) ≈ 1.0 + Test.@test u(τ) ≈ 1.0 + Test.@test u(0.55) ≈ -1.0 + Test.@test u(0.95) ≈ -1.0 + end + + Test.@testset "function data is used as is" begin + sol = Solutions.build_solution( + _ocp_1d(), + T11, + t -> [_x(t)], + t -> [_u(t)], + Float64[], + t -> [_p(t)]; + objective=1.0, + successful=true, + switching=_sw_1d(), + ) + Test.@test CTModels.state(sol)(0.55) ≈ 1.55 + Test.@test CTModels.state(sol)(τ) ≈ τ + Test.@test CTModels.switching_times(sol) == [τ] + end + + Test.@testset "limits not known: plain interpolation" begin + sw = Solutions.SwitchingModel(; times=[τ], labels=["a", "b"]) + sol = _sol_1d(; switching=sw) + # the linear interpolation between 0.5 (x = 0.5) and 0.6 (x = 1.6) + Test.@test CTModels.state(sol)(0.55) ≈ 1.05 + end + + Test.@testset "multiple grids keep the jumps on each grid" begin + Ts = T11 + Tu = collect(range(0.0, 1.0; length=6)) + sol = Solutions.build_solution( + _ocp_1d(), + Ts, + Tu, + Ts, + nothing, + reshape(_x.(Ts), :, 1), + reshape(_u.(Tu), :, 1), + Float64[], + reshape(_p.(Ts), :, 1); + objective=1.0, + successful=true, + switching=_sw_1d(), + ) + Test.@test CTModels.state(sol)(0.55) ≈ 1.55 + Test.@test CTModels.control(sol)(0.55) ≈ -1.0 + end + + # ==================================================================== + # ERROR TESTS - build_solution + # ==================================================================== + + Test.@testset "build_solution: switching errors" begin + # switching time outside (t0, tf) + for t in (0.0, 1.0, 1.5) + sw = Solutions.SwitchingModel(; times=[t]) + Test.@test_throws Exceptions.IncorrectArgument _sol_1d(; switching=sw) + end + # wrong number of columns of the limits + bad = Solutions.SwitchingModel(; times=[τ], state=(zeros(1, 2), zeros(1, 2))) + Test.@test_throws Exceptions.IncorrectArgument _sol_1d(; switching=bad) + bad_u = Solutions.SwitchingModel(; + times=[τ], control=(zeros(1, 3), zeros(1, 3)) + ) + Test.@test_throws Exceptions.IncorrectArgument _sol_1d(; switching=bad_u) + end + + Test.@testset "left_limit / right_limit: errors" begin + sol = _sol_1d() + for i in (0, 2) + Test.@test_throws Exceptions.IncorrectArgument CTModels.left_limit( + sol, :state, i + ) + Test.@test_throws Exceptions.IncorrectArgument CTModels.right_limit( + sol, :state, i + ) + end + Test.@test_throws Exceptions.IncorrectArgument CTModels.right_limit( + sol, :foo, 1 + ) + # no switching time + plain = _sol_1d(; switching=Solutions.EmptySwitchingModel()) + Test.@test_throws Exceptions.IncorrectArgument CTModels.right_limit( + plain, :state, 1 + ) + # limits not known + nolim = _sol_1d(; switching=Solutions.SwitchingModel(; times=[τ])) + Test.@test_throws Exceptions.IncorrectArgument CTModels.left_limit( + nolim, :state, 1 + ) + end + + Test.@testset "strict grid: a repeated time is an error" begin + T = [0.0, 0.5, 0.5, 1.0] + Test.@test_throws Exceptions.IncorrectArgument Solutions.build_solution( + _ocp_1d(), + T, + zeros(4, 1), + zeros(4, 1), + Float64[], + zeros(4, 1); + objective=0.0, + successful=true, + ) + # also when the grid is not sorted (the repetition shows once sorted) + Tu = [0.5, 1.0, 0.0, 0.5] + Test.@test_throws Exceptions.IncorrectArgument Solutions.build_solution( + _ocp_1d(), + Tu, + zeros(4, 1), + zeros(4, 1), + Float64[], + zeros(4, 1); + objective=0.0, + successful=true, + ) + # an unsorted grid without repeated time is still sorted, with a warning + sol = Test.@test_logs (:warn, r"not increasing") match_mode = :any Solutions.build_solution( + _ocp_1d(), + [0.0, 1.0, 0.5], + zeros(3, 1), + zeros(3, 1), + Float64[], + zeros(3, 1); + objective=0.0, + successful=true, + ) + Test.@test CTModels.time_grid(sol) == [0.0, 0.5, 1.0] + end + + # ==================================================================== + # UNIT TESTS - backward-compatible positional constructor + # ==================================================================== + + Test.@testset "positional Solution constructor without switching" begin + sol = _sol_1d() + s10 = Solutions.Solution( + sol.time_grid, + sol.times, + sol.state, + sol.control, + sol.variable, + sol.model, + sol.costate, + sol.objective, + sol.dual, + sol.solver_infos, + ) + Test.@test Solutions.switching_model(s10) isa Solutions.EmptySwitchingModel + Test.@test CTModels.state(s10)(0.55) ≈ 1.55 # trajectories kept + end + + # ==================================================================== + # UNIT TESTS - display + # ==================================================================== + + Test.@testset "show: switching times and phases" begin + s = _show(_sol_1d()) + Test.@test occursin("Switching times : [0.5]", s) + Test.@test occursin("Phases : bang+ → bang-", s) + # the last row closes the tree + Test.@test occursin("└─ Phases", s) + # unlabelled phase shows as · + s2 = _show(_sol_1d(; switching=_sw_1d(; labels=["", "bang-"]))) + Test.@test occursin("Phases : · → bang-", s2) + # no label at all: no Phases row + s3 = _show(_sol_1d(; switching=_sw_1d(; labels=["", ""]))) + Test.@test occursin("Switching times", s3) + Test.@test !occursin("Phases", s3) + # no switching structure: neither row + s4 = _show(_sol_1d(; switching=Solutions.EmptySwitchingModel())) + Test.@test !occursin("Switching", s4) + Test.@test !occursin("Phases", s4) + # a labelled single phase: a Phases row, no switching times + one = Solutions.SwitchingModel(; times=Float64[], labels=["bang"]) + s5 = _show(_sol_1d(; switching=one)) + Test.@test occursin("Phases : bang", s5) + Test.@test !occursin("Switching times", s5) + end + + Test.@testset "show: separator before solver metadata" begin + sol = _sol_1d(; iterations=3, status=:ok) + s = _show(sol) + Test.@test occursin("Phases", s) + Test.@test occursin("Iterations", s) + lines = split(s, '\n') + i = findfirst(l -> occursin("Phases", l), lines) + Test.@test strip(lines[i + 1]) == "│" + end + + # ==================================================================== + # TYPE STABILITY + # ==================================================================== + + Test.@testset "type stability" begin + sol = _sol_1d() + Test.@test_nowarn Test.@inferred CTModels.switching_times(sol) + Test.@test_nowarn Test.@inferred CTModels.phase_labels(sol) + Test.@test_nowarn Test.@inferred CTModels.has_switching(sol) + x = CTModels.state(sol) + Test.@test_nowarn Test.@inferred x(0.55) + end + end +end + +end # module + +test_switching() = TestSwitching.test_switching() From e1db09e51a4c86e510c4f76f93d4c026be5e3e61 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Sun, 18 Oct 2026 12:30:51 +0200 Subject: [PATCH 2/5] refactor: plot data from the CTBase building blocks; review fixes - PlotCase uses Plotting.splice_jumps / switching_vlines / phase_annotations instead of its own copies (shared with CTFlows); _phase_label_group extracted from _build_root - left_limit/right_limit: PreconditionError when the limits are not stored (EXC1) - suggestions on every new error (EXC3); one component check for the limits - one pass for the repeated-time check; serialized limit keys from one function, reused by the JSON extension Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++- ext/CTModelsJSON.jl | 9 +--- src/PlotCase/build.jl | 42 +++++++++++------ src/PlotCase/decorations.jl | 55 +++++++--------------- src/PlotCase/panels.jl | 53 +++++---------------- src/PlotCase/vocabulary.jl | 1 + src/Solutions/build_solution.jl | 36 ++++++++------ src/Solutions/switching_model.jl | 53 ++++++++++++++------- test/suite/plotting/test_plot_switching.jl | 34 +++++++++---- test/suite/solutions/test_switching.jl | 5 +- 10 files changed, 150 insertions(+), 145 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74a5e065..59c15be3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,14 +24,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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)`. + `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. - 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`). + `phase_labels_style`; an unlabelled phase gets no text). 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 diff --git a/ext/CTModelsJSON.jl b/ext/CTModelsJSON.jl index a84a4d40..db5081e6 100644 --- a/ext/CTModelsJSON.jl +++ b/ext/CTModelsJSON.jl @@ -30,14 +30,7 @@ const _OPTIONAL_MATRIX_FIELDS = [ ] "Switching limits (present when the solution holds a switching structure), converted like the other matrices." -const _SWITCHING_MATRIX_FIELDS = [ - "switching_state_left", - "switching_state_right", - "switching_costate_left", - "switching_costate_right", - "switching_control_left", - "switching_control_right", -] +const _SWITCHING_MATRIX_FIELDS = [first(k) for k in CTModels.Solutions._SWITCHING_LIMIT_KEYS] """ Convert Matrix fields to Vector{Vector} for JSON3 export. diff --git a/src/PlotCase/build.jl b/src/PlotCase/build.jl index 46ad1c60..689d0ab7 100644 --- a/src/PlotCase/build.jl +++ b/src/PlotCase/build.jl @@ -25,6 +25,25 @@ function _time_name(sol, time::Symbol) end end +""" +$(TYPEDSIGNATURES) + +Return the group that receives the phase labels: `:all` for `phase_labels = :all`; for +`:control`, the control group when it is drawn, otherwise the first drawn group, in the +order of the figure (`:state`, `:costate`, `:path`, `:dual`), or `:none` when no group is +drawn. The keywords tell which groups are drawn. +""" +function _phase_label_group( + phase_labels::Symbol; state::Bool, costate::Bool, control::Bool, path::Bool, dual::Bool +)::Symbol + phase_labels === :all && return :all + control && return :control + for (group, drawn) in ((:state, state), (:costate, costate), (:path, path), (:dual, dual)) + drawn && return group + end + return :none +end + # Build the layout tree (or `nothing` if there is nothing to draw). """ $(TYPEDSIGNATURES) @@ -87,21 +106,14 @@ function _build_root( else _phase_annotations(sol, time, phase_labels_style) end - label_group = if phase_labels === :all - :all - elseif do_control - :control - else - first( - g for (g, on) in ( - (:state, do_state), - (:costate, do_costate), - (:path, do_path), - (:dual, do_dual), - (:none, true), - ) if on - ) - end + label_group = _phase_label_group( + phase_labels; + state=do_state, + costate=do_costate, + control=do_control, + path=do_path, + dual=do_dual, + ) _texts(group) = (label_group === :all || label_group === group) ? texts : Plotting.Annotation[] ncomp(p) = size(p.data, 2) L(p; hlines=Vector{Plotting.HLine}[], group::Symbol=:none) = Plotting.lower( diff --git a/src/PlotCase/decorations.jl b/src/PlotCase/decorations.jl index 657e9cb5..2553a24b 100644 --- a/src/PlotCase/decorations.jl +++ b/src/PlotCase/decorations.jl @@ -91,22 +91,6 @@ function _time_vlines(sol, model, time::Symbol, style::NamedTuple) return Plotting.VLine[Plotting.VLine(t0; style=s), Plotting.VLine(tf; style=s)] end -# Plotted abscissa of a time `t`: itself, or rescaled to `[0, 1]` over the state grid -# under time normalisation — the same transform `Plotting.lower` applies to the panels. -""" -$(TYPEDSIGNATURES) - -Return the plotted abscissa of time `t`: `t` itself, or `t` rescaled to `[0, 1]` over the -state time grid when `time` is `:normalize` / `:normalise` (the transform -[`CTBase.Plotting.lower`](@extref) applies to the panels). -""" -function _plotted_time(sol, t::Real, time::Symbol) - (time === :normalize || time === :normalise) || return Float64(t) - T = CTModels.time_grid(sol, :state) - t0, tf = first(T), last(T) - return tf > t0 ? (t - t0) / (tf - t0) : Float64(t - t0) -end - """ $(TYPEDSIGNATURES) @@ -122,36 +106,29 @@ end """ $(TYPEDSIGNATURES) -Build the vertical lines at the switching times of `sol`, shared by every cell. Empty when -the solution has no switching time. +Build the vertical lines at the switching times of `sol`, shared by every cell, with +[`CTBase.Plotting.switching_vlines`](@extref) over the state time grid. Empty when the +solution has no switching time. """ function _switching_vlines(sol, time::Symbol, style::NamedTuple) - return Plotting.VLine[ - Plotting.VLine(_plotted_time(sol, τ, time); style=style) for - τ in CTModels.switching_times(sol) - ] + return Plotting.switching_vlines( + CTModels.time_grid(sol, :state), CTModels.switching_times(sol); time=time, style=style + ) end -""" -Default style of the phase labels: small grey text. -""" -const _PHASE_LABEL_STYLE = (color=:gray30, fontsize=9) - """ $(TYPEDSIGNATURES) -Build the phase labels of `sol` as texts centred on each phase, near the top of the cell. -A phase with an empty label gets no text; a solution with no label gets none. +Build the phase labels of `sol` as texts centred on each phase, with +[`CTBase.Plotting.phase_annotations`](@extref) over the state time grid. A phase with an +empty label gets no text; a solution with no label gets none. """ function _phase_annotations(sol, time::Symbol, style::NamedTuple) - labels = CTModels.phase_labels(sol) - any(!isempty, labels) || return Plotting.Annotation[] - T = CTModels.time_grid(sol, :state) - bounds = vcat(first(T), CTModels.switching_times(sol), last(T)) - s = merge(_PHASE_LABEL_STYLE, style) - return Plotting.Annotation[ - Plotting.Annotation( - _plotted_time(sol, (bounds[k] + bounds[k + 1]) / 2, time), labels[k]; style=s - ) for k in eachindex(labels) if !isempty(labels[k]) - ] + return Plotting.phase_annotations( + CTModels.time_grid(sol, :state), + CTModels.switching_times(sol), + CTModels.phase_labels(sol); + time=time, + style=style, + ) end diff --git a/src/PlotCase/panels.jl b/src/PlotCase/panels.jl index 281d43f2..c121c093 100644 --- a/src/PlotCase/panels.jl +++ b/src/PlotCase/panels.jl @@ -24,42 +24,17 @@ _sample(f, T) = Matrix{Float64}(reduce(hcat, f.(T))') $(TYPEDSIGNATURES) Insert the left and right limits of `component` (`:state`, `:costate` or `:control`) at the -switching times of `sol` into the samples `(T, M)` of that component. - -At every switching time `τ`, the rows `(τ, left)` then `(τ, right)` are inserted at their -place in time, and a sample already at `τ` is replaced, so that a jump is drawn as a -vertical segment. The samples are returned unchanged when the solution has no switching -time or the limits of `component` are not known. +switching times of `sol` into the samples `(T, M)` of that component, with +[`CTBase.Plotting.splice_jumps`](@extref): a jump is drawn as a vertical segment. The samples +are returned unchanged when the solution has no switching time or the limits of +`component` are not known. # Returns - `(T, M)`: the time grid (with each switching time twice) and the matrix of samples. """ -function _with_jumps(sol, T::AbstractVector, M::AbstractMatrix, component::Symbol) - taus = CTModels.switching_times(sol) - isempty(taus) && return (T, M) +function _spliced_samples(sol, T::AbstractVector, M::AbstractMatrix, component::Symbol) left, right = Solutions.limits(CTModels.switching_model(sol), component) - size(left, 2) == size(M, 2) || return (T, M) - Tn = Float64[] - rows = Vector{Vector{Float64}}() - i = 1 - for k in eachindex(T) - while i <= length(taus) && taus[i] <= T[k] - push!(Tn, taus[i], taus[i]) - push!(rows, left[i, :], right[i, :]) - i += 1 - end - # a sample exactly at a switching time is replaced by its two limits - (i > 1 && T[k] == taus[i - 1]) && continue - push!(Tn, T[k]) - push!(rows, M[k, :]) - end - while i <= length(taus) # switching times after the last sample - push!(Tn, taus[i], taus[i]) - push!(rows, left[i, :], right[i, :]) - i += 1 - end - Mn = isempty(rows) ? zeros(0, size(M, 2)) : Matrix{Float64}(reduce(hcat, rows)') - return (Tn, reshape(Mn, length(Tn), size(M, 2))) + return Plotting.splice_jumps(T, M, CTModels.switching_times(sol), left, right) end # Euclidean norm of a matrix row. @@ -86,12 +61,8 @@ $(TYPEDSIGNATURES) Build a [`CTBase.Plotting.Panel`](@extref) for the state trajectory. """ function _state_panel(sol, style::NamedTuple) - T, M = _with_jumps( - sol, - CTModels.time_grid(sol, :state), - _sample(CTModels.state(sol), CTModels.time_grid(sol, :state)), - :state, - ) + Tx = CTModels.time_grid(sol, :state) + T, M = _spliced_samples(sol, Tx, _sample(CTModels.state(sol), Tx), :state) return Plotting.Panel( T, M; title="state", labels=CTModels.state_components(sol), style=_base_style(style) ) @@ -109,7 +80,7 @@ Labels depend on `layout` and whether the state panel is shown alongside. """ function _costate_panel(sol, style::NamedTuple; layout::Symbol, state_shown::Bool) Tc = CTModels.time_grid(sol, :costate) - T, M = _with_jumps(sol, Tc, _sample(CTModels.costate(sol), Tc), :costate) + T, M = _spliced_samples(sol, Tc, _sample(CTModels.costate(sol), Tc), :costate) n = size(M, 2) p_labels = "p" .* CTModels.state_components(sol) labels = (layout === :split && state_shown) ? fill("", n) : p_labels @@ -135,7 +106,7 @@ Build one or more [`CTBase.Plotting.Panel`](@extref) objects for the control tra """ function _control_panels(sol, control::Symbol, style::NamedTuple, layout::Symbol) Tu = CTModels.time_grid(sol, :control) - T, U = _with_jumps(sol, Tu, _sample(CTModels.control(sol), Tu), :control) + T, U = _spliced_samples(sol, Tu, _sample(CTModels.control(sol), Tu), :control) u_labels = CTModels.control_components(sol) u_norm_label = "‖" * CTModels.control_name(sol) * "‖" st = CTModels.control_interpolation(sol) == :constant ? :steppost : :path @@ -185,8 +156,8 @@ function _path_panel(sol, model, style::NamedTuple) X, U = CTModels.state(sol).(Tp), CTModels.control(sol).(Tp) # at the switching times, evaluate the constraints at both limits of the state and # control (when both are known) - T, MX = _with_jumps(sol, Tp, _sample(CTModels.state(sol), Tp), :state) - Tu, MU = _with_jumps(sol, Tp, _sample(CTModels.control(sol), Tp), :control) + T, MX = _spliced_samples(sol, Tp, _sample(CTModels.state(sol), Tp), :state) + Tu, MU = _spliced_samples(sol, Tp, _sample(CTModels.control(sol), Tp), :control) if length(T) != length(Tp) && T == Tu X = [_as_value(MX[k, :]) for k in axes(MX, 1)] U = [_as_value(MU[k, :]) for k in axes(MU, 1)] diff --git a/src/PlotCase/vocabulary.jl b/src/PlotCase/vocabulary.jl index ace3482a..94653166 100644 --- a/src/PlotCase/vocabulary.jl +++ b/src/PlotCase/vocabulary.jl @@ -56,6 +56,7 @@ function _check_phase_labels(phase_labels::Symbol) "Invalid phase_labels choice"; got="phase_labels=$phase_labels", expected=":control, :all or :none", + suggestion="Use phase_labels=:control (default), :all or :none", context="CTModels.PlotCase.build_figure", ), ) diff --git a/src/Solutions/build_solution.jl b/src/Solutions/build_solution.jl index 568806f8..1fb6bb76 100644 --- a/src/Solutions/build_solution.jl +++ b/src/Solutions/build_solution.jl @@ -513,6 +513,8 @@ function _check_switching(sw::SwitchingModel, T::Vector{Float64}, dim_x::Int, di "switching time outside the time span"; got="switching time $τ", expected="a time in ($t0, $tf), strictly inside the time grid", + suggestion="Give switching times strictly between the first and last " * + "times of the grid", context="build_solution - validating the switching model", ), ) @@ -524,6 +526,7 @@ function _check_switching(sw::SwitchingModel, T::Vector{Float64}, dim_x::Int, di "wrong dimension of the $component limits"; got="$ncols column(s)", expected="$dim columns (one per $component component), or 0 if not known", + suggestion="Give one column per $component component in the limits", context="build_solution - validating the switching model", ), ) @@ -552,10 +555,7 @@ Validate and fix a time grid by ensuring it is strictly increasing. If the grid is not increasing, it is reordered and a warning is emitted. """ function _validate_and_fix_time_grid(T::Vector{Float64}, component_name::String) - for i in 1:(length(T) - 1) - # after sorting, a repeated time would be adjacent: check on the sorted grid - T[i] == T[i + 1] && _throw_repeated_time(T[i], component_name) - end + Ts = T if !issorted(T; lt=(<)) # Build appropriate message based on component name components_with_issues = [component_name] # TODO: Collect all components when called multiple times @@ -568,12 +568,12 @@ function _validate_and_fix_time_grid(T::Vector{Float64}, component_name::String) @warn msg Ts = sort(T) - for i in 1:(length(Ts) - 1) - Ts[i] == Ts[i + 1] && _throw_repeated_time(Ts[i], component_name) - end - return Ts end - return T + # on the sorted grid, a repeated time is adjacent + for i in 1:(length(Ts) - 1) + Ts[i] == Ts[i + 1] && _throw_repeated_time(Ts[i], component_name) + end + return Ts end """ @@ -1725,13 +1725,23 @@ Version history: """ const SERIALIZATION_FORMAT_VERSION = 2 +""" +$(TYPEDSIGNATURES) + +Return the key of the serialized `side` limit (`1` left, `2` right) of `component`, e.g. +`"switching_state_left"`. +""" +function _switching_limit_key(component::Symbol, side::Int)::String + return "switching_$(component)_$(side == 1 ? "left" : "right")" +end + """ Keys of the serialized switching limits, as `(key, component, side)` with `side = 1` for the left limit and `2` for the right limit. """ const _SWITCHING_LIMIT_KEYS = Tuple( - ("switching_$(c)_$(side_name)", c, side) for c in (:state, :costate, :control) for - (side, side_name) in ((1, "left"), (2, "right")) + (_switching_limit_key(c, side), c, side) for c in (:state, :costate, :control) for + side in (1, 2) ) """ @@ -1763,9 +1773,7 @@ function _deserialize_switching(data)::AbstractSwitchingModel haskey(data, "switching_times") || return EmptySwitchingModel() times = Vector{Float64}(collect(data["switching_times"])) labels = collect(String, data["phase_labels"]) - lim(c, side) = Matrix{Float64}( - data[first(k for (k, cc, ss) in _SWITCHING_LIMIT_KEYS if cc === c && ss == side)] - ) + lim(c, side) = Matrix{Float64}(data[_switching_limit_key(c, side)]) return SwitchingModel(; times=times, labels=labels, diff --git a/src/Solutions/switching_model.jl b/src/Solutions/switching_model.jl index 097e79b7..765ae0ea 100644 --- a/src/Solutions/switching_model.jl +++ b/src/Solutions/switching_model.jl @@ -108,6 +108,7 @@ function SwitchingModel(; "switching times must be finite"; got="times=$ts", expected="finite times", + suggestion="Give the switching times as finite numbers", context="SwitchingModel", ), ) @@ -124,7 +125,8 @@ function SwitchingModel(; Exceptions.IncorrectArgument( "one label per phase"; got="$(length(labels)) label(s) for $(length(ts) + 1) phase(s)", - expected="$(length(ts) + 1) labels (length(times) + 1); use \"\" for no label", + expected="$(length(ts) + 1) labels (length(times) + 1)", + suggestion="Give one label per phase, \"\" for a phase with no label", context="SwitchingModel", ), ) @@ -162,6 +164,7 @@ function _checked_limits(lr, n::Integer, name::String) "one row of $name limits per switching time"; got="$(size(left, 1)) and $(size(right, 1)) rows (left, right)", expected="$n rows (one per switching time)", + suggestion="Give the $name limit at the i-th switching time in row i", context="SwitchingModel - $name limits", ), ) @@ -170,6 +173,7 @@ function _checked_limits(lr, n::Integer, name::String) "left and right $name limits must have the same size"; got="left $(size(left)), right $(size(right))", expected="two matrices of the same size", + suggestion="Give the left and right $name limits for the same components", context="SwitchingModel - $name limits", ), ) @@ -226,17 +230,10 @@ Return the `(left, right)` matrices of limits of `component` (`:state`, `:costat - [`CTBase.Exceptions.IncorrectArgument`](@extref): if `component` is not one of these. """ function limits(m::SwitchingModel, component::Symbol)::NTuple{2,Matrix{Float64}} + _check_limit_component(component) component === :state && return m.state component === :costate && return m.costate - component === :control && return m.control - throw( - Exceptions.IncorrectArgument( - "unknown component for switching limits"; - got=":$component", - expected=":state, :costate or :control", - context="limits(::SwitchingModel, component)", - ), - ) + return m.control end """ @@ -246,15 +243,31 @@ Return the `(left, right)` limits of `component`: none for an [`CTModels.Solutions.EmptySwitchingModel`](@extref) (two `0 × 0` matrices). """ function limits(::EmptySwitchingModel, component::Symbol)::NTuple{2,Matrix{Float64}} + _check_limit_component(component) + return (zeros(0, 0), zeros(0, 0)) +end + +""" +$(TYPEDSIGNATURES) + +Check that `component` is a component with switching limits: `:state`, `:costate` or +`:control`. + +# Throws + +- [`CTBase.Exceptions.IncorrectArgument`](@extref): if it is not. +""" +function _check_limit_component(component::Symbol) component in (:state, :costate, :control) || throw( Exceptions.IncorrectArgument( "unknown component for switching limits"; got=":$component", expected=":state, :costate or :control", - context="limits(::EmptySwitchingModel, component)", + suggestion="Use :state, :costate or :control", + context="switching limits", ), ) - return (zeros(0, 0), zeros(0, 0)) + return nothing end # ------------------------------------------------------------------------------ # @@ -318,7 +331,9 @@ A one-dimensional component is returned as a scalar. # Throws - [`CTBase.Exceptions.IncorrectArgument`](@extref): if the solution has no `i`-th switching - time, if its limits of `component` are not known, or if `component` is unknown. + time, or if `component` is unknown. +- [`CTBase.Exceptions.PreconditionError`](@extref): if the limits of `component` are not + stored in the switching model of the solution. See also: [`CTModels.Solutions.right_limit`](@extref). """ @@ -334,7 +349,9 @@ control law. A one-dimensional component is returned as a scalar. # Throws - [`CTBase.Exceptions.IncorrectArgument`](@extref): if the solution has no `i`-th switching - time, if its limits of `component` are not known, or if `component` is unknown. + time, or if `component` is unknown. +- [`CTBase.Exceptions.PreconditionError`](@extref): if the limits of `component` are not + stored in the switching model of the solution. See also: [`CTModels.Solutions.left_limit`](@extref). """ @@ -355,14 +372,16 @@ function _limit(sol::Solution, component::Symbol, i::Integer, side::Int) "no switching time with this index"; got="i = $i", expected=n == 0 ? "no index: the solution has no switching time" : "1 ≤ i ≤ $n", + suggestion="Use an index of switching_times(sol)", context=(side == 1 ? "left_limit" : "right_limit"), ), ) size(M, 2) == 0 && throw( - Exceptions.IncorrectArgument( + Exceptions.PreconditionError( "the $component limits at the switching times are not known"; - got="no $component limit stored in the switching model", - expected="limits given to SwitchingModel(; $component=(left, right))", + reason="the switching model of the solution stores no $component limit", + suggestion="Build the solution with " * + "switching=SwitchingModel(; $component=(left, right), ...)", context=(side == 1 ? "left_limit" : "right_limit"), ), ) diff --git a/test/suite/plotting/test_plot_switching.jl b/test/suite/plotting/test_plot_switching.jl index 5fc30b74..9de3bec1 100644 --- a/test/suite/plotting/test_plot_switching.jl +++ b/test/suite/plotting/test_plot_switching.jl @@ -79,9 +79,9 @@ function test_plot_switching() # UNIT TESTS - vertical jumps in the panel data # ==================================================================== - Test.@testset "_with_jumps: insertion of the limits" begin + Test.@testset "_spliced_samples: insertion of the limits" begin sol = _sol() - Ts, M = CTModels.PlotCase._with_jumps(sol, T, reshape(_x.(T), :, 1), :state) + Ts, M = CTModels.PlotCase._spliced_samples(sol, T, reshape(_x.(T), :, 1), :state) # τ is in the grid: its sample is replaced by (τ, left), (τ, right) Test.@test count(==(τ), Ts) == 2 Test.@test length(Ts) == length(T) + 1 @@ -91,19 +91,37 @@ function test_plot_switching() Test.@test issorted(Ts) # τ not in the samples: inserted at its place Tn = [0.0, 0.5, 1.5, 2.0] - Ts2, M2 = CTModels.PlotCase._with_jumps(sol, Tn, reshape(_x.(Tn), :, 1), :state) + Ts2, M2 = CTModels.PlotCase._spliced_samples(sol, Tn, reshape(_x.(Tn), :, 1), :state) Test.@test Ts2 == [0.0, 0.5, τ, τ, 1.5, 2.0] Test.@test M2[:, 1] ≈ [0.0, 0.5, τ, τ + 1.0, 2.5, 3.0] # τ after the last sample - Ts3, _ = CTModels.PlotCase._with_jumps(sol, [0.0, 0.5], zeros(2, 1), :state) + Ts3, _ = CTModels.PlotCase._spliced_samples(sol, [0.0, 0.5], zeros(2, 1), :state) Test.@test Ts3 == [0.0, 0.5, τ, τ] - # unchanged when no switching time, or limits not known, or dimension mismatch + # unchanged when no switching time, or limits not known plain = _sol(; switching=Solutions.EmptySwitchingModel()) - Test.@test CTModels.PlotCase._with_jumps(plain, T, zeros(11, 1), :state) == + Test.@test CTModels.PlotCase._spliced_samples(plain, T, zeros(11, 1), :state) == (T, zeros(11, 1)) nolim = _sol(; switching=_sw(; limits=false)) - Test.@test CTModels.PlotCase._with_jumps(nolim, T, zeros(11, 1), :state)[1] == T - Test.@test CTModels.PlotCase._with_jumps(sol, T, zeros(11, 2), :state)[1] == T + Test.@test CTModels.PlotCase._spliced_samples(nolim, T, zeros(11, 1), :state)[1] == T + # samples with other components than the limits: an error + Test.@test_throws Exceptions.IncorrectArgument CTModels.PlotCase._spliced_samples( + sol, T, zeros(11, 2), :state + ) + end + + Test.@testset "_phase_label_group" begin + g(pl; kw...) = CTModels.PlotCase._phase_label_group( + pl; + (; state=false, costate=false, control=false, path=false, dual=false)..., + kw..., + ) + Test.@test g(:all; control=true) === :all + Test.@test g(:control; state=true, control=true) === :control + Test.@test g(:control; state=true, costate=true) === :state + Test.@test g(:control; costate=true, path=true) === :costate + Test.@test g(:control; path=true, dual=true) === :path + Test.@test g(:control; dual=true) === :dual + Test.@test g(:control) === :none end Test.@testset "state, costate and control panels draw vertical jumps" begin diff --git a/test/suite/solutions/test_switching.jl b/test/suite/solutions/test_switching.jl index 008237d7..445c6652 100644 --- a/test/suite/solutions/test_switching.jl +++ b/test/suite/solutions/test_switching.jl @@ -322,9 +322,12 @@ function test_switching() ) # limits not known nolim = _sol_1d(; switching=Solutions.SwitchingModel(; times=[τ])) - Test.@test_throws Exceptions.IncorrectArgument CTModels.left_limit( + Test.@test_throws Exceptions.PreconditionError CTModels.left_limit( nolim, :state, 1 ) + Test.@test_throws Exceptions.PreconditionError CTModels.right_limit( + nolim, :control, 1 + ) end Test.@testset "strict grid: a repeated time is an error" begin From 2840ed7247a2ced7e9141e5726a9fa4af9c74154 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Sun, 18 Oct 2026 12:56:58 +0200 Subject: [PATCH 3/5] docs: no line starting with the word import (MOD3 meta test) Co-Authored-By: Claude Opus 5.5 --- docs/src/serialization/export_import.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/serialization/export_import.md b/docs/src/serialization/export_import.md index 4362fbbf..4c4d239e 100644 --- a/docs/src/serialization/export_import.md +++ b/docs/src/serialization/export_import.md @@ -131,7 +131,7 @@ mechanism used when [building a solution](../solution/trajectories.md). ## 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. On -import, the trajectories are rebuilt phase by phase, so they keep their jumps. A file +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). From 15fd66bbe7705ba6ef0b371cd31c687fd6a5a647 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Sun, 18 Oct 2026 15:32:50 +0200 Subject: [PATCH 4/5] feat: plot keyword jumps (:segment | :none); displays of the switching models - plot(sol; jumps=:none) breaks the curves between the two limits instead of drawing the vertical segment (default :segment); the path constraints are evaluated at both limits and spliced the same way - Base.show for SwitchingModel (tree, as the Solution display) and EmptySwitchingModel; the phase sequence comes from one helper Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +- docs/src/serialization/plotting.md | 3 +- docs/src/solution/switching.md | 13 +++ ext/CTModelsMakie.jl | 2 +- ext/CTModelsPlots.jl | 2 + src/PlotCase/build.jl | 30 ++++-- src/PlotCase/panels.jl | 75 +++++++++----- src/PlotCase/vocabulary.jl | 29 ++++++ src/Solutions/show.jl | 2 +- src/Solutions/switching_model.jl | 98 +++++++++++++++++++ .../extensions/test_plot_switching_render.jl | 6 ++ test/suite/plotting/test_plot_switching.jl | 25 +++++ test/suite/solutions/test_switching.jl | 23 +++++ 13 files changed, 276 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59c15be3..1aa5a48b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,11 +28,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `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. + - 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). The plot data comes from the + `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 diff --git a/docs/src/serialization/plotting.md b/docs/src/serialization/plotting.md index 91e97e98..4980fdad 100644 --- a/docs/src/serialization/plotting.md +++ b/docs/src/serialization/plotting.md @@ -81,7 +81,8 @@ A solution with a switching structure (see [Switching structure](../solution/swi 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). +`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 diff --git a/docs/src/solution/switching.md b/docs/src/solution/switching.md index 18d56f65..d9593232 100644 --- a/docs/src/solution/switching.md +++ b/docs/src/solution/switching.md @@ -95,11 +95,24 @@ plot(sol) | `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 diff --git a/ext/CTModelsMakie.jl b/ext/CTModelsMakie.jl index a1ea29ce..d8293482 100644 --- a/ext/CTModelsMakie.jl +++ b/ext/CTModelsMakie.jl @@ -78,7 +78,7 @@ Makie backend. Same `description` and keyword arguments as the Plots-backend `plot` (`layout`, `control`, `time`, the `*_style` / `*_bounds_style` keywords, -`switching_style`, `phase_labels`, `phase_labels_style`, `color`, `size`). Returns a +`switching_style`, `phase_labels`, `phase_labels_style`, `jumps`, `color`, `size`). Returns a `Makie.Figure`. # Example diff --git a/ext/CTModelsPlots.jl b/ext/CTModelsPlots.jl index e23a4a9e..15641bac 100644 --- a/ext/CTModelsPlots.jl +++ b/ext/CTModelsPlots.jl @@ -117,6 +117,8 @@ limit. `CTModels.phase_labels`): `:control` (the control cell, or the first drawn group when the control is not drawn), `:all` (every group) or `:none`. - `phase_labels_style`: style of the labels (`color`, `fontsize`, `halign`, `valign`). +- `jumps::Symbol = :segment`: a jump is drawn as a vertical segment (`:segment`) or as a + break in the curve (`:none`). # Returns A `Plots.Plot`. All layout and rendering is delegated to `CTBase.Plotting`. diff --git a/src/PlotCase/build.jl b/src/PlotCase/build.jl index 689d0ab7..7ef4e66b 100644 --- a/src/PlotCase/build.jl +++ b/src/PlotCase/build.jl @@ -71,9 +71,12 @@ function _build_root( switching_style::Union{NamedTuple,Symbol}=__plot_style(), phase_labels::Symbol=__phase_labels(), phase_labels_style::NamedTuple=__plot_style(), + jumps::Symbol=__plot_jumps(), ) model = CTModels.model(sol) _check_phase_labels(phase_labels) + _check_jumps(jumps) + connect = jumps === :segment desc = clean(isempty(description) ? __description() : description) do_state, do_costate, do_control, do_path, do_dual = do_plot( sol, @@ -129,17 +132,19 @@ function _build_root( if layout === :group # No bounds lines in :group (historical); only the time markers, via `vlines`. cells = Plotting.AbstractLayoutNode[] - do_state && push!(cells, L(_state_panel(sol, state_style); group=:state)) + do_state && push!(cells, L(_state_panel(sol, state_style; connect=connect); group=:state)) do_costate && push!( cells, L( - _costate_panel(sol, costate_style; layout=layout, state_shown=do_state); + _costate_panel( + sol, costate_style; layout=layout, state_shown=do_state, connect=connect + ); group=:costate, ), ) if do_control # with `control=:all`, the labels go to the first control cell only - for (k, cp) in enumerate(_control_panels(sol, control, control_style, layout)) + for (k, cp) in enumerate(_control_panels(sol, control, control_style, layout; connect=connect)) push!(cells, L(cp; group=(k == 1 ? :control : :none))) end end @@ -148,7 +153,7 @@ function _build_root( elseif layout === :split state_col = nothing if do_state - sp = _state_panel(sol, state_style) + sp = _state_panel(sol, state_style; connect=connect) hl = if dec_state_bounds _box_hlines( CTModels.state_constraints_box(model), ncomp(sp), state_bounds_style @@ -160,7 +165,9 @@ function _build_root( end costate_col = if do_costate L( - _costate_panel(sol, costate_style; layout=layout, state_shown=do_state); + _costate_panel( + sol, costate_style; layout=layout, state_shown=do_state, connect=connect + ); group=:costate, ) else @@ -168,7 +175,7 @@ function _build_root( end control_col = nothing if do_control - cp = only(_control_panels(sol, control, control_style, layout)) + cp = only(_control_panels(sol, control, control_style, layout; connect=connect)) hl = if (dec_control_bounds && control !== :norm) _box_hlines( CTModels.control_constraints_box(model), @@ -182,7 +189,7 @@ function _build_root( end path_col = nothing if do_path - pp = _path_panel(sol, model, path_style) + pp = _path_panel(sol, model, path_style; connect=connect) hl = if dec_path_bounds _path_hlines(model, path_bounds_style) else @@ -238,10 +245,14 @@ assembles the layout template and wraps it in a `Figure`. The `CTModelsPlots` / first drawn group when the control is not drawn), `:all` (every group) or `:none`. - `phase_labels_style::NamedTuple = NamedTuple()`: style of the phase labels (`color`, `fontsize`, `halign`, `valign`). +- `jumps::Symbol = :segment`: how a jump at a switching time is drawn: `:segment`, a + vertical segment from the left to the right limit (it makes the jumps easy to see and to + compare with a smoothed jump, e.g. from a direct method), or `:none`, the curve is broken + between the two limits (no segment suggests values the trajectory does not take). # Throws - [`CTBase.Exceptions.IncorrectArgument`](@extref): for an invalid `layout`, `control`, - `time` or `phase_labels`. + `time`, `phase_labels` or `jumps`. """ function build_figure( sol::CTModels.Solution, @@ -261,6 +272,7 @@ function build_figure( switching_style::Union{NamedTuple,Symbol}=__plot_style(), phase_labels::Symbol=__phase_labels(), phase_labels_style::NamedTuple=__plot_style(), + jumps::Symbol=__plot_jumps(), size=nothing, ) root = _build_root( @@ -281,6 +293,7 @@ function build_figure( switching_style=switching_style, phase_labels=phase_labels, phase_labels_style=phase_labels_style, + jumps=jumps, ) root === nothing && return nothing return Plotting.Figure(root; size=size) @@ -306,6 +319,7 @@ const _BUILD_KEYS = ( :switching_style, :phase_labels, :phase_labels_style, + :jumps, ) """ diff --git a/src/PlotCase/panels.jl b/src/PlotCase/panels.jl index c121c093..1143956d 100644 --- a/src/PlotCase/panels.jl +++ b/src/PlotCase/panels.jl @@ -27,14 +27,19 @@ Insert the left and right limits of `component` (`:state`, `:costate` or `:contr switching times of `sol` into the samples `(T, M)` of that component, with [`CTBase.Plotting.splice_jumps`](@extref): a jump is drawn as a vertical segment. The samples are returned unchanged when the solution has no switching time or the limits of -`component` are not known. +`component` are not known. With `connect = false`, the curve is broken between the two +limits instead of drawing the vertical segment. # Returns - `(T, M)`: the time grid (with each switching time twice) and the matrix of samples. """ -function _spliced_samples(sol, T::AbstractVector, M::AbstractMatrix, component::Symbol) +function _spliced_samples( + sol, T::AbstractVector, M::AbstractMatrix, component::Symbol; connect::Bool=true +) left, right = Solutions.limits(CTModels.switching_model(sol), component) - return Plotting.splice_jumps(T, M, CTModels.switching_times(sol), left, right) + return Plotting.splice_jumps( + T, M, CTModels.switching_times(sol), left, right; connect=connect + ) end # Euclidean norm of a matrix row. @@ -60,9 +65,11 @@ $(TYPEDSIGNATURES) Build a [`CTBase.Plotting.Panel`](@extref) for the state trajectory. """ -function _state_panel(sol, style::NamedTuple) +function _state_panel(sol, style::NamedTuple; connect::Bool=true) Tx = CTModels.time_grid(sol, :state) - T, M = _spliced_samples(sol, Tx, _sample(CTModels.state(sol), Tx), :state) + T, M = _spliced_samples( + sol, Tx, _sample(CTModels.state(sol), Tx), :state; connect=connect + ) return Plotting.Panel( T, M; title="state", labels=CTModels.state_components(sol), style=_base_style(style) ) @@ -78,9 +85,13 @@ Build a [`CTBase.Plotting.Panel`](@extref) for the costate trajectory. Labels depend on `layout` and whether the state panel is shown alongside. """ -function _costate_panel(sol, style::NamedTuple; layout::Symbol, state_shown::Bool) +function _costate_panel( + sol, style::NamedTuple; layout::Symbol, state_shown::Bool, connect::Bool=true +) Tc = CTModels.time_grid(sol, :costate) - T, M = _spliced_samples(sol, Tc, _sample(CTModels.costate(sol), Tc), :costate) + T, M = _spliced_samples( + sol, Tc, _sample(CTModels.costate(sol), Tc), :costate; connect=connect + ) n = size(M, 2) p_labels = "p" .* CTModels.state_components(sol) labels = (layout === :split && state_shown) ? fill("", n) : p_labels @@ -104,9 +115,13 @@ Build one or more [`CTBase.Plotting.Panel`](@extref) objects for the control tra # Returns - `Vector{Plotting.Panel}`: The panels to be assembled into the layout. """ -function _control_panels(sol, control::Symbol, style::NamedTuple, layout::Symbol) +function _control_panels( + sol, control::Symbol, style::NamedTuple, layout::Symbol; connect::Bool=true +) Tu = CTModels.time_grid(sol, :control) - T, U = _spliced_samples(sol, Tu, _sample(CTModels.control(sol), Tu), :control) + T, U = _spliced_samples( + sol, Tu, _sample(CTModels.control(sol), Tu), :control; connect=connect + ) u_labels = CTModels.control_components(sol) u_norm_label = "‖" * CTModels.control_name(sol) * "‖" st = CTModels.control_interpolation(sol) == :constant ? :steppost : :path @@ -151,27 +166,37 @@ $(TYPEDSIGNATURES) Build a [`CTBase.Plotting.Panel`](@extref) for the nonlinear path constraints. """ -function _path_panel(sol, model, style::NamedTuple) +function _path_panel(sol, model, style::NamedTuple; connect::Bool=true) Tp = CTModels.time_grid(sol, :path) - X, U = CTModels.state(sol).(Tp), CTModels.control(sol).(Tp) - # at the switching times, evaluate the constraints at both limits of the state and - # control (when both are known) - T, MX = _spliced_samples(sol, Tp, _sample(CTModels.state(sol), Tp), :state) - Tu, MU = _spliced_samples(sol, Tp, _sample(CTModels.control(sol), Tp), :control) - if length(T) != length(Tp) && T == Tu - X = [_as_value(MX[k, :]) for k in axes(MX, 1)] - U = [_as_value(MU[k, :]) for k in axes(MU, 1)] - else - T = Tp - end v = CTModels.variable(sol) cp = CTModels.path_constraints_nl(model) nc = length(cp[1]) - data = zeros(Float64, length(T), nc) g = zeros(Float64, nc) - for k in eachindex(T) - cp[2](g, T[k], X[k], U[k], v) - data[k, :] .= g + constraint(t, x, u) = (cp[2](g, t, x, u, v); copy(g)) + x, u = CTModels.state(sol), CTModels.control(sol) + data = zeros(Float64, length(Tp), nc) + for k in eachindex(Tp) + data[k, :] .= constraint(Tp[k], x(Tp[k]), u(Tp[k])) + end + # at the switching times, the constraints at both limits of the state and control + # (when both are known) + T = Tp + taus = CTModels.switching_times(sol) + sw = CTModels.switching_model(sol) + (xl, xr), (ul, ur) = Solutions.limits(sw, :state), Solutions.limits(sw, :control) + if !isempty(taus) && size(xl, 2) > 0 && size(ul, 2) > 0 + at(X, U) = permutedims( + reduce( + hcat, + ( + constraint(taus[i], _as_value(X[i, :]), _as_value(U[i, :])) for + i in eachindex(taus) + ), + ), + ) + T, data = Plotting.splice_jumps( + Tp, data, taus, at(xl, ul), at(xr, ur); connect=connect + ) end labels = string.(cp[4]) return Plotting.Panel( diff --git a/src/PlotCase/vocabulary.jl b/src/PlotCase/vocabulary.jl index 94653166..2fc44375 100644 --- a/src/PlotCase/vocabulary.jl +++ b/src/PlotCase/vocabulary.jl @@ -66,6 +66,35 @@ end """ $(TYPEDSIGNATURES) +Return the default drawing of a jump at a switching time (`:segment`, a vertical segment +from the left to the right limit). +""" +__plot_jumps() = :segment + +""" +$(TYPEDSIGNATURES) + +Check the drawing of the jumps: `:segment` or `:none`. + +# Throws +- [`CTBase.Exceptions.IncorrectArgument`](@extref): for any other value. +""" +function _check_jumps(jumps::Symbol) + jumps in (:segment, :none) || throw( + Exceptions.IncorrectArgument( + "Invalid jumps choice"; + got="jumps=$jumps", + expected=":segment or :none", + suggestion="Use jumps=:segment (default, a vertical segment) or :none (a break)", + context="CTModels.PlotCase.build_figure", + ), + ) + return nothing +end + +""" +$(TYPEDSIGNATURES) + Return the default suffix appended to plot labels (empty string). """ __plot_label_suffix() = "" diff --git a/src/Solutions/show.jl b/src/Solutions/show.jl index 53975ab9..ce1fba50 100644 --- a/src/Solutions/show.jl +++ b/src/Solutions/show.jl @@ -103,7 +103,7 @@ function Base.show(io::IO, ::MIME"text/plain", sol::Solution) # ── Switching times and phases (optional) ──────────────────────────────── has_sw && _row(conn(:sw), "Switching times", string(_sw_times)) if has_phases - _row(conn(:phases), "Phases", join((isempty(l) ? "·" : l for l in _labels), " → ")) + _row(conn(:phases), "Phases", Solutions._phase_sequence(_labels)) end # ── Variable (optional) ────────────────────────────────────────────────── diff --git a/src/Solutions/switching_model.jl b/src/Solutions/switching_model.jl index 765ae0ea..0e337bde 100644 --- a/src/Solutions/switching_model.jl +++ b/src/Solutions/switching_model.jl @@ -470,3 +470,101 @@ function _with_jumps( size(left, 2) == 0 && return base return _JumpInterpolant(base, grid, switching_times(sw), left, right, constant) end + +# ------------------------------------------------------------------------------ # +# Display +# ------------------------------------------------------------------------------ # + +""" +$(TYPEDSIGNATURES) + +Return the sequence of phase labels as written by the displays, e.g. `"bang → · → singular"`: +the labels joined by arrows, a phase with no label written `·` so that every phase keeps +its place between the switching times. +""" +function _phase_sequence(labels::AbstractVector{<:AbstractString})::String + return join((isempty(l) ? "·" : l for l in labels), " → ") +end + +""" +$(TYPEDSIGNATURES) + +Return the components whose limits a switching model stores, e.g. `"state, costate"`, or +`"none"`. +""" +function _stored_limits(m::SwitchingModel)::String + stored = [String(c) for c in (:state, :costate, :control) if size(limits(m, c)[1], 2) > 0] + return isempty(stored) ? "none" : join(stored, ", ") +end + +""" +$(TYPEDSIGNATURES) + +Compact display of an [`CTModels.Solutions.EmptySwitchingModel`](@extref). +""" +function Base.show(io::IO, ::EmptySwitchingModel) + fmt = CTBase.Core.get_format_codes(io) + return print(io, fmt.name, "EmptySwitchingModel", fmt.reset, "()") +end + +""" +$(TYPEDSIGNATURES) + +Display of an [`CTModels.Solutions.EmptySwitchingModel`](@extref): no switching time, no +phase label. +""" +function Base.show(io::IO, ::MIME"text/plain", ::EmptySwitchingModel) + fmt = CTBase.Core.get_format_codes(io) + return print( + io, fmt.name, "EmptySwitchingModel", fmt.reset, fmt.muted, " (no switching)", fmt.reset + ) +end + +""" +$(TYPEDSIGNATURES) + +Compact one-line display of a [`CTModels.Solutions.SwitchingModel`](@extref): its switching +times and its sequence of phases. +""" +function Base.show(io::IO, m::SwitchingModel) + fmt = CTBase.Core.get_format_codes(io) + print(io, fmt.name, "SwitchingModel", fmt.reset, "(") + print(io, fmt.label, "times=", fmt.reset, fmt.value, m.times, fmt.reset) + if any(!isempty, m.labels) + print(io, ", ", fmt.label, "phases=", fmt.reset, fmt.value, _phase_sequence(m.labels)) + print(io, fmt.reset) + end + return print(io, ")") +end + +""" +$(TYPEDSIGNATURES) + +Display of a [`CTModels.Solutions.SwitchingModel`](@extref) as a tree, in the style of the +display of a [`CTModels.Solutions.Solution`](@extref): the switching times, the sequence of +phases (an unlabelled phase shows as `·`) and the components whose limits are stored. + +# Example +```julia-repl +julia> CTModels.SwitchingModel(; times=[0.5], labels=["bang", "singular"]) +SwitchingModel + │ Switching times : [0.5] + │ Phases : bang → singular + └─ Limits : none +``` +""" +function Base.show(io::IO, ::MIME"text/plain", m::SwitchingModel) + fmt = CTBase.Core.get_format_codes(io) + print(io, fmt.name, "SwitchingModel", fmt.reset) + rows = ( + ("Switching times", string(m.times)), + ("Phases", _phase_sequence(m.labels)), + ("Limits", _stored_limits(m)), + ) + for (k, (label, value)) in enumerate(rows) + c = k == length(rows) ? " └─ " : " │ " + print(io, "\n", fmt.muted, c, fmt.reset, fmt.label, label, " : ", fmt.reset) + print(io, fmt.value, value, fmt.reset) + end + return nothing +end diff --git a/test/suite/extensions/test_plot_switching_render.jl b/test/suite/extensions/test_plot_switching_render.jl index a898e93b..6411df8a 100644 --- a/test/suite/extensions/test_plot_switching_render.jl +++ b/test/suite/extensions/test_plot_switching_render.jl @@ -77,6 +77,12 @@ function test_plot_switching_render() Plots.Plot # overlay Test.@test Plots.plot!(Plots.plot(sol), sol) isa Plots.Plot + # jumps=:none: a break (NaN) in the curve, no vertical segment + br = Plots.plot(sol; jumps=:none) + ys = br.subplots[1].series_list[1][:y] + Test.@test count(isnan, ys) == 1 + # one color per trajectory: one series for the whole state component + Test.@test count(s -> s[:seriestype] === :path, plt.subplots[1].series_list) == 1 end Test.@testset "Makie" begin diff --git a/test/suite/plotting/test_plot_switching.jl b/test/suite/plotting/test_plot_switching.jl index 9de3bec1..2b73da6d 100644 --- a/test/suite/plotting/test_plot_switching.jl +++ b/test/suite/plotting/test_plot_switching.jl @@ -157,6 +157,25 @@ function test_plot_switching() Test.@test count(==(τ), _series(fig2, "path constraints").x) == 1 end + Test.@testset "jumps=:none breaks the curves between the limits" begin + fig = CTModels.PlotCase.build_figure(_sol(); jumps=:none) + for (title, left, right) in + (("state", τ, τ + 1.0), ("costate", 0.0, 2.0), ("control", 1.0, -1.0)) + s = _series(fig, title) + k = findfirst(==(τ), s.x) + Test.@test s.x[k:(k + 2)] == [τ, τ, τ] + Test.@test s.y[k] ≈ left + Test.@test isnan(s.y[k + 1]) # no vertical segment + Test.@test s.y[k + 2] ≈ right + end + p = _series(CTModels.PlotCase.build_figure(_sol(), :path; jumps=:none), "path constraints") + k = findfirst(==(τ), p.x) + Test.@test isnan(p.y[k + 1]) + Test.@test (p.y[k], p.y[k + 2]) == (τ + 1.0, (τ + 1.0) - 1.0) + # default: the vertical segment, no NaN + Test.@test !any(isnan, _series(CTModels.PlotCase.build_figure(_sol()), "state").y) + end + Test.@testset "no switching structure: unchanged panels" begin fig = CTModels.PlotCase.build_figure(_sol(; switching=Solutions.EmptySwitchingModel())) Test.@test _series(fig, "state").x == T @@ -264,6 +283,12 @@ function test_plot_switching() # ERROR TESTS # ==================================================================== + Test.@testset "invalid jumps" begin + Test.@test_throws Exceptions.IncorrectArgument CTModels.PlotCase.build_figure( + _sol(); jumps=:dotted + ) + end + Test.@testset "invalid phase_labels" begin Test.@test_throws Exceptions.IncorrectArgument CTModels.PlotCase.build_figure( _sol(); phase_labels=:everywhere diff --git a/test/suite/solutions/test_switching.jl b/test/suite/solutions/test_switching.jl index 445c6652..699caccf 100644 --- a/test/suite/solutions/test_switching.jl +++ b/test/suite/solutions/test_switching.jl @@ -116,6 +116,29 @@ function test_switching() Test.@test Solutions.limits(e, :costate) == (zeros(0, 0), zeros(0, 0)) end + Test.@testset "show: SwitchingModel and EmptySwitchingModel" begin + e = Solutions.EmptySwitchingModel() + Test.@test sprint(show, e) == "EmptySwitchingModel()" + Test.@test occursin("EmptySwitchingModel", sprint(show, MIME"text/plain"(), e)) + Test.@test occursin("no switching", sprint(show, MIME"text/plain"(), e)) + sw = _sw_1d() + plain = sprint(show, MIME"text/plain"(), sw) + Test.@test startswith(plain, "SwitchingModel") + Test.@test occursin("Switching times : [0.5]", plain) + Test.@test occursin("Phases : bang+ → bang-", plain) + Test.@test occursin("Limits : state, costate, control", plain) + Test.@test sprint(show, sw) == "SwitchingModel(times=[0.5], phases=bang+ → bang-)" + # labels only, limits not stored; an unlabelled phase shows as · + m = Solutions.SwitchingModel(; times=[0.2, 0.6], labels=["a", "", "c"]) + Test.@test occursin("Phases : a → · → c", sprint(show, MIME"text/plain"(), m)) + Test.@test occursin("Limits : none", sprint(show, MIME"text/plain"(), m)) + # no label: no phases in the compact form + u = Solutions.SwitchingModel(; times=[0.5]) + Test.@test sprint(show, u) == "SwitchingModel(times=[0.5])" + # colors only with an IOContext asking for them + Test.@test !occursin("\e[", plain) + end + # ==================================================================== # ERROR TESTS - SwitchingModel # ==================================================================== From 9bf53253d1a4d4866604475f49aed31ae1cc5a12 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Sun, 18 Oct 2026 20:35:49 +0200 Subject: [PATCH 5/5] chore: require CTBase 0.30.11 (Plotting.Annotation, splice_jumps, switching_vlines, phase_annotations) Tested against the registered release: full suite and docs build. Co-Authored-By: Claude Sonnet 5.5 --- Project.toml | 2 +- docs/Project.toml | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Project.toml b/Project.toml index 7107797e..a0edde34 100644 --- a/Project.toml +++ b/Project.toml @@ -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" JET = "0.9, 0.12" diff --git a/docs/Project.toml b/docs/Project.toml index 28a90e89..eb0b50f9 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -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" @@ -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"