Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 130 additions & 35 deletions src/parcels/_core/index_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,56 +92,151 @@ def _search_time_index(field: Field, time: np.ndarray):


def curvilinear_point_in_cell(grid, y: np.ndarray, x: np.ndarray, yi: np.ndarray, xi: np.ndarray):
xsi = eta = -1.0 * np.ones(len(x), dtype=float)
invA = np.array(
[
[1, 0, 0, 0],
[-1, 1, 0, 0],
[-1, 0, 0, 1],
[1, -1, 1, -1],
]
)
"""Locate points within 2D curvilinear grid cells.

px = np.array([grid.lon[yi, xi], grid.lon[yi, xi + 1], grid.lon[yi + 1, xi + 1], grid.lon[yi + 1, xi]])
For spherical grids, the bilinear inverse runs in a tangent plane on a sphere
through each cell's centroid — robust across the antimeridian and the
polar cap. For flat grids it runs directly in (lon, lat). In both
cases the returned (xsi, eta) are bilinear weights in the space the inverse
was solved in; downstream interpolators must agree on that same space.
"""
clon = np.asarray(
[grid.lon[yi, xi], grid.lon[yi, xi + 1], grid.lon[yi + 1, xi + 1], grid.lon[yi + 1, xi]],
dtype=float,
)
clat = np.asarray(
[grid.lat[yi, xi], grid.lat[yi, xi + 1], grid.lat[yi + 1, xi + 1], grid.lat[yi + 1, xi]],
dtype=float,
)

if grid._mesh == "spherical":
# Map grid and particle longitudes to [-180,180)
px = ((px + 180.0) % 360.0) - 180.0
x = ((x + 180.0) % 360.0) - 180.0
xsi, eta = _bilinear_inverse_tangent_plane(clon, clat, x, y)
is_in_cell = np.where((xsi >= 0) & (xsi <= 1) & (eta >= 0) & (eta <= 1), 1, 0)
else:
xsi, eta = _bilinear_inverse_latlon(clon, clat, x, y, apply_antimeridian_shift=False)
is_in_cell = np.where((xsi >= 0) & (xsi <= 1) & (eta >= 0) & (eta <= 1), 1, 0)

# Create a mask for antimeridian cells
lon_span = px.max(axis=0) - px.min(axis=0)
antimeridian_cell = lon_span > 180.0
return is_in_cell, np.column_stack((xsi, eta))

if np.any(antimeridian_cell):
# For any antimeridian cell ...
# If particle longitude is closer to 180.0, then negative cell longitudes need to be bumped by +360
mask = (px < 0.0) & antimeridian_cell[np.newaxis, :] & (x[np.newaxis, :] > 0.0)
px[mask] += 360.0
# If particle longitude is closer to -180.0, then positive cell longitudes need to be bumped by -360
mask = (px > 0.0) & antimeridian_cell[np.newaxis, :] & (x[np.newaxis, :] < 0.0)
px[mask] -= 360.0

py = np.array([grid.lat[yi, xi], grid.lat[yi, xi + 1], grid.lat[yi + 1, xi + 1], grid.lat[yi + 1, xi]])
_invA = np.array(
[
[1, 0, 0, 0],
[-1, 1, 0, 0],
[-1, 0, 0, 1],
[1, -1, 1, -1],
]
)


a, b = np.dot(invA, px), np.dot(invA, py)
def _bilinear_inverse(px, py, xq, yq):
"""Solve for (xsi, eta) s.t. bilinear blend of (px, py) corners equals (xq, yq)."""
eta_init = -1.0 * np.ones(len(xq), dtype=float)
a, b = np.dot(_invA, px), np.dot(_invA, py)
aa = a[3] * b[2] - a[2] * b[3]
bb = a[3] * b[0] - a[0] * b[3] + a[1] * b[2] - a[2] * b[1] + x * b[3] - y * a[3]
cc = a[1] * b[0] - a[0] * b[1] + x * b[1] - y * a[1]
bb = a[3] * b[0] - a[0] * b[3] + a[1] * b[2] - a[2] * b[1] + xq * b[3] - yq * a[3]
cc = a[1] * b[0] - a[0] * b[1] + xq * b[1] - yq * a[1]
det2 = bb * bb - 4 * aa * cc

with np.errstate(divide="ignore", invalid="ignore"):
det = np.where(det2 > 0, np.sqrt(det2), eta)
eta = np.where(abs(aa) < 1e-12, -cc / bb, np.where(det2 > 0, (-bb + det) / (2 * aa), eta))

det = np.where(det2 > 0, np.sqrt(det2), eta_init)
eta = np.where(abs(aa) < 1e-12, -cc / bb, np.where(det2 > 0, (-bb + det) / (2 * aa), eta_init))
xsi = np.where(
abs(a[1] + a[3] * eta) < 1e-12,
((y - py[0]) / (py[1] - py[0]) + (y - py[3]) / (py[2] - py[3])) * 0.5,
(x - a[0] - a[2] * eta) / (a[1] + a[3] * eta),
((yq - py[0]) / (py[1] - py[0]) + (yq - py[3]) / (py[2] - py[3])) * 0.5,
(xq - a[0] - a[2] * eta) / (a[1] + a[3] * eta),
)
is_in_cell = np.where((xsi >= 0) & (xsi <= 1) & (eta >= 0) & (eta <= 1), 1, 0)
return xsi, eta

return is_in_cell, np.column_stack((xsi, eta))

def _bilinear_inverse_latlon(clon, clat, x, y, apply_antimeridian_shift=True):
"""Bilinear inverse in raw lat/lon, with antimeridian corner shifting."""
px = clon.copy()
xq = np.asarray(x, dtype=float).copy()
if apply_antimeridian_shift:
px = ((px + 180.0) % 360.0) - 180.0
xq = ((xq + 180.0) % 360.0) - 180.0
lon_span = px.max(axis=0) - px.min(axis=0)
antimeridian_cell = lon_span > 180.0
if np.any(antimeridian_cell):
mask = (px < 0.0) & antimeridian_cell[np.newaxis, :] & (xq[np.newaxis, :] > 0.0)
px[mask] += 360.0
mask = (px > 0.0) & antimeridian_cell[np.newaxis, :] & (xq[np.newaxis, :] < 0.0)
px[mask] -= 360.0
py = clat
yq = np.asarray(y, dtype=float)
return _bilinear_inverse(px, py, xq, yq)


def _bilinear_inverse_tangent_plane(clon, clat, x, y):
"""Bilinear inverse after tangent-plane projection through the cell centroid.

Avoids lat/lon singularities at poles and on the antimeridian.
"""
px, py, xq, yq = _spherical_project_cell_and_query(clon, clat, x, y)
return _bilinear_inverse(px, py, xq, yq)


def _spherical_project_cell_and_query(clon, clat, x, y):
"""Project 4 cell corners and the query onto a plane defined by the cell.

The plane's basis (e_u, e_v) is built from the cell's edge-midpoint
difference vectors, then orthonormalized using Gramm-Schmidt

Inputs
------
clon, clat : (4, N) arrays of corner lon/lat in degrees
x, y : (N,) query lon/lat in degrees

Returns
-------
px, py : (4, N) projected corner coordinates (u, v)
xq, yq : (N,) projected query coordinates in the same (u, v)

Notes
-----
Orthogonal projection is 2-to-1 on the full sphere: a query and its
antipode map to the same (u, v). In practice this is harmless because the
downstream bilinear gate ``(xsi, eta) in [0, 1]`` only admits queries whose
projected magnitude is ≲ the cell's angular size.
"""
cX, cY, cZ = _latlon_rad_to_xyz(np.deg2rad(clat), np.deg2rad(clon)) # (4, N) each
qX, qY, qZ = _latlon_rad_to_xyz(np.deg2rad(np.asarray(y, dtype=float)), np.deg2rad(np.asarray(x, dtype=float)))

# Cell-intrinsic tangent basis from edge-midpoint difference vectors.
# Corner order is CCW: c0=(yi,xi), c1=(yi,xi+1), c2=(yi+1,xi+1), c3=(yi+1,xi).
# u-axis (along xsi): right-edge midpoint - left-edge midpoint = (c1+c2) - (c0+c3)
# v-axis (along eta): top-edge midpoint - bottom-edge midpoint = (c2+c3) - (c0+c1)
ux = (cX[1] + cX[2]) - (cX[0] + cX[3])
uy = (cY[1] + cY[2]) - (cY[0] + cY[3])
uz = (cZ[1] + cZ[2]) - (cZ[0] + cZ[3])
u_norm = np.sqrt(ux * ux + uy * uy + uz * uz)
u_norm = np.where(u_norm == 0.0, 1.0, u_norm)
e_ux, e_uy, e_uz = ux / u_norm, uy / u_norm, uz / u_norm

vx = (cX[2] + cX[3]) - (cX[0] + cX[1])
vy = (cY[2] + cY[3]) - (cY[0] + cY[1])
vz = (cZ[2] + cZ[3]) - (cZ[0] + cZ[1])
# Gram-Schmidt: make e_v perpendicular to e_u so the bilinear inverse
# in (u, v) is well-conditioned even on skewed cells.
v_dot_eu = vx * e_ux + vy * e_uy + vz * e_uz
vx = vx - v_dot_eu * e_ux
vy = vy - v_dot_eu * e_uy
vz = vz - v_dot_eu * e_uz
v_norm = np.sqrt(vx * vx + vy * vy + vz * vz)
v_norm = np.where(v_norm == 0.0, 1.0, v_norm)
e_vx, e_vy, e_vz = vx / v_norm, vy / v_norm, vz / v_norm

# Orthogonal projection onto span(e_u, e_v): two dot products per point.
# Same pattern as uxgrid_point_in_cell (drop the component normal to the plane).
def _project(vx, vy, vz):
u = vx * e_ux + vy * e_uy + vz * e_uz
v = vx * e_vx + vy * e_vy + vz * e_vz
return u, v

px_u, px_v = _project(cX, cY, cZ) # (4, N) each
xq_u, xq_v = _project(qX, qY, qZ) # (N,) each
return px_u, px_v, xq_u, xq_v


def _search_indices_curvilinear_2d(
Expand Down
8 changes: 0 additions & 8 deletions src/parcels/interpolators/_xinterpolators.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,6 @@ def CGrid_Velocity(
yi, eta = grid_positions["Y"]["index"], grid_positions["Y"]["bcoord"]
zi, zeta = grid_positions["Z"]["index"], grid_positions["Z"]["bcoord"]
ti, tau = grid_positions["T"]["index"], grid_positions["T"]["bcoord"]
lon = particle_positions["lon"]

U = vectorfield.U.data
V = vectorfield.V.data
Expand Down Expand Up @@ -305,13 +304,6 @@ def _compute_corner_data(data, selection_dict) -> np.ndarray:
u /= conversion
v /= conversion

# check whether the grid conversion has been applied correctly
xx = (1 - xsi) * (1 - eta) * px[0] + xsi * (1 - eta) * px[1] + xsi * eta * px[2] + (1 - xsi) * eta * px[3]
dlon = xx - lon
if grid._mesh == "spherical":
dlon = ((dlon + 180.0) % 360.0) - 180.0
u = np.where(np.abs(dlon / lon) > 1e-4, np.nan, u)
Comment thread
erikvansebille marked this conversation as resolved.

if vectorfield.W:
W = vectorfield.W.data

Expand Down
27 changes: 23 additions & 4 deletions tests/test_index_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import parcels.tutorial
from parcels import Field, XGrid
from parcels._core.index_search import _search_indices_curvilinear_2d
from parcels._core.index_search import _latlon_rad_to_xyz, _search_indices_curvilinear_2d
from parcels._datasets.structured.generic import datasets
from parcels.interpolators import XLinear

Expand Down Expand Up @@ -73,6 +73,25 @@ def test_indexing_nemo_curvilinear():
for i in range(lons.shape[0]):
np.testing.assert_allclose(px[1, i], px[:, i], atol=5)

# Reconstruct lons values from cornerpoints
xx = (1 - xsi) * (1 - eta) * px[0] + xsi * (1 - eta) * px[1] + xsi * eta * px[2] + (1 - xsi) * eta * px[3]
np.testing.assert_allclose(xx, lons, atol=1e-6)
# Each query should have been located inside some cell: xsi, eta in [0, 1]
assert np.all((xsi >= 0) & (xsi <= 1)), f"xsi out of [0,1]: {xsi}"
assert np.all((eta >= 0) & (eta <= 1)), f"eta out of [0,1]: {eta}"

# Reconstruct query lat/lon by bilinear-blending the 4 corner 3D unit-sphere
# vectors with (xsi, eta) and renormalizing onto the unit sphere. Tolerance
# reflects the residual spherical curvature for a 1/4° NEMO cell.
clat = np.array([grid.lat[yi, xi], grid.lat[yi, xi + 1], grid.lat[yi + 1, xi + 1], grid.lat[yi + 1, xi]])
clon = np.array([grid.lon[yi, xi], grid.lon[yi, xi + 1], grid.lon[yi + 1, xi + 1], grid.lon[yi + 1, xi]])
cX, cY, cZ = _latlon_rad_to_xyz(np.deg2rad(clat), np.deg2rad(clon))
w = np.array([(1 - xsi) * (1 - eta), xsi * (1 - eta), xsi * eta, (1 - xsi) * eta])
x = np.sum(w * cX, axis=0)
y = np.sum(w * cY, axis=0)
z = np.sum(w * cZ, axis=0)
n = np.sqrt(x * x + y * y + z * z)
x, y, z = x / n, y / n, z / n
lat_recon = np.rad2deg(np.arcsin(z))
lon_recon = np.rad2deg(np.arctan2(y, x))
lons_wrapped = ((lons + 180) % 360) - 180
dlon = ((lon_recon - lons_wrapped + 180) % 360) - 180
np.testing.assert_allclose(lat_recon, lats, atol=1e-5)
np.testing.assert_allclose(dlon, 0.0, atol=1e-5)
Loading