Skip to content

Commit feb368d

Browse files
HuiteHuiteJoerivanEngelen
authored
to_facet method (Deltares#350)
* Create to_facet method, closes Deltares#173 * Add to_node, to_edge, to_face methods; API, changelog * Make sure source dim isn't chunked before indexing * Defalt to nmax instead of contributors for the new dim name * Drop python 3.9 * Update lockfile * Remove py 3.9 from CI * Pin minimum dask version to 2025.1.0 and update pixi dependencies --------- Co-authored-by: Huite <huite.bootsma@gmail.com> Co-authored-by: JoerivanEngelen <joerivanengelen@hotmail.com>
1 parent 5ac2b49 commit feb368d

11 files changed

Lines changed: 4676 additions & 8850 deletions

File tree

.github/workflows/ci.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ jobs:
3737
- py312
3838
- py311
3939
- py310
40-
- py309
4140
steps:
4241
- name: Check out repo
4342
uses: actions/checkout@v4

docs/api.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,9 @@ UgridDataArray or UgridDataset.
123123
UgridDataArrayAccessor.rasterize
124124
UgridDataArrayAccessor.rasterize_like
125125
UgridDataArrayAccessor.reindex_like
126+
UgridDataArrayAccessor.to_node
127+
UgridDataArrayAccessor.to_edge
128+
UgridDataArrayAccessor.to_face
126129
UgridDataArrayAccessor.to_periodic
127130
UgridDataArrayAccessor.to_nonperiodic
128131
UgridDataArrayAccessor.to_geodataframe

docs/changelog.rst

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,12 @@ Added
3131
inserted vertices.
3232
- Added :class:`xugrid.NetworkGridder` to grid networks (Ugrid1d) to a 2D grid.
3333
Currently only support gridding to a Ugrid2d grid.
34-
- :meth:`xugrid.UgridDataArray.interpolate_na` will now also work for Ugrid1d
34+
- :meth:`xugrid.UgridDataArrayAccessor.interpolate_na` will now also work for Ugrid1d
3535
topologies.
36+
- :meth:`xugrid.UgridDataArrayAccessor.to_node`,
37+
:meth:`xugrid.UgridDataArrayAccessor.to_edge`, and
38+
:meth:`xugrid.UgridDataArrayAccessor.to_face` have been added to map data
39+
from one facet of a grid to another.
3640
- Added ``tolerance`` argument to :meth:`xugrid.Ugrid1d.sel_points`,
3741
:meth:`xugrid.Ugrid1d.refine_by_vertices` and
3842
:meth:`xugrid.Ugrid2d.sel_points`, :meth:`xugrid.Ugrid2d.locate_centroids`.

pixi.lock

Lines changed: 4475 additions & 8838 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ name = "xugrid"
77
description = "Xarray extension for unstructured grids"
88
readme = { file = "README.rst", content-type = "text/x-rst" }
99
maintainers = [{ name = "Huite Bootsma", email = "huite.bootsma@deltares.nl" }]
10-
requires-python = ">=3.9"
10+
requires-python = ">=3.10"
1111
dependencies = [
1212
'pandas',
1313
'numba',
1414
'numba_celltree>=0.4.1',
15+
'dask>=2025.1.0',
1516
'numpy',
1617
'pooch',
1718
'scipy',
@@ -25,7 +26,6 @@ classifiers = [
2526
'Programming Language :: Python',
2627
'Operating System :: OS Independent',
2728
'Programming Language :: Python :: 3',
28-
'Programming Language :: Python :: 3.9',
2929
'Programming Language :: Python :: 3.10',
3030
'Programming Language :: Python :: 3.11',
3131
'Programming Language :: Python :: 3.12',
@@ -75,15 +75,14 @@ platforms = ["win-64", "linux-64", "osx-64", "osx-arm64"]
7575

7676
[tool.pixi.pypi-dependencies]
7777
xugrid = { path = ".", editable = true }
78-
numba_celltree = ">=0.4.1"
7978

8079
[tool.pixi.dependencies]
81-
dask = "*"
80+
dask = ">=2025.1.0"
8281
geopandas = "*"
8382
mapbox_earcut = "*"
8483
matplotlib-base = "*"
8584
netcdf4 = "*"
86-
# numba_celltree = ">=0.4.0"
85+
numba_celltree = ">=0.4.1"
8786
numpy = "*"
8887
pip = "*"
8988
pooch = "*"
@@ -94,7 +93,7 @@ pyproj = "*"
9493
pytest = "*"
9594
pytest-cases = "*"
9695
pytest-cov = "*"
97-
python = ">=3.9"
96+
python = ">=3.10"
9897
ruff = "*"
9998
shapely = ">=2.0"
10099
scipy = "*"
@@ -126,16 +125,12 @@ python = "3.11.*"
126125
[tool.pixi.feature.py310.dependencies]
127126
python = "3.10.*"
128127

129-
[tool.pixi.feature.py309.dependencies]
130-
python = "3.9.*"
131-
132128
[tool.pixi.environments]
133129
default = { features = ["py312"], solve-group = "py312" }
134130
py313 = { features = ["py313"], solve-group = "py313" }
135131
py312 = { features = ["py312"], solve-group = "py312" }
136132
py311 = ["py311"]
137133
py310 = ["py310"]
138-
py309 = ["py309"]
139134

140135
[tool.ruff.lint]
141136
# See https://docs.astral.sh/ruff/rules/

tests/test_ugrid1d.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ def test_ugrid1d_properties():
8787
assert grid.edge_dimension == f"{NAME}_nEdges"
8888
assert grid.n_node == 3
8989
assert grid.n_edge == 2
90+
assert grid.facets == {"node": grid.node_dimension, "edge": grid.edge_dimension}
9091
expected_coords = np.array([[0.0, 0.0], [1.0, 1.0], [2.0, 2.0]])
9192
assert np.allclose(grid.node_coordinates, expected_coords)
9293
assert np.allclose(grid.edge_x, [0.5, 1.5])

tests/test_ugrid2d.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,11 @@ def test_ugrid2d_properties():
146146
assert grid.n_edge == 10
147147
assert grid.n_face == 4
148148
assert grid.n_max_node_per_face == 4
149+
assert grid.facets == {
150+
"node": grid.node_dimension,
151+
"edge": grid.edge_dimension,
152+
"face": grid.face_dimension,
153+
}
149154
assert np.array_equal(grid.n_node_per_face, [4, 4, 3, 3])
150155
assert np.allclose(grid.node_coordinates, VERTICES)
151156
assert grid.bounds == (0.0, 0.0, 2.0, 2.0)

tests/test_ugrid_dataset.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,38 @@ def test_broadcasted_laplace_interpolate(self):
379379
assert set(actual.dims) == set(nd_uda2.dims)
380380
assert isinstance(actual.data, dask.array.Array)
381381

382+
def test_to_facets(self):
383+
face_da = self.uda
384+
grid = face_da.ugrid.grid
385+
386+
with pytest.raises(ValueError, match="No conversion needed"):
387+
face_da.ugrid.to_face()
388+
with pytest.raises(ValueError, match="already exists"):
389+
face_da.ugrid.to_face(grid.face_dimension)
390+
391+
node_da = face_da.ugrid.to_node()
392+
edge_da = face_da.ugrid.to_edge()
393+
assert isinstance(node_da, xugrid.UgridDataArray)
394+
assert isinstance(edge_da, xugrid.UgridDataArray)
395+
396+
back1 = node_da.mean("nmax").ugrid.to_face()
397+
back2 = edge_da.mean("nmax").ugrid.to_face()
398+
cross1 = node_da.mean("nmax").ugrid.to_edge()
399+
cross2 = edge_da.mean("nmax").ugrid.to_node()
400+
401+
assert isinstance(back1, xugrid.UgridDataArray)
402+
assert isinstance(back2, xugrid.UgridDataArray)
403+
assert isinstance(cross1, xugrid.UgridDataArray)
404+
assert isinstance(cross2, xugrid.UgridDataArray)
405+
406+
assert node_da.dims == (grid.node_dimension, "nmax")
407+
assert edge_da.dims == (grid.edge_dimension, "nmax")
408+
assert back1.dims == (grid.face_dimension, "nmax")
409+
410+
# Check fill values, should contain two NaNs for the fill value.
411+
assert back1[2:, -1].isnull().all()
412+
assert back1.isnull().sum() == 2
413+
382414
def test_to_dataset(self):
383415
uda2 = self.uda.copy()
384416
uda2.ugrid.obj.name = "test"
@@ -1524,6 +1556,24 @@ def test_laplace_interpolate_facets():
15241556
assert np.allclose(actual, 1.0)
15251557

15261558

1559+
def test_to_facets_1d():
1560+
uds = ugrid1d_ds()
1561+
with pytest.raises(ValueError, match="Cannot map to face"):
1562+
uds["a1d"].ugrid.to_face()
1563+
with pytest.raises(ValueError, match="No conversion needed"):
1564+
uds["a1d"].ugrid.to_node()
1565+
with pytest.raises(ValueError, match="No conversion needed"):
1566+
uds["b1d"].ugrid.to_edge()
1567+
1568+
grid = uds.ugrid.grid
1569+
to_edge = uds["a1d"].ugrid.to_edge()
1570+
to_node = uds["b1d"].ugrid.to_node()
1571+
assert isinstance(to_edge, xugrid.UgridDataArray)
1572+
assert isinstance(to_node, xugrid.UgridDataArray)
1573+
assert to_edge.dims == (grid.edge_dimension, "nmax")
1574+
assert to_node.dims == (grid.node_dimension, "nmax")
1575+
1576+
15271577
def test_laplace_interpolate_1d():
15281578
uda = ugrid1d_ds()["a1d"]
15291579
uda[:] = 1.0

xugrid/core/dataarray_accessor.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,123 @@ def to_nonperiodic(self, xmax: float):
276276
grid, obj = self.grid.to_nonperiodic(xmax=xmax, obj=self.obj)
277277
return UgridDataArray(obj, grid)
278278

279+
def _to_facet(self, facet: str, newdim: str):
280+
"""
281+
Map the data from one facet to another.
282+
283+
Parameters
284+
----------
285+
facet: str
286+
node, edge, face
287+
newdim: str
288+
how to name the new dimension, e.g. three nodes
289+
per triangle face, two nodes per edge, etc.
290+
"""
291+
grid = self.grid
292+
obj = self.obj
293+
294+
gridfacets = grid.facets
295+
if facet not in gridfacets:
296+
raise ValueError(
297+
f"Cannot map to {facet} for a {type(grid).__name__} topology."
298+
)
299+
300+
if newdim in obj.dims:
301+
raise ValueError(
302+
f"Dimension {newdim} already exists. Please provide a new dimension name."
303+
)
304+
305+
source_dim = set(grid.dimensions).intersection(obj.dims).pop()
306+
target_dim = getattr(grid, f"{facet}_dimension")
307+
if source_dim == target_dim:
308+
raise ValueError(
309+
f"No conversion needed, data is already {facet}-associated."
310+
)
311+
312+
# Find out on which facet we're currently located
313+
source = {v: k for k, v in gridfacets.items()}[source_dim]
314+
connectivity = grid.format_connectivity_as_dense(
315+
getattr(grid, f"{facet}_{source}_connectivity")
316+
)
317+
indexer = xr.DataArray(connectivity, dims=(target_dim, newdim))
318+
# Ensure the source dimension is not chunked for efficient indexing.
319+
obj = obj.chunk({source_dim: -1})
320+
# Set the fill values (-1) to NaN
321+
mapped = obj.isel({source_dim: indexer}).where(connectivity != -1)
322+
return UgridDataArray(mapped, grid)
323+
324+
def to_node(self, dim: str = "nmax"):
325+
"""
326+
Map data to nodes.
327+
328+
Creates a new dimension representing the contributing source elements
329+
for each node, as multiple faces/edges can connect to a single node.
330+
331+
Parameters
332+
----------
333+
dim : str, optional
334+
335+
Returns
336+
-------
337+
mapped: UgridDataArray
338+
A new UgridDataArray with data mapped to the nodes of the grid.
339+
340+
Examples
341+
--------
342+
Compute the mean elevation based on the surrounding faces for each node:
343+
344+
>>> node_elevation = face_elevation.to_node().mean("contributors")
345+
"""
346+
return self._to_facet("node", dim)
347+
348+
def to_edge(self, dim: str = "nmax"):
349+
"""
350+
Map data to edges.
351+
352+
Creates a new dimension representing the contributing source elements
353+
for each node, as two nodes or two faces are connected to an edge.
354+
355+
Parameters
356+
----------
357+
dim : str, optional
358+
359+
Returns
360+
-------
361+
mapped: UgridDataArray
362+
A new UgridDataArray with data mapped to the edges of the grid.
363+
364+
Examples
365+
--------
366+
Compute the mean elevation based on the nodes for each edge:
367+
368+
>>> edge_elevation = node_elevation.to_edge().mean("contributors")
369+
"""
370+
return self._to_facet("edge", dim)
371+
372+
def to_face(self, dim: str = "nmax"):
373+
"""
374+
Map data to faces.
375+
376+
Creates a new dimension representing the contributing source elements
377+
for each node, as two edges or multiple nodes are connected to a face.
378+
379+
Parameters
380+
----------
381+
dim : str, optional
382+
383+
Returns
384+
-------
385+
mapped: UgridDataArray
386+
A new UgridDataArray with data mapped to the faces of the grid.
387+
388+
Examples
389+
--------
390+
Compute the mean elevation based on the nodes for each face:
391+
392+
>>> face_elevation = node_elevation.to_face().mean("contributors")
393+
"""
394+
return self._to_facet("face", dim)
395+
279396
def intersect_line(
280397
self, start: Sequence[float], end: Sequence[float]
281398
) -> xr.DataArray:

xugrid/ugrid/ugrid1d.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,13 @@ def get_coordinates(self, dim: str) -> FloatArray:
311311
f"Expected {self.node_dimension} or {self.edge_dimension}; got: {dim}"
312312
)
313313

314+
@property
315+
def facets(self) -> dict[str, str]:
316+
return {
317+
"node": self.node_dimension,
318+
"edge": self.edge_dimension,
319+
}
320+
314321
def get_connectivity_matrix(self, dim: str, xy_weights: bool):
315322
"""Return the connectivity matrix for the specified UGRID dimension."""
316323
if dim == self.node_dimension:

0 commit comments

Comments
 (0)