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

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/>.
22__all__ = ["MakeRawVisitInfo"]
24import math
25import logging
26import numpy as np
28import astropy.coordinates
29import astropy.time
30import astropy.units
32from lsst.daf.base import DateTime
33from lsst.geom import degrees
34from lsst.afw.image import VisitInfo
36PascalPerMillibar = 100.0
37PascalPerMmHg = 133.322387415 # from Wikipedia; exact
38PascalPerTorr = 101325.0/760.0 # from Wikipedia; exact
39KelvinMinusCentigrade = 273.15 # from Wikipedia; exact
41# have these read at need, to avoid unexpected errors later
42NaN = float("nan")
43BadDate = DateTime()
46class MakeRawVisitInfo:
47 """Base class functor to make a VisitInfo from the FITS header of a raw
48 image.
50 A subclass will be wanted for each camera. Subclasses should override:
52 - `setArgDict`, The override can call the base implementation,
53 which simply sets exposure time and date of observation
54 - `getDateAvg`
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.
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.
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 """
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
80 def __call__(self, md, exposureId):
81 """Construct a VisitInfo and strip associated data from the metadata.
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
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)
105 def setArgDict(self, md, argDict):
106 """Fill an argument dict with arguments for VisitInfo and pop
107 associated metadata
109 Subclasses are expected to override this method, though the override
110 may wish to call this default implementation, which:
112 - sets exposureTime from "EXPTIME"
113 - sets date by calling getDateAvg
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
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"])
131 def getDateAvg(self, md, exposureTime):
132 """Return date at the middle of the exposure.
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)
143 Notes
144 -----
145 Subclasses must override. Here is a typical implementation::
147 dateObs = self.popIsoDate(md, "DATE-OBS")
148 return self.offsetDate(dateObs, 0.5*exposureTime)
149 """
150 raise NotImplementedError()
152 def getDarkTime(self, argDict):
153 """Get the darkTime from the DARKTIME keyword, else expTime, else NaN,
155 If dark time is available then subclasses should call this method by
156 putting the following in their `__init__` method::
158 argDict['darkTime'] = self.getDarkTime(argDict)
160 Parameters
161 ----------
162 argdict : `dict`
163 Dict of arguments.
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
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")
179 return exposureTime
181 def offsetDate(self, date, offsetSec):
182 """Return a date offset by a specified number of seconds.
184 date : `lsst.daf.base.DateTime`
185 Date baseline to offset from.
186 offsetSec : `float`
187 Offset, in seconds.
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)
203 def popItem(self, md, key, default=None):
204 """Return an item of metadata.
206 The item is removed if ``doStripHeader`` is ``True``.
208 Log a warning if the key is not found.
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.
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
239 def popFloat(self, md, key):
240 """Pop a float with a default of NaN.
242 Parameters
243 ----------
244 md : `lsst.daf.base.PropertyList` or `PropertySet`
245 Metadata to pull `key` from.
246 key : `str`
247 Key to read.
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
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
266 The angle may be specified as a float or sexagesimal string with 1-3
267 fields.
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.
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
290 def popIsoDate(self, md, key, timesys=None):
291 """Pop a FITS ISO date as an lsst.daf.base.DateTime
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.
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
327 def popMjdDate(self, md, key, timesys=None):
328 """Get a FITS MJD date as an ``lsst.daf.base.DateTime``.
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.
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
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).
368 NOTE: if we properly compute ERA via UT1 a la DM-8053, we should remove
369 this method.
370 """
371 return lst - longitude
373 @staticmethod
374 def altitudeFromZenithDistance(zd):
375 """Convert zenith distance to altitude (lsst.afw.geom.Angle)"""
376 return 90*degrees - zd
378 @staticmethod
379 def centigradeFromKelvin(tempK):
380 """Convert temperature from Kelvin to Centigrade"""
381 return tempK - KelvinMinusCentigrade
383 @staticmethod
384 def pascalFromMBar(mbar):
385 """Convert pressure from millibars to Pascals
386 """
387 return mbar*PascalPerMillibar
389 @staticmethod
390 def pascalFromMmHg(mmHg):
391 """Convert pressure from mm Hg to Pascals
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
402 @staticmethod
403 def pascalFromTorr(torr):
404 """Convert pressure from torr to Pascals
405 """
406 return torr*PascalPerTorr
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.
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
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