Coverage for tests/fgcmcalTestBase.py: 10%

247 statements  

« prev     ^ index     » next       coverage.py v7.3.1, created at 2023-09-28 12:02 +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 

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 executor = SimplePipelineExecutor.from_pipeline(pipeline, 

131 where=queryString, 

132 root=repo, 

133 butler=butler) 

134 quanta = executor.run(register_dataset_types=registerDatasetTypes) 

135 

136 return len(quanta) 

137 

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

139 """Test running of FgcmMakeLutTask 

140 

141 Parameters 

142 ---------- 

143 instName : `str` 

144 Short name of the instrument. 

145 testName : `str` 

146 Base name of the test collection. 

147 nBand : `int` 

148 Number of bands tested. 

149 i0Std : `np.ndarray' 

150 Values of i0Std to compare to. 

151 i10Std : `np.ndarray` 

152 Values of i10Std to compare to. 

153 i0Recon : `np.ndarray` 

154 Values of reconstructed i0 to compare to. 

155 i10Recon : `np.ndarray` 

156 Values of reconsntructed i10 to compare to. 

157 """ 

158 instCamel = instName.title() 

159 

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

161 'config', 

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

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

164 

165 self._runPipeline(self.repo, 

166 os.path.join(ROOT, 

167 'pipelines', 

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

169 configFiles=configFiles, 

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

171 outputCollection=outputCollection, 

172 registerDatasetTypes=True) 

173 

174 # Check output values 

175 butler = dafButler.Butler(self.repo) 

176 lutCat = butler.get('fgcmLookUpTable', 

177 collections=[outputCollection], 

178 instrument=instName) 

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

180 

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

182 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

197 indices) 

198 

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

200 

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

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

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

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

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

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

207 indices) 

208 

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

210 

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

212 atmStd = butler.get('fgcm_standard_atmosphere', 

213 collections=[outputCollection], 

214 instrument=instName) 

215 bounds = atmStd.getWavelengthBounds() 

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

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

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

219 

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

221 for physical_filter in fgcmLut.filterNames: 

222 passband = butler.get('fgcm_standard_passband', 

223 collections=[outputCollection], 

224 instrument=instName, 

225 physical_filter=physical_filter) 

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

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

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

229 

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

231 refcatCollection="refcats/gen2"): 

232 """Test running of FgcmBuildStarsTableTask 

233 

234 Parameters 

235 ---------- 

236 instName : `str` 

237 Short name of the instrument. 

238 testName : `str` 

239 Base name of the test collection. 

240 queryString : `str` 

241 Query to send to the pipetask. 

242 visits : `list` 

243 List of visits to calibrate. 

244 nStar : `int` 

245 Number of stars expected. 

246 nObs : `int` 

247 Number of observations of stars expected. 

248 refcatCollection : `str`, optional 

249 Name of reference catalog collection. 

250 """ 

251 instCamel = instName.title() 

252 

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

254 'config', 

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

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

257 

258 self._runPipeline(self.repo, 

259 os.path.join(ROOT, 

260 'pipelines', 

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

262 configFiles=configFiles, 

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

264 refcatCollection], 

265 outputCollection=outputCollection, 

266 queryString=queryString, 

267 registerDatasetTypes=True) 

268 

269 butler = dafButler.Butler(self.repo) 

270 

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

272 instrument=instName) 

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

274 

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

276 instrument=instName) 

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

278 

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

280 instrument=instName) 

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

282 

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

284 refcatCollection="refcats/gen2"): 

285 """Test running of FgcmBuildFromIsolatedStarsTask. 

286 

287 Parameters 

288 ---------- 

289 instName : `str` 

290 Short name of the instrument. 

291 testName : `str` 

292 Base name of the test collection. 

293 queryString : `str` 

294 Query to send to the pipetask. 

295 visits : `list` 

296 List of visits to calibrate. 

297 nStar : `int` 

298 Number of stars expected. 

299 nObs : `int` 

300 Number of observations of stars expected. 

301 refcatCollection : `str`, optional 

302 Name of reference catalog collection. 

303 """ 

304 instCamel = instName.title() 

305 

306 configFiles = {'fgcmBuildFromIsolatedStars': [ 

307 os.path.join(ROOT, 

308 'config', 

309 f'fgcmBuildFromIsolatedStars{instCamel}.py') 

310 ]} 

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

312 

313 self._runPipeline(self.repo, 

314 os.path.join(ROOT, 

315 'pipelines', 

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

317 configFiles=configFiles, 

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

319 refcatCollection], 

320 outputCollection=outputCollection, 

321 queryString=queryString, 

322 registerDatasetTypes=True) 

323 

324 butler = dafButler.Butler(self.repo) 

325 

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

327 instrument=instName) 

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

329 

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

331 instrument=instName) 

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

333 

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

335 instrument=instName) 

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

337 

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

339 nZp, nGoodZp, nOkZp, nBadZp, nStdStars, nPlots, 

340 skipChecks=False, extraConfig=None): 

341 """Test running of FgcmFitCycleTask 

342 

343 Parameters 

344 ---------- 

345 instName : `str` 

346 Short name of the instrument. 

347 testName : `str` 

348 Base name of the test collection. 

349 cycleNumber : `int` 

350 Fit cycle number. 

351 nZp : `int` 

352 Number of zeropoints created by the task. 

353 nGoodZp : `int` 

354 Number of good (photometric) zeropoints created. 

355 nOkZp : `int` 

356 Number of constrained zeropoints (photometric or not). 

357 nBadZp : `int` 

358 Number of unconstrained (bad) zeropoints. 

359 nStdStars : `int` 

360 Number of standard stars produced. 

361 nPlots : `int` 

362 Number of plots produced. 

363 skipChecks : `bool`, optional 

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

365 extraConfig : `str`, optional 

366 Name of an extra config file to apply. 

367 """ 

368 instCamel = instName.title() 

369 

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

371 'config', 

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

373 if extraConfig is not None: 

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

375 

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

377 

378 if cycleNumber == 0: 

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

380 else: 

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

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

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

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

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

386 # Note that this behavior is handled automatically by the 

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

388 # API. 

389 butler = dafButler.Butler(self.repo) 

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

391 

392 cwd = os.getcwd() 

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

394 os.makedirs(runDir, exist_ok=True) 

395 os.chdir(runDir) 

396 

397 configOptions = {'fgcmFitCycle': 

398 {'cycleNumber': f'{cycleNumber}', 

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

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

401 self._runPipeline(self.repo, 

402 os.path.join(ROOT, 

403 'pipelines', 

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

405 configFiles=configFiles, 

406 inputCollections=inputCollections, 

407 outputCollection=outputCollection, 

408 configOptions=configOptions, 

409 registerDatasetTypes=True) 

410 

411 os.chdir(cwd) 

412 

413 if skipChecks: 

414 return 

415 

416 butler = dafButler.Butler(self.repo) 

417 

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

419 

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

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

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

423 + '*.png')) 

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

425 

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

427 collections=[outputCollection], 

428 instrument=instName) 

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

430 

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

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

433 

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

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

436 

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

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

439 

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

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

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

443 

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

445 collections=[outputCollection], 

446 instrument=instName) 

447 

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

449 

450 def _testFgcmOutputProducts(self, instName, testName, 

451 zpOffsets, testVisit, testCcd, testFilter, testBandIndex, 

452 testSrc=True): 

453 """Test running of FgcmOutputProductsTask. 

454 

455 Parameters 

456 ---------- 

457 instName : `str` 

458 Short name of the instrument. 

459 testName : `str` 

460 Base name of the test collection. 

461 zpOffsets : `np.ndarray` 

462 Zeropoint offsets expected. 

463 testVisit : `int` 

464 Visit id to check for round-trip computations. 

465 testCcd : `int` 

466 Ccd id to check for round-trip computations. 

467 testFilter : `str` 

468 Filtername for testVisit/testCcd. 

469 testBandIndex : `int` 

470 Band index for testVisit/testCcd. 

471 testSrc : `bool`, optional 

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

473 """ 

474 instCamel = instName.title() 

475 

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

477 'config', 

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

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

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

481 

482 self._runPipeline(self.repo, 

483 os.path.join(ROOT, 

484 'pipelines', 

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

486 configFiles=configFiles, 

487 inputCollections=[inputCollection], 

488 outputCollection=outputCollection, 

489 registerDatasetTypes=True) 

490 

491 butler = dafButler.Butler(self.repo) 

492 offsetCat = butler.get('fgcmReferenceCalibrationOffsets', 

493 collections=[outputCollection], instrument=instName) 

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

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

496 

497 config = butler.get('fgcmOutputProducts_config', 

498 collections=[outputCollection], instrument=instName) 

499 

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

501 collections=[inputCollection], instrument=instName) 

502 

503 # Test the fgcm_photoCalib output 

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

505 collections=[inputCollection], instrument=instName) 

506 

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

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

509 

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

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

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

513 photoCalibDict = {} 

514 for visit in visits: 

515 expCat = butler.get('fgcmPhotoCalibCatalog', 

516 visit=visit, 

517 collections=[outputCollection], instrument=instName) 

518 for row in expCat: 

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

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

521 

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

523 for rec in zptCat[good]: 

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

525 

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

527 for rec in zptCat[bad]: 

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

529 

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

531 testCal = photoCalibDict[(testVisit, testCcd)] 

532 

533 if testSrc: 

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

535 collections=[outputCollection], instrument=instName) 

536 

537 # Only test sources with positive flux 

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

539 

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

541 # and doesn't know about that yet) 

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

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

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

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

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

547 

548 if config.doComposeWcsJacobian: 

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

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

551 collections=...) 

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

553 approxPixelAreaFields = fgcmcal.utilities.computeApproxPixelAreaFields(camera) 

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

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

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

557 

558 # This is the magnitude through the mean calibration 

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

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

561 photoCalMags = np.zeros_like(photoCalMeanCalMags) 

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

563 zptMeanCalMags = np.zeros_like(photoCalMeanCalMags) 

564 

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

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

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

568 rec.getCentroid()) 

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

570 

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

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

573 self.assertFloatsAlmostEqual(photoCalMeanCalMags, 

574 zptMeanCalMags, rtol=1e-6) 

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

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

577 # wrong. 

578 self.assertFloatsAlmostEqual(photoCalMeanCalMags, 

579 photoCalMags, rtol=1e-2) 

580 

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

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

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

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

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

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

587 # offsets used in the tests. 

588 

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

590 # (multiple ccds) 

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

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

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

594 where=whereClause, 

595 findFirst=True) 

596 photoCals = [] 

597 for srcRef in srcRefs: 

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

599 

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

601 rawStars, testBandIndex, offsets) 

602 

603 st = np.argsort(matchMag) 

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

605 # deltaMagBkgOffsetPercentile, we want to ensure that these stars 

606 # match on average. 

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

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

609 

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

611 self.assertFloatsAlmostEqual(testCal.getCalibrationErr(), 

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

613 

614 # Test the transmission output 

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

616 instrument=instName) 

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

618 instrument=instName) 

619 

620 testTrans = butler.get('transmission_atmosphere_fgcm', 

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

622 collections=[outputCollection], instrument=instName) 

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

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

625 

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

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

628 # these output atmospheres and the standard is the different 

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

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

631 # testing. 

632 

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

634 # we only care about the shape 

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

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

637 

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

639 # difference so they aren't identical. 

640 testTrans2 = butler.get('transmission_atmosphere_fgcm', 

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

642 collections=[outputCollection], instrument=instName) 

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

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

645 

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

647 ratio = np.median(testResp/testResp2) 

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

649 

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

651 refcatCollection="refcats/gen2"): 

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

653 

654 Parameters 

655 ---------- 

656 instName : `str` 

657 Short name of the instrument. 

658 testName : `str` 

659 Base name of the test collection. 

660 queryString : `str` 

661 Query to send to the pipetask. 

662 visits : `list` 

663 List of visits to calibrate. 

664 zpOffsets : `np.ndarray` 

665 Zeropoint offsets expected. 

666 refcatCollection : `str`, optional 

667 Name of reference catalog collection. 

668 """ 

669 instCamel = instName.title() 

670 

671 configFiles = {'fgcmBuildFromIsolatedStars': [ 

672 os.path.join(ROOT, 

673 'config', 

674 f'fgcmBuildFromIsolatedStars{instCamel}.py' 

675 )], 

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

677 'config', 

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

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

680 'config', 

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

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

683 

684 cwd = os.getcwd() 

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

686 os.makedirs(runDir) 

687 os.chdir(runDir) 

688 

689 self._runPipeline(self.repo, 

690 os.path.join(ROOT, 

691 'pipelines', 

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

693 configFiles=configFiles, 

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

695 refcatCollection], 

696 outputCollection=outputCollection, 

697 queryString=queryString, 

698 registerDatasetTypes=True) 

699 

700 os.chdir(cwd) 

701 

702 butler = dafButler.Butler(self.repo) 

703 

704 offsetCat = butler.get('fgcmReferenceCalibrationOffsets', 

705 collections=[outputCollection], instrument=instName) 

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

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

708 

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

710 rawStars, bandIndex, offsets): 

711 """ 

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

713 

714 Parameters 

715 ---------- 

716 butler : `lsst.daf.butler.Butler` 

717 srcHandles : `list` 

718 Handles of source catalogs. 

719 photoCals : `list` 

720 photoCalib objects, matched to srcHandles. 

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

722 Fgcm standard stars. 

723 bandIndex : `int` 

724 Index of the band for the source catalogs. 

725 offsets : `np.ndarray` 

726 Testing calibration offsets to apply to rawStars. 

727 

728 Returns 

729 ------- 

730 matchMag : `np.ndarray` 

731 Array of matched magnitudes. 

732 matchDelta : `np.ndarray` 

733 Array of matched deltas between src and standard stars. 

734 """ 

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

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

737 

738 matchDelta = None 

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

740 src = butler.get(srcHandle) 

741 src = photoCal.calibrateCatalog(src) 

742 

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

744 

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

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

747 1./3600., maxmatch=1) 

748 

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

750 # Apply offset here to the catalog mag 

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

752 delta = srcMag - catMag 

753 if matchDelta is None: 

754 matchDelta = delta 

755 matchMag = catMag 

756 else: 

757 matchDelta = np.append(matchDelta, delta) 

758 matchMag = np.append(matchMag, catMag) 

759 

760 return matchMag, matchDelta 

761 

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

763 rawRepeatability, filterNCalibMap): 

764 """Test running of FgcmCalibrateTractTask 

765 

766 Parameters 

767 ---------- 

768 instName : `str` 

769 Short name of the instrument. 

770 testName : `str` 

771 Base name of the test collection. 

772 visits : `list` 

773 List of visits to calibrate. 

774 tract : `int` 

775 Tract number. 

776 skymapName : `str` 

777 Name of the sky map. 

778 rawRepeatability : `np.array` 

779 Expected raw repeatability after convergence. 

780 Length should be number of bands. 

781 filterNCalibMap : `dict` 

782 Mapping from filter name to number of photoCalibs created. 

783 """ 

784 instCamel = instName.title() 

785 

786 configFiles = {'fgcmCalibrateTractTable': 

787 [os.path.join(ROOT, 

788 'config', 

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

790 

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

792 

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

794 'refcats/gen2'] 

795 

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

797 

798 self._runPipeline(self.repo, 

799 os.path.join(ROOT, 

800 'pipelines', 

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

802 queryString=queryString, 

803 configFiles=configFiles, 

804 inputCollections=inputCollections, 

805 outputCollection=outputCollection, 

806 registerDatasetTypes=True) 

807 

808 butler = dafButler.Butler(self.repo) 

809 

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

811 

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

813 dimensions=['tract'], 

814 collections=outputCollection, 

815 where=whereClause) 

816 

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

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

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

820 

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

822 for filterName in filterNCalibMap.keys(): 

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

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

825 

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

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

828 collections=outputCollection, 

829 where=whereClause) 

830 

831 count = 0 

832 for ref in set(refs): 

833 expCat = butler.get(ref) 

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

835 count += test.size 

836 

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

838 

839 # Check that every visit got a transmission 

840 for visit in visits: 

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

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

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

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

845 collections=outputCollection, 

846 where=whereClause) 

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

848 

849 @classmethod 

850 def tearDownClass(cls): 

851 """Tear down and clear directories. 

852 """ 

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

854 shutil.rmtree(cls.testDir, True)