Coverage for tests/test_gaap.py: 14%

362 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-04-04 03:49 -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 measConfig.plugins.names.add(algName) 

146 

147 if forced: 

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

149 

150 algConfig = measConfig.plugins[algName] 

151 algConfig.scalingFactors = scalingFactors 

152 algConfig.scaleByFwhm = True 

153 algConfig.doPsfPhotometry = True 

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

155 algConfig.doOptimalPhotometry = False 

156 

157 if forced: 

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

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

160 refSchema = afwTable.SourceTable.makeMinimalSchema() 

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

162 unit="pixel") 

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

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

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

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

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

168 refCat = afwTable.SourceCatalog(refSchema) 

169 refSource = refCat.addNew() 

170 refSource.set(centroidKey, center + offset) 

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

172 

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

174 taskInitArgs = (refSchema,) 

175 taskRunArgs = (refCat, refWcs) 

176 else: 

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

178 taskRunArgs = () 

179 

180 # Activate undeblended measurement with the same configuration 

181 measConfig.undeblended.names.add(algName) 

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

183 

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

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

186 measConfig.validate() 

187 measConfig.freeze() 

188 

189 algMetadata = dafBase.PropertyList() 

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

191 

192 schema = task.schema 

193 measCat = afwTable.SourceCatalog(schema) 

194 source = measCat.addNew() 

195 source.getTable().setMetadata(algMetadata) 

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

197 fp = ss.getFootprints()[0] 

198 source.setFootprint(fp) 

199 

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

201 

202 if display: 

203 disp = afwDisplay.Display(frame) 

204 disp.mtv(exposure) 

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

206 

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

208 

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

210 for baseName in algConfig.getAllGaapResultNames(algName): 

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

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

213 

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

215 for baseName in algConfig.getAllGaapResultNames(algName): 

216 if "_1_0x_" not in baseName: 

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

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

219 

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

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

222 

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

224 def testGaapPluginUnforced(self, psfSigma): 

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

226 """ 

227 self.runGaap(False, psfSigma) 

228 

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

230 def testGaapPluginForced(self, psfSigma): 

231 """Run GAaP as forced measurement plugin. 

232 """ 

233 self.runGaap(True, psfSigma) 

234 

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

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

237 

238 Set config parameters that are guaranteed to raise exceptions, 

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

240 expected log messages are generated. 

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

242 in the ``testFlags`` method. 

243 """ 

244 algName = "ext_gaap_GaapFlux" 

245 dependencies = ("base_SdssShape",) 

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

247 gaapConfig = config.plugins[algName] 

248 gaapConfig.scalingFactors = scalingFactors 

249 gaapConfig.sigmas = sigmas 

250 gaapConfig.doPsfPhotometry = True 

251 gaapConfig.doOptimalPhotometry = True 

252 

253 gaapConfig.scaleByFwhm = True 

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

255 

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

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

258 algMetadata=algMetadata) 

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

260 self.recordPsfShape(catalog) 

261 

262 # Expected error messages in the logs when running `sfmTask`. 

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

264 "Problematic scaling factors = 100.0 " 

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

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

267 "Problematic scaling factors = 100.0 " 

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

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

270 "Problematic scaling factors = 100.0 " 

271 "Errors: RuntimeError('Unable to determine kernel sum; 0 candidates')")] 

272 

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

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

275 with self.assertLogs(plugin_logger_name, "ERROR") as cm: 

276 sfmTask.run(catalog, exposure) 

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

278 

279 for record in catalog: 

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

281 for scalingFactor in scalingFactors: 

282 flagName = gaapConfig._getGaapResultName(scalingFactor, "flag_gaussianization", algName) 

283 self.assertTrue(record[flagName]) 

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

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

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

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

288 

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

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

291 

292 # Try and "fail" with no PSF. 

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

294 # use a context manager and catch it here. 

295 exposure.setPsf(None) 

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

297 sfmTask.run(catalog, exposure) 

298 

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

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

301 

302 Specifically, we test that 

303 

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

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

306 flagged. 

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

308 

309 Parameters 

310 ---------- 

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

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

313 `SingleFrameGaapFluxConfig`. 

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

315 The list of scaling factors to construct the 

316 `SingleFrameGaapFluxConfig`. 

317 

318 Raises 

319 ----- 

320 InvalidParameterError 

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

322 

323 Notes 

324 ----- 

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

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

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

328 raised. 

329 """ 

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

331 scalingFactors=scalingFactors) 

332 gaapConfig.scaleByFwhm = True 

333 gaapConfig.doOptimalPhotometry = True 

334 

335 # Make an instance of GAaP algorithm from a config 

336 algName = "ext_gaap_GaapFlux" 

337 algorithm, schema = self.makeAlgorithm(gaapConfig) 

338 # Make a noiseless exposure and measurements for reference 

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

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

341 if gaapConfig.doOptimalPhotometry: 

342 self.recordPsfShape(catalog) 

343 

344 record = catalog[0] 

345 algorithm.measure(record, exposure) 

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

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

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

349 # sigma < scalingFactor * seeing 

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

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

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

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

354 "for ``scalingFactors``") 

355 # Ensure that the measurement is not a complete failure 

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

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

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

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

360 targetSigma = scalingFactor*seeing 

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

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

363 # negative number instead of zero. 

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

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

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

367 ) 

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

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

370 ) 

371 else: 

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

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

374 ) 

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

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

377 ) 

378 

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

380 if gaapConfig.doOptimalPhotometry: 

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

382 for scalingFactor in gaapConfig.scalingFactors: 

383 targetSigma = scalingFactor*seeing 

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

385 try: 

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

387 normalize=True) 

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

389 except InvalidParameterError: 

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

391 

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

393 record = catalog[1] 

394 record.setFootprint(afwDetection.Footprint()) 

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

396 algorithm.measure(record, exposure) 

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

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

399 

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

401 record = catalog[2] 

402 algorithm.measure(record, exposure) 

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

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

405 

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

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

408 

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

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

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

412 fields setup 

413 

414 Parameters 

415 ---------- 

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

417 A source catalog containing records of the simulated sources. 

418 """ 

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

420 for record in catalog: 

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

422 

423 @staticmethod 

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

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

426 

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

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

429 

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

431 

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

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

434 """ 

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

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

437 return invShape 

438 

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

440 def testGalaxyPhotometry(self, gaussianizationMethod): 

441 """Test GAaP fluxes for extended sources. 

442 

443 Create and run a SingleFrameMeasurementTask with GAaP plugin and reuse 

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

445 the measured flux is compared with the analytical expectation. 

446 

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

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

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

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

451 """ 

452 algName = "ext_gaap_GaapFlux" 

453 dependencies = ("base_SdssShape",) 

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

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

456 # Turn on optimal photometry explicitly 

457 sfmConfig.plugins[algName].doOptimalPhotometry = True 

458 forcedConfig.plugins[algName].doOptimalPhotometry = True 

459 sfmConfig.plugins[algName].gaussianizationMethod = gaussianizationMethod 

460 forcedConfig.plugins[algName].gaussianizationMethod = gaussianizationMethod 

461 

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

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

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

465 refSchema=sfmTask.schema) 

466 

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

468 self.recordPsfShape(refCatalog) 

469 sfmTask.run(refCatalog, refExposure) 

470 

471 # Check if the measured values match the expectations from 

472 # analytical Gaussian integrals 

473 recordId = 1 # Elliptical Gaussian galaxy 

474 refRecord = refCatalog[recordId] 

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

476 schema = refRecord.schema 

477 trueFlux = refRecord["truth_instFlux"] 

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

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

480 intrinsicShape = afwGeom.Quadrupole(intrinsicShapeVector) 

481 invIntrinsicShape = self.invertQuadrupole(intrinsicShape) 

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

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

484 if sigma == "Optimal": 

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

486 else: 

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

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

489 geom.arcseconds).getLinear()) 

490 

491 invAperShape = self.invertQuadrupole(aperShape) 

492 analyticalFlux = trueFlux*(invIntrinsicShape.getDeterminantRadius() 

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

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

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

496 sigma, algName) 

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

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

499 

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

501 measDataset = self.dataset.transform(measWcs) 

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

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

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

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

506 

507 fullTransform = afwGeom.makeWcsPairTransform(refWcs, measWcs) 

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

509 intrinsicShape.transformInPlace(localTransform) 

510 invIntrinsicShape = self.invertQuadrupole(intrinsicShape) 

511 measRecord = measCatalog[recordId] 

512 

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

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

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

516 if sigma == "Optimal": 

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

518 "OptimalShape"]).get(measRecord) 

519 else: 

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

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

522 geom.arcseconds).getLinear()) 

523 

524 invAperShape = self.invertQuadrupole(aperShape) 

525 analyticalFlux = trueFlux*(invIntrinsicShape.getDeterminantRadius() 

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

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

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

529 sigma, algName) 

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

531 # The measurement in the measRecord must be consistent with 

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

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

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

535 

536 def getFluxErrScaling(self, kernel, aperShape): 

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

538 to noise correlations. 

539 

540 This is an alternative implementation to the `_getFluxErrScaling` 

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

542 

543 Parameters 

544 ---------- 

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

546 The PSF-Gaussianization kernel. 

547 

548 Returns 

549 ------- 

550 fluxErrScaling : `float` 

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

552 """ 

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

554 kernel.computeImage(kim, False) 

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

556 aperSigma = aperShape.getDeterminantRadius() 

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

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

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

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

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

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

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

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

565 return fluxErrScaling 

566 

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

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

569 

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

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

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

573 calculation of the scaling factors matches the analytical expression 

574 when the PSF-matching kernel is a Gaussian. 

575 

576 Parameters 

577 ---------- 

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

579 A list of effective Gaussian aperture sizes. 

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

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

582 

583 Notes 

584 ----- 

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

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

587 and intentionally breaks encapsulation. 

588 """ 

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

590 scalingFactors=scalingFactors) 

591 gaapConfig.scaleByFwhm = True 

592 

593 algorithm, schema = self.makeAlgorithm(gaapConfig) 

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

595 wcs = exposure.getWcs() 

596 record = catalog[0] 

597 center = self.center 

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

599 for scalingFactor in gaapConfig.scalingFactors: 

600 targetSigma = scalingFactor*seeing 

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

602 algorithm.config._modelPsfDimension, 

603 targetSigma) 

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

605 kernel = result.psfMatchingKernel 

606 kernelAcf = algorithm._computeKernelAcf(kernel) 

607 for sigma in gaapConfig.sigmas: 

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

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

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

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

612 fluxErrScaling1 = algorithm._getFluxErrScaling(kernelAcf, aperShape) 

613 fluxErrScaling2 = self.getFluxErrScaling(kernel, aperShape) 

614 

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

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

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

618 # Gaussian described by aperShape is given below. 

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

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

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

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

623 

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

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

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

627 fluxErrScaling1 = algorithm._getFluxErrScaling(kernelAcf, aperShape) 

628 fluxErrScaling2 = self.getFluxErrScaling(kernel, aperShape) 

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

630 

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

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

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

634 """Test GAaP flux uncertainties. 

635 

636 This test should demonstate that the estimated flux uncertainties agree 

637 with those from Monte Carlo simulations. 

638 

639 Parameters 

640 ---------- 

641 noise : `float` 

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

643 of the source. 

644 recordId : `int`, optional 

645 The source Id in the test dataset to measure. 

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

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

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

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

650 """ 

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

652 scalingFactors=scalingFactors) 

653 gaapConfig.scaleByFwhm = True 

654 gaapConfig.doPsfPhotometry = True 

655 gaapConfig.doOptimalPhotometry = True 

656 

657 algorithm, schema = self.makeAlgorithm(gaapConfig) 

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

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

660 if gaapConfig.doOptimalPhotometry: 

661 self.recordPsfShape(catalog) 

662 recordNoiseless = catalog[recordId] 

663 totalFlux = recordNoiseless["truth_instFlux"] 

664 algorithm.measure(recordNoiseless, exposure) 

665 

666 nSamples = 1024 

667 catalog = afwTable.SourceCatalog(schema) 

668 for repeat in range(nSamples): 

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

670 if gaapConfig.doOptimalPhotometry: 

671 self.recordPsfShape(cat) 

672 record = cat[recordId] 

673 algorithm.measure(record, exposure) 

674 catalog.append(record) 

675 

676 catalog = catalog.copy(deep=True) 

677 for baseName in gaapConfig.getAllGaapResultNames(): 

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

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

680 instFluxMean = catalog[instFluxKey].mean() 

681 instFluxErrMean = catalog[instFluxErrKey].mean() 

682 instFluxStdDev = catalog[instFluxKey].std() 

683 

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

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

686 # corresponding noiseless measurement instead of the true value 

687 instFlux = recordNoiseless[instFluxKey] 

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

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

690 

691 

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

693 pass 

694 

695 

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

697 lsst.utils.tests.init() 

698 try: 

699 afwDisplay.setDefaultBackend(backend) 

700 except Exception: 

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

702 

703 

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

705 import sys 

706 

707 from argparse import ArgumentParser 

708 parser = ArgumentParser() 

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

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

711 args = parser.parse_args() 

712 

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

714 unittest.main()