arcsec_core/auto/scale.rs
1//! Searching pixel scales: what the catalogue search does when it does not know
2//! the image's scale, or (if asked) when the scale it was given does not solve.
3//!
4//! The spiral steps one field at a time and reads a catalogue window one field
5//! wide, both derived from the pixel scale, so a scale wrong by about 2× reads the
6//! wrong stars at every position: too few to match an image that is really wider,
7//! too many, and too bright, for one that is really narrower. The quads themselves
8//! are scale-free; what the scale buys is a catalogue that looks like the image.
9//!
10//! So a scale search is a ladder of hypotheses, √2 apart, each a short catalogue
11//! search round the hint ([`LADDER_FIELDS`]); a true scale lies within 2^¼ (19 %) of
12//! one of them, and the measured tolerance of a single solve is wider than that
13//! (docs/plate-solving.md §10.3e).
14
15use core::f64::consts::SQRT_2;
16
17/// Ratio between neighbouring hypotheses: astrometry.net's index scales step by
18/// the same factor.
19pub const SCALE_STEP: f64 = SQRT_2;
20
21/// Steps of [`SCALE_STEP`] below and above 1″/px searched when nothing gives the
22/// scale: 0.25″/px to 64″/px, from a long focal length binned to a camera lens. The
23/// blind index searches 0.3–60″/px for the same case.
24pub const UNKNOWN_STEPS: (i32, i32) = (-4, 12);
25
26/// Steps either side of a scale that was given (or read from the header) and did
27/// not solve, with [`ScaleSearch::AlsoIfWrong`]: a quarter to four times it.
28pub const WRONG_STEPS: i32 = 4;
29
30/// How far round the hint each hypothesis is searched, in fields of that
31/// hypothesis: the hint's own position and the ring of eight round it. The search
32/// at the assumed scale then continues out to `-r`, as it always has.
33pub const LADDER_FIELDS: f64 = 1.0;
34
35/// When the catalogue search tries other pixel scales.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37#[non_exhaustive]
38pub enum ScaleSearch {
39 /// Never: an unknown scale is taken to be 1″/px, as before 0.6.
40 Never,
41 /// When neither a field of view nor header optics give the scale (ASTAP's
42 /// `-fov 0` with no FOCALLEN/XPIXSZ): a ladder of scales round 1″/px, then the
43 /// usual search at 1″/px. The default.
44 #[default]
45 IfUnknown,
46 /// As [`Self::IfUnknown`], and also when a solve at a known scale finds
47 /// nothing: a ladder from a quarter to four times that scale (the CLI's
48 /// `--fov-search`). Off by default, so a correctly configured solve that fails
49 /// costs no more than it did.
50 AlsoIfWrong,
51}
52
53/// One scale hypothesis: `centre × SCALE_STEP^step`.
54#[derive(Debug, Clone, Copy, PartialEq)]
55pub struct Hypothesis {
56 /// Steps of [`SCALE_STEP`] from the centre of the ladder.
57 pub step: i32,
58 /// Pixel scale, arcseconds per (unbinned) pixel.
59 pub scale: f64,
60}
61
62/// The hypotheses `centre × √2^k` for `k` in `lo..=hi`, most likely first.
63///
64/// "Most likely" is nearest the centre (the scale assumed or given), in steps;
65/// of two equally near, the larger scale first, because a wider field is the
66/// cheaper search (fewer catalogue stars per degree, fewer positions) and the
67/// common way to have no scale at all is a camera lens. `skip_centre` leaves the
68/// centre out, for when it has already been searched.
69#[must_use]
70pub fn ladder(centre: f64, lo: i32, hi: i32, skip_centre: bool) -> Vec<Hypothesis> {
71 let mut steps: Vec<i32> = (lo..=hi).filter(|&k| !(skip_centre && k == 0)).collect();
72 steps.sort_by_key(|&k| (k.unsigned_abs(), k < 0));
73 steps
74 .into_iter()
75 .map(|step| Hypothesis {
76 step,
77 scale: centre * SCALE_STEP.powi(step),
78 })
79 .collect()
80}
81
82/// The fraction by which a solved scale may differ from the one the solve started
83/// from before the solution says so ([`inaccurate_scale_warning`]), as `astap_cli`.
84pub const INACCURATE_SCALE: f64 = 0.05;
85
86/// `astap_cli`'s warning when the solved scale is not the one it started from:
87/// `Warning scale was inaccurate! Set FOV=1.00d, scale=1.2"`, printed after the
88/// solution and written to the `.ini` as `WARNING`.
89///
90/// `start_scale` is the scale the solve started from and `solved_scale` the
91/// solution's, both arcseconds per unbinned pixel; `height` is the unbinned image
92/// height in pixels. The FOV is the solution's image height, as `-fov` takes it.
93#[must_use]
94pub fn inaccurate_scale_warning(
95 start_scale: f64,
96 solved_scale: f64,
97 height: usize,
98) -> Option<String> {
99 let ratio = start_scale / solved_scale;
100 if !ratio.is_finite() || (ratio - 1.0).abs() <= INACCURATE_SCALE {
101 return None;
102 }
103 Some(format!(
104 "Warning scale was inaccurate! Set FOV={:.2}d, scale={:.1}\"",
105 solved_scale * height as f64 / 3600.0,
106 solved_scale
107 ))
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 fn steps(v: &[Hypothesis]) -> Vec<i32> {
115 v.iter().map(|h| h.step).collect()
116 }
117
118 #[test]
119 fn the_ladder_starts_at_the_centre_and_alternates_outwards() {
120 let l = ladder(1.0, -2, 3, false);
121 assert_eq!(steps(&l), [0, 1, -1, 2, -2, 3]);
122 assert!((l[1].scale - SQRT_2).abs() < 1e-12);
123 assert!((l[2].scale - 1.0 / SQRT_2).abs() < 1e-12);
124 assert!((l[5].scale - 2.0 * SQRT_2).abs() < 1e-12);
125 }
126
127 #[test]
128 fn the_unknown_ladder_covers_a_quarter_to_64_arcsec_per_pixel() {
129 let (lo, hi) = UNKNOWN_STEPS;
130 let l = ladder(1.0, lo, hi, false);
131 assert_eq!(l.len(), 17);
132 let min = l.iter().map(|h| h.scale).fold(f64::INFINITY, f64::min);
133 let max = l.iter().map(|h| h.scale).fold(0.0, f64::max);
134 assert!((min - 0.25).abs() < 1e-12 && (max - 64.0).abs() < 1e-9);
135 // Past the smaller end, only larger scales remain, in order.
136 assert_eq!(steps(&l)[8..], [-4, 5, 6, 7, 8, 9, 10, 11, 12]);
137 // Every scale in the range is within 2^(1/4) of a hypothesis.
138 let mut s = 0.25_f64;
139 while s <= 64.0 {
140 let nearest = l
141 .iter()
142 .map(|h| (h.scale / s).ln().abs())
143 .fold(f64::INFINITY, f64::min);
144 assert!(nearest <= SCALE_STEP.ln() / 2.0 + 1e-12, "{s}");
145 s *= 1.01;
146 }
147 }
148
149 #[test]
150 fn a_given_scale_that_failed_is_not_tried_again() {
151 let l = ladder(2.0, -WRONG_STEPS, WRONG_STEPS, true);
152 assert_eq!(steps(&l), [1, -1, 2, -2, 3, -3, 4, -4]);
153 assert!((l[6].scale - 8.0).abs() < 1e-9 && (l[7].scale - 0.5).abs() < 1e-12);
154 }
155
156 #[test]
157 fn the_warning_follows_astap() {
158 assert_eq!(inaccurate_scale_warning(1.25, 1.241, 2900), None);
159 assert_eq!(
160 inaccurate_scale_warning(1.0, 1.2857, 1400).as_deref(),
161 Some("Warning scale was inaccurate! Set FOV=0.50d, scale=1.3\"")
162 );
163 assert!(inaccurate_scale_warning(1.31, 1.241, 2900).is_some());
164 assert!(inaccurate_scale_warning(1.17, 1.241, 2900).is_some());
165 assert_eq!(inaccurate_scale_warning(1.0, 0.0, 100), None);
166 }
167}