Skip to content

Added .plot.contour() function and added docs for visualizations - #1812

Open
dylannelson wants to merge 15 commits into
mainfrom
contour-maps
Open

dylannelson wants to merge 15 commits into
mainfrom
contour-maps

Conversation

@dylannelson

@dylannelson dylannelson commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

Closes #366 Closes #1243

Overview

  1. Adds a new function, dataarray.plot.contour()
  2. Adds new tutorials for creating contour plots on unstructured grids
  3. Wires up the readthedocs site to show the new tutorial and function

All plots are holoviews interactive plots by default. If matplotlib output is desired, backend="matplotlib" gives a static image. Note that you can still get a static version of any holoviews plot by clicking the save button in the top right of the plot. I think this arg is likely to have the highest potential for bugs, and we could even likely do without it, but I wanted to include because I'd prefer if we can improve it with time, and explore what it may be useful for (mostly flexibility in outputs). But if it's better to proceed without it let me know.

The following new pages/edits to existing pages have been added to the docs:

plot.contour() documentation on readthedocs

plot.contour() Notebooks on readthedocs

Expected Usage

import uxarray as ux

grid_path = "test/meshfiles/mpas/QU/oQU480.231010.nc"
depth = ux.open_dataset(grid_path, grid_path)["bottomDepth"]

# backend="matplotlib" gives a static image. Without it the plot is interactive (zoom, hover).
shading = depth.plot(
    cmap="Blues", backend="matplotlib", width=900, height=450, title="MPAS ocean depth (m)"
)

# lines at 2000 m and 4000 m, labeled with their level
contours = depth.plot.contour(
    levels=[2000, 4000], method="interpolated", color="white", line_width=1
)

shading * contours
image

Old plot.contour

  • Was not a uxarray function
  • Could be used on certain xarray data
  • This overwrote some instances that would have been xarray before, aimed to be as careful with that as possible
  • xarray methods result in matplotlib plots and still do, uxarray calls result in holoviews plots.
  • Many instances where it would have errored before now work

Here's a kind of summary with before/after, as well as info on how this works on different data types, and scenarios where it doesn't work and why:

uxds is a UxDataset; temp is a UxDataArray with dims (time, lev, n_face) on the same grid.

.plot.contour() called on Type before .plot Dimensions main PR Why it does not plot
uxds UxDataset — AttributeError same Must only provide one variable. Pick one: uxds["var"].plot.contour()
uxds.uxgrid Grid — AttributeError same A grid has no data values to contour.
uxds["bottomDepth"] UxDataArray (n_face) ValueError: DataArray must be 2d contour lines with labels (hv.Overlay)
a node-centered variable UxDataArray (n_node) ValueError: DataArray must be 2d contour lines with labels (hv.Overlay)
an edge-centered variable UxDataArray (n_edge) ValueError: DataArray must be 2d DataCenteringError Edge-centered data is not supported.
temp.isel(time=[0], lev=[0]) UxDataArray (time=1, lev=1, n_face) ValueError: DataArray must be 2d contour lines with labels (hv.Overlay)
temp.isel(lev=0) UxDataArray (time=4, n_face) Matplotlib plot of time against face number DimensionError Changed from main, whose plot was not a map. Select one time: .isel(time=0)
temp UxDataArray (time, lev, n_face) ValueError: DataArray must be 2d DimensionError Select one time and level first.
temp.mean("n_face"), temp.weighted_mean() UxDataArray (time, lev) Matplotlib contour plot (QuadContourSet) same
temp.isel(time=0).mean("n_face") UxDataArray (lev) ValueError: DataArray must be 2d same Only one dimension; a contour needs two.
zonal_mean() / azimuthal_mean() results xr.DataArray 2-D Matplotlib contour plot (QuadContourSet) same
uxds["bottomDepth"].to_xarray() xr.DataArray (n_face) ValueError: DataArray must be 2d same The grid is dropped. Call it on the UxDataArray instead.

Only one call that returned something on main raises with this PR: data with a grid dimension and a second dimension longer than 1, for example (time=4, n_face). On main xarray drew it with the face number as an axis, which is not a map. Everything else that plotted on main plots the same way.

Known Concerns, not planned for this PR:

  • Pacific-centred maps on cubed-sphere grids. uxda.plot(projection=...) with a projection centred on 180° raises on grids with a node at a pole. That is an existing bug on main in the shaded plot; the contour lines alone render fine.

PR Checklist

General

  • An issue is created and linked
  • Added appropriate labels (if your uxarray repo permissions allow it)
  • Filled out Overview and Expected Usage (if applicable) sections

Testing & Benchmarking

  • There is adequate test coverage of changes from this PR (add new tests if needed)
  • If this PR could affect performance, ran ASV benchmarks and confirmed they show expected behavior (add a new benchmark if necessary)

Documentation and Examples

  • Docstrings updated with any function changes, and included in all new functions
  • User (public) functions added to docs/api.rst; internal (private) function names start with an underscore (_)
  • If touched any notebook files, cleared the output of all cells before committing
  • If added new notebook files, put into appropriate directories and referenced in appropriate files

AI Disclosure

AI Usage:
Claude Code

  • I have tested and take responsibility for all AI-generated content in my PR.

@review-notebook-app

Copy link
Copy Markdown

Check out this pull request on  ReviewNB

See visual diffs & provide feedback on Jupyter Notebooks.


Powered by ReviewNB

@dylannelson dylannelson added documentation Improvements or additions to documentation run-benchmark Run ASV benchmark workflow visualization Plotting or other visualizations labels Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

ASV Benchmarking

Benchmark Comparison Results

Benchmarks that have improved:

Change Before [140b91c] After [20e3ae4] Ratio Benchmark (Parameter)
- 1.33±0.03ms 1.15±0.02ms 0.86 connectivity.Connectivity.time_node_edge('120km')
- 446±8μs 380±8μs 0.85 connectivity.Connectivity.time_node_edge('480km')
- 1.08±0.1μs 871±0.5ns 0.81 geometry_kernels.AccucrossKernels.time_accucross
- 666±9ns 551±7ns 0.83 geometry_kernels.GCAGCAIntersection.time_accux_gca_kernel
- 866±20ns 640±10ns 0.74 geometry_kernels.GCAGCAIntersection.time_gca_gca_intersection
- 26.0±1ms 22.8±0.03ms 0.88 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_dispatch
- 6.76±0.4ms 5.48±0.06ms 0.81 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_kernel
- 257±5ms 214±0.6ms 0.83 mpas_ocean.CrossSections.time_const_lat('120km', 2)
- 131±5ms 113±0.7ms 0.86 mpas_ocean.CrossSections.time_const_lat('120km', 4)
- 451±5ms 373±5ms 0.83 mpas_ocean.CrossSections.time_const_lat('480km', 1)
- 223±4ms 185±2ms 0.83 mpas_ocean.CrossSections.time_const_lat('480km', 2)
- 116±3ms 96.1±0.7ms 0.83 mpas_ocean.CrossSections.time_const_lat('480km', 4)
- 23.0±0.8ms 19.4±0.07ms 0.85 mpas_ocean.DualMesh.time_dual_mesh_construction('120km')
- 184±9μs 143±1μs 0.78 mpas_ocean.Integrate.time_integrate('120km')
- 185±4ms 151±0.6ms 0.82 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'exclude')
- 184±5ms 152±1ms 0.83 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'include')
- 181±5ms 152±0.4ms 0.84 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'split')
- 11.8±0.5ms 10.2±0.06ms 0.86 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'exclude')
- 41.9±3ms 32.5±0.5ms 0.78 mpas_ocean.NeighborhoodDask.time_mean('120km', 'grid_chunks')
- 19.8±0.4ms 14.5±0.09ms 0.73 mpas_ocean.NeighborhoodDask.time_mean('120km', 'numpy')
- 37.8±2ms 29.3±0.2ms 0.78 mpas_ocean.NeighborhoodDask.time_mean('120km', 'time_chunks')
- 9.77±0.1ms 7.70±0.1ms 0.79 mpas_ocean.NeighborhoodDask.time_mean('480km', 'grid_chunks')
- 124±1ms 100±0.9ms 0.81 mpas_ocean.RemapDownsample.time_bilinear_remapping
- 1.17±0.01s 753±2ms 0.65 mpas_ocean.RemapUpsample.time_bilinear_remapping
- 10.2±0.2ms 8.40±0.05ms 0.82 mpas_ocean.RemapUpsample.time_nearest_neighbor_remapping
- 8.18±0.1ms 6.64±0.1ms 0.81 mpas_ocean.ZonalAverage.time_zonal_average('120km')
- 1.2183288246483917 1.0245263338005886 0.84 nogil_scaling.GILScaling.track_gil_scaling
- 6.39±0.01s 700±5ms 0.11 to_raster.ToRaster.time_to_raster((10000.0, 10.0))
- 19.2±0.02s 1.67±0s 0.09 to_raster.ToRaster.time_to_raster((10000.0, 100.0))
- 21.7±0.2s 1.91±0.02s 0.09 to_raster.ToRaster.time_to_raster((100000.0, 100.0))
* failed 684M n/a to_raster.ToRaster.track_peakmem((10000.0, 100.0))
* failed 768M n/a to_raster.ToRaster.track_peakmem((100000.0, 100.0))

Benchmarks that have stayed the same:

Change Before [140b91c] After [20e3ae4] Ratio Benchmark (Parameter)
901±30μs 823±6μs 0.91 connectivity.Connectivity.time_edge_face('120km')
420±40μs 367±10μs ~0.87 connectivity.Connectivity.time_edge_face('480km')
3.21±0.3ms 2.71±0.09ms ~0.85 connectivity.Connectivity.time_edge_node('120km')
1.14±0.2ms 873±10μs ~0.77 connectivity.Connectivity.time_edge_node('480km')
53.8±2μs 46.9±3μs ~0.87 connectivity.Connectivity.time_face_edge('120km')
53.6±5μs 44.4±10μs ~0.83 connectivity.Connectivity.time_face_edge('480km')
785±70μs 599±10μs ~0.76 connectivity.Connectivity.time_face_face('120km')
345±20μs 320±6μs 0.93 connectivity.Connectivity.time_face_face('480km')
55.5±10μs 42.8±5μs ~0.77 connectivity.Connectivity.time_face_node('120km')
57.3±20μs 42.2±3μs ~0.74 connectivity.Connectivity.time_face_node('480km')
494±60μs 443±90μs ~0.90 connectivity.Connectivity.time_n_nodes_per_face('120km')
381±20μs 287±30μs ~0.75 connectivity.Connectivity.time_n_nodes_per_face('480km')
81.9±2ms 69.5±7ms ~0.85 connectivity.Connectivity.time_node_face('120km')
4.93±0.3ms 3.76±0.02ms ~0.76 connectivity.Connectivity.time_node_face('480km')
1.42M 1.42M 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_edge_face('120km')
106k 106k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_edge_face('480km')
6.48M 6.48M 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_edge_node('120km')
420k 420k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_edge_node('480km')
2.42k 2.42k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_edge('120km')
2.42k 2.42k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_edge('480km')
1.6M 1.6M 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_face('120km')
101k 101k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_face('480km')
2.54k 2.54k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_node('120km')
2.54k 2.54k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_node('480km')
240k 240k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_n_nodes_per_face('120km')
26.3k 26.3k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_n_nodes_per_face('480km')
1.9M 1.9M 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_node_edge('120km')
127k 127k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_node_edge('480km')
11.9M 11.9M 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_node_face('120km')
747k 747k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_node_face('480km')
10.1±1ms 10.2±0.06ms 1.00 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
2.86±0.06ms 2.89±0.07ms 1.01 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
13.1±1ms 12.8±8ms 0.98 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
1.42±0.03ms 1.46±0.03ms 1.03 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
57.3k 57.3k 1.00 face_bounds.FaceBounds.track_nbytes_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
12.3k 12.3k 1.00 face_bounds.FaceBounds.track_nbytes_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
123k 123k 1.00 face_bounds.FaceBounds.track_nbytes_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
128 128 1.00 face_bounds.FaceBounds.track_nbytes_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
1.27M 1.27M 1.00 face_bounds.FaceBounds.track_nbytes_grid_with_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
50.1k 50.1k 1.00 face_bounds.FaceBounds.track_nbytes_grid_with_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
1.48M 1.48M 1.00 face_bounds.FaceBounds.track_nbytes_grid_with_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
712 712 1.00 face_bounds.FaceBounds.track_nbytes_grid_with_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
1.82M 1.82M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
1.81M 1.81M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
1.98M 1.98M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
35.6k 35.6k 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
2.21±0.2μs 1.93±0.02μs ~0.88 geometry_kernels.AccucrossKernels.time_accucross_pair
456±20ns 430±4ns 0.94 geometry_kernels.EFTPrimitives.time_acc_sqrt_re
436±9ns 441±9ns 1.01 geometry_kernels.EFTPrimitives.time_diff_of_products
391±10ns 371±40ns 0.95 geometry_kernels.EFTPrimitives.time_two_prod
381±5ns 386±10ns 1.01 geometry_kernels.EFTPrimitives.time_two_sum
641±20ns 746±20ns ~1.17 geometry_kernels.GCAConstLatIntersection.time_accux_constlat_kernel
826±10ns 870±70ns 1.05 geometry_kernels.GCAConstLatIntersection.time_gca_const_lat_intersection
876±50ns 860±40ns 0.98 geometry_kernels.GCAConstLatIntersection.time_try_gca_const_lat_intersection
941±100ns 731±20ns ~0.78 geometry_kernels.GCAGCAIntersection.time_try_gca_gca_intersection
48.3±0.9μs 50.3±1μs 1.04 geometry_kernels.OrientPredicates.time_on_minor_arc
49.5±0.9μs 50.2±0.9μs 1.01 geometry_kernels.OrientPredicates.time_orient3d_on_sphere
3.05±0ms 3.09±0.01ms 1.01 geometry_samebody.SameBodyConstLat.time_accux_dispatch
1.16±0ms 1.17±0.01ms 1.00 geometry_samebody.SameBodyConstLat.time_accux_kernel
2.30±0ms 2.29±0.01ms 1.00 geometry_samebody.SameBodyConstLat.time_fp64_dispatch
146±0.3μs 148±0.2μs 1.01 geometry_samebody.SameBodyConstLat.time_fp64_kernel
21.5±3ms 18.1±0.09ms ~0.84 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_dispatch
809±100μs 698±5μs ~0.86 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_kernel
761±2ms 764±5ms 1.00 import.Imports.timeraw_import_uxarray
2.68±0.01ms 2.66±0.03ms 0.99 mpas_ocean.CheckNorm.time_check_norm('120km')
1.76±0.01ms 1.81±0.01ms 1.03 mpas_ocean.CheckNorm.time_check_norm('480km')
1.14±0.01ms 1.16±0.01ms 1.02 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('120km')
549±9μs 557±10μs 1.01 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('480km')
653±9μs 671±20μs 1.03 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('120km')
598±9μs 621±20μs 1.04 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('480km')
4.53±0.01ms 4.60±0.03ms 1.02 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('120km')
3.47±0.02ms 3.55±0.03ms 1.02 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('480km')
106±1ms 104±0.1ms 0.99 mpas_ocean.ConstructFaceLatLon.time_welzl('120km')
9.59±0.01ms 10.1±0.3ms 1.05 mpas_ocean.ConstructFaceLatLon.time_welzl('480km')
18.6±0.03ms 18.8±0.1ms 1.01 mpas_ocean.ConstructTreeStructures.time_ball_tree('120km')
974±30μs 978±20μs 1.00 mpas_ocean.ConstructTreeStructures.time_ball_tree('480km')
9.91±0.02ms 10.0±0.05ms 1.01 mpas_ocean.ConstructTreeStructures.time_kd_tree('120km')
613±20μs 622±20μs 1.01 mpas_ocean.ConstructTreeStructures.time_kd_tree('480km')
494±30ms 419±4ms ~0.85 mpas_ocean.CrossSections.time_const_lat('120km', 1)
340M 364M 1.07 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 1)
340M 364M 1.07 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 2)
341M 364M 1.07 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 4)
2.28±0.08ms 2.12±0.01ms 0.93 mpas_ocean.DualMesh.time_dual_mesh_construction('480km')
13.3±0.5ms 12.9±0.8ms 0.97 mpas_ocean.FaceAreas.time_face_areas('120km')
4.13±0.2ms 3.96±0.2ms 0.96 mpas_ocean.FaceAreas.time_face_areas('480km')
229k 229k 1.00 mpas_ocean.FaceAreas.track_nbytes_face_areas('120km')
14.3k 14.3k 1.00 mpas_ocean.FaceAreas.track_nbytes_face_areas('480km')
2.12M 2.12M 1.00 mpas_ocean.FaceAreas.track_peakmem_face_areas('120km')
699k 699k 1.00 mpas_ocean.FaceAreas.track_peakmem_face_areas('480km')
320±0.3ms 333±5ms 1.04 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', False)
49.1±0.9ms 50.7±0.9ms 1.03 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', True)
41.2±0.07ms 41.9±0.3ms 1.02 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', False)
5.48±0.5ms 5.14±0.2ms 0.94 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', True)
12.3±0.06ms 12.4±0.06ms 1.01 mpas_ocean.Gradient.time_gradient('120km')
1.65±0.01ms 1.68±0.01ms 1.02 mpas_ocean.Gradient.time_gradient('480km')
457k 457k 1.00 mpas_ocean.Gradient.track_nbytes_gradient('120km')
28.7k 28.7k 1.00 mpas_ocean.Gradient.track_nbytes_gradient('480km')
3.2M 3.2M 1.00 mpas_ocean.Gradient.track_peakmem_gradient('120km')
204k 204k 1.00 mpas_ocean.Gradient.track_peakmem_gradient('480km')
336M 362M 1.08 mpas_ocean.GradientColdStartRss.track_peakmem_gradient('120km')
274±9μs 276±8μs 1.01 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('120km')
147±4μs 150±9μs 1.02 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('480km')
155±10μs 130±3μs ~0.84 mpas_ocean.Integrate.time_integrate('480km')
18.4M 18.4M 1.00 mpas_ocean.Integrate.track_nbytes_integrate('120km')
1.2M 1.2M 1.00 mpas_ocean.Integrate.track_nbytes_integrate('480km')
12.5±2ms 10.3±0.2ms ~0.82 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'include')
13.1±2ms 10.3±0.2ms ~0.78 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'split')
153±0.3ms 152±0.4ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('120km', 1.0)
341±1ms 340±0.5ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('120km', 5.0)
10.1±0.08ms 9.92±0.05ms 0.98 mpas_ocean.NeighborhoodBuild.time_build('480km', 1.0)
12.4±0.06ms 12.4±0.2ms 0.99 mpas_ocean.NeighborhoodBuild.time_build('480km', 5.0)
149±0.7ms 150±0.7ms 1.01 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 1.0)
331±0.7ms 330±0.9ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 5.0)
9.81±0.09ms 9.68±0.07ms 0.99 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 1.0)
12.3±0.2ms 12.1±0.04ms 0.99 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 5.0)
612.76 612.76 1.00 mpas_ocean.NeighborhoodBuild.track_mean_neighbors('120km', 15.0)
74.17 74.17 1.00 mpas_ocean.NeighborhoodBuild.track_mean_neighbors('120km', 5.0)
37.29 37.29 1.00 mpas_ocean.NeighborhoodBuild.track_mean_neighbors('480km', 15.0)
6.57 6.57 1.00 mpas_ocean.NeighborhoodBuild.track_mean_neighbors('480km', 5.0)
141M 141M 1.00 mpas_ocean.NeighborhoodBuild.track_nbytes_neighbors('120km', 15.0)
17.4M 17.4M 1.00 mpas_ocean.NeighborhoodBuild.track_nbytes_neighbors('120km', 5.0)
563k 563k 1.00 mpas_ocean.NeighborhoodBuild.track_nbytes_neighbors('480km', 15.0)
123k 123k 1.00 mpas_ocean.NeighborhoodBuild.track_nbytes_neighbors('480km', 5.0)
145M 145M 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('120km', 15.0)
21.5M 21.5M 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('120km', 5.0)
825k 825k 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('480km', 15.0)
384k 384k 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('480km', 5.0)
604±70μs 497±9μs ~0.82 mpas_ocean.NeighborhoodDask.time_mean('480km', 'numpy')
6.03±0.9ms 5.36±0.09ms ~0.89 mpas_ocean.NeighborhoodDask.time_mean('480km', 'time_chunks')
5.81M 5.83M 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('120km', 'grid_chunks')
2.75M 2.75M 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('120km', 'numpy')
5.68M 5.68M 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('120km', 'time_chunks')
691k 679k 0.98 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'grid_chunks')
177k 177k 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'numpy')
547k 546k 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'time_chunks')
4.49±0.01s 4.50±0.02s 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'mean')
4.61±0.01s 4.62±0.01s 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'median')
121±0.2ms 121±1ms 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'mean')
124±1ms 123±0.9ms 0.99 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'median')
509±1ms 511±3ms 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'mean')
541±2ms 542±3ms 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'median')
16.8±0.1ms 16.7±0.4ms 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'mean')
16.9±0.02ms 17.4±0.4ms 1.03 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'median')
4.49±0.01ms 4.54±0.07ms 1.01 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'mean')
37.4±0.03ms 37.3±0.4ms 1.00 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'median')
254±10μs 243±8μs 0.96 mpas_ocean.NeighborhoodReduce.time_reduce('480km', 'mean')
533±10μs 540±7μs 1.01 mpas_ocean.NeighborhoodReduce.time_reduce('480km', 'median')
234k 234k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('120km', 'mean')
235k 235k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('120km', 'median')
19.5k 19.5k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('480km', 'mean')
19.5k 19.5k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('480km', 'median')
397±6μs 381±8μs 0.96 mpas_ocean.PointInPolygon.time_face_search_lonlat('120km')
381±6μs 370±8μs 0.97 mpas_ocean.PointInPolygon.time_face_search_lonlat('480km')
372±9μs 356±9μs 0.96 mpas_ocean.PointInPolygon.time_face_search_xyz('120km')
351±7μs 357±8μs 1.02 mpas_ocean.PointInPolygon.time_face_search_xyz('480km')
15.5±0.1ms 15.6±0.1ms 1.01 mpas_ocean.RemapDownsample.time_inverse_distance_weighted_remapping
13.9±0.1ms 14.1±0.09ms 1.02 mpas_ocean.RemapDownsample.time_nearest_neighbor_remapping
25.3±3ms 20.3±0.6ms ~0.80 mpas_ocean.RemapUpsample.time_inverse_distance_weighted_remapping
4.09±0.4ms 3.74±0.06ms 0.91 mpas_ocean.ZonalAverage.time_zonal_average('480km')
341M 362M 1.06 mpas_ocean.ZonalAveragePeakMem.track_peakmem_zonal_average('120km')
6.91±0.2ms 6.96±0.05ms 1.01 quad_hexagon.QuadHexagon.time_open_dataset
5.74±0.03ms 5.78±0.03ms 1.01 quad_hexagon.QuadHexagon.time_open_grid
408 408 1.00 quad_hexagon.QuadHexagon.track_nbytes_open_dataset
392 392 1.00 quad_hexagon.QuadHexagon.track_nbytes_open_grid
73k 73.5k 1.01 quad_hexagon.QuadHexagon.track_peakmem_open_dataset
71.8k 71.8k 1.00 quad_hexagon.QuadHexagon.track_peakmem_open_grid
271M 271M 1.00 to_raster.ToRaster.track_peakmem((10000.0, 10.0))

Benchmarks that have got worse:

Change Before [140b91c] After [20e3ae4] Ratio Benchmark (Parameter)
+ 320M 364M 1.14 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
+ 321M 364M 1.14 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
+ 322M 364M 1.13 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
+ 321M 364M 1.13 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
+ 278M 361M 1.3 import.Imports.track_peakmem_import_uxarray
+ 323M 364M 1.13 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 1)
+ 323M 364M 1.13 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 2)
+ 324M 364M 1.13 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 4)
+ 315M 362M 1.15 mpas_ocean.GradientColdStartRss.track_peakmem_gradient('480km')
+ 324M 362M 1.12 mpas_ocean.ZonalAveragePeakMem.track_peakmem_zonal_average('480km')

@dylannelson dylannelson changed the title Support and/or docs for contours Added .plot.contour() function and added docs for visualizations Oct 13, 2026
@dylannelson
dylannelson marked this pull request as ready for review October 13, 2026 18:51
@cmdupuis3

Copy link
Copy Markdown
Collaborator

Graphical review:

Contours are looking good overall, so these are my fine-tuning preferences:

  1. Optional interpolation/smoothing at a tunable density, so you have the choice between the hi-fi contours here and smoother curves that are generally easier to look at.
  2. I'd also add some algorithm to filter out the tiny islands of contours in large borderline areas, since you'll get a complicated patchwork of contours there. That can probably interact with the interpolation somehow.
  3. A parameter to tune the ratio of labeled contours to total contours.

@dylannelson

Copy link
Copy Markdown
Member Author

@cmdupuis3
For 1, do you think it should be a kind of range, like 0-1 that determines how smooth it is? Currently it can either be fully smoothed or along face edges like plot 7 from the contour plots page:
image
image
Though this is only 2 options and no in between. If we want a full range, it may take some extra math and functions but I can look into that. Curious about what values it should take though

  1. Yeah that's a good idea, I was seeing a lot of small islands but mostly only when the plots were the whole globe. I'll check that out.
  2. Really like that too. I was reducing the number of contours to make the plot less noisy, but it wasn't really the contours, it was the many labels that overlapped because there was so many contours. Clever idea.

@cmdupuis3

Copy link
Copy Markdown
Collaborator

@dylannelson (1) Yeah, I was thinking some kind of range, because I think it would enable more flexibility for showing certain granular features without overwhelming the plot.

One other suggestion: usually for synoptic charts, you'll see the label carves out the contour behind it, so you can center the label on the contour without having the actual line clutter it up. That might actually be a use case for neighborhood filters; you can select where the labels will go, then delete contour line segments anywhere in a radius from them.

@cmdupuis3

Copy link
Copy Markdown
Collaborator

(2) Actually, I'm thinking the tiny contour filter should be presented as an optional radius. Looking at your Maria contour plots, there are some small bits that I'm fairly sure are real over some of the Caribbean islands.

I'm adding performance edits on cmd/contour-maps-opt, each optimization is committed separately. I'm seeing speedups on the order of 100x or more so far. Some of them require borrowing from cartopy, so you can merge from 32b29ec for the non-cartopy stuff and I can maybe add a PR for the rest if we want to hold off on that.

@dylannelson

Copy link
Copy Markdown
Member Author

@cmdupuis3 I have added an option for the labels arg to be a ratio, from 0-1, as well as True (1) or False (0), which covers # 3, I think the other 2 either you have worked on or would heavily change the functions you have work on. Over chat we discussed how some of these changes can/should come after this is merged, so we can come back to that later on.

@rajeeja rajeeja left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes for the two correctness cases in the inline comments.

Comment thread uxarray/plot/accessor.py
Comment thread uxarray/plot/contour.py Outdated
@cmdupuis3

Copy link
Copy Markdown
Collaborator

@dylannelson Yeah, 2 and 3 are more QOL fixes, they can probably be another PR

@dylannelson
dylannelson requested a review from rajeeja October 15, 2026 02:33
@cmdupuis3 cmdupuis3 removed the run-benchmark Run ASV benchmark workflow label Oct 15, 2026

@Sevans711 Sevans711 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks really great overall, and I'm excited to use this new feature!

I have a few thoughts, mostly minor, and left some inline comments as well.

  1. Re: summary of before and after, most cases look good to me, the only one I would push back on is the temp.isel(time=[0], lev=[0]) case. I would personally say this should crash with DimensionError, just like temp does, because the time and lev dimensions do exist, they just happen to have size 1. Crashing here would match xarray's behavior for specific plotting methods (like arr.plot.methodname()), which do not squeeze() automatically (see #1724 for more details).

  2. Re: the known concern about projection centered on 180, I agree it is out of scope for this PR; it is in fact already being tracked at #1795 (which has an open PR, so hopefully it will be fixed soon)!

  3. (Non-blocking, could go into a followup PR) Is there any way to get the contour labels to be rotated to align with the local contour line?

  4. (Non-blocking style question) would it make sense to add %%time at the top of some of the cells? E.g. this would follow the style of https://uxarray.readthedocs.io/en/latest/user-guide/mpl.html where %%time is placed at the top of cells relevant to explicit mentions of computation speed.

  5. Please add a bit more explanation for this sentence: "With lat= the samples cover the whole latitude circle, so steps=14401 places one every 0.025° of longitude, about the width of a mesh cell". How did you derive / check these values? It's okay to not explain further the "about the width of a mesh cell" part if you just checked it by eye, but it would be helpful to at least show where the number 14401 comes from.

  6. (Non-blocking, can go into a later PR) Contours should probably infer the projection from the other plot object's projection if they are being combined via *, or at least provide a clearer warning/help if the projections differ, but this can be pushed to a later PR. It feels like this would either be very easy to set up or very challenging to set up, but I don't know which it is, yet.

  7. Contours using a projection by themselves display strangely, see example below. Is there any way to fix this? It feels like a basic enough behavior that it would also be appropriate to expect a fix as part of this PR, but if it is especially challenging to fix I could also accept it getting pushed to a later PR.

Example of strange display when just showing contours, why is the plot on the left so squished? I would have expected the plot sizes to be the same.

import uxarray as ux
import cartopy.crs
arr = ux.tutorial.open_dataset('outCSne30-vortex')['psi']
proj = cartopy.crs.Robinson()
contours = arr.plot.contour(projection=proj, show_legend=False)
data_and_contours = arr.plot(projection=proj, cmap="viridis") * contours
contours + data_and_contours
Image

Comment thread uxarray/plot/accessor.py Outdated
The contour lines are computed on the unstructured grid itself, without
regridding the data, and are returned as HoloViews elements that can be
overlaid on other plots with ``*``.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it needs to be individual plotting methods' docstrings' job to mention how holoviews elements can be combined, at least not this high up in the docstring. It is also already discoverable by the examples in the docstring! Also, more importantly, using * is not the only want to combine holoviews elements; ror example, you can use + to create a layout with multiple plots.

I'd recommend to either remove the mention of "can be overlaid on other plots with *", or, if you prefer, add a mention about the ability to use + as well.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with this!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really agree with this entirely, I think that you will likely want to always put contours on top of other data (*), and not really ever add it to a separate plot on it's own (+) so I don't know why the added (+) information would be needed. I think it's of the few plot types that is almost exclusively used on top of other data, hence the distinction. But if I'm in the minority I can delete it

@cmdupuis3 cmdupuis3 Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You do see pure contour plots occasionally, it's the standard with pressure, streamfunction, geopotential height, etc.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still imagine many would still at least have at least coastlines, which require "*", but I get it, it's gone now

Comment thread uxarray/plot/accessor.py Outdated
Comment on lines +581 to +583
projection : ccrs.Projection, optional
The map projection to use. Pass the same projection as the plot the
contours are overlaid on.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor nit, the contours might not be overlaid on a plot at all!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok updated.

Comment thread uxarray/plot/accessor.py
Comment thread uxarray/plot/contour.py
Comment on lines +61 to +71
def _fan_corners(
face_node_connectivity: np.ndarray,
n_nodes_per_face: np.ndarray,
lon: np.ndarray,
lat: np.ndarray,
) -> np.ndarray:
"""For each face, the position of a corner that can see every other corner, so that
a fan of triangles from it covers the face exactly. 0 is tried first; -1 means that
no corner can. Faces that touch or contain a pole, or have no area, keep 0: a pole is
one point drawn at an arbitrary longitude, so their shape in longitude and latitude
says nothing about dents."""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you don't need to add support for this? Uxarray only supports convex grids right now, see #1615. If this could be relevant on a convex grid, please clarify. If this is only to handle non-convex grids, please clarify that in the docstring instead. Since it already exists and is an internal method, I won't push for removing the function right now, since it could easily be removed or edited later if needed; just updating comment/docstring would be sufficient for clarity here.

Comment thread uxarray/plot/contour.py
Comment on lines +72 to +78
corners = np.zeros(len(n_nodes_per_face), dtype=np.int64)
for face in range(len(n_nodes_per_face)):
n = n_nodes_per_face[face]
if n < 4:
continue
x = np.empty(n)
y = np.empty(n)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will likely permit some nice performance optimizations later due to the tiny numpy arrays, as per #1648. It's probably okay to push the performance optimization to a later PR, though. Feel free to mark this as resolved once you read it, I just wanted to leave this note/link for future reference when looking at 1648.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1, the tiny numpy arrays should be fixed.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you be able to make it possible with this PR if you sat together, @dylannelson and @Sevans711 ?

My thinking is that since we solved it once, let's do it here as well rather than introducing the issue again and solving it later.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm working on a fix now!

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Fixed" on sevans/contour-maps-avoid-tiny-numpy-in-numba branch, by lifting the x and y buffer initializations to be outside the loop. Putting "fixed" in quotes because I didn't actually see any noticeable speedup when calling arr.plot.contour(). This is probably because the _fan_corners accounts for at most a few percent of the .plot.contour() calls, in all cases I tested. So, this is probably some premature optimization and not actually necessary… though it also doesn't hurt. I'd personally be fine regardless of whether this gets merged into this PR or not.

I also separately checked what happens if I keep the buffers in the loop and use prange instead of range, but that also didn't show any noticeable speedups (again, this method is only a few percent of the .plot.contour() calls).

More details: timed across tutorial examples "outCSne30-vortex" and "mpas-QU-oQU480" (ocean grid, might have nontrivial _fan_corners), as well as healpix grids (I tried example UxDataArray like: grid = ux.Grid.from_healpix(zoom=zoom); areas = grid1.compute_face_areas(as_uxarray=True); arr = (areas1* np.arange(len(areas))), with zoom values of 6, 7, 8, and 9, which reaches up to 3M faces). Also tried using both method="edges" and method="interpolated".

Even with "interpolated", .plot.contour() is taking <30 seconds on 3M faces, which to me feels "fast enough" for initially shipping the feature?

Comment thread uxarray/plot/contour.py Outdated
Comment on lines +1 to +11
"""Contour lines for data on an unstructured grid, without regridding.

Two methods are provided:

- ``"interpolated"``: the data is treated as values at the corners of a
triangulation of the grid (the grid's own faces for node-centered data, its
dual mesh for face-centered data) and contoured with linear interpolation
inside each triangle.
- ``"edges"``: a contour is made of the grid edges that separate faces above a
level from faces at or below it. No interpolation is done.
"""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file has a lot of code, it makes me think you may already have in your mind a high-level overview of what problems are being solved in this file, but it isn't fully clear yet to me at a high level like this. Could you provide a few sentences or bullet points in the file docstring here about the main problems being solved / the difficulties with building contour maps?

For example, one thing is needing to figure out labels and where they go, another is needing to deal with lines crossing the antimeridian, another is needing to join segments into continuous lines (why?), and another is needing to triangulate faces (why?).

This doesn't need to be a long explanation, but even just one sentence about each problem would make it much easier to understand what this file is doing at a high level, so that in the future developers (or LLMs) don't need to parse the whole thing to understand its goals, they can use the docstring as a guide.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like those comments would belong to the actual methods that solve those issues though? My POV, I wouldn't even mention triangulation at all, it should just say "we're making contours with two methods" and leave any complexity out.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with Chris, the majority of the explanation I think should fall within each function. I can see how this is only a partial explanation though, from a previous version that only had a few functions, and so I removed it, as the other methods are plentiful now and have their own explanations.

Meanwhile constants.py exists in the same directory and it's literally only 2 lines of code:

# threshold for number of faces before recompensing rasterization
N_FACE_THRESHOLD = 10000

matplotlib.py exists in the same directory and has 0 doc string/overview at all

Image

accessor.py is even more broad and less self explanatory, and has no high level overview:

Image

Not sure why we need a full explanation at the top, or what basis there is for needing one now.

Comment thread test/test_plot.py
Comment thread uxarray/plot/contour.py
"Contours are only supported for face-centered or node-centered data, "
f"but got dimension '{dim}'"
)
return np.asarray(uxda.squeeze().values, dtype=float), dim

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pushed back on this squeeze() in my main review comment; leaving an inline comment to help track this topic.

@dylannelson dylannelson Oct 16, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one I'm really confused about. The original concern was "presence of "time" ... may be mishandling coords from source to the output", and when time is 1D, it can cause functions to crash. From my understanding, squeezing solves the error in this case and is the recommended solution. But the proposed change is to fix it sometimes and not other's because we want to "follow in the style of xarray when possible"? If we wanted to make user's workflows/behavior more consistent, why would having asymmetrical behaviors like that be beneficial? Feels like a lose-lose situation either way, either be different than xarray, or have similar functions error differently to the same coordinates. I feel like it would be easier to just squeeze to clean the dims and provide less resistance for the user, vs in the proposed case, which is to assume the user knows the asymmetrical relationships of plotting functions and we avoid friction for that user. Since this isn't a discrepancy with xarray that anyone has actually raised a concern about, and it doesn't seem to make the "extra dims" problem any easier for a user, could we discuss more about it with others before letting it be a blocker here?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation visualization Plotting or other visualizations

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Overlaying Contours on Color-Filled Shading in UXarray/Holoviews/Matplotlib Support for contour plots

5 participants