4040import esutil
4141from smatch .matcher import Matcher
4242
43-
43+ import lsst . geom as geom
4444import lsst .pex .config as pexConfig
4545import lsst .pipe .base as pipeBase
4646import 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