From 19d4464a83e97308874c88fe109640195cd743fa Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 7 Oct 2024 16:16:27 -0500 Subject: [PATCH 1/4] Add method for creating lightcurve for object --- pyproject.toml | 1 + stellarphot/conftest.py | 22 ++++++++++ stellarphot/core.py | 77 ++++++++++++++++++++++++++++++++ stellarphot/tests/test_core.py | 80 +++++++++++++++++++++++++++++++++- 4 files changed, 179 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 610ab502..b287d1f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "ipyfilechooser", "ipywidgets", "jupyter-app-launcher >=0.3.0", + "lightkurve", "matplotlib", "papermill", "pandas", 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..f922e468 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,82 @@ 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, star_id=None, coordinates=None, name=None, 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 + ---------- + star_id : str, optional + The star_id of the star in the photometry data. If not provided, coordinates + or name must be provided. + + coordinates : `astropy.coordinates.SkyCoord`, optional + The coordinates of the star in the photometry data. If not provided, star_id + or name must be provided. + + name : str, optional + The name of the star in Simbad. If not provided, star_id or coordinates must + be provided. + + 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``. + """ + num_args = sum([star_id is not None, coordinates is not None, name is not None]) + + if num_args == 0: + raise ValueError("Either star_id, coordinates or name must be provided.") + + if num_args > 1: + raise ValueError( + "Only one of star_id, coordinates, or name can be provided." + ) + + if name is not None: + coordinates = SkyCoord.from_name(name) + + 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( + "No star in the photometry data is close enough to the " + "provided coordinates." + ) + + star_data = self[self["star_id"] == star_id] + + # 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..8bff9c91 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,81 @@ def test_sourcelist_slicing(): # Checking attributes survive slicing assert slicing_test.has_ra_dec assert slicing_test.has_x_y + + +@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) + + 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], + ) + + selectors = dict( + star_id=target_star_id, + coordinates=star_id_coords, + ) + + assert isinstance(new_table, PhotometryData) + + # Grab just the keywords we need for this test + selector = {target_by: selectors[target_by]} + lc = new_table.lightcurve_for(**selector) + + # We made 5 times, so there should be 5 rows in the lightcurve + assert len(lc) == 5 + assert (lc["star_id"] == target_star_id).all() + + +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(name="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_logic(simple_photometry_data): + # providing no argument should raise an error + with pytest.raises( + ValueError, match="Either star_id, coordinates or name must be provided" + ): + simple_photometry_data.lightcurve_for() + + # providing two arguments should raise an error + with pytest.raises( + ValueError, match="Only one of star_id, coordinates, or name can be provided" + ): + simple_photometry_data.lightcurve_for( + star_id=1, coordinates=SkyCoord(0, 0, unit="deg") + ) From dc07018f628183c44241891ea4230afc5a0156da Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 7 Oct 2024 16:43:58 -0500 Subject: [PATCH 2/4] Add another DeprecationWarning to ignore --- pyproject.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b287d1f5..29a5312e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -194,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' ] From b3e117ea804364ba98bd9cdf6517ffca55fdc95e Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 7 Oct 2024 16:47:35 -0500 Subject: [PATCH 3/4] Mark a test remote-data --- stellarphot/tests/test_core.py | 1 + 1 file changed, 1 insertion(+) diff --git a/stellarphot/tests/test_core.py b/stellarphot/tests/test_core.py index 8bff9c91..60590e53 100644 --- a/stellarphot/tests/test_core.py +++ b/stellarphot/tests/test_core.py @@ -1203,6 +1203,7 @@ def test_to_lightcurve(simple_photometry_data, target_by, target_star_id): assert (lc["star_id"] == target_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 From 1c89681bb17c4c21f3a22d124501417dc1734567 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Wed, 9 Oct 2024 09:24:06 -0500 Subject: [PATCH 4/4] Change signature of lightcurve_for --- stellarphot/core.py | 47 +++++++++++++++----------------- stellarphot/tests/test_core.py | 49 +++++++++++++++++++++------------- 2 files changed, 53 insertions(+), 43 deletions(-) diff --git a/stellarphot/core.py b/stellarphot/core.py index f922e468..fc325ca2 100644 --- a/stellarphot/core.py +++ b/stellarphot/core.py @@ -577,26 +577,16 @@ 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, star_id=None, coordinates=None, name=None, flux_column="mag_inst" - ): + 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 ---------- - star_id : str, optional - The star_id of the star in the photometry data. If not provided, coordinates - or name must be provided. - - coordinates : `astropy.coordinates.SkyCoord`, optional - The coordinates of the star in the photometry data. If not provided, star_id - or name must be provided. - - name : str, optional - The name of the star in Simbad. If not provided, star_id or coordinates must - be provided. + 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 @@ -609,18 +599,23 @@ def lightcurve_for( `stellarphot.`PhotometryData` object and columns ``time``, ``flux``, and ``flux_err``. """ - num_args = sum([star_id is not None, coordinates is not None, name is not None]) - if num_args == 0: - raise ValueError("Either star_id, coordinates or name must be provided.") + # This will get set if we need to find the star_id from the coordinates + coordinates = None - if num_args > 1: - raise ValueError( - "Only one of star_id, coordinates, or name can be provided." - ) + 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. - if name is not None: - coordinates = SkyCoord.from_name(name) + # 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 @@ -629,12 +624,14 @@ def lightcurve_for( star_id = self["star_id"][idx] if d2d > 1 * u.arcsec: raise ValueError( - "No star in the photometry data is close enough to the " - "provided coordinates." + 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"] diff --git a/stellarphot/tests/test_core.py b/stellarphot/tests/test_core.py index 60590e53..5852d112 100644 --- a/stellarphot/tests/test_core.py +++ b/stellarphot/tests/test_core.py @@ -1165,6 +1165,7 @@ def test_sourcelist_slicing(): 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): @@ -1181,27 +1182,33 @@ def test_to_lightcurve(simple_photometry_data, target_by, target_star_id): # 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], ) - selectors = dict( - star_id=target_star_id, - coordinates=star_id_coords, - ) - - assert isinstance(new_table, PhotometryData) + if target_by == "coordinates": + target = star_id_coords + else: + target = target_star_id # Grab just the keywords we need for this test - selector = {target_by: selectors[target_by]} - lc = new_table.lightcurve_for(**selector) + 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): @@ -1220,7 +1227,7 @@ def test_to_lightcurve_from_name(simple_photometry_data): assert isinstance(new_table, PhotometryData) - lc = new_table.lightcurve_for(name="wasp 10") + lc = new_table.lightcurve_for("wasp 10") # We made 5 times, so there should be 5 rows in the lightcurve assert len(lc) == 5 @@ -1228,17 +1235,23 @@ def test_to_lightcurve_from_name(simple_photometry_data): assert (lc["star_id"] == 1).all() -def test_to_lightcurve_argument_logic(simple_photometry_data): +def test_to_lightcurve_argument_errors(simple_photometry_data): # providing no argument should raise an error - with pytest.raises( - ValueError, match="Either star_id, coordinates or name must be provided" - ): + with pytest.raises(TypeError, match="missing 1 required positional argument"): simple_photometry_data.lightcurve_for() - # providing two arguments should raise an error + # Providing a coordinate that does not match anything should raise an error with pytest.raises( - ValueError, match="Only one of star_id, coordinates, or name can be provided" + ValueError, match="No matching star in the photometry data found" ): - simple_photometry_data.lightcurve_for( - star_id=1, coordinates=SkyCoord(0, 0, unit="deg") - ) + 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