XGrid localization - #2082

Merged
VeckoTheGecko merged 6 commits into
v4-devfrom
grid-localization
Jul 16, 2025
Merged

XGrid localization#2082
VeckoTheGecko merged 6 commits into
v4-devfrom
grid-localization

Conversation

@VeckoTheGecko

@VeckoTheGeckoVeckoTheGecko commented Jul 11, 2025

Copy link
Copy Markdown
Contributor

Currently we have a search method which returns a particle position relative to the F points. However, since our data can be defined on a staggered grid, it's important to "localize" this particle position to the grid for the array of interest. This mainly applies for C grids and when working with MITgcm and NEMO where their F points and C points are defined differently relative to each other (see diagram in docs or in #2037) .

This PR introduces this grid localization. This is really just the first draft to get feedback (code is a bit more messy than I would like - and there are no tests). This good localization will help with writing interpolators.

  • Chose the correct base branch (v4-dev for v4 changes)
  • Fixes None
  • Added tests (not yet)
  • Added documentation

@VeckoTheGecko

VeckoTheGecko commented Jul 11, 2025

Copy link
Copy Markdown
ContributorAuthor

Below is a small testing script along with output. data_c with the -1 index seems a bit strange, but I guess it makes sense (if vertical positions are defined on the cell centers and the particle is at the surface then -1 with bcoord 0.5 is expected). Thoughts @erikvansebille ?

# %%importnumpyasnpimportxarrayasxrfrompprintimportpprintfromparcelsimportxgcmfromparcels._datasets.structured.genericimportX, Y, Zfromparcels.xgridimportXGridT=2Z=Y=X=3TIME=xr.date_range("2000", "2001", T)
ds_mitgcm=xr.Dataset(
{
"data_g": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"data_c": (["time", "ZC", "YC", "XC"], np.random.rand(T, Z, Y, X)),
"U (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"V (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"U (C grid)": (["time", "ZG", "YC", "XG"], np.random.rand(T, Z, Y, X)),
"V (C grid)": (["time", "ZG", "YG", "XC"], np.random.rand(T, Z, Y, X)),
},
coords={
"XG": (
["XG"],
np.arange(0, X),
{"axis": "X", "c_grid_axis_shift": -0.5},
),
"XC": (["XC"], np.arange(0, X) +0.5, {"axis": "X"}),
"YG": (
["YG"],
np.arange(0, Y),
{"axis": "Y", "c_grid_axis_shift": -0.5},
),
"YC": (
["YC"],
np.arange(0, Y) +0.5,
{"axis": "Y"},
),
"ZG": (
["ZG"],
np.arange(Z),
{"axis": "Z", "c_grid_axis_shift": -0.5},
),
"ZC": (
["ZC"],
np.arange(Z) +0.5,
{"axis": "Z"},
),
"lon": (["XG"], np.arange(0, X)),
"lat": (["YG"], np.arange(0, Y)),
"depth": (["ZG"], np.arange(Z)),
"time": (["time"], TIME, {"axis": "T"}),
},
)
ds_nemo=xr.Dataset(
{
"data_g": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"data_c": (["time", "ZC", "YC", "XC"], np.random.rand(T, Z, Y, X)),
"U (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"V (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"U (C grid)": (["time", "ZG", "YC", "XG"], np.random.rand(T, Z, Y, X)),
"V (C grid)": (["time", "ZG", "YG", "XC"], np.random.rand(T, Z, Y, X)),
},
coords={
"XG": (
["XG"],
np.arange(0, X),
{"axis": "X", "c_grid_axis_shift": 0.5},
),
"XC": (["XC"], np.arange(0, X) -0.5, {"axis": "X"}),
"YG": (
["YG"],
np.arange(0, Y),
{"axis": "Y", "c_grid_axis_shift": 0.5},
),
"YC": (
["YC"],
np.arange(0, Y) -0.5,
{"axis": "Y"},
),
"ZG": (
["ZG"],
np.arange(Z),
{"axis": "Z", "c_grid_axis_shift": -0.5},
),
"ZC": (
["ZC"],
np.arange(Z) +0.5,
{"axis": "Z"},
),
"lon": (["XG"], np.arange(0, X)),
"lat": (["YG"], np.arange(0, Y)),
"depth": (["ZG"], np.arange(Z)),
"time": (["time"], TIME, {"axis": "T"}),
},
)
grid_mitgcm=XGrid(xgcm.Grid(ds_mitgcm, periodic=False))
grid_nemo=XGrid(xgcm.Grid(ds_nemo, periodic=False))
print("XGCM repr of MITgcm grid:")
print(grid_mitgcm.xgcm_grid)
print("\nXGCM repr of NEMO grid:")
print(grid_nemo.xgcm_grid)
# %%defshow_point_on_grid(grid, z, y, x):
"""Pretty printing of some info"""position=grid.search(z, y, x)
print(f"Position wrt. fpoints (lon/lat grid):")
pprint(position)
fordaingrid.xgcm_grid._ds.data_vars.values():
local_position=grid.localize(position, da.dims)
print(f"On {da.name=} with {da.dims=}, local position:")
pprint(local_position)
print("----Working with MITgcm grid----")
show_point_on_grid(grid_mitgcm, 0, 0.8, 0.8)
print("\n----Working with NEMO grid----")
show_point_on_grid(grid_nemo, 0, 0.8, 0.8)

output:

XGCM repr of MITgcm grid:
<parcels.Grid>
Y Axis (not periodic, boundary=None):
* center YC --> left
* left YG --> center
X Axis (not periodic, boundary=None):
* center XC --> left
* left XG --> center
Z Axis (not periodic, boundary=None):
* center ZC --> left
* left ZG --> center
T Axis (not periodic, boundary=None):
* center time
XGCM repr of NEMO grid:
<parcels.Grid>
Y Axis (not periodic, boundary=None):
* center YC --> right
* right YG --> center
X Axis (not periodic, boundary=None):
* center XC --> right
* right XG --> center
Z Axis (not periodic, boundary=None):
* center ZC --> left
* left ZG --> center
T Axis (not periodic, boundary=None):
* center time
----Working with MITgcm grid----
Position wrt. fpoints (lon/lat grid):
{'X': (np.int64(0), np.float64(0.8)),
'Y': (np.int64(0), np.float64(0.8)),
'Z': (np.int64(0), np.float64(0.0))}
On da.name='data_g' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='data_c' with da.dims=('time', 'ZC', 'YC', 'XC'), local position:
{'XC': (np.int64(0), np.float64(0.30000000000000004)),
'YC': (np.int64(0), np.float64(0.30000000000000004)),
'ZC': (np.int64(-1), np.float64(0.5))}
On da.name='U (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='U (C grid)' with da.dims=('time', 'ZG', 'YC', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YC': (np.int64(0), np.float64(0.30000000000000004)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (C grid)' with da.dims=('time', 'ZG', 'YG', 'XC'), local position:
{'XC': (np.int64(0), np.float64(0.30000000000000004)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
----Working with NEMO grid----
Position wrt. fpoints (lon/lat grid):
{'X': (np.int64(0), np.float64(0.8)),
'Y': (np.int64(0), np.float64(0.8)),
'Z': (np.int64(0), np.float64(0.0))}
On da.name='data_g' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='data_c' with da.dims=('time', 'ZC', 'YC', 'XC'), local position:
{'XC': (np.int64(1), np.float64(0.30000000000000004)),
'YC': (np.int64(1), np.float64(0.30000000000000004)),
'ZC': (np.int64(-1), np.float64(0.5))}
On da.name='U (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='U (C grid)' with da.dims=('time', 'ZG', 'YC', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YC': (np.int64(1), np.float64(0.30000000000000004)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (C grid)' with da.dims=('time', 'ZG', 'YG', 'XC'), local position:
{'XC': (np.int64(1), np.float64(0.30000000000000004)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}

@erikvansebille

Copy link
Copy Markdown
Member

Note that some datasets have an attribute c_grid_axis_shift, see for example below. If this attribute exists (but that's unfortunately not guaranteed), we can use it in this function too?

https://github.com/OceanParcels/Parcels/blob/b7bdddc4efe8024b5a2eab0025ca50eb5f8a9369/parcels/_datasets/structured/circulation_models.py#L627-L636

@VeckoTheGecko

Copy link
Copy Markdown
ContributorAuthor

Note that some datasets have an attribute c_grid_axis_shift, see for example below. If this attribute exists (but that's unfortunately not guaranteed), we can use it in this function too?

Yes, this is already taken into account by xgcm during grid ingestion to determine the nature of the grid staggering. In fact, the only difference between ds_mitgcm and ds_nemo is the value of this attribute and the corresponding offset.

@VeckoTheGecko

VeckoTheGecko commented Jul 11, 2025

Copy link
Copy Markdown
ContributorAuthor

if we're comfortable with this approach, I'll go and clear up some stuff (e.g., variable naming since it might be a bit confusing)

@VeckoTheGecko
VeckoTheGeckoforce-pushed the grid-localization branch 2 times, most recently from f3949e4 to 0d0b0e5CompareJuly 14, 2025 11:34
@VeckoTheGecko
VeckoTheGecko marked this pull request as ready for review July 14, 2025 11:35

@erikvansebilleerikvansebille left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. And how would a user use this in an interpolation method? Or will that come in a next PR?

Comment threadparcels/xgrid.py
@VeckoTheGecko

VeckoTheGecko commented Jul 15, 2025

Copy link
Copy Markdown
ContributorAuthor

I realised that this localization step isn't actually needed.

image

If a point is defined at (in X) index, bcoord = 0,0.8 wrt. the f-points, that will be at 0, 0.3 wrt. the C points. However, since the dual grid already has dimension coordinates that are offset by 0.5 (i.e., 0.5, 1.5 ...) we can just use xarray's interpolation functionality with 0.8 on this grid in order to get this point no matter the grid.

Still we need to build tooling around getting the dimension names that correspond to the axes (i.e, for da.dims == ["XC", "YC"] the grid needs to tell us "X" -> "XC" and "Y" -> "YC". I'll put up another PR

@VeckoTheGecko

Copy link
Copy Markdown
ContributorAuthor

I realised that this localization step isn't actually needed.

I realised that this is wrong actually 😅 . Localisation will be needed when writing curvilinear interpolators such as in the following being investigated in #2081 :

defXTriCurviLinear(
field: Field,
ti: int,
position: dict[_XGRID_AXES, tuple[int, float|np.ndarray]],
tau: np.float32|np.float64,
t: np.float32|np.float64,
z: np.float32|np.float64,
y: np.float32|np.float64,
x: np.float32|np.float64,
):
"""Trilinear interpolation on a curvilinear grid."""xi, xsi=position["X"]
yi, eta=position["Y"]
zi, zeta=position["Z"]
data=field.dataaxis_dim=field.grid.get_axis_dim_mapping(field.data.dims)
return (
(
(1-xsi) * (1-eta) *data.isel({axis_dim["Y"]: yi, axis_dim["X"]: xi})
+xsi* (1-eta) *data.isel({axis_dim["Y"]: yi, axis_dim["X"]: xi+1})
+xsi*eta*data.isel({axis_dim["Y"]: yi+1, axis_dim["X"]: xi+1})
+ (1-xsi) *eta*data.isel({axis_dim["Y"]: yi+1, axis_dim["X"]: xi})
)
.interp(time=t, **{axis_dim["Z"]: zi+zeta})
.values
)

Here, xsi and eta need to be adjusted to match how the data is defined (e.g., if the data is defined on the cell centers).

Perhaps there are other ways to achieve this without a localization step - but I need to get more familiar with Xarray internals and coordinate aware interpolation for that. I'll mark this as unstable API subject to change in the docstring - this is an isolated change we can remove later.

@erikvansebille any additional thoughts?

@erikvansebille

Copy link
Copy Markdown
Member

Yep agree; let's keep it in for now

@VeckoTheGecko

VeckoTheGecko commented Jul 16, 2025

Copy link
Copy Markdown
ContributorAuthor

Also wrapped into this numpydoc!=1.9.0 so that our docs build again on v4-dev (didn't bother with v3 since I assume they'll issue a fix before we need to update v3 docs again) Actually, I'll quickly backport to v3 as well

@VeckoTheGecko
VeckoTheGecko merged commit b510f11 into v4-devJul 16, 2025
@VeckoTheGecko
VeckoTheGecko deleted the grid-localization branch July 16, 2025 09:58
@github-project-automationgithub-project-automationBot moved this from Backlog to Done in Parcels developmentJul 16, 2025
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants

@VeckoTheGecko@erikvansebille
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

XGrid localization - #2082

Merged
VeckoTheGecko merged 6 commits into
v4-devfrom
grid-localization
Jul 16, 2025
Merged

XGrid localization#2082
VeckoTheGecko merged 6 commits into
v4-devfrom
grid-localization

Conversation

@VeckoTheGecko

@VeckoTheGeckoVeckoTheGecko commented Jul 11, 2025

Copy link
Copy Markdown
Contributor

Currently we have a search method which returns a particle position relative to the F points. However, since our data can be defined on a staggered grid, it's important to "localize" this particle position to the grid for the array of interest. This mainly applies for C grids and when working with MITgcm and NEMO where their F points and C points are defined differently relative to each other (see diagram in docs or in #2037) .

This PR introduces this grid localization. This is really just the first draft to get feedback (code is a bit more messy than I would like - and there are no tests). This good localization will help with writing interpolators.

  • Chose the correct base branch (v4-dev for v4 changes)
  • Fixes None
  • Added tests (not yet)
  • Added documentation

@VeckoTheGecko

VeckoTheGecko commented Jul 11, 2025

Copy link
Copy Markdown
ContributorAuthor

Below is a small testing script along with output. data_c with the -1 index seems a bit strange, but I guess it makes sense (if vertical positions are defined on the cell centers and the particle is at the surface then -1 with bcoord 0.5 is expected). Thoughts @erikvansebille ?

# %%importnumpyasnpimportxarrayasxrfrompprintimportpprintfromparcelsimportxgcmfromparcels._datasets.structured.genericimportX, Y, Zfromparcels.xgridimportXGridT=2Z=Y=X=3TIME=xr.date_range("2000", "2001", T)
ds_mitgcm=xr.Dataset(
{
"data_g": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"data_c": (["time", "ZC", "YC", "XC"], np.random.rand(T, Z, Y, X)),
"U (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"V (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"U (C grid)": (["time", "ZG", "YC", "XG"], np.random.rand(T, Z, Y, X)),
"V (C grid)": (["time", "ZG", "YG", "XC"], np.random.rand(T, Z, Y, X)),
},
coords={
"XG": (
["XG"],
np.arange(0, X),
{"axis": "X", "c_grid_axis_shift": -0.5},
),
"XC": (["XC"], np.arange(0, X) +0.5, {"axis": "X"}),
"YG": (
["YG"],
np.arange(0, Y),
{"axis": "Y", "c_grid_axis_shift": -0.5},
),
"YC": (
["YC"],
np.arange(0, Y) +0.5,
{"axis": "Y"},
),
"ZG": (
["ZG"],
np.arange(Z),
{"axis": "Z", "c_grid_axis_shift": -0.5},
),
"ZC": (
["ZC"],
np.arange(Z) +0.5,
{"axis": "Z"},
),
"lon": (["XG"], np.arange(0, X)),
"lat": (["YG"], np.arange(0, Y)),
"depth": (["ZG"], np.arange(Z)),
"time": (["time"], TIME, {"axis": "T"}),
},
)
ds_nemo=xr.Dataset(
{
"data_g": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"data_c": (["time", "ZC", "YC", "XC"], np.random.rand(T, Z, Y, X)),
"U (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"V (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"U (C grid)": (["time", "ZG", "YC", "XG"], np.random.rand(T, Z, Y, X)),
"V (C grid)": (["time", "ZG", "YG", "XC"], np.random.rand(T, Z, Y, X)),
},
coords={
"XG": (
["XG"],
np.arange(0, X),
{"axis": "X", "c_grid_axis_shift": 0.5},
),
"XC": (["XC"], np.arange(0, X) -0.5, {"axis": "X"}),
"YG": (
["YG"],
np.arange(0, Y),
{"axis": "Y", "c_grid_axis_shift": 0.5},
),
"YC": (
["YC"],
np.arange(0, Y) -0.5,
{"axis": "Y"},
),
"ZG": (
["ZG"],
np.arange(Z),
{"axis": "Z", "c_grid_axis_shift": -0.5},
),
"ZC": (
["ZC"],
np.arange(Z) +0.5,
{"axis": "Z"},
),
"lon": (["XG"], np.arange(0, X)),
"lat": (["YG"], np.arange(0, Y)),
"depth": (["ZG"], np.arange(Z)),
"time": (["time"], TIME, {"axis": "T"}),
},
)
grid_mitgcm=XGrid(xgcm.Grid(ds_mitgcm, periodic=False))
grid_nemo=XGrid(xgcm.Grid(ds_nemo, periodic=False))
print("XGCM repr of MITgcm grid:")
print(grid_mitgcm.xgcm_grid)
print("\nXGCM repr of NEMO grid:")
print(grid_nemo.xgcm_grid)
# %%defshow_point_on_grid(grid, z, y, x):
"""Pretty printing of some info"""position=grid.search(z, y, x)
print(f"Position wrt. fpoints (lon/lat grid):")
pprint(position)
fordaingrid.xgcm_grid._ds.data_vars.values():
local_position=grid.localize(position, da.dims)
print(f"On {da.name=} with {da.dims=}, local position:")
pprint(local_position)
print("----Working with MITgcm grid----")
show_point_on_grid(grid_mitgcm, 0, 0.8, 0.8)
print("\n----Working with NEMO grid----")
show_point_on_grid(grid_nemo, 0, 0.8, 0.8)

output:

XGCM repr of MITgcm grid:
<parcels.Grid>
Y Axis (not periodic, boundary=None):
* center YC --> left
* left YG --> center
X Axis (not periodic, boundary=None):
* center XC --> left
* left XG --> center
Z Axis (not periodic, boundary=None):
* center ZC --> left
* left ZG --> center
T Axis (not periodic, boundary=None):
* center time
XGCM repr of NEMO grid:
<parcels.Grid>
Y Axis (not periodic, boundary=None):
* center YC --> right
* right YG --> center
X Axis (not periodic, boundary=None):
* center XC --> right
* right XG --> center
Z Axis (not periodic, boundary=None):
* center ZC --> left
* left ZG --> center
T Axis (not periodic, boundary=None):
* center time
----Working with MITgcm grid----
Position wrt. fpoints (lon/lat grid):
{'X': (np.int64(0), np.float64(0.8)),
'Y': (np.int64(0), np.float64(0.8)),
'Z': (np.int64(0), np.float64(0.0))}
On da.name='data_g' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='data_c' with da.dims=('time', 'ZC', 'YC', 'XC'), local position:
{'XC': (np.int64(0), np.float64(0.30000000000000004)),
'YC': (np.int64(0), np.float64(0.30000000000000004)),
'ZC': (np.int64(-1), np.float64(0.5))}
On da.name='U (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='U (C grid)' with da.dims=('time', 'ZG', 'YC', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YC': (np.int64(0), np.float64(0.30000000000000004)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (C grid)' with da.dims=('time', 'ZG', 'YG', 'XC'), local position:
{'XC': (np.int64(0), np.float64(0.30000000000000004)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
----Working with NEMO grid----
Position wrt. fpoints (lon/lat grid):
{'X': (np.int64(0), np.float64(0.8)),
'Y': (np.int64(0), np.float64(0.8)),
'Z': (np.int64(0), np.float64(0.0))}
On da.name='data_g' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='data_c' with da.dims=('time', 'ZC', 'YC', 'XC'), local position:
{'XC': (np.int64(1), np.float64(0.30000000000000004)),
'YC': (np.int64(1), np.float64(0.30000000000000004)),
'ZC': (np.int64(-1), np.float64(0.5))}
On da.name='U (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='U (C grid)' with da.dims=('time', 'ZG', 'YC', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YC': (np.int64(1), np.float64(0.30000000000000004)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (C grid)' with da.dims=('time', 'ZG', 'YG', 'XC'), local position:
{'XC': (np.int64(1), np.float64(0.30000000000000004)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}

@erikvansebille

Copy link
Copy Markdown
Member

Note that some datasets have an attribute c_grid_axis_shift, see for example below. If this attribute exists (but that's unfortunately not guaranteed), we can use it in this function too?

https://github.com/OceanParcels/Parcels/blob/b7bdddc4efe8024b5a2eab0025ca50eb5f8a9369/parcels/_datasets/structured/circulation_models.py#L627-L636

@VeckoTheGecko

Copy link
Copy Markdown
ContributorAuthor

Note that some datasets have an attribute c_grid_axis_shift, see for example below. If this attribute exists (but that's unfortunately not guaranteed), we can use it in this function too?

Yes, this is already taken into account by xgcm during grid ingestion to determine the nature of the grid staggering. In fact, the only difference between ds_mitgcm and ds_nemo is the value of this attribute and the corresponding offset.

@VeckoTheGecko

VeckoTheGecko commented Jul 11, 2025

Copy link
Copy Markdown
ContributorAuthor

if we're comfortable with this approach, I'll go and clear up some stuff (e.g., variable naming since it might be a bit confusing)

@VeckoTheGecko
VeckoTheGeckoforce-pushed the grid-localization branch 2 times, most recently from f3949e4 to 0d0b0e5CompareJuly 14, 2025 11:34
@VeckoTheGecko
VeckoTheGecko marked this pull request as ready for review July 14, 2025 11:35

@erikvansebilleerikvansebille left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. And how would a user use this in an interpolation method? Or will that come in a next PR?

Comment threadparcels/xgrid.py
@VeckoTheGecko

VeckoTheGecko commented Jul 15, 2025

Copy link
Copy Markdown
ContributorAuthor

I realised that this localization step isn't actually needed.

image

If a point is defined at (in X) index, bcoord = 0,0.8 wrt. the f-points, that will be at 0, 0.3 wrt. the C points. However, since the dual grid already has dimension coordinates that are offset by 0.5 (i.e., 0.5, 1.5 ...) we can just use xarray's interpolation functionality with 0.8 on this grid in order to get this point no matter the grid.

Still we need to build tooling around getting the dimension names that correspond to the axes (i.e, for da.dims == ["XC", "YC"] the grid needs to tell us "X" -> "XC" and "Y" -> "YC". I'll put up another PR

@VeckoTheGecko

Copy link
Copy Markdown
ContributorAuthor

I realised that this localization step isn't actually needed.

I realised that this is wrong actually 😅 . Localisation will be needed when writing curvilinear interpolators such as in the following being investigated in #2081 :

defXTriCurviLinear(
field: Field,
ti: int,
position: dict[_XGRID_AXES, tuple[int, float|np.ndarray]],
tau: np.float32|np.float64,
t: np.float32|np.float64,
z: np.float32|np.float64,
y: np.float32|np.float64,
x: np.float32|np.float64,
):
"""Trilinear interpolation on a curvilinear grid."""xi, xsi=position["X"]
yi, eta=position["Y"]
zi, zeta=position["Z"]
data=field.dataaxis_dim=field.grid.get_axis_dim_mapping(field.data.dims)
return (
(
(1-xsi) * (1-eta) *data.isel({axis_dim["Y"]: yi, axis_dim["X"]: xi})
+xsi* (1-eta) *data.isel({axis_dim["Y"]: yi, axis_dim["X"]: xi+1})
+xsi*eta*data.isel({axis_dim["Y"]: yi+1, axis_dim["X"]: xi+1})
+ (1-xsi) *eta*data.isel({axis_dim["Y"]: yi+1, axis_dim["X"]: xi})
)
.interp(time=t, **{axis_dim["Z"]: zi+zeta})
.values
)

Here, xsi and eta need to be adjusted to match how the data is defined (e.g., if the data is defined on the cell centers).

Perhaps there are other ways to achieve this without a localization step - but I need to get more familiar with Xarray internals and coordinate aware interpolation for that. I'll mark this as unstable API subject to change in the docstring - this is an isolated change we can remove later.

@erikvansebille any additional thoughts?

@erikvansebille

Copy link
Copy Markdown
Member

Yep agree; let's keep it in for now

@VeckoTheGecko

VeckoTheGecko commented Jul 16, 2025

Copy link
Copy Markdown
ContributorAuthor

Also wrapped into this numpydoc!=1.9.0 so that our docs build again on v4-dev (didn't bother with v3 since I assume they'll issue a fix before we need to update v3 docs again) Actually, I'll quickly backport to v3 as well

@VeckoTheGecko
VeckoTheGecko merged commit b510f11 into v4-devJul 16, 2025
@VeckoTheGecko
VeckoTheGecko deleted the grid-localization branch July 16, 2025 09:58
@github-project-automationgithub-project-automationBot moved this from Backlog to Done in Parcels developmentJul 16, 2025
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants

@VeckoTheGecko@erikvansebille
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

XGrid localization - #2082

Merged
VeckoTheGecko merged 6 commits into
v4-devfrom
grid-localization
Jul 16, 2025
Merged

XGrid localization#2082
VeckoTheGecko merged 6 commits into
v4-devfrom
grid-localization

Conversation

@VeckoTheGecko

@VeckoTheGeckoVeckoTheGecko commented Jul 11, 2025

Copy link
Copy Markdown
Contributor

Currently we have a search method which returns a particle position relative to the F points. However, since our data can be defined on a staggered grid, it's important to "localize" this particle position to the grid for the array of interest. This mainly applies for C grids and when working with MITgcm and NEMO where their F points and C points are defined differently relative to each other (see diagram in docs or in #2037) .

This PR introduces this grid localization. This is really just the first draft to get feedback (code is a bit more messy than I would like - and there are no tests). This good localization will help with writing interpolators.

  • Chose the correct base branch (v4-dev for v4 changes)
  • Fixes None
  • Added tests (not yet)
  • Added documentation

@VeckoTheGecko

VeckoTheGecko commented Jul 11, 2025

Copy link
Copy Markdown
ContributorAuthor

Below is a small testing script along with output. data_c with the -1 index seems a bit strange, but I guess it makes sense (if vertical positions are defined on the cell centers and the particle is at the surface then -1 with bcoord 0.5 is expected). Thoughts @erikvansebille ?

# %%importnumpyasnpimportxarrayasxrfrompprintimportpprintfromparcelsimportxgcmfromparcels._datasets.structured.genericimportX, Y, Zfromparcels.xgridimportXGridT=2Z=Y=X=3TIME=xr.date_range("2000", "2001", T)
ds_mitgcm=xr.Dataset(
{
"data_g": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"data_c": (["time", "ZC", "YC", "XC"], np.random.rand(T, Z, Y, X)),
"U (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"V (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"U (C grid)": (["time", "ZG", "YC", "XG"], np.random.rand(T, Z, Y, X)),
"V (C grid)": (["time", "ZG", "YG", "XC"], np.random.rand(T, Z, Y, X)),
},
coords={
"XG": (
["XG"],
np.arange(0, X),
{"axis": "X", "c_grid_axis_shift": -0.5},
),
"XC": (["XC"], np.arange(0, X) +0.5, {"axis": "X"}),
"YG": (
["YG"],
np.arange(0, Y),
{"axis": "Y", "c_grid_axis_shift": -0.5},
),
"YC": (
["YC"],
np.arange(0, Y) +0.5,
{"axis": "Y"},
),
"ZG": (
["ZG"],
np.arange(Z),
{"axis": "Z", "c_grid_axis_shift": -0.5},
),
"ZC": (
["ZC"],
np.arange(Z) +0.5,
{"axis": "Z"},
),
"lon": (["XG"], np.arange(0, X)),
"lat": (["YG"], np.arange(0, Y)),
"depth": (["ZG"], np.arange(Z)),
"time": (["time"], TIME, {"axis": "T"}),
},
)
ds_nemo=xr.Dataset(
{
"data_g": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"data_c": (["time", "ZC", "YC", "XC"], np.random.rand(T, Z, Y, X)),
"U (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"V (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"U (C grid)": (["time", "ZG", "YC", "XG"], np.random.rand(T, Z, Y, X)),
"V (C grid)": (["time", "ZG", "YG", "XC"], np.random.rand(T, Z, Y, X)),
},
coords={
"XG": (
["XG"],
np.arange(0, X),
{"axis": "X", "c_grid_axis_shift": 0.5},
),
"XC": (["XC"], np.arange(0, X) -0.5, {"axis": "X"}),
"YG": (
["YG"],
np.arange(0, Y),
{"axis": "Y", "c_grid_axis_shift": 0.5},
),
"YC": (
["YC"],
np.arange(0, Y) -0.5,
{"axis": "Y"},
),
"ZG": (
["ZG"],
np.arange(Z),
{"axis": "Z", "c_grid_axis_shift": -0.5},
),
"ZC": (
["ZC"],
np.arange(Z) +0.5,
{"axis": "Z"},
),
"lon": (["XG"], np.arange(0, X)),
"lat": (["YG"], np.arange(0, Y)),
"depth": (["ZG"], np.arange(Z)),
"time": (["time"], TIME, {"axis": "T"}),
},
)
grid_mitgcm=XGrid(xgcm.Grid(ds_mitgcm, periodic=False))
grid_nemo=XGrid(xgcm.Grid(ds_nemo, periodic=False))
print("XGCM repr of MITgcm grid:")
print(grid_mitgcm.xgcm_grid)
print("\nXGCM repr of NEMO grid:")
print(grid_nemo.xgcm_grid)
# %%defshow_point_on_grid(grid, z, y, x):
"""Pretty printing of some info"""position=grid.search(z, y, x)
print(f"Position wrt. fpoints (lon/lat grid):")
pprint(position)
fordaingrid.xgcm_grid._ds.data_vars.values():
local_position=grid.localize(position, da.dims)
print(f"On {da.name=} with {da.dims=}, local position:")
pprint(local_position)
print("----Working with MITgcm grid----")
show_point_on_grid(grid_mitgcm, 0, 0.8, 0.8)
print("\n----Working with NEMO grid----")
show_point_on_grid(grid_nemo, 0, 0.8, 0.8)

output:

XGCM repr of MITgcm grid:
<parcels.Grid>
Y Axis (not periodic, boundary=None):
* center YC --> left
* left YG --> center
X Axis (not periodic, boundary=None):
* center XC --> left
* left XG --> center
Z Axis (not periodic, boundary=None):
* center ZC --> left
* left ZG --> center
T Axis (not periodic, boundary=None):
* center time
XGCM repr of NEMO grid:
<parcels.Grid>
Y Axis (not periodic, boundary=None):
* center YC --> right
* right YG --> center
X Axis (not periodic, boundary=None):
* center XC --> right
* right XG --> center
Z Axis (not periodic, boundary=None):
* center ZC --> left
* left ZG --> center
T Axis (not periodic, boundary=None):
* center time
----Working with MITgcm grid----
Position wrt. fpoints (lon/lat grid):
{'X': (np.int64(0), np.float64(0.8)),
'Y': (np.int64(0), np.float64(0.8)),
'Z': (np.int64(0), np.float64(0.0))}
On da.name='data_g' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='data_c' with da.dims=('time', 'ZC', 'YC', 'XC'), local position:
{'XC': (np.int64(0), np.float64(0.30000000000000004)),
'YC': (np.int64(0), np.float64(0.30000000000000004)),
'ZC': (np.int64(-1), np.float64(0.5))}
On da.name='U (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='U (C grid)' with da.dims=('time', 'ZG', 'YC', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YC': (np.int64(0), np.float64(0.30000000000000004)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (C grid)' with da.dims=('time', 'ZG', 'YG', 'XC'), local position:
{'XC': (np.int64(0), np.float64(0.30000000000000004)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
----Working with NEMO grid----
Position wrt. fpoints (lon/lat grid):
{'X': (np.int64(0), np.float64(0.8)),
'Y': (np.int64(0), np.float64(0.8)),
'Z': (np.int64(0), np.float64(0.0))}
On da.name='data_g' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='data_c' with da.dims=('time', 'ZC', 'YC', 'XC'), local position:
{'XC': (np.int64(1), np.float64(0.30000000000000004)),
'YC': (np.int64(1), np.float64(0.30000000000000004)),
'ZC': (np.int64(-1), np.float64(0.5))}
On da.name='U (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='U (C grid)' with da.dims=('time', 'ZG', 'YC', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YC': (np.int64(1), np.float64(0.30000000000000004)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (C grid)' with da.dims=('time', 'ZG', 'YG', 'XC'), local position:
{'XC': (np.int64(1), np.float64(0.30000000000000004)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}

@erikvansebille

Copy link
Copy Markdown
Member

Note that some datasets have an attribute c_grid_axis_shift, see for example below. If this attribute exists (but that's unfortunately not guaranteed), we can use it in this function too?

https://github.com/OceanParcels/Parcels/blob/b7bdddc4efe8024b5a2eab0025ca50eb5f8a9369/parcels/_datasets/structured/circulation_models.py#L627-L636

@VeckoTheGecko

Copy link
Copy Markdown
ContributorAuthor

Note that some datasets have an attribute c_grid_axis_shift, see for example below. If this attribute exists (but that's unfortunately not guaranteed), we can use it in this function too?

Yes, this is already taken into account by xgcm during grid ingestion to determine the nature of the grid staggering. In fact, the only difference between ds_mitgcm and ds_nemo is the value of this attribute and the corresponding offset.

@VeckoTheGecko

VeckoTheGecko commented Jul 11, 2025

Copy link
Copy Markdown
ContributorAuthor

if we're comfortable with this approach, I'll go and clear up some stuff (e.g., variable naming since it might be a bit confusing)

@VeckoTheGecko
VeckoTheGeckoforce-pushed the grid-localization branch 2 times, most recently from f3949e4 to 0d0b0e5CompareJuly 14, 2025 11:34
@VeckoTheGecko
VeckoTheGecko marked this pull request as ready for review July 14, 2025 11:35

@erikvansebilleerikvansebille left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. And how would a user use this in an interpolation method? Or will that come in a next PR?

Comment threadparcels/xgrid.py
@VeckoTheGecko

VeckoTheGecko commented Jul 15, 2025

Copy link
Copy Markdown
ContributorAuthor

I realised that this localization step isn't actually needed.

image

If a point is defined at (in X) index, bcoord = 0,0.8 wrt. the f-points, that will be at 0, 0.3 wrt. the C points. However, since the dual grid already has dimension coordinates that are offset by 0.5 (i.e., 0.5, 1.5 ...) we can just use xarray's interpolation functionality with 0.8 on this grid in order to get this point no matter the grid.

Still we need to build tooling around getting the dimension names that correspond to the axes (i.e, for da.dims == ["XC", "YC"] the grid needs to tell us "X" -> "XC" and "Y" -> "YC". I'll put up another PR

@VeckoTheGecko

Copy link
Copy Markdown
ContributorAuthor

I realised that this localization step isn't actually needed.

I realised that this is wrong actually 😅 . Localisation will be needed when writing curvilinear interpolators such as in the following being investigated in #2081 :

defXTriCurviLinear(
field: Field,
ti: int,
position: dict[_XGRID_AXES, tuple[int, float|np.ndarray]],
tau: np.float32|np.float64,
t: np.float32|np.float64,
z: np.float32|np.float64,
y: np.float32|np.float64,
x: np.float32|np.float64,
):
"""Trilinear interpolation on a curvilinear grid."""xi, xsi=position["X"]
yi, eta=position["Y"]
zi, zeta=position["Z"]
data=field.dataaxis_dim=field.grid.get_axis_dim_mapping(field.data.dims)
return (
(
(1-xsi) * (1-eta) *data.isel({axis_dim["Y"]: yi, axis_dim["X"]: xi})
+xsi* (1-eta) *data.isel({axis_dim["Y"]: yi, axis_dim["X"]: xi+1})
+xsi*eta*data.isel({axis_dim["Y"]: yi+1, axis_dim["X"]: xi+1})
+ (1-xsi) *eta*data.isel({axis_dim["Y"]: yi+1, axis_dim["X"]: xi})
)
.interp(time=t, **{axis_dim["Z"]: zi+zeta})
.values
)

Here, xsi and eta need to be adjusted to match how the data is defined (e.g., if the data is defined on the cell centers).

Perhaps there are other ways to achieve this without a localization step - but I need to get more familiar with Xarray internals and coordinate aware interpolation for that. I'll mark this as unstable API subject to change in the docstring - this is an isolated change we can remove later.

@erikvansebille any additional thoughts?

@erikvansebille

Copy link
Copy Markdown
Member

Yep agree; let's keep it in for now

@VeckoTheGecko

VeckoTheGecko commented Jul 16, 2025

Copy link
Copy Markdown
ContributorAuthor

Also wrapped into this numpydoc!=1.9.0 so that our docs build again on v4-dev (didn't bother with v3 since I assume they'll issue a fix before we need to update v3 docs again) Actually, I'll quickly backport to v3 as well

@VeckoTheGecko
VeckoTheGecko merged commit b510f11 into v4-devJul 16, 2025
@VeckoTheGecko
VeckoTheGecko deleted the grid-localization branch July 16, 2025 09:58
@github-project-automationgithub-project-automationBot moved this from Backlog to Done in Parcels developmentJul 16, 2025
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants

@VeckoTheGecko@erikvansebille
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

XGrid localization - #2082

Merged
VeckoTheGecko merged 6 commits into
v4-devfrom
grid-localization
Jul 16, 2025
Merged

XGrid localization#2082
VeckoTheGecko merged 6 commits into
v4-devfrom
grid-localization

Conversation

@VeckoTheGecko

@VeckoTheGeckoVeckoTheGecko commented Jul 11, 2025

Copy link
Copy Markdown
Contributor

Currently we have a search method which returns a particle position relative to the F points. However, since our data can be defined on a staggered grid, it's important to "localize" this particle position to the grid for the array of interest. This mainly applies for C grids and when working with MITgcm and NEMO where their F points and C points are defined differently relative to each other (see diagram in docs or in #2037) .

This PR introduces this grid localization. This is really just the first draft to get feedback (code is a bit more messy than I would like - and there are no tests). This good localization will help with writing interpolators.

  • Chose the correct base branch (v4-dev for v4 changes)
  • Fixes None
  • Added tests (not yet)
  • Added documentation

@VeckoTheGecko

VeckoTheGecko commented Jul 11, 2025

Copy link
Copy Markdown
ContributorAuthor

Below is a small testing script along with output. data_c with the -1 index seems a bit strange, but I guess it makes sense (if vertical positions are defined on the cell centers and the particle is at the surface then -1 with bcoord 0.5 is expected). Thoughts @erikvansebille ?

# %%importnumpyasnpimportxarrayasxrfrompprintimportpprintfromparcelsimportxgcmfromparcels._datasets.structured.genericimportX, Y, Zfromparcels.xgridimportXGridT=2Z=Y=X=3TIME=xr.date_range("2000", "2001", T)
ds_mitgcm=xr.Dataset(
{
"data_g": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"data_c": (["time", "ZC", "YC", "XC"], np.random.rand(T, Z, Y, X)),
"U (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"V (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"U (C grid)": (["time", "ZG", "YC", "XG"], np.random.rand(T, Z, Y, X)),
"V (C grid)": (["time", "ZG", "YG", "XC"], np.random.rand(T, Z, Y, X)),
},
coords={
"XG": (
["XG"],
np.arange(0, X),
{"axis": "X", "c_grid_axis_shift": -0.5},
),
"XC": (["XC"], np.arange(0, X) +0.5, {"axis": "X"}),
"YG": (
["YG"],
np.arange(0, Y),
{"axis": "Y", "c_grid_axis_shift": -0.5},
),
"YC": (
["YC"],
np.arange(0, Y) +0.5,
{"axis": "Y"},
),
"ZG": (
["ZG"],
np.arange(Z),
{"axis": "Z", "c_grid_axis_shift": -0.5},
),
"ZC": (
["ZC"],
np.arange(Z) +0.5,
{"axis": "Z"},
),
"lon": (["XG"], np.arange(0, X)),
"lat": (["YG"], np.arange(0, Y)),
"depth": (["ZG"], np.arange(Z)),
"time": (["time"], TIME, {"axis": "T"}),
},
)
ds_nemo=xr.Dataset(
{
"data_g": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"data_c": (["time", "ZC", "YC", "XC"], np.random.rand(T, Z, Y, X)),
"U (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"V (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"U (C grid)": (["time", "ZG", "YC", "XG"], np.random.rand(T, Z, Y, X)),
"V (C grid)": (["time", "ZG", "YG", "XC"], np.random.rand(T, Z, Y, X)),
},
coords={
"XG": (
["XG"],
np.arange(0, X),
{"axis": "X", "c_grid_axis_shift": 0.5},
),
"XC": (["XC"], np.arange(0, X) -0.5, {"axis": "X"}),
"YG": (
["YG"],
np.arange(0, Y),
{"axis": "Y", "c_grid_axis_shift": 0.5},
),
"YC": (
["YC"],
np.arange(0, Y) -0.5,
{"axis": "Y"},
),
"ZG": (
["ZG"],
np.arange(Z),
{"axis": "Z", "c_grid_axis_shift": -0.5},
),
"ZC": (
["ZC"],
np.arange(Z) +0.5,
{"axis": "Z"},
),
"lon": (["XG"], np.arange(0, X)),
"lat": (["YG"], np.arange(0, Y)),
"depth": (["ZG"], np.arange(Z)),
"time": (["time"], TIME, {"axis": "T"}),
},
)
grid_mitgcm=XGrid(xgcm.Grid(ds_mitgcm, periodic=False))
grid_nemo=XGrid(xgcm.Grid(ds_nemo, periodic=False))
print("XGCM repr of MITgcm grid:")
print(grid_mitgcm.xgcm_grid)
print("\nXGCM repr of NEMO grid:")
print(grid_nemo.xgcm_grid)
# %%defshow_point_on_grid(grid, z, y, x):
"""Pretty printing of some info"""position=grid.search(z, y, x)
print(f"Position wrt. fpoints (lon/lat grid):")
pprint(position)
fordaingrid.xgcm_grid._ds.data_vars.values():
local_position=grid.localize(position, da.dims)
print(f"On {da.name=} with {da.dims=}, local position:")
pprint(local_position)
print("----Working with MITgcm grid----")
show_point_on_grid(grid_mitgcm, 0, 0.8, 0.8)
print("\n----Working with NEMO grid----")
show_point_on_grid(grid_nemo, 0, 0.8, 0.8)

output:

XGCM repr of MITgcm grid:
<parcels.Grid>
Y Axis (not periodic, boundary=None):
* center YC --> left
* left YG --> center
X Axis (not periodic, boundary=None):
* center XC --> left
* left XG --> center
Z Axis (not periodic, boundary=None):
* center ZC --> left
* left ZG --> center
T Axis (not periodic, boundary=None):
* center time
XGCM repr of NEMO grid:
<parcels.Grid>
Y Axis (not periodic, boundary=None):
* center YC --> right
* right YG --> center
X Axis (not periodic, boundary=None):
* center XC --> right
* right XG --> center
Z Axis (not periodic, boundary=None):
* center ZC --> left
* left ZG --> center
T Axis (not periodic, boundary=None):
* center time
----Working with MITgcm grid----
Position wrt. fpoints (lon/lat grid):
{'X': (np.int64(0), np.float64(0.8)),
'Y': (np.int64(0), np.float64(0.8)),
'Z': (np.int64(0), np.float64(0.0))}
On da.name='data_g' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='data_c' with da.dims=('time', 'ZC', 'YC', 'XC'), local position:
{'XC': (np.int64(0), np.float64(0.30000000000000004)),
'YC': (np.int64(0), np.float64(0.30000000000000004)),
'ZC': (np.int64(-1), np.float64(0.5))}
On da.name='U (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='U (C grid)' with da.dims=('time', 'ZG', 'YC', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YC': (np.int64(0), np.float64(0.30000000000000004)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (C grid)' with da.dims=('time', 'ZG', 'YG', 'XC'), local position:
{'XC': (np.int64(0), np.float64(0.30000000000000004)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
----Working with NEMO grid----
Position wrt. fpoints (lon/lat grid):
{'X': (np.int64(0), np.float64(0.8)),
'Y': (np.int64(0), np.float64(0.8)),
'Z': (np.int64(0), np.float64(0.0))}
On da.name='data_g' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='data_c' with da.dims=('time', 'ZC', 'YC', 'XC'), local position:
{'XC': (np.int64(1), np.float64(0.30000000000000004)),
'YC': (np.int64(1), np.float64(0.30000000000000004)),
'ZC': (np.int64(-1), np.float64(0.5))}
On da.name='U (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='U (C grid)' with da.dims=('time', 'ZG', 'YC', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YC': (np.int64(1), np.float64(0.30000000000000004)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (C grid)' with da.dims=('time', 'ZG', 'YG', 'XC'), local position:
{'XC': (np.int64(1), np.float64(0.30000000000000004)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}

@erikvansebille

Copy link
Copy Markdown
Member

Note that some datasets have an attribute c_grid_axis_shift, see for example below. If this attribute exists (but that's unfortunately not guaranteed), we can use it in this function too?

https://github.com/OceanParcels/Parcels/blob/b7bdddc4efe8024b5a2eab0025ca50eb5f8a9369/parcels/_datasets/structured/circulation_models.py#L627-L636

@VeckoTheGecko

Copy link
Copy Markdown
ContributorAuthor

Note that some datasets have an attribute c_grid_axis_shift, see for example below. If this attribute exists (but that's unfortunately not guaranteed), we can use it in this function too?

Yes, this is already taken into account by xgcm during grid ingestion to determine the nature of the grid staggering. In fact, the only difference between ds_mitgcm and ds_nemo is the value of this attribute and the corresponding offset.

@VeckoTheGecko

VeckoTheGecko commented Jul 11, 2025

Copy link
Copy Markdown
ContributorAuthor

if we're comfortable with this approach, I'll go and clear up some stuff (e.g., variable naming since it might be a bit confusing)

@VeckoTheGecko
VeckoTheGeckoforce-pushed the grid-localization branch 2 times, most recently from f3949e4 to 0d0b0e5CompareJuly 14, 2025 11:34
@VeckoTheGecko
VeckoTheGecko marked this pull request as ready for review July 14, 2025 11:35

@erikvansebilleerikvansebille left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. And how would a user use this in an interpolation method? Or will that come in a next PR?

Comment threadparcels/xgrid.py
@VeckoTheGecko

VeckoTheGecko commented Jul 15, 2025

Copy link
Copy Markdown
ContributorAuthor

I realised that this localization step isn't actually needed.

image

If a point is defined at (in X) index, bcoord = 0,0.8 wrt. the f-points, that will be at 0, 0.3 wrt. the C points. However, since the dual grid already has dimension coordinates that are offset by 0.5 (i.e., 0.5, 1.5 ...) we can just use xarray's interpolation functionality with 0.8 on this grid in order to get this point no matter the grid.

Still we need to build tooling around getting the dimension names that correspond to the axes (i.e, for da.dims == ["XC", "YC"] the grid needs to tell us "X" -> "XC" and "Y" -> "YC". I'll put up another PR

@VeckoTheGecko

Copy link
Copy Markdown
ContributorAuthor

I realised that this localization step isn't actually needed.

I realised that this is wrong actually 😅 . Localisation will be needed when writing curvilinear interpolators such as in the following being investigated in #2081 :

defXTriCurviLinear(
field: Field,
ti: int,
position: dict[_XGRID_AXES, tuple[int, float|np.ndarray]],
tau: np.float32|np.float64,
t: np.float32|np.float64,
z: np.float32|np.float64,
y: np.float32|np.float64,
x: np.float32|np.float64,
):
"""Trilinear interpolation on a curvilinear grid."""xi, xsi=position["X"]
yi, eta=position["Y"]
zi, zeta=position["Z"]
data=field.dataaxis_dim=field.grid.get_axis_dim_mapping(field.data.dims)
return (
(
(1-xsi) * (1-eta) *data.isel({axis_dim["Y"]: yi, axis_dim["X"]: xi})
+xsi* (1-eta) *data.isel({axis_dim["Y"]: yi, axis_dim["X"]: xi+1})
+xsi*eta*data.isel({axis_dim["Y"]: yi+1, axis_dim["X"]: xi+1})
+ (1-xsi) *eta*data.isel({axis_dim["Y"]: yi+1, axis_dim["X"]: xi})
)
.interp(time=t, **{axis_dim["Z"]: zi+zeta})
.values
)

Here, xsi and eta need to be adjusted to match how the data is defined (e.g., if the data is defined on the cell centers).

Perhaps there are other ways to achieve this without a localization step - but I need to get more familiar with Xarray internals and coordinate aware interpolation for that. I'll mark this as unstable API subject to change in the docstring - this is an isolated change we can remove later.

@erikvansebille any additional thoughts?

@erikvansebille

Copy link
Copy Markdown
Member

Yep agree; let's keep it in for now

@VeckoTheGecko

VeckoTheGecko commented Jul 16, 2025

Copy link
Copy Markdown
ContributorAuthor

Also wrapped into this numpydoc!=1.9.0 so that our docs build again on v4-dev (didn't bother with v3 since I assume they'll issue a fix before we need to update v3 docs again) Actually, I'll quickly backport to v3 as well

@VeckoTheGecko
VeckoTheGecko merged commit b510f11 into v4-devJul 16, 2025
@VeckoTheGecko
VeckoTheGecko deleted the grid-localization branch July 16, 2025 09:58
@github-project-automationgithub-project-automationBot moved this from Backlog to Done in Parcels developmentJul 16, 2025
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants

@VeckoTheGecko@erikvansebille
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

XGrid localization - #2082

Merged
VeckoTheGecko merged 6 commits into
v4-devfrom
grid-localization
Jul 16, 2025
Merged

XGrid localization#2082
VeckoTheGecko merged 6 commits into
v4-devfrom
grid-localization

Conversation

@VeckoTheGecko

@VeckoTheGeckoVeckoTheGecko commented Jul 11, 2025

Copy link
Copy Markdown
Contributor

Currently we have a search method which returns a particle position relative to the F points. However, since our data can be defined on a staggered grid, it's important to "localize" this particle position to the grid for the array of interest. This mainly applies for C grids and when working with MITgcm and NEMO where their F points and C points are defined differently relative to each other (see diagram in docs or in #2037) .

This PR introduces this grid localization. This is really just the first draft to get feedback (code is a bit more messy than I would like - and there are no tests). This good localization will help with writing interpolators.

  • Chose the correct base branch (v4-dev for v4 changes)
  • Fixes None
  • Added tests (not yet)
  • Added documentation

@VeckoTheGecko

VeckoTheGecko commented Jul 11, 2025

Copy link
Copy Markdown
ContributorAuthor

Below is a small testing script along with output. data_c with the -1 index seems a bit strange, but I guess it makes sense (if vertical positions are defined on the cell centers and the particle is at the surface then -1 with bcoord 0.5 is expected). Thoughts @erikvansebille ?

# %%importnumpyasnpimportxarrayasxrfrompprintimportpprintfromparcelsimportxgcmfromparcels._datasets.structured.genericimportX, Y, Zfromparcels.xgridimportXGridT=2Z=Y=X=3TIME=xr.date_range("2000", "2001", T)
ds_mitgcm=xr.Dataset(
{
"data_g": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"data_c": (["time", "ZC", "YC", "XC"], np.random.rand(T, Z, Y, X)),
"U (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"V (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"U (C grid)": (["time", "ZG", "YC", "XG"], np.random.rand(T, Z, Y, X)),
"V (C grid)": (["time", "ZG", "YG", "XC"], np.random.rand(T, Z, Y, X)),
},
coords={
"XG": (
["XG"],
np.arange(0, X),
{"axis": "X", "c_grid_axis_shift": -0.5},
),
"XC": (["XC"], np.arange(0, X) +0.5, {"axis": "X"}),
"YG": (
["YG"],
np.arange(0, Y),
{"axis": "Y", "c_grid_axis_shift": -0.5},
),
"YC": (
["YC"],
np.arange(0, Y) +0.5,
{"axis": "Y"},
),
"ZG": (
["ZG"],
np.arange(Z),
{"axis": "Z", "c_grid_axis_shift": -0.5},
),
"ZC": (
["ZC"],
np.arange(Z) +0.5,
{"axis": "Z"},
),
"lon": (["XG"], np.arange(0, X)),
"lat": (["YG"], np.arange(0, Y)),
"depth": (["ZG"], np.arange(Z)),
"time": (["time"], TIME, {"axis": "T"}),
},
)
ds_nemo=xr.Dataset(
{
"data_g": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"data_c": (["time", "ZC", "YC", "XC"], np.random.rand(T, Z, Y, X)),
"U (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"V (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"U (C grid)": (["time", "ZG", "YC", "XG"], np.random.rand(T, Z, Y, X)),
"V (C grid)": (["time", "ZG", "YG", "XC"], np.random.rand(T, Z, Y, X)),
},
coords={
"XG": (
["XG"],
np.arange(0, X),
{"axis": "X", "c_grid_axis_shift": 0.5},
),
"XC": (["XC"], np.arange(0, X) -0.5, {"axis": "X"}),
"YG": (
["YG"],
np.arange(0, Y),
{"axis": "Y", "c_grid_axis_shift": 0.5},
),
"YC": (
["YC"],
np.arange(0, Y) -0.5,
{"axis": "Y"},
),
"ZG": (
["ZG"],
np.arange(Z),
{"axis": "Z", "c_grid_axis_shift": -0.5},
),
"ZC": (
["ZC"],
np.arange(Z) +0.5,
{"axis": "Z"},
),
"lon": (["XG"], np.arange(0, X)),
"lat": (["YG"], np.arange(0, Y)),
"depth": (["ZG"], np.arange(Z)),
"time": (["time"], TIME, {"axis": "T"}),
},
)
grid_mitgcm=XGrid(xgcm.Grid(ds_mitgcm, periodic=False))
grid_nemo=XGrid(xgcm.Grid(ds_nemo, periodic=False))
print("XGCM repr of MITgcm grid:")
print(grid_mitgcm.xgcm_grid)
print("\nXGCM repr of NEMO grid:")
print(grid_nemo.xgcm_grid)
# %%defshow_point_on_grid(grid, z, y, x):
"""Pretty printing of some info"""position=grid.search(z, y, x)
print(f"Position wrt. fpoints (lon/lat grid):")
pprint(position)
fordaingrid.xgcm_grid._ds.data_vars.values():
local_position=grid.localize(position, da.dims)
print(f"On {da.name=} with {da.dims=}, local position:")
pprint(local_position)
print("----Working with MITgcm grid----")
show_point_on_grid(grid_mitgcm, 0, 0.8, 0.8)
print("\n----Working with NEMO grid----")
show_point_on_grid(grid_nemo, 0, 0.8, 0.8)

output:

XGCM repr of MITgcm grid:
<parcels.Grid>
Y Axis (not periodic, boundary=None):
* center YC --> left
* left YG --> center
X Axis (not periodic, boundary=None):
* center XC --> left
* left XG --> center
Z Axis (not periodic, boundary=None):
* center ZC --> left
* left ZG --> center
T Axis (not periodic, boundary=None):
* center time
XGCM repr of NEMO grid:
<parcels.Grid>
Y Axis (not periodic, boundary=None):
* center YC --> right
* right YG --> center
X Axis (not periodic, boundary=None):
* center XC --> right
* right XG --> center
Z Axis (not periodic, boundary=None):
* center ZC --> left
* left ZG --> center
T Axis (not periodic, boundary=None):
* center time
----Working with MITgcm grid----
Position wrt. fpoints (lon/lat grid):
{'X': (np.int64(0), np.float64(0.8)),
'Y': (np.int64(0), np.float64(0.8)),
'Z': (np.int64(0), np.float64(0.0))}
On da.name='data_g' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='data_c' with da.dims=('time', 'ZC', 'YC', 'XC'), local position:
{'XC': (np.int64(0), np.float64(0.30000000000000004)),
'YC': (np.int64(0), np.float64(0.30000000000000004)),
'ZC': (np.int64(-1), np.float64(0.5))}
On da.name='U (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='U (C grid)' with da.dims=('time', 'ZG', 'YC', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YC': (np.int64(0), np.float64(0.30000000000000004)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (C grid)' with da.dims=('time', 'ZG', 'YG', 'XC'), local position:
{'XC': (np.int64(0), np.float64(0.30000000000000004)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
----Working with NEMO grid----
Position wrt. fpoints (lon/lat grid):
{'X': (np.int64(0), np.float64(0.8)),
'Y': (np.int64(0), np.float64(0.8)),
'Z': (np.int64(0), np.float64(0.0))}
On da.name='data_g' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='data_c' with da.dims=('time', 'ZC', 'YC', 'XC'), local position:
{'XC': (np.int64(1), np.float64(0.30000000000000004)),
'YC': (np.int64(1), np.float64(0.30000000000000004)),
'ZC': (np.int64(-1), np.float64(0.5))}
On da.name='U (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='U (C grid)' with da.dims=('time', 'ZG', 'YC', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YC': (np.int64(1), np.float64(0.30000000000000004)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (C grid)' with da.dims=('time', 'ZG', 'YG', 'XC'), local position:
{'XC': (np.int64(1), np.float64(0.30000000000000004)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}

@erikvansebille

Copy link
Copy Markdown
Member

Note that some datasets have an attribute c_grid_axis_shift, see for example below. If this attribute exists (but that's unfortunately not guaranteed), we can use it in this function too?

https://github.com/OceanParcels/Parcels/blob/b7bdddc4efe8024b5a2eab0025ca50eb5f8a9369/parcels/_datasets/structured/circulation_models.py#L627-L636

@VeckoTheGecko

Copy link
Copy Markdown
ContributorAuthor

Note that some datasets have an attribute c_grid_axis_shift, see for example below. If this attribute exists (but that's unfortunately not guaranteed), we can use it in this function too?

Yes, this is already taken into account by xgcm during grid ingestion to determine the nature of the grid staggering. In fact, the only difference between ds_mitgcm and ds_nemo is the value of this attribute and the corresponding offset.

@VeckoTheGecko

VeckoTheGecko commented Jul 11, 2025

Copy link
Copy Markdown
ContributorAuthor

if we're comfortable with this approach, I'll go and clear up some stuff (e.g., variable naming since it might be a bit confusing)

@VeckoTheGecko
VeckoTheGeckoforce-pushed the grid-localization branch 2 times, most recently from f3949e4 to 0d0b0e5CompareJuly 14, 2025 11:34
@VeckoTheGecko
VeckoTheGecko marked this pull request as ready for review July 14, 2025 11:35

@erikvansebilleerikvansebille left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. And how would a user use this in an interpolation method? Or will that come in a next PR?

Comment threadparcels/xgrid.py
@VeckoTheGecko

VeckoTheGecko commented Jul 15, 2025

Copy link
Copy Markdown
ContributorAuthor

I realised that this localization step isn't actually needed.

image

If a point is defined at (in X) index, bcoord = 0,0.8 wrt. the f-points, that will be at 0, 0.3 wrt. the C points. However, since the dual grid already has dimension coordinates that are offset by 0.5 (i.e., 0.5, 1.5 ...) we can just use xarray's interpolation functionality with 0.8 on this grid in order to get this point no matter the grid.

Still we need to build tooling around getting the dimension names that correspond to the axes (i.e, for da.dims == ["XC", "YC"] the grid needs to tell us "X" -> "XC" and "Y" -> "YC". I'll put up another PR

@VeckoTheGecko

Copy link
Copy Markdown
ContributorAuthor

I realised that this localization step isn't actually needed.

I realised that this is wrong actually 😅 . Localisation will be needed when writing curvilinear interpolators such as in the following being investigated in #2081 :

defXTriCurviLinear(
field: Field,
ti: int,
position: dict[_XGRID_AXES, tuple[int, float|np.ndarray]],
tau: np.float32|np.float64,
t: np.float32|np.float64,
z: np.float32|np.float64,
y: np.float32|np.float64,
x: np.float32|np.float64,
):
"""Trilinear interpolation on a curvilinear grid."""xi, xsi=position["X"]
yi, eta=position["Y"]
zi, zeta=position["Z"]
data=field.dataaxis_dim=field.grid.get_axis_dim_mapping(field.data.dims)
return (
(
(1-xsi) * (1-eta) *data.isel({axis_dim["Y"]: yi, axis_dim["X"]: xi})
+xsi* (1-eta) *data.isel({axis_dim["Y"]: yi, axis_dim["X"]: xi+1})
+xsi*eta*data.isel({axis_dim["Y"]: yi+1, axis_dim["X"]: xi+1})
+ (1-xsi) *eta*data.isel({axis_dim["Y"]: yi+1, axis_dim["X"]: xi})
)
.interp(time=t, **{axis_dim["Z"]: zi+zeta})
.values
)

Here, xsi and eta need to be adjusted to match how the data is defined (e.g., if the data is defined on the cell centers).

Perhaps there are other ways to achieve this without a localization step - but I need to get more familiar with Xarray internals and coordinate aware interpolation for that. I'll mark this as unstable API subject to change in the docstring - this is an isolated change we can remove later.

@erikvansebille any additional thoughts?

@erikvansebille

Copy link
Copy Markdown
Member

Yep agree; let's keep it in for now

@VeckoTheGecko

VeckoTheGecko commented Jul 16, 2025

Copy link
Copy Markdown
ContributorAuthor

Also wrapped into this numpydoc!=1.9.0 so that our docs build again on v4-dev (didn't bother with v3 since I assume they'll issue a fix before we need to update v3 docs again) Actually, I'll quickly backport to v3 as well

@VeckoTheGecko
VeckoTheGecko merged commit b510f11 into v4-devJul 16, 2025
@VeckoTheGecko
VeckoTheGecko deleted the grid-localization branch July 16, 2025 09:58
@github-project-automationgithub-project-automationBot moved this from Backlog to Done in Parcels developmentJul 16, 2025
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants

@VeckoTheGecko@erikvansebille
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

XGrid localization - #2082

Merged
VeckoTheGecko merged 6 commits into
v4-devfrom
grid-localization
Jul 16, 2025
Merged

XGrid localization#2082
VeckoTheGecko merged 6 commits into
v4-devfrom
grid-localization

Conversation

@VeckoTheGecko

@VeckoTheGeckoVeckoTheGecko commented Jul 11, 2025

Copy link
Copy Markdown
Contributor

Currently we have a search method which returns a particle position relative to the F points. However, since our data can be defined on a staggered grid, it's important to "localize" this particle position to the grid for the array of interest. This mainly applies for C grids and when working with MITgcm and NEMO where their F points and C points are defined differently relative to each other (see diagram in docs or in #2037) .

This PR introduces this grid localization. This is really just the first draft to get feedback (code is a bit more messy than I would like - and there are no tests). This good localization will help with writing interpolators.

  • Chose the correct base branch (v4-dev for v4 changes)
  • Fixes None
  • Added tests (not yet)
  • Added documentation

@VeckoTheGecko

VeckoTheGecko commented Jul 11, 2025

Copy link
Copy Markdown
ContributorAuthor

Below is a small testing script along with output. data_c with the -1 index seems a bit strange, but I guess it makes sense (if vertical positions are defined on the cell centers and the particle is at the surface then -1 with bcoord 0.5 is expected). Thoughts @erikvansebille ?

# %%importnumpyasnpimportxarrayasxrfrompprintimportpprintfromparcelsimportxgcmfromparcels._datasets.structured.genericimportX, Y, Zfromparcels.xgridimportXGridT=2Z=Y=X=3TIME=xr.date_range("2000", "2001", T)
ds_mitgcm=xr.Dataset(
{
"data_g": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"data_c": (["time", "ZC", "YC", "XC"], np.random.rand(T, Z, Y, X)),
"U (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"V (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"U (C grid)": (["time", "ZG", "YC", "XG"], np.random.rand(T, Z, Y, X)),
"V (C grid)": (["time", "ZG", "YG", "XC"], np.random.rand(T, Z, Y, X)),
},
coords={
"XG": (
["XG"],
np.arange(0, X),
{"axis": "X", "c_grid_axis_shift": -0.5},
),
"XC": (["XC"], np.arange(0, X) +0.5, {"axis": "X"}),
"YG": (
["YG"],
np.arange(0, Y),
{"axis": "Y", "c_grid_axis_shift": -0.5},
),
"YC": (
["YC"],
np.arange(0, Y) +0.5,
{"axis": "Y"},
),
"ZG": (
["ZG"],
np.arange(Z),
{"axis": "Z", "c_grid_axis_shift": -0.5},
),
"ZC": (
["ZC"],
np.arange(Z) +0.5,
{"axis": "Z"},
),
"lon": (["XG"], np.arange(0, X)),
"lat": (["YG"], np.arange(0, Y)),
"depth": (["ZG"], np.arange(Z)),
"time": (["time"], TIME, {"axis": "T"}),
},
)
ds_nemo=xr.Dataset(
{
"data_g": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"data_c": (["time", "ZC", "YC", "XC"], np.random.rand(T, Z, Y, X)),
"U (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"V (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"U (C grid)": (["time", "ZG", "YC", "XG"], np.random.rand(T, Z, Y, X)),
"V (C grid)": (["time", "ZG", "YG", "XC"], np.random.rand(T, Z, Y, X)),
},
coords={
"XG": (
["XG"],
np.arange(0, X),
{"axis": "X", "c_grid_axis_shift": 0.5},
),
"XC": (["XC"], np.arange(0, X) -0.5, {"axis": "X"}),
"YG": (
["YG"],
np.arange(0, Y),
{"axis": "Y", "c_grid_axis_shift": 0.5},
),
"YC": (
["YC"],
np.arange(0, Y) -0.5,
{"axis": "Y"},
),
"ZG": (
["ZG"],
np.arange(Z),
{"axis": "Z", "c_grid_axis_shift": -0.5},
),
"ZC": (
["ZC"],
np.arange(Z) +0.5,
{"axis": "Z"},
),
"lon": (["XG"], np.arange(0, X)),
"lat": (["YG"], np.arange(0, Y)),
"depth": (["ZG"], np.arange(Z)),
"time": (["time"], TIME, {"axis": "T"}),
},
)
grid_mitgcm=XGrid(xgcm.Grid(ds_mitgcm, periodic=False))
grid_nemo=XGrid(xgcm.Grid(ds_nemo, periodic=False))
print("XGCM repr of MITgcm grid:")
print(grid_mitgcm.xgcm_grid)
print("\nXGCM repr of NEMO grid:")
print(grid_nemo.xgcm_grid)
# %%defshow_point_on_grid(grid, z, y, x):
"""Pretty printing of some info"""position=grid.search(z, y, x)
print(f"Position wrt. fpoints (lon/lat grid):")
pprint(position)
fordaingrid.xgcm_grid._ds.data_vars.values():
local_position=grid.localize(position, da.dims)
print(f"On {da.name=} with {da.dims=}, local position:")
pprint(local_position)
print("----Working with MITgcm grid----")
show_point_on_grid(grid_mitgcm, 0, 0.8, 0.8)
print("\n----Working with NEMO grid----")
show_point_on_grid(grid_nemo, 0, 0.8, 0.8)

output:

XGCM repr of MITgcm grid:
<parcels.Grid>
Y Axis (not periodic, boundary=None):
* center YC --> left
* left YG --> center
X Axis (not periodic, boundary=None):
* center XC --> left
* left XG --> center
Z Axis (not periodic, boundary=None):
* center ZC --> left
* left ZG --> center
T Axis (not periodic, boundary=None):
* center time
XGCM repr of NEMO grid:
<parcels.Grid>
Y Axis (not periodic, boundary=None):
* center YC --> right
* right YG --> center
X Axis (not periodic, boundary=None):
* center XC --> right
* right XG --> center
Z Axis (not periodic, boundary=None):
* center ZC --> left
* left ZG --> center
T Axis (not periodic, boundary=None):
* center time
----Working with MITgcm grid----
Position wrt. fpoints (lon/lat grid):
{'X': (np.int64(0), np.float64(0.8)),
'Y': (np.int64(0), np.float64(0.8)),
'Z': (np.int64(0), np.float64(0.0))}
On da.name='data_g' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='data_c' with da.dims=('time', 'ZC', 'YC', 'XC'), local position:
{'XC': (np.int64(0), np.float64(0.30000000000000004)),
'YC': (np.int64(0), np.float64(0.30000000000000004)),
'ZC': (np.int64(-1), np.float64(0.5))}
On da.name='U (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='U (C grid)' with da.dims=('time', 'ZG', 'YC', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YC': (np.int64(0), np.float64(0.30000000000000004)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (C grid)' with da.dims=('time', 'ZG', 'YG', 'XC'), local position:
{'XC': (np.int64(0), np.float64(0.30000000000000004)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
----Working with NEMO grid----
Position wrt. fpoints (lon/lat grid):
{'X': (np.int64(0), np.float64(0.8)),
'Y': (np.int64(0), np.float64(0.8)),
'Z': (np.int64(0), np.float64(0.0))}
On da.name='data_g' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='data_c' with da.dims=('time', 'ZC', 'YC', 'XC'), local position:
{'XC': (np.int64(1), np.float64(0.30000000000000004)),
'YC': (np.int64(1), np.float64(0.30000000000000004)),
'ZC': (np.int64(-1), np.float64(0.5))}
On da.name='U (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='U (C grid)' with da.dims=('time', 'ZG', 'YC', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YC': (np.int64(1), np.float64(0.30000000000000004)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (C grid)' with da.dims=('time', 'ZG', 'YG', 'XC'), local position:
{'XC': (np.int64(1), np.float64(0.30000000000000004)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}

@erikvansebille

Copy link
Copy Markdown
Member

Note that some datasets have an attribute c_grid_axis_shift, see for example below. If this attribute exists (but that's unfortunately not guaranteed), we can use it in this function too?

https://github.com/OceanParcels/Parcels/blob/b7bdddc4efe8024b5a2eab0025ca50eb5f8a9369/parcels/_datasets/structured/circulation_models.py#L627-L636

@VeckoTheGecko

Copy link
Copy Markdown
ContributorAuthor

Note that some datasets have an attribute c_grid_axis_shift, see for example below. If this attribute exists (but that's unfortunately not guaranteed), we can use it in this function too?

Yes, this is already taken into account by xgcm during grid ingestion to determine the nature of the grid staggering. In fact, the only difference between ds_mitgcm and ds_nemo is the value of this attribute and the corresponding offset.

@VeckoTheGecko

VeckoTheGecko commented Jul 11, 2025

Copy link
Copy Markdown
ContributorAuthor

if we're comfortable with this approach, I'll go and clear up some stuff (e.g., variable naming since it might be a bit confusing)

@VeckoTheGecko
VeckoTheGeckoforce-pushed the grid-localization branch 2 times, most recently from f3949e4 to 0d0b0e5CompareJuly 14, 2025 11:34
@VeckoTheGecko
VeckoTheGecko marked this pull request as ready for review July 14, 2025 11:35

@erikvansebilleerikvansebille left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. And how would a user use this in an interpolation method? Or will that come in a next PR?

Comment threadparcels/xgrid.py
@VeckoTheGecko

VeckoTheGecko commented Jul 15, 2025

Copy link
Copy Markdown
ContributorAuthor

I realised that this localization step isn't actually needed.

image

If a point is defined at (in X) index, bcoord = 0,0.8 wrt. the f-points, that will be at 0, 0.3 wrt. the C points. However, since the dual grid already has dimension coordinates that are offset by 0.5 (i.e., 0.5, 1.5 ...) we can just use xarray's interpolation functionality with 0.8 on this grid in order to get this point no matter the grid.

Still we need to build tooling around getting the dimension names that correspond to the axes (i.e, for da.dims == ["XC", "YC"] the grid needs to tell us "X" -> "XC" and "Y" -> "YC". I'll put up another PR

@VeckoTheGecko

Copy link
Copy Markdown
ContributorAuthor

I realised that this localization step isn't actually needed.

I realised that this is wrong actually 😅 . Localisation will be needed when writing curvilinear interpolators such as in the following being investigated in #2081 :

defXTriCurviLinear(
field: Field,
ti: int,
position: dict[_XGRID_AXES, tuple[int, float|np.ndarray]],
tau: np.float32|np.float64,
t: np.float32|np.float64,
z: np.float32|np.float64,
y: np.float32|np.float64,
x: np.float32|np.float64,
):
"""Trilinear interpolation on a curvilinear grid."""xi, xsi=position["X"]
yi, eta=position["Y"]
zi, zeta=position["Z"]
data=field.dataaxis_dim=field.grid.get_axis_dim_mapping(field.data.dims)
return (
(
(1-xsi) * (1-eta) *data.isel({axis_dim["Y"]: yi, axis_dim["X"]: xi})
+xsi* (1-eta) *data.isel({axis_dim["Y"]: yi, axis_dim["X"]: xi+1})
+xsi*eta*data.isel({axis_dim["Y"]: yi+1, axis_dim["X"]: xi+1})
+ (1-xsi) *eta*data.isel({axis_dim["Y"]: yi+1, axis_dim["X"]: xi})
)
.interp(time=t, **{axis_dim["Z"]: zi+zeta})
.values
)

Here, xsi and eta need to be adjusted to match how the data is defined (e.g., if the data is defined on the cell centers).

Perhaps there are other ways to achieve this without a localization step - but I need to get more familiar with Xarray internals and coordinate aware interpolation for that. I'll mark this as unstable API subject to change in the docstring - this is an isolated change we can remove later.

@erikvansebille any additional thoughts?

@erikvansebille

Copy link
Copy Markdown
Member

Yep agree; let's keep it in for now

@VeckoTheGecko

VeckoTheGecko commented Jul 16, 2025

Copy link
Copy Markdown
ContributorAuthor

Also wrapped into this numpydoc!=1.9.0 so that our docs build again on v4-dev (didn't bother with v3 since I assume they'll issue a fix before we need to update v3 docs again) Actually, I'll quickly backport to v3 as well

@VeckoTheGecko
VeckoTheGecko merged commit b510f11 into v4-devJul 16, 2025
@VeckoTheGecko
VeckoTheGecko deleted the grid-localization branch July 16, 2025 09:58
@github-project-automationgithub-project-automationBot moved this from Backlog to Done in Parcels developmentJul 16, 2025
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants

@VeckoTheGecko@erikvansebille
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

XGrid localization - #2082

Merged
VeckoTheGecko merged 6 commits into
v4-devfrom
grid-localization
Jul 16, 2025
Merged

XGrid localization#2082
VeckoTheGecko merged 6 commits into
v4-devfrom
grid-localization

Conversation

@VeckoTheGecko

@VeckoTheGeckoVeckoTheGecko commented Jul 11, 2025

Copy link
Copy Markdown
Contributor

Currently we have a search method which returns a particle position relative to the F points. However, since our data can be defined on a staggered grid, it's important to "localize" this particle position to the grid for the array of interest. This mainly applies for C grids and when working with MITgcm and NEMO where their F points and C points are defined differently relative to each other (see diagram in docs or in #2037) .

This PR introduces this grid localization. This is really just the first draft to get feedback (code is a bit more messy than I would like - and there are no tests). This good localization will help with writing interpolators.

  • Chose the correct base branch (v4-dev for v4 changes)
  • Fixes None
  • Added tests (not yet)
  • Added documentation

@VeckoTheGecko

VeckoTheGecko commented Jul 11, 2025

Copy link
Copy Markdown
ContributorAuthor

Below is a small testing script along with output. data_c with the -1 index seems a bit strange, but I guess it makes sense (if vertical positions are defined on the cell centers and the particle is at the surface then -1 with bcoord 0.5 is expected). Thoughts @erikvansebille ?

# %%importnumpyasnpimportxarrayasxrfrompprintimportpprintfromparcelsimportxgcmfromparcels._datasets.structured.genericimportX, Y, Zfromparcels.xgridimportXGridT=2Z=Y=X=3TIME=xr.date_range("2000", "2001", T)
ds_mitgcm=xr.Dataset(
{
"data_g": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"data_c": (["time", "ZC", "YC", "XC"], np.random.rand(T, Z, Y, X)),
"U (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"V (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"U (C grid)": (["time", "ZG", "YC", "XG"], np.random.rand(T, Z, Y, X)),
"V (C grid)": (["time", "ZG", "YG", "XC"], np.random.rand(T, Z, Y, X)),
},
coords={
"XG": (
["XG"],
np.arange(0, X),
{"axis": "X", "c_grid_axis_shift": -0.5},
),
"XC": (["XC"], np.arange(0, X) +0.5, {"axis": "X"}),
"YG": (
["YG"],
np.arange(0, Y),
{"axis": "Y", "c_grid_axis_shift": -0.5},
),
"YC": (
["YC"],
np.arange(0, Y) +0.5,
{"axis": "Y"},
),
"ZG": (
["ZG"],
np.arange(Z),
{"axis": "Z", "c_grid_axis_shift": -0.5},
),
"ZC": (
["ZC"],
np.arange(Z) +0.5,
{"axis": "Z"},
),
"lon": (["XG"], np.arange(0, X)),
"lat": (["YG"], np.arange(0, Y)),
"depth": (["ZG"], np.arange(Z)),
"time": (["time"], TIME, {"axis": "T"}),
},
)
ds_nemo=xr.Dataset(
{
"data_g": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"data_c": (["time", "ZC", "YC", "XC"], np.random.rand(T, Z, Y, X)),
"U (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"V (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"U (C grid)": (["time", "ZG", "YC", "XG"], np.random.rand(T, Z, Y, X)),
"V (C grid)": (["time", "ZG", "YG", "XC"], np.random.rand(T, Z, Y, X)),
},
coords={
"XG": (
["XG"],
np.arange(0, X),
{"axis": "X", "c_grid_axis_shift": 0.5},
),
"XC": (["XC"], np.arange(0, X) -0.5, {"axis": "X"}),
"YG": (
["YG"],
np.arange(0, Y),
{"axis": "Y", "c_grid_axis_shift": 0.5},
),
"YC": (
["YC"],
np.arange(0, Y) -0.5,
{"axis": "Y"},
),
"ZG": (
["ZG"],
np.arange(Z),
{"axis": "Z", "c_grid_axis_shift": -0.5},
),
"ZC": (
["ZC"],
np.arange(Z) +0.5,
{"axis": "Z"},
),
"lon": (["XG"], np.arange(0, X)),
"lat": (["YG"], np.arange(0, Y)),
"depth": (["ZG"], np.arange(Z)),
"time": (["time"], TIME, {"axis": "T"}),
},
)
grid_mitgcm=XGrid(xgcm.Grid(ds_mitgcm, periodic=False))
grid_nemo=XGrid(xgcm.Grid(ds_nemo, periodic=False))
print("XGCM repr of MITgcm grid:")
print(grid_mitgcm.xgcm_grid)
print("\nXGCM repr of NEMO grid:")
print(grid_nemo.xgcm_grid)
# %%defshow_point_on_grid(grid, z, y, x):
"""Pretty printing of some info"""position=grid.search(z, y, x)
print(f"Position wrt. fpoints (lon/lat grid):")
pprint(position)
fordaingrid.xgcm_grid._ds.data_vars.values():
local_position=grid.localize(position, da.dims)
print(f"On {da.name=} with {da.dims=}, local position:")
pprint(local_position)
print("----Working with MITgcm grid----")
show_point_on_grid(grid_mitgcm, 0, 0.8, 0.8)
print("\n----Working with NEMO grid----")
show_point_on_grid(grid_nemo, 0, 0.8, 0.8)

output:

XGCM repr of MITgcm grid:
<parcels.Grid>
Y Axis (not periodic, boundary=None):
* center YC --> left
* left YG --> center
X Axis (not periodic, boundary=None):
* center XC --> left
* left XG --> center
Z Axis (not periodic, boundary=None):
* center ZC --> left
* left ZG --> center
T Axis (not periodic, boundary=None):
* center time
XGCM repr of NEMO grid:
<parcels.Grid>
Y Axis (not periodic, boundary=None):
* center YC --> right
* right YG --> center
X Axis (not periodic, boundary=None):
* center XC --> right
* right XG --> center
Z Axis (not periodic, boundary=None):
* center ZC --> left
* left ZG --> center
T Axis (not periodic, boundary=None):
* center time
----Working with MITgcm grid----
Position wrt. fpoints (lon/lat grid):
{'X': (np.int64(0), np.float64(0.8)),
'Y': (np.int64(0), np.float64(0.8)),
'Z': (np.int64(0), np.float64(0.0))}
On da.name='data_g' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='data_c' with da.dims=('time', 'ZC', 'YC', 'XC'), local position:
{'XC': (np.int64(0), np.float64(0.30000000000000004)),
'YC': (np.int64(0), np.float64(0.30000000000000004)),
'ZC': (np.int64(-1), np.float64(0.5))}
On da.name='U (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='U (C grid)' with da.dims=('time', 'ZG', 'YC', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YC': (np.int64(0), np.float64(0.30000000000000004)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (C grid)' with da.dims=('time', 'ZG', 'YG', 'XC'), local position:
{'XC': (np.int64(0), np.float64(0.30000000000000004)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
----Working with NEMO grid----
Position wrt. fpoints (lon/lat grid):
{'X': (np.int64(0), np.float64(0.8)),
'Y': (np.int64(0), np.float64(0.8)),
'Z': (np.int64(0), np.float64(0.0))}
On da.name='data_g' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='data_c' with da.dims=('time', 'ZC', 'YC', 'XC'), local position:
{'XC': (np.int64(1), np.float64(0.30000000000000004)),
'YC': (np.int64(1), np.float64(0.30000000000000004)),
'ZC': (np.int64(-1), np.float64(0.5))}
On da.name='U (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='U (C grid)' with da.dims=('time', 'ZG', 'YC', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YC': (np.int64(1), np.float64(0.30000000000000004)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (C grid)' with da.dims=('time', 'ZG', 'YG', 'XC'), local position:
{'XC': (np.int64(1), np.float64(0.30000000000000004)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}

@erikvansebille

Copy link
Copy Markdown
Member

Note that some datasets have an attribute c_grid_axis_shift, see for example below. If this attribute exists (but that's unfortunately not guaranteed), we can use it in this function too?

https://github.com/OceanParcels/Parcels/blob/b7bdddc4efe8024b5a2eab0025ca50eb5f8a9369/parcels/_datasets/structured/circulation_models.py#L627-L636

@VeckoTheGecko

Copy link
Copy Markdown
ContributorAuthor

Note that some datasets have an attribute c_grid_axis_shift, see for example below. If this attribute exists (but that's unfortunately not guaranteed), we can use it in this function too?

Yes, this is already taken into account by xgcm during grid ingestion to determine the nature of the grid staggering. In fact, the only difference between ds_mitgcm and ds_nemo is the value of this attribute and the corresponding offset.

@VeckoTheGecko

VeckoTheGecko commented Jul 11, 2025

Copy link
Copy Markdown
ContributorAuthor

if we're comfortable with this approach, I'll go and clear up some stuff (e.g., variable naming since it might be a bit confusing)

@VeckoTheGecko
VeckoTheGeckoforce-pushed the grid-localization branch 2 times, most recently from f3949e4 to 0d0b0e5CompareJuly 14, 2025 11:34
@VeckoTheGecko
VeckoTheGecko marked this pull request as ready for review July 14, 2025 11:35

@erikvansebilleerikvansebille left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. And how would a user use this in an interpolation method? Or will that come in a next PR?

Comment threadparcels/xgrid.py
@VeckoTheGecko

VeckoTheGecko commented Jul 15, 2025

Copy link
Copy Markdown
ContributorAuthor

I realised that this localization step isn't actually needed.

image

If a point is defined at (in X) index, bcoord = 0,0.8 wrt. the f-points, that will be at 0, 0.3 wrt. the C points. However, since the dual grid already has dimension coordinates that are offset by 0.5 (i.e., 0.5, 1.5 ...) we can just use xarray's interpolation functionality with 0.8 on this grid in order to get this point no matter the grid.

Still we need to build tooling around getting the dimension names that correspond to the axes (i.e, for da.dims == ["XC", "YC"] the grid needs to tell us "X" -> "XC" and "Y" -> "YC". I'll put up another PR

@VeckoTheGecko

Copy link
Copy Markdown
ContributorAuthor

I realised that this localization step isn't actually needed.

I realised that this is wrong actually 😅 . Localisation will be needed when writing curvilinear interpolators such as in the following being investigated in #2081 :

defXTriCurviLinear(
field: Field,
ti: int,
position: dict[_XGRID_AXES, tuple[int, float|np.ndarray]],
tau: np.float32|np.float64,
t: np.float32|np.float64,
z: np.float32|np.float64,
y: np.float32|np.float64,
x: np.float32|np.float64,
):
"""Trilinear interpolation on a curvilinear grid."""xi, xsi=position["X"]
yi, eta=position["Y"]
zi, zeta=position["Z"]
data=field.dataaxis_dim=field.grid.get_axis_dim_mapping(field.data.dims)
return (
(
(1-xsi) * (1-eta) *data.isel({axis_dim["Y"]: yi, axis_dim["X"]: xi})
+xsi* (1-eta) *data.isel({axis_dim["Y"]: yi, axis_dim["X"]: xi+1})
+xsi*eta*data.isel({axis_dim["Y"]: yi+1, axis_dim["X"]: xi+1})
+ (1-xsi) *eta*data.isel({axis_dim["Y"]: yi+1, axis_dim["X"]: xi})
)
.interp(time=t, **{axis_dim["Z"]: zi+zeta})
.values
)

Here, xsi and eta need to be adjusted to match how the data is defined (e.g., if the data is defined on the cell centers).

Perhaps there are other ways to achieve this without a localization step - but I need to get more familiar with Xarray internals and coordinate aware interpolation for that. I'll mark this as unstable API subject to change in the docstring - this is an isolated change we can remove later.

@erikvansebille any additional thoughts?

@erikvansebille

Copy link
Copy Markdown
Member

Yep agree; let's keep it in for now

@VeckoTheGecko

VeckoTheGecko commented Jul 16, 2025

Copy link
Copy Markdown
ContributorAuthor

Also wrapped into this numpydoc!=1.9.0 so that our docs build again on v4-dev (didn't bother with v3 since I assume they'll issue a fix before we need to update v3 docs again) Actually, I'll quickly backport to v3 as well

@VeckoTheGecko
VeckoTheGecko merged commit b510f11 into v4-devJul 16, 2025
@VeckoTheGecko
VeckoTheGecko deleted the grid-localization branch July 16, 2025 09:58
@github-project-automationgithub-project-automationBot moved this from Backlog to Done in Parcels developmentJul 16, 2025
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants

@VeckoTheGecko@erikvansebille
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

XGrid localization - #2082

Merged
VeckoTheGecko merged 6 commits into
v4-devfrom
grid-localization
Jul 16, 2025
Merged

XGrid localization#2082
VeckoTheGecko merged 6 commits into
v4-devfrom
grid-localization

Conversation

@VeckoTheGecko

@VeckoTheGeckoVeckoTheGecko commented Jul 11, 2025

Copy link
Copy Markdown
Contributor

Currently we have a search method which returns a particle position relative to the F points. However, since our data can be defined on a staggered grid, it's important to "localize" this particle position to the grid for the array of interest. This mainly applies for C grids and when working with MITgcm and NEMO where their F points and C points are defined differently relative to each other (see diagram in docs or in #2037) .

This PR introduces this grid localization. This is really just the first draft to get feedback (code is a bit more messy than I would like - and there are no tests). This good localization will help with writing interpolators.

  • Chose the correct base branch (v4-dev for v4 changes)
  • Fixes None
  • Added tests (not yet)
  • Added documentation

@VeckoTheGecko

VeckoTheGecko commented Jul 11, 2025

Copy link
Copy Markdown
ContributorAuthor

Below is a small testing script along with output. data_c with the -1 index seems a bit strange, but I guess it makes sense (if vertical positions are defined on the cell centers and the particle is at the surface then -1 with bcoord 0.5 is expected). Thoughts @erikvansebille ?

# %%importnumpyasnpimportxarrayasxrfrompprintimportpprintfromparcelsimportxgcmfromparcels._datasets.structured.genericimportX, Y, Zfromparcels.xgridimportXGridT=2Z=Y=X=3TIME=xr.date_range("2000", "2001", T)
ds_mitgcm=xr.Dataset(
{
"data_g": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"data_c": (["time", "ZC", "YC", "XC"], np.random.rand(T, Z, Y, X)),
"U (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"V (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"U (C grid)": (["time", "ZG", "YC", "XG"], np.random.rand(T, Z, Y, X)),
"V (C grid)": (["time", "ZG", "YG", "XC"], np.random.rand(T, Z, Y, X)),
},
coords={
"XG": (
["XG"],
np.arange(0, X),
{"axis": "X", "c_grid_axis_shift": -0.5},
),
"XC": (["XC"], np.arange(0, X) +0.5, {"axis": "X"}),
"YG": (
["YG"],
np.arange(0, Y),
{"axis": "Y", "c_grid_axis_shift": -0.5},
),
"YC": (
["YC"],
np.arange(0, Y) +0.5,
{"axis": "Y"},
),
"ZG": (
["ZG"],
np.arange(Z),
{"axis": "Z", "c_grid_axis_shift": -0.5},
),
"ZC": (
["ZC"],
np.arange(Z) +0.5,
{"axis": "Z"},
),
"lon": (["XG"], np.arange(0, X)),
"lat": (["YG"], np.arange(0, Y)),
"depth": (["ZG"], np.arange(Z)),
"time": (["time"], TIME, {"axis": "T"}),
},
)
ds_nemo=xr.Dataset(
{
"data_g": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"data_c": (["time", "ZC", "YC", "XC"], np.random.rand(T, Z, Y, X)),
"U (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"V (A grid)": (["time", "ZG", "YG", "XG"], np.random.rand(T, Z, Y, X)),
"U (C grid)": (["time", "ZG", "YC", "XG"], np.random.rand(T, Z, Y, X)),
"V (C grid)": (["time", "ZG", "YG", "XC"], np.random.rand(T, Z, Y, X)),
},
coords={
"XG": (
["XG"],
np.arange(0, X),
{"axis": "X", "c_grid_axis_shift": 0.5},
),
"XC": (["XC"], np.arange(0, X) -0.5, {"axis": "X"}),
"YG": (
["YG"],
np.arange(0, Y),
{"axis": "Y", "c_grid_axis_shift": 0.5},
),
"YC": (
["YC"],
np.arange(0, Y) -0.5,
{"axis": "Y"},
),
"ZG": (
["ZG"],
np.arange(Z),
{"axis": "Z", "c_grid_axis_shift": -0.5},
),
"ZC": (
["ZC"],
np.arange(Z) +0.5,
{"axis": "Z"},
),
"lon": (["XG"], np.arange(0, X)),
"lat": (["YG"], np.arange(0, Y)),
"depth": (["ZG"], np.arange(Z)),
"time": (["time"], TIME, {"axis": "T"}),
},
)
grid_mitgcm=XGrid(xgcm.Grid(ds_mitgcm, periodic=False))
grid_nemo=XGrid(xgcm.Grid(ds_nemo, periodic=False))
print("XGCM repr of MITgcm grid:")
print(grid_mitgcm.xgcm_grid)
print("\nXGCM repr of NEMO grid:")
print(grid_nemo.xgcm_grid)
# %%defshow_point_on_grid(grid, z, y, x):
"""Pretty printing of some info"""position=grid.search(z, y, x)
print(f"Position wrt. fpoints (lon/lat grid):")
pprint(position)
fordaingrid.xgcm_grid._ds.data_vars.values():
local_position=grid.localize(position, da.dims)
print(f"On {da.name=} with {da.dims=}, local position:")
pprint(local_position)
print("----Working with MITgcm grid----")
show_point_on_grid(grid_mitgcm, 0, 0.8, 0.8)
print("\n----Working with NEMO grid----")
show_point_on_grid(grid_nemo, 0, 0.8, 0.8)

output:

XGCM repr of MITgcm grid:
<parcels.Grid>
Y Axis (not periodic, boundary=None):
* center YC --> left
* left YG --> center
X Axis (not periodic, boundary=None):
* center XC --> left
* left XG --> center
Z Axis (not periodic, boundary=None):
* center ZC --> left
* left ZG --> center
T Axis (not periodic, boundary=None):
* center time
XGCM repr of NEMO grid:
<parcels.Grid>
Y Axis (not periodic, boundary=None):
* center YC --> right
* right YG --> center
X Axis (not periodic, boundary=None):
* center XC --> right
* right XG --> center
Z Axis (not periodic, boundary=None):
* center ZC --> left
* left ZG --> center
T Axis (not periodic, boundary=None):
* center time
----Working with MITgcm grid----
Position wrt. fpoints (lon/lat grid):
{'X': (np.int64(0), np.float64(0.8)),
'Y': (np.int64(0), np.float64(0.8)),
'Z': (np.int64(0), np.float64(0.0))}
On da.name='data_g' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='data_c' with da.dims=('time', 'ZC', 'YC', 'XC'), local position:
{'XC': (np.int64(0), np.float64(0.30000000000000004)),
'YC': (np.int64(0), np.float64(0.30000000000000004)),
'ZC': (np.int64(-1), np.float64(0.5))}
On da.name='U (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='U (C grid)' with da.dims=('time', 'ZG', 'YC', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YC': (np.int64(0), np.float64(0.30000000000000004)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (C grid)' with da.dims=('time', 'ZG', 'YG', 'XC'), local position:
{'XC': (np.int64(0), np.float64(0.30000000000000004)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
----Working with NEMO grid----
Position wrt. fpoints (lon/lat grid):
{'X': (np.int64(0), np.float64(0.8)),
'Y': (np.int64(0), np.float64(0.8)),
'Z': (np.int64(0), np.float64(0.0))}
On da.name='data_g' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='data_c' with da.dims=('time', 'ZC', 'YC', 'XC'), local position:
{'XC': (np.int64(1), np.float64(0.30000000000000004)),
'YC': (np.int64(1), np.float64(0.30000000000000004)),
'ZC': (np.int64(-1), np.float64(0.5))}
On da.name='U (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (A grid)' with da.dims=('time', 'ZG', 'YG', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='U (C grid)' with da.dims=('time', 'ZG', 'YC', 'XG'), local position:
{'XG': (np.int64(0), np.float64(0.8)),
'YC': (np.int64(1), np.float64(0.30000000000000004)),
'ZG': (np.int64(0), np.float64(0.0))}
On da.name='V (C grid)' with da.dims=('time', 'ZG', 'YG', 'XC'), local position:
{'XC': (np.int64(1), np.float64(0.30000000000000004)),
'YG': (np.int64(0), np.float64(0.8)),
'ZG': (np.int64(0), np.float64(0.0))}

@erikvansebille

Copy link
Copy Markdown
Member

Note that some datasets have an attribute c_grid_axis_shift, see for example below. If this attribute exists (but that's unfortunately not guaranteed), we can use it in this function too?

https://github.com/OceanParcels/Parcels/blob/b7bdddc4efe8024b5a2eab0025ca50eb5f8a9369/parcels/_datasets/structured/circulation_models.py#L627-L636

@VeckoTheGecko

Copy link
Copy Markdown
ContributorAuthor

Note that some datasets have an attribute c_grid_axis_shift, see for example below. If this attribute exists (but that's unfortunately not guaranteed), we can use it in this function too?

Yes, this is already taken into account by xgcm during grid ingestion to determine the nature of the grid staggering. In fact, the only difference between ds_mitgcm and ds_nemo is the value of this attribute and the corresponding offset.

@VeckoTheGecko

VeckoTheGecko commented Jul 11, 2025

Copy link
Copy Markdown
ContributorAuthor

if we're comfortable with this approach, I'll go and clear up some stuff (e.g., variable naming since it might be a bit confusing)

@VeckoTheGecko
VeckoTheGeckoforce-pushed the grid-localization branch 2 times, most recently from f3949e4 to 0d0b0e5CompareJuly 14, 2025 11:34
@VeckoTheGecko
VeckoTheGecko marked this pull request as ready for review July 14, 2025 11:35

@erikvansebilleerikvansebille left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. And how would a user use this in an interpolation method? Or will that come in a next PR?

Comment threadparcels/xgrid.py
@VeckoTheGecko

VeckoTheGecko commented Jul 15, 2025

Copy link
Copy Markdown
ContributorAuthor

I realised that this localization step isn't actually needed.

image

If a point is defined at (in X) index, bcoord = 0,0.8 wrt. the f-points, that will be at 0, 0.3 wrt. the C points. However, since the dual grid already has dimension coordinates that are offset by 0.5 (i.e., 0.5, 1.5 ...) we can just use xarray's interpolation functionality with 0.8 on this grid in order to get this point no matter the grid.

Still we need to build tooling around getting the dimension names that correspond to the axes (i.e, for da.dims == ["XC", "YC"] the grid needs to tell us "X" -> "XC" and "Y" -> "YC". I'll put up another PR

@VeckoTheGecko

Copy link
Copy Markdown
ContributorAuthor

I realised that this localization step isn't actually needed.

I realised that this is wrong actually 😅 . Localisation will be needed when writing curvilinear interpolators such as in the following being investigated in #2081 :

defXTriCurviLinear(
field: Field,
ti: int,
position: dict[_XGRID_AXES, tuple[int, float|np.ndarray]],
tau: np.float32|np.float64,
t: np.float32|np.float64,
z: np.float32|np.float64,
y: np.float32|np.float64,
x: np.float32|np.float64,
):
"""Trilinear interpolation on a curvilinear grid."""xi, xsi=position["X"]
yi, eta=position["Y"]
zi, zeta=position["Z"]
data=field.dataaxis_dim=field.grid.get_axis_dim_mapping(field.data.dims)
return (
(
(1-xsi) * (1-eta) *data.isel({axis_dim["Y"]: yi, axis_dim["X"]: xi})
+xsi* (1-eta) *data.isel({axis_dim["Y"]: yi, axis_dim["X"]: xi+1})
+xsi*eta*data.isel({axis_dim["Y"]: yi+1, axis_dim["X"]: xi+1})
+ (1-xsi) *eta*data.isel({axis_dim["Y"]: yi+1, axis_dim["X"]: xi})
)
.interp(time=t, **{axis_dim["Z"]: zi+zeta})
.values
)

Here, xsi and eta need to be adjusted to match how the data is defined (e.g., if the data is defined on the cell centers).

Perhaps there are other ways to achieve this without a localization step - but I need to get more familiar with Xarray internals and coordinate aware interpolation for that. I'll mark this as unstable API subject to change in the docstring - this is an isolated change we can remove later.

@erikvansebille any additional thoughts?

@erikvansebille

Copy link
Copy Markdown
Member

Yep agree; let's keep it in for now

@VeckoTheGecko

VeckoTheGecko commented Jul 16, 2025

Copy link
Copy Markdown
ContributorAuthor

Also wrapped into this numpydoc!=1.9.0 so that our docs build again on v4-dev (didn't bother with v3 since I assume they'll issue a fix before we need to update v3 docs again) Actually, I'll quickly backport to v3 as well

@VeckoTheGecko
VeckoTheGecko merged commit b510f11 into v4-devJul 16, 2025
@VeckoTheGecko
VeckoTheGecko deleted the grid-localization branch July 16, 2025 09:58
@github-project-automationgithub-project-automationBot moved this from Backlog to Done in Parcels developmentJul 16, 2025
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants

@VeckoTheGecko@erikvansebille