Coverage for tests / fgcmcalTestBase.py: 8%
305 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-07 08:49 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-07 08:49 +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.
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 esutil
33import warnings
35import lsst.afw.image
36import lsst.daf.butler as dafButler
37import lsst.pipe.base as pipeBase
38import lsst.geom as geom
39from lsst.pipe.base import Pipeline, ExecutionResources
40from lsst.pipe.base.simple_pipeline_executor import SimplePipelineExecutor
42import lsst.fgcmcal as fgcmcal
44ROOT = os.path.abspath(os.path.dirname(__file__))
47class FgcmcalTestBase(object):
48 """Base class for gen3 fgcmcal tests, to genericize some test running and setup.
50 Derive from this first, then from TestCase.
51 """
52 @classmethod
53 def _importRepository(cls, instrument, exportPath, exportFile):
54 """Import a test repository into self.testDir
56 Parameters
57 ----------
58 instrument : `str`
59 Full string name for the instrument.
60 exportPath : `str`
61 Path to location of repository to export.
62 exportFile : `str`
63 Filename of export data.
64 """
65 cls.repo = os.path.join(cls.testDir, 'testrepo')
67 # Make the repo and retrieve a writeable Butler
68 _ = dafButler.Butler.makeRepo(cls.repo)
69 with dafButler.Butler.from_config(cls.repo, writeable=True) as butler:
70 # Register the instrument
71 instrInstance = pipeBase.Instrument.from_string(instrument)
72 instrInstance.register(butler.registry)
73 # Import the exportFile
74 butler.import_(directory=exportPath, filename=exportFile,
75 transfer='symlink',
76 skip_dimensions={'instrument', 'detector', 'physical_filter'})
78 def _runPipeline(self, repo, pipelineFile, queryString='',
79 inputCollections=None, outputCollection=None,
80 configFiles={}, configOptions={},
81 registerDatasetTypes=False):
82 """Run a pipeline via pipetask.
84 Parameters
85 ----------
86 repo : `str`
87 Gen3 repository yaml file.
88 pipelineFile : `str`
89 Pipeline definition file.
90 queryString : `str`, optional
91 Where query that defines the data to use.
92 inputCollections : `list` [`str`], optional
93 Input collections list.
94 outputCollection : `str`, optional
95 Output collection name.
96 configFiles : `dict` [`list`], optional
97 Dictionary of config files. The key of the ``configFiles``
98 dict is the relevant task label. The value of ``configFiles``
99 is a list of config files to apply (in order) to that task.
100 configOptions : `dict` [`dict`], optional
101 Dictionary of individual config options. The key of the
102 ``configOptions`` dict is the relevant task label. The value
103 of ``configOptions`` is another dict that contains config
104 key/value overrides to apply.
105 configOptions : `list` [`str`], optional
106 List of individual config options to use. Each string will
107 be of the form ``taskName:configField=value``.
108 registerDatasetTypes : `bool`, optional
109 Register new dataset types?
111 Returns
112 -------
113 exit_code : `int`
114 Exit code for pipetask run.
116 Raises
117 ------
118 RuntimeError : Raised if the "pipetask" call fails.
119 """
120 butler = SimplePipelineExecutor.prep_butler(repo,
121 inputs=inputCollections,
122 output=outputCollection)
123 self.enterContext(butler)
124 pipeline = Pipeline.fromFile(pipelineFile)
125 for taskName, fileList in configFiles.items():
126 for fileName in fileList:
127 pipeline.addConfigFile(taskName, fileName)
128 for taskName, configDict in configOptions.items():
129 for option, value in configDict.items():
130 pipeline.addConfigOverride(taskName, option, value)
132 resources = ExecutionResources(num_cores=1)
134 executor = SimplePipelineExecutor.from_pipeline(pipeline,
135 where=queryString,
136 butler=butler,
137 resources=resources)
138 quanta = executor.run(register_dataset_types=registerDatasetTypes)
140 return len(quanta)
142 def _testFgcmMakeLut(self, instName, testName, nBand, i0Std, i0Recon, i10Std, i10Recon):
143 """Test running of FgcmMakeLutTask
145 Parameters
146 ----------
147 instName : `str`
148 Short name of the instrument.
149 testName : `str`
150 Base name of the test collection.
151 nBand : `int`
152 Number of bands tested.
153 i0Std : `np.ndarray'
154 Values of i0Std to compare to.
155 i10Std : `np.ndarray`
156 Values of i10Std to compare to.
157 i0Recon : `np.ndarray`
158 Values of reconstructed i0 to compare to.
159 i10Recon : `np.ndarray`
160 Values of reconsntructed i10 to compare to.
161 """
162 instCamel = instName.title()
164 configFiles = {'fgcmMakeLut': [os.path.join(ROOT,
165 'config',
166 f'fgcmMakeLut{instCamel}.py')]}
167 outputCollection = f'{instName}/{testName}/lut'
169 self._runPipeline(self.repo,
170 os.path.join(ROOT,
171 'pipelines',
172 f'fgcmMakeLut{instCamel}.yaml'),
173 configFiles=configFiles,
174 inputCollections=[f'{instName}/calib', f'{instName}/testdata'],
175 outputCollection=outputCollection,
176 registerDatasetTypes=True)
178 # Check output values
179 butler = dafButler.Butler(self.repo)
180 self.enterContext(butler)
181 lutCat = butler.get('fgcmLookUpTable',
182 collections=[outputCollection],
183 instrument=instName)
184 fgcmLut, lutIndexVals, lutStd = fgcmcal.utilities.translateFgcmLut(lutCat, {})
186 self.assertEqual(nBand, len(lutIndexVals[0]['FILTERNAMES']))
188 indices = fgcmLut.getIndices(np.arange(nBand, dtype=np.int32),
189 np.zeros(nBand) + np.log(lutStd[0]['PWVSTD']),
190 np.zeros(nBand) + lutStd[0]['O3STD'],
191 np.zeros(nBand) + np.log(lutStd[0]['TAUSTD']),
192 np.zeros(nBand) + lutStd[0]['ALPHASTD'],
193 np.zeros(nBand) + 1./np.cos(np.radians(lutStd[0]['ZENITHSTD'])),
194 np.zeros(nBand, dtype=np.int32),
195 np.zeros(nBand) + lutStd[0]['PMBSTD'])
196 i0 = fgcmLut.computeI0(np.zeros(nBand) + np.log(lutStd[0]['PWVSTD']),
197 np.zeros(nBand) + lutStd[0]['O3STD'],
198 np.zeros(nBand) + np.log(lutStd[0]['TAUSTD']),
199 np.zeros(nBand) + lutStd[0]['ALPHASTD'],
200 np.zeros(nBand) + 1./np.cos(np.radians(lutStd[0]['ZENITHSTD'])),
201 np.zeros(nBand) + lutStd[0]['PMBSTD'],
202 indices)
204 self.assertFloatsAlmostEqual(i0Recon, i0, msg='i0Recon', rtol=1e-5)
206 i1 = fgcmLut.computeI1(np.zeros(nBand) + np.log(lutStd[0]['PWVSTD']),
207 np.zeros(nBand) + lutStd[0]['O3STD'],
208 np.zeros(nBand) + np.log(lutStd[0]['TAUSTD']),
209 np.zeros(nBand) + lutStd[0]['ALPHASTD'],
210 np.zeros(nBand) + 1./np.cos(np.radians(lutStd[0]['ZENITHSTD'])),
211 np.zeros(nBand) + lutStd[0]['PMBSTD'],
212 indices)
214 self.assertFloatsAlmostEqual(i10Recon, i1/i0, msg='i10Recon', rtol=1e-5)
216 # Check that the standard atmosphere was output and non-zero.
217 atmStd = butler.get('fgcm_standard_atmosphere',
218 collections=[outputCollection],
219 instrument=instName)
220 bounds = atmStd.getWavelengthBounds()
221 lambdas = np.linspace(bounds[0], bounds[1], 1000)
222 tputs = atmStd.sampleAt(position=geom.Point2D(0.0, 0.0), wavelengths=lambdas)
223 self.assertGreater(np.min(tputs), 0.0)
225 # Check that the standard passbands were output and non-zero.
226 for physical_filter in fgcmLut.filterNames:
227 passband = butler.get('fgcm_standard_passband',
228 collections=[outputCollection],
229 instrument=instName,
230 physical_filter=physical_filter)
231 tputs = passband.sampleAt(position=geom.Point2D(0.0, 0.0), wavelengths=lambdas)
232 self.assertEqual(np.min(tputs), 0.0)
233 self.assertGreater(np.max(tputs), 0.0)
235 # Check that the standard passband tables were output and non-zero.
236 refs = butler.query_datasets(
237 "standard_passband",
238 collections=[outputCollection],
239 instrument=instName,
240 )
241 self.assertEqual(len(refs), len(fgcmLut.filterNames))
242 for ref in refs:
243 passbandTable = butler.get(ref)
244 self.assertEqual(np.min(passbandTable["throughput"]), 0.0)
245 self.assertGreater(np.max(passbandTable["throughput"]), 0.0)
247 def _testFgcmBuildStarsTable(self, instName, testName, queryString, visits, nStar, nObs,
248 refcatCollection="refcats/gen2"):
249 """Test running of FgcmBuildStarsTableTask
251 Parameters
252 ----------
253 instName : `str`
254 Short name of the instrument.
255 testName : `str`
256 Base name of the test collection.
257 queryString : `str`
258 Query to send to the pipetask.
259 visits : `list`
260 List of visits to calibrate.
261 nStar : `int`
262 Number of stars expected.
263 nObs : `int`
264 Number of observations of stars expected.
265 refcatCollection : `str`, optional
266 Name of reference catalog collection.
267 """
268 instCamel = instName.title()
270 configFiles = {'fgcmBuildStarsTable': [os.path.join(ROOT,
271 'config',
272 f'fgcmBuildStarsTable{instCamel}.py')]}
273 outputCollection = f'{instName}/{testName}/buildstars'
275 self._runPipeline(self.repo,
276 os.path.join(ROOT,
277 'pipelines',
278 'fgcmBuildStarsTable%s.yaml' % (instCamel)),
279 configFiles=configFiles,
280 inputCollections=[f'{instName}/{testName}/lut',
281 refcatCollection],
282 outputCollection=outputCollection,
283 queryString=queryString,
284 registerDatasetTypes=True)
286 butler = dafButler.Butler(self.repo)
287 self.enterContext(butler)
289 visitCat = butler.get('fgcmVisitCatalog', collections=[outputCollection],
290 instrument=instName)
291 self.assertEqual(len(visits), len(visitCat))
293 starIds = butler.get('fgcmStarIds', collections=[outputCollection],
294 instrument=instName)
295 self.assertEqual(len(starIds), nStar)
297 starObs = butler.get('fgcmStarObservations', collections=[outputCollection],
298 instrument=instName)
299 self.assertEqual(len(starObs), nObs)
301 def _testFgcmBuildFromIsolatedStars(self, instName, testName, queryString, visits, nStar, nObs,
302 refcatCollection="refcats/gen2"):
303 """Test running of FgcmBuildFromIsolatedStarsTask.
305 Parameters
306 ----------
307 instName : `str`
308 Short name of the instrument.
309 testName : `str`
310 Base name of the test collection.
311 queryString : `str`
312 Query to send to the pipetask.
313 visits : `list`
314 List of visits to calibrate.
315 nStar : `int`
316 Number of stars expected.
317 nObs : `int`
318 Number of observations of stars expected.
319 refcatCollection : `str`, optional
320 Name of reference catalog collection.
321 """
322 instCamel = instName.title()
324 configFiles = {'fgcmBuildFromIsolatedStars': [
325 os.path.join(ROOT,
326 'config',
327 f'fgcmBuildFromIsolatedStars{instCamel}.py')
328 ]}
329 outputCollection = f'{instName}/{testName}/buildstars'
331 self._runPipeline(self.repo,
332 os.path.join(ROOT,
333 'pipelines',
334 'fgcmBuildFromIsolatedStars%s.yaml' % (instCamel)),
335 configFiles=configFiles,
336 inputCollections=[f'{instName}/{testName}/lut',
337 refcatCollection],
338 outputCollection=outputCollection,
339 queryString=queryString,
340 registerDatasetTypes=True)
342 butler = dafButler.Butler(self.repo)
343 self.enterContext(butler)
345 visitCat = butler.get('fgcmVisitCatalog', collections=[outputCollection],
346 instrument=instName)
347 self.assertEqual(len(visits), len(visitCat))
349 starIds = butler.get('fgcm_star_ids', collections=[outputCollection],
350 instrument=instName)
351 self.assertEqual(len(starIds), nStar)
353 starObs = butler.get('fgcm_star_observations', collections=[outputCollection],
354 instrument=instName)
355 self.assertEqual(len(starObs), nObs)
357 def _testFgcmFitCycle(self, instName, testName, cycleNumber,
358 nZp, nGoodZp, nOkZp, nBadZp, nStdStars, nPlots,
359 skipChecks=False, extraConfig=None):
360 """Test running of FgcmFitCycleTask
362 Parameters
363 ----------
364 instName : `str`
365 Short name of the instrument.
366 testName : `str`
367 Base name of the test collection.
368 cycleNumber : `int`
369 Fit cycle number.
370 nZp : `int`
371 Number of zeropoints created by the task.
372 nGoodZp : `int`
373 Number of good (photometric) zeropoints created.
374 nOkZp : `int`
375 Number of constrained zeropoints (photometric or not).
376 nBadZp : `int`
377 Number of unconstrained (bad) zeropoints.
378 nStdStars : `int`
379 Number of standard stars produced.
380 nPlots : `int`
381 Number of plots produced.
382 skipChecks : `bool`, optional
383 Skip number checks, when running less-than-final cycle.
384 extraConfig : `str`, optional
385 Name of an extra config file to apply.
386 """
387 instCamel = instName.title()
389 configFiles = {'fgcmFitCycle': [os.path.join(ROOT,
390 'config',
391 f'fgcmFitCycle{instCamel}.py')]}
392 if extraConfig is not None:
393 configFiles['fgcmFitCycle'].append(extraConfig)
395 outputCollection = f'{instName}/{testName}/fit'
397 if cycleNumber == 0:
398 inputCollections = [f'{instName}/{testName}/buildstars']
399 else:
400 # In these tests we are running the fit cycle task multiple
401 # times into the same output collection. This code allows
402 # us to find the correct chained input collections to use
403 # so that we can both read from previous runs in the output
404 # collection and write to a new run in the output collection.
405 # Note that this behavior is handled automatically by the
406 # pipetask command-line interface, but not by the python
407 # API.
408 butler = dafButler.Butler(self.repo)
409 self.enterContext(butler)
410 inputCollections = list(butler.registry.getCollectionChain(outputCollection))
412 cwd = os.getcwd()
413 runDir = os.path.join(self.testDir, testName)
414 os.makedirs(runDir, exist_ok=True)
415 os.chdir(runDir)
417 configOptions = {'fgcmFitCycle':
418 {'cycleNumber': f'{cycleNumber}',
419 'connections.previousCycleNumber': f'{cycleNumber - 1}',
420 'connections.cycleNumber': f'{cycleNumber}'}}
421 self._runPipeline(self.repo,
422 os.path.join(ROOT,
423 'pipelines',
424 f'fgcmFitCycle{instCamel}.yaml'),
425 configFiles=configFiles,
426 inputCollections=inputCollections,
427 outputCollection=outputCollection,
428 configOptions=configOptions,
429 registerDatasetTypes=True)
431 os.chdir(cwd)
433 if skipChecks:
434 return
436 butler = dafButler.Butler(self.repo)
437 self.enterContext(butler)
439 # Check that the expected number of plots are there.
440 plotDatasets = list(butler.registry.queryDatasets(
441 f"fgcm_Cycle{cycleNumber}_*Plot",
442 collections=[outputCollection],
443 ))
444 self.assertEqual(len(plotDatasets), nPlots)
446 zps = butler.get(f'fgcm_Cycle{cycleNumber}_Zeropoints',
447 collections=[outputCollection],
448 instrument=instName)
449 self.assertEqual(len(zps), nZp)
451 gd, = np.where(zps['fgcmFlag'] == 1)
452 self.assertEqual(len(gd), nGoodZp)
454 ok, = np.where(zps['fgcmFlag'] < 16)
455 self.assertEqual(len(ok), nOkZp)
457 bd, = np.where(zps['fgcmFlag'] >= 16)
458 self.assertEqual(len(bd), nBadZp)
460 # Check that there are no illegal values with the ok zeropoints
461 test, = np.where(zps['fgcmZpt'][gd] < -9000.0)
462 self.assertEqual(len(test), 0)
464 stds = butler.get(f'fgcm_Cycle{cycleNumber}_StandardStars',
465 collections=[outputCollection],
466 instrument=instName)
468 self.assertEqual(len(stds), nStdStars)
470 def _testFgcmOutputProducts(self, instName, testName,
471 zpOffsets, testVisit, testCcd, testFilter, testBandIndex,
472 skymapName=None, testSrc=True):
473 """Test running of FgcmOutputProductsTask.
475 Parameters
476 ----------
477 instName : `str`
478 Short name of the instrument.
479 testName : `str`
480 Base name of the test collection.
481 zpOffsets : `np.ndarray`
482 Zeropoint offsets expected.
483 testVisit : `int`
484 Visit id to check for round-trip computations.
485 testCcd : `int`
486 Ccd id to check for round-trip computations.
487 testFilter : `str`
488 Filtername for testVisit/testCcd.
489 testBandIndex : `int`
490 Band index for testVisit/testCcd.
491 skymapName : `str`, optional
492 Name of a skymap to use if sharding by tract.
493 testSrc : `bool`, optional
494 Test the source catalogs? (Only if available in dataset.)
495 """
496 instCamel = instName.title()
498 configFiles = {'fgcmOutputProducts': [os.path.join(ROOT,
499 'config',
500 f'fgcmOutputProducts{instCamel}.py')]}
501 inputCollection = f'{instName}/{testName}/fit'
502 outputCollection = f'{instName}/{testName}/fit/output'
503 if skymapName is not None:
504 queryString = f"skymap='{skymapName:s}'"
505 else:
506 queryString = ""
508 self._runPipeline(self.repo,
509 os.path.join(ROOT,
510 'pipelines',
511 'fgcmOutputProducts%s.yaml' % (instCamel)),
512 queryString=queryString,
513 configFiles=configFiles,
514 inputCollections=[inputCollection, 'skymaps'],
515 outputCollection=outputCollection,
516 registerDatasetTypes=True)
518 butler = dafButler.Butler(self.repo, collections=[outputCollection], instrument=instName)
519 self.enterContext(butler)
520 offsetCat = butler.get('fgcmReferenceCalibrationOffsets')
521 offsets = offsetCat['offset'][:]
522 self.assertFloatsAlmostEqual(offsets, zpOffsets, atol=1e-6)
524 config = butler.get('fgcmOutputProducts_config',
525 collections=[outputCollection], instrument=instName)
527 rawStars = butler.get(f'fgcm_Cycle{config.connections.cycleNumber}_StandardStars',
528 collections=[inputCollection])
530 # Test the fgcm_photoCalib output
531 zptCat = butler.get(f'fgcm_Cycle{config.connections.cycleNumber}_Zeropoints')
533 good = (zptCat['fgcmFlag'] < 16)
534 bad = (zptCat['fgcmFlag'] >= 16)
536 # Read in all the calibrations, these should all be there
537 # This test is simply to ensure that all the photoCalib files exist
538 visits = np.unique(zptCat['visit'][good])
539 photoCalibDict = {}
540 for visit in visits:
541 expCat = butler.get('fgcmPhotoCalibCatalog',
542 visit=visit)
543 for row in expCat:
544 if row['visit'] == visit:
545 photoCalibDict[(visit, row['id'])] = row.getPhotoCalib()
547 # Check that all of the good photocalibs are there.
548 for rec in zptCat[good]:
549 self.assertTrue((rec['visit'], rec['detector']) in photoCalibDict)
551 # Check that none of the bad photocalibs are there.
552 for rec in zptCat[bad]:
553 self.assertFalse((rec['visit'], rec['detector']) in photoCalibDict)
555 # We do round-trip value checking on just the final one (chosen arbitrarily)
556 testCal = photoCalibDict[(testVisit, testCcd)]
558 # Check the output standard star catalogs.
559 if skymapName is not None:
560 refs = butler.query_datasets("fgcm_standard_star")
561 self.assertEqual(len(refs), 1)
562 catalog = butler.get(refs[0])
563 self.assertEqual(len(catalog), len(rawStars))
565 # Check that the fgcm_id array is what we expect.
566 np.testing.assert_array_equal(catalog["fgcm_id"], rawStars["id"])
568 # Check ids against inputs.
569 isoConfig = butler.get("fgcmBuildFromIsolatedStars_config")
570 isolatedCatalog = butler.get(
571 isoConfig.connections.isolated_star_cats,
572 tract=refs[0].dataId["tract"],
573 )
575 a, b = esutil.numpy_util.match(
576 np.asarray(catalog["isolated_star_id"]),
577 np.asarray(isolatedCatalog["isolated_star_id"]),
578 )
580 # All of the stars should be matched, and we can confirm
581 # that the RA values are equal as an additional check.
582 self.assertEqual(len(a), len(catalog))
583 np.testing.assert_array_almost_equal(catalog["ra"][a], isolatedCatalog["ra"][b])
585 if testSrc:
586 src = butler.get('src', visit=int(testVisit), detector=int(testCcd),
587 collections=[outputCollection], instrument=instName)
589 # Only test sources with positive flux
590 gdSrc = (src['slot_CalibFlux_instFlux'] > 0.0)
592 # We need to apply the calibration offset to the fgcmzpt (which is internal
593 # and doesn't know about that yet)
594 testZpInd, = np.where((zptCat['visit'] == testVisit)
595 & (zptCat['detector'] == testCcd))
596 fgcmZpt = (zptCat['fgcmZpt'][testZpInd] + offsets[testBandIndex]
597 + zptCat['fgcmDeltaChrom'][testZpInd])
598 fgcmZptGrayErr = np.sqrt(zptCat['fgcmZptVar'][testZpInd])
600 if config.doComposeWcsJacobian:
601 # The raw zeropoint needs to be modified to know about the wcs jacobian
602 refs = butler.registry.queryDatasets('camera', dimensions=['instrument'],
603 collections=...)
604 camera = butler.get(list(refs)[0])
605 approxPixelAreaFields = fgcmcal.utilities.computeApproxPixelAreaFields(camera)
606 center = approxPixelAreaFields[testCcd].getBBox().getCenter()
607 pixAreaCorr = approxPixelAreaFields[testCcd].evaluate(center)
608 fgcmZpt += -2.5*np.log10(pixAreaCorr)
610 # This is the magnitude through the mean calibration
611 photoCalMeanCalMags = np.zeros(gdSrc.sum())
612 # This is the magnitude through the full focal-plane variable mags
613 photoCalMags = np.zeros_like(photoCalMeanCalMags)
614 # This is the magnitude with the FGCM (central-ccd) zeropoint
615 zptMeanCalMags = np.zeros_like(photoCalMeanCalMags)
617 for i, rec in enumerate(src[gdSrc]):
618 photoCalMeanCalMags[i] = testCal.instFluxToMagnitude(rec['slot_CalibFlux_instFlux'])
619 photoCalMags[i] = testCal.instFluxToMagnitude(rec['slot_CalibFlux_instFlux'],
620 rec.getCentroid())
621 zptMeanCalMags[i] = fgcmZpt[0] - 2.5*np.log10(rec['slot_CalibFlux_instFlux'])
623 # These should be very close but some tiny differences because the fgcm value
624 # is defined at the center of the bbox, and the photoCal is the mean over the box
625 self.assertFloatsAlmostEqual(photoCalMeanCalMags,
626 zptMeanCalMags, rtol=1e-6)
627 # These should be roughly equal, but not precisely because of the focal-plane
628 # variation. However, this is a useful sanity check for something going totally
629 # wrong.
630 self.assertFloatsAlmostEqual(photoCalMeanCalMags,
631 photoCalMags, rtol=1e-2)
633 # The next test compares the "FGCM standard magnitudes" (which are output
634 # from the fgcm code itself) to the "calibrated magnitudes" that are
635 # obtained from running photoCalib.calibrateCatalog() on the original
636 # src catalogs. This summary comparison ensures that using photoCalibs
637 # yields the same results as what FGCM is computing internally.
638 # Note that we additionally need to take into account the post-processing
639 # offsets used in the tests.
641 # For decent statistics, we are matching all the sources from one visit
642 # (multiple ccds)
643 whereClause = f"instrument='{instName:s}' and visit={testVisit:d}"
644 srcRefs = butler.registry.queryDatasets('src', dimensions=['visit'],
645 collections='%s/testdata' % (instName),
646 where=whereClause,
647 findFirst=True)
648 photoCals = []
649 for srcRef in srcRefs:
650 photoCals.append(photoCalibDict[(testVisit, srcRef.dataId['detector'])])
652 matchMag, matchDelta = self._getMatchedVisitCat(butler, srcRefs, photoCals,
653 rawStars, testBandIndex, offsets)
655 st = np.argsort(matchMag)
656 # Compare the brightest 25% of stars. No matter the setting of
657 # deltaMagBkgOffsetPercentile, we want to ensure that these stars
658 # match on average.
659 brightest, = np.where(matchMag < matchMag[st[int(0.25*st.size)]])
660 self.assertFloatsAlmostEqual(np.median(matchDelta[brightest]), 0.0, atol=0.002)
662 # And the photoCal error is just the zeropoint gray error
663 self.assertFloatsAlmostEqual(testCal.getCalibrationErr(),
664 (np.log(10.0)/2.5)*testCal.getCalibrationMean()*fgcmZptGrayErr)
666 # Test the transmission output
667 visitCatalog = butler.get('fgcmVisitCatalog', collections=[inputCollection],
668 instrument=instName)
669 lutCat = butler.get('fgcmLookUpTable', collections=[inputCollection],
670 instrument=instName)
672 testTrans = butler.get('transmission_atmosphere_fgcm',
673 visit=visitCatalog[0]['visit'],
674 collections=[outputCollection], instrument=instName)
675 testResp = testTrans.sampleAt(position=geom.Point2D(0, 0),
676 wavelengths=lutCat[0]['atmLambda'])
678 # The test fit is performed with the atmosphere parameters frozen
679 # (freezeStdAtmosphere = True). Thus the only difference between
680 # these output atmospheres and the standard is the different
681 # airmass. Furthermore, this is a very rough comparison because
682 # the look-up table is computed with very coarse sampling for faster
683 # testing.
685 # To account for overall throughput changes, we scale by the median ratio,
686 # we only care about the shape
687 ratio = np.median(testResp/lutCat[0]['atmStdTrans'])
688 self.assertFloatsAlmostEqual(testResp/ratio, lutCat[0]['atmStdTrans'], atol=0.2)
690 # The second should be close to the first, but there is the airmass
691 # difference so they aren't identical.
692 testTrans2 = butler.get('transmission_atmosphere_fgcm',
693 visit=visitCatalog[1]['visit'],
694 collections=[outputCollection], instrument=instName)
695 testResp2 = testTrans2.sampleAt(position=geom.Point2D(0, 0),
696 wavelengths=lutCat[0]['atmLambda'])
698 # As above, we scale by the ratio to compare the shape of the curve.
699 ratio = np.median(testResp/testResp2)
700 self.assertFloatsAlmostEqual(testResp/ratio, testResp2, atol=0.04)
702 def _testFgcmOutputIlluminationCorrection(self, instName, testName, detector):
703 """Test running of FgcmOutputIlluminationCorrectionTask.
705 Parameters
706 ----------
707 instName : `str`
708 Short name of the instrument.
709 testName : `str`
710 Base name of the test collection.
711 detector : `int`
712 Detector ID to test illumination correction output.
713 """
714 instCamel = instName.title()
716 configFiles = {
717 "fgcmOutputIlluminationCorrection": [
718 os.path.join(
719 ROOT,
720 "config",
721 f"fgcmOutputIlluminationCorrection{instCamel}.py",
722 ),
723 ],
724 }
725 inputCollection = f"{instName}/{testName}/fit"
726 outputCollection = f"{instName}/{testName}/fit/illumcorr"
728 self._runPipeline(
729 self.repo,
730 os.path.join(
731 ROOT,
732 "pipelines",
733 f"fgcmOutputIlluminationCorrection{instCamel}.yaml",
734 ),
735 configFiles=configFiles,
736 inputCollections=[inputCollection],
737 outputCollection=outputCollection,
738 registerDatasetTypes=True,
739 queryString=f"detector = {detector}",
740 )
742 butler = dafButler.Butler(self.repo)
743 self.enterContext(butler)
745 # Check for illumination correction files.
746 illumCorrRefs = butler.query_datasets(
747 "illuminationCorrection",
748 collections=[outputCollection],
749 )
750 self.assertGreater(len(illumCorrRefs), 0)
752 with warnings.catch_warnings(record=True) as w:
753 warnings.simplefilter("always")
754 testCorr = butler.get(illumCorrRefs[0])
756 self.assertEqual(len(w), 0, "Reading an illumination correction had a warning!")
757 self.assertIsInstance(testCorr, lsst.afw.image.ExposureF)
759 self.assertIsNotNone(testCorr.metadata.get("CALIB_CREATION_DATETIME"))
760 self.assertIsNotNone(testCorr.metadata.get("CALIB_CREATION_DATE"))
761 self.assertIsNotNone(testCorr.metadata.get("CALIB_CREATION_TIME"))
762 self.assertIsNotNone(testCorr.metadata.get("LSST CALIB UUID FLAT"))
763 self.assertIsNotNone(testCorr.metadata.get("LSST ISR UNITS"))
764 self.assertIsNotNone(testCorr.getDetector())
765 self.assertEqual(testCorr.getDetector().getId(), illumCorrRefs[0].dataId["detector"])
767 def _testFgcmMultiFit(self, instName, testName, queryString, visits, zpOffsets,
768 nPlotsPenultimateCycle, nPlotsFinalCycle,
769 refcatCollection="refcats/gen2"):
770 """Test running the full pipeline with multiple fit cycles.
772 Parameters
773 ----------
774 instName : `str`
775 Short name of the instrument.
776 testName : `str`
777 Base name of the test collection.
778 queryString : `str`
779 Query to send to the pipetask.
780 visits : `list`
781 List of visits to calibrate.
782 zpOffsets : `np.ndarray`
783 Zeropoint offsets expected.
784 nPlotsPenultimateCycle : `int`
785 Number of plots expected in penultimate cycle.
786 nPlotsFinalCycle : `int`
787 Number of plots expected in final cycle.
788 refcatCollection : `str`, optional
789 Name of reference catalog collection.
790 """
791 instCamel = instName.title()
793 configFiles = {
794 'fgcmBuildFromIsolatedStars': [
795 os.path.join(
796 ROOT,
797 'config',
798 f'fgcmBuildFromIsolatedStars{instCamel}.py',
799 ),
800 ],
801 'fgcmFitCycle': [
802 os.path.join(
803 ROOT,
804 'config',
805 f'fgcmFitCycle{instCamel}.py',
806 ),
807 ],
808 'fgcmOutputProducts': [
809 os.path.join(
810 ROOT,
811 'config',
812 f'fgcmOutputProducts{instCamel}.py',
813 ),
814 ],
815 }
816 outputCollection = f'{instName}/{testName}/unified'
818 cwd = os.getcwd()
819 runDir = os.path.join(self.testDir, testName)
820 os.makedirs(runDir)
821 os.chdir(runDir)
823 self._runPipeline(self.repo,
824 os.path.join(ROOT,
825 'pipelines',
826 f'fgcmFullPipeline{instCamel}.yaml'),
827 configFiles=configFiles,
828 inputCollections=[f'{instName}/{testName}/lut',
829 refcatCollection,
830 'skymaps'],
831 outputCollection=outputCollection,
832 queryString=queryString,
833 registerDatasetTypes=True)
835 os.chdir(cwd)
837 butler = dafButler.Butler(self.repo)
838 self.enterContext(butler)
840 # Check that the expected number of plots are there.
841 config = butler.get("fgcmFitCycle_config", collections=[outputCollection])
843 cycleNumber = config.multipleCyclesFinalCycleNumber - 1
844 plotDatasets = list(butler.registry.queryDatasets(
845 f"fgcm_Cycle{cycleNumber}_*Plot",
846 collections=[outputCollection],
847 ))
848 self.assertEqual(len(plotDatasets), nPlotsPenultimateCycle)
850 cycleNumber = config.multipleCyclesFinalCycleNumber
851 plotDatasets = list(butler.registry.queryDatasets(
852 f"fgcm_Cycle{cycleNumber}_*Plot",
853 collections=[outputCollection],
854 ))
855 self.assertEqual(len(plotDatasets), nPlotsFinalCycle)
857 offsetCat = butler.get('fgcmReferenceCalibrationOffsets',
858 collections=[outputCollection], instrument=instName)
859 offsets = offsetCat['offset'][:]
860 self.assertFloatsAlmostEqual(offsets, zpOffsets, atol=1e-6)
862 def _getMatchedVisitCat(self, butler, srcHandles, photoCals,
863 rawStars, bandIndex, offsets):
864 """
865 Get a list of matched magnitudes and deltas from calibrated src catalogs.
867 Parameters
868 ----------
869 butler : `lsst.daf.butler.Butler`
870 srcHandles : `list`
871 Handles of source catalogs.
872 photoCals : `list`
873 photoCalib objects, matched to srcHandles.
874 rawStars : `lsst.afw.table.SourceCatalog`
875 Fgcm standard stars.
876 bandIndex : `int`
877 Index of the band for the source catalogs.
878 offsets : `np.ndarray`
879 Testing calibration offsets to apply to rawStars.
881 Returns
882 -------
883 matchMag : `np.ndarray`
884 Array of matched magnitudes.
885 matchDelta : `np.ndarray`
886 Array of matched deltas between src and standard stars.
887 """
888 matcher = esutil.htm.Matcher(11, np.rad2deg(rawStars['coord_ra']),
889 np.rad2deg(rawStars['coord_dec']))
891 matchDelta = None
892 for srcHandle, photoCal in zip(srcHandles, photoCals):
893 src = butler.get(srcHandle)
894 src = photoCal.calibrateCatalog(src)
896 gdSrc, = np.where(np.nan_to_num(src['slot_CalibFlux_flux']) > 0.0)
898 matches = matcher.match(np.rad2deg(src['coord_ra'][gdSrc]),
899 np.rad2deg(src['coord_dec'][gdSrc]),
900 1./3600., maxmatch=1)
902 srcMag = src['slot_CalibFlux_mag'][gdSrc][matches[0]]
903 # Apply offset here to the catalog mag
904 catMag = rawStars['mag_std_noabs'][matches[1]][:, bandIndex] + offsets[bandIndex]
905 delta = srcMag - catMag
906 if matchDelta is None:
907 matchDelta = delta
908 matchMag = catMag
909 else:
910 matchDelta = np.append(matchDelta, delta)
911 matchMag = np.append(matchMag, catMag)
913 return matchMag, matchDelta
915 def _testFgcmCalibrateTract(self, instName, testName, visits, tract, skymapName,
916 rawRepeatability, filterNCalibMap):
917 """Test running of FgcmCalibrateTractTask
919 Parameters
920 ----------
921 instName : `str`
922 Short name of the instrument.
923 testName : `str`
924 Base name of the test collection.
925 visits : `list`
926 List of visits to calibrate.
927 tract : `int`
928 Tract number.
929 skymapName : `str`
930 Name of the sky map.
931 rawRepeatability : `np.array`
932 Expected raw repeatability after convergence.
933 Length should be number of bands.
934 filterNCalibMap : `dict`
935 Mapping from filter name to number of photoCalibs created.
936 """
937 instCamel = instName.title()
939 configFiles = {'fgcmCalibrateTractTable':
940 [os.path.join(ROOT,
941 'config',
942 f'fgcmCalibrateTractTable{instCamel}.py')]}
944 outputCollection = f'{instName}/{testName}/tract'
946 inputCollections = [f'{instName}/{testName}/lut',
947 'refcats/gen2']
949 queryString = f"tract={tract:d} and skymap='{skymapName:s}'"
951 self._runPipeline(self.repo,
952 os.path.join(ROOT,
953 'pipelines',
954 f'fgcmCalibrateTractTable{instCamel:s}.yaml'),
955 queryString=queryString,
956 configFiles=configFiles,
957 inputCollections=inputCollections,
958 outputCollection=outputCollection,
959 registerDatasetTypes=True)
961 butler = dafButler.Butler(self.repo)
962 self.enterContext(butler)
964 whereClause = f"instrument='{instName:s}' and tract={tract:d} and skymap='{skymapName:s}'"
966 repRefs = butler.registry.queryDatasets('fgcmRawRepeatability',
967 dimensions=['tract'],
968 collections=outputCollection,
969 where=whereClause)
971 repeatabilityCat = butler.get(list(repRefs)[0])
972 repeatability = repeatabilityCat['rawRepeatability'][:]
973 self.assertFloatsAlmostEqual(repeatability, rawRepeatability, atol=5e-4)
975 # Check that the number of photoCalib objects in each filter are what we expect
976 for filterName in filterNCalibMap.keys():
977 whereClause = (f"instrument='{instName:s}' and tract={tract:d} and "
978 f"physical_filter='{filterName:s}' and skymap='{skymapName:s}'")
980 refs = butler.registry.queryDatasets('fgcmPhotoCalibTractCatalog',
981 dimensions=['tract', 'physical_filter'],
982 collections=outputCollection,
983 where=whereClause)
985 count = 0
986 for ref in set(refs):
987 expCat = butler.get(ref)
988 test, = np.where((expCat['visit'] > 0) & (expCat['id'] >= 0))
989 count += test.size
991 self.assertEqual(count, filterNCalibMap[filterName])
993 # Check that every visit got a transmission
994 for visit in visits:
995 whereClause = (f"instrument='{instName:s}' and tract={tract:d} and "
996 f"visit={visit:d} and skymap='{skymapName:s}'")
997 refs = butler.registry.queryDatasets('transmission_atmosphere_fgcm_tract',
998 dimensions=['tract', 'visit'],
999 collections=outputCollection,
1000 where=whereClause)
1001 self.assertEqual(len(set(refs)), 1)
1003 @classmethod
1004 def tearDownClass(cls):
1005 """Tear down and clear directories.
1006 """
1007 if os.path.exists(cls.testDir):
1008 shutil.rmtree(cls.testDir, True)