Coverage for tests/fgcmcalTestBase.py: 10%

248 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-05-01 12:57 +0000

1# See COPYRIGHT file at the top of the source tree. 

2# 

3# This file is part of fgcmcal. 

4# 

5# Developed for the LSST Data Management System. 

6# This product includes software developed by the LSST Project 

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

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

9# for details of code ownership. 

10# 

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

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

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

14# (at your option) any later version. 

15# 

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

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

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

19# GNU General Public License for more details. 

20# 

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

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

23"""General fgcmcal testing class. 

24 

25This class is used as the basis for individual obs package tests using 

26data from testdata_jointcal for Gen3 repos. 

27""" 

28 

29import os 

30import shutil 

31import numpy as np 

32import glob 

33import esutil 

34 

35import lsst.daf.butler as dafButler 

36import lsst.pipe.base as pipeBase 

37import lsst.geom as geom 

38from lsst.pipe.base import Pipeline, ExecutionResources 

39from lsst.ctrl.mpexec import SimplePipelineExecutor 

40 

41import lsst.fgcmcal as fgcmcal 

42 

43ROOT = os.path.abspath(os.path.dirname(__file__)) 

44 

45 

46class FgcmcalTestBase(object): 

47 """Base class for gen3 fgcmcal tests, to genericize some test running and setup. 

48 

49 Derive from this first, then from TestCase. 

50 """ 

51 @classmethod 

52 def _importRepository(cls, instrument, exportPath, exportFile): 

53 """Import a test repository into self.testDir 

54 

55 Parameters 

56 ---------- 

57 instrument : `str` 

58 Full string name for the instrument. 

59 exportPath : `str` 

60 Path to location of repository to export. 

61 exportFile : `str` 

62 Filename of export data. 

63 """ 

64 cls.repo = os.path.join(cls.testDir, 'testrepo') 

65 

66 # Make the repo and retrieve a writeable Butler 

67 _ = dafButler.Butler.makeRepo(cls.repo) 

68 butler = dafButler.Butler(cls.repo, writeable=True) 

69 # Register the instrument 

70 instrInstance = pipeBase.Instrument.from_string(instrument) 

71 instrInstance.register(butler.registry) 

72 # Import the exportFile 

73 butler.import_(directory=exportPath, filename=exportFile, 

74 transfer='symlink', 

75 skip_dimensions={'instrument', 'detector', 'physical_filter'}) 

76 

77 def _runPipeline(self, repo, pipelineFile, queryString='', 

78 inputCollections=None, outputCollection=None, 

79 configFiles={}, configOptions={}, 

80 registerDatasetTypes=False): 

81 """Run a pipeline via pipetask. 

82 

83 Parameters 

84 ---------- 

85 repo : `str` 

86 Gen3 repository yaml file. 

87 pipelineFile : `str` 

88 Pipeline definition file. 

89 queryString : `str`, optional 

90 Where query that defines the data to use. 

91 inputCollections : `list` [`str`], optional 

92 Input collections list. 

93 outputCollection : `str`, optional 

94 Output collection name. 

95 configFiles : `dict` [`list`], optional 

96 Dictionary of config files. The key of the ``configFiles`` 

97 dict is the relevant task label. The value of ``configFiles`` 

98 is a list of config files to apply (in order) to that task. 

99 configOptions : `dict` [`dict`], optional 

100 Dictionary of individual config options. The key of the 

101 ``configOptions`` dict is the relevant task label. The value 

102 of ``configOptions`` is another dict that contains config 

103 key/value overrides to apply. 

104 configOptions : `list` [`str`], optional 

105 List of individual config options to use. Each string will 

106 be of the form ``taskName:configField=value``. 

107 registerDatasetTypes : `bool`, optional 

108 Register new dataset types? 

109 

110 Returns 

111 ------- 

112 exit_code : `int` 

113 Exit code for pipetask run. 

114 

115 Raises 

116 ------ 

117 RuntimeError : Raised if the "pipetask" call fails. 

118 """ 

119 butler = SimplePipelineExecutor.prep_butler(repo, 

120 inputs=inputCollections, 

121 output=outputCollection) 

122 pipeline = Pipeline.fromFile(pipelineFile) 

123 for taskName, fileList in configFiles.items(): 

124 for fileName in fileList: 

125 pipeline.addConfigFile(taskName, fileName) 

126 for taskName, configDict in configOptions.items(): 

127 for option, value in configDict.items(): 

128 pipeline.addConfigOverride(taskName, option, value) 

129 

130 resources = ExecutionResources(num_cores=1) 

131 

132 executor = SimplePipelineExecutor.from_pipeline(pipeline, 

133 where=queryString, 

134 butler=butler, 

135 resources=resources) 

136 quanta = executor.run(register_dataset_types=registerDatasetTypes) 

137 

138 return len(quanta) 

139 

140 def _testFgcmMakeLut(self, instName, testName, nBand, i0Std, i0Recon, i10Std, i10Recon): 

141 """Test running of FgcmMakeLutTask 

142 

143 Parameters 

144 ---------- 

145 instName : `str` 

146 Short name of the instrument. 

147 testName : `str` 

148 Base name of the test collection. 

149 nBand : `int` 

150 Number of bands tested. 

151 i0Std : `np.ndarray' 

152 Values of i0Std to compare to. 

153 i10Std : `np.ndarray` 

154 Values of i10Std to compare to. 

155 i0Recon : `np.ndarray` 

156 Values of reconstructed i0 to compare to. 

157 i10Recon : `np.ndarray` 

158 Values of reconsntructed i10 to compare to. 

159 """ 

160 instCamel = instName.title() 

161 

162 configFiles = {'fgcmMakeLut': [os.path.join(ROOT, 

163 'config', 

164 f'fgcmMakeLut{instCamel}.py')]} 

165 outputCollection = f'{instName}/{testName}/lut' 

166 

167 self._runPipeline(self.repo, 

168 os.path.join(ROOT, 

169 'pipelines', 

170 f'fgcmMakeLut{instCamel}.yaml'), 

171 configFiles=configFiles, 

172 inputCollections=[f'{instName}/calib', f'{instName}/testdata'], 

173 outputCollection=outputCollection, 

174 registerDatasetTypes=True) 

175 

176 # Check output values 

177 butler = dafButler.Butler(self.repo) 

178 lutCat = butler.get('fgcmLookUpTable', 

179 collections=[outputCollection], 

180 instrument=instName) 

181 fgcmLut, lutIndexVals, lutStd = fgcmcal.utilities.translateFgcmLut(lutCat, {}) 

182 

183 self.assertEqual(nBand, len(lutIndexVals[0]['FILTERNAMES'])) 

184 

185 indices = fgcmLut.getIndices(np.arange(nBand, dtype=np.int32), 

186 np.zeros(nBand) + np.log(lutStd[0]['PWVSTD']), 

187 np.zeros(nBand) + lutStd[0]['O3STD'], 

188 np.zeros(nBand) + np.log(lutStd[0]['TAUSTD']), 

189 np.zeros(nBand) + lutStd[0]['ALPHASTD'], 

190 np.zeros(nBand) + 1./np.cos(np.radians(lutStd[0]['ZENITHSTD'])), 

191 np.zeros(nBand, dtype=np.int32), 

192 np.zeros(nBand) + lutStd[0]['PMBSTD']) 

193 i0 = fgcmLut.computeI0(np.zeros(nBand) + np.log(lutStd[0]['PWVSTD']), 

194 np.zeros(nBand) + lutStd[0]['O3STD'], 

195 np.zeros(nBand) + np.log(lutStd[0]['TAUSTD']), 

196 np.zeros(nBand) + lutStd[0]['ALPHASTD'], 

197 np.zeros(nBand) + 1./np.cos(np.radians(lutStd[0]['ZENITHSTD'])), 

198 np.zeros(nBand) + lutStd[0]['PMBSTD'], 

199 indices) 

200 

201 self.assertFloatsAlmostEqual(i0Recon, i0, msg='i0Recon', rtol=1e-5) 

202 

203 i1 = fgcmLut.computeI1(np.zeros(nBand) + np.log(lutStd[0]['PWVSTD']), 

204 np.zeros(nBand) + lutStd[0]['O3STD'], 

205 np.zeros(nBand) + np.log(lutStd[0]['TAUSTD']), 

206 np.zeros(nBand) + lutStd[0]['ALPHASTD'], 

207 np.zeros(nBand) + 1./np.cos(np.radians(lutStd[0]['ZENITHSTD'])), 

208 np.zeros(nBand) + lutStd[0]['PMBSTD'], 

209 indices) 

210 

211 self.assertFloatsAlmostEqual(i10Recon, i1/i0, msg='i10Recon', rtol=1e-5) 

212 

213 # Check that the standard atmosphere was output and non-zero. 

214 atmStd = butler.get('fgcm_standard_atmosphere', 

215 collections=[outputCollection], 

216 instrument=instName) 

217 bounds = atmStd.getWavelengthBounds() 

218 lambdas = np.linspace(bounds[0], bounds[1], 1000) 

219 tputs = atmStd.sampleAt(position=geom.Point2D(0.0, 0.0), wavelengths=lambdas) 

220 self.assertGreater(np.min(tputs), 0.0) 

221 

222 # Check that the standard passbands were output and non-zero. 

223 for physical_filter in fgcmLut.filterNames: 

224 passband = butler.get('fgcm_standard_passband', 

225 collections=[outputCollection], 

226 instrument=instName, 

227 physical_filter=physical_filter) 

228 tputs = passband.sampleAt(position=geom.Point2D(0.0, 0.0), wavelengths=lambdas) 

229 self.assertEqual(np.min(tputs), 0.0) 

230 self.assertGreater(np.max(tputs), 0.0) 

231 

232 def _testFgcmBuildStarsTable(self, instName, testName, queryString, visits, nStar, nObs, 

233 refcatCollection="refcats/gen2"): 

234 """Test running of FgcmBuildStarsTableTask 

235 

236 Parameters 

237 ---------- 

238 instName : `str` 

239 Short name of the instrument. 

240 testName : `str` 

241 Base name of the test collection. 

242 queryString : `str` 

243 Query to send to the pipetask. 

244 visits : `list` 

245 List of visits to calibrate. 

246 nStar : `int` 

247 Number of stars expected. 

248 nObs : `int` 

249 Number of observations of stars expected. 

250 refcatCollection : `str`, optional 

251 Name of reference catalog collection. 

252 """ 

253 instCamel = instName.title() 

254 

255 configFiles = {'fgcmBuildStarsTable': [os.path.join(ROOT, 

256 'config', 

257 f'fgcmBuildStarsTable{instCamel}.py')]} 

258 outputCollection = f'{instName}/{testName}/buildstars' 

259 

260 self._runPipeline(self.repo, 

261 os.path.join(ROOT, 

262 'pipelines', 

263 'fgcmBuildStarsTable%s.yaml' % (instCamel)), 

264 configFiles=configFiles, 

265 inputCollections=[f'{instName}/{testName}/lut', 

266 refcatCollection], 

267 outputCollection=outputCollection, 

268 queryString=queryString, 

269 registerDatasetTypes=True) 

270 

271 butler = dafButler.Butler(self.repo) 

272 

273 visitCat = butler.get('fgcmVisitCatalog', collections=[outputCollection], 

274 instrument=instName) 

275 self.assertEqual(len(visits), len(visitCat)) 

276 

277 starIds = butler.get('fgcmStarIds', collections=[outputCollection], 

278 instrument=instName) 

279 self.assertEqual(len(starIds), nStar) 

280 

281 starObs = butler.get('fgcmStarObservations', collections=[outputCollection], 

282 instrument=instName) 

283 self.assertEqual(len(starObs), nObs) 

284 

285 def _testFgcmBuildFromIsolatedStars(self, instName, testName, queryString, visits, nStar, nObs, 

286 refcatCollection="refcats/gen2"): 

287 """Test running of FgcmBuildFromIsolatedStarsTask. 

288 

289 Parameters 

290 ---------- 

291 instName : `str` 

292 Short name of the instrument. 

293 testName : `str` 

294 Base name of the test collection. 

295 queryString : `str` 

296 Query to send to the pipetask. 

297 visits : `list` 

298 List of visits to calibrate. 

299 nStar : `int` 

300 Number of stars expected. 

301 nObs : `int` 

302 Number of observations of stars expected. 

303 refcatCollection : `str`, optional 

304 Name of reference catalog collection. 

305 """ 

306 instCamel = instName.title() 

307 

308 configFiles = {'fgcmBuildFromIsolatedStars': [ 

309 os.path.join(ROOT, 

310 'config', 

311 f'fgcmBuildFromIsolatedStars{instCamel}.py') 

312 ]} 

313 outputCollection = f'{instName}/{testName}/buildstars' 

314 

315 self._runPipeline(self.repo, 

316 os.path.join(ROOT, 

317 'pipelines', 

318 'fgcmBuildFromIsolatedStars%s.yaml' % (instCamel)), 

319 configFiles=configFiles, 

320 inputCollections=[f'{instName}/{testName}/lut', 

321 refcatCollection], 

322 outputCollection=outputCollection, 

323 queryString=queryString, 

324 registerDatasetTypes=True) 

325 

326 butler = dafButler.Butler(self.repo) 

327 

328 visitCat = butler.get('fgcmVisitCatalog', collections=[outputCollection], 

329 instrument=instName) 

330 self.assertEqual(len(visits), len(visitCat)) 

331 

332 starIds = butler.get('fgcm_star_ids', collections=[outputCollection], 

333 instrument=instName) 

334 self.assertEqual(len(starIds), nStar) 

335 

336 starObs = butler.get('fgcm_star_observations', collections=[outputCollection], 

337 instrument=instName) 

338 self.assertEqual(len(starObs), nObs) 

339 

340 def _testFgcmFitCycle(self, instName, testName, cycleNumber, 

341 nZp, nGoodZp, nOkZp, nBadZp, nStdStars, nPlots, 

342 skipChecks=False, extraConfig=None): 

343 """Test running of FgcmFitCycleTask 

344 

345 Parameters 

346 ---------- 

347 instName : `str` 

348 Short name of the instrument. 

349 testName : `str` 

350 Base name of the test collection. 

351 cycleNumber : `int` 

352 Fit cycle number. 

353 nZp : `int` 

354 Number of zeropoints created by the task. 

355 nGoodZp : `int` 

356 Number of good (photometric) zeropoints created. 

357 nOkZp : `int` 

358 Number of constrained zeropoints (photometric or not). 

359 nBadZp : `int` 

360 Number of unconstrained (bad) zeropoints. 

361 nStdStars : `int` 

362 Number of standard stars produced. 

363 nPlots : `int` 

364 Number of plots produced. 

365 skipChecks : `bool`, optional 

366 Skip number checks, when running less-than-final cycle. 

367 extraConfig : `str`, optional 

368 Name of an extra config file to apply. 

369 """ 

370 instCamel = instName.title() 

371 

372 configFiles = {'fgcmFitCycle': [os.path.join(ROOT, 

373 'config', 

374 f'fgcmFitCycle{instCamel}.py')]} 

375 if extraConfig is not None: 

376 configFiles['fgcmFitCycle'].append(extraConfig) 

377 

378 outputCollection = f'{instName}/{testName}/fit' 

379 

380 if cycleNumber == 0: 

381 inputCollections = [f'{instName}/{testName}/buildstars'] 

382 else: 

383 # In these tests we are running the fit cycle task multiple 

384 # times into the same output collection. This code allows 

385 # us to find the correct chained input collections to use 

386 # so that we can both read from previous runs in the output 

387 # collection and write to a new run in the output collection. 

388 # Note that this behavior is handled automatically by the 

389 # pipetask command-line interface, but not by the python 

390 # API. 

391 butler = dafButler.Butler(self.repo) 

392 inputCollections = list(butler.registry.getCollectionChain(outputCollection)) 

393 

394 cwd = os.getcwd() 

395 runDir = os.path.join(self.testDir, testName) 

396 os.makedirs(runDir, exist_ok=True) 

397 os.chdir(runDir) 

398 

399 configOptions = {'fgcmFitCycle': 

400 {'cycleNumber': f'{cycleNumber}', 

401 'connections.previousCycleNumber': f'{cycleNumber - 1}', 

402 'connections.cycleNumber': f'{cycleNumber}'}} 

403 self._runPipeline(self.repo, 

404 os.path.join(ROOT, 

405 'pipelines', 

406 f'fgcmFitCycle{instCamel}.yaml'), 

407 configFiles=configFiles, 

408 inputCollections=inputCollections, 

409 outputCollection=outputCollection, 

410 configOptions=configOptions, 

411 registerDatasetTypes=True) 

412 

413 os.chdir(cwd) 

414 

415 if skipChecks: 

416 return 

417 

418 butler = dafButler.Butler(self.repo) 

419 

420 config = butler.get('fgcmFitCycle_config', collections=[outputCollection]) 

421 

422 # Check that the expected number of plots are there. 

423 plots = glob.glob(os.path.join(runDir, config.outfileBase 

424 + '_cycle%02d_plots/' % (cycleNumber) 

425 + '*.png')) 

426 self.assertEqual(len(plots), nPlots) 

427 

428 zps = butler.get('fgcmZeropoints%d' % (cycleNumber), 

429 collections=[outputCollection], 

430 instrument=instName) 

431 self.assertEqual(len(zps), nZp) 

432 

433 gd, = np.where(zps['fgcmFlag'] == 1) 

434 self.assertEqual(len(gd), nGoodZp) 

435 

436 ok, = np.where(zps['fgcmFlag'] < 16) 

437 self.assertEqual(len(ok), nOkZp) 

438 

439 bd, = np.where(zps['fgcmFlag'] >= 16) 

440 self.assertEqual(len(bd), nBadZp) 

441 

442 # Check that there are no illegal values with the ok zeropoints 

443 test, = np.where(zps['fgcmZpt'][gd] < -9000.0) 

444 self.assertEqual(len(test), 0) 

445 

446 stds = butler.get('fgcmStandardStars%d' % (cycleNumber), 

447 collections=[outputCollection], 

448 instrument=instName) 

449 

450 self.assertEqual(len(stds), nStdStars) 

451 

452 def _testFgcmOutputProducts(self, instName, testName, 

453 zpOffsets, testVisit, testCcd, testFilter, testBandIndex, 

454 testSrc=True): 

455 """Test running of FgcmOutputProductsTask. 

456 

457 Parameters 

458 ---------- 

459 instName : `str` 

460 Short name of the instrument. 

461 testName : `str` 

462 Base name of the test collection. 

463 zpOffsets : `np.ndarray` 

464 Zeropoint offsets expected. 

465 testVisit : `int` 

466 Visit id to check for round-trip computations. 

467 testCcd : `int` 

468 Ccd id to check for round-trip computations. 

469 testFilter : `str` 

470 Filtername for testVisit/testCcd. 

471 testBandIndex : `int` 

472 Band index for testVisit/testCcd. 

473 testSrc : `bool`, optional 

474 Test the source catalogs? (Only if available in dataset.) 

475 """ 

476 instCamel = instName.title() 

477 

478 configFiles = {'fgcmOutputProducts': [os.path.join(ROOT, 

479 'config', 

480 f'fgcmOutputProducts{instCamel}.py')]} 

481 inputCollection = f'{instName}/{testName}/fit' 

482 outputCollection = f'{instName}/{testName}/fit/output' 

483 

484 self._runPipeline(self.repo, 

485 os.path.join(ROOT, 

486 'pipelines', 

487 'fgcmOutputProducts%s.yaml' % (instCamel)), 

488 configFiles=configFiles, 

489 inputCollections=[inputCollection], 

490 outputCollection=outputCollection, 

491 registerDatasetTypes=True) 

492 

493 butler = dafButler.Butler(self.repo) 

494 offsetCat = butler.get('fgcmReferenceCalibrationOffsets', 

495 collections=[outputCollection], instrument=instName) 

496 offsets = offsetCat['offset'][:] 

497 self.assertFloatsAlmostEqual(offsets, zpOffsets, atol=1e-6) 

498 

499 config = butler.get('fgcmOutputProducts_config', 

500 collections=[outputCollection], instrument=instName) 

501 

502 rawStars = butler.get('fgcmStandardStars' + config.connections.cycleNumber, 

503 collections=[inputCollection], instrument=instName) 

504 

505 # Test the fgcm_photoCalib output 

506 zptCat = butler.get('fgcmZeropoints' + config.connections.cycleNumber, 

507 collections=[inputCollection], instrument=instName) 

508 

509 good = (zptCat['fgcmFlag'] < 16) 

510 bad = (zptCat['fgcmFlag'] >= 16) 

511 

512 # Read in all the calibrations, these should all be there 

513 # This test is simply to ensure that all the photoCalib files exist 

514 visits = np.unique(zptCat['visit'][good]) 

515 photoCalibDict = {} 

516 for visit in visits: 

517 expCat = butler.get('fgcmPhotoCalibCatalog', 

518 visit=visit, 

519 collections=[outputCollection], instrument=instName) 

520 for row in expCat: 

521 if row['visit'] == visit: 

522 photoCalibDict[(visit, row['id'])] = row.getPhotoCalib() 

523 

524 # Check that all of the good photocalibs are there. 

525 for rec in zptCat[good]: 

526 self.assertTrue((rec['visit'], rec['detector']) in photoCalibDict) 

527 

528 # Check that none of the bad photocalibs are there. 

529 for rec in zptCat[bad]: 

530 self.assertFalse((rec['visit'], rec['detector']) in photoCalibDict) 

531 

532 # We do round-trip value checking on just the final one (chosen arbitrarily) 

533 testCal = photoCalibDict[(testVisit, testCcd)] 

534 

535 if testSrc: 

536 src = butler.get('src', visit=int(testVisit), detector=int(testCcd), 

537 collections=[outputCollection], instrument=instName) 

538 

539 # Only test sources with positive flux 

540 gdSrc = (src['slot_CalibFlux_instFlux'] > 0.0) 

541 

542 # We need to apply the calibration offset to the fgcmzpt (which is internal 

543 # and doesn't know about that yet) 

544 testZpInd, = np.where((zptCat['visit'] == testVisit) 

545 & (zptCat['detector'] == testCcd)) 

546 fgcmZpt = (zptCat['fgcmZpt'][testZpInd] + offsets[testBandIndex] 

547 + zptCat['fgcmDeltaChrom'][testZpInd]) 

548 fgcmZptGrayErr = np.sqrt(zptCat['fgcmZptVar'][testZpInd]) 

549 

550 if config.doComposeWcsJacobian: 

551 # The raw zeropoint needs to be modified to know about the wcs jacobian 

552 refs = butler.registry.queryDatasets('camera', dimensions=['instrument'], 

553 collections=...) 

554 camera = butler.get(list(refs)[0]) 

555 approxPixelAreaFields = fgcmcal.utilities.computeApproxPixelAreaFields(camera) 

556 center = approxPixelAreaFields[testCcd].getBBox().getCenter() 

557 pixAreaCorr = approxPixelAreaFields[testCcd].evaluate(center) 

558 fgcmZpt += -2.5*np.log10(pixAreaCorr) 

559 

560 # This is the magnitude through the mean calibration 

561 photoCalMeanCalMags = np.zeros(gdSrc.sum()) 

562 # This is the magnitude through the full focal-plane variable mags 

563 photoCalMags = np.zeros_like(photoCalMeanCalMags) 

564 # This is the magnitude with the FGCM (central-ccd) zeropoint 

565 zptMeanCalMags = np.zeros_like(photoCalMeanCalMags) 

566 

567 for i, rec in enumerate(src[gdSrc]): 

568 photoCalMeanCalMags[i] = testCal.instFluxToMagnitude(rec['slot_CalibFlux_instFlux']) 

569 photoCalMags[i] = testCal.instFluxToMagnitude(rec['slot_CalibFlux_instFlux'], 

570 rec.getCentroid()) 

571 zptMeanCalMags[i] = fgcmZpt[0] - 2.5*np.log10(rec['slot_CalibFlux_instFlux']) 

572 

573 # These should be very close but some tiny differences because the fgcm value 

574 # is defined at the center of the bbox, and the photoCal is the mean over the box 

575 self.assertFloatsAlmostEqual(photoCalMeanCalMags, 

576 zptMeanCalMags, rtol=1e-6) 

577 # These should be roughly equal, but not precisely because of the focal-plane 

578 # variation. However, this is a useful sanity check for something going totally 

579 # wrong. 

580 self.assertFloatsAlmostEqual(photoCalMeanCalMags, 

581 photoCalMags, rtol=1e-2) 

582 

583 # The next test compares the "FGCM standard magnitudes" (which are output 

584 # from the fgcm code itself) to the "calibrated magnitudes" that are 

585 # obtained from running photoCalib.calibrateCatalog() on the original 

586 # src catalogs. This summary comparison ensures that using photoCalibs 

587 # yields the same results as what FGCM is computing internally. 

588 # Note that we additionally need to take into account the post-processing 

589 # offsets used in the tests. 

590 

591 # For decent statistics, we are matching all the sources from one visit 

592 # (multiple ccds) 

593 whereClause = f"instrument='{instName:s}' and visit={testVisit:d}" 

594 srcRefs = butler.registry.queryDatasets('src', dimensions=['visit'], 

595 collections='%s/testdata' % (instName), 

596 where=whereClause, 

597 findFirst=True) 

598 photoCals = [] 

599 for srcRef in srcRefs: 

600 photoCals.append(photoCalibDict[(testVisit, srcRef.dataId['detector'])]) 

601 

602 matchMag, matchDelta = self._getMatchedVisitCat(butler, srcRefs, photoCals, 

603 rawStars, testBandIndex, offsets) 

604 

605 st = np.argsort(matchMag) 

606 # Compare the brightest 25% of stars. No matter the setting of 

607 # deltaMagBkgOffsetPercentile, we want to ensure that these stars 

608 # match on average. 

609 brightest, = np.where(matchMag < matchMag[st[int(0.25*st.size)]]) 

610 self.assertFloatsAlmostEqual(np.median(matchDelta[brightest]), 0.0, atol=0.002) 

611 

612 # And the photoCal error is just the zeropoint gray error 

613 self.assertFloatsAlmostEqual(testCal.getCalibrationErr(), 

614 (np.log(10.0)/2.5)*testCal.getCalibrationMean()*fgcmZptGrayErr) 

615 

616 # Test the transmission output 

617 visitCatalog = butler.get('fgcmVisitCatalog', collections=[inputCollection], 

618 instrument=instName) 

619 lutCat = butler.get('fgcmLookUpTable', collections=[inputCollection], 

620 instrument=instName) 

621 

622 testTrans = butler.get('transmission_atmosphere_fgcm', 

623 visit=visitCatalog[0]['visit'], 

624 collections=[outputCollection], instrument=instName) 

625 testResp = testTrans.sampleAt(position=geom.Point2D(0, 0), 

626 wavelengths=lutCat[0]['atmLambda']) 

627 

628 # The test fit is performed with the atmosphere parameters frozen 

629 # (freezeStdAtmosphere = True). Thus the only difference between 

630 # these output atmospheres and the standard is the different 

631 # airmass. Furthermore, this is a very rough comparison because 

632 # the look-up table is computed with very coarse sampling for faster 

633 # testing. 

634 

635 # To account for overall throughput changes, we scale by the median ratio, 

636 # we only care about the shape 

637 ratio = np.median(testResp/lutCat[0]['atmStdTrans']) 

638 self.assertFloatsAlmostEqual(testResp/ratio, lutCat[0]['atmStdTrans'], atol=0.2) 

639 

640 # The second should be close to the first, but there is the airmass 

641 # difference so they aren't identical. 

642 testTrans2 = butler.get('transmission_atmosphere_fgcm', 

643 visit=visitCatalog[1]['visit'], 

644 collections=[outputCollection], instrument=instName) 

645 testResp2 = testTrans2.sampleAt(position=geom.Point2D(0, 0), 

646 wavelengths=lutCat[0]['atmLambda']) 

647 

648 # As above, we scale by the ratio to compare the shape of the curve. 

649 ratio = np.median(testResp/testResp2) 

650 self.assertFloatsAlmostEqual(testResp/ratio, testResp2, atol=0.04) 

651 

652 def _testFgcmMultiFit(self, instName, testName, queryString, visits, zpOffsets, 

653 refcatCollection="refcats/gen2"): 

654 """Test running the full pipeline with multiple fit cycles. 

655 

656 Parameters 

657 ---------- 

658 instName : `str` 

659 Short name of the instrument. 

660 testName : `str` 

661 Base name of the test collection. 

662 queryString : `str` 

663 Query to send to the pipetask. 

664 visits : `list` 

665 List of visits to calibrate. 

666 zpOffsets : `np.ndarray` 

667 Zeropoint offsets expected. 

668 refcatCollection : `str`, optional 

669 Name of reference catalog collection. 

670 """ 

671 instCamel = instName.title() 

672 

673 configFiles = {'fgcmBuildFromIsolatedStars': [ 

674 os.path.join(ROOT, 

675 'config', 

676 f'fgcmBuildFromIsolatedStars{instCamel}.py' 

677 )], 

678 'fgcmFitCycle': [os.path.join(ROOT, 

679 'config', 

680 f'fgcmFitCycle{instCamel}.py')], 

681 'fgcmOutputProducts': [os.path.join(ROOT, 

682 'config', 

683 f'fgcmOutputProducts{instCamel}.py')]} 

684 outputCollection = f'{instName}/{testName}/unified' 

685 

686 cwd = os.getcwd() 

687 runDir = os.path.join(self.testDir, testName) 

688 os.makedirs(runDir) 

689 os.chdir(runDir) 

690 

691 self._runPipeline(self.repo, 

692 os.path.join(ROOT, 

693 'pipelines', 

694 f'fgcmFullPipeline{instCamel}.yaml'), 

695 configFiles=configFiles, 

696 inputCollections=[f'{instName}/{testName}/lut', 

697 refcatCollection], 

698 outputCollection=outputCollection, 

699 queryString=queryString, 

700 registerDatasetTypes=True) 

701 

702 os.chdir(cwd) 

703 

704 butler = dafButler.Butler(self.repo) 

705 

706 offsetCat = butler.get('fgcmReferenceCalibrationOffsets', 

707 collections=[outputCollection], instrument=instName) 

708 offsets = offsetCat['offset'][:] 

709 self.assertFloatsAlmostEqual(offsets, zpOffsets, atol=1e-6) 

710 

711 def _getMatchedVisitCat(self, butler, srcHandles, photoCals, 

712 rawStars, bandIndex, offsets): 

713 """ 

714 Get a list of matched magnitudes and deltas from calibrated src catalogs. 

715 

716 Parameters 

717 ---------- 

718 butler : `lsst.daf.butler.Butler` 

719 srcHandles : `list` 

720 Handles of source catalogs. 

721 photoCals : `list` 

722 photoCalib objects, matched to srcHandles. 

723 rawStars : `lsst.afw.table.SourceCatalog` 

724 Fgcm standard stars. 

725 bandIndex : `int` 

726 Index of the band for the source catalogs. 

727 offsets : `np.ndarray` 

728 Testing calibration offsets to apply to rawStars. 

729 

730 Returns 

731 ------- 

732 matchMag : `np.ndarray` 

733 Array of matched magnitudes. 

734 matchDelta : `np.ndarray` 

735 Array of matched deltas between src and standard stars. 

736 """ 

737 matcher = esutil.htm.Matcher(11, np.rad2deg(rawStars['coord_ra']), 

738 np.rad2deg(rawStars['coord_dec'])) 

739 

740 matchDelta = None 

741 for srcHandle, photoCal in zip(srcHandles, photoCals): 

742 src = butler.get(srcHandle) 

743 src = photoCal.calibrateCatalog(src) 

744 

745 gdSrc, = np.where(np.nan_to_num(src['slot_CalibFlux_flux']) > 0.0) 

746 

747 matches = matcher.match(np.rad2deg(src['coord_ra'][gdSrc]), 

748 np.rad2deg(src['coord_dec'][gdSrc]), 

749 1./3600., maxmatch=1) 

750 

751 srcMag = src['slot_CalibFlux_mag'][gdSrc][matches[0]] 

752 # Apply offset here to the catalog mag 

753 catMag = rawStars['mag_std_noabs'][matches[1]][:, bandIndex] + offsets[bandIndex] 

754 delta = srcMag - catMag 

755 if matchDelta is None: 

756 matchDelta = delta 

757 matchMag = catMag 

758 else: 

759 matchDelta = np.append(matchDelta, delta) 

760 matchMag = np.append(matchMag, catMag) 

761 

762 return matchMag, matchDelta 

763 

764 def _testFgcmCalibrateTract(self, instName, testName, visits, tract, skymapName, 

765 rawRepeatability, filterNCalibMap): 

766 """Test running of FgcmCalibrateTractTask 

767 

768 Parameters 

769 ---------- 

770 instName : `str` 

771 Short name of the instrument. 

772 testName : `str` 

773 Base name of the test collection. 

774 visits : `list` 

775 List of visits to calibrate. 

776 tract : `int` 

777 Tract number. 

778 skymapName : `str` 

779 Name of the sky map. 

780 rawRepeatability : `np.array` 

781 Expected raw repeatability after convergence. 

782 Length should be number of bands. 

783 filterNCalibMap : `dict` 

784 Mapping from filter name to number of photoCalibs created. 

785 """ 

786 instCamel = instName.title() 

787 

788 configFiles = {'fgcmCalibrateTractTable': 

789 [os.path.join(ROOT, 

790 'config', 

791 f'fgcmCalibrateTractTable{instCamel}.py')]} 

792 

793 outputCollection = f'{instName}/{testName}/tract' 

794 

795 inputCollections = [f'{instName}/{testName}/lut', 

796 'refcats/gen2'] 

797 

798 queryString = f"tract={tract:d} and skymap='{skymapName:s}'" 

799 

800 self._runPipeline(self.repo, 

801 os.path.join(ROOT, 

802 'pipelines', 

803 f'fgcmCalibrateTractTable{instCamel:s}.yaml'), 

804 queryString=queryString, 

805 configFiles=configFiles, 

806 inputCollections=inputCollections, 

807 outputCollection=outputCollection, 

808 registerDatasetTypes=True) 

809 

810 butler = dafButler.Butler(self.repo) 

811 

812 whereClause = f"instrument='{instName:s}' and tract={tract:d} and skymap='{skymapName:s}'" 

813 

814 repRefs = butler.registry.queryDatasets('fgcmRawRepeatability', 

815 dimensions=['tract'], 

816 collections=outputCollection, 

817 where=whereClause) 

818 

819 repeatabilityCat = butler.get(list(repRefs)[0]) 

820 repeatability = repeatabilityCat['rawRepeatability'][:] 

821 self.assertFloatsAlmostEqual(repeatability, rawRepeatability, atol=4e-6) 

822 

823 # Check that the number of photoCalib objects in each filter are what we expect 

824 for filterName in filterNCalibMap.keys(): 

825 whereClause = (f"instrument='{instName:s}' and tract={tract:d} and " 

826 f"physical_filter='{filterName:s}' and skymap='{skymapName:s}'") 

827 

828 refs = butler.registry.queryDatasets('fgcmPhotoCalibTractCatalog', 

829 dimensions=['tract', 'physical_filter'], 

830 collections=outputCollection, 

831 where=whereClause) 

832 

833 count = 0 

834 for ref in set(refs): 

835 expCat = butler.get(ref) 

836 test, = np.where((expCat['visit'] > 0) & (expCat['id'] >= 0)) 

837 count += test.size 

838 

839 self.assertEqual(count, filterNCalibMap[filterName]) 

840 

841 # Check that every visit got a transmission 

842 for visit in visits: 

843 whereClause = (f"instrument='{instName:s}' and tract={tract:d} and " 

844 f"visit={visit:d} and skymap='{skymapName:s}'") 

845 refs = butler.registry.queryDatasets('transmission_atmosphere_fgcm_tract', 

846 dimensions=['tract', 'visit'], 

847 collections=outputCollection, 

848 where=whereClause) 

849 self.assertEqual(len(set(refs)), 1) 

850 

851 @classmethod 

852 def tearDownClass(cls): 

853 """Tear down and clear directories. 

854 """ 

855 if os.path.exists(cls.testDir): 

856 shutil.rmtree(cls.testDir, True)