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