Coverage for python/lsst/meas/base/plugins.py: 95%

361 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-22 01:38 -0700

1# This file is part of meas_base. 

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"""Definition of measurement plugins. 

23 

24This module defines and registers a series of pure-Python measurement plugins 

25which have trivial implementations. It also wraps measurement algorithms 

26defined in C++ to expose them to the measurement framework. 

27""" 

28 

29import logging 

30import numpy as np 

31 

32import lsst.pex.exceptions 

33import lsst.geom 

34import lsst.afw.detection 

35import lsst.afw.geom 

36 

37from ._measBaseLib import (ApertureFluxControl, ApertureFluxTransform, 

38 BaseTransform, BlendednessAlgorithm, 

39 BlendednessControl, CircularApertureFluxAlgorithm, 

40 GaussianFluxAlgorithm, GaussianFluxControl, 

41 GaussianFluxTransform, LocalBackgroundAlgorithm, 

42 LocalBackgroundControl, LocalBackgroundTransform, 

43 MeasurementError, 

44 PeakLikelihoodFluxAlgorithm, 

45 PeakLikelihoodFluxControl, 

46 PeakLikelihoodFluxTransform, PixelFlagsAlgorithm, 

47 PixelFlagsControl, PsfFluxAlgorithm, PsfFluxControl, 

48 PsfFluxTransform, ScaledApertureFluxAlgorithm, 

49 ScaledApertureFluxControl, 

50 ScaledApertureFluxTransform, SdssCentroidAlgorithm, 

51 SdssCentroidControl, SdssCentroidTransform, 

52 SdssShapeAlgorithm, SdssShapeControl, 

53 SdssShapeTransform) 

54 

55from .baseMeasurement import BaseMeasurementPluginConfig 

56from .forcedMeasurement import ForcedPlugin, ForcedPluginConfig 

57from .pluginRegistry import register 

58from .pluginsBase import BasePlugin 

59from .sfm import SingleFramePlugin, SingleFramePluginConfig 

60from .transforms import SimpleCentroidTransform 

61from .wrappers import GenericPlugin, wrapSimpleAlgorithm, wrapTransform 

62 

63__all__ = ( 

64 "SingleFrameFPPositionConfig", "SingleFrameFPPositionPlugin", 

65 "SingleFrameJacobianConfig", "SingleFrameJacobianPlugin", 

66 "VarianceConfig", "SingleFrameVariancePlugin", "ForcedVariancePlugin", 

67 "InputCountConfig", "SingleFrameInputCountPlugin", "ForcedInputCountPlugin", 

68 "SingleFramePeakCentroidConfig", "SingleFramePeakCentroidPlugin", 

69 "SingleFrameSkyCoordConfig", "SingleFrameSkyCoordPlugin", 

70 "SingleFrameClassificationSizeExtendednessConfig", 

71 "SingleFrameClassificationSizeExtendednessPlugin", 

72 "ForcedPeakCentroidConfig", "ForcedPeakCentroidPlugin", 

73 "ForcedTransformedCentroidConfig", "ForcedTransformedCentroidPlugin", 

74 "ForcedTransformedCentroidFromCoordConfig", 

75 "ForcedTransformedCentroidFromCoordPlugin", 

76 "ForcedTransformedShapeConfig", "ForcedTransformedShapePlugin", 

77 "EvaluateLocalPhotoCalibPlugin", "EvaluateLocalPhotoCalibPluginConfig", 

78 "EvaluateLocalWcsPlugin", "EvaluateLocalWcsPluginConfig", 

79) 

80 

81 

82wrapSimpleAlgorithm(PsfFluxAlgorithm, Control=PsfFluxControl, 

83 TransformClass=PsfFluxTransform, executionOrder=BasePlugin.FLUX_ORDER, 

84 shouldApCorr=True, hasLogName=True) 

85wrapSimpleAlgorithm(PeakLikelihoodFluxAlgorithm, Control=PeakLikelihoodFluxControl, 

86 TransformClass=PeakLikelihoodFluxTransform, executionOrder=BasePlugin.FLUX_ORDER) 

87wrapSimpleAlgorithm(GaussianFluxAlgorithm, Control=GaussianFluxControl, 

88 TransformClass=GaussianFluxTransform, executionOrder=BasePlugin.FLUX_ORDER, 

89 shouldApCorr=True) 

90wrapSimpleAlgorithm(SdssCentroidAlgorithm, Control=SdssCentroidControl, 

91 TransformClass=SdssCentroidTransform, executionOrder=BasePlugin.CENTROID_ORDER) 

92wrapSimpleAlgorithm(PixelFlagsAlgorithm, Control=PixelFlagsControl, 

93 executionOrder=BasePlugin.FLUX_ORDER) 

94wrapSimpleAlgorithm(SdssShapeAlgorithm, Control=SdssShapeControl, 

95 TransformClass=SdssShapeTransform, executionOrder=BasePlugin.SHAPE_ORDER) 

96wrapSimpleAlgorithm(ScaledApertureFluxAlgorithm, Control=ScaledApertureFluxControl, 

97 TransformClass=ScaledApertureFluxTransform, executionOrder=BasePlugin.FLUX_ORDER) 

98 

99wrapSimpleAlgorithm(CircularApertureFluxAlgorithm, needsMetadata=True, Control=ApertureFluxControl, 

100 TransformClass=ApertureFluxTransform, executionOrder=BasePlugin.FLUX_ORDER) 

101wrapSimpleAlgorithm(BlendednessAlgorithm, Control=BlendednessControl, 

102 TransformClass=BaseTransform, executionOrder=BasePlugin.SHAPE_ORDER) 

103 

104wrapSimpleAlgorithm(LocalBackgroundAlgorithm, Control=LocalBackgroundControl, 

105 TransformClass=LocalBackgroundTransform, executionOrder=BasePlugin.FLUX_ORDER) 

106 

107wrapTransform(PsfFluxTransform) 

108wrapTransform(PeakLikelihoodFluxTransform) 

109wrapTransform(GaussianFluxTransform) 

110wrapTransform(SdssCentroidTransform) 

111wrapTransform(SdssShapeTransform) 

112wrapTransform(ScaledApertureFluxTransform) 

113wrapTransform(ApertureFluxTransform) 

114wrapTransform(LocalBackgroundTransform) 

115 

116log = logging.getLogger(__name__) 

117 

118 

119class SingleFrameFPPositionConfig(SingleFramePluginConfig): 

120 """Configuration for the focal plane position measurement algorithm. 

121 """ 

122 

123 

124@register("base_FPPosition") 

125class SingleFrameFPPositionPlugin(SingleFramePlugin): 

126 """Algorithm to calculate the position of a centroid on the focal plane. 

127 

128 Parameters 

129 ---------- 

130 config : `SingleFrameFPPositionConfig` 

131 Plugin configuration. 

132 name : `str` 

133 Plugin name. 

134 schema : `lsst.afw.table.Schema` 

135 The schema for the measurement output catalog. New fields will be 

136 added to hold measurements produced by this plugin. 

137 metadata : `lsst.daf.base.PropertySet` 

138 Plugin metadata that will be attached to the output catalog 

139 """ 

140 

141 ConfigClass = SingleFrameFPPositionConfig 

142 

143 @classmethod 

144 def getExecutionOrder(cls): 

145 return cls.SHAPE_ORDER 

146 

147 def __init__(self, config, name, schema, metadata): 

148 SingleFramePlugin.__init__(self, config, name, schema, metadata) 

149 self.focalValue = lsst.afw.table.Point2DKey.addFields(schema, name, "Position on the focal plane", 

150 "mm") 

151 self.focalFlag = schema.addField(name + "_flag", type="Flag", doc="Set to True for any fatal failure") 

152 self.detectorFlag = schema.addField(name + "_missingDetector_flag", type="Flag", 

153 doc="Set to True if detector object is missing") 

154 

155 def measure(self, measRecord, exposure): 

156 det = exposure.getDetector() 

157 if not det: 157 ↛ 158line 157 didn't jump to line 158 because the condition on line 157 was never true

158 measRecord.set(self.detectorFlag, True) 

159 fp = lsst.geom.Point2D(np.nan, np.nan) 

160 else: 

161 center = measRecord.getCentroid() 

162 fp = det.transform(center, lsst.afw.cameraGeom.PIXELS, lsst.afw.cameraGeom.FOCAL_PLANE) 

163 measRecord.set(self.focalValue, fp) 

164 

165 def fail(self, measRecord, error=None): 

166 measRecord.set(self.focalFlag, True) 

167 

168 

169class SingleFrameJacobianConfig(SingleFramePluginConfig): 

170 """Configuration for the Jacobian calculation plugin. 

171 """ 

172 

173 pixelScale = lsst.pex.config.Field(dtype=float, default=0.5, doc="Nominal pixel size (arcsec)") 

174 

175 

176@register("base_Jacobian") 

177class SingleFrameJacobianPlugin(SingleFramePlugin): 

178 """Compute the Jacobian and its ratio with a nominal pixel area. 

179 

180 This enables one to compare relative, rather than absolute, pixel areas. 

181 

182 Parameters 

183 ---------- 

184 config : `SingleFrameJacobianConfig` 

185 Plugin configuration. 

186 name : `str` 

187 Plugin name. 

188 schema : `lsst.afw.table.Schema` 

189 The schema for the measurement output catalog. New fields will be 

190 added to hold measurements produced by this plugin. 

191 metadata : `lsst.daf.base.PropertySet` 

192 Plugin metadata that will be attached to the output catalog 

193 """ 

194 

195 ConfigClass = SingleFrameJacobianConfig 

196 

197 @classmethod 

198 def getExecutionOrder(cls): 

199 return cls.SHAPE_ORDER 

200 

201 def __init__(self, config, name, schema, metadata): 

202 SingleFramePlugin.__init__(self, config, name, schema, metadata) 

203 self.jacValue = schema.addField(name + '_value', type="D", doc="Jacobian correction") 

204 self.jacFlag = schema.addField(name + '_flag', type="Flag", doc="Set to 1 for any fatal failure") 

205 # Calculate one over the area of a nominal reference pixel, where area 

206 # is in arcsec^2. 

207 self.scale = pow(self.config.pixelScale, -2) 

208 

209 def measure(self, measRecord, exposure): 

210 center = measRecord.getCentroid() 

211 # Compute the area of a pixel at a source record's centroid, and take 

212 # the ratio of that with the defined reference pixel area. 

213 result = np.abs(self.scale*exposure.getWcs().linearizePixelToSky( 

214 center, 

215 lsst.geom.arcseconds).getLinear().computeDeterminant()) 

216 measRecord.set(self.jacValue, result) 

217 

218 def fail(self, measRecord, error=None): 

219 measRecord.set(self.jacFlag, True) 

220 

221 

222class VarianceConfig(BaseMeasurementPluginConfig): 

223 """Configuration for the variance calculation plugin. 

224 """ 

225 scale = lsst.pex.config.Field(dtype=float, default=5.0, optional=True, 

226 doc="Scale factor to apply to shape for aperture") 

227 mask = lsst.pex.config.ListField(doc="Mask planes to ignore", dtype=str, 

228 default=["DETECTED", "DETECTED_NEGATIVE", "BAD", "SAT"]) 

229 

230 

231class VariancePlugin(GenericPlugin): 

232 """Compute the median variance corresponding to a footprint. 

233 

234 The aim here is to measure the background variance, rather than that of 

235 the object itself. In order to achieve this, the variance is calculated 

236 over an area scaled up from the shape of the input footprint. 

237 

238 Parameters 

239 ---------- 

240 config : `VarianceConfig` 

241 Plugin configuration. 

242 name : `str` 

243 Plugin name. 

244 schema : `lsst.afw.table.Schema` 

245 The schema for the measurement output catalog. New fields will be 

246 added to hold measurements produced by this plugin. 

247 metadata : `lsst.daf.base.PropertySet` 

248 Plugin metadata that will be attached to the output catalog 

249 """ 

250 

251 ConfigClass = VarianceConfig 

252 

253 FAILURE_BAD_CENTROID = 1 

254 """Denotes failures due to bad centroiding (`int`). 

255 """ 

256 

257 FAILURE_EMPTY_FOOTPRINT = 2 

258 """Denotes failures due to a lack of usable pixels (`int`). 

259 """ 

260 

261 @classmethod 

262 def getExecutionOrder(cls): 

263 return BasePlugin.FLUX_ORDER 

264 

265 def __init__(self, config, name, schema, metadata): 

266 GenericPlugin.__init__(self, config, name, schema, metadata) 

267 self.varValue = schema.addField(name + '_value', type="D", doc="Variance at object position") 

268 self.emptyFootprintFlag = schema.addField(name + '_flag_emptyFootprint', type="Flag", 

269 doc="Set to True when the footprint has no usable pixels") 

270 

271 # Alias the badCentroid flag to that which is defined for the target 

272 # of the centroid slot. We do not simply rely on the alias because 

273 # that could be changed post-measurement. 

274 schema.getAliasMap().set(name + '_flag_badCentroid', schema.getAliasMap().apply("slot_Centroid_flag")) 

275 

276 def measure(self, measRecord, exposure, center): 

277 # Create an aperture and grow it by scale value defined in config to 

278 # ensure there are enough pixels around the object to get decent 

279 # statistics 

280 if not np.all(np.isfinite(measRecord.getCentroid())): 

281 raise MeasurementError("Bad centroid and/or shape", self.FAILURE_BAD_CENTROID) 

282 aperture = lsst.afw.geom.Ellipse(measRecord.getShape(), measRecord.getCentroid()) 

283 aperture.scale(self.config.scale) 

284 ellipse = lsst.afw.geom.SpanSet.fromShape(aperture) 

285 foot = lsst.afw.detection.Footprint(ellipse) 

286 foot.clipTo(exposure.getBBox(lsst.afw.image.PARENT)) 

287 # Filter out any pixels which have mask bits set corresponding to the 

288 # planes to be excluded (defined in config.mask) 

289 maskedImage = exposure.getMaskedImage() 

290 pixels = lsst.afw.detection.makeHeavyFootprint(foot, maskedImage) 

291 maskBits = maskedImage.getMask().getPlaneBitMask(self.config.mask) 

292 logicalMask = np.logical_not(pixels.getMaskArray() & maskBits) 

293 # Compute the median variance value for each pixel not excluded by the 

294 # mask and write the record. Numpy median is used here instead of 

295 # afw.math makeStatistics because of an issue with data types being 

296 # passed into the C++ layer (DM-2379). 

297 if np.any(logicalMask): 

298 medVar = np.median(pixels.getVarianceArray()[logicalMask]) 

299 measRecord.set(self.varValue, medVar) 

300 else: 

301 raise MeasurementError("Footprint empty, or all pixels are masked, can't compute median", 

302 self.FAILURE_EMPTY_FOOTPRINT) 

303 

304 def fail(self, measRecord, error=None): 

305 # Check that we have an error object and that it is of type 

306 # MeasurementError 

307 if isinstance(error, MeasurementError): 307 ↛ 312line 307 didn't jump to line 312 because the condition on line 307 was always true

308 assert error.getFlagBit() in (self.FAILURE_BAD_CENTROID, self.FAILURE_EMPTY_FOOTPRINT) 

309 # FAILURE_BAD_CENTROID handled by alias to centroid record. 

310 if error.getFlagBit() == self.FAILURE_EMPTY_FOOTPRINT: 

311 measRecord.set(self.emptyFootprintFlag, True) 

312 measRecord.set(self.varValue, np.nan) 

313 GenericPlugin.fail(self, measRecord, error) 

314 

315 

316SingleFrameVariancePlugin = VariancePlugin.makeSingleFramePlugin("base_Variance") 

317"""Single-frame version of `VariancePlugin`. 

318""" 

319 

320ForcedVariancePlugin = VariancePlugin.makeForcedPlugin("base_Variance") 

321"""Forced version of `VariancePlugin`. 

322""" 

323 

324 

325class InputCountConfig(BaseMeasurementPluginConfig): 

326 """Configuration for the input image counting plugin. 

327 """ 

328 

329 

330class InputCountPlugin(GenericPlugin): 

331 """Count the number of input images which contributed to a source. 

332 

333 Parameters 

334 ---------- 

335 config : `InputCountConfig` 

336 Plugin configuration. 

337 name : `str` 

338 Plugin name. 

339 schema : `lsst.afw.table.Schema` 

340 The schema for the measurement output catalog. New fields will be 

341 added to hold measurements produced by this plugin. 

342 metadata : `lsst.daf.base.PropertySet` 

343 Plugin metadata that will be attached to the output catalog 

344 

345 Notes 

346 ----- 

347 Information is derived from the image's `~lsst.afw.image.CoaddInputs`. 

348 Note these limitation: 

349 

350 - This records the number of images which contributed to the pixel in the 

351 center of the source footprint, rather than to any or all pixels in the 

352 source. 

353 - Clipping in the coadd is not taken into account. 

354 """ 

355 

356 ConfigClass = InputCountConfig 

357 

358 FAILURE_BAD_CENTROID = 1 

359 """Denotes failures due to bad centroiding (`int`). 

360 """ 

361 

362 FAILURE_NO_INPUTS = 2 

363 """Denotes failures due to the image not having coadd inputs. (`int`) 

364 """ 

365 

366 @classmethod 

367 def getExecutionOrder(cls): 

368 return BasePlugin.SHAPE_ORDER 

369 

370 def __init__(self, config, name, schema, metadata): 

371 GenericPlugin.__init__(self, config, name, schema, metadata) 

372 self.numberKey = schema.addField(name + '_value', type="I", 

373 doc="Number of images contributing at center, not including any" 

374 "clipping") 

375 self.noInputsFlag = schema.addField(name + '_flag_noInputs', type="Flag", 

376 doc="No coadd inputs available") 

377 # Alias the badCentroid flag to that which is defined for the target of 

378 # the centroid slot. We do not simply rely on the alias because that 

379 # could be changed post-measurement. 

380 schema.getAliasMap().set(name + '_flag_badCentroid', schema.getAliasMap().apply("slot_Centroid_flag")) 

381 

382 def measure(self, measRecord, exposure, center): 

383 if not (coaddInputs := exposure.getInfo().getCoaddInputs()): 

384 raise MeasurementError("No coadd inputs defined.", self.FAILURE_NO_INPUTS) 

385 if not np.all(np.isfinite(center)): 

386 raise MeasurementError("Source has a bad centroid.", self.FAILURE_BAD_CENTROID) 

387 

388 count = len(coaddInputs.subset_containing_ccds(center, exposure.wcs)) 

389 measRecord.set(self.numberKey, count) 

390 

391 def fail(self, measRecord, error=None): 

392 if error is not None: 392 ↛ 397line 392 didn't jump to line 397 because the condition on line 392 was always true

393 assert error.getFlagBit() in (self.FAILURE_BAD_CENTROID, self.FAILURE_NO_INPUTS) 

394 # FAILURE_BAD_CENTROID handled by alias to centroid record. 

395 if error.getFlagBit() == self.FAILURE_NO_INPUTS: 

396 measRecord.set(self.noInputsFlag, True) 

397 GenericPlugin.fail(self, measRecord, error) 

398 

399 

400SingleFrameInputCountPlugin = InputCountPlugin.makeSingleFramePlugin("base_InputCount") 

401"""Single-frame version of `InputCoutPlugin`. 

402""" 

403 

404ForcedInputCountPlugin = InputCountPlugin.makeForcedPlugin("base_InputCount") 

405"""Forced version of `InputCoutPlugin`. 

406""" 

407 

408 

409class EvaluateLocalPhotoCalibPluginConfig(BaseMeasurementPluginConfig): 

410 """Configuration for the variance calculation plugin. 

411 """ 

412 

413 

414class EvaluateLocalPhotoCalibPlugin(GenericPlugin): 

415 """Evaluate the local value of the Photometric Calibration in the exposure. 

416 

417 The aim is to store the local calib value within the catalog for later 

418 use in the Science Data Model functors. 

419 """ 

420 ConfigClass = EvaluateLocalPhotoCalibPluginConfig 

421 

422 @classmethod 

423 def getExecutionOrder(cls): 

424 return BasePlugin.FLUX_ORDER 

425 

426 def __init__(self, config, name, schema, metadata): 

427 GenericPlugin.__init__(self, config, name, schema, metadata) 

428 self.photoKey = schema.addField( 

429 name, 

430 type="D", 

431 doc="Local approximation of the PhotoCalib calibration factor at " 

432 "the location of the src.") 

433 self.photoErrKey = schema.addField( 

434 "%sErr" % name, 

435 type="D", 

436 doc="Error on the local approximation of the PhotoCalib " 

437 "calibration factor at the location of the src.") 

438 

439 def measure(self, measRecord, exposure, center): 

440 photoCalib = exposure.getPhotoCalib() 

441 if photoCalib is None: 

442 log.debug( 

443 "%s: photoCalib is None. Setting localPhotoCalib to NaN for record %d", 

444 self.name, 

445 measRecord.getId(), 

446 ) 

447 calib = np.nan 

448 calibErr = np.nan 

449 measRecord.set(self._failKey, True) 

450 else: 

451 calib = photoCalib.getLocalCalibration(center) 

452 calibErr = photoCalib.getCalibrationErr() 

453 measRecord.set(self.photoKey, calib) 

454 measRecord.set(self.photoErrKey, calibErr) 

455 

456 

457SingleFrameEvaluateLocalPhotoCalibPlugin = EvaluateLocalPhotoCalibPlugin.makeSingleFramePlugin( 

458 "base_LocalPhotoCalib") 

459"""Single-frame version of `EvaluatePhotoCalibPlugin`. 

460""" 

461 

462ForcedEvaluateLocalPhotoCalibPlugin = EvaluateLocalPhotoCalibPlugin.makeForcedPlugin( 

463 "base_LocalPhotoCalib") 

464"""Forced version of `EvaluatePhotoCalibPlugin`. 

465""" 

466 

467 

468class EvaluateLocalWcsPluginConfig(BaseMeasurementPluginConfig): 

469 """Configuration for the variance calculation plugin. 

470 """ 

471 

472 

473class EvaluateLocalWcsPlugin(GenericPlugin): 

474 """Evaluate the local, linear approximation of the Wcs. 

475 

476 The aim is to store the local calib value within the catalog for later 

477 use in the Science Data Model functors. 

478 """ 

479 ConfigClass = EvaluateLocalWcsPluginConfig 

480 _scale = (1.0 * lsst.geom.arcseconds).asDegrees() 

481 

482 @classmethod 

483 def getExecutionOrder(cls): 

484 return BasePlugin.FLUX_ORDER 

485 

486 def __init__(self, config, name, schema, metadata): 

487 GenericPlugin.__init__(self, config, name, schema, metadata) 

488 self.cdMatrix11Key = schema.addField( 

489 f"{name}_CDMatrix_1_1", 

490 type="D", 

491 doc="(1, 1) element of the CDMatrix for the linear approximation " 

492 "of the WCS at the src location. Gives units in radians.") 

493 self.cdMatrix12Key = schema.addField( 

494 f"{name}_CDMatrix_1_2", 

495 type="D", 

496 doc="(1, 2) element of the CDMatrix for the linear approximation " 

497 "of the WCS at the src location. Gives units in radians.") 

498 self.cdMatrix21Key = schema.addField( 

499 f"{name}_CDMatrix_2_1", 

500 type="D", 

501 doc="(2, 1) element of the CDMatrix for the linear approximation " 

502 "of the WCS at the src location. Gives units in radians.") 

503 self.cdMatrix22Key = schema.addField( 

504 f"{name}_CDMatrix_2_2", 

505 type="D", 

506 doc="(2, 2) element of the CDMatrix for the linear approximation " 

507 "of the WCS at the src location. Gives units in radians.") 

508 

509 def measure(self, measRecord, exposure, center): 

510 wcs = exposure.getWcs() 

511 if wcs is None: 

512 log.debug( 

513 "%s: WCS is None. Setting localWcs matrix values to NaN for record %d", 

514 self.name, 

515 measRecord.getId(), 

516 ) 

517 localMatrix = np.array([[np.nan, np.nan], [np.nan, np.nan]]) 

518 measRecord.set(self._failKey, True) 

519 else: 

520 localMatrix = self.makeLocalTransformMatrix(wcs, center) 

521 measRecord.set(self.cdMatrix11Key, localMatrix[0, 0]) 

522 measRecord.set(self.cdMatrix12Key, localMatrix[0, 1]) 

523 measRecord.set(self.cdMatrix21Key, localMatrix[1, 0]) 

524 measRecord.set(self.cdMatrix22Key, localMatrix[1, 1]) 

525 

526 def makeLocalTransformMatrix(self, wcs, center): 

527 """Create a local, linear approximation of the wcs transformation 

528 matrix. 

529 

530 The approximation is created as if the center is at RA=0, DEC=0. All 

531 comparing x,y coordinate are relative to the position of center. Matrix 

532 is initially calculated with units arcseconds and then converted to 

533 radians. This yields higher precision results due to quirks in AST. 

534 

535 Parameters 

536 ---------- 

537 wcs : `lsst.afw.geom.SkyWcs` 

538 Wcs to approximate 

539 center : `lsst.geom.Point2D` 

540 Point at which to evaluate the LocalWcs. 

541 

542 Returns 

543 ------- 

544 localMatrix : `numpy.ndarray` 

545 Matrix representation the local wcs approximation with units 

546 radians. 

547 """ 

548 skyCenter = wcs.pixelToSky(center) 

549 localGnomonicWcs = lsst.afw.geom.makeSkyWcs( 

550 center, skyCenter, np.diag((self._scale, self._scale))) 

551 measurementToLocalGnomonic = wcs.getTransform().then( 

552 localGnomonicWcs.getTransform().inverted() 

553 ) 

554 localMatrix = measurementToLocalGnomonic.getJacobian(center) 

555 return np.radians(localMatrix / 3600) 

556 

557 

558SingleFrameEvaluateLocalWcsPlugin = EvaluateLocalWcsPlugin.makeSingleFramePlugin("base_LocalWcs") 

559"""Single-frame version of `EvaluateLocalWcsPlugin`. 

560""" 

561 

562ForcedEvaluateLocalWcsPlugin = EvaluateLocalWcsPlugin.makeForcedPlugin("base_LocalWcs") 

563"""Forced version of `EvaluateLocalWcsPlugin`. 

564""" 

565 

566 

567class SingleFramePeakCentroidConfig(SingleFramePluginConfig): 

568 """Configuration for the single frame peak centroiding algorithm. 

569 """ 

570 

571 

572@register("base_PeakCentroid") 

573class SingleFramePeakCentroidPlugin(SingleFramePlugin): 

574 """Record the highest peak in a source footprint as its centroid. 

575 

576 This is of course a relatively poor measure of the true centroid of the 

577 object; this algorithm is provided mostly for testing and debugging. 

578 

579 Parameters 

580 ---------- 

581 config : `SingleFramePeakCentroidConfig` 

582 Plugin configuration. 

583 name : `str` 

584 Plugin name. 

585 schema : `lsst.afw.table.Schema` 

586 The schema for the measurement output catalog. New fields will be 

587 added to hold measurements produced by this plugin. 

588 metadata : `lsst.daf.base.PropertySet` 

589 Plugin metadata that will be attached to the output catalog 

590 """ 

591 

592 ConfigClass = SingleFramePeakCentroidConfig 

593 

594 @classmethod 

595 def getExecutionOrder(cls): 

596 return cls.CENTROID_ORDER 

597 

598 def __init__(self, config, name, schema, metadata): 

599 SingleFramePlugin.__init__(self, config, name, schema, metadata) 

600 self.keyX = schema.addField(name + "_x", type="D", doc="peak centroid", units="pixel") 

601 self.keyY = schema.addField(name + "_y", type="D", doc="peak centroid", units="pixel") 

602 self.flag = schema.addField(name + "_flag", type="Flag", doc="Centroiding failed") 

603 

604 def measure(self, measRecord, exposure): 

605 peak = measRecord.getFootprint().getPeaks()[0] 

606 measRecord.set(self.keyX, peak.getFx()) 

607 measRecord.set(self.keyY, peak.getFy()) 

608 

609 def fail(self, measRecord, error=None): 

610 measRecord.set(self.flag, True) 

611 

612 @staticmethod 

613 def getTransformClass(): 

614 return SimpleCentroidTransform 

615 

616 

617class SingleFrameSkyCoordConfig(SingleFramePluginConfig): 

618 """Configuration for the sky coordinates algorithm. 

619 """ 

620 

621 

622@register("base_SkyCoord") 

623class SingleFrameSkyCoordPlugin(SingleFramePlugin): 

624 """Record the sky position and uncertainties of an object based on its 

625 centroid slot and WCS. 

626 

627 The position is recorded in the ``coord`` field, which is part of the 

628 `~lsst.afw.table.SourceCatalog` minimal schema. The associated 

629 uncertainty fields propagate the centroid covariance through a Jacobian 

630 taken in a local tangent (gnomonic) plane centered on the source. 

631 

632 Parameters 

633 ---------- 

634 config : `SingleFrameSkyCoordConfig` 

635 Plugin configuration. 

636 name : `str` 

637 Plugin name. 

638 schema : `lsst.afw.table.Schema` 

639 The schema for the measurement output catalog. New fields will be 

640 added to hold measurements produced by this plugin. 

641 metadata : `lsst.daf.base.PropertySet` 

642 Plugin metadata that will be attached to the output catalog 

643 """ 

644 

645 ConfigClass = SingleFrameSkyCoordConfig 

646 

647 @classmethod 

648 def getExecutionOrder(cls): 

649 return cls.SHAPE_ORDER 

650 

651 def __init__(self, config, name, schema, metadata): 

652 SingleFramePlugin.__init__(self, config, name, schema, metadata) 

653 if "coord_raErr" not in schema: 653 ↛ 654line 653 didn't jump to line 654 because the condition on line 653 was never true

654 lsst.afw.table.CoordKey.addErrorFields(schema) 

655 

656 def measure(self, measRecord, exposure): 

657 # There should be a base class method for handling this exception. Put 

658 # this on a later ticket. Also, there should be a python Exception of 

659 # the appropriate type for this error 

660 if not exposure.hasWcs(): 660 ↛ 661line 660 didn't jump to line 661 because the condition on line 660 was never true

661 raise RuntimeError("Wcs not attached to exposure. Required for " + self.name + " algorithm") 

662 measRecord.updateCoord(exposure.getWcs()) 

663 

664 def fail(self, measRecord, error=None): 

665 # Override fail() to do nothing in the case of an exception: this is 

666 # not ideal, but we don't have a place to put failures because we 

667 # don't allocate any fields. Should consider fixing as part of 

668 # DM-1011 

669 pass 

670 

671 

672class SingleFrameClassificationSizeExtendednessConfig(SingleFramePluginConfig): 

673 """Configuration for moments-based star-galaxy classifier.""" 

674 

675 exponent = lsst.pex.config.Field[float]( 

676 doc="Exponent to raise the PSF size squared (Ixx + Iyy) to, " 

677 "in the likelihood normalization", 

678 default=0.5, 

679 ) 

680 

681 

682@register("base_ClassificationSizeExtendedness") 

683class SingleFrameClassificationSizeExtendednessPlugin(SingleFramePlugin): 

684 """Classify objects by comparing their moments-based trace radius to PSF's. 

685 

686 The plugin computes chi^2 as ((T_obj - T_psf)/T_psf^exponent)^2, where 

687 T_obj is the sum of Ixx and Iyy moments of the object, and T_psf is the 

688 sum of Ixx and Iyy moments of the PSF. The exponent is configurable. 

689 The measure of being a galaxy is then 1 - exp(-0.5*chi^2). 

690 

691 Parameters 

692 ---------- 

693 config : `SingleFrameClassificationSizeExtendednessConfig` 

694 Plugin configuration. 

695 name : `str` 

696 Plugin name. 

697 schema : `~lsst.afw.table.Schema` 

698 The schema for the measurement output catalog. New fields will be 

699 added to hold measurements produced by this plugin. 

700 metadata : `~lsst.daf.base.PropertySet` 

701 Plugin metadata that will be attached to the output catalog. 

702 

703 Notes 

704 ----- 

705 The ``measure`` method of the plugin requires a value for the ``exposure`` 

706 argument to maintain consistent API, but it is not used in the measurement. 

707 """ 

708 

709 ConfigClass = SingleFrameClassificationSizeExtendednessConfig 

710 

711 FAILURE_BAD_SHAPE = 1 

712 """Denotes failures due to bad shape (`int`). 

713 """ 

714 

715 @classmethod 

716 def getExecutionOrder(cls): 

717 return cls.FLUX_ORDER 

718 

719 def __init__(self, config, name, schema, metadata): 

720 SingleFramePlugin.__init__(self, config, name, schema, metadata) 

721 self.key = schema.addField(name + "_value", 

722 type="D", 

723 doc="Measure of being a galaxy based on trace of second order moments", 

724 ) 

725 self.flag = schema.addField(name + "_flag", type="Flag", doc="Moments-based classification failed") 

726 

727 def measure(self, measRecord, exposure) -> None: 

728 # Docstring inherited. 

729 

730 if measRecord.getShapeFlag(): 

731 raise MeasurementError( 

732 "Shape flag is set. Required for " + self.name + " algorithm", 

733 self.FAILURE_BAD_SHAPE, 

734 ) 

735 

736 shape = measRecord.getShape() 

737 psf_shape = measRecord.getPsfShape() 

738 

739 ixx = shape.getIxx() 

740 iyy = shape.getIyy() 

741 ixx_psf = psf_shape.getIxx() 

742 iyy_psf = psf_shape.getIyy() 

743 

744 object_t = ixx + iyy 

745 psf_t = ixx_psf + iyy_psf 

746 

747 chi_sq = ((object_t - psf_t)/(psf_t**self.config.exponent))**2. 

748 likelihood = 1. - np.exp(-0.5*chi_sq) 

749 measRecord.set(self.key, likelihood) 

750 

751 def fail(self, measRecord, error=None) -> None: 

752 # Docstring inherited. 

753 measRecord.set(self.key, np.nan) 

754 measRecord.set(self.flag, True) 

755 

756 

757class ForcedPeakCentroidConfig(ForcedPluginConfig): 

758 """Configuration for the forced peak centroid algorithm. 

759 """ 

760 

761 

762@register("base_PeakCentroid") 

763class ForcedPeakCentroidPlugin(ForcedPlugin): 

764 """Record the highest peak in a source footprint as its centroid. 

765 

766 This is of course a relatively poor measure of the true centroid of the 

767 object; this algorithm is provided mostly for testing and debugging. 

768 

769 This is similar to `SingleFramePeakCentroidPlugin`, except that transforms 

770 the peak coordinate from the original (reference) coordinate system to the 

771 coordinate system of the exposure being measured. 

772 

773 Parameters 

774 ---------- 

775 config : `ForcedPeakCentroidConfig` 

776 Plugin configuration. 

777 name : `str` 

778 Plugin name. 

779 schemaMapper : `lsst.afw.table.SchemaMapper` 

780 A mapping from reference catalog fields to output 

781 catalog fields. Output fields are added to the output schema. 

782 metadata : `lsst.daf.base.PropertySet` 

783 Plugin metadata that will be attached to the output catalog. 

784 """ 

785 

786 ConfigClass = ForcedPeakCentroidConfig 

787 

788 @classmethod 

789 def getExecutionOrder(cls): 

790 return cls.CENTROID_ORDER 

791 

792 def __init__(self, config, name, schemaMapper, metadata): 

793 ForcedPlugin.__init__(self, config, name, schemaMapper, metadata) 

794 schema = schemaMapper.editOutputSchema() 

795 self.keyX = schema.addField(name + "_x", type="D", doc="peak centroid", units="pixel") 

796 self.keyY = schema.addField(name + "_y", type="D", doc="peak centroid", units="pixel") 

797 

798 def measure(self, measRecord, exposure, refRecord, refWcs): 

799 targetWcs = exposure.getWcs() 

800 peak = refRecord.getFootprint().getPeaks()[0] 

801 result = lsst.geom.Point2D(peak.getFx(), peak.getFy()) 

802 result = targetWcs.skyToPixel(refWcs.pixelToSky(result)) 

803 measRecord.set(self.keyX, result.getX()) 

804 measRecord.set(self.keyY, result.getY()) 

805 

806 @staticmethod 

807 def getTransformClass(): 

808 return SimpleCentroidTransform 

809 

810 

811class ForcedTransformedCentroidConfig(ForcedPluginConfig): 

812 """Configuration for the forced transformed centroid algorithm. 

813 """ 

814 

815 

816@register("base_TransformedCentroid") 

817class ForcedTransformedCentroidPlugin(ForcedPlugin): 

818 """Record the transformation of the reference catalog centroid. 

819 

820 The centroid recorded in the reference catalog is tranformed to the 

821 measurement coordinate system and stored. 

822 

823 Parameters 

824 ---------- 

825 config : `ForcedTransformedCentroidConfig` 

826 Plugin configuration 

827 name : `str` 

828 Plugin name 

829 schemaMapper : `lsst.afw.table.SchemaMapper` 

830 A mapping from reference catalog fields to output 

831 catalog fields. Output fields are added to the output schema. 

832 metadata : `lsst.daf.base.PropertySet` 

833 Plugin metadata that will be attached to the output catalog. 

834 

835 Notes 

836 ----- 

837 This is used as the slot centroid by default in forced measurement, 

838 allowing subsequent measurements to simply refer to the slot value just as 

839 they would in single-frame measurement. 

840 """ 

841 

842 ConfigClass = ForcedTransformedCentroidConfig 

843 

844 @classmethod 

845 def getExecutionOrder(cls): 

846 return cls.CENTROID_ORDER 

847 

848 def __init__(self, config, name, schemaMapper, metadata): 

849 ForcedPlugin.__init__(self, config, name, schemaMapper, metadata) 

850 schema = schemaMapper.editOutputSchema() 

851 # Allocate x and y fields, join these into a single FunctorKey for 

852 # ease-of-use. 

853 xKey = schema.addField(name + "_x", type="D", doc="transformed reference centroid column", 

854 units="pixel") 

855 yKey = schema.addField(name + "_y", type="D", doc="transformed reference centroid row", 

856 units="pixel") 

857 self.centroidKey = lsst.afw.table.Point2DKey(xKey, yKey) 

858 # Because we're taking the reference position as given, we don't bother 

859 # transforming its uncertainty and reporting that here, so there are no 

860 # sigma or cov fields. We do propagate the flag field, if it exists. 

861 if "slot_Centroid_flag" in schemaMapper.getInputSchema(): 

862 self.flagKey = schema.addField(name + "_flag", type="Flag", 

863 doc="whether the reference centroid is marked as bad") 

864 else: 

865 self.flagKey = None 

866 

867 def measure(self, measRecord, exposure, refRecord, refWcs): 

868 targetWcs = exposure.getWcs() 

869 if not refWcs == targetWcs: 869 ↛ 873line 869 didn't jump to line 873 because the condition on line 869 was always true

870 targetPos = targetWcs.skyToPixel(refWcs.pixelToSky(refRecord.getCentroid())) 

871 measRecord.set(self.centroidKey, targetPos) 

872 else: 

873 measRecord.set(self.centroidKey, refRecord.getCentroid()) 

874 if self.flagKey is not None: 874 ↛ exitline 874 didn't return from function 'measure' because the condition on line 874 was always true

875 measRecord.set(self.flagKey, refRecord.getCentroidFlag()) 

876 

877 

878class ForcedTransformedCentroidFromCoordConfig(ForcedTransformedCentroidConfig): 

879 """Configuration for the forced transformed coord algorithm. 

880 """ 

881 

882 

883@register("base_TransformedCentroidFromCoord") 

884class ForcedTransformedCentroidFromCoordPlugin(ForcedTransformedCentroidPlugin): 

885 """Record the transformation of the reference catalog coord. 

886 

887 The coord recorded in the reference catalog is tranformed to the 

888 measurement coordinate system and stored. 

889 

890 Parameters 

891 ---------- 

892 config : `ForcedTransformedCentroidFromCoordConfig` 

893 Plugin configuration 

894 name : `str` 

895 Plugin name 

896 schemaMapper : `lsst.afw.table.SchemaMapper` 

897 A mapping from reference catalog fields to output 

898 catalog fields. Output fields are added to the output schema. 

899 metadata : `lsst.daf.base.PropertySet` 

900 Plugin metadata that will be attached to the output catalog. 

901 

902 Notes 

903 ----- 

904 This can be used as the slot centroid in forced measurement when only a 

905 reference coord exist, allowing subsequent measurements to simply refer to 

906 the slot value just as they would in single-frame measurement. 

907 """ 

908 

909 ConfigClass = ForcedTransformedCentroidFromCoordConfig 

910 

911 def measure(self, measRecord, exposure, refRecord, refWcs): 

912 targetWcs = exposure.getWcs() 

913 

914 targetPos = targetWcs.skyToPixel(refRecord.getCoord()) 

915 measRecord.set(self.centroidKey, targetPos) 

916 

917 if self.flagKey is not None: 

918 measRecord.set(self.flagKey, refRecord.getCentroidFlag()) 

919 

920 

921class ForcedTransformedShapeConfig(ForcedPluginConfig): 

922 """Configuration for the forced transformed shape algorithm. 

923 """ 

924 

925 

926@register("base_TransformedShape") 

927class ForcedTransformedShapePlugin(ForcedPlugin): 

928 """Record the transformation of the reference catalog shape. 

929 

930 The shape recorded in the reference catalog is tranformed to the 

931 measurement coordinate system and stored. 

932 

933 Parameters 

934 ---------- 

935 config : `ForcedTransformedShapeConfig` 

936 Plugin configuration 

937 name : `str` 

938 Plugin name 

939 schemaMapper : `lsst.afw.table.SchemaMapper` 

940 A mapping from reference catalog fields to output 

941 catalog fields. Output fields are added to the output schema. 

942 metadata : `lsst.daf.base.PropertySet` 

943 Plugin metadata that will be attached to the output catalog. 

944 

945 Notes 

946 ----- 

947 This is used as the slot shape by default in forced measurement, allowing 

948 subsequent measurements to simply refer to the slot value just as they 

949 would in single-frame measurement. 

950 """ 

951 

952 ConfigClass = ForcedTransformedShapeConfig 

953 

954 @classmethod 

955 def getExecutionOrder(cls): 

956 return cls.SHAPE_ORDER 

957 

958 def __init__(self, config, name, schemaMapper, metadata): 

959 ForcedPlugin.__init__(self, config, name, schemaMapper, metadata) 

960 schema = schemaMapper.editOutputSchema() 

961 # Allocate xx, yy, xy fields, join these into a single FunctorKey for 

962 # ease-of-use. 

963 xxKey = schema.addField(name + "_xx", type="D", doc="transformed reference shape x^2 moment", 

964 units="pixel^2") 

965 yyKey = schema.addField(name + "_yy", type="D", doc="transformed reference shape y^2 moment", 

966 units="pixel^2") 

967 xyKey = schema.addField(name + "_xy", type="D", doc="transformed reference shape xy moment", 

968 units="pixel^2") 

969 self.shapeKey = lsst.afw.table.QuadrupoleKey(xxKey, yyKey, xyKey) 

970 # Because we're taking the reference position as given, we don't bother 

971 # transforming its uncertainty and reporting that here, so there are no 

972 # sigma or cov fields. We do propagate the flag field, if it exists. 

973 if "slot_Shape_flag" in schemaMapper.getInputSchema(): 973 ↛ 977line 973 didn't jump to line 977 because the condition on line 973 was always true

974 self.flagKey = schema.addField(name + "_flag", type="Flag", 

975 doc="whether the reference shape is marked as bad") 

976 else: 

977 self.flagKey = None 

978 

979 def measure(self, measRecord, exposure, refRecord, refWcs): 

980 targetWcs = exposure.getWcs() 

981 if not refWcs == targetWcs: 981 ↛ 986line 981 didn't jump to line 986 because the condition on line 981 was always true

982 fullTransform = lsst.afw.geom.makeWcsPairTransform(refWcs, targetWcs) 

983 localTransform = lsst.afw.geom.linearizeTransform(fullTransform, refRecord.getCentroid()) 

984 measRecord.set(self.shapeKey, refRecord.getShape().transform(localTransform.getLinear())) 

985 else: 

986 measRecord.set(self.shapeKey, refRecord.getShape()) 

987 if self.flagKey is not None: 987 ↛ exitline 987 didn't return from function 'measure' because the condition on line 987 was always true

988 measRecord.set(self.flagKey, refRecord.getShapeFlag())