Skip to content

Commit 94e5a1c

Browse files
committed
Add second moment in sky /AltAz and fgcm phot for finalizeCharacterization.py.
1 parent 512d62d commit 94e5a1c

1 file changed

Lines changed: 315 additions & 5 deletions

File tree

python/lsst/pipe/tasks/finalizeCharacterization.py

Lines changed: 315 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
import esutil
4141
from smatch.matcher import Matcher
4242

43-
43+
import lsst.geom as geom
4444
import lsst.pex.config as pexConfig
4545
import lsst.pipe.base as pipeBase
4646
import lsst.daf.base as dafBase
@@ -97,7 +97,10 @@ def __init__(self, *, config=None):
9797
super().__init__(config=config)
9898
if config is None:
9999
return None
100-
if not self.config.psf_determiner['piff'].useColor:
100+
# Keep fgcm_standard_star if using chromatic PSF OR if adding FGCM photometry
101+
needs_fgcm = (self.config.psf_determiner['piff'].useColor
102+
or self.config.doAddFgcmPhotometry)
103+
if not needs_fgcm:
101104
self.inputs.remove("fgcm_standard_star")
102105

103106

@@ -209,6 +212,16 @@ class FinalizeCharacterizationConfigBase(
209212
target=ApplyApCorrTask,
210213
doc="Subtask to apply aperture corrections"
211214
)
215+
doAddSkyMoments = pexConfig.Field(
216+
dtype=bool,
217+
default=True,
218+
doc="Add second moments in sky (RA/Dec) and alt/az coordinates to the output table.",
219+
)
220+
doAddFgcmPhotometry = pexConfig.Field(
221+
dtype=bool,
222+
default=True,
223+
doc="Add FGCM photometry for all bands (u,g,r,i,z,y) to the output table.",
224+
)
212225

213226
def setDefaults(self):
214227
super().setDefaults()
@@ -440,6 +453,84 @@ def _make_output_schema_mapper(self, input_schema):
440453
doReplace=True,
441454
)
442455

456+
if self.config.doAddSkyMoments:
457+
# Sky coordinate moments for source shape (arcsec^2)
458+
output_schema.addField(
459+
'shape_Iuu',
460+
type=np.float32,
461+
doc="Second moment of source shape along RA axis in sky coordinates (arcsec^2).",
462+
)
463+
output_schema.addField(
464+
'shape_Ivv',
465+
type=np.float32,
466+
doc="Second moment of source shape along Dec axis in sky coordinates (arcsec^2).",
467+
)
468+
output_schema.addField(
469+
'shape_Iuv',
470+
type=np.float32,
471+
doc="Cross-term of source shape second moments in sky coordinates (arcsec^2).",
472+
)
473+
474+
# Sky coordinate moments for PSF shape (arcsec^2)
475+
output_schema.addField(
476+
'psfShape_Iuu',
477+
type=np.float32,
478+
doc="Second moment of PSF shape along RA axis in sky coordinates (arcsec^2).",
479+
)
480+
output_schema.addField(
481+
'psfShape_Ivv',
482+
type=np.float32,
483+
doc="Second moment of PSF shape along Dec axis in sky coordinates (arcsec^2).",
484+
)
485+
output_schema.addField(
486+
'psfShape_Iuv',
487+
type=np.float32,
488+
doc="Cross-term of PSF shape second moments in sky coordinates (arcsec^2).",
489+
)
490+
491+
# Alt/Az coordinate moments for source shape (arcsec^2)
492+
output_schema.addField(
493+
'shape_Ialtalt',
494+
type=np.float32,
495+
doc="Second moment of source shape along altitude axis (arcsec^2).",
496+
)
497+
output_schema.addField(
498+
'shape_Iazaz',
499+
type=np.float32,
500+
doc="Second moment of source shape along azimuth axis (arcsec^2).",
501+
)
502+
output_schema.addField(
503+
'shape_Ialtaz',
504+
type=np.float32,
505+
doc="Cross-term of source shape second moments in alt/az coordinates (arcsec^2).",
506+
)
507+
508+
# Alt/Az coordinate moments for PSF shape (arcsec^2)
509+
output_schema.addField(
510+
'psfShape_Ialtalt',
511+
type=np.float32,
512+
doc="Second moment of PSF shape along altitude axis (arcsec^2).",
513+
)
514+
output_schema.addField(
515+
'psfShape_Iazaz',
516+
type=np.float32,
517+
doc="Second moment of PSF shape along azimuth axis (arcsec^2).",
518+
)
519+
output_schema.addField(
520+
'psfShape_Ialtaz',
521+
type=np.float32,
522+
doc="Cross-term of PSF shape second moments in alt/az coordinates (arcsec^2).",
523+
)
524+
525+
if self.config.doAddFgcmPhotometry:
526+
# FGCM photometry for all bands
527+
for band_name in ('u', 'g', 'r', 'i', 'z', 'y'):
528+
output_schema.addField(
529+
f'fgcm_mag_{band_name}',
530+
type=np.float32,
531+
doc=f"FGCM standard star magnitude in {band_name} band (mag).",
532+
)
533+
443534
alias_map = input_schema.getAliasMap()
444535
alias_map_output = afwTable.AliasMap()
445536
alias_map_output.set('slot_Centroid', alias_map.get('slot_Centroid'))
@@ -492,6 +583,129 @@ def _make_selection_schema_mapper(self, input_schema):
492583

493584
return mapper, selection_schema
494585

586+
def _compute_sky_moments(self, wcs, x, y, ixx, iyy, ixy):
587+
"""Compute second moments in sky coordinates from pixel moments.
588+
589+
Transforms the second moments tensor from pixel coordinates to sky
590+
coordinates (RA/Dec) using the local WCS CD matrix at each source
591+
position.
592+
593+
Parameters
594+
----------
595+
wcs : `lsst.afw.geom.SkyWcs`
596+
The WCS of the exposure.
597+
x : `numpy.ndarray`
598+
X pixel coordinates of the sources.
599+
y : `numpy.ndarray`
600+
Y pixel coordinates of the sources.
601+
ixx : `numpy.ndarray`
602+
Second moment Ixx in pixel coordinates (pixels^2).
603+
iyy : `numpy.ndarray`
604+
Second moment Iyy in pixel coordinates (pixels^2).
605+
ixy : `numpy.ndarray`
606+
Second moment Ixy in pixel coordinates (pixels^2).
607+
608+
Returns
609+
-------
610+
iuu : `numpy.ndarray`
611+
Second moment along RA axis in sky coordinates (arcsec^2).
612+
ivv : `numpy.ndarray`
613+
Second moment along Dec axis in sky coordinates (arcsec^2).
614+
iuv : `numpy.ndarray`
615+
Cross-term of second moments in sky coordinates (arcsec^2).
616+
"""
617+
n_sources = len(x)
618+
iuu = np.full(n_sources, np.nan, dtype=np.float32)
619+
ivv = np.full(n_sources, np.nan, dtype=np.float32)
620+
iuv = np.full(n_sources, np.nan, dtype=np.float32)
621+
622+
# Conversion factor from degrees^2 to arcsec^2
623+
# (CD matrix is in degrees/pixel per FITS standard)
624+
deg2_to_arcsec2 = 3600.0 ** 2
625+
626+
for i in range(n_sources):
627+
# Skip sources with invalid moments
628+
if not np.isfinite(ixx[i]) or not np.isfinite(iyy[i]) or not np.isfinite(ixy[i]):
629+
continue
630+
631+
center = geom.Point2D(x[i], y[i])
632+
cd_matrix = wcs.getCdMatrix(center)
633+
634+
cd11 = cd_matrix[0, 0]
635+
cd12 = cd_matrix[0, 1]
636+
cd21 = cd_matrix[1, 0]
637+
cd22 = cd_matrix[1, 1]
638+
639+
# Transform moments: M_sky = CD * M_pixel * CD^T
640+
# Iuu = CD11*(Ixx*CD11 + Ixy*CD12) + CD12*(Ixy*CD11 + Iyy*CD12)
641+
iuu[i] = (cd11 * (ixx[i] * cd11 + ixy[i] * cd12)
642+
+ cd12 * (ixy[i] * cd11 + iyy[i] * cd12))
643+
# Ivv = CD21*(Ixx*CD21 + Ixy*CD22) + CD22*(Ixy*CD21 + Iyy*CD22)
644+
ivv[i] = (cd21 * (ixx[i] * cd21 + ixy[i] * cd22)
645+
+ cd22 * (ixy[i] * cd21 + iyy[i] * cd22))
646+
# Iuv = (CD11*Ixx + CD12*Ixy)*CD21 + (CD11*Ixy + CD12*Iyy)*CD22
647+
iuv[i] = ((cd11 * ixx[i] + cd12 * ixy[i]) * cd21
648+
+ (cd11 * ixy[i] + cd12 * iyy[i]) * cd22)
649+
650+
# Convert from degrees^2 to arcsec^2
651+
iuu[i] *= deg2_to_arcsec2
652+
ivv[i] *= deg2_to_arcsec2
653+
iuv[i] *= deg2_to_arcsec2
654+
655+
return iuu, ivv, iuv
656+
657+
def _rotate_moments_to_altaz(self, iuu, ivv, iuv, parallactic_angle):
658+
"""Rotate second moments from RA/Dec to Alt/Az coordinates.
659+
660+
Rotates the second moments tensor from equatorial (RA/Dec) coordinates
661+
to horizontal (Alt/Az) coordinates using the parallactic angle.
662+
663+
Parameters
664+
----------
665+
iuu : `numpy.ndarray`
666+
Second moment along RA axis in sky coordinates (arcsec^2).
667+
ivv : `numpy.ndarray`
668+
Second moment along Dec axis in sky coordinates (arcsec^2).
669+
iuv : `numpy.ndarray`
670+
Cross-term of second moments in sky coordinates (arcsec^2).
671+
parallactic_angle : `lsst.geom.Angle`
672+
The parallactic angle (from visitInfo.boresightParAngle).
673+
674+
Returns
675+
-------
676+
ialtalt : `numpy.ndarray`
677+
Second moment along altitude axis (arcsec^2).
678+
iazaz : `numpy.ndarray`
679+
Second moment along azimuth axis (arcsec^2).
680+
ialtaz : `numpy.ndarray`
681+
Cross-term of second moments in alt/az coordinates (arcsec^2).
682+
"""
683+
from lsst.afw.geom import Quadrupole
684+
from lsst.geom import LinearTransform
685+
686+
n_sources = len(iuu)
687+
ialtalt = np.full(n_sources, np.nan, dtype=np.float32)
688+
iazaz = np.full(n_sources, np.nan, dtype=np.float32)
689+
ialtaz = np.full(n_sources, np.nan, dtype=np.float32)
690+
691+
# Create the rotation transformation for the parallactic angle
692+
rot_transform = LinearTransform.makeRotation(parallactic_angle)
693+
694+
for i in range(n_sources):
695+
# Skip sources with invalid moments
696+
if not np.isfinite(iuu[i]) or not np.isfinite(ivv[i]) or not np.isfinite(iuv[i]):
697+
continue
698+
699+
# Create a Quadrupole from the sky moments and rotate it
700+
shape = Quadrupole(iuu[i], ivv[i], iuv[i])
701+
rot_shape = shape.transform(rot_transform)
702+
703+
ialtalt[i] = rot_shape.getIxx()
704+
iazaz[i] = rot_shape.getIyy()
705+
ialtaz[i] = rot_shape.getIxy()
706+
707+
return ialtalt, iazaz, ialtaz
708+
495709
def concat_isolated_star_cats(
496710
self,
497711
band,
@@ -648,7 +862,10 @@ def concat_isolated_star_cats(
648862

649863
def add_src_colors(self, srcCat, fgcmCat, band):
650864

651-
if self.isPsfDeterminerPiff and fgcmCat is not None:
865+
needColor = self.isPsfDeterminerPiff and fgcmCat is not None
866+
needColor &= self.config.psf_determiner['piff'].useColor
867+
868+
if needColor:
652869

653870
raSrc = (srcCat['coord_ra'] * u.radian).to(u.degree).value
654871
decSrc = (srcCat['coord_dec'] * u.radian).to(u.degree).value
@@ -669,6 +886,43 @@ def add_src_colors(self, srcCat, fgcmCat, band):
669886
srcCat[idSrc]['psf_color_value'] = colors[idColor]
670887
srcCat[idSrc]['psf_color_type'] = f"{magStr1}-{magStr2}"
671888

889+
def add_fgcm_photometry(self, srcCat, fgcmCat):
890+
"""Add FGCM photometry for all bands to the source catalog.
891+
892+
Parameters
893+
----------
894+
srcCat : `lsst.afw.table.SourceCatalog`
895+
Source catalog to add photometry to.
896+
fgcmCat : `numpy.ndarray`
897+
FGCM standard star catalog with ra, dec, and mag_* columns.
898+
"""
899+
# Initialize all FGCM magnitude columns to NaN
900+
for band_name in ('u', 'g', 'r', 'i', 'z', 'y'):
901+
output_col = f'fgcm_mag_{band_name}'
902+
srcCat[output_col] = np.nan
903+
904+
if fgcmCat is None:
905+
return
906+
907+
raSrc = (srcCat['coord_ra'] * u.radian).to(u.degree).value
908+
decSrc = (srcCat['coord_dec'] * u.radian).to(u.degree).value
909+
910+
with Matcher(raSrc, decSrc) as matcher:
911+
idx, idxSrcCat, idxFgcmCat, d = matcher.query_radius(
912+
fgcmCat["ra"],
913+
fgcmCat["dec"],
914+
1. / 3600.0,
915+
return_indices=True,
916+
)
917+
918+
# Add FGCM magnitudes for all bands
919+
for band_name in ('u', 'g', 'r', 'i', 'z', 'y'):
920+
mag_col = f'mag_{band_name}'
921+
output_col = f'fgcm_mag_{band_name}'
922+
if mag_col in fgcmCat.dtype.names:
923+
for idSrc, idFgcm in zip(idxSrcCat, idxFgcmCat):
924+
srcCat[idSrc][output_col] = fgcmCat[mag_col][idFgcm]
925+
672926
def compute_psf_and_ap_corr_map(self, visit, detector, exposure, src,
673927
isolated_source_table, fgcm_standard_star_cat):
674928
"""Compute psf model and aperture correction map for a single exposure.
@@ -758,6 +1012,10 @@ def compute_psf_and_ap_corr_map(self, visit, detector, exposure, src,
7581012
self.add_src_colors(selected_src, fgcm_standard_star_cat, band)
7591013
self.add_src_colors(measured_src, fgcm_standard_star_cat, band)
7601014

1015+
# Add FGCM photometry for all bands to the output catalog
1016+
if self.config.doAddFgcmPhotometry:
1017+
self.add_fgcm_photometry(measured_src, fgcm_standard_star_cat)
1018+
7611019
# Select the psf candidates from the selection catalog
7621020
try:
7631021
psf_selection_result = self.make_psf_candidates.run(selected_src, exposure=exposure)
@@ -815,6 +1073,54 @@ def compute_psf_and_ap_corr_map(self, visit, detector, exposure, src,
8151073
visit, detector, e)
8161074
return psf, None, measured_src
8171075

1076+
# Compute sky and alt/az coordinate moments for source and PSF shapes.
1077+
if self.config.doAddSkyMoments:
1078+
wcs = exposure.getWcs()
1079+
x = np.array(measured_src['slot_Centroid_x'])
1080+
y = np.array(measured_src['slot_Centroid_y'])
1081+
1082+
# Source shape sky moments
1083+
shape_xx = np.array(measured_src['slot_Shape_xx'])
1084+
shape_yy = np.array(measured_src['slot_Shape_yy'])
1085+
shape_xy = np.array(measured_src['slot_Shape_xy'])
1086+
shape_iuu, shape_ivv, shape_iuv = self._compute_sky_moments(
1087+
wcs, x, y, shape_xx, shape_yy, shape_xy
1088+
)
1089+
measured_src['shape_Iuu'] = shape_iuu
1090+
measured_src['shape_Ivv'] = shape_ivv
1091+
measured_src['shape_Iuv'] = shape_iuv
1092+
1093+
# PSF shape sky moments
1094+
psf_xx = np.array(measured_src['slot_PsfShape_xx'])
1095+
psf_yy = np.array(measured_src['slot_PsfShape_yy'])
1096+
psf_xy = np.array(measured_src['slot_PsfShape_xy'])
1097+
psf_iuu, psf_ivv, psf_iuv = self._compute_sky_moments(
1098+
wcs, x, y, psf_xx, psf_yy, psf_xy
1099+
)
1100+
measured_src['psfShape_Iuu'] = psf_iuu
1101+
measured_src['psfShape_Ivv'] = psf_ivv
1102+
measured_src['psfShape_Iuv'] = psf_iuv
1103+
1104+
# Rotate sky moments to alt/az coordinates using the parallactic angle.
1105+
visit_info = exposure.info.getVisitInfo()
1106+
parallactic_angle = visit_info.boresightParAngle
1107+
1108+
# Source shape alt/az moments
1109+
shape_ialtalt, shape_iazaz, shape_ialtaz = self._rotate_moments_to_altaz(
1110+
shape_iuu, shape_ivv, shape_iuv, parallactic_angle
1111+
)
1112+
measured_src['shape_Ialtalt'] = shape_ialtalt
1113+
measured_src['shape_Iazaz'] = shape_iazaz
1114+
measured_src['shape_Ialtaz'] = shape_ialtaz
1115+
1116+
# PSF shape alt/az moments
1117+
psf_ialtalt, psf_iazaz, psf_ialtaz = self._rotate_moments_to_altaz(
1118+
psf_iuu, psf_ivv, psf_iuv, parallactic_angle
1119+
)
1120+
measured_src['psfShape_Ialtalt'] = psf_ialtalt
1121+
measured_src['psfShape_Iazaz'] = psf_iazaz
1122+
measured_src['psfShape_Ialtaz'] = psf_ialtaz
1123+
8181124
# And finally the ap corr map.
8191125
try:
8201126
ap_corr_map = self.measure_ap_corr.run(exposure=exposure,
@@ -864,7 +1170,9 @@ def runQuantum(self, butlerQC, inputRefs, outputRefs):
8641170
isolated_star_source_dict = {tract: isolated_star_source_dict_temp[tract] for
8651171
tract in sorted(isolated_star_source_dict_temp.keys())}
8661172

867-
if self.config.psf_determiner['piff'].useColor:
1173+
needs_fgcm = (self.config.psf_determiner['piff'].useColor
1174+
or self.config.doAddFgcmPhotometry)
1175+
if needs_fgcm:
8681176
fgcm_standard_star_dict_temp = {handle.dataId['tract']: handle
8691177
for handle in input_handle_dict['fgcm_standard_star']}
8701178
fgcm_standard_star_dict = {tract: fgcm_standard_star_dict_temp[tract] for
@@ -1034,7 +1342,9 @@ def runQuantum(self, butlerQC, inputRefs, outputRefs):
10341342
isolated_star_source_dict = {tract: isolated_star_source_dict_temp[tract] for
10351343
tract in sorted(isolated_star_source_dict_temp.keys())}
10361344

1037-
if self.config.psf_determiner['piff'].useColor:
1345+
needs_fgcm = (self.config.psf_determiner['piff'].useColor
1346+
or self.config.doAddFgcmPhotometry)
1347+
if needs_fgcm:
10381348
fgcm_standard_star_dict_temp = {handle.dataId['tract']: handle
10391349
for handle in input_handle_dict['fgcm_standard_star']}
10401350
fgcm_standard_star_dict = {tract: fgcm_standard_star_dict_temp[tract] for

0 commit comments

Comments
 (0)