Coverage for python/lsst/obs/base/makeRawVisitInfo.py: 26%
133 statements
« prev ^ index » next coverage.py v6.4.1, created at 2022-06-14 02:56 -0700
« prev ^ index » next coverage.py v6.4.1, created at 2022-06-14 02:56 -0700
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 logging
25import math
27import astropy.coordinates
28import astropy.time
29import astropy.units
30import numpy as np
31from lsst.afw.image import VisitInfo
32from lsst.daf.base import DateTime
33from lsst.geom import degrees
35PascalPerMillibar = 100.0
36PascalPerMmHg = 133.322387415 # from Wikipedia; exact
37PascalPerTorr = 101325.0 / 760.0 # from Wikipedia; exact
38KelvinMinusCentigrade = 273.15 # from Wikipedia; exact
40# have these read at need, to avoid unexpected errors later
41NaN = float("nan")
42BadDate = DateTime()
45class MakeRawVisitInfo:
46 """Base class functor to make a VisitInfo from the FITS header of a raw
47 image.
49 A subclass will be wanted for each camera. Subclasses should override:
51 - `setArgDict`, The override can call the base implementation,
52 which simply sets exposure time and date of observation
53 - `getDateAvg`
55 The design philosophy is to make a best effort and log warnings of
56 problems, rather than raising exceptions, in order to extract as much
57 VisitInfo information as possible from a messy FITS header without the
58 user needing to add a lot of error handling.
60 However, the methods that transform units are less forgiving; they assume
61 the user provides proper data types, since type errors in arguments to
62 those are almost certainly due to coding mistakes.
64 Parameters
65 ----------
66 log : `logging.Logger` or None
67 Logger to use for messages.
68 (None to use ``logging.getLogger("lsst.obs.base.makeRawVisitInfo")``).
69 doStripHeader : `bool`, optional
70 Strip header keywords from the metadata as they are used?
71 """
73 def __init__(self, log=None, doStripHeader=False):
74 if log is None:
75 log = logging.getLogger(__name__)
76 self.log = log
77 self.doStripHeader = doStripHeader
79 def __call__(self, md, exposureId):
80 """Construct a VisitInfo and strip associated data from the metadata.
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) if ``doStripHeader``.
88 exposureId : `int`
89 exposure ID
91 Notes
92 -----
93 The basic implementation sets `date` and `exposureTime` using typical
94 values 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.warning("argDict[%s] is None; stripping", key)
101 del argDict[key]
102 return VisitInfo(**argDict)
104 def setArgDict(self, md, argDict):
105 """Fill an argument dict with arguments for VisitInfo and pop
106 associated metadata
108 Subclasses are expected to override this method, though the override
109 may wish to call this default implementation, which:
111 - sets exposureTime from "EXPTIME"
112 - sets date by calling getDateAvg
114 Parameters
115 ----------
116 md : `lsst.daf.base.PropertyList` or `PropertySet`
117 Metadata to pull from.
118 Items that are used are stripped from the metadata (except TIMESYS,
119 because it may apply to other keywords).
120 argdict : `dict`
121 dict of arguments
123 Notes
124 -----
125 Subclasses should expand this or replace it.
126 """
127 argDict["exposureTime"] = self.popFloat(md, "EXPTIME")
128 argDict["date"] = self.getDateAvg(md=md, exposureTime=argDict["exposureTime"])
130 def getDateAvg(self, md, exposureTime):
131 """Return date at the middle of the exposure.
133 Parameters
134 ----------
135 md : `lsst.daf.base.PropertyList` or `PropertySet`
136 Metadata to pull from.
137 Items that are used are stripped from the metadata (except TIMESYS,
138 because it may apply to other keywords).
139 exposureTime : `float`
140 Exposure time (sec)
142 Notes
143 -----
144 Subclasses must override. Here is a typical implementation::
146 dateObs = self.popIsoDate(md, "DATE-OBS")
147 return self.offsetDate(dateObs, 0.5*exposureTime)
148 """
149 raise NotImplementedError()
151 def getDarkTime(self, argDict):
152 """Get the darkTime from the DARKTIME keyword, else expTime, else NaN,
154 If dark time is available then subclasses should call this method by
155 putting the following in their `__init__` method::
157 argDict['darkTime'] = self.getDarkTime(argDict)
159 Parameters
160 ----------
161 argdict : `dict`
162 Dict of arguments.
164 Returns
165 -------
166 `float`
167 Dark time, as inferred from the metadata.
168 """
169 darkTime = argDict.get("darkTime", NaN)
170 if np.isfinite(darkTime):
171 return darkTime
173 self.log.info("darkTime is NaN/Inf; using exposureTime")
174 exposureTime = argDict.get("exposureTime", NaN)
175 if not np.isfinite(exposureTime):
176 raise RuntimeError("Tried to substitute exposureTime for darkTime but it is not available")
178 return exposureTime
180 def offsetDate(self, date, offsetSec):
181 """Return a date offset by a specified number of seconds.
183 date : `lsst.daf.base.DateTime`
184 Date baseline to offset from.
185 offsetSec : `float`
186 Offset, in seconds.
188 Returns
189 -------
190 `lsst.daf.base.DateTime`
191 The offset date.
192 """
193 if not date.isValid():
194 self.log.warning("date is invalid; cannot offset it")
195 return date
196 if math.isnan(offsetSec):
197 self.log.warning("offsetSec is invalid; cannot offset date")
198 return date
199 dateNSec = date.nsecs(DateTime.TAI)
200 return DateTime(dateNSec + int(offsetSec * 1.0e9), DateTime.TAI)
202 def popItem(self, md, key, default=None):
203 """Return an item of metadata.
205 The item is removed if ``doStripHeader`` is ``True``.
207 Log a warning if the key is not found.
209 Parameters
210 ----------
211 md : `lsst.daf.base.PropertyList` or `PropertySet`
212 Metadata to pull `key` from and (optionally) remove.
213 key : `str`
214 Metadata key to extract.
215 default : `object`
216 Value to return if key not found.
218 Returns
219 -------
220 `object`
221 The value of the specified key, using whatever type
222 md.getScalar(key) returns.
223 """
224 try:
225 if not md.exists(key):
226 self.log.warning('Key="%s" not in metadata', key)
227 return default
228 val = md.getScalar(key)
229 if self.doStripHeader:
230 md.remove(key)
231 return val
232 except Exception as e:
233 # this should never happen, but is a last ditch attempt to avoid
234 # exceptions
235 self.log.warning('Could not read key="%s" in metadata: %s', key, e)
236 return default
238 def popFloat(self, md, key):
239 """Pop a float with a default of NaN.
241 Parameters
242 ----------
243 md : `lsst.daf.base.PropertyList` or `PropertySet`
244 Metadata to pull `key` from.
245 key : `str`
246 Key to read.
248 Returns
249 -------
250 `float`
251 Value of the requested key as a float; float("nan") if the key is
252 not found.
253 """
254 val = self.popItem(md, key, default=NaN)
255 try:
256 return float(val)
257 except Exception as e:
258 self.log.warning("Could not interpret %s value %r as a float: %s", key, val, e)
259 return NaN
261 def popAngle(self, md, key, units=astropy.units.deg):
262 """Pop an lsst.afw.geom.Angle, whose metadata is in the specified
263 units, with a default of Nan
265 The angle may be specified as a float or sexagesimal string with 1-3
266 fields.
268 Parameters
269 ----------
270 md : `lsst.daf.base.PropertyList` or `PropertySet`
271 Metadata to pull `key` from.
272 key : `str`
273 Key to read from md.
275 Returns
276 -------
277 `lsst.afw.geom.Angle`
278 Value of the requested key as an angle; Angle(NaN) if the key is
279 not found.
280 """
281 angleStr = self.popItem(md, key, default=None)
282 if angleStr is not None:
283 try:
284 return (astropy.coordinates.Angle(angleStr, unit=units).deg) * degrees
285 except Exception as e:
286 self.log.warning("Could not intepret %s value %r as an angle: %s", key, angleStr, e)
287 return NaN * degrees
289 def popIsoDate(self, md, key, timesys=None):
290 """Pop a FITS ISO date as an lsst.daf.base.DateTime
292 Parameters
293 ----------
294 md : `lsst.daf.base.PropertyList` or `PropertySet`
295 Metadata to pull `key` from.
296 key : `str`
297 Date key to read from md.
298 timesys : `str`
299 Time system as a string (not case sensitive), e.g. "UTC" or None;
300 if None then look for TIMESYS (but do NOT pop it, since it may be
301 used for more than one date) and if not found, use UTC.
303 Returns
304 -------
305 `lsst.daf.base.DateTime`
306 Value of the requested date; `DateTime()` if the key is not found.
307 """
308 isoDateStr = self.popItem(md=md, key=key)
309 if isoDateStr is not None:
310 try:
311 if timesys is None:
312 timesys = md.getScalar("TIMESYS") if md.exists("TIMESYS") else "UTC"
313 if isoDateStr.endswith("Z"): # illegal in FITS
314 isoDateStr = isoDateStr[0:-1]
315 astropyTime = astropy.time.Time(isoDateStr, scale=timesys.lower(), format="fits")
316 # DateTime uses nanosecond resolution, regardless of the
317 # resolution of the original date
318 astropyTime.precision = 9
319 # isot is ISO8601 format with "T" separating date and time and
320 # no time zone
321 return DateTime(astropyTime.tai.isot, DateTime.TAI)
322 except Exception as e:
323 self.log.warning("Could not parse %s = %r as an ISO date: %s", key, isoDateStr, e)
324 return BadDate
326 def popMjdDate(self, md, key, timesys=None):
327 """Get a FITS MJD date as an ``lsst.daf.base.DateTime``.
329 Parameters
330 ----------
331 md : `lsst.daf.base.PropertyList` or `PropertySet`
332 Metadata to pull `key` from.
333 key : `str`
334 Date key to read from md.
335 timesys : `str`
336 Time system as a string (not case sensitive), e.g. "UTC" or None;
337 if None then look for TIMESYS (but do NOT pop it, since it may be
338 used for more than one date) and if not found, use UTC.
340 Returns
341 -------
342 `lsst.daf.base.DateTime`
343 Value of the requested date; `DateTime()` if the key is not found.
344 """
345 mjdDate = self.popFloat(md, key)
346 try:
347 if timesys is None:
348 timesys = md.getScalar("TIMESYS") if md.exists("TIMESYS") else "UTC"
349 astropyTime = astropy.time.Time(mjdDate, format="mjd", scale=timesys.lower())
350 # DateTime uses nanosecond resolution, regardless of the resolution
351 # of the original date
352 astropyTime.precision = 9
353 # isot is ISO8601 format with "T" separating date and time and no
354 # time zone
355 return DateTime(astropyTime.tai.isot, DateTime.TAI)
356 except Exception as e:
357 self.log.warning("Could not parse %s = %r as an MJD date: %s", key, mjdDate, e)
358 return BadDate
360 @staticmethod
361 def eraFromLstAndLongitude(lst, longitude):
362 """
363 Return an approximate Earth Rotation Angle (afw:Angle) computed from
364 local sidereal time and longitude (both as afw:Angle; Longitude shares
365 the afw:Observatory covention: positive values are E of Greenwich).
367 NOTE: if we properly compute ERA via UT1 a la DM-8053, we should remove
368 this method.
369 """
370 return lst - longitude
372 @staticmethod
373 def altitudeFromZenithDistance(zd):
374 """Convert zenith distance to altitude (lsst.afw.geom.Angle)"""
375 return 90 * degrees - zd
377 @staticmethod
378 def centigradeFromKelvin(tempK):
379 """Convert temperature from Kelvin to Centigrade"""
380 return tempK - KelvinMinusCentigrade
382 @staticmethod
383 def pascalFromMBar(mbar):
384 """Convert pressure from millibars to Pascals"""
385 return mbar * PascalPerMillibar
387 @staticmethod
388 def pascalFromMmHg(mmHg):
389 """Convert pressure from mm Hg to Pascals
391 Notes
392 -----
393 Could use the following, but astropy.units.cds is not fully compatible
394 with Python 2 as of astropy 1.2.1 (see
395 https://github.com/astropy/astropy/issues/5350#issuecomment-248612824):
396 astropy.units.cds.mmHg.to(astropy.units.pascal, mmHg)
397 """
398 return mmHg * PascalPerMmHg
400 @staticmethod
401 def pascalFromTorr(torr):
402 """Convert pressure from torr to Pascals"""
403 return torr * PascalPerTorr
405 @staticmethod
406 def defaultMetadata(value, defaultValue, minimum=None, maximum=None):
407 """Return the value if it is not NaN and within min/max, otherwise
408 return defaultValue.
410 Parameters
411 ----------
412 value : `float`
413 metadata value returned by popItem, popFloat, or popAngle
414 defaultValue : `float``
415 default value to use if the metadata value is invalid
416 minimum : `float`
417 Minimum possible valid value, optional
418 maximum : `float`
419 Maximum possible valid value, optional
421 Returns
422 -------
423 `float`
424 The "validated" value.
425 """
426 if np.isnan(value):
427 retVal = defaultValue
428 else:
429 if minimum is not None and value < minimum:
430 retVal = defaultValue
431 elif maximum is not None and value > maximum:
432 retVal = defaultValue
433 else:
434 retVal = value
435 return retVal