Coverage for python/lsst/cp/pipe/utils.py: 7%

773 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-24 02:10 -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 

23__all__ = ['ddict2dict', 'CovFastFourierTransform', 'getReadNoise', 'ampOffsetGainRatioFixup'] 

24 

25from astropy.table import Table 

26import galsim 

27import logging 

28import numpy as np 

29import itertools 

30import numpy.polynomial.polynomial as poly 

31import warnings 

32 

33from scipy.interpolate import Akima1DInterpolator 

34from scipy.optimize import leastsq, minimize 

35from scipy.stats import median_abs_deviation, norm 

36 

37from lsst.ip.isr import isrMock 

38import lsst.afw.cameraGeom 

39import lsst.afw.image 

40import lsst.afw.math 

41 

42 

43def sigmaClipCorrection(nSigClip): 

44 """Correct measured sigma to account for clipping. 

45 

46 If we clip our input data and then measure sigma, then the 

47 measured sigma is smaller than the true value because real 

48 points beyond the clip threshold have been removed. This is a 

49 small (1.5% at nSigClip=3) effect when nSigClip >~ 3, but the 

50 default parameters for measure crosstalk use nSigClip=2.0. 

51 This causes the measured sigma to be about 15% smaller than 

52 real. This formula corrects the issue, for the symmetric case 

53 (upper clip threshold equal to lower clip threshold). 

54 

55 Parameters 

56 ---------- 

57 nSigClip : `float` 

58 Number of sigma the measurement was clipped by. 

59 

60 Returns 

61 ------- 

62 scaleFactor : `float` 

63 Scale factor to increase the measured sigma by. 

64 """ 

65 varFactor = 1.0 - (2 * nSigClip * norm.pdf(nSigClip)) / (norm.cdf(nSigClip) - norm.cdf(-nSigClip)) 

66 return 1.0 / np.sqrt(varFactor) 

67 

68 

69def calculateWeightedReducedChi2(measured, model, weightsMeasured, nData, nParsModel): 

70 """Calculate weighted reduced chi2. 

71 

72 Parameters 

73 ---------- 

74 measured : `list` 

75 List with measured data. 

76 model : `list` 

77 List with modeled data. 

78 weightsMeasured : `list` 

79 List with weights for the measured data. 

80 nData : `int` 

81 Number of data points. 

82 nParsModel : `int` 

83 Number of parameters in the model. 

84 

85 Returns 

86 ------- 

87 redWeightedChi2 : `float` 

88 Reduced weighted chi2. 

89 """ 

90 wRes = (measured - model)*weightsMeasured 

91 return ((wRes*wRes).sum())/(nData-nParsModel) 

92 

93 

94def makeMockFlats(expTime, gain=1.0, readNoiseElectrons=5, fluxElectrons=1000, 

95 randomSeedFlat1=1984, randomSeedFlat2=666, powerLawBfParams=[], 

96 expId1=0, expId2=1, ampNames=[]): 

97 """Create a pair or mock flats with isrMock. 

98 

99 Parameters 

100 ---------- 

101 expTime : `float` 

102 Exposure time of the flats. 

103 gain : `float`, optional 

104 Gain, in e/ADU. 

105 readNoiseElectrons : `float`, optional 

106 Read noise rms, in electrons. 

107 fluxElectrons : `float`, optional 

108 Flux of flats, in electrons per second. 

109 randomSeedFlat1 : `int`, optional 

110 Random seed for the normal distrubutions for the mean signal 

111 and noise (flat1). 

112 randomSeedFlat2 : `int`, optional 

113 Random seed for the normal distrubutions for the mean signal 

114 and noise (flat2). 

115 powerLawBfParams : `list`, optional 

116 Parameters for `galsim.cdmodel.PowerLawCD` to simulate the 

117 brightter-fatter effect. 

118 expId1 : `int`, optional 

119 Exposure ID for first flat. 

120 expId2 : `int`, optional 

121 Exposure ID for second flat. 

122 ampNames : `list` [`str`], optional 

123 Names of amplifiers for filling in header information. 

124 

125 Returns 

126 ------- 

127 flatExp1 : `lsst.afw.image.exposure.ExposureF` 

128 First exposure of flat field pair. 

129 flatExp2 : `lsst.afw.image.exposure.ExposureF` 

130 Second exposure of flat field pair. 

131 

132 Notes 

133 ----- 

134 The parameters of `galsim.cdmodel.PowerLawCD` are `n, r0, t0, rx, 

135 tx, r, t, alpha`. For more information about their meaning, see 

136 the Galsim documentation 

137 https://galsim-developers.github.io/GalSim/_build/html/_modules/galsim/cdmodel.html # noqa: W505 

138 and Gruen+15 (1501.02802). 

139 

140 Example: galsim.cdmodel.PowerLawCD(8, 1.1e-7, 1.1e-7, 1.0e-8, 

141 1.0e-8, 1.0e-9, 1.0e-9, 2.0) 

142 """ 

143 flatFlux = fluxElectrons # e/s 

144 flatMean = flatFlux*expTime # e 

145 readNoise = readNoiseElectrons # e 

146 

147 mockImageConfig = isrMock.IsrMock.ConfigClass() 

148 

149 mockImageConfig.flatDrop = 0.99999 

150 mockImageConfig.isTrimmed = True 

151 

152 flatExp1 = isrMock.FlatMock(config=mockImageConfig).run() 

153 flatExp2 = flatExp1.clone() 

154 (shapeY, shapeX) = flatExp1.getDimensions() 

155 flatWidth = np.sqrt(flatMean) 

156 

157 rng1 = np.random.RandomState(randomSeedFlat1) 

158 flatData1 = rng1.normal(flatMean, flatWidth, (shapeX, shapeY)) + rng1.normal(0.0, readNoise, 

159 (shapeX, shapeY)) 

160 rng2 = np.random.RandomState(randomSeedFlat2) 

161 flatData2 = rng2.normal(flatMean, flatWidth, (shapeX, shapeY)) + rng2.normal(0.0, readNoise, 

162 (shapeX, shapeY)) 

163 # Simulate BF with power law model in galsim 

164 if len(powerLawBfParams): 

165 if not len(powerLawBfParams) == 8: 

166 raise RuntimeError("Wrong number of parameters for `galsim.cdmodel.PowerLawCD`. " 

167 f"Expected 8; passed {len(powerLawBfParams)}.") 

168 cd = galsim.cdmodel.PowerLawCD(*powerLawBfParams) 

169 tempFlatData1 = galsim.Image(flatData1) 

170 temp2FlatData1 = cd.applyForward(tempFlatData1) 

171 

172 tempFlatData2 = galsim.Image(flatData2) 

173 temp2FlatData2 = cd.applyForward(tempFlatData2) 

174 

175 flatExp1.image.array[:] = temp2FlatData1.array/gain # ADU 

176 flatExp2.image.array[:] = temp2FlatData2.array/gain # ADU 

177 else: 

178 flatExp1.image.array[:] = flatData1/gain # ADU 

179 flatExp2.image.array[:] = flatData2/gain # ADU 

180 

181 visitInfoExp1 = lsst.afw.image.VisitInfo(exposureTime=expTime) 

182 visitInfoExp2 = lsst.afw.image.VisitInfo(exposureTime=expTime) 

183 

184 flatExp1.info.id = expId1 

185 flatExp1.getInfo().setVisitInfo(visitInfoExp1) 

186 flatExp2.info.id = expId2 

187 flatExp2.getInfo().setVisitInfo(visitInfoExp2) 

188 

189 # Set ISR metadata. 

190 flatExp1.metadata["LSST ISR UNITS"] = "adu" 

191 flatExp2.metadata["LSST ISR UNITS"] = "adu" 

192 for ampName in ampNames: 

193 key = f"LSST ISR OVERSCAN RESIDUAL SERIAL STDEV {ampName}" 

194 value = readNoiseElectrons / gain 

195 

196 flatExp1.metadata[key] = value 

197 flatExp2.metadata[key] = value 

198 

199 key = f"LSST ISR OVERSCAN SERIAL MEDIAN {ampName}" 

200 flatExp1.metadata[key] = 25000.0 # adu 

201 flatExp2.metadata[key] = 25000.0 # adu 

202 

203 key = f"LSST ISR AMPOFFSET PEDESTAL {ampName}" 

204 value = 0.0 

205 

206 flatExp1.metadata[key] = value 

207 flatExp2.metadata[key] = value 

208 

209 return flatExp1, flatExp2 

210 

211 

212def irlsFit(initialParams, dataX, dataY, function, weightsY=None, weightType='Cauchy', scaleResidual=True): 

213 """Iteratively reweighted least squares fit. 

214 

215 This uses the `lsst.cp.pipe.utils.fitLeastSq`, but applies weights 

216 based on the Cauchy distribution by default. Other weight options 

217 are implemented. See e.g. Holland and Welsch, 1977, 

218 doi:10.1080/03610927708827533 

219 

220 Parameters 

221 ---------- 

222 initialParams : `list` [`float`] 

223 Starting parameters. 

224 dataX : `numpy.array`, (N,) 

225 Abscissa data. 

226 dataY : `numpy.array`, (N,) 

227 Ordinate data. 

228 function : callable 

229 Function to fit. 

230 weightsY : `numpy.array`, (N,) 

231 Weights to apply to the data. 

232 weightType : `str`, optional 

233 Type of weighting to use. One of Cauchy, Anderson, bisquare, 

234 box, Welsch, Huber, logistic, or Fair. 

235 scaleResidual : `bool`, optional 

236 If true, the residual is scaled by the sqrt of the Y values. 

237 

238 Returns 

239 ------- 

240 polyFit : `list` [`float`] 

241 Final best fit parameters. 

242 polyFitErr : `list` [`float`] 

243 Final errors on fit parameters. 

244 chiSq : `float` 

245 Reduced chi squared. 

246 weightsY : `list` [`float`] 

247 Final weights used for each point. 

248 

249 Raises 

250 ------ 

251 RuntimeError : 

252 Raised if an unknown weightType string is passed. 

253 """ 

254 if not weightsY: 

255 weightsY = np.ones_like(dataX) 

256 

257 polyFit, polyFitErr, chiSq = fitLeastSq(initialParams, dataX, dataY, function, weightsY=weightsY) 

258 for iteration in range(10): 

259 resid = np.abs(dataY - function(polyFit, dataX)) 

260 if scaleResidual: 

261 resid = resid / np.sqrt(dataY) 

262 if weightType == 'Cauchy': 

263 # Use Cauchy weighting. This is a soft weight. 

264 # At [2, 3, 5, 10] sigma, weights are [.59, .39, .19, .05]. 

265 Z = resid / 2.385 

266 weightsY = 1.0 / (1.0 + np.square(Z)) 

267 elif weightType == 'Anderson': 

268 # Anderson+1972 weighting. This is a hard weight. 

269 # At [2, 3, 5, 10] sigma, weights are [.67, .35, 0.0, 0.0]. 

270 Z = resid / (1.339 * np.pi) 

271 weightsY = np.where(Z < 1.0, np.sinc(Z), 0.0) 

272 elif weightType == 'bisquare': 

273 # Beaton and Tukey (1974) biweight. This is a hard weight. 

274 # At [2, 3, 5, 10] sigma, weights are [.81, .59, 0.0, 0.0]. 

275 Z = resid / 4.685 

276 weightsY = np.where(Z < 1.0, 1.0 - np.square(Z), 0.0) 

277 elif weightType == 'box': 

278 # Hinich and Talwar (1975). This is a hard weight. 

279 # At [2, 3, 5, 10] sigma, weights are [1.0, 0.0, 0.0, 0.0]. 

280 weightsY = np.where(resid < 2.795, 1.0, 0.0) 

281 elif weightType == 'Welsch': 

282 # Dennis and Welsch (1976). This is a hard weight. 

283 # At [2, 3, 5, 10] sigma, weights are [.64, .36, .06, 1e-5]. 

284 Z = resid / 2.985 

285 weightsY = np.exp(-1.0 * np.square(Z)) 

286 elif weightType == 'Huber': 

287 # Huber (1964) weighting. This is a soft weight. 

288 # At [2, 3, 5, 10] sigma, weights are [.67, .45, .27, .13]. 

289 Z = resid / 1.345 

290 weightsY = np.where(Z < 1.0, 1.0, 1 / Z) 

291 elif weightType == 'logistic': 

292 # Logistic weighting. This is a soft weight. 

293 # At [2, 3, 5, 10] sigma, weights are [.56, .40, .24, .12]. 

294 Z = resid / 1.205 

295 weightsY = np.tanh(Z) / Z 

296 elif weightType == 'Fair': 

297 # Fair (1974) weighting. This is a soft weight. 

298 # At [2, 3, 5, 10] sigma, weights are [.41, .32, .22, .12]. 

299 Z = resid / 1.4 

300 weightsY = (1.0 / (1.0 + (Z))) 

301 else: 

302 raise RuntimeError(f"Unknown weighting type: {weightType}") 

303 polyFit, polyFitErr, chiSq = fitLeastSq(initialParams, dataX, dataY, function, weightsY=weightsY) 

304 

305 return polyFit, polyFitErr, chiSq, weightsY 

306 

307 

308def fitLeastSq(initialParams, dataX, dataY, function, weightsY=None): 

309 """Do a fit and estimate the parameter errors using using 

310 scipy.optimize.leastq. 

311 

312 optimize.leastsq returns the fractional covariance matrix. To 

313 estimate the standard deviation of the fit parameters, multiply 

314 the entries of this matrix by the unweighted reduced chi squared 

315 and take the square root of the diagonal elements. 

316 

317 Parameters 

318 ---------- 

319 initialParams : `list` [`float`] 

320 initial values for fit parameters. 

321 dataX : `numpy.array`, (N,) 

322 Data in the abscissa axis. 

323 dataY : `numpy.array`, (N,) 

324 Data in the ordinate axis. 

325 function : callable object (function) 

326 Function to fit the data with. 

327 weightsY : `numpy.array`, (N,) 

328 Weights of the data in the ordinate axis. 

329 

330 Return 

331 ------ 

332 pFitSingleLeastSquares : `list` [`float`] 

333 List with fitted parameters. 

334 pErrSingleLeastSquares : `list` [`float`] 

335 List with errors for fitted parameters. 

336 

337 reducedChiSqSingleLeastSquares : `float` 

338 Reduced chi squared, unweighted if weightsY is not provided. 

339 """ 

340 if weightsY is None: 

341 weightsY = np.ones(len(dataX)) 

342 

343 def errFunc(p, x, y, weightsY=None): 

344 if weightsY is None: 

345 weightsY = np.ones(len(x)) 

346 return (function(p, x) - y)*weightsY 

347 

348 pFit, pCov, infoDict, errMessage, success = leastsq(errFunc, initialParams, 

349 args=(dataX, dataY, weightsY), full_output=1, 

350 epsfcn=0.0001) 

351 

352 if (len(dataY) > len(initialParams)) and pCov is not None: 

353 reducedChiSq = calculateWeightedReducedChi2(dataY, function(pFit, dataX), weightsY, len(dataY), 

354 len(initialParams)) 

355 pCov *= reducedChiSq 

356 else: 

357 pCov = np.zeros((len(initialParams), len(initialParams))) 

358 pCov[:, :] = np.nan 

359 reducedChiSq = np.nan 

360 

361 errorVec = [] 

362 for i in range(len(pFit)): 

363 errorVec.append(np.fabs(pCov[i][i])**0.5) 

364 

365 pFitSingleLeastSquares = pFit 

366 pErrSingleLeastSquares = np.array(errorVec) 

367 

368 return pFitSingleLeastSquares, pErrSingleLeastSquares, reducedChiSq 

369 

370 

371def fitBootstrap(initialParams, dataX, dataY, function, weightsY=None, confidenceSigma=1.): 

372 """Do a fit using least squares and bootstrap to estimate parameter errors. 

373 

374 The bootstrap error bars are calculated by fitting 100 random data sets. 

375 

376 Parameters 

377 ---------- 

378 initialParams : `list` [`float`] 

379 initial values for fit parameters. 

380 dataX : `numpy.array`, (N,) 

381 Data in the abscissa axis. 

382 dataY : `numpy.array`, (N,) 

383 Data in the ordinate axis. 

384 function : callable object (function) 

385 Function to fit the data with. 

386 weightsY : `numpy.array`, (N,), optional. 

387 Weights of the data in the ordinate axis. 

388 confidenceSigma : `float`, optional. 

389 Number of sigmas that determine confidence interval for the 

390 bootstrap errors. 

391 

392 Return 

393 ------ 

394 pFitBootstrap : `list` [`float`] 

395 List with fitted parameters. 

396 pErrBootstrap : `list` [`float`] 

397 List with errors for fitted parameters. 

398 reducedChiSqBootstrap : `float` 

399 Reduced chi squared, unweighted if weightsY is not provided. 

400 """ 

401 if weightsY is None: 

402 weightsY = np.ones(len(dataX)) 

403 

404 def errFunc(p, x, y, weightsY): 

405 if weightsY is None: 

406 weightsY = np.ones(len(x)) 

407 return (function(p, x) - y)*weightsY 

408 

409 # Fit first time 

410 pFit, _ = leastsq(errFunc, initialParams, args=(dataX, dataY, weightsY), full_output=0) 

411 

412 # Get the stdev of the residuals 

413 residuals = errFunc(pFit, dataX, dataY, weightsY) 

414 # 100 random data sets are generated and fitted 

415 pars = [] 

416 for i in range(100): 

417 randomDelta = np.random.normal(0., np.fabs(residuals), len(dataY)) 

418 randomDataY = dataY + randomDelta 

419 randomFit, _ = leastsq(errFunc, initialParams, 

420 args=(dataX, randomDataY, weightsY), full_output=0) 

421 pars.append(randomFit) 

422 pars = np.array(pars) 

423 meanPfit = np.mean(pars, 0) 

424 

425 # confidence interval for parameter estimates 

426 errPfit = confidenceSigma*np.std(pars, 0) 

427 pFitBootstrap = meanPfit 

428 pErrBootstrap = errPfit 

429 

430 reducedChiSq = calculateWeightedReducedChi2(dataY, function(pFitBootstrap, dataX), weightsY, len(dataY), 

431 len(initialParams)) 

432 return pFitBootstrap, pErrBootstrap, reducedChiSq 

433 

434 

435def funcPolynomial(pars, x): 

436 """Polynomial function definition 

437 Parameters 

438 ---------- 

439 params : `list` 

440 Polynomial coefficients. Its length determines the polynomial order. 

441 

442 x : `numpy.array`, (N,) 

443 Abscisa array. 

444 

445 Returns 

446 ------- 

447 y : `numpy.array`, (N,) 

448 Ordinate array after evaluating polynomial of order 

449 len(pars)-1 at `x`. 

450 """ 

451 return poly.polyval(x, [*pars]) 

452 

453 

454def funcAstier(pars, x): 

455 """Single brighter-fatter parameter model for PTC; Equation 16 of 

456 Astier+19. 

457 

458 Model: 

459 

460 C_{00}(mu) = frac{1}{2 a_{00} g**2} * [exp(2 a_{00} mu g ) - 1] 

461 + n_{00} / g**2 

462 

463 Parameters 

464 ---------- 

465 params : `list` 

466 Parameters of the model: a00 (brightter-fatter), gain (e/ADU), 

467 and noise (e^2). 

468 x : `numpy.array`, (N,) 

469 Signal mu (ADU). 

470 

471 Returns 

472 ------- 

473 y : `numpy.array`, (N,) 

474 C_00 (variance) in ADU^2. 

475 """ 

476 a00, gain, noiseSquared = pars 

477 return 0.5/(a00*gain*gain)*(np.exp(2*a00*x*gain)-1) + noiseSquared/(gain*gain) # C_00 

478 

479 

480def ptcRolloffModel(params, mu): 

481 """Piece-wise exponential saturation roll-off model of the PTC. 

482 

483 Parameters 

484 ---------- 

485 params : `list` 

486 Parameters of the model: muTurnoff (adu), tau (rolloff sharpness). 

487 mu : `numpy.array`, (N,) 

488 Signal mu (ADU). 

489 

490 Returns 

491 ------- 

492 y : `numpy.array`, (N,) 

493 Difference in variance in ADU^2. 

494 """ 

495 muTurnoff, tau = params 

496 return np.where(mu < muTurnoff, 0, np.exp(-(mu - muTurnoff) / tau) - 1) 

497 

498 

499def funcAstierWithRolloff(pars, x): 

500 """Single brighter-fatter parameter model for PTC; Equation 16 of 

501 Astier+19 with an piece-wise exponential model for the PTC roll-off 

502 of the PTC caused by saturation. 

503 

504 The nominal turnoff is calculated beforehand, and we extend the PTC 

505 fit to include signal values up to 5% above the nominally computed 

506 turnoff. 

507 

508 Model: 

509 

510 C_{00}(mu) = funcAstier - np.where( 

511 x < muTurnoff, 

512 0, 

513 np.exp(-(x - muTurnoff) / tau) - 1, 

514 ) 

515 

516 Parameters 

517 ---------- 

518 params : `list` 

519 Parameters of the model: a00 (brightter-fatter), gain (e/ADU), 

520 and noise (e^2), muTurnoff (adu), tau (rolloff sharpness). 

521 x : `numpy.array`, (N,) 

522 Signal mu (ADU). 

523 

524 Returns 

525 ------- 

526 y : `numpy.array`, (N,) 

527 C_00 (variance) in ADU^2. 

528 """ 

529 # Initial computation of Astier+19 (Eqn 19). 

530 originalModelPars = pars[:-2] 

531 ptcRolloffModelPars = pars[-2:] 

532 model = funcAstier(originalModelPars, x) 

533 

534 return model - ptcRolloffModel(ptcRolloffModelPars, x) # C_00 

535 

536 

537def arrangeFlatsByExpTime(exposureList, exposureIdList, log=None): 

538 """Arrange exposures by exposure time. 

539 

540 Parameters 

541 ---------- 

542 exposureList : `list` [`lsst.pipe.base.connections.DeferredDatasetRef`] 

543 Input list of exposure references. 

544 exposureIdList : `list` [`int`] 

545 List of exposure ids as obtained by dataId[`exposure`]. 

546 log : `lsst.utils.logging.LsstLogAdapter`, optional 

547 Log object. 

548 

549 Returns 

550 ------ 

551 flatsAtExpTime : `dict` [`float`, 

552 `list`[(`lsst.pipe.base.connections.DeferredDatasetRef`, 

553 `int`)]] 

554 Dictionary that groups references to flat-field exposures 

555 (and their IDs) that have the same exposure time (seconds). 

556 """ 

557 flatsAtExpTime = {} 

558 assert len(exposureList) == len(exposureIdList), "Different lengths for exp. list and exp. ID lists" 

559 for expRef, expId in zip(exposureList, exposureIdList): 

560 expTime = expRef.get(component='visitInfo').exposureTime 

561 if not np.isfinite(expTime) and log is not None: 

562 log.warning("Exposure %d has non-finite exposure time.", expId) 

563 listAtExpTime = flatsAtExpTime.setdefault(expTime, []) 

564 listAtExpTime.append((expRef, expId)) 

565 

566 return flatsAtExpTime 

567 

568 

569def arrangeFlatsByExpFlux(exposureList, exposureIdList, fluxKeyword, log=None): 

570 """Arrange exposures by exposure flux. 

571 

572 Parameters 

573 ---------- 

574 exposureList : `list` [`lsst.pipe.base.connections.DeferredDatasetRef`] 

575 Input list of exposure references. 

576 exposureIdList : `list` [`int`] 

577 List of exposure ids as obtained by dataId[`exposure`]. 

578 fluxKeyword : `str` 

579 Header keyword that contains the flux per exposure. 

580 log : `lsst.utils.logging.LsstLogAdapter`, optional 

581 Log object. 

582 

583 Returns 

584 ------- 

585 flatsAtFlux : `dict` [`float`, 

586 `list`[(`lsst.pipe.base.connections.DeferredDatasetRef`, 

587 `int`)]] 

588 Dictionary that groups references to flat-field exposures 

589 (and their IDs) that have the same flux. 

590 """ 

591 flatsAtExpFlux = {} 

592 assert len(exposureList) == len(exposureIdList), "Different lengths for exp. list and exp. ID lists" 

593 for expRef, expId in zip(exposureList, exposureIdList): 

594 # Get flux from header, assuming it is in the metadata. 

595 try: 

596 expFlux = expRef.get().getMetadata()[fluxKeyword] 

597 except KeyError: 

598 # If it's missing from the header, continue; it will 

599 # be caught and rejected when pairing exposures. 

600 expFlux = None 

601 if expFlux is None: 

602 if log is not None: 

603 log.warning("Exposure %d does not have valid header keyword %s.", expId, fluxKeyword) 

604 expFlux = np.nan 

605 listAtExpFlux = flatsAtExpFlux.setdefault(expFlux, []) 

606 listAtExpFlux.append((expRef, expId)) 

607 

608 return flatsAtExpFlux 

609 

610 

611def arrangeFlatsByExpId(exposureList, exposureIdList): 

612 """Arrange exposures by exposure ID. 

613 

614 There is no guarantee that this will properly group exposures, 

615 but allows a sequence of flats that have different 

616 illumination (despite having the same exposure time) to be 

617 processed. 

618 

619 Parameters 

620 ---------- 

621 exposureList : `list`[`lsst.pipe.base.connections.DeferredDatasetRef`] 

622 Input list of exposure references. 

623 exposureIdList : `list`[`int`] 

624 List of exposure ids as obtained by dataId[`exposure`]. 

625 

626 Returns 

627 ------ 

628 flatsAtExpId : `dict` [`float`, 

629 `list`[(`lsst.pipe.base.connections.DeferredDatasetRef`, 

630 `int`)]] 

631 Dictionary that groups references to flat-field exposures (and their 

632 IDs) sequentially by their exposure id. 

633 

634 Notes 

635 ----- 

636 

637 This algorithm sorts the input exposure references by their exposure 

638 id, and then assigns each pair of exposure references (exp_j, exp_{j+1}) 

639 to pair k, such that 2*k = j, where j is the python index of one of the 

640 exposure references (starting from zero). By checking for the IndexError 

641 while appending, we can ensure that there will only ever be fully 

642 populated pairs. 

643 """ 

644 flatsAtExpId = {} 

645 assert len(exposureList) == len(exposureIdList), "Different lengths for exp. list and exp. ID lists" 

646 # Sort exposures by expIds, which are in the second list `exposureIdList`. 

647 sortedExposures = sorted(zip(exposureList, exposureIdList), key=lambda pair: pair[1]) 

648 

649 for jPair, expTuple in enumerate(sortedExposures): 

650 if (jPair + 1) % 2: 

651 kPair = jPair // 2 

652 listAtExpId = flatsAtExpId.setdefault(kPair, []) 

653 try: 

654 listAtExpId.append(expTuple) 

655 listAtExpId.append(sortedExposures[jPair + 1]) 

656 except IndexError: 

657 pass 

658 

659 return flatsAtExpId 

660 

661 

662def extractCalibDate(calib): 

663 """Extract common calibration metadata values that will be written to 

664 output header. 

665 

666 Parameters 

667 ---------- 

668 calib : `lsst.afw.image.Exposure` or `lsst.ip.isr.IsrCalib` 

669 Calibration to pull date information from. 

670 

671 Returns 

672 ------- 

673 dateString : `str` 

674 Calibration creation date string to add to header. 

675 """ 

676 if hasattr(calib, "getMetadata"): 

677 if 'CALIB_CREATION_DATE' in calib.getMetadata(): 

678 return " ".join((calib.getMetadata().get("CALIB_CREATION_DATE", "Unknown"), 

679 calib.getMetadata().get("CALIB_CREATION_TIME", "Unknown"))) 

680 else: 

681 return " ".join((calib.getMetadata().get("CALIB_CREATE_DATE", "Unknown"), 

682 calib.getMetadata().get("CALIB_CREATE_TIME", "Unknown"))) 

683 else: 

684 return "Unknown Unknown" 

685 

686 

687class CovFastFourierTransform: 

688 """A class to compute (via FFT) the nearby pixels correlation function. 

689 

690 Implements appendix of Astier+19. 

691 

692 Parameters 

693 ---------- 

694 diff : `numpy.array` 

695 Image where to calculate the covariances (e.g., the difference 

696 image of two flats). 

697 w : `numpy.array` 

698 Weight image (mask): it should consist of 1's (good pixel) and 

699 0's (bad pixels). 

700 fftShape : `tuple` 

701 2d-tuple with the shape of the FFT 

702 maxRangeCov : `int` 

703 Maximum range for the covariances. 

704 """ 

705 

706 def __init__(self, diff, w, fftShape, maxRangeCov): 

707 # check that the zero padding implied by "fft_shape" 

708 # is large enough for the required correlation range 

709 assert fftShape[0] > diff.shape[0]+maxRangeCov+1 

710 assert fftShape[1] > diff.shape[1]+maxRangeCov+1 

711 # for some reason related to numpy.fft.rfftn, 

712 # the second dimension should be even, so 

713 if fftShape[1]%2 == 1: 

714 fftShape = (fftShape[0], fftShape[1]+1) 

715 tIm = np.fft.rfft2(diff*w, fftShape) 

716 tMask = np.fft.rfft2(w, fftShape) 

717 # sum of "squares" 

718 self.pCov = np.fft.irfft2(tIm*tIm.conjugate()) 

719 # sum of values 

720 self.pMean = np.fft.irfft2(tIm*tMask.conjugate()) 

721 # number of w!=0 pixels. 

722 self.pCount = np.fft.irfft2(tMask*tMask.conjugate()) 

723 

724 def cov(self, dx, dy): 

725 """Covariance for dx,dy averaged with dx,-dy if both non zero. 

726 

727 Implements appendix of Astier+19. 

728 

729 Parameters 

730 ---------- 

731 dx : `int` 

732 Lag in x 

733 dy : `int` 

734 Lag in y 

735 

736 Returns 

737 ------- 

738 0.5*(cov1+cov2) : `float` 

739 Covariance at (dx, dy) lag 

740 npix1+npix2 : `int` 

741 Number of pixels used in covariance calculation. 

742 

743 Raises 

744 ------ 

745 ValueError if number of pixels for a given lag is 0. 

746 """ 

747 # compensate rounding errors 

748 nPix1 = int(round(self.pCount[dy, dx])) 

749 if nPix1 == 0: 

750 raise ValueError(f"Could not compute covariance term {dy}, {dx}, as there are no good pixels.") 

751 cov1 = self.pCov[dy, dx]/nPix1-self.pMean[dy, dx]*self.pMean[-dy, -dx]/(nPix1*nPix1) 

752 if (dx == 0 or dy == 0): 

753 return cov1, nPix1 

754 nPix2 = int(round(self.pCount[-dy, dx])) 

755 if nPix2 == 0: 

756 raise ValueError("Could not compute covariance term {dy}, {dx} as there are no good pixels.") 

757 cov2 = self.pCov[-dy, dx]/nPix2-self.pMean[-dy, dx]*self.pMean[dy, -dx]/(nPix2*nPix2) 

758 return 0.5*(cov1+cov2), nPix1+nPix2 

759 

760 def reportCovFastFourierTransform(self, maxRange): 

761 """Produce a list of tuples with covariances. 

762 

763 Implements appendix of Astier+19. 

764 

765 Parameters 

766 ---------- 

767 maxRange : `int` 

768 Maximum range of covariances. 

769 

770 Returns 

771 ------- 

772 tupleVec : `list` 

773 List with covariance tuples. 

774 """ 

775 tupleVec = [] 

776 # (dy,dx) = (0,0) has to be first 

777 for dy in range(maxRange+1): 

778 for dx in range(maxRange+1): 

779 cov, npix = self.cov(dx, dy) 

780 if (dx == 0 and dy == 0): 

781 var = cov 

782 tupleVec.append((dx, dy, var, cov, npix)) 

783 return tupleVec 

784 

785 

786def getFitDataFromCovariances(i, j, mu, fullCov, fullCovModel, fullCovSqrtWeights, gain=1.0, 

787 divideByMu=False, returnMasked=False): 

788 """Get measured signal and covariance, cov model, weigths, and mask at 

789 covariance lag (i, j). 

790 

791 Parameters 

792 ---------- 

793 i : `int` 

794 Lag for covariance matrix. 

795 j : `int` 

796 Lag for covariance matrix. 

797 mu : `list` 

798 Mean signal values. 

799 fullCov : `list` of `numpy.array` 

800 Measured covariance matrices at each mean signal level in mu. 

801 fullCovSqrtWeights : `list` of `numpy.array` 

802 List of square root of measured covariances at each mean 

803 signal level in mu. 

804 fullCovModel : `list` of `numpy.array` 

805 List of modeled covariances at each mean signal level in mu. 

806 gain : `float`, optional 

807 Gain, in e-/ADU. If other than 1.0 (default), the returned 

808 quantities will be in electrons or powers of electrons. 

809 divideByMu : `bool`, optional 

810 Divide returned covariance, model, and weights by the mean 

811 signal mu? 

812 returnMasked : `bool`, optional 

813 Use mask (based on weights) in returned arrays (mu, 

814 covariance, and model)? 

815 

816 Returns 

817 ------- 

818 mu : `numpy.array` 

819 list of signal values at (i, j). 

820 covariance : `numpy.array` 

821 Covariance at (i, j) at each mean signal mu value (fullCov[:, i, j]). 

822 covarianceModel : `numpy.array` 

823 Covariance model at (i, j). 

824 weights : `numpy.array` 

825 Weights at (i, j). 

826 maskFromWeights : `numpy.array`, optional 

827 Boolean mask of the covariance at (i,j), where the weights 

828 differ from 0. 

829 """ 

830 mu = np.array(mu) 

831 fullCov = np.array(fullCov) 

832 fullCovModel = np.array(fullCovModel) 

833 fullCovSqrtWeights = np.array(fullCovSqrtWeights) 

834 covariance = fullCov[:, i, j]*(gain**2) 

835 covarianceModel = fullCovModel[:, i, j]*(gain**2) 

836 weights = fullCovSqrtWeights[:, i, j]/(gain**2) 

837 

838 maskFromWeights = weights != 0 

839 if returnMasked: 

840 weights = weights[maskFromWeights] 

841 covarianceModel = covarianceModel[maskFromWeights] 

842 mu = mu[maskFromWeights] 

843 covariance = covariance[maskFromWeights] 

844 

845 if divideByMu: 

846 covariance /= mu 

847 covarianceModel /= mu 

848 weights *= mu 

849 return mu, covariance, covarianceModel, weights, maskFromWeights 

850 

851 

852def symmetrize(inputArray): 

853 """ Copy array over 4 quadrants prior to convolution. 

854 

855 Parameters 

856 ---------- 

857 inputarray : `numpy.array` 

858 Input array to symmetrize. 

859 

860 Returns 

861 ------- 

862 aSym : `numpy.array` 

863 Symmetrized array. 

864 """ 

865 targetShape = list(inputArray.shape) 

866 r1, r2 = inputArray.shape[-1], inputArray.shape[-2] 

867 targetShape[-1] = 2*r1-1 

868 targetShape[-2] = 2*r2-1 

869 aSym = np.ndarray(tuple(targetShape)) 

870 aSym[..., r2-1:, r1-1:] = inputArray 

871 aSym[..., r2-1:, r1-1::-1] = inputArray 

872 aSym[..., r2-1::-1, r1-1::-1] = inputArray 

873 aSym[..., r2-1::-1, r1-1:] = inputArray 

874 

875 return aSym 

876 

877 

878def ddict2dict(d): 

879 """Convert nested default dictionaries to regular dictionaries. 

880 

881 This is needed to prevent yaml persistence issues. 

882 

883 Parameters 

884 ---------- 

885 d : `defaultdict` 

886 A possibly nested set of `defaultdict`. 

887 

888 Returns 

889 ------- 

890 dict : `dict` 

891 A possibly nested set of `dict`. 

892 """ 

893 for k, v in d.items(): 

894 if isinstance(v, dict): 

895 d[k] = ddict2dict(v) 

896 return dict(d) 

897 

898 

899class Pol2D: 

900 """2D Polynomial Regression. 

901 

902 Parameters 

903 ---------- 

904 x : numpy.ndarray 

905 Input array for the x-coordinate. 

906 y : numpy.ndarray 

907 Input array for the y-coordinate. 

908 z : numpy.ndarray 

909 Input array for the dependent variable. 

910 order : int 

911 Order of the polynomial. 

912 w : numpy.ndarray, optional 

913 Weight array for weighted regression. Default is None. 

914 

915 Notes 

916 ----- 

917 Ported from by https://gitlab.in2p3.fr/astier/bfptc P. Astier. 

918 

919 Example: 

920 >>> x = np.array([1, 2, 3]) 

921 >>> y = np.array([4, 5, 6]) 

922 >>> z = np.array([7, 8, 9]) 

923 >>> order = 2 

924 >>> poly_reg = Pol2D(x, y, z, order) 

925 >>> result = poly_reg.eval(2.5, 5.5) 

926 """ 

927 def __init__(self, x, y, z, order, w=None): 

928 """ 

929 orderx : `int` 

930 Effective order in the x-direction. 

931 ordery : `int` 

932 Effective order in the y-direction. 

933 coeff : `numpy.ndarray` 

934 Coefficients of the polynomial regression. 

935 """ 

936 self.orderx = min(order, x.shape[0] - 1) 

937 self.ordery = min(order, x.shape[1] - 1) 

938 G = self.monomials(x.ravel(), y.ravel()) 

939 if w is None: 

940 self.coeff, _, rank, _ = np.linalg.lstsq(G, z.ravel(), rcond=None) 

941 else: 

942 self.coeff, _, rank, _ = np.linalg.lstsq((w.ravel() * G.T).T, z.ravel() * w.ravel(), rcond=None) 

943 

944 def monomials(self, x, y): 

945 """ 

946 Generate the monomials matrix for the given x and y. 

947 

948 Parameters 

949 ---------- 

950 x : numpy.ndarray 

951 Input array for the x-coordinate. 

952 y : numpy.ndarray 

953 Input array for the y-coordinate. 

954 

955 Returns 

956 ------- 

957 G : numpy.ndarray 

958 Monomials matrix. 

959 """ 

960 ncols = (self.orderx + 1) * (self.ordery + 1) 

961 G = np.zeros(x.shape + (ncols,)) 

962 ij = itertools.product(range(self.orderx + 1), range(self.ordery + 1)) 

963 for k, (i, j) in enumerate(ij): 

964 G[..., k] = x**i * y**j 

965 return G 

966 

967 def eval(self, x, y): 

968 """ 

969 Evaluate the polynomial at the given x and y coordinates. 

970 

971 Parameters 

972 ---------- 

973 x : `float` 

974 x-coordinate for evaluation. 

975 y : `float` 

976 y-coordinate for evaluation. 

977 

978 Returns 

979 ------- 

980 result : `float` 

981 Result of the polynomial evaluation. 

982 """ 

983 G = self.monomials(x, y) 

984 return np.dot(G, self.coeff) 

985 

986 

987class AstierSplineLinearityFitter: 

988 """Class to fit the Astier spline linearity model. 

989 

990 This is a spline fit with photodiode data based on a model 

991 from Pierre Astier, referenced in June 2023 from 

992 https://me.lsst.eu/astier/bot/7224D/model_nonlin.py 

993 

994 This model fits a spline with (optional) nuisance parameters 

995 to allow for different linearity coefficients with different 

996 photodiode settings. The minimization is a least-squares 

997 fit with the residual of 

998 Sum[(S(mu_i) + mu_i)/(k_j * D_i - O) - 1]**2, where S(mu_i) 

999 is an Akima Spline function of mu_i, the observed flat-pair 

1000 mean; D_j is the photo-diode measurement corresponding to 

1001 that flat-pair; and k_j is a constant of proportionality 

1002 which is over index j as it is allowed to 

1003 be different based on different photodiode settings (e.g. 

1004 CCOBCURR); and O is a constant offset to allow for light 

1005 leaks (and is only fit if fit_offset=True). In 

1006 addition, if config.doSplineFitTemperature is True then 

1007 the fit will adjust mu such that 

1008 mu = mu_input*(1 + alpha*(temperature_scaled)) 

1009 and temperature_scaled = T - T_ref. Finally, if 

1010 config.doSplineFitTemporal is True then the fit will 

1011 further adjust mu such that 

1012 mu = mu_input*(1 + beta*(mjd_scaled)) 

1013 and mjd_scaled = mjd - mjd_ref. 

1014 

1015 The fit has additional constraints to ensure that the spline 

1016 goes through the (0, 0) point, as well as a normalization 

1017 condition so that the average of the spline over the full 

1018 range is 0. The normalization ensures that the spline only 

1019 fits deviations from linearity, rather than the linear 

1020 function itself which is degenerate with the gain. 

1021 

1022 Parameters 

1023 ---------- 

1024 nodes : `np.ndarray` (N,) 

1025 Array of spline node locations. 

1026 grouping_values : `np.ndarray` (M,) 

1027 Array of values to group values for different proportionality 

1028 constants (e.g. CCOBCURR). 

1029 pd : `np.ndarray` (M,) 

1030 Array of photodiode measurements. 

1031 mu : `np.ndarray` (M,) 

1032 Array of flat mean values. 

1033 mask : `np.ndarray` (M,), optional 

1034 Input mask (True is good point, False is bad point). 

1035 log : `logging.logger`, optional 

1036 Logger object to use for logging. 

1037 fit_offset : `bool`, optional 

1038 Fit a constant offset to allow for light leaks? 

1039 fit_weights : `bool`, optional 

1040 Fit for the weight parameters? 

1041 weight_pars_start : `list` [ `float` ] 

1042 Iterable of 2 weight parameters for weighed fit. These will 

1043 be used as input if the weight parameters are not fit. 

1044 fit_temperature : `bool`, optional 

1045 Fit for temperature scaling? 

1046 temperature_scaled : `np.ndarray` (M,), optional 

1047 Input scaled temperature values (T - T_ref). 

1048 max_signal_nearly_linear : `float`, optional 

1049 Maximum signal that we are confident the input is nearly 

1050 linear. This is used both for regularization, and for 

1051 fitting the raw slope. Usually set to the ptc turnoff, 

1052 above which we allow the spline to significantly deviate 

1053 and do not demand the deviation to average to zero. 

1054 fit_temporal : `bool`, optional 

1055 Fit for temporal scaling? 

1056 mjd_scaled : `np.ndarray` (M,), optional 

1057 Input scaled mjd values (mjd - mjd_ref). 

1058 max_frac_correction : `float`, optional 

1059 Maximum fractional correction. 

1060 max_correction : `float`, optional 

1061 Maximum correction (unscaled). 

1062 """ 

1063 def __init__( 

1064 self, 

1065 nodes, 

1066 grouping_values, 

1067 pd, 

1068 mu, 

1069 mask=None, 

1070 log=None, 

1071 fit_offset=True, 

1072 fit_weights=False, 

1073 weight_pars_start=[1.0, 0.0], 

1074 fit_temperature=False, 

1075 temperature_scaled=None, 

1076 max_signal_nearly_linear=None, 

1077 fit_temporal=False, 

1078 mjd_scaled=None, 

1079 max_frac_correction=0.25, 

1080 max_correction=np.inf, 

1081 ): 

1082 self._pd = pd 

1083 self._mu = mu 

1084 self._grouping_values = grouping_values 

1085 self.log = log if log else logging.getLogger(__name__) 

1086 self._fit_offset = fit_offset 

1087 self._fit_weights = fit_weights 

1088 self._weight_pars_start = weight_pars_start 

1089 self._fit_temperature = fit_temperature 

1090 self._fit_temporal = fit_temporal 

1091 self._max_frac_correction = max_frac_correction 

1092 self._max_correction = max_correction 

1093 

1094 self._nodes = nodes 

1095 if nodes[0] != 0.0: 

1096 raise ValueError("First node must be 0.0") 

1097 if not np.all(np.diff(nodes) > 0): 

1098 raise ValueError("Nodes must be sorted with no repeats.") 

1099 

1100 # Find the group indices. 

1101 u_group_values = np.unique(self._grouping_values) 

1102 self.ngroup = len(u_group_values) 

1103 

1104 self.group_indices = [] 

1105 for i in range(self.ngroup): 

1106 self.group_indices.append(np.where(self._grouping_values == u_group_values[i])[0]) 

1107 

1108 # Weight values. Outliers will be set to 0. 

1109 if mask is None: 

1110 _mask = np.ones(len(mu), dtype=np.bool_) 

1111 else: 

1112 _mask = mask 

1113 self._w = self.compute_weights(self._weight_pars_start, self._mu, _mask) 

1114 

1115 if temperature_scaled is None: 

1116 temperature_scaled = np.zeros(len(self._mu)) 

1117 else: 

1118 if len(np.atleast_1d(temperature_scaled)) != len(self._mu): 

1119 raise ValueError("temperature_scaled must be the same length as input mu.") 

1120 self._temperature_scaled = temperature_scaled 

1121 

1122 if mjd_scaled is None: 

1123 mjd_scaled = np.zeros(len(self._mu)) 

1124 else: 

1125 if len(np.atleast_1d(mjd_scaled)) != len(self._mu): 

1126 raise ValueError("mjd_scaled must be the same length as input mu.") 

1127 self._mjd_scaled = mjd_scaled 

1128 

1129 # Values to regularize spline fit. 

1130 if max_signal_nearly_linear is None: 

1131 max_signal_nearly_linear = self._mu[self.mask].max() 

1132 self._max_signal_nearly_linear = max_signal_nearly_linear 

1133 self._x_regularize = np.linspace(0.0, self._max_signal_nearly_linear, 100) 

1134 

1135 # Set up the indices for the fit parameters. 

1136 self.par_indices = { 

1137 "values": np.arange(len(self._nodes)), 

1138 "groups": len(self._nodes) + np.arange(self.ngroup), 

1139 "offset": np.zeros(0, dtype=np.int64), 

1140 "weight_pars": np.zeros(0, dtype=np.int64), 

1141 "temperature_coeff": np.zeros(0, dtype=np.int64), 

1142 "temporal_coeff": np.zeros(0, dtype=np.int64), 

1143 } 

1144 if self._fit_offset: 

1145 self.par_indices["offset"] = np.arange(1) + ( 

1146 len(self.par_indices["values"]) 

1147 + len(self.par_indices["groups"]) 

1148 ) 

1149 if self._fit_weights: 

1150 self.par_indices["weight_pars"] = np.arange(2) + ( 

1151 len(self.par_indices["values"]) 

1152 + len(self.par_indices["groups"]) 

1153 + len(self.par_indices["offset"]) 

1154 ) 

1155 if self._fit_temperature: 

1156 self.par_indices["temperature_coeff"] = np.arange(1) + ( 

1157 len(self.par_indices["values"]) 

1158 + len(self.par_indices["groups"]) 

1159 + len(self.par_indices["offset"]) 

1160 + len(self.par_indices["weight_pars"]) 

1161 ) 

1162 if self._fit_temporal: 

1163 self.par_indices["temporal_coeff"] = np.arange(1) + ( 

1164 len(self.par_indices["values"]) 

1165 + len(self.par_indices["groups"]) 

1166 + len(self.par_indices["offset"]) 

1167 + len(self.par_indices["weight_pars"]) 

1168 + len(self.par_indices["temperature_coeff"]) 

1169 ) 

1170 

1171 @staticmethod 

1172 def compute_weights(weight_pars, mu, mask): 

1173 w = 1./np.sqrt(weight_pars[0]**2. + weight_pars[1]**2./mu) 

1174 w[~mask] = 0.0 

1175 

1176 return w 

1177 

1178 def estimate_p0(self): 

1179 """Estimate initial fit parameters. 

1180 

1181 Returns 

1182 ------- 

1183 p0 : `np.ndarray` 

1184 Parameter array, with spline values (one for each node) followed 

1185 by proportionality constants (one for each group); one extra 

1186 for the offset O (if fit_offset was set to True); two extra 

1187 for the weights (if fit_weights was set to True); one 

1188 extra for the temperature coefficient (if fit_temperature was 

1189 set to True); and one extra for the temporal coefficient (if 

1190 fit_temporal was set to True). 

1191 """ 

1192 npt = (len(self.par_indices["values"]) 

1193 + len(self.par_indices["groups"]) 

1194 + len(self.par_indices["offset"]) 

1195 + len(self.par_indices["weight_pars"]) 

1196 + len(self.par_indices["temperature_coeff"]) 

1197 + len(self.par_indices["temporal_coeff"])) 

1198 p0 = np.zeros(npt) 

1199 

1200 # Do a simple linear fit for each group. 

1201 for i, indices in enumerate(self.group_indices): 

1202 mask = self.mask[indices] 

1203 mu = self._mu[indices][mask] 

1204 pd = self._pd[indices][mask] 

1205 to_fit = (mu < self._max_signal_nearly_linear) 

1206 # If we have no points (low PTC turnoff) then 

1207 # we just use the first half for our initial value. 

1208 if to_fit.sum() == 0: 

1209 to_fit = (mu < np.median(mu)) 

1210 linfit = np.polyfit(pd[to_fit], mu[to_fit], 1) 

1211 p0[self.par_indices["groups"][i]] = linfit[0] 

1212 

1213 # Look at the residuals... 

1214 ratio_model = self.compute_ratio_model( 

1215 self._nodes, 

1216 self.group_indices, 

1217 self.par_indices, 

1218 p0, 

1219 self._pd, 

1220 self._mu, 

1221 self._temperature_scaled, 

1222 self._mjd_scaled, 

1223 ) 

1224 # ...and adjust the linear parameters accordingly. 

1225 p0[self.par_indices["groups"]] *= np.median(ratio_model[self.mask]) 

1226 

1227 # Re-compute the residuals. 

1228 ratio_model2 = self.compute_ratio_model( 

1229 self._nodes, 

1230 self.group_indices, 

1231 self.par_indices, 

1232 p0, 

1233 self._pd, 

1234 self._mu, 

1235 self._temperature_scaled, 

1236 self._mjd_scaled, 

1237 ) 

1238 

1239 # And compute a first guess of the spline nodes. 

1240 bins = np.clip(np.searchsorted(self._nodes, self._mu[self.mask]), 0, len(self._nodes) - 1) 

1241 tot_arr = np.zeros(len(self._nodes)) 

1242 n_arr = np.zeros(len(self._nodes), dtype=int) 

1243 np.add.at(tot_arr, bins, ratio_model2[self.mask]) 

1244 np.add.at(n_arr, bins, 1) 

1245 

1246 ratio = np.ones(len(self._nodes)) 

1247 ratio[n_arr > 0] = tot_arr[n_arr > 0]/n_arr[n_arr > 0] 

1248 ratio[0] = 1.0 

1249 p0[self.par_indices["values"]] = (ratio - 1) * self._nodes 

1250 

1251 if self._fit_offset: 

1252 p0[self.par_indices["offset"]] = 0.0 

1253 

1254 if self._fit_weights: 

1255 p0[self.par_indices["weight_pars"]] = self._weight_pars_start 

1256 

1257 return p0 

1258 

1259 @staticmethod 

1260 def compute_ratio_model( 

1261 nodes, 

1262 group_indices, 

1263 par_indices, 

1264 pars, 

1265 pd, 

1266 mu, 

1267 temperature_scaled, 

1268 mjd_scaled, 

1269 return_spline=False, 

1270 ): 

1271 """Compute the ratio model values. 

1272 

1273 Parameters 

1274 ---------- 

1275 nodes : `np.ndarray` (M,) 

1276 Array of node positions. 

1277 group_indices : `list` [`np.ndarray`] 

1278 List of group indices, one array for each group. 

1279 par_indices : `dict` 

1280 Dictionary showing which indices in the pars belong to 

1281 each set of fit values. 

1282 pars : `np.ndarray` 

1283 Parameter array, with spline values (one for each node) followed 

1284 by proportionality constants (one for each group); one extra 

1285 for the offset O (if fit_offset was set to True); two extra 

1286 for the weights (if fit_weights was set to True); and one 

1287 extra for the temperature coefficient (if fit_temperature was 

1288 set to True). 

1289 pd : `np.ndarray` (N,) 

1290 Array of photodiode measurements. 

1291 mu : `np.ndarray` (N,) 

1292 Array of flat means. 

1293 temperature_scaled : `np.ndarray` (N,) 

1294 Array of scaled temperature values. 

1295 mjd_scaled : `np.ndarray` (N,) 

1296 Array of scaled mjd values. 

1297 return_spline : `bool`, optional 

1298 Return the spline interpolation as well as the model ratios? 

1299 

1300 Returns 

1301 ------- 

1302 ratio_models : `np.ndarray` (N,) 

1303 Model ratio, (mu_i - S(mu_i) - O)/(k_j * D_i) 

1304 spl : `scipy.interpolate.Akima1DInterpolator` 

1305 Spline interpolator (returned if return_spline=True). 

1306 """ 

1307 spl = Akima1DInterpolator(nodes, pars[par_indices["values"]], method="akima") 

1308 

1309 # Check if we want to do just the left or both with temp scale. 

1310 if len(par_indices["temperature_coeff"]) == 1: 

1311 mu_corr = mu*(1. + pars[par_indices["temperature_coeff"]]*temperature_scaled) 

1312 else: 

1313 mu_corr = mu 

1314 

1315 if len(par_indices["temporal_coeff"]) == 1: 

1316 mu_corr = mu_corr*(1. + pars[par_indices["temporal_coeff"]]*mjd_scaled) 

1317 

1318 numerator = mu_corr - spl(np.clip(mu_corr, nodes[0], nodes[-1])) 

1319 if len(par_indices["offset"]) == 1: 

1320 numerator -= pars[par_indices["offset"][0]] 

1321 denominator = pd.copy() 

1322 ngroup = len(group_indices) 

1323 kj = pars[par_indices["groups"]] 

1324 for j in range(ngroup): 

1325 denominator[group_indices[j]] *= kj[j] 

1326 

1327 if return_spline: 

1328 return numerator / denominator, spl 

1329 else: 

1330 return numerator / denominator 

1331 

1332 def fit(self, p0, min_iter=3, max_iter=20, max_rejection_per_iteration=5, n_sigma_clip=5.0): 

1333 """ 

1334 Perform iterative fit for linear + spline model with offsets. 

1335 

1336 Parameters 

1337 ---------- 

1338 p0 : `np.ndarray` 

1339 Initial parameter array, with spline values (one for each node) 

1340 followed by proportionality constants (one for each group); one 

1341 extra for the offset O (if fit_offset was set to True); two extra 

1342 for the weights (if fit_weights was set to True); one 

1343 extra for the temperature coefficient (if fit_temperature was 

1344 set to True); and one extra for the temporal coefficient (if 

1345 fit_temporal was set to True). 

1346 min_iter : `int`, optional 

1347 Minimum number of fit iterations. 

1348 max_iter : `int`, optional 

1349 Maximum number of fit iterations. 

1350 max_rejection_per_iteration : `int`, optional 

1351 Maximum number of points to reject per iteration. 

1352 n_sigma_clip : `float`, optional 

1353 Number of sigma to do clipping in each iteration. 

1354 """ 

1355 init_params = p0 

1356 for k in range(max_iter): 

1357 params, cov_params, _, msg, ierr = leastsq( 

1358 self, 

1359 init_params, 

1360 full_output=True, 

1361 ftol=1e-5, 

1362 maxfev=12000, 

1363 ) 

1364 init_params = params.copy() 

1365 

1366 # We need to cut off the constraints at the end (there are more 

1367 # residuals than data points.) 

1368 res = self(params)[: len(self._w)] 

1369 std_res = median_abs_deviation(res[self.good_points], scale="normal") 

1370 sample = len(self.good_points) 

1371 

1372 # We don't want to reject too many outliers at once. 

1373 if sample > max_rejection_per_iteration: 

1374 sres = np.sort(np.abs(res)) 

1375 cut = max(sres[-max_rejection_per_iteration], std_res*n_sigma_clip) 

1376 else: 

1377 cut = std_res*n_sigma_clip 

1378 

1379 outliers = np.abs(res) > cut 

1380 self._w[outliers] = 0 

1381 if outliers.sum() != 0: 

1382 self.log.info( 

1383 "After iteration %d there are %d outliers (of %d).", 

1384 k, 

1385 outliers.sum(), 

1386 sample, 

1387 ) 

1388 elif k >= min_iter: 

1389 self.log.info("After iteration %d there are no more outliers.", k) 

1390 break 

1391 

1392 return params 

1393 

1394 @property 

1395 def mask(self): 

1396 return (self._w > 0) 

1397 

1398 @property 

1399 def good_points(self): 

1400 return self.mask.nonzero()[0] 

1401 

1402 def compute_chisq_dof(self, pars): 

1403 """Compute the chi-squared per degree of freedom for a set of pars. 

1404 

1405 Parameters 

1406 ---------- 

1407 pars : `np.ndarray` 

1408 Parameter array. 

1409 

1410 Returns 

1411 ------- 

1412 chisq_dof : `float` 

1413 Chi-squared per degree of freedom. 

1414 """ 

1415 resids = self(pars)[0: len(self.mask)] 

1416 chisq = np.sum(resids[self.mask]**2.) 

1417 dof = self.mask.sum() - self.ngroup 

1418 if self._fit_temporal: 

1419 dof -= 1 

1420 if self._fit_temperature: 

1421 dof -= 1 

1422 if self._fit_offset: 

1423 dof -= 1 

1424 if self._fit_weights: 

1425 dof -= 2 

1426 

1427 return chisq/dof 

1428 

1429 def __call__(self, pars): 

1430 

1431 ratio_model, spl = self.compute_ratio_model( 

1432 self._nodes, 

1433 self.group_indices, 

1434 self.par_indices, 

1435 pars, 

1436 self._pd, 

1437 self._mu, 

1438 self._temperature_scaled, 

1439 self._mjd_scaled, 

1440 return_spline=True, 

1441 ) 

1442 

1443 _mask = self.mask 

1444 # Update the weights if we are fitting them. 

1445 if self._fit_weights: 

1446 self._w = self.compute_weights(pars[self.par_indices["weight_pars"]], self._mu, _mask) 

1447 resid = self._w*(ratio_model - 1.0) 

1448 

1449 # Ensure masked points have 0 residual. 

1450 resid[~_mask] = 0.0 

1451 

1452 constraint = [1e3 * np.mean(spl(np.clip(self._x_regularize, self._nodes[0], self._nodes[-1])))] 

1453 # 0 should transform to 0 

1454 constraint.append(spl(0)*1e10) 

1455 # Use a Jeffreys prior on the weight if we are fitting it. 

1456 if self._fit_weights: 

1457 # This factor ensures that log(fact * w) is negative. 

1458 fact = 1e-3 / self._w.max() 

1459 # We only add non-zero weights to the constraint array. 

1460 log_w = np.sqrt(-2.*np.log(fact*self._w[self._w > 0])) 

1461 constraint = np.hstack([constraint, log_w]) 

1462 

1463 # Don't let it get to >5% correction. 

1464 values = pars[self.par_indices["values"]] 

1465 if np.abs(values[-1])/self._nodes[-1] > self._max_frac_correction \ 

1466 or np.abs(values[-1]) > self._max_correction: 

1467 extra_constraint = 1e10 

1468 else: 

1469 extra_constraint = 0 

1470 

1471 return np.hstack([resid, constraint, extra_constraint]) 

1472 

1473 

1474def getReadNoise(exposure, ampName, taskMetadata=None, log=None): 

1475 """Gets readout noise for an amp from ISR metadata. 

1476 

1477 If possible, this attempts to get the now-standard headers 

1478 added to the exposure itself. If not found there, the ISR 

1479 TaskMetadata is searched. If neither of these has the value, 

1480 warn and set the read noise to NaN. 

1481 

1482 Parameters 

1483 ---------- 

1484 exposure : `lsst.afw.image.Exposure` 

1485 Exposure to check for read noise first. 

1486 ampName : `str` 

1487 Amplifier name. 

1488 taskMetadata : `lsst.pipe.base.TaskMetadata`, optional 

1489 List of exposures metadata from ISR for this exposure. 

1490 log : `logging.logger`, optional 

1491 Log for messages. 

1492 

1493 Returns 

1494 ------- 

1495 readNoise : `float` 

1496 The read noise for this set of exposure/amplifier. 

1497 """ 

1498 exposureMetadata = exposure.getMetadata() 

1499 

1500 # Try from the exposure first. 

1501 expectedKey = f"LSST ISR OVERSCAN RESIDUAL SERIAL STDEV {ampName}" 

1502 if expectedKey in exposureMetadata: 

1503 return exposureMetadata[expectedKey] 

1504 

1505 # If not, try getting it from the task metadata. 

1506 if taskMetadata: 

1507 expectedKey = f"RESIDUAL STDEV {ampName}" 

1508 if "isr" in taskMetadata: 

1509 if expectedKey in taskMetadata["isr"]: 

1510 return taskMetadata["isr"][expectedKey] 

1511 

1512 log = log if log else logging.getLogger(__name__) 

1513 log.warning("Median readout noise from ISR metadata for amp %s " 

1514 "could not be found.", ampName) 

1515 return np.nan 

1516 

1517 

1518def ampOffsetGainRatioFixup(ptc, minAdu, maxAdu, log=None): 

1519 """Apply gain ratio fixup using amp offsets. 

1520 

1521 Parameters 

1522 ---------- 

1523 ptc : `lsst.ip.isr.PhotonTransferCurveDataset` 

1524 Input PTC. Will be modified in place. 

1525 minAdu : `float` 

1526 Minimum number of ADU in mean of amplifier to use for 

1527 computing gain ratio fixup. 

1528 maxAdu : `float` 

1529 Maximum number of ADU in mean of amplifier to use for 

1530 computing gain ratio fixup. 

1531 log : `lsst.utils.logging.LsstLogAdapter`, optional 

1532 Log object. 

1533 """ 

1534 # We need to find the reference amplifier. 

1535 # Find an amp near the middle to use as a pivot. 

1536 if log is None: 

1537 log = logging.getLogger(__name__) 

1538 

1539 # We first check for which amps have gain measurements 

1540 # (fully bad amps are filled with NaN for gain.) 

1541 gainArray = np.zeros(len(ptc.ampNames)) 

1542 for i, ampName in enumerate(ptc.ampNames): 

1543 gainArray[i] = ptc.gain[ampName] 

1544 good, = np.where(np.isfinite(gainArray)) 

1545 

1546 if len(good) > 1: 

1547 # This only works with more than 1 good amp. 

1548 

1549 # We sort the gains and take the one that is closest 

1550 # to the median to use as the reference amplifier for 

1551 # gain ratios. 

1552 st = np.argsort(gainArray[good]) 

1553 midAmp = good[st[int(0.5*len(good))]] 

1554 midAmpName = ptc.ampNames[midAmp] 

1555 

1556 log.info("Using amplifier %s as the reference for doLinearityGainRatioFixup.", midAmpName) 

1557 

1558 # First pass, we need to compute the corrections. 

1559 corrections = {} 

1560 for ampName in ptc.ampNames: 

1561 if not np.isfinite(ptc.gain[ampName]) or ampName == midAmpName: 

1562 continue 

1563 

1564 ratioPtc = ptc.gain[ampName] / ptc.gain[midAmpName] 

1565 

1566 deltas = ptc.ampOffsets[ampName] - ptc.ampOffsets[midAmpName] 

1567 use = ( 

1568 (ptc.expIdMask[ampName]) 

1569 & (np.isfinite(deltas)) 

1570 & (ptc.finalMeans[ampName] >= minAdu) 

1571 & (ptc.finalMeans[ampName] <= maxAdu) 

1572 & (np.isfinite(ptc.finalMeans[midAmpName])) 

1573 & (ptc.expIdMask[midAmpName]) 

1574 ) 

1575 if use.sum() < 3: 

1576 log.warning("Not enough good amp offset measurements to fix up amp %s " 

1577 "gains from amp ratios.", ampName) 

1578 continue 

1579 

1580 ratios = 1. / (deltas / ptc.finalMeans[midAmpName] + 1.0) 

1581 ratio = np.median(ratios[use]) 

1582 corrections[ampName] = ratio / ratioPtc 

1583 

1584 # For the final correction, we need to make sure that the 

1585 # reference amplifier is included. By definition, it has a 

1586 # correction factor of 1.0 before any final fix. 

1587 corrections[midAmpName] = 1.0 

1588 

1589 # Adjust the median correction to be 1.0 so we do not 

1590 # change the gain of the detector on average. 

1591 # This is needed in case the reference amplifier is 

1592 # skewed in terms of offsets even though it has the median 

1593 # gain. 

1594 medCorrection = np.median([corrections[key] for key in corrections]) 

1595 

1596 for ampName in ptc.ampNames: 

1597 if ampName not in corrections: 

1598 continue 

1599 

1600 correction = corrections[ampName] / medCorrection 

1601 newGain = ptc.gain[ampName] * correction 

1602 log.info( 

1603 "Adjusting gain from amplifier %s by factor of %.5f (from %.5f to %.5f)", 

1604 ampName, 

1605 correction, 

1606 ptc.gain[ampName], 

1607 newGain, 

1608 ) 

1609 # Copying the value should not be necessary, but we record 

1610 # it just in case. 

1611 ptc.gainUnadjusted[ampName] = ptc.gain[ampName] 

1612 ptc.gain[ampName] = newGain 

1613 else: 

1614 log.warning("Cannot apply ampOffsetGainRatioFixup with fewer than 2 good amplifiers.") 

1615 

1616 

1617class FlatGradientFitter: 

1618 """Class to fit various large-scale flat-field gradients. 

1619 

1620 This fitter will take arrays of x/y/value and (by default) fit a radial 

1621 gradient, using a spline function at the specified nodes. The fitter 

1622 will also fit out a nuisance parameter for the ratio of the ITL/E2V 

1623 throughput (though in general this could work for any such focal plane). 

1624 The focal plane origin is set by fp_centroid_x and fp_centroid_y which 

1625 is the center of the radial gradient, though it may be modified if 

1626 fit_centroid is True. In addition, the fitter may fit a linear gradient 

1627 in x/y if fit_gradient is True. The "pivot" of the gradient is at 

1628 fp_centroid_x/fp_centroid_y. 

1629 

1630 Parameters 

1631 ---------- 

1632 nodes : `np.ndarray` 

1633 Array of spline nodes for radial spline. 

1634 x : `np.ndarray` 

1635 Array of x values for points to fit (mm). 

1636 y : `np.ndarray` 

1637 Array of y values for points to fit (mm). 

1638 value : `np.ndarray` 

1639 Array of values describing the flat field to fit the gradient. 

1640 itl_indices : `np.ndarray` 

1641 Array of indices corresponding to the ITL detectors. 

1642 constrain_zero : `bool`, optional 

1643 Constrain the outermost radial spline value to 0? 

1644 fit_centroid : `bool`, optional 

1645 Fit an additional centroid offset? 

1646 fit_gradient : `bool`, optional 

1647 Fit an additional plane gradient? 

1648 fp_centroid_x : `float`, optional 

1649 Focal plane centroid x (mm). 

1650 fp_centroid_y : `float`, optional 

1651 Focal plane centroid y (mm). 

1652 """ 

1653 def __init__( 

1654 self, 

1655 nodes, 

1656 x, 

1657 y, 

1658 value, 

1659 itl_indices, 

1660 constrain_zero=True, 

1661 fit_centroid=False, 

1662 fit_gradient=False, 

1663 fp_centroid_x=0.0, 

1664 fp_centroid_y=0.0 

1665 ): 

1666 self._nodes = nodes 

1667 self._x = x 

1668 self._y = y 

1669 self._value = value 

1670 self._itl_indices = itl_indices 

1671 self._fp_centroid_x = fp_centroid_x 

1672 self._fp_centroid_y = fp_centroid_y 

1673 

1674 self._constrain_zero = constrain_zero 

1675 

1676 self._fit_centroid = fit_centroid 

1677 self._fit_gradient = fit_gradient 

1678 

1679 self.indices = {"spline": np.arange(len(nodes))} 

1680 npar = len(nodes) 

1681 

1682 self._fit_itl_ratio = False 

1683 if len(itl_indices) > 0: 

1684 self._fit_itl_ratio = True 

1685 self.indices["itl_ratio"] = npar 

1686 npar += 1 

1687 

1688 radius = np.sqrt((self._x - self._fp_centroid_x)**2. + (self._y - self._fp_centroid_y)**2.) 

1689 

1690 if fit_centroid: 

1691 self.indices["centroid_delta"] = np.arange(2) + npar 

1692 npar += 2 

1693 else: 

1694 self._radius = radius 

1695 

1696 if fit_gradient: 

1697 self.indices["gradient"] = np.arange(2) + npar 

1698 npar += 2 

1699 

1700 self._npar = npar 

1701 

1702 self._bounds = [(None, None)]*npar 

1703 

1704 if self._constrain_zero: 

1705 self._bounds[self.indices["spline"][-1]] = (0.0, 0.0) 

1706 

1707 def compute_p0(self, itl_ratio=None): 

1708 """Compute initial guess for fit parameters. 

1709 

1710 Returns 

1711 ------- 

1712 pars : `np.ndarray` 

1713 Array of first guess fit parameters. 

1714 """ 

1715 pars = np.zeros(self._npar) 

1716 value = self._value.copy() 

1717 

1718 if itl_ratio is not None and self._fit_itl_ratio: 

1719 value[self._itl_indices] /= itl_ratio 

1720 

1721 # Initial spline values 

1722 radius = np.sqrt((self._x - self._fp_centroid_x)**2. + (self._y - self._fp_centroid_y)**2.) 

1723 for i, index in enumerate(self.indices["spline"]): 

1724 if i == 0: 

1725 low = self._nodes[i] 

1726 else: 

1727 low = (self._nodes[i - 1] + self._nodes[i])/2. 

1728 if i == (len(self._nodes) - 1): 

1729 high = self._nodes[i] 

1730 else: 

1731 high = (self._nodes[i] + self._nodes[i + 1])/2. 

1732 u = ((radius > low) & (radius < high)) 

1733 if u.sum() == 0: 

1734 pars[index] = 0.0 

1735 else: 

1736 pars[index] = np.median(value[u]) 

1737 

1738 if self._constrain_zero: 

1739 pars[self.indices["spline"][-1]] = 0.0 

1740 

1741 spl = Akima1DInterpolator(self._nodes, pars[self.indices["spline"]], method="akima") 

1742 model = spl(radius) 

1743 resid_ratio = value / model 

1744 

1745 if self._fit_itl_ratio: 

1746 if itl_ratio is not None: 

1747 pars[self.indices["itl_ratio"]] = itl_ratio 

1748 else: 

1749 e2v_indices = np.delete(np.arange(len(self._value)), self._itl_indices) 

1750 

1751 itl_inner = radius[self._itl_indices] < 0.8*np.max(radius) 

1752 e2v_inner = radius[e2v_indices] < 0.8*np.max(radius) 

1753 

1754 itl_median = np.nanmedian(resid_ratio[self._itl_indices][itl_inner]) 

1755 e2v_median = np.nanmedian(resid_ratio[e2v_indices][e2v_inner]) 

1756 

1757 pars[self.indices["itl_ratio"]] = itl_median / e2v_median 

1758 

1759 if self._fit_centroid: 

1760 pars[self.indices["centroid_delta"]] = [0.0, 0.0] 

1761 

1762 if self._fit_gradient: 

1763 resid_ratio = self._value / model 

1764 

1765 ok = (np.isfinite(resid_ratio) & (model > 0.5)) 

1766 

1767 fit = np.polyfit(self._x[ok], resid_ratio[ok], 1) 

1768 pars[self.indices["gradient"][0]] = fit[0] 

1769 

1770 fit = np.polyfit(self._y[ok], resid_ratio[ok], 1) 

1771 pars[self.indices["gradient"][1]] = fit[0] 

1772 

1773 return pars 

1774 

1775 def fit(self, p0, freeze_itl_ratio=False, fit_eps=1e-8, fit_gtol=1e-10): 

1776 """Do a non-linear minimization to fit the parameters. 

1777 

1778 Parameters 

1779 ---------- 

1780 p0 : `np.ndarray` 

1781 Array of initial parameter estimates. 

1782 freeze_itl_ratio : `bool`, optional 

1783 Freeze the ITL ratio in the fit? 

1784 fit_eps : `float`, optional 

1785 Value of ``eps`` to send to the scipy minimizer. 

1786 fit_gtol : `float`, optional 

1787 Value of ``gtol`` to send to the scipy minimizer. 

1788 

1789 Returns 

1790 ------- 

1791 pars : `np.ndarray` 

1792 Array of parameters. Use ``fitter.indices`` for the 

1793 dictionary to map parameters to subsets. 

1794 """ 

1795 bounds = self._bounds 

1796 if freeze_itl_ratio and self._fit_itl_ratio: 

1797 ind = self.indices["itl_ratio"] 

1798 par = p0[ind] 

1799 bounds[ind] = (par, par) 

1800 

1801 res = minimize( 

1802 self, 

1803 p0, 

1804 method="L-BFGS-B", 

1805 jac=False, 

1806 bounds=bounds, 

1807 options={ 

1808 "maxfun": 10000, 

1809 "maxiter": 10000, 

1810 "maxcor": 20, 

1811 "eps": fit_eps, 

1812 "gtol": fit_gtol, 

1813 }, 

1814 callback=None, 

1815 ) 

1816 pars = res.x 

1817 

1818 return pars 

1819 

1820 def compute_model(self, pars): 

1821 """Compute the model given a set of parameters. 

1822 

1823 Parameters 

1824 ---------- 

1825 pars : `np.ndarray` 

1826 Parameter array to compute model. 

1827 

1828 Returns 

1829 ------- 

1830 model_array : `np.ndarray` 

1831 Array of model parameters at the input x/y. 

1832 """ 

1833 spl = Akima1DInterpolator(self._nodes, pars[self.indices["spline"]], method="akima") 

1834 if self._fit_centroid: 

1835 centroid_delta = pars[self.indices["centroid_delta"]] 

1836 centroid_x = self._fp_centroid_x + centroid_delta[0] 

1837 centroid_y = self._fp_centroid_y + centroid_delta[1] 

1838 radius = np.sqrt((self._x - centroid_x)**2. + (self._y - centroid_y)**2.) 

1839 else: 

1840 radius = self._radius 

1841 

1842 model = spl(np.clip(radius, self._nodes[0], self._nodes[-1])) 

1843 if self._fit_itl_ratio: 

1844 model[self._itl_indices] *= pars[self.indices["itl_ratio"]] 

1845 

1846 if self._fit_gradient: 

1847 a, b = pars[self.indices["gradient"]] 

1848 gradient = 1 + a*(self._x - self._fp_centroid_x) + b*(self._y - self._fp_centroid_y) 

1849 model /= gradient 

1850 

1851 return model 

1852 

1853 def __call__(self, pars): 

1854 """Compute the cost function for a set of parameters. 

1855 

1856 Parameters 

1857 ---------- 

1858 pars : `np.ndarray` 

1859 Parameter array to compute model. 

1860 

1861 Returns 

1862 ------- 

1863 cost : `float` 

1864 Cost value computed from the absolute deviation. 

1865 """ 

1866 

1867 model = self.compute_model(pars) 

1868 

1869 absdev = np.abs(self._value - model) 

1870 t = np.sum(absdev.astype(np.float64)) 

1871 

1872 return t 

1873 

1874 

1875class FlatGainRatioFitter: 

1876 """Class to fit amplifier gain ratios, removing a simple gradient. 

1877 

1878 This fitter will take arrays of x/y/amp_num/value and fit amplifier 

1879 gain ratios, using one amplifier as the fixed point. The fitter 

1880 uses a low-order chebyshev polynomial to fit out the gradient, with 

1881 amp ratios on top of this. 

1882 

1883 Parameters 

1884 ---------- 

1885 bbox : `lsst.geom.Box2I` 

1886 Bounding box for Chebyshev polynomial gradient. 

1887 order : `int` 

1888 Order of Chebyshev polynomial. 

1889 x : `np.ndarray` 

1890 Array of x values for points to fit (detector pixels). 

1891 y : `np.ndarray` 

1892 Array of y values for points to fit (detector pixels). 

1893 amp_index : `np.ndarray` 

1894 Array of amp numbers associated with each x/y pair. 

1895 value : `np.ndarray` 

1896 Flat value at each x/y pair. 

1897 amps : `np.ndarray` 

1898 Array of unique amplifier numbers that will be parameterized. 

1899 Any of these amps that does not have any data with the same 

1900 amp_index will be set to 1.0. 

1901 fixed_amp_index : `int` 

1902 Amplifier number to keep fixed. 

1903 """ 

1904 def __init__(self, bbox, order, x, y, amp_index, value, amps, fixed_amp_index): 

1905 self._bbox = bbox 

1906 self._order = order 

1907 self._x = x.astype(np.float64) 

1908 self._y = y.astype(np.float64) 

1909 self._amp_index = amp_index 

1910 self._value = value.astype(np.float64) 

1911 self._fixed_amp_index = fixed_amp_index 

1912 

1913 self.indices = {"chebyshev": np.arange((order + 1) * (order + 1))} 

1914 npar = len(self.indices["chebyshev"]) 

1915 

1916 self._amps = amps 

1917 self._n_amp = len(self._amps) 

1918 

1919 self.indices["amp_pars"] = np.arange(self._n_amp) + npar 

1920 npar += self._n_amp 

1921 

1922 self._npar = npar 

1923 

1924 self._amp_indices = {} 

1925 for i in range(self._n_amp): 

1926 amp_index = self._amps[i] 

1927 self._amp_indices[amp_index] = (self._amp_index == amp_index) 

1928 

1929 def fit(self, n_iter=10, nsig_clip=5.0): 

1930 """Fit the amp ratio parameters. 

1931 

1932 This uses an iterative fit, where it fits a Chebyshev gradient, 

1933 computes amp ratios, and re-fits the gradient. 

1934 

1935 Parameters 

1936 ---------- 

1937 n_iter : `int`, optional 

1938 Number of iterations for fit. 

1939 nsig_clip : `float`, optional 

1940 Number of sigma in gain correction distribution to clip when 

1941 fitting out the Chebyshev gradient. 

1942 

1943 Returns 

1944 ------- 

1945 pars : `np.ndarray` 

1946 Chebyshev parameters and amp offset parameters. 

1947 """ 

1948 value = self._value.copy() 

1949 

1950 control = lsst.afw.math.ChebyshevBoundedFieldControl() 

1951 control.orderX = self._order 

1952 control.orderY = self._order 

1953 control.triangular = False 

1954 

1955 pars = np.zeros(self._npar) 

1956 

1957 pars[self.indices["amp_pars"]] = 1.0 

1958 

1959 field_use = np.ones(len(value), dtype=np.bool_) 

1960 

1961 for i in range(n_iter): 

1962 field = lsst.afw.math.ChebyshevBoundedField.fit( 

1963 self._bbox, 

1964 self._x[field_use], 

1965 self._y[field_use], 

1966 value[field_use], 

1967 control, 

1968 ) 

1969 

1970 pars[self.indices["chebyshev"][:]] = field.getCoefficients().ravel() 

1971 

1972 ratio = self._value / field.evaluate(self._x, self._y) 

1973 

1974 fixed_med = np.median(ratio[self._amp_indices[self._fixed_amp_index]]) 

1975 ratio /= fixed_med 

1976 

1977 pars[self.indices["amp_pars"][self._fixed_amp_index]] = 1.0 

1978 

1979 value = self._value.copy() 

1980 

1981 for j in range(self._n_amp): 

1982 amp_index = self._amps[j] 

1983 

1984 if np.sum(self._amp_indices[amp_index]) == 0: 

1985 continue 

1986 

1987 pars[self.indices["amp_pars"][j]] = np.median(ratio[self._amp_indices[amp_index]]) 

1988 

1989 amp_pars = pars[self.indices["amp_pars"]] 

1990 med = np.median(amp_pars) 

1991 sig = median_abs_deviation(amp_pars, scale="normal") 

1992 amp_gradient_bad, = np.where(np.abs(amp_pars - med) > nsig_clip * sig) 

1993 field_use[:] = True 

1994 for agb in amp_gradient_bad: 

1995 field_use[self._amp_indices[agb]] = False 

1996 

1997 return pars 

1998 

1999 def compute_model(self, pars): 

2000 """Compute the gradient/amp ratio model for a given set of parameters. 

2001 

2002 Parameters 

2003 ---------- 

2004 pars : `np.ndarray` 

2005 Chebyshev parameters and amp offset parameters. 

2006 

2007 Returns 

2008 ------- 

2009 model : `np.ndarray` 

2010 The model at each x/y pair. 

2011 """ 

2012 field = lsst.afw.math.ChebyshevBoundedField( 

2013 self._bbox, 

2014 pars[self.indices["chebyshev"]].reshape(self._order + 1, self._order + 1), 

2015 ) 

2016 model = field.evaluate(self._x, self._y) 

2017 

2018 for i in range(self._n_amp): 

2019 amp_index = self._amps[i] 

2020 

2021 model[self._amp_indices[amp_index]] *= pars[self.indices["amp_pars"][i]] 

2022 

2023 return model 

2024 

2025 def __call__(self, pars): 

2026 """Compute the cost function for a set of parameters. 

2027 

2028 Parameters 

2029 ---------- 

2030 pars : `np.ndarray` 

2031 Chebyshev parameters and amp offset parameters. 

2032 

2033 Returns 

2034 ------- 

2035 cost : `float` 

2036 Cost value computed from the absolute deviation. 

2037 """ 

2038 model = self.compute_model(pars) 

2039 

2040 absdev = np.abs(self._value - model) 

2041 t = np.sum(absdev.astype(np.float64)) 

2042 

2043 return t 

2044 

2045 

2046def bin_focal_plane( 

2047 exposure_handle_dict, 

2048 detector_boundary, 

2049 bin_factor, 

2050 defect_handle_dict={}, 

2051 include_itl_flag=True, 

2052): 

2053 """Bin all the detectors into the full focal plane. 

2054 

2055 This function reads in images; takes a simple average if there 

2056 are more than one input per detector; excludes detector edges; 

2057 and bins according to the bin factor. The output is a struct 

2058 with focal plane coordinates, detector numbers, and a flag 

2059 if the detector is an ITL detector. 

2060 

2061 Parameters 

2062 ---------- 

2063 exposure_handle_dict : `dict` 

2064 Dict keyed by detector (`int`), each element is a list 

2065 of `lsst.daf.butler.DeferredDatasetHandle` that will be averaged. 

2066 detector_boundary : `int` 

2067 Boundary of the detector to ignore (pixels). 

2068 bin_factor : `int` 

2069 Binning factor. Detectors will be cropped (after applying the 

2070 ``detector_boundary``) such that there are no partially 

2071 covered binned pixels. 

2072 defect_handle_dict : `dict`, optional 

2073 Dict keyed by detector (`int`), each element is a 

2074 `lsst.daf.butler.DeferredDatasetHandle` for defects to be applied. 

2075 include_itl_flag : `bool`, optional 

2076 Include a flag for which detectors are ITL? 

2077 

2078 Returns 

2079 ------- 

2080 binned : `astropy.table.Table` 

2081 Table with focal plane positions at the center of each bin 

2082 (``xf``, ``yf``); average image values (``value``); and detector 

2083 number (``detector``). 

2084 """ 

2085 xf_arrays = [] 

2086 yf_arrays = [] 

2087 value_arrays = [] 

2088 detector_arrays = [] 

2089 itl_arrays = [] 

2090 

2091 for det in exposure_handle_dict.keys(): 

2092 flat = exposure_handle_dict[det].get() 

2093 defect_handle = defect_handle_dict.get(det, None) 

2094 if defect_handle is not None: 

2095 defects = defect_handle.get() 

2096 else: 

2097 defects = None 

2098 

2099 detector = flat.getDetector() 

2100 

2101 # Mask out defects if we have them. 

2102 if defects is not None: 

2103 for defect in defects: 

2104 flat.image[defect.getBBox()].array[:, :] = np.nan 

2105 

2106 # Mask NO_DATA pixels if we have them. 

2107 no_data = ((flat.mask.array[:, :] & flat.mask.getPlaneBitMask("NO_DATA")) > 0) 

2108 flat.image.array[no_data] = np.nan 

2109 

2110 # Bin the image, avoiding the boundary and the masked pixels. 

2111 # We also make sure we are using an integral number of 

2112 # steps to avoid partially covered binned pixels. 

2113 

2114 arr = flat.image.array 

2115 

2116 n_step_y = (arr.shape[0] - (2 * detector_boundary)) // bin_factor 

2117 y_min = detector_boundary 

2118 y_max = bin_factor * n_step_y + y_min 

2119 n_step_x = (arr.shape[1] - (2 * detector_boundary)) // bin_factor 

2120 x_min = detector_boundary 

2121 x_max = bin_factor * n_step_x + x_min 

2122 

2123 arr = arr[y_min: y_max, x_min: x_max] 

2124 binned = arr.reshape((n_step_y, bin_factor, n_step_x, bin_factor)) 

2125 with warnings.catch_warnings(): 

2126 warnings.filterwarnings("ignore", r"Mean of empty") 

2127 binned = np.nanmean(binned, axis=1) 

2128 binned = np.nanmean(binned, axis=2) 

2129 

2130 xx = np.arange(binned.shape[1]) * bin_factor + bin_factor / 2. + x_min 

2131 yy = np.arange(binned.shape[0]) * bin_factor + bin_factor / 2. + y_min 

2132 x, y = np.meshgrid(xx, yy) 

2133 x = x.ravel() 

2134 y = y.ravel() 

2135 value = binned.ravel() 

2136 

2137 # Transform to focal plane coordinates. 

2138 transform = detector.getTransform(lsst.afw.cameraGeom.PIXELS, lsst.afw.cameraGeom.FOCAL_PLANE) 

2139 xy = np.vstack((x, y)) 

2140 xf, yf = np.vsplit(transform.getMapping().applyForward(xy), 2) 

2141 xf = xf.ravel() 

2142 yf = yf.ravel() 

2143 

2144 if include_itl_flag: 

2145 is_itl = np.zeros(len(value), dtype=np.bool_) 

2146 # We use this check so that ITL matches ITL science detectors, 

2147 # ITL_WF wavefront detectors, and pseudoITL test detectors. 

2148 is_itl[:] = ("ITL" in detector.getPhysicalType()) 

2149 

2150 xf_arrays.append(xf) 

2151 yf_arrays.append(yf) 

2152 value_arrays.append(value) 

2153 detector_arrays.append(np.full_like(xf, det, dtype=np.int32)) 

2154 if include_itl_flag: 

2155 itl_arrays.append(is_itl) 

2156 

2157 xf = np.concatenate(xf_arrays) 

2158 yf = np.concatenate(yf_arrays) 

2159 value = np.concatenate(value_arrays) 

2160 detector = np.concatenate(detector_arrays) 

2161 

2162 binned = Table( 

2163 np.zeros( 

2164 len(xf), 

2165 dtype=[ 

2166 ("xf", "f8"), 

2167 ("yf", "f8"), 

2168 ("value", "f8"), 

2169 ("detector", "i4"), 

2170 ], 

2171 ) 

2172 ) 

2173 binned["xf"] = xf 

2174 binned["yf"] = yf 

2175 binned["value"] = value 

2176 binned["detector"] = detector 

2177 

2178 if include_itl_flag: 

2179 binned["itl"] = np.concatenate(itl_arrays).astype(np.bool_) 

2180 

2181 return binned 

2182 

2183 

2184def bin_flat(ptc, exposure, bin_factor=8, amp_boundary=20, apply_gains=True, gain_ratios=None): 

2185 """Bin a flat image, being careful with amplifier edges. 

2186 

2187 This will optionally apply gains, and apply any gain 

2188 ratios. 

2189 

2190 Parameters 

2191 ---------- 

2192 ptc : `lsst.ip.isr.PhotonTransferCurveDatasets` 

2193 PTC dataset with relevant gains. 

2194 exposure : `lsst.afw.image.Exposure` 

2195 Exposure to bin. 

2196 bin_factor : `int`, optional 

2197 Binning factor. 

2198 amp_boundary : `int`, optional 

2199 Boundary around each amp to ignore in binning. 

2200 apply_gains : `bool`, optional 

2201 Apply gains before binning? 

2202 gain_ratios : `np.ndarray`, optional 

2203 Array of gain ratios to apply. 

2204 

2205 Returns 

2206 ------- 

2207 binned : `astropy.table.Table` 

2208 Table with detector coordinates at the center of each bin 

2209 (``xd``, ``yd``); average image values (``value``); and amplifier 

2210 index (``amp_index``). 

2211 """ 

2212 detector = exposure.getDetector() 

2213 

2214 for i, amp_name in enumerate(ptc.ampNames): 

2215 bbox = detector[amp_name].getBBox() 

2216 if amp_name in ptc.badAmps: 

2217 exposure[bbox].image.array[:, :] = np.nan 

2218 continue 

2219 

2220 if apply_gains: 

2221 exposure[bbox].image.array[:, :] *= ptc.gainUnadjusted[amp_name] 

2222 if gain_ratios is not None: 

2223 exposure[bbox].image.array[:, :] /= gain_ratios[i] 

2224 

2225 # Next we bin the detector, avoiding amp edges. 

2226 xd_arrays = [] 

2227 yd_arrays = [] 

2228 value_arrays = [] 

2229 amp_arrays = [] 

2230 

2231 for i, amp in enumerate(detector): 

2232 arr = exposure[amp.getBBox()].image.array 

2233 

2234 n_step_y = (arr.shape[0] - (2 * amp_boundary)) // bin_factor 

2235 y_min = amp_boundary 

2236 y_max = bin_factor * n_step_y + y_min 

2237 n_step_x = (arr.shape[1] - (2 * amp_boundary)) // bin_factor 

2238 x_min = amp_boundary 

2239 x_max = bin_factor * n_step_x + x_min 

2240 

2241 arr = arr[y_min: y_max, x_min: x_max] 

2242 binned = arr.reshape((n_step_y, bin_factor, n_step_x, bin_factor)) 

2243 with warnings.catch_warnings(): 

2244 warnings.filterwarnings("ignore", r"Mean of empty") 

2245 binned = np.nanmean(binned, axis=1) 

2246 binned = np.nanmean(binned, axis=2) 

2247 

2248 xx = np.arange(binned.shape[1]) * bin_factor + bin_factor / 2. + x_min 

2249 yy = np.arange(binned.shape[0]) * bin_factor + bin_factor / 2. + y_min 

2250 x, y = np.meshgrid(xx, yy) 

2251 x = x.ravel() + amp.getBBox().getBeginX() 

2252 y = y.ravel() + amp.getBBox().getBeginY() 

2253 value = binned.ravel() 

2254 

2255 xd_arrays.append(x) 

2256 yd_arrays.append(y) 

2257 value_arrays.append(value) 

2258 amp_arrays.append(np.full(len(x), i)) 

2259 

2260 xd = np.concatenate(xd_arrays) 

2261 yd = np.concatenate(yd_arrays) 

2262 value = np.concatenate(value_arrays) 

2263 amp_index = np.concatenate(amp_arrays) 

2264 

2265 binned = np.zeros( 

2266 len(xd), 

2267 dtype=[ 

2268 ("xd", "f8"), 

2269 ("yd", "f8"), 

2270 ("value", "f8"), 

2271 ("amp_index", "i4"), 

2272 ], 

2273 ) 

2274 binned["xd"] = xd 

2275 binned["yd"] = yd 

2276 binned["value"] = value 

2277 binned["amp_index"] = amp_index 

2278 

2279 return Table(data=binned)