Skip to content

Add test coverage for the NISAR GUNW loader (prep_nisar) - #1507

Open
s-sasaki-earthsea-wizard wants to merge 1 commit into
insarlab:mainfrom
s-sasaki-earthsea-wizard:nisar_prep_tests
Open

Add test coverage for the NISAR GUNW loader (prep_nisar)#1507
s-sasaki-earthsea-wizard wants to merge 1 commit into
insarlab:mainfrom
s-sasaki-earthsea-wizard:nisar_prep_tests

Conversation

@s-sasaki-earthsea-wizard

@s-sasaki-earthsea-wizard s-sasaki-earthsea-wizard commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Description of proposed changes

src/mintpy/prep_nisar.py (the NISAR GUNW loader added in #1487) currently has no automated test coverage. This PR adds a synthetic-fixture test suite for its frequency resolution and metadata parsing. No production code is changed — this is tests only.

The primary motivation is regression coverage for the class of bug reported in #1485 (the reader must read .../radarGrid/referenceSlantRange, not a bare slantRange dataset that does not exist in real GUNW products). The current code already reads referenceSlantRange (addressed by #1487); these tests pin that behavior so it cannot silently regress.

What is covered

Fixtures are minimal synthetic GUNW HDF5 files built with h5py (a few KB, no GDAL warp and no network) that mirror the real GUNW schema: byte-string identification fields, scalar spacing/looks/EPSG datasets, and the radarGrid layout. tests/test_prep_nisar.py covers:

  • frequency normalization and resolution (auto/A/B, missing frequency or polarization);
  • required-path discovery per stack (ifgram/ion/tropo/set);
  • extract_metadata — UTM (N/S) and geographic (EPSG 4326) metadata, half-pixel X_FIRST/Y_FIRST, common bounds, CENTER_LINE_UTC, and ascending/descending coordinate handling;
  • the Missing 'slantRange' dataset in NISAR GUNW files #1485 regression itself — a bare-slantRange fixture must raise, and the constant that names the dataset is guarded against reverting to the non-existent bare name.

What is not covered (and why)

The GDAL warp path (_warp_to_grid_mem, DEM/mask reprojection) and the full load_nisar / prepare_* stack writers require a real projected DEM raster and are out of scope for this unit-test PR — a synthetic fixture cannot faithfully represent the 3-D radarGrid cube or a GDAL reprojection. They were validated separately against a real product (below).

Validation against a real product

The synthetic fixtures were cross-checked against a real JPL NISAR sample GUNW (ALOS-1 PALSAR surrogate, L-band, ~264 MB, no login required) to confirm they match the real file layout. On that product the GDAL-free readers resolve cleanly:

  • radarGrid/referenceSlantRange present, bare slantRange absent (Missing 'slantRange' dataset in NISAR GUNW files #1485)
  • all four stacks (ifgram/ion/tropo/set) resolve
  • extract_metadata parses: EPSG 32611 / UTM 11N / L-band λ 0.236 m / 1555 × 1136 / platform ALOS / starting range 727 292 m

Full log (commit-pinned): https://github.com/s-sasaki-earthsea-wizard/mintpy-nisar-sample/blob/a2cd7dcaf86b72e1804f41548571ee2b412812be/reports/validation.md

Testing

  • 31 tests pass locally (pytest tests/test_prep_nisar.py).
  • pre-commit run --all-files passes.
  • No production code changed; every existing code path is untouched.

Mutation check — the tests fail when the loader regresses

"31 passed" alone cannot distinguish a meaningful suite from a vacuous one, so here is the falsification side: three single-line regressions applied to prep_nisar.py (one at a time), each caught by the suite. Everything below runs locally against the synthetic fixtures — no NISAR product is needed — and each check takes under a minute: apply the diff, run python -m pytest tests/test_prep_nisar.py -q, revert.

Baseline on the unmodified branch: 31 passed.

Mutation 1 — revert the #1485 fix (read bare slantRange) → 9 failed
diff --git a/src/mintpy/prep_nisar.py b/src/mintpy/prep_nisar.py
index cc48884d..1ea0c92b 100644
--- a/src/mintpy/prep_nisar.py
+++ b/src/mintpy/prep_nisar.py
@@ -40,7 +40,7 @@ PROCESSINFO = {
     "end_time": f"{IDENTIFICATION}/referenceZeroDopplerEndTime",
     "rdr_xcoord": f"{RADARGRID_ROOT}/xCoordinates",
     "rdr_ycoord": f"{RADARGRID_ROOT}/yCoordinates",
-    "rdr_slant_range": f"{RADARGRID_ROOT}/referenceSlantRange",
+    "rdr_slant_range": f"{RADARGRID_ROOT}/slantRange",
     "rdr_height": f"{RADARGRID_ROOT}/heightAboveEllipsoid",
     "rdr_incidence": f"{RADARGRID_ROOT}/incidenceAngle",
     "rdr_los_x": f"{RADARGRID_ROOT}/losUnitVectorX",
FAILED tests/test_prep_nisar.py::test_processinfo_reads_reference_slant_range
FAILED tests/test_prep_nisar.py::test_extract_metadata_utm
FAILED tests/test_prep_nisar.py::test_extract_metadata_southern_utm_zone
FAILED tests/test_prep_nisar.py::test_extract_metadata_geographic_4326
FAILED tests/test_prep_nisar.py::test_extract_metadata_ascending_y
FAILED tests/test_prep_nisar.py::test_extract_metadata_requires_reference_slant_range
FAILED tests/test_prep_nisar.py::test_extract_metadata_center_line_utc_matches_manual
FAILED tests/test_prep_nisar.py::test_extract_metadata_origin_and_bounds_values
FAILED tests/test_prep_nisar.py::test_extract_metadata_frequency_b
9 failed, 22 passed in 6.39s

Both dedicated slant-range regression tests fail, and so do all seven extract_metadata tests — a regression of the #1485 class cannot slip through.

Mutation 2 — drop the half-pixel shift in X_FIRST → 1 failed
diff --git a/src/mintpy/prep_nisar.py b/src/mintpy/prep_nisar.py
index cc48884d..ede6a400 100644
--- a/src/mintpy/prep_nisar.py
+++ b/src/mintpy/prep_nisar.py
@@ -797,7 +797,7 @@ def extract_metadata(input_files, bbox=None, polarization="HH", frequency="frequ
     ).total_seconds()
 
     # These were previously using //2 (integer) which is wrong for float spacing.
-    meta["X_FIRST"] = x_origin - float(pixel_width) / 2.0
+    meta["X_FIRST"] = x_origin
     meta["Y_FIRST"] = y_origin - float(pixel_height) / 2.0
     meta["X_STEP"] = float(pixel_width)
     meta["Y_STEP"] = float(pixel_height)
FAILED tests/test_prep_nisar.py::test_extract_metadata_origin_and_bounds_values
1 failed, 30 passed in 4.66s

Exactly the dedicated origin/bounds test fails and nothing else — the tests pin values independently instead of failing as a correlated cluster.

Mutation 3 — disable the required-path check in _resolve_frequency → 2 failed
diff --git a/src/mintpy/prep_nisar.py b/src/mintpy/prep_nisar.py
index cc48884d..36fb0692 100644
--- a/src/mintpy/prep_nisar.py
+++ b/src/mintpy/prep_nisar.py
@@ -115,7 +115,7 @@ def _resolve_frequency(gunw_file: str, frequency, polarization: str) -> str:
             datasets["unw"],
             _center_frequency_path(resolved),
         ]
-        missing = [path for path in required_paths if path not in ds]
+        missing = []
 
     if missing:
         requested_frequency = "auto" if frequency is None else str(frequency)
FAILED tests/test_prep_nisar.py::test_resolve_frequency_missing_b_raises
FAILED tests/test_prep_nisar.py::test_resolve_frequency_missing_polarization_raises
2 failed, 29 passed in 4.78s

Both error-path tests fail — the *_raises tests are not vacuous.

On #1485

#1485 is still open; its underlying cause was addressed by #1487. If this
regression coverage is acceptable, #1485 could likely be closed as fixed-and-guarded — I'll defer that judgment to the maintainers.


Disclosure: these tests were developed with AI assistance. All code was reviewed by the author and validated against a real GUNW sample product.

Summary by Sourcery

Add automated test coverage for the NISAR GUNW loader without changing production code.

Enhancements:

  • Add comprehensive synthetic-fixture unit coverage for NISAR GUNW frequency resolution, required-path discovery, metadata extraction, coordinate handling, and multi-file bounds.

Tests:

  • Add regression tests ensuring NISAR metadata parsing uses radarGrid/referenceSlantRange and rejects products containing only a bare slantRange dataset.

@welcome

welcome Bot commented Jul 16, 2026

Copy link
Copy Markdown

💖 Thanks for opening this pull request! Please check out our contributing guidelines. 💖
Keep in mind that all new features should be documented. It helps to write the comments next to the code or below your functions describing all arguments, and return types before writing the code. This will help you think about your code design and usually results in better code.

@sourcery-ai

sourcery-ai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

This PR adds a new synthetic-fixture unit test suite for the NISAR GUNW loader in prep_nisar, exercising frequency normalization, required-path discovery, metadata extraction, and regression coverage for the referenceSlantRange dataset without modifying production code.

File-Level Changes

Change Details Files
Add synthetic HDF5-based unit tests that cover prep_nisar frequency handling, required dataset paths per stack, metadata extraction, and the #1485 regression guard around referenceSlantRange.
  • Introduce a _build_gunw helper that constructs minimal synthetic NISAR GUNW HDF5 files with the key identification, radarGrid, and grids/parameters datasets used by prep_nisar.
  • Add Tier A tests for pure helper functions such as _normalize_frequency, dataset path builders, required paths per stack type, and the PROCESSINFO constant targeting radarGrid/referenceSlantRange.
  • Add Tier B tests that exercise HDF5 traversal for _resolve_frequency, missing-required-path detection across ifgram/ion/tropo/set stacks, and polarization/frequency error handling.
  • Add Tier C tests for extract_metadata covering UTM and geographic EPSG handling, units, half-pixel origin and bounds, CENTER_LINE_UTC computation, wavelength and starting range derivation from referenceSlantRange, and explicit failure when only a bare slantRange dataset exists.
  • Add additional tests for frequencyB positive paths, get_raster_corners, and common_raster_bound including non-overlap error handling.
tests/test_prep_nisar.py

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've reviewed your changes and they look great!


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@s-sasaki-earthsea-wizard

Copy link
Copy Markdown
Contributor Author

@sourcery-ai
Could you review this PR?

@s-sasaki-earthsea-wizard

Copy link
Copy Markdown
Contributor Author

A note on future NISAR product versions

One known limitation of this test suite, for the record: the synthetic fixtures mirror the GUNW schema of the currently distributed products (beta/provisional era). NASA has announced an updated NISAR processor for fall 2026, followed by reprocessing of the forward stream and back-processing of earlier acquisitions, so a schema drift in validated products is a realistic possibility.

Synthetic fixtures cannot detect that class of change by design — they pin the current contract so the code side cannot silently regress (the #1485 class of bug). If the schema does change, the fixture builders are plain code and cheap to update.

As a follow-up (separate PR, production code), I'd propose a small version guard in prep_nisar.py: read identification/productVersion / productSpecificationVersion, log them, and warn on unrecognized versions — so a future schema change surfaces as a diagnosable message instead of a bare KeyError. Would that be welcome, or do you prefer to wait until the reprocessed products actually ship?

prep_nisar (the NISAR GUNW loader added in insarlab#1487) had no automated test
coverage. Add a synthetic-fixture suite for its frequency resolution and
metadata parsing; no production code is changed.

The primary motivation is regression coverage for the insarlab#1485 class of bug:
the reader must read radarGrid/referenceSlantRange, not a bare slantRange
dataset absent from real GUNW products. A bare-slantRange fixture is
asserted to raise, and the constant that names the dataset is guarded.

Fixtures are minimal synthetic GUNW HDF5 files built with h5py (a few KB,
no GDAL, no network). Coverage: frequency normalization/resolution,
required-path discovery per stack (ifgram/ion/tropo/set), and
extract_metadata (UTM N/S and geographic EPSG 4326, half-pixel origin,
common bounds, CENTER_LINE_UTC) including the insarlab#1485 regression.

The GDAL warp path (_warp_to_grid_mem, DEM/mask reprojection) and the full
load_nisar / prepare_* writers need a real projected raster and are out of
scope; they are validated against a real sample GUNW product separately.
@s-sasaki-earthsea-wizard

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (06a98622); 31 tests pass locally, pre-commit clean.

Real-data validation. The fixtures here are synthetic, so I wanted to check that they pin a contract that real products actually exercise. I ran the loader on a stack of 10 NISAR GUNW products (spec 1.5.0, P05023) covering 5 acquisitions over the Boso peninsula, Japan:

  • 10 files loaded from a single glob; dates resolved from the HDF5 zero-Doppler time, with no reliance on filenames
  • ifgramStack / ionStack / setStack / geometry / water mask written; the optional troposphere stack is absent from these products and is skipped with a warning rather than failing
  • output grid EPSG 32654, 80 m, 2546 x 3634
  • smallbaselineApp.py then runs all 18 steps to Normal end
  • re-running modify_network -> invert_network with the network cut from 10 pairs down to 4 also reaches Normal end

That is loader plumbing only -- no claim about the science of these products.

Interaction with #1494. Since #1494 also reworks prep_nisar, I measured how the two interact, so that the decision has numbers behind it and so that this PR does not turn into avoidable work on the #1494 side. I applied this test file on top of #1494's head (4a4c16b3) and ran it:

1 failed, 30 passed

The new sar_band / product_ctx parameters are keyword arguments with defaults, so every call site in these tests still resolves. The single failure is test_processinfo_reads_reference_slant_range, because #1494 replaces the PROCESSINFO constant with a _processinfo() function. It needs a one-line change:

-    slant_path = prep_nisar.PROCESSINFO["rdr_slant_range"]
+    slant_path = prep_nisar._processinfo()["rdr_slant_range"]

which still resolves to /science/LSAR/GUNW/metadata/radarGrid/referenceSlantRange, so the #1485 guard keeps guarding the same thing.

So the two PRs are close to independent. I am glad to follow whichever order is easiest for you and for #1494 -- if #1494 lands first, I will push that one-line update here as soon as it does, and I am equally happy to hold this PR until then if that is simpler.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant