diff --git a/BREAKING.md b/BREAKING.md index 926d5074..0503591b 100644 --- a/BREAKING.md +++ b/BREAKING.md @@ -4,6 +4,27 @@ This document describes breaking changes in CTModels releases and how to migrate your code. +## [0.19.0-beta] - unreleased + +### Plotting case-layer helpers moved to `CTModels.PlotCase` + +The backend-free plotting helpers (`clean`, `do_plot`, `do_decorate`, the +`__plot_*` defaults, the panel/decoration builders) moved out of the +`CTModelsPlots` extension into a new `src` submodule, `CTModels.PlotCase`. + +**Who is affected**: only code reaching these *internal* helpers through +`Base.get_extension(CTModels, :CTModelsPlots)`. The public `Plots.plot(sol)` / +`plot!(sol)` API — signatures, keywords, defaults and behaviour — is unchanged. + +**Migration**: replace +`Base.get_extension(CTModels, :CTModelsPlots).clean` (etc.) with +`CTModels.PlotCase.clean`. These helpers are now available without loading `Plots`. + +### `CTBase` lower bound raised to 0.30 + +The Makie backend needs `CTBase.Plotting.MakieBackend` (CTBase 0.30). `[compat]` +is now `CTBase = "0.30"`. + ## [0.18.0] - 2026-08-23 ### `Components.times(sol)` returns the time grid instead of the times model diff --git a/CHANGELOG.md b/CHANGELOG.md index 50f8a7d2..98a16db8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,30 @@ 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). +## [0.19.0-beta] - unreleased + +### ✨ Added + +- **Makie plotting backend (proof of concept)**: new `CTModelsMakie` weak-dependency + extension (trigger: `Makie`). Load a Makie backend package (`CairoMakie`, + `GLMakie`, …) and `plot(sol)` renders a `Makie.Figure`; the backend is chosen by + which package is loaded (`Plots` → Plots, `Makie` → Makie), with identical + `description` and keyword arguments. Part of the plot-engine roadmap + ([#366](https://github.com/control-toolbox/CTModels.jl/issues/366)). + - Scope: `plot` only. Not yet handled (parity follow-up): `plot!` (overlay, + throws `NotImplemented`), the reference-line decorations (box bounds, + initial/final time markers), and step/scatter control curves — constant + controls render as lines. + - Requires `CTBase` ≥ 0.30 (`Plotting.MakieBackend` + `CTBaseMakie`). + +### ♻️ Internal + +- The backend-free plotting case layer (vocabulary, gating, panel/decoration + builders, layout assembly, `build_figure`) moved from the `CTModelsPlots` + extension into a new `src` submodule, `CTModels.PlotCase`, so the Plots and Makie + extensions share it. `Plots.plot(sol)` behaviour, signatures and defaults are + unchanged. + ## [0.18.0] - 2026-08-23 ### 💥 Changed diff --git a/Project.toml b/Project.toml index ba1ff33c..124ba4fe 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "CTModels" uuid = "34c4fa32-2049-4079-8329-de33c2a22e2d" -version = "0.18.0" +version = "0.19.0-beta" authors = ["Olivier Cots "] [deps] @@ -16,23 +16,27 @@ RecipesBase = "3cdcf5f2-1ef4-517c-9805-6587b60abb01" [weakdeps] JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819" JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" +Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a" Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80" [extensions] CTModelsJLD = "JLD2" CTModelsJSON = "JSON3" +CTModelsMakie = "Makie" CTModelsPlots = "Plots" [compat] Aqua = "0.8" BenchmarkTools = "1" -CTBase = "0.29" +CTBase = "0.30" +CairoMakie = "0.15" DocStringExtensions = "0.9" JET = "0.9, 0.11, 0.12" JLD2 = "0.6" JSON3 = "1" LinearAlgebra = "1" MLStyle = "0.4" +Makie = "0.24" MacroTools = "0.5" OrderedCollections = "1, 2" Parameters = "0.13" @@ -45,6 +49,7 @@ julia = "1.10" [extras] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf" +CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" JET = "c3a54625-cd67-489e-a8e7-0a5a0ff4e31b" JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819" JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" @@ -53,4 +58,4 @@ Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["Aqua", "BenchmarkTools", "JET", "JLD2", "JSON3", "Plots", "Random", "Test"] +test = ["Aqua", "BenchmarkTools", "CairoMakie", "JET", "JLD2", "JSON3", "Plots", "Random", "Test"] diff --git a/docs/Project.toml b/docs/Project.toml index 002769e5..1a3bec39 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -11,7 +11,7 @@ MarkdownAST = "d0879d2d-cac2-40c8-9cee-1863dc0c7391" Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80" [compat] -CTBase = "0.29" +CTBase = "0.30" Documenter = "1" DocumenterInterLinks = "1" DocumenterVitepress = "0.3" diff --git a/docs/api_reference.jl b/docs/api_reference.jl index f7c755b7..748b801b 100644 --- a/docs/api_reference.jl +++ b/docs/api_reference.jl @@ -24,6 +24,7 @@ function generate_api_reference(src_dir::String, ext_dir::String) Symbol("@unpack_PreModel"), :is_empty, :time_ns, + :CTModels, # PlotCase binds the parent module name for the moved case files ] EXCLUDE_INTERNALS = vcat( EXCLUDE_SYMBOLS, @@ -102,6 +103,19 @@ function generate_api_reference(src_dir::String, ext_dir::String) joinpath("Solutions", "show.jl"), ), ), + ( + mod=CTModels.PlotCase, + title="PlotCase", + filename="plotcase", + files=src( + joinpath("PlotCase", "PlotCase.jl"), + joinpath("PlotCase", "vocabulary.jl"), + joinpath("PlotCase", "panels.jl"), + joinpath("PlotCase", "decorations.jl"), + joinpath("PlotCase", "assemble.jl"), + joinpath("PlotCase", "build.jl"), + ), + ), ( mod=CTModels.Display, title="Display", @@ -166,17 +180,8 @@ function generate_api_reference(src_dir::String, ext_dir::String) # Conditional extensions for (sym, files) in [ - ( - :CTModelsPlots, - ext( - "CTModelsPlots.jl", - joinpath("case", "vocabulary.jl"), - joinpath("case", "panels.jl"), - joinpath("case", "decorations.jl"), - joinpath("case", "assemble.jl"), - joinpath("case", "plot.jl"), - ), - ), + (:CTModelsPlots, ext("CTModelsPlots.jl", joinpath("case", "plot.jl"))), + (:CTModelsMakie, ext("CTModelsMakie.jl")), (:CTModelsJSON, ext("CTModelsJSON.jl")), (:CTModelsJLD, ext("CTModelsJLD.jl")), ] diff --git a/docs/src/index.md b/docs/src/index.md index ba5d7fbf..fa4f8f9b 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -143,6 +143,12 @@ by the corresponding packages: display the trajectories of state, control, costate, constraints, and dual variables in a consistent, configurable way. +- **CTModelsMakie.jl** (requires `Makie.jl`, e.g. via `CairoMakie` / `GLMakie`): + a second, backend-agnostic rendering path for the same figure — + `Makie.plot(sol::CTModels.Solution, ...)` returns a `Makie.Figure`. Proof of + concept ([#366](https://github.com/control-toolbox/CTModels.jl/issues/366)): + `plot` only, no reference lines or `plot!` yet. + If the corresponding extension package is not loaded, the public wrappers `export_ocp_solution`, `import_ocp_solution`, and the generic `RecipesBase.plot` throw a descriptive `CTBase.ExtensionError`. diff --git a/docs/src/serialization/plotting.md b/docs/src/serialization/plotting.md index 624e091b..51ac1b6d 100644 --- a/docs/src/serialization/plotting.md +++ b/docs/src/serialization/plotting.md @@ -72,3 +72,28 @@ plt Because the recipe reads the *typed* solution (its time grids, interpolation kind, and dual structure) rather than raw arrays, the same call works for unified- and multiple-grid solutions alike — see [Time grids](../solution/time_grids.md). + +## Makie backend (proof of concept) + +A second backend renders the same figure with [Makie.jl](https://docs.makie.org). +Load a Makie backend package (`CairoMakie`, `GLMakie`, …) instead of `Plots`; the +`CTModelsMakie` extension then provides `plot(sol)`: + +```julia +using CTModels +using CairoMakie # activates the CTModelsMakie extension + +f = plot(sol) # a Makie.Figure +plot(sol; layout=:group, control=:all) +``` + +The backend is chosen by which package is loaded — `Plots.plot(sol)` renders with +Plots, `Makie.plot(sol)` renders with Makie; the `description` and keyword +arguments (`layout`, `control`, `time`, the `*_style` keywords, `color`, `size`) +are identical. Loading both packages at once means `plot` must be qualified. + +This backend is a proof of concept (issue +[#366](https://github.com/control-toolbox/CTModels.jl/issues/366)). It does not +yet draw the reference lines (box bounds, initial/final time markers), renders +constant-interpolation controls as lines rather than steps, and does not support +`plot!` (overlay). A parity follow-up tracks these. diff --git a/ext/CTModelsMakie.jl b/ext/CTModelsMakie.jl new file mode 100644 index 00000000..8ff1d606 --- /dev/null +++ b/ext/CTModelsMakie.jl @@ -0,0 +1,77 @@ +""" +Weak-dependency extension of CTModels providing `Makie.plot` for solutions +(proof of concept — issue #366). + +Loaded automatically when both `CTModels` and `Makie` are available (for example +via `CairoMakie` or `GLMakie`). Thin plumbing on top of the backend-free case +layer [`CTModels.PlotCase`](@extref): `Makie.plot` builds the figure with +[`CTModels.PlotCase.build_figure`](@extref) and renders it through the +`CTBase.Plotting` Makie backend. + +## Scope + +`plot` only. `plot!` overlay is not implemented (throws `NotImplemented`), and the +Makie backend itself does not yet draw reference-line decorations (box bounds, +initial/final time markers) or step/scatter control curves — see the parity +follow-up of #366. +""" +module CTModelsMakie + +using DocStringExtensions: TYPEDSIGNATURES + +using CTBase: Plotting, Exceptions +using CTModels: CTModels, PlotCase +using Makie: Makie + +""" +$(TYPEDSIGNATURES) + +Plot the components of an optimal control [`CTModels.Solution`](@extref) with a +Makie backend. + +Same `description` and keyword arguments as `Plots.plot(::CTModels.Solution)` +(`layout`, `control`, `time`, the `*_style` / `*_bounds_style` keywords, `color`, +`size`). Returns a `Makie.Figure`. + +# Example +```julia-repl +julia> using CairoMakie + +julia> plot(sol) + +julia> plot(sol, :state, :control; layout=:group, control=:all) +``` +""" +function Makie.plot( + sol::CTModels.Solution, description::Symbol...; size=nothing, color=nothing, kwargs... +) + build, render_kwargs = PlotCase.split_plot_kwargs(kwargs) + fig = PlotCase.build_figure(sol, description...; size=size, build...) + fig === nothing && return Makie.Figure() + return if color === nothing + Plotting.render(Plotting.MakieBackend(), fig; render_kwargs...) + else + Plotting.render(Plotting.MakieBackend(), fig; color=color, render_kwargs...) + end +end + +""" +$(TYPEDSIGNATURES) + +Overlay is not implemented by the Makie proof-of-concept backend. + +# Throws +- `CTBase.Exceptions.NotImplemented`: always — use the Plots backend for overlays, + or wait for the parity follow-up of CTModels#366. +""" +function Makie.plot!(::CTModels.Solution, args...; kwargs...) + return throw( + Exceptions.NotImplemented( + "Makie.plot!(::CTModels.Solution) (overlay) is not implemented"; + suggestion="use the Plots backend for overlays, or wait for the parity follow-up of CTModels#366", + context="CTModelsMakie", + ), + ) +end + +end # module CTModelsMakie diff --git a/ext/CTModelsPlots.jl b/ext/CTModelsPlots.jl index 8a1c1d4c..f5d17b15 100644 --- a/ext/CTModelsPlots.jl +++ b/ext/CTModelsPlots.jl @@ -1,23 +1,20 @@ """ Weak-dependency extension of CTModels providing `Plots.plot` / `plot!` for solutions. -Loaded automatically when both `CTModels` and `Plots` are available. This is a thin -**case layer**: it names and samples the optimal-control quantities (state, control, -costate, path constraints, duals), chooses a layout template, and delegates all layout -and rendering to the generic `CTBase.Plotting` engine. +Loaded automatically when both `CTModels` and `Plots` are available. This is the thin +plumbing on top of the backend-free case layer [`CTModels.PlotCase`](@extref): the +public `Plots.plot` / `plot!` methods build the figure with +[`CTModels.PlotCase.build_figure`](@extref) and render it through the +`CTBase.Plotting` Plots backend. """ module CTModelsPlots using DocStringExtensions: TYPEDSIGNATURES -using CTBase: Plotting, Exceptions -using CTModels: CTModels +using CTBase: Plotting +using CTModels: CTModels, PlotCase using Plots: Plots -include(joinpath(@__DIR__, "case", "vocabulary.jl")) -include(joinpath(@__DIR__, "case", "panels.jl")) -include(joinpath(@__DIR__, "case", "decorations.jl")) -include(joinpath(@__DIR__, "case", "assemble.jl")) include(joinpath(@__DIR__, "case", "plot.jl")) end diff --git a/ext/case/plot.jl b/ext/case/plot.jl index f01b2398..45ba57f8 100644 --- a/ext/case/plot.jl +++ b/ext/case/plot.jl @@ -1,213 +1,34 @@ # ============================================================================= # plot.jl — public Plots.plot / plot! for CTModels solutions. # -# The thin public methods forward to `_plot` / `_plot!`, which resolve the -# description, gate the groups (`do_plot`), build the panels, assemble the layout -# tree and delegate all rendering to `CTBase.Plotting`. +# The thin public methods forward to `_plot` / `_plot!`, which build the +# backend-agnostic figure with `CTModels.PlotCase.build_figure` and render it +# through the `CTBase.Plotting` Plots backend. Everything domain-specific (the +# vocabulary, panels, decorations, layout template) lives in `CTModels.PlotCase`. # # Docstrings deferred (Handbook convention). # ============================================================================= -# Time-axis name, with the historical "(normalized)" suffix when time is rescaled. -""" -$(TYPEDSIGNATURES) - -Return the time-axis label, with the historical "(normalized)" suffix when time is rescaled. -""" -function _time_name(sol, time::Symbol) - tn = CTModels.time_name(sol) - if time === :normalize - return tn == "" ? tn : tn * " (normalized)" - elseif time === :normalise - return tn == "" ? tn : tn * " (normalised)" - else - return tn - end -end - -# Build the layout tree (or `nothing` if there is nothing to draw). Decorations -# (bounds, initial/final time lines) are added in Phase 3c; path/dual in Phase 3b. -""" -$(TYPEDSIGNATURES) - -Build the CTBase.Plotting layout tree for a CTModels solution. - -Returns `nothing` if there is nothing to draw. Decorations (bounds, initial/final time lines) -are added according to the user-supplied style keywords. -""" -function _build_root( - sol, - description::Symbol...; - layout::Symbol, - control::Symbol, - time::Symbol, - state_style::Union{NamedTuple,Symbol}, - costate_style::Union{NamedTuple,Symbol}, - control_style::Union{NamedTuple,Symbol}, - path_style::Union{NamedTuple,Symbol}, - dual_style::Union{NamedTuple,Symbol}, - time_style::Union{NamedTuple,Symbol}, - state_bounds_style::Union{NamedTuple,Symbol}, - control_bounds_style::Union{NamedTuple,Symbol}, - path_bounds_style::Union{NamedTuple,Symbol}, -) - model = CTModels.model(sol) - desc = clean(isempty(description) ? __description() : description) - do_state, do_costate, do_control, do_path, do_dual = do_plot( - sol, - desc...; - state_style=state_style, - control_style=control_style, - costate_style=costate_style, - path_style=path_style, - dual_style=dual_style, - ) - dec_time, dec_state_bounds, dec_control_bounds, dec_path_bounds = do_decorate(; - model=model, - time_style=time_style, - state_bounds_style=state_bounds_style, - control_bounds_style=control_bounds_style, - path_bounds_style=path_bounds_style, - ) - tn = _time_name(sol, time) - # Initial/final time markers are shared by every cell (both layouts). - vlines = dec_time ? _time_vlines(sol, model, time, time_style) : Plotting.VLine[] - 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 - ) - - 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_costate && push!( - cells, - L(_costate_panel(sol, costate_style; layout=layout, state_shown=do_state)), - ) - if do_control - for cp in _control_panels(sol, control, control_style, layout) - push!(cells, L(cp)) - end - end - isempty(cells) && return nothing - return _assemble_group(cells) - elseif layout === :split - state_col = nothing - if do_state - sp = _state_panel(sol, state_style) - hl = if dec_state_bounds - _box_hlines( - CTModels.state_constraints_box(model), ncomp(sp), state_bounds_style - ) - else - Vector{Plotting.HLine}[] - end - state_col = L(sp; hlines=hl) - end - costate_col = if do_costate - L(_costate_panel(sol, costate_style; layout=layout, state_shown=do_state)) - else - nothing - end - control_col = nothing - if do_control - cp = only(_control_panels(sol, control, control_style, layout)) - hl = if (dec_control_bounds && control !== :norm) - _box_hlines( - CTModels.control_constraints_box(model), - ncomp(cp), - control_bounds_style, - ) - else - Vector{Plotting.HLine}[] - end - control_col = L(cp; hlines=hl) - end - path_col = nothing - if do_path - pp = _path_panel(sol, model, path_style) - hl = if dec_path_bounds - _path_hlines(model, path_bounds_style) - else - Vector{Plotting.HLine}[] - end - path_col = L(pp; hlines=hl) - end - dual_col = if do_dual - L(_dual_panel(sol, model, dual_style; path_shown=do_path)) - else - nothing - end - return _assemble_split(; - state=state_col, - costate=costate_col, - control=control_col, - path=path_col, - dual=dual_col, - ) - else - throw( - Exceptions.IncorrectArgument( - "Invalid layout choice"; - got="layout=$layout", - expected=":group or :split", - context="CTModelsPlots plot layout", - ), - ) - end -end - """ $(TYPEDSIGNATURES) Internal implementation of `Plots.plot(::CTModels.Solution)`. -Builds the layout tree and renders it via [`CTBase.Plotting`](@extref). +Builds the figure with [`CTModels.PlotCase.build_figure`](@extref) and renders it +via [`CTBase.Plotting`](@extref) (Plots backend). """ function _plot( - sol::CTModels.Solution, - description::Symbol...; - layout::Symbol=__plot_layout(), - control::Symbol=__control_layout(), - time::Symbol=__time_normalization(), - state_style::Union{NamedTuple,Symbol}=__plot_style(), - state_bounds_style::Union{NamedTuple,Symbol}=__plot_style(), - control_style::Union{NamedTuple,Symbol}=__plot_style(), - control_bounds_style::Union{NamedTuple,Symbol}=__plot_style(), - costate_style::Union{NamedTuple,Symbol}=__plot_style(), - time_style::Union{NamedTuple,Symbol}=__plot_style(), - path_style::Union{NamedTuple,Symbol}=__plot_style(), - path_bounds_style::Union{NamedTuple,Symbol}=__plot_style(), - dual_style::Union{NamedTuple,Symbol}=__plot_style(), - size=nothing, - color=nothing, - kwargs..., + sol::CTModels.Solution, description::Symbol...; size=nothing, color=nothing, kwargs... ) - root = _build_root( - sol, - description...; - layout=layout, - control=control, - time=time, - state_style=state_style, - costate_style=costate_style, - control_style=control_style, - path_style=path_style, - dual_style=dual_style, - time_style=time_style, - state_bounds_style=state_bounds_style, - control_bounds_style=control_bounds_style, - path_bounds_style=path_bounds_style, - ) + build, render_kwargs = PlotCase.split_plot_kwargs(kwargs) + fig = PlotCase.build_figure(sol, description...; size=size, build...) # Nothing to draw (empty description, every group :none, or only path/dual in # :group): return an empty figure, as the historical CTModels plot did. - root === nothing && return Plots.plot() - fig = Plotting.Figure(root; size=size) + fig === nothing && return Plots.plot() return if color === nothing - Plotting.render(fig; kwargs...) + Plotting.render(Plotting.PlotsBackend(), fig; render_kwargs...) else - Plotting.render(fig; color=color, kwargs...) + Plotting.render(Plotting.PlotsBackend(), fig; color=color, render_kwargs...) end end @@ -216,53 +37,23 @@ $(TYPEDSIGNATURES) Internal implementation of `Plots.plot!(::Plots.Plot, ::CTModels.Solution)`. -Builds the layout tree and overlays it onto the existing plot `p` via [`CTBase.Plotting`](@extref). +Builds the figure with [`CTModels.PlotCase.build_figure`](@extref) and overlays it +onto the existing plot `p` via [`CTBase.Plotting`](@extref) (Plots backend). """ function _plot!( - p::Plots.Plot, - sol::CTModels.Solution, - description::Symbol...; - layout::Symbol=__plot_layout(), - control::Symbol=__control_layout(), - time::Symbol=__time_normalization(), - state_style::Union{NamedTuple,Symbol}=__plot_style(), - state_bounds_style::Union{NamedTuple,Symbol}=__plot_style(), - control_style::Union{NamedTuple,Symbol}=__plot_style(), - control_bounds_style::Union{NamedTuple,Symbol}=__plot_style(), - costate_style::Union{NamedTuple,Symbol}=__plot_style(), - time_style::Union{NamedTuple,Symbol}=__plot_style(), - path_style::Union{NamedTuple,Symbol}=__plot_style(), - path_bounds_style::Union{NamedTuple,Symbol}=__plot_style(), - dual_style::Union{NamedTuple,Symbol}=__plot_style(), - color=nothing, - kwargs..., + p::Plots.Plot, sol::CTModels.Solution, description::Symbol...; color=nothing, kwargs... ) - root = _build_root( - sol, - description...; - layout=layout, - control=control, - time=time, - state_style=state_style, - costate_style=costate_style, - control_style=control_style, - path_style=path_style, - dual_style=dual_style, - time_style=time_style, - state_bounds_style=state_bounds_style, - control_bounds_style=control_bounds_style, - path_bounds_style=path_bounds_style, - ) - root === nothing && return p - fig = Plotting.Figure(root) + build, render_kwargs = PlotCase.split_plot_kwargs(kwargs) + fig = PlotCase.build_figure(sol, description...; build...) + fig === nothing && return p # Empty target (e.g. `plot!(sol)` onto a bare `plot()`): the figure has no cells to # overlay, so build a fresh figure and substitute it field-by-field into `p`, # preserving `p`'s identity — the historical empty-figure path (R1). if isempty(p.series_list) fresh = if color === nothing - Plotting.render(fig; kwargs...) + Plotting.render(Plotting.PlotsBackend(), fig; render_kwargs...) else - Plotting.render(fig; color=color, kwargs...) + Plotting.render(Plotting.PlotsBackend(), fig; color=color, render_kwargs...) end for k in fieldnames(typeof(p)) setfield!(p, k, getfield(fresh, k)) @@ -270,9 +61,9 @@ function _plot!( return p end return if color === nothing - Plotting.render!(p, fig; kwargs...) + Plotting.render!(Plotting.PlotsBackend(), p, fig; render_kwargs...) else - Plotting.render!(p, fig; color=color, kwargs...) + Plotting.render!(Plotting.PlotsBackend(), p, fig; color=color, render_kwargs...) end end diff --git a/src/CTModels.jl b/src/CTModels.jl index 52d580e4..27954ab3 100644 --- a/src/CTModels.jl +++ b/src/CTModels.jl @@ -16,6 +16,7 @@ initial-guess management; and optional extensions for serialization and plotting | [`CTModels.Models`](@extref) | Immutable `Model` type and its accessor methods | | [`CTModels.Building`](@extref) | `PreModel`, component mutators, `build` | | [`CTModels.Solutions`](@extref) | `Solution` types, `build_solution`, dual model, interpolation | +| [`CTModels.PlotCase`](@extref) | Backend-free plotting case layer: builds the `CTBase.Plotting` figure IR | | [`CTModels.Display`](@extref) | `Base.show` extensions for models and solutions | | [`CTModels.Serialization`](@extref) | `export_ocp_solution` / `import_ocp_solution` (JLD2, JSON) | | [`CTModels.Init`](@extref) | Initial guess construction and validation | @@ -25,6 +26,7 @@ initial-guess management; and optional extensions for serialization and plotting | Extension | Trigger package | Adds | |-----------|----------------|------| | `CTModelsPlots` | `Plots.jl` | `Plots.plot(sol)` and `Plots.plot!(sol)` | +| `CTModelsMakie` | `Makie.jl` | `Makie.plot(sol)` (proof of concept) | | `CTModelsJSON` | `JSON3.jl` | JSON serialization | | `CTModelsJLD` | `JLD2.jl` | JLD2 serialization | @@ -48,6 +50,11 @@ using .Building include(joinpath(@__DIR__, "Solutions", "Solutions.jl")) using .Solutions +# PlotCase — backend-free optimal-control plotting case layer (builds the +# CTBase.Plotting IR; the CTModelsPlots / CTModelsMakie extensions render it) +include(joinpath(@__DIR__, "PlotCase", "PlotCase.jl")) +using .PlotCase + # Display and visualization include(joinpath(@__DIR__, "Display", "Display.jl")) using .Display diff --git a/src/PlotCase/PlotCase.jl b/src/PlotCase/PlotCase.jl new file mode 100644 index 00000000..6964dd37 --- /dev/null +++ b/src/PlotCase/PlotCase.jl @@ -0,0 +1,41 @@ +""" + PlotCase + +Backend-free optimal-control **case layer** for the [`CTBase.Plotting`](@extref) engine. + +It owns the optimal-control plotting vocabulary (`:state`, `:costate`, `:control`, +`:path`, `:dual`), turns a [`CTModels.Solutions.Solution`](@extref) plus a +`description` into [`CTBase.Plotting.Panel`](@extref)s via the semantic accessors, +adds the reference-line decorations (box bounds, initial/final time), assembles the +layout template, and produces a [`CTBase.Plotting.Figure`](@extref). + +It carries **no rendering geometry and no backend dependency**: the concrete +`Plots.plot` / `Makie.plot` methods live in the `CTModelsPlots` / `CTModelsMakie` +extensions, which call [`CTModels.PlotCase.build_figure`](@extref) and hand the +figure to a [`CTBase.Plotting.render`](@extref) backend. +""" +module PlotCase + +using DocStringExtensions: TYPEDSIGNATURES + +using CTBase: Plotting +using CTBase: Exceptions + +using ..Components +using ..Models +using ..Building +using ..Solutions + +# The case-layer files were written against the flat `CTModels.` API +# (state, control, model, initial_time, …). `PlotCase` is a submodule of `CTModels`, +# so bind that name here and keep the files verbatim — the accessors resolve at +# call time from the fully-loaded parent module. +const CTModels = parentmodule(@__MODULE__) + +include(joinpath(@__DIR__, "vocabulary.jl")) +include(joinpath(@__DIR__, "panels.jl")) +include(joinpath(@__DIR__, "decorations.jl")) +include(joinpath(@__DIR__, "assemble.jl")) +include(joinpath(@__DIR__, "build.jl")) + +end # module PlotCase diff --git a/ext/case/assemble.jl b/src/PlotCase/assemble.jl similarity index 100% rename from ext/case/assemble.jl rename to src/PlotCase/assemble.jl diff --git a/src/PlotCase/build.jl b/src/PlotCase/build.jl new file mode 100644 index 00000000..65207645 --- /dev/null +++ b/src/PlotCase/build.jl @@ -0,0 +1,246 @@ +# ============================================================================= +# build.jl — resolve description → gate groups → build panels → assemble tree → +# CTBase.Plotting.Figure. +# +# `build_figure` is the single backend-agnostic entry point of the case layer: +# the `CTModelsPlots` / `CTModelsMakie` extensions call it and hand the figure to +# a `CTBase.Plotting.render` backend. It also owns the user-facing defaults, so +# the two extensions only forward keyword arguments. +# +# Docstrings deferred where the historical helpers already carried none (Handbook). +# ============================================================================= + +# Time-axis name, with the historical "(normalized)" suffix when time is rescaled. +""" +$(TYPEDSIGNATURES) + +Return the time-axis label, with the historical "(normalized)" suffix when time is rescaled. +""" +function _time_name(sol, time::Symbol) + tn = CTModels.time_name(sol) + if time === :normalize + return tn == "" ? tn : tn * " (normalized)" + elseif time === :normalise + return tn == "" ? tn : tn * " (normalised)" + else + return tn + end +end + +# Build the layout tree (or `nothing` if there is nothing to draw). +""" +$(TYPEDSIGNATURES) + +Build the [`CTBase.Plotting`](@extref) layout tree for a CTModels solution. + +Returns `nothing` if there is nothing to draw. Decorations (bounds, initial/final time lines) +are added according to the user-supplied style keywords. +""" +function _build_root( + sol, + description::Symbol...; + layout::Symbol, + control::Symbol, + time::Symbol, + state_style::Union{NamedTuple,Symbol}, + costate_style::Union{NamedTuple,Symbol}, + control_style::Union{NamedTuple,Symbol}, + path_style::Union{NamedTuple,Symbol}, + dual_style::Union{NamedTuple,Symbol}, + time_style::Union{NamedTuple,Symbol}, + state_bounds_style::Union{NamedTuple,Symbol}, + control_bounds_style::Union{NamedTuple,Symbol}, + path_bounds_style::Union{NamedTuple,Symbol}, +) + model = CTModels.model(sol) + desc = clean(isempty(description) ? __description() : description) + do_state, do_costate, do_control, do_path, do_dual = do_plot( + sol, + desc...; + state_style=state_style, + control_style=control_style, + costate_style=costate_style, + path_style=path_style, + dual_style=dual_style, + ) + dec_time, dec_state_bounds, dec_control_bounds, dec_path_bounds = do_decorate(; + model=model, + time_style=time_style, + state_bounds_style=state_bounds_style, + control_bounds_style=control_bounds_style, + path_bounds_style=path_bounds_style, + ) + tn = _time_name(sol, time) + # Initial/final time markers are shared by every cell (both layouts). + vlines = dec_time ? _time_vlines(sol, model, time, time_style) : Plotting.VLine[] + 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 + ) + + 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_costate && push!( + cells, + L(_costate_panel(sol, costate_style; layout=layout, state_shown=do_state)), + ) + if do_control + for cp in _control_panels(sol, control, control_style, layout) + push!(cells, L(cp)) + end + end + isempty(cells) && return nothing + return _assemble_group(cells) + elseif layout === :split + state_col = nothing + if do_state + sp = _state_panel(sol, state_style) + hl = if dec_state_bounds + _box_hlines( + CTModels.state_constraints_box(model), ncomp(sp), state_bounds_style + ) + else + Vector{Plotting.HLine}[] + end + state_col = L(sp; hlines=hl) + end + costate_col = if do_costate + L(_costate_panel(sol, costate_style; layout=layout, state_shown=do_state)) + else + nothing + end + control_col = nothing + if do_control + cp = only(_control_panels(sol, control, control_style, layout)) + hl = if (dec_control_bounds && control !== :norm) + _box_hlines( + CTModels.control_constraints_box(model), + ncomp(cp), + control_bounds_style, + ) + else + Vector{Plotting.HLine}[] + end + control_col = L(cp; hlines=hl) + end + path_col = nothing + if do_path + pp = _path_panel(sol, model, path_style) + hl = if dec_path_bounds + _path_hlines(model, path_bounds_style) + else + Vector{Plotting.HLine}[] + end + path_col = L(pp; hlines=hl) + end + dual_col = if do_dual + L(_dual_panel(sol, model, dual_style; path_shown=do_path)) + else + nothing + end + return _assemble_split(; + state=state_col, + costate=costate_col, + control=control_col, + path=path_col, + dual=dual_col, + ) + else + throw( + Exceptions.IncorrectArgument( + "Invalid layout choice"; + got="layout=$layout", + expected=":group or :split", + context="CTModels.PlotCase._build_root", + ), + ) + end +end + +""" +$(TYPEDSIGNATURES) + +Build the [`CTBase.Plotting.Figure`](@extref) for a CTModels `sol` (or `nothing` +if there is nothing to draw). Backend-agnostic entry point of the case layer: it +resolves the `description`, gates the groups, builds the panels and decorations, +assembles the layout template and wraps it in a `Figure`. The `CTModelsPlots` / +`CTModelsMakie` extensions render the result with a concrete backend. + +# Keyword arguments +- `layout::Symbol = :split`: `:split` (one subplot per component) or `:group`. +- `control::Symbol = :components`: `:components`, `:norm` or `:all`. +- `time::Symbol = :default`: `:default` or `:normalize` / `:normalise`. +- `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. +""" +function build_figure( + sol::CTModels.Solution, + description::Symbol...; + layout::Symbol=__plot_layout(), + control::Symbol=__control_layout(), + time::Symbol=__time_normalization(), + state_style::Union{NamedTuple,Symbol}=__plot_style(), + costate_style::Union{NamedTuple,Symbol}=__plot_style(), + control_style::Union{NamedTuple,Symbol}=__plot_style(), + path_style::Union{NamedTuple,Symbol}=__plot_style(), + dual_style::Union{NamedTuple,Symbol}=__plot_style(), + time_style::Union{NamedTuple,Symbol}=__plot_style(), + state_bounds_style::Union{NamedTuple,Symbol}=__plot_style(), + control_bounds_style::Union{NamedTuple,Symbol}=__plot_style(), + path_bounds_style::Union{NamedTuple,Symbol}=__plot_style(), + size=nothing, +) + root = _build_root( + sol, + description...; + layout=layout, + control=control, + time=time, + state_style=state_style, + costate_style=costate_style, + control_style=control_style, + path_style=path_style, + dual_style=dual_style, + time_style=time_style, + state_bounds_style=state_bounds_style, + control_bounds_style=control_bounds_style, + path_bounds_style=path_bounds_style, + ) + root === nothing && return nothing + return Plotting.Figure(root; size=size) +end + +""" +Keyword-argument names consumed by [`build_figure`](@ref); every other keyword a +user passes to `plot(sol; …)` is forwarded to the rendering backend. +""" +const _BUILD_KEYS = ( + :layout, + :control, + :time, + :state_style, + :costate_style, + :control_style, + :path_style, + :dual_style, + :time_style, + :state_bounds_style, + :control_bounds_style, + :path_bounds_style, +) + +""" +$(TYPEDSIGNATURES) + +Split the user keyword arguments of `plot(sol; …)` into the pair +`(build, render)`: `build` holds the `_BUILD_KEYS` consumed by [`build_figure`](@ref), +`render` holds everything else (forwarded to the `CTBase.Plotting` backend). +""" +function split_plot_kwargs(kwargs) + build = NamedTuple(k => v for (k, v) in pairs(kwargs) if k in _BUILD_KEYS) + render = NamedTuple(k => v for (k, v) in pairs(kwargs) if !(k in _BUILD_KEYS)) + return build, render +end diff --git a/ext/case/decorations.jl b/src/PlotCase/decorations.jl similarity index 100% rename from ext/case/decorations.jl rename to src/PlotCase/decorations.jl diff --git a/ext/case/panels.jl b/src/PlotCase/panels.jl similarity index 99% rename from ext/case/panels.jl rename to src/PlotCase/panels.jl index 9b692cef..6b68dc07 100644 --- a/ext/case/panels.jl +++ b/src/PlotCase/panels.jl @@ -119,7 +119,7 @@ function _control_panels(sol, control::Symbol, style::NamedTuple, layout::Symbol "Invalid control choice"; got="control=$control", expected=":components, :norm or :all", - context="CTModelsPlots._control_panels", + context="CTModels.PlotCase._control_panels", ), ) end diff --git a/ext/case/vocabulary.jl b/src/PlotCase/vocabulary.jl similarity index 100% rename from ext/case/vocabulary.jl rename to src/PlotCase/vocabulary.jl diff --git a/test/suite/extensions/test_plot.jl b/test/suite/extensions/test_plot.jl index ef6c7059..7c027243 100644 --- a/test/suite/extensions/test_plot.jl +++ b/test/suite/extensions/test_plot.jl @@ -41,7 +41,8 @@ function test_plot() # ==================================================================== # Resolve the plotting extension module to access internal helpers. - plots_ext = Base.get_extension(CTModels, :CTModelsPlots) + # Case-layer vocabulary / gating helpers now live in `src` (backend-free). + plots_ext = CTModels.PlotCase Test.@testset "plot helpers: clean" begin description = ( diff --git a/test/suite/extensions/test_plot_makie.jl b/test/suite/extensions/test_plot_makie.jl new file mode 100644 index 00000000..2d7d1661 --- /dev/null +++ b/test/suite/extensions/test_plot_makie.jl @@ -0,0 +1,118 @@ +module TestPlotMakie + +# ============================================================================= +# End-to-end matrix for the Makie plotting extension (CTModelsMakie, POC). +# +# Loaded with CairoMakie so `Makie` is present and the `CTModelsMakie` extension +# is active. Mirrors the subset of `test_plot_reference.jl` that the POC Makie +# backend supports: `Makie.plot(sol, …)` must return a `Makie.Figure` for the +# common description / layout / style combinations. Freeze granularity is +# behavioural (`isa Makie.Figure` / no throw), as for the Plots reference. +# ============================================================================= + +using Test: Test +using CTBase: Exceptions +using CTBase: Plotting +using CairoMakie: CairoMakie +using CairoMakie: Makie +using CTModels: CTModels + +include(joinpath("..", "..", "problems", "TestProblems.jl")) +using .TestProblems: TestProblems + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +_n_axes(f) = count(x -> x isa Makie.Axis, f.content) + +function test_plot_makie() + Test.@testset "Plotting Makie matrix" verbose = VERBOSE showtiming = SHOWTIMING begin + _, sol, _ = TestProblems.solution_example() + _, sol_pc = TestProblems.solution_example_dual() + _, sol_tf = TestProblems.solution_example_free_final_time() + + Test.@testset "default plot for every fixture" begin + for s in (sol, sol_pc, sol_tf) + Test.@test Makie.plot(s) isa Makie.Figure + end + end + + Test.@testset "description subsets" begin + for desc in ( + (:state,), + (:state, :costate), + (:state, :control), + (:state, :control, :path), + (:costate,), + (:control,), + (:path,), + (:dual,), + (:path, :dual), + ) + Test.@test Makie.plot(sol_pc, desc...) isa Makie.Figure + Test.@test Makie.plot(sol_pc, desc...; layout=:group) isa Makie.Figure + end + end + + Test.@testset "layout x control" begin + for layout in (:split, :group), control in (:components, :norm, :all) + Test.@test Makie.plot(sol_pc; layout=layout, control=control) isa + Makie.Figure + end + end + + Test.@testset "group style :none / NamedTuple" begin + for kw in ( + (; state_style=:none), + (; costate_style=:none), + (; control_style=:none), + (; path_style=:none), + (; dual_style=:none), + (; state_style=(color=:blue,)), + (; state_style=(color=:blue,), costate_style=:none, control_style=:none), + ) + Test.@test Makie.plot(sol_pc; layout=:split, kw...) isa Makie.Figure + end + end + + Test.@testset "color / size keywords" begin + Test.@test Makie.plot(sol_pc; color=:red) isa Makie.Figure + f = Makie.plot(sol_pc; size=(700, 500)) + Test.@test size(f.scene) == (700, 500) + end + + Test.@testset "axis count matches the layout" begin + f_split = Makie.plot(sol_pc) + Test.@test _n_axes(f_split) == + Plotting.n_leaves(CTModels.PlotCase.build_figure(sol_pc)) + f_group = Makie.plot(sol_pc; layout=:group) + Test.@test _n_axes(f_group) == 3 + end + + Test.@testset "nothing to draw -> empty figure, no throw" begin + f = Makie.plot( + sol_pc; + state_style=:none, + costate_style=:none, + control_style=:none, + path_style=:none, + dual_style=:none, + ) + Test.@test f isa Makie.Figure + end + + Test.@testset "overlay is not implemented" begin + Test.@test_throws Exceptions.NotImplemented Makie.plot!(sol_pc) + end + + Test.@testset "time normalization renders" begin + Test.@test Makie.plot(sol_pc; time=:normalize) isa Makie.Figure + end + end + return nothing +end + +end # module TestPlotMakie + +# CRITICAL: Redefine in outer scope for TestRunner +test_plot_makie() = TestPlotMakie.test_plot_makie() diff --git a/test/suite/plotting/test_plot_build.jl b/test/suite/plotting/test_plot_build.jl new file mode 100644 index 00000000..209f7632 --- /dev/null +++ b/test/suite/plotting/test_plot_build.jl @@ -0,0 +1,148 @@ +module TestPlotBuild + +# ============================================================================= +# Unit tests for the backend-free plotting case layer `CTModels.PlotCase`. +# +# `build_figure` produces a `CTBase.Plotting.Figure` (pure data) — these tests +# run with NO plotting backend loaded (no Plots, no Makie), exercising the +# vocabulary, gating, panels, decorations and layout assembly directly on the IR. +# ============================================================================= + +using Test: Test +using CTBase: Plotting +using CTModels: CTModels + +include(joinpath("..", "..", "problems", "TestProblems.jl")) +using .TestProblems: TestProblems + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +_titles(fig) = filter(!isempty, [leaf.axes.title for leaf in Plotting.leaves(fig.root)]) +function _all_decorations(fig) + return reduce( + vcat, + (leaf.axes.decorations for leaf in Plotting.leaves(fig.root)); + init=Plotting.Decoration[], + ) +end +_vlines(fig) = filter(d -> d isa Plotting.VLine, _all_decorations(fig)) +_hlines(fig) = filter(d -> d isa Plotting.HLine, _all_decorations(fig)) + +function test_plot_build() + Test.@testset "PlotCase.build_figure (backend-free)" verbose = VERBOSE showtiming = + SHOWTIMING begin + _, sol, _ = TestProblems.solution_example() + _, sol_pc = TestProblems.solution_example_dual() + _, sol_tf = TestProblems.solution_example_free_final_time() + + Test.@testset "returns a Figure for every fixture" begin + for s in (sol, sol_pc, sol_tf) + Test.@test CTModels.PlotCase.build_figure(s) isa Plotting.Figure + end + end + + Test.@testset "nothing to draw -> nothing" begin + Test.@test CTModels.PlotCase.build_figure( + sol_pc; + state_style=:none, + costate_style=:none, + control_style=:none, + path_style=:none, + dual_style=:none, + ) === nothing + end + + Test.@testset "leaf count: :split sums the component counts" begin + model = CTModels.model(sol_pc) + n = CTModels.state_dimension(sol_pc) # state + n += CTModels.state_dimension(sol_pc) # costate + n += CTModels.control_dimension(sol_pc) # control + n += 2 * CTModels.dim_path_constraints_nl(model) # path + dual + Test.@test Plotting.n_leaves(CTModels.PlotCase.build_figure(sol_pc)) == n + end + + Test.@testset "leaf count: :group is one cell per group" begin + fig = CTModels.PlotCase.build_figure(sol_pc; layout=:group) + # state, costate, control (path/dual are dropped in :group, historical) + Test.@test Plotting.n_leaves(fig) == 3 + end + + Test.@testset ":group control=:all with 4 groups folds into a 2x2 grid" begin + fig = CTModels.PlotCase.build_figure(sol; layout=:group, control=:all) # state, costate, control, control-norm + Test.@test fig.root isa Plotting.VBox + Test.@test length(fig.root.children) == 2 + Test.@test all( + c -> c isa Plotting.HBox && length(c.children) == 2, fig.root.children + ) + end + + Test.@testset "description subset selects the groups" begin + fig = CTModels.PlotCase.build_figure(sol_pc, :state, :control) + Test.@test Set(_titles(fig)) == Set(["state", "control"]) + end + + Test.@testset "control layout" begin + fnorm = CTModels.PlotCase.build_figure(sol_pc, :control; control=:norm) + Test.@test "control" in _titles(fnorm) + Test.@test Plotting.n_leaves(fnorm) == 1 + fall = CTModels.PlotCase.build_figure(sol_pc, :control; control=:all) + # :split, :all -> one column of m + 1 components + m = CTModels.control_dimension(sol_pc) + Test.@test Plotting.n_leaves(fall) == m + 1 + end + + Test.@testset "time markers: two VLines on every leaf by default" begin + fig = CTModels.PlotCase.build_figure(sol_pc) + for leaf in Plotting.leaves(fig.root) + vl = filter(d -> d isa Plotting.VLine, leaf.axes.decorations) + Test.@test length(vl) == 2 + end + # time_style=:none removes them + f2 = CTModels.PlotCase.build_figure(sol_pc; time_style=:none) + Test.@test isempty(_vlines(f2)) + end + + Test.@testset "bound lines: present with box constraints, gated by style" begin + # solution_example has a state box constraint (see fixture) + f_on = CTModels.PlotCase.build_figure(sol, :state) + f_off = CTModels.PlotCase.build_figure(sol, :state; state_bounds_style=:none) + Test.@test length(_hlines(f_on)) > length(_hlines(f_off)) + Test.@test isempty(_hlines(f_off)) + end + + Test.@testset "free final time: VLines at initial_time / final_time" begin + model = CTModels.model(sol_tf) + v = CTModels.variable(sol_tf) + t0 = if CTModels.has_fixed_initial_time(model) + CTModels.initial_time(model) + else + CTModels.initial_time(model, v) + end + tf = if CTModels.has_fixed_final_time(model) + CTModels.final_time(model) + else + CTModels.final_time(model, v) + end + vals = sort( + unique(d.value for d in _vlines(CTModels.PlotCase.build_figure(sol_tf))) + ) + Test.@test vals ≈ sort(unique([t0, tf])) + end + + Test.@testset "time normalization: VLines at 0 and 1, xlabel suffix" begin + fig = CTModels.PlotCase.build_figure(sol_pc; time=:normalize) + Test.@test sort(unique(d.value for d in _vlines(fig))) ≈ [0.0, 1.0] + xlabels = filter( + !isempty, [leaf.axes.xlabel for leaf in Plotting.leaves(fig.root)] + ) + Test.@test any(occursin("(normalized)", x) for x in xlabels) + end + end + return nothing +end + +end # module TestPlotBuild + +# CRITICAL: Redefine in outer scope for TestRunner +test_plot_build() = TestPlotBuild.test_plot_build()