Repository: feder-observatory/stellarphot

Issue:
Maybe make `Camera` and `Observatory` table attributes
The `PhotometryTable` code has this comment:

```python
# Add the TableAttributes directly to meta (and adding attribute
# functions below) since using TableAttributes results in a
# inability to access the values to due a
# AttributeError: 'TableAttribute' object has no attribute 'name'
```

I was able to work out today how to make things like `Camera` round trip to `ECSV` -- should we make these attributes so that the objects don't have to be unpacked into `meta` the way they are [here](https://github.com/feder-observatory/stellarphot/blob/main/stellarphot/core.py#L624)?

Discussion:
codecov-commenter: ## [Codecov](https://app.codecov.io/gh/feder-observatory/stellarphot/pull/233?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=feder-observatory) Report
Attention: `4 lines` in your changes are missing coverage. Please review.
> Comparison is base [(`d35a332`)](https://app.codecov.io/gh/feder-observatory/stellarphot/commit/d35a332de435fe27e598a39db279752bd41a7588?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=feder-observatory) 53.80% compared to head [(`23f3940`)](https://app.codecov.io/gh/feder-observatory/stellarphot/pull/233?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=feder-observatory) 53.73%.
> Report is 1 commits behind head on main.

| [Files](https://app.codecov.io/gh/feder-observatory/stellarphot/pull/233?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=feder-observatory) | Patch % | Lines |
|---|---|---|
| [stellarphot/core.py](https://app.codecov.io/gh/feder-observatory/stellarphot/pull/233?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=feder-observatory#diff-c3RlbGxhcnBob3QvY29yZS5weQ==) | 71.42% | [4 Missing :warning: ](https://app.codecov.io/gh/feder-observatory/stellarphot/pull/233?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=feder-observatory) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #233      +/-   ##
==========================================
- Coverage   53.80%   53.73%   -0.07%     
==========================================
  Files          23       23              
  Lines        2881     2881              
==========================================
- Hits         1550     1548       -2     
- Misses       1331     1333       +2     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/feder-observatory/stellarphot/pull/233?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=feder-observatory).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=feder-observatory).


Patch:
diff --git a/stellarphot/core.py b/stellarphot/core.py
index 569fb20..141bd12 100644
--- a/stellarphot/core.py
+++ b/stellarphot/core.py
@@ -2,7 +2,8 @@ import re
 
 from astropy import units as u
 from astropy.coordinates import EarthLocation, SkyCoord
-from astropy.table import Column, QTable, Table
+from astropy.io.misc.yaml import AstropyDumper, AstropyLoader
+from astropy.table import Column, QTable, Table, TableAttribute
 from astropy.time import Time
 from astropy.units import Quantity, Unit, IrreducibleUnit
 from astropy.wcs import WCS
@@ -295,6 +296,19 @@ class Camera(BaseModel):
         return v
 
 
+# Add YAML round-tripping for Camera
+def camera_representer(dumper, cam):
+    return dumper.represent_mapping("!Camera", cam.dict())
+
+
+def camera_constructor(loader, node):
+    return Camera(**loader.construct_mapping(node))
+
+
+AstropyDumper.add_representer(Camera, camera_representer)
+AstropyLoader.add_constructor("!Camera", camera_constructor)
+
+
 class BaseEnhancedTable(QTable):
     """
     A class to validate an `astropy.table.QTable` table of astronomical data during
@@ -527,7 +541,7 @@ class BaseEnhancedTable(QTable):
 class PhotometryData(BaseEnhancedTable):
     """
     A modified `astropy.table.QTable` to hold reduced photometry data that
-    provides the convience of validating the data table is in the proper
+    provides the convenience of validating the data table is in the proper
     format including units.  It returns an `PhotometryData` which is
     a `astropy.table.QTable` with additional attributes describing
     the observatory and camera.
@@ -658,36 +672,26 @@ class PhotometryData(BaseEnhancedTable):
         "passband": None,
         "file": None,
     }
-    observatory = None
-    camera = None
+
+    observatory = TableAttribute(default=None)
+    camera = TableAttribute(default=None)
 
     def __init__(
         self,
         *args,
         input_data=None,
-        observatory=None,
-        camera=None,
         colname_map=None,
         passband_map=None,
         retain_user_computed=False,
         **kwargs,
     ):
-        if (observatory is None) and (camera is None) and (input_data is None):
+        if (
+            (self.observatory is None)
+            and (self.camera is None)
+            and (input_data is None)
+        ):
             super().__init__(*args, **kwargs)
         else:
-            # Perform input validation
-            if not isinstance(observatory, EarthLocation):
-                raise TypeError(
-                    "observatory must be an "
-                    "astropy.coordinates.EarthLocation object instead "
-                    f"of type {type(observatory)}."
-                )
-            if not isinstance(camera, Camera):
-                raise TypeError(
-                    "camera must be a stellarphot.Camera object instead "
-                    f"of type {type(camera)}."
-                )
-
             # Check the time column is correct format and scale
             try:
                 if input_data["date-obs"][0].scale != "utc":
@@ -697,7 +701,7 @@ class PhotometryData(BaseEnhancedTable):
                         f"not '{input_data['date-obs'][0].scale}'."
                     )
             except AttributeError:
-                # Happens if first item dosn't have a "scale"
+                # Happens if first item doesn't have a "scale"
                 raise ValueError(
                     "input_data['date-obs'] isn't column of "
                     "astropy.time.Time entries."
@@ -711,19 +715,30 @@ class PhotometryData(BaseEnhancedTable):
                 **kwargs,
             )
 
+            # Perform input validation
+            if not isinstance(self.observatory, EarthLocation):
+                raise TypeError(
+                    "observatory must be an "
+                    "astropy.coordinates.EarthLocation object instead "
+                    f"of type {type(self.observatory)}."
+                )
+            if not isinstance(self.camera, Camera):
+                raise TypeError(
+                    "camera must be a stellarphot.Camera object instead "
+                    f"of type {type(self.camera)}."
+                )
+
             # Add the TableAttributes directly to meta (and adding attribute
             # functions below) since using TableAttributes results in a
             # inability to access the values to due a
             # AttributeError: 'TableAttribute' object has no attribute 'name'
-            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
+            # self.meta["lat"] = observatory.lat
+            # self.meta["lon"] = observatory.lon
+            # self.meta["height"] = observatory.height
+            # 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
 
             # Check for consistency of counts-related columns
             counts_columns = [
@@ -777,13 +792,13 @@ class PhotometryData(BaseEnhancedTable):
                     # python>=3.10)
                     match this_col:
                         case "bjd":
-                            self["bjd"] = self.add_bjd_col(observatory)
+                            self["bjd"] = self.add_bjd_col(self.observatory)
 
                         case "night":
                             # Generate integer counter for nights. This should be
                             # approximately the MJD at noon local before the evening of
                             # the observation.
-                            hr_offset = int(observatory.lon.value / 15)
+                            hr_offset = int(self.observatory.lon.value / 15)
                             # Compute offset to 12pm Local Time before evening
                             LocalTime = Time(self["date-obs"]) + hr_offset * u.hr
                             hr = LocalTime.ymdhms.hour
@@ -846,23 +861,6 @@ class PhotometryData(BaseEnhancedTable):
             # Return BJD at midpoint of exposure at each location
             return Time(time_barycenter + self["exposure"] / 2, scale="tdb")
 
-    @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
-    def observatory(self):
-        return EarthLocation(
-            lat=self.meta["lat"], lon=self.meta["lon"], height=self.meta["height"]
-        )
-
 
 class CatalogData(BaseEnhancedTable):
     """
diff --git a/stellarphot/settings/models.py b/stellarphot/settings/models.py
index 5a06de4..37451fa 100644
--- a/stellarphot/settings/models.py
+++ b/stellarphot/settings/models.py
@@ -2,19 +2,11 @@
 
 from pathlib import Path
 
-from pydantic import BaseModel, Field, conint, validator
+from pydantic import BaseModel, Field, conint
 
 from .autowidgets import CustomBoundedIntTex
 
-from astropy.time import Time
-from astropy.coordinates import SkyCoord
-from astropy.units import Quantity
-import astropy.units as u
-
-from ..core import QuantityType
-
-
-__all__ = ["ApertureSettings", "PhotometryFileSettings", "Exoplanet"]
+__all__ = ["ApertureSettings", "PhotometryFileSettings"]
 
 
 class ApertureSettings(BaseModel):
@@ -48,9 +40,7 @@ class ApertureSettings(BaseModel):
     To create an `ApertureSettings` object, you can pass in the radius, gap,
     and annulus_width as keyword arguments:
 
-    >>> aperture_settings = ApertureSettings(radius=4,
-    ...                                      gap=10,
-    ...                                      annulus_width=15)
+    >>> aperture_settings = ApertureSettings(radius=4, gap=10, annulus_width=15)
     """
 
     radius: conint(ge=1) = Field(autoui=CustomBoundedIntTex, default=1)
@@ -90,113 +80,3 @@ class PhotometryFileSettings(BaseModel):
     aperture_locations_file: Path = Field(
         filter_pattern=["*.ecsv", "*.csv"], default=""
     )
-
-
-class TimeType(Time):
-    @classmethod
-    def __get_validators__(cls):
-        yield cls.validate
-
-    @classmethod
-    def validate(cls, v):
-        return Time(v)
-
-
-class SkyCoordType(SkyCoord):
-    @classmethod
-    def __get_validators__(cls):
-        yield cls.validate
-
-    @classmethod
-    def validate(cls, v):
-        return SkyCoord(v)
-
-
-class Exoplanet(BaseModel):
-    """
-    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)
-    """
-
-    epoch: TimeType | None = None
-    period: QuantityType | None = None
-    identifier: str
-    coordinate: SkyCoordType
-    depth: float | None = None
-    duration: QuantityType | None = None
-
-    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}",
-            Time: lambda v: f"{v.value}",
-        }
-
-    @validator("period")
-    @classmethod
-    def validate_period(cls, value):
-        """
-        Checks that the period has physical units of time and raises an error
-        if that is not true.
-        """
-        if u.get_physical_type(value) != "time":
-            raise ValueError(
-                f"Period does not have time units," f"currently has {value.unit} units."
-            )
-        return value
-
-    @validator("duration")
-    @classmethod
-    def validate_duration(cls, value):
-        """
-        Checks that the duration has physical units of time and raises an error
-        if that is not true.
-        """
-        if u.get_physical_type(value) != "time":
-            raise ValueError(
-                f"Duration does not have time units,"
-                f"currently has {value.unit} units."
-            )
-        return value


Test patch:
diff --git a/stellarphot/settings/tests/test_models.py b/stellarphot/settings/tests/test_models.py
index 3cb7ab6..1bef5e8 100644
--- a/stellarphot/settings/tests/test_models.py
+++ b/stellarphot/settings/tests/test_models.py
@@ -1,11 +1,8 @@
-from astropy.time import Time
 from pydantic import ValidationError
 import pytest
 
-from stellarphot.settings.models import ApertureSettings, Exoplanet
+from stellarphot.settings.models import ApertureSettings
 
-from astropy.coordinates import SkyCoord
-import astropy.units as u
 
 DEFAULT_APERTURE_SETTINGS = dict(radius=5, gap=10, annulus_width=15)
 
@@ -32,36 +29,3 @@ def test_create_invalid_values(bad_one):
     bad_settings[bad_one] = -1
     with pytest.raises(ValidationError, match=bad_one):
         ApertureSettings(**bad_settings)
-
-
-DEFAULT_EXOPLANET_SETTINGS = dict(
-    epoch=Time(0, format="jd"),
-    period=0 * u.min,
-    identifier="a planet",
-    coordinate=SkyCoord(
-        ra="00:00:00.00", dec="+00:00:00.0", frame="icrs", unit=("hour", "degree")
-    ),
-    depth=0,
-    duration=0 * u.min,
-)
-
-
-def test_create_exoplanet_correctly():
-    planet = Exoplanet(**DEFAULT_EXOPLANET_SETTINGS)
-    print(planet)
-    assert planet.epoch == DEFAULT_EXOPLANET_SETTINGS["epoch"]
-    assert u.get_physical_type(planet.period) == "time"
-    assert planet.identifier == DEFAULT_EXOPLANET_SETTINGS["identifier"]
-    assert planet.coordinate == DEFAULT_EXOPLANET_SETTINGS["coordinate"]
-    assert planet.depth == DEFAULT_EXOPLANET_SETTINGS["depth"]
-    assert u.get_physical_type(planet.duration) == "time"
-
-
-def test_create_invalid_exoplanet():
-    values = DEFAULT_EXOPLANET_SETTINGS.copy()
-    # Make pediod and duration have invalid units for a time
-    values["period"] = values["period"].value * u.m
-    values["duration"] = values["duration"].value * u.m
-    # Check that individual values that are bad raise an error
-    with pytest.raises(ValidationError, match="2 validation errors"):
-        Exoplanet(**values)
diff --git a/stellarphot/tests/test_core.py b/stellarphot/tests/test_core.py
index edd93b4..1860408 100644
--- a/stellarphot/tests/test_core.py
+++ b/stellarphot/tests/test_core.py
@@ -660,6 +660,8 @@ def test_photometry_blank():
     test_base = PhotometryData()
     assert isinstance(test_base, PhotometryData)
     assert len(test_base) == 0
+    assert test_base.camera is None
+    assert test_base.observatory is None
 
 
 def test_photometry_data():