LSSTApplications  11.0-13-gbb96280,12.1.rc1,12.1.rc1+1,12.1.rc1+2,12.1.rc1+5,12.1.rc1+8,12.1.rc1-1-g06d7636+1,12.1.rc1-1-g253890b+5,12.1.rc1-1-g3d31b68+7,12.1.rc1-1-g3db6b75+1,12.1.rc1-1-g5c1385a+3,12.1.rc1-1-g83b2247,12.1.rc1-1-g90cb4cf+6,12.1.rc1-1-g91da24b+3,12.1.rc1-2-g3521f8a,12.1.rc1-2-g39433dd+4,12.1.rc1-2-g486411b+2,12.1.rc1-2-g4c2be76,12.1.rc1-2-gc9c0491,12.1.rc1-2-gda2cd4f+6,12.1.rc1-3-g3391c73+2,12.1.rc1-3-g8c1bd6c+1,12.1.rc1-3-gcf4b6cb+2,12.1.rc1-4-g057223e+1,12.1.rc1-4-g19ed13b+2,12.1.rc1-4-g30492a7
LSSTDataManagementBasePackage
photoCal.py
Go to the documentation of this file.
1 from __future__ import absolute_import, division
2 #
3 # LSST Data Management System
4 # Copyright 2008-2015 AURA/LSST.
5 #
6 # This product includes software developed by the
7 # LSST Project (http://www.lsst.org/).
8 #
9 # This program is free software: you can redistribute it and/or modify
10 # it under the terms of the GNU General Public License as published by
11 # the Free Software Foundation, either version 3 of the License, or
12 # (at your option) any later version.
13 #
14 # This program is distributed in the hope that it will be useful,
15 # but WITHOUT ANY WARRANTY; without even the implied warranty of
16 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 # GNU General Public License for more details.
18 #
19 # You should have received a copy of the LSST License Statement and
20 # the GNU General Public License along with this program. If not,
21 # see <https://www.lsstcorp.org/LegalNotices/>.
22 #
23 # \package lsst.pipe.tasks.
24 from itertools import izip
25 import math
26 import sys
27 
28 import numpy as np
29 
30 import lsst.pex.config as pexConf
31 import lsst.pipe.base as pipeBase
32 import lsst.afw.table as afwTable
33 from lsst.afw.image import abMagFromFlux, abMagErrFromFluxErr, fluxFromABMag, Calib
34 import lsst.afw.display.ds9 as ds9
35 from lsst.meas.algorithms import getRefFluxField
36 from .colorterms import ColortermLibrary
37 
38 __all__ = ["PhotoCalTask", "PhotoCalConfig"]
39 
40 
41 def checkSourceFlags(source, sourceKeys):
42  """!Return True if the given source has all good flags set and none of the bad flags set.
43 
44  \param[in] source SourceRecord object to process.
45  \param[in] sourceKeys Struct of source catalog keys, as returned by PhotCalTask.getSourceKeys()
46  """
47  for k in sourceKeys.goodFlags:
48  if not source.get(k):
49  return False
50  if source.getPsfFluxFlag():
51  return False
52  for k in sourceKeys.badFlags:
53  if source.get(k):
54  return False
55  return True
56 
57 
58 class PhotoCalConfig(pexConf.Config):
59  """Config for PhotoCal"""
60  magLimit = pexConf.Field(
61  dtype = float,
62  default = 22.0,
63  doc = "Don't use objects fainter than this magnitude",
64  )
65  doWriteOutput = pexConf.Field(
66  dtype = bool,
67  default = True,
68  doc = "Write a field name astrom_usedByPhotoCal to the schema",
69  )
70  fluxField = pexConf.Field(
71  dtype = str,
72  default = "slot_CalibFlux_flux",
73  doc = ("Name of the source flux field to use. The associated flag field\n"
74  "('<name>_flags') will be implicitly included in badFlags."),
75  )
76  applyColorTerms = pexConf.Field(
77  dtype = bool,
78  default = None,
79  doc = ("Apply photometric color terms to reference stars? One of:\n"
80  "None: apply if colorterms and photoCatName are not None;\n"
81  " fail if color term data is not available for the specified ref catalog and filter.\n"
82  "True: always apply colorterms; fail if color term data is not available for the\n"
83  " specified reference catalog and filter.\n"
84  "False: do not apply."),
85  optional = True,
86  )
87  goodFlags = pexConf.ListField(
88  dtype = str,
89  default = [],
90  doc = "List of source flag fields that must be set for a source to be used.",
91  )
92  badFlags = pexConf.ListField(
93  dtype = str,
94  default = ["base_PixelFlags_flag_edge", "base_PixelFlags_flag_interpolated",
95  "base_PixelFlags_flag_saturated"],
96  doc = "List of source flag fields that will cause a source to be rejected when they are set.",
97  )
98  sigmaMax = pexConf.Field(
99  dtype = float,
100  default = 0.25,
101  doc = "maximum sigma to use when clipping",
102  optional = True,
103  )
104  nSigma = pexConf.Field(
105  dtype = float,
106  default = 3.0,
107  doc = "clip at nSigma",
108  )
109  useMedian = pexConf.Field(
110  dtype = bool,
111  default = True,
112  doc = "use median instead of mean to compute zeropoint",
113  )
114  nIter = pexConf.Field(
115  dtype = int,
116  default = 20,
117  doc = "number of iterations",
118  )
119  colorterms = pexConf.ConfigField(
120  dtype = ColortermLibrary,
121  doc = "Library of photometric reference catalog name: color term dict",
122  )
123  photoCatName = pexConf.Field(
124  dtype = str,
125  optional = True,
126  doc = ("Name of photometric reference catalog; used to select a color term dict in colorterms."
127  " see also applyColorTerms"),
128  )
129  magErrFloor = pexConf.RangeField(
130  dtype = float,
131  default = 0.0,
132  doc = "Additional magnitude uncertainty to be added in quadrature with measurement errors.",
133  min = 0.0,
134  )
135  doSelectUnresolved = pexConf.Field(
136  dtype = bool,
137  default = True,
138  doc = ("Use the extendedness parameter to select objects to use in photometric calibration?\n"
139  "This applies only to the sources detected on the exposure, not the reference catalog"),
140  )
141 
142  def validate(self):
143  pexConf.Config.validate(self)
144  if self.applyColorTerms and self.photoCatName is None:
145  raise RuntimeError("applyColorTerms=True requires photoCatName is non-None")
146  if self.applyColorTerms and len(self.colorterms.data) == 0:
147  raise RuntimeError("applyColorTerms=True requires colorterms be provided")
148 
149 
150 ## \addtogroup LSST_task_documentation
151 ## \{
152 ## \page photoCalTask
153 ## \ref PhotoCalTask_ "PhotoCalTask"
154 ## Detect positive and negative sources on an exposure and return a new SourceCatalog.
155 ## \}
156 
157 class PhotoCalTask(pipeBase.Task):
158  """!
159 \anchor PhotoCalTask_
160 
161 \brief Calculate the zero point of an exposure given a lsst.afw.table.ReferenceMatchVector.
162 
163 \section pipe_tasks_photocal_Contents Contents
164 
165  - \ref pipe_tasks_photocal_Purpose
166  - \ref pipe_tasks_photocal_Initialize
167  - \ref pipe_tasks_photocal_IO
168  - \ref pipe_tasks_photocal_Config
169  - \ref pipe_tasks_photocal_Debug
170  - \ref pipe_tasks_photocal_Example
171 
172 \section pipe_tasks_photocal_Purpose Description
173 
174 \copybrief PhotoCalTask
175 
176 Calculate an Exposure's zero-point given a set of flux measurements of stars matched to an input catalogue.
177 The type of flux to use is specified by PhotoCalConfig.fluxField.
178 
179 The algorithm clips outliers iteratively, with parameters set in the configuration.
180 
181 \note This task can adds fields to the schema, so any code calling this task must ensure that
182 these columns are indeed present in the input match list; see \ref pipe_tasks_photocal_Example
183 
184 \section pipe_tasks_photocal_Initialize Task initialisation
185 
186 \copydoc \_\_init\_\_
187 
188 \section pipe_tasks_photocal_IO Inputs/Outputs to the run method
189 
190 \copydoc run
191 
192 \section pipe_tasks_photocal_Config Configuration parameters
193 
194 See \ref PhotoCalConfig
195 
196 \section pipe_tasks_photocal_Debug Debug variables
197 
198 The \link lsst.pipe.base.cmdLineTask.CmdLineTask command line task\endlink interface supports a
199 flag \c -d to import \b debug.py from your \c PYTHONPATH; see \ref baseDebug for more about \b debug.py files.
200 
201 The available variables in PhotoCalTask are:
202 <DL>
203  <DT> \c display
204  <DD> If True enable other debug outputs
205  <DT> \c displaySources
206  <DD> If True, display the exposure on ds9's frame 1 and overlay the source catalogue:
207  <DL>
208  <DT> red x
209  <DD> Bad objects
210  <DT> blue +
211  <DD> Matched objects deemed unsuitable for photometric calibration.
212  Additional information is:
213  - a cyan o for galaxies
214  - a magenta o for variables
215  <DT> magenta *
216  <DD> Objects that failed the flux cut
217  <DT> green o
218  <DD> Objects used in the photometric calibration
219  </DL>
220  <DT> \c scatterPlot
221  <DD> Make a scatter plot of flux v. reference magnitude as a function of reference magnitude.
222  - good objects in blue
223  - rejected objects in red
224  (if \c scatterPlot is 2 or more, prompt to continue after each iteration)
225 </DL>
226 
227 \section pipe_tasks_photocal_Example A complete example of using PhotoCalTask
228 
229 This code is in \link examples/photoCalTask.py\endlink, and can be run as \em e.g.
230 \code
231 examples/photoCalTask.py
232 \endcode
233 \dontinclude photoCalTask.py
234 
235 Import the tasks (there are some other standard imports; read the file for details)
236 \skipline from lsst.pipe.tasks.astrometry
237 \skipline measPhotocal
238 
239 We need to create both our tasks before processing any data as the task constructors
240 can add extra columns to the schema which we get from the input catalogue, \c scrCat:
241 \skipline getSchema
242 
243 Astrometry first:
244 \skip AstrometryTask.ConfigClass
245 \until aTask
246 (that \c filterMap line is because our test code doesn't use a filter that the reference catalogue recognises,
247 so we tell it to use the \c r band)
248 
249 Then photometry:
250 \skip measPhotocal
251 \until pTask
252 
253 If the schema has indeed changed we need to add the new columns to the source table
254 (yes; this should be easier!)
255 \skip srcCat
256 \until srcCat = cat
257 
258 We're now ready to process the data (we could loop over multiple exposures/catalogues using the same
259 task objects):
260 \skip matches
261 \until result
262 
263 We can then unpack and use the results:
264 \skip calib
265 \until np.log
266 
267 <HR>
268 To investigate the \ref pipe_tasks_photocal_Debug, put something like
269 \code{.py}
270  import lsstDebug
271  def DebugInfo(name):
272  di = lsstDebug.getInfo(name) # N.b. lsstDebug.Info(name) would call us recursively
273  if name.endswith(".PhotoCal"):
274  di.display = 1
275 
276  return di
277 
278  lsstDebug.Info = DebugInfo
279 \endcode
280 into your debug.py file and run photoCalTask.py with the \c --debug flag.
281  """
282  ConfigClass = PhotoCalConfig
283  _DefaultName = "photoCal"
284 
285  def __init__(self, schema, **kwds):
286  """!Create the photometric calibration task. See PhotoCalTask.init for documentation
287  """
288  pipeBase.Task.__init__(self, **kwds)
289  self.scatterPlot = None
290  self.fig = None
291  if self.config.doWriteOutput:
292  self.outputField = schema.addField("photocal_photometricStandard", type="Flag",
293  doc="set if source was used in photometric calibration")
294  else:
295  self.outputField = None
296 
297  def getSourceKeys(self, schema):
298  """!Return a struct containing the source catalog keys for fields used by PhotoCalTask.
299 
300  Returned fields include:
301  - flux
302  - fluxErr
303  - goodFlags: a list of keys for field names in self.config.goodFlags
304  - badFlags: a list of keys for field names in self.config.badFlags
305  - starGal: key for star/galaxy classification
306  """
307  goodFlags = [schema.find(name).key for name in self.config.goodFlags]
308  flux = schema.find(self.config.fluxField).key
309  fluxErr = schema.find(self.config.fluxField + "Sigma").key
310  badFlags = [schema.find(name).key for name in self.config.badFlags]
311  try:
312  starGal = schema.find("base_ClassificationExtendedness_value").key
313  except KeyError:
314  starGal = None
315  return pipeBase.Struct(flux=flux, fluxErr=fluxErr, goodFlags=goodFlags, badFlags=badFlags,
316  starGal=starGal)
317 
318  def isUnresolved(self, source, starGalKey=None):
319  """!Return whether the provided source is unresolved or not
320 
321  This particular implementation is designed to work with the
322  base_ClassificationExtendedness_value=0.0 or 1.0 scheme. Because
323  of the diversity of star/galaxy classification outputs (binary
324  decision vs probabilities; signs), it's difficult to make this
325  configurable without using code. This method should therefore
326  be overridden to use the appropriate classification output.
327 
328  \param[in] source Source to test
329  \param[in] starGalKey Struct of schema keys for source
330  \return boolean value for starGalKey (True indicates Unresolved)
331  """
332  return source.get(starGalKey) < 0.5 if starGalKey is not None else True
333 
334  @pipeBase.timeMethod
335  def selectMatches(self, matches, sourceKeys, filterName, frame=None):
336  """!Select reference/source matches according the criteria specified in the config.
337 
338  \param[in] matches ReferenceMatchVector (not modified)
339  \param[in] sourceKeys Struct of source catalog keys, as returned by getSourceKeys()
340  \param[in] filterName name of camera filter; used to obtain the reference flux field
341  \param[in] frame ds9 frame number to use for debugging display
342  if frame is non-None, display information about trimmed objects on that ds9 frame:
343  - Bad: red x
344  - Unsuitable objects: blue + (and a cyan o if a galaxy)
345  - Failed flux cut: magenta *
346 
347  \return a \link lsst.afw.table.ReferenceMatchVector\endlink that contains only the selected matches.
348  If a schema was passed during task construction, a flag field will be set on sources
349  in the selected matches.
350 
351  \throws ValueError There are no valid matches.
352  """
353  self.log.debug("Number of input matches: %d", len(matches))
354 
355  if self.config.doSelectUnresolved:
356  # Select only resolved sources if asked to do so.
357  matches = [m for m in matches if self.isUnresolved(m.second, sourceKeys.starGal)]
358  self.log.debug("Number of matches after culling resolved sources: %d", len(matches))
359 
360  if len(matches) == 0:
361  raise ValueError("No input matches")
362 
363  # Only use stars for which the flags indicate the photometry is good.
364  afterFlagCutInd = [i for i, m in enumerate(matches) if checkSourceFlags(m.second, sourceKeys)]
365  afterFlagCut = [matches[i] for i in afterFlagCutInd]
366  self.log.debug("Number of matches after source flag cuts: %d", len(afterFlagCut))
367 
368  if len(afterFlagCut) != len(matches):
369  if frame is not None:
370  with ds9.Buffering():
371  for i, m in enumerate(matches):
372  if i not in afterFlagCutInd:
373  x, y = m.second.getCentroid()
374  ds9.dot("x", x, y, size=4, frame=frame, ctype=ds9.RED)
375 
376  matches = afterFlagCut
377 
378  if len(matches) == 0:
379  raise ValueError("All matches eliminated by source flags")
380 
381  refSchema = matches[0].first.schema
382  try:
383  photometricKey = refSchema.find("photometric").key
384  try:
385  resolvedKey = refSchema.find("resolved").key
386  except:
387  resolvedKey = None
388 
389  try:
390  variableKey = refSchema.find("variable").key
391  except:
392  variableKey = None
393  except:
394  self.log.warn("No 'photometric' flag key found in reference schema.")
395  photometricKey = None
396 
397  if photometricKey is not None:
398  afterRefCutInd = [i for i, m in enumerate(matches) if m.first.get(photometricKey)]
399  afterRefCut = [matches[i] for i in afterRefCutInd]
400 
401  if len(afterRefCut) != len(matches):
402  if frame is not None:
403  with ds9.Buffering():
404  for i, m in enumerate(matches):
405  if i not in afterRefCutInd:
406  x, y = m.second.getCentroid()
407  ds9.dot("+", x, y, size=4, frame=frame, ctype=ds9.BLUE)
408 
409  if resolvedKey and m.first.get(resolvedKey):
410  ds9.dot("o", x, y, size=6, frame=frame, ctype=ds9.CYAN)
411  if variableKey and m.first.get(variableKey):
412  ds9.dot("o", x, y, size=6, frame=frame, ctype=ds9.MAGENTA)
413 
414  matches = afterRefCut
415 
416  self.log.debug("Number of matches after reference catalog cuts: %d", len(matches))
417  if len(matches) == 0:
418  raise RuntimeError("No sources remain in match list after reference catalog cuts.")
419  fluxName = getRefFluxField(refSchema, filterName)
420  fluxKey = refSchema.find(fluxName).key
421  if self.config.magLimit is not None:
422  fluxLimit = fluxFromABMag(self.config.magLimit)
423 
424  afterMagCutInd = [i for i, m in enumerate(matches) if (m.first.get(fluxKey) > fluxLimit and
425  m.second.getPsfFlux() > 0.0)]
426  else:
427  afterMagCutInd = [i for i, m in enumerate(matches) if m.second.getPsfFlux() > 0.0]
428 
429  afterMagCut = [matches[i] for i in afterMagCutInd]
430 
431  if len(afterMagCut) != len(matches):
432  if frame is not None:
433  with ds9.Buffering():
434  for i, m in enumerate(matches):
435  if i not in afterMagCutInd:
436  x, y = m.second.getCentroid()
437  ds9.dot("*", x, y, size=4, frame=frame, ctype=ds9.MAGENTA)
438 
439  matches = afterMagCut
440 
441  self.log.debug("Number of matches after magnitude limit cuts: %d", len(matches))
442 
443  if len(matches) == 0:
444  raise RuntimeError("No sources remaining in match list after magnitude limit cuts.")
445 
446  if frame is not None:
447  with ds9.Buffering():
448  for m in matches:
449  x, y = m.second.getCentroid()
450  ds9.dot("o", x, y, size=4, frame=frame, ctype=ds9.GREEN)
451 
453  for m in matches:
454  if self.outputField is not None:
455  m.second.set(self.outputField, True)
456  result.append(m)
457  return result
458 
459  @pipeBase.timeMethod
460  def extractMagArrays(self, matches, filterName, sourceKeys):
461  """!Extract magnitude and magnitude error arrays from the given matches.
462 
463  \param[in] matches Reference/source matches, a \link lsst::afw::table::ReferenceMatchVector\endlink
464  \param[in] filterName Name of filter being calibrated
465  \param[in] sourceKeys Struct of source catalog keys, as returned by getSourceKeys()
466 
467  \return Struct containing srcMag, refMag, srcMagErr, refMagErr, and magErr numpy arrays
468  where magErr is an error in the magnitude; the error in srcMag - refMag
469  If nonzero, config.magErrFloor will be added to magErr *only* (not srcMagErr or refMagErr), as
470  magErr is what is later used to determine the zero point.
471  Struct also contains refFluxFieldList: a list of field names of the reference catalog used for fluxes
472  (1 or 2 strings)
473  \note These magnitude arrays are the \em inputs to the photometric calibration, some may have been
474  discarded by clipping while estimating the calibration (https://jira.lsstcorp.org/browse/DM-813)
475  """
476  srcFluxArr = np.array([m.second.get(sourceKeys.flux) for m in matches])
477  srcFluxErrArr = np.array([m.second.get(sourceKeys.fluxErr) for m in matches])
478  if not np.all(np.isfinite(srcFluxErrArr)):
479  # this is an unpleasant hack; see DM-2308 requesting a better solution
480  self.log.warn("Source catalog does not have flux uncertainties; using sqrt(flux).")
481  srcFluxErrArr = np.sqrt(srcFluxArr)
482 
483  # convert source flux from DN to an estimate of Jy
484  JanskysPerABFlux = 3631.0
485  srcFluxArr = srcFluxArr * JanskysPerABFlux
486  srcFluxErrArr = srcFluxErrArr * JanskysPerABFlux
487 
488  if not matches:
489  raise RuntimeError("No reference stars are available")
490  refSchema = matches[0].first.schema
491 
492  applyColorTerms = self.config.applyColorTerms
493  applyCTReason = "config.applyColorTerms is %s" % (self.config.applyColorTerms,)
494  if self.config.applyColorTerms is None:
495  # apply color terms if color term data is available and photoCatName specified
496  ctDataAvail = len(self.config.colorterms.data) > 0
497  photoCatSpecified = self.config.photoCatName is not None
498  applyCTReason += " and data %s available" % ("is" if ctDataAvail else "is not")
499  applyCTReason += " and photoRefCat %s None" % ("is not" if photoCatSpecified else "is")
500  applyColorTerms = ctDataAvail and photoCatSpecified
501 
502  if applyColorTerms:
503  self.log.info("Applying color terms for filterName=%r, config.photoCatName=%s because %s",
504  filterName, self.config.photoCatName, applyCTReason)
505  ct = self.config.colorterms.getColorterm(
506  filterName=filterName, photoCatName=self.config.photoCatName, doRaise=True)
507  else:
508  self.log.info("Not applying color terms because %s", applyCTReason)
509  ct = None
510 
511  if ct: # we have a color term to worry about
512  fluxFieldList = [getRefFluxField(refSchema, filt) for filt in (ct.primary, ct.secondary)]
513  missingFluxFieldList = []
514  for fluxField in fluxFieldList:
515  try:
516  refSchema.find(fluxField).key
517  except KeyError:
518  missingFluxFieldList.append(fluxField)
519 
520  if missingFluxFieldList:
521  self.log.warn("Source catalog does not have fluxes for %s; ignoring color terms" %
522  " ".join(missingFluxFieldList))
523  ct = None
524 
525  if not ct:
526  fluxFieldList = [getRefFluxField(refSchema, filterName)]
527 
528  refFluxArrList = [] # list of ref arrays, one per flux field
529  refFluxErrArrList = [] # list of ref flux arrays, one per flux field
530  for fluxField in fluxFieldList:
531  fluxKey = refSchema.find(fluxField).key
532  refFluxArr = np.array([m.first.get(fluxKey) for m in matches])
533  try:
534  fluxErrKey = refSchema.find(fluxField + "Sigma").key
535  refFluxErrArr = np.array([m.first.get(fluxErrKey) for m in matches])
536  except KeyError:
537  # Reference catalogue may not have flux uncertainties; HACK
538  self.log.warn("Reference catalog does not have flux uncertainties for %s; using sqrt(flux).",
539  fluxField)
540  refFluxErrArr = np.sqrt(refFluxArr)
541 
542  refFluxArrList.append(refFluxArr)
543  refFluxErrArrList.append(refFluxErrArr)
544 
545  if ct: # we have a color term to worry about
546  refMagArr1 = np.array([abMagFromFlux(rf1) for rf1 in refFluxArrList[0]]) # primary
547  refMagArr2 = np.array([abMagFromFlux(rf2) for rf2 in refFluxArrList[1]]) # secondary
548 
549  refMagArr = ct.transformMags(refMagArr1, refMagArr2)
550  refFluxErrArr = ct.propagateFluxErrors(refFluxErrArrList[0], refFluxErrArrList[1])
551  else:
552  refMagArr = np.array([abMagFromFlux(rf) for rf in refFluxArrList[0]])
553 
554  srcMagArr = np.array([abMagFromFlux(sf) for sf in srcFluxArr])
555 
556  # Fitting with error bars in both axes is hard
557  # for now ignore reference flux error, but ticket DM-2308 is a request for a better solution
558  magErrArr = np.array([abMagErrFromFluxErr(fe, sf) for fe, sf in izip(srcFluxErrArr, srcFluxArr)])
559  if self.config.magErrFloor != 0.0:
560  magErrArr = (magErrArr**2 + self.config.magErrFloor**2)**0.5
561 
562  srcMagErrArr = np.array([abMagErrFromFluxErr(sfe, sf) for sfe, sf in izip(srcFluxErrArr, srcFluxArr)])
563  refMagErrArr = np.array([abMagErrFromFluxErr(rfe, rf) for rfe, rf in izip(refFluxErrArr, refFluxArr)])
564 
565  return pipeBase.Struct(
566  srcMag = srcMagArr,
567  refMag = refMagArr,
568  magErr = magErrArr,
569  srcMagErr = srcMagErrArr,
570  refMagErr = refMagErrArr,
571  refFluxFieldList = fluxFieldList,
572  )
573 
574  @pipeBase.timeMethod
575  def run(self, exposure, matches):
576  """!Do photometric calibration - select matches to use and (possibly iteratively) compute
577  the zero point.
578 
579  \param[in] exposure Exposure upon which the sources in the matches were detected.
580  \param[in] matches Input lsst.afw.table.ReferenceMatchVector
581  (\em i.e. a list of lsst.afw.table.Match with
582  \c first being of type lsst.afw.table.SimpleRecord and \c second type lsst.afw.table.SourceRecord ---
583  the reference object and matched object respectively).
584  (will not be modified except to set the outputField if requested.).
585 
586  \return Struct of:
587  - calib ------- \link lsst::afw::image::Calib\endlink object containing the zero point
588  - arrays ------ Magnitude arrays returned be PhotoCalTask.extractMagArrays
589  - matches ----- Final ReferenceMatchVector, as returned by PhotoCalTask.selectMatches.
590  - zp ---------- Photometric zero point (mag)
591  - sigma ------- Standard deviation of fit of photometric zero point (mag)
592  - ngood ------- Number of sources used to fit photometric zero point
593 
594  The exposure is only used to provide the name of the filter being calibrated (it may also be
595  used to generate debugging plots).
596 
597  The reference objects:
598  - Must include a field \c photometric; True for objects which should be considered as
599  photometric standards
600  - Must include a field \c flux; the flux used to impose a magnitude limit and also to calibrate
601  the data to (unless a color term is specified, in which case ColorTerm.primary is used;
602  See https://jira.lsstcorp.org/browse/DM-933)
603  - May include a field \c stargal; if present, True means that the object is a star
604  - May include a field \c var; if present, True means that the object is variable
605 
606  The measured sources:
607  - Must include PhotoCalConfig.fluxField; the flux measurement to be used for calibration
608 
609  \throws RuntimeError with the following strings:
610 
611  <DL>
612  <DT> `sources' schema does not contain the calibration object flag "XXX"`
613  <DD> The constructor added fields to the schema that aren't in the Sources
614  <DT> No input matches
615  <DD> The input match vector is empty
616  <DT> All matches eliminated by source flags
617  <DD> The flags specified by \c badFlags in the config eliminated all candidate objects
618  <DT> No sources remain in match list after reference catalog cuts
619  <DD> The reference catalogue has a column "photometric", but no matched objects have it set
620  <DT> No sources remaining in match list after magnitude limit cuts
621  <DD> All surviving matches are either too faint in the catalogue or have negative or \c NaN flux
622  <DT> No reference stars are available
623  <DD> No matches survive all the checks
624  </DL>
625  """
626  import lsstDebug
627 
628  display = lsstDebug.Info(__name__).display
629  displaySources = display and lsstDebug.Info(__name__).displaySources
630  self.scatterPlot = display and lsstDebug.Info(__name__).scatterPlot
631 
632  if self.scatterPlot:
633  from matplotlib import pyplot
634  try:
635  self.fig.clf()
636  except:
637  self.fig = pyplot.figure()
638 
639  if displaySources:
640  frame = 1
641  ds9.mtv(exposure, frame=frame, title="photocal")
642  else:
643  frame = None
644 
645  filterName = exposure.getFilter().getName()
646  sourceKeys = self.getSourceKeys(matches[0].second.schema)
647  matches = self.selectMatches(matches=matches, sourceKeys=sourceKeys, filterName=filterName,
648  frame=frame)
649  arrays = self.extractMagArrays(matches=matches, filterName=filterName, sourceKeys=sourceKeys)
650 
651  if matches and self.outputField:
652  try:
653  # matches[].second is a measured source, wherein we wish to set outputField.
654  # Check that the field is present in the Sources schema.
655  matches[0].second.getSchema().find(self.outputField)
656  except:
657  raise RuntimeError("sources' schema does not contain the used-in-calibration flag \"%s\"" %
658  self.outputField)
659 
660  # Fit for zeropoint. We can run the code more than once, so as to
661  # give good stars that got clipped by a bad first guess a second
662  # chance.
663 
664  calib = Calib()
665  zp = None # initial guess
666  r = self.getZeroPoint(arrays.srcMag, arrays.refMag, arrays.magErr, zp0=zp)
667  zp = r.zp
668  self.log.info("Magnitude zero point: %f +/- %f from %d stars", r.zp, r.sigma, r.ngood)
669 
670  flux0 = 10**(0.4*r.zp) # Flux of mag=0 star
671  flux0err = 0.4*math.log(10)*flux0*r.sigma # Error in flux0
672 
673  calib.setFluxMag0(flux0, flux0err)
674 
675  return pipeBase.Struct(
676  calib = calib,
677  arrays = arrays,
678  matches = matches,
679  zp = r.zp,
680  sigma = r.sigma,
681  ngood = r.ngood,
682  )
683 
684  def getZeroPoint(self, src, ref, srcErr=None, zp0=None):
685  """!Flux calibration code, returning (ZeroPoint, Distribution Width, Number of stars)
686 
687  We perform nIter iterations of a simple sigma-clipping algorithm with a couple of twists:
688  1. We use the median/interquartile range to estimate the position to clip around, and the
689  "sigma" to use.
690  2. We never allow sigma to go _above_ a critical value sigmaMax --- if we do, a sufficiently
691  large estimate will prevent the clipping from ever taking effect.
692  3. Rather than start with the median we start with a crude mode. This means that a set of magnitude
693  residuals with a tight core and asymmetrical outliers will start in the core. We use the width of
694  this core to set our maximum sigma (see 2.)
695 
696  \return Struct of:
697  - zp ---------- Photometric zero point (mag)
698  - sigma ------- Standard deviation of fit of zero point (mag)
699  - ngood ------- Number of sources used to fit zero point
700  """
701  sigmaMax = self.config.sigmaMax
702 
703  dmag = ref - src
704 
705  indArr = np.argsort(dmag)
706  dmag = dmag[indArr]
707 
708  if srcErr is not None:
709  dmagErr = srcErr[indArr]
710  else:
711  dmagErr = np.ones(len(dmag))
712 
713  # need to remove nan elements to avoid errors in stats calculation with numpy
714  ind_noNan = np.array([i for i in range(len(dmag))
715  if (not np.isnan(dmag[i]) and not np.isnan(dmagErr[i]))])
716  dmag = dmag[ind_noNan]
717  dmagErr = dmagErr[ind_noNan]
718 
719  IQ_TO_STDEV = 0.741301109252802 # 1 sigma in units of interquartile (assume Gaussian)
720 
721  npt = len(dmag)
722  ngood = npt
723  good = None # set at end of first iteration
724  for i in range(self.config.nIter):
725  if i > 0:
726  npt = sum(good)
727 
728  center = None
729  if i == 0:
730  #
731  # Start by finding the mode
732  #
733  nhist = 20
734  try:
735  hist, edges = np.histogram(dmag, nhist, new=True)
736  except TypeError:
737  hist, edges = np.histogram(dmag, nhist) # they removed new=True around numpy 1.5
738  imode = np.arange(nhist)[np.where(hist == hist.max())]
739 
740  if imode[-1] - imode[0] + 1 == len(imode): # Multiple modes, but all contiguous
741  if zp0:
742  center = zp0
743  else:
744  center = 0.5*(edges[imode[0]] + edges[imode[-1] + 1])
745 
746  peak = sum(hist[imode])/len(imode) # peak height
747 
748  # Estimate FWHM of mode
749  j = imode[0]
750  while j >= 0 and hist[j] > 0.5*peak:
751  j -= 1
752  j = max(j, 0)
753  q1 = dmag[sum(hist[range(j)])]
754 
755  j = imode[-1]
756  while j < nhist and hist[j] > 0.5*peak:
757  j += 1
758  j = min(j, nhist - 1)
759  j = min(sum(hist[range(j)]), npt - 1)
760  q3 = dmag[j]
761 
762  if q1 == q3:
763  q1 = dmag[int(0.25*npt)]
764  q3 = dmag[int(0.75*npt)]
765 
766  sig = (q3 - q1)/2.3 # estimate of standard deviation (based on FWHM; 2.358 for Gaussian)
767 
768  if sigmaMax is None:
769  sigmaMax = 2*sig # upper bound on st. dev. for clipping. multiplier is a heuristic
770 
771  self.log.debug("Photo calibration histogram: center = %.2f, sig = %.2f",
772  center, sig)
773 
774  else:
775  if sigmaMax is None:
776  sigmaMax = dmag[-1] - dmag[0]
777 
778  center = np.median(dmag)
779  q1 = dmag[int(0.25*npt)]
780  q3 = dmag[int(0.75*npt)]
781  sig = (q3 - q1)/2.3 # estimate of standard deviation (based on FWHM; 2.358 for Gaussian)
782 
783  if center is None: # usually equivalent to (i > 0)
784  gdmag = dmag[good]
785  if self.config.useMedian:
786  center = np.median(gdmag)
787  else:
788  gdmagErr = dmagErr[good]
789  center = np.average(gdmag, weights=gdmagErr)
790 
791  q3 = gdmag[min(int(0.75*npt + 0.5), npt - 1)]
792  q1 = gdmag[min(int(0.25*npt + 0.5), npt - 1)]
793 
794  sig = IQ_TO_STDEV*(q3 - q1) # estimate of standard deviation
795 
796  good = abs(dmag - center) < self.config.nSigma*min(sig, sigmaMax) # don't clip too softly
797 
798  # =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
799  if self.scatterPlot:
800  try:
801  self.fig.clf()
802 
803  axes = self.fig.add_axes((0.1, 0.1, 0.85, 0.80))
804 
805  axes.plot(ref[good], dmag[good] - center, "b+")
806  axes.errorbar(ref[good], dmag[good] - center, yerr=dmagErr[good],
807  linestyle='', color='b')
808 
809  bad = np.logical_not(good)
810  if len(ref[bad]) > 0:
811  axes.plot(ref[bad], dmag[bad] - center, "r+")
812  axes.errorbar(ref[bad], dmag[bad] - center, yerr=dmagErr[bad],
813  linestyle='', color='r')
814 
815  axes.plot((-100, 100), (0, 0), "g-")
816  for x in (-1, 1):
817  axes.plot((-100, 100), x*0.05*np.ones(2), "g--")
818 
819  axes.set_ylim(-1.1, 1.1)
820  axes.set_xlim(24, 13)
821  axes.set_xlabel("Reference")
822  axes.set_ylabel("Reference - Instrumental")
823 
824  self.fig.show()
825 
826  if self.scatterPlot > 1:
827  reply = None
828  while i == 0 or reply != "c":
829  try:
830  reply = raw_input("Next iteration? [ynhpc] ")
831  except EOFError:
832  reply = "n"
833 
834  if reply == "h":
835  print >> sys.stderr, "Options: c[ontinue] h[elp] n[o] p[db] y[es]"
836  continue
837 
838  if reply in ("", "c", "n", "p", "y"):
839  break
840  else:
841  print >> sys.stderr, "Unrecognised response: %s" % reply
842 
843  if reply == "n":
844  break
845  elif reply == "p":
846  import pdb
847  pdb.set_trace()
848  except Exception, e:
849  print >> sys.stderr, "Error plotting in PhotoCal.getZeroPoint: %s" % e
850 
851  # =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
852 
853  old_ngood = ngood
854  ngood = sum(good)
855  if ngood == 0:
856  msg = "PhotoCal.getZeroPoint: no good stars remain"
857 
858  if i == 0: # failed the first time round -- probably all fell in one bin
859  center = np.average(dmag, weights=dmagErr)
860  msg += " on first iteration; using average of all calibration stars"
861 
862  self.log.log(self.log.WARN, msg)
863 
864  return pipeBase.Struct(
865  zp = center,
866  sigma = sig,
867  ngood = len(dmag))
868  elif ngood == old_ngood:
869  break
870 
871  if False:
872  ref = ref[good]
873  dmag = dmag[good]
874  dmagErr = dmagErr[good]
875 
876  dmag = dmag[good]
877  dmagErr = dmagErr[good]
878  zp, weightSum = np.average(dmag, weights=1/dmagErr**2, returned=True)
879  sigma = np.sqrt(1.0/weightSum)
880  return pipeBase.Struct(
881  zp = zp,
882  sigma = sigma,
883  ngood = len(dmag),
884  )
double abMagErrFromFluxErr(double fluxErr, double flux)
Compute AB magnitude error from flux and flux error in Janskys.
Definition: Calib.h:64
Calculate the zero point of an exposure given a lsst.afw.table.ReferenceMatchVector.
Definition: photoCal.py:157
std::vector< ReferenceMatch > ReferenceMatchVector
Definition: fwd.h:97
def checkSourceFlags
Return True if the given source has all good flags set and none of the bad flags set.
Definition: photoCal.py:41
double abMagFromFlux(double flux)
Compute AB magnitude from flux in Janskys.
Definition: Calib.h:59
double fluxFromABMag(double mag)
Compute flux in Janskys from AB magnitude.
Definition: Calib.h:69
def isUnresolved
Return whether the provided source is unresolved or not.
Definition: photoCal.py:318
def run
Do photometric calibration - select matches to use and (possibly iteratively) compute the zero point...
Definition: photoCal.py:575
def extractMagArrays
Extract magnitude and magnitude error arrays from the given matches.
Definition: photoCal.py:460
def getRefFluxField
Get name of flux field in schema.
def __init__
Create the photometric calibration task.
Definition: photoCal.py:285
def getZeroPoint
Flux calibration code, returning (ZeroPoint, Distribution Width, Number of stars) ...
Definition: photoCal.py:684
def getSourceKeys
Return a struct containing the source catalog keys for fields used by PhotoCalTask.
Definition: photoCal.py:297
def selectMatches
Select reference/source matches according the criteria specified in the config.
Definition: photoCal.py:335