Coverage for python/lsst/cp/pipe/linearity.py: 15%
Shortcuts on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# This file is part of cp_pipe.
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/>.
21#
22import numpy as np
24import lsst.afw.image as afwImage
25import lsst.afw.math as afwMath
26import lsst.pipe.base as pipeBase
27import lsst.pipe.base.connectionTypes as cT
28import lsst.pex.config as pexConfig
30from lsstDebug import getDebugFrame
31from lsst.ip.isr import (Linearizer, IsrProvenance)
33from .utils import (funcPolynomial, irlsFit)
34from ._lookupStaticCalibration import lookupStaticCalibration
36__all__ = ["LinearitySolveTask", "LinearitySolveConfig", "MeasureLinearityTask"]
39class LinearitySolveConnections(pipeBase.PipelineTaskConnections,
40 dimensions=("instrument", "exposure", "detector")):
41 dummy = cT.Input(
42 name="raw",
43 doc="Dummy exposure.",
44 storageClass='Exposure',
45 dimensions=("instrument", "exposure", "detector"),
46 multiple=True,
47 deferLoad=True,
48 )
49 camera = cT.PrerequisiteInput(
50 name="camera",
51 doc="Camera Geometry definition.",
52 storageClass="Camera",
53 dimensions=("instrument", ),
54 isCalibration=True,
55 lookupFunction=lookupStaticCalibration,
56 )
57 inputPtc = cT.PrerequisiteInput(
58 name="ptc",
59 doc="Input PTC dataset.",
60 storageClass="PhotonTransferCurveDataset",
61 dimensions=("instrument", "detector"),
62 isCalibration=True,
63 )
65 outputLinearizer = cT.Output(
66 name="linearity",
67 doc="Output linearity measurements.",
68 storageClass="Linearizer",
69 dimensions=("instrument", "detector"),
70 isCalibration=True,
71 )
74class LinearitySolveConfig(pipeBase.PipelineTaskConfig,
75 pipelineConnections=LinearitySolveConnections):
76 """Configuration for solving the linearity from PTC dataset.
77 """
78 linearityType = pexConfig.ChoiceField(
79 dtype=str,
80 doc="Type of linearizer to construct.",
81 default="Squared",
82 allowed={
83 "LookupTable": "Create a lookup table solution.",
84 "Polynomial": "Create an arbitrary polynomial solution.",
85 "Squared": "Create a single order squared solution.",
86 "Spline": "Create a spline based solution.",
87 "None": "Create a dummy solution.",
88 }
89 )
90 polynomialOrder = pexConfig.Field(
91 dtype=int,
92 doc="Degree of polynomial to fit.",
93 default=3,
94 )
95 splineKnots = pexConfig.Field(
96 dtype=int,
97 doc="Number of spline knots to use in fit.",
98 default=10,
99 )
100 maxLookupTableAdu = pexConfig.Field(
101 dtype=int,
102 doc="Maximum DN value for a LookupTable linearizer.",
103 default=2**18,
104 )
105 maxLinearAdu = pexConfig.Field(
106 dtype=float,
107 doc="Maximum DN value to use to estimate linear term.",
108 default=20000.0,
109 )
110 minLinearAdu = pexConfig.Field(
111 dtype=float,
112 doc="Minimum DN value to use to estimate linear term.",
113 default=2000.0,
114 )
115 nSigmaClipLinear = pexConfig.Field(
116 dtype=float,
117 doc="Maximum deviation from linear solution for Poissonian noise.",
118 default=5.0,
119 )
120 ignorePtcMask = pexConfig.Field(
121 dtype=bool,
122 doc="Ignore the expIdMask set by the PTC solver?",
123 default=False,
124 )
127class LinearitySolveTask(pipeBase.PipelineTask, pipeBase.CmdLineTask):
128 """Fit the linearity from the PTC dataset.
129 """
131 ConfigClass = LinearitySolveConfig
132 _DefaultName = 'cpLinearitySolve'
134 def runQuantum(self, butlerQC, inputRefs, outputRefs):
135 """Ensure that the input and output dimensions are passed along.
137 Parameters
138 ----------
139 butlerQC : `lsst.daf.butler.butlerQuantumContext.ButlerQuantumContext`
140 Butler to operate on.
141 inputRefs : `lsst.pipe.base.connections.InputQuantizedConnection`
142 Input data refs to load.
143 ouptutRefs : `lsst.pipe.base.connections.OutputQuantizedConnection`
144 Output data refs to persist.
145 """
146 inputs = butlerQC.get(inputRefs)
148 # Use the dimensions to set calib/provenance information.
149 inputs['inputDims'] = inputRefs.inputPtc.dataId.byName()
151 outputs = self.run(**inputs)
152 butlerQC.put(outputs, outputRefs)
154 def run(self, inputPtc, dummy, camera, inputDims):
155 """Fit non-linearity to PTC data, returning the correct Linearizer
156 object.
158 Parameters
159 ----------
160 inputPtc : `lsst.cp.pipe.PtcDataset`
161 Pre-measured PTC dataset.
162 dummy : `lsst.afw.image.Exposure`
163 The exposure used to select the appropriate PTC dataset.
164 In almost all circumstances, one of the input exposures
165 used to generate the PTC dataset is the best option.
166 camera : `lsst.afw.cameraGeom.Camera`
167 Camera geometry.
168 inputDims : `lsst.daf.butler.DataCoordinate` or `dict`
169 DataIds to use to populate the output calibration.
171 Returns
172 -------
173 results : `lsst.pipe.base.Struct`
174 The results struct containing:
176 ``outputLinearizer``
177 Final linearizer calibration (`lsst.ip.isr.Linearizer`).
178 ``outputProvenance``
179 Provenance data for the new calibration
180 (`lsst.ip.isr.IsrProvenance`).
182 Notes
183 -----
184 This task currently fits only polynomial-defined corrections,
185 where the correction coefficients are defined such that:
186 :math:`corrImage = uncorrImage + \\sum_i c_i uncorrImage^(2 + i)`
187 These :math:`c_i` are defined in terms of the direct polynomial fit:
188 :math:`meanVector ~ P(x=timeVector) = \\sum_j k_j x^j`
189 such that :math:`c_(j-2) = -k_j/(k_1^j)` in units of DN^(1-j) (c.f.,
190 Eq. 37 of 2003.05978). The `config.polynomialOrder` or
191 `config.splineKnots` define the maximum order of :math:`x^j` to fit.
192 As :math:`k_0` and :math:`k_1` are degenerate with bias level and gain,
193 they are not included in the non-linearity correction.
194 """
195 if len(dummy) == 0:
196 self.log.warning("No dummy exposure found.")
198 detector = camera[inputDims['detector']]
199 if self.config.linearityType == 'LookupTable':
200 table = np.zeros((len(detector), self.config.maxLookupTableAdu), dtype=np.float32)
201 tableIndex = 0
202 else:
203 table = None
204 tableIndex = None # This will fail if we increment it.
206 if self.config.linearityType == 'Spline':
207 fitOrder = self.config.splineKnots
208 else:
209 fitOrder = self.config.polynomialOrder
211 # Initialize the linearizer.
212 linearizer = Linearizer(detector=detector, table=table, log=self.log)
214 for i, amp in enumerate(detector):
215 ampName = amp.getName()
216 if ampName in inputPtc.badAmps:
217 nEntries = 1
218 pEntries = 1
219 if self.config.linearityType in ['Polynomial']:
220 nEntries = fitOrder + 1
221 pEntries = fitOrder + 1
222 elif self.config.linearityType in ['Spline']:
223 nEntries = fitOrder * 2
224 elif self.config.linearityType in ['Squared', 'None']:
225 nEntries = 1
226 pEntries = fitOrder + 1
227 elif self.config.linearityType in ['LookupTable']:
228 nEntries = 2
229 pEntries = fitOrder + 1
231 linearizer.linearityType[ampName] = "None"
232 linearizer.linearityCoeffs[ampName] = np.zeros(nEntries)
233 linearizer.linearityBBox[ampName] = amp.getBBox()
234 linearizer.fitParams[ampName] = np.zeros(pEntries)
235 linearizer.fitParamsErr[ampName] = np.zeros(pEntries)
236 linearizer.fitChiSq[ampName] = np.nan
237 self.log.warning("Amp %s has no usable PTC information. Skipping!", ampName)
238 continue
240 if (len(inputPtc.expIdMask[ampName]) == 0) or self.config.ignorePtcMask:
241 self.log.warning(f"Mask not found for {ampName} in non-linearity fit. Using all points.")
242 mask = np.repeat(True, len(inputPtc.expIdMask[ampName]))
243 else:
244 mask = np.array(inputPtc.expIdMask[ampName], dtype=bool)
246 inputAbscissa = np.array(inputPtc.rawExpTimes[ampName])[mask]
247 inputOrdinate = np.array(inputPtc.rawMeans[ampName])[mask]
249 # Determine proxy-to-linear-flux transformation
250 fluxMask = inputOrdinate < self.config.maxLinearAdu
251 lowMask = inputOrdinate > self.config.minLinearAdu
252 fluxMask = fluxMask & lowMask
253 linearAbscissa = inputAbscissa[fluxMask]
254 linearOrdinate = inputOrdinate[fluxMask]
256 linearFit, linearFitErr, chiSq, weights = irlsFit([0.0, 100.0], linearAbscissa,
257 linearOrdinate, funcPolynomial)
258 # Convert this proxy-to-flux fit into an expected linear flux
259 linearOrdinate = linearFit[0] + linearFit[1] * inputAbscissa
261 # Exclude low end outliers
262 threshold = self.config.nSigmaClipLinear * np.sqrt(linearOrdinate)
263 fluxMask = np.abs(inputOrdinate - linearOrdinate) < threshold
264 linearOrdinate = linearOrdinate[fluxMask]
265 fitOrdinate = inputOrdinate[fluxMask]
266 self.debugFit('linearFit', inputAbscissa, inputOrdinate, linearOrdinate, fluxMask, ampName)
267 # Do fits
268 if self.config.linearityType in ['Polynomial', 'Squared', 'LookupTable']:
269 polyFit = np.zeros(fitOrder + 1)
270 polyFit[1] = 1.0
271 polyFit, polyFitErr, chiSq, weights = irlsFit(polyFit, linearOrdinate,
272 fitOrdinate, funcPolynomial)
274 # Truncate the polynomial fit
275 k1 = polyFit[1]
276 linearityFit = [-coeff/(k1**order) for order, coeff in enumerate(polyFit)]
277 significant = np.where(np.abs(linearityFit) > 1e-10, True, False)
278 self.log.info(f"Significant polynomial fits: {significant}")
280 modelOrdinate = funcPolynomial(polyFit, linearAbscissa)
281 self.debugFit('polyFit', linearAbscissa, fitOrdinate, modelOrdinate, None, ampName)
283 if self.config.linearityType == 'Squared':
284 linearityFit = [linearityFit[2]]
285 elif self.config.linearityType == 'LookupTable':
286 # Use linear part to get time at wich signal is
287 # maxAduForLookupTableLinearizer DN
288 tMax = (self.config.maxLookupTableAdu - polyFit[0])/polyFit[1]
289 timeRange = np.linspace(0, tMax, self.config.maxLookupTableAdu)
290 signalIdeal = polyFit[0] + polyFit[1]*timeRange
291 signalUncorrected = funcPolynomial(polyFit, timeRange)
292 lookupTableRow = signalIdeal - signalUncorrected # LinearizerLookupTable has correction
294 linearizer.tableData[tableIndex, :] = lookupTableRow
295 linearityFit = [tableIndex, 0]
296 tableIndex += 1
297 elif self.config.linearityType in ['Spline']:
298 # See discussion in `lsst.ip.isr.linearize.py` before
299 # modifying.
300 numPerBin, binEdges = np.histogram(linearOrdinate, bins=fitOrder)
301 with np.errstate(invalid="ignore"):
302 # Algorithm note: With the counts of points per
303 # bin above, the next histogram calculates the
304 # values to put in each bin by weighting each
305 # point by the correction value.
306 values = np.histogram(linearOrdinate, bins=fitOrder,
307 weights=(inputOrdinate[fluxMask] - linearOrdinate))[0]/numPerBin
309 # After this is done, the binCenters are
310 # calculated by weighting by the value we're
311 # binning over. This ensures that widely
312 # spaced/poorly sampled data aren't assigned to
313 # the midpoint of the bin (as could be done using
314 # the binEdges above), but to the weighted mean of
315 # the inputs. Note that both histograms are
316 # scaled by the count per bin to normalize what
317 # the histogram returns (a sum of the points
318 # inside) into an average.
319 binCenters = np.histogram(linearOrdinate, bins=fitOrder,
320 weights=linearOrdinate)[0]/numPerBin
321 values = values[numPerBin > 0]
322 binCenters = binCenters[numPerBin > 0]
324 self.debugFit('splineFit', binCenters, np.abs(values), values, None, ampName)
325 interp = afwMath.makeInterpolate(binCenters.tolist(), values.tolist(),
326 afwMath.stringToInterpStyle("AKIMA_SPLINE"))
327 modelOrdinate = linearOrdinate + interp.interpolate(linearOrdinate)
328 self.debugFit('splineFit', linearOrdinate, fitOrdinate, modelOrdinate, None, ampName)
330 # If we exclude a lot of points, we may end up with
331 # less than fitOrder points. Pad out the low-flux end
332 # to ensure equal lengths.
333 if len(binCenters) != fitOrder:
334 padN = fitOrder - len(binCenters)
335 binCenters = np.pad(binCenters, (padN, 0), 'linear_ramp',
336 end_values=(binCenters.min() - 1.0, ))
337 # This stores the correction, which is zero at low values.
338 values = np.pad(values, (padN, 0))
340 # Pack the spline into a single array.
341 linearityFit = np.concatenate((binCenters.tolist(), values.tolist())).tolist()
342 polyFit = [0.0]
343 polyFitErr = [0.0]
344 chiSq = np.nan
345 else:
346 polyFit = [0.0]
347 polyFitErr = [0.0]
348 chiSq = np.nan
349 linearityFit = [0.0]
351 linearizer.linearityType[ampName] = self.config.linearityType
352 linearizer.linearityCoeffs[ampName] = np.array(linearityFit)
353 linearizer.linearityBBox[ampName] = amp.getBBox()
354 linearizer.fitParams[ampName] = np.array(polyFit)
355 linearizer.fitParamsErr[ampName] = np.array(polyFitErr)
356 linearizer.fitChiSq[ampName] = chiSq
358 image = afwImage.ImageF(len(inputOrdinate), 1)
359 image.getArray()[:, :] = inputOrdinate
360 linearizeFunction = linearizer.getLinearityTypeByName(linearizer.linearityType[ampName])
361 linearizeFunction()(image,
362 **{'coeffs': linearizer.linearityCoeffs[ampName],
363 'table': linearizer.tableData,
364 'log': linearizer.log})
365 linearizeModel = image.getArray()[0, :]
367 self.debugFit('solution', inputOrdinate[fluxMask], linearOrdinate,
368 linearizeModel[fluxMask], None, ampName)
370 linearizer.hasLinearity = True
371 linearizer.validate()
372 linearizer.updateMetadata(camera=camera, detector=detector, filterName='NONE')
373 linearizer.updateMetadata(setDate=True, setCalibId=True)
374 provenance = IsrProvenance(calibType='linearizer')
376 return pipeBase.Struct(
377 outputLinearizer=linearizer,
378 outputProvenance=provenance,
379 )
381 def debugFit(self, stepname, xVector, yVector, yModel, mask, ampName):
382 """Debug method for linearity fitting.
384 Parameters
385 ----------
386 stepname : `str`
387 A label to use to check if we care to debug at a given
388 line of code.
389 xVector : `numpy.array`, (N,)
390 The values to use as the independent variable in the
391 linearity fit.
392 yVector : `numpy.array`, (N,)
393 The values to use as the dependent variable in the
394 linearity fit.
395 yModel : `numpy.array`, (N,)
396 The values to use as the linearized result.
397 mask : `numpy.array` [`bool`], (N,) , optional
398 A mask to indicate which entries of ``xVector`` and
399 ``yVector`` to keep.
400 ampName : `str`
401 Amplifier name to lookup linearity correction values.
402 """
403 frame = getDebugFrame(self._display, stepname)
404 if frame:
405 import matplotlib.pyplot as plt
406 fig, axs = plt.subplots(2)
408 if mask is None:
409 mask = np.ones_like(xVector, dtype=bool)
411 fig.suptitle(f"{stepname} {ampName} {self.config.linearityType}")
412 if stepname == 'linearFit':
413 axs[0].set_xlabel("Input Abscissa (time or mondiode)")
414 axs[0].set_ylabel("Input Ordinate (flux)")
415 axs[1].set_xlabel("Linear Ordinate (linear flux)")
416 axs[1].set_ylabel("Flux Difference: (input - linear)")
417 elif stepname in ('polyFit', 'splineFit'):
418 axs[0].set_xlabel("Linear Abscissa (linear flux)")
419 axs[0].set_ylabel("Input Ordinate (flux)")
420 axs[1].set_xlabel("Linear Ordinate (linear flux)")
421 axs[1].set_ylabel("Flux Difference: (input - full model fit)")
422 elif stepname == 'solution':
423 axs[0].set_xlabel("Input Abscissa (time or mondiode)")
424 axs[0].set_ylabel("Linear Ordinate (linear flux)")
425 axs[1].set_xlabel("Model flux (linear flux)")
426 axs[1].set_ylabel("Flux Difference: (linear - model)")
428 axs[0].set_yscale('log')
429 axs[0].set_xscale('log')
430 axs[0].scatter(xVector, yVector)
431 axs[0].scatter(xVector[~mask], yVector[~mask], c='red', marker='x')
432 axs[1].set_xscale('log')
434 axs[1].scatter(yModel, yVector[mask] - yModel)
435 fig.show()
437 prompt = "Press Enter or c to continue [chpx]..."
438 while True:
439 ans = input(prompt).lower()
440 if ans in ("", " ", "c",):
441 break
442 elif ans in ("p", ):
443 import pdb
444 pdb.set_trace()
445 elif ans in ("h", ):
446 print("[h]elp [c]ontinue [p]db")
447 elif ans in ('x', ):
448 exit()
449 plt.close()
452class MeasureLinearityConfig(pexConfig.Config):
453 solver = pexConfig.ConfigurableField(
454 target=LinearitySolveTask,
455 doc="Task to convert PTC data to linearity solutions.",
456 )
459class MeasureLinearityTask(pipeBase.CmdLineTask):
460 """Stand alone Gen2 linearity measurement.
462 This class wraps the Gen3 linearity task to allow it to be run as
463 a Gen2 CmdLineTask.
464 """
466 ConfigClass = MeasureLinearityConfig
467 _DefaultName = "measureLinearity"
469 def __init__(self, **kwargs):
470 super().__init__(**kwargs)
471 self.makeSubtask("solver")
473 def runDataRef(self, dataRef):
474 """Run new linearity code for gen2.
476 Parameters
477 ----------
478 dataRef : `lsst.daf.persistence.ButlerDataRef`
479 Input dataref for the photon transfer curve data.
481 Returns
482 -------
483 results : `lsst.pipe.base.Struct`
484 The results struct containing:
486 ``outputLinearizer``
487 Final linearizer calibration (`lsst.ip.isr.Linearizer`).
488 ``outputProvenance``
489 Provenance data for the new calibration
490 (`lsst.ip.isr.IsrProvenance`).
491 """
492 ptc = dataRef.get('photonTransferCurveDataset')
493 camera = dataRef.get('camera')
494 inputDims = dataRef.dataId # This is the closest gen2 has.
495 linearityResults = self.solver.run(ptc, camera=camera, inputDims=inputDims)
497 inputDims['calibDate'] = linearityResults.outputLinearizer.getMetadata().get('CALIBDATE')
498 butler = dataRef.getButler()
499 butler.put(linearityResults.outputLinearizer, "linearizer", inputDims)
500 return linearityResults