From 85ec085765e7dadbb4701c355d76a9280e36a5e6 Mon Sep 17 00:00:00 2001 From: Rajeev Jain Date: Mon, 10 Aug 2026 13:58:16 -0500 Subject: [PATCH] Normalize the gradient by the dual cell and add the spherical metric terms Green-Gauss on a contour must divide by the area that contour encloses. The face gradient integrates around the dual cell but divided by the primal face area, inflating results by A_dual/A_primal: about 4 on quadrilateral meshes and 3 on hexagonal ones. Refining healpix z3 to z6 converged to 3.9278, 3.9811, 3.9952, 3.9988, so this was a normalization error rather than truncation. Curl and divergence used the planar formulas and dropped the u*tan(lat)/a and -v*tan(lat)/a metric terms. On solid-body rotation that costs exactly a factor of two, which partly cancelled the area inflation and made curl look like a clean 2x on quads but 1.5x on hexagons. Both corrections have to land together: fixing the area alone drives curl to 0.5. Faces whose contour is left open by a missing edge neighbor now return NaN, since no enclosed area exists to divide by. Interior faces of SCRIP-derived grids stay finite, so #1452 remains fixed. Adds manufactured-solution tests with non-zero closed-form answers. The previous suite relied on null tests and ordering comparisons, both of which are satisfied by an operator that is off by a constant factor. Fixes #1662 --- test/core/test_vector_calculus.py | 191 +++++++++++++++++++++++++++--- uxarray/core/dataarray.py | 27 ++++- uxarray/core/gradient.py | 100 +++++++++++++--- 3 files changed, 281 insertions(+), 37 deletions(-) diff --git a/test/core/test_vector_calculus.py b/test/core/test_vector_calculus.py index aad4c78bc..a3b9dc1f6 100644 --- a/test/core/test_vector_calculus.py +++ b/test/core/test_vector_calculus.py @@ -25,15 +25,31 @@ def test_gradient_output_format(self, gridpath, datasetpath): def test_gradient_all_boundary_faces(self, gridpath, datasetpath): """Quad hexagon grid has 4 faces, all touching the boundary. - Each face still has some interior edges, so the gradient should - produce finite (small) values rather than NaN. + Green-Gauss needs a closed contour to divide by the area it + encloses. Every face here sits on the mesh boundary, so no closed + contour exists and NaN is the honest answer rather than a value + normalized by an area that was never integrated over. """ uxds = ux.open_dataset(gridpath("ugrid", "quad-hexagon", "grid.nc"), datasetpath("ugrid", "quad-hexagon", "data.nc")) grad = uxds['t2m'].gradient() - assert not np.isnan(grad['meridional_gradient']).any() - assert not np.isnan(grad['zonal_gradient']).any() + assert np.isnan(grad['meridional_gradient']).all() + assert np.isnan(grad['zonal_gradient']).all() + + def test_gradient_partial_boundary_still_finite(self, gridpath): + """Interior faces of a SCRIP-derived grid keep finite gradients. + + Regression guard for #1452: the boundary handling must not go back + to NaN-ing an entire grid. + """ + uxgrid = ux.open_grid(gridpath("scrip", "ne30pg2", "grid.nc")) + lat = np.deg2rad(uxgrid.face_lat.values) + phi = ux.UxDataArray(np.sin(lat), dims=["n_face"], uxgrid=uxgrid, name="phi") + + grad = phi.gradient(scale_by_radius=False) + + assert np.isfinite(grad["meridional_gradient"].values).mean() > 0.95 class TestGradientMPASOcean: @@ -297,7 +313,12 @@ def test_scalardotgradient_rejects_misaligned_indexes(self, gridpath, datasetpat class TestDivergenceDyamondSubset: def test_divergence_constant_field(self, gridpath, datasetpath): - """Test divergence of constant vector field (should be zero)""" + """Divergence of a constant vector field reduces to the metric term. + + On a plane this would be zero, but on the sphere the divergence + carries -v*tan(lat)/a, so a constant v = 1 leaves exactly + -tan(lat)/a behind. + """ uxds = ux.open_dataset( gridpath("mpas", "dyamond-30km", "gradient_grid_subset.nc"), datasetpath("mpas", "dyamond-30km", "gradient_data_subset.nc") @@ -309,15 +330,16 @@ def test_divergence_constant_field(self, gridpath, datasetpath): div_field = constant_u.divergence(constant_v) - # Divergence of constant field should be close to zero for interior faces # Boundary faces may have NaN values (which is expected) - finite_values = div_field.values[np.isfinite(div_field.values)] + finite = np.isfinite(div_field.values) + assert finite.any(), "No finite divergence values found" - # Check that we have some finite values (interior faces) - assert len(finite_values) > 0, "No finite divergence values found" + radius = uxds.uxgrid._ds.attrs["sphere_radius"] + expected = -np.tan(np.deg2rad(uxds.uxgrid.face_lat.values)) / radius - # Divergence of constant field should be close to zero for finite values - assert np.abs(finite_values).max() < 1e-10, f"Max divergence: {np.abs(finite_values).max()}" + nt.assert_allclose( + div_field.values[finite], expected[finite], rtol=1e-10, atol=1e-15 + ) def test_divergence_linear_field(self, gridpath, datasetpath): """Test divergence of linear vector field""" @@ -485,7 +507,11 @@ def test_curl_basic(self, gridpath, datasetpath): class TestCurlDyamondSubset: def test_curl_constant_field(self, gridpath, datasetpath): - """Test curl of constant vector field (should be zero)""" + """Curl of a constant vector field reduces to the metric term. + + On a plane this would be zero, but on the sphere the curl carries + u*tan(lat)/a, so a constant u = 1 leaves exactly tan(lat)/a behind. + """ uxds = ux.open_dataset( gridpath("mpas", "dyamond-30km", "gradient_grid_subset.nc"), datasetpath("mpas", "dyamond-30km", "gradient_data_subset.nc") @@ -497,15 +523,16 @@ def test_curl_constant_field(self, gridpath, datasetpath): curl_field = constant_u.curl(constant_v) - # Curl of constant field should be close to zero for interior faces # Boundary faces may have NaN values (which is expected) - finite_values = curl_field.values[np.isfinite(curl_field.values)] + finite = np.isfinite(curl_field.values) + assert finite.any(), "No finite curl values found" - # Check that we have some finite values (interior faces) - assert len(finite_values) > 0, "No finite curl values found" + radius = uxds.uxgrid._ds.attrs["sphere_radius"] + expected = np.tan(np.deg2rad(uxds.uxgrid.face_lat.values)) / radius - # Curl of constant field should be close to zero for finite values - assert np.abs(finite_values).max() < 1e-10, f"Max curl: {np.abs(finite_values).max()}" + nt.assert_allclose( + curl_field.values[finite], expected[finite], rtol=1e-10, atol=1e-15 + ) def test_curl_linear_field(self, gridpath, datasetpath): """Test curl of linear vector field""" @@ -686,7 +713,7 @@ def test_curl_units_and_attributes(self, gridpath, datasetpath): # Check attributes assert "long_name" in curl_field.attrs assert "description" in curl_field.attrs - assert curl_field.attrs["description"] == "Curl of vector field computed as ∂v/∂x - ∂u/∂y" + assert curl_field.attrs["description"] == "Curl of vector field computed as ∂v/∂x - ∂u/∂y + u·tan(φ)/a" # Check name expected_name = f"curl_{u_component.name}_{v_component.name}" @@ -694,3 +721,129 @@ def test_curl_units_and_attributes(self, gridpath, datasetpath): # Check that grid is preserved assert curl_field.uxgrid == u_component.uxgrid + + +class TestSphericalManufacturedSolutions: + """Amplitude checks against closed-form answers on the unit sphere. + + The existing suite leans on null tests (constant fields, curl of a + gradient) and on sign/ordering comparisons. Those stay satisfied when an + operator is off by a constant factor, which is how the dual/primal area + mismatch and the missing metric terms survived. Each test below has a + non-zero exact answer, so a scale error fails immediately. + """ + + # Away from the poles, where tan(lat) blows up and the finite-volume + # stencil degrades. + MIDLAT = np.deg2rad(60) + + @staticmethod + def _grids(): + yield "healpix_z5_quad", ux.Grid.from_healpix(zoom=5) + + def test_gradient_amplitude(self): + """grad of sin(lat) has meridional component cos(lat), zonal zero.""" + for label, uxgrid in self._grids(): + lat = np.deg2rad(uxgrid.face_lat.values) + interior = np.abs(lat) < self.MIDLAT + + phi = ux.UxDataArray( + np.sin(lat), dims=["n_face"], uxgrid=uxgrid, name="phi" + ) + grad = phi.gradient(scale_by_radius=False) + + with np.errstate(divide="ignore", invalid="ignore"): + ratio = grad["meridional_gradient"].values / np.cos(lat) + sel = interior & np.isfinite(ratio) + assert np.abs(np.median(ratio[sel]) - 1.0) < 0.01, label + + def test_curl_solid_body_rotation(self): + """u = cos(lat), v = 0 has relative vorticity 2*sin(lat).""" + for label, uxgrid in self._grids(): + lat = np.deg2rad(uxgrid.face_lat.values) + interior = np.abs(lat) < self.MIDLAT + + u = ux.UxDataArray( + np.cos(lat), dims=["n_face"], uxgrid=uxgrid, name="u" + ) + v = ux.UxDataArray( + np.zeros_like(lat), dims=["n_face"], uxgrid=uxgrid, name="v" + ) + + with np.errstate(divide="ignore", invalid="ignore"): + ratio = u.curl(v, scale_by_radius=False).values / ( + 2 * np.sin(lat) + ) + sel = interior & np.isfinite(ratio) + assert np.abs(np.median(ratio[sel]) - 1.0) < 0.01, label + + def test_divergence_amplitude(self): + """u = 0, v = cos(lat) has divergence -2*sin(lat).""" + for label, uxgrid in self._grids(): + lat = np.deg2rad(uxgrid.face_lat.values) + interior = np.abs(lat) < self.MIDLAT + + u = ux.UxDataArray( + np.zeros_like(lat), dims=["n_face"], uxgrid=uxgrid, name="u" + ) + v = ux.UxDataArray( + np.cos(lat), dims=["n_face"], uxgrid=uxgrid, name="v" + ) + + with np.errstate(divide="ignore", invalid="ignore"): + ratio = u.divergence(v, scale_by_radius=False).values / ( + -2 * np.sin(lat) + ) + sel = interior & np.isfinite(ratio) + assert np.abs(np.median(ratio[sel]) - 1.0) < 0.01, label + + def test_gradient_converges_under_refinement(self): + """The error shrinks with resolution instead of sitting at a factor. + + This is the check that separates a normalization bug from truncation + error: before the fix the ratio converged to 4.0 on quads. + """ + errors = [] + for zoom in (4, 5, 6): + uxgrid = ux.Grid.from_healpix(zoom=zoom) + lat = np.deg2rad(uxgrid.face_lat.values) + interior = np.abs(lat) < self.MIDLAT + + phi = ux.UxDataArray( + np.sin(lat), dims=["n_face"], uxgrid=uxgrid, name="phi" + ) + grad = phi.gradient(scale_by_radius=False) + + with np.errstate(divide="ignore", invalid="ignore"): + ratio = grad["meridional_gradient"].values / np.cos(lat) + sel = interior & np.isfinite(ratio) + errors.append(abs(np.median(ratio[sel]) - 1.0)) + + assert errors[1] < errors[0] + assert errors[2] < errors[1] + + def test_hexagonal_matches_quadrilateral(self, gridpath): + """The answer must not depend on cell topology. + + The dual/primal ratio was ~4 on quads and ~3 on hexagons, so an + inflated gradient showed up as a topology-dependent answer. + """ + results = {} + for label, uxgrid in ( + ("quad", ux.Grid.from_healpix(zoom=5)), + ("hex", ux.open_grid(gridpath("mpas", "QU", "480", "grid.nc"))), + ): + lat = np.deg2rad(uxgrid.face_lat.values) + interior = np.abs(lat) < self.MIDLAT + + phi = ux.UxDataArray( + np.sin(lat), dims=["n_face"], uxgrid=uxgrid, name="phi" + ) + grad = phi.gradient(scale_by_radius=False) + + with np.errstate(divide="ignore", invalid="ignore"): + ratio = grad["meridional_gradient"].values / np.cos(lat) + sel = interior & np.isfinite(ratio) + results[label] = np.median(ratio[sel]) + + assert abs(results["quad"] - results["hex"]) < 0.02 diff --git a/uxarray/core/dataarray.py b/uxarray/core/dataarray.py index 28fa22886..a58282501 100644 --- a/uxarray/core/dataarray.py +++ b/uxarray/core/dataarray.py @@ -1663,8 +1663,17 @@ def curl( other, scale_by_radius=scale_by_radius ) - # Compute curl = ∂v/∂x - ∂u/∂y - curl_values = grad_v_zonal.values - grad_u_meridional.values + # Compute curl = ∂v/∂x - ∂u/∂y + u·tan(φ)/a + # + # The trailing term is the spherical metric term. Dropping it is only + # valid on a plane; on the sphere it costs a factor of two on + # solid-body rotation. When the derivatives have been divided by the + # radius the term carries the same 1/a factor. + tan_lat = np.tan(np.deg2rad(self.uxgrid.face_lat.values)) + metric = self.values * tan_lat + if scale_by_radius and "sphere_radius" in self.uxgrid._ds.attrs: + metric = metric / self.uxgrid._ds.attrs["sphere_radius"] + curl_values = grad_v_zonal.values - grad_u_meridional.values + metric u_units = self.attrs.get("units", "") has_sphere_radius = "sphere_radius" in self.uxgrid._ds.attrs @@ -1680,7 +1689,9 @@ def curl( attrs={ "long_name": f"Curl of ({self.name}, {other.name})", "units": curl_units, - "description": "Curl of vector field computed as ∂v/∂x - ∂u/∂y", + "description": ( + "Curl of vector field computed as ∂v/∂x - ∂u/∂y + u·tan(φ)/a" + ), }, uxgrid=self.uxgrid, name=f"curl_{self.name}_{other.name}", @@ -1756,7 +1767,7 @@ def divergence( u_gradient = self.gradient(scale_by_radius=scale_by_radius) v_gradient = other.gradient(scale_by_radius=scale_by_radius) - # For divergence: div(V) = ∂u/∂x + ∂v/∂y + # For divergence: div(V) = ∂u/∂x + ∂v/∂y - v·tan(φ)/a # We use the zonal gradient (∂/∂lon) of u and meridional gradient (∂/∂lat) of v u = u_gradient["zonal_gradient"] v = v_gradient["meridional_gradient"] @@ -1764,6 +1775,14 @@ def divergence( # Align DataArrays to ensure coords/dims match, then perform xarray-aware addition u, v = xr.align(u, v) divergence = u + v + + # Spherical metric term, the companion of the one in curl(). Omitting + # it is only valid on a plane. + tan_lat = np.tan(np.deg2rad(self.uxgrid.face_lat.values)) + metric = other.values * tan_lat + if scale_by_radius and "sphere_radius" in self.uxgrid._ds.attrs: + metric = metric / self.uxgrid._ds.attrs["sphere_radius"] + divergence = divergence - metric divergence.name = "divergence" # Infer units consistently with gradient()/curl(): a divergence is a diff --git a/uxarray/core/gradient.py b/uxarray/core/gradient.py index 30e2537d1..a1d385bc0 100644 --- a/uxarray/core/gradient.py +++ b/uxarray/core/gradient.py @@ -226,15 +226,57 @@ def _compute_gradient(data, scale_by_radius=True): @njit(cache=True) -def _normalize_and_project_gradient( - gradient, index, normal_lat, normal_lon, node_coords, node_neighbors -): - area, _ = calculate_face_area( - node_coords[0, node_neighbors].astype(np.float64), - node_coords[1, node_neighbors].astype(np.float64), - node_coords[2, node_neighbors].astype(np.float64), - ) +def _dual_cell_area(stencil_coords): + """Spherical area of the contour the Green-Gauss loop integrates around. + + ``stencil_coords`` holds the (3, n) Cartesian centroids of the faces that + form the contour. They arrive in connectivity order, which is not + necessarily the order in which they trace the polygon, so they are sorted + by azimuth about the contour centroid before the area is measured. + """ + n = stencil_coords.shape[1] + + # Contour centroid, used as the pole of the local azimuthal sort. + cx = np.sum(stencil_coords[0]) / n + cy = np.sum(stencil_coords[1]) / n + cz = np.sum(stencil_coords[2]) / n + cnorm = np.sqrt(cx * cx + cy * cy + cz * cz) + cx, cy, cz = cx / cnorm, cy / cnorm, cz / cnorm + + # Build a local tangent basis at the centroid. + if np.abs(cz) < 0.9: + ax, ay, az = 0.0, 0.0, 1.0 + else: + ax, ay, az = 1.0, 0.0, 0.0 + ex = ay * cz - az * cy + ey = az * cx - ax * cz + ez = ax * cy - ay * cx + enorm = np.sqrt(ex * ex + ey * ey + ez * ez) + ex, ey, ez = ex / enorm, ey / enorm, ez / enorm + fx = cy * ez - cz * ey + fy = cz * ex - cx * ez + fz = cx * ey - cy * ex + + angles = np.empty(n) + for i in range(n): + px = stencil_coords[0, i] + py = stencil_coords[1, i] + pz = stencil_coords[2, i] + angles[i] = np.arctan2(px * fx + py * fy + pz * fz, px * ex + py * ey + pz * ez) + + order = np.argsort(angles) + sorted_coords = np.empty((3, n)) + for i in range(n): + sorted_coords[0, i] = stencil_coords[0, order[i]] + sorted_coords[1, i] = stencil_coords[1, order[i]] + sorted_coords[2, i] = stencil_coords[2, order[i]] + + area, _ = calculate_face_area(sorted_coords[0], sorted_coords[1], sorted_coords[2]) + return area + +@njit(cache=True) +def _normalize_and_project_gradient(gradient, index, normal_lat, normal_lon, area): gradient = gradient / area # projection to horizontal gradient @@ -298,6 +340,17 @@ def _compute_gradients_on_faces( gradient = np.zeros(3) has_contribution = False + # Centroids of the faces forming the contour, collected as the loop + # walks it so the normalizing area matches the region integrated over. + max_stencil = face_node_connectivity.shape[1] * node_edge_connectivity.shape[1] + stencil = np.empty(max_stencil, dtype=np.int64) + n_stencil = 0 + + # Green-Gauss only applies to a closed contour. If any edge in this + # face's node neighborhood is missing a second face, the contour is + # open and no enclosed area exists. + contour_closed = True + for node_idx in face_node_connectivity[face_idx]: # take each node on that face if node_idx != INT_FILL_VALUE: for edge_idx in node_edge_connectivity[ @@ -310,6 +363,7 @@ def _compute_gradients_on_faces( # spurious INT_FILL_VALUE entries (e.g. SCRIP- # derived SE grids like ne120np4). See #1452. if INT_FILL_VALUE in edge_face_connectivity[edge_idx]: + contour_closed = False continue if ( @@ -347,16 +401,34 @@ def _compute_gradients_on_faces( ) has_contribution = True - if not has_contribution: - gradient = np.full(3, np.nan) - - node_neighbors = face_node_connectivity[face_idx] - node_neighbors = node_neighbors[node_neighbors != INT_FILL_VALUE] + for cand in (face1_idx, face2_idx): + seen = False + for s in range(n_stencil): + if stencil[s] == cand: + seen = True + break + if not seen: + stencil[n_stencil] = cand + n_stencil += 1 + + # The contour must be closed, and a polygon, before it encloses an area. + if not has_contribution or not contour_closed or n_stencil < 3: + gradients_faces[face_idx, 0] = np.nan + gradients_faces[face_idx, 1] = np.nan + continue + + stencil_coords = np.empty((3, n_stencil)) + for s in range(n_stencil): + stencil_coords[0, s] = face_coords[stencil[s], 0] + stencil_coords[1, s] = face_coords[stencil[s], 1] + stencil_coords[2, s] = face_coords[stencil[s], 2] + + area = _dual_cell_area(stencil_coords) # Normalize and project zonal and meridional components and store the result for the current face gradients_faces[face_idx, 0], gradients_faces[face_idx, 1] = ( _normalize_and_project_gradient( - gradient, face_idx, normal_lat, normal_lon, node_coords, node_neighbors + gradient, face_idx, normal_lat, normal_lon, area ) )