lsst.obs.base  14.0-18-g0eec5f5+3
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  - setArgDict: the override can call the base implementation,
55  which simply sets exposure time and date of observation
56  - setDateAvg
57 
58  The design philosophy is to make a best effort and log warnings of problems,
59  rather than raising exceptions, in order to extract as much VisitInfo information as possible
60  from a messy FITS header without the 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 those
64  are almost certainly due to coding mistakes.
65  """
66 
67  def __init__(self, log=None):
68  """Construct a MakeRawVisitInfo
69  """
70  if log is None:
71  log = Log.getLogger("MakeRawVisitInfo")
72  self.log = log
73 
74  def __call__(self, md, exposureId):
75  """Construct a VisitInfo and strip associated data from the metadata
76 
77  @param[in,out] md metadata, as an lsst.daf.base.PropertyList or PropertySet;
78  items that are used are stripped from the metadata
79  (except TIMESYS, because it may apply to more than one other keyword).
80  @param[in] exposureId exposure ID
81 
82  The basic implementation sets date and exposureTime using typical values
83  found in FITS files and logs a warning if neither can be set.
84  """
85  argDict = dict(exposureId=exposureId)
86  self.setArgDict(md, argDict)
87  for key in list(argDict.keys()): # use a copy because we may delete items
88  if argDict[key] is None:
89  self.log.warn("argDict[{}] is None; stripping".format(key, argDict[key]))
90  del argDict[key]
91  return VisitInfo(**argDict)
92 
93  def setArgDict(self, md, argDict):
94  """Fill an argument dict with arguments for VisitInfo and pop associated metadata
95 
96  Subclasses are expected to override this method, though the override
97  may wish to call this default implementation, which:
98  - sets exposureTime from "EXPTIME"
99  - sets date by calling getDateAvg
100 
101  @param[in,out] md metadata, as an lsst.daf.base.PropertyList or PropertySet;
102  items that are used are stripped from the metadata
103  (except TIMESYS, because it may apply to more than one other keyword).
104  @param[in,out] argdict a dict of arguments
105 
106  Subclasses should expand this or replace it.
107  """
108  argDict["exposureTime"] = self.popFloat(md, "EXPTIME")
109  argDict["date"] = self.getDateAvg(md=md, exposureTime=argDict["exposureTime"])
110 
111  def getDateAvg(self, md, exposureTime):
112  """Return date at the middle of the exposure
113 
114  @param[in,out] md metadata, as an lsst.daf.base.PropertyList or PropertySet;
115  items that are used are stripped from the metadata
116  (except TIMESYS, because it may apply to more than one other keyword).
117  @param[in] exposureTime exposure time (sec)
118 
119  Subclasses must override. Here is a typical implementation:
120  dateObs = self.popIsoDate(md, "DATE-OBS")
121  return self.offsetDate(dateObs, 0.5*exposureTime)
122  """
123  raise NotImplementedError()
124 
125  def getDarkTime(self, argDict):
126  """Get the darkTime from the DARKTIME keyword, else expTime, else NaN
127 
128  Subclasses should call this function if desired, by putting:
129  argDict['darkTime'] = self.getDarkTime(argDict)
130  in their __init__() method of the derived class.
131 
132  @param[in] argDict argDict
133  @return darkTime darkTime, as inferred from the metadata
134 
135  """
136  darkTime = argDict.get("darkTime", NaN)
137  if np.isfinite(darkTime):
138  return darkTime
139 
140  self.log.info("darkTime is NaN/Inf; using exposureTime")
141  exposureTime = argDict.get("exposureTime", NaN)
142  if not np.isfinite(exposureTime):
143  raise RuntimeError("Tried to substitute exposureTime for darkTime but it is not available")
144 
145  return exposureTime
146 
147  def offsetDate(self, date, offsetSec):
148  """Return a date offset by a specified number of seconds
149 
150  @param[in] date date (an lsst.daf.base.DateTime)
151  @param[in] offsetSec offset, in seconds (float)
152  @return the offset date (an lsst.daf.base.DateTime)
153  """
154  if not date.isValid():
155  self.log.warn("date is invalid; cannot offset it")
156  return date
157  if math.isnan(offsetSec):
158  self.log.warn("offsetSec is invalid; cannot offset date")
159  return date
160  dateNSec = date.nsecs(DateTime.TAI)
161  return DateTime(dateNSec + int(offsetSec*1.0e9), DateTime.TAI)
162 
163  def popItem(self, md, key, default=None):
164  """Remove an item of metadata and return the value
165 
166  @param[in,out] md metadata, as an lsst.daf.base.PropertyList or PropertySet;
167  the popped key is removed
168  @param[in] key metadata key
169  @param[in] default default value to return if key not found; ignored if doRaise true
170  @return the value of the specified key, using whatever type md.get(key) returns
171 
172  Log a warning if the key is not found
173  """
174  try:
175  if not md.exists(key):
176  self.log.warn("Key=\"{}\" not in metadata".format(key))
177  return default
178  val = md.get(key)
179  md.remove(key)
180  return val
181  except Exception as e:
182  # this should never happen, but is a last ditch attempt to avoid exceptions
183  self.log.warn("Could not read key=\"{}\" in metadata: {}" % (key, e))
184  return default
185 
186  def popFloat(self, md, key):
187  """Pop a float with a default of Nan
188 
189  @param[in,out] md metadata, as an lsst.daf.base.PropertyList or PropertySet
190  @param[in] key date key to read and remove from md
191  @return the value of the specified key as a float; float("nan") if the key is not found
192  """
193  val = self.popItem(md, key, default=NaN)
194  try:
195  return float(val)
196  except Exception as e:
197  self.log.warn("Could not interpret {} value {} as a float: {}".format(key, repr(val), e))
198  return NaN
199 
200  def popAngle(self, md, key, units=astropy.units.deg):
201  """Pop an lsst.afw.geom.Angle, whose metadata is in the specified units, with a default of Nan
202 
203  The angle may be specified as a float or sexagesimal string with 1-3 fields.
204 
205  @param[in,out] md metadata, as an lsst.daf.base.PropertyList or PropertySet
206  @param[in] key date key to read and remove from md
207  @return angle, as an lsst.afw.geom.Angle; Angle(NaN) if the key is not found
208  """
209  angleStr = self.popItem(md, key, default=None)
210  if angleStr is not None:
211  try:
212  return (astropy.coordinates.Angle(angleStr, unit=units).deg)*degrees
213  except Exception as e:
214  self.log.warn("Could not intepret {} value {} as an angle: {}".format(key, repr(angleStr), e))
215  return NaN*degrees
216 
217  def popIsoDate(self, md, key, timesys=None):
218  """Pop a FITS ISO date as an lsst.daf.base.DateTime
219 
220  @param[in,out] md metadata, as an lsst.daf.base.PropertyList or PropertySet
221  @param[in] key date key to read and remove from md
222  @param[in] timesys time system as a string (not case sensitive), e.g. "UTC" or None;
223  if None then look for TIMESYS (but do NOT pop it, since it may be used
224  for more than one date) and if not found, use UTC
225  @return date as an lsst.daf.base.DateTime; DateTime() if the key is not found
226  """
227  isoDateStr = self.popItem(md=md, key=key)
228  if isoDateStr is not None:
229  try:
230  if timesys is None:
231  timesys = md.get("TIMESYS") if md.exists("TIMESYS") else "UTC"
232  if isoDateStr.endswith("Z"): # illegal in FITS
233  isoDateStr = isoDateStr[0:-1]
234  astropyTime = astropy.time.Time(isoDateStr, scale=timesys.lower(), format="fits")
235  # DateTime uses nanosecond resolution, regardless of the resolution of the original date
236  astropyTime.precision = 9
237  # isot is ISO8601 format with "T" separating date and time and no time zone
238  return DateTime(astropyTime.tai.isot, DateTime.TAI)
239  except Exception as e:
240  self.log.warn("Could not parse {} = {} as an ISO date: {}".format(key, isoDateStr, e))
241  return BadDate
242 
243  def popMjdDate(self, md, key, timesys=None):
244  """Get a FITS MJD date as an lsst.daf.base.DateTime
245 
246  @param[in,out] md metadata, as an lsst.daf.base.PropertyList or PropertySet
247  @param[in] dateKey date key to read and remove from md
248  @param[in] timesys time system as a string, e.g. "UTC" or None;
249  if None then look for TIMESYS (but do NOT pop it, since it may be used
250  for more than one date) and if not found, use UTC
251  @return date as an lsst.daf.base.DateTime; DateTime() if the key is not found
252  """
253  mjdDate = self.popFloat(md, key)
254  try:
255  if timesys is None:
256  timesys = md.get("TIMESYS") if md.exists("TIMESYS") else "UTC"
257  astropyTime = astropy.time.Time(mjdDate, format="mjd", scale=timesys.lower())
258  # DateTime uses nanosecond resolution, regardless of the resolution of the original date
259  astropyTime.precision = 9
260  # isot is ISO8601 format with "T" separating date and time and no time zone
261  return DateTime(astropyTime.tai.isot, DateTime.TAI)
262  except Exception as e:
263  self.log.warn("Could not parse {} = {} as an MJD date: {}".format(key, mjdDate, e))
264  return BadDate
265 
266  @staticmethod
267  def eraFromLstAndLongitude(lst, longitude):
268  """
269  Return an approximate Earth Rotation Angle (afw:Angle) computed from
270  local sidereal time and longitude (both as afw:Angle; Longitude shares
271  the afw:Observatory covention: positive values are E of Greenwich).
272 
273  NOTE: if we properly compute ERA via UT1 a la DM-8053, we should remove
274  this method.
275  """
276  return lst - longitude
277 
278  @staticmethod
280  """Convert zenith distance to altitude (lsst.afw.geom.Angle)"""
281  return 90*degrees - zd
282 
283  @staticmethod
285  """Convert temperature from Kelvin to Centigrade"""
286  return tempK - KelvinMinusCentigrade
287 
288  @staticmethod
289  def pascalFromMBar(mbar):
290  """Convert pressure from millibars to Pascals
291  """
292  return mbar*PascalPerMillibar
293 
294  @staticmethod
295  def pascalFromMmHg(mmHg):
296  """Convert pressure from mm Hg to Pascals
297 
298  @note could use the following, but astropy.units.cds is not fully compatible with Python 2
299  as of astropy 1.2.1 (see https://github.com/astropy/astropy/issues/5350#issuecomment-248612824):
300  astropy.units.cds.mmHg.to(astropy.units.pascal, mmHg)
301  """
302  return mmHg*PascalPerMmHg
303 
304  @staticmethod
305  def pascalFromTorr(torr):
306  """Convert pressure from torr to Pascals
307  """
308  return torr*PascalPerTorr
def popIsoDate(self, md, key, timesys=None)
def popAngle(self, md, key, units=astropy.units.deg)
def popMjdDate(self, md, key, timesys=None)
def popItem(self, md, key, default=None)