Coverage for python / lsst / obs / base / formatters / fitsExposure.py: 17%

335 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-15 06:36 -0300

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__all__ = ( 

23 "FitsExposureFormatter", 

24 "FitsImageFormatter", 

25 "FitsMaskFormatter", 

26 "FitsMaskedImageFormatter", 

27 "standardizeAmplifierParameters", 

28) 

29 

30import hashlib 

31import json 

32import logging 

33import threading 

34import uuid 

35import warnings 

36from abc import abstractmethod 

37from collections.abc import Set 

38from io import BytesIO 

39from typing import Any, ClassVar, NamedTuple 

40 

41import astropy.io.fits 

42import numpy as np 

43 

44import lsst.geom 

45from lsst.afw.cameraGeom import AmplifierGeometryComparison, AmplifierIsolator 

46from lsst.afw.fits import CompressionOptions, MemFileManager 

47from lsst.afw.geom.wcsUtils import getImageXY0FromMetadata 

48from lsst.afw.image import ( 

49 ExposureFitsReader, 

50 ExposureInfo, 

51 FilterLabel, 

52 ImageFitsReader, 

53 MaskedImageFitsReader, 

54 MaskFitsReader, 

55) 

56 

57# Needed for ApCorrMap to resolve properly 

58from lsst.afw.math import BoundedField # noqa: F401 

59from lsst.daf.base import PropertyList 

60from lsst.daf.butler import DatasetProvenance, FormatterV2 

61from lsst.resources import ResourcePath 

62from lsst.utils.classes import cached_getter 

63from lsst.utils.introspection import find_outside_stacklevel 

64 

65from ..utils import add_provenance_to_fits_header 

66 

67_LOG = logging.getLogger(__name__) 

68 

69_ALWAYS_USE_ASTROPY_FOR_COMPONENT_READ = False 

70"""If True, the astropy code will always be used to read component and cutouts 

71even if the file is local, the cutout is too large, or the dataset type is 

72wrong. This should mostly be used for testing. 

73""" 

74 

75 

76class FitsImageFormatterBase(FormatterV2): 

77 """Base class formatter for image-like storage classes stored via FITS. 

78 

79 Notes 

80 ----- 

81 This class makes no assumptions about how many HDUs are used to represent 

82 the image on disk, and includes no support for writing. It's really just a 

83 collection of miscellaneous boilerplate common to all FITS image 

84 formatters. 

85 

86 Concrete subclasses must implement `readComponent`, `readFull`, and `write` 

87 (even if just to disable them by raising an exception). 

88 """ 

89 

90 can_read_from_local_file = True 

91 default_extension = ".fits" 

92 supported_extensions: ClassVar[Set[str]] = frozenset({".fits", ".fits.gz", ".fits.fz", ".fz", ".fit"}) 

93 

94 unsupported_parameters: ClassVar[Set[str]] = frozenset() 

95 """Support all parameters.""" 

96 

97 _reader = None 

98 _reader_path: str | None = None 

99 

100 ReaderClass: type # must be set by concrete subclasses 

101 

102 @property 

103 def reader(self): 

104 """The reader object that backs this formatter's read operations. 

105 

106 This is computed on first use and then cached. It should never be 

107 accessed when writing. Currently assumes a local file. 

108 """ 

109 if self._reader is None: 

110 self._reader = self.ReaderClass(self._reader_path) 

111 return self._reader 

112 

113 @property 

114 @cached_getter 

115 def checked_parameters(self): 

116 """The parameters passed by the butler user, after checking them 

117 against the storage class and transforming `None` into an empty `dict` 

118 (`dict`). 

119 

120 This is computed on first use and then cached. It should never be 

121 accessed when writing. Subclasses that need additional checking should 

122 delegate to `super` and then check the result before returning it. 

123 """ 

124 parameters = self.file_descriptor.parameters 

125 if parameters is None: 

126 parameters = {} 

127 self.file_descriptor.storageClass.validateParameters(parameters) 

128 return parameters 

129 

130 @property 

131 def storageClass_dtype(self) -> np.dtype | None: 

132 """The numpy data type associated with the storage class.""" 

133 dtype: np.dtype | None = None 

134 try: 

135 # lsst.afw.image.Exposure is generic base class and does not have 

136 # the dtype attribute. 

137 dtype = np.dtype(self.file_descriptor.storageClass.pytype.dtype) # type: ignore[attr-defined] 

138 except AttributeError: 

139 pass 

140 return dtype 

141 

142 def read_from_local_file(self, path: str, component: str | None = None, expected_size: int = -1) -> Any: 

143 # Docstring inherited. 

144 # The methods doing the reading all currently assume local file 

145 # and assume that the file descriptor refers to a local file. 

146 # With FormatterV2 that file descriptor does not refer to a local 

147 # file. 

148 self._reader_path = path 

149 self._reader = None # Ensure the reader class is reset. 

150 try: 

151 if component is not None: 

152 in_memory_dataset = self.readComponent(component) 

153 else: 

154 in_memory_dataset = self.readFull() 

155 finally: 

156 self._reader = None # Release the file handle. 

157 return in_memory_dataset 

158 

159 @abstractmethod 

160 def readComponent(self, component): 

161 """Read a component dataset. 

162 

163 Parameters 

164 ---------- 

165 component : `str`, optional 

166 Component to read from the file. 

167 

168 Returns 

169 ------- 

170 obj : component-dependent 

171 In-memory component object. 

172 

173 Raises 

174 ------ 

175 KeyError 

176 Raised if the requested component cannot be handled. 

177 """ 

178 raise NotImplementedError() 

179 

180 @abstractmethod 

181 def readFull(self): 

182 """Read the full dataset (while still accounting for parameters). 

183 

184 Returns 

185 ------- 

186 obj : component-dependent 

187 In-memory component object. 

188 

189 """ 

190 raise NotImplementedError() 

191 

192 

193class ReaderFitsImageFormatterBase(FitsImageFormatterBase): 

194 """Base class formatter for image-like storage classes stored via FITS 

195 backed by a "reader" object similar to `lsst.afw.image.ImageFitsReader`. 

196 

197 Notes 

198 ----- 

199 This class includes no support for writing. 

200 

201 Concrete subclasses must provide at least the `ReaderClass` attribute 

202 and a `write` implementation (even just to disable writing by raising). 

203 

204 The provided implementation of `readComponent` handles only the 'bbox', 

205 'dimensions', and 'xy0' components common to all image-like storage 

206 classes. Subclasses with additional components should handle them first, 

207 then delegate to ``super()`` for these (or, if necessary, delegate first 

208 and catch `KeyError`). 

209 

210 The provided implementation of `readFull` handles only parameters that 

211 can be forwarded directly to the reader class (usually ``bbox`` and 

212 ``origin``). Concrete subclasses that need to handle additional parameters 

213 should generally reimplement without delegating (the implementation is 

214 trivial). 

215 """ 

216 

217 

218class StandardFitsImageFormatterBase(ReaderFitsImageFormatterBase): 

219 """Base class interface for image-like storage stored via FITS, 

220 written using LSST code. 

221 

222 Notes 

223 ----- 

224 Concrete subclasses must provide at least the `ReaderClass` attribute. 

225 

226 The provided implementation of `readComponent` handles only the 'bbox', 

227 'dimensions', and 'xy0' components common to all image-like storage 

228 classes. Subclasses with additional components should handle them first, 

229 then delegate to ``super()`` for these (or, if necessary, delegate first 

230 and catch `KeyError`). 

231 

232 The provided implementation of `readFull` handles only parameters that 

233 can be forwarded directly to the reader class (usually ``bbox`` and 

234 ``origin``). Concrete subclasses that need to handle additional parameters 

235 should generally reimplement without delegating (the implementation is 

236 trivial). 

237 

238 This Formatter supports write recipes, and assumes its in-memory type has 

239 ``writeFits`` and (for write recipes) ``writeFitsWithOptions`` methods. 

240 

241 Each ``StandardFitsImageFormatterBase`` recipe for FITS compression should 

242 define ``image``, ``mask`` and ``variance`` entries, each of which may 

243 contain entries supported by 

244 `lsst.afw.fits.CompressionOptions.from_mapping` (``null`` disables 

245 compression). 

246 

247 A very simple example YAML recipe (for the ``Exposure`` specialization): 

248 

249 .. code-block:: yaml 

250 

251 lsst.obs.base.fitsExposureFormatter.FitsExposureFormatter: 

252 default: 

253 image: &default 

254 algorithm: GZIP_2 

255 mask: *default 

256 variance: *default 

257 

258 """ 

259 

260 supported_write_parameters = frozenset({"recipe"}) 

261 

262 def readComponent(self, component): 

263 # Docstring inherited. 

264 if component in ("bbox", "dimensions", "xy0"): 

265 bbox = self.reader.readBBox() 

266 if component == "dimensions": 

267 return bbox.getDimensions() 

268 elif component == "xy0": 

269 return bbox.getMin() 

270 else: 

271 return bbox 

272 else: 

273 raise KeyError(f"Unknown component requested: {component}") 

274 

275 def readFull(self): 

276 # Docstring inherited. 

277 return self.reader.read(**self.checked_parameters, dtype=self.storageClass_dtype) 

278 

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

280 # check to see if we have a recipe requested 

281 recipeName = self.write_parameters.get("recipe") 

282 recipe = self.get_image_compression_settings(recipeName) 

283 if recipe: 

284 in_memory_dataset.writeFitsWithOptions(uri.ospath, options=recipe) 

285 else: 

286 in_memory_dataset.writeFits(uri.ospath) 

287 

288 def get_image_compression_settings(self, recipeName): 

289 """Retrieve the relevant compression settings for this recipe. 

290 

291 Parameters 

292 ---------- 

293 recipeName : `str` 

294 Label associated with the collection of compression parameters 

295 to select. 

296 

297 Returns 

298 ------- 

299 settings : `dict` 

300 The selected settings. 

301 """ 

302 # if no recipe has been provided and there is no default 

303 # return immediately 

304 if not recipeName: 

305 if "default" not in self.write_recipes: 

306 return {} 

307 recipeName = "default" 

308 

309 if recipeName not in self.write_recipes: 

310 raise RuntimeError(f"Unrecognized recipe option given for compression: {recipeName}") 

311 

312 recipe = self.write_recipes[recipeName] 

313 if recipe is None: 

314 return 

315 seed: int | None = None 

316 for plane in ("image", "mask", "variance"): 

317 if plane in recipe and (quantization := recipe[plane].get("quantization")) is not None: 

318 if quantization.get("seed", 0) == 0: 

319 if seed is None: 

320 # Set the seed based on data ID. We can't just use 

321 # 'hash', since like 'set' that's not deterministic. 

322 # And we can't rely on a DimensionPacker because those 

323 # are only defined for certain combinations of 

324 # dimensions. Doing an MD5 of the JSON feels like 

325 # overkill but I don't really see anything much 

326 # simpler. 

327 hash_bytes = hashlib.md5( 

328 json.dumps(list(self.data_id.required_values)).encode(), 

329 usedforsecurity=False, 

330 ).digest() 

331 # And it *really* feels like overkill when we squash 

332 # that into the [1, 10000] range allowed by FITS. 

333 seed = 1 + int.from_bytes(hash_bytes) % 9999 

334 _LOG.debug( 

335 "Setting compression quantization seed for %s %s %s to %s.", 

336 self.data_id, 

337 self.dataset_ref.datasetType.name, 

338 plane, 

339 seed, 

340 ) 

341 quantization["seed"] = seed 

342 else: 

343 _LOG.warning( 

344 "Compression quantization seed for %s %s %s was set explicitly to %s.", 

345 self.dataset_ref.datasetType.name, 

346 self.data_id, 

347 plane, 

348 quantization["seed"], 

349 ) 

350 else: 

351 _LOG.debug( 

352 "No quantization found for %s %s %s.", 

353 self.dataset_ref.datasetType.name, 

354 self.data_id, 

355 plane, 

356 ) 

357 return recipe 

358 

359 @classmethod 

360 def validate_write_recipes(cls, recipes): 

361 """Validate supplied recipes for this formatter. 

362 

363 The recipes are supplemented with default values where appropriate. 

364 

365 Parameters 

366 ---------- 

367 recipes : `dict` 

368 Recipes to validate. Can be empty dict or `None`. 

369 

370 Returns 

371 ------- 

372 validated : `dict` 

373 Validated recipes. Returns what was given if there are no 

374 recipes listed. 

375 

376 Raises 

377 ------ 

378 RuntimeError 

379 Raised if validation fails. 

380 """ 

381 if not recipes: 

382 # We can not insist on recipes being specified. 

383 return recipes 

384 

385 validated = {} 

386 for name, recipe in recipes.items(): 

387 if recipe is not None: 

388 validated[name] = {} 

389 for plane in ["image", "mask", "variance"]: 

390 try: 

391 options = CompressionOptions.from_mapping(recipe[plane]) 

392 except Exception as err: 

393 err.add_note(f"Validating write recipe {name!r} ({plane!r} section).") 

394 raise 

395 validated[name][plane] = options.to_dict() 

396 else: 

397 validated[name] = None 

398 return validated 

399 

400 

401class FitsImageFormatter(StandardFitsImageFormatterBase): 

402 """Concrete formatter for reading/writing `~lsst.afw.image.Image` 

403 from/to FITS. 

404 """ 

405 

406 ReaderClass = ImageFitsReader 

407 

408 

409class FitsMaskFormatter(StandardFitsImageFormatterBase): 

410 """Concrete formatter for reading/writing `~lsst.afw.image.Mask` 

411 from/to FITS. 

412 """ 

413 

414 ReaderClass = MaskFitsReader 

415 

416 

417class FitsMaskedImageFormatter(StandardFitsImageFormatterBase): 

418 """Concrete formatter for reading/writing `~lsst.afw.image.MaskedImage` 

419 from/to FITS. 

420 """ 

421 

422 ReaderClass = MaskedImageFitsReader 

423 

424 def readComponent(self, component): 

425 # Docstring inherited. 

426 if component == "image": 

427 return self.reader.readImage(**self.checked_parameters, dtype=self.storageClass_dtype) 

428 elif component == "mask": 

429 return self.reader.readMask(**self.checked_parameters) 

430 elif component == "variance": 

431 return self.reader.readVariance(**self.checked_parameters, dtype=self.storageClass_dtype) 

432 else: 

433 # Delegate to base for bbox, dimensions, xy0. 

434 return super().readComponent(component) 

435 

436 

437def standardizeAmplifierParameters(parameters, on_disk_detector): 

438 """Preprocess the Exposure storage class's "amp" and "detector" parameters. 

439 

440 This checks the given objects for consistency with the on-disk geometry and 

441 converts amplifier IDs/names to Amplifier instances. 

442 

443 Parameters 

444 ---------- 

445 parameters : `dict` 

446 Dictionary of parameters passed to formatter. See the Exposure storage 

447 class definition in daf_butler for allowed keys and values. 

448 on_disk_detector : `lsst.afw.cameraGeom.Detector` or `None` 

449 Detector that represents the on-disk image being loaded, or `None` if 

450 this is unknown (and hence the user must provide one in 

451 ``parameters`` if "amp" is in ``parameters``). 

452 

453 Returns 

454 ------- 

455 amplifier : `lsst.afw.cameraGeom.Amplifier` or `None` 

456 An amplifier object that defines a subimage to load, or `None` if there 

457 was no "amp" parameter. 

458 detector : `lsst.afw.cameraGeom.Detector` or `None` 

459 A detector object whose amplifiers are in the same s/orientation 

460 state as the on-disk image. If there is no "amp" parameter, 

461 ``on_disk_detector`` is simply passed through. 

462 regions_differ : `bool` 

463 `True` if the on-disk detector and the detector given in the parameters 

464 had different bounding boxes for one or more regions. This can happen 

465 if the true overscan region sizes can only be determined when the image 

466 is actually read, but otherwise it should be considered user error. 

467 """ 

468 if (amplifier := parameters.get("amp")) is None: 

469 return None, on_disk_detector, False 

470 if "bbox" in parameters or "origin" in parameters: 

471 raise ValueError("Cannot pass 'amp' with 'bbox' or 'origin'.") 

472 if isinstance(amplifier, int | str): 

473 amp_key = amplifier 

474 target_amplifier = None 

475 else: 

476 amp_key = amplifier.getName() 

477 target_amplifier = amplifier 

478 if (detector := parameters.get("detector")) is not None: 

479 if on_disk_detector is not None: 

480 # User passed a detector and we also found one on disk. Check them 

481 # for consistency. Note that we are checking the amps we'd get 

482 # from the two detectors against each other, not the amplifier we 

483 # got directly from the user, as the latter is allowed to differ in 

484 # assembly/orientation state. 

485 comparison = on_disk_detector[amp_key].compareGeometry(detector[amp_key]) 

486 if comparison & comparison.ASSEMBLY_DIFFERS: 

487 raise ValueError( 

488 "The given 'detector' has a different assembly state and/or orientation from " 

489 f"the on-disk one for amp {amp_key}." 

490 ) 

491 else: 

492 if on_disk_detector is None: 

493 raise ValueError( 

494 f"No on-disk detector and no detector given; cannot load amplifier from key {amp_key}. " 

495 "Please provide either a 'detector' parameter or an Amplifier instance in the " 

496 "'amp' parameter." 

497 ) 

498 comparison = AmplifierGeometryComparison.EQUAL 

499 detector = on_disk_detector 

500 if target_amplifier is None: 

501 target_amplifier = detector[amp_key] 

502 return target_amplifier, detector, comparison & comparison.REGIONS_DIFFER 

503 

504 

505class _ComponentCache(NamedTuple): 

506 id_: uuid.UUID | None = None 

507 reader: ExposureFitsReader | None = None 

508 bbox: lsst.geom.Box2I | None = None 

509 mem: MemFileManager | None = None 

510 

511 

512class FitsExposureFormatter(FitsMaskedImageFormatter): 

513 """Concrete formatter for reading/writing `~lsst.afw.image.Exposure` 

514 from/to FITS. 

515 

516 Notes 

517 ----- 

518 This class inherits from `FitsMaskedImageFormatter` even though 

519 `lsst.afw.image.Exposure` doesn't inherit from 

520 `lsst.afw.image.MaskedImage`; this is just an easy way to be able to 

521 delegate to `FitsMaskedImageFormatter.super()` for component-handling, and 

522 should be replaced with e.g. both calling a free function if that slight 

523 type covariance violation ever becomes a practical problem. 

524 """ 

525 

526 can_read_from_uri = True 

527 ReaderClass = ExposureFitsReader 

528 # TODO: Remove MemFileManager from cache when DM-49640 is fixed. 

529 _lock = threading.Lock() 

530 _cached_fits: _ComponentCache = _ComponentCache() 

531 

532 def read_from_uri(self, uri: ResourcePath, component: str | None = None, expected_size: int = -1) -> Any: 

533 # For now only support small non-pixel components. In future 

534 # could work with cutouts. 

535 self._reader = None # Guarantee things are reset. 

536 

537 # Full read, always use local file read. 

538 if not component and not self.checked_parameters: 

539 return NotImplemented 

540 

541 if not _ALWAYS_USE_ASTROPY_FOR_COMPONENT_READ and uri.isLocal: 

542 # For a local URI allow afw to read it directly. 

543 return NotImplemented 

544 pixel_components = ("mask", "image", "variance") 

545 

546 if component in pixel_components: 

547 # For pixel access currently this can not be cached in memory 

548 # and the performance gains are unclear. Assume local file 

549 # read with file caching for now. 

550 return NotImplemented 

551 

552 # With current file layouts the non-pixel extensions account for 1/3 

553 # of the file size and it is more efficient to download the entire 

554 # file. 

555 if not ( 

556 _ALWAYS_USE_ASTROPY_FOR_COMPONENT_READ 

557 or self._dataset_ref.dataId.mapping.keys().isdisjoint({"tract", "patch"}) 

558 ): 

559 return NotImplemented 

560 

561 # Cutouts can be optimized. For now only use this optimization 

562 # if bbox is the only parameter and the number of pixels in the 

563 # bounding box is reasonable. 

564 bbox = None 

565 origin = lsst.afw.image.PARENT 

566 if not component: 

567 # Try to support PARENT and LOCAL origin but if there are any 

568 # other parameters do not attempt a cutout. 

569 if self.checked_parameters.keys() - {"bbox", "origin"}: 

570 return NotImplemented 

571 bbox = self.checked_parameters["bbox"] 

572 origin = self.checked_parameters.get("origin", lsst.afw.image.PARENT) 

573 # For larger cutouts use the full file. 

574 max_cutout_size = 500 * 500 

575 if not _ALWAYS_USE_ASTROPY_FOR_COMPONENT_READ and bbox.width * bbox.height > max_cutout_size: 

576 return NotImplemented 

577 

578 # We only cache component reads since those are small. 

579 if component: 

580 with self._lock: 

581 cache = type(self)._cached_fits 

582 if self.dataset_ref.id == cache.id_: 

583 if component in {"xy0", "dimensions", "bbox"} and cache.bbox is not None: 

584 match component: 

585 case "xy0": 

586 return cache.bbox.getMin() 

587 case "dimensions": 

588 return cache.bbox.getDimensions() 

589 case "bbox": 

590 return cache.bbox 

591 else: 

592 self._reader = cache.reader 

593 return self.readComponent(component) 

594 

595 try: 

596 fs, fspath = uri.to_fsspec() 

597 except Exception: 

598 # fsspec cannot be initialized, fall back to downloading the file. 

599 return NotImplemented 

600 

601 bbox_component = None 

602 try: 

603 hdul = [] 

604 with fs.open(fspath) as f, astropy.io.fits.open(f) as fits_obj: 

605 # Read all non-pixel components and cache. 

606 for hdu in fits_obj: 

607 hdr = hdu.header 

608 extname = hdr.get("EXTNAME") 

609 # Older files have IMAGE in EXTNAME in PRIMARY so check 

610 # for EXTEND=T. 

611 extend = hdr.get("EXTEND") 

612 if not extend and extname and extname.lower() in pixel_components: 

613 # Calculate the dimensional components for later 

614 # caching. Do not derive from cached FITS reader 

615 # because they depend on the dimensionality of the 

616 # pixel data and we do not want to cache the pixel 

617 # data. 

618 if bbox_component is None: 

619 shape = hdu.shape 

620 dimensions = lsst.geom.Extent2I(shape[1], shape[0]) 

621 

622 # XY0 is defined in the A WCS. 

623 pl = PropertyList() 

624 pl.update(hdr) 

625 xy0 = getImageXY0FromMetadata(pl, "A", strip=False) 

626 

627 # This is the PARENT bbox. 

628 bbox_component = lsst.geom.Box2I(xy0, dimensions) 

629 

630 # Handle cutout request. 

631 if bbox: 

632 if origin == lsst.afw.image.PARENT: 

633 full_bbox = bbox_component 

634 else: 

635 full_bbox = lsst.geom.Box2I( 

636 lsst.geom.Point2I(0, 0), bbox_component.getDimensions 

637 ) 

638 minX = bbox.getBeginX() - full_bbox.getBeginX() 

639 maxX = bbox.getEndX() - full_bbox.getBeginX() 

640 minY = bbox.getBeginY() - full_bbox.getBeginY() 

641 maxY = bbox.getEndY() - full_bbox.getBeginY() 

642 data = hdu.section[minY:maxY, minX:maxX] 

643 

644 # Must correct the header WCS to take into 

645 # account the offset. 

646 if (k := "CRPIX1") in hdr: 

647 hdr[k] -= minX 

648 if (k := "CRPIX2") in hdr: 

649 hdr[k] -= minY 

650 if (k := "LTV1") in hdr: 

651 hdr[k] = -bbox.getBeginX() 

652 if (k := "LTV2") in hdr: 

653 hdr[k] = -bbox.getBeginY() 

654 if (k := "CRVAL1A") in hdr: 

655 hdr[k] = bbox.getBeginX() 

656 if (k := "CRVAL2A") in hdr: 

657 hdr[k] = bbox.getBeginY() 

658 else: 

659 data = np.zeros([1, 1], dtype=np.int32) 

660 

661 # Construct a new HDU and copy the header. 

662 stripped_hdu = astropy.io.fits.ImageHDU(data=data, header=hdr) 

663 hdul.append(stripped_hdu) 

664 else: 

665 hdul.append(hdu) 

666 stripped_fits = astropy.io.fits.HDUList(hdus=hdul) 

667 # Write the FITS file to in-memory FITS. 

668 buffer = BytesIO() 

669 stripped_fits.writeto(buffer) 

670 except Exception as e: 

671 # For some reason we can't open the remote file so fall back. 

672 _LOG.debug( 

673 "Attempted remote read of components but encountered an error. " 

674 "Falling back to file download. Error: %s", 

675 str(e), 

676 ) 

677 return NotImplemented 

678 

679 # Pass the new FITS buffer to the reader class without going through 

680 # a temporary file. We can assume this is relatively small for 

681 # components. 

682 fits_data = buffer.getvalue() 

683 mem = MemFileManager(len(fits_data)) 

684 mem.setData(fits_data, len(fits_data)) 

685 self._reader = self.ReaderClass(mem) 

686 

687 if component: 

688 with self._lock: 

689 type(self)._cached_fits = _ComponentCache( 

690 id_=self.dataset_ref.id, 

691 reader=self._reader, 

692 mem=mem, 

693 bbox=bbox_component, 

694 ) 

695 match component: 

696 case "xy0": 

697 if bbox_component is None: # For mypy. 

698 return None 

699 return bbox_component.getMin() 

700 case "dimensions": 

701 if bbox_component is None: 

702 return None 

703 return bbox_component.getDimensions() 

704 case "bbox": 

705 return bbox_component 

706 case _: 

707 return self.readComponent(component) 

708 else: 

709 # Must be a cutout. We have applied the bbox parameter so no 

710 # parameters should be passed here. 

711 cutout = self.reader.read(dtype=self.storageClass_dtype) 

712 cutout.getInfo().setFilter(self._fixFilterLabels(cutout.getInfo().getFilter())) 

713 return cutout 

714 

715 def add_provenance( 

716 self, in_memory_dataset: Any, /, *, provenance: DatasetProvenance | None = None 

717 ) -> Any: 

718 # Add provenance via FITS headers. 

719 add_provenance_to_fits_header(in_memory_dataset.metadata, self.dataset_ref, provenance) 

720 return in_memory_dataset 

721 

722 def readComponent(self, component): 

723 # Docstring inherited. 

724 # Generic components can be read via a string name; DM-27754 will make 

725 # this mapping larger at the expense of the following one. 

726 genericComponents = { 

727 "summaryStats": ExposureInfo.KEY_SUMMARY_STATS, 

728 } 

729 if (genericComponentName := genericComponents.get(component)) is not None: 

730 return self.reader.readComponent(genericComponentName) 

731 # Other components have hard-coded method names, but don't take 

732 # parameters. 

733 standardComponents = { 

734 "id": "readExposureId", 

735 "metadata": "readMetadata", 

736 "wcs": "readWcs", 

737 "coaddInputs": "readCoaddInputs", 

738 "psf": "readPsf", 

739 "photoCalib": "readPhotoCalib", 

740 "filter": "readFilter", 

741 "validPolygon": "readValidPolygon", 

742 "apCorrMap": "readApCorrMap", 

743 "visitInfo": "readVisitInfo", 

744 "transmissionCurve": "readTransmissionCurve", 

745 "detector": "readDetector", 

746 "exposureInfo": "readExposureInfo", 

747 } 

748 if (methodName := standardComponents.get(component)) is not None: 

749 result = getattr(self.reader, methodName)() 

750 if component == "filter": 

751 return self._fixFilterLabels(result) 

752 return result 

753 # Delegate to MaskedImage and ImageBase implementations for the rest. 

754 return super().readComponent(component) 

755 

756 def readFull(self): 

757 # Docstring inherited. 

758 amplifier, detector, _ = standardizeAmplifierParameters( 

759 self.checked_parameters, 

760 self.reader.readDetector(), 

761 ) 

762 if amplifier is not None: 

763 amplifier_isolator = AmplifierIsolator( 

764 amplifier, 

765 self.reader.readBBox(), 

766 detector, 

767 ) 

768 result = amplifier_isolator.transform_subimage( 

769 self.reader.read(bbox=amplifier_isolator.subimage_bbox, dtype=self.storageClass_dtype) 

770 ) 

771 result.setDetector(amplifier_isolator.make_detector()) 

772 else: 

773 result = self.reader.read(**self.checked_parameters, dtype=self.storageClass_dtype) 

774 result.getInfo().setFilter(self._fixFilterLabels(result.getInfo().getFilter())) 

775 return result 

776 

777 def _fixFilterLabels(self, file_filter_label, should_be_standardized=None): 

778 """Compare the filter label read from the file with the one in the 

779 data ID. 

780 

781 Parameters 

782 ---------- 

783 file_filter_label : `lsst.afw.image.FilterLabel` or `None` 

784 Filter label read from the file, if there was one. 

785 should_be_standardized : `bool`, optional 

786 If `True`, expect ``file_filter_label`` to be consistent with the 

787 data ID and warn only if it is not. If `False`, expect it to be 

788 inconsistent and warn only if the data ID is incomplete and hence 

789 the `FilterLabel` cannot be fixed. If `None` (default) guess 

790 whether the file should be standardized by looking at the 

791 serialization version number in file, which requires this method to 

792 have been run after `readFull` or `readComponent`. 

793 

794 Returns 

795 ------- 

796 filter_label : `lsst.afw.image.FilterLabel` or `None` 

797 The preferred filter label; may be the given one or one built from 

798 the data ID. `None` is returned if there should never be any 

799 filters associated with this dataset type. 

800 

801 Notes 

802 ----- 

803 Most test coverage for this method is in ci_hsc_gen3, where we have 

804 much easier access to test data that exhibits the problems it attempts 

805 to solve. 

806 """ 

807 # Remember filter data ID keys that weren't in this particular data ID, 

808 # so we can warn about them later. 

809 missing = [] 

810 band = None 

811 physical_filter = None 

812 if "band" in self.data_id.dimensions.names: 

813 band = self.data_id.get("band") 

814 # band isn't in the data ID; is that just because this data ID 

815 # hasn't been filled in with everything the Registry knows, or 

816 # because this dataset is never associated with a band? 

817 if band is None and not self.data_id.hasFull() and "band" in self.data_id.dimensions.implied: 

818 missing.append("band") 

819 if "physical_filter" in self.data_id.dimensions.names: 

820 physical_filter = self.data_id.get("physical_filter") 

821 # Same check as above for band, but for physical_filter. 

822 if ( 

823 physical_filter is None 

824 and not self.data_id.hasFull() 

825 and "physical_filter" in self.data_id.dimensions.implied 

826 ): 

827 missing.append("physical_filter") 

828 if should_be_standardized is None: 

829 version = self.reader.readSerializationVersion() 

830 should_be_standardized = version >= 2 

831 if missing: 

832 # Data ID identifies a filter but the actual filter label values 

833 # haven't been fetched from the database; we have no choice but 

834 # to use the one in the file. 

835 # Warn if that's more likely than not to be bad, because the file 

836 # predates filter standardization. 

837 if not should_be_standardized: 

838 warnings.warn( 

839 f"Data ID {self.data_id} is missing (implied) value(s) for {missing}; " 

840 "the correctness of this Exposure's FilterLabel cannot be guaranteed. " 

841 "Call Registry.expandDataId before Butler.get to avoid this.", 

842 # Report the warning from outside of middleware or the 

843 # relevant runQuantum method. 

844 stacklevel=find_outside_stacklevel( 

845 "lsst.obs.base", "lsst.pipe.base", "lsst.daf.butler", allow_methods={"runQuantum"} 

846 ), 

847 ) 

848 return file_filter_label 

849 if band is None and physical_filter is None: 

850 data_id_filter_label = None 

851 else: 

852 data_id_filter_label = FilterLabel(band=band, physical=physical_filter) 

853 if data_id_filter_label != file_filter_label and should_be_standardized: 

854 # File was written after FilterLabel and standardization, but its 

855 # FilterLabel doesn't agree with the data ID: this indicates a bug 

856 # in whatever code produced the Exposure (though it may be one that 

857 # has been fixed since the file was written). 

858 warnings.warn( 

859 f"Reading {self.file_descriptor.location} with data ID {self.data_id}: " 

860 f"filter label mismatch (file is {file_filter_label}, data ID is " 

861 f"{data_id_filter_label}). This is probably a bug in the code that produced it.", 

862 stacklevel=find_outside_stacklevel( 

863 "lsst.obs.base", "lsst.pipe.base", "lsst.daf.butler", allow_methods={"runQuantum"} 

864 ), 

865 ) 

866 return data_id_filter_label