From 578b214496364e937bdc2a50535d86957a87f09b Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Sun, 11 Feb 2024 09:36:30 -0600 Subject: [PATCH 01/15] Typo fix --- stellarphot/photometry/tests/test_photometry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stellarphot/photometry/tests/test_photometry.py b/stellarphot/photometry/tests/test_photometry.py index 4ccff7c7..0432c56e 100644 --- a/stellarphot/photometry/tests/test_photometry.py +++ b/stellarphot/photometry/tests/test_photometry.py @@ -554,7 +554,7 @@ def test_photometry_on_directory(coords): # see the line where found_sources is defined. # # However, the images are shifted with respect to each other by - # list_of_fakes, so there are no long stars at those positions in the + # list_of_fakes, so there are no longer stars at those positions in the # other images. # # Because of that, the expected result is that either obs_avg_net_cnts From 5ebb43d6954582cbdc8dd57c79d721a61abd9939 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 12 Feb 2024 07:53:24 -0600 Subject: [PATCH 02/15] Add models for passbands and photometry --- stellarphot/settings/models.py | 80 +++++++++++++++++------ stellarphot/settings/tests/test_models.py | 29 ++++++-- 2 files changed, 85 insertions(+), 24 deletions(-) diff --git a/stellarphot/settings/models.py b/stellarphot/settings/models.py index 95e0d52a..81e43999 100644 --- a/stellarphot/settings/models.py +++ b/stellarphot/settings/models.py @@ -1,6 +1,5 @@ # Objects that contains the user settings for the program. -from pathlib import Path from typing import Annotated, Literal from astropy.coordinates import EarthLocation, Latitude, Longitude, SkyCoord @@ -30,8 +29,9 @@ __all__ = [ "Camera", + "PassbandMap", "PhotometryApertures", - "PhotometryFileSettings", + "PhotometrySettings", "PhotometryOptions", "Exoplanet", "Observatory", @@ -342,24 +342,6 @@ def outer_annulus(self): return self.inner_annulus + self.annulus_width -class PhotometryFileSettings(BaseModelWithTableRep): - """ - An evolutionary step on the way to having a monolithic set of photometry settings. - """ - - model_config = MODEL_DEFAULT_CONFIGURATION - - image_folder: Path = Field( - show_only_dirs=True, - default="", - description="Folder containing the calibrated images", - ) - aperture_settings_file: Path = Field(filter_pattern="*.json", default="") - aperture_locations_file: Path = Field( - filter_pattern=["*.ecsv", "*.csv"], default="" - ) - - class Observatory(BaseModelWithTableRep): """ Class to represent an observatory. @@ -510,6 +492,64 @@ class PhotometryOptions(BaseModelWithTableRep): console_log: bool = True +class PassbandMap(BaseModelWithTableRep): + """Class to represent a mapping from one set of filter names to another.""" + + yours_to_aavso: dict[str, str] + + +class PhotometrySettings(BaseModelWithTableRep): + """ + Settings for performing aperture photometry. + + Parameters + ---------- + + camera : `stellarphot.settings.Camera` + Camera object which has gain, read noise and dark current set. + + observatory : `stellarphot.settings.Observatory` + Observatory information. Used for calculating the BJD. + + photometry_apertures : `stellarphot.settings.PhotometryApertures` + Radius, inner and outer annulus radii settings and FWHM. + + photometry_options : `stellarphot.settings.PhotometryOptions` + Several options for the details of performing the photometry. See the + documentation for `~stellarphot.settings.PhotometryOptions` for details. + + passband_map: `stellarphot.settings.PassbandMap` + A dictionary containing instrumental passband names as keys and + AAVSO passband names as values. This is used to rename the passband + entries in the output photometry table from what is in the source list + to be AAVSO standard names, if available for that filter. + + object_of_interest : str + Name of the object of interest. The only files on which photometry + will be done are those whose header contains the keyword ``OBJECT`` + whose value is ``object_of_interest``. + + sourcelist : str + Name of a file with a table of extracted sources with positions in terms of + pixel coordinates OR RA/Dec coordinates. If both positions provided, + pixel coordinates will be used. For RA/Dec coordinates to be used, `ccd_image` + must have a valid WCS. + """ + + model_config = MODEL_DEFAULT_CONFIGURATION + + camera: Camera + observatory: Observatory + photometry_apertures: PhotometryApertures + photometry_options: PhotometryOptions + passband_map: PassbandMap | None + object_of_interest: str + source_list_file: str + # = Field( + # filter_pattern=["*.ecsv", "*.csv"], default="" + # ) + + class Exoplanet(BaseModelWithTableRep): """ Create an object representing an Exoplanet. diff --git a/stellarphot/settings/tests/test_models.py b/stellarphot/settings/tests/test_models.py index 643aa1f2..f6ada77e 100644 --- a/stellarphot/settings/tests/test_models.py +++ b/stellarphot/settings/tests/test_models.py @@ -12,8 +12,10 @@ Camera, Exoplanet, Observatory, + PassbandMap, PhotometryApertures, PhotometryOptions, + PhotometrySettings, ) DEFAULT_APERTURE_SETTINGS = dict(radius=5, gap=10, annulus_width=15, fwhm=3.2) @@ -48,10 +50,9 @@ TESS_telescope_code="tess test", ) - # The first setting here is required, the rest are optional. The optional # settings below are different than the defaults in the model definition. -DEFAULT_PHOTOMETRY_SETTINGS = dict( +DEFAULT_PHOTOMETRY_OPTIONS = dict( shift_tolerance=5, use_coordinates="pixel", include_dig_noise=False, @@ -62,6 +63,24 @@ console_log=False, ) +DEFAULT_PASSBAND_MAP = dict( + yours_to_aavso=dict( + V="V", + B="B", + rp="SR", + ) +) + +DEFAULT_PHOTOMETRY_SETTINGS = dict( + camera=Camera(**TEST_CAMERA_VALUES), + observatory=Observatory(**DEFAULT_OBSERVATORY_SETTINGS), + photometry_apertures=PhotometryApertures(**DEFAULT_APERTURE_SETTINGS), + photometry_options=PhotometryOptions(**DEFAULT_PHOTOMETRY_OPTIONS), + passband_map=PassbandMap(**DEFAULT_PASSBAND_MAP), + object_of_interest="test", + source_list_file="test.ecsv", +) + @pytest.mark.parametrize( "model,settings", @@ -70,7 +89,9 @@ [PhotometryApertures, DEFAULT_APERTURE_SETTINGS], [Exoplanet, DEFAULT_EXOPLANET_SETTINGS], [Observatory, DEFAULT_OBSERVATORY_SETTINGS], - [PhotometryOptions, DEFAULT_PHOTOMETRY_SETTINGS], + [PhotometryOptions, DEFAULT_PHOTOMETRY_OPTIONS], + [PassbandMap, DEFAULT_PASSBAND_MAP], + [PhotometrySettings, DEFAULT_PHOTOMETRY_SETTINGS], ], ) class TestModelAgnosticActions: @@ -306,7 +327,7 @@ def test_observatory_lat_long_as_float(): def test_photometry_settings_negative_shift_tolerance(): # Check that a negative shift tolerance raises an error - settings = dict(DEFAULT_PHOTOMETRY_SETTINGS) + settings = dict(DEFAULT_PHOTOMETRY_OPTIONS) settings["shift_tolerance"] = -1 with pytest.raises( ValidationError, match="Input should be greater than or equal to 0" From 603b824bbe9c50f03515450c86a62675c7d13c8c Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 12 Feb 2024 07:55:22 -0600 Subject: [PATCH 03/15] Add draft photometry class --- stellarphot/photometry/photometry.py | 97 ++++++------------- .../photometry/tests/test_photometry.py | 66 +++++++++++-- 2 files changed, 90 insertions(+), 73 deletions(-) diff --git a/stellarphot/photometry/photometry.py b/stellarphot/photometry/photometry.py index 5f0da471..63a5ae5f 100644 --- a/stellarphot/photometry/photometry.py +++ b/stellarphot/photometry/photometry.py @@ -14,6 +14,7 @@ from ccdproc import ImageFileCollection from photutils.aperture import CircularAnnulus, CircularAperture, aperture_photometry from photutils.centroids import centroid_sources +from pydantic import BaseModel, validate_call from scipy.spatial.distance import cdist from stellarphot import PhotometryData, SourceListData @@ -22,11 +23,13 @@ Observatory, PhotometryApertures, PhotometryOptions, + PhotometrySettings, ) from .source_detection import compute_fwhm __all__ = [ + "AperturePhotometry", "single_image_photometry", "multi_image_photometry", "faster_sigma_clip_stats", @@ -39,14 +42,22 @@ EXPOSURE_KEYWORDS = ["EXPOSURE", "EXPTIME", "TELAPSE", "ELAPTIME", "ONTIME", "LIVETIME"] +class AperturePhotometry(BaseModel): + """Class to perform aperture photometry on one or more images""" + + settings: PhotometrySettings + + @validate_call + def __call__( + self, + file_or_directory: str | Path, + ) -> PhotometryData: + pass + + def single_image_photometry( ccd_image, - sourcelist, - camera, - observatory, - photometry_apertures, - photometry_options, - passband_map=None, + photometry_settings, fname=None, logline="single_image_photometry:", ): @@ -73,25 +84,6 @@ def single_image_photometry( RA/Dec coordinates. If both positions provided, pixel coordinates will be used. For RA/Dec coordinates to be used, `ccd_image` must have a valid WCS. - camera : `stellarphot.settings.Camera` - Camera object which has gain, read noise and dark current set. - - observatory : `stellarphot.settings.Observatory` - Observatory information. Used for calculating the BJD. - - photometry_apertures : `stellarphot.settings.PhotometryApertures` - Radius, inner and outer annulus radii settings and FWHM. - - photometry_options : `stellarphot.settings.PhotometryOptions` - Several options for the details of performing the photometry. See the - documentation for `~stellarphot.settings.PhotometryOptions` for details. - - passband_map: dict, optional (Default: None) - A dictionary containing instrumental passband names as keys and - AAVSO passband names as values. This is used to rename the passband - entries in the output photometry table to be AAVSO standard versus - whatever is in the source list. - fname : str, optional (Default: None) Name of the image file on which photometry is being performed. @@ -127,6 +119,13 @@ def single_image_photometry( the `use_coordinates` parameter should be set to "sky". """ + sourcelist = SourceListData.read(photometry_settings.source_list_file) + camera = photometry_settings.camera + observatory = photometry_settings.observatory + photometry_apertures = photometry_settings.photometry_apertures + photometry_options = photometry_settings.photometry_options + passband_map = photometry_settings.passband_map + # Check that the input parameters are valid if not isinstance(ccd_image, CCDData): raise TypeError( @@ -539,13 +538,9 @@ def single_image_photometry( def multi_image_photometry( directory_with_images, + photometry_settings, object_of_interest, sourcelist, - camera, - observatory, - photometry_apertures, - photometry_options, - passband_map=None, reject_unmatched=True, ): """ @@ -562,35 +557,6 @@ def multi_image_photometry( EXPTIME, TELAPSE, ELAPTIME, ONTIME, or LIVETIME), and FILTER. If AIRMASS is available it will be added to `phot_table`. - object_of_interest : str - Name of the object of interest. The only files on which photometry - will be done are those whose header contains the keyword ``OBJECT`` - whose value is ``object_of_interest``. - - sourcelist : `stellarphot.SourceList` - Table of extracted sources with positions in terms of pixel coordinates and - RA/Dec coordinates. The x/y coordinates in the sourcelist will be ignored, - WCS derived x/y positions based on sky positions will be computed each image. - - camera : `stellarphot.settings.Camera` - Camera object which has gain, read noise and dark current set. - - observatory : `stellarphot.settings.Observatory` - Observatory information. Used for calculating the BJD. - - photometry_apertures : `stellarphot.settings.PhotometryApertures` - Radius, inner and outer annulus radii settings and FWHM. - - photometry_options : `stellarphot.settings.PhotometryOptions` - Several options for the details of performing the photometry. See the - documentation for `~stellarphot.settings.PhotometryOptions` for details. - - passband_map: dict, optional (Default: None) - A dictionary containing instrumental passband names as keys and - AAVSO passband names as values. This is used to rename the passband - entries in the output photometry table to be AAVSO standard versus - whatever is in the source list. - reject_unmatched : bool, optional (Default: True) If ``True``, any sources that are not detected on all the images are rejected. If you are interested in a source that can intermittently @@ -626,8 +592,8 @@ def multi_image_photometry( for handler in multilogger.handlers[:]: multilogger.removeHandler(handler) - logfile = photometry_options.logfile - console_log = photometry_options.console_log + logfile = photometry_settings.photometry_options.logfile + console_log = photometry_settings.photometry_options.console_log if logfile is not None: # Keep original name without path @@ -674,6 +640,8 @@ def multi_image_photometry( # Suppress the FITSFixedWarning that is raised when reading a FITS file header warnings.filterwarnings("ignore", category=FITSFixedWarning) + object_of_interest = photometry_settings.object_of_interest + # Process all the files for this_ccd, this_fname in ifc.ccds(object=object_of_interest, return_fname=True): multilogger.info(f"multi_image_photometry: Processing image {this_fname}") @@ -686,12 +654,7 @@ def multi_image_photometry( multilogger.info(" Calling single_image_photometry ...") this_phot, this_missing_sources = single_image_photometry( this_ccd, - sourcelist, - camera, - observatory, - photometry_apertures, - photometry_options, - passband_map=passband_map, + photometry_settings, fname=this_fname, logline=" >", ) diff --git a/stellarphot/photometry/tests/test_photometry.py b/stellarphot/photometry/tests/test_photometry.py index 0432c56e..b472949c 100644 --- a/stellarphot/photometry/tests/test_photometry.py +++ b/stellarphot/photometry/tests/test_photometry.py @@ -24,6 +24,7 @@ Observatory, PhotometryApertures, PhotometryOptions, + PhotometrySettings, ) # Constants for the tests @@ -79,6 +80,51 @@ fwhm=FAKE_CCD_IMAGE.sources["x_stddev"].mean(), ) +# Passband map for the tests +PASSBAND_MAP = { + "B": "B", + "rp": "SR", +} + + +# class TestAperturePhotometry: +# @staticmethod +# def create_source_list(): +# # This has X, Y +# sources = FAKE_CCD_IMAGE.sources.copy() + +# # Rename to match the expected names +# sources.rename_column("x_mean", "xcenter") +# sources.rename_column("y_mean", "ycenter") + +# # Calculate RA/Dec from image WCS +# coords = FAKE_CCD_IMAGE.wcs.pixel_to_world( +# sources["xcenter"], sources["ycenter"] +# ) +# sources["ra"] = coords.ra +# sources["dec"] = coords.dec +# sources["star_id"] = list(range(len(sources))) +# sources["xcenter"] = sources["xcenter"] * u.pixel +# sources["ycenter"] = sources["ycenter"] * u.pixel + +# return SourceListData(input_data=sources, colname_map=None) + +# def test_create_aperture_photometry(self): +# source_list = self.create_source_list() +# # Create an AperturePhotometry object +# ap_phot = AperturePhotometry( +# # source_list, +# camera=FAKE_CAMERA, +# observatory=FAKE_OBS, +# photometry_apertures=DEFAULT_PHOTOMETRY_APERTURES, +# photometry_options=PHOTOMETRY_OPTIONS, +# passband_map=PASSBAND_MAP, +# ) + +# # Check that the object was created correctly +# assert ap_phot.camera is FAKE_CAMERA +# assert ap_phot.observatory is FAKE_OBS + def test_calc_noise_defaults(): # If we put in nothing we should get an error about is missing camera @@ -269,7 +315,7 @@ def test_find_too_close(): # The True case below is a regression test for #157 @pytest.mark.parametrize("int_data", [True, False]) -def test_aperture_photometry_no_outlier_rejection(int_data): +def test_aperture_photometry_no_outlier_rejection(int_data, tmp_path): fake_CCDimage = deepcopy(FAKE_CCD_IMAGE) found_sources = source_detection( @@ -297,13 +343,21 @@ def test_aperture_photometry_no_outlier_rejection(int_data): phot_options.reject_too_close = False phot_options.include_dig_noise = True + source_list_file = tmp_path / "source_list.ecsv" + found_sources.write(source_list_file, format="ascii.ecsv", overwrite=True) + + photometry_settings = PhotometrySettings( + source_list_file=str(source_list_file), + camera=FAKE_CAMERA, + observatory=FAKE_OBS, + photometry_apertures=DEFAULT_PHOTOMETRY_APERTURES, + photometry_options=phot_options, + passband_map=None, + object_of_interest="Test Object", + ) phot, missing_sources = single_image_photometry( fake_CCDimage, - found_sources, - FAKE_CAMERA, - FAKE_OBS, - DEFAULT_PHOTOMETRY_APERTURES, - phot_options, + photometry_settings, ) phot.sort("aperture_sum") From 4aae720f638f8bfe92df04357259cb92d382e707 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Wed, 14 Feb 2024 09:01:00 -0600 Subject: [PATCH 04/15] Allow exposure key to be set --- stellarphot/photometry/photometry.py | 18 +++-- .../photometry/tests/test_photometry.py | 72 +++++++++++-------- 2 files changed, 55 insertions(+), 35 deletions(-) diff --git a/stellarphot/photometry/photometry.py b/stellarphot/photometry/photometry.py index 63a5ae5f..4955ea28 100644 --- a/stellarphot/photometry/photometry.py +++ b/stellarphot/photometry/photometry.py @@ -79,10 +79,11 @@ def single_image_photometry( This image must also have a WCS header associated with it if you want to use sky positions as inputs. - sourcelist : `stellarphot.SourceList` - Table of extracted sources with positions in terms of pixel coordinates OR - RA/Dec coordinates. If both positions provided, pixel coordinates will be used. - For RA/Dec coordinates to be used, `ccd_image` must have a valid WCS. + photometry_settings : `stellarphot.settings.PhotometrySettings` + Photometry settings to use for the photometry. This includes the camera, + observatory, the aperture and annulus radii to use for the photometry, a map + of passbands from your filters to AAVSO filter names, and options for the + photometry. See `stellarphot.settings.PhotometrySettings` for more information. fname : str, optional (Default: None) Name of the image file on which photometry is being performed. @@ -539,8 +540,6 @@ def single_image_photometry( def multi_image_photometry( directory_with_images, photometry_settings, - object_of_interest, - sourcelist, reject_unmatched=True, ): """ @@ -557,6 +556,12 @@ def multi_image_photometry( EXPTIME, TELAPSE, ELAPTIME, ONTIME, or LIVETIME), and FILTER. If AIRMASS is available it will be added to `phot_table`. + photometry_settings : `stellarphot.settings.PhotometrySettings` + Photometry settings to use for the photometry. This includes the camera, + observatory, the aperture and annulus radii to use for the photometry, a map + of passbands from your filters to AAVSO filter names, and options for the + photometry. See `stellarphot.settings.PhotometrySettings` for more information. + reject_unmatched : bool, optional (Default: True) If ``True``, any sources that are not detected on all the images are rejected. If you are interested in a source that can intermittently @@ -573,6 +578,7 @@ def multi_image_photometry( or to each other for successful aperture photometry. """ + sourcelist = SourceListData.read(photometry_settings.source_list_file) # Initialize lists to track all PhotometryData objects and all dropped sources phots = [] diff --git a/stellarphot/photometry/tests/test_photometry.py b/stellarphot/photometry/tests/test_photometry.py index b472949c..d736c57d 100644 --- a/stellarphot/photometry/tests/test_photometry.py +++ b/stellarphot/photometry/tests/test_photometry.py @@ -86,6 +86,15 @@ "rp": "SR", } +DEFAULT_PHOTOMETRY_SETTINGS = PhotometrySettings( + camera=FAKE_CAMERA, + observatory=FAKE_OBS, + photometry_apertures=DEFAULT_PHOTOMETRY_APERTURES, + photometry_options=PHOTOMETRY_OPTIONS, + passband_map=None, + object_of_interest="Test Object", + source_list_file="", +) # class TestAperturePhotometry: # @staticmethod @@ -396,7 +405,7 @@ def test_aperture_photometry_no_outlier_rejection(int_data, tmp_path): @pytest.mark.parametrize("reject", [True, False]) -def test_aperture_photometry_with_outlier_rejection(reject): +def test_aperture_photometry_with_outlier_rejection(reject, tmp_path): """ Insert some really large pixel values in the annulus and check that the photometry is correct when outliers are rejected and is @@ -415,6 +424,8 @@ def test_aperture_photometry_with_outlier_rejection(reject): found_sources = source_detection( fake_CCDimage, fwhm=sources["x_stddev"].mean(), threshold=10 ) + source_list_file = tmp_path / "source_list.ecsv" + found_sources.write(source_list_file, format="ascii.ecsv", overwrite=True) # Add some large pixel values to the annulus for each source. # adding these moves the average pixel value by quite a bit, @@ -434,13 +445,13 @@ def test_aperture_photometry_with_outlier_rejection(reject): phot_options.reject_too_close = False phot_options.include_dig_noise = True + photometry_settings = DEFAULT_PHOTOMETRY_SETTINGS.model_copy() + photometry_settings.source_list_file = str(source_list_file) + photometry_settings.photometry_options = phot_options + phot, missing_sources = single_image_photometry( fake_CCDimage, - found_sources, - FAKE_CAMERA, - FAKE_OBS, - aperture_settings, - phot_options, + photometry_settings, ) phot.sort("aperture_sum") @@ -531,6 +542,9 @@ def test_photometry_on_directory(coords): fake_images[0], fwhm=fake_images[0].sources["x_stddev"].mean(), threshold=10 ) + source_list_file = Path(temp_dir) / "source_list.ecsv" + found_sources.write(source_list_file, format="ascii.ecsv", overwrite=True) + # Make a copy of photometry options phot_options = PhotometryOptions(**PHOTOMETRY_OPTIONS.model_dump()) @@ -541,19 +555,17 @@ def test_photometry_on_directory(coords): phot_options.reject_background_outliers = True phot_options.fwhm_by_fit = True + photometry_settings = DEFAULT_PHOTOMETRY_SETTINGS.model_copy() + photometry_settings.source_list_file = str(source_list_file) + photometry_settings.photometry_options = phot_options + photometry_settings.object_of_interest = object_name with warnings.catch_warnings(): warnings.filterwarnings( "ignore", message="Cannot merge meta key", category=MergeConflictWarning ) phot_data = multi_image_photometry( temp_dir, - object_name, - found_sources, - FAKE_CAMERA, - FAKE_OBS, - aperture_settings, - phot_options, - passband_map=None, + photometry_settings, ) # For following assertion to be true, rad must be small enough that @@ -638,7 +650,6 @@ def test_photometry_on_directory_with_no_ra_dec(): image.write(temp_file_names[i]) object_name = fake_images[0].header["OBJECT"] - aperture_settings = DEFAULT_PHOTOMETRY_APERTURES # Generate the sourcelist found_sources = source_detection( @@ -648,6 +659,9 @@ def test_photometry_on_directory_with_no_ra_dec(): # Damage the sourcelist by removing the ra and dec columns found_sources.drop_ra_dec() + source_list_file = Path(temp_dir) / "source_list.ecsv" + found_sources.write(source_list_file, format="ascii.ecsv", overwrite=True) + phot_options = PhotometryOptions(**PHOTOMETRY_OPTIONS.model_dump()) # Modify options to match test before we used phot_options @@ -657,16 +671,15 @@ def test_photometry_on_directory_with_no_ra_dec(): phot_options.reject_background_outliers = True phot_options.fwhm_by_fit = True + photometry_settings = DEFAULT_PHOTOMETRY_SETTINGS.model_copy() + photometry_settings.source_list_file = str(source_list_file) + photometry_settings.photometry_options = phot_options + photometry_settings.object_of_interest = object_name + with pytest.raises(ValueError): multi_image_photometry( temp_dir, - object_name, - found_sources, - FAKE_CAMERA, - FAKE_OBS, - aperture_settings, - phot_options, - passband_map=None, + photometry_settings, ) @@ -691,7 +704,6 @@ def test_photometry_on_directory_with_bad_fits(): image.write(temp_file_names[i]) object_name = fake_images[0].header["OBJECT"] - aperture_settings = DEFAULT_PHOTOMETRY_APERTURES # Generate the sourcelist with RA/Dec information from a clean image found_sources = source_detection( @@ -700,6 +712,9 @@ def test_photometry_on_directory_with_bad_fits(): threshold=10, ) + source_list_file = Path(temp_dir) / "source_list.ecsv" + found_sources.write(source_list_file, format="ascii.ecsv", overwrite=True) + phot_options = PhotometryOptions(**PHOTOMETRY_OPTIONS.model_dump()) # Modify options to match test before we used phot_options @@ -709,15 +724,14 @@ def test_photometry_on_directory_with_bad_fits(): phot_options.reject_background_outliers = True phot_options.fwhm_by_fit = True + photometry_settings = DEFAULT_PHOTOMETRY_SETTINGS.model_copy() + photometry_settings.source_list_file = str(source_list_file) + photometry_settings.photometry_options = phot_options + photometry_settings.object_of_interest = object_name + # Since none of the images will be valid, it should raise a RuntimeError with pytest.raises(RuntimeError): multi_image_photometry( temp_dir, - object_name, - found_sources, - FAKE_CAMERA, - FAKE_OBS, - aperture_settings, - phot_options, - passband_map=None, + photometry_settings, ) From 78ec8dc8175247f2f04382918974a20a29ad0ab5 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Thu, 15 Feb 2024 09:11:24 -0600 Subject: [PATCH 05/15] Restore PhotometryFileSettings The interactive notebooks depend on having this at the moment. --- stellarphot/settings/models.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/stellarphot/settings/models.py b/stellarphot/settings/models.py index 81e43999..3bd8907b 100644 --- a/stellarphot/settings/models.py +++ b/stellarphot/settings/models.py @@ -1,5 +1,6 @@ # Objects that contains the user settings for the program. +from pathlib import Path from typing import Annotated, Literal from astropy.coordinates import EarthLocation, Latitude, Longitude, SkyCoord @@ -31,6 +32,7 @@ "Camera", "PassbandMap", "PhotometryApertures", + "PhotometryFileSettings", "PhotometrySettings", "PhotometryOptions", "Exoplanet", @@ -342,6 +344,24 @@ def outer_annulus(self): return self.inner_annulus + self.annulus_width +class PhotometryFileSettings(BaseModelWithTableRep): + """ + An evolutionary step on the way to having a monolithic set of photometry settings. + """ + + model_config = MODEL_DEFAULT_CONFIGURATION + + image_folder: Path = Field( + show_only_dirs=True, + default="", + description="Folder containing the calibrated images", + ) + aperture_settings_file: Path = Field(filter_pattern="*.json", default="") + aperture_locations_file: Path = Field( + filter_pattern=["*.ecsv", "*.csv"], default="" + ) + + class Observatory(BaseModelWithTableRep): """ Class to represent an observatory. From 4cc3cf50688c67a907d855acd9daa827f7dc01f3 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Thu, 15 Feb 2024 09:50:13 -0600 Subject: [PATCH 06/15] Fix a doc typo --- stellarphot/settings/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stellarphot/settings/models.py b/stellarphot/settings/models.py index 3bd8907b..bb2a7987 100644 --- a/stellarphot/settings/models.py +++ b/stellarphot/settings/models.py @@ -538,7 +538,7 @@ class PhotometrySettings(BaseModelWithTableRep): Several options for the details of performing the photometry. See the documentation for `~stellarphot.settings.PhotometryOptions` for details. - passband_map: `stellarphot.settings.PassbandMap` + passband_map: `stellarphot.settings.PassbandMap`, optional A dictionary containing instrumental passband names as keys and AAVSO passband names as values. This is used to rename the passband entries in the output photometry table from what is in the source list From fd40ebe4e6bc9f60ceb867071299626e3767cb52 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Thu, 15 Feb 2024 09:50:26 -0600 Subject: [PATCH 07/15] xfail a couple of tests for now --- stellarphot/settings/tests/test_models.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/stellarphot/settings/tests/test_models.py b/stellarphot/settings/tests/test_models.py index f6ada77e..8b9927b6 100644 --- a/stellarphot/settings/tests/test_models.py +++ b/stellarphot/settings/tests/test_models.py @@ -137,7 +137,11 @@ def test_model_table_round_trip(self, model, settings, tmp_path): new_table = Table.read(table_path) assert new_table.meta["model"] == mod - def test_aperture_settings_ui_generation(self, model, settings): + def test_settings_ui_generation(self, model, settings): + if model == PhotometrySettings or model == PassbandMap: + pytest.xfail( + reason="PassbandMap needs a dict widget -- https://github.com/feder-observatory/stellarphot/issues/274" + ) # Check a few things about the UI generation: # 1) The UI is generated # 2) The UI model matches our input From 8965e42532b62704fede9522278e474bb0f8a930 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Thu, 15 Feb 2024 11:03:12 -0600 Subject: [PATCH 08/15] Move model configuration into base class --- stellarphot/settings/models.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/stellarphot/settings/models.py b/stellarphot/settings/models.py index bb2a7987..694d94b8 100644 --- a/stellarphot/settings/models.py +++ b/stellarphot/settings/models.py @@ -70,6 +70,9 @@ class BaseModelWithTableRep(BaseModel): Class to add to a pydantic model YAML serialization to an Astropy table. """ + # NOTE WELL that this will set the configuration for all subclasses of this + model_config = MODEL_DEFAULT_CONFIGURATION + def __init__(self, *arg, **kwargs): super().__init__(*arg, **kwargs) self.generate_table_representers() @@ -312,8 +315,6 @@ class PhotometryApertures(BaseModelWithTableRep): ... ) """ - model_config = MODEL_DEFAULT_CONFIGURATION - radius: Annotated[ PositiveInt, Field(default=1, json_schema_extra=dict(autoui="ipywidgets.BoundedIntText")), @@ -349,8 +350,6 @@ class PhotometryFileSettings(BaseModelWithTableRep): An evolutionary step on the way to having a monolithic set of photometry settings. """ - model_config = MODEL_DEFAULT_CONFIGURATION - image_folder: Path = Field( show_only_dirs=True, default="", @@ -389,8 +388,6 @@ class Observatory(BaseModelWithTableRep): TESS telescope code. """ - model_config = MODEL_DEFAULT_CONFIGURATION - name: str latitude: Annotated[ Latitude, _UnitQuantTypePydanticAnnotation, BeforeValidator(add_degree_to_float) @@ -500,8 +497,6 @@ class PhotometryOptions(BaseModelWithTableRep): 'sky' """ - model_config = MODEL_DEFAULT_CONFIGURATION - shift_tolerance: NonNegativeFloat use_coordinates: Literal["sky", "pixel"] = "sky" include_dig_noise: bool = True @@ -556,8 +551,6 @@ class PhotometrySettings(BaseModelWithTableRep): must have a valid WCS. """ - model_config = MODEL_DEFAULT_CONFIGURATION - camera: Camera observatory: Observatory photometry_apertures: PhotometryApertures From 9e457e1cffc1662632c50c7230ef6d985c600e51 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Thu, 15 Feb 2024 11:03:49 -0600 Subject: [PATCH 09/15] Rearrange photometry settings --- stellarphot/photometry/photometry.py | 28 +++-- .../photometry/tests/test_photometry.py | 33 ++++-- stellarphot/settings/models.py | 104 ++++++++++-------- stellarphot/settings/tests/test_models.py | 28 +++-- 4 files changed, 119 insertions(+), 74 deletions(-) diff --git a/stellarphot/photometry/photometry.py b/stellarphot/photometry/photometry.py index 4955ea28..c9624e1a 100644 --- a/stellarphot/photometry/photometry.py +++ b/stellarphot/photometry/photometry.py @@ -120,12 +120,16 @@ def single_image_photometry( the `use_coordinates` parameter should be set to "sky". """ - sourcelist = SourceListData.read(photometry_settings.source_list_file) + sourcelist = SourceListData.read( + photometry_settings.source_locations.source_list_file + ) camera = photometry_settings.camera observatory = photometry_settings.observatory photometry_apertures = photometry_settings.photometry_apertures photometry_options = photometry_settings.photometry_options + logging_options = photometry_settings.logging_settings passband_map = photometry_settings.passband_map + use_coordinates = photometry_settings.source_locations.use_coordinates # Check that the input parameters are valid if not isinstance(ccd_image, CCDData): @@ -158,8 +162,8 @@ def single_image_photometry( ) # Set up logging - logfile = photometry_options.logfile - console_log = photometry_options.console_log + logfile = logging_options.logfile + console_log = logging_options.console_log logger = logging.getLogger("single_image_photometry") console_format = logging.Formatter("%(message)s") if logger.hasHandlers() is False: @@ -231,7 +235,7 @@ def single_image_photometry( src_cnt = len(sourcelist) # If RA/Dec are available attempt to use them to determine the source positions - if photometry_options.use_coordinates == "sky" and sourcelist.has_ra_dec: + if use_coordinates == "sky" and sourcelist.has_ra_dec: try: imgpos = ccd_image.wcs.world_to_pixel( SkyCoord(ra, dec, unit=u.deg, frame="icrs") @@ -242,7 +246,7 @@ def single_image_photometry( msg = f"{logline} ccd_image must have a valid WCS to use RA/Dec!" logger.warning(msg) return None, None - elif photometry_options.use_coordinates == "sky" and not sourcelist.has_ra_dec: + elif use_coordinates == "sky" and not sourcelist.has_ra_dec: raise ValueError( "use_coordinates='sky' but sourcelist does not have" "RA/Dec coordinates!" ) @@ -313,7 +317,7 @@ def single_image_photometry( # particularly useful is processing multiple images of the same field # and just passing the same sourcelist when calling single_image_photometry # on each image. - if photometry_options.use_coordinates == "sky": + if use_coordinates == "sky": try: xcen, ycen = centroid_sources( ccd_image.data, xs, ys, box_size=2 * photometry_apertures.radius + 1 @@ -331,7 +335,9 @@ def single_image_photometry( # The center really shouldn't move more than about the fwhm, could # rework this in the future to use that instead. - too_much_shift = center_diff > photometry_options.shift_tolerance + too_much_shift = ( + center_diff > photometry_settings.source_locations.shift_tolerance + ) # If the shift is too large, use the WCS-derived positions instead # (these sources are probably too faint for centroiding to work well) @@ -578,7 +584,9 @@ def multi_image_photometry( or to each other for successful aperture photometry. """ - sourcelist = SourceListData.read(photometry_settings.source_list_file) + sourcelist = SourceListData.read( + photometry_settings.source_locations.source_list_file + ) # Initialize lists to track all PhotometryData objects and all dropped sources phots = [] @@ -598,8 +606,8 @@ def multi_image_photometry( for handler in multilogger.handlers[:]: multilogger.removeHandler(handler) - logfile = photometry_settings.photometry_options.logfile - console_log = photometry_settings.photometry_options.console_log + logfile = photometry_settings.logging_settings.logfile + console_log = photometry_settings.logging_settings.console_log if logfile is not None: # Keep original name without path diff --git a/stellarphot/photometry/tests/test_photometry.py b/stellarphot/photometry/tests/test_photometry.py index d736c57d..b028f8ef 100644 --- a/stellarphot/photometry/tests/test_photometry.py +++ b/stellarphot/photometry/tests/test_photometry.py @@ -21,10 +21,12 @@ from stellarphot.photometry.tests.fake_image import FakeCCDImage, shift_FakeCCDImage from stellarphot.settings import ( Camera, + LoggingSettings, Observatory, PhotometryApertures, PhotometryOptions, PhotometrySettings, + SourceLocationSettings, ) # Constants for the tests @@ -37,11 +39,14 @@ # The default coordinate system and shift tolerance to use for the tests COORDS2USE = "pixel" SHIFT_TOLERANCE = 6 -PHOTOMETRY_OPTIONS = PhotometryOptions( +DEFAULT_SOURCE_LOCATIONS = SourceLocationSettings( + source_list_file="", shift_tolerance=SHIFT_TOLERANCE, use_coordinates=COORDS2USE, ) +PHOTOMETRY_OPTIONS = PhotometryOptions() + # A camera with not unreasonable settings FAKE_CAMERA = Camera( data_unit=u.adu, @@ -86,14 +91,17 @@ "rp": "SR", } +DEFAULT_LOGGING_SETTINGS = LoggingSettings() + DEFAULT_PHOTOMETRY_SETTINGS = PhotometrySettings( camera=FAKE_CAMERA, observatory=FAKE_OBS, photometry_apertures=DEFAULT_PHOTOMETRY_APERTURES, + source_locations=DEFAULT_SOURCE_LOCATIONS, photometry_options=PHOTOMETRY_OPTIONS, passband_map=None, + logging_settings=DEFAULT_LOGGING_SETTINGS, object_of_interest="Test Object", - source_list_file="", ) # class TestAperturePhotometry: @@ -355,13 +363,16 @@ def test_aperture_photometry_no_outlier_rejection(int_data, tmp_path): source_list_file = tmp_path / "source_list.ecsv" found_sources.write(source_list_file, format="ascii.ecsv", overwrite=True) + source_locations = DEFAULT_SOURCE_LOCATIONS.model_copy() + source_locations.source_list_file = str(source_list_file) photometry_settings = PhotometrySettings( - source_list_file=str(source_list_file), camera=FAKE_CAMERA, observatory=FAKE_OBS, photometry_apertures=DEFAULT_PHOTOMETRY_APERTURES, + source_locations=source_locations, photometry_options=phot_options, passband_map=None, + logging_settings=DEFAULT_LOGGING_SETTINGS, object_of_interest="Test Object", ) phot, missing_sources = single_image_photometry( @@ -446,7 +457,7 @@ def test_aperture_photometry_with_outlier_rejection(reject, tmp_path): phot_options.include_dig_noise = True photometry_settings = DEFAULT_PHOTOMETRY_SETTINGS.model_copy() - photometry_settings.source_list_file = str(source_list_file) + photometry_settings.source_locations.source_list_file = str(source_list_file) photometry_settings.photometry_options = phot_options phot, missing_sources = single_image_photometry( @@ -549,16 +560,16 @@ def test_photometry_on_directory(coords): phot_options = PhotometryOptions(**PHOTOMETRY_OPTIONS.model_dump()) # Modify options to match test before we used phot_options - phot_options.use_coordinates = coords phot_options.include_dig_noise = True phot_options.reject_too_close = True phot_options.reject_background_outliers = True phot_options.fwhm_by_fit = True photometry_settings = DEFAULT_PHOTOMETRY_SETTINGS.model_copy() - photometry_settings.source_list_file = str(source_list_file) photometry_settings.photometry_options = phot_options photometry_settings.object_of_interest = object_name + photometry_settings.source_locations.use_coordinates = coords + photometry_settings.source_locations.source_list_file = str(source_list_file) with warnings.catch_warnings(): warnings.filterwarnings( "ignore", message="Cannot merge meta key", category=MergeConflictWarning @@ -665,16 +676,17 @@ def test_photometry_on_directory_with_no_ra_dec(): phot_options = PhotometryOptions(**PHOTOMETRY_OPTIONS.model_dump()) # Modify options to match test before we used phot_options - phot_options.use_coordinates = "sky" # This was implicit in the old default phot_options.include_dig_noise = True phot_options.reject_too_close = True phot_options.reject_background_outliers = True phot_options.fwhm_by_fit = True photometry_settings = DEFAULT_PHOTOMETRY_SETTINGS.model_copy() - photometry_settings.source_list_file = str(source_list_file) photometry_settings.photometry_options = phot_options photometry_settings.object_of_interest = object_name + photometry_settings.source_locations.source_list_file = str(source_list_file) + # The setting below was implicit in the old default + photometry_settings.source_locations.use_coordinates = "sky" with pytest.raises(ValueError): multi_image_photometry( @@ -718,16 +730,17 @@ def test_photometry_on_directory_with_bad_fits(): phot_options = PhotometryOptions(**PHOTOMETRY_OPTIONS.model_dump()) # Modify options to match test before we used phot_options - phot_options.use_coordinates = "sky" # This was implicit in the old default phot_options.include_dig_noise = True phot_options.reject_too_close = True phot_options.reject_background_outliers = True phot_options.fwhm_by_fit = True photometry_settings = DEFAULT_PHOTOMETRY_SETTINGS.model_copy() - photometry_settings.source_list_file = str(source_list_file) photometry_settings.photometry_options = phot_options photometry_settings.object_of_interest = object_name + photometry_settings.source_locations.source_list_file = str(source_list_file) + # The settings below was implicit in the old default + photometry_settings.source_locations.use_coordinates = "sky" # Since none of the images will be valid, it should raise a RuntimeError with pytest.raises(RuntimeError): diff --git a/stellarphot/settings/models.py b/stellarphot/settings/models.py index 694d94b8..e9a33f0c 100644 --- a/stellarphot/settings/models.py +++ b/stellarphot/settings/models.py @@ -30,6 +30,7 @@ __all__ = [ "Camera", + "LoggingSettings", "PassbandMap", "PhotometryApertures", "PhotometryFileSettings", @@ -37,6 +38,7 @@ "PhotometryOptions", "Exoplanet", "Observatory", + "SourceLocationSettings", ] # Most models should use the default configuration, but it can be customized if needed. @@ -411,13 +413,25 @@ def earth_location(self): ) -class PhotometryOptions(BaseModelWithTableRep): +class SourceLocationSettings(BaseModelWithTableRep): """ - Options for performing photometry. + Settings for the location of the source list and the image files. Parameters ---------- - shift_tolerance : `pydantic.NonNegativeFloat` + source_list_file : str + Name of a file with a table of extracted sources with positions in terms of + pixel coordinates OR RA/Dec coordinates. If both positions provided, + the one that will be used is determined by `use_coordinates`. For RA/Dec + coordinates to be used, `ccd_image` must have a valid WCS. + + use_coordinates : `typing.Literal["sky", "pixel"]`, optional + If ``'pixel'``, use the x/y positions in the sourcelist for + performing aperture photometry. If ``'sky'``, use the ra/dec + positions in the sourcelist and the WCS of the `ccd_image` to + compute the x/y positions on the image. + + shift_tolerance : `pydantic.NonNegativeFloat`, optional Since source positions need to be computed on each image using the sky position and WCS, the computed x/y positions are refined afterward by centroiding the sources. This setting controls @@ -425,13 +439,19 @@ class PhotometryOptions(BaseModelWithTableRep): positions and the refined positions, in pixels. The expected shift shift should not be more than the FWHM, so a measured FWHM might be a good value to provide here. + """ - use_coordinates : `typing.Literal["sky", "pixel"]` - If ``'pixel'``, use the x/y positions in the sourcelist for - performing aperture photometry. If ``'sky'``, use the ra/dec - positions in the sourcelist and the WCS of the `ccd_image` to - compute the x/y positions on the image. + source_list_file: str + use_coordinates: Literal["sky", "pixel"] = "sky" + shift_tolerance: NonNegativeFloat = 5.0 + + +class PhotometryOptions(BaseModelWithTableRep): + """ + Options for performing photometry. + Parameters + ---------- include_dig_noise : bool, optional (Default: True) If ``True``, include the digitization noise in the calculation of the noise for each observation. If ``False``, only the Poisson noise from @@ -450,61 +470,39 @@ class PhotometryOptions(BaseModelWithTableRep): the star. If ``False``, the FWHM will be calculated by finding the second order moments of the light distribution. Default is ``True``. - logfile : str, optional (Default: None) - Name of the file to which log messages should be written. It will - be created in the `directory_with_images` directory. If None, - all messages are logged to stdout. - - console_log: bool, optional (Default: True) - If ``True`` and `logfile` is set, log messages will also be written to - stdout. If ``False``, log messages will not be written to stdout - if `logfile` is set. - Examples -------- The only option that must be set explicitly is the `shift_tolerance`: >>> from stellarphot.settings import PhotometryOptions - >>> photometry_options = PhotometryOptions(shift_tolerance=5.2) + >>> photometry_options = PhotometryOptions() >>> photometry_options - PhotometryOptions(shift_tolerance=5.2, use_coordinates='sky', - include_dig_noise=True, reject_too_close=True, reject_background_outliers=True, - fwhm_by_fit=True, logfile=None, console_log=True) - + PhotometryOptions(include_dig_noise=True, reject_too_close=True,... You can also set the other options explicitly when you create the options: >>> photometry_options = PhotometryOptions( - ... shift_tolerance=5.2, - ... use_coordinates="pixel", ... include_dig_noise=True, ... reject_too_close=False, ... reject_background_outliers=True, ... fwhm_by_fit=True, - ... logfile=None, - ... console_log=True ... ) >>> photometry_options - PhotometryOptions(shift_tolerance=5.2, use_coordinates='pixel', - include_dig_noise=True, reject_too_close=False, reject_background_outliers=True, - fwhm_by_fit=True, logfile=None, console_log=True) + PhotometryOptions(include_dig_noise=True, reject_too_close=False,... + reject_background_outliers=True, fwhm_by_fit=True) You can also change individual options after the object is created: - >>> photometry_options.use_coordinates = "sky" - >>> photometry_options.use_coordinates - 'sky' + >>> photometry_options.reject_background_outliers = False + >>> photometry_options.reject_background_outliers + False """ - shift_tolerance: NonNegativeFloat - use_coordinates: Literal["sky", "pixel"] = "sky" include_dig_noise: bool = True reject_too_close: bool = True reject_background_outliers: bool = True fwhm_by_fit: bool = True - logfile: str | None = None - console_log: bool = True class PassbandMap(BaseModelWithTableRep): @@ -513,6 +511,27 @@ class PassbandMap(BaseModelWithTableRep): yours_to_aavso: dict[str, str] +class LoggingSettings(BaseModelWithTableRep): + """ + Settings for logging. + + Parameters + ---------- + logfile : str, optional (Default: None) + Name of the file to which log messages should be written. It will + be created in the `directory_with_images` directory. If None, + all messages are logged to stdout. + + console_log: bool, optional (Default: True) + If ``True`` and `logfile` is set, log messages will also be written to + stdout. If ``False``, log messages will not be written to stdout + if `logfile` is set. + """ + + logfile: str | None = None + console_log: bool = True + + class PhotometrySettings(BaseModelWithTableRep): """ Settings for performing aperture photometry. @@ -544,23 +563,16 @@ class PhotometrySettings(BaseModelWithTableRep): will be done are those whose header contains the keyword ``OBJECT`` whose value is ``object_of_interest``. - sourcelist : str - Name of a file with a table of extracted sources with positions in terms of - pixel coordinates OR RA/Dec coordinates. If both positions provided, - pixel coordinates will be used. For RA/Dec coordinates to be used, `ccd_image` - must have a valid WCS. """ camera: Camera observatory: Observatory photometry_apertures: PhotometryApertures + source_locations: SourceLocationSettings photometry_options: PhotometryOptions passband_map: PassbandMap | None + logging_settings: LoggingSettings object_of_interest: str - source_list_file: str - # = Field( - # filter_pattern=["*.ecsv", "*.csv"], default="" - # ) class Exoplanet(BaseModelWithTableRep): diff --git a/stellarphot/settings/tests/test_models.py b/stellarphot/settings/tests/test_models.py index 8b9927b6..903793c9 100644 --- a/stellarphot/settings/tests/test_models.py +++ b/stellarphot/settings/tests/test_models.py @@ -11,11 +11,13 @@ from stellarphot.settings.models import ( Camera, Exoplanet, + LoggingSettings, Observatory, PassbandMap, PhotometryApertures, PhotometryOptions, PhotometrySettings, + SourceLocationSettings, ) DEFAULT_APERTURE_SETTINGS = dict(radius=5, gap=10, annulus_width=15, fwhm=3.2) @@ -53,14 +55,10 @@ # The first setting here is required, the rest are optional. The optional # settings below are different than the defaults in the model definition. DEFAULT_PHOTOMETRY_OPTIONS = dict( - shift_tolerance=5, - use_coordinates="pixel", include_dig_noise=False, reject_too_close=False, reject_background_outliers=False, fwhm_by_fit=False, - logfile="test.log", - console_log=False, ) DEFAULT_PASSBAND_MAP = dict( @@ -71,14 +69,26 @@ ) ) +DEFAULT_LOGGING_SETTINGS = dict( + logfile="test.log", + console_log=False, +) + +DEFAULT_SOURCE_LOCATION_SETTINGS = dict( + shift_tolerance=5, + source_list_file="test.ecsv", + use_coordinates="pixel", +) + DEFAULT_PHOTOMETRY_SETTINGS = dict( camera=Camera(**TEST_CAMERA_VALUES), observatory=Observatory(**DEFAULT_OBSERVATORY_SETTINGS), photometry_apertures=PhotometryApertures(**DEFAULT_APERTURE_SETTINGS), + source_locations=SourceLocationSettings(**DEFAULT_SOURCE_LOCATION_SETTINGS), photometry_options=PhotometryOptions(**DEFAULT_PHOTOMETRY_OPTIONS), passband_map=PassbandMap(**DEFAULT_PASSBAND_MAP), + logging_settings=LoggingSettings(**DEFAULT_LOGGING_SETTINGS), object_of_interest="test", - source_list_file="test.ecsv", ) @@ -92,6 +102,8 @@ [PhotometryOptions, DEFAULT_PHOTOMETRY_OPTIONS], [PassbandMap, DEFAULT_PASSBAND_MAP], [PhotometrySettings, DEFAULT_PHOTOMETRY_SETTINGS], + [LoggingSettings, DEFAULT_LOGGING_SETTINGS], + [SourceLocationSettings, DEFAULT_SOURCE_LOCATION_SETTINGS], ], ) class TestModelAgnosticActions: @@ -329,14 +341,14 @@ def test_observatory_lat_long_as_float(): assert obs == Observatory(**DEFAULT_OBSERVATORY_SETTINGS) -def test_photometry_settings_negative_shift_tolerance(): +def test_source_locations_negative_shift_tolerance(): # Check that a negative shift tolerance raises an error - settings = dict(DEFAULT_PHOTOMETRY_OPTIONS) + settings = dict(DEFAULT_SOURCE_LOCATION_SETTINGS) settings["shift_tolerance"] = -1 with pytest.raises( ValidationError, match="Input should be greater than or equal to 0" ): - PhotometryOptions(**settings) + SourceLocationSettings(**settings) def test_create_invalid_exoplanet(): From 528e9651fc731ffdb1a0fc7c4723665e3d5e39aa Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Thu, 15 Feb 2024 11:24:57 -0600 Subject: [PATCH 10/15] Docstring improvements --- stellarphot/settings/models.py | 75 +++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/stellarphot/settings/models.py b/stellarphot/settings/models.py index e9a33f0c..8ec0147f 100644 --- a/stellarphot/settings/models.py +++ b/stellarphot/settings/models.py @@ -388,6 +388,34 @@ class Observatory(BaseModelWithTableRep): TESS_telescope_code : str, optional TESS telescope code. + + Examples + -------- + >>> from astropy.coordinates import Latitude, Longitude + >>> from astropy import units as u + >>> from stellarphot.settings import Observatory + >>> observatory = Observatory( + ... name="test observatory", + ... latitude=Latitude(30.0 * u.deg), + ... longitude=Longitude(-100.0 * u.deg), + ... elevation=1000 * u.m, + ... ) + >>> observatory + Observatory(name='test observatory', latitude=, + longitude=, elevation=, AAVSO_code=None, + TESS_telescope_code=None) + >>> # You can also just provide numbers for the latitude and longitude + >>> observatory = Observatory( + ... name="test observatory", + ... latitude=30.0, + ... longitude=-100.0, + ... elevation=1000 * u.m, + ... ) + >>> observatory + Observatory(name='test observatory', latitude=, + longitude=, elevation=, AAVSO_code=None, + TESS_telescope_code=None) + """ name: str @@ -439,6 +467,18 @@ class SourceLocationSettings(BaseModelWithTableRep): positions and the refined positions, in pixels. The expected shift shift should not be more than the FWHM, so a measured FWHM might be a good value to provide here. + + Examples + -------- + >>> from stellarphot.settings import SourceLocationSettings + >>> source_location_settings = SourceLocationSettings( + ... source_list_file="source_list.ecsv", + ... use_coordinates="sky", + ... shift_tolerance=5.0 + ... ) + >>> source_location_settings + SourceLocationSettings(source_list_file='source_list.ecsv', use_coordinates='sky', + shift_tolerance=5.0) """ source_list_file: str @@ -506,7 +546,24 @@ class PhotometryOptions(BaseModelWithTableRep): class PassbandMap(BaseModelWithTableRep): - """Class to represent a mapping from one set of filter names to another.""" + """ + Class to represent a mapping from one set of filter names to another. + + Parameters + ---------- + yours_to_aavso : dict[str, str] + A dictionary containing instrumental passband names as keys and + AAVSO passband names as values. This is used to rename the passband + entries in the output photometry table. + + Examples + -------- + >>> from stellarphot.settings import PassbandMap + >>> passband_map = PassbandMap(yours_to_aavso={"B": "B", "V": "V"}) + >>> passband_map + PassbandMap(yours_to_aavso={'B': 'B', 'V': 'V'}) + + """ yours_to_aavso: dict[str, str] @@ -526,6 +583,13 @@ class LoggingSettings(BaseModelWithTableRep): If ``True`` and `logfile` is set, log messages will also be written to stdout. If ``False``, log messages will not be written to stdout if `logfile` is set. + + Examples + -------- + >>> from stellarphot.settings import LoggingSettings + >>> logging_settings = LoggingSettings() + >>> logging_settings + LoggingSettings(logfile=None, console_log=True) """ logfile: str | None = None @@ -548,6 +612,11 @@ class PhotometrySettings(BaseModelWithTableRep): photometry_apertures : `stellarphot.settings.PhotometryApertures` Radius, inner and outer annulus radii settings and FWHM. + source_locations : `stellarphot.settings.SourceLocationSettings` + Settings for the location of the sources for which photometry + will be performed. See the documentation for + `~stellarphot.settings.SourceLocationSettings` for details. + photometry_options : `stellarphot.settings.PhotometryOptions` Several options for the details of performing the photometry. See the documentation for `~stellarphot.settings.PhotometryOptions` for details. @@ -558,6 +627,10 @@ class PhotometrySettings(BaseModelWithTableRep): entries in the output photometry table from what is in the source list to be AAVSO standard names, if available for that filter. + logging_settings : `stellarphot.settings.LoggingSettings` + Settings for logging. See the documentation for + `~stellarphot.settings.LoggingSettings` for details. + object_of_interest : str Name of the object of interest. The only files on which photometry will be done are those whose header contains the keyword ``OBJECT`` From a3c5bb3b54b403287b35e9b02a8044f6ab947748 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Fri, 16 Feb 2024 16:03:12 -0600 Subject: [PATCH 11/15] Use and test photometry object --- stellarphot/photometry/photometry.py | 61 ++- .../photometry/tests/test_photometry.py | 516 ++++++++++++++++-- stellarphot/settings/models.py | 6 - stellarphot/settings/tests/test_models.py | 1 - 4 files changed, 532 insertions(+), 52 deletions(-) diff --git a/stellarphot/photometry/photometry.py b/stellarphot/photometry/photometry.py index c9624e1a..0d421c86 100644 --- a/stellarphot/photometry/photometry.py +++ b/stellarphot/photometry/photometry.py @@ -51,8 +51,58 @@ class AperturePhotometry(BaseModel): def __call__( self, file_or_directory: str | Path, + **kwargs, ) -> PhotometryData: - pass + """ + Perform aperture photometry on a single image or a directory of images. + + Parameters + ---------- + file_or_directory : str or Path + The file or directory on which to perform aperture photometry. + + fname : str, optional (Default: None) + Name of the image file on which photometry is being performed, + *only used for single image photometry*. + + logline : str, optional (Default: "single_image_photometry:") + String to prepend to all log messages, *only used for single image + photometry*. + + reject_unmatched : bool, optional (Default: True) + If ``True``, any sources that are not detected on all the images are + rejected. If you are interested in a source that can intermittently + fall below your detection limits, we suggest setting this to ``False`` + so that all sources detected on each image are reported. *Only used for + multi-image photometry*. + + object_of_interest : str, optional (Default: None) + Name of the object of interest. The only files on which photometry + will be done are those whose header contains the keyword ``OBJECT`` + whose value is ``object_of_interest``. *Only used for multi-image + photometry* to select which files to perform photometry on. + + Returns + ------- + photom_data : `stellarphot.PhotometryData` + Photometry data for all the sources on which aperture photometry was + performed in all the images. This may be a subset of the sources in + the source list if locations were too close to the edge of any one + image or to each other for successful aperture photometry. + """ + # Make sure we have a Path object + path = Path(file_or_directory) + if path.is_dir(): + photom_data = multi_image_photometry(path, self.settings, **kwargs) + elif path.is_file(): + image = CCDData.read(path) + photom_data = single_image_photometry(image, self.settings, **kwargs) + else: + raise ValueError( + f"file_or_directory '{path}' is not a valid file or directory." + ) + + return photom_data def single_image_photometry( @@ -547,6 +597,7 @@ def multi_image_photometry( directory_with_images, photometry_settings, reject_unmatched=True, + object_of_interest=None, ): """ Perform aperture photometry on a directory of images. @@ -574,6 +625,12 @@ def multi_image_photometry( fall below your detection limits, we suggest setting this to ``False`` so that all sources detected on each image are reported. + object_of_interest : str, optional (Default: None) + Name of the object of interest. The only files on which photometry + will be done are those whose header contains the keyword ``OBJECT`` + whose value is ``object_of_interest``. *Only used for multi-image + photometry* to select which files to perform photometry on. + Returns ------- @@ -654,8 +711,6 @@ def multi_image_photometry( # Suppress the FITSFixedWarning that is raised when reading a FITS file header warnings.filterwarnings("ignore", category=FITSFixedWarning) - object_of_interest = photometry_settings.object_of_interest - # Process all the files for this_ccd, this_fname in ifc.ccds(object=object_of_interest, return_fname=True): multilogger.info(f"multi_image_photometry: Processing image {this_fname}") diff --git a/stellarphot/photometry/tests/test_photometry.py b/stellarphot/photometry/tests/test_photometry.py index b028f8ef..f59a9900 100644 --- a/stellarphot/photometry/tests/test_photometry.py +++ b/stellarphot/photometry/tests/test_photometry.py @@ -12,6 +12,7 @@ from stellarphot.core import SourceListData from stellarphot.photometry import ( + AperturePhotometry, calculate_noise, find_too_close, multi_image_photometry, @@ -101,46 +102,478 @@ photometry_options=PHOTOMETRY_OPTIONS, passband_map=None, logging_settings=DEFAULT_LOGGING_SETTINGS, - object_of_interest="Test Object", ) -# class TestAperturePhotometry: -# @staticmethod -# def create_source_list(): -# # This has X, Y -# sources = FAKE_CCD_IMAGE.sources.copy() - -# # Rename to match the expected names -# sources.rename_column("x_mean", "xcenter") -# sources.rename_column("y_mean", "ycenter") - -# # Calculate RA/Dec from image WCS -# coords = FAKE_CCD_IMAGE.wcs.pixel_to_world( -# sources["xcenter"], sources["ycenter"] -# ) -# sources["ra"] = coords.ra -# sources["dec"] = coords.dec -# sources["star_id"] = list(range(len(sources))) -# sources["xcenter"] = sources["xcenter"] * u.pixel -# sources["ycenter"] = sources["ycenter"] * u.pixel - -# return SourceListData(input_data=sources, colname_map=None) - -# def test_create_aperture_photometry(self): -# source_list = self.create_source_list() -# # Create an AperturePhotometry object -# ap_phot = AperturePhotometry( -# # source_list, -# camera=FAKE_CAMERA, -# observatory=FAKE_OBS, -# photometry_apertures=DEFAULT_PHOTOMETRY_APERTURES, -# photometry_options=PHOTOMETRY_OPTIONS, -# passband_map=PASSBAND_MAP, -# ) - -# # Check that the object was created correctly -# assert ap_phot.camera is FAKE_CAMERA -# assert ap_phot.observatory is FAKE_OBS + +class TestAperturePhotometry: + @staticmethod + def create_source_list(): + # This has X, Y + sources = FAKE_CCD_IMAGE.sources.copy() + + # Rename to match the expected names + sources.rename_column("x_mean", "xcenter") + sources.rename_column("y_mean", "ycenter") + + # Calculate RA/Dec from image WCS + coords = FAKE_CCD_IMAGE.wcs.pixel_to_world( + sources["xcenter"], sources["ycenter"] + ) + sources["ra"] = coords.ra + sources["dec"] = coords.dec + sources["star_id"] = list(range(len(sources))) + sources["xcenter"] = sources["xcenter"] * u.pixel + sources["ycenter"] = sources["ycenter"] * u.pixel + + return SourceListData(input_data=sources, colname_map=None) + + @staticmethod + def list_of_fakes(num_files): + # Generate fake CCDData objects for use in photometry_on_directory tests + fake_images = [deepcopy(FAKE_CCD_IMAGE)] + + # Create additional images, each in a different position. + for i in range(num_files - 1): + angle = 2 * np.pi / (num_files - 1) * i + rad = 50 + dx, dy = rad * np.cos(angle), rad * np.sin(angle) + fake_images.append(shift_FakeCCDImage(fake_images[0], dx, dy)) + + filters = ["U", "B", "V", "R", "I"] + for i in range(num_files): + if i < 5: + fake_images[i].header["FILTER"] = filters[i] + else: + fake_images[i].header["FILTER"] = "V" + + return fake_images + + def test_create_aperture_photometry(self, tmp_path): + source_list = self.create_source_list() + source_list_file = tmp_path / "source_list.ecsv" + source_list.write(source_list_file, overwrite=True) + + photometry_settings = DEFAULT_PHOTOMETRY_SETTINGS.model_copy() + photometry_settings.source_locations.source_list_file = str(source_list_file) + + # Create an AperturePhotometry object + ap_phot = AperturePhotometry(settings=photometry_settings) + + # Check that the object was created correctly + assert ap_phot.settings.camera is FAKE_CAMERA + assert ap_phot.settings.observatory is FAKE_OBS + + # The True case below is a regression test for #157 + @pytest.mark.parametrize("int_data", [True, False]) + def test_aperture_photometry_no_outlier_rejection(self, int_data, tmp_path): + fake_CCDimage = deepcopy(FAKE_CCD_IMAGE) + + found_sources = source_detection( + fake_CCDimage, fwhm=fake_CCDimage.sources["x_stddev"].mean(), threshold=10 + ) + + # The scale_factor is used to rescale data to integers if needed. It + # needs to be set later on when the net counts are "unscaled" in the + # asserts that constitute the actual test. + scale_factor = 1.0 + if int_data: + scale_factor = ( + 0.75 * FAKE_CAMERA.max_data_value.value / fake_CCDimage.data.max() + ) + # For the moment, ensure the integer data is NOT larger than max_adu + # because until #161 is fixed then having NaN in the data will not succeed. + data = scale_factor * fake_CCDimage.data + fake_CCDimage.data = data.astype(int) + + # Make a copy of photometry options + phot_options = PhotometryOptions(**PHOTOMETRY_OPTIONS.model_dump()) + + # Modify options to match test before we used phot_options + phot_options.reject_background_outliers = False + phot_options.reject_too_close = False + phot_options.include_dig_noise = True + + source_list_file = tmp_path / "source_list.ecsv" + found_sources.write(source_list_file, format="ascii.ecsv", overwrite=True) + + source_locations = DEFAULT_SOURCE_LOCATIONS.model_copy() + source_locations.source_list_file = str(source_list_file) + + image_file = tmp_path / "fake_image.fits" + fake_CCDimage.write(image_file, overwrite=True) + + photometry_settings = PhotometrySettings( + camera=FAKE_CAMERA, + observatory=FAKE_OBS, + photometry_apertures=DEFAULT_PHOTOMETRY_APERTURES, + source_locations=source_locations, + photometry_options=phot_options, + passband_map=None, + logging_settings=DEFAULT_LOGGING_SETTINGS, + ) + + ap_phot = AperturePhotometry(settings=photometry_settings) + phot, missing_sources = ap_phot(image_file) + + phot.sort("aperture_sum") + sources = fake_CCDimage.sources + # Astropy tables sort in-place so we need to sort the sources table + # after the fact. + sources.sort("amplitude") + aperture = DEFAULT_PHOTOMETRY_APERTURES.radius + + for inp, out in zip(sources, phot, strict=True): + stdev = inp["x_stddev"] + expected_flux = ( + inp["amplitude"] + * 2 + * np.pi + * stdev**2 + * (1 - np.exp(-(aperture**2) / (2 * stdev**2))) + ) + # This expected flux is correct IF there were no noise. With noise, the + # standard deviation in the sum of the noise within in the aperture is + # n_pix_in_aperture times the single-pixel standard deviation. + + # We could require that the result be within some reasonable + # number of those expected variations or we could count up the + # actual number of background counts at each of the source + # positions. + + # Here we just check whether any difference is consistent with + # less than the expected one sigma deviation. + + # We need to remove any scaling that has been done of the data values. + assert ( + np.abs(expected_flux - out["aperture_net_cnts"].value / scale_factor) + < np.pi * aperture**2 * fake_CCDimage.noise_dev + ) + + @pytest.mark.parametrize("reject", [True, False]) + def test_aperture_photometry_with_outlier_rejection(self, reject, tmp_path): + """ + Insert some really large pixel values in the annulus and check that + the photometry is correct when outliers are rejected and is + incorrect when outliers are not rejected. + """ + fake_CCDimage = deepcopy(FAKE_CCD_IMAGE) + sources = fake_CCDimage.sources + + aperture_settings = DEFAULT_PHOTOMETRY_APERTURES + aperture = aperture_settings.radius + inner_annulus = aperture_settings.inner_annulus + outer_annulus = aperture_settings.outer_annulus + + image = fake_CCDimage.data + + found_sources = source_detection( + fake_CCDimage, fwhm=sources["x_stddev"].mean(), threshold=10 + ) + source_list_file = tmp_path / "source_list.ecsv" + found_sources.write(source_list_file, format="ascii.ecsv", overwrite=True) + + # Add some large pixel values to the annulus for each source. + # adding these moves the average pixel value by quite a bit, + # so we'll only get the correct net flux if these are removed. + for source in fake_CCDimage.sources: + center_px = (int(source["x_mean"]), int(source["y_mean"])) + begin = center_px[0] + inner_annulus + 1 + end = begin + (outer_annulus - inner_annulus - 1) + # Yes, x and y are deliberately reversed below. + image[center_px[1], begin:end] = 100 * fake_CCDimage.mean_noise + + # Make a copy of photometry options + phot_options = PhotometryOptions(**PHOTOMETRY_OPTIONS.model_dump()) + + # Modify options to match test before we used phot_options + phot_options.reject_background_outliers = reject + phot_options.reject_too_close = False + phot_options.include_dig_noise = True + + photometry_settings = DEFAULT_PHOTOMETRY_SETTINGS.model_copy() + photometry_settings.source_locations.source_list_file = str(source_list_file) + photometry_settings.photometry_options = phot_options + + image_file = tmp_path / "fake_image.fits" + fake_CCDimage.write(image_file, overwrite=True) + + ap_phot = AperturePhotometry(settings=photometry_settings) + phot, missing_sources = ap_phot(image_file) + + phot.sort("aperture_sum") + sources.sort("amplitude") + + for inp, out in zip(sources, phot, strict=True): + stdev = inp["x_stddev"] + expected_flux = ( + inp["amplitude"] + * 2 + * np.pi + * stdev**2 + * (1 - np.exp(-(aperture**2) / (2 * stdev**2))) + ) + # This expected flux is correct IF there were no noise. With noise, the + # standard deviation in the sum of the noise within in the aperture is + # n_pix_in_aperture times the single-pixel standard deviation. + # + + expected_deviation = np.pi * aperture**2 * fake_CCDimage.noise_dev + # We could require that the result be within some reasonable + # number of those expected variations or we could count up the + # actual number of background counts at each of the source + # positions. + + # Here we just check whether any difference is consistent with + # less than the expected one sigma deviation. + if reject: + assert ( + np.abs(expected_flux - out["aperture_net_cnts"].value) + < expected_deviation + ) + else: + with pytest.raises(AssertionError): + assert ( + np.abs(expected_flux - out["aperture_net_cnts"].value) + < expected_deviation + ) + + @pytest.mark.parametrize("coords", ["sky", "pixel"]) + def test_photometry_on_directory(self, coords): + # Create list of fake CCDData objects + num_files = 5 + fake_images = list_of_fakes(num_files) + + # Write fake images to temporary directory and test + # multi_image_photometry on them. + # NOTE: ignore_cleanup_errors=True is needed to avoid an error + # when the temporary directory is deleted on Windows. + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: + # Come up with Filenames + temp_file_names = [ + Path(temp_dir) / f"tempfile_{i:02d}.fit" + for i in range(1, num_files + 1) + ] + # Write the CCDData objects to files + for i, image in enumerate(fake_images): + image.write(temp_file_names[i]) + + object_name = fake_images[0].header["OBJECT"] + sources = fake_images[0].sources + aperture_settings = DEFAULT_PHOTOMETRY_APERTURES + aperture = aperture_settings.radius + + # Generate the sourcelist + found_sources = source_detection( + fake_images[0], + fwhm=fake_images[0].sources["x_stddev"].mean(), + threshold=10, + ) + + source_list_file = Path(temp_dir) / "source_list.ecsv" + found_sources.write(source_list_file, format="ascii.ecsv", overwrite=True) + + # Make a copy of photometry options + phot_options = PhotometryOptions(**PHOTOMETRY_OPTIONS.model_dump()) + + # Modify options to match test before we used phot_options + phot_options.include_dig_noise = True + phot_options.reject_too_close = True + phot_options.reject_background_outliers = True + phot_options.fwhm_by_fit = True + + photometry_settings = DEFAULT_PHOTOMETRY_SETTINGS.model_copy() + photometry_settings.photometry_options = phot_options + photometry_settings.source_locations.use_coordinates = coords + photometry_settings.source_locations.source_list_file = str( + source_list_file + ) + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message="Cannot merge meta key", + category=MergeConflictWarning, + ) + ap_phot = AperturePhotometry(settings=photometry_settings) + phot_data = ap_phot( + temp_dir, + object_of_interest=object_name, + ) + + # For following assertion to be true, rad must be small enough that + # no source lies within outer_annulus of the edge of an image. + assert len(phot_data) == num_files * len(found_sources) + + # Sort all data by amount of signal + sources.sort("amplitude") + found_sources.sort("flux") + + # Get noise level from the first image + noise_dev = fake_images[0].noise_dev + + for fnd, inp in zip(found_sources, sources, strict=True): + star_id_chk = fnd["star_id"] + # Select the rows in phot_data that correspond to the current star + # and compute the average of the aperture sums. + selected_rows = phot_data[phot_data["star_id"] == star_id_chk] + obs_avg_net_cnts = np.average(selected_rows["aperture_net_cnts"].value) + + stdev = inp["x_stddev"] + expected_flux = ( + inp["amplitude"] + * 2 + * np.pi + * stdev**2 + * (1 - np.exp(-(aperture**2) / (2 * stdev**2))) + ) + # This expected flux is correct IF there were no noise. With noise, the + # standard deviation in the sum of the noise within in the aperture is + # n_pix_in_aperture times the single-pixel standard deviation. + # + + expected_deviation = np.pi * aperture**2 * noise_dev + + # We have two cases to consider: use_coordinates="sky" and + # use_coordinates="pixel". + if coords == "sky": + # In this case, the expected result is the test below. + + # We could require that the result be within some reasonable + # number of those expected variations or we could count up the + # actual number of background counts at each of the source + # positions. + + # Here we just check whether any difference is consistent with + # less than the expected one sigma deviation. + assert np.abs(expected_flux - obs_avg_net_cnts) < expected_deviation + else: + # In this case we are trying to do photometry in pixel coordinates, + # using the pixel location of the sources as found in the first image -- + # see the line where found_sources is defined. + # + # However, the images are shifted with respect to each other by + # list_of_fakes, so there are no longer stars at those positions in the + # other images. + # + # Because of that, the expected result is that either obs_avg_net_cnts + # is nan or the difference is bigger than the expected_deviation. + assert ( + np.isnan(obs_avg_net_cnts) + or np.abs(expected_flux - obs_avg_net_cnts) > expected_deviation + ) + + def test_photometry_on_directory_with_no_ra_dec(self): + # Create list of fake CCDData objects + num_files = 5 + fake_images = list_of_fakes(num_files) + + # Write fake images to temporary directory and test + # multi_image_photometry on them. + # NOTE: ignore_cleanup_errors=True is needed to avoid an error + # when the temporary directory is deleted on Windows. + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: + # Come up with Filenames + temp_file_names = [ + Path(temp_dir) / f"tempfile_{i:02d}.fits" + for i in range(1, num_files + 1) + ] + # Write the CCDData objects to files + for i, image in enumerate(fake_images): + image.write(temp_file_names[i]) + + object_name = fake_images[0].header["OBJECT"] + + # Generate the sourcelist + found_sources = source_detection( + fake_images[0], + fwhm=fake_images[0].sources["x_stddev"].mean(), + threshold=10, + ) + + # Damage the sourcelist by removing the ra and dec columns + found_sources.drop_ra_dec() + + source_list_file = Path(temp_dir) / "source_list.ecsv" + found_sources.write(source_list_file, format="ascii.ecsv", overwrite=True) + + phot_options = PhotometryOptions(**PHOTOMETRY_OPTIONS.model_dump()) + + # Modify options to match test before we used phot_options + phot_options.include_dig_noise = True + phot_options.reject_too_close = True + phot_options.reject_background_outliers = True + phot_options.fwhm_by_fit = True + + photometry_settings = DEFAULT_PHOTOMETRY_SETTINGS.model_copy() + photometry_settings.photometry_options = phot_options + photometry_settings.source_locations.source_list_file = str( + source_list_file + ) + # The setting below was implicit in the old default + photometry_settings.source_locations.use_coordinates = "sky" + + ap_phot = AperturePhotometry(settings=photometry_settings) + with pytest.raises(ValueError): + ap_phot( + temp_dir, + object_of_interest=object_name, + ) + + def test_photometry_on_directory_with_bad_fits(self): + # Create list of fake CCDData objects + num_files = 5 + clean_fake_images = list_of_fakes(num_files) + fake_images = list_of_fakes(num_files) + + # Write fake images (without WCS) to temporary directory and test + # multi_image_photometry on them. + # NOTE: ignore_cleanup_errors=True is needed to avoid an error + # when the temporary directory is deleted on Windows. + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: + # Come up with Filenames + temp_file_names = [ + Path(temp_dir) / f"tempfile_{i:02d}.fits" + for i in range(1, num_files + 1) + ] + # Write the CCDData objects to files + for i, image in enumerate(fake_images): + image.drop_wcs() + image.write(temp_file_names[i]) + + object_name = fake_images[0].header["OBJECT"] + + # Generate the sourcelist with RA/Dec information from a clean image + found_sources = source_detection( + clean_fake_images[0], + fwhm=clean_fake_images[0].sources["x_stddev"].mean(), + threshold=10, + ) + + source_list_file = Path(temp_dir) / "source_list.ecsv" + found_sources.write(source_list_file, format="ascii.ecsv", overwrite=True) + + phot_options = PhotometryOptions(**PHOTOMETRY_OPTIONS.model_dump()) + + # Modify options to match test before we used phot_options + phot_options.include_dig_noise = True + phot_options.reject_too_close = True + phot_options.reject_background_outliers = True + phot_options.fwhm_by_fit = True + + photometry_settings = DEFAULT_PHOTOMETRY_SETTINGS.model_copy() + photometry_settings.photometry_options = phot_options + photometry_settings.source_locations.source_list_file = str( + source_list_file + ) + # The settings below was implicit in the old default + photometry_settings.source_locations.use_coordinates = "sky" + + ap_phot = AperturePhotometry(settings=photometry_settings) + # Since none of the images will be valid, it should raise a RuntimeError + with pytest.raises(RuntimeError): + ap_phot( + temp_dir, + object_of_interest=object_name, + ) def test_calc_noise_defaults(): @@ -373,7 +806,6 @@ def test_aperture_photometry_no_outlier_rejection(int_data, tmp_path): photometry_options=phot_options, passband_map=None, logging_settings=DEFAULT_LOGGING_SETTINGS, - object_of_interest="Test Object", ) phot, missing_sources = single_image_photometry( fake_CCDimage, @@ -567,7 +999,6 @@ def test_photometry_on_directory(coords): photometry_settings = DEFAULT_PHOTOMETRY_SETTINGS.model_copy() photometry_settings.photometry_options = phot_options - photometry_settings.object_of_interest = object_name photometry_settings.source_locations.use_coordinates = coords photometry_settings.source_locations.source_list_file = str(source_list_file) with warnings.catch_warnings(): @@ -577,6 +1008,7 @@ def test_photometry_on_directory(coords): phot_data = multi_image_photometry( temp_dir, photometry_settings, + object_of_interest=object_name, ) # For following assertion to be true, rad must be small enough that @@ -683,7 +1115,6 @@ def test_photometry_on_directory_with_no_ra_dec(): photometry_settings = DEFAULT_PHOTOMETRY_SETTINGS.model_copy() photometry_settings.photometry_options = phot_options - photometry_settings.object_of_interest = object_name photometry_settings.source_locations.source_list_file = str(source_list_file) # The setting below was implicit in the old default photometry_settings.source_locations.use_coordinates = "sky" @@ -692,6 +1123,7 @@ def test_photometry_on_directory_with_no_ra_dec(): multi_image_photometry( temp_dir, photometry_settings, + object_of_interest=object_name, ) @@ -737,7 +1169,6 @@ def test_photometry_on_directory_with_bad_fits(): photometry_settings = DEFAULT_PHOTOMETRY_SETTINGS.model_copy() photometry_settings.photometry_options = phot_options - photometry_settings.object_of_interest = object_name photometry_settings.source_locations.source_list_file = str(source_list_file) # The settings below was implicit in the old default photometry_settings.source_locations.use_coordinates = "sky" @@ -747,4 +1178,5 @@ def test_photometry_on_directory_with_bad_fits(): multi_image_photometry( temp_dir, photometry_settings, + object_of_interest=object_name, ) diff --git a/stellarphot/settings/models.py b/stellarphot/settings/models.py index 8ec0147f..36de7c6c 100644 --- a/stellarphot/settings/models.py +++ b/stellarphot/settings/models.py @@ -631,11 +631,6 @@ class PhotometrySettings(BaseModelWithTableRep): Settings for logging. See the documentation for `~stellarphot.settings.LoggingSettings` for details. - object_of_interest : str - Name of the object of interest. The only files on which photometry - will be done are those whose header contains the keyword ``OBJECT`` - whose value is ``object_of_interest``. - """ camera: Camera @@ -645,7 +640,6 @@ class PhotometrySettings(BaseModelWithTableRep): photometry_options: PhotometryOptions passband_map: PassbandMap | None logging_settings: LoggingSettings - object_of_interest: str class Exoplanet(BaseModelWithTableRep): diff --git a/stellarphot/settings/tests/test_models.py b/stellarphot/settings/tests/test_models.py index 903793c9..5e1d99c4 100644 --- a/stellarphot/settings/tests/test_models.py +++ b/stellarphot/settings/tests/test_models.py @@ -88,7 +88,6 @@ photometry_options=PhotometryOptions(**DEFAULT_PHOTOMETRY_OPTIONS), passband_map=PassbandMap(**DEFAULT_PASSBAND_MAP), logging_settings=LoggingSettings(**DEFAULT_LOGGING_SETTINGS), - object_of_interest="test", ) From 3bd930143d468c29c304da25f6a9eaa1082c5771 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Fri, 16 Feb 2024 16:03:31 -0600 Subject: [PATCH 12/15] Add very, very crude documentation --- docs/index.rst | 66 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 0a46c109..42b46b0a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,8 +1,70 @@ Documentation ============= -This is the documentation for stellarphot. -This is a package for doing stellar photometry that relies on astropy. +Stellarphot is a package for performing photometry on astronomical images. It +provides a simple interface for performing aperture photometry of either a single +image or a directory with multiple images. + + +Getting Started +=============== + +Installation +------------ + +Install it with pip or conda. + +Overview +-------- + +Using ``stellarphot`` starts with defining a bunch of configuration settings. Some of these, +like details about your observing location and camera, will change infrequently. +Others, like what size apertures to use for photometry and where in each image those +apertures should be placed, may change from night to night. + +Provide your observatory information +------------------------------------- + +TBD + +Provide your camera information +------------------------------- + +TBD + +Provide a source list +--------------------- + +TBD + +Some optional settings +----------------------- + +TBD + +Performing photometry +--------------------- + +Once you have made your settings doing photometry is a two line process. First, you +create a photometry object: + +```python +from stellarphot.photometry import AperurePhotometry +phot = AperurePhotometry(photometry_settings) +``` + +Then you can perform photometry on a single image: + +```python +phot(image) +``` + +If you have a directory of images you can perform photometry on all of them at once like this: + +```python +phot(directory, object_of_interest="M13") +``` + .. toctree:: :maxdepth: 3 From 4a3c553023cd8735726c33c163ab2c2a23722cc2 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 19 Feb 2024 13:14:25 -0600 Subject: [PATCH 13/15] Always provide fname to single_image_photometry --- stellarphot/photometry/photometry.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/stellarphot/photometry/photometry.py b/stellarphot/photometry/photometry.py index 0d421c86..ef120752 100644 --- a/stellarphot/photometry/photometry.py +++ b/stellarphot/photometry/photometry.py @@ -61,10 +61,6 @@ def __call__( file_or_directory : str or Path The file or directory on which to perform aperture photometry. - fname : str, optional (Default: None) - Name of the image file on which photometry is being performed, - *only used for single image photometry*. - logline : str, optional (Default: "single_image_photometry:") String to prepend to all log messages, *only used for single image photometry*. @@ -96,7 +92,9 @@ def __call__( photom_data = multi_image_photometry(path, self.settings, **kwargs) elif path.is_file(): image = CCDData.read(path) - photom_data = single_image_photometry(image, self.settings, **kwargs) + photom_data = single_image_photometry( + image, self.settings, fname=str(path), **kwargs + ) else: raise ValueError( f"file_or_directory '{path}' is not a valid file or directory." From 8b12ff2feb7bf4429c3a886f569190040d51609c Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 19 Feb 2024 13:14:53 -0600 Subject: [PATCH 14/15] Allude to the ways to enter settings --- docs/index.rst | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 42b46b0a..1bd01b40 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,9 +1,10 @@ Documentation ============= -Stellarphot is a package for performing photometry on astronomical images. It +Stellarphot is a package for performing photometry on calibrated (reduced) astronomical images. It provides a simple interface for performing aperture photometry of either a single -image or a directory with multiple images. +image or a directory with multiple images. It is designed to be easy to use for both +non-programmers and programmers. Getting Started @@ -22,6 +23,9 @@ like details about your observing location and camera, will change infrequently. Others, like what size apertures to use for photometry and where in each image those apertures should be placed, may change from night to night. +There is a graphical interface for making all of the settings below. They can also be set +programmatically in Python or by editing a file in your favorite text editor. + Provide your observatory information ------------------------------------- From fece5985c2bea80f6ee9aae3b93c6bb7cb55eb9b Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 19 Feb 2024 13:29:42 -0600 Subject: [PATCH 15/15] Add test for line that wasn't tested --- stellarphot/photometry/tests/test_photometry.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/stellarphot/photometry/tests/test_photometry.py b/stellarphot/photometry/tests/test_photometry.py index f59a9900..9528d705 100644 --- a/stellarphot/photometry/tests/test_photometry.py +++ b/stellarphot/photometry/tests/test_photometry.py @@ -575,6 +575,11 @@ def test_photometry_on_directory_with_bad_fits(self): object_of_interest=object_name, ) + def test_invalid_path(self): + ap = AperturePhotometry(settings=DEFAULT_PHOTOMETRY_SETTINGS) + with pytest.raises(ValueError, match="is not a valid file or directory"): + ap("invalid_path") + def test_calc_noise_defaults(): # If we put in nothing we should get an error about is missing camera