Coverage for tests/test_gaap.py: 13%

382 statements  

« prev     ^ index     » next       coverage.py v7.5.1, created at 2024-05-16 04:16 -0700

1# This file is part of meas_extensions_gaap 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (http://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 LSST License Statement and 

20# the GNU General Public License along with this program. If not, 

21# see <http://www.lsstcorp.org/LegalNotices/>. 

22 

23import math 

24import unittest 

25import galsim 

26import itertools 

27import lsst.afw.detection as afwDetection 

28import lsst.afw.display as afwDisplay 

29import lsst.afw.geom as afwGeom 

30import lsst.afw.image as afwImage 

31import lsst.afw.table as afwTable 

32import lsst.daf.base as dafBase 

33import lsst.geom as geom 

34from lsst.pex.exceptions import InvalidParameterError 

35import lsst.meas.base as measBase 

36import lsst.meas.base.tests 

37import lsst.meas.extensions.gaap 

38import lsst.utils.tests 

39import numpy as np 

40import scipy 

41 

42 

43try: 

44 type(display) 

45except NameError: 

46 display = False 

47 frame = 1 

48 

49 

50def makeGalaxyExposure(scale, psfSigma=0.9, flux=1000., galSigma=3.7, variance=1.0): 

51 """Make an ideal exposure of circular Gaussian 

52 

53 For the purpose of testing Gaussian Aperture and PSF algorithm (GAaP), this 

54 generates a noiseless image of circular Gaussian galaxy of a desired total 

55 flux convolved by a circular Gaussian PSF. The Gaussianity of the galaxy 

56 and the PSF allows comparison with analytical results, modulo pixelization. 

57 

58 Parameters 

59 ---------- 

60 scale : `float` 

61 Pixel scale in the exposure. 

62 psfSigma : `float` 

63 Sigma of the circular Gaussian PSF. 

64 flux : `float` 

65 The total flux of the galaxy. 

66 galSigma : `float` 

67 Sigma of the pre-seeing circular Gaussian galaxy. 

68 

69 Returns 

70 ------- 

71 exposure, center 

72 A tuple containing an lsst.afw.image.Exposure and lsst.geom.Point2D 

73 objects, corresponding to the galaxy image and its centroid. 

74 """ 

75 psfWidth = 2*int(4.0*psfSigma) + 1 

76 galWidth = 2*int(40.*math.hypot(galSigma, psfSigma)) + 1 

77 gal = galsim.Gaussian(sigma=galSigma, flux=flux) 

78 

79 galIm = galsim.Image(galWidth, galWidth) 

80 galIm = galsim.Convolve([gal, galsim.Gaussian(sigma=psfSigma, flux=1.)]).drawImage(image=galIm, 

81 scale=1.0, 

82 method='no_pixel') 

83 exposure = afwImage.makeExposure(afwImage.makeMaskedImageFromArrays(galIm.array)) 

84 exposure.setPsf(afwDetection.GaussianPsf(psfWidth, psfWidth, psfSigma)) 

85 

86 exposure.variance.set(variance) 

87 exposure.mask.set(0) 

88 center = exposure.getBBox().getCenter() 

89 

90 cdMatrix = afwGeom.makeCdMatrix(scale=scale) 

91 exposure.setWcs(afwGeom.makeSkyWcs(crpix=center, 

92 crval=geom.SpherePoint(0.0, 0.0, geom.degrees), 

93 cdMatrix=cdMatrix)) 

94 return exposure, center 

95 

96 

97class GaapFluxTestCase(lsst.meas.base.tests.AlgorithmTestCase, lsst.utils.tests.TestCase): 

98 """Main test case for the GAaP plugin. 

99 """ 

100 def setUp(self): 

101 self.center = lsst.geom.Point2D(100.0, 770.0) 

102 self.bbox = lsst.geom.Box2I(lsst.geom.Point2I(-20, -30), 

103 lsst.geom.Extent2I(240, 1600)) 

104 self.dataset = lsst.meas.base.tests.TestDataset(self.bbox) 

105 

106 # We will consider three sources in our test case 

107 # recordId = 0: A bright point source 

108 # recordId = 1: An elliptical (Gaussian) galaxy 

109 # recordId = 2: A source near a corner 

110 self.dataset.addSource(1000., self.center - lsst.geom.Extent2I(0, 100)) 

111 self.dataset.addSource(1000., self.center + lsst.geom.Extent2I(0, 100), 

112 afwGeom.Quadrupole(9., 9., 4.)) 

113 self.dataset.addSource(600., lsst.geom.Point2D(self.bbox.getMin()) + lsst.geom.Extent2I(10, 10)) 

114 

115 def tearDown(self): 

116 del self.center 

117 del self.bbox 

118 del self.dataset 

119 

120 def makeAlgorithm(self, gaapConfig=None): 

121 schema = lsst.meas.base.tests.TestDataset.makeMinimalSchema() 

122 if gaapConfig is None: 

123 gaapConfig = lsst.meas.extensions.gaap.SingleFrameGaapFluxConfig() 

124 gaapPlugin = lsst.meas.extensions.gaap.SingleFrameGaapFluxPlugin(gaapConfig, 

125 "ext_gaap_GaapFlux", 

126 schema, None) 

127 if gaapConfig.doOptimalPhotometry: 

128 afwTable.QuadrupoleKey.addFields(schema, "psfShape", "PSF shape") 

129 schema.getAliasMap().set("slot_PsfShape", "psfShape") 

130 return gaapPlugin, schema 

131 

132 def check(self, psfSigma=0.5, flux=1000., scalingFactors=[1.15], forced=False): 

133 """Check for non-negative values for GAaP instFlux and instFluxErr. 

134 """ 

135 scale = 0.1*geom.arcseconds 

136 

137 TaskClass = measBase.ForcedMeasurementTask if forced else measBase.SingleFrameMeasurementTask 

138 

139 # Create an image of a tiny source 

140 exposure, center = makeGalaxyExposure(scale, psfSigma, flux, galSigma=0.001, variance=0.) 

141 

142 measConfig = TaskClass.ConfigClass() 

143 algName = "ext_gaap_GaapFlux" 

144 

145 # Remove sky coordinate plugin because we don't have the columns 

146 # in the tests. 

147 if "base_SkyCoord" in measConfig.plugins.names: 

148 measConfig.plugins.names.remove("base_SkyCoord") 

149 

150 measConfig.plugins.names.add(algName) 

151 

152 if forced: 

153 measConfig.copyColumns = {"id": "objectId", "parent": "parentObjectId"} 

154 

155 algConfig = measConfig.plugins[algName] 

156 algConfig.scalingFactors = scalingFactors 

157 algConfig.scaleByFwhm = True 

158 algConfig.doPsfPhotometry = True 

159 # Do not turn on optimal photometry; not robust for a point-source. 

160 algConfig.doOptimalPhotometry = False 

161 

162 if forced: 

163 offset = geom.Extent2D(-12.3, 45.6) 

164 refWcs = exposure.getWcs().copyAtShiftedPixelOrigin(offset) 

165 refSchema = afwTable.SourceTable.makeMinimalSchema() 

166 centroidKey = afwTable.Point2DKey.addFields(refSchema, "my_centroid", doc="centroid", 

167 unit="pixel") 

168 shapeKey = afwTable.QuadrupoleKey.addFields(refSchema, "my_shape", "shape") 

169 refSchema.getAliasMap().set("slot_Centroid", "my_centroid") 

170 refSchema.getAliasMap().set("slot_Shape", "my_shape") 

171 refSchema.addField("my_centroid_flag", type="Flag", doc="centroid flag") 

172 refSchema.addField("my_shape_flag", type="Flag", doc="shape flag") 

173 refCat = afwTable.SourceCatalog(refSchema) 

174 refSource = refCat.addNew() 

175 refSource.set(centroidKey, center + offset) 

176 refSource.set(shapeKey, afwGeom.Quadrupole(1.0, 1.0, 0.0)) 

177 

178 refSource.setCoord(refWcs.pixelToSky(refSource.get(centroidKey))) 

179 taskInitArgs = (refSchema,) 

180 taskRunArgs = (refCat, refWcs) 

181 else: 

182 taskInitArgs = (afwTable.SourceTable.makeMinimalSchema(),) 

183 taskRunArgs = () 

184 

185 # Activate undeblended measurement with the same configuration 

186 measConfig.undeblended.names.add(algName) 

187 measConfig.undeblended[algName] = measConfig.plugins[algName] 

188 

189 # We are no longer going to change the configs. 

190 # So validate and freeze as they would happen when run from a CLI 

191 measConfig.validate() 

192 measConfig.freeze() 

193 

194 algMetadata = dafBase.PropertyList() 

195 task = TaskClass(*taskInitArgs, config=measConfig, algMetadata=algMetadata) 

196 

197 schema = task.schema 

198 measCat = afwTable.SourceCatalog(schema) 

199 source = measCat.addNew() 

200 source.getTable().setMetadata(algMetadata) 

201 ss = afwDetection.FootprintSet(exposure.getMaskedImage(), afwDetection.Threshold(10.0)) 

202 fp = ss.getFootprints()[0] 

203 source.setFootprint(fp) 

204 

205 task.run(measCat, exposure, *taskRunArgs) 

206 

207 if display: 

208 disp = afwDisplay.Display(frame) 

209 disp.mtv(exposure) 

210 disp.dot("x", *center, origin=afwImage.PARENT, title="psfSigma=%f" % (psfSigma,)) 

211 

212 self.assertFalse(source.get(algName + "_flag")) # algorithm succeeded 

213 

214 # We first check if it produces a positive number (non-nan) 

215 for baseName in algConfig.getAllGaapResultNames(algName): 

216 self.assertTrue((source.get(baseName + "_instFlux") >= 0)) 

217 self.assertTrue((source.get(baseName + "_instFluxErr") >= 0)) 

218 

219 # For scalingFactor > 1, check if the measured value is close to truth. 

220 for baseName in algConfig.getAllGaapResultNames(algName): 

221 if "_1_0x_" not in baseName: 

222 rtol = 0.1 if "PsfFlux" not in baseName else 0.2 

223 self.assertFloatsAlmostEqual(source.get(baseName + "_instFlux"), flux, rtol=rtol) 

224 

225 def runGaap(self, forced, psfSigma, scalingFactors=(1.0, 1.05, 1.1, 1.15, 1.2, 1.5, 2.0)): 

226 self.check(psfSigma=psfSigma, forced=forced, scalingFactors=scalingFactors) 

227 

228 @lsst.utils.tests.methodParameters(psfSigma=(1.7, 0.95, 1.3,)) 

229 def testGaapPluginUnforced(self, psfSigma): 

230 """Run GAaP as Single-frame measurement plugin. 

231 """ 

232 self.runGaap(False, psfSigma) 

233 

234 @lsst.utils.tests.methodParameters(psfSigma=(1.7, 0.95, 1.3,)) 

235 def testGaapPluginForced(self, psfSigma): 

236 """Run GAaP as forced measurement plugin. 

237 """ 

238 self.runGaap(True, psfSigma) 

239 

240 def testFail(self, scalingFactors=[100.], sigmas=[500.]): 

241 """Test that the fail method sets the flags correctly. 

242 

243 Set config parameters that are guaranteed to raise exceptions, 

244 and check that they are handled properly by the `fail` method and that 

245 expected log messages are generated. 

246 For failure modes not handled by the `fail` method, we test them 

247 in the ``testFlags`` method. 

248 """ 

249 algName = "ext_gaap_GaapFlux" 

250 dependencies = ("base_SdssShape",) 

251 config = self.makeSingleFrameMeasurementConfig(algName, dependencies=dependencies) 

252 gaapConfig = config.plugins[algName] 

253 gaapConfig.scalingFactors = scalingFactors 

254 gaapConfig.sigmas = sigmas 

255 gaapConfig.doPsfPhotometry = True 

256 gaapConfig.doOptimalPhotometry = True 

257 

258 gaapConfig.scaleByFwhm = True 

259 self.assertTrue(gaapConfig.scaleByFwhm) # Test the getter method. 

260 

261 algMetadata = lsst.daf.base.PropertyList() 

262 sfmTask = self.makeSingleFrameMeasurementTask(algName, dependencies=dependencies, config=config, 

263 algMetadata=algMetadata) 

264 exposure, catalog = self.dataset.realize(0.0, sfmTask.schema) 

265 self.recordPsfShape(catalog) 

266 

267 # Expected debug messages in the logs when running `sfmTask`. 

268 errorMessage = [("Failed to solve for PSF matching kernel in GAaP for (100.000000, 670.000000): " 

269 "Problematic scaling factors = 100.0 " 

270 "Errors: RuntimeError('Unable to determine kernel sum; 0 candidates')"), 

271 ("MeasurementError in ext_gaap_GaapFlux.measure on record 1: " 

272 "Failed to solve for PSF matching kernel"), 

273 ("Failed to solve for PSF matching kernel in GAaP for (100.000000, 870.000000): " 

274 "Problematic scaling factors = 100.0 " 

275 "Errors: RuntimeError('Unable to determine kernel sum; 0 candidates')"), 

276 ("MeasurementError in ext_gaap_GaapFlux.measure on record 2: " 

277 "Failed to solve for PSF matching kernel"), 

278 ("Failed to solve for PSF matching kernel in GAaP for (-10.000000, -20.000000): " 

279 "Problematic scaling factors = 100.0 " 

280 "Errors: RuntimeError('Unable to determine kernel sum; 0 candidates')"), 

281 ("MeasurementError in ext_gaap_GaapFlux.measure on record 3: " 

282 "Failed to solve for PSF matching kernel")] 

283 

284 testCatalog = catalog.copy(deep=True) 

285 plugin_logger_name = sfmTask.log.getChild(algName).name 

286 self.assertEqual(plugin_logger_name, "lsst.measurement.ext_gaap_GaapFlux") 

287 with self.assertLogs(plugin_logger_name, "DEBUG") as cm: 

288 sfmTask.run(testCatalog, exposure) 

289 self.assertEqual([record.message for record in cm.records], errorMessage) 

290 

291 self._checkAllFlags( 

292 testCatalog, 

293 algName, 

294 scalingFactors, 

295 sigmas, 

296 gaapConfig, 

297 specificFlag="flag_gaussianization", 

298 ) 

299 

300 # Trigger a "not (psfSigma > 0) error": 

301 exposureJunkPsf = exposure.clone() 

302 testCatalog = catalog.copy(deep=True) 

303 junkPsf = afwDetection.GaussianPsf(1, 1, 0) 

304 exposureJunkPsf.setPsf(junkPsf) 

305 sfmTask.run(testCatalog, exposureJunkPsf) 

306 

307 self._checkAllFlags( 

308 testCatalog, 

309 algName, 

310 scalingFactors, 

311 sigmas, 

312 gaapConfig, 

313 specificFlag="flag_gaussianization", 

314 ) 

315 

316 # Trigger a NoPixelError. 

317 testCatalog = catalog.copy(deep=True) 

318 testCatalog[0].setFootprint(afwDetection.Footprint()) 

319 with self.assertLogs(plugin_logger_name, "DEBUG") as cm: 

320 sfmTask.run(testCatalog, exposure) 

321 

322 self.assertEqual( 

323 cm.records[0].message, 

324 "MeasurementError in ext_gaap_GaapFlux.measure on record 1: No good pixels in footprint", 

325 ) 

326 self.assertEqual(testCatalog[f"{algName}_flag_no_pixel"][0], True) 

327 self.assertEqual(testCatalog[f"{algName}_flag"][0], True) 

328 

329 self._checkAllFlags(testCatalog[0: 1], algName, scalingFactors, sigmas, gaapConfig, allFailFlag=True) 

330 

331 # Try and "fail" with no PSF. 

332 # Since fatal exceptions are not caught by the measurement framework, 

333 # use a context manager and catch it here. 

334 exposure.setPsf(None) 

335 with self.assertRaises(lsst.meas.base.FatalAlgorithmError): 

336 sfmTask.run(catalog, exposure) 

337 

338 def _checkAllFlags( 

339 self, 

340 catalog, 

341 algName, 

342 scalingFactors, 

343 sigmas, 

344 gaapConfig, 

345 specificFlag=None, 

346 allFailFlag=False 

347 ): 

348 for record in catalog: 

349 self.assertEqual(record[algName + "_flag"], allFailFlag) 

350 for scalingFactor in scalingFactors: 

351 if specificFlag is not None: 

352 flagName = gaapConfig._getGaapResultName(scalingFactor, specificFlag, algName) 

353 self.assertTrue(record[flagName]) 

354 for sigma in sigmas + ["Optimal"]: 

355 baseName = gaapConfig._getGaapResultName(scalingFactor, sigma, algName) 

356 self.assertTrue(record[baseName + "_flag"]) 

357 self.assertFalse(record[baseName + "_flag_bigPsf"]) 

358 baseName = gaapConfig._getGaapResultName(scalingFactor, "PsfFlux", algName) 

359 self.assertTrue(record[baseName + "_flag"]) 

360 

361 def testFlags(self, sigmas=[0.4, 0.5, 0.7], scalingFactors=[1.15, 1.25, 1.4, 100.]): 

362 """Test that GAaP flags are set properly. 

363 

364 Specifically, we test that 

365 

366 1. for invalid combinations of config parameters, only the 

367 appropriate flags are set and not that the entire measurement itself is 

368 flagged. 

369 2. for sources close to the edge, the edge flags are set. 

370 

371 Parameters 

372 ---------- 

373 sigmas : `list` [`float`], optional 

374 The list of sigmas (in arcseconds) to construct the 

375 `SingleFrameGaapFluxConfig`. 

376 scalingFactors : `list` [`float`], optional 

377 The list of scaling factors to construct the 

378 `SingleFrameGaapFluxConfig`. 

379 

380 Raises 

381 ----- 

382 InvalidParameterError 

383 Raised if none of the config parameters will fail a measurement. 

384 

385 Notes 

386 ----- 

387 Since the seeing in the test dataset is 2 pixels, at least one of the 

388 ``sigmas`` should be smaller than at least twice of one of the 

389 ``scalingFactors`` to avoid the InvalidParameterError exception being 

390 raised. 

391 """ 

392 gaapConfig = lsst.meas.extensions.gaap.SingleFrameGaapFluxConfig(sigmas=sigmas, 

393 scalingFactors=scalingFactors) 

394 gaapConfig.scaleByFwhm = True 

395 gaapConfig.doOptimalPhotometry = True 

396 

397 # Make an instance of GAaP algorithm from a config 

398 algName = "ext_gaap_GaapFlux" 

399 algorithm, schema = self.makeAlgorithm(gaapConfig) 

400 # Make a noiseless exposure and measurements for reference 

401 exposure, catalog = self.dataset.realize(0.0, schema) 

402 # Record the PSF shapes if optimal photometry is performed. 

403 if gaapConfig.doOptimalPhotometry: 

404 self.recordPsfShape(catalog) 

405 

406 record = catalog[0] 

407 algorithm.measure(record, exposure) 

408 seeing = exposure.getPsf().getSigma() 

409 pixelScale = exposure.getWcs().getPixelScale().asArcseconds() 

410 # Measurement must fail (i.e., flag_bigPsf and flag must be set) if 

411 # sigma < scalingFactor * seeing 

412 # Ensure that there is at least one combination of parameters that fail 

413 if not (min(gaapConfig.sigmas)/pixelScale < seeing*max(gaapConfig.scalingFactors)): 

414 raise InvalidParameterError("The config parameters do not trigger a measurement failure. " 

415 "Consider including lower values in ``sigmas`` and/or larger values " 

416 "for ``scalingFactors``") 

417 # Ensure that the measurement is not a complete failure 

418 self.assertFalse(record[algName + "_flag"]) 

419 self.assertFalse(record[algName + "_flag_edge"]) 

420 # Ensure that flag_bigPsf is set if sigma < scalingFactor * seeing 

421 for scalingFactor, sigma in itertools.product(gaapConfig.scalingFactors, gaapConfig.sigmas): 

422 targetSigma = scalingFactor*seeing 

423 baseName = gaapConfig._getGaapResultName(scalingFactor, sigma, algName) 

424 # Give some leeway for the edge case and compare against a small 

425 # negative number instead of zero. 

426 if targetSigma*pixelScale - sigma >= -2e-7: 

427 self.assertTrue(record[baseName+"_flag_bigPsf"], 

428 msg=f"bigPsf flag not set for {scalingFactor=} and {sigma=}", 

429 ) 

430 self.assertTrue(record[baseName+"_flag"], 

431 msg=f"Flag not set for {scalingFactor=} and {sigma=}", 

432 ) 

433 else: 

434 self.assertFalse(record[baseName+"_flag_bigPsf"], 

435 msg=f"bigPsf flag set for {scalingFactor=} and {sigma=}", 

436 ) 

437 self.assertFalse(record[baseName+"_flag"], 

438 msg=f"Flag set for {scalingFactor=} and {sigma=}", 

439 ) 

440 

441 # Ensure that flag_bigPsf is set if OptimalShape is not large enough. 

442 if gaapConfig.doOptimalPhotometry: 

443 aperShape = afwTable.QuadrupoleKey(schema[schema.join(algName, "OptimalShape")]).get(record) 

444 for scalingFactor in gaapConfig.scalingFactors: 

445 targetSigma = scalingFactor*seeing 

446 baseName = gaapConfig._getGaapResultName(scalingFactor, "Optimal", algName) 

447 try: 

448 afwGeom.Quadrupole(aperShape.getParameterVector()-[targetSigma**2, targetSigma**2, 0.0], 

449 normalize=True) 

450 self.assertFalse(record[baseName + "_flag_bigPsf"]) 

451 except InvalidParameterError: 

452 self.assertTrue(record[baseName + "_flag_bigPsf"]) 

453 

454 # Set an empty footprint and check that no_pixels flag is set. 

455 record = catalog[1] 

456 record.setFootprint(afwDetection.Footprint()) 

457 with self.assertRaises(lsst.meas.extensions.gaap._gaap.NoPixelError): 

458 algorithm.measure(record, exposure) 

459 self.assertTrue(record[algName + "_flag"]) 

460 self.assertTrue(record[algName + "_flag_no_pixel"]) 

461 

462 # Ensure that the edge flag is set for the source at the corner. 

463 record = catalog[2] 

464 algorithm.measure(record, exposure) 

465 self.assertTrue(record[algName + "_flag_edge"]) 

466 self.assertFalse(record[algName + "_flag"]) 

467 

468 def recordPsfShape(self, catalog) -> None: 

469 """Record PSF shapes under the appropriate fields in ``catalog``. 

470 

471 This method must be called after the dataset is realized and a catalog 

472 is returned by the `realize` method. It assumes that the schema is 

473 non-minimal and has "psfShape_xx", "psfShape_yy" and "psfShape_xy" 

474 fields setup 

475 

476 Parameters 

477 ---------- 

478 catalog : `~lsst.afw.table.SourceCatalog` 

479 A source catalog containing records of the simulated sources. 

480 """ 

481 psfShapeKey = afwTable.QuadrupoleKey(catalog.schema["slot_PsfShape"]) 

482 for record in catalog: 

483 record.set(psfShapeKey, self.dataset.psfShape) 

484 

485 @staticmethod 

486 def invertQuadrupole(shape: afwGeom.Quadrupole) -> afwGeom.Quadrupole: 

487 """Compute the Quadrupole object corresponding to the inverse matrix. 

488 

489 If M = [[Q.getIxx(), Q.getIxy()], 

490 [Q.getIxy(), Q.getIyy()]] 

491 

492 for the input quadrupole Q, the returned quadrupole R corresponds to 

493 

494 M^{-1} = [[R.getIxx(), R.getIxy()], 

495 [R.getIxy(), R.getIyy()]]. 

496 """ 

497 invShape = afwGeom.Quadrupole(shape.getIyy(), shape.getIxx(), -shape.getIxy()) 

498 invShape.scale(1./shape.getDeterminantRadius()**2) 

499 return invShape 

500 

501 @lsst.utils.tests.methodParameters(gaussianizationMethod=("auto", "overlap-add", "direct", "fft")) 

502 def testGalaxyPhotometry(self, gaussianizationMethod): 

503 """Test GAaP fluxes for extended sources. 

504 

505 Create and run a SingleFrameMeasurementTask with GAaP plugin and reuse 

506 its outputs as reference for ForcedGaapFluxPlugin. In both cases, 

507 the measured flux is compared with the analytical expectation. 

508 

509 For a Gaussian source with intrinsic shape S and intrinsic aperture W, 

510 the GAaP flux is defined as (Eq. A16 of Kuijken et al. 2015) 

511 :math:`\\frac{F}{2\\pi\\det(S)}\\int\\mathrm{d}x\\exp(-x^T(S^{-1}+W^{-1})x/2)` 

512 :math:`F\\frac{\\det(S^{-1})}{\\det(S^{-1}+W^{-1})}` 

513 """ 

514 algName = "ext_gaap_GaapFlux" 

515 dependencies = ("base_SdssShape",) 

516 sfmConfig = self.makeSingleFrameMeasurementConfig(algName, dependencies=dependencies) 

517 forcedConfig = self.makeForcedMeasurementConfig(algName, dependencies=dependencies) 

518 # Turn on optimal photometry explicitly 

519 sfmConfig.plugins[algName].doOptimalPhotometry = True 

520 forcedConfig.plugins[algName].doOptimalPhotometry = True 

521 sfmConfig.plugins[algName].gaussianizationMethod = gaussianizationMethod 

522 forcedConfig.plugins[algName].gaussianizationMethod = gaussianizationMethod 

523 

524 algMetadata = lsst.daf.base.PropertyList() 

525 sfmTask = self.makeSingleFrameMeasurementTask(config=sfmConfig, algMetadata=algMetadata) 

526 forcedTask = self.makeForcedMeasurementTask(config=forcedConfig, algMetadata=algMetadata, 

527 refSchema=sfmTask.schema) 

528 

529 refExposure, refCatalog = self.dataset.realize(0.0, sfmTask.schema) 

530 self.recordPsfShape(refCatalog) 

531 sfmTask.run(refCatalog, refExposure) 

532 

533 # Check if the measured values match the expectations from 

534 # analytical Gaussian integrals 

535 recordId = 1 # Elliptical Gaussian galaxy 

536 refRecord = refCatalog[recordId] 

537 refWcs = self.dataset.exposure.getWcs() 

538 schema = refRecord.schema 

539 trueFlux = refRecord["truth_instFlux"] 

540 intrinsicShapeVector = afwTable.QuadrupoleKey(schema["truth"]).get(refRecord).getParameterVector() \ 

541 - afwTable.QuadrupoleKey(schema["slot_PsfShape"]).get(refRecord).getParameterVector() 

542 intrinsicShape = afwGeom.Quadrupole(intrinsicShapeVector) 

543 invIntrinsicShape = self.invertQuadrupole(intrinsicShape) 

544 # Assert that the measured fluxes agree with analytical expectations. 

545 for sigma in sfmTask.config.plugins[algName]._sigmas: 

546 if sigma == "Optimal": 

547 aperShape = afwTable.QuadrupoleKey(schema[f"{algName}_OptimalShape"]).get(refRecord) 

548 else: 

549 aperShape = afwGeom.Quadrupole(sigma**2, sigma**2, 0.0) 

550 aperShape.transformInPlace(refWcs.linearizeSkyToPixel(refRecord.getCentroid(), 

551 geom.arcseconds).getLinear()) 

552 

553 invAperShape = self.invertQuadrupole(aperShape) 

554 analyticalFlux = trueFlux*(invIntrinsicShape.getDeterminantRadius() 

555 / invIntrinsicShape.convolve(invAperShape).getDeterminantRadius())**2 

556 for scalingFactor in sfmTask.config.plugins[algName].scalingFactors: 

557 baseName = sfmTask.plugins[algName].ConfigClass._getGaapResultName(scalingFactor, 

558 sigma, algName) 

559 instFlux = refRecord.get(f"{baseName}_instFlux") 

560 self.assertFloatsAlmostEqual(instFlux, analyticalFlux, rtol=5e-3) 

561 

562 measWcs = self.dataset.makePerturbedWcs(refWcs, randomSeed=15) 

563 measDataset = self.dataset.transform(measWcs) 

564 measExposure, truthCatalog = measDataset.realize(0.0, schema) 

565 measCatalog = forcedTask.generateMeasCat(measExposure, refCatalog, refWcs) 

566 forcedTask.attachTransformedFootprints(measCatalog, refCatalog, measExposure, refWcs) 

567 forcedTask.run(measCatalog, measExposure, refCatalog, refWcs) 

568 

569 fullTransform = afwGeom.makeWcsPairTransform(refWcs, measWcs) 

570 localTransform = afwGeom.linearizeTransform(fullTransform, refRecord.getCentroid()).getLinear() 

571 intrinsicShape.transformInPlace(localTransform) 

572 invIntrinsicShape = self.invertQuadrupole(intrinsicShape) 

573 measRecord = measCatalog[recordId] 

574 

575 # Since measCatalog and refCatalog differ only by WCS, the GAaP flux 

576 # measured through consistent apertures must agree with each other. 

577 for sigma in forcedTask.config.plugins[algName]._sigmas: 

578 if sigma == "Optimal": 

579 aperShape = afwTable.QuadrupoleKey(measRecord.schema[f"{algName}_" 

580 "OptimalShape"]).get(measRecord) 

581 else: 

582 aperShape = afwGeom.Quadrupole(sigma**2, sigma**2, 0.0) 

583 aperShape.transformInPlace(measWcs.linearizeSkyToPixel(measRecord.getCentroid(), 

584 geom.arcseconds).getLinear()) 

585 

586 invAperShape = self.invertQuadrupole(aperShape) 

587 analyticalFlux = trueFlux*(invIntrinsicShape.getDeterminantRadius() 

588 / invIntrinsicShape.convolve(invAperShape).getDeterminantRadius())**2 

589 for scalingFactor in forcedTask.config.plugins[algName].scalingFactors: 

590 baseName = forcedTask.plugins[algName].ConfigClass._getGaapResultName(scalingFactor, 

591 sigma, algName) 

592 instFlux = measRecord.get(f"{baseName}_instFlux") 

593 # The measurement in the measRecord must be consistent with 

594 # the same in the refRecord in addition to analyticalFlux. 

595 self.assertFloatsAlmostEqual(instFlux, refRecord.get(f"{baseName}_instFlux"), rtol=5e-3) 

596 self.assertFloatsAlmostEqual(instFlux, analyticalFlux, rtol=5e-3) 

597 

598 def getFluxErrScaling(self, kernel, aperShape): 

599 """Returns the value by which the standard error has to be scaled due 

600 to noise correlations. 

601 

602 This is an alternative implementation to the `_getFluxErrScaling` 

603 method of `BaseGaapFluxPlugin`, but is less efficient. 

604 

605 Parameters 

606 ---------- 

607 `kernel` : `~lsst.afw.math.Kernel` 

608 The PSF-Gaussianization kernel. 

609 

610 Returns 

611 ------- 

612 fluxErrScaling : `float` 

613 The factor by which the standard error on GAaP flux must be scaled. 

614 """ 

615 kim = afwImage.ImageD(kernel.getDimensions()) 

616 kernel.computeImage(kim, False) 

617 weight = galsim.Image(np.zeros_like(kim.array)) 

618 aperSigma = aperShape.getDeterminantRadius() 

619 trace = aperShape.getIxx() + aperShape.getIyy() 

620 distortion = galsim.Shear(e1=(aperShape.getIxx()-aperShape.getIyy())/trace, 

621 e2=2*aperShape.getIxy()/trace) 

622 gauss = galsim.Gaussian(sigma=aperSigma, flux=2*np.pi*aperSigma**2).shear(distortion) 

623 weight = gauss.drawImage(image=weight, scale=1.0, method='no_pixel') 

624 kwarr = scipy.signal.convolve2d(weight.array, kim.array, boundary='fill') 

625 fluxErrScaling = np.sqrt(np.sum(kwarr*kwarr)) 

626 fluxErrScaling /= np.sqrt(np.pi*aperSigma**2) 

627 return fluxErrScaling 

628 

629 def testCorrelatedNoiseError(self, sigmas=[0.6, 0.8], scalingFactors=[1.15, 1.2, 1.25, 1.3, 1.4]): 

630 """Test the scaling to standard error due to correlated noise. 

631 

632 The uncertainty estimate on GAaP fluxes is scaled by an amount 

633 determined by the auto-correlation function of the PSF-matching kernel; 

634 see Eqs. A11 & A17 of Kuijken et al. (2015). This test ensures that the 

635 calculation of the scaling factors matches the analytical expression 

636 when the PSF-matching kernel is a Gaussian. 

637 

638 Parameters 

639 ---------- 

640 sigmas : `list` [`float`], optional 

641 A list of effective Gaussian aperture sizes. 

642 scalingFactors : `list` [`float`], optional 

643 A list of factors by which the PSF size must be scaled. 

644 

645 Notes 

646 ----- 

647 This unit test tests internal states of the plugin for accuracy and is 

648 specific to the implementation. It uses private variables as a result 

649 and intentionally breaks encapsulation. 

650 """ 

651 gaapConfig = lsst.meas.extensions.gaap.SingleFrameGaapFluxConfig(sigmas=sigmas, 

652 scalingFactors=scalingFactors) 

653 gaapConfig.scaleByFwhm = True 

654 

655 algorithm, schema = self.makeAlgorithm(gaapConfig) 

656 exposure, catalog = self.dataset.realize(0.0, schema) 

657 wcs = exposure.getWcs() 

658 record = catalog[0] 

659 center = self.center 

660 seeing = exposure.getPsf().computeShape(center).getDeterminantRadius() 

661 for scalingFactor in gaapConfig.scalingFactors: 

662 targetSigma = scalingFactor*seeing 

663 modelPsf = afwDetection.GaussianPsf(algorithm.config._modelPsfDimension, 

664 algorithm.config._modelPsfDimension, 

665 targetSigma) 

666 result = algorithm._gaussianize(exposure, modelPsf, record) 

667 kernel = result.psfMatchingKernel 

668 kernelAcf = algorithm._computeKernelAcf(kernel) 

669 for sigma in gaapConfig.sigmas: 

670 intrinsicShape = afwGeom.Quadrupole(sigma**2, sigma**2, 0.0) 

671 intrinsicShape.transformInPlace(wcs.linearizeSkyToPixel(center, geom.arcseconds).getLinear()) 

672 aperShape = afwGeom.Quadrupole(intrinsicShape.getParameterVector() 

673 - [targetSigma**2, targetSigma**2, 0.0]) 

674 fluxErrScaling1 = algorithm._getFluxErrScaling(kernelAcf, aperShape) 

675 fluxErrScaling2 = self.getFluxErrScaling(kernel, aperShape) 

676 

677 # The PSF matching kernel is a Gaussian of sigma^2 = (f^2-1)s^2 

678 # where f is the scalingFactor and s is the original seeing. 

679 # The integral of ACF of the kernel times the elliptical 

680 # Gaussian described by aperShape is given below. 

681 sigma /= wcs.getPixelScale().asArcseconds() 

682 analyticalValue = ((sigma**2 - (targetSigma)**2)/(sigma**2-seeing**2))**0.5 

683 self.assertFloatsAlmostEqual(fluxErrScaling1, analyticalValue, rtol=1e-4) 

684 self.assertFloatsAlmostEqual(fluxErrScaling1, fluxErrScaling2, rtol=1e-4) 

685 

686 # Try with an elliptical aperture. This is a proxy for 

687 # optimal aperture, since we do not actually measure anything. 

688 aperShape = afwGeom.Quadrupole(8, 6, 3) 

689 fluxErrScaling1 = algorithm._getFluxErrScaling(kernelAcf, aperShape) 

690 fluxErrScaling2 = self.getFluxErrScaling(kernel, aperShape) 

691 self.assertFloatsAlmostEqual(fluxErrScaling1, fluxErrScaling2, rtol=1e-4) 

692 

693 @lsst.utils.tests.methodParameters(noise=(0.001, 0.01, 0.1)) 

694 def testMonteCarlo(self, noise, recordId=1, sigmas=[0.7, 1.0, 1.25], 

695 scalingFactors=[1.1, 1.15, 1.2, 1.3, 1.4]): 

696 """Test GAaP flux uncertainties. 

697 

698 This test should demonstate that the estimated flux uncertainties agree 

699 with those from Monte Carlo simulations. 

700 

701 Parameters 

702 ---------- 

703 noise : `float` 

704 The RMS value of the Gaussian noise field divided by the total flux 

705 of the source. 

706 recordId : `int`, optional 

707 The source Id in the test dataset to measure. 

708 sigmas : `list` [`float`], optional 

709 The list of sigmas (in pixels) to construct the `GaapFluxConfig`. 

710 scalingFactors : `list` [`float`], optional 

711 The list of scaling factors to construct the `GaapFluxConfig`. 

712 """ 

713 gaapConfig = lsst.meas.extensions.gaap.SingleFrameGaapFluxConfig(sigmas=sigmas, 

714 scalingFactors=scalingFactors) 

715 gaapConfig.scaleByFwhm = True 

716 gaapConfig.doPsfPhotometry = True 

717 gaapConfig.doOptimalPhotometry = True 

718 

719 algorithm, schema = self.makeAlgorithm(gaapConfig) 

720 # Make a noiseless exposure and keep measurement record for reference 

721 exposure, catalog = self.dataset.realize(0.0, schema) 

722 if gaapConfig.doOptimalPhotometry: 

723 self.recordPsfShape(catalog) 

724 recordNoiseless = catalog[recordId] 

725 totalFlux = recordNoiseless["truth_instFlux"] 

726 algorithm.measure(recordNoiseless, exposure) 

727 

728 nSamples = 1024 

729 catalog = afwTable.SourceCatalog(schema) 

730 for repeat in range(nSamples): 

731 exposure, cat = self.dataset.realize(noise*totalFlux, schema, randomSeed=repeat) 

732 if gaapConfig.doOptimalPhotometry: 

733 self.recordPsfShape(cat) 

734 record = cat[recordId] 

735 algorithm.measure(record, exposure) 

736 catalog.append(record) 

737 

738 catalog = catalog.copy(deep=True) 

739 for baseName in gaapConfig.getAllGaapResultNames(): 

740 instFluxKey = schema.join(baseName, "instFlux") 

741 instFluxErrKey = schema.join(baseName, "instFluxErr") 

742 instFluxMean = catalog[instFluxKey].mean() 

743 instFluxErrMean = catalog[instFluxErrKey].mean() 

744 instFluxStdDev = catalog[instFluxKey].std() 

745 

746 # GAaP fluxes are not meant to be total fluxes. 

747 # We compare the mean of the noisy measurements to its 

748 # corresponding noiseless measurement instead of the true value 

749 instFlux = recordNoiseless[instFluxKey] 

750 self.assertFloatsAlmostEqual(instFluxErrMean, instFluxStdDev, rtol=0.02) 

751 self.assertLess(abs(instFluxMean - instFlux), 2.0*instFluxErrMean/nSamples**0.5) 

752 

753 

754class TestMemory(lsst.utils.tests.MemoryTestCase): 

755 pass 

756 

757 

758def setup_module(module, backend="virtualDevice"): 

759 lsst.utils.tests.init() 

760 try: 

761 afwDisplay.setDefaultBackend(backend) 

762 except Exception: 

763 print("Unable to configure display backend: %s" % backend) 

764 

765 

766if __name__ == "__main__": 766 ↛ 767line 766 didn't jump to line 767, because the condition on line 766 was never true

767 import sys 

768 

769 from argparse import ArgumentParser 

770 parser = ArgumentParser() 

771 parser.add_argument('--backend', type=str, default="virtualDevice", 

772 help="The backend to use, e.g. 'ds9'. Be sure to 'setup display_<backend>'") 

773 args = parser.parse_args() 

774 

775 setup_module(sys.modules[__name__], backend=args.backend) 

776 unittest.main()