Coverage for python / lsst / ap / association / filterDiaSourceCatalog.py: 30%

119 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-21 00:07 -0800

1# This file is part of ap_association 

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 

22__all__ = ( 

23 "FilterDiaSourceCatalogConfig", 

24 "FilterDiaSourceCatalogTask", 

25 "FilterDiaSourceReliabilityConfig", 

26 "FilterDiaSourceReliabilityTask" 

27) 

28 

29import numpy as np 

30 

31import lsst.pex.config as pexConfig 

32import lsst.pipe.base as pipeBase 

33import lsst.pipe.base.connectionTypes as connTypes 

34from lsst.utils.timer import timeMethod 

35 

36 

37class FilterDiaSourceCatalogConnections( 

38 pipeBase.PipelineTaskConnections, 

39 dimensions=("instrument", "visit", "detector"), 

40 defaultTemplates={"coaddName": "deep", "fakesType": ""}, 

41): 

42 """Connections class for FilterDiaSourceCatalogTask.""" 

43 

44 diaSourceCat = connTypes.Input( 

45 doc="Catalog of DiaSources produced during image differencing.", 

46 name="{fakesType}{coaddName}Diff_diaSrc", 

47 storageClass="SourceCatalog", 

48 dimensions=("instrument", "visit", "detector"), 

49 ) 

50 

51 diffImVisitInfo = connTypes.Input( 

52 doc="VisitInfo of diffIm.", 

53 name="{fakesType}{coaddName}Diff_differenceExp.visitInfo", 

54 storageClass="VisitInfo", 

55 dimensions=("instrument", "visit", "detector"), 

56 ) 

57 

58 filteredDiaSourceCat = connTypes.Output( 

59 doc="Output catalog of DiaSources after filtering.", 

60 name="{fakesType}{coaddName}Diff_candidateDiaSrc", 

61 storageClass="SourceCatalog", 

62 dimensions=("instrument", "visit", "detector"), 

63 ) 

64 

65 rejectedDiaSources = connTypes.Output( 

66 doc="Optional output storing all the rejected DiaSources.", 

67 name="{fakesType}{coaddName}Diff_rejectedDiaSrc", 

68 storageClass="SourceCatalog", 

69 dimensions={"instrument", "visit", "detector"}, 

70 ) 

71 

72 longTrailedSources = connTypes.Output( 

73 doc="Optional output temporarily storing long trailed diaSources.", 

74 dimensions=("instrument", "visit", "detector"), 

75 storageClass="ArrowAstropy", 

76 name="{fakesType}{coaddName}Diff_longTrailedSrc", 

77 ) 

78 

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

80 super().__init__(config=config) 

81 if not self.config.doWriteRejectedSkySources: 

82 self.outputs.remove("rejectedDiaSources") 

83 if not self.config.doTrailedSourceFilter: 

84 self.outputs.remove("longTrailedSources") 

85 if not self.config.doWriteTrailedSources: 

86 self.outputs.remove("longTrailedSources") 

87 

88 

89class FilterDiaSourceCatalogConfig( 

90 pipeBase.PipelineTaskConfig, pipelineConnections=FilterDiaSourceCatalogConnections 

91): 

92 """Config class for FilterDiaSourceCatalogTask.""" 

93 

94 doRemoveSkySources = pexConfig.Field( 

95 dtype=bool, 

96 default=False, 

97 doc="Input DiaSource catalog contains SkySources that should be " 

98 "removed before storing the output DiaSource catalog.", 

99 ) 

100 

101 doWriteRejectedSkySources = pexConfig.Field( 

102 dtype=bool, 

103 default=True, 

104 doc="Store the output DiaSource catalog containing all the rejected " 

105 "sky sources." 

106 ) 

107 

108 badFlagList = pexConfig.ListField( 

109 dtype=str, 

110 default=[ 

111 "base_PixelFlags_flag_crCenter", 

112 "base_PixelFlags_flag_high_varianceCenterAll" 

113 ], 

114 doc="List of flags which cause a source to be removed.", 

115 ) 

116 

117 doRemoveNegativeDirectImageSources = pexConfig.Field( 

118 dtype=bool, 

119 default=True, 

120 doc="Remove DIASources with negative scienceFlux/scienceFluxErr " 

121 "according to a configurable threshold.", 

122 ) 

123 

124 minAllowedDirectSnr = pexConfig.Field( 

125 dtype=float, 

126 doc="Minimum allowed ratio of scienceFlux/scienceFluxErr.", 

127 default=-2.0, 

128 ) 

129 

130 doTrailedSourceFilter = pexConfig.Field( 

131 doc="Run trailedSourceFilter to remove long trailed sources from the" 

132 "diaSource output catalog.", 

133 dtype=bool, 

134 default=True, 

135 ) 

136 

137 doWriteTrailedSources = pexConfig.Field( 

138 doc="Write trailed diaSources sources to a table.", 

139 dtype=bool, 

140 default=True, 

141 deprecated="Trailed sources will not be written out during production." 

142 ) 

143 

144 max_trail_length = pexConfig.Field( 

145 dtype=float, 

146 doc="Length of long trailed sources to remove from the input catalog, " 

147 "in arcseconds per second. Default comes from DMTN-199, which " 

148 "requires removal of sources with trails longer than 10 " 

149 "degrees/day, which is 36000/3600/24 arcsec/second, or roughly" 

150 "0.416 arcseconds per second.", 

151 default=36000/3600.0/24.0, 

152 ) 

153 estimatedPixelScale = pexConfig.Field( 

154 dtype=float, 

155 doc="Approximate plate scale, in arcseconds/pixel." 

156 "Used to convert trail length if the catalog calculation fails.", 

157 default=0.2, 

158 ) 

159 

160 

161class FilterDiaSourceCatalogTask(pipeBase.PipelineTask): 

162 """Filter sources from a DiaSource catalog.""" 

163 

164 ConfigClass = FilterDiaSourceCatalogConfig 

165 _DefaultName = "filterDiaSourceCatalog" 

166 

167 @timeMethod 

168 def run(self, diaSourceCat, diffImVisitInfo): 

169 """Filter sources from the supplied DiaSource catalog. 

170 

171 Parameters 

172 ---------- 

173 diaSourceCat : `lsst.afw.table.SourceCatalog` 

174 Catalog of sources measured on the difference image. 

175 diffImVisitInfo: `lsst.afw.image.VisitInfo` 

176 VisitInfo for the difference image corresponding to diaSourceCat. 

177 

178 Returns 

179 ------- 

180 filterResults : `lsst.pipe.base.Struct` 

181 

182 ``filteredDiaSourceCat`` : `lsst.afw.table.SourceCatalog` 

183 The catalog of filtered sources. 

184 ``rejectedDiaSources`` : `lsst.afw.table.SourceCatalog` 

185 The catalog of rejected sources. 

186 ``longTrailedDiaSources`` : `astropy.table.Table` 

187 DiaSources which have trail lengths greater than 

188 max_trail_length*exposure_time. 

189 """ 

190 rejectedSources = None 

191 exposure_time = diffImVisitInfo.exposureTime 

192 rejected_mask = np.zeros(len(diaSourceCat), dtype=bool) 

193 if self.config.doRemoveSkySources: 

194 sky_source_column = diaSourceCat["sky_source"] 

195 self.log.info(f"Filtered {np.sum(sky_source_column & ~rejected_mask)} sky sources.") 

196 rejected_mask |= sky_source_column 

197 

198 for flag in self.config.badFlagList: 

199 flag_mask = diaSourceCat[flag] 

200 self.log.info(f"Filtered {np.sum(flag_mask & ~rejected_mask)} sources with flag {flag}.") 

201 rejected_mask |= flag_mask 

202 

203 if self.config.doRemoveNegativeDirectImageSources: 

204 direct_snr = (diaSourceCat["ip_diffim_forced_PsfFlux_instFlux"] 

205 / diaSourceCat["ip_diffim_forced_PsfFlux_instFluxErr"]) 

206 too_negative = direct_snr < self.config.minAllowedDirectSnr 

207 self.log.info(f"Filtered {np.sum(too_negative & ~rejected_mask)} negative direct sources.") 

208 rejected_mask |= too_negative 

209 

210 if self.config.doTrailedSourceFilter: 

211 trail_mask = self._check_dia_source_trail(diaSourceCat, exposure_time) 

212 longTrailedDiaSources = diaSourceCat[trail_mask].copy(deep=True) 

213 rejectedSources = diaSourceCat[rejected_mask].copy(deep=True) 

214 rejected_mask |= trail_mask 

215 diaSourceCat = diaSourceCat[~rejected_mask].copy(deep=True) 

216 

217 self.log.info("%i DiaSources exceed max_trail_length %f arcseconds per second, " 

218 "dropping from source catalog." 

219 % (len(longTrailedDiaSources), self.config.max_trail_length)) 

220 self.metadata.add("num_filtered", len(longTrailedDiaSources)) 

221 

222 if self.config.doWriteTrailedSources: 

223 filterResults = pipeBase.Struct(filteredDiaSourceCat=diaSourceCat, 

224 rejectedDiaSources=rejectedSources, 

225 longTrailedSources=longTrailedDiaSources.asAstropy()) 

226 else: 

227 filterResults = pipeBase.Struct(filteredDiaSourceCat=diaSourceCat, 

228 rejectedDiaSources=rejectedSources) 

229 else: 

230 rejectedSources = diaSourceCat[rejected_mask].copy(deep=True) 

231 diaSourceCat = diaSourceCat[~rejected_mask].copy(deep=True) 

232 filterResults = pipeBase.Struct(filteredDiaSourceCat=diaSourceCat, 

233 rejectedDiaSources=rejectedSources) 

234 return filterResults 

235 

236 def _check_dia_source_trail(self, dia_sources, exposure_time): 

237 """Find DiaSources that have long trails or trails with indeterminant 

238 end points. 

239 

240 Return a mask of sources with lengths greater than 

241 (``config.max_trail_length`` multiplied by the exposure time) 

242 arcseconds. 

243 Additionally, set mask if 

244 ``ext_trailedSources_Naive_flag_off_image`` is set or if 

245 ``ext_trailedSources_Naive_flag_suspect_long_trail`` and 

246 ``ext_trailedSources_Naive_flag_edge`` are both set. 

247 

248 Parameters 

249 ---------- 

250 dia_sources : `pandas.DataFrame` 

251 Input diaSources to check for trail lengths. 

252 exposure_time : `float` 

253 Exposure time from difference image. 

254 

255 Returns 

256 ------- 

257 trail_mask : `pandas.DataFrame` 

258 Boolean mask for diaSources which are greater than the 

259 Boolean mask for diaSources which are greater than the 

260 cutoff length or have trails which extend beyond the edge of the 

261 detector (off_image set). Also checks if both 

262 suspect_long_trail and edge are set and masks those sources out. 

263 """ 

264 pixelScale = self._estimate_pixel_scale(dia_sources) 

265 trail_mask = (dia_sources["ext_trailedSources_Naive_length"] 

266 >= (self.config.max_trail_length*exposure_time/pixelScale)) 

267 trail_mask |= dia_sources['ext_trailedSources_Naive_flag_off_image'] 

268 trail_mask |= (dia_sources['ext_trailedSources_Naive_flag_suspect_long_trail'] 

269 & dia_sources['ext_trailedSources_Naive_flag_edge']) 

270 

271 return trail_mask 

272 

273 def _estimate_pixel_scale(self, catalog): 

274 """Quickly calculate the pixel scale from catalog values 

275 

276 Will return a fallback value from the task config if there is any error 

277 

278 Parameters 

279 ---------- 

280 catalog : `lsst.afw.table.SourceCatalog` 

281 Catalog of sources measured on the difference image. 

282 

283 Returns 

284 ------- 

285 scale : `float` 

286 Pixel scale of the catalog, in arcseconds/pixel 

287 """ 

288 nSrc = len(catalog) 

289 if nSrc < 2: 

290 return self.config.estimatedPixelScale 

291 try: 

292 coordKey = catalog.getCoordKey() 

293 decVals = catalog[coordKey.getDec()] # in radians 

294 raVals = catalog[coordKey.getRa()] # in radians 

295 xVals = catalog.getX() 

296 yVals = catalog.getY() 

297 # Find two points that are well separated for the calculation 

298 # Start with a point near one edge, and find the furthest point 

299 # from there 

300 iMin = np.argmin(xVals) 

301 dist = np.sqrt((xVals[iMin] - xVals)**2 + (yVals[iMin] - yVals)**2) 

302 iMax = np.argmax(dist) 

303 # Use the spherical law of cosines: 

304 t1 = np.sin(decVals[iMin])*np.sin(decVals[iMax]) 

305 t2 = np.cos(decVals[iMin])*np.cos(decVals[iMax])*np.cos(raVals[iMin] - raVals[iMax]) 

306 separation = np.arccos(t1 + t2) # in radians 

307 scale = separation/max(dist)*3600*180/np.pi # convert to arcseconds/pixel 

308 except Exception as e: 

309 self.log.warning("Error encountered estimating the pixel scale from the catalog: %s", e) 

310 return self.config.estimatedPixelScale 

311 else: 

312 if abs(scale - self.config.estimatedPixelScale)/self.config.estimatedPixelScale > 0.1: 

313 self.log.warning(f"Calculated pixel scale of {scale} too different from estimated value " 

314 f"{self.config.estimatedPixelScale}. Falling back on estimate.") 

315 return self.config.estimatedPixelScale 

316 else: 

317 return scale 

318 

319 

320class FilterDiaSourceReliabilityConnections( 

321 pipeBase.PipelineTaskConnections, 

322 dimensions=("instrument", "visit", "detector"), 

323 defaultTemplates={"coaddName": "deep", "fakesType": ""} 

324): 

325 diaSourceCat = connTypes.Input( 

326 doc="Catalog of DiaSources produced during image differencing.", 

327 name="{fakesType}{coaddName}Diff_candidateDiaSrc", 

328 storageClass="SourceCatalog", 

329 dimensions=("instrument", "visit", "detector"), 

330 ) 

331 reliability = connTypes.Input( 

332 doc="Reliability (e.g. real/bogus) classificiation of diaSourceCat sources.", 

333 name="{fakesType}{coaddName}RealBogusSources", 

334 storageClass="Catalog", 

335 dimensions=("instrument", "visit", "detector"), 

336 ) 

337 filteredDiaSources = connTypes.Output( 

338 doc="Accepted diaSource catalog filtered by reliability score.", 

339 name="dia_source_high_reliability", 

340 storageClass="SourceCatalog", 

341 dimensions=("instrument", "visit", "detector"), 

342 ) 

343 rejectedDiaSources = connTypes.Output( 

344 doc="Rejected diaSource catalog with low reliability scores.", 

345 name="dia_source_low_reliability", 

346 storageClass="SourceCatalog", 

347 dimensions=("instrument", "visit", "detector"), 

348 ) 

349 

350 

351class FilterDiaSourceReliabilityConfig( 

352 pipeBase.PipelineTaskConfig, pipelineConnections=FilterDiaSourceReliabilityConnections 

353): 

354 """Configuration for the FilterDiaSourceReliabilityTask.""" 

355 minReliability = pexConfig.Field( 

356 doc="Minimum reliability score to keep a source in the DiaSource catalog.", 

357 dtype=float, 

358 default=0.0, 

359 ) 

360 

361 

362class FilterDiaSourceReliabilityTask(pipeBase.PipelineTask): 

363 """Filter DiaSource catalog by reliability score. 

364 

365 Parameters 

366 ---------- 

367 diaSourceSchema: `lsst.afw.table.Schema` 

368 Schema for the input DiaSource catalog. 

369 diaSourceCat : `lsst.afw.table.SourceCatalog` 

370 Catalog of DiaSources produced during image differencing. 

371 reliability : `lsst.afw.table.SourceCatalog`, optional 

372 Reliability (e.g. real/bogus) classification of the sources in `diaSourceCat`. 

373 

374 Returns 

375 ------- 

376 filteredResults : `lsst.pipe.base.Struct` 

377 

378 ``filteredDiaSources`` : `lsst.afw.table.SourceCatalog` 

379 Catalog of unstandardized DiaSources filtered by reliability score. 

380 ``rejectedDiaSources`` : `lsst.afw.table.SourceCatalog` 

381 Catalog of unstandardized DiaSources that were rejected due to low 

382 reliability scores. 

383 """ 

384 

385 ConfigClass = FilterDiaSourceReliabilityConfig 

386 _DefaultName = "filterDiaSourceReliability" 

387 

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

389 inputs = butlerQC.get(inputRefs) 

390 outputs = self.run(**inputs) 

391 butlerQC.put(outputs, outputRefs) 

392 

393 def run(self, diaSourceCat, reliability): 

394 """Run the task to filter DiaSources by reliability.""" 

395 

396 # Copy the scores in the output catalog 

397 if np.all(diaSourceCat['id'] == reliability['id']): 

398 diaSourceCat['reliability'] = reliability['score'] 

399 else: 

400 # If the identifiers do not match, we cannot filter reliably. 

401 raise ValueError( 

402 "Reliability ids do not match DiaSource ids.") 

403 

404 # Filter the DiaSource catalog by reliability score 

405 low_reliability = diaSourceCat["reliability"] < self.config.minReliability 

406 rejectedDiaSources = diaSourceCat[low_reliability].copy(deep=True) 

407 filteredDiaSources = diaSourceCat[~low_reliability].copy(deep=True) 

408 

409 self.log.info(f"Filtered {np.sum(low_reliability)} sources with low reliability.") 

410 

411 return pipeBase.Struct( 

412 filteredDiaSources=filteredDiaSources, 

413 rejectedDiaSources=rejectedDiaSources 

414 )