Skip to main content

target_match/
optics.rs

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