Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

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 

22import math 

23import numpy as np 

24 

25import astropy.coordinates 

26import astropy.time 

27import astropy.units 

28 

29from lsst.log import Log 

30from lsst.daf.base import DateTime 

31from lsst.geom import degrees 

32from lsst.afw.image import VisitInfo 

33 

34__all__ = ["MakeRawVisitInfo"] 

35 

36 

37PascalPerMillibar = 100.0 

38PascalPerMmHg = 133.322387415 # from Wikipedia; exact 

39PascalPerTorr = 101325.0/760.0 # from Wikipedia; exact 

40KelvinMinusCentigrade = 273.15 # from Wikipedia; exact 

41 

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

43NaN = float("nan") 

44BadDate = DateTime() 

45 

46 

47class MakeRawVisitInfo: 

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

49 image. 

50 

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

52 

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

54 which simply sets exposure time and date of observation 

55 - `getDateAvg` 

56 

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

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

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

60 user needing to add a lot of error handling. 

61 

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

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

64 those are almost certainly due to coding mistakes. 

65 

66 Parameters 

67 ---------- 

68 log : `lsst.log.Log` or None 

69 Logger to use for messages. 

70 (None to use ``Log.getLogger("MakeRawVisitInfo")``). 

71 doStripHeader : `bool`, optional 

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

73 """ 

74 

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

76 if log is None: 

77 log = Log.getLogger("MakeRawVisitInfo") 

78 self.log = log 

79 self.doStripHeader = doStripHeader 

80 

81 def __call__(self, md, exposureId): 

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

83 

84 Parameters 

85 ---------- 

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

87 Metadata to pull from. 

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

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

90 exposureId : `int` 

91 exposure ID 

92 

93 Notes 

94 ----- 

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

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

97 """ 

98 argDict = dict(exposureId=exposureId) 

99 self.setArgDict(md, argDict) 

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

101 if argDict[key] is None: 

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

103 del argDict[key] 

104 return VisitInfo(**argDict) 

105 

106 def setArgDict(self, md, argDict): 

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

108 associated metadata 

109 

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

111 may wish to call this default implementation, which: 

112 

113 - sets exposureTime from "EXPTIME" 

114 - sets date by calling getDateAvg 

115 

116 Parameters 

117 ---------- 

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

119 Metadata to pull from. 

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

121 because it may apply to other keywords). 

122 argdict : `dict` 

123 dict of arguments 

124 

125 Notes 

126 ----- 

127 Subclasses should expand this or replace it. 

128 """ 

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

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

131 

132 def getDateAvg(self, md, exposureTime): 

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

134 

135 Parameters 

136 ---------- 

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

138 Metadata to pull from. 

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

140 because it may apply to other keywords). 

141 exposureTime : `float` 

142 Exposure time (sec) 

143 

144 Notes 

145 ----- 

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

147 

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

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

150 """ 

151 raise NotImplementedError() 

152 

153 def getDarkTime(self, argDict): 

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

155 

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

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

158 

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

160 

161 Parameters 

162 ---------- 

163 argdict : `dict` 

164 Dict of arguments. 

165 

166 Returns 

167 ------- 

168 `float` 

169 Dark time, as inferred from the metadata. 

170 """ 

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

172 if np.isfinite(darkTime): 

173 return darkTime 

174 

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

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

177 if not np.isfinite(exposureTime): 

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

179 

180 return exposureTime 

181 

182 def offsetDate(self, date, offsetSec): 

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

184 

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

186 Date baseline to offset from. 

187 offsetSec : `float` 

188 Offset, in seconds. 

189 

190 Returns 

191 ------- 

192 `lsst.daf.base.DateTime` 

193 The offset date. 

194 """ 

195 if not date.isValid(): 

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

197 return date 

198 if math.isnan(offsetSec): 

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

200 return date 

201 dateNSec = date.nsecs(DateTime.TAI) 

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

203 

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

205 """Return an item of metadata. 

206 

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

208 

209 Log a warning if the key is not found. 

210 

211 Parameters 

212 ---------- 

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

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

215 key : `str` 

216 Metadata key to extract. 

217 default : `object` 

218 Value to return if key not found. 

219 

220 Returns 

221 ------- 

222 `object` 

223 The value of the specified key, using whatever type 

224 md.getScalar(key) returns. 

225 """ 

226 try: 

227 if not md.exists(key): 

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

229 return default 

230 val = md.getScalar(key) 

231 if self.doStripHeader: 

232 md.remove(key) 

233 return val 

234 except Exception as e: 

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

236 # exceptions 

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

238 return default 

239 

240 def popFloat(self, md, key): 

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

242 

243 Parameters 

244 ---------- 

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

246 Metadata to pull `key` from. 

247 key : `str` 

248 Key to read. 

249 

250 Returns 

251 ------- 

252 `float` 

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

254 not found. 

255 """ 

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

257 try: 

258 return float(val) 

259 except Exception as e: 

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

261 return NaN 

262 

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

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

265 units, with a default of Nan 

266 

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

268 fields. 

269 

270 Parameters 

271 ---------- 

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

273 Metadata to pull `key` from. 

274 key : `str` 

275 Key to read from md. 

276 

277 Returns 

278 ------- 

279 `lsst.afw.geom.Angle` 

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

281 not found. 

282 """ 

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

284 if angleStr is not None: 

285 try: 

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

287 except Exception as e: 

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

289 return NaN*degrees 

290 

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

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

293 

294 Parameters 

295 ---------- 

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

297 Metadata to pull `key` from. 

298 key : `str` 

299 Date key to read from md. 

300 timesys : `str` 

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

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

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

304 

305 Returns 

306 ------- 

307 `lsst.daf.base.DateTime` 

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

309 """ 

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

311 if isoDateStr is not None: 

312 try: 

313 if timesys is None: 

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

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

316 isoDateStr = isoDateStr[0:-1] 

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

318 # DateTime uses nanosecond resolution, regardless of the 

319 # resolution of the original date 

320 astropyTime.precision = 9 

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

322 # no time zone 

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

324 except Exception as e: 

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

326 return BadDate 

327 

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

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

330 

331 Parameters 

332 ---------- 

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

334 Metadata to pull `key` from. 

335 key : `str` 

336 Date key to read from md. 

337 timesys : `str` 

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

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

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

341 

342 Returns 

343 ------- 

344 `lsst.daf.base.DateTime` 

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

346 """ 

347 mjdDate = self.popFloat(md, key) 

348 try: 

349 if timesys is None: 

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

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

352 # DateTime uses nanosecond resolution, regardless of the resolution 

353 # of the original date 

354 astropyTime.precision = 9 

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

356 # time zone 

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

358 except Exception as e: 

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

360 return BadDate 

361 

362 @staticmethod 

363 def eraFromLstAndLongitude(lst, longitude): 

364 """ 

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

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

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

368 

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

370 this method. 

371 """ 

372 return lst - longitude 

373 

374 @staticmethod 

375 def altitudeFromZenithDistance(zd): 

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

377 return 90*degrees - zd 

378 

379 @staticmethod 

380 def centigradeFromKelvin(tempK): 

381 """Convert temperature from Kelvin to Centigrade""" 

382 return tempK - KelvinMinusCentigrade 

383 

384 @staticmethod 

385 def pascalFromMBar(mbar): 

386 """Convert pressure from millibars to Pascals 

387 """ 

388 return mbar*PascalPerMillibar 

389 

390 @staticmethod 

391 def pascalFromMmHg(mmHg): 

392 """Convert pressure from mm Hg to Pascals 

393 

394 Notes 

395 ----- 

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

397 with Python 2 as of astropy 1.2.1 (see 

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

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

400 """ 

401 return mmHg*PascalPerMmHg 

402 

403 @staticmethod 

404 def pascalFromTorr(torr): 

405 """Convert pressure from torr to Pascals 

406 """ 

407 return torr*PascalPerTorr 

408 

409 @staticmethod 

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

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

412 return defaultValue. 

413 

414 Parameters 

415 ---------- 

416 value : `float` 

417 metadata value returned by popItem, popFloat, or popAngle 

418 defaultValue : `float`` 

419 default value to use if the metadata value is invalid 

420 minimum : `float` 

421 Minimum possible valid value, optional 

422 maximum : `float` 

423 Maximum possible valid value, optional 

424 

425 Returns 

426 ------- 

427 `float` 

428 The "validated" value. 

429 """ 

430 if np.isnan(value): 

431 retVal = defaultValue 

432 else: 

433 if minimum is not None and value < minimum: 

434 retVal = defaultValue 

435 elif maximum is not None and value > maximum: 

436 retVal = defaultValue 

437 else: 

438 retVal = value 

439 return retVal