Skip to content

Instrument centered coordinates for Moon/Sun sidelobe cuts - #1758

Draft
chervias wants to merge 27 commits into
masterfrom
chervias/instrument_centered_coordinates
Draft

chervias wants to merge 27 commits into
masterfrom
chervias/instrument_centered_coordinates

Conversation

@chervias

Copy link
Copy Markdown
Member

This change was implemented with Claude Code (Opus 5.5).
It requires the companion so3g PR that adds the det_left mode to
the projection engine (Assembly/ProjEng "det_left").

Summary

Adds a way to build a pointing matrix in instrument-centered
coordinates of a source (Moon, Sun, or any name accepted by
planets.get_source_pos), and uses it to flag samples from per-wafer
sidelobe masks delivered as observation metadata.

Instrument-centered coordinates give the position of the source
relative to each detector, on axes fixed to the telescope structure
(the focal plane xi/eta axes at zero roll). Unlike the previous
object-centered frame (detector pointing relative to the source), a
sidelobe that is fixed to the instrument stays fixed in this frame.

Quaternion chain

For detector d and sample t:

q[d,t] = A[d] * X[t]
X[t] = Rz(roll[t]) * q_bore[t]^-1 * q_src[t]            (sight.Q)
A[d] = Ry(pi/2) * Rz(roll0) * rotation_xieta(xi, eta, 0)^-1
       * Rz(-roll0)                                      (fp.quats)
  • q_bore is the standard CelestialSightLine.az_el boresight
    (qpoint * Rz(pi + roll)); q_src = rotation_lonlat(ra, dec) of the
    source. Only q_bore^-1 * q_src enters, so the horizon->celestial
    rotation cancels and the full-precision celestial pointing is used.
  • Rz(roll[t]) removes the focal-plane roll so the axes are fixed to the
    telescope structure; conjugating q_det by Rz(roll0) (circular mean
    roll) expresses the detector offsets in the same axes. Exact for
    constant roll; a warning is logged if roll varies by more than 0.1
    deg from its mean.
  • Detector quaternions are rebuilt with gamma = 0, since with q_det^-1
    on the left gamma would rotate each detector's map about the
    detector.
  • Ry(pi/2) moves the detector from the pole to (lon, lat) = (0, 0).

Map conventions: each detector is at (0, 0); for small offsets
lat ~ eta_src - eta_det and lon ~ -(xi_src - xi_det), so in a standard
CAR geometry right = +az and up = +el (commanded). For LAT observations
past the zenith (el > 90) the axes follow the structure and are
therefore flipped relative to the sky.

Changes

  • coords/pmat.py:
    • P has a det_left attribute, passed to Assembly.attach in _get_asm.
    • P.for_tod(..., instrument_centered=) builds the
      pointing above. Requires comps='T', geom given, rot=None and the
      boresight in az/el/roll (boresight_equ is not supported); raises
      ValueError otherwise.
    • New get_instrument_centered(sight, fp, roll, source, timestamps,
      site) returning the (sight, fp) pair for det_left projection.
  • coords/planets.py: new get_source_quat(source, timestamps, site,
    dt=600): source RA/Dec from get_source_pos on a grid of at most
    dt seconds, RA unwrapped and linearly interpolated to each sample,
    returned as rotation_lonlat quaternions.
  • coords/sidelobes.py: rewritten. Masks are no longer fixed files per
    object; they come from the observation metadata as aman.sidelobes
    (Enmap metadata loader): .map is an enmap [nfields, ny, nx] of 0/1
    in the instrument-centered coordinates of each object, labelled by
    the "fields" LabelAxis (e.g. ['moon', 'sun']).
    • get_cuts(aman, sidelobes="sidelobes", object_list=None,
      rise_tol=1 deg) returns one RangesMatrix [ndet, nsamp] per object.
      Clear errors if the metadata is missing or the map has no WCS.
    • make_cuts: skips objects below rise_tol at the first, middle and
      last sample; projects the mask with
      P.for_tod(instrument_centered=...); uses unit responsivities so the
      mask value is independent of the detector T response (the old
      astype(int) could round 1*T < 1 down to 0); cuts samples where the
      projected mask is non-zero.
  • site_pipeline/make_ml_map.py: call sidelobes.get_cuts(obs) with the
    new API. The --sun-mask/--moon-mask options are no longer used.
  • tests/test_pmat.py: test_pmat_instrument_centered checks gamma
    independence, that the angular distance from the origin equals the
    detector-source distance on the sky, the orientation for the
    boresight detector against the Moon's az/el, and that
    from_map/to_map run; comps='TQU' is rejected.

Validation on a LAT observation (el = 120 deg, roll = 60 deg): the
spread across detectors at fixed time is ~0.6 x 0.5 deg (one wafer),
and the boresight orientation check agrees with skyfield's Moon az/el
to ~1e-4 at all tested samples.

WARNING

This implementation can ONLY be used to go from a mask to flagged
samples, "T only". It CANNOT be used to make maps in
instrument-centered coordinates: polarization is not supported
(gamma is set to 0 and only comps='T' is allowed), and mapmaking in
these coordinates has not been designed or validated. That will need a
future implementation.

Known open point: the direction in which detector offsets are rotated
by the roll (+roll vs -roll) has not been verified independently; for a
~1 deg LAT wafer the effect is at most ~1 deg.

Co-Authored-By: Claude Opus 5.5 noreply@anthropic.com

mhasself and others added 27 commits September 9, 2026 15:46
These changes are needed to account for the non-linear distortions due
to the LAT's off-axis mirrors; otherwise only a single value of the
"roll" can be treated in the existing framework.

Introduces the idea of "focal_plane_template", a TOD member like
focal_plane that will be used as the source to compute the
(model-corrected) focal_plane.

This changes the apply_pointing_model interface a bit but probably not
in a way that users will notice.
mapcat's uuid7/datetime/path-rename migrations left this module behind:

- ctime/start_time/stop_time were passed as raw unix-time floats into
  columns mapcat's "Swapping to datetime" migration made datetime,
  causing session.commit() to raise
  "SQLite DateTime type only accepts Python datetime and date objects"
  on the very first commit_depth1_tods call (outside the mapmaking
  try/except, so it hard-crashes the pipeline before a map is made).
- commit_depth1_map passed time_path, a field DepthOneMapTable no
  longer has (split into start_time_path/mean_time_path/end_time_path).
  SQLModel silently drops unknown kwargs, so no time-map path was ever
  recorded.
- commit_depth1_tods passed map_name into TODDepthOneTable, which has
  no such column anymore (association is via a many-to-many
  relationship through TODToMapTable). The kwarg was silently ignored,
  so TODs were never actually linked to their map.

Verified against a real mapcat db migrated to head: commit succeeds,
ctime/start_time/stop_time land as datetimes, mean_time_path is
populated, and the TOD<->map relationship is linked in both
directions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rh1TMcQhLGM8TyQZ947Rvt
…elobe cuts

This change was implemented with Claude Code (Opus 5.5).
It requires the companion so3g change that adds the det_left mode to
the projection engine (Assembly/ProjEng "det_left").

Summary
-------
Adds a way to build a pointing matrix in instrument-centered
coordinates of a source (Moon, Sun, or any name accepted by
planets.get_source_pos), and uses it to flag samples from per-wafer
sidelobe masks delivered as observation metadata.

Instrument-centered coordinates give the position of the source
relative to each detector, on axes fixed to the telescope structure
(the focal plane xi/eta axes at zero roll). Unlike the previous
object-centered frame (detector pointing relative to the source), a
sidelobe that is fixed to the instrument stays fixed in this frame.

Quaternion chain
----------------
For detector d and sample t:

    q[d,t] = A[d] * X[t]
    X[t] = Rz(roll[t]) * q_bore[t]^-1 * q_src[t]            (sight.Q)
    A[d] = Ry(pi/2) * Rz(roll0) * rotation_xieta(xi, eta, 0)^-1
           * Rz(-roll0)                                      (fp.quats)

- q_bore is the standard CelestialSightLine.az_el boresight
  (qpoint * Rz(pi + roll)); q_src = rotation_lonlat(ra, dec) of the
  source. Only q_bore^-1 * q_src enters, so the horizon->celestial
  rotation cancels and the full-precision celestial pointing is used.
- Rz(roll[t]) removes the focal-plane roll so the axes are fixed to the
  telescope structure; conjugating q_det by Rz(roll0) (circular mean
  roll) expresses the detector offsets in the same axes. Exact for
  constant roll; a warning is logged if roll varies by more than 0.1
  deg from its mean.
- Detector quaternions are rebuilt with gamma = 0, since with q_det^-1
  on the left gamma would rotate each detector's map about the
  detector.
- Ry(pi/2) moves the detector from the pole to (lon, lat) = (0, 0).

Map conventions: each detector is at (0, 0); for small offsets
lat ~ eta_src - eta_det and lon ~ -(xi_src - xi_det), so in a standard
CAR geometry right = +az and up = +el (commanded). For LAT observations
past the zenith (el > 90) the axes follow the structure and are
therefore flipped relative to the sky.

Changes
-------
- coords/pmat.py:
  - P has a det_left attribute, passed to Assembly.attach in _get_asm.
  - P.for_tod(..., instrument_centered=<source name>) builds the
    pointing above. Requires comps='T', geom given, rot=None and the
    boresight in az/el/roll (boresight_equ is not supported); raises
    ValueError otherwise.
  - New get_instrument_centered(sight, fp, roll, source, timestamps,
    site) returning the (sight, fp) pair for det_left projection.
- coords/planets.py: new get_source_quat(source, timestamps, site,
  dt=600): source RA/Dec from get_source_pos on a grid of at most
  dt seconds, RA unwrapped and linearly interpolated to each sample,
  returned as rotation_lonlat quaternions.
- coords/sidelobes.py: rewritten. Masks are no longer fixed files per
  object; they come from the observation metadata as aman.sidelobes
  (Enmap metadata loader): .map is an enmap [nfields, ny, nx] of 0/1
  in the instrument-centered coordinates of each object, labelled by
  the "fields" LabelAxis (e.g. ['moon', 'sun']).
  - get_cuts(aman, sidelobes="sidelobes", object_list=None,
    rise_tol=1 deg) returns one RangesMatrix [ndet, nsamp] per object.
    Clear errors if the metadata is missing or the map has no WCS.
  - make_cuts: skips objects below rise_tol at the first, middle and
    last sample; projects the mask with
    P.for_tod(instrument_centered=...); uses unit responsivities so the
    mask value is independent of the detector T response (the old
    astype(int) could round 1*T < 1 down to 0); cuts samples where the
    projected mask is non-zero.
- site_pipeline/make_ml_map.py: call sidelobes.get_cuts(obs) with the
  new API. The --sun-mask/--moon-mask options are no longer used.
- tests/test_pmat.py: test_pmat_instrument_centered checks gamma
  independence, that the angular distance from the origin equals the
  detector-source distance on the sky, the orientation for the
  boresight detector against the Moon's az/el, and that
  from_map/to_map run; comps='TQU' is rejected.

Validation on a LAT observation (el = 120 deg, roll = 60 deg): the
spread across detectors at fixed time is ~0.6 x 0.5 deg (one wafer),
and the boresight orientation check agrees with skyfield's Moon az/el
to ~1e-4 at all tested samples.

WARNING
-------
This implementation can ONLY be used to go from a mask to flagged
samples, "T only". It CANNOT be used to make maps in
instrument-centered coordinates: polarization is not supported
(gamma is set to 0 and only comps='T' is allowed), and mapmaking in
these coordinates has not been designed or validated. That will need a
future implementation.

Known open point: the direction in which detector offsets are rotated
by the roll (+roll vs -roll) has not been verified independently; for a
~1 deg LAT wafer the effect is at most ~1 deg.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chervias

Copy link
Copy Markdown
Member Author

this will fail tests without the so3g PR, so we need to merge that first

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