Coverage for tests/fgcmcalTestBase.py: 10%

248 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-03-22 03:56 -0700

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 root=repo, 

135 butler=butler, 

136 resources=resources) 

137 quanta = executor.run(register_dataset_types=registerDatasetTypes) 

138 

139 return len(quanta) 

140 

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

142 """Test running of FgcmMakeLutTask 

143 

144 Parameters 

145 ---------- 

146 instName : `str` 

147 Short name of the instrument. 

148 testName : `str` 

149 Base name of the test collection. 

150 nBand : `int` 

151 Number of bands tested. 

152 i0Std : `np.ndarray' 

153 Values of i0Std to compare to. 

154 i10Std : `np.ndarray` 

155 Values of i10Std to compare to. 

156 i0Recon : `np.ndarray` 

157 Values of reconstructed i0 to compare to. 

158 i10Recon : `np.ndarray` 

159 Values of reconsntructed i10 to compare to. 

160 """ 

161 instCamel = instName.title() 

162 

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

164 'config', 

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

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

167 

168 self._runPipeline(self.repo, 

169 os.path.join(ROOT, 

170 'pipelines', 

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

172 configFiles=configFiles, 

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

174 outputCollection=outputCollection, 

175 registerDatasetTypes=True) 

176 

177 # Check output values 

178 butler = dafButler.Butler(self.repo) 

179 lutCat = butler.get('fgcmLookUpTable', 

180 collections=[outputCollection], 

181 instrument=instName) 

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

183 

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

185 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

200 indices) 

201 

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

203 

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

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

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

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

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

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

210 indices) 

211 

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

213 

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

215 atmStd = butler.get('fgcm_standard_atmosphere', 

216 collections=[outputCollection], 

217 instrument=instName) 

218 bounds = atmStd.getWavelengthBounds() 

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

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

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

222 

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

224 for physical_filter in fgcmLut.filterNames: 

225 passband = butler.get('fgcm_standard_passband', 

226 collections=[outputCollection], 

227 instrument=instName, 

228 physical_filter=physical_filter) 

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

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

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

232 

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

234 refcatCollection="refcats/gen2"): 

235 """Test running of FgcmBuildStarsTableTask 

236 

237 Parameters 

238 ---------- 

239 instName : `str` 

240 Short name of the instrument. 

241 testName : `str` 

242 Base name of the test collection. 

243 queryString : `str` 

244 Query to send to the pipetask. 

245 visits : `list` 

246 List of visits to calibrate. 

247 nStar : `int` 

248 Number of stars expected. 

249 nObs : `int` 

250 Number of observations of stars expected. 

251 refcatCollection : `str`, optional 

252 Name of reference catalog collection. 

253 """ 

254 instCamel = instName.title() 

255 

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

257 'config', 

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

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

260 

261 self._runPipeline(self.repo, 

262 os.path.join(ROOT, 

263 'pipelines', 

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

265 configFiles=configFiles, 

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

267 refcatCollection], 

268 outputCollection=outputCollection, 

269 queryString=queryString, 

270 registerDatasetTypes=True) 

271 

272 butler = dafButler.Butler(self.repo) 

273 

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

275 instrument=instName) 

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

277 

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

279 instrument=instName) 

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

281 

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

283 instrument=instName) 

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

285 

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

287 refcatCollection="refcats/gen2"): 

288 """Test running of FgcmBuildFromIsolatedStarsTask. 

289 

290 Parameters 

291 ---------- 

292 instName : `str` 

293 Short name of the instrument. 

294 testName : `str` 

295 Base name of the test collection. 

296 queryString : `str` 

297 Query to send to the pipetask. 

298 visits : `list` 

299 List of visits to calibrate. 

300 nStar : `int` 

301 Number of stars expected. 

302 nObs : `int` 

303 Number of observations of stars expected. 

304 refcatCollection : `str`, optional 

305 Name of reference catalog collection. 

306 """ 

307 instCamel = instName.title() 

308 

309 configFiles = {'fgcmBuildFromIsolatedStars': [ 

310 os.path.join(ROOT, 

311 'config', 

312 f'fgcmBuildFromIsolatedStars{instCamel}.py') 

313 ]} 

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

315 

316 self._runPipeline(self.repo, 

317 os.path.join(ROOT, 

318 'pipelines', 

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

320 configFiles=configFiles, 

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

322 refcatCollection], 

323 outputCollection=outputCollection, 

324 queryString=queryString, 

325 registerDatasetTypes=True) 

326 

327 butler = dafButler.Butler(self.repo) 

328 

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

330 instrument=instName) 

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

332 

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

334 instrument=instName) 

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

336 

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

338 instrument=instName) 

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

340 

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

342 nZp, nGoodZp, nOkZp, nBadZp, nStdStars, nPlots, 

343 skipChecks=False, extraConfig=None): 

344 """Test running of FgcmFitCycleTask 

345 

346 Parameters 

347 ---------- 

348 instName : `str` 

349 Short name of the instrument. 

350 testName : `str` 

351 Base name of the test collection. 

352 cycleNumber : `int` 

353 Fit cycle number. 

354 nZp : `int` 

355 Number of zeropoints created by the task. 

356 nGoodZp : `int` 

357 Number of good (photometric) zeropoints created. 

358 nOkZp : `int` 

359 Number of constrained zeropoints (photometric or not). 

360 nBadZp : `int` 

361 Number of unconstrained (bad) zeropoints. 

362 nStdStars : `int` 

363 Number of standard stars produced. 

364 nPlots : `int` 

365 Number of plots produced. 

366 skipChecks : `bool`, optional 

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

368 extraConfig : `str`, optional 

369 Name of an extra config file to apply. 

370 """ 

371 instCamel = instName.title() 

372 

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

374 'config', 

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

376 if extraConfig is not None: 

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

378 

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

380 

381 if cycleNumber == 0: 

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

383 else: 

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

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

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

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

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

389 # Note that this behavior is handled automatically by the 

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

391 # API. 

392 butler = dafButler.Butler(self.repo) 

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

394 

395 cwd = os.getcwd() 

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

397 os.makedirs(runDir, exist_ok=True) 

398 os.chdir(runDir) 

399 

400 configOptions = {'fgcmFitCycle': 

401 {'cycleNumber': f'{cycleNumber}', 

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

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

404 self._runPipeline(self.repo, 

405 os.path.join(ROOT, 

406 'pipelines', 

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

408 configFiles=configFiles, 

409 inputCollections=inputCollections, 

410 outputCollection=outputCollection, 

411 configOptions=configOptions, 

412 registerDatasetTypes=True) 

413 

414 os.chdir(cwd) 

415 

416 if skipChecks: 

417 return 

418 

419 butler = dafButler.Butler(self.repo) 

420 

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

422 

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

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

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

426 + '*.png')) 

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

428 

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

430 collections=[outputCollection], 

431 instrument=instName) 

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

433 

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

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

436 

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

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

439 

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

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

442 

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

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

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

446 

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

448 collections=[outputCollection], 

449 instrument=instName) 

450 

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

452 

453 def _testFgcmOutputProducts(self, instName, testName, 

454 zpOffsets, testVisit, testCcd, testFilter, testBandIndex, 

455 testSrc=True): 

456 """Test running of FgcmOutputProductsTask. 

457 

458 Parameters 

459 ---------- 

460 instName : `str` 

461 Short name of the instrument. 

462 testName : `str` 

463 Base name of the test collection. 

464 zpOffsets : `np.ndarray` 

465 Zeropoint offsets expected. 

466 testVisit : `int` 

467 Visit id to check for round-trip computations. 

468 testCcd : `int` 

469 Ccd id to check for round-trip computations. 

470 testFilter : `str` 

471 Filtername for testVisit/testCcd. 

472 testBandIndex : `int` 

473 Band index for testVisit/testCcd. 

474 testSrc : `bool`, optional 

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

476 """ 

477 instCamel = instName.title() 

478 

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

480 'config', 

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

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

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

484 

485 self._runPipeline(self.repo, 

486 os.path.join(ROOT, 

487 'pipelines', 

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

489 configFiles=configFiles, 

490 inputCollections=[inputCollection], 

491 outputCollection=outputCollection, 

492 registerDatasetTypes=True) 

493 

494 butler = dafButler.Butler(self.repo) 

495 offsetCat = butler.get('fgcmReferenceCalibrationOffsets', 

496 collections=[outputCollection], instrument=instName) 

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

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

499 

500 config = butler.get('fgcmOutputProducts_config', 

501 collections=[outputCollection], instrument=instName) 

502 

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

504 collections=[inputCollection], instrument=instName) 

505 

506 # Test the fgcm_photoCalib output 

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

508 collections=[inputCollection], instrument=instName) 

509 

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

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

512 

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

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

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

516 photoCalibDict = {} 

517 for visit in visits: 

518 expCat = butler.get('fgcmPhotoCalibCatalog', 

519 visit=visit, 

520 collections=[outputCollection], instrument=instName) 

521 for row in expCat: 

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

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

524 

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

526 for rec in zptCat[good]: 

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

528 

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

530 for rec in zptCat[bad]: 

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

532 

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

534 testCal = photoCalibDict[(testVisit, testCcd)] 

535 

536 if testSrc: 

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

538 collections=[outputCollection], instrument=instName) 

539 

540 # Only test sources with positive flux 

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

542 

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

544 # and doesn't know about that yet) 

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

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

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

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

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

550 

551 if config.doComposeWcsJacobian: 

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

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

554 collections=...) 

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

556 approxPixelAreaFields = fgcmcal.utilities.computeApproxPixelAreaFields(camera) 

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

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

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

560 

561 # This is the magnitude through the mean calibration 

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

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

564 photoCalMags = np.zeros_like(photoCalMeanCalMags) 

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

566 zptMeanCalMags = np.zeros_like(photoCalMeanCalMags) 

567 

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

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

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

571 rec.getCentroid()) 

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

573 

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

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

576 self.assertFloatsAlmostEqual(photoCalMeanCalMags, 

577 zptMeanCalMags, rtol=1e-6) 

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

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

580 # wrong. 

581 self.assertFloatsAlmostEqual(photoCalMeanCalMags, 

582 photoCalMags, rtol=1e-2) 

583 

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

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

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

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

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

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

590 # offsets used in the tests. 

591 

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

593 # (multiple ccds) 

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

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

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

597 where=whereClause, 

598 findFirst=True) 

599 photoCals = [] 

600 for srcRef in srcRefs: 

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

602 

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

604 rawStars, testBandIndex, offsets) 

605 

606 st = np.argsort(matchMag) 

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

608 # deltaMagBkgOffsetPercentile, we want to ensure that these stars 

609 # match on average. 

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

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

612 

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

614 self.assertFloatsAlmostEqual(testCal.getCalibrationErr(), 

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

616 

617 # Test the transmission output 

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

619 instrument=instName) 

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

621 instrument=instName) 

622 

623 testTrans = butler.get('transmission_atmosphere_fgcm', 

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

625 collections=[outputCollection], instrument=instName) 

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

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

628 

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

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

631 # these output atmospheres and the standard is the different 

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

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

634 # testing. 

635 

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

637 # we only care about the shape 

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

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

640 

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

642 # difference so they aren't identical. 

643 testTrans2 = butler.get('transmission_atmosphere_fgcm', 

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

645 collections=[outputCollection], instrument=instName) 

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

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

648 

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

650 ratio = np.median(testResp/testResp2) 

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

652 

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

654 refcatCollection="refcats/gen2"): 

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

656 

657 Parameters 

658 ---------- 

659 instName : `str` 

660 Short name of the instrument. 

661 testName : `str` 

662 Base name of the test collection. 

663 queryString : `str` 

664 Query to send to the pipetask. 

665 visits : `list` 

666 List of visits to calibrate. 

667 zpOffsets : `np.ndarray` 

668 Zeropoint offsets expected. 

669 refcatCollection : `str`, optional 

670 Name of reference catalog collection. 

671 """ 

672 instCamel = instName.title() 

673 

674 configFiles = {'fgcmBuildFromIsolatedStars': [ 

675 os.path.join(ROOT, 

676 'config', 

677 f'fgcmBuildFromIsolatedStars{instCamel}.py' 

678 )], 

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

680 'config', 

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

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

683 'config', 

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

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

686 

687 cwd = os.getcwd() 

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

689 os.makedirs(runDir) 

690 os.chdir(runDir) 

691 

692 self._runPipeline(self.repo, 

693 os.path.join(ROOT, 

694 'pipelines', 

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

696 configFiles=configFiles, 

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

698 refcatCollection], 

699 outputCollection=outputCollection, 

700 queryString=queryString, 

701 registerDatasetTypes=True) 

702 

703 os.chdir(cwd) 

704 

705 butler = dafButler.Butler(self.repo) 

706 

707 offsetCat = butler.get('fgcmReferenceCalibrationOffsets', 

708 collections=[outputCollection], instrument=instName) 

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

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

711 

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

713 rawStars, bandIndex, offsets): 

714 """ 

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

716 

717 Parameters 

718 ---------- 

719 butler : `lsst.daf.butler.Butler` 

720 srcHandles : `list` 

721 Handles of source catalogs. 

722 photoCals : `list` 

723 photoCalib objects, matched to srcHandles. 

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

725 Fgcm standard stars. 

726 bandIndex : `int` 

727 Index of the band for the source catalogs. 

728 offsets : `np.ndarray` 

729 Testing calibration offsets to apply to rawStars. 

730 

731 Returns 

732 ------- 

733 matchMag : `np.ndarray` 

734 Array of matched magnitudes. 

735 matchDelta : `np.ndarray` 

736 Array of matched deltas between src and standard stars. 

737 """ 

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

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

740 

741 matchDelta = None 

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

743 src = butler.get(srcHandle) 

744 src = photoCal.calibrateCatalog(src) 

745 

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

747 

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

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

750 1./3600., maxmatch=1) 

751 

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

753 # Apply offset here to the catalog mag 

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

755 delta = srcMag - catMag 

756 if matchDelta is None: 

757 matchDelta = delta 

758 matchMag = catMag 

759 else: 

760 matchDelta = np.append(matchDelta, delta) 

761 matchMag = np.append(matchMag, catMag) 

762 

763 return matchMag, matchDelta 

764 

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

766 rawRepeatability, filterNCalibMap): 

767 """Test running of FgcmCalibrateTractTask 

768 

769 Parameters 

770 ---------- 

771 instName : `str` 

772 Short name of the instrument. 

773 testName : `str` 

774 Base name of the test collection. 

775 visits : `list` 

776 List of visits to calibrate. 

777 tract : `int` 

778 Tract number. 

779 skymapName : `str` 

780 Name of the sky map. 

781 rawRepeatability : `np.array` 

782 Expected raw repeatability after convergence. 

783 Length should be number of bands. 

784 filterNCalibMap : `dict` 

785 Mapping from filter name to number of photoCalibs created. 

786 """ 

787 instCamel = instName.title() 

788 

789 configFiles = {'fgcmCalibrateTractTable': 

790 [os.path.join(ROOT, 

791 'config', 

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

793 

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

795 

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

797 'refcats/gen2'] 

798 

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

800 

801 self._runPipeline(self.repo, 

802 os.path.join(ROOT, 

803 'pipelines', 

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

805 queryString=queryString, 

806 configFiles=configFiles, 

807 inputCollections=inputCollections, 

808 outputCollection=outputCollection, 

809 registerDatasetTypes=True) 

810 

811 butler = dafButler.Butler(self.repo) 

812 

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

814 

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

816 dimensions=['tract'], 

817 collections=outputCollection, 

818 where=whereClause) 

819 

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

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

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

823 

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

825 for filterName in filterNCalibMap.keys(): 

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

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

828 

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

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

831 collections=outputCollection, 

832 where=whereClause) 

833 

834 count = 0 

835 for ref in set(refs): 

836 expCat = butler.get(ref) 

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

838 count += test.size 

839 

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

841 

842 # Check that every visit got a transmission 

843 for visit in visits: 

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

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

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

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

848 collections=outputCollection, 

849 where=whereClause) 

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

851 

852 @classmethod 

853 def tearDownClass(cls): 

854 """Tear down and clear directories. 

855 """ 

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

857 shutil.rmtree(cls.testDir, True)