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

843 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-10-11 02:31 -0700

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 if ampName in inputPtc.badAmps: 

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

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

519 ampName, detector.getName()) 

520 continue 

521 

522 # Check for too few points. 

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

524 and self.config.splineGroupingColumn is not None \ 

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

526 raise RuntimeError( 

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

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

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

530 "config.splineGroupingMinPoints.") 

531 

532 # We start with all finite values. 

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

534 

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

536 mask &= np.isfinite(temperatureValues) 

537 

538 if self.config.usePhotodiode: 

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

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

541 # are masked. 

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

543 

544 # Make sure any photodiode measurements below the configured 

545 # minimum are masked. 

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

547 

548 # Get the photodiode correction. 

549 if self.config.applyPhotodiodeCorrection: 

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

551 try: 

552 correction = abscissaCorrections[str(pair)] 

553 except KeyError: 

554 correction = 0.0 

555 modExpTimes[j] += correction 

556 

557 inputAbscissa = modExpTimes 

558 else: 

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

560 

561 # Normalize if configured. 

562 inputNorm = np.ones_like(inputAbscissa) 

563 if self.config.useFocalPlaneNormalization: 

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

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

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

567 inputAbscissa *= inputNorm 

568 

569 # Compute linearityTurnoff and linearitySignalMax. 

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

571 turnoffMask &= mask 

572 

573 turnoffIndex, turnoff, maxSignal, _ = _computeTurnoffAndMax( 

574 inputAbscissa, 

575 inputPtc.rawMeans[ampName], 

576 turnoffMask, 

577 groupingValues, 

578 ampName=ampName, 

579 minSignalFitLinearityTurnoff=self.config.minSignalFitLinearityTurnoff, 

580 maxFracLinearityDeviation=self.config.maxFracLinearityDeviation, 

581 log=self.log, 

582 ) 

583 

584 if np.isnan(turnoff): 

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

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

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

588 ampName, detector.getName()) 

589 continue 

590 

591 linearizer.linearityTurnoff[ampName] = turnoff 

592 linearizer.linearityMaxSignal[ampName] = maxSignal 

593 

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

595 

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

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

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

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

600 

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

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

603 else: 

604 # For spline fits, cut above the turnoff. 

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

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

607 extraMask[turnoffIndex + 1:] = False 

608 mask &= extraMask 

609 

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

611 

612 # Initial value for the mask. 

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

614 

615 if mask.sum() < 2: 

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

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

618 ampName, detector.getName()) 

619 continue 

620 

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

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

623 inputOrdinate[mask], funcPolynomial) 

624 

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

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

627 # Exclude low end outliers. 

628 # This is compared to the original values. 

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

630 

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

632 

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

634 

635 if mask.sum() < 2: 

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

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

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

639 continue 

640 

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

642 

643 # Do fits 

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

645 polyFit = np.zeros(fitOrder + 1) 

646 polyFit[1] = 1.0 

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

648 inputOrdinate[mask], funcPolynomial) 

649 

650 # Truncate the polynomial fit to the squared term. 

651 k1 = polyFit[1] 

652 linearityCoeffs = np.array( 

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

654 )[2:] 

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

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

657 

658 modelOrdinate = funcPolynomial(polyFit, linearOrdinate) 

659 

660 self.debugFit( 

661 'polyFit', 

662 inputAbscissa[mask], 

663 inputOrdinate[mask], 

664 modelOrdinate[mask], 

665 None, 

666 ampName, 

667 ) 

668 

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

670 # The first term is the squared term. 

671 linearityCoeffs = linearityCoeffs[0: 1] 

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

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

674 # maxAduForLookupTableLinearizer DN 

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

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

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

678 signalUncorrected = funcPolynomial(polyFit, timeRange) 

679 lookupTableRow = signalIdeal - signalUncorrected # LinearizerLookupTable has correction 

680 

681 linearizer.tableData[tableIndex, :] = lookupTableRow 

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

683 tableIndex += 1 

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

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

686 # from Pierre Astier. 

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

688 # to allow for different linearity coefficients with different 

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

690 # fit with the residual of 

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

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

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

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

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

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

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

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

699 # addition, if config.doSplineFitTemperature is True then 

700 # the fit will adjust mu such that 

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

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

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

704 # fit will further adjust mu such that 

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

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

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

708 # was taken with a randomly shuffled order of exposure 

709 # levels. 

710 

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

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

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

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

715 # fits deviations from linearity, rather than the linear 

716 # function itself which is degenerate with the gain. 

717 

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

719 # to avoid edge issues with the top point. 

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

721 

722 if temperatureValues is not None: 

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

724 else: 

725 temperatureValuesScaled = None 

726 

727 if self.config.doSplineFitTemporal: 

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

729 inputMjdScaled -= np.nanmedian(inputMjdScaled) 

730 else: 

731 inputMjdScaled = None 

732 

733 fitter = AstierSplineLinearityFitter( 

734 nodes, 

735 groupingValues, 

736 inputAbscissa, 

737 inputOrdinate, 

738 mask=mask, 

739 log=self.log, 

740 fit_offset=self.config.doSplineFitOffset, 

741 fit_weights=self.config.doSplineFitWeights, 

742 weight_pars_start=self.config.splineFitWeightParsStart, 

743 fit_temperature=self.config.doSplineFitTemperature, 

744 temperature_scaled=temperatureValuesScaled, 

745 max_signal_nearly_linear=inputPtc.ptcTurnoff[ampName], 

746 fit_temporal=self.config.doSplineFitTemporal, 

747 mjd_scaled=inputMjdScaled, 

748 ) 

749 p0 = fitter.estimate_p0() 

750 pars = fitter.fit( 

751 p0, 

752 min_iter=self.config.splineFitMinIter, 

753 max_iter=self.config.splineFitMaxIter, 

754 max_rejection_per_iteration=self.config.splineFitMaxRejectionPerIteration, 

755 n_sigma_clip=self.config.nSigmaClipLinear, 

756 ) 

757 

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

759 # exactly zero. 

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

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

762 "be consistent with zero.") 

763 pars[0] = 0.0 

764 

765 linearityChisq = fitter.compute_chisq_dof(pars) 

766 

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

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

769 

770 # We must modify the inputOrdinate according to the 

771 # nuisance terms in the linearity fit for the residual 

772 # computation code to work properly. 

773 # The true mu (inputOrdinate) is given by 

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

775 if self.config.doSplineFitTemperature: 

776 inputOrdinate *= (1.0 

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

778 if self.config.doSplineFitTemporal: 

779 inputOrdinate *= (1.0 

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

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

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

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

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

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

786 # becomes a multiplication by the relative scaling of the 

787 # different groups. 

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

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

790 # And remove the offset term. 

791 if self.config.doSplineFitOffset: 

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

793 

794 linearOrdinate = linearFit[1] * inputOrdinate 

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

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

797 # We additionally append the offset and weight_pars; 

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

799 # not configured to be fit. 

800 polyFit = np.concatenate(( 

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

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

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

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

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

806 )) 

807 polyFitErr = np.zeros_like(polyFit) 

808 chiSq = linearityChisq 

809 

810 # Update mask based on what the fitter rejected. 

811 mask = fitter.mask 

812 

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

814 else: 

815 polyFit = np.zeros(1) 

816 polyFitErr = np.zeros(1) 

817 chiSq = np.nan 

818 linearityCoeffs = np.zeros(1) 

819 

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

821 linearizer.linearityCoeffs[ampName] = linearityCoeffs 

822 if self.config.trimmedState: 

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

824 else: 

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

826 linearizer.fitParams[ampName] = polyFit 

827 linearizer.fitParamsErr[ampName] = polyFitErr 

828 linearizer.fitChiSq[ampName] = chiSq 

829 linearizer.linearFit[ampName] = linearFit 

830 

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

832 image.array[:, :] = inputOrdinate 

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

834 linearizeFunction()( 

835 image, 

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

837 'table': linearizer.tableData, 

838 'log': linearizer.log} 

839 ) 

840 linearizeModel = image.array[0, :] 

841 

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

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

844 if mask.sum() < 2: 

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

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

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

848 residualsUnmasked = residuals.copy() 

849 else: 

850 postLinearFit, _, _, _ = irlsFit( 

851 linearFit, 

852 inputAbscissa[mask], 

853 linearizeModel[mask], 

854 funcPolynomial, 

855 ) 

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

857 # the postLinearFit and not the intercept. The intercept 

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

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

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

861 residualsUnmasked = residuals.copy() 

862 # We set masked residuals to nan. 

863 residuals[~mask] = np.nan 

864 

865 linearizer.fitResidualsUnmasked[ampName] = residualsUnmasked 

866 linearizer.fitResiduals[ampName] = residuals 

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

868 

869 finite = np.isfinite(residuals) 

870 if finite.sum() == 0: 

871 sigmad = np.nan 

872 else: 

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

874 linearizer.fitResidualsSigmaMad[ampName] = sigmad 

875 

876 self.debugFit( 

877 'solution', 

878 inputOrdinate[mask], 

879 linearOrdinate[mask], 

880 linearizeModel[mask], 

881 None, 

882 ampName, 

883 ) 

884 

885 self.fixupBadAmps(linearizer) 

886 

887 linearizer.hasLinearity = True 

888 linearizer.validate() 

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

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

891 linearizer.updateMetadataFromExposures([inputPtc]) 

892 provenance = IsrProvenance(calibType='linearizer') 

893 

894 return pipeBase.Struct( 

895 outputLinearizer=linearizer, 

896 outputProvenance=provenance, 

897 ) 

898 

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

900 # Need to fill linearizer with empty values 

901 # if the amp is non-functional 

902 ampName = amp.getName() 

903 nEntries = 1 

904 pEntries = 1 

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

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

907 nEntries = fitOrder + 1 - 2 

908 pEntries = fitOrder + 1 - 2 

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

910 nEntries = fitOrder * 2 

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

912 nEntries = 1 

913 pEntries = fitOrder + 1 

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

915 nEntries = 2 

916 pEntries = fitOrder + 1 

917 

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

919 

920 linearizer.linearityType[ampName] = "None" 

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

922 if self.config.trimmedState: 

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

924 else: 

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

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

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

928 linearizer.fitChiSq[ampName] = np.nan 

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

930 linearizer.fitResidualsSigmaMad[ampName] = np.nan 

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

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

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

934 linearizer.linearityTurnoff[ampName] = np.nan 

935 linearizer.linearityMaxSignal[ampName] = np.nan 

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

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

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

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

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

941 

942 return linearizer 

943 

944 def fixupBadAmps(self, linearizer): 

945 """Fix nan padding in bad amplifiers. 

946 

947 Parameters 

948 ---------- 

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

950 """ 

951 fitParamsMaxLen = 0 

952 for ampName in linearizer.ampNames: 

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

954 fitParamsMaxLen = length 

955 

956 for ampName in linearizer.ampNames: 

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

958 # Bad amplifier. 

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

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

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

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

963 

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

965 """Debug method for linearity fitting. 

966 

967 Parameters 

968 ---------- 

969 stepname : `str` 

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

971 line of code. 

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

973 The values to use as the independent variable in the 

974 linearity fit. 

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

976 The values to use as the dependent variable in the 

977 linearity fit. 

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

979 The values to use as the linearized result. 

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

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

982 ``yVector`` to keep. 

983 ampName : `str` 

984 Amplifier name to lookup linearity correction values. 

985 """ 

986 frame = getDebugFrame(self._display, stepname) 

987 if frame: 

988 import matplotlib.pyplot as plt 

989 fig, axs = plt.subplots(2) 

990 

991 if mask is None: 

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

993 

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

995 if stepname == 'linearFit': 

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

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

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

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

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

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

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 - full model fit)") 

1005 elif stepname == 'solution': 

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

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

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

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

1010 

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

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

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

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

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

1016 

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

1018 fig.tight_layout() 

1019 fig.show() 

1020 

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

1022 while True: 

1023 ans = input(prompt).lower() 

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

1025 break 

1026 elif ans in ("p", ): 

1027 import pdb 

1028 pdb.set_trace() 

1029 elif ans in ("h", ): 

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

1031 elif ans in ('x', ): 

1032 exit() 

1033 plt.close() 

1034 

1035 

1036class LinearityDoubleSplineSolveConnections( 

1037 pipeBase.PipelineTaskConnections, 

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

1039): 

1040 dummy = cT.Input( 

1041 name="raw", 

1042 doc="Dummy exposure.", 

1043 storageClass='Exposure', 

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

1045 multiple=True, 

1046 deferLoad=True, 

1047 ) 

1048 camera = cT.PrerequisiteInput( 

1049 name="camera", 

1050 doc="Camera Geometry definition.", 

1051 storageClass="Camera", 

1052 dimensions=("instrument", ), 

1053 isCalibration=True, 

1054 ) 

1055 inputLinearizerPtc = cT.Input( 

1056 name="linearizerPtc", 

1057 doc="Input linearizer PTC dataset.", 

1058 storageClass="PhotonTransferCurveDataset", 

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

1060 isCalibration=True, 

1061 ) 

1062 inputNormalization = cT.Input( 

1063 name="cpLinearizerPtcNormalization", 

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

1065 storageClass="ArrowAstropy", 

1066 dimensions=("instrument",), 

1067 isCalibration=True, 

1068 ) 

1069 inputBinnedImagesHandles = cT.Input( 

1070 name="cpPtcPairBinned", 

1071 doc="Tabulated binned exposure pairs.", 

1072 storageClass="ArrowAstropy", 

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

1074 multiple=True, 

1075 deferLoad=True, 

1076 ) 

1077 outputLinearizer = cT.Output( 

1078 name="linearity", 

1079 doc="Output linearity measurements.", 

1080 storageClass="Linearizer", 

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

1082 isCalibration=True, 

1083 ) 

1084 

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

1086 super().__init__(config=config) 

1087 

1088 if not config.useFocalPlaneNormalization: 

1089 del self.inputNormalization 

1090 

1091 

1092class LinearityDoubleSplineSolveConfig( 

1093 pipeBase.PipelineTaskConfig, 

1094 pipelineConnections=LinearityDoubleSplineSolveConnections, 

1095): 

1096 maxFracLinearityDeviation = pexConfig.Field( 

1097 dtype=float, 

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

1099 "linearityTurnoff and linearityMaxSignal.", 

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

1101 default=0.01, 

1102 ) 

1103 minSignalFitLinearityTurnoff = pexConfig.Field( 

1104 dtype=float, 

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

1106 default=1000.0, 

1107 ) 

1108 usePhotodiode = pexConfig.Field( 

1109 dtype=bool, 

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

1111 default=False, 

1112 ) 

1113 minPhotodiodeCurrent = pexConfig.Field( 

1114 dtype=float, 

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

1116 default=0.0, 

1117 ) 

1118 doAutoGrouping = pexConfig.Field( 

1119 dtype=bool, 

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

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

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

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

1124 default=False, 

1125 ) 

1126 autoGroupingUseExptime = pexConfig.Field( 

1127 dtype=bool, 

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

1129 default=True, 

1130 ) 

1131 autoGroupingThreshold = pexConfig.Field( 

1132 dtype=float, 

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

1134 default=0.1, 

1135 ) 

1136 autoGroupingMaxSignalFraction = pexConfig.Field( 

1137 dtype=float, 

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

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

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

1141 "the linearity turnoff.", 

1142 default=0.9, 

1143 ) 

1144 groupingColumn = pexConfig.Field( 

1145 dtype=str, 

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

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

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

1149 default=None, 

1150 optional=True, 

1151 ) 

1152 absoluteSplineMinimumSignalNode = pexConfig.Field( 

1153 dtype=float, 

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

1155 default=0.0, 

1156 ) 

1157 absoluteSplineLowThreshold = pexConfig.Field( 

1158 dtype=float, 

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

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

1161 "level checks will be skipped.", 

1162 default=0.0, 

1163 ) 

1164 absoluteSplineLowNodeSize = pexConfig.Field( 

1165 dtype=float, 

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

1167 default=2000.0, 

1168 ) 

1169 absoluteSplineNodeSize = pexConfig.Field( 

1170 dtype=float, 

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

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

1173 default=3000.0, 

1174 ) 

1175 absoluteSplineFitMinIter = pexConfig.Field( 

1176 dtype=int, 

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

1178 default=3, 

1179 ) 

1180 absoluteSplineFitMaxIter = pexConfig.Field( 

1181 dtype=int, 

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

1183 default=20, 

1184 ) 

1185 absoluteSplineFitMaxRejectionPerIteration = pexConfig.Field( 

1186 dtype=int, 

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

1188 default=5, 

1189 ) 

1190 absoluteNSigmaClipLinear = pexConfig.Field( 

1191 dtype=float, 

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

1193 default=5.0, 

1194 ) 

1195 doAbsoluteSplineFitOffset = pexConfig.Field( 

1196 dtype=bool, 

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

1198 default=True, 

1199 ) 

1200 doAbsoluteSplineFitWeights = pexConfig.Field( 

1201 dtype=bool, 

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

1203 default=False, 

1204 ) 

1205 absoluteSplineFitWeightParsStart = pexConfig.ListField( 

1206 dtype=float, 

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

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

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

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

1211 length=2, 

1212 default=[1.0, 0.0], 

1213 ) 

1214 doAbsoluteSplineFitTemperature = pexConfig.Field( 

1215 dtype=bool, 

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

1217 default=False, 

1218 ) 

1219 absoluteSplineFitTemperatureColumn = pexConfig.Field( 

1220 dtype=str, 

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

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

1223 "doSplineFitTemperature is True.", 

1224 default=None, 

1225 optional=True, 

1226 ) 

1227 doAbsoluteSplineFitTemporal = pexConfig.Field( 

1228 dtype=bool, 

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

1230 default=False, 

1231 ) 

1232 useFocalPlaneNormalization = pexConfig.Field( 

1233 dtype=bool, 

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

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

1236 default=False, 

1237 ) 

1238 relativeSplineReferenceCounts = pexConfig.Field( 

1239 dtype=float, 

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

1241 "relative spline solution.", 

1242 default=10000.0, 

1243 ) 

1244 relativeSplineMinimumSignalNode = pexConfig.Field( 

1245 dtype=float, 

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

1247 default=100.0, 

1248 ) 

1249 relativeSplineLowThreshold = pexConfig.Field( 

1250 dtype=float, 

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

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

1253 "level checks will be skipped.", 

1254 default=5000.0, 

1255 ) 

1256 relativeSplineLowNodeSize = pexConfig.Field( 

1257 dtype=float, 

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

1259 default=750.0, 

1260 ) 

1261 relativeSplineMidNodeSize = pexConfig.Field( 

1262 dtype=float, 

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

1264 "this applies to counts between relativeSplineLowThreshold and the " 

1265 "PTC turnoff.", 

1266 default=5000.0, 

1267 ) 

1268 relativeSplineHighNodeSize = pexConfig.Field( 

1269 dtype=float, 

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

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

1272 default=2000.0, 

1273 ) 

1274 relativeSplineFitMinIter = pexConfig.Field( 

1275 dtype=int, 

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

1277 default=3, 

1278 ) 

1279 relativeSplineFitMaxIter = pexConfig.Field( 

1280 dtype=int, 

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

1282 default=20, 

1283 ) 

1284 relativeSplineFitMaxRejectionPerIteration = pexConfig.Field( 

1285 dtype=int, 

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

1287 default=5, 

1288 ) 

1289 relativeNSigmaClipLinear = pexConfig.Field( 

1290 dtype=float, 

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

1292 default=5.0, 

1293 ) 

1294 

1295 def validate(self): 

1296 super().validate() 

1297 

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

1299 raise ValueError( 

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

1301 ) 

1302 

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

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

1305 if self.doAutoGrouping: 

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

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

1308 "usePhotodiode must be True.") 

1309 

1310 

1311class LinearityDoubleSplineSolveTask(pipeBase.PipelineTask): 

1312 ConfigClass = LinearityDoubleSplineSolveConfig 

1313 _DefaultName = "cpLinearityDoubleSplineSolve" 

1314 

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

1316 # docstring inherited 

1317 inputs = butlerQC.get(inputRefs) 

1318 

1319 if self.config.useFocalPlaneNormalization: 

1320 inputNormalization = inputs["inputNormalization"] 

1321 else: 

1322 inputNormalization = None 

1323 

1324 # Add calibration provenance info to header. 

1325 kwargs = dict() 

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

1327 

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

1329 runKey = "PTC_RUN" 

1330 runValue = reference.run 

1331 idKey = "PTC_UUID" 

1332 idValue = str(reference.id) 

1333 dateKey = "PTC_DATE" 

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

1335 dateValue = extractCalibDate(calib) 

1336 

1337 kwargs[runKey] = runValue 

1338 kwargs[idKey] = idValue 

1339 kwargs[dateKey] = dateValue 

1340 

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

1342 

1343 outputs = self.run( 

1344 inputPtc=inputs["inputLinearizerPtc"], 

1345 camera=inputs["camera"], 

1346 inputBinnedImagesHandles=inputs["inputBinnedImagesHandles"], 

1347 inputNormalization=inputNormalization, 

1348 ) 

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

1350 

1351 butlerQC.put(outputs, outputRefs) 

1352 

1353 def run( 

1354 self, 

1355 *, 

1356 inputPtc, 

1357 camera, 

1358 inputBinnedImagesHandles, 

1359 inputNormalization, 

1360 ): 

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

1362 

1363 Parameters 

1364 ---------- 

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

1366 Pre-measured PTC dataset. 

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

1368 Camera geometry. 

1369 inputBinnedImagesHandles : `list` [`DeferredDatasetHandle`] 

1370 Handles for input binned image pairs. 

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

1372 Focal plane normalization table to use if 

1373 useFocalPlaneNormalization is True. 

1374 

1375 Returns 

1376 ------- 

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

1378 The results struct containing: 

1379 

1380 ``outputLinearizer`` 

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

1382 ``outputProvenance`` 

1383 Provenance data for the new calibration 

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

1385 """ 

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

1387 

1388 binnedImagesHandleDict = { 

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

1390 } 

1391 

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

1393 linearizer.updateMetadataFromExposures([inputPtc]) 

1394 

1395 groupingValues = _determineInputGroups( 

1396 inputPtc, 

1397 self.config.doAutoGrouping, 

1398 self.config.autoGroupingUseExptime, 

1399 self.config.autoGroupingMaxSignalFraction, 

1400 self.config.autoGroupingThreshold, 

1401 self.config.groupingColumn, 

1402 self.config.minPhotodiodeCurrent, 

1403 ) 

1404 

1405 if self.config.doAbsoluteSplineFitTemperature: 

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

1407 raise ValueError( 

1408 "Config requests fitting temperature coefficient for " 

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

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

1411 ) 

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

1413 else: 

1414 temperatureValues = None 

1415 

1416 # Fill the linearizer with empty values. 

1417 firstAmp = None 

1418 for ampName in inputPtc.ampNames: 

1419 if ampName not in inputPtc.badAmps: 

1420 firstAmp = ampName 

1421 break 

1422 if firstAmp is None: 

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

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

1425 nAmp = len(inputPtc.ampNames) 

1426 

1427 for amp in detector: 

1428 ampName = amp.getName() 

1429 

1430 linearizer.linearityType[ampName] = "None" 

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

1432 # This is not used; kept for compatibility. 

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

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

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

1436 linearizer.fitChiSq[ampName] = np.nan 

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

1438 linearizer.fitResidualsSigmaMad[ampName] = np.nan 

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

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

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

1442 linearizer.linearityTurnoff[ampName] = np.nan 

1443 linearizer.linearityMaxSignal[ampName] = np.nan 

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

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

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

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

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

1449 

1450 linearizer.absoluteReferenceAmplifier = "" 

1451 

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

1453 data = np.zeros( 

1454 nExp, 

1455 dtype=[ 

1456 ("exp_id", "i8"), 

1457 ("exptime", "f8"), 

1458 ("photocharge", "f8"), 

1459 ("mjd", "f8"), 

1460 ("raw_mean", ("f8", nAmp)), 

1461 ("abscissa", "f8"), 

1462 ("grouping", "i4"), 

1463 # The following are computed in the relative scaling 

1464 # measurements. 

1465 ("ref_counts", "f8"), 

1466 ("gain_ratio", ("f8", nAmp)), 

1467 ], 

1468 ) 

1469 

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

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

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

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

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

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

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

1477 

1478 for i, amp in enumerate(detector): 

1479 ampName = amp.getName() 

1480 

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

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

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

1484 

1485 if self.config.usePhotodiode: 

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

1487 

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

1489 else: 

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

1491 

1492 # Normalize if configured. 

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

1494 if self.config.useFocalPlaneNormalization: 

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

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

1497 data["abscissa"] *= inputNorm 

1498 

1499 postTurnoffMasks = {} 

1500 

1501 # Compute linearity turnoff for each amp. 

1502 for i, amp in enumerate(detector): 

1503 ampName = amp.getName() 

1504 

1505 if ampName in inputPtc.badAmps: 

1506 self.log.warning( 

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

1508 ampName, 

1509 detector.getName(), 

1510 ) 

1511 continue 

1512 

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

1514 

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

1516 turnoffMask &= mask 

1517 

1518 _, turnoff, maxSignal, goodPoints = _computeTurnoffAndMax( 

1519 data["abscissa"], 

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

1521 turnoffMask, 

1522 data["grouping"], 

1523 ampName=ampName, 

1524 minSignalFitLinearityTurnoff=self.config.minSignalFitLinearityTurnoff, 

1525 maxFracLinearityDeviation=self.config.maxFracLinearityDeviation, 

1526 log=self.log, 

1527 ) 

1528 

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

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

1531 # "high end" outliers. 

1532 postTurnoffMask = goodPoints 

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

1534 postTurnoffMasks[ampName] = postTurnoffMask 

1535 

1536 if np.isnan(turnoff): 

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

1538 self.log.warning( 

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

1540 ampName, 

1541 detector.getName(), 

1542 ) 

1543 continue 

1544 

1545 linearizer.linearityTurnoff[ampName] = turnoff 

1546 linearizer.linearityMaxSignal[ampName] = maxSignal 

1547 

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

1549 

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

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

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

1553 # finite for this amplifier. 

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

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

1556 # not chosen as a reference amp. 

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

1558 if ampName in inputPtc.badAmps or not np.isfinite(inputPtc.ptcTurnoff[ampName]): 

1559 turnoffArray[i] = np.nan 

1560 

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

1562 # Return the default blank linearizer. 

1563 linearizer.hasLinearity = True 

1564 linearizer.validate() 

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

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

1567 linearizer.updateMetadataFromExposures([inputPtc]) 

1568 provenance = IsrProvenance(calibType='linearizer') 

1569 

1570 return pipeBase.Struct( 

1571 outputLinearizer=linearizer, 

1572 outputProvenance=provenance, 

1573 ) 

1574 

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

1576 refAmpName = inputPtc.ampNames[refAmpIndex] 

1577 linearizer.absoluteReferenceAmplifier = refAmpName 

1578 

1579 # Choose a reference image. 

1580 refExpIndex = np.argmin( 

1581 np.abs( 

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

1583 ) 

1584 ) 

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

1586 

1587 self.log.info( 

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

1589 refExpId, 

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

1591 refAmpName, 

1592 ) 

1593 

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

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

1596 offset = refExpIndex % 2 

1597 

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

1599 refBinned = copy.copy(refBinned) 

1600 if offset == 0: 

1601 refBinned["value"] = refBinned["value1"] 

1602 else: 

1603 refBinned["value"] = refBinned["value2"] 

1604 

1605 # Scale reference according to reference amplifier. 

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

1607 refBinned["value"] /= refScaling 

1608 

1609 # Get the invidual amp scalings. 

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

1611 ampScalings = np.asarray( 

1612 [ 

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

1614 for ampIndex in range(nAmp) 

1615 ], 

1616 ) 

1617 

1618 # Compute the gain ratios for every exposure. 

1619 # The binned images are stored as pairs. 

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

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

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

1623 if (i % 2) == 0: 

1624 binned = binnedImagesHandleDict[expId].get() 

1625 binned["value"] = binned["value1"] 

1626 else: 

1627 binned["value"] = binned["value2"] 

1628 

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

1630 

1631 gainRatios = np.asarray( 

1632 [ 

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

1634 for ampIndex in range(nAmp) 

1635 ] 

1636 ) 

1637 ref_counts = gainRatios[refAmpIndex] 

1638 gainRatios /= ref_counts 

1639 

1640 data["ref_counts"][i] = ref_counts 

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

1642 

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

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

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

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

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

1648 maxAmplitudeGroup = np.argmax(groupAmplitudes) 

1649 

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

1651 

1652 # Compute relative linearization first. 

1653 maxRelNodes = 0 

1654 

1655 for i, amp in enumerate(detector): 

1656 if i == refAmpIndex: 

1657 continue 

1658 

1659 ampName = amp.getName() 

1660 

1661 ptcTurnoff = inputPtc.ptcTurnoff[ampName] 

1662 linearityTurnoff = linearizer.linearityTurnoff[ampName] 

1663 

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

1665 # This is a bad amp; skip it. 

1666 continue 

1667 

1668 if ptcTurnoff < self.config.relativeSplineLowThreshold: 

1669 lowThreshold = 0.0 

1670 else: 

1671 lowThreshold = self.config.relativeSplineLowThreshold 

1672 

1673 # Need to check what happens if these are the same (corner dets). 

1674 relNodes = _noderator( 

1675 lowThreshold, 

1676 ptcTurnoff, 

1677 linearityTurnoff, 

1678 self.config.relativeSplineMinimumSignalNode, 

1679 self.config.relativeSplineLowNodeSize, 

1680 self.config.relativeSplineMidNodeSize, 

1681 self.config.relativeSplineHighNodeSize, 

1682 ) 

1683 

1684 self.log.info("Relative linearity for amplifier %s using %d nodes.", ampName, len(relNodes)) 

1685 

1686 if len(relNodes) > maxRelNodes: 

1687 maxRelNodes = len(relNodes) 

1688 

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

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

1691 

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

1693 relMask = postTurnoffMasks[ampName] & np.isfinite(relAbscissa) & np.isfinite(relOrdinate) 

1694 

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

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

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

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

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

1700 

1701 fitter = AstierSplineLinearityFitter( 

1702 relNodes, 

1703 data["grouping"], 

1704 relAbscissa, 

1705 relOrdinate, 

1706 mask=relMask, 

1707 fit_offset=False, 

1708 fit_weights=False, 

1709 fit_temperature=False, 

1710 max_signal_nearly_linear=ptcTurnoff, 

1711 fit_temporal=False, 

1712 # Turn off max correction clipping. 

1713 max_correction=np.inf, 

1714 ) 

1715 p0 = fitter.estimate_p0() 

1716 pars = fitter.fit( 

1717 p0, 

1718 min_iter=self.config.relativeSplineFitMinIter, 

1719 max_iter=self.config.relativeSplineFitMaxIter, 

1720 max_rejection_per_iteration=self.config.relativeSplineFitMaxRejectionPerIteration, 

1721 n_sigma_clip=self.config.relativeNSigmaClipLinear, 

1722 ) 

1723 

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

1725 # exactly zero. 

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

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

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

1729 "be consistent with zero.") 

1730 relValues[0] = 0.0 

1731 

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

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

1734 # in the normalization and ensures that the overall linearized 

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

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

1737 

1738 relChisq = fitter.compute_chisq_dof(pars) 

1739 

1740 # Our reference fit is always 1.0 slope. 

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

1742 

1743 # Adjust the abscissa for different groups for residuals. 

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

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

1746 

1747 relMask = fitter.mask 

1748 

1749 # Record values in the linearizer. 

1750 linearizer.linearityType[ampName] = "DoubleSpline" 

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

1752 # the absolute spline. 

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

1754 linearizer.fitChiSq[ampName] = relChisq 

1755 linearizer.linearFit[ampName] = relLinearFit 

1756 

1757 # Compute residuals. 

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

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

1760 relModel = relOrdinate - relOffset 

1761 

1762 if relMask.sum() < 2: 

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

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

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

1766 relResidualsUnmasked = relResiduals.copy() 

1767 else: 

1768 postLinearFit, _, _, _ = irlsFit( 

1769 relLinearFit, 

1770 relAbscissa[relMask], 

1771 relModel[relMask], 

1772 funcPolynomial, 

1773 ) 

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

1775 # the postLinearFit and not the intercept. The intercept 

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

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

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

1779 relResidualsUnmasked = relResiduals.copy() 

1780 # We set masked residuals to nan. 

1781 relResiduals[~relMask] = np.nan 

1782 

1783 linearizer.fitResidualsUnmasked[ampName] = relResidualsUnmasked 

1784 linearizer.fitResiduals[ampName] = relResiduals 

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

1786 

1787 finite = np.isfinite(relResiduals) 

1788 if finite.sum() == 0: 

1789 sigmad = np.nan 

1790 else: 

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

1792 linearizer.fitResidualsSigmaMad[ampName] = sigmad 

1793 

1794 # Now compute absolute linearization. 

1795 

1796 if temperatureValues is not None: 

1797 temperatureValuesScaled = temperatureValues - np.median(temperatureValues) 

1798 else: 

1799 temperatureValuesScaled = None 

1800 

1801 if self.config.doAbsoluteSplineFitTemporal: 

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

1803 inputMjdScaled -= np.nanmedian(inputMjdScaled) 

1804 else: 

1805 inputMjdScaled = None 

1806 

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

1808 absPtcTurnoff = inputPtc.ptcTurnoff[refAmpName] 

1809 absLinearityTurnoff = linearizer.linearityTurnoff[refAmpName] 

1810 

1811 if absPtcTurnoff < self.config.absoluteSplineLowThreshold: 

1812 lowThreshold = 0.0 

1813 else: 

1814 lowThreshold = self.config.absoluteSplineLowThreshold 

1815 

1816 absNodes = _noderator( 

1817 lowThreshold, 

1818 absPtcTurnoff, 

1819 absLinearityTurnoff, 

1820 self.config.absoluteSplineMinimumSignalNode, 

1821 self.config.absoluteSplineLowNodeSize, 

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

1823 self.config.absoluteSplineNodeSize, 

1824 self.config.absoluteSplineNodeSize, 

1825 ) 

1826 

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

1828 

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

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

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

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

1833 

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

1835 linearizer.linearityType[refAmpName] = "DoubleSpline" 

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

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

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

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

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

1841 

1842 fitter = AstierSplineLinearityFitter( 

1843 absNodes, 

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

1845 absAbscissa, 

1846 absOrdinate, 

1847 mask=absMask, 

1848 log=self.log, 

1849 fit_offset=self.config.doAbsoluteSplineFitOffset, 

1850 fit_weights=self.config.doAbsoluteSplineFitWeights, 

1851 weight_pars_start=self.config.absoluteSplineFitWeightParsStart, 

1852 fit_temperature=self.config.doAbsoluteSplineFitTemperature, 

1853 temperature_scaled=temperatureValuesScaled, 

1854 max_signal_nearly_linear=absPtcTurnoff, 

1855 fit_temporal=self.config.doAbsoluteSplineFitTemporal, 

1856 mjd_scaled=inputMjdScaled, 

1857 ) 

1858 p0 = fitter.estimate_p0() 

1859 pars = fitter.fit( 

1860 p0, 

1861 min_iter=self.config.absoluteSplineFitMinIter, 

1862 max_iter=self.config.absoluteSplineFitMaxIter, 

1863 max_rejection_per_iteration=self.config.absoluteSplineFitMaxRejectionPerIteration, 

1864 n_sigma_clip=self.config.absoluteNSigmaClipLinear, 

1865 ) 

1866 

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

1868 # exactly zero. 

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

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

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

1872 "be consistent with zero.") 

1873 absValues[0] = 0.0 

1874 

1875 # We need a place to store this. 

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

1877 

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

1879 

1880 # We must modify the inputOrdinate according to the 

1881 # nuisance terms in the linearity fit for the residual 

1882 # computation code to work properly. 

1883 # The true mu (inputOrdinate) is given by 

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

1885 if self.config.doAbsoluteSplineFitTemperature: 

1886 absOrdinate *= (1.0 

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

1888 if self.config.doAbsoluteSplineFitTemporal: 

1889 absOrdinate *= (1.0 

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

1891 

1892 # Adjust the abscissa for different groups for residuals. 

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

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

1895 # And remove the offset term. 

1896 if self.config.doAbsoluteSplineFitOffset: 

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

1898 

1899 absMask = fitter.mask 

1900 

1901 # Compute residuals. 

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

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

1904 absModel = absOrdinate - absOffset 

1905 

1906 if absMask.sum() < 2: 

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

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

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

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

1911 absResidualsUnmasked = relResiduals.copy() 

1912 else: 

1913 postLinearFit, _, _, _ = irlsFit( 

1914 absLinearFit, 

1915 absAbscissa[absMask], 

1916 absModel[absMask], 

1917 funcPolynomial, 

1918 ) 

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

1920 # the postLinearFit and not the intercept. The intercept 

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

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

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

1924 absResidualsUnmasked = absResiduals.copy() 

1925 # We set masked residuals to nan. 

1926 absResiduals[~absMask] = np.nan 

1927 

1928 linearizer.fitResidualsUnmasked[refAmpName] = absResidualsUnmasked 

1929 linearizer.fitResiduals[refAmpName] = absResiduals 

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

1931 

1932 finite = np.isfinite(absResiduals) 

1933 if finite.sum() == 0: 

1934 sigmad = np.nan 

1935 else: 

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

1937 linearizer.fitResidualsSigmaMad[refAmpName] = sigmad 

1938 

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

1940 # along with extra padding for alignment. 

1941 nAbsNodes = len(absNodes) 

1942 for i, amp in enumerate(detector): 

1943 ampName = amp.getName() 

1944 

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

1946 if ampName == refAmpName: 

1947 # The reference amplifier only has the absolute spline. 

1948 coeffs[1] = nAbsNodes 

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

1950 else: 

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

1952 

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

1954 coeffs[0] = nRelNodes 

1955 coeffs[1] = nAbsNodes 

1956 relStart = 2 

1957 relEnd = relStart + 2 * nRelNodes 

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

1959 absStart = relEnd 

1960 absEnd = absStart + 2 * nAbsNodes 

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

1962 

1963 linearizer.linearityCoeffs[ampName] = coeffs 

1964 

1965 linearizer.hasLinearity = True 

1966 linearizer.validate() 

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

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

1969 linearizer.updateMetadataFromExposures([inputPtc]) 

1970 provenance = IsrProvenance(calibType='linearizer') 

1971 

1972 return pipeBase.Struct( 

1973 outputLinearizer=linearizer, 

1974 outputProvenance=provenance, 

1975 ) 

1976 

1977 

1978def _determineInputGroups( 

1979 ptc, 

1980 doAutoGrouping, 

1981 autoGroupingUseExptime, 

1982 autoGroupingMaxSignalFraction, 

1983 autoGroupingThreshold, 

1984 groupingColumn, 

1985 minPhotodiodeCurrent, 

1986): 

1987 """Determine input groups for linearity fit. 

1988 

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

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

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

1992 the ratio of signal to exposure time (if 

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

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

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

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

1997 very well separated. 

1998 

1999 Parameters 

2000 ---------- 

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

2002 Input PTC to do grouping. 

2003 doAutoGrouping : `bool` 

2004 Do automatic grouping of pairs? 

2005 autoGroupingUseExptime : `bool` 

2006 Use exposure time for automatic grouping of pairs? 

2007 autoGroupingMaxSignalFraction : `float` 

2008 All exposures with signal higher than this threshold will 

2009 be put into the largest signal group. 

2010 autoGroupingThreshold : `float` 

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

2012 minPhotodiodeCurrent : `float` 

2013 Minimum photodiode current if auto-grouping is used and 

2014 autoGroupingUseExptime is False. 

2015 splineGroupingColumn : `str` or `None` 

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

2017 

2018 Returns 

2019 ------- 

2020 groupingValues : `np.ndarray` 

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

2022 """ 

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

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

2025 

2026 if not doAutoGrouping: 

2027 if groupingColumn is not None: 

2028 if groupingColumn not in ptc.auxValues: 

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

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

2031 

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

2033 for i, uGroupValue in enumerate(uGroupValues): 

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

2035 else: 

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

2037 exptimes = np.zeros_like(means) 

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

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

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

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

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

2043 

2044 if autoGroupingUseExptime: 

2045 abscissa = detExptimes 

2046 else: 

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

2048 # Set illegal photocharges to NaN. 

2049 abscissa[abscissa < minPhotodiodeCurrent] = np.nan 

2050 

2051 ratio = detMeans / abscissa 

2052 ratio /= np.nanmedian(ratio) 

2053 

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

2055 # largest group. 

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

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

2058 ratio[above] = ratio[maxIndex] 

2059 

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

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

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

2063 st = np.argsort(ratio) 

2064 stratio = ratio[st] 

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

2066 

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

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

2069 if len(transitions) > 0: 

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

2071 

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

2073 if i == 0: 

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

2075 elif i == len(transitions): 

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

2077 else: 

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

2079 groupingValues[inGroup] = i 

2080 

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

2082 # These are masked later on. 

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

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

2085 groupingValues[above] = groupingValues[maxIndex] 

2086 

2087 return groupingValues 

2088 

2089 

2090def _computeTurnoffAndMax( 

2091 abscissa, 

2092 ordinate, 

2093 initialMask, 

2094 groupingValues, 

2095 ampName="UNKNOWN", 

2096 minSignalFitLinearityTurnoff=1000.0, 

2097 maxFracLinearityDeviation=0.01, 

2098 log=None, 

2099): 

2100 """Compute the turnoff and max signal. 

2101 

2102 Parameters 

2103 ---------- 

2104 abscissa : `np.ndarray` 

2105 Input x values, either photoCharges or exposure times. 

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

2107 ordinate : `np.ndarray` 

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

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

2110 initialMask : `np.ndarray` 

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

2112 groupingValues : `np.ndarray` 

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

2114 ampName : `str`, optional 

2115 Amplifier name (used for logging). 

2116 minSignalFitLinearityTurnoff : `float`, optional 

2117 Minimum signal to cmpute raw linearity slope for linearityTurnoff. 

2118 maxFracLinearityDeviation : `float`, optional 

2119 Maximum fraction deviation from raw linearity to compute turnoff. 

2120 log : `logging.Logger`, optional 

2121 Log object. 

2122 

2123 Returns 

2124 ------- 

2125 turnoffIndex : `int` 

2126 Fit turnoff index (keyed to raw input). 

2127 turnoff : `float` 

2128 Fit turnoff value. 

2129 maxSignal : `float` 

2130 Fit maximum signal value. 

2131 goodPoints : `np.ndarray` 

2132 Mask of good points used in turnoff fit. 

2133 """ 

2134 if log is None: 

2135 log = logging.getLogger(__name__) 

2136 

2137 # Follow eo_pipe: 

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

2139 # Replacing flux with abscissa, Ne with ordinate. 

2140 

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

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

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

2144 # Minimizing chi2 wrt aa, gives 

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

2146 

2147 fitMask = initialMask.copy() 

2148 fitMask[ordinate < minSignalFitLinearityTurnoff] = False 

2149 goodPoints = fitMask.copy() 

2150 

2151 gValues = np.unique(groupingValues) 

2152 groupIndicesList = [] 

2153 for gValue in gValues: 

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

2155 groupIndicesList.append(use) 

2156 

2157 found = False 

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

2159 residuals = np.zeros_like(ordinate) 

2160 

2161 abscissaMasked = abscissa.copy() 

2162 abscissaMasked[~fitMask] = np.nan 

2163 ordinateMasked = ordinate.copy() 

2164 ordinateMasked[~fitMask] = np.nan 

2165 

2166 for groupIndices in groupIndicesList: 

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

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

2169 aa = num / denom 

2170 

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

2172 ordinate[groupIndices] 

2173 

2174 # Use the residuals to compute the turnoff. 

2175 residuals -= np.nanmedian(residuals) 

2176 

2177 goodPoints = np.abs(residuals) < maxFracLinearityDeviation 

2178 

2179 if goodPoints.sum() > 4: 

2180 # This was an adequate fit. 

2181 found = True 

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

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

2184 else: 

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

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

2187 fitIndices, = np.nonzero(fitMask) 

2188 fitMask[fitIndices[badIndex]] = False 

2189 

2190 if not found: 

2191 # Could not find any reasonable value. 

2192 log.warning( 

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

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

2195 ampName, 

2196 ) 

2197 if np.all(~fitMask): 

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

2199 

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

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

2202 

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

2204 

2205 # Fit the maximum signal. 

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

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

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

2209 # value in practice. 

2210 log.info( 

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

2212 ampName, 

2213 ) 

2214 maxSignal = ordinate[turnoffIndex] 

2215 else: 

2216 maxSignalInitial = np.nanmax(ordinate) 

2217 

2218 highFluxPoints = (np.nan_to_num(ordinate) 

2219 > (1.0 - maxFracLinearityDeviation)*maxSignalInitial) 

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

2221 

2222 return turnoffIndex, turnoff, maxSignal, goodPoints 

2223 

2224 

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

2226 """The "noderator" node-finder. 

2227 

2228 Parameters 

2229 ---------- 

2230 turnoff0 : `float` 

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

2232 non-linearity threshold) (adu). 

2233 turnoff1 : `float` 

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

2235 turnoff2 : `float` 

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

2237 minNode : `float` 

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

2239 it will be ignored) (adu). 

2240 lowNodeSize : `float` 

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

2242 (below turnoff0) (adu). 

2243 midNodeSize : `float` 

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

2245 (between turnoff0 and turnoff1) (adu). 

2246 highNodeSize : `float` 

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

2248 (between turnoff1 and turnoff2) (adu). 

2249 

2250 Returns 

2251 ------- 

2252 nodes : `np.ndarray` 

2253 Array of node values (adu). 

2254 """ 

2255 if turnoff0 > minNode: 

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

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

2258 midStart = turnoff0 

2259 else: 

2260 nNodesLow = 0 

2261 midStart = 0.0 

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

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

2264 if turnoff2 > turnoff1: 

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

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

2267 else: 

2268 nNodesHigh = 0 

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

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

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

2272 

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

2274 nodeList = [] 

2275 if nNodesLow > 1: 

2276 nodeList.append([0.0]) 

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

2278 if nNodesMid > 1: 

2279 nodeList.append(nodesMid) 

2280 if nNodesHigh > 1: 

2281 nodeList.append(nodesHigh[1:]) 

2282 return np.concatenate(nodeList)