Coverage for tests/fgcmcalTestBase.py: 10%
248 statements
« prev ^ index » next coverage.py v7.5.0, created at 2024-04-24 04:24 -0700
« prev ^ index » next coverage.py v7.5.0, created at 2024-04-24 04:24 -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.
25This class is used as the basis for individual obs package tests using
26data from testdata_jointcal for Gen3 repos.
27"""
29import os
30import shutil
31import numpy as np
32import glob
33import esutil
35import lsst.daf.butler as dafButler
36import lsst.pipe.base as pipeBase
37import lsst.geom as geom
38from lsst.pipe.base import Pipeline, ExecutionResources
39from lsst.ctrl.mpexec import SimplePipelineExecutor
41import lsst.fgcmcal as fgcmcal
43ROOT = os.path.abspath(os.path.dirname(__file__))
46class FgcmcalTestBase(object):
47 """Base class for gen3 fgcmcal tests, to genericize some test running and setup.
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
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')
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'})
77 def _runPipeline(self, repo, pipelineFile, queryString='',
78 inputCollections=None, outputCollection=None,
79 configFiles={}, configOptions={},
80 registerDatasetTypes=False):
81 """Run a pipeline via pipetask.
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?
110 Returns
111 -------
112 exit_code : `int`
113 Exit code for pipetask run.
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)
130 resources = ExecutionResources(num_cores=1)
132 executor = SimplePipelineExecutor.from_pipeline(pipeline,
133 where=queryString,
134 butler=butler,
135 resources=resources)
136 quanta = executor.run(register_dataset_types=registerDatasetTypes)
138 return len(quanta)
140 def _testFgcmMakeLut(self, instName, testName, nBand, i0Std, i0Recon, i10Std, i10Recon):
141 """Test running of FgcmMakeLutTask
143 Parameters
144 ----------
145 instName : `str`
146 Short name of the instrument.
147 testName : `str`
148 Base name of the test collection.
149 nBand : `int`
150 Number of bands tested.
151 i0Std : `np.ndarray'
152 Values of i0Std to compare to.
153 i10Std : `np.ndarray`
154 Values of i10Std to compare to.
155 i0Recon : `np.ndarray`
156 Values of reconstructed i0 to compare to.
157 i10Recon : `np.ndarray`
158 Values of reconsntructed i10 to compare to.
159 """
160 instCamel = instName.title()
162 configFiles = {'fgcmMakeLut': [os.path.join(ROOT,
163 'config',
164 f'fgcmMakeLut{instCamel}.py')]}
165 outputCollection = f'{instName}/{testName}/lut'
167 self._runPipeline(self.repo,
168 os.path.join(ROOT,
169 'pipelines',
170 f'fgcmMakeLut{instCamel}.yaml'),
171 configFiles=configFiles,
172 inputCollections=[f'{instName}/calib', f'{instName}/testdata'],
173 outputCollection=outputCollection,
174 registerDatasetTypes=True)
176 # Check output values
177 butler = dafButler.Butler(self.repo)
178 lutCat = butler.get('fgcmLookUpTable',
179 collections=[outputCollection],
180 instrument=instName)
181 fgcmLut, lutIndexVals, lutStd = fgcmcal.utilities.translateFgcmLut(lutCat, {})
183 self.assertEqual(nBand, len(lutIndexVals[0]['FILTERNAMES']))
185 indices = fgcmLut.getIndices(np.arange(nBand, dtype=np.int32),
186 np.zeros(nBand) + np.log(lutStd[0]['PWVSTD']),
187 np.zeros(nBand) + lutStd[0]['O3STD'],
188 np.zeros(nBand) + np.log(lutStd[0]['TAUSTD']),
189 np.zeros(nBand) + lutStd[0]['ALPHASTD'],
190 np.zeros(nBand) + 1./np.cos(np.radians(lutStd[0]['ZENITHSTD'])),
191 np.zeros(nBand, dtype=np.int32),
192 np.zeros(nBand) + lutStd[0]['PMBSTD'])
193 i0 = fgcmLut.computeI0(np.zeros(nBand) + np.log(lutStd[0]['PWVSTD']),
194 np.zeros(nBand) + lutStd[0]['O3STD'],
195 np.zeros(nBand) + np.log(lutStd[0]['TAUSTD']),
196 np.zeros(nBand) + lutStd[0]['ALPHASTD'],
197 np.zeros(nBand) + 1./np.cos(np.radians(lutStd[0]['ZENITHSTD'])),
198 np.zeros(nBand) + lutStd[0]['PMBSTD'],
199 indices)
201 self.assertFloatsAlmostEqual(i0Recon, i0, msg='i0Recon', rtol=1e-5)
203 i1 = fgcmLut.computeI1(np.zeros(nBand) + np.log(lutStd[0]['PWVSTD']),
204 np.zeros(nBand) + lutStd[0]['O3STD'],
205 np.zeros(nBand) + np.log(lutStd[0]['TAUSTD']),
206 np.zeros(nBand) + lutStd[0]['ALPHASTD'],
207 np.zeros(nBand) + 1./np.cos(np.radians(lutStd[0]['ZENITHSTD'])),
208 np.zeros(nBand) + lutStd[0]['PMBSTD'],
209 indices)
211 self.assertFloatsAlmostEqual(i10Recon, i1/i0, msg='i10Recon', rtol=1e-5)
213 # Check that the standard atmosphere was output and non-zero.
214 atmStd = butler.get('fgcm_standard_atmosphere',
215 collections=[outputCollection],
216 instrument=instName)
217 bounds = atmStd.getWavelengthBounds()
218 lambdas = np.linspace(bounds[0], bounds[1], 1000)
219 tputs = atmStd.sampleAt(position=geom.Point2D(0.0, 0.0), wavelengths=lambdas)
220 self.assertGreater(np.min(tputs), 0.0)
222 # Check that the standard passbands were output and non-zero.
223 for physical_filter in fgcmLut.filterNames:
224 passband = butler.get('fgcm_standard_passband',
225 collections=[outputCollection],
226 instrument=instName,
227 physical_filter=physical_filter)
228 tputs = passband.sampleAt(position=geom.Point2D(0.0, 0.0), wavelengths=lambdas)
229 self.assertEqual(np.min(tputs), 0.0)
230 self.assertGreater(np.max(tputs), 0.0)
232 def _testFgcmBuildStarsTable(self, instName, testName, queryString, visits, nStar, nObs,
233 refcatCollection="refcats/gen2"):
234 """Test running of FgcmBuildStarsTableTask
236 Parameters
237 ----------
238 instName : `str`
239 Short name of the instrument.
240 testName : `str`
241 Base name of the test collection.
242 queryString : `str`
243 Query to send to the pipetask.
244 visits : `list`
245 List of visits to calibrate.
246 nStar : `int`
247 Number of stars expected.
248 nObs : `int`
249 Number of observations of stars expected.
250 refcatCollection : `str`, optional
251 Name of reference catalog collection.
252 """
253 instCamel = instName.title()
255 configFiles = {'fgcmBuildStarsTable': [os.path.join(ROOT,
256 'config',
257 f'fgcmBuildStarsTable{instCamel}.py')]}
258 outputCollection = f'{instName}/{testName}/buildstars'
260 self._runPipeline(self.repo,
261 os.path.join(ROOT,
262 'pipelines',
263 'fgcmBuildStarsTable%s.yaml' % (instCamel)),
264 configFiles=configFiles,
265 inputCollections=[f'{instName}/{testName}/lut',
266 refcatCollection],
267 outputCollection=outputCollection,
268 queryString=queryString,
269 registerDatasetTypes=True)
271 butler = dafButler.Butler(self.repo)
273 visitCat = butler.get('fgcmVisitCatalog', collections=[outputCollection],
274 instrument=instName)
275 self.assertEqual(len(visits), len(visitCat))
277 starIds = butler.get('fgcmStarIds', collections=[outputCollection],
278 instrument=instName)
279 self.assertEqual(len(starIds), nStar)
281 starObs = butler.get('fgcmStarObservations', collections=[outputCollection],
282 instrument=instName)
283 self.assertEqual(len(starObs), nObs)
285 def _testFgcmBuildFromIsolatedStars(self, instName, testName, queryString, visits, nStar, nObs,
286 refcatCollection="refcats/gen2"):
287 """Test running of FgcmBuildFromIsolatedStarsTask.
289 Parameters
290 ----------
291 instName : `str`
292 Short name of the instrument.
293 testName : `str`
294 Base name of the test collection.
295 queryString : `str`
296 Query to send to the pipetask.
297 visits : `list`
298 List of visits to calibrate.
299 nStar : `int`
300 Number of stars expected.
301 nObs : `int`
302 Number of observations of stars expected.
303 refcatCollection : `str`, optional
304 Name of reference catalog collection.
305 """
306 instCamel = instName.title()
308 configFiles = {'fgcmBuildFromIsolatedStars': [
309 os.path.join(ROOT,
310 'config',
311 f'fgcmBuildFromIsolatedStars{instCamel}.py')
312 ]}
313 outputCollection = f'{instName}/{testName}/buildstars'
315 self._runPipeline(self.repo,
316 os.path.join(ROOT,
317 'pipelines',
318 'fgcmBuildFromIsolatedStars%s.yaml' % (instCamel)),
319 configFiles=configFiles,
320 inputCollections=[f'{instName}/{testName}/lut',
321 refcatCollection],
322 outputCollection=outputCollection,
323 queryString=queryString,
324 registerDatasetTypes=True)
326 butler = dafButler.Butler(self.repo)
328 visitCat = butler.get('fgcmVisitCatalog', collections=[outputCollection],
329 instrument=instName)
330 self.assertEqual(len(visits), len(visitCat))
332 starIds = butler.get('fgcm_star_ids', collections=[outputCollection],
333 instrument=instName)
334 self.assertEqual(len(starIds), nStar)
336 starObs = butler.get('fgcm_star_observations', collections=[outputCollection],
337 instrument=instName)
338 self.assertEqual(len(starObs), nObs)
340 def _testFgcmFitCycle(self, instName, testName, cycleNumber,
341 nZp, nGoodZp, nOkZp, nBadZp, nStdStars, nPlots,
342 skipChecks=False, extraConfig=None):
343 """Test running of FgcmFitCycleTask
345 Parameters
346 ----------
347 instName : `str`
348 Short name of the instrument.
349 testName : `str`
350 Base name of the test collection.
351 cycleNumber : `int`
352 Fit cycle number.
353 nZp : `int`
354 Number of zeropoints created by the task.
355 nGoodZp : `int`
356 Number of good (photometric) zeropoints created.
357 nOkZp : `int`
358 Number of constrained zeropoints (photometric or not).
359 nBadZp : `int`
360 Number of unconstrained (bad) zeropoints.
361 nStdStars : `int`
362 Number of standard stars produced.
363 nPlots : `int`
364 Number of plots produced.
365 skipChecks : `bool`, optional
366 Skip number checks, when running less-than-final cycle.
367 extraConfig : `str`, optional
368 Name of an extra config file to apply.
369 """
370 instCamel = instName.title()
372 configFiles = {'fgcmFitCycle': [os.path.join(ROOT,
373 'config',
374 f'fgcmFitCycle{instCamel}.py')]}
375 if extraConfig is not None:
376 configFiles['fgcmFitCycle'].append(extraConfig)
378 outputCollection = f'{instName}/{testName}/fit'
380 if cycleNumber == 0:
381 inputCollections = [f'{instName}/{testName}/buildstars']
382 else:
383 # In these tests we are running the fit cycle task multiple
384 # times into the same output collection. This code allows
385 # us to find the correct chained input collections to use
386 # so that we can both read from previous runs in the output
387 # collection and write to a new run in the output collection.
388 # Note that this behavior is handled automatically by the
389 # pipetask command-line interface, but not by the python
390 # API.
391 butler = dafButler.Butler(self.repo)
392 inputCollections = list(butler.registry.getCollectionChain(outputCollection))
394 cwd = os.getcwd()
395 runDir = os.path.join(self.testDir, testName)
396 os.makedirs(runDir, exist_ok=True)
397 os.chdir(runDir)
399 configOptions = {'fgcmFitCycle':
400 {'cycleNumber': f'{cycleNumber}',
401 'connections.previousCycleNumber': f'{cycleNumber - 1}',
402 'connections.cycleNumber': f'{cycleNumber}'}}
403 self._runPipeline(self.repo,
404 os.path.join(ROOT,
405 'pipelines',
406 f'fgcmFitCycle{instCamel}.yaml'),
407 configFiles=configFiles,
408 inputCollections=inputCollections,
409 outputCollection=outputCollection,
410 configOptions=configOptions,
411 registerDatasetTypes=True)
413 os.chdir(cwd)
415 if skipChecks:
416 return
418 butler = dafButler.Butler(self.repo)
420 config = butler.get('fgcmFitCycle_config', collections=[outputCollection])
422 # Check that the expected number of plots are there.
423 plots = glob.glob(os.path.join(runDir, config.outfileBase
424 + '_cycle%02d_plots/' % (cycleNumber)
425 + '*.png'))
426 self.assertEqual(len(plots), nPlots)
428 zps = butler.get('fgcmZeropoints%d' % (cycleNumber),
429 collections=[outputCollection],
430 instrument=instName)
431 self.assertEqual(len(zps), nZp)
433 gd, = np.where(zps['fgcmFlag'] == 1)
434 self.assertEqual(len(gd), nGoodZp)
436 ok, = np.where(zps['fgcmFlag'] < 16)
437 self.assertEqual(len(ok), nOkZp)
439 bd, = np.where(zps['fgcmFlag'] >= 16)
440 self.assertEqual(len(bd), nBadZp)
442 # Check that there are no illegal values with the ok zeropoints
443 test, = np.where(zps['fgcmZpt'][gd] < -9000.0)
444 self.assertEqual(len(test), 0)
446 stds = butler.get('fgcmStandardStars%d' % (cycleNumber),
447 collections=[outputCollection],
448 instrument=instName)
450 self.assertEqual(len(stds), nStdStars)
452 def _testFgcmOutputProducts(self, instName, testName,
453 zpOffsets, testVisit, testCcd, testFilter, testBandIndex,
454 testSrc=True):
455 """Test running of FgcmOutputProductsTask.
457 Parameters
458 ----------
459 instName : `str`
460 Short name of the instrument.
461 testName : `str`
462 Base name of the test collection.
463 zpOffsets : `np.ndarray`
464 Zeropoint offsets expected.
465 testVisit : `int`
466 Visit id to check for round-trip computations.
467 testCcd : `int`
468 Ccd id to check for round-trip computations.
469 testFilter : `str`
470 Filtername for testVisit/testCcd.
471 testBandIndex : `int`
472 Band index for testVisit/testCcd.
473 testSrc : `bool`, optional
474 Test the source catalogs? (Only if available in dataset.)
475 """
476 instCamel = instName.title()
478 configFiles = {'fgcmOutputProducts': [os.path.join(ROOT,
479 'config',
480 f'fgcmOutputProducts{instCamel}.py')]}
481 inputCollection = f'{instName}/{testName}/fit'
482 outputCollection = f'{instName}/{testName}/fit/output'
484 self._runPipeline(self.repo,
485 os.path.join(ROOT,
486 'pipelines',
487 'fgcmOutputProducts%s.yaml' % (instCamel)),
488 configFiles=configFiles,
489 inputCollections=[inputCollection],
490 outputCollection=outputCollection,
491 registerDatasetTypes=True)
493 butler = dafButler.Butler(self.repo)
494 offsetCat = butler.get('fgcmReferenceCalibrationOffsets',
495 collections=[outputCollection], instrument=instName)
496 offsets = offsetCat['offset'][:]
497 self.assertFloatsAlmostEqual(offsets, zpOffsets, atol=1e-6)
499 config = butler.get('fgcmOutputProducts_config',
500 collections=[outputCollection], instrument=instName)
502 rawStars = butler.get('fgcmStandardStars' + config.connections.cycleNumber,
503 collections=[inputCollection], instrument=instName)
505 # Test the fgcm_photoCalib output
506 zptCat = butler.get('fgcmZeropoints' + config.connections.cycleNumber,
507 collections=[inputCollection], instrument=instName)
509 good = (zptCat['fgcmFlag'] < 16)
510 bad = (zptCat['fgcmFlag'] >= 16)
512 # Read in all the calibrations, these should all be there
513 # This test is simply to ensure that all the photoCalib files exist
514 visits = np.unique(zptCat['visit'][good])
515 photoCalibDict = {}
516 for visit in visits:
517 expCat = butler.get('fgcmPhotoCalibCatalog',
518 visit=visit,
519 collections=[outputCollection], instrument=instName)
520 for row in expCat:
521 if row['visit'] == visit:
522 photoCalibDict[(visit, row['id'])] = row.getPhotoCalib()
524 # Check that all of the good photocalibs are there.
525 for rec in zptCat[good]:
526 self.assertTrue((rec['visit'], rec['detector']) in photoCalibDict)
528 # Check that none of the bad photocalibs are there.
529 for rec in zptCat[bad]:
530 self.assertFalse((rec['visit'], rec['detector']) in photoCalibDict)
532 # We do round-trip value checking on just the final one (chosen arbitrarily)
533 testCal = photoCalibDict[(testVisit, testCcd)]
535 if testSrc:
536 src = butler.get('src', visit=int(testVisit), detector=int(testCcd),
537 collections=[outputCollection], instrument=instName)
539 # Only test sources with positive flux
540 gdSrc = (src['slot_CalibFlux_instFlux'] > 0.0)
542 # We need to apply the calibration offset to the fgcmzpt (which is internal
543 # and doesn't know about that yet)
544 testZpInd, = np.where((zptCat['visit'] == testVisit)
545 & (zptCat['detector'] == testCcd))
546 fgcmZpt = (zptCat['fgcmZpt'][testZpInd] + offsets[testBandIndex]
547 + zptCat['fgcmDeltaChrom'][testZpInd])
548 fgcmZptGrayErr = np.sqrt(zptCat['fgcmZptVar'][testZpInd])
550 if config.doComposeWcsJacobian:
551 # The raw zeropoint needs to be modified to know about the wcs jacobian
552 refs = butler.registry.queryDatasets('camera', dimensions=['instrument'],
553 collections=...)
554 camera = butler.get(list(refs)[0])
555 approxPixelAreaFields = fgcmcal.utilities.computeApproxPixelAreaFields(camera)
556 center = approxPixelAreaFields[testCcd].getBBox().getCenter()
557 pixAreaCorr = approxPixelAreaFields[testCcd].evaluate(center)
558 fgcmZpt += -2.5*np.log10(pixAreaCorr)
560 # This is the magnitude through the mean calibration
561 photoCalMeanCalMags = np.zeros(gdSrc.sum())
562 # This is the magnitude through the full focal-plane variable mags
563 photoCalMags = np.zeros_like(photoCalMeanCalMags)
564 # This is the magnitude with the FGCM (central-ccd) zeropoint
565 zptMeanCalMags = np.zeros_like(photoCalMeanCalMags)
567 for i, rec in enumerate(src[gdSrc]):
568 photoCalMeanCalMags[i] = testCal.instFluxToMagnitude(rec['slot_CalibFlux_instFlux'])
569 photoCalMags[i] = testCal.instFluxToMagnitude(rec['slot_CalibFlux_instFlux'],
570 rec.getCentroid())
571 zptMeanCalMags[i] = fgcmZpt[0] - 2.5*np.log10(rec['slot_CalibFlux_instFlux'])
573 # These should be very close but some tiny differences because the fgcm value
574 # is defined at the center of the bbox, and the photoCal is the mean over the box
575 self.assertFloatsAlmostEqual(photoCalMeanCalMags,
576 zptMeanCalMags, rtol=1e-6)
577 # These should be roughly equal, but not precisely because of the focal-plane
578 # variation. However, this is a useful sanity check for something going totally
579 # wrong.
580 self.assertFloatsAlmostEqual(photoCalMeanCalMags,
581 photoCalMags, rtol=1e-2)
583 # The next test compares the "FGCM standard magnitudes" (which are output
584 # from the fgcm code itself) to the "calibrated magnitudes" that are
585 # obtained from running photoCalib.calibrateCatalog() on the original
586 # src catalogs. This summary comparison ensures that using photoCalibs
587 # yields the same results as what FGCM is computing internally.
588 # Note that we additionally need to take into account the post-processing
589 # offsets used in the tests.
591 # For decent statistics, we are matching all the sources from one visit
592 # (multiple ccds)
593 whereClause = f"instrument='{instName:s}' and visit={testVisit:d}"
594 srcRefs = butler.registry.queryDatasets('src', dimensions=['visit'],
595 collections='%s/testdata' % (instName),
596 where=whereClause,
597 findFirst=True)
598 photoCals = []
599 for srcRef in srcRefs:
600 photoCals.append(photoCalibDict[(testVisit, srcRef.dataId['detector'])])
602 matchMag, matchDelta = self._getMatchedVisitCat(butler, srcRefs, photoCals,
603 rawStars, testBandIndex, offsets)
605 st = np.argsort(matchMag)
606 # Compare the brightest 25% of stars. No matter the setting of
607 # deltaMagBkgOffsetPercentile, we want to ensure that these stars
608 # match on average.
609 brightest, = np.where(matchMag < matchMag[st[int(0.25*st.size)]])
610 self.assertFloatsAlmostEqual(np.median(matchDelta[brightest]), 0.0, atol=0.002)
612 # And the photoCal error is just the zeropoint gray error
613 self.assertFloatsAlmostEqual(testCal.getCalibrationErr(),
614 (np.log(10.0)/2.5)*testCal.getCalibrationMean()*fgcmZptGrayErr)
616 # Test the transmission output
617 visitCatalog = butler.get('fgcmVisitCatalog', collections=[inputCollection],
618 instrument=instName)
619 lutCat = butler.get('fgcmLookUpTable', collections=[inputCollection],
620 instrument=instName)
622 testTrans = butler.get('transmission_atmosphere_fgcm',
623 visit=visitCatalog[0]['visit'],
624 collections=[outputCollection], instrument=instName)
625 testResp = testTrans.sampleAt(position=geom.Point2D(0, 0),
626 wavelengths=lutCat[0]['atmLambda'])
628 # The test fit is performed with the atmosphere parameters frozen
629 # (freezeStdAtmosphere = True). Thus the only difference between
630 # these output atmospheres and the standard is the different
631 # airmass. Furthermore, this is a very rough comparison because
632 # the look-up table is computed with very coarse sampling for faster
633 # testing.
635 # To account for overall throughput changes, we scale by the median ratio,
636 # we only care about the shape
637 ratio = np.median(testResp/lutCat[0]['atmStdTrans'])
638 self.assertFloatsAlmostEqual(testResp/ratio, lutCat[0]['atmStdTrans'], atol=0.2)
640 # The second should be close to the first, but there is the airmass
641 # difference so they aren't identical.
642 testTrans2 = butler.get('transmission_atmosphere_fgcm',
643 visit=visitCatalog[1]['visit'],
644 collections=[outputCollection], instrument=instName)
645 testResp2 = testTrans2.sampleAt(position=geom.Point2D(0, 0),
646 wavelengths=lutCat[0]['atmLambda'])
648 # As above, we scale by the ratio to compare the shape of the curve.
649 ratio = np.median(testResp/testResp2)
650 self.assertFloatsAlmostEqual(testResp/ratio, testResp2, atol=0.04)
652 def _testFgcmMultiFit(self, instName, testName, queryString, visits, zpOffsets,
653 refcatCollection="refcats/gen2"):
654 """Test running the full pipeline with multiple fit cycles.
656 Parameters
657 ----------
658 instName : `str`
659 Short name of the instrument.
660 testName : `str`
661 Base name of the test collection.
662 queryString : `str`
663 Query to send to the pipetask.
664 visits : `list`
665 List of visits to calibrate.
666 zpOffsets : `np.ndarray`
667 Zeropoint offsets expected.
668 refcatCollection : `str`, optional
669 Name of reference catalog collection.
670 """
671 instCamel = instName.title()
673 configFiles = {'fgcmBuildFromIsolatedStars': [
674 os.path.join(ROOT,
675 'config',
676 f'fgcmBuildFromIsolatedStars{instCamel}.py'
677 )],
678 'fgcmFitCycle': [os.path.join(ROOT,
679 'config',
680 f'fgcmFitCycle{instCamel}.py')],
681 'fgcmOutputProducts': [os.path.join(ROOT,
682 'config',
683 f'fgcmOutputProducts{instCamel}.py')]}
684 outputCollection = f'{instName}/{testName}/unified'
686 cwd = os.getcwd()
687 runDir = os.path.join(self.testDir, testName)
688 os.makedirs(runDir)
689 os.chdir(runDir)
691 self._runPipeline(self.repo,
692 os.path.join(ROOT,
693 'pipelines',
694 f'fgcmFullPipeline{instCamel}.yaml'),
695 configFiles=configFiles,
696 inputCollections=[f'{instName}/{testName}/lut',
697 refcatCollection],
698 outputCollection=outputCollection,
699 queryString=queryString,
700 registerDatasetTypes=True)
702 os.chdir(cwd)
704 butler = dafButler.Butler(self.repo)
706 offsetCat = butler.get('fgcmReferenceCalibrationOffsets',
707 collections=[outputCollection], instrument=instName)
708 offsets = offsetCat['offset'][:]
709 self.assertFloatsAlmostEqual(offsets, zpOffsets, atol=1e-6)
711 def _getMatchedVisitCat(self, butler, srcHandles, photoCals,
712 rawStars, bandIndex, offsets):
713 """
714 Get a list of matched magnitudes and deltas from calibrated src catalogs.
716 Parameters
717 ----------
718 butler : `lsst.daf.butler.Butler`
719 srcHandles : `list`
720 Handles of source catalogs.
721 photoCals : `list`
722 photoCalib objects, matched to srcHandles.
723 rawStars : `lsst.afw.table.SourceCatalog`
724 Fgcm standard stars.
725 bandIndex : `int`
726 Index of the band for the source catalogs.
727 offsets : `np.ndarray`
728 Testing calibration offsets to apply to rawStars.
730 Returns
731 -------
732 matchMag : `np.ndarray`
733 Array of matched magnitudes.
734 matchDelta : `np.ndarray`
735 Array of matched deltas between src and standard stars.
736 """
737 matcher = esutil.htm.Matcher(11, np.rad2deg(rawStars['coord_ra']),
738 np.rad2deg(rawStars['coord_dec']))
740 matchDelta = None
741 for srcHandle, photoCal in zip(srcHandles, photoCals):
742 src = butler.get(srcHandle)
743 src = photoCal.calibrateCatalog(src)
745 gdSrc, = np.where(np.nan_to_num(src['slot_CalibFlux_flux']) > 0.0)
747 matches = matcher.match(np.rad2deg(src['coord_ra'][gdSrc]),
748 np.rad2deg(src['coord_dec'][gdSrc]),
749 1./3600., maxmatch=1)
751 srcMag = src['slot_CalibFlux_mag'][gdSrc][matches[0]]
752 # Apply offset here to the catalog mag
753 catMag = rawStars['mag_std_noabs'][matches[1]][:, bandIndex] + offsets[bandIndex]
754 delta = srcMag - catMag
755 if matchDelta is None:
756 matchDelta = delta
757 matchMag = catMag
758 else:
759 matchDelta = np.append(matchDelta, delta)
760 matchMag = np.append(matchMag, catMag)
762 return matchMag, matchDelta
764 def _testFgcmCalibrateTract(self, instName, testName, visits, tract, skymapName,
765 rawRepeatability, filterNCalibMap):
766 """Test running of FgcmCalibrateTractTask
768 Parameters
769 ----------
770 instName : `str`
771 Short name of the instrument.
772 testName : `str`
773 Base name of the test collection.
774 visits : `list`
775 List of visits to calibrate.
776 tract : `int`
777 Tract number.
778 skymapName : `str`
779 Name of the sky map.
780 rawRepeatability : `np.array`
781 Expected raw repeatability after convergence.
782 Length should be number of bands.
783 filterNCalibMap : `dict`
784 Mapping from filter name to number of photoCalibs created.
785 """
786 instCamel = instName.title()
788 configFiles = {'fgcmCalibrateTractTable':
789 [os.path.join(ROOT,
790 'config',
791 f'fgcmCalibrateTractTable{instCamel}.py')]}
793 outputCollection = f'{instName}/{testName}/tract'
795 inputCollections = [f'{instName}/{testName}/lut',
796 'refcats/gen2']
798 queryString = f"tract={tract:d} and skymap='{skymapName:s}'"
800 self._runPipeline(self.repo,
801 os.path.join(ROOT,
802 'pipelines',
803 f'fgcmCalibrateTractTable{instCamel:s}.yaml'),
804 queryString=queryString,
805 configFiles=configFiles,
806 inputCollections=inputCollections,
807 outputCollection=outputCollection,
808 registerDatasetTypes=True)
810 butler = dafButler.Butler(self.repo)
812 whereClause = f"instrument='{instName:s}' and tract={tract:d} and skymap='{skymapName:s}'"
814 repRefs = butler.registry.queryDatasets('fgcmRawRepeatability',
815 dimensions=['tract'],
816 collections=outputCollection,
817 where=whereClause)
819 repeatabilityCat = butler.get(list(repRefs)[0])
820 repeatability = repeatabilityCat['rawRepeatability'][:]
821 self.assertFloatsAlmostEqual(repeatability, rawRepeatability, atol=4e-6)
823 # Check that the number of photoCalib objects in each filter are what we expect
824 for filterName in filterNCalibMap.keys():
825 whereClause = (f"instrument='{instName:s}' and tract={tract:d} and "
826 f"physical_filter='{filterName:s}' and skymap='{skymapName:s}'")
828 refs = butler.registry.queryDatasets('fgcmPhotoCalibTractCatalog',
829 dimensions=['tract', 'physical_filter'],
830 collections=outputCollection,
831 where=whereClause)
833 count = 0
834 for ref in set(refs):
835 expCat = butler.get(ref)
836 test, = np.where((expCat['visit'] > 0) & (expCat['id'] >= 0))
837 count += test.size
839 self.assertEqual(count, filterNCalibMap[filterName])
841 # Check that every visit got a transmission
842 for visit in visits:
843 whereClause = (f"instrument='{instName:s}' and tract={tract:d} and "
844 f"visit={visit:d} and skymap='{skymapName:s}'")
845 refs = butler.registry.queryDatasets('transmission_atmosphere_fgcm_tract',
846 dimensions=['tract', 'visit'],
847 collections=outputCollection,
848 where=whereClause)
849 self.assertEqual(len(set(refs)), 1)
851 @classmethod
852 def tearDownClass(cls):
853 """Tear down and clear directories.
854 """
855 if os.path.exists(cls.testDir):
856 shutil.rmtree(cls.testDir, True)