Hide keyboard shortcuts

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.""" 

23 

24__all__ = ['BrighterFatterKernelSolveTask', 

25 'BrighterFatterKernelSolveConfig'] 

26 

27import numpy as np 

28 

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 

33 

34from lsst.ip.isr import (BrighterFatterKernel) 

35from .utils import (funcPolynomial, irlsFit) 

36from ._lookupStaticCalibration import lookupStaticCalibration 

37 

38 

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 ) 

64 

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 ) 

72 

73 

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 ) 

113 

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 ) 

124 

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 ) 

140 

141 

142class BrighterFatterKernelSolveTask(pipeBase.PipelineTask, pipeBase.CmdLineTask): 

143 """Measure appropriate Brighter-Fatter Kernel from the PTC dataset. 

144 """ 

145 

146 ConfigClass = BrighterFatterKernelSolveConfig 

147 _DefaultName = 'cpBfkMeasure' 

148 

149 def runQuantum(self, butlerQC, inputRefs, outputRefs): 

150 """Ensure that the input and output dimensions are passed along. 

151 

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) 

162 

163 # Use the dimensions to set calib/provenance information. 

164 inputs['inputDims'] = inputRefs.inputPtc.dataId.byName() 

165 

166 outputs = self.run(**inputs) 

167 butlerQC.put(outputs, outputRefs) 

168 

169 def run(self, inputPtc, dummy, camera, inputDims): 

170 """Combine covariance information from PTC into brighter-fatter 

171 kernels. 

172 

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. 

185 

186 Returns 

187 ------- 

188 results : `lsst.pipe.base.Struct` 

189 The resulst struct containing: 

190 

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.") 

197 

198 detector = camera[inputDims['detector']] 

199 detName = detector.getName() 

200 

201 if self.config.level == 'DETECTOR': 

202 detectorCorrList = list() 

203 

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() 

217 

218 for amp in detector: 

219 ampName = amp.getName() 

220 mask = np.array(inputPtc.expIdMask[ampName], dtype=bool) 

221 

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] 

227 

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 

236 

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- 

239 

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]) 

248 

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) 

254 

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 

259 

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) 

264 

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 

270 

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) 

274 

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 

281 

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}") 

292 

293 center = int((bfk.shape[0] - 1) / 2) 

294 

295 if self.config.forceZeroSum: 

296 totalSum = np.sum(preKernel) 

297 

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) 

304 

305 preKernel[center, center] -= totalSum 

306 self.log.info("%s Zero-Sum Scale: %g", ampName, totalSum) 

307 

308 finalSum = np.sum(preKernel) 

309 bfk.meanXcorrs[ampName] = preKernel 

310 

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]) 

318 

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) 

324 

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]) 

329 

330 return pipeBase.Struct( 

331 outputBFK=bfk, 

332 ) 

333 

334 def averageCorrelations(self, xCorrList, name): 

335 """Average input correlations. 

336 

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. 

344 

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() 

358 

359 # To match previous definitions, pad by one element. 

360 meanXcorr = np.pad(meanXcorr, ((1, 1))) 

361 

362 return meanXcorr 

363 

364 def quadraticCorrelations(self, xCorrList, fluxList, name): 

365 """Measure a quadratic correlation model. 

366 

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. 

376 

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) 

385 

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]) 

395 

396 # To match previous definitions, pad by one element. 

397 meanXcorr = np.pad(meanXcorr, ((1, 1))) 

398 

399 return meanXcorr 

400 

401 @staticmethod 

402 def _tileArray(in_array): 

403 """Given an input quarter-image, tile/mirror it and return full image. 

404 

405 Given a square input of side-length n, of the form 

406 

407 input = array([[1, 2, 3], 

408 [4, 5, 6], 

409 [7, 8, 9]]) 

410 

411 return an array of size 2n-1 as 

412 

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]]) 

418 

419 Parameters 

420 ---------- 

421 input : `np.array`, (N, N) 

422 The square input quarter-array 

423 

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)) 

432 

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 

440 

441 def successiveOverRelax(self, source, maxIter=None, eLevel=None): 

442 """An implementation of the successive over relaxation (SOR) method. 

443 

444 A numerical method for solving a system of linear equations 

445 with faster convergence than the Gauss-Seidel method. 

446 

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. 

456 

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 

466 

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 

472 

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)) 

479 

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 

518 

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]