Coverage for python/lsst/obs/base/_fitsRawFormatterBase.py: 37%

144 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-11-02 10:32 +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/>. 

21 

22__all__ = ("FitsRawFormatterBase",) 

23 

24import logging 

25from abc import abstractmethod 

26 

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 

33 

34from .formatters.fitsExposure import FitsImageFormatterBase, standardizeAmplifierParameters 

35from .makeRawVisitInfoViaObsInfo import MakeRawVisitInfoViaObsInfo 

36from .utils import InitialSkyWcsError, createInitialSkyWcsFromBoresight 

37 

38log = logging.getLogger(__name__) 

39 

40 

41class FitsRawFormatterBase(FitsImageFormatterBase): 

42 """Abstract base class for reading and writing raw data to and from 

43 FITS files. 

44 """ 

45 

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

49 

50 def __init__(self, *args, **kwargs): 

51 super().__init__(*args, **kwargs) 

52 self._metadata = None 

53 self._observationInfo = None 

54 

55 @classmethod 

56 def fromMetadata(cls, metadata, obsInfo=None, storageClass=None, location=None): 

57 """Construct a possibly-limited formatter from known metadata. 

58 

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. 

76 

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 

86 

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 

94 

95 @property 

96 @abstractmethod 

97 def filterDefinitions(self): 

98 """`~lsst.obs.base.FilterDefinitions`, defining the filters for this 

99 instrument. 

100 """ 

101 return None 

102 

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 

114 

115 def readImage(self): 

116 """Read just the image component of the Exposure. 

117 

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) 

124 

125 def isOnSky(self): 

126 """Boolean to determine if the exposure is thought to be on the sky. 

127 

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. 

134 

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 

144 

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 

154 

155 def readMetadata(self): 

156 """Read all header metadata directly into a PropertyList. 

157 

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 

166 

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

173 

174 def makeVisitInfo(self): 

175 """Construct a VisitInfo from metadata. 

176 

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) 

191 

192 return visit_info 

193 

194 @abstractmethod 

195 def getDetector(self, id): 

196 """Return the detector that acquired this raw exposure. 

197 

198 Parameters 

199 ---------- 

200 id : `int` 

201 The identifying number of the detector to get. 

202 

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

209 

210 def makeWcs(self, visitInfo, detector): 

211 """Create a SkyWcs from information about the exposure. 

212 

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

216 

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. 

224 

225 Returns 

226 ------- 

227 skyWcs : `~lsst.afw.geom.SkyWcs` 

228 Reversible mapping from pixel coordinates to sky coordinates. 

229 

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 

239 

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 

250 

251 return self.makeRawSkyWcsFromBoresight( 

252 visitInfo.getBoresightRaDec(), visitInfo.getBoresightRotAngle(), detector 

253 ) 

254 

255 @classmethod 

256 def makeRawSkyWcsFromBoresight(cls, boresight, orientation, detector): 

257 """Class method to make a raw sky WCS from boresight and detector. 

258 

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. 

267 

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) 

274 

275 def _createSkyWcsFromMetadata(self): 

276 """Create a SkyWcs from the FITS header metadata in an Exposure. 

277 

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 

287 

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 

293 

294 def makeFilterLabel(self): 

295 """Construct a FilterLabel from metadata. 

296 

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) 

305 

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 

325 

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 

349 

350 def write(self, inMemoryDataset): 

351 """Write a Python object to a file. 

352 

353 Parameters 

354 ---------- 

355 inMemoryDataset : `object` 

356 The Python object to store. 

357 

358 Returns 

359 ------- 

360 path : `str` 

361 The `URI` where the primary file is stored. 

362 """ 

363 raise NotImplementedError("Raw data cannot be `put`.") 

364 

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 

378 

379 def attachComponentsFromMetadata(self, exposure): 

380 """Attach all `lsst.afw.image.Exposure` components derived from 

381 metadata (including the stripped metadata itself). 

382 

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

394 

395 self.stripMetadata() 

396 exposure.setMetadata(self.metadata)