Coverage for python / lsst / analysis / tools / tasks / sourceObjectTableAnalysis.py: 27%

188 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-13 02:03 -0800

1# This file is part of analysis_tools. 

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/>. 

21from __future__ import annotations 

22 

23__all__ = ( 

24 "SourceObjectTableAnalysisConfig", 

25 "SourceObjectTableAnalysisTask", 

26 "ObjectEpochTableConfig", 

27 "ObjectEpochTableTask", 

28) 

29 

30import astropy.time 

31import astropy.units as u 

32import lsst.pex.config as pexConfig 

33import lsst.pipe.base as pipeBase 

34import numpy as np 

35import pandas as pd 

36from astropy.table import Table, join, vstack 

37from lsst.drp.tasks.gbdesAstrometricFit import calculate_apparent_motion 

38from lsst.pipe.base import AlgorithmError 

39from lsst.pipe.base import connectionTypes as ct 

40from smatch import Matcher 

41 

42from ..interfaces import AnalysisBaseConfig, AnalysisBaseConnections, AnalysisPipelineTask 

43 

44 

45class IndexMismatchError(AlgorithmError): 

46 """Raised if the indices in input associatedSources do not match the input 

47 data.""" 

48 

49 def __init__(self) -> None: 

50 super().__init__( 

51 "Not all sourceIds in the associated sources catalog are available in the input data." 

52 ) 

53 

54 @property 

55 def metadata(self) -> dict: 

56 """There is no metadata associated with this error.""" 

57 return {} 

58 

59 

60class NoMatchError(AlgorithmError): 

61 """Raised if there are no matches between the source and reference 

62 catalogs. This can happen if areas of the source or reference image were 

63 not processed successfully.""" 

64 

65 def __init__(self, targetCatalogSize, refCatalogSize) -> None: 

66 self._metadata = {"targetCatalogSize": targetCatalogSize, "refCatalogSize": refCatalogSize} 

67 super().__init__("No matches were made between the source and reference catalogs.") 

68 

69 @property 

70 def metadata(self) -> dict: 

71 for key, value in self._metadata.items(): 

72 if not isinstance(value, int | float | str): 

73 raise TypeError(f"{key} is of type {type(value)}, but only (int, float, str) are allowed.") 

74 return self._metadata 

75 

76 

77class ObjectEpochTableConnections( 

78 pipeBase.PipelineTaskConnections, 

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

80): 

81 objectCat = ct.Input( 

82 doc="Catalog of positions in each patch.", 

83 name="objectTable", 

84 storageClass="ArrowAstropy", 

85 dimensions=["skymap", "tract", "patch"], 

86 multiple=True, 

87 deferLoad=True, 

88 deferGraphConstraint=True, 

89 ) 

90 

91 epochMap = ct.Input( 

92 doc="Healsparse map of mean epoch of objectCat in each band.", 

93 name="deepCoadd_epoch_map_mean", 

94 storageClass="HealSparseMap", 

95 dimensions=("skymap", "tract", "band"), 

96 multiple=True, 

97 deferLoad=True, 

98 ) 

99 

100 objectEpochs = ct.Output( 

101 doc="Catalog of epochs for objectCat objects.", 

102 name="object_epoch", 

103 storageClass="ArrowAstropy", 

104 dimensions=["skymap", "tract", "patch"], 

105 multiple=True, 

106 ) 

107 

108 

109class ObjectEpochTableConfig(pipeBase.PipelineTaskConfig, pipelineConnections=ObjectEpochTableConnections): 

110 bands = pexConfig.ListField( 

111 doc=("Bands in objectCat to be combined with `objectCat_selectors` to build objectCat column names."), 

112 dtype=str, 

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

114 ) 

115 

116 

117class ObjectEpochTableTask(pipeBase.PipelineTask): 

118 """Collect mean epochs for the observations that went into each object. 

119 

120 TODO: DM-46202, Remove this task once the object epochs are available 

121 elsewhere. 

122 """ 

123 

124 ConfigClass = ObjectEpochTableConfig 

125 _DefaultName = "objectEpochTable" 

126 

127 def getEpochs(self, cat, epochMapDict): 

128 """Get mean epoch of the visits corresponding to object position. 

129 

130 Parameters 

131 ---------- 

132 cat : `astropy.table.Table` 

133 Catalog containing object positions. 

134 epochMapDict: `dict` [`DeferredDatasetHandle`] 

135 Dictionary of handles for healsparse maps containing the mean epoch 

136 for positions in the reference catalog. 

137 

138 Returns 

139 ------- 

140 epochDf = `astropy.table.Table` 

141 Catalog with mean epoch of visits at each object position. 

142 """ 

143 allEpochs = {} 

144 for band in self.config.bands: 

145 epochs = np.ones(len(cat)) * np.nan 

146 validPositions = np.isfinite(cat[f"{band}_ra"]) & np.isfinite(cat[f"{band}_dec"]) 

147 if validPositions.any(): 

148 bandEpochs = epochMapDict[band].get_values_pos( 

149 cat[f"{band}_ra"][validPositions], cat[f"{band}_dec"][validPositions] 

150 ) 

151 epochsValid = epochMapDict[band].get_values_pos( 

152 cat[f"{band}_ra"][validPositions], cat[f"{band}_dec"][validPositions], valid_mask=True 

153 ) 

154 bandEpochs[~epochsValid] = np.nan 

155 epochs[validPositions] = bandEpochs 

156 allEpochs[f"{band}_epoch"] = epochs 

157 allEpochs["objectId"] = cat["objectId"] 

158 

159 epochTable = Table(allEpochs) 

160 return epochTable 

161 

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

163 inputs = butlerQC.get(inputRefs) 

164 

165 columns = [f"{band}_{coord}" for band in self.config.bands for coord in ["ra", "dec"]] 

166 columns.append("objectId") 

167 

168 inputs["epochMap"] = {ref.dataId["band"]: ref.get() for ref in inputs["epochMap"]} 

169 

170 outputEpochRefs = {outputRef.dataId["patch"]: outputRef for outputRef in outputRefs.objectEpochs} 

171 for objectCatRef in inputs["objectCat"]: 

172 patch = objectCatRef.dataId["patch"] 

173 objectCat = objectCatRef.get(parameters={"columns": columns}) 

174 epochs = self.getEpochs(objectCat, inputs["epochMap"]) 

175 butlerQC.put(epochs, outputEpochRefs[patch]) 

176 

177 

178class SourceObjectTableAnalysisConnections( 

179 AnalysisBaseConnections, 

180 dimensions=("visit",), 

181 defaultTemplates={ 

182 "inputName": "sourceTable_visit", 

183 "inputCoaddName": "deep", 

184 "associatedSourcesInputName": "isolated_star_presources", 

185 "associatedSourceIdsInputName": "isolated_star_presource_associations", 

186 "outputName": "sourceObjectTable", 

187 }, 

188): 

189 data = ct.Input( 

190 doc="Visit based source table to load from the butler", 

191 name="sourceTable_visit", 

192 storageClass="ArrowAstropy", 

193 dimensions=("visit",), 

194 deferLoad=True, 

195 ) 

196 

197 associatedSources = ct.Input( 

198 doc="Table of associated sources", 

199 name="{associatedSourcesInputName}", 

200 storageClass="ArrowAstropy", 

201 multiple=True, 

202 deferLoad=True, 

203 dimensions=("instrument", "skymap", "tract"), 

204 deferGraphConstraint=True, 

205 ) 

206 

207 associatedSourceIds = ct.Input( 

208 doc="Table containing unique ids for the associated sources", 

209 name="{associatedSourceIdsInputName}", 

210 storageClass="ArrowAstropy", 

211 deferLoad=True, 

212 multiple=True, 

213 dimensions=("instrument", "skymap", "tract"), 

214 deferGraphConstraint=True, 

215 ) 

216 

217 refCat = ct.Input( 

218 doc="Catalog of positions to use as reference.", 

219 name="objectTable", 

220 storageClass="DataFrame", 

221 dimensions=["skymap", "tract", "patch"], 

222 multiple=True, 

223 deferLoad=True, 

224 deferGraphConstraint=True, 

225 ) 

226 astrometricCorrectionCatalog = ct.Input( 

227 doc="Catalog containing proper motions and parallaxes.", 

228 name="isolated_star_stellar_motions", 

229 storageClass="ArrowAstropy", 

230 dimensions=("instrument", "skymap", "tract"), 

231 multiple=True, 

232 deferLoad=True, 

233 ) 

234 refCatEpochs = ct.Input( 

235 doc="Catalog of epochs for refCat objects.", 

236 name="object_epoch", 

237 storageClass="ArrowAstropy", 

238 dimensions=["skymap", "tract", "patch"], 

239 multiple=True, 

240 deferLoad=True, 

241 ) 

242 visitTable = ct.Input( 

243 doc="Catalog containing visit information.", 

244 name="visitTable", 

245 storageClass="DataFrame", 

246 dimensions=("instrument",), 

247 ) 

248 

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

250 super().__init__(config=config) 

251 

252 if not config.applyAstrometricCorrections: 

253 self.inputs.remove("astrometricCorrectionCatalog") 

254 self.inputs.remove("refCatEpochs") 

255 self.inputs.remove("visitTable") 

256 

257 

258class SourceObjectTableAnalysisConfig( 

259 AnalysisBaseConfig, pipelineConnections=SourceObjectTableAnalysisConnections 

260): 

261 ra_column = pexConfig.Field( 

262 doc="Name of column in refCat to use for right ascension.", 

263 dtype=str, 

264 default="r_ra", 

265 ) 

266 dec_column = pexConfig.Field( 

267 doc="Name of column in refCat to use for declination.", 

268 dtype=str, 

269 default="r_dec", 

270 ) 

271 epoch_column = pexConfig.Field( 

272 doc=( 

273 "Name of column in refCat corresponding to the epoch to which " 

274 "sources will be shifted. Should correspond to the positions in " 

275 "`ra_column` and `dec_column`." 

276 ), 

277 dtype=str, 

278 default="r_epoch", 

279 ) 

280 refCat_bands = pexConfig.ListField( 

281 doc=("Bands in refCat to be combined with `refCat_selectors` to build refCat column names."), 

282 dtype=str, 

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

284 ) 

285 refCat_selectors = pexConfig.ListField( 

286 doc=( 

287 "Remove objects for which these flags are true. These strings are combined with `refCat_bands`" 

288 " to build the full refCat column names" 

289 ), 

290 dtype=str, 

291 default=["pixelFlags_saturated", "pixelFlags_saturatedCenter"], 

292 ) 

293 refCatMatchingRadius = pexConfig.Field( 

294 dtype=float, 

295 default=1.0, 

296 doc=( 

297 "Radius in mas with which to match the mean positions of the sources with the positions in the" 

298 " reference catalog." 

299 ), 

300 ) 

301 applyAstrometricCorrections = pexConfig.Field( 

302 dtype=bool, 

303 default=True, 

304 doc="Apply proper motions and parallaxes to source positions.", 

305 ) 

306 correctionsMatchingRadius = pexConfig.Field( 

307 dtype=float, 

308 default=0.2, 

309 doc=( 

310 "Radius in mas with which to match the mean positions of the sources with the positions in the" 

311 " astrometricCorrectionCatalog." 

312 ), 

313 ) 

314 astrometricCorrectionParameters = pexConfig.DictField( 

315 keytype=str, 

316 itemtype=str, 

317 default={ 

318 "ra": "ra", 

319 "dec": "dec", 

320 "pmRA": "raPM", 

321 "pmDec": "decPM", 

322 "parallax": "parallax", 

323 "isolated_star_id": "isolated_star_id", 

324 }, 

325 doc="Column names for position and motion parameters in the astrometric correction catalogs.", 

326 ) 

327 

328 def setDefaults(self): 

329 super().setDefaults() 

330 from ..atools import TargetRefCatDeltaColorMetrics 

331 

332 self.atools.astromColorDiffMetrics = TargetRefCatDeltaColorMetrics 

333 

334 

335class SourceObjectTableAnalysisTask(AnalysisPipelineTask): 

336 ConfigClass = SourceObjectTableAnalysisConfig 

337 _DefaultName = "sourceObjectTableAnalysis" 

338 

339 def callback(self, inputs, dataId): 

340 """Callback function to be used with reconstructor.""" 

341 return self.prepareAssociatedSources( 

342 dataId["visit"], 

343 inputs["data"], 

344 inputs["associatedSources"], 

345 inputs["associatedSourceIds"], 

346 inputs["refCat"], 

347 inputs["visitTable"], 

348 inputs["astrometricCorrectionCatalog"], 

349 ) 

350 

351 def applyAstrometricCorrections( 

352 self, isolatedSources, astrometricCorrectionCatalog, visitTable, visit, refEpochs 

353 ): 

354 """Shift source positions to match the epoch of the reference catalog 

355 objects. 

356 

357 Parameters 

358 ---------- 

359 isolatedSources : `astropy.table.Table` 

360 Catalog of sources which will be modified in place with the 

361 astrometric corrections. 

362 astrometricCorrectionCatalog : `astropy.table.Table` 

363 Catalog with proper motion and parallax information. 

364 visitTable : `pd.DataFrame` 

365 Catalog containing the epoch for the visit corresponding to the 

366 isolatedSources. 

367 visit : `int` 

368 Identifier of the isolatedSources' visit. 

369 """ 

370 if visitTable.index.name is None: 

371 # The expected index may or may not be set, depending on whether 

372 # the table was written originally as a DataFrame or something else 

373 # Parquet-friendly. 

374 visitTable.set_index("visitId", inplace=True) 

375 sourceMjd = visitTable.loc[visit]["expMidptMJD"] 

376 

377 # Get target date from reference catalog 

378 targetEpochs = refEpochs.to_numpy() 

379 # There may not be a valid reference epoch on the edge of a given 

380 # region. Do not make an astrometric correction for any sources on the 

381 # edge. 

382 targetEpochs[~np.isfinite(targetEpochs)] = sourceMjd 

383 

384 # Get the stellar motion catalog into the right format: 

385 for key, value in self.config.astrometricCorrectionParameters.items(): 

386 astrometricCorrectionCatalog.rename_column(value, key) 

387 astrometricCorrectionCatalog["ra"] *= u.degree 

388 astrometricCorrectionCatalog["dec"] *= u.degree 

389 astrometricCorrectionCatalog["pmRA"] *= u.mas / u.yr 

390 astrometricCorrectionCatalog["pmDec"] *= u.mas / u.yr 

391 astrometricCorrectionCatalog["parallax"] *= u.mas 

392 

393 joinedData = join( 

394 isolatedSources[["isolated_star_id"]], 

395 astrometricCorrectionCatalog, 

396 keys="isolated_star_id", 

397 join_type="left", 

398 keep_order=True, 

399 metadata_conflicts="silent", 

400 ) 

401 joinedData["MJD"] = astropy.time.Time(sourceMjd, format="mjd", scale="tai") 

402 

403 raCorrection, decCorrection = calculate_apparent_motion( 

404 joinedData, astropy.time.Time(targetEpochs, format="mjd", scale="tai") 

405 ) 

406 

407 isolatedSources["coord_ra"] -= raCorrection.value 

408 isolatedSources["coord_dec"] -= decCorrection.value 

409 

410 def prepareAssociatedSources( 

411 self, 

412 visit, 

413 data, 

414 associatedSourceRefs, 

415 associatedSourceIdRefs, 

416 refCats, 

417 visitTable, 

418 astrometricCorrectionCatalog, 

419 ): 

420 """Match isolated sources with reference objects and shift the sources 

421 to the object epochs if `self.config.applyAstrometricCorrections` is 

422 True. 

423 

424 Parameters 

425 ---------- 

426 visit : `int` 

427 Identifier of the visit corresponding to the data. 

428 data : `astropy.table.Table` 

429 Catalog of sources to be associated. 

430 associatedSourceRefs : `list` [`DeferredDatasetHandle`] 

431 Handle for the catalogs of isolated sources. There will be multiple 

432 if the visit overlaps with multiple tracts. 

433 refCats : `list` [`pd.DataFrame`] 

434 Catalog of objects with which the sources will be compared. 

435 visitTable : `pd.DataFrame` 

436 Catalog containing the epoch for the visit corresponding to the 

437 isolatedSources. 

438 astrometricCorrectionCatalog : `astropy.table.Table` 

439 Catalog with proper motion and parallax information. 

440 """ 

441 isolatedSources = [] 

442 associatedSourceIds = { 

443 ref.dataId["tract"]: ref.get(parameters={"columns": ["isolated_star_id"]}) 

444 for ref in associatedSourceIdRefs 

445 } 

446 for associatedSourceRef in associatedSourceRefs: 

447 tract = associatedSourceRef.dataId["tract"] 

448 associatedSources = associatedSourceRef.get( 

449 parameters={"columns": ["visit", "sourceId", "obj_index"]} 

450 ) 

451 index = associatedSources["obj_index"] 

452 associatedSources["isolated_star_id"] = associatedSourceIds[tract]["isolated_star_id"][index] 

453 

454 visit_sources = associatedSources[associatedSources["visit"] == visit] 

455 try: 

456 visitData = data.loc[visit_sources["sourceId"]] 

457 visitData["isolated_star_id"] = visit_sources["isolated_star_id"] 

458 isolatedSources.append(visitData) 

459 except KeyError: 

460 raise IndexMismatchError() 

461 isolatedSources = vstack(isolatedSources) 

462 

463 if len(isolatedSources) == 0: 

464 raise pipeBase.NoWorkFound(f"No isolated sources found for visit {visit}") 

465 

466 with Matcher(np.asarray(isolatedSources["coord_ra"]), np.asarray(isolatedSources["coord_dec"])) as m: 

467 idx, isolatedMatchIndices, refMatchIndices, dists = m.query_radius( 

468 np.asarray(refCats[self.config.ra_column]), 

469 np.asarray(refCats[self.config.dec_column]), 

470 self.config.refCatMatchingRadius / 3600.0, 

471 return_indices=True, 

472 ) 

473 

474 matchIS = isolatedSources[isolatedMatchIndices] 

475 

476 if len(matchIS) == 0: 

477 raise NoMatchError(len(isolatedSources), len(refCats)) 

478 

479 # Apply proper motions and parallaxes to visit sources. 

480 if self.config.applyAstrometricCorrections: 

481 refCatEpochs = refCats[self.config.epoch_column].iloc[refMatchIndices] 

482 self.applyAstrometricCorrections( 

483 matchIS, astrometricCorrectionCatalog, visitTable, visit, refCatEpochs 

484 ) 

485 

486 matchRef = refCats.iloc[refMatchIndices] 

487 matchIS = matchIS.to_pandas() 

488 

489 allCat = pd.concat([matchRef.reset_index(), matchIS.reset_index()], axis=1) 

490 return allCat 

491 

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

493 inputs = butlerQC.get(inputRefs) 

494 

495 dataId = butlerQC.quantum.dataId 

496 plotInfo = self.parsePlotInfo(inputs, dataId) 

497 

498 # Get isolated sources: 

499 visit = inputs["data"].dataId["visit"] 

500 band = inputs["data"].dataId["band"] 

501 names = self.collectInputNames() 

502 names -= {self.config.ra_column, self.config.dec_column} 

503 names.add("sourceId") 

504 data = inputs["data"].get(parameters={"columns": names}) 

505 data.add_index("sourceId") 

506 inputs["data"] = data 

507 

508 if self.config.applyAstrometricCorrections: 

509 refCatEpochs = { 

510 epochTable.dataId["patch"]: epochTable.get() for epochTable in inputs["refCatEpochs"] 

511 } 

512 # Get objects: 

513 allRefCats = [] 

514 refCatSelectors = [ 

515 f"{refCatBand}_{selector}" 

516 for refCatBand in self.config.refCat_bands 

517 for selector in self.config.refCat_selectors 

518 ] 

519 

520 for refCatRef in inputs["refCat"]: 

521 refCat = refCatRef.get( 

522 parameters={ 

523 "columns": ["detect_isPrimary", self.config.ra_column, self.config.dec_column, "objectId"] 

524 + refCatSelectors 

525 } 

526 ) 

527 refCat.set_index("objectId") 

528 if self.config.applyAstrometricCorrections: 

529 refCat = pd.merge(refCat, refCatEpochs[refCatRef.dataId["patch"]].to_pandas(), on="objectId") 

530 goodInds = ( 

531 refCat["detect_isPrimary"] 

532 & np.isfinite(refCat[self.config.ra_column]) 

533 & np.isfinite(refCat[self.config.dec_column]) 

534 ) 

535 goodInds &= ~refCat[refCatSelectors].any(axis=1) 

536 allRefCats.append(refCat[goodInds]) 

537 

538 refCat = pd.concat(allRefCats) 

539 inputs["refCat"] = refCat 

540 if len(refCat) == 0: 

541 raise pipeBase.NoWorkFound(f"No reference catalog objects found to associate with visit {visit}") 

542 

543 if self.config.applyAstrometricCorrections: 

544 pmCats = [] 

545 for astrometricCorrectionCatalogRef in inputs["astrometricCorrectionCatalog"]: 

546 pmCat = astrometricCorrectionCatalogRef.get( 

547 parameters={"columns": self.config.astrometricCorrectionParameters.values()} 

548 ) 

549 pmCats.append(pmCat) 

550 inputs["astrometricCorrectionCatalog"] = vstack(pmCats, metadata_conflicts="silent") 

551 else: 

552 inputs["astrometricCorrectionCatalog"] = None 

553 inputs["visitTable"] = None 

554 

555 try: 

556 allCat = self.callback(inputs, dataId) 

557 except pipeBase.AlgorithmError as e: 

558 error = pipeBase.AnnotatedPartialOutputsError.annotate(e, self, log=self.log) 

559 raise error from e 

560 

561 outputs = self.run(data=allCat, bands=band, plotInfo=plotInfo) 

562 butlerQC.put(outputs, outputRefs)