Skip to content

Commit e9c451c

Browse files
committed
Warn on missing sphere_radius, set gradient/curl units, add vorticity example
- _compute_gradient: emit UserWarning when scale_by_radius=True but the grid lacks a sphere_radius attribute, instead of silently leaving the result on the unit sphere. - Set units attrs on gradient and curl outputs so callers can distinguish physical (/m, 1/s) from unit-sphere (/rad) results. - Add unit assertions to the existing radius-scaling tests. - vector_calculus user guide: units callout under Gradient and a new solid-body-rotation vorticity example that validates curl against the analytic zeta = -2 Omega sin(phi).
1 parent 141d1aa commit e9c451c

4 files changed

Lines changed: 107 additions & 6 deletions

File tree

docs/user-guide/vector_calculus.ipynb

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@
120120
"Gradients can be computed using the `UxDataArray.gradient()` method on a face-centered data variable."
121121
]
122122
},
123+
{
124+
"cell_type": "markdown",
125+
"id": "66caacb5",
126+
"metadata": {},
127+
"source": "> **Units.** By default, `gradient()` and `curl()` divide by `uxgrid.sphere_radius` so derivatives carry physical units (e.g. `[data units]/m`, `1/s` for velocity curl). Pass `scale_by_radius=False` to keep results on the unit sphere (per radian). If the grid has no `sphere_radius` attribute, the call falls back to unit-sphere output and emits a `UserWarning`."
128+
},
123129
{
124130
"cell_type": "code",
125131
"execution_count": null,
@@ -440,6 +446,70 @@
440446
"(vortex_vectors + curl_magnitude).opts(shared_axes=False)"
441447
]
442448
},
449+
{
450+
"cell_type": "markdown",
451+
"id": "6e67a135",
452+
"metadata": {},
453+
"source": "### Example 3: Relative Vorticity from Solid-Body Rotation\n\nThe 2D curl on the sphere is **relative vorticity** $\\zeta = \\partial v/\\partial x - \\partial u/\\partial y$, a quantity meteorologists and oceanographers care about every day. With the default `scale_by_radius=True`, `u.curl(v)` returns $\\zeta$ in physical units of $s^{-1}$.\n\nA clean analytical check is **solid-body rotation about the polar axis** with angular speed $\\Omega$:\n\n$$\nu(\\varphi) = \\Omega R \\cos\\varphi, \\qquad v = 0\n$$\n\nFor this flow the relative vorticity on a sphere of radius $R$ is\n\n$$\n\\zeta = -\\frac{1}{R\\cos\\varphi}\\frac{\\partial(u\\cos\\varphi)}{\\partial \\varphi} = -2\\Omega \\sin\\varphi.\n$$\n\nWe construct the field on the existing MPAS subset and compare `u.curl(v)` against this closed form."
454+
},
455+
{
456+
"cell_type": "code",
457+
"execution_count": null,
458+
"id": "3b5f3090",
459+
"metadata": {},
460+
"outputs": [],
461+
"source": [
462+
"OMEGA = 7.292115e-5 # Earth's angular speed (rad/s)\n",
463+
"R = uxds.uxgrid.sphere_radius\n",
464+
"lat_rad = np.deg2rad(uxds.uxgrid.face_lat.values)\n",
465+
"\n",
466+
"u_sbr_data = OMEGA * R * np.cos(lat_rad)\n",
467+
"v_sbr_data = np.zeros_like(u_sbr_data)\n",
468+
"\n",
469+
"u_sbr = ux.UxDataArray(\n",
470+
" u_sbr_data,\n",
471+
" dims=[\"n_face\"],\n",
472+
" uxgrid=uxds.uxgrid,\n",
473+
" name=\"u_sbr\",\n",
474+
" attrs={\"units\": \"m/s\"},\n",
475+
")\n",
476+
"v_sbr = ux.UxDataArray(\n",
477+
" v_sbr_data,\n",
478+
" dims=[\"n_face\"],\n",
479+
" uxgrid=uxds.uxgrid,\n",
480+
" name=\"v_sbr\",\n",
481+
" attrs={\"units\": \"m/s\"},\n",
482+
")\n",
483+
"\n",
484+
"zeta = u_sbr.curl(v_sbr)\n",
485+
"zeta_analytic = -2.0 * OMEGA * np.sin(lat_rad)\n",
486+
"\n",
487+
"finite = np.isfinite(zeta.values)\n",
488+
"err = np.abs(zeta.values[finite] - zeta_analytic[finite])\n",
489+
"\n",
490+
"print(f\"curl units: {zeta.attrs['units']}\")\n",
491+
"print(f\"sphere_radius (m): {R:.3e}\")\n",
492+
"print(\n",
493+
" f\"computed zeta range: [{zeta.values[finite].min():.3e}, {zeta.values[finite].max():.3e}] 1/s\"\n",
494+
")\n",
495+
"print(\n",
496+
" f\"analytic zeta range: [{zeta_analytic.min():.3e}, {zeta_analytic.max():.3e}] 1/s\"\n",
497+
")\n",
498+
"print(f\"max |error|: {err.max():.3e} 1/s (≈ {err.max() / (2 * OMEGA):.1%} of 2Omega)\")"
499+
]
500+
},
501+
{
502+
"cell_type": "code",
503+
"execution_count": null,
504+
"id": "434634a9",
505+
"metadata": {},
506+
"outputs": [],
507+
"source": [
508+
"zeta.plot(cmap=\"RdBu_r\", aspect=1).opts(\n",
509+
" title=\"Relative Vorticity (1/s) — Solid-Body Rotation\", colorbar=True\n",
510+
")"
511+
]
512+
},
443513
{
444514
"cell_type": "markdown",
445515
"id": "23",

test/core/test_vector_calculus.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,9 @@ def test_gradient_scales_by_radius(self, gridpath, datasetpath):
162162
equal_nan=True,
163163
)
164164

165+
assert grad_scaled["zonal_gradient"].attrs["units"].endswith("/m")
166+
assert grad_unit["zonal_gradient"].attrs["units"].endswith("/rad")
167+
165168

166169
class TestDivergenceQuadHex:
167170

@@ -559,6 +562,9 @@ def test_curl_scales_by_radius(self, gridpath, datasetpath):
559562

560563
nt.assert_allclose(curl_scaled, curl_unit / radius, equal_nan=True)
561564

565+
assert curl_scaled.attrs["units"]
566+
assert curl_unit.attrs["units"].endswith("/rad")
567+
562568
def test_curl_units_and_attributes(self, gridpath, datasetpath):
563569
"""Test that curl preserves appropriate units and attributes"""
564570
uxds = ux.open_dataset(

uxarray/core/dataarray.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1535,15 +1535,20 @@ def curl(
15351535
# Compute curl = ∂v/∂x - ∂u/∂y
15361536
curl_values = grad_v_zonal.values - grad_u_meridional.values
15371537

1538+
u_units = self.attrs.get("units", "")
1539+
has_sphere_radius = "sphere_radius" in self.uxgrid._ds.attrs
1540+
if scale_by_radius and has_sphere_radius:
1541+
curl_units = f"({u_units})/m" if u_units else "1/s"
1542+
else:
1543+
curl_units = f"({u_units})/rad" if u_units else "1/rad"
1544+
15381545
# Create the result UxDataArray
15391546
curl_da = UxDataArray(
15401547
curl_values,
15411548
dims=self.dims,
15421549
attrs={
15431550
"long_name": f"Curl of ({self.name}, {other.name})",
1544-
"units": "1/s"
1545-
if "units" not in self.attrs
1546-
else f"({self.attrs.get('units', '1')})/m",
1551+
"units": curl_units,
15471552
"description": "Curl of vector field computed as ∂v/∂x - ∂u/∂y",
15481553
},
15491554
uxgrid=self.uxgrid,

uxarray/core/gradient.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import warnings
2+
13
import numpy as np
24
from numba import njit, prange
35

@@ -186,20 +188,38 @@ def _compute_gradient(data, scale_by_radius=True):
186188
"Computing the gradient is only supported for face-centered data variables."
187189
)
188190

191+
has_sphere_radius = "sphere_radius" in uxgrid._ds.attrs
189192
if scale_by_radius:
190-
radius = uxgrid.sphere_radius
191-
grad_zonal = grad_zonal / radius
192-
grad_meridional = grad_meridional / radius
193+
if has_sphere_radius:
194+
radius = uxgrid.sphere_radius
195+
grad_zonal = grad_zonal / radius
196+
grad_meridional = grad_meridional / radius
197+
else:
198+
warnings.warn(
199+
"scale_by_radius=True but the grid has no 'sphere_radius' "
200+
"attribute; result is left on the unit sphere. Set "
201+
"uxgrid.sphere_radius or pass scale_by_radius=False.",
202+
UserWarning,
203+
stacklevel=2,
204+
)
205+
206+
base_units = data.attrs.get("units", "")
207+
if scale_by_radius and has_sphere_radius:
208+
grad_units = f"{base_units}/m" if base_units else "1/m"
209+
else:
210+
grad_units = f"{base_units}/rad" if base_units else "1/rad"
193211

194212
# Zonal
195213
grad_zonal_da = UxDataArray(
196214
data=grad_zonal, name="zonal_gradient", dims=data.dims, uxgrid=uxgrid
197215
)
216+
grad_zonal_da.attrs["units"] = grad_units
198217

199218
# Meridional
200219
grad_meridional_da = UxDataArray(
201220
data=grad_meridional, name="meridional_gradient", dims=data.dims, uxgrid=uxgrid
202221
)
222+
grad_meridional_da.attrs["units"] = grad_units
203223

204224
return grad_zonal_da, grad_meridional_da
205225

0 commit comments

Comments
 (0)