Coverage for python/lsst/cp/pipe/cpBfk.py: 11%

296 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-19 02:31 -0800

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

36from .cpLinearitySolve import ptcLookup 

37 

38 

39class BrighterFatterKernelSolveConnections(pipeBase.PipelineTaskConnections, 

40 dimensions=("instrument", "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 ) 

56 inputPtc = cT.PrerequisiteInput( 

57 name="ptc", 

58 doc="Photon transfer curve dataset.", 

59 storageClass="PhotonTransferCurveDataset", 

60 dimensions=("instrument", "detector"), 

61 isCalibration=True, 

62 lookupFunction=ptcLookup, 

63 ) 

64 inputBfkPtc = cT.Input( 

65 name="bfkPtc", 

66 doc="Input BFK PTC dataset.", 

67 storageClass="PhotonTransferCurveDataset", 

68 dimensions=("instrument", "detector"), 

69 isCalibration=True, 

70 ) 

71 

72 outputBFK = cT.Output( 

73 name="brighterFatterKernel", 

74 doc="Output measured brighter-fatter kernel.", 

75 storageClass="BrighterFatterKernel", 

76 dimensions=("instrument", "detector"), 

77 isCalibration=True, 

78 ) 

79 

80 def __init__(self, *, config=None): 

81 if config.useBfkPtc: 

82 del self.inputPtc 

83 del self.dummy 

84 else: 

85 del self.inputBfkPtc 

86 

87 

88class BrighterFatterKernelSolveConfig(pipeBase.PipelineTaskConfig, 

89 pipelineConnections=BrighterFatterKernelSolveConnections): 

90 level = pexConfig.ChoiceField( 

91 doc="The level at which to calculate the brighter-fatter kernels", 

92 dtype=str, 

93 default="AMP", 

94 allowed={ 

95 "AMP": "Every amplifier treated separately", 

96 "DETECTOR": "One kernel per detector", 

97 } 

98 ) 

99 ignoreAmpsForAveraging = pexConfig.ListField( 

100 dtype=str, 

101 doc="List of amp names to ignore when averaging the amplifier kernels into the detector" 

102 " kernel. Only relevant for level = DETECTOR", 

103 default=[] 

104 ) 

105 xcorrCheckRejectLevel = pexConfig.Field( 

106 dtype=float, 

107 doc="Rejection level for the sum of the input cross-correlations. Arrays which " 

108 "sum to greater than this are discarded before the clipped mean is calculated.", 

109 default=2.0 

110 ) 

111 nSigmaClip = pexConfig.Field( 

112 dtype=float, 

113 doc="Number of sigma to clip when calculating means for the cross-correlation", 

114 default=5 

115 ) 

116 forceZeroSum = pexConfig.Field( 

117 dtype=bool, 

118 doc="Force the correlation matrix to have zero sum by adjusting the (0,0) value?" 

119 "Defaults to true bsed on recommendation of Broughton et al. 2024.", 

120 default=True, 

121 ) 

122 useAmatrix = pexConfig.Field( 

123 dtype=bool, 

124 doc="Use the PTC 'a' matrix (Astier et al. 2019 equation 20) " 

125 "instead of the average of measured covariances?", 

126 default=False, 

127 ) 

128 

129 useCovModelSample = pexConfig.Field( 

130 dtype=bool, 

131 doc="Use the covariance matrix sampled from the full covariance model " 

132 "(Astier et al. 2019 equation 20) instead of the average measured covariances?", 

133 default=False, 

134 ) 

135 

136 covModelFluxSample = pexConfig.DictField( 

137 keytype=str, 

138 itemtype=float, 

139 doc="Flux level in electrons at which to sample the full covariance" 

140 "model if useCovModelSample=True. The same level is applied to all" 

141 "amps if this parameter [`dict`] is passed as {'ALL_AMPS': value}", 

142 default={'ALL_AMPS': 25000.0}, 

143 ) 

144 maxIterSuccessiveOverRelaxation = pexConfig.Field( 

145 dtype=int, 

146 doc="The maximum number of iterations allowed for the successive over-relaxation method", 

147 default=10000 

148 ) 

149 eLevelSuccessiveOverRelaxation = pexConfig.Field( 

150 dtype=float, 

151 doc="The target residual error for the successive over-relaxation method", 

152 default=5.0e-14 

153 ) 

154 correlationQuadraticFit = pexConfig.Field( 

155 dtype=bool, 

156 doc="Use a quadratic fit to find the correlations instead of simple averaging?", 

157 default=False, 

158 ) 

159 correlationModelRadius = pexConfig.Field( 

160 dtype=int, 

161 doc="Build a model of the correlation coefficients for radii larger than this value in pixels?", 

162 default=100, 

163 ) 

164 correlationModelSlope = pexConfig.Field( 

165 dtype=float, 

166 doc="Slope of the correlation model for radii larger than correlationModelRadius", 

167 default=-1.35, 

168 ) 

169 

170 nSigmaTolForValidEdge = pexConfig.Field( 

171 dtype=float, 

172 doc="A valid kernel will have all pixels in a 3px picture frame (edges) " 

173 "be within +/- nSigmaTolForValidEdge of zero.", 

174 default=5.0, 

175 ) 

176 fractionK00ForKernelMax = pexConfig.Field( 

177 dtype=float, 

178 doc="All kernel values must be less than value*abs(K_00) to be valid.", 

179 default=0.025, 

180 ) 

181 

182 useBfkPtc = pexConfig.Field( 

183 dtype=bool, 

184 doc="Use a BFK ptc in a single pipeline?", 

185 default=False, 

186 ) 

187 

188 doCheckValidity = pexConfig.Field( 

189 dtype=bool, 

190 doc="Check the AMP kernels for basic validity criteria? " 

191 "Will set bfk.valid for each amp.", 

192 default=True, 

193 ) 

194 

195 

196class BrighterFatterKernelSolveTask(pipeBase.PipelineTask): 

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

198 """ 

199 

200 ConfigClass = BrighterFatterKernelSolveConfig 

201 _DefaultName = 'cpBfkMeasure' 

202 

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

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

205 

206 Parameters 

207 ---------- 

208 butlerQC : `lsst.daf.butler.QuantumContext` 

209 Butler to operate on. 

210 inputRefs : `lsst.pipe.base.InputQuantizedConnection` 

211 Input data refs to load. 

212 ouptutRefs : `lsst.pipe.base.OutputQuantizedConnection` 

213 Output data refs to persist. 

214 """ 

215 inputs = butlerQC.get(inputRefs) 

216 

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

218 

219 if self.config.useBfkPtc: 

220 inputs["inputDims"] = dict(inputRefs.inputBfkPtc.dataId.required) 

221 inputs["inputPtc"] = inputs["inputBfkPtc"] 

222 inputs["dummy"] = [] 

223 del inputs["inputBfkPtc"] 

224 else: 

225 inputs["inputDims"] = dict(inputRefs.inputPtc.dataId.required) 

226 

227 # Add calibration provenance info to header. 

228 kwargs = dict() 

229 reference = getattr(inputRefs, "inputPtc", None) 

230 

231 if reference is not None and hasattr(reference, "run"): 

232 runKey = "PTC_RUN" 

233 runValue = reference.run 

234 idKey = "PTC_UUID" 

235 idValue = str(reference.id) 

236 dateKey = "PTC_DATE" 

237 calib = inputs.get("inputPtc", None) 

238 dateValue = extractCalibDate(calib) 

239 

240 kwargs[runKey] = runValue 

241 kwargs[idKey] = idValue 

242 kwargs[dateKey] = dateValue 

243 

244 self.log.info("Using " + str(reference.run)) 

245 

246 outputs = self.run(**inputs) 

247 outputs.outputBFK.updateMetadata(setDate=False, **kwargs) 

248 

249 butlerQC.put(outputs, outputRefs) 

250 

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

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

253 kernels. 

254 

255 Parameters 

256 ---------- 

257 inputPtc : `lsst.ip.isr.PhotonTransferCurveDataset` 

258 PTC data containing per-amplifier covariance measurements. 

259 dummy : `lsst.afw.image.Exposure` 

260 The exposure used to select the appropriate PTC dataset. 

261 In almost all circumstances, one of the input exposures 

262 used to generate the PTC dataset is the best option. 

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

264 Camera to use for camera geometry information. 

265 inputDims : `lsst.daf.butler.DataCoordinate` or `dict` 

266 DataIds to use to populate the output calibration. 

267 

268 Returns 

269 ------- 

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

271 The resulst struct containing: 

272 

273 ``outputBfk`` 

274 Resulting Brighter-Fatter Kernel 

275 (`lsst.ip.isr.BrighterFatterKernel`). 

276 """ 

277 detector = camera[inputDims['detector']] 

278 detName = detector.getName() 

279 

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

281 detectorCorrList = list() 

282 detectorFluxes = list() 

283 

284 if not inputPtc.ptcFitType == "FULLCOVARIANCE" and self.config.useCovModelSample: 

285 raise ValueError("ptcFitType must be FULLCOVARIANCE if useCovModelSample=True.") 

286 

287 # Get flux sample dictionary 

288 fluxSampleDict = {ampName: 0.0 for ampName in inputPtc.ampNames} 

289 for ampName in inputPtc.ampNames: 

290 if 'ALL_AMPS' in self.config.covModelFluxSample: 

291 fluxSampleDict[ampName] = self.config.covModelFluxSample['ALL_AMPS'] 

292 elif ampName in self.config.covModelFluxSample: 

293 fluxSampleDict[ampName] = self.config.covModelFluxSample[ampName] 

294 

295 bfk = BrighterFatterKernel(camera=camera, detectorId=detector.getId(), level=self.config.level) 

296 bfk.rawMeans = inputPtc.rawMeans # ADU 

297 bfk.rawVariances = inputPtc.rawVars # ADU^2 

298 bfk.expIdMask = inputPtc.expIdMask 

299 

300 # Use the PTC covariances as the cross-correlations. These 

301 # are scaled before the kernel is generated, which performs 

302 # the conversion. The input covariances are in (x, y) index 

303 # ordering, as is the aMatrix. 

304 bfk.rawXcorrs = inputPtc.covariances # ADU^2 

305 bfk.badAmps = inputPtc.badAmps 

306 bfk.shape = (inputPtc.covMatrixSide*2 + 1, inputPtc.covMatrixSide*2 + 1) 

307 bfk.gain = inputPtc.gain 

308 bfk.noise = inputPtc.noise 

309 bfk.meanXcorrs = dict() 

310 bfk.valid = dict() 

311 bfk.updateMetadataFromExposures([inputPtc]) 

312 

313 for amp in detector: 

314 ampName = amp.getName() 

315 gain = bfk.gain[ampName] 

316 noiseMatrix = inputPtc.noiseMatrix[ampName] 

317 mask = inputPtc.expIdMask[ampName] 

318 if gain <= 0: 

319 # We've received very bad data. 

320 self.log.warning("Impossible gain recieved from PTC for %s: %f. Skipping bad amplifier.", 

321 ampName, gain) 

322 bfk.meanXcorrs[ampName] = np.zeros(bfk.shape) 

323 bfk.ampKernels[ampName] = np.zeros(bfk.shape) 

324 bfk.rawXcorrs[ampName] = np.zeros((len(mask), inputPtc.covMatrixSide, inputPtc.covMatrixSide)) 

325 bfk.valid[ampName] = False 

326 continue 

327 

328 # Use inputPtc.expIdMask to get the means, variances, and 

329 # covariances that were not masked after PTC. The 

330 # covariances may now have the mask already applied. 

331 fluxes = np.array(bfk.rawMeans[ampName])[mask] 

332 variances = np.array(bfk.rawVariances[ampName])[mask] 

333 covModelList = np.array(inputPtc.covariancesModel[ampName]) 

334 

335 xCorrList = np.array([np.array(xcorr) for xcorr in bfk.rawXcorrs[ampName]]) 

336 if np.sum(mask) < len(xCorrList): 

337 # Only apply the mask if needed. 

338 xCorrList = xCorrList[mask] 

339 

340 fluxes = np.array([flux*gain for flux in fluxes]) # Now in e^- 

341 variances = np.array([variance*gain*gain for variance in variances]) # Now in e^2- 

342 

343 # This should duplicate Coulton et al. 2017 Equation 22-29 

344 # (arxiv:1711.06273) 

345 scaledCorrList = list() 

346 corrList = list() 

347 truncatedFluxes = list() 

348 for xcorrNum, (xcorr, flux, var) in enumerate(zip(xCorrList, fluxes, variances), 1): 

349 q = np.array(xcorr) * gain * gain # xcorr now in e^- 

350 q *= 2.0 # Remove factor of 1/2 applied in PTC. 

351 self.log.info("Amp: %s %d/%d Flux: %f Var: %f Q(0,0): %g Q(1,0): %g Q(0,1): %g", 

352 ampName, xcorrNum, len(xCorrList), flux, var, q[0][0], q[1][0], q[0][1]) 

353 

354 # Normalize by the flux, which removes the (0,0) 

355 # component attributable to Poisson noise. This 

356 # contains the two "t I delta(x - x')" terms in 

357 # Coulton et al. 2017 equation 29 

358 q[0][0] -= 2.0*(flux) 

359 

360 if q[0][0] > 0.0: 

361 self.log.warning("Amp: %s %d skipped due to value of (variance-mean)=%f", 

362 ampName, xcorrNum, q[0][0]) 

363 # If we drop an element of ``scaledCorrList`` 

364 # (which is what this does), we need to ensure we 

365 # drop the flux entry as well. 

366 continue 

367 

368 # This removes the "t (I_a^2 + I_b^2)" factor in 

369 # Coulton et al. 2017 equation 29. 

370 # The quadratic fit option needs the correlations unscaled 

371 q /= -2.0 

372 unscaled = self._tileArray(q) 

373 q /= flux**2 

374 scaled = self._tileArray(q) 

375 xcorrCheck = np.abs(np.sum(scaled))/np.sum(np.abs(scaled)) 

376 if (xcorrCheck > self.config.xcorrCheckRejectLevel) or not (np.isfinite(xcorrCheck)): 

377 self.log.warning("Amp: %s %d skipped due to value of triangle-inequality sum %f", 

378 ampName, xcorrNum, xcorrCheck) 

379 continue 

380 

381 scaledCorrList.append(scaled) 

382 corrList.append(unscaled) 

383 truncatedFluxes.append(flux) 

384 self.log.info("Amp: %s %d/%d Final: %g XcorrCheck: %f", 

385 ampName, xcorrNum, len(xCorrList), q[0][0], xcorrCheck) 

386 

387 fluxes = np.array(truncatedFluxes) 

388 

389 if len(scaledCorrList) == 0: 

390 self.log.warning("Amp: %s All inputs rejected for amp!", ampName) 

391 bfk.meanXcorrs[ampName] = np.zeros(bfk.shape) 

392 bfk.ampKernels[ampName] = np.zeros(bfk.shape) 

393 bfk.valid[ampName] = False 

394 continue 

395 

396 if self.config.useAmatrix: 

397 # Use the aMatrix, ignoring the meanXcorr generated above. 

398 preKernel = np.pad(self._tileArray(-1.0 * np.array(inputPtc.aMatrix[ampName])), ((1, 1))) 

399 elif self.config.correlationQuadraticFit: 

400 # Use a quadratic fit to the correlations as a 

401 # function of flux. 

402 preKernel = self.quadraticCorrelations(corrList, fluxes, f"Amp: {ampName}") 

403 elif self.config.useCovModelSample: 

404 # Sample the full covariance model at a given flux. 

405 # Use the non-truncated fluxes for this 

406 mu = bfk.rawMeans[ampName] 

407 covTilde = self.sampleCovModel(mu, noiseMatrix, gain, 

408 covModelList, fluxSampleDict[ampName], 

409 f"Amp: {ampName}") 

410 preKernel = np.pad(self._tileArray(-1.0 * covTilde), ((1, 1))) 

411 else: 

412 # Use a simple average of the measured correlations. 

413 preKernel = self.averageCorrelations(scaledCorrList, f"Amp: {ampName}") 

414 

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

416 

417 if self.config.forceZeroSum: 

418 totalSum = np.sum(preKernel) 

419 

420 if self.config.correlationModelRadius < (preKernel.shape[0] - 1) / 2: 

421 # Assume a correlation model of 

422 # Corr(r) = -preFactor * r^(2 * slope) 

423 preFactor = np.sqrt(preKernel[center, center + 1] * preKernel[center + 1, center]) 

424 slopeFactor = 2.0 * np.abs(self.config.correlationModelSlope) 

425 totalSum += 2.0*np.pi*(preFactor / (slopeFactor*(center + 0.5))**slopeFactor) 

426 

427 preKernel[center, center] -= totalSum 

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

429 

430 finalSum = np.sum(preKernel) 

431 bfk.meanXcorrs[ampName] = preKernel 

432 

433 postKernel = self.successiveOverRelax(preKernel) 

434 bfk.ampKernels[ampName] = postKernel 

435 if self.config.level == 'DETECTOR' and ampName not in self.config.ignoreAmpsForAveraging: 

436 detectorCorrList.extend(scaledCorrList) 

437 detectorFluxes.extend(fluxes) 

438 

439 # Check for validity 

440 if self.config.doCheckValidity: 

441 # We will check 3 boundary conditions to test the 

442 # validitity of the kernel: 

443 # (1) The edge of the kernel should be consistent with zero. 

444 # (2) The kernel should be negative 

445 # (3) The center of the kernal should be the absolute minimum. 

446 

447 # Since there are typically too few pixels along the edge, we 

448 # will use the stddev of the 3px picture frame of the kernel to 

449 # set the tolerances of the validity conditions. 

450 kernelSize = bfk.ampKernels[ampName].shape[0] 

451 kernelCenterValue = bfk.ampKernels[ampName][kernelSize//2, kernelSize//2] 

452 xv, yv = np.meshgrid(range(kernelSize), range(kernelSize)) 

453 

454 # Get a mask for a 3px picture frame 

455 valid = True 

456 if kernelSize >= 7: 

457 # Last 1px 

458 edge = (xv == 0) 

459 edge += (xv == kernelSize - 1) 

460 edge += (yv == 0) 

461 edge += (yv == kernelSize - 1) 

462 

463 # Last 3 px 

464 edges = (xv < 3) 

465 edges += (xv > kernelSize - 3 - 1) 

466 edges += (yv < 3) 

467 edges += (yv > kernelSize - 3 - 1) 

468 

469 kernelEdge = bfk.ampKernels[ampName][edge].ravel() 

470 kernelEdges = bfk.ampKernels[ampName][edges].ravel() 

471 kernelEdgeStd = np.std(kernelEdges) 

472 

473 threshold = self.config.nSigmaTolForValidEdge*kernelEdgeStd 

474 

475 # Edge should converge to zero 

476 valid = np.all(np.isclose( 

477 kernelEdge, 0, 

478 rtol=threshold, 

479 )) 

480 if not valid: 

481 self.log.warning("%s: the kernel edges did not converge to 0 within " 

482 "+/- %s sigma.", ampName, threshold) 

483 

484 # The center should be the minimum 

485 valid *= np.all(bfk.ampKernels[ampName] >= kernelCenterValue) 

486 if not np.all(bfk.ampKernels[ampName] >= kernelCenterValue): 

487 self.log.warning("%s: the kernel center is not the absolute minimum.", ampName) 

488 

489 # Kernel should be negative 

490 negativityThreshold = self.config.fractionK00ForKernelMax*np.abs(kernelCenterValue) 

491 valid *= np.all(bfk.ampKernels[ampName] <= negativityThreshold) 

492 if not np.all(bfk.ampKernels[ampName] <= negativityThreshold): 

493 self.log.warning("%s: the kernel is not negative", ampName) 

494 else: 

495 raise RuntimeError("%s: The kernel is too small for validity check.", ampName) 

496 

497 bfk.valid[ampName] = valid 

498 else: 

499 # The kernel at this point will be valid 

500 bfk.valid[ampName] = True 

501 

502 self.log.info("Amp: %s Sum: %g Center Info Pre: %g Post: %g", 

503 ampName, finalSum, preKernel[center, center], postKernel[center, center]) 

504 

505 # Assemble a detector kernel? 

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

507 if self.config.correlationQuadraticFit: 

508 preKernel = self.quadraticCorrelations(detectorCorrList, detectorFluxes, f"Amp: {ampName}") 

509 else: 

510 preKernel = self.averageCorrelations(detectorCorrList, f"Det: {detName}") 

511 finalSum = np.sum(preKernel) 

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

513 

514 postKernel = self.successiveOverRelax(preKernel) 

515 bfk.detKernels[detName] = postKernel 

516 self.log.info("Det: %s Sum: %g Center Info Pre: %g Post: %g", 

517 detName, finalSum, preKernel[center, center], postKernel[center, center]) 

518 

519 return pipeBase.Struct( 

520 outputBFK=bfk, 

521 ) 

522 

523 def averageCorrelations(self, xCorrList, name): 

524 """Average input correlations. 

525 

526 Parameters 

527 ---------- 

528 xCorrList : `list` [`numpy.array`] 

529 List of cross-correlations. These are expected to be 

530 square arrays. 

531 name : `str` 

532 Name for log messages. 

533 

534 Returns 

535 ------- 

536 meanXcorr : `numpy.array`, (N, N) 

537 The averaged cross-correlation. 

538 """ 

539 meanXcorr = np.zeros_like(xCorrList[0]) 

540 xCorrList = np.array(xCorrList) 

541 

542 sctrl = afwMath.StatisticsControl() 

543 sctrl.setNumSigmaClip(self.config.nSigmaClip) 

544 for i in range(np.shape(meanXcorr)[0]): 

545 for j in range(np.shape(meanXcorr)[1]): 

546 meanXcorr[i, j] = afwMath.makeStatistics(xCorrList[:, i, j], 

547 afwMath.MEANCLIP, sctrl).getValue() 

548 

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

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

551 

552 return meanXcorr 

553 

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

555 """Measure a quadratic correlation model. 

556 

557 Parameters 

558 ---------- 

559 xCorrList : `list` [`numpy.array`] 

560 List of cross-correlations. These are expected to be 

561 square arrays. 

562 fluxList : `numpy.array`, (Nflux,) 

563 Associated list of fluxes. 

564 name : `str` 

565 Name for log messages. 

566 

567 Returns 

568 ------- 

569 meanXcorr : `numpy.array`, (N, N) 

570 The averaged cross-correlation. 

571 """ 

572 meanXcorr = np.zeros_like(xCorrList[0]) 

573 fluxList = np.square(fluxList) 

574 xCorrList = np.array(xCorrList) 

575 

576 for i in range(np.shape(meanXcorr)[0]): 

577 for j in range(np.shape(meanXcorr)[1]): 

578 # Fit corrlation_i(x, y) = a0 + a1 * (flux_i)^2 We do 

579 # not want to transpose, so use (i, j) without 

580 # inversion. 

581 linearFit, linearFitErr, chiSq, weights = irlsFit([0.0, 1e-4], fluxList, 

582 xCorrList[:, i, j], funcPolynomial, 

583 scaleResidual=False) 

584 meanXcorr[i, j] = linearFit[1] # Discard the intercept. 

585 self.log.info("Quad fit meanXcorr[%d,%d] = %g", i, j, linearFit[1]) 

586 

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

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

589 

590 return meanXcorr 

591 

592 def sampleCovModel(self, fluxes, noiseMatrix, gain, covModelList, flux, name): 

593 """Sample the correlation model and measure 

594 widetile{C}_{ij} from Broughton et al. 2023 (eq. 4) 

595 

596 Parameters 

597 ---------- 

598 fluxes : `list` [`float`] 

599 List of fluxes (in ADU) 

600 noiseMatrix : `numpy.array`, (N, N) 

601 Noise matrix 

602 gain : `float` 

603 Amplifier gain 

604 covModelList : `numpy.array`, (N, N) 

605 List of covariance model matrices. These are 

606 expected to be square arrays. 

607 flux : `float` 

608 Flux in electrons at which to sample the 

609 covariance model. 

610 name : `str` 

611 Name for log messages. 

612 

613 Returns 

614 ------- 

615 covTilde : `numpy.array`, (N, N) 

616 The calculated C-tilde from Broughton et al. 2023 (eq. 4). 

617 """ 

618 

619 # Get the index of the flux sample 

620 # (this must be done in electron units) 

621 ix = np.argmin((fluxes*gain - flux)**2) 

622 assert len(fluxes) == len(covModelList) 

623 

624 # Find the nearest measured flux level 

625 # and the full covariance model at that point 

626 nearestFlux = fluxes[ix] 

627 covModelSample = covModelList[ix] 

628 

629 # Calculate flux sample 

630 # covTilde returned in ADU units 

631 covTilde = (covModelSample - noiseMatrix/gain**2)/(nearestFlux**2) 

632 covTilde[0][0] -= (nearestFlux/gain)/(nearestFlux**2) 

633 

634 return covTilde 

635 

636 @staticmethod 

637 def _tileArray(in_array): 

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

639 

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

641 

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

643 [4, 5, 6], 

644 [7, 8, 9]]) 

645 

646 return an array of size 2n-1 as 

647 

648 output = array([[ 9, 8, 7, 8, 9], 

649 [ 6, 5, 4, 5, 6], 

650 [ 3, 2, 1, 2, 3], 

651 [ 6, 5, 4, 5, 6], 

652 [ 9, 8, 7, 8, 9]]) 

653 

654 Parameters 

655 ---------- 

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

657 The square input quarter-array 

658 

659 Returns 

660 ------- 

661 output : `np.array`, (2*N + 1, 2*N + 1) 

662 The full, tiled array 

663 """ 

664 assert in_array.shape[0] == in_array.shape[1] 

665 length = in_array.shape[0] - 1 

666 output = np.zeros((2*length + 1, 2*length + 1)) 

667 

668 for i in range(length + 1): 

669 for j in range(length + 1): 

670 output[i + length, j + length] = in_array[i, j] 

671 output[-i + length, j + length] = in_array[i, j] 

672 output[i + length, -j + length] = in_array[i, j] 

673 output[-i + length, -j + length] = in_array[i, j] 

674 return output 

675 

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

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

678 

679 A numerical method for solving a system of linear equations 

680 with faster convergence than the Gauss-Seidel method. 

681 

682 Parameters 

683 ---------- 

684 source : `numpy.ndarray`, (N, N) 

685 The input array. 

686 maxIter : `int`, optional 

687 Maximum number of iterations to attempt before aborting. 

688 eLevel : `float`, optional 

689 The target error level at which we deem convergence to have 

690 occurred. 

691 

692 Returns 

693 ------- 

694 output : `numpy.ndarray`, (N, N) 

695 The solution. 

696 """ 

697 if not maxIter: 

698 maxIter = self.config.maxIterSuccessiveOverRelaxation 

699 if not eLevel: 

700 eLevel = self.config.eLevelSuccessiveOverRelaxation 

701 

702 assert source.shape[0] == source.shape[1], "Input array must be square" 

703 # initialize, and set boundary conditions 

704 func = np.zeros([source.shape[0] + 2, source.shape[1] + 2]) 

705 resid = np.zeros([source.shape[0] + 2, source.shape[1] + 2]) 

706 rhoSpe = np.cos(np.pi/source.shape[0]) # Here a square grid is assumed 

707 

708 # Calculate the initial error 

709 for i in range(1, func.shape[0] - 1): 

710 for j in range(1, func.shape[1] - 1): 

711 resid[i, j] = (func[i, j - 1] + func[i, j + 1] + func[i - 1, j] 

712 + func[i + 1, j] - 4*func[i, j] - source[i - 1, j - 1]) 

713 inError = np.sum(np.abs(resid)) 

714 

715 # Iterate until convergence 

716 # We perform two sweeps per cycle, 

717 # updating 'odd' and 'even' points separately 

718 nIter = 0 

719 omega = 1.0 

720 dx = 1.0 

721 while nIter < maxIter*2: 

722 outError = 0 

723 if nIter%2 == 0: 

724 for i in range(1, func.shape[0] - 1, 2): 

725 for j in range(1, func.shape[1] - 1, 2): 

726 resid[i, j] = float(func[i, j-1] + func[i, j + 1] + func[i - 1, j] 

727 + func[i + 1, j] - 4.0*func[i, j] - dx*dx*source[i - 1, j - 1]) 

728 func[i, j] += omega*resid[i, j]*.25 

729 for i in range(2, func.shape[0] - 1, 2): 

730 for j in range(2, func.shape[1] - 1, 2): 

731 resid[i, j] = float(func[i, j - 1] + func[i, j + 1] + func[i - 1, j] 

732 + func[i + 1, j] - 4.0*func[i, j] - dx*dx*source[i - 1, j - 1]) 

733 func[i, j] += omega*resid[i, j]*.25 

734 else: 

735 for i in range(1, func.shape[0] - 1, 2): 

736 for j in range(2, func.shape[1] - 1, 2): 

737 resid[i, j] = float(func[i, j - 1] + func[i, j + 1] + func[i - 1, j] 

738 + func[i + 1, j] - 4.0*func[i, j] - dx*dx*source[i - 1, j - 1]) 

739 func[i, j] += omega*resid[i, j]*.25 

740 for i in range(2, func.shape[0] - 1, 2): 

741 for j in range(1, func.shape[1] - 1, 2): 

742 resid[i, j] = float(func[i, j - 1] + func[i, j + 1] + func[i - 1, j] 

743 + func[i + 1, j] - 4.0*func[i, j] - dx*dx*source[i - 1, j - 1]) 

744 func[i, j] += omega*resid[i, j]*.25 

745 outError = np.sum(np.abs(resid)) 

746 if outError < inError*eLevel: 

747 break 

748 if nIter == 0: 

749 omega = 1.0/(1 - rhoSpe*rhoSpe/2.0) 

750 else: 

751 omega = 1.0/(1 - rhoSpe*rhoSpe*omega/4.0) 

752 nIter += 1 

753 

754 if nIter >= maxIter*2: 

755 self.log.warning("Failure: SuccessiveOverRelaxation did not converge in %s iterations." 

756 "\noutError: %s, inError: %s,", nIter//2, outError, inError*eLevel) 

757 else: 

758 self.log.info("Success: SuccessiveOverRelaxation converged in %s iterations." 

759 "\noutError: %s, inError: %s", nIter//2, outError, inError*eLevel) 

760 return func[1: -1, 1: -1]