Coverage for python/lsst/cp/pipe/linearity.py: 15%

Shortcuts on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

209 statements  

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 numpy as np 

23 

24import lsst.afw.image as afwImage 

25import lsst.afw.math as afwMath 

26import lsst.pipe.base as pipeBase 

27import lsst.pipe.base.connectionTypes as cT 

28import lsst.pex.config as pexConfig 

29 

30from lsstDebug import getDebugFrame 

31from lsst.ip.isr import (Linearizer, IsrProvenance) 

32 

33from .utils import (funcPolynomial, irlsFit) 

34from ._lookupStaticCalibration import lookupStaticCalibration 

35 

36__all__ = ["LinearitySolveTask", "LinearitySolveConfig", "MeasureLinearityTask"] 

37 

38 

39class LinearitySolveConnections(pipeBase.PipelineTaskConnections, 

40 dimensions=("instrument", "exposure", "detector")): 

41 dummy = cT.Input( 

42 name="raw", 

43 doc="Dummy exposure.", 

44 storageClass='Exposure', 

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

46 multiple=True, 

47 deferLoad=True, 

48 ) 

49 camera = cT.PrerequisiteInput( 

50 name="camera", 

51 doc="Camera Geometry definition.", 

52 storageClass="Camera", 

53 dimensions=("instrument", ), 

54 isCalibration=True, 

55 lookupFunction=lookupStaticCalibration, 

56 ) 

57 inputPtc = cT.PrerequisiteInput( 

58 name="ptc", 

59 doc="Input PTC dataset.", 

60 storageClass="PhotonTransferCurveDataset", 

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

62 isCalibration=True, 

63 ) 

64 

65 outputLinearizer = cT.Output( 

66 name="linearity", 

67 doc="Output linearity measurements.", 

68 storageClass="Linearizer", 

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

70 isCalibration=True, 

71 ) 

72 

73 

74class LinearitySolveConfig(pipeBase.PipelineTaskConfig, 

75 pipelineConnections=LinearitySolveConnections): 

76 """Configuration for solving the linearity from PTC dataset. 

77 """ 

78 linearityType = pexConfig.ChoiceField( 

79 dtype=str, 

80 doc="Type of linearizer to construct.", 

81 default="Squared", 

82 allowed={ 

83 "LookupTable": "Create a lookup table solution.", 

84 "Polynomial": "Create an arbitrary polynomial solution.", 

85 "Squared": "Create a single order squared solution.", 

86 "Spline": "Create a spline based solution.", 

87 "None": "Create a dummy solution.", 

88 } 

89 ) 

90 polynomialOrder = pexConfig.Field( 

91 dtype=int, 

92 doc="Degree of polynomial to fit.", 

93 default=3, 

94 ) 

95 splineKnots = pexConfig.Field( 

96 dtype=int, 

97 doc="Number of spline knots to use in fit.", 

98 default=10, 

99 ) 

100 maxLookupTableAdu = pexConfig.Field( 

101 dtype=int, 

102 doc="Maximum DN value for a LookupTable linearizer.", 

103 default=2**18, 

104 ) 

105 maxLinearAdu = pexConfig.Field( 

106 dtype=float, 

107 doc="Maximum DN value to use to estimate linear term.", 

108 default=20000.0, 

109 ) 

110 minLinearAdu = pexConfig.Field( 

111 dtype=float, 

112 doc="Minimum DN value to use to estimate linear term.", 

113 default=2000.0, 

114 ) 

115 nSigmaClipLinear = pexConfig.Field( 

116 dtype=float, 

117 doc="Maximum deviation from linear solution for Poissonian noise.", 

118 default=5.0, 

119 ) 

120 ignorePtcMask = pexConfig.Field( 

121 dtype=bool, 

122 doc="Ignore the expIdMask set by the PTC solver?", 

123 default=False, 

124 ) 

125 

126 

127class LinearitySolveTask(pipeBase.PipelineTask, pipeBase.CmdLineTask): 

128 """Fit the linearity from the PTC dataset. 

129 """ 

130 

131 ConfigClass = LinearitySolveConfig 

132 _DefaultName = 'cpLinearitySolve' 

133 

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

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

136 

137 Parameters 

138 ---------- 

139 butlerQC : `lsst.daf.butler.butlerQuantumContext.ButlerQuantumContext` 

140 Butler to operate on. 

141 inputRefs : `lsst.pipe.base.connections.InputQuantizedConnection` 

142 Input data refs to load. 

143 ouptutRefs : `lsst.pipe.base.connections.OutputQuantizedConnection` 

144 Output data refs to persist. 

145 """ 

146 inputs = butlerQC.get(inputRefs) 

147 

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

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

150 

151 outputs = self.run(**inputs) 

152 butlerQC.put(outputs, outputRefs) 

153 

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

155 """Fit non-linearity to PTC data, returning the correct Linearizer 

156 object. 

157 

158 Parameters 

159 ---------- 

160 inputPtc : `lsst.cp.pipe.PtcDataset` 

161 Pre-measured PTC dataset. 

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

163 The exposure used to select the appropriate PTC dataset. 

164 In almost all circumstances, one of the input exposures 

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

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

167 Camera geometry. 

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

169 DataIds to use to populate the output calibration. 

170 

171 Returns 

172 ------- 

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

174 The results struct containing: 

175 

176 ``outputLinearizer`` 

177 Final linearizer calibration (`lsst.ip.isr.Linearizer`). 

178 ``outputProvenance`` 

179 Provenance data for the new calibration 

180 (`lsst.ip.isr.IsrProvenance`). 

181 

182 Notes 

183 ----- 

184 This task currently fits only polynomial-defined corrections, 

185 where the correction coefficients are defined such that: 

186 :math:`corrImage = uncorrImage + \\sum_i c_i uncorrImage^(2 + i)` 

187 These :math:`c_i` are defined in terms of the direct polynomial fit: 

188 :math:`meanVector ~ P(x=timeVector) = \\sum_j k_j x^j` 

189 such that :math:`c_(j-2) = -k_j/(k_1^j)` in units of DN^(1-j) (c.f., 

190 Eq. 37 of 2003.05978). The `config.polynomialOrder` or 

191 `config.splineKnots` define the maximum order of :math:`x^j` to fit. 

192 As :math:`k_0` and :math:`k_1` are degenerate with bias level and gain, 

193 they are not included in the non-linearity correction. 

194 """ 

195 if len(dummy) == 0: 

196 self.log.warning("No dummy exposure found.") 

197 

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

199 if self.config.linearityType == 'LookupTable': 

200 table = np.zeros((len(detector), self.config.maxLookupTableAdu), dtype=np.float32) 

201 tableIndex = 0 

202 else: 

203 table = None 

204 tableIndex = None # This will fail if we increment it. 

205 

206 if self.config.linearityType == 'Spline': 

207 fitOrder = self.config.splineKnots 

208 else: 

209 fitOrder = self.config.polynomialOrder 

210 

211 # Initialize the linearizer. 

212 linearizer = Linearizer(detector=detector, table=table, log=self.log) 

213 

214 for i, amp in enumerate(detector): 

215 ampName = amp.getName() 

216 if ampName in inputPtc.badAmps: 

217 nEntries = 1 

218 pEntries = 1 

219 if self.config.linearityType in ['Polynomial']: 

220 nEntries = fitOrder + 1 

221 pEntries = fitOrder + 1 

222 elif self.config.linearityType in ['Spline']: 

223 nEntries = fitOrder * 2 

224 elif self.config.linearityType in ['Squared', 'None']: 

225 nEntries = 1 

226 pEntries = fitOrder + 1 

227 elif self.config.linearityType in ['LookupTable']: 

228 nEntries = 2 

229 pEntries = fitOrder + 1 

230 

231 linearizer.linearityType[ampName] = "None" 

232 linearizer.linearityCoeffs[ampName] = np.zeros(nEntries) 

233 linearizer.linearityBBox[ampName] = amp.getBBox() 

234 linearizer.fitParams[ampName] = np.zeros(pEntries) 

235 linearizer.fitParamsErr[ampName] = np.zeros(pEntries) 

236 linearizer.fitChiSq[ampName] = np.nan 

237 self.log.warning("Amp %s has no usable PTC information. Skipping!", ampName) 

238 continue 

239 

240 if (len(inputPtc.expIdMask[ampName]) == 0) or self.config.ignorePtcMask: 

241 self.log.warning(f"Mask not found for {ampName} in non-linearity fit. Using all points.") 

242 mask = np.repeat(True, len(inputPtc.expIdMask[ampName])) 

243 else: 

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

245 

246 inputAbscissa = np.array(inputPtc.rawExpTimes[ampName])[mask] 

247 inputOrdinate = np.array(inputPtc.rawMeans[ampName])[mask] 

248 

249 # Determine proxy-to-linear-flux transformation 

250 fluxMask = inputOrdinate < self.config.maxLinearAdu 

251 lowMask = inputOrdinate > self.config.minLinearAdu 

252 fluxMask = fluxMask & lowMask 

253 linearAbscissa = inputAbscissa[fluxMask] 

254 linearOrdinate = inputOrdinate[fluxMask] 

255 

256 linearFit, linearFitErr, chiSq, weights = irlsFit([0.0, 100.0], linearAbscissa, 

257 linearOrdinate, funcPolynomial) 

258 # Convert this proxy-to-flux fit into an expected linear flux 

259 linearOrdinate = linearFit[0] + linearFit[1] * inputAbscissa 

260 

261 # Exclude low end outliers 

262 threshold = self.config.nSigmaClipLinear * np.sqrt(linearOrdinate) 

263 fluxMask = np.abs(inputOrdinate - linearOrdinate) < threshold 

264 linearOrdinate = linearOrdinate[fluxMask] 

265 fitOrdinate = inputOrdinate[fluxMask] 

266 self.debugFit('linearFit', inputAbscissa, inputOrdinate, linearOrdinate, fluxMask, ampName) 

267 # Do fits 

268 if self.config.linearityType in ['Polynomial', 'Squared', 'LookupTable']: 

269 polyFit = np.zeros(fitOrder + 1) 

270 polyFit[1] = 1.0 

271 polyFit, polyFitErr, chiSq, weights = irlsFit(polyFit, linearOrdinate, 

272 fitOrdinate, funcPolynomial) 

273 

274 # Truncate the polynomial fit 

275 k1 = polyFit[1] 

276 linearityFit = [-coeff/(k1**order) for order, coeff in enumerate(polyFit)] 

277 significant = np.where(np.abs(linearityFit) > 1e-10, True, False) 

278 self.log.info(f"Significant polynomial fits: {significant}") 

279 

280 modelOrdinate = funcPolynomial(polyFit, linearAbscissa) 

281 self.debugFit('polyFit', linearAbscissa, fitOrdinate, modelOrdinate, None, ampName) 

282 

283 if self.config.linearityType == 'Squared': 

284 linearityFit = [linearityFit[2]] 

285 elif self.config.linearityType == 'LookupTable': 

286 # Use linear part to get time at wich signal is 

287 # maxAduForLookupTableLinearizer DN 

288 tMax = (self.config.maxLookupTableAdu - polyFit[0])/polyFit[1] 

289 timeRange = np.linspace(0, tMax, self.config.maxLookupTableAdu) 

290 signalIdeal = polyFit[0] + polyFit[1]*timeRange 

291 signalUncorrected = funcPolynomial(polyFit, timeRange) 

292 lookupTableRow = signalIdeal - signalUncorrected # LinearizerLookupTable has correction 

293 

294 linearizer.tableData[tableIndex, :] = lookupTableRow 

295 linearityFit = [tableIndex, 0] 

296 tableIndex += 1 

297 elif self.config.linearityType in ['Spline']: 

298 # See discussion in `lsst.ip.isr.linearize.py` before 

299 # modifying. 

300 numPerBin, binEdges = np.histogram(linearOrdinate, bins=fitOrder) 

301 with np.errstate(invalid="ignore"): 

302 # Algorithm note: With the counts of points per 

303 # bin above, the next histogram calculates the 

304 # values to put in each bin by weighting each 

305 # point by the correction value. 

306 values = np.histogram(linearOrdinate, bins=fitOrder, 

307 weights=(inputOrdinate[fluxMask] - linearOrdinate))[0]/numPerBin 

308 

309 # After this is done, the binCenters are 

310 # calculated by weighting by the value we're 

311 # binning over. This ensures that widely 

312 # spaced/poorly sampled data aren't assigned to 

313 # the midpoint of the bin (as could be done using 

314 # the binEdges above), but to the weighted mean of 

315 # the inputs. Note that both histograms are 

316 # scaled by the count per bin to normalize what 

317 # the histogram returns (a sum of the points 

318 # inside) into an average. 

319 binCenters = np.histogram(linearOrdinate, bins=fitOrder, 

320 weights=linearOrdinate)[0]/numPerBin 

321 values = values[numPerBin > 0] 

322 binCenters = binCenters[numPerBin > 0] 

323 

324 self.debugFit('splineFit', binCenters, np.abs(values), values, None, ampName) 

325 interp = afwMath.makeInterpolate(binCenters.tolist(), values.tolist(), 

326 afwMath.stringToInterpStyle("AKIMA_SPLINE")) 

327 modelOrdinate = linearOrdinate + interp.interpolate(linearOrdinate) 

328 self.debugFit('splineFit', linearOrdinate, fitOrdinate, modelOrdinate, None, ampName) 

329 

330 # If we exclude a lot of points, we may end up with 

331 # less than fitOrder points. Pad out the low-flux end 

332 # to ensure equal lengths. 

333 if len(binCenters) != fitOrder: 

334 padN = fitOrder - len(binCenters) 

335 binCenters = np.pad(binCenters, (padN, 0), 'linear_ramp', 

336 end_values=(binCenters.min() - 1.0, )) 

337 # This stores the correction, which is zero at low values. 

338 values = np.pad(values, (padN, 0)) 

339 

340 # Pack the spline into a single array. 

341 linearityFit = np.concatenate((binCenters.tolist(), values.tolist())).tolist() 

342 polyFit = [0.0] 

343 polyFitErr = [0.0] 

344 chiSq = np.nan 

345 else: 

346 polyFit = [0.0] 

347 polyFitErr = [0.0] 

348 chiSq = np.nan 

349 linearityFit = [0.0] 

350 

351 linearizer.linearityType[ampName] = self.config.linearityType 

352 linearizer.linearityCoeffs[ampName] = np.array(linearityFit) 

353 linearizer.linearityBBox[ampName] = amp.getBBox() 

354 linearizer.fitParams[ampName] = np.array(polyFit) 

355 linearizer.fitParamsErr[ampName] = np.array(polyFitErr) 

356 linearizer.fitChiSq[ampName] = chiSq 

357 

358 image = afwImage.ImageF(len(inputOrdinate), 1) 

359 image.getArray()[:, :] = inputOrdinate 

360 linearizeFunction = linearizer.getLinearityTypeByName(linearizer.linearityType[ampName]) 

361 linearizeFunction()(image, 

362 **{'coeffs': linearizer.linearityCoeffs[ampName], 

363 'table': linearizer.tableData, 

364 'log': linearizer.log}) 

365 linearizeModel = image.getArray()[0, :] 

366 

367 self.debugFit('solution', inputOrdinate[fluxMask], linearOrdinate, 

368 linearizeModel[fluxMask], None, ampName) 

369 

370 linearizer.hasLinearity = True 

371 linearizer.validate() 

372 linearizer.updateMetadata(camera=camera, detector=detector, filterName='NONE') 

373 linearizer.updateMetadata(setDate=True, setCalibId=True) 

374 provenance = IsrProvenance(calibType='linearizer') 

375 

376 return pipeBase.Struct( 

377 outputLinearizer=linearizer, 

378 outputProvenance=provenance, 

379 ) 

380 

381 def debugFit(self, stepname, xVector, yVector, yModel, mask, ampName): 

382 """Debug method for linearity fitting. 

383 

384 Parameters 

385 ---------- 

386 stepname : `str` 

387 A label to use to check if we care to debug at a given 

388 line of code. 

389 xVector : `numpy.array`, (N,) 

390 The values to use as the independent variable in the 

391 linearity fit. 

392 yVector : `numpy.array`, (N,) 

393 The values to use as the dependent variable in the 

394 linearity fit. 

395 yModel : `numpy.array`, (N,) 

396 The values to use as the linearized result. 

397 mask : `numpy.array` [`bool`], (N,) , optional 

398 A mask to indicate which entries of ``xVector`` and 

399 ``yVector`` to keep. 

400 ampName : `str` 

401 Amplifier name to lookup linearity correction values. 

402 """ 

403 frame = getDebugFrame(self._display, stepname) 

404 if frame: 

405 import matplotlib.pyplot as plt 

406 fig, axs = plt.subplots(2) 

407 

408 if mask is None: 

409 mask = np.ones_like(xVector, dtype=bool) 

410 

411 fig.suptitle(f"{stepname} {ampName} {self.config.linearityType}") 

412 if stepname == 'linearFit': 

413 axs[0].set_xlabel("Input Abscissa (time or mondiode)") 

414 axs[0].set_ylabel("Input Ordinate (flux)") 

415 axs[1].set_xlabel("Linear Ordinate (linear flux)") 

416 axs[1].set_ylabel("Flux Difference: (input - linear)") 

417 elif stepname in ('polyFit', 'splineFit'): 

418 axs[0].set_xlabel("Linear Abscissa (linear flux)") 

419 axs[0].set_ylabel("Input Ordinate (flux)") 

420 axs[1].set_xlabel("Linear Ordinate (linear flux)") 

421 axs[1].set_ylabel("Flux Difference: (input - full model fit)") 

422 elif stepname == 'solution': 

423 axs[0].set_xlabel("Input Abscissa (time or mondiode)") 

424 axs[0].set_ylabel("Linear Ordinate (linear flux)") 

425 axs[1].set_xlabel("Model flux (linear flux)") 

426 axs[1].set_ylabel("Flux Difference: (linear - model)") 

427 

428 axs[0].set_yscale('log') 

429 axs[0].set_xscale('log') 

430 axs[0].scatter(xVector, yVector) 

431 axs[0].scatter(xVector[~mask], yVector[~mask], c='red', marker='x') 

432 axs[1].set_xscale('log') 

433 

434 axs[1].scatter(yModel, yVector[mask] - yModel) 

435 fig.show() 

436 

437 prompt = "Press Enter or c to continue [chpx]..." 

438 while True: 

439 ans = input(prompt).lower() 

440 if ans in ("", " ", "c",): 

441 break 

442 elif ans in ("p", ): 

443 import pdb 

444 pdb.set_trace() 

445 elif ans in ("h", ): 

446 print("[h]elp [c]ontinue [p]db") 

447 elif ans in ('x', ): 

448 exit() 

449 plt.close() 

450 

451 

452class MeasureLinearityConfig(pexConfig.Config): 

453 solver = pexConfig.ConfigurableField( 

454 target=LinearitySolveTask, 

455 doc="Task to convert PTC data to linearity solutions.", 

456 ) 

457 

458 

459class MeasureLinearityTask(pipeBase.CmdLineTask): 

460 """Stand alone Gen2 linearity measurement. 

461 

462 This class wraps the Gen3 linearity task to allow it to be run as 

463 a Gen2 CmdLineTask. 

464 """ 

465 

466 ConfigClass = MeasureLinearityConfig 

467 _DefaultName = "measureLinearity" 

468 

469 def __init__(self, **kwargs): 

470 super().__init__(**kwargs) 

471 self.makeSubtask("solver") 

472 

473 def runDataRef(self, dataRef): 

474 """Run new linearity code for gen2. 

475 

476 Parameters 

477 ---------- 

478 dataRef : `lsst.daf.persistence.ButlerDataRef` 

479 Input dataref for the photon transfer curve data. 

480 

481 Returns 

482 ------- 

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

484 The results struct containing: 

485 

486 ``outputLinearizer`` 

487 Final linearizer calibration (`lsst.ip.isr.Linearizer`). 

488 ``outputProvenance`` 

489 Provenance data for the new calibration 

490 (`lsst.ip.isr.IsrProvenance`). 

491 """ 

492 ptc = dataRef.get('photonTransferCurveDataset') 

493 camera = dataRef.get('camera') 

494 inputDims = dataRef.dataId # This is the closest gen2 has. 

495 linearityResults = self.solver.run(ptc, camera=camera, inputDims=inputDims) 

496 

497 inputDims['calibDate'] = linearityResults.outputLinearizer.getMetadata().get('CALIBDATE') 

498 butler = dataRef.getButler() 

499 butler.put(linearityResults.outputLinearizer, "linearizer", inputDims) 

500 return linearityResults