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