Coverage for python/lsst/cp/pipe/makeBrighterFatterKernel.py : 13%

Hot-keys 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#
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)
36from ._lookupStaticCalibration import lookupStaticCalibration
39class BrighterFatterKernelSolveConnections(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 associated with this data.",
52 storageClass="Camera",
53 dimensions=("instrument", ),
54 isCalibration=True,
55 lookupFunction=lookupStaticCalibration,
56 )
57 inputPtc = cT.PrerequisiteInput(
58 name="ptc",
59 doc="Photon transfer curve dataset.",
60 storageClass="PhotonTransferCurveDataset",
61 dimensions=("instrument", "detector"),
62 isCalibration=True,
63 )
65 outputBFK = cT.Output(
66 name="brighterFatterKernel",
67 doc="Output measured brighter-fatter kernel.",
68 storageClass="BrighterFatterKernel",
69 dimensions=("instrument", "detector"),
70 isCalibration=True,
71 )
74class BrighterFatterKernelSolveConfig(pipeBase.PipelineTaskConfig,
75 pipelineConnections=BrighterFatterKernelSolveConnections):
76 level = pexConfig.ChoiceField(
77 doc="The level at which to calculate the brighter-fatter kernels",
78 dtype=str,
79 default="AMP",
80 allowed={
81 "AMP": "Every amplifier treated separately",
82 "DETECTOR": "One kernel per detector",
83 }
84 )
85 ignoreAmpsForAveraging = pexConfig.ListField(
86 dtype=str,
87 doc="List of amp names to ignore when averaging the amplifier kernels into the detector"
88 " kernel. Only relevant for level = DETECTOR",
89 default=[]
90 )
91 xcorrCheckRejectLevel = pexConfig.Field(
92 dtype=float,
93 doc="Rejection level for the sum of the input cross-correlations. Arrays which "
94 "sum to greater than this are discarded before the clipped mean is calculated.",
95 default=2.0
96 )
97 nSigmaClip = pexConfig.Field(
98 dtype=float,
99 doc="Number of sigma to clip when calculating means for the cross-correlation",
100 default=5
101 )
102 forceZeroSum = pexConfig.Field(
103 dtype=bool,
104 doc="Force the correlation matrix to have zero sum by adjusting the (0,0) value?",
105 default=False,
106 )
107 useAmatrix = pexConfig.Field(
108 dtype=bool,
109 doc="Use the PTC 'a' matrix (Astier et al. 2019 equation 20) "
110 "instead of the average of measured covariances?",
111 default=False,
112 )
114 maxIterSuccessiveOverRelaxation = pexConfig.Field(
115 dtype=int,
116 doc="The maximum number of iterations allowed for the successive over-relaxation method",
117 default=10000
118 )
119 eLevelSuccessiveOverRelaxation = pexConfig.Field(
120 dtype=float,
121 doc="The target residual error for the successive over-relaxation method",
122 default=5.0e-14
123 )
125 correlationQuadraticFit = pexConfig.Field(
126 dtype=bool,
127 doc="Use a quadratic fit to find the correlations instead of simple averaging?",
128 default=False,
129 )
130 correlationModelRadius = pexConfig.Field(
131 dtype=int,
132 doc="Build a model of the correlation coefficients for radii larger than this value in pixels?",
133 default=100,
134 )
135 correlationModelSlope = pexConfig.Field(
136 dtype=float,
137 doc="Slope of the correlation model for radii larger than correlationModelRadius",
138 default=-1.35,
139 )
142class BrighterFatterKernelSolveTask(pipeBase.PipelineTask, pipeBase.CmdLineTask):
143 """Measure appropriate Brighter-Fatter Kernel from the PTC dataset.
144 """
146 ConfigClass = BrighterFatterKernelSolveConfig
147 _DefaultName = 'cpBfkMeasure'
149 def runQuantum(self, butlerQC, inputRefs, outputRefs):
150 """Ensure that the input and output dimensions are passed along.
152 Parameters
153 ----------
154 butlerQC : `lsst.daf.butler.butlerQuantumContext.ButlerQuantumContext`
155 Butler to operate on.
156 inputRefs : `lsst.pipe.base.connections.InputQuantizedConnection`
157 Input data refs to load.
158 ouptutRefs : `lsst.pipe.base.connections.OutputQuantizedConnection`
159 Output data refs to persist.
160 """
161 inputs = butlerQC.get(inputRefs)
163 # Use the dimensions to set calib/provenance information.
164 inputs['inputDims'] = inputRefs.inputPtc.dataId.byName()
166 outputs = self.run(**inputs)
167 butlerQC.put(outputs, outputRefs)
169 def run(self, inputPtc, dummy, camera, inputDims):
170 """Combine covariance information from PTC into brighter-fatter
171 kernels.
173 Parameters
174 ----------
175 inputPtc : `lsst.ip.isr.PhotonTransferCurveDataset`
176 PTC data containing per-amplifier covariance measurements.
177 dummy : `lsst.afw.image.Exposure`
178 The exposure used to select the appropriate PTC dataset.
179 In almost all circumstances, one of the input exposures
180 used to generate the PTC dataset is the best option.
181 camera : `lsst.afw.cameraGeom.Camera`
182 Camera to use for camera geometry information.
183 inputDims : `lsst.daf.butler.DataCoordinate` or `dict`
184 DataIds to use to populate the output calibration.
186 Returns
187 -------
188 results : `lsst.pipe.base.Struct`
189 The resulst struct containing:
191 ``outputBfk``
192 Resulting Brighter-Fatter Kernel
193 (`lsst.ip.isr.BrighterFatterKernel`).
194 """
195 if len(dummy) == 0:
196 self.log.warn("No dummy exposure found.")
198 detector = camera[inputDims['detector']]
199 detName = detector.getName()
201 if self.config.level == 'DETECTOR':
202 detectorCorrList = list()
204 bfk = BrighterFatterKernel(camera=camera, detectorId=detector.getId(), level=self.config.level)
205 bfk.means = inputPtc.finalMeans # ADU
206 bfk.variances = inputPtc.finalVars # ADU^2
207 # Use the PTC covariances as the cross-correlations. These
208 # are scaled before the kernel is generated, which performs
209 # the conversion.
210 bfk.rawXcorrs = inputPtc.covariances # ADU^2
211 bfk.badAmps = inputPtc.badAmps
212 bfk.shape = (inputPtc.covMatrixSide*2 + 1, inputPtc.covMatrixSide*2 + 1)
213 bfk.gain = inputPtc.gain
214 bfk.noise = inputPtc.noise
215 bfk.meanXcorrs = dict()
216 bfk.valid = dict()
218 for amp in detector:
219 ampName = amp.getName()
220 mask = np.array(inputPtc.expIdMask[ampName], dtype=bool)
222 gain = bfk.gain[ampName]
223 fluxes = np.array(bfk.means[ampName])[mask]
224 variances = np.array(bfk.variances[ampName])[mask]
225 xCorrList = [np.array(xcorr) for xcorr in bfk.rawXcorrs[ampName]]
226 xCorrList = np.array(xCorrList)[mask]
228 if gain <= 0:
229 # We've received very bad data.
230 self.log.warn("Impossible gain recieved from PTC for %s: %f. Skipping amplifier.",
231 ampName, gain)
232 bfk.meanXcorrs[ampName] = np.zeros(bfk.shape)
233 bfk.ampKernels[ampName] = np.zeros(bfk.shape)
234 bfk.valid[ampName] = False
235 continue
237 fluxes = np.array([flux*gain for flux in fluxes]) # Now in e^-
238 variances = np.array([variance*gain*gain for variance in variances]) # Now in e^2-
240 # This should duplicate Coulton et al. 2017 Equation 22-29
241 # (arxiv:1711.06273)
242 scaledCorrList = list()
243 for xcorrNum, (xcorr, flux, var) in enumerate(zip(xCorrList, fluxes, variances), 1):
244 q = np.array(xcorr) * gain * gain # xcorr now in e^-
245 q *= 2.0 # Remove factor of 1/2 applied in PTC.
246 self.log.info("Amp: %s %d/%d Flux: %f Var: %f Q(0,0): %g Q(1,0): %g Q(0,1): %g",
247 ampName, xcorrNum, len(xCorrList), flux, var, q[0][0], q[1][0], q[0][1])
249 # Normalize by the flux, which removes the (0,0)
250 # component attributable to Poisson noise. This
251 # contains the two "t I delta(x - x')" terms in
252 # Coulton et al. 2017 equation 29
253 q[0][0] -= 2.0*(flux)
255 if q[0][0] > 0.0:
256 self.log.warn("Amp: %s %d skipped due to value of (variance-mean)=%f",
257 ampName, xcorrNum, q[0][0])
258 continue
260 # This removes the "t (I_a^2 + I_b^2)" factor in
261 # Coulton et al. 2017 equation 29.
262 q /= -2.0*(flux**2)
263 scaled = self._tileArray(q)
265 xcorrCheck = np.abs(np.sum(scaled))/np.sum(np.abs(scaled))
266 if (xcorrCheck > self.config.xcorrCheckRejectLevel) or not (np.isfinite(xcorrCheck)):
267 self.log.warn("Amp: %s %d skipped due to value of triangle-inequality sum %f",
268 ampName, xcorrNum, xcorrCheck)
269 continue
271 scaledCorrList.append(scaled)
272 self.log.info("Amp: %s %d/%d Final: %g XcorrCheck: %f",
273 ampName, xcorrNum, len(xCorrList), q[0][0], xcorrCheck)
275 if len(scaledCorrList) == 0:
276 self.log.warn("Amp: %s All inputs rejected for amp!", ampName)
277 bfk.meanXcorrs[ampName] = np.zeros(bfk.shape)
278 bfk.ampKernels[ampName] = np.zeros(bfk.shape)
279 bfk.valid[ampName] = False
280 continue
282 if self.config.useAmatrix:
283 # Use the aMatrix, ignoring the meanXcorr generated above.
284 preKernel = np.pad(self._tileArray(np.array(inputPtc.aMatrix[ampName])), ((1, 1)))
285 elif self.config.correlationQuadraticFit:
286 # Use a quadratic fit to the correlations as a
287 # function of flux.
288 preKernel = self.quadraticCorrelations(scaledCorrList, fluxes, f"Amp: {ampName}")
289 else:
290 # Use a simple average of the measured correlations.
291 preKernel = self.averageCorrelations(scaledCorrList, f"Amp: {ampName}")
293 center = int((bfk.shape[0] - 1) / 2)
295 if self.config.forceZeroSum:
296 totalSum = np.sum(preKernel)
298 if self.config.correlationModelRadius < (preKernel.shape[0] - 1) / 2:
299 # Assume a correlation model of
300 # Corr(r) = -preFactor * r^(2 * slope)
301 preFactor = np.sqrt(preKernel[center, center + 1] * preKernel[center + 1, center])
302 slopeFactor = 2.0 * np.abs(self.config.correlationModelSlope)
303 totalSum += 2.0*np.pi*(preFactor / (slopeFactor*(center + 0.5))**slopeFactor)
305 preKernel[center, center] -= totalSum
306 self.log.info("%s Zero-Sum Scale: %g", ampName, totalSum)
308 finalSum = np.sum(preKernel)
309 bfk.meanXcorrs[ampName] = preKernel
311 postKernel = self.successiveOverRelax(preKernel)
312 bfk.ampKernels[ampName] = postKernel
313 if self.config.level == 'DETECTOR':
314 detectorCorrList.extend(scaledCorrList)
315 bfk.valid[ampName] = True
316 self.log.info("Amp: %s Sum: %g Center Info Pre: %g Post: %g",
317 ampName, finalSum, preKernel[center, center], postKernel[center, center])
319 # Assemble a detector kernel?
320 if self.config.level == 'DETECTOR':
321 preKernel = self.averageCorrelations(detectorCorrList, f"Det: {detName}")
322 finalSum = np.sum(preKernel)
323 center = int((bfk.shape[0] - 1) / 2)
325 postKernel = self.successiveOverRelax(preKernel)
326 bfk.detKernels[detName] = postKernel
327 self.log.info("Det: %s Sum: %g Center Info Pre: %g Post: %g",
328 detName, finalSum, preKernel[center, center], postKernel[center, center])
330 return pipeBase.Struct(
331 outputBFK=bfk,
332 )
334 def averageCorrelations(self, xCorrList, name):
335 """Average input correlations.
337 Parameters
338 ----------
339 xCorrList : `list` [`numpy.array`]
340 List of cross-correlations. These are expected to be
341 square arrays.
342 name : `str`
343 Name for log messages.
345 Returns
346 -------
347 meanXcorr : `numpy.array`, (N, N)
348 The averaged cross-correlation.
349 """
350 meanXcorr = np.zeros_like(xCorrList[0])
351 xCorrList = np.transpose(xCorrList)
352 sctrl = afwMath.StatisticsControl()
353 sctrl.setNumSigmaClip(self.config.nSigmaClip)
354 for i in range(np.shape(meanXcorr)[0]):
355 for j in range(np.shape(meanXcorr)[1]):
356 meanXcorr[i, j] = afwMath.makeStatistics(xCorrList[i, j],
357 afwMath.MEANCLIP, sctrl).getValue()
359 # To match previous definitions, pad by one element.
360 meanXcorr = np.pad(meanXcorr, ((1, 1)))
362 return meanXcorr
364 def quadraticCorrelations(self, xCorrList, fluxList, name):
365 """Measure a quadratic correlation model.
367 Parameters
368 ----------
369 xCorrList : `list` [`numpy.array`]
370 List of cross-correlations. These are expected to be
371 square arrays.
372 fluxList : `numpy.array`, (Nflux,)
373 Associated list of fluxes.
374 name : `str`
375 Name for log messages.
377 Returns
378 -------
379 meanXcorr : `numpy.array`, (N, N)
380 The averaged cross-correlation.
381 """
382 meanXcorr = np.zeros_like(xCorrList[0])
383 fluxList = np.square(fluxList)
384 xCorrList = np.array(xCorrList)
386 for i in range(np.shape(meanXcorr)[0]):
387 for j in range(np.shape(meanXcorr)[1]):
388 # Fit corrlation_i(x, y) = a0 + a1 * (flux_i)^2 The
389 # i,j indices are inverted to apply the transposition,
390 # as is done in the averaging case.
391 linearFit, linearFitErr, chiSq, weights = irlsFit([0.0, 1e-4], fluxList,
392 xCorrList[:, j, i], funcPolynomial)
393 meanXcorr[i, j] = linearFit[1] # Discard the intercept.
394 self.log.debug("Quad fit meanXcorr[%d,%d] = %g", i, j, linearFit[1])
396 # To match previous definitions, pad by one element.
397 meanXcorr = np.pad(meanXcorr, ((1, 1)))
399 return meanXcorr
401 @staticmethod
402 def _tileArray(in_array):
403 """Given an input quarter-image, tile/mirror it and return full image.
405 Given a square input of side-length n, of the form
407 input = array([[1, 2, 3],
408 [4, 5, 6],
409 [7, 8, 9]])
411 return an array of size 2n-1 as
413 output = array([[ 9, 8, 7, 8, 9],
414 [ 6, 5, 4, 5, 6],
415 [ 3, 2, 1, 2, 3],
416 [ 6, 5, 4, 5, 6],
417 [ 9, 8, 7, 8, 9]])
419 Parameters
420 ----------
421 input : `np.array`, (N, N)
422 The square input quarter-array
424 Returns
425 -------
426 output : `np.array`, (2*N + 1, 2*N + 1)
427 The full, tiled array
428 """
429 assert(in_array.shape[0] == in_array.shape[1])
430 length = in_array.shape[0] - 1
431 output = np.zeros((2*length + 1, 2*length + 1))
433 for i in range(length + 1):
434 for j in range(length + 1):
435 output[i + length, j + length] = in_array[i, j]
436 output[-i + length, j + length] = in_array[i, j]
437 output[i + length, -j + length] = in_array[i, j]
438 output[-i + length, -j + length] = in_array[i, j]
439 return output
441 def successiveOverRelax(self, source, maxIter=None, eLevel=None):
442 """An implementation of the successive over relaxation (SOR) method.
444 A numerical method for solving a system of linear equations
445 with faster convergence than the Gauss-Seidel method.
447 Parameters
448 ----------
449 source : `numpy.ndarray`, (N, N)
450 The input array.
451 maxIter : `int`, optional
452 Maximum number of iterations to attempt before aborting.
453 eLevel : `float`, optional
454 The target error level at which we deem convergence to have
455 occurred.
457 Returns
458 -------
459 output : `numpy.ndarray`, (N, N)
460 The solution.
461 """
462 if not maxIter:
463 maxIter = self.config.maxIterSuccessiveOverRelaxation
464 if not eLevel:
465 eLevel = self.config.eLevelSuccessiveOverRelaxation
467 assert source.shape[0] == source.shape[1], "Input array must be square"
468 # initialize, and set boundary conditions
469 func = np.zeros([source.shape[0] + 2, source.shape[1] + 2])
470 resid = np.zeros([source.shape[0] + 2, source.shape[1] + 2])
471 rhoSpe = np.cos(np.pi/source.shape[0]) # Here a square grid is assumed
473 # Calculate the initial error
474 for i in range(1, func.shape[0] - 1):
475 for j in range(1, func.shape[1] - 1):
476 resid[i, j] = (func[i, j - 1] + func[i, j + 1] + func[i - 1, j]
477 + func[i + 1, j] - 4*func[i, j] - source[i - 1, j - 1])
478 inError = np.sum(np.abs(resid))
480 # Iterate until convergence
481 # We perform two sweeps per cycle,
482 # updating 'odd' and 'even' points separately
483 nIter = 0
484 omega = 1.0
485 dx = 1.0
486 while nIter < maxIter*2:
487 outError = 0
488 if nIter%2 == 0:
489 for i in range(1, func.shape[0] - 1, 2):
490 for j in range(1, func.shape[1] - 1, 2):
491 resid[i, j] = float(func[i, j-1] + func[i, j + 1] + func[i - 1, j]
492 + func[i + 1, j] - 4.0*func[i, j] - dx*dx*source[i - 1, j - 1])
493 func[i, j] += omega*resid[i, j]*.25
494 for i in range(2, func.shape[0] - 1, 2):
495 for j in range(2, func.shape[1] - 1, 2):
496 resid[i, j] = float(func[i, j - 1] + func[i, j + 1] + func[i - 1, j]
497 + func[i + 1, j] - 4.0*func[i, j] - dx*dx*source[i - 1, j - 1])
498 func[i, j] += omega*resid[i, j]*.25
499 else:
500 for i in range(1, func.shape[0] - 1, 2):
501 for j in range(2, func.shape[1] - 1, 2):
502 resid[i, j] = float(func[i, j - 1] + func[i, j + 1] + func[i - 1, j]
503 + func[i + 1, j] - 4.0*func[i, j] - dx*dx*source[i - 1, j - 1])
504 func[i, j] += omega*resid[i, j]*.25
505 for i in range(2, func.shape[0] - 1, 2):
506 for j in range(1, func.shape[1] - 1, 2):
507 resid[i, j] = float(func[i, j - 1] + func[i, j + 1] + func[i - 1, j]
508 + func[i + 1, j] - 4.0*func[i, j] - dx*dx*source[i - 1, j - 1])
509 func[i, j] += omega*resid[i, j]*.25
510 outError = np.sum(np.abs(resid))
511 if outError < inError*eLevel:
512 break
513 if nIter == 0:
514 omega = 1.0/(1 - rhoSpe*rhoSpe/2.0)
515 else:
516 omega = 1.0/(1 - rhoSpe*rhoSpe*omega/4.0)
517 nIter += 1
519 if nIter >= maxIter*2:
520 self.log.warn("Failure: SuccessiveOverRelaxation did not converge in %s iterations."
521 "\noutError: %s, inError: %s," % (nIter//2, outError, inError*eLevel))
522 else:
523 self.log.info("Success: SuccessiveOverRelaxation converged in %s iterations."
524 "\noutError: %s, inError: %s", nIter//2, outError, inError*eLevel)
525 return func[1: -1, 1: -1]