Coverage for python/lsst/cp/pipe/makeBrighterFatterKernel.py: 13%
214 statements
« prev ^ index » next coverage.py v7.3.0, created at 2023-09-01 11:01 +0000
« prev ^ index » next coverage.py v7.3.0, created at 2023-09-01 11:01 +0000
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#
22"""Calculation of brighter-fatter effect correlations and kernels."""
24__all__ = ['BrighterFatterKernelSolveTask',
25 'BrighterFatterKernelSolveConfig']
27import numpy as np
29import lsst.afw.math as afwMath
30import lsst.pex.config as pexConfig
31import lsst.pipe.base as pipeBase
32import lsst.pipe.base.connectionTypes as cT
34from lsst.ip.isr import (BrighterFatterKernel)
35from .utils import (funcPolynomial, irlsFit)
38class BrighterFatterKernelSolveConnections(pipeBase.PipelineTaskConnections,
39 dimensions=("instrument", "exposure", "detector")):
40 dummy = cT.Input(
41 name="raw",
42 doc="Dummy exposure.",
43 storageClass='Exposure',
44 dimensions=("instrument", "exposure", "detector"),
45 multiple=True,
46 deferLoad=True,
47 )
48 camera = cT.PrerequisiteInput(
49 name="camera",
50 doc="Camera associated with this data.",
51 storageClass="Camera",
52 dimensions=("instrument", ),
53 isCalibration=True,
54 )
55 inputPtc = cT.PrerequisiteInput(
56 name="ptc",
57 doc="Photon transfer curve dataset.",
58 storageClass="PhotonTransferCurveDataset",
59 dimensions=("instrument", "detector"),
60 isCalibration=True,
61 )
63 outputBFK = cT.Output(
64 name="brighterFatterKernel",
65 doc="Output measured brighter-fatter kernel.",
66 storageClass="BrighterFatterKernel",
67 dimensions=("instrument", "detector"),
68 isCalibration=True,
69 )
72class BrighterFatterKernelSolveConfig(pipeBase.PipelineTaskConfig,
73 pipelineConnections=BrighterFatterKernelSolveConnections):
74 level = pexConfig.ChoiceField(
75 doc="The level at which to calculate the brighter-fatter kernels",
76 dtype=str,
77 default="AMP",
78 allowed={
79 "AMP": "Every amplifier treated separately",
80 "DETECTOR": "One kernel per detector",
81 }
82 )
83 ignoreAmpsForAveraging = pexConfig.ListField(
84 dtype=str,
85 doc="List of amp names to ignore when averaging the amplifier kernels into the detector"
86 " kernel. Only relevant for level = DETECTOR",
87 default=[]
88 )
89 xcorrCheckRejectLevel = pexConfig.Field(
90 dtype=float,
91 doc="Rejection level for the sum of the input cross-correlations. Arrays which "
92 "sum to greater than this are discarded before the clipped mean is calculated.",
93 default=2.0
94 )
95 nSigmaClip = pexConfig.Field(
96 dtype=float,
97 doc="Number of sigma to clip when calculating means for the cross-correlation",
98 default=5
99 )
100 forceZeroSum = pexConfig.Field(
101 dtype=bool,
102 doc="Force the correlation matrix to have zero sum by adjusting the (0,0) value?",
103 default=False,
104 )
105 useAmatrix = pexConfig.Field(
106 dtype=bool,
107 doc="Use the PTC 'a' matrix (Astier et al. 2019 equation 20) "
108 "instead of the average of measured covariances?",
109 default=False,
110 )
112 maxIterSuccessiveOverRelaxation = pexConfig.Field(
113 dtype=int,
114 doc="The maximum number of iterations allowed for the successive over-relaxation method",
115 default=10000
116 )
117 eLevelSuccessiveOverRelaxation = pexConfig.Field(
118 dtype=float,
119 doc="The target residual error for the successive over-relaxation method",
120 default=5.0e-14
121 )
123 correlationQuadraticFit = pexConfig.Field(
124 dtype=bool,
125 doc="Use a quadratic fit to find the correlations instead of simple averaging?",
126 default=False,
127 )
128 correlationModelRadius = pexConfig.Field(
129 dtype=int,
130 doc="Build a model of the correlation coefficients for radii larger than this value in pixels?",
131 default=100,
132 )
133 correlationModelSlope = pexConfig.Field(
134 dtype=float,
135 doc="Slope of the correlation model for radii larger than correlationModelRadius",
136 default=-1.35,
137 )
140class BrighterFatterKernelSolveTask(pipeBase.PipelineTask):
141 """Measure appropriate Brighter-Fatter Kernel from the PTC dataset.
142 """
144 ConfigClass = BrighterFatterKernelSolveConfig
145 _DefaultName = 'cpBfkMeasure'
147 def runQuantum(self, butlerQC, inputRefs, outputRefs):
148 """Ensure that the input and output dimensions are passed along.
150 Parameters
151 ----------
152 butlerQC : `lsst.daf.butler.butlerQuantumContext.ButlerQuantumContext`
153 Butler to operate on.
154 inputRefs : `lsst.pipe.base.connections.InputQuantizedConnection`
155 Input data refs to load.
156 ouptutRefs : `lsst.pipe.base.connections.OutputQuantizedConnection`
157 Output data refs to persist.
158 """
159 inputs = butlerQC.get(inputRefs)
161 # Use the dimensions to set calib/provenance information.
162 inputs['inputDims'] = inputRefs.inputPtc.dataId.byName()
164 outputs = self.run(**inputs)
165 butlerQC.put(outputs, outputRefs)
167 def run(self, inputPtc, dummy, camera, inputDims):
168 """Combine covariance information from PTC into brighter-fatter
169 kernels.
171 Parameters
172 ----------
173 inputPtc : `lsst.ip.isr.PhotonTransferCurveDataset`
174 PTC data containing per-amplifier covariance measurements.
175 dummy : `lsst.afw.image.Exposure`
176 The exposure used to select the appropriate PTC dataset.
177 In almost all circumstances, one of the input exposures
178 used to generate the PTC dataset is the best option.
179 camera : `lsst.afw.cameraGeom.Camera`
180 Camera to use for camera geometry information.
181 inputDims : `lsst.daf.butler.DataCoordinate` or `dict`
182 DataIds to use to populate the output calibration.
184 Returns
185 -------
186 results : `lsst.pipe.base.Struct`
187 The resulst struct containing:
189 ``outputBfk``
190 Resulting Brighter-Fatter Kernel
191 (`lsst.ip.isr.BrighterFatterKernel`).
192 """
193 if len(dummy) == 0:
194 self.log.warning("No dummy exposure found.")
196 detector = camera[inputDims['detector']]
197 detName = detector.getName()
199 if self.config.level == 'DETECTOR':
200 detectorCorrList = list()
201 detectorFluxes = list()
203 bfk = BrighterFatterKernel(camera=camera, detectorId=detector.getId(), level=self.config.level)
204 bfk.rawMeans = inputPtc.rawMeans # ADU
205 bfk.rawVariances = inputPtc.rawVars # ADU^2
206 bfk.expIdMask = inputPtc.expIdMask
208 # Use the PTC covariances as the cross-correlations. These
209 # are scaled before the kernel is generated, which performs
210 # the conversion. The input covariances are in (x, y) index
211 # ordering, as is the aMatrix.
212 bfk.rawXcorrs = inputPtc.covariances # ADU^2
213 bfk.badAmps = inputPtc.badAmps
214 bfk.shape = (inputPtc.covMatrixSide*2 + 1, inputPtc.covMatrixSide*2 + 1)
215 bfk.gain = inputPtc.gain
216 bfk.noise = inputPtc.noise
217 bfk.meanXcorrs = dict()
218 bfk.valid = dict()
219 bfk.updateMetadataFromExposures([inputPtc])
221 for amp in detector:
222 ampName = amp.getName()
223 gain = bfk.gain[ampName]
224 mask = inputPtc.expIdMask[ampName]
225 if gain <= 0:
226 # We've received very bad data.
227 self.log.warning("Impossible gain recieved from PTC for %s: %f. Skipping bad amplifier.",
228 ampName, gain)
229 bfk.meanXcorrs[ampName] = np.zeros(bfk.shape)
230 bfk.ampKernels[ampName] = np.zeros(bfk.shape)
231 bfk.rawXcorrs[ampName] = np.zeros((len(mask), inputPtc.covMatrixSide, inputPtc.covMatrixSide))
232 bfk.valid[ampName] = False
233 continue
235 # Use inputPtc.expIdMask to get the means, variances, and
236 # covariances that were not masked after PTC. The
237 # covariances may now have the mask already applied.
238 fluxes = np.array(bfk.rawMeans[ampName])[mask]
239 variances = np.array(bfk.rawVariances[ampName])[mask]
240 xCorrList = np.array([np.array(xcorr) for xcorr in bfk.rawXcorrs[ampName]])
241 if np.sum(mask) < len(xCorrList):
242 # Only apply the mask if needed.
243 xCorrList = xCorrList[mask]
245 fluxes = np.array([flux*gain for flux in fluxes]) # Now in e^-
246 variances = np.array([variance*gain*gain for variance in variances]) # Now in e^2-
248 # This should duplicate Coulton et al. 2017 Equation 22-29
249 # (arxiv:1711.06273)
250 scaledCorrList = list()
251 corrList = list()
252 truncatedFluxes = list()
253 for xcorrNum, (xcorr, flux, var) in enumerate(zip(xCorrList, fluxes, variances), 1):
254 q = np.array(xcorr) * gain * gain # xcorr now in e^-
255 q *= 2.0 # Remove factor of 1/2 applied in PTC.
256 self.log.info("Amp: %s %d/%d Flux: %f Var: %f Q(0,0): %g Q(1,0): %g Q(0,1): %g",
257 ampName, xcorrNum, len(xCorrList), flux, var, q[0][0], q[1][0], q[0][1])
259 # Normalize by the flux, which removes the (0,0)
260 # component attributable to Poisson noise. This
261 # contains the two "t I delta(x - x')" terms in
262 # Coulton et al. 2017 equation 29
263 q[0][0] -= 2.0*(flux)
265 if q[0][0] > 0.0:
266 self.log.warning("Amp: %s %d skipped due to value of (variance-mean)=%f",
267 ampName, xcorrNum, q[0][0])
268 # If we drop an element of ``scaledCorrList``
269 # (which is what this does), we need to ensure we
270 # drop the flux entry as well.
271 continue
273 # This removes the "t (I_a^2 + I_b^2)" factor in
274 # Coulton et al. 2017 equation 29.
275 # The quadratic fit option needs the correlations unscaled
276 q /= -2.0
277 unscaled = self._tileArray(q)
278 q /= flux**2
279 scaled = self._tileArray(q)
280 xcorrCheck = np.abs(np.sum(scaled))/np.sum(np.abs(scaled))
281 if (xcorrCheck > self.config.xcorrCheckRejectLevel) or not (np.isfinite(xcorrCheck)):
282 self.log.warning("Amp: %s %d skipped due to value of triangle-inequality sum %f",
283 ampName, xcorrNum, xcorrCheck)
284 continue
286 scaledCorrList.append(scaled)
287 corrList.append(unscaled)
288 truncatedFluxes.append(flux)
289 self.log.info("Amp: %s %d/%d Final: %g XcorrCheck: %f",
290 ampName, xcorrNum, len(xCorrList), q[0][0], xcorrCheck)
292 fluxes = np.array(truncatedFluxes)
294 if len(scaledCorrList) == 0:
295 self.log.warning("Amp: %s All inputs rejected for amp!", ampName)
296 bfk.meanXcorrs[ampName] = np.zeros(bfk.shape)
297 bfk.ampKernels[ampName] = np.zeros(bfk.shape)
298 bfk.valid[ampName] = False
299 continue
301 if self.config.useAmatrix:
302 # Use the aMatrix, ignoring the meanXcorr generated above.
303 preKernel = np.pad(self._tileArray(-1.0 * np.array(inputPtc.aMatrix[ampName])), ((1, 1)))
304 elif self.config.correlationQuadraticFit:
305 # Use a quadratic fit to the correlations as a
306 # function of flux.
307 preKernel = self.quadraticCorrelations(corrList, fluxes, f"Amp: {ampName}")
308 else:
309 # Use a simple average of the measured correlations.
310 preKernel = self.averageCorrelations(scaledCorrList, f"Amp: {ampName}")
312 center = int((bfk.shape[0] - 1) / 2)
314 if self.config.forceZeroSum:
315 totalSum = np.sum(preKernel)
317 if self.config.correlationModelRadius < (preKernel.shape[0] - 1) / 2:
318 # Assume a correlation model of
319 # Corr(r) = -preFactor * r^(2 * slope)
320 preFactor = np.sqrt(preKernel[center, center + 1] * preKernel[center + 1, center])
321 slopeFactor = 2.0 * np.abs(self.config.correlationModelSlope)
322 totalSum += 2.0*np.pi*(preFactor / (slopeFactor*(center + 0.5))**slopeFactor)
324 preKernel[center, center] -= totalSum
325 self.log.info("%s Zero-Sum Scale: %g", ampName, totalSum)
327 finalSum = np.sum(preKernel)
328 bfk.meanXcorrs[ampName] = preKernel
330 postKernel = self.successiveOverRelax(preKernel)
331 bfk.ampKernels[ampName] = postKernel
332 if self.config.level == 'DETECTOR' and ampName not in self.config.ignoreAmpsForAveraging:
333 detectorCorrList.extend(scaledCorrList)
334 detectorFluxes.extend(fluxes)
335 bfk.valid[ampName] = True
336 self.log.info("Amp: %s Sum: %g Center Info Pre: %g Post: %g",
337 ampName, finalSum, preKernel[center, center], postKernel[center, center])
339 # Assemble a detector kernel?
340 if self.config.level == 'DETECTOR':
341 if self.config.correlationQuadraticFit:
342 preKernel = self.quadraticCorrelations(detectorCorrList, detectorFluxes, f"Amp: {ampName}")
343 else:
344 preKernel = self.averageCorrelations(detectorCorrList, f"Det: {detName}")
345 finalSum = np.sum(preKernel)
346 center = int((bfk.shape[0] - 1) / 2)
348 postKernel = self.successiveOverRelax(preKernel)
349 bfk.detKernels[detName] = postKernel
350 self.log.info("Det: %s Sum: %g Center Info Pre: %g Post: %g",
351 detName, finalSum, preKernel[center, center], postKernel[center, center])
353 return pipeBase.Struct(
354 outputBFK=bfk,
355 )
357 def averageCorrelations(self, xCorrList, name):
358 """Average input correlations.
360 Parameters
361 ----------
362 xCorrList : `list` [`numpy.array`]
363 List of cross-correlations. These are expected to be
364 square arrays.
365 name : `str`
366 Name for log messages.
368 Returns
369 -------
370 meanXcorr : `numpy.array`, (N, N)
371 The averaged cross-correlation.
372 """
373 meanXcorr = np.zeros_like(xCorrList[0])
374 xCorrList = np.array(xCorrList)
376 sctrl = afwMath.StatisticsControl()
377 sctrl.setNumSigmaClip(self.config.nSigmaClip)
378 for i in range(np.shape(meanXcorr)[0]):
379 for j in range(np.shape(meanXcorr)[1]):
380 meanXcorr[i, j] = afwMath.makeStatistics(xCorrList[:, i, j],
381 afwMath.MEANCLIP, sctrl).getValue()
383 # To match previous definitions, pad by one element.
384 meanXcorr = np.pad(meanXcorr, ((1, 1)))
386 return meanXcorr
388 def quadraticCorrelations(self, xCorrList, fluxList, name):
389 """Measure a quadratic correlation model.
391 Parameters
392 ----------
393 xCorrList : `list` [`numpy.array`]
394 List of cross-correlations. These are expected to be
395 square arrays.
396 fluxList : `numpy.array`, (Nflux,)
397 Associated list of fluxes.
398 name : `str`
399 Name for log messages.
401 Returns
402 -------
403 meanXcorr : `numpy.array`, (N, N)
404 The averaged cross-correlation.
405 """
406 meanXcorr = np.zeros_like(xCorrList[0])
407 fluxList = np.square(fluxList)
408 xCorrList = np.array(xCorrList)
410 for i in range(np.shape(meanXcorr)[0]):
411 for j in range(np.shape(meanXcorr)[1]):
412 # Fit corrlation_i(x, y) = a0 + a1 * (flux_i)^2 We do
413 # not want to transpose, so use (i, j) without
414 # inversion.
415 linearFit, linearFitErr, chiSq, weights = irlsFit([0.0, 1e-4], fluxList,
416 xCorrList[:, i, j], funcPolynomial,
417 scaleResidual=False)
418 meanXcorr[i, j] = linearFit[1] # Discard the intercept.
419 self.log.info("Quad fit meanXcorr[%d,%d] = %g", i, j, linearFit[1])
421 # To match previous definitions, pad by one element.
422 meanXcorr = np.pad(meanXcorr, ((1, 1)))
424 return meanXcorr
426 @staticmethod
427 def _tileArray(in_array):
428 """Given an input quarter-image, tile/mirror it and return full image.
430 Given a square input of side-length n, of the form
432 input = array([[1, 2, 3],
433 [4, 5, 6],
434 [7, 8, 9]])
436 return an array of size 2n-1 as
438 output = array([[ 9, 8, 7, 8, 9],
439 [ 6, 5, 4, 5, 6],
440 [ 3, 2, 1, 2, 3],
441 [ 6, 5, 4, 5, 6],
442 [ 9, 8, 7, 8, 9]])
444 Parameters
445 ----------
446 input : `np.array`, (N, N)
447 The square input quarter-array
449 Returns
450 -------
451 output : `np.array`, (2*N + 1, 2*N + 1)
452 The full, tiled array
453 """
454 assert in_array.shape[0] == in_array.shape[1]
455 length = in_array.shape[0] - 1
456 output = np.zeros((2*length + 1, 2*length + 1))
458 for i in range(length + 1):
459 for j in range(length + 1):
460 output[i + length, j + length] = in_array[i, j]
461 output[-i + length, j + length] = in_array[i, j]
462 output[i + length, -j + length] = in_array[i, j]
463 output[-i + length, -j + length] = in_array[i, j]
464 return output
466 def successiveOverRelax(self, source, maxIter=None, eLevel=None):
467 """An implementation of the successive over relaxation (SOR) method.
469 A numerical method for solving a system of linear equations
470 with faster convergence than the Gauss-Seidel method.
472 Parameters
473 ----------
474 source : `numpy.ndarray`, (N, N)
475 The input array.
476 maxIter : `int`, optional
477 Maximum number of iterations to attempt before aborting.
478 eLevel : `float`, optional
479 The target error level at which we deem convergence to have
480 occurred.
482 Returns
483 -------
484 output : `numpy.ndarray`, (N, N)
485 The solution.
486 """
487 if not maxIter:
488 maxIter = self.config.maxIterSuccessiveOverRelaxation
489 if not eLevel:
490 eLevel = self.config.eLevelSuccessiveOverRelaxation
492 assert source.shape[0] == source.shape[1], "Input array must be square"
493 # initialize, and set boundary conditions
494 func = np.zeros([source.shape[0] + 2, source.shape[1] + 2])
495 resid = np.zeros([source.shape[0] + 2, source.shape[1] + 2])
496 rhoSpe = np.cos(np.pi/source.shape[0]) # Here a square grid is assumed
498 # Calculate the initial error
499 for i in range(1, func.shape[0] - 1):
500 for j in range(1, func.shape[1] - 1):
501 resid[i, j] = (func[i, j - 1] + func[i, j + 1] + func[i - 1, j]
502 + func[i + 1, j] - 4*func[i, j] - source[i - 1, j - 1])
503 inError = np.sum(np.abs(resid))
505 # Iterate until convergence
506 # We perform two sweeps per cycle,
507 # updating 'odd' and 'even' points separately
508 nIter = 0
509 omega = 1.0
510 dx = 1.0
511 while nIter < maxIter*2:
512 outError = 0
513 if nIter%2 == 0:
514 for i in range(1, func.shape[0] - 1, 2):
515 for j in range(1, func.shape[1] - 1, 2):
516 resid[i, j] = float(func[i, j-1] + func[i, j + 1] + func[i - 1, j]
517 + func[i + 1, j] - 4.0*func[i, j] - dx*dx*source[i - 1, j - 1])
518 func[i, j] += omega*resid[i, j]*.25
519 for i in range(2, func.shape[0] - 1, 2):
520 for j in range(2, func.shape[1] - 1, 2):
521 resid[i, j] = float(func[i, j - 1] + func[i, j + 1] + func[i - 1, j]
522 + func[i + 1, j] - 4.0*func[i, j] - dx*dx*source[i - 1, j - 1])
523 func[i, j] += omega*resid[i, j]*.25
524 else:
525 for i in range(1, func.shape[0] - 1, 2):
526 for j in range(2, func.shape[1] - 1, 2):
527 resid[i, j] = float(func[i, j - 1] + func[i, j + 1] + func[i - 1, j]
528 + func[i + 1, j] - 4.0*func[i, j] - dx*dx*source[i - 1, j - 1])
529 func[i, j] += omega*resid[i, j]*.25
530 for i in range(2, func.shape[0] - 1, 2):
531 for j in range(1, func.shape[1] - 1, 2):
532 resid[i, j] = float(func[i, j - 1] + func[i, j + 1] + func[i - 1, j]
533 + func[i + 1, j] - 4.0*func[i, j] - dx*dx*source[i - 1, j - 1])
534 func[i, j] += omega*resid[i, j]*.25
535 outError = np.sum(np.abs(resid))
536 if outError < inError*eLevel:
537 break
538 if nIter == 0:
539 omega = 1.0/(1 - rhoSpe*rhoSpe/2.0)
540 else:
541 omega = 1.0/(1 - rhoSpe*rhoSpe*omega/4.0)
542 nIter += 1
544 if nIter >= maxIter*2:
545 self.log.warning("Failure: SuccessiveOverRelaxation did not converge in %s iterations."
546 "\noutError: %s, inError: %s,", nIter//2, outError, inError*eLevel)
547 else:
548 self.log.info("Success: SuccessiveOverRelaxation converged in %s iterations."
549 "\noutError: %s, inError: %s", nIter//2, outError, inError*eLevel)
550 return func[1: -1, 1: -1]