Apply function to points within circular neighborhood - #941
Conversation
| # face centers, edge centers or nodes. | ||
| tree = self.uxgrid.get_ball_tree(coordinates=data_mapping, reconstruct=True) |
There was a problem hiding this comment.
We should probably fix this logic in get_ball_tree(), since we shouldn't need to manually set reconstruct=False
if self._ball_tree is None or reconstruct:
self._ball_tree = BallTree(
self,
coordinates=coordinates,
distance_metric=distance_metric,
coordinate_system=coordinate_system,
reconstruct=reconstruct,
)
else:
if coordinates != self._ball_tree._coordinates:
self._ball_tree.coordinates = coordinatesThe coordinates != self._ball_tree._coordinates check should be included in the first if
There was a problem hiding this comment.
That makes sense. So, move the coordinates check to the if-clause like this?
if (
self._ball_tree is None
or coordinates != self._ball_tree._coordinates
or reconstruct
):
self._ball_tree = BallTree(
self,
coordinates=coordinates,
distance_metric=distance_metric,
coordinate_system=coordinate_system,
reconstruct=reconstruct,
)What if the coordinate_system is different? Would that also require a newly constructed tree?
There was a problem hiding this comment.
Whatever logic is fixed in Grid.get_ball_tree should also be applied to Grid.get_kdtree.
There was a problem hiding this comment.
checking coordinate system also (coordinate_system is not a hidden variable of _ball_tree; it has no underscore):
if (
self._ball_tree is None
or coordinates != self._ball_tree._coordinates
or coordinate_system != self._ball_tree.coordinate_system
or reconstruct
):
self._ball_tree = BallTree(
self,
coordinates=coordinates,
distance_metric=distance_metric,
coordinate_system=coordinate_system,
reconstruct=reconstruct,
)|
|
||
| coordinate_system = tree.coordinate_system | ||
|
|
||
| if coordinate_system == "spherical": | ||
| if data_mapping == "nodes": | ||
| lon, lat = ( | ||
| self.uxgrid.node_lon.values, | ||
| self.uxgrid.node_lat.values, | ||
| ) | ||
| elif data_mapping == "face centers": | ||
| lon, lat = ( | ||
| self.uxgrid.face_lon.values, | ||
| self.uxgrid.face_lat.values, | ||
| ) | ||
| elif data_mapping == "edge centers": | ||
| lon, lat = ( | ||
| self.uxgrid.edge_lon.values, | ||
| self.uxgrid.edge_lat.values, | ||
| ) | ||
| else: | ||
| raise ValueError( | ||
| f"Invalid data_mapping. Expected 'nodes', 'edge centers', or 'face centers', " | ||
| f"but received: {data_mapping}" | ||
| ) | ||
|
|
||
| dest_coords = np.vstack((lon, lat)).T | ||
|
|
||
| elif coordinate_system == "cartesian": | ||
| if data_mapping == "nodes": | ||
| x, y, z = ( | ||
| self.uxgrid.node_x.values, | ||
| self.uxgrid.node_y.values, | ||
| self.uxgrid.node_z.values, | ||
| ) | ||
| elif data_mapping == "face centers": | ||
| x, y, z = ( | ||
| self.uxgrid.face_x.values, | ||
| self.uxgrid.face_y.values, | ||
| self.uxgrid.face_z.values, | ||
| ) | ||
| elif data_mapping == "edge centers": | ||
| x, y, z = ( | ||
| self.uxgrid.edge_x.values, | ||
| self.uxgrid.edge_y.values, | ||
| self.uxgrid.edge_z.values, | ||
| ) | ||
| else: | ||
| raise ValueError( | ||
| f"Invalid data_mapping. Expected 'nodes', 'edge centers', or 'face centers', " | ||
| f"but received: {data_mapping}" | ||
| ) |
There was a problem hiding this comment.
Use #974 's new remap.utils._remap_grid_parse instead of this code block.
Kept neighborhood and dual additions
|
HI @ahijevyc Apologies for not getting to this PR earlier. Looking at the implementation here, it looks great. It does however bring to light a possible need for us to consider a better, more streamlined, approach to handling these types of groupings and then applying some function on the result. I mention this because of our Topological Aggregations. For this family of functions, we have distinct methods (i.e. Very generally speaking, these functions essentially:
I wonder if this would be a good opportunity to extend the inherited I'm not sure of calling these approaches "kernels" is appropriate, but for the sake of this example, we could provide spatial kernels the user could pass into # radial neighborhood of r=0.25
uxds['t2m'].groupby(kernel=ux.BoundingCircle(r=0.25)).mean()
# 2 deg by 2 deg bounding box
uxds['t2m'].groupby(kernel=ux.BoundingBox(dlon=2, dlat=2))
# group the nodes that surround each face and find the maximum
uxds['node_centered_var'].groupby(kernel=ux.FaceNode()).max()
# this is equivalent to the following in the current release
uxds['node_centered_var'].topological_max(destination='face')I'll ping @aaronzedwick and @erogluorhan for their thoughts on this. I personally really like the design above and think that it aligns well with the overall design. |
That is interesting. You suggesting changing the way we do aggregations entirely? Then this would affect the reduction PR I am working on then. Perhaps this PR could implement that change if you wish. I am fine with this, if you want to, it sounds like it would be intuitive. |
We should be changing the API for it, since the current one is not sustainable if we plan to implement many times of spatial grouping methods. The underlying functionality would remain mostly unchanged.
If we do decide to do this, I'll open up a separate PR starting with the topological aggregations. |
So would the reductions PR be obsolete? |
No. The underlying implementation would remain the same, since we would still need those implemented. This would just provide a different interface for it, with a more "Xarray-like" interface. |
Ah, okay, I see. That makes sense, thanks for the clarification! |
|
pre-commit.ci autofix |
for more information, see https://pre-commit.ci
|
pre-commit.ci autofix |
|
@ahijevyc are you still looking into this? Let me know if not, I can takeover.. |
|
|
@erogluorhan @rajeeja I forgot that I assigned myself to this! Please take it over, Rajeev, and thank you for that. |
…_filter # Conflicts: # uxarray/core/dataset.py
- Fix Grid.get_ball_tree()/get_kd_tree() caching to rebuild when the coordinates or coordinate_system changes, instead of mutating the cached tree in place (per review discussion). - Move the bulk of UxDataArray.neighborhood_filter's computation into a new _neighborhood_filter() helper in uxarray.grid.neighbors, with UxDataArray/UxDataset now acting as thin wrappers around it. - Fix a bug where func was applied across all axes instead of just the grid axis, which collapsed extra dimensions (e.g. time) in the output. - Bring UxDataset.neighborhood_filter's docstring in line with the UxDataArray implementation. - Add tests for face/node/edge-centered data, custom functions via functools.partial, extra dimension preservation, and dataset-level filtering. - Document neighborhood_filter in docs/api.rst.
…ter' into ahijevyc/neighborhood_filter
- neighbors.py: np.empty -> np.full(np.nan) so empty neighborhoods yield NaN instead of uninitialized garbage memory - dataarray.py: replace bare assert with raise ValueError; auto-transpose so grid dim is last before filtering, restore original dim order after - dataset.py: simplify loop now that UxDataArray handles the transpose - grid.py: fix get_ball_tree docstring default coordinate_system 'spherical' - test_dataarray.py: add test_empty_neighborhood_returns_nan and test_auto_transpose_direct_on_uxdataarray tests - docs: add neighborhood-filter.ipynb user guide and register in userguide.rst
|
Check out this pull request on See visual diffs & provide feedback on Jupyter Notebooks. Powered by ReviewNB |
Two related fixes in UxDataArray:
1. _copy() default deep behavior: kwargs.get('deep', None) returned None
(falsy) when called with no arguments, so the uxgrid was always shallow-
copied even though xarray's own default is deep=True. Changed the
fallback to True so _copy() matches xarray's documented default.
2. neighborhood_filter memory optimization: the filter was calling
_copy() (deep copy of data) then immediately overwriting .data with
the freshly computed result, wasting a full copy of the input array.
Switch to _copy(data=destination_data, deep=False) which:
- Passes the filtered data directly, skipping the redundant deep copy
- Uses deep=False so the returned UxDataArray shares the same uxgrid
object (appropriate: the filtered result lives on the same grid topology)
- Preserves all metadata (name, attrs, coords) as before
UxDataArray.neighborhood_filter and UxDataset.neighborhood_filter now include Examples and See Also docstring sections, making them consistent with xarray conventions and discoverable from help() / sphinx docs. The user-guide notebook is also expanded (30 cells): - Use ux.tutorial datasets (no raw file paths) - Add before/after visualizations (.plot.polygons) - Cover face-, node-, and edge-centered data - Show multi-dimensional (time x space) usage - Demonstrate chaining with xarray .where() and .groupby() - Add radius sweep comparison (0 deg, 2.5 deg, 5 deg, 10 deg) - Add API reference section with cross-links to related methods
|
@ahijevyc — since GitHub blocks adding the PR author as a reviewer, could you take a look when you get a chance? Key changes since your last review round: Bug fixes:
API compliance / xarray parity:
Docs:
Happy to discuss further or adjust anything before merge! |
Philip is no longer on the project. Dismissing stale review — all concerns have been addressed in subsequent commits.
There was a problem hiding this comment.
If the search radius is so small that no neighbor is found for a given element,
the result for that element isNaNrather than an uninitialized garbage value.
In practice this only happens for extremely small radii on very coarse grids; the
filter always includes the queried element itself, sor = 0is safe.
I don't understand this comment. Even with a very coarse grid and an extremely small radius, I don't see how a NaN could be returned. After all, the filter always includes the queried element itself.
ahijevyc
left a comment
There was a problem hiding this comment.
Thanks for carrying this across the finish line! I just had a comment or two about the assertion that NaN could be returned for a coarse grid and small radius. I don't think this could occur in practice, as the filter should at least return the values at the original grid points even if the radius is zero. And grid.neighbors.BallTree.query_radius already makes sure the radius is not negative.
| neighbor_indices = tree.query_radius(dest_coords, r=r) | ||
|
|
||
| # Initialize with NaN so that any element whose neighborhood is empty | ||
| # (e.g. an isolated point queried with a very small radius) yields NaN |
There was a problem hiding this comment.
Initializing with NaN is a good defensive idea. But I don't think the reasoning about an isolated point queried with a very small radius is strictly correct. The filter should return the original values even if the radius is zero.
Apply a neighborhood filter within a circular radius r to a UxDataset or UxDataArray.
Issue #930
Overview
This is kind of like
uxarray.UxDataArray.inverse_distance_weighted_remap, but the neighborhood is defined by distance, not a number of nearest neighbors. This is ideally suited for a variable resolution mesh, in which a constant of neighbors doesn't have a constant sized neighborhood. Another difference is that this neighborhood filter does not weight data by inverse distance.Just like
uxarray.UxDataArray.subset.bounding_circlethis function usesball_tree.query_radiusto select grid elements in a circular neighborhood, but this function finds the neighborhood for all elements in grid, not just one center_coordinate.The filter function
funcmay be a user-defined function, but usesnp.meanby default. It could bemin,max,np.median. It can even use functions that require additional arguments, likenp.percentileif you supply the argument(s) withfunctools.partial(see below)Expected Usage
PR Checklist
General
Testing
Documentation
_) and have been added todocs/internal_api/index.rstdocs/user_api/index.rstExamples
docs/examples/folderdocs/examples.rsttoctreedocs/gallery.ymlwith appropriate thumbnail photo indocs/_static/thumbnails/