Skip to main content

target_match/
matcher.rs

1//! The matching engine: input trait, constraints, ranking, and a prebuilt index.
2//!
3//! A consumer's catalogue type implements [`SkyObject`] (position only — never a
4//! name). A [`Constraint`] combines a [`Membership`] shape (circular, rectangle,
5//! or rotated rectangle) with a [`Query`] mode. [`rank`] scans a slice; [`Matcher`]
6//! builds a declination-sorted index once and answers repeated queries, returning
7//! results identical to [`rank`].
8//!
9//! # Geometry
10//!
11//! Matching precesses the pointing to J2000 (via [`skymath::precess`]), then works
12//! in the local tangent frame about the pointing: an object's offset is decomposed
13//! from its great-circle separation and position angle (East of North, both from
14//! `skymath`) into East/North components, which are rotated into the camera frame
15//! for rectangular membership. The circumscribed circle pre-filters both rectangle
16//! tests, so the tangent decomposition is only evaluated for objects near the
17//! frame — never on the far side of the sky.
18
19use core::cmp::Ordering;
20
21use skymath::{position_angle, precess, separation, Angle, Epoch, Equatorial};
22
23use crate::optics::{Field, RadiusPolicy};
24
25/// A catalogue object that can be matched by sky position.
26///
27/// The trait exposes **only** a J2000 position — matching never reads a name or
28/// designation. A caller's own type keeps its identity; a [`Match`] borrows it.
29pub trait SkyObject {
30    /// The object's J2000 equatorial position.
31    fn position(&self) -> Equatorial;
32}
33
34/// The shape that decides whether an object is "in frame".
35#[derive(Debug, Clone, Copy, PartialEq)]
36#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
37pub enum Membership {
38    /// Pure angular distance: in frame iff separation ≤ `radius`.
39    Circular {
40        /// Search radius.
41        radius: Angle,
42    },
43    /// Axis-aligned rectangle of the given width×height field of view.
44    Rectangle {
45        /// `(width, height)` field of view.
46        fov: (Angle, Angle),
47    },
48    /// Rectangle rotated by a camera position angle (degrees East of North).
49    Rotated {
50        /// `(width, height)` field of view.
51        fov: (Angle, Angle),
52        /// Camera position angle, East of North.
53        position_angle: Angle,
54    },
55}
56
57/// What to return from a match.
58#[derive(Debug, Clone, Copy, PartialEq)]
59#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
60pub enum Query {
61    /// Every object inside the membership shape, ranked nearest-first.
62    AllWithinField,
63    /// The single nearest in-frame object.
64    NearestOne,
65    /// The `n` nearest objects by separation, optionally bounded by a radius.
66    NearestN {
67        /// Maximum number of results.
68        n: usize,
69        /// Optional maximum separation; unbounded when `None`.
70        max_radius: Option<Angle>,
71    },
72}
73
74/// A membership shape combined with a query mode (and the plate scale, when known,
75/// so pixel offsets can be reported).
76#[derive(Debug, Clone, Copy, PartialEq)]
77#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
78pub struct Constraint {
79    /// The in-frame shape.
80    pub membership: Membership,
81    /// The query mode.
82    pub query: Query,
83    /// Per-axis plate scale (arcsec/px), used only to fill pixel offsets.
84    pub pixel_scale: Option<(f64, f64)>,
85}
86
87impl Constraint {
88    /// Circular membership with a radius derived from a field under `policy`.
89    #[must_use]
90    pub fn within(field: &Field, policy: RadiusPolicy) -> Self {
91        Self {
92            membership: Membership::Circular {
93                radius: field.radius(policy),
94            },
95            query: Query::AllWithinField,
96            pixel_scale: field.pixel_scale(),
97        }
98    }
99    /// Circular membership with an explicit radius (no plate scale).
100    #[must_use]
101    pub fn circular(radius: Angle) -> Self {
102        Self {
103            membership: Membership::Circular { radius },
104            query: Query::AllWithinField,
105            pixel_scale: None,
106        }
107    }
108    /// Axis-aligned rectangular membership from a field's width×height.
109    #[must_use]
110    pub fn frame(field: &Field) -> Self {
111        Self {
112            membership: Membership::Rectangle {
113                fov: (field.width(), field.height()),
114            },
115            query: Query::AllWithinField,
116            pixel_scale: field.pixel_scale(),
117        }
118    }
119    /// Rotated rectangular membership from a field and a camera position angle.
120    #[must_use]
121    pub fn frame_rotated(field: &Field, position_angle: Angle) -> Self {
122        Self {
123            membership: Membership::Rotated {
124                fov: (field.width(), field.height()),
125                position_angle,
126            },
127            query: Query::AllWithinField,
128            pixel_scale: field.pixel_scale(),
129        }
130    }
131    /// Set the query to all-within-field.
132    #[must_use]
133    pub fn all(mut self) -> Self {
134        self.query = Query::AllWithinField;
135        self
136    }
137    /// Set the query to nearest-one.
138    #[must_use]
139    pub fn nearest_one(mut self) -> Self {
140        self.query = Query::NearestOne;
141        self
142    }
143    /// Set the query to the `n` nearest (unbounded).
144    #[must_use]
145    pub fn nearest_n(mut self, n: usize) -> Self {
146        self.query = Query::NearestN {
147            n,
148            max_radius: None,
149        };
150        self
151    }
152    /// Set the query to the `n` nearest within `max_radius`.
153    #[must_use]
154    pub fn nearest_n_within(mut self, n: usize, max_radius: Angle) -> Self {
155        self.query = Query::NearestN {
156            n,
157            max_radius: Some(max_radius),
158        };
159        self
160    }
161}
162
163/// The offset of a matched object relative to the frame centre.
164#[derive(Debug, Clone, Copy, PartialEq)]
165#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
166pub struct Offset {
167    /// Sky-tangent offset `(East, North)` — always present.
168    pub sky: (Angle, Angle),
169    /// Frame-aligned offset `(x, y)`, present for rectangular membership.
170    pub frame: Option<(Angle, Angle)>,
171    /// Frame-aligned offset in pixels `(x, y)`, present when a plate scale is known.
172    pub pixels: Option<(f64, f64)>,
173}
174
175/// A ranked match: a borrowed catalogue object plus its computed geometry.
176#[derive(Debug, Clone, Copy, PartialEq)]
177pub struct Match<'a, T> {
178    /// The matched object, borrowed from the caller's slice or the [`Matcher`].
179    pub object: &'a T,
180    /// Great-circle separation from the frame centre.
181    pub separation: Angle,
182    /// Whether the object is inside the active membership shape.
183    pub in_frame: bool,
184    /// The object's offset relative to the frame centre.
185    pub offset: Offset,
186    /// Position angle from frame centre to the object (degrees East of North).
187    pub position_angle: Angle,
188}
189
190/// Tangent-frame `(East, North)` offset in radians, decomposed from an
191/// already-computed separation and position angle (the polar decomposition
192/// `skymath::tangent_offset` performs, reusing this evaluation's sep/PA
193/// instead of recomputing them).
194fn tangent_components(sep: Angle, pa: Angle) -> (f64, f64) {
195    let (s, r) = (pa.radians(), sep.radians());
196    (r * s.sin(), r * s.cos())
197}
198
199fn rotate(east: f64, north: f64, pa_rad: f64) -> (f64, f64) {
200    let (s, c) = pa_rad.sin_cos();
201    (east * c - north * s, east * s + north * c)
202}
203
204/// The circumscribed-circle radius (radians) that bounds a membership shape.
205fn bound_radius(m: Membership) -> f64 {
206    match m {
207        Membership::Circular { radius } => radius.radians(),
208        Membership::Rectangle { fov } | Membership::Rotated { fov, .. } => {
209            (fov.0.radians() / 2.0).hypot(fov.1.radians() / 2.0)
210        }
211    }
212}
213
214fn contains(sep: Angle, east: f64, north: f64, m: Membership) -> bool {
215    match m {
216        Membership::Circular { radius } => {
217            let r = radius.radians();
218            r.is_finite() && r >= 0.0 && sep.radians() <= r
219        }
220        Membership::Rectangle { fov } => within_rect(sep, east, north, fov, 0.0),
221        Membership::Rotated {
222            fov,
223            position_angle,
224        } => within_rect(sep, east, north, fov, position_angle.radians()),
225    }
226}
227
228fn within_rect(sep: Angle, east: f64, north: f64, fov: (Angle, Angle), pa: f64) -> bool {
229    let (hx, hy) = (fov.0.radians() / 2.0, fov.1.radians() / 2.0);
230    let circum = hx.hypot(hy);
231    // Circumscribed pre-filter (also rejects NaN separations).
232    if sep.radians() > circum || sep.radians().is_nan() {
233        return false;
234    }
235    let (x, y) = rotate(east, north, pa);
236    x.abs() <= hx && y.abs() <= hy
237}
238
239fn build_offset(east: f64, north: f64, m: Membership, scale: Option<(f64, f64)>) -> Offset {
240    let sky = (Angle::from_radians(east), Angle::from_radians(north));
241    match m {
242        Membership::Circular { .. } => Offset {
243            sky,
244            frame: None,
245            pixels: None,
246        },
247        Membership::Rectangle { .. } | Membership::Rotated { .. } => {
248            let pa = match m {
249                Membership::Rotated { position_angle, .. } => position_angle.radians(),
250                _ => 0.0,
251            };
252            let (x, y) = rotate(east, north, pa);
253            let frame = Some((Angle::from_radians(x), Angle::from_radians(y)));
254            let pixels = scale.map(|(sx, sy)| {
255                (
256                    Angle::from_radians(x).arcseconds() / sx,
257                    Angle::from_radians(y).arcseconds() / sy,
258                )
259            });
260            Offset { sky, frame, pixels }
261        }
262    }
263}
264
265fn evaluate<'a, T: SkyObject>(
266    pointing: Equatorial,
267    obj: &'a T,
268    m: Membership,
269    scale: Option<(f64, f64)>,
270) -> Match<'a, T> {
271    let pos = obj.position();
272    let sep = separation(pointing, pos);
273    let pa = position_angle(pointing, pos);
274    let (east, north) = tangent_components(sep, pa);
275    Match {
276        object: obj,
277        separation: sep,
278        in_frame: contains(sep, east, north, m),
279        offset: build_offset(east, north, m, scale),
280        position_angle: pa,
281    }
282}
283
284/// Whether `obj` should be kept for `query` given its evaluated match.
285fn keep<T>(m: &Match<'_, T>, query: Query) -> bool {
286    match query {
287        Query::AllWithinField | Query::NearestOne => m.in_frame,
288        Query::NearestN { max_radius, .. } => {
289            max_radius.map_or(true, |r| m.separation.radians() <= r.radians())
290        }
291    }
292}
293
294/// Shared core: evaluate candidates, filter, rank, and truncate per the query.
295fn rank_candidates<'a, T: SkyObject, I>(
296    pointing: Equatorial,
297    candidates: I,
298    c: &Constraint,
299) -> Vec<Match<'a, T>>
300where
301    I: Iterator<Item = (usize, &'a T)>,
302{
303    let mut scored: Vec<(usize, Match<'a, T>)> = candidates
304        .map(|(i, o)| (i, evaluate(pointing, o, c.membership, c.pixel_scale)))
305        .filter(|(_, m)| keep(m, c.query))
306        .collect();
307    scored.sort_by(|a, b| {
308        a.1.separation
309            .radians()
310            .partial_cmp(&b.1.separation.radians())
311            .unwrap_or(Ordering::Equal)
312            .then(a.0.cmp(&b.0))
313    });
314    let mut out: Vec<Match<'a, T>> = scored.into_iter().map(|(_, m)| m).collect();
315    match c.query {
316        Query::NearestOne => out.truncate(1),
317        Query::NearestN { n, .. } => out.truncate(n),
318        Query::AllWithinField => {}
319    }
320    out
321}
322
323/// Rank a slice of objects against a pointing under a constraint (stateless scan).
324///
325/// The pointing is precessed to J2000 first. Results are ascending by separation
326/// with ties broken by input order.
327#[must_use]
328pub fn rank<T: SkyObject>(pointing: Equatorial, objects: &[T], c: Constraint) -> Vec<Match<'_, T>> {
329    let p = precess(pointing, Epoch::J2000);
330    rank_candidates(p, objects.iter().enumerate(), &c)
331}
332
333/// Evaluate a single object against a frame, returning its membership + geometry.
334///
335/// The pointing is precessed to J2000 first. Unlike [`rank`], no filtering or
336/// ranking is applied — the returned [`Match`] always describes `object`.
337#[must_use]
338pub fn is_framed<T: SkyObject>(
339    pointing: Equatorial,
340    object: &T,
341    membership: Membership,
342) -> Match<'_, T> {
343    let p = precess(pointing, Epoch::J2000);
344    evaluate(p, object, membership, None)
345}
346
347/// A prebuilt, declination-sorted index for repeated queries against one catalogue.
348///
349/// Produces results identical to [`rank`] for the same objects, pointing, and
350/// constraint — the index is a performance optimization only. Build it once, then
351/// [`query`](Matcher::query) many pointings.
352pub struct Matcher<T> {
353    storage: Vec<T>,
354    /// `(declination_degrees, original_index)` sorted by declination.
355    sorted: Vec<(f64, usize)>,
356}
357
358impl<T: SkyObject> Matcher<T> {
359    /// Build an index from a set of objects (original order is preserved for
360    /// tie-breaking and [`objects`](Matcher::objects)).
361    #[must_use]
362    pub fn from_objects(objects: Vec<T>) -> Self {
363        let mut sorted: Vec<(f64, usize)> = objects
364            .iter()
365            .enumerate()
366            .map(|(i, o)| (o.position().dec().degrees(), i))
367            .collect();
368        sorted.sort_by(|a, b| {
369            a.0.partial_cmp(&b.0)
370                .unwrap_or(Ordering::Equal)
371                .then(a.1.cmp(&b.1))
372        });
373        Self {
374            storage: objects,
375            sorted,
376        }
377    }
378
379    /// The stored objects, in their original insertion order.
380    #[must_use]
381    pub fn objects(&self) -> &[T] {
382        &self.storage
383    }
384
385    /// Query the index for a pointing under a constraint.
386    #[must_use]
387    pub fn query(&self, pointing: Equatorial, c: Constraint) -> Vec<Match<'_, T>> {
388        let p = precess(pointing, Epoch::J2000);
389        let r = match c.query {
390            Query::NearestN { max_radius, .. } => max_radius.map_or(f64::INFINITY, |a| a.radians()),
391            _ => bound_radius(c.membership),
392        };
393        let idxs = self.band(p.dec().degrees(), r);
394        rank_candidates(p, idxs.into_iter().map(|i| (i, &self.storage[i])), &c)
395    }
396
397    /// Evaluate a single stored-or-external object against a frame (see [`is_framed`]).
398    #[must_use]
399    pub fn is_framed<'a>(
400        &self,
401        pointing: Equatorial,
402        object: &'a T,
403        m: Membership,
404    ) -> Match<'a, T> {
405        is_framed(pointing, object, m)
406    }
407
408    /// Original indices whose declination lies within `r_rad` of `dec0_deg`.
409    fn band(&self, dec0_deg: f64, r_rad: f64) -> Vec<usize> {
410        if r_rad.is_infinite() && r_rad > 0.0 {
411            return self.sorted.iter().map(|&(_, i)| i).collect();
412        }
413        if !r_rad.is_finite() || r_rad < 0.0 {
414            return Vec::new();
415        }
416        let r_deg = r_rad.to_degrees();
417        let (lo, hi) = (dec0_deg - r_deg, dec0_deg + r_deg);
418        let start = self.sorted.partition_point(|&(d, _)| d < lo);
419        let end = self.sorted.partition_point(|&(d, _)| d <= hi);
420        self.sorted[start..end].iter().map(|&(_, i)| i).collect()
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    #[derive(Clone)]
429    struct Obj {
430        name: &'static str,
431        ra: f64,
432        dec: f64,
433    }
434    impl SkyObject for Obj {
435        fn position(&self) -> Equatorial {
436            Equatorial::j2000(Angle::from_degrees(self.ra), Angle::from_degrees(self.dec)).unwrap()
437        }
438    }
439
440    fn catalog() -> Vec<Obj> {
441        vec![
442            Obj {
443                name: "M 31",
444                ra: 10.6847,
445                dec: 41.2688,
446            },
447            Obj {
448                name: "M 110",
449                ra: 10.0921,
450                dec: 41.6853,
451            },
452            Obj {
453                name: "M 33",
454                ra: 23.4621,
455                dec: 30.6599,
456            },
457            Obj {
458                name: "M 42",
459                ra: 83.8221,
460                dec: -5.3911,
461            },
462        ]
463    }
464
465    fn m31() -> Equatorial {
466        Equatorial::j2000(Angle::from_degrees(10.6847), Angle::from_degrees(41.2688)).unwrap()
467    }
468
469    #[test]
470    fn nearest_one_circular() {
471        let cat = catalog();
472        let c = Constraint::circular(Angle::from_degrees(2.0)).nearest_one();
473        let hits = rank(m31(), &cat, c);
474        assert_eq!(hits.len(), 1);
475        assert_eq!(hits[0].object.name, "M 31");
476        assert!(hits[0].separation.arcseconds() < 1.0);
477    }
478
479    #[test]
480    fn all_within_field_ranked() {
481        let cat = catalog();
482        // ~1° radius keeps M31 (self) + M110 (~0.62°); excludes M33/M42.
483        let c = Constraint::circular(Angle::from_degrees(1.0)).all();
484        let hits = rank(m31(), &cat, c);
485        assert_eq!(
486            hits.len(),
487            2,
488            "{hits:?}",
489            hits = hits.iter().map(|h| h.object.name).collect::<Vec<_>>()
490        );
491        assert_eq!(hits[0].object.name, "M 31");
492        assert_eq!(hits[1].object.name, "M 110");
493        assert!(hits[0].separation.radians() <= hits[1].separation.radians());
494    }
495
496    #[test]
497    fn nearest_n_bounds_and_counts() {
498        let cat = catalog();
499        let c = Constraint::circular(Angle::from_degrees(1.0)).nearest_n(3);
500        let hits = rank(m31(), &cat, c);
501        assert_eq!(hits.len(), 3, "top-3 by separation regardless of frame");
502        assert_eq!(hits[0].object.name, "M 31");
503        // Bounded nearest-N respects the radius.
504        let c2 = Constraint::circular(Angle::from_degrees(1.0))
505            .nearest_n_within(3, Angle::from_degrees(1.0));
506        assert_eq!(rank(m31(), &cat, c2).len(), 2, "only M31 + M110 within 1°");
507    }
508
509    #[test]
510    fn coordinates_only_never_name() {
511        // A far object literally named "M 31" must NOT match near M31's pointing.
512        let cat = vec![
513            Obj {
514                name: "M 31",
515                ra: 200.0,
516                dec: -40.0,
517            },
518            Obj {
519                name: "Some Galaxy",
520                ra: 10.6847,
521                dec: 41.2688,
522            },
523        ];
524        let c = Constraint::circular(Angle::from_degrees(2.0)).nearest_one();
525        let hits = rank(m31(), &cat, c);
526        assert_eq!(hits.len(), 1);
527        assert_eq!(hits[0].object.name, "Some Galaxy");
528    }
529
530    #[test]
531    fn rectangle_excludes_circle_only_corner() {
532        // An object just outside the rectangle but inside the circumscribed circle.
533        // Field 2°×1°: half-width 1°, half-height 0.5°. Put an object 0.9° North
534        // (in frame) vs 0.9° along the diagonal (out of the axis-aligned rect).
535        let field = Field::from_fov(Angle::from_degrees(2.0), Angle::from_degrees(1.0)).unwrap();
536        let north_obj = Obj {
537            name: "N",
538            ra: 10.6847,
539            dec: 41.2688 + 0.4,
540        }; // within 0.5° height
541        let high_obj = Obj {
542            name: "H",
543            ra: 10.6847,
544            dec: 41.2688 + 0.9,
545        }; // beyond height, inside circle
546        let cat = vec![north_obj, high_obj];
547        let c = Constraint::frame(&field).all();
548        let hits = rank(m31(), &cat, c);
549        assert_eq!(hits.len(), 1);
550        assert_eq!(hits[0].object.name, "N");
551    }
552
553    #[test]
554    fn matcher_matches_rank_exactly() {
555        let cat = catalog();
556        let c = Constraint::circular(Angle::from_degrees(5.0)).all();
557        let via_rank: Vec<_> = rank(m31(), &cat, c).iter().map(|m| m.object.name).collect();
558        let matcher = Matcher::from_objects(cat.clone());
559        let via_index: Vec<_> = matcher
560            .query(m31(), c)
561            .iter()
562            .map(|m| m.object.name)
563            .collect();
564        assert_eq!(via_rank, via_index);
565        assert_eq!(matcher.objects().len(), 4);
566    }
567
568    #[test]
569    fn is_framed_reports_geometry() {
570        let m110 = catalog()[1].clone();
571        let m = is_framed(
572            m31(),
573            &m110,
574            Membership::Circular {
575                radius: Angle::from_degrees(1.0),
576            },
577        );
578        assert!(m.in_frame);
579        assert!((0.4..0.9).contains(&m.separation.degrees()));
580    }
581
582    #[test]
583    fn empty_catalog_and_zero_radius() {
584        let cat = catalog();
585        assert!(rank(
586            m31(),
587            &[] as &[Obj],
588            Constraint::circular(Angle::from_degrees(1.0))
589        )
590        .is_empty());
591        let c = Constraint::circular(Angle::from_degrees(-1.0)).all();
592        assert!(
593            rank(m31(), &cat, c).is_empty(),
594            "negative radius matches nothing"
595        );
596    }
597}