Skip to content

Commit a605eae

Browse files
committed
o Divergence implementation, first pass
1 parent 3ca080a commit a605eae

File tree

2 files changed

+243
-0
lines changed

2 files changed

+243
-0
lines changed

test/core/test_divergence.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import numpy as np
2+
import pytest
3+
4+
import uxarray as ux
5+
import numpy.testing as nt
6+
7+
8+
class TestQuadHex:
9+
10+
def test_divergence_output_format(self, gridpath, datasetpath):
11+
"""Tests the output format of divergence functionality"""
12+
uxds = ux.open_dataset(gridpath("ugrid", "quad-hexagon", "grid.nc"), datasetpath("ugrid", "quad-hexagon", "data.nc"))
13+
14+
# Create two components for vector field (using same data for simplicity in test)
15+
u_component = uxds['t2m']
16+
v_component = uxds['t2m']
17+
18+
div_da = u_component.divergence(v_component)
19+
20+
assert isinstance(div_da, ux.UxDataArray)
21+
assert div_da.name == "divergence"
22+
assert "divergence" in div_da.attrs
23+
assert u_component.sizes == div_da.sizes
24+
25+
def test_divergence_input_validation(self, gridpath, datasetpath):
26+
"""Tests input validation for divergence method"""
27+
uxds = ux.open_dataset(gridpath("ugrid", "quad-hexagon", "grid.nc"), datasetpath("ugrid", "quad-hexagon", "data.nc"))
28+
29+
u_component = uxds['t2m']
30+
31+
# Test with non-UxDataArray
32+
with pytest.raises(TypeError, match="other must be a UxDataArray"):
33+
u_component.divergence(np.array([1, 2, 3]))
34+
35+
# Test with different grids (create a simple test case)
36+
# This would require creating another grid, so we'll skip for now
37+
38+
# Test with different dimensions
39+
# This would require creating data with different dims, so we'll skip for now
40+
41+
42+
class TestMPASOcean:
43+
44+
def test_divergence_basic(self, gridpath, datasetpath):
45+
"""Basic test of divergence computation"""
46+
uxds = ux.open_dataset(gridpath("mpas", "QU", "480", "grid.nc"), datasetpath("mpas", "QU", "480", "data.nc"))
47+
48+
# Use the same field for both components (not physically meaningful but tests the method)
49+
u_component = uxds['bottomDepth']
50+
v_component = uxds['bottomDepth']
51+
52+
div_field = u_component.divergence(v_component)
53+
54+
# Check that we get finite values where expected
55+
assert not np.isnan(div_field.values).all()
56+
assert np.isfinite(div_field.values).any()
57+
58+
59+
class TestDyamondSubset:
60+
61+
def test_divergence_constant_field(self, gridpath, datasetpath):
62+
"""Test divergence of constant vector field (should be zero)"""
63+
uxds = ux.open_dataset(
64+
gridpath("mpas", "dyamond-30km", "gradient_grid_subset.nc"),
65+
datasetpath("mpas", "dyamond-30km", "gradient_data_subset.nc")
66+
)
67+
68+
# Create constant fields
69+
constant_u = uxds['face_lat'] * 0 + 1.0 # Constant field = 1
70+
constant_v = uxds['face_lat'] * 0 + 1.0 # Constant field = 1
71+
72+
div_field = constant_u.divergence(constant_v)
73+
74+
# Divergence of constant field should be close to zero for interior faces
75+
# Boundary faces may have NaN values (which is expected)
76+
finite_values = div_field.values[np.isfinite(div_field.values)]
77+
78+
# Check that we have some finite values (interior faces)
79+
assert len(finite_values) > 0, "No finite divergence values found"
80+
81+
# Divergence of constant field should be close to zero for finite values
82+
assert np.abs(finite_values).max() < 1e-10, f"Max divergence: {np.abs(finite_values).max()}"
83+
84+
def test_divergence_linear_field(self, gridpath, datasetpath):
85+
"""Test divergence of linear vector field"""
86+
uxds = ux.open_dataset(
87+
gridpath("mpas", "dyamond-30km", "gradient_grid_subset.nc"),
88+
datasetpath("mpas", "dyamond-30km", "gradient_data_subset.nc")
89+
)
90+
91+
# Create linear fields: u = x, v = y (in spherical coordinates: u = lon, v = lat)
92+
u_component = uxds['face_lon'] # Linear in longitude
93+
v_component = uxds['face_lat'] # Linear in latitude
94+
95+
div_field = u_component.divergence(v_component)
96+
97+
# Check that we have some finite values (interior faces)
98+
finite_mask = np.isfinite(div_field.values)
99+
finite_values = div_field.values[finite_mask]
100+
101+
assert len(finite_values) > 0, "No finite divergence values found"
102+
103+
# For linear fields, divergence should be finite where computable
104+
# Boundary faces may have NaN values (which is expected)
105+
assert not np.isnan(finite_values).any(), "Found NaN in finite values"
106+
107+
def test_divergence_radial_field(self, gridpath, datasetpath):
108+
"""Test divergence of radial vector field (should be positive)"""
109+
uxds = ux.open_dataset(
110+
gridpath("mpas", "dyamond-30km", "gradient_grid_subset.nc"),
111+
datasetpath("mpas", "dyamond-30km", "gradient_data_subset.nc")
112+
)
113+
114+
# Create a radial field pointing outward from center
115+
# Use the inverse gaussian as a proxy for radial distance
116+
radial_distance = uxds['inverse_gaussian']
117+
118+
# Create components proportional to position (simplified radial field)
119+
u_component = radial_distance * uxds['face_lon']
120+
v_component = radial_distance * uxds['face_lat']
121+
122+
div_field = u_component.divergence(v_component)
123+
124+
# Check that we have some finite values (interior faces)
125+
finite_mask = np.isfinite(div_field.values)
126+
finite_values = div_field.values[finite_mask]
127+
128+
assert len(finite_values) > 0, "No finite divergence values found"
129+
130+
# Boundary faces may have NaN values (which is expected)
131+
assert not np.isnan(finite_values).any(), "Found NaN in finite values"
132+
133+
# Most finite values should be positive for an expanding field
134+
positive_values = finite_values > 0
135+
assert positive_values.sum() > len(finite_values) * 0.3 # At least 30% positive
136+
137+
def test_divergence_curl_identity(self, gridpath, datasetpath):
138+
"""Test that divergence of curl is zero (vector calculus identity)"""
139+
uxds = ux.open_dataset(
140+
gridpath("mpas", "dyamond-30km", "gradient_grid_subset.nc"),
141+
datasetpath("mpas", "dyamond-30km", "gradient_data_subset.nc")
142+
)
143+
144+
# Create a scalar potential field
145+
potential = uxds['gaussian']
146+
147+
# Compute gradient to get a conservative vector field
148+
grad = potential.gradient()
149+
u_component = grad['zonal_gradient']
150+
v_component = grad['meridional_gradient']
151+
152+
# Compute divergence of this gradient (should be the Laplacian)
153+
div_grad = u_component.divergence(v_component)
154+
155+
# Check that we have some finite values (interior faces)
156+
finite_mask = np.isfinite(div_grad.values)
157+
finite_values = div_grad.values[finite_mask]
158+
159+
assert len(finite_values) > 0, "No finite divergence values found"
160+
161+
# This tests the Laplacian computation via div(grad)
162+
# Boundary faces may have NaN values (which is expected)
163+
assert not np.isnan(finite_values).any(), "Found NaN in finite values"
164+
165+
# The Laplacian of a Gaussian should have both positive and negative values
166+
assert (finite_values > 0).any(), "No positive Laplacian values found"
167+
assert (finite_values < 0).any(), "No negative Laplacian values found"

uxarray/core/dataarray.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
_calculate_edge_node_difference,
2020
_compute_gradient,
2121
)
22+
from uxarray.core.vector_calculus import _calculate_divergence
2223
from uxarray.core.utils import _map_dims_to_ugrid
2324
from uxarray.core.zonal import _compute_non_conservative_zonal_mean
2425
from uxarray.cross_sections import UxDataArrayCrossSectionAccessor
@@ -1266,6 +1267,81 @@ def gradient(self, **kwargs) -> UxDataset:
12661267
coords=self.coords,
12671268
)
12681269

1270+
def divergence(self, other: "UxDataArray", **kwargs) -> "UxDataArray":
1271+
"""
1272+
Computes the divergence of the vector field defined by this UxDataArray and other.
1273+
1274+
Parameters
1275+
----------
1276+
other : UxDataArray
1277+
The second component of the vector field. This UxDataArray represents the first component.
1278+
**kwargs
1279+
Additional keyword arguments (reserved for future use).
1280+
1281+
Returns
1282+
-------
1283+
divergence : UxDataArray
1284+
UxDataArray containing the divergence of the vector field.
1285+
1286+
Notes
1287+
-----
1288+
The divergence is computed using the finite volume method. For a vector field V = (u, v),
1289+
where u and v are the components represented by this UxDataArray and other respectively,
1290+
the divergence is calculated as div(V) = ∂u/∂x + ∂v/∂y.
1291+
1292+
The implementation uses edge-centered gradients and face-centered divergence calculation
1293+
following the discrete divergence theorem.
1294+
1295+
Example
1296+
-------
1297+
>>> u_component = uxds["u_wind"] # First component of vector field
1298+
>>> v_component = uxds["v_wind"] # Second component of vector field
1299+
>>> div_field = u_component.divergence(v_component)
1300+
"""
1301+
from uxarray import UxDataArray
1302+
1303+
if not isinstance(other, UxDataArray):
1304+
raise TypeError("other must be a UxDataArray")
1305+
1306+
if self.uxgrid != other.uxgrid:
1307+
raise ValueError("Both UxDataArrays must have the same grid")
1308+
1309+
if self.dims != other.dims:
1310+
raise ValueError("Both UxDataArrays must have the same dimensions")
1311+
1312+
if self.ndim > 1:
1313+
raise ValueError(
1314+
"Divergence currently requires 1D face-centered data. Consider "
1315+
"reducing the dimension by selecting data across leading dimensions (e.g., `.isel(time=0)`, "
1316+
"`.sel(lev=500)`, or `.mean('time')`)."
1317+
)
1318+
1319+
if not (self._face_centered() and other._face_centered()):
1320+
raise ValueError(
1321+
"Computing the divergence is only supported for face-centered data variables."
1322+
)
1323+
1324+
# Compute gradients of both components
1325+
u_grad_zonal, u_grad_meridional = _compute_gradient(self)
1326+
v_grad_zonal, v_grad_meridional = _compute_gradient(other)
1327+
1328+
# For divergence: div(V) = ∂u/∂x + ∂v/∂y
1329+
# In spherical coordinates: div(V) = (1/cos(lat)) * ∂u/∂lon + ∂v/∂lat
1330+
# We use the zonal gradient (∂/∂lon) of u and meridional gradient (∂/∂lat) of v
1331+
divergence_values = u_grad_zonal.values + v_grad_meridional.values
1332+
1333+
# Create the divergence UxDataArray
1334+
divergence_da = UxDataArray(
1335+
data=divergence_values,
1336+
name="divergence",
1337+
dims=self.dims,
1338+
uxgrid=self.uxgrid,
1339+
attrs={"divergence": True, "units": "1/s" if "units" not in kwargs else kwargs["units"]},
1340+
coords=self.coords,
1341+
)
1342+
1343+
return divergence_da
1344+
12691345
def difference(self, destination: Optional[str] = "edge"):
12701346
"""Computes the absolute difference of a data variable.
12711347

0 commit comments

Comments
 (0)