Coverage for python/lsst/cell_coadds/_fits.py: 15%
276 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-22 01:06 -0800
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-22 01:06 -0800
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/>.
22"""Module to handle FITS serialization and de-serialization.
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.
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:
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.
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.
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.
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.
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.
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.
73Note that major version 0 is considered unstable and experimental and none of
74the guarantee above applies.
75"""
77from __future__ import annotations
79__all__ = (
80 "CellCoaddFitsFormatter",
81 "CellCoaddFitsReader",
82 "IncompatibleVersionError",
83 "writeMultipleCellCoaddAsFits",
84)
86import logging
87import os
88from collections.abc import Mapping
89from dataclasses import dataclass
90from typing import Any
92import numpy as np
93from astropy.io import fits
94from astropy.table import Table
95from frozendict import frozendict
96from numpy.typing import DTypeLike
97from packaging import version
99import lsst.afw.geom as afwGeom
100import lsst.afw.image as afwImage
101from lsst.afw.image import ImageD, ImageF
102from lsst.daf.base import PropertySet
103from lsst.geom import Box2I, Extent2I, Point2D, Point2I
104from lsst.obs.base.formatters.fitsGeneric import FitsGenericFormatter
105from lsst.skymap import Index2D
107from ._coadd_ap_corr_map import EMPTY_AP_CORR_MAP
108from ._common_components import CoaddUnits, CommonComponents
109from ._grid_container import GridContainer
110from ._identifiers import CellIdentifiers, ObservationIdentifiers, PatchIdentifiers
111from ._image_planes import OwnedImagePlanes
112from ._multiple_cell_coadd import MultipleCellCoadd
113from ._single_cell_coadd import CoaddInputs, SingleCellCoadd
114from ._uniform_grid import UniformGrid
115from .typing_helpers import SingleCellCoaddApCorrMap
117FILE_FORMAT_VERSION = "0.8"
118"""Version number for the file format as persisted, presented as a string of
119the form M.m, where M is the major version, m is the minor version.
120"""
122MAX_POLYGON_VERTICES = 6
123"""Maximum number of vertices the overlap polygon region between a per-detector
124warp and the patch bounding box can have."""
125# 3 vertices from the detector, 3 vertices from the patch bounding box.
127logger = logging.getLogger(__name__)
130class IncompatibleVersionError(RuntimeError):
131 """Exception raised when the CellCoaddFitsReader version is not compatible
132 with the FITS file attempted to read.
133 """
136@dataclass
137class VisitRecord:
138 """A dataclass to hold relevant info about a visit.
140 This is intended for use with this module.
141 """
143 visit: int
144 day_obs: int
145 physical_filter: str
148class CellCoaddFitsFormatter(FitsGenericFormatter):
149 """Interface for writing and reading cell coadds to/from FITS files.
151 This assumes the existence of readFits and writeFits methods (for now).
152 """
155class CellCoaddFitsReader:
156 """A reader class to read from a FITS file and produce cell-based coadds.
158 This reader class has read methods that can either return a single
159 component without reading the entire file (e.g., readBBox, readWcs)
160 and read methods that return a full coadd (e.g.,
161 readAsMultipleCellCoadd, readAsExplodedCellCoadd, readAsStitchedCoadd).
163 Parameters
164 ----------
165 filename : `str`
166 The name of the FITS file to read.
167 """
169 # Minimum and maximum compatible file format versions are listed as
170 # iterables so as to allow for discontiguous intervals.
171 MINIMUM_FILE_FORMAT_VERSIONS = ("0.1",)
172 MAXIMUM_FILE_FORMAT_VERSIONS = ("1.0",)
174 def __init__(self, filename: str) -> None:
175 if not os.path.exists(filename):
176 raise FileNotFoundError(f"File {filename} not found")
178 self.filename = filename
180 @classmethod
181 def isCompatibleWith(cls, written_version: str, /) -> bool:
182 """Check if the serialization version is compatible with the reader.
184 This is a convenience method to ask if the current version of this
185 class can read a file, based on the VERSION in its header.
187 Parameters
188 ----------
189 written_version: `str`
190 The VERSION of the file to be read.
192 Returns
193 -------
194 compatible : `bool`
195 Whether the reader can read a file whose VERSION is
196 ``written_version``.
198 Notes
199 -----
200 This accepts the other version as a positional argument only.
201 """
202 written_version_object = version.parse(written_version)
203 for min_version, max_version in zip(
204 cls.MINIMUM_FILE_FORMAT_VERSIONS,
205 cls.MAXIMUM_FILE_FORMAT_VERSIONS,
206 strict=True,
207 ):
208 if version.parse(min_version) <= written_version_object < version.parse(max_version):
209 return True
211 return False
213 def readAsMultipleCellCoadd(self) -> MultipleCellCoadd:
214 """Read the FITS file as a MultipleCellCoadd object.
216 Raises
217 ------
218 IncompatibleError
219 Raised if the version of this module that wrote the file is
220 incompatible with this module that is reading it in.
221 ValueError
222 Raised if the outer cell size is not padded equally in either
223 direction or by an even number of pixels.
224 """
225 with fits.open(self.filename) as hdu_list:
226 header = hdu_list[1].header
227 written_version = header.get("VERSION", "0.1")
228 if not self.isCompatibleWith(written_version):
229 raise IncompatibleVersionError(
230 f"{self.filename} was written with version {written_version}"
231 f"but attempting to read it with a reader designed for {FILE_FORMAT_VERSION}"
232 )
233 if written_version != FILE_FORMAT_VERSION:
234 logger.info(
235 "Reading %s having version %s with reader designed for %s",
236 self.filename,
237 written_version,
238 FILE_FORMAT_VERSION,
239 )
241 written_version = version.parse(written_version)
243 # TODO: Remove this when FILE_FORMAT_VERSION is bumped to 1.0
244 if written_version < version.parse("0.3"):
245 header.rename_keyword("BAND", "FILTER")
247 data = hdu_list[1].data
249 # Read in WCS
250 ps = PropertySet()
251 ps.update(hdu_list[0].header)
252 wcs = afwGeom.makeSkyWcs(ps)
254 # Build the quantities needed to construct a MultipleCellCoadd.
255 if written_version >= version.parse("0.4"):
256 coadd_units = header["TUNIT2"]
257 else:
258 logger.info("Attemping to set pixel units from TUNIT1.")
259 coadd_units = header.get("TUNIT1", CoaddUnits.legacy.name)
261 outer_cell_size = Extent2I(header["OCELL1"], header["OCELL2"])
262 psf_image_size = Extent2I(header["PSFSIZE1"], header["PSFSIZE2"])
264 grid_cell_size = Extent2I(header["GRCELL1"], header["GRCELL2"]) # Inner size of a single cell.
265 grid_shape = Extent2I(header["GRSHAPE1"], header["GRSHAPE2"])
266 grid_min = Point2I(header["GRMIN1"], header["GRMIN2"])
267 grid_padding_extent = outer_cell_size - grid_cell_size
268 # In hindsight, it would have been easier to store the padding as a
269 # separate keyword in the header instead of OCELL1 and OCELL2.
270 # This can be done when the file format is bumped to 1.0.
271 if grid_padding_extent.x != grid_padding_extent.y:
272 raise ValueError(
273 "Outer cell size is not padded equally in either directions. "
274 f"Got {outer_cell_size} and {grid_cell_size}."
275 )
276 if grid_padding_extent.x % 2 != 0:
277 raise ValueError(
278 "Outer cell size is not padded by an even number of pixels. "
279 f"Got {outer_cell_size} and {grid_cell_size}."
280 )
281 grid_padding = grid_padding_extent.x // 2
282 grid = UniformGrid(cell_size=grid_cell_size, shape=grid_shape, padding=grid_padding, min=grid_min)
284 # This is the inner bounding box for the multiple cell coadd
285 inner_bbox = Box2I(
286 Point2I(header["INBBOX11"], header["INBBOX12"]),
287 Point2I(header["INBBOX21"], header["INBBOX22"]),
288 )
290 # Attempt to get inputs for each cell.
291 inputs = GridContainer[dict[ObservationIdentifiers, CoaddInputs]](shape=grid.shape)
292 coadd_input = CoaddInputs( # default version for written_version <= 0.5.
293 overlaps_center=True,
294 overlap_fraction=1.0,
295 weight=0.0,
296 psf_shape=afwGeom.Quadrupole(),
297 psf_shape_flag=True,
298 )
299 visit_polygons = {}
300 if written_version >= version.parse("0.3"):
301 if written_version >= version.parse("0.6"):
302 visit_hdu = hdu_list[hdu_list.index_of("VISIT_SUMMARY")].data
303 else:
304 visit_hdu = hdu_list[hdu_list.index_of("VISIT")].data
306 visit_dict = {
307 row["visit"]: VisitRecord(
308 visit=row["visit"],
309 physical_filter=(
310 row["physical_filter"]
311 if written_version >= version.parse("0.8")
312 else header["FILTER"]
313 ),
314 day_obs=row["day_obs"] if written_version >= version.parse("0.8") else 00000000,
315 )
316 for row in visit_hdu
317 }
318 link_table = hdu_list[hdu_list.index_of("CELL")].data
320 if written_version >= version.parse("0.6"):
321 mask_definitions = hdu_list[hdu_list.index_of("MASKDEF")].data
322 mask_plane_dict = {}
323 for mask_name, mask_value in mask_definitions:
324 afwImage.Mask.addMaskPlane(mask_name)
325 mask_plane_dict[mask_name] = mask_value
326 else:
327 logger.warning(
328 "Mask definitions can be incorrect since this was written with "
329 "FILE_FORMAT_VERSION %s < 0.6",
330 header["VERSION"],
331 )
332 mask_plane_dict = None
334 if written_version >= version.parse("0.6"):
335 visit_summary_hdu = hdu_list[hdu_list.index_of("VISIT_SUMMARY")]
336 for row in visit_summary_hdu.data:
337 visit = int(row["visit"])
338 obs_id = ObservationIdentifiers(
339 instrument=header["INSTRUME"],
340 visit=visit,
341 detector=row["detector"],
342 day_obs=visit_dict[visit].day_obs,
343 physical_filter=visit_dict[visit].physical_filter,
344 )
345 if written_version >= version.parse("0.7"):
346 num_vertices = row["num_vertices"]
347 else:
348 num_vertices = None # li[:None] returns the entire list.
349 visit_polygons[obs_id] = afwGeom.Polygon(
350 [Point2D(vertex) for vertex in row["polygon_vertices"][:num_vertices]]
351 )
353 for link_row in link_table:
354 cell_id = Index2D(link_row["cell_x"], link_row["cell_y"])
355 visit = link_row["visit"]
356 obs_id = ObservationIdentifiers(
357 instrument=header["INSTRUME"],
358 visit=visit,
359 detector=link_row["detector"],
360 day_obs=visit_dict[visit].day_obs,
361 physical_filter=visit_dict[visit].physical_filter,
362 )
363 if written_version >= version.parse("0.6"):
364 coadd_input = CoaddInputs(
365 overlaps_center=link_row["overlaps_center"],
366 overlap_fraction=link_row["overlap_fraction"],
367 weight=link_row["weight"],
368 psf_shape=afwGeom.Quadrupole(
369 ixx=link_row["psf_shape_ixx"],
370 iyy=link_row["psf_shape_iyy"],
371 ixy=link_row["psf_shape_ixy"],
372 ),
373 psf_shape_flag=link_row["psf_shape_flag"],
374 )
376 if cell_id in inputs:
377 inputs[cell_id][obs_id] = coadd_input
378 else:
379 inputs[cell_id] = {obs_id: coadd_input}
380 else:
381 logger.info(
382 "Cell inputs are available for VERSION=0.3 or later. The file provided has ",
383 "VERSION = %s",
384 written_version,
385 )
387 try:
388 aperture_correction_hdu = hdu_list[hdu_list.index_of("APCORR")].data
389 if len(aperture_correction_hdu) < len(data):
390 logger.warning("Aperture correction map is not available for all cells.")
391 aperture_correction_grid = self._readApCorr(aperture_correction_hdu, grid_shape)
392 except KeyError:
393 if written_version >= version.parse("0.4"):
394 logger.warning("Unable to read aperture correction map from the file.")
395 # Create an empty grid container regardless.
396 aperture_correction_grid = GridContainer[SingleCellCoaddApCorrMap](shape=grid_shape)
398 common = CommonComponents(
399 units=CoaddUnits(coadd_units),
400 wcs=wcs,
401 band=header["FILTER"],
402 identifiers=PatchIdentifiers(
403 skymap=header["SKYMAP"],
404 tract=header["TRACT"],
405 patch=Index2D(x=header["PATCH_X"], y=header["PATCH_Y"]),
406 band=header["FILTER"],
407 ),
408 visit_polygons=visit_polygons,
409 )
411 coadd = MultipleCellCoadd(
412 (
413 self._readSingleCellCoadd(
414 data=data[row_id],
415 header=header,
416 common=common,
417 inputs=inputs[
418 Index2D(int(data[row_id]["cell_id"][0]), int(data[row_id]["cell_id"][1]))
419 ],
420 outer_cell_size=outer_cell_size,
421 psf_image_size=psf_image_size,
422 inner_cell_size=grid_cell_size,
423 mask_plane_dict=mask_plane_dict,
424 aperture_correction_map=aperture_correction_grid.get(
425 Index2D(
426 data[row_id]["cell_id"][0].tolist(),
427 data[row_id]["cell_id"][1].tolist(),
428 ),
429 EMPTY_AP_CORR_MAP,
430 ),
431 )
432 for row_id in range(len(data))
433 ),
434 grid=grid,
435 outer_cell_size=outer_cell_size,
436 psf_image_size=psf_image_size,
437 inner_bbox=inner_bbox,
438 common=common,
439 )
441 return coadd
443 @staticmethod
444 def _readSingleCellCoadd(
445 data: Mapping[str, Any],
446 common: CommonComponents,
447 header: Mapping[str, Any],
448 *,
449 inputs: Mapping[ObservationIdentifiers, CoaddInputs],
450 outer_cell_size: Extent2I,
451 inner_cell_size: Extent2I,
452 psf_image_size: Extent2I,
453 mask_plane_dict: Mapping[str, int] | None,
454 aperture_correction_map: SingleCellCoaddApCorrMap = EMPTY_AP_CORR_MAP,
455 ) -> SingleCellCoadd:
456 """Read a coadd from a FITS file.
458 Parameters
459 ----------
460 data : `Mapping`
461 The data from the FITS file. Usually, a single row from the binary
462 table representation.
463 common : `CommonComponents`
464 The common components of the coadd.
465 header : `Mapping`
466 The header of the FITS file as a dictionary.
467 inputs : `Mapping` [`ObservationIdentifiers`, `CoaddInputs`]
468 A mapping of ObservationIdentifiers that contributed to this cell
469 to their corresponding CoaddInputs.
470 outer_cell_size : `Extent2I`
471 The size of the outer cell.
472 psf_image_size : `Extent2I`
473 The size of the PSF image.
474 inner_cell_size : `Extent2I`
475 The size of the inner cell.
476 mask_plane_dict: `Mapping[str, int]` | None
477 A mapping defining the mask planes to their bit value.
478 aperture_correction_map : `Mapping` [`str`, `float`], optional
479 Mapping of algorithm name to aperture correction value.
480 If None, no aperture correction is applied.
482 Returns
483 -------
484 coadd : `SingleCellCoadd`
485 The coadd read from the file.
486 """
487 buffer = (outer_cell_size - inner_cell_size) // 2
489 n_noise_realizations = header.get("NNOISE", 0)
491 psf = ImageD(
492 array=data["psf"].astype(np.float64),
493 xy0=(-(psf_image_size // 2)).asPoint(), # integer division and negation do not commute.
494 ) # use the variable
495 xy0 = Point2I(
496 inner_cell_size.x * data["cell_id"][0] - buffer.x + header["GRMIN1"],
497 inner_cell_size.y * data["cell_id"][1] - buffer.y + header["GRMIN2"],
498 )
500 # Parse the mask array carefully, accounting for a change in
501 # the definitions.
502 mask = afwImage.Mask(
503 bbox=Box2I(corner=xy0, dimensions=Extent2I(outer_cell_size.x, outer_cell_size.y)),
504 initialValue=0,
505 )
506 mask_array = data["mask"].astype(np.int32)
507 if mask_plane_dict is not None:
508 for mask_name, bit_value in mask_plane_dict.items():
509 mask.array[(mask_array & 2**bit_value) > 0] |= afwImage.Mask.getPlaneBitMask(mask_name)
510 else:
511 mask.array[:, :] = mask_array
513 try:
514 maskfrac = data["maskfrac"]
515 mask_fractions = ImageF(maskfrac.astype(np.float32), xy0=xy0)
516 except KeyError:
517 mask_fractions = None
519 image_planes = OwnedImagePlanes(
520 image=ImageF(
521 data["image"].astype(np.float32),
522 xy0=xy0,
523 ),
524 mask=mask,
525 variance=ImageF(data["variance"].astype(np.float32), xy0=xy0),
526 noise_realizations=[
527 ImageF(data[f"noise_{n:02}"].astype(np.float32), xy0=xy0) for n in range(n_noise_realizations)
528 ],
529 mask_fractions=mask_fractions,
530 )
532 identifiers = CellIdentifiers(
533 cell=Index2D(data["cell_id"][0], data["cell_id"][1]),
534 skymap=common.identifiers.skymap,
535 tract=common.identifiers.tract,
536 patch=common.identifiers.patch,
537 band=common.identifiers.band,
538 )
540 return SingleCellCoadd(
541 outer=image_planes,
542 psf=psf,
543 inner_bbox=Box2I(
544 corner=Point2I(
545 inner_cell_size.x * data["cell_id"][0] + header["GRMIN1"],
546 inner_cell_size.y * data["cell_id"][1] + header["GRMIN2"],
547 ),
548 dimensions=inner_cell_size,
549 ),
550 common=common,
551 identifiers=identifiers,
552 inputs=inputs,
553 aperture_correction_map=aperture_correction_map,
554 )
556 def readWcs(self) -> afwGeom.SkyWcs:
557 """Read the WCS information from the FITS file.
559 Returns
560 -------
561 wcs : `~lsst.afw.geom.SkyWcs`
562 The WCS information read from the FITS file.
563 """
564 # Read in WCS
565 ps = PropertySet()
566 with fits.open(self.filename) as hdu_list:
567 ps.update(hdu_list[0].header)
568 wcs = afwGeom.makeSkyWcs(ps)
569 return wcs
571 def _readApCorr(
572 self, aperture_correction_hdu: fits.fitsrec.FITS_rec, grid_shape: Index2D
573 ) -> GridContainer[SingleCellCoaddApCorrMap]:
574 """Read the aperture correction map from the FITS file."""
575 column_names = aperture_correction_hdu.names
576 column_names.remove("x")
577 column_names.remove("y")
579 gc = GridContainer[SingleCellCoaddApCorrMap](shape=grid_shape)
580 for row_idx in range(len(aperture_correction_hdu)):
581 cell_id = Index2D(
582 x=aperture_correction_hdu["x"][row_idx], y=aperture_correction_hdu["y"][row_idx]
583 )
584 ap_corr_map_dict = {name: float(aperture_correction_hdu[name][row_idx]) for name in column_names}
585 gc[cell_id] = frozendict(ap_corr_map_dict)
587 return gc
590def to_numpy_record(ap_corr_map: Mapping[str, float], xy: Index2D, dtypes: DTypeLike) -> np.record:
591 """Convert the aperture correction map to a numpy record with
592 appropriate data types.
594 Parameters
595 ----------
596 ap_corr_map: `Mapping` [`str`, `float`]
597 Aperture correction values for each algorithm
598 xy: `~lsst.skymap.Index2D`
599 An object identifying the cell
600 dtypes: `~numpy.typing.DTypeLike`
601 An iterable of tuples describing the name and data type. It must
602 contain ("x", int) and ("y", int) to accommodate ``xy``.
604 Returns
605 -------
606 record : `np.record`
607 Per-cell aperture correction map as a record.
608 """
609 record = np.recarray((1,), dtype=dtypes)[0]
610 for field_name in ap_corr_map:
611 record[field_name] = ap_corr_map.get(field_name, np.nan)
613 record["x"] = xy.x
614 record["y"] = xy.y
616 return record
619def writeMultipleCellCoaddAsFits(
620 multiple_cell_coadd: MultipleCellCoadd,
621 filename: str,
622 overwrite: bool = False,
623 metadata: PropertySet | None = None,
624) -> fits.HDUList:
625 """Write a MultipleCellCoadd object to a FITS file.
627 Parameters
628 ----------
629 multiple_cell_coadd : `MultipleCellCoadd`
630 The multiple cell coadd to write to a FITS file.
631 filename : `str`
632 The name of the file to write to.
633 overwrite : `bool`, optional
634 Whether to overwrite the file if it already exists?
635 metadata : `~lsst.daf.base.PropertySet`, optional
636 Additional metadata to write to the FITS file.
638 Returns
639 -------
640 hdu_list : `~astropy.io.fits.HDUList`
641 The FITS file as an HDUList.
643 Notes
644 -----
645 Changes to this function that modify the way the file is written to disk
646 must be accompanied with a change to FILE_FORMAT_VERSION.
647 """
648 # Create metadata tables:
649 # 1. Visit table containing information about the visits.
650 # 2. Cell table containing info about the visit+detector for each cell.
651 visit_records: list[Any] = []
652 cell_records: list[Any] = []
653 instrument_set = set()
654 for cell_id, single_cell_coadd in multiple_cell_coadd.cells.items():
655 for observation_id, coadd_input in single_cell_coadd.inputs.items():
656 visit_records.append(
657 (observation_id.visit, observation_id.physical_filter, observation_id.day_obs)
658 )
659 cell_records.append(
660 (
661 cell_id.x,
662 cell_id.y,
663 observation_id.visit,
664 observation_id.detector,
665 coadd_input.overlaps_center,
666 coadd_input.overlap_fraction,
667 coadd_input.weight,
668 coadd_input.psf_shape.getIxx(),
669 coadd_input.psf_shape.getIyy(),
670 coadd_input.psf_shape.getIxy(),
671 coadd_input.psf_shape_flag,
672 )
673 )
674 instrument_set.add(observation_id.instrument)
676 assert len(instrument_set) == 1, "All cells must have the same instrument."
677 instrument = instrument_set.pop()
679 # Create visit_summary equivalent table
680 visit_column = fits.Column(
681 name="visit",
682 format="K",
683 array=[obs_id.visit for obs_id in multiple_cell_coadd.common.visit_polygons],
684 )
685 detector_column = fits.Column(
686 name="detector",
687 format="I",
688 array=[obs_id.detector for obs_id in multiple_cell_coadd.common.visit_polygons],
689 )
690 physical_filter_column = fits.Column(
691 name="physical_filter",
692 format="32A",
693 array=[obs_id.physical_filter for obs_id in multiple_cell_coadd.common.visit_polygons],
694 )
695 day_obs_column = fits.Column(
696 name="day_obs",
697 format="K",
698 array=[obs_id.day_obs for obs_id in multiple_cell_coadd.common.visit_polygons],
699 )
700 number_of_vertices = []
701 polygon_vertices_array = []
702 for obs_id, poly in multiple_cell_coadd.common.visit_polygons.items():
703 if (num_vertices := len(poly.getVertices())) > MAX_POLYGON_VERTICES:
704 logger.warning(
705 "Visit %d, detector %d has a polygon with %d vertices. "
706 "This geometry should be impossible for two intersecting "
707 "convex quadrilaterals. Only the first %d will be stored in "
708 "the FITS file.",
709 obs_id.visit,
710 obs_id.detector,
711 num_vertices,
712 MAX_POLYGON_VERTICES,
713 )
714 number_of_vertices.append(num_vertices)
715 vertices = poly.getVertices() + poly.getVertices()
716 vertices = vertices[:MAX_POLYGON_VERTICES]
717 polygon_vertices_array.append(np.array(vertices))
718 polygon_column = fits.Column(
719 name="polygon_vertices",
720 format=f"{2 * MAX_POLYGON_VERTICES}E",
721 dim=f"(2,{MAX_POLYGON_VERTICES})",
722 array=polygon_vertices_array,
723 )
724 number_of_vertices_column = fits.Column(
725 name="num_vertices",
726 format="I",
727 array=number_of_vertices,
728 )
729 visit_summary_hdu = fits.BinTableHDU.from_columns(
730 [
731 visit_column,
732 detector_column,
733 physical_filter_column,
734 day_obs_column,
735 number_of_vertices_column,
736 polygon_column,
737 ],
738 name="VISIT_SUMMARY",
739 )
740 visit_summary_hdu.header["POLYVERT"] = MAX_POLYGON_VERTICES
742 visit_recarray = np.rec.fromrecords(
743 recList=sorted(set(visit_records), key=lambda x: x[0]), # Sort by visit.
744 formats=None, # formats has specified to please mypy. See numpy#26376.
745 names=(
746 "visit",
747 "physical_filter",
748 "day_obs",
749 ),
750 )
751 cell_recarray = np.rec.fromrecords(
752 recList=cell_records,
753 formats=None, # formats has specified to please mypy. See numpy#26376.
754 names=(
755 "cell_x",
756 "cell_y",
757 "visit",
758 "detector",
759 "overlaps_center",
760 "overlap_fraction",
761 "weight",
762 "psf_shape_ixx",
763 "psf_shape_iyy",
764 "psf_shape_ixy",
765 "psf_shape_flag",
766 ),
767 )
769 visit_hdu = fits.BinTableHDU.from_columns(visit_recarray, name="VISIT")
770 cell_hdu = fits.BinTableHDU.from_columns(cell_recarray, name="CELL")
772 cell_id = fits.Column(
773 name="cell_id",
774 format="2I",
775 array=[cell.identifiers.cell for cell in multiple_cell_coadd.cells.values()],
776 )
778 image_array = [cell.outer.image.array for cell in multiple_cell_coadd.cells.values()]
779 unit_array = [cell.common.units.name for cell in multiple_cell_coadd.cells.values()]
780 image = fits.Column(
781 name="image",
782 unit=unit_array[0],
783 format=f"{image_array[0].size}E",
784 dim=f"({image_array[0].shape[1]}, {image_array[0].shape[0]})",
785 array=image_array,
786 )
788 mask_array = [cell.outer.mask.array for cell in multiple_cell_coadd.cells.values()]
789 mask = fits.Column(
790 name="mask",
791 format=f"{mask_array[0].size}I",
792 dim=f"({mask_array[0].shape[1]}, {mask_array[0].shape[0]})",
793 array=mask_array,
794 )
795 # Add in mask bit definitions as its own table.
796 mask_definition_table = Table(names=("MASK_NAME", "BIT_VALUE"), dtype=(str, np.uint32))
797 for name, value in multiple_cell_coadd.cells.arbitrary.outer.mask.getMaskPlaneDict().items():
798 mask_definition_table.add_row((name, value))
799 mask_definition_hdu = fits.BinTableHDU.from_columns(mask_definition_table.as_array(), name="MASKDEF")
801 variance_array = [cell.outer.variance.array for cell in multiple_cell_coadd.cells.values()]
802 variance = fits.Column(
803 name="variance",
804 format=f"{variance_array[0].size}E",
805 dim=f"({variance_array[0].shape[1]}, {variance_array[0].shape[0]})",
806 array=variance_array,
807 )
809 psf_array = [cell.psf_image.array for cell in multiple_cell_coadd.cells.values()]
810 psf = fits.Column(
811 name="psf",
812 format=f"{psf_array[0].size}D",
813 dim=f"({psf_array[0].shape[1]}, {psf_array[0].shape[0]})",
814 array=[cell.psf_image.array for cell in multiple_cell_coadd.cells.values()],
815 )
817 columns = [cell_id, image, mask, variance, psf]
818 maskfrac_array = [
819 cell.outer.mask_fractions.array
820 for cell in multiple_cell_coadd.cells.values()
821 if cell.outer.mask_fractions
822 ]
823 if maskfrac_array:
824 maskfrac = fits.Column(
825 name="maskfrac",
826 format=f"{maskfrac_array[0].size}E",
827 dim=f"({maskfrac_array[0].shape[1]}, {maskfrac_array[0].shape[0]})",
828 array=maskfrac_array,
829 )
830 columns.append(maskfrac)
832 n_noise_realizations = multiple_cell_coadd.n_noise_realizations
833 for n in range(n_noise_realizations):
834 noise_array = [cell.outer.noise_realizations[n].array for cell in multiple_cell_coadd.cells.values()]
835 columns.append(
836 fits.Column(
837 name=f"noise_{n:02}",
838 format=f"{noise_array[0].size}E",
839 dim=f"({noise_array[0].shape[1]}, {noise_array[0].shape[0]})",
840 array=noise_array,
841 )
842 )
844 if multiple_cell_coadd.cells.arbitrary.aperture_correction_map:
845 apcorr_fields: set[str] = set()
846 for cell in multiple_cell_coadd.cells.values():
847 if cell.aperture_correction_map:
848 apcorr_fields.update(cell.aperture_correction_map)
849 dtypes = [("x", int), ("y", int)] + [(key, float) for key in apcorr_fields]
850 aperture_correction_recarray = np.rec.fromrecords(
851 recList=[
852 to_numpy_record(cell.aperture_correction_map, cell.identifiers.cell, dtypes)
853 for cell in multiple_cell_coadd.cells.values()
854 if cell.aperture_correction_map
855 ],
856 dtype=dtypes,
857 )
858 aperture_correction_hdu = fits.BinTableHDU.from_columns(aperture_correction_recarray, name="APCORR")
859 else:
860 aperture_correction_hdu = None
862 col_defs = fits.ColDefs(columns)
863 hdu = fits.BinTableHDU.from_columns(col_defs)
865 grid_cell_size = multiple_cell_coadd.grid.cell_size
866 grid_shape = multiple_cell_coadd.grid.shape
867 grid_min = multiple_cell_coadd.grid.bbox.getMin()
868 grid_cards = {
869 "GRCELL1": grid_cell_size.x,
870 "GRCELL2": grid_cell_size.y,
871 "GRSHAPE1": grid_shape.x,
872 "GRSHAPE2": grid_shape.y,
873 "GRMIN1": grid_min.x,
874 "GRMIN2": grid_min.y,
875 }
876 hdu.header.extend(grid_cards)
878 outer_cell_size_cards = {
879 "OCELL1": multiple_cell_coadd.outer_cell_size.x,
880 "OCELL2": multiple_cell_coadd.outer_cell_size.y,
881 }
882 hdu.header.extend(outer_cell_size_cards)
884 psf_image_size_cards = {
885 "PSFSIZE1": multiple_cell_coadd.psf_image_size.x,
886 "PSFSIZE2": multiple_cell_coadd.psf_image_size.y,
887 }
888 hdu.header.extend(psf_image_size_cards)
890 inner_bbox_cards = {
891 "INBBOX11": multiple_cell_coadd.inner_bbox.minX,
892 "INBBOX12": multiple_cell_coadd.inner_bbox.minY,
893 "INBBOX21": multiple_cell_coadd.inner_bbox.maxX,
894 "INBBOX22": multiple_cell_coadd.inner_bbox.maxY,
895 }
896 hdu.header.extend(inner_bbox_cards)
898 wcs = multiple_cell_coadd.common.wcs
899 wcs_cards = wcs.getFitsMetadata().toDict()
900 primary_hdu = fits.PrimaryHDU()
901 primary_hdu.header.extend(wcs_cards)
903 hdu.header["VERSION"] = FILE_FORMAT_VERSION
904 hdu.header["TUNIT2"] = multiple_cell_coadd.common.units.name
905 hdu.header["TUNIT4"] = multiple_cell_coadd.common.units.name + "**2"
906 # This assumed to be the same as multiple_cell_coadd.common.identifers.band
907 # See DM-38843.
908 hdu.header["INSTRUME"] = instrument
909 hdu.header["FILTER"] = multiple_cell_coadd.common.band
910 hdu.header["SKYMAP"] = multiple_cell_coadd.common.identifiers.skymap
911 hdu.header["TRACT"] = multiple_cell_coadd.common.identifiers.tract
912 hdu.header["PATCH_X"] = multiple_cell_coadd.common.identifiers.patch.x
913 hdu.header["PATCH_Y"] = multiple_cell_coadd.common.identifiers.patch.y
914 hdu.header["NNOISE"] = n_noise_realizations
916 if metadata is not None:
917 hdu.header.extend(metadata.toDict())
919 hdu_list = fits.HDUList([primary_hdu, hdu, cell_hdu, visit_hdu, mask_definition_hdu, visit_summary_hdu])
920 if aperture_correction_hdu:
921 hdu_list.append(aperture_correction_hdu)
923 hdu_list.writeto(filename, overwrite=overwrite, checksum=True)
925 return hdu_list