Coverage for python/lsst/obs/base/_fitsRawFormatterBase.py: 37%
144 statements
« prev ^ index » next coverage.py v7.5.0, created at 2024-05-02 10:58 +0000
« prev ^ index » next coverage.py v7.5.0, created at 2024-05-02 10:58 +0000
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 <http://www.gnu.org/licenses/>.
22__all__ = ("FitsRawFormatterBase",)
24import logging
25from abc import abstractmethod
27import lsst.afw.fits
28import lsst.afw.geom
29import lsst.afw.image
30from astro_metadata_translator import ObservationInfo, fix_header
31from lsst.daf.butler import FileDescriptor
32from lsst.utils.classes import cached_getter
34from .formatters.fitsExposure import FitsImageFormatterBase, standardizeAmplifierParameters
35from .makeRawVisitInfoViaObsInfo import MakeRawVisitInfoViaObsInfo
36from .utils import InitialSkyWcsError, createInitialSkyWcsFromBoresight
38log = logging.getLogger(__name__)
41class FitsRawFormatterBase(FitsImageFormatterBase):
42 """Abstract base class for reading and writing raw data to and from
43 FITS files.
44 """
46 # This has to be explicit until we fix camera geometry in DM-20746
47 wcsFlipX = False
48 """Control whether the WCS is flipped in the X-direction (`bool`)"""
50 def __init__(self, *args, **kwargs):
51 super().__init__(*args, **kwargs)
52 self._metadata = None
53 self._observationInfo = None
55 @classmethod
56 def fromMetadata(cls, metadata, obsInfo=None, storageClass=None, location=None):
57 """Construct a possibly-limited formatter from known metadata.
59 Parameters
60 ----------
61 metadata : `lsst.daf.base.PropertyList`
62 Raw header metadata, with any fixes (see
63 `astro_metadata_translator.fix_header`) applied but nothing
64 stripped.
65 obsInfo : `astro_metadata_translator.ObservationInfo`, optional
66 Structured information already extracted from ``metadata``.
67 If not provided, will be read from ``metadata`` on first use.
68 storageClass : `lsst.daf.butler.StorageClass`, optional
69 StorageClass for this file. If not provided, the formatter will
70 only support `makeWcs`, `makeVisitInfo`, `makeFilter`, and other
71 operations that operate purely on metadata and not the actual file.
72 location : `lsst.daf.butler.Location`, optional.
73 Location of the file. If not provided, the formatter will only
74 support `makeWcs`, `makeVisitInfo`, `makeFilter`, and other
75 operations that operate purely on metadata and not the actual file.
77 Returns
78 -------
79 formatter : `FitsRawFormatterBase`
80 An instance of ``cls``.
81 """
82 self = cls(FileDescriptor(location, storageClass))
83 self._metadata = metadata
84 self._observationInfo = obsInfo
85 return self
87 @property
88 @abstractmethod
89 def translatorClass(self):
90 """`~astro_metadata_translator.MetadataTranslator` to translate
91 metadata header to `~astro_metadata_translator.ObservationInfo`.
92 """
93 return None
95 @property
96 @abstractmethod
97 def filterDefinitions(self):
98 """`~lsst.obs.base.FilterDefinitions`, defining the filters for this
99 instrument.
100 """
101 return None
103 @property
104 @cached_getter
105 def checked_parameters(self):
106 # Docstring inherited.
107 parameters = super().checked_parameters
108 if "bbox" in parameters:
109 raise TypeError(
110 "Raw formatters do not support reading arbitrary subimages, as some "
111 "implementations may be assembled on-the-fly."
112 )
113 return parameters
115 def readImage(self):
116 """Read just the image component of the Exposure.
118 Returns
119 -------
120 image : `~lsst.afw.image.Image`
121 In-memory image component.
122 """
123 return lsst.afw.image.ImageU(self.fileDescriptor.location.path)
125 def isOnSky(self):
126 """Boolean to determine if the exposure is thought to be on the sky.
128 Returns
129 -------
130 onSky : `bool`
131 Returns `True` if the observation looks like it was taken on the
132 sky. Returns `False` if this observation looks like a calibration
133 observation.
135 Notes
136 -----
137 If there is tracking RA/Dec information associated with the
138 observation it is assumed that the observation is on sky.
139 Currently the observation type is not checked.
140 """
141 if self.observationInfo.tracking_radec is None:
142 return False
143 return True
145 @property
146 def metadata(self):
147 """The metadata read from this file. It will be stripped as
148 components are extracted from it
149 (`lsst.daf.base.PropertyList`).
150 """
151 if self._metadata is None:
152 self._metadata = self.readMetadata()
153 return self._metadata
155 def readMetadata(self):
156 """Read all header metadata directly into a PropertyList.
158 Returns
159 -------
160 metadata : `~lsst.daf.base.PropertyList`
161 Header metadata.
162 """
163 md = lsst.afw.fits.readMetadata(self.fileDescriptor.location.path)
164 fix_header(md, translator_class=self.translatorClass)
165 return md
167 def stripMetadata(self):
168 """Remove metadata entries that are parsed into components."""
169 try:
170 lsst.afw.geom.stripWcsMetadata(self.metadata)
171 except TypeError as e:
172 log.debug("Error caught and ignored while stripping metadata: %s", e.args[0])
174 def makeVisitInfo(self):
175 """Construct a VisitInfo from metadata.
177 Returns
178 -------
179 visitInfo : `~lsst.afw.image.VisitInfo`
180 Structured metadata about the observation.
181 """
182 visit_info = MakeRawVisitInfoViaObsInfo.observationInfo2visitInfo(self.observationInfo)
183 if self.dataId and "exposure" in self.dataId:
184 # Special case exposure existing for this dataset type.
185 # Want to ensure that the id stored in the visitInfo matches that
186 # from the dataId from butler, regardless of what may have come
187 # from the ObservationInfo. In some edge cases they might differ.
188 exposure_id = self.dataId["exposure"]
189 if exposure_id != visit_info.id:
190 visit_info = visit_info.copyWith(id=exposure_id)
192 return visit_info
194 @abstractmethod
195 def getDetector(self, id):
196 """Return the detector that acquired this raw exposure.
198 Parameters
199 ----------
200 id : `int`
201 The identifying number of the detector to get.
203 Returns
204 -------
205 detector : `~lsst.afw.cameraGeom.Detector`
206 The detector associated with that ``id``.
207 """
208 raise NotImplementedError("Must be implemented by subclasses.")
210 def makeWcs(self, visitInfo, detector):
211 """Create a SkyWcs from information about the exposure.
213 If VisitInfo is not None, use it and the detector to create a SkyWcs,
214 otherwise return the metadata-based SkyWcs (always created, so that
215 the relevant metadata keywords are stripped).
217 Parameters
218 ----------
219 visitInfo : `~lsst.afw.image.VisitInfo`
220 The information about the telescope boresight and camera
221 orientation angle for this exposure.
222 detector : `~lsst.afw.cameraGeom.Detector`
223 The detector used to acquire this exposure.
225 Returns
226 -------
227 skyWcs : `~lsst.afw.geom.SkyWcs`
228 Reversible mapping from pixel coordinates to sky coordinates.
230 Raises
231 ------
232 InitialSkyWcsError
233 Raised if there is an error generating the SkyWcs, chained from the
234 lower-level exception if available.
235 """
236 if not self.isOnSky():
237 # This is not an on-sky observation
238 return None
240 if visitInfo is None:
241 msg = "No VisitInfo; cannot access boresight information. Defaulting to metadata-based SkyWcs."
242 log.warning(msg)
243 skyWcs = self._createSkyWcsFromMetadata()
244 if skyWcs is None:
245 raise InitialSkyWcsError(
246 "Failed to create both metadata and boresight-based SkyWcs."
247 "See warnings in log messages for details."
248 )
249 return skyWcs
251 return self.makeRawSkyWcsFromBoresight(
252 visitInfo.getBoresightRaDec(), visitInfo.getBoresightRotAngle(), detector
253 )
255 @classmethod
256 def makeRawSkyWcsFromBoresight(cls, boresight, orientation, detector):
257 """Class method to make a raw sky WCS from boresight and detector.
259 Parameters
260 ----------
261 boresight : `lsst.geom.SpherePoint`
262 The ICRS boresight RA/Dec
263 orientation : `lsst.geom.Angle`
264 The rotation angle of the focal plane on the sky.
265 detector : `lsst.afw.cameraGeom.Detector`
266 Where to get the camera geometry from.
268 Returns
269 -------
270 skyWcs : `~lsst.afw.geom.SkyWcs`
271 Reversible mapping from pixel coordinates to sky coordinates.
272 """
273 return createInitialSkyWcsFromBoresight(boresight, orientation, detector, flipX=cls.wcsFlipX)
275 def _createSkyWcsFromMetadata(self):
276 """Create a SkyWcs from the FITS header metadata in an Exposure.
278 Returns
279 -------
280 skyWcs: `lsst.afw.geom.SkyWcs`, or None
281 The WCS that was created from ``self.metadata``, or None if that
282 creation fails due to invalid metadata.
283 """
284 if not self.isOnSky():
285 # This is not an on-sky observation
286 return None
288 try:
289 return lsst.afw.geom.makeSkyWcs(self.metadata, strip=True)
290 except TypeError as e:
291 log.warning("Cannot create a valid WCS from metadata: %s", e.args[0])
292 return None
294 def makeFilterLabel(self):
295 """Construct a FilterLabel from metadata.
297 Returns
298 -------
299 filter : `~lsst.afw.image.FilterLabel`
300 Object that identifies the filter for this image.
301 """
302 physical = self.observationInfo.physical_filter
303 band = self.filterDefinitions.physical_to_band[physical]
304 return lsst.afw.image.FilterLabel(physical=physical, band=band)
306 def readComponent(self, component):
307 # Docstring inherited.
308 _ = self.checked_parameters # just for checking; no supported parameters.
309 if component == "image":
310 return self.readImage()
311 elif component == "filter":
312 return self.makeFilterLabel()
313 elif component == "visitInfo":
314 return self.makeVisitInfo()
315 elif component == "detector":
316 return self.getDetector(self.observationInfo.detector_num)
317 elif component == "wcs":
318 detector = self.getDetector(self.observationInfo.detector_num)
319 visitInfo = self.makeVisitInfo()
320 return self.makeWcs(visitInfo, detector)
321 elif component == "metadata":
322 self.stripMetadata()
323 return self.metadata
324 return None
326 def readFull(self):
327 # Docstring inherited.
328 amplifier, detector, _ = standardizeAmplifierParameters(
329 self.checked_parameters,
330 self.getDetector(self.observationInfo.detector_num),
331 )
332 if amplifier is not None:
333 reader = lsst.afw.image.ImageFitsReader(self.fileDescriptor.location.path)
334 amplifier_isolator = lsst.afw.cameraGeom.AmplifierIsolator(
335 amplifier,
336 reader.readBBox(),
337 detector,
338 )
339 subimage = amplifier_isolator.transform_subimage(
340 reader.read(bbox=amplifier_isolator.subimage_bbox)
341 )
342 exposure = lsst.afw.image.makeExposure(lsst.afw.image.makeMaskedImage(subimage))
343 exposure.setDetector(amplifier_isolator.make_detector())
344 else:
345 exposure = lsst.afw.image.makeExposure(lsst.afw.image.makeMaskedImage(self.readImage()))
346 exposure.setDetector(detector)
347 self.attachComponentsFromMetadata(exposure)
348 return exposure
350 def write(self, inMemoryDataset):
351 """Write a Python object to a file.
353 Parameters
354 ----------
355 inMemoryDataset : `object`
356 The Python object to store.
358 Returns
359 -------
360 path : `str`
361 The `URI` where the primary file is stored.
362 """
363 raise NotImplementedError("Raw data cannot be `put`.")
365 @property
366 def observationInfo(self):
367 """The `~astro_metadata_translator.ObservationInfo` extracted from
368 this file's metadata (`~astro_metadata_translator.ObservationInfo`,
369 read-only).
370 """
371 if self._observationInfo is None:
372 location = self.fileDescriptor.location
373 path = location.path if location is not None else None
374 self._observationInfo = ObservationInfo(
375 self.metadata, translator_class=self.translatorClass, filename=path
376 )
377 return self._observationInfo
379 def attachComponentsFromMetadata(self, exposure):
380 """Attach all `lsst.afw.image.Exposure` components derived from
381 metadata (including the stripped metadata itself).
383 Parameters
384 ----------
385 exposure : `lsst.afw.image.Exposure`
386 Exposure to attach components to (modified in place). Must already
387 have a detector attached.
388 """
389 info = exposure.getInfo()
390 info.id = self.observationInfo.detector_exposure_id
391 info.setFilter(self.makeFilterLabel())
392 info.setVisitInfo(self.makeVisitInfo())
393 info.setWcs(self.makeWcs(info.getVisitInfo(), info.getDetector()))
395 self.stripMetadata()
396 exposure.setMetadata(self.metadata)