1use core::f64::consts::PI;
4use std::path::PathBuf;
5
6use crate::catalog::read_catalog_stars;
7use crate::catalog::{CatalogLayout, CatalogStar};
8use crate::detection::get_background;
9use crate::detection::stars::find_stars_and_deep;
10use crate::error::{ArcsecError, Result};
11use crate::math::coords::{ang_sep, equatorial_standard, standard_equatorial};
12use crate::math::lsq::{fit_affine, solve_plate_constants};
13use crate::quads::{
14 QuadGrid, TETRA_TOL_FACTOR, bijective_filter, build_quads, build_quads_presorted,
15 build_triangles, extract_star_pairs, extract_triangle_pairs, filter_by_scale,
16 filter_triangles_by_scale, find_triangle_matches, vote_filter,
17};
18use crate::types::{MatchedStar, PairedPositions, PlateConstants, Star, StarList, WcsSolution};
19use crate::wcs::output::derive_wcs;
20
21use super::distortion::{Pair, Refined, StarGrid, best_linear, max_departure_px, refine};
22use super::spiral::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 !significant(v) {
235 return false;
236 }
237 if v.n() >= MIN_VERIFIED_STARS {
238 return true;
239 }
240 let p = &v.plate;
241 let scale = (p.a * p.e - p.b * p.d).abs().sqrt();
242 let ok = (scale / self.expected_scale - 1.0).abs() <= RELAXED_SCALE_TOL
243 && v.rms <= RELAXED_MAX_RMS_PX * scale;
244 log::info!(
245 "{} stars verified, scale {:.4}\"/px against {:.4} expected, residual {:.2} px: {}",
246 v.n(),
247 scale,
248 self.expected_scale,
249 v.rms / scale,
250 if ok { "accepted" } else { "refused" }
251 );
252 ok
253 }
254}
255
256const MIN_SIGNIFICANCE: f64 = 4.0;
267
268fn significant(v: &Verified) -> bool {
273 let p = &v.plate;
274 let scale = (p.a * p.e - p.b * p.d).abs().sqrt();
275 let ok = v.n() as f64 >= MIN_SIGNIFICANCE * v.chance
276 && v.rms <= VERIFY_RADII[VERIFY_RADII.len() - 1] * scale;
277 if !ok {
278 log::info!(
279 "{} stars verified against {:.1} expected by chance, residual {:.2} px: refused",
280 v.n(),
281 v.chance,
282 v.rms / scale.max(f64::MIN_POSITIVE)
283 );
284 }
285 ok
286}
287
288const VERIFY_RADII: [f64; 3] = [6.0, 3.0, 2.0];
290const MIN_VERIFY_SPREAD: f64 = 0.20;
297
298struct Verified {
301 plate: PlateConstants,
302 rms: f64,
303 img_pos: Vec<(f64, f64)>,
305 cat_pos: Vec<(f64, f64)>,
308 chance: f64,
312}
313
314impl Verified {
315 fn n(&self) -> usize {
317 self.img_pos.len()
318 }
319}
320
321fn verify_and_refit(
334 img_stars: &StarList,
335 cat_stars: &StarList,
336 plate: &PlateConstants,
337 img_w: usize,
338 img_h: usize,
339 accept: &Acceptance,
340) -> Option<Verified> {
341 if img_stars.is_empty() || cat_stars.is_empty() {
342 return None;
343 }
344
345 let grid = StarGrid::new(img_stars, VERIFY_RADII[0])?;
347
348 let mut current = plate.clone();
349 let mut best: Option<(Verified, f64)> = None;
351
352 for &radius in &VERIFY_RADII {
353 let det = current.a * current.e - current.b * current.d;
354 if det.abs() < 1e-12 {
355 return None;
356 }
357 let r2 = radius * radius;
358
359 let mut img_pos: Vec<(f64, f64)> = Vec::new();
360 let mut cat_pos: Vec<(f64, f64)> = Vec::new();
361 let mut used = vec![false; img_stars.len()];
362 let mut in_frame = 0usize;
363
364 for cs in &cat_stars.0 {
365 let dx = cs.x - current.c;
367 let dy = cs.y - current.f;
368 let px = (current.e * dx - current.b * dy) / det;
369 let py = (-current.d * dx + current.a * dy) / det;
370 if px >= 0.0 && py >= 0.0 && px < img_w as f64 && py < img_h as f64 {
371 in_frame += 1;
372 }
373 if !grid.near(px, py, radius) {
374 continue;
375 }
376 if let Some(i) = grid.nearest(px, py, r2, &used) {
377 used[i] = true; img_pos.push(grid.pos(i));
379 cat_pos.push((cs.x, cs.y));
380 }
381 }
382
383 if img_pos.len() < 4 {
384 break;
385 }
386 let Ok(refined) = solve_plate_constants(&img_pos, &cat_pos) else {
387 break;
388 };
389 let mut sq = 0.0;
390 for (&(xi, yi), &(xc, yc)) in img_pos.iter().zip(cat_pos.iter()) {
391 let xp = refined.a * xi + refined.b * yi + refined.c;
392 let yp = refined.d * xi + refined.e * yi + refined.f;
393 sq += (xp - xc).powi(2) + (yp - yc).powi(2);
394 }
395 let rms = (sq / img_pos.len() as f64).sqrt();
396 let spread = spread_of(&img_pos, img_w, img_h);
398 log::debug!(
399 "verify: {} stars, spread {:.3}, rms {:.2}\"",
400 img_pos.len(),
401 spread,
402 rms
403 );
404
405 let density = img_stars.len() as f64 / (img_w * img_h).max(1) as f64;
408 let chance = in_frame as f64 * (1.0 - (-density * core::f64::consts::PI * r2).exp());
409
410 current = refined.clone();
411 best = Some((
412 Verified {
413 plate: refined,
414 rms,
415 img_pos,
416 cat_pos,
417 chance,
418 },
419 spread,
420 ));
421 }
422
423 best.filter(|(v, spread)| accept.accepts(v, *spread))
424 .map(|(v, _)| v)
425}
426
427fn density_star_limit(params: &SolveParams, img: &crate::types::ImageBuffer) -> usize {
433 let Some(density) = crate::catalog::database_density(¶ms.db_name) else {
434 return params.max_stars;
435 };
436 let fov_deg = params.fov.to_degrees();
437 let (w, h) = (img.width as f64, img.height as f64);
438 let area = fov_deg * fov_deg * w.min(h) / w.max(h).max(1.0);
439 let cap = (density * area).round();
440 if cap < params.max_stars as f64 {
441 cap as usize
442 } else {
443 params.max_stars
444 }
445}
446
447struct SpiralCtx<'a> {
449 params: &'a SolveParams,
450 img: &'a crate::types::ImageBuffer,
451 stars: &'a StarList,
452 img_quads: &'a crate::types::QuadList,
453 img_grid: &'a QuadGrid,
455 img_tris: &'a crate::quads::TriangleList,
456 nrstars_image: usize,
457 star_limit: usize,
460 nrstars_required: usize,
461 oversize: f64,
462 min_quads: usize,
463 step_size: f64,
464 accept: Acceptance,
465 aspect: f64,
467}
468
469struct PositionOutcome {
471 idx: usize,
472 ra_db: f64,
473 dec_db: f64,
474 sep_deg: f64,
475 verified: Verified,
476 n_matched: usize,
477 n_raw: usize,
478 mag_limit: f64,
479 refused: bool,
482}
483
484struct PositionTry {
488 sep_deg: Option<f64>,
489 outcome: Option<PositionOutcome>,
490}
491
492impl PositionTry {
493 const NONE: Self = Self {
494 sep_deg: None,
495 outcome: None,
496 };
497}
498
499fn try_position(ctx: &SpiralCtx<'_>, idx: usize, sx: i32, sy: i32) -> PositionTry {
502 let params = ctx.params;
503 let step_size = ctx.step_size;
504
505 let dec_db_raw = params.dec_hint + step_size * sy as f64;
506 let (dec_db, flip) = if dec_db_raw > PI / 2.0 {
507 (PI - dec_db_raw, PI)
508 } else if dec_db_raw < -PI / 2.0 {
509 (-PI - dec_db_raw, PI)
510 } else {
511 (dec_db_raw, 0.0)
512 };
513
514 let extra = if dec_db > 0.0 {
515 step_size * 0.5
516 } else {
517 -step_size * 0.5
518 };
519 let ra_offset = step_size * sx as f64 / (dec_db - extra).cos();
520 if ra_offset > PI / 2.0 + step_size * 0.5 || ra_offset < -PI / 2.0 {
521 return PositionTry::NONE;
522 }
523
524 let ra_db = (flip + params.ra_hint + ra_offset).rem_euclid(2.0 * PI);
525 let sep = ang_sep(ra_db, dec_db, params.ra_hint, params.dec_hint);
526 if sep > params.search_radius + step_size / 2.0 {
527 return PositionTry::NONE;
528 }
529
530 let cat_raw = match read_catalog_stars(
533 ¶ms.db_path,
534 ¶ms.db_name,
535 ra_db,
536 dec_db,
537 params.fov * ctx.oversize,
538 ctx.nrstars_required,
539 ) {
540 Ok(v) if !v.is_empty() => v,
541 Ok(_) | Err(_) => return PositionTry::NONE,
542 };
543
544 let sep_deg = sep.to_degrees();
545 let mag_limit = cat_raw
546 .iter()
547 .map(|s| s.mag)
548 .fold(f64::NEG_INFINITY, f64::max);
549 log::info!(
550 "Search {}, [{},{}], position: {} Down to magn {:.1} {} database stars {} database quads to compare.",
551 idx,
552 sx,
553 sy,
554 format_radec(ra_db, dec_db),
555 mag_limit,
556 cat_raw.len(),
557 cat_raw.len(),
558 );
559
560 let mut cat_stars: Vec<Star> = cat_raw
561 .iter()
562 .map(|s| {
563 let (x, y) = equatorial_standard(ra_db, dec_db, s.ra, s.dec, 1.0);
564 Star {
565 x,
566 y,
567 snr: 1.0,
568 hfd: 2.0,
569 }
570 })
571 .collect();
572 cat_stars.sort_unstable_by(|a, b| a.x.total_cmp(&b.x));
573 let cat_star_list = StarList(cat_stars);
574
575 let failed = PositionTry {
576 sep_deg: Some(sep_deg),
577 outcome: None,
578 };
579
580 let (img_pos, cat_pos, n_raw) = match params.method {
581 SolveMethod::Quads => {
582 let mut cat_quads = build_quads_presorted(&cat_star_list, ctx.nrstars_image);
583 if ctx.nrstars_image < ctx.star_limit {
584 add_density_matched_quads(ctx, &cat_raw, ra_db, dec_db, &mut cat_quads);
585 }
586 if cat_quads.is_empty() {
587 return failed;
588 }
589 let raw = ctx
592 .img_grid
593 .find_matches(ctx.img_quads, &cat_quads, params.quad_tolerance);
594 let n_raw = raw.len();
595 log::info!("Found {n_raw} references");
596 let mut filtered = vote_filter(ctx.img_quads, &cat_quads, &raw, params.quad_tolerance);
597 if filtered.len() < ctx.min_quads {
598 let (by_scale, _) = filter_by_scale(&raw, params.quad_tolerance);
599 if by_scale.len() > filtered.len() {
600 filtered = by_scale;
601 }
602 }
603 if filtered.len() < ctx.min_quads {
604 return failed;
605 }
606 let (ip, cp) = extract_star_pairs(ctx.img_quads, &cat_quads, &filtered);
607 (ip, cp, n_raw)
608 }
609 SolveMethod::Tetra => {
610 let cat_tris = build_triangles(&cat_star_list);
611 if cat_tris.is_empty() {
612 return failed;
613 }
614 let tol = params.quad_tolerance * TETRA_TOL_FACTOR;
615 let raw = find_triangle_matches(ctx.img_tris, &cat_tris, tol);
616 let n_raw = raw.len();
617 log::info!("Found {n_raw} triangle references");
618 let biject = bijective_filter(&raw, ctx.img_tris, &cat_tris);
619 let (filtered, _) = filter_triangles_by_scale(&biject, params.quad_tolerance);
620 if filtered.len() < ctx.min_quads {
621 return failed;
622 }
623 let (ip, cp) = extract_triangle_pairs(ctx.img_tris, &cat_tris, &filtered);
624 (ip, cp, n_raw)
625 }
626 };
627
628 let seeds = Seeds {
631 img: img_pos.clone(),
632 cat: cat_pos.clone(),
633 ra: ra_db,
634 dec: dec_db,
635 };
636 let Some((plate, n_matched)) = fit_pattern_pairs(img_pos, cat_pos, ctx.min_quads) else {
637 return failed;
638 };
639
640 let found = |verified, ra_db, dec_db, refused| PositionTry {
641 sep_deg: Some(sep_deg),
642 outcome: Some(PositionOutcome {
643 idx,
644 ra_db,
645 dec_db,
646 sep_deg,
647 verified,
648 n_matched,
649 n_raw,
650 mag_limit,
651 refused,
652 }),
653 };
654
655 let Some(verified) = verify_and_refit(
656 ctx.stars,
657 &cat_star_list,
658 &plate,
659 ctx.img.width,
660 ctx.img.height,
661 &ctx.accept,
662 ) else {
663 log::info!("Verification failed at this position; continuing search.");
664 if n_matched >= STRONG_VOTE
665 && let Some((verified, ra_c, dec_c)) =
666 second_chance(ctx, &cat_raw, &seeds, &plate, ra_db, dec_db)
667 {
668 return found(verified, ra_c, dec_c, false);
669 }
670 return failed;
671 };
672 log::info!(
673 "Verified {} stars against the catalogue, residual {:.2}\"",
674 verified.n(),
675 verified.rms
676 );
677
678 let (verified, ra_db, dec_db) = recentre(ctx, &cat_raw, verified, ra_db, dec_db);
679 match model_distortion(ctx, &cat_raw, &seeds, verified, ra_db, dec_db) {
680 Modelled::Linear(v) => found(v, ra_db, dec_db, false),
681 Modelled::Distorted(v, ra_c, dec_c) => found(v, ra_c, dec_c, false),
682 Modelled::Refused(v) => found(v, ra_db, dec_db, true),
683 }
684}
685
686const STRONG_VOTE: usize = 50;
691
692fn project(cat_raw: &[CatalogStar], ra: f64, dec: f64) -> StarList {
694 StarList(
695 cat_raw
696 .iter()
697 .map(|s| {
698 let (x, y) = equatorial_standard(ra, dec, s.ra, s.dec, 1.0);
699 Star {
700 x,
701 y,
702 snr: 1.0,
703 hfd: 2.0,
704 }
705 })
706 .collect(),
707 )
708}
709
710struct Seeds {
712 img: Vec<(f64, f64)>,
713 cat: Vec<(f64, f64)>,
714 ra: f64,
715 dec: f64,
716}
717
718impl Seeds {
719 fn in_plane(&self, ra: f64, dec: f64) -> Vec<Pair> {
721 self.img
722 .iter()
723 .zip(&self.cat)
724 .map(|(&i, &(x, y))| {
725 if ra == self.ra && dec == self.dec {
726 return (i, (x, y));
727 }
728 let (sra, sdec) = standard_equatorial(self.ra, self.dec, x, y, 1.0);
729 (i, equatorial_standard(ra, dec, sra, sdec, 1.0))
730 })
731 .collect()
732 }
733}
734
735fn fit_distortion(
738 ctx: &SpiralCtx<'_>,
739 cat_raw: &[CatalogStar],
740 seeds: &Seeds,
741 plate: &PlateConstants,
742 ra: f64,
743 dec: f64,
744) -> Option<Refined> {
745 let (w, h) = (ctx.img.width as f64, ctx.img.height as f64);
751 let (xs, ys) = (
752 plate.a * (w - 1.0) * 0.5 + plate.b * (h - 1.0) * 0.5 + plate.c,
753 plate.d * (w - 1.0) * 0.5 + plate.e * (h - 1.0) * 0.5 + plate.f,
754 );
755 let (ra_c, dec_c) = standard_equatorial(ra, dec, xs, ys, 1.0);
756 let window = w.hypot(h) / w.max(h);
757 let cat = match read_catalog_stars(
758 &ctx.params.db_path,
759 &ctx.params.db_name,
760 ra_c,
761 dec_c,
762 ctx.params.fov * window,
763 (ctx.params.max_stars as f64 * window * window).round() as usize,
764 ) {
765 Ok(v) if !v.is_empty() => project(&v, ra, dec),
766 _ => project(cat_raw, ra, dec),
767 };
768 let grid = StarGrid::new(ctx.stars, VERIFY_RADII[0])?;
769 let r = refine(
770 &grid,
771 &cat,
772 &seeds.in_plane(ra, dec),
773 plate,
774 ctx.img.width,
775 ctx.img.height,
776 VERIFY_RADII[VERIFY_RADII.len() - 1],
777 )?;
778 log::info!(
779 "Distortion model: {} terms, {} stars within {} px, rms {:.2} px, F {:.1} over linear, {} of 9 cells",
780 r.model.n_terms,
781 r.img_pos.len(),
782 VERIFY_RADII[VERIFY_RADII.len() - 1],
783 r.rms / r.model.scale(),
784 r.f_linear,
785 r.cells
786 );
787 Some(r)
788}
789
790fn linear_from_model(
794 ctx: &SpiralCtx<'_>,
795 r: &Refined,
796 ra: f64,
797 dec: f64,
798) -> Option<(Verified, f64, f64)> {
799 let (w, h) = (ctx.img.width, ctx.img.height);
800 let (xs, ys) = r
801 .model
802 .apply((w as f64 - 1.0) * 0.5, (h as f64 - 1.0) * 0.5);
803 let (ra_c, dec_c) = standard_equatorial(ra, dec, xs, ys, 1.0);
804 let moved = |(x, y): (f64, f64)| {
807 let (sra, sdec) = standard_equatorial(ra, dec, x, y, 1.0);
808 equatorial_standard(ra_c, dec_c, sra, sdec, 1.0)
809 };
810 let plate = best_linear(|x, y| moved(r.model.apply(x, y)), w, h)?;
811 let cat_pos = r.cat_pos.iter().map(|&p| moved(p)).collect();
812 Some((
813 Verified {
814 plate,
815 rms: r.rms,
816 img_pos: r.img_pos.clone(),
817 cat_pos,
818 chance: 0.0,
819 },
820 ra_c,
821 dec_c,
822 ))
823}
824
825enum Modelled {
827 Linear(Verified),
829 Distorted(Verified, f64, f64),
831 Refused(Verified),
834}
835
836const MIN_REPORT_F: f64 = 30.0;
843
844const MIN_DEPARTURE_PX: f64 = 1.0;
848
849const REFUSE_F: f64 = 100.0;
852const REFUSE_DEPARTURE_PX: f64 = 3.0;
857
858fn model_distortion(
868 ctx: &SpiralCtx<'_>,
869 cat_raw: &[CatalogStar],
870 seeds: &Seeds,
871 verified: Verified,
872 ra: f64,
873 dec: f64,
874) -> Modelled {
875 let Some(r) = fit_distortion(ctx, cat_raw, seeds, &verified.plate, ra, dec) else {
876 return Modelled::Linear(verified);
877 };
878 let (w, h) = (ctx.img.width, ctx.img.height);
879 let departure = max_departure_px(&r.model, &verified.plate, w, h);
880 log::info!(
881 "Distortion: verified plate departs {departure:.2} px from the model; {} stars against {} verified",
882 r.img_pos.len(),
883 verified.n(),
884 );
885 let min_cells = if r.model.n_terms == 10 { 9 } else { 7 };
886 let usable = r.model.n_terms > 3
887 && r.cells >= min_cells
888 && r.f_linear >= MIN_REPORT_F
889 && r.img_pos.len() * 10 >= verified.n() * 9;
890 if usable {
891 if departure >= MIN_DEPARTURE_PX
892 && let Some((v, ra_c, dec_c)) = linear_from_model(ctx, &r, ra, dec)
893 {
894 log::info!("Reporting the linear plate closest to the distortion model.");
895 return Modelled::Distorted(v, ra_c, dec_c);
896 }
897 return Modelled::Linear(verified);
898 }
899 let (wide_f, wide_dep) = r.unmodelled();
900 log::info!("Where the stars are: a cubic with F {wide_f:.1}, {wide_dep:.2} px from the plate.");
901 if wide_f >= REFUSE_F && wide_dep >= REFUSE_DEPARTURE_PX {
902 log::info!(
903 "The field is distorted by {wide_dep:.1} px where it has stars, and the distortion \
904 cannot be modelled over the whole frame: refusing a linear solution."
905 );
906 return Modelled::Refused(verified);
907 }
908 if r.img_pos.len() as f64 >= MODEL_PAIRS_REFIT * verified.n() as f64
909 && let Some(v) = refit_linear(&r.img_pos, &r.cat_pos)
910 {
911 log::info!(
912 "The full-frame match pairs {} stars against {} verified: refitting the linear plate to them.",
913 r.img_pos.len(),
914 verified.n()
915 );
916 return Modelled::Linear(v);
917 }
918 Modelled::Linear(verified)
919}
920
921const MODEL_PAIRS_REFIT: f64 = 1.5;
936
937fn refit_linear(img_pos: &[(f64, f64)], cat_pos: &[(f64, f64)]) -> Option<Verified> {
939 let plate = solve_plate_constants(img_pos, cat_pos).ok()?;
940 let sq: f64 = img_pos
941 .iter()
942 .zip(cat_pos)
943 .map(|(&(x, y), &(xc, yc))| {
944 (plate.a * x + plate.b * y + plate.c - xc).powi(2)
945 + (plate.d * x + plate.e * y + plate.f - yc).powi(2)
946 })
947 .sum();
948 let rms = (sq / img_pos.len().max(1) as f64).sqrt();
949 Some(Verified {
950 plate,
951 rms,
952 img_pos: img_pos.to_vec(),
953 cat_pos: cat_pos.to_vec(),
954 chance: 0.0,
955 })
956}
957
958fn spread_of(img_pos: &[(f64, f64)], img_w: usize, img_h: usize) -> f64 {
961 let n = img_pos.len() as f64;
962 let mx = img_pos.iter().map(|p| p.0).sum::<f64>() / n;
963 let my = img_pos.iter().map(|p| p.1).sum::<f64>() / n;
964 let var = img_pos
965 .iter()
966 .map(|&(x, y)| (x - mx) * (x - mx) + (y - my) * (y - my))
967 .sum::<f64>()
968 / n;
969 let half_diag = 0.5 * ((img_w * img_w + img_h * img_h) as f64).sqrt();
970 var.sqrt() / half_diag
971}
972
973fn second_chance(
979 ctx: &SpiralCtx<'_>,
980 cat_raw: &[CatalogStar],
981 seeds: &Seeds,
982 plate: &PlateConstants,
983 ra: f64,
984 dec: f64,
985) -> Option<(Verified, f64, f64)> {
986 log::info!("Strong pattern match: retrying verification with a distortion model.");
987 let r = fit_distortion(ctx, cat_raw, seeds, plate, ra, dec)?;
988 if r.cells < if r.model.n_terms == 10 { 9 } else { 7 } {
990 log::info!("The distortion model's stars do not cover the frame.");
991 return None;
992 }
993 let probe = Verified {
994 plate: r.model.linear_part(),
995 rms: r.rms,
996 img_pos: r.img_pos.clone(),
997 cat_pos: r.cat_pos.clone(),
998 chance: 0.0,
1000 };
1001 let spread = spread_of(&r.img_pos, ctx.img.width, ctx.img.height);
1002 if !ctx.accept.accepts(&probe, spread) {
1003 log::info!("The distortion model did not verify either.");
1004 return None;
1005 }
1006 log::info!(
1007 "Verified {} stars with the distortion model.",
1008 r.img_pos.len()
1009 );
1010 linear_from_model(ctx, &r, ra, dec)
1011}
1012
1013const DENSITY_MATCH_MIN_RATIO: f64 = 2.5;
1021
1022fn add_density_matched_quads(
1038 ctx: &SpiralCtx<'_>,
1039 cat_raw: &[CatalogStar],
1040 ra_db: f64,
1041 dec_db: f64,
1042 cat_quads: &mut crate::types::QuadList,
1043) {
1044 let k = (ctx.nrstars_image as f64 * ctx.oversize * ctx.oversize * ctx.aspect).round() as usize;
1045 if k < 5 || (k as f64) * DENSITY_MATCH_MIN_RATIO > cat_raw.len() as f64 {
1046 return;
1047 }
1048 let mut sub: Vec<Star> = cat_raw[..k]
1050 .iter()
1051 .map(|s| {
1052 let (x, y) = equatorial_standard(ra_db, dec_db, s.ra, s.dec, 1.0);
1053 Star {
1054 x,
1055 y,
1056 snr: 1.0,
1057 hfd: 2.0,
1058 }
1059 })
1060 .collect();
1061 sub.sort_unstable_by(|a, b| a.x.total_cmp(&b.x));
1062 let extra = build_quads_presorted(&StarList(sub), ctx.nrstars_image);
1063 let key = |q: &crate::types::Quad| {
1066 (
1067 (q.center_x * 1000.0).round() as i64,
1068 (q.center_y * 1000.0).round() as i64,
1069 (q.d1 * 1000.0).round() as i64,
1070 )
1071 };
1072 let seen: std::collections::HashSet<_> = cat_quads.0.iter().map(key).collect();
1073 let before = cat_quads.len();
1074 cat_quads
1075 .0
1076 .extend(extra.0.into_iter().filter(|q| !seen.contains(&key(q))));
1077 log::info!(
1078 "{} more database quads from its {k} brightest stars, the image's density.",
1079 cat_quads.len() - before
1080 );
1081}
1082
1083fn recentre(
1100 ctx: &SpiralCtx<'_>,
1101 cat_raw: &[CatalogStar],
1102 mut verified: Verified,
1103 mut ra_db: f64,
1104 mut dec_db: f64,
1105) -> (Verified, f64, f64) {
1106 let (w, h) = (ctx.img.width as f64, ctx.img.height as f64);
1107 let (cx, cy) = ((w - 1.0) * 0.5, (h - 1.0) * 0.5);
1108 let apply =
1109 |p: &PlateConstants, x: f64, y: f64| (p.a * x + p.b * y + p.c, p.d * x + p.e * y + p.f);
1110
1111 for _ in 0..2 {
1112 let plate = &verified.plate;
1113 let (xs, ys) = apply(plate, cx, cy);
1114 if xs.hypot(ys) < 1e-3 {
1116 break;
1117 }
1118 let (ra0, dec0) = standard_equatorial(ra_db, dec_db, xs, ys, 1.0);
1119
1120 let det = plate.a * plate.e - plate.b * plate.d;
1126 if det.abs() < 1e-12 {
1127 break;
1128 }
1129 let r2 = VERIFY_RADII[0] * VERIFY_RADII[0];
1130 let mut used = vec![false; ctx.stars.len()];
1131 let mut img_pos = Vec::new();
1132 let mut new_pos = Vec::new();
1133 let mut cat = Vec::with_capacity(cat_raw.len());
1134 for s in cat_raw {
1135 let (nx, ny) = equatorial_standard(ra0, dec0, s.ra, s.dec, 1.0);
1136 cat.push(Star {
1137 x: nx,
1138 y: ny,
1139 snr: 1.0,
1140 hfd: 2.0,
1141 });
1142 let (ox, oy) = equatorial_standard(ra_db, dec_db, s.ra, s.dec, 1.0);
1143 let (dx, dy) = (ox - plate.c, oy - plate.f);
1144 let px = (plate.e * dx - plate.b * dy) / det;
1145 let py = (-plate.d * dx + plate.a * dy) / det;
1146 let nearest = ctx
1147 .stars
1148 .0
1149 .iter()
1150 .enumerate()
1151 .filter(|&(i, _)| !used[i])
1152 .map(|(i, st)| (i, (st.x - px).powi(2) + (st.y - py).powi(2)))
1153 .filter(|&(_, d2)| d2 < r2)
1154 .min_by(|a, b| a.1.total_cmp(&b.1));
1155 if let Some((i, _)) = nearest {
1156 used[i] = true;
1157 img_pos.push((ctx.stars.0[i].x, ctx.stars.0[i].y));
1158 new_pos.push((nx, ny));
1159 }
1160 }
1161 let Ok(guess) = solve_plate_constants(&img_pos, &new_pos) else {
1162 break;
1163 };
1164 let cat = StarList(cat);
1165 let Some(v) = verify_and_refit(
1166 ctx.stars,
1167 &cat,
1168 &guess,
1169 ctx.img.width,
1170 ctx.img.height,
1171 &ctx.accept,
1172 ) else {
1173 log::info!("Re-centring on the image centre did not verify; keeping the fit.");
1174 break;
1175 };
1176 log::info!(
1177 "Re-centred on the image centre: verified {} stars, residual {:.2}\"",
1178 v.n(),
1179 v.rms
1180 );
1181 (verified, ra_db, dec_db) = (v, ra0, dec0);
1182 }
1183 (verified, ra_db, dec_db)
1184}
1185
1186const SEEDED_MAX_STARS: usize = 2000;
1189const SEEDED_WINDOW: f64 = 1.5;
1192const SEEDED_CAT_STARS: usize = 150;
1195const SEEDED_MAX_QUADS: usize = 600;
1197const SEEDED_SCALE_TOL: f64 = 0.05;
1199const SEEDED_PROBE_PX: f64 = 2.5;
1201const SEEDED_MIN_CENSUS: usize = 10;
1203const SEEDED_BUDGET: u64 = 30_000_000;
1209
1210fn seeded_fallback(ctx: &SpiralCtx<'_>, deep: &StarList) -> Option<PositionOutcome> {
1215 use crate::quads::seeded::{ImageIndex, SeedParams, max_backbone_px, search};
1216 let params = ctx.params;
1217 if deep.len() < 30 {
1218 return None;
1219 }
1220 let (ra, dec) = (params.ra_hint, params.dec_hint);
1221 let n_read = (ctx.nrstars_required as f64 * (SEEDED_WINDOW / ctx.oversize).powi(2)).round();
1222 let cat_raw = read_catalog_stars(
1223 ¶ms.db_path,
1224 ¶ms.db_name,
1225 ra,
1226 dec,
1227 params.fov * SEEDED_WINDOW,
1228 n_read as usize,
1229 )
1230 .ok()
1231 .filter(|v| v.len() >= 8)?;
1232 let mag_limit = cat_raw
1233 .iter()
1234 .map(|s| s.mag)
1235 .fold(f64::NEG_INFINITY, f64::max);
1236 let cat_list = project(&cat_raw, ra, dec);
1237 let cat_pos: Vec<(f64, f64)> = cat_list.0.iter().map(|s| (s.x, s.y)).collect();
1238 let sp = SeedParams {
1239 scale: ctx.accept.expected_scale,
1240 scale_tol: SEEDED_SCALE_TOL,
1241 width: ctx.img.width as f64,
1242 height: ctx.img.height as f64,
1243 seed_stars: SEEDED_CAT_STARS,
1244 max_quads: SEEDED_MAX_QUADS,
1245 census_stars: SEEDED_CAT_STARS,
1246 min_census: SEEDED_MIN_CENSUS,
1247 verify_cost: 30 * cat_pos.len() as u64,
1250 };
1251 let index = ImageIndex::new(
1252 deep.0.iter().map(|s| (s.x, s.y)).collect(),
1253 max_backbone_px(&cat_pos, &sp),
1254 SEEDED_PROBE_PX,
1255 );
1256 log::info!(
1257 "Catalogue-seeded search: {} database stars about the hint, {} image stars, {} pairs.",
1258 cat_raw.len(),
1259 index.len(),
1260 index.n_pairs()
1261 );
1262 let mut budget = SEEDED_BUDGET;
1263 let mut verified = None;
1264 let mut candidates = 0usize;
1265 let cand = search(&index, &cat_pos, &sp, &mut budget, |c| {
1266 candidates += 1;
1267 verified = verify_and_refit(
1268 ctx.stars,
1269 &cat_list,
1270 &c.plate,
1271 ctx.img.width,
1272 ctx.img.height,
1273 &ctx.accept,
1274 );
1275 verified.is_some()
1276 });
1277 log::info!(
1278 "Catalogue-seeded search: {candidates} candidates verified, {} of {SEEDED_BUDGET} work spent.",
1279 SEEDED_BUDGET - budget
1280 );
1281 let cand = cand?;
1282 let verified = verified?;
1283 log::info!(
1284 "Verified {} stars against the catalogue, residual {:.2}\"",
1285 verified.n(),
1286 verified.rms
1287 );
1288 let seeds = Seeds {
1289 img: cand.img.clone(),
1290 cat: cand.cat.clone(),
1291 ra,
1292 dec,
1293 };
1294 let (verified, ra_db, dec_db) = recentre(ctx, &cat_raw, verified, ra, dec);
1295 let (verified, ra_db, dec_db, refused) =
1296 match model_distortion(ctx, &cat_raw, &seeds, verified, ra_db, dec_db) {
1297 Modelled::Linear(v) => (v, ra_db, dec_db, false),
1298 Modelled::Distorted(v, ra_c, dec_c) => (v, ra_c, dec_c, false),
1299 Modelled::Refused(v) => (v, ra_db, dec_db, true),
1300 };
1301 Some(PositionOutcome {
1302 idx: usize::MAX,
1303 ra_db,
1304 dec_db,
1305 sep_deg: 0.0,
1306 verified,
1307 n_matched: cand.img.len(),
1308 n_raw: candidates,
1309 mag_limit,
1310 refused,
1311 })
1312}
1313
1314pub fn solve_image(img: &crate::types::ImageBuffer, params: &SolveParams) -> Result<WcsSolution> {
1332 if !(params.fov.is_finite() && params.fov > 0.0) {
1335 return Err(ArcsecError::InvalidParameter(format!(
1336 "field of view must be positive, got {} rad",
1337 params.fov
1338 )));
1339 }
1340 if !(params.search_radius.is_finite() && params.search_radius >= 0.0) {
1341 return Err(ArcsecError::InvalidParameter(format!(
1342 "search radius must be non-negative, got {} rad",
1343 params.search_radius
1344 )));
1345 }
1346
1347 if !crate::catalog::catalog_present(¶ms.db_path, ¶ms.db_name) {
1352 return Err(ArcsecError::CatalogNotFound(params.db_path.clone()));
1353 }
1354
1355 let bg = get_background(img, params.max_stars);
1357 log::info!("Start finding stars");
1358 let (stars, stars_raw, deep_stars) =
1359 find_stars_and_deep(img, &bg, params.hfd_min, params.max_stars, SEEDED_MAX_STARS);
1360 log::info!(
1361 "{} stars found of the requested {}. Background value is {:.0}. \
1362 Detection level used {:.0} above background. Star level is {:.0} above background. \
1363 Noise level is {:.0}",
1364 stars_raw,
1365 params.max_stars,
1366 bg.mean,
1367 bg.star_level,
1368 bg.star_level,
1369 bg.noise,
1370 );
1371 if stars_raw > params.max_stars {
1372 log::info!("Selecting the {} brightest stars only.", params.max_stars);
1373 }
1374
1375 let star_limit = density_star_limit(params, img);
1387 let mut stars = stars;
1388 if stars.len() > star_limit {
1389 stars.0.sort_by(|a, b| b.snr.total_cmp(&a.snr));
1390 stars.0.truncate(star_limit);
1391 log::info!(
1392 "Database limit for this field is {star_limit} stars; using the {star_limit} brightest."
1393 );
1394 }
1395
1396 let nrstars_image = stars.len();
1397 if nrstars_image < 5 {
1398 return Err(ArcsecError::InsufficientStars {
1399 found: nrstars_image,
1400 required: 5,
1401 });
1402 }
1403
1404 let img_quads = build_quads(&stars, nrstars_image);
1406 let nr_quads = img_quads.len();
1407
1408 let img_tris = if params.method == SolveMethod::Tetra {
1409 build_triangles(&stars)
1410 } else {
1411 crate::quads::TriangleList::default()
1412 };
1413
1414 let patterns_empty = match params.method {
1415 SolveMethod::Quads => nr_quads == 0,
1416 SolveMethod::Tetra => img_tris.is_empty(),
1417 };
1418 if patterns_empty {
1419 return Err(ArcsecError::InsufficientQuads {
1420 found: 0,
1421 required: 3,
1422 });
1423 }
1424
1425 let min_quads: usize = 3 + nrstars_image / 140;
1426 let img_grid = if params.method == SolveMethod::Quads {
1427 QuadGrid::build(&img_quads, params.quad_tolerance)
1428 } else {
1429 QuadGrid::build(&crate::types::QuadList::default(), params.quad_tolerance)
1430 };
1431
1432 let oversize: f64 = match params.speed {
1433 SearchSpeed::Auto if nrstars_image < 35 => 2.0,
1434 SearchSpeed::Auto if nrstars_image > 140 => 1.0,
1435 SearchSpeed::Auto => 2.0 * (35.0 / nrstars_image as f64).sqrt(),
1436 SearchSpeed::Slow => {
1439 let max_fov_deg = match crate::catalog::detect_layout(¶ms.db_path, ¶ms.db_name)
1440 {
1441 CatalogLayout::Areas1476 => 5.142_857_143_f64,
1442 CatalogLayout::Areas290 => 9.53,
1443 CatalogLayout::AllSky001 => 180.0,
1444 };
1445 2.0_f64.min(max_fov_deg.to_radians() / params.fov).max(1.0)
1446 }
1447 };
1448
1449 let nrstars_required = (params.max_stars as f64 * oversize * oversize).round() as usize;
1451 let step_size = params.fov;
1452 let fov_deg = step_size.to_degrees();
1453 let max_distance = (params.search_radius / step_size + 2.0) as i32;
1454
1455 log::info!(
1456 "{} stars, {} quads selected in the image. {} database stars, {} database quads required \
1457 for the {:.2}d square search window. Step size {:.2}d. Oversize {:.2}",
1458 nrstars_image,
1459 nr_quads,
1460 nrstars_required,
1461 nrstars_required,
1462 fov_deg * oversize,
1463 fov_deg,
1464 oversize,
1465 );
1466
1467 let ctx = SpiralCtx {
1473 params,
1474 img,
1475 stars: &stars,
1476 img_quads: &img_quads,
1477 img_grid: &img_grid,
1478 img_tris: &img_tris,
1479 nrstars_image,
1480 star_limit,
1481 nrstars_required,
1482 oversize,
1483 min_quads,
1484 step_size,
1485 accept: Acceptance::new(nrstars_image, params, img),
1486 aspect: img.width.max(img.height) as f64 / img.width.min(img.height).max(1) as f64,
1487 };
1488
1489 let n_threads = if params.threads > 0 {
1490 params.threads
1491 } else {
1492 crate::max_threads()
1493 }
1494 .clamp(1, 64);
1495
1496 let positions: Vec<(i32, i32)> = SpiralSearch::new(max_distance).collect();
1497 let (step_distances, winner) = search_in_order(positions.len(), n_threads, |idx| {
1498 let (sx, sy) = positions[idx];
1499 let t = try_position(&ctx, idx, sx, sy);
1500 (t.sep_deg, t.outcome)
1501 });
1502 let mut winner = winner.map(|(_, o)| o);
1503
1504 if winner.is_none() && params.method == SolveMethod::Quads {
1506 winner = seeded_fallback(&ctx, &deep_stars);
1507 }
1508
1509 if let Some(o) = winner.as_ref().filter(|o| o.refused) {
1510 log::info!(
1511 "No solution: the field at search position {} is too distorted for a linear plate.",
1512 o.idx
1513 );
1514 return Err(ArcsecError::InsufficientQuads {
1515 found: 0,
1516 required: min_quads,
1517 });
1518 }
1519 if let Some(o) = winner {
1520 log::info!(
1521 "{} of {} patterns selected matching within {:.3} tolerance.",
1522 o.n_matched,
1523 o.n_raw,
1524 params.quad_tolerance,
1525 );
1526
1527 let v = o.verified;
1528 let mut wcs = derive_wcs(o.ra_db, o.dec_db, &v.plate, img.width, img.height);
1529 let b = params.binning.max(1) as f64;
1532 wcs.matched_stars = v
1533 .img_pos
1534 .iter()
1535 .zip(&v.cat_pos)
1536 .map(|(&(x, y), &(sx, sy))| {
1537 let (ra, dec) = standard_equatorial(o.ra_db, o.dec_db, sx, sy, 1.0);
1538 MatchedStar {
1539 x: (x + 0.5) * b + 0.5,
1540 y: (y + 0.5) * b + 0.5,
1541 ra,
1542 dec,
1543 }
1544 })
1545 .collect();
1546 if params.binning > 1 {
1547 let b = params.binning as f64;
1548 wcs.crpix1 = (wcs.crpix1 - 0.5) * b + 0.5;
1549 wcs.crpix2 = (wcs.crpix2 - 0.5) * b + 0.5;
1550 wcs.cd1_1 /= b;
1551 wcs.cd1_2 /= b;
1552 wcs.cd2_1 /= b;
1553 wcs.cd2_2 /= b;
1554 wcs.cdelt1 /= b;
1555 wcs.cdelt2 /= b;
1556 }
1557 wcs.residual_rms = v.rms;
1558 wcs.stars_matched = v.n();
1559 wcs.raw_matches = o.n_raw;
1560 wcs.plate = v.plate;
1561 wcs.mag_limit = o.mag_limit;
1562 wcs.search_dist_deg = o.sep_deg;
1563 wcs.step_distances = step_distances;
1564 return Ok(wcs);
1565 }
1566
1567 Err(ArcsecError::InsufficientQuads {
1568 found: 0,
1569 required: min_quads,
1570 })
1571}
1572
1573type Tried<T> = (usize, Option<f64>, Option<T>);
1575
1576fn search_in_order<T: Send>(
1589 n: usize,
1590 n_threads: usize,
1591 try_at: impl Fn(usize) -> (Option<f64>, Option<T>) + Sync,
1592) -> (Vec<f64>, Option<(usize, T)>) {
1593 use core::sync::atomic::{AtomicUsize, Ordering};
1594
1595 if n == 0 {
1596 return (Vec::new(), None);
1597 }
1598 let mut tried: Vec<Tried<T>> = Vec::new();
1601 let (d, o) = try_at(0);
1602 let first_hit = o.is_some();
1603 tried.push((0, d, o));
1604 if !first_hit && n > 1 {
1605 if n_threads <= 1 {
1606 for idx in 1..n {
1607 let (d, o) = try_at(idx);
1608 let hit = o.is_some();
1609 tried.push((idx, d, o));
1610 if hit {
1611 break;
1612 }
1613 }
1614 } else {
1615 let next = AtomicUsize::new(1);
1616 let first_found = AtomicUsize::new(usize::MAX);
1617 let try_at = &try_at;
1618 let per_worker: Vec<Vec<Tried<T>>> = std::thread::scope(|scope| {
1619 let handles: Vec<_> = (0..n_threads.min(n - 1))
1620 .map(|_| {
1621 let (next, first_found) = (&next, &first_found);
1622 scope.spawn(move || {
1623 let mut done = Vec::new();
1624 loop {
1625 let idx = next.fetch_add(1, Ordering::Relaxed);
1626 if idx >= n || idx > first_found.load(Ordering::Relaxed) {
1627 break;
1628 }
1629 let (d, o) = try_at(idx);
1630 if o.is_some() {
1631 first_found.fetch_min(idx, Ordering::Relaxed);
1632 }
1633 done.push((idx, d, o));
1634 }
1635 done
1636 })
1637 })
1638 .collect();
1639 handles
1640 .into_iter()
1641 .map(|h| h.join().unwrap_or_else(|e| std::panic::resume_unwind(e)))
1645 .collect()
1646 });
1647 tried.extend(per_worker.into_iter().flatten());
1648 tried.sort_unstable_by_key(|&(idx, _, _)| idx);
1649 }
1650 }
1651
1652 let mut distances = Vec::new();
1655 for (idx, d, o) in tried {
1656 distances.extend(d);
1657 if let Some(o) = o {
1658 return (distances, Some((idx, o)));
1659 }
1660 }
1661 (distances, None)
1662}
1663
1664#[must_use]
1667pub fn format_ra(ra_rad: f64) -> String {
1668 const TENTHS_PER_DAY: f64 = 24.0 * 36_000.0;
1673 let ra_tenths = ((ra_rad.to_degrees() / 15.0 * 36_000.0)
1674 .round()
1675 .rem_euclid(TENTHS_PER_DAY)) as u64;
1676 let h = ra_tenths / 36_000;
1677 let m = ra_tenths / 600 % 60;
1678 let s = ra_tenths % 600 / 10;
1679 let tenths = ra_tenths % 10;
1680 format!("{h:02}: {m:02} {s:02}.{tenths}")
1681}
1682
1683#[must_use]
1686pub fn format_dec(dec_rad: f64) -> String {
1687 let dec_deg = dec_rad.to_degrees();
1688 let sign = if dec_deg < 0.0 { '-' } else { '+' };
1689 let dec_secs = (dec_deg.abs() * 3600.0).round() as u64;
1690 let dd = dec_secs / 3600;
1691 let dm = dec_secs / 60 % 60;
1692 let ds = dec_secs % 60;
1693 format!("{sign}{dd:02}d {dm:02} {ds:02}")
1694}
1695
1696#[must_use]
1700pub fn format_radec(ra_rad: f64, dec_rad: f64) -> String {
1701 format!("{} {}", format_ra(ra_rad), format_dec(dec_rad))
1702}
1703
1704#[cfg(test)]
1705mod tests {
1706 use super::*;
1707 use crate::math::coords::{ang_sep, standard_equatorial};
1708 use crate::test_support::{
1709 Rng, SkySpec, SkyStar, TempDir, TruthWcs, random_sky, render, write_001_db, write_290_db,
1710 write_1476_db,
1711 };
1712 use crate::types::{ImageBuffer, PlateConstants};
1713 use crate::wcs::output::derive_wcs;
1714 use core::f64::consts::PI;
1715
1716 fn deg(d: f64) -> f64 {
1717 d * PI / 180.0
1718 }
1719
1720 fn make_test_scene(
1721 n_stars: usize,
1722 ra_center: f64,
1723 dec_center: f64,
1724 cdelt_arcsec: f64,
1725 width: usize,
1726 height: usize,
1727 ) -> (ImageBuffer, Vec<(f64, f64)>, PlateConstants) {
1728 let mut data = vec![100.0f32; width * height];
1729 let mut catalog_sky: Vec<(f64, f64)> = Vec::new();
1730 let stars_per_row = (n_stars as f64).sqrt().ceil() as usize;
1731 let spacing = 40.0;
1732 let cx = (width as f64 - 1.0) / 2.0;
1733 let cy = (height as f64 - 1.0) / 2.0;
1734 let a = cdelt_arcsec;
1735 let c = -a * cx;
1736 let e = cdelt_arcsec;
1737 let f_offset = -e * cy;
1738 let plate = PlateConstants {
1739 a,
1740 b: 0.0,
1741 c,
1742 d: 0.0,
1743 e,
1744 f: f_offset,
1745 };
1746 let mut count = 0;
1747 'outer: for row in 0..stars_per_row {
1748 for col in 0..stars_per_row {
1749 if count >= n_stars {
1750 break 'outer;
1751 }
1752 let px = 20.0 + col as f64 * spacing;
1753 let py = 20.0 + row as f64 * spacing;
1754 if px >= width as f64 - 20.0 || py >= height as f64 - 20.0 {
1755 continue;
1756 }
1757 let x_std = a * px + c;
1758 let y_std = e * py + f_offset;
1759 let (ra, dec) = standard_equatorial(ra_center, dec_center, x_std, y_std, 1.0);
1760 catalog_sky.push((ra, dec));
1761 let sigma = 2.0;
1762 let amp = 30000.0f32;
1763 for dy in -8i32..=8 {
1764 for dx in -8i32..=8 {
1765 let x = (px as i32 + dx) as usize;
1766 let y = (py as i32 + dy) as usize;
1767 if x < width && y < height {
1768 let r2 = (dx * dx + dy * dy) as f64 / (2.0 * sigma * sigma);
1769 data[y * width + x] += amp * (-r2).exp() as f32;
1770 }
1771 }
1772 }
1773 count += 1;
1774 }
1775 }
1776 let img = ImageBuffer {
1777 data,
1778 width,
1779 height,
1780 };
1781 (img, catalog_sky, plate)
1782 }
1783
1784 #[test]
1785 fn derive_wcs_recovers_position() {
1786 let ra_center = deg(45.0);
1787 let dec_center = deg(30.0);
1788 let (img, _cat, plate) = make_test_scene(16, ra_center, dec_center, 2.0, 300, 300);
1789 let wcs = derive_wcs(ra_center, dec_center, &plate, img.width, img.height);
1790 let sep_arcsec = ang_sep(wcs.ra0, wcs.dec0, ra_center, dec_center) * (180.0 / PI * 3600.0);
1791 assert!(sep_arcsec < 0.5, "centre offset = {sep_arcsec} arcsec");
1792 }
1793
1794 #[test]
1795 fn the_search_returns_the_serial_result_on_any_number_of_threads() {
1796 let mut rng = crate::test_support::Rng::new(5);
1797 for case in 0..40 {
1798 let n = 1 + (rng.next_u64() % 300) as usize;
1799 let read: Vec<bool> = (0..n).map(|_| rng.uniform() < 0.8).collect();
1801 let hits: Vec<bool> = (0..n)
1802 .map(|_| case % 4 != 0 && rng.uniform() < 0.02)
1803 .collect();
1804 let try_at = |idx: usize| {
1805 let d = read[idx].then_some(idx as f64);
1806 for _ in 0..(idx * 7919) % 5000 {
1808 core::hint::black_box(idx);
1809 }
1810 (d, (read[idx] && hits[idx]).then_some(idx * 10))
1811 };
1812 let want_hit = (0..n).find(|&i| read[i] && hits[i]);
1813 let want_d: Vec<f64> = (0..=want_hit.unwrap_or(n - 1))
1814 .filter(|&i| read[i])
1815 .map(|i| i as f64)
1816 .collect();
1817 for threads in [1, 2, 3, 8] {
1818 let (d, hit) = search_in_order(n, threads, try_at);
1819 assert_eq!(
1820 hit,
1821 want_hit.map(|i| (i, i * 10)),
1822 "case {case}, {threads} threads"
1823 );
1824 assert_eq!(d, want_d, "case {case}, {threads} threads");
1825 }
1826 }
1827 assert_eq!(
1828 search_in_order(0, 4, |_| (Some(1.0), Some(()))),
1829 (vec![], None)
1830 );
1831 }
1832
1833 #[test]
1834 fn spiral_covers_origin_first() {
1835 assert_eq!(SpiralSearch::new(5).next(), Some((0, 0)));
1836 }
1837
1838 #[test]
1839 fn oversize_formula_limits() {
1840 for n in [10, 35, 70, 140, 200] {
1841 let ov: f64 = if n < 35 {
1842 2.0
1843 } else if n > 140 {
1844 1.0
1845 } else {
1846 2.0 * (35.0 / n as f64).sqrt()
1847 };
1848 assert!((1.0..=2.0).contains(&ov), "oversize={ov} for n={n}");
1849 }
1850 }
1851
1852 #[test]
1853 fn format_radec_carries_rounded_seconds() {
1854 let ra = deg((1.0 + 59.0 / 60.0 + 59.97 / 3600.0) * 15.0);
1856 let dec = deg(10.0 + 59.0 / 60.0 + 59.7 / 3600.0);
1858 assert_eq!(format_radec(ra, dec), "02: 00 00.0 +11d 00 00");
1859 let s = format_radec(deg(359.999_999_9), deg(-0.5));
1861 assert_eq!(s, "00: 00 00.0 -00d 30 00");
1862 assert_eq!(
1864 format_radec(deg((5.0 + 35.0 / 60.0 + 17.3 / 3600.0) * 15.0), deg(-5.39)),
1865 "05: 35 17.3 -05d 23 24"
1866 );
1867 }
1868
1869 #[test]
1874 fn ra_and_dec_are_formatted_as_astap_cli_prints_them() {
1875 let ra = deg(65.0); let dec = deg(35.0);
1877 assert_eq!(format_ra(ra), "04: 20 00.0");
1878 assert_eq!(format_dec(dec), "+35d 00 00");
1879 assert_eq!(format_radec(ra, dec), "04: 20 00.0 +35d 00 00");
1880 assert_eq!(
1881 format_radec(
1882 deg((13.0 + 7.0 / 60.0 + 9.25 / 3600.0) * 15.0),
1883 -deg(89.0 + 1.0 / 60.0 + 2.0 / 3600.0)
1884 ),
1885 "13: 07 09.3 -89d 01 02"
1886 );
1887 assert_eq!(format_dec(deg(-0.0001)), "-00d 00 00");
1888 }
1889
1890 #[test]
1891 fn solve_image_rejects_a_non_positive_fov() {
1892 let img = ImageBuffer::new(64, 64);
1893 let params = SolveParams {
1894 ra_hint: 0.0,
1895 dec_hint: 0.0,
1896 fov: 0.0,
1897 search_radius: 0.1,
1898 quad_tolerance: 0.007,
1899 hfd_min: 1.5,
1900 max_stars: 500,
1901 db_path: std::path::PathBuf::from("/nonexistent"),
1902 db_name: "d50".into(),
1903 binning: 1,
1904 method: SolveMethod::Quads,
1905 threads: 1,
1906 speed: SearchSpeed::Auto,
1907 };
1908 assert!(matches!(
1909 solve_image(&img, ¶ms),
1910 Err(ArcsecError::InvalidParameter(_))
1911 ));
1912 }
1913
1914 fn known_plate() -> PlateConstants {
1918 let (s, r) = (3.2_f64, 0.61_f64);
1919 PlateConstants {
1920 a: -s * r.cos(),
1921 b: s * r.sin(),
1922 c: 640.0,
1923 d: s * r.sin(),
1924 e: s * r.cos(),
1925 f: -512.0,
1926 }
1927 }
1928
1929 fn apply(p: &PlateConstants, (x, y): (f64, f64)) -> (f64, f64) {
1930 (p.a * x + p.b * y + p.c, p.d * x + p.e * y + p.f)
1931 }
1932
1933 fn plate_close(p: &PlateConstants, q: &PlateConstants, tol: f64) -> bool {
1934 [
1935 (p.a, q.a),
1936 (p.b, q.b),
1937 (p.c, q.c),
1938 (p.d, q.d),
1939 (p.e, q.e),
1940 (p.f, q.f),
1941 ]
1942 .iter()
1943 .all(|(u, v)| (u - v).abs() <= tol)
1944 }
1945
1946 const STRICT: Acceptance = Acceptance {
1949 min_stars: MIN_VERIFIED_STARS,
1950 expected_scale: 3.2,
1951 };
1952
1953 fn star_at(x: f64, y: f64) -> Star {
1954 Star {
1955 x,
1956 y,
1957 snr: 50.0,
1958 hfd: 2.5,
1959 }
1960 }
1961
1962 fn pairs_with_outliers(outlier: impl Fn(usize, (f64, f64)) -> (f64, f64)) -> PairedPositions {
1965 let plate = known_plate();
1966 let mut rng = Rng::new(7);
1967 let mut img = Vec::new();
1968 let mut cat = Vec::new();
1969 for _ in 0..40 {
1970 let p = (rng.range(0.0, 500.0), rng.range(0.0, 500.0));
1971 img.push(p);
1972 cat.push(apply(&plate, p));
1973 }
1974 for k in 0..5 {
1975 let p = (rng.range(0.0, 500.0), rng.range(0.0, 500.0));
1976 img.push(p);
1977 cat.push(outlier(k, apply(&plate, p)));
1978 }
1979 (img, cat)
1980 }
1981
1982 #[test]
1983 fn sigma_clip_pairs_rejects_outliers_and_keeps_the_rest() {
1984 let (img, cat) = pairs_with_outliers(|k, (x, y)| {
1986 let a = k as f64 * 1.3;
1987 (x + 100.0 * a.cos(), y + 100.0 * a.sin())
1988 });
1989 let (ci, cc) = sigma_clip_pairs(img, cat, 3.0, 3);
1990 assert_eq!(ci.len(), 40, "all and only the true pairs survive");
1991 let fit = solve_plate_constants(&ci, &cc).unwrap();
1992 assert!(plate_close(&fit, &known_plate(), 1e-6), "{fit:?}");
1993 }
1994
1995 #[test]
2003 fn sigma_clip_pairs_rejects_gross_outliers() {
2004 let (img, cat) =
2005 pairs_with_outliers(|k, _| (1000.0 + 150.0 * k as f64, -900.0 + 70.0 * k as f64));
2006 assert!(matches!(
2007 solve_plate_constants(&img, &cat),
2008 Err(ArcsecError::BadSolution { .. })
2009 ));
2010 let (ci, _) = sigma_clip_pairs(img, cat, 3.0, 3);
2011 assert_eq!(ci.len(), 40, "the five gross outliers should be clipped");
2012 }
2013
2014 #[test]
2018 fn fit_pattern_pairs_recovers_a_plate_the_plain_fit_refuses() {
2019 let (img, cat) =
2020 pairs_with_outliers(|k, _| (1000.0 + 150.0 * k as f64, -900.0 + 70.0 * k as f64));
2021 assert!(solve_plate_constants(&img, &cat).is_err());
2022 let (plate, n) = fit_pattern_pairs(img.clone(), cat.clone(), 3).expect("clipped fit");
2023 assert_eq!(n, 40);
2024 assert!(plate_close(&plate, &known_plate(), 1e-6), "{plate:?}");
2025 let (plate, n) = fit_pattern_pairs(img[..40].to_vec(), cat[..40].to_vec(), 3).unwrap();
2027 assert_eq!(n, 40);
2028 assert!(plate_close(&plate, &known_plate(), 1e-6));
2029 assert!(fit_pattern_pairs(img, cat, 41).is_none());
2031 }
2032
2033 #[test]
2034 fn sigma_clip_pairs_leaves_too_few_pairs_alone() {
2035 let img = vec![(0.0, 0.0), (1.0, 0.0)];
2036 let cat = vec![(5.0, 5.0), (9.0, 9.0)];
2037 let (ci, cc) = sigma_clip_pairs(img.clone(), cat.clone(), 3.0, 3);
2038 assert_eq!((ci, cc), (img, cat));
2039 }
2040
2041 #[test]
2042 fn verify_and_refit_recovers_the_plate_from_a_rough_guess() {
2043 let truth = known_plate();
2044 let mut rng = Rng::new(11);
2045 let mut img_stars = Vec::new();
2046 let mut cat_stars = Vec::new();
2047 for _ in 0..60 {
2048 let (x, y) = (rng.range(5.0, 395.0), rng.range(5.0, 295.0));
2049 img_stars.push(star_at(x, y));
2050 let (cx, cy) = apply(&truth, (x, y));
2051 cat_stars.push(star_at(cx, cy));
2052 }
2053 for k in 0..20 {
2055 let (cx, cy) = apply(&truth, (-300.0 - 10.0 * k as f64, 900.0));
2056 cat_stars.push(star_at(cx, cy));
2057 }
2058 let mut rough = truth.clone();
2060 rough.c += 2.0 * truth.a;
2061 rough.f += 2.0 * truth.e;
2062 rough.b += 0.01;
2063 let v = verify_and_refit(
2064 &StarList(img_stars),
2065 &StarList(cat_stars),
2066 &rough,
2067 400,
2068 300,
2069 &STRICT,
2070 )
2071 .expect("a correct plate must verify");
2072 assert_eq!(v.n(), 60);
2073 assert_eq!(v.cat_pos.len(), 60);
2074 assert!(v.rms < 1e-6, "rms {}", v.rms);
2075 assert!(plate_close(&v.plate, &truth, 1e-6), "{:?}", v.plate);
2076 for (&(x, y), &(cx, cy)) in v.img_pos.iter().zip(&v.cat_pos) {
2078 let (px, py) = apply(&truth, (x, y));
2079 assert!((px - cx).hypot(py - cy) < 1e-6);
2080 }
2081 }
2082
2083 #[test]
2084 fn verify_and_refit_rejects_too_few_or_clustered_matches() {
2085 let truth = known_plate();
2086 let mut rng = Rng::new(12);
2087 let build = |pts: &[(f64, f64)]| {
2088 let img = StarList(pts.iter().map(|&(x, y)| star_at(x, y)).collect());
2089 let cat = StarList(
2090 pts.iter()
2091 .map(|&p| apply(&truth, p))
2092 .map(|(x, y)| star_at(x, y))
2093 .collect(),
2094 );
2095 (img, cat)
2096 };
2097
2098 let few: Vec<_> = (0..20)
2100 .map(|_| (rng.range(0.0, 400.0), rng.range(0.0, 300.0)))
2101 .collect();
2102 let (img, cat) = build(&few);
2103 assert!(verify_and_refit(&img, &cat, &truth, 400, 300, &STRICT).is_none());
2104
2105 let clustered: Vec<_> = (0..80)
2107 .map(|_| (rng.range(0.0, 40.0), rng.range(0.0, 40.0)))
2108 .collect();
2109 let (img, cat) = build(&clustered);
2110 assert!(verify_and_refit(&img, &cat, &truth, 400, 300, &STRICT).is_none());
2111
2112 let spread: Vec<_> = (0..80)
2114 .map(|_| (rng.range(0.0, 400.0), rng.range(0.0, 300.0)))
2115 .collect();
2116 let (img, cat) = build(&spread);
2117 assert!(verify_and_refit(&img, &cat, &truth, 400, 300, &STRICT).is_some());
2118
2119 let empty = StarList::default();
2121 assert!(verify_and_refit(&empty, &cat, &truth, 400, 300, &STRICT).is_none());
2122 let mut singular = truth.clone();
2123 singular.a = 0.0;
2124 singular.b = 0.0;
2125 assert!(verify_and_refit(&img, &cat, &singular, 400, 300, &STRICT).is_none());
2126 }
2127
2128 #[derive(Clone, Copy)]
2131 enum Db {
2132 Areas1476,
2133 Areas290,
2134 AllSky001,
2135 }
2136
2137 struct Scene {
2139 dir: TempDir,
2140 img: ImageBuffer,
2141 truth: TruthWcs,
2142 sky: Vec<SkyStar>,
2144 }
2145
2146 fn scene(truth: TruthWcs, db: Db, n_in_frame: usize, seed: u64) -> Scene {
2149 let mut rng = Rng::new(seed);
2150 let scale_deg = truth.cd[1].hypot(truth.cd[3]);
2151 let (w_deg, h_deg) = (
2152 truth.width as f64 * scale_deg,
2153 truth.height as f64 * scale_deg,
2154 );
2155 let side = 6.0 * w_deg.max(h_deg);
2156 let sky = random_sky(
2157 &mut rng,
2158 &SkySpec {
2159 ra0: truth.ra0,
2160 dec0: truth.dec0,
2161 side_deg: side,
2162 n: (n_in_frame as f64 * side * side / (w_deg * h_deg)) as usize,
2163 min_sep_deg: 12.0 * scale_deg,
2164 mag_lo: 10.0,
2165 mag_hi: 14.5,
2166 },
2167 );
2168 let sigma = 1.3 * 5.0 / (scale_deg * 3600.0);
2170 let img = render(
2171 &truth,
2172 &sky,
2173 sigma.max(1.3),
2174 1000.0,
2175 8.0,
2176 30_000.0,
2177 &mut rng,
2178 );
2179 let dir = TempDir::new("solve");
2180 match db {
2181 Db::Areas1476 => write_1476_db(dir.path(), "t50", &sky),
2182 Db::Areas290 => write_290_db(dir.path(), "t50", &sky),
2183 Db::AllSky001 => write_001_db(dir.path(), "t50", &sky),
2184 }
2185 Scene {
2186 dir,
2187 img,
2188 truth,
2189 sky,
2190 }
2191 }
2192
2193 fn params_for_blank() -> SolveParams {
2195 SolveParams {
2196 ra_hint: 0.0,
2197 dec_hint: 0.0,
2198 fov: deg(1.0),
2199 search_radius: 0.0,
2200 quad_tolerance: 0.007,
2201 hfd_min: 1.5,
2202 max_stars: 500,
2203 db_path: std::path::PathBuf::from("/nonexistent"),
2204 db_name: "d50".into(),
2205 binning: 1,
2206 method: SolveMethod::Quads,
2207 threads: 1,
2208 speed: SearchSpeed::Auto,
2209 }
2210 }
2211
2212 fn params_for(s: &Scene, ra_hint: f64, dec_hint: f64) -> SolveParams {
2213 SolveParams {
2214 ra_hint,
2215 dec_hint,
2216 fov: (s.truth.height as f64 * s.truth.cd[1].hypot(s.truth.cd[3])).to_radians(),
2217 search_radius: deg(2.0),
2218 quad_tolerance: 0.007,
2219 hfd_min: 1.5,
2220 max_stars: 500,
2221 db_path: s.dir.path().to_path_buf(),
2222 db_name: "t50".into(),
2223 binning: 1,
2224 method: SolveMethod::Quads,
2225 threads: 1,
2226 speed: SearchSpeed::Auto,
2227 }
2228 }
2229
2230 fn assert_solved(s: &Scene, wcs: &WcsSolution, tol_arcsec: f64) {
2231 let err = s.truth.max_error_arcsec(wcs);
2232 assert!(
2233 err < tol_arcsec,
2234 "worst centre/corner error {err:.3}\" (matched {}, rms {:.3})",
2235 wcs.stars_matched,
2236 wcs.residual_rms
2237 );
2238 assert!(wcs.stars_matched >= 10);
2239 let scale_arcsec = s.truth.cd[1].hypot(s.truth.cd[3]) * 3600.0;
2241 assert!(
2242 wcs.residual_rms < 0.3 * scale_arcsec,
2243 "rms {}",
2244 wcs.residual_rms
2245 );
2246 assert!(wcs.raw_matches > 0);
2247 assert_matches_agree(wcs, 0.3, 1.0);
2248 assert!(wcs.mag_limit > 10.0 && wcs.mag_limit <= 14.5);
2249 assert!(
2250 wcs.cdelt1 < 0.0 && wcs.cdelt2 > 0.0,
2251 "CDELT sign convention"
2252 );
2253 }
2254
2255 fn assert_matches_agree(wcs: &WcsSolution, rms_px: f64, binning: f64) {
2258 assert_eq!(wcs.matched_stars.len(), wcs.stars_matched);
2259 assert!(wcs.sip.is_none(), "solve_image never fits SIP");
2260 let tan = crate::wcs::TanWcs::from(wcs);
2261 let mut sq = 0.0;
2262 for m in &wcs.matched_stars {
2263 let (x, y) = tan.sky_to_pixel(m.ra, m.dec).unwrap();
2264 let d = (x - m.x).hypot(y - m.y);
2265 assert!(
2266 d < VERIFY_RADII[VERIFY_RADII.len() - 1] * binning,
2267 "pair at ({:.2},{:.2}) projects to ({x:.2},{y:.2})",
2268 m.x,
2269 m.y
2270 );
2271 sq += d * d;
2272 }
2273 let rms = (sq / wcs.matched_stars.len() as f64).sqrt();
2274 assert!(rms < rms_px, "pair rms {rms} px");
2275 }
2276
2277 #[test]
2278 fn solves_a_1476_database_from_an_offset_hint() {
2279 let truth = TruthWcs::new(deg(84.3), deg(-5.2), 5.0, 23.0, false, 400, 320);
2280 let s = scene(truth, Db::Areas1476, 130, 1);
2281 let mut p = params_for(&s, deg(84.3 + 0.6), deg(-5.2 - 0.45));
2283 p.threads = 4;
2284 let wcs = solve_image(&s.img, &p).expect("solve");
2285 assert_solved(&s, &wcs, 1.0);
2286 assert!(wcs.search_dist_deg > 0.1, "solved at the hint itself?");
2287 assert!(wcs.step_distances.len() > 1);
2288 assert!((wcs.cdelt2 * 3600.0 - 5.0).abs() < 0.01, "{}", wcs.cdelt2);
2290 assert!((wcs.crota2 - 23.0).abs() < 0.05, "crota2 {}", wcs.crota2);
2291 }
2292
2293 #[test]
2294 fn solves_a_mirrored_image_on_a_290_database() {
2295 let truth = TruthWcs::new(deg(201.0), deg(47.5), 6.0, 160.0, true, 360, 360);
2296 let s = scene(truth, Db::Areas290, 120, 2);
2297 let wcs = solve_image(&s.img, ¶ms_for(&s, truth.ra0, truth.dec0)).expect("solve");
2298 assert_solved(&s, &wcs, 1.0);
2299 assert!(wcs.search_dist_deg < 1e-9, "should solve at the hint");
2300 assert!(wcs.cd1_1 * wcs.cd2_2 - wcs.cd1_2 * wcs.cd2_1 > 0.0);
2302 }
2303
2304 #[test]
2305 fn solves_across_ra_zero_with_an_all_sky_001_database() {
2306 let truth = TruthWcs::new(deg(0.05), deg(21.0), 5.0, -70.0, false, 360, 300);
2308 let s = scene(truth, Db::AllSky001, 120, 3);
2309 let wcs = solve_image(&s.img, ¶ms_for(&s, truth.ra0, truth.dec0)).expect("solve");
2310 assert_solved(&s, &wcs, 1.0);
2311 }
2312
2313 #[test]
2314 fn solves_across_ra_zero_with_a_1476_database() {
2315 let truth = TruthWcs::new(deg(359.97), deg(-33.0), 5.0, 95.0, false, 360, 300);
2316 let s = scene(truth, Db::Areas1476, 120, 4);
2317 let wcs = solve_image(&s.img, ¶ms_for(&s, truth.ra0, truth.dec0)).expect("solve");
2318 assert_solved(&s, &wcs, 1.0);
2319 }
2320
2321 #[test]
2322 fn solves_a_field_near_the_celestial_pole() {
2323 let truth = TruthWcs::new(deg(40.0), deg(88.9), 5.0, 10.0, false, 360, 300);
2324 let s = scene(truth, Db::Areas1476, 120, 5);
2325 let wcs = solve_image(&s.img, ¶ms_for(&s, truth.ra0, truth.dec0)).expect("solve");
2326 assert_solved(&s, &wcs, 1.0);
2327 }
2328
2329 #[test]
2343 fn accuracy_does_not_depend_on_the_hint_offset() {
2344 let truth = TruthWcs::new(deg(150.0), deg(30.0), 15.0, 20.0, false, 360, 300);
2345 let s = scene(truth, Db::Areas1476, 120, 21);
2346 let off = 0.4;
2347 let p = params_for(&s, deg(150.0 + off / deg(30.0).cos()), deg(30.0 + off));
2348 let wcs = solve_image(&s.img, &p).expect("solve");
2349 assert!(wcs.search_dist_deg < 1e-9, "solved at the hint");
2350 let err = s.truth.max_error_arcsec(&wcs);
2351 assert!(
2352 err < 5.0,
2353 "worst corner error {err:.2}\" with a {off}° hint offset"
2354 );
2355 }
2356
2357 #[test]
2358 fn solves_with_the_tetra_method() {
2359 let truth = TruthWcs::new(deg(150.0), deg(2.0), 5.0, 45.0, false, 360, 300);
2360 let s = scene(truth, Db::Areas1476, 110, 6);
2361 let mut p = params_for(&s, truth.ra0, truth.dec0);
2362 p.method = SolveMethod::Tetra;
2363 let wcs = solve_image(&s.img, &p).expect("solve");
2364 assert_solved(&s, &wcs, 1.0);
2365 }
2366
2367 #[test]
2368 fn slow_speed_solves_from_an_offset_hint() {
2369 let truth = TruthWcs::new(deg(84.3), deg(-5.2), 5.0, 23.0, false, 400, 320);
2370 let s = scene(truth, Db::Areas1476, 130, 1);
2371 let mut p = params_for(&s, deg(84.3 + 0.6), deg(-5.2 - 0.45));
2372 p.speed = SearchSpeed::Slow;
2373 let wcs = solve_image(&s.img, &p).expect("solve");
2374 assert_solved(&s, &wcs, 1.0);
2375 }
2376
2377 #[test]
2378 fn binned_solve_is_reported_on_the_unbinned_pixel_grid() {
2379 let truth = TruthWcs::new(deg(10.0), deg(40.0), 2.5, 30.0, false, 720, 600);
2381 let s = scene(truth, Db::Areas1476, 120, 7);
2382 let binned = s.img.bin_image(2);
2383 assert_eq!((binned.width, binned.height), (360, 300));
2384 let mut p = params_for(&s, truth.ra0, truth.dec0);
2385 p.binning = 2;
2386 let wcs = solve_image(&binned, &p).expect("solve");
2387 assert!((wcs.crpix1 - 360.5).abs() < 1e-9, "crpix1 {}", wcs.crpix1);
2389 assert!((wcs.crpix2 - 300.5).abs() < 1e-9, "crpix2 {}", wcs.crpix2);
2390 assert!((wcs.cdelt2 * 3600.0 - 2.5).abs() < 0.01, "{}", wcs.cdelt2);
2391 let err = s.truth.max_error_arcsec(&wcs);
2392 assert!(err < 2.0, "worst corner error {err:.3}\"");
2393 assert_matches_agree(&wcs, 0.6, 2.0);
2395 }
2396
2397 #[test]
2398 fn the_star_limit_is_the_database_density_times_the_field_area() {
2399 let params = |fov_deg: f64, db: &str, max_stars: usize| SolveParams {
2400 fov: deg(fov_deg),
2401 max_stars,
2402 db_name: db.into(),
2403 ..params_for_blank()
2404 };
2405 let square = ImageBuffer::new(200, 200);
2406 let wide = ImageBuffer::new(400, 200);
2407 assert_eq!(density_star_limit(¶ms(0.2, "d80", 500), &square), 320);
2409 assert_eq!(density_star_limit(¶ms(0.2, "d80", 500), &wide), 160);
2411 assert_eq!(density_star_limit(¶ms(1.0, "d80", 500), &square), 500);
2413 assert_eq!(density_star_limit(¶ms(0.2, "d80", 100), &square), 100);
2414 assert_eq!(density_star_limit(¶ms(0.8, "g05", 500), &square), 320);
2416 assert_eq!(density_star_limit(¶ms(20.0, "w08", 500), &wide), 200);
2417 assert_eq!(density_star_limit(¶ms(0.1, "v17", 500), &square), 500);
2419 }
2420
2421 #[test]
2427 fn a_frame_deeper_than_the_database_solves_at_the_database_limit() {
2428 let truth = TruthWcs::new(deg(250.0), deg(36.0), 3.0, 12.0, false, 600, 500);
2430 let s = scene(truth, Db::Areas1476, 450, 31);
2431 let mut sky = s.sky.clone();
2434 sky.sort_by(|a, b| a.mag.total_cmp(&b.mag));
2435 sky.truncate(200 * 9);
2436 write_1476_db(s.dir.path(), "t02", &sky);
2437 write_1476_db(s.dir.path(), "t17", &sky);
2439
2440 let mut p = params_for(&s, truth.ra0, truth.dec0);
2441 p.fov = (600.0 * 3.0 / 3600.0_f64).to_radians();
2442 p.search_radius = 0.0;
2443 p.db_name = "t17".into();
2444 let wcs = solve_image(&s.img, &p).expect("every detection: the fallback solves");
2445 assert!(s.truth.max_error_arcsec(&wcs) < 2.0);
2446 p.db_name = "t02".into();
2447 let wcs = solve_image(&s.img, &p).expect("solve at the database limit");
2448 assert!(s.truth.max_error_arcsec(&wcs) < 2.0);
2449 }
2450
2451 #[test]
2452 fn min_verified_stars_relaxes_only_for_sparse_images() {
2453 for (n, want) in [
2454 (0, 10),
2455 (5, 10),
2456 (66, 10),
2457 (67, 11),
2458 (100, 15),
2459 (193, 29),
2460 (194, 30),
2461 (200, 30),
2462 (500, 30),
2463 (usize::MAX, 30),
2464 ] {
2465 assert_eq!(min_verified_stars(n), want, "{n} detections");
2466 }
2467 }
2468
2469 #[test]
2471 fn a_sparse_match_must_have_the_expected_scale_and_a_tight_fit() {
2472 let truth = known_plate(); let verified = |n: usize, rms_px: f64, scale: f64| {
2474 let mut plate = truth.clone();
2475 for c in [&mut plate.a, &mut plate.b, &mut plate.d, &mut plate.e] {
2476 *c *= scale;
2477 }
2478 Verified {
2479 plate,
2480 rms: rms_px * 3.2 * scale,
2481 img_pos: vec![(0.0, 0.0); n],
2482 cat_pos: vec![(0.0, 0.0); n],
2483 chance: 0.0,
2484 }
2485 };
2486 let accept = Acceptance {
2487 min_stars: 12,
2488 expected_scale: 3.2,
2489 };
2490 assert!(accept.accepts(&verified(30, 1.9, 1.36), 0.5));
2493 assert!(!accept.accepts(&verified(30, 2.1, 1.0), 0.5));
2494 assert!(accept.accepts(&verified(12, 0.3, 1.0), 0.5));
2496 assert!(accept.accepts(&verified(20, 0.49, 1.09), 0.5));
2497 assert!(accept.accepts(&verified(20, 0.49, 0.91), 0.5));
2498 assert!(!accept.accepts(&verified(20, 0.3, 1.11), 0.5));
2500 assert!(!accept.accepts(&verified(20, 0.3, 0.89), 0.5));
2501 assert!(!accept.accepts(&verified(29, 0.51, 1.0), 0.5));
2502 assert!(!accept.accepts(&verified(11, 0.1, 1.0), 0.5));
2503 assert!(!accept.accepts(&verified(20, 0.1, 1.0), 0.1));
2504 assert!(!accept.accepts(&verified(12, 2.9, 1.36), 0.5));
2506 }
2507
2508 #[test]
2512 fn a_sparse_frame_solves_at_the_hint_scale_only() {
2513 let truth = TruthWcs::new(deg(30.0), deg(-12.0), 5.0, 40.0, false, 360, 300);
2514 let s = scene(truth, Db::Areas1476, 150, 41);
2515 let mut bright = s.sky.clone();
2516 bright.sort_by(|a, b| a.mag.total_cmp(&b.mag));
2517 let bright: Vec<SkyStar> = bright
2518 .into_iter()
2519 .filter(|st| {
2520 s.truth
2521 .sky_to_pixel(st.ra, st.dec)
2522 .is_some_and(|(x, y)| (5.0..355.0).contains(&x) && (5.0..295.0).contains(&y))
2523 })
2524 .take(22)
2525 .collect();
2526 let mut rng = Rng::new(42);
2527 let img = render(&s.truth, &bright, 1.3, 1000.0, 8.0, 30_000.0, &mut rng);
2528 let mut p = params_for(&s, truth.ra0, truth.dec0);
2529 p.fov = (360.0 * 5.0 / 3600.0_f64).to_radians(); p.search_radius = 0.0;
2531 let wcs = solve_image(&img, &p).expect("sparse solve");
2532 assert!(
2533 wcs.stars_matched < MIN_VERIFIED_STARS,
2534 "{}",
2535 wcs.stars_matched
2536 );
2537 assert!(s.truth.max_error_arcsec(&wcs) < 2.0);
2538
2539 p.fov *= 1.2;
2540 assert!(matches!(
2541 solve_image(&img, &p),
2542 Err(ArcsecError::InsufficientQuads { .. })
2543 ));
2544 }
2545
2546 #[test]
2551 fn a_shallow_frame_matches_the_density_matched_catalogue_quads() {
2552 let truth = TruthWcs::new(deg(140.0), deg(55.0), 5.0, -25.0, true, 360, 300);
2553 let s = scene(truth, Db::Areas1476, 500, 51);
2554 let mut bright = s.sky.clone();
2555 bright.sort_by(|a, b| a.mag.total_cmp(&b.mag));
2556 let in_frame = |st: &SkyStar| {
2557 s.truth
2558 .sky_to_pixel(st.ra, st.dec)
2559 .is_some_and(|(x, y)| (0.0..360.0).contains(&x) && (0.0..300.0).contains(&y))
2560 };
2561 let n_frame = bright.iter().filter(|st| in_frame(st)).count();
2562 bright.truncate(bright.len() * 40 / n_frame.max(1));
2563 let mut rng = Rng::new(52);
2564 let img = render(&s.truth, &bright, 1.3, 1000.0, 8.0, 30_000.0, &mut rng);
2565 let mut p = params_for(&s, truth.ra0, truth.dec0);
2566 p.fov = (360.0 * 5.0 / 3600.0_f64).to_radians();
2567 p.search_radius = 0.0;
2568 let wcs = solve_image(&img, &p).expect("shallow solve");
2569 assert!(s.truth.max_error_arcsec(&wcs) < 2.0);
2570 }
2571
2572 fn fallback_only(img: &ImageBuffer, p: &SolveParams) -> Option<WcsSolution> {
2575 let bg = get_background(img, p.max_stars);
2576 let (stars, _, deep) =
2577 find_stars_and_deep(img, &bg, p.hfd_min, p.max_stars, SEEDED_MAX_STARS);
2578 let n = stars.len();
2579 let oversize = if n < 35 {
2580 2.0
2581 } else if n > 140 {
2582 1.0
2583 } else {
2584 2.0 * (35.0 / n as f64).sqrt()
2585 };
2586 let (quads, tris) = (crate::types::QuadList::default(), Default::default());
2587 let grid = QuadGrid::build(&quads, p.quad_tolerance);
2588 let ctx = SpiralCtx {
2589 params: p,
2590 img,
2591 stars: &stars,
2592 img_quads: &quads,
2593 img_grid: &grid,
2594 img_tris: &tris,
2595 nrstars_image: n,
2596 star_limit: p.max_stars,
2597 nrstars_required: (p.max_stars as f64 * oversize * oversize).round() as usize,
2598 oversize,
2599 min_quads: 3 + n / 140,
2600 step_size: p.fov,
2601 accept: Acceptance::new(n, p, img),
2602 aspect: img.width.max(img.height) as f64 / img.width.min(img.height) as f64,
2603 };
2604 let o = seeded_fallback(&ctx, &deep)?;
2605 assert!(!o.refused);
2606 Some(derive_wcs(
2607 o.ra_db,
2608 o.dec_db,
2609 &o.verified.plate,
2610 img.width,
2611 img.height,
2612 ))
2613 }
2614
2615 #[test]
2618 fn the_seeded_fallback_solves_a_field_on_its_own() {
2619 for (mirrored, seed) in [(false, 71), (true, 72)] {
2620 let truth = TruthWcs::new(deg(201.0), deg(-43.0), 4.0, 61.0, mirrored, 800, 600);
2621 let s = scene(truth, Db::Areas1476, 400, seed);
2622 let fov = 800.0 * 4.0 / 3600.0;
2624 let mut p = params_for(&s, truth.ra0 + deg(0.3 * fov), truth.dec0 - deg(0.2 * fov));
2625 p.fov = deg(fov);
2626 let wcs = fallback_only(&s.img, &p).expect("the fallback solves");
2627 assert!(
2628 s.truth.max_error_arcsec(&wcs) < 2.0,
2629 "mirrored {mirrored}: {:.2}\"",
2630 s.truth.max_error_arcsec(&wcs)
2631 );
2632 }
2633 }
2634
2635 #[test]
2638 fn the_seeded_fallback_does_not_invent_a_field() {
2639 let truth = TruthWcs::new(deg(201.0), deg(-43.0), 4.0, 61.0, false, 800, 600);
2640 let s = scene(truth, Db::Areas1476, 400, 73);
2641 let mut p = params_for(&s, truth.ra0, truth.dec0 + deg(2.0));
2643 p.fov = deg(800.0 * 4.0 / 3600.0);
2644 assert!(fallback_only(&s.img, &p).is_none());
2645 }
2646
2647 #[test]
2650 fn a_verification_no_better_than_chance_is_refused() {
2651 let plate = known_plate();
2652 let v = |n: usize, chance: f64, rms_px: f64| Verified {
2653 plate: plate.clone(),
2654 rms: rms_px * 3.2,
2655 img_pos: vec![(0.0, 0.0); n],
2656 cat_pos: vec![(0.0, 0.0); n],
2657 chance,
2658 };
2659 assert!(!significant(&v(31, 17.8, 1.3)));
2661 assert!(!significant(&v(32, 14.7, 1.3)));
2662 assert!(significant(&v(121, 15.3, 0.65)));
2664 assert!(!significant(&v(30, 3.1, 4.4)));
2666 assert!(significant(&v(30, 0.0, 1.9)));
2668 }
2669
2670 fn distorted_scene(corner_px: f64, seed: u64) -> Scene {
2673 let truth = TruthWcs::new(deg(84.3), deg(-5.2), 10.0, 23.0, false, 1024, 768)
2674 .with_corner_distortion(corner_px);
2675 scene(truth, Db::Areas1476, 300, seed)
2676 }
2677
2678 fn sip_error_arcsec(s: &Scene, wcs: &WcsSolution) -> f64 {
2681 let tan = crate::wcs::TanWcs::from(wcs);
2682 let (w, h) = (s.truth.width as f64 - 1.0, s.truth.height as f64 - 1.0);
2683 let mut worst: f64 = 0.0;
2684 for (fx, fy) in [
2685 (0.5, 0.5),
2686 (0.0, 0.0),
2687 (1.0, 0.0),
2688 (0.0, 1.0),
2689 (1.0, 1.0),
2690 (0.5, 0.0),
2691 (0.0, 0.5),
2692 ] {
2693 let (x, y) = (w * fx, h * fy);
2694 let (ra_t, dec_t) = s.truth.pixel_to_sky(x, y);
2695 let (ra_s, dec_s) = tan.pixel_to_sky(x + 1.0, y + 1.0);
2696 let sep = crate::test_support::separation(ra_t, dec_t, ra_s, dec_s);
2697 worst = worst.max(sep.to_degrees() * 3600.0);
2698 }
2699 worst
2700 }
2701
2702 #[test]
2703 fn a_distorted_field_reports_the_best_linear_plate_over_the_frame() {
2704 for (hint_ra, hint_dec) in [(84.3, -5.2), (84.3 + 0.9, -5.2 - 0.7)] {
2709 let s = distorted_scene(30.0, 7);
2710 let wcs =
2711 solve_image(&s.img, ¶ms_for(&s, deg(hint_ra), deg(hint_dec))).expect("solve");
2712 let floor = s.truth.linear_floor_arcsec();
2713 let err = s.truth.max_error_arcsec(&wcs);
2714 assert!(floor > 80.0, "floor {floor:.1}\"");
2715 assert!(
2716 err < floor + 5.0,
2717 "corner error {err:.1}\" against a linear floor of {floor:.1}\""
2718 );
2719 assert!(wcs.sip.is_none(), "solve_image never fits SIP");
2720 assert!(wcs.stars_matched > 150, "{} stars", wcs.stars_matched);
2722 let mut with_sip = wcs.clone();
2723 with_sip.sip = crate::wcs::fit_sip(&wcs, 1024, 768);
2724 assert!(with_sip.sip.is_some(), "the distortion is significant");
2725 let sip_err = sip_error_arcsec(&s, &with_sip);
2726 assert!(sip_err < 3.0, "SIP error {sip_err:.2}\"");
2727 }
2728 }
2729
2730 fn part_empty_scene(corner_px: f64) -> Scene {
2733 let mut s = distorted_scene(corner_px, 7);
2734 let w = s.img.width;
2735 for y in 0..s.img.height {
2736 for x in (2 * w / 3)..w {
2737 s.img.data[y * w + x] = 1000.0;
2738 }
2739 }
2740 s
2741 }
2742
2743 #[test]
2744 fn strong_distortion_that_cannot_be_modelled_over_the_frame_is_refused() {
2745 let s = part_empty_scene(30.0);
2750 let r = solve_image(&s.img, ¶ms_for(&s, deg(84.3), deg(-5.2)));
2751 assert!(
2752 matches!(r, Err(ArcsecError::InsufficientQuads { .. })),
2753 "{:?}",
2754 r.map(|w| s.truth.max_error_arcsec(&w))
2755 );
2756 }
2757
2758 #[test]
2759 fn an_undistorted_field_with_an_empty_third_still_solves() {
2760 let s = part_empty_scene(0.0);
2761 let wcs = solve_image(&s.img, ¶ms_for(&s, deg(84.3), deg(-5.2))).expect("solve");
2762 assert_solved(&s, &wcs, 1.0);
2763 }
2764
2765 #[test]
2766 fn mild_distortion_is_modelled_too() {
2767 let s = distorted_scene(3.0, 11);
2770 let wcs = solve_image(&s.img, ¶ms_for(&s, deg(84.3), deg(-5.2))).expect("solve");
2771 let floor = s.truth.linear_floor_arcsec();
2772 let err = s.truth.max_error_arcsec(&wcs);
2773 assert!(
2774 err < floor + 2.0,
2775 "corner error {err:.1}\" against a floor of {floor:.1}\""
2776 );
2777 }
2778
2779 #[test]
2780 fn a_field_absent_from_the_catalogue_does_not_solve() {
2781 let truth = TruthWcs::new(deg(120.0), deg(-40.0), 5.0, 0.0, false, 360, 300);
2784 let s = scene(truth, Db::Areas1476, 120, 8);
2785 let decoy = TempDir::new("decoy");
2786 let mut rng = Rng::new(99);
2787 let other = random_sky(
2788 &mut rng,
2789 &SkySpec {
2790 ra0: truth.ra0,
2791 dec0: truth.dec0,
2792 side_deg: 3.0,
2793 n: 4000,
2794 min_sep_deg: 0.015,
2795 mag_lo: 10.0,
2796 mag_hi: 14.5,
2797 },
2798 );
2799 write_1476_db(decoy.path(), "t50", &other);
2800 let mut p = params_for(&s, truth.ra0, truth.dec0);
2801 p.db_path = decoy.path().to_path_buf();
2802 p.search_radius = deg(0.5);
2803 match solve_image(&s.img, &p) {
2804 Err(ArcsecError::InsufficientQuads { found: 0, required }) => {
2805 assert!(required >= 3);
2806 }
2807 other => panic!("expected InsufficientQuads, got {other:?}"),
2808 }
2809 }
2810
2811 #[test]
2812 fn a_corrupt_catalogue_tile_is_skipped_not_fatal() {
2813 let truth = TruthWcs::new(deg(120.0), deg(-40.0), 5.0, 0.0, false, 360, 300);
2814 let s = scene(truth, Db::Areas1476, 120, 9);
2815 for entry in std::fs::read_dir(s.dir.path()).unwrap() {
2817 let path = entry.unwrap().path();
2818 let mut bytes = std::fs::read(&path).unwrap();
2819 bytes[109] = 7;
2820 std::fs::write(&path, bytes).unwrap();
2821 }
2822 let mut p = params_for(&s, truth.ra0, truth.dec0);
2823 p.search_radius = 0.0;
2824 assert!(matches!(
2825 solve_image(&s.img, &p),
2826 Err(ArcsecError::InsufficientQuads { .. })
2827 ));
2828 }
2829
2830 #[test]
2831 fn a_blank_frame_reports_insufficient_stars() {
2832 let dir = TempDir::new("blank");
2833 write_1476_db(dir.path(), "t50", &[]);
2834 let mut rng = Rng::new(3);
2835 let img = ImageBuffer {
2836 data: (0..200 * 200)
2837 .map(|_| (1000.0 + 5.0 * rng.gauss()) as f32)
2838 .collect(),
2839 width: 200,
2840 height: 200,
2841 };
2842 let p = SolveParams {
2843 ra_hint: 0.0,
2844 dec_hint: 0.0,
2845 fov: deg(0.3),
2846 search_radius: deg(1.0),
2847 quad_tolerance: 0.007,
2848 hfd_min: 1.5,
2849 max_stars: 500,
2850 db_path: dir.path().to_path_buf(),
2851 db_name: "t50".into(),
2852 binning: 1,
2853 method: SolveMethod::Quads,
2854 threads: 1,
2855 speed: SearchSpeed::Auto,
2856 };
2857 match solve_image(&img, &p) {
2858 Err(ArcsecError::InsufficientStars { found, required: 5 }) => assert!(found < 5),
2859 other => panic!("expected InsufficientStars, got {other:?}"),
2860 }
2861 }
2862
2863 #[test]
2864 fn a_missing_database_is_reported_before_any_detection() {
2865 let dir = TempDir::new("nodb");
2866 let p = SolveParams {
2867 ra_hint: 0.0,
2868 dec_hint: 0.0,
2869 fov: deg(1.0),
2870 search_radius: deg(1.0),
2871 quad_tolerance: 0.007,
2872 hfd_min: 1.5,
2873 max_stars: 500,
2874 db_path: dir.path().to_path_buf(),
2875 db_name: "d50".into(),
2876 binning: 1,
2877 method: SolveMethod::Quads,
2878 threads: 1,
2879 speed: SearchSpeed::Auto,
2880 };
2881 match solve_image(&ImageBuffer::new(64, 64), &p) {
2882 Err(ArcsecError::CatalogNotFound(path)) => assert_eq!(path, dir.path()),
2883 other => panic!("expected CatalogNotFound, got {other:?}"),
2884 }
2885 }
2886
2887 #[test]
2888 fn solve_image_rejects_a_bad_search_radius_or_fov() {
2889 let base = SolveParams {
2890 ra_hint: 0.0,
2891 dec_hint: 0.0,
2892 fov: deg(1.0),
2893 search_radius: 0.1,
2894 quad_tolerance: 0.007,
2895 hfd_min: 1.5,
2896 max_stars: 500,
2897 db_path: std::path::PathBuf::from("/nonexistent"),
2898 db_name: "d50".into(),
2899 binning: 1,
2900 method: SolveMethod::Quads,
2901 threads: 1,
2902 speed: SearchSpeed::Auto,
2903 };
2904 let img = ImageBuffer::new(64, 64);
2905 for (fov, radius) in [
2906 (f64::NAN, 0.1),
2907 (-1.0, 0.1),
2908 (f64::INFINITY, 0.1),
2909 (0.01, -0.1),
2910 (0.01, f64::NAN),
2911 (0.01, f64::INFINITY),
2912 ] {
2913 let p = SolveParams {
2914 fov,
2915 search_radius: radius,
2916 ..base.clone()
2917 };
2918 assert!(
2919 matches!(solve_image(&img, &p), Err(ArcsecError::InvalidParameter(_))),
2920 "fov {fov}, radius {radius}"
2921 );
2922 }
2923 }
2924
2925 #[test]
2926 fn format_radec_roundtrip() {
2927 let s = format_radec(deg(160.875), deg(-59.524));
2928 assert!(s.contains("10:"), "RA hours: {s}");
2929 assert!(s.contains('-'), "dec sign: {s}");
2930 }
2931}