From 9dcb324408f0df6a3f873ed575e65a0a303b4e49 Mon Sep 17 00:00:00 2001 From: "ucgmsim[bot]" <335857963+ucgmsim[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:02:26 +1300 Subject: [PATCH] Test XYTS endianness, proc-local files and date-line domains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings `qcore/xyts.py` from 96% to 100% line coverage. The new tests build small synthetic XYTS files in `tmp_path` (`_write_xyts` helper), so they don't depend on the 45 MB sample file. What the new tests check: - **Endianness detection** (`test_endianness_detected`): the same data is written once little-endian and once big-endian. Both files must give the same header values, data, `ll_map`, magnitude timeslice and PGV map. - **Process-local files** (`test_proc_local_file`): with `proc_local_file=True`, the code reads the extra `local_nx/ny/nz` header fields. The data section starts at offset 72 and has shape `(nt, 3, local_ny, local_nx)`. `ll_map` still covers the global `nx × ny` domain. - **Missing local dimensions** (`test_proc_local_file_missing_local_dims`): if a process-local header has `local_nx == 0`, the constructor raises `ValueError`. With `meta_only=True` the same file still loads. - **Antimeridian crossing** (`test_date_line_crossing`): for a domain centred on 180° longitude, `ll_map`, `corners()` and `region()` all give continuous longitudes in (179, 181) instead of wrapping to negative values. `.ai/ci-checks` passes: ruff-format, ruff, deptry, npdlint, ty and pytest (176 passed). Coverage of `qcore/xyts.py`: 95.8% → 100.0%. _Cost $0.44, 15 turns._ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- tests/test_xyts.py | 123 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/tests/test_xyts.py b/tests/test_xyts.py index 185f72b2..f2ec5c60 100644 --- a/tests/test_xyts.py +++ b/tests/test_xyts.py @@ -237,3 +237,126 @@ def test_pgv_meta_only(meta_only_xyts_file: xyts.XYTSFile) -> None: AttributeError, match="The data and ll_map attributes must be set to use `pgv`" ): meta_only_xyts_file.pgv() + + +def _write_xyts( + path: Path, + data: np.ndarray, + endian: str = "<", + mrot: float = 30.0, + mlat: float = -43.5, + mlon: float = 172.5, + hh: float = 0.1, + dxts: int = 5, + local_shape: tuple[int, int] | None = None, +) -> None: + """Write a synthetic XYTS file with the given data and header values. + + `data` has shape (nt, 3, ny, nx). If `local_shape` (local_nx, local_ny) is + given, a process-local header is written instead of a merged one. + """ + nt, _, ny, nx = data.shape + ints = [0, 0, 0, 0] + if local_shape is not None: + ints += [*local_shape, 1] + ints += [nx, ny, 1, nt] + floats = [hh * dxts, hh * dxts, hh, 0.1, mrot, mlat, mlon] + with open(path, "wb") as f: + np.asarray(ints, dtype=f"{endian}i4").tofile(f) + np.asarray(floats, dtype=f"{endian}f4").tofile(f) + data.astype(f"{endian}f4").tofile(f) + + +@pytest.fixture +def synthetic_data() -> np.ndarray: + """Provide deterministic (nt, 3, ny, nx) velocity data.""" + rng = np.random.default_rng(0) + return rng.uniform(0.1, 2.0, size=(4, 3, 3, 5)).astype(np.float32) + + +def test_endianness_detected(tmp_path: Path, synthetic_data: np.ndarray) -> None: + """Little- and big-endian files with the same contents read identically.""" + little = tmp_path / "little.e3d" + big = tmp_path / "big.e3d" + _write_xyts(little, synthetic_data, endian="<") + _write_xyts(big, synthetic_data, endian=">") + # The two files really are byte-swapped versions of each other. + assert little.read_bytes() != big.read_bytes() + + little_file = xyts.XYTSFile(little) + big_file = xyts.XYTSFile(big) + + for xyts_file in (little_file, big_file): + assert (xyts_file.nx, xyts_file.ny, xyts_file.nz, xyts_file.nt) == (5, 3, 1, 4) + assert xyts_file.hh == pytest.approx(0.1) + assert xyts_file.dxts == 5 + assert xyts_file.mlon == pytest.approx(172.5) + assert xyts_file.data is not None + assert np.array_equal(xyts_file.data, synthetic_data) + assert xyts_file.tslice_get(2) == pytest.approx( + np.linalg.norm(synthetic_data[2], axis=0) + ) + + assert little_file.ll_map is not None and big_file.ll_map is not None + assert np.array_equal(little_file.ll_map, big_file.ll_map) + assert little_file.pgv() == pytest.approx(big_file.pgv()) + + +def test_proc_local_file(tmp_path: Path, synthetic_data: np.ndarray) -> None: + """A process-local file uses the local dimensions for its data section.""" + local_data = synthetic_data[:, :, :2, :3] + path = tmp_path / "local.e3d" + _write_xyts(path, local_data, local_shape=(3, 2)) + # Make the header claim the global (merged) domain is 5x3. + with open(path, "r+b") as f: + f.seek(28) + np.asarray([5, 3], dtype=" None: + """A process-local file with zero local nx is rejected unless meta only.""" + path = tmp_path / "local.e3d" + _write_xyts(path, synthetic_data, local_shape=(0, 3)) + + with pytest.raises(ValueError, match="Local nx, ny must be set"): + xyts.XYTSFile(path, proc_local_file=True) + + meta = xyts.XYTSFile(path, proc_local_file=True, meta_only=True) + assert (meta.local_nx, meta.local_ny) == (0, 3) + assert meta.data is None + + +def test_date_line_crossing(tmp_path: Path, synthetic_data: np.ndarray) -> None: + """Domains crossing the antimeridian report continuous longitudes past 180.""" + path = tmp_path / "dateline.e3d" + _write_xyts(path, synthetic_data, mrot=0.0, mlat=-40.0, mlon=180.0, hh=1.0) + + xyts_file = xyts.XYTSFile(path) + + assert xyts_file.ll_map is not None + lons = xyts_file.ll_map[:, :, 0] + assert np.all((lons > 179) & (lons < 181)) + assert lons.min() < 180 < lons.max() + + corners = np.array(xyts_file.corners()) + assert np.all((corners[:, 0] > 179) & (corners[:, 0] < 181)) + assert corners[:, 0].min() < 180 < corners[:, 0].max() + + x_min, x_max, _, _ = xyts_file.region() + assert 179 < x_min < 180 < x_max < 181