lsst.obs.base  15.0-12-g3681e7a+4
makeRawVisitInfo.py
Go to the documentation of this file.
1 #
2 # LSST Data Management System
3 # Copyright 2016 LSST Corporation.
4 #
5 # This product includes software developed by the
6 # LSST Project (http://www.lsst.org/).
7 #
8 # This program is free software: you can redistribute it and/or modify
9 # it under the terms of the GNU General Public License as published by
10 # the Free Software Foundation, either version 3 of the License, or
11 # (at your option) any later version.
12 #
13 # This program is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 # GNU General Public License for more details.
17 #
18 # You should have received a copy of the LSST License Statement and
19 # the GNU General Public License along with this program. If not,
20 # see <http://www.lsstcorp.org/LegalNotices/>.
21 #
22 from __future__ import absolute_import
23 from __future__ import division
24 from __future__ import print_function
25 import math
26 import numpy as np
27 
28 import astropy.coordinates
29 import astropy.time
30 import astropy.units
31 
32 from lsst.log import Log
33 from lsst.daf.base import DateTime
34 from lsst.afw.geom import degrees
35 from lsst.afw.image import VisitInfo
36 
37 __all__ = ["MakeRawVisitInfo"]
38 
39 
40 PascalPerMillibar = 100.0
41 PascalPerMmHg = 133.322387415 # from Wikipedia; exact
42 PascalPerTorr = 101325.0/760.0 # from Wikipedia; exact
43 KelvinMinusCentigrade = 273.15 # from Wikipedia; exact
44 
45 # have these read at need, to avoid unexpected errors later
46 NaN = float("nan")
47 BadDate = DateTime()
48 
49 
50 class MakeRawVisitInfo(object):
51  """Base class functor to make a VisitInfo from the FITS header of a raw image.
52 
53  A subclass will be wanted for each camera. Subclasses should override:
54 
55  - `setArgDict`, The override can call the base implementation,
56  which simply sets exposure time and date of observation
57  - `getDateAvg`
58 
59  The design philosophy is to make a best effort and log warnings of problems,
60  rather than raising exceptions, in order to extract as much VisitInfo information as possible
61  from a messy FITS header without the user needing to add a lot of error handling.
62 
63  However, the methods that transform units are less forgiving; they assume
64  the user provides proper data types, since type errors in arguments to those
65  are almost certainly due to coding mistakes.
66 
67  Parameters
68  ----------
69  log : `lsst.log.Log` or None
70  Logger to use for messages.
71  (None to use ``Log.getLogger("MakeRawVisitInfo")``).
72  """
73 
74  def __init__(self, log=None):
75  if log is None:
76  log = Log.getLogger("MakeRawVisitInfo")
77  self.log = log
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).
88  exposureId : `int`
89  exposure ID
90 
91  Notes
92  -----
93  The basic implementation sets `date` and `exposureTime` using typical values
94  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.warn("argDict[{}] is None; stripping".format(key, argDict[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 associated metadata
106 
107  Subclasses are expected to override this method, though the override
108  may wish to call this default implementation, which:
109 
110  - sets exposureTime from "EXPTIME"
111  - sets date by calling getDateAvg
112 
113  Parameters
114  ----------
115  md : `lsst.daf.base.PropertyList` or `PropertySet`
116  Metadata to pull from.
117  Items that are used are stripped from the metadata (except TIMESYS,
118  because it may apply to other keywords).
119  argdict : `dict`
120  dict of arguments
121 
122  Notes
123  -----
124  Subclasses should expand this or replace it.
125  """
126  argDict["exposureTime"] = self.popFloat(md, "EXPTIME")
127  argDict["date"] = self.getDateAvg(md=md, exposureTime=argDict["exposureTime"])
128 
129  def getDateAvg(self, md, exposureTime):
130  """Return date at the middle of the exposure.
131 
132  Parameters
133  ----------
134  md : `lsst.daf.base.PropertyList` or `PropertySet`
135  Metadata to pull from.
136  Items that are used are stripped from the metadata (except TIMESYS,
137  because it may apply to other keywords).
138  exposureTime : `float`
139  Exposure time (sec)
140 
141  Notes
142  -----
143  Subclasses must override. Here is a typical implementation::
144 
145  dateObs = self.popIsoDate(md, "DATE-OBS")
146  return self.offsetDate(dateObs, 0.5*exposureTime)
147  """
148  raise NotImplementedError()
149 
150  def getDarkTime(self, argDict):
151  """Get the darkTime from the DARKTIME keyword, else expTime, else NaN,
152 
153  If dark time is available then subclasses should call this method by
154  putting the following in their `__init__` method::
155 
156  argDict['darkTime'] = self.getDarkTime(argDict)
157 
158  Parameters
159  ----------
160  argdict : `dict`
161  Dict of arguments.
162 
163  Returns
164  -------
165  `float`
166  Dark time, as inferred from the metadata.
167  """
168  darkTime = argDict.get("darkTime", NaN)
169  if np.isfinite(darkTime):
170  return darkTime
171 
172  self.log.info("darkTime is NaN/Inf; using exposureTime")
173  exposureTime = argDict.get("exposureTime", NaN)
174  if not np.isfinite(exposureTime):
175  raise RuntimeError("Tried to substitute exposureTime for darkTime but it is not available")
176 
177  return exposureTime
178 
179  def offsetDate(self, date, offsetSec):
180  """Return a date offset by a specified number of seconds.
181 
182  date : `lsst.daf.base.DateTime`
183  Date baseline to offset from.
184  offsetSec : `float`
185  Offset, in seconds.
186 
187  Returns
188  -------
189  `lsst.daf.base.DateTime`
190  The offset date.
191  """
192  if not date.isValid():
193  self.log.warn("date is invalid; cannot offset it")
194  return date
195  if math.isnan(offsetSec):
196  self.log.warn("offsetSec is invalid; cannot offset date")
197  return date
198  dateNSec = date.nsecs(DateTime.TAI)
199  return DateTime(dateNSec + int(offsetSec*1.0e9), DateTime.TAI)
200 
201  def popItem(self, md, key, default=None):
202  """Remove an item of metadata and return the value.
203 
204  Log a warning if the key is not found.
205 
206  Parameters
207  ----------
208  md : `lsst.daf.base.PropertyList` or `PropertySet`
209  Metadata to pull `key` from and remove.
210  key : `str`
211  Metadata key to extract.
212  default : `object`
213  Value to return if key not found.
214 
215  Returns
216  -------
217  `object`
218  The value of the specified key, using whatever type md.get(key)
219  returns.
220  """
221  try:
222  if not md.exists(key):
223  self.log.warn("Key=\"{}\" not in metadata".format(key))
224  return default
225  val = md.get(key)
226  md.remove(key)
227  return val
228  except Exception as e:
229  # this should never happen, but is a last ditch attempt to avoid exceptions
230  self.log.warn("Could not read key=\"{}\" in metadata: {}" % (key, e))
231  return default
232 
233  def popFloat(self, md, key):
234  """Pop a float with a default of NaN.
235 
236  Parameters
237  ----------
238  md : `lsst.daf.base.PropertyList` or `PropertySet`
239  Metadata to pull `key` from and remove.
240  key : `str`
241  Key to read and remove from md.
242 
243  Returns
244  -------
245  `float`
246  Value of the requested key as a float; float("nan") if the key is
247  not found.
248  """
249  val = self.popItem(md, key, default=NaN)
250  try:
251  return float(val)
252  except Exception as e:
253  self.log.warn("Could not interpret {} value {} as a float: {}".format(key, repr(val), e))
254  return NaN
255 
256  def popAngle(self, md, key, units=astropy.units.deg):
257  """Pop an lsst.afw.geom.Angle, whose metadata is in the specified units, with a default of Nan
258 
259  The angle may be specified as a float or sexagesimal string with 1-3 fields.
260 
261  Parameters
262  ----------
263  md : `lsst.daf.base.PropertyList` or `PropertySet`
264  Metadata to pull `key` from and remove.
265  key : `str`
266  Key to read and remove from md.
267 
268  Returns
269  -------
270  `lsst.afw.geom.Angle`
271  Value of the requested key as an angle; Angle(NaN) if the key is
272  not found.
273  """
274  angleStr = self.popItem(md, key, default=None)
275  if angleStr is not None:
276  try:
277  return (astropy.coordinates.Angle(angleStr, unit=units).deg)*degrees
278  except Exception as e:
279  self.log.warn("Could not intepret {} value {} as an angle: {}".format(key, repr(angleStr), e))
280  return NaN*degrees
281 
282  def popIsoDate(self, md, key, timesys=None):
283  """Pop a FITS ISO date as an lsst.daf.base.DateTime
284 
285  Parameters
286  ----------
287  md : `lsst.daf.base.PropertyList` or `PropertySet`
288  Metadata to pull `key` from and remove.
289  key : `str`
290  Date key to read and remove from md.
291  timesys : `str`
292  Time system as a string (not case sensitive), e.g. "UTC" or None;
293  if None then look for TIMESYS (but do NOT pop it, since it may be
294  used for more than one date) and if not found, use UTC.
295 
296  Returns
297  -------
298  `lsst.daf.base.DateTime`
299  Value of the requested date; `DateTime()` if the key is not found.
300  """
301  isoDateStr = self.popItem(md=md, key=key)
302  if isoDateStr is not None:
303  try:
304  if timesys is None:
305  timesys = md.get("TIMESYS") if md.exists("TIMESYS") else "UTC"
306  if isoDateStr.endswith("Z"): # illegal in FITS
307  isoDateStr = isoDateStr[0:-1]
308  astropyTime = astropy.time.Time(isoDateStr, scale=timesys.lower(), format="fits")
309  # DateTime uses nanosecond resolution, regardless of the resolution of the original date
310  astropyTime.precision = 9
311  # isot is ISO8601 format with "T" separating date and time and no time zone
312  return DateTime(astropyTime.tai.isot, DateTime.TAI)
313  except Exception as e:
314  self.log.warn("Could not parse {} = {} as an ISO date: {}".format(key, isoDateStr, e))
315  return BadDate
316 
317  def popMjdDate(self, md, key, timesys=None):
318  """Get a FITS MJD date as an ``lsst.daf.base.DateTime``.
319 
320  Parameters
321  ----------
322  md : `lsst.daf.base.PropertyList` or `PropertySet`
323  Metadata to pull `key` from and remove.
324  key : `str`
325  Date key to read and remove from md.
326  timesys : `str`
327  Time system as a string (not case sensitive), e.g. "UTC" or None;
328  if None then look for TIMESYS (but do NOT pop it, since it may be
329  used for more than one date) and if not found, use UTC.
330 
331  Returns
332  -------
333  `lsst.daf.base.DateTime`
334  Value of the requested date; `DateTime()` if the key is not found.
335  """
336  mjdDate = self.popFloat(md, key)
337  try:
338  if timesys is None:
339  timesys = md.get("TIMESYS") if md.exists("TIMESYS") else "UTC"
340  astropyTime = astropy.time.Time(mjdDate, format="mjd", scale=timesys.lower())
341  # DateTime uses nanosecond resolution, regardless of the resolution of the original date
342  astropyTime.precision = 9
343  # isot is ISO8601 format with "T" separating date and time and no time zone
344  return DateTime(astropyTime.tai.isot, DateTime.TAI)
345  except Exception as e:
346  self.log.warn("Could not parse {} = {} as an MJD date: {}".format(key, mjdDate, e))
347  return BadDate
348 
349  @staticmethod
350  def eraFromLstAndLongitude(lst, longitude):
351  """
352  Return an approximate Earth Rotation Angle (afw:Angle) computed from
353  local sidereal time and longitude (both as afw:Angle; Longitude shares
354  the afw:Observatory covention: positive values are E of Greenwich).
355 
356  NOTE: if we properly compute ERA via UT1 a la DM-8053, we should remove
357  this method.
358  """
359  return lst - longitude
360 
361  @staticmethod
363  """Convert zenith distance to altitude (lsst.afw.geom.Angle)"""
364  return 90*degrees - zd
365 
366  @staticmethod
368  """Convert temperature from Kelvin to Centigrade"""
369  return tempK - KelvinMinusCentigrade
370 
371  @staticmethod
372  def pascalFromMBar(mbar):
373  """Convert pressure from millibars to Pascals
374  """
375  return mbar*PascalPerMillibar
376 
377  @staticmethod
378  def pascalFromMmHg(mmHg):
379  """Convert pressure from mm Hg to Pascals
380 
381  Notes
382  -----
383  Could use the following, but astropy.units.cds is not fully compatible with Python 2
384  as of astropy 1.2.1 (see https://github.com/astropy/astropy/issues/5350#issuecomment-248612824):
385  astropy.units.cds.mmHg.to(astropy.units.pascal, mmHg)
386  """
387  return mmHg*PascalPerMmHg
388 
389  @staticmethod
390  def pascalFromTorr(torr):
391  """Convert pressure from torr to Pascals
392  """
393  return torr*PascalPerTorr
394 
395  @staticmethod
396  def defaultMetadata(value, defaultValue, minimum=None, maximum=None):
397  """Return the value if it is not NaN and within min/max, otherwise
398  return defaultValue.
399 
400  Parameters
401  ----------
402  value : `float`
403  metadata value returned by popItem, popFloat, or popAngle
404  defaultValue : `float``
405  default value to use if the metadata value is invalid
406  minimum : `float`
407  Minimum possible valid value, optional
408  maximum : `float`
409  Maximum possible valid value, optional
410 
411  Returns
412  -------
413  `float`
414  The "validated" value.
415  """
416  if np.isnan(value):
417  retVal = defaultValue
418  else:
419  if minimum is not None and value < minimum:
420  retVal = defaultValue
421  elif maximum is not None and value > maximum:
422  retVal = defaultValue
423  else:
424  retVal = value
425  return retVal
def popIsoDate(self, md, key, timesys=None)
def popAngle(self, md, key, units=astropy.units.deg)
def defaultMetadata(value, defaultValue, minimum=None, maximum=None)
def popMjdDate(self, md, key, timesys=None)
def popItem(self, md, key, default=None)