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::spiral::SpiralSearch;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum SolveMethod {
26 #[default]
28 Quads,
29 Tetra,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum SearchSpeed {
36 #[default]
39 Auto,
40 Slow,
45}
46
47#[derive(Debug, Clone)]
49pub struct SolveParams {
50 pub ra_hint: f64,
52 pub dec_hint: f64,
54 pub fov: f64,
56 pub search_radius: f64,
58 pub quad_tolerance: f64,
60 pub hfd_min: f64,
62 pub max_stars: usize,
64 pub db_path: PathBuf,
66 pub db_name: String,
68 pub binning: usize,
71 pub method: SolveMethod,
73 pub speed: SearchSpeed,
75 pub threads: usize,
82}
83
84fn sigma_clip_pairs(
91 mut img_pos: Vec<(f64, f64)>,
92 mut cat_pos: Vec<(f64, f64)>,
93 sigma: f64,
94 min_count: usize,
95) -> PairedPositions {
96 let mut first_pass = true;
97 for _ in 0..10 {
98 if img_pos.len() < min_count.max(3) {
99 break;
100 }
101 let Ok(plate) = fit_affine(&img_pos, &cat_pos) else {
105 break;
106 };
107 let residuals: Vec<f64> = img_pos
108 .iter()
109 .zip(cat_pos.iter())
110 .map(|(&(xi, yi), &(xc, yc))| {
111 let xp = plate.a * xi + plate.b * yi + plate.c;
112 let yp = plate.d * xi + plate.e * yi + plate.f;
113 ((xp - xc).powi(2) + (yp - yc).powi(2)).sqrt()
114 })
115 .collect();
116 let rms = (residuals.iter().map(|r| r * r).sum::<f64>() / residuals.len() as f64).sqrt();
117 let threshold = if first_pass {
118 first_pass = false;
119 let cdelt = (plate.a.powi(2) + plate.d.powi(2)).sqrt();
121 let mut sorted = residuals.clone();
126 sorted.sort_unstable_by(f64::total_cmp);
127 let median = sorted[sorted.len() / 2];
128 (10.0 * cdelt).max(10.0).max(3.0 * 1.4826 * median)
129 } else {
130 sigma * rms
131 };
132 let before = img_pos.len();
133 let mut new_img = Vec::with_capacity(before);
134 let mut new_cat = Vec::with_capacity(before);
135 for ((&ip, &cp), &r) in img_pos.iter().zip(cat_pos.iter()).zip(residuals.iter()) {
136 if r <= threshold {
137 new_img.push(ip);
138 new_cat.push(cp);
139 }
140 }
141 if new_img.len() == before {
142 break; }
144 img_pos = new_img;
145 cat_pos = new_cat;
146 }
147 (img_pos, cat_pos)
148}
149
150const MIN_VERIFIED_STARS: usize = 30;
158const VERIFY_RADII: [f64; 3] = [6.0, 3.0, 2.0];
160const MIN_VERIFY_SPREAD: f64 = 0.20;
167
168struct Verified {
171 plate: PlateConstants,
172 rms: f64,
173 img_pos: Vec<(f64, f64)>,
175 cat_pos: Vec<(f64, f64)>,
178}
179
180impl Verified {
181 fn n(&self) -> usize {
183 self.img_pos.len()
184 }
185}
186
187fn verify_and_refit(
199 img_stars: &StarList,
200 cat_stars: &StarList,
201 plate: &PlateConstants,
202 img_w: usize,
203 img_h: usize,
204) -> Option<Verified> {
205 if img_stars.is_empty() || cat_stars.is_empty() {
206 return None;
207 }
208
209 let (mut min_x, mut min_y) = (f64::INFINITY, f64::INFINITY);
211 let (mut max_x, mut max_y) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
212 for st in &img_stars.0 {
213 min_x = min_x.min(st.x);
214 max_x = max_x.max(st.x);
215 min_y = min_y.min(st.y);
216 max_y = max_y.max(st.y);
217 }
218 if !(min_x.is_finite() && min_y.is_finite() && max_x > min_x && max_y > min_y) {
219 return None;
220 }
221 let cell = VERIFY_RADII[0].max(1.0);
222 let nx = (((max_x - min_x) / cell).ceil() as usize + 1).max(1);
223 let ny = (((max_y - min_y) / cell).ceil() as usize + 1).max(1);
224 let mut grid: Vec<Vec<u32>> = vec![Vec::new(); nx * ny];
225 for (i, st) in img_stars.0.iter().enumerate() {
226 let gx = ((st.x - min_x) / cell) as usize;
227 let gy = ((st.y - min_y) / cell) as usize;
228 grid[gy.min(ny - 1) * nx + gx.min(nx - 1)].push(i as u32);
229 }
230
231 let mut current = plate.clone();
232 let mut best: Option<(Verified, f64)> = None;
234
235 for &radius in &VERIFY_RADII {
236 let det = current.a * current.e - current.b * current.d;
237 if det.abs() < 1e-12 {
238 return None;
239 }
240 let r2 = radius * radius;
241
242 let mut img_pos: Vec<(f64, f64)> = Vec::new();
243 let mut cat_pos: Vec<(f64, f64)> = Vec::new();
244 let mut used = vec![false; img_stars.len()];
245
246 for cs in &cat_stars.0 {
247 let dx = cs.x - current.c;
249 let dy = cs.y - current.f;
250 let px = (current.e * dx - current.b * dy) / det;
251 let py = (-current.d * dx + current.a * dy) / det;
252 if px < min_x - radius
253 || px > max_x + radius
254 || py < min_y - radius
255 || py > max_y + radius
256 {
257 continue;
258 }
259
260 let gx = (((px - min_x) / cell) as isize).clamp(0, nx as isize - 1);
261 let gy = (((py - min_y) / cell) as isize).clamp(0, ny as isize - 1);
262 let mut best_i: Option<usize> = None;
263 let mut best_d2 = r2;
264 for oy in -1isize..=1 {
265 for ox in -1isize..=1 {
266 let cx = gx + ox;
267 let cy = gy + oy;
268 if cx < 0 || cy < 0 || cx >= nx as isize || cy >= ny as isize {
269 continue;
270 }
271 for &i in &grid[cy as usize * nx + cx as usize] {
272 let i = i as usize;
273 if used[i] {
274 continue;
275 }
276 let st = &img_stars.0[i];
277 let d2 = (st.x - px) * (st.x - px) + (st.y - py) * (st.y - py);
278 if d2 < best_d2 {
279 best_d2 = d2;
280 best_i = Some(i);
281 }
282 }
283 }
284 }
285 if let Some(i) = best_i {
286 used[i] = true; img_pos.push((img_stars.0[i].x, img_stars.0[i].y));
288 cat_pos.push((cs.x, cs.y));
289 }
290 }
291
292 if img_pos.len() < 4 {
293 break;
294 }
295 let Ok(refined) = solve_plate_constants(&img_pos, &cat_pos) else {
296 break;
297 };
298 let mut sq = 0.0;
299 for (&(xi, yi), &(xc, yc)) in img_pos.iter().zip(cat_pos.iter()) {
300 let xp = refined.a * xi + refined.b * yi + refined.c;
301 let yp = refined.d * xi + refined.e * yi + refined.f;
302 sq += (xp - xc).powi(2) + (yp - yc).powi(2);
303 }
304 let rms = (sq / img_pos.len() as f64).sqrt();
305 let n = img_pos.len() as f64;
308 let mx = img_pos.iter().map(|p| p.0).sum::<f64>() / n;
309 let my = img_pos.iter().map(|p| p.1).sum::<f64>() / n;
310 let var = img_pos
311 .iter()
312 .map(|&(x, y)| (x - mx) * (x - mx) + (y - my) * (y - my))
313 .sum::<f64>()
314 / n;
315 let half_diag = 0.5 * ((img_w * img_w + img_h * img_h) as f64).sqrt();
316 let spread = var.sqrt() / half_diag;
317 log::debug!(
318 "verify: {} stars, spread {:.3}, rms {:.2}\"",
319 img_pos.len(),
320 spread,
321 rms
322 );
323
324 current = refined.clone();
325 best = Some((
326 Verified {
327 plate: refined,
328 rms,
329 img_pos,
330 cat_pos,
331 },
332 spread,
333 ));
334 }
335
336 best.filter(|(v, spread)| v.n() >= MIN_VERIFIED_STARS && *spread >= MIN_VERIFY_SPREAD)
337 .map(|(v, _)| v)
338}
339
340struct SpiralCtx<'a> {
342 params: &'a SolveParams,
343 img: &'a crate::types::ImageBuffer,
344 stars: &'a StarList,
345 img_quads: &'a crate::types::QuadList,
346 img_tris: &'a crate::quads::TriangleList,
347 nrstars_image: usize,
348 nrstars_required: usize,
349 oversize: f64,
350 min_quads: usize,
351 step_size: f64,
352}
353
354struct PositionOutcome {
356 idx: usize,
357 ra_db: f64,
358 dec_db: f64,
359 sep_deg: f64,
360 verified: Verified,
361 n_matched: usize,
362 n_raw: usize,
363 mag_limit: f64,
364}
365
366struct PositionTry {
370 sep_deg: Option<f64>,
371 outcome: Option<PositionOutcome>,
372}
373
374impl PositionTry {
375 const NONE: Self = Self {
376 sep_deg: None,
377 outcome: None,
378 };
379}
380
381fn try_position(ctx: &SpiralCtx<'_>, idx: usize, sx: i32, sy: i32) -> PositionTry {
384 let params = ctx.params;
385 let step_size = ctx.step_size;
386
387 let dec_db_raw = params.dec_hint + step_size * sy as f64;
388 let (dec_db, flip) = if dec_db_raw > PI / 2.0 {
389 (PI - dec_db_raw, PI)
390 } else if dec_db_raw < -PI / 2.0 {
391 (-PI - dec_db_raw, PI)
392 } else {
393 (dec_db_raw, 0.0)
394 };
395
396 let extra = if dec_db > 0.0 {
397 step_size * 0.5
398 } else {
399 -step_size * 0.5
400 };
401 let ra_offset = step_size * sx as f64 / (dec_db - extra).cos();
402 if ra_offset > PI / 2.0 + step_size * 0.5 || ra_offset < -PI / 2.0 {
403 return PositionTry::NONE;
404 }
405
406 let ra_db = (flip + params.ra_hint + ra_offset).rem_euclid(2.0 * PI);
407 let sep = ang_sep(ra_db, dec_db, params.ra_hint, params.dec_hint);
408 if sep > params.search_radius + step_size / 2.0 {
409 return PositionTry::NONE;
410 }
411
412 let cat_raw = match read_catalog_stars(
415 ¶ms.db_path,
416 ¶ms.db_name,
417 ra_db,
418 dec_db,
419 params.fov * ctx.oversize,
420 ctx.nrstars_required,
421 ) {
422 Ok(v) if !v.is_empty() => v,
423 Ok(_) | Err(_) => return PositionTry::NONE,
424 };
425
426 let sep_deg = sep.to_degrees();
427 let mag_limit = cat_raw
428 .iter()
429 .map(|s| s.mag)
430 .fold(f64::NEG_INFINITY, f64::max);
431 log::info!(
432 "Search {}, [{},{}], position: {} Down to magn {:.1} {} database stars {} database quads to compare.",
433 idx,
434 sx,
435 sy,
436 format_radec(ra_db, dec_db),
437 mag_limit,
438 cat_raw.len(),
439 cat_raw.len(),
440 );
441
442 let mut cat_stars: Vec<Star> = cat_raw
443 .iter()
444 .map(|s| {
445 let (x, y) = equatorial_standard(ra_db, dec_db, s.ra, s.dec, 1.0);
446 Star {
447 x,
448 y,
449 snr: 1.0,
450 hfd: 2.0,
451 }
452 })
453 .collect();
454 cat_stars.sort_unstable_by(|a, b| a.x.total_cmp(&b.x));
455 let cat_star_list = StarList(cat_stars);
456
457 let failed = PositionTry {
458 sep_deg: Some(sep_deg),
459 outcome: None,
460 };
461
462 let (img_pos, cat_pos, n_matched, n_raw) = match params.method {
463 SolveMethod::Quads => {
464 let mut cat_quads = build_quads_presorted(&cat_star_list, ctx.nrstars_image);
465 if cat_quads.is_empty() {
466 return failed;
467 }
468 crate::quads::r#match::sort_catalog_quads(&mut cat_quads);
469 let raw = find_matches_sorted(ctx.img_quads, &cat_quads, params.quad_tolerance);
470 let n_raw = raw.len();
471 log::info!("Found {n_raw} references");
472 let mut filtered = vote_filter(ctx.img_quads, &cat_quads, &raw, params.quad_tolerance);
473 if filtered.len() < ctx.min_quads {
474 let (by_scale, _) = filter_by_scale(&raw, params.quad_tolerance);
475 if by_scale.len() > filtered.len() {
476 filtered = by_scale;
477 }
478 }
479 if filtered.len() < ctx.min_quads {
480 return failed;
481 }
482 let (ip, cp) = extract_star_pairs(ctx.img_quads, &cat_quads, &filtered);
483 (ip, cp, filtered.len(), n_raw)
484 }
485 SolveMethod::Tetra => {
486 let cat_tris = build_triangles(&cat_star_list);
487 if cat_tris.is_empty() {
488 return failed;
489 }
490 let tol = params.quad_tolerance * TETRA_TOL_FACTOR;
491 let raw = find_triangle_matches(ctx.img_tris, &cat_tris, tol);
492 let n_raw = raw.len();
493 log::info!("Found {n_raw} triangle references");
494 let biject = bijective_filter(&raw, ctx.img_tris, &cat_tris);
495 let (filtered, _) = filter_triangles_by_scale(&biject, params.quad_tolerance);
496 if filtered.len() < ctx.min_quads {
497 return failed;
498 }
499 let (ip, cp) = extract_triangle_pairs(ctx.img_tris, &cat_tris, &filtered);
500 let (ip, cp) = sigma_clip_pairs(ip, cp, 3.0, ctx.min_quads);
501 if ip.len() < ctx.min_quads {
502 return failed;
503 }
504 let n_clean = ip.len();
505 (ip, cp, n_clean, n_raw)
506 }
507 };
508
509 let Ok(plate) = solve_plate_constants(&img_pos, &cat_pos) else {
510 return failed;
511 };
512
513 let Some(verified) = verify_and_refit(
514 ctx.stars,
515 &cat_star_list,
516 &plate,
517 ctx.img.width,
518 ctx.img.height,
519 ) else {
520 log::info!("Verification failed at this position; continuing search.");
521 return failed;
522 };
523 log::info!(
524 "Verified {} stars against the catalogue, residual {:.2}\"",
525 verified.n(),
526 verified.rms
527 );
528
529 let (verified, ra_db, dec_db) = recentre(ctx, &cat_raw, verified, ra_db, dec_db);
530
531 PositionTry {
532 sep_deg: Some(sep_deg),
533 outcome: Some(PositionOutcome {
534 idx,
535 ra_db,
536 dec_db,
537 sep_deg,
538 verified,
539 n_matched,
540 n_raw,
541 mag_limit,
542 }),
543 }
544}
545
546fn recentre(
563 ctx: &SpiralCtx<'_>,
564 cat_raw: &[CatalogStar],
565 mut verified: Verified,
566 mut ra_db: f64,
567 mut dec_db: f64,
568) -> (Verified, f64, f64) {
569 let (w, h) = (ctx.img.width as f64, ctx.img.height as f64);
570 let (cx, cy) = ((w - 1.0) * 0.5, (h - 1.0) * 0.5);
571 let apply =
572 |p: &PlateConstants, x: f64, y: f64| (p.a * x + p.b * y + p.c, p.d * x + p.e * y + p.f);
573
574 for _ in 0..2 {
575 let plate = &verified.plate;
576 let (xs, ys) = apply(plate, cx, cy);
577 if xs.hypot(ys) < 1e-3 {
579 break;
580 }
581 let (ra0, dec0) = standard_equatorial(ra_db, dec_db, xs, ys, 1.0);
582
583 let det = plate.a * plate.e - plate.b * plate.d;
589 if det.abs() < 1e-12 {
590 break;
591 }
592 let r2 = VERIFY_RADII[0] * VERIFY_RADII[0];
593 let mut used = vec![false; ctx.stars.len()];
594 let mut img_pos = Vec::new();
595 let mut new_pos = Vec::new();
596 let mut cat = Vec::with_capacity(cat_raw.len());
597 for s in cat_raw {
598 let (nx, ny) = equatorial_standard(ra0, dec0, s.ra, s.dec, 1.0);
599 cat.push(Star {
600 x: nx,
601 y: ny,
602 snr: 1.0,
603 hfd: 2.0,
604 });
605 let (ox, oy) = equatorial_standard(ra_db, dec_db, s.ra, s.dec, 1.0);
606 let (dx, dy) = (ox - plate.c, oy - plate.f);
607 let px = (plate.e * dx - plate.b * dy) / det;
608 let py = (-plate.d * dx + plate.a * dy) / det;
609 let nearest = ctx
610 .stars
611 .0
612 .iter()
613 .enumerate()
614 .filter(|&(i, _)| !used[i])
615 .map(|(i, st)| (i, (st.x - px).powi(2) + (st.y - py).powi(2)))
616 .filter(|&(_, d2)| d2 < r2)
617 .min_by(|a, b| a.1.total_cmp(&b.1));
618 if let Some((i, _)) = nearest {
619 used[i] = true;
620 img_pos.push((ctx.stars.0[i].x, ctx.stars.0[i].y));
621 new_pos.push((nx, ny));
622 }
623 }
624 let Ok(guess) = solve_plate_constants(&img_pos, &new_pos) else {
625 break;
626 };
627 let cat = StarList(cat);
628 let Some(v) = verify_and_refit(ctx.stars, &cat, &guess, ctx.img.width, ctx.img.height)
629 else {
630 log::info!("Re-centring on the image centre did not verify; keeping the fit.");
631 break;
632 };
633 log::info!(
634 "Re-centred on the image centre: verified {} stars, residual {:.2}\"",
635 v.n(),
636 v.rms
637 );
638 (verified, ra_db, dec_db) = (v, ra0, dec0);
639 }
640 (verified, ra_db, dec_db)
641}
642
643pub fn solve_image(img: &crate::types::ImageBuffer, params: &SolveParams) -> Result<WcsSolution> {
661 if !(params.fov.is_finite() && params.fov > 0.0) {
664 return Err(ArcsecError::InvalidParameter(format!(
665 "field of view must be positive, got {} rad",
666 params.fov
667 )));
668 }
669 if !(params.search_radius.is_finite() && params.search_radius >= 0.0) {
670 return Err(ArcsecError::InvalidParameter(format!(
671 "search radius must be non-negative, got {} rad",
672 params.search_radius
673 )));
674 }
675
676 if !crate::catalog::catalog_present(¶ms.db_path, ¶ms.db_name) {
681 return Err(ArcsecError::CatalogNotFound(params.db_path.clone()));
682 }
683
684 let bg = get_background(img, params.max_stars);
686 log::info!("Start finding stars");
687 let (stars, stars_raw) = find_stars_with_background(
688 img,
689 &bg,
690 params.hfd_min,
691 params.max_stars,
692 img.width,
693 img.height,
694 );
695 log::info!(
696 "{} stars found of the requested {}. Background value is {:.0}. \
697 Detection level used {:.0} above background. Star level is {:.0} above background. \
698 Noise level is {:.0}",
699 stars_raw,
700 params.max_stars,
701 bg.mean,
702 bg.star_level,
703 bg.star_level,
704 bg.noise,
705 );
706 if stars_raw > params.max_stars {
707 log::info!("Selecting the {} brightest stars only.", params.max_stars);
708 }
709
710 let nrstars_image = stars.len();
717 if nrstars_image < 5 {
718 return Err(ArcsecError::InsufficientStars {
719 found: nrstars_image,
720 required: 5,
721 });
722 }
723
724 let img_quads = build_quads(&stars, nrstars_image);
726 let nr_quads = img_quads.len();
727
728 let img_tris = if params.method == SolveMethod::Tetra {
729 build_triangles(&stars)
730 } else {
731 crate::quads::TriangleList::default()
732 };
733
734 let patterns_empty = match params.method {
735 SolveMethod::Quads => nr_quads == 0,
736 SolveMethod::Tetra => img_tris.is_empty(),
737 };
738 if patterns_empty {
739 return Err(ArcsecError::InsufficientQuads {
740 found: 0,
741 required: 3,
742 });
743 }
744
745 let min_quads: usize = 3 + nrstars_image / 140;
746
747 let oversize: f64 = match params.speed {
748 SearchSpeed::Auto if nrstars_image < 35 => 2.0,
749 SearchSpeed::Auto if nrstars_image > 140 => 1.0,
750 SearchSpeed::Auto => 2.0 * (35.0 / nrstars_image as f64).sqrt(),
751 SearchSpeed::Slow => {
754 let max_fov_deg = match crate::catalog::detect_layout(¶ms.db_path, ¶ms.db_name)
755 {
756 CatalogLayout::Areas1476 => 5.142_857_143_f64,
757 CatalogLayout::Areas290 => 9.53,
758 CatalogLayout::AllSky001 => 180.0,
759 };
760 2.0_f64.min(max_fov_deg.to_radians() / params.fov).max(1.0)
761 }
762 };
763
764 let nrstars_required = (params.max_stars as f64 * oversize * oversize).round() as usize;
766 let step_size = params.fov;
767 let fov_deg = step_size.to_degrees();
768 let max_distance = (params.search_radius / step_size + 2.0) as i32;
769
770 log::info!(
771 "{} stars, {} quads selected in the image. {} database stars, {} database quads required \
772 for the {:.2}d square search window. Step size {:.2}d. Oversize {:.2}",
773 nrstars_image,
774 nr_quads,
775 nrstars_required,
776 nrstars_required,
777 fov_deg * oversize,
778 fov_deg,
779 oversize,
780 );
781
782 let ctx = SpiralCtx {
790 params,
791 img,
792 stars: &stars,
793 img_quads: &img_quads,
794 img_tris: &img_tris,
795 nrstars_image,
796 nrstars_required,
797 oversize,
798 min_quads,
799 step_size,
800 };
801
802 let n_threads = if params.threads > 0 {
803 params.threads
804 } else {
805 crate::max_threads()
806 }
807 .clamp(1, 64);
808
809 let positions: Vec<(i32, i32)> = SpiralSearch::new(max_distance).collect();
810 let mut step_distances: Vec<f64> = Vec::new();
811
812 let mut winner: Option<PositionOutcome> = None;
813 let mut start_idx = 0usize;
814 while start_idx < positions.len() && winner.is_none() {
815 let batch_len = if start_idx == 0 {
818 1
819 } else {
820 n_threads.min(positions.len() - start_idx)
821 };
822 let batch = &positions[start_idx..start_idx + batch_len];
823
824 let tries: Vec<PositionTry> = if n_threads == 1 || batch.len() == 1 {
825 batch
826 .iter()
827 .enumerate()
828 .map(|(k, &(sx, sy))| try_position(&ctx, start_idx + k, sx, sy))
829 .collect()
830 } else {
831 std::thread::scope(|scope| {
832 let handles: Vec<_> = batch
833 .iter()
834 .enumerate()
835 .map(|(k, &(sx, sy))| {
836 let ctx = &ctx;
837 scope.spawn(move || try_position(ctx, start_idx + k, sx, sy))
838 })
839 .collect();
840 handles
841 .into_iter()
842 .map(|h| h.join().unwrap_or_else(|e| std::panic::resume_unwind(e)))
846 .collect()
847 })
848 };
849
850 for t in tries {
851 if let Some(d) = t.sep_deg {
852 step_distances.push(d);
853 }
854 if let Some(o) = t.outcome
855 && winner.as_ref().is_none_or(|w| o.idx < w.idx)
856 {
857 winner = Some(o);
858 }
859 }
860
861 start_idx += batch_len;
862 }
863
864 if let Some(o) = winner {
865 log::info!(
866 "{} of {} patterns selected matching within {:.3} tolerance.",
867 o.n_matched,
868 o.n_raw,
869 params.quad_tolerance,
870 );
871
872 let v = o.verified;
873 let mut wcs = derive_wcs(o.ra_db, o.dec_db, &v.plate, img.width, img.height);
874 let b = params.binning.max(1) as f64;
877 wcs.matched_stars = v
878 .img_pos
879 .iter()
880 .zip(&v.cat_pos)
881 .map(|(&(x, y), &(sx, sy))| {
882 let (ra, dec) = standard_equatorial(o.ra_db, o.dec_db, sx, sy, 1.0);
883 MatchedStar {
884 x: (x + 0.5) * b + 0.5,
885 y: (y + 0.5) * b + 0.5,
886 ra,
887 dec,
888 }
889 })
890 .collect();
891 if params.binning > 1 {
892 let b = params.binning as f64;
893 wcs.crpix1 = (wcs.crpix1 - 0.5) * b + 0.5;
894 wcs.crpix2 = (wcs.crpix2 - 0.5) * b + 0.5;
895 wcs.cd1_1 /= b;
896 wcs.cd1_2 /= b;
897 wcs.cd2_1 /= b;
898 wcs.cd2_2 /= b;
899 wcs.cdelt1 /= b;
900 wcs.cdelt2 /= b;
901 }
902 wcs.residual_rms = v.rms;
903 wcs.stars_matched = v.n();
904 wcs.raw_matches = o.n_raw;
905 wcs.plate = v.plate;
906 wcs.mag_limit = o.mag_limit;
907 wcs.search_dist_deg = o.sep_deg;
908 wcs.step_distances = step_distances;
909 return Ok(wcs);
910 }
911
912 Err(ArcsecError::InsufficientQuads {
913 found: 0,
914 required: min_quads,
915 })
916}
917
918#[must_use]
921pub fn format_ra(ra_rad: f64) -> String {
922 const TENTHS_PER_DAY: f64 = 24.0 * 36_000.0;
927 let ra_tenths = ((ra_rad.to_degrees() / 15.0 * 36_000.0)
928 .round()
929 .rem_euclid(TENTHS_PER_DAY)) as u64;
930 let h = ra_tenths / 36_000;
931 let m = ra_tenths / 600 % 60;
932 let s = ra_tenths % 600 / 10;
933 let tenths = ra_tenths % 10;
934 format!("{h:02}: {m:02} {s:02}.{tenths}")
935}
936
937#[must_use]
940pub fn format_dec(dec_rad: f64) -> String {
941 let dec_deg = dec_rad.to_degrees();
942 let sign = if dec_deg < 0.0 { '-' } else { '+' };
943 let dec_secs = (dec_deg.abs() * 3600.0).round() as u64;
944 let dd = dec_secs / 3600;
945 let dm = dec_secs / 60 % 60;
946 let ds = dec_secs % 60;
947 format!("{sign}{dd:02}d {dm:02} {ds:02}")
948}
949
950#[must_use]
954pub fn format_radec(ra_rad: f64, dec_rad: f64) -> String {
955 format!("{} {}", format_ra(ra_rad), format_dec(dec_rad))
956}
957
958#[cfg(test)]
959mod tests {
960 use super::*;
961 use crate::math::coords::{ang_sep, standard_equatorial};
962 use crate::test_support::{
963 Rng, SkySpec, TempDir, TruthWcs, random_sky, render, write_001_db, write_290_db,
964 write_1476_db,
965 };
966 use crate::types::{ImageBuffer, PlateConstants};
967 use crate::wcs::output::derive_wcs;
968 use core::f64::consts::PI;
969
970 fn deg(d: f64) -> f64 {
971 d * PI / 180.0
972 }
973
974 fn make_test_scene(
975 n_stars: usize,
976 ra_center: f64,
977 dec_center: f64,
978 cdelt_arcsec: f64,
979 width: usize,
980 height: usize,
981 ) -> (ImageBuffer, Vec<(f64, f64)>, PlateConstants) {
982 let mut data = vec![100.0f32; width * height];
983 let mut catalog_sky: Vec<(f64, f64)> = Vec::new();
984 let stars_per_row = (n_stars as f64).sqrt().ceil() as usize;
985 let spacing = 40.0;
986 let cx = (width as f64 - 1.0) / 2.0;
987 let cy = (height as f64 - 1.0) / 2.0;
988 let a = cdelt_arcsec;
989 let c = -a * cx;
990 let e = cdelt_arcsec;
991 let f_offset = -e * cy;
992 let plate = PlateConstants {
993 a,
994 b: 0.0,
995 c,
996 d: 0.0,
997 e,
998 f: f_offset,
999 };
1000 let mut count = 0;
1001 'outer: for row in 0..stars_per_row {
1002 for col in 0..stars_per_row {
1003 if count >= n_stars {
1004 break 'outer;
1005 }
1006 let px = 20.0 + col as f64 * spacing;
1007 let py = 20.0 + row as f64 * spacing;
1008 if px >= width as f64 - 20.0 || py >= height as f64 - 20.0 {
1009 continue;
1010 }
1011 let x_std = a * px + c;
1012 let y_std = e * py + f_offset;
1013 let (ra, dec) = standard_equatorial(ra_center, dec_center, x_std, y_std, 1.0);
1014 catalog_sky.push((ra, dec));
1015 let sigma = 2.0;
1016 let amp = 30000.0f32;
1017 for dy in -8i32..=8 {
1018 for dx in -8i32..=8 {
1019 let x = (px as i32 + dx) as usize;
1020 let y = (py as i32 + dy) as usize;
1021 if x < width && y < height {
1022 let r2 = (dx * dx + dy * dy) as f64 / (2.0 * sigma * sigma);
1023 data[y * width + x] += amp * (-r2).exp() as f32;
1024 }
1025 }
1026 }
1027 count += 1;
1028 }
1029 }
1030 let img = ImageBuffer {
1031 data,
1032 width,
1033 height,
1034 };
1035 (img, catalog_sky, plate)
1036 }
1037
1038 #[test]
1039 fn derive_wcs_recovers_position() {
1040 let ra_center = deg(45.0);
1041 let dec_center = deg(30.0);
1042 let (img, _cat, plate) = make_test_scene(16, ra_center, dec_center, 2.0, 300, 300);
1043 let wcs = derive_wcs(ra_center, dec_center, &plate, img.width, img.height);
1044 let sep_arcsec = ang_sep(wcs.ra0, wcs.dec0, ra_center, dec_center) * (180.0 / PI * 3600.0);
1045 assert!(sep_arcsec < 0.5, "centre offset = {sep_arcsec} arcsec");
1046 }
1047
1048 #[test]
1049 fn spiral_covers_origin_first() {
1050 assert_eq!(SpiralSearch::new(5).next(), Some((0, 0)));
1051 }
1052
1053 #[test]
1054 fn oversize_formula_limits() {
1055 for n in [10, 35, 70, 140, 200] {
1056 let ov: f64 = if n < 35 {
1057 2.0
1058 } else if n > 140 {
1059 1.0
1060 } else {
1061 2.0 * (35.0 / n as f64).sqrt()
1062 };
1063 assert!((1.0..=2.0).contains(&ov), "oversize={ov} for n={n}");
1064 }
1065 }
1066
1067 #[test]
1068 fn format_radec_carries_rounded_seconds() {
1069 let ra = deg((1.0 + 59.0 / 60.0 + 59.97 / 3600.0) * 15.0);
1071 let dec = deg(10.0 + 59.0 / 60.0 + 59.7 / 3600.0);
1073 assert_eq!(format_radec(ra, dec), "02: 00 00.0 +11d 00 00");
1074 let s = format_radec(deg(359.999_999_9), deg(-0.5));
1076 assert_eq!(s, "00: 00 00.0 -00d 30 00");
1077 assert_eq!(
1079 format_radec(deg((5.0 + 35.0 / 60.0 + 17.3 / 3600.0) * 15.0), deg(-5.39)),
1080 "05: 35 17.3 -05d 23 24"
1081 );
1082 }
1083
1084 #[test]
1089 fn ra_and_dec_are_formatted_as_astap_cli_prints_them() {
1090 let ra = deg(65.0); let dec = deg(35.0);
1092 assert_eq!(format_ra(ra), "04: 20 00.0");
1093 assert_eq!(format_dec(dec), "+35d 00 00");
1094 assert_eq!(format_radec(ra, dec), "04: 20 00.0 +35d 00 00");
1095 assert_eq!(
1096 format_radec(
1097 deg((13.0 + 7.0 / 60.0 + 9.25 / 3600.0) * 15.0),
1098 -deg(89.0 + 1.0 / 60.0 + 2.0 / 3600.0)
1099 ),
1100 "13: 07 09.3 -89d 01 02"
1101 );
1102 assert_eq!(format_dec(deg(-0.0001)), "-00d 00 00");
1103 }
1104
1105 #[test]
1106 fn solve_image_rejects_a_non_positive_fov() {
1107 let img = ImageBuffer::new(64, 64);
1108 let params = SolveParams {
1109 ra_hint: 0.0,
1110 dec_hint: 0.0,
1111 fov: 0.0,
1112 search_radius: 0.1,
1113 quad_tolerance: 0.007,
1114 hfd_min: 1.5,
1115 max_stars: 500,
1116 db_path: std::path::PathBuf::from("/nonexistent"),
1117 db_name: "d50".into(),
1118 binning: 1,
1119 method: SolveMethod::Quads,
1120 threads: 1,
1121 speed: SearchSpeed::Auto,
1122 };
1123 assert!(matches!(
1124 solve_image(&img, ¶ms),
1125 Err(ArcsecError::InvalidParameter(_))
1126 ));
1127 }
1128
1129 fn known_plate() -> PlateConstants {
1133 let (s, r) = (3.2_f64, 0.61_f64);
1134 PlateConstants {
1135 a: -s * r.cos(),
1136 b: s * r.sin(),
1137 c: 640.0,
1138 d: s * r.sin(),
1139 e: s * r.cos(),
1140 f: -512.0,
1141 }
1142 }
1143
1144 fn apply(p: &PlateConstants, (x, y): (f64, f64)) -> (f64, f64) {
1145 (p.a * x + p.b * y + p.c, p.d * x + p.e * y + p.f)
1146 }
1147
1148 fn plate_close(p: &PlateConstants, q: &PlateConstants, tol: f64) -> bool {
1149 [
1150 (p.a, q.a),
1151 (p.b, q.b),
1152 (p.c, q.c),
1153 (p.d, q.d),
1154 (p.e, q.e),
1155 (p.f, q.f),
1156 ]
1157 .iter()
1158 .all(|(u, v)| (u - v).abs() <= tol)
1159 }
1160
1161 fn star_at(x: f64, y: f64) -> Star {
1162 Star {
1163 x,
1164 y,
1165 snr: 50.0,
1166 hfd: 2.5,
1167 }
1168 }
1169
1170 fn pairs_with_outliers(outlier: impl Fn(usize, (f64, f64)) -> (f64, f64)) -> PairedPositions {
1173 let plate = known_plate();
1174 let mut rng = Rng::new(7);
1175 let mut img = Vec::new();
1176 let mut cat = Vec::new();
1177 for _ in 0..40 {
1178 let p = (rng.range(0.0, 500.0), rng.range(0.0, 500.0));
1179 img.push(p);
1180 cat.push(apply(&plate, p));
1181 }
1182 for k in 0..5 {
1183 let p = (rng.range(0.0, 500.0), rng.range(0.0, 500.0));
1184 img.push(p);
1185 cat.push(outlier(k, apply(&plate, p)));
1186 }
1187 (img, cat)
1188 }
1189
1190 #[test]
1191 fn sigma_clip_pairs_rejects_outliers_and_keeps_the_rest() {
1192 let (img, cat) = pairs_with_outliers(|k, (x, y)| {
1194 let a = k as f64 * 1.3;
1195 (x + 100.0 * a.cos(), y + 100.0 * a.sin())
1196 });
1197 let (ci, cc) = sigma_clip_pairs(img, cat, 3.0, 3);
1198 assert_eq!(ci.len(), 40, "all and only the true pairs survive");
1199 let fit = solve_plate_constants(&ci, &cc).unwrap();
1200 assert!(plate_close(&fit, &known_plate(), 1e-6), "{fit:?}");
1201 }
1202
1203 #[test]
1211 fn sigma_clip_pairs_rejects_gross_outliers() {
1212 let (img, cat) =
1213 pairs_with_outliers(|k, _| (1000.0 + 150.0 * k as f64, -900.0 + 70.0 * k as f64));
1214 assert!(matches!(
1215 solve_plate_constants(&img, &cat),
1216 Err(ArcsecError::BadSolution { .. })
1217 ));
1218 let (ci, _) = sigma_clip_pairs(img, cat, 3.0, 3);
1219 assert_eq!(ci.len(), 40, "the five gross outliers should be clipped");
1220 }
1221
1222 #[test]
1223 fn sigma_clip_pairs_leaves_too_few_pairs_alone() {
1224 let img = vec![(0.0, 0.0), (1.0, 0.0)];
1225 let cat = vec![(5.0, 5.0), (9.0, 9.0)];
1226 let (ci, cc) = sigma_clip_pairs(img.clone(), cat.clone(), 3.0, 3);
1227 assert_eq!((ci, cc), (img, cat));
1228 }
1229
1230 #[test]
1231 fn verify_and_refit_recovers_the_plate_from_a_rough_guess() {
1232 let truth = known_plate();
1233 let mut rng = Rng::new(11);
1234 let mut img_stars = Vec::new();
1235 let mut cat_stars = Vec::new();
1236 for _ in 0..60 {
1237 let (x, y) = (rng.range(5.0, 395.0), rng.range(5.0, 295.0));
1238 img_stars.push(star_at(x, y));
1239 let (cx, cy) = apply(&truth, (x, y));
1240 cat_stars.push(star_at(cx, cy));
1241 }
1242 for k in 0..20 {
1244 let (cx, cy) = apply(&truth, (-300.0 - 10.0 * k as f64, 900.0));
1245 cat_stars.push(star_at(cx, cy));
1246 }
1247 let mut rough = truth.clone();
1249 rough.c += 2.0 * truth.a;
1250 rough.f += 2.0 * truth.e;
1251 rough.b += 0.01;
1252 let v = verify_and_refit(&StarList(img_stars), &StarList(cat_stars), &rough, 400, 300)
1253 .expect("a correct plate must verify");
1254 assert_eq!(v.n(), 60);
1255 assert_eq!(v.cat_pos.len(), 60);
1256 assert!(v.rms < 1e-6, "rms {}", v.rms);
1257 assert!(plate_close(&v.plate, &truth, 1e-6), "{:?}", v.plate);
1258 for (&(x, y), &(cx, cy)) in v.img_pos.iter().zip(&v.cat_pos) {
1260 let (px, py) = apply(&truth, (x, y));
1261 assert!((px - cx).hypot(py - cy) < 1e-6);
1262 }
1263 }
1264
1265 #[test]
1266 fn verify_and_refit_rejects_too_few_or_clustered_matches() {
1267 let truth = known_plate();
1268 let mut rng = Rng::new(12);
1269 let build = |pts: &[(f64, f64)]| {
1270 let img = StarList(pts.iter().map(|&(x, y)| star_at(x, y)).collect());
1271 let cat = StarList(
1272 pts.iter()
1273 .map(|&p| apply(&truth, p))
1274 .map(|(x, y)| star_at(x, y))
1275 .collect(),
1276 );
1277 (img, cat)
1278 };
1279
1280 let few: Vec<_> = (0..20)
1282 .map(|_| (rng.range(0.0, 400.0), rng.range(0.0, 300.0)))
1283 .collect();
1284 let (img, cat) = build(&few);
1285 assert!(verify_and_refit(&img, &cat, &truth, 400, 300).is_none());
1286
1287 let clustered: Vec<_> = (0..80)
1289 .map(|_| (rng.range(0.0, 40.0), rng.range(0.0, 40.0)))
1290 .collect();
1291 let (img, cat) = build(&clustered);
1292 assert!(verify_and_refit(&img, &cat, &truth, 400, 300).is_none());
1293
1294 let spread: Vec<_> = (0..80)
1296 .map(|_| (rng.range(0.0, 400.0), rng.range(0.0, 300.0)))
1297 .collect();
1298 let (img, cat) = build(&spread);
1299 assert!(verify_and_refit(&img, &cat, &truth, 400, 300).is_some());
1300
1301 let empty = StarList::default();
1303 assert!(verify_and_refit(&empty, &cat, &truth, 400, 300).is_none());
1304 let mut singular = truth.clone();
1305 singular.a = 0.0;
1306 singular.b = 0.0;
1307 assert!(verify_and_refit(&img, &cat, &singular, 400, 300).is_none());
1308 }
1309
1310 #[derive(Clone, Copy)]
1313 enum Db {
1314 Areas1476,
1315 Areas290,
1316 AllSky001,
1317 }
1318
1319 struct Scene {
1321 dir: TempDir,
1322 img: ImageBuffer,
1323 truth: TruthWcs,
1324 }
1325
1326 fn scene(truth: TruthWcs, db: Db, n_in_frame: usize, seed: u64) -> Scene {
1329 let mut rng = Rng::new(seed);
1330 let scale_deg = truth.cd[1].hypot(truth.cd[3]);
1331 let (w_deg, h_deg) = (
1332 truth.width as f64 * scale_deg,
1333 truth.height as f64 * scale_deg,
1334 );
1335 let side = 6.0 * w_deg.max(h_deg);
1336 let sky = random_sky(
1337 &mut rng,
1338 &SkySpec {
1339 ra0: truth.ra0,
1340 dec0: truth.dec0,
1341 side_deg: side,
1342 n: (n_in_frame as f64 * side * side / (w_deg * h_deg)) as usize,
1343 min_sep_deg: 12.0 * scale_deg,
1344 mag_lo: 10.0,
1345 mag_hi: 14.5,
1346 },
1347 );
1348 let sigma = 1.3 * 5.0 / (scale_deg * 3600.0);
1350 let img = render(
1351 &truth,
1352 &sky,
1353 sigma.max(1.3),
1354 1000.0,
1355 8.0,
1356 30_000.0,
1357 &mut rng,
1358 );
1359 let dir = TempDir::new("solve");
1360 match db {
1361 Db::Areas1476 => write_1476_db(dir.path(), "t50", &sky),
1362 Db::Areas290 => write_290_db(dir.path(), "t50", &sky),
1363 Db::AllSky001 => write_001_db(dir.path(), "t50", &sky),
1364 }
1365 Scene { dir, img, truth }
1366 }
1367
1368 fn params_for(s: &Scene, ra_hint: f64, dec_hint: f64) -> SolveParams {
1369 SolveParams {
1370 ra_hint,
1371 dec_hint,
1372 fov: (s.truth.height as f64 * s.truth.cd[1].hypot(s.truth.cd[3])).to_radians(),
1373 search_radius: deg(2.0),
1374 quad_tolerance: 0.007,
1375 hfd_min: 1.5,
1376 max_stars: 500,
1377 db_path: s.dir.path().to_path_buf(),
1378 db_name: "t50".into(),
1379 binning: 1,
1380 method: SolveMethod::Quads,
1381 threads: 1,
1382 speed: SearchSpeed::Auto,
1383 }
1384 }
1385
1386 fn assert_solved(s: &Scene, wcs: &WcsSolution, tol_arcsec: f64) {
1387 let err = s.truth.max_error_arcsec(wcs);
1388 assert!(
1389 err < tol_arcsec,
1390 "worst centre/corner error {err:.3}\" (matched {}, rms {:.3})",
1391 wcs.stars_matched,
1392 wcs.residual_rms
1393 );
1394 assert!(wcs.stars_matched >= MIN_VERIFIED_STARS);
1395 let scale_arcsec = s.truth.cd[1].hypot(s.truth.cd[3]) * 3600.0;
1397 assert!(
1398 wcs.residual_rms < 0.3 * scale_arcsec,
1399 "rms {}",
1400 wcs.residual_rms
1401 );
1402 assert!(wcs.raw_matches > 0);
1403 assert_matches_agree(wcs, 0.3, 1.0);
1404 assert!(wcs.mag_limit > 10.0 && wcs.mag_limit <= 14.5);
1405 assert!(
1406 wcs.cdelt1 < 0.0 && wcs.cdelt2 > 0.0,
1407 "CDELT sign convention"
1408 );
1409 }
1410
1411 fn assert_matches_agree(wcs: &WcsSolution, rms_px: f64, binning: f64) {
1414 assert_eq!(wcs.matched_stars.len(), wcs.stars_matched);
1415 assert!(wcs.sip.is_none(), "solve_image never fits SIP");
1416 let tan = crate::wcs::TanWcs::from(wcs);
1417 let mut sq = 0.0;
1418 for m in &wcs.matched_stars {
1419 let (x, y) = tan.sky_to_pixel(m.ra, m.dec).unwrap();
1420 let d = (x - m.x).hypot(y - m.y);
1421 assert!(
1422 d < VERIFY_RADII[VERIFY_RADII.len() - 1] * binning,
1423 "pair at ({:.2},{:.2}) projects to ({x:.2},{y:.2})",
1424 m.x,
1425 m.y
1426 );
1427 sq += d * d;
1428 }
1429 let rms = (sq / wcs.matched_stars.len() as f64).sqrt();
1430 assert!(rms < rms_px, "pair rms {rms} px");
1431 }
1432
1433 #[test]
1434 fn solves_a_1476_database_from_an_offset_hint() {
1435 let truth = TruthWcs::new(deg(84.3), deg(-5.2), 5.0, 23.0, false, 400, 320);
1436 let s = scene(truth, Db::Areas1476, 130, 1);
1437 let mut p = params_for(&s, deg(84.3 + 0.6), deg(-5.2 - 0.45));
1439 p.threads = 4;
1440 let wcs = solve_image(&s.img, &p).expect("solve");
1441 assert_solved(&s, &wcs, 1.0);
1442 assert!(wcs.search_dist_deg > 0.1, "solved at the hint itself?");
1443 assert!(wcs.step_distances.len() > 1);
1444 assert!((wcs.cdelt2 * 3600.0 - 5.0).abs() < 0.01, "{}", wcs.cdelt2);
1446 assert!((wcs.crota2 - 23.0).abs() < 0.05, "crota2 {}", wcs.crota2);
1447 }
1448
1449 #[test]
1450 fn solves_a_mirrored_image_on_a_290_database() {
1451 let truth = TruthWcs::new(deg(201.0), deg(47.5), 6.0, 160.0, true, 360, 360);
1452 let s = scene(truth, Db::Areas290, 120, 2);
1453 let wcs = solve_image(&s.img, ¶ms_for(&s, truth.ra0, truth.dec0)).expect("solve");
1454 assert_solved(&s, &wcs, 1.0);
1455 assert!(wcs.search_dist_deg < 1e-9, "should solve at the hint");
1456 assert!(wcs.cd1_1 * wcs.cd2_2 - wcs.cd1_2 * wcs.cd2_1 > 0.0);
1458 }
1459
1460 #[test]
1461 fn solves_across_ra_zero_with_an_all_sky_001_database() {
1462 let truth = TruthWcs::new(deg(0.05), deg(21.0), 5.0, -70.0, false, 360, 300);
1464 let s = scene(truth, Db::AllSky001, 120, 3);
1465 let wcs = solve_image(&s.img, ¶ms_for(&s, truth.ra0, truth.dec0)).expect("solve");
1466 assert_solved(&s, &wcs, 1.0);
1467 }
1468
1469 #[test]
1470 fn solves_across_ra_zero_with_a_1476_database() {
1471 let truth = TruthWcs::new(deg(359.97), deg(-33.0), 5.0, 95.0, false, 360, 300);
1472 let s = scene(truth, Db::Areas1476, 120, 4);
1473 let wcs = solve_image(&s.img, ¶ms_for(&s, truth.ra0, truth.dec0)).expect("solve");
1474 assert_solved(&s, &wcs, 1.0);
1475 }
1476
1477 #[test]
1478 fn solves_a_field_near_the_celestial_pole() {
1479 let truth = TruthWcs::new(deg(40.0), deg(88.9), 5.0, 10.0, false, 360, 300);
1480 let s = scene(truth, Db::Areas1476, 120, 5);
1481 let wcs = solve_image(&s.img, ¶ms_for(&s, truth.ra0, truth.dec0)).expect("solve");
1482 assert_solved(&s, &wcs, 1.0);
1483 }
1484
1485 #[test]
1499 fn accuracy_does_not_depend_on_the_hint_offset() {
1500 let truth = TruthWcs::new(deg(150.0), deg(30.0), 15.0, 20.0, false, 360, 300);
1501 let s = scene(truth, Db::Areas1476, 120, 21);
1502 let off = 0.4;
1503 let p = params_for(&s, deg(150.0 + off / deg(30.0).cos()), deg(30.0 + off));
1504 let wcs = solve_image(&s.img, &p).expect("solve");
1505 assert!(wcs.search_dist_deg < 1e-9, "solved at the hint");
1506 let err = s.truth.max_error_arcsec(&wcs);
1507 assert!(
1508 err < 5.0,
1509 "worst corner error {err:.2}\" with a {off}° hint offset"
1510 );
1511 }
1512
1513 #[test]
1514 fn solves_with_the_tetra_method() {
1515 let truth = TruthWcs::new(deg(150.0), deg(2.0), 5.0, 45.0, false, 360, 300);
1516 let s = scene(truth, Db::Areas1476, 110, 6);
1517 let mut p = params_for(&s, truth.ra0, truth.dec0);
1518 p.method = SolveMethod::Tetra;
1519 let wcs = solve_image(&s.img, &p).expect("solve");
1520 assert_solved(&s, &wcs, 1.0);
1521 }
1522
1523 #[test]
1524 fn slow_speed_solves_from_an_offset_hint() {
1525 let truth = TruthWcs::new(deg(84.3), deg(-5.2), 5.0, 23.0, false, 400, 320);
1526 let s = scene(truth, Db::Areas1476, 130, 1);
1527 let mut p = params_for(&s, deg(84.3 + 0.6), deg(-5.2 - 0.45));
1528 p.speed = SearchSpeed::Slow;
1529 let wcs = solve_image(&s.img, &p).expect("solve");
1530 assert_solved(&s, &wcs, 1.0);
1531 }
1532
1533 #[test]
1534 fn binned_solve_is_reported_on_the_unbinned_pixel_grid() {
1535 let truth = TruthWcs::new(deg(10.0), deg(40.0), 2.5, 30.0, false, 720, 600);
1537 let s = scene(truth, Db::Areas1476, 120, 7);
1538 let binned = s.img.bin_image(2);
1539 assert_eq!((binned.width, binned.height), (360, 300));
1540 let mut p = params_for(&s, truth.ra0, truth.dec0);
1541 p.binning = 2;
1542 let wcs = solve_image(&binned, &p).expect("solve");
1543 assert!((wcs.crpix1 - 360.5).abs() < 1e-9, "crpix1 {}", wcs.crpix1);
1545 assert!((wcs.crpix2 - 300.5).abs() < 1e-9, "crpix2 {}", wcs.crpix2);
1546 assert!((wcs.cdelt2 * 3600.0 - 2.5).abs() < 0.01, "{}", wcs.cdelt2);
1547 let err = s.truth.max_error_arcsec(&wcs);
1548 assert!(err < 2.0, "worst corner error {err:.3}\"");
1549 assert_matches_agree(&wcs, 0.6, 2.0);
1551 }
1552
1553 #[test]
1554 fn a_field_absent_from_the_catalogue_does_not_solve() {
1555 let truth = TruthWcs::new(deg(120.0), deg(-40.0), 5.0, 0.0, false, 360, 300);
1558 let s = scene(truth, Db::Areas1476, 120, 8);
1559 let decoy = TempDir::new("decoy");
1560 let mut rng = Rng::new(99);
1561 let other = random_sky(
1562 &mut rng,
1563 &SkySpec {
1564 ra0: truth.ra0,
1565 dec0: truth.dec0,
1566 side_deg: 3.0,
1567 n: 4000,
1568 min_sep_deg: 0.015,
1569 mag_lo: 10.0,
1570 mag_hi: 14.5,
1571 },
1572 );
1573 write_1476_db(decoy.path(), "t50", &other);
1574 let mut p = params_for(&s, truth.ra0, truth.dec0);
1575 p.db_path = decoy.path().to_path_buf();
1576 p.search_radius = deg(0.5);
1577 match solve_image(&s.img, &p) {
1578 Err(ArcsecError::InsufficientQuads { found: 0, required }) => {
1579 assert!(required >= 3);
1580 }
1581 other => panic!("expected InsufficientQuads, got {other:?}"),
1582 }
1583 }
1584
1585 #[test]
1586 fn a_corrupt_catalogue_tile_is_skipped_not_fatal() {
1587 let truth = TruthWcs::new(deg(120.0), deg(-40.0), 5.0, 0.0, false, 360, 300);
1588 let s = scene(truth, Db::Areas1476, 120, 9);
1589 for entry in std::fs::read_dir(s.dir.path()).unwrap() {
1591 let path = entry.unwrap().path();
1592 let mut bytes = std::fs::read(&path).unwrap();
1593 bytes[109] = 7;
1594 std::fs::write(&path, bytes).unwrap();
1595 }
1596 let mut p = params_for(&s, truth.ra0, truth.dec0);
1597 p.search_radius = 0.0;
1598 assert!(matches!(
1599 solve_image(&s.img, &p),
1600 Err(ArcsecError::InsufficientQuads { .. })
1601 ));
1602 }
1603
1604 #[test]
1605 fn a_blank_frame_reports_insufficient_stars() {
1606 let dir = TempDir::new("blank");
1607 write_1476_db(dir.path(), "t50", &[]);
1608 let mut rng = Rng::new(3);
1609 let img = ImageBuffer {
1610 data: (0..200 * 200)
1611 .map(|_| (1000.0 + 5.0 * rng.gauss()) as f32)
1612 .collect(),
1613 width: 200,
1614 height: 200,
1615 };
1616 let p = SolveParams {
1617 ra_hint: 0.0,
1618 dec_hint: 0.0,
1619 fov: deg(0.3),
1620 search_radius: deg(1.0),
1621 quad_tolerance: 0.007,
1622 hfd_min: 1.5,
1623 max_stars: 500,
1624 db_path: dir.path().to_path_buf(),
1625 db_name: "t50".into(),
1626 binning: 1,
1627 method: SolveMethod::Quads,
1628 threads: 1,
1629 speed: SearchSpeed::Auto,
1630 };
1631 match solve_image(&img, &p) {
1632 Err(ArcsecError::InsufficientStars { found, required: 5 }) => assert!(found < 5),
1633 other => panic!("expected InsufficientStars, got {other:?}"),
1634 }
1635 }
1636
1637 #[test]
1638 fn a_missing_database_is_reported_before_any_detection() {
1639 let dir = TempDir::new("nodb");
1640 let p = SolveParams {
1641 ra_hint: 0.0,
1642 dec_hint: 0.0,
1643 fov: deg(1.0),
1644 search_radius: deg(1.0),
1645 quad_tolerance: 0.007,
1646 hfd_min: 1.5,
1647 max_stars: 500,
1648 db_path: dir.path().to_path_buf(),
1649 db_name: "d50".into(),
1650 binning: 1,
1651 method: SolveMethod::Quads,
1652 threads: 1,
1653 speed: SearchSpeed::Auto,
1654 };
1655 match solve_image(&ImageBuffer::new(64, 64), &p) {
1656 Err(ArcsecError::CatalogNotFound(path)) => assert_eq!(path, dir.path()),
1657 other => panic!("expected CatalogNotFound, got {other:?}"),
1658 }
1659 }
1660
1661 #[test]
1662 fn solve_image_rejects_a_bad_search_radius_or_fov() {
1663 let base = SolveParams {
1664 ra_hint: 0.0,
1665 dec_hint: 0.0,
1666 fov: deg(1.0),
1667 search_radius: 0.1,
1668 quad_tolerance: 0.007,
1669 hfd_min: 1.5,
1670 max_stars: 500,
1671 db_path: std::path::PathBuf::from("/nonexistent"),
1672 db_name: "d50".into(),
1673 binning: 1,
1674 method: SolveMethod::Quads,
1675 threads: 1,
1676 speed: SearchSpeed::Auto,
1677 };
1678 let img = ImageBuffer::new(64, 64);
1679 for (fov, radius) in [
1680 (f64::NAN, 0.1),
1681 (-1.0, 0.1),
1682 (f64::INFINITY, 0.1),
1683 (0.01, -0.1),
1684 (0.01, f64::NAN),
1685 (0.01, f64::INFINITY),
1686 ] {
1687 let p = SolveParams {
1688 fov,
1689 search_radius: radius,
1690 ..base.clone()
1691 };
1692 assert!(
1693 matches!(solve_image(&img, &p), Err(ArcsecError::InvalidParameter(_))),
1694 "fov {fov}, radius {radius}"
1695 );
1696 }
1697 }
1698
1699 #[test]
1700 fn format_radec_roundtrip() {
1701 let s = format_radec(deg(160.875), deg(-59.524));
1702 assert!(s.contains("10:"), "RA hours: {s}");
1703 assert!(s.contains('-'), "dec sign: {s}");
1704 }
1705}