Skip to content

prep_nisar: fix the 90 deg offset in azimuthAngle - #1519

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

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

Conversation

@s-sasaki-earthsea-wizard

@s-sasaki-earthsea-wizard s-sasaki-earthsea-wizard commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Description of proposed changes

prep_nisar writes an azimuthAngle into geometryGeo.h5 (the layer was added in #1487) that is a constant 90 deg away from the convention the rest of MintPy uses.
This one-line change fixes it:

# before
az = np.degrees(np.arctan2(-losy, -losx))
# after
az = np.degrees(np.arctan2(-losx, losy))

Why. The GUNW attributes define losUnitVectorX / losUnitVectorY as the east/north components of the LOS unit vector from the target to the sensor, in ENU.
So (losx, losy) is already the ground-to-satellite horizontal vector (E, N) and needs no sign flip.
(Numerically, hypot(losx, losy) equals sin(incidenceAngle) per pixel to 1.2e-7 on every product I checked.)
MintPy's azimuthAngle follows the ISCE-2 convention documented in utils0.enu2los(), measured from north with anti-clockwise positive, which for (E, N) is atan2(-E, N).
The old atan2(-N, -E) equals atan2(-E, N) - 90 deg (mod 360).

Effect. Nothing raises; the angle is silently rotated.
Phase-only steps (invert_network, velocity, correct_topography) don't read it, so a routine smallbaselineApp.py run completes and looks normal.
What does read it: correct_SET (tides are projected onto a LOS rotated by 90 deg), asc_desc2horz_vert.py, and any ENU-to-LOS projection a user does from the exported geometry.

This is not limited to NISAR data.
prep_nisar reads the GUNW format, and isce3 writes that format for other missions too: it ships converters for ALOS PALSAR (alos_to_nisar_l0b.py) and ALOS-2 PALSAR-2 (alos2_to_nisar_l1.py) under share/nisar/examples/.
GUNWs produced that way load through prep_nisar like any NISAR product, and get the same rotated geometry.

Verification. geometryGeo.h5 written by prep_nisar before and after this change, with the azimuth converted back to a platform heading through utils0.azimuth2heading_angle().
A near-polar orbit has a heading of about -12 deg ascending and -168 deg descending.

GUNW pass / look heading before heading after expected
NISAR beta, track 159 frame 022 Asc / Left 73.11 -16.89 ~ -12
NISAR beta, track 011 frame 088 Desc / Left -75.84 -165.84 ~ -168
ALOS-2 PALSAR-2 stripmap, converted and processed with isce3 Asc / Right 79.38 -10.62 ~ -12

The third row is one of those non-NISAR cases: an ALOS-2 stack converted with isce3's alos2_to_nisar_l1.py, which is where this bug first showed up.
Those missions are right-looking while NISAR flies left-looking only, so the row also shows that the fix holds for both look directions.
On that stack, correct_SET reported the ENU-to-LOS unit vector as E = -0.099, N = 0.526, U = 0.845 before the change.
Computed the same way from the patched geometry, it becomes E = -0.526, N = -0.099, U = 0.845, i.e. the sensor lies to the west-southwest, as it should for an ascending right-looking orbit.

pre-commit run --all-files: 13 hooks passed, 1 skipped.

Reproduction: check the convention on any GUNW file (no MintPy run needed)
#!/usr/bin/env python3
"""Check the azimuthAngle convention used by mintpy.prep_nisar against any NISAR GUNW file(s)."""
import sys

import h5py
import numpy as np

from mintpy.utils.utils0 import azimuth2heading_angle

RG = 'science/LSAR/GUNW/metadata/radarGrid'
IDN = 'science/LSAR/identification'

for path in sys.argv[1:]:
    with h5py.File(path, 'r') as f:
        rg = f[RG]
        k = rg['losUnitVectorX'].shape[0] // 2      # middle height layer of the metadata cube
        losx = rg['losUnitVectorX'][k]
        losy = rg['losUnitVectorY'][k]
        inc = rg['incidenceAngle'][k]
        look = f[IDN]['lookDirection'][()].decode().lower()
        orbit = f[IDN]['orbitPassDirection'][()].decode()

    # (losx, losy) is the horizontal part of a unit vector: |(x,y)| == sin(incidence)
    resid = np.nanmax(np.abs(np.hypot(losx, losy) - np.sin(np.deg2rad(inc))))

    az_old = np.degrees(np.arctan2(-losy, -losx))   # before this PR
    az_new = np.degrees(np.arctan2(-losx, losy))    # after this PR
    head_old = np.nanmedian(azimuth2heading_angle(az_old, look))
    head_new = np.nanmedian(azimuth2heading_angle(az_new, look))
    exp = -12 if orbit.lower().startswith('asc') else -168

    print(f'{orbit:10s} look={look:5s}  max||(x,y)|-sin(inc)|={resid:.1e}   '
          f'heading before={head_old:8.2f}   after={head_new:8.2f}   (expected ~{exp})')

Output on the three products in the table:

Ascending  look=left   max||(x,y)|-sin(inc)|=1.2e-07   heading before=   73.11   after=  -16.89   (expected ~-12)
Descending look=left   max||(x,y)|-sin(inc)|=1.2e-07   heading before=  -75.99   after= -165.99   (expected ~-168)
Ascending  look=right  max||(x,y)|-sin(inc)|=1.2e-07   heading before=   79.42   after=  -10.58   (expected ~-12)

These come from the raw metadata cube, so they differ from the table above by a few hundredths of a degree.
The table is from the geometryGeo.h5 that prep_nisar writes.

There is no unit test in this PR: tests/test_prep_nisar.py is being introduced by #1507, and I didn't want two open PRs creating the same file.
I'm happy to add a regression test there once this lands (recovered heading for ascending/descending x left/right over a synthetic radarGrid), or here if you prefer.


Disclosure: this change was developed with AI assistance. The change was reviewed and verified by the author.

Reminders

  • Fix #xxxx — n/a, self-contained bug fix without a separate issue
  • Pass Pre-commit check (green) — passes locally (pre-commit run --all-files)
  • Pass Codacy code review (green)
  • Pass Circle CI test (green)
  • Make sure that your code follows our style. Use the other functions/files as a basis.
  • If modifying functionality, describe changes to function behavior and arguments in a comment below the function declaration. — the comment above the changed line now states the convention.
  • If adding new functionality, add a detailed description to the documentation and/or an example. — n/a, no new functionality.

Summary by Sourcery

Bug Fixes:

  • Correct the azimuth angle written by prep_nisar so geometry uses MintPy’s standard target-to-sensor convention instead of a 90-degree-rotated heading.

The GUNW losUnitVectorX/Y layers are the east/north components of the
target-to-sensor LOS unit vector, so the LOS azimuth angle in the ISCE-2
convention used across MintPy (measured from north, anti-clockwise
positive; see utils0.enu2los) is atan2(-E, N). The previous expression,
atan2(-N, -E), is offset from it by a constant -90 deg, which rotated the
LOS direction seen by correct_SET and asc_desc2horz_vert.py.
@sourcery-ai

sourcery-ai Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor
Reviewer's guide (collapsed on small PRs)

Reviewer's Guide

Fixes the 90-degree azimuth offset written by prep_nisar by applying the correct target-to-sensor ENU-to-azimuth conversion, with validation showing corrected headings for NISAR and converted ALOS-2 GUNWs across orbit and look directions.

Flow diagram for corrected NISAR azimuth conversion

flowchart LR
    A["GUNW LOS components<br/>losUnitVectorX = E<br/>losUnitVectorY = N"] --> B["prep_nisar.interpolate_geometry"]
    B --> C["az = degrees(atan2(-losx, losy))"]
    C --> D["geometryGeo.h5<br/>azimuthAngle"]
    D --> E["Correct ENU-to-LOS consumers<br/>correct_SET and asc_desc2horz_vert"]
Loading

File-Level Changes

Change Details Files
Correct the azimuth-angle calculation to match MintPy’s ISCE-2 ENU convention.
  • Use the target-to-sensor horizontal LOS components directly as east/north values.
  • Compute azimuth as atan2(-east, north), measured from north with counter-clockwise-positive orientation.
  • Update the inline comment to document the vector convention and reference implementation.
src/mintpy/prep_nisar.py
Validate the corrected geometry orientation across supported GUNW sources and orbit/look configurations.
  • Compare headings recovered from geometry before and after the change against expected ascending and descending near-polar headings.
  • Verify behavior for NISAR and converted ALOS-2 products, including left- and right-looking acquisitions.
  • Run all pre-commit hooks successfully; no regression test is included because the related test file is being added separately.
src/mintpy/prep_nisar.py

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 ✨

This branch has not been deployed

No deployments
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