Skip to content

Commit 9dec036

Browse files
committed
o Enhance dual mesh construction with comprehensive testing and stability improvements
1 parent aa76ac3 commit 9dec036

2 files changed

Lines changed: 174 additions & 21 deletions

File tree

test/test_grid.py

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
from uxarray.grid.coordinates import _populate_node_latlon, _lonlat_rad_to_xyz, _xyz_to_lonlat_rad_scalar
1717

18-
from uxarray.constants import INT_FILL_VALUE, ERROR_TOLERANCE
18+
from uxarray.constants import INT_FILL_VALUE, ERROR_TOLERANCE, INT_DTYPE
1919

2020
from uxarray.grid.arcs import extreme_gca_latitude
2121

@@ -748,6 +748,139 @@ def test_dual_duplicate():
748748
dataset.get_dual()
749749

750750

751+
def test_dual_mesh_parallel():
752+
"""Test dual mesh construction with parallel processing enabled."""
753+
import numba
754+
from uxarray.grid.dual import construct_faces
755+
756+
# Test with a simple grid
757+
grid = ux.open_grid(gridfile_mpas, use_dual=False)
758+
759+
# Get the inputs for construct_faces
760+
dual_node_x = grid.face_x.values
761+
dual_node_y = grid.face_y.values
762+
dual_node_z = grid.face_z.values
763+
node_x = grid.node_x.values
764+
node_y = grid.node_y.values
765+
node_z = grid.node_z.values
766+
node_face_connectivity = grid.node_face_connectivity.values
767+
768+
# Get an array with the number of edges for each face
769+
n_edges_mask = node_face_connectivity != INT_FILL_VALUE
770+
n_edges = np.sum(n_edges_mask, axis=1)
771+
max_edges = len(node_face_connectivity[0])
772+
773+
valid_node_indices = np.where(n_edges >= 3)[0]
774+
construct_node_face_connectivity = np.full(
775+
(len(valid_node_indices), max_edges), INT_FILL_VALUE, dtype=INT_DTYPE
776+
)
777+
778+
# Test that construct_faces works (this tests the numba compilation)
779+
try:
780+
result = construct_faces(
781+
valid_node_indices,
782+
n_edges,
783+
dual_node_x,
784+
dual_node_y,
785+
dual_node_z,
786+
node_face_connectivity,
787+
node_x,
788+
node_y,
789+
node_z,
790+
construct_node_face_connectivity,
791+
max_edges,
792+
)
793+
794+
# Verify the result has the expected shape and properties
795+
assert result is not None
796+
assert result.dtype == INT_DTYPE
797+
assert result.ndim == 2
798+
799+
# Compare with the standard dual construction
800+
dual = grid.get_dual()
801+
expected_shape = dual.face_node_connectivity.values.shape
802+
803+
# The shapes should match since we're using the same algorithm
804+
assert result.shape == expected_shape
805+
806+
except numba.errors.NumbaError as e:
807+
pytest.skip(f"Numba compilation failed: {e}")
808+
except Exception as e:
809+
pytest.fail(f"Dual mesh parallel construction failed: {e}")
810+
811+
812+
def test_dual_mesh_parallel_validation():
813+
"""Test that parallel dual construction produces identical results and validates threading."""
814+
import time
815+
from uxarray.grid.dual import construct_faces
816+
817+
grid = ux.open_grid(gridfile_mpas, use_dual=False)
818+
819+
# Prepare inputs for direct function call
820+
dual_node_x = grid.face_x.values
821+
dual_node_y = grid.face_y.values
822+
dual_node_z = grid.face_z.values
823+
node_x = grid.node_x.values
824+
node_y = grid.node_y.values
825+
node_z = grid.node_z.values
826+
node_face_connectivity = grid.node_face_connectivity.values
827+
828+
n_edges_mask = node_face_connectivity != INT_FILL_VALUE
829+
n_edges = np.sum(n_edges_mask, axis=1)
830+
max_edges = node_face_connectivity.shape[1]
831+
832+
valid_node_indices = np.where(n_edges >= 3)[0]
833+
834+
# Test multiple runs for consistency
835+
results = []
836+
times = []
837+
838+
for i in range(3):
839+
construct_node_face_connectivity = np.full(
840+
(len(valid_node_indices), max_edges), INT_FILL_VALUE, dtype=INT_DTYPE
841+
)
842+
843+
start_time = time.time()
844+
result = construct_faces(
845+
valid_node_indices,
846+
n_edges,
847+
dual_node_x,
848+
dual_node_y,
849+
dual_node_z,
850+
node_face_connectivity,
851+
node_x,
852+
node_y,
853+
node_z,
854+
construct_node_face_connectivity,
855+
max_edges,
856+
)
857+
elapsed = time.time() - start_time
858+
859+
results.append(result)
860+
times.append(elapsed)
861+
862+
# Verify all results are identical (parallel consistency)
863+
for i in range(1, len(results)):
864+
nt.assert_array_equal(results[0], results[i],
865+
err_msg=f"Parallel dual construction run {i} differs from run 0")
866+
867+
# Verify results are valid
868+
for result in results:
869+
assert result is not None
870+
assert result.dtype == INT_DTYPE
871+
assert result.ndim == 2
872+
assert result.shape[0] == len(valid_node_indices)
873+
assert result.shape[1] == max_edges
874+
875+
# Verify performance is reasonable (should complete in reasonable time)
876+
avg_time = np.mean(times)
877+
assert avg_time < 30.0, f"Dual construction too slow: {avg_time:.2f}s"
878+
879+
# Verify against standard implementation
880+
dual_standard = grid.get_dual()
881+
assert results[0].shape == dual_standard.face_node_connectivity.values.shape
882+
883+
751884
def test_normalize_existing_coordinates_non_norm_initial():
752885
gridfile_mpas = current_path / "meshfiles" / "mpas" / "QU" / "mesh.QU.1920km.151026.nc"
753886
from uxarray.grid.validation import _check_normalization

uxarray/grid/dual.py

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@ def construct_dual(grid):
3434
# Get an array with the number of edges for each face
3535
n_edges_mask = node_face_connectivity != INT_FILL_VALUE
3636
n_edges = np.sum(n_edges_mask, axis=1)
37-
max_edges = len(node_face_connectivity[0])
37+
max_edges = node_face_connectivity.shape[1]
3838

39+
# Only nodes with 3+ edges can form valid dual faces
3940
valid_node_indices = np.where(n_edges >= 3)[0]
4041

4142
construct_node_face_connectivity = np.full(
@@ -80,25 +81,25 @@ def construct_faces(
8081
Parameters
8182
----------
8283
valid_node_indices: np.ndarray
83-
number of valid nodes in the primal mesh
84+
Array of node indices with at least 3 connections in the primal mesh
8485
n_edges: np.ndarray
85-
array of the number of edges for each dual face
86+
Array of the number of edges for each node in the primal mesh
8687
dual_node_x: np.ndarray
87-
x node coordinates for the dual mesh
88+
x coordinates for the dual mesh nodes (face centers of primal mesh)
8889
dual_node_y: np.ndarray
89-
y node coordinates for the dual mesh
90+
y coordinates for the dual mesh nodes (face centers of primal mesh)
9091
dual_node_z: np.ndarray
91-
z node coordinates for the dual mesh
92+
z coordinates for the dual mesh nodes (face centers of primal mesh)
9293
node_face_connectivity: np.ndarray
93-
`node_face_connectivity` of the primal mesh
94+
Node-to-face connectivity of the primal mesh
9495
node_x: np.ndarray
95-
x node coordinates from the primal mesh
96+
x coordinates of nodes from the primal mesh
9697
node_y: np.ndarray
97-
y node coordinates from the primal mesh
98+
y coordinates of nodes from the primal mesh
9899
node_z: np.ndarray
99-
z node coordinates from the primal mesh
100+
z coordinates of nodes from the primal mesh
100101
construct_node_face_connectivity: np.ndarray
101-
Empty array to store connectivity
102+
Pre-allocated array to store the dual mesh connectivity
102103
max_edges: int
103104
The max number of edges in a face
104105
@@ -107,6 +108,13 @@ def construct_faces(
107108
--------
108109
construct_node_face_connectivity : ndarray
109110
Constructed node_face_connectivity for the dual mesh
111+
112+
Notes
113+
-----
114+
In dual mesh construction, the "valid node indices" are face indices from
115+
the primal mesh's node_face_connectivity that are not fill values. These
116+
represent the actual faces that each primal node connects to, which become
117+
the nodes of the dual mesh faces.
110118
"""
111119
n_valid = valid_node_indices.shape[0]
112120

@@ -118,14 +126,13 @@ def construct_faces(
118126
[INT_FILL_VALUE for _ in range(n_edges[i])], dtype=INT_DTYPE
119127
)
120128

121-
# Get a list of the valid non fill value nodes
129+
# Get the face indices this node connects to (these become dual face nodes)
122130
connected_faces = node_face_connectivity[i][0 : n_edges[i]]
123-
index = 0
124131

125132
# Connect the face centers around the node to make dual face
126-
for node_idx in connected_faces:
127-
temp_face[index] = node_idx
128-
index += 1
133+
for index, node_idx in enumerate(connected_faces):
134+
if node_idx != INT_FILL_VALUE:
135+
temp_face[index] = node_idx
129136

130137
# Order the nodes using the angles so the faces have nodes in counter-clockwise sequence
131138
node_central = np.array([node_x[i], node_y[i], node_z[i]])
@@ -138,7 +145,7 @@ def construct_faces(
138145
)
139146

140147
# Order the face nodes properly in a counter-clockwise fashion
141-
if temp_face[0] is not INT_FILL_VALUE:
148+
if temp_face[0] != INT_FILL_VALUE:
142149
_face = _order_nodes(
143150
temp_face,
144151
node_0,
@@ -192,10 +199,18 @@ def _order_nodes(
192199
final_face : np.ndarray
193200
The face in proper counter-clockwise order
194201
"""
202+
# Add numerical stability check for degenerate cases
203+
if n_edges < 3:
204+
return np.full(max_edges, INT_FILL_VALUE, dtype=INT_DTYPE)
205+
195206
node_zero = node_0 - node_central
207+
node_zero_mag = np.linalg.norm(node_zero)
208+
209+
# Check for numerical stability
210+
if node_zero_mag < 1e-15:
211+
return np.full(max_edges, INT_FILL_VALUE, dtype=INT_DTYPE)
196212

197213
node_cross = np.cross(node_0, node_central)
198-
node_zero_mag = np.linalg.norm(node_zero)
199214

200215
d_angles = np.zeros(n_edges, dtype=np.float64)
201216
d_angles[0] = 0.0
@@ -214,11 +229,16 @@ def _order_nodes(
214229
node_diff = sub - node_central
215230
node_diff_mag = np.linalg.norm(node_diff)
216231

232+
# Skip if node difference is too small (numerical stability)
233+
if node_diff_mag < 1e-15:
234+
d_angles[j] = 0.0
235+
continue
236+
217237
d_side = np.dot(node_cross, node_diff)
218238
d_dot_norm = np.dot(node_zero, node_diff) / (node_zero_mag * node_diff_mag)
219239

220-
if d_dot_norm > 1.0:
221-
d_dot_norm = 1.0
240+
# Clamp to valid range for arccos to avoid numerical errors
241+
d_dot_norm = max(-1.0, min(1.0, d_dot_norm))
222242

223243
d_angles[j] = np.arccos(d_dot_norm)
224244

0 commit comments

Comments
 (0)