1use core::cmp::Ordering;
20
21use skymath::{position_angle, precess, separation, Angle, Epoch, Equatorial};
22
23use crate::optics::{Field, RadiusPolicy};
24
25pub trait SkyObject {
30 fn position(&self) -> Equatorial;
32}
33
34#[derive(Debug, Clone, Copy, PartialEq)]
36#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
37pub enum Membership {
38 Circular {
40 radius: Angle,
42 },
43 Rectangle {
45 fov: (Angle, Angle),
47 },
48 Rotated {
50 fov: (Angle, Angle),
52 position_angle: Angle,
54 },
55}
56
57#[derive(Debug, Clone, Copy, PartialEq)]
59#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
60pub enum Query {
61 AllWithinField,
63 NearestOne,
65 NearestN {
67 n: usize,
69 max_radius: Option<Angle>,
71 },
72}
73
74#[derive(Debug, Clone, Copy, PartialEq)]
77#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
78pub struct Constraint {
79 pub membership: Membership,
81 pub query: Query,
83 pub pixel_scale: Option<(f64, f64)>,
85}
86
87impl Constraint {
88 #[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 #[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 #[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 #[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 #[must_use]
133 pub fn all(mut self) -> Self {
134 self.query = Query::AllWithinField;
135 self
136 }
137 #[must_use]
139 pub fn nearest_one(mut self) -> Self {
140 self.query = Query::NearestOne;
141 self
142 }
143 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq)]
165#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
166pub struct Offset {
167 pub sky: (Angle, Angle),
169 pub frame: Option<(Angle, Angle)>,
171 pub pixels: Option<(f64, f64)>,
173}
174
175#[derive(Debug, Clone, Copy, PartialEq)]
177pub struct Match<'a, T> {
178 pub object: &'a T,
180 pub separation: Angle,
182 pub in_frame: bool,
184 pub offset: Offset,
186 pub position_angle: Angle,
188}
189
190fn 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
204fn 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 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
284fn 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
294fn 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#[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#[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
347pub struct Matcher<T> {
353 storage: Vec<T>,
354 sorted: Vec<(f64, usize)>,
356}
357
358impl<T: SkyObject> Matcher<T> {
359 #[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 #[must_use]
381 pub fn objects(&self) -> &[T] {
382 &self.storage
383 }
384
385 #[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 #[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 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 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 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 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 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 }; let high_obj = Obj {
542 name: "H",
543 ra: 10.6847,
544 dec: 41.2688 + 0.9,
545 }; 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}