Coverage for python/lsst/ap/pipe/createApFakes.py: 90%

260 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-04 17:46 +0000

1# This file is part of ap_pipe. 

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 

22import numpy as np 

23import pandas as pd 

24import uuid 

25 

26import logging 

27from astropy.table import Table, vstack 

28 

29import lsst.pex.config as pexConfig 

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

31import lsst.pipe.base.connectionTypes as connTypes 

32from lsst.pipe.tasks.insertFakes import InsertFakesConfig 

33from lsst.skymap import BaseSkyMap 

34 

35from lsst.source.injection import generate_injection_catalog 

36 

37from deprecated.sphinx import deprecated 

38 

39__all__ = ["CreateRandomApFakesTask", 

40 "CreateRandomApFakesConfig", 

41 "CreateRandomApFakesConnections", 

42 "CreateVisitDetectorFakesTask", 

43 "CreateVisitDetectorFakesConfig", 

44 "CreateVisitDetectorFakesConnections"] 

45 

46 

47class CreateRandomApFakesConnections(PipelineTaskConnections, 

48 dimensions=("tract", "skymap")): 

49 skyMap = connTypes.Input( 

50 doc="Input definition of geometry/bbox and projection/wcs for " 

51 "template exposures", 

52 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME, 

53 dimensions=("skymap",), 

54 storageClass="SkyMap", 

55 ) 

56 fakeCat = connTypes.Output( 

57 doc="Catalog of fake sources to draw inputs from.", 

58 name="fakeSourceCat", 

59 storageClass="DataFrame", 

60 dimensions=("tract", "skymap") 

61 ) 

62 

63 

64@deprecated( 

65 reason="This task will be removed in v28.0 as it is replaced by `source_injection` tasks.", 

66 version="v28.0", 

67 category=FutureWarning, 

68) 

69class CreateRandomApFakesConfig( 

70 InsertFakesConfig, 

71 pipelineConnections=CreateRandomApFakesConnections): 

72 """Config for CreateRandomApFakesTask. Copy from the InsertFakesConfig to 

73 assert that columns created with in this task match that those expected in 

74 the InsertFakes and related tasks. 

75 """ 

76 fakeDensity = pexConfig.RangeField( 

77 doc="Goal density of random fake sources per square degree. Default " 

78 "value is roughly the density per square degree for ~10k sources " 

79 "visit.", 

80 dtype=float, 

81 default=1000, 

82 min=0, 

83 ) 

84 filterSet = pexConfig.ListField( 

85 doc="Set of Abstract filter names to produce magnitude columns for.", 

86 dtype=str, 

87 default=["u", "g", "r", "i", "z", "y"], 

88 ) 

89 fraction = pexConfig.RangeField( 

90 doc="Fraction of the created source that should be inserted into both " 

91 "the visit and template images. Values less than 1 will result in " 

92 "(1 - fraction) / 2 inserted into only visit or the template.", 

93 dtype=float, 

94 default=1/3, 

95 min=0, 

96 max=1, 

97 ) 

98 magMin = pexConfig.RangeField( 

99 doc="Minimum magnitude the mag distribution. All magnitudes requested " 

100 "are set to the same value.", 

101 dtype=float, 

102 default=20, 

103 min=1, 

104 max=40, 

105 ) 

106 magMax = pexConfig.RangeField( 

107 doc="Maximum magnitude the mag distribution. All magnitudes requested " 

108 "are set to the same value.", 

109 dtype=float, 

110 default=30, 

111 min=1, 

112 max=40, 

113 ) 

114 visitSourceFlagCol = pexConfig.Field( 

115 doc="Name of the column flagging objects for insertion into the visit " 

116 "image.", 

117 dtype=str, 

118 default="isVisitSource" 

119 ) 

120 templateSourceFlagCol = pexConfig.Field( 

121 doc="Name of the column flagging objects for insertion into the " 

122 "template image.", 

123 dtype=str, 

124 default="isTemplateSource" 

125 ) 

126 

127 

128@deprecated( 

129 reason="This task will be removed in v28.0 as it is replaced by `source_injection` tasks.", 

130 version="v28.0", 

131 category=FutureWarning, 

132) 

133class CreateRandomApFakesTask(PipelineTask): 

134 """Create and store a set of spatially uniform star fakes over the sphere 

135 for use in AP processing. Additionally assign random magnitudes to said 

136 fakes and assign them to be inserted into either a visit exposure or 

137 template exposure. 

138 """ 

139 

140 _DefaultName = "createApFakes" 

141 ConfigClass = CreateRandomApFakesConfig 

142 

143 def runQuantum(self, butlerQC, inputRefs, outputRefs): 

144 inputs = butlerQC.get(inputRefs) 

145 inputs["tractId"] = butlerQC.quantum.dataId["tract"] 

146 

147 outputs = self.run(**inputs) 

148 butlerQC.put(outputs, outputRefs) 

149 

150 def run(self, tractId, skyMap): 

151 """Create a set of uniform random points that covers a tract. 

152 

153 Parameters 

154 ---------- 

155 tractId : `int` 

156 Tract id to produce randoms over. 

157 skyMap : `lsst.skymap.SkyMap` 

158 Skymap to produce randoms over. 

159 

160 Returns 

161 ------- 

162 randoms : `pandas.DataFrame` 

163 Catalog of random points covering the given tract. Follows the 

164 columns and format expected in `lsst.pipe.tasks.InsertFakes`. 

165 """ 

166 

167 tract = skyMap.generateTract(tractId) 

168 tractArea = tract.getOuterSkyPolygon().getBoundingBox().getArea() 

169 tractArea *= (180 / np.pi) ** 2 

170 tractWcs = tract.getWcs() 

171 vertexList = tract.getVertexList() 

172 vertexRas = [vertex.getRa().asDegrees() for vertex in vertexList] 

173 vertexDecs = [vertex.getDec().asDegrees() for vertex in vertexList] 

174 

175 catalog = generate_injection_catalog( 

176 ra_lim=sorted([np.min(vertexRas), np.max(vertexRas)]), 

177 dec_lim=sorted([np.min(vertexDecs), np.max(vertexDecs)]), 

178 mag_lim=(self.config.magMin, self.config.magMax), 

179 density=self.config.fakeDensity, 

180 source_type="Star", 

181 seed=str(tractId), 

182 wcs=tractWcs 

183 ) 

184 

185 nFakes = len(catalog) 

186 

187 self.log.info( 

188 f"Creating {nFakes} star fakes over tractId={tractId} with " 

189 f" RA in ({sorted([np.min(vertexRas), np.max(vertexRas)])} " 

190 f" Dec in ({sorted([np.min(vertexDecs), np.max(vertexDecs)])}), " 

191 f"area={tractArea:.4f} deg^2 and " 

192 f"magnitude range: [{self.config.magMin, self.config.magMax}]") 

193 

194 onesColumn = np.ones(nFakes, dtype="float") 

195 zerosColumn = np.zeros(nFakes, dtype="float") 

196 # Concatenate the data and add dummy values for the unused variables. 

197 # Set all data to PSF like objects. 

198 mags = np.asarray(catalog["mag"], dtype=float) 

199 randData = { 

200 "fakeId": [uuid.uuid4().int & (1 << 64) - 1 for n in range(nFakes)], 

201 self.config.ra_col: np.asarray(catalog["ra"], dtype=float), 

202 self.config.dec_col: np.asarray(catalog["dec"], dtype=float), 

203 **self.createVisitCoaddSubdivision(nFakes), 

204 **self.createMagnitudeColumns(mags), 

205 self.config.disk_semimajor_col: onesColumn, 

206 self.config.bulge_semimajor_col: onesColumn, 

207 self.config.disk_n_col: onesColumn, 

208 self.config.bulge_n_col: onesColumn, 

209 self.config.disk_axis_ratio_col: onesColumn, 

210 self.config.bulge_axis_ratio_col: onesColumn, 

211 self.config.disk_pa_col: zerosColumn, 

212 self.config.bulge_pa_col: onesColumn, 

213 self.config.sourceType: np.asarray(catalog["source_type"], dtype=str), 

214 "source_type": np.asarray(catalog["source_type"], dtype=str), 

215 "injection_id": np.asarray(catalog["injection_id"], dtype=np.int64) 

216 } 

217 

218 return Struct(fakeCat=pd.DataFrame(data=randData)) 

219 

220 def createVisitCoaddSubdivision(self, nFakes): 

221 """Assign a given fake either a visit image or coadd or both based on 

222 the ``faction`` config value. 

223 

224 Parameters 

225 ---------- 

226 nFakes : `int` 

227 Number of fakes to create. 

228 

229 Returns 

230 ------- 

231 output : `dict`[`str`, `numpy.ndarray`] 

232 Dictionary of boolean arrays specifying which image to put a 

233 given fake into. 

234 """ 

235 nBoth = int(self.config.fraction * nFakes) 

236 nOnly = int((1 - self.config.fraction) / 2 * nFakes) 

237 isVisitSource = np.zeros(nFakes, dtype=bool) 

238 isTemplateSource = np.zeros(nFakes, dtype=bool) 

239 if nBoth > 0: 239 ↛ 242line 239 didn't jump to line 242 because the condition on line 239 was always true

240 isVisitSource[:nBoth] = True 

241 isTemplateSource[:nBoth] = True 

242 if nOnly > 0: 242 ↛ 246line 242 didn't jump to line 246 because the condition on line 242 was always true

243 isVisitSource[nBoth:(nBoth + nOnly)] = True 

244 isTemplateSource[(nBoth + nOnly):] = True 

245 

246 return {self.config.visitSourceFlagCol: isVisitSource, 

247 self.config.templateSourceFlagCol: isTemplateSource} 

248 

249 def createMagnitudeColumns(self, mags): 

250 """Create magnitude columns from a 1D magnitude array. 

251 

252 Parameters 

253 ---------- 

254 mags : `numpy.ndarray` 

255 Magnitudes to copy to all configured filter-band columns. 

256 

257 Returns 

258 ------- 

259 randMags : `dict`[`str`, `numpy.ndarray`] 

260 Dictionary containing per-band magnitudes plus a ``mag`` column 

261 compatible with ``source_injection`` catalogs. 

262 """ 

263 randMags = {} 

264 for fil in self.config.filterSet: 

265 randMags[self.config.mag_col % fil] = mags 

266 randMags["mag"] = mags 

267 return randMags 

268 

269 

270class CreateVisitDetectorFakesConnections( 

271 PipelineTaskConnections, 

272 defaultTemplates={"coaddName": "deep"}, 

273 dimensions=("instrument", 

274 "visit", 

275 "detector")): 

276 

277 sourceCat = connTypes.Input( 

278 doc="Catalog of sources detected on the calibrated exposure; ", 

279 name="single_visit_star_reprocessed_footprints", 

280 storageClass="SourceCatalog", 

281 dimensions=["instrument", "visit", "detector"], 

282 ) 

283 visit_image = connTypes.Input( 

284 doc="Calibrated exposure to inject synthetic sources into.", 

285 name="preliminary_visit_image", 

286 storageClass="ExposureF", 

287 dimensions=["instrument", "visit", "detector"], 

288 ) 

289 outputCat = connTypes.Output( 

290 doc="Catalog of fake sources to draw inputs from.", 

291 name="VisitDetectorFakeSourceCat", 

292 storageClass="ArrowAstropy", 

293 dimensions=["instrument", "visit", "detector"], 

294 ) 

295 

296 

297class CreateVisitDetectorFakesConfig( 

298 PipelineTaskConfig, 

299 pipelineConnections=CreateVisitDetectorFakesConnections 

300): 

301 """Config for CreateVisitDetectorFakesTask.""" 

302 randomFakeDensity = pexConfig.RangeField( 

303 doc="Goal density of visit detector fake sources per square degree.", 

304 dtype=float, 

305 default=1000, 

306 min=1, 

307 ) 

308 nRandomFakes = pexConfig.RangeField( 

309 doc="Number of random fakes to add to the visit detector. Overrides " 

310 "the randomFakeDensity if set to a positive value.", 

311 dtype=int, 

312 default=-1, 

313 min=-1, 

314 ) 

315 doAddRandomVisitFakes = pexConfig.Field( 

316 doc="Whether to add random positive fakes to the visit detector.", 

317 dtype=bool, 

318 default=True, 

319 ) 

320 doAddRandomTemplateFakes = pexConfig.Field( 

321 doc="Whether to add random template fakes to the visit detector (negatives).", 

322 dtype=bool, 

323 default=True, 

324 ) 

325 templateFakeFraction = pexConfig.RangeField( 

326 doc="Fraction of random fakes that should be added to the template image." 

327 " The rest will be added to the visit image.", 

328 dtype=float, 

329 default=0.25, 

330 min=0, 

331 max=1, 

332 ) 

333 doAddVariableFakes = pexConfig.Field( 

334 doc="Whether to add variable fakes to the visit detector.", 

335 dtype=bool, 

336 default=False, 

337 ) 

338 variableFakeFraction = pexConfig.RangeField( 

339 doc="Fraction of variable fakes that should be added to the template image." 

340 " The rest will be added to the visit image.", 

341 dtype=float, 

342 default=0.1, 

343 min=0, 

344 max=1, 

345 ) 

346 variableFakeMean = pexConfig.RangeField( 

347 doc="Mean magnitude variation for variable fakes.", 

348 dtype=float, 

349 default=0.0, 

350 min=-1, 

351 max=1, 

352 ) 

353 variableFakeStd = pexConfig.RangeField( 

354 doc="Standard deviation of magnitude variation for variable fakes.", 

355 dtype=float, 

356 default=0.5, 

357 min=0, 

358 max=1, 

359 ) 

360 doAddHostedFakes = pexConfig.Field( 

361 doc="Whether to add hosted fakes to the visit detector.", 

362 dtype=bool, 

363 default=False, 

364 ) 

365 fracHostedFakes = pexConfig.RangeField( 

366 doc="Fraction of hosts with fakes to add to the visit detector.", 

367 dtype=float, 

368 default=0.1, 

369 min=0, 

370 max=1, 

371 ) 

372 minHostedFakes = pexConfig.RangeField( 

373 doc="Minimum number of hosted fakes to add to the visit detector.", 

374 dtype=int, 

375 default=20, 

376 min=1, 

377 ) 

378 doAddModelFakes = pexConfig.Field( 

379 doc="Whether to add model fakes to the visit detector.", 

380 dtype=bool, 

381 default=False, 

382 ) 

383 magMin = pexConfig.Field( 

384 doc="Minimum magnitude for the fake sources.", 

385 dtype=float, 

386 default=20, 

387 ) 

388 magMax = pexConfig.Field( 

389 doc="Maximum magnitude for the fake sources.", 

390 dtype=float, 

391 default=26, 

392 ) 

393 

394 

395class CreateVisitDetectorFakesTask(PipelineTask): 

396 """Create and store a set of visit detector fakes for use in AP processing. 

397 This task creates a catalog of fake sources that can be used to inject 

398 sources into visit detector images. 

399 """ 

400 

401 _DefaultName = "createVisitDetectorFakes" 

402 ConfigClass = CreateVisitDetectorFakesConfig 

403 

404 def __init__(self, **kwargs): 

405 super().__init__(**kwargs) 

406 self.log = logging.getLogger(__name__) 

407 

408 def runQuantum(self, butlerQC, inputRefs, outputRefs): 

409 inputs = butlerQC.get(inputRefs) 

410 outputs = self.run(**inputs) 

411 butlerQC.put(outputs, outputRefs) 

412 

413 def _make_unique_injection_ids(self, n_ids, used_ids=None): 

414 """Generate collision-free injection IDs within the 24-bit ID space.""" 

415 used = set() if used_ids is None else {int(value) for value in used_ids} 

416 injection_ids = [] 

417 

418 while len(injection_ids) < n_ids: 

419 candidate = uuid.uuid4().int & ((1 << 24) - 1) 

420 if candidate in used: 420 ↛ 421line 420 didn't jump to line 421 because the condition on line 420 was never true

421 continue 

422 used.add(candidate) 

423 injection_ids.append(candidate) 

424 

425 return np.asarray(injection_ids, dtype=np.int64) 

426 

427 def run(self, sourceCat, visit_image): 

428 """Create a set of visit detector fakes. 

429 

430 Parameters 

431 ---------- 

432 sourceCat : `lsst.afw.table.SourceCatalog` 

433 Catalog of sources detected on the calibrated exposure. 

434 visit_image : `lsst.afw.image.Exposure` 

435 Visit image to inject synthetic sources into. 

436 

437 Returns 

438 ------- 

439 outputCat : `astropy.table.Table` 

440 Catalog of fake sources to draw inputs from. 

441 """ 

442 # Use the visit+detector ids as the random seed. 

443 visitId = visit_image.getInfo().getVisitInfo().id 

444 detId = visit_image.detector.getId() 

445 rng = np.random.default_rng([visitId, detId]) 

446 

447 # set of catalogs to concatenate at the end 

448 catalog_set = [] 

449 

450 photoCalib = visit_image.getPhotoCalib() 

451 bbox = visit_image.getBBox() 

452 xmin, xmax = bbox.getMinX(), bbox.getMaxX() 

453 ymin, ymax = bbox.getMinY(), bbox.getMaxY() 

454 visit_stats = visit_image.getInfo().getSummaryStats() 

455 

456 wcs = visit_image.getWcs() 

457 magLim = visit_stats.magLim 

458 max_mag = np.min([magLim+1, self.config.magMax]) 

459 

460 if self.config.doAddRandomVisitFakes: 

461 # Generate random visit fakes 

462 self.log.info("Generating random visit fakes.") 

463 if self.config.nRandomFakes > 0: 463 ↛ 467line 463 didn't jump to line 467 because the condition on line 463 was always true

464 self.log.info(f"Generating random visit fakes with nRandomFakes={self.config.nRandomFakes}.") 

465 n_random_fakes = self.config.nRandomFakes 

466 else: 

467 self.log.info( 

468 f"Generating random visit fakes with randomFakeDensity={self.config.randomFakeDensity}.") 

469 n_random_fakes = self.get_n_fakes_from_density( 

470 visit_image=visit_image, 

471 density=self.config.randomFakeDensity 

472 ) 

473 self.log.info(f"Calculated n_random_fakes={n_random_fakes}.") 

474 

475 # draw random x-y coordinates 

476 x_ssi = rng.uniform(xmin, xmax, size=n_random_fakes) 

477 y_ssi = rng.uniform(ymin, ymax, size=n_random_fakes) 

478 mags = rng.uniform(self.config.magMin, max_mag, size=n_random_fakes) 

479 ra_ssi, dec_ssi = wcs.pixelToSkyArray(x_ssi, y_ssi, degrees=True) 

480 

481 random_catalog = Table() 

482 random_catalog["x"] = x_ssi 

483 random_catalog["y"] = y_ssi 

484 random_catalog["mag"] = mags 

485 random_catalog["ra"] = ra_ssi 

486 random_catalog["dec"] = dec_ssi 

487 random_catalog["source_type"] = "Star" 

488 random_catalog["isVisitSource"] = True 

489 random_catalog["isTemplateSource"] = False 

490 catalog_set.append(random_catalog) 

491 # Ignore now the possibility of _just_ template fakes 

492 if self.config.doAddModelFakes: 492 ↛ 494line 492 didn't jump to line 494 because the condition on line 492 was never true

493 # Generate model fakes 

494 self.log.info("Not implemented yet model fakes.") 

495 # Placeholder for actual model fake generation logic 

496 pass 

497 

498 if self.config.doAddHostedFakes: 

499 # Generate hosted fakes 

500 self.log.info("Generating hosted fakes.") 

501 # Select hosts that look like extended sources. 

502 hostcatalog = photoCalib.calibrateCatalog(sourceCat).asAstropy() 

503 hostcatalog = self.select_hosts(hostcatalog) 

504 n_hosts = len(hostcatalog) 

505 if n_hosts == 0: 

506 self.log.warning("Hosted fake generation requested, but no valid hosts were selected.") 

507 else: 

508 requested_n_fakes = max( 

509 int(self.config.fracHostedFakes * n_hosts), self.config.minHostedFakes 

510 ) 

511 n_fakes = min(requested_n_fakes, n_hosts) 

512 if n_fakes < requested_n_fakes: 512 ↛ 520line 512 didn't jump to line 520 because the condition on line 512 was always true

513 self.log.warning( 

514 "Reducing hosted fake count from %d to %d because only %d hosts are available.", 

515 requested_n_fakes, 

516 n_fakes, 

517 n_hosts, 

518 ) 

519 

520 idx = rng.choice(n_hosts, size=n_fakes, replace=False) 

521 hostcat = hostcatalog[idx] 

522 

523 x_hosts = hostcat['slot_Centroid_x'] 

524 y_hosts = hostcat['slot_Centroid_y'] 

525 mag_hosts = hostcat['slot_ModelFlux_mag'] 

526 # the units below are pixels and radians 

527 pa, a, b = self.get_PA_and_axes( 

528 hostcat['slot_Shape_xx'], 

529 hostcat['slot_Shape_xy'], 

530 hostcat['slot_Shape_yy'] 

531 ) 

532 # random radius and angle for the fake around the host 

533 theta = rng.uniform(0, 2 * np.pi, size=n_fakes) 

534 angle = np.sqrt((a*np.cos(theta))**2 + (b*np.sin(theta))**2) 

535 radii = angle * np.sqrt(rng.uniform(0, 6, size=n_fakes)) 

536 

537 # Polar -> Cartesian wrt the host in the PA coordinate system 

538 xs = radii * np.cos(theta) 

539 ys = radii * np.sin(theta) 

540 

541 # Retrieve the right position removing the galaxy orientation PA 

542 x_rots = xs * np.cos(pa) - ys * np.sin(pa) 

543 y_rots = xs * np.sin(pa) + ys * np.cos(pa) 

544 

545 x_ssi = x_hosts + x_rots 

546 y_ssi = y_hosts + y_rots 

547 

548 # retrieving the global ra dec position of the injection 

549 ra_ssi, dec_ssi = wcs.pixelToSkyArray(x_ssi, y_ssi, degrees=True) 

550 delta_ra = (ra_ssi - np.rad2deg(hostcat['coord_ra'])) * 3600. 

551 delta_dec = (dec_ssi - np.rad2deg(hostcat['coord_dec'])) * 3600. 

552 

553 delta_mag = rng.normal(loc=1, scale=1, size=n_fakes) 

554 mags = mag_hosts + delta_mag 

555 

556 # Create the table of hosted fakes 

557 hosted_fakes = Table() 

558 hosted_fakes["x"] = x_ssi 

559 hosted_fakes["y"] = y_ssi 

560 hosted_fakes["mag"] = mags 

561 hosted_fakes["ra"] = ra_ssi 

562 hosted_fakes["dec"] = dec_ssi 

563 hosted_fakes["host_id"] = hostcat['id'] 

564 hosted_fakes["host_flux"] = hostcat['slot_ModelFlux_flux'] 

565 hosted_fakes["host_mag"] = hostcat['slot_ModelFlux_mag'] 

566 hosted_fakes["host_ra"] = np.rad2deg(hostcat['coord_ra']) 

567 hosted_fakes["host_dec"] = np.rad2deg(hostcat['coord_dec']) 

568 hosted_fakes["delta_ra"] = delta_ra 

569 hosted_fakes["delta_dec"] = delta_dec 

570 hosted_fakes["delta_mag"] = delta_mag 

571 hosted_fakes["host_a"] = a 

572 hosted_fakes["host_b"] = b 

573 hosted_fakes["host_pa"] = pa 

574 hosted_fakes["source_type"] = "Star" 

575 hosted_fakes["hosted_fake"] = True 

576 hosted_fakes["isVisitSource"] = True 

577 hosted_fakes["isTemplateSource"] = False 

578 

579 catalog_set.append(hosted_fakes) 

580 

581 if not catalog_set: 

582 raise RuntimeError( 

583 "No fake sources were generated. Enable at least one fakes mode or provide usable hosts." 

584 ) 

585 

586 catalog = vstack(catalog_set) 

587 catalog['injection_id'] = self._make_unique_injection_ids(len(catalog)) 

588 

589 if self.config.doAddRandomTemplateFakes: 

590 is_tmplt_fake = rng.random(len(catalog)) < self.config.templateFakeFraction 

591 catalog["isTemplateSource"] = is_tmplt_fake 

592 catalog["isVisitSource"] = ~is_tmplt_fake 

593 else: 

594 catalog["isVisitSource"] = True 

595 catalog["isTemplateSource"] = False 

596 

597 if self.config.doAddVariableFakes: 

598 # Generate variable fakes by duplicating some fakes and adding the counterpart 

599 # either science or template with a magnitude offset drawn from a normal 

600 # distribution with mean and std defined in the config. 

601 self.log.info("Generating variable fakes.") 

602 n_variable_fakes = int(len(catalog) * self.config.variableFakeFraction) 

603 idx = rng.choice(len(catalog), size=n_variable_fakes, replace=False) 

604 variable_fakes = catalog[idx].copy() 

605 variable_fakes["mag_offset"] = rng.normal( 

606 loc=self.config.variableFakeMean, 

607 scale=self.config.variableFakeStd, 

608 size=n_variable_fakes 

609 ) 

610 variable_fakes["mag"] += variable_fakes["mag_offset"] 

611 # we flip the source, so for example if it was a visit, we trasnform it into a template 

612 # with the idea of having duplicate injections, in the same location 

613 variable_fakes["isVisitSource"] = ~variable_fakes["isVisitSource"] 

614 variable_fakes["isTemplateSource"] = ~variable_fakes["isTemplateSource"] 

615 

616 variable_fakes["twin_id"] = variable_fakes["injection_id"] 

617 variable_fakes["injection_id"] = self._make_unique_injection_ids( 

618 len(variable_fakes), 

619 used_ids=catalog["injection_id"], 

620 ) 

621 # create column of isVariable flag 

622 catalog["isVariable"] = np.where(np.isin(np.arange(len(catalog)), idx), True, False) 

623 variable_fakes["isVariable"] = True 

624 

625 catalog = vstack([catalog, variable_fakes]) 

626 

627 if len(catalog) > len(np.unique(catalog["injection_id"])): 627 ↛ 628line 627 didn't jump to line 628 because the condition on line 627 was never true

628 self.log.warning("Duplicate injection IDs detected after catalog assembly; reassigning them.") 

629 old_injection_ids = np.asarray(catalog["injection_id"], dtype=np.int64) 

630 new_injection_ids = self._make_unique_injection_ids(len(catalog)) 

631 # re-assign fresh injection ids 

632 catalog["injection_id"] = new_injection_ids 

633 if "twin_id" in catalog.colnames: 

634 id_map = {old_id: new_id for old_id, new_id in zip(old_injection_ids, new_injection_ids)} 

635 catalog["twin_id"] = np.asarray( 

636 [id_map.get(int(twin_id), int(twin_id)) for twin_id in catalog["twin_id"]], 

637 dtype=np.int64, 

638 ) 

639 

640 catalog["visit"] = visitId 

641 catalog["detector"] = detId 

642 

643 return Struct(outputCat=catalog) 

644 

645 def select_hosts(self, sourceCat): 

646 """ 

647 Selects host sources from a given source catalog based on a series of classification and flux cuts. 

648 The selection criteria are: 

649 - The 'base_ClassificationSizeExtendedness_flag' and 

650 'base_ClassificationExtendedness_flag' must both be False. 

651 - The 'base_ClassificationSizeExtendedness_value' must be greater than 0.9. 

652 - The 'base_ClassificationExtendedness_value' must be equal to 1. 

653 - The 'base_PsfFlux_flux' must be greater than 0. 

654 Parameters 

655 ---------- 

656 sourceCat : SourceCatalog 

657 The source catalog containing the columns required for selection. 

658 *args, **kwargs 

659 Additional arguments (not used). 

660 Returns 

661 ------- 

662 hostCat : ArrowAstropy 

663 A deep copy of the subset of the source catalog that passes all selection criteria. 

664 """ 

665 

666 # Avoid calibration stars or psf stars; remove flagged sources sky_sources 

667 skySourceCut = ~sourceCat['sky_source'] 

668 

669 flagCut = ~sourceCat['base_ClassificationSizeExtendedness_flag'] 

670 flagCut &= ~sourceCat['base_ClassificationExtendedness_flag'] 

671 flagCut &= ~sourceCat['slot_Shape_flag'] 

672 flagCut &= ~sourceCat['slot_Centroid_flag'] 

673 flagCut &= ~sourceCat['base_PixelFlags_flag'] 

674 

675 extendednessCut = sourceCat['base_ClassificationSizeExtendedness_value'] > 0.9 

676 extendednessCut &= sourceCat['base_ClassificationExtendedness_value'] == 1 

677 

678 snrCut = sourceCat['slot_ModelFlux_flux']/sourceCat['slot_ModelFlux_fluxErr'] > 15 

679 

680 hostCat = sourceCat[ 

681 skySourceCut & flagCut & extendednessCut & snrCut].copy() 

682 return hostCat 

683 

684 def get_PA_and_axes(self, Ixx, Ixy, Iyy): 

685 ''' 

686 Calculates the orientation and extent of an object based on its second moments. 

687 

688 Parameters: 

689 Ixx (float): Second moment of the object along the x-axis. Often in degree² 

690 Ixy (float): Second moment of the object along the x and y-axes. Often in degree² 

691 Iyy (float): Second moment of the object along the y-axis. Often in degree² 

692 

693 Returns: 

694 tuple: A tuple containing: 

695 - theta (float): The orientation angle of the object in radians. 

696 - a (float): The semi-major axis length of the object. 

697 - b (float): The semi-minor axis length of the object. 

698 ''' 

699 # Calculate position angle (orientation) 

700 theta = 0.5 * np.arctan2(2 * Ixy, Ixx - Iyy) 

701 

702 # Calculate eigenvalues of the moment matrix 

703 term1 = (Ixx + Iyy) / 2 

704 term2 = np.sqrt(((Ixx - Iyy) / 2) ** 2 + Ixy ** 2) 

705 lambda1 = term1 + term2 

706 lambda2 = term1 - term2 

707 

708 a = np.sqrt(lambda1) 

709 b = np.sqrt(lambda2) 

710 

711 return theta, a, b 

712 

713 def get_n_fakes_from_density(self, visit_image, density): 

714 """Calculate the area of the injection limits in square degrees based on the RA and Dec limits.""" 

715 image_area = visit_image.getConvexPolygon().getBoundingBox().getArea() 

716 image_area *= (180 / np.pi) ** 2 

717 number = np.round(density * image_area).astype(int) 

718 return number