Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

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. 

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

241 in the ``testFlags`` method. 

242 """ 

243 algName = "ext_gaap_GaapFlux" 

244 dependencies = ("base_SdssShape",) 

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

246 gaapConfig = config.plugins[algName] 

247 gaapConfig.scalingFactors = scalingFactors 

248 gaapConfig.sigmas = sigmas 

249 gaapConfig.doPsfPhotometry = True 

250 gaapConfig.doOptimalPhotometry = True 

251 

252 gaapConfig.scaleByFwhm = True 

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

254 

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

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

257 algMetadata=algMetadata) 

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

259 self.recordPsfShape(catalog) 

260 sfmTask.run(catalog, exposure) 

261 

262 for record in catalog: 

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

264 for scalingFactor in scalingFactors: 

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

266 self.assertTrue(record[flagName]) 

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

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

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

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

271 

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

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

274 

275 # Try and "fail" with no PSF. 

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

277 # use a context manager and catch it here. 

278 exposure.setPsf(None) 

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

280 sfmTask.run(catalog, exposure) 

281 

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

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

284 

285 Specifically, we test that 

286 

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

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

289 flagged. 

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

291 

292 Parameters 

293 ---------- 

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

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

296 `SingleFrameGaapFluxConfig`. 

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

298 The list of scaling factors to construct the 

299 `SingleFrameGaapFluxConfig`. 

300 

301 Raises 

302 ----- 

303 InvalidParameterError 

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

305 

306 Notes 

307 ----- 

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

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

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

311 raised. 

312 """ 

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

314 scalingFactors=scalingFactors) 

315 gaapConfig.scaleByFwhm = True 

316 gaapConfig.doOptimalPhotometry = True 

317 

318 # Make an instance of GAaP algorithm from a config 

319 algName = "ext_gaap_GaapFlux" 

320 algorithm, schema = self.makeAlgorithm(gaapConfig) 

321 # Make a noiseless exposure and measurements for reference 

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

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

324 if gaapConfig.doOptimalPhotometry: 

325 self.recordPsfShape(catalog) 

326 

327 record = catalog[0] 

328 algorithm.measure(record, exposure) 

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

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

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

332 # sigma < scalingFactor * seeing 

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

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

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

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

337 "for ``scalingFactors``") 

338 # Ensure that the measurement is not a complete failure 

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

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

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

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

343 targetSigma = scalingFactor*seeing 

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

345 # Give some leeway for the edge case. 

346 if targetSigma - sigma/pixelScale >= -1e-10: 

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

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

349 else: 

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

351 self.assertFalse(record[baseName+"_flag"]) 

352 

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

354 if gaapConfig.doOptimalPhotometry: 

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

356 for scalingFactor in gaapConfig.scalingFactors: 

357 targetSigma = scalingFactor*seeing 

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

359 try: 

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

361 normalize=True) 

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

363 except InvalidParameterError: 

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

365 

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

367 record = catalog[2] 

368 algorithm.measure(record, exposure) 

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

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

371 

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

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

374 

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

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

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

378 fields setup 

379 

380 Parameters 

381 ---------- 

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

383 A source catalog containing records of the simulated sources. 

384 """ 

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

386 for record in catalog: 

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

388 

389 @staticmethod 

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

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

392 

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

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

395 

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

397 

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

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

400 """ 

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

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

403 return invShape 

404 

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

406 def testGalaxyPhotometry(self, gaussianizationMethod): 

407 """Test GAaP fluxes for extended sources. 

408 

409 Create and run a SingleFrameMeasurementTask with GAaP plugin and reuse 

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

411 the measured flux is compared with the analytical expectation. 

412 

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

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

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

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

417 """ 

418 algName = "ext_gaap_GaapFlux" 

419 dependencies = ("base_SdssShape",) 

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

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

422 # Turn on optimal photometry explicitly 

423 sfmConfig.plugins[algName].doOptimalPhotometry = True 

424 forcedConfig.plugins[algName].doOptimalPhotometry = True 

425 sfmConfig.plugins[algName].gaussianizationMethod = gaussianizationMethod 

426 forcedConfig.plugins[algName].gaussianizationMethod = gaussianizationMethod 

427 

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

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

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

431 refSchema=sfmTask.schema) 

432 

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

434 self.recordPsfShape(refCatalog) 

435 sfmTask.run(refCatalog, refExposure) 

436 

437 # Check if the measured values match the expectations from 

438 # analytical Gaussian integrals 

439 recordId = 1 # Elliptical Gaussian galaxy 

440 refRecord = refCatalog[recordId] 

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

442 schema = refRecord.schema 

443 trueFlux = refRecord["truth_instFlux"] 

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

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

446 intrinsicShape = afwGeom.Quadrupole(intrinsicShapeVector) 

447 invIntrinsicShape = self.invertQuadrupole(intrinsicShape) 

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

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

450 if sigma == "Optimal": 

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

452 else: 

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

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

455 geom.arcseconds).getLinear()) 

456 

457 invAperShape = self.invertQuadrupole(aperShape) 

458 analyticalFlux = trueFlux*(invIntrinsicShape.getDeterminantRadius() 

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

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

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

462 sigma, algName) 

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

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

465 

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

467 measDataset = self.dataset.transform(measWcs) 

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

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

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

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

472 

473 fullTransform = afwGeom.makeWcsPairTransform(refWcs, measWcs) 

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

475 intrinsicShape.transformInPlace(localTransform) 

476 invIntrinsicShape = self.invertQuadrupole(intrinsicShape) 

477 measRecord = measCatalog[recordId] 

478 

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

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

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

482 if sigma == "Optimal": 

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

484 "OptimalShape"]).get(measRecord) 

485 else: 

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

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

488 geom.arcseconds).getLinear()) 

489 

490 invAperShape = self.invertQuadrupole(aperShape) 

491 analyticalFlux = trueFlux*(invIntrinsicShape.getDeterminantRadius() 

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

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

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

495 sigma, algName) 

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

497 # The measurement in the measRecord must be consistent with 

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

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

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

501 

502 def getFluxErrScaling(self, kernel, aperShape): 

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

504 to noise correlations. 

505 

506 This is an alternative implementation to the `_getFluxErrScaling` 

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

508 

509 Parameters 

510 ---------- 

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

512 The PSF-Gaussianization kernel. 

513 

514 Returns 

515 ------- 

516 fluxErrScaling : `float` 

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

518 """ 

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

520 kernel.computeImage(kim, False) 

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

522 aperSigma = aperShape.getDeterminantRadius() 

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

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

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

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

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

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

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

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

531 return fluxErrScaling 

532 

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

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

535 

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

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

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

539 calculation of the scaling factors matches the analytical expression 

540 when the PSF-matching kernel is a Gaussian. 

541 

542 Parameters 

543 ---------- 

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

545 A list of effective Gaussian aperture sizes. 

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

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

548 

549 Notes 

550 ----- 

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

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

553 and intentionally breaks encapsulation. 

554 """ 

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

556 scalingFactors=scalingFactors) 

557 gaapConfig.scaleByFwhm = True 

558 

559 algorithm, schema = self.makeAlgorithm(gaapConfig) 

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

561 wcs = exposure.getWcs() 

562 record = catalog[0] 

563 center = self.center 

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

565 for scalingFactor in gaapConfig.scalingFactors: 

566 targetSigma = scalingFactor*seeing 

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

568 algorithm.config._modelPsfDimension, 

569 targetSigma) 

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

571 kernel = result.psfMatchingKernel 

572 kernelAcf = algorithm._computeKernelAcf(kernel) 

573 for sigma in gaapConfig.sigmas: 

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

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

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

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

578 fluxErrScaling1 = algorithm._getFluxErrScaling(kernelAcf, aperShape) 

579 fluxErrScaling2 = self.getFluxErrScaling(kernel, aperShape) 

580 

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

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

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

584 # Gaussian described by aperShape is given below. 

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

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

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

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

589 

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

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

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

593 fluxErrScaling1 = algorithm._getFluxErrScaling(kernelAcf, aperShape) 

594 fluxErrScaling2 = self.getFluxErrScaling(kernel, aperShape) 

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

596 

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

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

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

600 """Test GAaP flux uncertainties. 

601 

602 This test should demonstate that the estimated flux uncertainties agree 

603 with those from Monte Carlo simulations. 

604 

605 Parameters 

606 ---------- 

607 noise : `float` 

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

609 of the source. 

610 recordId : `int`, optional 

611 The source Id in the test dataset to measure. 

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

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

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

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

616 """ 

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

618 scalingFactors=scalingFactors) 

619 gaapConfig.scaleByFwhm = True 

620 gaapConfig.doPsfPhotometry = True 

621 gaapConfig.doOptimalPhotometry = True 

622 

623 algorithm, schema = self.makeAlgorithm(gaapConfig) 

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

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

626 if gaapConfig.doOptimalPhotometry: 

627 self.recordPsfShape(catalog) 

628 recordNoiseless = catalog[recordId] 

629 totalFlux = recordNoiseless["truth_instFlux"] 

630 algorithm.measure(recordNoiseless, exposure) 

631 

632 nSamples = 1024 

633 catalog = afwTable.SourceCatalog(schema) 

634 for repeat in range(nSamples): 

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

636 if gaapConfig.doOptimalPhotometry: 

637 self.recordPsfShape(cat) 

638 record = cat[recordId] 

639 algorithm.measure(record, exposure) 

640 catalog.append(record) 

641 

642 catalog = catalog.copy(deep=True) 

643 for baseName in gaapConfig.getAllGaapResultNames(): 

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

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

646 instFluxMean = catalog[instFluxKey].mean() 

647 instFluxErrMean = catalog[instFluxErrKey].mean() 

648 instFluxStdDev = catalog[instFluxKey].std() 

649 

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

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

652 # corresponding noiseless measurement instead of the true value 

653 instFlux = recordNoiseless[instFluxKey] 

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

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

656 

657 

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

659 pass 

660 

661 

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

663 lsst.utils.tests.init() 

664 try: 

665 afwDisplay.setDefaultBackend(backend) 

666 except Exception: 

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

668 

669 

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

671 import sys 

672 

673 from argparse import ArgumentParser 

674 parser = ArgumentParser() 

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

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

677 args = parser.parse_args() 

678 

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

680 unittest.main()