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

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/>. 

21 

22"""Support for assembling and disassembling afw Exposures.""" 

23 

24from __future__ import annotations 

25 

26import contextlib 

27import logging 

28from collections.abc import Iterable, Mapping 

29from typing import Any 

30 

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 

35 

36from .utils import add_provenance_to_fits_header 

37 

38log = logging.getLogger(__name__) 

39 

40 

41class ExposureAssembler(StorageClassDelegate): 

42 """Knowledge of how to assemble and disassemble an 

43 `~lsst.afw.image.Exposure`. 

44 """ 

45 

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 } 

65 

66 COMPONENT_MAP = {"bbox": "BBox", "xy0": "XY0"} 

67 """Map component name to actual getter name.""" 

68 

69 def _groupRequestedComponents(self) -> tuple[set[str], set[str]]: 

70 """Group requested components into top level and ExposureInfo. 

71 

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 

78 

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()) 

86 

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}") 

91 

92 expItems = requested & self.EXPOSURE_COMPONENTS 

93 expInfoItems = requested & self.EXPOSURE_INFO_COMPONENTS 

94 return expItems, expInfoItems 

95 

96 def getComponent(self, composite: lsst.afw.image.Exposure, componentName: str) -> Any: 

97 """Get a component from an Exposure. 

98 

99 Parameters 

100 ---------- 

101 composite : `~lsst.afw.image.Exposure` 

102 `Exposure` to access component. 

103 componentName : `str` 

104 Name of component to retrieve. 

105 

106 Returns 

107 ------- 

108 component : `object` 

109 The component. Can be None. 

110 

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 ) 

130 

131 def disassemble( 

132 self, composite: Any, subset: Iterable | None = None, override: Any | None = None 

133 ) -> dict[str, DatasetComponent]: 

134 """Disassemble an afw Exposure. 

135 

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. 

139 

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. 

149 

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. 

156 

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`. 

164 

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 ) 

183 

184 # Only look for components that are defined by the StorageClass 

185 components: dict[str, DatasetComponent] = {} 

186 expItems, expInfoItems = self._groupRequestedComponents() 

187 

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) 

191 

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) 

195 

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"] 

202 

203 return components 

204 

205 def assemble(self, components: dict[str, Any], pytype: type | None = None) -> Exposure: 

206 """Construct an Exposure from components. 

207 

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. 

215 

216 Returns 

217 ------- 

218 exposure : `~lsst.afw.image.Exposure` 

219 Assembled exposure. 

220 

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 

237 

238 wcs = None 

239 if "wcs" in components: 

240 wcs = components.pop("wcs") 

241 

242 pytype = self.storageClass.pytype 

243 if hasMaskedImage: 

244 maskedImage = makeMaskedImage(**maskedImageComponents) 

245 exposure = makeExposure(maskedImage, wcs=wcs) 

246 

247 if not isinstance(exposure, pytype): 

248 raise RuntimeError( 

249 f"Unexpected type created in assembly; was {type(exposure)} expected {pytype}" 

250 ) 

251 

252 else: 

253 exposure = pytype() 

254 if wcs is not None: 

255 exposure.setWcs(wcs) 

256 

257 # Set other components 

258 exposure.setPsf(components.pop("psf", None)) 

259 exposure.setPhotoCalib(components.pop("photoCalib", None)) 

260 

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)) 

273 

274 info.setFilter(components.pop("filter", None)) 

275 

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())}") 

279 

280 return exposure 

281 

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. 

285 

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. 

295 

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) 

309 

310 return inMemoryDataset 

311 

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}") 

327 

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