Coverage for python/lsst/meas/base/diaCalculationPlugins.py: 92%
602 statements
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 08:46 +0000
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 08:46 +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/>.
22"""Plugins for use in DiaSource summary statistics.
24Output columns must be
25as defined in the schema of the Apdb both in name and units.
26"""
28import functools
29import warnings
31from astropy.stats import median_absolute_deviation
32import numpy as np
33import pandas as pd
34from scipy.optimize import lsq_linear
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
45from .diaCalculation import (
46 DiaObjectCalculationPluginConfig,
47 DiaObjectCalculationPlugin)
48from .pluginRegistry import register
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")
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
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)
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.
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]
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
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.
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)
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.
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.
165 Returns
166 -------
167 frequencies : `array`
168 The computed optimized periodogram frequency grid.
169 """
171 num_points = len(x0)
172 baseline = np.max(x0) - np.min(x0)
174 # Calculate the frequency resolution based on oversampling factor and baseline
175 frequency_resolution = 1. / baseline / oversampling_factor
177 num_frequencies = int(
178 0.5 * oversampling_factor * nyquist_factor * num_points)
179 frequencies = frequency_resolution + \
180 frequency_resolution * np.arange(num_frequencies)
182 return frequencies
185class LombScarglePeriodogramConfig(DiaObjectCalculationPluginConfig):
186 pass
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
195 plugType = "multi"
196 outputCols = ["period", "power"]
197 needsFilter = True
199 @classmethod
200 def getExecutionOrder(cls):
201 return cls.DEFAULT_CATALOGCALCULATION
203 @catchWarnings(warns=["All-NaN slice encountered"])
204 def calculate(self,
205 diaObjects,
206 diaSources,
207 filterDiaSources,
208 band):
209 """Compute the periodogram.
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 """
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
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.
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.
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"]))]
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})
252 time = tmpDf["midpointMjdTai"].to_numpy()
253 flux = tmpDf["psfFlux"].to_numpy()
254 flux_err = tmpDf["psfFluxErr"].to_numpy()
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)
262 return pd.Series({periodCol: period[np.argmax(power)],
263 powerCol: np.max(power)})
265 diaObjects.loc[:, [periodCol, powerCol]
266 ] = filterDiaSources.apply(_calculate_period)
269class LombScarglePeriodogramMultiConfig(DiaObjectCalculationPluginConfig):
270 pass
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
279 plugType = "multi"
280 outputCols = ["multiPeriod", "multiPower",
281 "multiFap", "multiAmp", "multiPhase"]
282 needsFilter = True
284 @classmethod
285 def getExecutionOrder(cls):
286 return cls.DEFAULT_CATALOGCALCULATION
288 @staticmethod
289 def calculate_baluev_fap(time, n, maxPeriod, zmax):
290 """Calculate the False-Alarm probability using the Baluev approximation.
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.
303 Returns
304 -------
305 fap_estimate : `float`
306 The False-Alarm probability Baluev approximation.
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
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)
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.
334 Returns
335 -------
336 Amp : `array`
337 The amplitude of the time series.
338 Ph : `array`
339 The phase of the time series.
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)
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)]
350 df_params = pd.DataFrame([best_params], columns=name_params)
352 unique_bands = np.unique(bands)
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]
360 amp = [a[0] for a in amplitude_band]
361 ph = [p[0] for p in phase_bands]
363 return amp, ph
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.
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 """
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
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.
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.
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"]))]
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
435 return pd_tab_nodet
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()
442 lsp = LombScargleMultiband(time, flux, bands, dy=flux_err,
443 nterms_base=1, nterms_band=1)
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)
450 fap_estimate = self.calculate_baluev_fap(
451 time, len(time), period[np.argmax(power)], np.max(power))
453 params_table_new = self.generate_lsp_params(lsp, f_grid[np.argmax(power)], bands)
455 pd_tab = pd.Series({periodCol: period[np.argmax(power)],
456 powerCol: np.max(power),
457 fapCol: fap_estimate
458 })
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
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]
471 return pd_tab
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}")
478 diaObjects.loc[:, columns_list
479 ] = diaSources.apply(_calculate_period_multi, unique_bands)
482class UnphysicalDiaSourceSeparation(pipeBase.AlgorithmError):
483 """Raised if associated DiaSources are unphysically separated.
485 Parameters
486 ----------
487 separation : `float`
488 Observed separation in arseconds.
489 max_allowed_separation : `float`
490 Configured maximum separation in arcseconds.
491 """
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}")
499 @property
500 def metadata(self) -> dict:
501 return {
502 "separation": self._separation,
503 "max_allowed_separation": self._max_allowed_separation,
504 }
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 )
516@register("ap_meanPosition")
517class MeanDiaPosition(DiaObjectCalculationPlugin):
518 """Compute the mean position and position uncertainty of a DiaObject
519 from its associated DiaSources.
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,
526 C_formal = (sum_i inv(C_i))^-1,
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:
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
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 """
542 ConfigClass = MeanDiaPositionConfig
544 plugType = 'multi'
546 outputCols = ["ra", "dec", "raErr", "decErr", "ra_dec_Cov"]
547 needsFilter = False
549 @classmethod
550 def getExecutionOrder(cls):
551 return cls.DEFAULT_CATALOGCALCULATION
553 def calculate(self, diaObjects, diaSources, **kwargs):
554 """Compute the mean ra/dec position and its uncertainty for each
555 DiaObject.
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
570 maxAllowedSep = self.config.MaxAllowedDiaSourceSeparation
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)
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)
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())
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))
601 aveCoord = unweightedAvg
602 varRa = np.nan
603 varDec = np.nan
604 raDecCovObj = np.nan
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())
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)
633 W_sum = W.sum(axis=0)
634 C_formal = np.linalg.inv(W_sum)
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)
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
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.
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
695 return pd.Series({"ra": aveCoord.getRa().asDegrees(),
696 "dec": aveCoord.getDec().asDegrees(),
697 "raErr": raErrObj,
698 "decErr": decErrObj,
699 "ra_dec_Cov": raDecCovObj})
701 ans = diaSources.apply(_computeMeanPos)
702 typeSafePandasAssignment(diaObjects, ans, ["ra", "dec", "raErr", "decErr", "ra_dec_Cov"])
705class HTMIndexDiaPositionConfig(DiaObjectCalculationPluginConfig):
707 htmLevel = pexConfig.Field(
708 dtype=int,
709 doc="Level of the HTM pixelization.",
710 default=20,
711 )
714@register("ap_HTMIndex")
715class HTMIndexDiaPosition(DiaObjectCalculationPlugin):
716 """Compute the mean position of a DiaObject given a set of DiaSources.
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
727 plugType = 'single'
729 inputCols = ["ra", "dec"]
730 outputCols = ["pixelId"]
731 needsFilter = False
733 def __init__(self, config, name, metadata):
734 DiaObjectCalculationPlugin.__init__(self, config, name, metadata)
735 self.pixelator = sphgeom.HtmPixelization(self.config.htmLevel)
737 @classmethod
738 def getExecutionOrder(cls):
739 return cls.FLUX_MOMENTS_CALCULATED
741 def calculate(self, diaObjects, diaObjectId, **kwargs):
742 """Compute the mean position of a DiaObject given a set of DiaSources
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())
760class NumDiaSourcesDiaPluginConfig(DiaObjectCalculationPluginConfig):
761 pass
764@register("ap_nDiaSources")
765class NumDiaSourcesDiaPlugin(DiaObjectCalculationPlugin):
766 """Compute the total number of DiaSources associated with this DiaObject.
767 """
769 ConfigClass = NumDiaSourcesDiaPluginConfig
770 outputCols = ["nDiaSources"]
771 plugType = "multi"
772 needsFilter = False
774 @classmethod
775 def getExecutionOrder(cls):
776 return cls.DEFAULT_CATALOGCALCULATION
778 def calculate(self, diaObjects, diaSources, **kwargs):
779 """Compute the total number of DiaSources associated with this DiaObject.
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"])
791class SimpleSourceFlagDiaPluginConfig(DiaObjectCalculationPluginConfig):
792 pass
795@register("ap_diaObjectFlag")
796class SimpleSourceFlagDiaPlugin(DiaObjectCalculationPlugin):
797 """Find if any DiaSource is flagged.
799 Set the DiaObject flag if any DiaSource is flagged.
800 """
802 ConfigClass = NumDiaSourcesDiaPluginConfig
803 outputCols = ["flags"]
804 plugType = "multi"
805 needsFilter = False
807 @classmethod
808 def getExecutionOrder(cls):
809 return cls.DEFAULT_CATALOGCALCULATION
811 def calculate(self, diaObjects, diaSources, **kwargs):
812 """Find if any DiaSource is flagged.
814 Set the DiaObject flag if any DiaSource is flagged.
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)
826class WeightedMeanDiaPsfFluxConfig(DiaObjectCalculationPluginConfig):
827 pass
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.
835 Additionally store number of usable data points.
836 """
838 ConfigClass = WeightedMeanDiaPsfFluxConfig
839 outputCols = ["psfFluxMean", "psfFluxMeanErr", "psfFluxNdata"]
840 plugType = "multi"
841 needsFilter = True
843 @classmethod
844 def getExecutionOrder(cls):
845 return cls.DEFAULT_CATALOGCALCULATION
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.
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
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)
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)
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 )
913@register("ap_percentileFlux")
914class PercentileDiaPsfFlux(DiaObjectCalculationPlugin):
915 """Compute percentiles of diaSource fluxes.
916 """
918 ConfigClass = PercentileDiaPsfFluxConfig
919 # Output columns are created upon instantiation of the class.
920 outputCols = []
921 plugType = "multi"
922 needsFilter = True
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]
933 @classmethod
934 def getExecutionOrder(cls):
935 return cls.DEFAULT_CATALOGCALCULATION
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.
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
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)))
975 typeSafePandasAssignment(
976 diaObjects,
977 filterDiaSources.apply(_fluxPercentiles),
978 pTileNames,
979 )
982class SigmaDiaPsfFluxConfig(DiaObjectCalculationPluginConfig):
983 pass
986@register("ap_sigmaFlux")
987class SigmaDiaPsfFlux(DiaObjectCalculationPlugin):
988 """Compute scatter of diaSource fluxes.
989 """
991 ConfigClass = SigmaDiaPsfFluxConfig
992 # Output columns are created upon instantiation of the class.
993 outputCols = ["psfFluxSigma"]
994 plugType = "multi"
995 needsFilter = True
997 @classmethod
998 def getExecutionOrder(cls):
999 return cls.DEFAULT_CATALOGCALCULATION
1001 def calculate(self,
1002 diaObjects,
1003 diaSources,
1004 filterDiaSources,
1005 band,
1006 **kwargs):
1007 """Compute the sigma fluxes of the point source flux.
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)
1028 typeSafePandasAssignment(
1029 diaObjects,
1030 filterDiaSources.psfFlux.std(),
1031 [column],
1032 )
1035class Chi2DiaPsfFluxConfig(DiaObjectCalculationPluginConfig):
1036 pass
1039@register("ap_chi2Flux")
1040class Chi2DiaPsfFlux(DiaObjectCalculationPlugin):
1041 """Compute chi2 of diaSource fluxes.
1042 """
1044 ConfigClass = Chi2DiaPsfFluxConfig
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
1053 @classmethod
1054 def getExecutionOrder(cls):
1055 return cls.FLUX_MOMENTS_CALCULATED
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.
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)
1084 def _chi2(df):
1085 delta = (df["psfFlux"]
1086 - diaObjects.at[df.diaObjectId.iat[0], meanName])
1087 return np.nansum((delta / df["psfFluxErr"]) ** 2)
1089 typeSafePandasAssignment(
1090 diaObjects,
1091 filterDiaSources.apply(_chi2),
1092 [column],
1093 )
1096class MadDiaPsfFluxConfig(DiaObjectCalculationPluginConfig):
1097 pass
1100@register("ap_madFlux")
1101class MadDiaPsfFlux(DiaObjectCalculationPlugin):
1102 """Compute median absolute deviation of diaSource fluxes.
1103 """
1105 ConfigClass = MadDiaPsfFluxConfig
1107 # Required input Cols
1108 # Output columns are created upon instantiation of the class.
1109 outputCols = ["psfFluxMAD"]
1110 plugType = "multi"
1111 needsFilter = True
1113 @classmethod
1114 def getExecutionOrder(cls):
1115 return cls.DEFAULT_CATALOGCALCULATION
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.
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)
1143 typeSafePandasAssignment(
1144 diaObjects,
1145 filterDiaSources.psfFlux.apply(median_absolute_deviation, ignore_nan=True),
1146 [column],
1147 )
1150class SkewDiaPsfFluxConfig(DiaObjectCalculationPluginConfig):
1151 pass
1154@register("ap_skewFlux")
1155class SkewDiaPsfFlux(DiaObjectCalculationPlugin):
1156 """Compute the skew of diaSource fluxes.
1157 """
1159 ConfigClass = SkewDiaPsfFluxConfig
1161 # Required input Cols
1162 # Output columns are created upon instantiation of the class.
1163 outputCols = ["psfFluxSkew"]
1164 plugType = "multi"
1165 needsFilter = True
1167 @classmethod
1168 def getExecutionOrder(cls):
1169 return cls.DEFAULT_CATALOGCALCULATION
1171 def calculate(self,
1172 diaObjects,
1173 diaSources,
1174 filterDiaSources,
1175 band,
1176 **kwargs):
1177 """Compute the skew of the point source fluxes.
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)
1196 typeSafePandasAssignment(
1197 diaObjects,
1198 filterDiaSources.psfFlux.skew(),
1199 [column],
1200 )
1203class MinMaxDiaPsfFluxConfig(DiaObjectCalculationPluginConfig):
1204 pass
1207@register("ap_minMaxFlux")
1208class MinMaxDiaPsfFlux(DiaObjectCalculationPlugin):
1209 """Compute min/max of diaSource fluxes.
1210 """
1212 ConfigClass = MinMaxDiaPsfFluxConfig
1214 # Required input Cols
1215 # Output columns are created upon instantiation of the class.
1216 outputCols = ["psfFluxMin", "psfFluxMax"]
1217 plugType = "multi"
1218 needsFilter = True
1220 @classmethod
1221 def getExecutionOrder(cls):
1222 return cls.DEFAULT_CATALOGCALCULATION
1224 def calculate(self,
1225 diaObjects,
1226 diaSources,
1227 filterDiaSources,
1228 band,
1229 **kwargs):
1230 """Compute min/max of the point source fluxes.
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
1254 typeSafePandasAssignment(
1255 diaObjects,
1256 filterDiaSources.psfFlux.min(),
1257 [minName],
1258 )
1259 typeSafePandasAssignment(
1260 diaObjects,
1261 filterDiaSources.psfFlux.max(),
1262 [maxName],
1263 )
1266class MaxSlopeDiaPsfFluxConfig(DiaObjectCalculationPluginConfig):
1267 pass
1270@register("ap_maxSlopeFlux")
1271class MaxSlopeDiaPsfFlux(DiaObjectCalculationPlugin):
1272 """Compute the maximum ratio time ordered deltaFlux / deltaTime.
1273 """
1275 ConfigClass = MinMaxDiaPsfFluxConfig
1277 # Required input Cols
1278 # Output columns are created upon instantiation of the class.
1279 outputCols = ["psfFluxMaxSlope"]
1280 plugType = "multi"
1281 needsFilter = True
1283 @classmethod
1284 def getExecutionOrder(cls):
1285 return cls.DEFAULT_CATALOGCALCULATION
1287 def calculate(self,
1288 diaObjects,
1289 diaSources,
1290 filterDiaSources,
1291 band,
1292 **kwargs):
1293 """Compute the maximum ratio time ordered deltaFlux / deltaTime.
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 """
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()
1322 column = "{}_psfFluxMaxSlope".format(band)
1324 typeSafePandasAssignment(
1325 diaObjects,
1326 filterDiaSources.apply(_maxSlope),
1327 [column],
1328 )
1331class ErrMeanDiaPsfFluxConfig(DiaObjectCalculationPluginConfig):
1332 pass
1335@register("ap_meanErrFlux")
1336class ErrMeanDiaPsfFlux(DiaObjectCalculationPlugin):
1337 """Compute the mean of the dia source errors.
1338 """
1340 ConfigClass = ErrMeanDiaPsfFluxConfig
1342 # Required input Cols
1343 # Output columns are created upon instantiation of the class.
1344 outputCols = ["psfFluxErrMean"]
1345 plugType = "multi"
1346 needsFilter = True
1348 @classmethod
1349 def getExecutionOrder(cls):
1350 return cls.DEFAULT_CATALOGCALCULATION
1352 def calculate(self,
1353 diaObjects,
1354 diaSources,
1355 filterDiaSources,
1356 band,
1357 **kwargs):
1358 """Compute the mean of the dia source errors.
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)
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 )
1386class LinearFitDiaPsfFluxConfig(DiaObjectCalculationPluginConfig):
1387 pass
1390@register("ap_linearFit")
1391class LinearFitDiaPsfFlux(DiaObjectCalculationPlugin):
1392 """Compute fit a linear model to flux vs time.
1393 """
1395 ConfigClass = LinearFitDiaPsfFluxConfig
1397 # Required input Cols
1398 # Output columns are created upon instantiation of the class.
1399 outputCols = ["psfFluxLinearSlope", "psfFluxLinearIntercept"]
1400 plugType = "multi"
1401 needsFilter = True
1403 @classmethod
1404 def getExecutionOrder(cls):
1405 return cls.DEFAULT_CATALOGCALCULATION
1407 def calculate(self,
1408 diaObjects,
1409 diaSources,
1410 filterDiaSources,
1411 band,
1412 **kwargs):
1413 """Compute fit a linear model to flux vs time.
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 """
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
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)
1453 typeSafePandasAssignment(
1454 diaObjects,
1455 filterDiaSources.apply(_linearFit),
1456 [mName, bName],
1457 )
1460class StetsonJDiaPsfFluxConfig(DiaObjectCalculationPluginConfig):
1461 pass
1464@register("ap_stetsonJ")
1465class StetsonJDiaPsfFlux(DiaObjectCalculationPlugin):
1466 """Compute the StetsonJ statistic on the DIA point source fluxes.
1467 """
1469 ConfigClass = LinearFitDiaPsfFluxConfig
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
1478 @classmethod
1479 def getExecutionOrder(cls):
1480 return cls.FLUX_MOMENTS_CALCULATED
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.
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)
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()
1515 return self._stetson_J(
1516 fluxes,
1517 errors,
1518 diaObjects.at[tmpDf.diaObjectId.iat[0], meanName])
1520 column = "{}_psfFluxStetsonJ".format(band)
1521 typeSafePandasAssignment(
1522 diaObjects,
1523 filterDiaSources.apply(_stetsonJ),
1524 [column],
1525 )
1527 def _stetson_J(self, fluxes, errors, mean=None):
1528 """Compute the single band stetsonJ statistic.
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.
1539 Returns
1540 -------
1541 stetsonJ : `float`
1542 stetsonJ statistic for the input fluxes and errors.
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
1555 return np.mean(np.sign(p_k) * np.sqrt(np.fabs(p_k)))
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.
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.
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)
1592 Returns
1593 -------
1594 mean : `float`
1595 Weighted stetson mean result.
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
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
1620class WeightedMeanDiaTotFluxConfig(DiaObjectCalculationPluginConfig):
1621 pass
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.
1629 Additionally store number of usable data points.
1630 """
1632 ConfigClass = WeightedMeanDiaPsfFluxConfig
1633 outputCols = ["scienceFluxMean", "scienceFluxMeanErr"]
1634 plugType = "multi"
1635 needsFilter = True
1637 @classmethod
1638 def getExecutionOrder(cls):
1639 return cls.DEFAULT_CATALOGCALCULATION
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.
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
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)
1682 return pd.Series({totMeanName: fluxMean,
1683 totErrName: fluxMeanErr})
1685 df = filterDiaSources.apply(_meanFlux).astype(diaObjects.dtypes[[totMeanName, totErrName]])
1686 typeSafePandasAssignment(diaObjects, df, [totMeanName, totErrName])
1689class SigmaDiaTotFluxConfig(DiaObjectCalculationPluginConfig):
1690 pass
1693@register("ap_sigmaTotFlux")
1694class SigmaDiaTotFlux(DiaObjectCalculationPlugin):
1695 """Compute scatter of diaSource fluxes.
1696 """
1698 ConfigClass = SigmaDiaPsfFluxConfig
1699 # Output columns are created upon instantiation of the class.
1700 outputCols = ["scienceFluxSigma"]
1701 plugType = "multi"
1702 needsFilter = True
1704 @classmethod
1705 def getExecutionOrder(cls):
1706 return cls.DEFAULT_CATALOGCALCULATION
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.
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])