Coverage for tests/test_diaPipe.py: 16%

248 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-10-10 02:09 -0700

1# This file is part of ap_association. 

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 contextlib 

23import tempfile 

24import unittest 

25from unittest.mock import patch, Mock, MagicMock, DEFAULT 

26import warnings 

27 

28import numpy as np 

29import pandas as pd 

30import astropy.table as tb 

31 

32import lsst.afw.image as afwImage 

33import lsst.afw.table as afwTable 

34import lsst.dax.apdb as daxApdb 

35from lsst.meas.base import IdGenerator 

36import lsst.pex.config as pexConfig 

37import lsst.pipe.base as pipeBase 

38import lsst.utils.tests 

39from lsst.pipe.base.testUtils import assertValidOutput 

40 

41from lsst.ap.association import DiaPipelineTask 

42from lsst.ap.association.utils import convertDataFrameToSdmSchema 

43from utils_tests import makeExposure, makeDiaObjects, makeDiaSources, makeDiaForcedSources, \ 

44 makeSolarSystemSources 

45 

46 

47def _makeMockDataFrame(): 

48 """Create a new mock of a DataFrame. 

49 

50 Returns 

51 ------- 

52 mock : `unittest.mock.Mock` 

53 A mock guaranteed to accept all operations used by `pandas.DataFrame`. 

54 """ 

55 with warnings.catch_warnings(): 

56 # spec triggers deprecation warnings on DataFrame, but will 

57 # automatically adapt to any removals. 

58 warnings.simplefilter("ignore", category=DeprecationWarning) 

59 return MagicMock(spec=pd.DataFrame()) 

60 

61 

62def _makeMockTable(): 

63 """Create a new mock of a Table. 

64 

65 Returns 

66 ------- 

67 mock : `unittest.mock.Mock` 

68 A mock guaranteed to accept all operations used by `astropy.table.Table`. 

69 """ 

70 with warnings.catch_warnings(): 

71 # spec triggers deprecation warnings on DataFrame, but will 

72 # automatically adapt to any removals. 

73 warnings.simplefilter("ignore", category=DeprecationWarning) 

74 return MagicMock(spec=tb.Table()) 

75 

76 

77class TestDiaPipelineTask(unittest.TestCase): 

78 

79 @classmethod 

80 def _makeDefaultConfig(cls, config_file, **kwargs): 

81 config = DiaPipelineTask.ConfigClass() 

82 config.apdb_config_url = config_file 

83 config.update(**kwargs) 

84 return config 

85 

86 def setUp(self): 

87 # Create an instance of random generator with fixed seed. 

88 rng = np.random.default_rng(1234) 

89 self.rng = rng 

90 

91 # schemas are persisted in both Gen 2 and Gen 3 butler as prototypical catalogs 

92 srcSchema = afwTable.SourceTable.makeMinimalSchema() 

93 srcSchema.addField("base_PixelFlags_flag", type="Flag") 

94 srcSchema.addField("base_PixelFlags_flag_offimage", type="Flag") 

95 self.srcSchema = afwTable.SourceCatalog(srcSchema) 

96 self.exposure = makeExposure(False, False) 

97 self.diaObjects = makeDiaObjects(20, self.exposure, rng) 

98 self.diaSources = makeDiaSources( 

99 100, self.diaObjects["diaObjectId"].to_numpy(), self.exposure, rng) 

100 self.diaForcedSources = makeDiaForcedSources( 

101 200, self.diaObjects["diaObjectId"].to_numpy(), self.exposure, rng) 

102 self.ssSources = makeSolarSystemSources( 

103 20, self.diaObjects["diaObjectId"].to_numpy(), self.exposure, rng) 

104 

105 sqlite_file = tempfile.NamedTemporaryFile() 

106 self.addCleanup(sqlite_file.close) 

107 self.config_file = tempfile.NamedTemporaryFile() 

108 self.addCleanup(self.config_file.close) 

109 apdb_config = daxApdb.ApdbSql.init_database(db_url=f"sqlite:///{sqlite_file.name}") 

110 apdb_config.save(self.config_file.name) 

111 

112 def testRun(self): 

113 """Test running while creating and packaging alerts. 

114 """ 

115 self._testRun(doPackageAlerts=True, doSolarSystemAssociation=True) 

116 

117 def testRunWithSolarSystemAssociation(self): 

118 """Test running while creating and packaging alerts. 

119 """ 

120 self._testRun(doPackageAlerts=False, doSolarSystemAssociation=True) 

121 

122 def testRunWithAlerts(self): 

123 """Test running while creating and packaging alerts. 

124 """ 

125 self._testRun(doPackageAlerts=True, doSolarSystemAssociation=False) 

126 

127 def testRunWithoutAlertsOrSolarSystem(self): 

128 """Test running without creating and packaging alerts. 

129 """ 

130 self._testRun(doPackageAlerts=False, doSolarSystemAssociation=False) 

131 

132 def _testRun(self, doPackageAlerts=False, doSolarSystemAssociation=False, doRunForcedMeasurement=False): 

133 """Test the normal workflow of each ap_pipe step. 

134 """ 

135 config = self._makeDefaultConfig( 

136 config_file=self.config_file.name, 

137 doPackageAlerts=doPackageAlerts, 

138 doSolarSystemAssociation=doSolarSystemAssociation, 

139 doRunForcedMeasurement=doRunForcedMeasurement, 

140 ) 

141 task = DiaPipelineTask(config=config) 

142 # Set DataFrame index testing to always return False. Mocks return 

143 # true for this check otherwise. 

144 task.testDataFrameIndex = lambda x: False 

145 diffIm = Mock(spec=afwImage.ExposureF) 

146 exposure = Mock(spec=afwImage.ExposureF) 

147 template = Mock(spec=afwImage.ExposureF) 

148 diaSrc = _makeMockDataFrame() 

149 ssObjects = _makeMockTable() 

150 

151 # Each of these subtasks should be called once during diaPipe 

152 # execution. We use mocks here to check they are being executed 

153 # appropriately. 

154 subtasksToMock = [ 

155 "diaCalculation", 

156 ] 

157 if doPackageAlerts: 

158 subtasksToMock.append("alertPackager") 

159 else: 

160 self.assertFalse(hasattr(task, "alertPackager")) 

161 

162 if not doSolarSystemAssociation: 

163 self.assertFalse(hasattr(task, "solarSystemAssociator")) 

164 

165 def concatMock(_data, **_kwargs): 

166 return _makeMockDataFrame() 

167 

168 # Mock out the run() methods of these two Tasks to ensure they 

169 # return data in the correct form. 

170 def solarSystemAssociator_run(unAssocDiaSources, solarSystemObjectTable, visitInfo, 

171 bbox, wcs): 

172 return lsst.pipe.base.Struct(nTotalSsObjects=42, 

173 nAssociatedSsObjects=30, 

174 ssoAssocDiaSources=_makeMockTable(), 

175 unAssocDiaSources=_makeMockTable(), 

176 associatedSsSources=_makeMockTable(), 

177 unassociatedSsObjects=_makeMockTable()) 

178 

179 def associator_run(table, diaObjects): 

180 return lsst.pipe.base.Struct(nUpdatedDiaObjects=2, nUnassociatedDiaObjects=3, 

181 matchedDiaSources=_makeMockDataFrame(), 

182 unAssocDiaSources=_makeMockDataFrame()) 

183 

184 def updateObjectTableMock(diaObjects, diaSources): 

185 pass 

186 

187 # apdb isn't a subtask, but still needs to be mocked out for correct 

188 # execution in the test environment. 

189 with patch.multiple(task, **{task: DEFAULT for task in subtasksToMock + ["apdb"]}), \ 

190 patch('lsst.ap.association.diaPipe.pd.concat', side_effect=concatMock), \ 

191 patch('lsst.ap.association.diaPipe.DiaPipelineTask.updateObjectTable', 

192 side_effect=updateObjectTableMock), \ 

193 patch('lsst.ap.association.association.AssociationTask.run', 

194 side_effect=associator_run) as mainRun, \ 

195 patch('lsst.pipe.tasks.ssoAssociation.SolarSystemAssociationTask.run', 

196 side_effect=solarSystemAssociator_run) as ssRun: 

197 

198 result = task.run(diaSrc, 

199 None, 

200 diffIm, 

201 exposure, 

202 template, 

203 preloadedDiaObjects=self.diaObjects, 

204 preloadedDiaSources=self.diaSources, 

205 preloadedDiaForcedSources=self.diaForcedSources, 

206 band="g", 

207 idGenerator=IdGenerator(), 

208 solarSystemObjectTable=ssObjects) 

209 for subtaskName in subtasksToMock: 

210 getattr(task, subtaskName).run.assert_called_once() 

211 assertValidOutput(task, result) 

212 # Exact type and contents of apdbMarker are undefined. 

213 self.assertIsInstance(result.apdbMarker, pexConfig.Config) 

214 meta = task.getFullMetadata() 

215 # Check that the expected metadata has been set. 

216 self.assertEqual(meta["diaPipe.numUpdatedDiaObjects"], 2) 

217 self.assertEqual(meta["diaPipe.numUnassociatedDiaObjects"], 3) 

218 # and that associators ran once or not at all. 

219 mainRun.assert_called_once() 

220 if doSolarSystemAssociation: 

221 ssRun.assert_called_once() 

222 else: 

223 ssRun.assert_not_called() 

224 

225 def test_tooManyDiaObjectsError(self): 

226 maxNewDiaObjects = 100 

227 

228 nDiaSources = maxNewDiaObjects + 1 

229 diaSources = makeDiaSources(nDiaSources, np.zeros(nDiaSources), self.exposure, self.rng) 

230 

231 def runAndTestWithContextManager(threshold): 

232 config = self._makeDefaultConfig(config_file=self.config_file.name, 

233 doSolarSystemAssociation=False, 

234 filterUnAssociatedSources=False, 

235 maxNewDiaObjects=threshold, 

236 ) 

237 task = DiaPipelineTask(config=config) 

238 contextManager = self.assertRaises(pipeBase.AlgorithmError) if nDiaSources > threshold > 0 \ 

239 else contextlib.nullcontext() 

240 with contextManager: 

241 task.associateDiaSources( 

242 diaSources, 

243 None, 

244 None, 

245 self.diaObjects, 

246 ) 

247 # Test cases at, above, and below the threshold as well as at 0. 

248 runAndTestWithContextManager(0) 

249 runAndTestWithContextManager(maxNewDiaObjects - 1) 

250 runAndTestWithContextManager(maxNewDiaObjects) 

251 runAndTestWithContextManager(maxNewDiaObjects + 1) 

252 

253 def test_createDiaObjects(self): 

254 """Test that creating new DiaObjects works as expected. 

255 """ 

256 nSources = 5 

257 config = self._makeDefaultConfig(config_file=self.config_file.name, doPackageAlerts=False) 

258 task = DiaPipelineTask(config=config) 

259 diaSources = pd.DataFrame(data=[ 

260 {"ra": 0.04*idx, "dec": 0.04*idx, 

261 "diaSourceId": idx + 1 + nSources, "diaObjectId": 0, 

262 "ssObjectId": 0} 

263 for idx in range(nSources)]) 

264 

265 result = task.createNewDiaObjects(convertDataFrameToSdmSchema(task.schema, diaSources, "DiaSource")) 

266 self.assertEqual(nSources, len(result.newDiaObjects)) 

267 self.assertTrue(np.all(np.equal( 

268 result.diaSources["diaObjectId"].to_numpy(), 

269 result.diaSources["diaSourceId"].to_numpy()))) 

270 self.assertTrue(np.all(np.equal( 

271 result.newDiaObjects["diaObjectId"].to_numpy(), 

272 result.diaSources["diaSourceId"].to_numpy()))) 

273 

274 def test_purgeDiaObjects(self): 

275 """Remove diaOjects that are outside an image's bounding box. 

276 """ 

277 

278 config = self._makeDefaultConfig(config_file=self.config_file.name, doPackageAlerts=False) 

279 task = DiaPipelineTask(config=config) 

280 exposure = makeExposure(False, False) 

281 nObj0 = 20 

282 

283 # Create diaObjects 

284 diaObjects = makeDiaObjects(nObj0, exposure, self.rng) 

285 # Shrink the bounding box so that some of the diaObjects will be outside 

286 bbox = exposure.getBBox() 

287 size = np.minimum(bbox.getHeight(), bbox.getWidth()) 

288 bbox.grow(-size//4) 

289 exposureCut = exposure[bbox] 

290 sizeCut = np.minimum(bbox.getHeight(), bbox.getWidth()) 

291 buffer = 10 

292 bbox.grow(buffer) 

293 

294 def check_diaObjects(bbox, wcs, diaObjects): 

295 raVals = diaObjects.ra.to_numpy() 

296 decVals = diaObjects.dec.to_numpy() 

297 xVals, yVals = wcs.skyToPixelArray(raVals, decVals, degrees=True) 

298 selector = bbox.contains(xVals, yVals) 

299 return selector 

300 

301 selector0 = check_diaObjects(bbox, exposureCut.getWcs(), diaObjects) 

302 nIn0 = np.count_nonzero(selector0) 

303 nOut0 = np.count_nonzero(~selector0) 

304 self.assertEqual(nObj0, nIn0 + nOut0) 

305 

306 # Add an ID that is not in the diaObject table. It should not get removed. 

307 diaObjectIds0 = diaObjects["diaObjectId"].copy(deep=True) 

308 diaObjectIds0[max(diaObjectIds0.index) + 1] = 999 

309 diaObjects1, objIds = task.purgeDiaObjects(exposureCut.getBBox(), exposureCut.getWcs(), diaObjects, 

310 diaObjectIds=diaObjectIds0, buffer=buffer) 

311 diaObjectIds1 = diaObjects1["diaObjectId"] 

312 # Verify that the bounding box was not changed 

313 sizeCheck = np.minimum(exposureCut.getBBox().getHeight(), exposureCut.getBBox().getWidth()) 

314 self.assertEqual(sizeCut, sizeCheck) 

315 selector1 = check_diaObjects(bbox, exposureCut.getWcs(), diaObjects1) 

316 nIn1 = np.count_nonzero(selector1) 

317 nOut1 = np.count_nonzero(~selector1) 

318 nObj1 = len(diaObjects1) 

319 self.assertEqual(nObj1, nIn0) 

320 # Verify that not all diaObjects were removed 

321 self.assertGreater(nObj1, 0) 

322 # Check that some diaObjects were removed 

323 self.assertLess(nObj1, nObj0) 

324 # Verify that no objects outside the bounding box remain 

325 self.assertEqual(nOut1, 0) 

326 # Verify that no objects inside the bounding box were removed 

327 self.assertEqual(nIn1, nIn0) 

328 # The length of the updated object IDs should equal the number of objects 

329 # plus one, since we added an extra ID. 

330 self.assertEqual(nObj1 + 1, len(objIds)) 

331 # All of the object IDs extracted from the catalog should be in the pruned object IDs 

332 self.assertTrue(set(objIds).issuperset(diaObjectIds1)) 

333 # The pruned object IDs should contain entries that are not in the catalog 

334 self.assertFalse(set(diaObjectIds1).issuperset(objIds)) 

335 # Some IDs should have been removed 

336 self.assertLess(len(objIds), len(diaObjectIds0)) 

337 

338 def test_filterDiaObjects(self): 

339 """Unassociated diaSources that are filtered should have good reliability and SNR. 

340 Glint trail sources should also be filtered out. 

341 """ 

342 

343 config = self._makeDefaultConfig(config_file=self.config_file.name, 

344 doPackageAlerts=False, 

345 filterUnAssociatedSources=True) 

346 

347 configBadFilter = self._makeDefaultConfig(config_file=self.config_file.name, 

348 doPackageAlerts=False, 

349 filterUnAssociatedSources=True, 

350 newObjectFluxField="notAFlux") 

351 configBadFlag = self._makeDefaultConfig(config_file=self.config_file.name, 

352 doPackageAlerts=False, 

353 filterUnAssociatedSources=True, 

354 newObjectBadFlags=("junkSource", "notUsed")) 

355 with self.assertRaises(pipeBase.InvalidQuantumError): 

356 DiaPipelineTask(config=configBadFilter) 

357 with self.assertRaises(pipeBase.InvalidQuantumError): 

358 DiaPipelineTask(config=configBadFlag) 

359 task = DiaPipelineTask(config=config) 

360 nUnassociatedDiaSources = 234 

361 

362 # Create diaSources 

363 diaSources = makeDiaSources(nUnassociatedDiaSources, 

364 np.zeros(nUnassociatedDiaSources), 

365 self.exposure, 

366 self.rng, 

367 flagList=task.config.newObjectBadFlags) 

368 reliability = self.rng.random(nUnassociatedDiaSources) 

369 flux = (self.rng.random(nUnassociatedDiaSources)**2)*100 

370 fluxErr = np.sqrt(flux) 

371 glint_trail = np.zeros(nUnassociatedDiaSources, dtype=bool) 

372 glint_trail[12:16] = True # add 4 glint trail sources 

373 diaSources["reliability"] = reliability 

374 diaSources[config.newObjectFluxField] = flux 

375 diaSources[config.newObjectFluxField + "Err"] = fluxErr 

376 diaSources["glint_trail"] = glint_trail 

377 badFlagName = task.config.newObjectBadFlags[0] 

378 badFlags = np.zeros(nUnassociatedDiaSources, dtype=bool) 

379 nBadFlags = 20 

380 badFlags[0:nBadFlags] = True 

381 diaSources[badFlagName] = badFlags 

382 

383 def runAndCheckFilter(diaSources, snrThreshold=None, lowReliabilitySnrThreshold=None, 

384 reliabilityThreshold=None, lowSnrReliabilityThreshold=None, 

385 badFlags=None, 

386 ): 

387 

388 filterResults = task.filterSources( 

389 diaSources.copy(deep=True), 

390 snrThreshold=snrThreshold, 

391 lowReliabilitySnrThreshold=lowReliabilitySnrThreshold, 

392 reliabilityThreshold=reliabilityThreshold, 

393 lowSnrReliabilityThreshold=lowSnrReliabilityThreshold, 

394 badFlags=badFlags, 

395 ) 

396 self.assertEqual(len(filterResults.goodSources) + len(filterResults.badSources), 

397 nUnassociatedDiaSources) 

398 goodFlux = filterResults.goodSources[config.newObjectFluxField] 

399 goodFluxErr = filterResults.goodSources[config.newObjectFluxField + "Err"] 

400 goodSnr = np.array(goodFlux/goodFluxErr) 

401 self.assertTrue(np.all(goodSnr > snrThreshold)) 

402 goodReliability = np.array(filterResults.goodSources["reliability"]) 

403 self.assertTrue(np.all(goodReliability > reliabilityThreshold)) 

404 goodLowSnrFlag = goodSnr < lowReliabilitySnrThreshold 

405 lowSnrReliability = goodReliability[goodLowSnrFlag] 

406 self.assertTrue(np.all(lowSnrReliability > lowSnrReliabilityThreshold)) 

407 glintTrailSources = np.array(filterResults.goodSources["glint_trail"]) 

408 self.assertTrue(not any(glintTrailSources)) 

409 

410 # No sources should be removed if the thresholds are turned off 

411 runAndCheckFilter(diaSources, 

412 snrThreshold=0, lowReliabilitySnrThreshold=0, 

413 reliabilityThreshold=0, lowSnrReliabilityThreshold=0) 

414 runAndCheckFilter(diaSources, 

415 snrThreshold=0, lowReliabilitySnrThreshold=0, 

416 reliabilityThreshold=0, lowSnrReliabilityThreshold=0, 

417 badFlags=[badFlagName]) 

418 runAndCheckFilter(diaSources, 

419 snrThreshold=2, lowReliabilitySnrThreshold=8, 

420 reliabilityThreshold=0, lowSnrReliabilityThreshold=0) 

421 runAndCheckFilter(diaSources, 

422 snrThreshold=2, lowReliabilitySnrThreshold=8, 

423 reliabilityThreshold=0, lowSnrReliabilityThreshold=0.5) 

424 runAndCheckFilter(diaSources, 

425 snrThreshold=0, lowReliabilitySnrThreshold=0, 

426 reliabilityThreshold=0.1, lowSnrReliabilityThreshold=0.5) 

427 runAndCheckFilter(diaSources, 

428 snrThreshold=2, lowReliabilitySnrThreshold=8, 

429 reliabilityThreshold=0.1, lowSnrReliabilityThreshold=0.5) 

430 runAndCheckFilter(diaSources, 

431 snrThreshold=2, lowReliabilitySnrThreshold=8, 

432 reliabilityThreshold=0.1, lowSnrReliabilityThreshold=0.5, 

433 badFlags=[badFlagName]) 

434 

435 def test_mergeEmptyCatalog(self): 

436 """Test that a catalog is unchanged if it is merged with an empty 

437 catalog. 

438 """ 

439 diaSourcesBase = self.diaSources 

440 

441 config = self._makeDefaultConfig(config_file=self.config_file.name, doPackageAlerts=False) 

442 task = DiaPipelineTask(config=config) 

443 # Include some but not all columns that should be in diaSourcesBase, and some that are mis-matched 

444 diaSourcesEmpty = pd.DataFrame(columns=["ra", "dec", "foo"]) 

445 diaSourcesTest = task.mergeCatalogs(diaSourcesBase, diaSourcesEmpty, tableName="DiaSource") 

446 self.assertTrue(diaSourcesBase.equals(diaSourcesTest)) 

447 

448 def test_mergeCatalogs(self): 

449 """Test that a merged catalog is concatenated correctly. 

450 """ 

451 config = self._makeDefaultConfig(config_file=self.config_file.name, doPackageAlerts=False) 

452 task = DiaPipelineTask(config=config) 

453 

454 diaSourcesBase = convertDataFrameToSdmSchema(task.schema, self.diaSources, "DiaSource") 

455 nBase = len(diaSourcesBase) 

456 nNew = int(nBase/2) 

457 

458 diaSourcesNew = makeDiaSources(nNew, self.diaObjects["diaObjectId"].to_numpy(), self.exposure, 

459 self.rng) 

460 diaSourcesNew = convertDataFrameToSdmSchema(task.schema, diaSourcesNew, "DiaSource") 

461 diaSourcesTest = task.mergeCatalogs(diaSourcesBase, diaSourcesNew, tableName="DiaSource") 

462 self.assertEqual(len(diaSourcesTest), nBase + nNew) 

463 diaSourcesExtract1 = diaSourcesTest.iloc[:nBase] 

464 diaSourcesExtract2 = diaSourcesTest.iloc[nBase:] 

465 

466 pd.testing.assert_frame_equal(diaSourcesBase, diaSourcesExtract1) 

467 pd.testing.assert_frame_equal(diaSourcesNew, diaSourcesExtract2) 

468 

469 def test_updateObjectTable(self): 

470 """Test that the diaObject record is updated with the number of 

471 diaSources. 

472 """ 

473 config = self._makeDefaultConfig(config_file=self.config_file.name, doPackageAlerts=False) 

474 task = DiaPipelineTask(config=config) 

475 nObjects = 20 

476 nSrcPerObject = 10 

477 nExtraSources = 5 

478 nSources = nSrcPerObject*nObjects + nExtraSources 

479 expectedSourcesPerObject = nSrcPerObject*np.ones(nObjects) 

480 expectedSourcesPerObject[:nExtraSources] += 1 

481 diaObjects = makeDiaObjects(nObjects, self.exposure, self.rng) 

482 diaSources = makeDiaSources(nSources, diaObjects["diaObjectId"].to_numpy(), self.exposure, self.rng) 

483 updatedDiaObjects = task.updateObjectTable(diaObjects, diaSources) 

484 self.assertTrue(np.all(updatedDiaObjects.nDiaSources.values == expectedSourcesPerObject)) 

485 

486 

487class MemoryTester(lsst.utils.tests.MemoryTestCase): 

488 pass 

489 

490 

491def setup_module(module): 

492 lsst.utils.tests.init() 

493 

494 

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

496 lsst.utils.tests.init() 

497 unittest.main()