1use core::f64::consts::PI;
4use std::path::PathBuf;
5
6use crate::catalog::read_catalog_stars;
7use crate::catalog::{CatalogLayout, CatalogStar};
8use crate::detection::get_background;
9use crate::detection::stars::find_stars_and_deep;
10use crate::error::{ArcsecError, Result};
11use crate::math::coords::{ang_sep, equatorial_standard, standard_equatorial};
12use crate::math::lsq::{fit_affine, solve_plate_constants};
13use crate::quads::{
14 QuadGrid, TETRA_TOL_FACTOR, bijective_filter, build_quads, build_quads_presorted,
15 build_triangles, extract_star_pairs, extract_triangle_pairs, filter_by_scale,
16 filter_triangles_by_scale, find_triangle_matches, vote_filter,
17};
18use crate::types::{MatchedStar, PairedPositions, PlateConstants, Star, StarList, WcsSolution};
19use crate::wcs::output::derive_wcs;
20
21use super::distortion::{Pair, Refined, StarGrid, best_linear, max_departure_px, refine};
22use super::spiral::{spiral_len, spiral_position};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum SolveMethod {
27 #[default]
29 Quads,
30 Tetra,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum SearchSpeed {
37 #[default]
40 Auto,
41 Slow,
46}
47
48pub const SEARCH_LOG_TARGET: &str = "arcsec_core::search";
54
55#[derive(Debug, Clone)]
57pub struct SolveParams {
58 pub ra_hint: f64,
60 pub dec_hint: f64,
62 pub fov: f64,
66 pub search_radius: f64,
68 pub quad_tolerance: f64,
70 pub hfd_min: f64,
72 pub max_stars: usize,
74 pub db_path: PathBuf,
76 pub db_name: String,
78 pub binning: usize,
81 pub method: SolveMethod,
83 pub speed: SearchSpeed,
85 pub threads: usize,
92}
93
94fn sigma_clip_pairs(
101 mut img_pos: Vec<(f64, f64)>,
102 mut cat_pos: Vec<(f64, f64)>,
103 sigma: f64,
104 min_count: usize,
105) -> PairedPositions {
106 let mut first_pass = true;
107 for _ in 0..10 {
108 if img_pos.len() < min_count.max(3) {
109 break;
110 }
111 let Ok(plate) = fit_affine(&img_pos, &cat_pos) else {
115 break;
116 };
117 let residuals: Vec<f64> = img_pos
118 .iter()
119 .zip(cat_pos.iter())
120 .map(|(&(xi, yi), &(xc, yc))| {
121 let xp = plate.a * xi + plate.b * yi + plate.c;
122 let yp = plate.d * xi + plate.e * yi + plate.f;
123 ((xp - xc).powi(2) + (yp - yc).powi(2)).sqrt()
124 })
125 .collect();
126 let rms = (residuals.iter().map(|r| r * r).sum::<f64>() / residuals.len() as f64).sqrt();
127 let threshold = if first_pass {
128 first_pass = false;
129 let cdelt = (plate.a.powi(2) + plate.d.powi(2)).sqrt();
131 let mut sorted = residuals.clone();
136 sorted.sort_unstable_by(f64::total_cmp);
137 let median = sorted[sorted.len() / 2];
138 (10.0 * cdelt).max(10.0).max(3.0 * 1.4826 * median)
139 } else {
140 sigma * rms
141 };
142 let before = img_pos.len();
143 let mut new_img = Vec::with_capacity(before);
144 let mut new_cat = Vec::with_capacity(before);
145 for ((&ip, &cp), &r) in img_pos.iter().zip(cat_pos.iter()).zip(residuals.iter()) {
146 if r <= threshold {
147 new_img.push(ip);
148 new_cat.push(cp);
149 }
150 }
151 if new_img.len() == before {
152 break; }
154 img_pos = new_img;
155 cat_pos = new_cat;
156 }
157 (img_pos, cat_pos)
158}
159
160fn fit_pattern_pairs(
173 img_pos: Vec<(f64, f64)>,
174 cat_pos: Vec<(f64, f64)>,
175 min_count: usize,
176) -> Option<(PlateConstants, usize)> {
177 let (img_pos, cat_pos) = sigma_clip_pairs(img_pos, cat_pos, 3.0, min_count);
178 if img_pos.len() < min_count {
179 return None;
180 }
181 let plate = solve_plate_constants(&img_pos, &cat_pos).ok()?;
182 Some((plate, img_pos.len()))
183}
184
185const MIN_VERIFIED_STARS: usize = 30;
193const MAX_SPIRAL_RINGS: f64 = 1e6;
196pub const MAX_QUAD_TOLERANCE: f64 = 0.1;
201fn min_verified_stars(nrstars_image: usize) -> usize {
209 nrstars_image
210 .saturating_mul(15)
211 .div_ceil(100)
212 .clamp(10, MIN_VERIFIED_STARS)
213}
214const RELAXED_SCALE_TOL: f64 = 0.10;
217const RELAXED_MAX_RMS_PX: f64 = 0.5;
222
223struct Acceptance {
225 min_stars: usize,
227 expected_scale: f64,
229}
230
231impl Acceptance {
232 fn new(nrstars_image: usize, params: &SolveParams, img: &crate::types::ImageBuffer) -> Self {
233 Self {
234 min_stars: min_verified_stars(nrstars_image),
235 expected_scale: params.fov.to_degrees() * 3600.0
236 / img.width.max(img.height).max(1) as f64,
237 }
238 }
239
240 fn accepts(&self, v: &Verified, spread: f64) -> bool {
246 if v.n() < self.min_stars || spread < MIN_VERIFY_SPREAD {
247 return false;
248 }
249 if !significant(v) {
250 return false;
251 }
252 if v.n() >= MIN_VERIFIED_STARS {
253 return true;
254 }
255 let p = &v.plate;
256 let scale = (p.a * p.e - p.b * p.d).abs().sqrt();
257 let ok = (scale / self.expected_scale - 1.0).abs() <= RELAXED_SCALE_TOL
258 && v.rms <= RELAXED_MAX_RMS_PX * scale;
259 log::info!(
260 "{} stars verified, scale {:.4}\"/px against {:.4} expected, residual {:.2} px: {}",
261 v.n(),
262 scale,
263 self.expected_scale,
264 v.rms / scale,
265 if ok { "accepted" } else { "refused" }
266 );
267 ok
268 }
269}
270
271const MIN_SIGNIFICANCE: f64 = 4.0;
282
283fn significant(v: &Verified) -> bool {
288 let p = &v.plate;
289 let scale = (p.a * p.e - p.b * p.d).abs().sqrt();
290 let ok = v.n() as f64 >= MIN_SIGNIFICANCE * v.chance
291 && v.rms <= VERIFY_RADII[VERIFY_RADII.len() - 1] * scale;
292 if !ok {
293 log::info!(
294 "{} stars verified against {:.1} expected by chance, residual {:.2} px: refused",
295 v.n(),
296 v.chance,
297 v.rms / scale.max(f64::MIN_POSITIVE)
298 );
299 }
300 ok
301}
302
303const VERIFY_RADII: [f64; 3] = [6.0, 3.0, 2.0];
305const MIN_VERIFY_SPREAD: f64 = 0.20;
312
313struct Verified {
316 plate: PlateConstants,
317 rms: f64,
318 img_pos: Vec<(f64, f64)>,
320 cat_pos: Vec<(f64, f64)>,
323 chance: f64,
327}
328
329impl Verified {
330 fn n(&self) -> usize {
332 self.img_pos.len()
333 }
334}
335
336fn verify_and_refit(
349 img_stars: &StarList,
350 cat_stars: &StarList,
351 plate: &PlateConstants,
352 img_w: usize,
353 img_h: usize,
354 accept: &Acceptance,
355) -> Option<Verified> {
356 if img_stars.is_empty() || cat_stars.is_empty() {
357 return None;
358 }
359
360 let grid = StarGrid::new(img_stars, VERIFY_RADII[0])?;
362
363 let mut current = plate.clone();
364 let mut best: Option<(Verified, f64)> = None;
366
367 for &radius in &VERIFY_RADII {
368 let det = current.a * current.e - current.b * current.d;
369 if det.abs() < 1e-12 {
370 return None;
371 }
372 let r2 = radius * radius;
373
374 let mut img_pos: Vec<(f64, f64)> = Vec::new();
375 let mut cat_pos: Vec<(f64, f64)> = Vec::new();
376 let mut used = vec![false; img_stars.len()];
377 let mut in_frame = 0usize;
378
379 for cs in &cat_stars.0 {
380 let dx = cs.x - current.c;
382 let dy = cs.y - current.f;
383 let px = (current.e * dx - current.b * dy) / det;
384 let py = (-current.d * dx + current.a * dy) / det;
385 if px >= 0.0 && py >= 0.0 && px < img_w as f64 && py < img_h as f64 {
386 in_frame += 1;
387 }
388 if !grid.near(px, py, radius) {
389 continue;
390 }
391 if let Some(i) = grid.nearest(px, py, r2, &used) {
392 used[i] = true; img_pos.push(grid.pos(i));
394 cat_pos.push((cs.x, cs.y));
395 }
396 }
397
398 if img_pos.len() < 4 {
399 break;
400 }
401 let Ok(refined) = solve_plate_constants(&img_pos, &cat_pos) else {
402 break;
403 };
404 let mut sq = 0.0;
405 for (&(xi, yi), &(xc, yc)) in img_pos.iter().zip(cat_pos.iter()) {
406 let xp = refined.a * xi + refined.b * yi + refined.c;
407 let yp = refined.d * xi + refined.e * yi + refined.f;
408 sq += (xp - xc).powi(2) + (yp - yc).powi(2);
409 }
410 let rms = (sq / img_pos.len() as f64).sqrt();
411 let spread = spread_of(&img_pos, img_w, img_h);
413 log::debug!(
414 "verify: {} stars, spread {:.3}, rms {:.2}\"",
415 img_pos.len(),
416 spread,
417 rms
418 );
419
420 let density = img_stars.len() as f64 / (img_w * img_h).max(1) as f64;
423 let chance = in_frame as f64 * (1.0 - (-density * core::f64::consts::PI * r2).exp());
424
425 current = refined.clone();
426 best = Some((
427 Verified {
428 plate: refined,
429 rms,
430 img_pos,
431 cat_pos,
432 chance,
433 },
434 spread,
435 ));
436 }
437
438 best.filter(|(v, spread)| accept.accepts(v, *spread))
439 .map(|(v, _)| v)
440}
441
442fn density_star_limit(params: &SolveParams, img: &crate::types::ImageBuffer) -> usize {
448 let Some(density) = crate::catalog::database_density(¶ms.db_name) else {
449 return params.max_stars;
450 };
451 let fov_deg = params.fov.to_degrees();
452 let (w, h) = (img.width as f64, img.height as f64);
453 let area = fov_deg * fov_deg * w.min(h) / w.max(h).max(1.0);
454 let cap = (density * area).round();
455 if cap < params.max_stars as f64 {
456 cap as usize
457 } else {
458 params.max_stars
459 }
460}
461
462struct SpiralCtx<'a> {
464 params: &'a SolveParams,
465 img: &'a crate::types::ImageBuffer,
466 stars: &'a StarList,
467 img_quads: &'a crate::types::QuadList,
468 img_grid: &'a QuadGrid,
470 img_tris: &'a crate::quads::TriangleList,
471 nrstars_image: usize,
472 star_limit: usize,
475 nrstars_required: usize,
476 oversize: f64,
477 min_quads: usize,
478 step_size: f64,
479 accept: Acceptance,
480 aspect: f64,
482 cancel: Option<crate::cancel::CancelToken>,
485}
486
487struct PositionOutcome {
489 idx: usize,
490 ra_db: f64,
491 dec_db: f64,
492 sep_deg: f64,
493 verified: Verified,
494 n_matched: usize,
495 n_raw: usize,
496 mag_limit: f64,
497 refused: bool,
500}
501
502struct PositionTry {
506 sep_deg: Option<f64>,
507 outcome: Option<PositionOutcome>,
508}
509
510impl PositionTry {
511 const NONE: Self = Self {
512 sep_deg: None,
513 outcome: None,
514 };
515}
516
517fn try_position(ctx: &SpiralCtx<'_>, idx: usize, sx: i32, sy: i32) -> PositionTry {
520 let params = ctx.params;
521 let step_size = ctx.step_size;
522
523 let dec_db_raw = params.dec_hint + step_size * sy as f64;
524 let (dec_db, flip) = if dec_db_raw > PI / 2.0 {
525 (PI - dec_db_raw, PI)
526 } else if dec_db_raw < -PI / 2.0 {
527 (-PI - dec_db_raw, PI)
528 } else {
529 (dec_db_raw, 0.0)
530 };
531
532 let extra = if dec_db > 0.0 {
533 step_size * 0.5
534 } else {
535 -step_size * 0.5
536 };
537 let ra_offset = step_size * sx as f64 / (dec_db - extra).cos();
538 if ra_offset > PI / 2.0 + step_size * 0.5 || ra_offset < -PI / 2.0 {
539 return PositionTry::NONE;
540 }
541
542 let ra_db = (flip + params.ra_hint + ra_offset).rem_euclid(2.0 * PI);
543 let sep = ang_sep(ra_db, dec_db, params.ra_hint, params.dec_hint);
544 if sep > params.search_radius + step_size / 2.0 {
545 return PositionTry::NONE;
546 }
547
548 let cat_raw = match read_catalog_stars(
551 ¶ms.db_path,
552 ¶ms.db_name,
553 ra_db,
554 dec_db,
555 params.fov * ctx.oversize,
556 ctx.nrstars_required,
557 ) {
558 Ok(v) if !v.is_empty() => v,
559 Ok(_) | Err(_) => return PositionTry::NONE,
560 };
561 if crate::cancel::fired(ctx.cancel.as_ref()) {
562 return PositionTry::NONE;
563 }
564
565 let sep_deg = sep.to_degrees();
566 let mag_limit = cat_raw
567 .iter()
568 .map(|s| s.mag)
569 .fold(f64::NEG_INFINITY, f64::max);
570 log::info!(
571 target: SEARCH_LOG_TARGET,
572 "Search {}, [{},{}], position: {} Down to magn {:.1} {} database stars {} database quads to compare.",
573 idx,
574 sx,
575 sy,
576 format_radec(ra_db, dec_db),
577 mag_limit,
578 cat_raw.len(),
579 cat_raw.len(),
580 );
581
582 let mut cat_stars: Vec<Star> = cat_raw
583 .iter()
584 .map(|s| {
585 let (x, y) = equatorial_standard(ra_db, dec_db, s.ra, s.dec, 1.0);
586 Star {
587 x,
588 y,
589 snr: 1.0,
590 hfd: 2.0,
591 }
592 })
593 .collect();
594 cat_stars.sort_unstable_by(|a, b| a.x.total_cmp(&b.x));
595 let cat_star_list = StarList(cat_stars);
596
597 let failed = PositionTry {
598 sep_deg: Some(sep_deg),
599 outcome: None,
600 };
601
602 let (img_pos, cat_pos, n_raw) = match params.method {
603 SolveMethod::Quads => {
604 let mut cat_quads = build_quads_presorted(&cat_star_list, ctx.nrstars_image);
605 if ctx.nrstars_image < ctx.star_limit {
606 add_density_matched_quads(ctx, &cat_raw, ra_db, dec_db, &mut cat_quads);
607 }
608 if cat_quads.is_empty() {
609 return failed;
610 }
611 let raw = ctx
614 .img_grid
615 .find_matches(ctx.img_quads, &cat_quads, params.quad_tolerance);
616 let n_raw = raw.len();
617 log::info!(target: SEARCH_LOG_TARGET, "Found {n_raw} references");
618 let mut filtered = vote_filter(ctx.img_quads, &cat_quads, &raw, params.quad_tolerance);
619 if filtered.len() < ctx.min_quads {
620 let (by_scale, _) = filter_by_scale(&raw, params.quad_tolerance);
621 if by_scale.len() > filtered.len() {
622 filtered = by_scale;
623 }
624 }
625 if filtered.len() < ctx.min_quads {
626 return failed;
627 }
628 let (ip, cp) = extract_star_pairs(ctx.img_quads, &cat_quads, &filtered);
629 (ip, cp, n_raw)
630 }
631 SolveMethod::Tetra => {
632 let cat_tris = build_triangles(&cat_star_list);
633 if cat_tris.is_empty() {
634 return failed;
635 }
636 let tol = params.quad_tolerance * TETRA_TOL_FACTOR;
637 let raw = find_triangle_matches(ctx.img_tris, &cat_tris, tol);
638 let n_raw = raw.len();
639 log::info!(target: SEARCH_LOG_TARGET, "Found {n_raw} triangle references");
640 let biject = bijective_filter(&raw, ctx.img_tris, &cat_tris);
641 let (filtered, _) = filter_triangles_by_scale(&biject, params.quad_tolerance);
642 if filtered.len() < ctx.min_quads {
643 return failed;
644 }
645 let (ip, cp) = extract_triangle_pairs(ctx.img_tris, &cat_tris, &filtered);
646 (ip, cp, n_raw)
647 }
648 };
649
650 let seeds = Seeds {
653 img: img_pos.clone(),
654 cat: cat_pos.clone(),
655 ra: ra_db,
656 dec: dec_db,
657 };
658 if crate::cancel::fired(ctx.cancel.as_ref()) {
659 return failed;
660 }
661 let Some((plate, n_matched)) = fit_pattern_pairs(img_pos, cat_pos, ctx.min_quads) else {
662 return failed;
663 };
664
665 let found = |verified, ra_db, dec_db, refused| PositionTry {
666 sep_deg: Some(sep_deg),
667 outcome: Some(PositionOutcome {
668 idx,
669 ra_db,
670 dec_db,
671 sep_deg,
672 verified,
673 n_matched,
674 n_raw,
675 mag_limit,
676 refused,
677 }),
678 };
679
680 let Some(verified) = verify_and_refit(
681 ctx.stars,
682 &cat_star_list,
683 &plate,
684 ctx.img.width,
685 ctx.img.height,
686 &ctx.accept,
687 ) else {
688 log::info!(
689 target: SEARCH_LOG_TARGET,
690 "Verification failed at this position; continuing search."
691 );
692 if n_matched >= STRONG_VOTE
693 && let Some((verified, ra_c, dec_c)) =
694 second_chance(ctx, &cat_raw, &seeds, &plate, ra_db, dec_db)
695 {
696 return found(verified, ra_c, dec_c, false);
697 }
698 return failed;
699 };
700 log::info!(
701 "Verified {} stars against the catalogue, residual {:.2}\"",
702 verified.n(),
703 verified.rms
704 );
705
706 let (verified, ra_db, dec_db) = recentre(ctx, &cat_raw, verified, ra_db, dec_db);
707 match model_distortion(ctx, &cat_raw, &seeds, verified, ra_db, dec_db) {
708 Modelled::Linear(v) => found(v, ra_db, dec_db, false),
709 Modelled::Distorted(v, ra_c, dec_c) => found(v, ra_c, dec_c, false),
710 Modelled::Refused(v) => found(v, ra_db, dec_db, true),
711 }
712}
713
714const STRONG_VOTE: usize = 50;
719
720fn project(cat_raw: &[CatalogStar], ra: f64, dec: f64) -> StarList {
722 StarList(
723 cat_raw
724 .iter()
725 .map(|s| {
726 let (x, y) = equatorial_standard(ra, dec, s.ra, s.dec, 1.0);
727 Star {
728 x,
729 y,
730 snr: 1.0,
731 hfd: 2.0,
732 }
733 })
734 .collect(),
735 )
736}
737
738struct Seeds {
740 img: Vec<(f64, f64)>,
741 cat: Vec<(f64, f64)>,
742 ra: f64,
743 dec: f64,
744}
745
746impl Seeds {
747 fn in_plane(&self, ra: f64, dec: f64) -> Vec<Pair> {
749 self.img
750 .iter()
751 .zip(&self.cat)
752 .map(|(&i, &(x, y))| {
753 if ra == self.ra && dec == self.dec {
754 return (i, (x, y));
755 }
756 let (sra, sdec) = standard_equatorial(self.ra, self.dec, x, y, 1.0);
757 (i, equatorial_standard(ra, dec, sra, sdec, 1.0))
758 })
759 .collect()
760 }
761}
762
763fn fit_distortion(
766 ctx: &SpiralCtx<'_>,
767 cat_raw: &[CatalogStar],
768 seeds: &Seeds,
769 plate: &PlateConstants,
770 ra: f64,
771 dec: f64,
772) -> Option<Refined> {
773 let (w, h) = (ctx.img.width as f64, ctx.img.height as f64);
779 let (xs, ys) = (
780 plate.a * (w - 1.0) * 0.5 + plate.b * (h - 1.0) * 0.5 + plate.c,
781 plate.d * (w - 1.0) * 0.5 + plate.e * (h - 1.0) * 0.5 + plate.f,
782 );
783 let (ra_c, dec_c) = standard_equatorial(ra, dec, xs, ys, 1.0);
784 let window = w.hypot(h) / w.max(h);
785 let cat = match read_catalog_stars(
786 &ctx.params.db_path,
787 &ctx.params.db_name,
788 ra_c,
789 dec_c,
790 ctx.params.fov * window,
791 (ctx.params.max_stars as f64 * window * window).round() as usize,
792 ) {
793 Ok(v) if !v.is_empty() => project(&v, ra, dec),
794 _ => project(cat_raw, ra, dec),
795 };
796 let grid = StarGrid::new(ctx.stars, VERIFY_RADII[0])?;
797 let r = refine(
798 &grid,
799 &cat,
800 &seeds.in_plane(ra, dec),
801 plate,
802 ctx.img.width,
803 ctx.img.height,
804 VERIFY_RADII[VERIFY_RADII.len() - 1],
805 )?;
806 log::info!(
807 "Distortion model: {} terms, {} stars within {} px, rms {:.2} px, F {:.1} over linear, {} of 9 cells",
808 r.model.n_terms,
809 r.img_pos.len(),
810 VERIFY_RADII[VERIFY_RADII.len() - 1],
811 r.rms / r.model.scale(),
812 r.f_linear,
813 r.cells
814 );
815 Some(r)
816}
817
818fn linear_from_model(
822 ctx: &SpiralCtx<'_>,
823 r: &Refined,
824 ra: f64,
825 dec: f64,
826) -> Option<(Verified, f64, f64)> {
827 let (w, h) = (ctx.img.width, ctx.img.height);
828 let (xs, ys) = r
829 .model
830 .apply((w as f64 - 1.0) * 0.5, (h as f64 - 1.0) * 0.5);
831 let (ra_c, dec_c) = standard_equatorial(ra, dec, xs, ys, 1.0);
832 let moved = |(x, y): (f64, f64)| {
835 let (sra, sdec) = standard_equatorial(ra, dec, x, y, 1.0);
836 equatorial_standard(ra_c, dec_c, sra, sdec, 1.0)
837 };
838 let plate = best_linear(|x, y| moved(r.model.apply(x, y)), w, h)?;
839 let cat_pos = r.cat_pos.iter().map(|&p| moved(p)).collect();
840 Some((
841 Verified {
842 plate,
843 rms: r.rms,
844 img_pos: r.img_pos.clone(),
845 cat_pos,
846 chance: 0.0,
847 },
848 ra_c,
849 dec_c,
850 ))
851}
852
853enum Modelled {
855 Linear(Verified),
857 Distorted(Verified, f64, f64),
859 Refused(Verified),
862}
863
864const MIN_REPORT_F: f64 = 30.0;
871
872const MIN_DEPARTURE_PX: f64 = 1.0;
876
877const REFUSE_F: f64 = 100.0;
880const REFUSE_DEPARTURE_PX: f64 = 3.0;
885
886fn model_distortion(
896 ctx: &SpiralCtx<'_>,
897 cat_raw: &[CatalogStar],
898 seeds: &Seeds,
899 verified: Verified,
900 ra: f64,
901 dec: f64,
902) -> Modelled {
903 let Some(r) = fit_distortion(ctx, cat_raw, seeds, &verified.plate, ra, dec) else {
904 return Modelled::Linear(verified);
905 };
906 let (w, h) = (ctx.img.width, ctx.img.height);
907 let departure = max_departure_px(&r.model, &verified.plate, w, h);
908 log::info!(
909 "Distortion: verified plate departs {departure:.2} px from the model; {} stars against {} verified",
910 r.img_pos.len(),
911 verified.n(),
912 );
913 let min_cells = if r.model.n_terms == 10 { 9 } else { 7 };
914 let usable = r.model.n_terms > 3
915 && r.cells >= min_cells
916 && r.f_linear >= MIN_REPORT_F
917 && r.img_pos.len() * 10 >= verified.n() * 9;
918 if usable {
919 if departure >= MIN_DEPARTURE_PX
920 && let Some((v, ra_c, dec_c)) = linear_from_model(ctx, &r, ra, dec)
921 {
922 log::info!("Reporting the linear plate closest to the distortion model.");
923 return Modelled::Distorted(v, ra_c, dec_c);
924 }
925 return Modelled::Linear(verified);
926 }
927 let (wide_f, wide_dep) = r.unmodelled();
928 log::info!("Where the stars are: a cubic with F {wide_f:.1}, {wide_dep:.2} px from the plate.");
929 if wide_f >= REFUSE_F && wide_dep >= REFUSE_DEPARTURE_PX {
930 log::info!(
931 "The field is distorted by {wide_dep:.1} px where it has stars, and the distortion \
932 cannot be modelled over the whole frame: refusing a linear solution."
933 );
934 return Modelled::Refused(verified);
935 }
936 if r.img_pos.len() as f64 >= MODEL_PAIRS_REFIT * verified.n() as f64
937 && let Some(v) = refit_linear(&r.img_pos, &r.cat_pos)
938 {
939 log::info!(
940 "The full-frame match pairs {} stars against {} verified: refitting the linear plate to them.",
941 r.img_pos.len(),
942 verified.n()
943 );
944 return Modelled::Linear(v);
945 }
946 Modelled::Linear(verified)
947}
948
949const MODEL_PAIRS_REFIT: f64 = 1.5;
964
965fn refit_linear(img_pos: &[(f64, f64)], cat_pos: &[(f64, f64)]) -> Option<Verified> {
967 let plate = solve_plate_constants(img_pos, cat_pos).ok()?;
968 let sq: f64 = img_pos
969 .iter()
970 .zip(cat_pos)
971 .map(|(&(x, y), &(xc, yc))| {
972 (plate.a * x + plate.b * y + plate.c - xc).powi(2)
973 + (plate.d * x + plate.e * y + plate.f - yc).powi(2)
974 })
975 .sum();
976 let rms = (sq / img_pos.len().max(1) as f64).sqrt();
977 Some(Verified {
978 plate,
979 rms,
980 img_pos: img_pos.to_vec(),
981 cat_pos: cat_pos.to_vec(),
982 chance: 0.0,
983 })
984}
985
986fn spread_of(img_pos: &[(f64, f64)], img_w: usize, img_h: usize) -> f64 {
989 let n = img_pos.len() as f64;
990 let mx = img_pos.iter().map(|p| p.0).sum::<f64>() / n;
991 let my = img_pos.iter().map(|p| p.1).sum::<f64>() / n;
992 let var = img_pos
993 .iter()
994 .map(|&(x, y)| (x - mx) * (x - mx) + (y - my) * (y - my))
995 .sum::<f64>()
996 / n;
997 let half_diag = 0.5 * ((img_w * img_w + img_h * img_h) as f64).sqrt();
998 var.sqrt() / half_diag
999}
1000
1001fn second_chance(
1007 ctx: &SpiralCtx<'_>,
1008 cat_raw: &[CatalogStar],
1009 seeds: &Seeds,
1010 plate: &PlateConstants,
1011 ra: f64,
1012 dec: f64,
1013) -> Option<(Verified, f64, f64)> {
1014 log::info!(
1015 target: SEARCH_LOG_TARGET,
1016 "Strong pattern match: retrying verification with a distortion model."
1017 );
1018 let r = fit_distortion(ctx, cat_raw, seeds, plate, ra, dec)?;
1019 if r.cells < if r.model.n_terms == 10 { 9 } else { 7 } {
1021 log::info!(
1022 target: SEARCH_LOG_TARGET,
1023 "The distortion model's stars do not cover the frame."
1024 );
1025 return None;
1026 }
1027 let probe = Verified {
1028 plate: r.model.linear_part(),
1029 rms: r.rms,
1030 img_pos: r.img_pos.clone(),
1031 cat_pos: r.cat_pos.clone(),
1032 chance: 0.0,
1034 };
1035 let spread = spread_of(&r.img_pos, ctx.img.width, ctx.img.height);
1036 if !ctx.accept.accepts(&probe, spread) {
1037 log::info!(target: SEARCH_LOG_TARGET, "The distortion model did not verify either.");
1038 return None;
1039 }
1040 log::info!(
1041 "Verified {} stars with the distortion model.",
1042 r.img_pos.len()
1043 );
1044 linear_from_model(ctx, &r, ra, dec)
1045}
1046
1047const DENSITY_MATCH_MIN_RATIO: f64 = 2.5;
1055
1056fn add_density_matched_quads(
1072 ctx: &SpiralCtx<'_>,
1073 cat_raw: &[CatalogStar],
1074 ra_db: f64,
1075 dec_db: f64,
1076 cat_quads: &mut crate::types::QuadList,
1077) {
1078 let k = (ctx.nrstars_image as f64 * ctx.oversize * ctx.oversize * ctx.aspect).round() as usize;
1079 if k < 5 || (k as f64) * DENSITY_MATCH_MIN_RATIO > cat_raw.len() as f64 {
1080 return;
1081 }
1082 let mut sub: Vec<Star> = cat_raw[..k]
1084 .iter()
1085 .map(|s| {
1086 let (x, y) = equatorial_standard(ra_db, dec_db, s.ra, s.dec, 1.0);
1087 Star {
1088 x,
1089 y,
1090 snr: 1.0,
1091 hfd: 2.0,
1092 }
1093 })
1094 .collect();
1095 sub.sort_unstable_by(|a, b| a.x.total_cmp(&b.x));
1096 let extra = build_quads_presorted(&StarList(sub), ctx.nrstars_image);
1097 let key = |q: &crate::types::Quad| {
1100 (
1101 (q.center_x * 1000.0).round() as i64,
1102 (q.center_y * 1000.0).round() as i64,
1103 (q.d1 * 1000.0).round() as i64,
1104 )
1105 };
1106 let seen: std::collections::HashSet<_> = cat_quads.0.iter().map(key).collect();
1107 let before = cat_quads.len();
1108 cat_quads
1109 .0
1110 .extend(extra.0.into_iter().filter(|q| !seen.contains(&key(q))));
1111 log::info!(
1112 "{} more database quads from its {k} brightest stars, the image's density.",
1113 cat_quads.len() - before
1114 );
1115}
1116
1117fn recentre(
1134 ctx: &SpiralCtx<'_>,
1135 cat_raw: &[CatalogStar],
1136 mut verified: Verified,
1137 mut ra_db: f64,
1138 mut dec_db: f64,
1139) -> (Verified, f64, f64) {
1140 let (w, h) = (ctx.img.width as f64, ctx.img.height as f64);
1141 let (cx, cy) = ((w - 1.0) * 0.5, (h - 1.0) * 0.5);
1142 let apply =
1143 |p: &PlateConstants, x: f64, y: f64| (p.a * x + p.b * y + p.c, p.d * x + p.e * y + p.f);
1144
1145 for _ in 0..2 {
1146 let plate = &verified.plate;
1147 let (xs, ys) = apply(plate, cx, cy);
1148 if xs.hypot(ys) < 1e-3 {
1150 break;
1151 }
1152 let (ra0, dec0) = standard_equatorial(ra_db, dec_db, xs, ys, 1.0);
1153
1154 let det = plate.a * plate.e - plate.b * plate.d;
1160 if det.abs() < 1e-12 {
1161 break;
1162 }
1163 let r2 = VERIFY_RADII[0] * VERIFY_RADII[0];
1164 let mut used = vec![false; ctx.stars.len()];
1165 let mut img_pos = Vec::new();
1166 let mut new_pos = Vec::new();
1167 let mut cat = Vec::with_capacity(cat_raw.len());
1168 for s in cat_raw {
1169 let (nx, ny) = equatorial_standard(ra0, dec0, s.ra, s.dec, 1.0);
1170 cat.push(Star {
1171 x: nx,
1172 y: ny,
1173 snr: 1.0,
1174 hfd: 2.0,
1175 });
1176 let (ox, oy) = equatorial_standard(ra_db, dec_db, s.ra, s.dec, 1.0);
1177 let (dx, dy) = (ox - plate.c, oy - plate.f);
1178 let px = (plate.e * dx - plate.b * dy) / det;
1179 let py = (-plate.d * dx + plate.a * dy) / det;
1180 let nearest = ctx
1181 .stars
1182 .0
1183 .iter()
1184 .enumerate()
1185 .filter(|&(i, _)| !used[i])
1186 .map(|(i, st)| (i, (st.x - px).powi(2) + (st.y - py).powi(2)))
1187 .filter(|&(_, d2)| d2 < r2)
1188 .min_by(|a, b| a.1.total_cmp(&b.1));
1189 if let Some((i, _)) = nearest {
1190 used[i] = true;
1191 img_pos.push((ctx.stars.0[i].x, ctx.stars.0[i].y));
1192 new_pos.push((nx, ny));
1193 }
1194 }
1195 let Ok(guess) = solve_plate_constants(&img_pos, &new_pos) else {
1196 break;
1197 };
1198 let cat = StarList(cat);
1199 let Some(v) = verify_and_refit(
1200 ctx.stars,
1201 &cat,
1202 &guess,
1203 ctx.img.width,
1204 ctx.img.height,
1205 &ctx.accept,
1206 ) else {
1207 log::info!("Re-centring on the image centre did not verify; keeping the fit.");
1208 break;
1209 };
1210 log::info!(
1211 "Re-centred on the image centre: verified {} stars, residual {:.2}\"",
1212 v.n(),
1213 v.rms
1214 );
1215 (verified, ra_db, dec_db) = (v, ra0, dec0);
1216 }
1217 (verified, ra_db, dec_db)
1218}
1219
1220const SEEDED_MAX_STARS: usize = 2000;
1223const SEEDED_WINDOW: f64 = 1.5;
1226const SEEDED_CAT_STARS: usize = 150;
1229const SEEDED_MAX_QUADS: usize = 600;
1231const SEEDED_SCALE_TOL: f64 = 0.05;
1233const SEEDED_PROBE_PX: f64 = 2.5;
1235const SEEDED_MIN_CENSUS: usize = 10;
1237const SEEDED_BUDGET: u64 = 30_000_000;
1243
1244fn seeded_fallback(ctx: &SpiralCtx<'_>, deep: &StarList) -> Option<PositionOutcome> {
1249 use crate::quads::seeded::{ImageIndex, SeedParams, max_backbone_px, search};
1250 let params = ctx.params;
1251 if deep.len() < 30 {
1252 return None;
1253 }
1254 let (ra, dec) = (params.ra_hint, params.dec_hint);
1255 let n_read = (ctx.nrstars_required as f64 * (SEEDED_WINDOW / ctx.oversize).powi(2)).round();
1256 let cat_raw = read_catalog_stars(
1257 ¶ms.db_path,
1258 ¶ms.db_name,
1259 ra,
1260 dec,
1261 params.fov * SEEDED_WINDOW,
1262 n_read as usize,
1263 )
1264 .ok()
1265 .filter(|v| v.len() >= 8)?;
1266 let mag_limit = cat_raw
1267 .iter()
1268 .map(|s| s.mag)
1269 .fold(f64::NEG_INFINITY, f64::max);
1270 let cat_list = project(&cat_raw, ra, dec);
1271 let cat_pos: Vec<(f64, f64)> = cat_list.0.iter().map(|s| (s.x, s.y)).collect();
1272 let sp = SeedParams {
1273 scale: ctx.accept.expected_scale,
1274 scale_tol: SEEDED_SCALE_TOL,
1275 width: ctx.img.width as f64,
1276 height: ctx.img.height as f64,
1277 seed_stars: SEEDED_CAT_STARS,
1278 max_quads: SEEDED_MAX_QUADS,
1279 census_stars: SEEDED_CAT_STARS,
1280 min_census: SEEDED_MIN_CENSUS,
1281 verify_cost: 30 * cat_pos.len() as u64,
1284 };
1285 let index = ImageIndex::new(
1286 deep.0.iter().map(|s| (s.x, s.y)).collect(),
1287 max_backbone_px(&cat_pos, &sp),
1288 SEEDED_PROBE_PX,
1289 );
1290 log::info!(
1291 "Catalogue-seeded search: {} database stars about the hint, {} image stars, {} pairs.",
1292 cat_raw.len(),
1293 index.len(),
1294 index.n_pairs()
1295 );
1296 let mut budget = SEEDED_BUDGET;
1297 let mut verified = None;
1298 let mut candidates = 0usize;
1299 let cand = search(&index, &cat_pos, &sp, &mut budget, |c| {
1300 candidates += 1;
1301 verified = verify_and_refit(
1302 ctx.stars,
1303 &cat_list,
1304 &c.plate,
1305 ctx.img.width,
1306 ctx.img.height,
1307 &ctx.accept,
1308 );
1309 verified.is_some()
1310 });
1311 log::info!(
1312 "Catalogue-seeded search: {candidates} candidates verified, {} of {SEEDED_BUDGET} work spent.",
1313 SEEDED_BUDGET - budget
1314 );
1315 let cand = cand?;
1316 let verified = verified?;
1317 log::info!(
1318 "Verified {} stars against the catalogue, residual {:.2}\"",
1319 verified.n(),
1320 verified.rms
1321 );
1322 let seeds = Seeds {
1323 img: cand.img.clone(),
1324 cat: cand.cat.clone(),
1325 ra,
1326 dec,
1327 };
1328 let (verified, ra_db, dec_db) = recentre(ctx, &cat_raw, verified, ra, dec);
1329 let (verified, ra_db, dec_db, refused) =
1330 match model_distortion(ctx, &cat_raw, &seeds, verified, ra_db, dec_db) {
1331 Modelled::Linear(v) => (v, ra_db, dec_db, false),
1332 Modelled::Distorted(v, ra_c, dec_c) => (v, ra_c, dec_c, false),
1333 Modelled::Refused(v) => (v, ra_db, dec_db, true),
1334 };
1335 Some(PositionOutcome {
1336 idx: usize::MAX,
1337 ra_db,
1338 dec_db,
1339 sep_deg: 0.0,
1340 verified,
1341 n_matched: cand.img.len(),
1342 n_raw: candidates,
1343 mag_limit,
1344 refused,
1345 })
1346}
1347
1348pub fn solve_image(img: &crate::types::ImageBuffer, params: &SolveParams) -> Result<WcsSolution> {
1369 if !(params.fov.is_finite() && params.fov > 0.0) {
1372 return Err(ArcsecError::InvalidParameter(format!(
1373 "field of view must be positive, got {} rad",
1374 params.fov
1375 )));
1376 }
1377 if !(params.search_radius.is_finite() && params.search_radius >= 0.0) {
1378 return Err(ArcsecError::InvalidParameter(format!(
1379 "search radius must be non-negative, got {} rad",
1380 params.search_radius
1381 )));
1382 }
1383 if !(0.0..=MAX_QUAD_TOLERANCE).contains(¶ms.quad_tolerance) {
1384 return Err(ArcsecError::InvalidParameter(format!(
1385 "quad tolerance must be between 0 and {MAX_QUAD_TOLERANCE}, got {}",
1386 params.quad_tolerance
1387 )));
1388 }
1389 if params.search_radius / params.fov > MAX_SPIRAL_RINGS {
1393 return Err(ArcsecError::InvalidParameter(format!(
1394 "a search radius of {:.0} fields cannot be searched (field {} rad)",
1395 params.search_radius / params.fov,
1396 params.fov
1397 )));
1398 }
1399
1400 if !crate::catalog::catalog_present(¶ms.db_path, ¶ms.db_name) {
1405 return Err(ArcsecError::CatalogNotFound(params.db_path.clone()));
1406 }
1407
1408 let cancel = crate::cancel::current();
1410 let cancelled = || crate::cancel::fired(cancel.as_ref());
1411 if cancelled() {
1412 return Err(ArcsecError::Cancelled);
1413 }
1414
1415 if let Some(c) = &cancel {
1417 c.progress(crate::cancel::stage::DETECTING, -1.0);
1418 }
1419 let bg = get_background(img, params.max_stars);
1420 log::info!("Start finding stars");
1421 let (stars, stars_raw, deep_stars) =
1422 find_stars_and_deep(img, &bg, params.hfd_min, params.max_stars, SEEDED_MAX_STARS);
1423 log::info!(
1424 "{} stars found of the requested {}. Background value is {:.0}. \
1425 Detection level used {:.0} above background. Star level is {:.0} above background. \
1426 Noise level is {:.0}",
1427 stars_raw,
1428 params.max_stars,
1429 bg.mean,
1430 bg.star_level,
1431 bg.star_level,
1432 bg.noise,
1433 );
1434 if stars_raw > params.max_stars {
1435 log::info!("Selecting the {} brightest stars only.", params.max_stars);
1436 }
1437
1438 let star_limit = density_star_limit(params, img);
1450 let mut stars = stars;
1451 if stars.len() > star_limit {
1452 stars.0.sort_by(|a, b| b.snr.total_cmp(&a.snr));
1453 stars.0.truncate(star_limit);
1454 log::info!(
1455 "Database limit for this field is {star_limit} stars; using the {star_limit} brightest."
1456 );
1457 }
1458
1459 if cancelled() {
1460 return Err(ArcsecError::Cancelled);
1461 }
1462 let nrstars_image = stars.len();
1463 if nrstars_image < 5 {
1464 return Err(ArcsecError::InsufficientStars {
1465 found: nrstars_image,
1466 required: 5,
1467 });
1468 }
1469
1470 let img_quads = build_quads(&stars, nrstars_image);
1472 let nr_quads = img_quads.len();
1473
1474 let img_tris = if params.method == SolveMethod::Tetra {
1475 build_triangles(&stars)
1476 } else {
1477 crate::quads::TriangleList::default()
1478 };
1479
1480 let patterns_empty = match params.method {
1481 SolveMethod::Quads => nr_quads == 0,
1482 SolveMethod::Tetra => img_tris.is_empty(),
1483 };
1484 if patterns_empty {
1485 return Err(ArcsecError::InsufficientQuads {
1486 found: 0,
1487 required: 3,
1488 });
1489 }
1490
1491 let min_quads: usize = 3 + nrstars_image / 140;
1492 let img_grid = if params.method == SolveMethod::Quads {
1493 QuadGrid::build(&img_quads, params.quad_tolerance)
1494 } else {
1495 QuadGrid::build(&crate::types::QuadList::default(), params.quad_tolerance)
1496 };
1497
1498 let oversize: f64 = match params.speed {
1499 SearchSpeed::Auto if nrstars_image < 35 => 2.0,
1500 SearchSpeed::Auto if nrstars_image > 140 => 1.0,
1501 SearchSpeed::Auto => 2.0 * (35.0 / nrstars_image as f64).sqrt(),
1502 SearchSpeed::Slow => {
1505 let max_fov_deg = match crate::catalog::detect_layout(¶ms.db_path, ¶ms.db_name)
1506 {
1507 CatalogLayout::Areas1476 => 5.142_857_143_f64,
1508 CatalogLayout::Areas290 => 9.53,
1509 CatalogLayout::AllSky001 => 180.0,
1510 };
1511 2.0_f64.min(max_fov_deg.to_radians() / params.fov).max(1.0)
1512 }
1513 };
1514
1515 let nrstars_required = (params.max_stars as f64 * oversize * oversize).round() as usize;
1517 let step_size = params.fov;
1518 let fov_deg = step_size.to_degrees();
1519 let max_distance = (params.search_radius / step_size + 2.0) as i32;
1520
1521 log::info!(
1522 "{} stars, {} quads selected in the image. {} database stars, {} database quads required \
1523 for the {:.2}d square search window. Step size {:.2}d. Oversize {:.2}",
1524 nrstars_image,
1525 nr_quads,
1526 nrstars_required,
1527 nrstars_required,
1528 fov_deg * oversize,
1529 fov_deg,
1530 oversize,
1531 );
1532
1533 let ctx = SpiralCtx {
1539 params,
1540 img,
1541 stars: &stars,
1542 img_quads: &img_quads,
1543 img_grid: &img_grid,
1544 img_tris: &img_tris,
1545 nrstars_image,
1546 star_limit,
1547 nrstars_required,
1548 oversize,
1549 min_quads,
1550 step_size,
1551 accept: Acceptance::new(nrstars_image, params, img),
1552 aspect: img.width.max(img.height) as f64 / img.width.min(img.height).max(1) as f64,
1553 cancel: cancel.clone(),
1554 };
1555
1556 let n_threads = if params.threads > 0 {
1557 params.threads
1558 } else {
1559 crate::max_threads()
1560 }
1561 .clamp(1, 64);
1562
1563 let n_positions = usize::try_from(spiral_len(max_distance)).unwrap_or(usize::MAX);
1567 let reported = core::sync::atomic::AtomicUsize::new(0);
1570 let progress_total = n_positions.max(1);
1571 let (step_distances, winner) = search_in_order(n_positions, n_threads, |idx| {
1572 if cancelled() {
1574 return (None, None);
1575 }
1576 if let Some(c) = &cancel {
1577 let bucket = (idx as u128 * 200 / progress_total as u128) as usize;
1578 if bucket > reported.fetch_max(bucket, core::sync::atomic::Ordering::Relaxed) {
1579 c.progress(
1580 crate::cancel::stage::SEARCHING,
1581 idx as f64 / progress_total as f64,
1582 );
1583 }
1584 }
1585 let (sx, sy) = spiral_position(idx as u64);
1586 let t = try_position(&ctx, idx, sx, sy);
1587 (t.sep_deg, t.outcome)
1588 });
1589 let mut winner = winner.map(|(_, o)| o);
1590 if winner.is_none() && cancelled() {
1591 return Err(ArcsecError::Cancelled);
1592 }
1593
1594 if winner.is_none() && params.method == SolveMethod::Quads {
1596 winner = seeded_fallback(&ctx, &deep_stars);
1597 }
1598
1599 if let Some(o) = winner.as_ref().filter(|o| o.refused) {
1600 log::info!(
1601 "No solution: the field at search position {} is too distorted for a linear plate.",
1602 o.idx
1603 );
1604 return Err(ArcsecError::InsufficientQuads {
1605 found: 0,
1606 required: min_quads,
1607 });
1608 }
1609 if let Some(o) = winner {
1610 log::info!(
1611 "{} of {} patterns selected matching within {:.3} tolerance.",
1612 o.n_matched,
1613 o.n_raw,
1614 params.quad_tolerance,
1615 );
1616
1617 let v = o.verified;
1618 let mut wcs = derive_wcs(o.ra_db, o.dec_db, &v.plate, img.width, img.height);
1619 let b = params.binning.max(1) as f64;
1622 wcs.matched_stars = v
1623 .img_pos
1624 .iter()
1625 .zip(&v.cat_pos)
1626 .map(|(&(x, y), &(sx, sy))| {
1627 let (ra, dec) = standard_equatorial(o.ra_db, o.dec_db, sx, sy, 1.0);
1628 MatchedStar {
1629 x: (x + 0.5) * b + 0.5,
1630 y: (y + 0.5) * b + 0.5,
1631 ra,
1632 dec,
1633 }
1634 })
1635 .collect();
1636 if params.binning > 1 {
1637 let b = params.binning as f64;
1638 wcs.crpix1 = (wcs.crpix1 - 0.5) * b + 0.5;
1639 wcs.crpix2 = (wcs.crpix2 - 0.5) * b + 0.5;
1640 wcs.cd1_1 /= b;
1641 wcs.cd1_2 /= b;
1642 wcs.cd2_1 /= b;
1643 wcs.cd2_2 /= b;
1644 wcs.cdelt1 /= b;
1645 wcs.cdelt2 /= b;
1646 }
1647 wcs.residual_rms = v.rms;
1648 wcs.stars_matched = v.n();
1649 wcs.raw_matches = o.n_raw;
1650 wcs.plate = v.plate;
1651 wcs.mag_limit = o.mag_limit;
1652 wcs.search_dist_deg = o.sep_deg;
1653 wcs.step_distances = step_distances;
1654 return Ok(wcs);
1655 }
1656
1657 Err(ArcsecError::InsufficientQuads {
1658 found: 0,
1659 required: min_quads,
1660 })
1661}
1662
1663type Tried<T> = (usize, Option<f64>, Option<T>);
1665
1666fn search_in_order<T: Send>(
1679 n: usize,
1680 n_threads: usize,
1681 try_at: impl Fn(usize) -> (Option<f64>, Option<T>) + Sync,
1682) -> (Vec<f64>, Option<(usize, T)>) {
1683 use core::sync::atomic::{AtomicUsize, Ordering};
1684
1685 if n == 0 {
1686 return (Vec::new(), None);
1687 }
1688 let mut tried: Vec<Tried<T>> = Vec::new();
1691 let (d, o) = try_at(0);
1692 let first_hit = o.is_some();
1693 tried.push((0, d, o));
1694 if !first_hit && n > 1 {
1695 if n_threads <= 1 {
1696 for idx in 1..n {
1697 let (d, o) = try_at(idx);
1698 let hit = o.is_some();
1699 tried.push((idx, d, o));
1700 if hit {
1701 break;
1702 }
1703 }
1704 } else {
1705 let next = AtomicUsize::new(1);
1706 let first_found = AtomicUsize::new(usize::MAX);
1707 let try_at = &try_at;
1708 let per_worker: Vec<Vec<Tried<T>>> = std::thread::scope(|scope| {
1709 let handles: Vec<_> = (0..n_threads.min(n - 1))
1710 .map(|_| {
1711 let (next, first_found) = (&next, &first_found);
1712 scope.spawn(move || {
1713 let mut done = Vec::new();
1714 loop {
1715 let idx = next.fetch_add(1, Ordering::Relaxed);
1716 if idx >= n || idx > first_found.load(Ordering::Relaxed) {
1717 break;
1718 }
1719 let (d, o) = try_at(idx);
1720 if o.is_some() {
1721 first_found.fetch_min(idx, Ordering::Relaxed);
1722 }
1723 done.push((idx, d, o));
1724 }
1725 done
1726 })
1727 })
1728 .collect();
1729 handles
1730 .into_iter()
1731 .map(|h| h.join().unwrap_or_else(|e| std::panic::resume_unwind(e)))
1735 .collect()
1736 });
1737 tried.extend(per_worker.into_iter().flatten());
1738 tried.sort_unstable_by_key(|&(idx, _, _)| idx);
1739 }
1740 }
1741
1742 let mut distances = Vec::new();
1745 for (idx, d, o) in tried {
1746 distances.extend(d);
1747 if let Some(o) = o {
1748 return (distances, Some((idx, o)));
1749 }
1750 }
1751 (distances, None)
1752}
1753
1754#[must_use]
1757pub fn format_ra(ra_rad: f64) -> String {
1758 const TENTHS_PER_DAY: f64 = 24.0 * 36_000.0;
1763 let ra_tenths = ((ra_rad.to_degrees() / 15.0 * 36_000.0)
1764 .round()
1765 .rem_euclid(TENTHS_PER_DAY)) as u64;
1766 let h = ra_tenths / 36_000;
1767 let m = ra_tenths / 600 % 60;
1768 let s = ra_tenths % 600 / 10;
1769 let tenths = ra_tenths % 10;
1770 format!("{h:02}: {m:02} {s:02}.{tenths}")
1771}
1772
1773#[must_use]
1776pub fn format_dec(dec_rad: f64) -> String {
1777 let dec_deg = dec_rad.to_degrees();
1778 let sign = if dec_deg < 0.0 { '-' } else { '+' };
1779 let dec_secs = (dec_deg.abs() * 3600.0).round() as u64;
1780 let dd = dec_secs / 3600;
1781 let dm = dec_secs / 60 % 60;
1782 let ds = dec_secs % 60;
1783 format!("{sign}{dd:02}d {dm:02} {ds:02}")
1784}
1785
1786#[must_use]
1790pub fn format_radec(ra_rad: f64, dec_rad: f64) -> String {
1791 format!("{} {}", format_ra(ra_rad), format_dec(dec_rad))
1792}
1793
1794#[cfg(test)]
1795mod tests {
1796 use super::*;
1797 use crate::math::coords::{ang_sep, standard_equatorial};
1798 use crate::test_support::{
1799 Rng, SkySpec, SkyStar, TempDir, TruthWcs, random_sky, render, write_001_db, write_290_db,
1800 write_1476_db,
1801 };
1802 use crate::types::{ImageBuffer, PlateConstants};
1803 use crate::wcs::output::derive_wcs;
1804 use core::f64::consts::PI;
1805
1806 fn deg(d: f64) -> f64 {
1807 d * PI / 180.0
1808 }
1809
1810 fn make_test_scene(
1811 n_stars: usize,
1812 ra_center: f64,
1813 dec_center: f64,
1814 cdelt_arcsec: f64,
1815 width: usize,
1816 height: usize,
1817 ) -> (ImageBuffer, Vec<(f64, f64)>, PlateConstants) {
1818 let mut data = vec![100.0f32; width * height];
1819 let mut catalog_sky: Vec<(f64, f64)> = Vec::new();
1820 let stars_per_row = (n_stars as f64).sqrt().ceil() as usize;
1821 let spacing = 40.0;
1822 let cx = (width as f64 - 1.0) / 2.0;
1823 let cy = (height as f64 - 1.0) / 2.0;
1824 let a = cdelt_arcsec;
1825 let c = -a * cx;
1826 let e = cdelt_arcsec;
1827 let f_offset = -e * cy;
1828 let plate = PlateConstants {
1829 a,
1830 b: 0.0,
1831 c,
1832 d: 0.0,
1833 e,
1834 f: f_offset,
1835 };
1836 let mut count = 0;
1837 'outer: for row in 0..stars_per_row {
1838 for col in 0..stars_per_row {
1839 if count >= n_stars {
1840 break 'outer;
1841 }
1842 let px = 20.0 + col as f64 * spacing;
1843 let py = 20.0 + row as f64 * spacing;
1844 if px >= width as f64 - 20.0 || py >= height as f64 - 20.0 {
1845 continue;
1846 }
1847 let x_std = a * px + c;
1848 let y_std = e * py + f_offset;
1849 let (ra, dec) = standard_equatorial(ra_center, dec_center, x_std, y_std, 1.0);
1850 catalog_sky.push((ra, dec));
1851 let sigma = 2.0;
1852 let amp = 30000.0f32;
1853 for dy in -8i32..=8 {
1854 for dx in -8i32..=8 {
1855 let x = (px as i32 + dx) as usize;
1856 let y = (py as i32 + dy) as usize;
1857 if x < width && y < height {
1858 let r2 = (dx * dx + dy * dy) as f64 / (2.0 * sigma * sigma);
1859 data[y * width + x] += amp * (-r2).exp() as f32;
1860 }
1861 }
1862 }
1863 count += 1;
1864 }
1865 }
1866 let img = ImageBuffer {
1867 data,
1868 width,
1869 height,
1870 };
1871 (img, catalog_sky, plate)
1872 }
1873
1874 #[test]
1875 fn derive_wcs_recovers_position() {
1876 let ra_center = deg(45.0);
1877 let dec_center = deg(30.0);
1878 let (img, _cat, plate) = make_test_scene(16, ra_center, dec_center, 2.0, 300, 300);
1879 let wcs = derive_wcs(ra_center, dec_center, &plate, img.width, img.height);
1880 let sep_arcsec = ang_sep(wcs.ra0, wcs.dec0, ra_center, dec_center) * (180.0 / PI * 3600.0);
1881 assert!(sep_arcsec < 0.5, "centre offset = {sep_arcsec} arcsec");
1882 }
1883
1884 #[test]
1885 fn the_search_returns_the_serial_result_on_any_number_of_threads() {
1886 let mut rng = crate::test_support::Rng::new(5);
1887 for case in 0..40 {
1888 let n = 1 + (rng.next_u64() % 300) as usize;
1889 let read: Vec<bool> = (0..n).map(|_| rng.uniform() < 0.8).collect();
1891 let hits: Vec<bool> = (0..n)
1892 .map(|_| case % 4 != 0 && rng.uniform() < 0.02)
1893 .collect();
1894 let try_at = |idx: usize| {
1895 let d = read[idx].then_some(idx as f64);
1896 for _ in 0..(idx * 7919) % 5000 {
1898 core::hint::black_box(idx);
1899 }
1900 (d, (read[idx] && hits[idx]).then_some(idx * 10))
1901 };
1902 let want_hit = (0..n).find(|&i| read[i] && hits[i]);
1903 let want_d: Vec<f64> = (0..=want_hit.unwrap_or(n - 1))
1904 .filter(|&i| read[i])
1905 .map(|i| i as f64)
1906 .collect();
1907 for threads in [1, 2, 3, 8] {
1908 let (d, hit) = search_in_order(n, threads, try_at);
1909 assert_eq!(
1910 hit,
1911 want_hit.map(|i| (i, i * 10)),
1912 "case {case}, {threads} threads"
1913 );
1914 assert_eq!(d, want_d, "case {case}, {threads} threads");
1915 }
1916 }
1917 assert_eq!(
1918 search_in_order(0, 4, |_| (Some(1.0), Some(()))),
1919 (vec![], None)
1920 );
1921 }
1922
1923 #[test]
1924 fn spiral_covers_origin_first() {
1925 assert_eq!(
1926 super::super::spiral::SpiralSearch::new(5).next(),
1927 Some((0, 0))
1928 );
1929 assert_eq!(spiral_position(0), (0, 0));
1930 }
1931
1932 #[test]
1933 fn oversize_formula_limits() {
1934 for n in [10, 35, 70, 140, 200] {
1935 let ov: f64 = if n < 35 {
1936 2.0
1937 } else if n > 140 {
1938 1.0
1939 } else {
1940 2.0 * (35.0 / n as f64).sqrt()
1941 };
1942 assert!((1.0..=2.0).contains(&ov), "oversize={ov} for n={n}");
1943 }
1944 }
1945
1946 #[test]
1947 fn format_radec_carries_rounded_seconds() {
1948 let ra = deg((1.0 + 59.0 / 60.0 + 59.97 / 3600.0) * 15.0);
1950 let dec = deg(10.0 + 59.0 / 60.0 + 59.7 / 3600.0);
1952 assert_eq!(format_radec(ra, dec), "02: 00 00.0 +11d 00 00");
1953 let s = format_radec(deg(359.999_999_9), deg(-0.5));
1955 assert_eq!(s, "00: 00 00.0 -00d 30 00");
1956 assert_eq!(
1958 format_radec(deg((5.0 + 35.0 / 60.0 + 17.3 / 3600.0) * 15.0), deg(-5.39)),
1959 "05: 35 17.3 -05d 23 24"
1960 );
1961 }
1962
1963 #[test]
1968 fn ra_and_dec_are_formatted_as_astap_cli_prints_them() {
1969 let ra = deg(65.0); let dec = deg(35.0);
1971 assert_eq!(format_ra(ra), "04: 20 00.0");
1972 assert_eq!(format_dec(dec), "+35d 00 00");
1973 assert_eq!(format_radec(ra, dec), "04: 20 00.0 +35d 00 00");
1974 assert_eq!(
1975 format_radec(
1976 deg((13.0 + 7.0 / 60.0 + 9.25 / 3600.0) * 15.0),
1977 -deg(89.0 + 1.0 / 60.0 + 2.0 / 3600.0)
1978 ),
1979 "13: 07 09.3 -89d 01 02"
1980 );
1981 assert_eq!(format_dec(deg(-0.0001)), "-00d 00 00");
1982 }
1983
1984 #[test]
1985 fn solve_image_rejects_a_non_positive_fov() {
1986 let img = ImageBuffer::new(64, 64);
1987 let params = SolveParams {
1988 ra_hint: 0.0,
1989 dec_hint: 0.0,
1990 fov: 0.0,
1991 search_radius: 0.1,
1992 quad_tolerance: 0.007,
1993 hfd_min: 1.5,
1994 max_stars: 500,
1995 db_path: std::path::PathBuf::from("/nonexistent"),
1996 db_name: "d50".into(),
1997 binning: 1,
1998 method: SolveMethod::Quads,
1999 threads: 1,
2000 speed: SearchSpeed::Auto,
2001 };
2002 assert!(matches!(
2003 solve_image(&img, ¶ms),
2004 Err(ArcsecError::InvalidParameter(_))
2005 ));
2006 }
2007
2008 fn known_plate() -> PlateConstants {
2012 let (s, r) = (3.2_f64, 0.61_f64);
2013 PlateConstants {
2014 a: -s * r.cos(),
2015 b: s * r.sin(),
2016 c: 640.0,
2017 d: s * r.sin(),
2018 e: s * r.cos(),
2019 f: -512.0,
2020 }
2021 }
2022
2023 fn apply(p: &PlateConstants, (x, y): (f64, f64)) -> (f64, f64) {
2024 (p.a * x + p.b * y + p.c, p.d * x + p.e * y + p.f)
2025 }
2026
2027 fn plate_close(p: &PlateConstants, q: &PlateConstants, tol: f64) -> bool {
2028 [
2029 (p.a, q.a),
2030 (p.b, q.b),
2031 (p.c, q.c),
2032 (p.d, q.d),
2033 (p.e, q.e),
2034 (p.f, q.f),
2035 ]
2036 .iter()
2037 .all(|(u, v)| (u - v).abs() <= tol)
2038 }
2039
2040 const STRICT: Acceptance = Acceptance {
2043 min_stars: MIN_VERIFIED_STARS,
2044 expected_scale: 3.2,
2045 };
2046
2047 fn star_at(x: f64, y: f64) -> Star {
2048 Star {
2049 x,
2050 y,
2051 snr: 50.0,
2052 hfd: 2.5,
2053 }
2054 }
2055
2056 fn pairs_with_outliers(outlier: impl Fn(usize, (f64, f64)) -> (f64, f64)) -> PairedPositions {
2059 let plate = known_plate();
2060 let mut rng = Rng::new(7);
2061 let mut img = Vec::new();
2062 let mut cat = Vec::new();
2063 for _ in 0..40 {
2064 let p = (rng.range(0.0, 500.0), rng.range(0.0, 500.0));
2065 img.push(p);
2066 cat.push(apply(&plate, p));
2067 }
2068 for k in 0..5 {
2069 let p = (rng.range(0.0, 500.0), rng.range(0.0, 500.0));
2070 img.push(p);
2071 cat.push(outlier(k, apply(&plate, p)));
2072 }
2073 (img, cat)
2074 }
2075
2076 #[test]
2077 fn sigma_clip_pairs_rejects_outliers_and_keeps_the_rest() {
2078 let (img, cat) = pairs_with_outliers(|k, (x, y)| {
2080 let a = k as f64 * 1.3;
2081 (x + 100.0 * a.cos(), y + 100.0 * a.sin())
2082 });
2083 let (ci, cc) = sigma_clip_pairs(img, cat, 3.0, 3);
2084 assert_eq!(ci.len(), 40, "all and only the true pairs survive");
2085 let fit = solve_plate_constants(&ci, &cc).unwrap();
2086 assert!(plate_close(&fit, &known_plate(), 1e-6), "{fit:?}");
2087 }
2088
2089 #[test]
2097 fn sigma_clip_pairs_rejects_gross_outliers() {
2098 let (img, cat) =
2099 pairs_with_outliers(|k, _| (1000.0 + 150.0 * k as f64, -900.0 + 70.0 * k as f64));
2100 assert!(matches!(
2101 solve_plate_constants(&img, &cat),
2102 Err(ArcsecError::BadSolution { .. })
2103 ));
2104 let (ci, _) = sigma_clip_pairs(img, cat, 3.0, 3);
2105 assert_eq!(ci.len(), 40, "the five gross outliers should be clipped");
2106 }
2107
2108 #[test]
2112 fn fit_pattern_pairs_recovers_a_plate_the_plain_fit_refuses() {
2113 let (img, cat) =
2114 pairs_with_outliers(|k, _| (1000.0 + 150.0 * k as f64, -900.0 + 70.0 * k as f64));
2115 assert!(solve_plate_constants(&img, &cat).is_err());
2116 let (plate, n) = fit_pattern_pairs(img.clone(), cat.clone(), 3).expect("clipped fit");
2117 assert_eq!(n, 40);
2118 assert!(plate_close(&plate, &known_plate(), 1e-6), "{plate:?}");
2119 let (plate, n) = fit_pattern_pairs(img[..40].to_vec(), cat[..40].to_vec(), 3).unwrap();
2121 assert_eq!(n, 40);
2122 assert!(plate_close(&plate, &known_plate(), 1e-6));
2123 assert!(fit_pattern_pairs(img, cat, 41).is_none());
2125 }
2126
2127 #[test]
2128 fn sigma_clip_pairs_leaves_too_few_pairs_alone() {
2129 let img = vec![(0.0, 0.0), (1.0, 0.0)];
2130 let cat = vec![(5.0, 5.0), (9.0, 9.0)];
2131 let (ci, cc) = sigma_clip_pairs(img.clone(), cat.clone(), 3.0, 3);
2132 assert_eq!((ci, cc), (img, cat));
2133 }
2134
2135 #[test]
2136 fn verify_and_refit_recovers_the_plate_from_a_rough_guess() {
2137 let truth = known_plate();
2138 let mut rng = Rng::new(11);
2139 let mut img_stars = Vec::new();
2140 let mut cat_stars = Vec::new();
2141 for _ in 0..60 {
2142 let (x, y) = (rng.range(5.0, 395.0), rng.range(5.0, 295.0));
2143 img_stars.push(star_at(x, y));
2144 let (cx, cy) = apply(&truth, (x, y));
2145 cat_stars.push(star_at(cx, cy));
2146 }
2147 for k in 0..20 {
2149 let (cx, cy) = apply(&truth, (-300.0 - 10.0 * k as f64, 900.0));
2150 cat_stars.push(star_at(cx, cy));
2151 }
2152 let mut rough = truth.clone();
2154 rough.c += 2.0 * truth.a;
2155 rough.f += 2.0 * truth.e;
2156 rough.b += 0.01;
2157 let v = verify_and_refit(
2158 &StarList(img_stars),
2159 &StarList(cat_stars),
2160 &rough,
2161 400,
2162 300,
2163 &STRICT,
2164 )
2165 .expect("a correct plate must verify");
2166 assert_eq!(v.n(), 60);
2167 assert_eq!(v.cat_pos.len(), 60);
2168 assert!(v.rms < 1e-6, "rms {}", v.rms);
2169 assert!(plate_close(&v.plate, &truth, 1e-6), "{:?}", v.plate);
2170 for (&(x, y), &(cx, cy)) in v.img_pos.iter().zip(&v.cat_pos) {
2172 let (px, py) = apply(&truth, (x, y));
2173 assert!((px - cx).hypot(py - cy) < 1e-6);
2174 }
2175 }
2176
2177 #[test]
2178 fn verify_and_refit_rejects_too_few_or_clustered_matches() {
2179 let truth = known_plate();
2180 let mut rng = Rng::new(12);
2181 let build = |pts: &[(f64, f64)]| {
2182 let img = StarList(pts.iter().map(|&(x, y)| star_at(x, y)).collect());
2183 let cat = StarList(
2184 pts.iter()
2185 .map(|&p| apply(&truth, p))
2186 .map(|(x, y)| star_at(x, y))
2187 .collect(),
2188 );
2189 (img, cat)
2190 };
2191
2192 let few: Vec<_> = (0..20)
2194 .map(|_| (rng.range(0.0, 400.0), rng.range(0.0, 300.0)))
2195 .collect();
2196 let (img, cat) = build(&few);
2197 assert!(verify_and_refit(&img, &cat, &truth, 400, 300, &STRICT).is_none());
2198
2199 let clustered: Vec<_> = (0..80)
2201 .map(|_| (rng.range(0.0, 40.0), rng.range(0.0, 40.0)))
2202 .collect();
2203 let (img, cat) = build(&clustered);
2204 assert!(verify_and_refit(&img, &cat, &truth, 400, 300, &STRICT).is_none());
2205
2206 let spread: Vec<_> = (0..80)
2208 .map(|_| (rng.range(0.0, 400.0), rng.range(0.0, 300.0)))
2209 .collect();
2210 let (img, cat) = build(&spread);
2211 assert!(verify_and_refit(&img, &cat, &truth, 400, 300, &STRICT).is_some());
2212
2213 let empty = StarList::default();
2215 assert!(verify_and_refit(&empty, &cat, &truth, 400, 300, &STRICT).is_none());
2216 let mut singular = truth.clone();
2217 singular.a = 0.0;
2218 singular.b = 0.0;
2219 assert!(verify_and_refit(&img, &cat, &singular, 400, 300, &STRICT).is_none());
2220 }
2221
2222 #[derive(Clone, Copy)]
2225 enum Db {
2226 Areas1476,
2227 Areas290,
2228 AllSky001,
2229 }
2230
2231 struct Scene {
2233 dir: TempDir,
2234 img: ImageBuffer,
2235 truth: TruthWcs,
2236 sky: Vec<SkyStar>,
2238 }
2239
2240 fn scene(truth: TruthWcs, db: Db, n_in_frame: usize, seed: u64) -> Scene {
2243 let mut rng = Rng::new(seed);
2244 let scale_deg = truth.cd[1].hypot(truth.cd[3]);
2245 let (w_deg, h_deg) = (
2246 truth.width as f64 * scale_deg,
2247 truth.height as f64 * scale_deg,
2248 );
2249 let side = 6.0 * w_deg.max(h_deg);
2250 let sky = random_sky(
2251 &mut rng,
2252 &SkySpec {
2253 ra0: truth.ra0,
2254 dec0: truth.dec0,
2255 side_deg: side,
2256 n: (n_in_frame as f64 * side * side / (w_deg * h_deg)) as usize,
2257 min_sep_deg: 12.0 * scale_deg,
2258 mag_lo: 10.0,
2259 mag_hi: 14.5,
2260 },
2261 );
2262 let sigma = 1.3 * 5.0 / (scale_deg * 3600.0);
2264 let img = render(
2265 &truth,
2266 &sky,
2267 sigma.max(1.3),
2268 1000.0,
2269 8.0,
2270 30_000.0,
2271 &mut rng,
2272 );
2273 let dir = TempDir::new("solve");
2274 match db {
2275 Db::Areas1476 => write_1476_db(dir.path(), "t50", &sky),
2276 Db::Areas290 => write_290_db(dir.path(), "t50", &sky),
2277 Db::AllSky001 => write_001_db(dir.path(), "t50", &sky),
2278 }
2279 Scene {
2280 dir,
2281 img,
2282 truth,
2283 sky,
2284 }
2285 }
2286
2287 fn params_for_blank() -> SolveParams {
2289 SolveParams {
2290 ra_hint: 0.0,
2291 dec_hint: 0.0,
2292 fov: deg(1.0),
2293 search_radius: 0.0,
2294 quad_tolerance: 0.007,
2295 hfd_min: 1.5,
2296 max_stars: 500,
2297 db_path: std::path::PathBuf::from("/nonexistent"),
2298 db_name: "d50".into(),
2299 binning: 1,
2300 method: SolveMethod::Quads,
2301 threads: 1,
2302 speed: SearchSpeed::Auto,
2303 }
2304 }
2305
2306 fn params_for(s: &Scene, ra_hint: f64, dec_hint: f64) -> SolveParams {
2307 SolveParams {
2308 ra_hint,
2309 dec_hint,
2310 fov: (s.truth.height as f64 * s.truth.cd[1].hypot(s.truth.cd[3])).to_radians(),
2311 search_radius: deg(2.0),
2312 quad_tolerance: 0.007,
2313 hfd_min: 1.5,
2314 max_stars: 500,
2315 db_path: s.dir.path().to_path_buf(),
2316 db_name: "t50".into(),
2317 binning: 1,
2318 method: SolveMethod::Quads,
2319 threads: 1,
2320 speed: SearchSpeed::Auto,
2321 }
2322 }
2323
2324 fn assert_solved(s: &Scene, wcs: &WcsSolution, tol_arcsec: f64) {
2325 let err = s.truth.max_error_arcsec(wcs);
2326 assert!(
2327 err < tol_arcsec,
2328 "worst centre/corner error {err:.3}\" (matched {}, rms {:.3})",
2329 wcs.stars_matched,
2330 wcs.residual_rms
2331 );
2332 assert!(wcs.stars_matched >= 10);
2333 let scale_arcsec = s.truth.cd[1].hypot(s.truth.cd[3]) * 3600.0;
2335 assert!(
2336 wcs.residual_rms < 0.3 * scale_arcsec,
2337 "rms {}",
2338 wcs.residual_rms
2339 );
2340 assert!(wcs.raw_matches > 0);
2341 assert_matches_agree(wcs, 0.3, 1.0);
2342 assert!(wcs.mag_limit > 10.0 && wcs.mag_limit <= 14.5);
2343 let truth_det = s.truth.cd[0] * s.truth.cd[3] - s.truth.cd[1] * s.truth.cd[2];
2346 assert!(
2347 (wcs.cdelt1 > 0.0) == (truth_det > 0.0) && wcs.cdelt2 > 0.0,
2348 "CDELT sign convention"
2349 );
2350 }
2351
2352 fn assert_matches_agree(wcs: &WcsSolution, rms_px: f64, binning: f64) {
2355 assert_eq!(wcs.matched_stars.len(), wcs.stars_matched);
2356 assert!(wcs.sip.is_none(), "solve_image never fits SIP");
2357 let tan = crate::wcs::TanWcs::from(wcs);
2358 let mut sq = 0.0;
2359 for m in &wcs.matched_stars {
2360 let (x, y) = tan.sky_to_pixel(m.ra, m.dec).unwrap();
2361 let d = (x - m.x).hypot(y - m.y);
2362 assert!(
2363 d < VERIFY_RADII[VERIFY_RADII.len() - 1] * binning,
2364 "pair at ({:.2},{:.2}) projects to ({x:.2},{y:.2})",
2365 m.x,
2366 m.y
2367 );
2368 sq += d * d;
2369 }
2370 let rms = (sq / wcs.matched_stars.len() as f64).sqrt();
2371 assert!(rms < rms_px, "pair rms {rms} px");
2372 }
2373
2374 #[test]
2375 fn solves_a_1476_database_from_an_offset_hint() {
2376 let truth = TruthWcs::new(deg(84.3), deg(-5.2), 5.0, 23.0, false, 400, 320);
2377 let s = scene(truth, Db::Areas1476, 130, 1);
2378 let mut p = params_for(&s, deg(84.3 + 0.6), deg(-5.2 - 0.45));
2380 p.threads = 4;
2381 let wcs = solve_image(&s.img, &p).expect("solve");
2382 assert_solved(&s, &wcs, 1.0);
2383 assert!(wcs.search_dist_deg > 0.1, "solved at the hint itself?");
2384 assert!(wcs.step_distances.len() > 1);
2385 assert!((wcs.cdelt2 * 3600.0 - 5.0).abs() < 0.01, "{}", wcs.cdelt2);
2387 assert!((wcs.crota2 + 23.0).abs() < 0.05, "crota2 {}", wcs.crota2);
2391 assert!(
2392 (wcs.crota1() + 23.0).abs() < 0.05,
2393 "crota1 {}",
2394 wcs.crota1()
2395 );
2396 assert!(wcs.cdelt1 < 0.0, "an unmirrored image has CDELT1 < 0");
2397 }
2398
2399 #[test]
2400 fn a_cancelled_token_stops_the_search() {
2401 use crate::cancel::{CancelToken, with_token};
2402 let truth = TruthWcs::new(deg(84.3), deg(-5.2), 5.0, 23.0, false, 400, 320);
2403 let s = scene(truth, Db::Areas1476, 130, 1);
2404 let p = params_for(&s, deg(84.3 + 0.6), deg(-5.2 - 0.45));
2405
2406 let token = CancelToken::new();
2407 token.cancel();
2408 let r = with_token(&token, || solve_image(&s.img, &p));
2409 assert!(matches!(r, Err(ArcsecError::Cancelled)), "{r:?}");
2410
2411 let polls = alloc::sync::Arc::new(core::sync::atomic::AtomicUsize::new(0));
2415 let n = alloc::sync::Arc::clone(&polls);
2416 let token = CancelToken::with_poll(move || {
2417 n.fetch_add(1, core::sync::atomic::Ordering::Relaxed) >= 1
2418 });
2419 let mut p4 = p.clone();
2420 p4.threads = 4;
2421 let r = with_token(&token, || solve_image(&s.img, &p4));
2422 assert!(matches!(r, Err(ArcsecError::Cancelled)), "{r:?}");
2423
2424 let wcs = with_token(&CancelToken::new(), || solve_image(&s.img, &p)).expect("solve");
2426 assert_solved(&s, &wcs, 1.0);
2427 }
2428
2429 #[test]
2430 fn the_auto_plan_solves_a_synthetic_field() {
2431 use crate::auto::{Plan, SolveRequest};
2432 let truth = TruthWcs::new(deg(84.3), deg(-5.2), 5.0, 23.0, false, 400, 320);
2433 let s = scene(truth, Db::Areas1476, 130, 1);
2434 let req = SolveRequest {
2435 hint: Some((deg(84.3 + 0.3), deg(-5.2))),
2436 pixel_scale: Some(5.0),
2437 search_radius: deg(2.0),
2438 db_path: Some(s.dir.path().to_path_buf()),
2439 db_name: Some("t50".into()),
2440 threads: 2,
2441 sip: true,
2442 ..SolveRequest::default()
2443 };
2444 let plan = Plan::new(&req, s.img.width, s.img.height).unwrap();
2445 assert_eq!(plan.binning, 1);
2446 let solved = plan.solve(&s.img).expect("solve");
2447 let mut wcs = solved.wcs;
2448 wcs.sip = None;
2450 assert_solved(&s, &wcs, 1.0);
2451 assert!(solved.index_estimate.is_none());
2452 }
2453
2454 #[test]
2455 fn solves_a_mirrored_image_on_a_290_database() {
2456 let truth = TruthWcs::new(deg(201.0), deg(47.5), 6.0, 160.0, true, 360, 360);
2457 let s = scene(truth, Db::Areas290, 120, 2);
2458 let wcs = solve_image(&s.img, ¶ms_for(&s, truth.ra0, truth.dec0)).expect("solve");
2459 assert_solved(&s, &wcs, 1.0);
2460 assert!(wcs.search_dist_deg < 1e-9, "should solve at the hint");
2461 assert!(wcs.cd1_1 * wcs.cd2_2 - wcs.cd1_2 * wcs.cd2_1 > 0.0);
2463 }
2464
2465 #[test]
2466 fn solves_across_ra_zero_with_an_all_sky_001_database() {
2467 let truth = TruthWcs::new(deg(0.05), deg(21.0), 5.0, -70.0, false, 360, 300);
2469 let s = scene(truth, Db::AllSky001, 120, 3);
2470 let wcs = solve_image(&s.img, ¶ms_for(&s, truth.ra0, truth.dec0)).expect("solve");
2471 assert_solved(&s, &wcs, 1.0);
2472 }
2473
2474 #[test]
2475 fn solves_across_ra_zero_with_a_1476_database() {
2476 let truth = TruthWcs::new(deg(359.97), deg(-33.0), 5.0, 95.0, false, 360, 300);
2477 let s = scene(truth, Db::Areas1476, 120, 4);
2478 let wcs = solve_image(&s.img, ¶ms_for(&s, truth.ra0, truth.dec0)).expect("solve");
2479 assert_solved(&s, &wcs, 1.0);
2480 }
2481
2482 #[test]
2483 fn solves_a_field_near_the_celestial_pole() {
2484 let truth = TruthWcs::new(deg(40.0), deg(88.9), 5.0, 10.0, false, 360, 300);
2485 let s = scene(truth, Db::Areas1476, 120, 5);
2486 let wcs = solve_image(&s.img, ¶ms_for(&s, truth.ra0, truth.dec0)).expect("solve");
2487 assert_solved(&s, &wcs, 1.0);
2488 }
2489
2490 #[test]
2504 fn accuracy_does_not_depend_on_the_hint_offset() {
2505 let truth = TruthWcs::new(deg(150.0), deg(30.0), 15.0, 20.0, false, 360, 300);
2506 let s = scene(truth, Db::Areas1476, 120, 21);
2507 let off = 0.4;
2508 let p = params_for(&s, deg(150.0 + off / deg(30.0).cos()), deg(30.0 + off));
2509 let wcs = solve_image(&s.img, &p).expect("solve");
2510 assert!(wcs.search_dist_deg < 1e-9, "solved at the hint");
2511 let err = s.truth.max_error_arcsec(&wcs);
2512 assert!(
2513 err < 5.0,
2514 "worst corner error {err:.2}\" with a {off}° hint offset"
2515 );
2516 }
2517
2518 #[test]
2519 fn solves_with_the_tetra_method() {
2520 let truth = TruthWcs::new(deg(150.0), deg(2.0), 5.0, 45.0, false, 360, 300);
2521 let s = scene(truth, Db::Areas1476, 110, 6);
2522 let mut p = params_for(&s, truth.ra0, truth.dec0);
2523 p.method = SolveMethod::Tetra;
2524 let wcs = solve_image(&s.img, &p).expect("solve");
2525 assert_solved(&s, &wcs, 1.0);
2526 }
2527
2528 #[test]
2529 fn slow_speed_solves_from_an_offset_hint() {
2530 let truth = TruthWcs::new(deg(84.3), deg(-5.2), 5.0, 23.0, false, 400, 320);
2531 let s = scene(truth, Db::Areas1476, 130, 1);
2532 let mut p = params_for(&s, deg(84.3 + 0.6), deg(-5.2 - 0.45));
2533 p.speed = SearchSpeed::Slow;
2534 let wcs = solve_image(&s.img, &p).expect("solve");
2535 assert_solved(&s, &wcs, 1.0);
2536 }
2537
2538 #[test]
2539 fn binned_solve_is_reported_on_the_unbinned_pixel_grid() {
2540 let truth = TruthWcs::new(deg(10.0), deg(40.0), 2.5, 30.0, false, 720, 600);
2542 let s = scene(truth, Db::Areas1476, 120, 7);
2543 let binned = s.img.bin_image(2);
2544 assert_eq!((binned.width, binned.height), (360, 300));
2545 let mut p = params_for(&s, truth.ra0, truth.dec0);
2546 p.binning = 2;
2547 let wcs = solve_image(&binned, &p).expect("solve");
2548 assert!((wcs.crpix1 - 360.5).abs() < 1e-9, "crpix1 {}", wcs.crpix1);
2550 assert!((wcs.crpix2 - 300.5).abs() < 1e-9, "crpix2 {}", wcs.crpix2);
2551 assert!((wcs.cdelt2 * 3600.0 - 2.5).abs() < 0.01, "{}", wcs.cdelt2);
2552 let err = s.truth.max_error_arcsec(&wcs);
2553 assert!(err < 2.0, "worst corner error {err:.3}\"");
2554 assert_matches_agree(&wcs, 0.6, 2.0);
2556 }
2557
2558 #[test]
2559 fn the_star_limit_is_the_database_density_times_the_field_area() {
2560 let params = |fov_deg: f64, db: &str, max_stars: usize| SolveParams {
2561 fov: deg(fov_deg),
2562 max_stars,
2563 db_name: db.into(),
2564 ..params_for_blank()
2565 };
2566 let square = ImageBuffer::new(200, 200);
2567 let wide = ImageBuffer::new(400, 200);
2568 assert_eq!(density_star_limit(¶ms(0.2, "d80", 500), &square), 320);
2570 assert_eq!(density_star_limit(¶ms(0.2, "d80", 500), &wide), 160);
2572 assert_eq!(density_star_limit(¶ms(1.0, "d80", 500), &square), 500);
2574 assert_eq!(density_star_limit(¶ms(0.2, "d80", 100), &square), 100);
2575 assert_eq!(density_star_limit(¶ms(0.8, "g05", 500), &square), 320);
2577 assert_eq!(density_star_limit(¶ms(20.0, "w08", 500), &wide), 200);
2578 assert_eq!(density_star_limit(¶ms(0.1, "v17", 500), &square), 500);
2580 }
2581
2582 #[test]
2588 fn a_frame_deeper_than_the_database_solves_at_the_database_limit() {
2589 let truth = TruthWcs::new(deg(250.0), deg(36.0), 3.0, 12.0, false, 600, 500);
2591 let s = scene(truth, Db::Areas1476, 450, 31);
2592 let mut sky = s.sky.clone();
2595 sky.sort_by(|a, b| a.mag.total_cmp(&b.mag));
2596 sky.truncate(200 * 9);
2597 write_1476_db(s.dir.path(), "t02", &sky);
2598 write_1476_db(s.dir.path(), "t17", &sky);
2600
2601 let mut p = params_for(&s, truth.ra0, truth.dec0);
2602 p.fov = (600.0 * 3.0 / 3600.0_f64).to_radians();
2603 p.search_radius = 0.0;
2604 p.db_name = "t17".into();
2605 let wcs = solve_image(&s.img, &p).expect("every detection: the fallback solves");
2606 assert!(s.truth.max_error_arcsec(&wcs) < 2.0);
2607 p.db_name = "t02".into();
2608 let wcs = solve_image(&s.img, &p).expect("solve at the database limit");
2609 assert!(s.truth.max_error_arcsec(&wcs) < 2.0);
2610 }
2611
2612 #[test]
2613 fn min_verified_stars_relaxes_only_for_sparse_images() {
2614 for (n, want) in [
2615 (0, 10),
2616 (5, 10),
2617 (66, 10),
2618 (67, 11),
2619 (100, 15),
2620 (193, 29),
2621 (194, 30),
2622 (200, 30),
2623 (500, 30),
2624 (usize::MAX, 30),
2625 ] {
2626 assert_eq!(min_verified_stars(n), want, "{n} detections");
2627 }
2628 }
2629
2630 #[test]
2632 fn a_sparse_match_must_have_the_expected_scale_and_a_tight_fit() {
2633 let truth = known_plate(); let verified = |n: usize, rms_px: f64, scale: f64| {
2635 let mut plate = truth.clone();
2636 for c in [&mut plate.a, &mut plate.b, &mut plate.d, &mut plate.e] {
2637 *c *= scale;
2638 }
2639 Verified {
2640 plate,
2641 rms: rms_px * 3.2 * scale,
2642 img_pos: vec![(0.0, 0.0); n],
2643 cat_pos: vec![(0.0, 0.0); n],
2644 chance: 0.0,
2645 }
2646 };
2647 let accept = Acceptance {
2648 min_stars: 12,
2649 expected_scale: 3.2,
2650 };
2651 assert!(accept.accepts(&verified(30, 1.9, 1.36), 0.5));
2654 assert!(!accept.accepts(&verified(30, 2.1, 1.0), 0.5));
2655 assert!(accept.accepts(&verified(12, 0.3, 1.0), 0.5));
2657 assert!(accept.accepts(&verified(20, 0.49, 1.09), 0.5));
2658 assert!(accept.accepts(&verified(20, 0.49, 0.91), 0.5));
2659 assert!(!accept.accepts(&verified(20, 0.3, 1.11), 0.5));
2661 assert!(!accept.accepts(&verified(20, 0.3, 0.89), 0.5));
2662 assert!(!accept.accepts(&verified(29, 0.51, 1.0), 0.5));
2663 assert!(!accept.accepts(&verified(11, 0.1, 1.0), 0.5));
2664 assert!(!accept.accepts(&verified(20, 0.1, 1.0), 0.1));
2665 assert!(!accept.accepts(&verified(12, 2.9, 1.36), 0.5));
2667 }
2668
2669 #[test]
2673 fn a_sparse_frame_solves_at_the_hint_scale_only() {
2674 let truth = TruthWcs::new(deg(30.0), deg(-12.0), 5.0, 40.0, false, 360, 300);
2675 let s = scene(truth, Db::Areas1476, 150, 41);
2676 let mut bright = s.sky.clone();
2677 bright.sort_by(|a, b| a.mag.total_cmp(&b.mag));
2678 let bright: Vec<SkyStar> = bright
2679 .into_iter()
2680 .filter(|st| {
2681 s.truth
2682 .sky_to_pixel(st.ra, st.dec)
2683 .is_some_and(|(x, y)| (5.0..355.0).contains(&x) && (5.0..295.0).contains(&y))
2684 })
2685 .take(22)
2686 .collect();
2687 let mut rng = Rng::new(42);
2688 let img = render(&s.truth, &bright, 1.3, 1000.0, 8.0, 30_000.0, &mut rng);
2689 let mut p = params_for(&s, truth.ra0, truth.dec0);
2690 p.fov = (360.0 * 5.0 / 3600.0_f64).to_radians(); p.search_radius = 0.0;
2692 let wcs = solve_image(&img, &p).expect("sparse solve");
2693 assert!(
2694 wcs.stars_matched < MIN_VERIFIED_STARS,
2695 "{}",
2696 wcs.stars_matched
2697 );
2698 assert!(s.truth.max_error_arcsec(&wcs) < 2.0);
2699
2700 p.fov *= 1.2;
2701 assert!(matches!(
2702 solve_image(&img, &p),
2703 Err(ArcsecError::InsufficientQuads { .. })
2704 ));
2705 }
2706
2707 #[test]
2712 fn a_shallow_frame_matches_the_density_matched_catalogue_quads() {
2713 let truth = TruthWcs::new(deg(140.0), deg(55.0), 5.0, -25.0, true, 360, 300);
2714 let s = scene(truth, Db::Areas1476, 500, 51);
2715 let mut bright = s.sky.clone();
2716 bright.sort_by(|a, b| a.mag.total_cmp(&b.mag));
2717 let in_frame = |st: &SkyStar| {
2718 s.truth
2719 .sky_to_pixel(st.ra, st.dec)
2720 .is_some_and(|(x, y)| (0.0..360.0).contains(&x) && (0.0..300.0).contains(&y))
2721 };
2722 let n_frame = bright.iter().filter(|st| in_frame(st)).count();
2723 bright.truncate(bright.len() * 40 / n_frame.max(1));
2724 let mut rng = Rng::new(52);
2725 let img = render(&s.truth, &bright, 1.3, 1000.0, 8.0, 30_000.0, &mut rng);
2726 let mut p = params_for(&s, truth.ra0, truth.dec0);
2727 p.fov = (360.0 * 5.0 / 3600.0_f64).to_radians();
2728 p.search_radius = 0.0;
2729 let wcs = solve_image(&img, &p).expect("shallow solve");
2730 assert!(s.truth.max_error_arcsec(&wcs) < 2.0);
2731 }
2732
2733 fn fallback_only(img: &ImageBuffer, p: &SolveParams) -> Option<WcsSolution> {
2736 let bg = get_background(img, p.max_stars);
2737 let (stars, _, deep) =
2738 find_stars_and_deep(img, &bg, p.hfd_min, p.max_stars, SEEDED_MAX_STARS);
2739 let n = stars.len();
2740 let oversize = if n < 35 {
2741 2.0
2742 } else if n > 140 {
2743 1.0
2744 } else {
2745 2.0 * (35.0 / n as f64).sqrt()
2746 };
2747 let (quads, tris) = (crate::types::QuadList::default(), Default::default());
2748 let grid = QuadGrid::build(&quads, p.quad_tolerance);
2749 let ctx = SpiralCtx {
2750 params: p,
2751 img,
2752 stars: &stars,
2753 img_quads: &quads,
2754 img_grid: &grid,
2755 img_tris: &tris,
2756 nrstars_image: n,
2757 star_limit: p.max_stars,
2758 nrstars_required: (p.max_stars as f64 * oversize * oversize).round() as usize,
2759 oversize,
2760 min_quads: 3 + n / 140,
2761 step_size: p.fov,
2762 accept: Acceptance::new(n, p, img),
2763 aspect: img.width.max(img.height) as f64 / img.width.min(img.height) as f64,
2764 cancel: None,
2765 };
2766 let o = seeded_fallback(&ctx, &deep)?;
2767 assert!(!o.refused);
2768 Some(derive_wcs(
2769 o.ra_db,
2770 o.dec_db,
2771 &o.verified.plate,
2772 img.width,
2773 img.height,
2774 ))
2775 }
2776
2777 #[test]
2780 fn the_seeded_fallback_solves_a_field_on_its_own() {
2781 for (mirrored, seed) in [(false, 71), (true, 72)] {
2782 let truth = TruthWcs::new(deg(201.0), deg(-43.0), 4.0, 61.0, mirrored, 800, 600);
2783 let s = scene(truth, Db::Areas1476, 400, seed);
2784 let fov = 800.0 * 4.0 / 3600.0;
2786 let mut p = params_for(&s, truth.ra0 + deg(0.3 * fov), truth.dec0 - deg(0.2 * fov));
2787 p.fov = deg(fov);
2788 let wcs = fallback_only(&s.img, &p).expect("the fallback solves");
2789 assert!(
2790 s.truth.max_error_arcsec(&wcs) < 2.0,
2791 "mirrored {mirrored}: {:.2}\"",
2792 s.truth.max_error_arcsec(&wcs)
2793 );
2794 }
2795 }
2796
2797 #[test]
2800 fn the_seeded_fallback_does_not_invent_a_field() {
2801 let truth = TruthWcs::new(deg(201.0), deg(-43.0), 4.0, 61.0, false, 800, 600);
2802 let s = scene(truth, Db::Areas1476, 400, 73);
2803 let mut p = params_for(&s, truth.ra0, truth.dec0 + deg(2.0));
2805 p.fov = deg(800.0 * 4.0 / 3600.0);
2806 assert!(fallback_only(&s.img, &p).is_none());
2807 }
2808
2809 #[test]
2812 fn a_verification_no_better_than_chance_is_refused() {
2813 let plate = known_plate();
2814 let v = |n: usize, chance: f64, rms_px: f64| Verified {
2815 plate: plate.clone(),
2816 rms: rms_px * 3.2,
2817 img_pos: vec![(0.0, 0.0); n],
2818 cat_pos: vec![(0.0, 0.0); n],
2819 chance,
2820 };
2821 assert!(!significant(&v(31, 17.8, 1.3)));
2823 assert!(!significant(&v(32, 14.7, 1.3)));
2824 assert!(significant(&v(121, 15.3, 0.65)));
2826 assert!(!significant(&v(30, 3.1, 4.4)));
2828 assert!(significant(&v(30, 0.0, 1.9)));
2830 }
2831
2832 fn distorted_scene(corner_px: f64, seed: u64) -> Scene {
2835 let truth = TruthWcs::new(deg(84.3), deg(-5.2), 10.0, 23.0, false, 1024, 768)
2836 .with_corner_distortion(corner_px);
2837 scene(truth, Db::Areas1476, 300, seed)
2838 }
2839
2840 fn sip_error_arcsec(s: &Scene, wcs: &WcsSolution) -> f64 {
2843 let tan = crate::wcs::TanWcs::from(wcs);
2844 let (w, h) = (s.truth.width as f64 - 1.0, s.truth.height as f64 - 1.0);
2845 let mut worst: f64 = 0.0;
2846 for (fx, fy) in [
2847 (0.5, 0.5),
2848 (0.0, 0.0),
2849 (1.0, 0.0),
2850 (0.0, 1.0),
2851 (1.0, 1.0),
2852 (0.5, 0.0),
2853 (0.0, 0.5),
2854 ] {
2855 let (x, y) = (w * fx, h * fy);
2856 let (ra_t, dec_t) = s.truth.pixel_to_sky(x, y);
2857 let (ra_s, dec_s) = tan.pixel_to_sky(x + 1.0, y + 1.0);
2858 let sep = crate::test_support::separation(ra_t, dec_t, ra_s, dec_s);
2859 worst = worst.max(sep.to_degrees() * 3600.0);
2860 }
2861 worst
2862 }
2863
2864 #[test]
2865 fn a_distorted_field_reports_the_best_linear_plate_over_the_frame() {
2866 for (hint_ra, hint_dec) in [(84.3, -5.2), (84.3 + 0.9, -5.2 - 0.7)] {
2871 let s = distorted_scene(30.0, 7);
2872 let wcs =
2873 solve_image(&s.img, ¶ms_for(&s, deg(hint_ra), deg(hint_dec))).expect("solve");
2874 let floor = s.truth.linear_floor_arcsec();
2875 let err = s.truth.max_error_arcsec(&wcs);
2876 assert!(floor > 80.0, "floor {floor:.1}\"");
2877 assert!(
2878 err < floor + 5.0,
2879 "corner error {err:.1}\" against a linear floor of {floor:.1}\""
2880 );
2881 assert!(wcs.sip.is_none(), "solve_image never fits SIP");
2882 assert!(wcs.stars_matched > 150, "{} stars", wcs.stars_matched);
2884 let mut with_sip = wcs.clone();
2885 with_sip.sip = crate::wcs::fit_sip(&wcs, 1024, 768);
2886 assert!(with_sip.sip.is_some(), "the distortion is significant");
2887 let sip_err = sip_error_arcsec(&s, &with_sip);
2888 assert!(sip_err < 3.0, "SIP error {sip_err:.2}\"");
2889 }
2890 }
2891
2892 fn part_empty_scene(corner_px: f64) -> Scene {
2895 let mut s = distorted_scene(corner_px, 7);
2896 let w = s.img.width;
2897 for y in 0..s.img.height {
2898 for x in (2 * w / 3)..w {
2899 s.img.data[y * w + x] = 1000.0;
2900 }
2901 }
2902 s
2903 }
2904
2905 #[test]
2906 fn strong_distortion_that_cannot_be_modelled_over_the_frame_is_refused() {
2907 let s = part_empty_scene(30.0);
2912 let r = solve_image(&s.img, ¶ms_for(&s, deg(84.3), deg(-5.2)));
2913 assert!(
2914 matches!(r, Err(ArcsecError::InsufficientQuads { .. })),
2915 "{:?}",
2916 r.map(|w| s.truth.max_error_arcsec(&w))
2917 );
2918 }
2919
2920 #[test]
2921 fn an_undistorted_field_with_an_empty_third_still_solves() {
2922 let s = part_empty_scene(0.0);
2923 let wcs = solve_image(&s.img, ¶ms_for(&s, deg(84.3), deg(-5.2))).expect("solve");
2924 assert_solved(&s, &wcs, 1.0);
2925 }
2926
2927 #[test]
2928 fn mild_distortion_is_modelled_too() {
2929 let s = distorted_scene(3.0, 11);
2932 let wcs = solve_image(&s.img, ¶ms_for(&s, deg(84.3), deg(-5.2))).expect("solve");
2933 let floor = s.truth.linear_floor_arcsec();
2934 let err = s.truth.max_error_arcsec(&wcs);
2935 assert!(
2936 err < floor + 2.0,
2937 "corner error {err:.1}\" against a floor of {floor:.1}\""
2938 );
2939 }
2940
2941 #[test]
2942 fn a_field_absent_from_the_catalogue_does_not_solve() {
2943 let truth = TruthWcs::new(deg(120.0), deg(-40.0), 5.0, 0.0, false, 360, 300);
2946 let s = scene(truth, Db::Areas1476, 120, 8);
2947 let decoy = TempDir::new("decoy");
2948 let mut rng = Rng::new(99);
2949 let other = random_sky(
2950 &mut rng,
2951 &SkySpec {
2952 ra0: truth.ra0,
2953 dec0: truth.dec0,
2954 side_deg: 3.0,
2955 n: 4000,
2956 min_sep_deg: 0.015,
2957 mag_lo: 10.0,
2958 mag_hi: 14.5,
2959 },
2960 );
2961 write_1476_db(decoy.path(), "t50", &other);
2962 let mut p = params_for(&s, truth.ra0, truth.dec0);
2963 p.db_path = decoy.path().to_path_buf();
2964 p.search_radius = deg(0.5);
2965 match solve_image(&s.img, &p) {
2966 Err(ArcsecError::InsufficientQuads { found: 0, required }) => {
2967 assert!(required >= 3);
2968 }
2969 other => panic!("expected InsufficientQuads, got {other:?}"),
2970 }
2971 }
2972
2973 #[test]
2974 fn a_corrupt_catalogue_tile_is_skipped_not_fatal() {
2975 let truth = TruthWcs::new(deg(120.0), deg(-40.0), 5.0, 0.0, false, 360, 300);
2976 let s = scene(truth, Db::Areas1476, 120, 9);
2977 for entry in std::fs::read_dir(s.dir.path()).unwrap() {
2979 let path = entry.unwrap().path();
2980 let mut bytes = std::fs::read(&path).unwrap();
2981 bytes[109] = 7;
2982 std::fs::write(&path, bytes).unwrap();
2983 }
2984 let mut p = params_for(&s, truth.ra0, truth.dec0);
2985 p.search_radius = 0.0;
2986 assert!(matches!(
2987 solve_image(&s.img, &p),
2988 Err(ArcsecError::InsufficientQuads { .. })
2989 ));
2990 }
2991
2992 #[test]
2993 fn a_blank_frame_reports_insufficient_stars() {
2994 let dir = TempDir::new("blank");
2995 write_1476_db(dir.path(), "t50", &[]);
2996 let mut rng = Rng::new(3);
2997 let img = ImageBuffer {
2998 data: (0..200 * 200)
2999 .map(|_| (1000.0 + 5.0 * rng.gauss()) as f32)
3000 .collect(),
3001 width: 200,
3002 height: 200,
3003 };
3004 let p = SolveParams {
3005 ra_hint: 0.0,
3006 dec_hint: 0.0,
3007 fov: deg(0.3),
3008 search_radius: deg(1.0),
3009 quad_tolerance: 0.007,
3010 hfd_min: 1.5,
3011 max_stars: 500,
3012 db_path: dir.path().to_path_buf(),
3013 db_name: "t50".into(),
3014 binning: 1,
3015 method: SolveMethod::Quads,
3016 threads: 1,
3017 speed: SearchSpeed::Auto,
3018 };
3019 match solve_image(&img, &p) {
3020 Err(ArcsecError::InsufficientStars { found, required: 5 }) => assert!(found < 5),
3021 other => panic!("expected InsufficientStars, got {other:?}"),
3022 }
3023 }
3024
3025 #[test]
3026 fn a_missing_database_is_reported_before_any_detection() {
3027 let dir = TempDir::new("nodb");
3028 let p = SolveParams {
3029 ra_hint: 0.0,
3030 dec_hint: 0.0,
3031 fov: deg(1.0),
3032 search_radius: deg(1.0),
3033 quad_tolerance: 0.007,
3034 hfd_min: 1.5,
3035 max_stars: 500,
3036 db_path: dir.path().to_path_buf(),
3037 db_name: "d50".into(),
3038 binning: 1,
3039 method: SolveMethod::Quads,
3040 threads: 1,
3041 speed: SearchSpeed::Auto,
3042 };
3043 match solve_image(&ImageBuffer::new(64, 64), &p) {
3044 Err(ArcsecError::CatalogNotFound(path)) => assert_eq!(path, dir.path()),
3045 other => panic!("expected CatalogNotFound, got {other:?}"),
3046 }
3047 }
3048
3049 #[test]
3050 fn solve_image_rejects_a_bad_search_radius_or_fov() {
3051 let base = SolveParams {
3052 ra_hint: 0.0,
3053 dec_hint: 0.0,
3054 fov: deg(1.0),
3055 search_radius: 0.1,
3056 quad_tolerance: 0.007,
3057 hfd_min: 1.5,
3058 max_stars: 500,
3059 db_path: std::path::PathBuf::from("/nonexistent"),
3060 db_name: "d50".into(),
3061 binning: 1,
3062 method: SolveMethod::Quads,
3063 threads: 1,
3064 speed: SearchSpeed::Auto,
3065 };
3066 let img = ImageBuffer::new(64, 64);
3067 for (fov, radius) in [
3068 (f64::NAN, 0.1),
3069 (-1.0, 0.1),
3070 (f64::INFINITY, 0.1),
3071 (0.01, -0.1),
3072 (0.01, f64::NAN),
3073 (0.01, f64::INFINITY),
3074 (1e-300, core::f64::consts::PI),
3077 (1e-7, core::f64::consts::PI),
3078 ] {
3079 let p = SolveParams {
3080 fov,
3081 search_radius: radius,
3082 ..base.clone()
3083 };
3084 assert!(
3085 matches!(solve_image(&img, &p), Err(ArcsecError::InvalidParameter(_))),
3086 "fov {fov}, radius {radius}"
3087 );
3088 }
3089 for quad_tolerance in [f64::NAN, -0.001, 0.11, 1e141, f64::INFINITY] {
3092 let p = SolveParams {
3093 quad_tolerance,
3094 ..base.clone()
3095 };
3096 assert!(
3097 matches!(solve_image(&img, &p), Err(ArcsecError::InvalidParameter(_))),
3098 "tolerance {quad_tolerance}"
3099 );
3100 }
3101 }
3102
3103 #[test]
3104 fn format_radec_roundtrip() {
3105 let s = format_radec(deg(160.875), deg(-59.524));
3106 assert!(s.contains("10:"), "RA hours: {s}");
3107 assert!(s.contains('-'), "dec sign: {s}");
3108 }
3109}