55import numpy as np
66import uxarray as ux
77
8+ from parcels ._index_search import GRID_SEARCH_ERROR
89from parcels ._typing import assert_valid_mesh
9- from parcels .tools .statuscodes import FieldOutOfBoundError
1010from parcels .xgrid import _search_1d_array
1111
1212from .basegrid import BaseGrid
@@ -42,6 +42,7 @@ def __init__(self, grid: ux.grid.Grid, z: ux.UxDataArray, mesh="flat") -> UxGrid
4242 raise ValueError ("z must be a 1D array of vertical coordinates" )
4343 self .z = z
4444 self ._mesh = mesh
45+ self ._spatialhash = None
4546
4647 assert_valid_mesh (mesh )
4748
@@ -73,96 +74,100 @@ def get_axis_dim(self, axis: _UXGRID_AXES) -> int:
7374 return self .uxgrid .n_face
7475
7576 def search (self , z , y , x , ei = None , tol = 1e-6 ):
76- def try_face (fid ):
77- bcoords , err = self ._get_barycentric_coordinates_latlon (y , x , fid )
78- if (bcoords >= 0 ).all () and (bcoords <= 1 ).all () and err < tol :
79- return bcoords
80- else :
81- bcoords = self ._get_barycentric_coordinates_cartesian (y , x , fid )
82- if (bcoords >= 0 ).all () and (bcoords <= 1 ).all ():
83- return bcoords
84-
85- return None
77+ """
78+ Search for the grid cell (face) and vertical layer that contains the given points.
8679
80+ Parameters
81+ ----------
82+ z : float or np.ndarray
83+ The vertical coordinate(s) (depth) of the point(s).
84+ y : float or np.ndarray
85+ The latitude(s) of the point(s).
86+ x : float or np.ndarray
87+ The longitude(s) of the point(s).
88+ ei : np.ndarray, optional
89+ Precomputed horizontal indices (face indices) for the points.
90+
91+ TO BE IMPLEMENTED : If provided, we'll check
92+ if the points are within the faces specified by these indices. For cells where the particles
93+ are not found, a nearest neighbor search will be performed. As a last resort, the spatial hash will be used.
94+ tol : float, optional
95+ Tolerance for barycentric coordinate checks. Default is 1e-6.
96+ """
97+ indices = self .unravel_index (ei )
98+ fi = indices ["FACE" ]
99+ zi = indices ["Z" ]
87100 zi , zeta = _search_1d_array (self .z .values , z )
101+ if np .any (ei ):
102+ is_in_cell , coords = uxgrid_point_in_cell (self .uxgrid , y , x , fi , fi )
103+ y_check = y [is_in_cell == 0 ]
104+ x_check = x [is_in_cell == 0 ]
105+ zero_indices = np .where (is_in_cell == 0 )[0 ]
106+ else :
107+ # Otherwise, we need to check all points
108+ fi = np .full (len (y ), GRID_SEARCH_ERROR , dtype = np .int32 )
109+ y_check = y
110+ x_check = x
111+ coords = - 1.0 * np .ones ((len (y ), 2 ), dtype = np .float32 )
112+ zero_indices = np .arange (len (y ))
113+
114+ if len (zero_indices ) > 0 :
115+ face_ids_q , _ , coords_q = self .uxgrid .get_spatial_hash ().query (y_check , x_check )
116+ coords [zero_indices , :] = coords_q
117+ fi [zero_indices ] = face_ids_q
118+
119+ return {"Z" : (zi , zeta ), "FACE" : (fi , coords )}
120+
121+
122+ def uxgrid_point_in_cell (grid , y : np .ndarray , x : np .ndarray , yi : np .ndarray , xi : np .ndarray ):
123+ """Check if points are inside the grid cells defined by the given face indices.
88124
89- if ei is not None :
90- indices = self .unravel_index (ei )
91- fi = indices ["FACE" ][0 ]
92- bcoords = try_face (fi )
93- if bcoords is not None :
94- return {"Z" : (zi , zeta ), "FACE" : (np .asarray ([fi ]), bcoords )}
95- # Try neighbors of current face
96- for neighbor in self .uxgrid .face_face_connectivity [fi , :]:
97- if neighbor == - 1 :
98- continue
99- bcoords = try_face (neighbor )
100- if bcoords is not None :
101- return {"Z" : (zi , zeta ), "FACE" : (np .asarray ([neighbor ]), bcoords )}
102-
103- # Global fallback as last ditch effort
104- points = np .column_stack ((x , y ))
105- face_ids = self .uxgrid .get_faces_containing_point (points , return_counts = False )[0 ]
106- fi = face_ids [0 ] if len (face_ids ) > 0 else - 1
107- if fi == - 1 :
108- raise FieldOutOfBoundError (z , y , x )
109- bcoords = try_face (fi )
110- if bcoords is None :
111- raise FieldOutOfBoundError (z , y , x )
112- return {"Z" : (zi , zeta ), "FACE" : (fi , bcoords )}
113-
114- def _get_barycentric_coordinates_latlon (self , y , x , fi ):
115- """Checks if a point is inside a given face id on a UxGrid."""
116- # Check if particle is in the same face, otherwise search again.
117- n_nodes = self .uxgrid .n_nodes_per_face [fi ].to_numpy ()
118- node_ids = self .uxgrid .face_node_connectivity [fi , 0 :n_nodes ]
119- nodes = np .column_stack (
120- (
121- np .deg2rad (self .uxgrid .node_lon [node_ids ].to_numpy ()),
122- np .deg2rad (self .uxgrid .node_lat [node_ids ].to_numpy ()),
123- )
124- )
125+ Parameters
126+ ----------
127+ grid : ux.grid.Grid
128+ The uxarray grid object containing the unstructured grid data.
129+ y : np.ndarray
130+ Array of latitudes of the points to check.
131+ x : np.ndarray
132+ Array of longitudes of the points to check.
133+ yi : np.ndarray
134+ Array of face indices corresponding to the points.
135+ xi : np.ndarray
136+ Not used, but included for compatibility with other search functions.
125137
126- coord = np .deg2rad (np .column_stack ((x , y )))
127- bcoord = np .asarray (_barycentric_coordinates (nodes , coord ))
128- proj_coord = np .matmul (np .transpose (nodes ), bcoord )
129- err = np .linalg .norm (proj_coord - coord )
130- return bcoord , err
131-
132- def _get_barycentric_coordinates_cartesian (self , y , x , fi ):
133- n_nodes = self .uxgrid .n_nodes_per_face [fi ].to_numpy ()
134- node_ids = self .uxgrid .face_node_connectivity [fi , 0 :n_nodes ]
135-
136- coord = np .deg2rad ([x , y ])
137- x , y , z = _lonlat_rad_to_xyz (coord [0 ], coord [1 ])
138- cart_coord = np .array ([x , y , z ]).T
139- # Second attempt to find barycentric coordinates using cartesian coordinates
140- nodes = np .stack (
141- (
142- self .uxgrid .node_x [node_ids ].values ,
143- self .uxgrid .node_y [node_ids ].values ,
144- self .uxgrid .node_z [node_ids ].values ,
145- ),
146- axis = - 1 ,
138+ Returns
139+ -------
140+ is_in_cell : np.ndarray
141+ An array indicating whether each point is inside (1) or outside (0) the corresponding cell.
142+ coords : np.ndarray
143+ Barycentric coordinates of the points within their respective cells.
144+ """
145+ if grid .mesh == "spherical" :
146+ lon_rad = np .deg2rad (grid .lon .values )
147+ lat_rad = np .deg2rad (grid .lat .values )
148+ x_cart , y_cart , z_cart = _lonlat_rad_to_xyz (lon_rad , lat_rad )
149+ points = np .column_stack ((x_cart .flatten (), y_cart .flatten (), z_cart .flatten ()))
150+
151+ # Get the vertex indices for each face
152+ nodeids = grid .face_node_connectivity [yi , :].values
153+ face_vertices = np .column_stack (
154+ (grid .node_x [nodeids ].values , grid .node_y [nodeids ].values , grid .node_z [nodeids ].values )
147155 )
156+ else :
157+ nodeids = grid .face_node_connectivity [yi , :].values
158+ face_vertices = np .column_stack (
159+ (grid .node_lon [nodeids ].values .flatten (), grid .node_lat [nodeids ].values .flatten ())
160+ )
161+ points = np .column_stack ((x , y ))
148162
149- bcoord = np .asarray (_barycentric_coordinates (nodes , cart_coord ))
150-
151- return bcoord
163+ M = len (points )
152164
165+ is_in_cell = np .zeros (M , dtype = np .int32 )
153166
154- def _lonlat_rad_to_xyz (
155- lon ,
156- lat ,
157- ):
158- """Converts Spherical latitude and longitude coordinates into Cartesian x,
159- y, z coordinates.
160- """
161- x = np .cos (lon ) * np .cos (lat )
162- y = np .sin (lon ) * np .cos (lat )
163- z = np .sin (lat )
167+ coords = _barycentric_coordinates (face_vertices , points )
168+ is_in_cell = np .where (np .all ((coords >= - 1e-6 ) & (coords <= 1 + 1e-6 ), axis = 1 ), 1 , 0 )
164169
165- return x , y , z
170+ return is_in_cell , coords
166171
167172
168173def _triangle_area (A , B , C ):
@@ -173,7 +178,7 @@ def _triangle_area(A, B, C):
173178 return 0.5 * np .linalg .norm (d3 )
174179
175180
176- def _barycentric_coordinates (nodes , point , min_area = 1e-8 ):
181+ def _barycentric_coordinates (nodes , points , min_area = 1e-8 ):
177182 """
178183 Compute the barycentric coordinates of a point P inside a convex polygon using area-based weights.
179184 So that this method generalizes to n-sided polygons, we use the Waschpress points as the generalized
@@ -182,29 +187,58 @@ def _barycentric_coordinates(nodes, point, min_area=1e-8):
182187 Parameters
183188 ----------
184189 nodes : numpy.ndarray
185- Spherical coordinates (lat,lon) of each corner node of a face
186- point : numpy.ndarray
187- Spherical coordinates (lat,lon) of the point
190+ Polygon verties per query of shape (M, 3, 2/3) where M is the number of query points. The second dimension corresponds to the number
191+ of vertices
192+ The last dimension can be either 2 or 3, where 3 corresponds to the (z, y, x) coordinates of each vertex and 2 corresponds to the
193+ (lat, lon) coordinates of each vertex.
194+
195+ points : numpy.ndarray
196+ Spherical coordinates of the point (M,2/3) where M is the number of query points.
188197
189198 Returns
190199 -------
191200 numpy.ndarray
192201 Barycentric coordinates corresponding to each vertex.
193202
194203 """
195- n = len (nodes )
196- sum_wi = 0
197- w = []
198-
199- for i in range (0 , n ):
200- vim1 = nodes [i - 1 ]
201- vi = nodes [i ]
202- vi1 = nodes [(i + 1 ) % n ]
203- a0 = _triangle_area (vim1 , vi , vi1 )
204- a1 = max (_triangle_area (point , vim1 , vi ), min_area )
205- a2 = max (_triangle_area (point , vi , vi1 ), min_area )
206- sum_wi += a0 / (a1 * a2 )
207- w .append (a0 / (a1 * a2 ))
208- barycentric_coords = [w_i / sum_wi for w_i in w ]
209-
210- return barycentric_coords
204+ M , K = nodes .shape [:2 ]
205+
206+ # roll(-1) to get vi+1, roll(+1) to get vi-1
207+ vi = nodes # (M,K,2)
208+ vi1 = np .roll (nodes , shift = - 1 , axis = 1 ) # (M,K,2)
209+ vim1 = np .roll (nodes , shift = + 1 , axis = 1 ) # (M,K,2)
210+
211+ # a0 = area(v_{i-1}, v_i, v_{i+1})
212+ a0 = _triangle_area (vim1 , vi , vi1 ) # (M,K)
213+
214+ # a1 = area(P, v_{i-1}, v_i); a2 = area(P, v_i, v_{i+1})
215+ P = points [:, None , :] # (M,1,2) -> (M,K,2)
216+ a1 = _triangle_area (P , vim1 , vi )
217+ a2 = _triangle_area (P , vi , vi1 )
218+
219+ # clamp tiny denominators for stability
220+ a1c = np .maximum (a1 , min_area )
221+ a2c = np .maximum (a2 , min_area )
222+
223+ wi = a0 / (a1c * a2c ) # (M,K)
224+
225+ sum_wi = wi .sum (axis = 1 , keepdims = True ) # (M,1)
226+ # Avoid 0/0: if sum_wi==0 (degenerate), keep zeros
227+ with np .errstate (invalid = "ignore" , divide = "ignore" ):
228+ bcoords = wi / sum_wi
229+
230+ return bcoords
231+
232+
233+ def _lonlat_rad_to_xyz (
234+ lon ,
235+ lat ,
236+ ):
237+ """Converts Spherical latitude and longitude coordinates into Cartesian x,
238+ y, z coordinates.
239+ """
240+ x = np .cos (lon ) * np .cos (lat )
241+ y = np .sin (lon ) * np .cos (lat )
242+ z = np .sin (lat )
243+
244+ return x , y , z
0 commit comments