astro-otter 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
@@ -0,0 +1,76 @@
1
+ """
2
+ OtterPlotter Class which handles the backend of the plotting.
3
+
4
+ Currently supported backends are:
5
+ - matplotlib
6
+ - plotly
7
+ """
8
+
9
+ from __future__ import annotations
10
+ import importlib
11
+ import numpy as np
12
+
13
+
14
+ class OtterPlotter:
15
+ """
16
+ Handles the backend for the "plotter" module
17
+
18
+ Args:
19
+ backend (string): a string of the module name to import and use
20
+ as the backend. Currently supported are "matplotlib",
21
+ "matplotlib.pyplot", "plotly", and "plotly.graph_objects"
22
+ """
23
+
24
+ def __init__(self, backend):
25
+ if backend == "matplotlib.pyplot":
26
+ self.backend = backend
27
+ elif backend == "plotly.graph_objects":
28
+ self.backend = backend
29
+ elif "plotly" in backend and "graph_objects" not in backend:
30
+ self.backend = "plotly.graph_objects"
31
+ elif "matplotlib" in backend and "pyplot" not in backend:
32
+ self.backend = "matplotlib.pyplot"
33
+ else:
34
+ raise ValueError("Not a valid backend string!")
35
+
36
+ self.plotter = importlib.import_module(self.backend)
37
+
38
+ if self.backend == "matplotlib.pyplot":
39
+ self.plot = self._plot_matplotlib
40
+ elif self.backend == "plotly.graph_objects":
41
+ self.plot = self._plot_plotly
42
+ else:
43
+ raise ValueError("Unknown plotting backend!")
44
+
45
+ def _plot_matplotlib(self, x, y, xerr=None, yerr=None, ax=None, **kwargs):
46
+ """
47
+ General plots using matplotlib, is called by _matplotlib_light_curve and
48
+ _matplotlib_sed
49
+ """
50
+
51
+ if ax is None:
52
+ _, ax = self.plotter.subplots()
53
+
54
+ if yerr is not None:
55
+ yerr = np.abs(np.array(yerr))
56
+ ax.errorbar(x, y, xerr=xerr, yerr=yerr, **kwargs)
57
+ return ax
58
+
59
+ def _plot_plotly(self, x, y, xerr=None, yerr=None, ax=None, *args, **kwargs):
60
+ """
61
+ General plotting method using plotly, is called by _plotly_light_curve and
62
+ _plotly_sed
63
+ """
64
+
65
+ if ax is None:
66
+ go = self.plotter.Figure()
67
+ else:
68
+ go = ax
69
+
70
+ if yerr is not None:
71
+ yerr = np.abs(np.array(yerr))
72
+ fig = go.add_scatter(
73
+ x=x, y=y, error_x=dict(array=xerr), error_y=dict(array=yerr), **kwargs
74
+ )
75
+
76
+ return fig
@@ -0,0 +1,266 @@
1
+ """
2
+ Some utilities to create common plots for transients that use the OtterPlotter
3
+ """
4
+
5
+ from __future__ import annotations
6
+ from warnings import warn
7
+
8
+ import matplotlib.pyplot as plt
9
+ import numpy as np
10
+ import pandas as pd
11
+
12
+ from .otter_plotter import OtterPlotter
13
+ from ..exceptions import FailedQueryError
14
+ from ..io.otter import Transient, Otter
15
+
16
+
17
+ def query_quick_view(
18
+ db: Otter,
19
+ ptype: str = "both",
20
+ sed_dim: str = "freq",
21
+ dt_over_t: float = 0,
22
+ plotting_kwargs: dict = {},
23
+ phot_cleaning_kwargs: dict = {},
24
+ result_length_tol=10,
25
+ **kwargs,
26
+ ) -> list[plt.Figure]:
27
+ """
28
+ Queries otter and then plots all of the transients. It will either query the Otter
29
+ object provided in db or construct an Otter object from otter_path.
30
+
31
+ Args:
32
+ db (otter.Otter) : The otter object to query
33
+ ptype (str) : The plot type to generate. Valid options are
34
+ - both -> Plot both light curve and sed (default)
35
+ - sed -> Plot just the sed
36
+ - lc -> Plot just the light curve
37
+ sed_dim (str) : The x dimension to plot in the SED. Options are "freq" or
38
+ "wave". Default is "freq".
39
+ time_tol (float) : The tolerance to split the days by. Default is 1 day. must be
40
+ in units of days.
41
+ plotting_kwargs (dict) : dictionary of key word arguments to pass to
42
+ otter.plotter.plot_light_curve or
43
+ otter.plotter.plot_sed.
44
+ phot_cleaning_kwargs (dict) : Keyword arguments passed to
45
+ otter.Transient.clean_photometry
46
+ result_length_tol (int) : If the query result is longer than this it will throw
47
+ an erorr to prevent 100s of plots from spitting out
48
+ (and likely crashing your computer). Default is 10 but
49
+ can be adjusted.
50
+ **kwargs : Arguments to pass to otter.Otter.query
51
+
52
+ Returns:
53
+ A list of matplotlib pyplot Figure objects that we plotted
54
+
55
+ """
56
+ res = db.query(**kwargs)
57
+
58
+ if len(res) > result_length_tol:
59
+ raise RuntimeError(
60
+ f"This query returned {len(res)} results which is greater than the given "
61
+ + f"tolerance of {result_length_tol}! Either increase the result_length_tol"
62
+ + " keyword or pass in a stricter query!"
63
+ )
64
+
65
+ figs = []
66
+ for t in res:
67
+ try:
68
+ fig = quick_view(
69
+ t, ptype, sed_dim, dt_over_t, plotting_kwargs, **phot_cleaning_kwargs
70
+ )
71
+ except (KeyError, FailedQueryError):
72
+ warn(f"No photometry associated with {t.default_name}, skipping!")
73
+ continue
74
+
75
+ fig.suptitle(t.default_name)
76
+ figs.append(fig)
77
+
78
+ return figs
79
+
80
+
81
+ def quick_view(
82
+ t: Transient,
83
+ ptype: str = "both",
84
+ sed_dim: str = "freq",
85
+ dt_over_t: float = 0,
86
+ plotting_kwargs: dict = {},
87
+ **kwargs,
88
+ ) -> plt.Figure:
89
+ """
90
+ Generate a quick view (not necessarily publication ready) of the transients light
91
+ curve, SED, or both. Default is to do both.
92
+
93
+ Args:
94
+ t (otter.Transient) : An otter Transient object to grab photometry from
95
+ ptype (str) : The plot type to generate. Valid options are
96
+ - both -> Plot both light curve and sed (default)
97
+ - sed -> Plot just the sed
98
+ - lc -> Plot just the light curve
99
+ sed_dim (str) : The x dimension to plot in the SED. Options are "freq" or
100
+ "wave". Default is "freq".
101
+ dt_over_t (float) : The tolerance to split the days by. Default is 1 day. must
102
+ be unitless.
103
+ plotting_kwargs (dict) : dictionary of key word arguments to pass to
104
+ otter.plotter.plot_light_curve or
105
+ otter.plotter.plot_sed.
106
+ **kwargs : Any other arguments to pass to otter.Transient.clean_photometry
107
+
108
+ Returns:
109
+ The matplotlib figure used for plotting.
110
+ """
111
+ backend = plotting_kwargs.get("backend", "matplotlib.pyplot")
112
+ if backend not in {"matplotlib.pyplot", "matplotlib"}:
113
+ raise ValueError(
114
+ "Only matplotlib.pyplot backend is available for quick_view!"
115
+ + " To use plotly, use the plotting functionality individually!"
116
+ )
117
+
118
+ allphot = t.clean_photometry(**kwargs)
119
+ allphot = allphot.sort_values("converted_date")
120
+ allphot["time_tol"] = dt_over_t * allphot["converted_date"]
121
+ allphot["time_diff"] = allphot["converted_date"].diff().fillna(-np.inf)
122
+ allphot["time_grp"] = (allphot.time_diff > allphot.time_tol).cumsum()
123
+
124
+ plt_lc = (ptype == "both") or (ptype == "lc")
125
+ plt_sed = (ptype == "both") or (ptype == "sed")
126
+
127
+ if ptype == "both":
128
+ fig, (lc_ax, sed_ax) = plt.subplots(1, 2)
129
+ elif ptype == "sed":
130
+ fig, sed_ax = plt.subplots()
131
+ elif ptype == "lc":
132
+ fig, lc_ax = plt.subplots()
133
+
134
+ if np.all(pd.isna(allphot.converted_flux_err)):
135
+ flux_err = None
136
+ else:
137
+ flux_err = allphot.converted_flux_err
138
+
139
+ if plt_lc:
140
+ for filt, phot in allphot.groupby("filter_name"):
141
+ plot_light_curve(
142
+ date=phot.converted_date,
143
+ flux=phot.converted_flux,
144
+ flux_err=flux_err[allphot.filter_name == filt],
145
+ xlabel=f"Date [{phot.converted_date_unit.values[0]}]",
146
+ ylabel=f"Flux [{phot.converted_flux_unit.values[0]}]",
147
+ ax=lc_ax,
148
+ label=filt,
149
+ **plotting_kwargs,
150
+ )
151
+
152
+ if plt_sed:
153
+ for grp_name, phot in allphot.groupby("time_grp"):
154
+ if sed_dim == "wave":
155
+ wave_or_freq = phot.converted_wave
156
+ xlab = f"Wavelength [{phot.converted_wave_unit.values[0]}]"
157
+ elif sed_dim == "freq":
158
+ wave_or_freq = phot.converted_freq
159
+ xlab = f"Frequency [{phot.converted_freq_unit.values[0]}]"
160
+ else:
161
+ raise ValueError("sed_dim value is not recognized!")
162
+
163
+ plot_sed(
164
+ wave_or_freq=wave_or_freq,
165
+ flux=phot.converted_flux,
166
+ flux_err=flux_err[allphot.time_grp == grp_name],
167
+ ax=sed_ax,
168
+ xlabel=xlab,
169
+ ylabel=f"Flux [{phot.converted_flux_unit.values[0]}]",
170
+ label=phot.converted_date.mean(),
171
+ **plotting_kwargs,
172
+ )
173
+
174
+ sed_ax.set_xscale("log")
175
+
176
+ return fig
177
+
178
+
179
+ def plot_light_curve(
180
+ date: float,
181
+ flux: float,
182
+ date_err: float = None,
183
+ flux_err: float = None,
184
+ fig=None,
185
+ ax=None,
186
+ backend: str = "matplotlib",
187
+ xlabel: str = "Date",
188
+ ylabel: str = "Flux",
189
+ **kwargs,
190
+ ):
191
+ """
192
+ Plot the light curve for the input data
193
+
194
+ Args:
195
+ date (float): MJD dates
196
+ flux (float): Flux
197
+ date_err (float): optional error on the MJD dates
198
+ flux_err (float): optional error on the flux
199
+ fig (float): matplotlib fig object, optional. Will be created if not provided.
200
+ ax (float): matplitlib axis object, optional. Will be created if not provided.
201
+ backend (str): backend for plotting. options: "matplotlib" (default) or "plotly"
202
+ xlabel (str): x-axis label
203
+ ylabel (str): y-axis label
204
+ **kwargs: keyword arguments to pass to either plotly.graph_objects.add_scatter
205
+ or matplotlib.pyplot.errorbar
206
+
207
+ Returns:
208
+ Either a matplotlib axis or plotly figure
209
+ """
210
+
211
+ plt = OtterPlotter(backend)
212
+ fig = plt.plot(date, flux, date_err, flux_err, ax=ax, **kwargs)
213
+
214
+ if backend == "matplotlib":
215
+ fig.set_ylabel(ylabel)
216
+ fig.set_xlabel(xlabel)
217
+
218
+ elif backend == "plotly":
219
+ fig.update_layout(xaxis_title=xlabel, yaxis_title=ylabel)
220
+
221
+ return fig
222
+
223
+
224
+ def plot_sed(
225
+ wave_or_freq: float,
226
+ flux: float,
227
+ wave_or_freq_err: float = None,
228
+ flux_err: float = None,
229
+ fig=None,
230
+ ax=None,
231
+ backend: str = "matplotlib",
232
+ xlabel: str = "Frequency or Wavelength",
233
+ ylabel: str = "Flux",
234
+ **kwargs,
235
+ ):
236
+ """
237
+ Plot the SED for the input data
238
+
239
+ Args:
240
+ wave_or_freq (float): wave or frequency array
241
+ flux (float): Flux
242
+ wave_or_freq_err (float): optional error on the MJD dates
243
+ flux_err (float): optional error on the flux
244
+ fig (float): matplotlib fig object, optional. Will be created if not provided.
245
+ ax (float): matplitlib axis object, optional. Will be created if not provided.
246
+ backend (str): backend for plotting. Options: "matplotlib" (default) or "plotly"
247
+ xlabel (str): x-axis label
248
+ ylabel (str): y-axis label
249
+ **kwargs: keyword arguments to pass to either plotly.graph_objects.add_scatter
250
+ or matplotlib.pyplot.errorbar
251
+
252
+ Returns:
253
+ Either a matplotlib axis or plotly figure
254
+ """
255
+
256
+ plt = OtterPlotter(backend)
257
+ fig = plt.plot(wave_or_freq, flux, wave_or_freq_err, flux_err, ax=ax, **kwargs)
258
+
259
+ if backend == "matplotlib":
260
+ fig.set_ylabel(ylabel)
261
+ fig.set_xlabel(xlabel)
262
+
263
+ elif backend == "plotly":
264
+ fig.update_layout(xaxis_title=xlabel, yaxis_title=ylabel)
265
+
266
+ return fig
otter/schema.py ADDED
@@ -0,0 +1,312 @@
1
+ """
2
+ Pydantic Schema Model of our JSON schema
3
+ """
4
+
5
+ from pydantic import BaseModel, model_validator, field_validator
6
+ from typing import Optional, Union, List
7
+
8
+
9
+ class VersionSchema(BaseModel):
10
+ value: Union[str, int] = None
11
+ comment: str = None
12
+
13
+
14
+ class _AliasSchema(BaseModel):
15
+ value: str
16
+ reference: Union[str, List[str]]
17
+
18
+
19
+ class _XrayModelSchema(BaseModel):
20
+ # the following two lines are needed to prevent annoying warnings
21
+ model_config: dict = {}
22
+ model_config["protected_namespaces"] = ()
23
+
24
+ # required keywords
25
+ model_name: str
26
+ param_names: List[str]
27
+ param_values: List[Union[float, int, str]]
28
+ param_units: List[Union[str, None]]
29
+ min_energy: Union[float, int, str]
30
+ max_energy: Union[float, int, str]
31
+ energy_units: str
32
+
33
+ # optional keywords
34
+ param_value_err_upper: Optional[List[Union[float, int, str]]] = None
35
+ param_value_err_lower: Optional[List[Union[float, int, str]]] = None
36
+ param_upperlimit: Optional[List[Union[float, int, str]]] = None
37
+ param_descriptions: Optional[List[str]] = None
38
+ model_reference: Optional[Union[str, List[str]]] = None
39
+
40
+
41
+ class _ErrDetailSchema(BaseModel):
42
+ # all optional keywords!
43
+ upper: Optional[List[Union[float, int, str]]] = None
44
+ lower: Optional[List[Union[float, int, str]]] = None
45
+ systematic: Optional[List[Union[float, int, str]]] = None
46
+ statistical: Optional[List[Union[float, int, str]]] = None
47
+ iss: Optional[List[Union[float, int, str]]] = None
48
+
49
+
50
+ class NameSchema(BaseModel):
51
+ default_name: str
52
+ alias: list[_AliasSchema]
53
+
54
+
55
+ class CoordinateSchema(BaseModel):
56
+ reference: Union[List[str], str]
57
+ ra: Union[str, float] = None
58
+ dec: Union[str, float] = None
59
+ l: Union[str, float] = None # noqa: E741
60
+ b: Union[str, float] = None
61
+ lon: Union[str, float] = None
62
+ lat: Union[str, float] = None
63
+ ra_units: str = None
64
+ dec_units: str = None
65
+ l_units: str = None
66
+ b_units: str = None
67
+ lon_units: str = None
68
+ lat_units: str = None
69
+ ra_error: Union[str, float] = None
70
+ dec_error: Union[str, float] = None
71
+ l_error: Union[str, float] = None
72
+ b_error: Union[str, float] = None
73
+ lon_error: Union[str, float] = None
74
+ lat_error: Union[str, float] = None
75
+ epoch: str = None
76
+ frame: str = "J2000"
77
+ coord_type: str = None
78
+ computed: bool = False
79
+ default: bool = False
80
+
81
+ @model_validator(mode="after")
82
+ def _has_coordinate(self):
83
+ uses_ra_dec = self.ra is not None and self.dec is not None
84
+ uses_galactic = self.l is not None and self.b is not None
85
+ uses_lon_lat = self.lon is not None and self.lat is not None
86
+
87
+ if uses_ra_dec:
88
+ if self.ra_units is None:
89
+ raise ValueError("ra_units must be provided for RA!")
90
+ if self.dec_units is None:
91
+ raise ValueError("dec_units must be provided for Dec!")
92
+
93
+ elif uses_galactic:
94
+ if self.l_units is None:
95
+ raise ValueError("l_units must be provided for RA!")
96
+ if self.b_units is None:
97
+ raise ValueError("b_units must be provided for Dec!")
98
+
99
+ elif uses_lon_lat:
100
+ if self.lon_units is None:
101
+ raise ValueError("lon_units must be provided for RA!")
102
+ if self.lat_units is None:
103
+ raise ValueError("lat_units must be provided for Dec!")
104
+
105
+ else:
106
+ raise ValueError("Must have RA/Dec, l/b, and/or lon/lat!")
107
+
108
+ return self
109
+
110
+
111
+ class DistanceSchema(BaseModel):
112
+ value: Union[str, float, int]
113
+ unit: str = None
114
+ reference: Union[str, List[str]]
115
+ distance_type: str
116
+ error: Union[str, float, int] = None
117
+ cosmology: str = None
118
+ computed: bool = False
119
+ uuid: str = None
120
+ default: bool = False
121
+
122
+ @model_validator(mode="after")
123
+ def _has_units(self):
124
+ if self.distance_type != "redshift" and self.unit is None:
125
+ raise ValueError("Need units if the distance_type is not redshift!")
126
+
127
+ return self
128
+
129
+
130
+ class ClassificationSchema(BaseModel):
131
+ object_class: str
132
+ confidence: float
133
+ reference: Union[str, List[str]]
134
+ default: bool = False
135
+ class_type: str = None
136
+
137
+
138
+ class ClassificationDictSchema(BaseModel):
139
+ spec_classed: Optional[int] = None
140
+ unambiguous: Optional[bool] = None
141
+ value: list[ClassificationSchema]
142
+
143
+
144
+ class ReferenceSchema(BaseModel):
145
+ name: str
146
+ human_readable_name: str
147
+
148
+
149
+ class DateSchema(BaseModel):
150
+ value: Union[str, int, float]
151
+ date_format: str
152
+ date_type: str
153
+ reference: Union[str, List[str]]
154
+ computed: bool = None
155
+
156
+
157
+ class PhotometrySchema(BaseModel):
158
+ reference: Union[List[str], str]
159
+ raw: list[Union[float, int]]
160
+ raw_err: Optional[List[float]] = []
161
+ raw_units: Union[str, List[str]]
162
+ value: Optional[list[Union[float, int]]] = None
163
+ value_err: Optional[list[Union[float, int]]] = None
164
+ value_units: Optional[Union[str, List[str]]] = None
165
+ epoch_zeropoint: Optional[Union[float, str, int]] = None
166
+ epoch_redshift: Optional[Union[float, int]] = None
167
+ filter: Optional[Union[str, List[str]]] = None
168
+ filter_key: Union[str, List[str]]
169
+ obs_type: Union[str, List[str]]
170
+ telescope_area: Optional[Union[float, List[float]]] = None
171
+ date: Union[str, float, List[Union[str, float]]]
172
+ date_format: Union[str, List[str]]
173
+ date_err: Optional[Union[str, float, List[Union[str, float]]]] = None
174
+ date_min: Optional[Union[str, float, List[Union[str, float]]]] = None
175
+ date_max: Optional[Union[str, float, List[Union[str, float]]]] = None
176
+ ignore: Optional[Union[bool, List[bool]]] = None
177
+ upperlimit: Optional[Union[bool, List[bool]]] = None
178
+ sigma: Optional[Union[str, float, List[Union[str, float]]]] = None
179
+ sky: Optional[Union[str, float, List[Union[str, float]]]] = None
180
+ telescope: Optional[Union[str, List[str]]] = None
181
+ instrument: Optional[Union[str, List[str]]] = None
182
+ phot_type: Optional[Union[str, List[str]]] = None
183
+ exptime: Optional[Union[str, int, float, List[Union[str, int, float]]]] = None
184
+ aperture: Optional[Union[str, int, float, List[Union[str, int, float]]]] = None
185
+ observer: Optional[Union[str, List[str]]] = None
186
+ reducer: Optional[Union[str, List[str]]] = None
187
+ pipeline: Optional[Union[str, List[str]]] = None
188
+ corr_k: Optional[Union[bool, str, List[Union[bool, str]]]] = None
189
+ corr_s: Optional[Union[bool, str, List[Union[bool, str]]]] = None
190
+ corr_av: Optional[Union[bool, str, List[Union[bool, str]]]] = None
191
+ corr_host: Optional[Union[bool, str, List[Union[bool, str]]]] = None
192
+ corr_hostav: Optional[Union[bool, str, List[Union[bool, str]]]] = None
193
+ val_k: Optional[Union[float, int, str, List[Union[float, int, str]]]] = None
194
+ val_s: Optional[Union[float, int, str, List[Union[float, int, str]]]] = None
195
+ val_av: Optional[Union[float, int, str, List[Union[float, int, str]]]] = None
196
+ val_host: Optional[Union[float, int, str, List[Union[float, int, str]]]] = None
197
+ val_hostav: Optional[Union[float, int, str, List[Union[float, int, str]]]] = None
198
+ xray_model: Optional[Union[List[_XrayModelSchema], List[None]]] = None
199
+ raw_err_detail: Optional[_ErrDetailSchema] = None
200
+ value_err_detail: Optional[_ErrDetailSchema] = None
201
+
202
+ @field_validator(
203
+ "raw_units",
204
+ "raw_err",
205
+ "filter_key",
206
+ "obs_type",
207
+ "date_format",
208
+ "upperlimit",
209
+ "date",
210
+ "telescope",
211
+ )
212
+ @classmethod
213
+ def ensure_list(cls, v):
214
+ if not isinstance(v, list):
215
+ return [v]
216
+ return v
217
+
218
+ @model_validator(mode="after")
219
+ def _ensure_min_and_max_date(self):
220
+ """
221
+ This will make sure that if date_min is provided so is date_max
222
+ """
223
+ if (self.date_min is not None and self.date_max is None) or (
224
+ self.date_min is None and self.date_max is not None
225
+ ):
226
+ raise ValueError(
227
+ "If you provide date_min or date_max you must provide the other!"
228
+ )
229
+
230
+ @model_validator(mode="after")
231
+ def _ensure_xray_model(self):
232
+ """
233
+ This will eventually ensure the xray_model key is used if obs_type="xray"
234
+
235
+ It will be commented out until we get the data setup correctly
236
+ """
237
+ # if self.obs_type == "xray" and self.xray_model is None:
238
+ # raise ValueError(
239
+ # "Need an xray_model for this xray data!"
240
+ # )
241
+
242
+ return self
243
+
244
+
245
+ class FilterSchema(BaseModel):
246
+ filter_key: str
247
+ filter_name: str
248
+ wave_eff: Union[str, float, int] = None
249
+ wave_min: Union[str, float, int] = None
250
+ wave_max: Union[str, float, int] = None
251
+ freq_eff: Union[str, float, int] = None
252
+ freq_min: Union[str, float, int] = None
253
+ freq_max: Union[str, float, int] = None
254
+ zp: Union[str, float, int] = None
255
+ wave_units: Union[str, float, int] = None
256
+ freq_units: Union[str, float, int] = None
257
+ zp_units: Union[str, float, int] = None
258
+ zp_system: Union[str, float, int] = None
259
+
260
+
261
+ class HostSchema(BaseModel):
262
+ reference: Union[str, List[str]]
263
+ host_ra: Optional[Union[str, float]] = None
264
+ host_dec: Optional[Union[str, float]] = None
265
+ host_ra_units: Optional[str] = None
266
+ host_dec_units: Optional[str] = None
267
+ host_z: Optional[Union[str, int, float]] = None
268
+ host_type: Optional[str] = None
269
+ host_name: Optional[str] = None
270
+
271
+ @model_validator(mode="after")
272
+ def _has_coordinate_or_name(self):
273
+ has_coordinate = self.host_ra is not None and self.host_dec is not None
274
+ has_name = self.host_name is not None
275
+
276
+ # if it has the RA/Dec keys, make sure it also has ra_unit, dec_unit keys
277
+ if has_coordinate:
278
+ if self.host_ra_units is None:
279
+ raise ValueError("Need RA unit if coordinates are provided!")
280
+ if self.host_dec_units is None:
281
+ raise ValueError("Need Dec unit if coordinates are provided!")
282
+
283
+ # we need either the coordinate or name to identify this object
284
+ # Both are okay too (more info is always better)
285
+ if not has_coordinate and not has_name:
286
+ raise ValueError("Need to provide a Host name and/or host coordinates!")
287
+
288
+ # Make sure that if one of RA/Dec is given then both are given
289
+ if (self.host_ra is None and self.host_dec is not None) or (
290
+ self.host_ra is not None and self.host_dec is None
291
+ ):
292
+ raise ValueError("Please provide RA AND Dec, not just one or the other!")
293
+
294
+ return self
295
+
296
+
297
+ class OtterSchema(BaseModel):
298
+ schema_version: Optional[VersionSchema] = None
299
+ name: NameSchema
300
+ coordinate: list[CoordinateSchema]
301
+ distance: Optional[list[DistanceSchema]] = None
302
+ classification: Optional[ClassificationDictSchema] = None
303
+ reference_alias: list[ReferenceSchema]
304
+ date_reference: Optional[list[DateSchema]] = None
305
+ photometry: Optional[list[PhotometrySchema]] = None
306
+ filter_alias: Optional[list[FilterSchema]] = None
307
+ host: Optional[list[HostSchema]] = None
308
+
309
+ @model_validator(mode="after")
310
+ def _verify_filter_alias(self):
311
+ if self.photometry is not None and self.filter_alias is None:
312
+ raise ValueError("filter_alias is needed if photometry is given!")