Skip to content

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

Merged
rajeeja merged 27 commits into
mainfrom
contour-maps
Oct 23, 2026
Merged

rajeeja merged 27 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 [dab6f03] <main~1> After [b3896f4] Ratio Benchmark (Parameter)
- 7.59±0.1ms 6.37±0.01ms 0.84 quad_hexagon.QuadHexagon.time_open_grid

Benchmarks that have stayed the same:

Change Before [dab6f03] <main~1> After [b3896f4] Ratio Benchmark (Parameter)
827±20μs 837±10μs 1.01 connectivity.Connectivity.time_edge_face('120km')
362±9μs 369±5μs 1.02 connectivity.Connectivity.time_edge_face('480km')
2.72±0.01ms 2.72±0.02ms 1.00 connectivity.Connectivity.time_edge_node('120km')
884±10μs 890±20μs 1.01 connectivity.Connectivity.time_edge_node('480km')
45.9±4μs 56.9±6μs ~1.24 connectivity.Connectivity.time_face_edge('120km')
43.6±4μs 54.9±8μs ~1.26 connectivity.Connectivity.time_face_edge('480km')
597±20μs 601±10μs 1.01 connectivity.Connectivity.time_face_face('120km')
325±10μs 317±8μs 0.98 connectivity.Connectivity.time_face_face('480km')
45.0±6μs 43.2±5μs 0.96 connectivity.Connectivity.time_face_node('120km')
44.5±4μs 44.1±6μs 0.99 connectivity.Connectivity.time_face_node('480km')
392±20μs 392±9μs 1.00 connectivity.Connectivity.time_n_nodes_per_face('120km')
294±20μs 289±8μs 0.98 connectivity.Connectivity.time_n_nodes_per_face('480km')
1.12±0.05ms 1.13±0.01ms 1.01 connectivity.Connectivity.time_node_edge('120km')
365±6μs 378±4μs 1.04 connectivity.Connectivity.time_node_edge('480km')
57.9±1ms 58.4±1ms 1.01 connectivity.Connectivity.time_node_face('120km')
3.64±0.04ms 3.70±0.08ms 1.02 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')
5.92±0.05ms 5.97±0.08ms 1.01 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
1.89±0.02ms 1.90±0.03ms 1.01 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
7.30±0.04ms 7.36±8ms 1.01 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
1.08±0.01ms 1.07±0.02ms 0.98 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.91M 1.91M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
1.9M 1.9M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
2.06M 2.06M 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'))
1.18±0.02μs 1.14±0.02μs 0.97 geometry_kernels.AccucrossKernels.time_accucross
2.64±0.02μs 2.61±0.02μs 0.99 geometry_kernels.AccucrossKernels.time_accucross_pair
425±9ns 446±20ns 1.05 geometry_kernels.EFTPrimitives.time_acc_sqrt_re
425±5ns 421±10ns 0.99 geometry_kernels.EFTPrimitives.time_diff_of_products
361±10ns 391±10ns 1.08 geometry_kernels.EFTPrimitives.time_two_prod
371±5ns 380±20ns 1.03 geometry_kernels.EFTPrimitives.time_two_sum
691±40ns 657±20ns 0.95 geometry_kernels.GCAConstLatIntersection.time_accux_constlat_kernel
696±5ns 726±30ns 1.04 geometry_kernels.GCAConstLatIntersection.time_gca_const_lat_intersection
822±30ns 811±40ns 0.99 geometry_kernels.GCAConstLatIntersection.time_try_gca_const_lat_intersection
551±10ns 556±20ns 1.01 geometry_kernels.GCAGCAIntersection.time_accux_gca_kernel
661±100ns 676±20ns 1.02 geometry_kernels.GCAGCAIntersection.time_gca_gca_intersection
756±20ns 731±10ns 0.97 geometry_kernels.GCAGCAIntersection.time_try_gca_gca_intersection
51.0±1μs 51.9±1μs 1.02 geometry_kernels.OrientPredicates.time_on_minor_arc
50.3±0.8μs 51.7±0.3μs 1.03 geometry_kernels.OrientPredicates.time_orient3d_on_sphere
2.40±0ms 2.41±0ms 1.00 geometry_samebody.SameBodyConstLat.time_accux_dispatch
1.02±0ms 1.02±0ms 1.00 geometry_samebody.SameBodyConstLat.time_accux_kernel
1.64±0ms 1.64±0.01ms 1.00 geometry_samebody.SameBodyConstLat.time_fp64_dispatch
121±0.07μs 121±0.9μs 1.00 geometry_samebody.SameBodyConstLat.time_fp64_kernel
29.5±0.02ms 29.6±0.02ms 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_dispatch
6.27±0.01ms 6.36±0.04ms 1.02 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_kernel
23.6±0.04ms 23.7±0.1ms 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_dispatch
859±0.5μs 861±1μs 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_kernel
687±5ms 710±4ms 1.03 import.Imports.timeraw_import_uxarray
2.50±0.02ms 2.46±0.02ms 0.99 mpas_ocean.CheckNorm.time_check_norm('120km')
2.02±0.01ms 1.98±0.03ms 0.98 mpas_ocean.CheckNorm.time_check_norm('480km')
1.13±0.01ms 1.15±0.01ms 1.02 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('120km')
548±10μs 550±10μs 1.00 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('480km')
658±10μs 668±5μs 1.01 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('120km')
594±10μs 621±10μs 1.05 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('480km')
3.39±0.01ms 3.38±0.02ms 1.00 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('120km')
2.60±0.01ms 2.59±0.02ms 1.00 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('480km')
78.2±0.4ms 79.5±0.3ms 1.02 mpas_ocean.ConstructFaceLatLon.time_welzl('120km')
7.22±0.01ms 7.56±0.3ms 1.05 mpas_ocean.ConstructFaceLatLon.time_welzl('480km')
18.8±0.05ms 18.7±0.08ms 1.00 mpas_ocean.ConstructTreeStructures.time_ball_tree('120km')
1.04±0.07ms 981±20μs 0.95 mpas_ocean.ConstructTreeStructures.time_ball_tree('480km')
10.1±0.01ms 10.1±0.09ms 1.00 mpas_ocean.ConstructTreeStructures.time_kd_tree('120km')
685±20μs 636±30μs 0.93 mpas_ocean.ConstructTreeStructures.time_kd_tree('480km')
417±9ms 422±5ms 1.01 mpas_ocean.CrossSections.time_const_lat('120km', 1)
214±6ms 210±0.2ms 0.98 mpas_ocean.CrossSections.time_const_lat('120km', 2)
109±2ms 114±2ms 1.05 mpas_ocean.CrossSections.time_const_lat('120km', 4)
368±1ms 377±5ms 1.03 mpas_ocean.CrossSections.time_const_lat('480km', 1)
183±0.6ms 184±0.3ms 1.00 mpas_ocean.CrossSections.time_const_lat('480km', 2)
94.1±0.5ms 94.1±0.1ms 1.00 mpas_ocean.CrossSections.time_const_lat('480km', 4)
351M 372M 1.06 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 1)
351M 372M 1.06 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 2)
350M 372M 1.06 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 4)
26.3±0.2ms 25.8±0.02ms 0.98 mpas_ocean.DualMesh.time_dual_mesh_construction('120km')
2.89±0.02ms 2.90±0.02ms 1.00 mpas_ocean.DualMesh.time_dual_mesh_construction('480km')
14.2±0.9ms 14.5±0.2ms 1.02 mpas_ocean.FaceAreas.time_face_areas('120km')
4.29±0.1ms 4.23±0.3ms 0.99 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')
718k 719k 1.00 mpas_ocean.FaceAreas.track_peakmem_face_areas('480km')
333±4ms 339±5ms 1.02 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', False)
51.0±1ms 50.5±0.5ms 0.99 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', True)
43.5±0.2ms 44.8±0.8ms 1.03 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', False)
5.62±0.09ms 5.45±0.3ms 0.97 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', True)
15.0±0.3ms 14.4±1ms 0.96 mpas_ocean.Gradient.time_gradient('120km')
1.83±0.01ms 1.83±0.01ms 1.00 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')
346M 371M 1.07 mpas_ocean.GradientColdStartRss.track_peakmem_gradient('120km')
202±1μs 193±3μs 0.96 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('120km')
100±2μs 96.9±2μs 0.97 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('480km')
140±1μs 143±2μs 1.02 mpas_ocean.Integrate.time_integrate('120km')
126±0.4μs 132±2μs 1.05 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')
185±1ms 180±0.8ms 0.97 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'exclude')
188±2ms 181±0.9ms 0.96 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'include')
187±2ms 180±1ms 0.96 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'split')
13.1±0.05ms 12.9±0.1ms 0.99 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'exclude')
13.2±0.3ms 12.9±0.3ms 0.98 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'include')
13.0±0.05ms 12.7±0.02ms 0.98 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'split')
210±0.7ms 211±0.3ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('120km', 1.0)
434±0.9ms 436±1ms 1.01 mpas_ocean.NeighborhoodBuild.time_build('120km', 5.0)
11.3±0.02ms 11.4±0.04ms 1.01 mpas_ocean.NeighborhoodBuild.time_build('480km', 1.0)
13.9±0.02ms 14.1±0.04ms 1.01 mpas_ocean.NeighborhoodBuild.time_build('480km', 5.0)
207±0.8ms 207±0.4ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 1.0)
430±1ms 431±0.6ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 5.0)
11.0±0.01ms 11.1±0.05ms 1.01 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 1.0)
13.7±0.09ms 13.8±0.03ms 1.01 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)
824k 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)
39.4±0.6ms 39.5±0.4ms 1.00 mpas_ocean.NeighborhoodDask.time_mean('120km', 'grid_chunks')
18.2±0.06ms 18.3±0.04ms 1.01 mpas_ocean.NeighborhoodDask.time_mean('120km', 'numpy')
36.4±0.5ms 36.1±0.3ms 0.99 mpas_ocean.NeighborhoodDask.time_mean('120km', 'time_chunks')
10.6±0.1ms 10.9±0.2ms 1.03 mpas_ocean.NeighborhoodDask.time_mean('480km', 'grid_chunks')
733±6μs 730±10μs 1.00 mpas_ocean.NeighborhoodDask.time_mean('480km', 'numpy')
7.49±0.09ms 7.57±0.2ms 1.01 mpas_ocean.NeighborhoodDask.time_mean('480km', 'time_chunks')
5.83M 5.77M 0.99 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')
757k 757k 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'grid_chunks')
177k 177k 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'numpy')
539k 547k 1.02 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'time_chunks')
3.90±0s 3.94±0s 1.01 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'mean')
4.00±0s 4.04±0.01s 1.01 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'median')
104±0.5ms 104±0.7ms 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'mean')
106±0.3ms 105±0.1ms 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'median')
437±1ms 439±1ms 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'mean')
467±1ms 469±0.3ms 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'median')
14.2±0.02ms 14.4±0.04ms 1.02 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'mean')
14.5±0.08ms 14.6±0.1ms 1.01 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'median')
3.84±0ms 3.87±0ms 1.01 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'mean')
32.3±0.1ms 32.2±0.03ms 1.00 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'median')
156±1μs 158±2μs 1.01 mpas_ocean.NeighborhoodReduce.time_reduce('480km', 'mean')
361±20μs 401±4μs ~1.11 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')
264±20μs 249±20μs 0.94 mpas_ocean.PointInPolygon.time_face_search_lonlat('120km')
270±9μs 266±20μs 0.99 mpas_ocean.PointInPolygon.time_face_search_lonlat('480km')
254±10μs 257±20μs 1.01 mpas_ocean.PointInPolygon.time_face_search_xyz('120km')
244±20μs 241±30μs 0.99 mpas_ocean.PointInPolygon.time_face_search_xyz('480km')
79.6±0.3ms 81.9±0.5ms 1.03 mpas_ocean.RemapDownsample.time_bilinear_remapping
13.0±0.07ms 13.7±0.1ms 1.05 mpas_ocean.RemapDownsample.time_inverse_distance_weighted_remapping
11.7±0.05ms 12.1±0.08ms 1.04 mpas_ocean.RemapDownsample.time_nearest_neighbor_remapping
740±7ms 754±6ms 1.02 mpas_ocean.RemapUpsample.time_bilinear_remapping
20.0±0.2ms 19.8±0.06ms 0.99 mpas_ocean.RemapUpsample.time_inverse_distance_weighted_remapping
8.11±0.06ms 8.02±0.02ms 0.99 mpas_ocean.RemapUpsample.time_nearest_neighbor_remapping
8.88±0.2ms 9.12±0.3ms 1.03 mpas_ocean.ZonalAverage.time_zonal_average('120km')
5.59±0.08ms 5.58±0.07ms 1.00 mpas_ocean.ZonalAverage.time_zonal_average('480km')
352M 371M 1.05 mpas_ocean.ZonalAveragePeakMem.track_peakmem_zonal_average('120km')
1.0318310866512919 1.0398902447913223 1.01 nogil_scaling.GILScaling.track_gil_scaling
8.60±0.2ms 7.96±0.08ms 0.93 quad_hexagon.QuadHexagon.time_open_dataset
408 408 1.00 quad_hexagon.QuadHexagon.track_nbytes_open_dataset
392 392 1.00 quad_hexagon.QuadHexagon.track_nbytes_open_grid
73.5k 72.5k 0.99 quad_hexagon.QuadHexagon.track_peakmem_open_dataset
72.6k 71.6k 0.99 quad_hexagon.QuadHexagon.track_peakmem_open_grid
1.14±0.01s 1.14±0.01s 1.00 to_raster.ToRaster.time_to_raster((10000.0, 10.0))
2.76±0.01s 2.75±0.02s 0.99 to_raster.ToRaster.time_to_raster((10000.0, 100.0))
3.17±0.02s 3.14±0.02s 0.99 to_raster.ToRaster.time_to_raster((100000.0, 100.0))
271M 271M 1.00 to_raster.ToRaster.track_peakmem((10000.0, 10.0))
684M 684M 1.00 to_raster.ToRaster.track_peakmem((10000.0, 100.0))
768M 768M 1.00 to_raster.ToRaster.track_peakmem((100000.0, 100.0))

Benchmarks that have got worse:

Change Before [dab6f03] <main~1> After [b3896f4] Ratio Benchmark (Parameter)
+ 331M 371M 1.12 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
+ 330M 371M 1.12 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
+ 332M 371M 1.12 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
+ 331M 371M 1.12 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
+ 288M 371M 1.29 import.Imports.track_peakmem_import_uxarray
+ 334M 372M 1.11 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 1)
+ 334M 372M 1.12 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 2)
+ 334M 372M 1.12 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 4)
+ 326M 371M 1.14 mpas_ocean.GradientColdStartRss.track_peakmem_gradient('480km')
+ 336M 371M 1.1 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.

cmdupuis3 and others added 3 commits October 13, 2026 16:58
HoloViews and both plotting backends handle each path separately, so a
noisy field with ~77k lines on a 786k-face mesh took 181 s to render with
Matplotlib and 14.5 s with Bokeh, plus 2 s to build the element. With one
path per level the lines are drawn as gaps at almost no cost: 0.14 s,
0.13 s and 0.07 s. The level is still the value of each path, so hover
is unchanged, and the rendered images match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Projecting every line with project_geometry took ~0.1 ms per line, or
7.4 s for the ~77k lines of a noisy field on a 786k-face mesh. A line with
a projected point well inside the projection's domain has extent on the
map, so all points are projected at once and only the remaining lines,
such as those along the edge of the map or off it, are projected as
lines. The lines kept are the same as before for 10 projections (global,
hemispheric, regional and shifted central longitudes) on four datasets,
and the check takes 0.04-1.1 s instead of 2.3-4.4 s on a 197k-face mesh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both were done with several NumPy calls per line, which for the ~77k
lines of a noisy field on a 786k-face mesh took 1.1 s in _edge_contours
and 0.5 s in _label_points. The lines are now handled as one array of
points: _edge_contours unwraps them together and splits only the lines
that cross the antimeridian (1.8 s -> 1.0 s including joining), and
_label_points finds the middles only of the lines it labels
(0.52 s -> 0.05 s).

Longitudes are now unwrapped by whole multiples of 360, so a node on the
antimeridian lands exactly on +-180 instead of a rounding error past it.
That removes the zero-length pieces and repeated end points that clipping
at the antimeridian gave for such lines. The lines are otherwise the
same, and the labels are at the same points; lines of exactly equal
length are now labeled in the order they were found.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@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.

_join_segments walked the segments in pure Python, which took 0.89 s for
the ~650k segments of a noisy field on a 786k-face mesh. The kernel
keeps the same walk, with the segments at each node in CSR arrays and a
per-node cursor to the first unused one, and gives the same lines in the
same order (checked on random graphs with branching nodes, self-loops and
repeated segments, and on the segments of four meshes). It takes 0.074 s,
and _edge_contours as a whole goes from 1.0 s to 0.2 s.

The lines are returned as one array of nodes with offsets, the layout
_edge_contours already built from the lists. The kernel holds no Python
objects, so it is compiled with nogil=True and can run in threads (8
calls on 8 threads ran 4x faster than in sequence).

Also applies ruff format to a line from the projection filter commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@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.

@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
Comment thread uxarray/plot/accessor.py Outdated
Comment thread uxarray/plot/accessor.py
Comment thread uxarray/plot/contour.py Outdated
Comment thread uxarray/plot/contour.py Outdated
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.

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.

Linking as relevant: #1598. I believe the team eventually wants to see docstrings at the top of files, even if they are short? I would classify these example files as "missing a docstring", not as evidence to put no docstring at all. Even if the decision here ends up being to keep all the details out of the docstring, I would personally really appreciate at least keeping something like the first line you had originally, like """Contour lines for data on an unstructured grid, without regridding.""".

More details: the main reason I asked for more explanation here is the top-level complexity of this file; the other examples considered have much less top-level complexity.

  • uxarray/plot/constants.py has only 2 lines, so it's not hard to quickly understand all of its contents.
  • uxarray/plot/matplotlib.py has only 4 top-level methods/classes: _ensure_dimensions, _RasterAxAttrs, _get_points_from_axis, and _nearest_neighbor_resample, so it's not too difficult to parse.
  • uxarray/plot/accessor.py similarly has only 4 top-level methods/classes: _ensure_hvplot_imported, GridPlotAccessor, UxDataArrayPlotAccessor, UxDatasetPlotAccessor.
  • The proposed uxarray/plot.contour.py has 16 top-level methods: _spatial_values, _wrap_longitude, _fan_corners, _ear_clipping, _fan_triangles, _triangulation, _split_at_antimeridian, _contour_levels, _interpolated_contours, _join_segments, _edge_contours, _drop_lines_outside_projection, _label_fraction, _label_points, _apply_options, _compute_contours. Some are called from other top-level methods, and some are called from outside the file. The tree of calls goes a few layers deep.
    • _compute_contours calls _spatial_values, _contour_levels, _edge_contours, _interpolated_contours
      • _edge_contours calls _wrap_longitude, _join_segments, _split_at_antimeridian
      • _interpolated_contours calls _triangulation, _split_at_antimeridian
        • _triangulation calls _wrap_longitude, _fan_triangles
          • _fan_triangles calls _fan_corners and _ear_clipping

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.

(Leaving a note about this for upcoming internal meeting, maybe it would be easiest to chat about it out loud and report back here with a decision?)

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 believe the team eventually wants to see docstrings at the top of files, even if they are short?

Honestly I think they're just a Claude-ism, I just leave them in if they're actually helpful, but otherwise I wouldn't expect one to be there at all.

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 believe the team eventually wants to see docstrings at the top of files, even if they are short?

Honestly I think they're just a Claude-ism, I just leave them in if they're actually helpful, but otherwise I wouldn't expect one to be there at all.

#1598 describes more about the benefits of including such docstrings, and has been triaged as To-Do. If there's disagreement about whether it should be done in general, could we move that discussion over there so that it doesn't get lost?

In this particular PR, though, I'd argue that """Contour lines for data on an unstructured grid, without regridding.""" is quite helpful, and has very little cost, it's just one line.

Upon looking at this again, I'm realizing part of the reason I wanted a high-level overview is that the docstrings for the 16 helper functions don't clearly specify their expected entry points and outputs. For example, if I isolate just one function and its docstring:

def _ear_clipping(x: np.ndarray, y: np.ndarray) -> list[tuple[int, int, int]]:
    """Triangles of a simple polygon, as positions of its corners."""

It's hard to tell exactly what the intended inputs and outputs mean. I understand the rough concepts, but what shapes should x and y have? Why do you only need x and y to specify a polygon and not also z? How long with the output list be?

As an alternative to a detailed top-of-file docstring, I would also be happy to resolve this point without needing to discuss at internal meetings if the helper functions' docstrings were all updated to clearly answer questions like these. This would make it easy to understand each function as a standalone object, without needing a high-level map to understand how it relates to all 15 other functions in order to review the file.

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.

Reportback from internal discussion: please update the docstrings of helper functions to improve clarity, especially with regards to the shape and meaning of their inputs and outputs!

Meanwhile, there is no need to put any docstring at the top of the file. (My personal preference would still be to at least include the one-liner, but I this is a non-blocking style preference here.)

@dylannelson dylannelson Oct 23, 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.

Nearly all of the new internal function have descriptions that are 2-3x longer now and state how they are used

Ear clipping now reads:

def _ear_clipping(x: np.ndarray, y: np.ndarray) -> list[tuple[int, int, int]]:
    """Triangles of a simple polygon, as positions of its corners.

    Used by ``_fan_triangles`` for the rare dented faces in which no corner can see
    every other corner (a C-shaped face, for example), so that no fan of triangles
    covers them. It cuts off one "ear" at a time: three corners in a row whose triangle
    turns the same way as the polygon and holds no other corner. Plain Python is fast
    enough, as it only runs on those faces."""

Comment thread test/test_plot.py
Comment thread uxarray/plot/contour.py
Sevans711 and others added 2 commits October 16, 2026 11:26
Lifts the buffers out of the loop in order to avoid tiny numpy arrays in the _fan_corners numba method
@dylannelson

Copy link
Copy Markdown
Member Author
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).

Commented on that line, I can see the desire for consistency, but don't understand why we need to follow xarray in this scenario since it seems like it wouldn't address the main concern of reducing the extra dims which cause problems.

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)!

Awesome, and yeah!

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?

Probably! That would be an interesting addition. Maybe for the next update?

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.

Not really sure where this would be useful since none of this is that computationally heavy, or likely to be looped heavily, and is 100% dependent on whatever CI pipeline bot runs it, so not really a consistent metric. But if you want to add it, you sure can, may just be high variance noise though.

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.

360 degrees divided by .025 degree steps = 14400 (+1 for looping back around). But yeah that's a bit convoluted and not needed. Removed the explanation.

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.

Agreed! If people want they can set a variable to a particular projection and use that. But yeah not sure if I can get one map object to inherit the projection from another. I'll look into that.

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.

Ha, and I thought I caught all the weird display quirks. I didn't do many contours just on their own with additional maps to the side. Especially since I tended to put them with/on other data. Great catch, thanks. I'm working on a fix, but this one is weird, hope to test it further and upload soon.

@Sevans711

Copy link
Copy Markdown
Collaborator

Thank you for looking into all of my comments, I really appreciate it! Replying below for each of the points from my main message (partly as a note to myself also for when I re-review).

  1. Tracking in the inline comment above due to the more detailed response there: Added .plot.contour() function and added docs for visualizations #1812 (comment). Hoping to talk through it out loud to get on the same page, then report back here with a decision.
  2. (nothing left to do here!)
  3. (nothing left to do here; pushed to future PR!)
  4. (nothing needs to be done here, can skip for now!) I agree that the absolute times would vary based on where you run it, though the notebook does include some plots with smaller subset and some with larger subset, so times would at least show some demonstration of how plotting performance scales with amount of data.
  5. Clarifying, the live preview of the docs on this branch still show the original explanation. Also, I do think the 14401 number needs to be explained somewhere, removing the explanation entirely isn't a full solution here. Though, it could be located in the code itself if you prefer? E.g. steps=int(360/0.025)+1 # 0.025 degrees per step would work great!
  6. (nothing needs to be done here, can be pushed to future PR. Though if it does happen to be simple/easy to implement, I would be happy to review it here too!)
  7. (I will take a look at the fix once it is ready! Or, we can push this to a future PR if needed.)

dylannelson and others added 2 commits October 19, 2026 12:29
Conflicts resolved:
- uxarray/plot/contour.py: kept both _unwrap_lines and _grid_extents, and
  both _paths_by_level and _label_fraction. _label_points combines the
  line lengths found for all lines at once with labeling only a fraction
  of the levels.
- uxarray/plot/accessor.py: one NaN-separated path per level, keyed by
  the fixed "level" dimension, with labels for a fraction of the levels.
- test/test_plot.py: kept both the projection filter test and the frame
  test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cmdupuis3

Copy link
Copy Markdown
Collaborator

Local timing data for cmd/contour-maps-opt:

The gain is large wherever there are many contour lines, and close to nothing with only a few dozen.

Times are .plot.contour() plus the render, one timed run per case after a warm-up (connectivity, imports and numba compilation aren't counted):

Case contour-maps opt Speed-up
Noisy field, 786k faces (~77k lines)
Edges, Bokeh 51.4 s 0.44 s 118×
Edges, Matplotlib 122.3 s 0.34 s 357×
Edges, Bokeh, Robinson 160.8 s 9.0 s 18×
Edges, Matplotlib, Robinson 221.1 s 9.8 s 23×
Interpolated, Bokeh 56.9 s 3.8 s 15×
Interpolated, Matplotlib 130.6 s 3.6 s 36×
Interpolated, Bokeh, Robinson 178.2 s 14.8 s 12×
Interpolated, Matplotlib, Robinson 242.3 s 15.2 s 16×
Smooth field, 786k faces (~70 lines)
Edges, all four variants 0.16–1.40 s 0.11–0.91 s 1.4–1.8×
Interpolated, all four variants 3.1–4.6 s 3.1–4.0 s 1.0–1.1×
Real data, oQU120 bottom depth (~2.6k lines)
Edges, no projection 1.8 / 4.2 s 0.06 / 0.07 s 29× / 60×
Edges, Robinson 6.7 / 8.6 s 0.8 / 1.1 s 8×
Interpolated, no projection 1.8 / 4.3 s 0.11 / 0.12 s 17× / 36×
Interpolated, Robinson 6.2 / 8.6 s 0.8 / 1.1 s 8×

In the real-data rows, each pair is Bokeh / Matplotlib.

The biggest gain is in rendering. One path per level instead of one per line takes the noisy-mesh render from 47–224 s to under 0.1 s without a projection.

Some more optimizations are available on cmd/contour-maps-opt-2, but I'm deferring that to another PR due to possible licensing questions with cartopy.

@cmdupuis3 cmdupuis3 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.

One major issue, grids given in meters produce contours as if they were in degrees:

import warnings; warnings.simplefilter("ignore")
import numpy as np, xarray as xr, uxarray as ux
from uxarray.plot.contour import _compute_contours, _grid_extents

# 5 x 4 patch of 100 m quads in projected coordinates
X0, Y0 = 1_500_000.0, 800_000.0
xs, ys = np.meshgrid(X0 + 100.0 * np.arange(6), Y0 + 100.0 * np.arange(5))
faces = [[j*6+i, j*6+i+1, (j+1)*6+i+1, (j+1)*6+i] for j in range(4) for i in range(5)]
g = ux.Grid(xr.Dataset({
    "node_lon": xr.DataArray(xs.ravel(), dims=["n_node"], attrs={"standard_name": "projection_x_coordinate", "units": "m"}),
    "node_lat": xr.DataArray(ys.ravel(), dims=["n_node"], attrs={"standard_name": "projection_y_coordinate", "units": "m"}),
    "face_node_connectivity": xr.DataArray(np.array(faces), dims=["n_face", "n_max_face_nodes"],
        attrs={"cf_role": "face_node_connectivity", "start_index": 0, "_FillValue": -1}),
}), source_grid_spec="UGRID")

dist = ux.UxDataArray(xr.DataArray(g.node_lon.values - X0, dims=["n_node"]), uxgrid=g)
print(_compute_contours(dist, [250.0])[0][1][0])            # branch: x=130     expected: x=1500250

col = ux.UxDataArray(xr.DataArray(np.tile(np.arange(5.0), 4), dims=["n_face"]), uxgrid=g)
print([l[0, 0] for _, l in _compute_contours(col, [0.5, 1.5, 2.5, 3.5], "edges")])
                                                             # branch: [-20, 80, -180, -80]
                                                             # expected: [1500100, 1500200, 1500300, 1500400]
print(_grid_extents(g))                                      # branch: (-180, 800000, 180, 800400)

Comment thread uxarray/plot/contour.py Outdated
Comment thread uxarray/plot/contour.py Outdated
@dylannelson

dylannelson commented Oct 22, 2026 •

Copy link
Copy Markdown
Member Author

@cmdupuis3

One major issue, grids given in meters produce contours as if they were in degrees:

Curious how other functions handle this (because this didn't come up in any of the test files because it doesn't appear as if any are in meters), I had claude run through this on other functions and this may just be a broader problem we haven't been accounting for in function design. From claude:

I ran 30 functions on main against a 12 × 8 grid in metres (cells 0.5–2 km) and compared each result with the answer for a flat grid.

  • Right (8): only functions that use the grid's connectivity alone, or pass the coordinates through untouched: connectivity, validate(), isel, topological_mean, plotting node data as points, and writing the grid back to UGRID.
  • Wrong, no error (19): face centres, areas, edge lengths and bounds; get_dual(); the bounding-box subset (0 faces instead of 12), bounding-circle and nearest-neighbour subsets; get_faces_containing_point; weighted_mean, integrate, gradient (−10,400 instead of 1) and azimuthal_mean; remapping (nearest neighbour onto the same mesh moved 10 m keeps 71 of 96 values instead of all 96); and the shaded .plot().
  • Raise (3): cross_section and zonal_mean ("latitude out of bounds"), and grid.plot().

The cause is the same everywhere: uxarray stores coordinates as node_lon/node_lat and turns them into points on a sphere, so 1,500,000 m is read as 1,500,000°. The UGRID reader keeps units: m from the file, but nothing looks at it.

My takeaway:
I'm not sure if this needs to be a seperate issue, or just handled here as an error? But solving this within plot.contours() may not make sense as it seems to belong somewhere more general. May need more feedback from @erogluorhan and the others?

@cmdupuis3

cmdupuis3 commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

@dylannelson Yeah, since this is a bigger issue than I thought, it should probably be a separate PR. I would guess this has more to do with units checking and conversion to the unit sphere for ux.Grid than anything specifically with using meters as units.

@dylannelson

dylannelson commented Oct 23, 2026 •

Copy link
Copy Markdown
Member Author

@Sevans711

  1. Looks like we plan to keep it for now

  2. Should be added now

image
  1. Should also be implemented and fixed

@cmdupuis3
cmdupuis3 self-requested a review October 23, 2026 18:12

@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.

Good work 👏

@rajeeja
rajeeja merged commit b3896f4 into main Oct 23, 2026
19 checks passed
@cmdupuis3 cmdupuis3 added the run-benchmark Run ASV benchmark workflow label Oct 23, 2026
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 run-benchmark Run ASV benchmark workflow 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