diff --git a/docs/index.rst b/docs/index.rst index 0a46c109..1bd01b40 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,8 +1,74 @@ 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 calibrated (reduced) astronomical images. It +provides a simple interface for performing aperture photometry of either a single +image or a directory with multiple images. It is designed to be easy to use for both +non-programmers and programmers. + + +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. + +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 +------------------------------------- + +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 diff --git a/stellarphot/photometry/photometry.py b/stellarphot/photometry/photometry.py index 5f0da471..ef120752 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,70 @@ 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, + **kwargs, + ) -> PhotometryData: + """ + 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. + + 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, fname=str(path), **kwargs + ) + else: + raise ValueError( + f"file_or_directory '{path}' is not a valid file or directory." + ) + + return photom_data + + def single_image_photometry( ccd_image, - sourcelist, - camera, - observatory, - photometry_apertures, - photometry_options, - passband_map=None, + photometry_settings, fname=None, logline="single_image_photometry:", ): @@ -68,29 +127,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. - - 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. + 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. @@ -127,6 +168,17 @@ def single_image_photometry( the `use_coordinates` parameter should be set to "sky". """ + 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): raise TypeError( @@ -158,8 +210,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 +283,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 +294,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 +365,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 +383,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) @@ -539,14 +593,9 @@ def single_image_photometry( def multi_image_photometry( directory_with_images, - object_of_interest, - sourcelist, - camera, - observatory, - photometry_apertures, - photometry_options, - passband_map=None, + photometry_settings, reject_unmatched=True, + object_of_interest=None, ): """ Perform aperture photometry on a directory of images. @@ -562,34 +611,11 @@ 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. + 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 @@ -597,6 +623,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 ------- @@ -607,6 +639,9 @@ def multi_image_photometry( or to each other for successful aperture photometry. """ + sourcelist = SourceListData.read( + photometry_settings.source_locations.source_list_file + ) # Initialize lists to track all PhotometryData objects and all dropped sources phots = [] @@ -626,8 +661,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.logging_settings.logfile + console_log = photometry_settings.logging_settings.console_log if logfile is not None: # Keep original name without path @@ -686,12 +721,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 4ccff7c7..9528d705 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, @@ -21,9 +22,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 @@ -36,11 +40,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, @@ -79,6 +86,500 @@ fwhm=FAKE_CCD_IMAGE.sources["x_stddev"].mean(), ) +# Passband map for the tests +PASSBAND_MAP = { + "B": "B", + "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, +) + + +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_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 @@ -269,7 +770,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 +798,23 @@ 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) + + source_locations = DEFAULT_SOURCE_LOCATIONS.model_copy() + source_locations.source_list_file = str(source_list_file) + 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, + ) 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") @@ -342,7 +853,7 @@ def test_aperture_photometry_no_outlier_rejection(int_data): @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 @@ -361,6 +872,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, @@ -380,13 +893,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_locations.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") @@ -477,29 +990,30 @@ 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()) # 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.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 ) phot_data = multi_image_photometry( temp_dir, - object_name, - found_sources, - FAKE_CAMERA, - FAKE_OBS, - aperture_settings, - phot_options, - passband_map=None, + photometry_settings, + object_of_interest=object_name, ) # For following assertion to be true, rad must be small enough that @@ -554,7 +1068,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 @@ -584,7 +1098,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( @@ -594,25 +1107,28 @@ 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 - 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.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" + 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, + object_of_interest=object_name, ) @@ -637,7 +1153,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( @@ -646,24 +1161,27 @@ 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 - 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.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" + # 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, + object_of_interest=object_name, ) diff --git a/stellarphot/settings/models.py b/stellarphot/settings/models.py index 95e0d52a..36de7c6c 100644 --- a/stellarphot/settings/models.py +++ b/stellarphot/settings/models.py @@ -30,11 +30,15 @@ __all__ = [ "Camera", + "LoggingSettings", + "PassbandMap", "PhotometryApertures", "PhotometryFileSettings", + "PhotometrySettings", "PhotometryOptions", "Exoplanet", "Observatory", + "SourceLocationSettings", ] # Most models should use the default configuration, but it can be customized if needed. @@ -68,6 +72,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() @@ -310,8 +317,6 @@ class PhotometryApertures(BaseModelWithTableRep): ... ) """ - model_config = MODEL_DEFAULT_CONFIGURATION - radius: Annotated[ PositiveInt, Field(default=1, json_schema_extra=dict(autoui="ipywidgets.BoundedIntText")), @@ -347,8 +352,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="", @@ -385,9 +388,35 @@ class Observatory(BaseModelWithTableRep): TESS_telescope_code : str, optional TESS telescope code. - """ - model_config = MODEL_DEFAULT_CONFIGURATION + 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 latitude: Annotated[ @@ -412,13 +441,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 @@ -427,12 +468,30 @@ class PhotometryOptions(BaseModelWithTableRep): 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. + 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 + 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 @@ -451,65 +510,138 @@ 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 """ - model_config = MODEL_DEFAULT_CONFIGURATION - - 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 + + +class PassbandMap(BaseModelWithTableRep): + """ + 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] + + +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. + + Examples + -------- + >>> from stellarphot.settings import LoggingSettings + >>> logging_settings = LoggingSettings() + >>> logging_settings + LoggingSettings(logfile=None, console_log=True) + """ + logfile: str | None = None console_log: bool = True +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. + + 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. + + 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 + 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. + + """ + + camera: Camera + observatory: Observatory + photometry_apertures: PhotometryApertures + source_locations: SourceLocationSettings + photometry_options: PhotometryOptions + passband_map: PassbandMap | None + logging_settings: LoggingSettings + + 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..5e1d99c4 100644 --- a/stellarphot/settings/tests/test_models.py +++ b/stellarphot/settings/tests/test_models.py @@ -11,9 +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) @@ -48,20 +52,44 @@ 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( - shift_tolerance=5, - use_coordinates="pixel", +DEFAULT_PHOTOMETRY_OPTIONS = dict( include_dig_noise=False, reject_too_close=False, reject_background_outliers=False, fwhm_by_fit=False, +) + +DEFAULT_PASSBAND_MAP = dict( + yours_to_aavso=dict( + V="V", + B="B", + rp="SR", + ) +) + +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), +) + @pytest.mark.parametrize( "model,settings", @@ -70,7 +98,11 @@ [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], + [LoggingSettings, DEFAULT_LOGGING_SETTINGS], + [SourceLocationSettings, DEFAULT_SOURCE_LOCATION_SETTINGS], ], ) class TestModelAgnosticActions: @@ -116,7 +148,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 @@ -304,14 +340,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_SETTINGS) + 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():