Coverage for python/lsst/obs/subaru/ampOffset.py: 14%

69 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-10-05 12:32 -0700

1# This file is part of obs_subaru. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <https://www.gnu.org/licenses/>. 

21 

22import numpy as np 

23import warnings 

24 

25import lsst.afw.table as afwTable 

26import lsst.afw.math as afwMath 

27from lsst.ip.isr.ampOffset import (AmpOffsetConfig, AmpOffsetTask) 

28 

29 

30class SubaruAmpOffsetConfig(AmpOffsetConfig): 

31 """Configuration parameters for SubaruAmpOffsetTask. 

32 """ 

33 

34 def setDefaults(self): 

35 # the binSize should be as large as possible, preventing background 

36 # subtraction from inadvertently removing the amp offset signature. 

37 # Here it's set to the width of an HSC amp, which seems reasonable. 

38 self.background.binSize = 512 

39 self.background.algorithm = "AKIMA_SPLINE" 

40 self.background.useApprox = False 

41 self.background.ignoredPixelMask = ["BAD", "SAT", "INTRP", "CR", "EDGE", "DETECTED", 

42 "DETECTED_NEGATIVE", "SUSPECT", "NO_DATA"] 

43 self.detection.reEstimateBackground = False 

44 

45 

46class SubaruAmpOffsetTask(AmpOffsetTask): 

47 """Calculate and apply amp offset corrections to an exposure. 

48 """ 

49 ConfigClass = SubaruAmpOffsetConfig 

50 _DefaultName = "subaruIsrAmpOffset" 

51 

52 def __init__(self, *args, **kwargs): 

53 AmpOffsetTask.__init__(self, *args, **kwargs) 

54 

55 def run(self, exposure): 

56 """Calculate amp offset values, determine corrective pedestals for each 

57 amp, and update the input exposure in-place. 

58 

59 Parameters 

60 ---------- 

61 exposure: `lsst.afw.image.Exposure` 

62 Exposure to be corrected for amp offsets. 

63 """ 

64 

65 # generate a clone to work on and establish the bit mask 

66 exp = exposure.clone() 

67 bitMask = exp.mask.getPlaneBitMask(self.background.config.ignoredPixelMask) 

68 self.log.debug(f"Ignoring mask planes: {', '.join(self.background.config.ignoredPixelMask)}") 

69 

70 # fit and subtract background 

71 if self.config.doBackground: 

72 maskedImage = exp.getMaskedImage() 

73 bg = self.background.fitBackground(maskedImage) 

74 bgImage = bg.getImageF(self.background.config.algorithm, self.background.config.undersampleStyle) 

75 maskedImage -= bgImage 

76 

77 # detect sources and update cloned exposure mask planes in-place 

78 if self.config.doDetection: 

79 schema = afwTable.SourceTable.makeMinimalSchema() 

80 table = afwTable.SourceTable.make(schema) 

81 # detection sigma, used for smoothing and to grow detections, is 

82 # normally measured from the PSF of the exposure. As the PSF hasn't 

83 # been measured at this stage of processing, sigma is instead 

84 # set to an approximate value here which should be sufficient. 

85 _ = self.detection.run(table=table, exposure=exp, sigma=2) 

86 

87 # safety check: do any pixels remain for amp offset estimation? 

88 if (exp.mask.array & bitMask).all(): 

89 warnings.warn("All pixels masked: cannot calculate any amp offset corrections.") 

90 else: 

91 # set up amp offset inputs 

92 im = exp.image 

93 im.array[(exp.mask.array & bitMask) > 0] = np.nan 

94 amps = exp.getDetector().getAmplifiers() 

95 ampEdgeOuter = self.config.ampEdgeInset + self.config.ampEdgeWidth 

96 sctrl = afwMath.StatisticsControl() 

97 

98 # loop over each amp edge boundary to extract amp offset values 

99 ampOffsets = [] 

100 for ii in range(1, len(amps)): 

101 ampA = im[amps[ii - 1].getBBox()].array 

102 ampB = im[amps[ii].getBBox()].array 

103 stripA = ampA[:, -ampEdgeOuter:][:, :self.config.ampEdgeWidth] 

104 stripB = ampB[:, :ampEdgeOuter][:, -self.config.ampEdgeWidth:] 

105 # catch warnings to prevent all-NaN slice RuntimeWarning 

106 with warnings.catch_warnings(): 

107 warnings.filterwarnings('ignore', r'All-NaN (slice|axis) encountered') 

108 edgeA = np.nanmedian(stripA, axis=1) 

109 edgeB = np.nanmedian(stripB, axis=1) 

110 edgeDiff = edgeB - edgeA 

111 # compute rolling averages 

112 edgeDiffSum = np.convolve(np.nan_to_num(edgeDiff), np.ones(self.config.ampEdgeWindow), 'same') 

113 edgeDiffNum = np.convolve(~np.isnan(edgeDiff), np.ones(self.config.ampEdgeWindow), 'same') 

114 edgeDiffAvg = edgeDiffSum / np.clip(edgeDiffNum, 1, None) 

115 edgeDiffAvg[np.isnan(edgeDiff)] = np.nan 

116 # take clipped mean of rolling average data as amp offset value 

117 ampOffset = afwMath.makeStatistics(edgeDiffAvg, afwMath.MEANCLIP, sctrl).getValue() 

118 # perform a couple of do-no-harm safety checks: 

119 # a) the fraction of unmasked pixel rows is > ampEdgeMinFrac, 

120 # b) the absolute offset ADU value is < ampEdgeMaxOffset 

121 ampEdgeGoodFrac = 1 - (np.sum(np.isnan(edgeDiffAvg))/len(edgeDiffAvg)) 

122 minFracFail = ampEdgeGoodFrac < self.config.ampEdgeMinFrac 

123 maxOffsetFail = np.abs(ampOffset) > self.config.ampEdgeMaxOffset 

124 if minFracFail or maxOffsetFail: 

125 ampOffset = 0 

126 ampOffsets.append(ampOffset) 

127 self.log.debug(f"amp edge {ii}{ii+1} : " 

128 f"viable edge frac = {ampEdgeGoodFrac}, " 

129 f"edge offset = {ampOffset:.3f}") 

130 

131 # solve for pedestal values and update original exposure in-place 

132 A = np.array([[-1.0, 1.0, 0.0, 0.0], 

133 [1.0, -2.0, 1.0, 0.0], 

134 [0.0, 1.0, -2.0, 1.0], 

135 [0.0, 0.0, 1.0, -1.0]]) 

136 B = np.array([ampOffsets[0], 

137 ampOffsets[1] - ampOffsets[0], 

138 ampOffsets[2] - ampOffsets[1], 

139 -ampOffsets[2]]) 

140 # if least-squares minimization fails, convert NaNs to zeroes, 

141 # ensuring that no values are erroneously added/subtracted 

142 pedestals = np.nan_to_num(np.linalg.lstsq(A, B, rcond=None)[0]) 

143 metadata = exposure.getMetadata() 

144 for ii, (amp, pedestal) in enumerate(zip(amps, pedestals)): 

145 ampIm = exposure.image[amp.getBBox()].array 

146 ampIm -= pedestal 

147 metadata.set(f"PEDESTAL{ii + 1}", 

148 float(pedestal), 

149 f"Pedestal level subtracted from amp {ii + 1}") 

150 self.log.info(f"amp pedestal values: {', '.join([f'{x:.2f}' for x in pedestals])}")