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

333 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-10-10 01:33 -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# (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 logging 

31import threading 

32import uuid 

33import warnings 

34from abc import abstractmethod 

35from collections.abc import Set 

36from io import BytesIO 

37from typing import Any, ClassVar, NamedTuple 

38 

39import astropy.io.fits 

40import numpy as np 

41 

42import lsst.geom 

43from lsst.afw.cameraGeom import AmplifierGeometryComparison, AmplifierIsolator 

44from lsst.afw.fits import MemFileManager 

45from lsst.afw.geom.wcsUtils import getImageXY0FromMetadata 

46from lsst.afw.image import ( 

47 ExposureFitsReader, 

48 ExposureInfo, 

49 FilterLabel, 

50 ImageFitsReader, 

51 MaskedImageFitsReader, 

52 MaskFitsReader, 

53) 

54 

55# Needed for ApCorrMap to resolve properly 

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

57from lsst.daf.base import PropertyList, PropertySet 

58from lsst.daf.butler import DatasetProvenance, FormatterV2 

59from lsst.resources import ResourcePath 

60from lsst.utils.classes import cached_getter 

61from lsst.utils.introspection import find_outside_stacklevel 

62 

63from ..utils import add_provenance_to_fits_header 

64 

65_LOG = logging.getLogger() 

66_ALWAYS_USE_ASTROPY_FOR_COMPONENT_READ = False 

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

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

69wrong. This should mostly be used for testing. 

70""" 

71 

72 

73class FitsImageFormatterBase(FormatterV2): 

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

75 

76 Notes 

77 ----- 

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

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

80 collection of miscellaneous boilerplate common to all FITS image 

81 formatters. 

82 

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

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

85 """ 

86 

87 can_read_from_local_file = True 

88 default_extension = ".fits" 

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

90 

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

92 """Support all parameters.""" 

93 

94 _reader = None 

95 _reader_path: str | None = None 

96 

97 ReaderClass: type # must be set by concrete subclasses 

98 

99 @property 

100 def reader(self): 

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

102 

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

104 accessed when writing. Currently assumes a local file. 

105 """ 

106 if self._reader is None: 

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

108 return self._reader 

109 

110 @property 

111 @cached_getter 

112 def checked_parameters(self): 

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

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

115 (`dict`). 

116 

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

118 accessed when writing. Subclasses that need additional checking should 

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

120 """ 

121 parameters = self.file_descriptor.parameters 

122 if parameters is None: 

123 parameters = {} 

124 self.file_descriptor.storageClass.validateParameters(parameters) 

125 return parameters 

126 

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

128 # Docstring inherited. 

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

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

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

132 # file. 

133 self._reader_path = path 

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

135 try: 

136 if component is not None: 

137 in_memory_dataset = self.readComponent(component) 

138 else: 

139 in_memory_dataset = self.readFull() 

140 finally: 

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

142 return in_memory_dataset 

143 

144 @abstractmethod 

145 def readComponent(self, component): 

146 """Read a component dataset. 

147 

148 Parameters 

149 ---------- 

150 component : `str`, optional 

151 Component to read from the file. 

152 

153 Returns 

154 ------- 

155 obj : component-dependent 

156 In-memory component object. 

157 

158 Raises 

159 ------ 

160 KeyError 

161 Raised if the requested component cannot be handled. 

162 """ 

163 raise NotImplementedError() 

164 

165 @abstractmethod 

166 def readFull(self): 

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

168 

169 Returns 

170 ------- 

171 obj : component-dependent 

172 In-memory component object. 

173 

174 """ 

175 raise NotImplementedError() 

176 

177 

178class ReaderFitsImageFormatterBase(FitsImageFormatterBase): 

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

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

181 

182 Notes 

183 ----- 

184 This class includes no support for writing. 

185 

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

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

188 

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

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

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

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

193 and catch `KeyError`). 

194 

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

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

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

198 should generally reimplement without delegating (the implementation is 

199 trivial). 

200 """ 

201 

202 

203class StandardFitsImageFormatterBase(ReaderFitsImageFormatterBase): 

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

205 written using LSST code. 

206 

207 Notes 

208 ----- 

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

210 

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

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

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

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

215 and catch `KeyError`). 

216 

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

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

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

220 should generally reimplement without delegating (the implementation is 

221 trivial). 

222 

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

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

225 

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

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

228 contain ``compression`` and ``scaling`` entries. Defaults will be 

229 provided for any missing elements under ``compression`` and 

230 ``scaling``. 

231 

232 The allowed entries under ``compression`` are: 

233 

234 * ``algorithm`` (`str`): compression algorithm to use 

235 * ``rows`` (`int`): number of rows per tile (0 = entire dimension) 

236 * ``columns`` (`int`): number of columns per tile (0 = entire dimension) 

237 * ``quantizeLevel`` (`float`): cfitsio quantization level 

238 

239 The allowed entries under ``scaling`` are: 

240 

241 * ``algorithm`` (`str`): scaling algorithm to use 

242 * ``bitpix`` (`int`): bits per pixel (0,8,16,32,64,-32,-64) 

243 * ``fuzz`` (`bool`): fuzz the values when quantising floating-point values? 

244 * ``seed`` (`int`): seed for random number generator when fuzzing 

245 * ``maskPlanes`` (`list` of `str`): mask planes to ignore when doing 

246 statistics 

247 * ``quantizeLevel`` (`float`): divisor of the standard deviation for 

248 ``STDEV_*`` scaling 

249 * ``quantizePad`` (`float`): number of stdev to allow on the low side (for 

250 ``STDEV_POSITIVE``/``NEGATIVE``) 

251 * ``bscale`` (`float`): manually specified ``BSCALE`` 

252 (for ``MANUAL`` scaling) 

253 * ``bzero`` (`float`): manually specified ``BSCALE`` 

254 (for ``MANUAL`` scaling) 

255 

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

257 

258 .. code-block:: yaml 

259 

260 lsst.obs.base.fitsExposureFormatter.FitsExposureFormatter: 

261 default: 

262 image: &default 

263 compression: 

264 algorithm: GZIP_SHUFFLE 

265 mask: *default 

266 variance: *default 

267 

268 """ 

269 

270 supported_write_parameters = frozenset({"recipe"}) 

271 

272 def readComponent(self, component): 

273 # Docstring inherited. 

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

275 bbox = self.reader.readBBox() 

276 if component == "dimensions": 

277 return bbox.getDimensions() 

278 elif component == "xy0": 

279 return bbox.getMin() 

280 else: 

281 return bbox 

282 else: 

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

284 

285 def readFull(self): 

286 # Docstring inherited. 

287 return self.reader.read(**self.checked_parameters) 

288 

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

290 # check to see if we have a recipe requested 

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

292 recipe = self.get_image_compression_settings(recipeName) 

293 if recipe: 

294 # Can not construct a PropertySet from a hierarchical 

295 # dict but can update one. 

296 ps = PropertySet() 

297 ps.update(recipe) 

298 in_memory_dataset.writeFitsWithOptions(uri.ospath, options=ps) 

299 else: 

300 in_memory_dataset.writeFits(uri.ospath) 

301 

302 def get_image_compression_settings(self, recipeName): 

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

304 

305 Parameters 

306 ---------- 

307 recipeName : `str` 

308 Label associated with the collection of compression parameters 

309 to select. 

310 

311 Returns 

312 ------- 

313 settings : `dict` 

314 The selected settings. 

315 """ 

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

317 # return immediately 

318 if not recipeName: 

319 if "default" not in self.write_recipes: 

320 return {} 

321 recipeName = "default" 

322 

323 if recipeName not in self.write_recipes: 

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

325 

326 recipe = self.write_recipes[recipeName] 

327 

328 # Set the seed based on dataId 

329 seed = hash(tuple(self.data_id.required.items())) % 2**31 

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

331 if plane in recipe and "scaling" in recipe[plane]: 

332 scaling = recipe[plane]["scaling"] 

333 if "seed" in scaling and scaling["seed"] == 0: 

334 scaling["seed"] = seed 

335 

336 return recipe 

337 

338 @classmethod 

339 def validate_write_recipes(cls, recipes): 

340 """Validate supplied recipes for this formatter. 

341 

342 The recipes are supplemented with default values where appropriate. 

343 

344 TODO: replace this custom validation code with Cerberus (DM-11846) 

345 

346 Parameters 

347 ---------- 

348 recipes : `dict` 

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

350 

351 Returns 

352 ------- 

353 validated : `dict` 

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

355 recipes listed. 

356 

357 Raises 

358 ------ 

359 RuntimeError 

360 Raised if validation fails. 

361 """ 

362 # Schemas define what should be there, and the default values (and by 

363 # the default value, the expected type). 

364 compressionSchema = { 

365 "algorithm": "NONE", 

366 "rows": 1, 

367 "columns": 0, 

368 "quantizeLevel": 0.0, 

369 } 

370 scalingSchema = { 

371 "algorithm": "NONE", 

372 "bitpix": 0, 

373 "maskPlanes": ["NO_DATA"], 

374 "seed": 0, 

375 "quantizeLevel": 4.0, 

376 "quantizePad": 5.0, 

377 "fuzz": True, 

378 "bscale": 1.0, 

379 "bzero": 0.0, 

380 } 

381 

382 if not recipes: 

383 # We can not insist on recipes being specified 

384 return recipes 

385 

386 def checkUnrecognized(entry, allowed, description): 

387 """Check to see if the entry contains unrecognised keywords.""" 

388 unrecognized = set(entry) - set(allowed) 

389 if unrecognized: 

390 raise RuntimeError( 

391 f"Unrecognized entries when parsing image compression recipe {description}: " 

392 f"{unrecognized}" 

393 ) 

394 

395 validated = {} 

396 for name in recipes: 

397 checkUnrecognized(recipes[name], ["image", "mask", "variance"], name) 

398 validated[name] = {} 

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

400 checkUnrecognized(recipes[name][plane], ["compression", "scaling"], f"{name}->{plane}") 

401 

402 np = {} 

403 validated[name][plane] = np 

404 for settings, schema in (("compression", compressionSchema), ("scaling", scalingSchema)): 

405 np[settings] = {} 

406 if settings not in recipes[name][plane]: 

407 for key in schema: 

408 np[settings][key] = schema[key] 

409 continue 

410 entry = recipes[name][plane][settings] 

411 checkUnrecognized(entry, schema.keys(), f"{name}->{plane}->{settings}") 

412 for key in schema: 

413 value = type(schema[key])(entry[key]) if key in entry else schema[key] 

414 np[settings][key] = value 

415 return validated 

416 

417 

418class FitsImageFormatter(StandardFitsImageFormatterBase): 

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

420 from/to FITS. 

421 """ 

422 

423 ReaderClass = ImageFitsReader 

424 

425 

426class FitsMaskFormatter(StandardFitsImageFormatterBase): 

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

428 from/to FITS. 

429 """ 

430 

431 ReaderClass = MaskFitsReader 

432 

433 

434class FitsMaskedImageFormatter(StandardFitsImageFormatterBase): 

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

436 from/to FITS. 

437 """ 

438 

439 ReaderClass = MaskedImageFitsReader 

440 

441 def readComponent(self, component): 

442 # Docstring inherited. 

443 if component == "image": 

444 return self.reader.readImage(**self.checked_parameters) 

445 elif component == "mask": 

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

447 elif component == "variance": 

448 return self.reader.readVariance(**self.checked_parameters) 

449 else: 

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

451 return super().readComponent(component) 

452 

453 

454def standardizeAmplifierParameters(parameters, on_disk_detector): 

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

456 

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

458 converts amplifier IDs/names to Amplifier instances. 

459 

460 Parameters 

461 ---------- 

462 parameters : `dict` 

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

464 class definition in daf_butler for allowed keys and values. 

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

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

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

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

469 

470 Returns 

471 ------- 

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

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

474 was no "amp" parameter. 

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

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

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

478 ``on_disk_detector`` is simply passed through. 

479 regions_differ : `bool` 

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

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

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

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

484 """ 

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

486 return None, on_disk_detector, False 

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

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

489 if isinstance(amplifier, int | str): 

490 amp_key = amplifier 

491 target_amplifier = None 

492 else: 

493 amp_key = amplifier.getName() 

494 target_amplifier = amplifier 

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

496 if on_disk_detector is not None: 

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

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

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

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

501 # assembly/orientation state. 

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

503 if comparison & comparison.ASSEMBLY_DIFFERS: 

504 raise ValueError( 

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

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

507 ) 

508 else: 

509 if on_disk_detector is None: 

510 raise ValueError( 

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

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

513 "'amp' parameter." 

514 ) 

515 comparison = AmplifierGeometryComparison.EQUAL 

516 detector = on_disk_detector 

517 if target_amplifier is None: 

518 target_amplifier = detector[amp_key] 

519 return target_amplifier, detector, comparison & comparison.REGIONS_DIFFER 

520 

521 

522class _ComponentCache(NamedTuple): 

523 id_: uuid.UUID | None = None 

524 reader: ExposureFitsReader | None = None 

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

526 mem: MemFileManager | None = None 

527 

528 

529class FitsExposureFormatter(FitsMaskedImageFormatter): 

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

531 from/to FITS. 

532 

533 Notes 

534 ----- 

535 This class inherits from `FitsMaskedImageFormatter` even though 

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

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

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

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

540 type covariance violation ever becomes a practical problem. 

541 """ 

542 

543 can_read_from_uri = True 

544 ReaderClass = ExposureFitsReader 

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

546 _lock = threading.Lock() 

547 _cached_fits: _ComponentCache = _ComponentCache() 

548 

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

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

551 # could work with cutouts. 

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

553 

554 # Full read, always use local file read. 

555 if not component and not self.checked_parameters: 

556 return NotImplemented 

557 

558 if not _ALWAYS_USE_ASTROPY_FOR_COMPONENT_READ and uri.isLocal: 

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

560 return NotImplemented 

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

562 

563 if component in pixel_components: 

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

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

566 # read with file caching for now. 

567 return NotImplemented 

568 

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

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

571 # file. 

572 if not ( 

573 _ALWAYS_USE_ASTROPY_FOR_COMPONENT_READ 

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

575 ): 

576 return NotImplemented 

577 

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

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

580 # bounding box is reasonable. 

581 bbox = None 

582 origin = lsst.afw.image.PARENT 

583 if not component: 

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

585 # other parameters do not attempt a cutout. 

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

587 return NotImplemented 

588 bbox = self.checked_parameters["bbox"] 

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

590 # For larger cutouts use the full file. 

591 max_cutout_size = 500 * 500 

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

593 return NotImplemented 

594 

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

596 if component: 

597 with self._lock: 

598 cache = type(self)._cached_fits 

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

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

601 match component: 

602 case "xy0": 

603 return cache.bbox.getMin() 

604 case "dimensions": 

605 return cache.bbox.getDimensions() 

606 case "bbox": 

607 return cache.bbox 

608 else: 

609 self._reader = cache.reader 

610 return self.readComponent(component) 

611 

612 try: 

613 fs, fspath = uri.to_fsspec() 

614 except Exception: 

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

616 return NotImplemented 

617 

618 bbox_component = None 

619 try: 

620 hdul = [] 

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

622 # Read all non-pixel components and cache. 

623 for hdu in fits_obj: 

624 hdr = hdu.header 

625 extname = hdr.get("EXTNAME") 

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

627 # for EXTEND=T. 

628 extend = hdr.get("EXTEND") 

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

630 # Calculate the dimensional components for later 

631 # caching. Do not derive from cached FITS reader 

632 # because they depend on the dimensionality of the 

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

634 # data. 

635 if bbox_component is None: 

636 shape = hdu.shape 

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

638 

639 # XY0 is defined in the A WCS. 

640 pl = PropertyList() 

641 pl.update(hdr) 

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

643 

644 # This is the PARENT bbox. 

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

646 

647 # Handle cutout request. 

648 if bbox: 

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

650 full_bbox = bbox_component 

651 else: 

652 full_bbox = lsst.geom.Box2I( 

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

654 ) 

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

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

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

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

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

660 

661 # Must correct the header WCS to take into 

662 # account the offset. 

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

664 hdr[k] -= minX 

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

666 hdr[k] -= minY 

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

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

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

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

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

672 hdr[k] = bbox.getBeginX() 

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

674 hdr[k] = bbox.getBeginY() 

675 else: 

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

677 

678 # Construct a new HDU and copy the header. 

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

680 hdul.append(stripped_hdu) 

681 else: 

682 hdul.append(hdu) 

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

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

685 buffer = BytesIO() 

686 stripped_fits.writeto(buffer) 

687 except Exception as e: 

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

689 _LOG.debug( 

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

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

692 str(e), 

693 ) 

694 return NotImplemented 

695 

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

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

698 # components. 

699 fits_data = buffer.getvalue() 

700 mem = MemFileManager(len(fits_data)) 

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

702 self._reader = self.ReaderClass(mem) 

703 

704 if component: 

705 with self._lock: 

706 type(self)._cached_fits = _ComponentCache( 

707 id_=self.dataset_ref.id, 

708 reader=self._reader, 

709 mem=mem, 

710 bbox=bbox_component, 

711 ) 

712 match component: 

713 case "xy0": 

714 if bbox_component is None: # For mypy. 

715 return None 

716 return bbox_component.getMin() 

717 case "dimensions": 

718 if bbox_component is None: 

719 return None 

720 return bbox_component.getDimensions() 

721 case "bbox": 

722 return bbox_component 

723 case _: 

724 return self.readComponent(component) 

725 else: 

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

727 # parameters should be passed here. 

728 cutout = self.reader.read() 

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

730 return cutout 

731 

732 def add_provenance( 

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

734 ) -> Any: 

735 # Add provenance via FITS headers. 

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

737 return in_memory_dataset 

738 

739 def readComponent(self, component): 

740 # Docstring inherited. 

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

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

743 genericComponents = { 

744 "summaryStats": ExposureInfo.KEY_SUMMARY_STATS, 

745 } 

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

747 return self.reader.readComponent(genericComponentName) 

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

749 # parameters. 

750 standardComponents = { 

751 "id": "readExposureId", 

752 "metadata": "readMetadata", 

753 "wcs": "readWcs", 

754 "coaddInputs": "readCoaddInputs", 

755 "psf": "readPsf", 

756 "photoCalib": "readPhotoCalib", 

757 "filter": "readFilter", 

758 "validPolygon": "readValidPolygon", 

759 "apCorrMap": "readApCorrMap", 

760 "visitInfo": "readVisitInfo", 

761 "transmissionCurve": "readTransmissionCurve", 

762 "detector": "readDetector", 

763 "exposureInfo": "readExposureInfo", 

764 } 

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

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

767 if component == "filter": 

768 return self._fixFilterLabels(result) 

769 return result 

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

771 return super().readComponent(component) 

772 

773 def readFull(self): 

774 # Docstring inherited. 

775 amplifier, detector, _ = standardizeAmplifierParameters( 

776 self.checked_parameters, 

777 self.reader.readDetector(), 

778 ) 

779 if amplifier is not None: 

780 amplifier_isolator = AmplifierIsolator( 

781 amplifier, 

782 self.reader.readBBox(), 

783 detector, 

784 ) 

785 result = amplifier_isolator.transform_subimage( 

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

787 ) 

788 result.setDetector(amplifier_isolator.make_detector()) 

789 else: 

790 result = self.reader.read(**self.checked_parameters) 

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

792 return result 

793 

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

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

796 data ID. 

797 

798 Parameters 

799 ---------- 

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

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

802 should_be_standardized : `bool`, optional 

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

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

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

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

807 whether the file should be standardized by looking at the 

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

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

810 

811 Returns 

812 ------- 

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

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

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

816 filters associated with this dataset type. 

817 

818 Notes 

819 ----- 

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

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

822 to solve. 

823 """ 

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

825 # so we can warn about them later. 

826 missing = [] 

827 band = None 

828 physical_filter = None 

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

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

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

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

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

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

835 missing.append("band") 

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

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

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

839 if ( 

840 physical_filter is None 

841 and not self.data_id.hasFull() 

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

843 ): 

844 missing.append("physical_filter") 

845 if should_be_standardized is None: 

846 version = self.reader.readSerializationVersion() 

847 should_be_standardized = version >= 2 

848 if missing: 

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

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

851 # to use the one in the file. 

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

853 # predates filter standardization. 

854 if not should_be_standardized: 

855 warnings.warn( 

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

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

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

859 # Report the warning from outside of middleware or the 

860 # relevant runQuantum method. 

861 stacklevel=find_outside_stacklevel( 

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

863 ), 

864 ) 

865 return file_filter_label 

866 if band is None and physical_filter is None: 

867 data_id_filter_label = None 

868 else: 

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

870 if data_id_filter_label != file_filter_label and should_be_standardized: 

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

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

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

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

875 warnings.warn( 

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

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

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

879 stacklevel=find_outside_stacklevel( 

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

881 ), 

882 ) 

883 return data_id_filter_label