Coverage for python/lsst/drp/tasks/metadetection_shear.py: 19%

277 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-05 08:55 +0000

1# This file is part of drp_tasks. 

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 

22from __future__ import annotations 

23 

24__all__ = ( 

25 "MetadetectionProcessingError", 

26 "MetadetectionShearConfig", 

27 "MetadetectionShearTask", 

28) 

29 

30from collections.abc import Collection, Mapping, Sequence 

31from itertools import product 

32from typing import Any, ClassVar 

33 

34import esutil as eu 

35import numpy as np 

36import pyarrow as pa 

37from metadetect.lsst.masking import apply_apodized_bright_masks_mbexp, apply_apodized_edge_masks_mbexp 

38from metadetect.lsst.metacal_exposures import STEP as SHEAR_STEP 

39from metadetect.lsst.metadetect import MetadetectTask 

40from metadetect.lsst.util import extract_multiband_coadd_data 

41 

42import lsst.geom as geom 

43import lsst.pipe.base.connectionTypes as cT 

44from lsst.afw.image import ExposureF 

45from lsst.afw.table import SimpleCatalog 

46from lsst.cell_coadds import MultipleCellCoadd, StitchedCoadd 

47from lsst.daf.butler import DataCoordinate, DatasetRef 

48from lsst.meas.algorithms import LoadReferenceObjectsConfig, ReferenceObjectLoader 

49from lsst.meas.base import FullIdGenerator, SkyMapIdGeneratorConfig 

50from lsst.pex.config import ConfigField, ConfigurableField, Field, FieldValidationError, ListField 

51from lsst.pipe.base import ( 

52 AlgorithmError, 

53 AnnotatedPartialOutputsError, 

54 InputQuantizedConnection, 

55 InvalidQuantumError, 

56 NoWorkFound, 

57 OutputQuantizedConnection, 

58 PipelineTask, 

59 PipelineTaskConfig, 

60 PipelineTaskConnections, 

61 QuantumContext, 

62 Struct, 

63) 

64from lsst.pipe.base.connectionTypes import BaseInput, Output 

65from lsst.pipe.tasks.coaddBase import makeSkyInfo 

66from lsst.skymap import BaseSkyMap, Index2D 

67 

68 

69class MetadetectionProcessingError(AlgorithmError): 

70 """Exception raised when metadetection processing fails.""" 

71 

72 @property 

73 def metadata(self) -> dict: 

74 return {} 

75 

76 

77class MetadetectionShearConnections(PipelineTaskConnections, dimensions={"patch"}): 

78 """Definitions of inputs and outputs for MetadetectionShearTask.""" 

79 

80 input_coadds = cT.Input( 

81 "deep_coadd_cell_predetection", 

82 storageClass="MultipleCellCoadd", 

83 doc="Per-band deep coadds.", 

84 multiple=True, 

85 dimensions={"patch", "band"}, 

86 ) 

87 

88 sky_map = cT.Input( 

89 doc="Cell-based skymap defining the patch structure.", 

90 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME, 

91 storageClass="SkyMap", 

92 dimensions=("skymap",), 

93 ) 

94 

95 ref_cat = cT.PrerequisiteInput( 

96 doc="Reference catalog used to mask bright objects.", 

97 name="the_monster_20250219", 

98 storageClass="SimpleCatalog", 

99 dimensions=("skypix",), 

100 deferLoad=True, 

101 multiple=True, 

102 ) 

103 

104 metadetect_catalog = cT.Output( 

105 "object_shear_patch", 

106 storageClass="ArrowTable", 

107 doc="Output catalog with all quantities measured inside the metacalibration loop.", 

108 multiple=False, 

109 dimensions={"patch"}, 

110 ) 

111 

112 metadetect_schema = cT.InitOutput( 

113 "object_shear_schema", 

114 storageClass="ArrowSchema", 

115 doc="Schema of the output catalog.", 

116 ) 

117 

118 config: MetadetectionShearConfig 

119 

120 def __init__(self, *, config=None): 

121 super().__init__(config=config) 

122 

123 if not config: 

124 return None 

125 

126 if not config.do_mask_bright_objects: 

127 del self.ref_cat 

128 

129 def adjustQuantum( 

130 self, 

131 inputs: dict[str, tuple[BaseInput, Collection[DatasetRef]]], 

132 outputs: dict[str, tuple[Output, Collection[DatasetRef]]], 

133 label: str, 

134 data_id: DataCoordinate, 

135 ) -> tuple[ 

136 Mapping[str, tuple[BaseInput, Collection[DatasetRef]]], 

137 Mapping[str, tuple[Output, Collection[DatasetRef]]], 

138 ]: 

139 # Docstring inherited. 

140 # This is a hook for customizing what is input and output to each 

141 # invocation of the task as early as possible, which we override here 

142 # to make sure we have exactly the required bands, no more, no less. 

143 connection, original_input_coadds = inputs["input_coadds"] 

144 bands_missing = set(self.config.photometry_bands) 

145 adjusted_input_coadds = [] 

146 for ref in original_input_coadds: 

147 if ref.dataId["band"] in self.config.photometry_bands: 

148 adjusted_input_coadds.append(ref) 

149 bands_missing.remove(ref.dataId["band"]) 

150 if missing_shear_bands := bands_missing.intersection(self.config.metadetect.shear_bands): 

151 raise NoWorkFound(f"Required bands {missing_shear_bands} not present for {label}@{data_id}).") 

152 adjusted_inputs = {"input_coadds": (connection, adjusted_input_coadds)} 

153 inputs.update(adjusted_inputs) 

154 super().adjustQuantum(inputs, outputs, label, data_id) 

155 return adjusted_inputs, {} 

156 

157 

158class MetadetectionShearConfig(PipelineTaskConfig, pipelineConnections=MetadetectionShearConnections): 

159 """Configuration definition for MetadetectionShearTask.""" 

160 

161 metadetect = ConfigurableField( 

162 target=MetadetectTask, 

163 doc="Configuration for metadetection.", 

164 ) 

165 

166 photometry_bands = ListField[str]( 

167 "Bands expected to be present. Cells with one or more of these bands " 

168 "missing will be skipped. Bands other than those listed here will " 

169 "not be processed.", 

170 default=["g", "r", "i", "z"], 

171 ) 

172 

173 do_mask_bright_objects = Field[bool]( 

174 doc="Mask bright objects in coadds?", 

175 default=False, 

176 ) 

177 

178 ref_loader = ConfigField( 

179 dtype=LoadReferenceObjectsConfig, 

180 doc="Reference object loader used for bright-object masking.", 

181 ) 

182 

183 ref_loader_filter_name = Field[str]( 

184 "Filter name from ref_loader used for bright-object masking.", 

185 default="monster_DES_r", 

186 ) 

187 

188 border = Field[int]( 

189 "Border to apply to single cell images, if skymap has no cell borders", 

190 default=50, 

191 ) 

192 

193 id_generator = SkyMapIdGeneratorConfig.make_field() 

194 

195 def setDefaults(self): 

196 super().setDefaults() 

197 self.metadetect.shear_bands = ["r", "i", "z"] 

198 self.metadetect.metacal.types = ["noshear", "1p", "1m", "2p", "2m"] 

199 

200 def validate(self): 

201 super().validate() 

202 if (shear_bands := self.metadetect.shear_bands) is not None and not set(shear_bands).issubset( 

203 self.photometry_bands 

204 ): 

205 raise FieldValidationError( 

206 self.__class__.metadetect, 

207 self, 

208 "photometry_bands must be a list of bands that is a superset of metadetect.shear_bands", 

209 ) 

210 

211 

212class MetadetectionShearTask(PipelineTask): 

213 """A PipelineTask that measures shear using metadetection.""" 

214 

215 _DefaultName: ClassVar[str] = "metadetectionShear" 

216 ConfigClass: ClassVar[type[MetadetectionShearConfig]] = MetadetectionShearConfig 

217 

218 config: MetadetectionShearConfig 

219 

220 def __init__(self, *, initInputs: dict[str, Any] | None = None, **kwargs: Any): 

221 super().__init__(initInputs=initInputs, **kwargs) 

222 self.metadetect_schema = self.make_metadetect_schema(self.config) 

223 self.makeSubtask("metadetect") 

224 

225 @classmethod 

226 def make_metadetect_schema(cls, config: MetadetectionShearConfig) -> pa.Schema: 

227 """Construct a PyArrow Schema for this task's main output catalog. 

228 

229 Parameters 

230 ---------- 

231 config : `MetadetectionShearConfig` 

232 Configuration that may be used to control details of the schema. 

233 

234 Returns 

235 ------- 

236 object_schema : `pyarrow.Schema` 

237 Schema for the object catalog produced by this task. Each field's 

238 metadata should include both a 'doc' entry and a 'unit' entry. 

239 """ 

240 pa_schema = pa.schema( 

241 [ 

242 # Fields from pipeline bookkeeping. 

243 pa.field( 

244 "shearObjectId", 

245 pa.int64(), 

246 nullable=False, 

247 metadata={ 

248 "doc": ( 

249 "Unique identifier for a ShearObject, specific " 

250 "to a single metacalibration counterfactual image." 

251 ), 

252 "unit": "", 

253 }, 

254 ), 

255 pa.field( 

256 "tract", 

257 pa.int64(), 

258 nullable=False, 

259 metadata={ 

260 "doc": "ID of the tract on which this measurement was made.", 

261 "unit": "", 

262 }, 

263 ), 

264 pa.field( 

265 "patch", 

266 pa.int64(), 

267 nullable=False, 

268 metadata={ 

269 "doc": "ID of the patch within the tract on which this measurement was made.", 

270 "unit": "", 

271 }, 

272 ), 

273 pa.field( 

274 "cell_x", 

275 pa.int32(), 

276 nullable=False, 

277 metadata={ 

278 "doc": "Column of the cell within the patch on which this measurement was made.", 

279 "unit": "", 

280 }, 

281 ), 

282 pa.field( 

283 "cell_y", 

284 pa.int32(), 

285 nullable=False, 

286 metadata={ 

287 "doc": "Row of the cell within the patch on which this measurement was made.", 

288 "unit": "", 

289 }, 

290 ), 

291 pa.field( 

292 "is_cell_inner", 

293 pa.bool_(), 

294 nullable=False, 

295 metadata={ 

296 "doc": "Whether this object is within the inner region of the cell.", 

297 "unit": "", 

298 }, 

299 ), 

300 pa.field( 

301 "is_patch_inner", 

302 pa.bool_(), 

303 nullable=False, 

304 metadata={ 

305 "doc": "Whether this object is within the inner region of the patch.", 

306 "unit": "", 

307 }, 

308 ), 

309 pa.field( 

310 "is_tract_inner", 

311 pa.bool_(), 

312 nullable=False, 

313 metadata={ 

314 "doc": "Whether this object is within the inner region of the tract.", 

315 "unit": "", 

316 }, 

317 ), 

318 pa.field( 

319 "is_primary", 

320 pa.bool_(), 

321 nullable=False, 

322 metadata={ 

323 "doc": "Whether this object is to be selected after de-duplication", 

324 "unit": "", 

325 }, 

326 ), 

327 # Fields from metadetection (generic). 

328 pa.field( 

329 "metaStep", 

330 pa.string(), 

331 nullable=False, 

332 metadata={ 

333 "doc": ( 

334 "Type of artificial shear applied to image. " 

335 "One of: 'ns', '1p', '1m', '2p', '2m'." 

336 ), 

337 "unit": "", 

338 }, 

339 ), 

340 pa.field( 

341 "image_flags", 

342 pa.int32(), 

343 nullable=False, 

344 metadata={ 

345 "doc": "Flags for the image on which this measurement was made.", 

346 "unit": "", 

347 }, 

348 ), 

349 pa.field( 

350 "x", 

351 pa.float32(), 

352 nullable=False, 

353 metadata={ 

354 "doc": "Centroid (tract, x-axis) of the detected ShearObject.", 

355 "unit": "", 

356 }, 

357 ), 

358 pa.field( 

359 "y", 

360 pa.float32(), 

361 nullable=False, 

362 metadata={ 

363 "doc": "Centroid (tract, y-axis) of the detected ShearObject.", 

364 "unit": "", 

365 }, 

366 ), 

367 pa.field( 

368 "ra", 

369 pa.float64(), 

370 nullable=False, 

371 metadata={ 

372 "doc": "Detected Right Ascension of the ShearObject.", 

373 "unit": "degrees", 

374 }, 

375 ), 

376 pa.field( 

377 "dec", 

378 pa.float64(), 

379 nullable=False, 

380 metadata={ 

381 "doc": "Detected Declination of the ShearObject.", 

382 "unit": "degrees", 

383 }, 

384 ), 

385 # Original PSF measurements 

386 pa.field( 

387 "psfOriginal_flags", 

388 pa.int32(), 

389 nullable=False, 

390 metadata={ 

391 "doc": "Flags for the original PSF measurement.", 

392 "unit": "", 

393 }, 

394 ), 

395 pa.field( 

396 "psfOriginal_e1", 

397 pa.float32(), 

398 nullable=False, 

399 metadata={ 

400 "doc": "Distortion-style e1 of the original PSF from adaptive moments.", 

401 "unit": "", 

402 }, 

403 ), 

404 pa.field( 

405 "psfOriginal_e2", 

406 pa.float32(), 

407 nullable=False, 

408 metadata={ 

409 "doc": "Distortion-style e2 of the original PSF from adaptive moments.", 

410 "unit": "", 

411 }, 

412 ), 

413 pa.field( 

414 "psfOriginal_T", 

415 pa.float32(), 

416 nullable=False, 

417 metadata={ 

418 "doc": "Trace (<x^2> + <y^2>) measurement of the original PSF from adaptive moments.", 

419 "unit": "arcseconds squared", 

420 }, 

421 ), 

422 pa.field( 

423 "bmask_flags", 

424 pa.int32(), 

425 nullable=False, 

426 metadata={ 

427 "doc": "`bmask` flags for the ShearObject", 

428 "unit": "", 

429 }, 

430 ), 

431 pa.field( 

432 "ormask_flags", 

433 pa.int32(), 

434 nullable=False, 

435 metadata={ 

436 "doc": "`ored` mask flags for the ShearObject", 

437 "unit": "", 

438 }, 

439 ), 

440 pa.field( 

441 "mfrac", 

442 pa.float32(), 

443 nullable=False, 

444 metadata={ 

445 "doc": "Gaussian-weighted masked fraction for the ShearObject.", 

446 "unit": "", 

447 }, 

448 ), 

449 # Fields that come only from gauss algorithm. 

450 # Reconvolved PSF measurements (gauss) 

451 pa.field( 

452 "gauss_psfReconvolved_flags", 

453 pa.int32(), 

454 nullable=False, 

455 metadata={ 

456 "doc": "Flags for reconvolved PSF (measured with gauss algorithm).", 

457 "unit": "", 

458 }, 

459 ), 

460 pa.field( 

461 "gauss_psfReconvolved_g1", 

462 pa.float32(), 

463 nullable=False, 

464 metadata={ 

465 "doc": "Reduced-shear g1 of the reconvolved PSF (measured with gauss algorithm).", 

466 "unit": "", 

467 }, 

468 ), 

469 pa.field( 

470 "gauss_psfReconvolved_g2", 

471 pa.float32(), 

472 nullable=False, 

473 metadata={ 

474 "doc": "Reduced-shear g2 of the reconvolved PSF (measured with gauss algorithm).", 

475 "unit": "", 

476 }, 

477 ), 

478 pa.field( 

479 "gauss_psfReconvolved_T", 

480 pa.float32(), 

481 nullable=False, 

482 metadata={ 

483 "doc": ( 

484 "Trace (<x^2> + <y^2>) of the reconvolved PSF (measured with gauss algorithm)." 

485 ), 

486 "unit": "arcseconds squared", 

487 }, 

488 ), 

489 # Object measurements (gauss algorithm). 

490 pa.field( 

491 "gauss_g1", 

492 pa.float32(), 

493 nullable=False, 

494 metadata={ 

495 "doc": ( 

496 "Reduced-shear g1 measurement of the ShearObject " 

497 "(measured with gauss algorithm)." 

498 ), 

499 "unit": "", 

500 }, 

501 ), 

502 pa.field( 

503 "gauss_g2", 

504 pa.float32(), 

505 nullable=False, 

506 metadata={ 

507 "doc": ( 

508 "Reduced-shear g2 measurement of the ShearObject " 

509 "(measured with gauss algorithm)." 

510 ), 

511 "unit": "", 

512 }, 

513 ), 

514 pa.field( 

515 "gauss_g1_g1_Cov", 

516 pa.float32(), 

517 nullable=False, 

518 metadata={ 

519 "doc": ( 

520 "Auto-covariance of g1 measurement of the ShearObject " 

521 "(measured with gauss algorithm)." 

522 ), 

523 "unit": "", 

524 }, 

525 ), 

526 pa.field( 

527 "gauss_g1_g2_Cov", 

528 pa.float32(), 

529 nullable=False, 

530 metadata={ 

531 "doc": ( 

532 "Cross-covariance of g1 and g2 measurement of the ShearObject " 

533 "(measured with gauss algorithm)." 

534 ), 

535 "unit": "", 

536 }, 

537 ), 

538 pa.field( 

539 "gauss_g2_g2_Cov", 

540 pa.float32(), 

541 nullable=False, 

542 metadata={ 

543 "doc": ( 

544 "Auto-covariance of g2 measurement of the ShearObject " 

545 "(measured with gauss algorithm)." 

546 ), 

547 "unit": "", 

548 }, 

549 ), 

550 ], 

551 metadata={ 

552 "shear_step": str(SHEAR_STEP), 

553 "shear_bands": "".join(sorted(config.metadetect.shear_bands)), 

554 }, 

555 ) 

556 

557 for alg_name in ("gauss", "pgauss"): 

558 pa_schema = pa_schema.append( 

559 pa.field( 

560 f"{alg_name}_snr", 

561 pa.float32(), 

562 nullable=False, 

563 metadata={ 

564 "doc": ( 

565 "Signal-to-noise ratio measure of the ShearObject " 

566 f"(measured with {alg_name} algorithm)." 

567 ), 

568 "unit": "", 

569 }, 

570 ), 

571 ) 

572 pa_schema = pa_schema.append( 

573 pa.field( 

574 f"{alg_name}_T", 

575 pa.float32(), 

576 nullable=False, 

577 metadata={ 

578 "doc": ( 

579 "Trace (<x^2> + <y^2>) measurement of the ShearObject " 

580 f"(measured with {alg_name} algorithm)." 

581 ), 

582 "unit": "arcseconds squared", 

583 }, 

584 ), 

585 ) 

586 pa_schema = pa_schema.append( 

587 pa.field( 

588 f"{alg_name}_TErr", 

589 pa.float32(), 

590 nullable=False, 

591 metadata={ 

592 "doc": ( 

593 "Uncertainty in the trace measurement of the ShearObject " 

594 f"(measured with {alg_name} algorithm)." 

595 ), 

596 "unit": "arcseconds squared", 

597 }, 

598 ), 

599 ) 

600 pa_schema = pa_schema.append( 

601 pa.field( 

602 f"{alg_name}_shape_flags", 

603 pa.int32(), 

604 nullable=False, 

605 metadata={ 

606 "doc": ( 

607 "Flags for the second order moments measurement of the ShearObject " 

608 f"(measured with {alg_name} algorithm)." 

609 ), 

610 "unit": "", 

611 }, 

612 ), 

613 ) 

614 pa_schema = pa_schema.append( 

615 pa.field( 

616 f"{alg_name}_object_flags", 

617 pa.int32(), 

618 nullable=False, 

619 metadata={ 

620 "doc": f"Flags for the ShearObject measurement (measured with {alg_name} algorithm).", 

621 "unit": "", 

622 }, 

623 ), 

624 ) 

625 pa_schema = pa_schema.append( 

626 pa.field( 

627 f"{alg_name}_flags", 

628 pa.int32(), 

629 nullable=False, 

630 metadata={ 

631 "doc": f"Overall flags for {alg_name} measurement algorithm.", 

632 "unit": "", 

633 }, 

634 ), 

635 ) 

636 

637 # Per-band quantities, typically fluxes and associated quantites. 

638 for b in config.photometry_bands: 

639 pa_schema = pa_schema.append( 

640 pa.field( 

641 f"{b}_{alg_name}Flux_flags", 

642 pa.int32(), 

643 nullable=False, 

644 metadata={ 

645 "doc": f"Flags set for flux in {b} band measured with {alg_name} algorithm.", 

646 "unit": "", 

647 }, 

648 ), 

649 ) 

650 pa_schema = pa_schema.append( 

651 pa.field( 

652 f"{b}_{alg_name}Flux", 

653 pa.float32(), 

654 nullable=b not in config.metadetect.shear_bands, 

655 metadata={ 

656 "doc": f"Flux in {b} band (measured with {alg_name} algorithm).", 

657 "unit": "", 

658 }, 

659 ), 

660 ) 

661 pa_schema = pa_schema.append( 

662 pa.field( 

663 f"{b}_{alg_name}FluxErr", 

664 pa.float32(), 

665 nullable=b not in config.metadetect.shear_bands, 

666 metadata={ 

667 "doc": f"Flux uncertainty in {b} band (measured with {alg_name} algorithm).", 

668 "unit": "", 

669 }, 

670 ), 

671 ) 

672 

673 return pa_schema 

674 

675 def validate_skymap_config(self, skymap_config: BaseSkyMap.ConfigClass) -> None: 

676 if not skymap_config.tractBuilder.name == "cells": 

677 raise InvalidQuantumError("MetadetectionShearTask requires a cell-based skymap.") 

678 

679 cell_config = skymap_config.tractBuilder.active 

680 if (self.config.border == 0 and cell_config.cellBorder == 0) or ( 

681 self.config.border > 0 and cell_config.cellBorder > 0 

682 ): 

683 raise InvalidQuantumError( 

684 "MetadetectionShearTask requires a positive border to be set either in the skymap config " 

685 "or in the task config (but not in both)." 

686 ) 

687 

688 if self.config.border: 

689 # In case cellInnerDimensions are different in different directions 

690 # (which is rare in practice), take the min of it 

691 cell_inner_dimensions = min(cell_config.cellInnerDimensions) 

692 if self.config.border > ( 

693 max_border_value := cell_config.numCellsInPatchBorder * cell_inner_dimensions 

694 ): 

695 raise InvalidQuantumError( 

696 "The border value is too large for the skymap configuration. " 

697 f"Maximum border value is {max_border_value}." 

698 ) 

699 

700 # Ensure that the amount by which we withdraw inwards does not 

701 # create gaps at tract boundaries. 

702 # tractOverlap is specified in degrees. 

703 if ( 

704 skymap_config.tractOverlap * 3600 / skymap_config.pixelScale 

705 < self.count_cells_along_edges(skymap_config) * cell_inner_dimensions + cell_config.cellBorder 

706 ): 

707 raise InvalidQuantumError( 

708 "The tract overlap is insufficient given the borders. " 

709 "This will result in missed regions between adjacent tracts." 

710 ) 

711 

712 @staticmethod 

713 def count_cells_along_edges(skymap_config: BaseSkyMap.ConfigClass) -> int: 

714 """Count the number of cells along the edges to skip processing.""" 

715 if skymap_config.tractBuilder["cells"].cellBorder > 0: 

716 return 0 

717 

718 return skymap_config.tractBuilder["cells"].numCellsInPatchBorder 

719 

720 def runQuantum( 

721 self, 

722 qc: QuantumContext, 

723 inputRefs: InputQuantizedConnection, 

724 outputRefs: OutputQuantizedConnection, 

725 ) -> None: 

726 # Docstring inherited. 

727 

728 # Get the skyMap for this quantum 

729 sky_map = qc.get(inputRefs.sky_map) 

730 

731 self.validate_skymap_config(sky_map.config) 

732 

733 id_generator = self.config.id_generator.apply(qc.quantum.dataId) 

734 

735 if self.config.do_mask_bright_objects: 

736 ref_loader = ReferenceObjectLoader( 

737 dataIds=[ref.datasetRef.dataId for ref in inputRefs.ref_cat], 

738 refCats=[qc.get(ref) for ref in inputRefs.ref_cat], 

739 name=self.config.connections.ref_cat, 

740 config=self.config.ref_loader, 

741 log=self.log, 

742 ) 

743 ref_cat = ref_loader.loadRegion( 

744 qc.quantum.dataId.region, filterName=self.config.ref_loader_filter_name 

745 ) 

746 else: 

747 ref_cat = None 

748 

749 # Read the coadds and put them in the order defined by 

750 # config.photometry_bands (note that each MultipleCellCoadd object also 

751 # knows its own band, if that's needed). 

752 

753 coadds_by_band = { 

754 ref.dataId["band"]: qc.get(ref) 

755 for ref in inputRefs.input_coadds 

756 if ref.dataId["band"] in self.config.photometry_bands 

757 } 

758 

759 try: 

760 outputs = self.run( 

761 patch_coadds=coadds_by_band, 

762 id_generator=id_generator, 

763 sky_map=sky_map, 

764 ref_cat=ref_cat, 

765 ) 

766 except AlgorithmError as err: 

767 # We know there are no actual outputs in this case, but this is 

768 # still the right exception to raise (it's just badly named). 

769 raise AnnotatedPartialOutputsError.annotate(err, self, log=self.log) from err 

770 qc.put(outputs, outputRefs) 

771 

772 def run( 

773 self, 

774 *, 

775 patch_coadds: Mapping[str, MultipleCellCoadd], 

776 id_generator: FullIdGenerator, 

777 sky_map: BaseSkyMap, 

778 ref_cat: SimpleCatalog | None, 

779 ) -> Struct: 

780 """Run metadetection on a patch. 

781 

782 Parameters 

783 ---------- 

784 patch_coadds : `~collections.abc.Mapping` [ \ 

785 `~lsst.cell_coadds.MultipleCellCoadd` ] 

786 Per-band, per-patch coadds, in the order specified by 

787 `MetadetectionShearConfig.photometry_bands`. 

788 id_generator : `~lsst.meas.base.FullIdGenerator` 

789 Generator for object IDs and to seed the random number generator. 

790 sky_map : `~lsst.skymap.BaseSkyMap` 

791 Sky map to use for determining the patch boundaries. 

792 ref_cat : `lsst.afw.table.SimpleCatalog`, optional 

793 Reference catalog to use when masking bright stars. 

794 

795 Returns 

796 ------- 

797 results : `lsst.pipe.base.Struct` 

798 Structure with the following attributes: 

799 

800 - ``metadetect_catalog`` [ `pyarrow.Table` ]: the output object 

801 catalog for the patch, with schema equal to `metadetect_schema`. 

802 """ 

803 seed = id_generator.catalog_id 

804 self.rng = np.random.RandomState(seed) 

805 idstart = 0 

806 

807 sky_info = makeSkyInfo( 

808 sky_map, 

809 tractId=id_generator.data_id.tract.id, 

810 patchId=id_generator.data_id.patch.id, 

811 ) 

812 # Since cells have no borders, we cannot apply the metacal 

813 # procedure to the edge cells in the same way as inner cells. 

814 # We can do so for all that cells that are marked as borders, 

815 # but it has to be at least 1. 

816 if (num_edge_cells_skip := sky_map.config.tractBuilder.active.numCellsInPatchBorder) < 1: 

817 raise InvalidQuantumError("No border cells found in the skymap configuration.") 

818 

819 dilate_by = self.config.border or sky_map.config.tractBuilder.active.cellBorder 

820 

821 grid = patch_coadds[self.config.metadetect.shear_bands[0]].grid 

822 # Undo any padding that was applied when creating the grid. 

823 # We only support metadetection on non-overlapping single cells. 

824 # lsst_cells_v1 has overlapping cells, so we need to undo the padding. 

825 grid = grid.from_bbox_shape(bbox=grid.bbox, shape=grid.shape, padding=0) 

826 

827 nx_cells, ny_cells = grid.shape 

828 single_cell_tables: list[pa.Table] = [] 

829 for nx, ny in product( 

830 range(num_edge_cells_skip, nx_cells - num_edge_cells_skip), 

831 range(num_edge_cells_skip, ny_cells - num_edge_cells_skip), 

832 ): 

833 cell_id = Index2D(nx, ny) 

834 bbox = grid.bbox_of(cell_id).dilatedBy(dilate_by) 

835 cell_coadds = [patch_coadd.stitch(bbox) for patch_coadd in patch_coadds.values()] 

836 self.log.debug("Processing cell %s %s", nx, ny) 

837 

838 try: 

839 res = self.process_cell(cell_coadds, cell_id=cell_id) 

840 except Exception as e: 

841 self.log.error("Failed to process cell %s %s: %s", nx, ny, e) 

842 continue 

843 

844 if len(res) > 0: 

845 res["id"] = id_generator.arange(idstart, idstart + len(res)) 

846 # TODO: Avoid back and forth conversion between array and dict. 

847 da = self._dictify( 

848 res, 

849 tract=id_generator.data_id.tract.id, 

850 patch=id_generator.data_id.patch.id, 

851 ) 

852 

853 self._fill_is_inner(da, sky_info.patchInfo[cell_id].inner_bbox, "is_cell_inner") 

854 self._fill_is_inner(da, sky_info.patchInfo.inner_bbox, "is_patch_inner") 

855 # Calculating is_tract_inner follows a different logic with 

856 # regions instead of boxes. 

857 da["is_tract_inner"] = sky_info.tractInfo.inner_sky_region.contains( 

858 da["ra"] * geom.degrees, 

859 da["dec"] * geom.degrees, 

860 ) 

861 da["is_primary"] = da["is_cell_inner"] & da["is_patch_inner"] & da["is_tract_inner"] 

862 

863 table = pa.Table.from_pydict(da, self.metadetect_schema) 

864 

865 single_cell_tables.append(table) 

866 idstart += len(res) 

867 

868 if not single_cell_tables: 

869 raise MetadetectionProcessingError("No objects found in any cell") 

870 

871 # TODO: DM-53796 De-duplicate objects before concatenation. 

872 return Struct( 

873 metadetect_catalog=pa.concat_tables(single_cell_tables), 

874 ) 

875 

876 def process_cell( 

877 self, 

878 cell_coadds: Sequence[StitchedCoadd], 

879 cell_id: Index2D, 

880 ) -> pa.Table: 

881 """Run metadetection on a single cell. 

882 

883 Parameters 

884 ---------- 

885 cell_coadds : `~collections.abc.Sequence` [ \ 

886 `~lsst.cell_coadds.StitchedCoadd` ] 

887 Per-band, per-cell coadds, in the order specified by 

888 `MetadetectionShearConfig.photometry_bands`. 

889 cell_id : `~lsst.skymap.Index2D` 

890 The cell ID for the cell being processed. 

891 

892 Returns 

893 ------- 

894 metadetect_catalog : `pyarrow.Table` 

895 Output object catalog for the cell, with schema equal to 

896 `metadetect_schema`. 

897 """ 

898 

899 coadd_data = self._cell_to_coadd_data(cell_coadds) 

900 # TODO get bright star etc. info as input 

901 bright_info = [] 

902 

903 apply_apodized_edge_masks_mbexp(**coadd_data) 

904 

905 if len(bright_info) > 0: 

906 apply_apodized_bright_masks_mbexp(bright_info=bright_info, **coadd_data) 

907 

908 mask_frac = _get_mask_frac( 

909 coadd_data["mfrac_mbexp"], 

910 trim_pixels=0, 

911 ) 

912 

913 res = self.metadetect.run(rng=self.rng, **coadd_data) 

914 

915 comb_res = _make_comb_data( 

916 cell_coadd=cell_coadds[0], 

917 res=res, 

918 mask_frac=mask_frac, 

919 bands=[cell_coadd.band for cell_coadd in cell_coadds], 

920 cell_id=cell_id, 

921 ) 

922 

923 return comb_res 

924 

925 @staticmethod 

926 def _cell_to_coadd_data(cell_coadds: Sequence[StitchedCoadd]): 

927 coadd_data_list = [] 

928 for cell_coadd in cell_coadds: 

929 coadd_data = {} 

930 coadd_data["coadd_exp"] = cell_coadd.asExposure() 

931 coadd_data["coadd_noise_exp"] = cell_coadd.asExposure(noise_index=0) 

932 coadd_data["coadd_mfrac_exp"] = ExposureF(coadd_data["coadd_exp"], deep=True) 

933 coadd_data["coadd_mfrac_exp"].image = cell_coadd.mask_fractions 

934 coadd_data_list.append(coadd_data) 

935 

936 return extract_multiband_coadd_data(coadd_data_list) 

937 

938 def _dictify(self, data, tract: int, patch: int): 

939 output = {} 

940 # TODO: Move this to a better location after DP2. 

941 mapping = { 

942 "bmask": "bmask_flags", 

943 "cell_x": "cell_x", 

944 "cell_y": "cell_y", 

945 "col": "x", 

946 "col_diff": "x_offset", # dropped. 

947 "dec": "dec", 

948 "gauss_flags": "gauss_flags", 

949 "gauss_g_1": "gauss_g1", 

950 "gauss_g_2": "gauss_g2", 

951 "gauss_g_cov_11": "gauss_g1_g1_Cov", 

952 "gauss_g_cov_12": "gauss_g1_g2_Cov", # same as 21. 

953 "gauss_g_cov_22": "gauss_g2_g2_Cov", 

954 "gauss_obj_flags": "gauss_object_flags", 

955 "gauss_psf_flags": "gauss_psfReconvolved_flags", 

956 "gauss_psf_g_1": "gauss_psfReconvolved_g1", 

957 "gauss_psf_g_2": "gauss_psfReconvolved_g2", 

958 "gauss_psf_T": "gauss_psfReconvolved_T", 

959 "gauss_s2n": "gauss_snr", 

960 "gauss_T": "gauss_T", 

961 "gauss_T_err": "gauss_TErr", 

962 "gauss_T_flags": "gauss_shape_flags", 

963 "gauss_T_ratio": "gauss_T_ratio", # dropped. 

964 "id": "shearObjectId", 

965 "mfrac": "mfrac", 

966 "ormask": "ormask_flags", 

967 "pgauss_flags": "pgauss_flags", 

968 "pgauss_obj_flags": "pgauss_object_flags", 

969 "pgauss_s2n": "pgauss_snr", 

970 "pgauss_T": "pgauss_T", 

971 "pgauss_T_err": "pgauss_TErr", 

972 "pgauss_T_flags": "pgauss_shape_flags", 

973 "pgauss_T_ratio": "pgauss_T_ratio", # dropped. 

974 "psfrec_flags": "psfOriginal_flags", 

975 "psfrec_g_1": "psfOriginal_e1", 

976 "psfrec_g_2": "psfOriginal_e2", 

977 "psfrec_T": "psfOriginal_T", 

978 "ra": "ra", 

979 "row": "y", 

980 "row_diff": "y_offset", # dropped. 

981 "shear_type": "metaStep", 

982 "stamp_flags": "image_flags", 

983 } 

984 

985 for b, alg_name in product(self.config.photometry_bands, ("gauss", "pgauss")): 

986 mapping[f"{alg_name}_band_flux_{b}"] = f"{b}_{alg_name}Flux" 

987 mapping[f"{alg_name}_band_flux_err_{b}"] = f"{b}_{alg_name}FluxErr" 

988 mapping[f"{alg_name}_band_flux_flags_{b}"] = f"{b}_{alg_name}Flux_flags" 

989 

990 for name in mapping: 

991 if name in data.dtype.names: 

992 output[mapping.get(name, name)] = data[name] 

993 else: 

994 if "flags" in name.lower(): 

995 output[mapping.get(name, name)] = np.ones_like(data["id"], dtype=np.int32) 

996 else: 

997 output[mapping.get(name, name)] = np.ones_like(data["id"], dtype=np.float32) 

998 output[mapping.get(name, name)] *= np.nan 

999 

1000 output["tract"] = tract * np.ones_like(data["id"], dtype=np.int64) 

1001 output["patch"] = patch * np.ones_like(data["id"], dtype=np.int32) 

1002 

1003 return output 

1004 

1005 @staticmethod 

1006 def _fill_is_inner( 

1007 di: dict, 

1008 bbox: geom.Box2I, 

1009 key: str, 

1010 ) -> None: 

1011 # Convert it to a Box2D object so the full pixels are considered, not 

1012 # just the pixel centers. 

1013 bbox = geom.Box2D(bbox) 

1014 xmin = bbox.getMinX() 

1015 ymin = bbox.getMinY() 

1016 xmax = bbox.getMaxX() 

1017 ymax = bbox.getMaxY() 

1018 di[key] = (di["x"] >= xmin) & (di["x"] <= xmax) & (di["y"] >= ymin) & (di["y"] <= ymax) 

1019 

1020 

1021def _make_comb_data( 

1022 cell_coadd, 

1023 res, 

1024 mask_frac, 

1025 bands, 

1026 cell_id, 

1027): 

1028 idinfo = cell_coadd.identifiers 

1029 

1030 copy_dt = [ 

1031 # we will copy out of arrays to these 

1032 ("psfrec_g_1", "f4"), 

1033 ("psfrec_g_2", "f4"), 

1034 ("gauss_psf_g_1", "f4"), 

1035 ("gauss_psf_g_2", "f4"), 

1036 ("gauss_g_1", "f4"), 

1037 ("gauss_g_2", "f4"), 

1038 ("gauss_g_cov_11", "f4"), 

1039 ("gauss_g_cov_12", "f4"), 

1040 ("gauss_g_cov_21", "f4"), 

1041 ("gauss_g_cov_22", "f4"), 

1042 ] 

1043 

1044 for b in bands: 

1045 copy_dt.append(("gauss_band_flux_flags_%s" % b, "i4")) 

1046 copy_dt.append(("gauss_band_flux_%s" % b, "f4")) 

1047 copy_dt.append(("gauss_band_flux_err_%s" % b, "f4")) 

1048 copy_dt.append(("pgauss_band_flux_flags_%s" % b, "i4")) 

1049 copy_dt.append(("pgauss_band_flux_%s" % b, "f4")) 

1050 copy_dt.append(("pgauss_band_flux_err_%s" % b, "f4")) 

1051 

1052 add_dt = [ 

1053 ("id", "u8"), 

1054 ("tract", "u4"), 

1055 ("patch_x", "u1"), 

1056 ("patch_y", "u1"), 

1057 ("cell_x", "u1"), 

1058 ("cell_y", "u1"), 

1059 ("shear_type", "U2"), 

1060 ("mask_frac", "f4"), 

1061 ("primary", bool), 

1062 ] + copy_dt 

1063 

1064 if not hasattr(res, "keys"): 

1065 res = {"noshear": res} 

1066 

1067 dlist = [] 

1068 for stype in res.keys(): 

1069 data = res[stype] 

1070 if data is not None: 

1071 newdata = eu.numpy_util.add_fields(data, add_dt) 

1072 newdata["psfrec_g_1"] = newdata["psfrec_g"][:, 0] 

1073 newdata["psfrec_g_2"] = newdata["psfrec_g"][:, 1] 

1074 

1075 newdata["gauss_psf_g_1"] = newdata["gauss_psf_g"][:, 0] 

1076 newdata["gauss_psf_g_2"] = newdata["gauss_psf_g"][:, 1] 

1077 newdata["gauss_g_1"] = newdata["gauss_g"][:, 0] 

1078 newdata["gauss_g_2"] = newdata["gauss_g"][:, 1] 

1079 

1080 newdata["gauss_g_cov_11"] = newdata["gauss_g_cov"][:, 0, 0] 

1081 newdata["gauss_g_cov_12"] = newdata["gauss_g_cov"][:, 0, 1] 

1082 newdata["gauss_g_cov_21"] = newdata["gauss_g_cov"][:, 1, 0] 

1083 newdata["gauss_g_cov_22"] = newdata["gauss_g_cov"][:, 1, 1] 

1084 

1085 # To-do make compatible with a single band better than this. 

1086 if len(bands) > 1: 

1087 for i, b in enumerate(bands): 

1088 newdata["gauss_band_flux_flags_%s" % b] = newdata["gauss_band_flux_flags"][:, i] 

1089 newdata["gauss_band_flux_%s" % b] = newdata["gauss_band_flux"][:, i] 

1090 newdata["gauss_band_flux_err_%s" % b] = newdata["gauss_band_flux_err"][:, i] 

1091 newdata["pgauss_band_flux_flags_%s" % b] = newdata["pgauss_band_flux_flags"][:, i] 

1092 newdata["pgauss_band_flux_%s" % b] = newdata["pgauss_band_flux"][:, i] 

1093 newdata["pgauss_band_flux_err_%s" % b] = newdata["pgauss_band_flux_err"][:, i] 

1094 newdata["gauss_band_flux_flags_%s" % b] = newdata["gauss_band_flux_flags"][:, i] 

1095 newdata["gauss_band_flux_%s" % b] = newdata["gauss_band_flux"][:, i] 

1096 newdata["gauss_band_flux_err_%s" % b] = newdata["gauss_band_flux_err"][:, i] 

1097 else: 

1098 b = bands[0] 

1099 newdata["gauss_band_flux_flags_%s" % b] = newdata["gauss_band_flux_flags"] 

1100 newdata["gauss_band_flux_%s" % b] = newdata["gauss_band_flux"] 

1101 newdata["gauss_band_flux_err_%s" % b] = newdata["gauss_band_flux_err"] 

1102 newdata["pgauss_band_flux_flags_%s" % b] = newdata["pgauss_band_flux_flags"] 

1103 newdata["pgauss_band_flux_%s" % b] = newdata["pgauss_band_flux"] 

1104 newdata["pgauss_band_flux_err_%s" % b] = newdata["pgauss_band_flux_err"] 

1105 newdata["gauss_band_flux_flags_%s" % b] = newdata["gauss_band_flux_flags"] 

1106 newdata["gauss_band_flux_%s" % b] = newdata["gauss_band_flux"] 

1107 newdata["gauss_band_flux_err_%s" % b] = newdata["gauss_band_flux_err"] 

1108 

1109 newdata["tract"] = idinfo.tract 

1110 newdata["patch_x"] = idinfo.patch.x 

1111 newdata["patch_y"] = idinfo.patch.y 

1112 newdata["cell_x"] = cell_id.x 

1113 newdata["cell_y"] = cell_id.y 

1114 

1115 if stype == "noshear": 

1116 newdata["shear_type"] = "ns" 

1117 else: 

1118 newdata["shear_type"] = stype 

1119 

1120 dlist.append(newdata) 

1121 

1122 if len(dlist) > 0: 

1123 output = eu.numpy_util.combine_arrlist(dlist) 

1124 else: 

1125 output = [] 

1126 

1127 return output 

1128 

1129 

1130def _get_mask_frac(mfrac_mbexp, trim_pixels=0): 

1131 """ 

1132 get the average mask frac for each band and then return the max of those 

1133 """ 

1134 

1135 mask_fracs = [] 

1136 for mfrac_exp in mfrac_mbexp: 

1137 mfrac = mfrac_exp.image.array 

1138 dim = mfrac.shape[0] 

1139 mfrac = mfrac[ 

1140 trim_pixels : dim - trim_pixels - 1, 

1141 trim_pixels : dim - trim_pixels - 1, 

1142 ] 

1143 mask_fracs.append(mfrac.mean()) 

1144 

1145 return max(mask_fracs)