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_with_background;
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 TETRA_TOL_FACTOR, bijective_filter, build_quads, build_quads_presorted, build_triangles,
15 extract_star_pairs, extract_triangle_pairs, filter_by_scale, filter_triangles_by_scale,
16 find_matches_sorted, 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::SpiralSearch;
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
48#[derive(Debug, Clone)]
50pub struct SolveParams {
51 pub ra_hint: f64,
53 pub dec_hint: f64,
55 pub fov: f64,
59 pub search_radius: f64,
61 pub quad_tolerance: f64,
63 pub hfd_min: f64,
65 pub max_stars: usize,
67 pub db_path: PathBuf,
69 pub db_name: String,
71 pub binning: usize,
74 pub method: SolveMethod,
76 pub speed: SearchSpeed,
78 pub threads: usize,
85}
86
87fn sigma_clip_pairs(
94 mut img_pos: Vec<(f64, f64)>,
95 mut cat_pos: Vec<(f64, f64)>,
96 sigma: f64,
97 min_count: usize,
98) -> PairedPositions {
99 let mut first_pass = true;
100 for _ in 0..10 {
101 if img_pos.len() < min_count.max(3) {
102 break;
103 }
104 let Ok(plate) = fit_affine(&img_pos, &cat_pos) else {
108 break;
109 };
110 let residuals: Vec<f64> = img_pos
111 .iter()
112 .zip(cat_pos.iter())
113 .map(|(&(xi, yi), &(xc, yc))| {
114 let xp = plate.a * xi + plate.b * yi + plate.c;
115 let yp = plate.d * xi + plate.e * yi + plate.f;
116 ((xp - xc).powi(2) + (yp - yc).powi(2)).sqrt()
117 })
118 .collect();
119 let rms = (residuals.iter().map(|r| r * r).sum::<f64>() / residuals.len() as f64).sqrt();
120 let threshold = if first_pass {
121 first_pass = false;
122 let cdelt = (plate.a.powi(2) + plate.d.powi(2)).sqrt();
124 let mut sorted = residuals.clone();
129 sorted.sort_unstable_by(f64::total_cmp);
130 let median = sorted[sorted.len() / 2];
131 (10.0 * cdelt).max(10.0).max(3.0 * 1.4826 * median)
132 } else {
133 sigma * rms
134 };
135 let before = img_pos.len();
136 let mut new_img = Vec::with_capacity(before);
137 let mut new_cat = Vec::with_capacity(before);
138 for ((&ip, &cp), &r) in img_pos.iter().zip(cat_pos.iter()).zip(residuals.iter()) {
139 if r <= threshold {
140 new_img.push(ip);
141 new_cat.push(cp);
142 }
143 }
144 if new_img.len() == before {
145 break; }
147 img_pos = new_img;
148 cat_pos = new_cat;
149 }
150 (img_pos, cat_pos)
151}
152
153fn fit_pattern_pairs(
166 img_pos: Vec<(f64, f64)>,
167 cat_pos: Vec<(f64, f64)>,
168 min_count: usize,
169) -> Option<(PlateConstants, usize)> {
170 let (img_pos, cat_pos) = sigma_clip_pairs(img_pos, cat_pos, 3.0, min_count);
171 if img_pos.len() < min_count {
172 return None;
173 }
174 let plate = solve_plate_constants(&img_pos, &cat_pos).ok()?;
175 Some((plate, img_pos.len()))
176}
177
178const MIN_VERIFIED_STARS: usize = 30;
186fn min_verified_stars(nrstars_image: usize) -> usize {
194 nrstars_image
195 .saturating_mul(15)
196 .div_ceil(100)
197 .clamp(10, MIN_VERIFIED_STARS)
198}
199const RELAXED_SCALE_TOL: f64 = 0.10;
202const RELAXED_MAX_RMS_PX: f64 = 0.5;
207
208struct Acceptance {
210 min_stars: usize,
212 expected_scale: f64,
214}
215
216impl Acceptance {
217 fn new(nrstars_image: usize, params: &SolveParams, img: &crate::types::ImageBuffer) -> Self {
218 Self {
219 min_stars: min_verified_stars(nrstars_image),
220 expected_scale: params.fov.to_degrees() * 3600.0
221 / img.width.max(img.height).max(1) as f64,
222 }
223 }
224
225 fn accepts(&self, v: &Verified, spread: f64) -> bool {
231 if v.n() < self.min_stars || spread < MIN_VERIFY_SPREAD {
232 return false;
233 }
234 if v.n() >= MIN_VERIFIED_STARS {
235 return true;
236 }
237 let p = &v.plate;
238 let scale = (p.a * p.e - p.b * p.d).abs().sqrt();
239 let ok = (scale / self.expected_scale - 1.0).abs() <= RELAXED_SCALE_TOL
240 && v.rms <= RELAXED_MAX_RMS_PX * scale;
241 log::info!(
242 "{} stars verified, scale {:.4}\"/px against {:.4} expected, residual {:.2} px: {}",
243 v.n(),
244 scale,
245 self.expected_scale,
246 v.rms / scale,
247 if ok { "accepted" } else { "refused" }
248 );
249 ok
250 }
251}
252
253const VERIFY_RADII: [f64; 3] = [6.0, 3.0, 2.0];
255const MIN_VERIFY_SPREAD: f64 = 0.20;
262
263struct Verified {
266 plate: PlateConstants,
267 rms: f64,
268 img_pos: Vec<(f64, f64)>,
270 cat_pos: Vec<(f64, f64)>,
273}
274
275impl Verified {
276 fn n(&self) -> usize {
278 self.img_pos.len()
279 }
280}
281
282fn verify_and_refit(
295 img_stars: &StarList,
296 cat_stars: &StarList,
297 plate: &PlateConstants,
298 img_w: usize,
299 img_h: usize,
300 accept: &Acceptance,
301) -> Option<Verified> {
302 if img_stars.is_empty() || cat_stars.is_empty() {
303 return None;
304 }
305
306 let grid = StarGrid::new(img_stars, VERIFY_RADII[0])?;
308
309 let mut current = plate.clone();
310 let mut best: Option<(Verified, f64)> = None;
312
313 for &radius in &VERIFY_RADII {
314 let det = current.a * current.e - current.b * current.d;
315 if det.abs() < 1e-12 {
316 return None;
317 }
318 let r2 = radius * radius;
319
320 let mut img_pos: Vec<(f64, f64)> = Vec::new();
321 let mut cat_pos: Vec<(f64, f64)> = Vec::new();
322 let mut used = vec![false; img_stars.len()];
323
324 for cs in &cat_stars.0 {
325 let dx = cs.x - current.c;
327 let dy = cs.y - current.f;
328 let px = (current.e * dx - current.b * dy) / det;
329 let py = (-current.d * dx + current.a * dy) / det;
330 if !grid.near(px, py, radius) {
331 continue;
332 }
333 if let Some(i) = grid.nearest(px, py, r2, &used) {
334 used[i] = true; img_pos.push(grid.pos(i));
336 cat_pos.push((cs.x, cs.y));
337 }
338 }
339
340 if img_pos.len() < 4 {
341 break;
342 }
343 let Ok(refined) = solve_plate_constants(&img_pos, &cat_pos) else {
344 break;
345 };
346 let mut sq = 0.0;
347 for (&(xi, yi), &(xc, yc)) in img_pos.iter().zip(cat_pos.iter()) {
348 let xp = refined.a * xi + refined.b * yi + refined.c;
349 let yp = refined.d * xi + refined.e * yi + refined.f;
350 sq += (xp - xc).powi(2) + (yp - yc).powi(2);
351 }
352 let rms = (sq / img_pos.len() as f64).sqrt();
353 let spread = spread_of(&img_pos, img_w, img_h);
355 log::debug!(
356 "verify: {} stars, spread {:.3}, rms {:.2}\"",
357 img_pos.len(),
358 spread,
359 rms
360 );
361
362 current = refined.clone();
363 best = Some((
364 Verified {
365 plate: refined,
366 rms,
367 img_pos,
368 cat_pos,
369 },
370 spread,
371 ));
372 }
373
374 best.filter(|(v, spread)| accept.accepts(v, *spread))
375 .map(|(v, _)| v)
376}
377
378fn density_star_limit(params: &SolveParams, img: &crate::types::ImageBuffer) -> usize {
384 let Some(density) = crate::catalog::database_density(¶ms.db_name) else {
385 return params.max_stars;
386 };
387 let fov_deg = params.fov.to_degrees();
388 let (w, h) = (img.width as f64, img.height as f64);
389 let area = fov_deg * fov_deg * w.min(h) / w.max(h).max(1.0);
390 let cap = (density * area).round();
391 if cap < params.max_stars as f64 {
392 cap as usize
393 } else {
394 params.max_stars
395 }
396}
397
398struct SpiralCtx<'a> {
400 params: &'a SolveParams,
401 img: &'a crate::types::ImageBuffer,
402 stars: &'a StarList,
403 img_quads: &'a crate::types::QuadList,
404 img_tris: &'a crate::quads::TriangleList,
405 nrstars_image: usize,
406 star_limit: usize,
409 nrstars_required: usize,
410 oversize: f64,
411 min_quads: usize,
412 step_size: f64,
413 accept: Acceptance,
414 aspect: f64,
416}
417
418struct PositionOutcome {
420 idx: usize,
421 ra_db: f64,
422 dec_db: f64,
423 sep_deg: f64,
424 verified: Verified,
425 n_matched: usize,
426 n_raw: usize,
427 mag_limit: f64,
428 refused: bool,
431}
432
433struct PositionTry {
437 sep_deg: Option<f64>,
438 outcome: Option<PositionOutcome>,
439}
440
441impl PositionTry {
442 const NONE: Self = Self {
443 sep_deg: None,
444 outcome: None,
445 };
446}
447
448fn try_position(ctx: &SpiralCtx<'_>, idx: usize, sx: i32, sy: i32) -> PositionTry {
451 let params = ctx.params;
452 let step_size = ctx.step_size;
453
454 let dec_db_raw = params.dec_hint + step_size * sy as f64;
455 let (dec_db, flip) = if dec_db_raw > PI / 2.0 {
456 (PI - dec_db_raw, PI)
457 } else if dec_db_raw < -PI / 2.0 {
458 (-PI - dec_db_raw, PI)
459 } else {
460 (dec_db_raw, 0.0)
461 };
462
463 let extra = if dec_db > 0.0 {
464 step_size * 0.5
465 } else {
466 -step_size * 0.5
467 };
468 let ra_offset = step_size * sx as f64 / (dec_db - extra).cos();
469 if ra_offset > PI / 2.0 + step_size * 0.5 || ra_offset < -PI / 2.0 {
470 return PositionTry::NONE;
471 }
472
473 let ra_db = (flip + params.ra_hint + ra_offset).rem_euclid(2.0 * PI);
474 let sep = ang_sep(ra_db, dec_db, params.ra_hint, params.dec_hint);
475 if sep > params.search_radius + step_size / 2.0 {
476 return PositionTry::NONE;
477 }
478
479 let cat_raw = match read_catalog_stars(
482 ¶ms.db_path,
483 ¶ms.db_name,
484 ra_db,
485 dec_db,
486 params.fov * ctx.oversize,
487 ctx.nrstars_required,
488 ) {
489 Ok(v) if !v.is_empty() => v,
490 Ok(_) | Err(_) => return PositionTry::NONE,
491 };
492
493 let sep_deg = sep.to_degrees();
494 let mag_limit = cat_raw
495 .iter()
496 .map(|s| s.mag)
497 .fold(f64::NEG_INFINITY, f64::max);
498 log::info!(
499 "Search {}, [{},{}], position: {} Down to magn {:.1} {} database stars {} database quads to compare.",
500 idx,
501 sx,
502 sy,
503 format_radec(ra_db, dec_db),
504 mag_limit,
505 cat_raw.len(),
506 cat_raw.len(),
507 );
508
509 let mut cat_stars: Vec<Star> = cat_raw
510 .iter()
511 .map(|s| {
512 let (x, y) = equatorial_standard(ra_db, dec_db, s.ra, s.dec, 1.0);
513 Star {
514 x,
515 y,
516 snr: 1.0,
517 hfd: 2.0,
518 }
519 })
520 .collect();
521 cat_stars.sort_unstable_by(|a, b| a.x.total_cmp(&b.x));
522 let cat_star_list = StarList(cat_stars);
523
524 let failed = PositionTry {
525 sep_deg: Some(sep_deg),
526 outcome: None,
527 };
528
529 let (img_pos, cat_pos, n_raw) = match params.method {
530 SolveMethod::Quads => {
531 let mut cat_quads = build_quads_presorted(&cat_star_list, ctx.nrstars_image);
532 if ctx.nrstars_image < ctx.star_limit {
533 add_density_matched_quads(ctx, &cat_raw, ra_db, dec_db, &mut cat_quads);
534 }
535 if cat_quads.is_empty() {
536 return failed;
537 }
538 crate::quads::r#match::sort_catalog_quads(&mut cat_quads);
539 let raw = find_matches_sorted(ctx.img_quads, &cat_quads, params.quad_tolerance);
540 let n_raw = raw.len();
541 log::info!("Found {n_raw} references");
542 let mut filtered = vote_filter(ctx.img_quads, &cat_quads, &raw, params.quad_tolerance);
543 if filtered.len() < ctx.min_quads {
544 let (by_scale, _) = filter_by_scale(&raw, params.quad_tolerance);
545 if by_scale.len() > filtered.len() {
546 filtered = by_scale;
547 }
548 }
549 if filtered.len() < ctx.min_quads {
550 return failed;
551 }
552 let (ip, cp) = extract_star_pairs(ctx.img_quads, &cat_quads, &filtered);
553 (ip, cp, n_raw)
554 }
555 SolveMethod::Tetra => {
556 let cat_tris = build_triangles(&cat_star_list);
557 if cat_tris.is_empty() {
558 return failed;
559 }
560 let tol = params.quad_tolerance * TETRA_TOL_FACTOR;
561 let raw = find_triangle_matches(ctx.img_tris, &cat_tris, tol);
562 let n_raw = raw.len();
563 log::info!("Found {n_raw} triangle references");
564 let biject = bijective_filter(&raw, ctx.img_tris, &cat_tris);
565 let (filtered, _) = filter_triangles_by_scale(&biject, params.quad_tolerance);
566 if filtered.len() < ctx.min_quads {
567 return failed;
568 }
569 let (ip, cp) = extract_triangle_pairs(ctx.img_tris, &cat_tris, &filtered);
570 (ip, cp, n_raw)
571 }
572 };
573
574 let seeds = Seeds {
577 img: img_pos.clone(),
578 cat: cat_pos.clone(),
579 ra: ra_db,
580 dec: dec_db,
581 };
582 let Some((plate, n_matched)) = fit_pattern_pairs(img_pos, cat_pos, ctx.min_quads) else {
583 return failed;
584 };
585
586 let found = |verified, ra_db, dec_db, refused| PositionTry {
587 sep_deg: Some(sep_deg),
588 outcome: Some(PositionOutcome {
589 idx,
590 ra_db,
591 dec_db,
592 sep_deg,
593 verified,
594 n_matched,
595 n_raw,
596 mag_limit,
597 refused,
598 }),
599 };
600
601 let Some(verified) = verify_and_refit(
602 ctx.stars,
603 &cat_star_list,
604 &plate,
605 ctx.img.width,
606 ctx.img.height,
607 &ctx.accept,
608 ) else {
609 log::info!("Verification failed at this position; continuing search.");
610 if n_matched >= STRONG_VOTE
611 && let Some((verified, ra_c, dec_c)) =
612 second_chance(ctx, &cat_raw, &seeds, &plate, ra_db, dec_db)
613 {
614 return found(verified, ra_c, dec_c, false);
615 }
616 return failed;
617 };
618 log::info!(
619 "Verified {} stars against the catalogue, residual {:.2}\"",
620 verified.n(),
621 verified.rms
622 );
623
624 let (verified, ra_db, dec_db) = recentre(ctx, &cat_raw, verified, ra_db, dec_db);
625 match model_distortion(ctx, &cat_raw, &seeds, verified, ra_db, dec_db) {
626 Modelled::Linear(v) => found(v, ra_db, dec_db, false),
627 Modelled::Distorted(v, ra_c, dec_c) => found(v, ra_c, dec_c, false),
628 Modelled::Refused(v) => found(v, ra_db, dec_db, true),
629 }
630}
631
632const STRONG_VOTE: usize = 50;
637
638fn project(cat_raw: &[CatalogStar], ra: f64, dec: f64) -> StarList {
640 StarList(
641 cat_raw
642 .iter()
643 .map(|s| {
644 let (x, y) = equatorial_standard(ra, dec, s.ra, s.dec, 1.0);
645 Star {
646 x,
647 y,
648 snr: 1.0,
649 hfd: 2.0,
650 }
651 })
652 .collect(),
653 )
654}
655
656struct Seeds {
658 img: Vec<(f64, f64)>,
659 cat: Vec<(f64, f64)>,
660 ra: f64,
661 dec: f64,
662}
663
664impl Seeds {
665 fn in_plane(&self, ra: f64, dec: f64) -> Vec<Pair> {
667 self.img
668 .iter()
669 .zip(&self.cat)
670 .map(|(&i, &(x, y))| {
671 if ra == self.ra && dec == self.dec {
672 return (i, (x, y));
673 }
674 let (sra, sdec) = standard_equatorial(self.ra, self.dec, x, y, 1.0);
675 (i, equatorial_standard(ra, dec, sra, sdec, 1.0))
676 })
677 .collect()
678 }
679}
680
681fn fit_distortion(
684 ctx: &SpiralCtx<'_>,
685 cat_raw: &[CatalogStar],
686 seeds: &Seeds,
687 plate: &PlateConstants,
688 ra: f64,
689 dec: f64,
690) -> Option<Refined> {
691 let (w, h) = (ctx.img.width as f64, ctx.img.height as f64);
697 let (xs, ys) = (
698 plate.a * (w - 1.0) * 0.5 + plate.b * (h - 1.0) * 0.5 + plate.c,
699 plate.d * (w - 1.0) * 0.5 + plate.e * (h - 1.0) * 0.5 + plate.f,
700 );
701 let (ra_c, dec_c) = standard_equatorial(ra, dec, xs, ys, 1.0);
702 let window = w.hypot(h) / w.max(h);
703 let cat = match read_catalog_stars(
704 &ctx.params.db_path,
705 &ctx.params.db_name,
706 ra_c,
707 dec_c,
708 ctx.params.fov * window,
709 (ctx.params.max_stars as f64 * window * window).round() as usize,
710 ) {
711 Ok(v) if !v.is_empty() => project(&v, ra, dec),
712 _ => project(cat_raw, ra, dec),
713 };
714 let grid = StarGrid::new(ctx.stars, VERIFY_RADII[0])?;
715 let r = refine(
716 &grid,
717 &cat,
718 &seeds.in_plane(ra, dec),
719 plate,
720 ctx.img.width,
721 ctx.img.height,
722 VERIFY_RADII[VERIFY_RADII.len() - 1],
723 )?;
724 log::info!(
725 "Distortion model: {} terms, {} stars within {} px, rms {:.2} px, F {:.1} over linear, {} of 9 cells",
726 r.model.n_terms,
727 r.img_pos.len(),
728 VERIFY_RADII[VERIFY_RADII.len() - 1],
729 r.rms / r.model.scale(),
730 r.f_linear,
731 r.cells
732 );
733 Some(r)
734}
735
736fn linear_from_model(
740 ctx: &SpiralCtx<'_>,
741 r: &Refined,
742 ra: f64,
743 dec: f64,
744) -> Option<(Verified, f64, f64)> {
745 let (w, h) = (ctx.img.width, ctx.img.height);
746 let (xs, ys) = r
747 .model
748 .apply((w as f64 - 1.0) * 0.5, (h as f64 - 1.0) * 0.5);
749 let (ra_c, dec_c) = standard_equatorial(ra, dec, xs, ys, 1.0);
750 let moved = |(x, y): (f64, f64)| {
753 let (sra, sdec) = standard_equatorial(ra, dec, x, y, 1.0);
754 equatorial_standard(ra_c, dec_c, sra, sdec, 1.0)
755 };
756 let plate = best_linear(|x, y| moved(r.model.apply(x, y)), w, h)?;
757 let cat_pos = r.cat_pos.iter().map(|&p| moved(p)).collect();
758 Some((
759 Verified {
760 plate,
761 rms: r.rms,
762 img_pos: r.img_pos.clone(),
763 cat_pos,
764 },
765 ra_c,
766 dec_c,
767 ))
768}
769
770enum Modelled {
772 Linear(Verified),
774 Distorted(Verified, f64, f64),
776 Refused(Verified),
779}
780
781const MIN_REPORT_F: f64 = 30.0;
788
789const MIN_DEPARTURE_PX: f64 = 1.0;
793
794const REFUSE_F: f64 = 100.0;
797const REFUSE_DEPARTURE_PX: f64 = 3.0;
802
803fn model_distortion(
813 ctx: &SpiralCtx<'_>,
814 cat_raw: &[CatalogStar],
815 seeds: &Seeds,
816 verified: Verified,
817 ra: f64,
818 dec: f64,
819) -> Modelled {
820 let Some(r) = fit_distortion(ctx, cat_raw, seeds, &verified.plate, ra, dec) else {
821 return Modelled::Linear(verified);
822 };
823 let (w, h) = (ctx.img.width, ctx.img.height);
824 let departure = max_departure_px(&r.model, &verified.plate, w, h);
825 log::info!(
826 "Distortion: verified plate departs {departure:.2} px from the model; {} stars against {} verified",
827 r.img_pos.len(),
828 verified.n(),
829 );
830 let min_cells = if r.model.n_terms == 10 { 9 } else { 7 };
831 let usable = r.model.n_terms > 3
832 && r.cells >= min_cells
833 && r.f_linear >= MIN_REPORT_F
834 && r.img_pos.len() * 10 >= verified.n() * 9;
835 if usable {
836 if departure >= MIN_DEPARTURE_PX
837 && let Some((v, ra_c, dec_c)) = linear_from_model(ctx, &r, ra, dec)
838 {
839 log::info!("Reporting the linear plate closest to the distortion model.");
840 return Modelled::Distorted(v, ra_c, dec_c);
841 }
842 return Modelled::Linear(verified);
843 }
844 let (wide_f, wide_dep) = r.unmodelled();
845 log::info!("Where the stars are: a cubic with F {wide_f:.1}, {wide_dep:.2} px from the plate.");
846 if wide_f >= REFUSE_F && wide_dep >= REFUSE_DEPARTURE_PX {
847 log::info!(
848 "The field is distorted by {wide_dep:.1} px where it has stars, and the distortion \
849 cannot be modelled over the whole frame: refusing a linear solution."
850 );
851 return Modelled::Refused(verified);
852 }
853 Modelled::Linear(verified)
854}
855
856fn spread_of(img_pos: &[(f64, f64)], img_w: usize, img_h: usize) -> f64 {
859 let n = img_pos.len() as f64;
860 let mx = img_pos.iter().map(|p| p.0).sum::<f64>() / n;
861 let my = img_pos.iter().map(|p| p.1).sum::<f64>() / n;
862 let var = img_pos
863 .iter()
864 .map(|&(x, y)| (x - mx) * (x - mx) + (y - my) * (y - my))
865 .sum::<f64>()
866 / n;
867 let half_diag = 0.5 * ((img_w * img_w + img_h * img_h) as f64).sqrt();
868 var.sqrt() / half_diag
869}
870
871fn second_chance(
877 ctx: &SpiralCtx<'_>,
878 cat_raw: &[CatalogStar],
879 seeds: &Seeds,
880 plate: &PlateConstants,
881 ra: f64,
882 dec: f64,
883) -> Option<(Verified, f64, f64)> {
884 log::info!("Strong pattern match: retrying verification with a distortion model.");
885 let r = fit_distortion(ctx, cat_raw, seeds, plate, ra, dec)?;
886 if r.cells < if r.model.n_terms == 10 { 9 } else { 7 } {
888 log::info!("The distortion model's stars do not cover the frame.");
889 return None;
890 }
891 let probe = Verified {
892 plate: r.model.linear_part(),
893 rms: r.rms,
894 img_pos: r.img_pos.clone(),
895 cat_pos: r.cat_pos.clone(),
896 };
897 let spread = spread_of(&r.img_pos, ctx.img.width, ctx.img.height);
898 if !ctx.accept.accepts(&probe, spread) {
899 log::info!("The distortion model did not verify either.");
900 return None;
901 }
902 log::info!(
903 "Verified {} stars with the distortion model.",
904 r.img_pos.len()
905 );
906 linear_from_model(ctx, &r, ra, dec)
907}
908
909const DENSITY_MATCH_MIN_RATIO: f64 = 2.5;
917
918fn add_density_matched_quads(
934 ctx: &SpiralCtx<'_>,
935 cat_raw: &[CatalogStar],
936 ra_db: f64,
937 dec_db: f64,
938 cat_quads: &mut crate::types::QuadList,
939) {
940 let k = (ctx.nrstars_image as f64 * ctx.oversize * ctx.oversize * ctx.aspect).round() as usize;
941 if k < 5 || (k as f64) * DENSITY_MATCH_MIN_RATIO > cat_raw.len() as f64 {
942 return;
943 }
944 let mut sub: Vec<Star> = cat_raw[..k]
946 .iter()
947 .map(|s| {
948 let (x, y) = equatorial_standard(ra_db, dec_db, s.ra, s.dec, 1.0);
949 Star {
950 x,
951 y,
952 snr: 1.0,
953 hfd: 2.0,
954 }
955 })
956 .collect();
957 sub.sort_unstable_by(|a, b| a.x.total_cmp(&b.x));
958 let extra = build_quads_presorted(&StarList(sub), ctx.nrstars_image);
959 let key = |q: &crate::types::Quad| {
962 (
963 (q.center_x * 1000.0).round() as i64,
964 (q.center_y * 1000.0).round() as i64,
965 (q.d1 * 1000.0).round() as i64,
966 )
967 };
968 let seen: std::collections::HashSet<_> = cat_quads.0.iter().map(key).collect();
969 let before = cat_quads.len();
970 cat_quads
971 .0
972 .extend(extra.0.into_iter().filter(|q| !seen.contains(&key(q))));
973 log::info!(
974 "{} more database quads from its {k} brightest stars, the image's density.",
975 cat_quads.len() - before
976 );
977}
978
979fn recentre(
996 ctx: &SpiralCtx<'_>,
997 cat_raw: &[CatalogStar],
998 mut verified: Verified,
999 mut ra_db: f64,
1000 mut dec_db: f64,
1001) -> (Verified, f64, f64) {
1002 let (w, h) = (ctx.img.width as f64, ctx.img.height as f64);
1003 let (cx, cy) = ((w - 1.0) * 0.5, (h - 1.0) * 0.5);
1004 let apply =
1005 |p: &PlateConstants, x: f64, y: f64| (p.a * x + p.b * y + p.c, p.d * x + p.e * y + p.f);
1006
1007 for _ in 0..2 {
1008 let plate = &verified.plate;
1009 let (xs, ys) = apply(plate, cx, cy);
1010 if xs.hypot(ys) < 1e-3 {
1012 break;
1013 }
1014 let (ra0, dec0) = standard_equatorial(ra_db, dec_db, xs, ys, 1.0);
1015
1016 let det = plate.a * plate.e - plate.b * plate.d;
1022 if det.abs() < 1e-12 {
1023 break;
1024 }
1025 let r2 = VERIFY_RADII[0] * VERIFY_RADII[0];
1026 let mut used = vec![false; ctx.stars.len()];
1027 let mut img_pos = Vec::new();
1028 let mut new_pos = Vec::new();
1029 let mut cat = Vec::with_capacity(cat_raw.len());
1030 for s in cat_raw {
1031 let (nx, ny) = equatorial_standard(ra0, dec0, s.ra, s.dec, 1.0);
1032 cat.push(Star {
1033 x: nx,
1034 y: ny,
1035 snr: 1.0,
1036 hfd: 2.0,
1037 });
1038 let (ox, oy) = equatorial_standard(ra_db, dec_db, s.ra, s.dec, 1.0);
1039 let (dx, dy) = (ox - plate.c, oy - plate.f);
1040 let px = (plate.e * dx - plate.b * dy) / det;
1041 let py = (-plate.d * dx + plate.a * dy) / det;
1042 let nearest = ctx
1043 .stars
1044 .0
1045 .iter()
1046 .enumerate()
1047 .filter(|&(i, _)| !used[i])
1048 .map(|(i, st)| (i, (st.x - px).powi(2) + (st.y - py).powi(2)))
1049 .filter(|&(_, d2)| d2 < r2)
1050 .min_by(|a, b| a.1.total_cmp(&b.1));
1051 if let Some((i, _)) = nearest {
1052 used[i] = true;
1053 img_pos.push((ctx.stars.0[i].x, ctx.stars.0[i].y));
1054 new_pos.push((nx, ny));
1055 }
1056 }
1057 let Ok(guess) = solve_plate_constants(&img_pos, &new_pos) else {
1058 break;
1059 };
1060 let cat = StarList(cat);
1061 let Some(v) = verify_and_refit(
1062 ctx.stars,
1063 &cat,
1064 &guess,
1065 ctx.img.width,
1066 ctx.img.height,
1067 &ctx.accept,
1068 ) else {
1069 log::info!("Re-centring on the image centre did not verify; keeping the fit.");
1070 break;
1071 };
1072 log::info!(
1073 "Re-centred on the image centre: verified {} stars, residual {:.2}\"",
1074 v.n(),
1075 v.rms
1076 );
1077 (verified, ra_db, dec_db) = (v, ra0, dec0);
1078 }
1079 (verified, ra_db, dec_db)
1080}
1081
1082pub fn solve_image(img: &crate::types::ImageBuffer, params: &SolveParams) -> Result<WcsSolution> {
1100 if !(params.fov.is_finite() && params.fov > 0.0) {
1103 return Err(ArcsecError::InvalidParameter(format!(
1104 "field of view must be positive, got {} rad",
1105 params.fov
1106 )));
1107 }
1108 if !(params.search_radius.is_finite() && params.search_radius >= 0.0) {
1109 return Err(ArcsecError::InvalidParameter(format!(
1110 "search radius must be non-negative, got {} rad",
1111 params.search_radius
1112 )));
1113 }
1114
1115 if !crate::catalog::catalog_present(¶ms.db_path, ¶ms.db_name) {
1120 return Err(ArcsecError::CatalogNotFound(params.db_path.clone()));
1121 }
1122
1123 let bg = get_background(img, params.max_stars);
1125 log::info!("Start finding stars");
1126 let (stars, stars_raw) = find_stars_with_background(
1127 img,
1128 &bg,
1129 params.hfd_min,
1130 params.max_stars,
1131 img.width,
1132 img.height,
1133 );
1134 log::info!(
1135 "{} stars found of the requested {}. Background value is {:.0}. \
1136 Detection level used {:.0} above background. Star level is {:.0} above background. \
1137 Noise level is {:.0}",
1138 stars_raw,
1139 params.max_stars,
1140 bg.mean,
1141 bg.star_level,
1142 bg.star_level,
1143 bg.noise,
1144 );
1145 if stars_raw > params.max_stars {
1146 log::info!("Selecting the {} brightest stars only.", params.max_stars);
1147 }
1148
1149 let star_limit = density_star_limit(params, img);
1161 let mut stars = stars;
1162 if stars.len() > star_limit {
1163 stars.0.sort_by(|a, b| b.snr.total_cmp(&a.snr));
1164 stars.0.truncate(star_limit);
1165 log::info!(
1166 "Database limit for this field is {star_limit} stars; using the {star_limit} brightest."
1167 );
1168 }
1169
1170 let nrstars_image = stars.len();
1171 if nrstars_image < 5 {
1172 return Err(ArcsecError::InsufficientStars {
1173 found: nrstars_image,
1174 required: 5,
1175 });
1176 }
1177
1178 let img_quads = build_quads(&stars, nrstars_image);
1180 let nr_quads = img_quads.len();
1181
1182 let img_tris = if params.method == SolveMethod::Tetra {
1183 build_triangles(&stars)
1184 } else {
1185 crate::quads::TriangleList::default()
1186 };
1187
1188 let patterns_empty = match params.method {
1189 SolveMethod::Quads => nr_quads == 0,
1190 SolveMethod::Tetra => img_tris.is_empty(),
1191 };
1192 if patterns_empty {
1193 return Err(ArcsecError::InsufficientQuads {
1194 found: 0,
1195 required: 3,
1196 });
1197 }
1198
1199 let min_quads: usize = 3 + nrstars_image / 140;
1200
1201 let oversize: f64 = match params.speed {
1202 SearchSpeed::Auto if nrstars_image < 35 => 2.0,
1203 SearchSpeed::Auto if nrstars_image > 140 => 1.0,
1204 SearchSpeed::Auto => 2.0 * (35.0 / nrstars_image as f64).sqrt(),
1205 SearchSpeed::Slow => {
1208 let max_fov_deg = match crate::catalog::detect_layout(¶ms.db_path, ¶ms.db_name)
1209 {
1210 CatalogLayout::Areas1476 => 5.142_857_143_f64,
1211 CatalogLayout::Areas290 => 9.53,
1212 CatalogLayout::AllSky001 => 180.0,
1213 };
1214 2.0_f64.min(max_fov_deg.to_radians() / params.fov).max(1.0)
1215 }
1216 };
1217
1218 let nrstars_required = (params.max_stars as f64 * oversize * oversize).round() as usize;
1220 let step_size = params.fov;
1221 let fov_deg = step_size.to_degrees();
1222 let max_distance = (params.search_radius / step_size + 2.0) as i32;
1223
1224 log::info!(
1225 "{} stars, {} quads selected in the image. {} database stars, {} database quads required \
1226 for the {:.2}d square search window. Step size {:.2}d. Oversize {:.2}",
1227 nrstars_image,
1228 nr_quads,
1229 nrstars_required,
1230 nrstars_required,
1231 fov_deg * oversize,
1232 fov_deg,
1233 oversize,
1234 );
1235
1236 let ctx = SpiralCtx {
1244 params,
1245 img,
1246 stars: &stars,
1247 img_quads: &img_quads,
1248 img_tris: &img_tris,
1249 nrstars_image,
1250 star_limit,
1251 nrstars_required,
1252 oversize,
1253 min_quads,
1254 step_size,
1255 accept: Acceptance::new(nrstars_image, params, img),
1256 aspect: img.width.max(img.height) as f64 / img.width.min(img.height).max(1) as f64,
1257 };
1258
1259 let n_threads = if params.threads > 0 {
1260 params.threads
1261 } else {
1262 crate::max_threads()
1263 }
1264 .clamp(1, 64);
1265
1266 let positions: Vec<(i32, i32)> = SpiralSearch::new(max_distance).collect();
1267 let mut step_distances: Vec<f64> = Vec::new();
1268
1269 let mut winner: Option<PositionOutcome> = None;
1270 let mut start_idx = 0usize;
1271 while start_idx < positions.len() && winner.is_none() {
1272 let batch_len = if start_idx == 0 {
1275 1
1276 } else {
1277 n_threads.min(positions.len() - start_idx)
1278 };
1279 let batch = &positions[start_idx..start_idx + batch_len];
1280
1281 let tries: Vec<PositionTry> = if n_threads == 1 || batch.len() == 1 {
1282 batch
1283 .iter()
1284 .enumerate()
1285 .map(|(k, &(sx, sy))| try_position(&ctx, start_idx + k, sx, sy))
1286 .collect()
1287 } else {
1288 std::thread::scope(|scope| {
1289 let handles: Vec<_> = batch
1290 .iter()
1291 .enumerate()
1292 .map(|(k, &(sx, sy))| {
1293 let ctx = &ctx;
1294 scope.spawn(move || try_position(ctx, start_idx + k, sx, sy))
1295 })
1296 .collect();
1297 handles
1298 .into_iter()
1299 .map(|h| h.join().unwrap_or_else(|e| std::panic::resume_unwind(e)))
1303 .collect()
1304 })
1305 };
1306
1307 for t in tries {
1308 if let Some(d) = t.sep_deg {
1309 step_distances.push(d);
1310 }
1311 if let Some(o) = t.outcome
1312 && winner.as_ref().is_none_or(|w| o.idx < w.idx)
1313 {
1314 winner = Some(o);
1315 }
1316 }
1317
1318 start_idx += batch_len;
1319 }
1320
1321 if let Some(o) = winner.as_ref().filter(|o| o.refused) {
1322 log::info!(
1323 "No solution: the field at search position {} is too distorted for a linear plate.",
1324 o.idx
1325 );
1326 return Err(ArcsecError::InsufficientQuads {
1327 found: 0,
1328 required: min_quads,
1329 });
1330 }
1331 if let Some(o) = winner {
1332 log::info!(
1333 "{} of {} patterns selected matching within {:.3} tolerance.",
1334 o.n_matched,
1335 o.n_raw,
1336 params.quad_tolerance,
1337 );
1338
1339 let v = o.verified;
1340 let mut wcs = derive_wcs(o.ra_db, o.dec_db, &v.plate, img.width, img.height);
1341 let b = params.binning.max(1) as f64;
1344 wcs.matched_stars = v
1345 .img_pos
1346 .iter()
1347 .zip(&v.cat_pos)
1348 .map(|(&(x, y), &(sx, sy))| {
1349 let (ra, dec) = standard_equatorial(o.ra_db, o.dec_db, sx, sy, 1.0);
1350 MatchedStar {
1351 x: (x + 0.5) * b + 0.5,
1352 y: (y + 0.5) * b + 0.5,
1353 ra,
1354 dec,
1355 }
1356 })
1357 .collect();
1358 if params.binning > 1 {
1359 let b = params.binning as f64;
1360 wcs.crpix1 = (wcs.crpix1 - 0.5) * b + 0.5;
1361 wcs.crpix2 = (wcs.crpix2 - 0.5) * b + 0.5;
1362 wcs.cd1_1 /= b;
1363 wcs.cd1_2 /= b;
1364 wcs.cd2_1 /= b;
1365 wcs.cd2_2 /= b;
1366 wcs.cdelt1 /= b;
1367 wcs.cdelt2 /= b;
1368 }
1369 wcs.residual_rms = v.rms;
1370 wcs.stars_matched = v.n();
1371 wcs.raw_matches = o.n_raw;
1372 wcs.plate = v.plate;
1373 wcs.mag_limit = o.mag_limit;
1374 wcs.search_dist_deg = o.sep_deg;
1375 wcs.step_distances = step_distances;
1376 return Ok(wcs);
1377 }
1378
1379 Err(ArcsecError::InsufficientQuads {
1380 found: 0,
1381 required: min_quads,
1382 })
1383}
1384
1385#[must_use]
1388pub fn format_ra(ra_rad: f64) -> String {
1389 const TENTHS_PER_DAY: f64 = 24.0 * 36_000.0;
1394 let ra_tenths = ((ra_rad.to_degrees() / 15.0 * 36_000.0)
1395 .round()
1396 .rem_euclid(TENTHS_PER_DAY)) as u64;
1397 let h = ra_tenths / 36_000;
1398 let m = ra_tenths / 600 % 60;
1399 let s = ra_tenths % 600 / 10;
1400 let tenths = ra_tenths % 10;
1401 format!("{h:02}: {m:02} {s:02}.{tenths}")
1402}
1403
1404#[must_use]
1407pub fn format_dec(dec_rad: f64) -> String {
1408 let dec_deg = dec_rad.to_degrees();
1409 let sign = if dec_deg < 0.0 { '-' } else { '+' };
1410 let dec_secs = (dec_deg.abs() * 3600.0).round() as u64;
1411 let dd = dec_secs / 3600;
1412 let dm = dec_secs / 60 % 60;
1413 let ds = dec_secs % 60;
1414 format!("{sign}{dd:02}d {dm:02} {ds:02}")
1415}
1416
1417#[must_use]
1421pub fn format_radec(ra_rad: f64, dec_rad: f64) -> String {
1422 format!("{} {}", format_ra(ra_rad), format_dec(dec_rad))
1423}
1424
1425#[cfg(test)]
1426mod tests {
1427 use super::*;
1428 use crate::math::coords::{ang_sep, standard_equatorial};
1429 use crate::test_support::{
1430 Rng, SkySpec, SkyStar, TempDir, TruthWcs, random_sky, render, write_001_db, write_290_db,
1431 write_1476_db,
1432 };
1433 use crate::types::{ImageBuffer, PlateConstants};
1434 use crate::wcs::output::derive_wcs;
1435 use core::f64::consts::PI;
1436
1437 fn deg(d: f64) -> f64 {
1438 d * PI / 180.0
1439 }
1440
1441 fn make_test_scene(
1442 n_stars: usize,
1443 ra_center: f64,
1444 dec_center: f64,
1445 cdelt_arcsec: f64,
1446 width: usize,
1447 height: usize,
1448 ) -> (ImageBuffer, Vec<(f64, f64)>, PlateConstants) {
1449 let mut data = vec![100.0f32; width * height];
1450 let mut catalog_sky: Vec<(f64, f64)> = Vec::new();
1451 let stars_per_row = (n_stars as f64).sqrt().ceil() as usize;
1452 let spacing = 40.0;
1453 let cx = (width as f64 - 1.0) / 2.0;
1454 let cy = (height as f64 - 1.0) / 2.0;
1455 let a = cdelt_arcsec;
1456 let c = -a * cx;
1457 let e = cdelt_arcsec;
1458 let f_offset = -e * cy;
1459 let plate = PlateConstants {
1460 a,
1461 b: 0.0,
1462 c,
1463 d: 0.0,
1464 e,
1465 f: f_offset,
1466 };
1467 let mut count = 0;
1468 'outer: for row in 0..stars_per_row {
1469 for col in 0..stars_per_row {
1470 if count >= n_stars {
1471 break 'outer;
1472 }
1473 let px = 20.0 + col as f64 * spacing;
1474 let py = 20.0 + row as f64 * spacing;
1475 if px >= width as f64 - 20.0 || py >= height as f64 - 20.0 {
1476 continue;
1477 }
1478 let x_std = a * px + c;
1479 let y_std = e * py + f_offset;
1480 let (ra, dec) = standard_equatorial(ra_center, dec_center, x_std, y_std, 1.0);
1481 catalog_sky.push((ra, dec));
1482 let sigma = 2.0;
1483 let amp = 30000.0f32;
1484 for dy in -8i32..=8 {
1485 for dx in -8i32..=8 {
1486 let x = (px as i32 + dx) as usize;
1487 let y = (py as i32 + dy) as usize;
1488 if x < width && y < height {
1489 let r2 = (dx * dx + dy * dy) as f64 / (2.0 * sigma * sigma);
1490 data[y * width + x] += amp * (-r2).exp() as f32;
1491 }
1492 }
1493 }
1494 count += 1;
1495 }
1496 }
1497 let img = ImageBuffer {
1498 data,
1499 width,
1500 height,
1501 };
1502 (img, catalog_sky, plate)
1503 }
1504
1505 #[test]
1506 fn derive_wcs_recovers_position() {
1507 let ra_center = deg(45.0);
1508 let dec_center = deg(30.0);
1509 let (img, _cat, plate) = make_test_scene(16, ra_center, dec_center, 2.0, 300, 300);
1510 let wcs = derive_wcs(ra_center, dec_center, &plate, img.width, img.height);
1511 let sep_arcsec = ang_sep(wcs.ra0, wcs.dec0, ra_center, dec_center) * (180.0 / PI * 3600.0);
1512 assert!(sep_arcsec < 0.5, "centre offset = {sep_arcsec} arcsec");
1513 }
1514
1515 #[test]
1516 fn spiral_covers_origin_first() {
1517 assert_eq!(SpiralSearch::new(5).next(), Some((0, 0)));
1518 }
1519
1520 #[test]
1521 fn oversize_formula_limits() {
1522 for n in [10, 35, 70, 140, 200] {
1523 let ov: f64 = if n < 35 {
1524 2.0
1525 } else if n > 140 {
1526 1.0
1527 } else {
1528 2.0 * (35.0 / n as f64).sqrt()
1529 };
1530 assert!((1.0..=2.0).contains(&ov), "oversize={ov} for n={n}");
1531 }
1532 }
1533
1534 #[test]
1535 fn format_radec_carries_rounded_seconds() {
1536 let ra = deg((1.0 + 59.0 / 60.0 + 59.97 / 3600.0) * 15.0);
1538 let dec = deg(10.0 + 59.0 / 60.0 + 59.7 / 3600.0);
1540 assert_eq!(format_radec(ra, dec), "02: 00 00.0 +11d 00 00");
1541 let s = format_radec(deg(359.999_999_9), deg(-0.5));
1543 assert_eq!(s, "00: 00 00.0 -00d 30 00");
1544 assert_eq!(
1546 format_radec(deg((5.0 + 35.0 / 60.0 + 17.3 / 3600.0) * 15.0), deg(-5.39)),
1547 "05: 35 17.3 -05d 23 24"
1548 );
1549 }
1550
1551 #[test]
1556 fn ra_and_dec_are_formatted_as_astap_cli_prints_them() {
1557 let ra = deg(65.0); let dec = deg(35.0);
1559 assert_eq!(format_ra(ra), "04: 20 00.0");
1560 assert_eq!(format_dec(dec), "+35d 00 00");
1561 assert_eq!(format_radec(ra, dec), "04: 20 00.0 +35d 00 00");
1562 assert_eq!(
1563 format_radec(
1564 deg((13.0 + 7.0 / 60.0 + 9.25 / 3600.0) * 15.0),
1565 -deg(89.0 + 1.0 / 60.0 + 2.0 / 3600.0)
1566 ),
1567 "13: 07 09.3 -89d 01 02"
1568 );
1569 assert_eq!(format_dec(deg(-0.0001)), "-00d 00 00");
1570 }
1571
1572 #[test]
1573 fn solve_image_rejects_a_non_positive_fov() {
1574 let img = ImageBuffer::new(64, 64);
1575 let params = SolveParams {
1576 ra_hint: 0.0,
1577 dec_hint: 0.0,
1578 fov: 0.0,
1579 search_radius: 0.1,
1580 quad_tolerance: 0.007,
1581 hfd_min: 1.5,
1582 max_stars: 500,
1583 db_path: std::path::PathBuf::from("/nonexistent"),
1584 db_name: "d50".into(),
1585 binning: 1,
1586 method: SolveMethod::Quads,
1587 threads: 1,
1588 speed: SearchSpeed::Auto,
1589 };
1590 assert!(matches!(
1591 solve_image(&img, ¶ms),
1592 Err(ArcsecError::InvalidParameter(_))
1593 ));
1594 }
1595
1596 fn known_plate() -> PlateConstants {
1600 let (s, r) = (3.2_f64, 0.61_f64);
1601 PlateConstants {
1602 a: -s * r.cos(),
1603 b: s * r.sin(),
1604 c: 640.0,
1605 d: s * r.sin(),
1606 e: s * r.cos(),
1607 f: -512.0,
1608 }
1609 }
1610
1611 fn apply(p: &PlateConstants, (x, y): (f64, f64)) -> (f64, f64) {
1612 (p.a * x + p.b * y + p.c, p.d * x + p.e * y + p.f)
1613 }
1614
1615 fn plate_close(p: &PlateConstants, q: &PlateConstants, tol: f64) -> bool {
1616 [
1617 (p.a, q.a),
1618 (p.b, q.b),
1619 (p.c, q.c),
1620 (p.d, q.d),
1621 (p.e, q.e),
1622 (p.f, q.f),
1623 ]
1624 .iter()
1625 .all(|(u, v)| (u - v).abs() <= tol)
1626 }
1627
1628 const STRICT: Acceptance = Acceptance {
1631 min_stars: MIN_VERIFIED_STARS,
1632 expected_scale: 3.2,
1633 };
1634
1635 fn star_at(x: f64, y: f64) -> Star {
1636 Star {
1637 x,
1638 y,
1639 snr: 50.0,
1640 hfd: 2.5,
1641 }
1642 }
1643
1644 fn pairs_with_outliers(outlier: impl Fn(usize, (f64, f64)) -> (f64, f64)) -> PairedPositions {
1647 let plate = known_plate();
1648 let mut rng = Rng::new(7);
1649 let mut img = Vec::new();
1650 let mut cat = Vec::new();
1651 for _ in 0..40 {
1652 let p = (rng.range(0.0, 500.0), rng.range(0.0, 500.0));
1653 img.push(p);
1654 cat.push(apply(&plate, p));
1655 }
1656 for k in 0..5 {
1657 let p = (rng.range(0.0, 500.0), rng.range(0.0, 500.0));
1658 img.push(p);
1659 cat.push(outlier(k, apply(&plate, p)));
1660 }
1661 (img, cat)
1662 }
1663
1664 #[test]
1665 fn sigma_clip_pairs_rejects_outliers_and_keeps_the_rest() {
1666 let (img, cat) = pairs_with_outliers(|k, (x, y)| {
1668 let a = k as f64 * 1.3;
1669 (x + 100.0 * a.cos(), y + 100.0 * a.sin())
1670 });
1671 let (ci, cc) = sigma_clip_pairs(img, cat, 3.0, 3);
1672 assert_eq!(ci.len(), 40, "all and only the true pairs survive");
1673 let fit = solve_plate_constants(&ci, &cc).unwrap();
1674 assert!(plate_close(&fit, &known_plate(), 1e-6), "{fit:?}");
1675 }
1676
1677 #[test]
1685 fn sigma_clip_pairs_rejects_gross_outliers() {
1686 let (img, cat) =
1687 pairs_with_outliers(|k, _| (1000.0 + 150.0 * k as f64, -900.0 + 70.0 * k as f64));
1688 assert!(matches!(
1689 solve_plate_constants(&img, &cat),
1690 Err(ArcsecError::BadSolution { .. })
1691 ));
1692 let (ci, _) = sigma_clip_pairs(img, cat, 3.0, 3);
1693 assert_eq!(ci.len(), 40, "the five gross outliers should be clipped");
1694 }
1695
1696 #[test]
1700 fn fit_pattern_pairs_recovers_a_plate_the_plain_fit_refuses() {
1701 let (img, cat) =
1702 pairs_with_outliers(|k, _| (1000.0 + 150.0 * k as f64, -900.0 + 70.0 * k as f64));
1703 assert!(solve_plate_constants(&img, &cat).is_err());
1704 let (plate, n) = fit_pattern_pairs(img.clone(), cat.clone(), 3).expect("clipped fit");
1705 assert_eq!(n, 40);
1706 assert!(plate_close(&plate, &known_plate(), 1e-6), "{plate:?}");
1707 let (plate, n) = fit_pattern_pairs(img[..40].to_vec(), cat[..40].to_vec(), 3).unwrap();
1709 assert_eq!(n, 40);
1710 assert!(plate_close(&plate, &known_plate(), 1e-6));
1711 assert!(fit_pattern_pairs(img, cat, 41).is_none());
1713 }
1714
1715 #[test]
1716 fn sigma_clip_pairs_leaves_too_few_pairs_alone() {
1717 let img = vec![(0.0, 0.0), (1.0, 0.0)];
1718 let cat = vec![(5.0, 5.0), (9.0, 9.0)];
1719 let (ci, cc) = sigma_clip_pairs(img.clone(), cat.clone(), 3.0, 3);
1720 assert_eq!((ci, cc), (img, cat));
1721 }
1722
1723 #[test]
1724 fn verify_and_refit_recovers_the_plate_from_a_rough_guess() {
1725 let truth = known_plate();
1726 let mut rng = Rng::new(11);
1727 let mut img_stars = Vec::new();
1728 let mut cat_stars = Vec::new();
1729 for _ in 0..60 {
1730 let (x, y) = (rng.range(5.0, 395.0), rng.range(5.0, 295.0));
1731 img_stars.push(star_at(x, y));
1732 let (cx, cy) = apply(&truth, (x, y));
1733 cat_stars.push(star_at(cx, cy));
1734 }
1735 for k in 0..20 {
1737 let (cx, cy) = apply(&truth, (-300.0 - 10.0 * k as f64, 900.0));
1738 cat_stars.push(star_at(cx, cy));
1739 }
1740 let mut rough = truth.clone();
1742 rough.c += 2.0 * truth.a;
1743 rough.f += 2.0 * truth.e;
1744 rough.b += 0.01;
1745 let v = verify_and_refit(
1746 &StarList(img_stars),
1747 &StarList(cat_stars),
1748 &rough,
1749 400,
1750 300,
1751 &STRICT,
1752 )
1753 .expect("a correct plate must verify");
1754 assert_eq!(v.n(), 60);
1755 assert_eq!(v.cat_pos.len(), 60);
1756 assert!(v.rms < 1e-6, "rms {}", v.rms);
1757 assert!(plate_close(&v.plate, &truth, 1e-6), "{:?}", v.plate);
1758 for (&(x, y), &(cx, cy)) in v.img_pos.iter().zip(&v.cat_pos) {
1760 let (px, py) = apply(&truth, (x, y));
1761 assert!((px - cx).hypot(py - cy) < 1e-6);
1762 }
1763 }
1764
1765 #[test]
1766 fn verify_and_refit_rejects_too_few_or_clustered_matches() {
1767 let truth = known_plate();
1768 let mut rng = Rng::new(12);
1769 let build = |pts: &[(f64, f64)]| {
1770 let img = StarList(pts.iter().map(|&(x, y)| star_at(x, y)).collect());
1771 let cat = StarList(
1772 pts.iter()
1773 .map(|&p| apply(&truth, p))
1774 .map(|(x, y)| star_at(x, y))
1775 .collect(),
1776 );
1777 (img, cat)
1778 };
1779
1780 let few: Vec<_> = (0..20)
1782 .map(|_| (rng.range(0.0, 400.0), rng.range(0.0, 300.0)))
1783 .collect();
1784 let (img, cat) = build(&few);
1785 assert!(verify_and_refit(&img, &cat, &truth, 400, 300, &STRICT).is_none());
1786
1787 let clustered: Vec<_> = (0..80)
1789 .map(|_| (rng.range(0.0, 40.0), rng.range(0.0, 40.0)))
1790 .collect();
1791 let (img, cat) = build(&clustered);
1792 assert!(verify_and_refit(&img, &cat, &truth, 400, 300, &STRICT).is_none());
1793
1794 let spread: Vec<_> = (0..80)
1796 .map(|_| (rng.range(0.0, 400.0), rng.range(0.0, 300.0)))
1797 .collect();
1798 let (img, cat) = build(&spread);
1799 assert!(verify_and_refit(&img, &cat, &truth, 400, 300, &STRICT).is_some());
1800
1801 let empty = StarList::default();
1803 assert!(verify_and_refit(&empty, &cat, &truth, 400, 300, &STRICT).is_none());
1804 let mut singular = truth.clone();
1805 singular.a = 0.0;
1806 singular.b = 0.0;
1807 assert!(verify_and_refit(&img, &cat, &singular, 400, 300, &STRICT).is_none());
1808 }
1809
1810 #[derive(Clone, Copy)]
1813 enum Db {
1814 Areas1476,
1815 Areas290,
1816 AllSky001,
1817 }
1818
1819 struct Scene {
1821 dir: TempDir,
1822 img: ImageBuffer,
1823 truth: TruthWcs,
1824 sky: Vec<SkyStar>,
1826 }
1827
1828 fn scene(truth: TruthWcs, db: Db, n_in_frame: usize, seed: u64) -> Scene {
1831 let mut rng = Rng::new(seed);
1832 let scale_deg = truth.cd[1].hypot(truth.cd[3]);
1833 let (w_deg, h_deg) = (
1834 truth.width as f64 * scale_deg,
1835 truth.height as f64 * scale_deg,
1836 );
1837 let side = 6.0 * w_deg.max(h_deg);
1838 let sky = random_sky(
1839 &mut rng,
1840 &SkySpec {
1841 ra0: truth.ra0,
1842 dec0: truth.dec0,
1843 side_deg: side,
1844 n: (n_in_frame as f64 * side * side / (w_deg * h_deg)) as usize,
1845 min_sep_deg: 12.0 * scale_deg,
1846 mag_lo: 10.0,
1847 mag_hi: 14.5,
1848 },
1849 );
1850 let sigma = 1.3 * 5.0 / (scale_deg * 3600.0);
1852 let img = render(
1853 &truth,
1854 &sky,
1855 sigma.max(1.3),
1856 1000.0,
1857 8.0,
1858 30_000.0,
1859 &mut rng,
1860 );
1861 let dir = TempDir::new("solve");
1862 match db {
1863 Db::Areas1476 => write_1476_db(dir.path(), "t50", &sky),
1864 Db::Areas290 => write_290_db(dir.path(), "t50", &sky),
1865 Db::AllSky001 => write_001_db(dir.path(), "t50", &sky),
1866 }
1867 Scene {
1868 dir,
1869 img,
1870 truth,
1871 sky,
1872 }
1873 }
1874
1875 fn params_for_blank() -> SolveParams {
1877 SolveParams {
1878 ra_hint: 0.0,
1879 dec_hint: 0.0,
1880 fov: deg(1.0),
1881 search_radius: 0.0,
1882 quad_tolerance: 0.007,
1883 hfd_min: 1.5,
1884 max_stars: 500,
1885 db_path: std::path::PathBuf::from("/nonexistent"),
1886 db_name: "d50".into(),
1887 binning: 1,
1888 method: SolveMethod::Quads,
1889 threads: 1,
1890 speed: SearchSpeed::Auto,
1891 }
1892 }
1893
1894 fn params_for(s: &Scene, ra_hint: f64, dec_hint: f64) -> SolveParams {
1895 SolveParams {
1896 ra_hint,
1897 dec_hint,
1898 fov: (s.truth.height as f64 * s.truth.cd[1].hypot(s.truth.cd[3])).to_radians(),
1899 search_radius: deg(2.0),
1900 quad_tolerance: 0.007,
1901 hfd_min: 1.5,
1902 max_stars: 500,
1903 db_path: s.dir.path().to_path_buf(),
1904 db_name: "t50".into(),
1905 binning: 1,
1906 method: SolveMethod::Quads,
1907 threads: 1,
1908 speed: SearchSpeed::Auto,
1909 }
1910 }
1911
1912 fn assert_solved(s: &Scene, wcs: &WcsSolution, tol_arcsec: f64) {
1913 let err = s.truth.max_error_arcsec(wcs);
1914 assert!(
1915 err < tol_arcsec,
1916 "worst centre/corner error {err:.3}\" (matched {}, rms {:.3})",
1917 wcs.stars_matched,
1918 wcs.residual_rms
1919 );
1920 assert!(wcs.stars_matched >= 10);
1921 let scale_arcsec = s.truth.cd[1].hypot(s.truth.cd[3]) * 3600.0;
1923 assert!(
1924 wcs.residual_rms < 0.3 * scale_arcsec,
1925 "rms {}",
1926 wcs.residual_rms
1927 );
1928 assert!(wcs.raw_matches > 0);
1929 assert_matches_agree(wcs, 0.3, 1.0);
1930 assert!(wcs.mag_limit > 10.0 && wcs.mag_limit <= 14.5);
1931 assert!(
1932 wcs.cdelt1 < 0.0 && wcs.cdelt2 > 0.0,
1933 "CDELT sign convention"
1934 );
1935 }
1936
1937 fn assert_matches_agree(wcs: &WcsSolution, rms_px: f64, binning: f64) {
1940 assert_eq!(wcs.matched_stars.len(), wcs.stars_matched);
1941 assert!(wcs.sip.is_none(), "solve_image never fits SIP");
1942 let tan = crate::wcs::TanWcs::from(wcs);
1943 let mut sq = 0.0;
1944 for m in &wcs.matched_stars {
1945 let (x, y) = tan.sky_to_pixel(m.ra, m.dec).unwrap();
1946 let d = (x - m.x).hypot(y - m.y);
1947 assert!(
1948 d < VERIFY_RADII[VERIFY_RADII.len() - 1] * binning,
1949 "pair at ({:.2},{:.2}) projects to ({x:.2},{y:.2})",
1950 m.x,
1951 m.y
1952 );
1953 sq += d * d;
1954 }
1955 let rms = (sq / wcs.matched_stars.len() as f64).sqrt();
1956 assert!(rms < rms_px, "pair rms {rms} px");
1957 }
1958
1959 #[test]
1960 fn solves_a_1476_database_from_an_offset_hint() {
1961 let truth = TruthWcs::new(deg(84.3), deg(-5.2), 5.0, 23.0, false, 400, 320);
1962 let s = scene(truth, Db::Areas1476, 130, 1);
1963 let mut p = params_for(&s, deg(84.3 + 0.6), deg(-5.2 - 0.45));
1965 p.threads = 4;
1966 let wcs = solve_image(&s.img, &p).expect("solve");
1967 assert_solved(&s, &wcs, 1.0);
1968 assert!(wcs.search_dist_deg > 0.1, "solved at the hint itself?");
1969 assert!(wcs.step_distances.len() > 1);
1970 assert!((wcs.cdelt2 * 3600.0 - 5.0).abs() < 0.01, "{}", wcs.cdelt2);
1972 assert!((wcs.crota2 - 23.0).abs() < 0.05, "crota2 {}", wcs.crota2);
1973 }
1974
1975 #[test]
1976 fn solves_a_mirrored_image_on_a_290_database() {
1977 let truth = TruthWcs::new(deg(201.0), deg(47.5), 6.0, 160.0, true, 360, 360);
1978 let s = scene(truth, Db::Areas290, 120, 2);
1979 let wcs = solve_image(&s.img, ¶ms_for(&s, truth.ra0, truth.dec0)).expect("solve");
1980 assert_solved(&s, &wcs, 1.0);
1981 assert!(wcs.search_dist_deg < 1e-9, "should solve at the hint");
1982 assert!(wcs.cd1_1 * wcs.cd2_2 - wcs.cd1_2 * wcs.cd2_1 > 0.0);
1984 }
1985
1986 #[test]
1987 fn solves_across_ra_zero_with_an_all_sky_001_database() {
1988 let truth = TruthWcs::new(deg(0.05), deg(21.0), 5.0, -70.0, false, 360, 300);
1990 let s = scene(truth, Db::AllSky001, 120, 3);
1991 let wcs = solve_image(&s.img, ¶ms_for(&s, truth.ra0, truth.dec0)).expect("solve");
1992 assert_solved(&s, &wcs, 1.0);
1993 }
1994
1995 #[test]
1996 fn solves_across_ra_zero_with_a_1476_database() {
1997 let truth = TruthWcs::new(deg(359.97), deg(-33.0), 5.0, 95.0, false, 360, 300);
1998 let s = scene(truth, Db::Areas1476, 120, 4);
1999 let wcs = solve_image(&s.img, ¶ms_for(&s, truth.ra0, truth.dec0)).expect("solve");
2000 assert_solved(&s, &wcs, 1.0);
2001 }
2002
2003 #[test]
2004 fn solves_a_field_near_the_celestial_pole() {
2005 let truth = TruthWcs::new(deg(40.0), deg(88.9), 5.0, 10.0, false, 360, 300);
2006 let s = scene(truth, Db::Areas1476, 120, 5);
2007 let wcs = solve_image(&s.img, ¶ms_for(&s, truth.ra0, truth.dec0)).expect("solve");
2008 assert_solved(&s, &wcs, 1.0);
2009 }
2010
2011 #[test]
2025 fn accuracy_does_not_depend_on_the_hint_offset() {
2026 let truth = TruthWcs::new(deg(150.0), deg(30.0), 15.0, 20.0, false, 360, 300);
2027 let s = scene(truth, Db::Areas1476, 120, 21);
2028 let off = 0.4;
2029 let p = params_for(&s, deg(150.0 + off / deg(30.0).cos()), deg(30.0 + off));
2030 let wcs = solve_image(&s.img, &p).expect("solve");
2031 assert!(wcs.search_dist_deg < 1e-9, "solved at the hint");
2032 let err = s.truth.max_error_arcsec(&wcs);
2033 assert!(
2034 err < 5.0,
2035 "worst corner error {err:.2}\" with a {off}° hint offset"
2036 );
2037 }
2038
2039 #[test]
2040 fn solves_with_the_tetra_method() {
2041 let truth = TruthWcs::new(deg(150.0), deg(2.0), 5.0, 45.0, false, 360, 300);
2042 let s = scene(truth, Db::Areas1476, 110, 6);
2043 let mut p = params_for(&s, truth.ra0, truth.dec0);
2044 p.method = SolveMethod::Tetra;
2045 let wcs = solve_image(&s.img, &p).expect("solve");
2046 assert_solved(&s, &wcs, 1.0);
2047 }
2048
2049 #[test]
2050 fn slow_speed_solves_from_an_offset_hint() {
2051 let truth = TruthWcs::new(deg(84.3), deg(-5.2), 5.0, 23.0, false, 400, 320);
2052 let s = scene(truth, Db::Areas1476, 130, 1);
2053 let mut p = params_for(&s, deg(84.3 + 0.6), deg(-5.2 - 0.45));
2054 p.speed = SearchSpeed::Slow;
2055 let wcs = solve_image(&s.img, &p).expect("solve");
2056 assert_solved(&s, &wcs, 1.0);
2057 }
2058
2059 #[test]
2060 fn binned_solve_is_reported_on_the_unbinned_pixel_grid() {
2061 let truth = TruthWcs::new(deg(10.0), deg(40.0), 2.5, 30.0, false, 720, 600);
2063 let s = scene(truth, Db::Areas1476, 120, 7);
2064 let binned = s.img.bin_image(2);
2065 assert_eq!((binned.width, binned.height), (360, 300));
2066 let mut p = params_for(&s, truth.ra0, truth.dec0);
2067 p.binning = 2;
2068 let wcs = solve_image(&binned, &p).expect("solve");
2069 assert!((wcs.crpix1 - 360.5).abs() < 1e-9, "crpix1 {}", wcs.crpix1);
2071 assert!((wcs.crpix2 - 300.5).abs() < 1e-9, "crpix2 {}", wcs.crpix2);
2072 assert!((wcs.cdelt2 * 3600.0 - 2.5).abs() < 0.01, "{}", wcs.cdelt2);
2073 let err = s.truth.max_error_arcsec(&wcs);
2074 assert!(err < 2.0, "worst corner error {err:.3}\"");
2075 assert_matches_agree(&wcs, 0.6, 2.0);
2077 }
2078
2079 #[test]
2080 fn the_star_limit_is_the_database_density_times_the_field_area() {
2081 let params = |fov_deg: f64, db: &str, max_stars: usize| SolveParams {
2082 fov: deg(fov_deg),
2083 max_stars,
2084 db_name: db.into(),
2085 ..params_for_blank()
2086 };
2087 let square = ImageBuffer::new(200, 200);
2088 let wide = ImageBuffer::new(400, 200);
2089 assert_eq!(density_star_limit(¶ms(0.2, "d80", 500), &square), 320);
2091 assert_eq!(density_star_limit(¶ms(0.2, "d80", 500), &wide), 160);
2093 assert_eq!(density_star_limit(¶ms(1.0, "d80", 500), &square), 500);
2095 assert_eq!(density_star_limit(¶ms(0.2, "d80", 100), &square), 100);
2096 assert_eq!(density_star_limit(¶ms(0.8, "g05", 500), &square), 320);
2098 assert_eq!(density_star_limit(¶ms(20.0, "w08", 500), &wide), 200);
2099 assert_eq!(density_star_limit(¶ms(0.1, "v17", 500), &square), 500);
2101 }
2102
2103 #[test]
2107 fn a_frame_deeper_than_the_database_solves_at_the_database_limit() {
2108 let truth = TruthWcs::new(deg(250.0), deg(36.0), 3.0, 12.0, false, 600, 500);
2110 let s = scene(truth, Db::Areas1476, 450, 31);
2111 let mut sky = s.sky.clone();
2114 sky.sort_by(|a, b| a.mag.total_cmp(&b.mag));
2115 sky.truncate(200 * 9);
2116 write_1476_db(s.dir.path(), "t02", &sky);
2117 write_1476_db(s.dir.path(), "t17", &sky);
2119
2120 let mut p = params_for(&s, truth.ra0, truth.dec0);
2121 p.fov = (600.0 * 3.0 / 3600.0_f64).to_radians();
2122 p.search_radius = 0.0;
2123 p.db_name = "t17".into();
2124 assert!(
2125 solve_image(&s.img, &p).is_err(),
2126 "every detection: should not match"
2127 );
2128 p.db_name = "t02".into();
2129 let wcs = solve_image(&s.img, &p).expect("solve at the database limit");
2130 assert!(s.truth.max_error_arcsec(&wcs) < 2.0);
2131 }
2132
2133 #[test]
2134 fn min_verified_stars_relaxes_only_for_sparse_images() {
2135 for (n, want) in [
2136 (0, 10),
2137 (5, 10),
2138 (66, 10),
2139 (67, 11),
2140 (100, 15),
2141 (193, 29),
2142 (194, 30),
2143 (200, 30),
2144 (500, 30),
2145 (usize::MAX, 30),
2146 ] {
2147 assert_eq!(min_verified_stars(n), want, "{n} detections");
2148 }
2149 }
2150
2151 #[test]
2153 fn a_sparse_match_must_have_the_expected_scale_and_a_tight_fit() {
2154 let truth = known_plate(); let verified = |n: usize, rms_px: f64, scale: f64| {
2156 let mut plate = truth.clone();
2157 for c in [&mut plate.a, &mut plate.b, &mut plate.d, &mut plate.e] {
2158 *c *= scale;
2159 }
2160 Verified {
2161 plate,
2162 rms: rms_px * 3.2 * scale,
2163 img_pos: vec![(0.0, 0.0); n],
2164 cat_pos: vec![(0.0, 0.0); n],
2165 }
2166 };
2167 let accept = Acceptance {
2168 min_stars: 12,
2169 expected_scale: 3.2,
2170 };
2171 assert!(accept.accepts(&verified(30, 3.9, 1.36), 0.5));
2173 assert!(accept.accepts(&verified(12, 0.3, 1.0), 0.5));
2175 assert!(accept.accepts(&verified(20, 0.49, 1.09), 0.5));
2176 assert!(accept.accepts(&verified(20, 0.49, 0.91), 0.5));
2177 assert!(!accept.accepts(&verified(20, 0.3, 1.11), 0.5));
2179 assert!(!accept.accepts(&verified(20, 0.3, 0.89), 0.5));
2180 assert!(!accept.accepts(&verified(29, 0.51, 1.0), 0.5));
2181 assert!(!accept.accepts(&verified(11, 0.1, 1.0), 0.5));
2182 assert!(!accept.accepts(&verified(20, 0.1, 1.0), 0.1));
2183 assert!(!accept.accepts(&verified(12, 2.9, 1.36), 0.5));
2185 }
2186
2187 #[test]
2191 fn a_sparse_frame_solves_at_the_hint_scale_only() {
2192 let truth = TruthWcs::new(deg(30.0), deg(-12.0), 5.0, 40.0, false, 360, 300);
2193 let s = scene(truth, Db::Areas1476, 150, 41);
2194 let mut bright = s.sky.clone();
2195 bright.sort_by(|a, b| a.mag.total_cmp(&b.mag));
2196 let bright: Vec<SkyStar> = bright
2197 .into_iter()
2198 .filter(|st| {
2199 s.truth
2200 .sky_to_pixel(st.ra, st.dec)
2201 .is_some_and(|(x, y)| (5.0..355.0).contains(&x) && (5.0..295.0).contains(&y))
2202 })
2203 .take(22)
2204 .collect();
2205 let mut rng = Rng::new(42);
2206 let img = render(&s.truth, &bright, 1.3, 1000.0, 8.0, 30_000.0, &mut rng);
2207 let mut p = params_for(&s, truth.ra0, truth.dec0);
2208 p.fov = (360.0 * 5.0 / 3600.0_f64).to_radians(); p.search_radius = 0.0;
2210 let wcs = solve_image(&img, &p).expect("sparse solve");
2211 assert!(
2212 wcs.stars_matched < MIN_VERIFIED_STARS,
2213 "{}",
2214 wcs.stars_matched
2215 );
2216 assert!(s.truth.max_error_arcsec(&wcs) < 2.0);
2217
2218 p.fov *= 1.2;
2219 assert!(matches!(
2220 solve_image(&img, &p),
2221 Err(ArcsecError::InsufficientQuads { .. })
2222 ));
2223 }
2224
2225 #[test]
2230 fn a_shallow_frame_matches_the_density_matched_catalogue_quads() {
2231 let truth = TruthWcs::new(deg(140.0), deg(55.0), 5.0, -25.0, true, 360, 300);
2232 let s = scene(truth, Db::Areas1476, 500, 51);
2233 let mut bright = s.sky.clone();
2234 bright.sort_by(|a, b| a.mag.total_cmp(&b.mag));
2235 let in_frame = |st: &SkyStar| {
2236 s.truth
2237 .sky_to_pixel(st.ra, st.dec)
2238 .is_some_and(|(x, y)| (0.0..360.0).contains(&x) && (0.0..300.0).contains(&y))
2239 };
2240 let n_frame = bright.iter().filter(|st| in_frame(st)).count();
2241 bright.truncate(bright.len() * 40 / n_frame.max(1));
2242 let mut rng = Rng::new(52);
2243 let img = render(&s.truth, &bright, 1.3, 1000.0, 8.0, 30_000.0, &mut rng);
2244 let mut p = params_for(&s, truth.ra0, truth.dec0);
2245 p.fov = (360.0 * 5.0 / 3600.0_f64).to_radians();
2246 p.search_radius = 0.0;
2247 let wcs = solve_image(&img, &p).expect("shallow solve");
2248 assert!(s.truth.max_error_arcsec(&wcs) < 2.0);
2249 }
2250
2251 fn distorted_scene(corner_px: f64, seed: u64) -> Scene {
2254 let truth = TruthWcs::new(deg(84.3), deg(-5.2), 10.0, 23.0, false, 1024, 768)
2255 .with_corner_distortion(corner_px);
2256 scene(truth, Db::Areas1476, 300, seed)
2257 }
2258
2259 fn sip_error_arcsec(s: &Scene, wcs: &WcsSolution) -> f64 {
2262 let tan = crate::wcs::TanWcs::from(wcs);
2263 let (w, h) = (s.truth.width as f64 - 1.0, s.truth.height as f64 - 1.0);
2264 let mut worst: f64 = 0.0;
2265 for (fx, fy) in [
2266 (0.5, 0.5),
2267 (0.0, 0.0),
2268 (1.0, 0.0),
2269 (0.0, 1.0),
2270 (1.0, 1.0),
2271 (0.5, 0.0),
2272 (0.0, 0.5),
2273 ] {
2274 let (x, y) = (w * fx, h * fy);
2275 let (ra_t, dec_t) = s.truth.pixel_to_sky(x, y);
2276 let (ra_s, dec_s) = tan.pixel_to_sky(x + 1.0, y + 1.0);
2277 let sep = crate::test_support::separation(ra_t, dec_t, ra_s, dec_s);
2278 worst = worst.max(sep.to_degrees() * 3600.0);
2279 }
2280 worst
2281 }
2282
2283 #[test]
2284 fn a_distorted_field_reports_the_best_linear_plate_over_the_frame() {
2285 for (hint_ra, hint_dec) in [(84.3, -5.2), (84.3 + 0.9, -5.2 - 0.7)] {
2290 let s = distorted_scene(30.0, 7);
2291 let wcs =
2292 solve_image(&s.img, ¶ms_for(&s, deg(hint_ra), deg(hint_dec))).expect("solve");
2293 let floor = s.truth.linear_floor_arcsec();
2294 let err = s.truth.max_error_arcsec(&wcs);
2295 assert!(floor > 80.0, "floor {floor:.1}\"");
2296 assert!(
2297 err < floor + 5.0,
2298 "corner error {err:.1}\" against a linear floor of {floor:.1}\""
2299 );
2300 assert!(wcs.sip.is_none(), "solve_image never fits SIP");
2301 assert!(wcs.stars_matched > 150, "{} stars", wcs.stars_matched);
2303 let mut with_sip = wcs.clone();
2304 with_sip.sip = crate::wcs::fit_sip(&wcs, 1024, 768);
2305 assert!(with_sip.sip.is_some(), "the distortion is significant");
2306 let sip_err = sip_error_arcsec(&s, &with_sip);
2307 assert!(sip_err < 3.0, "SIP error {sip_err:.2}\"");
2308 }
2309 }
2310
2311 fn part_empty_scene(corner_px: f64) -> Scene {
2314 let mut s = distorted_scene(corner_px, 7);
2315 let w = s.img.width;
2316 for y in 0..s.img.height {
2317 for x in (2 * w / 3)..w {
2318 s.img.data[y * w + x] = 1000.0;
2319 }
2320 }
2321 s
2322 }
2323
2324 #[test]
2325 fn strong_distortion_that_cannot_be_modelled_over_the_frame_is_refused() {
2326 let s = part_empty_scene(30.0);
2331 let r = solve_image(&s.img, ¶ms_for(&s, deg(84.3), deg(-5.2)));
2332 assert!(
2333 matches!(r, Err(ArcsecError::InsufficientQuads { .. })),
2334 "{:?}",
2335 r.map(|w| s.truth.max_error_arcsec(&w))
2336 );
2337 }
2338
2339 #[test]
2340 fn an_undistorted_field_with_an_empty_third_still_solves() {
2341 let s = part_empty_scene(0.0);
2342 let wcs = solve_image(&s.img, ¶ms_for(&s, deg(84.3), deg(-5.2))).expect("solve");
2343 assert_solved(&s, &wcs, 1.0);
2344 }
2345
2346 #[test]
2347 fn mild_distortion_is_modelled_too() {
2348 let s = distorted_scene(3.0, 11);
2351 let wcs = solve_image(&s.img, ¶ms_for(&s, deg(84.3), deg(-5.2))).expect("solve");
2352 let floor = s.truth.linear_floor_arcsec();
2353 let err = s.truth.max_error_arcsec(&wcs);
2354 assert!(
2355 err < floor + 2.0,
2356 "corner error {err:.1}\" against a floor of {floor:.1}\""
2357 );
2358 }
2359
2360 #[test]
2361 fn a_field_absent_from_the_catalogue_does_not_solve() {
2362 let truth = TruthWcs::new(deg(120.0), deg(-40.0), 5.0, 0.0, false, 360, 300);
2365 let s = scene(truth, Db::Areas1476, 120, 8);
2366 let decoy = TempDir::new("decoy");
2367 let mut rng = Rng::new(99);
2368 let other = random_sky(
2369 &mut rng,
2370 &SkySpec {
2371 ra0: truth.ra0,
2372 dec0: truth.dec0,
2373 side_deg: 3.0,
2374 n: 4000,
2375 min_sep_deg: 0.015,
2376 mag_lo: 10.0,
2377 mag_hi: 14.5,
2378 },
2379 );
2380 write_1476_db(decoy.path(), "t50", &other);
2381 let mut p = params_for(&s, truth.ra0, truth.dec0);
2382 p.db_path = decoy.path().to_path_buf();
2383 p.search_radius = deg(0.5);
2384 match solve_image(&s.img, &p) {
2385 Err(ArcsecError::InsufficientQuads { found: 0, required }) => {
2386 assert!(required >= 3);
2387 }
2388 other => panic!("expected InsufficientQuads, got {other:?}"),
2389 }
2390 }
2391
2392 #[test]
2393 fn a_corrupt_catalogue_tile_is_skipped_not_fatal() {
2394 let truth = TruthWcs::new(deg(120.0), deg(-40.0), 5.0, 0.0, false, 360, 300);
2395 let s = scene(truth, Db::Areas1476, 120, 9);
2396 for entry in std::fs::read_dir(s.dir.path()).unwrap() {
2398 let path = entry.unwrap().path();
2399 let mut bytes = std::fs::read(&path).unwrap();
2400 bytes[109] = 7;
2401 std::fs::write(&path, bytes).unwrap();
2402 }
2403 let mut p = params_for(&s, truth.ra0, truth.dec0);
2404 p.search_radius = 0.0;
2405 assert!(matches!(
2406 solve_image(&s.img, &p),
2407 Err(ArcsecError::InsufficientQuads { .. })
2408 ));
2409 }
2410
2411 #[test]
2412 fn a_blank_frame_reports_insufficient_stars() {
2413 let dir = TempDir::new("blank");
2414 write_1476_db(dir.path(), "t50", &[]);
2415 let mut rng = Rng::new(3);
2416 let img = ImageBuffer {
2417 data: (0..200 * 200)
2418 .map(|_| (1000.0 + 5.0 * rng.gauss()) as f32)
2419 .collect(),
2420 width: 200,
2421 height: 200,
2422 };
2423 let p = SolveParams {
2424 ra_hint: 0.0,
2425 dec_hint: 0.0,
2426 fov: deg(0.3),
2427 search_radius: deg(1.0),
2428 quad_tolerance: 0.007,
2429 hfd_min: 1.5,
2430 max_stars: 500,
2431 db_path: dir.path().to_path_buf(),
2432 db_name: "t50".into(),
2433 binning: 1,
2434 method: SolveMethod::Quads,
2435 threads: 1,
2436 speed: SearchSpeed::Auto,
2437 };
2438 match solve_image(&img, &p) {
2439 Err(ArcsecError::InsufficientStars { found, required: 5 }) => assert!(found < 5),
2440 other => panic!("expected InsufficientStars, got {other:?}"),
2441 }
2442 }
2443
2444 #[test]
2445 fn a_missing_database_is_reported_before_any_detection() {
2446 let dir = TempDir::new("nodb");
2447 let p = SolveParams {
2448 ra_hint: 0.0,
2449 dec_hint: 0.0,
2450 fov: deg(1.0),
2451 search_radius: deg(1.0),
2452 quad_tolerance: 0.007,
2453 hfd_min: 1.5,
2454 max_stars: 500,
2455 db_path: dir.path().to_path_buf(),
2456 db_name: "d50".into(),
2457 binning: 1,
2458 method: SolveMethod::Quads,
2459 threads: 1,
2460 speed: SearchSpeed::Auto,
2461 };
2462 match solve_image(&ImageBuffer::new(64, 64), &p) {
2463 Err(ArcsecError::CatalogNotFound(path)) => assert_eq!(path, dir.path()),
2464 other => panic!("expected CatalogNotFound, got {other:?}"),
2465 }
2466 }
2467
2468 #[test]
2469 fn solve_image_rejects_a_bad_search_radius_or_fov() {
2470 let base = SolveParams {
2471 ra_hint: 0.0,
2472 dec_hint: 0.0,
2473 fov: deg(1.0),
2474 search_radius: 0.1,
2475 quad_tolerance: 0.007,
2476 hfd_min: 1.5,
2477 max_stars: 500,
2478 db_path: std::path::PathBuf::from("/nonexistent"),
2479 db_name: "d50".into(),
2480 binning: 1,
2481 method: SolveMethod::Quads,
2482 threads: 1,
2483 speed: SearchSpeed::Auto,
2484 };
2485 let img = ImageBuffer::new(64, 64);
2486 for (fov, radius) in [
2487 (f64::NAN, 0.1),
2488 (-1.0, 0.1),
2489 (f64::INFINITY, 0.1),
2490 (0.01, -0.1),
2491 (0.01, f64::NAN),
2492 (0.01, f64::INFINITY),
2493 ] {
2494 let p = SolveParams {
2495 fov,
2496 search_radius: radius,
2497 ..base.clone()
2498 };
2499 assert!(
2500 matches!(solve_image(&img, &p), Err(ArcsecError::InvalidParameter(_))),
2501 "fov {fov}, radius {radius}"
2502 );
2503 }
2504 }
2505
2506 #[test]
2507 fn format_radec_roundtrip() {
2508 let s = format_radec(deg(160.875), deg(-59.524));
2509 assert!(s.contains("10:"), "RA hours: {s}");
2510 assert!(s.contains('-'), "dec sign: {s}");
2511 }
2512}