Skip to main content

arcsec_core/auto/
blind.rs

1//! Blind solving for [`super::Plan`]: rank Astrometry.net index files and run the
2//! blind solver over them to estimate the image position for the catalogue solve,
3//! and decide when and how arcsec's own index is used.
4
5use alloc::sync::Arc;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use crate::ArcsecError;
10use crate::catalog::{load_anet_index, peek_anet_scale};
11use crate::pipeline::{BlindSolveParams, blind_solve};
12use crate::types::ImageBuffer;
13
14/// Index files tried per solve. They run on separate threads, so total blind time is
15/// `max(t_index0, t_index1)` rather than the sum.
16const BLIND_MAX_INDEXES: usize = 2;
17
18/// What the blind stage concluded.
19pub(crate) enum BlindOutcome {
20    /// Position estimate (RA, Dec) in radians, from the best-scoring index.
21    Found(f64, f64),
22    /// No index produced a position.
23    NotFound,
24    /// Nothing solved, and at least one index reported too few stars.
25    InsufficientStars { found: usize, required: usize },
26}
27
28/// Is `name` an Astrometry.net index file name (`index-*.fits`)?
29fn is_index_name(name: &str) -> bool {
30    name.starts_with("index-") && name.ends_with(".fits")
31}
32
33/// Collect and rank astrometry.net index files for blind solving.
34///
35/// For a single file, returns `[path]`. For a directory, peeks every
36/// `index-*.fits` header, filters out files whose scale range is incompatible
37/// with `fov_deg`, and returns the remainder sorted by closeness of scale
38/// midpoint to `fov_deg / 2` (best match first).
39///
40/// When `fov_deg <= 0` the scale filter is skipped and files are returned
41/// in ascending filename order.
42#[must_use]
43pub fn collect_index_files(path: &Path, fov_deg: f64) -> Vec<PathBuf> {
44    if path.is_file() {
45        return vec![path.to_path_buf()];
46    }
47    let Ok(rd) = fs::read_dir(path) else {
48        return vec![];
49    };
50    let mut raw: Vec<PathBuf> = rd
51        .filter_map(Result::ok)
52        .map(|e| e.path())
53        .filter(|p| {
54            p.is_file()
55                && p.file_name()
56                    .and_then(|n| n.to_str())
57                    .is_some_and(is_index_name)
58        })
59        .collect();
60    raw.sort();
61
62    if fov_deg <= 0.0 {
63        return raw;
64    }
65
66    // Peek each file's scale range; filter + rank by match to FOV.
67    // A "compatible" index has some overlap with [fov*0.2, fov*1.5].
68    let fov_lo = fov_deg * 0.2;
69    let fov_hi = fov_deg * 1.5;
70    let ideal = fov_deg / 2.0;
71
72    let mut ranked: Vec<(PathBuf, f64)> = raw
73        .into_iter()
74        .filter_map(|p| {
75            // An unreadable file is skipped silently, as is one CFITSIO panics on
76            // (rsfitsio 0.470.3 does on a header value that is not text).
77            let (_dq, lo_rad, hi_rad) = std::panic::catch_unwind(|| peek_anet_scale(&p))
78                .ok()?
79                .ok()?;
80            let lo_deg = lo_rad.to_degrees();
81            let hi_deg = hi_rad.to_degrees();
82            if hi_deg < fov_lo || lo_deg > fov_hi {
83                log::info!(
84                    "Blind: skipping {} (scale {lo_deg:.2}°–{hi_deg:.2}°, outside [{fov_lo:.2}°–{fov_hi:.2}°])",
85                    p.display(),
86                );
87                return None;
88            }
89            let mid = (lo_deg + hi_deg) / 2.0;
90            Some((p, (mid - ideal).abs()))
91        })
92        .collect();
93
94    // Ascending distance to the ideal scale: best first.
95    ranked.sort_by(|a, b| a.1.total_cmp(&b.1));
96    ranked.into_iter().map(|(p, _)| p).collect()
97}
98
99/// Run the blind solver over the best-ranked `index_files` in parallel and keep the
100/// highest-scoring position.
101///
102/// The number of concurrent indexes is capped by the thread limit, so
103/// `--threads 1` stays genuinely single-threaded. Each index thread inherits the
104/// caller's thread limit and cancellation token.
105pub(crate) fn estimate_position(
106    img: &ImageBuffer,
107    index_files: &[PathBuf],
108    params: &BlindSolveParams,
109) -> BlindOutcome {
110    let max_indexes = BLIND_MAX_INDEXES.min(crate::max_threads().max(1));
111    let img = Arc::new(img.clone());
112    let cancel = crate::cancel::current();
113    let local_threads = crate::local_max_threads();
114
115    let handles: Vec<_> = index_files
116        .iter()
117        .take(max_indexes)
118        .map(|idx_path| {
119            let idx_path = idx_path.clone();
120            let img = Arc::clone(&img);
121            let params = params.clone();
122            let cancel = cancel.clone();
123            std::thread::spawn(move || -> Result<(f64, f64, usize), ArcsecError> {
124                log::info!("Blind: trying index {}", idx_path.display());
125                let anet_index = load_anet_index(&idx_path)?;
126                let res = crate::cancel::with_optional(cancel.as_ref(), || {
127                    crate::with_max_threads(local_threads, || {
128                        blind_solve(&img, &anet_index, &params)
129                    })
130                });
131                if let Ok((ra, dec, score)) = &res {
132                    log::info!(
133                        "Blind: {} → RA={:.3}° Dec={:.3}° score={score}",
134                        idx_path
135                            .file_name()
136                            .map(|n| n.to_string_lossy().into_owned())
137                            .unwrap_or_default(),
138                        ra.to_degrees(),
139                        dec.to_degrees(),
140                    );
141                }
142                res
143            })
144        })
145        .collect();
146
147    let mut best: Option<(f64, f64, usize)> = None;
148    let mut too_few: Option<(usize, usize)> = None;
149    for handle in handles {
150        match handle.join() {
151            Ok(Ok((ra, dec, score))) => {
152                if best.is_none_or(|(_, _, s)| score > s) {
153                    best = Some((ra, dec, score));
154                }
155            }
156            Ok(Err(ArcsecError::InsufficientStars { found, required })) => {
157                too_few = Some((found, required));
158            }
159            Ok(Err(e)) => log::info!("Blind: did not solve: {e}"),
160            Err(_) => log::info!("Blind: thread panicked"),
161        }
162    }
163
164    // One index running short of stars must not discard another's solution.
165    match (best, too_few) {
166        (Some((ra, dec, _)), _) => BlindOutcome::Found(ra, dec),
167        (None, Some((found, required))) => BlindOutcome::InsufficientStars { found, required },
168        (None, None) => BlindOutcome::NotFound,
169    }
170}
171
172// ── arcsec's own index ──────────────────────────────────────────────────────────
173
174/// Relative uncertainty allowed on a pixel scale taken from `--fov` or the header.
175/// FOCALLEN and XPIXSZ are often a few percent off (reducers, binning written
176/// inconsistently); 20% covers that without letting the vote spread.
177const SCALE_SLACK: f64 = 1.2;
178
179/// Pixel scales searched when nothing gives one, arcseconds per pixel.
180const SCALE_UNKNOWN: (f64, f64) = (0.3, 60.0);
181
182/// The arcsec blind index `path` names: the file itself, or the first `*.arcsecix`
183/// in a directory. `None` when there is none (the path may still hold
184/// Astrometry.net files).
185#[must_use]
186pub fn find_arcsec_index(path: &Path) -> Option<PathBuf> {
187    if path.is_file() {
188        return crate::index::is_blind_index(path).then(|| path.to_path_buf());
189    }
190    // Several indexes (say one left from G05 beside one from D80): the one built
191    // from the deepest database; failing a readable one, the first by magic, so
192    // that a damaged file is reported rather than silently ignored.
193    preferred_index(path).or_else(|| {
194        super::index_files(path)
195            .into_iter()
196            .find(|p| crate::index::is_blind_index(p))
197    })
198}
199
200/// Databases an index can be built from, deepest first: an index built from an
201/// earlier one is preferred when a directory holds several.
202pub const SOURCES: [&str; 6] = ["d80", "d50", "d20", "d05", "g05", "w08"];
203
204/// Position of `db` in [`SOURCES`] (deeper is smaller); unknown names sort last.
205#[must_use]
206pub fn depth_rank(db: &str) -> usize {
207    SOURCES
208        .iter()
209        .position(|s| s.eq_ignore_ascii_case(db))
210        .unwrap_or(SOURCES.len())
211}
212
213/// The readable arcsec index in `dir` the solver uses: the one built from the
214/// deepest database (by [`SOURCES`]), then by file name. `None` if there is none.
215#[must_use]
216pub fn preferred_index(dir: &Path) -> Option<PathBuf> {
217    let mut v: Vec<(usize, PathBuf)> = super::index_files(dir)
218        .into_iter()
219        .filter_map(|p| {
220            let ix = crate::index::BlindIndex::open(&p).ok()?;
221            Some((depth_rank(ix.source()), p))
222        })
223        .collect();
224    v.sort();
225    v.into_iter().next().map(|(_, p)| p)
226}
227
228/// Whether a search with `template`'s radius and field is wide enough that an
229/// installed index is consulted automatically: more than five fields, and at
230/// least 10°.
231#[must_use]
232pub fn wants_installed_index(template: &crate::pipeline::SolveParams) -> bool {
233    template.search_radius > stage_one_radius(template) && template.search_radius >= AUTO_MIN_RADIUS
234}
235
236/// Search radius, in fields, that the spiral covers before an automatically found
237/// index is consulted. Inside it the result is exactly the spiral's, so a usable
238/// hint solves as it always did; beyond it the spiral's cost grows with the square
239/// of the radius, while the index's does not grow at all.
240pub(crate) const AUTO_SPIRAL_FIELDS: f64 = 5.0;
241
242/// The smallest stage-one spiral radius, radians (1°).
243const AUTO_SPIRAL_MIN: f64 = 1.0 * core::f64::consts::PI / 180.0;
244
245/// Smallest `-r` at which an installed index is consulted automatically, radians
246/// (10°). Below it a failed search is cheap anyway (about a second on the corpus),
247/// and consulting the index roughly doubled it for nothing; above it the spiral's
248/// cost dominates and the index adds a few percent to a failure.
249const AUTO_MIN_RADIUS: f64 = 10.0 * core::f64::consts::PI / 180.0;
250
251/// An arcsec index to use, and whether the user named it.
252pub(crate) struct OwnIndex {
253    path: PathBuf,
254    /// Tried first, and over the whole sky, as `--index` is; otherwise after the
255    /// spiral has searched round the hint, and only within the search radius.
256    pub(crate) explicit: bool,
257}
258
259/// The arcsec index for this solve: the one `--index` names, if it names one;
260/// otherwise, when the search radius reaches past [`AUTO_SPIRAL_FIELDS`] fields and
261/// is at least 10°, one installed in the catalogue directory or beside the star
262/// database. `automatic: false` turns the second case off.
263pub(crate) fn arcsec_index_for(
264    explicit: Option<&PathBuf>,
265    template: &crate::pipeline::SolveParams,
266    automatic: bool,
267) -> Option<OwnIndex> {
268    if let Some(p) = explicit {
269        return find_arcsec_index(p).map(|path| OwnIndex {
270            path,
271            explicit: true,
272        });
273    }
274    if !automatic || !wants_installed_index(template) {
275        return None;
276    }
277    find_arcsec_index(&super::default_catalog_dir())
278        .or_else(|| find_arcsec_index(&template.db_path))
279        .map(|path| OwnIndex {
280            path,
281            explicit: false,
282        })
283}
284
285/// How far round the hint the spiral searches before a blind index is consulted:
286/// [`AUTO_SPIRAL_FIELDS`] fields, at least 1°.
287pub(crate) fn stage_one_radius(template: &crate::pipeline::SolveParams) -> f64 {
288    (template.fov * AUTO_SPIRAL_FIELDS).max(AUTO_SPIRAL_MIN)
289}
290
291/// What [`index_stage`] concluded.
292pub(crate) enum IndexOutcome {
293    /// A verified solution within the search radius (or anywhere, for a named
294    /// index).
295    Solved(Box<crate::types::WcsSolution>),
296    /// The index verified the field outside the search radius, at this distance
297    /// from the hint (degrees): the ordinary search cannot find it within `-r`,
298    /// and there is no need to run it.
299    Elsewhere(f64),
300    /// Nothing verified; the caller runs the ordinary search.
301    NotFound,
302    /// The solve was cancelled.
303    Cancelled,
304}
305
306/// Solve with an arcsec index.
307///
308/// Named with `--index`, the index is tried first. Found automatically, the
309/// spiral first searches [`AUTO_SPIRAL_FIELDS`] fields round the hint (if there is
310/// a hint), which returns exactly what the full search would for any field that
311/// close; only then is the index consulted, limited to `-r` round the hint unless
312/// the radius is the whole sky.
313///
314/// When that finds nothing, the index is asked again without the limit. A field it
315/// verifies more than [`ELSEWHERE_FIELDS`] fields beyond `-r` is somewhere the rest
316/// of the spiral cannot reach, and the spiral would spend all of its time (the
317/// bulk of a failed search: 6000 positions for a 0.2° field at `-r 10`) finding
318/// nothing; [`IndexOutcome::Elsewhere`] tells the caller to stop. The answer is
319/// still "no solution", as `-r` requires: the field is not reported.
320///
321/// `scale` is arcseconds per pixel of the image as solved (after binning);
322/// `scale_known` says whether it came from the user or the header rather than the
323/// 1″/px fallback.
324pub(crate) fn index_stage(
325    img: &ImageBuffer,
326    ix: &OwnIndex,
327    template: &crate::pipeline::SolveParams,
328    has_hint: bool,
329    scale: f64,
330    scale_known: bool,
331) -> IndexOutcome {
332    use core::f64::consts::PI;
333    if !ix.explicit && has_hint {
334        let r0 = stage_one_radius(template);
335        log::info!(
336            "Searching {:.1}° round the hint before the blind index.",
337            r0.to_degrees()
338        );
339        let near = crate::pipeline::SolveParams {
340            search_radius: r0,
341            ..template.clone()
342        };
343        match crate::pipeline::solve_image(img, &near) {
344            Ok(w) => return IndexOutcome::Solved(Box::new(w)),
345            Err(ArcsecError::Cancelled) => return IndexOutcome::Cancelled,
346            Err(_) => {}
347        }
348    }
349    // Named with --index the solve is blind, as with Astrometry.net files; found
350    // automatically it stands in for the rest of the spiral, so it keeps to -r.
351    let within = (!ix.explicit && has_hint && template.search_radius < PI).then_some((
352        template.ra_hint,
353        template.dec_hint,
354        template.search_radius + template.fov,
355    ));
356    let from_hint = |w: &crate::types::WcsSolution| {
357        crate::math::coords::ang_sep(w.ra0, w.dec0, template.ra_hint, template.dec_hint)
358    };
359    if let Some(mut wcs) =
360        solve_with_arcsec_index(img, &ix.path, template, scale, scale_known, within)
361    {
362        // The hinted solve started at the index's hypothesis; report the distance
363        // from the user's start position, as the spiral would.
364        wcs.search_dist_deg = from_hint(&wcs).to_degrees();
365        return IndexOutcome::Solved(Box::new(wcs));
366    }
367    if within.is_some()
368        && let Some(wcs) =
369            solve_with_arcsec_index(img, &ix.path, template, scale, scale_known, None)
370    {
371        let sep = from_hint(&wcs);
372        if beyond_reach(sep, template) {
373            log::info!(
374                "Blind index: the field is at RA={:.4}° Dec={:.4}°, {:.1}° from the hint, \
375                 outside the search radius; not searching it.",
376                wcs.ra0.to_degrees(),
377                wcs.dec0.to_degrees(),
378                sep.to_degrees()
379            );
380            return IndexOutcome::Elsewhere(sep.to_degrees());
381        }
382    }
383    if crate::cancel::is_cancelled() {
384        return IndexOutcome::Cancelled;
385    }
386    IndexOutcome::NotFound
387}
388
389/// How many fields past `-r` a field the index verifies must lie for the
390/// ordinary search to be skipped. The spiral reads catalogue windows up to half a
391/// step and up to a field beyond `-r`, so a field just outside the radius could
392/// still be matched there; two fields is clear of that.
393const ELSEWHERE_FIELDS: f64 = 2.0;
394
395/// Whether a field centred `sep` radians from the hint is out of the ordinary
396/// search's reach.
397fn beyond_reach(sep: f64, template: &crate::pipeline::SolveParams) -> bool {
398    sep > template.search_radius + ELSEWHERE_FIELDS * template.fov
399}
400
401/// Solve with an arcsec blind index; `None` if it found nothing that verified.
402fn solve_with_arcsec_index(
403    img: &ImageBuffer,
404    path: &Path,
405    template: &crate::pipeline::SolveParams,
406    scale: f64,
407    scale_known: bool,
408    within: Option<(f64, f64, f64)>,
409) -> Option<crate::types::WcsSolution> {
410    let t0 = std::time::Instant::now();
411    let index = match crate::index::BlindIndex::open(path) {
412        Ok(ix) => ix,
413        Err(e) => {
414            log::warn!("Blind index {}: {e}", path.display());
415            return None;
416        }
417    };
418    let (scale_lo, scale_hi) = if scale_known {
419        (scale / SCALE_SLACK, scale * SCALE_SLACK)
420    } else {
421        SCALE_UNKNOWN
422    };
423    log::info!(
424        "Blind index {} ({} patterns), pixel scale {scale_lo:.3}–{scale_hi:.3}\"/px",
425        path.display(),
426        index.n_patterns()
427    );
428    let params = crate::pipeline::IndexSolveParams {
429        scale_lo,
430        scale_hi,
431        within,
432    };
433    match crate::pipeline::index_solve(img, &index, template, &params) {
434        Ok((wcs, stats)) => {
435            log::info!(
436                "Blind index: solved in {:.2} s (hypothesis rank {:?}, score {}, {} hinted solves)",
437                t0.elapsed().as_secs_f64(),
438                stats.accepted_rank,
439                stats.best_score,
440                stats.verified
441            );
442            Some(wcs)
443        }
444        Err(e) => {
445            log::info!(
446                "Blind index: no solution after {:.2} s: {e}",
447                t0.elapsed().as_secs_f64()
448            );
449            None
450        }
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    #[test]
459    fn index_names_are_recognised() {
460        assert!(is_index_name("index-4107.fits"));
461        assert!(is_index_name("index-5200-07.fits"));
462        assert!(!is_index_name("index-4107.fits.part"));
463        assert!(!is_index_name("d50_0101.1476"));
464    }
465
466    #[test]
467    fn only_a_field_two_fields_past_the_radius_is_beyond_reach() {
468        use crate::pipeline::{SearchSpeed, SolveMethod, SolveParams};
469        let deg = f64::to_radians;
470        let t = SolveParams {
471            ra_hint: 0.0,
472            dec_hint: 0.0,
473            fov: deg(0.5),
474            search_radius: deg(10.0),
475            quad_tolerance: 0.007,
476            hfd_min: 1.5,
477            max_stars: 500,
478            db_path: PathBuf::new(),
479            db_name: "d80".into(),
480            binning: 1,
481            method: SolveMethod::Quads,
482            speed: SearchSpeed::Auto,
483            threads: 1,
484        };
485        assert!(!beyond_reach(deg(5.0), &t));
486        assert!(!beyond_reach(deg(10.9), &t));
487        assert!(beyond_reach(deg(11.1), &t));
488        assert!(beyond_reach(deg(40.0), &t));
489    }
490
491    #[test]
492    fn a_missing_path_yields_no_indexes() {
493        assert!(collect_index_files(Path::new("/nonexistent/arcsec/indexes"), 1.0).is_empty());
494    }
495}