Coverage for python/lsst/source/injection/inject_base.py: 15%

199 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-03-23 05:18 -0700

1# This file is part of source_injection. 

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__ = ["BaseInjectConnections", "BaseInjectConfig", "BaseInjectTask"] 

25 

26from typing import cast 

27 

28import galsim 

29import numpy as np 

30import numpy.ma as ma 

31from astropy import units 

32from astropy.table import Table, hstack, vstack 

33from astropy.units import Quantity, UnitConversionError 

34from lsst.afw.image.exposure.exposureUtils import bbox_contains_sky_coords 

35from lsst.pex.config import ChoiceField, Field 

36from lsst.pipe.base import PipelineTask, PipelineTaskConfig, PipelineTaskConnections, Struct 

37from lsst.pipe.base.connectionTypes import PrerequisiteInput 

38 

39from .inject_engine import generate_galsim_objects, inject_galsim_objects_into_exposure 

40 

41 

42class BaseInjectConnections( 

43 PipelineTaskConnections, 

44 dimensions=("instrument",), 

45 defaultTemplates={ 

46 "injection_prefix": "injection_", 

47 "injected_prefix": "injected_", 

48 }, 

49): 

50 """Base connections for source injection tasks.""" 

51 

52 injection_catalogs = PrerequisiteInput( 

53 doc="Set of catalogs of sources to draw inputs from.", 

54 name="{injection_prefix}catalog", 

55 dimensions=("htm7", "band"), 

56 storageClass="ArrowAstropy", 

57 minimum=0, 

58 multiple=True, 

59 ) 

60 

61 

62class BaseInjectConfig(PipelineTaskConfig, pipelineConnections=BaseInjectConnections): 

63 """Base configuration for source injection tasks.""" 

64 

65 # Catalog manipulation options. 

66 process_all_data_ids = Field[bool]( 

67 doc="If True, all input data IDs will be processed, even those where no synthetic sources were " 

68 "identified for injection. In such an eventuality this returns a clone of the input image, renamed " 

69 "to the *output_exposure* connection name and with an empty *mask_plane_name* mask plane attached.", 

70 default=False, 

71 ) 

72 trim_padding = Field[int]( 

73 doc="Size of the pixel padding surrounding the image. Only those synthetic sources with a centroid " 

74 "falling within the ``image + trim_padding`` region will be considered for source injection.", 

75 default=100, 

76 optional=True, 

77 ) 

78 selection = Field[str]( 

79 doc="A string that can be evaluated as a boolean expression to select rows in the input injection " 

80 "catalog. To make use of this configuration option, the internal object name ``injection_catalog`` " 

81 "must be used. For example, to select all sources with a magnitude in the range 20.0 < mag < 25.0, " 

82 "set ``selection=\"(injection_catalog['mag'] > 20.0) & (injection_catalog['mag'] < 25.0)\"``. " 

83 "The ``{visit}`` field will be substituted for the current visit ID of the exposure being processed. " 

84 "For example, to select only visits that match a user-supplied visit column in the input injection " 

85 "catalog, set ``selection=\"np.isin(injection_catalog['visit'], {visit})\"``.", 

86 optional=True, 

87 ) 

88 

89 # General configuration options. 

90 mask_plane_name = Field[str]( 

91 doc="Name assigned to the injected mask plane which is attached to the output exposure.", 

92 default="INJECTED", 

93 ) 

94 calib_flux_radius = Field[float]( 

95 doc="Aperture radius (in pixels) that was used to define the calibration for this image+catalog. " 

96 "This will be used to produce the correct instrumental fluxes within the radius. " 

97 "This value should match that of the field defined in ``slot_CalibFlux_instFlux``.", 

98 default=12.0, 

99 ) 

100 fits_alignment = ChoiceField[str]( # type: ignore 

101 doc="How should injections from FITS files be aligned?", 

102 dtype=str, 

103 allowed={ 

104 "wcs": ( 

105 "Input image will be transformed such that the local WCS in the FITS header matches the " 

106 "local WCS in the target image. I.e., North, East, and angular distances in the input image " 

107 "will match North, East, and angular distances in the target image." 

108 ), 

109 "pixel": ( 

110 "Input image will **not** be transformed. Up, right, and pixel distances in the input image " 

111 "will match up, right and pixel distances in the target image." 

112 ), 

113 }, 

114 default="pixel", 

115 ) 

116 stamp_prefix = Field[str]( 

117 doc="String to prefix to the entries in the *col_stamp* column, for example, a directory path.", 

118 default="", 

119 ) 

120 

121 # Custom column names. 

122 col_ra = Field[str]( 

123 doc="Column name for right ascension (in degrees).", 

124 default="ra", 

125 ) 

126 col_dec = Field[str]( 

127 doc="Column name for declination (in degrees).", 

128 default="dec", 

129 ) 

130 col_source_type = Field[str]( 

131 doc="Column name for the source type used in the input catalog. Must match one of the surface " 

132 "brightness profiles defined by GalSim.", 

133 default="source_type", 

134 ) 

135 col_mag = Field[str]( 

136 doc="Column name for magnitude.", 

137 default="mag", 

138 ) 

139 col_stamp = Field[str]( 

140 doc="Column name to identify FITS file postage stamps for direct injection. The strings in this " 

141 "column will be prefixed with a string given in *stamp_prefix*, to assist in providing the full " 

142 "path to a FITS file.", 

143 default="stamp", 

144 ) 

145 col_draw_size = Field[str]( 

146 doc="Column name providing pixel size of the region into which the source profile will be drawn. If " 

147 "this column is not provided as an input, the GalSim method ``getGoodImageSize`` will be used " 

148 "instead.", 

149 default="draw_size", 

150 ) 

151 col_trail_length = Field[str]( 

152 doc="Column name for specifying a satellite trail length (in pixels).", 

153 default="trail_length", 

154 ) 

155 

156 def setDefaults(self): 

157 super().setDefaults() 

158 

159 

160class BaseInjectTask(PipelineTask): 

161 """Base class for injecting sources into images.""" 

162 

163 _DefaultName = "baseInjectTask" 

164 ConfigClass = BaseInjectConfig 

165 

166 def run(self, injection_catalogs, input_exposure, psf, photo_calib, wcs): 

167 """Inject sources into an image. 

168 

169 Parameters 

170 ---------- 

171 injection_catalogs : `list` [`astropy.table.Table`] 

172 Tract level injection catalogs that potentially cover the named 

173 input exposure. 

174 input_exposure : `lsst.afw.image.ExposureF` 

175 The exposure sources will be injected into. 

176 psf: `lsst.meas.algorithms.ImagePsf` 

177 PSF model. 

178 photo_calib : `lsst.afw.image.PhotoCalib` 

179 Photometric calibration used to calibrate injected sources. 

180 wcs : `lsst.afw.geom.SkyWcs` 

181 WCS used to calibrate injected sources. 

182 

183 Returns 

184 ------- 

185 output_struct : `lsst.pipe.base.Struct` 

186 contains : output_exposure : `lsst.afw.image.ExposureF` 

187 output_catalog : `lsst.afw.table.SourceCatalog` 

188 """ 

189 self.config = cast(BaseInjectConfig, self.config) 

190 

191 # Attach potential externally calibrated datasets to input_exposure. 

192 # Keep originals so we can reset at the end. 

193 original_psf = input_exposure.getPsf() 

194 original_photo_calib = input_exposure.getPhotoCalib() 

195 original_wcs = input_exposure.getWcs() 

196 input_exposure.setPsf(psf) 

197 input_exposure.setPhotoCalib(photo_calib) 

198 input_exposure.setWcs(wcs) 

199 

200 # Make empty table if none supplied to support process_all_data_ids. 

201 if len(injection_catalogs) == 0: 

202 if self.config.process_all_data_ids: 

203 injection_catalogs = [Table(names=["ra", "dec", "source_type"])] 

204 else: 

205 raise RuntimeError( 

206 "No injection sources overlap the data query. Check injection catalog coverage." 

207 ) 

208 

209 # Consolidate injection catalogs and compose main injection catalog. 

210 injection_catalog = self._compose_injection_catalog(injection_catalogs) 

211 

212 # Mapping between standard column names and configured names/units. 

213 column_mapping = { 

214 "ra": (self.config.col_ra, units.deg), 

215 "dec": (self.config.col_dec, units.deg), 

216 "source_type": (self.config.col_source_type, None), 

217 "mag": (self.config.col_mag, units.mag), 

218 "stamp": (self.config.col_stamp, None), 

219 "draw_size": (self.config.col_draw_size, units.pix), 

220 "trail_length": (self.config.col_trail_length, units.pix), 

221 } 

222 

223 # Standardize injection catalog column names and units. 

224 injection_catalog = self._standardize_columns( 

225 injection_catalog, 

226 column_mapping, 

227 input_exposure.getWcs().getPixelScale().asArcseconds(), 

228 ) 

229 

230 # Clean the injection catalog of sources which are not injectable. 

231 injection_catalog = self._clean_sources(injection_catalog, input_exposure) 

232 

233 # Injection binary flag lookup dictionary. 

234 binary_flags = { 

235 "MAG_BAD": 0, 

236 "TYPE_UNKNOWN": 1, 

237 "SERSIC_EXTREME": 2, 

238 "NO_OVERLAP": 3, 

239 "FFT_SIZE_ERROR": 4, 

240 "PSF_COMPUTE_ERROR": 5, 

241 } 

242 

243 # Check that sources in the injection catalog are able to be injected. 

244 injection_catalog = self._check_sources(injection_catalog, binary_flags) 

245 

246 # Inject sources into input_exposure. 

247 good_injections: list[bool] = injection_catalog["injection_flag"] == 0 

248 good_injections_index = [i for i, val in enumerate(good_injections) if val] 

249 num_injection_sources = np.sum(good_injections) 

250 if num_injection_sources > 0: 

251 object_generator = generate_galsim_objects( 

252 injection_catalog=injection_catalog[good_injections], 

253 photo_calib=photo_calib, 

254 wcs=wcs, 

255 fits_alignment=self.config.fits_alignment, 

256 stamp_prefix=self.config.stamp_prefix, 

257 logger=self.log, 

258 ) 

259 ( 

260 draw_sizes, 

261 common_bounds, 

262 fft_size_errors, 

263 psf_compute_errors, 

264 ) = inject_galsim_objects_into_exposure( 

265 input_exposure, 

266 object_generator, 

267 mask_plane_name=self.config.mask_plane_name, 

268 calib_flux_radius=self.config.calib_flux_radius, 

269 draw_size_max=10000, # TODO: replace draw_size logic with GS logic. 

270 logger=self.log, 

271 ) 

272 # Add inject_galsim_objects_into_exposure outputs into output cat. 

273 common_areas = [x.area() if x is not None else None for x in common_bounds] 

274 for i, (draw_size, common_area, fft_size_error, psf_compute_error) in enumerate( 

275 zip(draw_sizes, common_areas, fft_size_errors, psf_compute_errors) 

276 ): 

277 injection_catalog["injection_draw_size"][good_injections_index[i]] = draw_size 

278 if common_area == 0: 

279 injection_catalog["injection_flag"][good_injections_index[i]] += ( 

280 2 ** binary_flags["NO_OVERLAP"] 

281 ) 

282 if fft_size_error: 

283 injection_catalog["injection_flag"][good_injections_index[i]] += ( 

284 2 ** binary_flags["FFT_SIZE_ERROR"] 

285 ) 

286 if psf_compute_error: 

287 injection_catalog["injection_flag"][good_injections_index[i]] += ( 

288 2 ** binary_flags["PSF_COMPUTE_ERROR"] 

289 ) 

290 num_injected_sources = np.sum(injection_catalog["injection_flag"] == 0) 

291 num_skipped_sources = np.sum(injection_catalog["injection_flag"] != 0) 

292 grammar1 = "source" if num_injection_sources == 1 else "sources" 

293 grammar2 = "source" if num_skipped_sources == 1 else "sources" 

294 

295 injection_flags = np.array(injection_catalog["injection_flag"]) 

296 num_injection_flags = [np.sum((injection_flags & 2**x) > 0) for x in binary_flags.values()] 

297 if np.sum(num_injection_flags) > 0: 

298 injection_flag_report = ": " + ", ".join( 

299 [f"{x}({y})" for x, y in zip(binary_flags.keys(), num_injection_flags) if y > 0] 

300 ) 

301 else: 

302 injection_flag_report = "" 

303 self.log.info( 

304 "Injected %d of %d potential %s. %d %s flagged and skipped%s.", 

305 num_injected_sources, 

306 num_injection_sources, 

307 grammar1, 

308 num_skipped_sources, 

309 grammar2, 

310 injection_flag_report, 

311 ) 

312 elif num_injection_sources == 0 and self.config.process_all_data_ids: 

313 self.log.warning("No sources to be injected for this DatasetRef; processing anyway.") 

314 input_exposure.mask.addMaskPlane(self.config.mask_plane_name) 

315 mask_plane_core_name = self.config.mask_plane_name + "_CORE" 

316 input_exposure.mask.addMaskPlane(mask_plane_core_name) 

317 self.log.info( 

318 "Adding %s and %s mask planes to the exposure.", 

319 self.config.mask_plane_name, 

320 mask_plane_core_name, 

321 ) 

322 else: 

323 raise RuntimeError( 

324 "No sources to be injected for this DatasetRef, and process_all_data_ids is False." 

325 ) 

326 

327 # Restore original input_exposure calibrated data. 

328 input_exposure.setPsf(original_psf) 

329 input_exposure.setPhotoCalib(original_photo_calib) 

330 input_exposure.setWcs(original_wcs) 

331 

332 # Add injection provenance and injection flags metadata. 

333 metadata = input_exposure.getMetadata() 

334 input_dataset_type = self.config.connections.input_exposure.format(**self.config.connections.toDict()) 

335 metadata.set("INJECTED", input_dataset_type, "Initial source injection dataset type") 

336 for flag, value in sorted(binary_flags.items(), key=lambda item: item[1]): 

337 injection_catalog.meta[flag] = value 

338 

339 output_struct = Struct(output_exposure=input_exposure, output_catalog=injection_catalog) 

340 return output_struct 

341 

342 def _compose_injection_catalog(self, injection_catalogs): 

343 """Consolidate injection catalogs and compose main injection catalog. 

344 

345 If multiple injection catalogs are input, all catalogs are 

346 concatenated together. 

347 

348 A running injection_id, specific to this dataset ref, is assigned to 

349 each source in the output injection catalog if not provided. 

350 

351 Parameters 

352 ---------- 

353 injection_catalogs : `list` [`astropy.table.Table`] 

354 Set of synthetic source catalogs to concatenate. 

355 

356 Returns 

357 ------- 

358 injection_catalog : `astropy.table.Table` 

359 Catalog of sources to be injected. 

360 """ 

361 self.config = cast(BaseInjectConfig, self.config) 

362 

363 # Generate injection IDs (if not provided) and injection flag column. 

364 injection_data = vstack(injection_catalogs) 

365 if "injection_id" in injection_data.columns: 

366 injection_id = injection_data["injection_id"] 

367 injection_data.remove_column("injection_id") 

368 else: 

369 injection_id = range(len(injection_data)) 

370 injection_header = Table( 

371 { 

372 "injection_id": injection_id, 

373 "injection_flag": np.zeros(len(injection_data), dtype=int), 

374 "injection_draw_size": np.zeros(len(injection_data), dtype=int), 

375 } 

376 ) 

377 

378 # Construct final injection catalog. 

379 injection_catalog = hstack([injection_header, injection_data]) 

380 injection_catalog["source_type"] = injection_catalog["source_type"].astype(str) 

381 

382 # Log and return. 

383 num_injection_catalogs = np.sum([len(table) > 0 for table in injection_catalogs]) 

384 grammar1 = "source" if len(injection_catalog) == 1 else "sources" 

385 grammar2 = "trixel" if num_injection_catalogs == 1 else "trixels" 

386 self.log.info( 

387 "Retrieved %d injection %s from %d HTM %s.", 

388 len(injection_catalog), 

389 grammar1, 

390 num_injection_catalogs, 

391 grammar2, 

392 ) 

393 return injection_catalog 

394 

395 def _standardize_columns(self, injection_catalog, column_mapping, pixel_scale): 

396 """Standardize injection catalog column names and units. 

397 

398 Use config variables to standardize the expected columns and column 

399 names in the input injection catalog. This method replaces all core 

400 column names in the config with hard-coded internal names. 

401 

402 Only a core group of column names are standardized; additional column 

403 names will not be modified. If certain parameters are needed (i.e., 

404 by GalSim), these columns must be given exactly as required in the 

405 appropriate units. Refer to the configuration documentation for more 

406 details. 

407 

408 Parameters 

409 ---------- 

410 injection_catalog : `astropy.table.Table` 

411 A catalog of sources to be injected. 

412 column_mapping : `dict` [`str`, `tuple` [`str`, `astropy.units.Unit`]] 

413 A dictionary mapping standard column names to the configured column 

414 names and units. 

415 pixel_scale : `float` 

416 Pixel scale of the exposure in arcseconds per pixel. 

417 

418 Returns 

419 ------- 

420 injection_catalog : `astropy.table.Table` 

421 The standardized catalog of sources to be injected. 

422 """ 

423 self.config = cast(BaseInjectConfig, self.config) 

424 

425 pixel_scale_equivalency = units.pixel_scale( 

426 Quantity(pixel_scale, units.arcsec / units.pix) # type: ignore 

427 ) 

428 for standard_col, (configured_col, unit) in column_mapping.items(): 

429 # Rename columns if necessary. 

430 if configured_col in injection_catalog.colnames: 

431 injection_catalog.rename_column(configured_col, standard_col) 

432 # Attempt to convert to our desired units, then remove units. 

433 if standard_col in injection_catalog.columns and unit: 

434 try: 

435 injection_catalog[standard_col] = ( 

436 injection_catalog[standard_col].to(unit, pixel_scale_equivalency).value 

437 ) 

438 except UnitConversionError: 

439 pass 

440 return Table(injection_catalog) 

441 

442 def _clean_sources(self, injection_catalog, input_exposure): 

443 """Clean the injection catalog of sources which are not injectable. 

444 

445 This method will remove sources which are not injectable for a variety 

446 of reasons, namely: sources which fall outside the padded exposure 

447 bounding box or sources not selected by virtue of their evaluated 

448 selection criteria. 

449 

450 If the input injection catalog contains x/y inputs but does not contain 

451 RA/Dec inputs, WCS information will be used to generate RA/Dec sky 

452 coordinate information and appended to the injection catalog. 

453 

454 Parameters 

455 ---------- 

456 injection_catalog : `astropy.table.Table` 

457 The catalog of sources to be injected. 

458 input_exposure : `lsst.afw.image.ExposureF` 

459 The exposure to inject sources into. 

460 

461 Returns 

462 ------- 

463 injection_catalog : `astropy.table.Table` 

464 Updated injection catalog containing *x* and *y* pixel coordinates, 

465 and cleaned to only include injection sources which fall within the 

466 bounding box of the input exposure dilated by *trim_padding*. 

467 """ 

468 self.config = cast(BaseInjectConfig, self.config) 

469 

470 sources_to_keep = np.ones(len(injection_catalog), dtype=bool) 

471 

472 # Determine centroids and remove sources outside the padded bbox. 

473 wcs = input_exposure.getWcs() 

474 has_sky = {"ra", "dec"} <= set(injection_catalog.columns) 

475 has_pixel = {"x", "y"} <= set(injection_catalog.columns) 

476 # Input catalog must contain either RA/Dec OR x/y. 

477 # If only x/y given, RA/Dec will be calculated. 

478 if not has_sky and has_pixel: 

479 begin_x, begin_y = input_exposure.getBBox().getBegin() 

480 ras, decs = wcs.pixelToSkyArray( 

481 begin_x + injection_catalog["x"].astype(float), 

482 begin_y + injection_catalog["y"].astype(float), 

483 degrees=True, 

484 ) 

485 injection_catalog["ra"] = ras 

486 injection_catalog["dec"] = decs 

487 injection_catalog["x"] += begin_x 

488 injection_catalog["y"] += begin_y 

489 has_sky = True 

490 elif not has_sky and not has_pixel: 

491 self.log.warning("No spatial coordinates found in injection catalog; cannot inject any sources!") 

492 if has_sky: 

493 bbox = input_exposure.getBBox() 

494 if self.config.trim_padding: 

495 bbox.grow(int(self.config.trim_padding)) 

496 is_contained = bbox_contains_sky_coords( 

497 bbox, wcs, injection_catalog["ra"] * units.deg, injection_catalog["dec"] * units.deg 

498 ) 

499 sources_to_keep &= is_contained 

500 if (num_not_contained := np.sum(~is_contained)) > 0: 

501 grammar = ("source", "a centroid") if num_not_contained == 1 else ("sources", "centroids") 

502 self.log.info( 

503 "Identified %d injection %s with %s outside the padded image bounding box.", 

504 num_not_contained, 

505 grammar[0], 

506 grammar[1], 

507 ) 

508 

509 # Remove sources by boolean selection flag. 

510 if self.config.selection: 

511 visit = input_exposure.getInfo().getVisitInfo().getId() 

512 selected = eval(self.config.selection.format(visit=visit)) 

513 sources_to_keep &= selected 

514 if (num_not_selected := np.sum(~selected)) >= 0: 

515 grammar = ["source", "was"] if num_not_selected == 1 else ["sources", "were"] 

516 self.log.warning( 

517 "Identified %d injection %s that %s not selected.", 

518 num_not_selected, 

519 grammar[0], 

520 grammar[1], 

521 ) 

522 

523 # Print final cleaning report and return. 

524 num_cleaned_total = np.sum(~sources_to_keep) 

525 grammar = "source" if len(sources_to_keep) == 1 else "sources" 

526 self.log.info( 

527 "Catalog cleaning removed %d of %d %s; %d remaining for catalog checking.", 

528 num_cleaned_total, 

529 len(sources_to_keep), 

530 grammar, 

531 np.sum(sources_to_keep), 

532 ) 

533 injection_catalog = injection_catalog[sources_to_keep] 

534 return injection_catalog 

535 

536 def _check_sources(self, injection_catalog, binary_flags): 

537 """Check that sources in the injection catalog are able to be injected. 

538 

539 This method will check that sources in the injection catalog are able 

540 to be injected, and will flag them if not. Checks will be made on a 

541 number of parameters, including magnitude, source type and Sérsic index 

542 (where relevant). 

543 

544 Legacy profile types will be renamed to their standardized GalSim 

545 equivalents; any source profile types that are not GalSim classes will 

546 be flagged. 

547 

548 Note: Unlike the cleaning method, no sources are actually removed here. 

549 Instead, a binary flag is set in the *injection_flag* column for each 

550 source. Only unflagged sources will be generated for source injection. 

551 

552 Parameters 

553 ---------- 

554 injection_catalog : `astropy.table.Table` 

555 Catalog of sources to be injected. 

556 binary_flags : `dict` [`str`, `int`] 

557 Dictionary of binary flags to be used in the injection_flag column. 

558 

559 Returns 

560 ------- 

561 injection_catalog : `astropy.table.Table` 

562 The cleaned catalog of sources to be injected. 

563 """ 

564 self.config = cast(BaseInjectConfig, self.config) 

565 

566 # Flag erroneous magnitude values (missing mag data or NaN mag values). 

567 if "mag" not in injection_catalog.columns: 

568 # Check injection_catalog has a mag column. 

569 self.log.warning("No magnitude data found in injection catalog; cannot inject any sources!") 

570 injection_catalog["injection_flag"] += 2 ** binary_flags["MAG_BAD"] 

571 else: 

572 # Check that all input mag values are finite. 

573 mag_array = np.isfinite(ma.array(injection_catalog["mag"])) 

574 bad_mag = ~(mag_array.data * ~mag_array.mask) 

575 if (num_bad_mag := np.sum(bad_mag)) > 0: 

576 grammar = "source" if num_bad_mag == 1 else "sources" 

577 self.log.warning( 

578 "Flagging %d injection %s that do not have a finite magnitude.", num_bad_mag, grammar 

579 ) 

580 injection_catalog["injection_flag"][bad_mag] += 2 ** binary_flags["MAG_BAD"] 

581 

582 # Replace legacy source types with standardized profile names. 

583 injection_catalog["source_type"] = injection_catalog["source_type"].astype("O") 

584 replace_dict = {"Star": "DeltaFunction"} 

585 for legacy_type, standard_type in replace_dict.items(): 

586 legacy_matches = injection_catalog["source_type"] == legacy_type 

587 if np.any(legacy_matches): 

588 injection_catalog["source_type"][legacy_matches] = standard_type 

589 injection_catalog["source_type"] = injection_catalog["source_type"].astype(str) 

590 

591 # Flag source types not supported by GalSim. 

592 input_source_types = set(injection_catalog["source_type"]) 

593 allowed_source_types = [ 

594 "Gaussian", 

595 "Box", 

596 "TopHat", 

597 "DeltaFunction", 

598 "Airy", 

599 "Moffat", 

600 "Kolmogorov", 

601 "VonKarman", 

602 "Exponential", 

603 "DeVaucouleurs", 

604 "Sersic", 

605 "InclinedExponential", 

606 "InclinedSersic", 

607 "Spergel", 

608 "RandomKnots", 

609 "Trail", 

610 "Stamp", 

611 ] 

612 for input_source_type in input_source_types: 

613 if input_source_type not in allowed_source_types: 

614 unknown_source_types = injection_catalog["source_type"] == input_source_type 

615 grammar = "source" if np.sum(unknown_source_types) == 1 else "sources" 

616 self.log.warning( 

617 "Flagging %d injection %s with an unsupported source type: %s.", 

618 np.sum(unknown_source_types), 

619 grammar, 

620 input_source_type, 

621 ) 

622 injection_catalog["injection_flag"][unknown_source_types] += 2 ** binary_flags["TYPE_UNKNOWN"] 

623 

624 # Flag extreme Sersic index sources. 

625 if "n" in injection_catalog.columns: 

626 min_n = galsim.Sersic._minimum_n 

627 max_n = galsim.Sersic._maximum_n 

628 n_vals = injection_catalog["n"] 

629 extreme_sersics = (n_vals <= min_n) | (n_vals >= max_n) 

630 if (num_extreme_sersics := np.sum(extreme_sersics)) > 0: 

631 grammar = "source" if num_extreme_sersics == 1 else "sources" 

632 self.log.warning( 

633 "Flagging %d injection %s with a Sersic index outside the range %.1f <= n <= %.1f.", 

634 num_extreme_sersics, 

635 grammar, 

636 min_n, 

637 max_n, 

638 ) 

639 injection_catalog["injection_flag"][extreme_sersics] += 2 ** binary_flags["SERSIC_EXTREME"] 

640 

641 # Print final cleaning report. 

642 num_flagged_total = np.sum(injection_catalog["injection_flag"] != 0) 

643 grammar = "source" if len(injection_catalog) == 1 else "sources" 

644 self.log.info( 

645 "Catalog checking flagged %d of %d %s; %d remaining for source generation.", 

646 num_flagged_total, 

647 len(injection_catalog), 

648 grammar, 

649 np.sum(injection_catalog["injection_flag"] == 0), 

650 ) 

651 return injection_catalog