27import astropy.units
as u
31from lsst.afw.image import abMagErrFromFluxErr, makePhotoCalibFromCalibZeroPoint
36from lsst.utils.timer
import timeMethod
37from .colorterms
import ColortermLibrary
39__all__ = [
"PhotoCalTask",
"PhotoCalConfig"]
43 """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. The associated flag field\n"
51 "('<name>_flags') will be implicitly included in badFlags."),
53 applyColorTerms = pexConf.Field(
56 doc=(
"Apply photometric color terms to reference stars? One of:\n"
57 "None: apply if colorterms and photoCatName are not None;\n"
58 " fail if color term data is not available for the specified ref catalog and filter.\n"
59 "True: always apply colorterms; fail if color term data is not available for the\n"
60 " specified reference catalog and filter.\n"
61 "False: do not apply."),
64 sigmaMax = pexConf.Field(
67 doc=
"maximum sigma to use when clipping",
70 nSigma = pexConf.Field(
75 useMedian = pexConf.Field(
78 doc=
"use median instead of mean to compute zeropoint",
80 nIter = pexConf.Field(
83 doc=
"number of iterations",
85 colorterms = pexConf.ConfigField(
86 dtype=ColortermLibrary,
87 doc=
"Library of photometric reference catalog name: color term dict",
89 photoCatName = pexConf.Field(
92 doc=(
"Name of photometric reference catalog; used to select a color term dict in colorterms."
93 " see also applyColorTerms"),
95 magErrFloor = pexConf.RangeField(
98 doc=
"Additional magnitude uncertainty to be added in quadrature with measurement errors.",
103 pexConf.Config.validate(self)
105 raise RuntimeError(
"applyColorTerms=True requires photoCatName is non-None")
107 raise RuntimeError(
"applyColorTerms=True requires colorterms be provided")
110 pexConf.Config.setDefaults(self)
111 self.
matchmatch.sourceSelection.doFlags =
True
112 self.
matchmatch.sourceSelection.flags.bad = [
113 "base_PixelFlags_flag_edge",
114 "base_PixelFlags_flag_interpolated",
115 "base_PixelFlags_flag_saturated",
117 self.
matchmatch.sourceSelection.doUnresolved =
True
131@brief Calculate the zero point of an exposure given a lsst.afw.table.ReferenceMatchVector.
133@section pipe_tasks_photocal_Contents Contents
135 - @ref pipe_tasks_photocal_Purpose
136 - @ref pipe_tasks_photocal_Initialize
137 - @ref pipe_tasks_photocal_IO
138 - @ref pipe_tasks_photocal_Config
139 - @ref pipe_tasks_photocal_Debug
140 - @ref pipe_tasks_photocal_Example
142@section pipe_tasks_photocal_Purpose Description
144@copybrief PhotoCalTask
146Calculate an Exposure's zero-point given a set of flux measurements of stars matched to an input catalogue.
147The type of flux to use is specified by PhotoCalConfig.fluxField.
149The algorithm clips outliers iteratively, with parameters set in the configuration.
151@note This task can adds fields to the schema, so any code calling this task must ensure that
152these columns are indeed present in the input match list; see @ref pipe_tasks_photocal_Example
154@section pipe_tasks_photocal_Initialize Task initialisation
158@section pipe_tasks_photocal_IO Inputs/Outputs to the run method
162@section pipe_tasks_photocal_Config Configuration parameters
164See
@ref PhotoCalConfig
166@section pipe_tasks_photocal_Debug Debug variables
168The command line task interface supports a
169flag
@c -d to
import @b debug.py
from your
@c PYTHONPATH; see
170<a href=
"https://pipelines.lsst.io/modules/lsstDebug/">the lsstDebug documentation</a>
171for more about
@b debug.py files.
173The available variables
in PhotoCalTask are:
176 <DD> If
True enable other debug outputs
177 <DT>
@c displaySources
178 <DD> If
True, display the exposure on the display
's frame 1 and overlay the source catalogue.
181 <DD> Reserved objects
183 <DD> Objects used in the photometric calibration
186 <DD> Make a scatter plot of flux v. reference magnitude
as a function of reference magnitude.
187 - good objects
in blue
188 - rejected objects
in red
189 (
if @c scatterPlot
is 2
or more, prompt to
continue after each iteration)
192@section pipe_tasks_photocal_Example A complete example of using PhotoCalTask
194This code
is in `examples/photoCalTask.py`,
and can be run
as @em e.g.
196examples/photoCalTask.py
198@dontinclude photoCalTask.py
200Import the tasks (there are some other standard imports; read the file
for details)
201@skipline from lsst.pipe.tasks.astrometry
202@skipline measPhotocal
204We need to create both our tasks before processing any data
as the task constructors
205can add extra columns to the schema which we get
from the input catalogue,
@c scrCat:
209@skip AstrometryTask.ConfigClass
211(that
@c filterMap line
is because our test code doesn
't use a filter that the reference catalogue recognises,
212so we tell it to use the @c r band)
218If the schema has indeed changed we need to add the new columns to the source table
219(yes; this should be easier!)
223We
're now ready to process the data (we could loop over multiple exposures/catalogues using the same
228We can then unpack
and use the results:
233To investigate the
@ref pipe_tasks_photocal_Debug, put something like
238 if name.endswith(
".PhotoCal"):
245into your debug.py file
and run photoCalTask.py
with the
@c --debug flag.
247 ConfigClass = PhotoCalConfig
248 _DefaultName = "photoCal"
250 def __init__(self, refObjLoader, schema=None, **kwds):
251 """!Create the photometric calibration task. See PhotoCalTask.init for documentation
253 pipeBase.Task.__init__(self, **kwds)
256 if schema
is not None:
257 self.
usedKeyusedKey = schema.addField(
"calib_photometry_used", type=
"Flag",
258 doc=
"set if source was used in photometric calibration")
262 name=
"match", parentTask=self)
263 self.makeSubtask(
"reserve", columnName=
"calib_photometry", schema=schema,
264 doc=
"set if source was reserved from photometric calibration")
267 """Return a struct containing the source catalog keys for fields used
273 schema : `lsst.afw.table.schema`
274 Schema of the catalog to get keys from.
278 result : `lsst.pipe.base.Struct`
279 Result struct
with components:
281 - ``instFlux``: Instrument flux key.
282 - ``instFluxErr``: Instrument flux error key.
284 instFlux = schema.find(self.config.fluxField).key
285 instFluxErr = schema.find(self.config.fluxField + "Err").key
286 return pipeBase.Struct(instFlux=instFlux, instFluxErr=instFluxErr)
290 """!Extract magnitude and magnitude error arrays from the given matches.
292 @param[
in] matches Reference/source matches, a
@link lsst::afw::table::ReferenceMatchVector
@endlink
293 @param[
in] filterLabel Label of filter being calibrated
294 @param[
in] sourceKeys Struct of source catalog keys,
as returned by
getSourceKeys()
296 @return Struct containing srcMag, refMag, srcMagErr, refMagErr,
and magErr numpy arrays
297 where magErr
is an error
in the magnitude; the error
in srcMag - refMag
298 If nonzero, config.magErrFloor will be added to magErr *only* (
not srcMagErr
or refMagErr),
as
299 magErr
is what
is later used to determine the zero point.
300 Struct also contains refFluxFieldList: a list of field names of the reference catalog used
for fluxes
302 @note These magnitude arrays are the
@em inputs to the photometric calibration, some may have been
303 discarded by clipping
while estimating the calibration (https://jira.lsstcorp.org/browse/DM-813)
305 srcInstFluxArr = np.array([m.second.get(sourceKeys.instFlux) for m
in matches])
306 srcInstFluxErrArr = np.array([m.second.get(sourceKeys.instFluxErr)
for m
in matches])
307 if not np.all(np.isfinite(srcInstFluxErrArr)):
309 self.log.
warning(
"Source catalog does not have flux uncertainties; using sqrt(flux).")
310 srcInstFluxErrArr = np.sqrt(srcInstFluxArr)
313 referenceFlux = (0*u.ABmag).to_value(u.nJy)
314 srcInstFluxArr = srcInstFluxArr * referenceFlux
315 srcInstFluxErrArr = srcInstFluxErrArr * referenceFlux
318 raise RuntimeError(
"No reference stars are available")
319 refSchema = matches[0].first.schema
321 applyColorTerms = self.config.applyColorTerms
322 applyCTReason =
"config.applyColorTerms is %s" % (self.config.applyColorTerms,)
323 if self.config.applyColorTerms
is None:
325 ctDataAvail = len(self.config.colorterms.data) > 0
326 photoCatSpecified = self.config.photoCatName
is not None
327 applyCTReason +=
" and data %s available" % (
"is" if ctDataAvail
else "is not")
328 applyCTReason +=
" and photoRefCat %s provided" % (
"is" if photoCatSpecified
else "is not")
329 applyColorTerms = ctDataAvail
and photoCatSpecified
332 self.log.
info(
"Applying color terms for filter=%r, config.photoCatName=%s because %s",
333 filterLabel.physicalLabel, self.config.photoCatName, applyCTReason)
334 colorterm = self.config.colorterms.getColorterm(filterLabel.physicalLabel,
335 self.config.photoCatName,
340 refCat.reserve(len(matches))
342 record = refCat.addNew()
343 record.assign(x.first)
345 refMagArr, refMagErrArr = colorterm.getCorrectedMagnitudes(refCat)
346 fluxFieldList = [
getRefFluxField(refSchema, filt)
for filt
in (colorterm.primary,
347 colorterm.secondary)]
350 self.log.
info(
"Not applying color terms because %s", applyCTReason)
355 fluxKey = refSchema.find(fluxField).key
356 refFluxArr = np.array([m.first.get(fluxKey)
for m
in matches])
359 fluxErrKey = refSchema.find(fluxField +
"Err").key
360 refFluxErrArr = np.array([m.first.get(fluxErrKey)
for m
in matches])
363 self.log.
warning(
"Reference catalog does not have flux uncertainties for %s;"
364 " using sqrt(flux).", fluxField)
365 refFluxErrArr = np.sqrt(refFluxArr)
367 refMagArr = u.Quantity(refFluxArr, u.nJy).to_value(u.ABmag)
372 srcMagArr = u.Quantity(srcInstFluxArr, u.nJy).to_value(u.ABmag)
377 if self.config.magErrFloor != 0.0:
378 magErrArr = (magErrArr**2 + self.config.magErrFloor**2)**0.5
382 good = np.isfinite(srcMagArr) & np.isfinite(refMagArr)
384 return pipeBase.Struct(
385 srcMag=srcMagArr[good],
386 refMag=refMagArr[good],
387 magErr=magErrArr[good],
388 srcMagErr=srcMagErrArr[good],
389 refMagErr=refMagErrArr[good],
390 refFluxFieldList=fluxFieldList,
394 def run(self, exposure, sourceCat, expId=0):
395 """!Do photometric calibration - select matches to use and (possibly iteratively) compute
398 @param[
in] exposure Exposure upon which the sources
in the matches were detected.
399 @param[
in] sourceCat A catalog of sources to use
in the calibration
402 the reference object
and matched object respectively).
403 (will
not be modified
except to set the outputField
if requested.).
404 @param[
in] expId Exposure identifier; used
for seeding the random number generator.
407 - photoCalib --
@link lsst::afw::image::PhotoCalib
@endlink object containing the calibration
408 - arrays ------ Magnitude arrays returned be PhotoCalTask.extractMagArrays
409 - matches ----- Final ReferenceMatchVector,
as returned by PhotoCalTask.selectMatches.
410 - zp ---------- Photometric zero point (mag)
411 - sigma ------- Standard deviation of fit of photometric zero point (mag)
412 - ngood ------- Number of sources used to fit photometric zero point
414 The exposure
is only used to provide the name of the filter being calibrated (it may also be
415 used to generate debugging plots).
417 The reference objects:
418 - Must include a field
@c photometric;
True for objects which should be considered
as
419 photometric standards
420 - Must include a field
@c flux; the flux used to impose a magnitude limit
and also to calibrate
421 the data to (unless a color term
is specified,
in which case ColorTerm.primary
is used;
422 See https://jira.lsstcorp.org/browse/DM-933)
423 - May include a field
@c stargal;
if present,
True means that the object
is a star
424 - May include a field
@c var;
if present,
True means that the object
is variable
426 The measured sources:
427 - Must include PhotoCalConfig.fluxField; the flux measurement to be used
for calibration
429 @throws RuntimeError
with the following strings:
432 <DT> No matches to use
for photocal
433 <DD> No matches are available (perhaps no sources/references were selected by the matcher).
434 <DT> No reference stars are available
435 <DD> No matches are available
from which to extract magnitudes.
441 displaySources = display
and lsstDebug.Info(__name__).displaySources
445 from matplotlib
import pyplot
449 self.
figfig = pyplot.figure()
451 filterLabel = exposure.getFilter()
454 matchResults = self.
matchmatch.
run(sourceCat, filterLabel.bandLabel)
455 matches = matchResults.matches
457 reserveResults = self.reserve.
run([mm.second
for mm
in matches], expId=expId)
459 self.
displaySourcesdisplaySources(exposure, matches, reserveResults.reserved)
460 if reserveResults.reserved.sum() > 0:
461 matches = [mm
for mm, use
in zip(matches, reserveResults.use)
if use]
462 if len(matches) == 0:
463 raise RuntimeError(
"No matches to use for photocal")
464 if self.
usedKeyusedKey
is not None:
466 mm.second.set(self.
usedKeyusedKey,
True)
469 sourceKeys = self.
getSourceKeysgetSourceKeys(matches[0].second.schema)
470 arrays = self.
extractMagArraysextractMagArrays(matches, filterLabel, sourceKeys)
473 r = self.
getZeroPointgetZeroPoint(arrays.srcMag, arrays.refMag, arrays.magErr)
474 self.log.
info(
"Magnitude zero point: %f +/- %f from %d stars", r.zp, r.sigma, r.ngood)
477 flux0 = 10**(0.4*r.zp)
478 flux0err = 0.4*math.log(10)*flux0*r.sigma
481 return pipeBase.Struct(
482 photoCalib=photoCalib,
491 """Display sources we'll use for photocal
493 Sources that will be actually used will be green.
494 Sources reserved from the fit will be red.
498 exposure : `lsst.afw.image.ExposureF`
500 matches : `list` of `lsst.afw.table.RefMatch`
501 Matches used
for photocal.
502 reserved : `numpy.ndarray` of type `bool`
503 Boolean array indicating sources that are reserved.
505 Frame number
for display.
507 disp = afwDisplay.getDisplay(frame=frame)
508 disp.mtv(exposure, title="photocal")
509 with disp.Buffering():
510 for mm, rr
in zip(matches, reserved):
511 x, y = mm.second.getCentroid()
512 ctype = afwDisplay.RED
if rr
else afwDisplay.GREEN
513 disp.dot(
"o", x, y, size=4, ctype=ctype)
516 """!Flux calibration code, returning (ZeroPoint, Distribution Width, Number of stars)
518 We perform nIter iterations of a simple sigma-clipping algorithm with a couple of twists:
519 1. We use the median/interquartile range to estimate the position to clip around,
and the
521 2. We never allow sigma to go _above_ a critical value sigmaMax ---
if we do, a sufficiently
522 large estimate will prevent the clipping
from ever taking effect.
523 3. Rather than start
with the median we start
with a crude mode. This means that a set of magnitude
524 residuals
with a tight core
and asymmetrical outliers will start
in the core. We use the width of
525 this core to set our maximum sigma (see 2.)
528 - zp ---------- Photometric zero point (mag)
529 - sigma ------- Standard deviation of fit of zero point (mag)
530 - ngood ------- Number of sources used to fit zero point
532 sigmaMax = self.config.sigmaMax
536 indArr = np.argsort(dmag)
539 if srcErr
is not None:
540 dmagErr = srcErr[indArr]
542 dmagErr = np.ones(len(dmag))
545 ind_noNan = np.array([i
for i
in range(len(dmag))
546 if (
not np.isnan(dmag[i])
and not np.isnan(dmagErr[i]))])
547 dmag = dmag[ind_noNan]
548 dmagErr = dmagErr[ind_noNan]
550 IQ_TO_STDEV = 0.741301109252802
555 for i
in range(self.config.nIter):
566 hist, edges = np.histogram(dmag, nhist, new=
True)
568 hist, edges = np.histogram(dmag, nhist)
569 imode = np.arange(nhist)[np.where(hist == hist.max())]
571 if imode[-1] - imode[0] + 1 == len(imode):
575 center = 0.5*(edges[imode[0]] + edges[imode[-1] + 1])
577 peak = sum(hist[imode])/len(imode)
581 while j >= 0
and hist[j] > 0.5*peak:
584 q1 = dmag[sum(hist[range(j)])]
587 while j < nhist
and hist[j] > 0.5*peak:
589 j =
min(j, nhist - 1)
590 j =
min(sum(hist[range(j)]), npt - 1)
594 q1 = dmag[
int(0.25*npt)]
595 q3 = dmag[
int(0.75*npt)]
602 self.log.
debug(
"Photo calibration histogram: center = %.2f, sig = %.2f", center, sig)
606 sigmaMax = dmag[-1] - dmag[0]
608 center = np.median(dmag)
609 q1 = dmag[
int(0.25*npt)]
610 q3 = dmag[
int(0.75*npt)]
615 if self.config.useMedian:
616 center = np.median(gdmag)
618 gdmagErr = dmagErr[good]
619 center = np.average(gdmag, weights=gdmagErr)
621 q3 = gdmag[
min(
int(0.75*npt + 0.5), npt - 1)]
622 q1 = gdmag[
min(
int(0.25*npt + 0.5), npt - 1)]
624 sig = IQ_TO_STDEV*(q3 - q1)
626 good =
abs(dmag - center) < self.config.nSigma*
min(sig, sigmaMax)
633 axes = self.
figfig.add_axes((0.1, 0.1, 0.85, 0.80))
635 axes.plot(ref[good], dmag[good] - center,
"b+")
636 axes.errorbar(ref[good], dmag[good] - center, yerr=dmagErr[good],
637 linestyle=
'', color=
'b')
639 bad = np.logical_not(good)
640 if len(ref[bad]) > 0:
641 axes.plot(ref[bad], dmag[bad] - center,
"r+")
642 axes.errorbar(ref[bad], dmag[bad] - center, yerr=dmagErr[bad],
643 linestyle=
'', color=
'r')
645 axes.plot((-100, 100), (0, 0),
"g-")
647 axes.plot((-100, 100), x*0.05*np.ones(2),
"g--")
649 axes.set_ylim(-1.1, 1.1)
650 axes.set_xlim(24, 13)
651 axes.set_xlabel(
"Reference")
652 axes.set_ylabel(
"Reference - Instrumental")
658 while i == 0
or reply !=
"c":
660 reply = input(
"Next iteration? [ynhpc] ")
665 print(
"Options: c[ontinue] h[elp] n[o] p[db] y[es]", file=sys.stderr)
668 if reply
in (
"",
"c",
"n",
"p",
"y"):
671 print(
"Unrecognised response: %s" % reply, file=sys.stderr)
678 except Exception
as e:
679 print(
"Error plotting in PhotoCal.getZeroPoint: %s" % e, file=sys.stderr)
686 msg =
"PhotoCal.getZeroPoint: no good stars remain"
689 center = np.average(dmag, weights=dmagErr)
690 msg +=
" on first iteration; using average of all calibration stars"
694 return pipeBase.Struct(
698 elif ngood == old_ngood:
704 dmagErr = dmagErr[good]
707 dmagErr = dmagErr[good]
708 zp, weightSum = np.average(dmag, weights=1/dmagErr**2, returned=
True)
709 sigma = np.sqrt(1.0/weightSum)
710 return pipeBase.Struct(
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)
Flux calibration code, returning (ZeroPoint, Distribution Width, Number of stars)
def extractMagArrays(self, matches, filterLabel, sourceKeys)
Extract magnitude and magnitude error arrays from the given matches.
def __init__(self, refObjLoader, schema=None, **kwds)
Create the photometric calibration task.
def displaySources(self, exposure, matches, reserved, frame=1)
def run(self, exposure, sourceCat, expId=0)
Do photometric calibration - select matches to use and (possibly iteratively) compute the zero point.
Backwards-compatibility support for depersisting the old Calib (FluxMag0/FluxMag0Err) objects.
double abMagErrFromFluxErr(double fluxErr, double flux)
Compute AB magnitude error from flux and flux error in Janskys.
std::shared_ptr< PhotoCalib > makePhotoCalibFromCalibZeroPoint(double instFluxMag0, double instFluxMag0Err)
Construct a PhotoCalib from the deprecated Calib-style instFluxMag0/instFluxMag0Err values.
def getRefFluxField(schema, filterName)
Fit spatial kernel using approximate fluxes for candidates, and solving a linear system of equations.
Angle abs(Angle const &a)
Lightweight representation of a geometric match between two records.