Skip to content

Make plotly a first-class plotting backend - #701

Open
ecomodeller wants to merge 7 commits into
mainfrom
feat/plotly-first-class
Open

Make plotly a first-class plotting backend#701
ecomodeller wants to merge 7 commits into
mainfrom
feat/plotly-first-class

Conversation

@ecomodeller

@ecomodellerecomodeller commented Aug 19, 2026

Copy link
Copy Markdown
Member

Summary

backend="plotly" used to exist on exactly two plots (scatter and Comparer.plot.timeseries), silently ignore figsize, and blow up with plotly's internal Bad property path: cmap if you passed a matplotlib argument. The plotly scatter also called fig.show() and returned None.

This makes plotly a peer of matplotlib rather than a special case.

Same plots.backend= is now on every plot method: scatter, hist, kde, qq, box, residual_hist and taylor on both Comparer and ComparerCollection; Comparer.plot.timeseries; ComparerCollection.plot.spatial_overview and .temporal_coverage; the timeseries/hist plots on observations and model results; and the standalone functions in ms.plotting including wind_rose.

Same arguments.figsize is in inches for both backends and is translated to plotly's width/height. ax is rejected with a clear message on the plotly backend instead of being silently dropped. A matplotlib-only argument now gets a modelskill error naming the offending keyword and pointing at the plotly layout reference. Directional quantities get a 0-360 compass axis in plotly too.

Same contract. Every plot returns a figure/axes; the plotly scatter no longer calls fig.show().

New plotting/_backend.py owns the backend vocabulary (names, validation, return types, rejecting ax), and plotting/_plotly.py owns the plotly renderers plus the plotly layout interop (the optional import, figsize translation, directional axes), so there is one place to look for plotly behaviour and _backend.py stays backend-neutral.

Deliberate breaking changes

  1. The plotly scatter no longer displays itself -- it used to call fig.show() and return None. Notebooks still render it as the cell result; scripts need .show().
  2. plotly scatter default size 600x600 -> 800x800, because figsize (default (8, 8)) is now honored.
  3. **kwargs on the matplotlib Comparer.plot.timeseries are no longer ignored -- they are forwarded to Series.plot(). cmp.plot.timeseries(width=1000) used to do nothing silently and now raises; width is a plotly argument.
  4. ax= with backend="plotly" now raises on timeseries instead of being silently ignored (scatter already raised).
  5. Invalid-backend message changed from "Plotting backend: x not supported" / "backend must be one of [...]" to "Invalid backend 'x'. Valid options are: [...]".
  6. Non-uniform histogram bin edges raise on the plotly backend. plotly's xbins can only express uniformly spaced bins, so hist(bins=[0, 0.5, 1.0, 3.0], backend="plotly") raises rather than silently drawing uniform bins. Note also that an integer bins is an upper bound to plotly, not an exact bin count.
  7. PlotlyTimeSeriesPlotter removed from the private modelskill.timeseries._plotter. Backend is a per-call argument now, so the two plotter classes collapse into one TimeSeriesPlotter (MatplotlibTimeSeriesPlotter kept as an alias, TimeSeries.plotter still resolves).

1-4 are all cases where the old behaviour was silently wrong. Worth a changelog line.

Docs

The plotting user guide has a new Backends section, and renders the observation timeseries and the comparer scatter with both backends so the interactive version is on the page. plotly is now explicit in the docs dependency group (the docs build got it via dev by accident).

Notes for review

  • The taylor plotly diagram draws dotted centered-RMS-difference contours rather than matplotlib's labelled contour set; the reference point, correlation ticks and radial std axis match.
  • spatial_overview's domain-geometry lookup moved to a _model_geometry helper shared by both backends.
  • _reglabel moved to _misc.reglabel and the residual grey to _misc.RESIDUAL_COLOR (both used by both backends); _scatter_plotly moved to _plotly.scatter.
  • directional is now an argument to ms.plotting.scatter handled by both backends, rather than a tick fixup applied by the callers afterwards. A directional scatter spans 0-360 unless xlim/ylim say otherwise, which is what matplotlib already did.
  • spatial_overview classifies observations once for both backends, so plotly raises on an unsupported observation type rather than dropping it silently.
  • Worth a second opinion on whether TimeSeries.plotter should go entirely now that nothing swaps it.
  • The plotly extra itself is not installed by CI; plotly comes in via the test dependency group, so the missing-plotly path is covered by a unit test rather than end to end.

🤖 Generated with Claude Code

Every comparison plot that used to be matplotlib-only now accepts
backend="plotly", and both backends take the same arguments.
- plotting/_backend.py: backend names, validation, the optional-plotly
import, figsize (inches) -> plotly width/height (px), directional axes,
and a modelskill-level error when a matplotlib-only argument reaches
plotly's update_layout
- plotting/_plotly.py: plotly renderers for timeseries, line, histogram,
kde, qq, box, residual_hist and scatter (moved out of _scatter.py)
- backend= added to hist, kde, qq, box and residual_hist on Comparer and
ComparerCollection, and to the observation/model result timeseries and
hist plots
- the plotly scatter returns the figure instead of calling fig.show()
- TimeSeries plotter classes collapsed into one backend-aware plotter;
the plotly plotter class was the only reason for the plugin hook
taylor, spatial_overview, temporal_coverage and wind_rose remain
matplotlib-only and do not take a backend argument.
taylor, spatial_overview, temporal_coverage and wind_rose now take a
backend argument too, so backend= is on every plot method.
- taylor: single-quadrant Scatterpolar with r=std, theta=arccos(cc) and
dotted centered-RMS-difference contours
- spatial_overview: model domain boundary polygons plus labelled point
and track observations, equal aspect
- temporal_coverage: one categorical row per model/observation
- wind_rose: stacked Barpolar with the calm fraction as the polar hole
The domain geometry lookup in spatial_overview moved to a
_model_geometry helper so both backends share it; the wind rose already
kept its histogram computation separate from rendering.
Comment threadtests/plot/test_plotly_backend.py Fixed
Adding the backend argument dropped the return annotations; mypy
accepted it because an unannotated return is Any.
_backend.py now defines the two aliases the plots actually return:
PlotResult (matplotlib axes or a plotly figure) and FigureResult
(a matplotlib figure or a plotly figure, for taylor). Every plot method,
standalone plotting function and plotly renderer is annotated again.
Note that plotly ships no py.typed marker, so with ignore_missing_imports
the plotly half of each union is Any to mypy; the annotation still
documents the contract and will start checking if plotly adds stubs.
Comment threadsrc/modelskill/plotting/_backend.py Fixed
Comment threadsrc/modelskill/plotting/_backend.py Fixed
Comment threadsrc/modelskill/plotting/_backend.py Dismissed
ANN2 (flake8-annotations) is not in this project's ruff selection, so the
four helpers added with the plotly backend kept their implicit Any return.
- the plotly scatter now gets a compass axis for directional quantities;
`directional` is an argument to ms.plotting.scatter() handled by both
backends, so the post-hoc tick fixup in the two plotter classes is gone.
A directional scatter spans 0-360 unless the user passes xlim/ylim,
which is what the matplotlib backend already did
- _backend.py holds the backend vocabulary only; the plotly layout interop
(import_plotly_go, apply_layout, figsize_to_layout, directional_axis)
moved to _plotly.py and series_range to _misc.py
- RESIDUAL_COLOR moved to _misc.py: it is used by the matplotlib code path,
which had no business importing from _plotly
- spatial_overview classifies observations once for both backends, so the
plotly backend raises on an unsupported observation type instead of
silently dropping it
- non-uniform bin edges raise on the plotly backend rather than being
silently rendered as uniform bins; _hist_bins lost its unused argument
- the plotly wind rose colors a magnitude bin by its upper edge normalized
to vmax and honors n_dir_labels, as the matplotlib one does
- temporal_coverage applies its (7, 0.45*n_lines) figsize default for both
backends; marker is documented as matplotlib-only
- the leftover Literal["matplotlib", "plotly"] annotations use Backend
The README mentioned it but the plotting user guide did not. Adds a
Backends section and renders the observation timeseries and the comparer
scatter with both backends, so the interactive version is on the page.
plotly is now explicit in the docs dependency group; the docs build got it
via the dev group by accident.
Comment threadtests/plot/test_plotly_backend.py Fixed
@ecomodeller
ecomodeller marked this pull request as ready for review August 19, 2026 16:36
The layout helpers were unit tested by importing them from private modules,
which is a boundary that should not be crossed regardless of what the
import is for. Four of those tests duplicated assertions that already
exist against the public plot methods, so they are gone; the two that
carried real coverage are rewritten as public calls:
- explicit width beating figsize -> cmp.plot.scatter(figsize=..., width=...)
- the missing-plotly ImportError -> monkeypatched sys.modules plus
cmp.plot.hist(backend="plotly")
tests/plot/test_backend.py is removed for the same reason: all four of its
tests are covered by the public equivalents in test_plotly_backend.py
(invalid backend, ax rejected, compass ticks).
The taylor test used the private mtr._std_mod; it now passes a local
std_mod function, which is the documented way to add a metric.
Also replaces the module-level type-checking imports in _backend.py with
symbol imports, so the aliases read as `Axes | go.Figure`.
Comment threadsrc/modelskill/plotting/_backend.py Dismissed
Comment threadsrc/modelskill/plotting/_backend.py Dismissed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@ecomodeller