Coverage for python / lsst / meas / extensions / piff / piffPsfDeterminer.py: 17%

242 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-06 08:45 +0000

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 

89 def __init__( 

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 = 27 

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 

412class PiffPsfDeterminerTask(BasePsfDeterminerTask): 

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 

430 def determinePsf( 

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 colorTypes = [stars[0].data.properties['colorType']] 

545 else: 

546 meanColors = np.mean(colors) 

547 colorType = list(set(colorTypes)) 

548 if len(colorType) > 1: 

549 raise ValueError(f"More than one colorType was provided:{colorType}") 

550 colorType = colorType[0] 

551 for s in stars: 

552 if not np.isfinite(s.data.properties['colorValue']): 

553 s.data.properties['colorValue'] = meanColors 

554 s.data.properties['colorType'] = colorType 

555 keys.append('colorValue') 

556 orders.append(self.config.colorOrder) 

557 

558 if self.config.piffPsfConfigYaml is None: 

559 piffConfig = { 

560 'type': 'Simple', 

561 'model': { 

562 'type': 'PixelGrid', 

563 'scale': scale * self.config.samplingSize, 

564 'size': self.config.modelSize, 

565 'interp': self.config.interpolant, 

566 'centered': self.config.piffPixelGridFitCenter, 

567 }, 

568 'interp': { 

569 'type': 'BasisPolynomial', 

570 'order': orders, 

571 'keys': keys, 

572 'solver': self.config.piffBasisPolynomialSolver, 

573 }, 

574 'outliers': { 

575 'type': 'Chisq', 

576 'nsigma': self.config.outlierNSigma, 

577 'max_remove': self.config.outlierMaxRemove, 

578 }, 

579 'max_iter': self.config.piffMaxIter 

580 } 

581 else: 

582 piffConfig = yaml.safe_load(self.config.piffPsfConfigYaml) 

583 

584 def _get_threshold(nth_order): 

585 if isinstance(nth_order, list): 

586 # right now, nth_order[0] and nth_order[1] are the same. 

587 freeParameters = ((nth_order[0] + 1) * (nth_order[0] + 2)) // 2 

588 if len(nth_order) == 3: # when color correction 

589 freeParameters += nth_order[2] 

590 else: 

591 # number of free parameter in the polynomial interpolation 

592 freeParameters = ((nth_order + 1) * (nth_order + 2)) // 2 

593 return freeParameters 

594 

595 if piffConfig['interp']['type'] in ['BasisPolynomial', 'Polynomial']: 

596 threshold = _get_threshold(piffConfig['interp']['order']) 

597 if len(stars) < threshold: 

598 if self.config.zerothOrderInterpNotEnoughStars: 

599 self.log.warning( 

600 "Only %d stars found, " 

601 "but %d required. Using zeroth order interpolation."%((len(stars), threshold)) 

602 ) 

603 piffConfig['interp']['order'] = [0] * len(keys) 

604 # No need to do any outlier rejection assume 

605 # PSF to be average of few stars. 

606 piffConfig['max_iter'] = 1 

607 else: 

608 raise PiffTooFewGoodStarsError( 

609 num_good_stars=len(stars), 

610 minimum_dof=threshold, 

611 poly_ndim=piffConfig['interp']['order'], 

612 ) 

613 self._piffConfig = piffConfig 

614 piffResult = piff.PSF.process(piffConfig) 

615 wcs = {0: gswcs} 

616 

617 piffResult.fit(stars, wcs, pointing, logger=self.piffLogger) 

618 

619 nUsedStars = len([s for s in piffResult.stars if not s.is_flagged and not s.is_reserve]) 

620 

621 if piffConfig['interp']['type'] in ['BasisPolynomial', 'Polynomial']: 

622 threshold = _get_threshold(piffConfig['interp']['order']) 

623 if nUsedStars < threshold: 

624 if self.config.zerothOrderInterpNotEnoughStars: 

625 self.log.warning( 

626 "Only %d after outlier rejection, " 

627 "but %d required. Using zeroth order interpolation."%((nUsedStars, threshold)) 

628 ) 

629 piffConfig['interp']['order'] = [0] * len(keys) 

630 # No need to do any outlier rejection assume 

631 # PSF to be average of few stars. 

632 piffConfig['max_iter'] = 1 

633 piffResult.fit(stars, wcs, pointing, logger=self.piffLogger) 

634 nUsedStars = len(stars) 

635 else: 

636 raise PiffTooFewGoodStarsError( 

637 num_good_stars=nUsedStars, 

638 minimum_dof=threshold, 

639 poly_ndim=piffConfig['interp']['order'], 

640 ) 

641 

642 drawSize = 2*np.floor(0.5*stampSize/self.config.samplingSize) + 1 

643 

644 used_image_starId = {s.data.properties['starId'] for s in piffResult.stars 

645 if not s.is_flagged and not s.is_reserve} 

646 

647 assert len(used_image_starId) == nUsedStars, "Star IDs are not unique" 

648 

649 if flagKey: 

650 for candidate in psfCandidateList: 

651 source = candidate.getSource() 

652 starId = source.getId() 

653 if starId in used_image_starId: 

654 source.set(flagKey, True) 

655 

656 if metadata is not None: 

657 metadata["spatialFitChi2"] = piffResult.chisq 

658 metadata["numAvailStars"] = len(stars) 

659 metadata["numGoodStars"] = nUsedStars 

660 metadata["avgX"] = np.mean([s.x for s in piffResult.stars 

661 if not s.is_flagged and not s.is_reserve]) 

662 metadata["avgY"] = np.mean([s.y for s in piffResult.stars 

663 if not s.is_flagged and not s.is_reserve]) 

664 

665 if not self.config.debugStarData: 

666 for star in piffResult.stars: 

667 # Remove large data objects from the stars 

668 del star.fit.params 

669 del star.fit.params_var 

670 del star.fit.A 

671 del star.fit.b 

672 del star.data.image 

673 del star.data.weight 

674 del star.data.orig_weight 

675 

676 return PiffPsf(drawSize, drawSize, piffResult), None 

677 

678 

679measAlg.psfDeterminerRegistry.register("piff", PiffPsfDeterminerTask)