diff --git a/docs/Project.toml b/docs/Project.toml index 1a3bec39..13f532c1 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -1,5 +1,6 @@ [deps] CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd" +CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" DocumenterInterLinks = "d12716ef-a0f6-4df4-a9f1-a5a34e75c656" DocumenterVitepress = "4710194d-e776-4893-9690-8d956a29c365" @@ -12,6 +13,7 @@ Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80" [compat] CTBase = "0.30" +CairoMakie = "0.15" Documenter = "1" DocumenterInterLinks = "1" DocumenterVitepress = "0.3" diff --git a/docs/api_reference.jl b/docs/api_reference.jl index 748b801b..0e992739 100644 --- a/docs/api_reference.jl +++ b/docs/api_reference.jl @@ -180,7 +180,7 @@ function generate_api_reference(src_dir::String, ext_dir::String) # Conditional extensions for (sym, files) in [ - (:CTModelsPlots, ext("CTModelsPlots.jl", joinpath("case", "plot.jl"))), + (:CTModelsPlots, ext("CTModelsPlots.jl")), (:CTModelsMakie, ext("CTModelsMakie.jl")), (:CTModelsJSON, ext("CTModelsJSON.jl")), (:CTModelsJLD, ext("CTModelsJLD.jl")), diff --git a/docs/make.jl b/docs/make.jl index ffb1b915..c55cfb8b 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -61,10 +61,11 @@ end # ═══════════════════════════════════════════════════════════════════════════════ # Docstrings from external packages # ═══════════════════════════════════════════════════════════════════════════════ -using JLD2, JSON3, Plots +using JLD2, JSON3, Plots, CairoMakie const CTModelsJLD = Base.get_extension(CTModels, :CTModelsJLD) const CTModelsJSON = Base.get_extension(CTModels, :CTModelsJSON) const CTModelsPlots = Base.get_extension(CTModels, :CTModelsPlots) +const CTModelsMakie = Base.get_extension(CTModels, :CTModelsMakie) # ═══════════════════════════════════════════════════════════════════════════════ # Paths diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index 21a286fa..6cda02c4 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -25,7 +25,7 @@ It provides: - **Types** and **building blocks** for states, controls, variables, time grids, constraints, and cost functionals. - An immutable `Model` / `Solution` hierarchy for optimal control problems and their numerical solutions. - Tools to build **initial guesses** for warm-starting a solver. -- Optional extensions for **serialization** (JSON, JLD2) and **plotting**. +- Optional extensions for **serialization** (JSON, JLD2) and **plotting** (Plots, Makie). Two things to keep in mind: diff --git a/docs/src/index.md b/docs/src/index.md index fa4f8f9b..4d72b47b 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -144,10 +144,9 @@ by the corresponding packages: 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. + a second rendering path for the same figure, at feature parity with + `CTModelsPlots` — `Makie.plot(sol::CTModels.Solution, ...)` returns a + `Makie.Figure`, and `Makie.plot!` overlays onto an existing one. If the corresponding extension package is not loaded, the public wrappers `export_ocp_solution`, `import_ocp_solution`, and the generic `RecipesBase.plot` @@ -190,7 +189,7 @@ the details of a particular function or type. - **I want to save/load or plot solutions** See the [Serialization & extensions](serialization/overview.md) guide for `export_ocp_solution`, - `import_ocp_solution`, and `plot(sol)`. + `import_ocp_solution`, and `Plots.plot(sol)` / `Makie.plot(sol)`. - **I want to solve an optimal control problem** Use [CTSolvers.jl](https://github.com/control-toolbox/CTSolvers.jl) which provides discretization, NLP backends, and optimization strategies. diff --git a/docs/src/model/display.md b/docs/src/model/display.md index 22017ac5..76c02424 100644 --- a/docs/src/model/display.md +++ b/docs/src/model/display.md @@ -221,7 +221,10 @@ end ``` When `Plots.jl` is loaded, the `CTModelsPlots` extension provides full plot -recipes. See [Plotting](@ref) for details. +recipes. A Makie backend (`CTModelsMakie`, activated by loading `CairoMakie` or +`GLMakie`) renders the same figure as a `Makie.Figure` through a separate +`Makie.plot(sol)` method — not the `RecipesBase.plot` stub above. See +[Plotting](@ref) for both backends. ## See also diff --git a/docs/src/serialization/overview.md b/docs/src/serialization/overview.md index 05fbceb9..f614e4a4 100644 --- a/docs/src/serialization/overview.md +++ b/docs/src/serialization/overview.md @@ -13,18 +13,19 @@ when the trigger package is present. | `CTModelsJSON` | `JSON3` | JSON export/import of a [`Solution`](@ref CTModels.Solutions.Solution) | | `CTModelsJLD` | `JLD2` | JLD2 (binary) export/import | | `CTModelsPlots` | `Plots` | `Plots.plot(sol)` / `Plots.plot!(sol)` | +| `CTModelsMakie` | `Makie` (via `CairoMakie`, `GLMakie`, …) | `Makie.plot(sol)` / `Makie.plot!(sol)`, returning a `Makie.Figure` | The public wrappers [`export_ocp_solution`](@ref CTModels.Serialization.export_ocp_solution), [`import_ocp_solution`](@ref CTModels.Serialization.import_ocp_solution) and the plot recipe live in the core; their **implementations** live in the extension. Until the trigger package is loaded, calling a wrapper raises a descriptive `CTBase.ExtensionError` — the core never hard- -depends on JSON3, JLD2 or Plots. +depends on JSON3, JLD2, Plots or Makie. ```text core wrapper ──(trigger pkg loaded?)──► extension method │ │ export_ocp_solution no ─► CTBase.ExtensionError -plot recipe yes ─► JSON3 / JLD2 / Plots implementation +plot / Makie.plot yes ─► JSON3 / JLD2 / Plots / Makie implementation ``` ## Reading order @@ -32,7 +33,7 @@ plot recipe yes ─► JSON3 / JLD2 / Plots implementation | Page | Topic | Key symbols | |---|---|---| | [Export & import](export_import.md) | Persisting solutions | [`export_ocp_solution`](@ref CTModels.Serialization.export_ocp_solution), [`import_ocp_solution`](@ref CTModels.Serialization.import_ocp_solution) | -| [Plotting](plotting.md) | Visualising trajectories | `Plots.plot`, `Plots.plot!` | +| [Plotting](plotting.md) | Visualising trajectories | `Plots.plot`, `Makie.plot` | ## A solution to serialize @@ -78,4 +79,4 @@ CTModels.objective(reloaded) ``` See [Export & import](export_import.md) for the formats and the resampling strategy, and -[Plotting](plotting.md) for the Plots recipe. +[Plotting](plotting.md) for the Plots and Makie backends. diff --git a/docs/src/serialization/plotting.md b/docs/src/serialization/plotting.md index 51ac1b6d..868ea711 100644 --- a/docs/src/serialization/plotting.md +++ b/docs/src/serialization/plotting.md @@ -6,6 +6,7 @@ CurrentModule = CTModels ```@setup plt using Plots +using CairoMakie: CairoMakie, Makie Base.showable(::MIME"image/png", ::Plots.Plot) = false ``` @@ -73,27 +74,37 @@ Because the recipe reads the *typed* solution (its time grids, interpolation kin 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) +## Makie backend -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)`: +A second backend renders the same figure with [Makie.jl](https://docs.makie.org), +at feature parity with the Plots backend (reference lines, step controls, overlay +and forwarded attributes all supported). Load a Makie backend package +(`CairoMakie`, `GLMakie`, …); the `CTModelsMakie` extension then provides +`Makie.plot(sol)`, returning a `Makie.Figure`: -```julia -using CTModels -using CairoMakie # activates the CTModelsMakie extension +```@example plt +Makie.plot(sol) +``` + +```@example plt +Makie.plot(sol; layout=:group, control=:all) +``` -f = plot(sol) # a Makie.Figure -plot(sol; layout=:group, control=:all) +`Makie.plot!` overlays onto an existing figure, the same way `Plots.plot!` does: + +```@example plt +f = Makie.plot(sol) +Makie.plot!(f, sol; time=:normalize) +f ``` -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. +The `description` and keyword arguments (`layout`, `control`, `time`, the +`*_style` keywords, `color`, `size`) are identical to the Plots backend. When both +`Plots` and a Makie package are loaded, `plot` must be qualified — +`Plots.plot(sol)` renders with Plots, `Makie.plot(sol)` with Makie. -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. +Style keywords beyond the neutral vocabulary (`color`, `linewidth`, `linestyle`, +`alpha`, `seriestype`) are resolved per backend: the Plots backend forwards any +attribute Plots recognises, while the Makie backend only forwards a fixed whitelist +and drops the rest. See the *Plotting Engine* guide in the CTBase documentation +("User Attributes: Series vs Axis") for the exact rule. diff --git a/ext/CTModelsMakie.jl b/ext/CTModelsMakie.jl index 8ff1d606..a8af467f 100644 --- a/ext/CTModelsMakie.jl +++ b/ext/CTModelsMakie.jl @@ -1,35 +1,78 @@ """ -Weak-dependency extension of CTModels providing `Makie.plot` for solutions -(proof of concept — issue #366). +Weak-dependency extension of CTModels providing `Makie.plot` / `plot!` for solutions. -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. +Loaded automatically when both `CTModels` and `Makie` are available (for example via +`CairoMakie` or `GLMakie`). This is the thin plumbing on top of the backend-free case +layer [`CTModels.PlotCase`](@extref): the public `Makie.plot` / `plot!` methods build +the figure with [`CTModels.PlotCase.build_figure`](@extref) and render it through the +`CTBase.Plotting` Makie backend, which is at feature parity with the Plots backend. +Everything domain-specific (the vocabulary, panels, decorations, layout template) +lives in `CTModels.PlotCase` and is shared with the `CTModelsPlots` extension. """ module CTModelsMakie using DocStringExtensions: TYPEDSIGNATURES -using CTBase: Plotting, Exceptions +using CTBase: Plotting using CTModels: CTModels, PlotCase using Makie: Makie +# --- internal implementations (backend-agnostic build + Makie render) -------- + +""" +$(TYPEDSIGNATURES) + +Internal implementation of `Makie.plot(::CTModels.Solution)`. + +Builds the figure with [`CTModels.PlotCase.build_figure`](@extref) and renders it +via [`CTBase.Plotting`](@extref) (Makie backend). +""" +function _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...) + # Nothing to draw (empty description, every group :none, or only path/dual in + # :group): return an empty figure, as the Plots backend does. + 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) + +Internal implementation of `Makie.plot!(::Makie.Figure, ::CTModels.Solution)`. + +Builds the figure with [`CTModels.PlotCase.build_figure`](@extref) and overlays it +onto `f` via [`CTBase.Plotting`](@extref) (Makie backend); an empty `f` is filled as +if by `plot`. +""" +function _plot!( + f::Makie.Figure, sol::CTModels.Solution, description::Symbol...; color=nothing, kwargs... +) + build, render_kwargs = PlotCase.split_plot_kwargs(kwargs) + fig = PlotCase.build_figure(sol, description...; build...) + fig === nothing && return f + return if color === nothing + Plotting.render!(Plotting.MakieBackend(), f, fig; render_kwargs...) + else + Plotting.render!(Plotting.MakieBackend(), f, fig; color=color, render_kwargs...) + end +end + +# --- public methods (thin; forward to _plot / _plot!) ------------------------ + """ $(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)` +Same `description` and keyword arguments as [`Plots.plot(::CTModels.Solution)`](@extref) (`layout`, `control`, `time`, the `*_style` / `*_bounds_style` keywords, `color`, `size`). Returns a `Makie.Figure`. @@ -42,36 +85,52 @@ 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 +function Makie.plot(sol::CTModels.Solution, description::Symbol...; kwargs...) + return _plot(sol, description...; kwargs...) end """ $(TYPEDSIGNATURES) -Overlay is not implemented by the Makie proof-of-concept backend. +Overlay the optimal control solution `sol` onto the existing `Makie.Figure` `f`. Same +behaviour and keyword arguments as [`Plots.plot(::CTModels.Solution)`](@extref); an +empty `f` is filled as if by `plot`. +""" +function Makie.plot!( + f::Makie.Figure, sol::CTModels.Solution, description::Symbol...; kwargs... +) + return _plot!(f, sol, description...; kwargs...) +end -# 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", - ), - ) +$(TYPEDSIGNATURES) + +Overlay the optimal control solution `sol` onto the current Makie figure +(`Makie.current_figure()`), creating one if none exists. +""" +function Makie.plot!(sol::CTModels.Solution, description::Symbol...; kwargs...) + f = Makie.current_figure() + return _plot!(f === nothing ? Makie.Figure() : f, sol, description...; kwargs...) end +""" +$(TYPEDSIGNATURES) + +Create an empty `Makie.Figure`, forwarding keyword arguments (`size`, …). + +Mirror of `Plots.plot(; kwargs...)`: a blank canvas to overlay solutions onto with +`Makie.plot!`. Makie has no zero-argument `plot` of its own, so this fills that gap +for the `Plots`-style workflow `f = plot(; size=…); plot!(f, sol)`. + +# Example +```julia-repl +julia> using CairoMakie + +julia> f = plot(; size=(800, 800)); + +julia> plot!(f, sol) +``` +""" +Makie.plot(; kwargs...) = Makie.Figure(; kwargs...) + end # module CTModelsMakie diff --git a/ext/CTModelsPlots.jl b/ext/CTModelsPlots.jl index f5d17b15..fe41edc3 100644 --- a/ext/CTModelsPlots.jl +++ b/ext/CTModelsPlots.jl @@ -5,7 +5,8 @@ Loaded automatically when both `CTModels` and `Plots` are available. This is the 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. +`CTBase.Plotting` Plots backend. Everything domain-specific (the vocabulary, panels, +decorations, layout template) lives in `CTModels.PlotCase`. """ module CTModelsPlots @@ -15,6 +16,131 @@ using CTBase: Plotting using CTModels: CTModels, PlotCase using Plots: Plots -include(joinpath(@__DIR__, "case", "plot.jl")) +# --- internal implementations (backend-agnostic build + Plots render) -------- +""" +$(TYPEDSIGNATURES) + +Internal implementation of `Plots.plot(::CTModels.Solution)`. + +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...; size=nothing, color=nothing, kwargs... +) + 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. + fig === nothing && return Plots.plot() + return if color === nothing + Plotting.render(Plotting.PlotsBackend(), fig; render_kwargs...) + else + Plotting.render(Plotting.PlotsBackend(), fig; color=color, render_kwargs...) + end end + +""" +$(TYPEDSIGNATURES) + +Internal implementation of `Plots.plot!(::Plots.Plot, ::CTModels.Solution)`. + +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...; color=nothing, kwargs... +) + 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(Plotting.PlotsBackend(), fig; render_kwargs...) + else + Plotting.render(Plotting.PlotsBackend(), fig; color=color, render_kwargs...) + end + for k in fieldnames(typeof(p)) + setfield!(p, k, getfield(fresh, k)) + end + return p + end + return if color === nothing + Plotting.render!(Plotting.PlotsBackend(), p, fig; render_kwargs...) + else + Plotting.render!(Plotting.PlotsBackend(), p, fig; color=color, render_kwargs...) + end +end + +# --- public methods (thin; forward to _plot / _plot!) ------------------------ + +""" +$(TYPEDSIGNATURES) + +Plot the components of an optimal control [`CTModels.Solution`](@extref). + +Generates a set of subplots showing the state, control, costate, path constraints and +dual variables over time, depending on the problem and the given `description`. + +# Arguments +- `sol`: the optimal control solution to visualise. +- `description`: symbols selecting which groups to include; any of `:state`, `:costate`, + `:control`, `:path` (path constraints), `:dual` (their multipliers). If none is given, + a default set is used based on the problem. + +# Keyword arguments +- `layout::Symbol = :split`: `:split` (one subplot per component) or `:group` (group + each signal into a single subplot with a legend). +- `control::Symbol = :components`: `:components` (a curve per control component), `:norm` + (the Euclidean norm `‖u(t)‖`) or `:all` (both). +- `time::Symbol = :default`: `:default` (real time) or `:normalize`/`:normalise` (`[0, 1]`). +- `color`: colour applied to every curve. +- `size`: figure size; defaults to a heuristic based on the layout. + +## Style options +Each `*_style` keyword is a `NamedTuple` of plotting attributes, or `:none` to hide the +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`. + +# Returns +A `Plots.Plot`. All layout and rendering is delegated to `CTBase.Plotting`. + +# Example +```julia-repl +julia> plot(sol) +julia> plot(sol, :state, :control; layout=:group, control=:all) +julia> plot(sol; state_style=(color=:blue,), costate_style=:none) +``` +""" +function Plots.plot(sol::CTModels.Solution, description::Symbol...; kwargs...) + return _plot(sol, description...; kwargs...) +end + +""" +$(TYPEDSIGNATURES) + +Overlay the optimal control solution `sol` onto the existing plot `p`. Same behaviour and +keyword arguments as [`Plots.plot(::CTModels.Solution)`](@extref); an empty `p` is filled as +if by `plot`. +""" +function Plots.plot!( + p::Plots.Plot, sol::CTModels.Solution, description::Symbol...; kwargs... +) + return _plot!(p, sol, description...; kwargs...) +end + +""" +$(TYPEDSIGNATURES) + +Overlay the optimal control solution `sol` onto the current plot (`Plots.current()`). +""" +function Plots.plot!(sol::CTModels.Solution, description::Symbol...; kwargs...) + return _plot!(Plots.current(), sol, description...; kwargs...) +end + +end # module CTModelsPlots diff --git a/ext/case/plot.jl b/ext/case/plot.jl deleted file mode 100644 index 45ba57f8..00000000 --- a/ext/case/plot.jl +++ /dev/null @@ -1,135 +0,0 @@ -# ============================================================================= -# plot.jl — public Plots.plot / plot! for CTModels solutions. -# -# 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). -# ============================================================================= - -""" -$(TYPEDSIGNATURES) - -Internal implementation of `Plots.plot(::CTModels.Solution)`. - -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...; size=nothing, color=nothing, kwargs... -) - 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. - fig === nothing && return Plots.plot() - return if color === nothing - Plotting.render(Plotting.PlotsBackend(), fig; render_kwargs...) - else - Plotting.render(Plotting.PlotsBackend(), fig; color=color, render_kwargs...) - end -end - -""" -$(TYPEDSIGNATURES) - -Internal implementation of `Plots.plot!(::Plots.Plot, ::CTModels.Solution)`. - -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...; color=nothing, kwargs... -) - 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(Plotting.PlotsBackend(), fig; render_kwargs...) - else - Plotting.render(Plotting.PlotsBackend(), fig; color=color, render_kwargs...) - end - for k in fieldnames(typeof(p)) - setfield!(p, k, getfield(fresh, k)) - end - return p - end - return if color === nothing - Plotting.render!(Plotting.PlotsBackend(), p, fig; render_kwargs...) - else - Plotting.render!(Plotting.PlotsBackend(), p, fig; color=color, render_kwargs...) - end -end - -# --- public methods (thin; forward to _plot / _plot!) ------------------------ - -""" -$(TYPEDSIGNATURES) - -Plot the components of an optimal control [`CTModels.Solution`](@extref). - -Generates a set of subplots showing the state, control, costate, path constraints and -dual variables over time, depending on the problem and the given `description`. - -# Arguments -- `sol`: the optimal control solution to visualise. -- `description`: symbols selecting which groups to include; any of `:state`, `:costate`, - `:control`, `:path` (path constraints), `:dual` (their multipliers). If none is given, - a default set is used based on the problem. - -# Keyword arguments -- `layout::Symbol = :split`: `:split` (one subplot per component) or `:group` (group - each signal into a single subplot with a legend). -- `control::Symbol = :components`: `:components` (a curve per control component), `:norm` - (the Euclidean norm `‖u(t)‖`) or `:all` (both). -- `time::Symbol = :default`: `:default` (real time) or `:normalize`/`:normalise` (`[0, 1]`). -- `color`: colour applied to every curve. -- `size`: figure size; defaults to a heuristic based on the layout. - -## Style options -Each `*_style` keyword is a `NamedTuple` of plotting attributes, or `:none` to hide the -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`. - -# Returns -A `Plots.Plot`. All layout and rendering is delegated to `CTBase.Plotting`. - -# Example -```julia-repl -julia> plot(sol) -julia> plot(sol, :state, :control; layout=:group, control=:all) -julia> plot(sol; state_style=(color=:blue,), costate_style=:none) -``` -""" -function Plots.plot(sol::CTModels.Solution, description::Symbol...; kwargs...) - return _plot(sol, description...; kwargs...) -end - -""" -$(TYPEDSIGNATURES) - -Overlay the optimal control solution `sol` onto the existing plot `p`. Same behaviour and -keyword arguments as [`Plots.plot(::CTModels.Solution)`](@extref); an empty `p` is filled as -if by `plot`. -""" -function Plots.plot!( - p::Plots.Plot, sol::CTModels.Solution, description::Symbol...; kwargs... -) - return _plot!(p, sol, description...; kwargs...) -end - -""" -$(TYPEDSIGNATURES) - -Overlay the optimal control solution `sol` onto the current plot (`Plots.current()`). -""" -function Plots.plot!(sol::CTModels.Solution, description::Symbol...; kwargs...) - return _plot!(Plots.current(), sol, description...; kwargs...) -end diff --git a/test/suite/extensions/test_plot_makie.jl b/test/suite/extensions/test_plot_makie.jl index 2d7d1661..3553d072 100644 --- a/test/suite/extensions/test_plot_makie.jl +++ b/test/suite/extensions/test_plot_makie.jl @@ -1,17 +1,18 @@ module TestPlotMakie # ============================================================================= -# End-to-end matrix for the Makie plotting extension (CTModelsMakie, POC). +# End-to-end matrix for the Makie plotting extension (CTModelsMakie). # # 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. +# is active. Mirrors `test_plot_reference.jl` (the Plots reference matrix): +# `Makie.plot(sol, …)` / `Makie.plot!(f, sol, …)` must return a `Makie.Figure` +# for the common description / layout / style / overlay combinations. Freeze +# granularity is behavioural (`isa Makie.Figure` / no throw), plus the structural +# assertions the Makie backend makes checkable (axis count, Stairs / HLines / +# VLines plot objects). # ============================================================================= using Test: Test -using CTBase: Exceptions using CTBase: Plotting using CairoMakie: CairoMakie using CairoMakie: Makie @@ -23,13 +24,15 @@ 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) +_axes(f) = [x for x in f.content if x isa Makie.Axis] +_n_axes(f) = length(_axes(f)) +_count_plots(axis, ::Type{T}) where {T} = count(p -> p isa T, axis.scene.plots) 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() + ocp_tf, sol_tf = TestProblems.solution_example_free_final_time() Test.@testset "default plot for every fixture" begin for s in (sol, sol_pc, sol_tf) @@ -38,6 +41,7 @@ function test_plot_makie() end Test.@testset "description subsets" begin + Test.@test Makie.plot(sol_pc) isa Makie.Figure for desc in ( (:state,), (:state, :costate), @@ -54,27 +58,67 @@ function test_plot_makie() 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 + Test.@testset "control mode × description × layout" begin + for layout in (:split, :group) + Test.@test Makie.plot(sol; layout=layout, control=:components) isa Makie.Figure + Test.@test Makie.plot(sol; layout=layout, control=:norm) isa Makie.Figure + Test.@test Makie.plot(sol; layout=layout, control=:all) isa Makie.Figure + Test.@test Makie.plot(sol, :control; layout=layout, control=:norm) isa + Makie.Figure + Test.@test Makie.plot( + sol, :state, :control; layout=layout, control=:all + ) isa Makie.Figure end end - Test.@testset "group style :none / NamedTuple" begin + Test.@testset "group style :none renders" 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), + (; state_style=:none, control_style=:none), + (; state_style=:none, costate_style=:none), + (; costate_style=:none, control_style=:none), + (; path_style=:none, control_style=:none), ) Test.@test Makie.plot(sol_pc; layout=:split, kw...) isa Makie.Figure end end + Test.@testset "decorations disabled render" begin + for kw in ( + (; time_style=:none, label="toto"), + (; state_bounds_style=:none), + (; control_bounds_style=:none), + (; path_bounds_style=:none), + (; state_bounds_style=:none, control_bounds_style=:none), + (; + state_bounds_style=:none, + control_bounds_style=:none, + path_bounds_style=:none, + ), + (; time_style=:none, control_bounds_style=:none, path_bounds_style=:none), + ) + Test.@test Makie.plot(sol_pc; layout=:split, kw...) isa Makie.Figure + end + end + + Test.@testset "user styles and keywords" begin + dash = (linestyle=:dash, linewidth=1) + Test.@test Makie.plot(sol; label="tata", color=2) isa Makie.Figure + Test.@test Makie.plot(sol; state_style=dash) isa Makie.Figure + Test.@test Makie.plot(sol; costate_style=dash) isa Makie.Figure + Test.@test Makie.plot( + sol; state_style=dash, control_style=(dash..., seriestype=:path) + ) isa Makie.Figure + Test.@test Makie.plot( + sol; state_style=:none, costate_style=dash, control_style=dash + ) isa Makie.Figure + 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)) @@ -89,6 +133,15 @@ function test_plot_makie() Test.@test _n_axes(f_group) == 3 end + Test.@testset "decorations and step controls reach the axes" begin + # solution_example has constant-interpolation control (→ stairs) and + # box bounds + t0/tf markers (→ HLines / VLines). + f = Makie.plot(sol) + axs = _axes(f) + Test.@test any(ax -> _count_plots(ax, Makie.Stairs) >= 1, axs) + Test.@test any(ax -> _count_plots(ax, Makie.VLines) >= 1, axs) + end + Test.@testset "nothing to draw -> empty figure, no throw" begin f = Makie.plot( sol_pc; @@ -101,12 +154,50 @@ function test_plot_makie() Test.@test f isa Makie.Figure end - Test.@testset "overlay is not implemented" begin - Test.@test_throws Exceptions.NotImplemented Makie.plot!(sol_pc) + Test.@testset "overlay onto an existing figure" begin + f = Makie.plot(sol_pc; color=15, time=:normalise, label="sol1") + n = _n_axes(f) + style = (linestyle=:dash,) + out = Makie.plot!( + f, + sol_pc; + color=1, + time=:normalise, + label="sol2", + state_style=style, + costate_style=style, + control_style=style, + ) + Test.@test out === f + Test.@test _n_axes(f) == n # overlay adds no axes + end + + Test.@testset "plot! onto current and fresh figures" begin + Makie.plot(sol_pc) # sets the current figure + Test.@test Makie.plot!(sol_pc; color=2) isa Makie.Figure + Test.@test Makie.plot!(Makie.Figure(), sol_pc) isa Makie.Figure + end + + Test.@testset "plot(; size) is an empty canvas to overlay onto" begin + # mirror of `Plots.plot(; size=…)`: a blank figure, then `plot!(f, sol)`. + f = Makie.plot(; size=(800, 800)) + Test.@test f isa Makie.Figure + Test.@test _n_axes(f) == 0 + Test.@test size(f.scene) == (800, 800) + out = Makie.plot!(f, sol_pc) + Test.@test out === f + Test.@test _n_axes(f) == _n_axes(Makie.plot(sol_pc)) end - Test.@testset "time normalization renders" begin - Test.@test Makie.plot(sol_pc; time=:normalize) isa Makie.Figure + Test.@testset "free final time decorations" begin + Test.@test !CTModels.has_fixed_final_time(ocp_tf) + Test.@test CTModels.final_time(ocp_tf, CTModels.variable(sol_tf)) ≈ 2.0 + Test.@test Makie.plot(sol_tf) isa Makie.Figure + Test.@test Makie.plot(sol_tf; layout=:group) isa Makie.Figure + Test.@test Makie.plot(sol_tf; time=:normalize) isa Makie.Figure + Test.@test Makie.plot(sol_tf, :state, :control) isa Makie.Figure + Test.@test Makie.plot(sol_tf; time_style=(color=:red,)) isa Makie.Figure + Test.@test Makie.plot(sol_tf; time_style=:none) isa Makie.Figure end end return nothing