Coverage for python / lsst / ip / isr / electrostaticBrighterFatter.py: 9%

231 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-14 23:58 +0000

1# This file is part of ip_isr. 

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"""Brighter Fatter Kernel calibration definition.""" 

23 

24 

25__all__ = [ 

26 'ElectrostaticBrighterFatterDistortionMatrix', 

27 'electrostaticBrighterFatterCorrection', 

28] 

29 

30 

31import pyfftw 

32import numpy as np 

33from astropy.table import Table 

34 

35from . import IsrCalib 

36from .isrFunctions import gainContext 

37 

38 

39class ElectrostaticBrighterFatterDistortionMatrix(IsrCalib): 

40 """Calibration of brighter-fatter kernels for an instrument. 

41 

42 ampKernels are the kernels for each amplifier in a detector, as 

43 generated by having ``level == 'AMP'``. 

44 

45 detectorKernel is the kernel generated for a detector as a 

46 whole, as generated by having ``level == 'DETECTOR'``. 

47 

48 makeDetectorKernelFromAmpwiseKernels is a method to generate the 

49 kernel for a detector, constructed by averaging together the 

50 ampwise kernels in the detector. The existing application code is 

51 only defined for kernels with ``level == 'DETECTOR'``, so this method 

52 is used if the supplied kernel was built with ``level == 'AMP'``. 

53 

54 Parameters 

55 ---------- 

56 camera : `lsst.afw.cameraGeom.Camera` 

57 Camera describing detector geometry. 

58 level : `str` 

59 Level the kernels will be generated for. 

60 log : `logging.Logger`, optional 

61 Log to write messages to. 

62 **kwargs : 

63 Parameters to pass to parent constructor. 

64 

65 Notes 

66 ----- 

67 Version 1.1 adds the `expIdMask` property, and substitutes 

68 `means` and `variances` for `rawMeans` and `rawVariances` 

69 from the PTC dataset. 

70 

71 inputRange : `int` 

72 The size of the input aMatrix shape in each dimension. 

73 fitRange : `int` 

74 The size of the input aMatrix shape in each dimension that is 

75 used for fitting the electrostatic model. Must be less than or 

76 equal to inputRange. 

77 badAmps : `list`, [`str`] 

78 List of bad amplifiers names. 

79 gain : `dict`, [`str`,`float`] 

80 Dictionary keyed by amp names containing the gains inherited 

81 from the inputPTC. 

82 aMatrix : `numpy.ndarray` 

83 The average aMatrix inherited from the inputPTC's good amplifiers. 

84 aMatrixSigma : `numpy.ndarray` 

85 The uncertainty matrix used to weight the fit. 

86 aMatrixModel : `numpy.ndarray` 

87 The modeled aMatrix based on the electrostatic fit parameters. 

88 aMatrixSum : `float` 

89 The sum of the symmetrized aMatrix. 

90 aMatrixModelSum : `float` 

91 The sum of the symmetrized aMatrixModel. 

92 modelNormalization : `list` 

93 A two element array of the multiplicative and additive 

94 normalization to the aMatrixModel. 

95 usedPixels : `numpy.ndarray`, [`bool`] 

96 Array of shape like aMatrix containing the mask indicating which 

97 elements of the input aMatrix were used to fit the electrostatic 

98 model. 

99 fitParamNames : `list`, [`str`] 

100 List of all the parameter names in the electrostatic fit. 

101 freeFitParamNames : `list`, [`str`] 

102 List of the parameter names that were allowed to vary during 

103 the electrostatic fit. 

104 fitParams : `dict`, [`str`, `float`] 

105 Dictionary containing each named parameter and its final 

106 fitted value. 

107 fitParamErrors : `dict`, [`str`, `float`] 

108 Dictionary containing each named parameter and its 

109 estimated fitting error. 

110 fitChi2 : `float` 

111 The computed chi squared between the data and the 

112 final model. 

113 fitReducedChi2 : `float` 

114 The computed reduced chi squared between the data 

115 and the final model. 

116 fitParamCovMatrix : `numpy.ndarray` 

117 The estimated covariance matrix between all fit parameters. 

118 ath : `numpy.ndarray` 

119 something... 

120 athMinusBeta : `numpy.ndarray` 

121 something... 

122 aN : `numpy.ndarray` 

123 Array of shape (fitRange, fitRange) containing the 

124 computed `North` component of the pixel boundary shift. 

125 aS : `numpy.ndarray` 

126 Array of shape (fitRange, fitRange) containing the 

127 computed `South` component of the pixel boundary shift. 

128 aE : `numpy.ndarray` 

129 Array of shape (fitRange, fitRange) containing the 

130 computed `East` component of the pixel boundary shift. 

131 aW : `numpy.ndarray` 

132 Array of shape (fitRange, fitRange) containing the 

133 computed `West` component of the pixel boundary shift. 

134 """ 

135 _OBSTYPE = 'BF_DISTORTION_MATRIX' 

136 _SCHEMA = 'ELectrostatic brighter-fatter pixel distortion model' 

137 _VERSION = 1.0 

138 

139 def __init__(self, camera=None, inputRange=1, fitRange=None, **kwargs): 

140 """ 

141 Filename refers to an input tuple that contains the 

142 boundary shifts for one electron. This file is produced by an 

143 electrostatic fit to data extracted from flat-field statistics, 

144 implemented in https://gitlab.in2p3.fr/astier/bfptc/tools/fit_cov.py 

145 """ 

146 self.ampNames = [] 

147 self.inputRange = inputRange 

148 if fitRange is None: 

149 fitRange = inputRange 

150 self.fitRange = fitRange 

151 self.badAmps = list() 

152 self.gain = dict() 

153 self.aMatrix = np.full((inputRange, inputRange), np.nan) 

154 self.aMatrixSigma = np.full((inputRange, inputRange), np.nan) 

155 self.aMatrixModel = np.full((fitRange, fitRange), np.nan) 

156 self.aMatrixSum = np.nan 

157 self.aMatrixModelSum = np.nan 

158 self.modelNormalization = [np.nan, np.nan] 

159 self.usedPixels = np.full((inputRange, inputRange), np.nan) 

160 self.fitParamNames = list() 

161 self.freeFitParamNames = list() 

162 self.fitParams = dict() 

163 self.fitParamErrors = dict() 

164 self.fitChi2 = np.nan 

165 self.fitReducedChi2 = np.nan 

166 self.fitParamCovMatrix = np.array([]) 

167 self.ath = np.full((fitRange, fitRange), np.nan) 

168 self.athMinusBeta = np.full((fitRange, fitRange), np.nan) 

169 self.aN = np.full((fitRange, fitRange), np.nan) 

170 self.aS = np.full((fitRange, fitRange), np.nan) 

171 self.aE = np.full((fitRange, fitRange), np.nan) 

172 self.aW = np.full((fitRange, fitRange), np.nan) 

173 

174 super().__init__(**kwargs) 

175 

176 if camera: 

177 self.initFromCamera(camera, detectorId=kwargs.get('detectorId', None)) 

178 

179 self.requiredAttributes.update([ 

180 'inputRange', 'fitRange', 'badAmps', 'gain', 'aMatrix', 

181 'aMatrixSigma', 'aMatrixModel', 'aMatrixSum', 'aMatrixModelSum', 

182 'modelNormalization', 'usedPixels', 'fitParamNames', 

183 'freeFitParamNames', 'fitParams', 'fitParamErrors', 'fitChi2', 

184 'fitReducedChi2', 'fitParamCovMatrix', 'ath', 'athMinusBeta', 

185 'aN', 'aS', 'aE', 'aW', 'ampNames', 

186 ]) 

187 

188 def updateMetadata(self, setDate=False, **kwargs): 

189 """Update calibration metadata. 

190 

191 This calls the base class's method after ensuring the required 

192 calibration keywords will be saved. 

193 

194 Parameters 

195 ---------- 

196 setDate : `bool`, optional 

197 Update the CALIBDATE fields in the metadata to the current 

198 time. Defaults to False. 

199 kwargs : 

200 Other keyword parameters to set in the metadata. 

201 """ 

202 kwargs['INPUT_RANGE'] = self.inputRange 

203 kwargs['FIT_RANGE'] = self.fitRange 

204 

205 super().updateMetadata(setDate=setDate, **kwargs) 

206 

207 def initFromCamera(self, camera, detectorId=None): 

208 """Initialize kernel structure from camera. 

209 

210 Parameters 

211 ---------- 

212 camera : `lsst.afw.cameraGeom.Camera` 

213 Camera to use to define geometry. 

214 detectorId : `int`, optional 

215 Index of the detector to generate. 

216 

217 Returns 

218 ------- 

219 calib : `lsst.ip.isr.BrighterFatterKernel` 

220 The initialized calibration. 

221 

222 Raises 

223 ------ 

224 RuntimeError 

225 Raised if no detectorId is supplied for a calibration with 

226 ``level='AMP'``. 

227 """ 

228 self._instrument = camera.getName() 

229 

230 if detectorId is not None: 

231 detector = camera[detectorId] 

232 self._detectorId = detectorId 

233 self._detectorName = detector.getName() 

234 self._detectorSerial = detector.getSerial() 

235 

236 for amp in detector: 

237 ampName = amp.getName() 

238 self.ampNames.append(ampName) 

239 else: 

240 raise RuntimeError("A detectorId must be supplied if " 

241 "camera is supplied.") 

242 

243 return self 

244 

245 @classmethod 

246 def fromDict(cls, dictionary): 

247 """Construct a calibration from a dictionary of properties. 

248 

249 Parameters 

250 ---------- 

251 dictionary : `dict` 

252 Dictionary of properties. 

253 

254 Returns 

255 ------- 

256 calib : `lsst.ip.isr.BrighterFatterKernel` 

257 Constructed calibration. 

258 

259 Raises 

260 ------ 

261 RuntimeError 

262 Raised if the supplied dictionary is for a different 

263 calibration. 

264 Raised if the version of the supplied dictionary is 1.0. 

265 """ 

266 calib = cls() 

267 

268 if calib._OBSTYPE != (found := dictionary['metadata']['OBSTYPE']): 

269 raise RuntimeError(f"Incorrect brighter-fatter calibration supplied. Expected {calib._OBSTYPE}, " 

270 f"found {found}") 

271 calib.setMetadata(dictionary['metadata']) 

272 calib.calibInfoFromDict(dictionary) 

273 

274 calib.ampNames = dictionary['ampNames'] 

275 inputRange = dictionary['inputRange'] 

276 fitRange = dictionary['fitRange'] 

277 calib.inputRange = inputRange 

278 calib.fitRange = fitRange 

279 calib.gain = dictionary['gain'] 

280 calib.badAmps = dictionary['badAmps'] 

281 calib.fitParamNames = dictionary['fitParamNames'] 

282 calib.freeFitParamNames = dictionary['freeFitParamNames'] 

283 calib.aMatrix = np.array( 

284 dictionary['aMatrix'], 

285 dtype=np.float64, 

286 ).reshape(inputRange, inputRange) 

287 calib.aMatrixSigma = np.array( 

288 dictionary['aMatrixSigma'], 

289 dtype=np.float64, 

290 ).reshape(inputRange, inputRange) 

291 calib.aMatrixModel = np.array( 

292 dictionary['aMatrixSigma'], 

293 dtype=np.float64, 

294 ).reshape(fitRange, fitRange) 

295 calib.aMatrixSum = float(dictionary['aMatrixSum']) 

296 calib.aMatrixModelSum = float(dictionary['aMatrixModelSum']) 

297 calib.modelNormalization = dictionary['modelNormalization'] 

298 calib.usedPixels = np.array(dictionary['usedPixels']) 

299 calib.fitParams = dictionary['fitParams'] 

300 calib.fitParamErrors = dictionary['fitParamErrors'] 

301 calib.fitChi2 = float(dictionary['fitChi2']) 

302 calib.fitReducedChi2 = float(dictionary['fitReducedChi2']) 

303 calib.fitParamCovMatrix = np.array( 

304 dictionary['fitParamCovMatrix'], 

305 dtype=np.float64, 

306 ).reshape(len(calib.freeFitParamNames), len(calib.freeFitParamNames)) 

307 calib.ath = np.array( 

308 dictionary['ath'], 

309 dtype=np.float64, 

310 ).reshape(fitRange, fitRange) 

311 calib.athMinusBeta = np.array( 

312 dictionary['athMinusBeta'], 

313 dtype=np.float64, 

314 ).reshape(fitRange, fitRange) 

315 calib.aN = np.array( 

316 dictionary['aN'], 

317 dtype=np.float64, 

318 ).reshape(fitRange, fitRange) 

319 calib.aS = np.array( 

320 dictionary['aS'], 

321 dtype=np.float64, 

322 ).reshape(fitRange, fitRange) 

323 calib.aE = np.array( 

324 dictionary['aE'], 

325 dtype=np.float64, 

326 ).reshape(fitRange, fitRange) 

327 calib.aW = np.array( 

328 dictionary['aW'], 

329 dtype=np.float64, 

330 ).reshape(fitRange, fitRange) 

331 

332 calib.updateMetadata() 

333 return calib 

334 

335 def toDict(self): 

336 """Return a dictionary containing the calibration properties. 

337 

338 The dictionary should be able to be round-tripped through 

339 `fromDict`. 

340 

341 Returns 

342 ------- 

343 dictionary : `dict` 

344 Dictionary of properties. 

345 """ 

346 self.updateMetadata() 

347 

348 outDict = dict() 

349 metadata = self.getMetadata() 

350 outDict['metadata'] = metadata 

351 

352 outDict['ampNames'] = self.ampNames 

353 outDict['inputRange'] = self.inputRange 

354 outDict['fitRange'] = self.fitRange 

355 outDict['badAmps'] = self.badAmps 

356 outDict['fitParamNames'] = self.fitParamNames 

357 outDict['freeFitParamNames'] = self.freeFitParamNames 

358 outDict['gain'] = self.gain 

359 outDict['aMatrix'] = self.aMatrix.ravel().tolist() 

360 outDict['aMatrixSigma'] = self.aMatrixSigma.ravel().tolist() 

361 outDict['aMatrixModel'] = self.aMatrixModel.ravel().tolist() 

362 outDict['aMatrixSum'] = self.aMatrixSum 

363 outDict['aMatrixModelSum'] = self.aMatrixModelSum 

364 outDict['aMatrixModel'] = self.aMatrixModel.ravel().tolist() 

365 outDict['modelNormalization'] = self.modelNormalization 

366 outDict['fitParamCovMatrix'] = self.fitParamCovMatrix.ravel().tolist() 

367 outDict['usedPixels'] = self.usedPixels.ravel().tolist() 

368 outDict['fitParams'] = self.fitParams 

369 outDict['fitParamErrors'] = self.fitParamErrors 

370 outDict['fitChi2'] = self.fitChi2 

371 outDict['fitReducedChi2'] = self.fitReducedChi2 

372 outDict['ath'] = self.ath.ravel().tolist() 

373 outDict['athMinusBeta'] = self.athMinusBeta.ravel().tolist() 

374 outDict['aN'] = self.aN.ravel().tolist() 

375 outDict['aS'] = self.aS.ravel().tolist() 

376 outDict['aE'] = self.aE.ravel().tolist() 

377 outDict['aW'] = self.aW.ravel().tolist() 

378 

379 return outDict 

380 

381 @classmethod 

382 def fromTable(cls, tableList): 

383 """Construct calibration from a list of tables. 

384 

385 This method uses the `fromDict` method to create the 

386 calibration, after constructing an appropriate dictionary from 

387 the input tables. 

388 

389 Parameters 

390 ---------- 

391 tableList : `list` [`astropy.table.Table`] 

392 List of tables to use to construct the brighter-fatter 

393 calibration. 

394 

395 Returns 

396 ------- 

397 calib : `lsst.ip.isr.BrighterFatterKernel` 

398 The calibration defined in the tables. 

399 """ 

400 record = tableList[0][0] 

401 

402 metadata = record.meta 

403 calibVersion = metadata['BF_DISTORTION_MATRIX_VERSION'] 

404 

405 # Initialize inDict with all expected keys 

406 # and empty values of the corresponding type 

407 inDict = dict() 

408 inDict['metadata'] = metadata 

409 inDict['ampNames'] = record['AMPNAMES'] 

410 inDict['inputRange'] = record['INPUT_RANGE'] 

411 inDict['fitRange'] = record['FIT_RANGE'] 

412 inDict['badAmps'] = record['BAD_AMPS'] 

413 inDict['fitParamNames'] = record['FIT_PARAM_NAMES'] 

414 inDict['freeFitParamNames'] = record['FREE_FIT_PARAM_NAMES'] 

415 inDict['gain'] = {str(n): v for n, v in zip(record['AMPNAMES'], record['GAIN'])} 

416 inDict['aMatrix'] = record['A_MATRIX'] 

417 inDict['aMatrixSigma'] = record['A_MATRIX_SIGMA'] 

418 inDict['aMatrixModel'] = record['A_MATRIX_MODEL'] 

419 inDict['aMatrixSum'] = record['A_MATRIX_SUM'] 

420 inDict['aMatrixModelSum'] = record['A_MATRIX_MODEL_SUM'] 

421 inDict['modelNormalization'] = record['MODEL_NORMALIZATION'] 

422 inDict['usedPixels'] = record['USED_PIXELS'] 

423 inDict['fitParams'] = {str(n): v for n, v in zip(record['FIT_PARAM_NAMES'], record['FIT_PARAMS'])} 

424 inDict['fitParamErrors'] = {str(n): v for n, v in zip(record['FIT_PARAM_NAMES'], 

425 record['FIT_PARAM_ERRORS'])} 

426 inDict['fitChi2'] = record['FIT_CHI2'] 

427 inDict['fitReducedChi2'] = record['FIT_REDUCED_CHI2'] 

428 inDict['fitParamCovMatrix'] = record['FIT_PARAM_COV_MATRIX'] 

429 inDict['ath'] = record['ATH'] 

430 inDict['athMinusBeta'] = record['ATH_MINUS_BETA'] 

431 inDict['aN'] = record['A_N'] 

432 inDict['aS'] = record['A_S'] 

433 inDict['aE'] = record['A_E'] 

434 inDict['aW'] = record['A_W'] 

435 

436 if not calibVersion == 1.0: 

437 cls().log.warning("Unkown version found for " 

438 f"ElectrostaticBrighterFatterDistortionMatrix: {calibVersion}. ") 

439 

440 # Check for newer versions, but there are none... 

441 

442 return cls.fromDict(inDict) 

443 

444 def toTable(self): 

445 """Construct a list of tables containing the information in this 

446 calibration. 

447 

448 The list of tables should create an identical calibration 

449 after being passed to this class's fromTable method. 

450 

451 Returns 

452 ------- 

453 tableList : `list` [`lsst.afw.table.Table`] 

454 List of tables containing the crosstalk calibration 

455 information. 

456 

457 """ 

458 tableList = [] 

459 self.updateMetadata() 

460 

461 recordDict = { 

462 'AMPNAMES': self.ampNames, 

463 'INPUT_RANGE': self.inputRange, 

464 'FIT_RANGE': self.fitRange, 

465 'BAD_AMPS': self.badAmps, 

466 'GAIN': list(self.gain.values()), 

467 'A_MATRIX': self.aMatrix.ravel(), 

468 'A_MATRIX_SIGMA': self.aMatrixSigma.ravel(), 

469 'A_MATRIX_MODEL': self.aMatrixModel.ravel(), 

470 'A_MATRIX_SUM': self.aMatrixSum, 

471 'A_MATRIX_MODEL_SUM': self.aMatrixModelSum, 

472 'MODEL_NORMALIZATION': self.modelNormalization, 

473 'USED_PIXELS': self.usedPixels.ravel(), 

474 'FIT_PARAM_NAMES': self.fitParamNames, 

475 'FREE_FIT_PARAM_NAMES': self.freeFitParamNames, 

476 'FIT_PARAMS': list(self.fitParams.values()), 

477 'FIT_PARAM_ERRORS': list(self.fitParamErrors.values()), 

478 'FIT_CHI2': self.fitChi2, 

479 'FIT_REDUCED_CHI2': self.fitReducedChi2, 

480 'FIT_PARAM_COV_MATRIX': self.fitParamCovMatrix.ravel(), 

481 'ATH': self.ath.ravel(), 

482 'ATH_MINUS_BETA': self.athMinusBeta.ravel(), 

483 'A_N': self.aN.ravel(), 

484 'A_S': self.aS.ravel(), 

485 'A_E': self.aE.ravel(), 

486 'A_W': self.aW.ravel(), 

487 } 

488 

489 catalogList = [recordDict] 

490 catalog = Table(catalogList) 

491 

492 inMeta = self.getMetadata().toDict() 

493 outMeta = {k: v for k, v in inMeta.items() if v is not None} 

494 outMeta.update({k: "" for k, v in inMeta.items() if v is None}) 

495 catalog.meta = outMeta 

496 tableList.append(catalog) 

497 

498 return tableList 

499 

500 

501class CustomFFTConvolution(object): 

502 """ 

503 A class that performs image convolutions in Fourier space, using pyfftw. 

504 The constructor takes images as arguments and creates the FFTW plans. 

505 The convolutions are performed by the __call__ routine. 

506 This is faster than scipy.signal.fftconvolve, and it saves some transforms 

507 by allowing the same image to be convolved with several kernels. 

508 pyfftw does not accommodate float32 images, so everything 

509 should be double precision. 

510 

511 Code adaped from : 

512 https://stackoverflow.com/questions/14786920/convolution-of-two-three-dimensional-arrays-with-padding-on-one-side-too-slow 

513 Code posted by Henry Gomersal 

514 """ 

515 def __init__(self, im, kernel, threads=1): 

516 # Compute the minimum size of the convolution result. 

517 shape = (np.array(im.shape) + np.array(kernel.shape)) - 1 

518 

519 # Find the next larger "fast size" for FFT computation. 

520 # This can provide a significant speedup. 

521 shape = np.array([pyfftw.next_fast_len(s) for s in shape]) 

522 

523 # Create FFTW building objects for the image and kernel. 

524 self.fftImageObj = pyfftw.builders.rfftn(im, s=shape, threads=threads) 

525 self.fftKernelObj = pyfftw.builders.rfftn(kernel, s=shape, threads=threads) 

526 self.ifftObj = pyfftw.builders.irfftn( 

527 self.fftImageObj.get_output_array(), 

528 s=shape, 

529 threads=threads, 

530 ) 

531 

532 def __call__(self, im, kernels): 

533 """ 

534 Perform the convolution and trim the result to the 

535 size of the input image. If kernels is a list, then 

536 the routine returns a list of corresponding 

537 convolutions. 

538 """ 

539 # Handle both a list of kernels and a single kernel. 

540 ks = [kernels] if type(kernels) is not list else kernels 

541 convolutions = [] 

542 for k in ks: 

543 # Transform the image and the kernel using FFT. 

544 tim = self.fftImageObj(im) 

545 tk = self.fftKernelObj(k) 

546 

547 # Multiply in Fourier space and perform the inverse FFT. 

548 convolution = self.ifftObj(tim * tk) 

549 # Trim the result to match the input image size, following 

550 # the 'same' policy of scipy.signal.fftconvolve. 

551 oy = k.shape[0] // 2 

552 ox = k.shape[1] // 2 

553 convolutions.append(convolution[oy:oy + im.shape[0], ox:ox + im.shape[1]].copy()) 

554 # Return a single convolution if kernels was 

555 # not a list, otherwise return the list. 

556 return convolutions[0] if type(kernels) is not list else convolutions 

557 

558 

559def electrostaticBrighterFatterCorrection(exposure, electroBfDistortionMatrix, applyGain, gains=None): 

560 """ 

561 Evaluates the correction of CCD images affected by the 

562 brighter-fatter effect, as described in 

563 https://arxiv.org/abs/2301.03274. Requires as input the result of 

564 an electrostatic fit to flat covariance data (or any other 

565 determination of pixel boundary shifts under the influence of a 

566 single electron). 

567 

568 The filename refers to an input tuple that contains the 

569 boundary shifts for one electron. This file is produced by an 

570 electrostatic fit to data extracted from flat-field statistics, 

571 implemented in https://gitlab.in2p3.fr/astier/bfptc/tools/fit_cov.py. 

572 """ 

573 

574 # Use the symmetrize function to fill the four quadrants for each kernel 

575 r = electroBfDistortionMatrix.fitRange - 1 

576 aN = electroBfDistortionMatrix.aN 

577 aS = electroBfDistortionMatrix.aS 

578 aE = electroBfDistortionMatrix.aE 

579 aW = electroBfDistortionMatrix.aW 

580 

581 # Initialize kN and kE arrays 

582 kN = np.zeros((2 * r + 1, 2 * r + 1)) 

583 kE = np.zeros_like(kN) 

584 

585 # Fill in the 4 quadrants for kN 

586 kN[r:, r:] = aN # Quadrant 1 (bottom-right) 

587 kN[:r+1, r:] = np.flipud(aN) # Quadrant 2 (top-right) 

588 kN[r:, :r+1] = np.fliplr(aS) # Quadrant 3 (bottom-left) 

589 kN[:r+1, :r+1] = np.flipud(np.fliplr(aS)) # Quadrant 4 (top-left) 

590 

591 # Fill in the 4 quadrants for kE 

592 kE[r:, r:] = aE # Quadrant 1 (bottom-right) 

593 kE[:r+1, r:] = np.flipud(aW) # Quadrant 2 (top-right) 

594 kE[r:, :r+1] = np.fliplr(aE) # Quadrant 3 (bottom-left) 

595 kE[:r+1, :r+1] = np.flipud(np.fliplr(aW)) # Quadrant 4 (top-left) 

596 

597 # Tweak the edges so that the sum rule applies. 

598 kN[:, 0] = -kN[:, -1] 

599 kE[0, :] = -kE[-1, :] 

600 

601 # We use the normalization of Guyonnet et al. (2015), 

602 # which is compatible with the way the input file is produced. 

603 # The factor 1/2 is due to the fact that the charge distribution at the end 

604 # is twice the average, and the second 1/2 is due to 

605 # charge interpolation. 

606 kN *= 0.25 

607 kE *= 0.25 

608 

609 # Indeed, i and j in the tuple refer to serial and parallel directions. 

610 # In most Python code, the image reads im[j, i], so we transpose: 

611 kN = kN.T 

612 kE = kE.T 

613 

614 # Get the image to perform the correction 

615 image = exposure.getMaskedImage().getImage() 

616 

617 # The image needs to be units of electrons/holes 

618 with gainContext(exposure, image, applyGain, gains): 

619 # Computes the correction and returns the "delta_image", 

620 # which should be subtracted from "im" in order to undo the BF effect. 

621 # The input image should be expressed in electrons 

622 im = image.getArray().copy() 

623 convolver = CustomFFTConvolution(im, kN) 

624 convolutions = convolver(im, [kN, kE]) 

625 

626 # The convolutions contain the boundary shifts (in pixel size units) 

627 # for [horizontal, vertical] boundaries. 

628 # We now compute the charge to move around. 

629 delta = np.zeros_like(im) 

630 boundaryCharge = np.zeros_like(im) 

631 

632 # Horizontal boundaries (parallel direction). 

633 # We could use a more elaborate interpolator for estimating the 

634 # charge on the boundary. 

635 boundaryCharge[:-1, :] = im[1:, :] + im[:-1, :] 

636 # boundaryCharge[1:-2,:] = (9./8.)*(I[2:-1,:]+I[1:-2,:] - 

637 # (1./8.)*(I[0:-3,:]+I[3,:]) 

638 

639 # The charge to move around is the 

640 # product of the boundary shift (in pixel size units) times the 

641 # charge on the boundary (in charge per pixel unit). 

642 dq = boundaryCharge * convolutions[0] 

643 delta += dq 

644 

645 # What is gained by a pixel is lost by its neighbor (the right one). 

646 delta[1:, :] -= dq[:-1, :] 

647 

648 # Vertical boundaries. 

649 boundaryCharge = np.zeros_like(im) # Reset to zero. 

650 boundaryCharge[:, :-1] = im[:, 1:] + im[:, :-1] 

651 dq = boundaryCharge * convolutions[1] 

652 delta += dq 

653 

654 # What is gained by a pixel is lost by its neighbor. 

655 delta[:, 1:] -= dq[:, :-1] 

656 

657 # TODO: One might check that delta.sum() ~ 0 (charge conservation). 

658 

659 # Apply the correction to the original image 

660 exposure.image.array -= delta 

661 

662 return exposure