Skip to content

Commit

Permalink
basic frequency analysis plugin (#30)
Browse files Browse the repository at this point in the history
* basic frequency analysis plugin
* requires dev version of jdaviz for plot plugin subcomponent
* non-interactive
* BLS/LS only
* test coverage
  • Loading branch information
kecnry authored Jul 17, 2023
1 parent c2e7c5d commit 12da262
Show file tree
Hide file tree
Showing 7 changed files with 328 additions and 3 deletions.
3 changes: 2 additions & 1 deletion lcviz/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ class LCviz(ConfigHelper):
'context': {'notebook': {'max_height': '600px'}}},
'toolbar': ['g-data-tools', 'g-subset-tools', 'lcviz-coords-info'],
'tray': ['g-metadata-viewer', 'lcviz-plot-options', 'g-subset-plugin',
'lcviz-markers', 'flatten', 'ephemeris', 'g-export-plot'],
'lcviz-markers', 'flatten', 'frequency analysis', 'ephemeris',
'g-export-plot'],
'viewer_area': [{'container': 'col',
'children': [{'container': 'row',
'viewers': [{'name': 'flux-vs-time',
Expand Down
1 change: 1 addition & 0 deletions lcviz/plugins/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from .coords_info.coords_info import * # noqa
from .ephemeris.ephemeris import * # noqa
from .flatten.flatten import * # noqa
from .frequency_analysis.frequency_analysis import * # noqa
from .markers.markers import * # noqa
from .plot_options.plot_options import * # noqa
1 change: 1 addition & 0 deletions lcviz/plugins/frequency_analysis/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .frequency_analysis import * # noqa
170 changes: 170 additions & 0 deletions lcviz/plugins/frequency_analysis/frequency_analysis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
from functools import cached_property
from traitlets import Bool, Float, List, Unicode, observe

from lightkurve import periodogram

from jdaviz.core.custom_traitlets import FloatHandleEmpty
from jdaviz.core.registries import tray_registry
from jdaviz.core.template_mixin import (PluginTemplateMixin,
DatasetSelectMixin, SelectPluginComponent, PlotMixin)
from jdaviz.core.user_api import PluginUserApi


__all__ = ['FrequencyAnalysis']


@tray_registry('frequency analysis', label="Frequency Analysis")
class FrequencyAnalysis(PluginTemplateMixin, DatasetSelectMixin, PlotMixin):
"""
See the :ref:`Frequency Analysis Plugin Documentation <frequency_ananlysis>` for more details.
Only the following attributes and methods are available through the
public plugin API.
* ``dataset`` (:class:`~jdaviz.core.template_mixin.DatasetSelect`):
Dataset to use for analysis.
* ``method`` (:class:`~jdaviz.core.template_mixing.SelectPluginComponent`):
Method/algorithm to determine the period.
* ``xunit`` (:class:`~jdaviz.core.template_mixing.SelectPluginComponent`):
Whether to plot power vs fequency or period.
* ``auto_range``
* ``minimum``
* ``maximum``
* :meth:``periodogram``
"""
template_file = __file__, "frequency_analysis.vue"

method_items = List().tag(sync=True)
method_selected = Unicode().tag(sync=True)

xunit_items = List().tag(sync=True)
xunit_selected = Unicode().tag(sync=True)

auto_range = Bool(True).tag(sync=True)
minimum = FloatHandleEmpty(0.1).tag(sync=True) # frequency
minimum_step = Float(0.1).tag(sync=True)
maximum = FloatHandleEmpty(1).tag(sync=True) # frequency
maximum_step = Float(0.1).tag(sync=True)

spinner = Bool().tag(sync=True)
err = Unicode().tag(sync=True)

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

self._ignore_auto_update = False

self.method = SelectPluginComponent(self,
items='method_items',
selected='method_selected',
manual_options=['Lomb-Scargle', 'Box Least Squares'])

self.xunit = SelectPluginComponent(self,
items='xunit_items',
selected='xunit_selected',
manual_options=['frequency', 'period'])

self.plot.add_line('line', color='gray', marker_size=12)
self.plot.figure.axes[1].label = 'power'
self._update_xunit()

# TODO: remove if/once inherited from jdaviz
# (https://github.com/spacetelescope/jdaviz/pull/2253)
def _clear_cache(self, *attrs):
"""
provide convenience function to clearing the cache for cached_properties
"""
if not len(attrs):
attrs = self._cached_properties
for attr in attrs:
if attr in self.__dict__:
del self.__dict__[attr]

@property
def user_api(self):
expose = ['dataset', 'method', 'xunit', 'auto_range', 'minimum', 'maximum', 'periodogram']
return PluginUserApi(self, expose=expose)

@cached_property
def periodogram(self):
# TODO: support multiselect on self.dataset and combine light curves (or would that be a
# dedicated plugin of its own)?
self.spinner = True
self.err = ''
if self.auto_range:
min_period, max_period = None, None
elif self.xunit_selected == 'period':
min_period, max_period = self.minimum, self.maximum
else:
min_period, max_period = self.maximum ** -1, self.minimum ** -1
if self.method == 'Box Least Squares':
try:
per = periodogram.BoxLeastSquaresPeriodogram.from_lightcurve(self.dataset.selected_obj, # noqa
minimum_period=min_period, # noqa
maximum_period=max_period) # noqa
except Exception as err:
self.spinner = False
self.err = str(err)
self.plot.clear_all_marks()
return None
elif self.method == 'Lomb-Scargle':
try:
per = periodogram.LombScarglePeriodogram.from_lightcurve(self.dataset.selected_obj,
minimum_period=min_period,
maximum_period=max_period)
except Exception as err:
self.spinner = False
self.err = str(err)
self.plot.clear_all_marks()
return None
else:
self.spinner = False
raise NotImplementedError(f"periodogram not implemented for {self.method}")

self._update_periodogram_labels(per)
self.spinner = False
return per

@observe('xunit_selected')
def _update_xunit(self, *args):
per = self.periodogram
if per is not None:
self.plot.marks['line'].x = getattr(per, self.xunit_selected)
else:
self.plot.clear_all_marks()

self._update_periodogram_labels(per)

# convert minimum/maximum for next computation
self._ignore_auto_update = True
old_min, old_max = self.minimum, self.maximum
self.minimum = old_max ** -1 if old_max > 0 else 0
self.maximum = old_min ** -1 if old_min > 0 else 0
self._ignore_auto_update = False

def _update_periodogram_labels(self, per=None):
per = per if per is not None else self.periodogram
if per is not None:
self.plot.figure.axes[0].label = f"{self.xunit_selected} ({getattr(per, self.xunit_selected).unit})" # noqa
self.plot.figure.axes[1].label = f"power ({per.power.unit})" if per.power.unit != "" else "power" # noqa
else:
self.plot.figure.axes[0].label = self.xunit_selected
self.plot.figure.axes[1].label = "power"

@observe('dataset_selected', 'method_selected', 'auto_range', 'minimum', 'maximum')
def _update_periodogram(self, *args):
if not (hasattr(self, 'method') and hasattr(self, 'dataset')):
return
if self._ignore_auto_update:
return

# TODO: avoid clearing cache if change was to min/max but auto_range is True?
self._clear_cache('periodogram')

per = self.periodogram
if per is not None:
line = self.plot.marks['line']
line.x, line.y = getattr(per, self.xunit_selected), per.power
self._update_periodogram_labels(per)
else:
self.plot.clear_all_marks()
110 changes: 110 additions & 0 deletions lcviz/plugins/frequency_analysis/frequency_analysis.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<template>
<j-tray-plugin
description='Frequency/period analysis.'
:link="'https://lcviz.readthedocs.io/en/'+vdocs+'/plugins.html#frequency_analysis'"
:popout_button="popout_button">

<plugin-dataset-select
:items="dataset_items"
:selected.sync="dataset_selected"
:show_if_single_entry="false"
label="Data"
hint="Select the light curve as input."
/>

<v-row>
<v-select
:menu-props="{ left: true }"
attach
:items="method_items.map(i => i.label)"
v-model="method_selected"
label="Algorithm/Method"
:hint="'Method to determine power at each '+xunit_selected+'.'"
persistent-hint
></v-select>
</v-row>

<v-row>
<v-select
:menu-props="{ left: true }"
attach
:items="xunit_items.map(i => i.label)"
v-model="xunit_selected"
label="X Units"
hint="Whether to plot in frequency or period-space."
persistent-hint
></v-select>
</v-row>

<v-row>
<v-switch
v-model="auto_range"
:label="'Auto '+xunit_selected+' range'"
:hint="'Whether to automatically or manually set the range on sampled '+xunit_selected+'s.'"
persistent-hint
></v-switch>
</v-row>

<v-row>
<v-text-field
v-if="!auto_range"
ref="min"
type="number"
:label="'Minimum '+xunit_selected"
v-model.number="minimum"
:step="minimum_step"
type="number"
:hint="'Minimum '+xunit_selected+' to search.'"
persistent-hint
:rules="[() => minimum!=='' || 'This field is required']"
></v-text-field>
</v-row>

<v-row>
<v-text-field
v-if="!auto_range"
ref="max"
type="number"
:label="'Maximum '+xunit_selected"
v-model.number="maximum"
:step="maximum_step"
type="number"
:hint="'Maximum '+xunit_selected+' to search.'"
persistent-hint
:rules="[() => maximum!=='' || 'This field is required']"
></v-text-field>
</v-row>


<div style="display: grid"> <!-- overlay container -->
<div style="grid-area: 1/1">

<v-row v-if="err.length > 0">
<v-alert color="warning">{{ err }}</v-alert>
</v-row>
<v-row v-else>
<jupyter-widget :widget="plot_widget"/>
</v-row>

</div>
<div v-if="spinner"
class="text-center"
style="grid-area: 1/1;
z-index:2;
margin-left: -24px;
margin-right: -24px;
padding-top: 6px;
background-color: rgb(0 0 0 / 20%)">
<v-progress-circular
indeterminate
color="spinner"
size="50"
width="6"
></v-progress-circular>
</div>
</div>

</div>

</j-tray-plugin>
</template>
41 changes: 41 additions & 0 deletions lcviz/tests/test_plugin_frequency_analysis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from numpy.testing import assert_allclose

from lightkurve.periodogram import LombScarglePeriodogram, BoxLeastSquaresPeriodogram


def test_plugin_frequency_analysis(helper, light_curve_like_kepler_quarter):
helper.load_data(light_curve_like_kepler_quarter)
# tv = helper.app.get_viewer(helper._default_time_viewer_reference_name)

freq = helper.plugins['Frequency Analysis']
freq.open_in_tray()

assert freq.method == 'Lomb-Scargle'
assert freq._obj.err == ''
assert isinstance(freq.periodogram, LombScarglePeriodogram)

freq.method = 'Box Least Squares'
assert freq._obj.err == ''
assert isinstance(freq.periodogram, BoxLeastSquaresPeriodogram)

assert freq.xunit == 'frequency'
assert freq._obj.plot.figure.axes[0].label == 'frequency (1 / d)'

freq.xunit = 'period'
assert freq._obj.plot.figure.axes[0].label == 'period (d)'
line_x = freq._obj.plot.marks['line'].x
assert_allclose((line_x.min(), line_x.max()), (0.3508333334885538, 31.309906458683404))

freq.auto_range = False
assert_allclose((freq.minimum, freq.maximum), (1, 10))
while freq._obj.spinner:
pass
line_x = freq._obj.plot.marks['line'].x
assert_allclose((line_x.min(), line_x.max()), (1, 10.00141))

freq.xunit = 'frequency'
assert_allclose((freq.minimum, freq.maximum), (0.1, 1))
while freq._obj.spinner:
pass
line_x = freq._obj.plot.marks['line'].x
assert_allclose((line_x.min(), line_x.max()), (0.0999859, 1))
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ classifiers = [
]
dependencies = [
"astropy>=5.2",
"jdaviz>=3.5.0",
"lightkurve>=2",
"jdaviz@git+https://github.com/spacetelescope/jdaviz", # > 3.5 (unreleased)
"lightkurve@git+https://github.com/lightkurve/lightkurve", # until https://github.com/lightkurve/lightkurve/pull/1342 is in a release
]
dynamic = [
"version",
Expand Down Expand Up @@ -102,6 +102,7 @@ filterwarnings = [
"ignore::DeprecationWarning:ipykernel",
"ignore::DeprecationWarning:traittypes",
"ignore::DeprecationWarning:voila",
"ignore::UserWarning:traittypes",
"ignore::DeprecationWarning:asteval",
"ignore::FutureWarning:asteval",
]
Expand Down

0 comments on commit 12da262

Please sign in to comment.