1use skymath::Angle;
10
11use crate::error::{Error, Result};
12
13pub const ARCSEC_PER_RADIAN: f64 = skymath::ARCSEC_PER_RADIAN;
15pub const ARCSEC_PER_DEGREE: f64 = 3600.0;
17pub const DEFAULT_FALLBACK_RADIUS: Angle = Angle::from_radians(5.0 * core::f64::consts::PI / 180.0);
19
20#[derive(Debug, Clone, Copy, PartialEq)]
23#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
24pub struct Optics {
25 pub focal_mm: f64,
27 pub pixel_um: (f64, f64),
29 pub binning: (u32, u32),
31 pub pixels: (u32, u32),
33}
34
35#[derive(Debug, Clone, Copy, PartialEq)]
37#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
38pub enum RadiusPolicy {
39 Circumscribed,
41 Inscribed,
43 Multiplier(f64),
45 Explicit(Angle),
47}
48
49#[derive(Debug, Clone, Copy, PartialEq)]
51#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
52pub struct Field {
53 fov: (Angle, Angle),
54 pixel_scale: Option<(f64, f64)>, }
56
57impl Field {
58 pub fn from_optics(o: Optics) -> Result<Self> {
67 let focal = finite_positive(o.focal_mm, "focal length")?;
68 let (px, py) = (
69 finite_positive(o.pixel_um.0, "pixel size x")?,
70 finite_positive(o.pixel_um.1, "pixel size y")?,
71 );
72 let (bx, by) = (
73 positive_count(o.binning.0, "binning x")?,
74 positive_count(o.binning.1, "binning y")?,
75 );
76 let (nx, ny) = (
77 positive_count(o.pixels.0, "naxis1")?,
78 positive_count(o.pixels.1, "naxis2")?,
79 );
80 let scale_x = (px * bx) / 1000.0 / focal * ARCSEC_PER_RADIAN;
82 let scale_y = (py * by) / 1000.0 / focal * ARCSEC_PER_RADIAN;
83 Ok(Self {
84 fov: (
85 Angle::from_arcseconds(scale_x * nx),
86 Angle::from_arcseconds(scale_y * ny),
87 ),
88 pixel_scale: Some((scale_x, scale_y)),
89 })
90 }
91
92 pub fn from_pixel_scale(scale_arcsec_px: (f64, f64), pixels: (u32, u32)) -> Result<Self> {
98 let sx = finite_positive(scale_arcsec_px.0, "pixel scale x")?;
99 let sy = finite_positive(scale_arcsec_px.1, "pixel scale y")?;
100 let nx = positive_count(pixels.0, "naxis1")?;
101 let ny = positive_count(pixels.1, "naxis2")?;
102 Ok(Self {
103 fov: (
104 Angle::from_arcseconds(sx * nx),
105 Angle::from_arcseconds(sy * ny),
106 ),
107 pixel_scale: Some((sx, sy)),
108 })
109 }
110
111 pub fn from_fov(width: Angle, height: Angle) -> Result<Self> {
117 finite_positive(width.degrees(), "field width")?;
118 finite_positive(height.degrees(), "field height")?;
119 Ok(Self {
120 fov: (width, height),
121 pixel_scale: None,
122 })
123 }
124
125 #[must_use]
127 pub fn width(self) -> Angle {
128 self.fov.0
129 }
130 #[must_use]
132 pub fn height(self) -> Angle {
133 self.fov.1
134 }
135 #[must_use]
137 pub fn diagonal(self) -> Angle {
138 Angle::from_degrees(self.fov.0.degrees().hypot(self.fov.1.degrees()))
139 }
140 #[must_use]
142 pub fn pixel_scale(self) -> Option<(f64, f64)> {
143 self.pixel_scale
144 }
145
146 #[must_use]
148 pub fn radius(self, policy: RadiusPolicy) -> Angle {
149 match policy {
150 RadiusPolicy::Circumscribed => Angle::from_degrees(self.diagonal().degrees() / 2.0),
151 RadiusPolicy::Inscribed => {
152 Angle::from_degrees(self.fov.0.degrees().min(self.fov.1.degrees()) / 2.0)
153 }
154 RadiusPolicy::Multiplier(m) => Angle::from_degrees(self.diagonal().degrees() / 2.0 * m),
155 RadiusPolicy::Explicit(a) => a,
156 }
157 }
158}
159
160fn finite_positive(v: f64, what: &str) -> Result<f64> {
161 if v.is_finite() && v > 0.0 {
162 Ok(v)
163 } else {
164 Err(Error::InvalidOptics(format!(
165 "{what} must be finite and > 0, got {v}"
166 )))
167 }
168}
169
170fn positive_count(v: u32, what: &str) -> Result<f64> {
171 if v >= 1 {
172 Ok(f64::from(v))
173 } else {
174 Err(Error::InvalidOptics(format!("{what} must be >= 1")))
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 fn approx(a: f64, b: f64, eps: f64) -> bool {
183 (a - b).abs() < eps
184 }
185
186 fn asi2600_800mm() -> Optics {
187 Optics {
188 focal_mm: 800.0,
189 pixel_um: (3.76, 3.76),
190 binning: (1, 1),
191 pixels: (6248, 4176),
192 }
193 }
194
195 #[test]
196 fn from_optics_matches_hand_calc() {
197 let f = Field::from_optics(asi2600_800mm()).unwrap();
198 let (sx, sy) = f.pixel_scale().unwrap();
199 assert!(approx(sx, 0.9694, 1e-3), "scale {sx}");
200 assert!(approx(sy, 0.9694, 1e-3));
201 assert!(
202 approx(f.width().degrees(), 1.683, 5e-3),
203 "w {}",
204 f.width().degrees()
205 );
206 assert!(
207 approx(f.height().degrees(), 1.125, 5e-3),
208 "h {}",
209 f.height().degrees()
210 );
211 assert!(approx(
213 f.radius(RadiusPolicy::Circumscribed).degrees(),
214 1.012,
215 5e-3
216 ));
217 }
218
219 #[test]
220 fn binning_doubles_scale_and_fov() {
221 let mut o = asi2600_800mm();
222 o.binning = (2, 2);
223 let f = Field::from_optics(o).unwrap();
224 let (sx, _) = f.pixel_scale().unwrap();
225 assert!(approx(sx, 2.0 * 0.9694, 2e-3), "binned scale {sx}");
226 assert!(
228 approx(f.width().degrees(), 2.0 * 1.683, 1e-2),
229 "w {}",
230 f.width().degrees()
231 );
232 }
233
234 #[test]
235 fn binning_fov_doubles() {
236 let base = Field::from_optics(asi2600_800mm()).unwrap();
237 let mut o = asi2600_800mm();
238 o.binning = (2, 2);
239 let binned = Field::from_optics(o).unwrap();
240 assert!(approx(
241 binned.width().degrees(),
242 2.0 * base.width().degrees(),
243 1e-6
244 ));
245 }
246
247 #[test]
248 fn from_fov_and_pixel_scale_paths() {
249 let direct =
250 Field::from_fov(Angle::from_degrees(1.683), Angle::from_degrees(1.125)).unwrap();
251 assert!(direct.pixel_scale().is_none());
252 assert!(approx(
253 direct.radius(RadiusPolicy::Circumscribed).degrees(),
254 1.012,
255 5e-3
256 ));
257
258 let by_scale = Field::from_pixel_scale((0.9694, 0.9694), (6248, 4176)).unwrap();
259 assert!(approx(by_scale.width().degrees(), 1.683, 5e-3));
260 assert_eq!(by_scale.pixel_scale(), Some((0.9694, 0.9694)));
261 }
262
263 #[test]
264 fn radius_policies() {
265 let f = Field::from_fov(Angle::from_degrees(2.0), Angle::from_degrees(1.0)).unwrap();
266 assert!(approx(
267 f.radius(RadiusPolicy::Inscribed).degrees(),
268 0.5,
269 1e-9
270 )); let circ = f.radius(RadiusPolicy::Circumscribed).degrees();
272 assert!(approx(circ, (2.0_f64.hypot(1.0)) / 2.0, 1e-9));
273 assert!(approx(
274 f.radius(RadiusPolicy::Multiplier(2.0)).degrees(),
275 circ * 2.0,
276 1e-9
277 ));
278 assert!(approx(
279 f.radius(RadiusPolicy::Explicit(Angle::from_degrees(3.0)))
280 .degrees(),
281 3.0,
282 1e-9
283 ));
284 }
285
286 #[test]
287 fn rejects_bad_optics() {
288 let mut o = asi2600_800mm();
289 o.focal_mm = 0.0;
290 assert!(matches!(
291 Field::from_optics(o).unwrap_err(),
292 Error::InvalidOptics(_)
293 ));
294 let mut o2 = asi2600_800mm();
295 o2.pixel_um = (-1.0, 3.76);
296 assert!(matches!(
297 Field::from_optics(o2).unwrap_err(),
298 Error::InvalidOptics(_)
299 ));
300 let mut o3 = asi2600_800mm();
301 o3.pixels = (0, 4176);
302 assert!(matches!(
303 Field::from_optics(o3).unwrap_err(),
304 Error::InvalidOptics(_)
305 ));
306 assert!(matches!(
307 Field::from_fov(Angle::from_degrees(0.0), Angle::from_degrees(1.0)).unwrap_err(),
308 Error::InvalidOptics(_)
309 ));
310 }
311
312 #[test]
313 fn fallback_radius_is_five_degrees() {
314 assert!(approx(DEFAULT_FALLBACK_RADIUS.degrees(), 5.0, 1e-9));
315 }
316}