Coverage for python/lsst/source/injection/inject_base.py: 15%
199 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-01 14:09 +0000
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-01 14:09 +0000
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/>.
22from __future__ import annotations
24__all__ = ["BaseInjectConnections", "BaseInjectConfig", "BaseInjectTask"]
26from typing import cast
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
39from .inject_engine import generate_galsim_objects, inject_galsim_objects_into_exposure
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."""
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 )
62class BaseInjectConfig(PipelineTaskConfig, pipelineConnections=BaseInjectConnections):
63 """Base configuration for source injection tasks."""
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 )
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 )
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 )
156 def setDefaults(self):
157 super().setDefaults()
160class BaseInjectTask(PipelineTask):
161 """Base class for injecting sources into images."""
163 _DefaultName = "baseInjectTask"
164 ConfigClass = BaseInjectConfig
166 def run(self, injection_catalogs, input_exposure, psf, photo_calib, wcs):
167 """Inject sources into an image.
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.
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)
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)
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 )
209 # Consolidate injection catalogs and compose main injection catalog.
210 injection_catalog = self._compose_injection_catalog(injection_catalogs)
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 }
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 )
230 # Clean the injection catalog of sources which are not injectable.
231 injection_catalog = self._clean_sources(injection_catalog, input_exposure)
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 }
243 # Check that sources in the injection catalog are able to be injected.
244 injection_catalog = self._check_sources(injection_catalog, binary_flags)
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"
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 )
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)
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
339 output_struct = Struct(output_exposure=input_exposure, output_catalog=injection_catalog)
340 return output_struct
342 def _compose_injection_catalog(self, injection_catalogs):
343 """Consolidate injection catalogs and compose main injection catalog.
345 If multiple injection catalogs are input, all catalogs are
346 concatenated together.
348 A running injection_id, specific to this dataset ref, is assigned to
349 each source in the output injection catalog if not provided.
351 Parameters
352 ----------
353 injection_catalogs : `list` [`astropy.table.Table`]
354 Set of synthetic source catalogs to concatenate.
356 Returns
357 -------
358 injection_catalog : `astropy.table.Table`
359 Catalog of sources to be injected.
360 """
361 self.config = cast(BaseInjectConfig, self.config)
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 )
378 # Construct final injection catalog.
379 injection_catalog = hstack([injection_header, injection_data])
380 injection_catalog["source_type"] = injection_catalog["source_type"].astype(str)
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
395 def _standardize_columns(self, injection_catalog, column_mapping, pixel_scale):
396 """Standardize injection catalog column names and units.
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.
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.
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.
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)
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)
442 def _clean_sources(self, injection_catalog, input_exposure):
443 """Clean the injection catalog of sources which are not injectable.
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.
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.
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.
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)
470 sources_to_keep = np.ones(len(injection_catalog), dtype=bool)
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 )
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 )
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
536 def _check_sources(self, injection_catalog, binary_flags):
537 """Check that sources in the injection catalog are able to be injected.
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).
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.
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.
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.
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)
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"]
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)
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"]
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"]
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