Skip to content
113 changes: 48 additions & 65 deletions test/io/test_esmf.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import uxarray as ux
import os
import numpy as np
import pytest
import uxarray as ux
import xarray as xr
import numpy as np
from uxarray.constants import ERROR_TOLERANCE
from uxarray.constants import INT_FILL_VALUE


def test_read_esmf(gridpath):
Expand Down Expand Up @@ -35,71 +34,55 @@ def test_read_esmf_dataset(gridpath, datasetpath):
for dim in dims:
assert dim in uxds.dims

def test_esmf_round_trip_consistency(gridpath):
"""Test round-trip serialization of grid objects through ESMF xarray format.

Validates that grid objects can be successfully converted to ESMF xarray.Dataset
format, serialized to disk, and reloaded while maintaining numerical accuracy
and topological integrity.

The test verifies:
- Successful conversion to ESMF xarray format
- File I/O round-trip consistency
- Preservation of face-node connectivity (exact)
- Preservation of node coordinates (within numerical tolerance)
def test_encode_esmf_structure(gridpath):
"""Encoding to ESMF produces the variables the format requires.

Raises:
AssertionError: If any round-trip validation fails
Round-trip fidelity is covered for every writable format by
``TestIOWriteRoundTrip`` in test_io_common.py; this only pins down the
ESMF-specific layout.
"""
# Load original grid
original_grid = ux.open_grid(gridpath("ugrid", "outCSne30", "outCSne30.ug"))
uxgrid = ux.open_grid(gridpath("ugrid", "outCSne30", "outCSne30.ug"))
esmf_dataset = uxgrid.to_xarray("ESMF")

# Convert to ESMF xarray format
esmf_dataset = original_grid.to_xarray("ESMF")

# Verify dataset structure
assert isinstance(esmf_dataset, xr.Dataset)
assert 'nodeCoords' in esmf_dataset
assert 'elementConn' in esmf_dataset
assert 'numElementConn' in esmf_dataset

# elementConn is 1-based with -1 marking unused slots
assert esmf_dataset['elementConn'].attrs['_FillValue'] == -1
assert esmf_dataset['numElementConn'].values.sum() == (
uxgrid.n_nodes_per_face.values.sum()
)


# Define output file path
esmf_filepath = "test_esmf_ne30.nc"

# Remove existing test file to ensure clean state
if os.path.exists(esmf_filepath):
os.remove(esmf_filepath)

try:
# Serialize dataset to disk
esmf_dataset.to_netcdf(esmf_filepath)

# Reload grid from serialized file
reloaded_grid = ux.open_grid(esmf_filepath)

# Validate topological consistency (face-node connectivity)
# Integer connectivity arrays must be exactly preserved
np.testing.assert_array_equal(
original_grid.face_node_connectivity.values,
reloaded_grid.face_node_connectivity.values,
err_msg="ESMF face connectivity mismatch"
)

# Validate coordinate consistency with numerical tolerance
# Coordinate transformations and I/O precision may introduce minor differences
np.testing.assert_allclose(
original_grid.node_lon.values,
reloaded_grid.node_lon.values,
err_msg="ESMF longitude mismatch",
rtol=ERROR_TOLERANCE
)
np.testing.assert_allclose(
original_grid.node_lat.values,
reloaded_grid.node_lat.values,
err_msg="ESMF latitude mismatch",
rtol=ERROR_TOLERANCE
)

finally:
# Clean up temporary test file
if os.path.exists(esmf_filepath):
os.remove(esmf_filepath)
@pytest.mark.parametrize("mask_and_scale", [True, False])
def test_read_esmf_padding_independent_of_cf_decoding(mask_and_scale, tmp_path):
"""Padding is recognized whether or not xarray decoded the fill value.

CF decoding replaces the ``-1`` padding with NaN and promotes elementConn to
float; an undecoded read hands back the raw int32. Neither survives the cast
to INT_DTYPE as INT_FILL_VALUE, so both must be identified before it.
"""
uxgrid = ux.Grid.from_topology(
node_lon=np.array([0.0, 10.0, 10.0, 0.0, 20.0]),
node_lat=np.array([0.0, 0.0, 10.0, 10.0, 0.0]),
face_node_connectivity=np.array([
[0, 1, 2, 3],
[1, 4, 2, INT_FILL_VALUE],
[0, 3, 4, INT_FILL_VALUE],
]),
fill_value=INT_FILL_VALUE,
)

path = tmp_path / "esmf_ragged.nc"
uxgrid.to_xarray("ESMF").to_netcdf(path)

with xr.open_dataset(path, mask_and_scale=mask_and_scale) as ds:
reloaded = ux.open_grid(ds)

np.testing.assert_array_equal(
reloaded.face_node_connectivity.values,
uxgrid.face_node_connectivity.values,
)
np.testing.assert_array_equal(reloaded.n_nodes_per_face.values, [4, 3, 3])
73 changes: 71 additions & 2 deletions test/io/test_exodus.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import numpy as np
import pytest
import uxarray as ux
import xarray as xr
from uxarray.constants import INT_DTYPE, INT_FILL_VALUE


Expand All @@ -21,8 +22,76 @@ def test_init_verts():
def test_encode_exodus(gridpath):
"""Read a UGRID dataset and encode that as an Exodus format."""
uxgrid = ux.open_grid(gridpath("exodus", "outCSne8", "outCSne8.g"))
# Add encoding logic and assertions as needed
pass # Placeholder for actual implementation
exo_ds = uxgrid.to_xarray("Exodus")

# A uniform quad mesh belongs in exactly one block, typed for a quad
blocks = [v for v in exo_ds.data_vars if v.startswith("connect")]
assert blocks == ["connect1"]
assert exo_ds["connect1"].attrs["elem_type"] == "SHELL4"
assert exo_ds["connect1"].shape == (uxgrid.n_face, 4)

def test_encode_exodus_mixed_blocks():
"""Faces of different sizes go into separate, correctly typed blocks.

Exodus element blocks are homogeneous, so a mixed mesh has to be split by
face size. Getting the fill value wrong collapses everything into one
max-width block and writes the padding out as a node index.
"""
face_node_connectivity = np.array([
[0, 1, 2, 3],
[1, 4, 2, INT_FILL_VALUE],
[0, 3, 4, INT_FILL_VALUE],
])
uxgrid = ux.Grid.from_topology(
node_lon=np.array([0.0, 10.0, 10.0, 0.0, 20.0]),
node_lat=np.array([0.0, 0.0, 10.0, 10.0, 0.0]),
face_node_connectivity=face_node_connectivity,
fill_value=INT_FILL_VALUE,
)

exo_ds = uxgrid.to_xarray("Exodus")

blocks = sorted(v for v in exo_ds.data_vars if v.startswith("connect"))
assert blocks == ["connect1", "connect2"]

by_type = {exo_ds[b].attrs["elem_type"]: exo_ds[b] for b in blocks}
assert set(by_type) == {"TRI", "SHELL4"}
assert by_type["TRI"].shape == (2, 3)
assert by_type["SHELL4"].shape == (1, 4)

# Blocks are written grouped by type, so the original face order has to be
# recorded or face-centered data silently misaligns on the way back in.
assert "elem_num_map" in exo_ds
assert sorted(exo_ds["elem_num_map"].values.tolist()) == [1, 2, 3]

def test_read_exodus_ignores_third_party_elem_num_map(gridpath, tmp_path):
"""Element IDs from another writer must not reorder the mesh.

``_encode_exodus`` records the original face order in ``elem_num_map`` to undo
its own block grouping. Exodus defines that variable as a user-facing ID, which
a third-party file is free to renumber, so the reader only honors it on files
it wrote -- identified via ``qa_records``.
"""
source = gridpath("exodus", "mixed", "mixed.exo")
original = ux.open_grid(source)

# Same mesh, same blocks, same connectivity -- only the element IDs change
with xr.open_dataset(source) as ds:
renumbered = ds.load()
n_elem = renumbered["elem_num_map"].sizes["num_elem"]
renumbered["elem_num_map"][:] = np.arange(n_elem, 0, -1)

assert b"uxarray" not in np.asarray(
renumbered["qa_records"].values, dtype="S"
).ravel(), "fixture is no longer a third-party file"

path = tmp_path / "mixed_renumbered.exo"
renumbered.to_netcdf(path)

np.testing.assert_array_equal(
ux.open_grid(path).face_node_connectivity.values,
original.face_node_connectivity.values,
)

def test_mixed_exodus(gridpath):
"""Read/write an exodus file with two types of faces (triangle and quadrilaterals) and writes a ugrid file."""
Expand Down
Loading