Coverage for tests/fgcmcalTestBase.py: 9%

246 statements  

« prev     ^ index     » next       coverage.py v7.2.3, created at 2023-04-28 10:34 +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 """Test running of FgcmBuildStarsTableTask 

232 

233 Parameters 

234 ---------- 

235 instName : `str` 

236 Short name of the instrument. 

237 testName : `str` 

238 Base name of the test collection. 

239 queryString : `str` 

240 Query to send to the pipetask. 

241 visits : `list` 

242 List of visits to calibrate. 

243 nStar : `int` 

244 Number of stars expected. 

245 nObs : `int` 

246 Number of observations of stars expected. 

247 """ 

248 instCamel = instName.title() 

249 

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

251 'config', 

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

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

254 

255 self._runPipeline(self.repo, 

256 os.path.join(ROOT, 

257 'pipelines', 

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

259 configFiles=configFiles, 

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

261 'refcats/gen2'], 

262 outputCollection=outputCollection, 

263 queryString=queryString, 

264 registerDatasetTypes=True) 

265 

266 butler = dafButler.Butler(self.repo) 

267 

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

269 instrument=instName) 

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

271 

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

273 instrument=instName) 

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

275 

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

277 instrument=instName) 

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

279 

280 def _testFgcmBuildFromIsolatedStars(self, instName, testName, queryString, visits, nStar, nObs): 

281 """Test running of FgcmBuildFromIsolatedStarsTask. 

282 

283 Parameters 

284 ---------- 

285 instName : `str` 

286 Short name of the instrument. 

287 testName : `str` 

288 Base name of the test collection. 

289 queryString : `str` 

290 Query to send to the pipetask. 

291 visits : `list` 

292 List of visits to calibrate. 

293 nStar : `int` 

294 Number of stars expected. 

295 nObs : `int` 

296 Number of observations of stars expected. 

297 """ 

298 instCamel = instName.title() 

299 

300 configFiles = {'fgcmBuildFromIsolatedStars': [ 

301 os.path.join(ROOT, 

302 'config', 

303 f'fgcmBuildFromIsolatedStars{instCamel}.py') 

304 ]} 

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

306 

307 self._runPipeline(self.repo, 

308 os.path.join(ROOT, 

309 'pipelines', 

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

311 configFiles=configFiles, 

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

313 'refcats/gen2'], 

314 outputCollection=outputCollection, 

315 queryString=queryString, 

316 registerDatasetTypes=True) 

317 

318 butler = dafButler.Butler(self.repo) 

319 

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

321 instrument=instName) 

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

323 

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

325 instrument=instName) 

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

327 

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

329 instrument=instName) 

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

331 

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

333 nZp, nGoodZp, nOkZp, nBadZp, nStdStars, nPlots, 

334 skipChecks=False, extraConfig=None): 

335 """Test running of FgcmFitCycleTask 

336 

337 Parameters 

338 ---------- 

339 instName : `str` 

340 Short name of the instrument. 

341 testName : `str` 

342 Base name of the test collection. 

343 cycleNumber : `int` 

344 Fit cycle number. 

345 nZp : `int` 

346 Number of zeropoints created by the task. 

347 nGoodZp : `int` 

348 Number of good (photometric) zeropoints created. 

349 nOkZp : `int` 

350 Number of constrained zeropoints (photometric or not). 

351 nBadZp : `int` 

352 Number of unconstrained (bad) zeropoints. 

353 nStdStars : `int` 

354 Number of standard stars produced. 

355 nPlots : `int` 

356 Number of plots produced. 

357 skipChecks : `bool`, optional 

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

359 extraConfig : `str`, optional 

360 Name of an extra config file to apply. 

361 """ 

362 instCamel = instName.title() 

363 

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

365 'config', 

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

367 if extraConfig is not None: 

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

369 

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

371 

372 if cycleNumber == 0: 

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

374 else: 

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

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

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

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

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

380 # Note that this behavior is handled automatically by the 

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

382 # API. 

383 butler = dafButler.Butler(self.repo) 

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

385 

386 cwd = os.getcwd() 

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

388 os.makedirs(runDir, exist_ok=True) 

389 os.chdir(runDir) 

390 

391 configOptions = {'fgcmFitCycle': 

392 {'cycleNumber': f'{cycleNumber}', 

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

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

395 self._runPipeline(self.repo, 

396 os.path.join(ROOT, 

397 'pipelines', 

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

399 configFiles=configFiles, 

400 inputCollections=inputCollections, 

401 outputCollection=outputCollection, 

402 configOptions=configOptions, 

403 registerDatasetTypes=True) 

404 

405 os.chdir(cwd) 

406 

407 if skipChecks: 

408 return 

409 

410 butler = dafButler.Butler(self.repo) 

411 

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

413 

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

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

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

417 + '*.png')) 

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

419 

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

421 collections=[outputCollection], 

422 instrument=instName) 

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

424 

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

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

427 

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

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

430 

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

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

433 

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

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

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

437 

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

439 collections=[outputCollection], 

440 instrument=instName) 

441 

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

443 

444 def _testFgcmOutputProducts(self, instName, testName, 

445 zpOffsets, testVisit, testCcd, testFilter, testBandIndex): 

446 """Test running of FgcmOutputProductsTask. 

447 

448 Parameters 

449 ---------- 

450 instName : `str` 

451 Short name of the instrument. 

452 testName : `str` 

453 Base name of the test collection. 

454 zpOffsets : `np.ndarray` 

455 Zeropoint offsets expected. 

456 testVisit : `int` 

457 Visit id to check for round-trip computations. 

458 testCcd : `int` 

459 Ccd id to check for round-trip computations. 

460 testFilter : `str` 

461 Filtername for testVisit/testCcd. 

462 testBandIndex : `int` 

463 Band index for testVisit/testCcd. 

464 """ 

465 instCamel = instName.title() 

466 

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

468 'config', 

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

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

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

472 

473 self._runPipeline(self.repo, 

474 os.path.join(ROOT, 

475 'pipelines', 

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

477 configFiles=configFiles, 

478 inputCollections=[inputCollection], 

479 outputCollection=outputCollection, 

480 registerDatasetTypes=True) 

481 

482 butler = dafButler.Butler(self.repo) 

483 offsetCat = butler.get('fgcmReferenceCalibrationOffsets', 

484 collections=[outputCollection], instrument=instName) 

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

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

487 

488 config = butler.get('fgcmOutputProducts_config', 

489 collections=[outputCollection], instrument=instName) 

490 

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

492 collections=[inputCollection], instrument=instName) 

493 

494 # Test the fgcm_photoCalib output 

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

496 collections=[inputCollection], instrument=instName) 

497 

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

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

500 

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

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

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

504 photoCalibDict = {} 

505 for visit in visits: 

506 expCat = butler.get('fgcmPhotoCalibCatalog', 

507 visit=visit, 

508 collections=[outputCollection], instrument=instName) 

509 for row in expCat: 

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

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

512 

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

514 for rec in zptCat[good]: 

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

516 

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

518 for rec in zptCat[bad]: 

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

520 

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

522 testCal = photoCalibDict[(testVisit, testCcd)] 

523 

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

525 collections=[outputCollection], instrument=instName) 

526 

527 # Only test sources with positive flux 

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

529 

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

531 # and doesn't know about that yet) 

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

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

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

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

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

537 

538 if config.doComposeWcsJacobian: 

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

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

541 collections=...) 

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

543 approxPixelAreaFields = fgcmcal.utilities.computeApproxPixelAreaFields(camera) 

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

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

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

547 

548 # This is the magnitude through the mean calibration 

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

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

551 photoCalMags = np.zeros_like(photoCalMeanCalMags) 

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

553 zptMeanCalMags = np.zeros_like(photoCalMeanCalMags) 

554 

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

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

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

558 rec.getCentroid()) 

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

560 

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

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

563 self.assertFloatsAlmostEqual(photoCalMeanCalMags, 

564 zptMeanCalMags, rtol=1e-6) 

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

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

567 # wrong. 

568 self.assertFloatsAlmostEqual(photoCalMeanCalMags, 

569 photoCalMags, rtol=1e-2) 

570 

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

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

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

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

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

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

577 # offsets used in the tests. 

578 

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

580 # (multiple ccds) 

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

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

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

584 where=whereClause, 

585 findFirst=True) 

586 photoCals = [] 

587 for srcRef in srcRefs: 

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

589 

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

591 rawStars, testBandIndex, offsets) 

592 

593 st = np.argsort(matchMag) 

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

595 # deltaMagBkgOffsetPercentile, we want to ensure that these stars 

596 # match on average. 

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

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

599 

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

601 self.assertFloatsAlmostEqual(testCal.getCalibrationErr(), 

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

603 

604 # Test the transmission output 

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

606 instrument=instName) 

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

608 instrument=instName) 

609 

610 testTrans = butler.get('transmission_atmosphere_fgcm', 

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

612 collections=[outputCollection], instrument=instName) 

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

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

615 

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

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

618 # these output atmospheres and the standard is the different 

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

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

621 # testing. 

622 

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

624 # we only care about the shape 

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

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

627 

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

629 # difference so they aren't identical. 

630 testTrans2 = butler.get('transmission_atmosphere_fgcm', 

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

632 collections=[outputCollection], instrument=instName) 

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

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

635 

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

637 ratio = np.median(testResp/testResp2) 

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

639 

640 def _testFgcmMultiFit(self, instName, testName, queryString, visits, zpOffsets): 

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

642 

643 Parameters 

644 ---------- 

645 instName : `str` 

646 Short name of the instrument. 

647 testName : `str` 

648 Base name of the test collection. 

649 queryString : `str` 

650 Query to send to the pipetask. 

651 visits : `list` 

652 List of visits to calibrate. 

653 zpOffsets : `np.ndarray` 

654 Zeropoint offsets expected. 

655 """ 

656 instCamel = instName.title() 

657 

658 configFiles = {'fgcmBuildFromIsolatedStars': [ 

659 os.path.join(ROOT, 

660 'config', 

661 f'fgcmBuildFromIsolatedStars{instCamel}.py' 

662 )], 

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

664 'config', 

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

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

667 'config', 

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

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

670 

671 cwd = os.getcwd() 

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

673 os.makedirs(runDir) 

674 os.chdir(runDir) 

675 

676 self._runPipeline(self.repo, 

677 os.path.join(ROOT, 

678 'pipelines', 

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

680 configFiles=configFiles, 

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

682 'refcats/gen2'], 

683 outputCollection=outputCollection, 

684 queryString=queryString, 

685 registerDatasetTypes=True) 

686 

687 os.chdir(cwd) 

688 

689 butler = dafButler.Butler(self.repo) 

690 

691 offsetCat = butler.get('fgcmReferenceCalibrationOffsets', 

692 collections=[outputCollection], instrument=instName) 

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

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

695 

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

697 rawStars, bandIndex, offsets): 

698 """ 

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

700 

701 Parameters 

702 ---------- 

703 butler : `lsst.daf.butler.Butler` 

704 srcHandles : `list` 

705 Handles of source catalogs. 

706 photoCals : `list` 

707 photoCalib objects, matched to srcHandles. 

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

709 Fgcm standard stars. 

710 bandIndex : `int` 

711 Index of the band for the source catalogs. 

712 offsets : `np.ndarray` 

713 Testing calibration offsets to apply to rawStars. 

714 

715 Returns 

716 ------- 

717 matchMag : `np.ndarray` 

718 Array of matched magnitudes. 

719 matchDelta : `np.ndarray` 

720 Array of matched deltas between src and standard stars. 

721 """ 

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

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

724 

725 matchDelta = None 

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

727 src = butler.get(srcHandle) 

728 src = photoCal.calibrateCatalog(src) 

729 

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

731 

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

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

734 1./3600., maxmatch=1) 

735 

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

737 # Apply offset here to the catalog mag 

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

739 delta = srcMag - catMag 

740 if matchDelta is None: 

741 matchDelta = delta 

742 matchMag = catMag 

743 else: 

744 matchDelta = np.append(matchDelta, delta) 

745 matchMag = np.append(matchMag, catMag) 

746 

747 return matchMag, matchDelta 

748 

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

750 rawRepeatability, filterNCalibMap): 

751 """Test running of FgcmCalibrateTractTask 

752 

753 Parameters 

754 ---------- 

755 instName : `str` 

756 Short name of the instrument. 

757 testName : `str` 

758 Base name of the test collection. 

759 visits : `list` 

760 List of visits to calibrate. 

761 tract : `int` 

762 Tract number. 

763 skymapName : `str` 

764 Name of the sky map. 

765 rawRepeatability : `np.array` 

766 Expected raw repeatability after convergence. 

767 Length should be number of bands. 

768 filterNCalibMap : `dict` 

769 Mapping from filter name to number of photoCalibs created. 

770 """ 

771 instCamel = instName.title() 

772 

773 configFiles = {'fgcmCalibrateTractTable': 

774 [os.path.join(ROOT, 

775 'config', 

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

777 

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

779 

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

781 'refcats/gen2'] 

782 

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

784 

785 self._runPipeline(self.repo, 

786 os.path.join(ROOT, 

787 'pipelines', 

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

789 queryString=queryString, 

790 configFiles=configFiles, 

791 inputCollections=inputCollections, 

792 outputCollection=outputCollection, 

793 registerDatasetTypes=True) 

794 

795 butler = dafButler.Butler(self.repo) 

796 

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

798 

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

800 dimensions=['tract'], 

801 collections=outputCollection, 

802 where=whereClause) 

803 

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

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

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

807 

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

809 for filterName in filterNCalibMap.keys(): 

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

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

812 

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

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

815 collections=outputCollection, 

816 where=whereClause) 

817 

818 count = 0 

819 for ref in set(refs): 

820 expCat = butler.get(ref) 

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

822 count += test.size 

823 

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

825 

826 # Check that every visit got a transmission 

827 for visit in visits: 

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

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

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

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

832 collections=outputCollection, 

833 where=whereClause) 

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

835 

836 @classmethod 

837 def tearDownClass(cls): 

838 """Tear down and clear directories. 

839 """ 

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

841 shutil.rmtree(cls.testDir, True)