Skip to content

Add ENU/geodetic image transforms needed by the GMN contrail pipeline - #986

Open
KeatonBuddy wants to merge 11 commits into
CroatianMeteorNetwork:prereleasefrom
KeatonBuddy:pipeline-coordinate-transforms
Open

Add ENU/geodetic image transforms needed by the GMN contrail pipeline#986
KeatonBuddy wants to merge 11 commits into
CroatianMeteorNetwork:prereleasefrom
KeatonBuddy:pipeline-coordinate-transforms

Conversation

@KeatonBuddy

Copy link
Copy Markdown

What this adds

The GMN contrail pipeline (GMNContrailPipelineJanus) needs a set of ENU/geodetic
image transforms that currently exist only on the test-coordinate-transforms branch.
Rather than merge that whole branch, this ports forward only the surface the pipeline
actually imports, on top of prerelease.

RMS/Astrometry/ApplyAstrometry.pyxyHtToENUPP, enHtToXYPP, enuToXYPP,
geoToENUPP, geoToXYPP, ENHt0ToENHt1, xyToAltAzPP, plus the imageCenter and
_centreAltAz helpers they share.

RMS/Astrometry/CyFunctions.pyxcyXYToAltAz, cyXYHttoENU_wgs84, cyGeoToENU,
cyGeoToXY_wgs84_iter, cyENUToXY_iter, cyENHtToXY_iter, cyENHt0ToENHt1 and their
three geodetic cdef helpers. Appended; nothing existing was modified.

RMS/Astrometry/GPUENHt.py — new module (optional downstream, guarded by
except ImportError).

RMS/Formats/Platepar.py — sets height_wgs84 from elev via EGM96 in
loadFromDict() and read(), as the source branch does. The ENU/geodetic transforms
need the ellipsoidal station height rather than the MSL elev.

RMS/GeoidHeightEGM96.py — taken from the source branch. This file had not been
modified on prerelease since 073d33a5 (2021-06-28), and the merge-base blob is
identical to prerelease's, so the branch's file is prerelease's plus dd8e1383
(2025-09-05). Same four functions on both sides; nothing dropped.

What this deliberately does not change

Every function prerelease already has keeps prerelease's implementation. In
particular rotationWrtHorizon and xyHt2Geo are byte-identical to prerelease,
even though the source branch reworked both — those rewrites are not included here.

One adaptation was unavoidable: the ported functions call
pyRefractionTrueToApparent / pyRefractionApparentToTrue instead of
refractionTrueToApparent / refractionApparentToTrue, because prerelease declares
the latter cdef, so they are not callable from Python. The py* forms are
prerelease's own cpdef wrappers around the same code.

Verification

  • prerelease astrometry is bit-identical across xyToRaDecPP, raDecToXYPP,
    rotationWrtHorizon, rotationWrtHorizonToPosAngle, computeFOVSize, xyHt2Geo,
    fovEdgePolygon, getFOVSelectionRadius, correctVignetting
  • No prerelease symbol is lost (AST scan over every .py); the diff is additive
  • Cython rebuilds from a cleared ~/.pyxbld; SkyFit2 starts; core modules import
  • Round-trips check out: XY→ENU→XY and geodetic→XY→geodetic agree to ~0.01 px, and
    ENHt0ToENHt1 preserves the line of sight when changing target height

Known gap — needs a decision

dd8e1383 changed the GeoidHeightEGM96 signatures (config → optional path) but
never updated the callers that pass a Config. Those still pass one where a path is
now expected, and raise when reached:

Location Function
RMS/Astrometry/Conversions.py:516,528 addECEFVectortoLatLonEle
RMS/Astrometry/Conversions.py:577,578 getECEFVectorBetweenGeoPoints
RMS/Formats/CAL.py:119 writeCAL
RMS/Formats/FrameInterface.py:1697 FrameInterface.__init__

This is pre-existing on the source branch (the pipeline simply never reaches these
paths). They are left untouched here because they are prerelease code this branch
does not otherwise modify. The fix is small — pass
os.path.join(config.egm96_path, config.egm96_file_name), or let the argument default —
but whether to make GeoidHeightEGM96 accept a Config for backward compatibility, or
update these four call sites, is a maintainer call. Happy to do either.

Marked draft for that reason.

🤖 Generated with Claude Code

KeatonBuddy and others added 4 commits September 4, 2026 15:40
The contrail pipeline (GMNContrailPipeline -> Janus) needs a set of coordinate
transforms that live only on the test-coordinate-transforms branch. Rebasing
that whole branch onto prerelease silently dropped prerelease code in several
places, so instead port forward only the surface Janus actually imports.

Ported from test-coordinate-transforms:

  ApplyAstrometry  xyHtToENUPP, enHtToXYPP, enuToXYPP, geoToENUPP, geoToXYPP,
                   ENHt0ToENHt1, xyToAltAzPP, plus the imageCenter and
                   _centreAltAz helpers they share
  CyFunctions      cyXYToAltAz, cyXYHttoENU_wgs84, cyGeoToENU,
                   cyGeoToXY_wgs84_iter, cyENUToXY_iter, cyENHtToXY_iter,
                   cyENHt0ToENHt1 and their three geodetic cdef helpers
  GPUENHt          new module (optional in Janus, which guards the import)

Three supporting changes, all additive:

  * rotationWrtHorizonTangentPlane() carries the branch's reworked rotation -
    azimuth scaled by cos(Alt) so the angle is in the local tangent plane, plus
    a zenith-pointing branch. The ported transforms need those semantics, but
    rotationWrtHorizon() has 37 call sites across 9 files, so it is added under
    a separate name and left untouched rather than replaced.
  * Platepar.height_wgs84 is a lazy property deriving the ellipsoidal station
    height from elev via EGM96. Janus assigns it directly, so it has a setter;
    the cache is stripped in jsonStr() so written platepars are unchanged.
  * GeoidHeightEGM96 now takes the EGM96 source as None, a path, or a Config,
    so Janus's mslToWGS84Height(lat, lon, elev) and loadEGM96Data() work
    alongside the existing config-passing callers. The geoid model is cached
    per file path, which it previously rebuilt on every call.

Verified: the seven transforms are numerically identical to the source branch
(max 5.7e-14); prerelease's astrometry is bit-identical and its written .cal
output byte-identical; no prerelease symbol was lost; all 24 RMS symbols Janus
imports resolve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
loadFromDict() replaces the instance __dict__ wholesale, so a platepar file
that already carries height_wgs84 put the value straight into __dict__. The
property read that before its own storage, which meant a later assignment was
accepted but never observed - the file value kept winning.

The contrail pipeline hits this exactly: Janus loads platepars with
loadFromDict(), corrects the station elevation, and then reassigns
pp.height_wgs84 from the corrected elev. That correction was being dropped.

Read the assigned value first, fall back to the file value, and only then
derive from elev. The setter also refreshes a file-supplied entry so a
corrected height is what gets written back out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reworked the port so every function prerelease already has keeps prerelease's
implementation untouched, and only what the pipeline needs and prerelease
lacks is brought over from test-coordinate-transforms verbatim.

Reverted from the previous commits:

  * rotationWrtHorizonTangentPlane is gone. rotationWrtHorizon is prerelease's,
    byte-identical, and the ported transforms call it exactly as the branch
    source does. They no longer reproduce the branch's numbers - the branch's
    reworked rotation is not carried over.
  * xyHt2Geo stays prerelease's per-point, MSL-based version.
  * The height_wgs84 property is replaced by the branch's own blocks in
    loadFromDict() and read(), which set it as a plain attribute.
  * The ENU transform tests are removed.

The one adaptation that remains: the ported functions call
pyRefractionTrueToApparent/pyRefractionApparentToTrue instead of
refractionTrueToApparent/refractionApparentToTrue. Prerelease declares those
cdef, so they are not callable from Python; the py* forms are prerelease's own
cpdef wrappers around the same code. (The branch calls refractionApparentToTrue
in xyToAltAzPP without importing it, which would raise there.)

RMS/GeoidHeightEGM96.py is left at the prerelease baseline for manual merging.
Prerelease and the branch give its three functions incompatible signatures, so
until that is merged Janus's mslToWGS84Height(lat, lon, elev) and
loadEGM96Data() raise TypeError, and Platepar.height_wgs84 falls back to elev
through its except branch with a warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RMS/GeoidHeightEGM96.py had not been touched on prerelease since 073d33a
(2021-06-28), and the merge base blob is identical to prerelease's, so the
branch's file is prerelease's file plus dd8e138 (2025-09-05, "Add partial
WGS84 height support"). Taking it wholesale loses nothing: the same four
functions exist on both sides.

That commit replaced the config parameter with an optional path defaulting to
RMS/share/WW15MGH.DAC via getRmsRootDir(), added @memoizeSingle caching, and
dropped the now-unused ConfigReader import and -c/--config CLI flag in favour
of --egm96. It also corrected the swapped north/east wording in the latitude
and longitude help text.

This makes Platepar.height_wgs84 derive properly instead of falling back to
elev, and it is what the contrail pipeline already calls:
mslToWGS84Height(lat, lon, elev) and loadEGM96Data() with no arguments.

Known gap, unchanged from the source branch: dd8e138 never updated the
callers that pass a Config, so these still pass one where a path is now
expected and raise when reached -

  RMS/Astrometry/Conversions.py:516,528  addECEFVectortoLatLonEle
  RMS/Astrometry/Conversions.py:577,578  getECEFVectorBetweenGeoPoints
  RMS/Formats/CAL.py:119                 writeCAL
  RMS/Formats/FrameInterface.py:1697     FrameInterface.__init__

Left as-is here rather than edited, since they are prerelease code this
branch otherwise does not touch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@dvida dvida 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.

Thanks for the careful port and the detailed write-up. I built the PR head (8e6a3149) from scratch and compared the new transforms against the existing prerelease astrometry on two real GMN platepars (US05F1, radial7-odd, centre alt 69°; NZ000N, radial7-odd, centre alt 36°). The Cython kernels themselves are numerically sound, but the Python wrappers feed them the wrong rotation angle, so the ported transforms currently disagree with xyToRaDecPP by tens of arcminutes on an unrolled camera and by many degrees on a rolled one. There are also two runtime regressions for existing users. Details below; inline comments point at the exact lines.

Blocking

B1. Wrong rotation angle passed to the alt/az gnomonic (rot = rotationWrtHorizon(platepar) in all five wrappers).

The kernels need the position angle of the image +x axis in the local tangent plane. Prerelease rotationWrtHorizon returns atan2(dAlt, dAz) without cos(alt) on the azimuth step; it is a display quantity, not a tangent-plane angle. Reference: xyToRaDecPPcyTrueRaDec2ApparentAltAz(…, refraction=False) (true alt/az, same epoch), 63-point grid over the full frame:

platepar roll added to pos_angle_ref max error, as shipped
US05F1 (alt 69°) 0 28′
US05F1 +45° 1104′ (18.4°)
NZ000N (alt 36°) 0 38′
NZ000N +45° 285′

Fitting the rotation numerically brings the max error down to 0.09′–0.2′ across the FOV, so the Cython math is right and only the input is wrong. The best-fit rotation is exactly

rot = pos_angle_ref − q,   q = atan2(sin HA, tan(lat)·cos(dec) − sin(dec)·cos(HA))

with q the parallactic angle at the FOV centre (HA from JD2LST(pp.JD, pp.lon) and pp.RA_d; RA_d/dec_d are epoch-of-date in RMS, which is also why the cyraDec2AltAz centre call without precession is correct). This matches the fit to 0.0003°–0.0007° at rolls 0/45/90 on both platepars. I'd suggest a new analytic helper (e.g. rotationWrtHorizonTangentPlane(platepar)) used by the five wrappers, and leaving prerelease rotationWrtHorizon (26 call sites) alone. Note that the source branch's finite-difference tangent-plane version is not accurate enough either (it left 26′–224′ residuals in my tests), so "numerically identical to the source branch" is not a useful acceptance criterion here.

B2. Refraction applied twice to the FOV centre. Each wrapper does alt_centre = pyRefractionTrueToApparent(alt_centre) unconditionally, then the kernel applies refractionTrueToApparent(h0) again when refraction=True. When platepar.refraction is False the Python side still refracts once. Either way the centre carries one extra refraction: 0.47′ (alt 69°) to 1.8′ (alt 36°) of systematic offset after fixing B1. Drop the Python-side call and pass the true centre altitude; the kernels already handle the flag. (Inherited from the source branch.)

B3. GeoidHeightEGM96 signature change breaks nightly processing for cams_code > 0 stations. Four prerelease callers still pass a Config (CAL.py:119, Conversions.py:516,528,577,578, FrameInterface.py:1697). Verified: it raises AttributeError: 'Config' object has no attribute 'flush' from np.fromfile. writeCAL is called unguarded from Reprocess.py:636 whenever config.cams_code > 0, so processNight will raise for every CAMS-compatible station. This has to be fixed in this PR rather than left as a known gap. Simplest: accept None | str | Config (if hasattr(x, 'egm96_path'), build the path), which is what 7b5125e4 did before it was reverted.

B4. GPUENHt.py raises NameError, not ImportError, without numba. @cuda.jit at module level runs after the except ImportError handler, so cuda is undefined. Verified: import RMS.Astrometry.GPUENHt with numba absent → NameError: name 'cuda' is not defined; Janus's except ImportError will not catch it. Wrap the kernel in if CUDA_AVAILABLE:, or better, keep this pipeline-specific CUDA module out of RMS (no RMS caller, prints at import time).

Important

I1. Platepar.height_wgs84 is a persisted derived value that goes stale. Verified: Platepar() has no height_wgs84 (every ported transform raises AttributeError on a fresh platepar); pp.elev += 1000 leaves it unchanged, and Reprocess.py:120, ApplyRecalibrate.py:730,844 and SkyFit2's station dialog all overwrite elev; jsonStr() now writes it into every platepar file, and once present loadFromDict never recomputes it, so a stale value is baked in. Suggest not storing it: compute it inside the wrappers from lat/lon/elev, or make it a read-only @property stripped in jsonStr.

I2. mslToWGS84Height rebuilds the geoid spline on every call. interpolateEGM96Data (721×1440 RectSphereBivariateSpline) costs ~0.09 s here per distinct argument tuple; @memoizeSingle caches only the last call. Platepar.read() went from ~0 to 0.09 s on a desktop; expect roughly 0.5–1 s per load on a Pi 4. Cache the model per file path in loadEGM96Data/interpolateEGM96Data instead.

I3. cyXYHttoENU_wgs84 drops the poly3+radial3 / poly3+radial5 terms that cyXYToAltAz and the three *_iter kernels include, so XY→ENU and ENU→XY are not inverses for those distortion types. It also silently falls through to "no distortion" for an unrecognised dist_type.

I4. No tests. 4ab9bbbf removed the ENU tests and the branch's coordinate_transform_tests/ were not brought over. Given B1/B2, a test comparing xyToAltAzPP against xyToRaDecPP → alt/az on a shipped template platepar (plus a rolled variant) to < 0.5′ is the minimum.

I5. cyENHt0ToENHt1 / cyENHtToXY_iter bracketing. Bracket expansion moves U_lo but only re-evaluates f_hi, so f_lo goes stale. The second bisection in cyENHt0ToENHt1 assumes height increases monotonically along the ray and converges to t_hi = 1e6 m (garbage, not NaN) when Ht1 is never reached; a downward ray bisects in the wrong direction. Return NaN on bracket failure as cyXYHttoENU_wgs84 does.

Minor

  • imageCenter and _centreAltAz are dead code here; _centreAltAz would also be wrong if used (it precesses epoch-of-date RA_d/dec_d).
  • geodetic_to_ecef, ecef_to_geodetic_bowring, R_ecef_from_enu are unused.
  • cyXYToAltAz lost the @cython.boundscheck/wraparound/cdivision decorators the source has; r_corr, r_scale (and dX, r_corr in cyXYHttoENU_wgs84) are undeclared and become Python objects in the hot loop. Its docstring says "az_centre: Reference right ascension".
  • ENHt0ToENHt1 docstring promises "ndarray or float" for Ht1_data, but a scalar raises ValueError (verified); broadcast instead.
  • xyHtToENUPP: np.full(len(X_data), …) fails if X_data is a scalar.
  • enHtToXYPP imports cyENHtToXY_iter locally; move it to the module import block.
  • xyToAltAzPP docstring documents a non-existent extinction_correction kwarg.
  • GeoidHeightEGM96 CLI lost -c/--config.

What checks out

The seven kernels (apart from cyXYToAltAz's decorators) are byte-identical to the source branch; prerelease functions are untouched; XY→ENU→geodetic→XY round-trips to 0.015 px and the ellipsoid intersection hits the target height to 9 mm; memoizeSingle, getRmsRootDir and share/WW15MGH.DAC all exist on prerelease; Cython builds cleanly from a cold cache.

Happy to share the comparison scripts if useful.

az_centre, alt_centre = np.degrees(az_centre), np.degrees(alt_centre)


rot = rotationWrtHorizon(platepar)

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.

B1 (blocking). rotationWrtHorizon returns atan2(dAlt, dAz) without cos(alt) on the azimuth step, so it is not the tangent-plane position angle the alt/az gnomonic in cyXYToAltAz needs. Measured max error vs xyToRaDecPP → alt/az: 28′–38′ on unrolled real platepars, up to 18.4° with pos_angle_ref + 45°. The best-fit rotation is exactly pos_angle_ref − q with q the parallactic angle at the FOV centre (atan2(sin HA, tan(lat)·cos(dec) − sin(dec)·cos(HA)), HA from JD2LST(pp.JD, pp.lon) and pp.RA_d), matching to < 0.001° on both test platepars; with that the max error drops to 0.1′–0.2′. Same issue at lines 1745, 1838, 1886, 1939.

Comment thread RMS/Astrometry/ApplyAstrometry.py Outdated
np.radians(platepar.lat),
np.radians(platepar.lon)
)
alt_centre = pyRefractionTrueToApparent(alt_centre)

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.

B2 (blocking). This refracts the centre unconditionally, and cyXYToAltAz applies refractionTrueToApparent(h0) again when refraction=True (and applies nothing when False, so the centre is then refracted once where it should not be). Net effect: one extra refraction of the centre either way, 0.47′ at alt 69° to 1.8′ at alt 36°. Drop this line (and the same line in the other four wrappers: 1742, 1835, 1883, 1936) and pass the true altitude; the kernels already handle the flag.

Comment thread RMS/GeoidHeightEGM96.py Outdated

def mslToWGS84Height(lat, lon, msl_height, config):
@memoizeSingle
def mslToWGS84Height(lat, lon, msl_height, egm96_file_path=None):

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.

B3 (blocking). Four prerelease callers still pass a Config here (CAL.py:119, Conversions.py:516,528,577,578, FrameInterface.py:1697). Verified: mslToWGS84Height(lat, lon, h, config)AttributeError: 'Config' object has no attribute 'flush' from np.fromfile. writeCAL is called unguarded from Reprocess.py:636 for every station with cams_code > 0, so this breaks nightly processing for them. Please accept None | str | Config (e.g. if hasattr(egm96, 'egm96_path'): path = os.path.join(egm96.egm96_path, egm96.egm96_file_name)) rather than leaving it as a known gap.

Comment thread RMS/GeoidHeightEGM96.py Outdated
GEOID_HEIGHTS = loadEGM96Data(file_path=egm96_file_path)

# Init the interpolated geoid model
GEOID_MODEL = interpolateEGM96Data(GEOID_HEIGHTS)

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.

I2. interpolateEGM96Data builds a 721×1440 RectSphereBivariateSpline on every call (~0.09 s on a desktop, likely 0.5–1 s on a Pi 4), and @memoizeSingle on the outer function only caches the last argument tuple, so every Platepar.read() of a different platepar pays the full cost. Cache the loaded array and the interpolated model per file path (memoize loadEGM96Data and interpolateEGM96Data) instead.

Comment thread RMS/Astrometry/GPUENHt.py Outdated
print("Install with: pip install numba")


@cuda.jit

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.

B4 (blocking). When numba is missing the except ImportError above sets CUDA_AVAILABLE = False, but this decorator still executes and raises NameError: name 'cuda' is not defined (verified). Janus's except ImportError guard will not catch a NameError. Wrap the kernel definition in if CUDA_AVAILABLE: or, preferably, keep this contrail-pipeline CUDA module outside RMS since nothing in RMS calls it.

x_img = x_data[i] - x_res/2.0
y_img = y_data[i] - y_res/2.0

# 2) UNDISTORT (IDENTICAL to cyXYToAltAz)

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.

I3. This poly3 branch omits the +radial3 / +radial5 extra terms (x_poly_fwd[12], [13]) that cyXYToAltAz, cyGeoToXY_wgs84_iter, cyENUToXY_iter and cyENHtToXY_iter all include, so XY→ENU and ENU→XY are not inverses for poly3+radial3 / poly3+radial5 platepars. The else at line 2187 also silently applies no distortion for an unknown dist_type instead of raising.


# Ensure bracketing
it = 0
while f_lo*f_hi > 0.0 and it < 8:

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.

I5. When the bracket is expanded on the U_lo side only f_hi is re-evaluated, so f_lo is stale (same pattern at line 2927 in cyENHtToXY_iter). Further down, the second bisection (t_lo = 10, t_hi = 1e6) assumes height increases monotonically along the ray and, if Ht1 is never reached, converges to t_hi and returns that as a valid result rather than NaN; a downward ray bisects in the wrong direction. Please return NaN on bracket failure as cyXYHttoENU_wgs84 does.

Comment thread RMS/Astrometry/ApplyAstrometry.py Outdated

# Check that arrays have the same length
if len(E0_array) != len(N0_array) or len(E0_array) != len(Ht0_array) or len(E0_array) != len(Ht1_array):
raise ValueError(f"E0, N0, Ht0, and Ht1 arrays must have the same length. Got E0:{len(E0_array)}, N0:{len(N0_array)}, Ht0:{len(Ht0_array)}, Ht1:{len(Ht1_array)}")

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.

Minor: the docstring says Ht1_data may be a float, but a scalar Ht1 raises this ValueError (verified: Got E0:3, N0:3, Ht0:3, Ht1:1). Broadcast scalars to the length of E0 instead.

return (x, y)


def _centreAltAz(platepar, jd):

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.

Minor: _centreAltAz and imageCenter above have no callers on this branch. If _centreAltAz were used it would also be wrong: cyTrueRaDec2ApparentAltAz precesses J2000→date, but RA_d/dec_d are already epoch-of-date in RMS (see pointingCorrection), which is why the cyraDec2AltAz call in the wrappers is the right one.

# test-coordinate-transforms branch for the GMN contrail pipeline (Janus).
# ============================================================================

def cyXYToAltAz(np.ndarray[FLOAT_TYPE_t, ndim=1] x_data, \

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.

Minor: the source branch has @cython.boundscheck(False) @cython.wraparound(False) @cython.cdivision(True) on this function; they were dropped here, so it is not quite verbatim. r_corr and r_scale are also undeclared (Python objects in the loop), and the docstring describes az_centre/alt_centre as RA/Dec. The three cdef inline helpers below (geodetic_to_ecef, ecef_to_geodetic_bowring, R_ecef_from_enu) are unused.

@Cybis320

Cybis320 commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

A little background on rotationWrtHorizon (issue B.1): The angle has never been correct in master, and it did not really matter as the RaDec functions don't really care about the value being correct - they just require an anchor. However, the more efficient direct XY <> AltAz transforms copied here from my implementation do require an accurate value. My test-coordinate-transforms branch has the correct rotationWrtHorizon implementation and I don't believe it breaks current platepars.

Regarding issue B.2, it was fixed in test-coordinate-transforms if you want to port that over.

Comment thread RMS/GeoidHeightEGM96.py Outdated
Comment on lines +23 to +24
if not file_path:
file_path = os.path.join(getRmsRootDir(), 'share', 'WW15MGH.DAC')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

If RMS is installed in a conda environment this is at os.path.join(sys.prefix, "share", "WW15MGH.DAC") instead. We should check both.

KeatonBuddy and others added 7 commits September 10, 2026 15:05
Addresses the blocking runtime issues in the PR CroatianMeteorNetwork#986 review. No change to the
ported transforms themselves; those are handled separately.

B3 - GeoidHeightEGM96 signature change broke nightly processing.

Changing the EGM96 source from a Config to a path left four prerelease callers
passing a Config where a path was expected, which raised
AttributeError: 'Config' object has no attribute 'flush' out of np.fromfile:

  Conversions.py:516,528  addECEFVectortoLatLonEle
  Conversions.py:577,578  getECEFVectorBetweenGeoPoints
  CAL.py:119              writeCAL
  FrameInterface.py:1697  FrameInterface.__init__

writeCAL is called unguarded from Reprocess.py:636 for every station with
cams_code > 0, so processNight raised for all CAMS-compatible stations.

egm96FilePath() now resolves None, a path, or a Config, so both the existing
config-passing callers and the pipeline's mslToWGS84Height(lat, lon, elev) work.
The 4th parameter is renamed egm96_file_path -> egm96_source since it is no
longer just a path; all in-repo callers pass it positionally. loadEGM96Data
keeps the legacy (dir_path, file_name) form. A non-path, non-Config source now
raises TypeError instead of failing inside numpy.

Also in the same code path:

  * The shipped data file is looked up under <RMS root>/share and then
    <sys.prefix>/share, so a conda install finds it too.
  * The -c/--config CLI flag is restored alongside --egm96, which overrides it.

Verified bit-identical to prerelease across 330 lat/lon/height combinations in
both directions and all three source forms (max deviation 0.000e+00 m). All four
Config-passing call sites exercised; writeCAL writes a valid .txt again.

B4 - GPUENHt.py raised NameError, not ImportError, without numba.

@cuda.jit executed at module level after the except ImportError handler, so
`cuda` was undefined and the import raised NameError, which the pipeline's
except ImportError guard does not catch. Nothing in RMS imports this module and
it printed at import time, so it is removed here rather than guarded; it belongs
with the contrail pipeline, which depends on numba directly. The pipeline's
existing guard falls back to the CPU ENHt0ToENHt1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…iew B1, B2)

B1 - wrong rotation angle passed to the alt/az gnomonic.

The five wrappers that project through the alt/az kernels passed
rotationWrtHorizon(), which measures atan2(dAlt, dAz) with no cos(Alt) on the
azimuth step. That is a display quantity used by the RA/Dec path purely as a
rotation anchor, not the tangent-plane position angle the kernels need.

rotationWrtHorizonTangentPlane() is ported from the test-coordinate-transforms
branch, as suggested by the author of those transforms in the PR discussion:
central differences at dx and dx/2 with Richardson extrapolation, the azimuth
step scaled by cos(Alt), and a pole-safe branch for zenith-pointing platepars
where the finite difference degenerates.

It is added under a new name rather than replacing rotationWrtHorizon(), which
has ~29 call sites across 9 files that depend on its present semantics. Verified
that rotationWrtHorizon, rotationWrtHorizonToPosAngle, xyToRaDecPP, raDecToXYPP,
computeFOVSize, xyHt2Geo, getFOVSelectionRadius and correctVignetting are all
byte-identical to prerelease.

B2 - refraction applied twice to the FOV centre.

Each wrapper called pyRefractionTrueToApparent(alt_centre) unconditionally, and
the kernels apply refractionTrueToApparent(h0) again when refraction=True (and
nothing when False, so the centre was refracted once where it should not be).
Either way the centre carried one extra refraction. The five Python-side calls
are removed and the true centre altitude is passed; the kernels already handle
the flag.

Measured against the reference path (xyToRaDecPP -> true alt/az, same epoch, no
refraction) on a 49-point grid over the full frame, across centre altitudes 69
and 36 deg, centre azimuths 20/110/200/290 deg and rolls 0/45 deg:

  as shipped (rotationWrtHorizon)     100' - 1383'   max error
  ported (test-coordinate-transforms)   0.04' - 0.28' max error

Round-trips after the change: XY->ENU->XY 0.0046 px, XY->ENHt->XY 0.0086 px,
geo->XY vs geo->ENU->XY 0.0000 px, and ENHt0ToENHt1 preserves the bearing
exactly.

Note on the review discussion: the analytic parallactic-angle rotation suggested
in the review was measured on the same grid and reached 3.48' worst case (at
centre alt 36 deg, azimuth 20 deg), i.e. worse than the ported finite-difference
version, which stays under 0.3' everywhere tested. Both were measured on a
template platepar retargeted to those pointings, not on fitted station
platepars, so this wants confirming against real ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ms version

Follows the PR discussion: rather than carrying the corrected rotation under a
separate name, take the test-coordinate-transforms implementation as the single
rotationWrtHorizon, matching that branch. Its executable AST is now identical to
the branch's (signature included: platepar, jd_obs=None, dx=5), and the five ENU
wrappers call it directly, as they do there.

rotationWrtHorizonTangentPlane, added in the previous commit, is removed as
redundant.

rotationWrtHorizonToPosAngle needs no change: it is byte-identical on both
branches and is defined in terms of whatever rotationWrtHorizon returns, so the
forward/inverse pair stays self-consistent. Verified: recovering pos_angle_ref
through it is exact (0.0000 deg) across 72 pointings both before and after.

WHAT THIS CHANGES FOR EXISTING USERS

Astrometry is unaffected. xyToRaDecPP, raDecToXYPP, rotationWrtHorizonToPosAngle,
rotationWrtStandard, computeFOVSize, xyHt2Geo, getFOVSelectionRadius,
correctVignetting, screenNudgeToAzAltDelta and fovEdgePolygon are all
byte-identical to prerelease, and pos_angle_ref remains the fitted quantity.

The reported rotation value does change, because the old one omitted cos(Alt) on
the azimuth step. Measured over 72 pointings (two templates, centre altitudes
69/36/15 deg, azimuths 20/110/200/290 deg, rolls 0/45/137 deg):

  max |delta|  31.16 deg
  median       3.33 deg

The shift is altitude-driven, as expected from the missing cos(Alt): ~31 deg at
centre altitude 69 deg, ~0.1 deg at 15 deg. That flows into every consumer of
rotation_from_horiz, which is persisted in platepars and reported as rot_horiz in
CAL.py output and obs_rot in ApplyAstrometryECSV.py, Astra.py and SkyFit2, plus
the CalibrationReport plot and the SkyFit2 display. Downstream consumers
calibrated against the old value will see a step change at high-elevation
stations.

Accuracy of the ENU wrappers is unchanged from the previous commit: 0.04' - 0.28'
max error against the xyToRaDecPP reference across the same grid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ew I4)

The review asked for at least a test comparing xyToAltAzPP against the
xyToRaDecPP -> alt/az path on a shipped template platepar plus a rolled variant,
since B1 and B2 both slipped through without one. 4ab9bbb had removed the
earlier ENU tests and the source branch's coordinate_transform_tests/ were never
brought over.

Tests/test_AltAzTransforms.py, following the pytest style of test_Grouping3Dcy.py
and depending only on the platepar templates shipped in share/:

  testXyToAltAzMatchesRaDecPath   32 cases: both templates x centre altitudes
                                  69/36 deg x azimuths 20/110/200/290 deg x
                                  rolls 0/45 deg, on a 49-point grid over the
                                  full frame
  testRotationTracksRoll          roll must shift the reported rotation ~1:1
  testPosAngleRoundTrip           rotationWrtHorizonToPosAngle inverts back to
                                  pos_angle_ref
  testXyEnuRoundTrip              XY -> ENU -> XY and XY -> ENHt -> XY
  testGeoPathsAgree               geoToXYPP vs geoToENUPP -> enuToXYPP
  testENHt0ToENHt1PreservesLineOfSight

50 cases, all passing. Verified the suite actually catches what it guards: with
B1 reverted 32/32 acceptance cases fail, with B2 reverted 18/32 fail.

Thresholds are set from measurement, not aspiration:

  * MAX_ERROR_ARCMIN = 0.6. Worst case is 0.285' on the 4 mm template
    (radial7-odd) and 0.528' on the 6 mm one (radial5-odd). The review asked for
    < 0.5', which the 6 mm template marginally misses, so the bar sits just above
    current behaviour - still far below the 100' - 1383' the pre-fix code
    produced on this grid. Worth revisiting on real station platepars.
  * Roll tracking 5e-3 deg. The relation is not exactly rigid because refraction
    is altitude-dependent while the finite difference samples slightly different
    altitudes: 9e-5 deg at centre altitude 85, 5e-4 at 36, 1.6e-3 at 15, 1.2e-2
    at 5. Pointings tested stop at 15 deg for that reason.
  * ENHt round-trip 0.15 px against 0.05 px for the plain ENU one, because the
    (E, N, Ht) form bisects for the height rather than projecting a known point.
    Measured max 0.085 px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…iew I1, I2)

I1 - height_wgs84 was a persisted derived value that went stale.

All four problems raised in the review were reproduced before the change:
a fresh Platepar() had no height_wgs84 at all, so every ported transform raised
AttributeError on one; pp.elev += 1000 left the value unchanged, while
Reprocess.py:120, ApplyRecalibrate.py:730,844 and the SkyFit2 station dialog all
overwrite elev; jsonStr() wrote it into every platepar file; and once present,
loadFromDict never recomputed it, baking the stale number in.

It is now a read-only-by-default property computed from lat/lon/elev on access.
Assigning to it stores an explicit override, since Janus sets it directly, and
the override is stripped in jsonStr() along with any height_wgs84 carried by
platepars written by earlier revisions of this branch, so nothing derived reaches
disk and nothing stale is preferred over a fresh computation. Note loadFromDict
replaces __dict__ wholesale, so the incoming key has to be popped explicitly
rather than relying on the descriptor.

Verified after the change: fresh Platepar() returns 168.0910 instead of raising;
elev += 1000 moves it by exactly 1000.0; jsonStr() contains neither key; a file
carrying a bogus 99999.0 recomputes to 168.0910; an explicit assignment is
honoured and still not written; the .cal text path still populates it.

I2 - the geoid spline was rebuilt on every conversion.

interpolateEGM96Data builds a 721x1440 RectSphereBivariateSpline, and
@memoizeSingle on the public functions keyed on lat/lon/height, caching a single
entry and resetting whenever the arguments differed. So every distinct conversion
paid the full construction: 0.096 s measured here, and Platepar.read() had gone
from ~0 to that.

Added geoidModel(file_path), memoized with memoizeAll and keyed on the resolved
data file path, which is the only thing the model depends on. memoizeSingle is
dropped from both public functions.

  Platepar read + height access   0.096 s  ->  0.0001 s
  per distinct conversion                      0.000006 s

Making the value derived (I1) is only affordable because of this, since the
property recomputes on every access.

Regression: all four Config-passing call sites, writeCAL, both legacy
loadEGM96Data forms and the 50-case transform suite still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…view I3, I5)

I3 - cyXYHttoENU_wgs84 dropped the poly3 extra radial terms.

Its undistort block is commented "IDENTICAL to cyXYToAltAz" but omitted the
x_poly_fwd[12]/[13] and y_poly_fwd[12]/[13] terms that cyXYToRADec, cyXYToAltAz,
cyGeoToXY_wgs84_iter, cyENUToXY_iter and cyENHtToXY_iter all apply for
poly3+radial3 and poly3+radial5, so XY->ENU and ENU->XY were not inverses for
those two distortion types. The terms are added, and all six kernels now carry
them.

Verified by perturbing the coefficients on a poly3+radial3 and a poly3+radial5
platepar, sized so each contributes about a pixel at the frame corner:

  coeff[12]=4e-12   ENU path moves 3.338'   cyXYToAltAz moves 3.338'
  coeff[13]=7e-18   ENU path moves 2.784'   cyXYToAltAz moves 2.783'

The two kernels agree to 0.004' - 0.005' afterwards. Before the change the ENU
path did not move at all.

The trailing else silently applied no distortion for an unrecognised dist_type,
returning plausible-looking wrong coordinates. Every valid type starts with
"poly3+radial" or "radial", so it now raises ValueError.

I5 - bracketing in cyENHt0ToENHt1 and cyENHtToXY_iter.

Three separate problems:

  * Both bracket-expansion loops widened either end but re-evaluated only at
    U_hi, so f_lo went stale whenever the U_lo side was the one moved. Each loop
    now re-evaluates the endpoint that actually moved.

  * Neither checked that a sign change had been found after the 8 expansion
    attempts, so bisection ran on an unbracketed interval and converged on an
    endpoint. Both now emit NaN, matching cyXYHttoENU_wgs84.

  * The second bisection in cyENHt0ToENHt1 (t_lo=10 m, t_hi=1e6 m) assumed height
    increases along the ray and always moved t_lo when hP < Ht1, which bisects
    the wrong way on a descending ray, and returned t_hi = 1e6 m as a valid
    position when Ht1 was never reached. It now evaluates both ends, returns NaN
    when Ht1 is not bracketed, and drives the bisection from the sign at t_lo so
    ascending and descending rays both work.

Verified: descending (11 km -> 8 km) and ascending (11 km -> 30 km) targets still
solve and preserve the line of sight exactly; unreachable targets (-500 km, 5000
km) now return NaN for all points instead of a t_hi position; enHtToXYPP returns
NaN for heights the ray cannot reach.

Cython rebuilds from a cleared ~/.pyxbld. Every prerelease kernel is unchanged
and the 50-case transform suite still passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Working through the Minor list from the PR review.

Cython:

  * cyXYToAltAz regains the @cython.boundscheck(False) / wraparound(False) /
    cdivision(True) decorators it has on the source branch, so the port is now
    verbatim there too.
  * r_corr and r_scale in cyXYToAltAz, and r_corr, r_scale and dX in
    cyXYHttoENU_wgs84, are declared double. Untyped they were Python objects
    inside the per-point loop.
  * geodetic_to_ecef, ecef_to_geodetic_bowring and R_ecef_from_enu are removed;
    all three were defined and never called.
  * cyXYToAltAz's docstring called az_centre a "Reference right ascension".

Python:

  * ENHt0ToENHt1 broadcasts length-1 heights over the points instead of raising.
    The docstring already promised "ndarray or float", but a scalar Ht1 with
    array E0/N0 raised ValueError. Mismatched non-scalar lengths still raise, and
    the message now mentions that heights may be scalar. The f-string is dropped
    for consistency with the rest of the file.
  * xyToAltAzPP, xyHtToENUPP and enuToXYPP accept a single point. The kernels take
    ndim=1 buffers, so a scalar was rejected with "Buffer has wrong number of
    dimensions"; the review flagged xyHtToENUPP's np.full(len(X_data), ...) but
    np.size alone was not enough, the inputs themselves needed atleast_1d.
  * enHtToXYPP's local cyENHtToXY_iter import moves to the module import block.
  * xyToAltAzPP's docstring documented an extinction_correction keyword it does
    not take.

_centreAltAz deviates from the source branch, deliberately. The review noted it
would be wrong if used, because cyTrueRaDec2ApparentAltAz precesses J2000 -> date
while RA_d/dec_d are already epoch-of-date in RMS. It is no longer dead code: the
pole-safe branch of rotationWrtHorizon calls it, so it now uses cyraDec2AltAz,
the same call the ENU wrappers make. Only the > 89 deg pole-safe threshold test
depends on it, so the effect is small, but the epoch handling was wrong either
way. Confirmed rotationWrtHorizon is unchanged for normal pointings (centre
altitudes 85/69/36/15 all return the same values as before).

Verified: Cython rebuilds from a cleared ~/.pyxbld; the 50-case suite passes;
scalar and array calls agree and a scalar round-trips 640.00, 360.00 exactly; all
12 sampled prerelease functions are byte-identical; an AST scan over every .py in
the repo finds no prerelease symbol lost; writeCAL and both Conversions helpers
still work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@KeatonBuddy
KeatonBuddy marked this pull request as ready for review September 11, 2026 17:19
@KeatonBuddy
KeatonBuddy marked this pull request as draft September 11, 2026 17:19
@KeatonBuddy
KeatonBuddy marked this pull request as ready for review September 11, 2026 17:19
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.

4 participants