Coverage for python/lsst/ip/isr/photodiode.py: 21%

101 statements  

« prev     ^ index     » next       coverage.py v7.5.1, created at 2024-05-15 02:10 -0700

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""" 

22Photodiode storage class. 

23""" 

24 

25__all__ = ["PhotodiodeCalib"] 

26 

27import numpy as np 

28from astropy.table import Table 

29 

30from lsst.ip.isr import IsrCalib 

31 

32 

33class PhotodiodeCalib(IsrCalib): 

34 """Independent current measurements from photodiode for linearity 

35 calculations. 

36 

37 Parameters 

38 ---------- 

39 timeSamples : `list` or `numpy.ndarray` 

40 List of samples the photodiode was measured at. 

41 currentSamples : `list` or `numpy.ndarray` 

42 List of current measurements at each time sample. 

43 log : `lsst.log.Log`, optional 

44 Log to write messages to. 

45 **kwargs : 

46 Additional parameters. These will be passed to the parent 

47 constructor with the exception of: 

48 

49 ``"integrationMethod"`` 

50 Name of the algorithm to use to integrate the current 

51 samples. Allowed values are ``DIRECT_SUM``, 

52 ``TRIMMED_SUM``, and ``CHARGE_SUM`` (`str`). 

53 ``"currentScale"`` 

54 Scale factor to apply to the current samples for the 

55 ``CHARGE_SUM`` integration method. A typical value 

56 would be `-1`, to flip the sign of the integrated charge. 

57 """ 

58 

59 _OBSTYPE = 'PHOTODIODE' 

60 _SCHEMA = 'Photodiode' 

61 _VERSION = 1.0 

62 

63 def __init__(self, timeSamples=None, currentSamples=None, **kwargs): 

64 if timeSamples is not None and currentSamples is not None: 

65 if len(timeSamples) != len(currentSamples): 

66 raise RuntimeError(f"Inconsitent vector lengths: {len(timeSamples)} vs {len(currentSamples)}") 

67 else: 

68 self.timeSamples = np.array(timeSamples).ravel() 

69 self.currentSamples = np.array(currentSamples).ravel() 

70 else: 

71 self.timeSamples = np.array([]).ravel() 

72 self.currentSamples = np.array([]).ravel() 

73 

74 super().__init__(**kwargs) 

75 

76 if 'integrationMethod' in kwargs: 

77 self.integrationMethod = kwargs.pop('integrationMethod') 

78 else: 

79 self.integrationMethod = 'DIRECT_SUM' 

80 

81 if 'currentScale' in kwargs: 

82 self.currentScale = kwargs.pop('currentScale') 

83 else: 

84 self.currentScale = 1.0 

85 

86 if 'day_obs' in kwargs: 

87 self.updateMetadata(day_obs=kwargs['day_obs']) 

88 if 'seq_num' in kwargs: 

89 self.updateMetadata(seq_num=kwargs['seq_num']) 

90 

91 self.requiredAttributes.update(['timeSamples', 'currentSamples', 'integrationMethod']) 

92 

93 @classmethod 

94 def fromDict(cls, dictionary): 

95 """Construct a PhotodiodeCalib from a dictionary of properties. 

96 

97 Parameters 

98 ---------- 

99 dictionary : `dict` 

100 Dictionary of properties. 

101 

102 Returns 

103 ------- 

104 calib : `lsst.ip.isr.PhotodiodeCalib` 

105 Constructed photodiode data. 

106 

107 Raises 

108 ------ 

109 RuntimeError 

110 Raised if the supplied dictionary is for a different 

111 calibration type. 

112 """ 

113 calib = cls() 

114 

115 if calib._OBSTYPE != dictionary['metadata']['OBSTYPE']: 

116 raise RuntimeError(f"Incorrect photodiode supplied. Expected {calib._OBSTYPE}, " 

117 f"found {dictionary['metadata']['OBSTYPE']}") 

118 

119 calib.setMetadata(dictionary['metadata']) 

120 

121 calib.timeSamples = np.array(dictionary['timeSamples']).ravel() 

122 calib.currentSamples = np.array(dictionary['currentSamples']).ravel() 

123 calib.integrationMethod = dictionary.get('integrationMethod', "DIRECT_SUM") 

124 

125 calib.updateMetadata() 

126 return calib 

127 

128 def toDict(self): 

129 """Return a dictionary containing the photodiode properties. 

130 

131 The dictionary should be able to be round-tripped through. 

132 `fromDict`. 

133 

134 Returns 

135 ------- 

136 dictionary : `dict` 

137 Dictionary of properties. 

138 """ 

139 self.updateMetadata() 

140 

141 outDict = {} 

142 outDict['metadata'] = self.getMetadata() 

143 

144 outDict['timeSamples'] = self.timeSamples.tolist() 

145 outDict['currentSamples'] = self.currentSamples.tolist() 

146 

147 outDict['integrationMethod'] = self.integrationMethod 

148 

149 return outDict 

150 

151 @classmethod 

152 def fromTable(cls, tableList): 

153 """Construct calibration from a list of tables. 

154 

155 This method uses the `fromDict` method to create the 

156 calibration after constructing an appropriate dictionary from 

157 the input tables. 

158 

159 Parameters 

160 ---------- 

161 tableList : `list` [`astropy.table.Table`] 

162 List of tables to use to construct the crosstalk 

163 calibration. 

164 

165 Returns 

166 ------- 

167 calib : `lsst.ip.isr.PhotodiodeCalib` 

168 The calibration defined in the tables. 

169 """ 

170 dataTable = tableList[0] 

171 

172 metadata = dataTable.meta 

173 inDict = {} 

174 inDict['metadata'] = metadata 

175 inDict['integrationMethod'] = metadata.pop('INTEGRATION_METHOD', 'DIRECT_SUM') 

176 

177 inDict['timeSamples'] = dataTable['TIME'] 

178 inDict['currentSamples'] = dataTable['CURRENT'] 

179 

180 return cls().fromDict(inDict) 

181 

182 def toTable(self): 

183 """Construct a list of tables containing the information in this 

184 calibration. 

185 

186 The list of tables should create an identical calibration 

187 after being passed to this class's fromTable method. 

188 

189 Returns 

190 ------- 

191 tableList : `list` [`astropy.table.Table`] 

192 List of tables containing the photodiode calibration 

193 information. 

194 """ 

195 self.updateMetadata() 

196 catalog = Table([{'TIME': self.timeSamples, 

197 'CURRENT': self.currentSamples}]) 

198 inMeta = self.getMetadata().toDict() 

199 outMeta = {k: v for k, v in inMeta.items() if v is not None} 

200 outMeta.update({k: "" for k, v in inMeta.items() if v is None}) 

201 outMeta['INTEGRATION_METHOD'] = self.integrationMethod 

202 catalog.meta = outMeta 

203 

204 return [catalog] 

205 

206 @classmethod 

207 def readTwoColumnPhotodiodeData(cls, filename): 

208 """Construct a PhotodiodeCalib by reading the simple column format. 

209 

210 Parameters 

211 ---------- 

212 filename : `str` 

213 File to read samples from. 

214 

215 Returns 

216 ------- 

217 calib : `lsst.ip.isr.PhotodiodeCalib` 

218 The calibration defined in the file. 

219 """ 

220 import os.path 

221 

222 rawData = np.loadtxt(filename, dtype=[('time', 'float'), ('current', 'float')]) 

223 

224 basename = os.path.basename(filename) 

225 cleaned = os.path.splitext(basename)[0] 

226 _, _, day_obs, seq_num = cleaned.split("_") 

227 

228 return cls(timeSamples=rawData['time'], currentSamples=rawData['current'], 

229 day_obs=int(day_obs), seq_num=int(seq_num)) 

230 

231 def integrate(self): 

232 """Integrate the current. 

233 

234 Raises 

235 ------ 

236 RuntimeError 

237 Raised if the integration method is not known. 

238 """ 

239 if self.integrationMethod == 'DIRECT_SUM': 

240 return self.integrateDirectSum() 

241 elif self.integrationMethod == 'TRIMMED_SUM': 

242 return self.integrateTrimmedSum() 

243 elif self.integrationMethod == 'CHARGE_SUM': 

244 return self.integrateChargeSum() 

245 else: 

246 raise RuntimeError(f"Unknown integration method {self.integrationMethod}") 

247 

248 def integrateDirectSum(self): 

249 """Integrate points. 

250 

251 This uses numpy's trapezoidal integrator. 

252 

253 Returns 

254 ------- 

255 sum : `float` 

256 Total charge measured. 

257 """ 

258 return np.trapz(self.currentSamples, x=self.timeSamples) 

259 

260 def integrateTrimmedSum(self): 

261 """Integrate points with a baseline level subtracted. 

262 

263 This uses numpy's trapezoidal integrator. 

264 

265 Returns 

266 ------- 

267 sum : `float` 

268 Total charge measured. 

269 

270 See Also 

271 -------- 

272 lsst.eotask.gen3.eoPtc 

273 """ 

274 currentThreshold = ((max(self.currentSamples) - min(self.currentSamples))/5.0 

275 + min(self.currentSamples)) 

276 lowValueIndices = np.where(self.currentSamples < currentThreshold) 

277 baseline = np.median(self.currentSamples[lowValueIndices]) 

278 return np.trapz(self.currentSamples - baseline, self.timeSamples) 

279 

280 def integrateChargeSum(self): 

281 """For this method, the values in .currentSamples are actually the 

282 integrated charge values as measured by the ammeter for each 

283 sampling interval. We need to do a baseline subtraction, 

284 based on the charge values when the LED is off, then sum up 

285 the corrected signals. 

286 

287 Returns 

288 ------- 

289 sum : `float` 

290 Total charge measured. 

291 """ 

292 dt = self.timeSamples[1:] - self.timeSamples[:-1] 

293 # The .currentSamples values are the current integrals over 

294 # the interval preceding the current time stamp, so omit the 

295 # first value. 

296 charge = self.currentScale*self.currentSamples[1:] 

297 # The current per interval to use for baseline subtraction 

298 # without assuming all of the dt values are the same: 

299 current = charge/dt 

300 # To determine the baseline current level, exclude points with 

301 # signal levels > 5% of the maximum (measured relative to the 

302 # overall minimum), and extend that selection 2 entries on 

303 # either side to avoid otherwise low-valued points that sample 

304 # the signal ramp and which should not be included in the 

305 # baseline estimate. 

306 dy = np.max(current) - np.min(current) 

307 signal, = np.where(current > dy/20. + np.min(current)) 

308 imin = signal[0] - 2 

309 imax = signal[-1] + 2 

310 bg = np.concatenate([np.arange(0, imin), np.arange(imax, len(current))]) 

311 bg_current = np.sum(charge[bg])/np.sum(dt[bg]) 

312 # Return the background-subtracted total charge. 

313 return np.sum(charge - bg_current*dt)