Source code for lightkurve.periodogram

"""Defines the Periodogram class and associated tools."""
from __future__ import division, print_function

import copy
import logging
import math
import re
import warnings

import numpy as np
from matplotlib import pyplot as plt

import astropy
from astropy.table import Table
from astropy import units as u
from astropy.units import cds
from astropy.convolution import convolve, Box1DKernel
from astropy.time import Time

from astropy.timeseries import LombScargle
from astropy.timeseries.periodograms.lombscargle import implementations  # for .main._is_regular


from . import MPLSTYLE
from .utils import LightkurveWarning, validate_method
from .lightcurve import LightCurve

log = logging.getLogger(__name__)

__all__ = ["Periodogram", "LombScarglePeriodogram", "BoxLeastSquaresPeriodogram"]


[docs]class Periodogram(object): """Generic class to represent a power spectrum (frequency vs power data). The Periodogram class represents a power spectrum, with values of frequency on the x-axis (in any frequency units) and values of power on the y-axis (in units of flux^2 / [frequency units]). Attributes ---------- frequency : `~astropy.units.Quantity` Array of frequencies as an AstroPy Quantity object. power : `~astropy.units.Quantity` Array of power-spectral-densities. The Quantity must have units of `flux^2 / freq_unit`, where freq_unit is the unit of the frequency attribute. nyquist : float The Nyquist frequency of the lightcurve. In units of freq_unit, where freq_unit is the unit of the frequency attribute. label : str Human-friendly object label, e.g. "KIC 123456789". targetid : str Identifier of the target. default_view : "frequency" or "period" Should plots be shown in frequency space or period space by default? meta : dict Free-form metadata associated with the Periodogram. """ frequency = None """The array of frequency values.""" power = None """The array of power values."""
[docs] def __init__( self, frequency, power, nyquist=None, label=None, targetid=None, default_view="frequency", meta={}, ): # Input validation if not isinstance(frequency, u.quantity.Quantity): raise ValueError("frequency must be an `astropy.units.Quantity` object.") if not isinstance(power, u.quantity.Quantity): raise ValueError("power must be an `astropy.units.Quantity` object.") # Frequency must have frequency units try: frequency.to(u.Hz) except u.UnitConversionError: raise ValueError("Frequency must be in units of 1/time.") # Frequency and power must have sensible shapes if frequency.shape[0] <= 1: raise ValueError("frequency and power must have a length greater than 1.") if frequency.shape != power.shape: raise ValueError("frequency and power must have the same length.") self.frequency = frequency self.power = power self.nyquist = nyquist self.label = label self.targetid = targetid self.default_view = self._validate_view(default_view) self.meta = meta
def _validate_view(self, view): """Verifies whether `view` is is one of {"frequency", "period"} and raises a helpful `ValueError` if not. """ if view is None and hasattr(self, "default_view"): view = self.default_view return validate_method(view, ["frequency", "period"]) def _is_evenly_spaced(self): """Returns true if the values in ``frequency`` are evenly spaced. This helper method exists because some features, such as ``smooth()``, ``estimate_numax()``, and ``estimate_deltanu()``, require a grid of evenly-spaced frequencies. """ # verify that the first differences are all equal freqdiff = np.diff(self.frequency.value) if np.allclose(freqdiff[0], freqdiff): return True return False @property def period(self): """The array of periods, i.e. 1/frequency.""" return 1.0 / self.frequency @property def max_power(self): """Power of the highest peak in the periodogram.""" return np.nanmax(self.power) @property def frequency_at_max_power(self): """Frequency value corresponding to the highest peak in the periodogram.""" return self.frequency[np.nanargmax(self.power)] @property def period_at_max_power(self): """Period value corresponding to the highest peak in the periodogram.""" return 1.0 / self.frequency_at_max_power
[docs] def bin(self, binsize=10, method="mean"): """Bins the power spectrum. Parameters ---------- binsize : int The factor by which to bin the power spectrum, in the sense that the power spectrum will be smoothed by taking the mean in bins of size N / binsize, where N is the length of the original frequency array. Defaults to 10. method : str, one of 'mean' or 'median' Method to use for binning. Default is 'mean'. Returns ------- binned_periodogram : a `Periodogram` object Returns a new `Periodogram` object which has been binned. """ # Input validation if binsize < 1: raise ValueError("binsize must be larger than or equal to 1") method = validate_method(method, ["mean", "median"]) m = int(len(self.power) / binsize) # length of the binned arrays if method == "mean": binned_freq = self.frequency[: m * binsize].reshape((m, binsize)).mean(1) binned_power = self.power[: m * binsize].reshape((m, binsize)).mean(1) elif method == "median": binned_freq = np.nanmedian( self.frequency[: m * binsize].reshape((m, binsize)), axis=1 ) binned_power = np.nanmedian( self.power[: m * binsize].reshape((m, binsize)), axis=1 ) binned_pg = self.copy() binned_pg.frequency = binned_freq binned_pg.power = binned_power return binned_pg
[docs] def smooth(self, method="boxkernel", filter_width=0.1): """Smooths the power spectrum using the 'boxkernel' or 'logmedian' method. If `method` is set to 'boxkernel', this method will smooth the power spectrum by convolving with a numpy Box1DKernel with a width of `filter_width`, where `filter width` is in units of frequency. This is best for filtering out noise while maintaining seismic mode peaks. This method requires the Periodogram to have an evenly spaced grid of frequencies. A `ValueError` exception will be raised if this is not the case. If `method` is set to 'logmedian', it smooths the power spectrum using a moving median which moves across the power spectrum in a steps of log10(x0) + 0.5 * filter_width where `filter width` is in log10(frequency) space. This is best for estimating the noise background, as it filters over the seismic peaks. Periodograms that are unsmoothed have multiplicative noise that is distributed as chi squared 2 degrees of freedom. This noise distribution has a well defined mean and median but the two are not equivalent. The mean of a chi squared 2 dof distribution is 2, but the median is 2(8/9)**3. (see https://en.wikipedia.org/wiki/Chi-squared_distribution) In order to maintain consistency between 'boxkernel' and 'logmedian' a correction factor of (8/9)**3 is applied to (i.e., the median is divided by the factor) to the median values. In addition to consistency with the 'boxkernel' method, the correction of the median values is useful when applying the periodogram flatten method. The flatten method divides the periodgram by the smoothed periodogram using the 'logmedian' method. By appyling the correction factor we follow asteroseismic convention that the signal-to-noise power has a mean value of unity. (note the signal-to-noise power is really the signal plus noise divided by the noise and hence should be unity in the absence of any signal) Parameters ---------- method : str, one of 'boxkernel' or 'logmedian' The smoothing method to use. Defaults to 'boxkernel'. filter_width : float If `method` = 'boxkernel', this is the width of the smoothing filter in units of frequency. If method = `logmedian`, this is the width of the smoothing filter in log10(frequency) space. Returns ------- smoothed_pg : `Periodogram` object Returns a new `Periodogram` object in which the power spectrum has been smoothed. """ method = validate_method(method, ["boxkernel", "logmedian"]) if method == "boxkernel": if filter_width <= 0.0: raise ValueError( "the `filter_width` parameter must be " "larger than 0 for the 'boxkernel' method." ) try: filter_width = u.Quantity(filter_width, self.frequency.unit) except u.UnitConversionError: raise ValueError( "the `filter_width` parameter must have " "frequency units." ) # Check to see if we have a grid of evenly spaced periods instead. if not self._is_evenly_spaced(): raise ValueError( "the 'boxkernel' method requires the periodogram " "to have a grid of evenly spaced frequencies." ) fs = np.mean(np.diff(self.frequency)) box_kernel = Box1DKernel(math.ceil((filter_width / fs).value)) smooth_power = convolve(self.power.value, box_kernel) smooth_pg = self.copy() smooth_pg.power = u.Quantity(smooth_power, self.power.unit) return smooth_pg if method == "logmedian": if isinstance(filter_width, astropy.units.quantity.Quantity): raise ValueError( "the 'logmedian' method requires a dimensionless " "value for `filter_width` in log10(frequency) space." ) count = np.zeros(len(self.frequency.value), dtype=int) bkg = np.zeros_like(self.frequency.value) x0 = np.log10(self.frequency[0].value) corr_factor = (8.0 / 9.0) ** 3 while x0 < np.log10(self.frequency[-1].value): m = np.abs(np.log10(self.frequency.value) - x0) < filter_width if len(bkg[m] > 0): bkg[m] += np.nanmedian(self.power[m].value) / corr_factor count[m] += 1 x0 += 0.5 * filter_width bkg /= count smooth_pg = self.copy() smooth_pg.power = u.Quantity(bkg, self.power.unit) return smooth_pg
[docs] def plot( self, scale="linear", ax=None, xlabel=None, ylabel=None, title="", style="lightkurve", view=None, unit=None, **kwargs ): """Plots the Periodogram. Parameters ---------- scale: str Set x,y axis to be "linear" or "log". Default is linear. ax : `~matplotlib.axes.Axes` A matplotlib axes object to plot into. If no axes is provided, a new one will be generated. xlabel : str Plot x axis label ylabel : str Plot y axis label title : str Plot set_title style : str Path or URL to a matplotlib style file, or name of one of matplotlib's built-in stylesheets (e.g. 'ggplot'). Lightkurve's custom stylesheet is used by default. view : str {'frequency', 'period'}. Default 'frequency'. If 'frequency', x-axis units will be frequency. If 'period', the x-axis units will be period and 'log' scale. kwargs : dict Dictionary of arguments to be passed to `matplotlib.pyplot.plot`. Returns ------- ax : `~matplotlib.axes.Axes` The matplotlib axes object. """ if isinstance(unit, u.quantity.Quantity): unit = unit.unit view = self._validate_view(view) if unit is None: unit = self.frequency.unit if view == "period": unit = self.period.unit if style is None or style == "lightkurve": style = MPLSTYLE if ylabel is None: ylabel = "Power" if self.power.unit.to_string() != "": unit_label = self.power.unit.to_string("latex") # The line below is a workaround for AstroPy bug #9218. # It can be removed once the fix for that issue is widespread. # See https://github.com/astropy/astropy/pull/9218 unit_label = re.sub( r"\^{([^}]+)}\^{([^}]+)}", r"^{\g<1>^{\g<2>}}", unit_label ) ylabel += " [{}]".format(unit_label) # This will need to be fixed with housekeeping. Self.label currently doesnt exist. if ("label" not in kwargs) and ("label" in dir(self)): kwargs["label"] = self.label with plt.style.context(style): if ax is None: fig, ax = plt.subplots() # Plot frequency and power if view == "frequency": ax.plot(self.frequency.to(unit), self.power, **kwargs) if xlabel is None: xlabel = "Frequency [{}]".format(unit.to_string("latex")) elif view == "period": ax.plot(self.period.to(unit), self.power, **kwargs) if xlabel is None: xlabel = "Period [{}]".format(unit.to_string("latex")) ax.set_xlabel(xlabel) ax.set_ylabel(ylabel) # Show the legend if labels were set legend_labels = ax.get_legend_handles_labels() if np.sum([len(a) for a in legend_labels]) != 0: ax.legend(loc="best") ax.set_yscale(scale) ax.set_xscale(scale) ax.set_title(title) return ax
[docs] def flatten(self, method="logmedian", filter_width=0.01, return_trend=False): """Estimates the Signal-To-Noise (SNR) spectrum by dividing out an estimate of the noise background. This method divides the power spectrum by a background estimated using a moving filter in log10 space by default. For details on the `method` and `filter_width` parameters, see `Periodogram.smooth()` Dividing the power through by the noise background produces a spectrum with no units of power. Since the signal is divided through by a measure of the noise, we refer to this as a `Signal-To-Noise` spectrum. Parameters ---------- method : str, one of 'boxkernel' or 'logmedian' Background estimation method passed on to `Periodogram.smooth()`. Defaults to 'logmedian'. filter_width : float If `method` = 'boxkernel', this is the width of the smoothing filter in units of frequency. If method = `logmedian`, this is the width of the smoothing filter in log10(frequency) space. return_trend : bool If True, then the background estimate, alongside the SNR spectrum, will be returned. Returns ------- snr_spectrum : `Periodogram` object Returns a periodogram object where the power is an estimate of the signal-to-noise of the spectrum, creating by dividing the powers with a simple estimate of the noise background using a smoothing filter. bkg : `Periodogram` object The estimated power spectrum of the background noise. This is only returned if `return_trend = True`. """ bkg = self.smooth(method=method, filter_width=filter_width) snr_pg = self / bkg.power snr = SNRPeriodogram( snr_pg.frequency, snr_pg.power, nyquist=self.nyquist, targetid=self.targetid, label=self.label, meta=self.meta, ) if return_trend: return snr, bkg return snr
[docs] def to_table(self): """Exports the Periodogram as an Astropy Table. Returns ------- table : `~astropy.table.Table` object An AstroPy Table with columns 'frequency', 'period', and 'power'. """ return Table( data=(self.frequency, self.period, self.power), names=("frequency", "period", "power"), meta=self.meta, )
[docs] def copy(self): """Returns a copy of the Periodogram object. This method uses the `copy.deepcopy` function to ensure that all objects stored within the Periodogram are copied. Returns ------- pg_copy : Periodogram A new `Periodogram` object which is a copy of the original. """ return copy.deepcopy(self)
def __repr__(self): return "Periodogram(ID: {})".format(self.label) def __getitem__(self, key): copy_self = self.copy() copy_self.frequency = self.frequency[key] copy_self.power = self.power[key] return copy_self def __add__(self, other): copy_self = self.copy() copy_self.power = copy_self.power + u.Quantity(other, self.power.unit) return copy_self def __radd__(self, other): return self.__add__(other) def __sub__(self, other): return self.__add__(-other) def __rsub__(self, other): copy_self = self.copy() copy_self.power = other - copy_self.power return copy_self def __mul__(self, other): copy_self = self.copy() copy_self.power = other * copy_self.power return copy_self def __rmul__(self, other): return self.__mul__(other) def __truediv__(self, other): return self.__mul__(1.0 / other) def __rtruediv__(self, other): copy_self = self.copy() copy_self.power = other / copy_self.power return copy_self def __div__(self, other): return self.__truediv__(other) def __rdiv__(self, other): return self.__rtruediv__(other)
[docs] def show_properties(self): """Prints a summary of the non-callable attributes of the Periodogram object. Prints in order of type (ints, strings, lists, arrays and others). Prints in alphabetical order. """ attrs = {} for attr in dir(self): if not attr.startswith("_"): res = getattr(self, attr) if callable(res): continue if isinstance(res, astropy.units.quantity.Quantity): unit = res.unit res = res.value attrs[attr] = {"res": res} attrs[attr]["unit"] = unit.to_string() else: attrs[attr] = {"res": res} attrs[attr]["unit"] = "" if attr == "hdu": attrs[attr] = {"res": res, "type": "list"} for idx, r in enumerate(res): if idx == 0: attrs[attr]["print"] = "{}".format(r.header["EXTNAME"]) else: attrs[attr]["print"] = "{}, {}".format( attrs[attr]["print"], "{}".format(r.header["EXTNAME"]) ) continue if isinstance(res, int): attrs[attr]["print"] = "{}".format(res) attrs[attr]["type"] = "int" elif isinstance(res, float): attrs[attr]["print"] = "{}".format(np.round(res, 4)) attrs[attr]["type"] = "float" elif isinstance(res, np.ndarray): attrs[attr]["print"] = "array {}".format(res.shape) attrs[attr]["type"] = "array" elif isinstance(res, list): attrs[attr]["print"] = "list length {}".format(len(res)) attrs[attr]["type"] = "list" elif isinstance(res, str): if res == "": attrs[attr]["print"] = "{}".format("None") else: attrs[attr]["print"] = "{}".format(res) attrs[attr]["type"] = "str" elif attr == "wcs": attrs[attr]["print"] = "astropy.wcs.wcs.WCS" attrs[attr]["type"] = "other" else: attrs[attr]["print"] = "{}".format(type(res)) attrs[attr]["type"] = "other" output = Table( names=["Attribute", "Description", "Units"], dtype=[object, object, object] ) idx = 0 types = ["int", "str", "float", "list", "array", "other"] for typ in types: for attr, dic in attrs.items(): if dic["type"] == typ: output.add_row([attr, dic["print"], dic["unit"]]) idx += 1 print("lightkurve.Periodogram properties:") output.pprint(max_lines=-1, max_width=-1)
[docs] def to_seismology(self, **kwargs): """Returns a `~lightkurve.seismology.Seismology` object to analyze the periodogram. Returns ------- seismology : `~lightkurve.seismology.Seismology` Helper object to run asteroseismology methods. """ from .seismology import Seismology return Seismology(self)
class SNRPeriodogram(Periodogram): """Defines a Signal-to-Noise Ratio (SNR) Periodogram class. This class is nearly identical to the standard :class:`Periodogram` class, but has different plotting defaults. """ def __init__(self, *args, **kwargs): super(SNRPeriodogram, self).__init__(*args, **kwargs) def __repr__(self): return "SNRPeriodogram(ID: {})".format(self.label) def plot(self, **kwargs): """Plot the SNR spectrum using matplotlib's `plot` method. See `Periodogram.plot` for details on the accepted arguments. Parameters ---------- kwargs : dict Dictionary of arguments ot be passed to `Periodogram.plot`. Returns ------- ax : `~matplotlib.axes.Axes` The matplotlib axes object. """ ax = super(SNRPeriodogram, self).plot(**kwargs) if "ylabel" not in kwargs: ax.set_ylabel("Signal to Noise Ratio (SNR)") return ax class LombScarglePeriodogram(Periodogram): """Subclass of :class:`Periodogram <lightkurve.periodogram.Periodogram>` representing a power spectrum generated using the Lomb Scargle method. """ def __init__(self, *args, **kwargs): self._LS_object = kwargs.pop("ls_obj", None) self.nterms = kwargs.pop("nterms", 1) self.ls_method = kwargs.pop("ls_method", "fastchi2") super(LombScarglePeriodogram, self).__init__(*args, **kwargs) def __repr__(self): return "LombScarglePeriodogram(ID: {})".format(self.label)
[docs] @staticmethod def from_lightcurve( lc, minimum_frequency=None, maximum_frequency=None, minimum_period=None, maximum_period=None, frequency=None, period=None, nterms=1, nyquist_factor=1, oversample_factor=None, freq_unit=None, normalization="amplitude", ls_method="fast", **kwargs ): """Creates a `Periodogram` from a LightCurve using the Lomb-Scargle method. By default, the periodogram will be created for a regular grid of frequencies from one frequency separation to the Nyquist frequency, where the frequency separation is determined as 1 / the time baseline. The min frequency and/or max frequency (or max period and/or min period) can be passed to set custom limits for the frequency grid. Alternatively, the user can provide a custom regular grid using the `frequency` parameter or a custom regular grid of periods using the `period` parameter. The sampling of the spectrum can be changed using the `oversample_factor` parameter. An oversampled spectrum (oversample_factor > 1) is useful for displaying the full details of the spectrum, allowing the frequencies and amplitudes to be measured directly from the plot itself, with no fitting required. This is recommended for most applications, with a value of 5 or 10. On the other hand, an oversample_factor of 1 means the spectrum is critically sampled, where every point in the spectrum is independent of the others. This may be used when Lorentzians are to be fitted to modes in the power spectrum, in cases where the mode lifetimes are shorter than the time-base of the data (which is sometimes the case for solar-like oscillations). An oversample_factor of 1 is suitable for these stars because the modes are usually fully resolved. That is, the power from each mode is spread over a range of frequencies due to damping. Hence, any small error from measuring mode frequencies by taking the maximum of the peak is negligible compared with the intrinsic linewidth of the modes. The `normalization` parameter will normalize the spectrum to either power spectral density ("psd") or amplitude ("amplitude"). Users doing asteroseismology on classical pulsators (e.g. delta Scutis) typically prefer `normalization="amplitude"` because "amplitude" has higher dynamic range (high and low peaks visible simultaneously), and we often want to read off amplitudes from the plot. If `normalization="amplitude"`, the default value for `oversample_factor` is set to 5 and `freq_unit` is 1/day. Alternatively, users doing asteroseismology on solar-like oscillators tend to prefer `normalization="psd"` because power density has a scaled axis that depends on the length of the observing time, and is used when we are interested in noise levels (e.g. granulation) and are looking at damped oscillations. If `normalization="psd"`, the default value for `oversample_factor` is set to 1 and `freq_unit` is set to microHz. Default values of `freq_unit` and `oversample_factor` can be overridden. See Appendix A of Kjeldsen & Bedding, 1995 for a full discussion of normalization and measurement of oscillation amplitudes (http://adsabs.harvard.edu/abs/1995A%26A...293...87K). The parameter nterms controls how many Fourier terms are used in the model. Setting the Nyquist_factor to be greater than 1 will sample the space beyond the Nyquist frequency, which may introduce aliasing. The `freq_unit` parameter allows a request for alternative units in frequency space. By default frequency is in (1/day) and power in (amplitude). Asteroseismologists for example may want frequency in (microHz) in which case they would pass `freq_unit=u.microhertz`. By default this method uses the LombScargle 'fast' method, which assumes a regular grid. If a regular grid of periods (i.e. an irregular grid of frequencies) it will use the 'slow' method. If nterms > 1 is passed, it will use the 'fastchi2' method for regular grids, and 'chi2' for irregular grids. Caution: this method assumes that the LightCurve's time (lc.time) is given in units of days. Parameters ---------- lc : LightCurve object The LightCurve from which to compute the Periodogram. minimum_frequency : float If specified, use this minimum frequency rather than one over the time baseline. maximum_frequency : float If specified, use this maximum frequency rather than nyquist_factor times the nyquist frequency. minimum_period : float If specified, use 1./minium_period as the maximum frequency rather than nyquist_factor times the nyquist frequency. maximum_period : float If specified, use 1./maximum_period as the minimum frequency rather than one over the time baseline. frequency : array-like The grid of frequencies to use. If given a unit, it is converted to units of freq_unit. If not, it is assumed to be in units of freq_unit. This over rides any set frequency limits. period : array-like The grid of periods to use (as 1/period). If given a unit, it is converted to units of freq_unit. If not, it is assumed to be in units of 1/freq_unit. This overrides any set period limits. nterms : int Default 1. Number of terms to use in the Fourier fit. nyquist_factor : int Default 1. The multiple of the average Nyquist frequency. Is overriden by maximum_frequency (or minimum period). oversample_factor : int Default: None. The frequency spacing, determined by the time baseline of the lightcurve, is divided by this factor, oversampling the frequency space. This parameter is identical to the samples_per_peak parameter in astropy.LombScargle(). If normalization='amplitude', oversample_factor will be set to 5. If normalization='psd', it will be 1. These defaults can be overridden. freq_unit : `astropy.units.core.CompositeUnit` Default: None. The desired frequency units for the Lomb Scargle periodogram. This implies that 1/freq_unit is the units for period. With default normalization ('amplitude'), the freq_unit is set to 1/day, which can be overridden. 'psd' normalization will set freq_unit to microhertz. normalization : 'psd' or 'amplitude' Default: `'amplitude'`. The desired normalization of the spectrum. Can be either power spectral density (`'psd'`) or amplitude (`'amplitude'`). ls_method : str Default: `'fast'`. Passed to the `method` keyword of `astropy.stats.LombScargle()`. kwargs : dict Keyword arguments passed to `astropy.stats.LombScargle()` Returns ------- Periodogram : `Periodogram` object Returns a Periodogram object extracted from the lightcurve. """ # Input validation normalization = validate_method(normalization, ["psd", "amplitude"]) if np.isnan(lc.flux).any() or (hasattr(lc.flux, 'unmasked') and np.isnan(lc.flux.unmasked).any()): lc = lc.remove_nans() log.debug( "Lightcurve contains NaN values." "These are removed before creating the periodogram." ) # Setting default frequency units if freq_unit is None: freq_unit = 1 / u.day if normalization == "amplitude" else u.microhertz # Default oversample factor if oversample_factor is None: oversample_factor = 5.0 if normalization == "amplitude" else 1.0 if "min_period" in kwargs: warnings.warn( "`min_period` keyword is deprecated, " "please use `minimum_period` instead.", LightkurveWarning, ) minimum_period = kwargs.pop("min_period", None) if "max_period" in kwargs: warnings.warn( "`max_period` keyword is deprecated, " "please use `maximum_period` instead.", LightkurveWarning, ) maximum_period = kwargs.pop("max_period", None) if "min_frequency" in kwargs: warnings.warn( "`min_frequency` keyword is deprecated, " "please use `minimum_frequency` instead.", LightkurveWarning, ) minimum_frequency = kwargs.pop("min_frequency", None) if "max_frequency" in kwargs: warnings.warn( "`max_frequency` keyword is deprecated, " "please use `maximum_frequency` instead.", LightkurveWarning, ) maximum_frequency = kwargs.pop("max_frequency", None) # Check if any values of period have been passed and set format accordingly if not all(b is None for b in [period, minimum_period, maximum_period]): default_view = "period" else: default_view = "frequency" # If period and frequency keywords have both been set, throw an error if (not all(b is None for b in [period, minimum_period, maximum_period])) & ( not all( b is None for b in [frequency, minimum_frequency, maximum_frequency] ) ): raise ValueError( "You have input keyword arguments for both frequency and period. " "Please only use one." ) time = lc.time.copy() # Approximate Nyquist Frequency and frequency bin width in terms of days nyquist = 0.5 * (1.0 / (np.median(np.diff(time.value)))) * (1 / cds.d) fs = (1.0 / (time[-1] - time[0])) / oversample_factor # Convert these values to requested frequency unit nyquist = nyquist.to(freq_unit) fs = fs.to(freq_unit) # Warn if there is confusing input if (frequency is not None) & ( any([a is not None for a in [minimum_frequency, maximum_frequency]]) ): log.warning( "You have passed both a grid of frequencies " "and min_frequency/maximum_frequency arguments; " "the latter will be ignored." ) if (period is not None) & ( any([a is not None for a in [minimum_period, maximum_period]]) ): log.warning( "You have passed a grid of periods " "and minimum_period/maximum_period arguments; " "the latter will be ignored." ) # Tidy up the period stuff... if maximum_period is not None: # minimum_frequency MUST be none by this point. minimum_frequency = 1.0 / maximum_period if minimum_period is not None: # maximum_frequency MUST be none by this point. maximum_frequency = 1.0 / minimum_period # If the user specified a period, copy it into the frequency. if period is not None: frequency = 1.0 / period # Do unit conversions if user input min/max frequency or period if frequency is None: if minimum_frequency is not None: minimum_frequency = u.Quantity(minimum_frequency, freq_unit) if maximum_frequency is not None: maximum_frequency = u.Quantity(maximum_frequency, freq_unit) if (minimum_frequency is not None) & (maximum_frequency is not None): if minimum_frequency > maximum_frequency: if default_view == "frequency": raise ValueError( "minimum_frequency cannot be larger than maximum_frequency" ) if default_view == "period": raise ValueError( "minimum_period cannot be larger than maximum_period" ) # If nothing has been passed in, set them to the defaults if minimum_frequency is None: minimum_frequency = fs if maximum_frequency is None: maximum_frequency = nyquist * nyquist_factor # Create frequency grid evenly spaced in frequency frequency = np.arange( minimum_frequency.value, maximum_frequency.value, fs.value ) # Convert to desired units frequency = u.Quantity(frequency, freq_unit) # Change to compatible ls method if sampling not even in frequency if not implementations.main._is_regular(frequency) and ls_method in [ "fastchi2", "fast", ]: oldmethod = ls_method ls_method = {"fastchi2": "chi2", "fast": "slow"}[ls_method] log.warning( "The requested periodogram is not evenly sampled in frequency.\n" "Method has been changed from '{}' to '{}' to allow for this.".format( oldmethod, ls_method ) ) if (nterms > 1) and (ls_method not in ["fastchi2", "chi2"]): warnings.warn( "Building a Lomb Scargle Periodogram using the `slow` method. " "`nterms` has been set to >1, however this is not supported under the `{}` method. " "To run with higher nterms, set `ls_method` to either 'fastchi2', or 'chi2'. " "Please refer to the `astropy.timeseries.periodogram.LombScargle` documentation.".format( ls_method ), LightkurveWarning, ) nterms = 1 if float(astropy.__version__[0]) >= 3: LS = LombScargle( time, lc.flux, nterms=nterms, normalization="psd", **kwargs ) power = LS.power(frequency, method=ls_method) else: LS = LombScargle(time, lc.flux, nterms=nterms, **kwargs) power = LS.power(frequency, method=ls_method, normalization="psd") if normalization == "psd": # Power spectral density # Rescale from the unnormalized power output by Astropy's # Lomb-Scargle function to units of flux_variance / [frequency unit] # that may be of more interest for asteroseismology. power *= 2.0 / (len(time) * oversample_factor * fs) elif normalization == "amplitude": power = np.sqrt(power) * np.sqrt(4.0 / len(lc.time)) # Periodogram needs properties return LombScarglePeriodogram( frequency=frequency, power=power, nyquist=nyquist, targetid=lc.meta.get("TARGETID"), label=lc.meta.get("LABEL"), default_view=default_view, ls_obj=LS, nterms=nterms, ls_method=ls_method, meta=lc.meta, )
def model(self, time, frequency=None): """Obtain the flux model for a given frequency and time Parameters ---------- time : np.ndarray Time points to evaluate model. frequency : frequency to evaluate model. Default is the frequency at max power. Returns ------- result : lightkurve.LightCurve Model object with the time and flux model """ if self._LS_object is None: raise ValueError("No `astropy` Lomb Scargle object exists.") if frequency is None: frequency = self.frequency_at_max_power f = self._LS_object.model(time, frequency) lc = LightCurve( time=time, flux=f, meta={"FREQUENCY": frequency}, label="LS Model", targetid="{} LS Model".format(self.targetid), ) return lc.normalize() class BoxLeastSquaresPeriodogram(Periodogram): """Subclass of :class:`Periodogram <lightkurve.periodogram.Periodogram>` representing a power spectrum generated using the Box Least Squares (BLS) method. """ def __init__(self, *args, **kwargs): self.duration = kwargs.pop("duration", None) self.depth = kwargs.pop("depth", None) self.snr = kwargs.pop("snr", None) self._BLS_result = kwargs.pop("bls_result", None) self._BLS_object = kwargs.pop("bls_obj", None) self.transit_time = kwargs.pop("transit_time", None) self.time = kwargs.pop("time", None) self.flux = kwargs.pop("flux", None) self.time_unit = kwargs.pop("time_unit", None) super(BoxLeastSquaresPeriodogram, self).__init__(*args, **kwargs) def __repr__(self): return "BoxLeastSquaresPeriodogram(ID: {})".format(self.label)
[docs] @staticmethod def from_lightcurve(lc, **kwargs): """Creates a `Periodogram` from a LightCurve using the Box Least Squares (BLS) method. Parameters ---------- lc : `LightCurve` object The LightCurve from which to compute the Periodogram. duration : float, array_like, or `~astropy.units.Quantity`, optional The set of durations that will be considered. Default to `[0.05, 0.10, 0.15, 0.20, 0.25, 0.33]` if not specified. period : array_like or `~astropy.units.Quantity`, optional The periods where the Periodogram should be computed. If not provided, a default will be created using `BoxLeastSquares.autoperiod() <astropy.timeseries.BoxLeastSquares.autoperiod>`. minimum_period, maximum_period : float or `~astropy.units.Quantity`, optional If ``period`` is not provided, the minimum/maximum periods to search. The defaults will be computed as described in the notes below. frequency_factor : float, optional If ``period`` is not provided, a factor to control the frequency spacing of periods to be considered. kwargs : dict Keyword arguments passed to `BoxLeastSquares.power() <astropy.timeseries.BoxLeastSquares.power>` Returns ------- Periodogram : `Periodogram` object Returns a Periodogram object extracted from the lightcurve. Notes ----- If ``period`` is not provided, the default minimum period is computed from maximum duration and the median observation time gap as .. code-block:: python minimum_period = max(median(diff(lc.time)) * 4, max(duration) + median(diff(lc.time))) The default maximum period is computed as .. code-block:: python maximum_period = (max(lc.time) - min(lc.time)) / 3 ensuring that any systems with at least 3 transits are within the range of searched periods. """ # BoxLeastSquares was added to `astropy.stats` in AstroPy v3.1 and then # moved to `astropy.timeseries` in v3.2, which makes the import below # somewhat complicated. try: from astropy.timeseries import BoxLeastSquares except ImportError: try: from astropy.stats import BoxLeastSquares except ImportError: raise ImportError("BLS requires AstroPy v3.1 or later") # Validate user input for `lc` # (BoxLeastSquares will not work if flux or flux_err contain NaNs) lc = lc.remove_nans() if np.isfinite(lc.flux_err).all(): dy = lc.flux_err else: dy = None # Validate user input for `duration` duration = kwargs.pop("duration", [0.05, 0.10, 0.15, 0.20, 0.25, 0.33]) if duration is not None and ~np.all(np.isfinite(duration)): raise ValueError( "`duration` parameter contains illegal nan or inf value(s)" ) # Validate user input for `period` period = kwargs.pop("period", None) minimum_period = kwargs.pop("minimum_period", None) maximum_period = kwargs.pop("maximum_period", None) if period is not None and ~np.all(np.isfinite(period)): raise ValueError("`period` parameter contains illegal nan or inf value(s)") if minimum_period is None: if period is None: minimum_period = np.max( [ np.median(np.diff(lc.time.value)) * 4, np.max(duration) + np.median(np.diff(lc.time.value)), ] ) else: minimum_period = np.min(period) if maximum_period is None: if period is None: maximum_period = (np.max(lc.time.value) - np.min(lc.time.value)) / 3.0 else: maximum_period = np.max(period) # Validate user input for `time_unit` time_unit = kwargs.pop("time_unit", "day") if time_unit not in dir(u): raise ValueError( "{} is not a valid value for `time_unit`".format(time_unit) ) # Validate user input for `frequency_factor` frequency_factor = kwargs.pop("frequency_factor", 10) df = ( frequency_factor * np.min(duration) / (np.max(lc.time.value) - np.min(lc.time.value)) ** 2 ) npoints = int(((1 / minimum_period) - (1 / maximum_period)) / df) if npoints > 1e7: raise ValueError( "`period` contains {} points." "Periodogram is too large to evaluate. " "Consider setting `frequency_factor` to a higher value." "".format(np.round(npoints, 4)) ) elif npoints > 1e5: log.warning( "`period` contains {} points." "Periodogram is likely to be large, and slow to evaluate. " "Consider setting `frequency_factor` to a higher value." "".format(np.round(npoints, 4)) ) # Create BLS object and run the BLS search bls = BoxLeastSquares(lc.time, lc.flux, dy) if period is None: period = bls.autoperiod( duration, minimum_period=minimum_period, maximum_period=maximum_period, frequency_factor=frequency_factor, ) result = bls.power(period, duration, **kwargs) if not isinstance(result.period, u.quantity.Quantity): result.period = u.Quantity(result.period, time_unit) if not isinstance(result.power, u.quantity.Quantity): result.power = result.power * u.dimensionless_unscaled if not isinstance(result.duration, u.quantity.Quantity): result.duration = u.Quantity(result.duration, time_unit) return BoxLeastSquaresPeriodogram( frequency=1.0 / result.period, power=result.power, default_view="period", label=lc.meta.get("LABEL"), targetid=lc.meta.get("TARGETID"), transit_time=result.transit_time, duration=result.duration, depth=result.depth, bls_result=result, snr=result.depth_snr, bls_obj=bls, time=lc.time, flux=lc.flux, time_unit=time_unit, )
[docs] def compute_stats(self, period=None, duration=None, transit_time=None): """Computes commonly used vetting statistics for a transit model. See `~astropy.timeseries.BoxLeastSquares` docs for further details. Parameters ---------- period : float or Quantity Period of the transits. Default is `period_at_max_power` duration : float or Quantity Duration of the transits. Default is `duration_at_max_power` transit_time : float or Quantity Transit midpoint of the transits. Default is `transit_time_at_max_power` Returns ------- stats : dict Dictionary of vetting statistics """ if period is None: period = self.period_at_max_power log.warning("No period specified. Using period at max power") if duration is None: duration = self.duration_at_max_power log.warning("No duration specified. Using duration at max power") if transit_time is None: transit_time = self.transit_time_at_max_power log.warning("No transit time specified. Using transit time at max power") if not isinstance(transit_time, Time): transit_time = Time( transit_time, format=self.time.format, scale=self.time.scale ) return self._BLS_object.compute_stats( u.Quantity(period, "d").value, u.Quantity(duration, "d").value, transit_time )
[docs] def get_transit_model(self, period=None, duration=None, transit_time=None): """Computes the transit model using the BLS, returns a lightkurve.LightCurve See `~astropy.timeseries.BoxLeastSquares` docs for further details. Parameters ---------- period : float or Quantity Period of the transits. Default is `period_at_max_power` duration : float or Quantity Duration of the transits. Default is `duration_at_max_power` transit_time : float or Quantity Transit midpoint of the transits. Default is `transit_time_at_max_power` Returns ------- model : lightkurve.LightCurve Model of transit """ from .lightcurve import LightCurve if period is None: period = self.period_at_max_power log.warning("No period specified. Using period at max power") if duration is None: duration = self.duration_at_max_power log.warning("No duration specified. Using duration at max power") if transit_time is None: transit_time = self.transit_time_at_max_power log.warning("No transit time specified. Using transit time at max power") if not isinstance(transit_time, Time): transit_time = Time( transit_time, format=self.time.format, scale=self.time.scale ) model_flux = self._BLS_object.model( self.time, u.Quantity(period, "d").value, u.Quantity(duration, "d").value, transit_time, ) model = LightCurve(time=self.time, flux=model_flux, label="Transit Model Flux") return model
def get_transit_mask(self, period=None, duration=None, transit_time=None): """Returns a boolean array that is ``True`` during transits and ``False`` elsewhere. Parameters ---------- period : float or Quantity Period of the transits. Default is `period_at_max_power` duration : float or Quantity Duration of the transits. Default is `duration_at_max_power` transit_time : float or Quantity Transit midpoint of the transits. Default is `transit_time_at_max_power` Returns ------- transit_mask : np.array of bool Mask that flags transits. Mask is ``True`` where there are transits. """ model = self.get_transit_model( period=period, duration=duration, transit_time=transit_time ) return model.flux != np.median(model.flux) @property def transit_time_at_max_power(self): """Returns the transit time corresponding to the highest peak in the periodogram.""" return self.transit_time[np.nanargmax(self.power)] @property def duration_at_max_power(self): """Returns the duration corresponding to the highest peak in the periodogram.""" return self.duration[np.nanargmax(self.power)] @property def depth_at_max_power(self): """Returns the depth corresponding to the highest peak in the periodogram.""" return self.depth[np.nanargmax(self.power)] def plot(self, **kwargs): """Plot the BoxLeastSquaresPeriodogram spectrum using matplotlib's `plot` method. See `Periodogram.plot` for details on the accepted arguments. Parameters ---------- kwargs : dict Dictionary of arguments ot be passed to `Periodogram.plot`. Returns ------- ax : `~matplotlib.axes.Axes` The matplotlib axes object. """ ax = super(BoxLeastSquaresPeriodogram, self).plot(**kwargs) if "ylabel" not in kwargs: ax.set_ylabel("BLS Power") return ax def flatten(self, **kwargs): raise NotImplementedError( "`flatten` is not implemented for `BoxLeastSquaresPeriodogram`." ) def smooth(self, **kwargs): raise NotImplementedError( "`smooth` is not implemented for `BoxLeastSquaresPeriodogram`. " )