Skip to content

PSF fourth-order moments in the sky frame (spin-2 + rho4) - #859

Merged
cailmdaley merged 8 commits into
developfrom
feat/psf-fourth-moments
Sep 28, 2026
Merged

cailmdaley merged 8 commits into
developfrom
feat/psf-fourth-moments

Conversation

@cailmdaley

@cailmdaley cailmdaley commented Jul 21, 2026 •

Copy link
Copy Markdown
Contributor

Closes #697. Supersedes #698.

Summary

The PSFEx chain now measures the fourth-order moments of the PSF model and of stars, alongside the second-order shape (g1, g2, T). They are measured directly in the sky frame, as the second moments have been since #812. Three new quantities per object:

Column Definition Spin
HSM_M4_1_{PSF,STAR} M40 − M04 2
HSM_M4_2_{PSF,STAR} 2(M13 + M31) 2
HSM_RHO4_{PSF,STAR} galsim moments_rho4 (radial kurtosis; 2 for a Gaussian) 0

Here M_pq are the whitened, Gaussian-weighted moments of PSFHOME (Zhang et al. 2022, 2023); see Design.

Problem

Scope

  • In: psfex_interp (single-epoch, validation and multi-epoch outputs), merge_starcat, and make_cat's per-epoch PSF slots.
  • Out: MCCD, which writes its own HSM columns (PSFEx is the fiducial PSF model).

Design

Measurement

For each PSF or star stamp (_fourth_moments in psfex_interp.py):

  1. HSM adaptive moments with use_sky_coords=True give the shape, σ and centroid in world coordinates. The second-moment matrix M is built from them.
  2. The local WCS Jacobian maps the pixel grid to world offsets from that centroid.
  3. The offsets are whitened with the symmetric M^{-1/2} (a matched elliptical Gaussian becomes a unit circle) and weighted with a unit Gaussian.
  4. The centred p+q=4 moments give M4_1 and M4_2. rho4 comes from the same HSM call.

Why measure in the sky frame: under a WCS map A, the whitened coordinates change by Q = M'^{-1/2} A M^{1/2}. That is a pure rotation or flip, and its angle depends on the object's own M. So the pixel-frame values differ from the sky values by an object-dependent rotation, not by a shear. Building M and the grid in world coordinates gives the sky values directly.

Data flow

flowchart LR
  P["psfex_interp<br/>_hsm_columns"] -->|validation catalogue| M["merge_starcat"] --> S[("merged star catalogue<br/>HSM_*_PSF, HSM_*_STAR")]
  P -->|"multi-epoch SHAPES (SAVE_PSF_DATA)"| C["make_cat"] --> F[("final galaxy catalogue<br/>HSM_*_PSF_n per-epoch slots")]
  P -.->|"star_cat_merge (PR 879)"| S2[("full_starcat_{campaign}.hdf5")]
  F -.->|"final_cat_merge (PR 879)"| F2[("final_cat_{campaign}.hdf5")]
Loading

Solid edges carry the new columns in this PR. Dashed edges are follow-ups.

  • One column mapper. _HSM_ROW names the shape-row slots once (G1, G2, SIGMA, FLAG, M4_1, M4_2, RHO4). _hsm_columns(shapes, obj) maps them to column names for all three psfex_interp writers, so no writer indexes a shape row by hand.
  • merge_starcat reads the six new columns by name, with no fallback. This avoids fourth-order moments #698's bug of filling M_4_PSF_2 from M_4_PSF_1.
  • make_cat carries HSM_M4_1/M4_2/RHO4_PSF_n in the same per-epoch slots as HSM_G1_PSF_n, from a single column table.

Missing values

  • psfex_interp outputs and the merged star catalogue:
    • On HSM failure: M4_1 = M4_2 = 0, RHO4 = −1 and HSM_FLAG_* = 1. This matches galsim's g = 0 on failure.
    • Select on the flag.
  • make_cat per-epoch slots:
    • A slot with no PSFEx fourth-moment measurement holds M4 = −10 and RHO4 = −1, like HSM_G1_PSF_n. That covers an empty slot, an HSM failure, MCCD, or an older producer.
    • For MCCD or an older producer, HSM_FLAG_PSF_n is 0, so select on RHO4 > 0.

Verification

  • Against theory (test_psf_fourth_moments.py):
    • An analytic oracle (Isserlis' theorem on a rotated two-component Gaussian) pins the signs, the factor 2 and the weight width.
    • The spin-2 rotation law pins the relative sign of the two components.
    • Invariance under WCS rotation and under world-frame shifts pins the frame and the centroid.
    • Masking and the failure fills are covered too.
  • Across modules (test_hsm_column_seams.py): merge_starcat must read exactly the HSM_* set that _hsm_columns writes, and make_cat a subset of it.
  • CI is green.

Cost and compatibility

Follow-ups

The #879 campaign merges pick fixed column lists, so the new columns stop there for now:

  • Star side: add the six HSM_M4_* / HSM_RHO4_* columns to COLUMNS in workflow/scripts/merge_star_cat.py.
  • Galaxy side: the tile config doesn't set SAVE_PSF_DATA, so no HSM_*_PSF_n slots are written. final_cat.param can list them only once make_cat writes a fixed number of slots per tile. Today it writes max(N_EPOCH)+1, which varies by tile.

— Claude (Opus) on behalf of Cail

🤖 Generated with Claude Code

cailmdaley and others added 6 commits September 26, 2026 02:22
Extend the sky-coordinate HSM measurement (PR #812) to also store
fourth-order moments for PSF and star:

- spin-2 combinations HSM_M4_1_* = M40 - M04 and HSM_M4_2_* = 2*(M13 + M31),
  ported from the PSFHOME/moment4 math, and
- galsim's spin-0 HSM_RHO4_* (moments_rho4).

Frame handling is the key change over the moment4 port. Whitening with the
symmetric sqrt(inv(M)) leaves the whitened axes aligned with the frame in
which M and the pixel grid are built, so the spin-2 combinations are defined
relative to those axes. Building them in the pixel frame ties them to the CCD
orientation. Instead _fourth_moments builds everything in the world frame: M
from the use_sky_coords world ellipticity/size, and the grid via the local WCS
Jacobian mapping pixel offsets to world offsets about the world centroid
(moments_centroid is relative to the stamp true_center). The result is
invariant to a re-orientation of the pixel frame viewing the same sky object.

HSM failures fill the spin-2 terms with 0 and pass rho4 through (-1); the FLAG
column stays the source of truth. Masked star pixels are zeroed before the
moment sum. Columns are added to the single-epoch, validation and multi-epoch
output paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K98geWHTuT62whqjKocfCm
…ments

Drive the real _get_psfshapes / _get_starshapes on galsim stamps rendered
through a nontrivial local WCS (scale + rotation + shear):

- round and elliptical Gaussians: whitened spin-2 fourth moments ~ 0 and
  rho4 ~ 2 (whitening circularises any single sheared circular profile, so an
  elliptical Moffat would also vanish -- a two-Gaussian composite is used to
  get a genuine non-zero spin-2 signal),
- frame invariance: one sky object rendered under WCS orientations of
  0/28/63/-45/90 deg gives the same world-frame fourth moments, on both the
  PSF and star paths,
- PSF/star agreement on a clean stamp, and HSM-failure sentinel handling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K98geWHTuT62whqjKocfCm
MergeStarCatPSFEX read and wrote only HSM_G1/G2/T/FLAG for PSF and STAR,
so the spin-2 fourth moments and rho4 written per-tile by psfex_interp
were dropped at the merge step. The merged full_starcat is the catalogue
PSF validation consumes, so the feature was inert past the per-tile stage.

Read and write HSM_M4_1/HSM_M4_2/HSM_RHO4 for both PSF and STAR, matching
the per-tile column grammar. Avoids the moment4 reference's copy-paste bug
(m4_2_psf now reads the _2 column) and adds RHO4, which the reference omitted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K98geWHTuT62whqjKocfCm
Two paths the summary claimed but the suite left unexercised:

- Bad-pixel masking: every star fixture had an all-zero mask, so zeroing
  masked pixels before the fourth-moment sum was a no-op in the suite.
  Add a test that plants the -1e30 sentinel in outskirt pixels, asserts the
  masked vignet reproduces the clean result, and asserts the same sentinels
  fed through un-zeroed blow the fourth moments up by ~5 orders (mutation:
  removing the zeroing makes M4_1 go -0.03 -> -4278).

- Centroid transform: every fixture was drawn centered, so moments_centroid
  ~ 0 and the sky-frame recentering was dead. Add a world-frame-shift case;
  the fourth moments stay invariant only because the transformed centroid is
  subtracted (mutation: dropping the subtraction fails both angles).

Also tighten the frame-invariance tolerances from rtol=1e-3/atol=1e-5 to
rtol=1e-5/atol=1e-6 (measured cross-orientation agreement is ~1e-8), so a
subtle partial-frame error at the 1e-4 level now bites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K98geWHTuT62whqjKocfCm
…_n slots

psfex_interp's multi-epoch SHAPES dict already holds HSM_M4_1/M4_2/RHO4_PSF;
_save_psf_data now copies them alongside G1/G2/T/FLAG, driven by one column
table (name, fill, dtype) instead of six hand-rolled slot initialisers. A
SHAPES dict from an older producer leaves the slot at its fill.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151inhpkat9cPPdMTG65Rwm
Static AST check that MergeStarCatPSFEX reads exactly the HSM_* set
_write_output_validation writes (both read by name with no fallback — the
seam behind the #698 copy-paste bug), and that make_cat's per-epoch columns
are a subset of what _interpolate_me writes into SHAPES. @sc contracts at
the four declarations name the invariants; the frozen-grammar regex learns
the M4_1/M4_2/RHO4 tokens.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151inhpkat9cPPdMTG65Rwm
@cailmdaley
cailmdaley force-pushed the feat/psf-fourth-moments branch from 97a4328 to b40dfc2 Compare September 26, 2026 00:22
@cailmdaley cailmdaley mentioned this pull request Sep 26, 2026
12 tasks
cailmdaley and others added 2 commits September 26, 2026 02:32
…moment fills in make_cat

_HSM_ROW names the shape-row slots once and _hsm_columns maps them to the
HSM_*_{PSF,STAR} grammar (sigma -> T, FLAG -> int), so the single-epoch,
validation and multi-epoch writers no longer each index [4],[5],[6] by
hand; the seam test takes the produced set from the helper. make_cat fills
unmeasured HSM_M4_*_PSF_n slots with -10 (like G1/G2) instead of 0.0, which
is a physically valid value and would let an MCCD or pre-fourth-moment
SHAPES dict pass for a measurement. Drops the stray log_-i.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151inhpkat9cPPdMTG65Rwm
…rth moments

The suite was null and invariance tests only, so a factor-1 or sign flip on
M4_2, swapped whitened axes, or a wrong weight width all passed. The oracle
predicts M4_1/M4_2 for a rotated two-component coaxial Gaussian from
Isserlis fourth moments of each whitened, weighted component (agreement
~5e-8, tolerance 1e-6); the rotation test checks (M4_1 + i M4_2) picks up
e^{2i dbeta}. Each of the four mutants now fails at least one of them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151inhpkat9cPPdMTG65Rwm

@sachaguer sachaguer left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This PR looks good to me, great job @cailmdaley !
I did not click approve by mistake but consider it done ;)

@cailmdaley
cailmdaley merged commit b7ada7f into develop Sep 28, 2026
3 checks passed
cailmdaley added a commit that referenced this pull request Sep 28, 2026
Compose N_EPOCH_SLOTS with develop's fourth-moment PSF columns (#859):
the fixed slot count applies to every per-epoch family, including
HSM_M4_1/M4_2/RHO4_PSF_n. TILE_LIST doc entry dropped as on develop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cailmdaley added a commit that referenced this pull request Sep 28, 2026
Develop carries #894 (squashed, with Martin's run_config fixpoint /
${name} expansion), #918's MCCD exposure chain, and #859's fourth-moment
PSF columns. Resolution:

- run_config: develop's variable table (every top-level scalar, ${name},
  fixpoint, `run` written back) with this branch's required `run:` and
  recursive unexpanded-$var refusal.
- MCCD chain (config_exp_mccd.ini, completeness.py, exposure.smk
  comment): develop's #918 wiring; psf_model=mccd stays refused at parse
  time for persistence.
- final_cat_merge replaces develop's merge_final_cats rule; config.yaml
  keeps `run:` unset (required).
- #859 composes with the campaign products: MergeStarCatPSFEX's two-pass
  _COLUMNS and merge_star_cat.py's COLUMNS gain the six M4/RHO4 columns
  (22 in both), and cfis/final_cat.param lists the per-epoch
  HSM_M4_1/M4_2/RHO4_PSF_n slots.
- make_cat: taken from the updated feat/make-cat-fixed-epoch-slots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

[NEW FEATURE] Fourth-order moments

2 participants