Coverage for python/lsst/cell_coadds/_fits.py: 19%

220 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-10-10 02:10 -0700

1# This file is part of cell_coadds. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <https://www.gnu.org/licenses/>. 

21 

22"""Module to handle FITS serialization and de-serialization. 

23 

24The routines to write and read the files are in the same module, as a change to 

25one is typically accompanied by a corresponding change to another. Code changes 

26relating to writing the file must bump to the version number denoted by the 

27module constant FILE_FORMAT_VERSION. 

28 

29Although the typical use case is for newer versions of the code to read files 

30written by an older version, for the purposes of deciding the newer version 

31string, it is helpful to think about an older version of the reader attempting 

32to read a newer version of the file on disk. The policy for bumping the version 

33is as follows: 

34 

351. When the on-disk file format written by this module changes such that the 

36previous version of the reader can still read files written by the newer 

37version, then there should be a minor bump. 

38 

392. When the on-disk format written by this module changes in a way that will 

40prevent the previous version of the reader from reading a file produced by the 

41current version of the module, then there should be a major bump. This usually 

42means that the new version of the reader cannot read older file either, 

43save the temporary support with deprecation warnings, possibly until a new 

44release of the Science Pipelines is made. 

45 

46Examples 

47-------- 

481. A file with VERSION=1.3 should still be readable by the reader in 

49this module when the module-level constant FILE_FORMAT_VERSION=1.4. A file 

50written with VERSION=1.4 will typically be readable by a reader when the 

51module-level FILE_FORMAT_VERSION=1.3, although such a use case is not expected. 

52A concrete example of change 

53that requires only a minor bump is adding another BinTable that keeps track of 

54the input visits. 

55 

562. An example of major change would be migrating from using 

57BinTableHDU to ImageHDU to save data. Even if the reader supports reading 

58either of this formats based on the value of VERSION from the header, it should 

59be a major change because the previous version of the reader cannot read data 

60from ImageHDUs. 

61 

62Unit tests only check that a file written can be read by the concurrent version 

63of the module, but not by any of the previous ones. Hence, bumping 

64FILE_FORMAT_VERSION to the appropriate value is ultimately at the discretion of 

65the developers. 

66 

67A major bump must also be recorded in the `isCompatibleWith` method. 

68It is plausible that different (non-consequent) major format versions can be 

69read by the same reader (due to reverting back to an earlier format, or to 

70something very similar). `isCompatibleWith` method offers the convenience of 

71checking if a particular format version can be read by the current reader. 

72 

73Note that major version 0 is considered unstable and experimental and none of 

74the guarantee above applies. 

75""" 

76 

77from __future__ import annotations 

78 

79__all__ = ( 

80 "CellCoaddFitsFormatter", 

81 "CellCoaddFitsReader", 

82 "IncompatibleVersionError", 

83 "writeMultipleCellCoaddAsFits", 

84) 

85 

86import logging 

87import os 

88from collections.abc import Iterable, Mapping 

89from dataclasses import dataclass 

90from typing import Any 

91 

92import numpy as np 

93from astropy.io import fits 

94from frozendict import frozendict 

95from numpy.typing import DTypeLike 

96from packaging import version 

97 

98import lsst.afw.geom as afwGeom 

99import lsst.afw.image as afwImage 

100from lsst.afw.image import ImageD, ImageF 

101from lsst.daf.base import PropertySet 

102from lsst.geom import Box2I, Extent2I, Point2I 

103from lsst.obs.base.formatters.fitsGeneric import FitsGenericFormatter 

104from lsst.skymap import Index2D 

105 

106from ._coadd_ap_corr_map import EMPTY_AP_CORR_MAP 

107from ._common_components import CoaddUnits, CommonComponents 

108from ._grid_container import GridContainer 

109from ._identifiers import CellIdentifiers, ObservationIdentifiers, PatchIdentifiers 

110from ._image_planes import OwnedImagePlanes 

111from ._multiple_cell_coadd import MultipleCellCoadd, SingleCellCoadd 

112from ._uniform_grid import UniformGrid 

113from .typing_helpers import SingleCellCoaddApCorrMap 

114 

115FILE_FORMAT_VERSION = "0.5" 

116"""Version number for the file format as persisted, presented as a string of 

117the form M.m, where M is the major version, m is the minor version. 

118""" 

119 

120logger = logging.getLogger(__name__) 

121 

122 

123class IncompatibleVersionError(RuntimeError): 

124 """Exception raised when the CellCoaddFitsReader version is not compatible 

125 with the FITS file attempted to read. 

126 """ 

127 

128 

129@dataclass 

130class VisitRecord: 

131 """A dataclass to hold relevant info about a visit. 

132 

133 This is intended for use with this module. 

134 """ 

135 

136 visit: int 

137 day_obs: int 

138 physical_filter: str 

139 

140 

141class CellCoaddFitsFormatter(FitsGenericFormatter): 

142 """Interface for writing and reading cell coadds to/from FITS files. 

143 

144 This assumes the existence of readFits and writeFits methods (for now). 

145 """ 

146 

147 

148class CellCoaddFitsReader: 

149 """A reader class to read from a FITS file and produce cell-based coadds. 

150 

151 This reader class has read methods that can either return a single 

152 component without reading the entire file (e.g., readBBox, readWcs) 

153 and read methods that return a full coadd (e.g., 

154 readAsMultipleCellCoadd, readAsExplodedCellCoadd, readAsStitchedCoadd). 

155 

156 Parameters 

157 ---------- 

158 filename : `str` 

159 The name of the FITS file to read. 

160 """ 

161 

162 # Minimum and maximum compatible file format versions are listed as 

163 # iterables so as to allow for discontiguous intervals. 

164 MINIMUM_FILE_FORMAT_VERSIONS = ("0.1",) 

165 MAXIMUM_FILE_FORMAT_VERSIONS = ("1.0",) 

166 

167 def __init__(self, filename: str) -> None: 

168 if not os.path.exists(filename): 

169 raise FileNotFoundError(f"File {filename} not found") 

170 

171 self.filename = filename 

172 

173 @classmethod 

174 def isCompatibleWith(cls, written_version: str, /) -> bool: 

175 """Check if the serialization version is compatible with the reader. 

176 

177 This is a convenience method to ask if the current version of this 

178 class can read a file, based on the VERSION in its header. 

179 

180 Parameters 

181 ---------- 

182 written_version: `str` 

183 The VERSION of the file to be read. 

184 

185 Returns 

186 ------- 

187 compatible : `bool` 

188 Whether the reader can read a file whose VERSION is 

189 ``written_version``. 

190 

191 Notes 

192 ----- 

193 This accepts the other version as a positional argument only. 

194 """ 

195 written_version_object = version.parse(written_version) 

196 for min_version, max_version in zip( 

197 cls.MINIMUM_FILE_FORMAT_VERSIONS, 

198 cls.MAXIMUM_FILE_FORMAT_VERSIONS, 

199 strict=True, 

200 ): 

201 if version.parse(min_version) <= written_version_object < version.parse(max_version): 

202 return True 

203 

204 return False 

205 

206 def readAsMultipleCellCoadd(self) -> MultipleCellCoadd: 

207 """Read the FITS file as a MultipleCellCoadd object. 

208 

209 Raises 

210 ------ 

211 IncompatibleError 

212 Raised if the version of this module that wrote the file is 

213 incompatible with this module that is reading it in. 

214 ValueError 

215 Raised if the outer cell size is not padded equally in either 

216 direction or by an even number of pixels. 

217 """ 

218 with fits.open(self.filename) as hdu_list: 

219 header = hdu_list[1].header 

220 written_version = header.get("VERSION", "0.1") 

221 if not self.isCompatibleWith(written_version): 

222 raise IncompatibleVersionError( 

223 f"{self.filename} was written with version {written_version}" 

224 f"but attempting to read it with a reader designed for {FILE_FORMAT_VERSION}" 

225 ) 

226 if written_version != FILE_FORMAT_VERSION: 

227 logger.info( 

228 "Reading %s having version %s with reader designed for %s", 

229 self.filename, 

230 written_version, 

231 FILE_FORMAT_VERSION, 

232 ) 

233 

234 written_version = version.parse(written_version) 

235 

236 # TODO: Remove this when FILE_FORMAT_VERSION is bumped to 1.0 

237 if written_version < version.parse("0.3"): 

238 header.rename_keyword("BAND", "FILTER") 

239 

240 data = hdu_list[1].data 

241 

242 # Read in WCS 

243 ps = PropertySet() 

244 ps.update(hdu_list[0].header) 

245 wcs = afwGeom.makeSkyWcs(ps) 

246 

247 # Build the quantities needed to construct a MultipleCellCoadd. 

248 if written_version >= version.parse("0.4"): 

249 coadd_units = header["TUNIT2"] 

250 else: 

251 logger.info("Attemping to set pixel units from TUNIT1.") 

252 coadd_units = header.get("TUNIT1", CoaddUnits.legacy.name) 

253 

254 common = CommonComponents( 

255 units=CoaddUnits(coadd_units), 

256 wcs=wcs, 

257 band=header["FILTER"], 

258 identifiers=PatchIdentifiers( 

259 skymap=header["SKYMAP"], 

260 tract=header["TRACT"], 

261 patch=Index2D(x=header["PATCH_X"], y=header["PATCH_Y"]), 

262 band=header["FILTER"], 

263 ), 

264 ) 

265 

266 outer_cell_size = Extent2I(header["OCELL1"], header["OCELL2"]) 

267 psf_image_size = Extent2I(header["PSFSIZE1"], header["PSFSIZE2"]) 

268 

269 grid_cell_size = Extent2I(header["GRCELL1"], header["GRCELL2"]) # Inner size of a single cell. 

270 grid_shape = Extent2I(header["GRSHAPE1"], header["GRSHAPE2"]) 

271 grid_min = Point2I(header["GRMIN1"], header["GRMIN2"]) 

272 grid_padding_extent = outer_cell_size - grid_cell_size 

273 # In hindsight, it would have been easier to store the padding as a 

274 # separate keyword in the header instead of OCELL1 and OCELL2. 

275 # This can be done when the file format is bumped to 1.0. 

276 if grid_padding_extent.x != grid_padding_extent.y: 

277 raise ValueError( 

278 "Outer cell size is not padded equally in either directions. " 

279 f"Got {outer_cell_size} and {grid_cell_size}." 

280 ) 

281 if grid_padding_extent.x % 2 != 0: 

282 raise ValueError( 

283 "Outer cell size is not padded by an even number of pixels. " 

284 f"Got {outer_cell_size} and {grid_cell_size}." 

285 ) 

286 grid_padding = grid_padding_extent.x // 2 

287 grid = UniformGrid(cell_size=grid_cell_size, shape=grid_shape, padding=grid_padding, min=grid_min) 

288 

289 # This is the inner bounding box for the multiple cell coadd 

290 inner_bbox = Box2I( 

291 Point2I(header["INBBOX11"], header["INBBOX12"]), 

292 Point2I(header["INBBOX21"], header["INBBOX22"]), 

293 ) 

294 

295 # Attempt to get inputs for each cell. 

296 inputs = GridContainer[list[ObservationIdentifiers]](shape=grid.shape) 

297 if written_version >= version.parse("0.3"): 

298 visit_dict = { 

299 row["visit"]: VisitRecord( 

300 visit=row["visit"], 

301 physical_filter=row["physical_filter"], 

302 day_obs=row["day_obs"], 

303 ) 

304 for row in hdu_list[hdu_list.index_of("VISIT")].data 

305 } 

306 link_table = hdu_list[hdu_list.index_of("CELL")].data 

307 for link_row in link_table: 

308 cell_id = Index2D(link_row["cell_x"], link_row["cell_y"]) 

309 visit = link_row["visit"] 

310 obs_id = ObservationIdentifiers( 

311 instrument=header["INSTRUME"], 

312 visit=visit, 

313 detector=link_row["detector"], 

314 day_obs=visit_dict[visit].day_obs, 

315 physical_filter=visit_dict[visit].physical_filter, 

316 ) 

317 if cell_id in inputs: 

318 inputs[cell_id] += [obs_id] 

319 else: 

320 inputs[cell_id] = [obs_id] 

321 else: 

322 logger.info( 

323 "Cell inputs are available for VERSION=0.3 or later. The file provided has ", 

324 "VERSION = %s", 

325 written_version, 

326 ) 

327 

328 try: 

329 aperture_correction_hdu = hdu_list[hdu_list.index_of("APCORR")].data 

330 assert len(aperture_correction_hdu) == 0 or len(aperture_correction_hdu) == len( 

331 data 

332 ), "Aperture correction map is not available for all cells." 

333 aperture_correction_grid = self._readApCorr(aperture_correction_hdu, grid_shape) 

334 except KeyError: 

335 if written_version >= version.parse("0.4"): 

336 logger.warning("Unable to read aperture correction map from the file.") 

337 # Create an empty grid container regardless. 

338 aperture_correction_grid = GridContainer[SingleCellCoaddApCorrMap](shape=grid_shape) 

339 

340 coadd = MultipleCellCoadd( 

341 ( 

342 self._readSingleCellCoadd( 

343 data=data[row_id], 

344 header=header, 

345 common=common, 

346 inputs=inputs[ 

347 Index2D(int(data[row_id]["cell_id"][0]), int(data[row_id]["cell_id"][1])) 

348 ], 

349 outer_cell_size=outer_cell_size, 

350 psf_image_size=psf_image_size, 

351 inner_cell_size=grid_cell_size, 

352 aperture_correction_map=aperture_correction_grid.get( 

353 Index2D( 

354 data[row_id]["cell_id"][0].tolist(), 

355 data[row_id]["cell_id"][1].tolist(), 

356 ), 

357 EMPTY_AP_CORR_MAP, 

358 ), 

359 ) 

360 for row_id in range(len(data)) 

361 ), 

362 grid=grid, 

363 outer_cell_size=outer_cell_size, 

364 psf_image_size=psf_image_size, 

365 inner_bbox=inner_bbox, 

366 common=common, 

367 ) 

368 

369 return coadd 

370 

371 @staticmethod 

372 def _readSingleCellCoadd( 

373 data: Mapping[str, Any], 

374 common: CommonComponents, 

375 header: Mapping[str, Any], 

376 *, 

377 inputs: Iterable[ObservationIdentifiers], 

378 outer_cell_size: Extent2I, 

379 inner_cell_size: Extent2I, 

380 psf_image_size: Extent2I, 

381 aperture_correction_map: SingleCellCoaddApCorrMap = EMPTY_AP_CORR_MAP, 

382 ) -> SingleCellCoadd: 

383 """Read a coadd from a FITS file. 

384 

385 Parameters 

386 ---------- 

387 data : `Mapping` 

388 The data from the FITS file. Usually, a single row from the binary 

389 table representation. 

390 common : `CommonComponents` 

391 The common components of the coadd. 

392 header : `Mapping` 

393 The header of the FITS file as a dictionary. 

394 inputs : `Iterable` [`ObservationIdentifiers`] 

395 Any iterable of ObservationIdentifiers instances that contributed 

396 to this cell. 

397 outer_cell_size : `Extent2I` 

398 The size of the outer cell. 

399 psf_image_size : `Extent2I` 

400 The size of the PSF image. 

401 inner_cell_size : `Extent2I` 

402 The size of the inner cell. 

403 aperture_correction_map : `Mapping` [`str`, `float`], optional 

404 Mapping of algorithm name to aperture correction value. 

405 If None, no aperture correction is applied. 

406 

407 Returns 

408 ------- 

409 coadd : `SingleCellCoadd` 

410 The coadd read from the file. 

411 """ 

412 buffer = (outer_cell_size - inner_cell_size) // 2 

413 

414 n_noise_realizations = header.get("NNOISE", 0) 

415 

416 psf = ImageD( 

417 array=data["psf"].astype(np.float64), 

418 xy0=(-(psf_image_size // 2)).asPoint(), # integer division and negation do not commute. 

419 ) # use the variable 

420 xy0 = Point2I( 

421 inner_cell_size.x * data["cell_id"][0] - buffer.x + header["GRMIN1"], 

422 inner_cell_size.y * data["cell_id"][1] - buffer.y + header["GRMIN2"], 

423 ) 

424 mask = afwImage.Mask(data["mask"].astype(np.int32), xy0=xy0) 

425 try: 

426 maskfrac = data["maskfrac"] 

427 mask_fractions = ImageF(maskfrac.astype(np.float32), xy0=xy0) 

428 except KeyError: 

429 mask_fractions = None 

430 

431 image_planes = OwnedImagePlanes( 

432 image=ImageF( 

433 data["image"].astype(np.float32), 

434 xy0=xy0, 

435 ), 

436 mask=mask, 

437 variance=ImageF(data["variance"].astype(np.float32), xy0=xy0), 

438 noise_realizations=[ 

439 ImageF(data[f"noise_{n:02}"].astype(np.float32), xy0=xy0) for n in range(n_noise_realizations) 

440 ], 

441 mask_fractions=mask_fractions, 

442 ) 

443 

444 identifiers = CellIdentifiers( 

445 cell=Index2D(data["cell_id"][0], data["cell_id"][1]), 

446 skymap=common.identifiers.skymap, 

447 tract=common.identifiers.tract, 

448 patch=common.identifiers.patch, 

449 band=common.identifiers.band, 

450 ) 

451 

452 return SingleCellCoadd( 

453 outer=image_planes, 

454 psf=psf, 

455 inner_bbox=Box2I( 

456 corner=Point2I( 

457 inner_cell_size.x * data["cell_id"][0] + header["GRMIN1"], 

458 inner_cell_size.y * data["cell_id"][1] + header["GRMIN2"], 

459 ), 

460 dimensions=inner_cell_size, 

461 ), 

462 common=common, 

463 identifiers=identifiers, 

464 inputs=inputs, 

465 aperture_correction_map=aperture_correction_map, 

466 ) 

467 

468 def readWcs(self) -> afwGeom.SkyWcs: 

469 """Read the WCS information from the FITS file. 

470 

471 Returns 

472 ------- 

473 wcs : `~lsst.afw.geom.SkyWcs` 

474 The WCS information read from the FITS file. 

475 """ 

476 # Read in WCS 

477 ps = PropertySet() 

478 with fits.open(self.filename) as hdu_list: 

479 ps.update(hdu_list[0].header) 

480 wcs = afwGeom.makeSkyWcs(ps) 

481 return wcs 

482 

483 def _readApCorr( 

484 self, aperture_correction_hdu: fits.fitsrec.FITS_rec, grid_shape: Index2D 

485 ) -> GridContainer[SingleCellCoaddApCorrMap]: 

486 """Read the aperture correction map from the FITS file.""" 

487 column_names = aperture_correction_hdu.names 

488 column_names.remove("x") 

489 column_names.remove("y") 

490 

491 gc = GridContainer[SingleCellCoaddApCorrMap](shape=grid_shape) 

492 for row_idx in range(len(aperture_correction_hdu)): 

493 cell_id = Index2D( 

494 x=aperture_correction_hdu["x"][row_idx], y=aperture_correction_hdu["y"][row_idx] 

495 ) 

496 ap_corr_map_dict = {name: float(aperture_correction_hdu[name][row_idx]) for name in column_names} 

497 gc[cell_id] = frozendict(ap_corr_map_dict) 

498 

499 return gc 

500 

501 

502def to_numpy_record(ap_corr_map: Mapping[str, float], xy: Index2D, dtypes: DTypeLike) -> np.record: 

503 """Convert the aperture correction map to a numpy record with 

504 appropriate data types. 

505 

506 Parameters 

507 ---------- 

508 ap_corr_map: `Mapping` [`str`, `float`] 

509 Aperture correction values for each algorithm 

510 xy: `~lsst.skymap.Index2D` 

511 An object identifying the cell 

512 dtypes: `~numpy.typing.DTypeLike` 

513 An iterable of tuples describing the name and data type. It must 

514 contain ("x", int) and ("y", int) to accommodate ``xy``. 

515 

516 Returns 

517 ------- 

518 record : `np.record` 

519 Per-cell aperture correction map as a record. 

520 """ 

521 record = np.recarray((1,), dtype=dtypes)[0] 

522 for field_name in ap_corr_map: 

523 record[field_name] = ap_corr_map.get(field_name, np.nan) 

524 

525 record["x"] = xy.x 

526 record["y"] = xy.y 

527 

528 return record 

529 

530 

531def writeMultipleCellCoaddAsFits( 

532 multiple_cell_coadd: MultipleCellCoadd, 

533 filename: str, 

534 overwrite: bool = False, 

535 metadata: PropertySet | None = None, 

536) -> fits.HDUList: 

537 """Write a MultipleCellCoadd object to a FITS file. 

538 

539 Parameters 

540 ---------- 

541 multiple_cell_coadd : `MultipleCellCoadd` 

542 The multiple cell coadd to write to a FITS file. 

543 filename : `str` 

544 The name of the file to write to. 

545 overwrite : `bool`, optional 

546 Whether to overwrite the file if it already exists? 

547 metadata : `~lsst.daf.base.PropertySet`, optional 

548 Additional metadata to write to the FITS file. 

549 

550 Returns 

551 ------- 

552 hdu_list : `~astropy.io.fits.HDUList` 

553 The FITS file as an HDUList. 

554 

555 Notes 

556 ----- 

557 Changes to this function that modify the way the file is written to disk 

558 must be accompanied with a change to FILE_FORMAT_VERSION. 

559 """ 

560 # Create metadata tables: 

561 # 1. Visit table containing information about the visits. 

562 # 2. Cell table containing info about the visit+detector for each cell. 

563 visit_records: list[Any] = [] 

564 cell_records: list[Any] = [] 

565 instrument_set = set() 

566 for cell_id, single_cell_coadd in multiple_cell_coadd.cells.items(): 

567 for observation_id in single_cell_coadd.inputs: 

568 visit_records.append( 

569 (observation_id.visit, observation_id.physical_filter, observation_id.day_obs) 

570 ) 

571 cell_records.append((cell_id.x, cell_id.y, observation_id.visit, observation_id.detector)) 

572 instrument_set.add(observation_id.instrument) 

573 

574 assert len(instrument_set) == 1, "All cells must have the same instrument." 

575 instrument = instrument_set.pop() 

576 

577 visit_recarray = np.rec.fromrecords( 

578 recList=sorted(set(visit_records), key=lambda x: x[0]), # Sort by visit. 

579 formats=None, # formats has specified to please mypy. See numpy#26376. 

580 names=( 

581 "visit", 

582 "physical_filter", 

583 "day_obs", 

584 ), 

585 ) 

586 cell_recarray = np.rec.fromrecords( 

587 recList=cell_records, 

588 formats=None, # formats has specified to please mypy. See numpy#26376. 

589 names=( 

590 "cell_x", 

591 "cell_y", 

592 "visit", 

593 "detector", 

594 ), 

595 ) 

596 

597 visit_hdu = fits.BinTableHDU.from_columns(visit_recarray, name="VISIT") 

598 cell_hdu = fits.BinTableHDU.from_columns(cell_recarray, name="CELL") 

599 

600 cell_id = fits.Column( 

601 name="cell_id", 

602 format="2I", 

603 array=[cell.identifiers.cell for cell in multiple_cell_coadd.cells.values()], 

604 ) 

605 

606 image_array = [cell.outer.image.array for cell in multiple_cell_coadd.cells.values()] 

607 unit_array = [cell.common.units.name for cell in multiple_cell_coadd.cells.values()] 

608 image = fits.Column( 

609 name="image", 

610 unit=unit_array[0], 

611 format=f"{image_array[0].size}E", 

612 dim=f"({image_array[0].shape[1]}, {image_array[0].shape[0]})", 

613 array=image_array, 

614 ) 

615 

616 mask_array = [cell.outer.mask.array for cell in multiple_cell_coadd.cells.values()] 

617 mask = fits.Column( 

618 name="mask", 

619 format=f"{mask_array[0].size}I", 

620 dim=f"({mask_array[0].shape[1]}, {mask_array[0].shape[0]})", 

621 array=mask_array, 

622 ) 

623 

624 variance_array = [cell.outer.variance.array for cell in multiple_cell_coadd.cells.values()] 

625 variance = fits.Column( 

626 name="variance", 

627 format=f"{variance_array[0].size}E", 

628 dim=f"({variance_array[0].shape[1]}, {variance_array[0].shape[0]})", 

629 array=variance_array, 

630 ) 

631 

632 psf_array = [cell.psf_image.array for cell in multiple_cell_coadd.cells.values()] 

633 psf = fits.Column( 

634 name="psf", 

635 format=f"{psf_array[0].size}D", 

636 dim=f"({psf_array[0].shape[1]}, {psf_array[0].shape[0]})", 

637 array=[cell.psf_image.array for cell in multiple_cell_coadd.cells.values()], 

638 ) 

639 

640 columns = [cell_id, image, mask, variance, psf] 

641 maskfrac_array = [ 

642 cell.outer.mask_fractions.array 

643 for cell in multiple_cell_coadd.cells.values() 

644 if cell.outer.mask_fractions 

645 ] 

646 if maskfrac_array: 

647 maskfrac = fits.Column( 

648 name="maskfrac", 

649 format=f"{maskfrac_array[0].size}E", 

650 dim=f"({maskfrac_array[0].shape[1]}, {maskfrac_array[0].shape[0]})", 

651 array=maskfrac_array, 

652 ) 

653 columns.append(maskfrac) 

654 

655 n_noise_realizations = multiple_cell_coadd.n_noise_realizations 

656 for n in range(n_noise_realizations): 

657 noise_array = [cell.outer.noise_realizations[n].array for cell in multiple_cell_coadd.cells.values()] 

658 columns.append( 

659 fits.Column( 

660 name=f"noise_{n:02}", 

661 format=f"{noise_array[0].size}E", 

662 dim=f"({noise_array[0].shape[1]}, {noise_array[0].shape[0]})", 

663 array=noise_array, 

664 ) 

665 ) 

666 

667 if multiple_cell_coadd.cells.arbitrary.aperture_correction_map: 

668 apcorr_fields: set[str] = set() 

669 for cell in multiple_cell_coadd.cells.values(): 

670 apcorr_fields.update(cell.aperture_correction_map) 

671 dtypes = [("x", int), ("y", int)] + [(key, float) for key in apcorr_fields] 

672 aperture_correction_recarray = np.rec.fromrecords( 

673 recList=[ 

674 to_numpy_record(cell.aperture_correction_map, cell.identifiers.cell, dtypes) 

675 for cell in multiple_cell_coadd.cells.values() 

676 if cell.aperture_correction_map 

677 ], 

678 dtype=dtypes, 

679 ) 

680 aperture_correction_hdu = fits.BinTableHDU.from_columns(aperture_correction_recarray, name="APCORR") 

681 else: 

682 aperture_correction_hdu = None 

683 

684 col_defs = fits.ColDefs(columns) 

685 hdu = fits.BinTableHDU.from_columns(col_defs) 

686 

687 grid_cell_size = multiple_cell_coadd.grid.cell_size 

688 grid_shape = multiple_cell_coadd.grid.shape 

689 grid_min = multiple_cell_coadd.grid.bbox.getMin() 

690 grid_cards = { 

691 "GRCELL1": grid_cell_size.x, 

692 "GRCELL2": grid_cell_size.y, 

693 "GRSHAPE1": grid_shape.x, 

694 "GRSHAPE2": grid_shape.y, 

695 "GRMIN1": grid_min.x, 

696 "GRMIN2": grid_min.y, 

697 } 

698 hdu.header.extend(grid_cards) 

699 

700 outer_cell_size_cards = { 

701 "OCELL1": multiple_cell_coadd.outer_cell_size.x, 

702 "OCELL2": multiple_cell_coadd.outer_cell_size.y, 

703 } 

704 hdu.header.extend(outer_cell_size_cards) 

705 

706 psf_image_size_cards = { 

707 "PSFSIZE1": multiple_cell_coadd.psf_image_size.x, 

708 "PSFSIZE2": multiple_cell_coadd.psf_image_size.y, 

709 } 

710 hdu.header.extend(psf_image_size_cards) 

711 

712 inner_bbox_cards = { 

713 "INBBOX11": multiple_cell_coadd.inner_bbox.minX, 

714 "INBBOX12": multiple_cell_coadd.inner_bbox.minY, 

715 "INBBOX21": multiple_cell_coadd.inner_bbox.maxX, 

716 "INBBOX22": multiple_cell_coadd.inner_bbox.maxY, 

717 } 

718 hdu.header.extend(inner_bbox_cards) 

719 

720 wcs = multiple_cell_coadd.common.wcs 

721 wcs_cards = wcs.getFitsMetadata().toDict() 

722 primary_hdu = fits.PrimaryHDU() 

723 primary_hdu.header.extend(wcs_cards) 

724 

725 hdu.header["VERSION"] = FILE_FORMAT_VERSION 

726 hdu.header["TUNIT2"] = multiple_cell_coadd.common.units.name 

727 hdu.header["TUNIT4"] = multiple_cell_coadd.common.units.name + "**2" 

728 # This assumed to be the same as multiple_cell_coadd.common.identifers.band 

729 # See DM-38843. 

730 hdu.header["INSTRUME"] = instrument 

731 hdu.header["FILTER"] = multiple_cell_coadd.common.band 

732 hdu.header["SKYMAP"] = multiple_cell_coadd.common.identifiers.skymap 

733 hdu.header["TRACT"] = multiple_cell_coadd.common.identifiers.tract 

734 hdu.header["PATCH_X"] = multiple_cell_coadd.common.identifiers.patch.x 

735 hdu.header["PATCH_Y"] = multiple_cell_coadd.common.identifiers.patch.y 

736 hdu.header["NNOISE"] = n_noise_realizations 

737 

738 if metadata is not None: 

739 hdu.header.extend(metadata.toDict()) 

740 

741 hdu_list = fits.HDUList([primary_hdu, hdu, cell_hdu, visit_hdu]) 

742 if aperture_correction_hdu: 

743 hdu_list.append(aperture_correction_hdu) 

744 

745 hdu_list.writeto(filename, overwrite=overwrite) 

746 

747 return hdu_list