Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added a pydantic-validated Camera class and test. #163

Merged
merged 4 commits into from
Aug 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
New Features
^^^^^^^^^^^^
+ Development of new data classes for handling source list, photometry, and catalog data which include data format validation. [#125]
+ Aperture photometry streamlined into ``single_image_photometry`` ``multi_image_photometry`` functions that use the new data classes. [#141]
+ Photometry related notebooks updated to use new data classes. [#151]
+ Aperture photometry streamlined into ``single_image_photometry`` and ``multi_image_photometry`` functions that use the new data classes. [#141]
+ ``multi_image_photometry`` now is a wrapper for single_image_photometry instead of a completely separate function.
+ Logging has been implemented for photmetry, so all the output can now be logged to a file. [#150]
+ Photometry related notebooks updated to use new data classes and new functions. [#151]
+ Logging has been implemented for photometry, so all the output can now be logged to a file. [#150]

Other Changes and Additions
^^^^^^^^^^^^^^^^^^^^^^^^^^^
Expand Down
130 changes: 95 additions & 35 deletions stellarphot/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,73 @@
from astropy.coordinates import EarthLocation, SkyCoord
from astropy.table import Column, QTable, Table
from astropy.time import Time
from astropy.units import Quantity

from pydantic import BaseModel, root_validator

import numpy as np

__all__ = ['Camera', 'BaseEnhancedTable', 'PhotometryData', 'CatalogData',
'SourceListData']


class Camera:
# Approach to validation of units was inspired by the GammaPy project
# which did it before we did:
# https://docs.gammapy.org/dev/_modules/gammapy/analysis/config.html

class QuantityType(Quantity):
# Validator for Quantity type
@classmethod
def __get_validators__(cls):
yield cls.validate

@classmethod
def validate(cls, v):
try:
v = Quantity(v)
except TypeError:
raise ValueError(f"Invalid value for Quantity: {v}")
JuanCab marked this conversation as resolved.
Show resolved Hide resolved
else:
if not v.unit.bases:
raise ValueError("Must provided a unit")
return v


class PixelScaleType(Quantity):
# Validator for pixel scale type
@classmethod
def __get_validators__(cls):
yield cls.validate

@classmethod
def validate(cls, v):
try:
v = Quantity(v)
except TypeError:
raise ValueError(f"Invalid value for Quantity: {v}")
JuanCab marked this conversation as resolved.
Show resolved Hide resolved
if (len(v.unit.bases) != 2 or
v.unit.bases[0].physical_type != "angle" or
v.unit.bases[1].name != "pix" ):
raise ValueError(f"Invalid unit for pixel scale: {v.unit!r}")
return v

class Camera(BaseModel):
"""
A class to represent a CCD-based camera.

Parameters
----------

gain : `astropy.quantity.Quantity`
The gain of the camera in units such that the unit of the product `gain`
times the image data matches the unit of the `read_noise`.
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.quantity.Quantity`
The read noise of the camera with units.

dark_current : `astropy.quantity.Quantity`
The dark current of the camera in units such that, when multiplied by
exposure time, the unit matches the unit of the `read_noise`.
exposure time, the unit matches the units of the `read_noise`.

pixel_scale : `astropy.quantity.Quantity`
The pixel scale of the camera in units of arcseconds per pixel.
Expand All @@ -34,15 +77,15 @@ class Camera:
----------

gain : `astropy.quantity.Quantity`
The gain of the camera in units such that the unit of the product
`gain` times the image data matches the unit of the `read_noise`.
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.quantity.Quantity`
The read noise of the camera in units of electrons.
The read noise of the camera with units.

dark_current : `astropy.quantity.Quantity`
The dark current of the camera in units such that, when multiplied
by exposure time, the unit matches the unit of the `read_noise`.
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.quantity.Quantity`
The pixel scale of the camera in units of arcseconds per pixel.
Expand Down Expand Up @@ -70,31 +113,48 @@ class Camera:
<Quantity 0.563 arcsec / pix>

"""
def __init__(self, gain=1.0 * u.electron / u.adu,
read_noise=1.0 * u.electron,
dark_current=0.01 * u.electron / u.second,
pixel_scale=1.0 * u.arcsec / u.pixel):
super().__init__()

# Check that input has units and if not crap out.
if isinstance(gain, u.Quantity):
self.gain = gain
else:
raise TypeError("gain must have units.")

if isinstance(read_noise, u.Quantity):
self.read_noise = read_noise
else:
raise TypeError("read_noise must have units.")

if isinstance(dark_current, u.Quantity):
self.dark_current = dark_current
else:
raise TypeError("dark_current must have units.")
if isinstance(pixel_scale, u.Quantity):
self.pixel_scale = pixel_scale
else:
raise TypeError("pixel_scale must have units.")
gain: QuantityType
read_noise: QuantityType
dark_current: QuantityType
pixel_scale: PixelScaleType

class Config:
validate_all = True
validate_assignment = True
extra = "forbid"
json_encoders = {
Quantity: lambda v: f"{v.value} {v.unit}",
QuantityType: lambda v: f"{v.value} {v.unit}",
PixelScaleType: lambda v: f"{v.value} {v.unit}"
}

# When the switch to pydantic v2 happens, this root_validator will need
# to be replaced by a model_validator decorator.
@root_validator(skip_on_failure=True)
@classmethod
def validate_gain(cls, values):
JuanCab marked this conversation as resolved.
Show resolved Hide resolved
# Get read noise units
rn_unit = Quantity(values['read_noise']).unit

# Check that gain and read noise have compatible units, that is that
# gain is read noise per adu.
gain = values['gain']
if (len(gain.unit.bases) != 2 or gain.unit.bases[0] != rn_unit or
gain.unit.bases[1] != u.adu):
raise ValueError(f"Gain units {gain.unit} are not compatible with "
f"read noise units {rn_unit}.")

# Check that dark current and read noise have compatible units, that is
# that dark current is read noise per second.
dark_current = values['dark_current']
if (len(dark_current.unit.bases) != 2 or
dark_current.unit.bases[0] != rn_unit or
dark_current.unit.bases[1] != u.s):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think all we need here is a check of the physical type of the denominator -- if the user wants to give it in electrons / century, then great, I guess....

Suggested change
dark_current.unit.bases[1] != u.s):
dark_current.unit.bases[1].physical_type != "time"):

raise ValueError(f"Dark current units {dark_current.unit} are not "
f"compatible with read noise units {rn_unit}.")

# Dark current validates against read noise
return values

def copy(self):
return Camera(gain=self.gain,
Expand Down Expand Up @@ -807,5 +867,5 @@ def drop_x_y(self):
self.meta['has_x_y'] = False
self['xcenter'] = Column(data=np.full(len(self), np.nan), name='ra',
unit=u.deg)
self['ycenter'] = Column(data=np.full(len(self), np.nan), name='dec',
self['ycenter'] = Column(data=np.full(len(self), np.nan), name='dec',
unit=u.deg)
26 changes: 21 additions & 5 deletions stellarphot/tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from astropy.io import ascii
from astropy.coordinates import EarthLocation
from astropy.utils.data import get_pkg_data_filename

from pydantic import ValidationError
from stellarphot.core import (Camera, BaseEnhancedTable, PhotometryData,
CatalogData, SourceListData)

Expand All @@ -32,28 +32,44 @@ def test_camera_unitscheck():
dark_current = 0.01 * u.electron / u.second
pixel_scale = 0.563 * u.arcsec / u.pix

with pytest.raises(TypeError):
with pytest.raises(ValidationError, match="gain"):
c = Camera(gain=gain.value,
read_noise=read_noise,
dark_current=dark_current,
pixel_scale=pixel_scale)
with pytest.raises(TypeError):
with pytest.raises(ValidationError, match="read_noise"):
c = Camera(gain=gain,
read_noise=read_noise.value,
dark_current=dark_current,
pixel_scale=pixel_scale)
with pytest.raises(TypeError):
with pytest.raises(ValidationError, match="dark_current"):
c = Camera(gain=gain,
read_noise=read_noise,
dark_current=dark_current.value,
pixel_scale=pixel_scale)
with pytest.raises(TypeError):
with pytest.raises(ValidationError, match="pixel_scale"):
c = Camera(gain=gain,
read_noise=read_noise,
dark_current=dark_current,
pixel_scale=pixel_scale.value)


def test_camera_altunitscheck():
# Check to see that 'count' is allowed instead of 'electron'
gain = 2.0 * u.count / u.adu
read_noise = 10 * u.count
dark_current = 0.01 * u.count / u.second
pixel_scale = 0.563 * u.arcsec / u.pix
c = Camera(gain=gain,
read_noise=read_noise,
dark_current=dark_current,
pixel_scale=pixel_scale)
assert c.gain == gain
assert c.dark_current == dark_current
assert c.read_noise == read_noise
assert c.pixel_scale == pixel_scale


# Create several test descriptions for use in base_enhanced_table tests.
test_descript = {'id': None,
'ra': u.deg,
Expand Down