27 import astropy.units 
as u
 
   29 import lsst.pex.config 
as pexConf
 
   31 from lsst.afw.image import abMagErrFromFluxErr, makePhotoCalibFromCalibZeroPoint
 
   36 from .colorterms 
import ColortermLibrary
 
   38 __all__ = [
"PhotoCalTask", 
"PhotoCalConfig"]
 
   42     """Config for PhotoCal""" 
   43     match = pexConf.ConfigField(
"Match to reference catalog",
 
   44                                 DirectMatchConfigWithoutLoader)
 
   45     reserve = pexConf.ConfigurableField(target=ReserveSourcesTask, doc=
"Reserve sources from fitting")
 
   46     fluxField = pexConf.Field(
 
   48         default=
"slot_CalibFlux_instFlux",
 
   49         doc=(
"Name of the source instFlux field to use.  The associated flag field\n" 
   50              "('<name>_flags') will be implicitly included in badFlags."),
 
   52     applyColorTerms = pexConf.Field(
 
   55         doc=(
"Apply photometric color terms to reference stars? One of:\n" 
   56              "None: apply if colorterms and photoCatName are not None;\n" 
   57              "      fail if color term data is not available for the specified ref catalog and filter.\n" 
   58              "True: always apply colorterms; fail if color term data is not available for the\n" 
   59              "      specified reference catalog and filter.\n" 
   60              "False: do not apply."),
 
   63     sigmaMax = pexConf.Field(
 
   66         doc=
"maximum sigma to use when clipping",
 
   69     nSigma = pexConf.Field(
 
   74     useMedian = pexConf.Field(
 
   77         doc=
"use median instead of mean to compute zeropoint",
 
   79     nIter = pexConf.Field(
 
   82         doc=
"number of iterations",
 
   84     colorterms = pexConf.ConfigField(
 
   85         dtype=ColortermLibrary,
 
   86         doc=
"Library of photometric reference catalog name: color term dict",
 
   88     photoCatName = pexConf.Field(
 
   91         doc=(
"Name of photometric reference catalog; used to select a color term dict in colorterms." 
   92              " see also applyColorTerms"),
 
   94     magErrFloor = pexConf.RangeField(
 
   97         doc=
"Additional magnitude uncertainty to be added in quadrature with measurement errors.",
 
  102         pexConf.Config.validate(self)
 
  104             raise RuntimeError(
"applyColorTerms=True requires photoCatName is non-None")
 
  106             raise RuntimeError(
"applyColorTerms=True requires colorterms be provided")
 
  109         pexConf.Config.setDefaults(self)
 
  110         self.
match.sourceSelection.doFlags = 
True 
  111         self.
match.sourceSelection.flags.bad = [
 
  112             "base_PixelFlags_flag_edge",
 
  113             "base_PixelFlags_flag_interpolated",
 
  114             "base_PixelFlags_flag_saturated",
 
  116         self.
match.sourceSelection.doUnresolved = 
True 
  128 @anchor PhotoCalTask_ 
  130 @brief Calculate the zero point of an exposure given a lsst.afw.table.ReferenceMatchVector. 
  132 @section pipe_tasks_photocal_Contents Contents 
  134  - @ref pipe_tasks_photocal_Purpose 
  135  - @ref pipe_tasks_photocal_Initialize 
  136  - @ref pipe_tasks_photocal_IO 
  137  - @ref pipe_tasks_photocal_Config 
  138  - @ref pipe_tasks_photocal_Debug 
  139  - @ref pipe_tasks_photocal_Example 
  141 @section pipe_tasks_photocal_Purpose    Description 
  143 @copybrief PhotoCalTask 
  145 Calculate an Exposure's zero-point given a set of flux measurements of stars matched to an input catalogue. 
  146 The type of flux to use is specified by PhotoCalConfig.fluxField. 
  148 The algorithm clips outliers iteratively, with parameters set in the configuration. 
  150 @note This task can adds fields to the schema, so any code calling this task must ensure that 
  151 these columns are indeed present in the input match list; see @ref pipe_tasks_photocal_Example 
  153 @section pipe_tasks_photocal_Initialize Task initialisation 
  155 @copydoc \_\_init\_\_ 
  157 @section pipe_tasks_photocal_IO         Inputs/Outputs to the run method 
  161 @section pipe_tasks_photocal_Config       Configuration parameters 
  163 See @ref PhotoCalConfig 
  165 @section pipe_tasks_photocal_Debug              Debug variables 
  167 The @link lsst.pipe.base.cmdLineTask.CmdLineTask command line task@endlink interface supports a 
  168 flag @c -d to import @b debug.py from your @c PYTHONPATH; see @ref baseDebug for more about @b debug.py files. 
  170 The available variables in PhotoCalTask are: 
  173   <DD> If True enable other debug outputs 
  174   <DT> @c displaySources 
  175   <DD> If True, display the exposure on the display's frame 1 and overlay the source catalogue. 
  178       <DD> Reserved objects 
  180       <DD> Objects used in the photometric calibration 
  183   <DD> Make a scatter plot of flux v. reference magnitude as a function of reference magnitude. 
  184     - good objects in blue 
  185     - rejected objects in red 
  186   (if @c scatterPlot is 2 or more, prompt to continue after each iteration) 
  189 @section pipe_tasks_photocal_Example    A complete example of using PhotoCalTask 
  191 This code is in @link examples/photoCalTask.py@endlink, and can be run as @em e.g. 
  193 examples/photoCalTask.py 
  195 @dontinclude photoCalTask.py 
  197 Import the tasks (there are some other standard imports; read the file for details) 
  198 @skipline from lsst.pipe.tasks.astrometry 
  199 @skipline measPhotocal 
  201 We need to create both our tasks before processing any data as the task constructors 
  202 can add extra columns to the schema which we get from the input catalogue, @c scrCat: 
  206 @skip AstrometryTask.ConfigClass 
  208 (that @c filterMap line is because our test code doesn't use a filter that the reference catalogue recognises, 
  209 so we tell it to use the @c r band) 
  215 If the schema has indeed changed we need to add the new columns to the source table 
  216 (yes; this should be easier!) 
  220 We're now ready to process the data (we could loop over multiple exposures/catalogues using the same 
  225 We can then unpack and use the results: 
  230 To investigate the @ref pipe_tasks_photocal_Debug, put something like 
  234         di = lsstDebug.getInfo(name)        # N.b. lsstDebug.Info(name) would call us recursively 
  235         if name.endswith(".PhotoCal"): 
  240     lsstDebug.Info = DebugInfo 
  242 into your debug.py file and run photoCalTask.py with the @c --debug flag. 
  244     ConfigClass = PhotoCalConfig
 
  245     _DefaultName = 
"photoCal" 
  247     def __init__(self, refObjLoader, schema=None, **kwds):
 
  248         """!Create the photometric calibration task.  See PhotoCalTask.init for documentation 
  250         pipeBase.Task.__init__(self, **kwds)
 
  253         if schema 
is not None:
 
  254             self.
usedKey = schema.addField(
"calib_photometry_used", type=
"Flag",
 
  255                                            doc=
"set if source was used in photometric calibration")
 
  259                                      name=
"match", parentTask=self)
 
  260         self.makeSubtask(
"reserve", columnName=
"calib_photometry", schema=schema,
 
  261                          doc=
"set if source was reserved from photometric calibration")
 
  264         """Return a struct containing the source catalog keys for fields used 
  270         schema : `lsst.afw.table.schema` 
  271             Schema of the catalog to get keys from. 
  275         result : `lsst.pipe.base.Struct` 
  276             Result struct with components: 
  278             - ``instFlux``: Instrument flux key. 
  279             - ``instFluxErr``: Instrument flux error key. 
  281         instFlux = schema.find(self.config.fluxField).key
 
  282         instFluxErr = schema.find(self.config.fluxField + 
"Err").key
 
  283         return pipeBase.Struct(instFlux=instFlux, instFluxErr=instFluxErr)
 
  287         """!Extract magnitude and magnitude error arrays from the given matches. 
  289         @param[in] matches Reference/source matches, a @link lsst::afw::table::ReferenceMatchVector@endlink 
  290         @param[in] filterName  Name of filter being calibrated 
  291         @param[in] sourceKeys  Struct of source catalog keys, as returned by getSourceKeys() 
  293         @return Struct containing srcMag, refMag, srcMagErr, refMagErr, and magErr numpy arrays 
  294         where magErr is an error in the magnitude; the error in srcMag - refMag 
  295         If nonzero, config.magErrFloor will be added to magErr *only* (not srcMagErr or refMagErr), as 
  296         magErr is what is later used to determine the zero point. 
  297         Struct also contains refFluxFieldList: a list of field names of the reference catalog used for fluxes 
  299         @note These magnitude arrays are the @em inputs to the photometric calibration, some may have been 
  300         discarded by clipping while estimating the calibration (https://jira.lsstcorp.org/browse/DM-813) 
  302         srcInstFluxArr = np.array([m.second.get(sourceKeys.instFlux) 
for m 
in matches])
 
  303         srcInstFluxErrArr = np.array([m.second.get(sourceKeys.instFluxErr) 
for m 
in matches])
 
  304         if not np.all(np.isfinite(srcInstFluxErrArr)):
 
  306             self.log.
warn(
"Source catalog does not have flux uncertainties; using sqrt(flux).")
 
  307             srcInstFluxErrArr = np.sqrt(srcInstFluxArr)
 
  310         referenceFlux = (0*u.ABmag).to_value(u.nJy)
 
  311         srcInstFluxArr = srcInstFluxArr * referenceFlux
 
  312         srcInstFluxErrArr = srcInstFluxErrArr * referenceFlux
 
  315             raise RuntimeError(
"No reference stars are available")
 
  316         refSchema = matches[0].first.schema
 
  318         applyColorTerms = self.config.applyColorTerms
 
  319         applyCTReason = 
"config.applyColorTerms is %s" % (self.config.applyColorTerms,)
 
  320         if self.config.applyColorTerms 
is None:
 
  322             ctDataAvail = len(self.config.colorterms.data) > 0
 
  323             photoCatSpecified = self.config.photoCatName 
is not None 
  324             applyCTReason += 
" and data %s available" % (
"is" if ctDataAvail 
else "is not")
 
  325             applyCTReason += 
" and photoRefCat %s provided" % (
"is" if photoCatSpecified 
else "is not")
 
  326             applyColorTerms = ctDataAvail 
and photoCatSpecified
 
  329             self.log.
info(
"Applying color terms for filterName=%r, config.photoCatName=%s because %s",
 
  330                           filterName, self.config.photoCatName, applyCTReason)
 
  331             colorterm = self.config.colorterms.getColorterm(
 
  332                 filterName=filterName, photoCatName=self.config.photoCatName, doRaise=
True)
 
  336             refCat.reserve(len(matches))
 
  338                 record = refCat.addNew()
 
  339                 record.assign(x.first)
 
  341             refMagArr, refMagErrArr = colorterm.getCorrectedMagnitudes(refCat, filterName)
 
  342             fluxFieldList = [
getRefFluxField(refSchema, filt) 
for filt 
in (colorterm.primary,
 
  343                                                                            colorterm.secondary)]
 
  346             self.log.
info(
"Not applying color terms because %s", applyCTReason)
 
  351             fluxKey = refSchema.find(fluxField).key
 
  352             refFluxArr = np.array([m.first.get(fluxKey) 
for m 
in matches])
 
  355                 fluxErrKey = refSchema.find(fluxField + 
"Err").key
 
  356                 refFluxErrArr = np.array([m.first.get(fluxErrKey) 
for m 
in matches])
 
  359                 self.log.
warn(
"Reference catalog does not have flux uncertainties for %s; using sqrt(flux).",
 
  361                 refFluxErrArr = np.sqrt(refFluxArr)
 
  363             refMagArr = u.Quantity(refFluxArr, u.nJy).to_value(u.ABmag)
 
  368         srcMagArr = u.Quantity(srcInstFluxArr, u.nJy).to_value(u.ABmag)
 
  373         if self.config.magErrFloor != 0.0:
 
  374             magErrArr = (magErrArr**2 + self.config.magErrFloor**2)**0.5
 
  378         good = np.isfinite(srcMagArr) & np.isfinite(refMagArr)
 
  380         return pipeBase.Struct(
 
  381             srcMag=srcMagArr[good],
 
  382             refMag=refMagArr[good],
 
  383             magErr=magErrArr[good],
 
  384             srcMagErr=srcMagErrArr[good],
 
  385             refMagErr=refMagErrArr[good],
 
  386             refFluxFieldList=fluxFieldList,
 
  390     def run(self, exposure, sourceCat, expId=0):
 
  391         """!Do photometric calibration - select matches to use and (possibly iteratively) compute 
  394         @param[in]  exposure  Exposure upon which the sources in the matches were detected. 
  395         @param[in]  sourceCat  A catalog of sources to use in the calibration 
  396         (@em i.e. a list of lsst.afw.table.Match with 
  397         @c first being of type lsst.afw.table.SimpleRecord and @c second type lsst.afw.table.SourceRecord --- 
  398         the reference object and matched object respectively). 
  399         (will not be modified  except to set the outputField if requested.). 
  402          - photoCalib -- @link lsst::afw::image::PhotoCalib@endlink object containing the calibration 
  403          - arrays ------ Magnitude arrays returned be PhotoCalTask.extractMagArrays 
  404          - matches ----- Final ReferenceMatchVector, as returned by PhotoCalTask.selectMatches. 
  405          - zp ---------- Photometric zero point (mag) 
  406          - sigma ------- Standard deviation of fit of photometric zero point (mag) 
  407          - ngood ------- Number of sources used to fit photometric zero point 
  409         The exposure is only used to provide the name of the filter being calibrated (it may also be 
  410         used to generate debugging plots). 
  412         The reference objects: 
  413          - Must include a field @c photometric; True for objects which should be considered as 
  414             photometric standards 
  415          - Must include a field @c flux; the flux used to impose a magnitude limit and also to calibrate 
  416             the data to (unless a color term is specified, in which case ColorTerm.primary is used; 
  417             See https://jira.lsstcorp.org/browse/DM-933) 
  418          - May include a field @c stargal; if present, True means that the object is a star 
  419          - May include a field @c var; if present, True means that the object is variable 
  421         The measured sources: 
  422         - Must include PhotoCalConfig.fluxField; the flux measurement to be used for calibration 
  424         @throws RuntimeError with the following strings: 
  427         <DT> No matches to use for photocal 
  428         <DD> No matches are available (perhaps no sources/references were selected by the matcher). 
  429         <DT> No reference stars are available 
  430         <DD> No matches are available from which to extract magnitudes. 
  436         displaySources = display 
and lsstDebug.Info(__name__).displaySources
 
  440             from matplotlib 
import pyplot
 
  444                 self.
fig = pyplot.figure()
 
  446         filterName = exposure.getFilter().getName()
 
  449         matchResults = self.
match.
run(sourceCat, filterName)
 
  450         matches = matchResults.matches
 
  452         reserveResults = self.reserve.
run([mm.second 
for mm 
in matches], expId=expId)
 
  455         if reserveResults.reserved.sum() > 0:
 
  456             matches = [mm 
for mm, use 
in zip(matches, reserveResults.use) 
if use]
 
  457         if len(matches) == 0:
 
  458             raise RuntimeError(
"No matches to use for photocal")
 
  461                 mm.second.set(self.
usedKey, 
True)
 
  465         arrays = self.
extractMagArrays(matches=matches, filterName=filterName, sourceKeys=sourceKeys)
 
  468         r = self.
getZeroPoint(arrays.srcMag, arrays.refMag, arrays.magErr)
 
  469         self.log.
info(
"Magnitude zero point: %f +/- %f from %d stars", r.zp, r.sigma, r.ngood)
 
  472         flux0 = 10**(0.4*r.zp)  
 
  473         flux0err = 0.4*math.log(10)*flux0*r.sigma  
 
  476         return pipeBase.Struct(
 
  477             photoCalib=photoCalib,
 
  486         """Display sources we'll use for photocal 
  488         Sources that will be actually used will be green. 
  489         Sources reserved from the fit will be red. 
  493         exposure : `lsst.afw.image.ExposureF` 
  495         matches : `list` of `lsst.afw.table.RefMatch` 
  496             Matches used for photocal. 
  497         reserved : `numpy.ndarray` of type `bool` 
  498             Boolean array indicating sources that are reserved. 
  500             Frame number for display. 
  502         disp = afwDisplay.getDisplay(frame=frame)
 
  503         disp.mtv(exposure, title=
"photocal")
 
  504         with disp.Buffering():
 
  505             for mm, rr 
in zip(matches, reserved):
 
  506                 x, y = mm.second.getCentroid()
 
  507                 ctype = afwDisplay.RED 
if rr 
else afwDisplay.GREEN
 
  508                 disp.dot(
"o", x, y, size=4, ctype=ctype)
 
  511         """!Flux calibration code, returning (ZeroPoint, Distribution Width, Number of stars) 
  513         We perform nIter iterations of a simple sigma-clipping algorithm with a couple of twists: 
  514         1.  We use the median/interquartile range to estimate the position to clip around, and the 
  516         2.  We never allow sigma to go _above_ a critical value sigmaMax --- if we do, a sufficiently 
  517         large estimate will prevent the clipping from ever taking effect. 
  518         3.  Rather than start with the median we start with a crude mode.  This means that a set of magnitude 
  519         residuals with a tight core and asymmetrical outliers will start in the core.  We use the width of 
  520         this core to set our maximum sigma (see 2.) 
  523          - zp ---------- Photometric zero point (mag) 
  524          - sigma ------- Standard deviation of fit of zero point (mag) 
  525          - ngood ------- Number of sources used to fit zero point 
  527         sigmaMax = self.config.sigmaMax
 
  531         indArr = np.argsort(dmag)
 
  534         if srcErr 
is not None:
 
  535             dmagErr = srcErr[indArr]
 
  537             dmagErr = np.ones(len(dmag))
 
  540         ind_noNan = np.array([i 
for i 
in range(len(dmag))
 
  541                               if (
not np.isnan(dmag[i]) 
and not np.isnan(dmagErr[i]))])
 
  542         dmag = dmag[ind_noNan]
 
  543         dmagErr = dmagErr[ind_noNan]
 
  545         IQ_TO_STDEV = 0.741301109252802    
 
  550         for i 
in range(self.config.nIter):
 
  561                     hist, edges = np.histogram(dmag, nhist, new=
True)
 
  563                     hist, edges = np.histogram(dmag, nhist)  
 
  564                 imode = np.arange(nhist)[np.where(hist == hist.max())]
 
  566                 if imode[-1] - imode[0] + 1 == len(imode):  
 
  570                         center = 0.5*(edges[imode[0]] + edges[imode[-1] + 1])
 
  572                     peak = sum(hist[imode])/len(imode)  
 
  576                     while j >= 0 
and hist[j] > 0.5*peak:
 
  579                     q1 = dmag[sum(hist[range(j)])]
 
  582                     while j < nhist 
and hist[j] > 0.5*peak:
 
  584                     j = 
min(j, nhist - 1)
 
  585                     j = 
min(sum(hist[range(j)]), npt - 1)
 
  589                         q1 = dmag[int(0.25*npt)]
 
  590                         q3 = dmag[int(0.75*npt)]
 
  597                     self.log.
debug(
"Photo calibration histogram: center = %.2f, sig = %.2f", center, sig)
 
  601                         sigmaMax = dmag[-1] - dmag[0]
 
  603                     center = np.median(dmag)
 
  604                     q1 = dmag[int(0.25*npt)]
 
  605                     q3 = dmag[int(0.75*npt)]
 
  610                 if self.config.useMedian:
 
  611                     center = np.median(gdmag)
 
  613                     gdmagErr = dmagErr[good]
 
  614                     center = np.average(gdmag, weights=gdmagErr)
 
  616                 q3 = gdmag[
min(int(0.75*npt + 0.5), npt - 1)]
 
  617                 q1 = gdmag[
min(int(0.25*npt + 0.5), npt - 1)]
 
  619                 sig = IQ_TO_STDEV*(q3 - q1)     
 
  621             good = 
abs(dmag - center) < self.config.nSigma*
min(sig, sigmaMax)  
 
  628                     axes = self.
fig.add_axes((0.1, 0.1, 0.85, 0.80))
 
  630                     axes.plot(ref[good], dmag[good] - center, 
"b+")
 
  631                     axes.errorbar(ref[good], dmag[good] - center, yerr=dmagErr[good],
 
  632                                   linestyle=
'', color=
'b')
 
  634                     bad = np.logical_not(good)
 
  635                     if len(ref[bad]) > 0:
 
  636                         axes.plot(ref[bad], dmag[bad] - center, 
"r+")
 
  637                         axes.errorbar(ref[bad], dmag[bad] - center, yerr=dmagErr[bad],
 
  638                                       linestyle=
'', color=
'r')
 
  640                     axes.plot((-100, 100), (0, 0), 
"g-")
 
  642                         axes.plot((-100, 100), x*0.05*np.ones(2), 
"g--")
 
  644                     axes.set_ylim(-1.1, 1.1)
 
  645                     axes.set_xlim(24, 13)
 
  646                     axes.set_xlabel(
"Reference")
 
  647                     axes.set_ylabel(
"Reference - Instrumental")
 
  653                         while i == 0 
or reply != 
"c":
 
  655                                 reply = input(
"Next iteration? [ynhpc] ")
 
  660                                 print(
"Options: c[ontinue] h[elp] n[o] p[db] y[es]", file=sys.stderr)
 
  663                             if reply 
in (
"", 
"c", 
"n", 
"p", 
"y"):
 
  666                                 print(
"Unrecognised response: %s" % reply, file=sys.stderr)
 
  673                 except Exception 
as e:
 
  674                     print(
"Error plotting in PhotoCal.getZeroPoint: %s" % e, file=sys.stderr)
 
  681                 msg = 
"PhotoCal.getZeroPoint: no good stars remain" 
  684                     center = np.average(dmag, weights=dmagErr)
 
  685                     msg += 
" on first iteration; using average of all calibration stars" 
  689                 return pipeBase.Struct(
 
  693             elif ngood == old_ngood:
 
  699                 dmagErr = dmagErr[good]
 
  702         dmagErr = dmagErr[good]
 
  703         zp, weightSum = np.average(dmag, weights=1/dmagErr**2, returned=
True)
 
  704         sigma = np.sqrt(1.0/weightSum)
 
  705         return pipeBase.Struct(