1use core::f64::consts::PI;
4use std::path::PathBuf;
5
6use crate::catalog::CatalogStar;
7use crate::catalog::read_catalog_stars;
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::{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)]
35pub struct SolveParams {
36 pub ra_hint: f64,
38 pub dec_hint: f64,
40 pub fov: f64,
42 pub search_radius: f64,
44 pub quad_tolerance: f64,
46 pub hfd_min: f64,
48 pub max_stars: usize,
50 pub db_path: PathBuf,
52 pub db_name: String,
54 pub binning: usize,
57 pub method: SolveMethod,
59 pub threads: usize,
66}
67
68fn sigma_clip_pairs(
75 mut img_pos: Vec<(f64, f64)>,
76 mut cat_pos: Vec<(f64, f64)>,
77 sigma: f64,
78 min_count: usize,
79) -> PairedPositions {
80 let mut first_pass = true;
81 for _ in 0..10 {
82 if img_pos.len() < min_count.max(3) {
83 break;
84 }
85 let Ok(plate) = fit_affine(&img_pos, &cat_pos) else {
89 break;
90 };
91 let residuals: Vec<f64> = img_pos
92 .iter()
93 .zip(cat_pos.iter())
94 .map(|(&(xi, yi), &(xc, yc))| {
95 let xp = plate.a * xi + plate.b * yi + plate.c;
96 let yp = plate.d * xi + plate.e * yi + plate.f;
97 ((xp - xc).powi(2) + (yp - yc).powi(2)).sqrt()
98 })
99 .collect();
100 let rms = (residuals.iter().map(|r| r * r).sum::<f64>() / residuals.len() as f64).sqrt();
101 let threshold = if first_pass {
102 first_pass = false;
103 let cdelt = (plate.a.powi(2) + plate.d.powi(2)).sqrt();
105 let mut sorted = residuals.clone();
110 sorted.sort_unstable_by(f64::total_cmp);
111 let median = sorted[sorted.len() / 2];
112 (10.0 * cdelt).max(10.0).max(3.0 * 1.4826 * median)
113 } else {
114 sigma * rms
115 };
116 let before = img_pos.len();
117 let mut new_img = Vec::with_capacity(before);
118 let mut new_cat = Vec::with_capacity(before);
119 for ((&ip, &cp), &r) in img_pos.iter().zip(cat_pos.iter()).zip(residuals.iter()) {
120 if r <= threshold {
121 new_img.push(ip);
122 new_cat.push(cp);
123 }
124 }
125 if new_img.len() == before {
126 break; }
128 img_pos = new_img;
129 cat_pos = new_cat;
130 }
131 (img_pos, cat_pos)
132}
133
134const MIN_VERIFIED_STARS: usize = 30;
142const VERIFY_RADII: [f64; 3] = [6.0, 3.0, 2.0];
144const MIN_VERIFY_SPREAD: f64 = 0.20;
151
152type VerifyPass = (PlateConstants, usize, f64, f64);
155
156fn verify_and_refit(
168 img_stars: &StarList,
169 cat_stars: &StarList,
170 plate: &PlateConstants,
171 img_w: usize,
172 img_h: usize,
173) -> Option<(PlateConstants, usize, f64)> {
174 if img_stars.is_empty() || cat_stars.is_empty() {
175 return None;
176 }
177
178 let (mut min_x, mut min_y) = (f64::INFINITY, f64::INFINITY);
180 let (mut max_x, mut max_y) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
181 for st in &img_stars.0 {
182 min_x = min_x.min(st.x);
183 max_x = max_x.max(st.x);
184 min_y = min_y.min(st.y);
185 max_y = max_y.max(st.y);
186 }
187 if !(min_x.is_finite() && min_y.is_finite() && max_x > min_x && max_y > min_y) {
188 return None;
189 }
190 let cell = VERIFY_RADII[0].max(1.0);
191 let nx = (((max_x - min_x) / cell).ceil() as usize + 1).max(1);
192 let ny = (((max_y - min_y) / cell).ceil() as usize + 1).max(1);
193 let mut grid: Vec<Vec<u32>> = vec![Vec::new(); nx * ny];
194 for (i, st) in img_stars.0.iter().enumerate() {
195 let gx = ((st.x - min_x) / cell) as usize;
196 let gy = ((st.y - min_y) / cell) as usize;
197 grid[gy.min(ny - 1) * nx + gx.min(nx - 1)].push(i as u32);
198 }
199
200 let mut current = plate.clone();
201 let mut best: Option<VerifyPass> = None;
202
203 for &radius in &VERIFY_RADII {
204 let det = current.a * current.e - current.b * current.d;
205 if det.abs() < 1e-12 {
206 return None;
207 }
208 let r2 = radius * radius;
209
210 let mut img_pos: Vec<(f64, f64)> = Vec::new();
211 let mut cat_pos: Vec<(f64, f64)> = Vec::new();
212 let mut used = vec![false; img_stars.len()];
213
214 for cs in &cat_stars.0 {
215 let dx = cs.x - current.c;
217 let dy = cs.y - current.f;
218 let px = (current.e * dx - current.b * dy) / det;
219 let py = (-current.d * dx + current.a * dy) / det;
220 if px < min_x - radius
221 || px > max_x + radius
222 || py < min_y - radius
223 || py > max_y + radius
224 {
225 continue;
226 }
227
228 let gx = (((px - min_x) / cell) as isize).clamp(0, nx as isize - 1);
229 let gy = (((py - min_y) / cell) as isize).clamp(0, ny as isize - 1);
230 let mut best_i: Option<usize> = None;
231 let mut best_d2 = r2;
232 for oy in -1isize..=1 {
233 for ox in -1isize..=1 {
234 let cx = gx + ox;
235 let cy = gy + oy;
236 if cx < 0 || cy < 0 || cx >= nx as isize || cy >= ny as isize {
237 continue;
238 }
239 for &i in &grid[cy as usize * nx + cx as usize] {
240 let i = i as usize;
241 if used[i] {
242 continue;
243 }
244 let st = &img_stars.0[i];
245 let d2 = (st.x - px) * (st.x - px) + (st.y - py) * (st.y - py);
246 if d2 < best_d2 {
247 best_d2 = d2;
248 best_i = Some(i);
249 }
250 }
251 }
252 }
253 if let Some(i) = best_i {
254 used[i] = true; img_pos.push((img_stars.0[i].x, img_stars.0[i].y));
256 cat_pos.push((cs.x, cs.y));
257 }
258 }
259
260 if img_pos.len() < 4 {
261 break;
262 }
263 let Ok(refined) = solve_plate_constants(&img_pos, &cat_pos) else {
264 break;
265 };
266 let mut sq = 0.0;
267 for (&(xi, yi), &(xc, yc)) in img_pos.iter().zip(cat_pos.iter()) {
268 let xp = refined.a * xi + refined.b * yi + refined.c;
269 let yp = refined.d * xi + refined.e * yi + refined.f;
270 sq += (xp - xc).powi(2) + (yp - yc).powi(2);
271 }
272 let rms = (sq / img_pos.len() as f64).sqrt();
273 let n = img_pos.len() as f64;
276 let mx = img_pos.iter().map(|p| p.0).sum::<f64>() / n;
277 let my = img_pos.iter().map(|p| p.1).sum::<f64>() / n;
278 let var = img_pos
279 .iter()
280 .map(|&(x, y)| (x - mx) * (x - mx) + (y - my) * (y - my))
281 .sum::<f64>()
282 / n;
283 let half_diag = 0.5 * ((img_w * img_w + img_h * img_h) as f64).sqrt();
284 let spread = var.sqrt() / half_diag;
285 log::debug!(
286 "verify: {} stars, spread {:.3}, rms {:.2}\"",
287 img_pos.len(),
288 spread,
289 rms
290 );
291
292 best = Some((refined.clone(), img_pos.len(), rms, spread));
293 current = refined;
294 }
295
296 best.filter(|&(_, n, _, spread)| n >= MIN_VERIFIED_STARS && spread >= MIN_VERIFY_SPREAD)
297 .map(|(p, n, r, _)| (p, n, r))
298}
299
300struct SpiralCtx<'a> {
302 params: &'a SolveParams,
303 img: &'a crate::types::ImageBuffer,
304 stars: &'a StarList,
305 img_quads: &'a crate::types::QuadList,
306 img_tris: &'a crate::quads::TriangleList,
307 nrstars_image: usize,
308 nrstars_required: usize,
309 oversize: f64,
310 min_quads: usize,
311 step_size: f64,
312}
313
314struct PositionOutcome {
316 idx: usize,
317 ra_db: f64,
318 dec_db: f64,
319 sep_deg: f64,
320 plate: PlateConstants,
321 n_verified: usize,
322 rms: f64,
323 n_matched: usize,
324 n_raw: usize,
325 mag_limit: f64,
326}
327
328struct PositionTry {
332 sep_deg: Option<f64>,
333 outcome: Option<PositionOutcome>,
334}
335
336impl PositionTry {
337 const NONE: Self = Self {
338 sep_deg: None,
339 outcome: None,
340 };
341}
342
343fn try_position(ctx: &SpiralCtx<'_>, idx: usize, sx: i32, sy: i32) -> PositionTry {
346 let params = ctx.params;
347 let step_size = ctx.step_size;
348
349 let dec_db_raw = params.dec_hint + step_size * sy as f64;
350 let (dec_db, flip) = if dec_db_raw > PI / 2.0 {
351 (PI - dec_db_raw, PI)
352 } else if dec_db_raw < -PI / 2.0 {
353 (-PI - dec_db_raw, PI)
354 } else {
355 (dec_db_raw, 0.0)
356 };
357
358 let extra = if dec_db > 0.0 {
359 step_size * 0.5
360 } else {
361 -step_size * 0.5
362 };
363 let ra_offset = step_size * sx as f64 / (dec_db - extra).cos();
364 if ra_offset > PI / 2.0 + step_size * 0.5 || ra_offset < -PI / 2.0 {
365 return PositionTry::NONE;
366 }
367
368 let ra_db = (flip + params.ra_hint + ra_offset).rem_euclid(2.0 * PI);
369 let sep = ang_sep(ra_db, dec_db, params.ra_hint, params.dec_hint);
370 if sep > params.search_radius + step_size / 2.0 {
371 return PositionTry::NONE;
372 }
373
374 let cat_raw = match read_catalog_stars(
377 ¶ms.db_path,
378 ¶ms.db_name,
379 ra_db,
380 dec_db,
381 params.fov * ctx.oversize,
382 ctx.nrstars_required,
383 ) {
384 Ok(v) if !v.is_empty() => v,
385 Ok(_) | Err(_) => return PositionTry::NONE,
386 };
387
388 let sep_deg = sep.to_degrees();
389 let mag_limit = cat_raw
390 .iter()
391 .map(|s| s.mag)
392 .fold(f64::NEG_INFINITY, f64::max);
393 log::info!(
394 "Search {}, [{},{}], position: {} Down to magn {:.1} {} database stars {} database quads to compare.",
395 idx,
396 sx,
397 sy,
398 format_radec(ra_db, dec_db),
399 mag_limit,
400 cat_raw.len(),
401 cat_raw.len(),
402 );
403
404 let mut cat_stars: Vec<Star> = cat_raw
405 .iter()
406 .map(|s| {
407 let (x, y) = equatorial_standard(ra_db, dec_db, s.ra, s.dec, 1.0);
408 Star {
409 x,
410 y,
411 snr: 1.0,
412 hfd: 2.0,
413 }
414 })
415 .collect();
416 cat_stars.sort_unstable_by(|a, b| a.x.total_cmp(&b.x));
417 let cat_star_list = StarList(cat_stars);
418
419 let failed = PositionTry {
420 sep_deg: Some(sep_deg),
421 outcome: None,
422 };
423
424 let (img_pos, cat_pos, n_matched, n_raw) = match params.method {
425 SolveMethod::Quads => {
426 let mut cat_quads = build_quads_presorted(&cat_star_list, ctx.nrstars_image);
427 if cat_quads.is_empty() {
428 return failed;
429 }
430 crate::quads::r#match::sort_catalog_quads(&mut cat_quads);
431 let raw = find_matches_sorted(ctx.img_quads, &cat_quads, params.quad_tolerance);
432 let n_raw = raw.len();
433 log::info!("Found {n_raw} references");
434 let mut filtered = vote_filter(ctx.img_quads, &cat_quads, &raw, params.quad_tolerance);
435 if filtered.len() < ctx.min_quads {
436 let (by_scale, _) = filter_by_scale(&raw, params.quad_tolerance);
437 if by_scale.len() > filtered.len() {
438 filtered = by_scale;
439 }
440 }
441 if filtered.len() < ctx.min_quads {
442 return failed;
443 }
444 let (ip, cp) = extract_star_pairs(ctx.img_quads, &cat_quads, &filtered);
445 (ip, cp, filtered.len(), n_raw)
446 }
447 SolveMethod::Tetra => {
448 let cat_tris = build_triangles(&cat_star_list);
449 if cat_tris.is_empty() {
450 return failed;
451 }
452 let tol = params.quad_tolerance * TETRA_TOL_FACTOR;
453 let raw = find_triangle_matches(ctx.img_tris, &cat_tris, tol);
454 let n_raw = raw.len();
455 log::info!("Found {n_raw} triangle references");
456 let biject = bijective_filter(&raw, ctx.img_tris, &cat_tris);
457 let (filtered, _) = filter_triangles_by_scale(&biject, params.quad_tolerance);
458 if filtered.len() < ctx.min_quads {
459 return failed;
460 }
461 let (ip, cp) = extract_triangle_pairs(ctx.img_tris, &cat_tris, &filtered);
462 let (ip, cp) = sigma_clip_pairs(ip, cp, 3.0, ctx.min_quads);
463 if ip.len() < ctx.min_quads {
464 return failed;
465 }
466 let n_clean = ip.len();
467 (ip, cp, n_clean, n_raw)
468 }
469 };
470
471 let Ok(plate) = solve_plate_constants(&img_pos, &cat_pos) else {
472 return failed;
473 };
474
475 let Some((plate, n_verified, rms)) = verify_and_refit(
476 ctx.stars,
477 &cat_star_list,
478 &plate,
479 ctx.img.width,
480 ctx.img.height,
481 ) else {
482 log::info!("Verification failed at this position; continuing search.");
483 return failed;
484 };
485 log::info!("Verified {n_verified} stars against the catalogue, residual {rms:.2}\"");
486
487 let (plate, ra_db, dec_db, n_verified, rms) =
488 recentre(ctx, &cat_raw, plate, ra_db, dec_db, n_verified, rms);
489
490 PositionTry {
491 sep_deg: Some(sep_deg),
492 outcome: Some(PositionOutcome {
493 idx,
494 ra_db,
495 dec_db,
496 sep_deg,
497 plate,
498 n_verified,
499 rms,
500 n_matched,
501 n_raw,
502 mag_limit,
503 }),
504 }
505}
506
507fn recentre(
524 ctx: &SpiralCtx<'_>,
525 cat_raw: &[CatalogStar],
526 mut plate: PlateConstants,
527 mut ra_db: f64,
528 mut dec_db: f64,
529 mut n_verified: usize,
530 mut rms: f64,
531) -> (PlateConstants, f64, f64, usize, f64) {
532 let (w, h) = (ctx.img.width as f64, ctx.img.height as f64);
533 let (cx, cy) = ((w - 1.0) * 0.5, (h - 1.0) * 0.5);
534 let apply =
535 |p: &PlateConstants, x: f64, y: f64| (p.a * x + p.b * y + p.c, p.d * x + p.e * y + p.f);
536
537 for _ in 0..2 {
538 let (xs, ys) = apply(&plate, cx, cy);
539 if xs.hypot(ys) < 1e-3 {
541 break;
542 }
543 let (ra0, dec0) = standard_equatorial(ra_db, dec_db, xs, ys, 1.0);
544
545 let det = plate.a * plate.e - plate.b * plate.d;
551 if det.abs() < 1e-12 {
552 break;
553 }
554 let r2 = VERIFY_RADII[0] * VERIFY_RADII[0];
555 let mut used = vec![false; ctx.stars.len()];
556 let mut img_pos = Vec::new();
557 let mut new_pos = Vec::new();
558 let mut cat = Vec::with_capacity(cat_raw.len());
559 for s in cat_raw {
560 let (nx, ny) = equatorial_standard(ra0, dec0, s.ra, s.dec, 1.0);
561 cat.push(Star {
562 x: nx,
563 y: ny,
564 snr: 1.0,
565 hfd: 2.0,
566 });
567 let (ox, oy) = equatorial_standard(ra_db, dec_db, s.ra, s.dec, 1.0);
568 let (dx, dy) = (ox - plate.c, oy - plate.f);
569 let px = (plate.e * dx - plate.b * dy) / det;
570 let py = (-plate.d * dx + plate.a * dy) / det;
571 let nearest = ctx
572 .stars
573 .0
574 .iter()
575 .enumerate()
576 .filter(|&(i, _)| !used[i])
577 .map(|(i, st)| (i, (st.x - px).powi(2) + (st.y - py).powi(2)))
578 .filter(|&(_, d2)| d2 < r2)
579 .min_by(|a, b| a.1.total_cmp(&b.1));
580 if let Some((i, _)) = nearest {
581 used[i] = true;
582 img_pos.push((ctx.stars.0[i].x, ctx.stars.0[i].y));
583 new_pos.push((nx, ny));
584 }
585 }
586 let Ok(guess) = solve_plate_constants(&img_pos, &new_pos) else {
587 break;
588 };
589 let cat = StarList(cat);
590 let Some((p, n, r)) =
591 verify_and_refit(ctx.stars, &cat, &guess, ctx.img.width, ctx.img.height)
592 else {
593 log::info!("Re-centring on the image centre did not verify; keeping the fit.");
594 break;
595 };
596 log::info!("Re-centred on the image centre: verified {n} stars, residual {r:.2}\"");
597 (plate, ra_db, dec_db, n_verified, rms) = (p, ra0, dec0, n, r);
598 }
599 (plate, ra_db, dec_db, n_verified, rms)
600}
601
602pub fn solve_image(img: &crate::types::ImageBuffer, params: &SolveParams) -> Result<WcsSolution> {
620 if !(params.fov.is_finite() && params.fov > 0.0) {
623 return Err(ArcsecError::InvalidParameter(format!(
624 "field of view must be positive, got {} rad",
625 params.fov
626 )));
627 }
628 if !(params.search_radius.is_finite() && params.search_radius >= 0.0) {
629 return Err(ArcsecError::InvalidParameter(format!(
630 "search radius must be non-negative, got {} rad",
631 params.search_radius
632 )));
633 }
634
635 if !crate::catalog::catalog_present(¶ms.db_path, ¶ms.db_name) {
640 return Err(ArcsecError::CatalogNotFound(params.db_path.clone()));
641 }
642
643 let bg = get_background(img, params.max_stars);
645 log::info!("Start finding stars");
646 let (stars, stars_raw) = find_stars_with_background(
647 img,
648 &bg,
649 params.hfd_min,
650 params.max_stars,
651 img.width,
652 img.height,
653 );
654 log::info!(
655 "{} stars found of the requested {}. Background value is {:.0}. \
656 Detection level used {:.0} above background. Star level is {:.0} above background. \
657 Noise level is {:.0}",
658 stars_raw,
659 params.max_stars,
660 bg.mean,
661 bg.star_level,
662 bg.star_level,
663 bg.noise,
664 );
665 if stars_raw > params.max_stars {
666 log::info!("Selecting the {} brightest stars only.", params.max_stars);
667 }
668
669 let nrstars_image = stars.len();
676 if nrstars_image < 5 {
677 return Err(ArcsecError::InsufficientStars {
678 found: nrstars_image,
679 required: 5,
680 });
681 }
682
683 let img_quads = build_quads(&stars, nrstars_image);
685 let nr_quads = img_quads.len();
686
687 let img_tris = if params.method == SolveMethod::Tetra {
688 build_triangles(&stars)
689 } else {
690 crate::quads::TriangleList::default()
691 };
692
693 let patterns_empty = match params.method {
694 SolveMethod::Quads => nr_quads == 0,
695 SolveMethod::Tetra => img_tris.is_empty(),
696 };
697 if patterns_empty {
698 return Err(ArcsecError::InsufficientQuads {
699 found: 0,
700 required: 3,
701 });
702 }
703
704 let min_quads: usize = 3 + nrstars_image / 140;
705
706 let oversize: f64 = if nrstars_image < 35 {
707 2.0
708 } else if nrstars_image > 140 {
709 1.0
710 } else {
711 2.0 * (35.0 / nrstars_image as f64).sqrt()
712 };
713
714 let nrstars_required = (params.max_stars as f64 * oversize * oversize).round() as usize;
716 let step_size = params.fov;
717 let fov_deg = step_size.to_degrees();
718 let max_distance = (params.search_radius / step_size + 2.0) as i32;
719
720 log::info!(
721 "{} stars, {} quads selected in the image. {} database stars, {} database quads required \
722 for the {:.2}d square search window. Step size {:.2}d. Oversize {:.2}",
723 nrstars_image,
724 nr_quads,
725 nrstars_required,
726 nrstars_required,
727 fov_deg * oversize,
728 fov_deg,
729 oversize,
730 );
731
732 let ctx = SpiralCtx {
740 params,
741 img,
742 stars: &stars,
743 img_quads: &img_quads,
744 img_tris: &img_tris,
745 nrstars_image,
746 nrstars_required,
747 oversize,
748 min_quads,
749 step_size,
750 };
751
752 let n_threads = if params.threads > 0 {
753 params.threads
754 } else {
755 crate::max_threads()
756 }
757 .clamp(1, 64);
758
759 let positions: Vec<(i32, i32)> = SpiralSearch::new(max_distance).collect();
760 let mut step_distances: Vec<f64> = Vec::new();
761
762 let mut winner: Option<PositionOutcome> = None;
763 let mut start_idx = 0usize;
764 while start_idx < positions.len() && winner.is_none() {
765 let batch_len = if start_idx == 0 {
768 1
769 } else {
770 n_threads.min(positions.len() - start_idx)
771 };
772 let batch = &positions[start_idx..start_idx + batch_len];
773
774 let tries: Vec<PositionTry> = if n_threads == 1 || batch.len() == 1 {
775 batch
776 .iter()
777 .enumerate()
778 .map(|(k, &(sx, sy))| try_position(&ctx, start_idx + k, sx, sy))
779 .collect()
780 } else {
781 std::thread::scope(|scope| {
782 let handles: Vec<_> = batch
783 .iter()
784 .enumerate()
785 .map(|(k, &(sx, sy))| {
786 let ctx = &ctx;
787 scope.spawn(move || try_position(ctx, start_idx + k, sx, sy))
788 })
789 .collect();
790 handles
791 .into_iter()
792 .map(|h| h.join().unwrap_or_else(|e| std::panic::resume_unwind(e)))
796 .collect()
797 })
798 };
799
800 for t in tries {
801 if let Some(d) = t.sep_deg {
802 step_distances.push(d);
803 }
804 if let Some(o) = t.outcome
805 && winner.as_ref().is_none_or(|w| o.idx < w.idx)
806 {
807 winner = Some(o);
808 }
809 }
810
811 start_idx += batch_len;
812 }
813
814 if let Some(o) = winner {
815 log::info!(
816 "{} of {} patterns selected matching within {:.3} tolerance.",
817 o.n_matched,
818 o.n_raw,
819 params.quad_tolerance,
820 );
821
822 let mut wcs = derive_wcs(o.ra_db, o.dec_db, &o.plate, img.width, img.height);
823 if params.binning > 1 {
824 let b = params.binning as f64;
825 wcs.crpix1 = (wcs.crpix1 - 0.5) * b + 0.5;
826 wcs.crpix2 = (wcs.crpix2 - 0.5) * b + 0.5;
827 wcs.cd1_1 /= b;
828 wcs.cd1_2 /= b;
829 wcs.cd2_1 /= b;
830 wcs.cd2_2 /= b;
831 wcs.cdelt1 /= b;
832 wcs.cdelt2 /= b;
833 }
834 wcs.residual_rms = o.rms;
835 wcs.stars_matched = o.n_verified;
836 wcs.raw_matches = o.n_raw;
837 wcs.plate = o.plate;
838 wcs.mag_limit = o.mag_limit;
839 wcs.search_dist_deg = o.sep_deg;
840 wcs.step_distances = step_distances;
841 return Ok(wcs);
842 }
843
844 Err(ArcsecError::InsufficientQuads {
845 found: 0,
846 required: min_quads,
847 })
848}
849
850#[must_use]
852pub fn format_radec(ra_rad: f64, dec_rad: f64) -> String {
853 const TENTHS_PER_DAY: f64 = 24.0 * 36_000.0;
857 let ra_tenths = ((ra_rad.to_degrees() / 15.0 * 36_000.0)
858 .round()
859 .rem_euclid(TENTHS_PER_DAY)) as u64;
860 let h = ra_tenths / 36_000;
861 let m = ra_tenths / 600 % 60;
862 let s = (ra_tenths % 600) as f64 / 10.0;
863
864 let dec_deg = dec_rad.to_degrees();
865 let sign = if dec_deg < 0.0 { '-' } else { '+' };
866 let dec_secs = (dec_deg.abs() * 3600.0).round() as u64;
867 let dd = dec_secs / 3600;
868 let dm = dec_secs / 60 % 60;
869 let ds = dec_secs % 60;
870
871 format!("{h}: {m:02} {s:.1} {sign}{dd}d {dm:02} {ds}")
872}
873
874#[cfg(test)]
875mod tests {
876 use super::*;
877 use crate::math::coords::{ang_sep, standard_equatorial};
878 use crate::test_support::{
879 Rng, SkySpec, TempDir, TruthWcs, random_sky, render, write_001_db, write_290_db,
880 write_1476_db,
881 };
882 use crate::types::{ImageBuffer, PlateConstants};
883 use crate::wcs::output::derive_wcs;
884 use core::f64::consts::PI;
885
886 fn deg(d: f64) -> f64 {
887 d * PI / 180.0
888 }
889
890 fn make_test_scene(
891 n_stars: usize,
892 ra_center: f64,
893 dec_center: f64,
894 cdelt_arcsec: f64,
895 width: usize,
896 height: usize,
897 ) -> (ImageBuffer, Vec<(f64, f64)>, PlateConstants) {
898 let mut data = vec![100.0f32; width * height];
899 let mut catalog_sky: Vec<(f64, f64)> = Vec::new();
900 let stars_per_row = (n_stars as f64).sqrt().ceil() as usize;
901 let spacing = 40.0;
902 let cx = (width as f64 - 1.0) / 2.0;
903 let cy = (height as f64 - 1.0) / 2.0;
904 let a = cdelt_arcsec;
905 let c = -a * cx;
906 let e = cdelt_arcsec;
907 let f_offset = -e * cy;
908 let plate = PlateConstants {
909 a,
910 b: 0.0,
911 c,
912 d: 0.0,
913 e,
914 f: f_offset,
915 };
916 let mut count = 0;
917 'outer: for row in 0..stars_per_row {
918 for col in 0..stars_per_row {
919 if count >= n_stars {
920 break 'outer;
921 }
922 let px = 20.0 + col as f64 * spacing;
923 let py = 20.0 + row as f64 * spacing;
924 if px >= width as f64 - 20.0 || py >= height as f64 - 20.0 {
925 continue;
926 }
927 let x_std = a * px + c;
928 let y_std = e * py + f_offset;
929 let (ra, dec) = standard_equatorial(ra_center, dec_center, x_std, y_std, 1.0);
930 catalog_sky.push((ra, dec));
931 let sigma = 2.0;
932 let amp = 30000.0f32;
933 for dy in -8i32..=8 {
934 for dx in -8i32..=8 {
935 let x = (px as i32 + dx) as usize;
936 let y = (py as i32 + dy) as usize;
937 if x < width && y < height {
938 let r2 = (dx * dx + dy * dy) as f64 / (2.0 * sigma * sigma);
939 data[y * width + x] += amp * (-r2).exp() as f32;
940 }
941 }
942 }
943 count += 1;
944 }
945 }
946 let img = ImageBuffer {
947 data,
948 width,
949 height,
950 };
951 (img, catalog_sky, plate)
952 }
953
954 #[test]
955 fn derive_wcs_recovers_position() {
956 let ra_center = deg(45.0);
957 let dec_center = deg(30.0);
958 let (img, _cat, plate) = make_test_scene(16, ra_center, dec_center, 2.0, 300, 300);
959 let wcs = derive_wcs(ra_center, dec_center, &plate, img.width, img.height);
960 let sep_arcsec = ang_sep(wcs.ra0, wcs.dec0, ra_center, dec_center) * (180.0 / PI * 3600.0);
961 assert!(sep_arcsec < 0.5, "centre offset = {sep_arcsec} arcsec");
962 }
963
964 #[test]
965 fn spiral_covers_origin_first() {
966 assert_eq!(SpiralSearch::new(5).next(), Some((0, 0)));
967 }
968
969 #[test]
970 fn oversize_formula_limits() {
971 for n in [10, 35, 70, 140, 200] {
972 let ov: f64 = if n < 35 {
973 2.0
974 } else if n > 140 {
975 1.0
976 } else {
977 2.0 * (35.0 / n as f64).sqrt()
978 };
979 assert!((1.0..=2.0).contains(&ov), "oversize={ov} for n={n}");
980 }
981 }
982
983 #[test]
984 fn format_radec_carries_rounded_seconds() {
985 let ra = deg((1.0 + 59.0 / 60.0 + 59.97 / 3600.0) * 15.0);
987 let dec = deg(10.0 + 59.0 / 60.0 + 59.7 / 3600.0);
989 assert_eq!(format_radec(ra, dec), "2: 00 0.0 +11d 00 0");
990 let s = format_radec(deg(359.999_999_9), deg(-0.5));
992 assert!(s.starts_with("0: 00 0.0 -0d 30 0"), "{s}");
993 assert_eq!(
995 format_radec(deg((5.0 + 35.0 / 60.0 + 17.3 / 3600.0) * 15.0), deg(-5.39)),
996 "5: 35 17.3 -5d 23 24"
997 );
998 }
999
1000 #[test]
1001 fn solve_image_rejects_a_non_positive_fov() {
1002 let img = ImageBuffer::new(64, 64);
1003 let params = SolveParams {
1004 ra_hint: 0.0,
1005 dec_hint: 0.0,
1006 fov: 0.0,
1007 search_radius: 0.1,
1008 quad_tolerance: 0.007,
1009 hfd_min: 1.5,
1010 max_stars: 500,
1011 db_path: std::path::PathBuf::from("/nonexistent"),
1012 db_name: "d50".into(),
1013 binning: 1,
1014 method: SolveMethod::Quads,
1015 threads: 1,
1016 };
1017 assert!(matches!(
1018 solve_image(&img, ¶ms),
1019 Err(ArcsecError::InvalidParameter(_))
1020 ));
1021 }
1022
1023 fn known_plate() -> PlateConstants {
1027 let (s, r) = (3.2_f64, 0.61_f64);
1028 PlateConstants {
1029 a: -s * r.cos(),
1030 b: s * r.sin(),
1031 c: 640.0,
1032 d: s * r.sin(),
1033 e: s * r.cos(),
1034 f: -512.0,
1035 }
1036 }
1037
1038 fn apply(p: &PlateConstants, (x, y): (f64, f64)) -> (f64, f64) {
1039 (p.a * x + p.b * y + p.c, p.d * x + p.e * y + p.f)
1040 }
1041
1042 fn plate_close(p: &PlateConstants, q: &PlateConstants, tol: f64) -> bool {
1043 [
1044 (p.a, q.a),
1045 (p.b, q.b),
1046 (p.c, q.c),
1047 (p.d, q.d),
1048 (p.e, q.e),
1049 (p.f, q.f),
1050 ]
1051 .iter()
1052 .all(|(u, v)| (u - v).abs() <= tol)
1053 }
1054
1055 fn star_at(x: f64, y: f64) -> Star {
1056 Star {
1057 x,
1058 y,
1059 snr: 50.0,
1060 hfd: 2.5,
1061 }
1062 }
1063
1064 fn pairs_with_outliers(outlier: impl Fn(usize, (f64, f64)) -> (f64, f64)) -> PairedPositions {
1067 let plate = known_plate();
1068 let mut rng = Rng::new(7);
1069 let mut img = Vec::new();
1070 let mut cat = Vec::new();
1071 for _ in 0..40 {
1072 let p = (rng.range(0.0, 500.0), rng.range(0.0, 500.0));
1073 img.push(p);
1074 cat.push(apply(&plate, p));
1075 }
1076 for k in 0..5 {
1077 let p = (rng.range(0.0, 500.0), rng.range(0.0, 500.0));
1078 img.push(p);
1079 cat.push(outlier(k, apply(&plate, p)));
1080 }
1081 (img, cat)
1082 }
1083
1084 #[test]
1085 fn sigma_clip_pairs_rejects_outliers_and_keeps_the_rest() {
1086 let (img, cat) = pairs_with_outliers(|k, (x, y)| {
1088 let a = k as f64 * 1.3;
1089 (x + 100.0 * a.cos(), y + 100.0 * a.sin())
1090 });
1091 let (ci, cc) = sigma_clip_pairs(img, cat, 3.0, 3);
1092 assert_eq!(ci.len(), 40, "all and only the true pairs survive");
1093 let fit = solve_plate_constants(&ci, &cc).unwrap();
1094 assert!(plate_close(&fit, &known_plate(), 1e-6), "{fit:?}");
1095 }
1096
1097 #[test]
1105 fn sigma_clip_pairs_rejects_gross_outliers() {
1106 let (img, cat) =
1107 pairs_with_outliers(|k, _| (1000.0 + 150.0 * k as f64, -900.0 + 70.0 * k as f64));
1108 assert!(matches!(
1109 solve_plate_constants(&img, &cat),
1110 Err(ArcsecError::BadSolution { .. })
1111 ));
1112 let (ci, _) = sigma_clip_pairs(img, cat, 3.0, 3);
1113 assert_eq!(ci.len(), 40, "the five gross outliers should be clipped");
1114 }
1115
1116 #[test]
1117 fn sigma_clip_pairs_leaves_too_few_pairs_alone() {
1118 let img = vec![(0.0, 0.0), (1.0, 0.0)];
1119 let cat = vec![(5.0, 5.0), (9.0, 9.0)];
1120 let (ci, cc) = sigma_clip_pairs(img.clone(), cat.clone(), 3.0, 3);
1121 assert_eq!((ci, cc), (img, cat));
1122 }
1123
1124 #[test]
1125 fn verify_and_refit_recovers_the_plate_from_a_rough_guess() {
1126 let truth = known_plate();
1127 let mut rng = Rng::new(11);
1128 let mut img_stars = Vec::new();
1129 let mut cat_stars = Vec::new();
1130 for _ in 0..60 {
1131 let (x, y) = (rng.range(5.0, 395.0), rng.range(5.0, 295.0));
1132 img_stars.push(star_at(x, y));
1133 let (cx, cy) = apply(&truth, (x, y));
1134 cat_stars.push(star_at(cx, cy));
1135 }
1136 for k in 0..20 {
1138 let (cx, cy) = apply(&truth, (-300.0 - 10.0 * k as f64, 900.0));
1139 cat_stars.push(star_at(cx, cy));
1140 }
1141 let mut rough = truth.clone();
1143 rough.c += 2.0 * truth.a;
1144 rough.f += 2.0 * truth.e;
1145 rough.b += 0.01;
1146 let (refined, n, rms) =
1147 verify_and_refit(&StarList(img_stars), &StarList(cat_stars), &rough, 400, 300)
1148 .expect("a correct plate must verify");
1149 assert_eq!(n, 60);
1150 assert!(rms < 1e-6, "rms {rms}");
1151 assert!(plate_close(&refined, &truth, 1e-6), "{refined:?}");
1152 }
1153
1154 #[test]
1155 fn verify_and_refit_rejects_too_few_or_clustered_matches() {
1156 let truth = known_plate();
1157 let mut rng = Rng::new(12);
1158 let build = |pts: &[(f64, f64)]| {
1159 let img = StarList(pts.iter().map(|&(x, y)| star_at(x, y)).collect());
1160 let cat = StarList(
1161 pts.iter()
1162 .map(|&p| apply(&truth, p))
1163 .map(|(x, y)| star_at(x, y))
1164 .collect(),
1165 );
1166 (img, cat)
1167 };
1168
1169 let few: Vec<_> = (0..20)
1171 .map(|_| (rng.range(0.0, 400.0), rng.range(0.0, 300.0)))
1172 .collect();
1173 let (img, cat) = build(&few);
1174 assert!(verify_and_refit(&img, &cat, &truth, 400, 300).is_none());
1175
1176 let clustered: Vec<_> = (0..80)
1178 .map(|_| (rng.range(0.0, 40.0), rng.range(0.0, 40.0)))
1179 .collect();
1180 let (img, cat) = build(&clustered);
1181 assert!(verify_and_refit(&img, &cat, &truth, 400, 300).is_none());
1182
1183 let spread: Vec<_> = (0..80)
1185 .map(|_| (rng.range(0.0, 400.0), rng.range(0.0, 300.0)))
1186 .collect();
1187 let (img, cat) = build(&spread);
1188 assert!(verify_and_refit(&img, &cat, &truth, 400, 300).is_some());
1189
1190 let empty = StarList::default();
1192 assert!(verify_and_refit(&empty, &cat, &truth, 400, 300).is_none());
1193 let mut singular = truth.clone();
1194 singular.a = 0.0;
1195 singular.b = 0.0;
1196 assert!(verify_and_refit(&img, &cat, &singular, 400, 300).is_none());
1197 }
1198
1199 #[derive(Clone, Copy)]
1202 enum Db {
1203 Areas1476,
1204 Areas290,
1205 AllSky001,
1206 }
1207
1208 struct Scene {
1210 dir: TempDir,
1211 img: ImageBuffer,
1212 truth: TruthWcs,
1213 }
1214
1215 fn scene(truth: TruthWcs, db: Db, n_in_frame: usize, seed: u64) -> Scene {
1218 let mut rng = Rng::new(seed);
1219 let scale_deg = truth.cd[1].hypot(truth.cd[3]);
1220 let (w_deg, h_deg) = (
1221 truth.width as f64 * scale_deg,
1222 truth.height as f64 * scale_deg,
1223 );
1224 let side = 6.0 * w_deg.max(h_deg);
1225 let sky = random_sky(
1226 &mut rng,
1227 &SkySpec {
1228 ra0: truth.ra0,
1229 dec0: truth.dec0,
1230 side_deg: side,
1231 n: (n_in_frame as f64 * side * side / (w_deg * h_deg)) as usize,
1232 min_sep_deg: 12.0 * scale_deg,
1233 mag_lo: 10.0,
1234 mag_hi: 14.5,
1235 },
1236 );
1237 let sigma = 1.3 * 5.0 / (scale_deg * 3600.0);
1239 let img = render(
1240 &truth,
1241 &sky,
1242 sigma.max(1.3),
1243 1000.0,
1244 8.0,
1245 30_000.0,
1246 &mut rng,
1247 );
1248 let dir = TempDir::new("solve");
1249 match db {
1250 Db::Areas1476 => write_1476_db(dir.path(), "t50", &sky),
1251 Db::Areas290 => write_290_db(dir.path(), "t50", &sky),
1252 Db::AllSky001 => write_001_db(dir.path(), "t50", &sky),
1253 }
1254 Scene { dir, img, truth }
1255 }
1256
1257 fn params_for(s: &Scene, ra_hint: f64, dec_hint: f64) -> SolveParams {
1258 SolveParams {
1259 ra_hint,
1260 dec_hint,
1261 fov: (s.truth.height as f64 * s.truth.cd[1].hypot(s.truth.cd[3])).to_radians(),
1262 search_radius: deg(2.0),
1263 quad_tolerance: 0.007,
1264 hfd_min: 1.5,
1265 max_stars: 500,
1266 db_path: s.dir.path().to_path_buf(),
1267 db_name: "t50".into(),
1268 binning: 1,
1269 method: SolveMethod::Quads,
1270 threads: 1,
1271 }
1272 }
1273
1274 fn assert_solved(s: &Scene, wcs: &WcsSolution, tol_arcsec: f64) {
1275 let err = s.truth.max_error_arcsec(wcs);
1276 assert!(
1277 err < tol_arcsec,
1278 "worst centre/corner error {err:.3}\" (matched {}, rms {:.3})",
1279 wcs.stars_matched,
1280 wcs.residual_rms
1281 );
1282 assert!(wcs.stars_matched >= MIN_VERIFIED_STARS);
1283 let scale_arcsec = s.truth.cd[1].hypot(s.truth.cd[3]) * 3600.0;
1285 assert!(
1286 wcs.residual_rms < 0.3 * scale_arcsec,
1287 "rms {}",
1288 wcs.residual_rms
1289 );
1290 assert!(wcs.raw_matches > 0);
1291 assert!(wcs.mag_limit > 10.0 && wcs.mag_limit <= 14.5);
1292 assert!(
1293 wcs.cdelt1 < 0.0 && wcs.cdelt2 > 0.0,
1294 "CDELT sign convention"
1295 );
1296 }
1297
1298 #[test]
1299 fn solves_a_1476_database_from_an_offset_hint() {
1300 let truth = TruthWcs::new(deg(84.3), deg(-5.2), 5.0, 23.0, false, 400, 320);
1301 let s = scene(truth, Db::Areas1476, 130, 1);
1302 let mut p = params_for(&s, deg(84.3 + 0.6), deg(-5.2 - 0.45));
1304 p.threads = 4;
1305 let wcs = solve_image(&s.img, &p).expect("solve");
1306 assert_solved(&s, &wcs, 1.0);
1307 assert!(wcs.search_dist_deg > 0.1, "solved at the hint itself?");
1308 assert!(wcs.step_distances.len() > 1);
1309 assert!((wcs.cdelt2 * 3600.0 - 5.0).abs() < 0.01, "{}", wcs.cdelt2);
1311 assert!((wcs.crota2 - 23.0).abs() < 0.05, "crota2 {}", wcs.crota2);
1312 }
1313
1314 #[test]
1315 fn solves_a_mirrored_image_on_a_290_database() {
1316 let truth = TruthWcs::new(deg(201.0), deg(47.5), 6.0, 160.0, true, 360, 360);
1317 let s = scene(truth, Db::Areas290, 120, 2);
1318 let wcs = solve_image(&s.img, ¶ms_for(&s, truth.ra0, truth.dec0)).expect("solve");
1319 assert_solved(&s, &wcs, 1.0);
1320 assert!(wcs.search_dist_deg < 1e-9, "should solve at the hint");
1321 assert!(wcs.cd1_1 * wcs.cd2_2 - wcs.cd1_2 * wcs.cd2_1 > 0.0);
1323 }
1324
1325 #[test]
1326 fn solves_across_ra_zero_with_an_all_sky_001_database() {
1327 let truth = TruthWcs::new(deg(0.05), deg(21.0), 5.0, -70.0, false, 360, 300);
1329 let s = scene(truth, Db::AllSky001, 120, 3);
1330 let wcs = solve_image(&s.img, ¶ms_for(&s, truth.ra0, truth.dec0)).expect("solve");
1331 assert_solved(&s, &wcs, 1.0);
1332 }
1333
1334 #[test]
1335 fn solves_across_ra_zero_with_a_1476_database() {
1336 let truth = TruthWcs::new(deg(359.97), deg(-33.0), 5.0, 95.0, false, 360, 300);
1337 let s = scene(truth, Db::Areas1476, 120, 4);
1338 let wcs = solve_image(&s.img, ¶ms_for(&s, truth.ra0, truth.dec0)).expect("solve");
1339 assert_solved(&s, &wcs, 1.0);
1340 }
1341
1342 #[test]
1343 fn solves_a_field_near_the_celestial_pole() {
1344 let truth = TruthWcs::new(deg(40.0), deg(88.9), 5.0, 10.0, false, 360, 300);
1345 let s = scene(truth, Db::Areas1476, 120, 5);
1346 let wcs = solve_image(&s.img, ¶ms_for(&s, truth.ra0, truth.dec0)).expect("solve");
1347 assert_solved(&s, &wcs, 1.0);
1348 }
1349
1350 #[test]
1364 fn accuracy_does_not_depend_on_the_hint_offset() {
1365 let truth = TruthWcs::new(deg(150.0), deg(30.0), 15.0, 20.0, false, 360, 300);
1366 let s = scene(truth, Db::Areas1476, 120, 21);
1367 let off = 0.4;
1368 let p = params_for(&s, deg(150.0 + off / deg(30.0).cos()), deg(30.0 + off));
1369 let wcs = solve_image(&s.img, &p).expect("solve");
1370 assert!(wcs.search_dist_deg < 1e-9, "solved at the hint");
1371 let err = s.truth.max_error_arcsec(&wcs);
1372 assert!(
1373 err < 5.0,
1374 "worst corner error {err:.2}\" with a {off}° hint offset"
1375 );
1376 }
1377
1378 #[test]
1379 fn solves_with_the_tetra_method() {
1380 let truth = TruthWcs::new(deg(150.0), deg(2.0), 5.0, 45.0, false, 360, 300);
1381 let s = scene(truth, Db::Areas1476, 110, 6);
1382 let mut p = params_for(&s, truth.ra0, truth.dec0);
1383 p.method = SolveMethod::Tetra;
1384 let wcs = solve_image(&s.img, &p).expect("solve");
1385 assert_solved(&s, &wcs, 1.0);
1386 }
1387
1388 #[test]
1389 fn binned_solve_is_reported_on_the_unbinned_pixel_grid() {
1390 let truth = TruthWcs::new(deg(10.0), deg(40.0), 2.5, 30.0, false, 720, 600);
1392 let s = scene(truth, Db::Areas1476, 120, 7);
1393 let binned = s.img.bin_image(2);
1394 assert_eq!((binned.width, binned.height), (360, 300));
1395 let mut p = params_for(&s, truth.ra0, truth.dec0);
1396 p.binning = 2;
1397 let wcs = solve_image(&binned, &p).expect("solve");
1398 assert!((wcs.crpix1 - 360.5).abs() < 1e-9, "crpix1 {}", wcs.crpix1);
1400 assert!((wcs.crpix2 - 300.5).abs() < 1e-9, "crpix2 {}", wcs.crpix2);
1401 assert!((wcs.cdelt2 * 3600.0 - 2.5).abs() < 0.01, "{}", wcs.cdelt2);
1402 let err = s.truth.max_error_arcsec(&wcs);
1403 assert!(err < 2.0, "worst corner error {err:.3}\"");
1404 }
1405
1406 #[test]
1407 fn a_field_absent_from_the_catalogue_does_not_solve() {
1408 let truth = TruthWcs::new(deg(120.0), deg(-40.0), 5.0, 0.0, false, 360, 300);
1411 let s = scene(truth, Db::Areas1476, 120, 8);
1412 let decoy = TempDir::new("decoy");
1413 let mut rng = Rng::new(99);
1414 let other = random_sky(
1415 &mut rng,
1416 &SkySpec {
1417 ra0: truth.ra0,
1418 dec0: truth.dec0,
1419 side_deg: 3.0,
1420 n: 4000,
1421 min_sep_deg: 0.015,
1422 mag_lo: 10.0,
1423 mag_hi: 14.5,
1424 },
1425 );
1426 write_1476_db(decoy.path(), "t50", &other);
1427 let mut p = params_for(&s, truth.ra0, truth.dec0);
1428 p.db_path = decoy.path().to_path_buf();
1429 p.search_radius = deg(0.5);
1430 match solve_image(&s.img, &p) {
1431 Err(ArcsecError::InsufficientQuads { found: 0, required }) => {
1432 assert!(required >= 3);
1433 }
1434 other => panic!("expected InsufficientQuads, got {other:?}"),
1435 }
1436 }
1437
1438 #[test]
1439 fn a_corrupt_catalogue_tile_is_skipped_not_fatal() {
1440 let truth = TruthWcs::new(deg(120.0), deg(-40.0), 5.0, 0.0, false, 360, 300);
1441 let s = scene(truth, Db::Areas1476, 120, 9);
1442 for entry in std::fs::read_dir(s.dir.path()).unwrap() {
1444 let path = entry.unwrap().path();
1445 let mut bytes = std::fs::read(&path).unwrap();
1446 bytes[109] = 7;
1447 std::fs::write(&path, bytes).unwrap();
1448 }
1449 let mut p = params_for(&s, truth.ra0, truth.dec0);
1450 p.search_radius = 0.0;
1451 assert!(matches!(
1452 solve_image(&s.img, &p),
1453 Err(ArcsecError::InsufficientQuads { .. })
1454 ));
1455 }
1456
1457 #[test]
1458 fn a_blank_frame_reports_insufficient_stars() {
1459 let dir = TempDir::new("blank");
1460 write_1476_db(dir.path(), "t50", &[]);
1461 let mut rng = Rng::new(3);
1462 let img = ImageBuffer {
1463 data: (0..200 * 200)
1464 .map(|_| (1000.0 + 5.0 * rng.gauss()) as f32)
1465 .collect(),
1466 width: 200,
1467 height: 200,
1468 };
1469 let p = SolveParams {
1470 ra_hint: 0.0,
1471 dec_hint: 0.0,
1472 fov: deg(0.3),
1473 search_radius: deg(1.0),
1474 quad_tolerance: 0.007,
1475 hfd_min: 1.5,
1476 max_stars: 500,
1477 db_path: dir.path().to_path_buf(),
1478 db_name: "t50".into(),
1479 binning: 1,
1480 method: SolveMethod::Quads,
1481 threads: 1,
1482 };
1483 match solve_image(&img, &p) {
1484 Err(ArcsecError::InsufficientStars { found, required: 5 }) => assert!(found < 5),
1485 other => panic!("expected InsufficientStars, got {other:?}"),
1486 }
1487 }
1488
1489 #[test]
1490 fn a_missing_database_is_reported_before_any_detection() {
1491 let dir = TempDir::new("nodb");
1492 let p = SolveParams {
1493 ra_hint: 0.0,
1494 dec_hint: 0.0,
1495 fov: deg(1.0),
1496 search_radius: deg(1.0),
1497 quad_tolerance: 0.007,
1498 hfd_min: 1.5,
1499 max_stars: 500,
1500 db_path: dir.path().to_path_buf(),
1501 db_name: "d50".into(),
1502 binning: 1,
1503 method: SolveMethod::Quads,
1504 threads: 1,
1505 };
1506 match solve_image(&ImageBuffer::new(64, 64), &p) {
1507 Err(ArcsecError::CatalogNotFound(path)) => assert_eq!(path, dir.path()),
1508 other => panic!("expected CatalogNotFound, got {other:?}"),
1509 }
1510 }
1511
1512 #[test]
1513 fn solve_image_rejects_a_bad_search_radius_or_fov() {
1514 let base = SolveParams {
1515 ra_hint: 0.0,
1516 dec_hint: 0.0,
1517 fov: deg(1.0),
1518 search_radius: 0.1,
1519 quad_tolerance: 0.007,
1520 hfd_min: 1.5,
1521 max_stars: 500,
1522 db_path: std::path::PathBuf::from("/nonexistent"),
1523 db_name: "d50".into(),
1524 binning: 1,
1525 method: SolveMethod::Quads,
1526 threads: 1,
1527 };
1528 let img = ImageBuffer::new(64, 64);
1529 for (fov, radius) in [
1530 (f64::NAN, 0.1),
1531 (-1.0, 0.1),
1532 (f64::INFINITY, 0.1),
1533 (0.01, -0.1),
1534 (0.01, f64::NAN),
1535 (0.01, f64::INFINITY),
1536 ] {
1537 let p = SolveParams {
1538 fov,
1539 search_radius: radius,
1540 ..base.clone()
1541 };
1542 assert!(
1543 matches!(solve_image(&img, &p), Err(ArcsecError::InvalidParameter(_))),
1544 "fov {fov}, radius {radius}"
1545 );
1546 }
1547 }
1548
1549 #[test]
1550 fn format_radec_roundtrip() {
1551 let s = format_radec(deg(160.875), deg(-59.524));
1552 assert!(s.contains("10:"), "RA hours: {s}");
1553 assert!(s.contains('-'), "dec sign: {s}");
1554 }
1555}