Coverage for python / lsst / obs / base / exposureAssembler.py: 14%

114 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-12-17 16:34 -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# (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 

24import contextlib 

25import logging 

26from collections.abc import Iterable, Mapping 

27from typing import Any 

28 

29# Need to enable PSFs to be instantiated 

30import lsst.afw.detection 

31from lsst.afw.image import Exposure, makeExposure, makeMaskedImage 

32from lsst.daf.butler import DatasetComponent, DatasetProvenance, DatasetRef, StorageClassDelegate 

33 

34from .utils import add_provenance_to_fits_header 

35 

36log = logging.getLogger(__name__) 

37 

38 

39class ExposureAssembler(StorageClassDelegate): 

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

41 `~lsst.afw.image.Exposure`. 

42 """ 

43 

44 EXPOSURE_COMPONENTS = {"image", "variance", "mask", "wcs", "psf"} 

45 EXPOSURE_INFO_COMPONENTS = { 

46 "apCorrMap", 

47 "coaddInputs", 

48 "photoCalib", 

49 "metadata", 

50 "filter", 

51 "transmissionCurve", 

52 "visitInfo", 

53 "detector", 

54 "validPolygon", 

55 "summaryStats", 

56 "id", 

57 } 

58 EXPOSURE_READ_COMPONENTS = { 

59 "bbox", 

60 "dimensions", 

61 "xy0", 

62 } 

63 

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

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

66 

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

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

69 

70 Returns 

71 ------- 

72 expComps : `set` [`str`] 

73 Components associated with the top level Exposure. 

74 expInfoComps : `set` [`str`] 

75 Components associated with the ExposureInfo 

76 

77 Raises 

78 ------ 

79 ValueError 

80 There are components defined in the storage class that are not 

81 expected by this assembler. 

82 """ 

83 requested = set(self.storageClass.components.keys()) 

84 

85 # Check that we are requesting something that we support 

86 unknown = requested - (self.EXPOSURE_COMPONENTS | self.EXPOSURE_INFO_COMPONENTS) 

87 if unknown: 

88 raise ValueError(f"Asking for unrecognized component: {unknown}") 

89 

90 expItems = requested & self.EXPOSURE_COMPONENTS 

91 expInfoItems = requested & self.EXPOSURE_INFO_COMPONENTS 

92 return expItems, expInfoItems 

93 

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

95 """Get a component from an Exposure. 

96 

97 Parameters 

98 ---------- 

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

100 `Exposure` to access component. 

101 componentName : `str` 

102 Name of component to retrieve. 

103 

104 Returns 

105 ------- 

106 component : `object` 

107 The component. Can be None. 

108 

109 Raises 

110 ------ 

111 AttributeError 

112 The component can not be found. 

113 """ 

114 if componentName in self.EXPOSURE_COMPONENTS or componentName in self.EXPOSURE_READ_COMPONENTS: 

115 # Use getter translation if relevant or the name itself 

116 return super().getComponent(composite, self.COMPONENT_MAP.get(componentName, componentName)) 

117 elif componentName in self.EXPOSURE_INFO_COMPONENTS: 

118 if hasattr(composite, "getInfo"): 

119 # it is possible for this method to be called with 

120 # an ExposureInfo composite so trap for that and only get 

121 # the ExposureInfo if the method is supported 

122 composite = composite.getInfo() 

123 return super().getComponent(composite, self.COMPONENT_MAP.get(componentName, componentName)) 

124 else: 

125 raise AttributeError( 

126 f"Do not know how to retrieve component {componentName} from {type(composite)}" 

127 ) 

128 

129 def disassemble( 

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

131 ) -> dict[str, DatasetComponent]: 

132 """Disassemble an afw Exposure. 

133 

134 This implementation attempts to extract components from the parent 

135 by looking for attributes of the same name or getter methods derived 

136 from the component name. 

137 

138 Parameters 

139 ---------- 

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

141 `Exposure` composite object consisting of components to be 

142 extracted. 

143 subset : iterable, optional 

144 Not supported by this assembler. 

145 override : `object`, optional 

146 Not supported by this assembler. 

147 

148 Returns 

149 ------- 

150 components : `dict` 

151 `dict` with keys matching the components defined in 

152 `self.storageClass` and values being `DatasetComponent` instances 

153 describing the component. 

154 

155 Raises 

156 ------ 

157 ValueError 

158 A requested component can not be found in the parent using generic 

159 lookups. 

160 TypeError 

161 The parent object does not match the supplied `self.storageClass`. 

162 

163 Notes 

164 ----- 

165 If a PSF is present but is not persistable, the PSF will not be 

166 included in the returned components. 

167 """ 

168 if subset is not None: 

169 raise NotImplementedError( 

170 "ExposureAssembler does not support the 'subset' argument to disassemble." 

171 ) 

172 if override is not None: 

173 raise NotImplementedError( 

174 "ExposureAssembler does not support the 'override' argument to disassemble." 

175 ) 

176 if not self.storageClass.validateInstance(composite): 

177 raise TypeError( 

178 "Unexpected type mismatch between parent and StorageClass" 

179 f" ({type(composite)} != {self.storageClass.pytype})" 

180 ) 

181 

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

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

184 expItems, expInfoItems = self._groupRequestedComponents() 

185 

186 fromExposure = super().disassemble(composite, subset=expItems) 

187 assert fromExposure is not None, "Base class implementation guarantees this, but ABC does not." 

188 components.update(fromExposure) 

189 

190 fromExposureInfo = super().disassemble(composite, subset=expInfoItems, override=composite.getInfo()) 

191 assert fromExposureInfo is not None, "Base class implementation guarantees this, but ABC does not." 

192 components.update(fromExposureInfo) 

193 

194 if "psf" in components and not components["psf"].component.isPersistable(): 

195 log.warning( 

196 "PSF of type %s is not persistable and has been ignored.", 

197 type(components["psf"].component).__name__, 

198 ) 

199 del components["psf"] 

200 

201 return components 

202 

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

204 """Construct an Exposure from components. 

205 

206 Parameters 

207 ---------- 

208 components : `dict` 

209 All the components from which to construct the Exposure. 

210 Some can be missing. 

211 pytype : `type`, optional 

212 Not supported by this assembler. 

213 

214 Returns 

215 ------- 

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

217 Assembled exposure. 

218 

219 Raises 

220 ------ 

221 ValueError 

222 Some supplied components are not recognized. 

223 """ 

224 if pytype is not None: 

225 raise NotImplementedError("ExposureAssembler does not support the 'pytype' argument to assemble.") 

226 components = components.copy() 

227 maskedImageComponents = {} 

228 hasMaskedImage = False 

229 for component in ("image", "variance", "mask"): 

230 value = None 

231 if component in components: 

232 hasMaskedImage = True 

233 value = components.pop(component) 

234 maskedImageComponents[component] = value 

235 

236 wcs = None 

237 if "wcs" in components: 

238 wcs = components.pop("wcs") 

239 

240 pytype = self.storageClass.pytype 

241 if hasMaskedImage: 

242 maskedImage = makeMaskedImage(**maskedImageComponents) 

243 exposure = makeExposure(maskedImage, wcs=wcs) 

244 

245 if not isinstance(exposure, pytype): 

246 raise RuntimeError( 

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

248 ) 

249 

250 else: 

251 exposure = pytype() 

252 if wcs is not None: 

253 exposure.setWcs(wcs) 

254 

255 # Set other components 

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

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

258 

259 info = exposure.getInfo() 

260 if "visitInfo" in components: 

261 info.setVisitInfo(components.pop("visitInfo")) 

262 if "id" in components: 

263 info.id = components.pop("id") 

264 info.setApCorrMap(components.pop("apCorrMap", None)) 

265 info.setCoaddInputs(components.pop("coaddInputs", None)) 

266 info.setMetadata(components.pop("metadata", None)) 

267 info.setValidPolygon(components.pop("validPolygon", None)) 

268 info.setDetector(components.pop("detector", None)) 

269 info.setTransmissionCurve(components.pop("transmissionCurve", None)) 

270 info.setSummaryStats(components.pop("summaryStats", None)) 

271 

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

273 

274 # If we have some components left over that is a problem 

275 if components: 

276 raise ValueError(f"The following components were not understood: {list(components.keys())}") 

277 

278 return exposure 

279 

280 def handleParameters(self, inMemoryDataset: Any, parameters: Mapping[str, Any] | None = None) -> Any: 

281 """Modify the in-memory dataset using the supplied parameters, 

282 returning a possibly new object. 

283 

284 Parameters 

285 ---------- 

286 inMemoryDataset : `object` 

287 Object to modify based on the parameters. 

288 parameters : `dict`, optional 

289 Parameters to apply. Values are specific to the parameter. 

290 Supported parameters are defined in the associated 

291 `~lsst.daf.butler.StorageClass`. If no relevant parameters are 

292 specified the ``inMemoryDataset`` will be return unchanged. 

293 

294 Returns 

295 ------- 

296 inMemoryDataset : `object` 

297 Updated form of supplied in-memory dataset, after parameters 

298 have been used. 

299 """ 

300 if parameters is None: 

301 return inMemoryDataset 

302 # Understood by *this* subset command 

303 understood = ("bbox", "origin") 

304 use = self.storageClass.filterParameters(parameters, subset=understood) 

305 if use: 

306 inMemoryDataset = inMemoryDataset.subset(**use) 

307 

308 return inMemoryDataset 

309 

310 @classmethod 

311 def selectResponsibleComponent(cls, readComponent: str, fromComponents: set[str | None]) -> str: 

312 # Docstring inherited. 

313 imageComponents = ["mask", "image", "variance"] 

314 forwarderMap = { 

315 "bbox": imageComponents, 

316 "dimensions": imageComponents, 

317 "xy0": imageComponents, 

318 } 

319 forwarder = forwarderMap.get(readComponent) 

320 if forwarder is not None: 

321 for c in forwarder: 

322 if c in fromComponents: 

323 return c 

324 raise ValueError(f"Can not calculate read component {readComponent} from {fromComponents}") 

325 

326 def add_provenance( 

327 self, inMemoryDataset: Any, ref: DatasetRef, provenance: DatasetProvenance | None = None 

328 ) -> Any: 

329 # Add provenance via FITS headers. This delegate is reused by 

330 # MaskedImage as well as Exposure so no guarantee that metadata 

331 # is present. 

332 with contextlib.suppress(AttributeError): 

333 add_provenance_to_fits_header(inMemoryDataset.metadata, ref, provenance) 

334 return inMemoryDataset