Coverage for python/lsst/obs/base/makeRawVisitInfo.py: 26%

133 statements  

« prev     ^ index     » next       coverage.py v6.4.1, created at 2022-06-24 02:02 -0700

1# This file is part of obs_base. 

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__ = ["MakeRawVisitInfo"] 

23 

24import logging 

25import math 

26 

27import astropy.coordinates 

28import astropy.time 

29import astropy.units 

30import numpy as np 

31from lsst.afw.image import VisitInfo 

32from lsst.daf.base import DateTime 

33from lsst.geom import degrees 

34 

35PascalPerMillibar = 100.0 

36PascalPerMmHg = 133.322387415 # from Wikipedia; exact 

37PascalPerTorr = 101325.0 / 760.0 # from Wikipedia; exact 

38KelvinMinusCentigrade = 273.15 # from Wikipedia; exact 

39 

40# have these read at need, to avoid unexpected errors later 

41NaN = float("nan") 

42BadDate = DateTime() 

43 

44 

45class MakeRawVisitInfo: 

46 """Base class functor to make a VisitInfo from the FITS header of a raw 

47 image. 

48 

49 A subclass will be wanted for each camera. Subclasses should override: 

50 

51 - `setArgDict`, The override can call the base implementation, 

52 which simply sets exposure time and date of observation 

53 - `getDateAvg` 

54 

55 The design philosophy is to make a best effort and log warnings of 

56 problems, rather than raising exceptions, in order to extract as much 

57 VisitInfo information as possible from a messy FITS header without the 

58 user needing to add a lot of error handling. 

59 

60 However, the methods that transform units are less forgiving; they assume 

61 the user provides proper data types, since type errors in arguments to 

62 those are almost certainly due to coding mistakes. 

63 

64 Parameters 

65 ---------- 

66 log : `logging.Logger` or None 

67 Logger to use for messages. 

68 (None to use ``logging.getLogger("lsst.obs.base.makeRawVisitInfo")``). 

69 doStripHeader : `bool`, optional 

70 Strip header keywords from the metadata as they are used? 

71 """ 

72 

73 def __init__(self, log=None, doStripHeader=False): 

74 if log is None: 

75 log = logging.getLogger(__name__) 

76 self.log = log 

77 self.doStripHeader = doStripHeader 

78 

79 def __call__(self, md, exposureId): 

80 """Construct a VisitInfo and strip associated data from the metadata. 

81 

82 Parameters 

83 ---------- 

84 md : `lsst.daf.base.PropertyList` or `lsst.daf.base.PropertySet` 

85 Metadata to pull from. 

86 Items that are used are stripped from the metadata (except TIMESYS, 

87 because it may apply to other keywords) if ``doStripHeader``. 

88 exposureId : `int` 

89 exposure ID 

90 

91 Notes 

92 ----- 

93 The basic implementation sets `date` and `exposureTime` using typical 

94 values found in FITS files and logs a warning if neither can be set. 

95 """ 

96 argDict = dict(exposureId=exposureId) 

97 self.setArgDict(md, argDict) 

98 for key in list(argDict.keys()): # use a copy because we may delete items 

99 if argDict[key] is None: 

100 self.log.warning("argDict[%s] is None; stripping", key) 

101 del argDict[key] 

102 return VisitInfo(**argDict) 

103 

104 def setArgDict(self, md, argDict): 

105 """Fill an argument dict with arguments for VisitInfo and pop 

106 associated metadata 

107 

108 Subclasses are expected to override this method, though the override 

109 may wish to call this default implementation, which: 

110 

111 - sets exposureTime from "EXPTIME" 

112 - sets date by calling getDateAvg 

113 

114 Parameters 

115 ---------- 

116 md : `lsst.daf.base.PropertyList` or `PropertySet` 

117 Metadata to pull from. 

118 Items that are used are stripped from the metadata (except TIMESYS, 

119 because it may apply to other keywords). 

120 argdict : `dict` 

121 dict of arguments 

122 

123 Notes 

124 ----- 

125 Subclasses should expand this or replace it. 

126 """ 

127 argDict["exposureTime"] = self.popFloat(md, "EXPTIME") 

128 argDict["date"] = self.getDateAvg(md=md, exposureTime=argDict["exposureTime"]) 

129 

130 def getDateAvg(self, md, exposureTime): 

131 """Return date at the middle of the exposure. 

132 

133 Parameters 

134 ---------- 

135 md : `lsst.daf.base.PropertyList` or `PropertySet` 

136 Metadata to pull from. 

137 Items that are used are stripped from the metadata (except TIMESYS, 

138 because it may apply to other keywords). 

139 exposureTime : `float` 

140 Exposure time (sec) 

141 

142 Notes 

143 ----- 

144 Subclasses must override. Here is a typical implementation:: 

145 

146 dateObs = self.popIsoDate(md, "DATE-OBS") 

147 return self.offsetDate(dateObs, 0.5*exposureTime) 

148 """ 

149 raise NotImplementedError() 

150 

151 def getDarkTime(self, argDict): 

152 """Get the darkTime from the DARKTIME keyword, else expTime, else NaN, 

153 

154 If dark time is available then subclasses should call this method by 

155 putting the following in their `__init__` method:: 

156 

157 argDict['darkTime'] = self.getDarkTime(argDict) 

158 

159 Parameters 

160 ---------- 

161 argdict : `dict` 

162 Dict of arguments. 

163 

164 Returns 

165 ------- 

166 `float` 

167 Dark time, as inferred from the metadata. 

168 """ 

169 darkTime = argDict.get("darkTime", NaN) 

170 if np.isfinite(darkTime): 

171 return darkTime 

172 

173 self.log.info("darkTime is NaN/Inf; using exposureTime") 

174 exposureTime = argDict.get("exposureTime", NaN) 

175 if not np.isfinite(exposureTime): 

176 raise RuntimeError("Tried to substitute exposureTime for darkTime but it is not available") 

177 

178 return exposureTime 

179 

180 def offsetDate(self, date, offsetSec): 

181 """Return a date offset by a specified number of seconds. 

182 

183 date : `lsst.daf.base.DateTime` 

184 Date baseline to offset from. 

185 offsetSec : `float` 

186 Offset, in seconds. 

187 

188 Returns 

189 ------- 

190 `lsst.daf.base.DateTime` 

191 The offset date. 

192 """ 

193 if not date.isValid(): 

194 self.log.warning("date is invalid; cannot offset it") 

195 return date 

196 if math.isnan(offsetSec): 

197 self.log.warning("offsetSec is invalid; cannot offset date") 

198 return date 

199 dateNSec = date.nsecs(DateTime.TAI) 

200 return DateTime(dateNSec + int(offsetSec * 1.0e9), DateTime.TAI) 

201 

202 def popItem(self, md, key, default=None): 

203 """Return an item of metadata. 

204 

205 The item is removed if ``doStripHeader`` is ``True``. 

206 

207 Log a warning if the key is not found. 

208 

209 Parameters 

210 ---------- 

211 md : `lsst.daf.base.PropertyList` or `PropertySet` 

212 Metadata to pull `key` from and (optionally) remove. 

213 key : `str` 

214 Metadata key to extract. 

215 default : `object` 

216 Value to return if key not found. 

217 

218 Returns 

219 ------- 

220 `object` 

221 The value of the specified key, using whatever type 

222 md.getScalar(key) returns. 

223 """ 

224 try: 

225 if not md.exists(key): 

226 self.log.warning('Key="%s" not in metadata', key) 

227 return default 

228 val = md.getScalar(key) 

229 if self.doStripHeader: 

230 md.remove(key) 

231 return val 

232 except Exception as e: 

233 # this should never happen, but is a last ditch attempt to avoid 

234 # exceptions 

235 self.log.warning('Could not read key="%s" in metadata: %s', key, e) 

236 return default 

237 

238 def popFloat(self, md, key): 

239 """Pop a float with a default of NaN. 

240 

241 Parameters 

242 ---------- 

243 md : `lsst.daf.base.PropertyList` or `PropertySet` 

244 Metadata to pull `key` from. 

245 key : `str` 

246 Key to read. 

247 

248 Returns 

249 ------- 

250 `float` 

251 Value of the requested key as a float; float("nan") if the key is 

252 not found. 

253 """ 

254 val = self.popItem(md, key, default=NaN) 

255 try: 

256 return float(val) 

257 except Exception as e: 

258 self.log.warning("Could not interpret %s value %r as a float: %s", key, val, e) 

259 return NaN 

260 

261 def popAngle(self, md, key, units=astropy.units.deg): 

262 """Pop an lsst.afw.geom.Angle, whose metadata is in the specified 

263 units, with a default of Nan 

264 

265 The angle may be specified as a float or sexagesimal string with 1-3 

266 fields. 

267 

268 Parameters 

269 ---------- 

270 md : `lsst.daf.base.PropertyList` or `PropertySet` 

271 Metadata to pull `key` from. 

272 key : `str` 

273 Key to read from md. 

274 

275 Returns 

276 ------- 

277 `lsst.afw.geom.Angle` 

278 Value of the requested key as an angle; Angle(NaN) if the key is 

279 not found. 

280 """ 

281 angleStr = self.popItem(md, key, default=None) 

282 if angleStr is not None: 

283 try: 

284 return (astropy.coordinates.Angle(angleStr, unit=units).deg) * degrees 

285 except Exception as e: 

286 self.log.warning("Could not intepret %s value %r as an angle: %s", key, angleStr, e) 

287 return NaN * degrees 

288 

289 def popIsoDate(self, md, key, timesys=None): 

290 """Pop a FITS ISO date as an lsst.daf.base.DateTime 

291 

292 Parameters 

293 ---------- 

294 md : `lsst.daf.base.PropertyList` or `PropertySet` 

295 Metadata to pull `key` from. 

296 key : `str` 

297 Date key to read from md. 

298 timesys : `str` 

299 Time system as a string (not case sensitive), e.g. "UTC" or None; 

300 if None then look for TIMESYS (but do NOT pop it, since it may be 

301 used for more than one date) and if not found, use UTC. 

302 

303 Returns 

304 ------- 

305 `lsst.daf.base.DateTime` 

306 Value of the requested date; `DateTime()` if the key is not found. 

307 """ 

308 isoDateStr = self.popItem(md=md, key=key) 

309 if isoDateStr is not None: 

310 try: 

311 if timesys is None: 

312 timesys = md.getScalar("TIMESYS") if md.exists("TIMESYS") else "UTC" 

313 if isoDateStr.endswith("Z"): # illegal in FITS 

314 isoDateStr = isoDateStr[0:-1] 

315 astropyTime = astropy.time.Time(isoDateStr, scale=timesys.lower(), format="fits") 

316 # DateTime uses nanosecond resolution, regardless of the 

317 # resolution of the original date 

318 astropyTime.precision = 9 

319 # isot is ISO8601 format with "T" separating date and time and 

320 # no time zone 

321 return DateTime(astropyTime.tai.isot, DateTime.TAI) 

322 except Exception as e: 

323 self.log.warning("Could not parse %s = %r as an ISO date: %s", key, isoDateStr, e) 

324 return BadDate 

325 

326 def popMjdDate(self, md, key, timesys=None): 

327 """Get a FITS MJD date as an ``lsst.daf.base.DateTime``. 

328 

329 Parameters 

330 ---------- 

331 md : `lsst.daf.base.PropertyList` or `PropertySet` 

332 Metadata to pull `key` from. 

333 key : `str` 

334 Date key to read from md. 

335 timesys : `str` 

336 Time system as a string (not case sensitive), e.g. "UTC" or None; 

337 if None then look for TIMESYS (but do NOT pop it, since it may be 

338 used for more than one date) and if not found, use UTC. 

339 

340 Returns 

341 ------- 

342 `lsst.daf.base.DateTime` 

343 Value of the requested date; `DateTime()` if the key is not found. 

344 """ 

345 mjdDate = self.popFloat(md, key) 

346 try: 

347 if timesys is None: 

348 timesys = md.getScalar("TIMESYS") if md.exists("TIMESYS") else "UTC" 

349 astropyTime = astropy.time.Time(mjdDate, format="mjd", scale=timesys.lower()) 

350 # DateTime uses nanosecond resolution, regardless of the resolution 

351 # of the original date 

352 astropyTime.precision = 9 

353 # isot is ISO8601 format with "T" separating date and time and no 

354 # time zone 

355 return DateTime(astropyTime.tai.isot, DateTime.TAI) 

356 except Exception as e: 

357 self.log.warning("Could not parse %s = %r as an MJD date: %s", key, mjdDate, e) 

358 return BadDate 

359 

360 @staticmethod 

361 def eraFromLstAndLongitude(lst, longitude): 

362 """ 

363 Return an approximate Earth Rotation Angle (afw:Angle) computed from 

364 local sidereal time and longitude (both as afw:Angle; Longitude shares 

365 the afw:Observatory covention: positive values are E of Greenwich). 

366 

367 NOTE: if we properly compute ERA via UT1 a la DM-8053, we should remove 

368 this method. 

369 """ 

370 return lst - longitude 

371 

372 @staticmethod 

373 def altitudeFromZenithDistance(zd): 

374 """Convert zenith distance to altitude (lsst.afw.geom.Angle)""" 

375 return 90 * degrees - zd 

376 

377 @staticmethod 

378 def centigradeFromKelvin(tempK): 

379 """Convert temperature from Kelvin to Centigrade""" 

380 return tempK - KelvinMinusCentigrade 

381 

382 @staticmethod 

383 def pascalFromMBar(mbar): 

384 """Convert pressure from millibars to Pascals""" 

385 return mbar * PascalPerMillibar 

386 

387 @staticmethod 

388 def pascalFromMmHg(mmHg): 

389 """Convert pressure from mm Hg to Pascals 

390 

391 Notes 

392 ----- 

393 Could use the following, but astropy.units.cds is not fully compatible 

394 with Python 2 as of astropy 1.2.1 (see 

395 https://github.com/astropy/astropy/issues/5350#issuecomment-248612824): 

396 astropy.units.cds.mmHg.to(astropy.units.pascal, mmHg) 

397 """ 

398 return mmHg * PascalPerMmHg 

399 

400 @staticmethod 

401 def pascalFromTorr(torr): 

402 """Convert pressure from torr to Pascals""" 

403 return torr * PascalPerTorr 

404 

405 @staticmethod 

406 def defaultMetadata(value, defaultValue, minimum=None, maximum=None): 

407 """Return the value if it is not NaN and within min/max, otherwise 

408 return defaultValue. 

409 

410 Parameters 

411 ---------- 

412 value : `float` 

413 metadata value returned by popItem, popFloat, or popAngle 

414 defaultValue : `float`` 

415 default value to use if the metadata value is invalid 

416 minimum : `float` 

417 Minimum possible valid value, optional 

418 maximum : `float` 

419 Maximum possible valid value, optional 

420 

421 Returns 

422 ------- 

423 `float` 

424 The "validated" value. 

425 """ 

426 if np.isnan(value): 

427 retVal = defaultValue 

428 else: 

429 if minimum is not None and value < minimum: 

430 retVal = defaultValue 

431 elif maximum is not None and value > maximum: 

432 retVal = defaultValue 

433 else: 

434 retVal = value 

435 return retVal