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 

22__all__ = ["MakeRawVisitInfo"] 

23 

24import math 

25import logging 

26import numpy as np 

27 

28import astropy.coordinates 

29import astropy.time 

30import astropy.units 

31 

32from lsst.daf.base import DateTime 

33from lsst.geom import degrees 

34from lsst.afw.image import VisitInfo 

35 

36PascalPerMillibar = 100.0 

37PascalPerMmHg = 133.322387415 # from Wikipedia; exact 

38PascalPerTorr = 101325.0/760.0 # from Wikipedia; exact 

39KelvinMinusCentigrade = 273.15 # from Wikipedia; exact 

40 

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

42NaN = float("nan") 

43BadDate = DateTime() 

44 

45 

46class MakeRawVisitInfo: 

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

48 image. 

49 

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

51 

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

53 which simply sets exposure time and date of observation 

54 - `getDateAvg` 

55 

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

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

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

59 user needing to add a lot of error handling. 

60 

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

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

63 those are almost certainly due to coding mistakes. 

64 

65 Parameters 

66 ---------- 

67 log : `logging.Logger` or None 

68 Logger to use for messages. 

69 (None to use ``logging.getLogger("MakeRawVisitInfo")``). 

70 doStripHeader : `bool`, optional 

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

72 """ 

73 

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

75 if log is None: 

76 log = logging.getLogger("MakeRawVisitInfo") 

77 self.log = log 

78 self.doStripHeader = doStripHeader 

79 

80 def __call__(self, md, exposureId): 

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

82 

83 Parameters 

84 ---------- 

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

86 Metadata to pull from. 

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

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

89 exposureId : `int` 

90 exposure ID 

91 

92 Notes 

93 ----- 

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

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

96 """ 

97 argDict = dict(exposureId=exposureId) 

98 self.setArgDict(md, argDict) 

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

100 if argDict[key] is None: 

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

102 del argDict[key] 

103 return VisitInfo(**argDict) 

104 

105 def setArgDict(self, md, argDict): 

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

107 associated metadata 

108 

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

110 may wish to call this default implementation, which: 

111 

112 - sets exposureTime from "EXPTIME" 

113 - sets date by calling getDateAvg 

114 

115 Parameters 

116 ---------- 

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

118 Metadata to pull from. 

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

120 because it may apply to other keywords). 

121 argdict : `dict` 

122 dict of arguments 

123 

124 Notes 

125 ----- 

126 Subclasses should expand this or replace it. 

127 """ 

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

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

130 

131 def getDateAvg(self, md, exposureTime): 

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

133 

134 Parameters 

135 ---------- 

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

137 Metadata to pull from. 

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

139 because it may apply to other keywords). 

140 exposureTime : `float` 

141 Exposure time (sec) 

142 

143 Notes 

144 ----- 

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

146 

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

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

149 """ 

150 raise NotImplementedError() 

151 

152 def getDarkTime(self, argDict): 

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

154 

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

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

157 

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

159 

160 Parameters 

161 ---------- 

162 argdict : `dict` 

163 Dict of arguments. 

164 

165 Returns 

166 ------- 

167 `float` 

168 Dark time, as inferred from the metadata. 

169 """ 

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

171 if np.isfinite(darkTime): 

172 return darkTime 

173 

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

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

176 if not np.isfinite(exposureTime): 

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

178 

179 return exposureTime 

180 

181 def offsetDate(self, date, offsetSec): 

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

183 

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

185 Date baseline to offset from. 

186 offsetSec : `float` 

187 Offset, in seconds. 

188 

189 Returns 

190 ------- 

191 `lsst.daf.base.DateTime` 

192 The offset date. 

193 """ 

194 if not date.isValid(): 

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

196 return date 

197 if math.isnan(offsetSec): 

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

199 return date 

200 dateNSec = date.nsecs(DateTime.TAI) 

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

202 

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

204 """Return an item of metadata. 

205 

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

207 

208 Log a warning if the key is not found. 

209 

210 Parameters 

211 ---------- 

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

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

214 key : `str` 

215 Metadata key to extract. 

216 default : `object` 

217 Value to return if key not found. 

218 

219 Returns 

220 ------- 

221 `object` 

222 The value of the specified key, using whatever type 

223 md.getScalar(key) returns. 

224 """ 

225 try: 

226 if not md.exists(key): 

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

228 return default 

229 val = md.getScalar(key) 

230 if self.doStripHeader: 

231 md.remove(key) 

232 return val 

233 except Exception as e: 

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

235 # exceptions 

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

237 return default 

238 

239 def popFloat(self, md, key): 

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

241 

242 Parameters 

243 ---------- 

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

245 Metadata to pull `key` from. 

246 key : `str` 

247 Key to read. 

248 

249 Returns 

250 ------- 

251 `float` 

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

253 not found. 

254 """ 

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

256 try: 

257 return float(val) 

258 except Exception as e: 

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

260 return NaN 

261 

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

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

264 units, with a default of Nan 

265 

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

267 fields. 

268 

269 Parameters 

270 ---------- 

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

272 Metadata to pull `key` from. 

273 key : `str` 

274 Key to read from md. 

275 

276 Returns 

277 ------- 

278 `lsst.afw.geom.Angle` 

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

280 not found. 

281 """ 

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

283 if angleStr is not None: 

284 try: 

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

286 except Exception as e: 

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

288 return NaN*degrees 

289 

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

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

292 

293 Parameters 

294 ---------- 

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

296 Metadata to pull `key` from. 

297 key : `str` 

298 Date key to read from md. 

299 timesys : `str` 

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

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

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

303 

304 Returns 

305 ------- 

306 `lsst.daf.base.DateTime` 

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

308 """ 

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

310 if isoDateStr is not None: 

311 try: 

312 if timesys is None: 

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

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

315 isoDateStr = isoDateStr[0:-1] 

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

317 # DateTime uses nanosecond resolution, regardless of the 

318 # resolution of the original date 

319 astropyTime.precision = 9 

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

321 # no time zone 

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

323 except Exception as e: 

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

325 return BadDate 

326 

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

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

329 

330 Parameters 

331 ---------- 

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

333 Metadata to pull `key` from. 

334 key : `str` 

335 Date key to read from md. 

336 timesys : `str` 

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

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

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

340 

341 Returns 

342 ------- 

343 `lsst.daf.base.DateTime` 

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

345 """ 

346 mjdDate = self.popFloat(md, key) 

347 try: 

348 if timesys is None: 

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

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

351 # DateTime uses nanosecond resolution, regardless of the resolution 

352 # of the original date 

353 astropyTime.precision = 9 

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

355 # time zone 

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

357 except Exception as e: 

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

359 return BadDate 

360 

361 @staticmethod 

362 def eraFromLstAndLongitude(lst, longitude): 

363 """ 

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

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

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

367 

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

369 this method. 

370 """ 

371 return lst - longitude 

372 

373 @staticmethod 

374 def altitudeFromZenithDistance(zd): 

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

376 return 90*degrees - zd 

377 

378 @staticmethod 

379 def centigradeFromKelvin(tempK): 

380 """Convert temperature from Kelvin to Centigrade""" 

381 return tempK - KelvinMinusCentigrade 

382 

383 @staticmethod 

384 def pascalFromMBar(mbar): 

385 """Convert pressure from millibars to Pascals 

386 """ 

387 return mbar*PascalPerMillibar 

388 

389 @staticmethod 

390 def pascalFromMmHg(mmHg): 

391 """Convert pressure from mm Hg to Pascals 

392 

393 Notes 

394 ----- 

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

396 with Python 2 as of astropy 1.2.1 (see 

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

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

399 """ 

400 return mmHg*PascalPerMmHg 

401 

402 @staticmethod 

403 def pascalFromTorr(torr): 

404 """Convert pressure from torr to Pascals 

405 """ 

406 return torr*PascalPerTorr 

407 

408 @staticmethod 

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

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

411 return defaultValue. 

412 

413 Parameters 

414 ---------- 

415 value : `float` 

416 metadata value returned by popItem, popFloat, or popAngle 

417 defaultValue : `float`` 

418 default value to use if the metadata value is invalid 

419 minimum : `float` 

420 Minimum possible valid value, optional 

421 maximum : `float` 

422 Maximum possible valid value, optional 

423 

424 Returns 

425 ------- 

426 `float` 

427 The "validated" value. 

428 """ 

429 if np.isnan(value): 

430 retVal = defaultValue 

431 else: 

432 if minimum is not None and value < minimum: 

433 retVal = defaultValue 

434 elif maximum is not None and value > maximum: 

435 retVal = defaultValue 

436 else: 

437 retVal = value 

438 return retVal