LSST Applications g04e9c324dd+8c5ae1fdc5,g134cb467dc+b203dec576,g18429d2f64+358861cd2c,g199a45376c+0ba108daf9,g1fd858c14a+dd066899e3,g262e1987ae+ebfced1d55,g29ae962dfc+72fd90588e,g2cef7863aa+aef1011c0b,g35bb328faa+8c5ae1fdc5,g3fd5ace14f+b668f15bc5,g4595892280+3897dae354,g47891489e3+abcf9c3559,g4d44eb3520+fb4ddce128,g53246c7159+8c5ae1fdc5,g67b6fd64d1+abcf9c3559,g67fd3c3899+1f72b5a9f7,g74acd417e5+cb6b47f07b,g786e29fd12+668abc6043,g87389fa792+8856018cbb,g89139ef638+abcf9c3559,g8d7436a09f+bcf525d20c,g8ea07a8fe4+9f5ccc88ac,g90f42f885a+6054cc57f1,g97be763408+06f794da49,g9dd6db0277+1f72b5a9f7,ga681d05dcb+7e36ad54cd,gabf8522325+735880ea63,gac2eed3f23+abcf9c3559,gb89ab40317+abcf9c3559,gbf99507273+8c5ae1fdc5,gd8ff7fe66e+1f72b5a9f7,gdab6d2f7ff+cb6b47f07b,gdc713202bf+1f72b5a9f7,gdfd2d52018+8225f2b331,ge365c994fd+375fc21c71,ge410e46f29+abcf9c3559,geaed405ab2+562b3308c0,gf9a733ac38+8c5ae1fdc5,w.2025.35
LSST Data Management Base Package
Loading...
Searching...
No Matches
piffPsfDeterminer.py
Go to the documentation of this file.
1# This file is part of meas_extensions_piff.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
21
22__all__ = ["PiffPsfDeterminerConfig", "PiffPsfDeterminerTask"]
23
24import numpy as np
25import piff
26import galsim
27import re
28import logging
29import yaml
30
31import lsst.utils.logging
32from lsst.afw.cameraGeom import PIXELS, FIELD_ANGLE
33import lsst.pex.config as pexConfig
34import lsst.meas.algorithms as measAlg
35from lsst.meas.algorithms.psfDeterminer import BasePsfDeterminerTask
36from lsst.pipe.base import AlgorithmError
37from .piffPsf import PiffPsf
38from .wcs_wrapper import CelestialWcsWrapper, UVWcsWrapper
39
40
41def _validateGalsimInterpolant(name: str) -> bool:
42 """A helper function to validate the GalSim interpolant at config time.
43
44 Parameters
45 ----------
46 name : str
47 The name of the interpolant to use from GalSim. Valid options are:
48 galsim.Lanczos(N) or Lancsos(N), where N is a positive integer
49 galsim.Linear
50 galsim.Cubic
51 galsim.Quintic
52 galsim.Delta
53 galsim.Nearest
54 galsim.SincInterpolant
55
56 Returns
57 -------
58 is_valid : bool
59 Whether the provided interpolant name is valid.
60 """
61 # First, check if ``name`` is a valid Lanczos interpolant.
62 for pattern in (re.compile(r"Lanczos\‍(\d+\‍)"), re.compile(r"galsim.Lanczos\‍(\d+\‍)"),):
63 match = re.match(pattern, name) # Search from the start of the string.
64 if match is not None:
65 # Check that the pattern is also the end of the string.
66 return match.end() == len(name)
67
68 # If not, check if ``name`` is any other valid GalSim interpolant.
69 names = {f"galsim.{interp}" for interp in
70 ("Cubic", "Delta", "Linear", "Nearest", "Quintic", "SincInterpolant")
71 }
72 return name in names
73
74
75class PiffTooFewGoodStarsError(AlgorithmError):
76 """Raised if too few good stars are available for PSF determination.
77
78 Parameters
79 ----------
80 num_good_stars : `int`
81 Number of good stars used for PSF determination.
82 poly_ndim : `int`
83 Number of dependency parameters (dimensions) used in
84 polynomial fitting.
85 minimum_dof : `int`
86 Minimum number of degree of freedom to do the fit.
87 """
88
90 self,
91 num_good_stars,
92 minimum_dof,
93 poly_ndim,
94 ):
95 self._num_good_stars = num_good_stars
96 self._poly_ndim = poly_ndim
97 self._minimum_dof = minimum_dof
98 super().__init__(
99 f"Failed to determine piff psf: too few good stars ({num_good_stars}) and minimum dof to fit "
100 f"a {poly_ndim} order polynomial is {minimum_dof}."
101 )
102
103 @property
104 def metadata(self):
105 return {
106 "num_good_stars": self._num_good_stars,
107 "poly_ndim": self._poly_ndim,
108 "minimum_dof": self._minimum_dof,
109 }
110
111
112class PiffPsfDeterminerConfig(BasePsfDeterminerTask.ConfigClass):
113 spatialOrderPerBand = pexConfig.DictField(
114 doc="Per-band spatial order for PSF kernel creation. "
115 "Ignored if piffPsfConfigYaml is set.",
116 keytype=str,
117 itemtype=int,
118 default={},
119 )
120 spatialOrder = pexConfig.Field[int](
121 doc="Spatial order for PSF kernel creation. "
122 "Ignored if piffPsfConfigYaml is set or if the current "
123 "band is in the spatialOrderPerBand dict config.",
124 default=2,
125 )
126 piffBasisPolynomialSolver = pexConfig.ChoiceField[str](
127 doc="Solver to solve linear algebra for "
128 "the spatial interpolation of the PSF. "
129 "Ignore if piffPsfConfigYaml is not None.",
130 allowed=dict(
131 scipy="Default solver using scipy.",
132 cpp="C++ solver using eigen.",
133 jax="JAX solver.",
134 qr="QR decomposition using scipy/numpy.",
135 ),
136 default="scipy",
137 )
138 piffPixelGridFitCenter = pexConfig.Field[bool](
139 doc="PixelGrid can re-estimate center if this option is True. "
140 "Will use provided centroid if set to False. Default in Piff "
141 "is set to True. Depends on how input centroids can be trust. "
142 "Ignore if piffPsfConfigYaml is not None.",
143 default=True
144 )
145 samplingSize = pexConfig.Field[float](
146 doc="Resolution of the internal PSF model relative to the pixel size; "
147 "e.g. 0.5 is equal to 2x oversampling. This affects only the size of "
148 "the PSF model stamp if piffPsfConfigYaml is set.",
149 default=1,
150 )
151 modelSize = pexConfig.Field[int](
152 doc="Internal model size for PIFF (typically odd, but not enforced). "
153 "Partially ignored if piffPsfConfigYaml is set.",
154 default=25,
155 )
156 outlierNSigma = pexConfig.Field[float](
157 doc="n sigma for chisq outlier rejection. "
158 "Ignored if piffPsfConfigYaml is set.",
159 default=4.0
160 )
161 outlierMaxRemove = pexConfig.Field[float](
162 doc="Max fraction of stars to remove as outliers each iteration. "
163 "Ignored if piffPsfConfigYaml is set.",
164 default=0.05
165 )
166 maxSNR = pexConfig.Field[float](
167 doc="Rescale the weight of bright stars such that their SNR is less "
168 "than this value.",
169 default=200.0
170 )
171 zeroWeightMaskBits = pexConfig.ListField[str](
172 doc="List of mask bits for which to set pixel weights to zero.",
173 default=['BAD', 'CR', 'INTRP', 'SAT', 'SUSPECT', 'NO_DATA']
174 )
175 minimumUnmaskedFraction = pexConfig.Field[float](
176 doc="Minimum fraction of unmasked pixels required to use star.",
177 default=0.5
178 )
179 useColor = pexConfig.Field[bool](
180 doc="Use color information to correct for amtospheric turbulences and "
181 "differential chromatic refraction."
182 "Ignored if piffPsfConfigYaml is set.",
183 default=False,
184 )
185 color = pexConfig.DictField(
186 doc="The bands to use for calculating color."
187 "Ignored if piffPsfConfigYaml is set.",
188 default={},
189 keytype=str,
190 itemtype=str,
191 )
192 colorOrder = pexConfig.Field[int](
193 doc="Color order for PSF kernel creation. "
194 "Ignored if piffPsfConfigYaml is set.",
195 default=1,
196 )
197 interpolant = pexConfig.Field[str](
198 doc="GalSim interpolant name for Piff to use. "
199 "Options include 'Lanczos(N)', where N is an integer, along with "
200 "galsim.Cubic, galsim.Delta, galsim.Linear, galsim.Nearest, "
201 "galsim.Quintic, and galsim.SincInterpolant. Ignored if "
202 "piffPsfConfigYaml is set.",
203 check=_validateGalsimInterpolant,
204 default="Lanczos(11)",
205 )
206 zerothOrderInterpNotEnoughStars = pexConfig.Field[bool](
207 doc="If True, use zeroth order interpolation if not enough stars are found.",
208 default=False
209 )
210 debugStarData = pexConfig.Field[bool](
211 doc="Include star images used for fitting in PSF model object.",
212 default=False
213 )
214 useCoordinates = pexConfig.ChoiceField[str](
215 doc="Which spatial coordinates to regress against in PSF modeling.",
216 allowed=dict(
217 pixel='Regress against pixel coordinates',
218 field='Regress against field angles',
219 sky='Regress against RA/Dec'
220 ),
221 default='pixel'
222 )
223 piffMaxIter = pexConfig.Field[int](
224 doc="Maximum iteration while doing outlier rejection."
225 "Might be overwrite if zerothOrderInterpNotEnoughStars is True and "
226 "ignore if piffPsfConfigYaml is not None.",
227 default=15
228 )
229 piffLoggingLevel = pexConfig.RangeField[int](
230 doc="PIFF-specific logging level, from 0 (least chatty) to 3 (very verbose); "
231 "note that logs will come out with unrelated notations (e.g. WARNING does "
232 "not imply a warning.)",
233 default=0,
234 min=0,
235 max=3,
236 )
237 piffPsfConfigYaml = pexConfig.Field[str](
238 doc="Configuration file for PIFF in YAML format. This overrides many "
239 "other settings in this config and is not validated ahead of runtime.",
240 default=None,
241 optional=True,
242 )
243
244 def setDefaults(self):
245 super().setDefaults()
246 # stampSize should be at least 25 so that
247 # i) aperture flux with 12 pixel radius can be compared to PSF flux.
248 # ii) fake sources injected to match the 12 pixel aperture flux get
249 # measured correctly
250 # stampSize should also be at least sqrt(2)*modelSize/samplingSize so
251 # that rotated images have support in the model
252
253 self.stampSize = 25
254 # Resize the stamp to accommodate the model, but only if necessary.
255 if self.useCoordinates == "sky":
256 self.stampSize = max(25, 2*int(0.5*self.modelSize*np.sqrt(2)/self.samplingSize) + 1)
257
258 def validate(self):
259 super().validate()
260
261 if (stamp_size := self.stampSize) is not None:
262 model_size = self.modelSize
263 sampling_size = self.samplingSize
264 if self.useCoordinates == 'sky':
265 min_stamp_size = int(np.sqrt(2) * model_size / sampling_size)
266 else:
267 min_stamp_size = int(model_size / sampling_size)
268
269 if stamp_size < min_stamp_size:
270 msg = (f"PIFF model size of {model_size} is too large for stamp size {stamp_size}. "
271 f"Set stampSize >= {min_stamp_size}"
272 )
273 raise pexConfig.FieldValidationError(self.__class__.modelSize, self, msg)
274
275
276def getGoodPixels(maskedImage, zeroWeightMaskBits):
277 """Compute an index array indicating good pixels to use.
278
279 Parameters
280 ----------
281 maskedImage : `afw.image.MaskedImage`
282 PSF candidate postage stamp
283 zeroWeightMaskBits : `List[str]`
284 List of mask bits for which to set pixel weights to zero.
285
286 Returns
287 -------
288 good : `ndarray`
289 Index array indicating good pixels.
290 """
291 imArr = maskedImage.image.array
292 varArr = maskedImage.variance.array
293 bitmask = maskedImage.mask.getPlaneBitMask(zeroWeightMaskBits)
294 good = (
295 (varArr != 0)
296 & (np.isfinite(varArr))
297 & (np.isfinite(imArr))
298 & ((maskedImage.mask.array & bitmask) == 0)
299 )
300 return good
301
302
303def computeWeight(maskedImage, maxSNR, good):
304 """Derive a weight map without Poisson variance component due to signal.
305
306 Parameters
307 ----------
308 maskedImage : `afw.image.MaskedImage`
309 PSF candidate postage stamp
310 maxSNR : `float`
311 Maximum SNR applying variance floor.
312 good : `ndarray`
313 Index array indicating good pixels.
314
315 Returns
316 -------
317 weightArr : `ndarry`
318 Array to use for weight.
319
320 See Also
321 --------
322 `lsst.meas.algorithms.variance_plance.remove_signal_from_variance` :
323 Remove the Poisson contribution from sources in the variance plane of
324 an Exposure.
325 """
326 imArr = maskedImage.image.array
327 varArr = maskedImage.variance.array
328
329 # Fit a straight line to variance vs (sky-subtracted) signal.
330 # The evaluate that line at zero signal to get an estimate of the
331 # signal-free variance.
332 fit = np.polyfit(imArr[good], varArr[good], deg=1)
333 # fit is [1/gain, sky_var]
334 weightArr = np.zeros_like(imArr, dtype=float)
335 weightArr[good] = 1./fit[1]
336
337 applyMaxSNR(imArr, weightArr, good, maxSNR)
338 return weightArr
339
340
341def applyMaxSNR(imArr, weightArr, good, maxSNR):
342 """Rescale weight of bright stars to cap the computed SNR.
343
344 Parameters
345 ----------
346 imArr : `ndarray`
347 Signal (image) array of stamp.
348 weightArr : `ndarray`
349 Weight map array. May be rescaled in place.
350 good : `ndarray`
351 Index array of pixels to use when computing SNR.
352 maxSNR : `float`
353 Threshold for adjusting variance plane implementing maximum SNR.
354 """
355 # We define the SNR value following Piff. Here's the comment from that
356 # code base explaining the calculation.
357 #
358 # The S/N value that we use will be the weighted total flux where the
359 # weight function is the star's profile itself. This is the maximum S/N
360 # value that any flux measurement can possibly produce, which will be
361 # closer to an in-practice S/N than using all the pixels equally.
362 #
363 # F = Sum_i w_i I_i^2
364 # var(F) = Sum_i w_i^2 I_i^2 var(I_i)
365 # = Sum_i w_i I_i^2 <--- Assumes var(I_i) = 1/w_i
366 #
367 # S/N = F / sqrt(var(F))
368 #
369 # Note that if the image is pure noise, this will produce a "signal" of
370 #
371 # F_noise = Sum_i w_i 1/w_i = Npix
372 #
373 # So for a more accurate estimate of the S/N of the actual star itself, one
374 # should subtract off Npix from the measured F.
375 #
376 # The final formula then is:
377 #
378 # F = Sum_i w_i I_i^2
379 # S/N = (F-Npix) / sqrt(F)
380 F = np.sum(weightArr[good]*imArr[good]**2, dtype=float)
381 Npix = np.sum(good)
382 SNR = 0.0 if F < Npix else (F-Npix)/np.sqrt(F)
383 # rescale weight of bright stars. Essentially makes an error floor.
384 if SNR > maxSNR:
385 factor = (maxSNR / SNR)**2
386 weightArr[good] *= factor
387
388
389def _computeWeightAlternative(maskedImage, maxSNR):
390 """Alternative algorithm for creating weight map.
391
392 This version is equivalent to that used by Piff internally. The weight map
393 it produces tends to leave a residual when removing the Poisson component
394 due to the signal. We leave it here as a reference, but without intending
395 that it be used (or be maintained).
396 """
397 imArr = maskedImage.image.array
398 varArr = maskedImage.variance.array
399 good = (varArr != 0) & np.isfinite(varArr) & np.isfinite(imArr)
400
401 fit = np.polyfit(imArr[good], varArr[good], deg=1)
402 # fit is [1/gain, sky_var]
403 gain = 1./fit[0]
404 varArr[good] -= imArr[good] / gain
405 weightArr = np.zeros_like(imArr, dtype=float)
406 weightArr[good] = 1./varArr[good]
407
408 applyMaxSNR(imArr, weightArr, good, maxSNR)
409 return weightArr
410
411
413 """A measurePsfTask PSF estimator using Piff as the implementation.
414 """
415 ConfigClass = PiffPsfDeterminerConfig
416 _DefaultName = "psfDeterminer.Piff"
417
418 def __init__(self, config, schema=None, **kwds):
419 BasePsfDeterminerTask.__init__(self, config, schema=schema, **kwds)
420
421 piffLoggingLevels = {
422 0: logging.CRITICAL,
423 1: logging.WARNING,
424 2: logging.INFO,
425 3: logging.DEBUG,
426 }
427 self.piffLogger = lsst.utils.logging.getLogger(f"{self.log.name}.piff")
428 self.piffLogger.setLevel(piffLoggingLevels[self.config.piffLoggingLevel])
429
431 self, exposure, psfCandidateList, metadata=None, flagKey=None,
432 ):
433 """Determine a Piff PSF model for an exposure given a list of PSF
434 candidates.
435
436 Parameters
437 ----------
438 exposure : `lsst.afw.image.Exposure`
439 Exposure containing the PSF candidates.
440 psfCandidateList : `list` of `lsst.meas.algorithms.PsfCandidate`
441 A sequence of PSF candidates typically obtained by detecting sources
442 and then running them through a star selector.
443 metadata : `lsst.daf.base import PropertyList` or `None`, optional
444 A home for interesting tidbits of information.
445 flagKey : `str` or `None`, optional
446 Schema key used to mark sources actually used in PSF determination.
447
448 Returns
449 -------
450 psf : `lsst.meas.extensions.piff.PiffPsf`
451 The measured PSF model.
452 psfCellSet : `None`
453 Unused by this PsfDeterminer.
454 """
455 psfCandidateList = self.downsampleCandidates(psfCandidateList)
456
457 if self.config.stampSize:
458 stampSize = self.config.stampSize
459 if stampSize > psfCandidateList[0].getWidth():
460 self.log.warning("stampSize is larger than the PSF candidate size. Using candidate size.")
461 stampSize = psfCandidateList[0].getWidth()
462 else: # TODO: Only the if block should stay after DM-36311
463 self.log.debug("stampSize not set. Using candidate size.")
464 stampSize = psfCandidateList[0].getWidth()
465
466 scale = exposure.getWcs().getPixelScale(exposure.getBBox().getCenter()).asArcseconds()
467
468 match self.config.useCoordinates:
469 case 'field':
470 detector = exposure.getDetector()
471 pix_to_field = detector.getTransform(PIXELS, FIELD_ANGLE)
472 gswcs = UVWcsWrapper(pix_to_field)
473 pointing = None
474 keys = ['u', 'v']
475
476 case 'sky':
477 gswcs = CelestialWcsWrapper(exposure.getWcs())
478 skyOrigin = exposure.getWcs().getSkyOrigin()
479 ra = skyOrigin.getLongitude().asDegrees()
480 dec = skyOrigin.getLatitude().asDegrees()
481 pointing = galsim.CelestialCoord(
482 ra*galsim.degrees,
483 dec*galsim.degrees
484 )
485 keys = ['u', 'v']
486
487 case 'pixel':
488 gswcs = galsim.PixelScale(scale)
489 pointing = None
490 keys = ['x', 'y']
491
492 stars = []
493 for candidate in psfCandidateList:
494 cmi = candidate.getMaskedImage(stampSize, stampSize)
495 good = getGoodPixels(cmi, self.config.zeroWeightMaskBits)
496 fracGood = np.sum(good)/good.size
497 if fracGood < self.config.minimumUnmaskedFraction:
498 continue
499 weight = computeWeight(cmi, self.config.maxSNR, good)
500
501 bbox = cmi.getBBox()
502 bds = galsim.BoundsI(
503 galsim.PositionI(*bbox.getMin()),
504 galsim.PositionI(*bbox.getMax())
505 )
506 gsImage = galsim.Image(bds, wcs=gswcs, dtype=float)
507 gsImage.array[:] = cmi.image.array
508 gsWeight = galsim.Image(bds, wcs=gswcs, dtype=float)
509 gsWeight.array[:] = weight
510
511 source = candidate.getSource()
512 starId = source.getId()
513 image_pos = galsim.PositionD(source.getX(), source.getY())
514
515 data = piff.StarData(
516 gsImage,
517 image_pos,
518 weight=gsWeight,
519 pointing=pointing
520 )
521 star = piff.Star(data, None)
522 star.data.properties['starId'] = starId
523 star.data.properties['colorValue'] = candidate.getPsfColorValue()
524 star.data.properties['colorType'] = candidate.getPsfColorType()
525 stars.append(star)
526
527 # The following is mostly accommodating unittests that don't have
528 # the filter attribute set on the mock exposure.
529 if hasattr(exposure.filter, "bandLabel"):
530 band = exposure.filter.bandLabel
531 else:
532 band = None
533 spatialOrder = self.config.spatialOrderPerBand.get(band, self.config.spatialOrder)
534 orders = [spatialOrder] * len(keys)
535
536 if self.config.useColor:
537 colors = [s.data.properties['colorValue'] for s in stars
538 if np.isfinite(s.data.properties['colorValue'])]
539 colorTypes = [s.data.properties['colorType'] for s in stars
540 if np.isfinite(s.data.properties['colorValue'])]
541 if len(colors) == 0:
542 self.log.warning("No color informations for PSF candidates, Set PSF colors to 0s.")
543 meanColors = 0.
544 else:
545 meanColors = np.mean(colors)
546 colorType = list(set(colorTypes))
547 if len(colorType) > 1:
548 raise ValueError(f"More than one colorType was providen:{colorType}")
549 colorType = colorType[0]
550 for s in stars:
551 if not np.isfinite(s.data.properties['colorValue']):
552 s.data.properties['colorValue'] = meanColors
553 s.data.properties['colorType'] = colorType
554 keys.append('colorValue')
555 orders.append(self.config.colorOrder)
556
557 if self.config.piffPsfConfigYaml is None:
558 piffConfig = {
559 'type': 'Simple',
560 'model': {
561 'type': 'PixelGrid',
562 'scale': scale * self.config.samplingSize,
563 'size': self.config.modelSize,
564 'interp': self.config.interpolant,
565 'centered': self.config.piffPixelGridFitCenter,
566 },
567 'interp': {
568 'type': 'BasisPolynomial',
569 'order': orders,
570 'keys': keys,
571 'solver': self.config.piffBasisPolynomialSolver,
572 },
573 'outliers': {
574 'type': 'Chisq',
575 'nsigma': self.config.outlierNSigma,
576 'max_remove': self.config.outlierMaxRemove,
577 },
578 'max_iter': self.config.piffMaxIter
579 }
580 else:
581 piffConfig = yaml.safe_load(self.config.piffPsfConfigYaml)
582
583 def _get_threshold(nth_order):
584 if isinstance(nth_order, list):
585 # right now, nth_order[0] and nth_order[1] are the same.
586 freeParameters = ((nth_order[0] + 1) * (nth_order[0] + 2)) // 2
587 if len(nth_order) == 3: # when color correction
588 freeParameters += nth_order[2]
589 else:
590 # number of free parameter in the polynomial interpolation
591 freeParameters = ((nth_order + 1) * (nth_order + 2)) // 2
592 return freeParameters
593
594 if piffConfig['interp']['type'] in ['BasisPolynomial', 'Polynomial']:
595 threshold = _get_threshold(piffConfig['interp']['order'])
596 if len(stars) < threshold:
597 if self.config.zerothOrderInterpNotEnoughStars:
598 self.log.warning(
599 "Only %d stars found, "
600 "but %d required. Using zeroth order interpolation."%((len(stars), threshold))
601 )
602 piffConfig['interp']['order'] = [0] * len(keys)
603 # No need to do any outlier rejection assume
604 # PSF to be average of few stars.
605 piffConfig['max_iter'] = 1
606 else:
608 num_good_stars=len(stars),
609 minimum_dof=threshold,
610 poly_ndim=piffConfig['interp']['order'],
611 )
612 self._piffConfig = piffConfig
613 piffResult = piff.PSF.process(piffConfig)
614 wcs = {0: gswcs}
615
616 piffResult.fit(stars, wcs, pointing, logger=self.piffLogger)
617
618 nUsedStars = len([s for s in piffResult.stars if not s.is_flagged and not s.is_reserve])
619
620 if piffConfig['interp']['type'] in ['BasisPolynomial', 'Polynomial']:
621 threshold = _get_threshold(piffConfig['interp']['order'])
622 if nUsedStars < threshold:
623 if self.config.zerothOrderInterpNotEnoughStars:
624 self.log.warning(
625 "Only %d after outlier rejection, "
626 "but %d required. Using zeroth order interpolation."%((nUsedStars, threshold))
627 )
628 piffConfig['interp']['order'] = [0] * len(keys)
629 # No need to do any outlier rejection assume
630 # PSF to be average of few stars.
631 piffConfig['max_iter'] = 1
632 piffResult.fit(stars, wcs, pointing, logger=self.piffLogger)
633 nUsedStars = len(stars)
634 else:
636 num_good_stars=nUsedStars,
637 minimum_dof=threshold,
638 poly_ndim=piffConfig['interp']['order'],
639 )
640
641 drawSize = 2*np.floor(0.5*stampSize/self.config.samplingSize) + 1
642
643 used_image_starId = {s.data.properties['starId'] for s in piffResult.stars
644 if not s.is_flagged and not s.is_reserve}
645
646 assert len(used_image_starId) == nUsedStars, "Star IDs are not unique"
647
648 if flagKey:
649 for candidate in psfCandidateList:
650 source = candidate.getSource()
651 starId = source.getId()
652 if starId in used_image_starId:
653 source.set(flagKey, True)
654
655 if metadata is not None:
656 metadata["spatialFitChi2"] = piffResult.chisq
657 metadata["numAvailStars"] = len(stars)
658 metadata["numGoodStars"] = nUsedStars
659 metadata["avgX"] = np.mean([s.x for s in piffResult.stars
660 if not s.is_flagged and not s.is_reserve])
661 metadata["avgY"] = np.mean([s.y for s in piffResult.stars
662 if not s.is_flagged and not s.is_reserve])
663
664 if not self.config.debugStarData:
665 for star in piffResult.stars:
666 # Remove large data objects from the stars
667 del star.fit.params
668 del star.fit.params_var
669 del star.fit.A
670 del star.fit.b
671 del star.data.image
672 del star.data.weight
673 del star.data.orig_weight
674
675 return PiffPsf(drawSize, drawSize, piffResult), None
676
677
678measAlg.psfDeterminerRegistry.register("piff", PiffPsfDeterminerTask)
determinePsf(self, exposure, psfCandidateList, metadata=None, flagKey=None)
getGoodPixels(maskedImage, zeroWeightMaskBits)
applyMaxSNR(imArr, weightArr, good, maxSNR)