Coverage for python/lsst/images/fits/_output_archive.py: 72%

229 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-16 15:24 -0700

1# This file is part of lsst-images. 

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# Use of this source code is governed by a 3-clause BSD-style 

10# license that can be found in the LICENSE file. 

11 

12from __future__ import annotations 

13 

14__all__ = ("FitsOutputArchive", "write") 

15 

16import dataclasses 

17from collections.abc import Callable, Hashable, Iterator, Mapping 

18from contextlib import contextmanager 

19from pathlib import Path 

20from typing import Any, Self 

21 

22import astropy.io.fits 

23import astropy.table 

24import astropy.time 

25import numpy as np 

26import pydantic 

27 

28from .._transforms import FrameSet 

29from ..serialization import ( 

30 ArchiveTree, 

31 ArrayReferenceModel, 

32 ButlerInfo, 

33 MetadataValue, 

34 NestedOutputArchive, 

35 NumberType, 

36 OutputArchive, 

37 TableColumnModel, 

38 TableModel, 

39 no_header_updates, 

40) 

41from ._common import ( 

42 JSON_COLUMN, 

43 JSON_EXTNAME, 

44 ExtensionHDU, 

45 ExtensionKey, 

46 FitsCompressionOptions, 

47 FitsOpaqueMetadata, 

48 PointerModel, 

49 suppress_fits_card_warnings, 

50) 

51 

52_FITS_FORMAT_VERSION = 1 

53"""Container layout version for files written by `FitsOutputArchive`. 

54 

55Bumps when the on-disk FITS layout (HDU placement, INDX/JSON keyword schema) 

56changes. Independent of any data-model ``SCHEMA_VERSION``. 

57""" 

58 

59 

60def _set_creation_date(header: astropy.io.fits.Header) -> None: 

61 """Record the current UTC time in the ``DATE`` card of a FITS header. 

62 

63 Parameters 

64 ---------- 

65 header 

66 Header to modify in place. 

67 

68 Notes 

69 ----- 

70 The FITS standard requires every HDU to record the UTC date and time its 

71 header was created via a ``DATE`` card in ``YYYY-MM-DDThh:mm:ss[.sss]`` 

72 form. Any pre-existing ``DATE`` card (such as one preserved from an input 

73 archive) is overwritten in place so the value reflects this write. 

74 """ 

75 header.set("DATE", astropy.time.Time.now().fits, "UTC date this HDU was written.") 

76 

77 

78def write( 

79 obj: Any, 

80 path: Path | str, 

81 compression_options: Mapping[str, FitsCompressionOptions | None] | None = None, 

82 update_header: Callable[[astropy.io.fits.Header], None] = no_header_updates, 

83 compression_seed: int | None = None, 

84 metadata: dict[str, MetadataValue] | None = None, 

85 butler_info: ButlerInfo | None = None, 

86) -> Any: 

87 """Write an object with a ``serialize`` method to a FITS file. 

88 

89 Parameters 

90 ---------- 

91 obj 

92 Object with a ``serialize`` method to write. 

93 path 

94 Name of the file to write to. Must not already exist. 

95 compression_options 

96 Options for how to compress the FITS file, keyed by the name of 

97 the attribute (with JSON pointer ``/`` separators for nested 

98 attributes). 

99 update_header 

100 A callback that will be given the primary HDU FITS header and an 

101 opportunity to modify it. 

102 compression_seed 

103 A FITS tile compression seed to use whenever the configured 

104 compression seed is `None` or (for backwards compatibility) ``0``. 

105 This value is then incremented every time it is used. 

106 metadata 

107 Additional metadata to save with the object. This will override any 

108 flexible metadata carried by the object itself with the same keys. 

109 butler_info 

110 Butler information to store in the file. 

111 

112 Returns 

113 ------- 

114 `.serialization.ArchiveTree` 

115 The serialized representation of the object. 

116 """ 

117 opaque_metadata = getattr(obj, "_opaque_metadata", None) 

118 name = getattr(obj, "_archive_default_name", None) 

119 with FitsOutputArchive.open( 

120 path, 

121 compression_options=compression_options, 

122 opaque_metadata=opaque_metadata, 

123 update_header=update_header, 

124 compression_seed=compression_seed, 

125 ) as archive: 

126 tree = archive.serialize_direct(name, obj.serialize) if name is not None else obj.serialize(archive) 

127 if metadata is not None: 127 ↛ 128line 127 didn't jump to line 128 because the condition on line 127 was never true

128 tree.metadata.update(metadata) 

129 if butler_info is not None: 

130 tree.butler_info = butler_info 

131 archive.add_tree(tree) 

132 return tree 

133 

134 

135class FitsOutputArchive(OutputArchive[PointerModel]): 

136 """An implementation of the `.serialization.OutputArchive` interface that 

137 writes to FITS files. 

138 

139 Instances of this class should only be constructed via the `open` 

140 context manager. 

141 

142 Parameters 

143 ---------- 

144 hdu_list 

145 HDU list that the archive writes its HDUs into. 

146 compression_options 

147 Options for how to compress the FITS file, keyed by the name of 

148 the attribute (with JSON pointer ``/`` separators for nested 

149 attributes). 

150 opaque_metadata 

151 Opaque metadata to carry through from the object being written. 

152 compression_seed 

153 A FITS tile compression seed to use whenever the configured 

154 compression seed is `None` or (for backwards compatibility) 

155 ``0``. 

156 """ 

157 

158 def __init__( 

159 self, 

160 hdu_list: astropy.io.fits.HDUList, 

161 compression_options: Mapping[str, FitsCompressionOptions | None] | None = None, 

162 opaque_metadata: Any = None, 

163 compression_seed: int | None = None, 

164 ) -> None: 

165 super().__init__() 

166 # JSON blobs for objects we've saved as pointers: 

167 self._pointer_targets: list[bytes] = [] 

168 # Mapping from user provided key (e.g. id(some object)) to a table 

169 # pointer to where we actually saved it: 

170 self._pointers_by_key: dict[Hashable, PointerModel] = {} 

171 self._hdu_list = hdu_list 

172 self._primary_hdu = astropy.io.fits.PrimaryHDU() 

173 self._primary_hdu.header.set("FMTVER", _FITS_FORMAT_VERSION, "FITS container layout version.") 

174 self._primary_hdu.header.set("INDXADDR", 0, "Offset in bytes to the HDU index.") 

175 self._primary_hdu.header.set("INDXSIZE", 0, "Size of the HDU index.") 

176 self._primary_hdu.header.set("JSONADDR", 0, "Offset in bytes to the JSON tree HDU.") 

177 self._primary_hdu.header.set("JSONSIZE", 0, "Size of the JSON tree HDU.") 

178 self._compression_options = dict(compression_options) if compression_options is not None else {} 

179 self._compression_seed = compression_seed 

180 self._opaque_metadata = ( 

181 opaque_metadata if isinstance(opaque_metadata, FitsOpaqueMetadata) else FitsOpaqueMetadata() 

182 ) 

183 if (opaque_primary_header := self._opaque_metadata.headers.get(ExtensionKey())) is not None: 

184 self._primary_hdu.header.extend(opaque_primary_header) 

185 self._hdu_list.append(self._primary_hdu) 

186 self._json_hdu_added: bool = False 

187 self._frame_sets: list[tuple[FrameSet, PointerModel]] = [] 

188 

189 @classmethod 

190 @contextmanager 

191 def open( 

192 cls, 

193 filename: Path | str, 

194 compression_options: Mapping[str, FitsCompressionOptions | None] | None = None, 

195 opaque_metadata: Any = None, 

196 update_header: Callable[[astropy.io.fits.Header], None] = no_header_updates, 

197 compression_seed: int | None = None, 

198 ) -> Iterator[Self]: 

199 """Create an output archive that writes to the given file. 

200 

201 Parameters 

202 ---------- 

203 filename 

204 Name of the file to write to. Must not already exist. 

205 compression_options 

206 Options for how to compress the FITS file, keyed by the name of 

207 the attribute (with JSON pointer ``/`` separators for nested 

208 attributes). 

209 opaque_metadata 

210 Metadata read from an input archive along with the object being 

211 written now. Ignored if the metadata is not from a FITS archive. 

212 update_header 

213 A callback that will be given the primary HDU FITS header and an 

214 opportunity to modify it. 

215 compression_seed 

216 A FITS tile compression seed to use whenever the configured 

217 compression seed is `None` or (for backwards compatibility) ``0``. 

218 This value is then incremented every time it is used. 

219 

220 Returns 

221 ------- 

222 `contextlib.AbstractContextManager` [`FitsOutputArchive`] 

223 A context manager that returns a `FitsOutputArchive` when entered. 

224 """ 

225 # Astropy auto-fixes over-long keywords (HIERARCH) and comments 

226 # (truncation) as cards are created and written, but doesn't honor its 

227 # own output_verify option for these warnings, so we filter them out 

228 # across the write of the main HDUs. 

229 with suppress_fits_card_warnings(), astropy.io.fits.open(filename, mode="append") as hdu_list: 

230 if hdu_list: 230 ↛ 231line 230 didn't jump to line 231 because the condition on line 230 was never true

231 raise OSError(f"File {filename!r} already exists.") 

232 archive = cls(hdu_list, compression_options, opaque_metadata, compression_seed=compression_seed) 

233 update_header(hdu_list[0].header) 

234 # Set the creation date after update_header so a caller's callback 

235 # cannot leave a stale DATE in the primary header; this card must 

236 # always record the time of this write. 

237 _set_creation_date(hdu_list[0].header) 

238 yield archive 

239 if not archive._json_hdu_added: 239 ↛ 240line 239 didn't jump to line 240 because the condition on line 239 was never true

240 raise RuntimeError("Write context exited without 'add_tree' being called.") 

241 hdu_list.flush() 

242 # This multi-open dance is necessary to get Astropy to tell us the 

243 # byte addresses of the HDUs. Hopefully we can get an upstream change 

244 # make this unnecessary at some point. 

245 with astropy.io.fits.open(filename, mode="readonly", disable_image_compression=True) as hdu_list: 

246 index_hdu = cls._make_index_table(hdu_list) 

247 with astropy.io.fits.open(filename, mode="append") as hdu_list: 

248 hdu_list.append(index_hdu) 

249 json_bytes = _HDUBytes.from_index_row(index_hdu.data[-1]) 

250 index_bytes = _HDUBytes.from_write_hdu(index_hdu) 

251 # Update the primary HDU with the address and size of the index and 

252 # JSON HDUs, and rewrite just that. We do this write manually, since 

253 # astropy's docs on its 'update' mode are scarce and it's not obvious 

254 # whether we can guarantee it won't rewrite the whole file if we edit 

255 # the primary header. 

256 archive._primary_hdu.header["INDXADDR"] = index_bytes.header_address 

257 archive._primary_hdu.header["INDXSIZE"] = index_bytes.size 

258 archive._primary_hdu.header["JSONADDR"] = json_bytes.header_address 

259 archive._primary_hdu.header["JSONSIZE"] = json_bytes.size 

260 with open(filename, "r+b") as stream: 

261 stream.write(archive._primary_hdu.header.tostring().encode()) 

262 

263 def serialize_direct[T: pydantic.BaseModel | None]( 

264 self, name: str, serializer: Callable[[OutputArchive[PointerModel]], T] 

265 ) -> T: 

266 nested = NestedOutputArchive[PointerModel](name, self) 

267 return serializer(nested) 

268 

269 def serialize_pointer[T: ArchiveTree]( 

270 self, name: str, serializer: Callable[[OutputArchive[PointerModel]], T], key: Hashable 

271 ) -> PointerModel: 

272 if (pointer := self._pointers_by_key.get(key)) is not None: 272 ↛ 273line 272 didn't jump to line 273 because the condition on line 272 was never true

273 return pointer 

274 # Rooting the nested archive at "" follows the NestedOutputArchive 

275 # convention that root paths are absolute, but serialize_direct roots 

276 # at the bare name, so an array written by a pointer target would get 

277 # a "/"-prefixed EXTNAME (e.g. "/DATA"). No serializer currently 

278 # writes arrays inside pointer targets; if one ever does, consider 

279 # rooting at ``name`` instead, as the NDF backend does. 

280 model = self.serialize_direct("", serializer) 

281 json_bytes = model.model_dump_json().encode() 

282 self._pointer_targets.append(json_bytes) 

283 pointer = PointerModel( 

284 column=TableColumnModel( 

285 name=JSON_COLUMN, 

286 data=ArrayReferenceModel( 

287 source=f"fits:{JSON_EXTNAME}[1]", 

288 shape=[len(json_bytes)], 

289 datatype=NumberType.uint8, 

290 ), 

291 ), 

292 row=len(self._pointer_targets), 

293 ) 

294 self._pointers_by_key[key] = pointer 

295 return pointer 

296 

297 def serialize_frame_set[T: ArchiveTree]( 

298 self, name: str, frame_set: FrameSet, serializer: Callable[[OutputArchive], T], key: Hashable 

299 ) -> PointerModel: 

300 # Docstring inherited. 

301 pointer = self.serialize_pointer(name, serializer, key) 

302 self._frame_sets.append((frame_set, pointer)) 

303 return pointer 

304 

305 def iter_frame_sets(self) -> Iterator[tuple[FrameSet, PointerModel]]: 

306 return iter(self._frame_sets) 

307 

308 def add_array( 

309 self, 

310 array: np.ndarray, 

311 *, 

312 name: str | None = None, 

313 update_header: Callable[[astropy.io.fits.Header], None] = no_header_updates, 

314 tile_shape: tuple[int, ...] | None = None, 

315 options_name: str | None = None, 

316 ) -> ArrayReferenceModel: 

317 if name is None: 317 ↛ 318line 317 didn't jump to line 318 because the condition on line 317 was never true

318 raise RuntimeError("Cannot save array with name=None unless it is nested.") 

319 name, version = self._register_name(name) 

320 extname = name.upper() 

321 hdu = self._opaque_metadata.maybe_use_precompressed(extname) 

322 if hdu is None: 322 ↛ 329line 322 didn't jump to line 329 because the condition on line 322 was always true

323 if options_name is None: 323 ↛ 325line 323 didn't jump to line 325 because the condition on line 323 was always true

324 options_name = name 

325 if (compression_options := self._get_compression_options(options_name, tile_shape)) is not None: 325 ↛ 328line 325 didn't jump to line 328 because the condition on line 325 was always true

326 hdu = compression_options.make_hdu(array, name=extname) 

327 else: 

328 hdu = astropy.io.fits.ImageHDU(array, name=extname) 

329 key = self._add_hdu(hdu, version, update_header) 

330 return ArrayReferenceModel( 

331 source=str(key), 

332 shape=list(array.shape), 

333 datatype=NumberType.from_numpy(array.dtype), 

334 ) 

335 

336 def add_table( 

337 self, 

338 table: astropy.table.Table, 

339 *, 

340 name: str | None = None, 

341 update_header: Callable[[astropy.io.fits.Header], None] = no_header_updates, 

342 ) -> TableModel: 

343 if name is None: 

344 raise RuntimeError("Cannot save table with name=None unless it is nested.") 

345 name, version = self._register_name(name) 

346 extname = name.upper() 

347 hdu: astropy.io.fits.BinTableHDU = astropy.io.fits.table_to_hdu(table, name=extname) 

348 # Extract column information directly from the input array, not the 

349 # data in the binary table HDU, because we want to assume as little as 

350 # possible about where Astropy does uint -> TZERO stuff. 

351 columns = TableColumnModel.from_table(table) 

352 key = self._add_hdu(hdu, version, update_header) 

353 for n, c in enumerate(columns, start=1): 

354 assert isinstance(c.data, ArrayReferenceModel) 

355 c.data.source = f"{key}[{n}]" 

356 return TableModel(columns=columns, meta=table.meta) 

357 

358 def add_structured_array( 

359 self, 

360 array: np.ndarray, 

361 *, 

362 name: str | None = None, 

363 units: Mapping[str, astropy.units.Unit] | None = None, 

364 descriptions: Mapping[str, str] | None = None, 

365 update_header: Callable[[astropy.io.fits.Header], None] = no_header_updates, 

366 ) -> TableModel: 

367 if name is None: 

368 raise RuntimeError("Cannot save structured array with name=None unless it is nested.") 

369 name, version = self._register_name(name) 

370 extname = name.upper() 

371 # Extract column information directly from the input array, not the 

372 # data in the binary table HDU, because we want to assume as little as 

373 # possible about where Astropy does uint -> TZERO stuff. 

374 columns = TableColumnModel.from_record_dtype(array.dtype) 

375 hdu = astropy.io.fits.BinTableHDU(array, name=extname) 

376 if units is not None: 

377 for c in columns: 

378 c.unit = units.get(c.name) 

379 if descriptions is not None: 

380 for c in columns: 

381 c.description = descriptions.get(c.name, "") 

382 key = self._add_hdu(hdu, version, update_header) 

383 for n, c in enumerate(columns, start=1): 

384 assert isinstance(c.data, ArrayReferenceModel) 

385 c.data.source = f"{key}[{n}]" 

386 return TableModel(columns=columns) 

387 

388 def _add_hdu( 

389 self, 

390 hdu: astropy.io.fits.ImageHDU | astropy.io.fits.CompImageHDU | astropy.io.fits.BinTableHDU, 

391 version: int, 

392 update_header: Callable[[astropy.io.fits.Header], None] = no_header_updates, 

393 ) -> ExtensionKey: 

394 key = ExtensionKey(hdu.name, version) 

395 key.check() 

396 if version > 1: 

397 hdu.header["EXTVER"] = version 

398 update_header(hdu.header) 

399 if (opaque_headers := self._opaque_metadata.headers.get(key)) is not None: 

400 hdu.header.extend(opaque_headers) 

401 _set_creation_date(hdu.header) 

402 self._hdu_list.append(hdu) 

403 return key 

404 

405 def add_tree(self, tree: ArchiveTree) -> None: 

406 """Write the JSON tree to the archive. 

407 

408 This method must be called exactly once, just before the `open` context 

409 is exited. 

410 

411 Parameters 

412 ---------- 

413 tree 

414 Pydantic model that represents the tree. 

415 """ 

416 self._primary_hdu.header.set("DATAMODL", tree.schema_url, "Schema URL.") 

417 json_hdu = astropy.io.fits.BinTableHDU.from_columns( 

418 [astropy.io.fits.Column(JSON_COLUMN, "PB")], 

419 nrows=len(self._pointer_targets) + 1, 

420 name=JSON_EXTNAME, 

421 ) 

422 json_hdu.data[0][JSON_COLUMN] = np.frombuffer(tree.model_dump_json().encode(), dtype=np.byte) 

423 for n, json_target_data in enumerate(self._pointer_targets): 

424 json_hdu.data[n + 1][JSON_COLUMN] = np.frombuffer(json_target_data, dtype=np.byte) 

425 _set_creation_date(json_hdu.header) 

426 self._hdu_list.append(json_hdu) 

427 self._json_hdu_added = True 

428 

429 def _get_compression_options( 

430 self, name: str, tile_shape: tuple[int, ...] | None 

431 ) -> FitsCompressionOptions | None: 

432 result = self._compression_options.get(name, FitsCompressionOptions.DEFAULT) 

433 if result is None: 433 ↛ 434line 433 didn't jump to line 434 because the condition on line 433 was never true

434 return result 

435 if tile_shape is not None and result.tile_shape is None: 435 ↛ 436line 435 didn't jump to line 436 because the condition on line 435 was never true

436 result = result.model_copy(update={"tile_shape": tile_shape}) 

437 if result.quantization is None: 

438 return result 

439 if self._compression_seed is not None and not result.quantization.seed: 439 ↛ 450line 439 didn't jump to line 450 because the condition on line 439 was always true

440 result = result.model_copy( 

441 update={ 

442 "quantization": result.quantization.model_copy(update={"seed": self._compression_seed}) 

443 } 

444 ) 

445 self._compression_seed += 1 

446 if self._compression_seed > 10000: 446 ↛ 447line 446 didn't jump to line 447 because the condition on line 446 was never true

447 self._compression_seed = 1 

448 # MyPy can tell that result.quantization is not None in the 'if', but 

449 # forgets that by this 'else': 

450 elif result.quantization.seed is None: # type: ignore[union-attr] 

451 raise RuntimeError("No quantization seed provided.") 

452 return result 

453 

454 @staticmethod 

455 def _make_index_table(hdu_list: astropy.io.fits.HDUList) -> astropy.io.fits.BinTableHDU: 

456 # We use a fixed-length string for the EXTNAME column; it might be 

457 # better to use a variable-length array, but I have not been able to 

458 # figure out how to get astropy to accept a string for the the 

459 # character (TFORM='A') variant of that. And that's only better if the 

460 # EXTNAMEs get super long, which is not likely (but maybe something to 

461 # guard against). 

462 max_name_size = max(len(hdu.header.get("EXTNAME", "")) for hdu in hdu_list) 

463 # Attach the ZIMAGE values as a boolean array on the Column itself. 

464 # Astropy only fills a logical column's raw bytes with 'F' as the 

465 # default (rather than NULL, 0x00) when the bool array is present at 

466 # construction; assigning False element-wise after construction leaves 

467 # the byte as NULL under Astropy 8.0.0, which warns on every read. 

468 # Should be fixed in v8.0.1. 

469 # See https://github.com/astropy/astropy/pull/19939 

470 # but pre-allocating the entire column works in v7 and v8. 

471 zimage = np.array([hdu.header.get("ZIMAGE", False) for hdu in hdu_list], dtype=bool) 

472 index_hdu = astropy.io.fits.BinTableHDU.from_columns( 

473 [ 

474 astropy.io.fits.Column("EXTNAME", f"A{max_name_size}"), 

475 astropy.io.fits.Column("EXTVER", "J"), 

476 astropy.io.fits.Column("XTENSION", "A8"), 

477 astropy.io.fits.Column("ZIMAGE", "L", array=zimage), 

478 ] 

479 + _HDUBytes.get_index_hdu_columns(), 

480 nrows=len(hdu_list), 

481 name="INDEX", 

482 ) 

483 hdu: ExtensionHDU | astropy.io.fits.PrimaryHDU 

484 for n, hdu in enumerate(hdu_list): 

485 index_hdu.data[n]["EXTNAME"] = hdu.header.get("EXTNAME", "") 

486 index_hdu.data[n]["EXTVER"] = hdu.header.get("EXTVER", 1) 

487 index_hdu.data[n]["XTENSION"] = hdu.header.get("XTENSION", "IMAGE") 

488 bytes = _HDUBytes.from_read_hdu(hdu) 

489 bytes.update_index_row(index_hdu.data[n]) 

490 _set_creation_date(index_hdu.header) 

491 return index_hdu 

492 

493 

494@dataclasses.dataclass 

495class _HDUBytes: 

496 """A struct that records the byte offsets into a FITS HDU.""" 

497 

498 @classmethod 

499 def from_write_hdu(cls, hdu: astropy.io.fits.PrimaryHDU | ExtensionHDU) -> Self: 

500 """Construct from an Astropy HDU instance that has just been written. 

501 

502 Parameters 

503 ---------- 

504 hdu 

505 An Astropy HDU object. 

506 

507 Returns 

508 ------- 

509 hdu_bytes 

510 Struct with byte offsets. 

511 

512 Notes 

513 ----- 

514 This method relies on internal Astropy attributes and does not work on 

515 CompImageHDU objects. 

516 """ 

517 # This is implemented by accessing private Astropy attributes because 

518 # it turns out that's much more reliable than the public fileinfo() 

519 # method, which seems to always return a dict with `None` entries or 

520 # raise; it looks buggy, but docs are scarce enough that it's not clear 

521 # what the right behavior is supposed to be. 

522 if (header_address := getattr(hdu, "_header_offset", None)) is None: 522 ↛ 523line 522 didn't jump to line 523 because the condition on line 522 was never true

523 raise RuntimeError("Failed to get Astropy's _header_offset.") 

524 if (data_address := getattr(hdu, "_data_offset", None)) is None: 524 ↛ 525line 524 didn't jump to line 525 because the condition on line 524 was never true

525 raise RuntimeError("Failed to get Astropy's _data_offset.") 

526 if (data_size := getattr(hdu, "_data_size", None)) is None: 526 ↛ 527line 526 didn't jump to line 527 because the condition on line 526 was never true

527 raise RuntimeError("Failed to get Astropy's _data_size.") 

528 return cls(header_address, data_address, data_size) 

529 

530 @classmethod 

531 def from_read_hdu(cls, hdu: astropy.io.fits.PrimaryHDU | ExtensionHDU) -> Self: 

532 """Construct from an Astropy HDU instance that has just been read. 

533 

534 Parameters 

535 ---------- 

536 hdu 

537 An Astropy HDU object. 

538 

539 Returns 

540 ------- 

541 hdu_bytes 

542 Struct with byte offsets. 

543 """ 

544 info = hdu.fileinfo() 

545 header_address = info["hdrLoc"] 

546 data_address = info["datLoc"] 

547 data_size = info["datSpan"] 

548 return cls(header_address, data_address, data_size) 

549 

550 @classmethod 

551 def from_index_row(cls, row: np.void) -> Self: 

552 """Construct from a row of the index HDU. 

553 

554 Parameters 

555 ---------- 

556 row 

557 A Numpy struct-like scalar. 

558 

559 Returns 

560 ------- 

561 hdu_bytes 

562 Struct with byte offsets. 

563 """ 

564 return cls( 

565 header_address=int(row["HDRADDR"]), 

566 data_address=int(row["DATADDR"]), 

567 data_size=int(row["DATSIZE"]), 

568 ) 

569 

570 @staticmethod 

571 def get_index_hdu_columns() -> list[astropy.io.fits.Column]: 

572 """Return the definitions of the columns this class gets and sets 

573 from the index HDU. 

574 

575 Returns 

576 ------- 

577 columns 

578 A `list` of `astropy.io.fits.Column` objects that represent the 

579 header address, data address, and data size. 

580 """ 

581 return [ 

582 astropy.io.fits.Column("HDRADDR", "K"), 

583 astropy.io.fits.Column("DATADDR", "K"), 

584 astropy.io.fits.Column("DATSIZE", "K"), 

585 ] 

586 

587 header_address: int 

588 """Offset from the beginning of the start of the file to the header of this 

589 HDU, in bytes. 

590 """ 

591 

592 data_address: int 

593 """Offset from the beginning of the start of the file to the data section 

594 of this HDU, in bytes. 

595 """ 

596 

597 data_size: int 

598 """Size of the data section in bytes.""" 

599 

600 @property 

601 def header_size(self) -> int: 

602 """Size of the header in bytes.""" 

603 return self.data_address - self.header_address 

604 

605 @property 

606 def end_address(self) -> int: 

607 """Offset in bytes from the start of the file to the end of the HDU.""" 

608 return self.data_address + self.data_size 

609 

610 @property 

611 def size(self) -> int: 

612 """Total size of this HDU in bytes.""" 

613 return self.data_size + self.data_address - self.header_address 

614 

615 def update_index_row(self, row: np.void) -> None: 

616 """Set the values of a row of the index HDU from this strut. 

617 

618 Parameters 

619 ---------- 

620 row 

621 A Numpy struct-like scalar to modify in place. 

622 """ 

623 row["HDRADDR"] = self.header_address 

624 row["DATADDR"] = self.data_address 

625 row["DATSIZE"] = self.data_size