Source code for stellarphot.settings.models

# Objects that contains the user settings for the program.

import re
from copy import deepcopy
from enum import StrEnum
from pathlib import Path
from typing import Annotated, Any, Literal, TypeVar

from astropy.coordinates import EarthLocation, Latitude, Longitude, SkyCoord
from astropy.time import Time
from astropy.units import Quantity, Unit, UnitConversionError
from astropy.utils import lazyproperty
from pydantic import (
    AfterValidator,
    AliasChoices,
    BaseModel,
    BeforeValidator,
    ConfigDict,
    Field,
    NonNegativeFloat,
    PositiveFloat,
    create_model,
    field_validator,
    model_validator,
)

from .aavso_models import AAVSOFilters
from .astropy_pydantic import (
    AstropyValidator,
    EquivalentTo,
    QuantityType,
    UnitType,
    WithPhysicalType,
    _UnitQuantTypePydanticAnnotation,
)

__all__ = [
    "Camera",
    "FwhmMethods",
    "LoggingSettings",
    "PartialPhotometrySettings",
    "PassbandMap",
    "PhotometryApertures",
    "PhotometryFileSettings",
    "PhotometryRunSettings",
    "PhotometrySettings",
    "PhotometryOptionalSettings",
    "Exoplanet",
    "Observatory",
    "SourceLocationSettings",
]

# Most models should use the default configuration, but it can be customized if needed.
MODEL_DEFAULT_CONFIGURATION = ConfigDict(
    # Make sure default values are valid
    validate_default=True,
    # Make sure changes to values made after initialization are valid
    validate_assignment=True,
    # Make sure there are no extra fields
    extra="forbid",
)


# Make a type for a non-empty string for use in name fields
# Names may not have any leading or trailing spaces, and cannot simply
# be spaces. Though this could be implemented as a regular expression
# pattern, that leads to a validation message likely to confuse users,
# along the lines of (for pattern r"^\S$|^\S.*\S$"):
#
# String should match pattern "^\\S$|^\\S.*\\S$"
#
# Instead, the custom validation function below checks for errors and
# raises a ValueError with a more user-friendly message.
def _non_empty_string_validator(value):
    if not value.strip():
        raise ValueError("name must not be empty or contain only whitespace.")

    if not re.search(r"^\S$|^\S.*\S$", value):
        # Name must have exactly one non-whitespace character or
        # at least two non-whitespace characters with any amount of
        # other characters between them.
        raise ValueError("name must not have leading or trailing whitespace.")

    return value


NonEmptyStr = Annotated[str, AfterValidator(_non_empty_string_validator)]


def _extract_short_description(docstring: str) -> str:
    """
    Extract the first line of the docstring as a short description.
    """
    # Everything up the the first blank line is the short description
    short_desc = docstring.split("\n\n")[0]

    # This may have some extra whitespace at the beginning and end, remove that
    short_desc = short_desc.strip()

    # split the string and rejoin with spaces in case there are multiple spaces
    # between words, e.g. caused by a multi-line docstring
    return " ".join(short_desc.split())


def add_degree_to_float(value, _handler):
    """
    Translate a value that can be a number to a string with "degree" appended.
    """
    try:
        as_number = float(value)
    except (ValueError, TypeError):
        # A value error will happen if the value is not a number.
        # A type error will happen at least in the case where the value is
        # an astropy Quantity.
        return value
    else:
        return f"{as_number} degree"


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)


[docs] class Camera(BaseModelWithTableRep): """ A class to represent a CCD-based camera. Parameters ---------- name : str The name of the camera; can be anything that helps the user identify the camera. data_unit : `astropy.units.Unit` The unit of the data. gain : `astropy.units.Quantity` The gain of the camera in units such the product of `gain` times the image data has units equal to that of the `read_noise`. read_noise : `astropy.units.Quantity` The read noise of the camera with units. dark_current : `astropy.units.Quantity` The dark current of the camera in units such that, when multiplied by exposure time, the unit matches the units of the `read_noise`. pixel_scale : `astropy.units.Quantity` The pixel scale of the camera in units of arcseconds per pixel. max_data_value : `astropy.units.Quantity` The maximum pixel value to allow while performing photometry. Pixel values above this will be set to ``NaN``. The unit must be ``data_unit``. Attributes ---------- data_unit : `astropy.units.Unit` The unit of the data. gain : `astropy.units.Quantity` The gain of the camera in units such the product of `gain` times the image data has units equal to that of the `read_noise`. name : str The name of the camera; can be anything that helps the user identify the camera. read_noise : `astropy.units.Quantity` The read noise of the camera with units. dark_current : `astropy.units.Quantity` The dark current of the camera in units such that, when multiplied by exposure time, the unit matches the units of the `read_noise`. pixel_scale : `astropy.units.Quantity` The pixel scale of the camera in units of arcseconds per pixel. max_data_value : `astropy.units.Quantity` The maximum pixel value to allow while performing photometry. Pixel values above this will be set to ``NaN``. The unit must be ``data_unit``. Notes ----- The gain, read noise, and dark current are all assumed to be constant across the entire CCD. Examples -------- >>> from astropy import units as u >>> from stellarphot.settings import Camera >>> camera = Camera(data_unit="adu", ... gain=1.0 * u.electron / u.adu, ... name="test camera", ... read_noise=1.0 * u.electron, ... dark_current=0.01 * u.electron / u.second, ... pixel_scale=0.563 * u.arcsec / u.pixel, ... max_data_value=50000 * u.adu) >>> camera.data_unit Unit("adu") >>> camera.gain <Quantity 1. electron / adu> >>> camera.name 'test camera' >>> camera.read_noise <Quantity 1. electron> >>> camera.dark_current <Quantity 0.01 electron / s> >>> camera.pixel_scale <Quantity 0.563 arcsec / pix> >>> camera.max_data_value <Quantity 50000. adu> """ # This ensures that just the first line of the docstring is used as the # model description. The json schema is really hard to read if the # description is too long. # WHen a subclass has a ConfigDict it is merged with the parent class's # ConfigDict. model_config = ConfigDict( json_schema_extra=dict( description=_extract_short_description(__doc__), ) ) name: Annotated[ NonEmptyStr, Field( description="Name of the camera", examples=["SBIG FakeCam", "ZWO NadaCam", "CG16m"], ), ] data_unit: UnitType = Field( description="units of the data", examples=["adu", "DN", "count"] ) gain: QuantityType = Field( description="unit should be consistent with data and read noise", examples=["1.5 electron / adu", "1.0 electron / DN", "1.0 photon / count"], ) read_noise: QuantityType = Field( description="unit should be consistent with dark current", examples=["10.0 electron", "10.0 electron", "10.0 photon"], ) dark_current: QuantityType = Field( description="unit consistent with read noise, per unit time", examples=[ "0.01 electron / second", "0.01 electron / second", "0.01 photon / second", ], ) pixel_scale: Annotated[ QuantityType, EquivalentTo(Unit("arcsec / pix")), Field(description="units of angle per pixel", examples=["0.6 arcsec / pix"]), ] max_data_value: Annotated[ QuantityType, Field( description="maximum data value while performing photometry", examples=["50000 adu", "50000 DN", "50000 count"], gt=0, ), ] # Run the model validator after the default validator. Unlike in pydantic 1, # mode="after" passes in an instance as an argument not a value.
[docs] @model_validator(mode="after") def validate_gain(self): # Get read noise units rn_unit = Quantity(self.read_noise).unit # Get dark current units dark_unit = Quantity(self.dark_current).unit # Check that gain and read noise have compatible units, that is that # gain is read noise per data unit. gain = self.gain try: gain.to(self.read_noise.unit / self.data_unit) except UnitConversionError as e: raise ValueError( f"Gain units {gain.unit} are not compatible with " f"read noise units {rn_unit}." ) from e # Check that dark current and read noise have compatible units, that is # that dark current is read noise per second. dark_current = self.dark_current try: dark_current.to(self.read_noise.unit / Unit("s")) except UnitConversionError as e: raise ValueError( f"Dark current units {dark_unit} are not " f"compatible with read noise units {rn_unit}." ) from e # Check that maximum data value is consistent with data units if self.max_data_value.unit != self.data_unit: raise ValueError( f"Maximum data value units {self.max_data_value.unit} " f"are not consistent with data units {self.data_unit}." ) return self
[docs] class PhotometryApertures(BaseModelWithTableRep): """ Settings for aperture photometry. Parameters ---------- radius : int Radius of the aperture in pixels, must be greater than or equal to 1. gap : int Distance between the radius and the inner annulus in pixels, must be greater than or equal to 1. annulus_width : int Width of the annulus in pixels, must be greater than or equal to 1. fwhm : float Full width at half maximum of the typical star in pixels. Attributes ---------- inner_annulus : int Radius of the inner annulus in pixels. outer_annulus : int Radius of the outer annulus in pixels. Examples -------- To create an `PhotometryApertures` object, you can pass in the radius, gap, and annulus_width as keyword arguments: >>> aperture_settings = PhotometryApertures( ... radius=4, ... gap=10, ... annulus_width=15, ... fwhm=3.0 ... ) """ # This ensures that just the first line of the docstring is used as the # model description. The json schema is really hard to read if the # description is too long. # WHen a subclass has a ConfigDict it is merged with the parent class's # ConfigDict. model_config = ConfigDict( json_schema_extra=dict( description=_extract_short_description(__doc__), ) ) variable_aperture: Annotated[ bool, Field( default=False, # To match the original default description=( "Should the aperture be variable? If True, the aperture will be " "calculated from the average FWHM of the stars in each image." ), ), ] radius: Annotated[ PositiveFloat, Field( default=1, description="radius of circular aperture, in pixels or multiple of FWHM", json_schema_extra=dict(autoui="ipywidgets.BoundedFloatText"), ), ] gap: Annotated[ PositiveFloat, Field( default=1, description="Size of gap between aperture and annulus, in pixels", json_schema_extra=dict(autoui="ipywidgets.BoundedFloatText"), ), ] annulus_width: Annotated[ PositiveFloat, Field( default=1, description=("distance between inner and outer radii of annulus in pixels"), json_schema_extra=dict(autoui="ipywidgets.BoundedFloatText"), ), ] # Disable the UI element by default because it is often calculate from an image fwhm_estimate: Annotated[ PositiveFloat, Field( disabled=True, default=1.0, title="FWHM estimate", description="FWHM estimate in pixels", validation_alias=AliasChoices( "fwhm", # for backwards compatibility, "fwhm_estimate", # yes, pydantic does make you do this ), ), ] @property def inner_annulus(self): """ Radius of the inner annulus in pixels. """ return self.radius_pixels(self.fwhm_estimate) + self.gap @property def outer_annulus(self): """ Radius of the outer annulus in pixels. """ return self.inner_annulus + self.annulus_width
[docs] def radius_pixels(self, fwhm): """ Return the radius in pixels, depending on whether the aperture is variable or not. """ if self.variable_aperture: return fwhm * self.radius else: return self.radius
[docs] class PhotometryFileSettings(BaseModelWithTableRep): """ An evolutionary step on the way to having a monolithic set of photometry settings. """ 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="" )
[docs] class Observatory(BaseModelWithTableRep): """ Class to represent an observatory. Parameters ---------- name : str Name of the observatory. latitude : `astropy.coordinates.Latitude` or other valid latitude representation Latitude of the observatory. Use a positive number for north and negative for south. longitude : `astropy.coordinates.Longitude` or other valid longitude representation Longitude of the observatory. Use a positive number for east and negative for west. elevation : `astropy.units.Quantity` Elevation of the observatory. AAVSO_code : str, optional AAVSO observer code. TESS_telescope_code : str, optional TESS telescope code. Examples -------- The first example shows how to set up an observatory whose latitude and longitude are 30° North and 100° West, respectively, and is at an elevation of 1000 meters. >>> 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=<Latitude 30. deg>, longitude=<Longitude 260. deg>, elevation=<Quantity 1000. m>, AAVSO_code=None, TESS_telescope_code=None) Note that units can be omitted from the latitude and longitude if and only if the units are degrees and the values are in decimal degrees. >>> # 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=<Latitude 30. deg>, longitude=<Longitude 260. deg>, elevation=<Quantity 1000. m>, AAVSO_code=None, TESS_telescope_code=None) An observatory located at 46° 00' 00.00" South and 96° 00' 00.00" East, and at an elevation of 2300 meters would be created like this: >>> observatory = Observatory( ... name="test observatory", ... latitude="-46d00m00.00s", ... longitude="96d00m00.00s", ... elevation=2.3 * u.km, ... ) >>> observatory Observatory(name='test observatory', latitude=<Latitude -46. deg>,... It would be fine to use decmial degrees for the latitude and longitude in the above example, but the example is given in part to show how to use sexagesimal notation. """ # This ensures that just the first line of the docstring is used as the # model description. The json schema is really hard to read if the # description is too long. # WHen a subclass has a ConfigDict it is merged with the parent class's # ConfigDict. model_config = ConfigDict( json_schema_extra=dict( description=_extract_short_description(__doc__), ) ) name: Annotated[ NonEmptyStr, Field(description="Name of the observatory", examples=["My Observatory"]), ] latitude: Annotated[ Latitude, _UnitQuantTypePydanticAnnotation, BeforeValidator(add_degree_to_float), Field( description="Latitude of the observatory", examples=[ "46.8738", "46.8738 degree", "46:52:25.68 degree", "46d52m25.68s", ], ), ] longitude: Annotated[ Longitude, _UnitQuantTypePydanticAnnotation, BeforeValidator(add_degree_to_float), Field( description="Longitude of the observatory", examples=[ "-96.7678", "-96d46m04.08s", "263.2322 degree", "263d13m55.92s", ], ), ] elevation: Annotated[ QuantityType, WithPhysicalType("length"), Field( description="Elevation of the observatory", examples=["1000 m", "1 km", "3.241e-14 pc", "1e12 nm"], ), ] AAVSO_code: Annotated[str | None, Field(description="AAVSO code for observer")] = ( None ) TESS_telescope_code: Annotated[ str | None, Field(description="Code for observatory used by TFOP SG01") ] = None @lazyproperty def earth_location(self): """ Return an `astropy.coordinates.EarthLocation` object for the observatory. """ return EarthLocation( lat=self.latitude, lon=self.longitude, height=self.elevation )
[docs] class SourceLocationSettings(BaseModelWithTableRep): """ Settings for the location of the source list and the image files. Parameters ---------- 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 the tolerance in pixels for the shift between the the computed 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) """ # This ensures that just the first line of the docstring is used as the # model description. The json schema is really hard to read if the # description is too long. # WHen a subclass has a ConfigDict it is merged with the parent class's # ConfigDict. model_config = ConfigDict( json_schema_extra=dict( description=_extract_short_description(__doc__), ) ) source_list_file: Annotated[ str, Field( json_schema_extra=dict( autoui="ipyautoui.custom.FileChooser", filter_pattern=["*.ecsv", "*.csv"], ) ), ] = "source_locations.ecsv" use_coordinates: Annotated[ Literal["sky", "pixel"], Field(description="coordinates to use for locating sources in image"), ] = "sky" shift_tolerance: Annotated[ NonNegativeFloat, Field( description=( "Maximum shift between source position in list and " "in image, in pixels" ) ), ] = 5.0
[docs] class FwhmMethods(StrEnum): """ Available methods for finding FWHM of stars. """ FIT = "fit" MOMENTS = "moments" PROFILE = "profile"
[docs] class PhotometryOptionalSettings(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 the source and the sky will be included. reject_too_close : bool, optional (Default: True) If ``True``, any sources that are closer than twice the aperture radius are rejected. If ``False``, all sources in field are used. reject_background_outliers : bool, optional (Default: True) If ``True``, sigma clip the pixels in the annulus to reject outlying pixels (e.g. like stars in the annulus) fwhm_method : `FwhmMethods` (default: ``FwhmMethods.FIT``) Method for finding the FWHM of the star. 'fit' fits a 1D Gaussian to the star, 'profiile' fits a 1D Gaussian to the radial profile, and 'moments' uses second order moments of the image, which is terrible. partial_pixel_method : `typing.Literal["exact", "center", "subpixel"]`, optional How to handle partial pixels in the aperture. If ``'exact'``, the fraction of the flux included is the fraction of the pixel within the aperture. If ``'center'``, whether a pixel's flux is included is determined by whether the center of the pixel is within the aperture. If ``'subpixel'``, the flux included is determineid by breaking region into subpixels. The default is ``exact``. For more information, see the `photutils documentation <https://photutils.readthedocs.io/en/stable/aperture.html#aperture-and-pixel-overlap>`_. Examples -------- In many cases the default options are fine: >>> from stellarphot.settings import PhotometryOptionalSettings >>> photometry_options = PhotometryOptionalSettings() >>> photometry_options PhotometryOptionalSettings(include_dig_noise=True, reject_too_close=True,... You can also set options explicitly when you create the options: >>> photometry_options = PhotometryOptionalSettings( ... include_dig_noise=True, ... reject_too_close=False, ... reject_background_outliers=True, ... fwhm_by_fit=True, ... partial_pixel_method="center" ... ) >>> photometry_options PhotometryOptionalSettings(include_dig_noise=True, reject_too_close=False,... reject_background_outliers=True, fwhm_method=<FwhmMethods.FIT: 'fit'>,... You can also change individual options after the object is created: >>> photometry_options.reject_background_outliers = False >>> photometry_options.reject_background_outliers False """ # This ensures that just the first line of the docstring is used as the # model description. The json schema is really hard to read if the # description is too long. # When a subclass has a ConfigDict it is merged with the parent class's # ConfigDict. model_config = ConfigDict( json_schema_extra=dict( description=_extract_short_description(__doc__), ) ) # Made this a static method to define inside this class, since this is the only # class that needs to handle this. @staticmethod def _validate_fwhm_method(value: Any) -> Any: if isinstance(value, bool): # This is likely an old fwhm_by_fit option, so we convert to one # of the new options. # Note that originally False meant MOMENTS but it turns out that # method is completely incorrect return FwhmMethods.FIT if value else FwhmMethods.PROFILE else: # Pass the value along to pydantic to validate return value include_dig_noise: Annotated[ bool, Field( title="Include Digitization Noise", description=( "Should the digitization noise be included in the calculation of " "the noise for each observation?" ), ), ] = True reject_too_close: Annotated[ bool, Field( description=( "Should sources that are too close each other be " "excluded from photometry?" ) ), ] = True reject_background_outliers: Annotated[ bool, Field( description=( "Should extreme pixels in the annulus be rejected " "(e.g. from a star in the annulus)?" ) ), ] = True fwhm_method: Annotated[ FwhmMethods, BeforeValidator(_validate_fwhm_method), Field( description=( "Method for finding the FWHM of the star. 'fit' fits a 1D Gaussian to " "the star, 'profiile' fits a 1D Gaussian to the radial profile, and " "'moments' uses second order moments of the image, which is terrible." ), validation_alias=AliasChoices( "fwhm_by_fit", # for backwards compatibility, "fwhm_method", # yes, pydantic does make you do this ), ), ] = FwhmMethods.PROFILE partial_pixel_method: Annotated[ Literal["exact", "center", "subpixel"], Field( description="How to handle partial pixels in the aperture.", validation_alias=AliasChoices( "method", # for backwards compatibility, "partial_pixel_method", # yes, pydantic does make you do this ), ), ] = "exact"
class PassbandMapEntry(BaseModel): """ A mapping from a single filter name to its corresponding AAVSO filter name. Parameters ---------- your_filter_name : str Instrumental filter name. aavso_filter_name : `stellarphot.settings.AAVSOFilters` AAVSO filter name. """ # This ensures that just the first line of the docstring is used as the # model description. The json schema is really hard to read if the # description is too long. model_config = ConfigDict( json_schema_extra=dict( description=_extract_short_description(__doc__), ) ) your_filter_name: Annotated[ NonEmptyStr, Field(description="Instrumental Filter Name") ] aavso_filter_name: Annotated[AAVSOFilters, Field(title="AAVSO Filter Name")]
[docs] class PassbandMap(BaseModelWithTableRep): """ Class to represent a mapping from one set of filter names to another that behaves like a `dict`. Parameters ---------- name : str Name of the passband map. your_filter_names_to_aavso : list[`stellarphot.settings.PassbandMapEntry`] A list of pairs of your filter name and the corresponding AAVSO filter name. This is used to rename the passband entries in the output photometry table. Note that, as shown in the example below, you can initialize this with a dictionary, and it will be converted to a list of `PassbandMapEntry` objects. Notes ----- This class behaves like a dictionary in terms of accessing individual entries but you _cannot_ use the `dict` methods to modify the object. This means, for example, that if ``my_map`` is a `PassbandMap` object, you can access the AAVSO passband that corresponds to your ``B`` passband with ``my_map["B"]`` but you _cannot_ set entries like this ``my_map["B"] = "B"`` and you _cannot_ delete entries like this ``del my_map["B"]``. Examples -------- >>> from stellarphot.settings import PassbandMap >>> passband_map = PassbandMap( ... name="Sample map", ... your_filter_names_to_aavso={"B": "B", "rp": "SR"} ... ) >>> passband_map PassbandMap(name='Sample map', your_filter_names_to_aavso=[PassbandMapEntry(your_... >>> # You can access the AAVSO filter name for a given filter name using dict syntax >>> passband_map["B"] 'B' >>> passband_map["rp"] 'SR' >>> # If you prefer you can access the individual entries in >>> # the list of PassbandMapEntry >>> passband_map.your_filter_names_to_aavso[1] PassbandMapEntry(your_filter_name='rp', aavso_filter_name=<AAVSOFilters.SR: 'SR'>) >>> # Getting the AAVSO filter name this way is a little cumbersome though: >>> passband_map.your_filter_names_to_aavso[1].aavso_filter_name.value 'SR' """ # This ensures that just the first line of the docstring is used as the # model description. The json schema is really hard to read if the # description is too long. # WHen a subclass has a ConfigDict it is merged with the parent class's # ConfigDict. model_config = ConfigDict( json_schema_extra=dict( description=_extract_short_description(__doc__), ) ) name: Annotated[ NonEmptyStr, Field( description="Name of the passband map", examples=["Filter wheel 1"], ), ] your_filter_names_to_aavso: list[PassbandMapEntry]
[docs] def model_post_init(self, __context: Any) -> None: # Create a dictionary from the list of entries so that the object # can behave like a dictionary. self._dict = { entry.your_filter_name: entry.aavso_filter_name.value for entry in self.your_filter_names_to_aavso }
[docs] @field_validator("your_filter_names_to_aavso", mode="before") @classmethod def validate_your_filter_names_to_aavso(cls, v): if isinstance(v, PassbandMap): return v.your_filter_names_to_aavso elif isinstance(v, dict): return [ PassbandMapEntry(your_filter_name=k, aavso_filter_name=v) for k, v in v.items() ] else: return v
# All of the remaining methods are to make the object behave like a dictionary. # It would have been preferable to subclass `collections.UserDict` but that # doesn't work with pydantic models because UserDict objects have a .data attribute # but we don't want a pydantic field named "data".
[docs] def items(self): return self._dict.items()
[docs] def keys(self): return self._dict.keys()
[docs] def values(self): return self._dict.values()
[docs] def get(self, key, default=None): return self._dict.get(key, default)
def __contains__(self, key): return key in self._dict def __getitem__(self, key): return self._dict[key] def __iter__(self): return iter(self._dict)
[docs] 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, no log file will be created. console_log: bool, optional (Default: True) If ``True``, log messages will be written to stdout. If ``False``, log messages will not be written to stdout. Examples -------- >>> from stellarphot.settings import LoggingSettings >>> logging_settings = LoggingSettings() >>> logging_settings LoggingSettings(logfile=None, console_log=True) """ # This ensures that just the first line of the docstring is used as the # model description. The json schema is really hard to read if the # description is too long. # WHen a subclass has a ConfigDict it is merged with the parent class's # ConfigDict. model_config = ConfigDict( json_schema_extra=dict( description=_extract_short_description(__doc__), ) ) logfile: Annotated[str | None, Field(description="File to save log to")] = None console_log: Annotated[bool, Field(description="Show log on console?")] = True
SCHEMA_EXTRAS = dict(show_null=True)
[docs] 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. """ # This ensures that just the first line of the docstring is used as the # model description. The json schema is really hard to read if the # description is too long. # WHen a subclass has a ConfigDict it is merged with the parent class's # ConfigDict. model_config = ConfigDict( json_schema_extra=dict( description=_extract_short_description(__doc__), ) ) camera: Annotated[ Camera, Field( description=_extract_short_description(Camera.__doc__), json_schema_extra=SCHEMA_EXTRAS, ), ] observatory: Annotated[ Observatory, Field( description=_extract_short_description(Observatory.__doc__), json_schema_extra=SCHEMA_EXTRAS, ), ] passband_map: Annotated[ PassbandMap | None, Field( description=_extract_short_description(PassbandMap.__doc__), json_schema_extra=SCHEMA_EXTRAS, ), ] photometry_apertures: Annotated[ PhotometryApertures, Field( description=_extract_short_description(PhotometryApertures.__doc__), json_schema_extra=SCHEMA_EXTRAS, ), ] source_location_settings: Annotated[ SourceLocationSettings, Field( description=_extract_short_description(SourceLocationSettings.__doc__), json_schema_extra=SCHEMA_EXTRAS, ), ] photometry_optional_settings: Annotated[ PhotometryOptionalSettings, Field( description=_extract_short_description(PhotometryOptionalSettings.__doc__), json_schema_extra=SCHEMA_EXTRAS, ), ] logging_settings: Annotated[ LoggingSettings, Field( description=_extract_short_description(LoggingSettings.__doc__), json_schema_extra=SCHEMA_EXTRAS, ), ]
# The code for _make_partial_model is adapted from # https://github.com/pydantic/pydantic/issues/3120#issuecomment-1528030416 BaseModelT = TypeVar("BaseModelT", bound=BaseModel) def _make_partial_model(model: type[BaseModelT], default=None) -> type[BaseModelT]: new_fields = {} for field_name, field_info in model.model_fields.items(): new = deepcopy(field_info) new.default = default new.annotation = field_info.annotation | None # type: ignore # noqa: UP007 new_fields[field_name] = (new.annotation, new) return create_model( # type: ignore f"Partial{model.__name__}", __base__=model, __doc__=model.__doc__, __module__=model.__module__, **new_fields, ) PartialPhotometrySettings = _make_partial_model(PhotometrySettings)
[docs] class Exoplanet(BaseModelWithTableRep): """ Create an object representing an Exoplanet. Parameters ---------- epoch : `astropy.time.Time`, optional Epoch of the exoplanet. period : `astropy.units.Quantity`, optional Period of the exoplanet. Identifier : str Identifier of the exoplanet. coordinate : `astropy.coordinates.SkyCoord` Coordinates of the exoplanet. depth : float Depth of the exoplanet. duration : `astropy.units.Quantity`, optional Duration of the exoplanet transit. Examples -------- To create an `Exoplanet` object, you can pass in the epoch, period, identifier, coordinate, depth, and duration as keyword arguments: >>> from astropy.time import Time >>> from astropy.coordinates import SkyCoord >>> from astropy import units as u >>> planet = Exoplanet(epoch=Time(2455909.29280, format="jd"), ... period=1.21749 * u.day, ... identifier="KELT-1b", ... coordinate=SkyCoord(ra="00:01:26.9169", ... dec="+39:23:01.7821", ... frame="icrs", ... unit=("hour", "degree")), ... depth=0.006, ... duration=120 * u.min) """ model_config = MODEL_DEFAULT_CONFIGURATION epoch: Annotated[Time, AstropyValidator] | None = None period: Annotated[QuantityType, WithPhysicalType("time")] | None = None identifier: str coordinate: Annotated[SkyCoord, AstropyValidator] depth: float | None = None duration: Annotated[QuantityType, WithPhysicalType("time")] | None = None
[docs] class PhotometryRunSettings(BaseModel): """ Settings for a photometry run. Parameters ---------- photometry_settings : `stellarphot.settings.PhotometrySettings` Settings for performing aperture photometry. exoplanet : `stellarphot.settings.Exoplanet`, optional Information about an exoplanet transit. """ directory_with_images: Path = "." photometry_settings_file: Path = "photometry_settings.json" reject_unmatched: Annotated[ bool, Field( description="Drop any stars that do not appear in all images", tooltip="Reject unmatched stars", ), ] = False object_of_interest: str | None = None