Coverage for tests / test_detectAndMeasure.py: 8%

829 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-21 10:37 +0000

1# This file is part of ip_diffim. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <https://www.gnu.org/licenses/>. 

21 

22import numpy as np 

23import os 

24import unittest 

25from unittest import mock 

26import requests 

27 

28import lsst.afw.geom as afwGeom 

29import lsst.afw.image as afwImage 

30import lsst.afw.math as afwMath 

31import lsst.geom 

32from lsst.ip.diffim import detectAndMeasure, subtractImages 

33from lsst.afw.table import IdFactory 

34from lsst.afw.cameraGeom.testUtils import DetectorWrapper 

35import lsst.meas.algorithms as measAlg 

36from lsst.pipe.base import InvalidQuantumError, UpstreamFailureNoWorkFound, AlgorithmError 

37import lsst.utils.tests 

38import lsst.meas.base.tests 

39import lsst.daf.base as dafBase 

40from lsst.afw.coord import Observatory, Weather 

41import lsst.geom as geom 

42import lsst.pex.config as pexConfig 

43 

44from utils import makeTestImage, checkMask 

45 

46 

47class DetectAndMeasureTestBase: 

48 

49 def _check_diaSource(self, refSources, diaSource, refIds=None, 

50 matchDistance=1., scale=1., usePsfFlux=True, 

51 rtol=0.025, atol=None): 

52 """Match a diaSource with a source in a reference catalog 

53 and compare properties. 

54 

55 Parameters 

56 ---------- 

57 refSources : `lsst.afw.table.SourceCatalog` 

58 The reference catalog. 

59 diaSource : `lsst.afw.table.SourceRecord` 

60 The new diaSource to match to the reference catalog. 

61 refIds : `list` of `int`, optional 

62 Source IDs of previously associated diaSources. 

63 matchDistance : `float`, optional 

64 Maximum distance allowed between the detected and reference source 

65 locations, in pixels. 

66 scale : `float`, optional 

67 Optional factor to scale the flux by before performing the test. 

68 usePsfFlux : `bool`, optional 

69 If set, test the PsfInstFlux field, otherwise use ApInstFlux. 

70 rtol : `float`, optional 

71 Relative tolerance of the flux value test. 

72 atol : `float`, optional 

73 Absolute tolerance of the flux value test. 

74 """ 

75 if diaSource["sky_source"]: 

76 return 

77 distance = np.sqrt((diaSource.getX() - refSources.getX())**2 

78 + (diaSource.getY() - refSources.getY())**2) 

79 self.assertLess(min(distance), matchDistance) 

80 src = refSources[np.argmin(distance)] 

81 if refIds is not None: 

82 # Check that the same source was not previously associated 

83 self.assertNotIn(src.getId(), refIds) 

84 refIds.append(src.getId()) 

85 if atol is None: 

86 atol = rtol*src.getPsfInstFlux() if usePsfFlux else rtol*src.getApInstFlux() 

87 if usePsfFlux: 

88 self.assertFloatsAlmostEqual(src.getPsfInstFlux()*scale, diaSource.getPsfInstFlux(), 

89 rtol=rtol, atol=atol) 

90 else: 

91 self.assertFloatsAlmostEqual(src.getApInstFlux()*scale, diaSource.getApInstFlux(), 

92 rtol=rtol, atol=atol) 

93 

94 def _check_values(self, values, minValue=None, maxValue=None): 

95 """Verify that an array has finite values, and optionally that they are 

96 within specified minimum and maximum bounds. 

97 

98 Parameters 

99 ---------- 

100 values : `numpy.ndarray` 

101 Array of values to check. 

102 minValue : `float`, optional 

103 Minimum allowable value. 

104 maxValue : `float`, optional 

105 Maximum allowable value. 

106 """ 

107 self.assertTrue(np.all(np.isfinite(values))) 

108 if minValue is not None: 

109 self.assertTrue(np.all(values >= minValue)) 

110 if maxValue is not None: 

111 self.assertTrue(np.all(values <= maxValue)) 

112 

113 def _setup_detection(self, doSkySources=True, nSkySources=5, 

114 doSubtractBackground=False, run_sattle=False, 

115 badSourceFlags=None, **kwargs): 

116 """Setup and configure the detection and measurement PipelineTask. 

117 

118 Parameters 

119 ---------- 

120 doSkySources : `bool`, optional 

121 Generate sky sources. 

122 nSkySources : `int`, optional 

123 The number of sky sources to add in isolated background regions. 

124 **kwargs 

125 Any additional config parameters to set. 

126 

127 Returns 

128 ------- 

129 `lsst.pipe.base.PipelineTask` 

130 The configured Task to use for detection and measurement. 

131 """ 

132 config = self.detectionTask.ConfigClass() 

133 config.doSkySources = doSkySources 

134 if doSkySources: 

135 config.skySources.nSources = nSkySources 

136 config.update(**kwargs) 

137 

138 config.run_sattle = run_sattle 

139 

140 # Make a realistic id generator so that output catalog ids are useful. 

141 dataId = lsst.daf.butler.DataCoordinate.standardize( 

142 instrument="I", 

143 visit=42, 

144 detector=12, 

145 universe=lsst.daf.butler.DimensionUniverse(), 

146 ) 

147 if badSourceFlags is None: 

148 badSourceFlags = ["base_PixelFlags_flag_offimage", ] 

149 config.idGenerator.packer.name = "observation" 

150 config.idGenerator.packer["observation"].n_observations = 10000 

151 config.idGenerator.packer["observation"].n_detectors = 99 

152 config.idGenerator.n_releases = 8 

153 config.idGenerator.release_id = 2 

154 config.doSubtractBackground = doSubtractBackground 

155 config.badSourceFlags = badSourceFlags 

156 self.idGenerator = config.idGenerator.apply(dataId) 

157 

158 return self.detectionTask(config=config) 

159 

160 

161class DetectAndMeasureTest(DetectAndMeasureTestBase, lsst.utils.tests.TestCase): 

162 detectionTask = detectAndMeasure.DetectAndMeasureTask 

163 

164 def test_detection_xy0(self): 

165 """Basic functionality test with non-zero x0 and y0. 

166 """ 

167 # Set up the simulated images 

168 noiseLevel = 1. 

169 staticSeed = 1 

170 fluxLevel = 500 

171 kwargs = {"seed": staticSeed, "psfSize": 2.4, "fluxLevel": fluxLevel, "x0": 12345, "y0": 67890} 

172 science, sources = makeTestImage(noiseLevel=noiseLevel, noiseSeed=6, **kwargs) 

173 science.getInfo().setVisitInfo(makeVisitInfo()) 

174 matchedTemplate, _ = makeTestImage(noiseLevel=noiseLevel/4, noiseSeed=7, **kwargs) 

175 difference = science.clone() 

176 

177 # Configure the detection Task 

178 # Set the subtraction residual metric threshold high, since the template 

179 # is not actually subtracted from the science image. 

180 detectionTask = self._setup_detection(doDeblend=True, badSubtractionRatioThreshold=1., 

181 doSkySources=False) 

182 

183 # Run detection and check the results 

184 output = detectionTask.run(science, matchedTemplate, difference, sources, 

185 idFactory=self.idGenerator.make_table_id_factory()) 

186 subtractedMeasuredExposure = output.subtractedMeasuredExposure 

187 

188 # Catalog ids should be very large from this id generator. 

189 self.assertTrue(all(output.diaSources['id'] > 1000000000)) 

190 self.assertImagesEqual(subtractedMeasuredExposure.image, difference.image) 

191 

192 # all of the sources should have been detected 

193 self.assertEqual(len(output.diaSources), len(sources)) 

194 # no sources should be flagged as negative 

195 self.assertEqual(len(~output.diaSources["is_negative"]), len(output.diaSources)) 

196 refIds = [] 

197 for source in sources: 

198 self._check_diaSource(output.diaSources, source, refIds=refIds) 

199 

200 def test_raise_bad_psf(self): 

201 """Detection should raise if the PSF width is NaN 

202 """ 

203 # Set up the simulated images 

204 noiseLevel = 1. 

205 staticSeed = 1 

206 fluxLevel = 500 

207 kwargs = {"seed": staticSeed, "psfSize": 2.4, "fluxLevel": fluxLevel, "x0": 12345, "y0": 67890} 

208 science, sources = makeTestImage(noiseLevel=noiseLevel, noiseSeed=6, **kwargs) 

209 matchedTemplate, _ = makeTestImage(noiseLevel=noiseLevel/4, noiseSeed=7, **kwargs) 

210 difference = science.clone() 

211 

212 psfShape = difference.getPsf().computeBBox(lsst.geom.Point2D(0, 0)).getDimensions() 

213 psfcI = afwImage.ImageD(lsst.geom.Extent2I(psfShape[1], psfShape[0])) 

214 psfNew = np.zeros(psfShape) 

215 psfNew[0, :] = 1 

216 psfcI.array = psfNew 

217 psfcK = afwMath.FixedKernel(psfcI) 

218 psf = measAlg.KernelPsf(psfcK) 

219 difference.setPsf(psf) 

220 

221 # Configure the detection Task 

222 detectionTask = self._setup_detection() 

223 

224 # Run detection and check the results 

225 with self.assertRaises(UpstreamFailureNoWorkFound): 

226 detectionTask.run(science, matchedTemplate, difference, sources) 

227 

228 def test_measurements_finite(self): 

229 """Measured fluxes and centroids should always be finite. 

230 """ 

231 columnNames = ["coord_ra", "coord_dec", "ip_diffim_forced_PsfFlux_instFlux", 

232 "ip_diffim_forced_template_PsfFlux_instFlux"] 

233 

234 # Set up the simulated images 

235 noiseLevel = 1. 

236 staticSeed = 1 

237 transientSeed = 6 

238 xSize = 256 

239 ySize = 256 

240 kwargs = {"psfSize": 2.4, "x0": 0, "y0": 0, 

241 "xSize": xSize, "ySize": ySize} 

242 science, sources = makeTestImage(seed=staticSeed, noiseLevel=noiseLevel, noiseSeed=6, 

243 nSrc=1, **kwargs) 

244 science.getInfo().setVisitInfo(makeVisitInfo()) 

245 matchedTemplate, _ = makeTestImage(seed=staticSeed, noiseLevel=noiseLevel/4, noiseSeed=7, 

246 nSrc=1, **kwargs) 

247 rng = np.random.RandomState(3) 

248 xLoc = np.arange(-5, xSize+5, 10) 

249 rng.shuffle(xLoc) 

250 yLoc = np.arange(-5, ySize+5, 10) 

251 rng.shuffle(yLoc) 

252 transients, transientSources = makeTestImage(seed=transientSeed, 

253 nSrc=len(xLoc), fluxLevel=1000., 

254 noiseLevel=noiseLevel, noiseSeed=8, 

255 xLoc=xLoc, yLoc=yLoc, 

256 **kwargs) 

257 difference = science.clone() 

258 difference.maskedImage -= matchedTemplate.maskedImage 

259 difference.maskedImage += transients.maskedImage 

260 

261 # Configure the detection Task 

262 detectionTask = self._setup_detection(doForcedMeasurement=True) 

263 

264 # Run detection and check the results 

265 output = detectionTask.run(science, matchedTemplate, difference, sources) 

266 

267 for column in columnNames: 

268 self._check_values(output.diaSources[column]) 

269 self._check_values(output.diaSources.getX(), minValue=0, maxValue=xSize) 

270 self._check_values(output.diaSources.getY(), minValue=0, maxValue=ySize) 

271 self._check_values(output.diaSources.getPsfInstFlux()) 

272 

273 def test_raise_config_schema_mismatch(self): 

274 """Check that sources with specified flags are removed from the catalog. 

275 """ 

276 # Configure the detection Task, and and set a config that is not in the schema 

277 with self.assertRaises(InvalidQuantumError): 

278 self._setup_detection(badSourceFlags=["Bogus_flag_42"]) 

279 

280 def test_remove_unphysical(self): 

281 """Check that sources with specified flags are removed from the catalog. 

282 """ 

283 # Set up the simulated images 

284 noiseLevel = 1. 

285 staticSeed = 1 

286 xSize = 256 

287 ySize = 256 

288 kwargs = {"psfSize": 2.4, "xSize": xSize, "ySize": ySize} 

289 science, sources = makeTestImage(seed=staticSeed, noiseLevel=noiseLevel, noiseSeed=6, 

290 nSrc=1, **kwargs) 

291 science.getInfo().setVisitInfo(makeVisitInfo()) 

292 matchedTemplate, _ = makeTestImage(seed=staticSeed, noiseLevel=noiseLevel/4, noiseSeed=7, 

293 nSrc=1, **kwargs) 

294 transients, transientSources = makeTestImage(noiseLevel=noiseLevel/4, noiseSeed=8, nSrc=1, **kwargs) 

295 science.maskedImage += transients.maskedImage 

296 difference = science.clone() 

297 bbox = difference.getBBox() 

298 difference.maskedImage -= matchedTemplate.maskedImage 

299 

300 # Configure the detection Task, and remove unphysical sources 

301 detectionTask = self._setup_detection(doForcedMeasurement=False, doSkySources=True, nSkySources=20, 

302 badSourceFlags=["base_PixelFlags_flag_offimage", ]) 

303 

304 # Run detection and check the results 

305 diaSources = detectionTask.run(science, matchedTemplate, difference, sources).diaSources 

306 badDiaSrcDoRemove = ~bbox.contains(diaSources.getX(), diaSources.getY()) 

307 nBadDoRemove = np.count_nonzero(badDiaSrcDoRemove) 

308 # Verify that all sources are physical 

309 self.assertEqual(nBadDoRemove, 0) 

310 # Set a few centroids outside the image bounding box 

311 nSetBad = 5 

312 for src in diaSources[0: nSetBad]: 

313 src["slot_Centroid_x"] += xSize 

314 src["slot_Centroid_y"] += ySize 

315 src["base_PixelFlags_flag_offimage"] = True 

316 # Verify that these sources are outside the image 

317 badDiaSrc = ~bbox.contains(diaSources.getX(), diaSources.getY()) 

318 nBad = np.count_nonzero(badDiaSrc) 

319 self.assertEqual(nBad, nSetBad) 

320 diaSourcesNoBad = detectionTask._removeBadSources(diaSources) 

321 badDiaSrcNoBad = ~bbox.contains(diaSourcesNoBad.getX(), diaSourcesNoBad.getY()) 

322 

323 # Verify that no sources outside the image bounding box remain 

324 self.assertEqual(np.count_nonzero(badDiaSrcNoBad), 0) 

325 self.assertEqual(len(diaSourcesNoBad), len(diaSources) - nSetBad) 

326 

327 def test_detect_transients(self): 

328 """Run detection on a difference image containing transients. 

329 """ 

330 # Set up the simulated images 

331 noiseLevel = 1. 

332 staticSeed = 1 

333 transientSeed = 6 

334 fluxLevel = 500 

335 kwargs = {"seed": staticSeed, "psfSize": 2.4, "fluxLevel": fluxLevel} 

336 scienceBase, sources = makeTestImage(noiseLevel=noiseLevel, noiseSeed=6, **kwargs) 

337 matchedTemplate, _ = makeTestImage(noiseLevel=noiseLevel/4, noiseSeed=7, **kwargs) 

338 

339 # Configure the detection Task 

340 detectionTask = self._setup_detection(doMerge=False, doSkySources=True) 

341 kwargs["seed"] = transientSeed 

342 kwargs["nSrc"] = 10 

343 kwargs["fluxLevel"] = 1000 

344 

345 # Run detection and check the results 

346 def _detection_wrapper(positive=True): 

347 transients, transientSources = makeTestImage(noiseLevel=noiseLevel, noiseSeed=8, **kwargs) 

348 science = scienceBase.clone() 

349 if positive: 

350 science.maskedImage += transients.maskedImage 

351 else: 

352 science.maskedImage -= transients.maskedImage 

353 difference = science.clone() 

354 difference.maskedImage -= matchedTemplate.maskedImage 

355 # NOTE: NoiseReplacer (run by forcedMeasurement) can modify the 

356 # science image if we've e.g. removed parents post-deblending. 

357 # Pass a clone of the science image, so that it doesn't disrupt 

358 # later tests. 

359 output = detectionTask.run(science, matchedTemplate, difference, sources) 

360 refIds = [] 

361 scale = 1. if positive else -1. 

362 for diaSource in output.diaSources: 

363 self._check_diaSource(transientSources, diaSource, refIds=refIds, scale=scale) 

364 _detection_wrapper(positive=True) 

365 _detection_wrapper(positive=False) 

366 

367 def test_detect_transients_with_background(self): 

368 """Run detection on a difference image containing transients and a background. 

369 """ 

370 # Set up the simulated images 

371 noiseLevel = 1. 

372 staticSeed = 1 

373 transientSeed = 6 

374 fluxLevel = 500 

375 xSize = 512 

376 ySize = 512 

377 x0 = 123 

378 y0 = 456 

379 kwargs = {"seed": staticSeed, "psfSize": 2.4, "fluxLevel": fluxLevel, 

380 "xSize": xSize, "ySize": ySize, "x0": x0, "y0": y0} 

381 params = [2.2, 2.1, 2.0, 1.2, 1.1, 1.0] 

382 

383 bbox2D = lsst.geom.Box2D(lsst.geom.Point2D(x0, y0), lsst.geom.Extent2D(xSize, ySize)) 

384 background_model = afwMath.Chebyshev1Function2D(params, bbox2D) 

385 scienceBase, sources = makeTestImage(noiseLevel=noiseLevel, noiseSeed=6, 

386 background=background_model, **kwargs) 

387 matchedTemplate, _ = makeTestImage(noiseLevel=noiseLevel/4, noiseSeed=7, **kwargs) 

388 

389 # Configure the detection Task 

390 detectionTask = self._setup_detection(doMerge=False, doSubtractBackground=True) 

391 kwargs["seed"] = transientSeed 

392 kwargs["nSrc"] = 10 

393 kwargs["fluxLevel"] = 1000 

394 

395 # Run detection and check the results 

396 def _detection_wrapper(positive=True): 

397 transients, transientSources = makeTestImage(noiseLevel=noiseLevel, noiseSeed=8, **kwargs) 

398 science = scienceBase.clone() 

399 if positive: 

400 science.maskedImage += transients.maskedImage 

401 else: 

402 science.maskedImage -= transients.maskedImage 

403 difference = science.clone() 

404 difference.maskedImage -= matchedTemplate.maskedImage 

405 # NOTE: NoiseReplacer (run by forcedMeasurement) can modify the 

406 # science image if we've e.g. removed parents post-deblending. 

407 # Pass a clone of the science image, so that it doesn't disrupt 

408 # later tests. 

409 output = detectionTask.run(science, matchedTemplate, difference, sources) 

410 refIds = [] 

411 scale = 1. if positive else -1. 

412 for transient in transientSources: 

413 self._check_diaSource(output.diaSources, transient, refIds=refIds, scale=scale) 

414 _detection_wrapper(positive=True) 

415 _detection_wrapper(positive=False) 

416 

417 def test_mask_cosmic_rays(self): 

418 """Run detection on a difference image containing a cosmic ray. 

419 """ 

420 # Set up the simulated images 

421 noiseLevel = 1. 

422 staticSeed = 1 

423 fluxLevel = 500 

424 xSize = 400 

425 ySize = 400 

426 kwargs = {"seed": staticSeed, "psfSize": 2.4, "fluxLevel": fluxLevel, "xSize": xSize, "ySize": ySize} 

427 science, sources = makeTestImage(noiseLevel=noiseLevel, noiseSeed=6, **kwargs) 

428 matchedTemplate, _ = makeTestImage(noiseLevel=noiseLevel/4, noiseSeed=7, **kwargs) 

429 crMask = science.mask.getPlaneBitMask("CR") 

430 

431 # Configure the detection Task 

432 detectionTask = self._setup_detection(doMerge=False, doSkySources=True) 

433 

434 # Test that no CRs are detected 

435 transients, transientSources = makeTestImage(noiseLevel=noiseLevel, noiseSeed=8, nSrc=10, **kwargs) 

436 science.maskedImage += transients.maskedImage 

437 difference = science.clone() 

438 difference.maskedImage -= matchedTemplate.maskedImage 

439 output = detectionTask.run(science, matchedTemplate, difference, sources) 

440 crMaskSet = (output.subtractedMeasuredExposure.mask.array & crMask) > 0 

441 self.assertTrue(np.all(crMaskSet == 0)) 

442 

443 crX0 = round(sources.getX()[0] - science.getX0()) 

444 crY0 = round(sources.getY()[0] - science.getY0()) 

445 crX1 = round(sources.getX()[1] - science.getX0()) 

446 crY1 = round(sources.getY()[1] - science.getY0()) 

447 # Add CR-like shape and check that CR is detected 

448 # Pick two locations on top of sources, since that is what is likely to 

449 # be missed in the first stage of CR rejection. 

450 difference.image.array[crX0:crX0+1, crY0:crY0+5] += 1234 # Arbitrary but large flux for the CRs 

451 difference.image.array[crX1:crX1+5, crY1:crY1+1] += 1234 

452 output = detectionTask.run(science, matchedTemplate, difference) 

453 crMaskSet = (output.subtractedMeasuredExposure.mask.array & crMask) > 0 

454 self.assertTrue(np.all(crMaskSet[crX0:crX0+1, crY0:crY0+5])) 

455 self.assertTrue(np.all(crMaskSet[crX1:crX1+5, crY1:crY1+1])) 

456 

457 def test_missing_mask_planes(self): 

458 """Check that detection runs with missing mask planes. 

459 """ 

460 # Set up the simulated images 

461 noiseLevel = 1. 

462 fluxLevel = 500 

463 kwargs = {"psfSize": 2.4, "fluxLevel": fluxLevel, "addMaskPlanes": []} 

464 # Use different seeds for the science and template so every source is a diaSource 

465 science, sources = makeTestImage(seed=5, noiseLevel=noiseLevel, noiseSeed=6, **kwargs) 

466 science.getInfo().setVisitInfo(makeVisitInfo()) 

467 matchedTemplate, _ = makeTestImage(seed=6, noiseLevel=noiseLevel/4, noiseSeed=7, **kwargs) 

468 

469 difference = science.clone() 

470 difference.maskedImage -= matchedTemplate.maskedImage 

471 detectionTask = self._setup_detection(raiseOnBadSubtractionRatio=False, raiseOnNoDiaSources=False) 

472 

473 # Verify that detection runs without errors 

474 detectionTask.run(science, matchedTemplate, difference, sources) 

475 

476 def test_detect_dipoles(self): 

477 """Run detection on a difference image containing dipoles. 

478 """ 

479 # Set up the simulated images 

480 noiseLevel = 1. 

481 staticSeed = 1 

482 fluxLevel = 1000 

483 fluxRange = 1.5 

484 nSources = 10 

485 offset = 1 

486 xSize = 300 

487 ySize = 300 

488 kernelSize = 32 

489 # Avoid placing sources near the edge for this test, so that we can 

490 # easily check that the correct number of sources are detected. 

491 templateBorderSize = kernelSize//2 

492 dipoleFlag = "ip_diffim_DipoleFit_classification" 

493 kwargs = {"seed": staticSeed, "psfSize": 2.4, "fluxLevel": fluxLevel, "fluxRange": fluxRange, 

494 "nSrc": nSources, "templateBorderSize": templateBorderSize, "kernelSize": kernelSize, 

495 "xSize": xSize, "ySize": ySize} 

496 dipoleFlag = "ip_diffim_DipoleFit_classification" 

497 science, sources = makeTestImage(noiseLevel=noiseLevel, noiseSeed=6, **kwargs) 

498 science.getInfo().setVisitInfo(makeVisitInfo()) 

499 matchedTemplate, _ = makeTestImage(noiseLevel=noiseLevel/4, noiseSeed=7, **kwargs) 

500 difference = science.clone() 

501 matchedTemplate.image.array[...] = np.roll(matchedTemplate.image.array[...], offset, axis=0) 

502 matchedTemplate.variance.array[...] = np.roll(matchedTemplate.variance.array[...], offset, axis=0) 

503 matchedTemplate.mask.array[...] = np.roll(matchedTemplate.mask.array[...], offset, axis=0) 

504 difference.maskedImage -= matchedTemplate.maskedImage[science.getBBox()] 

505 

506 # Need a higher residual metric threshold, since we are purposely 

507 # creating poor subtractions. 

508 detectionTask = self._setup_detection(doMerge=True, badSubtractionRatioThreshold=0.3) 

509 output = detectionTask.run(science, matchedTemplate, difference, sources) 

510 diaSources = output.diaSources[~output.diaSources["sky_source"]].copy(deep=True) 

511 self.assertEqual(len(diaSources), len(sources)) 

512 # no sources should be flagged as negative 

513 self.assertEqual(len(~diaSources["is_negative"]), len(diaSources)) 

514 refIds = [] 

515 for diaSource in diaSources: 

516 if diaSource[dipoleFlag]: 

517 self._check_diaSource(sources, diaSource, refIds=refIds, scale=0, 

518 rtol=0.05, atol=None, usePsfFlux=False) 

519 self.assertFloatsAlmostEqual(diaSource["ip_diffim_DipoleFit_orientation"], 

520 -np.pi / 2, atol=2.) 

521 self.assertFloatsAlmostEqual(diaSource["ip_diffim_DipoleFit_separation"], offset, rtol=0.1) 

522 else: 

523 raise ValueError("DiaSource with ID %s is not a dipole!", diaSource.getId()) 

524 

525 def test_sky_sources(self): 

526 """Add sky sources and check that they are sufficiently far from other 

527 sources and have negligible flux. 

528 """ 

529 # Set up the simulated images 

530 noiseLevel = 1. 

531 staticSeed = 1 

532 transientSeed = 6 

533 transientFluxLevel = 1000. 

534 transientFluxRange = 1.5 

535 fluxLevel = 500 

536 kwargs = {"seed": staticSeed, "psfSize": 2.4, "fluxLevel": fluxLevel} 

537 science, sources = makeTestImage(noiseLevel=noiseLevel, noiseSeed=6, **kwargs) 

538 science.getInfo().setVisitInfo(makeVisitInfo()) 

539 matchedTemplate, _ = makeTestImage(noiseLevel=noiseLevel/4, noiseSeed=7, **kwargs) 

540 transients, transientSources = makeTestImage(seed=transientSeed, psfSize=2.4, 

541 nSrc=10, fluxLevel=transientFluxLevel, 

542 fluxRange=transientFluxRange, 

543 noiseLevel=noiseLevel, noiseSeed=8) 

544 difference = science.clone() 

545 difference.maskedImage -= matchedTemplate.maskedImage 

546 difference.maskedImage += transients.maskedImage 

547 kernelWidth = np.max(science.psf.getKernel().getDimensions())//2 

548 

549 # Configure the detection Task 

550 detectionTask = self._setup_detection(doSkySources=True) 

551 

552 # Run detection and check the results 

553 output = detectionTask.run(science, matchedTemplate, difference, sources, 

554 idFactory=self.idGenerator.make_table_id_factory()) 

555 skySources = output.diaSources[output.diaSources["sky_source"]] 

556 self.assertEqual(len(skySources), detectionTask.config.skySources.nSources) 

557 for skySource in skySources: 

558 # Disable the sky_source flag to enable checks. 

559 skySource["sky_source"] = False 

560 # The sky sources should not be close to any other source 

561 with self.assertRaises(AssertionError): 

562 self._check_diaSource(transientSources, skySource, matchDistance=kernelWidth) 

563 with self.assertRaises(AssertionError): 

564 self._check_diaSource(sources, skySource, matchDistance=kernelWidth) 

565 # The sky sources should have low flux levels. 

566 self._check_diaSource(transientSources, skySource, matchDistance=1000, scale=0., 

567 atol=np.sqrt(transientFluxRange*transientFluxLevel)) 

568 

569 # Catalog ids should be very large from this id generator. 

570 self.assertTrue(all(output.diaSources['id'] > 1000000000)) 

571 

572 def test_exclude_mask_detections(self): 

573 """Sources with certain bad mask planes set should not be detected. 

574 """ 

575 # Set up the simulated images 

576 noiseLevel = 1. 

577 staticSeed = 1 

578 transientSeed = 6 

579 fluxLevel = 500 

580 radius = 2 

581 kwargs = {"seed": staticSeed, "psfSize": 2.4, "fluxLevel": fluxLevel} 

582 science, sources = makeTestImage(noiseLevel=noiseLevel, noiseSeed=6, **kwargs) 

583 science.getInfo().setVisitInfo(makeVisitInfo()) 

584 detector = DetectorWrapper(numAmps=1).detector 

585 science.setDetector(detector) 

586 matchedTemplate, _ = makeTestImage(noiseLevel=noiseLevel/4, noiseSeed=7, **kwargs) 

587 

588 # Configure the detection Task 

589 badSourceFlags = ["base_PixelFlags_flag_offimage", 

590 "base_PixelFlags_flag_edgeCenterAll", 

591 "base_PixelFlags_flag_badCenterAll", 

592 "base_PixelFlags_flag_saturatedCenterAll", ] 

593 detectionTask = self._setup_detection(nSkySources=0, badSourceFlags=badSourceFlags) 

594 excludeMaskPlanes = ["EDGE", "BAD", "SAT"] 

595 nBad = len(excludeMaskPlanes) 

596 self.assertGreater(nBad, 0) 

597 kwargs["seed"] = transientSeed 

598 kwargs["nSrc"] = nBad 

599 kwargs["fluxLevel"] = 1000 

600 

601 # Run detection and check the results 

602 def _detection_wrapper(setFlags=True): 

603 transients, transientSources = makeTestImage(noiseLevel=noiseLevel, noiseSeed=8, **kwargs) 

604 difference = science.clone() 

605 difference.maskedImage -= matchedTemplate.maskedImage 

606 difference.maskedImage += transients.maskedImage 

607 if setFlags: 

608 for src, badMask in zip(transientSources, excludeMaskPlanes): 

609 srcX = int(src.getX()) 

610 srcY = int(src.getY()) 

611 srcBbox = lsst.geom.Box2I(lsst.geom.Point2I(srcX - radius, srcY - radius), 

612 lsst.geom.Extent2I(2*radius + 1, 2*radius + 1)) 

613 difference[srcBbox].mask.array |= lsst.afw.image.Mask.getPlaneBitMask(badMask) 

614 with self.assertRaises(AlgorithmError): 

615 output = detectionTask.run(science, matchedTemplate, difference, sources) 

616 else: 

617 output = detectionTask.run(science, matchedTemplate, difference, sources) 

618 refIds = [] 

619 goodSrcFlags = checkMask(difference.mask, transientSources, excludeMaskPlanes) 

620 self.assertEqual(np.sum(~goodSrcFlags), 0) 

621 for diaSource, goodSrcFlag in zip(output.diaSources, goodSrcFlags): 

622 if ~goodSrcFlag: 

623 with self.assertRaises(AssertionError): 

624 self._check_diaSource(transientSources, diaSource, refIds=refIds) 

625 else: 

626 self._check_diaSource(transientSources, diaSource, refIds=refIds) 

627 _detection_wrapper(setFlags=False) 

628 _detection_wrapper(setFlags=True) 

629 

630 def test_fake_mask_plane_propagation(self): 

631 """Test that we have the mask planes related to fakes in diffim images. 

632 This is testing method called updateMasks 

633 """ 

634 xSize = 256 

635 ySize = 256 

636 science, sources = makeTestImage(psfSize=2.4, xSize=xSize, ySize=ySize, doApplyCalibration=True) 

637 science_fake_img, science_fake_sources = makeTestImage( 

638 psfSize=2.4, xSize=xSize, ySize=ySize, seed=5, nSrc=3, noiseLevel=0.25, fluxRange=1 

639 ) 

640 template, _ = makeTestImage(psfSize=2.4, xSize=xSize, ySize=ySize, doApplyCalibration=True) 

641 tmplt_fake_img, tmplt_fake_sources = makeTestImage( 

642 psfSize=2.4, xSize=xSize, ySize=ySize, seed=9, nSrc=3, noiseLevel=0.25, fluxRange=1 

643 ) 

644 # created fakes and added them to the images 

645 science.image += science_fake_img.image 

646 template.image += tmplt_fake_img.image 

647 

648 # TODO: DM-40796 update to INJECTED names when source injection gets refactored 

649 # adding mask planes to both science and template images 

650 science.mask.addMaskPlane("FAKE") 

651 science_fake_bitmask = science.mask.getPlaneBitMask("FAKE") 

652 template.mask.addMaskPlane("FAKE") 

653 template_fake_bitmask = template.mask.getPlaneBitMask("FAKE") 

654 

655 # makeTestImage sets the DETECTED plane on the sources; we can use 

656 # that to set the FAKE plane on the science and template images. 

657 detected = science_fake_img.mask.getPlaneBitMask("DETECTED") 

658 fake_pixels = (science_fake_img.mask.array & detected).nonzero() 

659 science.mask.array[fake_pixels] |= science_fake_bitmask 

660 detected = tmplt_fake_img.mask.getPlaneBitMask("DETECTED") 

661 fake_pixels = (tmplt_fake_img.mask.array & detected).nonzero() 

662 template.mask.array[fake_pixels] |= science_fake_bitmask 

663 

664 science_fake_masked = (science.mask.array & science_fake_bitmask) > 0 

665 template_fake_masked = (template.mask.array & template_fake_bitmask) > 0 

666 

667 subtractConfig = subtractImages.AlardLuptonSubtractTask.ConfigClass() 

668 subtractConfig.sourceSelector.signalToNoise.fluxField = "truth_instFlux" 

669 subtractConfig.sourceSelector.signalToNoise.errField = "truth_instFluxErr" 

670 subtractTask = subtractImages.AlardLuptonSubtractTask(config=subtractConfig) 

671 subtraction = subtractTask.run(template, science, sources) 

672 

673 # check subtraction mask plane is set where we set the previous masks 

674 diff_mask = subtraction.difference.mask 

675 

676 # science mask should be now in INJECTED 

677 inj_masked = (diff_mask.array & diff_mask.getPlaneBitMask("INJECTED")) > 0 

678 

679 # template mask should be now in INJECTED_TEMPLATE 

680 injTmplt_masked = (diff_mask.array & diff_mask.getPlaneBitMask("INJECTED_TEMPLATE")) > 0 

681 

682 self.assertFloatsEqual(inj_masked.astype(int), science_fake_masked.astype(int)) 

683 # The template is convolved, so the INJECTED_TEMPLATE mask plane may 

684 # include more pixels than the FAKE mask plane 

685 injTmplt_masked &= template_fake_masked 

686 self.assertFloatsEqual(injTmplt_masked.astype(int), template_fake_masked.astype(int)) 

687 

688 # Now check that detection of fakes have the correct flag for injections 

689 detectionTask = self._setup_detection() 

690 

691 output = detectionTask.run(subtraction.matchedScience, 

692 subtraction.matchedTemplate, 

693 subtraction.difference, 

694 subtraction.kernelSources) 

695 diaSources = output.diaSources[~output.diaSources["sky_source"]].copy(deep=True) 

696 

697 sci_refIds = [] 

698 tmpl_refIds = [] 

699 for diaSrc in diaSources: 

700 if diaSrc['base_PsfFlux_instFlux'] > 0: 

701 self._check_diaSource(science_fake_sources, diaSrc, scale=1, refIds=sci_refIds) 

702 self.assertTrue(diaSrc['base_PixelFlags_flag_injected']) 

703 self.assertTrue(diaSrc['base_PixelFlags_flag_injectedCenter']) 

704 self.assertFalse(diaSrc['base_PixelFlags_flag_injected_template']) 

705 self.assertFalse(diaSrc['base_PixelFlags_flag_injected_templateCenter']) 

706 else: 

707 self._check_diaSource(tmplt_fake_sources, diaSrc, scale=-1, refIds=tmpl_refIds) 

708 self.assertTrue(diaSrc['base_PixelFlags_flag_injected_template']) 

709 self.assertTrue(diaSrc['base_PixelFlags_flag_injected_templateCenter']) 

710 self.assertFalse(diaSrc['base_PixelFlags_flag_injected']) 

711 self.assertFalse(diaSrc['base_PixelFlags_flag_injectedCenter']) 

712 

713 def test_mask_streaks(self): 

714 """Run detection on a difference image containing a streak. 

715 """ 

716 # Set up the simulated images 

717 noiseLevel = 1. 

718 staticSeed = 1 

719 fluxLevel = 500 

720 xSize = 400 

721 ySize = 400 

722 kwargs = {"seed": staticSeed, "psfSize": 2.4, "fluxLevel": fluxLevel, "xSize": xSize, "ySize": ySize} 

723 science, sources = makeTestImage(noiseLevel=noiseLevel, noiseSeed=6, **kwargs) 

724 matchedTemplate, _ = makeTestImage(noiseLevel=noiseLevel/4, noiseSeed=7, **kwargs) 

725 

726 # Configure the detection Task 

727 detectionTask = self._setup_detection(doMerge=False, doMaskStreaks=True, 

728 raiseOnNoDiaSources=False, raiseOnBadSubtractionRatio=False) 

729 

730 # Test that no streaks are detected 

731 difference = science.clone() 

732 difference.maskedImage -= matchedTemplate.maskedImage 

733 output = detectionTask.run(science, matchedTemplate, difference, sources) 

734 outMask = output.subtractedMeasuredExposure.mask.array 

735 streakMask = output.subtractedMeasuredExposure.mask.getPlaneBitMask("STREAK") 

736 streakMaskSet = (outMask & streakMask) > 0 

737 self.assertTrue(np.all(streakMaskSet == 0)) 

738 

739 # Add streak-like shape and check that streak is detected 

740 difference.image.array[20:23, 40:200] += 50 

741 output = detectionTask.run(science, matchedTemplate, difference, sources) 

742 outMask = output.subtractedMeasuredExposure.mask.array 

743 streakMask = output.subtractedMeasuredExposure.mask.getPlaneBitMask("STREAK") 

744 streakMaskSet = (outMask & streakMask) > 0 

745 self.assertTrue(np.all(streakMaskSet[20:23, 40:200])) 

746 

747 # Add a more trapezoid shaped streak across an image that is 

748 # fainter and check that it is also detected 

749 bbox = science.getBBox() 

750 difference = science.clone() 

751 difference.maskedImage -= matchedTemplate.maskedImage 

752 width = 100 

753 amplitude = 5 

754 x0 = -100 + bbox.getBeginX() 

755 y0 = -100 + bbox.getBeginY() 

756 x1 = xSize + x0 + 100 

757 y1 = ySize/2 

758 corner_coords = [lsst.geom.Point2D(x0, y0), 

759 lsst.geom.Point2D(x0, y0 + width), 

760 lsst.geom.Point2D(x1, y1), 

761 lsst.geom.Point2D(x1 + width, y1)] 

762 streak_trapezoid = afwGeom.Polygon(corner_coords) 

763 streak_image = streak_trapezoid.createImage(bbox) 

764 streak_image *= amplitude 

765 difference.image.array += streak_image.array 

766 output = detectionTask.run(science, matchedTemplate, difference, sources) 

767 outMask = output.subtractedMeasuredExposure.mask.array 

768 streakMask = output.subtractedMeasuredExposure.mask.getPlaneBitMask("STREAK") 

769 streakMaskSet = (outMask & streakMask) > 0 

770 # Check that pixel values in streak_image equal the streak amplitude 

771 streak_check = np.where(streak_image.array == amplitude) 

772 self.assertTrue(np.all(streakMaskSet[streak_check])) 

773 # Check that the entire image was not masked STREAK 

774 self.assertFalse(np.all(streakMaskSet)) 

775 

776 def _setup_sattle_tests(self): 

777 noiseLevel = 1. 

778 staticSeed = 1 

779 fluxLevel = 500 

780 shared_kwargs = {"seed": staticSeed, "psfSize": 2.4, "fluxLevel": fluxLevel, 

781 "x0": 12345, "y0": 67890} 

782 science, sources = makeTestImage(noiseLevel=noiseLevel, noiseSeed=6, 

783 **shared_kwargs) 

784 science.getInfo().setVisitInfo(makeVisitInfo(id=2)) 

785 detector = DetectorWrapper(numAmps=1).detector 

786 science.setDetector(detector) 

787 matchedTemplate, _ = makeTestImage(noiseLevel=noiseLevel / 4, 

788 noiseSeed=7, **shared_kwargs) 

789 difference = science.clone() 

790 

791 detectionTask = self._setup_detection(doDeblend=True, 

792 badSubtractionRatioThreshold=1., 

793 doSkySources=False, run_sattle=True) 

794 

795 return science, matchedTemplate, difference, sources, detectionTask 

796 

797 @mock.patch.dict(os.environ, {"SATTLE_URI_BASE": "fake_host:1234"}) 

798 def test_sattle_not_available(self): 

799 science, matchedTemplate, difference, sources, detectionTask = self._setup_sattle_tests() 

800 

801 response = MockResponse({"allow_list": []}, 500, "sattle internal error") 

802 with mock.patch('requests.put', return_value=response): 

803 with self.assertRaises(requests.exceptions.HTTPError): 

804 detectionTask.run(science, matchedTemplate, difference, sources, 

805 idFactory=IdFactory.makeSimple()) 

806 

807 @mock.patch.dict(os.environ, {"SATTLE_URI_BASE": "fake_host:1234"}) 

808 def test_visit_id_not_in_sattle(self): 

809 science, matchedTemplate, difference, sources, detectionTask = self._setup_sattle_tests() 

810 

811 response = MockResponse({"allow_list": []}, 404, "missing visit cache") 

812 # visit id not in sattle raises 

813 with self.assertRaises(requests.exceptions.HTTPError): 

814 with mock.patch('lsst.ip.diffim.detectAndMeasure.requests.put', 

815 return_value=response): 

816 with mock.patch('lsst.ip.diffim.utils.populate_sattle_visit_cache'): 

817 detectionTask.run(science, matchedTemplate, difference, sources, 

818 idFactory=IdFactory.makeSimple()) 

819 

820 @mock.patch.dict(os.environ, {"SATTLE_URI_BASE": "fake_host:1234"}) 

821 def test_filter_satellites_some_allowed(self): 

822 science, matchedTemplate, difference, sources, detectionTask = self._setup_sattle_tests() 

823 

824 allowed_ids = [1, 5] 

825 response = MockResponse({"allow_list": allowed_ids}, 200, "some allowed") 

826 with mock.patch('requests.put', return_value=response): 

827 output = detectionTask.run(science, matchedTemplate, difference, sources, 

828 idFactory=IdFactory.makeSimple()) 

829 

830 self.assertEqual(len(output.diaSources), 2) 

831 

832 # Output should be sources 1 and 5 allowed out of 20 

833 self.assertEqual(set(output.diaSources['id']), set(allowed_ids)) 

834 

835 @mock.patch.dict(os.environ, {"SATTLE_URI_BASE": "fake_host:1234"}) 

836 def test_filter_satellites_all_allowed(self): 

837 science, matchedTemplate, difference, sources, detectionTask = self._setup_sattle_tests() 

838 

839 allowed_ids = list(range(1, 21)) 

840 response = MockResponse({"allow_list": allowed_ids}, 200, "all allowed") 

841 # Run detection and check the results 

842 with mock.patch('requests.put', return_value=response): 

843 output = detectionTask.run(science, matchedTemplate, difference, sources, 

844 idFactory=IdFactory.makeSimple()) 

845 

846 # Output should be all sources that went in. 20 go in, 20 should come out 

847 self.assertEqual(len(output.diaSources), 20) 

848 

849 self.assertEqual(set(output.diaSources['id']), set(allowed_ids)) 

850 

851 @mock.patch.dict(os.environ, {"SATTLE_URI_BASE": "fake_host:1234"}) 

852 def test_filter_satellites_none_allowed(self): 

853 science, matchedTemplate, difference, sources, detectionTask = self._setup_sattle_tests() 

854 

855 response = MockResponse({"allow_list": []}, 200, "none allowed") 

856 # Run detection and confirm it raises for no diasources 

857 with self.assertRaises(detectAndMeasure.NoDiaSourcesError): 

858 with mock.patch('requests.put', return_value=response): 

859 detectionTask.run(science, matchedTemplate, difference, sources, 

860 idFactory=IdFactory.makeSimple()) 

861 

862 @mock.patch.dict(os.environ, {"SATTLE_URI_BASE": ""}) 

863 def test_fail_on_sattle_misconfiguration(self): 

864 with self.assertRaises(pexConfig.FieldValidationError): 

865 self._setup_detection(run_sattle=True) 

866 

867 def test_trailed_glints(self): 

868 """Test that the glint_trail column works, and that 

869 the trailed_glints output contains the expected information. 

870 """ 

871 noiseLevel = 1. 

872 staticSeed = 1 

873 diffim, diaSources = makeTestImage(seed=staticSeed, noiseLevel=noiseLevel, noiseSeed=6) 

874 self._check_values(diaSources['glint_trail']) 

875 

876 # Run detection and return the output Struct so we can check it 

877 def _detection_wrapper(diffim, diaSources): 

878 detectionTask = self._setup_detection() 

879 scienceBase, sources = makeTestImage(noiseLevel=noiseLevel, noiseSeed=6) 

880 matchedTemplate, _ = makeTestImage(noiseLevel=noiseLevel/4, noiseSeed=7) 

881 science = scienceBase.clone() 

882 science.maskedImage -= diffim.maskedImage 

883 difference = science.clone() 

884 difference.maskedImage -= matchedTemplate.maskedImage 

885 output = detectionTask.run(science, matchedTemplate, difference, sources) 

886 return output 

887 

888 output = _detection_wrapper(diffim, diaSources) 

889 self.assertTrue('slopes' in output.glintTrailInfo) 

890 self.assertTrue('intercepts' in output.glintTrailInfo) 

891 self.assertTrue('stderrs' in output.glintTrailInfo) 

892 self.assertTrue('lengths' in output.glintTrailInfo) 

893 self.assertTrue('angles' in output.glintTrailInfo) 

894 

895 

896class DetectAndMeasureScoreTest(DetectAndMeasureTestBase, lsst.utils.tests.TestCase): 

897 detectionTask = detectAndMeasure.DetectAndMeasureScoreTask 

898 

899 def test_detection_xy0(self): 

900 """Basic functionality test with non-zero x0 and y0. 

901 """ 

902 # Set up the simulated images 

903 noiseLevel = 1. 

904 staticSeed = 1 

905 fluxLevel = 500 

906 kwargs = {"seed": staticSeed, "psfSize": 2.4, "fluxLevel": fluxLevel, "x0": 12345, "y0": 67890} 

907 science, sources = makeTestImage(noiseLevel=noiseLevel, noiseSeed=6, **kwargs) 

908 matchedTemplate, _ = makeTestImage(noiseLevel=noiseLevel/4, noiseSeed=7, **kwargs) 

909 difference = science.clone() 

910 subtractTask = subtractImages.AlardLuptonPreconvolveSubtractTask() 

911 scienceKernel = science.psf.getKernel() 

912 score = subtractTask._convolveExposure(difference, scienceKernel, subtractTask.convolutionControl) 

913 

914 # Configure the detection Task 

915 # Set the subtraction residual metric threshold high, since the template 

916 # is not actually subtracted from the science image. 

917 detectionTask = self._setup_detection(doDeblend=True, badSubtractionRatioThreshold=1., 

918 doSkySources=False) 

919 

920 # Run detection and check the results 

921 output = detectionTask.run(science, matchedTemplate, difference, score, sources, 

922 idFactory=self.idGenerator.make_table_id_factory()) 

923 

924 # Catalog ids should be very large from this id generator. 

925 self.assertTrue(all(output.diaSources['id'] > 1000000000)) 

926 subtractedMeasuredExposure = output.subtractedMeasuredExposure 

927 

928 self.assertImagesEqual(subtractedMeasuredExposure.image, difference.image) 

929 

930 self.assertEqual(len(output.diaSources), len(sources)) 

931 # no sources should be flagged as negative 

932 self.assertEqual(len(~output.diaSources["is_negative"]), len(output.diaSources)) 

933 # TODO DM-41496: restore this block once we handle detections on edge 

934 # pixels better; at least one of these sources currently has a bad 

935 # centroid because most of the source is rejected as EDGE. 

936 # refIds = [] 

937 # for source in sources: 

938 # self._check_diaSource(output.diaSources, source, refIds=refIds) 

939 

940 def test_measurements_finite(self): 

941 """Measured fluxes and centroids should always be finite. 

942 """ 

943 columnNames = ["coord_ra", "coord_dec", "ip_diffim_forced_PsfFlux_instFlux", 

944 "ip_diffim_forced_template_PsfFlux_instFlux"] 

945 

946 # Set up the simulated images 

947 noiseLevel = 1. 

948 staticSeed = 1 

949 transientSeed = 6 

950 xSize = 256 

951 ySize = 256 

952 kwargs = {"psfSize": 2.4, "x0": 0, "y0": 0, 

953 "xSize": xSize, "ySize": ySize} 

954 science, sources = makeTestImage(seed=staticSeed, noiseLevel=noiseLevel, noiseSeed=6, 

955 nSrc=1, **kwargs) 

956 matchedTemplate, _ = makeTestImage(seed=staticSeed, noiseLevel=noiseLevel/4, noiseSeed=7, 

957 nSrc=1, **kwargs) 

958 rng = np.random.RandomState(3) 

959 xLoc = np.arange(-5, xSize+5, 10) 

960 rng.shuffle(xLoc) 

961 yLoc = np.arange(-5, ySize+5, 10) 

962 rng.shuffle(yLoc) 

963 transients, transientSources = makeTestImage(seed=transientSeed, 

964 nSrc=len(xLoc), fluxLevel=1000., 

965 noiseLevel=noiseLevel, noiseSeed=8, 

966 xLoc=xLoc, yLoc=yLoc, 

967 **kwargs) 

968 difference = science.clone() 

969 difference.maskedImage -= matchedTemplate.maskedImage 

970 difference.maskedImage += transients.maskedImage 

971 subtractTask = subtractImages.AlardLuptonPreconvolveSubtractTask() 

972 scienceKernel = science.psf.getKernel() 

973 score = subtractTask._convolveExposure(difference, scienceKernel, subtractTask.convolutionControl) 

974 

975 # Configure the detection Task 

976 detectionTask = self._setup_detection(doForcedMeasurement=True) 

977 

978 # Run detection and check the results 

979 output = detectionTask.run(science, matchedTemplate, difference, score, sources) 

980 

981 for column in columnNames: 

982 self._check_values(output.diaSources[column]) 

983 self._check_values(output.diaSources.getX(), minValue=0, maxValue=xSize) 

984 self._check_values(output.diaSources.getY(), minValue=0, maxValue=ySize) 

985 self._check_values(output.diaSources.getPsfInstFlux()) 

986 

987 def test_detect_transients(self): 

988 """Run detection on a difference image containing transients. 

989 """ 

990 # Set up the simulated images 

991 noiseLevel = 1. 

992 staticSeed = 1 

993 transientSeed = 6 

994 fluxLevel = 500 

995 kwargs = {"seed": staticSeed, "psfSize": 2.4, "fluxLevel": fluxLevel} 

996 scienceBase, sources = makeTestImage(noiseLevel=noiseLevel, noiseSeed=6, **kwargs) 

997 matchedTemplate, _ = makeTestImage(noiseLevel=noiseLevel/4, noiseSeed=7, **kwargs) 

998 scienceKernel = scienceBase.psf.getKernel() 

999 subtractTask = subtractImages.AlardLuptonPreconvolveSubtractTask() 

1000 

1001 # Configure the detection Task 

1002 detectionTask = self._setup_detection(doMerge=False) 

1003 kwargs["seed"] = transientSeed 

1004 kwargs["nSrc"] = 10 

1005 kwargs["fluxLevel"] = 1000 

1006 

1007 # Run detection and check the results 

1008 def _detection_wrapper(positive=True): 

1009 """Simulate positive or negative transients and run detection. 

1010 

1011 Parameters 

1012 ---------- 

1013 positive : `bool`, optional 

1014 If set, use positive transient sources. 

1015 """ 

1016 

1017 transients, transientSources = makeTestImage(noiseLevel=noiseLevel, noiseSeed=8, **kwargs) 

1018 science = scienceBase.clone() 

1019 if positive: 

1020 science.maskedImage += transients.maskedImage 

1021 else: 

1022 science.maskedImage -= transients.maskedImage 

1023 difference = science.clone() 

1024 difference.maskedImage -= matchedTemplate.maskedImage 

1025 score = subtractTask._convolveExposure(difference, scienceKernel, subtractTask.convolutionControl) 

1026 # NOTE: NoiseReplacer (run by forcedMeasurement) can modify the 

1027 # science image if we've e.g. removed parents post-deblending. 

1028 # Pass a clone of the science image, so that it doesn't disrupt 

1029 # later tests. 

1030 output = detectionTask.run(science, matchedTemplate, difference, score, sources) 

1031 refIds = [] 

1032 scale = 1. if positive else -1. 

1033 # sources near the edge may have untrustworthy centroids 

1034 goodSrcFlags = ~output.diaSources['base_PixelFlags_flag_edge'] 

1035 for diaSource, goodSrcFlag in zip(output.diaSources, goodSrcFlags): 

1036 if goodSrcFlag: 

1037 self._check_diaSource(transientSources, diaSource, refIds=refIds, scale=scale) 

1038 _detection_wrapper(positive=True) 

1039 _detection_wrapper(positive=False) 

1040 

1041 def test_detect_dipoles(self): 

1042 """Run detection on a difference image containing dipoles. 

1043 """ 

1044 # Set up the simulated images 

1045 noiseLevel = 1. 

1046 staticSeed = 1 

1047 fluxLevel = 1000 

1048 fluxRange = 1.5 

1049 nSources = 10 

1050 offset = 1 

1051 xSize = 300 

1052 ySize = 300 

1053 kernelSize = 32 

1054 # Avoid placing sources near the edge for this test, so that we can 

1055 # easily check that the correct number of sources are detected. 

1056 templateBorderSize = kernelSize//2 

1057 dipoleFlag = "ip_diffim_DipoleFit_classification" 

1058 kwargs = {"seed": staticSeed, "psfSize": 2.4, "fluxLevel": fluxLevel, "fluxRange": fluxRange, 

1059 "nSrc": nSources, "templateBorderSize": templateBorderSize, "kernelSize": kernelSize, 

1060 "xSize": xSize, "ySize": ySize} 

1061 science, sources = makeTestImage(noiseLevel=noiseLevel, noiseSeed=6, **kwargs) 

1062 matchedTemplate, _ = makeTestImage(noiseLevel=noiseLevel/4, noiseSeed=7, **kwargs) 

1063 difference = science.clone() 

1064 # Shift the template by a pixel in order to make dipoles in the difference image. 

1065 matchedTemplate.image.array[...] = np.roll(matchedTemplate.image.array[...], offset, axis=0) 

1066 matchedTemplate.variance.array[...] = np.roll(matchedTemplate.variance.array[...], offset, axis=0) 

1067 matchedTemplate.mask.array[...] = np.roll(matchedTemplate.mask.array[...], offset, axis=0) 

1068 difference.maskedImage -= matchedTemplate.maskedImage[science.getBBox()] 

1069 subtractTask = subtractImages.AlardLuptonPreconvolveSubtractTask() 

1070 scienceKernel = science.psf.getKernel() 

1071 score = subtractTask._convolveExposure(difference, scienceKernel, subtractTask.convolutionControl) 

1072 

1073 detectionTask = self._setup_detection(badSubtractionRatioThreshold=0.3) 

1074 output = detectionTask.run(science, matchedTemplate, difference, score, sources) 

1075 diaSources = output.diaSources[~output.diaSources["sky_source"]].copy(deep=True) 

1076 self.assertEqual(len(diaSources), len(sources)) 

1077 refIds = [] 

1078 for diaSource in diaSources: 

1079 if diaSource[dipoleFlag]: 

1080 self._check_diaSource(sources, diaSource, refIds=refIds, scale=0, 

1081 rtol=0.05, atol=None, usePsfFlux=False) 

1082 self.assertFloatsAlmostEqual(diaSource["ip_diffim_DipoleFit_orientation"], 

1083 -np.pi / 2, atol=2.) 

1084 self.assertFloatsAlmostEqual(diaSource["ip_diffim_DipoleFit_separation"], offset, rtol=0.1) 

1085 else: 

1086 raise ValueError("DiaSource with ID %s is not a dipole!", diaSource.getId()) 

1087 

1088 def test_sky_sources(self): 

1089 """Add sky sources and check that they are sufficiently far from other 

1090 sources and have negligible flux. 

1091 """ 

1092 # Set up the simulated images 

1093 noiseLevel = 1. 

1094 staticSeed = 1 

1095 transientSeed = 6 

1096 transientFluxLevel = 1000. 

1097 transientFluxRange = 1.5 

1098 fluxLevel = 500 

1099 kwargs = {"seed": staticSeed, "psfSize": 2.4, "fluxLevel": fluxLevel} 

1100 science, sources = makeTestImage(noiseLevel=noiseLevel, noiseSeed=6, **kwargs) 

1101 matchedTemplate, _ = makeTestImage(noiseLevel=noiseLevel/4, noiseSeed=7, **kwargs) 

1102 transients, transientSources = makeTestImage(seed=transientSeed, psfSize=2.4, 

1103 nSrc=10, fluxLevel=transientFluxLevel, 

1104 fluxRange=transientFluxRange, 

1105 noiseLevel=noiseLevel, noiseSeed=8) 

1106 difference = science.clone() 

1107 difference.maskedImage -= matchedTemplate.maskedImage 

1108 difference.maskedImage += transients.maskedImage 

1109 subtractTask = subtractImages.AlardLuptonPreconvolveSubtractTask() 

1110 scienceKernel = science.psf.getKernel() 

1111 kernelWidth = np.max(scienceKernel.getDimensions())//2 

1112 score = subtractTask._convolveExposure(difference, scienceKernel, subtractTask.convolutionControl) 

1113 

1114 # Configure the detection Task 

1115 detectionTask = self._setup_detection(doSkySources=True) 

1116 

1117 # Run detection and check the results 

1118 output = detectionTask.run(science, matchedTemplate, difference, score, sources, 

1119 idFactory=self.idGenerator.make_table_id_factory()) 

1120 nSkySourcesGenerated = detectionTask.metadata["n_skySources"] 

1121 skySources = output.diaSources[output.diaSources["sky_source"]] 

1122 self.assertEqual(len(skySources), nSkySourcesGenerated) 

1123 for skySource in skySources: 

1124 # Disable the sky_source flag to enable checks. 

1125 skySource["sky_source"] = False 

1126 # The sky sources should not be close to any other source 

1127 with self.assertRaises(AssertionError): 

1128 self._check_diaSource(transientSources, skySource, matchDistance=kernelWidth) 

1129 with self.assertRaises(AssertionError): 

1130 self._check_diaSource(sources, skySource, matchDistance=kernelWidth) 

1131 # The sky sources should have low flux levels. 

1132 self._check_diaSource(transientSources, skySource, matchDistance=1000, scale=0., 

1133 atol=np.sqrt(transientFluxRange*transientFluxLevel)) 

1134 

1135 # Catalog ids should be very large from this id generator. 

1136 self.assertTrue(all(output.diaSources['id'] > 1000000000)) 

1137 

1138 def test_exclude_mask_detections(self): 

1139 """Sources with certain bad mask planes set should not be detected. 

1140 """ 

1141 # Set up the simulated images 

1142 noiseLevel = 1. 

1143 staticSeed = 1 

1144 transientSeed = 6 

1145 fluxLevel = 500 

1146 radius = 2 

1147 kwargs = {"seed": staticSeed, "psfSize": 2.4, "fluxLevel": fluxLevel} 

1148 science, sources = makeTestImage(noiseLevel=noiseLevel, noiseSeed=6, **kwargs) 

1149 science.getInfo().setVisitInfo(makeVisitInfo()) 

1150 detector = DetectorWrapper(numAmps=1).detector 

1151 science.setDetector(detector) 

1152 matchedTemplate, _ = makeTestImage(noiseLevel=noiseLevel/4, noiseSeed=7, **kwargs) 

1153 

1154 subtractTask = subtractImages.AlardLuptonPreconvolveSubtractTask() 

1155 scienceKernel = science.psf.getKernel() 

1156 # Configure the detection Task 

1157 badSourceFlags = ["base_PixelFlags_flag_offimage", 

1158 "base_PixelFlags_flag_edgeCenterAll", 

1159 "base_PixelFlags_flag_badCenterAll", 

1160 "base_PixelFlags_flag_saturatedCenterAll", ] 

1161 detectionTask = self._setup_detection(nSkySources=0, badSourceFlags=badSourceFlags) 

1162 excludeMaskPlanes = ["EDGE", "BAD", "SAT"] 

1163 nBad = len(excludeMaskPlanes) 

1164 self.assertGreater(nBad, 0) 

1165 kwargs["seed"] = transientSeed 

1166 kwargs["nSrc"] = nBad 

1167 kwargs["fluxLevel"] = 1000 

1168 

1169 # Run detection and check the results 

1170 def _detection_wrapper(setFlags=True): 

1171 transients, transientSources = makeTestImage(noiseLevel=noiseLevel, noiseSeed=8, **kwargs) 

1172 difference = science.clone() 

1173 difference.maskedImage -= matchedTemplate.maskedImage 

1174 difference.maskedImage += transients.maskedImage 

1175 if setFlags: 

1176 for src, badMask in zip(transientSources, excludeMaskPlanes): 

1177 srcX = int(src.getX()) 

1178 srcY = int(src.getY()) 

1179 srcBbox = lsst.geom.Box2I(lsst.geom.Point2I(srcX - radius, srcY - radius), 

1180 lsst.geom.Extent2I(2*radius + 1, 2*radius + 1)) 

1181 difference[srcBbox].mask.array |= lsst.afw.image.Mask.getPlaneBitMask(badMask) 

1182 score = subtractTask._convolveExposure(difference, scienceKernel, subtractTask.convolutionControl) 

1183 if setFlags: 

1184 with self.assertRaises(AlgorithmError): 

1185 output = detectionTask.run(science, matchedTemplate, difference, score, sources) 

1186 else: 

1187 output = detectionTask.run(science, matchedTemplate, difference, score, sources) 

1188 refIds = [] 

1189 goodSrcFlags = checkMask(difference.mask, transientSources, excludeMaskPlanes) 

1190 self.assertEqual(np.sum(~goodSrcFlags), 0) 

1191 for diaSource, goodSrcFlag in zip(output.diaSources, goodSrcFlags): 

1192 if ~goodSrcFlag: 

1193 with self.assertRaises(AssertionError): 

1194 self._check_diaSource(transientSources, diaSource, refIds=refIds) 

1195 else: 

1196 self._check_diaSource(transientSources, diaSource, refIds=refIds) 

1197 _detection_wrapper(setFlags=False) 

1198 _detection_wrapper(setFlags=True) 

1199 

1200 

1201class TestNegativePeaks(lsst.utils.tests.TestCase): 

1202 """Tests of deblending and merging negative peaks, to test fixes for the 

1203 various problems found on DM-48596. 

1204 """ 

1205 

1206 def testDeblendNegatives(self): 

1207 """Test that negative peaks get deblended and not destroyed: DM-48704. 

1208 This is only a test of deblending, not of merging. 

1209 """ 

1210 # Make a science image with one blend of two positive sources, to 

1211 # subtract from an empty template, resulting in a negative diffim. 

1212 bbox = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(100, 100)) 

1213 dataset = lsst.meas.base.tests.TestDataset(bbox) 

1214 delta = 10 

1215 with dataset.addBlend() as family: 

1216 family.addChild(instFlux=2E5, centroid=lsst.geom.Point2D(50, 72)) 

1217 family.addChild(instFlux=2.5E5, centroid=lsst.geom.Point2D(50+delta, 74)) 

1218 science, catalog = dataset.realize(noise=1.0, 

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

1220 dataset = lsst.meas.base.tests.TestDataset(bbox) 

1221 template, _ = dataset.realize(noise=1.0, 

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

1223 difference = template.clone() 

1224 difference.image -= science.image 

1225 

1226 config = detectAndMeasure.DetectAndMeasureTask.ConfigClass() 

1227 config.doDeblend = True 

1228 task = detectAndMeasure.DetectAndMeasureTask(config=config) 

1229 # prelude steps taken from `detectAndMeasure.run` 

1230 task._prepareInputs(difference) 

1231 table = lsst.afw.table.SourceTable.make(task.schema) 

1232 results = task.detection.run(table=table, exposure=difference, doSmooth=True) 

1233 # Just run the deblend step so we can check the footprints independently. 

1234 sources, positives, negatives = task._deblend(difference, results.positive, results.negative) 

1235 

1236 # DM-48704 fixed a problem where the peaks were in the footprints, but 

1237 # the spans were empty. 

1238 self.assertEqual(len(negatives), 2) 

1239 self.assertEqual(len(positives), 0) 

1240 self.assertGreater(negatives[0].getFootprint().getSpans().getArea(), 0) 

1241 self.assertGreater(negatives[1].getFootprint().getSpans().getArea(), 0) 

1242 # Deblended children are HeavyFootprints; we have to make sure the 

1243 # pixel values in those are correct (though DetectAndMeasureTask 

1244 # doesn't use the fact that they're Heavy). 

1245 # The sources are positive in the science image, and negative in the 

1246 # diffim, so the minimum value in the deblended negative footprint is 

1247 # the maximum value in the science catalog footprint (ignoring the 

1248 # noise in the science and template, hence rtol). 

1249 # (catalog[0] is the parent; we want the children) 

1250 self.assertFloatsAlmostEqual(negatives[0].getFootprint().getImageArray().min(), 

1251 -catalog[1].getFootprint().getImageArray().max(), rtol=1e-4) 

1252 self.assertFloatsAlmostEqual(negatives[1].getFootprint().getImageArray().min(), 

1253 -catalog[2].getFootprint().getImageArray().max(), rtol=1e-4) 

1254 

1255 self.assertEqual(sources["is_negative"].sum(), 2) 

1256 

1257 def testMergeFootprints(self): 

1258 """Test that merging footprints does not cause negative ones to 

1259 disappear (e.g. get merged into non-connected footprints). 

1260 

1261 As implemented, the diffim will have three positive sources (one a 

1262 blended pair), and 7 negative sources (two a blended pair). 

1263 """ 

1264 # Make a template image with multiple blends, designed to trigger the 

1265 # negative-footprint-related bug in `FootprintSet.merge`. 

1266 bbox = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Point2I(400, 200)) 

1267 dataset = lsst.meas.base.tests.TestDataset(bbox) 

1268 # Because these sources are on the template, they will be negative on 

1269 # the diffim, unless the pixels are explicitly made negative via 

1270 # `template.image.subset` below. 

1271 # positive isolated source on diffim 

1272 dataset.addSource(instFlux=.5E5, centroid=lsst.geom.Point2D(25, 26)) 

1273 # negative isolated source on diffim 

1274 dataset.addSource(instFlux=.7E5, centroid=lsst.geom.Point2D(75, 24), 

1275 shape=lsst.afw.geom.Quadrupole(12, 7, 2)) 

1276 delta = 10 

1277 # negative blended pair on diffim 

1278 with dataset.addBlend() as family: 

1279 family.addChild(instFlux=1E5, centroid=lsst.geom.Point2D(150, 72)) 

1280 family.addChild(instFlux=1.5E5, centroid=lsst.geom.Point2D(150+delta, 74)) 

1281 # positive blended pair on diffim 

1282 with dataset.addBlend() as family: 

1283 family.addChild(instFlux=2E5, centroid=lsst.geom.Point2D(250, 72)) 

1284 family.addChild(instFlux=2.5E5, centroid=lsst.geom.Point2D(250+delta, 74)) 

1285 # negative blended pair on diffim 

1286 with dataset.addBlend() as family: 

1287 family.addChild(instFlux=3E5, centroid=lsst.geom.Point2D(350, 72)) 

1288 family.addChild(instFlux=3.5E5, centroid=lsst.geom.Point2D(350+delta, 74)) 

1289 # negative isolated source on diffim 

1290 dataset.addSource(instFlux=4E5, centroid=lsst.geom.Point2D(75, 124)) 

1291 # negative isolated source on diffim 

1292 dataset.addSource(instFlux=5E5, centroid=lsst.geom.Point2D(175, 124)) 

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

1294 schema.addField("sky_source", "Flag", "Sky source.") 

1295 template, catalog = dataset.realize(noise=100.0, schema=schema) 

1296 # These will be positive sources on the diffim (as noted above). 

1297 template.image.subset(lsst.geom.Box2I(lsst.geom.Point2I(15, 15), 

1298 lsst.geom.Point2I(35, 35))).array *= -1 

1299 template.image.subset(lsst.geom.Box2I(lsst.geom.Point2I(230, 60), 

1300 lsst.geom.Point2I(270, 85))).array *= -1 

1301 dataset = lsst.meas.base.tests.TestDataset(bbox) 

1302 science, _ = dataset.realize(noise=100.0, schema=schema) 

1303 difference = science.clone() 

1304 difference.image -= template.image 

1305 

1306 config = detectAndMeasure.DetectAndMeasureTask.ConfigClass() 

1307 config.doDeblend = True 

1308 config.raiseOnBadSubtractionRatio = False 

1309 config.raiseOnNoDiaSources = False 

1310 task = detectAndMeasure.DetectAndMeasureTask(config=config) 

1311 result = task.run(science, template, difference, catalog) 

1312 

1313 # The original bug manifested as the (175,124) source disappaering in 

1314 # the merge step, and being included in the peaks of a duplicated 

1315 # (75,124) source, even though their footprints are not connected. 

1316 mask = np.isclose(result.diaSources["slot_Centroid_x"], 75) & \ 

1317 np.isclose(result.diaSources["slot_Centroid_y"], 124) 

1318 self.assertEqual(mask.sum(), 1) 

1319 peaks = result.diaSources[mask][0].getFootprint().peaks 

1320 self.assertEqual(len(peaks), 1) 

1321 self.assertEqual(peaks[0].getIx(), 75) 

1322 self.assertEqual(peaks[0].getIy(), 124) 

1323 

1324 mask = np.isclose(result.diaSources["slot_Centroid_x"], 175) & \ 

1325 np.isclose(result.diaSources["slot_Centroid_y"], 124) 

1326 self.assertEqual(mask.sum(), 1) 

1327 peaks = result.diaSources[mask][0].getFootprint().peaks 

1328 self.assertEqual(len(peaks), 1) 

1329 self.assertEqual(peaks[0].getIx(), 175) 

1330 self.assertEqual(peaks[0].getIy(), 124) 

1331 

1332 # This checks that all the returned footprints have exactly one peak; 

1333 # it's a more general test than the above, but I'm keeping that for 

1334 # the more explicit test of the original bugged sources. 

1335 peak_x = np.array([peak['f_x'] for src in result.diaSources for peak in src.getFootprint().peaks]) 

1336 peak_y = np.array([peak['f_y'] for src in result.diaSources for peak in src.getFootprint().peaks]) 

1337 peaks = np.column_stack((peak_x, peak_y)) 

1338 unique_peaks, counts = np.unique(peaks, axis=0, return_counts=True) 

1339 self.assertEqual(np.sum(counts > 1), 0) 

1340 

1341 # There are three positive sources that should not have `is_negative` 

1342 # set, independent of how deblending/merging of negative sources is 

1343 # handled. 

1344 self.assertEqual((~result.diaSources["is_negative"]).sum(), 3) 

1345 

1346 

1347def makeVisitInfo(id=1): 

1348 """Return a non-NaN visitInfo.""" 

1349 return afwImage.VisitInfo(id=id, 

1350 exposureTime=10.01, 

1351 darkTime=11.02, 

1352 date=dafBase.DateTime(65321.1, dafBase.DateTime.MJD, dafBase.DateTime.TAI), 

1353 ut1=12345.1, 

1354 era=45.1*geom.degrees, 

1355 boresightRaDec=geom.SpherePoint(23.1, 73.2, geom.degrees), 

1356 boresightAzAlt=geom.SpherePoint(134.5, 33.3, geom.degrees), 

1357 boresightAirmass=1.73, 

1358 boresightRotAngle=73.2*geom.degrees, 

1359 rotType=afwImage.RotType.SKY, 

1360 observatory=Observatory( 

1361 11.1*geom.degrees, 22.2*geom.degrees, 0.333), 

1362 weather=Weather(1.1, 2.2, 34.5), 

1363 ) 

1364 

1365 

1366class MockResponse: 

1367 """Provide a mock for requests.put calls""" 

1368 def __init__(self, json_data, status_code, text): 

1369 self.json_data = json_data 

1370 self.status_code = status_code 

1371 self.text = text 

1372 

1373 def json(self): 

1374 return self.json_data 

1375 

1376 def raise_for_status(self): 

1377 if self.status_code != 200: 

1378 raise requests.exceptions.HTTPError 

1379 

1380 

1381def setup_module(module): 

1382 lsst.utils.tests.init() 

1383 

1384 

1385class MemoryTestCase(lsst.utils.tests.MemoryTestCase): 

1386 pass 

1387 

1388 

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

1390 lsst.utils.tests.init() 

1391 unittest.main()