LSST Applications g0f08755f38+82efc23009,g12f32b3c4e+e7bdf1200e,g1653933729+a8ce1bb630,g1a0ca8cf93+50eff2b06f,g28da252d5a+52db39f6a5,g2bbee38e9b+37c5a29d61,g2bc492864f+37c5a29d61,g2cdde0e794+c05ff076ad,g3156d2b45e+41e33cbcdc,g347aa1857d+37c5a29d61,g35bb328faa+a8ce1bb630,g3a166c0a6a+37c5a29d61,g3e281a1b8c+fb992f5633,g414038480c+7f03dfc1b0,g41af890bb2+11b950c980,g5fbc88fb19+17cd334064,g6b1c1869cb+12dd639c9a,g781aacb6e4+a8ce1bb630,g80478fca09+72e9651da0,g82479be7b0+04c31367b4,g858d7b2824+82efc23009,g9125e01d80+a8ce1bb630,g9726552aa6+8047e3811d,ga5288a1d22+e532dc0a0b,gae0086650b+a8ce1bb630,gb58c049af0+d64f4d3760,gc28159a63d+37c5a29d61,gcf0d15dbbd+2acd6d4d48,gd7358e8bfb+778a810b6e,gda3e153d99+82efc23009,gda6a2b7d83+2acd6d4d48,gdaeeff99f8+1711a396fd,ge2409df99d+6b12de1076,ge79ae78c31+37c5a29d61,gf0baf85859+d0a5978c5a,gf3967379c6+4954f8c433,gfb92a5be7c+82efc23009,gfec2e1e490+2aaed99252,w.2024.46
LSST Data Management Base Package
Loading...
Searching...
No Matches
measureApCorr.py
Go to the documentation of this file.
2# LSST Data Management System
3#
4# Copyright 2008-2017 AURA/LSST.
5#
6# This product includes software developed by the
7# LSST Project (http://www.lsst.org/).
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 LSST License Statement and
20# the GNU General Public License along with this program. If not,
21# see <https://www.lsstcorp.org/LegalNotices/>.
22#
23
24__all__ = ("MeasureApCorrConfig", "MeasureApCorrTask", "MeasureApCorrError")
25
26import numpy as np
27from scipy.stats import median_abs_deviation
28
29import lsst.pex.config
30from lsst.afw.image import ApCorrMap
31from lsst.afw.math import ChebyshevBoundedField, ChebyshevBoundedFieldConfig
32from lsst.pipe.base import Task, Struct, AlgorithmError
33from lsst.meas.base.apCorrRegistry import getApCorrNameSet
34
35from .sourceSelector import sourceSelectorRegistry
36
37
38class MeasureApCorrError(AlgorithmError):
39 """Raised if Aperture Correction fails in a non-recoverable way.
40
41 Parameters
42 ----------
43 name : `str`
44 Name of the kind of aperture correction that failed; typically an
45 instFlux catalog field.
46 nSources : `int`
47 Number of sources available to the fitter at the point of failure.
48 ndof : `int`
49 Number of degrees of freedom required at the point of failure.
50 iteration : `int`, optional
51 Which fit iteration the failure occurred in.
52 """
53 def __init__(self, *, name, nSources, ndof, iteration=None):
54 msg = f"Unable to measure aperture correction for '{name}'"
55 if iteration is not None:
56 msg += f" after {iteration} steps:"
57 else:
58 msg += ":"
59 msg += f" only {nSources} sources, but require at least {ndof}."
60 super().__init__(msg)
61 self.name = name
62 self.nSources = nSources
63 self.ndof = ndof
64 self.iteration = iteration
65
66 @property
67 def metadata(self):
68 metadata = {"name": self.name,
69 "nSources": self.nSources,
70 "ndof": self.ndof,
71 }
72 # NOTE: have to do this because task metadata doesn't allow None.
73 if self.iteration is not None:
74 metadata["iteration"] = self.iteration
75 return metadata
76
77
79 """A collection of flux-related names for a given flux measurement algorithm.
80
81 Parameters
82 ----------
83 name : `str`
84 Name of flux measurement algorithm, e.g. ``base_PsfFlux``.
85 schema : `lsst.afw.table.Schema`
86 Catalog schema containing the flux field. The ``{name}_instFlux``,
87 ``{name}_instFluxErr``, ``{name}_flag`` fields are checked for
88 existence, and the ``apcorr_{name}_used`` field is added.
89
90 Raises
91 ------
92 KeyError if any of instFlux, instFluxErr, or flag fields is missing.
93 """
94 def __init__(self, name, schema):
95 self.fluxName = name + "_instFlux"
96 if self.fluxName not in schema:
97 raise KeyError("Could not find " + self.fluxName)
98 self.errName = name + "_instFluxErr"
99 if self.errName not in schema:
100 raise KeyError("Could not find " + self.errName)
101 self.flagName = name + "_flag"
102 if self.flagName not in schema:
103 raise KeyError("Cound not find " + self.flagName)
104 self.usedName = "apcorr_" + name + "_used"
105 schema.addField(self.usedName, type="Flag",
106 doc="Set if source was used in measuring aperture correction.")
107
108
110 """Configuration for MeasureApCorrTask.
111 """
113 doc="Field name prefix for the flux other measurements should be aperture corrected to match",
114 dtype=str,
115 default="slot_CalibFlux",
116 )
117 sourceSelector = sourceSelectorRegistry.makeField(
118 doc="Selector that sets the stars that aperture corrections will be measured from.",
119 default="science",
120 )
121 minDegreesOfFreedom = lsst.pex.config.RangeField(
122 doc="Minimum number of degrees of freedom (# of valid data points - # of parameters);"
123 " if this is exceeded, the order of the fit is decreased (in both dimensions), and"
124 " if we can't decrease it enough, we'll raise ValueError.",
125 dtype=int,
126 default=1,
127 min=1,
128 )
130 doc="Configuration used in fitting the aperture correction fields.",
131 dtype=ChebyshevBoundedFieldConfig,
132 )
134 doc="Number of iterations for robust MAD sigma clipping.",
135 dtype=int,
136 default=4,
137 )
138 numSigmaClip = lsst.pex.config.Field(
139 doc="Number of robust MAD sigma to do clipping.",
140 dtype=float,
141 default=4.0,
142 )
143 doFinalMedianShift = lsst.pex.config.Field(
144 doc="Do final shift to ensure medians match.",
145 dtype=bool,
146 default=True,
147 )
149 doc="Allow these measurement algorithms to fail without an exception.",
150 dtype=str,
151 default=[],
152 )
153
154 def setDefaults(self):
155 selector = self.sourceSelector["science"]
156
157 selector.doFlags = True
158 selector.doUnresolved = True
159 selector.doSignalToNoise = True
160 selector.doIsolated = False
161 selector.flags.good = []
162 selector.flags.bad = [
163 "base_PixelFlags_flag_edge",
164 "base_PixelFlags_flag_interpolatedCenter",
165 "base_PixelFlags_flag_saturatedCenter",
166 "base_PixelFlags_flag_crCenter",
167 "base_PixelFlags_flag_bad",
168 "base_PixelFlags_flag_interpolated",
169 "base_PixelFlags_flag_saturated",
170 ]
171 selector.signalToNoise.minimum = 200.0
172 selector.signalToNoise.maximum = None
173 selector.signalToNoise.fluxField = "base_PsfFlux_instFlux"
174 selector.signalToNoise.errField = "base_PsfFlux_instFluxErr"
175
176 def validate(self):
177 lsst.pex.config.Config.validate(self)
178 if self.sourceSelector.target.usesMatches:
180 MeasureApCorrConfig.sourceSelector,
181 self,
182 "Star selectors that require matches are not permitted.")
183
184
186 """Task to measure aperture correction.
187
188 For every name to correct, a new field apcorr_{name}_used will
189 be added, and will be logged in self.toCorrect.
190
191 Parameters
192 ----------
193 schema : `lsst.afw.table.Schema`
194 Schema for the input table; will be modified in place to
195 add ``apcorr_{name}_used`` fields.
196 namesToCorrect : `list` [`str`], optional
197 List of names to correct. If `None` then the set of
198 registered fields in lsst.meas.base.getApCorrNameSet()
199 will be used.
200 **kwargs : `dict`
201 Additional kwargs to pass to lsst.pipe.base.Task.__init__()
202
203 Raises
204 ------
205 MeasureApCorrError if any of the names to correct fails and is
206 not in the config.allowFailure list.
207 """
208 ConfigClass = MeasureApCorrConfig
209 _DefaultName = "measureApCorr"
210
211 def __init__(self, schema, namesToCorrect=None, **kwargs):
212 Task.__init__(self, **kwargs)
213 self.refFluxNames = _FluxNames(self.config.refFluxName, schema)
214 self.toCorrect = {} # dict of flux field name prefix: FluxKeys instance
215 names = namesToCorrect if namesToCorrect else getApCorrNameSet()
216 for name in sorted(names):
217 try:
218 self.toCorrect[name] = _FluxNames(name, schema)
219 except KeyError:
220 # if a field in the registry is missing, just ignore it.
221 pass
222 self.makeSubtask("sourceSelector")
223
224 def run(self, exposure, catalog):
225 """Measure aperture correction
226
227 Parameters
228 ----------
229 exposure : `lsst.afw.image.Exposure`
230 Exposure aperture corrections are being measured on. The
231 bounding box is retrieved from it, and it is passed to the
232 sourceSelector. The output aperture correction map is *not*
233 added to the exposure; this is left to the caller.
234 catalog : `lsst.afw.table.SourceCatalog`
235 SourceCatalog containing measurements to be used to
236 compute aperture corrections.
237
238 Returns
239 -------
240 Struct : `lsst.pipe.base.Struct`
241 Contains the following:
242
243 ``apCorrMap``
244 aperture correction map (`lsst.afw.image.ApCorrMap`)
245 that contains two entries for each flux field:
246 - flux field (e.g. base_PsfFlux_instFlux): 2d model
247 - flux sigma field (e.g. base_PsfFlux_instFluxErr): 2d model of error
248 """
249 bbox = exposure.getBBox()
250 import lsstDebug
251 display = lsstDebug.Info(__name__).display
252 doPause = lsstDebug.Info(__name__).doPause
253
254 self.log.info("Measuring aperture corrections for %d flux fields", len(self.toCorrect))
255
256 # First, create a subset of the catalog that contains only selected stars
257 # with non-flagged reference fluxes.
258 selected = self.sourceSelector.run(catalog, exposure=exposure)
259
260 use = (
261 ~selected.sourceCat[self.refFluxNames.flagName]
262 & (np.isfinite(selected.sourceCat[self.refFluxNames.fluxName]))
263 )
264 goodRefCat = selected.sourceCat[use].copy()
265
266 apCorrMap = ApCorrMap()
267
268 # Outer loop over the fields we want to correct
269 for name, fluxNames in self.toCorrect.items():
270 # Create a more restricted subset with only the objects where the to-be-correct flux
271 # is not flagged.
272 fluxes = goodRefCat[fluxNames.fluxName]
273 with np.errstate(invalid="ignore"): # suppress NaN warnings.
274 isGood = (
275 (~goodRefCat[fluxNames.flagName])
276 & (np.isfinite(fluxes))
277 & (fluxes > 0.0)
278 )
279
280 # The 1 is the minimum number of ctrl.computeSize() when the order
281 # drops to 0 in both x and y.
282 if (isGood.sum() - 1) < self.config.minDegreesOfFreedom:
283 if name in self.config.allowFailure:
284 self.log.warning("Unable to measure aperture correction for '%s': "
285 "only %d sources, but require at least %d.",
286 name, isGood.sum(), self.config.minDegreesOfFreedom + 1)
287 continue
288 else:
289 raise MeasureApCorrError(name=name, nSources=isGood.sum(),
290 ndof=self.config.minDegreesOfFreedom + 1)
291
292 goodCat = goodRefCat[isGood].copy()
293
294 x = goodCat['slot_Centroid_x']
295 y = goodCat['slot_Centroid_y']
296 z = goodCat[self.refFluxNames.fluxName]/goodCat[fluxNames.fluxName]
297 ids = goodCat['id']
298
299 # We start with an initial fit that is the median offset; this
300 # works well in practice.
301 fitValues = np.median(z)
302
303 ctrl = self.config.fitConfig.makeControl()
304
305 allBad = False
306 for iteration in range(self.config.numIter):
307 resid = z - fitValues
308 # We add a small (epsilon) amount of floating-point slop because
309 # the median_abs_deviation may give a value that is just larger than 0
310 # even if given a completely flat residual field (as in tests).
311 apCorrErr = median_abs_deviation(resid, scale="normal") + 1e-7
312 keep = np.abs(resid) <= self.config.numSigmaClip * apCorrErr
313
314 self.log.debug("Removing %d sources as outliers.", len(resid) - keep.sum())
315
316 x = x[keep]
317 y = y[keep]
318 z = z[keep]
319 ids = ids[keep]
320
321 while (len(x) - ctrl.computeSize()) < self.config.minDegreesOfFreedom:
322 if ctrl.orderX > 0:
323 ctrl.orderX -= 1
324 else:
325 allBad = True
326 break
327 if ctrl.orderY > 0:
328 ctrl.orderY -= 1
329 else:
330 allBad = True
331 break
332
333 if allBad:
334 if name in self.config.allowFailure:
335 self.log.warning("Unable to measure aperture correction for '%s': "
336 "only %d sources remain, but require at least %d." %
337 (name, keep.sum(), self.config.minDegreesOfFreedom + 1))
338 break
339 else:
340 raise MeasureApCorrError(name=name, nSources=keep.sum(),
341 ndof=self.config.minDegreesOfFreedom + 1,
342 iteration=iteration+1)
343
344 apCorrField = ChebyshevBoundedField.fit(bbox, x, y, z, ctrl)
345 fitValues = apCorrField.evaluate(x, y)
346
347 if allBad:
348 continue
349
350 if self.config.doFinalMedianShift:
351 med = np.median(fitValues - z)
352 coeffs = apCorrField.getCoefficients().copy()
353 coeffs[0, 0] -= med
354 apCorrField = ChebyshevBoundedField(bbox, coeffs)
355 fitValues = apCorrField.evaluate(x, y)
356
357 self.log.info(
358 "Aperture correction for %s from %d stars: med %f, MAD %f, RMS %f",
359 name,
360 len(x),
361 np.median(fitValues - z),
362 median_abs_deviation(fitValues - z, scale="normal"),
363 np.mean((fitValues - z)**2.)**0.5,
364 )
365
366 if display:
367 plotApCorr(bbox, x, y, z, apCorrField, "%s, final" % (name,), doPause)
368
369 # Record which sources were used.
370 used = np.zeros(len(catalog), dtype=bool)
371 used[np.searchsorted(catalog['id'], ids)] = True
372 catalog[fluxNames.usedName] = used
373
374 # Save the result in the output map
375 # The error is constant spatially (we could imagine being
376 # more clever, but we're not yet sure if it's worth the effort).
377 # We save the errors as a 0th-order ChebyshevBoundedField
378 apCorrMap[fluxNames.fluxName] = apCorrField
379 apCorrMap[fluxNames.errName] = ChebyshevBoundedField(
380 bbox,
381 np.array([[apCorrErr]]),
382 )
383
384 return Struct(
385 apCorrMap=apCorrMap,
386 )
387
388
389def plotApCorr(bbox, xx, yy, zzMeasure, field, title, doPause):
390 """Plot aperture correction fit residuals
391
392 There are two subplots: residuals against x and y.
393
394 Intended for debugging.
395
396 Parameters
397 ----------
398 bbox : `lsst.geom.Box2I`
399 Bounding box (for bounds)
400 xx, yy : `numpy.ndarray`, (N)
401 x and y coordinates
402 zzMeasure : `float`
403 Measured value of the aperture correction
404 field : `lsst.afw.math.ChebyshevBoundedField`
405 Fit aperture correction field
406 title : 'str'
407 Title for plot
408 doPause : `bool`
409 Pause to inspect the residuals plot? If
410 False, there will be a 4 second delay to
411 allow for inspection of the plot before
412 closing it and moving on.
413 """
414 import matplotlib.pyplot as plt
415
416 zzFit = field.evaluate(xx, yy)
417 residuals = zzMeasure - zzFit
418
419 fig, axes = plt.subplots(2, 1)
420
421 axes[0].scatter(xx, residuals, s=3, marker='o', lw=0, alpha=0.7)
422 axes[1].scatter(yy, residuals, s=3, marker='o', lw=0, alpha=0.7)
423 for ax in axes:
424 ax.set_ylabel("ApCorr Fit Residual")
425 ax.set_ylim(0.9*residuals.min(), 1.1*residuals.max())
426 axes[0].set_xlabel("x")
427 axes[0].set_xlim(bbox.getMinX(), bbox.getMaxX())
428 axes[1].set_xlabel("y")
429 axes[1].set_xlim(bbox.getMinY(), bbox.getMaxY())
430 plt.suptitle(title)
431
432 if not doPause:
433 try:
434 plt.pause(4)
435 plt.close()
436 except Exception:
437 print("%s: plt.pause() failed. Please close plots when done." % __name__)
438 plt.show()
439 else:
440 print("%s: Please close plots when done." % __name__)
441 plt.show()
std::vector< SchemaItem< Flag > > * items
A thin wrapper around std::map to allow aperture corrections to be attached to Exposures.
Definition ApCorrMap.h:45
A BoundedField based on 2-d Chebyshev polynomials of the first kind.
__init__(self, *name, nSources, ndof, iteration=None)
__init__(self, schema, namesToCorrect=None, **kwargs)
plotApCorr(bbox, xx, yy, zzMeasure, field, title, doPause)