Coverage for tests/fgcmcalTestBase.py: 9%

237 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-12-04 02:45 -0800

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 # Check that the standard atmosphere was output and non-zero. 

213 atmStd = butler.get('fgcm_standard_atmosphere', 

214 collections=[outputCollection], 

215 instrument=instName) 

216 bounds = atmStd.getWavelengthBounds() 

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

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

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

220 

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

222 for physical_filter in fgcmLut.filterNames: 

223 passband = butler.get('fgcm_standard_passband', 

224 collections=[outputCollection], 

225 instrument=instName, 

226 physical_filter=physical_filter) 

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

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

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

230 

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

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 """ 

249 instCamel = instName.title() 

250 

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

252 'config', 

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

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

255 

256 self._runPipeline(self.repo, 

257 os.path.join(ROOT, 

258 'pipelines', 

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

260 configFiles=configFiles, 

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

262 'refcats/gen2'], 

263 outputCollection=outputCollection, 

264 queryString=queryString, 

265 registerDatasetTypes=True) 

266 

267 butler = dafButler.Butler(self.repo) 

268 

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

270 instrument=instName) 

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

272 

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

274 instrument=instName) 

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

276 

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

278 instrument=instName) 

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

280 

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

282 nZp, nGoodZp, nOkZp, nBadZp, nStdStars, nPlots, 

283 skipChecks=False, extraConfig=None): 

284 """Test running of FgcmFitCycleTask 

285 

286 Parameters 

287 ---------- 

288 instName : `str` 

289 Short name of the instrument 

290 testName : `str` 

291 Base name of the test collection 

292 cycleNumber : `int` 

293 Fit cycle number. 

294 nZp : `int` 

295 Number of zeropoints created by the task 

296 nGoodZp : `int` 

297 Number of good (photometric) zeropoints created 

298 nOkZp : `int` 

299 Number of constrained zeropoints (photometric or not) 

300 nBadZp : `int` 

301 Number of unconstrained (bad) zeropoints 

302 nStdStars : `int` 

303 Number of standard stars produced 

304 nPlots : `int` 

305 Number of plots produced 

306 skipChecks : `bool`, optional 

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

308 extraConfig : `str`, optional 

309 Name of an extra config file to apply. 

310 """ 

311 instCamel = instName.title() 

312 

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

314 'config', 

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

316 if extraConfig is not None: 

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

318 

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

320 

321 if cycleNumber == 0: 

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

323 else: 

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

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

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

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

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

329 # Note that this behavior is handled automatically by the 

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

331 # API. 

332 butler = dafButler.Butler(self.repo) 

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

334 

335 cwd = os.getcwd() 

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

337 os.makedirs(runDir, exist_ok=True) 

338 os.chdir(runDir) 

339 

340 configOptions = {'fgcmFitCycle': 

341 {'cycleNumber': f'{cycleNumber}', 

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

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

344 self._runPipeline(self.repo, 

345 os.path.join(ROOT, 

346 'pipelines', 

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

348 configFiles=configFiles, 

349 inputCollections=inputCollections, 

350 outputCollection=outputCollection, 

351 configOptions=configOptions, 

352 registerDatasetTypes=True) 

353 

354 os.chdir(cwd) 

355 

356 if skipChecks: 

357 return 

358 

359 butler = dafButler.Butler(self.repo) 

360 

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

362 

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

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

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

366 + '*.png')) 

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

368 

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

370 collections=[outputCollection], 

371 instrument=instName) 

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

373 

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

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

376 

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

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

379 

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

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

382 

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

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

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

386 

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

388 collections=[outputCollection], 

389 instrument=instName) 

390 

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

392 

393 def _testFgcmOutputProducts(self, instName, testName, 

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

395 """Test running of FgcmOutputProductsTask. 

396 

397 Parameters 

398 ---------- 

399 instName : `str` 

400 Short name of the instrument 

401 testName : `str` 

402 Base name of the test collection 

403 zpOffsets : `np.ndarray` 

404 Zeropoint offsets expected 

405 testVisit : `int` 

406 Visit id to check for round-trip computations 

407 testCcd : `int` 

408 Ccd id to check for round-trip computations 

409 testFilter : `str` 

410 Filtername for testVisit/testCcd 

411 testBandIndex : `int` 

412 Band index for testVisit/testCcd 

413 """ 

414 instCamel = instName.title() 

415 

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

417 'config', 

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

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

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

421 

422 self._runPipeline(self.repo, 

423 os.path.join(ROOT, 

424 'pipelines', 

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

426 configFiles=configFiles, 

427 inputCollections=[inputCollection], 

428 outputCollection=outputCollection, 

429 registerDatasetTypes=True) 

430 

431 butler = dafButler.Butler(self.repo) 

432 offsetCat = butler.get('fgcmReferenceCalibrationOffsets', 

433 collections=[outputCollection], instrument=instName) 

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

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

436 

437 config = butler.get('fgcmOutputProducts_config', 

438 collections=[outputCollection], instrument=instName) 

439 

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

441 collections=[inputCollection], instrument=instName) 

442 

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

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

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

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

447 

448 # Test the fgcm_photoCalib output 

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

450 collections=[inputCollection], instrument=instName) 

451 

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

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

454 

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

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

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

458 photoCalibDict = {} 

459 for visit in visits: 

460 expCat = butler.get('fgcmPhotoCalibCatalog', 

461 visit=visit, 

462 collections=[outputCollection], instrument=instName) 

463 for row in expCat: 

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

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

466 

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

468 for rec in zptCat[good]: 

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

470 

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

472 for rec in zptCat[bad]: 

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

474 

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

476 testCal = photoCalibDict[(testVisit, testCcd)] 

477 

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

479 collections=[outputCollection], instrument=instName) 

480 

481 # Only test sources with positive flux 

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

483 

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

485 # and doesn't know about that yet) 

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

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

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

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

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

491 

492 if config.doComposeWcsJacobian: 

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

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

495 collections=...) 

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

497 approxPixelAreaFields = fgcmcal.utilities.computeApproxPixelAreaFields(camera) 

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

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

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

501 

502 # This is the magnitude through the mean calibration 

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

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

505 photoCalMags = np.zeros_like(photoCalMeanCalMags) 

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

507 zptMeanCalMags = np.zeros_like(photoCalMeanCalMags) 

508 

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

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

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

512 rec.getCentroid()) 

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

514 

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

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

517 self.assertFloatsAlmostEqual(photoCalMeanCalMags, 

518 zptMeanCalMags, rtol=1e-6) 

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

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

521 # wrong. 

522 self.assertFloatsAlmostEqual(photoCalMeanCalMags, 

523 photoCalMags, rtol=1e-2) 

524 

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

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

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

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

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

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

531 # offsets used in the tests. 

532 

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

534 # (multiple ccds) 

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

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

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

538 where=whereClause, 

539 findFirst=True) 

540 photoCals = [] 

541 for srcRef in srcRefs: 

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

543 

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

545 rawStars, testBandIndex, offsets) 

546 

547 st = np.argsort(matchMag) 

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

549 # deltaMagBkgOffsetPercentile, we want to ensure that these stars 

550 # match on average. 

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

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

553 

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

555 self.assertFloatsAlmostEqual(testCal.getCalibrationErr(), 

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

557 

558 # Test the transmission output 

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

560 instrument=instName) 

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

562 instrument=instName) 

563 

564 testTrans = butler.get('transmission_atmosphere_fgcm', 

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

566 collections=[outputCollection], instrument=instName) 

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

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

569 

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

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

572 # these output atmospheres and the standard is the different 

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

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

575 # testing. 

576 

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

578 # we only care about the shape 

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

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

581 

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

583 # difference so they aren't identical. 

584 testTrans2 = butler.get('transmission_atmosphere_fgcm', 

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

586 collections=[outputCollection], instrument=instName) 

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

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

589 

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

591 ratio = np.median(testResp/testResp2) 

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

593 

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

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

596 

597 Parameters 

598 ---------- 

599 instName : `str` 

600 Short name of the instrument 

601 testName : `str` 

602 Base name of the test collection 

603 queryString : `str` 

604 Query to send to the pipetask. 

605 visits : `list` 

606 List of visits to calibrate 

607 zpOffsets : `np.ndarray` 

608 Zeropoint offsets expected 

609 """ 

610 instCamel = instName.title() 

611 

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

613 'config', 

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

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

616 'config', 

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

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

619 'config', 

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

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

622 

623 cwd = os.getcwd() 

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

625 os.makedirs(runDir) 

626 os.chdir(runDir) 

627 

628 self._runPipeline(self.repo, 

629 os.path.join(ROOT, 

630 'pipelines', 

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

632 configFiles=configFiles, 

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

634 'refcats/gen2'], 

635 outputCollection=outputCollection, 

636 queryString=queryString, 

637 registerDatasetTypes=True) 

638 

639 os.chdir(cwd) 

640 

641 butler = dafButler.Butler(self.repo) 

642 

643 offsetCat = butler.get('fgcmReferenceCalibrationOffsets', 

644 collections=[outputCollection], instrument=instName) 

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

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

647 

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

649 rawStars, bandIndex, offsets): 

650 """ 

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

652 

653 Parameters 

654 ---------- 

655 butler : `lsst.daf.butler.Butler` 

656 srcHandles : `list` 

657 handles of source catalogs 

658 photoCals : `list` 

659 photoCalib objects, matched to srcHandles. 

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

661 Fgcm standard stars 

662 bandIndex : `int` 

663 Index of the band for the source catalogs 

664 offsets : `np.ndarray` 

665 Testing calibration offsets to apply to rawStars 

666 

667 Returns 

668 ------- 

669 matchMag : `np.ndarray` 

670 Array of matched magnitudes 

671 matchDelta : `np.ndarray` 

672 Array of matched deltas between src and standard stars. 

673 """ 

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

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

676 

677 matchDelta = None 

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

679 src = butler.getDirect(srcHandle) 

680 src = photoCal.calibrateCatalog(src) 

681 

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

683 

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

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

686 1./3600., maxmatch=1) 

687 

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

689 # Apply offset here to the catalog mag 

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

691 delta = srcMag - catMag 

692 if matchDelta is None: 

693 matchDelta = delta 

694 matchMag = catMag 

695 else: 

696 matchDelta = np.append(matchDelta, delta) 

697 matchMag = np.append(matchMag, catMag) 

698 

699 return matchMag, matchDelta 

700 

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

702 rawRepeatability, filterNCalibMap): 

703 """Test running of FgcmCalibrateTractTask 

704 

705 Parameters 

706 ---------- 

707 instName : `str` 

708 Short name of the instrument 

709 testName : `str` 

710 Base name of the test collection 

711 visits : `list` 

712 List of visits to calibrate 

713 tract : `int` 

714 Tract number 

715 skymapName : `str` 

716 Name of the sky map 

717 rawRepeatability : `np.array` 

718 Expected raw repeatability after convergence. 

719 Length should be number of bands. 

720 filterNCalibMap : `dict` 

721 Mapping from filter name to number of photoCalibs created. 

722 """ 

723 instCamel = instName.title() 

724 

725 configFiles = {'fgcmCalibrateTractTable': 

726 [os.path.join(ROOT, 

727 'config', 

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

729 

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

731 

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

733 'refcats/gen2'] 

734 

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

736 

737 self._runPipeline(self.repo, 

738 os.path.join(ROOT, 

739 'pipelines', 

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

741 queryString=queryString, 

742 configFiles=configFiles, 

743 inputCollections=inputCollections, 

744 outputCollection=outputCollection, 

745 registerDatasetTypes=True) 

746 

747 butler = dafButler.Butler(self.repo) 

748 

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

750 

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

752 dimensions=['tract'], 

753 collections=outputCollection, 

754 where=whereClause) 

755 

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

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

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

759 

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

761 for filterName in filterNCalibMap.keys(): 

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

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

764 

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

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

767 collections=outputCollection, 

768 where=whereClause) 

769 

770 count = 0 

771 for ref in set(refs): 

772 expCat = butler.getDirect(ref) 

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

774 count += test.size 

775 

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

777 

778 # Check that every visit got a transmission 

779 for visit in visits: 

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

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

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

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

784 collections=outputCollection, 

785 where=whereClause) 

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

787 

788 @classmethod 

789 def tearDownClass(cls): 

790 """Tear down and clear directories 

791 """ 

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

793 shutil.rmtree(cls.testDir, True)