Coverage for python / lsst / cp / pipe / cpLinearitySolve.py: 10%

872 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-15 07:22 -0300

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__ = [ 

24 "LinearitySolveTask", 

25 "LinearitySolveConfig", 

26 "LinearityDoubleSplineSolveTask", 

27 "LinearityDoubleSplineSolveConfig", 

28] 

29 

30import copy 

31import logging 

32import numpy as np 

33import esutil 

34from scipy.stats import median_abs_deviation 

35from scipy.interpolate import Akima1DInterpolator 

36 

37import lsst.afw.image as afwImage 

38import lsst.pipe.base as pipeBase 

39import lsst.pipe.base.connectionTypes as cT 

40import lsst.pex.config as pexConfig 

41 

42from lsstDebug import getDebugFrame 

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

44 

45from .utils import (funcPolynomial, irlsFit, AstierSplineLinearityFitter, 

46 extractCalibDate) 

47 

48 

49def ptcLookup(datasetType, registry, quantumDataId, collections): 

50 """Butler lookup function to allow PTC to be found. 

51 

52 Parameters 

53 ---------- 

54 datasetType : `lsst.daf.butler.DatasetType` 

55 Dataset type to look up. 

56 registry : `lsst.daf.butler.Registry` 

57 Registry for the data repository being searched. 

58 quantumDataId : `lsst.daf.butler.DataCoordinate` 

59 Data ID for the quantum of the task this dataset will be passed to. 

60 This must include an "instrument" key, and should also include any 

61 keys that are present in ``datasetType.dimensions``. If it has an 

62 ``exposure`` or ``visit`` key, that's a sign that this function is 

63 not actually needed, as those come with the temporal information that 

64 would allow a real validity-range lookup. 

65 collections : `lsst.daf.butler.registry.CollectionSearch` 

66 Collections passed by the user when generating a QuantumGraph. Ignored 

67 by this function (see notes below). 

68 

69 Returns 

70 ------- 

71 refs : `list` [ `DatasetRef` ] 

72 A zero- or single-element list containing the matching 

73 dataset, if one was found. 

74 

75 Raises 

76 ------ 

77 RuntimeError 

78 Raised if more than one PTC reference is found. 

79 """ 

80 refs = list(registry.queryDatasets(datasetType, dataId=quantumDataId, collections=collections, 

81 findFirst=False)) 

82 if len(refs) >= 2: 

83 RuntimeError("Too many PTC connections found. Incorrect collections supplied?") 

84 

85 return refs 

86 

87 

88class LinearitySolveConnections(pipeBase.PipelineTaskConnections, 

89 dimensions=("instrument", "detector")): 

90 dummy = cT.Input( 

91 name="raw", 

92 doc="Dummy exposure.", 

93 storageClass='Exposure', 

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

95 multiple=True, 

96 deferLoad=True, 

97 ) 

98 

99 camera = cT.PrerequisiteInput( 

100 name="camera", 

101 doc="Camera Geometry definition.", 

102 storageClass="Camera", 

103 dimensions=("instrument", ), 

104 isCalibration=True, 

105 ) 

106 

107 inputPtc = cT.PrerequisiteInput( 

108 name="ptc", 

109 doc="Input PTC dataset.", 

110 storageClass="PhotonTransferCurveDataset", 

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

112 isCalibration=True, 

113 lookupFunction=ptcLookup, 

114 ) 

115 

116 inputLinearizerPtc = cT.Input( 

117 name="linearizerPtc", 

118 doc="Input linearizer PTC dataset.", 

119 storageClass="PhotonTransferCurveDataset", 

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

121 isCalibration=True, 

122 ) 

123 

124 inputPhotodiodeCorrection = cT.Input( 

125 name="pdCorrection", 

126 doc="Input photodiode correction.", 

127 storageClass="IsrCalib", 

128 dimensions=("instrument", ), 

129 isCalibration=True, 

130 ) 

131 

132 inputNormalization = cT.Input( 

133 name="cpLinearizerPtcNormalization", 

134 doc="Focal-plane normalization table.", 

135 storageClass="ArrowAstropy", 

136 dimensions=("instrument",), 

137 isCalibration=True, 

138 ) 

139 

140 outputLinearizer = cT.Output( 

141 name="linearity", 

142 doc="Output linearity measurements.", 

143 storageClass="Linearizer", 

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

145 isCalibration=True, 

146 ) 

147 

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

149 super().__init__(config=config) 

150 

151 if not config.applyPhotodiodeCorrection: 

152 del self.inputPhotodiodeCorrection 

153 

154 if config.useLinearizerPtc: 

155 del self.inputPtc 

156 else: 

157 del self.inputLinearizerPtc 

158 

159 if not config.useFocalPlaneNormalization: 

160 del self.inputNormalization 

161 

162 

163class LinearitySolveConfig(pipeBase.PipelineTaskConfig, 

164 pipelineConnections=LinearitySolveConnections): 

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

166 """ 

167 linearityType = pexConfig.ChoiceField( 

168 dtype=str, 

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

170 default="Squared", 

171 allowed={ 

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

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

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

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

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

177 } 

178 ) 

179 polynomialOrder = pexConfig.RangeField( 

180 dtype=int, 

181 doc="Degree of polynomial to fit. Must be at least 2.", 

182 default=3, 

183 min=2, 

184 ) 

185 splineKnots = pexConfig.Field( 

186 dtype=int, 

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

188 default=10, 

189 ) 

190 

191 trimmedState = pexConfig.Field( 

192 dtype=bool, 

193 doc="Will this linearizer be used on trimmed data?", 

194 default=True, 

195 ) 

196 

197 maxLookupTableAdu = pexConfig.Field( 

198 dtype=int, 

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

200 default=2**18, 

201 ) 

202 maxLinearAdu = pexConfig.Field( 

203 dtype=float, 

204 doc="Maximum adu value to use to estimate linear term; not used with spline fits.", 

205 default=20000.0, 

206 ) 

207 minLinearAdu = pexConfig.Field( 

208 dtype=float, 

209 doc="Minimum adu value to use to estimate linear term.", 

210 default=30.0, 

211 ) 

212 nSigmaClipLinear = pexConfig.Field( 

213 dtype=float, 

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

215 default=5.0, 

216 ) 

217 ignorePtcMask = pexConfig.Field( 

218 dtype=bool, 

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

220 default=False, 

221 deprecated="This field is no longer used. Will be removed after v28.", 

222 ) 

223 maxFracLinearityDeviation = pexConfig.Field( 

224 dtype=float, 

225 doc="Maximum fraction deviation from raw linearity to compute " 

226 "linearityTurnoff and linearityMaxSignal.", 

227 # TODO: DM-46811 investigate if this can be raised to 0.05. 

228 default=0.01, 

229 ) 

230 minSignalFitLinearityTurnoff = pexConfig.Field( 

231 dtype=float, 

232 doc="Minimum signal to compute raw linearity slope for linearityTurnoff.", 

233 default=1000.0, 

234 ) 

235 usePhotodiode = pexConfig.Field( 

236 dtype=bool, 

237 doc="Use the photodiode info instead of the raw expTimes?", 

238 default=False, 

239 ) 

240 applyPhotodiodeCorrection = pexConfig.Field( 

241 dtype=bool, 

242 doc="Calculate and apply a correction to the photodiode readings?", 

243 default=False, 

244 ) 

245 minPhotodiodeCurrent = pexConfig.Field( 

246 dtype=float, 

247 doc="Minimum value to trust photodiode signals.", 

248 default=0.0, 

249 ) 

250 doAutoGrouping = pexConfig.Field( 

251 dtype=bool, 

252 doc="Do automatic group detection? Cannot be True if splineGroupingColumn is also set. " 

253 "The automatic group detection will use the ratio of signal to exposure time (if " 

254 "autoGroupingUseExptime is True) or photodiode (if False) to determine which " 

255 "flat pairs were taken with different illumination settings.", 

256 default=False, 

257 ) 

258 autoGroupingUseExptime = pexConfig.Field( 

259 dtype=bool, 

260 doc="Use exposure time to determine automatic grouping. Used if doAutoGrouping=True.", 

261 default=True, 

262 ) 

263 autoGroupingThreshold = pexConfig.Field( 

264 dtype=float, 

265 doc="Minimum relative jump from sorted conversion values to determine a group.", 

266 default=0.1, 

267 ) 

268 autoGroupingMaxSignalFraction = pexConfig.Field( 

269 dtype=float, 

270 doc="Only do auto-grouping when the signal is this fraction of the maximum signal. " 

271 "All exposures with signal higher than this threshold will be put into the " 

272 "largest signal group. This config is needed if the input PTC goes beyond " 

273 "the linearity turnoff.", 

274 default=0.9, 

275 ) 

276 splineGroupingColumn = pexConfig.Field( 

277 dtype=str, 

278 doc="Column to use for grouping together points for Spline mode, to allow " 

279 "for different proportionality constants. If None, then grouping will " 

280 "only be done if doAutoGrouping is True.", 

281 default=None, 

282 optional=True, 

283 ) 

284 splineGroupingMinPoints = pexConfig.Field( 

285 dtype=int, 

286 doc="Minimum number of linearity points to allow grouping together points " 

287 "for Spline mode with splineGroupingColumn. This configuration is here " 

288 "to prevent misuse of the Spline code to avoid over-fitting.", 

289 default=100, 

290 ) 

291 splineFitMinIter = pexConfig.Field( 

292 dtype=int, 

293 doc="Minimum number of iterations for spline fit.", 

294 default=3, 

295 ) 

296 splineFitMaxIter = pexConfig.Field( 

297 dtype=int, 

298 doc="Maximum number of iterations for spline fit.", 

299 default=20, 

300 ) 

301 splineFitMaxRejectionPerIteration = pexConfig.Field( 

302 dtype=int, 

303 doc="Maximum number of rejections per iteration for spline fit.", 

304 default=5, 

305 ) 

306 doSplineFitOffset = pexConfig.Field( 

307 dtype=bool, 

308 doc="Fit a scattered light offset in the spline fit.", 

309 default=True, 

310 ) 

311 doSplineFitWeights = pexConfig.Field( 

312 dtype=bool, 

313 doc="Fit linearity weight parameters in the spline fit.", 

314 default=False, 

315 ) 

316 splineFitWeightParsStart = pexConfig.ListField( 

317 dtype=float, 

318 doc="Starting parameters for weight fit, if doSplineFitWeights=True. " 

319 "Parameters are such that sigma = sqrt(par[0]**2. + par[1]**2./mu)." 

320 "If doSplineFitWeights=False then these are used as-is; otherwise " 

321 "they are used as the initial values for fitting these parameters.", 

322 length=2, 

323 default=[1.0, 0.0], 

324 ) 

325 doSplineFitTemperature = pexConfig.Field( 

326 dtype=bool, 

327 doc="Fit temperature coefficient in spline fit?", 

328 default=False, 

329 ) 

330 splineFitTemperatureColumn = pexConfig.Field( 

331 dtype=str, 

332 doc="Name of the temperature column to use when fitting temperature " 

333 "coefficients in spline fit; this must not be None if " 

334 "doSplineFitTemperature is True.", 

335 default=None, 

336 optional=True, 

337 ) 

338 doSplineFitTemporal = pexConfig.Field( 

339 dtype=bool, 

340 doc="Fit a linear temporal parameter coefficient in spline fit?", 

341 default=False, 

342 ) 

343 useLinearizerPtc = pexConfig.Field( 

344 dtype=bool, 

345 doc="Use a linearizer ptc in a single pipeline?", 

346 default=False, 

347 ) 

348 useFocalPlaneNormalization = pexConfig.Field( 

349 dtype=bool, 

350 doc="Use focal-plane normalization in addition to/instead of photodiode? " 

351 "(Only used with spline fitting).", 

352 default=False, 

353 ) 

354 

355 def validate(self): 

356 super().validate() 

357 

358 if self.doSplineFitTemperature and self.splineFitTemperatureColumn is None: 

359 raise ValueError("Must set splineFitTemperatureColumn if doSplineFitTemperature is True.") 

360 

361 if self.doAutoGrouping and self.splineGroupingColumn is not None: 

362 raise ValueError("Must not set doAutoGrouping and also splineGroupingColumn") 

363 if self.doAutoGrouping: 

364 if not self.autoGroupingUseExptime and not self.usePhotodiode: 

365 raise ValueError("If doAutoGrouping is True and autoGroupingUseExptime is False, then " 

366 "usePhotodiode must be True.") 

367 

368 

369class LinearitySolveTask(pipeBase.PipelineTask): 

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

371 """ 

372 

373 ConfigClass = LinearitySolveConfig 

374 _DefaultName = 'cpLinearitySolve' 

375 

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

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

378 

379 Parameters 

380 ---------- 

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

382 Butler to operate on. 

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

384 Input data refs to load. 

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

386 Output data refs to persist. 

387 """ 

388 inputs = butlerQC.get(inputRefs) 

389 

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

391 

392 if self.config.useLinearizerPtc: 

393 inputs["inputDims"] = dict(inputRefs.inputLinearizerPtc.dataId.required) 

394 inputs["inputPtc"] = inputs["inputLinearizerPtc"] 

395 del inputs["inputLinearizerPtc"] 

396 else: 

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

398 

399 # Add calibration provenance info to header. 

400 kwargs = dict() 

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

402 

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

404 runKey = "PTC_RUN" 

405 runValue = reference.run 

406 idKey = "PTC_UUID" 

407 idValue = str(reference.id) 

408 dateKey = "PTC_DATE" 

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

410 dateValue = extractCalibDate(calib) 

411 

412 kwargs[runKey] = runValue 

413 kwargs[idKey] = idValue 

414 kwargs[dateKey] = dateValue 

415 

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

417 

418 outputs = self.run(**inputs) 

419 outputs.outputLinearizer.updateMetadata(setDate=False, **kwargs) 

420 

421 butlerQC.put(outputs, outputRefs) 

422 

423 def run(self, inputPtc, dummy, camera, inputDims, 

424 inputPhotodiodeCorrection=None, inputNormalization=None): 

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

426 object. 

427 

428 Parameters 

429 ---------- 

430 inputPtc : `lsst.ip.isr.PtcDataset` 

431 Pre-measured PTC dataset. 

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

433 The exposure used to select the appropriate PTC dataset. 

434 In almost all circumstances, one of the input exposures 

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

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

437 Camera geometry. 

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

439 DataIds to use to populate the output calibration. 

440 inputPhotodiodeCorrection : 

441 `lsst.ip.isr.PhotodiodeCorrection`, optional 

442 Pre-measured photodiode correction used in the case when 

443 applyPhotodiodeCorrection=True. 

444 inputNormalization : `astropy.table.Table`, optional 

445 Focal plane normalization table to use if 

446 useFocalPlaneNormalization is True. 

447 

448 Returns 

449 ------- 

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

451 The results struct containing: 

452 

453 ``outputLinearizer`` 

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

455 ``outputProvenance`` 

456 Provenance data for the new calibration 

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

458 

459 Notes 

460 ----- 

461 This task currently fits only polynomial-defined corrections, 

462 where the correction coefficients are defined such that: 

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

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

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

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

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

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

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

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

471 """ 

472 if len(dummy) == 0: 

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

474 

475 detector = camera[inputDims['detector']] 

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

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

478 tableIndex = 0 

479 else: 

480 table = None 

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

482 

483 # Initialize the linearizer. 

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

485 linearizer.updateMetadataFromExposures([inputPtc]) 

486 if self.config.usePhotodiode and self.config.applyPhotodiodeCorrection: 

487 abscissaCorrections = inputPhotodiodeCorrection.abscissaCorrections 

488 

489 groupingValues = _determineInputGroups( 

490 inputPtc, 

491 self.config.doAutoGrouping, 

492 self.config.autoGroupingUseExptime, 

493 self.config.autoGroupingMaxSignalFraction, 

494 self.config.autoGroupingThreshold, 

495 self.config.splineGroupingColumn, 

496 self.config.minPhotodiodeCurrent, 

497 ) 

498 

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

500 if self.config.doSplineFitTemperature: 

501 if self.config.splineFitTemperatureColumn not in inputPtc.auxValues: 

502 raise ValueError("Config requests fitting temperature coefficient for " 

503 f"{self.config.splineFitTemperatureColumn} but this column " 

504 "is not available in inputPtc.auxValues.") 

505 temperatureValues = inputPtc.auxValues[self.config.splineFitTemperatureColumn] 

506 else: 

507 temperatureValues = None 

508 

509 # We set this to have a value to fill the bad amps. 

510 fitOrder = self.config.splineKnots 

511 else: 

512 fitOrder = self.config.polynomialOrder 

513 

514 for i, amp in enumerate(detector): 

515 ampName = amp.getName() 

516 

517 # Save the input gains 

518 linearizer.inputGain[ampName] = inputPtc.gain[ampName] 

519 

520 if ampName in inputPtc.badAmps: 

521 linearizer = self.fillBadAmp(linearizer, fitOrder, inputPtc, amp) 

522 self.log.warning("Amp %s in detector %s has no usable PTC information. Skipping!", 

523 ampName, detector.getName()) 

524 continue 

525 

526 # Check for too few points. 

527 if self.config.linearityType == "Spline" \ 

528 and self.config.splineGroupingColumn is not None \ 

529 and len(inputPtc.inputExpIdPairs[ampName]) < self.config.splineGroupingMinPoints: 

530 raise RuntimeError( 

531 "The input PTC has too few points to reliably run with PD grouping. " 

532 "The recommended course of action is to set splineGroupingColumn to None. " 

533 "If you really know what you are doing, you may reduce " 

534 "config.splineGroupingMinPoints.") 

535 

536 # We start with all finite values. 

537 mask = np.isfinite(inputPtc.rawMeans[ampName]) 

538 

539 if self.config.linearityType == "Spline" and temperatureValues is not None: 

540 mask &= np.isfinite(temperatureValues) 

541 

542 if self.config.usePhotodiode: 

543 modExpTimes = inputPtc.photoCharges[ampName].copy() 

544 # Make sure any exposure pairs that do not have photodiode data 

545 # are masked. 

546 mask[~np.isfinite(modExpTimes)] = False 

547 

548 # Make sure any photodiode measurements below the configured 

549 # minimum are masked. 

550 mask[modExpTimes < self.config.minPhotodiodeCurrent] = False 

551 

552 # Get the photodiode correction. 

553 if self.config.applyPhotodiodeCorrection: 

554 for j, pair in enumerate(inputPtc.inputExpIdPairs[ampName]): 

555 try: 

556 correction = abscissaCorrections[str(pair)] 

557 except KeyError: 

558 correction = 0.0 

559 modExpTimes[j] += correction 

560 

561 inputAbscissa = modExpTimes 

562 else: 

563 inputAbscissa = inputPtc.rawExpTimes[ampName].copy() 

564 

565 # Normalize if configured. 

566 inputNorm = np.ones_like(inputAbscissa) 

567 if self.config.useFocalPlaneNormalization: 

568 exposures = np.asarray(inputPtc.inputExpIdPairs[ampName])[:, 0] 

569 a, b = esutil.numpy_util.match(exposures, inputNormalization["exposure"]) 

570 inputNorm[a] = inputNormalization["normalization"][b] 

571 inputAbscissa *= inputNorm 

572 

573 # Compute linearityTurnoff and linearitySignalMax. 

574 turnoffMask = inputPtc.expIdMask[ampName].copy() 

575 turnoffMask &= mask 

576 

577 turnoffIndex, turnoff, maxSignal, _ = _computeTurnoffAndMax( 

578 inputAbscissa, 

579 inputPtc.rawMeans[ampName], 

580 turnoffMask, 

581 groupingValues, 

582 ampName=ampName, 

583 minSignalFitLinearityTurnoff=self.config.minSignalFitLinearityTurnoff, 

584 maxFracLinearityDeviation=self.config.maxFracLinearityDeviation, 

585 log=self.log, 

586 use_all_for_normalization=True, 

587 ) 

588 

589 if np.isnan(turnoff): 

590 # This is a bad amp, with no linearizer. 

591 linearizer = self.fillBadAmp(linearizer, fitOrder, inputPtc, amp) 

592 self.log.warning("Amp %s in detector %s has no usable linearizer information. Skipping!", 

593 ampName, detector.getName()) 

594 continue 

595 

596 linearizer.linearityTurnoff[ampName] = turnoff 

597 linearizer.linearityMaxSignal[ampName] = maxSignal 

598 

599 inputOrdinate = inputPtc.rawMeans[ampName].copy() 

600 

601 linearizer.inputAbscissa[ampName] = inputAbscissa.copy() 

602 linearizer.inputOrdinate[ampName] = inputOrdinate.copy() 

603 linearizer.inputGroupingIndex[ampName] = groupingValues.copy() 

604 linearizer.inputNormalization[ampName] = inputNorm.copy() 

605 

606 if self.config.linearityType != 'Spline': 

607 mask &= (inputOrdinate < self.config.maxLinearAdu) 

608 else: 

609 # For spline fits, cut above the turnoff. 

610 self.log.info("Using linearityTurnoff of %.4f adu for amplifier %s", turnoff, ampName) 

611 extraMask = np.ones(len(inputOrdinate), dtype=bool) 

612 extraMask[turnoffIndex + 1:] = False 

613 mask &= extraMask 

614 

615 mask &= (inputOrdinate > self.config.minLinearAdu) 

616 

617 # Initial value for the mask. 

618 linearizer.inputMask[ampName] = mask.copy() 

619 

620 if mask.sum() < 2: 

621 linearizer = self.fillBadAmp(linearizer, fitOrder, inputPtc, amp) 

622 self.log.warning("Amp %s in detector %s has not enough points for fit. Skipping!", 

623 ampName, detector.getName()) 

624 continue 

625 

626 if self.config.linearityType != 'Spline': 

627 linearFit, linearFitErr, chiSq, weights = irlsFit([0.0, 100.0], inputAbscissa[mask], 

628 inputOrdinate[mask], funcPolynomial) 

629 

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

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

632 # Exclude low end outliers. 

633 # This is compared to the original values. 

634 threshold = self.config.nSigmaClipLinear * np.sqrt(abs(inputOrdinate)) 

635 

636 mask[np.abs(inputOrdinate - linearOrdinate) >= threshold] = False 

637 

638 linearizer.inputMask[ampName] = mask.copy() 

639 

640 if mask.sum() < 2: 

641 linearizer = self.fillBadAmp(linearizer, fitOrder, inputPtc, amp) 

642 self.log.warning("Amp %s in detector %s has not enough points in linear ordinate. " 

643 "Skipping!", ampName, detector.getName()) 

644 continue 

645 

646 self.debugFit('linearFit', inputAbscissa, inputOrdinate, linearOrdinate, mask, ampName) 

647 

648 # Do fits 

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

650 polyFit = np.zeros(fitOrder + 1) 

651 polyFit[1] = 1.0 

652 polyFit, polyFitErr, chiSq, weights = irlsFit(polyFit, linearOrdinate[mask], 

653 inputOrdinate[mask], funcPolynomial) 

654 

655 # Truncate the polynomial fit to the squared term. 

656 k1 = polyFit[1] 

657 linearityCoeffs = np.array( 

658 [-coeff/(k1**order) for order, coeff in enumerate(polyFit)] 

659 )[2:] 

660 significant = np.where(np.abs(linearityCoeffs) > 1e-10) 

661 self.log.info("Significant polynomial fits: %s", significant) 

662 

663 modelOrdinate = funcPolynomial(polyFit, linearOrdinate) 

664 

665 self.debugFit( 

666 'polyFit', 

667 inputAbscissa[mask], 

668 inputOrdinate[mask], 

669 modelOrdinate[mask], 

670 None, 

671 ampName, 

672 ) 

673 

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

675 # The first term is the squared term. 

676 linearityCoeffs = linearityCoeffs[0: 1] 

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

678 # Use linear part to get time at which signal is 

679 # maxAduForLookupTableLinearizer DN 

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

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

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

683 signalUncorrected = funcPolynomial(polyFit, timeRange) 

684 lookupTableRow = signalIdeal - signalUncorrected # LinearizerLookupTable has correction 

685 

686 linearizer.tableData[tableIndex, :] = lookupTableRow 

687 linearityCoeffs = np.array([tableIndex, 0]) 

688 tableIndex += 1 

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

690 # This is a spline fit with photodiode data based on a model 

691 # from Pierre Astier. 

692 # This model fits a spline with (optional) nuisance parameters 

693 # to allow for different linearity coefficients with different 

694 # photodiode settings. The minimization is a least-squares 

695 # fit with the residual of 

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

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

698 # mean; D_j is the photo-diode measurement corresponding to 

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

700 # which is over index j as it is allowed to 

701 # be different based on different photodiode settings (e.g. 

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

703 # leaks (and is only fit if doSplineFitOffset=True). In 

704 # addition, if config.doSplineFitTemperature is True then 

705 # the fit will adjust mu such that 

706 # mu = mu_input*(1 + alpha*(T - T_ref)) 

707 # and T_ref is taken as the median temperature of the run. 

708 # Finally, if config.doSplineFitTemporal is True then the 

709 # fit will further adjust mu such that 

710 # mu = mu_input*(1 + beta*(mjd - mjd_ref)) 

711 # and mjd_ref is taken as the median mjd of the run. 

712 # Note that this fit is only valid if the input data 

713 # was taken with a randomly shuffled order of exposure 

714 # levels. 

715 

716 # The fit has additional constraints to ensure that the spline 

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

718 # condition so that the average of the spline over the full 

719 # range is 0. The normalization ensures that the spline only 

720 # fits deviations from linearity, rather than the linear 

721 # function itself which is degenerate with the gain. 

722 

723 # We want to make sure the top node is above the top value 

724 # to avoid edge issues with the top point. 

725 nodes = np.linspace(0.0, np.max(inputOrdinate[mask]) + 1.0, self.config.splineKnots) 

726 

727 if temperatureValues is not None: 

728 temperatureValuesScaled = temperatureValues - np.median(temperatureValues[mask]) 

729 else: 

730 temperatureValuesScaled = None 

731 

732 if self.config.doSplineFitTemporal: 

733 inputMjdScaled = inputPtc.inputExpPairMjdStartList[ampName].copy() 

734 inputMjdScaled -= np.nanmedian(inputMjdScaled) 

735 else: 

736 inputMjdScaled = None 

737 

738 fitter = AstierSplineLinearityFitter( 

739 nodes, 

740 groupingValues, 

741 inputAbscissa, 

742 inputOrdinate, 

743 mask=mask, 

744 log=self.log, 

745 fit_offset=self.config.doSplineFitOffset, 

746 fit_weights=self.config.doSplineFitWeights, 

747 weight_pars_start=self.config.splineFitWeightParsStart, 

748 fit_temperature=self.config.doSplineFitTemperature, 

749 temperature_scaled=temperatureValuesScaled, 

750 max_signal_nearly_linear=inputPtc.ptcTurnoff[ampName], 

751 fit_temporal=self.config.doSplineFitTemporal, 

752 mjd_scaled=inputMjdScaled, 

753 ) 

754 p0 = fitter.estimate_p0(use_all_for_normalization=True) 

755 pars = fitter.fit( 

756 p0, 

757 min_iter=self.config.splineFitMinIter, 

758 max_iter=self.config.splineFitMaxIter, 

759 max_rejection_per_iteration=self.config.splineFitMaxRejectionPerIteration, 

760 n_sigma_clip=self.config.nSigmaClipLinear, 

761 ) 

762 

763 # Confirm that the first parameter is 0, and set it to 

764 # exactly zero. 

765 if not np.isclose(pars[0], 0): 

766 raise RuntimeError("Programmer error! First spline parameter must " 

767 "be consistent with zero.") 

768 pars[0] = 0.0 

769 

770 linearityChisq = fitter.compute_chisq_dof(pars) 

771 

772 linearityCoeffs = np.concatenate([nodes, pars[fitter.par_indices["values"]]]) 

773 linearFit = np.array([0.0, np.mean(pars[fitter.par_indices["groups"]])]) 

774 

775 # We must modify the inputOrdinate according to the 

776 # nuisance terms in the linearity fit for the residual 

777 # computation code to work properly. 

778 # The true mu (inputOrdinate) is given by 

779 # mu = mu_in * (1 + alpha*t_scale) * (1 + beta*mjd_scale) 

780 if self.config.doSplineFitTemperature: 

781 inputOrdinate *= (1.0 

782 + pars[fitter.par_indices["temperature_coeff"]]*temperatureValuesScaled) 

783 if self.config.doSplineFitTemporal: 

784 inputOrdinate *= (1.0 

785 + pars[fitter.par_indices["temporal_coeff"]]*inputMjdScaled) 

786 # We have to adjust the abscissa for the different groups. 

787 # This is because we need a corrected abscissa to get a 

788 # reasonable linear fit to look for residuals, particularly in 

789 # the case of significantly different signal-vs-photodiode or 

790 # signal-vs-exptime scalings for different groups. This then 

791 # becomes a multiplication by the relative scaling of the 

792 # different groups. 

793 for j, group_index in enumerate(fitter.group_indices): 

794 inputAbscissa[group_index] *= (pars[fitter.par_indices["groups"][j]] / linearFit[1]) 

795 # And remove the offset term. 

796 if self.config.doSplineFitOffset: 

797 inputOrdinate -= pars[fitter.par_indices["offset"]] 

798 

799 linearOrdinate = linearFit[1] * inputOrdinate 

800 # For the spline fit, reuse the "polyFit -> fitParams" 

801 # field to record the linear coefficients for the groups. 

802 # We additionally append the offset and weight_pars; 

803 # however these will be zero-length arrays if these were 

804 # not configured to be fit. 

805 polyFit = np.concatenate(( 

806 pars[fitter.par_indices["groups"]], 

807 pars[fitter.par_indices["offset"]], 

808 pars[fitter.par_indices["weight_pars"]], 

809 pars[fitter.par_indices["temperature_coeff"]], 

810 pars[fitter.par_indices["temporal_coeff"]], 

811 )) 

812 polyFitErr = np.zeros_like(polyFit) 

813 chiSq = linearityChisq 

814 

815 # Update mask based on what the fitter rejected. 

816 mask = fitter.mask 

817 

818 linearizer.inputMask[ampName] = mask.copy() 

819 else: 

820 polyFit = np.zeros(1) 

821 polyFitErr = np.zeros(1) 

822 chiSq = np.nan 

823 linearityCoeffs = np.zeros(1) 

824 

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

826 linearizer.linearityCoeffs[ampName] = linearityCoeffs 

827 if self.config.trimmedState: 

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

829 else: 

830 linearizer.linearityBBox[ampName] = amp.getRawBBox() 

831 linearizer.fitParams[ampName] = polyFit 

832 linearizer.fitParamsErr[ampName] = polyFitErr 

833 linearizer.fitChiSq[ampName] = chiSq 

834 linearizer.linearFit[ampName] = linearFit 

835 

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

837 image.array[:, :] = inputOrdinate 

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

839 linearizeFunction()( 

840 image, 

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

842 'table': linearizer.tableData, 

843 'log': linearizer.log} 

844 ) 

845 linearizeModel = image.array[0, :] 

846 

847 # The residuals that we record are the final residuals compared to 

848 # a linear model, after everything has been (properly?) linearized. 

849 if mask.sum() < 2: 

850 self.log.warning("Amp %s in detector %s has not enough points in linear ordinate " 

851 "for residuals. Skipping!", ampName, detector.getName()) 

852 residuals = np.full_like(linearizeModel, np.nan) 

853 residualsUnmasked = residuals.copy() 

854 else: 

855 postLinearFit, _, _, _ = irlsFit( 

856 linearFit, 

857 inputAbscissa[mask], 

858 linearizeModel[mask], 

859 funcPolynomial, 

860 ) 

861 # When computing residuals, we only care about the slope of 

862 # the postLinearFit and not the intercept. The intercept 

863 # itself depends on a possibly unknown zero in the abscissa 

864 # (often photodiode) which may have an arbitrary value. 

865 residuals = linearizeModel - (postLinearFit[1] * inputAbscissa) 

866 residualsUnmasked = residuals.copy() 

867 # We set masked residuals to nan. 

868 residuals[~mask] = np.nan 

869 

870 linearizer.fitResidualsUnmasked[ampName] = residualsUnmasked 

871 linearizer.fitResiduals[ampName] = residuals 

872 linearizer.fitResidualsModel[ampName] = linearizeModel.copy() 

873 

874 finite = np.isfinite(residuals) 

875 if finite.sum() == 0: 

876 sigmad = np.nan 

877 else: 

878 sigmad = median_abs_deviation(residuals[finite]/inputOrdinate[finite], scale="normal") 

879 linearizer.fitResidualsSigmaMad[ampName] = sigmad 

880 

881 self.debugFit( 

882 'solution', 

883 inputOrdinate[mask], 

884 linearOrdinate[mask], 

885 linearizeModel[mask], 

886 None, 

887 ampName, 

888 ) 

889 

890 self.fixupBadAmps(linearizer) 

891 

892 linearizer.hasLinearity = True 

893 linearizer.validate() 

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

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

896 linearizer.updateMetadataFromExposures([inputPtc]) 

897 provenance = IsrProvenance(calibType='linearizer') 

898 

899 return pipeBase.Struct( 

900 outputLinearizer=linearizer, 

901 outputProvenance=provenance, 

902 ) 

903 

904 def fillBadAmp(self, linearizer, fitOrder, inputPtc, amp): 

905 # Need to fill linearizer with empty values 

906 # if the amp is non-functional 

907 ampName = amp.getName() 

908 nEntries = 1 

909 pEntries = 1 

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

911 # We discard the first 2 entries in the polynomial. 

912 nEntries = fitOrder + 1 - 2 

913 pEntries = fitOrder + 1 - 2 

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

915 nEntries = fitOrder * 2 

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

917 nEntries = 1 

918 pEntries = fitOrder + 1 

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

920 nEntries = 2 

921 pEntries = fitOrder + 1 

922 

923 nPair = len(inputPtc.inputExpIdPairs[ampName]) 

924 

925 linearizer.linearityType[ampName] = "None" 

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

927 if self.config.trimmedState: 

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

929 else: 

930 linearizer.linearityBBox[ampName] = amp.getRawBBox() 

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

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

933 linearizer.fitChiSq[ampName] = np.nan 

934 linearizer.fitResiduals[ampName] = np.zeros(nPair) 

935 linearizer.fitResidualsSigmaMad[ampName] = np.nan 

936 linearizer.fitResidualsUnmasked[ampName] = np.zeros(nPair) 

937 linearizer.fitResidualsModel[ampName] = np.zeros(nPair) 

938 linearizer.linearFit[ampName] = np.zeros(2) 

939 linearizer.linearityTurnoff[ampName] = np.nan 

940 linearizer.linearityMaxSignal[ampName] = np.nan 

941 linearizer.inputMask[ampName] = np.zeros(nPair, dtype=np.bool_) 

942 linearizer.inputAbscissa[ampName] = np.zeros(nPair) 

943 linearizer.inputOrdinate[ampName] = np.zeros(nPair) 

944 linearizer.inputGroupingIndex[ampName] = np.zeros(nPair, dtype=np.int64) 

945 linearizer.inputNormalization[ampName] = np.ones(nPair) 

946 

947 return linearizer 

948 

949 def fixupBadAmps(self, linearizer): 

950 """Fix nan padding in bad amplifiers. 

951 

952 Parameters 

953 ---------- 

954 linearizer : `lsst.ip.isr.Linearizer` 

955 """ 

956 fitParamsMaxLen = 0 

957 for ampName in linearizer.ampNames: 

958 if (length := len(linearizer.fitParams[ampName])) > fitParamsMaxLen: 

959 fitParamsMaxLen = length 

960 

961 for ampName in linearizer.ampNames: 

962 if linearizer.linearityType[ampName] == "None": 

963 # Bad amplifier. 

964 linearizer.fitParams[ampName] = np.zeros(fitParamsMaxLen) 

965 linearizer.fitParamsErr[ampName] = np.zeros(fitParamsMaxLen) 

966 elif len(linearizer.fitParams[ampName]) != fitParamsMaxLen: 

967 raise RuntimeError("Linearity has mismatched fitParams; check code/data.") 

968 

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

970 """Debug method for linearity fitting. 

971 

972 Parameters 

973 ---------- 

974 stepname : `str` 

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

976 line of code. 

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

978 The values to use as the independent variable in the 

979 linearity fit. 

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

981 The values to use as the dependent variable in the 

982 linearity fit. 

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

984 The values to use as the linearized result. 

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

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

987 ``yVector`` to keep. 

988 ampName : `str` 

989 Amplifier name to lookup linearity correction values. 

990 """ 

991 frame = getDebugFrame(self._display, stepname) 

992 if frame: 

993 import matplotlib.pyplot as plt 

994 fig, axs = plt.subplots(2) 

995 

996 if mask is None: 

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

998 

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

1000 if stepname == 'linearFit': 

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

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

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

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

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

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

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

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

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

1010 elif stepname == 'solution': 

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

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

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

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

1015 

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

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

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

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

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

1021 

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

1023 fig.tight_layout() 

1024 fig.show() 

1025 

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

1027 while True: 

1028 ans = input(prompt).lower() 

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

1030 break 

1031 elif ans in ("p", ): 

1032 import pdb 

1033 pdb.set_trace() 

1034 elif ans in ("h", ): 

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

1036 elif ans in ('x', ): 

1037 exit() 

1038 plt.close() 

1039 

1040 

1041class LinearityDoubleSplineSolveConnections( 

1042 pipeBase.PipelineTaskConnections, 

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

1044): 

1045 dummy = cT.Input( 

1046 name="raw", 

1047 doc="Dummy exposure.", 

1048 storageClass='Exposure', 

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

1050 multiple=True, 

1051 deferLoad=True, 

1052 ) 

1053 camera = cT.PrerequisiteInput( 

1054 name="camera", 

1055 doc="Camera Geometry definition.", 

1056 storageClass="Camera", 

1057 dimensions=("instrument", ), 

1058 isCalibration=True, 

1059 ) 

1060 inputLinearizerPtc = cT.Input( 

1061 name="linearizerPtc", 

1062 doc="Input linearizer PTC dataset.", 

1063 storageClass="PhotonTransferCurveDataset", 

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

1065 isCalibration=True, 

1066 ) 

1067 inputNormalization = cT.Input( 

1068 name="cpLinearizerPtcNormalization", 

1069 doc="Focal-plane normalization table.", 

1070 storageClass="ArrowAstropy", 

1071 dimensions=("instrument",), 

1072 isCalibration=True, 

1073 ) 

1074 inputBinnedImagesHandles = cT.Input( 

1075 name="cpPtcPairBinned", 

1076 doc="Tabulated binned exposure pairs.", 

1077 storageClass="ArrowAstropy", 

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

1079 multiple=True, 

1080 deferLoad=True, 

1081 ) 

1082 outputLinearizer = cT.Output( 

1083 name="linearity", 

1084 doc="Output linearity measurements.", 

1085 storageClass="Linearizer", 

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

1087 isCalibration=True, 

1088 ) 

1089 

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

1091 super().__init__(config=config) 

1092 

1093 if not config.useFocalPlaneNormalization: 

1094 del self.inputNormalization 

1095 

1096 

1097class LinearityDoubleSplineSolveConfig( 

1098 pipeBase.PipelineTaskConfig, 

1099 pipelineConnections=LinearityDoubleSplineSolveConnections, 

1100): 

1101 maxFracLinearityDeviation = pexConfig.Field( 

1102 dtype=float, 

1103 doc="Maximum fraction deviation from raw linearity to compute " 

1104 "linearityTurnoff and linearityMaxSignal.", 

1105 # TODO: DM-46811 investigate if this can be raised to 0.05. 

1106 default=0.01, 

1107 ) 

1108 minSignalFitLinearityTurnoff = pexConfig.Field( 

1109 dtype=float, 

1110 doc="Minimum signal to compute raw linearity slope for linearityTurnoff.", 

1111 default=1000.0, 

1112 ) 

1113 maxLinearityTurnoffRelativeToPtcTurnoff = pexConfig.Field( 

1114 dtype=float, 

1115 doc="Maximum fractional allowed linearity turnoff relative to the PTC turnoff. Used " 

1116 "to keep extra-high odd values from contaminating the fit.", 

1117 default=1.3, 

1118 ) 

1119 maxNoiseReference = pexConfig.Field( 

1120 dtype=float, 

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

1122 default=12.0, 

1123 ) 

1124 usePhotodiode = pexConfig.Field( 

1125 dtype=bool, 

1126 doc="Use the photodiode info instead of the raw expTimes?", 

1127 default=False, 

1128 ) 

1129 minPhotodiodeCurrent = pexConfig.Field( 

1130 dtype=float, 

1131 doc="Minimum value to trust photodiode signals.", 

1132 default=0.0, 

1133 ) 

1134 doAutoGrouping = pexConfig.Field( 

1135 dtype=bool, 

1136 doc="Do automatic group detection? Cannot be True if splineGroupingColumn is also set. " 

1137 "The automatic group detection will use the ratio of signal to exposure time (if " 

1138 "autoGroupingUseExptime is True) or photodiode (if False) to determine which " 

1139 "flat pairs were taken with different illumination settings.", 

1140 default=False, 

1141 ) 

1142 autoGroupingUseExptime = pexConfig.Field( 

1143 dtype=bool, 

1144 doc="Use exposure time to determine automatic grouping. Used if doAutoGrouping=True.", 

1145 default=True, 

1146 ) 

1147 autoGroupingThreshold = pexConfig.Field( 

1148 dtype=float, 

1149 doc="Minimum relative jump from sorted conversion values to determine a group.", 

1150 default=0.1, 

1151 ) 

1152 autoGroupingMaxSignalFraction = pexConfig.Field( 

1153 dtype=float, 

1154 doc="Only do auto-grouping when the signal is this fraction of the maximum signal. " 

1155 "All exposures with signal higher than this threshold will be put into the " 

1156 "largest signal group. This config is needed if the input PTC goes beyond " 

1157 "the linearity turnoff.", 

1158 default=0.9, 

1159 ) 

1160 groupingColumn = pexConfig.Field( 

1161 dtype=str, 

1162 doc="Column to use for grouping together points, to allow " 

1163 "for different proportionality constants. If None, then grouping will " 

1164 "only be done if doAutoGrouping is True.", 

1165 default=None, 

1166 optional=True, 

1167 ) 

1168 absoluteSplineMinimumSignalNode = pexConfig.Field( 

1169 dtype=float, 

1170 doc="Smallest node (above 0) for absolute spline (adu).", 

1171 default=0.0, 

1172 ) 

1173 absoluteSplineLowThreshold = pexConfig.Field( 

1174 dtype=float, 

1175 doc="Threshold for the low-level linearity nodes for absolute spline (adu). " 

1176 "If this is below ``absoluteSplineMinimumSignalNode`` then the low " 

1177 "level checks will be skipped.", 

1178 default=0.0, 

1179 ) 

1180 absoluteSplineLowNodeSize = pexConfig.Field( 

1181 dtype=float, 

1182 doc="Minimum size for low-level linearity nodes for absolute spline (adu).", 

1183 default=2000.0, 

1184 ) 

1185 absoluteSplineNodeSize = pexConfig.Field( 

1186 dtype=float, 

1187 doc="Minimum size for linearity nodes for absolute spline above absoluteSplineLowThreshold e(adu); " 

1188 "note that there will always be a node at the reference PTC turnoff.", 

1189 default=3000.0, 

1190 ) 

1191 absoluteSplineFitMinIter = pexConfig.Field( 

1192 dtype=int, 

1193 doc="Minimum number of iterations for absolute spline fit.", 

1194 default=3, 

1195 ) 

1196 absoluteSplineFitMaxIter = pexConfig.Field( 

1197 dtype=int, 

1198 doc="Maximum number of iterations for absolute spline fit.", 

1199 default=20, 

1200 ) 

1201 absoluteSplineFitMaxRejectionPerIteration = pexConfig.Field( 

1202 dtype=int, 

1203 doc="Maximum number of rejections per iteration for absolute spline fit.", 

1204 default=5, 

1205 ) 

1206 absoluteNSigmaClipLinear = pexConfig.Field( 

1207 dtype=float, 

1208 doc="Sigma-clipping for absolute spline solution.", 

1209 default=5.0, 

1210 ) 

1211 doAbsoluteSplineFitOffset = pexConfig.Field( 

1212 dtype=bool, 

1213 doc="Fit a scattered light offset in the spline fit.", 

1214 default=True, 

1215 ) 

1216 doAbsoluteSplineFitWeights = pexConfig.Field( 

1217 dtype=bool, 

1218 doc="Fit linearity weight parameters in the spline fit.", 

1219 default=False, 

1220 ) 

1221 absoluteSplineFitWeightParsStart = pexConfig.ListField( 

1222 dtype=float, 

1223 doc="Starting parameters for weight fit, if doSplineFitWeights=True. " 

1224 "Parameters are such that sigma = sqrt(par[0]**2. + par[1]**2./mu)." 

1225 "If doSplineFitWeights=False then these are used as-is; otherwise " 

1226 "they are used as the initial values for fitting these parameters.", 

1227 length=2, 

1228 default=[1.0, 0.0], 

1229 ) 

1230 doAbsoluteSplineFitTemperature = pexConfig.Field( 

1231 dtype=bool, 

1232 doc="Fit temperature coefficient in spline fit?", 

1233 default=False, 

1234 ) 

1235 absoluteSplineFitTemperatureColumn = pexConfig.Field( 

1236 dtype=str, 

1237 doc="Name of the temperature column to use when fitting temperature " 

1238 "coefficients in spline fit; this must not be None if " 

1239 "doSplineFitTemperature is True.", 

1240 default=None, 

1241 optional=True, 

1242 ) 

1243 doAbsoluteSplineFitTemporal = pexConfig.Field( 

1244 dtype=bool, 

1245 doc="Fit a linear temporal parameter coefficient in spline fit?", 

1246 default=False, 

1247 ) 

1248 useFocalPlaneNormalization = pexConfig.Field( 

1249 dtype=bool, 

1250 doc="Use focal-plane normalization in addition to/instead of photodiode? " 

1251 "(Only used with for absolute spline fitting).", 

1252 default=False, 

1253 ) 

1254 relativeSplineReferenceCounts = pexConfig.Field( 

1255 dtype=float, 

1256 doc="Number of target counts (adu) to select a reference image for " 

1257 "relative spline solution.", 

1258 default=10000.0, 

1259 ) 

1260 relativeSplineMinimumSignalNode = pexConfig.Field( 

1261 dtype=float, 

1262 doc="Smallest node (above 0) for relative spline (adu).", 

1263 default=100.0, 

1264 ) 

1265 relativeSplineLowThreshold = pexConfig.Field( 

1266 dtype=float, 

1267 doc="Threshold for the low-level linearity nodes for relative spline (adu)." 

1268 "If this is below ``relativeSplineMinimumSignalNode`` then the low " 

1269 "level checks will be skipped.", 

1270 default=5000.0, 

1271 ) 

1272 relativeSplineLowNodeSize = pexConfig.Field( 

1273 dtype=float, 

1274 doc="Minimum size for low-level linearity nodes for relative spline (adu).", 

1275 default=750.0, 

1276 ) 

1277 relativeSplineMidNodeSize = pexConfig.Field( 

1278 dtype=float, 

1279 doc="Minimum size for mid-level linearity nodes for relative spline (adu); " 

1280 "this applies to counts between relativeSplineLowThreshold and the " 

1281 "PTC turnoff.", 

1282 default=5000.0, 

1283 ) 

1284 relativeSplineHighNodeSize = pexConfig.Field( 

1285 dtype=float, 

1286 doc="Minimum size for high-level linearity nodes for relative spline (adu); " 

1287 "this applies to counts between the PTC and linearity turnoffs.", 

1288 default=2000.0, 

1289 ) 

1290 relativeSplineFitMinIter = pexConfig.Field( 

1291 dtype=int, 

1292 doc="Minimum number of iterations for relative spline fit.", 

1293 default=3, 

1294 ) 

1295 relativeSplineFitMaxIter = pexConfig.Field( 

1296 dtype=int, 

1297 doc="Maximum number of iterations for relative spline fit.", 

1298 default=20, 

1299 ) 

1300 relativeSplineFitMaxRejectionPerIteration = pexConfig.Field( 

1301 dtype=int, 

1302 doc="Maximum number of rejections per iteration for relative spline fit.", 

1303 default=5, 

1304 ) 

1305 relativeNSigmaClipLinear = pexConfig.Field( 

1306 dtype=float, 

1307 doc="Sigma-clipping for relative spline solution.", 

1308 default=5.0, 

1309 ) 

1310 

1311 def validate(self): 

1312 super().validate() 

1313 

1314 if self.doAbsoluteSplineFitTemperature and self.absoluteSplineFitTemperatureColumn is None: 

1315 raise ValueError( 

1316 "Must set absoluteSplineFitTemperatureColumn if doAbsoluteSplineFitTemperature is True.", 

1317 ) 

1318 

1319 if self.doAutoGrouping and self.groupingColumn is not None: 

1320 raise ValueError("Must not set doAutoGrouping and also groupingColumn") 

1321 if self.doAutoGrouping: 

1322 if not self.autoGroupingUseExptime and not self.usePhotodiode: 

1323 raise ValueError("If doAutoGrouping is True and autoGroupingUseExptime is False, then " 

1324 "usePhotodiode must be True.") 

1325 

1326 

1327class LinearityDoubleSplineSolveTask(pipeBase.PipelineTask): 

1328 ConfigClass = LinearityDoubleSplineSolveConfig 

1329 _DefaultName = "cpLinearityDoubleSplineSolve" 

1330 

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

1332 # docstring inherited 

1333 inputs = butlerQC.get(inputRefs) 

1334 

1335 if self.config.useFocalPlaneNormalization: 

1336 inputNormalization = inputs["inputNormalization"] 

1337 else: 

1338 inputNormalization = None 

1339 

1340 # Add calibration provenance info to header. 

1341 kwargs = dict() 

1342 reference = getattr(inputRefs, "inputLinearizerPtc", None) 

1343 

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

1345 runKey = "PTC_RUN" 

1346 runValue = reference.run 

1347 idKey = "PTC_UUID" 

1348 idValue = str(reference.id) 

1349 dateKey = "PTC_DATE" 

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

1351 dateValue = extractCalibDate(calib) 

1352 

1353 kwargs[runKey] = runValue 

1354 kwargs[idKey] = idValue 

1355 kwargs[dateKey] = dateValue 

1356 

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

1358 

1359 outputs = self.run( 

1360 inputPtc=inputs["inputLinearizerPtc"], 

1361 camera=inputs["camera"], 

1362 inputBinnedImagesHandles=inputs["inputBinnedImagesHandles"], 

1363 inputNormalization=inputNormalization, 

1364 ) 

1365 outputs.outputLinearizer.updateMetadata(setDate=False, **kwargs) 

1366 

1367 butlerQC.put(outputs, outputRefs) 

1368 

1369 def run( 

1370 self, 

1371 *, 

1372 inputPtc, 

1373 camera, 

1374 inputBinnedImagesHandles, 

1375 inputNormalization, 

1376 ): 

1377 """Fit the double-spline relative/absolute linearity correction. 

1378 

1379 Parameters 

1380 ---------- 

1381 inputPtc : `lsst.ip.isr.PtcDataset` 

1382 Pre-measured PTC dataset. 

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

1384 Camera geometry. 

1385 inputBinnedImagesHandles : `list` [`DeferredDatasetHandle`] 

1386 Handles for input binned image pairs. 

1387 inputNormalization : `astropy.table.Table`, optional 

1388 Focal plane normalization table to use if 

1389 useFocalPlaneNormalization is True. 

1390 

1391 Returns 

1392 ------- 

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

1394 The results struct containing: 

1395 

1396 ``outputLinearizer`` 

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

1398 ``outputProvenance`` 

1399 Provenance data for the new calibration 

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

1401 """ 

1402 detector = camera[inputPtc.metadata["DETECTOR"]] 

1403 

1404 binnedImagesHandleDict = { 

1405 handle.dataId["exposure"]: handle for handle in inputBinnedImagesHandles 

1406 } 

1407 

1408 linearizer = Linearizer(detector=detector, log=self.log) 

1409 linearizer.updateMetadataFromExposures([inputPtc]) 

1410 

1411 groupingValues = _determineInputGroups( 

1412 inputPtc, 

1413 self.config.doAutoGrouping, 

1414 self.config.autoGroupingUseExptime, 

1415 self.config.autoGroupingMaxSignalFraction, 

1416 self.config.autoGroupingThreshold, 

1417 self.config.groupingColumn, 

1418 self.config.minPhotodiodeCurrent, 

1419 ) 

1420 

1421 if self.config.doAbsoluteSplineFitTemperature: 

1422 if self.config.absoluteSplineFitTemperatureColumn not in inputPtc.auxValues: 

1423 raise ValueError( 

1424 "Config requests fitting temperature coefficient for " 

1425 f"{self.config.splineFitTemperatureColumn} but this column " 

1426 "is not available in inputPtc.auxValues.", 

1427 ) 

1428 temperatureValues = inputPtc.auxValues[self.config.splineFitTemperatureColumn] 

1429 else: 

1430 temperatureValues = None 

1431 

1432 # Fill the linearizer with empty values. 

1433 firstAmp = None 

1434 for ampName in inputPtc.ampNames: 

1435 if ampName not in inputPtc.badAmps: 

1436 firstAmp = ampName 

1437 break 

1438 if firstAmp is None: 

1439 raise pipeBase.NoWorkFound("No valid amps in input PTC.") 

1440 nExp = len(inputPtc.inputExpIdPairs[firstAmp]) * 2 

1441 nAmp = len(inputPtc.ampNames) 

1442 

1443 for amp in detector: 

1444 ampName = amp.getName() 

1445 

1446 linearizer.inputGain[ampName] = inputPtc.gain[ampName] 

1447 linearizer.linearityType[ampName] = "None" 

1448 linearizer.linearityCoeffs[ampName] = np.zeros(1) 

1449 # This is not used; kept for compatibility. 

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

1451 linearizer.fitParams[ampName] = np.zeros(1) 

1452 linearizer.fitParamsErr[ampName] = np.zeros(1) 

1453 linearizer.fitChiSq[ampName] = np.nan 

1454 linearizer.fitResiduals[ampName] = np.zeros(nExp) 

1455 linearizer.fitResidualsSigmaMad[ampName] = np.nan 

1456 linearizer.fitResidualsUnmasked[ampName] = np.zeros(nExp) 

1457 linearizer.fitResidualsModel[ampName] = np.zeros(nExp) 

1458 linearizer.linearFit[ampName] = np.zeros(2) 

1459 linearizer.linearityTurnoff[ampName] = np.nan 

1460 linearizer.linearityMaxSignal[ampName] = np.nan 

1461 linearizer.inputMask[ampName] = np.zeros(nExp, dtype=np.bool_) 

1462 linearizer.inputAbscissa[ampName] = np.zeros(nExp) 

1463 linearizer.inputOrdinate[ampName] = np.zeros(nExp) 

1464 linearizer.inputGroupingIndex[ampName] = np.zeros(nExp, dtype=np.int64) 

1465 linearizer.inputNormalization[ampName] = np.ones(nExp) 

1466 

1467 linearizer.absoluteReferenceAmplifier = "" 

1468 

1469 # Extract values in common, and per-amp. 

1470 data = np.zeros( 

1471 nExp, 

1472 dtype=[ 

1473 ("exp_id", "i8"), 

1474 ("exptime", "f8"), 

1475 ("photocharge", "f8"), 

1476 ("mjd", "f8"), 

1477 ("raw_mean", ("f8", nAmp)), 

1478 ("abscissa", "f8"), 

1479 ("grouping", "i4"), 

1480 # The following are computed in the relative scaling 

1481 # measurements. 

1482 ("ref_counts", "f8"), 

1483 ("gain_ratio", ("f8", nAmp)), 

1484 ], 

1485 ) 

1486 

1487 data["exp_id"] = np.asarray(inputPtc.inputExpIdPairs[firstAmp]).ravel() 

1488 data["exptime"] = np.repeat(inputPtc.rawExpTimes[firstAmp], 2) 

1489 data["mjd"] = np.repeat(inputPtc.inputExpPairMjdStartList[firstAmp], 2) 

1490 data["photocharge"] = np.repeat(inputPtc.photoCharges[firstAmp], 2) 

1491 data["photocharge"][::2] -= inputPtc.photoChargeDeltas[firstAmp] / 2. 

1492 data["photocharge"][1::2] += inputPtc.photoChargeDeltas[firstAmp] / 2. 

1493 data["grouping"] = np.repeat(groupingValues, 2) 

1494 

1495 for i, amp in enumerate(detector): 

1496 ampName = amp.getName() 

1497 

1498 data["raw_mean"][:, i] = np.repeat(inputPtc.rawMeans[ampName], 2) 

1499 data["raw_mean"][::2, i] -= inputPtc.rawDeltas[ampName] / 2. 

1500 data["raw_mean"][1::2, i] += inputPtc.rawDeltas[ampName] / 2. 

1501 

1502 if self.config.usePhotodiode: 

1503 data["abscissa"][:] = data["photocharge"] 

1504 

1505 data["abscissa"][data["photocharge"] < self.config.minPhotodiodeCurrent] = np.nan 

1506 else: 

1507 data["abscissa"][:] = data["exptime"] 

1508 

1509 # Normalize if configured. 

1510 inputNorm = np.ones(nExp, dtype=np.float64) 

1511 if self.config.useFocalPlaneNormalization: 

1512 a, b = esutil.numpy_util.match(data["exp_id"], inputNormalization["exposure"]) 

1513 inputNorm[a] = inputNormalization["normalization"][b] 

1514 data["abscissa"] *= inputNorm 

1515 

1516 postTurnoffMasks = {} 

1517 

1518 # Compute linearity turnoff for each amp. 

1519 for i, amp in enumerate(detector): 

1520 ampName = amp.getName() 

1521 

1522 if ampName in inputPtc.badAmps: 

1523 self.log.warning( 

1524 "Amp %s in detector %s has no usable PTC information. Skipping!", 

1525 ampName, 

1526 detector.getName(), 

1527 ) 

1528 continue 

1529 

1530 mask = np.isfinite(data["raw_mean"][:, i]) 

1531 

1532 turnoffMask = np.repeat(inputPtc.expIdMask[ampName], 2) 

1533 turnoffMask &= mask 

1534 

1535 _, turnoff, maxSignal, goodPoints = _computeTurnoffAndMax( 

1536 data["abscissa"], 

1537 data["raw_mean"][:, i], 

1538 turnoffMask, 

1539 data["grouping"], 

1540 ampName=ampName, 

1541 minSignalFitLinearityTurnoff=self.config.minSignalFitLinearityTurnoff, 

1542 maxFracLinearityDeviation=self.config.maxFracLinearityDeviation, 

1543 log=self.log, 

1544 maxTurnoff=inputPtc.ptcTurnoff[ampName] * self.config.maxLinearityTurnoffRelativeToPtcTurnoff, 

1545 ) 

1546 

1547 # Use the goodPoints as an initial estimate of the mask 

1548 # above the turnoff. But we only want to maintain the 

1549 # "high end" outliers. 

1550 postTurnoffMask = goodPoints 

1551 postTurnoffMask[data["raw_mean"][:, i] < np.median(data["raw_mean"][goodPoints, i])] = True 

1552 postTurnoffMasks[ampName] = postTurnoffMask 

1553 

1554 if np.isnan(turnoff): 

1555 # This is a bad amp, with no linearizer. 

1556 self.log.warning( 

1557 "Amp %s in detector %s has no usable linearizer information. Skipping!", 

1558 ampName, 

1559 detector.getName(), 

1560 ) 

1561 continue 

1562 

1563 linearizer.linearityTurnoff[ampName] = turnoff 

1564 linearizer.linearityMaxSignal[ampName] = maxSignal 

1565 

1566 self.log.info("Amplifier %s has a linearity turnoff of %.2f adu.", ampName, turnoff) 

1567 

1568 # Choose the reference amplifier as the one with the largest 

1569 # turnoff. This ensures that the absolute fit covers the full 

1570 # range. We additionally confirm that the ptc turnoff is 

1571 # finite for this amplifier. 

1572 turnoffArray = np.asarray([linearizer.linearityTurnoff[ampName] for ampName in inputPtc.ampNames]) 

1573 # This is a possibly redundant check to make sure that a bad amp is 

1574 # not chosen as a reference amp. We also check that a high noise 

1575 # amp is not chosen as the reference amp. 

1576 for i, ampName in enumerate(inputPtc.ampNames): 

1577 if ampName in inputPtc.badAmps \ 

1578 or not np.isfinite(inputPtc.ptcTurnoff[ampName]) \ 

1579 or inputPtc.noise[ampName] > self.config.maxNoiseReference: 

1580 turnoffArray[i] = np.nan 

1581 

1582 if np.all(~np.isfinite(turnoffArray)): 

1583 # Return the default blank linearizer. 

1584 linearizer.hasLinearity = True 

1585 linearizer.validate() 

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

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

1588 linearizer.updateMetadataFromExposures([inputPtc]) 

1589 provenance = IsrProvenance(calibType='linearizer') 

1590 

1591 return pipeBase.Struct( 

1592 outputLinearizer=linearizer, 

1593 outputProvenance=provenance, 

1594 ) 

1595 

1596 refAmpIndex = np.argmax(np.nan_to_num(turnoffArray)) 

1597 refAmpName = inputPtc.ampNames[refAmpIndex] 

1598 linearizer.absoluteReferenceAmplifier = refAmpName 

1599 

1600 # Choose a reference image. 

1601 refExpIndex = np.argmin( 

1602 np.abs( 

1603 np.nan_to_num(data["raw_mean"][:, refAmpIndex]) - self.config.relativeSplineReferenceCounts 

1604 ) 

1605 ) 

1606 refExpId = data["exp_id"][refExpIndex] 

1607 

1608 self.log.info( 

1609 "Using exposure %d (%.2f adu in amp %s) as reference.", 

1610 refExpId, 

1611 data["raw_mean"][refExpIndex, refAmpIndex], 

1612 refAmpName, 

1613 ) 

1614 

1615 # We need to know if the reference exposure is the first or second 

1616 # in the pair, because the binned are pairs. 

1617 offset = refExpIndex % 2 

1618 

1619 refBinned = binnedImagesHandleDict[data["exp_id"][refExpIndex - offset]].get() 

1620 refBinned = copy.copy(refBinned) 

1621 if offset == 0: 

1622 refBinned["value"] = refBinned["value1"] 

1623 else: 

1624 refBinned["value"] = refBinned["value2"] 

1625 

1626 # Scale reference according to reference amplifier. 

1627 refScaling = np.median(refBinned["value"][refBinned["amp_index"] == refAmpIndex]) 

1628 refBinned["value"] /= refScaling 

1629 

1630 # Get the invidual amp scalings. 

1631 # These are the relative gains for the reference image. 

1632 ampScalings = np.asarray( 

1633 [ 

1634 np.median(refBinned["value"][refBinned["amp_index"] == ampIndex]) 

1635 for ampIndex in range(nAmp) 

1636 ], 

1637 ) 

1638 

1639 # Compute the gain ratios for every exposure. 

1640 # The binned images are stored as pairs. 

1641 self.log.info("Computing gain ratios for %d exposures.", len(data)) 

1642 for i in range(len(data)): 

1643 expId = data["exp_id"][i] 

1644 if (i % 2) == 0: 

1645 binned = binnedImagesHandleDict[expId].get() 

1646 binned["value"] = binned["value1"] 

1647 else: 

1648 binned["value"] = binned["value2"] 

1649 

1650 binned["value"] /= refBinned["value"] 

1651 

1652 gainRatios = np.asarray( 

1653 [ 

1654 np.median(binned["value"][binned["amp_index"] == ampIndex]) 

1655 for ampIndex in range(nAmp) 

1656 ] 

1657 ) 

1658 ref_counts = gainRatios[refAmpIndex] 

1659 gainRatios /= ref_counts 

1660 

1661 data["ref_counts"][i] = ref_counts 

1662 data["gain_ratio"][i, :] = gainRatios 

1663 

1664 # We need to know which group has the largest size. 

1665 groupAmplitudes = np.zeros(len(np.unique(data["grouping"]))) 

1666 for g in range(len(groupAmplitudes)): 

1667 use = (data["grouping"] == g) 

1668 groupAmplitudes[g] = np.nanmax(data["ref_counts"][use]) - np.nanmin(data["ref_counts"][use]) 

1669 maxAmplitudeGroup = np.argmax(groupAmplitudes) 

1670 

1671 self.log.info("Illumination group %d has the largest signal amplitude.", maxAmplitudeGroup) 

1672 

1673 # Compute relative linearization first. 

1674 maxRelNodes = 0 

1675 

1676 for i, amp in enumerate(detector): 

1677 if i == refAmpIndex: 

1678 continue 

1679 

1680 ampName = amp.getName() 

1681 

1682 ptcTurnoff = inputPtc.ptcTurnoff[ampName] 

1683 linearityTurnoff = linearizer.linearityTurnoff[ampName] 

1684 

1685 if not np.isfinite(ptcTurnoff) or not np.isfinite(linearityTurnoff): 

1686 # This is a bad amp; skip it. 

1687 continue 

1688 

1689 if ptcTurnoff < self.config.relativeSplineLowThreshold: 

1690 lowThreshold = 0.0 

1691 else: 

1692 lowThreshold = self.config.relativeSplineLowThreshold 

1693 

1694 relAbscissa = data["ref_counts"] * ampScalings[i] 

1695 relOrdinate = data["ref_counts"] * data["gain_ratio"][:, i] * ampScalings[i] 

1696 

1697 # The mask here must exclude everything beyond the turnoff. 

1698 # Note that we need to do this before we use the actual 

1699 # turnoff to compute the nodes to avoid nodes going past the 

1700 # data domain. 

1701 relMask = ( 

1702 postTurnoffMasks[ampName] 

1703 & np.isfinite(relAbscissa) 

1704 & np.isfinite(relOrdinate) 

1705 & (relOrdinate < linearityTurnoff) 

1706 ) 

1707 

1708 # Make sure that the linearity turnoff used here does not 

1709 # go beyond the max value of the relOrdinate 

1710 relTurnoff = min(linearityTurnoff, np.max(relOrdinate[relMask])) 

1711 

1712 relNodes = _noderator( 

1713 lowThreshold, 

1714 ptcTurnoff, 

1715 relTurnoff, 

1716 self.config.relativeSplineMinimumSignalNode, 

1717 self.config.relativeSplineLowNodeSize, 

1718 self.config.relativeSplineMidNodeSize, 

1719 self.config.relativeSplineHighNodeSize, 

1720 ) 

1721 

1722 self.log.info( 

1723 "Relative linearity for amplifier %s using %d nodes from %.2f to %.2f counts.", 

1724 ampName, 

1725 len(relNodes), 

1726 relNodes[0], 

1727 relNodes[-1], 

1728 ) 

1729 

1730 # Update the number of relative nodes to concatenation. 

1731 if len(relNodes) > maxRelNodes: 

1732 maxRelNodes = len(relNodes) 

1733 

1734 linearizer.inputMask[ampName] = relMask.copy() 

1735 linearizer.inputAbscissa[ampName] = relAbscissa.copy() 

1736 linearizer.inputOrdinate[ampName] = relOrdinate.copy() 

1737 linearizer.inputGroupingIndex[ampName] = data["grouping"].copy() 

1738 linearizer.inputNormalization[ampName] = np.ones_like(relAbscissa) 

1739 

1740 fitter = AstierSplineLinearityFitter( 

1741 relNodes, 

1742 data["grouping"], 

1743 relAbscissa, 

1744 relOrdinate, 

1745 mask=relMask, 

1746 fit_offset=False, 

1747 fit_weights=False, 

1748 fit_temperature=False, 

1749 max_signal_nearly_linear=ptcTurnoff, 

1750 fit_temporal=False, 

1751 # Put a cap on the maximum correction in absolute value. 

1752 max_frac_correction=np.inf, 

1753 max_correction=10_000.0, 

1754 ) 

1755 p0 = fitter.estimate_p0() 

1756 pars = fitter.fit( 

1757 p0, 

1758 min_iter=self.config.relativeSplineFitMinIter, 

1759 max_iter=self.config.relativeSplineFitMaxIter, 

1760 max_rejection_per_iteration=self.config.relativeSplineFitMaxRejectionPerIteration, 

1761 n_sigma_clip=self.config.relativeNSigmaClipLinear, 

1762 ) 

1763 

1764 # Confirm that the first parameter is 0, and set it to 

1765 # exactly zero. 

1766 relValues = pars[fitter.par_indices["values"]] 

1767 if not np.isclose(relValues[0], 0): 

1768 raise RuntimeError("Programmer error! First spline parameter must " 

1769 "be consistent with zero.") 

1770 relValues[0] = 0.0 

1771 

1772 if np.any(np.abs(pars[fitter.par_indices["values"]]) > 10_000.0): 

1773 self.log.warning("Unconstrained nodes escaped containment; clipping.") 

1774 lo = (pars[fitter.par_indices["values"]] < -10_000.0) 

1775 if np.sum(lo) > 0: 

1776 pars[fitter.par_indices["values"][lo]] = -10_000.0 

1777 hi = (pars[fitter.par_indices["values"]] > 10_000.0) 

1778 if np.sum(hi) > 0: 

1779 pars[fitter.par_indices["values"][hi]] = 10_000.0 

1780 

1781 # We adjust the node values according to the slope of the 

1782 # group with the largest amplitude. This removes a degeneracy 

1783 # in the normalization and ensures that the overall linearized 

1784 # correction is as close to the reference as possible. 

1785 relValues -= (1.0 - pars[fitter.par_indices["groups"][maxAmplitudeGroup]]) * relNodes 

1786 

1787 relChisq = fitter.compute_chisq_dof(pars) 

1788 

1789 # Our reference fit is always 1.0 slope. 

1790 relLinearFit = np.array([0.0, 1.0]) 

1791 

1792 # Adjust the abscissa for different groups for residuals. 

1793 for j, groupIndex in enumerate(fitter.group_indices): 

1794 relAbscissa[groupIndex] *= (pars[fitter.par_indices["groups"][j]] / relLinearFit[1]) 

1795 

1796 relMask = fitter.mask 

1797 

1798 # Record values in the linearizer. 

1799 linearizer.linearityType[ampName] = "DoubleSpline" 

1800 # Note that we have a placeholder for the number of nodes in 

1801 # the absolute spline. 

1802 linearizer.linearityCoeffs[ampName] = np.concatenate([[len(relNodes), 0], relNodes, relValues]) 

1803 linearizer.fitChiSq[ampName] = relChisq 

1804 linearizer.linearFit[ampName] = relLinearFit 

1805 

1806 # Compute residuals. 

1807 spl = Akima1DInterpolator(relNodes, relValues, method="akima") 

1808 relOffset = spl(np.clip(relOrdinate, relNodes[0], relNodes[-1])) 

1809 relModel = relOrdinate - relOffset 

1810 

1811 if relMask.sum() < 2: 

1812 self.log.warning("Amp %s in detector %s has not enough points in linear ordinate " 

1813 "for residuals. Skipping!", ampName, detector.getName()) 

1814 relResiduals = np.full_like(relModel, np.nan) 

1815 relResidualsUnmasked = relResiduals.copy() 

1816 else: 

1817 postLinearFit, _, _, _ = irlsFit( 

1818 relLinearFit, 

1819 relAbscissa[relMask], 

1820 relModel[relMask], 

1821 funcPolynomial, 

1822 ) 

1823 # When computing residuals, we only care about the slope of 

1824 # the postLinearFit and not the intercept. The intercept 

1825 # itself depends on a possibly unknown zero in the abscissa 

1826 # (often photodiode) which may have an arbitrary value. 

1827 relResiduals = relModel - (postLinearFit[1] * relAbscissa) 

1828 relResidualsUnmasked = relResiduals.copy() 

1829 # We set masked residuals to nan. 

1830 relResiduals[~relMask] = np.nan 

1831 

1832 linearizer.fitResidualsUnmasked[ampName] = relResidualsUnmasked 

1833 linearizer.fitResiduals[ampName] = relResiduals 

1834 linearizer.fitResidualsModel[ampName] = relModel.copy() 

1835 

1836 finite = np.isfinite(relResiduals) 

1837 if finite.sum() == 0: 

1838 sigmad = np.nan 

1839 else: 

1840 sigmad = median_abs_deviation(relResiduals[finite]/relOrdinate[finite], scale="normal") 

1841 linearizer.fitResidualsSigmaMad[ampName] = sigmad 

1842 

1843 # Now compute absolute linearization. 

1844 

1845 if temperatureValues is not None: 

1846 temperatureValuesScaled = temperatureValues - np.median(temperatureValues) 

1847 else: 

1848 temperatureValuesScaled = None 

1849 

1850 if self.config.doAbsoluteSplineFitTemporal: 

1851 inputMjdScaled = data["mjd"].copy() 

1852 inputMjdScaled -= np.nanmedian(inputMjdScaled) 

1853 else: 

1854 inputMjdScaled = None 

1855 

1856 absAbscissa = data["abscissa"].copy() 

1857 absOrdinate = data["ref_counts"].copy() 

1858 

1859 # These are guaranteed to be finite (as checked previously). 

1860 absPtcTurnoff = inputPtc.ptcTurnoff[refAmpName] 

1861 absLinearityTurnoff = linearizer.linearityTurnoff[refAmpName] 

1862 

1863 if absPtcTurnoff < self.config.absoluteSplineLowThreshold: 

1864 lowThreshold = 0.0 

1865 else: 

1866 lowThreshold = self.config.absoluteSplineLowThreshold 

1867 

1868 # The mask here must exclude everything beyond the turnoff. 

1869 # Note that we need to do this before we use the actual 

1870 # turnoff to compute the nodes to avoid nodes going past the 

1871 # data domain. 

1872 absMask = postTurnoffMasks[refAmpName] & np.isfinite(absAbscissa) & np.isfinite(absOrdinate) 

1873 

1874 absLinearityTurnoff = min(absLinearityTurnoff, np.max(absOrdinate[absMask])) 

1875 

1876 absNodes = _noderator( 

1877 lowThreshold, 

1878 absPtcTurnoff, 

1879 absLinearityTurnoff, 

1880 self.config.absoluteSplineMinimumSignalNode, 

1881 self.config.absoluteSplineLowNodeSize, 

1882 # The medium and high are matched for absolute spline. 

1883 self.config.absoluteSplineNodeSize, 

1884 self.config.absoluteSplineNodeSize, 

1885 ) 

1886 

1887 self.log.info("Absolute linearity for using %d nodes.", len(absNodes)) 

1888 

1889 # We store the absolute residuals with the reference amplifier. 

1890 linearizer.linearityType[refAmpName] = "DoubleSpline" 

1891 linearizer.inputMask[refAmpName] = absMask.copy() 

1892 linearizer.inputAbscissa[refAmpName] = absAbscissa.copy() 

1893 linearizer.inputOrdinate[refAmpName] = absOrdinate.copy() 

1894 linearizer.inputGroupingIndex[refAmpName] = data["grouping"].copy() 

1895 linearizer.inputNormalization[refAmpName] = inputNorm.copy() 

1896 

1897 fitter = AstierSplineLinearityFitter( 

1898 absNodes, 

1899 data["grouping"].copy(), 

1900 absAbscissa, 

1901 absOrdinate, 

1902 mask=absMask, 

1903 log=self.log, 

1904 fit_offset=self.config.doAbsoluteSplineFitOffset, 

1905 fit_weights=self.config.doAbsoluteSplineFitWeights, 

1906 weight_pars_start=self.config.absoluteSplineFitWeightParsStart, 

1907 fit_temperature=self.config.doAbsoluteSplineFitTemperature, 

1908 temperature_scaled=temperatureValuesScaled, 

1909 max_signal_nearly_linear=absPtcTurnoff, 

1910 fit_temporal=self.config.doAbsoluteSplineFitTemporal, 

1911 mjd_scaled=inputMjdScaled, 

1912 ) 

1913 p0 = fitter.estimate_p0(use_all_for_normalization=True) 

1914 pars = fitter.fit( 

1915 p0, 

1916 min_iter=self.config.absoluteSplineFitMinIter, 

1917 max_iter=self.config.absoluteSplineFitMaxIter, 

1918 max_rejection_per_iteration=self.config.absoluteSplineFitMaxRejectionPerIteration, 

1919 n_sigma_clip=self.config.absoluteNSigmaClipLinear, 

1920 ) 

1921 

1922 # Confirm that the first parameter is 0, and set it to 

1923 # exactly zero. 

1924 absValues = pars[fitter.par_indices["values"]] 

1925 if not np.isclose(absValues[0], 0): 

1926 raise RuntimeError("Programmer error! First spline parameter must " 

1927 "be consistent with zero.") 

1928 absValues[0] = 0.0 

1929 

1930 # We need a place to store this. 

1931 linearizer.fitChiSq[refAmpName] = fitter.compute_chisq_dof(pars) 

1932 

1933 absLinearFit = np.array([0.0, np.mean(pars[fitter.par_indices["groups"]])]) 

1934 

1935 # We must modify the inputOrdinate according to the 

1936 # nuisance terms in the linearity fit for the residual 

1937 # computation code to work properly. 

1938 # The true mu (inputOrdinate) is given by 

1939 # mu = mu_in * (1 + alpha*t_scale) * (1 + beta*mjd_scale) 

1940 if self.config.doAbsoluteSplineFitTemperature: 

1941 absOrdinate *= (1.0 

1942 + pars[fitter.par_indices["temperature_coeff"]]*temperatureValuesScaled) 

1943 if self.config.doAbsoluteSplineFitTemporal: 

1944 absOrdinate *= (1.0 

1945 + pars[fitter.par_indices["temporal_coeff"]]*inputMjdScaled) 

1946 

1947 # Adjust the abscissa for different groups for residuals. 

1948 for j, groupIndex in enumerate(fitter.group_indices): 

1949 absAbscissa[groupIndex] *= (pars[fitter.par_indices["groups"][j]] / absLinearFit[1]) 

1950 # And remove the offset term. 

1951 if self.config.doAbsoluteSplineFitOffset: 

1952 absOrdinate -= pars[fitter.par_indices["offset"]] 

1953 

1954 absMask = fitter.mask 

1955 

1956 # Compute residuals. 

1957 spl = Akima1DInterpolator(absNodes, absValues, method="akima") 

1958 absOffset = spl(np.clip(absOrdinate, absNodes[0], absNodes[-1])) 

1959 absModel = absOrdinate - absOffset 

1960 

1961 if absMask.sum() < 2: 

1962 self.log.warning("Detector %s has not enough points in linear ordinate " 

1963 "for residuals. Skipping!", detector.getName()) 

1964 # We have to KICK OUT HERE something is VERY wrong. 

1965 absResiduals = np.full_like(absModel, np.nan) 

1966 absResidualsUnmasked = relResiduals.copy() 

1967 else: 

1968 postLinearFit, _, _, _ = irlsFit( 

1969 absLinearFit, 

1970 absAbscissa[absMask], 

1971 absModel[absMask], 

1972 funcPolynomial, 

1973 ) 

1974 # When computing residuals, we only care about the slope of 

1975 # the postLinearFit and not the intercept. The intercept 

1976 # itself depends on a possibly unknown zero in the abscissa 

1977 # (often photodiode) which may have an arbitrary value. 

1978 absResiduals = absModel - (postLinearFit[1] * absAbscissa) 

1979 absResidualsUnmasked = absResiduals.copy() 

1980 # We set masked residuals to nan. 

1981 absResiduals[~absMask] = np.nan 

1982 

1983 linearizer.fitResidualsUnmasked[refAmpName] = absResidualsUnmasked 

1984 linearizer.fitResiduals[refAmpName] = absResiduals 

1985 linearizer.fitResidualsModel[refAmpName] = absModel.copy() 

1986 

1987 finite = np.isfinite(absResiduals) 

1988 if finite.sum() == 0: 

1989 sigmad = np.nan 

1990 else: 

1991 sigmad = median_abs_deviation(absResiduals[finite]/absOrdinate[finite], scale="normal") 

1992 linearizer.fitResidualsSigmaMad[refAmpName] = sigmad 

1993 

1994 # Record the absolute nodes and values in each individual amplifier, 

1995 # along with extra padding for alignment. 

1996 nAbsNodes = len(absNodes) 

1997 for i, amp in enumerate(detector): 

1998 ampName = amp.getName() 

1999 

2000 coeffs = np.zeros(2 * nAbsNodes + 2 * maxRelNodes + 2) 

2001 if ampName == refAmpName: 

2002 # The reference amplifier only has the absolute spline. 

2003 coeffs[1] = nAbsNodes 

2004 coeffs[2: 2 + 2 * nAbsNodes] = np.concatenate([absNodes, absValues]) 

2005 else: 

2006 nRelNodes = int(linearizer.linearityCoeffs[ampName][0]) 

2007 

2008 coeffs = np.zeros(2 * nAbsNodes + 2 * maxRelNodes + 2) 

2009 coeffs[0] = nRelNodes 

2010 coeffs[1] = nAbsNodes 

2011 relStart = 2 

2012 relEnd = relStart + 2 * nRelNodes 

2013 coeffs[relStart: relEnd] = linearizer.linearityCoeffs[ampName][relStart: relEnd] 

2014 absStart = relEnd 

2015 absEnd = absStart + 2 * nAbsNodes 

2016 coeffs[absStart: absEnd] = np.concatenate([absNodes, absValues]) 

2017 

2018 linearizer.linearityCoeffs[ampName] = coeffs 

2019 

2020 linearizer.hasLinearity = True 

2021 linearizer.validate() 

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

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

2024 linearizer.updateMetadataFromExposures([inputPtc]) 

2025 provenance = IsrProvenance(calibType='linearizer') 

2026 

2027 return pipeBase.Struct( 

2028 outputLinearizer=linearizer, 

2029 outputProvenance=provenance, 

2030 ) 

2031 

2032 

2033def _determineInputGroups( 

2034 ptc, 

2035 doAutoGrouping, 

2036 autoGroupingUseExptime, 

2037 autoGroupingMaxSignalFraction, 

2038 autoGroupingThreshold, 

2039 groupingColumn, 

2040 minPhotodiodeCurrent, 

2041): 

2042 """Determine input groups for linearity fit. 

2043 

2044 If ``splineGroupingColumn`` is set, then grouping will be done 

2045 based on this. Otherwise, if ``doAutoGrouping`` is False, then 

2046 no grouping will be done. Finally, grouping will be done by measuring 

2047 the ratio of signal to exposure time (if 

2048 ``autoGroupingUseExptime`` is set; recommended) or photocharge. 

2049 These are then clustered with a simple algorithm to split into groups. 

2050 If the data was taking by varying exposure time at different 

2051 illumination levels, this grouping is very robust as the clusters are 

2052 very well separated. 

2053 

2054 Parameters 

2055 ---------- 

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

2057 Input PTC to do grouping. 

2058 doAutoGrouping : `bool` 

2059 Do automatic grouping of pairs? 

2060 autoGroupingUseExptime : `bool` 

2061 Use exposure time for automatic grouping of pairs? 

2062 autoGroupingMaxSignalFraction : `float` 

2063 All exposures with signal higher than this threshold will 

2064 be put into the largest signal group. 

2065 autoGroupingThreshold : `float` 

2066 Minimum relative jump from sorted values to determine a group. 

2067 minPhotodiodeCurrent : `float` 

2068 Minimum photodiode current if auto-grouping is used and 

2069 autoGroupingUseExptime is False. 

2070 splineGroupingColumn : `str` or `None` 

2071 Column to be used for spline grouping (if doAutoGrouping is False). 

2072 

2073 Returns 

2074 ------- 

2075 groupingValues : `np.ndarray` 

2076 Array of values that are unique for a given group. 

2077 """ 

2078 nPair = np.asarray(ptc.inputExpIdPairs[ptc.ampNames[0]]).shape[0] 

2079 groupingValues = np.zeros(nPair, dtype=np.int64) 

2080 

2081 if not doAutoGrouping: 

2082 if groupingColumn is not None: 

2083 if groupingColumn not in ptc.auxValues: 

2084 raise ValueError(f"Config requests grouping by {groupingColumn}, " 

2085 "but this column is not available in ptc.auxValues.") 

2086 

2087 uGroupValues = np.unique(ptc.auxValues[groupingColumn]) 

2088 for i, uGroupValue in enumerate(uGroupValues): 

2089 groupingValues[ptc.auxValues[groupingColumn] == uGroupValue] = i 

2090 else: 

2091 means = np.zeros((nPair, len(ptc.ampNames))) 

2092 exptimes = np.zeros_like(means) 

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

2094 means[:, i] = ptc.rawMeans[ampName] * ptc.gain[ampName] 

2095 exptimes[:, i] = ptc.rawExpTimes[ampName] 

2096 detMeans = np.nanmean(means, axis=1) 

2097 detExptimes = np.nanmean(exptimes, axis=1) 

2098 

2099 if autoGroupingUseExptime: 

2100 abscissa = detExptimes 

2101 else: 

2102 abscissa = ptc.photoCharges[ptc.ampNames[0]].copy() 

2103 # Set illegal photocharges to NaN. 

2104 abscissa[abscissa < minPhotodiodeCurrent] = np.nan 

2105 

2106 ratio = detMeans / abscissa 

2107 ratio /= np.nanmedian(ratio) 

2108 

2109 # Adjust those that are above threshold so they fall into the 

2110 # largest group. 

2111 above = (detMeans > autoGroupingMaxSignalFraction*np.nanmax(detMeans)) 

2112 maxIndex = np.argmax(detMeans[~above]) 

2113 ratio[above] = ratio[maxIndex] 

2114 

2115 # The clustering of ratios into groups is performed with a simple 

2116 # algorithm based on sorting and looking for the largest gaps. 

2117 # See https://stackoverflow.com/a/18385795 

2118 st = np.argsort(ratio) 

2119 stratio = ratio[st] 

2120 delta = stratio[1:] - stratio[0: -1] 

2121 

2122 transitions, = np.where(delta > autoGroupingThreshold) 

2123 # If there are no transitions then everything ends up in group 0. 

2124 if len(transitions) > 0: 

2125 ratioCuts = stratio[transitions] + autoGroupingThreshold/2. 

2126 

2127 for i in range(len(transitions) + 1): 

2128 if i == 0: 

2129 inGroup, = np.where(ratio < ratioCuts[i]) 

2130 elif i == len(transitions): 

2131 inGroup, = np.where(ratio > ratioCuts[i - 1]) 

2132 else: 

2133 inGroup, = np.where((ratio > ratioCuts[i - 1]) & (ratio < ratioCuts[i])) 

2134 groupingValues[inGroup] = i 

2135 

2136 # Ensure out-of-range photoCharges/exptimes are in their own group. 

2137 # These are masked later on. 

2138 groupingValues[~np.isfinite(abscissa)] = -1 

2139 # And put the high ones in the max group. 

2140 groupingValues[above] = groupingValues[maxIndex] 

2141 

2142 return groupingValues 

2143 

2144 

2145def _computeTurnoffAndMax( 

2146 abscissa, 

2147 ordinate, 

2148 initialMask, 

2149 groupingValues, 

2150 ampName="UNKNOWN", 

2151 minSignalFitLinearityTurnoff=1000.0, 

2152 maxFracLinearityDeviation=0.01, 

2153 log=None, 

2154 maxTurnoff=np.inf, 

2155 use_all_for_normalization=False, 

2156): 

2157 """Compute the turnoff and max signal. 

2158 

2159 Parameters 

2160 ---------- 

2161 abscissa : `np.ndarray` 

2162 Input x values, either photoCharges or exposure times. 

2163 These should be cleaned of any non-finite values. 

2164 ordinate : `np.ndarray` 

2165 Input y values, the raw mean values for the amp. 

2166 These should be cleaned of any non-finite values. 

2167 initialMask : `np.ndarray` 

2168 Mask to use for initial fit (usually from PTC). 

2169 groupingValues : `np.ndarray` 

2170 Array of values that are used to group different fits. 

2171 ampName : `str`, optional 

2172 Amplifier name (used for logging). 

2173 minSignalFitLinearityTurnoff : `float`, optional 

2174 Minimum signal to cmpute raw linearity slope for linearityTurnoff. 

2175 maxFracLinearityDeviation : `float`, optional 

2176 Maximum fraction deviation from raw linearity to compute turnoff. 

2177 log : `logging.Logger`, optional 

2178 Log object. 

2179 maxTurnoff : `float`, optional 

2180 Maximum turnoff allowed (will be set above PTC turnoff). 

2181 use_all_for_normalization : `bool`, optional 

2182 Use all the points (not just below turnoff) for normalization; 

2183 this is for compatibility with the old linearizer fits. 

2184 

2185 Returns 

2186 ------- 

2187 turnoffIndex : `int` 

2188 Fit turnoff index (keyed to raw input). 

2189 turnoff : `float` 

2190 Fit turnoff value. 

2191 maxSignal : `float` 

2192 Fit maximum signal value. 

2193 goodPoints : `np.ndarray` 

2194 Mask of good points used in turnoff fit. 

2195 """ 

2196 if log is None: 

2197 log = logging.getLogger(__name__) 

2198 

2199 # Follow eo_pipe: 

2200 # https://github.com/lsst-camera-dh/eo_pipe/blob/6afa546569f622b8d604921e248200481c445730/python/lsst/eo/pipe/linearityPlotsTask.py#L50 

2201 # Replacing flux with abscissa, Ne with ordinate. 

2202 

2203 # Fit a line with the y-intercept fixed to zero, using the 

2204 # signal counts Ne as the variance in the chi-square, i.e., 

2205 # chi2 = sum( (ordinate - aa*abscissa)**2/ordinate ) 

2206 # Minimizing chi2 wrt aa, gives 

2207 # aa = sum(abscissa) / sum(abscissa**2/ordinate) 

2208 

2209 fitMask = initialMask.copy() 

2210 fitMask[ordinate < minSignalFitLinearityTurnoff] = False 

2211 fitMask[ordinate > maxTurnoff] = False 

2212 fitMask[~np.isfinite(abscissa) | ~np.isfinite(ordinate)] = False 

2213 goodPoints = fitMask.copy() 

2214 

2215 gValues = np.unique(groupingValues) 

2216 groupIndicesList = [] 

2217 for gValue in gValues: 

2218 use, = np.where(groupingValues == gValue) 

2219 groupIndicesList.append(use) 

2220 

2221 found = False 

2222 firstIteration = True 

2223 while (fitMask.sum() >= 4) and not found: 

2224 residuals = np.zeros_like(ordinate) 

2225 

2226 abscissaMasked = abscissa.copy() 

2227 abscissaMasked[~fitMask] = np.nan 

2228 ordinateMasked = ordinate.copy() 

2229 ordinateMasked[~fitMask] = np.nan 

2230 

2231 for i, groupIndices in enumerate(groupIndicesList): 

2232 num = np.nansum(abscissaMasked[groupIndices]) 

2233 denom = np.nansum(abscissaMasked[groupIndices]**2./ordinateMasked[groupIndices]) 

2234 

2235 if num == 0.0 or denom == 0.0: 

2236 if firstIteration: 

2237 log.info( 

2238 "All points for %s were masked in linearity turnoff for group %d (first iteration).", 

2239 ampName, 

2240 i, 

2241 ) 

2242 # We can try to recover this. 

2243 nTry = min(10, len(groupIndices)) 

2244 num = np.nansum(abscissa[groupIndices][0: nTry]) 

2245 denom = np.nansum(abscissa[groupIndices][0: nTry]**2./ordinate[groupIndices][0: nTry]) 

2246 aa = num / denom 

2247 else: 

2248 log.warning("All points masked in linearity turnoff for group %d.", i) 

2249 aa = np.nan 

2250 else: 

2251 aa = num / denom 

2252 

2253 residuals[groupIndices] = (ordinate[groupIndices] - aa*abscissa[groupIndices]) / \ 

2254 ordinate[groupIndices] 

2255 

2256 # Use the residuals to compute the turnoff. 

2257 if use_all_for_normalization: 

2258 residuals -= np.nanmedian(residuals) 

2259 else: 

2260 # Only subtract off the median from the previously estimated 

2261 # fitMask. 

2262 residuals -= np.nanmedian(residuals[fitMask]) 

2263 

2264 goodPoints = (np.abs(residuals) < maxFracLinearityDeviation) & (ordinate < maxTurnoff) 

2265 

2266 if goodPoints.sum() > 4: 

2267 # This was an adequate fit. 

2268 found = True 

2269 turnoff = np.max(ordinate[goodPoints]) 

2270 turnoffIndex = np.where(np.isclose(ordinate, turnoff))[0][0] 

2271 else: 

2272 # This was a bad fit; remove the largest outlier. 

2273 badIndex = np.argmax(np.abs(residuals)[fitMask]) 

2274 fitIndices, = np.nonzero(fitMask) 

2275 fitMask[fitIndices[badIndex]] = False 

2276 

2277 firstIteration = False 

2278 

2279 if not found: 

2280 # Could not find any reasonable value. 

2281 log.warning( 

2282 "Could not find a reasonable initial linear fit to compute linearity turnoff for " 

2283 "amplifier %s; may need finer sampling of input data?", 

2284 ampName, 

2285 ) 

2286 if np.all(~fitMask): 

2287 return -1, np.nan, np.nan, goodPoints 

2288 

2289 turnoff = np.max(ordinate[fitMask]) 

2290 turnoffIndex = np.where(np.isclose(ordinate, turnoff))[0][0] 

2291 

2292 residuals = np.zeros(len(ordinate)) 

2293 

2294 # Fit the maximum signal. 

2295 if turnoffIndex == (len(residuals) - 1): 

2296 # This is the last point; we can't do a fit. 

2297 # This is not a warning because we do not actually need this 

2298 # value in practice. 

2299 log.info( 

2300 "No linearity turnoff detected for amplifier %s; try to increase the signal range.", 

2301 ampName, 

2302 ) 

2303 maxSignal = ordinate[turnoffIndex] 

2304 else: 

2305 maxSignalInitial = np.nanmax(ordinate) 

2306 

2307 highFluxPoints = (np.nan_to_num(ordinate) 

2308 > (1.0 - maxFracLinearityDeviation)*maxSignalInitial) 

2309 maxSignal = np.median(ordinate[highFluxPoints]) 

2310 

2311 return turnoffIndex, turnoff, maxSignal, goodPoints 

2312 

2313 

2314def _noderator(turnoff0, turnoff1, turnoff2, minNode, lowNodeSize, midNodeSize, highNodeSize): 

2315 """The "noderator" node-finder. 

2316 

2317 Parameters 

2318 ---------- 

2319 turnoff0 : `float` 

2320 Zeroth turnoff value (e.g. expectation of low-level 

2321 non-linearity threshold) (adu). 

2322 turnoff1 : `float` 

2323 First turnoff value (e.g. ptc turnoff) (adu). 

2324 turnoff2 : `float` 

2325 Second turnoff value (e.g. linearity turnoff) (adu). 

2326 minNode : `float` 

2327 Location to place the first node after 0.0 (if this is <= 0.0 

2328 it will be ignored) (adu). 

2329 lowNodeSize : `float` 

2330 Minimum node size in the low-level non-linearity regime 

2331 (below turnoff0) (adu). 

2332 midNodeSize : `float` 

2333 Minimum node size in the mid-level non-linearity regime 

2334 (between turnoff0 and turnoff1) (adu). 

2335 highNodeSize : `float` 

2336 Minimum node size in the high-level non-linearity regime 

2337 (between turnoff1 and turnoff2) (adu). 

2338 

2339 Returns 

2340 ------- 

2341 nodes : `np.ndarray` 

2342 Array of node values (adu). 

2343 """ 

2344 if turnoff0 > minNode: 

2345 # At least 2 nodes (edges) in the low signal regime. 

2346 nNodesLow = np.clip(int(np.ceil((turnoff0 - minNode) / lowNodeSize)), 2, None) 

2347 midStart = turnoff0 

2348 else: 

2349 nNodesLow = 0 

2350 midStart = 0.0 

2351 # At least 5 nodes (akima minimum) in the mid signal regime. 

2352 nNodesMid = np.clip(int(np.ceil((turnoff1 - midStart) / midNodeSize)), 5, None) 

2353 if turnoff2 > turnoff1: 

2354 # At least 2 nodes (edges) in the high signal regime. 

2355 nNodesHigh = np.clip(int(np.ceil((turnoff2 - turnoff1) / highNodeSize)), 3, None) 

2356 else: 

2357 nNodesHigh = 0 

2358 nodesLow = np.linspace(minNode, turnoff0, nNodesLow) 

2359 nodesMid = np.linspace(midStart, turnoff1, nNodesMid) 

2360 nodesHigh = np.linspace(turnoff1, turnoff2, nNodesHigh) 

2361 

2362 # Make sure we do not duplicate nodes when concatenating. 

2363 nodeList = [] 

2364 if nNodesLow > 1: 

2365 nodeList.append([0.0]) 

2366 nodeList.append(nodesLow[:-1]) 

2367 if nNodesMid > 1: 

2368 nodeList.append(nodesMid) 

2369 if nNodesHigh > 1: 

2370 nodeList.append(nodesHigh[1:]) 

2371 return np.concatenate(nodeList)