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
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226pub(crate) enum ScaleTrust {
227 Trusted,
233 Hypothesis,
237}
238
239struct Acceptance {
241 min_stars: usize,
243 expected_scale: f64,
245 trust: ScaleTrust,
247}
248
249impl Acceptance {
250 fn new(
251 nrstars_image: usize,
252 params: &SolveParams,
253 img: &crate::types::ImageBuffer,
254 trust: ScaleTrust,
255 ) -> Self {
256 Self {
257 min_stars: min_verified_stars(nrstars_image),
258 expected_scale: params.fov.to_degrees() * 3600.0
259 / img.width.max(img.height).max(1) as f64,
260 trust,
261 }
262 }
263
264 fn accepts(&self, v: &Verified, spread: f64) -> bool {
270 if v.n() < self.min_stars || spread < MIN_VERIFY_SPREAD {
271 return false;
272 }
273 if !significant(v) {
274 return false;
275 }
276 if v.n() >= MIN_VERIFIED_STARS {
277 return true;
278 }
279 if self.trust == ScaleTrust::Hypothesis {
280 log::info!(
281 "{} stars verified at a hypothetical scale: refused (needs {MIN_VERIFIED_STARS})",
282 v.n()
283 );
284 return false;
285 }
286 let p = &v.plate;
287 let scale = (p.a * p.e - p.b * p.d).abs().sqrt();
288 let ok = (scale / self.expected_scale - 1.0).abs() <= RELAXED_SCALE_TOL
289 && v.rms <= RELAXED_MAX_RMS_PX * scale;
290 log::info!(
291 "{} stars verified, scale {:.4}\"/px against {:.4} expected, residual {:.2} px: {}",
292 v.n(),
293 scale,
294 self.expected_scale,
295 v.rms / scale,
296 if ok { "accepted" } else { "refused" }
297 );
298 ok
299 }
300}
301
302const MIN_SIGNIFICANCE: f64 = 4.0;
313
314fn significant(v: &Verified) -> bool {
319 let p = &v.plate;
320 let scale = (p.a * p.e - p.b * p.d).abs().sqrt();
321 let ok = v.n() as f64 >= MIN_SIGNIFICANCE * v.chance
322 && v.rms <= VERIFY_RADII[VERIFY_RADII.len() - 1] * scale;
323 if !ok {
324 log::info!(
325 "{} stars verified against {:.1} expected by chance, residual {:.2} px: refused",
326 v.n(),
327 v.chance,
328 v.rms / scale.max(f64::MIN_POSITIVE)
329 );
330 }
331 ok
332}
333
334const VERIFY_RADII: [f64; 3] = [6.0, 3.0, 2.0];
336const MIN_VERIFY_SPREAD: f64 = 0.20;
343
344struct Verified {
347 plate: PlateConstants,
348 rms: f64,
349 img_pos: Vec<(f64, f64)>,
351 cat_pos: Vec<(f64, f64)>,
354 chance: f64,
358}
359
360impl Verified {
361 fn n(&self) -> usize {
363 self.img_pos.len()
364 }
365}
366
367fn verify_and_refit(
380 img_stars: &StarList,
381 cat_stars: &StarList,
382 plate: &PlateConstants,
383 img_w: usize,
384 img_h: usize,
385 accept: &Acceptance,
386) -> Option<Verified> {
387 if img_stars.is_empty() || cat_stars.is_empty() {
388 return None;
389 }
390
391 let grid = StarGrid::new(img_stars, VERIFY_RADII[0])?;
393
394 let mut current = plate.clone();
395 let mut best: Option<(Verified, f64)> = None;
397
398 for &radius in &VERIFY_RADII {
399 let det = current.a * current.e - current.b * current.d;
400 if det.abs() < 1e-12 {
401 return None;
402 }
403 let r2 = radius * radius;
404
405 let mut img_pos: Vec<(f64, f64)> = Vec::new();
406 let mut cat_pos: Vec<(f64, f64)> = Vec::new();
407 let mut used = vec![false; img_stars.len()];
408 let mut in_frame = 0usize;
409
410 for cs in &cat_stars.0 {
411 let dx = cs.x - current.c;
413 let dy = cs.y - current.f;
414 let px = (current.e * dx - current.b * dy) / det;
415 let py = (-current.d * dx + current.a * dy) / det;
416 if px >= 0.0 && py >= 0.0 && px < img_w as f64 && py < img_h as f64 {
417 in_frame += 1;
418 }
419 if !grid.near(px, py, radius) {
420 continue;
421 }
422 if let Some(i) = grid.nearest(px, py, r2, &used) {
423 used[i] = true; img_pos.push(grid.pos(i));
425 cat_pos.push((cs.x, cs.y));
426 }
427 }
428
429 if img_pos.len() < 4 {
430 break;
431 }
432 let Ok(refined) = solve_plate_constants(&img_pos, &cat_pos) else {
433 break;
434 };
435 let mut sq = 0.0;
436 for (&(xi, yi), &(xc, yc)) in img_pos.iter().zip(cat_pos.iter()) {
437 let xp = refined.a * xi + refined.b * yi + refined.c;
438 let yp = refined.d * xi + refined.e * yi + refined.f;
439 sq += (xp - xc).powi(2) + (yp - yc).powi(2);
440 }
441 let rms = (sq / img_pos.len() as f64).sqrt();
442 let spread = spread_of(&img_pos, img_w, img_h);
444 log::debug!(
445 "verify: {} stars, spread {:.3}, rms {:.2}\"",
446 img_pos.len(),
447 spread,
448 rms
449 );
450
451 let density = img_stars.len() as f64 / (img_w * img_h).max(1) as f64;
454 let chance = in_frame as f64 * (1.0 - (-density * core::f64::consts::PI * r2).exp());
455
456 current = refined.clone();
457 best = Some((
458 Verified {
459 plate: refined,
460 rms,
461 img_pos,
462 cat_pos,
463 chance,
464 },
465 spread,
466 ));
467 }
468
469 best.filter(|(v, spread)| accept.accepts(v, *spread))
470 .map(|(v, _)| v)
471}
472
473fn density_star_limit(params: &SolveParams, img: &crate::types::ImageBuffer) -> usize {
479 let Some(density) = crate::catalog::database_density(¶ms.db_name) else {
480 return params.max_stars;
481 };
482 let fov_deg = params.fov.to_degrees();
483 let (w, h) = (img.width as f64, img.height as f64);
484 let area = fov_deg * fov_deg * w.min(h) / w.max(h).max(1.0);
485 let cap = (density * area).round();
486 if cap < params.max_stars as f64 {
487 cap as usize
488 } else {
489 params.max_stars
490 }
491}
492
493struct SpiralCtx<'a> {
495 params: &'a SolveParams,
496 img: &'a crate::types::ImageBuffer,
497 stars: &'a StarList,
498 img_quads: &'a crate::types::QuadList,
499 img_grid: &'a QuadGrid,
501 img_tris: &'a crate::quads::TriangleList,
502 nrstars_image: usize,
503 star_limit: usize,
506 nrstars_required: usize,
507 oversize: f64,
508 min_quads: usize,
509 step_size: f64,
510 accept: Acceptance,
511 aspect: f64,
513 cancel: Option<crate::cancel::CancelToken>,
516}
517
518struct PositionOutcome {
520 idx: usize,
521 ra_db: f64,
522 dec_db: f64,
523 sep_deg: f64,
524 verified: Verified,
525 n_matched: usize,
526 n_raw: usize,
527 mag_limit: f64,
528 refused: bool,
531}
532
533struct PositionTry {
537 sep_deg: Option<f64>,
538 outcome: Option<PositionOutcome>,
539}
540
541impl PositionTry {
542 const NONE: Self = Self {
543 sep_deg: None,
544 outcome: None,
545 };
546}
547
548fn try_position(ctx: &SpiralCtx<'_>, idx: usize, sx: i32, sy: i32) -> PositionTry {
551 let params = ctx.params;
552 let step_size = ctx.step_size;
553
554 let dec_db_raw = params.dec_hint + step_size * sy as f64;
555 let (dec_db, flip) = if dec_db_raw > PI / 2.0 {
556 (PI - dec_db_raw, PI)
557 } else if dec_db_raw < -PI / 2.0 {
558 (-PI - dec_db_raw, PI)
559 } else {
560 (dec_db_raw, 0.0)
561 };
562
563 let extra = if dec_db > 0.0 {
564 step_size * 0.5
565 } else {
566 -step_size * 0.5
567 };
568 let ra_offset = step_size * sx as f64 / (dec_db - extra).cos();
569 if ra_offset > PI / 2.0 + step_size * 0.5 || ra_offset < -PI / 2.0 {
570 return PositionTry::NONE;
571 }
572
573 let ra_db = (flip + params.ra_hint + ra_offset).rem_euclid(2.0 * PI);
574 let sep = ang_sep(ra_db, dec_db, params.ra_hint, params.dec_hint);
575 if sep > params.search_radius + step_size / 2.0 {
576 return PositionTry::NONE;
577 }
578
579 let cat_raw = match read_catalog_stars(
582 ¶ms.db_path,
583 ¶ms.db_name,
584 ra_db,
585 dec_db,
586 params.fov * ctx.oversize,
587 ctx.nrstars_required,
588 ) {
589 Ok(v) if !v.is_empty() => v,
590 Ok(_) | Err(_) => return PositionTry::NONE,
591 };
592 if crate::cancel::fired(ctx.cancel.as_ref()) {
593 return PositionTry::NONE;
594 }
595
596 let sep_deg = sep.to_degrees();
597 let mag_limit = cat_raw
598 .iter()
599 .map(|s| s.mag)
600 .fold(f64::NEG_INFINITY, f64::max);
601 log::info!(
602 target: SEARCH_LOG_TARGET,
603 "Search {}, [{},{}], position: {} Down to magn {:.1} {} database stars {} database quads to compare.",
604 idx,
605 sx,
606 sy,
607 format_radec(ra_db, dec_db),
608 mag_limit,
609 cat_raw.len(),
610 cat_raw.len(),
611 );
612
613 let mut cat_stars: Vec<Star> = cat_raw
614 .iter()
615 .map(|s| {
616 let (x, y) = equatorial_standard(ra_db, dec_db, s.ra, s.dec, 1.0);
617 Star {
618 x,
619 y,
620 snr: 1.0,
621 hfd: 2.0,
622 }
623 })
624 .collect();
625 cat_stars.sort_unstable_by(|a, b| a.x.total_cmp(&b.x));
626 let cat_star_list = StarList(cat_stars);
627
628 let failed = PositionTry {
629 sep_deg: Some(sep_deg),
630 outcome: None,
631 };
632
633 let (img_pos, cat_pos, n_raw) = match params.method {
634 SolveMethod::Quads => {
635 let mut cat_quads = build_quads_presorted(&cat_star_list, ctx.nrstars_image);
636 if ctx.nrstars_image < ctx.star_limit {
637 add_density_matched_quads(ctx, &cat_raw, ra_db, dec_db, &mut cat_quads);
638 }
639 if cat_quads.is_empty() {
640 return failed;
641 }
642 let raw = ctx
645 .img_grid
646 .find_matches(ctx.img_quads, &cat_quads, params.quad_tolerance);
647 let n_raw = raw.len();
648 log::info!(target: SEARCH_LOG_TARGET, "Found {n_raw} references");
649 let mut filtered = vote_filter(ctx.img_quads, &cat_quads, &raw, params.quad_tolerance);
650 if filtered.len() < ctx.min_quads {
651 let (by_scale, _) = filter_by_scale(&raw, params.quad_tolerance);
652 if by_scale.len() > filtered.len() {
653 filtered = by_scale;
654 }
655 }
656 if filtered.len() < ctx.min_quads {
657 return failed;
658 }
659 let (ip, cp) = extract_star_pairs(ctx.img_quads, &cat_quads, &filtered);
660 (ip, cp, n_raw)
661 }
662 SolveMethod::Tetra => {
663 let cat_tris = build_triangles(&cat_star_list);
664 if cat_tris.is_empty() {
665 return failed;
666 }
667 let tol = params.quad_tolerance * TETRA_TOL_FACTOR;
668 let raw = find_triangle_matches(ctx.img_tris, &cat_tris, tol);
669 let n_raw = raw.len();
670 log::info!(target: SEARCH_LOG_TARGET, "Found {n_raw} triangle references");
671 let biject = bijective_filter(&raw, ctx.img_tris, &cat_tris);
672 let (filtered, _) = filter_triangles_by_scale(&biject, params.quad_tolerance);
673 if filtered.len() < ctx.min_quads {
674 return failed;
675 }
676 let (ip, cp) = extract_triangle_pairs(ctx.img_tris, &cat_tris, &filtered);
677 (ip, cp, n_raw)
678 }
679 };
680
681 let seeds = Seeds {
684 img: img_pos.clone(),
685 cat: cat_pos.clone(),
686 ra: ra_db,
687 dec: dec_db,
688 };
689 if crate::cancel::fired(ctx.cancel.as_ref()) {
690 return failed;
691 }
692 let Some((plate, n_matched)) = fit_pattern_pairs(img_pos, cat_pos, ctx.min_quads) else {
693 return failed;
694 };
695
696 let found = |verified, ra_db, dec_db, refused| PositionTry {
697 sep_deg: Some(sep_deg),
698 outcome: Some(PositionOutcome {
699 idx,
700 ra_db,
701 dec_db,
702 sep_deg,
703 verified,
704 n_matched,
705 n_raw,
706 mag_limit,
707 refused,
708 }),
709 };
710
711 let Some(verified) = verify_and_refit(
712 ctx.stars,
713 &cat_star_list,
714 &plate,
715 ctx.img.width,
716 ctx.img.height,
717 &ctx.accept,
718 ) else {
719 log::info!(
720 target: SEARCH_LOG_TARGET,
721 "Verification failed at this position; continuing search."
722 );
723 if n_matched >= STRONG_VOTE
724 && let Some((verified, ra_c, dec_c)) =
725 second_chance(ctx, &cat_raw, &seeds, &plate, ra_db, dec_db)
726 {
727 return found(verified, ra_c, dec_c, false);
728 }
729 return failed;
730 };
731 log::info!(
732 "Verified {} stars against the catalogue, residual {:.2}\"",
733 verified.n(),
734 verified.rms
735 );
736
737 let (verified, ra_db, dec_db) = recentre(ctx, &cat_raw, verified, ra_db, dec_db);
738 match model_distortion(ctx, &cat_raw, &seeds, verified, ra_db, dec_db) {
739 Modelled::Linear(v) => found(v, ra_db, dec_db, false),
740 Modelled::Distorted(v, ra_c, dec_c) => found(v, ra_c, dec_c, false),
741 Modelled::Refused(v) => found(v, ra_db, dec_db, true),
742 }
743}
744
745const STRONG_VOTE: usize = 50;
750
751fn project(cat_raw: &[CatalogStar], ra: f64, dec: f64) -> StarList {
753 StarList(
754 cat_raw
755 .iter()
756 .map(|s| {
757 let (x, y) = equatorial_standard(ra, dec, s.ra, s.dec, 1.0);
758 Star {
759 x,
760 y,
761 snr: 1.0,
762 hfd: 2.0,
763 }
764 })
765 .collect(),
766 )
767}
768
769struct Seeds {
771 img: Vec<(f64, f64)>,
772 cat: Vec<(f64, f64)>,
773 ra: f64,
774 dec: f64,
775}
776
777impl Seeds {
778 fn in_plane(&self, ra: f64, dec: f64) -> Vec<Pair> {
780 self.img
781 .iter()
782 .zip(&self.cat)
783 .map(|(&i, &(x, y))| {
784 if ra == self.ra && dec == self.dec {
785 return (i, (x, y));
786 }
787 let (sra, sdec) = standard_equatorial(self.ra, self.dec, x, y, 1.0);
788 (i, equatorial_standard(ra, dec, sra, sdec, 1.0))
789 })
790 .collect()
791 }
792}
793
794fn fit_distortion(
797 ctx: &SpiralCtx<'_>,
798 cat_raw: &[CatalogStar],
799 seeds: &Seeds,
800 plate: &PlateConstants,
801 ra: f64,
802 dec: f64,
803) -> Option<Refined> {
804 let (w, h) = (ctx.img.width as f64, ctx.img.height as f64);
810 let (xs, ys) = (
811 plate.a * (w - 1.0) * 0.5 + plate.b * (h - 1.0) * 0.5 + plate.c,
812 plate.d * (w - 1.0) * 0.5 + plate.e * (h - 1.0) * 0.5 + plate.f,
813 );
814 let (ra_c, dec_c) = standard_equatorial(ra, dec, xs, ys, 1.0);
815 let window = w.hypot(h) / w.max(h);
816 let cat = match read_catalog_stars(
817 &ctx.params.db_path,
818 &ctx.params.db_name,
819 ra_c,
820 dec_c,
821 ctx.params.fov * window,
822 (ctx.params.max_stars as f64 * window * window).round() as usize,
823 ) {
824 Ok(v) if !v.is_empty() => project(&v, ra, dec),
825 _ => project(cat_raw, ra, dec),
826 };
827 let grid = StarGrid::new(ctx.stars, VERIFY_RADII[0])?;
828 let r = refine(
829 &grid,
830 &cat,
831 &seeds.in_plane(ra, dec),
832 plate,
833 ctx.img.width,
834 ctx.img.height,
835 VERIFY_RADII[VERIFY_RADII.len() - 1],
836 )?;
837 log::info!(
838 "Distortion model: {} terms, {} stars within {} px, rms {:.2} px, F {:.1} over linear, {} of 9 cells",
839 r.model.n_terms,
840 r.img_pos.len(),
841 VERIFY_RADII[VERIFY_RADII.len() - 1],
842 r.rms / r.model.scale(),
843 r.f_linear,
844 r.cells
845 );
846 Some(r)
847}
848
849fn linear_from_model(
853 ctx: &SpiralCtx<'_>,
854 r: &Refined,
855 ra: f64,
856 dec: f64,
857) -> Option<(Verified, f64, f64)> {
858 let (w, h) = (ctx.img.width, ctx.img.height);
859 let (xs, ys) = r
860 .model
861 .apply((w as f64 - 1.0) * 0.5, (h as f64 - 1.0) * 0.5);
862 let (ra_c, dec_c) = standard_equatorial(ra, dec, xs, ys, 1.0);
863 let moved = |(x, y): (f64, f64)| {
866 let (sra, sdec) = standard_equatorial(ra, dec, x, y, 1.0);
867 equatorial_standard(ra_c, dec_c, sra, sdec, 1.0)
868 };
869 let plate = best_linear(|x, y| moved(r.model.apply(x, y)), w, h)?;
870 let cat_pos = r.cat_pos.iter().map(|&p| moved(p)).collect();
871 Some((
872 Verified {
873 plate,
874 rms: r.rms,
875 img_pos: r.img_pos.clone(),
876 cat_pos,
877 chance: 0.0,
878 },
879 ra_c,
880 dec_c,
881 ))
882}
883
884enum Modelled {
886 Linear(Verified),
888 Distorted(Verified, f64, f64),
890 Refused(Verified),
893}
894
895const MIN_REPORT_F: f64 = 30.0;
902
903const MIN_DEPARTURE_PX: f64 = 1.0;
907
908const REFUSE_F: f64 = 100.0;
911const REFUSE_DEPARTURE_PX: f64 = 3.0;
916
917fn model_distortion(
927 ctx: &SpiralCtx<'_>,
928 cat_raw: &[CatalogStar],
929 seeds: &Seeds,
930 verified: Verified,
931 ra: f64,
932 dec: f64,
933) -> Modelled {
934 let Some(r) = fit_distortion(ctx, cat_raw, seeds, &verified.plate, ra, dec) else {
935 return Modelled::Linear(verified);
936 };
937 let (w, h) = (ctx.img.width, ctx.img.height);
938 let departure = max_departure_px(&r.model, &verified.plate, w, h);
939 log::info!(
940 "Distortion: verified plate departs {departure:.2} px from the model; {} stars against {} verified",
941 r.img_pos.len(),
942 verified.n(),
943 );
944 let min_cells = if r.model.n_terms == 10 { 9 } else { 7 };
945 let usable = r.model.n_terms > 3
946 && r.cells >= min_cells
947 && r.f_linear >= MIN_REPORT_F
948 && r.img_pos.len() * 10 >= verified.n() * 9;
949 if usable {
950 if departure >= MIN_DEPARTURE_PX
951 && let Some((v, ra_c, dec_c)) = linear_from_model(ctx, &r, ra, dec)
952 {
953 log::info!("Reporting the linear plate closest to the distortion model.");
954 return Modelled::Distorted(v, ra_c, dec_c);
955 }
956 return Modelled::Linear(verified);
957 }
958 let (wide_f, wide_dep) = r.unmodelled();
959 log::info!("Where the stars are: a cubic with F {wide_f:.1}, {wide_dep:.2} px from the plate.");
960 if wide_f >= REFUSE_F && wide_dep >= REFUSE_DEPARTURE_PX {
961 log::info!(
962 "The field is distorted by {wide_dep:.1} px where it has stars, and the distortion \
963 cannot be modelled over the whole frame: refusing a linear solution."
964 );
965 return Modelled::Refused(verified);
966 }
967 if r.img_pos.len() as f64 >= MODEL_PAIRS_REFIT * verified.n() as f64
968 && let Some(v) = refit_linear(&r.img_pos, &r.cat_pos)
969 {
970 log::info!(
971 "The full-frame match pairs {} stars against {} verified: refitting the linear plate to them.",
972 r.img_pos.len(),
973 verified.n()
974 );
975 return Modelled::Linear(v);
976 }
977 Modelled::Linear(verified)
978}
979
980const MODEL_PAIRS_REFIT: f64 = 1.5;
995
996fn refit_linear(img_pos: &[(f64, f64)], cat_pos: &[(f64, f64)]) -> Option<Verified> {
998 let plate = solve_plate_constants(img_pos, cat_pos).ok()?;
999 let sq: f64 = img_pos
1000 .iter()
1001 .zip(cat_pos)
1002 .map(|(&(x, y), &(xc, yc))| {
1003 (plate.a * x + plate.b * y + plate.c - xc).powi(2)
1004 + (plate.d * x + plate.e * y + plate.f - yc).powi(2)
1005 })
1006 .sum();
1007 let rms = (sq / img_pos.len().max(1) as f64).sqrt();
1008 Some(Verified {
1009 plate,
1010 rms,
1011 img_pos: img_pos.to_vec(),
1012 cat_pos: cat_pos.to_vec(),
1013 chance: 0.0,
1014 })
1015}
1016
1017fn spread_of(img_pos: &[(f64, f64)], img_w: usize, img_h: usize) -> f64 {
1020 let n = img_pos.len() as f64;
1021 let mx = img_pos.iter().map(|p| p.0).sum::<f64>() / n;
1022 let my = img_pos.iter().map(|p| p.1).sum::<f64>() / n;
1023 let var = img_pos
1024 .iter()
1025 .map(|&(x, y)| (x - mx) * (x - mx) + (y - my) * (y - my))
1026 .sum::<f64>()
1027 / n;
1028 let half_diag = 0.5 * ((img_w * img_w + img_h * img_h) as f64).sqrt();
1029 var.sqrt() / half_diag
1030}
1031
1032fn second_chance(
1038 ctx: &SpiralCtx<'_>,
1039 cat_raw: &[CatalogStar],
1040 seeds: &Seeds,
1041 plate: &PlateConstants,
1042 ra: f64,
1043 dec: f64,
1044) -> Option<(Verified, f64, f64)> {
1045 log::info!(
1046 target: SEARCH_LOG_TARGET,
1047 "Strong pattern match: retrying verification with a distortion model."
1048 );
1049 let r = fit_distortion(ctx, cat_raw, seeds, plate, ra, dec)?;
1050 if r.cells < if r.model.n_terms == 10 { 9 } else { 7 } {
1052 log::info!(
1053 target: SEARCH_LOG_TARGET,
1054 "The distortion model's stars do not cover the frame."
1055 );
1056 return None;
1057 }
1058 let probe = Verified {
1059 plate: r.model.linear_part(),
1060 rms: r.rms,
1061 img_pos: r.img_pos.clone(),
1062 cat_pos: r.cat_pos.clone(),
1063 chance: 0.0,
1065 };
1066 let spread = spread_of(&r.img_pos, ctx.img.width, ctx.img.height);
1067 if !ctx.accept.accepts(&probe, spread) {
1068 log::info!(target: SEARCH_LOG_TARGET, "The distortion model did not verify either.");
1069 return None;
1070 }
1071 log::info!(
1072 "Verified {} stars with the distortion model.",
1073 r.img_pos.len()
1074 );
1075 linear_from_model(ctx, &r, ra, dec)
1076}
1077
1078const DENSITY_MATCH_MIN_RATIO: f64 = 2.5;
1086
1087fn add_density_matched_quads(
1103 ctx: &SpiralCtx<'_>,
1104 cat_raw: &[CatalogStar],
1105 ra_db: f64,
1106 dec_db: f64,
1107 cat_quads: &mut crate::types::QuadList,
1108) {
1109 let k = (ctx.nrstars_image as f64 * ctx.oversize * ctx.oversize * ctx.aspect).round() as usize;
1110 if k < 5 || (k as f64) * DENSITY_MATCH_MIN_RATIO > cat_raw.len() as f64 {
1111 return;
1112 }
1113 let mut sub: Vec<Star> = cat_raw[..k]
1115 .iter()
1116 .map(|s| {
1117 let (x, y) = equatorial_standard(ra_db, dec_db, s.ra, s.dec, 1.0);
1118 Star {
1119 x,
1120 y,
1121 snr: 1.0,
1122 hfd: 2.0,
1123 }
1124 })
1125 .collect();
1126 sub.sort_unstable_by(|a, b| a.x.total_cmp(&b.x));
1127 let extra = build_quads_presorted(&StarList(sub), ctx.nrstars_image);
1128 let key = |q: &crate::types::Quad| {
1131 (
1132 (q.center_x * 1000.0).round() as i64,
1133 (q.center_y * 1000.0).round() as i64,
1134 (q.d1 * 1000.0).round() as i64,
1135 )
1136 };
1137 let seen: std::collections::HashSet<_> = cat_quads.0.iter().map(key).collect();
1138 let before = cat_quads.len();
1139 cat_quads
1140 .0
1141 .extend(extra.0.into_iter().filter(|q| !seen.contains(&key(q))));
1142 log::info!(
1143 "{} more database quads from its {k} brightest stars, the image's density.",
1144 cat_quads.len() - before
1145 );
1146}
1147
1148fn recentre(
1165 ctx: &SpiralCtx<'_>,
1166 cat_raw: &[CatalogStar],
1167 mut verified: Verified,
1168 mut ra_db: f64,
1169 mut dec_db: f64,
1170) -> (Verified, f64, f64) {
1171 let (w, h) = (ctx.img.width as f64, ctx.img.height as f64);
1172 let (cx, cy) = ((w - 1.0) * 0.5, (h - 1.0) * 0.5);
1173 let apply =
1174 |p: &PlateConstants, x: f64, y: f64| (p.a * x + p.b * y + p.c, p.d * x + p.e * y + p.f);
1175
1176 for _ in 0..2 {
1177 let plate = &verified.plate;
1178 let (xs, ys) = apply(plate, cx, cy);
1179 if xs.hypot(ys) < 1e-3 {
1181 break;
1182 }
1183 let (ra0, dec0) = standard_equatorial(ra_db, dec_db, xs, ys, 1.0);
1184
1185 let det = plate.a * plate.e - plate.b * plate.d;
1191 if det.abs() < 1e-12 {
1192 break;
1193 }
1194 let r2 = VERIFY_RADII[0] * VERIFY_RADII[0];
1195 let mut used = vec![false; ctx.stars.len()];
1196 let mut img_pos = Vec::new();
1197 let mut new_pos = Vec::new();
1198 let mut cat = Vec::with_capacity(cat_raw.len());
1199 for s in cat_raw {
1200 let (nx, ny) = equatorial_standard(ra0, dec0, s.ra, s.dec, 1.0);
1201 cat.push(Star {
1202 x: nx,
1203 y: ny,
1204 snr: 1.0,
1205 hfd: 2.0,
1206 });
1207 let (ox, oy) = equatorial_standard(ra_db, dec_db, s.ra, s.dec, 1.0);
1208 let (dx, dy) = (ox - plate.c, oy - plate.f);
1209 let px = (plate.e * dx - plate.b * dy) / det;
1210 let py = (-plate.d * dx + plate.a * dy) / det;
1211 let nearest = ctx
1212 .stars
1213 .0
1214 .iter()
1215 .enumerate()
1216 .filter(|&(i, _)| !used[i])
1217 .map(|(i, st)| (i, (st.x - px).powi(2) + (st.y - py).powi(2)))
1218 .filter(|&(_, d2)| d2 < r2)
1219 .min_by(|a, b| a.1.total_cmp(&b.1));
1220 if let Some((i, _)) = nearest {
1221 used[i] = true;
1222 img_pos.push((ctx.stars.0[i].x, ctx.stars.0[i].y));
1223 new_pos.push((nx, ny));
1224 }
1225 }
1226 let Ok(guess) = solve_plate_constants(&img_pos, &new_pos) else {
1227 break;
1228 };
1229 let cat = StarList(cat);
1230 let Some(v) = verify_and_refit(
1231 ctx.stars,
1232 &cat,
1233 &guess,
1234 ctx.img.width,
1235 ctx.img.height,
1236 &ctx.accept,
1237 ) else {
1238 log::info!("Re-centring on the image centre did not verify; keeping the fit.");
1239 break;
1240 };
1241 log::info!(
1242 "Re-centred on the image centre: verified {} stars, residual {:.2}\"",
1243 v.n(),
1244 v.rms
1245 );
1246 (verified, ra_db, dec_db) = (v, ra0, dec0);
1247 }
1248 (verified, ra_db, dec_db)
1249}
1250
1251const SEEDED_MAX_STARS: usize = 2000;
1254const SEEDED_WINDOW: f64 = 1.5;
1257const SEEDED_CAT_STARS: usize = 150;
1260const SEEDED_MAX_QUADS: usize = 600;
1262const SEEDED_SCALE_TOL: f64 = 0.05;
1264const SEEDED_PROBE_PX: f64 = 2.5;
1266const SEEDED_MIN_CENSUS: usize = 10;
1268const SEEDED_BUDGET: u64 = 30_000_000;
1274
1275fn seeded_fallback(ctx: &SpiralCtx<'_>, deep: &StarList) -> Option<PositionOutcome> {
1280 use crate::quads::seeded::{ImageIndex, SeedParams, max_backbone_px, search};
1281 let params = ctx.params;
1282 if deep.len() < 30 {
1283 return None;
1284 }
1285 let (ra, dec) = (params.ra_hint, params.dec_hint);
1286 let n_read = (ctx.nrstars_required as f64 * (SEEDED_WINDOW / ctx.oversize).powi(2)).round();
1287 let cat_raw = read_catalog_stars(
1288 ¶ms.db_path,
1289 ¶ms.db_name,
1290 ra,
1291 dec,
1292 params.fov * SEEDED_WINDOW,
1293 n_read as usize,
1294 )
1295 .ok()
1296 .filter(|v| v.len() >= 8)?;
1297 let mag_limit = cat_raw
1298 .iter()
1299 .map(|s| s.mag)
1300 .fold(f64::NEG_INFINITY, f64::max);
1301 let cat_list = project(&cat_raw, ra, dec);
1302 let cat_pos: Vec<(f64, f64)> = cat_list.0.iter().map(|s| (s.x, s.y)).collect();
1303 let sp = SeedParams {
1304 scale: ctx.accept.expected_scale,
1305 scale_tol: SEEDED_SCALE_TOL,
1306 width: ctx.img.width as f64,
1307 height: ctx.img.height as f64,
1308 seed_stars: SEEDED_CAT_STARS,
1309 max_quads: SEEDED_MAX_QUADS,
1310 census_stars: SEEDED_CAT_STARS,
1311 min_census: SEEDED_MIN_CENSUS,
1312 verify_cost: 30 * cat_pos.len() as u64,
1315 };
1316 let index = ImageIndex::new(
1317 deep.0.iter().map(|s| (s.x, s.y)).collect(),
1318 max_backbone_px(&cat_pos, &sp),
1319 SEEDED_PROBE_PX,
1320 );
1321 log::info!(
1322 "Catalogue-seeded search: {} database stars about the hint, {} image stars, {} pairs.",
1323 cat_raw.len(),
1324 index.len(),
1325 index.n_pairs()
1326 );
1327 let mut budget = SEEDED_BUDGET;
1328 let mut verified = None;
1329 let mut candidates = 0usize;
1330 let cand = search(&index, &cat_pos, &sp, &mut budget, |c| {
1331 candidates += 1;
1332 verified = verify_and_refit(
1333 ctx.stars,
1334 &cat_list,
1335 &c.plate,
1336 ctx.img.width,
1337 ctx.img.height,
1338 &ctx.accept,
1339 );
1340 verified.is_some()
1341 });
1342 log::info!(
1343 "Catalogue-seeded search: {candidates} candidates verified, {} of {SEEDED_BUDGET} work spent.",
1344 SEEDED_BUDGET - budget
1345 );
1346 let cand = cand?;
1347 let verified = verified?;
1348 log::info!(
1349 "Verified {} stars against the catalogue, residual {:.2}\"",
1350 verified.n(),
1351 verified.rms
1352 );
1353 let seeds = Seeds {
1354 img: cand.img.clone(),
1355 cat: cand.cat.clone(),
1356 ra,
1357 dec,
1358 };
1359 let (verified, ra_db, dec_db) = recentre(ctx, &cat_raw, verified, ra, dec);
1360 let (verified, ra_db, dec_db, refused) =
1361 match model_distortion(ctx, &cat_raw, &seeds, verified, ra_db, dec_db) {
1362 Modelled::Linear(v) => (v, ra_db, dec_db, false),
1363 Modelled::Distorted(v, ra_c, dec_c) => (v, ra_c, dec_c, false),
1364 Modelled::Refused(v) => (v, ra_db, dec_db, true),
1365 };
1366 Some(PositionOutcome {
1367 idx: usize::MAX,
1368 ra_db,
1369 dec_db,
1370 sep_deg: 0.0,
1371 verified,
1372 n_matched: cand.img.len(),
1373 n_raw: candidates,
1374 mag_limit,
1375 refused,
1376 })
1377}
1378
1379pub fn solve_image(img: &crate::types::ImageBuffer, params: &SolveParams) -> Result<WcsSolution> {
1400 solve_image_with(img, params, ScaleTrust::Trusted)
1401}
1402
1403pub(crate) fn solve_image_with(
1405 img: &crate::types::ImageBuffer,
1406 params: &SolveParams,
1407 trust: ScaleTrust,
1408) -> Result<WcsSolution> {
1409 check_params(params)?;
1410 if crate::cancel::is_cancelled() {
1411 return Err(ArcsecError::Cancelled);
1412 }
1413 let detected = detect(img, params);
1414 solve_detected(img, params, trust, &detected)
1415}
1416
1417pub(crate) struct Detected {
1419 stars: StarList,
1421 deep: StarList,
1423}
1424
1425pub(crate) fn detect(img: &crate::types::ImageBuffer, params: &SolveParams) -> Detected {
1429 crate::cancel::progress(crate::cancel::stage::DETECTING, -1.0);
1430 let bg = get_background(img, params.max_stars);
1431 log::info!("Start finding stars");
1432 let (stars, stars_raw, deep) =
1433 find_stars_and_deep(img, &bg, params.hfd_min, params.max_stars, SEEDED_MAX_STARS);
1434 log::info!(
1435 "{} stars found of the requested {}. Background value is {:.0}. \
1436 Detection level used {:.0} above background. Star level is {:.0} above background. \
1437 Noise level is {:.0}",
1438 stars_raw,
1439 params.max_stars,
1440 bg.mean,
1441 bg.star_level,
1442 bg.star_level,
1443 bg.noise,
1444 );
1445 if stars_raw > params.max_stars {
1446 log::info!("Selecting the {} brightest stars only.", params.max_stars);
1447 }
1448 Detected { stars, deep }
1449}
1450
1451fn check_params(params: &SolveParams) -> Result<()> {
1453 if !(params.fov.is_finite() && params.fov > 0.0) {
1456 return Err(ArcsecError::InvalidParameter(format!(
1457 "field of view must be positive, got {} rad",
1458 params.fov
1459 )));
1460 }
1461 if !(params.search_radius.is_finite() && params.search_radius >= 0.0) {
1462 return Err(ArcsecError::InvalidParameter(format!(
1463 "search radius must be non-negative, got {} rad",
1464 params.search_radius
1465 )));
1466 }
1467 if !(0.0..=MAX_QUAD_TOLERANCE).contains(¶ms.quad_tolerance) {
1468 return Err(ArcsecError::InvalidParameter(format!(
1469 "quad tolerance must be between 0 and {MAX_QUAD_TOLERANCE}, got {}",
1470 params.quad_tolerance
1471 )));
1472 }
1473 if params.search_radius / params.fov > MAX_SPIRAL_RINGS {
1477 return Err(ArcsecError::InvalidParameter(format!(
1478 "a search radius of {:.0} fields cannot be searched (field {} rad)",
1479 params.search_radius / params.fov,
1480 params.fov
1481 )));
1482 }
1483
1484 if !crate::catalog::catalog_present(¶ms.db_path, ¶ms.db_name) {
1489 return Err(ArcsecError::CatalogNotFound(params.db_path.clone()));
1490 }
1491 Ok(())
1492}
1493
1494pub(crate) fn solve_detected(
1497 img: &crate::types::ImageBuffer,
1498 params: &SolveParams,
1499 trust: ScaleTrust,
1500 detected: &Detected,
1501) -> Result<WcsSolution> {
1502 check_params(params)?;
1503 let cancel = crate::cancel::current();
1505 let cancelled = || crate::cancel::fired(cancel.as_ref());
1506 if cancelled() {
1507 return Err(ArcsecError::Cancelled);
1508 }
1509 let deep_stars = &detected.deep;
1510
1511 let star_limit = density_star_limit(params, img);
1523 let mut stars = detected.stars.clone();
1524 if stars.len() > star_limit {
1525 stars.0.sort_by(|a, b| b.snr.total_cmp(&a.snr));
1526 stars.0.truncate(star_limit);
1527 log::info!(
1528 "Database limit for this field is {star_limit} stars; using the {star_limit} brightest."
1529 );
1530 }
1531
1532 if cancelled() {
1533 return Err(ArcsecError::Cancelled);
1534 }
1535 let nrstars_image = stars.len();
1536 if nrstars_image < 5 {
1537 return Err(ArcsecError::InsufficientStars {
1538 found: nrstars_image,
1539 required: 5,
1540 });
1541 }
1542
1543 let img_quads = build_quads(&stars, nrstars_image);
1545 let nr_quads = img_quads.len();
1546
1547 let img_tris = if params.method == SolveMethod::Tetra {
1548 build_triangles(&stars)
1549 } else {
1550 crate::quads::TriangleList::default()
1551 };
1552
1553 let patterns_empty = match params.method {
1554 SolveMethod::Quads => nr_quads == 0,
1555 SolveMethod::Tetra => img_tris.is_empty(),
1556 };
1557 if patterns_empty {
1558 return Err(ArcsecError::InsufficientQuads {
1559 found: 0,
1560 required: 3,
1561 });
1562 }
1563
1564 let min_quads: usize = 3 + nrstars_image / 140;
1565 let img_grid = if params.method == SolveMethod::Quads {
1566 QuadGrid::build(&img_quads, params.quad_tolerance)
1567 } else {
1568 QuadGrid::build(&crate::types::QuadList::default(), params.quad_tolerance)
1569 };
1570
1571 let oversize: f64 = match params.speed {
1572 SearchSpeed::Auto if nrstars_image < 35 => 2.0,
1573 SearchSpeed::Auto if nrstars_image > 140 => 1.0,
1574 SearchSpeed::Auto => 2.0 * (35.0 / nrstars_image as f64).sqrt(),
1575 SearchSpeed::Slow => {
1578 let max_fov_deg = match crate::catalog::detect_layout(¶ms.db_path, ¶ms.db_name)
1579 {
1580 CatalogLayout::Areas1476 => 5.142_857_143_f64,
1581 CatalogLayout::Areas290 => 9.53,
1582 CatalogLayout::AllSky001 => 180.0,
1583 };
1584 2.0_f64.min(max_fov_deg.to_radians() / params.fov).max(1.0)
1585 }
1586 };
1587
1588 let nrstars_required = (params.max_stars as f64 * oversize * oversize).round() as usize;
1590 let step_size = params.fov;
1591 let fov_deg = step_size.to_degrees();
1592 let max_distance = (params.search_radius / step_size + 2.0) as i32;
1593
1594 log::info!(
1595 "{} stars, {} quads selected in the image. {} database stars, {} database quads required \
1596 for the {:.2}d square search window. Step size {:.2}d. Oversize {:.2}",
1597 nrstars_image,
1598 nr_quads,
1599 nrstars_required,
1600 nrstars_required,
1601 fov_deg * oversize,
1602 fov_deg,
1603 oversize,
1604 );
1605
1606 let ctx = SpiralCtx {
1612 params,
1613 img,
1614 stars: &stars,
1615 img_quads: &img_quads,
1616 img_grid: &img_grid,
1617 img_tris: &img_tris,
1618 nrstars_image,
1619 star_limit,
1620 nrstars_required,
1621 oversize,
1622 min_quads,
1623 step_size,
1624 accept: Acceptance::new(nrstars_image, params, img, trust),
1625 aspect: img.width.max(img.height) as f64 / img.width.min(img.height).max(1) as f64,
1626 cancel: cancel.clone(),
1627 };
1628
1629 let n_threads = if params.threads > 0 {
1630 params.threads
1631 } else {
1632 crate::max_threads()
1633 }
1634 .clamp(1, 64);
1635
1636 let n_positions = usize::try_from(spiral_len(max_distance)).unwrap_or(usize::MAX);
1640 let reported = core::sync::atomic::AtomicUsize::new(0);
1643 let progress_total = n_positions.max(1);
1644 let (step_distances, winner) = search_in_order(n_positions, n_threads, |idx| {
1645 if cancelled() {
1647 return (None, None);
1648 }
1649 if let Some(c) = &cancel {
1650 let bucket = (idx as u128 * 200 / progress_total as u128) as usize;
1651 if bucket > reported.fetch_max(bucket, core::sync::atomic::Ordering::Relaxed) {
1652 c.progress(
1653 crate::cancel::stage::SEARCHING,
1654 idx as f64 / progress_total as f64,
1655 );
1656 }
1657 }
1658 let (sx, sy) = spiral_position(idx as u64);
1659 let t = try_position(&ctx, idx, sx, sy);
1660 (t.sep_deg, t.outcome)
1661 });
1662 let mut winner = winner.map(|(_, o)| o);
1663 if winner.is_none() && cancelled() {
1664 return Err(ArcsecError::Cancelled);
1665 }
1666
1667 if winner.is_none() && params.method == SolveMethod::Quads && trust == ScaleTrust::Trusted {
1669 winner = seeded_fallback(&ctx, deep_stars);
1670 }
1671
1672 if let Some(o) = winner.as_ref().filter(|o| o.refused) {
1673 log::info!(
1674 "No solution: the field at search position {} is too distorted for a linear plate.",
1675 o.idx
1676 );
1677 return Err(ArcsecError::InsufficientQuads {
1678 found: 0,
1679 required: min_quads,
1680 });
1681 }
1682 if let Some(o) = winner {
1683 log::info!(
1684 "{} of {} patterns selected matching within {:.3} tolerance.",
1685 o.n_matched,
1686 o.n_raw,
1687 params.quad_tolerance,
1688 );
1689
1690 let v = o.verified;
1691 let mut wcs = derive_wcs(o.ra_db, o.dec_db, &v.plate, img.width, img.height);
1692 let b = params.binning.max(1) as f64;
1695 wcs.matched_stars = v
1696 .img_pos
1697 .iter()
1698 .zip(&v.cat_pos)
1699 .map(|(&(x, y), &(sx, sy))| {
1700 let (ra, dec) = standard_equatorial(o.ra_db, o.dec_db, sx, sy, 1.0);
1701 MatchedStar {
1702 x: (x + 0.5) * b + 0.5,
1703 y: (y + 0.5) * b + 0.5,
1704 ra,
1705 dec,
1706 }
1707 })
1708 .collect();
1709 if params.binning > 1 {
1710 let b = params.binning as f64;
1711 wcs.crpix1 = (wcs.crpix1 - 0.5) * b + 0.5;
1712 wcs.crpix2 = (wcs.crpix2 - 0.5) * b + 0.5;
1713 wcs.cd1_1 /= b;
1714 wcs.cd1_2 /= b;
1715 wcs.cd2_1 /= b;
1716 wcs.cd2_2 /= b;
1717 wcs.cdelt1 /= b;
1718 wcs.cdelt2 /= b;
1719 }
1720 wcs.residual_rms = v.rms;
1721 wcs.stars_matched = v.n();
1722 wcs.raw_matches = o.n_raw;
1723 wcs.plate = v.plate;
1724 wcs.mag_limit = o.mag_limit;
1725 wcs.search_dist_deg = o.sep_deg;
1726 wcs.step_distances = step_distances;
1727 return Ok(wcs);
1728 }
1729
1730 Err(ArcsecError::InsufficientQuads {
1731 found: 0,
1732 required: min_quads,
1733 })
1734}
1735
1736type Tried<T> = (usize, Option<f64>, Option<T>);
1738
1739pub(crate) fn search_in_order<T: Send>(
1752 n: usize,
1753 n_threads: usize,
1754 try_at: impl Fn(usize) -> (Option<f64>, Option<T>) + Sync,
1755) -> (Vec<f64>, Option<(usize, T)>) {
1756 use core::sync::atomic::{AtomicUsize, Ordering};
1757
1758 if n == 0 {
1759 return (Vec::new(), None);
1760 }
1761 let mut tried: Vec<Tried<T>> = Vec::new();
1764 let (d, o) = try_at(0);
1765 let first_hit = o.is_some();
1766 tried.push((0, d, o));
1767 if !first_hit && n > 1 {
1768 if n_threads <= 1 {
1769 for idx in 1..n {
1770 let (d, o) = try_at(idx);
1771 let hit = o.is_some();
1772 tried.push((idx, d, o));
1773 if hit {
1774 break;
1775 }
1776 }
1777 } else {
1778 let next = AtomicUsize::new(1);
1779 let first_found = AtomicUsize::new(usize::MAX);
1780 let try_at = &try_at;
1781 let per_worker: Vec<Vec<Tried<T>>> = std::thread::scope(|scope| {
1782 let handles: Vec<_> = (0..n_threads.min(n - 1))
1783 .map(|_| {
1784 let (next, first_found) = (&next, &first_found);
1785 scope.spawn(move || {
1786 let mut done = Vec::new();
1787 loop {
1788 let idx = next.fetch_add(1, Ordering::Relaxed);
1789 if idx >= n || idx > first_found.load(Ordering::Relaxed) {
1790 break;
1791 }
1792 let (d, o) = try_at(idx);
1793 if o.is_some() {
1794 first_found.fetch_min(idx, Ordering::Relaxed);
1795 }
1796 done.push((idx, d, o));
1797 }
1798 done
1799 })
1800 })
1801 .collect();
1802 handles
1803 .into_iter()
1804 .map(|h| h.join().unwrap_or_else(|e| std::panic::resume_unwind(e)))
1808 .collect()
1809 });
1810 tried.extend(per_worker.into_iter().flatten());
1811 tried.sort_unstable_by_key(|&(idx, _, _)| idx);
1812 }
1813 }
1814
1815 let mut distances = Vec::new();
1818 for (idx, d, o) in tried {
1819 distances.extend(d);
1820 if let Some(o) = o {
1821 return (distances, Some((idx, o)));
1822 }
1823 }
1824 (distances, None)
1825}
1826
1827#[must_use]
1830pub fn format_ra(ra_rad: f64) -> String {
1831 const TENTHS_PER_DAY: f64 = 24.0 * 36_000.0;
1836 let ra_tenths = ((ra_rad.to_degrees() / 15.0 * 36_000.0)
1837 .round()
1838 .rem_euclid(TENTHS_PER_DAY)) as u64;
1839 let h = ra_tenths / 36_000;
1840 let m = ra_tenths / 600 % 60;
1841 let s = ra_tenths % 600 / 10;
1842 let tenths = ra_tenths % 10;
1843 format!("{h:02}: {m:02} {s:02}.{tenths}")
1844}
1845
1846#[must_use]
1849pub fn format_dec(dec_rad: f64) -> String {
1850 let dec_deg = dec_rad.to_degrees();
1851 let sign = if dec_deg < 0.0 { '-' } else { '+' };
1852 let dec_secs = (dec_deg.abs() * 3600.0).round() as u64;
1853 let dd = dec_secs / 3600;
1854 let dm = dec_secs / 60 % 60;
1855 let ds = dec_secs % 60;
1856 format!("{sign}{dd:02}d {dm:02} {ds:02}")
1857}
1858
1859#[must_use]
1863pub fn format_radec(ra_rad: f64, dec_rad: f64) -> String {
1864 format!("{} {}", format_ra(ra_rad), format_dec(dec_rad))
1865}
1866
1867#[cfg(test)]
1868mod tests {
1869 use super::*;
1870 use crate::math::coords::{ang_sep, standard_equatorial};
1871 use crate::test_support::{
1872 Rng, SkySpec, SkyStar, TempDir, TruthWcs, random_sky, render, write_001_db, write_290_db,
1873 write_1476_db,
1874 };
1875 use crate::types::{ImageBuffer, PlateConstants};
1876 use crate::wcs::output::derive_wcs;
1877 use core::f64::consts::PI;
1878
1879 fn deg(d: f64) -> f64 {
1880 d * PI / 180.0
1881 }
1882
1883 fn make_test_scene(
1884 n_stars: usize,
1885 ra_center: f64,
1886 dec_center: f64,
1887 cdelt_arcsec: f64,
1888 width: usize,
1889 height: usize,
1890 ) -> (ImageBuffer, Vec<(f64, f64)>, PlateConstants) {
1891 let mut data = vec![100.0f32; width * height];
1892 let mut catalog_sky: Vec<(f64, f64)> = Vec::new();
1893 let stars_per_row = (n_stars as f64).sqrt().ceil() as usize;
1894 let spacing = 40.0;
1895 let cx = (width as f64 - 1.0) / 2.0;
1896 let cy = (height as f64 - 1.0) / 2.0;
1897 let a = cdelt_arcsec;
1898 let c = -a * cx;
1899 let e = cdelt_arcsec;
1900 let f_offset = -e * cy;
1901 let plate = PlateConstants {
1902 a,
1903 b: 0.0,
1904 c,
1905 d: 0.0,
1906 e,
1907 f: f_offset,
1908 };
1909 let mut count = 0;
1910 'outer: for row in 0..stars_per_row {
1911 for col in 0..stars_per_row {
1912 if count >= n_stars {
1913 break 'outer;
1914 }
1915 let px = 20.0 + col as f64 * spacing;
1916 let py = 20.0 + row as f64 * spacing;
1917 if px >= width as f64 - 20.0 || py >= height as f64 - 20.0 {
1918 continue;
1919 }
1920 let x_std = a * px + c;
1921 let y_std = e * py + f_offset;
1922 let (ra, dec) = standard_equatorial(ra_center, dec_center, x_std, y_std, 1.0);
1923 catalog_sky.push((ra, dec));
1924 let sigma = 2.0;
1925 let amp = 30000.0f32;
1926 for dy in -8i32..=8 {
1927 for dx in -8i32..=8 {
1928 let x = (px as i32 + dx) as usize;
1929 let y = (py as i32 + dy) as usize;
1930 if x < width && y < height {
1931 let r2 = (dx * dx + dy * dy) as f64 / (2.0 * sigma * sigma);
1932 data[y * width + x] += amp * (-r2).exp() as f32;
1933 }
1934 }
1935 }
1936 count += 1;
1937 }
1938 }
1939 let img = ImageBuffer {
1940 data,
1941 width,
1942 height,
1943 };
1944 (img, catalog_sky, plate)
1945 }
1946
1947 #[test]
1948 fn derive_wcs_recovers_position() {
1949 let ra_center = deg(45.0);
1950 let dec_center = deg(30.0);
1951 let (img, _cat, plate) = make_test_scene(16, ra_center, dec_center, 2.0, 300, 300);
1952 let wcs = derive_wcs(ra_center, dec_center, &plate, img.width, img.height);
1953 let sep_arcsec = ang_sep(wcs.ra0, wcs.dec0, ra_center, dec_center) * (180.0 / PI * 3600.0);
1954 assert!(sep_arcsec < 0.5, "centre offset = {sep_arcsec} arcsec");
1955 }
1956
1957 #[test]
1958 fn the_search_returns_the_serial_result_on_any_number_of_threads() {
1959 let mut rng = crate::test_support::Rng::new(5);
1960 for case in 0..40 {
1961 let n = 1 + (rng.next_u64() % 300) as usize;
1962 let read: Vec<bool> = (0..n).map(|_| rng.uniform() < 0.8).collect();
1964 let hits: Vec<bool> = (0..n)
1965 .map(|_| case % 4 != 0 && rng.uniform() < 0.02)
1966 .collect();
1967 let try_at = |idx: usize| {
1968 let d = read[idx].then_some(idx as f64);
1969 for _ in 0..(idx * 7919) % 5000 {
1971 core::hint::black_box(idx);
1972 }
1973 (d, (read[idx] && hits[idx]).then_some(idx * 10))
1974 };
1975 let want_hit = (0..n).find(|&i| read[i] && hits[i]);
1976 let want_d: Vec<f64> = (0..=want_hit.unwrap_or(n - 1))
1977 .filter(|&i| read[i])
1978 .map(|i| i as f64)
1979 .collect();
1980 for threads in [1, 2, 3, 8] {
1981 let (d, hit) = search_in_order(n, threads, try_at);
1982 assert_eq!(
1983 hit,
1984 want_hit.map(|i| (i, i * 10)),
1985 "case {case}, {threads} threads"
1986 );
1987 assert_eq!(d, want_d, "case {case}, {threads} threads");
1988 }
1989 }
1990 assert_eq!(
1991 search_in_order(0, 4, |_| (Some(1.0), Some(()))),
1992 (vec![], None)
1993 );
1994 }
1995
1996 #[test]
1997 fn spiral_covers_origin_first() {
1998 assert_eq!(
1999 super::super::spiral::SpiralSearch::new(5).next(),
2000 Some((0, 0))
2001 );
2002 assert_eq!(spiral_position(0), (0, 0));
2003 }
2004
2005 #[test]
2006 fn oversize_formula_limits() {
2007 for n in [10, 35, 70, 140, 200] {
2008 let ov: f64 = if n < 35 {
2009 2.0
2010 } else if n > 140 {
2011 1.0
2012 } else {
2013 2.0 * (35.0 / n as f64).sqrt()
2014 };
2015 assert!((1.0..=2.0).contains(&ov), "oversize={ov} for n={n}");
2016 }
2017 }
2018
2019 #[test]
2020 fn format_radec_carries_rounded_seconds() {
2021 let ra = deg((1.0 + 59.0 / 60.0 + 59.97 / 3600.0) * 15.0);
2023 let dec = deg(10.0 + 59.0 / 60.0 + 59.7 / 3600.0);
2025 assert_eq!(format_radec(ra, dec), "02: 00 00.0 +11d 00 00");
2026 let s = format_radec(deg(359.999_999_9), deg(-0.5));
2028 assert_eq!(s, "00: 00 00.0 -00d 30 00");
2029 assert_eq!(
2031 format_radec(deg((5.0 + 35.0 / 60.0 + 17.3 / 3600.0) * 15.0), deg(-5.39)),
2032 "05: 35 17.3 -05d 23 24"
2033 );
2034 }
2035
2036 #[test]
2041 fn ra_and_dec_are_formatted_as_astap_cli_prints_them() {
2042 let ra = deg(65.0); let dec = deg(35.0);
2044 assert_eq!(format_ra(ra), "04: 20 00.0");
2045 assert_eq!(format_dec(dec), "+35d 00 00");
2046 assert_eq!(format_radec(ra, dec), "04: 20 00.0 +35d 00 00");
2047 assert_eq!(
2048 format_radec(
2049 deg((13.0 + 7.0 / 60.0 + 9.25 / 3600.0) * 15.0),
2050 -deg(89.0 + 1.0 / 60.0 + 2.0 / 3600.0)
2051 ),
2052 "13: 07 09.3 -89d 01 02"
2053 );
2054 assert_eq!(format_dec(deg(-0.0001)), "-00d 00 00");
2055 }
2056
2057 #[test]
2058 fn solve_image_rejects_a_non_positive_fov() {
2059 let img = ImageBuffer::new(64, 64);
2060 let params = SolveParams {
2061 ra_hint: 0.0,
2062 dec_hint: 0.0,
2063 fov: 0.0,
2064 search_radius: 0.1,
2065 quad_tolerance: 0.007,
2066 hfd_min: 1.5,
2067 max_stars: 500,
2068 db_path: std::path::PathBuf::from("/nonexistent"),
2069 db_name: "d50".into(),
2070 binning: 1,
2071 method: SolveMethod::Quads,
2072 threads: 1,
2073 speed: SearchSpeed::Auto,
2074 };
2075 assert!(matches!(
2076 solve_image(&img, ¶ms),
2077 Err(ArcsecError::InvalidParameter(_))
2078 ));
2079 }
2080
2081 fn known_plate() -> PlateConstants {
2085 let (s, r) = (3.2_f64, 0.61_f64);
2086 PlateConstants {
2087 a: -s * r.cos(),
2088 b: s * r.sin(),
2089 c: 640.0,
2090 d: s * r.sin(),
2091 e: s * r.cos(),
2092 f: -512.0,
2093 }
2094 }
2095
2096 fn apply(p: &PlateConstants, (x, y): (f64, f64)) -> (f64, f64) {
2097 (p.a * x + p.b * y + p.c, p.d * x + p.e * y + p.f)
2098 }
2099
2100 fn plate_close(p: &PlateConstants, q: &PlateConstants, tol: f64) -> bool {
2101 [
2102 (p.a, q.a),
2103 (p.b, q.b),
2104 (p.c, q.c),
2105 (p.d, q.d),
2106 (p.e, q.e),
2107 (p.f, q.f),
2108 ]
2109 .iter()
2110 .all(|(u, v)| (u - v).abs() <= tol)
2111 }
2112
2113 const STRICT: Acceptance = Acceptance {
2116 min_stars: MIN_VERIFIED_STARS,
2117 expected_scale: 3.2,
2118 trust: ScaleTrust::Trusted,
2119 };
2120
2121 fn star_at(x: f64, y: f64) -> Star {
2122 Star {
2123 x,
2124 y,
2125 snr: 50.0,
2126 hfd: 2.5,
2127 }
2128 }
2129
2130 fn pairs_with_outliers(outlier: impl Fn(usize, (f64, f64)) -> (f64, f64)) -> PairedPositions {
2133 let plate = known_plate();
2134 let mut rng = Rng::new(7);
2135 let mut img = Vec::new();
2136 let mut cat = Vec::new();
2137 for _ in 0..40 {
2138 let p = (rng.range(0.0, 500.0), rng.range(0.0, 500.0));
2139 img.push(p);
2140 cat.push(apply(&plate, p));
2141 }
2142 for k in 0..5 {
2143 let p = (rng.range(0.0, 500.0), rng.range(0.0, 500.0));
2144 img.push(p);
2145 cat.push(outlier(k, apply(&plate, p)));
2146 }
2147 (img, cat)
2148 }
2149
2150 #[test]
2151 fn sigma_clip_pairs_rejects_outliers_and_keeps_the_rest() {
2152 let (img, cat) = pairs_with_outliers(|k, (x, y)| {
2154 let a = k as f64 * 1.3;
2155 (x + 100.0 * a.cos(), y + 100.0 * a.sin())
2156 });
2157 let (ci, cc) = sigma_clip_pairs(img, cat, 3.0, 3);
2158 assert_eq!(ci.len(), 40, "all and only the true pairs survive");
2159 let fit = solve_plate_constants(&ci, &cc).unwrap();
2160 assert!(plate_close(&fit, &known_plate(), 1e-6), "{fit:?}");
2161 }
2162
2163 #[test]
2171 fn sigma_clip_pairs_rejects_gross_outliers() {
2172 let (img, cat) =
2173 pairs_with_outliers(|k, _| (1000.0 + 150.0 * k as f64, -900.0 + 70.0 * k as f64));
2174 assert!(matches!(
2175 solve_plate_constants(&img, &cat),
2176 Err(ArcsecError::BadSolution { .. })
2177 ));
2178 let (ci, _) = sigma_clip_pairs(img, cat, 3.0, 3);
2179 assert_eq!(ci.len(), 40, "the five gross outliers should be clipped");
2180 }
2181
2182 #[test]
2186 fn fit_pattern_pairs_recovers_a_plate_the_plain_fit_refuses() {
2187 let (img, cat) =
2188 pairs_with_outliers(|k, _| (1000.0 + 150.0 * k as f64, -900.0 + 70.0 * k as f64));
2189 assert!(solve_plate_constants(&img, &cat).is_err());
2190 let (plate, n) = fit_pattern_pairs(img.clone(), cat.clone(), 3).expect("clipped fit");
2191 assert_eq!(n, 40);
2192 assert!(plate_close(&plate, &known_plate(), 1e-6), "{plate:?}");
2193 let (plate, n) = fit_pattern_pairs(img[..40].to_vec(), cat[..40].to_vec(), 3).unwrap();
2195 assert_eq!(n, 40);
2196 assert!(plate_close(&plate, &known_plate(), 1e-6));
2197 assert!(fit_pattern_pairs(img, cat, 41).is_none());
2199 }
2200
2201 #[test]
2202 fn sigma_clip_pairs_leaves_too_few_pairs_alone() {
2203 let img = vec![(0.0, 0.0), (1.0, 0.0)];
2204 let cat = vec![(5.0, 5.0), (9.0, 9.0)];
2205 let (ci, cc) = sigma_clip_pairs(img.clone(), cat.clone(), 3.0, 3);
2206 assert_eq!((ci, cc), (img, cat));
2207 }
2208
2209 #[test]
2210 fn verify_and_refit_recovers_the_plate_from_a_rough_guess() {
2211 let truth = known_plate();
2212 let mut rng = Rng::new(11);
2213 let mut img_stars = Vec::new();
2214 let mut cat_stars = Vec::new();
2215 for _ in 0..60 {
2216 let (x, y) = (rng.range(5.0, 395.0), rng.range(5.0, 295.0));
2217 img_stars.push(star_at(x, y));
2218 let (cx, cy) = apply(&truth, (x, y));
2219 cat_stars.push(star_at(cx, cy));
2220 }
2221 for k in 0..20 {
2223 let (cx, cy) = apply(&truth, (-300.0 - 10.0 * k as f64, 900.0));
2224 cat_stars.push(star_at(cx, cy));
2225 }
2226 let mut rough = truth.clone();
2228 rough.c += 2.0 * truth.a;
2229 rough.f += 2.0 * truth.e;
2230 rough.b += 0.01;
2231 let v = verify_and_refit(
2232 &StarList(img_stars),
2233 &StarList(cat_stars),
2234 &rough,
2235 400,
2236 300,
2237 &STRICT,
2238 )
2239 .expect("a correct plate must verify");
2240 assert_eq!(v.n(), 60);
2241 assert_eq!(v.cat_pos.len(), 60);
2242 assert!(v.rms < 1e-6, "rms {}", v.rms);
2243 assert!(plate_close(&v.plate, &truth, 1e-6), "{:?}", v.plate);
2244 for (&(x, y), &(cx, cy)) in v.img_pos.iter().zip(&v.cat_pos) {
2246 let (px, py) = apply(&truth, (x, y));
2247 assert!((px - cx).hypot(py - cy) < 1e-6);
2248 }
2249 }
2250
2251 #[test]
2252 fn verify_and_refit_rejects_too_few_or_clustered_matches() {
2253 let truth = known_plate();
2254 let mut rng = Rng::new(12);
2255 let build = |pts: &[(f64, f64)]| {
2256 let img = StarList(pts.iter().map(|&(x, y)| star_at(x, y)).collect());
2257 let cat = StarList(
2258 pts.iter()
2259 .map(|&p| apply(&truth, p))
2260 .map(|(x, y)| star_at(x, y))
2261 .collect(),
2262 );
2263 (img, cat)
2264 };
2265
2266 let few: Vec<_> = (0..20)
2268 .map(|_| (rng.range(0.0, 400.0), rng.range(0.0, 300.0)))
2269 .collect();
2270 let (img, cat) = build(&few);
2271 assert!(verify_and_refit(&img, &cat, &truth, 400, 300, &STRICT).is_none());
2272
2273 let clustered: Vec<_> = (0..80)
2275 .map(|_| (rng.range(0.0, 40.0), rng.range(0.0, 40.0)))
2276 .collect();
2277 let (img, cat) = build(&clustered);
2278 assert!(verify_and_refit(&img, &cat, &truth, 400, 300, &STRICT).is_none());
2279
2280 let spread: Vec<_> = (0..80)
2282 .map(|_| (rng.range(0.0, 400.0), rng.range(0.0, 300.0)))
2283 .collect();
2284 let (img, cat) = build(&spread);
2285 assert!(verify_and_refit(&img, &cat, &truth, 400, 300, &STRICT).is_some());
2286
2287 let empty = StarList::default();
2289 assert!(verify_and_refit(&empty, &cat, &truth, 400, 300, &STRICT).is_none());
2290 let mut singular = truth.clone();
2291 singular.a = 0.0;
2292 singular.b = 0.0;
2293 assert!(verify_and_refit(&img, &cat, &singular, 400, 300, &STRICT).is_none());
2294 }
2295
2296 #[derive(Clone, Copy)]
2299 enum Db {
2300 Areas1476,
2301 Areas290,
2302 AllSky001,
2303 }
2304
2305 struct Scene {
2307 dir: TempDir,
2308 img: ImageBuffer,
2309 truth: TruthWcs,
2310 sky: Vec<SkyStar>,
2312 }
2313
2314 fn scene(truth: TruthWcs, db: Db, n_in_frame: usize, seed: u64) -> Scene {
2317 let mut rng = Rng::new(seed);
2318 let scale_deg = truth.cd[1].hypot(truth.cd[3]);
2319 let (w_deg, h_deg) = (
2320 truth.width as f64 * scale_deg,
2321 truth.height as f64 * scale_deg,
2322 );
2323 let side = 6.0 * w_deg.max(h_deg);
2324 let sky = random_sky(
2325 &mut rng,
2326 &SkySpec {
2327 ra0: truth.ra0,
2328 dec0: truth.dec0,
2329 side_deg: side,
2330 n: (n_in_frame as f64 * side * side / (w_deg * h_deg)) as usize,
2331 min_sep_deg: 12.0 * scale_deg,
2332 mag_lo: 10.0,
2333 mag_hi: 14.5,
2334 },
2335 );
2336 let sigma = 1.3 * 5.0 / (scale_deg * 3600.0);
2338 let img = render(
2339 &truth,
2340 &sky,
2341 sigma.max(1.3),
2342 1000.0,
2343 8.0,
2344 30_000.0,
2345 &mut rng,
2346 );
2347 let dir = TempDir::new("solve");
2348 match db {
2349 Db::Areas1476 => write_1476_db(dir.path(), "t50", &sky),
2350 Db::Areas290 => write_290_db(dir.path(), "t50", &sky),
2351 Db::AllSky001 => write_001_db(dir.path(), "t50", &sky),
2352 }
2353 Scene {
2354 dir,
2355 img,
2356 truth,
2357 sky,
2358 }
2359 }
2360
2361 fn params_for_blank() -> SolveParams {
2363 SolveParams {
2364 ra_hint: 0.0,
2365 dec_hint: 0.0,
2366 fov: deg(1.0),
2367 search_radius: 0.0,
2368 quad_tolerance: 0.007,
2369 hfd_min: 1.5,
2370 max_stars: 500,
2371 db_path: std::path::PathBuf::from("/nonexistent"),
2372 db_name: "d50".into(),
2373 binning: 1,
2374 method: SolveMethod::Quads,
2375 threads: 1,
2376 speed: SearchSpeed::Auto,
2377 }
2378 }
2379
2380 fn params_for(s: &Scene, ra_hint: f64, dec_hint: f64) -> SolveParams {
2381 SolveParams {
2382 ra_hint,
2383 dec_hint,
2384 fov: (s.truth.height as f64 * s.truth.cd[1].hypot(s.truth.cd[3])).to_radians(),
2385 search_radius: deg(2.0),
2386 quad_tolerance: 0.007,
2387 hfd_min: 1.5,
2388 max_stars: 500,
2389 db_path: s.dir.path().to_path_buf(),
2390 db_name: "t50".into(),
2391 binning: 1,
2392 method: SolveMethod::Quads,
2393 threads: 1,
2394 speed: SearchSpeed::Auto,
2395 }
2396 }
2397
2398 fn assert_solved(s: &Scene, wcs: &WcsSolution, tol_arcsec: f64) {
2399 let err = s.truth.max_error_arcsec(wcs);
2400 assert!(
2401 err < tol_arcsec,
2402 "worst centre/corner error {err:.3}\" (matched {}, rms {:.3})",
2403 wcs.stars_matched,
2404 wcs.residual_rms
2405 );
2406 assert!(wcs.stars_matched >= 10);
2407 let scale_arcsec = s.truth.cd[1].hypot(s.truth.cd[3]) * 3600.0;
2409 assert!(
2410 wcs.residual_rms < 0.3 * scale_arcsec,
2411 "rms {}",
2412 wcs.residual_rms
2413 );
2414 assert!(wcs.raw_matches > 0);
2415 assert_matches_agree(wcs, 0.3, 1.0);
2416 assert!(wcs.mag_limit > 10.0 && wcs.mag_limit <= 14.5);
2417 let truth_det = s.truth.cd[0] * s.truth.cd[3] - s.truth.cd[1] * s.truth.cd[2];
2420 assert!(
2421 (wcs.cdelt1 > 0.0) == (truth_det > 0.0) && wcs.cdelt2 > 0.0,
2422 "CDELT sign convention"
2423 );
2424 }
2425
2426 fn assert_matches_agree(wcs: &WcsSolution, rms_px: f64, binning: f64) {
2429 assert_eq!(wcs.matched_stars.len(), wcs.stars_matched);
2430 assert!(wcs.sip.is_none(), "solve_image never fits SIP");
2431 let tan = crate::wcs::TanWcs::from(wcs);
2432 let mut sq = 0.0;
2433 for m in &wcs.matched_stars {
2434 let (x, y) = tan.sky_to_pixel(m.ra, m.dec).unwrap();
2435 let d = (x - m.x).hypot(y - m.y);
2436 assert!(
2437 d < VERIFY_RADII[VERIFY_RADII.len() - 1] * binning,
2438 "pair at ({:.2},{:.2}) projects to ({x:.2},{y:.2})",
2439 m.x,
2440 m.y
2441 );
2442 sq += d * d;
2443 }
2444 let rms = (sq / wcs.matched_stars.len() as f64).sqrt();
2445 assert!(rms < rms_px, "pair rms {rms} px");
2446 }
2447
2448 #[test]
2449 fn solves_a_1476_database_from_an_offset_hint() {
2450 let truth = TruthWcs::new(deg(84.3), deg(-5.2), 5.0, 23.0, false, 400, 320);
2451 let s = scene(truth, Db::Areas1476, 130, 1);
2452 let mut p = params_for(&s, deg(84.3 + 0.6), deg(-5.2 - 0.45));
2454 p.threads = 4;
2455 let wcs = solve_image(&s.img, &p).expect("solve");
2456 assert_solved(&s, &wcs, 1.0);
2457 assert!(wcs.search_dist_deg > 0.1, "solved at the hint itself?");
2458 assert!(wcs.step_distances.len() > 1);
2459 assert!((wcs.cdelt2 * 3600.0 - 5.0).abs() < 0.01, "{}", wcs.cdelt2);
2461 assert!((wcs.crota2 + 23.0).abs() < 0.05, "crota2 {}", wcs.crota2);
2465 assert!(
2466 (wcs.crota1() + 23.0).abs() < 0.05,
2467 "crota1 {}",
2468 wcs.crota1()
2469 );
2470 assert!(wcs.cdelt1 < 0.0, "an unmirrored image has CDELT1 < 0");
2471 }
2472
2473 #[test]
2474 fn a_cancelled_token_stops_the_search() {
2475 use crate::cancel::{CancelToken, with_token};
2476 let truth = TruthWcs::new(deg(84.3), deg(-5.2), 5.0, 23.0, false, 400, 320);
2477 let s = scene(truth, Db::Areas1476, 130, 1);
2478 let p = params_for(&s, deg(84.3 + 0.6), deg(-5.2 - 0.45));
2479
2480 let token = CancelToken::new();
2481 token.cancel();
2482 let r = with_token(&token, || solve_image(&s.img, &p));
2483 assert!(matches!(r, Err(ArcsecError::Cancelled)), "{r:?}");
2484
2485 let polls = alloc::sync::Arc::new(core::sync::atomic::AtomicUsize::new(0));
2489 let n = alloc::sync::Arc::clone(&polls);
2490 let token = CancelToken::with_poll(move || {
2491 n.fetch_add(1, core::sync::atomic::Ordering::Relaxed) >= 1
2492 });
2493 let mut p4 = p.clone();
2494 p4.threads = 4;
2495 let r = with_token(&token, || solve_image(&s.img, &p4));
2496 assert!(matches!(r, Err(ArcsecError::Cancelled)), "{r:?}");
2497
2498 let wcs = with_token(&CancelToken::new(), || solve_image(&s.img, &p)).expect("solve");
2500 assert_solved(&s, &wcs, 1.0);
2501 }
2502
2503 #[test]
2504 fn the_auto_plan_solves_a_synthetic_field() {
2505 use crate::auto::{Plan, SolveRequest};
2506 let truth = TruthWcs::new(deg(84.3), deg(-5.2), 5.0, 23.0, false, 400, 320);
2507 let s = scene(truth, Db::Areas1476, 130, 1);
2508 let req = SolveRequest {
2509 hint: Some((deg(84.3 + 0.3), deg(-5.2))),
2510 pixel_scale: Some(5.0),
2511 search_radius: deg(2.0),
2512 db_path: Some(s.dir.path().to_path_buf()),
2513 db_name: Some("t50".into()),
2514 threads: 2,
2515 sip: true,
2516 ..SolveRequest::default()
2517 };
2518 let plan = Plan::new(&req, s.img.width, s.img.height).unwrap();
2519 assert_eq!(plan.binning, 1);
2520 let solved = plan.solve(&s.img).expect("solve");
2521 let mut wcs = solved.wcs;
2522 wcs.sip = None;
2524 assert_solved(&s, &wcs, 1.0);
2525 assert!(solved.index_estimate.is_none());
2526 }
2527
2528 fn scale_plan(
2531 s: &Scene,
2532 req: crate::auto::SolveRequest,
2533 ) -> (crate::auto::Plan, crate::auto::SolveRequest) {
2534 let req = crate::auto::SolveRequest {
2535 hint: Some((deg(84.3 + 0.2), deg(-5.2))),
2536 search_radius: deg(0.5),
2537 db_path: Some(s.dir.path().to_path_buf()),
2538 db_name: Some("t50".into()),
2539 threads: 4,
2540 ..req
2541 };
2542 (
2543 crate::auto::Plan::new(&req, s.img.width, s.img.height).unwrap(),
2544 req,
2545 )
2546 }
2547
2548 #[test]
2549 fn an_unknown_scale_is_found_by_the_ladder() {
2550 use crate::auto::{ScaleSearch, SolveRequest};
2551 let truth = TruthWcs::new(deg(84.3), deg(-5.2), 5.0, 23.0, false, 400, 320);
2552 let s = scene(truth, Db::Areas1476, 130, 1);
2553 let (plan, req) = scale_plan(&s, SolveRequest::default());
2554 assert!(!plan.scale_known && plan.searches_scales());
2555 assert!((plan.arcsec_per_px - 1.0).abs() < 1e-12, "1\"/px assumed");
2556 let wcs = plan.solve(&s.img).expect("the ladder finds 5\"/px").wcs;
2557 assert_solved(&s, &wcs, 1.0);
2558 assert_eq!(
2560 plan.scale_warning(&wcs).as_deref(),
2561 Some("Warning scale was inaccurate! Set FOV=0.44d, scale=5.0\"")
2562 );
2563
2564 let (never, _) = scale_plan(
2566 &s,
2567 SolveRequest {
2568 scale_search: ScaleSearch::Never,
2569 ..req
2570 },
2571 );
2572 assert!(!never.searches_scales());
2573 assert!(never.solve(&s.img).is_err());
2574 }
2575
2576 #[test]
2577 fn a_wrong_scale_is_searched_only_when_asked() {
2578 use crate::auto::{ScaleSearch, SolveRequest};
2579 let truth = TruthWcs::new(deg(84.3), deg(-5.2), 5.0, 23.0, false, 400, 320);
2580 let s = scene(truth, Db::Areas1476, 130, 1);
2581 let wrong = SolveRequest {
2583 pixel_scale: Some(20.0),
2584 ..SolveRequest::default()
2585 };
2586 let (plan, req) = scale_plan(&s, wrong);
2587 assert!(plan.scale_known && !plan.searches_scales());
2588 assert!(
2589 plan.solve(&s.img).is_err(),
2590 "a given scale is authoritative"
2591 );
2592
2593 let (search, _) = scale_plan(
2594 &s,
2595 SolveRequest {
2596 scale_search: ScaleSearch::AlsoIfWrong,
2597 ..req
2598 },
2599 );
2600 assert!(search.searches_scales());
2601 let wcs = search.solve(&s.img).expect("the ladder reaches 5\"/px").wcs;
2602 assert_solved(&s, &wcs, 1.0);
2603 assert!(search.scale_warning(&wcs).is_some());
2604
2605 let (right, _) = scale_plan(
2607 &s,
2608 SolveRequest {
2609 pixel_scale: Some(5.0),
2610 scale_search: ScaleSearch::AlsoIfWrong,
2611 ..SolveRequest::default()
2612 },
2613 );
2614 let wcs = right.solve(&s.img).expect("solve").wcs;
2615 assert_eq!(right.scale_warning(&wcs), None);
2616 }
2617
2618 #[test]
2619 fn a_hypothetical_scale_never_accepts_a_sparse_match() {
2620 let truth = TruthWcs::new(deg(84.3), deg(-5.2), 5.0, 23.0, false, 400, 320);
2621 let s = scene(truth, Db::Areas1476, 130, 1);
2622 let p = params_for(&s, truth.ra0, truth.dec0);
2623 let mut v = Verified {
2624 plate: PlateConstants {
2625 a: 5.0,
2626 b: 0.0,
2627 c: 0.0,
2628 d: 0.0,
2629 e: 5.0,
2630 f: 0.0,
2631 },
2632 rms: 0.5,
2633 img_pos: (0..20).map(|i| (f64::from(i) * 20.0, 160.0)).collect(),
2634 cat_pos: vec![(0.0, 0.0); 20],
2635 chance: 0.1,
2636 };
2637 let trusted = Acceptance::new(20, &p, &s.img, ScaleTrust::Trusted);
2638 let hypothesis = Acceptance::new(20, &p, &s.img, ScaleTrust::Hypothesis);
2639 v.plate.a = 4.0;
2641 v.plate.e = 4.0;
2642 assert!(
2643 trusted.accepts(&v, 0.5),
2644 "a sparse match at the known scale"
2645 );
2646 assert!(!hypothesis.accepts(&v, 0.5));
2647 v.img_pos = (0..30).map(|i| (f64::from(i) * 13.0, 160.0)).collect();
2649 v.cat_pos = vec![(0.0, 0.0); 30];
2650 assert!(hypothesis.accepts(&v, 0.5));
2651 }
2652
2653 #[test]
2654 fn solves_a_mirrored_image_on_a_290_database() {
2655 let truth = TruthWcs::new(deg(201.0), deg(47.5), 6.0, 160.0, true, 360, 360);
2656 let s = scene(truth, Db::Areas290, 120, 2);
2657 let wcs = solve_image(&s.img, ¶ms_for(&s, truth.ra0, truth.dec0)).expect("solve");
2658 assert_solved(&s, &wcs, 1.0);
2659 assert!(wcs.search_dist_deg < 1e-9, "should solve at the hint");
2660 assert!(wcs.cd1_1 * wcs.cd2_2 - wcs.cd1_2 * wcs.cd2_1 > 0.0);
2662 }
2663
2664 #[test]
2665 fn solves_across_ra_zero_with_an_all_sky_001_database() {
2666 let truth = TruthWcs::new(deg(0.05), deg(21.0), 5.0, -70.0, false, 360, 300);
2668 let s = scene(truth, Db::AllSky001, 120, 3);
2669 let wcs = solve_image(&s.img, ¶ms_for(&s, truth.ra0, truth.dec0)).expect("solve");
2670 assert_solved(&s, &wcs, 1.0);
2671 }
2672
2673 #[test]
2674 fn solves_across_ra_zero_with_a_1476_database() {
2675 let truth = TruthWcs::new(deg(359.97), deg(-33.0), 5.0, 95.0, false, 360, 300);
2676 let s = scene(truth, Db::Areas1476, 120, 4);
2677 let wcs = solve_image(&s.img, ¶ms_for(&s, truth.ra0, truth.dec0)).expect("solve");
2678 assert_solved(&s, &wcs, 1.0);
2679 }
2680
2681 #[test]
2682 fn solves_a_field_near_the_celestial_pole() {
2683 let truth = TruthWcs::new(deg(40.0), deg(88.9), 5.0, 10.0, false, 360, 300);
2684 let s = scene(truth, Db::Areas1476, 120, 5);
2685 let wcs = solve_image(&s.img, ¶ms_for(&s, truth.ra0, truth.dec0)).expect("solve");
2686 assert_solved(&s, &wcs, 1.0);
2687 }
2688
2689 #[test]
2703 fn accuracy_does_not_depend_on_the_hint_offset() {
2704 let truth = TruthWcs::new(deg(150.0), deg(30.0), 15.0, 20.0, false, 360, 300);
2705 let s = scene(truth, Db::Areas1476, 120, 21);
2706 let off = 0.4;
2707 let p = params_for(&s, deg(150.0 + off / deg(30.0).cos()), deg(30.0 + off));
2708 let wcs = solve_image(&s.img, &p).expect("solve");
2709 assert!(wcs.search_dist_deg < 1e-9, "solved at the hint");
2710 let err = s.truth.max_error_arcsec(&wcs);
2711 assert!(
2712 err < 5.0,
2713 "worst corner error {err:.2}\" with a {off}° hint offset"
2714 );
2715 }
2716
2717 #[test]
2718 fn solves_with_the_tetra_method() {
2719 let truth = TruthWcs::new(deg(150.0), deg(2.0), 5.0, 45.0, false, 360, 300);
2720 let s = scene(truth, Db::Areas1476, 110, 6);
2721 let mut p = params_for(&s, truth.ra0, truth.dec0);
2722 p.method = SolveMethod::Tetra;
2723 let wcs = solve_image(&s.img, &p).expect("solve");
2724 assert_solved(&s, &wcs, 1.0);
2725 }
2726
2727 #[test]
2728 fn slow_speed_solves_from_an_offset_hint() {
2729 let truth = TruthWcs::new(deg(84.3), deg(-5.2), 5.0, 23.0, false, 400, 320);
2730 let s = scene(truth, Db::Areas1476, 130, 1);
2731 let mut p = params_for(&s, deg(84.3 + 0.6), deg(-5.2 - 0.45));
2732 p.speed = SearchSpeed::Slow;
2733 let wcs = solve_image(&s.img, &p).expect("solve");
2734 assert_solved(&s, &wcs, 1.0);
2735 }
2736
2737 #[test]
2738 fn binned_solve_is_reported_on_the_unbinned_pixel_grid() {
2739 let truth = TruthWcs::new(deg(10.0), deg(40.0), 2.5, 30.0, false, 720, 600);
2741 let s = scene(truth, Db::Areas1476, 120, 7);
2742 let binned = s.img.bin_image(2);
2743 assert_eq!((binned.width, binned.height), (360, 300));
2744 let mut p = params_for(&s, truth.ra0, truth.dec0);
2745 p.binning = 2;
2746 let wcs = solve_image(&binned, &p).expect("solve");
2747 assert!((wcs.crpix1 - 360.5).abs() < 1e-9, "crpix1 {}", wcs.crpix1);
2749 assert!((wcs.crpix2 - 300.5).abs() < 1e-9, "crpix2 {}", wcs.crpix2);
2750 assert!((wcs.cdelt2 * 3600.0 - 2.5).abs() < 0.01, "{}", wcs.cdelt2);
2751 let err = s.truth.max_error_arcsec(&wcs);
2752 assert!(err < 2.0, "worst corner error {err:.3}\"");
2753 assert_matches_agree(&wcs, 0.6, 2.0);
2755 }
2756
2757 #[test]
2758 fn the_star_limit_is_the_database_density_times_the_field_area() {
2759 let params = |fov_deg: f64, db: &str, max_stars: usize| SolveParams {
2760 fov: deg(fov_deg),
2761 max_stars,
2762 db_name: db.into(),
2763 ..params_for_blank()
2764 };
2765 let square = ImageBuffer::new(200, 200);
2766 let wide = ImageBuffer::new(400, 200);
2767 assert_eq!(density_star_limit(¶ms(0.2, "d80", 500), &square), 320);
2769 assert_eq!(density_star_limit(¶ms(0.2, "d80", 500), &wide), 160);
2771 assert_eq!(density_star_limit(¶ms(1.0, "d80", 500), &square), 500);
2773 assert_eq!(density_star_limit(¶ms(0.2, "d80", 100), &square), 100);
2774 assert_eq!(density_star_limit(¶ms(0.8, "g05", 500), &square), 320);
2776 assert_eq!(density_star_limit(¶ms(20.0, "w08", 500), &wide), 200);
2777 assert_eq!(density_star_limit(¶ms(0.1, "v17", 500), &square), 500);
2779 }
2780
2781 #[test]
2787 fn a_frame_deeper_than_the_database_solves_at_the_database_limit() {
2788 let truth = TruthWcs::new(deg(250.0), deg(36.0), 3.0, 12.0, false, 600, 500);
2790 let s = scene(truth, Db::Areas1476, 450, 31);
2791 let mut sky = s.sky.clone();
2794 sky.sort_by(|a, b| a.mag.total_cmp(&b.mag));
2795 sky.truncate(200 * 9);
2796 write_1476_db(s.dir.path(), "t02", &sky);
2797 write_1476_db(s.dir.path(), "t17", &sky);
2799
2800 let mut p = params_for(&s, truth.ra0, truth.dec0);
2801 p.fov = (600.0 * 3.0 / 3600.0_f64).to_radians();
2802 p.search_radius = 0.0;
2803 p.db_name = "t17".into();
2804 let wcs = solve_image(&s.img, &p).expect("every detection: the fallback solves");
2805 assert!(s.truth.max_error_arcsec(&wcs) < 2.0);
2806 p.db_name = "t02".into();
2807 let wcs = solve_image(&s.img, &p).expect("solve at the database limit");
2808 assert!(s.truth.max_error_arcsec(&wcs) < 2.0);
2809 }
2810
2811 #[test]
2812 fn min_verified_stars_relaxes_only_for_sparse_images() {
2813 for (n, want) in [
2814 (0, 10),
2815 (5, 10),
2816 (66, 10),
2817 (67, 11),
2818 (100, 15),
2819 (193, 29),
2820 (194, 30),
2821 (200, 30),
2822 (500, 30),
2823 (usize::MAX, 30),
2824 ] {
2825 assert_eq!(min_verified_stars(n), want, "{n} detections");
2826 }
2827 }
2828
2829 #[test]
2831 fn a_sparse_match_must_have_the_expected_scale_and_a_tight_fit() {
2832 let truth = known_plate(); let verified = |n: usize, rms_px: f64, scale: f64| {
2834 let mut plate = truth.clone();
2835 for c in [&mut plate.a, &mut plate.b, &mut plate.d, &mut plate.e] {
2836 *c *= scale;
2837 }
2838 Verified {
2839 plate,
2840 rms: rms_px * 3.2 * scale,
2841 img_pos: vec![(0.0, 0.0); n],
2842 cat_pos: vec![(0.0, 0.0); n],
2843 chance: 0.0,
2844 }
2845 };
2846 let accept = Acceptance {
2847 min_stars: 12,
2848 expected_scale: 3.2,
2849 trust: ScaleTrust::Trusted,
2850 };
2851 assert!(accept.accepts(&verified(30, 1.9, 1.36), 0.5));
2854 assert!(!accept.accepts(&verified(30, 2.1, 1.0), 0.5));
2855 assert!(accept.accepts(&verified(12, 0.3, 1.0), 0.5));
2857 assert!(accept.accepts(&verified(20, 0.49, 1.09), 0.5));
2858 assert!(accept.accepts(&verified(20, 0.49, 0.91), 0.5));
2859 assert!(!accept.accepts(&verified(20, 0.3, 1.11), 0.5));
2861 assert!(!accept.accepts(&verified(20, 0.3, 0.89), 0.5));
2862 assert!(!accept.accepts(&verified(29, 0.51, 1.0), 0.5));
2863 assert!(!accept.accepts(&verified(11, 0.1, 1.0), 0.5));
2864 assert!(!accept.accepts(&verified(20, 0.1, 1.0), 0.1));
2865 assert!(!accept.accepts(&verified(12, 2.9, 1.36), 0.5));
2867 }
2868
2869 #[test]
2873 fn a_sparse_frame_solves_at_the_hint_scale_only() {
2874 let truth = TruthWcs::new(deg(30.0), deg(-12.0), 5.0, 40.0, false, 360, 300);
2875 let s = scene(truth, Db::Areas1476, 150, 41);
2876 let mut bright = s.sky.clone();
2877 bright.sort_by(|a, b| a.mag.total_cmp(&b.mag));
2878 let bright: Vec<SkyStar> = bright
2879 .into_iter()
2880 .filter(|st| {
2881 s.truth
2882 .sky_to_pixel(st.ra, st.dec)
2883 .is_some_and(|(x, y)| (5.0..355.0).contains(&x) && (5.0..295.0).contains(&y))
2884 })
2885 .take(22)
2886 .collect();
2887 let mut rng = Rng::new(42);
2888 let img = render(&s.truth, &bright, 1.3, 1000.0, 8.0, 30_000.0, &mut rng);
2889 let mut p = params_for(&s, truth.ra0, truth.dec0);
2890 p.fov = (360.0 * 5.0 / 3600.0_f64).to_radians(); p.search_radius = 0.0;
2892 let wcs = solve_image(&img, &p).expect("sparse solve");
2893 assert!(
2894 wcs.stars_matched < MIN_VERIFIED_STARS,
2895 "{}",
2896 wcs.stars_matched
2897 );
2898 assert!(s.truth.max_error_arcsec(&wcs) < 2.0);
2899
2900 p.fov *= 1.2;
2901 assert!(matches!(
2902 solve_image(&img, &p),
2903 Err(ArcsecError::InsufficientQuads { .. })
2904 ));
2905 }
2906
2907 #[test]
2912 fn a_shallow_frame_matches_the_density_matched_catalogue_quads() {
2913 let truth = TruthWcs::new(deg(140.0), deg(55.0), 5.0, -25.0, true, 360, 300);
2914 let s = scene(truth, Db::Areas1476, 500, 51);
2915 let mut bright = s.sky.clone();
2916 bright.sort_by(|a, b| a.mag.total_cmp(&b.mag));
2917 let in_frame = |st: &SkyStar| {
2918 s.truth
2919 .sky_to_pixel(st.ra, st.dec)
2920 .is_some_and(|(x, y)| (0.0..360.0).contains(&x) && (0.0..300.0).contains(&y))
2921 };
2922 let n_frame = bright.iter().filter(|st| in_frame(st)).count();
2923 bright.truncate(bright.len() * 40 / n_frame.max(1));
2924 let mut rng = Rng::new(52);
2925 let img = render(&s.truth, &bright, 1.3, 1000.0, 8.0, 30_000.0, &mut rng);
2926 let mut p = params_for(&s, truth.ra0, truth.dec0);
2927 p.fov = (360.0 * 5.0 / 3600.0_f64).to_radians();
2928 p.search_radius = 0.0;
2929 let wcs = solve_image(&img, &p).expect("shallow solve");
2930 assert!(s.truth.max_error_arcsec(&wcs) < 2.0);
2931 }
2932
2933 fn fallback_only(img: &ImageBuffer, p: &SolveParams) -> Option<WcsSolution> {
2936 let bg = get_background(img, p.max_stars);
2937 let (stars, _, deep) =
2938 find_stars_and_deep(img, &bg, p.hfd_min, p.max_stars, SEEDED_MAX_STARS);
2939 let n = stars.len();
2940 let oversize = if n < 35 {
2941 2.0
2942 } else if n > 140 {
2943 1.0
2944 } else {
2945 2.0 * (35.0 / n as f64).sqrt()
2946 };
2947 let (quads, tris) = (crate::types::QuadList::default(), Default::default());
2948 let grid = QuadGrid::build(&quads, p.quad_tolerance);
2949 let ctx = SpiralCtx {
2950 params: p,
2951 img,
2952 stars: &stars,
2953 img_quads: &quads,
2954 img_grid: &grid,
2955 img_tris: &tris,
2956 nrstars_image: n,
2957 star_limit: p.max_stars,
2958 nrstars_required: (p.max_stars as f64 * oversize * oversize).round() as usize,
2959 oversize,
2960 min_quads: 3 + n / 140,
2961 step_size: p.fov,
2962 accept: Acceptance::new(n, p, img, ScaleTrust::Trusted),
2963 aspect: img.width.max(img.height) as f64 / img.width.min(img.height) as f64,
2964 cancel: None,
2965 };
2966 let o = seeded_fallback(&ctx, &deep)?;
2967 assert!(!o.refused);
2968 Some(derive_wcs(
2969 o.ra_db,
2970 o.dec_db,
2971 &o.verified.plate,
2972 img.width,
2973 img.height,
2974 ))
2975 }
2976
2977 #[test]
2980 fn the_seeded_fallback_solves_a_field_on_its_own() {
2981 for (mirrored, seed) in [(false, 71), (true, 72)] {
2982 let truth = TruthWcs::new(deg(201.0), deg(-43.0), 4.0, 61.0, mirrored, 800, 600);
2983 let s = scene(truth, Db::Areas1476, 400, seed);
2984 let fov = 800.0 * 4.0 / 3600.0;
2986 let mut p = params_for(&s, truth.ra0 + deg(0.3 * fov), truth.dec0 - deg(0.2 * fov));
2987 p.fov = deg(fov);
2988 let wcs = fallback_only(&s.img, &p).expect("the fallback solves");
2989 assert!(
2990 s.truth.max_error_arcsec(&wcs) < 2.0,
2991 "mirrored {mirrored}: {:.2}\"",
2992 s.truth.max_error_arcsec(&wcs)
2993 );
2994 }
2995 }
2996
2997 #[test]
3000 fn the_seeded_fallback_does_not_invent_a_field() {
3001 let truth = TruthWcs::new(deg(201.0), deg(-43.0), 4.0, 61.0, false, 800, 600);
3002 let s = scene(truth, Db::Areas1476, 400, 73);
3003 let mut p = params_for(&s, truth.ra0, truth.dec0 + deg(2.0));
3005 p.fov = deg(800.0 * 4.0 / 3600.0);
3006 assert!(fallback_only(&s.img, &p).is_none());
3007 }
3008
3009 #[test]
3012 fn a_verification_no_better_than_chance_is_refused() {
3013 let plate = known_plate();
3014 let v = |n: usize, chance: f64, rms_px: f64| Verified {
3015 plate: plate.clone(),
3016 rms: rms_px * 3.2,
3017 img_pos: vec![(0.0, 0.0); n],
3018 cat_pos: vec![(0.0, 0.0); n],
3019 chance,
3020 };
3021 assert!(!significant(&v(31, 17.8, 1.3)));
3023 assert!(!significant(&v(32, 14.7, 1.3)));
3024 assert!(significant(&v(121, 15.3, 0.65)));
3026 assert!(!significant(&v(30, 3.1, 4.4)));
3028 assert!(significant(&v(30, 0.0, 1.9)));
3030 }
3031
3032 fn distorted_scene(corner_px: f64, seed: u64) -> Scene {
3035 let truth = TruthWcs::new(deg(84.3), deg(-5.2), 10.0, 23.0, false, 1024, 768)
3036 .with_corner_distortion(corner_px);
3037 scene(truth, Db::Areas1476, 300, seed)
3038 }
3039
3040 fn sip_error_arcsec(s: &Scene, wcs: &WcsSolution) -> f64 {
3043 let tan = crate::wcs::TanWcs::from(wcs);
3044 let (w, h) = (s.truth.width as f64 - 1.0, s.truth.height as f64 - 1.0);
3045 let mut worst: f64 = 0.0;
3046 for (fx, fy) in [
3047 (0.5, 0.5),
3048 (0.0, 0.0),
3049 (1.0, 0.0),
3050 (0.0, 1.0),
3051 (1.0, 1.0),
3052 (0.5, 0.0),
3053 (0.0, 0.5),
3054 ] {
3055 let (x, y) = (w * fx, h * fy);
3056 let (ra_t, dec_t) = s.truth.pixel_to_sky(x, y);
3057 let (ra_s, dec_s) = tan.pixel_to_sky(x + 1.0, y + 1.0);
3058 let sep = crate::test_support::separation(ra_t, dec_t, ra_s, dec_s);
3059 worst = worst.max(sep.to_degrees() * 3600.0);
3060 }
3061 worst
3062 }
3063
3064 #[test]
3065 fn a_distorted_field_reports_the_best_linear_plate_over_the_frame() {
3066 for (hint_ra, hint_dec) in [(84.3, -5.2), (84.3 + 0.9, -5.2 - 0.7)] {
3071 let s = distorted_scene(30.0, 7);
3072 let wcs =
3073 solve_image(&s.img, ¶ms_for(&s, deg(hint_ra), deg(hint_dec))).expect("solve");
3074 let floor = s.truth.linear_floor_arcsec();
3075 let err = s.truth.max_error_arcsec(&wcs);
3076 assert!(floor > 80.0, "floor {floor:.1}\"");
3077 assert!(
3078 err < floor + 5.0,
3079 "corner error {err:.1}\" against a linear floor of {floor:.1}\""
3080 );
3081 assert!(wcs.sip.is_none(), "solve_image never fits SIP");
3082 assert!(wcs.stars_matched > 150, "{} stars", wcs.stars_matched);
3084 let mut with_sip = wcs.clone();
3085 with_sip.sip = crate::wcs::fit_sip(&wcs, 1024, 768);
3086 assert!(with_sip.sip.is_some(), "the distortion is significant");
3087 let sip_err = sip_error_arcsec(&s, &with_sip);
3088 assert!(sip_err < 3.0, "SIP error {sip_err:.2}\"");
3089 }
3090 }
3091
3092 fn part_empty_scene(corner_px: f64) -> Scene {
3095 let mut s = distorted_scene(corner_px, 7);
3096 let w = s.img.width;
3097 for y in 0..s.img.height {
3098 for x in (2 * w / 3)..w {
3099 s.img.data[y * w + x] = 1000.0;
3100 }
3101 }
3102 s
3103 }
3104
3105 #[test]
3106 fn strong_distortion_that_cannot_be_modelled_over_the_frame_is_refused() {
3107 let s = part_empty_scene(30.0);
3112 let r = solve_image(&s.img, ¶ms_for(&s, deg(84.3), deg(-5.2)));
3113 assert!(
3114 matches!(r, Err(ArcsecError::InsufficientQuads { .. })),
3115 "{:?}",
3116 r.map(|w| s.truth.max_error_arcsec(&w))
3117 );
3118 }
3119
3120 #[test]
3121 fn an_undistorted_field_with_an_empty_third_still_solves() {
3122 let s = part_empty_scene(0.0);
3123 let wcs = solve_image(&s.img, ¶ms_for(&s, deg(84.3), deg(-5.2))).expect("solve");
3124 assert_solved(&s, &wcs, 1.0);
3125 }
3126
3127 #[test]
3128 fn mild_distortion_is_modelled_too() {
3129 let s = distorted_scene(3.0, 11);
3132 let wcs = solve_image(&s.img, ¶ms_for(&s, deg(84.3), deg(-5.2))).expect("solve");
3133 let floor = s.truth.linear_floor_arcsec();
3134 let err = s.truth.max_error_arcsec(&wcs);
3135 assert!(
3136 err < floor + 2.0,
3137 "corner error {err:.1}\" against a floor of {floor:.1}\""
3138 );
3139 }
3140
3141 #[test]
3142 fn a_field_absent_from_the_catalogue_does_not_solve() {
3143 let truth = TruthWcs::new(deg(120.0), deg(-40.0), 5.0, 0.0, false, 360, 300);
3146 let s = scene(truth, Db::Areas1476, 120, 8);
3147 let decoy = TempDir::new("decoy");
3148 let mut rng = Rng::new(99);
3149 let other = random_sky(
3150 &mut rng,
3151 &SkySpec {
3152 ra0: truth.ra0,
3153 dec0: truth.dec0,
3154 side_deg: 3.0,
3155 n: 4000,
3156 min_sep_deg: 0.015,
3157 mag_lo: 10.0,
3158 mag_hi: 14.5,
3159 },
3160 );
3161 write_1476_db(decoy.path(), "t50", &other);
3162 let mut p = params_for(&s, truth.ra0, truth.dec0);
3163 p.db_path = decoy.path().to_path_buf();
3164 p.search_radius = deg(0.5);
3165 match solve_image(&s.img, &p) {
3166 Err(ArcsecError::InsufficientQuads { found: 0, required }) => {
3167 assert!(required >= 3);
3168 }
3169 other => panic!("expected InsufficientQuads, got {other:?}"),
3170 }
3171 }
3172
3173 #[test]
3174 fn a_corrupt_catalogue_tile_is_skipped_not_fatal() {
3175 let truth = TruthWcs::new(deg(120.0), deg(-40.0), 5.0, 0.0, false, 360, 300);
3176 let s = scene(truth, Db::Areas1476, 120, 9);
3177 for entry in std::fs::read_dir(s.dir.path()).unwrap() {
3179 let path = entry.unwrap().path();
3180 let mut bytes = std::fs::read(&path).unwrap();
3181 bytes[109] = 7;
3182 std::fs::write(&path, bytes).unwrap();
3183 }
3184 let mut p = params_for(&s, truth.ra0, truth.dec0);
3185 p.search_radius = 0.0;
3186 assert!(matches!(
3187 solve_image(&s.img, &p),
3188 Err(ArcsecError::InsufficientQuads { .. })
3189 ));
3190 }
3191
3192 #[test]
3193 fn a_blank_frame_reports_insufficient_stars() {
3194 let dir = TempDir::new("blank");
3195 write_1476_db(dir.path(), "t50", &[]);
3196 let mut rng = Rng::new(3);
3197 let img = ImageBuffer {
3198 data: (0..200 * 200)
3199 .map(|_| (1000.0 + 5.0 * rng.gauss()) as f32)
3200 .collect(),
3201 width: 200,
3202 height: 200,
3203 };
3204 let p = SolveParams {
3205 ra_hint: 0.0,
3206 dec_hint: 0.0,
3207 fov: deg(0.3),
3208 search_radius: deg(1.0),
3209 quad_tolerance: 0.007,
3210 hfd_min: 1.5,
3211 max_stars: 500,
3212 db_path: dir.path().to_path_buf(),
3213 db_name: "t50".into(),
3214 binning: 1,
3215 method: SolveMethod::Quads,
3216 threads: 1,
3217 speed: SearchSpeed::Auto,
3218 };
3219 match solve_image(&img, &p) {
3220 Err(ArcsecError::InsufficientStars { found, required: 5 }) => assert!(found < 5),
3221 other => panic!("expected InsufficientStars, got {other:?}"),
3222 }
3223 }
3224
3225 #[test]
3226 fn a_missing_database_is_reported_before_any_detection() {
3227 let dir = TempDir::new("nodb");
3228 let p = SolveParams {
3229 ra_hint: 0.0,
3230 dec_hint: 0.0,
3231 fov: deg(1.0),
3232 search_radius: deg(1.0),
3233 quad_tolerance: 0.007,
3234 hfd_min: 1.5,
3235 max_stars: 500,
3236 db_path: dir.path().to_path_buf(),
3237 db_name: "d50".into(),
3238 binning: 1,
3239 method: SolveMethod::Quads,
3240 threads: 1,
3241 speed: SearchSpeed::Auto,
3242 };
3243 match solve_image(&ImageBuffer::new(64, 64), &p) {
3244 Err(ArcsecError::CatalogNotFound(path)) => assert_eq!(path, dir.path()),
3245 other => panic!("expected CatalogNotFound, got {other:?}"),
3246 }
3247 }
3248
3249 #[test]
3250 fn solve_image_rejects_a_bad_search_radius_or_fov() {
3251 let base = SolveParams {
3252 ra_hint: 0.0,
3253 dec_hint: 0.0,
3254 fov: deg(1.0),
3255 search_radius: 0.1,
3256 quad_tolerance: 0.007,
3257 hfd_min: 1.5,
3258 max_stars: 500,
3259 db_path: std::path::PathBuf::from("/nonexistent"),
3260 db_name: "d50".into(),
3261 binning: 1,
3262 method: SolveMethod::Quads,
3263 threads: 1,
3264 speed: SearchSpeed::Auto,
3265 };
3266 let img = ImageBuffer::new(64, 64);
3267 for (fov, radius) in [
3268 (f64::NAN, 0.1),
3269 (-1.0, 0.1),
3270 (f64::INFINITY, 0.1),
3271 (0.01, -0.1),
3272 (0.01, f64::NAN),
3273 (0.01, f64::INFINITY),
3274 (1e-300, core::f64::consts::PI),
3277 (1e-7, core::f64::consts::PI),
3278 ] {
3279 let p = SolveParams {
3280 fov,
3281 search_radius: radius,
3282 ..base.clone()
3283 };
3284 assert!(
3285 matches!(solve_image(&img, &p), Err(ArcsecError::InvalidParameter(_))),
3286 "fov {fov}, radius {radius}"
3287 );
3288 }
3289 for quad_tolerance in [f64::NAN, -0.001, 0.11, 1e141, f64::INFINITY] {
3292 let p = SolveParams {
3293 quad_tolerance,
3294 ..base.clone()
3295 };
3296 assert!(
3297 matches!(solve_image(&img, &p), Err(ArcsecError::InvalidParameter(_))),
3298 "tolerance {quad_tolerance}"
3299 );
3300 }
3301 }
3302
3303 #[test]
3304 fn format_radec_roundtrip() {
3305 let s = format_radec(deg(160.875), deg(-59.524));
3306 assert!(s.contains("10:"), "RA hours: {s}");
3307 assert!(s.contains('-'), "dec sign: {s}");
3308 }
3309}