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