Loading [MathJax]/extensions/tex2jax.js
LSST Applications g0fba68d861+83433b07ee,g16d25e1f1b+23bc9e47ac,g1ec0fe41b4+3ea9d11450,g1fd858c14a+9be2b0f3b9,g2440f9efcc+8c5ae1fdc5,g35bb328faa+8c5ae1fdc5,g4a4af6cd76+d25431c27e,g4d2262a081+c74e83464e,g53246c7159+8c5ae1fdc5,g55585698de+1e04e59700,g56a49b3a55+92a7603e7a,g60b5630c4e+1e04e59700,g67b6fd64d1+3fc8cb0b9e,g78460c75b0+7e33a9eb6d,g786e29fd12+668abc6043,g8352419a5c+8c5ae1fdc5,g8852436030+60e38ee5ff,g89139ef638+3fc8cb0b9e,g94187f82dc+1e04e59700,g989de1cb63+3fc8cb0b9e,g9d31334357+1e04e59700,g9f33ca652e+0a83e03614,gabe3b4be73+8856018cbb,gabf8522325+977d9fabaf,gb1101e3267+8b4b9c8ed7,gb89ab40317+3fc8cb0b9e,gc0af124501+57ccba3ad1,gcf25f946ba+60e38ee5ff,gd6cbbdb0b4+1cc2750d2e,gd794735e4e+7be992507c,gdb1c4ca869+be65c9c1d7,gde0f65d7ad+c7f52e58fe,ge278dab8ac+6b863515ed,ge410e46f29+3fc8cb0b9e,gf35d7ec915+97dd712d81,gf5e32f922b+8c5ae1fdc5,gf618743f1b+747388abfa,gf67bdafdda+3fc8cb0b9e,w.2025.18
LSST Data Management Base Package
All Classes Namespaces Files Functions Variables Typedefs Enumerations Enumerator Properties Friends Macros Modules Pages
fgcmOutputProducts.py
Go to the documentation of this file.
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.
24
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
35
36import numpy as np
37import hpgeom as hpg
38from astropy import units
39from astropy.table import Table
40import esutil
41
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
54
55from .utilities import computeApproxPixelAreaFields
56from .utilities import FGCM_ILLEGAL_VALUE
57
58import fgcm
59
60__all__ = ['FgcmOutputProductsConfig', 'FgcmOutputProductsTask']
61
62
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 )
73
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 )
80
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 )
89
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 )
97
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 )
105
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 )
113
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 )
121
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 )
130
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 )
140
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 )
149
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 )
157
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 )
165
166 def __init__(self, *, config=None):
167 super().__init__(config=config)
168
169 if str(int(config.connections.cycleNumber)) != config.connections.cycleNumber:
170 raise ValueError("cycleNumber must be of integer format")
171
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
183
184 def getSpatialBoundsConnections(self):
185 return ("fgcmPhotoCalib",)
186
187
188class FgcmOutputProductsConfig(pipeBase.PipelineTaskConfig,
189 pipelineConnections=FgcmOutputProductsConnections):
190 """Config for FgcmOutputProductsTask"""
191
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 )
261
262 def setDefaults(self):
263 pexConfig.Config.setDefaults(self)
264
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.
270
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
287
288
289class FgcmOutputProductsTask(pipeBase.PipelineTask):
290 """
291 Output products from FGCM global calibration.
292 """
293
294 ConfigClass = FgcmOutputProductsConfig
295 _DefaultName = "fgcmOutputProducts"
296
297 def __init__(self, **kwargs):
298 super().__init__(**kwargs)
299
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)
306
307 if self.config.doZeropointOutput:
308 handleDict['fgcmZeropoints'] = butlerQC.get(inputRefs.fgcmZeropoints)
309 photoCalibRefDict = {photoCalibRef.dataId['visit']:
310 photoCalibRef for photoCalibRef in outputRefs.fgcmPhotoCalib}
311
312 if self.config.doAtmosphereOutput:
313 handleDict['fgcmAtmosphereParameters'] = butlerQC.get(inputRefs.fgcmAtmosphereParameters)
314 atmRefDict = {atmRef.dataId['visit']: atmRef for
315 atmRef in outputRefs.fgcmTransmissionAtmosphere}
316
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
327
328 if self.config.doTractStars:
329 handleDict['skymap'] = butlerQC.get(inputRefs.skymap)
330 tractStarRefDict = {tractStarRef.dataId["tract"]: tractStarRef for
331 tractStarRef in outputRefs.fgcmTractStars}
332
333 struct = self.run(handleDict, self.config.physicalFilterMap)
334
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.")
341
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.")
348
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
357
358 butlerQC.put(offsetCat, outputRefs.fgcmOffsets)
359
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])
364
365 return
366
367 def run(self, handleDict, physicalFilterMap):
368 """Run the output products task.
369
370 Parameters
371 ----------
372 handleDict : `dict`
373 All handles are `lsst.daf.butler.DeferredDatasetHandle`
374 handle dictionary with keys:
375
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).
392
393 physicalFilterMap : `dict`
394 Dictionary of mappings from physical filter to FGCM band.
395
396 Returns
397 -------
398 retStruct : `lsst.pipe.base.Struct`
399 Output structure with keys:
400
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')
411
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))
417
418 if self.config.doZeropointOutput:
419 zptCat = handleDict['fgcmZeropoints'].get()
420 visitCat = handleDict['fgcmVisitCatalog'].get()
421
422 pcgen = self._outputZeropoints(handleDict['camera'], zptCat, visitCat, offsets, bands,
423 physicalFilterMap)
424 else:
425 pcgen = None
426
427 if self.config.doAtmosphereOutput:
428 atmCat = handleDict['fgcmAtmosphereParameters'].get()
429 atmgen = self._outputAtmospheres(handleDict, atmCat)
430 else:
431 atmgen = None
432
433 if self.config.doTractStars:
434 skymap = handleDict['skymap']
435 tractstargen = self._outputTractStars(skymap, stdCat)
436 else:
437 tractstargen = None
438
439 retStruct = pipeBase.Struct(offsets=offsets,
440 atmospheres=atmgen,
441 tractStars=tractstargen)
442 retStruct.photoCalibCatalogs = pcgen
443
444 return retStruct
445
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.
451
452 This method is here to have an alternate entry-point for
453 FgcmCalibrateTract.
454
455 Parameters
456 ----------
457 handleDict : `dict`
458 All handles are `lsst.daf.butler.DeferredDatasetHandle`
459 handle dictionary with keys:
460
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`
477
478 Returns
479 -------
480 retStruct : `lsst.pipe.base.Struct`
481 Output structure with keys:
482
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
491
492 md = stdCat.getMetadata()
493 bands = md.getArray('BANDS')
494
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.")
498
499 if not self.config.doComposeWcsJacobian and fgcmBuildStarsConfig.doApplyWcsJacobian:
500 self.log.warning("Jacobian was applied in build-stars but doComposeWcsJacobian is not set.")
501
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))
507
508 if self.config.doZeropointOutput:
509 pcgen = self._outputZeropoints(handleDict['camera'], zptCat, visitCat, offsets, bands,
510 physicalFilterMap)
511 else:
512 pcgen = None
513
514 if self.config.doAtmosphereOutput:
515 atmgen = self._outputAtmospheres(handleDict, atmCat)
516 else:
517 atmgen = None
518
519 retStruct = pipeBase.Struct(offsets=offsets,
520 atmospheres=atmgen)
521 retStruct.photoCalibCatalogs = pcgen
522
523 return retStruct
524
525 def _computeReferenceOffsets(self, stdCat, lutCat, physicalFilterMap, bands):
526 """
527 Compute offsets relative to a reference catalog.
528
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.
534
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 """
550
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)
555
556 goodStars = (minObs >= 1)
557 stdCat = stdCat[goodStars]
558
559 self.log.info("Found %d stars with at least 1 good observation in each band" %
560 (len(stdCat)))
561
562 # Associate each band with the appropriate physicalFilter and make
563 # filterLabels
564 filterLabels = []
565
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))
580
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")
597
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)
609
610 gdpix, = np.where(h >= self.config.referencePixelizationMinStars)
611
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))
616
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)
623
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]]]
630
631 # We need a boolean index to deal with catalogs...
632 selected = np.zeros(len(stdCat), dtype=bool)
633
634 refFluxFields = [None]*len(bands)
635
636 for p_index, pix in enumerate(gdpix):
637 i1a = rev[rev[pix]: rev[pix + 1]]
638
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
644
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
653
654 # And compute the summary statistics
655 offsets = np.zeros(len(bands))
656
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)
666
667 return offsets
668
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
674
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 """
692
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)
705
706 exposure = afwImage.ExposureF()
707 exposure.setFilter(filterLabel)
708
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
716
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())
724
725 struct = calTask.run(exposure, sourceCat)
726
727 return struct
728
729 def _outputZeropoints(self, camera, zptCat, visitCat, offsets, bands,
730 physicalFilterMap, tract=None):
731 """Output the zeropoints in fgcm_photoCalib format.
732
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)
749
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
763 # Log warnings for any visit which has no valid zeropoints
764 badVisits = np.unique(zptCat['visit'][~selected])
765 goodVisits = np.unique(zptCat['visit'][selected])
766 allBadVisits = badVisits[~np.isin(badVisits, goodVisits)]
767 for allBadVisit in allBadVisits:
768 self.log.warning(f'No suitable photoCalib for visit {allBadVisit}')
769
770 # Get a mapping from filtername to the offsets
771 offsetMapping = {}
772 for f in physicalFilterMap:
773 # Not every filter in the map will necesarily have a band.
774 if physicalFilterMap[f] in bands:
775 offsetMapping[f] = offsets[bands.index(physicalFilterMap[f])]
776
777 # Get a mapping from "ccd" to the ccd index used for the scaling
778 ccdMapping = {}
779 for ccdIndex, detector in enumerate(camera):
780 ccdMapping[detector.getId()] = ccdIndex
781
782 # And a mapping to get the flat-field scaling values
783 scalingMapping = {}
784 for rec in visitCat:
785 scalingMapping[rec['visit']] = rec['scaling']
786
787 if self.config.doComposeWcsJacobian:
788 approxPixelAreaFields = computeApproxPixelAreaFields(camera)
789
790 # The zptCat is sorted by visit, which is useful
791 lastVisit = -1
792 zptVisitCatalog = None
793
794 metadata = dafBase.PropertyList()
795 metadata.add("COMMENT", "Catalog id is detector id, sorted.")
796 metadata.add("COMMENT", "Only detectors with data have entries.")
797
798 for rec in zptCat[selected]:
799 # Retrieve overall scaling
800 scaling = scalingMapping[rec['visit']][ccdMapping[rec['detector']]]
801
802 # The postCalibrationOffset describe any zeropoint offsets
803 # to apply after the fgcm calibration. The first part comes
804 # from the reference catalog match (used in testing). The
805 # second part comes from the mean chromatic correction
806 # (if configured).
807 postCalibrationOffset = offsetMapping[rec['filtername']]
808 if self.config.doApplyMeanChromaticCorrection:
809 postCalibrationOffset += rec['fgcmDeltaChrom']
810
811 fgcmSuperStarField = self._getChebyshevBoundedField(rec['fgcmfZptSstarCheb'],
812 rec['fgcmfZptChebXyMax'])
813 # Convert from FGCM AB to nJy
814 fgcmZptField = self._getChebyshevBoundedField((rec['fgcmfZptCheb']*units.AB).to_value(units.nJy),
815 rec['fgcmfZptChebXyMax'],
816 offset=postCalibrationOffset,
817 scaling=scaling)
818
819 if self.config.doComposeWcsJacobian:
820
821 fgcmField = afwMath.ProductBoundedField([approxPixelAreaFields[rec['detector']],
822 fgcmSuperStarField,
823 fgcmZptField])
824 else:
825 # The photoCalib is just the product of the fgcmSuperStarField and the
826 # fgcmZptField
827 fgcmField = afwMath.ProductBoundedField([fgcmSuperStarField, fgcmZptField])
828
829 # The "mean" calibration will be set to the center of the ccd for reference
830 calibCenter = fgcmField.evaluate(fgcmField.getBBox().getCenter())
831 calibErr = (np.log(10.0)/2.5)*calibCenter*np.sqrt(rec['fgcmZptVar'])
832 photoCalib = afwImage.PhotoCalib(calibrationMean=calibCenter,
833 calibrationErr=calibErr,
834 calibration=fgcmField,
835 isConstant=False)
836
837 # Return full per-visit exposure catalogs
838 if rec['visit'] != lastVisit:
839 # This is a new visit. If the last visit was not -1, yield
840 # the ExposureCatalog
841 if lastVisit > -1:
842 # ensure that the detectors are in sorted order, for fast lookups
843 zptVisitCatalog.sort()
844 yield (int(lastVisit), zptVisitCatalog)
845 else:
846 # We need to create a new schema
847 zptExpCatSchema = afwTable.ExposureTable.makeMinimalSchema()
848 zptExpCatSchema.addField('visit', type='L', doc='Visit number')
849
850 # And start a new one
851 zptVisitCatalog = afwTable.ExposureCatalog(zptExpCatSchema)
852 zptVisitCatalog.setMetadata(metadata)
853
854 lastVisit = int(rec['visit'])
855
856 catRecord = zptVisitCatalog.addNew()
857 catRecord['id'] = int(rec['detector'])
858 catRecord['visit'] = rec['visit']
859 catRecord.setPhotoCalib(photoCalib)
860
861 # Final output of last exposure catalog
862 # ensure that the detectors are in sorted order, for fast lookups
863 zptVisitCatalog.sort()
864 yield (int(lastVisit), zptVisitCatalog)
865
866 @staticmethod
867 def _getChebyshevBoundedField(coefficients, xyMax, offset=0.0, scaling=1.0):
868 """
869 Make a ChebyshevBoundedField from fgcm coefficients, with optional offset
870 and scaling.
871
872 Parameters
873 ----------
874 coefficients: `numpy.array`
875 Flattened array of chebyshev coefficients
876 xyMax: `list` of length 2
877 Maximum x and y of the chebyshev bounding box
878 offset: `float`, optional
879 Absolute calibration offset. Default is 0.0
880 scaling: `float`, optional
881 Flat scaling value from fgcmBuildStars. Default is 1.0
882
883 Returns
884 -------
885 boundedField: `lsst.afw.math.ChebyshevBoundedField`
886 """
887
888 orderPlus1 = int(np.sqrt(coefficients.size))
889 pars = np.zeros((orderPlus1, orderPlus1))
890
891 bbox = lsst.geom.Box2I(minimum=lsst.geom.Point2I(0, 0),
892 maximum=lsst.geom.Point2I(*xyMax))
893
894 pars[:, :] = (coefficients.reshape(orderPlus1, orderPlus1)
895 * (10.**(offset/-2.5))*scaling)
896
897 boundedField = afwMath.ChebyshevBoundedField(bbox, pars)
898
899 return boundedField
900
901 def _outputAtmospheres(self, handleDict, atmCat):
902 """
903 Output the atmospheres.
904
905 Parameters
906 ----------
907 handleDict : `dict`
908 All data handles are `lsst.daf.butler.DeferredDatasetHandle`
909 The handleDict has the follownig keys:
910
911 ``"fgcmLookUpTable"``
912 handle for the FGCM look-up table.
913 atmCat : `lsst.afw.table.BaseCatalog`
914 FGCM atmosphere parameter catalog from fgcmFitCycleTask.
915
916 Returns
917 -------
918 atmospheres : `generator` [(`int`, `lsst.afw.image.TransmissionCurve`)]
919 Generator that returns (visit, transmissionCurve) tuples.
920 """
921 # First, we need to grab the look-up table and key info
922 lutCat = handleDict['fgcmLookUpTable'].get()
923
924 atmosphereTableName = lutCat[0]['tablename']
925 elevation = lutCat[0]['elevation']
926 atmLambda = lutCat[0]['atmLambda']
927 lutCat = None
928
929 # Make the atmosphere table if possible
930 try:
931 atmTable = fgcm.FgcmAtmosphereTable.initWithTableName(atmosphereTableName)
932 atmTable.loadTable()
933 except IOError:
934 atmTable = None
935
936 if atmTable is None:
937 # Try to use MODTRAN instead
938 try:
939 modGen = fgcm.ModtranGenerator(elevation)
940 lambdaRange = np.array([atmLambda[0], atmLambda[-1]])/10.
941 lambdaStep = (atmLambda[1] - atmLambda[0])/10.
942 except (ValueError, IOError) as e:
943 raise RuntimeError("FGCM look-up-table generated with modtran, "
944 "but modtran not configured to run.") from e
945
946 zenith = np.degrees(np.arccos(1./atmCat['secZenith']))
947
948 for i, visit in enumerate(atmCat['visit']):
949 if atmTable is not None:
950 # Interpolate the atmosphere table
951 atmVals = atmTable.interpolateAtmosphere(pmb=atmCat[i]['pmb'],
952 pwv=atmCat[i]['pwv'],
953 o3=atmCat[i]['o3'],
954 tau=atmCat[i]['tau'],
955 alpha=atmCat[i]['alpha'],
956 zenith=zenith[i],
957 ctranslamstd=[atmCat[i]['cTrans'],
958 atmCat[i]['lamStd']])
959 else:
960 # Run modtran
961 modAtm = modGen(pmb=atmCat[i]['pmb'],
962 pwv=atmCat[i]['pwv'],
963 o3=atmCat[i]['o3'],
964 tau=atmCat[i]['tau'],
965 alpha=atmCat[i]['alpha'],
966 zenith=zenith[i],
967 lambdaRange=lambdaRange,
968 lambdaStep=lambdaStep,
969 ctranslamstd=[atmCat[i]['cTrans'],
970 atmCat[i]['lamStd']])
971 atmVals = modAtm['COMBINED']
972
973 # Now need to create something to persist...
974 curve = TransmissionCurve.makeSpatiallyConstant(throughput=atmVals,
975 wavelengths=atmLambda,
976 throughputAtMin=atmVals[0],
977 throughputAtMax=atmVals[-1])
978
979 yield (int(visit), curve)
980
981 def _outputTractStars(self, skymap, stdCat):
982 """Output the tract-sharded stars.
983
984 Parameters
985 ----------
986 skymap : `lsst.skymap.SkyMap`
987 Skymap for tract id information.
988 stdCat : `lsst.afw.table.SimpleCatalog`
989 FGCM standard star catalog from ``FgcmFitCycleTask``
990
991 Returns
992 -------
993 tractgen : `generator` [(`int`, `astropy.table.Table`)]
994 Generator that returns (tractId, Table) tuples.
995 """
996 md = stdCat.getMetadata()
997 bands = md.getArray('BANDS')
998
999 dtype = [
1000 ("fgcm_id", "i8"),
1001 ("isolated_star_id", "i8"),
1002 ("ra", "f8"),
1003 ("dec", "f8"),
1004 ]
1005
1006 for band in bands:
1007 dtype.extend(
1008 (
1009 (f"mag_{band}", "f4"),
1010 (f"magErr_{band}", "f4"),
1011 (f"ngood_{band}", "i4"),
1012 ),
1013 )
1014
1015 tractIds = skymap.findTractIdArray(stdCat["coord_ra"], stdCat["coord_dec"])
1016
1017 h, rev = esutil.stat.histogram(tractIds, rev=True)
1018
1019 good, = np.where(h > 0)
1020
1021 for index in good:
1022 i1a = rev[rev[index]: rev[index + 1]]
1023 tractId = tractIds[i1a[0]]
1024
1025 table = Table(np.zeros(len(i1a), dtype=dtype))
1026 table["fgcm_id"] = stdCat["id"][i1a]
1027 table["isolated_star_id"] = stdCat["isolated_star_id"][i1a]
1028 table["ra"] = np.rad2deg(stdCat["coord_ra"][i1a])*units.degree
1029 table["dec"] = np.rad2deg(stdCat["coord_dec"][i1a])*units.degree
1030
1031 for i, band in enumerate(bands):
1032 table[f"mag_{band}"] = stdCat["mag_std_noabs"][i1a, i]*units.ABmag
1033 table[f"magErr_{band}"] = stdCat["magErr_std"][i1a, i]*units.ABmag
1034 table[f"ngood_{band}"] = stdCat["ngood"][i1a, i]
1035
1036 # Use NaN as a sentinel for missing values instead of 99.
1037 bad = (table[f"mag_{band}"] > 90.0)
1038 table[f"mag_{band}"][bad] = np.nan
1039 table[f"magErr_{band}"][bad] = np.nan
1040
1041 yield (int(tractId), table)
A group of labels for a filter in an exposure or coadd.
Definition FilterLabel.h:58
The photometric calibration of an exposure.
Definition PhotoCalib.h:114
A BoundedField based on 2-d Chebyshev polynomials of the first kind.
A BoundedField that lazily multiplies a sequence of other BoundedFields.
Defines the fields and offsets for a table.
Definition Schema.h:51
A mapping between the keys of two Schemas, used to copy data between them.
Class for storing ordered metadata with comments.
An integer coordinate rectangle.
Definition Box.h:55