Coverage for python/lsst/ap/association/association.py: 98%

139 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-29 22:47 +0000

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"""A simple implementation of source association task for ap_verify. 

23""" 

24 

25__all__ = ["AssociationConfig", "AssociationTask"] 

26 

27import itertools 

28 

29import numpy as np 

30import pandas as pd 

31from scipy.sparse import csr_matrix 

32from scipy.sparse.csgraph import min_weight_full_bipartite_matching 

33from scipy.spatial import cKDTree 

34 

35import lsst.geom as geom 

36import lsst.pex.config as pexConfig 

37import lsst.pipe.base as pipeBase 

38from lsst.pipe.tasks.schemaUtils import column_dtype 

39from lsst.utils.timer import timeMethod 

40 

41# Enforce an error for unsafe column/array value setting in pandas. 

42pd.options.mode.chained_assignment = 'raise' 

43 

44 

45class AssociationConfig(pexConfig.Config): 

46 """Config class for AssociationTask. 

47 """ 

48 

49 maxDistArcSeconds = pexConfig.Field( 

50 dtype=float, 

51 doc="Maximum distance in arcseconds for a DIASource to be matched " 

52 "to a DIAObject. This is the sole association radius: every " 

53 "DIAObject within this distance is a match candidate, ranked by " 

54 "position chi^2 when uncertainties are available and by angular " 

55 "distance otherwise.", 

56 default=1.0, 

57 ) 

58 sigmaFloorArcSeconds = pexConfig.Field( 

59 dtype=float, 

60 doc="Floor on the per-axis position uncertainty (arcsec) used when " 

61 "computing chi^2.", 

62 default=0.05, 

63 ) 

64 fallbackSigmaArcSeconds = pexConfig.Field( 

65 dtype=float, 

66 doc="Per-axis position uncertainty (arcsec) substituted for " 

67 "raErr/decErr values that are missing, non-finite, or " 

68 "non-positive when computing chi^2. Should reflect a plausible " 

69 "size for an unmeasured per-axis uncertainty (e.g., one LSSTCam " 

70 "pixel ~= 0.2 arcsec). Has no effect on rows that carry valid " 

71 "uncertainty values.", 

72 default=0.2, 

73 ) 

74 

75 

76class AssociationTask(pipeBase.Task): 

77 """Associate DIAOSources into existing DIAObjects. 

78 

79 This task performs the association of detected DIASources in a visit 

80 with the previous DIAObjects detected over time. It also creates new 

81 DIAObjects out of DIASources that cannot be associated with previously 

82 detected DIAObjects. 

83 """ 

84 

85 ConfigClass = AssociationConfig 

86 _DefaultName = "association" 

87 

88 @timeMethod 

89 def run(self, 

90 diaSources, 

91 diaObjects, 

92 schema=None): 

93 """Associate the new DiaSources with existing DiaObjects. 

94 

95 Parameters 

96 ---------- 

97 diaSources : `pandas.DataFrame` 

98 New DIASources to be associated with existing DIAObjects. 

99 diaObjects : `pandas.DataFrame` 

100 Existing diaObjects from the Apdb. 

101 schema : `dict` [`str`, `felis.datamodel.Schema`] or `None`, optional 

102 Dictionary of Schemas from ``sdm_schemas`` containing the table 

103 definition to use. If `None`, dtypes for new columns are guessed 

104 from the input tables. 

105 

106 Returns 

107 ------- 

108 result : `lsst.pipe.base.Struct` 

109 Results struct with components. 

110 

111 - ``matchedDiaSources`` : DiaSources that were matched. Matched 

112 Sources have their diaObjectId updated and set to the id of the 

113 diaObject they were matched to. (`pandas.DataFrame`) 

114 - ``unAssocDiaSources`` : DiaSources that were not matched. 

115 Unassociated sources have their diaObject set to 0 as they 

116 were not associated with any existing DiaObjects. 

117 (`pandas.DataFrame`) 

118 - ``nUpdatedDiaObjects`` : Number of DiaObjects that were 

119 matched to new DiaSources. (`int`) 

120 - ``nUnassociatedDiaObjects`` : Number of DiaObjects that were 

121 not matched a new DiaSource. (`int`) 

122 """ 

123 diaSources = self.check_dia_source_radec(diaSources) 

124 

125 if len(diaObjects) == 0: 

126 return pipeBase.Struct( 

127 matchedDiaSources=pd.DataFrame(columns=diaSources.columns), 

128 unAssocDiaSources=diaSources, 

129 nUpdatedDiaObjects=0, 

130 nUnassociatedDiaObjects=0) 

131 

132 matchResult = self.associate_sources(diaObjects, diaSources, schema) 

133 

134 mask = matchResult.diaSources["diaObjectId"] != 0 

135 

136 return pipeBase.Struct( 

137 matchedDiaSources=matchResult.diaSources[mask].reset_index(drop=True), 

138 unAssocDiaSources=matchResult.diaSources[~mask].reset_index(drop=True), 

139 nUpdatedDiaObjects=matchResult.nUpdatedDiaObjects, 

140 nUnassociatedDiaObjects=matchResult.nUnassociatedDiaObjects) 

141 

142 def check_dia_source_radec(self, dia_sources): 

143 """Check that all DiaSources have non-NaN values for RA/DEC. 

144 

145 If one or more DiaSources are found to have NaN values, throw a 

146 warning to the log with the ids of the offending sources. Drop them 

147 from the table. 

148 

149 Parameters 

150 ---------- 

151 dia_sources : `pandas.DataFrame` 

152 Input DiaSources to check for NaN values. 

153 

154 Returns 

155 ------- 

156 trimmed_sources : `pandas.DataFrame` 

157 DataFrame of DiaSources trimmed of all entries with NaN values for 

158 RA/DEC. 

159 """ 

160 nan_mask = dia_sources["ra"].isnull() | dia_sources["dec"].isnull() 

161 if nan_mask.any(): 

162 nan_ids = dia_sources.loc[nan_mask, "diaSourceId"] 

163 for nan_id in nan_ids: 

164 self.log.warning( 

165 "DiaSource %i has NaN value for RA/DEC, " 

166 "dropping from association.", nan_id) 

167 dia_sources = dia_sources[~nan_mask] 

168 return dia_sources 

169 

170 @timeMethod 

171 def associate_sources(self, dia_objects, dia_sources, schema=None): 

172 """Associate the input DIASources with the catalog of DIAObjects. 

173 

174 DiaObject DataFrame must be indexed on ``diaObjectId``. 

175 

176 Parameters 

177 ---------- 

178 dia_objects : `pandas.DataFrame` 

179 Catalog of DIAObjects to attempt to associate the input 

180 DIASources into. 

181 dia_sources : `pandas.DataFrame` 

182 DIASources to associate into the DIAObjectCollection. 

183 schema : `dict` [`str`, `felis.datamodel.Schema`] or `None`, optional 

184 Dictionary of Schemas from ``sdm_schemas`` containing the table 

185 definition to use. 

186 

187 Returns 

188 ------- 

189 result : `lsst.pipe.base.Struct` 

190 Results struct with components. 

191 

192 - ``diaSources`` : Full set of diaSources both matched and not. 

193 (`pandas.DataFrame`) 

194 - ``nUpdatedDiaObjects`` : Number of DiaObjects that were 

195 associated. (`int`) 

196 - ``nUnassociatedDiaObjects`` : Number of DiaObjects that were 

197 not matched a new DiaSource. (`int`) 

198 """ 

199 scores = self.score( 

200 dia_objects, dia_sources, 

201 self.config.maxDistArcSeconds * geom.arcseconds) 

202 match_result = self.match(dia_objects, dia_sources, scores, schema) 

203 

204 return match_result 

205 

206 @timeMethod 

207 def score(self, dia_objects, dia_sources, max_dist): 

208 """Build the candidate (DIASource, DIAObject) match table and 

209 score every pair. 

210 

211 For each DIASource, all DIAObjects within ``max_dist`` are retrieved 

212 from a kd-tree on unit vectors. Each candidate pair is then scored: 

213 

214 - If both inputs carry usable ``raErr``/``decErr`` columns, the 

215 score is the 2D Gaussian negative log-likelihood of the 

216 position residual (``0.5 * chi^2 + 0.5 * ln(var_ra * var_dec)``), 

217 so the match prefers the most likely object, not merely the 

218 nearest or the lowest-chi^2 one (see `_position_nll`). 

219 - Otherwise, the distance (in radians) is used as the score. 

220 

221 No candidates are dropped by the score itself: every pair within 

222 ``max_dist`` is retained, and the score is used only to rank them. 

223 

224 ``raErr`` and ``decErr`` are taken to follow the LSST DPDD 

225 convention: each is the marginal uncertainty of the catalog 

226 coordinate itself in degrees (no cos(dec) factor folded into 

227 ``raErr``). Under that convention the cos(dec) factor cancels 

228 between residual and uncertainty, and chi^2 reduces to 

229 ``dRA^2 / sum(raErr^2) + dDec^2 / sum(decErr^2)``. 

230 

231 ``max_dist`` is both the candidate pre-filter and the 

232 association radius: every pair within it is retained, and the 

233 score is used only to rank candidates in the downstream match. 

234 

235 Parameters 

236 ---------- 

237 dia_objects, dia_sources : `pandas.DataFrame` 

238 Must contain ``ra`` and ``dec``; ``raErr`` and ``decErr`` are 

239 used when present. 

240 max_dist : `lsst.geom.Angle` 

241 Hard angular upper bound on candidate pairs. 

242 

243 Returns 

244 ------- 

245 result : `lsst.pipe.base.Struct` 

246 Flat candidate-pair table: 

247 

248 - ``src_idx`` : `numpy.ndarray` of `int` 

249 Positional source index for each surviving pair. 

250 - ``obj_idx`` : `numpy.ndarray` of `int` 

251 Positional object index for each surviving pair. 

252 - ``scores`` : `numpy.ndarray` of `float` 

253 Cost of each pair (position negative log-likelihood if 

254 uncertainty-based, chord distance in radians otherwise). 

255 Lower is better; NLL values may be negative. 

256 - ``unmatched_cost`` : `float` 

257 Cost to assign to the synthetic 'no-match' alternative 

258 in the linear-assignment match — set so that any 

259 surviving real candidate is preferred. 

260 """ 

261 n_src = len(dia_sources) 

262 n_obj = len(dia_objects) 

263 empty_int = np.empty(0, dtype=np.int64) 

264 empty_float = np.empty(0, dtype=np.float64) 

265 max_dist_rad = max_dist.asRadians() 

266 # Used as the no-match cost in distance mode; always strictly 

267 # above any real candidate (which the kd-tree caps at max_dist_rad). 

268 chord_unmatched_cost = max_dist_rad * 1.01 + 1e-300 

269 

270 if n_obj == 0 or n_src == 0: 270 ↛ 271line 270 didn't jump to line 271 because the condition on line 270 was never true

271 return pipeBase.Struct( 

272 src_idx=empty_int, 

273 obj_idx=empty_int, 

274 scores=empty_float, 

275 unmatched_cost=chord_unmatched_cost) 

276 

277 spatial_tree = self._make_spatial_tree(dia_objects) 

278 src_vectors = self._radec_to_xyz(dia_sources) 

279 

280 candidate_lists = spatial_tree.query_ball_point( 

281 src_vectors, r=max_dist_rad) 

282 counts = np.fromiter( 

283 (len(c) for c in candidate_lists), dtype=np.int64, count=n_src) 

284 n_pairs = int(counts.sum()) 

285 if n_pairs == 0: 

286 return pipeBase.Struct( 

287 src_idx=empty_int, 

288 obj_idx=empty_int, 

289 scores=empty_float, 

290 unmatched_cost=chord_unmatched_cost) 

291 

292 src_idx = np.repeat(np.arange(n_src, dtype=np.int64), counts) 

293 obj_idx = np.fromiter( 

294 itertools.chain.from_iterable(candidate_lists), 

295 dtype=np.int64, count=n_pairs) 

296 

297 if (self._has_position_errors(dia_sources) 

298 and self._has_position_errors(dia_objects)): 

299 scores = self._position_nll(dia_sources, dia_objects, src_idx, obj_idx) 

300 # ``max_dist`` is the sole association gate: every candidate 

301 # pair is kept and the NLL only ranks them. Price the 

302 # 'no-match' alternative just above the worst candidate so a 

303 # real match is always preferred. A diaSource will only not be 

304 # associated if there are more diaSources than diaObjects. 

305 unmatchedCostDelta = 1.0 

306 unmatched_cost = (float(scores.max()) + unmatchedCostDelta) 

307 else: 

308 obj_vectors = self._radec_to_xyz(dia_objects) 

309 diffs = src_vectors[src_idx] - obj_vectors[obj_idx] 

310 scores = np.linalg.norm(diffs, axis=1) 

311 unmatched_cost = chord_unmatched_cost 

312 

313 return pipeBase.Struct( 

314 src_idx=src_idx, 

315 obj_idx=obj_idx, 

316 scores=scores, 

317 unmatched_cost=unmatched_cost) 

318 

319 @staticmethod 

320 def _has_position_errors(catalog): 

321 """Return True iff ``catalog`` carries ``raErr`` and ``decErr`` 

322 columns with at least one finite, positive value in each. 

323 """ 

324 if "raErr" not in catalog.columns or "decErr" not in catalog.columns: 

325 return False 

326 raErr = catalog["raErr"].to_numpy() 

327 decErr = catalog["decErr"].to_numpy() 

328 return (bool(np.any(np.isfinite(raErr) & (raErr > 0.0))) 

329 and bool(np.any(np.isfinite(decErr) & (decErr > 0.0)))) 

330 

331 def _position_nll(self, dia_sources, dia_objects, src_idx, obj_idx): 

332 """Return the position Gaussian negative log-likelihood (NLL) for 

333 paired DIASources/DIAObjects, used as the match cost. 

334 

335 Under the hypothesis that the DIASource and DIAObject are the same 

336 astrophysical object, each per-axis residual is a zero-mean 

337 Gaussian whose variance is the sum of the two catalogs' squared 

338 per-axis uncertainties. The cost is the negative log-likelihood of 

339 the observed residual, dropping the constant ``ln(2*pi)`` term: 

340 

341 NLL = 0.5 * (dra^2 / var_ra + ddec^2 / var_dec) 

342 + 0.5 * ln(var_ra * var_dec). 

343 

344 The first term is half the 2-DOF position chi^2. The second is the 

345 Gaussian normalization, which penalises poorly-localised 

346 candidates: a bare chi^2 cost omits it, and since a larger 

347 variance deflates chi^2 for a fixed separation, a bare chi^2 would 

348 bias the match toward the more poorly-localised object. 

349 

350 Non-finite or non-positive per-row uncertainties are replaced 

351 with ``self.config.fallbackSigmaArcSeconds``. The combined per-axis 

352 variance is then floored at ``self.config.sigmaFloorArcSeconds`` 

353 to guard against pathologically small reported errors. 

354 

355 Parameters 

356 ---------- 

357 dia_sources, dia_objects : `pandas.DataFrame` 

358 Catalogs containing ``ra``, ``dec``, ``raErr``, ``decErr`` 

359 (all in degrees). 

360 src_idx, obj_idx : `numpy.ndarray` of `int` 

361 Paired positional indices; ``src_idx[k]`` is matched against 

362 ``obj_idx[k]``. 

363 

364 Returns 

365 ------- 

366 nll : `numpy.ndarray` of `float` 

367 Position negative log-likelihood, one value per pair. Lower is 

368 a better (more likely) match; values may be negative. 

369 """ 

370 sigma_floor_sq_deg = (self.config.sigmaFloorArcSeconds / 3600.0) ** 2 

371 fallback_sq_deg = (self.config.fallbackSigmaArcSeconds / 3600.0) ** 2 

372 

373 def err_sq(catalog, col, idx): 

374 arr = catalog[col].to_numpy()[idx] 

375 sq = np.square(arr) 

376 return np.where(np.isfinite(sq) & (arr > 0.0), 

377 sq, fallback_sq_deg) 

378 

379 src_ra = dia_sources["ra"].to_numpy()[src_idx] 

380 src_dec = dia_sources["dec"].to_numpy()[src_idx] 

381 obj_ra = dia_objects["ra"].to_numpy()[obj_idx] 

382 obj_dec = dia_objects["dec"].to_numpy()[obj_idx] 

383 

384 # Make sure that the RA difference is never greater than +/-180 in 

385 # either direction. 

386 dra = ((src_ra - obj_ra) + 180.0) % 360.0 - 180.0 

387 ddec = src_dec - obj_dec 

388 

389 var_ra = (err_sq(dia_sources, "raErr", src_idx) 

390 + err_sq(dia_objects, "raErr", obj_idx)) 

391 var_dec = (err_sq(dia_sources, "decErr", src_idx) 

392 + err_sq(dia_objects, "decErr", obj_idx)) 

393 var_ra = np.maximum(var_ra, sigma_floor_sq_deg) 

394 var_dec = np.maximum(var_dec, sigma_floor_sq_deg) 

395 

396 chi2 = dra**2/var_ra + ddec**2/var_dec 

397 return 0.5*chi2 + 0.5*np.log(var_ra*var_dec) 

398 

399 def _make_spatial_tree(self, dia_objects): 

400 """Create a searchable kd-tree the input dia_object positions. 

401 

402 Parameters 

403 ---------- 

404 dia_objects : `pandas.DataFrame` 

405 A catalog of DIAObjects to create the tree from. 

406 

407 Returns 

408 ------- 

409 kd_tree : `scipy.spatical.cKDTree` 

410 Searchable kd-tree created from the positions of the DIAObjects. 

411 """ 

412 vectors = self._radec_to_xyz(dia_objects) 

413 return cKDTree(vectors) 

414 

415 def _radec_to_xyz(self, catalog): 

416 """Convert input ra/dec coordinates to spherical unit-vectors. 

417 

418 Parameters 

419 ---------- 

420 catalog : `pandas.DataFrame` 

421 Catalog to produce spherical unit-vector from. 

422 

423 Returns 

424 ------- 

425 vectors : `numpy.ndarray`, (N, 3) 

426 Output unit-vectors 

427 """ 

428 ras = np.radians(catalog["ra"]) 

429 decs = np.radians(catalog["dec"]) 

430 vectors = np.empty((len(ras), 3)) 

431 

432 sin_dec = np.sin(np.pi / 2 - decs) 

433 vectors[:, 0] = sin_dec * np.cos(ras) 

434 vectors[:, 1] = sin_dec * np.sin(ras) 

435 vectors[:, 2] = np.cos(np.pi / 2 - decs) 

436 

437 return vectors 

438 

439 @timeMethod 

440 def match(self, dia_objects, dia_sources, score_struct, schema=None): 

441 """Solve a min-cost bipartite matching between sources and objects. 

442 

443 Each DIASource is given a synthetic 'no-match' alternative (a 

444 per-source 'ghost' column) carrying ``score_struct.unmatched_cost``. 

445 A min-weight full bipartite matching is then solved on the sparse 

446 cost matrix made from real candidate pairs and ghost edges. 

447 Sources matched to a ghost are reported as unassociated; sources 

448 matched to a real object inherit that object's ``diaObjectId``. 

449 

450 When two sources compete for the same object, the source with a 

451 strictly worse alternative gets that object, and the other source falls 

452 back to its second-best candidate rather than creating a new DIAObject. 

453 

454 Parameters 

455 ---------- 

456 dia_objects, dia_sources : `pandas.DataFrame` 

457 Must contain ``ra`` and ``dec``; ``raErr`` and ``decErr`` are 

458 used when present. 

459 score_struct : `lsst.pipe.base.Struct` 

460 Output of `score`: ``src_idx``, ``obj_idx``, ``scores``, 

461 ``unmatched_cost``. 

462 schema : `dict` [`str`, `felis.datamodel.Schema`] or `None`, optional 

463 Dictionary of Schemas from ``sdm_schemas`` containing the table 

464 definition to use. 

465 

466 Returns 

467 ------- 

468 result : `lsst.pipe.base.Struct` 

469 

470 - ``diaSources`` : input source table with ``diaObjectId`` 

471 populated (0 for unmatched). (`pandas.DataFrame`) 

472 - ``nUpdatedDiaObjects`` : number of DIAObjects matched to a 

473 new DIASource. (`int`) 

474 - ``nUnassociatedDiaObjects`` : number of preloaded DIAObjects 

475 with no matching DIASource. (`int`) 

476 """ 

477 n_src = len(dia_sources) 

478 n_obj = len(dia_objects) 

479 if not pd.api.types.is_integer_dtype(dia_sources["diaObjectId"]): 

480 raise ValueError(f"diaSource column diaObjectId must be an integer, " 

481 f"got {dia_sources['diaObjectId'].dtype} instead") 

482 # Set the diaObjectId dtype from the schema if available. Otherwise 

483 # fall back on the dtype of the incoming diaObjectId column. 

484 # If the dtype is incompatible with the final schema (uint vs int), 

485 # then pandas will silently promote the column to a float. 

486 if schema is not None and schema.get("DiaSource") is not None: 

487 obj_id_col = next( 

488 c for c in schema["DiaSource"].columns if c.name == "diaObjectId" 

489 ) 

490 obj_id_dtype = column_dtype(obj_id_col.datatype, nullable=obj_id_col.nullable) 

491 else: 

492 obj_id_dtype = dia_sources["diaObjectId"].dtype 

493 # Allocate via pandas so pandas-extension dtypes (e.g., "Int64") 

494 # are supported alongside numpy dtypes. 

495 associated_dia_object_ids = pd.array([0]*n_src, dtype=obj_id_dtype) 

496 n_matched = 0 

497 

498 if n_src > 0: 498 ↛ 535line 498 didn't jump to line 535 because the condition on line 498 was always true

499 # Per-source ghost edges to columns [n_obj, n_obj + n_src). 

500 ghost_src = np.arange(n_src, dtype=np.int64) 

501 ghost_obj = n_obj + ghost_src 

502 ghost_cost = np.full( 

503 n_src, score_struct.unmatched_cost, dtype=np.float64) 

504 

505 real_weights = score_struct.scores.astype(np.float64, copy=False) 

506 rows = np.concatenate( 

507 [score_struct.src_idx.astype(np.int64, copy=False), 

508 ghost_src]) 

509 cols = np.concatenate( 

510 [score_struct.obj_idx.astype(np.int64, copy=False), 

511 ghost_obj]) 

512 weights = np.concatenate([real_weights, ghost_cost]) 

513 

514 # scipy's min_weight_full_bipartite_matching treats an explicit 

515 # zero as a missing edge, and the NLL cost can be negative or 

516 # zero. A full matching uses exactly one edge per source, so 

517 # shifting every stored weight by a constant leaves the optimal 

518 # matching unchanged; rescale so the smallest weight is strictly 

519 # positive and no real edge is dropped. 

520 weights = weights - weights.min() + 1.0 

521 

522 biadj = csr_matrix( 

523 (weights, (rows, cols)), 

524 shape=(n_src, n_obj + n_src)) 

525 match_src, match_dst = min_weight_full_bipartite_matching(biadj) 

526 

527 is_real = match_dst < n_obj 

528 matched_src = match_src[is_real] 

529 matched_obj = match_dst[is_real] 

530 n_matched = int(matched_src.size) 

531 if n_matched > 0: 

532 associated_dia_object_ids[matched_src] = ( 

533 dia_objects.index.to_numpy()[matched_obj]) 

534 

535 dia_sources = dia_sources.copy() 

536 dia_sources["diaObjectId"] = associated_dia_object_ids 

537 

538 return pipeBase.Struct( 

539 diaSources=dia_sources, 

540 nUpdatedDiaObjects=n_matched, 

541 nUnassociatedDiaObjects=int(n_obj - n_matched))