Skip to main content

target_match/
optics.rs

1//! Plate scale and field-of-view geometry.
2//!
3//! A [`Field`] is the angular extent of a frame, built from full [`Optics`], from
4//! a directly supplied pixel scale, or from a directly supplied field of view. It
5//! is binning-aware (effective pixel = pixel size × binning, per axis) and
6//! axis-independent (x and y are handled separately). A [`RadiusPolicy`] turns a
7//! field into a search radius.
8
9use skymath::Angle;
10
11use crate::error::{Error, Result};
12
13/// Exact number of arcseconds in one radian (supersedes the rounded `206.265`).
14pub const ARCSEC_PER_RADIAN: f64 = skymath::ARCSEC_PER_RADIAN;
15/// Arcseconds per degree.
16pub const ARCSEC_PER_DEGREE: f64 = 3600.0;
17/// Fallback search radius when a field of view cannot be derived (5°).
18pub const DEFAULT_FALLBACK_RADIUS: Angle = Angle::from_radians(5.0 * core::f64::consts::PI / 180.0);
19
20/// Full optical train: focal length, per-axis pixel size, per-axis binning, and
21/// sensor pixel counts.
22#[derive(Debug, Clone, Copy, PartialEq)]
23#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
24pub struct Optics {
25    /// Focal length, millimetres.
26    pub focal_mm: f64,
27    /// Pixel size in micrometres, `(x, y)`.
28    pub pixel_um: (f64, f64),
29    /// Binning factor, `(x, y)` (1 = unbinned).
30    pub binning: (u32, u32),
31    /// Sensor pixel counts, `(naxis1, naxis2)`.
32    pub pixels: (u32, u32),
33}
34
35/// How a search radius is derived from a [`Field`].
36#[derive(Debug, Clone, Copy, PartialEq)]
37#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
38pub enum RadiusPolicy {
39    /// Half the diagonal — the circle that circumscribes the whole frame (default).
40    Circumscribed,
41    /// Half the shorter side — the circle inscribed within the frame.
42    Inscribed,
43    /// A multiplier applied to the circumscribed radius.
44    Multiplier(f64),
45    /// An explicit radius, ignoring the field extent.
46    Explicit(Angle),
47}
48
49/// The angular extent of a frame.
50#[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)>, // arcsec/px, per axis; None for a direct-FOV field
55}
56
57impl Field {
58    /// Derive a field from full optics.
59    ///
60    /// Effective pixel size = pixel size × binning (per axis); pixel scale
61    /// (arcsec/px) = effective pixel size (mm) / focal length (mm) × arcsec/radian;
62    /// field extent = pixel scale × pixel count.
63    ///
64    /// # Errors
65    /// [`Error::InvalidOptics`] if any input is non-positive or non-finite.
66    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        // arcsec/px = eff_pixel_um / 1000 (→ mm) / focal_mm × arcsec/radian
81        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    /// Build a field from a directly supplied pixel scale (arcsec/px, per axis)
93    /// and sensor pixel counts.
94    ///
95    /// # Errors
96    /// [`Error::InvalidOptics`] if any input is non-positive or non-finite.
97    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    /// Build a field directly from its angular width and height (no optics; pixel
112    /// scale is unknown).
113    ///
114    /// # Errors
115    /// [`Error::InvalidOptics`] if width or height is non-positive or non-finite.
116    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    /// Field width (x extent).
126    #[must_use]
127    pub fn width(self) -> Angle {
128        self.fov.0
129    }
130    /// Field height (y extent).
131    #[must_use]
132    pub fn height(self) -> Angle {
133        self.fov.1
134    }
135    /// Diagonal field of view.
136    #[must_use]
137    pub fn diagonal(self) -> Angle {
138        Angle::from_degrees(self.fov.0.degrees().hypot(self.fov.1.degrees()))
139    }
140    /// Per-axis pixel scale (arcsec/px), if this field was built with a scale.
141    #[must_use]
142    pub fn pixel_scale(self) -> Option<(f64, f64)> {
143        self.pixel_scale
144    }
145
146    /// Compute a search radius from this field under `policy`.
147    #[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        // radius = circumscribed = half diagonal ≈ 1.012°
212        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        // Holding the (binned) pixel count, ×2 binning ⇒ ×2 scale ⇒ ×2 field (SC-009).
227        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        )); // half min side
271        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}