Coverage for python/lsst/pipe/tasks/photoCal.py: 11%
258 statements
« prev ^ index » next coverage.py v7.2.3, created at 2023-04-28 04:54 -0700
« prev ^ index » next coverage.py v7.2.3, created at 2023-04-28 04:54 -0700
1# This file is part of pipe_tasks.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
22__all__ = ["PhotoCalTask", "PhotoCalConfig"]
24import math
25import sys
27import numpy as np
28import astropy.units as u
30import lsst.pex.config as pexConf
31import lsst.pipe.base as pipeBase
32from lsst.afw.image import abMagErrFromFluxErr, makePhotoCalibFromCalibZeroPoint
33import lsst.afw.table as afwTable
34from lsst.meas.astrom import DirectMatchTask, DirectMatchConfigWithoutLoader
35import lsst.afw.display as afwDisplay
36from lsst.meas.algorithms import getRefFluxField, ReserveSourcesTask
37from lsst.utils.timer import timeMethod
38from .colorterms import ColortermLibrary
41class PhotoCalConfig(pexConf.Config):
42 """Config for PhotoCal."""
44 match = pexConf.ConfigField("Match to reference catalog",
45 DirectMatchConfigWithoutLoader)
46 reserve = pexConf.ConfigurableField(target=ReserveSourcesTask, doc="Reserve sources from fitting")
47 fluxField = pexConf.Field(
48 dtype=str,
49 default="slot_CalibFlux_instFlux",
50 doc=("Name of the source instFlux field to use.\nThe associated flag field "
51 "('<name>_flags') will be implicitly included in badFlags."),
52 )
53 applyColorTerms = pexConf.Field(
54 dtype=bool,
55 default=False,
56 doc=("Apply photometric color terms to reference stars?\n"
57 "`True`: attempt to apply color terms; fail if color term data is "
58 "not available for the specified reference catalog and filter.\n"
59 "`False`: do not apply color terms."),
60 optional=True,
61 )
62 sigmaMax = pexConf.Field(
63 dtype=float,
64 default=0.25,
65 doc="maximum sigma to use when clipping",
66 optional=True,
67 )
68 nSigma = pexConf.Field(
69 dtype=float,
70 default=3.0,
71 doc="clip at nSigma",
72 )
73 useMedian = pexConf.Field(
74 dtype=bool,
75 default=True,
76 doc="use median instead of mean to compute zeropoint",
77 )
78 nIter = pexConf.Field(
79 dtype=int,
80 default=20,
81 doc="number of iterations",
82 )
83 colorterms = pexConf.ConfigField(
84 dtype=ColortermLibrary,
85 doc="Library of photometric reference catalog name: color term dict (see also applyColorTerms).",
86 )
87 photoCatName = pexConf.Field(
88 dtype=str,
89 optional=True,
90 doc=("Name of photometric reference catalog; used to select a color term dict in colorterms.\n"
91 "See also applyColorTerms."),
92 )
93 magErrFloor = pexConf.RangeField(
94 dtype=float,
95 default=0.0,
96 doc="Additional magnitude uncertainty to be added in quadrature with measurement errors.",
97 min=0.0,
98 )
100 def validate(self):
101 pexConf.Config.validate(self)
102 if self.applyColorTerms and self.photoCatName is None:
103 raise RuntimeError("applyColorTerms=True requires photoCatName is non-None")
104 if self.applyColorTerms and len(self.colorterms.data) == 0:
105 raise RuntimeError("applyColorTerms=True requires colorterms be provided")
107 def setDefaults(self):
108 pexConf.Config.setDefaults(self)
109 self.match.sourceSelection.doFlags = True
110 self.match.sourceSelection.flags.bad = [
111 "base_PixelFlags_flag_edge",
112 "base_PixelFlags_flag_interpolated",
113 "base_PixelFlags_flag_saturated",
114 ]
115 self.match.sourceSelection.doUnresolved = True
118class PhotoCalTask(pipeBase.Task):
119 """Calculate an Exposure's zero-point given a set of flux measurements
120 of stars matched to an input catalogue.
122 Parameters
123 ----------
124 refObjLoader : `lsst.meas.algorithms.ReferenceObjectLoader`
125 An instance of LoadReferenceObjectsTasks that supplies an external reference
126 catalog.
127 schema : `lsst.afw.table.Schema`, optional
128 The schema of the detection catalogs used as input to this task.
129 **kwds
130 Additional keyword arguments.
132 Notes
133 -----
134 The type of flux to use is specified by PhotoCalConfig.fluxField.
136 The algorithm clips outliers iteratively, with parameters set in the configuration.
138 This task can adds fields to the schema, so any code calling this task must ensure that
139 these columns are indeed present in the input match list; see `pipe_tasks_photocal_Example`.
141 Debugging:
143 The available `~lsst.base.lsstDebug` variables in PhotoCalTask are:
145 display :
146 If True enable other debug outputs.
147 displaySources :
148 If True, display the exposure on ds9's frame 1 and overlay the source catalogue.
150 red o :
151 Reserved objects.
152 green o :
153 Objects used in the photometric calibration.
155 scatterPlot :
156 Make a scatter plot of flux v. reference magnitude as a function of reference magnitude:
158 - good objects in blue
159 - rejected objects in red
161 (if scatterPlot is 2 or more, prompt to continue after each iteration)
162 """
164 ConfigClass = PhotoCalConfig
165 _DefaultName = "photoCal"
167 def __init__(self, refObjLoader, schema=None, **kwds):
168 pipeBase.Task.__init__(self, **kwds)
169 self.scatterPlot = None
170 self.fig = None
171 if schema is not None:
172 self.usedKey = schema.addField("calib_photometry_used", type="Flag",
173 doc="set if source was used in photometric calibration")
174 else:
175 self.usedKey = None
176 self.match = DirectMatchTask(config=self.config.match, refObjLoader=refObjLoader,
177 name="match", parentTask=self)
178 self.makeSubtask("reserve", columnName="calib_photometry", schema=schema,
179 doc="set if source was reserved from photometric calibration")
181 def getSourceKeys(self, schema):
182 """Return a struct containing the source catalog keys for fields used
183 by PhotoCalTask.
185 Parameters
186 ----------
187 schema : `lsst.afw.table.schema`
188 Schema of the catalog to get keys from.
190 Returns
191 -------
192 result : `lsst.pipe.base.Struct`
193 Results as a struct with attributes:
195 ``instFlux``
196 Instrument flux key.
197 ``instFluxErr``
198 Instrument flux error key.
199 """
200 instFlux = schema.find(self.config.fluxField).key
201 instFluxErr = schema.find(self.config.fluxField + "Err").key
202 return pipeBase.Struct(instFlux=instFlux, instFluxErr=instFluxErr)
204 @timeMethod
205 def extractMagArrays(self, matches, filterLabel, sourceKeys):
206 """Extract magnitude and magnitude error arrays from the given matches.
208 Parameters
209 ----------
210 matches : `lsst.afw.table.ReferenceMatchVector`
211 Reference/source matches.
212 filterLabel : `str`
213 Label of filter being calibrated.
214 sourceKeys : `lsst.pipe.base.Struct`
215 Struct of source catalog keys, as returned by getSourceKeys().
217 Returns
218 -------
219 result : `lsst.pipe.base.Struct`
220 Results as a struct with attributes:
222 ``srcMag``
223 Source magnitude (`np.array`).
224 ``refMag``
225 Reference magnitude (`np.array`).
226 ``srcMagErr``
227 Source magnitude error (`np.array`).
228 ``refMagErr``
229 Reference magnitude error (`np.array`).
230 ``magErr``
231 An error in the magnitude; the error in ``srcMag`` - ``refMag``.
232 If nonzero, ``config.magErrFloor`` will be added to ``magErr`` only
233 (not ``srcMagErr`` or ``refMagErr``), as
234 ``magErr`` is what is later used to determine the zero point (`np.array`).
235 ``refFluxFieldList``
236 A list of field names of the reference catalog used for fluxes (1 or 2 strings) (`list`).
237 """
238 srcInstFluxArr = np.array([m.second.get(sourceKeys.instFlux) for m in matches])
239 srcInstFluxErrArr = np.array([m.second.get(sourceKeys.instFluxErr) for m in matches])
240 if not np.all(np.isfinite(srcInstFluxErrArr)):
241 # this is an unpleasant hack; see DM-2308 requesting a better solution
242 self.log.warning("Source catalog does not have flux uncertainties; using sqrt(flux).")
243 srcInstFluxErrArr = np.sqrt(srcInstFluxArr)
245 # convert source instFlux from DN to an estimate of nJy
246 referenceFlux = (0*u.ABmag).to_value(u.nJy)
247 srcInstFluxArr = srcInstFluxArr * referenceFlux
248 srcInstFluxErrArr = srcInstFluxErrArr * referenceFlux
250 if not matches:
251 raise RuntimeError("No reference stars are available")
252 refSchema = matches[0].first.schema
254 if self.config.applyColorTerms:
255 self.log.info("Applying color terms for filter=%r, config.photoCatName=%s",
256 filterLabel.physicalLabel, self.config.photoCatName)
257 colorterm = self.config.colorterms.getColorterm(filterLabel.physicalLabel,
258 self.config.photoCatName,
259 doRaise=True)
260 refCat = afwTable.SimpleCatalog(matches[0].first.schema)
262 # extract the matched refCat as a Catalog for the colorterm code
263 refCat.reserve(len(matches))
264 for x in matches:
265 record = refCat.addNew()
266 record.assign(x.first)
268 refMagArr, refMagErrArr = colorterm.getCorrectedMagnitudes(refCat)
269 fluxFieldList = [getRefFluxField(refSchema, filt) for filt in (colorterm.primary,
270 colorterm.secondary)]
271 else:
272 self.log.info("Not applying color terms.")
273 colorterm = None
275 fluxFieldList = [getRefFluxField(refSchema, filterLabel.bandLabel)]
276 fluxField = getRefFluxField(refSchema, filterLabel.bandLabel)
277 fluxKey = refSchema.find(fluxField).key
278 refFluxArr = np.array([m.first.get(fluxKey) for m in matches])
280 try:
281 fluxErrKey = refSchema.find(fluxField + "Err").key
282 refFluxErrArr = np.array([m.first.get(fluxErrKey) for m in matches])
283 except KeyError:
284 # Reference catalogue may not have flux uncertainties; HACK DM-2308
285 self.log.warning("Reference catalog does not have flux uncertainties for %s;"
286 " using sqrt(flux).", fluxField)
287 refFluxErrArr = np.sqrt(refFluxArr)
289 refMagArr = u.Quantity(refFluxArr, u.nJy).to_value(u.ABmag)
290 # HACK convert to Jy until we have a replacement for this (DM-16903)
291 refMagErrArr = abMagErrFromFluxErr(refFluxErrArr*1e-9, refFluxArr*1e-9)
293 # compute the source catalog magnitudes and errors
294 srcMagArr = u.Quantity(srcInstFluxArr, u.nJy).to_value(u.ABmag)
295 # Fitting with error bars in both axes is hard
296 # for now ignore reference flux error, but ticket DM-2308 is a request for a better solution
297 # HACK convert to Jy until we have a replacement for this (DM-16903)
298 magErrArr = abMagErrFromFluxErr(srcInstFluxErrArr*1e-9, srcInstFluxArr*1e-9)
299 if self.config.magErrFloor != 0.0:
300 magErrArr = (magErrArr**2 + self.config.magErrFloor**2)**0.5
302 srcMagErrArr = abMagErrFromFluxErr(srcInstFluxErrArr*1e-9, srcInstFluxArr*1e-9)
304 good = np.isfinite(srcMagArr) & np.isfinite(refMagArr)
306 return pipeBase.Struct(
307 srcMag=srcMagArr[good],
308 refMag=refMagArr[good],
309 magErr=magErrArr[good],
310 srcMagErr=srcMagErrArr[good],
311 refMagErr=refMagErrArr[good],
312 refFluxFieldList=fluxFieldList,
313 )
315 @timeMethod
316 def run(self, exposure, sourceCat, expId=0):
317 """Do photometric calibration - select matches to use and (possibly iteratively) compute
318 the zero point.
320 Parameters
321 ----------
322 exposure : `lsst.afw.image.Exposure`
323 Exposure upon which the sources in the matches were detected.
324 sourceCat : `lsst.afw.image.SourceCatalog`
325 A catalog of sources to use in the calibration
326 (i.e. a `list` of `lsst.afw.table.Match` with
327 first being of type `lsst.afw.table.SimpleRecord` and second type `lsst.afw.table.SourceRecord`
328 the reference object and matched object respectively).
329 Will not be modified except to set the outputField if requested.
330 expId : `int`, optional
331 Exposure ID.
333 Returns
334 -------
335 result : `lsst.pipe.base.Struct`
336 Results as a struct with attributes:
338 ``photoCalib``
339 Object containing the zero point (`lsst.afw.image.Calib`).
340 ``arrays``
341 Magnitude arrays returned be `PhotoCalTask.extractMagArrays`.
342 ``matches``
343 ReferenceMatchVector, as returned by `PhotoCalTask.selectMatches`.
344 ``zp``
345 Photometric zero point (mag, `float`).
346 ``sigma``
347 Standard deviation of fit of photometric zero point (mag, `float`).
348 ``ngood``
349 Number of sources used to fit photometric zero point (`int`).
351 Raises
352 ------
353 RuntimeError
354 Raised if any of the following occur:
355 - No matches to use for photocal.
356 - No matches are available (perhaps no sources/references were selected by the matcher).
357 - No reference stars are available.
358 - No matches are available from which to extract magnitudes.
360 Notes
361 -----
362 The exposure is only used to provide the name of the filter being calibrated (it may also be
363 used to generate debugging plots).
365 The reference objects:
366 - Must include a field ``photometric``; True for objects which should be considered as
367 photometric standards.
368 - Must include a field ``flux``; the flux used to impose a magnitude limit and also to calibrate
369 the data to (unless a color term is specified, in which case ColorTerm.primary is used;
370 See https://jira.lsstcorp.org/browse/DM-933).
371 - May include a field ``stargal``; if present, True means that the object is a star.
372 - May include a field ``var``; if present, True means that the object is variable.
374 The measured sources:
375 - Must include PhotoCalConfig.fluxField; the flux measurement to be used for calibration.
376 """
377 import lsstDebug
379 display = lsstDebug.Info(__name__).display
380 displaySources = display and lsstDebug.Info(__name__).displaySources
381 self.scatterPlot = display and lsstDebug.Info(__name__).scatterPlot
383 if self.scatterPlot:
384 from matplotlib import pyplot
385 try:
386 self.fig.clf()
387 except Exception:
388 self.fig = pyplot.figure()
390 filterLabel = exposure.getFilter()
392 # Match sources
393 matchResults = self.match.run(sourceCat, filterLabel.bandLabel)
394 matches = matchResults.matches
396 reserveResults = self.reserve.run([mm.second for mm in matches], expId=expId)
397 if displaySources:
398 self.displaySources(exposure, matches, reserveResults.reserved)
399 if reserveResults.reserved.sum() > 0:
400 matches = [mm for mm, use in zip(matches, reserveResults.use) if use]
401 if len(matches) == 0:
402 raise RuntimeError("No matches to use for photocal")
403 if self.usedKey is not None:
404 for mm in matches:
405 mm.second.set(self.usedKey, True)
407 # Prepare for fitting
408 sourceKeys = self.getSourceKeys(matches[0].second.schema)
409 arrays = self.extractMagArrays(matches, filterLabel, sourceKeys)
411 # Fit for zeropoint
412 r = self.getZeroPoint(arrays.srcMag, arrays.refMag, arrays.magErr)
413 self.log.info("Magnitude zero point: %f +/- %f from %d stars", r.zp, r.sigma, r.ngood)
415 # Prepare the results
416 flux0 = 10**(0.4*r.zp) # Flux of mag=0 star
417 flux0err = 0.4*math.log(10)*flux0*r.sigma # Error in flux0
418 photoCalib = makePhotoCalibFromCalibZeroPoint(flux0, flux0err)
420 return pipeBase.Struct(
421 photoCalib=photoCalib,
422 arrays=arrays,
423 matches=matches,
424 zp=r.zp,
425 sigma=r.sigma,
426 ngood=r.ngood,
427 )
429 def displaySources(self, exposure, matches, reserved, frame=1):
430 """Display sources we'll use for photocal.
432 Sources that will be actually used will be green.
433 Sources reserved from the fit will be red.
435 Parameters
436 ----------
437 exposure : `lsst.afw.image.ExposureF`
438 Exposure to display.
439 matches : `list` of `lsst.afw.table.RefMatch`
440 Matches used for photocal.
441 reserved : `numpy.ndarray` of type `bool`
442 Boolean array indicating sources that are reserved.
443 frame : `int`, optional
444 Frame number for display.
445 """
446 disp = afwDisplay.getDisplay(frame=frame)
447 disp.mtv(exposure, title="photocal")
448 with disp.Buffering():
449 for mm, rr in zip(matches, reserved):
450 x, y = mm.second.getCentroid()
451 ctype = afwDisplay.RED if rr else afwDisplay.GREEN
452 disp.dot("o", x, y, size=4, ctype=ctype)
454 def getZeroPoint(self, src, ref, srcErr=None, zp0=None):
455 """Flux calibration code, returning (ZeroPoint, Distribution Width, Number of stars).
457 Returns
458 -------
459 result : `lsst.pipe.base.Struct`
460 Results as a struct with attributes:
462 ``zp``
463 Photometric zero point (mag, `float`).
464 ``sigma``
465 Standard deviation of fit of photometric zero point (mag, `float`).
466 ``ngood``
467 Number of sources used to fit photometric zero point (`int`).
469 Notes
470 -----
471 We perform nIter iterations of a simple sigma-clipping algorithm with a couple of twists:
472 - We use the median/interquartile range to estimate the position to clip around, and the
473 "sigma" to use.
474 - We never allow sigma to go _above_ a critical value sigmaMax --- if we do, a sufficiently
475 large estimate will prevent the clipping from ever taking effect.
476 - Rather than start with the median we start with a crude mode. This means that a set of magnitude
477 residuals with a tight core and asymmetrical outliers will start in the core. We use the width of
478 this core to set our maximum sigma (see second bullet).
479 """
480 sigmaMax = self.config.sigmaMax
482 dmag = ref - src
484 indArr = np.argsort(dmag)
485 dmag = dmag[indArr]
487 if srcErr is not None:
488 dmagErr = srcErr[indArr]
489 else:
490 dmagErr = np.ones(len(dmag))
492 # need to remove nan elements to avoid errors in stats calculation with numpy
493 ind_noNan = np.array([i for i in range(len(dmag))
494 if (not np.isnan(dmag[i]) and not np.isnan(dmagErr[i]))])
495 dmag = dmag[ind_noNan]
496 dmagErr = dmagErr[ind_noNan]
498 IQ_TO_STDEV = 0.741301109252802 # 1 sigma in units of interquartile (assume Gaussian)
500 npt = len(dmag)
501 ngood = npt
502 good = None # set at end of first iteration
503 for i in range(self.config.nIter):
504 if i > 0:
505 npt = sum(good)
507 center = None
508 if i == 0:
509 #
510 # Start by finding the mode
511 #
512 nhist = 20
513 try:
514 hist, edges = np.histogram(dmag, nhist, new=True)
515 except TypeError:
516 hist, edges = np.histogram(dmag, nhist) # they removed new=True around numpy 1.5
517 imode = np.arange(nhist)[np.where(hist == hist.max())]
519 if imode[-1] - imode[0] + 1 == len(imode): # Multiple modes, but all contiguous
520 if zp0:
521 center = zp0
522 else:
523 center = 0.5*(edges[imode[0]] + edges[imode[-1] + 1])
525 peak = sum(hist[imode])/len(imode) # peak height
527 # Estimate FWHM of mode
528 j = imode[0]
529 while j >= 0 and hist[j] > 0.5*peak:
530 j -= 1
531 j = max(j, 0)
532 q1 = dmag[sum(hist[range(j)])]
534 j = imode[-1]
535 while j < nhist and hist[j] > 0.5*peak:
536 j += 1
537 j = min(j, nhist - 1)
538 j = min(sum(hist[range(j)]), npt - 1)
539 q3 = dmag[j]
541 if q1 == q3:
542 q1 = dmag[int(0.25*npt)]
543 q3 = dmag[int(0.75*npt)]
545 sig = (q3 - q1)/2.3 # estimate of standard deviation (based on FWHM; 2.358 for Gaussian)
547 if sigmaMax is None:
548 sigmaMax = 2*sig # upper bound on st. dev. for clipping. multiplier is a heuristic
550 self.log.debug("Photo calibration histogram: center = %.2f, sig = %.2f", center, sig)
552 else:
553 if sigmaMax is None:
554 sigmaMax = dmag[-1] - dmag[0]
556 center = np.median(dmag)
557 q1 = dmag[int(0.25*npt)]
558 q3 = dmag[int(0.75*npt)]
559 sig = (q3 - q1)/2.3 # estimate of standard deviation (based on FWHM; 2.358 for Gaussian)
561 if center is None: # usually equivalent to (i > 0)
562 gdmag = dmag[good]
563 if self.config.useMedian:
564 center = np.median(gdmag)
565 else:
566 gdmagErr = dmagErr[good]
567 center = np.average(gdmag, weights=gdmagErr)
569 q3 = gdmag[min(int(0.75*npt + 0.5), npt - 1)]
570 q1 = gdmag[min(int(0.25*npt + 0.5), npt - 1)]
572 sig = IQ_TO_STDEV*(q3 - q1) # estimate of standard deviation
574 good = abs(dmag - center) < self.config.nSigma*min(sig, sigmaMax) # don't clip too softly
576 # =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
577 if self.scatterPlot:
578 try:
579 self.fig.clf()
581 axes = self.fig.add_axes((0.1, 0.1, 0.85, 0.80))
583 axes.plot(ref[good], dmag[good] - center, "b+")
584 axes.errorbar(ref[good], dmag[good] - center, yerr=dmagErr[good],
585 linestyle='', color='b')
587 bad = np.logical_not(good)
588 if len(ref[bad]) > 0:
589 axes.plot(ref[bad], dmag[bad] - center, "r+")
590 axes.errorbar(ref[bad], dmag[bad] - center, yerr=dmagErr[bad],
591 linestyle='', color='r')
593 axes.plot((-100, 100), (0, 0), "g-")
594 for x in (-1, 1):
595 axes.plot((-100, 100), x*0.05*np.ones(2), "g--")
597 axes.set_ylim(-1.1, 1.1)
598 axes.set_xlim(24, 13)
599 axes.set_xlabel("Reference")
600 axes.set_ylabel("Reference - Instrumental")
602 self.fig.show()
604 if self.scatterPlot > 1:
605 reply = None
606 while i == 0 or reply != "c":
607 try:
608 reply = input("Next iteration? [ynhpc] ")
609 except EOFError:
610 reply = "n"
612 if reply == "h":
613 print("Options: c[ontinue] h[elp] n[o] p[db] y[es]", file=sys.stderr)
614 continue
616 if reply in ("", "c", "n", "p", "y"):
617 break
618 else:
619 print("Unrecognised response: %s" % reply, file=sys.stderr)
621 if reply == "n":
622 break
623 elif reply == "p":
624 import pdb
625 pdb.set_trace()
626 except Exception as e:
627 print("Error plotting in PhotoCal.getZeroPoint: %s" % e, file=sys.stderr)
629 # =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
631 old_ngood = ngood
632 ngood = sum(good)
633 if ngood == 0:
634 msg = "PhotoCal.getZeroPoint: no good stars remain"
636 if i == 0: # failed the first time round -- probably all fell in one bin
637 center = np.average(dmag, weights=dmagErr)
638 msg += " on first iteration; using average of all calibration stars"
640 self.log.warning(msg)
642 return pipeBase.Struct(
643 zp=center,
644 sigma=sig,
645 ngood=len(dmag))
646 elif ngood == old_ngood:
647 break
649 if False:
650 ref = ref[good]
651 dmag = dmag[good]
652 dmagErr = dmagErr[good]
654 dmag = dmag[good]
655 dmagErr = dmagErr[good]
656 zp, weightSum = np.average(dmag, weights=1/dmagErr**2, returned=True)
657 sigma = np.sqrt(1.0/weightSum)
658 return pipeBase.Struct(
659 zp=zp,
660 sigma=sigma,
661 ngood=len(dmag),
662 )