Coverage for python / lsst / fgcmcal / fgcmOutputProducts.py: 14%
356 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-18 09:15 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-18 09:15 +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"""Make the final fgcmcal output products.
25This task takes the final output from fgcmFitCycle and produces the following
26outputs for use in the DM stack: the FGCM standard stars in a reference
27catalog format; the model atmospheres in "transmission_atmosphere_fgcm"
28format; and the zeropoints in "fgcm_photoCalib" format. Optionally, the
29task can transfer the 'absolute' calibration from a reference catalog
30to put the fgcm standard stars in units of Jansky. This is accomplished
31by matching stars in a sample of healpix pixels, and applying the median
32offset per band.
33"""
34import copy
36import numpy as np
37import hpgeom as hpg
38from astropy import units
39from astropy.table import Table
40import esutil
42import lsst.daf.base as dafBase
43import lsst.pex.config as pexConfig
44import lsst.pipe.base as pipeBase
45from lsst.pipe.base import connectionTypes
46from lsst.afw.image import TransmissionCurve
47from lsst.meas.algorithms import ReferenceObjectLoader, LoadReferenceObjectsConfig
48from lsst.pipe.tasks.photoCal import PhotoCalTask
49import lsst.geom
50import lsst.afw.image as afwImage
51import lsst.afw.math as afwMath
52import lsst.afw.table as afwTable
53from lsst.skymap import BaseSkyMap
55from .utilities import computeApproxPixelAreaFields
56from .utilities import FGCM_ILLEGAL_VALUE
58import fgcm
60__all__ = ['FgcmOutputProductsConfig', 'FgcmOutputProductsTask']
63class FgcmOutputProductsConnections(pipeBase.PipelineTaskConnections,
64 dimensions=("instrument",),
65 defaultTemplates={"cycleNumber": "0"}):
66 camera = connectionTypes.PrerequisiteInput(
67 doc="Camera instrument",
68 name="camera",
69 storageClass="Camera",
70 dimensions=("instrument",),
71 isCalibration=True,
72 )
74 skymap = connectionTypes.Input(
75 doc="Skymap used for tract sharding of output catalog.",
76 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME,
77 storageClass="SkyMap",
78 dimensions=("skymap",),
79 )
81 fgcmLookUpTable = connectionTypes.PrerequisiteInput(
82 doc=("Atmosphere + instrument look-up-table for FGCM throughput and "
83 "chromatic corrections."),
84 name="fgcmLookUpTable",
85 storageClass="Catalog",
86 dimensions=("instrument",),
87 deferLoad=True,
88 )
90 fgcmVisitCatalog = connectionTypes.Input(
91 doc="Catalog of visit information for fgcm",
92 name="fgcmVisitCatalog",
93 storageClass="Catalog",
94 dimensions=("instrument",),
95 deferLoad=True,
96 )
98 fgcmStandardStars = connectionTypes.Input(
99 doc="Catalog of standard star data from fgcm fit",
100 name="fgcm_Cycle{cycleNumber}_StandardStars",
101 storageClass="SimpleCatalog",
102 dimensions=("instrument",),
103 deferLoad=True,
104 )
106 fgcmZeropoints = connectionTypes.Input(
107 doc="Catalog of zeropoints from fgcm fit",
108 name="fgcm_Cycle{cycleNumber}_Zeropoints",
109 storageClass="Catalog",
110 dimensions=("instrument",),
111 deferLoad=True,
112 )
114 fgcmAtmosphereParameters = connectionTypes.Input(
115 doc="Catalog of atmosphere parameters from fgcm fit",
116 name="fgcm_Cycle{cycleNumber}_AtmosphereParameters",
117 storageClass="Catalog",
118 dimensions=("instrument",),
119 deferLoad=True,
120 )
122 refCat = connectionTypes.PrerequisiteInput(
123 doc="Reference catalog to use for photometric calibration",
124 name="cal_ref_cat",
125 storageClass="SimpleCatalog",
126 dimensions=("skypix",),
127 deferLoad=True,
128 multiple=True,
129 )
131 fgcmPhotoCalib = connectionTypes.Output(
132 doc=("Per-visit photometric calibrations derived from fgcm calibration. "
133 "These catalogs use detector id for the id and are sorted for "
134 "fast lookups of a detector."),
135 name="fgcmPhotoCalibCatalog",
136 storageClass="ExposureCatalog",
137 dimensions=("instrument", "visit",),
138 multiple=True,
139 )
141 fgcmTransmissionAtmosphere = connectionTypes.Output(
142 doc="Per-visit atmosphere transmission files produced from fgcm calibration",
143 name="transmission_atmosphere_fgcm",
144 storageClass="TransmissionCurve",
145 dimensions=("instrument",
146 "visit",),
147 multiple=True,
148 )
150 fgcmOffsets = connectionTypes.Output(
151 doc="Per-band offsets computed from doReferenceCalibration",
152 name="fgcmReferenceCalibrationOffsets",
153 storageClass="Catalog",
154 dimensions=("instrument",),
155 multiple=False,
156 )
158 fgcmTractStars = connectionTypes.Output(
159 doc="Per-tract fgcm calibrated stars.",
160 name="fgcm_standard_star",
161 storageClass="ArrowAstropy",
162 dimensions=("instrument", "tract", "skymap"),
163 multiple=True,
164 )
166 def __init__(self, *, config=None):
167 super().__init__(config=config)
169 if str(int(config.connections.cycleNumber)) != config.connections.cycleNumber:
170 raise ValueError("cycleNumber must be of integer format")
172 if not config.doReferenceCalibration:
173 self.prerequisiteInputs.remove("refCat")
174 if not config.doAtmosphereOutput:
175 self.inputs.remove("fgcmAtmosphereParameters")
176 if not config.doZeropointOutput:
177 self.inputs.remove("fgcmZeropoints")
178 if not config.doReferenceCalibration:
179 self.outputs.remove("fgcmOffsets")
180 if not config.doTractStars:
181 del self.skymap
182 del self.fgcmTractStars
184 def getSpatialBoundsConnections(self):
185 return ("fgcmPhotoCalib",)
188class FgcmOutputProductsConfig(pipeBase.PipelineTaskConfig,
189 pipelineConnections=FgcmOutputProductsConnections):
190 """Config for FgcmOutputProductsTask"""
192 physicalFilterMap = pexConfig.DictField(
193 doc="Mapping from 'physicalFilter' to band.",
194 keytype=str,
195 itemtype=str,
196 default={},
197 )
198 # The following fields refer to calibrating from a reference
199 # catalog, but in the future this might need to be expanded
200 doReferenceCalibration = pexConfig.Field(
201 doc=("Transfer 'absolute' calibration from reference catalog? "
202 "This afterburner step is unnecessary if reference stars "
203 "were used in the full fit in FgcmFitCycleTask."),
204 dtype=bool,
205 default=False,
206 )
207 doAtmosphereOutput = pexConfig.Field(
208 doc="Output atmospheres in transmission_atmosphere_fgcm format",
209 dtype=bool,
210 default=True,
211 )
212 doZeropointOutput = pexConfig.Field(
213 doc="Output zeropoints in fgcm_photoCalib format",
214 dtype=bool,
215 default=True,
216 )
217 doComposeWcsJacobian = pexConfig.Field(
218 doc="Compose Jacobian of WCS with fgcm calibration for output photoCalib?",
219 dtype=bool,
220 default=True,
221 )
222 doApplyMeanChromaticCorrection = pexConfig.Field(
223 doc="Apply the mean chromatic correction to the zeropoints?",
224 dtype=bool,
225 default=True,
226 )
227 doTractStars = pexConfig.Field(
228 doc="Output tract-sharded standard stars?",
229 dtype=bool,
230 default=True,
231 # default=False,
232 )
233 photoCal = pexConfig.ConfigurableField(
234 target=PhotoCalTask,
235 doc="task to perform 'absolute' calibration",
236 )
237 referencePixelizationNside = pexConfig.Field(
238 doc="Healpix nside to pixelize catalog to compare to reference catalog",
239 dtype=int,
240 default=64,
241 )
242 referencePixelizationMinStars = pexConfig.Field(
243 doc=("Minimum number of stars per healpix pixel to select for comparison"
244 "to the specified reference catalog"),
245 dtype=int,
246 default=200,
247 )
248 referenceMinMatch = pexConfig.Field(
249 doc="Minimum number of stars matched to reference catalog to be used in statistics",
250 dtype=int,
251 default=50,
252 )
253 referencePixelizationNPixels = pexConfig.Field(
254 doc=("Number of healpix pixels to sample to do comparison. "
255 "Doing too many will take a long time and not yield any more "
256 "precise results because the final number is the median offset "
257 "(per band) from the set of pixels."),
258 dtype=int,
259 default=100,
260 )
262 def setDefaults(self):
263 pexConfig.Config.setDefaults(self)
265 # In order to transfer the "absolute" calibration from a reference
266 # catalog to the relatively calibrated FGCM standard stars (one number
267 # per band), we use the PhotoCalTask to match stars in a sample of healpix
268 # pixels. These basic settings ensure that only well-measured, good stars
269 # from the source and reference catalogs are used for the matching.
271 # applyColorTerms needs to be False if doReferenceCalibration is False,
272 # as is the new default after DM-16702
273 self.photoCal.applyColorTerms = False
274 self.photoCal.fluxField = 'instFlux'
275 self.photoCal.magErrFloor = 0.003
276 self.photoCal.match.referenceSelection.doSignalToNoise = True
277 self.photoCal.match.referenceSelection.signalToNoise.minimum = 10.0
278 self.photoCal.match.sourceSelection.doSignalToNoise = True
279 self.photoCal.match.sourceSelection.signalToNoise.minimum = 10.0
280 self.photoCal.match.sourceSelection.signalToNoise.fluxField = 'instFlux'
281 self.photoCal.match.sourceSelection.signalToNoise.errField = 'instFluxErr'
282 self.photoCal.match.sourceSelection.doFlags = True
283 self.photoCal.match.sourceSelection.flags.good = []
284 self.photoCal.match.sourceSelection.flags.bad = ['flag_badStar']
285 self.photoCal.match.sourceSelection.doUnresolved = False
286 self.photoCal.match.sourceSelection.doRequirePrimary = False
289class FgcmOutputProductsTask(pipeBase.PipelineTask):
290 """
291 Output products from FGCM global calibration.
292 """
294 ConfigClass = FgcmOutputProductsConfig
295 _DefaultName = "fgcmOutputProducts"
297 def __init__(self, **kwargs):
298 super().__init__(**kwargs)
300 def runQuantum(self, butlerQC, inputRefs, outputRefs):
301 handleDict = {}
302 handleDict['camera'] = butlerQC.get(inputRefs.camera)
303 handleDict['fgcmLookUpTable'] = butlerQC.get(inputRefs.fgcmLookUpTable)
304 handleDict['fgcmVisitCatalog'] = butlerQC.get(inputRefs.fgcmVisitCatalog)
305 handleDict['fgcmStandardStars'] = butlerQC.get(inputRefs.fgcmStandardStars)
307 if self.config.doZeropointOutput:
308 handleDict['fgcmZeropoints'] = butlerQC.get(inputRefs.fgcmZeropoints)
309 photoCalibRefDict = {photoCalibRef.dataId['visit']:
310 photoCalibRef for photoCalibRef in outputRefs.fgcmPhotoCalib}
312 if self.config.doAtmosphereOutput:
313 handleDict['fgcmAtmosphereParameters'] = butlerQC.get(inputRefs.fgcmAtmosphereParameters)
314 atmRefDict = {atmRef.dataId['visit']: atmRef for
315 atmRef in outputRefs.fgcmTransmissionAtmosphere}
317 if self.config.doReferenceCalibration:
318 refConfig = LoadReferenceObjectsConfig()
319 self.refObjLoader = ReferenceObjectLoader(dataIds=[ref.datasetRef.dataId
320 for ref in inputRefs.refCat],
321 refCats=butlerQC.get(inputRefs.refCat),
322 name=self.config.connections.refCat,
323 log=self.log,
324 config=refConfig)
325 else:
326 self.refObjLoader = None
328 if self.config.doTractStars:
329 handleDict['skymap'] = butlerQC.get(inputRefs.skymap)
330 tractStarRefDict = {tractStarRef.dataId["tract"]: tractStarRef for
331 tractStarRef in outputRefs.fgcmTractStars}
333 struct = self.run(handleDict, self.config.physicalFilterMap)
335 # Output the photoCalib exposure catalogs
336 if struct.photoCalibCatalogs is not None:
337 self.log.info("Outputting photoCalib catalogs.")
338 for visit, expCatalog in struct.photoCalibCatalogs:
339 butlerQC.put(expCatalog, photoCalibRefDict[visit])
340 self.log.info("Done outputting photoCalib catalogs.")
342 # Output the atmospheres
343 if struct.atmospheres is not None:
344 self.log.info("Outputting atmosphere transmission files.")
345 for visit, atm in struct.atmospheres:
346 butlerQC.put(atm, atmRefDict[visit])
347 self.log.info("Done outputting atmosphere files.")
349 if self.config.doReferenceCalibration:
350 # Turn offset into simple catalog for persistence if necessary
351 schema = afwTable.Schema()
352 schema.addField('offset', type=np.float64,
353 doc="Post-process calibration offset (mag)")
354 offsetCat = afwTable.BaseCatalog(schema)
355 offsetCat.resize(len(struct.offsets))
356 offsetCat['offset'][:] = struct.offsets
358 butlerQC.put(offsetCat, outputRefs.fgcmOffsets)
360 if self.config.doTractStars:
361 self.log.info("Outputting standard stars per-tract.")
362 for tractId, catalog in struct.tractStars:
363 butlerQC.put(catalog, tractStarRefDict[tractId])
365 return
367 def run(self, handleDict, physicalFilterMap):
368 """Run the output products task.
370 Parameters
371 ----------
372 handleDict : `dict`
373 All handles are `lsst.daf.butler.DeferredDatasetHandle`
374 handle dictionary with keys:
376 ``"camera"``
377 Camera object (`lsst.afw.cameraGeom.Camera`)
378 ``"fgcmLookUpTable"``
379 handle for the FGCM look-up table.
380 ``"fgcmVisitCatalog"``
381 handle for visit summary catalog.
382 ``"fgcmStandardStars"``
383 handle for the output standard star catalog.
384 ``"fgcmZeropoints"``
385 handle for the zeropoint data catalog.
386 ``"fgcmAtmosphereParameters"``
387 handle for the atmosphere parameter catalog.
388 ``"fgcmBuildStarsTableConfig"``
389 Config for `lsst.fgcmcal.fgcmBuildStarsTableTask`.
390 ``"skymap"``
391 Skymap for sharding standard stars (optional).
393 physicalFilterMap : `dict`
394 Dictionary of mappings from physical filter to FGCM band.
396 Returns
397 -------
398 retStruct : `lsst.pipe.base.Struct`
399 Output structure with keys:
401 offsets : `np.ndarray`
402 Final reference offsets, per band.
403 atmospheres : `generator` [(`int`, `lsst.afw.image.TransmissionCurve`)]
404 Generator that returns (visit, transmissionCurve) tuples.
405 photoCalibCatalogs : `generator` [(`int`, `lsst.afw.table.ExposureCatalog`)]
406 Generator that returns (visit, exposureCatalog) tuples.
407 """
408 stdCat = handleDict['fgcmStandardStars'].get()
409 md = stdCat.getMetadata()
410 bands = md.getArray('BANDS')
412 if self.config.doReferenceCalibration:
413 lutCat = handleDict['fgcmLookUpTable'].get()
414 offsets = self._computeReferenceOffsets(stdCat, lutCat, physicalFilterMap, bands)
415 else:
416 offsets = np.zeros(len(bands))
418 if self.config.doZeropointOutput:
419 zptCat = handleDict['fgcmZeropoints'].get()
420 visitCat = handleDict['fgcmVisitCatalog'].get()
422 pcgen = self._outputZeropoints(handleDict['camera'], zptCat, visitCat, offsets, bands,
423 physicalFilterMap)
424 else:
425 pcgen = None
427 if self.config.doAtmosphereOutput:
428 atmCat = handleDict['fgcmAtmosphereParameters'].get()
429 atmgen = self._outputAtmospheres(handleDict, atmCat)
430 else:
431 atmgen = None
433 if self.config.doTractStars:
434 skymap = handleDict['skymap']
435 tractstargen = self._outputTractStars(skymap, stdCat)
436 else:
437 tractstargen = None
439 retStruct = pipeBase.Struct(offsets=offsets,
440 atmospheres=atmgen,
441 tractStars=tractstargen)
442 retStruct.photoCalibCatalogs = pcgen
444 return retStruct
446 def generateTractOutputProducts(self, handleDict, tract,
447 visitCat, zptCat, atmCat, stdCat,
448 fgcmBuildStarsConfig):
449 """
450 Generate the output products for a given tract, as specified in the config.
452 This method is here to have an alternate entry-point for
453 FgcmCalibrateTract.
455 Parameters
456 ----------
457 handleDict : `dict`
458 All handles are `lsst.daf.butler.DeferredDatasetHandle`
459 handle dictionary with keys:
461 ``"camera"``
462 Camera object (`lsst.afw.cameraGeom.Camera`)
463 ``"fgcmLookUpTable"``
464 handle for the FGCM look-up table.
465 tract : `int`
466 Tract number
467 visitCat : `lsst.afw.table.BaseCatalog`
468 FGCM visitCat from `FgcmBuildStarsTask`
469 zptCat : `lsst.afw.table.BaseCatalog`
470 FGCM zeropoint catalog from `FgcmFitCycleTask`
471 atmCat : `lsst.afw.table.BaseCatalog`
472 FGCM atmosphere parameter catalog from `FgcmFitCycleTask`
473 stdCat : `lsst.afw.table.SimpleCatalog`
474 FGCM standard star catalog from `FgcmFitCycleTask`
475 fgcmBuildStarsConfig : `lsst.fgcmcal.FgcmBuildStarsConfig`
476 Configuration object from `FgcmBuildStarsTask`
478 Returns
479 -------
480 retStruct : `lsst.pipe.base.Struct`
481 Output structure with keys:
483 offsets : `np.ndarray`
484 Final reference offsets, per band.
485 atmospheres : `generator` [(`int`, `lsst.afw.image.TransmissionCurve`)]
486 Generator that returns (visit, transmissionCurve) tuples.
487 photoCalibCatalogs : `generator` [(`int`, `lsst.afw.table.ExposureCatalog`)]
488 Generator that returns (visit, exposureCatalog) tuples.
489 """
490 physicalFilterMap = fgcmBuildStarsConfig.physicalFilterMap
492 md = stdCat.getMetadata()
493 bands = md.getArray('BANDS')
495 if self.config.doComposeWcsJacobian and not fgcmBuildStarsConfig.doApplyWcsJacobian:
496 raise RuntimeError("Cannot compose the WCS jacobian if it hasn't been applied "
497 "in fgcmBuildStarsTask.")
499 if not self.config.doComposeWcsJacobian and fgcmBuildStarsConfig.doApplyWcsJacobian:
500 self.log.warning("Jacobian was applied in build-stars but doComposeWcsJacobian is not set.")
502 if self.config.doReferenceCalibration:
503 lutCat = handleDict['fgcmLookUpTable'].get()
504 offsets = self._computeReferenceOffsets(stdCat, lutCat, bands, physicalFilterMap)
505 else:
506 offsets = np.zeros(len(bands))
508 if self.config.doZeropointOutput:
509 pcgen = self._outputZeropoints(handleDict['camera'], zptCat, visitCat, offsets, bands,
510 physicalFilterMap)
511 else:
512 pcgen = None
514 if self.config.doAtmosphereOutput:
515 atmgen = self._outputAtmospheres(handleDict, atmCat)
516 else:
517 atmgen = None
519 retStruct = pipeBase.Struct(offsets=offsets,
520 atmospheres=atmgen)
521 retStruct.photoCalibCatalogs = pcgen
523 return retStruct
525 def _computeReferenceOffsets(self, stdCat, lutCat, physicalFilterMap, bands):
526 """
527 Compute offsets relative to a reference catalog.
529 This method splits the star catalog into healpix pixels
530 and computes the calibration transfer for a sample of
531 these pixels to approximate the 'absolute' calibration
532 values (on for each band) to apply to transfer the
533 absolute scale.
535 Parameters
536 ----------
537 stdCat : `lsst.afw.table.SimpleCatalog`
538 FGCM standard stars
539 lutCat : `lsst.afw.table.SimpleCatalog`
540 FGCM Look-up table
541 physicalFilterMap : `dict`
542 Dictionary of mappings from physical filter to FGCM band.
543 bands : `list` [`str`]
544 List of band names from FGCM output
545 Returns
546 -------
547 offsets : `numpy.array` of floats
548 Per band zeropoint offsets
549 """
551 # Only use stars that are observed in all the bands that were actually used
552 # This will ensure that we use the same healpix pixels for the absolute
553 # calibration of each band.
554 minObs = stdCat['ngood'].min(axis=1)
556 goodStars = (minObs >= 1)
557 stdCat = stdCat[goodStars]
559 self.log.info("Found %d stars with at least 1 good observation in each band" %
560 (len(stdCat)))
562 # Associate each band with the appropriate physicalFilter and make
563 # filterLabels
564 filterLabels = []
566 lutPhysicalFilters = lutCat[0]['physicalFilters'].split(',')
567 lutStdPhysicalFilters = lutCat[0]['stdPhysicalFilters'].split(',')
568 physicalFilterMapBands = list(physicalFilterMap.values())
569 physicalFilterMapFilters = list(physicalFilterMap.keys())
570 for band in bands:
571 # Find a physical filter associated from the band by doing
572 # a reverse lookup on the physicalFilterMap dict
573 physicalFilterMapIndex = physicalFilterMapBands.index(band)
574 physicalFilter = physicalFilterMapFilters[physicalFilterMapIndex]
575 # Find the appropriate fgcm standard physicalFilter
576 lutPhysicalFilterIndex = lutPhysicalFilters.index(physicalFilter)
577 stdPhysicalFilter = lutStdPhysicalFilters[lutPhysicalFilterIndex]
578 filterLabels.append(afwImage.FilterLabel(band=band,
579 physical=stdPhysicalFilter))
581 # We have to make a table for each pixel with flux/fluxErr
582 # This is a temporary table generated for input to the photoCal task.
583 # These fluxes are not instFlux (they are top-of-the-atmosphere approximate and
584 # have had chromatic corrections applied to get to the standard system
585 # specified by the atmosphere/instrumental parameters), nor are they
586 # in Jansky (since they don't have a proper absolute calibration: the overall
587 # zeropoint is estimated from the telescope size, etc.)
588 sourceMapper = afwTable.SchemaMapper(stdCat.schema)
589 sourceMapper.addMinimalSchema(afwTable.SimpleTable.makeMinimalSchema())
590 sourceMapper.editOutputSchema().addField('instFlux', type=np.float64,
591 doc="instrumental flux (counts)")
592 sourceMapper.editOutputSchema().addField('instFluxErr', type=np.float64,
593 doc="instrumental flux error (counts)")
594 badStarKey = sourceMapper.editOutputSchema().addField('flag_badStar',
595 type='Flag',
596 doc="bad flag")
598 # Split up the stars
599 # Note that there is an assumption here that the ra/dec coords stored
600 # on-disk are in radians, and therefore that starObs['coord_ra'] /
601 # starObs['coord_dec'] return radians when used as an array of numpy float64s.
602 ipring = hpg.angle_to_pixel(
603 self.config.referencePixelizationNside,
604 stdCat['coord_ra'],
605 stdCat['coord_dec'],
606 degrees=False,
607 )
608 h, rev = fgcm.fgcmUtilities.histogram_rev_sorted(ipring)
610 gdpix, = np.where(h >= self.config.referencePixelizationMinStars)
612 self.log.info("Found %d pixels (nside=%d) with at least %d good stars" %
613 (gdpix.size,
614 self.config.referencePixelizationNside,
615 self.config.referencePixelizationMinStars))
617 if gdpix.size < self.config.referencePixelizationNPixels:
618 self.log.warning("Found fewer good pixels (%d) than preferred in configuration (%d)" %
619 (gdpix.size, self.config.referencePixelizationNPixels))
620 else:
621 # Sample out the pixels we want to use
622 gdpix = np.random.choice(gdpix, size=self.config.referencePixelizationNPixels, replace=False)
624 results = np.zeros(gdpix.size, dtype=[('hpix', 'i4'),
625 ('nstar', 'i4', len(bands)),
626 ('nmatch', 'i4', len(bands)),
627 ('zp', 'f4', len(bands)),
628 ('zpErr', 'f4', len(bands))])
629 results['hpix'] = ipring[rev[rev[gdpix]]]
631 # We need a boolean index to deal with catalogs...
632 selected = np.zeros(len(stdCat), dtype=bool)
634 refFluxFields = [None]*len(bands)
636 for p_index, pix in enumerate(gdpix):
637 i1a = rev[rev[pix]: rev[pix + 1]]
639 # the stdCat afwTable can only be indexed with boolean arrays,
640 # and not numpy index arrays (see DM-16497). This little trick
641 # converts the index array into a boolean array
642 selected[:] = False
643 selected[i1a] = True
645 for b_index, filterLabel in enumerate(filterLabels):
646 struct = self._computeOffsetOneBand(sourceMapper, badStarKey, b_index,
647 filterLabel, stdCat,
648 selected, refFluxFields)
649 results['nstar'][p_index, b_index] = len(i1a)
650 results['nmatch'][p_index, b_index] = len(struct.arrays.refMag)
651 results['zp'][p_index, b_index] = struct.zp
652 results['zpErr'][p_index, b_index] = struct.sigma
654 # And compute the summary statistics
655 offsets = np.zeros(len(bands))
657 for b_index, band in enumerate(bands):
658 # make configurable
659 ok, = np.where(results['nmatch'][:, b_index] >= self.config.referenceMinMatch)
660 offsets[b_index] = np.median(results['zp'][ok, b_index])
661 # use median absolute deviation to estimate Normal sigma
662 # see https://en.wikipedia.org/wiki/Median_absolute_deviation
663 madSigma = 1.4826*np.median(np.abs(results['zp'][ok, b_index] - offsets[b_index]))
664 self.log.info("Reference catalog offset for %s band: %.12f +/- %.12f",
665 band, offsets[b_index], madSigma)
667 return offsets
669 def _computeOffsetOneBand(self, sourceMapper, badStarKey,
670 b_index, filterLabel, stdCat, selected, refFluxFields):
671 """
672 Compute the zeropoint offset between the fgcm stdCat and the reference
673 stars for one pixel in one band
675 Parameters
676 ----------
677 sourceMapper : `lsst.afw.table.SchemaMapper`
678 Mapper to go from stdCat to calibratable catalog
679 badStarKey : `lsst.afw.table.Key`
680 Key for the field with bad stars
681 b_index : `int`
682 Index of the band in the star catalog
683 filterLabel : `lsst.afw.image.FilterLabel`
684 filterLabel with band and physical filter
685 stdCat : `lsst.afw.table.SimpleCatalog`
686 FGCM standard stars
687 selected : `numpy.array(dtype=bool)`
688 Boolean array of which stars are in the pixel
689 refFluxFields : `list`
690 List of names of flux fields for reference catalog
691 """
693 sourceCat = afwTable.SimpleCatalog(sourceMapper.getOutputSchema())
694 sourceCat.reserve(selected.sum())
695 sourceCat.extend(stdCat[selected], mapper=sourceMapper)
696 sourceCat['instFlux'] = 10.**(stdCat['mag_std_noabs'][selected, b_index]/(-2.5))
697 sourceCat['instFluxErr'] = (np.log(10.)/2.5)*(stdCat['magErr_std'][selected, b_index]
698 * sourceCat['instFlux'])
699 # Make sure we only use stars that have valid measurements
700 # (This is perhaps redundant with requirements above that the
701 # stars be observed in all bands, but it can't hurt)
702 badStar = (stdCat['mag_std_noabs'][selected, b_index] > 90.0)
703 for rec in sourceCat[badStar]:
704 rec.set(badStarKey, True)
706 exposure = afwImage.ExposureF()
707 exposure.setFilter(filterLabel)
709 if refFluxFields[b_index] is None:
710 # Need to find the flux field in the reference catalog
711 # to work around limitations of DirectMatch in PhotoCal
712 ctr = stdCat[0].getCoord()
713 rad = 0.05*lsst.geom.degrees
714 refDataTest = self.refObjLoader.loadSkyCircle(ctr, rad, filterLabel.bandLabel)
715 refFluxFields[b_index] = refDataTest.fluxField
717 # Make a copy of the config so that we can modify it
718 calConfig = copy.copy(self.config.photoCal.value)
719 calConfig.match.referenceSelection.signalToNoise.fluxField = refFluxFields[b_index]
720 calConfig.match.referenceSelection.signalToNoise.errField = refFluxFields[b_index] + 'Err'
721 calTask = self.config.photoCal.target(refObjLoader=self.refObjLoader,
722 config=calConfig,
723 schema=sourceCat.getSchema())
725 struct = calTask.run(exposure, sourceCat)
727 return struct
729 def _outputZeropoints(self, camera, zptCat, visitCat, offsets, bands,
730 physicalFilterMap, tract=None):
731 """Output the zeropoints in fgcm_photoCalib format.
733 Parameters
734 ----------
735 camera : `lsst.afw.cameraGeom.Camera`
736 Camera from the butler.
737 zptCat : `lsst.afw.table.BaseCatalog`
738 FGCM zeropoint catalog from `FgcmFitCycleTask`.
739 visitCat : `lsst.afw.table.BaseCatalog`
740 FGCM visitCat from `FgcmBuildStarsTask`.
741 offsets : `numpy.array`
742 Float array of absolute calibration offsets, one for each filter.
743 bands : `list` [`str`]
744 List of band names from FGCM output.
745 physicalFilterMap : `dict`
746 Dictionary of mappings from physical filter to FGCM band.
747 tract: `int`, optional
748 Tract number to output. Default is None (global calibration)
750 Returns
751 -------
752 photoCalibCatalogs : `generator` [(`int`, `lsst.afw.table.ExposureCatalog`)]
753 Generator that returns (visit, exposureCatalog) tuples.
754 """
755 # Select visit/ccds where we have a calibration
756 # This includes ccds where we were able to interpolate from neighboring
757 # ccds.
758 cannot_compute = fgcm.fgcmUtilities.zpFlagDict['CANNOT_COMPUTE_ZEROPOINT']
759 selected = (((zptCat['fgcmFlag'] & cannot_compute) == 0)
760 & (zptCat['fgcmZptVar'] > 0.0)
761 & (zptCat['fgcmZpt'] > FGCM_ILLEGAL_VALUE)
762 & (zptCat['fgcmfZptCheb'][:, 0] > 0.0))
764 # Log warnings for any visit which has no valid zeropoints
765 badVisits = np.unique(zptCat['visit'][~selected])
766 goodVisits = np.unique(zptCat['visit'][selected])
767 allBadVisits = badVisits[~np.isin(badVisits, goodVisits)]
768 for allBadVisit in allBadVisits:
769 self.log.warning(f'No suitable photoCalib for visit {allBadVisit}')
771 # Get a mapping from filtername to the offsets
772 offsetMapping = {}
773 for f in physicalFilterMap:
774 # Not every filter in the map will necesarily have a band.
775 if physicalFilterMap[f] in bands:
776 offsetMapping[f] = offsets[bands.index(physicalFilterMap[f])]
778 # Get a mapping from "ccd" to the ccd index used for the scaling
779 ccdMapping = {}
780 for ccdIndex, detector in enumerate(camera):
781 ccdMapping[detector.getId()] = ccdIndex
783 # And a mapping to get the flat-field scaling values
784 scalingMapping = {}
785 for rec in visitCat:
786 scalingMapping[rec['visit']] = rec['scaling']
788 if self.config.doComposeWcsJacobian:
789 approxPixelAreaFields = computeApproxPixelAreaFields(camera)
791 # The zptCat is sorted by visit, which is useful
792 lastVisit = -1
793 zptVisitCatalog = None
795 metadata = dafBase.PropertyList()
796 metadata.add("COMMENT", "Catalog id is detector id, sorted.")
797 metadata.add("COMMENT", "Only detectors with data have entries.")
799 for rec in zptCat[selected]:
800 # Retrieve overall scaling
801 scaling = scalingMapping[rec['visit']][ccdMapping[rec['detector']]]
803 # The postCalibrationOffset describe any zeropoint offsets
804 # to apply after the fgcm calibration. The first part comes
805 # from the reference catalog match (used in testing). The
806 # second part comes from the mean chromatic correction
807 # (if configured).
808 postCalibrationOffset = offsetMapping[rec['filtername']]
809 if self.config.doApplyMeanChromaticCorrection:
810 postCalibrationOffset += rec['fgcmDeltaChrom']
812 fgcmSuperStarField = self._getChebyshevBoundedField(rec['fgcmfZptSstarCheb'],
813 rec['fgcmfZptChebXyMax'])
814 # Convert from FGCM AB to nJy
815 fgcmZptField = self._getChebyshevBoundedField((rec['fgcmfZptCheb']*units.AB).to_value(units.nJy),
816 rec['fgcmfZptChebXyMax'],
817 offset=postCalibrationOffset,
818 scaling=scaling)
820 if self.config.doComposeWcsJacobian:
822 fgcmField = afwMath.ProductBoundedField([approxPixelAreaFields[rec['detector']],
823 fgcmSuperStarField,
824 fgcmZptField])
825 else:
826 # The photoCalib is just the product of the fgcmSuperStarField and the
827 # fgcmZptField
828 fgcmField = afwMath.ProductBoundedField([fgcmSuperStarField, fgcmZptField])
830 # The "mean" calibration will be set to the center of the ccd for reference
831 calibCenter = fgcmField.evaluate(fgcmField.getBBox().getCenter())
832 calibErr = (np.log(10.0)/2.5)*calibCenter*np.sqrt(rec['fgcmZptVar'])
833 photoCalib = afwImage.PhotoCalib(calibrationMean=calibCenter,
834 calibrationErr=calibErr,
835 calibration=fgcmField,
836 isConstant=False)
838 # Return full per-visit exposure catalogs
839 if rec['visit'] != lastVisit:
840 # This is a new visit. If the last visit was not -1, yield
841 # the ExposureCatalog
842 if lastVisit > -1:
843 # ensure that the detectors are in sorted order, for fast lookups
844 zptVisitCatalog.sort()
845 yield (int(lastVisit), zptVisitCatalog)
846 else:
847 # We need to create a new schema
848 zptExpCatSchema = afwTable.ExposureTable.makeMinimalSchema()
849 zptExpCatSchema.addField('visit', type='L', doc='Visit number')
851 # And start a new one
852 zptVisitCatalog = afwTable.ExposureCatalog(zptExpCatSchema)
853 zptVisitCatalog.setMetadata(metadata)
855 lastVisit = int(rec['visit'])
857 catRecord = zptVisitCatalog.addNew()
858 catRecord['id'] = int(rec['detector'])
859 catRecord['visit'] = rec['visit']
860 catRecord.setPhotoCalib(photoCalib)
862 # Final output of last exposure catalog
863 # ensure that the detectors are in sorted order, for fast lookups
864 zptVisitCatalog.sort()
865 yield (int(lastVisit), zptVisitCatalog)
867 @staticmethod
868 def _getChebyshevBoundedField(coefficients, xyMax, offset=0.0, scaling=1.0):
869 """
870 Make a ChebyshevBoundedField from fgcm coefficients, with optional offset
871 and scaling.
873 Parameters
874 ----------
875 coefficients: `numpy.array`
876 Flattened array of chebyshev coefficients
877 xyMax: `list` of length 2
878 Maximum x and y of the chebyshev bounding box
879 offset: `float`, optional
880 Absolute calibration offset. Default is 0.0
881 scaling: `float`, optional
882 Flat scaling value from fgcmBuildStars. Default is 1.0
884 Returns
885 -------
886 boundedField: `lsst.afw.math.ChebyshevBoundedField`
887 """
889 orderPlus1 = int(np.sqrt(coefficients.size))
890 pars = np.zeros((orderPlus1, orderPlus1))
892 bbox = lsst.geom.Box2I(minimum=lsst.geom.Point2I(0, 0),
893 maximum=lsst.geom.Point2I(*xyMax))
895 pars[:, :] = (coefficients.reshape(orderPlus1, orderPlus1)
896 * (10.**(offset/-2.5))*scaling)
898 boundedField = afwMath.ChebyshevBoundedField(bbox, pars)
900 return boundedField
902 def _outputAtmospheres(self, handleDict, atmCat):
903 """
904 Output the atmospheres.
906 Parameters
907 ----------
908 handleDict : `dict`
909 All data handles are `lsst.daf.butler.DeferredDatasetHandle`
910 The handleDict has the follownig keys:
912 ``"fgcmLookUpTable"``
913 handle for the FGCM look-up table.
914 atmCat : `lsst.afw.table.BaseCatalog`
915 FGCM atmosphere parameter catalog from fgcmFitCycleTask.
917 Returns
918 -------
919 atmospheres : `generator` [(`int`, `lsst.afw.image.TransmissionCurve`)]
920 Generator that returns (visit, transmissionCurve) tuples.
921 """
922 # First, we need to grab the look-up table and key info
923 lutCat = handleDict['fgcmLookUpTable'].get()
925 atmosphereTableName = lutCat[0]['tablename']
926 elevation = lutCat[0]['elevation']
927 atmLambda = lutCat[0]['atmLambda']
928 lutCat = None
930 # Make the atmosphere table if possible
931 try:
932 atmTable = fgcm.FgcmAtmosphereTable.initWithTableName(atmosphereTableName)
933 atmTable.loadTable()
934 except IOError:
935 atmTable = None
937 if atmTable is None:
938 # Try to use MODTRAN instead
939 try:
940 modGen = fgcm.ModtranGenerator(elevation)
941 lambdaRange = np.array([atmLambda[0], atmLambda[-1]])/10.
942 lambdaStep = (atmLambda[1] - atmLambda[0])/10.
943 except (ValueError, IOError) as e:
944 raise RuntimeError("FGCM look-up-table generated with modtran, "
945 "but modtran not configured to run.") from e
947 zenith = np.degrees(np.arccos(1./atmCat['secZenith']))
949 for i, visit in enumerate(atmCat['visit']):
950 if atmTable is not None:
951 # Interpolate the atmosphere table
952 atmVals = atmTable.interpolateAtmosphere(pmb=atmCat[i]['pmb'],
953 pwv=atmCat[i]['pwv'],
954 o3=atmCat[i]['o3'],
955 tau=atmCat[i]['tau'],
956 alpha=atmCat[i]['alpha'],
957 zenith=zenith[i],
958 ctranslamstd=[atmCat[i]['cTrans'],
959 atmCat[i]['lamStd']])
960 else:
961 # Run modtran
962 modAtm = modGen(pmb=atmCat[i]['pmb'],
963 pwv=atmCat[i]['pwv'],
964 o3=atmCat[i]['o3'],
965 tau=atmCat[i]['tau'],
966 alpha=atmCat[i]['alpha'],
967 zenith=zenith[i],
968 lambdaRange=lambdaRange,
969 lambdaStep=lambdaStep,
970 ctranslamstd=[atmCat[i]['cTrans'],
971 atmCat[i]['lamStd']])
972 atmVals = modAtm['COMBINED']
974 # Now need to create something to persist...
975 curve = TransmissionCurve.makeSpatiallyConstant(throughput=atmVals,
976 wavelengths=atmLambda,
977 throughputAtMin=atmVals[0],
978 throughputAtMax=atmVals[-1])
980 yield (int(visit), curve)
982 def _outputTractStars(self, skymap, stdCat):
983 """Output the tract-sharded stars.
985 Parameters
986 ----------
987 skymap : `lsst.skymap.SkyMap`
988 Skymap for tract id information.
989 stdCat : `lsst.afw.table.SimpleCatalog`
990 FGCM standard star catalog from ``FgcmFitCycleTask``
992 Returns
993 -------
994 tractgen : `generator` [(`int`, `astropy.table.Table`)]
995 Generator that returns (tractId, Table) tuples.
996 """
997 md = stdCat.getMetadata()
998 bands = md.getArray('BANDS')
1000 dtype = [
1001 ("fgcm_id", "i8"),
1002 ("isolated_star_id", "i8"),
1003 ("ra", "f8"),
1004 ("dec", "f8"),
1005 ]
1007 for band in bands:
1008 dtype.extend(
1009 (
1010 (f"mag_{band}", "f4"),
1011 (f"magErr_{band}", "f4"),
1012 (f"ngood_{band}", "i4"),
1013 ),
1014 )
1016 tractIds = skymap.findTractIdArray(stdCat["coord_ra"], stdCat["coord_dec"])
1018 h, rev = esutil.stat.histogram(tractIds, rev=True)
1020 good, = np.where(h > 0)
1022 for index in good:
1023 i1a = rev[rev[index]: rev[index + 1]]
1024 tractId = tractIds[i1a[0]]
1026 table = Table(np.zeros(len(i1a), dtype=dtype))
1027 table["fgcm_id"] = stdCat["id"][i1a]
1028 table["isolated_star_id"] = stdCat["isolated_star_id"][i1a]
1029 table["ra"] = np.rad2deg(stdCat["coord_ra"][i1a])*units.degree
1030 table["dec"] = np.rad2deg(stdCat["coord_dec"][i1a])*units.degree
1032 for i, band in enumerate(bands):
1033 table[f"mag_{band}"] = stdCat["mag_std_noabs"][i1a, i]*units.ABmag
1034 table[f"magErr_{band}"] = stdCat["magErr_std"][i1a, i]*units.ABmag
1035 table[f"ngood_{band}"] = stdCat["ngood"][i1a, i]
1037 # Use NaN as a sentinel for missing values instead of 99.
1038 bad = (table[f"mag_{band}"] > 90.0)
1039 table[f"mag_{band}"][bad] = np.nan
1040 table[f"magErr_{band}"][bad] = np.nan
1042 yield (int(tractId), table)