Skip to main content

arcsec_core/auto/
mod.rs

1//! Everything the `arcsec` command line decides for the user, as a library.
2//!
3//! [`crate::pipeline::solve_image`] needs to be told everything: the field size in
4//! radians, the binning, the database directory and name, the minimum star size in
5//! (binned) pixels. A user — of the CLI, or of an application embedding arcsec —
6//! knows less than that and expects the rest to be worked out the way the CLI does
7//! it. This module is that working-out, shared by the `arcsec` binary and the C
8//! library so the two cannot drift:
9//!
10//! - where catalogues live ([`default_catalog_dir`], [`default_db_path`]);
11//! - which star database suits a field ([`select_db_for_fov`]);
12//! - how far to bin ([`choose_binning`]);
13//! - which pixel scales to try when the scale is not known ([`ScaleSearch`],
14//!   [`ladder`]);
15//! - when to use a blind index, and how ([`find_arcsec_index`],
16//!   [`collect_index_files`]);
17//! - and the whole solve built from those decisions: a [`SolveRequest`] becomes a
18//!   [`Plan`], and [`Plan::solve`] runs it.
19//!
20//! ```no_run
21//! use arcsec_core::auto::{Plan, SolveRequest};
22//! # fn load() -> arcsec_core::ImageBuffer { arcsec_core::ImageBuffer::new(4096, 3072) }
23//! let mut img = load();
24//! img.normalize_for_detection();
25//! let request = SolveRequest {
26//!     hint: Some((83.82_f64.to_radians(), (-5.39_f64).to_radians())),
27//!     pixel_scale: Some(1.1),
28//!     search_radius: 10.0_f64.to_radians(),
29//!     ..SolveRequest::default()
30//! };
31//! let plan = Plan::new(&request, img.width, img.height)?;
32//! let solved = plan.solve(&img)?;
33//! println!("RA {:.4}°", solved.wcs.ra0.to_degrees());
34//! # Ok::<(), arcsec_core::ArcsecError>(())
35//! ```
36
37mod blind;
38mod db;
39mod scale;
40
41use alloc::borrow::Cow;
42use core::f64::consts::PI;
43use std::path::{Path, PathBuf};
44
45pub use blind::{
46    SOURCES, collect_index_files, depth_rank, find_arcsec_index, preferred_index,
47    wants_installed_index,
48};
49pub use db::{
50    ASTAP_EXTS, DB_FOV_RANGES, available_dbs, default_db_path, has_star_database, select_db_for_fov,
51};
52pub use scale::{
53    Hypothesis, INACCURATE_SCALE, LADDER_FIELDS, SCALE_STEP, ScaleSearch, UNKNOWN_STEPS,
54    WRONG_STEPS, inaccurate_scale_warning, ladder,
55};
56
57use crate::cancel::CancelToken;
58use crate::error::{ArcsecError, Result};
59use crate::pipeline::solver::{
60    Detected, ScaleTrust, detect, search_in_order, solve_detected, solve_image_with,
61};
62use crate::pipeline::{BlindSolveParams, SearchSpeed, SolveMethod, SolveParams, solve_image};
63use crate::types::{ImageBuffer, WcsSolution};
64
65// ── Where things live ───────────────────────────────────────────────────────────
66
67/// Where catalogues are kept, in priority order:
68///
69/// 1. `$ARCSEC_CATALOG_DIR`, if set — for people who keep them on another disk.
70/// 2. `$XDG_DATA_HOME/arcsec/catalogs` on Linux, or the platform equivalent:
71///    `~/Library/Application Support/arcsec/catalogs` on macOS,
72///    `%LOCALAPPDATA%\arcsec\catalogs` on Windows.
73/// 3. `~/.arcsec/catalogs` if the home directory cannot be resolved any other way.
74///
75/// The point is that a user who runs `arcsec catalog install d50` never has to know
76/// this path, and the solver looks here without being told.
77#[must_use]
78pub fn default_catalog_dir() -> PathBuf {
79    default_catalog_dir_from(|k| std::env::var(k).ok())
80}
81
82/// [`default_catalog_dir`] with the environment supplied by `var`, so it can be
83/// tested without mutating the real process environment.
84fn default_catalog_dir_from(var: impl Fn(&str) -> Option<String>) -> PathBuf {
85    let get = |k: &str| var(k).filter(|v| !v.is_empty()).map(PathBuf::from);
86
87    if let Some(p) = get("ARCSEC_CATALOG_DIR") {
88        return p;
89    }
90
91    let platform = if cfg!(target_os = "windows") {
92        get("LOCALAPPDATA").map(|p| p.join("arcsec").join("catalogs"))
93    } else if cfg!(target_os = "macos") {
94        get("HOME").map(|h| {
95            h.join("Library")
96                .join("Application Support")
97                .join("arcsec")
98                .join("catalogs")
99        })
100    } else {
101        get("XDG_DATA_HOME")
102            .map(|p| p.join("arcsec").join("catalogs"))
103            .or_else(|| {
104                get("HOME").map(|h| {
105                    h.join(".local")
106                        .join("share")
107                        .join("arcsec")
108                        .join("catalogs")
109                })
110            })
111    };
112
113    platform.unwrap_or_else(|| {
114        get("HOME").or_else(|| get("USERPROFILE")).map_or_else(
115            || PathBuf::from("catalogs"),
116            |h| h.join(".arcsec").join("catalogs"),
117        )
118    })
119}
120
121/// Every arcsec blind index (`*.arcsecix`) directly in `dir`, sorted by name.
122/// Empty when there are none or `dir` cannot be read.
123#[must_use]
124pub fn index_files(dir: &Path) -> Vec<PathBuf> {
125    let mut v: Vec<PathBuf> = std::fs::read_dir(dir)
126        .map(|rd| {
127            rd.filter_map(core::result::Result::ok)
128                .map(|e| e.path())
129                .filter(|p| {
130                    p.extension()
131                        .is_some_and(|e| e == crate::index::format::EXTENSION)
132                })
133                .collect()
134        })
135        .unwrap_or_default();
136    v.sort();
137    v
138}
139
140// ── Binning ─────────────────────────────────────────────────────────────────────
141
142/// Smallest image side, in (binned) pixels, that reaches the solver. Detection
143/// cannot run on a one-pixel-wide image, and would otherwise panic on it.
144pub const MIN_SOLVE_DIM: usize = 2;
145
146/// The binning factor: `requested` if given and non-zero, else automatic.
147///
148/// Automatic binning brings a sampling finer than 1"/px back to about 1"/px, up to
149/// 16×. Either way the factor is capped so the binned image keeps at least
150/// [`MIN_SOLVE_DIM`] pixels a side: binning past the image size leaves nothing to
151/// detect in, and used to crash.
152#[must_use]
153pub fn choose_binning(
154    requested: Option<usize>,
155    arcsec_per_px: f64,
156    width: usize,
157    height: usize,
158) -> usize {
159    let binning = match requested {
160        Some(0) | None => {
161            if arcsec_per_px < 1.0 {
162                (1.0 / arcsec_per_px).round().clamp(1.0, 16.0) as usize
163            } else {
164                1
165            }
166        }
167        Some(z) => z,
168    };
169    binning.min((width.min(height) / MIN_SOLVE_DIM).max(1))
170}
171
172// ── The request and its plan ────────────────────────────────────────────────────
173
174/// What the caller knows about an image and how it wants it solved, in the
175/// caller's terms. Every field has the CLI's default ([`SolveRequest::default`]).
176#[derive(Debug, Clone)]
177pub struct SolveRequest {
178    /// Approximate centre (RA, Dec), radians. `None` for no hint: the search
179    /// starts at (0, 0), and an installed blind index is used as for any wide
180    /// search.
181    pub hint: Option<(f64, f64)>,
182    /// Field of view along the image *height*, radians (ASTAP's `-fov`). Takes
183    /// precedence over [`Self::pixel_scale`].
184    pub fov_height: Option<f64>,
185    /// Pixel scale of the unbinned image, arcseconds per pixel (for instance from
186    /// the FOCALLEN and XPIXSZ header keywords). Without it or a field of view, 1″/px
187    /// is assumed, and [`Self::scale_search`] says whether other scales are tried.
188    pub pixel_scale: Option<f64>,
189    /// When the catalogue search tries other pixel scales: by default only when
190    /// neither [`Self::fov_height`] nor [`Self::pixel_scale`] gives one.
191    pub scale_search: ScaleSearch,
192    /// Search radius around the hint, radians. Negative or NaN means 0.
193    pub search_radius: f64,
194    /// Binning factor; `None` or `Some(0)` chooses one ([`choose_binning`]).
195    pub downsample: Option<usize>,
196    /// Star database directory; `None` for [`default_db_path`].
197    pub db_path: Option<PathBuf>,
198    /// Star database name (`"d50"`); `None` to choose by field size
199    /// ([`select_db_for_fov`]).
200    pub db_name: Option<String>,
201    /// Blind index to use (the CLI's `--index`): an arcsec `.arcsecix` file, a
202    /// directory holding one, or Astrometry.net `index-*.fits` files. `None` still
203    /// uses an arcsec index installed in the catalogue directory for a wide search,
204    /// unless [`Self::auto_index`] is off.
205    pub index: Option<PathBuf>,
206    /// With a hint, whether the index named in [`Self::index`] is tried before
207    /// the search round the hint (the CLI's `--index`: true, the default), or only
208    /// after the spiral has searched a few fields round it (false), as an
209    /// installed index is. Without a hint the index always comes first.
210    pub index_first: bool,
211    /// Consult an arcsec index installed in the catalogue directory (or beside the
212    /// star database) when the search is wider than a few fields and at least 10°.
213    /// On by default, as in the CLI.
214    pub auto_index: bool,
215    /// Minimum star size (HFD), arcseconds.
216    pub hfd_min_arcsec: f64,
217    /// Pattern-matching tolerance.
218    pub quad_tolerance: f64,
219    /// Maximum number of image stars to use.
220    pub max_stars: usize,
221    /// Pattern-matching algorithm.
222    pub method: SolveMethod,
223    /// Catalogue window per spiral position.
224    pub speed: SearchSpeed,
225    /// Worker threads for this solve; 0 for the process-wide limit
226    /// ([`crate::max_threads`]).
227    pub threads: usize,
228    /// Fit SIP distortion polynomials to the solution ([`crate::wcs::fit_sip`]).
229    pub sip: bool,
230    /// Stop early when this token is cancelled ([`ArcsecError::Cancelled`]).
231    pub cancel: Option<CancelToken>,
232}
233
234impl Default for SolveRequest {
235    /// The CLI's defaults: no hint, a 180° radius (the whole sky), 500 stars,
236    /// tolerance 0.007, minimum HFD 1.5″, automatic binning and database.
237    fn default() -> Self {
238        Self {
239            hint: None,
240            fov_height: None,
241            pixel_scale: None,
242            scale_search: ScaleSearch::default(),
243            search_radius: PI,
244            downsample: None,
245            db_path: None,
246            db_name: None,
247            index: None,
248            index_first: true,
249            auto_index: true,
250            hfd_min_arcsec: 1.5,
251            quad_tolerance: 0.007,
252            max_stars: 500,
253            method: SolveMethod::Quads,
254            speed: SearchSpeed::Auto,
255            threads: 0,
256            sip: false,
257            cancel: None,
258        }
259    }
260}
261
262/// A [`SolveRequest`] with every decision made, for an image of a given size.
263///
264/// Built by [`Plan::new`] without touching the pixels, so a caller can report what
265/// will happen (the CLI prints it, as ASTAP does) before [`Plan::solve`] runs.
266#[derive(Debug, Clone)]
267pub struct Plan {
268    /// Start of the search (RA, Dec), radians: the hint, or (0, 0).
269    pub start: (f64, f64),
270    /// Whether the request had a hint.
271    pub has_hint: bool,
272    /// Pixel scale of the unbinned image, arcseconds per pixel.
273    pub arcsec_per_px: f64,
274    /// Whether the scale came from the request rather than the 1″/px fallback.
275    pub scale_known: bool,
276    /// Field of view along the image height, radians.
277    pub fov_height: f64,
278    /// Binning factor the image is solved at.
279    pub binning: usize,
280    /// Unbinned image size, pixels.
281    pub image_size: (usize, usize),
282    /// Minimum star size, arcseconds (as requested).
283    pub hfd_min_arcsec: f64,
284    /// The catalogue solve's parameters: the start, the field along the longer
285    /// side, the database, and the minimum HFD in binned pixels.
286    pub params: SolveParams,
287    index: Option<PathBuf>,
288    index_first: bool,
289    auto_index: bool,
290    sip: bool,
291    cancel: Option<CancelToken>,
292    /// The request, for the plans of other scales ([`ScaleSearch`]).
293    request: SolveRequest,
294}
295
296/// Something [`Plan::solve_with`] reports while it runs.
297#[derive(Debug, Clone, Copy, PartialEq)]
298#[non_exhaustive]
299pub enum Event {
300    /// The Astrometry.net blind solver estimated the position (RA, Dec), radians;
301    /// the catalogue search starts there.
302    IndexEstimate(f64, f64),
303}
304
305/// A solution from [`Plan::solve`].
306#[derive(Debug, Clone)]
307pub struct Solved {
308    /// The WCS, on the unbinned image's pixel grid; SIP included if requested
309    /// and the fit was worthwhile.
310    pub wcs: WcsSolution,
311    /// The Astrometry.net blind solver's position estimate, if one was used.
312    pub index_estimate: Option<(f64, f64)>,
313}
314
315impl Plan {
316    /// Make every decision for a `width` × `height` image (unbinned).
317    ///
318    /// # Errors
319    ///
320    /// [`ArcsecError::InvalidParameter`] for an empty image or a field of view or
321    /// pixel scale that is not positive and finite.
322    pub fn new(req: &SolveRequest, width: usize, height: usize) -> Result<Self> {
323        if width == 0 || height == 0 {
324            return Err(ArcsecError::InvalidParameter(format!(
325                "image is {width}×{height} pixels"
326            )));
327        }
328        let bad = |v: f64| !(v.is_finite() && v > 0.0);
329        if req.fov_height.is_some_and(bad) || req.pixel_scale.is_some_and(bad) {
330            return Err(ArcsecError::InvalidParameter(
331                "field of view and pixel scale must be positive".into(),
332            ));
333        }
334        // Priority: an explicit field of view > header optics > 1"/px fallback.
335        // `fov` (the field along the longer side) is what database selection and
336        // the search window use.
337        let naxis = width.max(height) as f64;
338        let h = height as f64;
339        let (arcsec_per_px, scale_known) = match (req.fov_height, req.pixel_scale) {
340            (Some(fov_h), _) => (fov_h.to_degrees() * 3600.0 / h, true),
341            (None, Some(ps)) => (ps, true),
342            (None, None) => (1.0, false),
343        };
344        let fov = match req.fov_height {
345            Some(fov_h) => fov_h * (naxis / h),
346            None => (naxis * arcsec_per_px / 3600.0).to_radians(),
347        };
348        let binning = choose_binning(req.downsample, arcsec_per_px, width, height);
349
350        let db_path = req.db_path.clone().unwrap_or_else(default_db_path);
351        // Resolved from the field size: the D-series covers 0.15°–6°, G05 3°–20°
352        // and W08 20°–80°.
353        let db_name = req.db_name.clone().unwrap_or_else(|| {
354            select_db_for_fov(&db_path, fov.to_degrees()).unwrap_or_else(|| "d80".to_string())
355        });
356        let hfd_min = (req.hfd_min_arcsec / (binning as f64 * arcsec_per_px)).max(0.8);
357        let start = req.hint.unwrap_or((0.0, 0.0));
358        Ok(Self {
359            start,
360            has_hint: req.hint.is_some(),
361            arcsec_per_px,
362            scale_known,
363            fov_height: fov * (h / naxis),
364            binning,
365            image_size: (width, height),
366            hfd_min_arcsec: req.hfd_min_arcsec,
367            params: SolveParams {
368                ra_hint: start.0,
369                dec_hint: start.1,
370                fov,
371                // ASTAP treats a negative (or NaN) radius as zero and solves at the
372                // start position; `max` maps NaN to 0.
373                search_radius: req.search_radius.max(0.0),
374                quad_tolerance: req.quad_tolerance,
375                hfd_min,
376                max_stars: req.max_stars,
377                db_path,
378                db_name,
379                binning,
380                method: req.method,
381                speed: req.speed,
382                threads: req.threads,
383            },
384            index: req.index.clone(),
385            index_first: req.index_first,
386            auto_index: req.auto_index,
387            sip: req.sip,
388            cancel: req.cancel.clone(),
389            request: req.clone(),
390        })
391    }
392
393    /// `astap_cli`'s warning for a solution whose scale is not the one the solve
394    /// started from ([`inaccurate_scale_warning`]): the scale given, read from the
395    /// header, or assumed.
396    #[must_use]
397    pub fn scale_warning(&self, wcs: &WcsSolution) -> Option<String> {
398        let solved = (wcs.cd1_1 * wcs.cd2_2 - wcs.cd1_2 * wcs.cd2_1).abs().sqrt() * 3600.0;
399        inaccurate_scale_warning(self.arcsec_per_px, solved, self.image_size.1)
400    }
401
402    /// Whether the catalogue search will try other scales should this one not
403    /// solve at once: always when the scale is unknown (unless
404    /// [`ScaleSearch::Never`]), and after a failure with
405    /// [`ScaleSearch::AlsoIfWrong`].
406    #[must_use]
407    pub fn searches_scales(&self) -> bool {
408        match self.request.scale_search {
409            ScaleSearch::Never => false,
410            ScaleSearch::IfUnknown => !self.scale_known,
411            ScaleSearch::AlsoIfWrong => true,
412        }
413    }
414
415    /// Size of the image as solved, after binning.
416    #[must_use]
417    pub fn binned_size(&self) -> (usize, usize) {
418        let b = self.binning.max(1);
419        (self.image_size.0 / b, self.image_size.1 / b)
420    }
421
422    /// Solve `img`, the unbinned image this plan was made for, with its pixels
423    /// already normalised ([`ImageBuffer::normalize_for_detection`]).
424    ///
425    /// # Errors
426    ///
427    /// Everything [`solve_image`] returns, and:
428    /// - [`ArcsecError::InvalidParameter`] if `img` is not the size planned for;
429    /// - [`ArcsecError::InsufficientStars`] if the binned image is too small;
430    /// - [`ArcsecError::IndexNotFound`] if [`SolveRequest::index`] names nothing;
431    /// - [`ArcsecError::OutsideSearchRadius`] if an index found the field beyond
432    ///   the search radius;
433    /// - [`ArcsecError::Cancelled`] if the request's token fired.
434    pub fn solve(&self, img: &ImageBuffer) -> Result<Solved> {
435        self.solve_with(img, |_| {})
436    }
437
438    /// [`Plan::solve`], reporting [`Event`]s to `on_event` as they happen.
439    ///
440    /// # Errors
441    ///
442    /// As [`Plan::solve`].
443    pub fn solve_with(&self, img: &ImageBuffer, on_event: impl FnMut(Event)) -> Result<Solved> {
444        if (img.width, img.height) != self.image_size || img.data.len() != img.width * img.height {
445            return Err(ArcsecError::InvalidParameter(format!(
446                "image is {}×{}, planned for {}×{}",
447                img.width, img.height, self.image_size.0, self.image_size.1
448            )));
449        }
450        if !self.scale_known {
451            log::warn!(
452                "No pixel scale given (a field of view, or FOCALLEN and XPIXSZ in the header): {}",
453                if self.searches_scales() {
454                    "searching scales from 0.25 to 64\"/px round the hint, then 1\"/px"
455                } else {
456                    "assuming 1\"/px, which will not solve unless it is roughly right"
457                }
458            );
459        }
460        crate::cancel::with_optional(self.cancel.as_ref(), || {
461            crate::with_max_threads(self.params.threads, || self.run(img, on_event))
462        })
463    }
464
465    fn run(&self, unbinned: &ImageBuffer, mut on_event: impl FnMut(Event)) -> Result<Solved> {
466        let img = unbinned;
467        let (bw, bh) = self.binned_size();
468        if bw < MIN_SOLVE_DIM || bh < MIN_SOLVE_DIM {
469            return Err(ArcsecError::InsufficientStars {
470                found: 0,
471                required: 5,
472            });
473        }
474        let img: Cow<'_, ImageBuffer> = if self.binning > 1 {
475            log::info!(
476                "Creating grayscale x {0} binning image for solving/star alignment.",
477                self.binning
478            );
479            Cow::Owned(img.bin_image(self.binning))
480        } else {
481            Cow::Borrowed(img)
482        };
483        let template = &self.params;
484        let (ra_hint, dec_hint) = self.start;
485
486        // arcsec's own blind index, named by `index` or, for a search wider than a
487        // few fields, found in the catalogue directory: it finds the field and the
488        // hinted solver accepts it (see blind::index_stage). With Astrometry.net
489        // files, the blind solver estimates the position first, and that estimate
490        // becomes the hint for the catalogue spiral solver.
491        // A hint, and an index named only as a fallback: the index waits until the
492        // spiral has searched round the hint, exactly as an installed one does.
493        let fallback = self.has_hint && !self.index_first;
494        let own_index = blind::arcsec_index_for(self.index.as_ref(), template, self.auto_index)
495            .map(|mut ix| {
496                ix.explicit &= !fallback;
497                ix
498            });
499        let index_wcs = match own_index.as_ref().map(|ix| {
500            blind::index_stage(
501                &img,
502                ix,
503                template,
504                self.has_hint,
505                self.arcsec_per_px * self.binning as f64,
506                self.scale_known,
507            )
508        }) {
509            Some(blind::IndexOutcome::Solved(w)) => Some(*w),
510            Some(blind::IndexOutcome::Elsewhere(separation_deg)) => {
511                return Err(ArcsecError::OutsideSearchRadius { separation_deg });
512            }
513            Some(blind::IndexOutcome::Cancelled) => return Err(ArcsecError::Cancelled),
514            Some(blind::IndexOutcome::NotFound) | None => None,
515        };
516
517        let mut index_estimate = None;
518        let (ra, dec, search_radius) = match &self.index {
519            None => (ra_hint, dec_hint, template.search_radius),
520            _ if index_wcs.is_some() => (ra_hint, dec_hint, template.search_radius),
521            Some(_) if own_index.is_some() => {
522                log::warn!("Blind index found no verified position. Falling back to hint.");
523                (ra_hint, dec_hint, template.search_radius)
524            }
525            Some(idx_root) => {
526                let index_files = collect_index_files(idx_root, template.fov.to_degrees());
527                if index_files.is_empty() {
528                    return Err(ArcsecError::IndexNotFound(idx_root.clone()));
529                }
530                // As a fallback, the index waits for a search round the hint.
531                if fallback {
532                    let near = SolveParams {
533                        search_radius: blind::stage_one_radius(template)
534                            .min(template.search_radius),
535                        ..template.clone()
536                    };
537                    log::info!(
538                        "Searching {:.1}° round the hint before the blind index.",
539                        near.search_radius.to_degrees()
540                    );
541                    match solve_image(&img, &near) {
542                        Ok(mut wcs) => {
543                            if self.sip {
544                                wcs.sip =
545                                    crate::wcs::fit_sip(&wcs, self.image_size.0, self.image_size.1);
546                            }
547                            return Ok(Solved {
548                                wcs,
549                                index_estimate: None,
550                            });
551                        }
552                        Err(
553                            e @ (ArcsecError::Cancelled
554                            | ArcsecError::CatalogNotFound(_)
555                            | ArcsecError::CatalogIo(_)
556                            | ArcsecError::InsufficientStars { .. }),
557                        ) => return Err(e),
558                        Err(_) => {}
559                    }
560                }
561                let params = BlindSolveParams {
562                    quad_tolerance: template.quad_tolerance,
563                    hfd_min: template.hfd_min,
564                    max_stars: template.max_stars,
565                    binning: self.binning,
566                    // The blind scale filter maps this through the image height.
567                    fov_deg: self.fov_height.to_degrees(),
568                };
569                match blind::estimate_position(&img, &index_files, &params) {
570                    blind::BlindOutcome::Found(ra, dec) => {
571                        index_estimate = Some((ra, dec));
572                        on_event(Event::IndexEstimate(ra, dec));
573                        // Narrow the catalog search so the spiral checks step 0 (the
574                        // blind position) and at most a few neighbours: the blind
575                        // position is off by at most one image width, so 2× fov is a
576                        // generous ceiling.
577                        (ra, dec, (template.fov * 2.0).max(5.0_f64.to_radians()))
578                    }
579                    blind::BlindOutcome::InsufficientStars { found, required } => {
580                        return Err(ArcsecError::InsufficientStars { found, required });
581                    }
582                    blind::BlindOutcome::NotFound => {
583                        if crate::cancel::is_cancelled() {
584                            return Err(ArcsecError::Cancelled);
585                        }
586                        log::warn!(
587                            "Blind position estimate failed for all index files. Falling back to hint."
588                        );
589                        (ra_hint, dec_hint, template.search_radius)
590                    }
591                }
592            }
593        };
594
595        let mut wcs = match index_wcs {
596            Some(w) => w,
597            None => self.catalogue_solve(unbinned, &img, (ra, dec), search_radius)?,
598        };
599        if self.sip {
600            wcs.sip = crate::wcs::fit_sip(&wcs, self.image_size.0, self.image_size.1);
601        }
602        Ok(Solved {
603            wcs,
604            index_estimate,
605        })
606    }
607}
608
609impl Plan {
610    /// The catalogue search from `start` out to `radius`
611    /// ([`Self::catalogue_search`]), then, if the solution's scale is more than
612    /// [`INACCURATE_SCALE`] from the one the search used, the same field again at
613    /// the solved scale ([`Self::refine`]).
614    fn catalogue_solve(
615        &self,
616        unbinned: &ImageBuffer,
617        img: &ImageBuffer,
618        start: (f64, f64),
619        radius: f64,
620    ) -> Result<WcsSolution> {
621        let wcs = self.catalogue_search(unbinned, img, start, radius)?;
622        Ok(self.refine(unbinned, &wcs).unwrap_or(wcs))
623    }
624
625    /// `wcs` solved again at its own pixel scale, at its own centre, when that
626    /// scale is more than [`INACCURATE_SCALE`] from the scale this plan searched
627    /// with; `None` when it is not, or when the second solve does not verify, or
628    /// lands elsewhere (more than a tenth of a field away).
629    ///
630    /// The scale sets the catalogue window, its depth and the density the star
631    /// list is trimmed to, and the verification and distortion model work within
632    /// that window: a solution found at half the true scale has been checked
633    /// against the catalogue of the middle quarter of the frame. Solved again at
634    /// the right scale it is the solution a correct scale would have given (on the
635    /// benchmark, identical to it in 93 of 95 images, and never more than 0.002″
636    /// apart at the corners; without this, up to 1.7″ worse, and three near-misses
637    /// past the 5″ limit). The cost is one detection and one catalogue position,
638    /// and only when the scale was off.
639    fn refine(&self, unbinned: &ImageBuffer, wcs: &WcsSolution) -> Option<WcsSolution> {
640        let solved = (wcs.cd1_1 * wcs.cd2_2 - wcs.cd1_2 * wcs.cd2_1).abs().sqrt() * 3600.0;
641        let off = (solved / self.arcsec_per_px - 1.0).abs();
642        if off.is_nan() || off <= INACCURATE_SCALE {
643            return None;
644        }
645        let (w, h) = self.image_size;
646        let req = SolveRequest {
647            hint: Some((wcs.ra0, wcs.dec0)),
648            fov_height: None,
649            pixel_scale: Some(solved),
650            scale_search: ScaleSearch::Never,
651            search_radius: 0.0,
652            index: None,
653            auto_index: false,
654            sip: false,
655            cancel: None,
656            ..self.request.clone()
657        };
658        let p = Plan::new(&req, w, h).ok()?;
659        let img: Cow<'_, ImageBuffer> = if p.binning > 1 {
660            Cow::Owned(unbinned.bin_image(p.binning))
661        } else {
662            Cow::Borrowed(unbinned)
663        };
664        let mut r = solve_image_with(&img, &p.params, ScaleTrust::Hypothesis).ok()?;
665        let sep = crate::math::coords::ang_sep(r.ra0, r.dec0, wcs.ra0, wcs.dec0);
666        log::info!(
667            "Solved again at {solved:.3}\"/px: {} stars verified (first {}), {:.1}\" from the first solution",
668            r.stars_matched,
669            wcs.stars_matched,
670            sep.to_degrees() * 3600.0
671        );
672        if sep > 0.1 * p.params.fov {
673            return None;
674        }
675        // The search that found the field is the one to report.
676        r.search_dist_deg = wcs.search_dist_deg;
677        r.step_distances.clone_from(&wcs.step_distances);
678        Some(r)
679    }
680
681    /// The catalogue search from `start` out to `radius`, trying other pixel
682    /// scales as [`SolveRequest::scale_search`] asks.
683    ///
684    /// With the scale unknown, the ladder of [`UNKNOWN_STEPS`] round the assumed
685    /// 1″/px is searched near the hint first ([`Self::scale_ladder`]), and then the
686    /// search at 1″/px runs as it always did, out to `radius`. With a known scale
687    /// and [`ScaleSearch::AlsoIfWrong`], a search that finds nothing is followed by
688    /// the ladder of [`WRONG_STEPS`] round that scale.
689    fn catalogue_search(
690        &self,
691        unbinned: &ImageBuffer,
692        img: &ImageBuffer,
693        start: (f64, f64),
694        radius: f64,
695    ) -> Result<WcsSolution> {
696        let params = SolveParams {
697            ra_hint: start.0,
698            dec_hint: start.1,
699            search_radius: radius,
700            ..self.params.clone()
701        };
702        if !self.searches_scales() {
703            return solve_image(img, &params);
704        }
705        if !self.scale_known {
706            let (lo, hi) = UNKNOWN_STEPS;
707            let hyps = ladder(self.arcsec_per_px, lo, hi, false);
708            if let Some(w) = self.scale_ladder(unbinned, img, start, radius, &hyps)? {
709                return Ok(w);
710            }
711            log::info!(
712                "No scale solved near the hint; searching {:.1}° at {:.2}\"/px.",
713                radius.to_degrees(),
714                self.arcsec_per_px
715            );
716            return solve_image(img, &params);
717        }
718        match solve_image(img, &params) {
719            Err(e @ ArcsecError::InsufficientQuads { .. }) => {
720                let hyps = ladder(self.arcsec_per_px, -WRONG_STEPS, WRONG_STEPS, true);
721                log::info!(
722                    "No solution at {:.3}\"/px; trying other scales round the hint.",
723                    self.arcsec_per_px
724                );
725                self.scale_ladder(unbinned, img, start, radius, &hyps)?
726                    .ok_or(e)
727            }
728            r => r,
729        }
730    }
731
732    /// Search each scale hypothesis in `hyps` (most likely first) within
733    /// [`LADDER_FIELDS`] fields of `start`, at most `radius`, and return the
734    /// solution of the first that verifies.
735    ///
736    /// Hypotheses whose field no star database covers (the named one, or any
737    /// installed) to within a factor 2 are left out, and so is one
738    /// whose binned image would be too small to detect stars in. They are run on
739    /// the solve's threads, in order: the first alone with all of them, the rest
740    /// one thread each, and none starts once one has solved; the earliest that
741    /// verifies wins, so the answer does not depend on the thread count (one
742    /// after it that is still running is cancelled). Each is
743    /// a [`ScaleTrust::Hypothesis`] search: at least 30 verified stars, and no
744    /// catalogue-seeded fallback.
745    ///
746    /// The cost of a search that finds nothing is bounded by the ladder: at most
747    /// one star detection per binning and minimum star size, and nine catalogue
748    /// positions per hypothesis.
749    fn scale_ladder(
750        &self,
751        unbinned: &ImageBuffer,
752        img: &ImageBuffer,
753        start: (f64, f64),
754        radius: f64,
755        hyps: &[Hypothesis],
756    ) -> Result<Option<WcsSolution>> {
757        let (w, h) = self.image_size;
758        let naxis = w.max(h) as f64;
759        let installed = available_dbs(&self.params.db_path);
760        let covers = |fov_deg: f64| {
761            let fits = |name: &str| {
762                DB_FOV_RANGES.iter().find(|r| r.0 == name).map_or(
763                    // A database arcsec has no range for: let it try.
764                    self.request.db_name.is_some(),
765                    |&(_, lo, hi, _)| fov_deg >= lo / 2.0 && fov_deg <= hi * 2.0,
766                )
767            };
768            match &self.request.db_name {
769                Some(name) => fits(name),
770                None => installed.iter().any(|d| fits(d)),
771            }
772        };
773        let plans: Vec<(Hypothesis, Plan)> = hyps
774            .iter()
775            .filter_map(|&hy| {
776                let fov = (naxis * hy.scale / 3600.0).to_radians();
777                if !covers(fov.to_degrees()) {
778                    return None;
779                }
780                let req = SolveRequest {
781                    hint: Some(start),
782                    fov_height: None,
783                    pixel_scale: Some(hy.scale),
784                    scale_search: ScaleSearch::Never,
785                    search_radius: radius.min(LADDER_FIELDS * fov),
786                    index: None,
787                    auto_index: false,
788                    sip: false,
789                    cancel: None,
790                    ..self.request.clone()
791                };
792                let p = Plan::new(&req, w, h).ok()?;
793                let (bw, bh) = p.binned_size();
794                (bw >= MIN_SOLVE_DIM && bh >= MIN_SOLVE_DIM).then_some((hy, p))
795            })
796            .collect();
797        if plans.is_empty() {
798            return Ok(None);
799        }
800        log::info!(
801            "Trying {} pixel scales, {:.3}–{:.3}\"/px, {} field round the hint each.",
802            plans.len(),
803            plans
804                .iter()
805                .map(|(h, _)| h.scale)
806                .fold(f64::INFINITY, f64::min),
807            plans.iter().map(|(h, _)| h.scale).fold(0.0, f64::max),
808            LADDER_FIELDS
809        );
810
811        // Each binning the ladder needs, made once.
812        let mut binned: Vec<(usize, Cow<'_, ImageBuffer>)> =
813            vec![(self.binning, Cow::Borrowed(img))];
814        for (_, p) in &plans {
815            if binned.iter().all(|(b, _)| *b != p.binning) {
816                let b = if p.binning > 1 {
817                    Cow::Owned(unbinned.bin_image(p.binning))
818                } else {
819                    Cow::Borrowed(unbinned)
820                };
821                binned.push((p.binning, b));
822            }
823        }
824        let image_for = |b: usize| {
825            binned.iter().find(|(bb, _)| *bb == b).map_or_else(
826                || unreachable!("binning {b} was made above"),
827                |(_, i)| i.as_ref(),
828            )
829        };
830
831        let n_threads = if self.params.threads > 0 {
832            self.params.threads
833        } else {
834            crate::max_threads()
835        }
836        .clamp(1, 64);
837        // Stars are detected once per binning and minimum star size, and shared by
838        // the hypotheses with both: the minimum size bottoms out at 0.8 px, so all
839        // but the finest few scales at a binning share one detection. The first
840        // hypothesis detects with every thread; the rest share them out.
841        let key = |p: &Plan| (p.binning, p.params.hfd_min.to_bits());
842        let mut keys: Vec<(usize, u64)> = plans.iter().map(|(_, p)| key(p)).collect();
843        keys.sort_unstable();
844        keys.dedup();
845        let detections: Vec<std::sync::OnceLock<Detected>> =
846            keys.iter().map(|_| std::sync::OnceLock::new()).collect();
847        let detect_threads = (n_threads / keys.len()).max(1);
848
849        let cancel = crate::cancel::current();
850        // The lowest hypothesis that has solved. A later one still running can no
851        // longer win, so it is stopped at its next checkpoint rather than finished.
852        let found = alloc::sync::Arc::new(core::sync::atomic::AtomicUsize::new(usize::MAX));
853        let (_, winner) = search_in_order(plans.len(), n_threads, |i| {
854            use core::sync::atomic::Ordering::Relaxed;
855            let (hy, p) = &plans[i];
856            if crate::cancel::fired(cancel.as_ref()) || found.load(Relaxed) < i {
857                return (None, None);
858            }
859            let token = {
860                let (found, outer) = (alloc::sync::Arc::clone(&found), cancel.clone());
861                CancelToken::with_poll(move || {
862                    found.load(Relaxed) < i || crate::cancel::fired(outer.as_ref())
863                })
864            };
865            let threads = if i == 0 { n_threads } else { 1 };
866            let params = SolveParams {
867                threads,
868                ..p.params.clone()
869            };
870            log::info!(
871                "Scale hypothesis {:.3}\"/px ({:.2}° field, binning {}, star database {})",
872                hy.scale,
873                p.params.fov.to_degrees(),
874                p.binning,
875                p.params.db_name.to_uppercase()
876            );
877            let img = image_for(p.binning);
878            let k = keys
879                .binary_search(&key(p))
880                .unwrap_or_else(|_| unreachable!());
881            let solved = crate::cancel::with_token(&token, || {
882                let stars = detections[k].get_or_init(|| {
883                    let t = if i == 0 { n_threads } else { detect_threads };
884                    crate::with_max_threads(t, || detect(img, &params))
885                });
886                crate::with_max_threads(threads, || {
887                    solve_detected(img, &params, ScaleTrust::Hypothesis, stars)
888                })
889            });
890            if solved.is_ok() {
891                found.fetch_min(i, Relaxed);
892            }
893            (None, solved.ok().map(|w| (hy.scale, w)))
894        });
895        if crate::cancel::fired(cancel.as_ref()) {
896            return Err(ArcsecError::Cancelled);
897        }
898        Ok(winner.map(|(_, (scale, wcs))| {
899            log::info!("Solved at the scale hypothesis {scale:.3}\"/px.");
900            wcs
901        }))
902    }
903}
904
905#[cfg(test)]
906mod tests {
907    use super::*;
908
909    #[test]
910    fn catalog_dir_is_namespaced() {
911        // Deliberately not asserting the exact path: it is platform dependent.
912        let d = default_catalog_dir();
913        assert!(
914            d.to_string_lossy().contains("arcsec"),
915            "catalogue dir should be namespaced: {}",
916            d.display()
917        );
918    }
919
920    #[test]
921    fn env_override_wins() {
922        let env = |k: &str| match k {
923            "ARCSEC_CATALOG_DIR" => Some("/data/catalogs".to_string()),
924            "HOME" => Some("/home/u".to_string()),
925            _ => None,
926        };
927        assert_eq!(
928            default_catalog_dir_from(env),
929            PathBuf::from("/data/catalogs")
930        );
931    }
932
933    #[test]
934    fn empty_variables_count_as_unset() {
935        let env = |k: &str| match k {
936            "ARCSEC_CATALOG_DIR" | "XDG_DATA_HOME" | "LOCALAPPDATA" => Some(String::new()),
937            "HOME" => Some("/home/u".to_string()),
938            _ => None,
939        };
940        let d = default_catalog_dir_from(env);
941        assert!(d.starts_with("/home/u"), "got {}", d.display());
942        assert!(d.ends_with("catalogs"));
943    }
944
945    #[test]
946    fn no_home_at_all_still_yields_a_path() {
947        assert_eq!(
948            default_catalog_dir_from(|_| None),
949            PathBuf::from("catalogs")
950        );
951    }
952
953    #[test]
954    fn binning_is_automatic_below_one_arcsec_per_pixel() {
955        assert_eq!(choose_binning(None, 2.0, 4000, 3000), 1);
956        assert_eq!(choose_binning(Some(0), 0.5, 4000, 3000), 2);
957        assert_eq!(choose_binning(None, 0.01, 4000, 3000), 16);
958        assert_eq!(choose_binning(Some(3), 2.0, 4000, 3000), 3);
959    }
960
961    #[test]
962    fn binning_never_exceeds_the_image() {
963        assert_eq!(choose_binning(Some(100), 1.0, 4, 4), 2);
964        assert_eq!(choose_binning(Some(usize::MAX), 1.0, 4, 4), 2);
965        assert_eq!(choose_binning(None, 0.01, 10, 50), 5);
966        assert_eq!(choose_binning(Some(4), 1.0, 1, 1), 1);
967    }
968
969    #[test]
970    fn a_plan_follows_the_cli_rules() {
971        let dir = crate::test_support::TempDir::new("auto_plan");
972        let req = SolveRequest {
973            hint: Some((1.0, 0.5)),
974            fov_height: Some(1.0_f64.to_radians()),
975            search_radius: -3.0,
976            db_path: Some(dir.path().to_path_buf()),
977            ..SolveRequest::default()
978        };
979        let p = Plan::new(&req, 4000, 2000).unwrap();
980        assert_eq!(p.start, (1.0, 0.5));
981        assert!(p.has_hint && p.scale_known);
982        // Field along the longer side; scale from the height.
983        assert!((p.params.fov.to_degrees() - 2.0).abs() < 1e-12);
984        assert!((p.arcsec_per_px - 1.8).abs() < 1e-12);
985        assert!((p.fov_height.to_degrees() - 1.0).abs() < 1e-12);
986        assert_eq!(p.binning, 1);
987        assert_eq!(p.params.search_radius, 0.0, "a negative radius is zero");
988        assert_eq!(p.params.db_name, "d80", "nothing installed: the fallback");
989
990        // No scale at all: 1"/px, unknown; fine sampling bins.
991        let p = Plan::new(
992            &SolveRequest {
993                pixel_scale: Some(0.25),
994                db_path: Some(dir.path().to_path_buf()),
995                ..SolveRequest::default()
996            },
997            1000,
998            1000,
999        )
1000        .unwrap();
1001        assert_eq!(p.binning, 4);
1002        assert_eq!(p.binned_size(), (250, 250));
1003        assert_eq!(p.start, (0.0, 0.0));
1004        assert!(!p.has_hint);
1005
1006        assert!(Plan::new(&SolveRequest::default(), 0, 10).is_err());
1007        let bad = SolveRequest {
1008            pixel_scale: Some(f64::NAN),
1009            ..SolveRequest::default()
1010        };
1011        assert!(Plan::new(&bad, 10, 10).is_err());
1012    }
1013
1014    #[test]
1015    fn a_plan_refuses_an_image_of_another_size() {
1016        let dir = crate::test_support::TempDir::new("auto_size");
1017        let req = SolveRequest {
1018            db_path: Some(dir.path().to_path_buf()),
1019            ..SolveRequest::default()
1020        };
1021        let p = Plan::new(&req, 100, 80).unwrap();
1022        let err = p.solve(&ImageBuffer::new(80, 100)).unwrap_err();
1023        assert!(matches!(err, ArcsecError::InvalidParameter(_)), "{err}");
1024    }
1025
1026    #[test]
1027    fn index_files_lists_only_arcsec_indexes() {
1028        let dir = crate::test_support::TempDir::new("auto_ix");
1029        for name in [
1030            "b.arcsecix",
1031            "a.arcsecix",
1032            "index-4107.fits",
1033            "d50_0101.1476",
1034        ] {
1035            std::fs::write(dir.path().join(name), b"x").unwrap();
1036        }
1037        let names: Vec<_> = index_files(dir.path())
1038            .iter()
1039            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
1040            .collect();
1041        assert_eq!(names, ["a.arcsecix", "b.arcsecix"]);
1042        assert!(index_files(Path::new("/nonexistent/arcsec")).is_empty());
1043        assert!(has_star_database(dir.path()));
1044        assert_eq!(available_dbs(dir.path()), ["d50"]);
1045    }
1046}