diff --git a/pyproject.toml b/pyproject.toml index 610ab502..29a5312e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "ipyfilechooser", "ipywidgets", "jupyter-app-launcher >=0.3.0", + "lightkurve", "matplotlib", "papermill", "pandas", @@ -193,5 +194,7 @@ filterwarnings = [ # papermill is using deprecated jupyter paths 'ignore:Jupyter is migrating its paths to use standard platformdirs:DeprecationWarning', # papermill is also using a deprecated method of getting current time - 'ignore:.*is deprecated and scheduled for removal.*:DeprecationWarning' + 'ignore:.*is deprecated and scheduled for removal.*:DeprecationWarning', + # lightkurve is using a deprecated numpy interface + 'ignore:.*numpy.core.einsumfunc is deprecated and has been renamed.*:DeprecationWarning' ] diff --git a/stellarphot/conftest.py b/stellarphot/conftest.py index fda4fd0f..e5dc9d35 100644 --- a/stellarphot/conftest.py +++ b/stellarphot/conftest.py @@ -5,8 +5,11 @@ import pytest from astropy.coordinates import SkyCoord from astropy.table import Table +from astropy.utils.data import get_pkg_data_filename from pytest_astropy_header.display import PYTEST_HEADER_MODULES, TESTED_VERSIONS +from stellarphot import PhotometryData + # astropy-specific-stuff @@ -57,3 +60,22 @@ def tess_tic_expected_values(): tic_id=236158940, expected_coords=SkyCoord(ra=313.41953739, dec=34.35164717, unit="degree"), ) + + +@pytest.fixture +def simple_photometry_data(): + # Grab the test photometry file and simplify it a bit. + data_file = get_pkg_data_filename("tests/data/test_photometry_data.ecsv") + pd_input = PhotometryData.read(data_file) + + # Keep stars 1, 6, 9, 12, first time slice only + # These stars have no NaNs in them and we only need one image to generate + # more test data from that. + first_slice = pd_input["file"] == "wasp-10-b-S001-R001-C099-r.fit" + ids = [1, 6, 9, 12] + good_star = pd_input["star_id"] == ids[0] + + for an_id in ids[1:]: + good_star = good_star | (pd_input["star_id"] == an_id) + + return pd_input[good_star & first_slice] diff --git a/stellarphot/core.py b/stellarphot/core.py index af296010..fc325ca2 100644 --- a/stellarphot/core.py +++ b/stellarphot/core.py @@ -1,5 +1,6 @@ import re +import lightkurve as lk import numpy as np import pandas as pd from astropy import units as u @@ -576,6 +577,79 @@ def add_bjd_col(self, observatory): # Return BJD at midpoint of exposure at each location return Time(time_barycenter + self["exposure"] / 2, scale="tdb") + def lightcurve_for(self, target, flux_column="mag_inst"): + """ + Return the light curve for a single star as a `lightkurve.LightCurve` object. + One of the parameters `star_id`, `coordinates` or `name` must be specified. + + Parameters + ---------- + target : str, int, or `astropy.coordinates.SkyCoord` + The target star. This can be a star_id, a SkyCoord object, or a name that + can be resolved by `astropy.coordinates.SkyCoord.from_name`. + + flux_column : str, optional + The name of the column to use as the flux. Default is 'mag_inst'. This need + not actually be a flux. + + Returns + ------- + `lightkurve.LightCurve` + The light curve for the star. This includes all of the columns in the + `stellarphot.`PhotometryData` object and columns ``time``, ``flux``, and + ``flux_err``. + """ + + # This will get set if we need to find the star_id from the coordinates + coordinates = None + + if isinstance(target, str): + # If the target is a string, it could be a star_id or a name that can be + # resolved to coordinates. + + # Try star_id first, since that doesn't require a network call + if target in self["star_id"]: + star_id = target + else: + coordinates = SkyCoord.from_name(target) + elif isinstance(target, SkyCoord): + coordinates = target + else: + star_id = target + + if coordinates is not None: + # Find the star_id for the closest coordinate match + my_coordinates = SkyCoord(self["ra"], self["dec"]) + idx, d2d, _ = coordinates.match_to_catalog_sky(my_coordinates) + star_id = self["star_id"][idx] + if d2d > 1 * u.arcsec: + raise ValueError( + f"No matching star in the photometry data found at {coordinates}." + ) + + star_data = self[self["star_id"] == star_id] + + if len(star_data) == 0: + raise ValueError(f"No star found that matched {target}.") + + # Create the columns that light curve needs, adding metadata about where each + # column came from. + star_data["time"] = star_data["bjd"] + star_data.meta["time"] = "BJD at midpoint of exposure, column bjd" + + star_data["flux"] = star_data[flux_column] + star_data.meta["flux"] = "Instrumental magnitude, column mag_inst" + + # Why value? Because the instrumental magnitude error is fubar, + # see #463 + flux_error_col = ( + "mag_error" if flux_column == "mag_inst" else flux_column + "_error" + ) + star_data["flux_err"] = star_data[flux_error_col].value + star_data.meta["flux_err"] = "Error in instrumental magnitude, column mag_error" + + return lk.LightCurve(star_data) + class CatalogData(BaseEnhancedTable): """ diff --git a/stellarphot/tests/test_core.py b/stellarphot/tests/test_core.py index 80964e57..5852d112 100644 --- a/stellarphot/tests/test_core.py +++ b/stellarphot/tests/test_core.py @@ -6,7 +6,7 @@ from astropy.coordinates import SkyCoord from astropy.io import ascii, fits from astropy.nddata import CCDData -from astropy.table import Table +from astropy.table import Table, vstack from astropy.time import Time from astropy.utils.data import get_pkg_data_filename from astropy.wcs import WCS @@ -1163,3 +1163,95 @@ def test_sourcelist_slicing(): # Checking attributes survive slicing assert slicing_test.has_ra_dec assert slicing_test.has_x_y + + +# Keep the name option separate because it requires remote data access +@pytest.mark.parametrize("target_by", ["star_id", "coordinates"]) +@pytest.mark.parametrize("target_star_id", [1, 6]) +def test_to_lightcurve(simple_photometry_data, target_by, target_star_id): + # Make a few more rows of data, changing only date_obs, then update the BJD + delta_t = 3 * u.minute + new_data = [simple_photometry_data.copy()] + t_init = simple_photometry_data["date-obs"][0] + + for i in range(1, 5): + data = simple_photometry_data.copy() + data["date-obs"] = t_init + i * delta_t + new_data.append(data) + + # Make a new table with the new data + new_table = vstack(new_data) + + # make absolutely sure we have a PhotometryData object + assert isinstance(new_table, PhotometryData) + + # Get the coordinates of the target star + select_star_id = simple_photometry_data["star_id"] == target_star_id + star_id_coords = SkyCoord( + simple_photometry_data["ra"][select_star_id], + simple_photometry_data["dec"][select_star_id], + ) + + if target_by == "coordinates": + target = star_id_coords + else: + target = target_star_id + + # Grab just the keywords we need for this test + lc = new_table.lightcurve_for(target) + + # We made 5 times, so there should be 5 rows in the lightcurve + assert len(lc) == 5 + + # We should only have the star_id we wanted + assert (lc["star_id"] == target_star_id).all() + + # The default flux should be the mag_inst column for the target, so check that + assert (lc["flux"] == simple_photometry_data["mag_inst"][select_star_id]).all() + + +@pytest.mark.remote_data +def test_to_lightcurve_from_name(simple_photometry_data): + # Make a few more rows of data, changing only date_obs, then update the BJD + delta_t = 3 * u.minute + new_data = [simple_photometry_data.copy()] + t_init = simple_photometry_data["date-obs"][0] + + for i in range(1, 5): + data = simple_photometry_data.copy() + data["date-obs"] = t_init + i * delta_t + new_data.append(data) + + # Make a new table with the new data + new_table = vstack(new_data) + + assert isinstance(new_table, PhotometryData) + + lc = new_table.lightcurve_for("wasp 10") + + # We made 5 times, so there should be 5 rows in the lightcurve + assert len(lc) == 5 + # wasp 10 is star_id 1 + assert (lc["star_id"] == 1).all() + + +def test_to_lightcurve_argument_errors(simple_photometry_data): + # providing no argument should raise an error + with pytest.raises(TypeError, match="missing 1 required positional argument"): + simple_photometry_data.lightcurve_for() + + # Providing a coordinate that does not match anything should raise an error + with pytest.raises( + ValueError, match="No matching star in the photometry data found" + ): + simple_photometry_data.lightcurve_for(SkyCoord(0, 0, unit="deg")) + + # Providing a star_id that does not match anything should raise an error + with pytest.raises(ValueError, match="No star found that matched"): + simple_photometry_data.lightcurve_for(999) + + # Turn star_id into a string and check that we can match star_id in that case + simple_photometry_data["star_id"] = simple_photometry_data["star_id"].astype(str) + + lc = simple_photometry_data.lightcurve_for("1") + assert len(lc) == 1