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

Update Camera object #231

Merged
merged 10 commits into from
Dec 24, 2023
Merged
169 changes: 133 additions & 36 deletions stellarphot/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
from astropy.coordinates import EarthLocation, SkyCoord
from astropy.table import Column, QTable, Table
from astropy.time import Time
from astropy.units import Quantity
from astropy.units import Quantity, Unit
from astropy.wcs import WCS

from astroquery.vizier import Vizier

import pandas as pd
from pydantic import BaseModel, root_validator
from pydantic import BaseModel, root_validator, Field, validator

import numpy as np

Expand All @@ -30,6 +30,36 @@
# https://docs.gammapy.org/dev/_modules/gammapy/analysis/config.html


class UnitType(Unit):
Copy link
Contributor

Choose a reason for hiding this comment

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

So I assume this is adding pydantic validators to the astropy units class? Do you know if it is pydantic v2 compatible? There is a part of me that worries that pydantic v2 is still waiting to bite us in the future.

# Validator for Unit type
@classmethod
def __get_validators__(cls):
yield cls.validate

@classmethod
def validate(cls, v):
Copy link
Contributor

Choose a reason for hiding this comment

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

What is this validate doing? It just returns the Unit cast version of v? Does it actually validate anything?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If the creation of the unit fails (e.g. Unit("nonsense")) then it generates a ValidationError

return Unit(v)

@classmethod
def __modify_schema__(cls, field_schema, field):
# Set default values for the schema in case the field doesn't provide them
Copy link
Contributor

Choose a reason for hiding this comment

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

I am honestly not sure how to assess what is going on here with __modify_schema__, need to dig into pydantic again or trust your tests. :). I will note __modify_schema__ doesn't appear to be supported in v2 of pydantic (based on https://docs.pydantic.dev/latest/errors/usage_errors/#custom-json-schema).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

To the extent I understand it, this is providing some of the information needed to generate a json schema for types that are either not built in to Python or pydantic.

I wish ipyautoui had already made the 1 to 2 transition so we could just code for 2. It looks like there is a replacement for __modify_schema__ in 2.

name = "Unit"
description = "An astropy unit"

name = field.name or name
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this setting name to a true/false value based on field.name or name as a boolean?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This sets name to field.name unless field.name evaluates to False, in which case name is used.

description = field.field_info.description or description
examples = field.field_info.extra.get("examples", [])

field_schema.update(
{
"title": name,
"description": description,
"examples": examples,
"type": "string",
}
)


class QuantityType(Quantity):
# Validator for Quantity type
@classmethod
Expand All @@ -47,6 +77,25 @@ def validate(cls, v):
raise ValueError("Must provided a unit")
return v

@classmethod
def __modify_schema__(cls, field_schema, field):
# Set default values for the schema in case the field doesn't provide them
name = "Quantity"
description = "An astropy Quantity with units"

name = field.name or name
description = field.field_info.description or description
examples = field.field_info.extra.get("examples", [])

field_schema.update(
{
"title": name,
"description": description,
"examples": examples,
"type": "string",
}
)


class PixelScaleType(Quantity):
# Validator for pixel scale type
Expand All @@ -68,6 +117,17 @@ def validate(cls, v):
raise ValueError(f"Invalid unit for pixel scale: {v.unit!r}")
return v

@classmethod
def __modify_schema__(cls, field_schema):
field_schema.update(
{
"title": "PixelScale",
"description": "An astropy Quantity with units of angle per pixel",
"examples": ["0.563 arcsec / pix"],
"type": "string",
}
)


class Camera(BaseModel):
"""
Expand All @@ -76,37 +136,51 @@ class Camera(BaseModel):
Parameters
----------

gain : `astropy.quantity.Quantity`
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.quantity.Quantity`
read_noise : `astropy.units.Quantity`
The read noise of the camera with units.

dark_current : `astropy.quantity.Quantity`
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.quantity.Quantity`
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
----------

gain : `astropy.quantity.Quantity`
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.quantity.Quantity`
read_noise : `astropy.units.Quantity`
The read noise of the camera with units.

dark_current : `astropy.quantity.Quantity`
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.quantity.Quantity`
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
Expand All @@ -116,10 +190,14 @@ class Camera(BaseModel):
--------
>>> from astropy import units as u
>>> from stellarphot import Camera
>>> camera = Camera(gain=1.0 * u.electron / u.adu,
>>> camera = Camera(data_unit="adu",
... gain=1.0 * u.electron / u.adu,
... read_noise=1.0 * u.electron,
... dark_current=0.01 * u.electron / u.second,
... pixel_scale=0.563 * u.arcsec / u.pixel)
... 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.read_noise
Expand All @@ -128,13 +206,32 @@ class Camera(BaseModel):
<Quantity 0.01 electron / s>
>>> camera.pixel_scale
<Quantity 0.563 arcsec / pix>

>>> camera.max_data_value
<Quantity 50000. adu>
"""

gain: QuantityType
read_noise: QuantityType
dark_current: QuantityType
pixel_scale: PixelScaleType
data_unit: UnitType = Field(
description="units of the data", examples=["adu", "counts", "DN", "electrons"]
)
gain: QuantityType = Field(
description="unit should be consistent with data and read noise",
examples=["1.0 electron / adu"],
)
read_noise: QuantityType = Field(
description="unit should be consistent with dark current",
examples=["10.0 electron"],
)
dark_current: QuantityType = Field(
description="unit consisten with read noise, per unit time",
Copy link
Contributor

Choose a reason for hiding this comment

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

"unit consistent" (typo)

examples=["0.01 electron / second"],
)
pixel_scale: PixelScaleType = Field(
description="units of angle per pixel", examples=["0.6 arcsec / pix"]
)
max_data_value: QuantityType = Field(
description="maximum data value while performing photometry",
examples=["50000 adu"],
)

class Config:
validate_all = True
Expand All @@ -143,6 +240,7 @@ class Config:
json_encoders = {
Quantity: lambda v: f"{v.value} {v.unit}",
QuantityType: lambda v: f"{v.value} {v.unit}",
UnitType: lambda v: f"{v}",
PixelScaleType: lambda v: f"{v.value} {v.unit}",
}

Expand All @@ -155,12 +253,12 @@ def validate_gain(cls, values):
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 is read noise per data unit.
gain = values["gain"]
if (
len(gain.unit.bases) != 2
or gain.unit.bases[0] != rn_unit
or gain.unit.bases[1] != u.adu
or gain.unit.bases[1] != values["data_unit"]
):
raise ValueError(
f"Gain units {gain.unit} are not compatible with "
Expand All @@ -180,25 +278,20 @@ def validate_gain(cls, values):
f"compatible with read noise units {rn_unit}."
)

# Dark current validates against read noise
# Check that maximum data value is consistent with data units
if values["max_data_value"].unit != values["data_unit"]:
raise ValueError(
f"Maximum data value units {values['max_data_value'].unit} "
f"are not consistent with data units {values['data_unit']}."
)
return values

def copy(self):
return Camera(
gain=self.gain,
read_noise=self.read_noise,
dark_current=self.dark_current,
pixel_scale=self.pixel_scale,
)

def __copy__(self):
return self.copy()

def __repr__(self):
return (
f"Camera(gain={self.gain}, read_noise={self.read_noise}, "
f"dark_current={self.dark_current}, pixel_scale={self.pixel_scale})"
)
@validator("max_data_value")
@classmethod
def validate_max_data_value(cls, v):
if v.value <= 0:
raise ValueError("max_data_value must be positive")
return v


class BaseEnhancedTable(QTable):
Expand Down Expand Up @@ -624,10 +717,12 @@ def __init__(
self.meta["lat"] = observatory.lat
self.meta["lon"] = observatory.lon
self.meta["height"] = observatory.height
self.meta["data_unit"] = camera.data_unit
self.meta["gain"] = camera.gain
self.meta["read_noise"] = camera.read_noise
self.meta["dark_current"] = camera.dark_current
self.meta["pixel_scale"] = camera.pixel_scale
self.meta["max_data_value"] = camera.max_data_value

# Check for consistency of counts-related columns
counts_columns = [
Expand Down Expand Up @@ -753,10 +848,12 @@ def add_bjd_col(self, observatory):
@property
def camera(self):
return Camera(
data_unit=self.meta["data_unit"],
gain=self.meta["gain"],
read_noise=self.meta["read_noise"],
dark_current=self.meta["dark_current"],
pixel_scale=self.meta["pixel_scale"],
max_data_value=self.meta["max_data_value"],
)

@property
Expand Down
24 changes: 9 additions & 15 deletions stellarphot/notebooks/photometry/03-photometry-template.ipynb
Copy link
Contributor

Choose a reason for hiding this comment

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

All changes consistent with moving max_adu to Camera.

Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,11 @@
"source": [
"from pathlib import Path\n",
"\n",
"import numpy as np\n",
"\n",
"import astropy.units as u\n",
"from astropy.nddata import CCDData\n",
"from astropy.coordinates import SkyCoord, EarthLocation\n",
"from astropy.table import Table\n",
"from astropy.time import Time\n",
"from astropy.coordinates import EarthLocation\n",
"from astropy import units as u\n",
"\n",
"from stellarphot import Camera, SourceListData\n",
"from stellarphot.photometry import *\n",
"from stellarphot.photometry import multi_image_photometry\n",
"from stellarphot.gui_tools.photometry_widget_functions import PhotometrySettings\n"
]
},
Expand All @@ -41,15 +36,14 @@
"##\n",
"## Setting defining the observatory and camera\n",
"##\n",
"feder_cg_16m = Camera(gain = 1.5 * u.electron / u.adu,\n",
"feder_cg_16m = Camera(data_unit=u.adu,\n",
" gain = 1.5 * u.electron / u.adu,\n",
" read_noise = 10.0 * u.electron,\n",
" dark_current=0.01 * u.electron / u.second,\n",
" pixel_scale = 0.563 * u.arcsec / u.pix)\n",
" pixel_scale = 0.563 * u.arcsec / u.pix,\n",
" max_adu = 50000 * u.adu,)\n",
"feder_filters = {'up':'SU', 'gp':'SG', 'rp':'SR', 'zp':'SZ', 'ip':'SI', 'V': 'V', 'B': 'B'}\n",
"feder_obs = EarthLocation(lat = 46.86678, lon=-96.45328, height=311)\n",
"\n",
"# Maxmimum ADU for the images before we should consider pixels saturated\n",
"max_adu = 50000\n"
"feder_obs = EarthLocation(lat = 46.86678, lon=-96.45328, height=311)\n"
]
},
{
Expand Down Expand Up @@ -158,7 +152,7 @@
" sources, feder_cg_16m,\n",
" feder_obs,\n",
" aperture_settings,\n",
" shift_tolerance, max_adu, fwhm_estimate,\n",
" shift_tolerance, fwhm_estimate,\n",
" include_dig_noise=True,\n",
" reject_too_close=True,\n",
" reject_background_outliers=True,\n",
Expand Down
Loading