Coverage for tests/fgcmcalTestBase.py: 10%

227 statements  

« prev     ^ index     » next       coverage.py v6.4.1, created at 2022-06-26 02:03 -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 

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 

123 pipeline = Pipeline.fromFile(pipelineFile) 

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

125 for fileName in fileList: 

126 pipeline.addConfigFile(taskName, fileName) 

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

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

129 pipeline.addConfigOverride(taskName, option, value) 

130 

131 executor = SimplePipelineExecutor.from_pipeline(pipeline, 

132 where=queryString, 

133 root=repo, 

134 butler=butler) 

135 quanta = executor.run(register_dataset_types=registerDatasetTypes) 

136 

137 return len(quanta) 

138 

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

140 """Test running of FgcmMakeLutTask 

141 

142 Parameters 

143 ---------- 

144 instName : `str` 

145 Short name of the instrument 

146 testName : `str` 

147 Base name of the test collection 

148 nBand : `int` 

149 Number of bands tested 

150 i0Std : `np.ndarray' 

151 Values of i0Std to compare to 

152 i10Std : `np.ndarray` 

153 Values of i10Std to compare to 

154 i0Recon : `np.ndarray` 

155 Values of reconstructed i0 to compare to 

156 i10Recon : `np.ndarray` 

157 Values of reconsntructed i10 to compare to 

158 """ 

159 instCamel = instName.title() 

160 

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

162 'config', 

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

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

165 

166 self._runPipeline(self.repo, 

167 os.path.join(ROOT, 

168 'pipelines', 

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

170 configFiles=configFiles, 

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

172 outputCollection=outputCollection, 

173 registerDatasetTypes=True) 

174 

175 # Check output values 

176 butler = dafButler.Butler(self.repo) 

177 lutCat = butler.get('fgcmLookUpTable', 

178 collections=[outputCollection], 

179 instrument=instName) 

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

181 

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

183 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

198 indices) 

199 

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

201 

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

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

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

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

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

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

208 indices) 

209 

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

211 

212 def _testFgcmBuildStarsTable(self, instName, testName, queryString, visits, nStar, nObs): 

213 """Test running of FgcmBuildStarsTableTask 

214 

215 Parameters 

216 ---------- 

217 instName : `str` 

218 Short name of the instrument 

219 testName : `str` 

220 Base name of the test collection 

221 queryString : `str` 

222 Query to send to the pipetask. 

223 visits : `list` 

224 List of visits to calibrate 

225 nStar : `int` 

226 Number of stars expected 

227 nObs : `int` 

228 Number of observations of stars expected 

229 """ 

230 instCamel = instName.title() 

231 

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

233 'config', 

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

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

236 

237 self._runPipeline(self.repo, 

238 os.path.join(ROOT, 

239 'pipelines', 

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

241 configFiles=configFiles, 

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

243 'refcats/gen2'], 

244 outputCollection=outputCollection, 

245 queryString=queryString, 

246 registerDatasetTypes=True) 

247 

248 butler = dafButler.Butler(self.repo) 

249 

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

251 instrument=instName) 

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

253 

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

255 instrument=instName) 

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

257 

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

259 instrument=instName) 

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

261 

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

263 nZp, nGoodZp, nOkZp, nBadZp, nStdStars, nPlots, 

264 skipChecks=False, extraConfig=None): 

265 """Test running of FgcmFitCycleTask 

266 

267 Parameters 

268 ---------- 

269 instName : `str` 

270 Short name of the instrument 

271 testName : `str` 

272 Base name of the test collection 

273 cycleNumber : `int` 

274 Fit cycle number. 

275 nZp : `int` 

276 Number of zeropoints created by the task 

277 nGoodZp : `int` 

278 Number of good (photometric) zeropoints created 

279 nOkZp : `int` 

280 Number of constrained zeropoints (photometric or not) 

281 nBadZp : `int` 

282 Number of unconstrained (bad) zeropoints 

283 nStdStars : `int` 

284 Number of standard stars produced 

285 nPlots : `int` 

286 Number of plots produced 

287 skipChecks : `bool`, optional 

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

289 extraConfig : `str`, optional 

290 Name of an extra config file to apply. 

291 """ 

292 instCamel = instName.title() 

293 

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

295 'config', 

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

297 if extraConfig is not None: 

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

299 

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

301 

302 if cycleNumber == 0: 

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

304 else: 

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

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

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

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

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

310 # Note that this behavior is handled automatically by the 

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

312 # API. 

313 butler = dafButler.Butler(self.repo) 

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

315 

316 cwd = os.getcwd() 

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

318 os.makedirs(runDir, exist_ok=True) 

319 os.chdir(runDir) 

320 

321 configOptions = {'fgcmFitCycle': 

322 {'cycleNumber': f'{cycleNumber}', 

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

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

325 self._runPipeline(self.repo, 

326 os.path.join(ROOT, 

327 'pipelines', 

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

329 configFiles=configFiles, 

330 inputCollections=inputCollections, 

331 outputCollection=outputCollection, 

332 configOptions=configOptions, 

333 registerDatasetTypes=True) 

334 

335 os.chdir(cwd) 

336 

337 if skipChecks: 

338 return 

339 

340 butler = dafButler.Butler(self.repo) 

341 

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

343 

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

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

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

347 + '*.png')) 

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

349 

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

351 collections=[outputCollection], 

352 instrument=instName) 

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

354 

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

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

357 

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

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

360 

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

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

363 

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

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

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

367 

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

369 collections=[outputCollection], 

370 instrument=instName) 

371 

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

373 

374 def _testFgcmOutputProducts(self, instName, testName, 

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

376 """Test running of FgcmOutputProductsTask. 

377 

378 Parameters 

379 ---------- 

380 instName : `str` 

381 Short name of the instrument 

382 testName : `str` 

383 Base name of the test collection 

384 zpOffsets : `np.ndarray` 

385 Zeropoint offsets expected 

386 testVisit : `int` 

387 Visit id to check for round-trip computations 

388 testCcd : `int` 

389 Ccd id to check for round-trip computations 

390 testFilter : `str` 

391 Filtername for testVisit/testCcd 

392 testBandIndex : `int` 

393 Band index for testVisit/testCcd 

394 """ 

395 instCamel = instName.title() 

396 

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

398 'config', 

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

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

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

402 

403 self._runPipeline(self.repo, 

404 os.path.join(ROOT, 

405 'pipelines', 

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

407 configFiles=configFiles, 

408 inputCollections=[inputCollection], 

409 outputCollection=outputCollection, 

410 registerDatasetTypes=True) 

411 

412 butler = dafButler.Butler(self.repo) 

413 offsetCat = butler.get('fgcmReferenceCalibrationOffsets', 

414 collections=[outputCollection], instrument=instName) 

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

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

417 

418 config = butler.get('fgcmOutputProducts_config', 

419 collections=[outputCollection], instrument=instName) 

420 

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

422 collections=[inputCollection], instrument=instName) 

423 

424 candRatio = (rawStars['npsfcand'][:, 0].astype(np.float64) 

425 / rawStars['ntotal'][:, 0].astype(np.float64)) 

426 self.assertFloatsAlmostEqual(candRatio.min(), 0.0) 

427 self.assertFloatsAlmostEqual(candRatio.max(), 1.0) 

428 

429 # Test the fgcm_photoCalib output 

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

431 collections=[inputCollection], instrument=instName) 

432 

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

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

435 

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

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

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

439 photoCalibDict = {} 

440 for visit in visits: 

441 expCat = butler.get('fgcmPhotoCalibCatalog', 

442 visit=visit, 

443 collections=[outputCollection], instrument=instName) 

444 for row in expCat: 

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

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

447 

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

449 for rec in zptCat[good]: 

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

451 

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

453 for rec in zptCat[bad]: 

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

455 

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

457 testCal = photoCalibDict[(testVisit, testCcd)] 

458 

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

460 collections=[outputCollection], instrument=instName) 

461 

462 # Only test sources with positive flux 

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

464 

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

466 # and doesn't know about that yet) 

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

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

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

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

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

472 

473 if config.doComposeWcsJacobian: 

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

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

476 collections=...) 

477 camera = butler.getDirect(list(refs)[0]) 

478 approxPixelAreaFields = fgcmcal.utilities.computeApproxPixelAreaFields(camera) 

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

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

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

482 

483 # This is the magnitude through the mean calibration 

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

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

486 photoCalMags = np.zeros_like(photoCalMeanCalMags) 

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

488 zptMeanCalMags = np.zeros_like(photoCalMeanCalMags) 

489 

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

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

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

493 rec.getCentroid()) 

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

495 

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

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

498 self.assertFloatsAlmostEqual(photoCalMeanCalMags, 

499 zptMeanCalMags, rtol=1e-6) 

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

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

502 # wrong. 

503 self.assertFloatsAlmostEqual(photoCalMeanCalMags, 

504 photoCalMags, rtol=1e-2) 

505 

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

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

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

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

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

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

512 # offsets used in the tests. 

513 

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

515 # (multiple ccds) 

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

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

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

519 where=whereClause, 

520 findFirst=True) 

521 photoCals = [] 

522 for srcRef in srcRefs: 

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

524 

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

526 rawStars, testBandIndex, offsets) 

527 

528 st = np.argsort(matchMag) 

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

530 # deltaMagBkgOffsetPercentile, we want to ensure that these stars 

531 # match on average. 

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

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

534 

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

536 self.assertFloatsAlmostEqual(testCal.getCalibrationErr(), 

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

538 

539 # Test the transmission output 

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

541 instrument=instName) 

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

543 instrument=instName) 

544 

545 testTrans = butler.get('transmission_atmosphere_fgcm', 

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

547 collections=[outputCollection], instrument=instName) 

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

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

550 

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

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

553 # these output atmospheres and the standard is the different 

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

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

556 # testing. 

557 

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

559 # we only care about the shape 

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

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

562 

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

564 # difference so they aren't identical. 

565 testTrans2 = butler.get('transmission_atmosphere_fgcm', 

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

567 collections=[outputCollection], instrument=instName) 

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

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

570 

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

572 ratio = np.median(testResp/testResp2) 

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

574 

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

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

577 

578 Parameters 

579 ---------- 

580 instName : `str` 

581 Short name of the instrument 

582 testName : `str` 

583 Base name of the test collection 

584 queryString : `str` 

585 Query to send to the pipetask. 

586 visits : `list` 

587 List of visits to calibrate 

588 zpOffsets : `np.ndarray` 

589 Zeropoint offsets expected 

590 """ 

591 instCamel = instName.title() 

592 

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

594 'config', 

595 f'fgcmBuildStarsTable{instCamel}.py')], 

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

597 'config', 

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

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

600 'config', 

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

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

603 

604 cwd = os.getcwd() 

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

606 os.makedirs(runDir) 

607 os.chdir(runDir) 

608 

609 self._runPipeline(self.repo, 

610 os.path.join(ROOT, 

611 'pipelines', 

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

613 configFiles=configFiles, 

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

615 'refcats/gen2'], 

616 outputCollection=outputCollection, 

617 queryString=queryString, 

618 registerDatasetTypes=True) 

619 

620 os.chdir(cwd) 

621 

622 butler = dafButler.Butler(self.repo) 

623 

624 offsetCat = butler.get('fgcmReferenceCalibrationOffsets', 

625 collections=[outputCollection], instrument=instName) 

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

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

628 

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

630 rawStars, bandIndex, offsets): 

631 """ 

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

633 

634 Parameters 

635 ---------- 

636 butler : `lsst.daf.butler.Butler` 

637 srcHandles : `list` 

638 handles of source catalogs 

639 photoCals : `list` 

640 photoCalib objects, matched to srcHandles. 

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

642 Fgcm standard stars 

643 bandIndex : `int` 

644 Index of the band for the source catalogs 

645 offsets : `np.ndarray` 

646 Testing calibration offsets to apply to rawStars 

647 

648 Returns 

649 ------- 

650 matchMag : `np.ndarray` 

651 Array of matched magnitudes 

652 matchDelta : `np.ndarray` 

653 Array of matched deltas between src and standard stars. 

654 """ 

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

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

657 

658 matchDelta = None 

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

660 src = butler.getDirect(srcHandle) 

661 src = photoCal.calibrateCatalog(src) 

662 

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

664 

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

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

667 1./3600., maxmatch=1) 

668 

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

670 # Apply offset here to the catalog mag 

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

672 delta = srcMag - catMag 

673 if matchDelta is None: 

674 matchDelta = delta 

675 matchMag = catMag 

676 else: 

677 matchDelta = np.append(matchDelta, delta) 

678 matchMag = np.append(matchMag, catMag) 

679 

680 return matchMag, matchDelta 

681 

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

683 rawRepeatability, filterNCalibMap): 

684 """Test running of FgcmCalibrateTractTask 

685 

686 Parameters 

687 ---------- 

688 instName : `str` 

689 Short name of the instrument 

690 testName : `str` 

691 Base name of the test collection 

692 visits : `list` 

693 List of visits to calibrate 

694 tract : `int` 

695 Tract number 

696 skymapName : `str` 

697 Name of the sky map 

698 rawRepeatability : `np.array` 

699 Expected raw repeatability after convergence. 

700 Length should be number of bands. 

701 filterNCalibMap : `dict` 

702 Mapping from filter name to number of photoCalibs created. 

703 """ 

704 instCamel = instName.title() 

705 

706 configFiles = {'fgcmCalibrateTractTable': 

707 [os.path.join(ROOT, 

708 'config', 

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

710 

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

712 

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

714 'refcats/gen2'] 

715 

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

717 

718 self._runPipeline(self.repo, 

719 os.path.join(ROOT, 

720 'pipelines', 

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

722 queryString=queryString, 

723 configFiles=configFiles, 

724 inputCollections=inputCollections, 

725 outputCollection=outputCollection, 

726 registerDatasetTypes=True) 

727 

728 butler = dafButler.Butler(self.repo) 

729 

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

731 

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

733 dimensions=['tract'], 

734 collections=outputCollection, 

735 where=whereClause) 

736 

737 repeatabilityCat = butler.getDirect(list(repRefs)[0]) 

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

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

740 

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

742 for filterName in filterNCalibMap.keys(): 

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

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

745 

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

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

748 collections=outputCollection, 

749 where=whereClause) 

750 

751 count = 0 

752 for ref in set(refs): 

753 expCat = butler.getDirect(ref) 

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

755 count += test.size 

756 

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

758 

759 # Check that every visit got a transmission 

760 for visit in visits: 

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

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

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

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

765 collections=outputCollection, 

766 where=whereClause) 

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

768 

769 @classmethod 

770 def tearDownClass(cls): 

771 """Tear down and clear directories 

772 """ 

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

774 shutil.rmtree(cls.testDir, True)