Coverage for python / lsst / obs / base / exposureAssembler.py: 15%
115 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-17 09:02 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-17 09:02 +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# (http://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"""Support for assembling and disassembling afw Exposures."""
24from __future__ import annotations
26import contextlib
27import logging
28from collections.abc import Iterable, Mapping
29from typing import Any
31# Need to enable PSFs to be instantiated
32import lsst.afw.detection
33from lsst.afw.image import Exposure, makeExposure, makeMaskedImage
34from lsst.daf.butler import DatasetComponent, DatasetProvenance, DatasetRef, StorageClassDelegate
36from .utils import add_provenance_to_fits_header
38log = logging.getLogger(__name__)
41class ExposureAssembler(StorageClassDelegate):
42 """Knowledge of how to assemble and disassemble an
43 `~lsst.afw.image.Exposure`.
44 """
46 EXPOSURE_COMPONENTS = {"image", "variance", "mask", "wcs", "psf"}
47 EXPOSURE_INFO_COMPONENTS = {
48 "apCorrMap",
49 "coaddInputs",
50 "photoCalib",
51 "metadata",
52 "filter",
53 "transmissionCurve",
54 "visitInfo",
55 "detector",
56 "validPolygon",
57 "summaryStats",
58 "id",
59 }
60 EXPOSURE_READ_COMPONENTS = {
61 "bbox",
62 "dimensions",
63 "xy0",
64 }
66 COMPONENT_MAP = {"bbox": "BBox", "xy0": "XY0"}
67 """Map component name to actual getter name."""
69 def _groupRequestedComponents(self) -> tuple[set[str], set[str]]:
70 """Group requested components into top level and ExposureInfo.
72 Returns
73 -------
74 expComps : `set` [`str`]
75 Components associated with the top level Exposure.
76 expInfoComps : `set` [`str`]
77 Components associated with the ExposureInfo
79 Raises
80 ------
81 ValueError
82 There are components defined in the storage class that are not
83 expected by this assembler.
84 """
85 requested = set(self.storageClass.components.keys())
87 # Check that we are requesting something that we support
88 unknown = requested - (self.EXPOSURE_COMPONENTS | self.EXPOSURE_INFO_COMPONENTS)
89 if unknown:
90 raise ValueError(f"Asking for unrecognized component: {unknown}")
92 expItems = requested & self.EXPOSURE_COMPONENTS
93 expInfoItems = requested & self.EXPOSURE_INFO_COMPONENTS
94 return expItems, expInfoItems
96 def getComponent(self, composite: lsst.afw.image.Exposure, componentName: str) -> Any:
97 """Get a component from an Exposure.
99 Parameters
100 ----------
101 composite : `~lsst.afw.image.Exposure`
102 `Exposure` to access component.
103 componentName : `str`
104 Name of component to retrieve.
106 Returns
107 -------
108 component : `object`
109 The component. Can be None.
111 Raises
112 ------
113 AttributeError
114 The component can not be found.
115 """
116 if componentName in self.EXPOSURE_COMPONENTS or componentName in self.EXPOSURE_READ_COMPONENTS:
117 # Use getter translation if relevant or the name itself
118 return super().getComponent(composite, self.COMPONENT_MAP.get(componentName, componentName))
119 elif componentName in self.EXPOSURE_INFO_COMPONENTS:
120 if hasattr(composite, "getInfo"):
121 # it is possible for this method to be called with
122 # an ExposureInfo composite so trap for that and only get
123 # the ExposureInfo if the method is supported
124 composite = composite.getInfo()
125 return super().getComponent(composite, self.COMPONENT_MAP.get(componentName, componentName))
126 else:
127 raise AttributeError(
128 f"Do not know how to retrieve component {componentName} from {type(composite)}"
129 )
131 def disassemble(
132 self, composite: Any, subset: Iterable | None = None, override: Any | None = None
133 ) -> dict[str, DatasetComponent]:
134 """Disassemble an afw Exposure.
136 This implementation attempts to extract components from the parent
137 by looking for attributes of the same name or getter methods derived
138 from the component name.
140 Parameters
141 ----------
142 composite : `~lsst.afw.image.Exposure`
143 `Exposure` composite object consisting of components to be
144 extracted.
145 subset : iterable, optional
146 Not supported by this assembler.
147 override : `object`, optional
148 Not supported by this assembler.
150 Returns
151 -------
152 components : `dict`
153 `dict` with keys matching the components defined in
154 `self.storageClass` and values being `DatasetComponent` instances
155 describing the component.
157 Raises
158 ------
159 ValueError
160 A requested component can not be found in the parent using generic
161 lookups.
162 TypeError
163 The parent object does not match the supplied `self.storageClass`.
165 Notes
166 -----
167 If a PSF is present but is not persistable, the PSF will not be
168 included in the returned components.
169 """
170 if subset is not None:
171 raise NotImplementedError(
172 "ExposureAssembler does not support the 'subset' argument to disassemble."
173 )
174 if override is not None:
175 raise NotImplementedError(
176 "ExposureAssembler does not support the 'override' argument to disassemble."
177 )
178 if not self.storageClass.validateInstance(composite):
179 raise TypeError(
180 "Unexpected type mismatch between parent and StorageClass"
181 f" ({type(composite)} != {self.storageClass.pytype})"
182 )
184 # Only look for components that are defined by the StorageClass
185 components: dict[str, DatasetComponent] = {}
186 expItems, expInfoItems = self._groupRequestedComponents()
188 fromExposure = super().disassemble(composite, subset=expItems)
189 assert fromExposure is not None, "Base class implementation guarantees this, but ABC does not."
190 components.update(fromExposure)
192 fromExposureInfo = super().disassemble(composite, subset=expInfoItems, override=composite.getInfo())
193 assert fromExposureInfo is not None, "Base class implementation guarantees this, but ABC does not."
194 components.update(fromExposureInfo)
196 if "psf" in components and not components["psf"].component.isPersistable():
197 log.warning(
198 "PSF of type %s is not persistable and has been ignored.",
199 type(components["psf"].component).__name__,
200 )
201 del components["psf"]
203 return components
205 def assemble(self, components: dict[str, Any], pytype: type | None = None) -> Exposure:
206 """Construct an Exposure from components.
208 Parameters
209 ----------
210 components : `dict`
211 All the components from which to construct the Exposure.
212 Some can be missing.
213 pytype : `type`, optional
214 Not supported by this assembler.
216 Returns
217 -------
218 exposure : `~lsst.afw.image.Exposure`
219 Assembled exposure.
221 Raises
222 ------
223 ValueError
224 Some supplied components are not recognized.
225 """
226 if pytype is not None:
227 raise NotImplementedError("ExposureAssembler does not support the 'pytype' argument to assemble.")
228 components = components.copy()
229 maskedImageComponents = {}
230 hasMaskedImage = False
231 for component in ("image", "variance", "mask"):
232 value = None
233 if component in components:
234 hasMaskedImage = True
235 value = components.pop(component)
236 maskedImageComponents[component] = value
238 wcs = None
239 if "wcs" in components:
240 wcs = components.pop("wcs")
242 pytype = self.storageClass.pytype
243 if hasMaskedImage:
244 maskedImage = makeMaskedImage(**maskedImageComponents)
245 exposure = makeExposure(maskedImage, wcs=wcs)
247 if not isinstance(exposure, pytype):
248 raise RuntimeError(
249 f"Unexpected type created in assembly; was {type(exposure)} expected {pytype}"
250 )
252 else:
253 exposure = pytype()
254 if wcs is not None:
255 exposure.setWcs(wcs)
257 # Set other components
258 exposure.setPsf(components.pop("psf", None))
259 exposure.setPhotoCalib(components.pop("photoCalib", None))
261 info = exposure.getInfo()
262 if "visitInfo" in components:
263 info.setVisitInfo(components.pop("visitInfo"))
264 if "id" in components:
265 info.id = components.pop("id")
266 info.setApCorrMap(components.pop("apCorrMap", None))
267 info.setCoaddInputs(components.pop("coaddInputs", None))
268 info.setMetadata(components.pop("metadata", None))
269 info.setValidPolygon(components.pop("validPolygon", None))
270 info.setDetector(components.pop("detector", None))
271 info.setTransmissionCurve(components.pop("transmissionCurve", None))
272 info.setSummaryStats(components.pop("summaryStats", None))
274 info.setFilter(components.pop("filter", None))
276 # If we have some components left over that is a problem
277 if components:
278 raise ValueError(f"The following components were not understood: {list(components.keys())}")
280 return exposure
282 def handleParameters(self, inMemoryDataset: Any, parameters: Mapping[str, Any] | None = None) -> Any:
283 """Modify the in-memory dataset using the supplied parameters,
284 returning a possibly new object.
286 Parameters
287 ----------
288 inMemoryDataset : `object`
289 Object to modify based on the parameters.
290 parameters : `dict`, optional
291 Parameters to apply. Values are specific to the parameter.
292 Supported parameters are defined in the associated
293 `~lsst.daf.butler.StorageClass`. If no relevant parameters are
294 specified the ``inMemoryDataset`` will be return unchanged.
296 Returns
297 -------
298 inMemoryDataset : `object`
299 Updated form of supplied in-memory dataset, after parameters
300 have been used.
301 """
302 if parameters is None:
303 return inMemoryDataset
304 # Understood by *this* subset command
305 understood = ("bbox", "origin")
306 use = self.storageClass.filterParameters(parameters, subset=understood)
307 if use:
308 inMemoryDataset = inMemoryDataset.subset(**use)
310 return inMemoryDataset
312 @classmethod
313 def selectResponsibleComponent(cls, readComponent: str, fromComponents: set[str | None]) -> str:
314 # Docstring inherited.
315 imageComponents = ["mask", "image", "variance"]
316 forwarderMap = {
317 "bbox": imageComponents,
318 "dimensions": imageComponents,
319 "xy0": imageComponents,
320 }
321 forwarder = forwarderMap.get(readComponent)
322 if forwarder is not None:
323 for c in forwarder:
324 if c in fromComponents:
325 return c
326 raise ValueError(f"Can not calculate read component {readComponent} from {fromComponents}")
328 def add_provenance(
329 self, inMemoryDataset: Any, ref: DatasetRef, provenance: DatasetProvenance | None = None
330 ) -> Any:
331 # Add provenance via FITS headers. This delegate is reused by
332 # MaskedImage as well as Exposure so no guarantee that metadata
333 # is present.
334 with contextlib.suppress(AttributeError):
335 add_provenance_to_fits_header(inMemoryDataset.metadata, ref, provenance)
336 return inMemoryDataset