1use core::cmp::Ordering;
19
20use crate::angle::{precess, separation, Angle, Epoch, Equatorial};
21use crate::optics::{Field, RadiusPolicy};
22
23pub trait SkyObject {
28 fn position(&self) -> Equatorial;
30}
31
32#[derive(Debug, Clone, Copy, PartialEq)]
34#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
35pub enum Membership {
36 Circular {
38 radius: Angle,
40 },
41 Rectangle {
43 fov: (Angle, Angle),
45 },
46 Rotated {
48 fov: (Angle, Angle),
50 position_angle: Angle,
52 },
53}
54
55#[derive(Debug, Clone, Copy, PartialEq)]
57#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
58pub enum Query {
59 AllWithinField,
61 NearestOne,
63 NearestN {
65 n: usize,
67 max_radius: Option<Angle>,
69 },
70}
71
72#[derive(Debug, Clone, Copy, PartialEq)]
75#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
76pub struct Constraint {
77 pub membership: Membership,
79 pub query: Query,
81 pub pixel_scale: Option<(f64, f64)>,
83}
84
85impl Constraint {
86 #[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 #[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 #[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 #[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 #[must_use]
131 pub fn all(mut self) -> Self {
132 self.query = Query::AllWithinField;
133 self
134 }
135 #[must_use]
137 pub fn nearest_one(mut self) -> Self {
138 self.query = Query::NearestOne;
139 self
140 }
141 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq)]
163#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
164pub struct Offset {
165 pub sky: (Angle, Angle),
167 pub frame: Option<(Angle, Angle)>,
169 pub pixels: Option<(f64, f64)>,
171}
172
173#[derive(Debug, Clone, Copy, PartialEq)]
175pub struct Match<'a, T> {
176 pub object: &'a T,
178 pub separation: Angle,
180 pub in_frame: bool,
182 pub offset: Offset,
184 pub position_angle: Angle,
186}
187
188fn 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
198fn 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
209fn 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 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
289fn 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
299fn 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#[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#[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
352pub struct Matcher<T> {
358 storage: Vec<T>,
359 sorted: Vec<(f64, usize)>,
361}
362
363impl<T: SkyObject> Matcher<T> {
364 #[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 #[must_use]
386 pub fn objects(&self) -> &[T] {
387 &self.storage
388 }
389
390 #[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 #[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 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 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 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 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 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 }; let high_obj = Obj {
547 name: "H",
548 ra: 10.6847,
549 dec: 41.2688 + 0.9,
550 }; 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}