Esempio n. 1
0
 def __init__(self, refObjLoader, schema=None, **kwds):
     """!Create the photometric calibration task.  See PhotoCalTask.init for documentation
     """
     pipeBase.Task.__init__(self, **kwds)
     self.scatterPlot = None
     self.fig = None
     if schema is not None:
         self.usedKey = schema.addField(
             "calib_photometry_used",
             type="Flag",
             doc="set if source was used in photometric calibration")
     else:
         self.usedKey = None
     self.match = DirectMatchTask(config=self.config.match,
                                  refObjLoader=refObjLoader,
                                  name="match",
                                  parentTask=self)
     self.makeSubtask(
         "reserve",
         columnName="calib_photometry",
         schema=schema,
         doc="set if source was reserved from photometric calibration")
Esempio n. 2
0
 def __init__(self, refObjLoader, schema=None, **kwds):
     """!Create the photometric calibration task.  See PhotoCalTask.init for documentation
     """
     pipeBase.Task.__init__(self, **kwds)
     self.scatterPlot = None
     self.fig = None
     if schema is not None:
         self.usedKey = schema.addField("calib_photometry_used", type="Flag",
                                        doc="set if source was used in photometric calibration")
     else:
         self.usedKey = None
     self.match = DirectMatchTask(config=self.config.match, refObjLoader=refObjLoader,
                                  name="match", parentTask=self)
     self.makeSubtask("reserve", columnName="calib_photometry", schema=schema,
                      doc="set if source was reserved from photometric calibration")
Esempio n. 3
0
class PhotoCalTask(pipeBase.Task):
    r"""!
@anchor PhotoCalTask_

@brief Calculate the zero point of an exposure given a lsst.afw.table.ReferenceMatchVector.

@section pipe_tasks_photocal_Contents Contents

 - @ref pipe_tasks_photocal_Purpose
 - @ref pipe_tasks_photocal_Initialize
 - @ref pipe_tasks_photocal_IO
 - @ref pipe_tasks_photocal_Config
 - @ref pipe_tasks_photocal_Debug
 - @ref pipe_tasks_photocal_Example

@section pipe_tasks_photocal_Purpose	Description

@copybrief PhotoCalTask

Calculate an Exposure's zero-point given a set of flux measurements of stars matched to an input catalogue.
The type of flux to use is specified by PhotoCalConfig.fluxField.

The algorithm clips outliers iteratively, with parameters set in the configuration.

@note This task can adds fields to the schema, so any code calling this task must ensure that
these columns are indeed present in the input match list; see @ref pipe_tasks_photocal_Example

@section pipe_tasks_photocal_Initialize	Task initialisation

@copydoc \_\_init\_\_

@section pipe_tasks_photocal_IO		Inputs/Outputs to the run method

@copydoc run

@section pipe_tasks_photocal_Config       Configuration parameters

See @ref PhotoCalConfig

@section pipe_tasks_photocal_Debug		Debug variables

The @link lsst.pipe.base.cmdLineTask.CmdLineTask command line task@endlink interface supports a
flag @c -d to import @b debug.py from your @c PYTHONPATH; see @ref baseDebug for more about @b debug.py files.

The available variables in PhotoCalTask are:
<DL>
  <DT> @c display
  <DD> If True enable other debug outputs
  <DT> @c displaySources
  <DD> If True, display the exposure on the display's frame 1 and overlay the source catalogue.
    <DL>
      <DT> red o
      <DD> Reserved objects
      <DT> green o
      <DD> Objects used in the photometric calibration
    </DL>
  <DT> @c scatterPlot
  <DD> Make a scatter plot of flux v. reference magnitude as a function of reference magnitude.
    - good objects in blue
    - rejected objects in red
  (if @c scatterPlot is 2 or more, prompt to continue after each iteration)
</DL>

@section pipe_tasks_photocal_Example	A complete example of using PhotoCalTask

This code is in @link examples/photoCalTask.py@endlink, and can be run as @em e.g.
@code
examples/photoCalTask.py
@endcode
@dontinclude photoCalTask.py

Import the tasks (there are some other standard imports; read the file for details)
@skipline from lsst.pipe.tasks.astrometry
@skipline measPhotocal

We need to create both our tasks before processing any data as the task constructors
can add extra columns to the schema which we get from the input catalogue, @c scrCat:
@skipline getSchema

Astrometry first:
@skip AstrometryTask.ConfigClass
@until aTask
(that @c filterMap line is because our test code doesn't use a filter that the reference catalogue recognises,
so we tell it to use the @c r band)

Then photometry:
@skip measPhotocal
@until pTask

If the schema has indeed changed we need to add the new columns to the source table
(yes; this should be easier!)
@skip srcCat
@until srcCat = cat

We're now ready to process the data (we could loop over multiple exposures/catalogues using the same
task objects):
@skip matches
@until result

We can then unpack and use the results:
@skip calib
@until np.log

<HR>
To investigate the @ref pipe_tasks_photocal_Debug, put something like
@code{.py}
    import lsstDebug
    def DebugInfo(name):
        di = lsstDebug.getInfo(name)        # N.b. lsstDebug.Info(name) would call us recursively
        if name.endswith(".PhotoCal"):
            di.display = 1

        return di

    lsstDebug.Info = DebugInfo
@endcode
into your debug.py file and run photoCalTask.py with the @c --debug flag.
    """
    ConfigClass = PhotoCalConfig
    _DefaultName = "photoCal"

    def __init__(self, refObjLoader, schema=None, **kwds):
        """!Create the photometric calibration task.  See PhotoCalTask.init for documentation
        """
        pipeBase.Task.__init__(self, **kwds)
        self.scatterPlot = None
        self.fig = None
        if schema is not None:
            self.usedKey = schema.addField(
                "calib_photometry_used",
                type="Flag",
                doc="set if source was used in photometric calibration")
        else:
            self.usedKey = None
        self.match = DirectMatchTask(config=self.config.match,
                                     refObjLoader=refObjLoader,
                                     name="match",
                                     parentTask=self)
        self.makeSubtask(
            "reserve",
            columnName="calib_photometry",
            schema=schema,
            doc="set if source was reserved from photometric calibration")

    def getSourceKeys(self, schema):
        """Return a struct containing the source catalog keys for fields used
        by PhotoCalTask.


        Parameters
        ----------
        schema : `lsst.afw.table.schema`
            Schema of the catalog to get keys from.

        Returns
        -------
        result : `lsst.pipe.base.Struct`
            Result struct with components:

            - ``instFlux``: Instrument flux key.
            - ``instFluxErr``: Instrument flux error key.
        """
        instFlux = schema.find(self.config.fluxField).key
        instFluxErr = schema.find(self.config.fluxField + "Err").key
        return pipeBase.Struct(instFlux=instFlux, instFluxErr=instFluxErr)

    @pipeBase.timeMethod
    def extractMagArrays(self, matches, filterLabel, sourceKeys):
        """!Extract magnitude and magnitude error arrays from the given matches.

        @param[in] matches Reference/source matches, a @link lsst::afw::table::ReferenceMatchVector@endlink
        @param[in] filterLabel Label of filter being calibrated
        @param[in] sourceKeys  Struct of source catalog keys, as returned by getSourceKeys()

        @return Struct containing srcMag, refMag, srcMagErr, refMagErr, and magErr numpy arrays
        where magErr is an error in the magnitude; the error in srcMag - refMag
        If nonzero, config.magErrFloor will be added to magErr *only* (not srcMagErr or refMagErr), as
        magErr is what is later used to determine the zero point.
        Struct also contains refFluxFieldList: a list of field names of the reference catalog used for fluxes
        (1 or 2 strings)
        @note These magnitude arrays are the @em inputs to the photometric calibration, some may have been
        discarded by clipping while estimating the calibration (https://jira.lsstcorp.org/browse/DM-813)
        """
        srcInstFluxArr = np.array(
            [m.second.get(sourceKeys.instFlux) for m in matches])
        srcInstFluxErrArr = np.array(
            [m.second.get(sourceKeys.instFluxErr) for m in matches])
        if not np.all(np.isfinite(srcInstFluxErrArr)):
            # this is an unpleasant hack; see DM-2308 requesting a better solution
            self.log.warn(
                "Source catalog does not have flux uncertainties; using sqrt(flux)."
            )
            srcInstFluxErrArr = np.sqrt(srcInstFluxArr)

        # convert source instFlux from DN to an estimate of nJy
        referenceFlux = (0 * u.ABmag).to_value(u.nJy)
        srcInstFluxArr = srcInstFluxArr * referenceFlux
        srcInstFluxErrArr = srcInstFluxErrArr * referenceFlux

        if not matches:
            raise RuntimeError("No reference stars are available")
        refSchema = matches[0].first.schema

        applyColorTerms = self.config.applyColorTerms
        applyCTReason = "config.applyColorTerms is %s" % (
            self.config.applyColorTerms, )
        if self.config.applyColorTerms is None:
            # apply color terms if color term data is available and photoCatName specified
            ctDataAvail = len(self.config.colorterms.data) > 0
            photoCatSpecified = self.config.photoCatName is not None
            applyCTReason += " and data %s available" % ("is" if ctDataAvail
                                                         else "is not")
            applyCTReason += " and photoRefCat %s provided" % (
                "is" if photoCatSpecified else "is not")
            applyColorTerms = ctDataAvail and photoCatSpecified

        if applyColorTerms:
            self.log.info(
                "Applying color terms for filter=%r, config.photoCatName=%s because %s",
                filterLabel.physicalLabel, self.config.photoCatName,
                applyCTReason)
            colorterm = self.config.colorterms.getColorterm(
                filterLabel.physicalLabel,
                self.config.photoCatName,
                doRaise=True)
            refCat = afwTable.SimpleCatalog(matches[0].first.schema)

            # extract the matched refCat as a Catalog for the colorterm code
            refCat.reserve(len(matches))
            for x in matches:
                record = refCat.addNew()
                record.assign(x.first)

            refMagArr, refMagErrArr = colorterm.getCorrectedMagnitudes(refCat)
            fluxFieldList = [
                getRefFluxField(refSchema, filt)
                for filt in (colorterm.primary, colorterm.secondary)
            ]
        else:
            # no colorterms to apply
            self.log.info("Not applying color terms because %s", applyCTReason)
            colorterm = None

            fluxFieldList = [getRefFluxField(refSchema, filterLabel.bandLabel)]
            fluxField = getRefFluxField(refSchema, filterLabel.bandLabel)
            fluxKey = refSchema.find(fluxField).key
            refFluxArr = np.array([m.first.get(fluxKey) for m in matches])

            try:
                fluxErrKey = refSchema.find(fluxField + "Err").key
                refFluxErrArr = np.array(
                    [m.first.get(fluxErrKey) for m in matches])
            except KeyError:
                # Reference catalogue may not have flux uncertainties; HACK DM-2308
                self.log.warn(
                    "Reference catalog does not have flux uncertainties for %s; using sqrt(flux).",
                    fluxField)
                refFluxErrArr = np.sqrt(refFluxArr)

            refMagArr = u.Quantity(refFluxArr, u.nJy).to_value(u.ABmag)
            # HACK convert to Jy until we have a replacement for this (DM-16903)
            refMagErrArr = abMagErrFromFluxErr(refFluxErrArr * 1e-9,
                                               refFluxArr * 1e-9)

        # compute the source catalog magnitudes and errors
        srcMagArr = u.Quantity(srcInstFluxArr, u.nJy).to_value(u.ABmag)
        # Fitting with error bars in both axes is hard
        # for now ignore reference flux error, but ticket DM-2308 is a request for a better solution
        # HACK convert to Jy until we have a replacement for this (DM-16903)
        magErrArr = abMagErrFromFluxErr(srcInstFluxErrArr * 1e-9,
                                        srcInstFluxArr * 1e-9)
        if self.config.magErrFloor != 0.0:
            magErrArr = (magErrArr**2 + self.config.magErrFloor**2)**0.5

        srcMagErrArr = abMagErrFromFluxErr(srcInstFluxErrArr * 1e-9,
                                           srcInstFluxArr * 1e-9)

        good = np.isfinite(srcMagArr) & np.isfinite(refMagArr)

        return pipeBase.Struct(
            srcMag=srcMagArr[good],
            refMag=refMagArr[good],
            magErr=magErrArr[good],
            srcMagErr=srcMagErrArr[good],
            refMagErr=refMagErrArr[good],
            refFluxFieldList=fluxFieldList,
        )

    @pipeBase.timeMethod
    def run(self, exposure, sourceCat, expId=0):
        """!Do photometric calibration - select matches to use and (possibly iteratively) compute
        the zero point.

        @param[in]  exposure  Exposure upon which the sources in the matches were detected.
        @param[in]  sourceCat  A catalog of sources to use in the calibration
        (@em i.e. a list of lsst.afw.table.Match with
        @c first being of type lsst.afw.table.SimpleRecord and @c second type lsst.afw.table.SourceRecord ---
        the reference object and matched object respectively).
        (will not be modified  except to set the outputField if requested.).

        @return Struct of:
         - photoCalib -- @link lsst::afw::image::PhotoCalib@endlink object containing the calibration
         - arrays ------ Magnitude arrays returned be PhotoCalTask.extractMagArrays
         - matches ----- Final ReferenceMatchVector, as returned by PhotoCalTask.selectMatches.
         - zp ---------- Photometric zero point (mag)
         - sigma ------- Standard deviation of fit of photometric zero point (mag)
         - ngood ------- Number of sources used to fit photometric zero point

        The exposure is only used to provide the name of the filter being calibrated (it may also be
        used to generate debugging plots).

        The reference objects:
         - Must include a field @c photometric; True for objects which should be considered as
            photometric standards
         - Must include a field @c flux; the flux used to impose a magnitude limit and also to calibrate
            the data to (unless a color term is specified, in which case ColorTerm.primary is used;
            See https://jira.lsstcorp.org/browse/DM-933)
         - May include a field @c stargal; if present, True means that the object is a star
         - May include a field @c var; if present, True means that the object is variable

        The measured sources:
        - Must include PhotoCalConfig.fluxField; the flux measurement to be used for calibration

        @throws RuntimeError with the following strings:

        <DL>
        <DT> No matches to use for photocal
        <DD> No matches are available (perhaps no sources/references were selected by the matcher).
        <DT> No reference stars are available
        <DD> No matches are available from which to extract magnitudes.
        </DL>
        """
        import lsstDebug

        display = lsstDebug.Info(__name__).display
        displaySources = display and lsstDebug.Info(__name__).displaySources
        self.scatterPlot = display and lsstDebug.Info(__name__).scatterPlot

        if self.scatterPlot:
            from matplotlib import pyplot
            try:
                self.fig.clf()
            except Exception:
                self.fig = pyplot.figure()

        filterLabel = exposure.getFilterLabel()

        # Match sources
        matchResults = self.match.run(sourceCat, filterLabel.bandLabel)
        matches = matchResults.matches

        reserveResults = self.reserve.run([mm.second for mm in matches],
                                          expId=expId)
        if displaySources:
            self.displaySources(exposure, matches, reserveResults.reserved)
        if reserveResults.reserved.sum() > 0:
            matches = [
                mm for mm, use in zip(matches, reserveResults.use) if use
            ]
        if len(matches) == 0:
            raise RuntimeError("No matches to use for photocal")
        if self.usedKey is not None:
            for mm in matches:
                mm.second.set(self.usedKey, True)

        # Prepare for fitting
        sourceKeys = self.getSourceKeys(matches[0].second.schema)
        arrays = self.extractMagArrays(matches, filterLabel, sourceKeys)

        # Fit for zeropoint
        r = self.getZeroPoint(arrays.srcMag, arrays.refMag, arrays.magErr)
        self.log.info("Magnitude zero point: %f +/- %f from %d stars", r.zp,
                      r.sigma, r.ngood)

        # Prepare the results
        flux0 = 10**(0.4 * r.zp)  # Flux of mag=0 star
        flux0err = 0.4 * math.log(10) * flux0 * r.sigma  # Error in flux0
        photoCalib = makePhotoCalibFromCalibZeroPoint(flux0, flux0err)

        return pipeBase.Struct(
            photoCalib=photoCalib,
            arrays=arrays,
            matches=matches,
            zp=r.zp,
            sigma=r.sigma,
            ngood=r.ngood,
        )

    def displaySources(self, exposure, matches, reserved, frame=1):
        """Display sources we'll use for photocal

        Sources that will be actually used will be green.
        Sources reserved from the fit will be red.

        Parameters
        ----------
        exposure : `lsst.afw.image.ExposureF`
            Exposure to display.
        matches : `list` of `lsst.afw.table.RefMatch`
            Matches used for photocal.
        reserved : `numpy.ndarray` of type `bool`
            Boolean array indicating sources that are reserved.
        frame : `int`
            Frame number for display.
        """
        disp = afwDisplay.getDisplay(frame=frame)
        disp.mtv(exposure, title="photocal")
        with disp.Buffering():
            for mm, rr in zip(matches, reserved):
                x, y = mm.second.getCentroid()
                ctype = afwDisplay.RED if rr else afwDisplay.GREEN
                disp.dot("o", x, y, size=4, ctype=ctype)

    def getZeroPoint(self, src, ref, srcErr=None, zp0=None):
        """!Flux calibration code, returning (ZeroPoint, Distribution Width, Number of stars)

        We perform nIter iterations of a simple sigma-clipping algorithm with a couple of twists:
        1.  We use the median/interquartile range to estimate the position to clip around, and the
        "sigma" to use.
        2.  We never allow sigma to go _above_ a critical value sigmaMax --- if we do, a sufficiently
        large estimate will prevent the clipping from ever taking effect.
        3.  Rather than start with the median we start with a crude mode.  This means that a set of magnitude
        residuals with a tight core and asymmetrical outliers will start in the core.  We use the width of
        this core to set our maximum sigma (see 2.)

        @return Struct of:
         - zp ---------- Photometric zero point (mag)
         - sigma ------- Standard deviation of fit of zero point (mag)
         - ngood ------- Number of sources used to fit zero point
        """
        sigmaMax = self.config.sigmaMax

        dmag = ref - src

        indArr = np.argsort(dmag)
        dmag = dmag[indArr]

        if srcErr is not None:
            dmagErr = srcErr[indArr]
        else:
            dmagErr = np.ones(len(dmag))

        # need to remove nan elements to avoid errors in stats calculation with numpy
        ind_noNan = np.array([
            i for i in range(len(dmag))
            if (not np.isnan(dmag[i]) and not np.isnan(dmagErr[i]))
        ])
        dmag = dmag[ind_noNan]
        dmagErr = dmagErr[ind_noNan]

        IQ_TO_STDEV = 0.741301109252802  # 1 sigma in units of interquartile (assume Gaussian)

        npt = len(dmag)
        ngood = npt
        good = None  # set at end of first iteration
        for i in range(self.config.nIter):
            if i > 0:
                npt = sum(good)

            center = None
            if i == 0:
                #
                # Start by finding the mode
                #
                nhist = 20
                try:
                    hist, edges = np.histogram(dmag, nhist, new=True)
                except TypeError:
                    hist, edges = np.histogram(
                        dmag, nhist)  # they removed new=True around numpy 1.5
                imode = np.arange(nhist)[np.where(hist == hist.max())]

                if imode[-1] - imode[0] + 1 == len(
                        imode):  # Multiple modes, but all contiguous
                    if zp0:
                        center = zp0
                    else:
                        center = 0.5 * (edges[imode[0]] + edges[imode[-1] + 1])

                    peak = sum(hist[imode]) / len(imode)  # peak height

                    # Estimate FWHM of mode
                    j = imode[0]
                    while j >= 0 and hist[j] > 0.5 * peak:
                        j -= 1
                    j = max(j, 0)
                    q1 = dmag[sum(hist[range(j)])]

                    j = imode[-1]
                    while j < nhist and hist[j] > 0.5 * peak:
                        j += 1
                    j = min(j, nhist - 1)
                    j = min(sum(hist[range(j)]), npt - 1)
                    q3 = dmag[j]

                    if q1 == q3:
                        q1 = dmag[int(0.25 * npt)]
                        q3 = dmag[int(0.75 * npt)]

                    sig = (
                        q3 - q1
                    ) / 2.3  # estimate of standard deviation (based on FWHM; 2.358 for Gaussian)

                    if sigmaMax is None:
                        sigmaMax = 2 * sig  # upper bound on st. dev. for clipping. multiplier is a heuristic

                    self.log.debug(
                        "Photo calibration histogram: center = %.2f, sig = %.2f",
                        center, sig)

                else:
                    if sigmaMax is None:
                        sigmaMax = dmag[-1] - dmag[0]

                    center = np.median(dmag)
                    q1 = dmag[int(0.25 * npt)]
                    q3 = dmag[int(0.75 * npt)]
                    sig = (
                        q3 - q1
                    ) / 2.3  # estimate of standard deviation (based on FWHM; 2.358 for Gaussian)

            if center is None:  # usually equivalent to (i > 0)
                gdmag = dmag[good]
                if self.config.useMedian:
                    center = np.median(gdmag)
                else:
                    gdmagErr = dmagErr[good]
                    center = np.average(gdmag, weights=gdmagErr)

                q3 = gdmag[min(int(0.75 * npt + 0.5), npt - 1)]
                q1 = gdmag[min(int(0.25 * npt + 0.5), npt - 1)]

                sig = IQ_TO_STDEV * (q3 - q1)  # estimate of standard deviation

            good = abs(dmag - center) < self.config.nSigma * min(
                sig, sigmaMax)  # don't clip too softly

            # =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
            if self.scatterPlot:
                try:
                    self.fig.clf()

                    axes = self.fig.add_axes((0.1, 0.1, 0.85, 0.80))

                    axes.plot(ref[good], dmag[good] - center, "b+")
                    axes.errorbar(ref[good],
                                  dmag[good] - center,
                                  yerr=dmagErr[good],
                                  linestyle='',
                                  color='b')

                    bad = np.logical_not(good)
                    if len(ref[bad]) > 0:
                        axes.plot(ref[bad], dmag[bad] - center, "r+")
                        axes.errorbar(ref[bad],
                                      dmag[bad] - center,
                                      yerr=dmagErr[bad],
                                      linestyle='',
                                      color='r')

                    axes.plot((-100, 100), (0, 0), "g-")
                    for x in (-1, 1):
                        axes.plot((-100, 100), x * 0.05 * np.ones(2), "g--")

                    axes.set_ylim(-1.1, 1.1)
                    axes.set_xlim(24, 13)
                    axes.set_xlabel("Reference")
                    axes.set_ylabel("Reference - Instrumental")

                    self.fig.show()

                    if self.scatterPlot > 1:
                        reply = None
                        while i == 0 or reply != "c":
                            try:
                                reply = input("Next iteration? [ynhpc] ")
                            except EOFError:
                                reply = "n"

                            if reply == "h":
                                print(
                                    "Options: c[ontinue] h[elp] n[o] p[db] y[es]",
                                    file=sys.stderr)
                                continue

                            if reply in ("", "c", "n", "p", "y"):
                                break
                            else:
                                print("Unrecognised response: %s" % reply,
                                      file=sys.stderr)

                        if reply == "n":
                            break
                        elif reply == "p":
                            import pdb
                            pdb.set_trace()
                except Exception as e:
                    print("Error plotting in PhotoCal.getZeroPoint: %s" % e,
                          file=sys.stderr)

            # =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-

            old_ngood = ngood
            ngood = sum(good)
            if ngood == 0:
                msg = "PhotoCal.getZeroPoint: no good stars remain"

                if i == 0:  # failed the first time round -- probably all fell in one bin
                    center = np.average(dmag, weights=dmagErr)
                    msg += " on first iteration; using average of all calibration stars"

                self.log.warn(msg)

                return pipeBase.Struct(zp=center, sigma=sig, ngood=len(dmag))
            elif ngood == old_ngood:
                break

            if False:
                ref = ref[good]
                dmag = dmag[good]
                dmagErr = dmagErr[good]

        dmag = dmag[good]
        dmagErr = dmagErr[good]
        zp, weightSum = np.average(dmag, weights=1 / dmagErr**2, returned=True)
        sigma = np.sqrt(1.0 / weightSum)
        return pipeBase.Struct(
            zp=zp,
            sigma=sigma,
            ngood=len(dmag),
        )
Esempio n. 4
0
class PhotoCalTask(pipeBase.Task):
    r"""!
@anchor PhotoCalTask_

@brief Calculate the zero point of an exposure given a lsst.afw.table.ReferenceMatchVector.

@section pipe_tasks_photocal_Contents Contents

 - @ref pipe_tasks_photocal_Purpose
 - @ref pipe_tasks_photocal_Initialize
 - @ref pipe_tasks_photocal_IO
 - @ref pipe_tasks_photocal_Config
 - @ref pipe_tasks_photocal_Debug
 - @ref pipe_tasks_photocal_Example

@section pipe_tasks_photocal_Purpose	Description

@copybrief PhotoCalTask

Calculate an Exposure's zero-point given a set of flux measurements of stars matched to an input catalogue.
The type of flux to use is specified by PhotoCalConfig.fluxField.

The algorithm clips outliers iteratively, with parameters set in the configuration.

@note This task can adds fields to the schema, so any code calling this task must ensure that
these columns are indeed present in the input match list; see @ref pipe_tasks_photocal_Example

@section pipe_tasks_photocal_Initialize	Task initialisation

@copydoc \_\_init\_\_

@section pipe_tasks_photocal_IO		Inputs/Outputs to the run method

@copydoc run

@section pipe_tasks_photocal_Config       Configuration parameters

See @ref PhotoCalConfig

@section pipe_tasks_photocal_Debug		Debug variables

The @link lsst.pipe.base.cmdLineTask.CmdLineTask command line task@endlink interface supports a
flag @c -d to import @b debug.py from your @c PYTHONPATH; see @ref baseDebug for more about @b debug.py files.

The available variables in PhotoCalTask are:
<DL>
  <DT> @c display
  <DD> If True enable other debug outputs
  <DT> @c displaySources
  <DD> If True, display the exposure on the display's frame 1 and overlay the source catalogue.
    <DL>
      <DT> red o
      <DD> Reserved objects
      <DT> green o
      <DD> Objects used in the photometric calibration
    </DL>
  <DT> @c scatterPlot
  <DD> Make a scatter plot of flux v. reference magnitude as a function of reference magnitude.
    - good objects in blue
    - rejected objects in red
  (if @c scatterPlot is 2 or more, prompt to continue after each iteration)
</DL>

@section pipe_tasks_photocal_Example	A complete example of using PhotoCalTask

This code is in @link examples/photoCalTask.py@endlink, and can be run as @em e.g.
@code
examples/photoCalTask.py
@endcode
@dontinclude photoCalTask.py

Import the tasks (there are some other standard imports; read the file for details)
@skipline from lsst.pipe.tasks.astrometry
@skipline measPhotocal

We need to create both our tasks before processing any data as the task constructors
can add extra columns to the schema which we get from the input catalogue, @c scrCat:
@skipline getSchema

Astrometry first:
@skip AstrometryTask.ConfigClass
@until aTask
(that @c filterMap line is because our test code doesn't use a filter that the reference catalogue recognises,
so we tell it to use the @c r band)

Then photometry:
@skip measPhotocal
@until pTask

If the schema has indeed changed we need to add the new columns to the source table
(yes; this should be easier!)
@skip srcCat
@until srcCat = cat

We're now ready to process the data (we could loop over multiple exposures/catalogues using the same
task objects):
@skip matches
@until result

We can then unpack and use the results:
@skip calib
@until np.log

<HR>
To investigate the @ref pipe_tasks_photocal_Debug, put something like
@code{.py}
    import lsstDebug
    def DebugInfo(name):
        di = lsstDebug.getInfo(name)        # N.b. lsstDebug.Info(name) would call us recursively
        if name.endswith(".PhotoCal"):
            di.display = 1

        return di

    lsstDebug.Info = DebugInfo
@endcode
into your debug.py file and run photoCalTask.py with the @c --debug flag.
    """
    ConfigClass = PhotoCalConfig
    _DefaultName = "photoCal"

    def __init__(self, refObjLoader, schema=None, **kwds):
        """!Create the photometric calibration task.  See PhotoCalTask.init for documentation
        """
        pipeBase.Task.__init__(self, **kwds)
        self.scatterPlot = None
        self.fig = None
        if schema is not None:
            self.usedKey = schema.addField("calib_photometry_used", type="Flag",
                                           doc="set if source was used in photometric calibration")
        else:
            self.usedKey = None
        self.match = DirectMatchTask(config=self.config.match, refObjLoader=refObjLoader,
                                     name="match", parentTask=self)
        self.makeSubtask("reserve", columnName="calib_photometry", schema=schema,
                         doc="set if source was reserved from photometric calibration")

    def getSourceKeys(self, schema):
        """Return a struct containing the source catalog keys for fields used
        by PhotoCalTask.


        Parameters
        ----------
        schema : `lsst.afw.table.schema`
            Schema of the catalog to get keys from.

        Returns
        -------
        result : `lsst.pipe.base.Struct`
            Result struct with components:

            - ``instFlux``: Instrument flux key.
            - ``instFluxErr``: Instrument flux error key.
        """
        instFlux = schema.find(self.config.fluxField).key
        instFluxErr = schema.find(self.config.fluxField + "Err").key
        return pipeBase.Struct(instFlux=instFlux, instFluxErr=instFluxErr)

    @pipeBase.timeMethod
    def extractMagArrays(self, matches, filterName, sourceKeys):
        """!Extract magnitude and magnitude error arrays from the given matches.

        @param[in] matches Reference/source matches, a @link lsst::afw::table::ReferenceMatchVector@endlink
        @param[in] filterName  Name of filter being calibrated
        @param[in] sourceKeys  Struct of source catalog keys, as returned by getSourceKeys()

        @return Struct containing srcMag, refMag, srcMagErr, refMagErr, and magErr numpy arrays
        where magErr is an error in the magnitude; the error in srcMag - refMag
        If nonzero, config.magErrFloor will be added to magErr *only* (not srcMagErr or refMagErr), as
        magErr is what is later used to determine the zero point.
        Struct also contains refFluxFieldList: a list of field names of the reference catalog used for fluxes
        (1 or 2 strings)
        @note These magnitude arrays are the @em inputs to the photometric calibration, some may have been
        discarded by clipping while estimating the calibration (https://jira.lsstcorp.org/browse/DM-813)
        """
        srcInstFluxArr = np.array([m.second.get(sourceKeys.instFlux) for m in matches])
        srcInstFluxErrArr = np.array([m.second.get(sourceKeys.instFluxErr) for m in matches])
        if not np.all(np.isfinite(srcInstFluxErrArr)):
            # this is an unpleasant hack; see DM-2308 requesting a better solution
            self.log.warn("Source catalog does not have flux uncertainties; using sqrt(flux).")
            srcInstFluxErrArr = np.sqrt(srcInstFluxArr)

        # convert source instFlux from DN to an estimate of nJy
        referenceFlux = (0*u.ABmag).to_value(u.nJy)
        srcInstFluxArr = srcInstFluxArr * referenceFlux
        srcInstFluxErrArr = srcInstFluxErrArr * referenceFlux

        if not matches:
            raise RuntimeError("No reference stars are available")
        refSchema = matches[0].first.schema

        applyColorTerms = self.config.applyColorTerms
        applyCTReason = "config.applyColorTerms is %s" % (self.config.applyColorTerms,)
        if self.config.applyColorTerms is None:
            # apply color terms if color term data is available and photoCatName specified
            ctDataAvail = len(self.config.colorterms.data) > 0
            photoCatSpecified = self.config.photoCatName is not None
            applyCTReason += " and data %s available" % ("is" if ctDataAvail else "is not")
            applyCTReason += " and photoRefCat %s provided" % ("is" if photoCatSpecified else "is not")
            applyColorTerms = ctDataAvail and photoCatSpecified

        if applyColorTerms:
            self.log.info("Applying color terms for filterName=%r, config.photoCatName=%s because %s",
                          filterName, self.config.photoCatName, applyCTReason)
            colorterm = self.config.colorterms.getColorterm(
                filterName=filterName, photoCatName=self.config.photoCatName, doRaise=True)
            refCat = afwTable.SimpleCatalog(matches[0].first.schema)

            # extract the matched refCat as a Catalog for the colorterm code
            refCat.reserve(len(matches))
            for x in matches:
                record = refCat.addNew()
                record.assign(x.first)

            refMagArr, refMagErrArr = colorterm.getCorrectedMagnitudes(refCat, filterName)
            fluxFieldList = [getRefFluxField(refSchema, filt) for filt in (colorterm.primary,
                                                                           colorterm.secondary)]
        else:
            # no colorterms to apply
            self.log.info("Not applying color terms because %s", applyCTReason)
            colorterm = None

            fluxFieldList = [getRefFluxField(refSchema, filterName)]
            fluxField = getRefFluxField(refSchema, filterName)
            fluxKey = refSchema.find(fluxField).key
            refFluxArr = np.array([m.first.get(fluxKey) for m in matches])

            try:
                fluxErrKey = refSchema.find(fluxField + "Err").key
                refFluxErrArr = np.array([m.first.get(fluxErrKey) for m in matches])
            except KeyError:
                # Reference catalogue may not have flux uncertainties; HACK DM-2308
                self.log.warn("Reference catalog does not have flux uncertainties for %s; using sqrt(flux).",
                              fluxField)
                refFluxErrArr = np.sqrt(refFluxArr)

            refMagArr = u.Quantity(refFluxArr, u.nJy).to_value(u.ABmag)
            # HACK convert to Jy until we have a replacement for this (DM-16903)
            refMagErrArr = abMagErrFromFluxErr(refFluxErrArr*1e-9, refFluxArr*1e-9)

        # compute the source catalog magnitudes and errors
        srcMagArr = u.Quantity(srcInstFluxArr, u.nJy).to_value(u.ABmag)
        # Fitting with error bars in both axes is hard
        # for now ignore reference flux error, but ticket DM-2308 is a request for a better solution
        # HACK convert to Jy until we have a replacement for this (DM-16903)
        magErrArr = abMagErrFromFluxErr(srcInstFluxErrArr*1e-9, srcInstFluxArr*1e-9)
        if self.config.magErrFloor != 0.0:
            magErrArr = (magErrArr**2 + self.config.magErrFloor**2)**0.5

        srcMagErrArr = abMagErrFromFluxErr(srcInstFluxErrArr*1e-9, srcInstFluxArr*1e-9)

        good = np.isfinite(srcMagArr) & np.isfinite(refMagArr)

        return pipeBase.Struct(
            srcMag=srcMagArr[good],
            refMag=refMagArr[good],
            magErr=magErrArr[good],
            srcMagErr=srcMagErrArr[good],
            refMagErr=refMagErrArr[good],
            refFluxFieldList=fluxFieldList,
        )

    @pipeBase.timeMethod
    def run(self, exposure, sourceCat, expId=0):
        """!Do photometric calibration - select matches to use and (possibly iteratively) compute
        the zero point.

        @param[in]  exposure  Exposure upon which the sources in the matches were detected.
        @param[in]  sourceCat  A catalog of sources to use in the calibration
        (@em i.e. a list of lsst.afw.table.Match with
        @c first being of type lsst.afw.table.SimpleRecord and @c second type lsst.afw.table.SourceRecord ---
        the reference object and matched object respectively).
        (will not be modified  except to set the outputField if requested.).

        @return Struct of:
         - photoCalib -- @link lsst::afw::image::PhotoCalib@endlink object containing the calibration
         - arrays ------ Magnitude arrays returned be PhotoCalTask.extractMagArrays
         - matches ----- Final ReferenceMatchVector, as returned by PhotoCalTask.selectMatches.
         - zp ---------- Photometric zero point (mag)
         - sigma ------- Standard deviation of fit of photometric zero point (mag)
         - ngood ------- Number of sources used to fit photometric zero point

        The exposure is only used to provide the name of the filter being calibrated (it may also be
        used to generate debugging plots).

        The reference objects:
         - Must include a field @c photometric; True for objects which should be considered as
            photometric standards
         - Must include a field @c flux; the flux used to impose a magnitude limit and also to calibrate
            the data to (unless a color term is specified, in which case ColorTerm.primary is used;
            See https://jira.lsstcorp.org/browse/DM-933)
         - May include a field @c stargal; if present, True means that the object is a star
         - May include a field @c var; if present, True means that the object is variable

        The measured sources:
        - Must include PhotoCalConfig.fluxField; the flux measurement to be used for calibration

        @throws RuntimeError with the following strings:

        <DL>
        <DT> No matches to use for photocal
        <DD> No matches are available (perhaps no sources/references were selected by the matcher).
        <DT> No reference stars are available
        <DD> No matches are available from which to extract magnitudes.
        </DL>
        """
        import lsstDebug

        display = lsstDebug.Info(__name__).display
        displaySources = display and lsstDebug.Info(__name__).displaySources
        self.scatterPlot = display and lsstDebug.Info(__name__).scatterPlot

        if self.scatterPlot:
            from matplotlib import pyplot
            try:
                self.fig.clf()
            except Exception:
                self.fig = pyplot.figure()

        filterName = exposure.getFilter().getName()

        # Match sources
        matchResults = self.match.run(sourceCat, filterName)
        matches = matchResults.matches

        reserveResults = self.reserve.run([mm.second for mm in matches], expId=expId)
        if displaySources:
            self.displaySources(exposure, matches, reserveResults.reserved)
        if reserveResults.reserved.sum() > 0:
            matches = [mm for mm, use in zip(matches, reserveResults.use) if use]
        if len(matches) == 0:
            raise RuntimeError("No matches to use for photocal")
        if self.usedKey is not None:
            for mm in matches:
                mm.second.set(self.usedKey, True)

        # Prepare for fitting
        sourceKeys = self.getSourceKeys(matches[0].second.schema)
        arrays = self.extractMagArrays(matches=matches, filterName=filterName, sourceKeys=sourceKeys)

        # Fit for zeropoint
        r = self.getZeroPoint(arrays.srcMag, arrays.refMag, arrays.magErr)
        self.log.info("Magnitude zero point: %f +/- %f from %d stars", r.zp, r.sigma, r.ngood)

        # Prepare the results
        flux0 = 10**(0.4*r.zp)  # Flux of mag=0 star
        flux0err = 0.4*math.log(10)*flux0*r.sigma  # Error in flux0
        photoCalib = makePhotoCalibFromCalibZeroPoint(flux0, flux0err)

        return pipeBase.Struct(
            photoCalib=photoCalib,
            arrays=arrays,
            matches=matches,
            zp=r.zp,
            sigma=r.sigma,
            ngood=r.ngood,
        )

    def displaySources(self, exposure, matches, reserved, frame=1):
        """Display sources we'll use for photocal

        Sources that will be actually used will be green.
        Sources reserved from the fit will be red.

        Parameters
        ----------
        exposure : `lsst.afw.image.ExposureF`
            Exposure to display.
        matches : `list` of `lsst.afw.table.RefMatch`
            Matches used for photocal.
        reserved : `numpy.ndarray` of type `bool`
            Boolean array indicating sources that are reserved.
        frame : `int`
            Frame number for display.
        """
        disp = afwDisplay.getDisplay(frame=frame)
        disp.mtv(exposure, title="photocal")
        with disp.Buffering():
            for mm, rr in zip(matches, reserved):
                x, y = mm.second.getCentroid()
                ctype = afwDisplay.RED if rr else afwDisplay.GREEN
                disp.dot("o", x, y, size=4, ctype=ctype)

    def getZeroPoint(self, src, ref, srcErr=None, zp0=None):
        """!Flux calibration code, returning (ZeroPoint, Distribution Width, Number of stars)

        We perform nIter iterations of a simple sigma-clipping algorithm with a couple of twists:
        1.  We use the median/interquartile range to estimate the position to clip around, and the
        "sigma" to use.
        2.  We never allow sigma to go _above_ a critical value sigmaMax --- if we do, a sufficiently
        large estimate will prevent the clipping from ever taking effect.
        3.  Rather than start with the median we start with a crude mode.  This means that a set of magnitude
        residuals with a tight core and asymmetrical outliers will start in the core.  We use the width of
        this core to set our maximum sigma (see 2.)

        @return Struct of:
         - zp ---------- Photometric zero point (mag)
         - sigma ------- Standard deviation of fit of zero point (mag)
         - ngood ------- Number of sources used to fit zero point
        """
        sigmaMax = self.config.sigmaMax

        dmag = ref - src

        indArr = np.argsort(dmag)
        dmag = dmag[indArr]

        if srcErr is not None:
            dmagErr = srcErr[indArr]
        else:
            dmagErr = np.ones(len(dmag))

        # need to remove nan elements to avoid errors in stats calculation with numpy
        ind_noNan = np.array([i for i in range(len(dmag))
                              if (not np.isnan(dmag[i]) and not np.isnan(dmagErr[i]))])
        dmag = dmag[ind_noNan]
        dmagErr = dmagErr[ind_noNan]

        IQ_TO_STDEV = 0.741301109252802    # 1 sigma in units of interquartile (assume Gaussian)

        npt = len(dmag)
        ngood = npt
        good = None  # set at end of first iteration
        for i in range(self.config.nIter):
            if i > 0:
                npt = sum(good)

            center = None
            if i == 0:
                #
                # Start by finding the mode
                #
                nhist = 20
                try:
                    hist, edges = np.histogram(dmag, nhist, new=True)
                except TypeError:
                    hist, edges = np.histogram(dmag, nhist)  # they removed new=True around numpy 1.5
                imode = np.arange(nhist)[np.where(hist == hist.max())]

                if imode[-1] - imode[0] + 1 == len(imode):  # Multiple modes, but all contiguous
                    if zp0:
                        center = zp0
                    else:
                        center = 0.5*(edges[imode[0]] + edges[imode[-1] + 1])

                    peak = sum(hist[imode])/len(imode)  # peak height

                    # Estimate FWHM of mode
                    j = imode[0]
                    while j >= 0 and hist[j] > 0.5*peak:
                        j -= 1
                    j = max(j, 0)
                    q1 = dmag[sum(hist[range(j)])]

                    j = imode[-1]
                    while j < nhist and hist[j] > 0.5*peak:
                        j += 1
                    j = min(j, nhist - 1)
                    j = min(sum(hist[range(j)]), npt - 1)
                    q3 = dmag[j]

                    if q1 == q3:
                        q1 = dmag[int(0.25*npt)]
                        q3 = dmag[int(0.75*npt)]

                    sig = (q3 - q1)/2.3  # estimate of standard deviation (based on FWHM; 2.358 for Gaussian)

                    if sigmaMax is None:
                        sigmaMax = 2*sig   # upper bound on st. dev. for clipping. multiplier is a heuristic

                    self.log.debug("Photo calibration histogram: center = %.2f, sig = %.2f", center, sig)

                else:
                    if sigmaMax is None:
                        sigmaMax = dmag[-1] - dmag[0]

                    center = np.median(dmag)
                    q1 = dmag[int(0.25*npt)]
                    q3 = dmag[int(0.75*npt)]
                    sig = (q3 - q1)/2.3  # estimate of standard deviation (based on FWHM; 2.358 for Gaussian)

            if center is None:              # usually equivalent to (i > 0)
                gdmag = dmag[good]
                if self.config.useMedian:
                    center = np.median(gdmag)
                else:
                    gdmagErr = dmagErr[good]
                    center = np.average(gdmag, weights=gdmagErr)

                q3 = gdmag[min(int(0.75*npt + 0.5), npt - 1)]
                q1 = gdmag[min(int(0.25*npt + 0.5), npt - 1)]

                sig = IQ_TO_STDEV*(q3 - q1)     # estimate of standard deviation

            good = abs(dmag - center) < self.config.nSigma*min(sig, sigmaMax)  # don't clip too softly

            # =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
            if self.scatterPlot:
                try:
                    self.fig.clf()

                    axes = self.fig.add_axes((0.1, 0.1, 0.85, 0.80))

                    axes.plot(ref[good], dmag[good] - center, "b+")
                    axes.errorbar(ref[good], dmag[good] - center, yerr=dmagErr[good],
                                  linestyle='', color='b')

                    bad = np.logical_not(good)
                    if len(ref[bad]) > 0:
                        axes.plot(ref[bad], dmag[bad] - center, "r+")
                        axes.errorbar(ref[bad], dmag[bad] - center, yerr=dmagErr[bad],
                                      linestyle='', color='r')

                    axes.plot((-100, 100), (0, 0), "g-")
                    for x in (-1, 1):
                        axes.plot((-100, 100), x*0.05*np.ones(2), "g--")

                    axes.set_ylim(-1.1, 1.1)
                    axes.set_xlim(24, 13)
                    axes.set_xlabel("Reference")
                    axes.set_ylabel("Reference - Instrumental")

                    self.fig.show()

                    if self.scatterPlot > 1:
                        reply = None
                        while i == 0 or reply != "c":
                            try:
                                reply = input("Next iteration? [ynhpc] ")
                            except EOFError:
                                reply = "n"

                            if reply == "h":
                                print("Options: c[ontinue] h[elp] n[o] p[db] y[es]", file=sys.stderr)
                                continue

                            if reply in ("", "c", "n", "p", "y"):
                                break
                            else:
                                print("Unrecognised response: %s" % reply, file=sys.stderr)

                        if reply == "n":
                            break
                        elif reply == "p":
                            import pdb
                            pdb.set_trace()
                except Exception as e:
                    print("Error plotting in PhotoCal.getZeroPoint: %s" % e, file=sys.stderr)

            # =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-

            old_ngood = ngood
            ngood = sum(good)
            if ngood == 0:
                msg = "PhotoCal.getZeroPoint: no good stars remain"

                if i == 0:                  # failed the first time round -- probably all fell in one bin
                    center = np.average(dmag, weights=dmagErr)
                    msg += " on first iteration; using average of all calibration stars"

                self.log.warn(msg)

                return pipeBase.Struct(
                    zp=center,
                    sigma=sig,
                    ngood=len(dmag))
            elif ngood == old_ngood:
                break

            if False:
                ref = ref[good]
                dmag = dmag[good]
                dmagErr = dmagErr[good]

        dmag = dmag[good]
        dmagErr = dmagErr[good]
        zp, weightSum = np.average(dmag, weights=1/dmagErr**2, returned=True)
        sigma = np.sqrt(1.0/weightSum)
        return pipeBase.Struct(
            zp=zp,
            sigma=sigma,
            ngood=len(dmag),
        )