Coverage for python/lsst/meas/base/diaCalculationPlugins.py: 92%

602 statements  

« prev     ^ index     » next       coverage.py v7.14.2, created at 2026-06-22 08:33 +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"""Plugins for use in DiaSource summary statistics. 

23 

24Output columns must be 

25as defined in the schema of the Apdb both in name and units. 

26""" 

27 

28import functools 

29import warnings 

30 

31from astropy.stats import median_absolute_deviation 

32import numpy as np 

33import pandas as pd 

34from scipy.optimize import lsq_linear 

35 

36import lsst.geom as geom 

37import lsst.pex.config as pexConfig 

38import lsst.pipe.base as pipeBase 

39import lsst.sphgeom as sphgeom 

40from astropy.timeseries import LombScargle 

41from astropy.timeseries import LombScargleMultiband 

42import math 

43import statistics 

44 

45from .diaCalculation import ( 

46 DiaObjectCalculationPluginConfig, 

47 DiaObjectCalculationPlugin) 

48from .pluginRegistry import register 

49 

50 

51__all__ = ("MeanDiaPositionConfig", "MeanDiaPosition", 

52 "HTMIndexDiaPosition", "HTMIndexDiaPositionConfig", 

53 "NumDiaSourcesDiaPlugin", "NumDiaSourcesDiaPluginConfig", 

54 "SimpleSourceFlagDiaPlugin", "SimpleSourceFlagDiaPluginConfig", 

55 "WeightedMeanDiaPsfFluxConfig", "WeightedMeanDiaPsfFlux", 

56 "PercentileDiaPsfFlux", "PercentileDiaPsfFluxConfig", 

57 "SigmaDiaPsfFlux", "SigmaDiaPsfFluxConfig", 

58 "Chi2DiaPsfFlux", "Chi2DiaPsfFluxConfig", 

59 "MadDiaPsfFlux", "MadDiaPsfFluxConfig", 

60 "SkewDiaPsfFlux", "SkewDiaPsfFluxConfig", 

61 "MinMaxDiaPsfFlux", "MinMaxDiaPsfFluxConfig", 

62 "MaxSlopeDiaPsfFlux", "MaxSlopeDiaPsfFluxConfig", 

63 "ErrMeanDiaPsfFlux", "ErrMeanDiaPsfFluxConfig", 

64 "LinearFitDiaPsfFlux", "LinearFitDiaPsfFluxConfig", 

65 "StetsonJDiaPsfFlux", "StetsonJDiaPsfFluxConfig", 

66 "WeightedMeanDiaTotFlux", "WeightedMeanDiaTotFluxConfig", 

67 "SigmaDiaTotFlux", "SigmaDiaTotFluxConfig", 

68 "LombScarglePeriodogram", "LombScarglePeriodogramConfig", 

69 "LombScarglePeriodogramMulti", "LombScarglePeriodogramMultiConfig", 

70 "UnphysicalDiaSourceSeparation") 

71 

72 

73def catchWarnings(_func=None, *, warns=[]): 

74 """Decorator for generically catching numpy warnings. 

75 """ 

76 def decoratorCatchWarnings(func): 

77 @functools.wraps(func) 

78 def wrapperCatchWarnings(*args, **kwargs): 

79 with warnings.catch_warnings(): 

80 for val in warns: 

81 warnings.filterwarnings("ignore", val) 

82 return func(*args, **kwargs) 

83 return wrapperCatchWarnings 

84 

85 if _func is None: 85 ↛ 88line 85 didn't jump to line 88 because the condition on line 85 was always true

86 return decoratorCatchWarnings 

87 else: 

88 return decoratorCatchWarnings(_func) 

89 

90 

91def typeSafePandasAssignment( 

92 target, 

93 source, 

94 columns, 

95 default_dtype=np.float64, 

96 int_fill_value=0, 

97 # TODO DM-53254: Remove the force_int_to_float hack. 

98 force_int_to_float=False, 

99): 

100 """ 

101 Assign from a source dataframe to a target dataframe in a type safe way. 

102 

103 Parameters 

104 ---------- 

105 target : `pd.DataFrame` 

106 Target pandas dataframe. 

107 source : `pd.DataFrame` or `pd.Series` 

108 Grouped source dataframe. 

109 columns : `list` [`str`] 

110 List of columns to transfer. 

111 default_dtype : `np.dtype`, optional 

112 Default datatype (if not in target). 

113 int_fill_value : `int`, optional 

114 Fill value for integer columns to avoid pandas insisting 

115 that everything should be float-ified as nans. 

116 force_int_to_float : `bool`, optional 

117 Force integer columns to float columns? Use this option 

118 for backwards compatibility for old pandas misfeatures which 

119 are expected by some downstream processes. 

120 """ 

121 is_series = isinstance(source, pd.Series) 

122 for col in columns: 

123 if is_series: 

124 source_col = source 

125 else: 

126 source_col = source[col] 

127 

128 matched_length = False 

129 if col in target.columns: 

130 target_dtype = target[col].dtype 

131 matched_length = len(target) == len(source) 

132 else: 

133 target_dtype = default_dtype 

134 

135 if (matched_length or pd.api.types.is_float_dtype(target_dtype)) and not force_int_to_float: 

136 # If we have a matched length or float, we can do a 

137 # straight assignment here. 

138 target.loc[:, col] = source_col.astype(target_dtype) 

139 else: 

140 # With mis-matched integers, we must do this dance to preserve types. 

141 # Note that this may lose precision with very large numbers. 

142 

143 # Convert to float 

144 target[col] = target[col].astype(np.float64) 

145 # Set the column, casting to float. 

146 target.loc[:, col] = source_col.astype(np.float64) 

147 if not force_int_to_float: 

148 # Convert back to integer 

149 target[col] = target[col].fillna(int_fill_value).astype(target_dtype) 

150 

151 

152def compute_optimized_periodogram_grid(x0, oversampling_factor=5, nyquist_factor=100): 

153 """ 

154 Computes an optimized periodogram frequency grid for a given time series. 

155 

156 Parameters 

157 ---------- 

158 x0 : `array` 

159 The input time axis. 

160 oversampling_factor : `int`, optional 

161 The oversampling factor for frequency grid. 

162 nyquist_factor : `int`, optional 

163 The Nyquist factor for frequency grid. 

164 

165 Returns 

166 ------- 

167 frequencies : `array` 

168 The computed optimized periodogram frequency grid. 

169 """ 

170 

171 num_points = len(x0) 

172 baseline = np.max(x0) - np.min(x0) 

173 

174 # Calculate the frequency resolution based on oversampling factor and baseline 

175 frequency_resolution = 1. / baseline / oversampling_factor 

176 

177 num_frequencies = int( 

178 0.5 * oversampling_factor * nyquist_factor * num_points) 

179 frequencies = frequency_resolution + \ 

180 frequency_resolution * np.arange(num_frequencies) 

181 

182 return frequencies 

183 

184 

185class LombScarglePeriodogramConfig(DiaObjectCalculationPluginConfig): 

186 pass 

187 

188 

189@register("ap_lombScarglePeriodogram") 

190class LombScarglePeriodogram(DiaObjectCalculationPlugin): 

191 """Compute the single-band period of a DiaObject given a set of DiaSources. 

192 """ 

193 ConfigClass = LombScarglePeriodogramConfig 

194 

195 plugType = "multi" 

196 outputCols = ["period", "power"] 

197 needsFilter = True 

198 

199 @classmethod 

200 def getExecutionOrder(cls): 

201 return cls.DEFAULT_CATALOGCALCULATION 

202 

203 @catchWarnings(warns=["All-NaN slice encountered"]) 

204 def calculate(self, 

205 diaObjects, 

206 diaSources, 

207 filterDiaSources, 

208 band): 

209 """Compute the periodogram. 

210 

211 Parameters 

212 ---------- 

213 diaObjects : `pandas.DataFrame` 

214 Summary objects to store values in. 

215 diaSources : `pandas.DataFrame` or `pandas.DataFrameGroupBy` 

216 Catalog of DiaSources summarized by this DiaObject. 

217 """ 

218 

219 # Check and initialize output columns in diaObjects. 

220 if (periodCol := f"{band}_period") not in diaObjects.columns: 220 ↛ 222line 220 didn't jump to line 222 because the condition on line 220 was always true

221 diaObjects[periodCol] = np.nan 

222 if (powerCol := f"{band}_power") not in diaObjects.columns: 222 ↛ 225line 222 didn't jump to line 225 because the condition on line 222 was always true

223 diaObjects[powerCol] = np.nan 

224 

225 def _calculate_period(df, min_detections=5, nterms=1, oversampling_factor=5, nyquist_factor=100): 

226 """Compute the Lomb-Scargle periodogram given a set of DiaSources. 

227 

228 Parameters 

229 ---------- 

230 df : `pandas.DataFrame` 

231 The input DataFrame. 

232 min_detections : `int`, optional 

233 The minimum number of detections. 

234 nterms : `int`, optional 

235 The number of terms in the Lomb-Scargle model. 

236 oversampling_factor : `int`, optional 

237 The oversampling factor for frequency grid. 

238 nyquist_factor : `int`, optional 

239 The Nyquist factor for frequency grid. 

240 

241 Returns 

242 ------- 

243 pd_tab : `pandas.Series` 

244 The output DataFrame with the Lomb-Scargle parameters. 

245 """ 

246 tmpDf = df[~np.logical_or(np.isnan(df["psfFlux"]), 

247 np.isnan(df["midpointMjdTai"]))] 

248 

249 if len(tmpDf) < min_detections: 249 ↛ 250line 249 didn't jump to line 250 because the condition on line 249 was never true

250 return pd.Series({periodCol: np.nan, powerCol: np.nan}) 

251 

252 time = tmpDf["midpointMjdTai"].to_numpy() 

253 flux = tmpDf["psfFlux"].to_numpy() 

254 flux_err = tmpDf["psfFluxErr"].to_numpy() 

255 

256 lsp = LombScargle(time, flux, dy=flux_err, nterms=nterms) 

257 f_grid = compute_optimized_periodogram_grid( 

258 time, oversampling_factor=oversampling_factor, nyquist_factor=nyquist_factor) 

259 period = 1/f_grid 

260 power = lsp.power(f_grid) 

261 

262 return pd.Series({periodCol: period[np.argmax(power)], 

263 powerCol: np.max(power)}) 

264 

265 diaObjects.loc[:, [periodCol, powerCol] 

266 ] = filterDiaSources.apply(_calculate_period) 

267 

268 

269class LombScarglePeriodogramMultiConfig(DiaObjectCalculationPluginConfig): 

270 pass 

271 

272 

273@register("ap_lombScarglePeriodogramMulti") 

274class LombScarglePeriodogramMulti(DiaObjectCalculationPlugin): 

275 """Compute the multi-band LombScargle periodogram of a DiaObject given a set of DiaSources. 

276 """ 

277 ConfigClass = LombScarglePeriodogramMultiConfig 

278 

279 plugType = "multi" 

280 outputCols = ["multiPeriod", "multiPower", 

281 "multiFap", "multiAmp", "multiPhase"] 

282 needsFilter = True 

283 

284 @classmethod 

285 def getExecutionOrder(cls): 

286 return cls.DEFAULT_CATALOGCALCULATION 

287 

288 @staticmethod 

289 def calculate_baluev_fap(time, n, maxPeriod, zmax): 

290 """Calculate the False-Alarm probability using the Baluev approximation. 

291 

292 Parameters 

293 ---------- 

294 time : `array` 

295 The input time axis. 

296 n : `int` 

297 The number of detections. 

298 maxPeriod : `float` 

299 The maximum period in the grid. 

300 zmax : `float` 

301 The maximum power in the grid. 

302 

303 Returns 

304 ------- 

305 fap_estimate : `float` 

306 The False-Alarm probability Baluev approximation. 

307 

308 Notes 

309 ---------- 

310 .. [1] Baluev, R. V. 2008, MNRAS, 385, 1279 

311 .. [2] Süveges, M., Guy, L.P., Eyer, L., et al. 2015, MNRAS, 450, 2052 

312 """ 

313 if n <= 2: 313 ↛ 314line 313 didn't jump to line 314 because the condition on line 313 was never true

314 return np.nan 

315 

316 gam_ratio = math.factorial(int((n - 1)/2)) / math.factorial(int((n - 2)/2)) 

317 fu = 1/maxPeriod 

318 return gam_ratio * np.sqrt( 

319 4*np.pi*statistics.variance(time) 

320 ) * fu * (1-zmax)**((n-4)/2) * np.sqrt(zmax) 

321 

322 @staticmethod 

323 def generate_lsp_params(lsp_model, fbest, bands): 

324 """Generate the Lomb-Scargle parameters. 

325 Parameters 

326 ---------- 

327 lsp_model : `astropy.timeseries.LombScargleMultiband` 

328 The Lomb-Scargle model. 

329 fbest : `float` 

330 The best period. 

331 bands : `array` 

332 The bands of the time series. 

333 

334 Returns 

335 ------- 

336 Amp : `array` 

337 The amplitude of the time series. 

338 Ph : `array` 

339 The phase of the time series. 

340 

341 Notes 

342 ---------- 

343 .. [1] VanderPlas, J. T., & Ivezic, Z. 2015, ApJ, 812, 18 

344 """ 

345 best_params = lsp_model.model_parameters(fbest, units=True) 

346 

347 name_params = [f"theta_base_{i}" for i in range(3)] 

348 name_params += [f"theta_band_{band}_{i}" for band in np.unique(bands) for i in range(3)] 

349 

350 df_params = pd.DataFrame([best_params], columns=name_params) 

351 

352 unique_bands = np.unique(bands) 

353 

354 amplitude_band = [np.sqrt(df_params[f"theta_band_{band}_1"]**2 

355 + df_params[f"theta_band_{band}_2"]**2) 

356 for band in unique_bands] 

357 phase_bands = [np.arctan2(df_params[f"theta_band_{band}_2"], 

358 df_params[f"theta_band_{band}_1"]) for band in unique_bands] 

359 

360 amp = [a[0] for a in amplitude_band] 

361 ph = [p[0] for p in phase_bands] 

362 

363 return amp, ph 

364 

365 @catchWarnings(warns=["All-NaN slice encountered"]) 

366 def calculate(self, 

367 diaObjects, 

368 diaSources, 

369 **kwargs): 

370 """Compute the multi-band LombScargle periodogram of a DiaObject given 

371 a set of DiaSources. 

372 

373 Parameters 

374 ---------- 

375 diaObjects : `pandas.DataFrame` 

376 Summary objects to store values in. 

377 diaSources : `pandas.DataFrame` or `pandas.DataFrameGroupBy` 

378 Catalog of DiaSources summarized by this DiaObject. 

379 **kwargs : `dict` 

380 Unused kwargs that are always passed to a plugin. 

381 """ 

382 

383 bands_arr = diaSources['band'].unique().values 

384 unique_bands = np.unique(np.concatenate(bands_arr)) 

385 # Check and initialize output columns in diaObjects. 

386 if (periodCol := "multiPeriod") not in diaObjects.columns: 386 ↛ 388line 386 didn't jump to line 388 because the condition on line 386 was always true

387 diaObjects[periodCol] = np.nan 

388 if (powerCol := "multiPower") not in diaObjects.columns: 388 ↛ 390line 388 didn't jump to line 390 because the condition on line 388 was always true

389 diaObjects[powerCol] = np.nan 

390 if (fapCol := "multiFap") not in diaObjects.columns: 390 ↛ 392line 390 didn't jump to line 392 because the condition on line 390 was always true

391 diaObjects[fapCol] = np.nan 

392 ampCol = "multiAmp" 

393 phaseCol = "multiPhase" 

394 for i in range(len(unique_bands)): 

395 ampCol_band = f"{unique_bands[i]}_{ampCol}" 

396 if ampCol_band not in diaObjects.columns: 396 ↛ 398line 396 didn't jump to line 398 because the condition on line 396 was always true

397 diaObjects[ampCol_band] = np.nan 

398 phaseCol_band = f"{unique_bands[i]}_{phaseCol}" 

399 if phaseCol_band not in diaObjects.columns: 399 ↛ 394line 399 didn't jump to line 394 because the condition on line 399 was always true

400 diaObjects[phaseCol_band] = np.nan 

401 

402 def _calculate_period_multi(df, all_unique_bands, 

403 min_detections=9, oversampling_factor=5, nyquist_factor=100): 

404 """Calculate the multi-band Lomb-Scargle periodogram. 

405 

406 Parameters 

407 ---------- 

408 df : `pandas.DataFrame` 

409 The input DataFrame. 

410 all_unique_bands : `list` of `str` 

411 List of all bands present in the diaSource table that is being worked on. 

412 min_detections : `int`, optional 

413 The minimum number of detections, including all bands. 

414 oversampling_factor : `int`, optional 

415 The oversampling factor for frequency grid. 

416 nyquist_factor : `int`, optional 

417 The Nyquist factor for frequency grid. 

418 

419 Returns 

420 ------- 

421 pd_tab : `pandas.Series` 

422 The output DataFrame with the Lomb-Scargle parameters. 

423 """ 

424 tmpDf = df[~np.logical_or(np.isnan(df["psfFlux"]), 

425 np.isnan(df["midpointMjdTai"]))] 

426 

427 if (len(tmpDf)) < min_detections: 

428 pd_tab_nodet = pd.Series({periodCol: np.nan, 

429 powerCol: np.nan, 

430 fapCol: np.nan}) 

431 for band in all_unique_bands: 

432 pd_tab_nodet[f"{band}_{ampCol}"] = np.nan 

433 pd_tab_nodet[f"{band}_{phaseCol}"] = np.nan 

434 

435 return pd_tab_nodet 

436 

437 time = tmpDf["midpointMjdTai"].to_numpy() 

438 flux = tmpDf["psfFlux"].to_numpy() 

439 flux_err = tmpDf["psfFluxErr"].to_numpy() 

440 bands = tmpDf["band"].to_numpy() 

441 

442 lsp = LombScargleMultiband(time, flux, bands, dy=flux_err, 

443 nterms_base=1, nterms_band=1) 

444 

445 f_grid = compute_optimized_periodogram_grid( 

446 time, oversampling_factor=oversampling_factor, nyquist_factor=nyquist_factor) 

447 period = 1/f_grid 

448 power = lsp.power(f_grid) 

449 

450 fap_estimate = self.calculate_baluev_fap( 

451 time, len(time), period[np.argmax(power)], np.max(power)) 

452 

453 params_table_new = self.generate_lsp_params(lsp, f_grid[np.argmax(power)], bands) 

454 

455 pd_tab = pd.Series({periodCol: period[np.argmax(power)], 

456 powerCol: np.max(power), 

457 fapCol: fap_estimate 

458 }) 

459 

460 # Initialize the per-band amplitude/phase columns as NaNs 

461 for band in all_unique_bands: 

462 pd_tab[f"{band}_{ampCol}"] = np.nan 

463 pd_tab[f"{band}_{phaseCol}"] = np.nan 

464 

465 # Populate the values of only the bands that have data for this diaSource 

466 unique_bands = np.unique(bands) 

467 for i in range(len(unique_bands)): 

468 pd_tab[f"{unique_bands[i]}_{ampCol}"] = params_table_new[0][i] 

469 pd_tab[f"{unique_bands[i]}_{phaseCol}"] = params_table_new[1][i] 

470 

471 return pd_tab 

472 

473 columns_list = [periodCol, powerCol, fapCol] 

474 for i in range(len(unique_bands)): 

475 columns_list.append(f"{unique_bands[i]}_{ampCol}") 

476 columns_list.append(f"{unique_bands[i]}_{phaseCol}") 

477 

478 diaObjects.loc[:, columns_list 

479 ] = diaSources.apply(_calculate_period_multi, unique_bands) 

480 

481 

482class UnphysicalDiaSourceSeparation(pipeBase.AlgorithmError): 

483 """Raised if associated DiaSources are unphysically separated. 

484 

485 Parameters 

486 ---------- 

487 separation : `float` 

488 Observed separation in arseconds. 

489 max_allowed_separation : `float` 

490 Configured maximum separation in arcseconds. 

491 """ 

492 

493 def __init__(self, separation, max_allowed_separation) -> None: 

494 self._separation = separation 

495 self._max_allowed_separation = max_allowed_separation 

496 super().__init__(f"Observed DiaSource separation {separation} exceeds allowed value of " 

497 f"{max_allowed_separation}") 

498 

499 @property 

500 def metadata(self) -> dict: 

501 return { 

502 "separation": self._separation, 

503 "max_allowed_separation": self._max_allowed_separation, 

504 } 

505 

506 

507class MeanDiaPositionConfig(DiaObjectCalculationPluginConfig): 

508 MaxAllowedDiaSourceSeparation = pexConfig.Field( 

509 dtype=float, 

510 default=3.0, 

511 doc="Max allowed separation of associated DiaSources in arcsec. " 

512 "Raises if unphysical separation is found. " 

513 ) 

514 

515 

516@register("ap_meanPosition") 

517class MeanDiaPosition(DiaObjectCalculationPlugin): 

518 """Compute the mean position and position uncertainty of a DiaObject 

519 from its associated DiaSources. 

520 

521 The reported (ra, dec) is the inverse-variance weighted spherical 

522 mean of the per-source positions, using only the DiaSources whose 

523 per-source coordinate errors are finite. The reported covariance 

524 is the covariance of that weighted-mean estimator, 

525 

526 C_formal = (sum_i inv(C_i))^-1, 

527 

528 where ``C_i`` is the per-source 2x2 covariance, multiplied by a 

529 *chi-squared scale factor* that inflates the result when the 

530 empirical scatter is larger than the per-source errors predict: 

531 

532 chi2 = sum_i r_i^T inv(C_i) r_i (r_i = tangent-plane 

533 residual from the mean) 

534 dof = 2 * (N - 1) 

535 C_obj = max(1, chi2 / dof) * C_formal 

536 

537 When the data agree with the per-source errors (chi2 ~ dof) the 

538 scale factor is unity and ``C_obj == C_formal``. When the residuals 

539 exceed what the errors predict, the covariance is inflated. 

540 """ 

541 

542 ConfigClass = MeanDiaPositionConfig 

543 

544 plugType = 'multi' 

545 

546 outputCols = ["ra", "dec", "raErr", "decErr", "ra_dec_Cov"] 

547 needsFilter = False 

548 

549 @classmethod 

550 def getExecutionOrder(cls): 

551 return cls.DEFAULT_CATALOGCALCULATION 

552 

553 def calculate(self, diaObjects, diaSources, **kwargs): 

554 """Compute the mean ra/dec position and its uncertainty for each 

555 DiaObject. 

556 

557 Parameters 

558 ---------- 

559 diaObjects : `pandas.DataFrame` 

560 Summary objects to store values in. 

561 diaSources : `pandas.DataFrame` or `pandas.DataFrameGroupBy` 

562 Catalog of DiaSources summarized by this DiaObject. 

563 **kwargs 

564 Any additional keyword arguments that may be passed to the plugin. 

565 """ 

566 for outCol in self.outputCols: 

567 if outCol not in diaObjects.columns: 567 ↛ 566line 567 didn't jump to line 566 because the condition on line 567 was always true

568 diaObjects[outCol] = np.nan 

569 

570 maxAllowedSep = self.config.MaxAllowedDiaSourceSeparation 

571 

572 def _column(df, name): 

573 """Read a column as float64, or return all-NaN if absent.""" 

574 if name in df.columns: 

575 return df[name].to_numpy(dtype=np.float64) 

576 return np.full(len(df), np.nan) 

577 

578 def _computeMeanPos(df): 

579 coords = list(geom.SpherePoint(src["ra"], src["dec"], geom.degrees) 

580 for idx, src in df.iterrows()) 

581 # Quick check of the unweighted mean to catch unphysical objects 

582 # and as a fallback. 

583 unweightedAvg = geom.averageSpherePoint(coords) 

584 maxSep = max(unweightedAvg.separation(coord).asArcseconds() for coord in coords) 

585 if maxSep > maxAllowedSep: 

586 raise UnphysicalDiaSourceSeparation(maxSep, maxAllowedSep) 

587 

588 nSrc = len(coords) 

589 raErr = _column(df, "raErr") 

590 decErr = _column(df, "decErr") 

591 raDecCov = _column(df, "ra_dec_Cov") 

592 finiteDiag = (np.isfinite(raErr) & (raErr > 0)) & (np.isfinite(decErr) & (decErr > 0)) 

593 nDiag = int(finiteDiag.sum()) 

594 

595 # Tangent-plane offsets from the unweighted mean reference, 

596 # in degrees (arc-length east, north). 

597 offsets = np.array( 

598 [[off.asDegrees() for off in unweightedAvg.getTangentPlaneOffset(coord)] for coord in coords] 

599 ) if nSrc >= 1 else np.zeros((0, 2)) 

600 

601 aveCoord = unweightedAvg 

602 varRa = np.nan 

603 varDec = np.nan 

604 raDecCovObj = np.nan 

605 

606 if nDiag >= 1: 

607 # Weighted-mean path: include only sources with finite 

608 # per-source coordinate errors. 

609 idx = np.where(finiteDiag)[0] 

610 sigmaRaSq = raErr[idx]**2 

611 sigmaDecSq = decErr[idx]**2 

612 # Use full 2x2 weights only when every included source 

613 # has a finite ra_dec_Cov; otherwise use diagonal weights 

614 # for all of them and emit NaN for the output covariance. 

615 haveFullCov = bool(np.isfinite(raDecCov[idx]).all()) 

616 

617 C = np.zeros((nDiag, 2, 2), dtype=np.float64) 

618 C[:, 0, 0] = sigmaRaSq 

619 C[:, 1, 1] = sigmaDecSq 

620 if haveFullCov: 

621 C[:, 0, 1] = raDecCov[idx] 

622 C[:, 1, 0] = raDecCov[idx] 

623 try: 

624 W = np.linalg.inv(C) 

625 except np.linalg.LinAlgError: 

626 # Singular per-source covariance: drop off-diagonal 

627 # and retry with diagonal weights only. 

628 haveFullCov = False 

629 C[:, 0, 1] = 0.0 

630 C[:, 1, 0] = 0.0 

631 W = np.linalg.inv(C) 

632 

633 W_sum = W.sum(axis=0) 

634 C_formal = np.linalg.inv(W_sum) 

635 

636 # Weighted-mean tangent-plane offset from the unweighted 

637 # reference: mu_offset = (sum W_i)^-1 (sum W_i r_i). Then 

638 # apply that offset to the unweighted mean to get the 

639 # weighted spherical mean. SpherePoint.offset takes a 

640 # bearing measured counter-clockwise from east (bearing 0 

641 # is due east, bearing pi/2 is due north). 

642 mu_offset = C_formal @ np.einsum('nij,nj->i', W, offsets[idx]) 

643 east, north = float(mu_offset[0]), float(mu_offset[1]) 

644 sep_deg = float(np.hypot(east, north)) 

645 if sep_deg > 0.0: 

646 bearing = geom.Angle(float(np.arctan2(north, east)), geom.radians) 

647 aveCoord = unweightedAvg.offset(bearing, sep_deg*geom.degrees) 

648 

649 varRaFormal = float(C_formal[0, 0]) 

650 varDecFormal = float(C_formal[1, 1]) 

651 covFormal = float(C_formal[0, 1]) if haveFullCov else np.nan 

652 

653 if nDiag == 1: 

654 # Single included source: pass its own 2x2 covariance through. 

655 varRa = varRaFormal 

656 varDec = varDecFormal 

657 raDecCovObj = covFormal 

658 else: 

659 # Inflate the variance if the reduced chi**2 fit of the 

660 # diaSource coordinates is greater than 1, to account for 

661 # scatter. 

662 residuals = offsets[idx] - mu_offset 

663 if haveFullCov: 

664 # Vectorized Σₙ rₙᵀ Wₙ rₙ (r = residuals). 

665 chi2 = float(np.einsum('ni,nij,nj->', residuals, W, residuals)) 

666 else: 

667 chi2 = float(np.sum(residuals[:, 0]**2/sigmaRaSq + residuals[:, 1]**2/sigmaDecSq)) 

668 dof = 2*(nDiag - 1) 

669 scale = max(1.0, chi2/dof) 

670 varRa = varRaFormal*scale 

671 varDec = varDecFormal*scale 

672 raDecCovObj = (covFormal*scale if np.isfinite(covFormal) else np.nan) 

673 else: 

674 # No source has finite per-source coordinate errors. Fall 

675 # back to the unweighted mean position plus the empirical 

676 # SEM (when N >= 2), and warn that the per-source errors 

677 # were not usable. 

678 warnings.warn( 

679 "No DiaSources with finite coordinate errors; falling " 

680 "back to the unweighted mean position with scatter-only " 

681 "uncertainty.", 

682 stacklevel=2, 

683 ) 

684 if nSrc >= 2: 

685 sampleCov = np.cov(offsets, rowvar=False, ddof=1) 

686 semCov = sampleCov/nSrc 

687 varRa = float(semCov[0, 0]) 

688 varDec = float(semCov[1, 1]) 

689 raDecCovObj = float(semCov[0, 1]) 

690 # else: nSrc == 1 with no error -- outputs stay NaN. 

691 

692 raErrObj = float(np.sqrt(varRa)) if np.isfinite(varRa) else np.nan 

693 decErrObj = float(np.sqrt(varDec)) if np.isfinite(varDec) else np.nan 

694 

695 return pd.Series({"ra": aveCoord.getRa().asDegrees(), 

696 "dec": aveCoord.getDec().asDegrees(), 

697 "raErr": raErrObj, 

698 "decErr": decErrObj, 

699 "ra_dec_Cov": raDecCovObj}) 

700 

701 ans = diaSources.apply(_computeMeanPos) 

702 typeSafePandasAssignment(diaObjects, ans, ["ra", "dec", "raErr", "decErr", "ra_dec_Cov"]) 

703 

704 

705class HTMIndexDiaPositionConfig(DiaObjectCalculationPluginConfig): 

706 

707 htmLevel = pexConfig.Field( 

708 dtype=int, 

709 doc="Level of the HTM pixelization.", 

710 default=20, 

711 ) 

712 

713 

714@register("ap_HTMIndex") 

715class HTMIndexDiaPosition(DiaObjectCalculationPlugin): 

716 """Compute the mean position of a DiaObject given a set of DiaSources. 

717 

718 Notes 

719 ----- 

720 This plugin was implemented to satisfy requirements of old APDB interface 

721 which required ``pixelId`` column in DiaObject with HTM20 index. APDB 

722 interface had migrated to not need that information, but we keep this 

723 plugin in case it may be useful for something else. 

724 """ 

725 ConfigClass = HTMIndexDiaPositionConfig 

726 

727 plugType = 'single' 

728 

729 inputCols = ["ra", "dec"] 

730 outputCols = ["pixelId"] 

731 needsFilter = False 

732 

733 def __init__(self, config, name, metadata): 

734 DiaObjectCalculationPlugin.__init__(self, config, name, metadata) 

735 self.pixelator = sphgeom.HtmPixelization(self.config.htmLevel) 

736 

737 @classmethod 

738 def getExecutionOrder(cls): 

739 return cls.FLUX_MOMENTS_CALCULATED 

740 

741 def calculate(self, diaObjects, diaObjectId, **kwargs): 

742 """Compute the mean position of a DiaObject given a set of DiaSources 

743 

744 Parameters 

745 ---------- 

746 diaObjects : `pandas.dataFrame` 

747 Summary objects to store values in and read ra/dec from. 

748 diaObjectId : `int` 

749 Id of the diaObject to update. 

750 **kwargs 

751 Any additional keyword arguments that may be passed to the plugin. 

752 """ 

753 sphPoint = geom.SpherePoint( 

754 diaObjects.at[diaObjectId, "ra"] * geom.degrees, 

755 diaObjects.at[diaObjectId, "dec"] * geom.degrees) 

756 diaObjects.at[diaObjectId, "pixelId"] = self.pixelator.index( 

757 sphPoint.getVector()) 

758 

759 

760class NumDiaSourcesDiaPluginConfig(DiaObjectCalculationPluginConfig): 

761 pass 

762 

763 

764@register("ap_nDiaSources") 

765class NumDiaSourcesDiaPlugin(DiaObjectCalculationPlugin): 

766 """Compute the total number of DiaSources associated with this DiaObject. 

767 """ 

768 

769 ConfigClass = NumDiaSourcesDiaPluginConfig 

770 outputCols = ["nDiaSources"] 

771 plugType = "multi" 

772 needsFilter = False 

773 

774 @classmethod 

775 def getExecutionOrder(cls): 

776 return cls.DEFAULT_CATALOGCALCULATION 

777 

778 def calculate(self, diaObjects, diaSources, **kwargs): 

779 """Compute the total number of DiaSources associated with this DiaObject. 

780 

781 Parameters 

782 ---------- 

783 diaObject : `dict` 

784 Summary object to store values in and read ra/dec from. 

785 **kwargs 

786 Any additional keyword arguments that may be passed to the plugin. 

787 """ 

788 typeSafePandasAssignment(diaObjects, diaSources.diaObjectId.count(), ["nDiaSources"]) 

789 

790 

791class SimpleSourceFlagDiaPluginConfig(DiaObjectCalculationPluginConfig): 

792 pass 

793 

794 

795@register("ap_diaObjectFlag") 

796class SimpleSourceFlagDiaPlugin(DiaObjectCalculationPlugin): 

797 """Find if any DiaSource is flagged. 

798 

799 Set the DiaObject flag if any DiaSource is flagged. 

800 """ 

801 

802 ConfigClass = NumDiaSourcesDiaPluginConfig 

803 outputCols = ["flags"] 

804 plugType = "multi" 

805 needsFilter = False 

806 

807 @classmethod 

808 def getExecutionOrder(cls): 

809 return cls.DEFAULT_CATALOGCALCULATION 

810 

811 def calculate(self, diaObjects, diaSources, **kwargs): 

812 """Find if any DiaSource is flagged. 

813 

814 Set the DiaObject flag if any DiaSource is flagged. 

815 

816 Parameters 

817 ---------- 

818 diaObject : `dict` 

819 Summary object to store values in and read ra/dec from. 

820 **kwargs 

821 Any additional keyword arguments that may be passed to the plugin. 

822 """ 

823 typeSafePandasAssignment(diaObjects, diaSources.flags.any(), ["flags"], default_dtype=np.uint64) 

824 

825 

826class WeightedMeanDiaPsfFluxConfig(DiaObjectCalculationPluginConfig): 

827 pass 

828 

829 

830@register("ap_meanFlux") 

831class WeightedMeanDiaPsfFlux(DiaObjectCalculationPlugin): 

832 """Compute the weighted mean and mean error on the point source fluxes 

833 of the DiaSource measured on the difference image. 

834 

835 Additionally store number of usable data points. 

836 """ 

837 

838 ConfigClass = WeightedMeanDiaPsfFluxConfig 

839 outputCols = ["psfFluxMean", "psfFluxMeanErr", "psfFluxNdata"] 

840 plugType = "multi" 

841 needsFilter = True 

842 

843 @classmethod 

844 def getExecutionOrder(cls): 

845 return cls.DEFAULT_CATALOGCALCULATION 

846 

847 @catchWarnings(warns=["invalid value encountered", 

848 "divide by zero"]) 

849 def calculate(self, 

850 diaObjects, 

851 diaSources, 

852 filterDiaSources, 

853 band, 

854 **kwargs): 

855 """Compute the weighted mean and mean error of the point source flux. 

856 

857 Parameters 

858 ---------- 

859 diaObject : `dict` 

860 Summary object to store values in. 

861 diaSources : `pandas.DataFrame` 

862 DataFrame representing all diaSources associated with this 

863 diaObject. 

864 filterDiaSources : `pandas.DataFrame` 

865 DataFrame representing diaSources associated with this 

866 diaObject that are observed in the band pass ``band``. 

867 band : `str` 

868 Simple, string name of the filter for the flux being calculated. 

869 **kwargs 

870 Any additional keyword arguments that may be passed to the plugin. 

871 """ 

872 meanName = "{}_psfFluxMean".format(band) 

873 errName = "{}_psfFluxMeanErr".format(band) 

874 nDataName = "{}_psfFluxNdata".format(band) 

875 if meanName not in diaObjects.columns: 875 ↛ 877line 875 didn't jump to line 877 because the condition on line 875 was always true

876 diaObjects[meanName] = np.nan 

877 if errName not in diaObjects.columns: 877 ↛ 879line 877 didn't jump to line 879 because the condition on line 877 was always true

878 diaObjects[errName] = np.nan 

879 if nDataName not in diaObjects.columns: 879 ↛ 882line 879 didn't jump to line 882 because the condition on line 879 was always true

880 diaObjects[nDataName] = 0 

881 

882 def _weightedMean(df): 

883 tmpDf = df[~np.logical_or(np.isnan(df["psfFlux"]), 

884 np.isnan(df["psfFluxErr"]))] 

885 tot_weight = np.nansum(1 / tmpDf["psfFluxErr"] ** 2) 

886 fluxMean = np.nansum(tmpDf["psfFlux"] 

887 / tmpDf["psfFluxErr"] ** 2) 

888 fluxMean /= tot_weight 

889 if tot_weight > 0: 889 ↛ 892line 889 didn't jump to line 892 because the condition on line 889 was always true

890 fluxMeanErr = np.sqrt(1 / tot_weight) 

891 else: 

892 fluxMeanErr = np.nan 

893 nFluxData = len(tmpDf) 

894 

895 return pd.Series({meanName: fluxMean, 

896 errName: fluxMeanErr, 

897 nDataName: nFluxData}, 

898 dtype="object") 

899 df = filterDiaSources.apply(_weightedMean).astype(diaObjects.dtypes[[meanName, errName, nDataName]]) 

900 # TODO DM-53254: Remove the force_int_to_float hack. 

901 typeSafePandasAssignment(diaObjects, df, [meanName, errName, nDataName], force_int_to_float=True) 

902 

903 

904class PercentileDiaPsfFluxConfig(DiaObjectCalculationPluginConfig): 

905 percentiles = pexConfig.ListField( 

906 dtype=int, 

907 default=[5, 25, 50, 75, 95], 

908 doc="Percentiles to calculate to compute values for. Should be " 

909 "integer values." 

910 ) 

911 

912 

913@register("ap_percentileFlux") 

914class PercentileDiaPsfFlux(DiaObjectCalculationPlugin): 

915 """Compute percentiles of diaSource fluxes. 

916 """ 

917 

918 ConfigClass = PercentileDiaPsfFluxConfig 

919 # Output columns are created upon instantiation of the class. 

920 outputCols = [] 

921 plugType = "multi" 

922 needsFilter = True 

923 

924 def __init__(self, config, name, metadata, **kwargs): 

925 DiaObjectCalculationPlugin.__init__(self, 

926 config, 

927 name, 

928 metadata, 

929 **kwargs) 

930 self.outputCols = ["psfFluxPercentile{:02d}".format(percent) 

931 for percent in self.config.percentiles] 

932 

933 @classmethod 

934 def getExecutionOrder(cls): 

935 return cls.DEFAULT_CATALOGCALCULATION 

936 

937 @catchWarnings(warns=["All-NaN slice encountered"]) 

938 def calculate(self, 

939 diaObjects, 

940 diaSources, 

941 filterDiaSources, 

942 band, 

943 **kwargs): 

944 """Compute the percentile fluxes of the point source flux. 

945 

946 Parameters 

947 ---------- 

948 diaObject : `dict` 

949 Summary object to store values in. 

950 diaSources : `pandas.DataFrame` 

951 DataFrame representing all diaSources associated with this 

952 diaObject. 

953 filterDiaSources : `pandas.DataFrame` 

954 DataFrame representing diaSources associated with this 

955 diaObject that are observed in the band pass ``band``. 

956 band : `str` 

957 Simple, string name of the filter for the flux being calculated. 

958 **kwargs 

959 Any additional keyword arguments that may be passed to the plugin. 

960 """ 

961 pTileNames = [] 

962 for tilePercent in self.config.percentiles: 

963 pTileName = "{}_psfFluxPercentile{:02d}".format(band, 

964 tilePercent) 

965 pTileNames.append(pTileName) 

966 if pTileName not in diaObjects.columns: 966 ↛ 962line 966 didn't jump to line 962 because the condition on line 966 was always true

967 diaObjects[pTileName] = np.nan 

968 

969 def _fluxPercentiles(df): 

970 pTiles = np.nanpercentile(df["psfFlux"], self.config.percentiles) 

971 return pd.Series( 

972 dict((tileName, pTile) 

973 for tileName, pTile in zip(pTileNames, pTiles))) 

974 

975 typeSafePandasAssignment( 

976 diaObjects, 

977 filterDiaSources.apply(_fluxPercentiles), 

978 pTileNames, 

979 ) 

980 

981 

982class SigmaDiaPsfFluxConfig(DiaObjectCalculationPluginConfig): 

983 pass 

984 

985 

986@register("ap_sigmaFlux") 

987class SigmaDiaPsfFlux(DiaObjectCalculationPlugin): 

988 """Compute scatter of diaSource fluxes. 

989 """ 

990 

991 ConfigClass = SigmaDiaPsfFluxConfig 

992 # Output columns are created upon instantiation of the class. 

993 outputCols = ["psfFluxSigma"] 

994 plugType = "multi" 

995 needsFilter = True 

996 

997 @classmethod 

998 def getExecutionOrder(cls): 

999 return cls.DEFAULT_CATALOGCALCULATION 

1000 

1001 def calculate(self, 

1002 diaObjects, 

1003 diaSources, 

1004 filterDiaSources, 

1005 band, 

1006 **kwargs): 

1007 """Compute the sigma fluxes of the point source flux. 

1008 

1009 Parameters 

1010 ---------- 

1011 diaObject : `dict` 

1012 Summary object to store values in. 

1013 diaSources : `pandas.DataFrame` 

1014 DataFrame representing all diaSources associated with this 

1015 diaObject. 

1016 filterDiaSources : `pandas.DataFrame` 

1017 DataFrame representing diaSources associated with this 

1018 diaObject that are observed in the band pass ``band``. 

1019 band : `str` 

1020 Simple, string name of the filter for the flux being calculated. 

1021 **kwargs 

1022 Any additional keyword arguments that may be passed to the plugin. 

1023 """ 

1024 # Set "delta degrees of freedom (ddf)" to 1 to calculate the unbiased 

1025 # estimator of scatter (i.e. 'N - 1' instead of 'N'). 

1026 column = "{}_psfFluxSigma".format(band) 

1027 

1028 typeSafePandasAssignment( 

1029 diaObjects, 

1030 filterDiaSources.psfFlux.std(), 

1031 [column], 

1032 ) 

1033 

1034 

1035class Chi2DiaPsfFluxConfig(DiaObjectCalculationPluginConfig): 

1036 pass 

1037 

1038 

1039@register("ap_chi2Flux") 

1040class Chi2DiaPsfFlux(DiaObjectCalculationPlugin): 

1041 """Compute chi2 of diaSource fluxes. 

1042 """ 

1043 

1044 ConfigClass = Chi2DiaPsfFluxConfig 

1045 

1046 # Required input Cols 

1047 inputCols = ["psfFluxMean"] 

1048 # Output columns are created upon instantiation of the class. 

1049 outputCols = ["psfFluxChi2"] 

1050 plugType = "multi" 

1051 needsFilter = True 

1052 

1053 @classmethod 

1054 def getExecutionOrder(cls): 

1055 return cls.FLUX_MOMENTS_CALCULATED 

1056 

1057 @catchWarnings(warns=["All-NaN slice encountered"]) 

1058 def calculate(self, 

1059 diaObjects, 

1060 diaSources, 

1061 filterDiaSources, 

1062 band, 

1063 **kwargs): 

1064 """Compute the chi2 of the point source fluxes. 

1065 

1066 Parameters 

1067 ---------- 

1068 diaObject : `dict` 

1069 Summary object to store values in. 

1070 diaSources : `pandas.DataFrame` 

1071 DataFrame representing all diaSources associated with this 

1072 diaObject. 

1073 filterDiaSources : `pandas.DataFrame` 

1074 DataFrame representing diaSources associated with this 

1075 diaObject that are observed in the band pass ``band``. 

1076 band : `str` 

1077 Simple, string name of the filter for the flux being calculated. 

1078 **kwargs 

1079 Any additional keyword arguments that may be passed to the plugin. 

1080 """ 

1081 meanName = "{}_psfFluxMean".format(band) 

1082 column = "{}_psfFluxChi2".format(band) 

1083 

1084 def _chi2(df): 

1085 delta = (df["psfFlux"] 

1086 - diaObjects.at[df.diaObjectId.iat[0], meanName]) 

1087 return np.nansum((delta / df["psfFluxErr"]) ** 2) 

1088 

1089 typeSafePandasAssignment( 

1090 diaObjects, 

1091 filterDiaSources.apply(_chi2), 

1092 [column], 

1093 ) 

1094 

1095 

1096class MadDiaPsfFluxConfig(DiaObjectCalculationPluginConfig): 

1097 pass 

1098 

1099 

1100@register("ap_madFlux") 

1101class MadDiaPsfFlux(DiaObjectCalculationPlugin): 

1102 """Compute median absolute deviation of diaSource fluxes. 

1103 """ 

1104 

1105 ConfigClass = MadDiaPsfFluxConfig 

1106 

1107 # Required input Cols 

1108 # Output columns are created upon instantiation of the class. 

1109 outputCols = ["psfFluxMAD"] 

1110 plugType = "multi" 

1111 needsFilter = True 

1112 

1113 @classmethod 

1114 def getExecutionOrder(cls): 

1115 return cls.DEFAULT_CATALOGCALCULATION 

1116 

1117 @catchWarnings(warns=["All-NaN slice encountered"]) 

1118 def calculate(self, 

1119 diaObjects, 

1120 diaSources, 

1121 filterDiaSources, 

1122 band, 

1123 **kwargs): 

1124 """Compute the median absolute deviation of the point source fluxes. 

1125 

1126 Parameters 

1127 ---------- 

1128 diaObject : `dict` 

1129 Summary object to store values in. 

1130 diaSources : `pandas.DataFrame` 

1131 DataFrame representing all diaSources associated with this 

1132 diaObject. 

1133 filterDiaSources : `pandas.DataFrame` 

1134 DataFrame representing diaSources associated with this 

1135 diaObject that are observed in the band pass ``band``. 

1136 band : `str` 

1137 Simple, string name of the filter for the flux being calculated. 

1138 **kwargs 

1139 Any additional keyword arguments that may be passed to the plugin. 

1140 """ 

1141 column = "{}_psfFluxMAD".format(band) 

1142 

1143 typeSafePandasAssignment( 

1144 diaObjects, 

1145 filterDiaSources.psfFlux.apply(median_absolute_deviation, ignore_nan=True), 

1146 [column], 

1147 ) 

1148 

1149 

1150class SkewDiaPsfFluxConfig(DiaObjectCalculationPluginConfig): 

1151 pass 

1152 

1153 

1154@register("ap_skewFlux") 

1155class SkewDiaPsfFlux(DiaObjectCalculationPlugin): 

1156 """Compute the skew of diaSource fluxes. 

1157 """ 

1158 

1159 ConfigClass = SkewDiaPsfFluxConfig 

1160 

1161 # Required input Cols 

1162 # Output columns are created upon instantiation of the class. 

1163 outputCols = ["psfFluxSkew"] 

1164 plugType = "multi" 

1165 needsFilter = True 

1166 

1167 @classmethod 

1168 def getExecutionOrder(cls): 

1169 return cls.DEFAULT_CATALOGCALCULATION 

1170 

1171 def calculate(self, 

1172 diaObjects, 

1173 diaSources, 

1174 filterDiaSources, 

1175 band, 

1176 **kwargs): 

1177 """Compute the skew of the point source fluxes. 

1178 

1179 Parameters 

1180 ---------- 

1181 diaObject : `dict` 

1182 Summary object to store values in. 

1183 diaSources : `pandas.DataFrame` 

1184 DataFrame representing all diaSources associated with this 

1185 diaObject. 

1186 filterDiaSources : `pandas.DataFrame` 

1187 DataFrame representing diaSources associated with this 

1188 diaObject that are observed in the band pass ``band``. 

1189 band : `str` 

1190 Simple, string name of the filter for the flux being calculated. 

1191 **kwargs 

1192 Any additional keyword arguments that may be passed to the plugin. 

1193 """ 

1194 column = "{}_psfFluxSkew".format(band) 

1195 

1196 typeSafePandasAssignment( 

1197 diaObjects, 

1198 filterDiaSources.psfFlux.skew(), 

1199 [column], 

1200 ) 

1201 

1202 

1203class MinMaxDiaPsfFluxConfig(DiaObjectCalculationPluginConfig): 

1204 pass 

1205 

1206 

1207@register("ap_minMaxFlux") 

1208class MinMaxDiaPsfFlux(DiaObjectCalculationPlugin): 

1209 """Compute min/max of diaSource fluxes. 

1210 """ 

1211 

1212 ConfigClass = MinMaxDiaPsfFluxConfig 

1213 

1214 # Required input Cols 

1215 # Output columns are created upon instantiation of the class. 

1216 outputCols = ["psfFluxMin", "psfFluxMax"] 

1217 plugType = "multi" 

1218 needsFilter = True 

1219 

1220 @classmethod 

1221 def getExecutionOrder(cls): 

1222 return cls.DEFAULT_CATALOGCALCULATION 

1223 

1224 def calculate(self, 

1225 diaObjects, 

1226 diaSources, 

1227 filterDiaSources, 

1228 band, 

1229 **kwargs): 

1230 """Compute min/max of the point source fluxes. 

1231 

1232 Parameters 

1233 ---------- 

1234 diaObject : `dict` 

1235 Summary object to store values in. 

1236 diaSources : `pandas.DataFrame` 

1237 DataFrame representing all diaSources associated with this 

1238 diaObject. 

1239 filterDiaSources : `pandas.DataFrame` 

1240 DataFrame representing diaSources associated with this 

1241 diaObject that are observed in the band pass ``band``. 

1242 band : `str` 

1243 Simple, string name of the filter for the flux being calculated. 

1244 **kwargs 

1245 Any additional keyword arguments that may be passed to the plugin. 

1246 """ 

1247 minName = "{}_psfFluxMin".format(band) 

1248 if minName not in diaObjects.columns: 1248 ↛ 1250line 1248 didn't jump to line 1250 because the condition on line 1248 was always true

1249 diaObjects[minName] = np.nan 

1250 maxName = "{}_psfFluxMax".format(band) 

1251 if maxName not in diaObjects.columns: 1251 ↛ 1254line 1251 didn't jump to line 1254 because the condition on line 1251 was always true

1252 diaObjects[maxName] = np.nan 

1253 

1254 typeSafePandasAssignment( 

1255 diaObjects, 

1256 filterDiaSources.psfFlux.min(), 

1257 [minName], 

1258 ) 

1259 typeSafePandasAssignment( 

1260 diaObjects, 

1261 filterDiaSources.psfFlux.max(), 

1262 [maxName], 

1263 ) 

1264 

1265 

1266class MaxSlopeDiaPsfFluxConfig(DiaObjectCalculationPluginConfig): 

1267 pass 

1268 

1269 

1270@register("ap_maxSlopeFlux") 

1271class MaxSlopeDiaPsfFlux(DiaObjectCalculationPlugin): 

1272 """Compute the maximum ratio time ordered deltaFlux / deltaTime. 

1273 """ 

1274 

1275 ConfigClass = MinMaxDiaPsfFluxConfig 

1276 

1277 # Required input Cols 

1278 # Output columns are created upon instantiation of the class. 

1279 outputCols = ["psfFluxMaxSlope"] 

1280 plugType = "multi" 

1281 needsFilter = True 

1282 

1283 @classmethod 

1284 def getExecutionOrder(cls): 

1285 return cls.DEFAULT_CATALOGCALCULATION 

1286 

1287 def calculate(self, 

1288 diaObjects, 

1289 diaSources, 

1290 filterDiaSources, 

1291 band, 

1292 **kwargs): 

1293 """Compute the maximum ratio time ordered deltaFlux / deltaTime. 

1294 

1295 Parameters 

1296 ---------- 

1297 diaObject : `dict` 

1298 Summary object to store values in. 

1299 diaSources : `pandas.DataFrame` 

1300 DataFrame representing all diaSources associated with this 

1301 diaObject. 

1302 filterDiaSources : `pandas.DataFrame` 

1303 DataFrame representing diaSources associated with this 

1304 diaObject that are observed in the band pass ``band``. 

1305 band : `str` 

1306 Simple, string name of the filter for the flux being calculated. 

1307 **kwargs 

1308 Any additional keyword arguments that may be passed to the plugin. 

1309 """ 

1310 

1311 def _maxSlope(df): 

1312 tmpDf = df[~np.logical_or(np.isnan(df["psfFlux"]), 

1313 np.isnan(df["midpointMjdTai"]))] 

1314 if len(tmpDf) < 2: 

1315 return np.nan 

1316 times = tmpDf["midpointMjdTai"].to_numpy() 

1317 timeArgs = times.argsort() 

1318 times = times[timeArgs] 

1319 fluxes = tmpDf["psfFlux"].to_numpy()[timeArgs] 

1320 return (np.diff(fluxes) / np.diff(times)).max() 

1321 

1322 column = "{}_psfFluxMaxSlope".format(band) 

1323 

1324 typeSafePandasAssignment( 

1325 diaObjects, 

1326 filterDiaSources.apply(_maxSlope), 

1327 [column], 

1328 ) 

1329 

1330 

1331class ErrMeanDiaPsfFluxConfig(DiaObjectCalculationPluginConfig): 

1332 pass 

1333 

1334 

1335@register("ap_meanErrFlux") 

1336class ErrMeanDiaPsfFlux(DiaObjectCalculationPlugin): 

1337 """Compute the mean of the dia source errors. 

1338 """ 

1339 

1340 ConfigClass = ErrMeanDiaPsfFluxConfig 

1341 

1342 # Required input Cols 

1343 # Output columns are created upon instantiation of the class. 

1344 outputCols = ["psfFluxErrMean"] 

1345 plugType = "multi" 

1346 needsFilter = True 

1347 

1348 @classmethod 

1349 def getExecutionOrder(cls): 

1350 return cls.DEFAULT_CATALOGCALCULATION 

1351 

1352 def calculate(self, 

1353 diaObjects, 

1354 diaSources, 

1355 filterDiaSources, 

1356 band, 

1357 **kwargs): 

1358 """Compute the mean of the dia source errors. 

1359 

1360 Parameters 

1361 ---------- 

1362 diaObject : `dict` 

1363 Summary object to store values in. 

1364 diaSources : `pandas.DataFrame` 

1365 DataFrame representing all diaSources associated with this 

1366 diaObject. 

1367 filterDiaSources : `pandas.DataFrame` 

1368 DataFrame representing diaSources associated with this 

1369 diaObject that are observed in the band pass ``band``. 

1370 band : `str` 

1371 Simple, string name of the filter for the flux being calculated. 

1372 **kwargs 

1373 Any additional keyword arguments that may be passed to the plugin. 

1374 """ 

1375 column = "{}_psfFluxErrMean".format(band) 

1376 

1377 typeSafePandasAssignment( 

1378 diaObjects, 

1379 filterDiaSources.psfFluxErr.mean(), 

1380 [column], 

1381 # Note that the schemas expect this to be single-precision. 

1382 default_dtype=np.float32, 

1383 ) 

1384 

1385 

1386class LinearFitDiaPsfFluxConfig(DiaObjectCalculationPluginConfig): 

1387 pass 

1388 

1389 

1390@register("ap_linearFit") 

1391class LinearFitDiaPsfFlux(DiaObjectCalculationPlugin): 

1392 """Compute fit a linear model to flux vs time. 

1393 """ 

1394 

1395 ConfigClass = LinearFitDiaPsfFluxConfig 

1396 

1397 # Required input Cols 

1398 # Output columns are created upon instantiation of the class. 

1399 outputCols = ["psfFluxLinearSlope", "psfFluxLinearIntercept"] 

1400 plugType = "multi" 

1401 needsFilter = True 

1402 

1403 @classmethod 

1404 def getExecutionOrder(cls): 

1405 return cls.DEFAULT_CATALOGCALCULATION 

1406 

1407 def calculate(self, 

1408 diaObjects, 

1409 diaSources, 

1410 filterDiaSources, 

1411 band, 

1412 **kwargs): 

1413 """Compute fit a linear model to flux vs time. 

1414 

1415 Parameters 

1416 ---------- 

1417 diaObject : `dict` 

1418 Summary object to store values in. 

1419 diaSources : `pandas.DataFrame` 

1420 DataFrame representing all diaSources associated with this 

1421 diaObject. 

1422 filterDiaSources : `pandas.DataFrame` 

1423 DataFrame representing diaSources associated with this 

1424 diaObject that are observed in the band pass ``band``. 

1425 band : `str` 

1426 Simple, string name of the filter for the flux being calculated. 

1427 **kwargs 

1428 Any additional keyword arguments that may be passed to the plugin. 

1429 """ 

1430 

1431 mName = "{}_psfFluxLinearSlope".format(band) 

1432 if mName not in diaObjects.columns: 1432 ↛ 1434line 1432 didn't jump to line 1434 because the condition on line 1432 was always true

1433 diaObjects[mName] = np.nan 

1434 bName = "{}_psfFluxLinearIntercept".format(band) 

1435 if bName not in diaObjects.columns: 1435 ↛ 1437line 1435 didn't jump to line 1437 because the condition on line 1435 was always true

1436 diaObjects[bName] = np.nan 

1437 dtype = diaObjects[mName].dtype 

1438 

1439 def _linearFit(df): 

1440 tmpDf = df[~np.logical_or( 

1441 np.isnan(df["psfFlux"]), 

1442 np.logical_or(np.isnan(df["psfFluxErr"]), 

1443 np.isnan(df["midpointMjdTai"])))] 

1444 if len(tmpDf) < 2: 1444 ↛ 1445line 1444 didn't jump to line 1445 because the condition on line 1444 was never true

1445 return pd.Series({mName: np.nan, bName: np.nan}) 

1446 fluxes = tmpDf["psfFlux"].to_numpy() 

1447 errors = tmpDf["psfFluxErr"].to_numpy() 

1448 times = tmpDf["midpointMjdTai"].to_numpy() 

1449 A = np.array([times / errors, 1 / errors]).transpose() 

1450 m, b = lsq_linear(A, fluxes / errors).x 

1451 return pd.Series({mName: m, bName: b}, dtype=dtype) 

1452 

1453 typeSafePandasAssignment( 

1454 diaObjects, 

1455 filterDiaSources.apply(_linearFit), 

1456 [mName, bName], 

1457 ) 

1458 

1459 

1460class StetsonJDiaPsfFluxConfig(DiaObjectCalculationPluginConfig): 

1461 pass 

1462 

1463 

1464@register("ap_stetsonJ") 

1465class StetsonJDiaPsfFlux(DiaObjectCalculationPlugin): 

1466 """Compute the StetsonJ statistic on the DIA point source fluxes. 

1467 """ 

1468 

1469 ConfigClass = LinearFitDiaPsfFluxConfig 

1470 

1471 # Required input Cols 

1472 inputCols = ["psfFluxMean"] 

1473 # Output columns are created upon instantiation of the class. 

1474 outputCols = ["psfFluxStetsonJ"] 

1475 plugType = "multi" 

1476 needsFilter = True 

1477 

1478 @classmethod 

1479 def getExecutionOrder(cls): 

1480 return cls.FLUX_MOMENTS_CALCULATED 

1481 

1482 def calculate(self, 

1483 diaObjects, 

1484 diaSources, 

1485 filterDiaSources, 

1486 band, 

1487 **kwargs): 

1488 """Compute the StetsonJ statistic on the DIA point source fluxes. 

1489 

1490 Parameters 

1491 ---------- 

1492 diaObject : `dict` 

1493 Summary object to store values in. 

1494 diaSources : `pandas.DataFrame` 

1495 DataFrame representing all diaSources associated with this 

1496 diaObject. 

1497 filterDiaSources : `pandas.DataFrame` 

1498 DataFrame representing diaSources associated with this 

1499 diaObject that are observed in the band pass ``band``. 

1500 band : `str` 

1501 Simple, string name of the filter for the flux being calculated. 

1502 **kwargs 

1503 Any additional keyword arguments that may be passed to the plugin. 

1504 """ 

1505 meanName = "{}_psfFluxMean".format(band) 

1506 

1507 def _stetsonJ(df): 

1508 tmpDf = df[~np.logical_or(np.isnan(df["psfFlux"]), 

1509 np.isnan(df["psfFluxErr"]))] 

1510 if len(tmpDf) < 2: 

1511 return np.nan 

1512 fluxes = tmpDf["psfFlux"].to_numpy() 

1513 errors = tmpDf["psfFluxErr"].to_numpy() 

1514 

1515 return self._stetson_J( 

1516 fluxes, 

1517 errors, 

1518 diaObjects.at[tmpDf.diaObjectId.iat[0], meanName]) 

1519 

1520 column = "{}_psfFluxStetsonJ".format(band) 

1521 typeSafePandasAssignment( 

1522 diaObjects, 

1523 filterDiaSources.apply(_stetsonJ), 

1524 [column], 

1525 ) 

1526 

1527 def _stetson_J(self, fluxes, errors, mean=None): 

1528 """Compute the single band stetsonJ statistic. 

1529 

1530 Parameters 

1531 ---------- 

1532 fluxes : `numpy.ndarray` (N,) 

1533 Calibrated lightcurve flux values. 

1534 errors : `numpy.ndarray` (N,) 

1535 Errors on the calibrated lightcurve fluxes. 

1536 mean : `float` 

1537 Starting mean from previous plugin. 

1538 

1539 Returns 

1540 ------- 

1541 stetsonJ : `float` 

1542 stetsonJ statistic for the input fluxes and errors. 

1543 

1544 References 

1545 ---------- 

1546 .. [1] Stetson, P. B., "On the Automatic Determination of Light-Curve 

1547 Parameters for Cepheid Variables", PASP, 108, 851S, 1996 

1548 """ 

1549 n_points = len(fluxes) 

1550 flux_mean = self._stetson_mean(fluxes, errors, mean) 

1551 delta_val = ( 

1552 np.sqrt(n_points / (n_points - 1)) * (fluxes - flux_mean) / errors) 

1553 p_k = delta_val ** 2 - 1 

1554 

1555 return np.mean(np.sign(p_k) * np.sqrt(np.fabs(p_k))) 

1556 

1557 def _stetson_mean(self, 

1558 values, 

1559 errors, 

1560 mean=None, 

1561 alpha=2., 

1562 beta=2., 

1563 n_iter=20, 

1564 tol=1e-6): 

1565 """Compute the stetson mean of the fluxes which down-weights outliers. 

1566 

1567 Weighted biased on an error weighted difference scaled by a constant 

1568 (1/``a``) and raised to the power beta. Higher betas more harshly 

1569 penalize outliers and ``a`` sets the number of sigma where a weighted 

1570 difference of 1 occurs. 

1571 

1572 Parameters 

1573 ---------- 

1574 values : `numpy.dnarray`, (N,) 

1575 Input values to compute the mean of. 

1576 errors : `numpy.ndarray`, (N,) 

1577 Errors on the input values. 

1578 mean : `float` 

1579 Starting mean value or None. 

1580 alpha : `float` 

1581 Scalar down-weighting of the fractional difference. lower->more 

1582 clipping. (Default value is 2.) 

1583 beta : `float` 

1584 Power law slope of the used to down-weight outliers. higher->more 

1585 clipping. (Default value is 2.) 

1586 n_iter : `int` 

1587 Number of iterations of clipping. 

1588 tol : `float` 

1589 Fractional and absolute tolerance goal on the change in the mean 

1590 before exiting early. (Default value is 1e-6) 

1591 

1592 Returns 

1593 ------- 

1594 mean : `float` 

1595 Weighted stetson mean result. 

1596 

1597 References 

1598 ---------- 

1599 .. [1] Stetson, P. B., "On the Automatic Determination of Light-Curve 

1600 Parameters for Cepheid Variables", PASP, 108, 851S, 1996 

1601 """ 

1602 n_points = len(values) 

1603 n_factor = np.sqrt(n_points / (n_points - 1)) 

1604 inv_var = 1 / errors ** 2 

1605 

1606 if mean is None: 1606 ↛ 1607line 1606 didn't jump to line 1607 because the condition on line 1606 was never true

1607 mean = np.average(values, weights=inv_var) 

1608 for iter_idx in range(n_iter): 1608 ↛ 1617line 1608 didn't jump to line 1617 because the loop on line 1608 didn't complete

1609 chi = np.fabs(n_factor * (values - mean) / errors) 

1610 tmp_mean = np.average( 

1611 values, 

1612 weights=inv_var / (1 + (chi / alpha) ** beta)) 

1613 diff = np.fabs(tmp_mean - mean) 

1614 mean = tmp_mean 

1615 if diff / mean < tol and diff < tol: 

1616 break 

1617 return mean 

1618 

1619 

1620class WeightedMeanDiaTotFluxConfig(DiaObjectCalculationPluginConfig): 

1621 pass 

1622 

1623 

1624@register("ap_meanTotFlux") 

1625class WeightedMeanDiaTotFlux(DiaObjectCalculationPlugin): 

1626 """Compute the weighted mean and mean error on the point source fluxes 

1627 forced photometered at the DiaSource location in the calibrated image. 

1628 

1629 Additionally store number of usable data points. 

1630 """ 

1631 

1632 ConfigClass = WeightedMeanDiaPsfFluxConfig 

1633 outputCols = ["scienceFluxMean", "scienceFluxMeanErr"] 

1634 plugType = "multi" 

1635 needsFilter = True 

1636 

1637 @classmethod 

1638 def getExecutionOrder(cls): 

1639 return cls.DEFAULT_CATALOGCALCULATION 

1640 

1641 @catchWarnings(warns=["invalid value encountered", 

1642 "divide by zero"]) 

1643 def calculate(self, 

1644 diaObjects, 

1645 diaSources, 

1646 filterDiaSources, 

1647 band, 

1648 **kwargs): 

1649 """Compute the weighted mean and mean error of the point source flux. 

1650 

1651 Parameters 

1652 ---------- 

1653 diaObject : `dict` 

1654 Summary object to store values in. 

1655 diaSources : `pandas.DataFrame` 

1656 DataFrame representing all diaSources associated with this 

1657 diaObject. 

1658 filterDiaSources : `pandas.DataFrame` 

1659 DataFrame representing diaSources associated with this 

1660 diaObject that are observed in the band pass ``band``. 

1661 band : `str` 

1662 Simple, string name of the filter for the flux being calculated. 

1663 **kwargs 

1664 Any additional keyword arguments that may be passed to the plugin. 

1665 """ 

1666 totMeanName = "{}_scienceFluxMean".format(band) 

1667 if totMeanName not in diaObjects.columns: 1667 ↛ 1669line 1667 didn't jump to line 1669 because the condition on line 1667 was always true

1668 diaObjects[totMeanName] = np.nan 

1669 totErrName = "{}_scienceFluxMeanErr".format(band) 

1670 if totErrName not in diaObjects.columns: 1670 ↛ 1673line 1670 didn't jump to line 1673 because the condition on line 1670 was always true

1671 diaObjects[totErrName] = np.nan 

1672 

1673 def _meanFlux(df): 

1674 tmpDf = df[~np.logical_or(np.isnan(df["scienceFlux"]), 

1675 np.isnan(df["scienceFluxErr"]))] 

1676 tot_weight = np.nansum(1 / tmpDf["scienceFluxErr"] ** 2) 

1677 fluxMean = np.nansum(tmpDf["scienceFlux"] 

1678 / tmpDf["scienceFluxErr"] ** 2) 

1679 fluxMean /= tot_weight 

1680 fluxMeanErr = np.sqrt(1 / tot_weight) 

1681 

1682 return pd.Series({totMeanName: fluxMean, 

1683 totErrName: fluxMeanErr}) 

1684 

1685 df = filterDiaSources.apply(_meanFlux).astype(diaObjects.dtypes[[totMeanName, totErrName]]) 

1686 typeSafePandasAssignment(diaObjects, df, [totMeanName, totErrName]) 

1687 

1688 

1689class SigmaDiaTotFluxConfig(DiaObjectCalculationPluginConfig): 

1690 pass 

1691 

1692 

1693@register("ap_sigmaTotFlux") 

1694class SigmaDiaTotFlux(DiaObjectCalculationPlugin): 

1695 """Compute scatter of diaSource fluxes. 

1696 """ 

1697 

1698 ConfigClass = SigmaDiaPsfFluxConfig 

1699 # Output columns are created upon instantiation of the class. 

1700 outputCols = ["scienceFluxSigma"] 

1701 plugType = "multi" 

1702 needsFilter = True 

1703 

1704 @classmethod 

1705 def getExecutionOrder(cls): 

1706 return cls.DEFAULT_CATALOGCALCULATION 

1707 

1708 def calculate(self, 

1709 diaObjects, 

1710 diaSources, 

1711 filterDiaSources, 

1712 band, 

1713 **kwargs): 

1714 """Compute the sigma fluxes of the point source flux measured on the 

1715 calibrated image. 

1716 

1717 Parameters 

1718 ---------- 

1719 diaObject : `dict` 

1720 Summary object to store values in. 

1721 diaSources : `pandas.DataFrame` 

1722 DataFrame representing all diaSources associated with this 

1723 diaObject. 

1724 filterDiaSources : `pandas.DataFrame` 

1725 DataFrame representing diaSources associated with this 

1726 diaObject that are observed in the band pass ``band``. 

1727 band : `str` 

1728 Simple, string name of the filter for the flux being calculated. 

1729 **kwargs 

1730 Any additional keyword arguments that may be passed to the plugin. 

1731 """ 

1732 # Set "delta degrees of freedom (ddf)" to 1 to calculate the unbiased 

1733 # estimator of scatter (i.e. 'N - 1' instead of 'N'). 

1734 column = "{}_scienceFluxSigma".format(band) 

1735 typeSafePandasAssignment(diaObjects, filterDiaSources.scienceFlux.std(), [column])