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

139 statements  

« prev     ^ index     » next       coverage.py v6.4.1, created at 2022-06-14 02:56 -0700

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 

25import warnings 

26from abc import abstractmethod 

27 

28import lsst.afw.fits 

29import lsst.afw.geom 

30import lsst.afw.image 

31from astro_metadata_translator import ObservationInfo, fix_header 

32from lsst.daf.butler import FileDescriptor 

33from lsst.utils.classes import cached_getter 

34 

35from .formatters.fitsExposure import FitsImageFormatterBase, standardizeAmplifierParameters 

36from .makeRawVisitInfoViaObsInfo import MakeRawVisitInfoViaObsInfo 

37from .utils import InitialSkyWcsError, createInitialSkyWcsFromBoresight 

38 

39log = logging.getLogger(__name__) 

40 

41 

42class FitsRawFormatterBase(FitsImageFormatterBase): 

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

44 FITS files. 

45 """ 

46 

47 # This has to be explicit until we fix camera geometry in DM-20746 

48 wcsFlipX = False 

49 """Control whether the WCS is flipped in the X-direction (`bool`)""" 

50 

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

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

53 self._metadata = None 

54 self._observationInfo = None 

55 

56 @classmethod 

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

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

59 

60 Parameters 

61 ---------- 

62 metadata : `lsst.daf.base.PropertyList` 

63 Raw header metadata, with any fixes (see 

64 `astro_metadata_translator.fix_header`) applied but nothing 

65 stripped. 

66 obsInfo : `astro_metadata_translator.ObservationInfo`, optional 

67 Structured information already extracted from ``metadata``. 

68 If not provided, will be read from ``metadata`` on first use. 

69 storageClass : `lsst.daf.butler.StorageClass`, optional 

70 StorageClass for this file. If not provided, the formatter will 

71 only support `makeWcs`, `makeVisitInfo`, `makeFilter`, and other 

72 operations that operate purely on metadata and not the actual file. 

73 location : `lsst.daf.butler.Location`, optional. 

74 Location of the file. If not provided, the formatter will only 

75 support `makeWcs`, `makeVisitInfo`, `makeFilter`, and other 

76 operations that operate purely on metadata and not the actual file. 

77 

78 Returns 

79 ------- 

80 formatter : `FitsRawFormatterBase` 

81 An instance of ``cls``. 

82 """ 

83 self = cls(FileDescriptor(location, storageClass)) 

84 self._metadata = metadata 

85 self._observationInfo = obsInfo 

86 return self 

87 

88 @property 

89 @abstractmethod 

90 def translatorClass(self): 

91 """`~astro_metadata_translator.MetadataTranslator` to translate 

92 metadata header to `~astro_metadata_translator.ObservationInfo`. 

93 """ 

94 return None 

95 

96 @property 

97 @abstractmethod 

98 def filterDefinitions(self): 

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

100 instrument. 

101 """ 

102 return None 

103 

104 @property # type: ignore 

105 @cached_getter 

106 def checked_parameters(self): 

107 # Docstring inherited. 

108 parameters = super().checked_parameters 

109 if "bbox" in parameters: 

110 raise TypeError( 

111 "Raw formatters do not support reading arbitrary subimages, as some " 

112 "implementations may be assembled on-the-fly." 

113 ) 

114 return parameters 

115 

116 def readImage(self): 

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

118 

119 Returns 

120 ------- 

121 image : `~lsst.afw.image.Image` 

122 In-memory image component. 

123 """ 

124 return lsst.afw.image.ImageU(self.fileDescriptor.location.path) 

125 

126 def isOnSky(self): 

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

128 

129 Returns 

130 ------- 

131 onSky : `bool` 

132 Returns `True` if the observation looks like it was taken on the 

133 sky. Returns `False` if this observation looks like a calibration 

134 observation. 

135 

136 Notes 

137 ----- 

138 If there is tracking RA/Dec information associated with the 

139 observation it is assumed that the observation is on sky. 

140 Currently the observation type is not checked. 

141 """ 

142 if self.observationInfo.tracking_radec is None: 

143 return False 

144 return True 

145 

146 @property 

147 def metadata(self): 

148 """The metadata read from this file. It will be stripped as 

149 components are extracted from it 

150 (`lsst.daf.base.PropertyList`). 

151 """ 

152 if self._metadata is None: 

153 self._metadata = self.readMetadata() 

154 return self._metadata 

155 

156 def readMetadata(self): 

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

158 

159 Returns 

160 ------- 

161 metadata : `~lsst.daf.base.PropertyList` 

162 Header metadata. 

163 """ 

164 md = lsst.afw.fits.readMetadata(self.fileDescriptor.location.path) 

165 fix_header(md) 

166 return md 

167 

168 def stripMetadata(self): 

169 """Remove metadata entries that are parsed into components.""" 

170 self._createSkyWcsFromMetadata() 

171 

172 def makeVisitInfo(self): 

173 """Construct a VisitInfo from metadata. 

174 

175 Returns 

176 ------- 

177 visitInfo : `~lsst.afw.image.VisitInfo` 

178 Structured metadata about the observation. 

179 """ 

180 return MakeRawVisitInfoViaObsInfo.observationInfo2visitInfo(self.observationInfo) 

181 

182 @abstractmethod 

183 def getDetector(self, id): 

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

185 

186 Parameters 

187 ---------- 

188 id : `int` 

189 The identifying number of the detector to get. 

190 

191 Returns 

192 ------- 

193 detector : `~lsst.afw.cameraGeom.Detector` 

194 The detector associated with that ``id``. 

195 """ 

196 raise NotImplementedError("Must be implemented by subclasses.") 

197 

198 def makeWcs(self, visitInfo, detector): 

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

200 

201 If VisitInfo is not None, use it and the detector to create a SkyWcs, 

202 otherwise return the metadata-based SkyWcs (always created, so that 

203 the relevant metadata keywords are stripped). 

204 

205 Parameters 

206 ---------- 

207 visitInfo : `~lsst.afw.image.VisitInfo` 

208 The information about the telescope boresight and camera 

209 orientation angle for this exposure. 

210 detector : `~lsst.afw.cameraGeom.Detector` 

211 The detector used to acquire this exposure. 

212 

213 Returns 

214 ------- 

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

216 Reversible mapping from pixel coordinates to sky coordinates. 

217 

218 Raises 

219 ------ 

220 InitialSkyWcsError 

221 Raised if there is an error generating the SkyWcs, chained from the 

222 lower-level exception if available. 

223 """ 

224 if not self.isOnSky(): 

225 # This is not an on-sky observation 

226 return None 

227 

228 skyWcs = self._createSkyWcsFromMetadata() 

229 

230 if visitInfo is None: 

231 msg = "No VisitInfo; cannot access boresight information. Defaulting to metadata-based SkyWcs." 

232 log.warning(msg) 

233 if skyWcs is None: 

234 raise InitialSkyWcsError( 

235 "Failed to create both metadata and boresight-based SkyWcs." 

236 "See warnings in log messages for details." 

237 ) 

238 return skyWcs 

239 

240 return self.makeRawSkyWcsFromBoresight( 

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

242 ) 

243 

244 @classmethod 

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

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

247 

248 Parameters 

249 ---------- 

250 boresight : `lsst.geom.SpherePoint` 

251 The ICRS boresight RA/Dec 

252 orientation : `lsst.geom.Angle` 

253 The rotation angle of the focal plane on the sky. 

254 detector : `lsst.afw.cameraGeom.Detector` 

255 Where to get the camera geomtry from. 

256 

257 Returns 

258 ------- 

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

260 Reversible mapping from pixel coordinates to sky coordinates. 

261 """ 

262 return createInitialSkyWcsFromBoresight(boresight, orientation, detector, flipX=cls.wcsFlipX) 

263 

264 def _createSkyWcsFromMetadata(self): 

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

266 

267 Returns 

268 ------- 

269 skyWcs: `lsst.afw.geom.SkyWcs`, or None 

270 The WCS that was created from ``self.metadata``, or None if that 

271 creation fails due to invalid metadata. 

272 """ 

273 if not self.isOnSky(): 

274 # This is not an on-sky observation 

275 return None 

276 

277 try: 

278 return lsst.afw.geom.makeSkyWcs(self.metadata, strip=True) 

279 except TypeError as e: 

280 log.warning("Cannot create a valid WCS from metadata: %s", e.args[0]) 

281 return None 

282 

283 def makeFilterLabel(self): 

284 """Construct a FilterLabel from metadata. 

285 

286 Returns 

287 ------- 

288 filter : `~lsst.afw.image.FilterLabel` 

289 Object that identifies the filter for this image. 

290 """ 

291 physical = self.observationInfo.physical_filter 

292 band = self.filterDefinitions.physical_to_band[physical] 

293 return lsst.afw.image.FilterLabel(physical=physical, band=band) 

294 

295 def readComponent(self, component): 

296 # Docstring inherited. 

297 self.checked_parameters # just for checking; no supported parameters. 

298 if component == "image": 

299 return self.readImage() 

300 elif component == "filter": 

301 return self.makeFilterLabel() 

302 # TODO: remove in DM-27811 

303 elif component == "filterLabel": 

304 warnings.warn( 

305 "Exposure.filterLabel component is deprecated; use .filter instead. " 

306 "Will be removed after v24.", 

307 FutureWarning, 

308 ) 

309 return self.makeFilterLabel() 

310 elif component == "visitInfo": 

311 return self.makeVisitInfo() 

312 elif component == "detector": 

313 return self.getDetector(self.observationInfo.detector_num) 

314 elif component == "wcs": 

315 detector = self.getDetector(self.observationInfo.detector_num) 

316 visitInfo = self.makeVisitInfo() 

317 return self.makeWcs(visitInfo, detector) 

318 elif component == "metadata": 

319 self.stripMetadata() 

320 return self.metadata 

321 return None 

322 

323 def readFull(self): 

324 # Docstring inherited. 

325 amplifier, detector, _ = standardizeAmplifierParameters( 

326 self.checked_parameters, 

327 self.getDetector(self.observationInfo.detector_num), 

328 ) 

329 if amplifier is not None: 

330 reader = lsst.afw.image.ImageFitsReader(self.fileDescriptor.location.path) 

331 amplifier_isolator = lsst.afw.cameraGeom.AmplifierIsolator( 

332 amplifier, 

333 reader.readBBox(), 

334 detector, 

335 ) 

336 subimage = amplifier_isolator.transform_subimage( 

337 reader.read(bbox=amplifier_isolator.subimage_bbox) 

338 ) 

339 exposure = lsst.afw.image.makeExposure(lsst.afw.image.makeMaskedImage(subimage)) 

340 exposure.setDetector(amplifier_isolator.make_detector()) 

341 else: 

342 exposure = lsst.afw.image.makeExposure(lsst.afw.image.makeMaskedImage(self.readImage())) 

343 exposure.setDetector(detector) 

344 self.attachComponentsFromMetadata(exposure) 

345 return exposure 

346 

347 def write(self, inMemoryDataset): 

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

349 

350 Parameters 

351 ---------- 

352 inMemoryDataset : `object` 

353 The Python object to store. 

354 

355 Returns 

356 ------- 

357 path : `str` 

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

359 """ 

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

361 

362 @property 

363 def observationInfo(self): 

364 """The `~astro_metadata_translator.ObservationInfo` extracted from 

365 this file's metadata (`~astro_metadata_translator.ObservationInfo`, 

366 read-only). 

367 """ 

368 if self._observationInfo is None: 

369 location = self.fileDescriptor.location 

370 path = location.path if location is not None else None 

371 self._observationInfo = ObservationInfo( 

372 self.metadata, translator_class=self.translatorClass, filename=path 

373 ) 

374 return self._observationInfo 

375 

376 def attachComponentsFromMetadata(self, exposure): 

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

378 metadata (including the stripped metadata itself). 

379 

380 Parameters 

381 ---------- 

382 exposure : `lsst.afw.image.Exposure` 

383 Exposure to attach components to (modified in place). Must already 

384 have a detector attached. 

385 """ 

386 info = exposure.getInfo() 

387 info.id = self.observationInfo.detector_exposure_id 

388 info.setFilter(self.makeFilterLabel()) 

389 info.setVisitInfo(self.makeVisitInfo()) 

390 info.setWcs(self.makeWcs(info.getVisitInfo(), info.getDetector())) 

391 # We don't need to call stripMetadata() here because it has already 

392 # been stripped during creation of the WCS. 

393 exposure.setMetadata(self.metadata)