Coverage for python / lsst / ip / isr / isrStatistics.py: 11%

408 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-07 08:28 +0000

1# This file is part of ip_isr. 

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__all__ = ["IsrStatisticsTaskConfig", "IsrStatisticsTask"] 

23 

24import numpy as np 

25import astropy.stats 

26from scipy.signal.windows import hamming, hann, gaussian 

27from scipy.signal import butter, filtfilt 

28from scipy.stats import linregress 

29 

30import lsst.afw.math as afwMath 

31import lsst.afw.image as afwImage 

32import lsst.pipe.base as pipeBase 

33import lsst.pex.config as pexConfig 

34 

35from lsst.afw.cameraGeom import ReadoutCorner 

36from .isrFunctions import gainContext, isTrimmedExposure 

37 

38 

39class IsrStatisticsTaskConfig(pexConfig.Config): 

40 """Image statistics options. 

41 """ 

42 doCtiStatistics = pexConfig.Field( 

43 dtype=bool, 

44 doc="Measure CTI statistics from image and overscans?", 

45 default=False, 

46 ) 

47 doApplyGainsForCtiStatistics = pexConfig.Field( 

48 dtype=bool, 

49 doc="Apply gain to the overscan region when measuring CTI statistics?", 

50 default=True, 

51 # TODO: DM-46721 

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

53 ) 

54 

55 doBandingStatistics = pexConfig.Field( 

56 dtype=bool, 

57 doc="Measure image banding metric?", 

58 default=False, 

59 ) 

60 bandingKernelSize = pexConfig.Field( 

61 dtype=int, 

62 doc="Width of box for boxcar smoothing for banding metric.", 

63 default=3, 

64 check=lambda x: x == 0 or x % 2 != 0, 

65 ) 

66 bandingFractionLow = pexConfig.Field( 

67 dtype=float, 

68 doc="Fraction of values to exclude from low samples.", 

69 default=0.1, 

70 check=lambda x: x >= 0.0 and x <= 1.0 

71 ) 

72 bandingFractionHigh = pexConfig.Field( 

73 dtype=float, 

74 doc="Fraction of values to exclude from high samples.", 

75 default=0.9, 

76 check=lambda x: x >= 0.0 and x <= 1.0, 

77 ) 

78 bandingUseHalfDetector = pexConfig.Field( 

79 dtype=float, 

80 doc="Use only the first half set of amplifiers.", 

81 default=True, 

82 ) 

83 

84 doProjectionStatistics = pexConfig.Field( 

85 dtype=bool, 

86 doc="Measure projection metric?", 

87 default=False, 

88 ) 

89 projectionKernelSize = pexConfig.Field( 

90 dtype=int, 

91 doc="Width of box for boxcar smoothing of projections.", 

92 default=0, 

93 check=lambda x: x == 0 or x % 2 != 0, 

94 ) 

95 doProjectionFft = pexConfig.Field( 

96 dtype=bool, 

97 doc="Generate FFTs from the image projections?", 

98 default=False, 

99 ) 

100 projectionFftWindow = pexConfig.ChoiceField( 

101 dtype=str, 

102 doc="Type of windowing to use prior to calculating FFT.", 

103 default="HAMMING", 

104 allowed={ 

105 "HAMMING": "Hamming window.", 

106 "HANN": "Hann window.", 

107 "GAUSSIAN": "Gaussian window.", 

108 "NONE": "No window." 

109 } 

110 ) 

111 

112 doDivisaderoStatistics = pexConfig.Field( 

113 dtype=bool, 

114 doc="Measure divisadero tearing statistics?", 

115 default=False, 

116 ) 

117 divisaderoEdgePixels = pexConfig.Field( 

118 dtype=int, 

119 doc="Number of edge pixels excluded from divisadero linear fit.", 

120 default=25, 

121 ) 

122 divisaderoNumImpactPixels = pexConfig.Field( 

123 dtype=int, 

124 doc="Number of edge pixels to examine for divisadero tearing.", 

125 default=2, 

126 ) 

127 divisaderoProjectionMinimum = pexConfig.Field( 

128 dtype=int, 

129 doc="Minimum row to consider when taking robust mean of columns.", 

130 default=10, 

131 ) 

132 divisaderoProjectionMaximum = pexConfig.Field( 

133 dtype=int, 

134 doc="Maximum row to consider when taking robust mean of columns", 

135 default=210, 

136 ) 

137 

138 doCopyCalibDistributionStatistics = pexConfig.Field( 

139 dtype=bool, 

140 doc="Copy calibration distribution statistics to output?", 

141 default=False, 

142 ) 

143 expectedDistributionLevels = pexConfig.ListField( 

144 dtype=float, 

145 doc="Percentile levels expected in the calibration header.", 

146 default=[0, 5, 16, 50, 84, 95, 100], 

147 ) 

148 

149 doBiasShiftStatistics = pexConfig.Field( 

150 dtype=bool, 

151 doc="Measure number of image shifts in overscan?", 

152 default=False, 

153 ) 

154 biasShiftFilterOrder = pexConfig.Field( 

155 dtype=int, 

156 doc="Filter order for Butterworth highpass filter.", 

157 default=5, 

158 ) 

159 biasShiftCutoff = pexConfig.Field( 

160 dtype=float, 

161 doc="Cutoff frequency for highpass filter.", 

162 default=1.0/15.0, 

163 ) 

164 biasShiftWindow = pexConfig.Field( 

165 dtype=int, 

166 doc="Filter window size in pixels for highpass filter.", 

167 default=30, 

168 ) 

169 biasShiftThreshold = pexConfig.Field( 

170 dtype=float, 

171 doc="S/N threshold for bias shift detection.", 

172 default=3.0, 

173 ) 

174 biasShiftRowSkip = pexConfig.Field( 

175 dtype=int, 

176 doc="Number of rows to skip for the bias shift detection.", 

177 default=30, 

178 ) 

179 biasShiftColumnSkip = pexConfig.Field( 

180 dtype=int, 

181 doc="Number of columns to skip when averaging the overscan region.", 

182 default=3, 

183 ) 

184 

185 doAmplifierCorrelationStatistics = pexConfig.Field( 

186 dtype=bool, 

187 doc="Measure amplifier correlations?", 

188 default=False, 

189 ) 

190 

191 stat = pexConfig.Field( 

192 dtype=str, 

193 default="MEANCLIP", 

194 doc="Statistic name to use to measure regions.", 

195 ) 

196 nSigmaClip = pexConfig.Field( 

197 dtype=float, 

198 default=3.0, 

199 doc="Clipping threshold for background", 

200 ) 

201 nIter = pexConfig.Field( 

202 dtype=int, 

203 default=3, 

204 doc="Clipping iterations for background", 

205 ) 

206 badMask = pexConfig.ListField( 

207 dtype=str, 

208 default=["BAD", "INTRP", "SAT"], 

209 doc="Mask planes to ignore when identifying source pixels." 

210 ) 

211 

212 

213class IsrStatisticsTask(pipeBase.Task): 

214 """Task to measure arbitrary statistics on ISR processed exposures. 

215 

216 The goal is to wrap a number of optional measurements that are 

217 useful for calibration production and detector stability. 

218 """ 

219 ConfigClass = IsrStatisticsTaskConfig 

220 _DefaultName = "isrStatistics" 

221 

222 def __init__(self, statControl=None, **kwargs): 

223 super().__init__(**kwargs) 

224 self.statControl = afwMath.StatisticsControl(self.config.nSigmaClip, self.config.nIter, 

225 afwImage.Mask.getPlaneBitMask(self.config.badMask)) 

226 self.statType = afwMath.stringToStatisticsProperty(self.config.stat) 

227 

228 def run(self, inputExp, untrimmedInputExposure=None, ptc=None, serialOverscanResults=None, 

229 parallelOverscanResults=None, doLegacyCtiStatistics=False, **kwargs): 

230 """Task to run arbitrary statistics. 

231 

232 The statistics should be measured by individual methods, and 

233 add to the dictionary in the return struct. 

234 

235 Parameters 

236 ---------- 

237 inputExp : `lsst.afw.image.Exposure` 

238 The exposure to measure. 

239 untrimmedInputExp : 

240 The exposure to measure overscan statistics from. 

241 ptc : `lsst.ip.isr.PtcDataset`, optional 

242 A PTC object containing gains to use. 

243 serialOverscanResults : `list` [`lsst.pipe.base.Struct`], optional 

244 List of serial overscan results. Expected fields are: 

245 

246 ``imageFit`` 

247 Value or fit subtracted from the amplifier image data 

248 (scalar or `lsst.afw.image.Image`). 

249 ``overscanFit`` 

250 Value or fit subtracted from the overscan image data 

251 (scalar or `lsst.afw.image.Image`). 

252 ``overscanImage`` 

253 Image of the overscan region with the overscan 

254 correction applied (`lsst.afw.image.Image`). This 

255 quantity is used to estimate the amplifier read noise 

256 empirically. 

257 parallelOverscanResults : `list` [`lsst.pipe.base.Struct`], optional 

258 List of parallel overscan results. Expected fields are: 

259 

260 ``imageFit`` 

261 Value or fit subtracted from the amplifier image data 

262 (scalar or `lsst.afw.image.Image`). 

263 ``overscanFit`` 

264 Value or fit subtracted from the overscan image data 

265 (scalar or `lsst.afw.image.Image`). 

266 ``overscanImage`` 

267 Image of the overscan region with the overscan 

268 correction applied (`lsst.afw.image.Image`). This 

269 quantity is used to estimate the amplifier read noise 

270 empirically. 

271 doLegacyCtiStatistics : `bool`, optional 

272 Use the older version of measureCti (not recommended). 

273 This should be True if and only if this task is called 

274 from IsrTask. TODO: Deprecate legacy CTI + CTI correction 

275 from IsrTask (DM-48757). 

276 **kwargs : 

277 Keyword arguments. Calibrations being passed in should 

278 have an entry here. 

279 

280 Returns 

281 ------- 

282 resultStruct : `lsst.pipe.base.Struct` 

283 Contains the measured statistics as a dict stored in a 

284 field named ``results``. 

285 

286 Raises 

287 ------ 

288 RuntimeError 

289 Raised if the amplifier gains could not be found. 

290 """ 

291 # Verify inputs 

292 if self.config.doCtiStatistics and not doLegacyCtiStatistics: 

293 if untrimmedInputExposure is None: 

294 raise RuntimeError("Must pass untrimmed exposure if doCtiStatistics==True.") 

295 

296 # Find gains. 

297 detector = inputExp.getDetector() 

298 if ptc is not None: 

299 gains = ptc.gain 

300 elif detector is not None: 

301 gains = {amp.getName(): amp.getGain() for amp in detector.getAmplifiers()} 

302 else: 

303 raise RuntimeError("No source of gains provided.") 

304 

305 ctiResults = None 

306 if self.config.doCtiStatistics: 

307 if doLegacyCtiStatistics: 

308 self.log.warning("Calculating the legacy CTI statistics is not recommended.") 

309 ctiResults = self.measureCtiLegacy(inputExp, serialOverscanResults, gains) 

310 else: 

311 ctiResults = self.measureCti(inputExp, untrimmedInputExposure, gains) 

312 

313 bandingResults = None 

314 if self.config.doBandingStatistics: 

315 bandingResults = self.measureBanding(inputExp, serialOverscanResults) 

316 

317 projectionResults = None 

318 if self.config.doProjectionStatistics: 

319 projectionResults = self.measureProjectionStatistics(inputExp, serialOverscanResults) 

320 

321 divisaderoResults = None 

322 if self.config.doDivisaderoStatistics: 

323 divisaderoResults = self.measureDivisaderoStatistics(inputExp, **kwargs) 

324 

325 calibDistributionResults = None 

326 if self.config.doCopyCalibDistributionStatistics: 

327 calibDistributionResults = self.copyCalibDistributionStatistics(inputExp, **kwargs) 

328 

329 biasShiftResults = None 

330 if self.config.doBiasShiftStatistics: 

331 biasShiftResults = self.measureBiasShifts(inputExp, serialOverscanResults) 

332 

333 ampCorrelationResults = None 

334 if self.config.doAmplifierCorrelationStatistics: 

335 ampCorrelationResults = self.measureAmpCorrelations(inputExp, serialOverscanResults) 

336 

337 mjd = inputExp.getMetadata().get("MJD", None) 

338 

339 return pipeBase.Struct( 

340 results={"CTI": ctiResults, 

341 "BANDING": bandingResults, 

342 "PROJECTION": projectionResults, 

343 "CALIBDIST": calibDistributionResults, 

344 "BIASSHIFT": biasShiftResults, 

345 "AMPCORR": ampCorrelationResults, 

346 "MJD": mjd, 

347 'DIVISADERO': divisaderoResults, 

348 }, 

349 ) 

350 

351 def measureCti(self, inputExp, untrimmedInputExp, gains): 

352 """Task to measure CTI statistics. 

353 

354 Parameters 

355 ---------- 

356 inputExp : `lsst.afw.image.Exposure` 

357 The exposure to measure. 

358 untrimmedInputExp : `lsst.afw.image.Exposure` 

359 Exposure to measure overscan from. 

360 gains : `dict` [`str` `float`] 

361 Dictionary of per-amplifier gains, indexed by amplifier name. 

362 

363 Returns 

364 ------- 

365 outputStats : `dict` [`str`, [`dict` [`str`, `float`]]] 

366 Dictionary of measurements, keyed by amplifier name and 

367 statistics segment. Everything in units based on electron. 

368 

369 Notes 

370 ------- 

371 The input exposure is needed because it contains the last imaging 

372 pixel, with defects applied. And the untrimmed input exposure is 

373 needed because it contains the overscan regions. It needs to be 

374 this way because the defect masking code requires that the image 

375 be trimmed, but we need the image with defects masked to measure 

376 the CTI from the last imaging pixel. 

377 """ 

378 outputStats = {} 

379 

380 detector = inputExp.getDetector() 

381 image = inputExp.image 

382 untrimmedImage = untrimmedInputExp.image 

383 

384 # It only makes sense to measure CTI in electron units. 

385 # Make it so. 

386 imageUnits = inputExp.getMetadata().get("LSST ISR UNITS") 

387 untrimmedImageUnits = untrimmedInputExp.getMetadata().get("LSST ISR UNITS") 

388 

389 # Ensure we always have the same units. 

390 applyGainToImage = True if imageUnits == "adu" else False 

391 applyGainToUntrimmedImage = True if untrimmedImageUnits == "adu" else False 

392 

393 with gainContext(inputExp, image, applyGainToImage, gains, isTrimmed=True): 

394 with gainContext(untrimmedInputExp, untrimmedImage, applyGainToUntrimmedImage, 

395 gains, isTrimmed=False): 

396 for amp in detector.getAmplifiers(): 

397 ampStats = {} 

398 readoutCorner = amp.getReadoutCorner() 

399 

400 ampStats["INPUT_GAIN"] = float(gains[amp.getName()]) 

401 turnoff = inputExp.metadata[f"LSST ISR PTCTURNOFF {amp.getName()}"] 

402 ampStats["INPUT_PTCTURNOFF"] = float(turnoff) 

403 

404 # Full data region. 

405 dataRegion = image[amp.getBBox()] 

406 serialOverscanImage = untrimmedImage[amp.getRawSerialOverscanBBox()] 

407 parallelOverscanImage = untrimmedImage[amp.getRawSerialOverscanBBox()] 

408 

409 # Get the mean of the image 

410 ampStats["IMAGE_MEAN"] = afwMath.makeStatistics(dataRegion, self.statType, 

411 self.statControl).getValue() 

412 

413 # First and last image columns. 

414 colA = afwMath.makeStatistics( 

415 dataRegion.array[:, 0], 

416 self.statType, 

417 self.statControl, 

418 ).getValue() 

419 colZ = afwMath.makeStatistics( 

420 dataRegion.array[:, -1], 

421 self.statType, 

422 self.statControl, 

423 ).getValue() 

424 

425 # First and last image rows. 

426 rowA = afwMath.makeStatistics( 

427 dataRegion.array[0, :], 

428 self.statType, 

429 self.statControl, 

430 ).getValue() 

431 rowZ = afwMath.makeStatistics( 

432 dataRegion.array[-1, :], 

433 self.statType, 

434 self.statControl, 

435 ).getValue() 

436 

437 # We want these relative to the readout corner. If that's 

438 # on the right side, we need to swap them. 

439 if readoutCorner in (ReadoutCorner.LR, ReadoutCorner.UR): 

440 ampStats["FIRST_COLUMN_MEAN"] = colZ 

441 ampStats["LAST_COLUMN_MEAN"] = colA 

442 else: 

443 ampStats["FIRST_COLUMN_MEAN"] = colA 

444 ampStats["LAST_COLUMN_MEAN"] = colZ 

445 

446 # We want these relative to the readout corner. If that's 

447 # on the top, we need to swap them. 

448 if readoutCorner in (ReadoutCorner.UR, ReadoutCorner.UL): 

449 ampStats["FIRST_ROW_MEAN"] = rowZ 

450 ampStats["LAST_ROW_MEAN"] = rowA 

451 else: 

452 ampStats["FIRST_ROW_MEAN"] = rowA 

453 ampStats["LAST_ROW_MEAN"] = rowZ 

454 

455 # Measure the columns of the serial overscan and the rows 

456 # of the parallel overscan. 

457 nSerialOverscanCols = amp.getRawSerialOverscanBBox().getWidth() 

458 nParallelOverscanRows = amp.getRawParallelOverscanBBox().getHeight() 

459 

460 # Calculate serial overscan statistics 

461 columns = [] 

462 columnValues = [] 

463 for idx in range(0, nSerialOverscanCols): 

464 # If overscan.doParallelOverscan=True, the 

465 # overscanImage will contain both the serial 

466 # and parallel overscan regions. 

467 serialOverscanColMean = afwMath.makeStatistics( 

468 serialOverscanImage.array[:, idx], 

469 self.statType, 

470 self.statControl, 

471 ).getValue() 

472 columns.append(idx) 

473 # The overscan input is always in adu, but it only 

474 # makes sense to measure CTI in electron units. 

475 columnValues.append(serialOverscanColMean) 

476 

477 # We want these relative to the readout corner. If that's 

478 # on the right side, we need to swap them. 

479 if readoutCorner in (ReadoutCorner.LR, ReadoutCorner.UR): 

480 ampStats["SERIAL_OVERSCAN_COLUMNS"] = list(reversed(columns)) 

481 ampStats["SERIAL_OVERSCAN_VALUES"] = list(reversed(columnValues)) 

482 else: 

483 ampStats["SERIAL_OVERSCAN_COLUMNS"] = columns 

484 ampStats["SERIAL_OVERSCAN_VALUES"] = columnValues 

485 

486 # Calculate parallel overscan statistics 

487 rows = [] 

488 rowValues = [] 

489 for idx in range(0, nParallelOverscanRows): 

490 parallelOverscanRowMean = afwMath.makeStatistics( 

491 parallelOverscanImage.array[idx, :], 

492 self.statType, 

493 self.statControl, 

494 ).getValue() 

495 rows.append(idx) 

496 # The overscan input is always in adu, but it only 

497 # makes sense to measure CTI in electron units. 

498 rowValues.append(parallelOverscanRowMean) 

499 

500 # We want these relative to the readout corner. If that's 

501 # on the top, we need to swap them. 

502 if readoutCorner in (ReadoutCorner.UR, ReadoutCorner.UL): 

503 ampStats["PARALLEL_OVERSCAN_ROWS"] = list(reversed(rows)) 

504 ampStats["PARALLEL_OVERSCAN_VALUES"] = list(reversed(rowValues)) 

505 else: 

506 ampStats["PARALLEL_OVERSCAN_ROWS"] = rows 

507 ampStats["PARALLEL_OVERSCAN_VALUES"] = rowValues 

508 

509 outputStats[amp.getName()] = ampStats 

510 

511 return outputStats 

512 

513 def measureCtiLegacy(self, inputExp, serialOverscans, gains): 

514 """Task to measure CTI statistics. 

515 

516 Parameters 

517 ---------- 

518 inputExp : `lsst.afw.image.Exposure` 

519 Exposure to measure. 

520 serialOverscans : `list` [`lsst.pipe.base.Struct`] 

521 List of serial overscan results (expects base units of adu). 

522 Expected fields are: 

523 

524 ``imageFit`` 

525 Value or fit subtracted from the amplifier image data 

526 (scalar or `lsst.afw.image.Image`). 

527 ``overscanFit`` 

528 Value or fit subtracted from the overscan image data 

529 (scalar or `lsst.afw.image.Image`). 

530 ``overscanImage`` 

531 Image of the overscan region with the overscan 

532 correction applied (`lsst.afw.image.Image`). This 

533 quantity is used to estimate the amplifier read noise 

534 empirically. 

535 gains : `dict` [`str` `float`] 

536 Dictionary of per-amplifier gains, indexed by amplifier name. 

537 

538 Returns 

539 ------- 

540 outputStats : `dict` [`str`, [`dict` [`str`, `float`]]] 

541 Dictionary of measurements, keyed by amplifier name and 

542 statistics segment. Everything in units based on electron. 

543 """ 

544 self.log.warning("Using the legacy version of CTI statistics may give wrong CTI") 

545 

546 outputStats = {} 

547 

548 detector = inputExp.getDetector() 

549 image = inputExp.image 

550 

551 # It only makes sense to measure CTI in electron units. 

552 # Make it so. 

553 imageUnits = inputExp.getMetadata().get("LSST ISR UNITS") 

554 applyGain = False 

555 if imageUnits == "adu": 

556 applyGain = True 

557 

558 # Check if the image is trimmed. 

559 isTrimmed = isTrimmedExposure(inputExp) 

560 

561 # Ensure we have the same number of overscans as amplifiers. 

562 assert len(serialOverscans) == len(detector.getAmplifiers()) 

563 

564 with gainContext(inputExp, image, applyGain, gains, isTrimmed=isTrimmed): 

565 for ampIter, amp in enumerate(detector.getAmplifiers()): 

566 ampStats = {} 

567 readoutCorner = amp.getReadoutCorner() 

568 

569 ampStats["INPUT_GAIN"] = float(gains[amp.getName()]) 

570 turnoff = inputExp.metadata[f"LSST ISR PTCTURNOFF {amp.getName()}"] 

571 ampStats["INPUT_PTCTURNOFF"] = float(turnoff) 

572 

573 # Full data region. 

574 dataRegion = image[amp.getBBox() if isTrimmed else amp.getRawDataBBox()] 

575 

576 # Get the mean of the image 

577 ampStats["IMAGE_MEAN"] = afwMath.makeStatistics(dataRegion, self.statType, 

578 self.statControl).getValue() 

579 

580 # First and last image columns. 

581 pixelA = afwMath.makeStatistics(dataRegion.array[:, 0], 

582 self.statType, 

583 self.statControl).getValue() 

584 pixelZ = afwMath.makeStatistics(dataRegion.array[:, -1], 

585 self.statType, 

586 self.statControl).getValue() 

587 

588 # We want these relative to the readout corner. If that's 

589 # on the right side, we need to swap them. 

590 if readoutCorner in (ReadoutCorner.LR, ReadoutCorner.UR): 

591 ampStats["FIRST_MEAN"] = pixelZ 

592 ampStats["LAST_MEAN"] = pixelA 

593 else: 

594 ampStats["FIRST_MEAN"] = pixelA 

595 ampStats["LAST_MEAN"] = pixelZ 

596 

597 # Measure the columns of the overscan. 

598 if serialOverscans[ampIter] is None: 

599 # The amplifier is likely entirely bad, and needs to 

600 # be skipped. 

601 self.log.warning("No overscan information available for ISR statistics for amp %s.", 

602 amp.getName()) 

603 nCols = amp.getRawSerialOverscanBBox().getWidth() 

604 ampStats["OVERSCAN_COLUMNS"] = np.full((nCols, ), np.nan) 

605 ampStats["OVERSCAN_VALUES"] = np.full((nCols, ), np.nan) 

606 else: 

607 overscanImage = serialOverscans[ampIter].overscanImage 

608 

609 columns = [] 

610 values = [] 

611 for column in range(0, overscanImage.getWidth()): 

612 # If overscan.doParallelOverscan=True, the 

613 # overscanImage will contain both the serial 

614 # and parallel overscan regions. 

615 # Only the serial CTI correction has been 

616 # implemented, so we must select only the 

617 # serial overscan rows for a given column. 

618 nRows = amp.getRawSerialOverscanBBox().getHeight() 

619 osMean = afwMath.makeStatistics(overscanImage.image.array[:nRows, column], 

620 self.statType, self.statControl).getValue() 

621 columns.append(column) 

622 # The overscan input is always in adu, but it only 

623 # makes sense to measure CTI in electron units. 

624 values.append(osMean * gains[amp.getName()]) 

625 

626 # We want these relative to the readout corner. If that's 

627 # on the right side, we need to swap them. 

628 if readoutCorner in (ReadoutCorner.LR, ReadoutCorner.UR): 

629 ampStats["OVERSCAN_COLUMNS"] = list(reversed(columns)) 

630 ampStats["OVERSCAN_VALUES"] = list(reversed(values)) 

631 else: 

632 ampStats["OVERSCAN_COLUMNS"] = columns 

633 ampStats["OVERSCAN_VALUES"] = values 

634 

635 outputStats[amp.getName()] = ampStats 

636 

637 return outputStats 

638 

639 @staticmethod 

640 def makeKernel(kernelSize): 

641 """Make a boxcar smoothing kernel. 

642 

643 Parameters 

644 ---------- 

645 kernelSize : `int` 

646 Size of the kernel in pixels. 

647 

648 Returns 

649 ------- 

650 kernel : `np.array` 

651 Kernel for boxcar smoothing. 

652 """ 

653 if kernelSize > 0: 

654 kernel = np.full(kernelSize, 1.0 / kernelSize) 

655 else: 

656 kernel = np.array([1.0]) 

657 return kernel 

658 

659 def measureBanding(self, inputExp, overscans): 

660 """Task to measure banding statistics. 

661 

662 Parameters 

663 ---------- 

664 inputExp : `lsst.afw.image.Exposure` 

665 Exposure to measure. 

666 overscans : `list` [`lsst.pipe.base.Struct`] 

667 List of overscan results. Expected fields are: 

668 

669 ``imageFit`` 

670 Value or fit subtracted from the amplifier image data 

671 (scalar or `lsst.afw.image.Image`). 

672 ``overscanFit`` 

673 Value or fit subtracted from the overscan image data 

674 (scalar or `lsst.afw.image.Image`). 

675 ``overscanImage`` 

676 Image of the overscan region with the overscan 

677 correction applied (`lsst.afw.image.Image`). This 

678 quantity is used to estimate the amplifier read noise 

679 empirically. 

680 

681 Returns 

682 ------- 

683 outputStats : `dict` [`str`, [`dict` [`str`, `float`]]] 

684 Dictionary of measurements, keyed by amplifier name and 

685 statistics segment. 

686 """ 

687 outputStats = {} 

688 

689 detector = inputExp.getDetector() 

690 kernel = self.makeKernel(self.config.bandingKernelSize) 

691 

692 outputStats["AMP_BANDING"] = [] 

693 for amp, overscanData in zip(detector.getAmplifiers(), overscans): 

694 overscanFit = np.array(overscanData.overscanFit) 

695 overscanArray = overscanData.overscanImage.image.array 

696 rawOverscan = np.mean(overscanArray + overscanFit, axis=1) 

697 

698 smoothedOverscan = np.convolve(rawOverscan, kernel, mode="valid") 

699 

700 low, high = np.quantile(smoothedOverscan, [self.config.bandingFractionLow, 

701 self.config.bandingFractionHigh]) 

702 outputStats["AMP_BANDING"].append(float(high - low)) 

703 

704 if self.config.bandingUseHalfDetector: 

705 fullLength = len(outputStats["AMP_BANDING"]) 

706 outputStats["DET_BANDING"] = float(np.nanmedian(outputStats["AMP_BANDING"][0:fullLength//2])) 

707 else: 

708 outputStats["DET_BANDING"] = float(np.nanmedian(outputStats["AMP_BANDING"])) 

709 

710 return outputStats 

711 

712 def measureProjectionStatistics(self, inputExp, overscans): 

713 """Task to measure metrics from image slicing. 

714 

715 Parameters 

716 ---------- 

717 inputExp : `lsst.afw.image.Exposure` 

718 Exposure to measure. 

719 overscans : `list` [`lsst.pipe.base.Struct`] 

720 List of overscan results. Expected fields are: 

721 

722 ``imageFit`` 

723 Value or fit subtracted from the amplifier image data 

724 (scalar or `lsst.afw.image.Image`). 

725 ``overscanFit`` 

726 Value or fit subtracted from the overscan image data 

727 (scalar or `lsst.afw.image.Image`). 

728 ``overscanImage`` 

729 Image of the overscan region with the overscan 

730 correction applied (`lsst.afw.image.Image`). This 

731 quantity is used to estimate the amplifier read noise 

732 empirically. 

733 

734 Returns 

735 ------- 

736 outputStats : `dict` [`str`, [`dict` [`str`, `float`]]] 

737 Dictionary of measurements, keyed by amplifier name and 

738 statistics segment. 

739 """ 

740 outputStats = {} 

741 

742 detector = inputExp.getDetector() 

743 kernel = self.makeKernel(self.config.projectionKernelSize) 

744 

745 outputStats["AMP_VPROJECTION"] = {} 

746 outputStats["AMP_HPROJECTION"] = {} 

747 convolveMode = "valid" 

748 if self.config.doProjectionFft: 

749 outputStats["AMP_VFFT_REAL"] = {} 

750 outputStats["AMP_VFFT_IMAG"] = {} 

751 outputStats["AMP_HFFT_REAL"] = {} 

752 outputStats["AMP_HFFT_IMAG"] = {} 

753 convolveMode = "same" 

754 

755 for amp in detector.getAmplifiers(): 

756 ampArray = inputExp.image[amp.getBBox()].array 

757 

758 horizontalProjection = np.mean(ampArray, axis=0) 

759 verticalProjection = np.mean(ampArray, axis=1) 

760 

761 horizontalProjection = np.convolve(horizontalProjection, kernel, mode=convolveMode) 

762 verticalProjection = np.convolve(verticalProjection, kernel, mode=convolveMode) 

763 

764 outputStats["AMP_HPROJECTION"][amp.getName()] = horizontalProjection.tolist() 

765 outputStats["AMP_VPROJECTION"][amp.getName()] = verticalProjection.tolist() 

766 

767 if self.config.doProjectionFft: 

768 horizontalWindow = np.ones_like(horizontalProjection) 

769 verticalWindow = np.ones_like(verticalProjection) 

770 if self.config.projectionFftWindow == "NONE": 

771 pass 

772 elif self.config.projectionFftWindow == "HAMMING": 

773 horizontalWindow = hamming(len(horizontalProjection)) 

774 verticalWindow = hamming(len(verticalProjection)) 

775 elif self.config.projectionFftWindow == "HANN": 

776 horizontalWindow = hann(len(horizontalProjection)) 

777 verticalWindow = hann(len(verticalProjection)) 

778 elif self.config.projectionFftWindow == "GAUSSIAN": 

779 horizontalWindow = gaussian(len(horizontalProjection)) 

780 verticalWindow = gaussian(len(verticalProjection)) 

781 else: 

782 raise RuntimeError(f"Invalid window function: {self.config.projectionFftWindow}") 

783 

784 horizontalFFT = np.fft.rfft(np.multiply(horizontalProjection, horizontalWindow)) 

785 verticalFFT = np.fft.rfft(np.multiply(verticalProjection, verticalWindow)) 

786 

787 outputStats["AMP_HFFT_REAL"][amp.getName()] = np.real(horizontalFFT).tolist() 

788 outputStats["AMP_HFFT_IMAG"][amp.getName()] = np.imag(horizontalFFT).tolist() 

789 outputStats["AMP_VFFT_REAL"][amp.getName()] = np.real(verticalFFT).tolist() 

790 outputStats["AMP_VFFT_IMAG"][amp.getName()] = np.imag(verticalFFT).tolist() 

791 

792 return outputStats 

793 

794 def copyCalibDistributionStatistics(self, inputExp, **kwargs): 

795 """Copy calibration statistics for this exposure. 

796 

797 Parameters 

798 ---------- 

799 inputExp : `lsst.afw.image.Exposure` 

800 The exposure being processed. 

801 **kwargs : 

802 Keyword arguments with calibrations. 

803 

804 Returns 

805 ------- 

806 outputStats : `dict` [`str`, [`dict` [`str`, `float`]]] 

807 Dictionary of measurements, keyed by amplifier name and 

808 statistics segment. 

809 """ 

810 outputStats = {} 

811 

812 # Amp level elements 

813 for amp in inputExp.getDetector(): 

814 ampStats = {} 

815 

816 for calibType in ("bias", "dark", "flat"): 

817 if kwargs.get(calibType, None) is not None: 

818 metadata = kwargs[calibType].getMetadata() 

819 for pct in self.config.expectedDistributionLevels: 

820 key = f"LSST CALIB {calibType.upper()} {amp.getName()} DISTRIBUTION {pct}-PCT" 

821 ampStats[key] = metadata.get(key, np.nan) 

822 

823 for calibType in ("defects"): 

824 if kwargs.get(calibType, None) is not None: 

825 metadata = kwargs[calibType].getMetadata() 

826 for key in (f"LSST CALIB {calibType.upper()} {amp.getName()} N_HOT", 

827 f"LSST CALIB {calibType.upper()} {amp.getName()} N_COLD"): 

828 ampStats[key] = metadata.get(key, np.nan) 

829 outputStats[amp.getName()] = ampStats 

830 

831 # Detector level elements 

832 for calibType in ("defects"): 

833 if kwargs.get(calibType, None) is not None: 

834 metadata = kwargs[calibType].getMetadata() 

835 for key in (f"LSST CALIB {calibType.upper()} N_BAD_COLUMNS"): 

836 outputStats["detector"][key] = metadata.get(key, np.nan) 

837 

838 return outputStats 

839 

840 def measureBiasShifts(self, inputExp, serialOverscanResults): 

841 """Measure number of bias shifts from overscan data. 

842 

843 Parameters 

844 ---------- 

845 inputExp : `lsst.afw.image.Exposure` 

846 Exposure to measure. 

847 overscans : `list` [`lsst.pipe.base.Struct`] 

848 List of overscan results. Expected fields are: 

849 

850 ``imageFit`` 

851 Value or fit subtracted from the amplifier image data 

852 (scalar or `lsst.afw.image.Image`). 

853 ``overscanFit`` 

854 Value or fit subtracted from the overscan image data 

855 (scalar or `lsst.afw.image.Image`). 

856 ``overscanImage`` 

857 Image of the overscan region with the overscan 

858 correction applied (`lsst.afw.image.Image`). This 

859 quantity is used to estimate the amplifier read noise 

860 empirically. 

861 

862 Returns 

863 ------- 

864 outputStats : `dict` [`str`, [`dict` [`str`, `float`]]] 

865 Dictionary of measurements, keyed by amplifier name and 

866 statistics segment. 

867 

868 Notes 

869 ----- 

870 Based on eo_pipe implementation: 

871 https://github.com/lsst-camera-dh/eo_pipe/blob/main/python/lsst/eo/pipe/biasShiftsTask.py # noqa: E501 W505 

872 """ 

873 outputStats = {} 

874 

875 detector = inputExp.getDetector() 

876 for amp, overscans in zip(detector, serialOverscanResults): 

877 ampStats = {} 

878 

879 # Check if the overscan results are None 

880 # This can happen if the amp is in the 

881 # input PTC's badAmp list, for instance. 

882 if overscans is None: 

883 ampStats["LOCAL_NOISE"] = 0.0 

884 ampStats["BIAS_SHIFTS"] = [] 

885 outputStats[amp.getName()] = ampStats 

886 continue 

887 

888 # Add fit back to data 

889 rawOverscan = overscans.overscanImage.image.array + overscans.overscanFit 

890 

891 # Collapse array, skipping first three columns 

892 rawOverscan = np.mean(rawOverscan[:, self.config.biasShiftColumnSkip:], axis=1) 

893 

894 # Scan for shifts 

895 noise, shift_peaks = self._scan_for_shifts(rawOverscan) 

896 ampStats["LOCAL_NOISE"] = float(noise) 

897 ampStats["BIAS_SHIFTS"] = shift_peaks 

898 

899 outputStats[amp.getName()] = ampStats 

900 return outputStats 

901 

902 def _scan_for_shifts(self, overscanData): 

903 """Scan overscan data for shifts. 

904 

905 Parameters 

906 ---------- 

907 overscanData : `list` [`float`] 

908 Overscan data to search for shifts. 

909 

910 Returns 

911 ------- 

912 noise : `float` 

913 Noise estimated from Butterworth filtered overscan data. 

914 peaks : `list` [`float`, `float`, `int`, `int`] 

915 Shift peak information, containing the convolved peak 

916 value, the raw peak value, and the lower and upper bounds 

917 of the region checked. 

918 """ 

919 numerator, denominator = butter(self.config.biasShiftFilterOrder, 

920 self.config.biasShiftCutoff, 

921 btype="high", analog=False) 

922 noise = np.std(filtfilt(numerator, denominator, overscanData)) 

923 kernel = np.concatenate([np.arange(self.config.biasShiftWindow), 

924 np.arange(-self.config.biasShiftWindow + 1, 0)]) 

925 kernel = kernel/np.sum(kernel[:self.config.biasShiftWindow]) 

926 

927 convolved = np.convolve(overscanData, kernel, mode="valid") 

928 convolved = np.pad(convolved, (self.config.biasShiftWindow - 1, self.config.biasShiftWindow)) 

929 

930 shift_check = np.abs(convolved)/noise 

931 shift_mask = shift_check > self.config.biasShiftThreshold 

932 shift_mask[:self.config.biasShiftRowSkip] = False 

933 

934 shift_regions = np.flatnonzero(np.diff(np.r_[np.int8(0), 

935 shift_mask.view(np.int8), 

936 np.int8(0)])).reshape(-1, 2) 

937 shift_peaks = [] 

938 for region in shift_regions: 

939 region_peak = np.argmax(shift_check[region[0]:region[1]]) + region[0] 

940 if self._satisfies_flatness(region_peak, convolved[region_peak], overscanData): 

941 shift_peaks.append( 

942 [float(convolved[region_peak]), float(region_peak), 

943 int(region[0]), int(region[1])]) 

944 return noise, shift_peaks 

945 

946 def _satisfies_flatness(self, shiftRow, shiftPeak, overscanData): 

947 """Determine if a region is flat. 

948 

949 Parameters 

950 ---------- 

951 shiftRow : `int` 

952 Row with possible peak. 

953 shiftPeak : `float` 

954 Value at the possible peak. 

955 overscanData : `list` [`float`] 

956 Overscan data used to fit around the possible peak. 

957 

958 Returns 

959 ------- 

960 isFlat : `bool` 

961 Indicates if the region is flat, and so the peak is valid. 

962 """ 

963 prerange = np.arange(shiftRow - self.config.biasShiftWindow, shiftRow) 

964 postrange = np.arange(shiftRow, shiftRow + self.config.biasShiftWindow) 

965 

966 preFit = linregress(prerange, overscanData[prerange]) 

967 postFit = linregress(postrange, overscanData[postrange]) 

968 

969 if shiftPeak > 0: 

970 preTrend = (2*preFit[0]*len(prerange) < shiftPeak) 

971 postTrend = (2*postFit[0]*len(postrange) < shiftPeak) 

972 else: 

973 preTrend = (2*preFit[0]*len(prerange) > shiftPeak) 

974 postTrend = (2*postFit[0]*len(postrange) > shiftPeak) 

975 

976 return (preTrend and postTrend) 

977 

978 def measureAmpCorrelations(self, inputExp, serialOverscanResults): 

979 """Measure correlations between amplifier segments. 

980 

981 Parameters 

982 ---------- 

983 inputExp : `lsst.afw.image.Exposure` 

984 Exposure to measure. 

985 overscans : `list` [`lsst.pipe.base.Struct`] 

986 List of overscan results. Expected fields are: 

987 

988 ``imageFit`` 

989 Value or fit subtracted from the amplifier image data 

990 (scalar or `lsst.afw.image.Image`). 

991 ``overscanFit`` 

992 Value or fit subtracted from the overscan image data 

993 (scalar or `lsst.afw.image.Image`). 

994 ``overscanImage`` 

995 Image of the overscan region with the overscan 

996 correction applied (`lsst.afw.image.Image`). This 

997 quantity is used to estimate the amplifier read noise 

998 empirically. 

999 

1000 Returns 

1001 ------- 

1002 outputStats : `dict` [`str`, [`dict` [`str`, `float`]]] 

1003 Dictionary of measurements, keyed by amplifier name and 

1004 statistics segment. 

1005 

1006 Notes 

1007 ----- 

1008 Based on eo_pipe implementation: 

1009 https://github.com/lsst-camera-dh/eo_pipe/blob/main/python/lsst/eo/pipe/raft_level_correlations.py # noqa: E501 W505 

1010 """ 

1011 detector = inputExp.getDetector() 

1012 rows = [] 

1013 

1014 for ampId, overscan in enumerate(serialOverscanResults): 

1015 # Check if the overscan results are None 

1016 # This can happen if the amp is in the 

1017 # input PTC's badAmp list, for instance. 

1018 if overscan is None: 

1019 for ampId2, overscan2 in enumerate(serialOverscanResults): 

1020 if ampId2 != ampId: 

1021 osC = 0.0 

1022 imC = 0.0 

1023 row = { 

1024 'detector': detector.getId(), 

1025 'detectorComp': detector.getId(), 

1026 'ampName': detector[ampId].getName(), 

1027 'ampComp': detector[ampId2].getName(), 

1028 'imageCorr': float(imC), 

1029 'overscanCorr': float(osC), 

1030 } 

1031 rows.append(row) 

1032 else: 

1033 rawOverscan = overscan.overscanImage.image.array + overscan.overscanFit 

1034 rawOverscan = rawOverscan.ravel() 

1035 

1036 ampImage = inputExp[detector[ampId].getBBox()] 

1037 ampImage = ampImage.image.array.ravel() 

1038 

1039 for ampId2, overscan2 in enumerate(serialOverscanResults): 

1040 osC = 1.0 

1041 imC = 1.0 

1042 if ampId2 != ampId: 

1043 # Check if the overscan results are None 

1044 # This can happen if the amp is in the 

1045 # input PTC's badAmp list, for instance. 

1046 if overscan2 is None: 

1047 osC = 0.0 

1048 imC = 0.0 

1049 else: 

1050 rawOverscan2 = overscan2.overscanImage.image.array + overscan2.overscanFit 

1051 rawOverscan2 = rawOverscan2.ravel() 

1052 

1053 osC = np.corrcoef(rawOverscan, rawOverscan2)[0, 1] 

1054 

1055 ampImage2 = inputExp[detector[ampId2].getBBox()] 

1056 ampImage2 = ampImage2.image.array.ravel() 

1057 

1058 imC = np.corrcoef(ampImage, ampImage2)[0, 1] 

1059 row = { 

1060 'detector': detector.getId(), 

1061 'detectorComp': detector.getId(), 

1062 'ampName': detector[ampId].getName(), 

1063 'ampComp': detector[ampId2].getName(), 

1064 'imageCorr': float(imC), 

1065 'overscanCorr': float(osC), 

1066 } 

1067 rows.append(row) 

1068 return rows 

1069 

1070 def measureDivisaderoStatistics(self, inputExp, **kwargs): 

1071 """Measure Max Divisadero Tearing effect per amp. 

1072 

1073 Parameters 

1074 ---------- 

1075 inputExp : `lsst.afw.image.Exposure` 

1076 Exposure to measure. Usually a flat. 

1077 **kwargs : 

1078 The flat will be selected from here. 

1079 

1080 Returns 

1081 ------- 

1082 outputStats : `dict` [`str`, [`dict` [`str`, `float`]]] 

1083 Dictionary of measurements, keyed by amplifier name and 

1084 statistics segment. 

1085 Measurements include 

1086 

1087 - DIVISADERO_PROFILE: Robust mean of rows between 

1088 divisaderoProjection<Maximum|Minumum> on readout edge of ccd 

1089 normalized by a linear fit to the same rows. 

1090 - DIVISADERO_MAX_PAIR: Tuple of maximum of the absolute values of 

1091 the DIVISADERO_PROFILE, for number of pixels (specified by 

1092 divisaderoNumImpactPixels on left and right side of amp. 

1093 - DIVISADERO_MAX: Maximum of the absolute values of the 

1094 the DIVISADERO_PROFILE, for the divisaderoNumImpactPixels on 

1095 boundaries of neighboring amps (including the pixels in those 

1096 neighborboring amps). 

1097 """ 

1098 outputStats = {} 

1099 

1100 for amp in inputExp.getDetector(): 

1101 # Copy unneeded if we do not ever modify the array by flipping 

1102 ampArray = inputExp.image[amp.getBBox()].array 

1103 # slice the outer top or bottom of the amp: the readout side 

1104 if amp.getReadoutCorner().name in ('UL', 'UR'): 

1105 minRow = amp.getBBox().getHeight() - self.config.divisaderoProjectionMaximum 

1106 maxRow = amp.getBBox().getHeight() - self.config.divisaderoProjectionMinimum 

1107 else: 

1108 minRow = self.config.divisaderoProjectionMinimum 

1109 maxRow = self.config.divisaderoProjectionMaximum 

1110 

1111 segment = slice(minRow, maxRow) 

1112 projection, _, _ = astropy.stats.sigma_clipped_stats(ampArray[segment, :], axis=0) 

1113 

1114 ampStats = {} 

1115 projection /= np.median(projection) 

1116 columns = np.arange(len(projection)) 

1117 

1118 segment = slice(self.config.divisaderoEdgePixels, -self.config.divisaderoEdgePixels) 

1119 model = np.polyfit(columns[segment], projection[segment], 1) 

1120 modelProjection = model[0] * columns + model[1] 

1121 divisaderoProfile = projection / modelProjection 

1122 

1123 # look for max at the edges: 

1124 leftMax = np.nanmax(np.abs(divisaderoProfile[0:self.config.divisaderoNumImpactPixels] - 1.0)) 

1125 rightMax = np.nanmax(np.abs(divisaderoProfile[-self.config.divisaderoNumImpactPixels:] - 1.0)) 

1126 

1127 ampStats['DIVISADERO_PROFILE'] = np.array(divisaderoProfile).tolist() 

1128 ampStats['DIVISADERO_MAX_PAIR'] = [leftMax, rightMax] 

1129 outputStats[amp.getName()] = ampStats 

1130 

1131 detector = inputExp.getDetector() 

1132 xCenters = [amp.getBBox().getCenterX() for amp in detector] 

1133 yCenters = [amp.getBBox().getCenterY() for amp in detector] 

1134 xIndices = np.ceil(xCenters / np.min(xCenters) / 2).astype(int) - 1 

1135 yIndices = np.ceil(yCenters / np.min(yCenters) / 2).astype(int) - 1 

1136 ampIds = np.zeros((len(set(yIndices)), len(set(xIndices))), dtype=int) 

1137 for ampId, xIndex, yIndex in zip(np.arange(len(detector)), xIndices, yIndices): 

1138 ampIds[yIndex, xIndex] = ampId 

1139 

1140 # Loop over amps again because the DIVISIDERO_MAX will be the max 

1141 # of the profile on its boundary with its neighboring amps 

1142 for i, amp in enumerate(detector): 

1143 y, x = np.where(ampIds == i) 

1144 end = ampIds.shape[1] - 1 

1145 xInd = x[0] 

1146 yInd = y[0] 

1147 thisAmpsPair = outputStats[amp.getName()]['DIVISADERO_MAX_PAIR'] 

1148 

1149 if x == 0: 

1150 # leftmost amp: take the max of your right side and 

1151 myMax = thisAmpsPair[1] 

1152 # your neighbor's left side 

1153 neighborMax = outputStats[detector[ampIds[yInd, 1]].getName()]['DIVISADERO_MAX_PAIR'][0] 

1154 elif x == end: 

1155 # rightmost amp: take the max of your left side and 

1156 myMax = thisAmpsPair[0] 

1157 # your neighbor's right side 

1158 neighborMax = outputStats[detector[ampIds[yInd, end - 1]].getName()]['DIVISADERO_MAX_PAIR'][1] 

1159 else: 

1160 # Middle amp: take the max of both your own sides and the 

1161 myMax = max(thisAmpsPair) 

1162 leftName = detector[ampIds[yInd, max(xInd - 1, 0)]].getName() 

1163 rightName = detector[ampIds[yInd, min(xInd + 1, ampIds.shape[1] - 1)]].getName() 

1164 # right side of the neighbor to your left 

1165 # and left side of your neighbor to your right 

1166 neighborMax = max(outputStats[leftName]['DIVISADERO_MAX_PAIR'][1], 

1167 outputStats[rightName]['DIVISADERO_MAX_PAIR'][0]) 

1168 

1169 divisaderoMax = max([myMax, neighborMax]) 

1170 outputStats[amp.getName()]['DIVISADERO_MAX'] = divisaderoMax 

1171 

1172 return outputStats