Coverage for python/lsst/cp/pipe/ptc/cpPtcAdjustGainRatios.py: 20%

140 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-23 00:36 -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# 

22import copy 

23import esutil 

24import logging 

25import numpy as np 

26from astropy.table import Table 

27 

28import lsst.pipe.base 

29import lsst.pex.config 

30import lsst.afw.cameraGeom 

31 

32from ..utils import FlatGradientFitter, FlatGainRatioFitter, bin_flat 

33 

34 

35__all__ = [ 

36 "PhotonTransferCurveAdjustGainRatiosConfig", 

37 "PhotonTransferCurveAdjustGainRatiosTask", 

38] 

39 

40 

41class PhotonTransferCurveAdjustGainRatiosConnections( 

42 lsst.pipe.base.PipelineTaskConnections, 

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

44): 

45 exposures = lsst.pipe.base.connectionTypes.Input( 

46 name="cpPtcIsrExp", 

47 doc="Input exposures (from PTC ISR) for gain ratio adjustment.", 

48 storageClass="Exposure", 

49 dimensions=("instrument", "exposure", "detector"), 

50 multiple=True, 

51 deferLoad=True, 

52 ) 

53 input_ptc = lsst.pipe.base.connectionTypes.Input( 

54 name="ptcUnadjusted", 

55 doc="Input PTC to have gain ratios adjusted.", 

56 storageClass="PhotonTransferCurveDataset", 

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

58 isCalibration=True, 

59 ) 

60 output_ptc = lsst.pipe.base.connectionTypes.Output( 

61 name="ptc", 

62 doc="Output PTC after gain ratio adjustment.", 

63 storageClass="PhotonTransferCurveDataset", 

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

65 isCalibration=True, 

66 ) 

67 output_adjust_summary = lsst.pipe.base.connectionTypes.Output( 

68 name="ptc_adjustment_summary", 

69 doc="Summary of gain adjustments.", 

70 storageClass="ArrowAstropy", 

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

72 ) 

73 

74 

75class PhotonTransferCurveAdjustGainRatiosConfig( 

76 lsst.pipe.base.PipelineTaskConfig, 

77 pipelineConnections=PhotonTransferCurveAdjustGainRatiosConnections, 

78): 

79 do_remove_radial_gradient = lsst.pex.config.Field( 

80 dtype=bool, 

81 doc="Remove radial gradient before fitting amp gain ratios?", 

82 default=True, 

83 ) 

84 radial_gradient_n_spline_nodes = lsst.pex.config.Field( 

85 dtype=int, 

86 doc="Number of radial spline nodes for radial gradient.", 

87 default=20, 

88 ) 

89 chebyshev_gradient_order = lsst.pex.config.Field( 

90 dtype=int, 

91 doc="Order of chebyshev x/y polynomials to remove additional gradients.", 

92 default=1, 

93 ) 

94 min_adu = lsst.pex.config.Field( 

95 dtype=float, 

96 doc="Minimum number of adu for an exposure to use in gain ratio calculation.", 

97 default=1000.0, 

98 ) 

99 max_adu = lsst.pex.config.Field( 

100 dtype=float, 

101 doc="Maximum number of adu for an exposure to use in gain ratio calculation.", 

102 default=20000.0, 

103 ) 

104 max_noise_reference = lsst.pex.config.Field( 

105 dtype=float, 

106 doc="Maximum read noise (e-) in the PTC for an amp to be considered as a reference", 

107 default=12.0, 

108 ) 

109 turnoff_percentile_reference = lsst.pex.config.Field( 

110 dtype=float, 

111 doc="Percentile threshold for sorting PTC turnoff for an amp to be considered as a reference", 

112 default=25.0, 

113 ) 

114 n_flat = lsst.pex.config.Field( 

115 dtype=int, 

116 doc="Number of flats (from min_adu to max_adu) to use in gain ratio calculation.", 

117 default=50, 

118 ) 

119 random_seed = lsst.pex.config.Field( 

120 dtype=int, 

121 doc="Random seed to use for down-sampling input flats.", 

122 default=12345, 

123 ) 

124 bin_factor = lsst.pex.config.Field( 

125 dtype=int, 

126 doc="Binning factor to compute gradients/gain ratios (pixels).", 

127 default=8, 

128 ) 

129 amp_boundary = lsst.pex.config.Field( 

130 dtype=int, 

131 doc="Amplifier boundary to ignore when computing gradients/gain ratios (pixels).", 

132 default=20, 

133 ) 

134 max_fractional_gain_ratio = lsst.pex.config.Field( 

135 dtype=float, 

136 doc="Maximum fractional gain ratio to consider per-exposure. Any amps with larger " 

137 "offset will be excluded from the gradient fit and will have no corrections " 

138 "applied.", 

139 default=0.05, 

140 ) 

141 

142 

143class PhotonTransferCurveAdjustGainRatiosTask(lsst.pipe.base.PipelineTask): 

144 """Task to remove gradients to fit amp ratio gain adjustments. 

145 """ 

146 ConfigClass = PhotonTransferCurveAdjustGainRatiosConfig 

147 _DefaultName = "cpPhotonTransferCurveAdjustGainRatios" 

148 

149 def run(self, *, exposures, input_ptc): 

150 """Run the gain adjustment task. 

151 

152 Parameters 

153 ---------- 

154 exposures : `list` [`lsst.pipe.base.connections.DeferredDatasetRef`] 

155 Handles for input exposures. 

156 input_ptc : `lsst.ip.isr.PhotonTransferCurveDataset` 

157 Input PTC to adjust. 

158 

159 Returns 

160 ------- 

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

162 The output struct contains: 

163 

164 ``output_ptc`` 

165 The output modified PTC. 

166 ``output_adjust_summary`` 

167 The summary of adjustments. 

168 """ 

169 # Choose the exposures that will be used. 

170 # For simplicity, we always use the first of a PTC pair. 

171 rng = np.random.RandomState(seed=self.config.random_seed) 

172 

173 exp_ids_first = np.asarray(input_ptc.inputExpIdPairs[input_ptc.ampNames[0]])[:, 0] 

174 

175 avg = np.zeros(len(exp_ids_first)) 

176 n_amp = 0 

177 for amp_name in input_ptc.ampNames: 

178 if amp_name in input_ptc.badAmps: 

179 continue 

180 

181 avg[:] += input_ptc.finalMeans[amp_name] 

182 n_amp += 1 

183 

184 avg /= n_amp 

185 

186 exp_ids = exp_ids_first[((avg > self.config.min_adu) & (avg < self.config.max_adu))] 

187 if len(exp_ids) > self.config.n_flat: 

188 exp_ids = rng.choice(exp_ids, size=self.config.n_flat, replace=False) 

189 exp_ids = np.sort(exp_ids) 

190 

191 fixed_amp_index = _choose_reference_amplifier( 

192 input_ptc, 

193 self.config.max_noise_reference, 

194 self.config.turnoff_percentile_reference, 

195 ) 

196 

197 if fixed_amp_index < 0: 

198 return lsst.pipe.base.Struct(output_ptc=input_ptc, output_adjust_summary=Table()) 

199 

200 self.log.info( 

201 "Using amplifier %d (%s) as fixed reference amplifier.", 

202 fixed_amp_index, 

203 input_ptc.ampNames[fixed_amp_index], 

204 ) 

205 

206 gain_ratio_array = np.zeros((len(exp_ids), len(input_ptc.ampNames))) 

207 

208 for i, exp_id in enumerate(exp_ids): 

209 for handle in exposures: 

210 if exp_id == handle.dataId["exposure"]: 

211 exposure = handle.get() 

212 break 

213 

214 self.log.info("Fitting gain ratios on exposure %d.", exp_id) 

215 if not exposure.metadata.get("LSST ISR BOOTSTRAP", True): 

216 raise RuntimeError( 

217 "PhotonTransferCurveAdjustGainRatiosTask can only be run on bootstrap exposures.", 

218 ) 

219 

220 binned = bin_flat( 

221 input_ptc, 

222 exposure, 

223 bin_factor=self.config.bin_factor, 

224 amp_boundary=self.config.amp_boundary, 

225 apply_gains=True, 

226 ) 

227 

228 # Clip out non-finite and extreme values. 

229 # We need to be careful about unmasked defects, so we take 

230 # a tighter percentile and expand. 

231 lo, hi = np.nanpercentile(binned["value"], [5.0, 95.0]) 

232 lo *= 0.8 

233 hi *= 1.2 

234 use = (np.isfinite(binned["value"]) & (binned["value"] >= lo) & (binned["value"] <= hi)) 

235 binned = binned[use] 

236 

237 gain_ratio_array[i, :] = _compute_gain_ratios( 

238 exposure.getDetector(), 

239 binned, 

240 fixed_amp_index, 

241 do_remove_radial_gradient=self.config.do_remove_radial_gradient, 

242 radial_gradient_n_spline_nodes=self.config.radial_gradient_n_spline_nodes, 

243 chebyshev_gradient_order=self.config.chebyshev_gradient_order, 

244 max_fractional_gain_ratio=self.config.max_fractional_gain_ratio, 

245 log=self.log, 

246 ) 

247 

248 output_ptc = copy.copy(input_ptc) 

249 

250 # Compute the summary table. 

251 summary = Table() 

252 summary.meta["fixed_amp_index"] = fixed_amp_index 

253 summary.meta["fixed_amp_name"] = input_ptc.ampNames[fixed_amp_index] 

254 

255 summary["exp_id"] = exp_ids 

256 

257 summary["mean_adu"] = np.zeros(len(exp_ids)) 

258 

259 a, b = esutil.numpy_util.match(exp_ids_first, exp_ids) 

260 summary["mean_adu"][b] = avg[a] 

261 

262 corrections = np.zeros(len(input_ptc.ampNames)) 

263 for i, amp_name in enumerate(input_ptc.ampNames): 

264 summary[f"{amp_name}_gain_ratio"] = gain_ratio_array[:, i] 

265 

266 corrections[i] = np.median(gain_ratio_array[:, i]) 

267 

268 # Adjust the median correction to 1.0 so we do not change the 

269 # gain of the detector on average. 

270 # This is needed in case the reference amplifier is skewed in 

271 # terms of offsets even though it has the median gain. 

272 med_correction = np.median(corrections) 

273 

274 summary.meta["median_correction"] = med_correction 

275 

276 for i, amp_name in enumerate(output_ptc.ampNames): 

277 correction = corrections[i] / med_correction 

278 new_gain = output_ptc.gainUnadjusted[amp_name] / correction 

279 self.log.info( 

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

281 amp_name, 

282 correction, 

283 output_ptc.gain[amp_name], 

284 new_gain, 

285 ) 

286 output_ptc.gain[amp_name] = new_gain 

287 

288 return lsst.pipe.base.Struct(output_ptc=output_ptc, output_adjust_summary=summary) 

289 

290 

291def _choose_reference_amplifier( 

292 ptc, 

293 max_noise_reference, 

294 turnoff_percentile_reference, 

295 log=None, 

296): 

297 """Choose a reference amplifier from a PTC. 

298 

299 Parameters 

300 ---------- 

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

302 Input PTC. 

303 max_noise_reference : `float` 

304 Maximum read noise (e-) in the PTC for an amp to be considered. 

305 turnoff_percentile_reference : `float` 

306 Percentile threshold for sorting PTC turnoff for an amp to be 

307 considered. 

308 log : `logging.logger`, optional 

309 Log object. 

310 

311 Returns 

312 ------- 

313 reference_amp_index : `int` 

314 Index of the reference amplifier. 

315 """ 

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

317 

318 gain_array = np.zeros(len(ptc.ampNames)) 

319 turnoff_array = np.zeros(len(ptc.ampNames)) 

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

321 gain_array[i] = ptc.gain[amp_name] 

322 turnoff_array[i] = ptc.ptcTurnoff[amp_name] 

323 # Do not consider any amplifier with high noise 

324 # as a reference amplifier. 

325 if ptc.noise[amp_name] > max_noise_reference: 

326 log.info( 

327 "Excluding amplifier %s as a possible reference amplifier due to high noise (%.2f)", 

328 amp_name, 

329 ptc.noise[amp_name], 

330 ) 

331 gain_array[i] = np.nan 

332 

333 turnoff_threshold = np.nanpercentile(turnoff_array, turnoff_percentile_reference) 

334 gain_array[turnoff_array < turnoff_threshold] = np.nan 

335 good, = np.where(np.isfinite(gain_array)) 

336 

337 if len(good) <= 1: 

338 log.warning("Insufficient good amplifiers for PTC gain adjustment.") 

339 return -1 

340 

341 st = np.argsort(np.nan_to_num(gain_array[good])) 

342 ref_amp_index = good[st[int(0.5*len(good))]] 

343 

344 return ref_amp_index 

345 

346 

347def _compute_gain_ratios( 

348 detector, 

349 binned, 

350 fixed_amp_index, 

351 do_remove_radial_gradient=True, 

352 radial_gradient_n_spline_nodes=20, 

353 chebyshev_gradient_order=1, 

354 max_fractional_gain_ratio=0.05, 

355 nsig_clip=5.0, 

356 log=None, 

357): 

358 """Compute the gain ratios from a given non-gain-corrected exposure. 

359 

360 Parameters 

361 ---------- 

362 detector : `lsst.afw.cameraGeom.Detector` 

363 Detector object. 

364 binned : `astropy.table.Table` 

365 Table of binned values. Will contain ``xd``, ``yd`` (detector 

366 bin positions); ``value`` (binned value); ``amp_index`` 

367 (index of the amplifiers). 

368 fixed_amp_index : `int` 

369 Use this amp index as the fixed point (gain ratio == 1.0). 

370 do_remove_radial_gradient : `bool`, optional 

371 Remove radial gradient before fitting amp gain ratios? 

372 radial_gradient_n_spline_nodes : `int`, optional 

373 Number of radial spline nodes for radial gradient. 

374 chebyshev_gradient_order : `int`, optional 

375 Order of chebyshev x/y polynomials to remove additional gradients. 

376 max_fractional_gain_ratio : `int`, optional 

377 Maximum fractional gain ratio to consider per-exposure. Any 

378 amps with larger offset will be excluded from the gradient fit 

379 and will have no corrections computed. 

380 nsig_clip : `float`, optional 

381 Number of sigma on distribution of gain ratios to clip when fitting 

382 out Chebyshev gradient. 

383 log : `logging.logger`, optional 

384 Log object. 

385 

386 Returns 

387 ------- 

388 amp_gain_ratios : `np.ndarray` 

389 Amp gain ratios, relative to fixed amp. 

390 """ 

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

392 

393 n_amp = len(detector) 

394 amp_names = [amp.getName() for amp in detector] 

395 

396 # We iterate up to 8x to look for any bad amps (that have a gain offset 

397 # fit greater than max_fractional_gain_ratio) and reject them 

398 # from the fits. 

399 bad_amps_converged = False 

400 n_iter = 0 

401 value_uncorrected = binned["value"].copy() 

402 while not bad_amps_converged and n_iter < n_amp // 2: 

403 # If configured, fit the radial gradient. 

404 if do_remove_radial_gradient: 

405 transform = detector.getTransform( 

406 lsst.afw.cameraGeom.PIXELS, 

407 lsst.afw.cameraGeom.FOCAL_PLANE, 

408 ) 

409 xy = np.vstack((binned["xd"], binned["yd"])) 

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

411 xf = xf.ravel() 

412 yf = yf.ravel() 

413 radius = np.sqrt(xf**2. + yf**2.) 

414 

415 nodes = np.linspace( 

416 np.min(radius), 

417 np.max(radius), 

418 radial_gradient_n_spline_nodes, 

419 ) 

420 

421 # Put in a normalization for fitting. 

422 norm = np.nanpercentile(binned["value"], 95.0) 

423 

424 fitter = FlatGradientFitter( 

425 nodes, 

426 xf, 

427 yf, 

428 binned["value"]/norm, 

429 np.array([]), 

430 constrain_zero=False, 

431 fit_centroid=False, 

432 fit_gradient=False, 

433 fp_centroid_x=0.0, 

434 fp_centroid_y=0.0, 

435 ) 

436 p0 = fitter.compute_p0() 

437 pars = fitter.fit(p0) 

438 

439 binned["value"] /= fitter.compute_model(pars) 

440 

441 # Fit gain ratios. 

442 fitter = FlatGainRatioFitter( 

443 detector.getBBox(), 

444 chebyshev_gradient_order, 

445 binned["xd"], 

446 binned["yd"], 

447 binned["amp_index"], 

448 binned["value"], 

449 np.arange(n_amp), 

450 fixed_amp_index, 

451 ) 

452 pars = fitter.fit(nsig_clip=nsig_clip) 

453 amp_pars = pars[fitter.indices["amp_pars"]] 

454 

455 fractional_gain_ratio = np.abs(amp_pars - 1.0) 

456 max_ratio_ind = np.argmax(fractional_gain_ratio) 

457 

458 if fractional_gain_ratio[max_ratio_ind] > max_fractional_gain_ratio: 

459 log.warning( 

460 "Found bad amp %s with offset parameter %.2f", 

461 amp_names[max_ratio_ind], 

462 amp_pars[max_ratio_ind], 

463 ) 

464 good = (binned["amp_index"] != max_ratio_ind) 

465 binned = binned[good] 

466 binned["value"] = value_uncorrected[good] 

467 

468 value_uncorrected = binned["value"].copy() 

469 else: 

470 bad_amps_converged = True 

471 

472 n_iter += 1 

473 

474 return amp_pars