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

151 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-31 01:09 -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# (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 

22from __future__ import annotations 

23 

24__all__ = ("FitsRawFormatterBase",) 

25 

26import logging 

27from abc import abstractmethod 

28from typing import TYPE_CHECKING, Any, ClassVar, Self 

29 

30from astro_metadata_translator import ObservationInfo, fix_header 

31 

32import lsst.afw.fits 

33import lsst.afw.geom 

34import lsst.afw.image 

35from lsst.daf.butler import FileDescriptor, FormatterNotImplementedError, Location, StorageClass 

36from lsst.resources import ResourcePath 

37from lsst.utils.classes import cached_getter 

38 

39from .formatters.fitsExposure import FitsImageFormatterBase, standardizeAmplifierParameters 

40from .makeRawVisitInfoViaObsInfo import MakeRawVisitInfoViaObsInfo 

41from .utils import InitialSkyWcsError, createInitialSkyWcsFromBoresight 

42 

43if TYPE_CHECKING: 

44 import astro_metadata_translator 

45 

46 import lsst.daf.base 

47 

48 from .filters import FilterDefinitionCollection 

49 

50log = logging.getLogger(__name__) 

51 

52 

53class FitsRawFormatterBase(FitsImageFormatterBase): 

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

55 FITS files. 

56 """ 

57 

58 ReaderClass = lsst.afw.image.ImageFitsReader 

59 

60 wcsFlipX: ClassVar[bool] = False 

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

62 """ 

63 

64 def __init__(self, *args: Any, **kwargs: Any) -> None: 

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

66 self._metadata: lsst.daf.base.PropertyList | None = None 

67 self._observationInfo: ObservationInfo | None = None 

68 

69 @classmethod 

70 def fromMetadata( 

71 cls, 

72 metadata: lsst.daf.base.PropertyList, 

73 obsInfo: ObservationInfo | None = None, 

74 storageClass: StorageClass | None = None, 

75 location: Location | None = None, 

76 ) -> Self: 

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

78 

79 Parameters 

80 ---------- 

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

82 Raw header metadata, with any fixes (see 

83 `astro_metadata_translator.fix_header`) applied but nothing 

84 stripped. 

85 obsInfo : `astro_metadata_translator.ObservationInfo`, optional 

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

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

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

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

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

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

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

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

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

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

96 

97 Returns 

98 ------- 

99 formatter : `FitsRawFormatterBase` 

100 An instance of ``cls``. 

101 """ 

102 self = cls( 

103 FileDescriptor( 

104 location if location is not None else Location("", "<unspecified>"), 

105 storageClass if storageClass is not None else StorageClass(), 

106 ) 

107 ) 

108 self._metadata = metadata 

109 self._observationInfo = obsInfo 

110 return self 

111 

112 @property 

113 @abstractmethod 

114 def translatorClass(self) -> type[astro_metadata_translator.MetadataTranslator] | None: 

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

116 metadata header to `~astro_metadata_translator.ObservationInfo`. 

117 """ 

118 return None 

119 

120 @property 

121 @abstractmethod 

122 def filterDefinitions(self) -> FilterDefinitionCollection | None: 

123 """`~lsst.obs.base.FilterDefinitionCollection`, defining the filters 

124 for this instrument. 

125 """ 

126 return None 

127 

128 @property 

129 @cached_getter 

130 def checked_parameters(self) -> dict[str, Any]: 

131 # Docstring inherited. 

132 parameters = super().checked_parameters 

133 if "bbox" in parameters: 

134 raise TypeError( 

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

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

137 ) 

138 return parameters 

139 

140 def readImage(self) -> lsst.afw.image.Image: 

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

142 

143 Returns 

144 ------- 

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

146 In-memory image component. 

147 """ 

148 return lsst.afw.image.ImageU(self._reader_path) 

149 

150 def isOnSky(self) -> bool: 

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

152 

153 Returns 

154 ------- 

155 onSky : `bool` 

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

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

158 observation. 

159 

160 Notes 

161 ----- 

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

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

164 Currently the observation type is not checked. 

165 """ 

166 if self.observationInfo.tracking_radec is None: 

167 return False 

168 return True 

169 

170 @property 

171 def metadata(self) -> lsst.daf.base.PropertyList: 

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

173 components are extracted from it 

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

175 """ 

176 if self._metadata is None: 

177 self._metadata = self.readMetadata() 

178 return self._metadata 

179 

180 def readMetadata(self) -> lsst.daf.base.PropertyList: 

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

182 

183 Returns 

184 ------- 

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

186 Header metadata. 

187 """ 

188 md = self.reader.readMetadata() 

189 translatorClass = self.translatorClass 

190 assert translatorClass is not None 

191 fix_header(md, translator_class=translatorClass) 

192 return md 

193 

194 def stripMetadata(self) -> None: 

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

196 try: 

197 lsst.afw.geom.stripWcsMetadata(self.metadata) 

198 except TypeError as e: 

199 log.debug("Error caught and ignored while stripping metadata: %s", e.args[0]) 

200 

201 def makeVisitInfo(self) -> lsst.afw.image.VisitInfo: 

202 """Construct a VisitInfo from metadata. 

203 

204 Returns 

205 ------- 

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

207 Structured metadata about the observation. 

208 """ 

209 visit_info = MakeRawVisitInfoViaObsInfo.observationInfo2visitInfo(self.observationInfo) 

210 if self.data_id and "exposure" in self.data_id: 

211 # Special case exposure existing for this dataset type. 

212 # Want to ensure that the id stored in the visitInfo matches that 

213 # from the dataId from butler, regardless of what may have come 

214 # from the ObservationInfo. In some edge cases they might differ. 

215 exposure_id = self.data_id["exposure"] 

216 if exposure_id != visit_info.id: 

217 visit_info = visit_info.copyWith(id=exposure_id) 

218 

219 return visit_info 

220 

221 @abstractmethod 

222 def getDetector(self, id: int) -> lsst.afw.cameraGeom.Detector: 

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

224 

225 Parameters 

226 ---------- 

227 id : `int` 

228 The identifying number of the detector to get. 

229 

230 Returns 

231 ------- 

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

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

234 """ 

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

236 

237 def makeWcs( 

238 self, visitInfo: lsst.afw.image.VisitInfo, detector: lsst.afw.cameraGeom.Detector 

239 ) -> lsst.afw.geom.SkyWcs: 

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

241 

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

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

244 the relevant metadata keywords are stripped). 

245 

246 Parameters 

247 ---------- 

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

249 The information about the telescope boresight and camera 

250 orientation angle for this exposure. 

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

252 The detector used to acquire this exposure. 

253 

254 Returns 

255 ------- 

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

257 Reversible mapping from pixel coordinates to sky coordinates. 

258 

259 Raises 

260 ------ 

261 InitialSkyWcsError 

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

263 lower-level exception if available. 

264 """ 

265 if not self.isOnSky(): 

266 # This is not an on-sky observation 

267 return None 

268 

269 if visitInfo is None: 

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

271 log.warning(msg) 

272 skyWcs = self._createSkyWcsFromMetadata() 

273 if skyWcs is None: 

274 raise InitialSkyWcsError( 

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

276 "See warnings in log messages for details." 

277 ) 

278 return skyWcs 

279 

280 return self.makeRawSkyWcsFromBoresight( 

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

282 ) 

283 

284 @classmethod 

285 def makeRawSkyWcsFromBoresight( 

286 cls, 

287 boresight: lsst.geom.SpherePoint, 

288 orientation: lsst.geom.Angle, 

289 detector: lsst.afw.cameraGeom.Detector, 

290 ) -> lsst.afw.geom.SkyWcs: 

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

292 

293 Parameters 

294 ---------- 

295 boresight : `lsst.geom.SpherePoint` 

296 The ICRS boresight RA/Dec 

297 orientation : `lsst.geom.Angle` 

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

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

300 Where to get the camera geometry from. 

301 

302 Returns 

303 ------- 

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

305 Reversible mapping from pixel coordinates to sky coordinates. 

306 """ 

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

308 

309 def _createSkyWcsFromMetadata(self) -> lsst.afw.geom.SkyWcs | None: 

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

311 

312 Returns 

313 ------- 

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

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

316 creation fails due to invalid metadata. 

317 """ 

318 if not self.isOnSky(): 

319 # This is not an on-sky observation 

320 return None 

321 

322 try: 

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

324 except TypeError as e: 

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

326 return None 

327 

328 def makeFilterLabel(self) -> lsst.afw.image.FilterLabel: 

329 """Construct a FilterLabel from metadata. 

330 

331 Returns 

332 ------- 

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

334 Object that identifies the filter for this image. 

335 """ 

336 physical = self.observationInfo.physical_filter 

337 filter_definitions = self.filterDefinitions 

338 assert filter_definitions is not None 

339 band = filter_definitions.physical_to_band[physical] 

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

341 

342 def readComponent(self, component: str) -> Any: 

343 # Docstring inherited. 

344 _ = self.checked_parameters # just for checking; no supported parameters. 

345 if component == "image": 

346 return self.readImage() 

347 elif component == "filter": 

348 return self.makeFilterLabel() 

349 elif component == "visitInfo": 

350 return self.makeVisitInfo() 

351 elif component == "detector": 

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

353 elif component == "wcs": 

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

355 visitInfo = self.makeVisitInfo() 

356 return self.makeWcs(visitInfo, detector) 

357 elif component == "metadata": 

358 self.stripMetadata() 

359 return self.metadata 

360 return None 

361 

362 def readFull(self) -> Any: 

363 # Docstring inherited. 

364 amplifier, detector, _ = standardizeAmplifierParameters( 

365 self.checked_parameters, 

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

367 ) 

368 if amplifier is not None: 

369 amplifier_isolator = lsst.afw.cameraGeom.AmplifierIsolator( 

370 amplifier, 

371 self.reader.readBBox(), 

372 detector, 

373 ) 

374 subimage = amplifier_isolator.transform_subimage( 

375 self.reader.read(bbox=amplifier_isolator.subimage_bbox) 

376 ) 

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

378 exposure.setDetector(amplifier_isolator.make_detector()) 

379 else: 

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

381 exposure.setDetector(detector) 

382 self.attachComponentsFromMetadata(exposure) 

383 return exposure 

384 

385 def write_local_file(self, in_memory_dataset: Any, uri: ResourcePath) -> None: 

386 raise FormatterNotImplementedError("Raw data cannot be `put`.") 

387 

388 @property 

389 def observationInfo(self) -> ObservationInfo: 

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

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

392 read-only). 

393 """ 

394 if self._observationInfo is None: 

395 # Use the primary path rather than any local variant that the 

396 # formatter might be using. 

397 location = self.file_descriptor.location 

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

399 self._observationInfo = ObservationInfo( 

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

401 ) 

402 return self._observationInfo 

403 

404 def attachComponentsFromMetadata(self, exposure: lsst.afw.image.Exposure) -> None: 

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

406 metadata (including the stripped metadata itself). 

407 

408 Parameters 

409 ---------- 

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

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

412 have a detector attached. 

413 """ 

414 info = exposure.getInfo() 

415 info.id = self.observationInfo.detector_exposure_id 

416 info.setFilter(self.makeFilterLabel()) 

417 info.setVisitInfo(self.makeVisitInfo()) 

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

419 

420 self.stripMetadata() 

421 exposure.setMetadata(self.metadata)