22__all__ = [
"PhotoCalTask",
"PhotoCalConfig"]
28import astropy.units
as u
32from lsst.afw.image import abMagErrFromFluxErr, makePhotoCalibFromCalibZeroPoint
37from lsst.utils.timer
import timeMethod
38from .colorterms
import ColortermLibrary
42 """Config for PhotoCal."""
44 match = pexConf.ConfigField(
"Match to reference catalog",
45 DirectMatchConfigWithoutLoader)
46 reserve = pexConf.ConfigurableField(target=ReserveSourcesTask, doc=
"Reserve sources from fitting")
47 fluxField = pexConf.Field(
49 default=
"slot_CalibFlux_instFlux",
50 doc=(
"Name of the source instFlux field to use.\nThe associated flag field "
51 "('<name>_flags') will be implicitly included in badFlags."),
53 applyColorTerms = pexConf.Field(
56 doc=(
"Apply photometric color terms to reference stars?\n"
57 "`True`: attempt to apply color terms; fail if color term data is "
58 "not available for the specified reference catalog and filter.\n"
59 "`False`: do not apply color terms."),
62 sigmaMax = pexConf.Field(
65 doc=
"maximum sigma to use when clipping",
68 nSigma = pexConf.Field(
73 useMedian = pexConf.Field(
76 doc=
"use median instead of mean to compute zeropoint",
78 nIter = pexConf.Field(
81 doc=
"number of iterations",
83 colorterms = pexConf.ConfigField(
84 dtype=ColortermLibrary,
85 doc=
"Library of photometric reference catalog name: color term dict (see also applyColorTerms).",
87 photoCatName = pexConf.Field(
90 doc=(
"Name of photometric reference catalog; used to select a color term dict in colorterms.\n"
91 "See also applyColorTerms."),
93 magErrFloor = pexConf.RangeField(
96 doc=
"Additional magnitude uncertainty to be added in quadrature with measurement errors.",
101 pexConf.Config.validate(self)
103 raise RuntimeError(
"applyColorTerms=True requires photoCatName is non-None")
105 raise RuntimeError(
"applyColorTerms=True requires colorterms be provided")
108 pexConf.Config.setDefaults(self)
109 self.
match.sourceSelection.doFlags =
True
110 self.
match.sourceSelection.flags.bad = [
111 "base_PixelFlags_flag_edge",
112 "base_PixelFlags_flag_interpolated",
113 "base_PixelFlags_flag_saturated",
115 self.
match.sourceSelection.doUnresolved =
True
119 """Calculate an Exposure's zero-point given a set of flux measurements
120 of stars matched to an input catalogue.
125 An instance of LoadReferenceObjectsTasks that supplies an external reference
128 The schema of the detection catalogs used as input to this task.
130 Additional keyword arguments.
134 The type of flux to use
is specified by PhotoCalConfig.fluxField.
136 The algorithm clips outliers iteratively,
with parameters set
in the configuration.
138 This task can adds fields to the schema, so any code calling this task must ensure that
139 these columns are indeed present
in the input match list; see `pipe_tasks_photocal_Example`.
143 The available `~lsst.base.lsstDebug` variables
in PhotoCalTask are:
146 If
True enable other debug outputs.
148 If
True, display the exposure on ds9
's frame 1 and overlay the source catalogue.
153 Objects used in the photometric calibration.
156 Make a scatter plot of flux v. reference magnitude
as a function of reference magnitude:
158 - good objects
in blue
159 - rejected objects
in red
161 (
if scatterPlot
is 2
or more, prompt to
continue after each iteration)
164 ConfigClass = PhotoCalConfig
165 _DefaultName = "photoCal"
167 def __init__(self, refObjLoader, schema=None, **kwds):
168 pipeBase.Task.__init__(self, **kwds)
171 if schema
is not None:
172 self.
usedKey = schema.addField(
"calib_photometry_used", type=
"Flag",
173 doc=
"set if source was used in photometric calibration")
177 name=
"match", parentTask=self)
178 self.makeSubtask(
"reserve", columnName=
"calib_photometry", schema=schema,
179 doc=
"set if source was reserved from photometric calibration")
182 """Return a struct containing the source catalog keys for fields used
187 schema : `lsst.afw.table.schema`
188 Schema of the catalog to get keys from.
192 result : `lsst.pipe.base.Struct`
193 Results
as a struct
with attributes:
198 Instrument flux error key.
200 instFlux = schema.find(self.config.fluxField).key
201 instFluxErr = schema.find(self.config.fluxField + "Err").key
202 return pipeBase.Struct(instFlux=instFlux, instFluxErr=instFluxErr)
206 """Extract magnitude and magnitude error arrays from the given matches.
211 Reference/source matches.
213 Label of filter being calibrated.
214 sourceKeys : `lsst.pipe.base.Struct`
215 Struct of source catalog keys, as returned by
getSourceKeys().
219 result : `lsst.pipe.base.Struct`
220 Results
as a struct
with attributes:
223 Source magnitude (`np.array`).
225 Reference magnitude (`np.array`).
227 Source magnitude error (`np.array`).
229 Reference magnitude error (`np.array`).
231 An error
in the magnitude; the error
in ``srcMag`` - ``refMag``.
232 If nonzero, ``config.magErrFloor`` will be added to ``magErr`` only
233 (
not ``srcMagErr``
or ``refMagErr``),
as
234 ``magErr``
is what
is later used to determine the zero point (`np.array`).
236 A list of field names of the reference catalog used
for fluxes (1
or 2 strings) (`list`).
238 srcInstFluxArr = np.array([m.second.get(sourceKeys.instFlux) for m
in matches])
239 srcInstFluxErrArr = np.array([m.second.get(sourceKeys.instFluxErr)
for m
in matches])
240 if not np.all(np.isfinite(srcInstFluxErrArr)):
242 self.log.warning(
"Source catalog does not have flux uncertainties; using sqrt(flux).")
243 srcInstFluxErrArr = np.sqrt(srcInstFluxArr)
246 referenceFlux = (0*u.ABmag).to_value(u.nJy)
247 srcInstFluxArr = srcInstFluxArr * referenceFlux
248 srcInstFluxErrArr = srcInstFluxErrArr * referenceFlux
251 raise RuntimeError(
"No reference stars are available")
252 refSchema = matches[0].first.schema
254 if self.config.applyColorTerms:
255 self.log.info(
"Applying color terms for filter=%r, config.photoCatName=%s",
256 filterLabel.physicalLabel, self.config.photoCatName)
257 colorterm = self.config.colorterms.getColorterm(filterLabel.physicalLabel,
258 self.config.photoCatName,
263 refCat.reserve(len(matches))
265 record = refCat.addNew()
266 record.assign(x.first)
268 refMagArr, refMagErrArr = colorterm.getCorrectedMagnitudes(refCat)
269 fluxFieldList = [getRefFluxField(refSchema, filt)
for filt
in (colorterm.primary,
270 colorterm.secondary)]
272 self.log.info(
"Not applying color terms.")
275 fluxFieldList = [getRefFluxField(refSchema, filterLabel.bandLabel)]
276 fluxField = getRefFluxField(refSchema, filterLabel.bandLabel)
277 fluxKey = refSchema.find(fluxField).key
278 refFluxArr = np.array([m.first.get(fluxKey)
for m
in matches])
281 fluxErrKey = refSchema.find(fluxField +
"Err").key
282 refFluxErrArr = np.array([m.first.get(fluxErrKey)
for m
in matches])
285 self.log.warning(
"Reference catalog does not have flux uncertainties for %s;"
286 " using sqrt(flux).", fluxField)
287 refFluxErrArr = np.sqrt(refFluxArr)
289 refMagArr = u.Quantity(refFluxArr, u.nJy).to_value(u.ABmag)
291 refMagErrArr = abMagErrFromFluxErr(refFluxErrArr*1e-9, refFluxArr*1e-9)
294 srcMagArr = u.Quantity(srcInstFluxArr, u.nJy).to_value(u.ABmag)
298 magErrArr = abMagErrFromFluxErr(srcInstFluxErrArr*1e-9, srcInstFluxArr*1e-9)
299 if self.config.magErrFloor != 0.0:
300 magErrArr = (magErrArr**2 + self.config.magErrFloor**2)**0.5
302 srcMagErrArr = abMagErrFromFluxErr(srcInstFluxErrArr*1e-9, srcInstFluxArr*1e-9)
304 good = np.isfinite(srcMagArr) & np.isfinite(refMagArr)
306 return pipeBase.Struct(
307 srcMag=srcMagArr[good],
308 refMag=refMagArr[good],
309 magErr=magErrArr[good],
310 srcMagErr=srcMagErrArr[good],
311 refMagErr=refMagErrArr[good],
312 refFluxFieldList=fluxFieldList,
316 def run(self, exposure, sourceCat, expId=0):
317 """Do photometric calibration - select matches to use and (possibly iteratively) compute
323 Exposure upon which the sources in the matches were detected.
324 sourceCat : `lsst.afw.image.SourceCatalog`
325 A catalog of sources to use
in the calibration
328 the reference object
and matched object respectively).
329 Will
not be modified
except to set the outputField
if requested.
330 expId : `int`, optional
335 result : `lsst.pipe.base.Struct`
336 Results
as a struct
with attributes:
339 Object containing the zero point (`lsst.afw.image.Calib`).
341 Magnitude arrays returned be `PhotoCalTask.extractMagArrays`.
343 ReferenceMatchVector,
as returned by `PhotoCalTask.selectMatches`.
345 Photometric zero point (mag, `float`).
347 Standard deviation of fit of photometric zero point (mag, `float`).
349 Number of sources used to fit photometric zero point (`int`).
354 Raised
if any of the following occur:
355 - No matches to use
for photocal.
356 - No matches are available (perhaps no sources/references were selected by the matcher).
357 - No reference stars are available.
358 - No matches are available
from which to extract magnitudes.
362 The exposure
is only used to provide the name of the filter being calibrated (it may also be
363 used to generate debugging plots).
365 The reference objects:
366 - Must include a field ``photometric``;
True for objects which should be considered
as
367 photometric standards.
368 - Must include a field ``flux``; the flux used to impose a magnitude limit
and also to calibrate
369 the data to (unless a color term
is specified,
in which case ColorTerm.primary
is used;
370 See https://jira.lsstcorp.org/browse/DM-933).
371 - May include a field ``stargal``;
if present,
True means that the object
is a star.
372 - May include a field ``var``;
if present,
True means that the object
is variable.
374 The measured sources:
375 - Must include PhotoCalConfig.fluxField; the flux measurement to be used
for calibration.
380 displaySources = display
and lsstDebug.Info(__name__).displaySources
384 from matplotlib
import pyplot
388 self.
fig = pyplot.figure()
390 filterLabel = exposure.getFilter()
393 matchResults = self.
match.run(sourceCat, filterLabel.bandLabel)
394 matches = matchResults.matches
396 reserveResults = self.reserve.run([mm.second
for mm
in matches], expId=expId)
399 if reserveResults.reserved.sum() > 0:
400 matches = [mm
for mm, use
in zip(matches, reserveResults.use)
if use]
401 if len(matches) == 0:
402 raise RuntimeError(
"No matches to use for photocal")
405 mm.second.set(self.
usedKey,
True)
412 r = self.
getZeroPoint(arrays.srcMag, arrays.refMag, arrays.magErr)
413 self.log.info(
"Magnitude zero point: %f +/- %f from %d stars", r.zp, r.sigma, r.ngood)
416 flux0 = 10**(0.4*r.zp)
417 flux0err = 0.4*math.log(10)*flux0*r.sigma
418 photoCalib = makePhotoCalibFromCalibZeroPoint(flux0, flux0err)
420 return pipeBase.Struct(
421 photoCalib=photoCalib,
430 """Display sources we'll use for photocal.
432 Sources that will be actually used will be green.
433 Sources reserved from the fit will be red.
437 exposure : `lsst.afw.image.ExposureF`
439 matches : `list` of `lsst.afw.table.RefMatch`
440 Matches used
for photocal.
441 reserved : `numpy.ndarray` of type `bool`
442 Boolean array indicating sources that are reserved.
443 frame : `int`, optional
444 Frame number
for display.
446 disp = afwDisplay.getDisplay(frame=frame)
447 disp.mtv(exposure, title="photocal")
448 with disp.Buffering():
449 for mm, rr
in zip(matches, reserved):
450 x, y = mm.second.getCentroid()
451 ctype = afwDisplay.RED
if rr
else afwDisplay.GREEN
452 disp.dot(
"o", x, y, size=4, ctype=ctype)
455 """Flux calibration code, returning (ZeroPoint, Distribution Width, Number of stars).
459 result : `lsst.pipe.base.Struct`
460 Results as a struct
with attributes:
463 Photometric zero point (mag, `float`).
465 Standard deviation of fit of photometric zero point (mag, `float`).
467 Number of sources used to fit photometric zero point (`int`).
471 We perform nIter iterations of a simple sigma-clipping algorithm
with a couple of twists:
472 - We use the median/interquartile range to estimate the position to clip around,
and the
474 - We never allow sigma to go _above_ a critical value sigmaMax ---
if we do, a sufficiently
475 large estimate will prevent the clipping
from ever taking effect.
476 - Rather than start
with the median we start
with a crude mode. This means that a set of magnitude
477 residuals
with a tight core
and asymmetrical outliers will start
in the core. We use the width of
478 this core to set our maximum sigma (see second bullet).
480 sigmaMax = self.config.sigmaMax
484 indArr = np.argsort(dmag)
487 if srcErr
is not None:
488 dmagErr = srcErr[indArr]
490 dmagErr = np.ones(len(dmag))
493 ind_noNan = np.array([i
for i
in range(len(dmag))
494 if (
not np.isnan(dmag[i])
and not np.isnan(dmagErr[i]))])
495 dmag = dmag[ind_noNan]
496 dmagErr = dmagErr[ind_noNan]
498 IQ_TO_STDEV = 0.741301109252802
503 for i
in range(self.config.nIter):
514 hist, edges = np.histogram(dmag, nhist, new=
True)
516 hist, edges = np.histogram(dmag, nhist)
517 imode = np.arange(nhist)[np.where(hist == hist.max())]
519 if imode[-1] - imode[0] + 1 == len(imode):
523 center = 0.5*(edges[imode[0]] + edges[imode[-1] + 1])
525 peak = sum(hist[imode])/len(imode)
529 while j >= 0
and hist[j] > 0.5*peak:
532 q1 = dmag[sum(hist[range(j)])]
535 while j < nhist
and hist[j] > 0.5*peak:
537 j =
min(j, nhist - 1)
538 j =
min(sum(hist[range(j)]), npt - 1)
542 q1 = dmag[int(0.25*npt)]
543 q3 = dmag[int(0.75*npt)]
550 self.log.debug(
"Photo calibration histogram: center = %.2f, sig = %.2f", center, sig)
554 sigmaMax = dmag[-1] - dmag[0]
556 center = np.median(dmag)
557 q1 = dmag[int(0.25*npt)]
558 q3 = dmag[int(0.75*npt)]
563 if self.config.useMedian:
564 center = np.median(gdmag)
566 gdmagErr = dmagErr[good]
567 center = np.average(gdmag, weights=gdmagErr)
569 q3 = gdmag[
min(int(0.75*npt + 0.5), npt - 1)]
570 q1 = gdmag[
min(int(0.25*npt + 0.5), npt - 1)]
572 sig = IQ_TO_STDEV*(q3 - q1)
574 good = abs(dmag - center) < self.config.nSigma*
min(sig, sigmaMax)
581 axes = self.
fig.add_axes((0.1, 0.1, 0.85, 0.80))
583 axes.plot(ref[good], dmag[good] - center,
"b+")
584 axes.errorbar(ref[good], dmag[good] - center, yerr=dmagErr[good],
585 linestyle=
'', color=
'b')
587 bad = np.logical_not(good)
588 if len(ref[bad]) > 0:
589 axes.plot(ref[bad], dmag[bad] - center,
"r+")
590 axes.errorbar(ref[bad], dmag[bad] - center, yerr=dmagErr[bad],
591 linestyle=
'', color=
'r')
593 axes.plot((-100, 100), (0, 0),
"g-")
595 axes.plot((-100, 100), x*0.05*np.ones(2),
"g--")
597 axes.set_ylim(-1.1, 1.1)
598 axes.set_xlim(24, 13)
599 axes.set_xlabel(
"Reference")
600 axes.set_ylabel(
"Reference - Instrumental")
606 while i == 0
or reply !=
"c":
608 reply = input(
"Next iteration? [ynhpc] ")
613 print(
"Options: c[ontinue] h[elp] n[o] p[db] y[es]", file=sys.stderr)
616 if reply
in (
"",
"c",
"n",
"p",
"y"):
619 print(
"Unrecognised response: %s" % reply, file=sys.stderr)
626 except Exception
as e:
627 print(
"Error plotting in PhotoCal.getZeroPoint: %s" % e, file=sys.stderr)
634 msg =
"PhotoCal.getZeroPoint: no good stars remain"
637 center = np.average(dmag, weights=dmagErr)
638 msg +=
" on first iteration; using average of all calibration stars"
640 self.log.warning(msg)
642 return pipeBase.Struct(
646 elif ngood == old_ngood:
652 dmagErr = dmagErr[good]
655 dmagErr = dmagErr[good]
656 zp, weightSum = np.average(dmag, weights=1/dmagErr**2, returned=
True)
657 sigma = np.sqrt(1.0/weightSum)
658 return pipeBase.Struct(
A class to contain the data, WCS, and other information needed to describe an image of the sky.
Defines the fields and offsets for a table.
Record class that must contain a unique ID field and a celestial coordinate field.
Custom catalog class for record/table subclasses that are guaranteed to have an ID,...
Record class that contains measurements made on a single exposure.
def getSourceKeys(self, schema)
def getZeroPoint(self, src, ref, srcErr=None, zp0=None)
def extractMagArrays(self, matches, filterLabel, sourceKeys)
def __init__(self, refObjLoader, schema=None, **kwds)
def displaySources(self, exposure, matches, reserved, frame=1)
Lightweight representation of a geometric match between two records.