Skip to content

Commit 182affe

Browse files
Merge branch 'v4-dev' into adding_unit_tests
2 parents f80c131 + c6f91d6 commit 182affe

File tree

3 files changed

+212
-17
lines changed

3 files changed

+212
-17
lines changed

parcels/_datasets/unstructured/generic.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,109 @@ def _fesom2_square_delaunay_uniform_z_coordinate():
208208
return ux.UxDataset({"U": u, "V": v, "W": w, "p": p}, uxgrid=uxgrid)
209209

210210

211+
def _fesom2_square_delaunay_antimeridian():
212+
"""
213+
Delaunay grid that crosses the antimeridian with uniform z-coordinate, mimicking a FESOM2 dataset.
214+
This dataset consists of a square domain with closed boundaries, where the grid is generated using Delaunay triangulation.
215+
The bottom topography is flat and uniform, and the vertical grid spacing is constant with 10 layers spanning [0,1000.0]
216+
The lateral velocity field components are non-zero constant, and the vertical velocity component is zero.
217+
The pressure field is constant.
218+
All fields are placed on location consistent with FESOM2 variable placement conventions
219+
"""
220+
lon, lat = np.meshgrid(
221+
np.linspace(-210.0, -150.0, Nx, dtype=np.float32), np.linspace(0, 60.0, Nx, dtype=np.float32)
222+
)
223+
# wrap longitude from [-180,180]
224+
lon = np.where(lon < -180, lon + 360, lon)
225+
lon_flat = lon.ravel()
226+
lat_flat = lat.ravel()
227+
zf = np.linspace(0.0, 1000.0, 10, endpoint=True, dtype=np.float32) # Vertical element faces
228+
zc = 0.5 * (zf[:-1] + zf[1:]) # Vertical element centers
229+
nz = zf.size
230+
nz1 = zc.size
231+
232+
# mask any point on one of the boundaries
233+
mask = (
234+
np.isclose(lon_flat, 0.0) | np.isclose(lon_flat, 60.0) | np.isclose(lat_flat, 0.0) | np.isclose(lat_flat, 60.0)
235+
)
236+
237+
boundary_points = np.flatnonzero(mask)
238+
239+
uxgrid = ux.Grid.from_points(
240+
(lon_flat, lat_flat),
241+
method="regional_delaunay",
242+
boundary_points=boundary_points,
243+
)
244+
uxgrid.attrs["Conventions"] = "UGRID-1.0"
245+
246+
# Define arrays U (zonal), V (meridional) and P (sea surface height)
247+
U = np.ones(
248+
(T, nz1, uxgrid.n_face), dtype=np.float64
249+
) # Lateral velocity is on the element centers and face centers
250+
V = np.ones(
251+
(T, nz1, uxgrid.n_face), dtype=np.float64
252+
) # Lateral velocity is on the element centers and face centers
253+
W = np.zeros(
254+
(T, nz, uxgrid.n_node), dtype=np.float64
255+
) # Vertical velocity is on the element faces and face vertices
256+
P = np.ones((T, nz1, uxgrid.n_node), dtype=np.float64) # Pressure is on the element centers and face vertices
257+
258+
u = ux.UxDataArray(
259+
data=U,
260+
name="U",
261+
uxgrid=uxgrid,
262+
dims=["time", "nz1", "n_face"],
263+
coords=dict(
264+
time=(["time"], TIME),
265+
nz1=(["nz1"], zc),
266+
),
267+
attrs=dict(
268+
description="zonal velocity", units="m/s", location="face", mesh="delaunay", Conventions="UGRID-1.0"
269+
),
270+
)
271+
v = ux.UxDataArray(
272+
data=V,
273+
name="V",
274+
uxgrid=uxgrid,
275+
dims=["time", "nz1", "n_face"],
276+
coords=dict(
277+
time=(["time"], TIME),
278+
nz1=(["nz1"], zc),
279+
),
280+
attrs=dict(
281+
description="meridional velocity", units="m/s", location="face", mesh="delaunay", Conventions="UGRID-1.0"
282+
),
283+
)
284+
w = ux.UxDataArray(
285+
data=W,
286+
name="w",
287+
uxgrid=uxgrid,
288+
dims=["time", "nz", "n_node"],
289+
coords=dict(
290+
time=(["time"], TIME),
291+
nz=(["nz"], zf),
292+
),
293+
attrs=dict(
294+
description="vertical velocity", units="m/s", location="node", mesh="delaunay", Conventions="UGRID-1.0"
295+
),
296+
)
297+
p = ux.UxDataArray(
298+
data=P,
299+
name="p",
300+
uxgrid=uxgrid,
301+
dims=["time", "nz1", "n_node"],
302+
coords=dict(
303+
time=(["time"], TIME),
304+
nz1=(["nz1"], zc),
305+
),
306+
attrs=dict(description="pressure", units="N/m^2", location="node", mesh="delaunay", Conventions="UGRID-1.0"),
307+
)
308+
309+
return ux.UxDataset({"U": u, "V": v, "W": w, "p": p}, uxgrid=uxgrid)
310+
311+
211312
datasets = {
212313
"stommel_gyre_delaunay": _stommel_gyre_delaunay(),
213314
"fesom2_square_delaunay_uniform_z_coordinate": _fesom2_square_delaunay_uniform_z_coordinate(),
315+
"fesom2_square_delaunay_antimeridian": _fesom2_square_delaunay_antimeridian(),
214316
}

parcels/uxgrid.py

Lines changed: 93 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import numpy as np
66
import uxarray as ux
7+
from uxarray.grid.coordinates import _lonlat_rad_to_xyz
78
from uxarray.grid.neighbors import _barycentric_coordinates
89

910
from parcels.field import FieldOutOfBoundError # Adjust import as necessary
@@ -67,49 +68,125 @@ def get_axis_dim(self, axis: _UXGRID_AXES) -> int:
6768
elif axis == "FACE":
6869
return self.uxgrid.n_face
6970

70-
def search(self, z, y, x, ei=None):
71-
tol = 1e-10
72-
71+
def search(self, z, y, x, ei=None, tol=1e-6):
7372
def try_face(fid):
74-
bcoords, err = self.uxgrid._get_barycentric_coordinates(y, x, fid)
73+
bcoords, err = self._get_barycentric_coordinates_latlon(y, x, fid)
7574
if (bcoords >= 0).all() and (bcoords <= 1).all() and err < tol:
76-
return bcoords, fid
77-
return None, None
75+
return bcoords
76+
else:
77+
bcoords = self._get_barycentric_coordinates_cartesian(y, x, fid)
78+
if (bcoords >= 0).all() and (bcoords <= 1).all():
79+
return bcoords
80+
81+
return None
7882

7983
zi, zeta = _search_1d_array(self.z.values, z)
8084

8185
if ei is not None:
8286
_, fi = self.unravel_index(ei)
83-
bcoords, fi_new = try_face(fi)
87+
bcoords = try_face(fi)
8488
if bcoords is not None:
85-
return bcoords, self.ravel_index(zi, fi_new)
89+
return bcoords, self.ravel_index(zi, fi)
8690
# Try neighbors of current face
8791
for neighbor in self.uxgrid.face_face_connectivity[fi, :]:
8892
if neighbor == -1:
8993
continue
90-
bcoords, fi_new = try_face(neighbor)
94+
bcoords = try_face(neighbor)
9195
if bcoords is not None:
92-
return bcoords, self.ravel_index(zi, fi_new)
96+
return bcoords, self.ravel_index(zi, neighbor)
9397

94-
# Global fallback using spatial hash
95-
fi, bcoords = self.uxgrid.get_spatial_hash().query([[x, y]])
98+
# Global fallback as last ditch effort
99+
face_ids = self.uxgrid.get_faces_containing_point([x, y], return_counts=False)[0]
100+
fi = face_ids[0] if len(face_ids) > 0 else -1
96101
if fi == -1:
97102
raise FieldOutOfBoundError(z, y, x)
98-
return {"Z": (zi, zeta), "FACE": (fi[0], bcoords[0])}
103+
bcoords = try_face(fi)
104+
if bcoords is None:
105+
raise FieldOutOfBoundError(z, y, x)
99106

100-
def _get_barycentric_coordinates(self, y, x, fi):
107+
return {"Z": (zi, zeta), "FACE": (fi, bcoords)}
108+
109+
def _get_barycentric_coordinates_latlon(self, y, x, fi):
101110
"""Checks if a point is inside a given face id on a UxGrid."""
102111
# Check if particle is in the same face, otherwise search again.
112+
103113
n_nodes = self.uxgrid.n_nodes_per_face[fi].to_numpy()
104114
node_ids = self.uxgrid.face_node_connectivity[fi, 0:n_nodes]
105115
nodes = np.column_stack(
106116
(
107-
np.deg2rad(self.uxgrid.grid.node_lon[node_ids].to_numpy()),
108-
np.deg2rad(self.uxgrid.grid.node_lat[node_ids].to_numpy()),
117+
np.deg2rad(self.uxgrid.node_lon[node_ids].to_numpy()),
118+
np.deg2rad(self.uxgrid.node_lat[node_ids].to_numpy()),
109119
)
110120
)
111121

112122
coord = np.deg2rad([x, y])
113123
bcoord = np.asarray(_barycentric_coordinates(nodes, coord))
114124
err = abs(np.dot(bcoord, nodes[:, 0]) - coord[0]) + abs(np.dot(bcoord, nodes[:, 1]) - coord[1])
115125
return bcoord, err
126+
127+
def _get_barycentric_coordinates_cartesian(self, y, x, fi):
128+
n_nodes = self.uxgrid.n_nodes_per_face[fi].to_numpy()
129+
node_ids = self.uxgrid.face_node_connectivity[fi, 0:n_nodes]
130+
131+
coord = np.deg2rad([x, y])
132+
x, y, z = _lonlat_rad_to_xyz(coord[0], coord[1])
133+
cart_coord = np.array([x, y, z]).T
134+
# Second attempt to find barycentric coordinates using cartesian coordinates
135+
nodes = np.stack(
136+
(
137+
self.uxgrid.node_x[node_ids].values,
138+
self.uxgrid.node_y[node_ids].values,
139+
self.uxgrid.node_z[node_ids].values,
140+
),
141+
axis=-1,
142+
)
143+
144+
bcoord = np.asarray(_barycentric_coordinates_cartesian(nodes, cart_coord))
145+
146+
return bcoord
147+
148+
149+
def _barycentric_coordinates_cartesian(nodes, point, min_area=1e-8):
150+
"""
151+
Compute the barycentric coordinates of a point P inside a convex polygon using area-based weights.
152+
So that this method generalizes to n-sided polygons, we use the Waschpress points as the generalized
153+
barycentric coordinates, which is only valid for convex polygons.
154+
155+
Parameters
156+
----------
157+
nodes : numpy.ndarray
158+
Cartesian coordinates (x,y,z) of each corner node of a face
159+
point : numpy.ndarray
160+
Cartesian coordinates (x,y,z) of the point
161+
162+
Returns
163+
-------
164+
numpy.ndarray
165+
Barycentric coordinates corresponding to each vertex.
166+
167+
"""
168+
n = len(nodes)
169+
sum_wi = 0
170+
w = []
171+
172+
for i in range(0, n):
173+
vim1 = nodes[i - 1]
174+
vi = nodes[i]
175+
vi1 = nodes[(i + 1) % n]
176+
a0 = _triangle_area_cartesian(vim1, vi, vi1)
177+
a1 = max(_triangle_area_cartesian(point, vim1, vi), min_area)
178+
a2 = max(_triangle_area_cartesian(point, vi, vi1), min_area)
179+
sum_wi += a0 / (a1 * a2)
180+
w.append(a0 / (a1 * a2))
181+
182+
barycentric_coords = [w_i / sum_wi for w_i in w]
183+
184+
return barycentric_coords
185+
186+
187+
def _triangle_area_cartesian(A, B, C):
188+
"""Compute the area of a triangle given by three points."""
189+
d1 = B - A
190+
d2 = C - A
191+
d3 = np.cross(d1, d2)
192+
return 0.5 * np.linalg.norm(d3)

tests/v4/test_uxarray_fieldset.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,10 +115,26 @@ def test_fesom2_square_delaunay_uniform_z_coordinate_eval():
115115
V=Field(name="V", data=ds.V, grid=UxGrid(ds.uxgrid, z=ds.coords["nz"]), interp_method=UXPiecewiseConstantFace),
116116
W=Field(name="W", data=ds.W, grid=UxGrid(ds.uxgrid, z=ds.coords["nz"]), interp_method=UXPiecewiseLinearNode),
117117
)
118-
P = Field(name="p", data=ds.p, grid=UxGrid(ds.uxgrid, z=ds.coords["nz"]), interp_method=UXPiecewiseConstantFace)
118+
P = Field(name="p", data=ds.p, grid=UxGrid(ds.uxgrid, z=ds.coords["nz"]), interp_method=UXPiecewiseLinearNode)
119119
fieldset = FieldSet([UVW, P, UVW.U, UVW.V, UVW.W])
120120

121121
assert fieldset.U.eval(time=ds.time[0].values, z=1.0, y=30.0, x=30.0, applyConversion=False) == 1.0
122122
assert fieldset.V.eval(time=ds.time[0].values, z=1.0, y=30.0, x=30.0, applyConversion=False) == 1.0
123123
assert fieldset.W.eval(time=ds.time[0].values, z=1.0, y=30.0, x=30.0, applyConversion=False) == 0.0
124124
assert fieldset.p.eval(time=ds.time[0].values, z=1.0, y=30.0, x=30.0, applyConversion=False) == 1.0
125+
126+
127+
def test_fesom2_square_delaunay_antimeridian_eval():
128+
"""
129+
Test the evaluation of a fieldset with a FESOM2 square Delaunay grid that crosses the antimeridian.
130+
Ensures that the fieldset can be created and evaluated correctly.
131+
Since the underlying data is constant, we can check that the values are as expected.
132+
"""
133+
ds = datasets_unstructured["fesom2_square_delaunay_antimeridian"]
134+
P = Field(name="p", data=ds.p, grid=UxGrid(ds.uxgrid, z=ds.coords["nz"]), interp_method=UXPiecewiseLinearNode)
135+
fieldset = FieldSet([P])
136+
137+
assert fieldset.p.eval(time=ds.time[0].values, z=1.0, y=30.0, x=-170.0, applyConversion=False) == 1.0
138+
assert fieldset.p.eval(time=ds.time[0].values, z=1.0, y=30.0, x=-180.0, applyConversion=False) == 1.0
139+
assert fieldset.p.eval(time=ds.time[0].values, z=1.0, y=30.0, x=180.0, applyConversion=False) == 1.0
140+
assert fieldset.p.eval(time=ds.time[0].values, z=1.0, y=30.0, x=170.0, applyConversion=False) == 1.0

0 commit comments

Comments
 (0)