Skip to main content

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}