Uh oh!
There was an error while loading. Please reload this page.
Make plotly a first-class plotting backend - #701
Open
ecomodeller wants to merge 7 commits into
Open
Conversation
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.
Uh oh!
There was an error while loading. Please reload this page.
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.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
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.
Uh oh!
There was an error while loading. Please reload this page.
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`.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
backend="plotly"used to exist on exactly two plots (scatterandComparer.plot.timeseries), silently ignorefigsize, and blow up with plotly's internalBad property path: cmapif you passed a matplotlib argument. The plotly scatter also calledfig.show()and returnedNone.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_histandtayloron bothComparerandComparerCollection;Comparer.plot.timeseries;ComparerCollection.plot.spatial_overviewand.temporal_coverage; thetimeseries/histplots on observations and model results; and the standalone functions inms.plottingincludingwind_rose.Same arguments.
figsizeis in inches for both backends and is translated to plotly'swidth/height.axis 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.pyowns the backend vocabulary (names, validation, return types, rejectingax), andplotting/_plotly.pyowns 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.pystays backend-neutral.Deliberate breaking changes
fig.show()and returnNone. Notebooks still render it as the cell result; scripts need.show().figsize(default(8, 8)) is now honored.**kwargson the matplotlibComparer.plot.timeseriesare no longer ignored -- they are forwarded toSeries.plot().cmp.plot.timeseries(width=1000)used to do nothing silently and now raises;widthis a plotly argument.ax=withbackend="plotly"now raises ontimeseriesinstead of being silently ignored (scatteralready raised)."Plotting backend: x not supported"/"backend must be one of [...]"to"Invalid backend 'x'. Valid options are: [...]".xbinscan only express uniformly spaced bins, sohist(bins=[0, 0.5, 1.0, 3.0], backend="plotly")raises rather than silently drawing uniform bins. Note also that an integerbinsis an upper bound to plotly, not an exact bin count.PlotlyTimeSeriesPlotterremoved from the privatemodelskill.timeseries._plotter. Backend is a per-call argument now, so the two plotter classes collapse into oneTimeSeriesPlotter(MatplotlibTimeSeriesPlotterkept as an alias,TimeSeries.plotterstill 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
docsdependency group (the docs build got it viadevby accident).Notes for review
taylorplotly 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_geometryhelper shared by both backends._reglabelmoved to_misc.reglabeland the residual grey to_misc.RESIDUAL_COLOR(both used by both backends);_scatter_plotlymoved to_plotly.scatter.directionalis now an argument toms.plotting.scatterhandled by both backends, rather than a tick fixup applied by the callers afterwards. A directional scatter spans 0-360 unlessxlim/ylimsay otherwise, which is what matplotlib already did.spatial_overviewclassifies observations once for both backends, so plotly raises on an unsupported observation type rather than dropping it silently.TimeSeries.plottershould go entirely now that nothing swaps it.testdependency group, so the missing-plotly path is covered by a unit test rather than end to end.🤖 Generated with Claude Code