{"action":"created","comment":{"url":"https://api.github.com/repos/lsst/ip_diffim/pulls/comments/61020463","id":61020463,"diff_hunk":"@@ -0,0 +1,921 @@\n+from __future__ import absolute_import, division, print_function\n+#\n+# LSST Data Management System\n+# Copyright 2008-2016 AURA/LSST.\n+#\n+# This product includes software developed by the\n+# LSST Project (http://www.lsst.org/).\n+#\n+# This program is free software: you can redistribute it and/or modify\n+# it under the terms of the GNU General Public License as published by\n+# the Free Software Foundation, either version 3 of the License, or\n+# (at your option) any later version.\n+#\n+# This program is distributed in the hope that it will be useful,\n+# but WITHOUT ANY WARRANTY; without even the implied warranty of\n+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n+# GNU General Public License for more details.\n+#\n+# You should have received a copy of the LSST License Statement and\n+# the GNU General Public License along with this program.  If not,\n+# see <https://www.lsstcorp.org/LegalNotices/>.\n+#\n+\n+from collections import namedtuple\n+import numpy as np\n+\n+# LSST imports\n+import lsst.afw.geom as afwGeom\n+import lsst.afw.image as afwImage\n+import lsst.meas.base as measBase\n+import lsst.afw.table as afwTable\n+import lsst.afw.detection as afwDet\n+import lsst.pex.exceptions as pexExcept\n+import lsst.pex.logging as pexLog\n+import lsst.pex.config as pexConfig\n+\n+__all__ = (\"DipoleFitPluginConfig\", \"DipoleFitTask\", \"DipoleFitPlugin\",\n+           \"DipoleFitAlgorithm\")\n+\n+\n+# Create a new measurement task (`DipoleFitTask`) that can handle all other SFM tasks but can\n+# pass a separate pos- and neg- exposure/image to the `DipoleFitPlugin`s `run()` method.\n+\n+\n+class DipoleFitPluginConfig(measBase.SingleFramePluginConfig):\n+    \"\"\"\n+    Class to initialize and store dipole fitting configuration parameters\n+    \"\"\"\n+\n+    maxSeparation = pexConfig.Field(\n+        dtype=float, default=5.,\n+        doc=\"Assume dipole is not separated by more than maxSeparation * psfSigma\")\n+\n+    relWeight = pexConfig.Field(\n+        dtype=float, default=0.5,\n+        doc=\"\"\"Relative weighting of pre-subtraction images (higher -> greater influence of pre-sub.\n+        images on fit)\"\"\")\n+\n+    tolerance = pexConfig.Field(\n+        dtype=float, default=1e-7,\n+        doc=\"Fit tolerance\")\n+\n+    fitBgGradient = pexConfig.Field(\n+        dtype=bool, default=True,\n+        doc=\"Include parameters to fit for linear gradient in pre-sub. images\")\n+\n+    fitSeparateNegParams = pexConfig.Field(\n+        dtype=bool, default=False,\n+        doc=\"Include parameters to fit for negative values (flux, gradient) separately from pos.\")\n+\n+    # Config params for classification of detected diaSources as dipole or not\n+    minSn = pexConfig.Field(\n+        dtype=float, default=np.sqrt(2) * 5.0,\n+        doc=\"Minimum quadrature sum of positive+negative lobe S/N to be considered a dipole\")\n+\n+    maxFluxRatio = pexConfig.Field(\n+        dtype=float, default=0.65,\n+        doc=\"Maximum flux ratio in either lobe to be considered a dipole\")\n+\n+    # Choose a maxChi2DoF corresponding to a significance level of at most 0.05\n+    # (note this is actually a significance not a chi2 number)\n+    maxChi2DoF = pexConfig.Field(\n+        dtype=float, default=0.05,\n+        doc=\"Maximum Chi2/DoF significance of fit to be considered a dipole.\")\n+\n+    verbose = pexConfig.Field(\n+        dtype=bool, default=False,\n+        doc=\"Be verbose; this is slow\")\n+\n+\n+class DipoleFitTask(measBase.SingleFrameMeasurementTask):\n+    \"\"\"This is a subclass of SingleFrameMeasurementTask which can accept\n+    three input images in its run() method. Because it subclasses\n+    SingleFrameMeasurementTask, and calls\n+    SingleFrameMeasurementTask.run() from its run() method, it still\n+    can be used identically to a standard SingleFrameMeasurementTask.\n+    \"\"\"\n+\n+    ConfigClass = DipoleFitPluginConfig\n+    _DefaultName = \"ip_diffim_DipoleFit\"\n+\n+    def __init__(self, schema, algMetadata=None, dpFitPluginConfig=None, **kwds):\n+\n+        measBase.SingleFrameMeasurementTask.__init__(self, schema, algMetadata, **kwds)\n+\n+        if dpFitPluginConfig is None:\n+            dpFitPluginConfig = DipoleFitPluginConfig()\n+        self.dipoleFitter = DipoleFitPlugin(dpFitPluginConfig, name=self._DefaultName,\n+                                            schema=schema, metadata=algMetadata)\n+\n+    def run(self, sources, exposure, posImage=None, negImage=None, **kwds):\n+        \"\"\"!Run dipole measurement and classification\n+\n+        @param sources       diaSources that will be measured using dipole measurement\n+        @param exposure      Exposure on which the diaSources were detected\n+        @param posImage      \"Positive\" exposure from which the template was subtracted\n+        @param negImage      \"Negative\" exposure which was subtracted from the posImage\n+        @param **kwds        Sent to SingleFrameMeasurementTask\n+        \"\"\"\n+\n+        measBase.SingleFrameMeasurementTask.run(self, sources, exposure, **kwds)\n+\n+        if not sources:\n+            return\n+\n+        for source in sources:\n+            self.dipoleFitter.measure(source, exposure, posImage, negImage)\n+\n+\n+@measBase.register(\"ip_diffim_DipoleFit\")\n+class DipoleFitPlugin(measBase.SingleFramePlugin):\n+    \"\"\"Subclass of SingleFramePlugin which can accept three input images\n+    in its measure() method and fits dipoles to all merged (two-peak)\n+    footprints in a diffim. If provided, it includes data from the\n+    pre-subtraction posImage (science image) and optionally negImage\n+    (template image) to constrain the fit. The meat of the fitting\n+    routines are in the class DipoleFitAlgorithm.\n+\n+    The motivation behind this plugin and the necessity for including more than\n+    one exposure are documented in DMTN-007 (http://dmtn-007.readthedocs.org).\n+\n+    This class is named ip_diffim_DipoleFit so that it may be used alongside\n+    the existing ip_diffim_DipoleMeasurement classes until such a time as those\n+    are deemed to be replaceable by this.\n+    \"\"\"\n+\n+    ConfigClass = DipoleFitPluginConfig\n+\n+    FAILURE_EDGE = 1   # too close to the edge\n+    FAILURE_FIT = 2    # failure in the fitting\n+    FAILURE_NOT_DIPOLE = 4  # input source is not a putative dipole to begin with\n+\n+    @classmethod\n+    def getExecutionOrder(cls):\n+        # algorithms that require both getShape() and getCentroid(),\n+        # in addition to a Footprint and its Peaks\n+        return cls.FLUX_ORDER\n+\n+    def __init__(self, config, name, schema, metadata):\n+        measBase.SingleFramePlugin.__init__(self, config, name, schema, metadata)\n+\n+        self.log = pexLog.Log(pexLog.Log.getDefaultLog(),\n+                              'lsst.ip.diffim.DipoleFitPlugin', pexLog.Log.INFO)\n+\n+        self._setupSchema(config, name, schema, metadata)\n+\n+    def _setupSchema(self, config, name, schema, metadata):\n+        # Get a FunctorKey that can quickly look up the \"blessed\" centroid value.\n+        self.centroidKey = afwTable.Point2DKey(schema[\"slot_Centroid\"])\n+\n+        # Add some fields for our outputs, and save their Keys.\n+        # Use setattr() to programmatically set the pos/neg named attributes to values, e.g.\n+        # self.posCentroidKeyX = 'ip_diffim_DipoleFit_pos_centroid_x'\n+\n+        for pos_neg in ['pos', 'neg']:\n+\n+            key = schema.addField(\n+                schema.join(name, pos_neg, \"flux\"), type=float, units=\"dn\",\n+                doc=\"Dipole {0} lobe flux\".format(pos_neg))\n+            setattr(self, ''.join((pos_neg, 'FluxKey')), key)\n+\n+            key = schema.addField(\n+                schema.join(name, pos_neg, \"fluxSigma\"), type=float, units=\"pixels\",\n+                doc=\"1-sigma uncertainty for {0} dipole flux\".format(pos_neg))\n+            setattr(self, ''.join((pos_neg, 'FluxSigmaKey')), key)\n+\n+            for x_y in ['x', 'y']:\n+                key = schema.addField(\n+                    schema.join(name, pos_neg, \"centroid\", x_y), type=float, units=\"pixels\",\n+                    doc=\"Dipole {0} lobe centroid\".format(pos_neg))\n+                setattr(self, ''.join((pos_neg, 'CentroidKey', x_y.upper())), key)\n+\n+        for x_y in ['x', 'y']:\n+            key = schema.addField(\n+                schema.join(name, \"centroid\", x_y), type=float, units=\"pixels\",\n+                doc=\"Dipole centroid\")\n+            setattr(self, ''.join(('centroidKey', x_y.upper())), key)\n+\n+        self.fluxKey = schema.addField(\n+            schema.join(name, \"flux\"), type=float, units=\"dn\",\n+            doc=\"Dipole overall flux\")\n+\n+        self.orientationKey = schema.addField(\n+            schema.join(name, \"orientation\"), type=float, units=\"deg\",\n+            doc=\"Dipole orientation\")\n+\n+        self.separationKey = schema.addField(\n+            schema.join(name, \"separation\"), type=float, units=\"pixels\",\n+            doc=\"Pixel separation between positive and negative lobes of dipole\")\n+\n+        self.chi2dofKey = schema.addField(\n+            schema.join(name, \"chi2dof\"), type=float,\n+            doc=\"Chi2 per degree of freedom of dipole fit\")\n+\n+        self.signalToNoiseKey = schema.addField(\n+            schema.join(name, \"signalToNoise\"), type=float,\n+            doc=\"Estimated signal-to-noise of dipole fit\")\n+\n+        self.classificationFlagKey = schema.addField(\n+            schema.join(name, \"flag\", \"classification\"), type=\"Flag\",\n+            doc=\"Flag indicating diaSource is classified as a dipole\")\n+\n+        self.flagKey = schema.addField(\n+            schema.join(name, \"flag\"), type=\"Flag\",\n+            doc=\"General failure flag for dipole fit\")\n+\n+        self.edgeFlagKey = schema.addField(\n+            schema.join(name, \"flag\", \"edge\"), type=\"Flag\",\n+            doc=\"Flag set when dipole is too close to edge of image\")\n+\n+    def measure(self, measRecord, exposure, posImage=None, negImage=None):\n+        \"\"\"Perform the non-linear least squares minimization.\n+\n+        The main functionality of this routine was placed outside of\n+        this plugin (into `DipoleFitAlgorithm.fitDipole()`) so that\n+        `DipoleFitAlgorithm.fitDipole()` can be called separately for\n+        testing (see `tests/testDipoleFitter.py`)\n+        \"\"\"\n+\n+        pks = measRecord.getFootprint().getPeaks()\n+        if len(pks) <= 1:  # not a dipole for our analysis\n+            self.fail(measRecord, measBase.MeasurementError('not a dipole', self.FAILURE_NOT_DIPOLE))\n+\n+        result = None\n+        try:\n+            alg = DipoleFitAlgorithm(exposure, posImage=posImage, negImage=negImage)\n+            result = alg.fitDipole(\n+                measRecord, rel_weight=self.config.relWeight,\n+                tol=self.config.tolerance,\n+                maxSepInSigma=self.config.maxSeparation,\n+                fitBgGradient=self.config.fitBgGradient,\n+                separateNegParams=self.config.fitSeparateNegParams,\n+                verbose=self.config.verbose, display=False)\n+        except pexExcept.LengthError:\n+            self.fail(measRecord, measBase.MeasurementError('edge failure', self.FAILURE_EDGE))\n+        except Exception:\n+            self.fail(measRecord, measBase.MeasurementError('dipole fit failure', self.FAILURE_FIT))\n+\n+        if result is None:\n+            return result\n+\n+        self.log.log(self.log.DEBUG, \"Dipole fit result: %s\" % str(result))\n+\n+        if result.psfFitPosFlux <= 1.:   # usually around 0.1 -- the minimum flux allowed -- i.e. bad fit.\n+            self.fail(measRecord, measBase.MeasurementError('dipole fit failure', self.FAILURE_FIT))\n+\n+        # add chi2, coord/flux uncertainties (TBD), dipole classification\n+\n+        # Add the relevant values to the measRecord\n+        measRecord[self.posFluxKey] = result.psfFitPosFlux\n+        measRecord[self.posFluxSigmaKey] = result.psfFitSignaltoNoise   # to be changed to actual sigma!\n+        measRecord[self.posCentroidKeyX] = result.psfFitPosCentroidX\n+        measRecord[self.posCentroidKeyY] = result.psfFitPosCentroidY\n+\n+        measRecord[self.negFluxKey] = result.psfFitNegFlux\n+        measRecord[self.negFluxSigmaKey] = result.psfFitSignaltoNoise   # to be changed to actual sigma!\n+        measRecord[self.negCentroidKeyX] = result.psfFitNegCentroidX\n+        measRecord[self.negCentroidKeyY] = result.psfFitNegCentroidY\n+\n+        # Dia source flux: average of pos+neg\n+        measRecord[self.fluxKey] = (abs(result.psfFitPosFlux) + abs(result.psfFitNegFlux))/2.\n+        measRecord[self.orientationKey] = result.psfFitOrientation\n+        measRecord[self.separationKey] = np.sqrt((result.psfFitPosCentroidX - result.psfFitNegCentroidX)**2. +\n+                                                 (result.psfFitPosCentroidY - result.psfFitNegCentroidY)**2.)\n+        measRecord[self.centroidKeyX] = (result.psfFitPosCentroidX + result.psfFitNegCentroidX)/2.\n+        measRecord[self.centroidKeyY] = (result.psfFitPosCentroidY + result.psfFitNegCentroidY)/2.\n+\n+        measRecord[self.signalToNoiseKey] = result.psfFitSignaltoNoise\n+        measRecord[self.chi2dofKey] = result.psfFitRedChi2\n+\n+        self.doClassify(measRecord, result)\n+\n+    def doClassify(self, measRecord, result):\n+        \"\"\"Determine if source is classified as dipole (similar to\n+        orig. dipole classification task).\n+        \"\"\"\n+\n+        # First, does the total signal-to-noise surpass the minSn?\n+        passesSn = measRecord[self.signalToNoiseKey] > self.config.minSn\n+\n+        # Second, are the pos/neg fluxes less than 1.0 and no more than 0.65 of the total flux?\n+        # By default this will never happen since posFlux = negFlux.\n+        passesFluxPos = (abs(measRecord[self.posFluxKey]) /\n+                         (measRecord[self.fluxKey]*2.)) < self.config.maxFluxRatio\n+        passesFluxPos &= (abs(measRecord[self.posFluxKey]) >= 1.0)\n+        passesFluxNeg = (abs(measRecord[self.negFluxKey]) /\n+                         (measRecord[self.fluxKey]*2.)) < self.config.maxFluxRatio\n+        passesFluxNeg &= (abs(measRecord[self.negFluxKey]) >= 1.0)\n+        allPass = (passesSn and passesFluxPos and passesFluxNeg)  # and passesChi2)\n+\n+        # Third, is it a good fit (chi2dof < 1)?\n+        # Use scipy's chi2 cumulative distrib to estimate significance\n+        # This doesn't really work since I don't trust the values in the variance plane (which\n+        #   affects the least-sq weights, which affects the resulting chi2).\n+        # But I'm going to keep this here for future use.\n+        if False:\n+            from scipy.stats import chi2\n+            ndof = result.psfFitChi2 / measRecord[self.chi2dofKey]\n+            significance = chi2.cdf(result.psfFitChi2, ndof)\n+            passesChi2 = significance < self.config.maxChi2DoF\n+            allPass = allPass and passesChi2\n+\n+        if allPass:  # Note cannot pass `allPass` into the `measRecord.set()` call below...?\n+            measRecord.set(self.classificationFlagKey, True)\n+        else:\n+            measRecord.set(self.classificationFlagKey, False)\n+\n+    # TBD: need to catch more exceptions, set correct flags.\n+    def fail(self, measRecord, error=None):\n+        measRecord.set(self.flagKey, True)\n+        if error is not None:\n+            if error.getFlagBit() == self.FAILURE_EDGE:\n+                measRecord.set(self.edgeFlagKey, True)\n+            if error.getFlagBit() == self.FAILURE_FIT:\n+                measRecord.set(self.flagKey, True)\n+            if error.getFlagBit() == self.FAILURE_NOT_DIPOLE:\n+                measRecord.set(self.flagKey, True)\n+\n+\n+class DipoleFitAlgorithm(object):\n+    \"\"\"Lightweight class containing methods for fitting dipoles in\n+    diffims, used by DipoleFitPlugin.  This code is documented in\n+    DMTN-007.\n+\n+    Below is a (somewhat incomplete) list of improvements\n+    that would be worth investigating, given the time:\n+\n+    1. evaluate necessity for separate parameters for pos- and neg- images\n+    2. only fit background OUTSIDE footprint (DONE) and dipole params INSIDE footprint (NOT DONE)?\n+    3. correct normalization of least-squares weights based on variance planes\n+    4. account for PSFs that vary across the exposures (should be happening by default?)\n+    5. correctly account for NA/masks  (i.e., ignore!)\n+    6. better exception handling in the plugin\n+    7. better classification of dipoles (e.g. by comparing chi2 fit vs. monopole?)\n+    8. (DONE) Initial fast estimate of background gradient(s) params -- perhaps using numpy.lstsq\n+    9. (NOT NEEDED - see (1)) Initial fast test whether a background gradient needs to be fit\n+    10. (DONE) better initial estimate for flux when there's a strong gradient\n+    11. (DONE) requires a new package `lmfit` -- investiate others? (astropy/scipy/iminuit?)\n+    \"\"\"\n+\n+    # This is just a private version number to sync with the ipython notebooks that I have been\n+    # using for algorithm development.\n+    _private_version_ = '0.0.4'\n+\n+    # Create a namedtuple to hold all of the relevant output from the lmfit results\n+    resultsOutput = namedtuple('resultsOutput',\n+                               ['psfFitPosCentroidX', 'psfFitPosCentroidY',\n+                                'psfFitNegCentroidX', 'psfFitNegCentroidY', 'psfFitPosFlux',\n+                                'psfFitNegFlux', 'psfFitPosFluxSigma', 'psfFitNegFluxSigma',\n+                                'psfFitCentroidX', 'psfFitCentroidY', 'psfFitOrientation',\n+                                'psfFitSignaltoNoise', 'psfFitChi2', 'psfFitRedChi2'])\n+\n+    def __init__(self, diffim, posImage=None, negImage=None):\n+        \"\"\"Algorithm to run dipole measurement on a diaSource\n+        @param diffim Exposure on which the diaSources were detected\n+        @param posImage \"Positive\" exposure from which the template was subtracted\n+        @param negImage \"Negative\" exposure which was subtracted from the posImage\n+        \"\"\"\n+\n+        self.diffim = diffim\n+        self.posImage = posImage\n+        self.negImage = negImage\n+        self.psfSigma = None\n+        if diffim is not None:\n+            self.psfSigma = diffim.getPsf().computeShape().getDeterminantRadius()\n+        self.log = pexLog.Log(pexLog.Log.getDefaultLog(),\n+                              'lsst.ip.diffim.DipoleFitAlgorithm', pexLog.Log.INFO)\n+\n+        import lsstDebug\n+        self.debug = lsstDebug.Info(__name__).debug\n+\n+    def genBgGradientModel(self, in_x, pars=()):\n+        \"\"\"Generate gradient model (2-d array) with up to 2nd-order polynomial\n+        @param in_x Provides the input x,y grid upon which to compute the gradient\n+        @param pars Up to 6 floats for up to 6 2nd-order 2-d polynomial gradient parameters.\n+        If emtpy, do nothing (for speed).\n+\n+        @return None, or 2-d numpy.array of width/height matching\n+        input bbox, containing computed gradient values.\n+        \"\"\"\n+\n+        gradient = None\n+\n+        # Don't fit for other gradient parameters if the intercept is not allowed.\n+        if len(pars) > 0 and pars[0] is not None:\n+            y, x = in_x[0, :], in_x[1, :]\n+            gradient = np.full_like(x, pars[0], dtype='float64')\n+            if len(pars) > 1 and pars[1] is not None:\n+                gradient += pars[1] * x\n+            if len(pars) > 2 and pars[2] is not None:\n+                gradient += pars[2] * y\n+            if len(pars) > 3 and pars[3] is not None:\n+                gradient += pars[3] * (x * y)\n+            if len(pars) > 4 and pars[4] is not None:\n+                gradient += pars[4] * (x * x)\n+            if len(pars) > 5 and pars[5] is not None:\n+                gradient += pars[5] * (y * y)\n+        return gradient\n+\n+    def _generateXYGrid(self, bbox):\n+        x, y = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]\n+        in_x = np.array([y, x]).astype(np.float64)\n+        in_x[0, :] -= np.mean(in_x[0, :])\n+        in_x[1, :] -= np.mean(in_x[1, :])\n+        return in_x\n+\n+    def fitBackgroundGradient(self, source, posImage, order=1):\n+        \"\"\" Fit a linear (polynomial) model of given order (max 2) to the background of a footprint.\n+        Only fit the pixels OUTSIDE of the footprint, but within its bounding box.\n+        TBD: look into whether afw_math background stuff -- see\n+        http://lsst-web.ncsa.illinois.edu/doxygen/x_masterDoxyDoc/_background_example.html\n+        \"\"\"\n+        from . import utils as ipUtils\n+\n+        fp = source.getFootprint()\n+        bbox = fp.getBBox()\n+        bbox.grow(3)\n+        posImg = afwImage.ImageF(posImage.getMaskedImage().getImage(), bbox, afwImage.PARENT)\n+\n+        # This code constructs the footprint image so that we can identify the pixels that are\n+        # outside the footprint (but within the bounding box). These are the pixels used for\n+        # fitting the background.\n+        posHfp = afwDet.HeavyFootprintF(fp, posImage.getMaskedImage())\n+        posFpImg = ipUtils.getHeavyFootprintSubimage(posHfp, grow=3)\n+\n+        isBg = np.isnan(posFpImg.getArray()).ravel()\n+\n+        data = posImg.getArray().ravel()\n+        data = data[isBg]\n+        B = data\n+\n+        x, y = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]\n+        x = x.astype(np.float64).ravel()\n+        x -= np.mean(x)\n+        x = x[isBg]\n+        y = y.astype(np.float64).ravel()\n+        y -= np.mean(y)\n+        y = y[isBg]\n+        b = np.ones_like(x, dtype=np.float64)\n+\n+        M = np.vstack([b]).T  # order = 0\n+        if order == 1:\n+            M = np.vstack([b, x, y]).T\n+        elif order == 2:\n+            M = np.vstack([b, x, y, x**2., y**2., x*y]).T\n+\n+        pars = np.linalg.lstsq(M, B)[0]\n+        return pars\n+\n+    def genStarModel(self, bbox, psf, xcen, ycen, flux):\n+        \"\"\"Generate model (2-d array) of a 'star' (single PSF) centered at given coordinates\n+\n+        @param bbox Bounding box marking pixel coordinates for generated model\n+        @param psf Psf model used to generate the 'star'\n+        @param xcen Desired x-centroid of the 'star'\n+        @param ycen Desired y-centroid of the 'star'\n+        @param flux Desired flux of the 'star'\n+\n+        @return 2-d numpy.array of width/height matching input bbox,\n+        containing PSF with given centroid and flux\n+        \"\"\"\n+\n+        # Generate the psf image, normalize to flux\n+        psf_img = psf.computeImage(afwGeom.Point2D(xcen, ycen)).convertF()\n+        psf_img_sum = np.nansum(psf_img.getArray())\n+        psf_img *= (flux/psf_img_sum)\n+\n+        # Clip the PSF image bounding box to fall within the footprint bounding box\n+        psf_box = psf_img.getBBox()\n+        psf_box.clip(bbox)\n+        psf_img = afwImage.ImageF(psf_img, psf_box, afwImage.PARENT)\n+\n+        # Then actually crop the psf image.\n+        # Usually not necessary, but if the dipole is near the edge of the image...\n+        # Would be nice if we could compare original pos_box with clipped pos_box and\n+        #     see if it actually was clipped.\n+        p_Im = afwImage.ImageF(bbox)\n+        tmpSubim = afwImage.ImageF(p_Im, psf_box, afwImage.PARENT)\n+        tmpSubim += psf_img\n+\n+        return p_Im\n+\n+    @staticmethod\n+    def genDipoleModel(x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=None,\n+                       b=None, x1=None, y1=None, xy=None, x2=None, y2=None,\n+                       bNeg=None, x1Neg=None, y1Neg=None, xyNeg=None, x2Neg=None, y2Neg=None,\n+                       **kwargs):\n+        \"\"\"Generate dipole model with given parameters.\n+\n+        This is the functor whose sum-of-squared difference from data\n+        is minimized by `lmfit`. Thus it must be static. However, it\n+        just defers to `self.genDipoleModelImpl()`, where `self` comes\n+        out of kwargs['myself'].\n+\n+        @see genDipoleModelImpl\n+        \"\"\"\n+        algObject = kwargs.pop('algObject')\n+        return algObject.genDipoleModelImpl(x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=fluxNeg,\n+                                            b=b, x1=x1, y1=y1, xy=xy, x2=x2, y2=y2,\n+                                            bNeg=bNeg, x1Neg=x1Neg, y1Neg=y1Neg, xyNeg=xyNeg,\n+                                            x2Neg=x2Neg, y2Neg=y2Neg, **kwargs)\n+\n+    def genDipoleModelImpl(self, x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=None,\n+                           b=None, x1=None, y1=None, xy=None, x2=None, y2=None,\n+                           bNeg=None, x1Neg=None, y1Neg=None, xyNeg=None, x2Neg=None, y2Neg=None,\n+                           **kwargs):\n+\n+        \"\"\"Generate dipole model with given parameters. This is the functor\n+        whose sum-of-squared difference from data is minimized by\n+        `lmfit`.\n+\n+        @param x Input independent variable. Used here as the grid on\n+        which to compute the background gradient model.\n+        @param flux Desired flux of the positive lobe of the dipole\n+        @param xcenPos Desired x-centroid of the positive lobe of the dipole\n+        @param ycenPos Desired y-centroid of the positive lobe of the dipole\n+        @param xcenNeg Desired x-centroid of the negative lobe of the dipole\n+        @param ycenNeg Desired y-centroid of the negative lobe of the dipole\n+        @param fluxNeg Desired flux of the negative lobe of the dipole, set to 'flux' if None\n+        @param b, x1, y1, xy, x2, y2 Gradient parameters for positive lobe.\n+        @see `genBgGradientModel`.\n+        @param bNeg, x1Neg, y1Neg, xyNeg, x2Neg, y2Neg Gradient\n+        parameters for negative lobe.  @see `genBgGradientModel`. Set\n+        to the corresponding positive values if None.\n+\n+        @param **kwargs Keyword arguments passed through `lmfit` and\n+        used by this functor. These must include:\n+           @param psf Psf model used to generate the 'star'\n+           @param rel_weight Used to signify if positive/negative images are to be\n+           included (!= 0. if yes)\n+           @param bbox Bounding box containing region to be modelled\n+\n+        @return numpy.array of width/height matching input bbox,\n+        containing dipole model with given centroids and flux(es). If\n+        rel_weight = 0., this is a 2-d array with dimensions matching\n+        those of bbox; otherwise a stack of three such arrays,\n+        representing the dipole (diffim), positive and negative images\n+        respectively.\n+        \"\"\"\n+\n+        psf = kwargs.get('psf')\n+        rel_weight = kwargs.get('rel_weight')  # if > 0, we're including pre-sub. images\n+        fp = kwargs.get('footprint')\n+        bbox = fp.getBBox()\n+\n+        if fluxNeg is None:\n+            fluxNeg = flux\n+\n+        if self.debug:\n+            self.log.log(self.log.DEBUG, '%.2f %.2f %.2f %.2f %.2f %.2f' %\n+                         (flux, fluxNeg, xcenPos, ycenPos, xcenNeg, ycenNeg))\n+            if x1 is not None:\n+                self.log.log(self.log.DEBUG, '     %.2f %.2f %.2f' % (b, x1, y1))\n+            if xy is not None:\n+                self.log.log(self.log.DEBUG, '     %.2f %.2f %.2f' % (xy, x2, y2))\n+\n+        posIm = self.genStarModel(bbox, psf, xcenPos, ycenPos, flux)\n+        negIm = self.genStarModel(bbox, psf, xcenNeg, ycenNeg, fluxNeg)\n+\n+        in_x = x\n+        if in_x is None:  # use the footprint to generate the input grid\n+            y, x = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]\n+            in_x = np.array([x, y]) * 1.\n+            in_x[0, :] -= in_x[0, :].mean()  # center it!\n+            in_x[1, :] -= in_x[1, :].mean()\n+\n+        gradient = self.genBgGradientModel(in_x, (b, x1, y1, xy, x2, y2))\n+        gradientNeg = gradient\n+        if bNeg is not None:\n+            gradientNeg = self.genBgGradientModel(in_x, (bNeg, x1Neg, y1Neg, xyNeg, x2Neg, y2Neg))\n+\n+        if gradient is not None:\n+            posIm.getArray()[:, :] += gradient\n+            negIm.getArray()[:, :] += gradientNeg\n+\n+        # Generate the diffIm model\n+        diffIm = afwImage.ImageF(bbox)\n+        diffIm += posIm\n+        diffIm -= negIm\n+\n+        zout = diffIm.getArray()\n+        if rel_weight > 0.:\n+            zout = np.append([zout], [posIm.getArray(), negIm.getArray()], axis=0)\n+\n+        return zout\n+\n+    def fitDipoleImpl(self, source, tol=1e-7, rel_weight=0.5,\n+                      fitBgGradient=True, bgGradientOrder=1, maxSepInSigma=5.,\n+                      separateNegParams=True, verbose=False, display=False):\n+        \"\"\"Fit a dipole model to an input difference image (actually,\n+        subimage bounded by the input source's footprint) and\n+        optionally constrain the fit using the pre-subtraction images\n+        posImage and negImage.\n+\n+        @see `fitDipole()`\n+        @return `lmfit.MinimizerResult` object containing the fit\n+        parameters and other information.\n+        \"\"\"\n+\n+        # Only import lmfit if someone wants to use the new DipoleFitAlgorithm.\n+        import lmfit\n+\n+        fp = source.getFootprint()\n+        bbox = fp.getBBox()\n+        subim = afwImage.MaskedImageF(self.diffim.getMaskedImage(), bbox, afwImage.PARENT)\n+\n+        z = diArr = subim.getArrays()[0]\n+        weights = 1. / subim.getArrays()[2]  # get the weights (=1/variance)\n+        if self.posImage is not None and rel_weight > 0.:\n+            posSubim = afwImage.MaskedImageF(self.posImage.getMaskedImage(), bbox, afwImage.PARENT)\n+            if self.negImage is not None:\n+                negSubim = afwImage.MaskedImageF(self.negImage.getMaskedImage(), bbox, afwImage.PARENT)\n+            else:  # no template provided; generate it from the obs. and diffim\n+                negSubim = posSubim.clone()\n+                negSubim -= subim\n+            z = np.append([z], [posSubim.getArrays()[0],\n+                                negSubim.getArrays()[0]], axis=0)\n+            # Weight the pos/neg images by rel_weight relative to the diffim\n+            weights = np.append([weights], [1. / posSubim.getArrays()[2] * rel_weight,\n+                                            1. / negSubim.getArrays()[2] * rel_weight], axis=0)\n+        else:\n+            rel_weight = 0.  # a short-cut for \"don't include the pre-subtraction data\"\n+\n+        # Create the lmfit model (lmfit uses scipy 'leastsq' option by default - Levenberg-Marquardt)\n+        gmod = lmfit.Model(DipoleFitAlgorithm.genDipoleModel, verbose=verbose)\n+\n+        # Add the constraints for centroids, fluxes.\n+        # starting constraint - near centroid of footprint\n+        fpCentroid = np.array([fp.getCentroid().getX(), fp.getCentroid().getY()])\n+        cenNeg = cenPos = fpCentroid\n+\n+        pks = fp.getPeaks()\n+        if len(pks) >= 1:\n+            cenPos = pks[0].getF()    # if individual (merged) peaks were detected, use those\n+        if len(pks) >= 2:\n+            cenNeg = pks[1].getF()\n+\n+        # For close/faint dipoles the starting locs (min/max) might be way off, let's help them a bit.\n+        # First assume dipole is not separated by more than 5*psfSigma.\n+        maxSep = self.psfSigma * maxSepInSigma\n+\n+        # Note - this may be a cheat to assume the dipole is centered in center of the footprint.\n+        if np.sum(np.sqrt((np.array(cenPos) - fpCentroid)**2.)) > maxSep:\n+            cenPos = fpCentroid\n+        if np.sum(np.sqrt((np.array(cenNeg) - fpCentroid)**2.)) > maxSep:\n+            cenPos = fpCentroid\n+\n+        # parameter hints/constraints: https://lmfit.github.io/lmfit-py/model.html#model-param-hints-section\n+        # might make sense to not use bounds -- see http://lmfit.github.io/lmfit-py/bounds.html\n+        # also see this discussion -- https://github.com/scipy/scipy/issues/3129\n+        gmod.set_param_hint('xcenPos', value=cenPos[0],\n+                            min=cenPos[0]-maxSep, max=cenPos[0]+maxSep)\n+        gmod.set_param_hint('ycenPos', value=cenPos[1],\n+                            min=cenPos[1]-maxSep, max=cenPos[1]+maxSep)\n+        gmod.set_param_hint('xcenNeg', value=cenNeg[0],\n+                            min=cenNeg[0]-maxSep, max=cenNeg[0]+maxSep)\n+        gmod.set_param_hint('ycenNeg', value=cenNeg[1],\n+                            min=cenNeg[1]-maxSep, max=cenNeg[1]+maxSep)\n+\n+        # Estimate starting flux. This strongly affects runtime performance so we want to make it close.\n+        if False:\n+            # Value to convert peak value to total flux based on flux within psf\n+            psfImg = self.diffim.getPsf().computeImage()\n+            pkToFlux = np.nansum(psfImg.getArray()) / self.diffim.getPsf().computePeak()\n+\n+            bg = np.nanmedian(diArr)  # Compute the dipole background (probably very close to zero)\n+            startingPk = np.nanmax(diArr) - bg   # use the dipole peak for an estimate.\n+            posFlux, negFlux = startingPk * pkToFlux, -startingPk * pkToFlux\n+\n+            if len(pks) >= 1:\n+                posFlux = pks[0].getPeakValue() * pkToFlux\n+            if len(pks) >= 2:\n+                negFlux = pks[1].getPeakValue() * pkToFlux\n+\n+        # Use the (flux under the dipole)*5 for an estimate.\n+        # Lots of testing showed that having startingFlux be too high was better than too low.\n+        startingFlux = np.nansum(np.abs(diArr) - np.nanmedian(np.abs(diArr))) * 5.\n+        posFlux = negFlux = startingFlux\n+\n+        # TBD: set max. flux limit?\n+        gmod.set_param_hint('flux', value=posFlux, min=0.1)\n+\n+        if separateNegParams:\n+            # TBD: set max negative lobe flux limit?\n+            gmod.set_param_hint('fluxNeg', value=np.abs(negFlux), min=0.1)\n+\n+        # Fixed parameters (dont fit for them if there are no pre-sub images or no gradient fit requested):\n+        # Right now, we use the linear model to fit the background and initialize the parameters.\n+        # It might be better to just subtract the linear model from the data and then don't fit the background\n+        # again.\n+        bgParsPos = bgParsNeg = (0., 0., 0.)\n+        if (rel_weight > 0. and fitBgGradient and bgGradientOrder >= 0):\n+            pbg = 0.\n+            # Fit the gradient to the background (linear model)\n+            bgParsPos = bgParsNeg = self.fitBackgroundGradient(source, self.posImage,\n+                                                               order=bgGradientOrder)\n+            # Generate the gradient and subtract it from the pre-subtraction image data\n+            in_x = self._generateXYGrid(bbox)\n+            pbg = self.genBgGradientModel(in_x, tuple(bgParsPos))\n+            z[1, :] -= pbg\n+            z[1, :] -= np.nanmedian(z[1, :])\n+            posFlux = np.nansum(z[1, :])\n+            gmod.set_param_hint('flux', value=posFlux*1.5, min=0.1)\n+\n+            if separateNegParams and self.negImage is not None:\n+                bgParsNeg = self.fitBackgroundGradient(source, self.negImage, order=bgGradientOrder)\n+                pbg = self.genBgGradientModel(in_x, tuple(bgParsNeg))\n+            z[2, :] -= pbg\n+            z[2, :] -= np.nanmedian(z[2, :])\n+            if separateNegParams:\n+                negFlux = np.nansum(z[2, :])\n+                gmod.set_param_hint('fluxNeg', value=negFlux*1.5, min=0.1)\n+\n+            if False:  # we have subtracted the background from the images so dont fit anymore (faster!)\n+                if bgGradientOrder >= 0:\n+                    gmod.set_param_hint('b', value=bgParsPos[0])\n+                    if separateNegParams:\n+                        gmod.set_param_hint('bNeg', value=bgParsNeg[0])\n+                if bgGradientOrder >= 1:\n+                    gmod.set_param_hint('x1', value=bgParsPos[1])\n+                    gmod.set_param_hint('y1', value=bgParsPos[2])\n+                    if separateNegParams:\n+                        gmod.set_param_hint('x1Neg', value=bgParsNeg[1])\n+                        gmod.set_param_hint('y1Neg', value=bgParsNeg[2])\n+                if bgGradientOrder >= 2:\n+                    gmod.set_param_hint('xy', value=bgParsPos[3])\n+                    gmod.set_param_hint('x2', value=bgParsPos[4])\n+                    gmod.set_param_hint('y2', value=bgParsPos[5])\n+                    if separateNegParams:\n+                        gmod.set_param_hint('xyNeg', value=bgParsNeg[3])\n+                        gmod.set_param_hint('x2Neg', value=bgParsNeg[4])\n+                        gmod.set_param_hint('y2Neg', value=bgParsNeg[5])\n+\n+        y, x = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]\n+        in_x = np.array([x, y]).astype(np.float)\n+        in_x[0, :] -= in_x[0, :].mean()  # center it!\n+        in_x[1, :] -= in_x[1, :].mean()\n+\n+        # Instead of explicitly using a mask to ignore flagged pixels, just set the ignored pixels'\n+        #  weights to 0 in the fit. Right now, this is not used. TBD: need to inspect mask planes to set\n+        #  this mask.\n+        mask = np.ones_like(z, dtype=bool)  # TBD: set mask values to False if the pixels are to be ignored\n+\n+        # I'm not sure about the variance planes in the diffim (or convolved pre-sub. images\n+        # for that matter) so for now, let's just do an un-weighted least-squares fit\n+        # (override weights computed above).\n+        weights = mask.astype(np.float64)\n+        if self.posImage is not None and rel_weight > 0.:\n+            weights = np.array([np.ones_like(diArr), np.ones_like(diArr)*rel_weight,\n+                                np.ones_like(diArr)*rel_weight])\n+\n+        # Set the weights to zero if mask is False\n+        if np.any(~mask):\n+            weights[~mask] = 0.\n+\n+        # Note that although we can, we're not required to set initial values for params here,\n+        # since we set their param_hint's above.\n+        # add \"method\" param to not use 'leastsq' (==levenberg-marquardt), e.g. \"method='nelder'\"\n+        result = gmod.fit(z, weights=weights, x=in_x,\n+                          verbose=verbose,\n+                          fit_kws={'ftol': tol, 'xtol': tol, 'gtol': tol, 'maxfev': 250},  # see scipy docs\n+                          psf=self.diffim.getPsf(),  # hereon: kwargs that get passed to genDipoleModel()\n+                          rel_weight=rel_weight,\n+                          footprint=fp,\n+                          algObject=self)\n+\n+        if verbose:  # the ci_report() seems to fail if neg params are constrained -- TBD why.\n+            # Never wanted in production - this takes a long time (longer than the fit!)\n+            # This is how to get confidence intervals out:\n+            #    https://lmfit.github.io/lmfit-py/confidence.html and\n+            #    http://cars9.uchicago.edu/software/python/lmfit/model.html\n+            print(result.fit_report(show_correl=False))\n+            if separateNegParams:\n+                print(result.ci_report())\n+\n+        # Display images, model fits and residuals (currently uses matplotlib display functions)\n+        if display:\n+            self.displayFitResults(fp, result)\n+\n+        return result\n+\n+    def fitDipole(self, source, tol=1e-7, rel_weight=0.1,\n+                  fitBgGradient=True, maxSepInSigma=5., separateNegParams=True,\n+                  bgGradientOrder=1, verbose=False, display=False, return_fitObj=False):\n+        \"\"\"Wrapper around `fitDipoleImpl()` which performs the fit of a dipole\n+        model to an input difference image (actually, subimage bounded\n+        by the input source's footprint) and optionally constrain the\n+        fit using the pre-subtraction images self.posImage (science) and\n+        self.negImage (template). Wraps the output into a `resultsOutput`\n+        named tuple after computing additional statistics such as\n+        orientation and SNR.\n+\n+        @param source Record containing the (merged) dipole source footprint detected on the diffim\n+        @param tol Tolerance parameter for scipy.leastsq() optimization\n+        @param rel_weight Weighting of posImage/negImage relative to the diffim in the fit\n+        @param fitBgGradient Fit linear background gradient in posImage/negImage?\n+        @param bgGradientOrder Desired polynomial order of background gradient (allowed are [0,1,2])\n+        @param maxSepInSigma Allowed window of centroid parameters relative to peak in input source footprint\n+        @param separateNegParams Fit separate parameters to the flux and background gradient in\n+        the negative images?\n+        If true, this adds a separate parameter for the negative flux, and [1, 3, or 6] additional\n+        parameters to fit for the background gradient in the negImage. Otherwise, the flux and\n+        gradient parameters are constrained\n+        to be exactly equal in the fit.\n+        @param verbose Be verbose\n+        @param display Display input data, best fit model(s) and residuals in a matplotlib window.\n+        @param return_fitObj In addition to the `resultsOutput` object, also return the\n+        `lmfit.MinimizerResult` object for debugging.\n+\n+        @return resultsOutput object containing the fit parameters and other information.\n+        @return lmfit.MinimizerResult object if `return_fitObj` is True.\n+        \"\"\"\n+\n+        fitResult = self.fitDipoleImpl(\n+            source, tol=tol, rel_weight=rel_weight, fitBgGradient=fitBgGradient,\n+            maxSepInSigma=maxSepInSigma, separateNegParams=separateNegParams,\n+            bgGradientOrder=bgGradientOrder, verbose=verbose, display=display)\n+\n+        fitParams = fitResult.best_values\n+        if fitParams['flux'] <= 1.:   # usually around 0.1 -- the minimun flux allowed -- i.e. bad fit.\n+            out = DipoleFitAlgorithm.resultsOutput(\n+                np.nan, np.nan, np.nan, np.nan, np.nan, np.nan, np.nan, np.nan, np.nan, np.nan, np.nan,\n+                np.nan, np.nan, np.nan)\n+            if return_fitObj:  # for debugging\n+                return out, fitResult\n+            return out\n+\n+        centroid = ((fitParams['xcenPos'] + fitParams['xcenNeg']) / 2.,\n+                    (fitParams['ycenPos'] + fitParams['ycenNeg']) / 2.)\n+        dx, dy = fitParams['xcenPos'] - fitParams['xcenNeg'], fitParams['ycenPos'] - fitParams['ycenNeg']\n+        angle = np.arctan2(dy, dx) / np.pi * 180.   # convert to degrees (should keep as rad?)\n+\n+        # Exctract flux value, compute signalToNoise from flux/variance_within_footprint\n+        # Also extract the stderr of flux estimate.\n+        def computeSumVariance(exposure, footprint):\n+            box = footprint.getBBox()\n+            subim = afwImage.MaskedImageF(exposure.getMaskedImage(), box, afwImage.PARENT)\n+            return np.sqrt(np.nansum(subim.getArrays()[1][:, :]))\n+\n+        fluxVal = fluxVar = fitParams['flux']\n+        fluxErr = fluxErrNeg = fitResult.params['flux'].stderr\n+        if self.posImage is not None:\n+            fluxVar = computeSumVariance(self.posImage, source.getFootprint())\n+        else:\n+            fluxVar = computeSumVariance(self.diffim, source.getFootprint())\n+\n+        fluxValNeg, fluxVarNeg = fluxVal, fluxVar\n+        if separateNegParams:\n+            fluxValNeg = fitParams['fluxNeg']\n+            fluxErrNeg = fitResult.params['fluxNeg'].stderr\n+        if self.negImage is not None:\n+            fluxVarNeg = computeSumVariance(self.negImage, source.getFootprint())\n+\n+        try:\n+            signalToNoise = np.sqrt((fluxVal/fluxVar)**2 + (fluxValNeg/fluxVarNeg)**2)\n+        except:  # catch divide by zero - should never happen.\n+            signalToNoise = np.nan\n+\n+        out = DipoleFitAlgorithm.resultsOutput(\n+            fitParams['xcenPos'], fitParams['ycenPos'], fitParams['xcenNeg'], fitParams['ycenNeg'],\n+            fluxVal, -fluxValNeg, fluxErr, fluxErrNeg, centroid[0], centroid[1], angle,\n+            signalToNoise, fitResult.chisqr, fitResult.redchi)\n+\n+        if return_fitObj:  # for debugging\n+            return out, fitResult\n+        return out\n+\n+    def displayFitResults(self, footprint, result):\n+        \"\"\"Display data, model fits and residuals (currently uses matplotlib display functions).\n+        @param footprint Footprint containing the dipole that was fit\n+        @param result `lmfit.MinimizerResult` object returned by `lmfit` optimizer\n+        \"\"\"\n+\n+        from . import utils as ipUtils\n+        plt = ipUtils.importMatplotlib()\n+        if not plt:  # Exit silently if no matplotlib installed\n+            return","path":"python/lsst/ip/diffim/dipoleFitTask.py","position":897,"original_position":897,"commit_id":"f520ed0785c016b0c05aab64c1e87bc8873c22e9","original_commit_id":"f520ed0785c016b0c05aab64c1e87bc8873c22e9","user":{"login":"djreiss","id":512257,"avatar_url":"https://avatars.githubusercontent.com/u/512257?v=3","gravatar_id":"","url":"https://api.github.com/users/djreiss","html_url":"https://github.com/djreiss","followers_url":"https://api.github.com/users/djreiss/followers","following_url":"https://api.github.com/users/djreiss/following{/other_user}","gists_url":"https://api.github.com/users/djreiss/gists{/gist_id}","starred_url":"https://api.github.com/users/djreiss/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/djreiss/subscriptions","organizations_url":"https://api.github.com/users/djreiss/orgs","repos_url":"https://api.github.com/users/djreiss/repos","events_url":"https://api.github.com/users/djreiss/events{/privacy}","received_events_url":"https://api.github.com/users/djreiss/received_events","type":"User","site_admin":false},"body":"Changed as suggested.","created_at":"2016-04-26T02:20:46Z","updated_at":"2016-04-26T02:20:46Z","html_url":"https://github.com/lsst/ip_diffim/pull/10#discussion_r61020463","pull_request_url":"https://api.github.com/repos/lsst/ip_diffim/pulls/10","_links":{"self":{"href":"https://api.github.com/repos/lsst/ip_diffim/pulls/comments/61020463"},"html":{"href":"https://github.com/lsst/ip_diffim/pull/10#discussion_r61020463"},"pull_request":{"href":"https://api.github.com/repos/lsst/ip_diffim/pulls/10"}}},"pull_request":{"url":"https://api.github.com/repos/lsst/ip_diffim/pulls/10","id":66172682,"html_url":"https://github.com/lsst/ip_diffim/pull/10","diff_url":"https://github.com/lsst/ip_diffim/pull/10.diff","patch_url":"https://github.com/lsst/ip_diffim/pull/10.patch","issue_url":"https://api.github.com/repos/lsst/ip_diffim/issues/10","number":10,"state":"open","locked":false,"title":"Rewrite unit tests for new dipole measurement task","user":{"login":"timj","id":60622,"avatar_url":"https://avatars.githubusercontent.com/u/60622?v=3","gravatar_id":"","url":"https://api.github.com/users/timj","html_url":"https://github.com/timj","followers_url":"https://api.github.com/users/timj/followers","following_url":"https://api.github.com/users/timj/following{/other_user}","gists_url":"https://api.github.com/users/timj/gists{/gist_id}","starred_url":"https://api.github.com/users/timj/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/timj/subscriptions","organizations_url":"https://api.github.com/users/timj/orgs","repos_url":"https://api.github.com/users/timj/repos","events_url":"https://api.github.com/users/timj/events{/privacy}","received_events_url":"https://api.github.com/users/timj/received_events","type":"User","site_admin":false},"body":"","created_at":"2016-04-12T14:58:56Z","updated_at":"2016-04-26T02:20:46Z","closed_at":null,"merged_at":null,"merge_commit_sha":"9ac60a5f92dc0a733edeef15be965de92ef9afe7","assignee":null,"milestone":null,"commits_url":"https://api.github.com/repos/lsst/ip_diffim/pulls/10/commits","review_comments_url":"https://api.github.com/repos/lsst/ip_diffim/pulls/10/comments","review_comment_url":"https://api.github.com/repos/lsst/ip_diffim/pulls/comments{/number}","comments_url":"https://api.github.com/repos/lsst/ip_diffim/issues/10/comments","statuses_url":"https://api.github.com/repos/lsst/ip_diffim/statuses/f520ed0785c016b0c05aab64c1e87bc8873c22e9","head":{"label":"lsst:tickets/DM-5296","ref":"tickets/DM-5296","sha":"f520ed0785c016b0c05aab64c1e87bc8873c22e9","user":{"login":"lsst","id":1134504,"avatar_url":"https://avatars.githubusercontent.com/u/1134504?v=3","gravatar_id":"","url":"https://api.github.com/users/lsst","html_url":"https://github.com/lsst","followers_url":"https://api.github.com/users/lsst/followers","following_url":"https://api.github.com/users/lsst/following{/other_user}","gists_url":"https://api.github.com/users/lsst/gists{/gist_id}","starred_url":"https://api.github.com/users/lsst/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/lsst/subscriptions","organizations_url":"https://api.github.com/users/lsst/orgs","repos_url":"https://api.github.com/users/lsst/repos","events_url":"https://api.github.com/users/lsst/events{/privacy}","received_events_url":"https://api.github.com/users/lsst/received_events","type":"Organization","site_admin":false},"repo":{"id":23013321,"name":"ip_diffim","full_name":"lsst/ip_diffim","owner":{"login":"lsst","id":1134504,"avatar_url":"https://avatars.githubusercontent.com/u/1134504?v=3","gravatar_id":"","url":"https://api.github.com/users/lsst","html_url":"https://github.com/lsst","followers_url":"https://api.github.com/users/lsst/followers","following_url":"https://api.github.com/users/lsst/following{/other_user}","gists_url":"https://api.github.com/users/lsst/gists{/gist_id}","starred_url":"https://api.github.com/users/lsst/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/lsst/subscriptions","organizations_url":"https://api.github.com/users/lsst/orgs","repos_url":"https://api.github.com/users/lsst/repos","events_url":"https://api.github.com/users/lsst/events{/privacy}","received_events_url":"https://api.github.com/users/lsst/received_events","type":"Organization","site_admin":false},"private":false,"html_url":"https://github.com/lsst/ip_diffim","description":"LSST data management: astronomical image differencing","fork":false,"url":"https://api.github.com/repos/lsst/ip_diffim","forks_url":"https://api.github.com/repos/lsst/ip_diffim/forks","keys_url":"https://api.github.com/repos/lsst/ip_diffim/keys{/key_id}","collaborators_url":"https://api.github.com/repos/lsst/ip_diffim/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/lsst/ip_diffim/teams","hooks_url":"https://api.github.com/repos/lsst/ip_diffim/hooks","issue_events_url":"https://api.github.com/repos/lsst/ip_diffim/issues/events{/number}","events_url":"https://api.github.com/repos/lsst/ip_diffim/events","assignees_url":"https://api.github.com/repos/lsst/ip_diffim/assignees{/user}","branches_url":"https://api.github.com/repos/lsst/ip_diffim/branches{/branch}","tags_url":"https://api.github.com/repos/lsst/ip_diffim/tags","blobs_url":"https://api.github.com/repos/lsst/ip_diffim/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/lsst/ip_diffim/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/lsst/ip_diffim/git/refs{/sha}","trees_url":"https://api.github.com/repos/lsst/ip_diffim/git/trees{/sha}","statuses_url":"https://api.github.com/repos/lsst/ip_diffim/statuses/{sha}","languages_url":"https://api.github.com/repos/lsst/ip_diffim/languages","stargazers_url":"https://api.github.com/repos/lsst/ip_diffim/stargazers","contributors_url":"https://api.github.com/repos/lsst/ip_diffim/contributors","subscribers_url":"https://api.github.com/repos/lsst/ip_diffim/subscribers","subscription_url":"https://api.github.com/repos/lsst/ip_diffim/subscription","commits_url":"https://api.github.com/repos/lsst/ip_diffim/commits{/sha}","git_commits_url":"https://api.github.com/repos/lsst/ip_diffim/git/commits{/sha}","comments_url":"https://api.github.com/repos/lsst/ip_diffim/comments{/number}","issue_comment_url":"https://api.github.com/repos/lsst/ip_diffim/issues/comments{/number}","contents_url":"https://api.github.com/repos/lsst/ip_diffim/contents/{+path}","compare_url":"https://api.github.com/repos/lsst/ip_diffim/compare/{base}...{head}","merges_url":"https://api.github.com/repos/lsst/ip_diffim/merges","archive_url":"https://api.github.com/repos/lsst/ip_diffim/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/lsst/ip_diffim/downloads","issues_url":"https://api.github.com/repos/lsst/ip_diffim/issues{/number}","pulls_url":"https://api.github.com/repos/lsst/ip_diffim/pulls{/number}","milestones_url":"https://api.github.com/repos/lsst/ip_diffim/milestones{/number}","notifications_url":"https://api.github.com/repos/lsst/ip_diffim/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/lsst/ip_diffim/labels{/name}","releases_url":"https://api.github.com/repos/lsst/ip_diffim/releases{/id}","deployments_url":"https://api.github.com/repos/lsst/ip_diffim/deployments","created_at":"2014-08-16T07:44:12Z","updated_at":"2016-04-11T22:01:34Z","pushed_at":"2016-04-21T22:32:25Z","git_url":"git://github.com/lsst/ip_diffim.git","ssh_url":"git@github.com:lsst/ip_diffim.git","clone_url":"https://github.com/lsst/ip_diffim.git","svn_url":"https://github.com/lsst/ip_diffim","homepage":"http://dm.lsst.org/","size":16149,"stargazers_count":1,"watchers_count":1,"language":"Python","has_issues":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"forks_count":2,"mirror_url":null,"open_issues_count":2,"forks":2,"open_issues":2,"watchers":1,"default_branch":"master"}},"base":{"label":"lsst:master","ref":"master","sha":"0f48044308d38af9aa6ab6a59b64f54fb453ded4","user":{"login":"lsst","id":1134504,"avatar_url":"https://avatars.githubusercontent.com/u/1134504?v=3","gravatar_id":"","url":"https://api.github.com/users/lsst","html_url":"https://github.com/lsst","followers_url":"https://api.github.com/users/lsst/followers","following_url":"https://api.github.com/users/lsst/following{/other_user}","gists_url":"https://api.github.com/users/lsst/gists{/gist_id}","starred_url":"https://api.github.com/users/lsst/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/lsst/subscriptions","organizations_url":"https://api.github.com/users/lsst/orgs","repos_url":"https://api.github.com/users/lsst/repos","events_url":"https://api.github.com/users/lsst/events{/privacy}","received_events_url":"https://api.github.com/users/lsst/received_events","type":"Organization","site_admin":false},"repo":{"id":23013321,"name":"ip_diffim","full_name":"lsst/ip_diffim","owner":{"login":"lsst","id":1134504,"avatar_url":"https://avatars.githubusercontent.com/u/1134504?v=3","gravatar_id":"","url":"https://api.github.com/users/lsst","html_url":"https://github.com/lsst","followers_url":"https://api.github.com/users/lsst/followers","following_url":"https://api.github.com/users/lsst/following{/other_user}","gists_url":"https://api.github.com/users/lsst/gists{/gist_id}","starred_url":"https://api.github.com/users/lsst/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/lsst/subscriptions","organizations_url":"https://api.github.com/users/lsst/orgs","repos_url":"https://api.github.com/users/lsst/repos","events_url":"https://api.github.com/users/lsst/events{/privacy}","received_events_url":"https://api.github.com/users/lsst/received_events","type":"Organization","site_admin":false},"private":false,"html_url":"https://github.com/lsst/ip_diffim","description":"LSST data management: astronomical image differencing","fork":false,"url":"https://api.github.com/repos/lsst/ip_diffim","forks_url":"https://api.github.com/repos/lsst/ip_diffim/forks","keys_url":"https://api.github.com/repos/lsst/ip_diffim/keys{/key_id}","collaborators_url":"https://api.github.com/repos/lsst/ip_diffim/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/lsst/ip_diffim/teams","hooks_url":"https://api.github.com/repos/lsst/ip_diffim/hooks","issue_events_url":"https://api.github.com/repos/lsst/ip_diffim/issues/events{/number}","events_url":"https://api.github.com/repos/lsst/ip_diffim/events","assignees_url":"https://api.github.com/repos/lsst/ip_diffim/assignees{/user}","branches_url":"https://api.github.com/repos/lsst/ip_diffim/branches{/branch}","tags_url":"https://api.github.com/repos/lsst/ip_diffim/tags","blobs_url":"https://api.github.com/repos/lsst/ip_diffim/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/lsst/ip_diffim/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/lsst/ip_diffim/git/refs{/sha}","trees_url":"https://api.github.com/repos/lsst/ip_diffim/git/trees{/sha}","statuses_url":"https://api.github.com/repos/lsst/ip_diffim/statuses/{sha}","languages_url":"https://api.github.com/repos/lsst/ip_diffim/languages","stargazers_url":"https://api.github.com/repos/lsst/ip_diffim/stargazers","contributors_url":"https://api.github.com/repos/lsst/ip_diffim/contributors","subscribers_url":"https://api.github.com/repos/lsst/ip_diffim/subscribers","subscription_url":"https://api.github.com/repos/lsst/ip_diffim/subscription","commits_url":"https://api.github.com/repos/lsst/ip_diffim/commits{/sha}","git_commits_url":"https://api.github.com/repos/lsst/ip_diffim/git/commits{/sha}","comments_url":"https://api.github.com/repos/lsst/ip_diffim/comments{/number}","issue_comment_url":"https://api.github.com/repos/lsst/ip_diffim/issues/comments{/number}","contents_url":"https://api.github.com/repos/lsst/ip_diffim/contents/{+path}","compare_url":"https://api.github.com/repos/lsst/ip_diffim/compare/{base}...{head}","merges_url":"https://api.github.com/repos/lsst/ip_diffim/merges","archive_url":"https://api.github.com/repos/lsst/ip_diffim/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/lsst/ip_diffim/downloads","issues_url":"https://api.github.com/repos/lsst/ip_diffim/issues{/number}","pulls_url":"https://api.github.com/repos/lsst/ip_diffim/pulls{/number}","milestones_url":"https://api.github.com/repos/lsst/ip_diffim/milestones{/number}","notifications_url":"https://api.github.com/repos/lsst/ip_diffim/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/lsst/ip_diffim/labels{/name}","releases_url":"https://api.github.com/repos/lsst/ip_diffim/releases{/id}","deployments_url":"https://api.github.com/repos/lsst/ip_diffim/deployments","created_at":"2014-08-16T07:44:12Z","updated_at":"2016-04-11T22:01:34Z","pushed_at":"2016-04-21T22:32:25Z","git_url":"git://github.com/lsst/ip_diffim.git","ssh_url":"git@github.com:lsst/ip_diffim.git","clone_url":"https://github.com/lsst/ip_diffim.git","svn_url":"https://github.com/lsst/ip_diffim","homepage":"http://dm.lsst.org/","size":16149,"stargazers_count":1,"watchers_count":1,"language":"Python","has_issues":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"forks_count":2,"mirror_url":null,"open_issues_count":2,"forks":2,"open_issues":2,"watchers":1,"default_branch":"master"}},"_links":{"self":{"href":"https://api.github.com/repos/lsst/ip_diffim/pulls/10"},"html":{"href":"https://github.com/lsst/ip_diffim/pull/10"},"issue":{"href":"https://api.github.com/repos/lsst/ip_diffim/issues/10"},"comments":{"href":"https://api.github.com/repos/lsst/ip_diffim/issues/10/comments"},"review_comments":{"href":"https://api.github.com/repos/lsst/ip_diffim/pulls/10/comments"},"review_comment":{"href":"https://api.github.com/repos/lsst/ip_diffim/pulls/comments{/number}"},"commits":{"href":"https://api.github.com/repos/lsst/ip_diffim/pulls/10/commits"},"statuses":{"href":"https://api.github.com/repos/lsst/ip_diffim/statuses/f520ed0785c016b0c05aab64c1e87bc8873c22e9"}}}}