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///
23/// Pass to [`Field::from_optics`] to derive a [`Field`].
24///
25/// # Example
26///
27/// ```
28/// use target_match::{Field, Optics};
29///
30/// let field = Field::from_optics(Optics {
31///     focal_mm: 800.0,
32///     pixel_um: (3.76, 3.76),
33///     binning: (1, 1),
34///     pixels: (6248, 4176),
35/// })
36/// .unwrap();
37/// assert!((field.width().degrees() - 1.683).abs() < 1e-2);
38/// ```
39#[derive(Debug, Clone, Copy, PartialEq)]
40#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
41pub struct Optics {
42    /// Focal length, millimetres.
43    pub focal_mm: f64,
44    /// Pixel size in micrometres, `(x, y)`.
45    pub pixel_um: (f64, f64),
46    /// Binning factor, `(x, y)` (1 = unbinned).
47    pub binning: (u32, u32),
48    /// Sensor pixel counts, `(naxis1, naxis2)`.
49    pub pixels: (u32, u32),
50}
51
52/// How a search radius is derived from a [`Field`].
53///
54/// Passed to [`Field::radius`], or embedded in a [`Constraint`](crate::Constraint)
55/// via [`Constraint::within`](crate::Constraint::within).
56///
57/// # Example
58///
59/// ```
60/// use skymath::Angle;
61/// use target_match::{Field, RadiusPolicy};
62///
63/// let field = Field::from_fov(Angle::from_degrees(2.0), Angle::from_degrees(1.0)).unwrap();
64/// let circumscribed = field.radius(RadiusPolicy::Circumscribed);
65/// let inscribed = field.radius(RadiusPolicy::Inscribed);
66/// assert!(circumscribed.degrees() > inscribed.degrees(), "circumscribed bounds the whole frame");
67/// ```
68#[derive(Debug, Clone, Copy, PartialEq)]
69#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
70pub enum RadiusPolicy {
71    /// Half the diagonal — the circle that circumscribes the whole frame (default).
72    Circumscribed,
73    /// Half the shorter side — the circle inscribed within the frame.
74    Inscribed,
75    /// A multiplier applied to the circumscribed radius.
76    Multiplier(f64),
77    /// An explicit radius, ignoring the field extent.
78    Explicit(Angle),
79}
80
81/// The angular extent of a frame.
82///
83/// Feed a `Field` to [`Constraint::within`](crate::Constraint::within) (circular
84/// membership sized by a [`RadiusPolicy`]) or
85/// [`Constraint::frame`](crate::Constraint::frame) (rectangular membership) to
86/// build a search [`Constraint`](crate::Constraint).
87///
88/// # Example
89///
90/// ```
91/// use target_match::{Field, Optics, RadiusPolicy};
92///
93/// let field = Field::from_optics(Optics {
94///     focal_mm: 800.0,
95///     pixel_um: (3.76, 3.76),
96///     binning: (1, 1),
97///     pixels: (6248, 4176),
98/// })
99/// .unwrap();
100///
101/// assert!(field.width().degrees() > 0.0);
102/// assert!(field.height().degrees() > 0.0);
103/// assert!(field.diagonal().degrees() > field.width().degrees());
104/// assert!(field.pixel_scale().is_some(), "derived from optics, so plate scale is known");
105/// let _radius = field.radius(RadiusPolicy::Circumscribed);
106/// ```
107#[derive(Debug, Clone, Copy, PartialEq)]
108#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
109pub struct Field {
110    fov: (Angle, Angle),
111    pixel_scale: Option<(f64, f64)>, // arcsec/px, per axis; None for a direct-FOV field
112}
113
114impl Field {
115    /// Derive a field from full optics.
116    ///
117    /// Effective pixel size = pixel size × binning (per axis); pixel scale
118    /// (arcsec/px) = effective pixel size (mm) / focal length (mm) × arcsec/radian;
119    /// field extent = pixel scale × pixel count.
120    ///
121    /// # Example
122    ///
123    /// ```
124    /// use target_match::{Field, Optics};
125    ///
126    /// let field = Field::from_optics(Optics {
127    ///     focal_mm: 800.0,
128    ///     pixel_um: (3.76, 3.76),
129    ///     binning: (1, 1),
130    ///     pixels: (6248, 4176),
131    /// })
132    /// .unwrap();
133    /// let (sx, _) = field.pixel_scale().unwrap();
134    /// assert!((sx - 0.969).abs() < 1e-2, "≈0.969 arcsec/px");
135    /// ```
136    ///
137    /// # Errors
138    /// [`Error::InvalidOptics`] if any input is non-positive or non-finite.
139    pub fn from_optics(o: Optics) -> Result<Self> {
140        let focal = finite_positive(o.focal_mm, "focal length")?;
141        let (px, py) = (
142            finite_positive(o.pixel_um.0, "pixel size x")?,
143            finite_positive(o.pixel_um.1, "pixel size y")?,
144        );
145        let (bx, by) = (
146            positive_count(o.binning.0, "binning x")?,
147            positive_count(o.binning.1, "binning y")?,
148        );
149        let (nx, ny) = (
150            positive_count(o.pixels.0, "naxis1")?,
151            positive_count(o.pixels.1, "naxis2")?,
152        );
153        // arcsec/px = eff_pixel_um / 1000 (→ mm) / focal_mm × arcsec/radian
154        let scale_x = (px * bx) / 1000.0 / focal * ARCSEC_PER_RADIAN;
155        let scale_y = (py * by) / 1000.0 / focal * ARCSEC_PER_RADIAN;
156        Ok(Self {
157            fov: (
158                Angle::from_arcseconds(scale_x * nx),
159                Angle::from_arcseconds(scale_y * ny),
160            ),
161            pixel_scale: Some((scale_x, scale_y)),
162        })
163    }
164
165    /// Build a field from a directly supplied pixel scale (arcsec/px, per axis)
166    /// and sensor pixel counts.
167    ///
168    /// # Example
169    ///
170    /// ```
171    /// use target_match::Field;
172    ///
173    /// let field = Field::from_pixel_scale((0.9694, 0.9694), (6248, 4176)).unwrap();
174    /// assert_eq!(field.pixel_scale(), Some((0.9694, 0.9694)));
175    /// assert!((field.width().degrees() - 1.683).abs() < 1e-2);
176    /// ```
177    ///
178    /// # Errors
179    /// [`Error::InvalidOptics`] if any input is non-positive or non-finite.
180    pub fn from_pixel_scale(scale_arcsec_px: (f64, f64), pixels: (u32, u32)) -> Result<Self> {
181        let sx = finite_positive(scale_arcsec_px.0, "pixel scale x")?;
182        let sy = finite_positive(scale_arcsec_px.1, "pixel scale y")?;
183        let nx = positive_count(pixels.0, "naxis1")?;
184        let ny = positive_count(pixels.1, "naxis2")?;
185        Ok(Self {
186            fov: (
187                Angle::from_arcseconds(sx * nx),
188                Angle::from_arcseconds(sy * ny),
189            ),
190            pixel_scale: Some((sx, sy)),
191        })
192    }
193
194    /// Build a field directly from its angular width and height (no optics; pixel
195    /// scale is unknown).
196    ///
197    /// # Example
198    ///
199    /// ```
200    /// use skymath::Angle;
201    /// use target_match::Field;
202    ///
203    /// let field = Field::from_fov(Angle::from_degrees(2.0), Angle::from_degrees(1.0)).unwrap();
204    /// assert_eq!(field.width().degrees(), 2.0);
205    /// assert!(field.pixel_scale().is_none(), "no optics, so no plate scale");
206    /// ```
207    ///
208    /// # Errors
209    /// [`Error::InvalidOptics`] if width or height is non-positive or non-finite.
210    pub fn from_fov(width: Angle, height: Angle) -> Result<Self> {
211        finite_positive(width.degrees(), "field width")?;
212        finite_positive(height.degrees(), "field height")?;
213        Ok(Self {
214            fov: (width, height),
215            pixel_scale: None,
216        })
217    }
218
219    /// Field width (x extent). See also [`height`](Field::height) and
220    /// [`diagonal`](Field::diagonal).
221    ///
222    /// # Example
223    ///
224    /// ```
225    /// use skymath::Angle;
226    /// use target_match::Field;
227    ///
228    /// let field = Field::from_fov(Angle::from_degrees(2.0), Angle::from_degrees(1.0)).unwrap();
229    /// assert_eq!(field.width().degrees(), 2.0);
230    /// ```
231    #[must_use]
232    pub fn width(self) -> Angle {
233        self.fov.0
234    }
235    /// Field height (y extent). See also [`width`](Field::width) and
236    /// [`diagonal`](Field::diagonal).
237    ///
238    /// # Example
239    ///
240    /// ```
241    /// use skymath::Angle;
242    /// use target_match::Field;
243    ///
244    /// let field = Field::from_fov(Angle::from_degrees(2.0), Angle::from_degrees(1.0)).unwrap();
245    /// assert_eq!(field.height().degrees(), 1.0);
246    /// ```
247    #[must_use]
248    pub fn height(self) -> Angle {
249        self.fov.1
250    }
251    /// Diagonal field of view — `hypot(`[`width`](Field::width)`,`
252    /// [`height`](Field::height)`)`. Halved, this is the
253    /// [`RadiusPolicy::Circumscribed`] search radius.
254    ///
255    /// # Example
256    ///
257    /// ```
258    /// use skymath::Angle;
259    /// use target_match::Field;
260    ///
261    /// let field = Field::from_fov(Angle::from_degrees(2.0), Angle::from_degrees(1.0)).unwrap();
262    /// assert!((field.diagonal().degrees() - 2.0_f64.hypot(1.0)).abs() < 1e-9);
263    /// ```
264    #[must_use]
265    pub fn diagonal(self) -> Angle {
266        Angle::from_degrees(self.fov.0.degrees().hypot(self.fov.1.degrees()))
267    }
268    /// Per-axis pixel scale (arcsec/px), if this field was built with a scale
269    /// (via [`from_optics`](Field::from_optics) or
270    /// [`from_pixel_scale`](Field::from_pixel_scale)) — `None` for
271    /// [`from_fov`](Field::from_fov).
272    ///
273    /// # Example
274    ///
275    /// ```
276    /// use target_match::{Field, Optics};
277    ///
278    /// let field = Field::from_optics(Optics {
279    ///     focal_mm: 800.0, pixel_um: (3.76, 3.76), binning: (1, 1), pixels: (6248, 4176),
280    /// })
281    /// .unwrap();
282    /// let (sx, sy) = field.pixel_scale().unwrap();
283    /// assert!((sx - 0.969).abs() < 1e-2);
284    /// assert_eq!(sx, sy);
285    /// ```
286    #[must_use]
287    pub fn pixel_scale(self) -> Option<(f64, f64)> {
288        self.pixel_scale
289    }
290
291    /// Compute a search radius from this field under `policy`. Feeds
292    /// [`Constraint::within`](crate::Constraint::within), which calls this
293    /// internally.
294    ///
295    /// # Example
296    ///
297    /// ```
298    /// use skymath::Angle;
299    /// use target_match::{Field, RadiusPolicy};
300    ///
301    /// let field = Field::from_fov(Angle::from_degrees(2.0), Angle::from_degrees(1.0)).unwrap();
302    /// let r = field.radius(RadiusPolicy::Explicit(Angle::from_degrees(3.0)));
303    /// assert!((r.degrees() - 3.0).abs() < 1e-9);
304    /// ```
305    #[must_use]
306    pub fn radius(self, policy: RadiusPolicy) -> Angle {
307        match policy {
308            RadiusPolicy::Circumscribed => Angle::from_degrees(self.diagonal().degrees() / 2.0),
309            RadiusPolicy::Inscribed => {
310                Angle::from_degrees(self.fov.0.degrees().min(self.fov.1.degrees()) / 2.0)
311            }
312            RadiusPolicy::Multiplier(m) => Angle::from_degrees(self.diagonal().degrees() / 2.0 * m),
313            RadiusPolicy::Explicit(a) => a,
314        }
315    }
316}
317
318fn finite_positive(v: f64, what: &str) -> Result<f64> {
319    if v.is_finite() && v > 0.0 {
320        Ok(v)
321    } else {
322        Err(Error::InvalidOptics(format!(
323            "{what} must be finite and > 0, got {v}"
324        )))
325    }
326}
327
328fn positive_count(v: u32, what: &str) -> Result<f64> {
329    if v >= 1 {
330        Ok(f64::from(v))
331    } else {
332        Err(Error::InvalidOptics(format!("{what} must be >= 1")))
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    fn approx(a: f64, b: f64, eps: f64) -> bool {
341        (a - b).abs() < eps
342    }
343
344    fn asi2600_800mm() -> Optics {
345        Optics {
346            focal_mm: 800.0,
347            pixel_um: (3.76, 3.76),
348            binning: (1, 1),
349            pixels: (6248, 4176),
350        }
351    }
352
353    #[test]
354    fn from_optics_matches_hand_calc() {
355        let f = Field::from_optics(asi2600_800mm()).unwrap();
356        let (sx, sy) = f.pixel_scale().unwrap();
357        assert!(approx(sx, 0.9694, 1e-3), "scale {sx}");
358        assert!(approx(sy, 0.9694, 1e-3));
359        assert!(
360            approx(f.width().degrees(), 1.683, 5e-3),
361            "w {}",
362            f.width().degrees()
363        );
364        assert!(
365            approx(f.height().degrees(), 1.125, 5e-3),
366            "h {}",
367            f.height().degrees()
368        );
369        // radius = circumscribed = half diagonal ≈ 1.012°
370        assert!(approx(
371            f.radius(RadiusPolicy::Circumscribed).degrees(),
372            1.012,
373            5e-3
374        ));
375    }
376
377    #[test]
378    fn binning_doubles_scale_and_fov() {
379        let mut o = asi2600_800mm();
380        o.binning = (2, 2);
381        let f = Field::from_optics(o).unwrap();
382        let (sx, _) = f.pixel_scale().unwrap();
383        assert!(approx(sx, 2.0 * 0.9694, 2e-3), "binned scale {sx}");
384        // Holding the (binned) pixel count, ×2 binning ⇒ ×2 scale ⇒ ×2 field (SC-009).
385        assert!(
386            approx(f.width().degrees(), 2.0 * 1.683, 1e-2),
387            "w {}",
388            f.width().degrees()
389        );
390    }
391
392    #[test]
393    fn binning_fov_doubles() {
394        let base = Field::from_optics(asi2600_800mm()).unwrap();
395        let mut o = asi2600_800mm();
396        o.binning = (2, 2);
397        let binned = Field::from_optics(o).unwrap();
398        assert!(approx(
399            binned.width().degrees(),
400            2.0 * base.width().degrees(),
401            1e-6
402        ));
403    }
404
405    #[test]
406    fn from_fov_and_pixel_scale_paths() {
407        let direct =
408            Field::from_fov(Angle::from_degrees(1.683), Angle::from_degrees(1.125)).unwrap();
409        assert!(direct.pixel_scale().is_none());
410        assert!(approx(
411            direct.radius(RadiusPolicy::Circumscribed).degrees(),
412            1.012,
413            5e-3
414        ));
415
416        let by_scale = Field::from_pixel_scale((0.9694, 0.9694), (6248, 4176)).unwrap();
417        assert!(approx(by_scale.width().degrees(), 1.683, 5e-3));
418        assert_eq!(by_scale.pixel_scale(), Some((0.9694, 0.9694)));
419    }
420
421    #[test]
422    fn radius_policies() {
423        let f = Field::from_fov(Angle::from_degrees(2.0), Angle::from_degrees(1.0)).unwrap();
424        assert!(approx(
425            f.radius(RadiusPolicy::Inscribed).degrees(),
426            0.5,
427            1e-9
428        )); // half min side
429        let circ = f.radius(RadiusPolicy::Circumscribed).degrees();
430        assert!(approx(circ, (2.0_f64.hypot(1.0)) / 2.0, 1e-9));
431        assert!(approx(
432            f.radius(RadiusPolicy::Multiplier(2.0)).degrees(),
433            circ * 2.0,
434            1e-9
435        ));
436        assert!(approx(
437            f.radius(RadiusPolicy::Explicit(Angle::from_degrees(3.0)))
438                .degrees(),
439            3.0,
440            1e-9
441        ));
442    }
443
444    #[test]
445    fn rejects_bad_optics() {
446        let mut o = asi2600_800mm();
447        o.focal_mm = 0.0;
448        assert!(matches!(
449            Field::from_optics(o).unwrap_err(),
450            Error::InvalidOptics(_)
451        ));
452        let mut o2 = asi2600_800mm();
453        o2.pixel_um = (-1.0, 3.76);
454        assert!(matches!(
455            Field::from_optics(o2).unwrap_err(),
456            Error::InvalidOptics(_)
457        ));
458        let mut o3 = asi2600_800mm();
459        o3.pixels = (0, 4176);
460        assert!(matches!(
461            Field::from_optics(o3).unwrap_err(),
462            Error::InvalidOptics(_)
463        ));
464        assert!(matches!(
465            Field::from_fov(Angle::from_degrees(0.0), Angle::from_degrees(1.0)).unwrap_err(),
466            Error::InvalidOptics(_)
467        ));
468    }
469
470    #[test]
471    fn fallback_radius_is_five_degrees() {
472        assert!(approx(DEFAULT_FALLBACK_RADIUS.degrees(), 5.0, 1e-9));
473    }
474}