Coverage for python/lsst/images/cells/_provenance.py: 68%

196 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-10 09:12 +0000

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__ = ("CoaddProvenance", "CoaddProvenanceSerializationModel") 

15 

16from collections.abc import Iterable 

17from typing import TYPE_CHECKING, Any, ClassVar 

18 

19import astropy.table 

20import astropy.units as u 

21import numpy as np 

22import pydantic 

23 

24from .._cell_grid import CellGridBounds, CellIJ 

25from .._polygon import Polygon 

26from ..describe import DescribableMixin, DescribeOptions, FieldRole, Report, ReportField 

27from ..serialization import ArchiveTree, InputArchive, InvalidParameterError, OutputArchive, TableModel 

28 

29if TYPE_CHECKING: 

30 try: 

31 from lsst.afw.geom import Polygon as LegacyPolygon 

32 from lsst.cell_coadds import CoaddInputs as LegacyCellCoaddInputs 

33 from lsst.cell_coadds import MultipleCellCoadd as LegacyMultipleCellCoadd 

34 from lsst.cell_coadds import ObservationIdentifiers as LegacyObservationIdentifiers 

35 from lsst.skymap import Index2D as LegacyIndex2D 

36 except ImportError: 

37 type LegacyIndex2D = Any # type: ignore[no-redef] 

38 type LegacyCellCoaddInputs = Any # type: ignore[no-redef] 

39 type LegacyPolygon = Any # type: ignore[no-redef] 

40 type LegacyMultipleCellCoadd = Any # type: ignore[no-redef] 

41 type LegacyObservationIdentifiers = Any # type: ignore[no-redef] 

42 

43 

44class CoaddProvenance(DescribableMixin): 

45 """A pair of tables that record the inputs to a cell-based coadd. 

46 

47 Parameters 

48 ---------- 

49 inputs 

50 A table of {visit, detector} combinations that contribute to any cell 

51 in the coadd. 

52 contributions 

53 A table of {visit, detector, cell} combinations that describe how an 

54 observation contributed to a cell. 

55 

56 Raises 

57 ------ 

58 ValueError 

59 Raised if ``inputs`` has no rows; provenance that records no input 

60 image is not meaningful. 

61 

62 Notes 

63 ----- 

64 This object can represent the provenance of a whole patch, a single cell, 

65 or anything in between. In the single-cell case, the ``inputs`` and 

66 ``contributions`` tables have the same number of rows (but may not be 

67 ordered the same way!). 

68 """ 

69 

70 def __init__(self, inputs: astropy.table.Table, contributions: astropy.table.Table) -> None: 

71 if not len(inputs): 

72 raise ValueError("Coadd provenance must contain at least one input image.") 

73 self._inputs = inputs 

74 self._contributions = contributions 

75 

76 _INPUT_TABLE_COLUMNS: ClassVar[list[tuple[str, type, str]]] = [ 

77 ("instrument", np.object_, "Name of the instrument."), 

78 ("visit", np.uint64, "ID of the visit."), 

79 ("detector", np.uint16, "ID of the detector."), 

80 ("physical_filter", np.object_, "Full name of the bandpass filter."), 

81 ("day_obs", np.uint32, "Observation night as a YYYYMMDD integer."), 

82 ( 

83 "polygon", 

84 np.object_, 

85 ( 

86 "Polygon that approximates the overlap of the observation and the coadd patch, " 

87 "in coadd coordinates." 

88 ), 

89 ), 

90 ] 

91 

92 _CONTRIBUTION_TABLE_COLUMNS: ClassVar[list[tuple[str, type, str, u.UnitBase | None]]] = [ 

93 ("cell_i", np.uint16, "Y-axis index of the cell within the patch.", None), 

94 ("cell_j", np.uint16, "X-axis index of the cell within the patch.", None), 

95 ("instrument", np.object_, "Name of the instrument.", None), 

96 ("visit", np.uint64, "ID of the visit.", None), 

97 ("detector", np.uint16, "ID of the detector.", None), 

98 ("overlaps_center", np.bool_, "Whether a this observation overlaps the center of the cell.", None), 

99 ("overlap_fraction", np.float64, "Fraction of the cell that is covered by the overlap region.", None), 

100 ("unmasked_fraction", np.float64, "Fraction of the cell propagated to the coadd.", None), 

101 ("weight", np.float64, "Weight to be used for this input in this cell.", None), 

102 ("psf_shape_xx", np.float64, "Second order moments of the PSF.", u.pix**2), 

103 ("psf_shape_yy", np.float64, "Second order moments of the PSF.", u.pix**2), 

104 ("psf_shape_xy", np.float64, "Second order moments of the PSF.", u.pix**2), 

105 ( 

106 "psf_shape_flag", 

107 np.bool_, 

108 "Flag indicating whether the PSF shape measurement was successful.", 

109 None, 

110 ), 

111 ] 

112 

113 @classmethod 

114 def make_empty_input_table(cls, n_rows: int) -> astropy.table.Table: 

115 """Make an empty `inputs` table with a set number of rows. 

116 

117 Parameters 

118 ---------- 

119 n_rows 

120 Number of rows in the new table. 

121 """ 

122 return astropy.table.Table( 

123 [ 

124 astropy.table.Column(name=name, length=n_rows, dtype=dtype, description=description) 

125 for name, dtype, description in cls._INPUT_TABLE_COLUMNS 

126 ] 

127 ) 

128 

129 @classmethod 

130 def make_empty_contribution_table(cls, n_rows: int) -> astropy.table.Table: 

131 """Make an empty `contributions` table with a set number of rows. 

132 

133 Parameters 

134 ---------- 

135 n_rows 

136 Number of rows in the new table. 

137 """ 

138 return astropy.table.Table( 

139 [ 

140 astropy.table.Column( 

141 name=name, length=n_rows, dtype=dtype, description=description, unit=unit 

142 ) 

143 for name, dtype, description, unit in cls._CONTRIBUTION_TABLE_COLUMNS 

144 ] 

145 ) 

146 

147 @property 

148 def inputs(self) -> astropy.table.Table: 

149 """A table of {visit, detector} combinations that contribute to any 

150 cell in the coadd. 

151 """ 

152 return self._inputs 

153 

154 @property 

155 def contributions(self) -> astropy.table.Table: 

156 """A table of {visit, detector, cell} combinations that describe how an 

157 observation contributed to a cell. 

158 """ 

159 return self._contributions 

160 

161 def __getitem__(self, cell: CellIJ) -> CoaddProvenance | None: 

162 return self.subset([cell]) 

163 

164 def subset(self, cells: Iterable[CellIJ]) -> CoaddProvenance | None: 

165 """Return a new provenance object with just the given cells. 

166 

167 Parameters 

168 ---------- 

169 cells 

170 Cells to keep in the returned provenance. 

171 

172 Returns 

173 ------- 

174 subset : `CoaddProvenance` or `None` 

175 Provenance for ``cells`` only, or `None` if none of them have 

176 contributions. 

177 """ 

178 cells_to_keep = astropy.table.Table( 

179 rows=[(index.i, index.j) for index in cells], 

180 names=["cell_i", "cell_j"], 

181 dtype=[np.uint16, np.uint16], 

182 ) 

183 contributions = astropy.table.join(self._contributions, cells_to_keep) 

184 assert contributions.columns.keys() == {name for name, _, _, _ in self._CONTRIBUTION_TABLE_COLUMNS} 

185 if not len(contributions): 

186 # No inputs to derive either, and astropy.table.join rejects an 

187 # empty operand outright. 

188 return None 

189 inputs = astropy.table.join(contributions["instrument", "visit", "detector"], self._inputs) 

190 assert inputs.columns.keys() == {name for name, _, _ in self._INPUT_TABLE_COLUMNS} 

191 return CoaddProvenance(inputs=inputs, contributions=contributions) 

192 

193 def _describe( 

194 self, 

195 options: DescribeOptions = DescribeOptions(), 

196 /, 

197 *, 

198 bounds: CellGridBounds | None = None, 

199 ) -> Report: 

200 """Return a `Report` describing this provenance. 

201 

202 Parameters 

203 ---------- 

204 options : `DescribeOptions`, optional 

205 Rendering options. `DescribeOptions.brief` reports only the 

206 number of input images, which no column scan is needed to count. 

207 bounds : `CellGridBounds`, optional 

208 Cells the image this provenance belongs to has data for. When 

209 given, the number of cells with contributions is reported as a 

210 fraction of them. 

211 

212 Notes 

213 ----- 

214 The report summarizes the tables rather than rendering them. Rich 

215 renders an embedded `astropy.table.Table` as plain text and never 

216 consults the table's own ``_repr_html_``, so `inputs` and 

217 `contributions` describe themselves better than this report could, 

218 and a patch has tens of thousands of contribution rows. 

219 """ 

220 n_inputs = len(self._inputs) 

221 summary = f"CoaddProvenance({n_inputs} input image{'s' if n_inputs != 1 else ''})" 

222 if options.brief: 

223 return Report(type_name="CoaddProvenance", summary=summary) 

224 fields: list[ReportField] = [] 

225 for column in ("instrument", "physical_filter"): 

226 fields.append( 

227 ReportField( 

228 label=column, 

229 value=", ".join(sorted({str(value) for value in self._inputs[column]})), 

230 role=FieldRole.DERIVED, 

231 ) 

232 ) 

233 n_visits = len(np.unique(self._inputs["visit"])) 

234 input_images = f"{n_inputs} from {n_visits} visit{'s' if n_visits != 1 else ''}" 

235 fields.append(ReportField(label="input images", value=input_images, role=FieldRole.DERIVED)) 

236 first_night = int(self._inputs["day_obs"].min()) 

237 last_night = int(self._inputs["day_obs"].max()) 

238 fields.append( 

239 ReportField( 

240 label="day_obs", 

241 value=str(first_night) if first_night == last_night else f"{first_night} - {last_night}", 

242 role=FieldRole.DERIVED, 

243 ) 

244 ) 

245 _, counts = np.unique( 

246 np.column_stack([self._contributions["cell_i"], self._contributions["cell_j"]]), 

247 axis=0, 

248 return_counts=True, 

249 ) 

250 if bounds is not None: 

251 n_cells_with_data = bounds.subgrid_size.i * bounds.subgrid_size.j - len(bounds.missing) 

252 cells = f"{len(counts)} of {n_cells_with_data} with contributions" 

253 else: 

254 cells = f"{len(counts)} with contributions" if len(counts) else "none" 

255 fields.append(ReportField(label="cells", value=cells, role=FieldRole.DERIVED)) 

256 if len(counts): 

257 low = int(counts.min()) 

258 high = int(counts.max()) 

259 per_cell = ( 

260 f"{low} input image{'s' if low != 1 else ''}" 

261 if low == high 

262 else f"{low} - {high} input images (median {np.median(counts):g})" 

263 ) 

264 fields.append(ReportField(label="per cell", value=per_cell, role=FieldRole.DERIVED)) 

265 return Report(type_name="CoaddProvenance", summary=summary, fields=fields) 

266 

267 def serialize(self, archive: OutputArchive[Any]) -> CoaddProvenanceSerializationModel: 

268 """Serialize the provenance to an output archive. 

269 

270 Parameters 

271 ---------- 

272 archive 

273 Archive to write to. 

274 """ 

275 # The tables are exposed and mutable, so a caller can empty one after 

276 # construction. Catch that here rather than deep in the column 

277 # rewriting below, where it surfaces as "max() iterable argument is 

278 # empty". 

279 assert len(self._inputs), "Coadd provenance must contain at least one input image." 

280 inputs = self._inputs.copy(copy_data=False) 

281 contributions = self._contributions.copy(copy_data=False) 

282 instrument = CoaddProvenanceSerializationModel._fix_str_for_serialization( 

283 "instrument", inputs, contributions 

284 ) 

285 physical_filter = CoaddProvenanceSerializationModel._fix_str_for_serialization( 

286 "physical_filter", inputs 

287 ) 

288 CoaddProvenanceSerializationModel._fix_polygon_for_serialization(inputs) 

289 inputs_model = archive.add_table(inputs, name="inputs") 

290 contributions_model = archive.add_table(contributions, name="contributions") 

291 return CoaddProvenanceSerializationModel( 

292 instrument=instrument, 

293 physical_filter=physical_filter, 

294 inputs=inputs_model, 

295 contributions=contributions_model, 

296 ) 

297 

298 @staticmethod 

299 def from_legacy(legacy_cell_coadd: LegacyMultipleCellCoadd) -> CoaddProvenance: 

300 """Extract provenance from a legacy 

301 `lsst.cell_coadds.MultipleCellCoadd` object. 

302 

303 Parameters 

304 ---------- 

305 legacy_cell_coadd 

306 Legacy cell coadd to extract provenance from. 

307 """ 

308 inputs = CoaddProvenance.make_empty_input_table(len(legacy_cell_coadd.common.visit_polygons)) 

309 for n, (legacy_identifiers, legacy_polygon) in enumerate( 

310 legacy_cell_coadd.common.visit_polygons.items() 

311 ): 

312 inputs["instrument"][n] = legacy_identifiers.instrument 

313 inputs["visit"][n] = legacy_identifiers.visit 

314 inputs["detector"][n] = legacy_identifiers.detector 

315 inputs["physical_filter"][n] = legacy_identifiers.physical_filter 

316 inputs["day_obs"][n] = legacy_identifiers.day_obs 

317 inputs["polygon"][n] = Polygon.from_legacy(legacy_polygon) 

318 n_contributions = 0 

319 for legacy_cell in legacy_cell_coadd.cells.values(): 

320 n_contributions += len(legacy_cell.inputs) 

321 contributions = CoaddProvenance.make_empty_contribution_table(n_contributions) 

322 n = 0 

323 for legacy_cell in legacy_cell_coadd.cells.values(): 

324 for legacy_identifiers, legacy_inputs in legacy_cell.inputs.items(): 

325 contributions["cell_i"][n] = legacy_cell.identifiers.cell.y 

326 contributions["cell_j"][n] = legacy_cell.identifiers.cell.x 

327 contributions["instrument"][n] = legacy_identifiers.instrument 

328 contributions["visit"][n] = legacy_identifiers.visit 

329 contributions["detector"][n] = legacy_identifiers.detector 

330 contributions["overlaps_center"][n] = legacy_inputs.overlaps_center 

331 contributions["overlap_fraction"][n] = legacy_inputs.overlap_fraction 

332 contributions["unmasked_fraction"][n] = legacy_inputs.unmasked_overlap_fraction 

333 contributions["weight"][n] = legacy_inputs.weight 

334 contributions["psf_shape_xx"][n] = legacy_inputs.psf_shape.getIxx() 

335 contributions["psf_shape_yy"][n] = legacy_inputs.psf_shape.getIyy() 

336 contributions["psf_shape_xy"][n] = legacy_inputs.psf_shape.getIxy() 

337 contributions["psf_shape_flag"][n] = legacy_inputs.psf_shape_flag 

338 n += 1 

339 return CoaddProvenance(inputs=inputs, contributions=contributions) 

340 

341 def to_legacy_polygon_map(self) -> dict[LegacyObservationIdentifiers, LegacyPolygon]: 

342 """Construct a legacy mapping from 

343 `lsst.cell_coadds.ObservationIdentifiers` to `lsst.afw.geom.Polygon` 

344 from the `inputs` table. 

345 """ 

346 from lsst.cell_coadds import ObservationIdentifiers as LegacyObservationIdentifiers 

347 

348 return { 

349 LegacyObservationIdentifiers( 

350 instrument=str(row["instrument"]), 

351 physical_filter=str(row["physical_filter"]), 

352 visit=int(row["visit"]), 

353 day_obs=int(row["day_obs"]), 

354 detector=int(row["detector"]), 

355 ): row["polygon"].to_legacy() 

356 for row in self.inputs 

357 } 

358 

359 def to_legacy_cell_coadd_inputs( 

360 self, observations: Iterable[LegacyObservationIdentifiers] | None 

361 ) -> dict[LegacyIndex2D, dict[LegacyObservationIdentifiers, LegacyCellCoaddInputs]]: 

362 """Construct a mapping from legacy cell index to the list of legacy 

363 input structs for that cell. 

364 

365 Parameters 

366 ---------- 

367 observations 

368 Observations to include, or `None` to include all observations 

369 in the `inputs` table. 

370 """ 

371 from lsst.afw.geom.ellipses import Quadrupole 

372 from lsst.cell_coadds import CoaddInputs as LegacyCoaddInputs 

373 from lsst.skymap import Index2D as LegacyIndex2D 

374 

375 if observations is None: 

376 observations = self.to_legacy_polygon_map().keys() 

377 observations_by_key: dict[tuple[str, int, int], LegacyObservationIdentifiers] = { 

378 (obs.instrument, obs.visit, obs.detector): obs for obs in observations 

379 } 

380 result: dict[LegacyIndex2D, dict[LegacyObservationIdentifiers, LegacyCoaddInputs]] = {} 

381 for row in self.contributions: 

382 obs_key = (str(row["instrument"]), int(row["visit"]), int(row["detector"])) 

383 obs = observations_by_key[obs_key] 

384 cell_inputs = result.setdefault(LegacyIndex2D(x=int(row["cell_j"]), y=int(row["cell_i"])), {}) 

385 cell_inputs[obs] = LegacyCoaddInputs( 

386 overlaps_center=bool(row["overlaps_center"]), 

387 overlap_fraction=float(row["overlap_fraction"]), 

388 unmasked_overlap_fraction=float(row["unmasked_fraction"]), 

389 weight=float(row["weight"]), 

390 psf_shape=Quadrupole(row["psf_shape_xx"], row["psf_shape_yy"], row["psf_shape_xy"]), 

391 psf_shape_flag=bool(row["psf_shape_flag"]), 

392 ) 

393 return result 

394 

395 

396class CoaddProvenanceSerializationModel(ArchiveTree): 

397 """A Pydantic model used to represent a serialized `CoaddProvenance`. 

398 

399 Notes 

400 ----- 

401 We can't rewrite the Astropy tables directly into the archive (e.g. as 

402 FITS binary tables for a FITS archive), because: 

403 

404 - `str` columns are a huge pain in both Numpy and FITS; 

405 - the polygon columns need to be rewritten as array-valued columns. 

406 

407 To deal with the string columns (``instrument`` and ``physical_filter``) 

408 we do dictionary compression: we map each distinct value of those columns 

409 to an integer, and then we save that mapping to the model while saving 

410 an integer version of that column in the table. But if there is actually 

411 only one value in that column (the most common case by far) we just drop 

412 the column and store that value directly in the model. 

413 """ 

414 

415 SCHEMA_NAME: ClassVar[str] = "coadd_provenance" 

416 SCHEMA_VERSION: ClassVar[str] = "1.0.0" 

417 MIN_READ_VERSION: ClassVar[int] = 1 

418 PUBLIC_TYPE: ClassVar[type] = CoaddProvenance 

419 

420 instrument: str | dict[str, int] = pydantic.Field( 

421 description=( 

422 "Instrument name for all inputs to this coadd, or a mapping from " 

423 "instrument name to the integer used in its place in the tables." 

424 ) 

425 ) 

426 physical_filter: str | dict[str, int] = pydantic.Field( 

427 description="Physical filter name for all inputs to this coadd." 

428 ) 

429 inputs: TableModel = pydantic.Field(description="Table of all inputs to the coadd.") 

430 contributions: TableModel = pydantic.Field(description="Table of per-cell contributions to the coadd.") 

431 

432 def deserialize(self, archive: InputArchive[Any], **kwargs: Any) -> CoaddProvenance: 

433 """Deserialize a provenance from an input archive. 

434 

435 Parameters 

436 ---------- 

437 archive 

438 Archive to read from. 

439 **kwargs 

440 Unsupported keyword arguments are accepted only to provide 

441 better error messages (raising 

442 `.serialization.InvalidParameterError`). 

443 

444 Notes 

445 ----- 

446 While `CoaddProvenance.subset` can be used to filter provenance 

447 information down to just certain cells, there is no advantage to be 

448 had from doing this during deserialization (the table data is not 

449 ordered by cell, and hence there's read-slicing we can do). 

450 """ 

451 if kwargs: 451 ↛ 452line 451 didn't jump to line 452 because the condition on line 451 was never true

452 raise InvalidParameterError(f"Unrecognized parameters for CoaddProvenance: {set(kwargs.keys())}.") 

453 inputs = archive.get_table(self.inputs) 

454 contributions = archive.get_table(self.contributions) 

455 CoaddProvenanceSerializationModel._fix_str_for_deserialization( 

456 "instrument", self.instrument, inputs, contributions 

457 ) 

458 CoaddProvenanceSerializationModel._fix_str_for_deserialization( 

459 "physical_filter", self.physical_filter, inputs 

460 ) 

461 CoaddProvenanceSerializationModel._fix_polygon_for_deserialization(inputs) 

462 for name, _, description in CoaddProvenance._INPUT_TABLE_COLUMNS: 

463 inputs.columns[name].description = description 

464 for name, _, description, unit in CoaddProvenance._CONTRIBUTION_TABLE_COLUMNS: 

465 contributions.columns[name].description = description 

466 contributions.columns[name].unit = unit 

467 return CoaddProvenance(inputs=inputs, contributions=contributions) 

468 

469 @staticmethod 

470 def _fix_str_for_serialization(column: str, *tables: astropy.table.Table) -> str | dict[str, int]: 

471 """Rewrite a string column as an integer column or drop it. 

472 

473 Parameters 

474 ---------- 

475 column 

476 Name of the column to rewrite. 

477 *tables 

478 One or more astropy tables to rewrite. The first table is assumed 

479 to have all values for this column that might appear in any other 

480 tables. 

481 

482 Returns 

483 ------- 

484 `str` | `dict` [`str`, `int`] 

485 If there is only one unique value for this column in the first 

486 table, that value (and the column will have been dropped from 

487 all givne tables). If the tables are empty, the column is 

488 dropped and an empty `dict` is returned. In all other cases the 

489 given column is replaced with an integer column in all given 

490 tables and the mapping from strings to integers is returned. 

491 """ 

492 result: str | dict[str, int] = {name: n for n, name in enumerate(sorted(set(tables[0][column])))} 

493 match len(result): 

494 case 0: 494 ↛ 495line 494 didn't jump to line 495 because the pattern on line 494 never matched

495 pass 

496 case 1: 496 ↛ 498line 496 didn't jump to line 498 because the pattern on line 496 always matched

497 (result,) = result.keys() # type: ignore[union-attr] 

498 case _: 

499 for table in tables: 

500 table.columns[column] = astropy.table.Column( 

501 data=[result[k] for k in table.columns[column]], 

502 name=column, 

503 dtype=np.uint8, 

504 description=f"Integer mapped to {column} name.", 

505 ) 

506 return result 

507 # If we didn't remap to an integer (case 0 and 1 above), delete the 

508 # column. 

509 for table in tables: 

510 del table.columns[column] 

511 return result 

512 

513 @staticmethod 

514 def _fix_str_for_deserialization( 

515 column: str, value: str | dict[str, int], *tables: astropy.table.Table 

516 ) -> None: 

517 """Rewrite an integer column back to a string one. 

518 

519 Parameters 

520 ---------- 

521 column 

522 Name of the column to rewrite. 

523 value 

524 Value or mapping of values returned by 

525 `_fix_str_for_serialization`. 

526 tables 

527 Tables to rewrite this column in. 

528 """ 

529 match value: 

530 case str(): 530 ↛ 533line 530 didn't jump to line 533 because the pattern on line 530 always matched

531 for table in tables: 

532 table.columns[column] = astropy.table.Column([value] * len(table), dtype=object) 

533 case dict(): 

534 mapping = {v: k for k, v in value.items()} 

535 for table in tables: 

536 table.columns[column] = astropy.table.Column( 

537 [mapping[k] for k in table[column]], dtype=object 

538 ) 

539 

540 @staticmethod 

541 def _fix_polygon_for_serialization(inputs: astropy.table.Table) -> None: 

542 """Rewrite a polygon `object` column as a pair of array-valued columns 

543 and an array-size column. 

544 

545 Parameters 

546 ---------- 

547 inputs 

548 A copy of the in-memory coadd inputs table to modify in-place into 

549 its serialization form. 

550 """ 

551 max_n_vertices = max(p.n_vertices for p in inputs["polygon"]) 

552 inputs["n_vertices"] = astropy.table.Column( 

553 [p.n_vertices for p in inputs["polygon"]], 

554 name="n_vertices", 

555 dtype=np.uint8, 

556 description="Number of polygon vertices.", 

557 ) 

558 inputs["x_vertices"] = astropy.table.Column( 

559 name="x_vertices", 

560 dtype=np.float64, 

561 length=len(inputs), 

562 shape=(max_n_vertices,), 

563 description="X coordinates of polygon vertices, in tract coordinates.", 

564 ) 

565 inputs["x_vertices"][:, :] = np.nan 

566 inputs["y_vertices"] = astropy.table.Column( 

567 name="y_vertices", 

568 dtype=np.float64, 

569 length=len(inputs), 

570 shape=(max_n_vertices,), 

571 description="Y coordinates of polygon vertices, in tract coordinates.", 

572 ) 

573 inputs["y_vertices"][:, :] = np.nan 

574 for i, polygon in enumerate(inputs["polygon"]): 

575 inputs["n_vertices"][i] = polygon.n_vertices 

576 inputs["x_vertices"][i][: polygon.n_vertices] = polygon.x_vertices 

577 inputs["y_vertices"][i][: polygon.n_vertices] = polygon.y_vertices 

578 del inputs["polygon"] 

579 

580 @staticmethod 

581 def _fix_polygon_for_deserialization(inputs: astropy.table.Table) -> None: 

582 """Rewrite a a pair of array-valued columns and an array-size column 

583 into a polygon `object` column. 

584 

585 Parameters 

586 ---------- 

587 inputs 

588 The serialized version of the coadd inputs table, to be modified 

589 in-place into its in-memory form. 

590 """ 

591 polygons = [ 

592 Polygon(x_vertices=x_vertices[:n_vertices], y_vertices=y_vertices[:n_vertices]) 

593 for n_vertices, x_vertices, y_vertices in zip( 

594 inputs["n_vertices"], inputs["x_vertices"], inputs["y_vertices"] 

595 ) 

596 ] 

597 del inputs["n_vertices"] 

598 del inputs["x_vertices"] 

599 del inputs["y_vertices"] 

600 inputs["polygon"] = astropy.table.Column(polygons, name="polygon", dtype=np.object_)