3535import lsst .afw .image as afwImage
3636import lsst .geom as geom
3737from lsst .meas .algorithms import ScienceSourceSelectorTask
38+ from lsst .meas .algorithms .computeExPsf import ComputeExPsfTask
3839from lsst .utils .timer import timeMethod
3940import lsst .ip .isr as ipIsr
4041
@@ -137,6 +138,22 @@ class ComputeExposureSummaryStatsConfig(pexConfig.Config):
137138 doc = "Signal-to-noise ratio for computing the magnitude limit depth." ,
138139 default = 5.0
139140 )
141+ psfTE1 = pexConfig .ConfigurableField (
142+ target = ComputeExPsfTask ,
143+ doc = "Use treecorr for computing scalar value of TE1." ,
144+ )
145+ psfTE2 = pexConfig .ConfigurableField (
146+ target = ComputeExPsfTask ,
147+ doc = "Use treecorr for computing scalar value of TE2." ,
148+ )
149+ psfTE3 = pexConfig .ConfigurableField (
150+ target = ComputeExPsfTask ,
151+ doc = "Use treecorr for computing scalar value of TE3." ,
152+ )
153+ psfTE4 = pexConfig .ConfigurableField (
154+ target = ComputeExPsfTask ,
155+ doc = "Use treecorr for computing scalar value of TE4." ,
156+ )
140157
141158 def setDefaults (self ):
142159 super ().setDefaults ()
@@ -159,6 +176,23 @@ def setDefaults(self):
159176 self .starSelector .signalToNoise .fluxField = "slot_PsfFlux_instFlux"
160177 self .starSelector .signalToNoise .errField = "slot_PsfFlux_instFluxErr"
161178
179+ min_theta = [1e-6 , 5.0 , 1e-6 , 5.0 ]
180+ max_theta = [1.0 , 100.0 , 5.0 , 20.0 ]
181+ psfTEx = [
182+ self .psfTE1 ,
183+ self .psfTE2 ,
184+ self .psfTE3 ,
185+ self .psfTE4 ,
186+ ]
187+
188+ for tex , mint , maxt in zip (psfTEx , min_theta , max_theta ):
189+ tex .setDefaults ()
190+ tex .treecorr .min_sep = mint / 60.0
191+ tex .treecorr .max_sep = maxt / 60.0
192+ tex .treecorr .nbins = 1
193+ tex .treecorr .bin_type = "Linear"
194+ tex .treecorr .sep_units = "degree"
195+
162196
163197class ComputeExposureSummaryStatsTask (pipeBase .Task ):
164198 """Task to compute exposure summary statistics.
@@ -200,6 +234,22 @@ class ComputeExposureSummaryStatsTask(pipeBase.Task):
200234 - psfTraceRadiusDelta
201235 - psfApFluxDelta
202236
237+ These quantities are computed as part of:
238+ https://rubinobs.atlassian.net/browse/DM-40780
239+
240+ - psfTE1e1
241+ - psfTE1e2
242+ - psfTE1ex
243+ - psfTE2e1
244+ - psfTE2e2
245+ - psfTE2ex
246+ - psfTE3e1
247+ - psfTE3e2
248+ - psfTE3ex
249+ - psfTE4e1
250+ - psfTE4e2
251+ - psfTE4ex
252+
203253 This quantity is computed based on the aperture correction map, the
204254 psfSigma, and the image mask to assess the robustness of the aperture
205255 corrections across a given detector:
@@ -219,6 +269,11 @@ def __init__(self, **kwargs):
219269 super ().__init__ (** kwargs )
220270
221271 self .makeSubtask ("starSelector" )
272+ self .makeSubtask ("psfTE1" )
273+ self .makeSubtask ("psfTE2" )
274+ self .makeSubtask ("psfTE3" )
275+ self .makeSubtask ("psfTE4" )
276+ self ._isTEXComputationDone = False
222277
223278 @timeMethod
224279 def run (self , exposure , sources , background ):
@@ -408,10 +463,13 @@ def update_psf_stats(
408463 psfE1 = (psfXX - psfYY )/ (psfXX + psfYY )
409464 psfE2 = 2 * psfXY / (psfXX + psfYY )
410465
411- psfStarDeltaE1Median = np .median (starE1 - psfE1 )
412- psfStarDeltaE1Scatter = sigmaMad (starE1 - psfE1 , scale = 'normal' )
413- psfStarDeltaE2Median = np .median (starE2 - psfE2 )
414- psfStarDeltaE2Scatter = sigmaMad (starE2 - psfE2 , scale = 'normal' )
466+ e1Residuals = starE1 - psfE1
467+ e2Residuals = starE2 - psfE2
468+
469+ psfStarDeltaE1Median = np .median (e1Residuals )
470+ psfStarDeltaE1Scatter = sigmaMad (e1Residuals , scale = 'normal' )
471+ psfStarDeltaE2Median = np .median (e2Residuals )
472+ psfStarDeltaE2Scatter = sigmaMad (e2Residuals , scale = 'normal' )
415473
416474 psfStarDeltaSizeMedian = np .median (starSize - psfSize )
417475 psfStarDeltaSizeScatter = sigmaMad (starSize - psfSize , scale = 'normal' )
@@ -434,6 +492,162 @@ def update_psf_stats(
434492 )
435493 summary .maxDistToNearestPsf = float (maxDistToNearestPsf )
436494
495+ def comp_psf_TEX_visit_level (self , summary , sources , sources_is_astropy = False ):
496+ """Compute all summary-statistic fields at visit level for TEx metric of PSF.
497+
498+ Parameters
499+ ----------
500+ summary : `lsst.afw.image.ExposureSummaryStats`
501+ Summary object to update in-place.
502+ sources : `lsst.afw.table.SourceCatalog` or `astropy.table.Table`
503+ Catalog for quantities that are computed from source table columns.
504+ If `None`, these quantities will be reset (generally to NaN).
505+ The type of this table must correspond to the
506+ ``sources_is_astropy`` argument.
507+ sources_is_astropy : `bool`, optional
508+ Whether ``sources`` is an `astropy.table.Table` instance instead
509+ of an `lsst.afw.table.Catalog` instance.
510+ """
511+
512+ if self ._isTEXComputationDone :
513+
514+ summary .psfTE1e1 = self .psfTE1e1
515+ summary .psfTE1e2 = self .psfTE1e2
516+ summary .psfTE1ex = self .psfTE1ex
517+ summary .psfTE2e1 = self .psfTE2e1
518+ summary .psfTE2e2 = self .psfTE2e2
519+ summary .psfTE2ex = self .psfTE2ex
520+ summary .psfTE3e1 = self .psfTE3e1
521+ summary .psfTE3e2 = self .psfTE3e2
522+ summary .psfTE3ex = self .psfTE3ex
523+ summary .psfTE4e1 = self .psfTE4e1
524+ summary .psfTE4e2 = self .psfTE4e2
525+ summary .psfTE4ex = self .psfTE4ex
526+
527+ else :
528+
529+ self ._isTEXComputationDone = True
530+
531+ nan = float ("nan" )
532+ summary .psfTE1e1 , self .psfTE1e1 = nan , nan
533+ summary .psfTE1e2 , self .psfTE1e2 = nan , nan
534+ summary .psfTE1ex , self .psfTE1ex = nan , nan
535+ summary .psfTE2e1 , self .psfTE2e1 = nan , nan
536+ summary .psfTE2e2 , self .psfTE2e2 = nan , nan
537+ summary .psfTE2ex , self .psfTE2ex = nan , nan
538+ summary .psfTE3e1 , self .psfTE3e1 = nan , nan
539+ summary .psfTE3e2 , self .psfTE3e2 = nan , nan
540+ summary .psfTE3ex , self .psfTE3ex = nan , nan
541+ summary .psfTE4e1 , self .psfTE4e1 = nan , nan
542+ summary .psfTE4e2 , self .psfTE4e2 = nan , nan
543+ summary .psfTE4ex , self .psfTE4ex = nan , nan
544+
545+ psf_mask = self .starSelector .run (sources ).selected
546+
547+ nPsfStarsUsedInStats = psf_mask .sum ()
548+
549+ if nPsfStarsUsedInStats == 0 :
550+ # No stars to measure statistics, so we must return the defaults
551+ # of 0 stars and NaN values.
552+ return
553+
554+ if sources_is_astropy :
555+ psf_cat = sources [psf_mask ]
556+ else :
557+ psf_cat = sources [psf_mask ].copy (deep = True )
558+
559+ starXX = psf_cat [self .config .starShape + '_xx' ]
560+ starYY = psf_cat [self .config .starShape + '_yy' ]
561+ starXY = psf_cat [self .config .starShape + '_xy' ]
562+ psfXX = psf_cat [self .config .psfShape + '_xx' ]
563+ psfYY = psf_cat [self .config .psfShape + '_yy' ]
564+ psfXY = psf_cat [self .config .psfShape + '_xy' ]
565+
566+ starE1 = (starXX - starYY )/ (starXX + starYY )
567+ starE2 = 2 * starXY / (starXX + starYY )
568+
569+ psfE1 = (psfXX - psfYY )/ (psfXX + psfYY )
570+ psfE2 = 2 * psfXY / (psfXX + psfYY )
571+
572+ e1Residuals = starE1 - psfE1
573+ e2Residuals = starE2 - psfE2
574+
575+ # Comp TEx
576+ ra = psf_cat ["coord_ra" ].to (units .deg )
577+ dec = psf_cat ["coord_dec" ].to (units .deg )
578+
579+ psfTEx = {
580+ "TE1" : self .psfTE1 ,
581+ "TE2" : self .psfTE2 ,
582+ }
583+
584+ gatherE12Stat = {
585+ "TE1" : {"E1" : np .nan , "E2" : np .nan , "Ex" : np .nan , },
586+ "TE2" : {"E1" : np .nan , "E2" : np .nan , "Ex" : np .nan , },
587+ }
588+
589+ isNotNan = np .array ([True ] * len (ra ))
590+ isNotNan &= np .isfinite (ra )
591+ isNotNan &= np .isfinite (dec )
592+ isNotNan &= np .isfinite (e1Residuals )
593+ isNotNan &= np .isfinite (e2Residuals )
594+
595+ if np .sum (isNotNan ) >= 2 :
596+ # TE1 and TE2 computation, over visit.
597+ for TEX in ["TE1" , "TE2" ]:
598+
599+ output = psfTEx [TEX ].run (
600+ e1Residuals [isNotNan ], e2Residuals [isNotNan ],
601+ ra [isNotNan ], dec [isNotNan ],
602+ units = "degree" ,
603+ )
604+
605+ gatherE12Stat [TEX ]["E1" ] = output .metric_E1
606+ gatherE12Stat [TEX ]["E2" ] = output .metric_E2
607+ gatherE12Stat [TEX ]["Ex" ] = output .metric_Ex
608+
609+ # TE3 and TE4 loop over detector and then median on visit.
610+
611+ psfTEx = {
612+ "TE3" : self .psfTE3 ,
613+ "TE4" : self .psfTE4 ,
614+ }
615+
616+ gatherE34Stat = {
617+ "TE3" : {"E1" : [], "E2" : [], "Ex" : [], },
618+ "TE4" : {"E1" : [], "E2" : [], "Ex" : [], },
619+ }
620+ # calibrateImage run at detector level,
621+ # need to wait second run of PSF to run this.
622+ if "detector" in psf_cat .colnames :
623+ detectorIds = list (set (psf_cat ["detector" ]))
624+ for TEX in ["TE3" , "TE4" ]:
625+ for ccdId in detectorIds :
626+ isccdId = (ccdId == psf_cat ["detector" ])
627+ mask = (isccdId & isNotNan )
628+ if np .sum (mask ) >= 2 :
629+ output = psfTEx [TEX ].run (
630+ e1Residuals [mask ], e2Residuals [mask ],
631+ ra [mask ], dec [mask ],
632+ units = "degree" ,
633+ )
634+ gatherE34Stat [TEX ]["E1" ].append (output .metric_E1 )
635+ gatherE34Stat [TEX ]["E2" ].append (output .metric_E2 )
636+ gatherE34Stat [TEX ]["Ex" ].append (output .metric_Ex )
637+
638+ self .psfTE1e1 = summary .psfTE1e1 = gatherE12Stat ["TE1" ]["E1" ]
639+ self .psfTE1e2 = summary .psfTE1e2 = gatherE12Stat ["TE1" ]["E2" ]
640+ self .psfTE1ex = summary .psfTE1ex = gatherE12Stat ["TE1" ]["Ex" ]
641+ self .psfTE2e1 = summary .psfTE2e1 = gatherE12Stat ["TE2" ]["E1" ]
642+ self .psfTE2e2 = summary .psfTE2e2 = gatherE12Stat ["TE2" ]["E2" ]
643+ self .psfTE2ex = summary .psfTE2ex = gatherE12Stat ["TE2" ]["Ex" ]
644+ self .psfTE3e1 = summary .psfTE3e1 = np .nanmedian (gatherE34Stat ["TE3" ]["E1" ])
645+ self .psfTE3e2 = summary .psfTE3e2 = np .nanmedian (gatherE34Stat ["TE3" ]["E2" ])
646+ self .psfTE3ex = summary .psfTE3ex = np .nanmedian (gatherE34Stat ["TE3" ]["Ex" ])
647+ self .psfTE4e1 = summary .psfTE4e1 = np .nanmedian (gatherE34Stat ["TE4" ]["E1" ])
648+ self .psfTE4e2 = summary .psfTE4e2 = np .nanmedian (gatherE34Stat ["TE4" ]["E2" ])
649+ self .psfTE4ex = summary .psfTE4ex = np .nanmedian (gatherE34Stat ["TE4" ]["Ex" ])
650+
437651 def update_wcs_stats (self , summary , wcs , bbox , visitInfo ):
438652 """Compute all summary-statistic fields that depend on the WCS model.
439653
0 commit comments