diff --git a/lcviz/helper.py b/lcviz/helper.py index d658ab8c..c47ae621 100644 --- a/lcviz/helper.py +++ b/lcviz/helper.py @@ -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', diff --git a/lcviz/plugins/__init__.py b/lcviz/plugins/__init__.py index e4211401..33815021 100644 --- a/lcviz/plugins/__init__.py +++ b/lcviz/plugins/__init__.py @@ -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 diff --git a/lcviz/plugins/frequency_analysis/__init__.py b/lcviz/plugins/frequency_analysis/__init__.py new file mode 100644 index 00000000..ed2a998b --- /dev/null +++ b/lcviz/plugins/frequency_analysis/__init__.py @@ -0,0 +1 @@ +from .frequency_analysis import * # noqa diff --git a/lcviz/plugins/frequency_analysis/frequency_analysis.py b/lcviz/plugins/frequency_analysis/frequency_analysis.py new file mode 100644 index 00000000..2d31c320 --- /dev/null +++ b/lcviz/plugins/frequency_analysis/frequency_analysis.py @@ -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 ` 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() diff --git a/lcviz/plugins/frequency_analysis/frequency_analysis.vue b/lcviz/plugins/frequency_analysis/frequency_analysis.vue new file mode 100644 index 00000000..44cd0431 --- /dev/null +++ b/lcviz/plugins/frequency_analysis/frequency_analysis.vue @@ -0,0 +1,110 @@ + diff --git a/lcviz/tests/test_plugin_frequency_analysis.py b/lcviz/tests/test_plugin_frequency_analysis.py new file mode 100644 index 00000000..78235ca9 --- /dev/null +++ b/lcviz/tests/test_plugin_frequency_analysis.py @@ -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)) diff --git a/pyproject.toml b/pyproject.toml index 3ac623fc..934bd3c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", @@ -102,6 +102,7 @@ filterwarnings = [ "ignore::DeprecationWarning:ipykernel", "ignore::DeprecationWarning:traittypes", "ignore::DeprecationWarning:voila", + "ignore::UserWarning:traittypes", "ignore::DeprecationWarning:asteval", "ignore::FutureWarning:asteval", ]