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//! - when to use a blind index, and how ([`find_arcsec_index`],
14//!   [`collect_index_files`]);
15//! - and the whole solve built from those decisions: a [`SolveRequest`] becomes a
16//!   [`Plan`], and [`Plan::solve`] runs it.
17//!
18//! ```no_run
19//! use arcsec_core::auto::{Plan, SolveRequest};
20//! # fn load() -> arcsec_core::ImageBuffer { arcsec_core::ImageBuffer::new(4096, 3072) }
21//! let mut img = load();
22//! img.normalize_for_detection();
23//! let request = SolveRequest {
24//!     hint: Some((83.82_f64.to_radians(), (-5.39_f64).to_radians())),
25//!     pixel_scale: Some(1.1),
26//!     search_radius: 10.0_f64.to_radians(),
27//!     ..SolveRequest::default()
28//! };
29//! let plan = Plan::new(&request, img.width, img.height)?;
30//! let solved = plan.solve(&img)?;
31//! println!("RA {:.4}°", solved.wcs.ra0.to_degrees());
32//! # Ok::<(), arcsec_core::ArcsecError>(())
33//! ```
34
35mod blind;
36mod db;
37
38use alloc::borrow::Cow;
39use core::f64::consts::PI;
40use std::path::{Path, PathBuf};
41
42pub use blind::{
43    SOURCES, collect_index_files, depth_rank, find_arcsec_index, preferred_index,
44    wants_installed_index,
45};
46pub use db::{
47    ASTAP_EXTS, DB_FOV_RANGES, available_dbs, default_db_path, has_star_database, select_db_for_fov,
48};
49
50use crate::cancel::CancelToken;
51use crate::error::{ArcsecError, Result};
52use crate::pipeline::{BlindSolveParams, SearchSpeed, SolveMethod, SolveParams, solve_image};
53use crate::types::{ImageBuffer, WcsSolution};
54
55// ── Where things live ───────────────────────────────────────────────────────────
56
57/// Where catalogues are kept, in priority order:
58///
59/// 1. `$ARCSEC_CATALOG_DIR`, if set — for people who keep them on another disk.
60/// 2. `$XDG_DATA_HOME/arcsec/catalogs` on Linux, or the platform equivalent:
61///    `~/Library/Application Support/arcsec/catalogs` on macOS,
62///    `%LOCALAPPDATA%\arcsec\catalogs` on Windows.
63/// 3. `~/.arcsec/catalogs` if the home directory cannot be resolved any other way.
64///
65/// The point is that a user who runs `arcsec catalog install d50` never has to know
66/// this path, and the solver looks here without being told.
67#[must_use]
68pub fn default_catalog_dir() -> PathBuf {
69    default_catalog_dir_from(|k| std::env::var(k).ok())
70}
71
72/// [`default_catalog_dir`] with the environment supplied by `var`, so it can be
73/// tested without mutating the real process environment.
74fn default_catalog_dir_from(var: impl Fn(&str) -> Option<String>) -> PathBuf {
75    let get = |k: &str| var(k).filter(|v| !v.is_empty()).map(PathBuf::from);
76
77    if let Some(p) = get("ARCSEC_CATALOG_DIR") {
78        return p;
79    }
80
81    let platform = if cfg!(target_os = "windows") {
82        get("LOCALAPPDATA").map(|p| p.join("arcsec").join("catalogs"))
83    } else if cfg!(target_os = "macos") {
84        get("HOME").map(|h| {
85            h.join("Library")
86                .join("Application Support")
87                .join("arcsec")
88                .join("catalogs")
89        })
90    } else {
91        get("XDG_DATA_HOME")
92            .map(|p| p.join("arcsec").join("catalogs"))
93            .or_else(|| {
94                get("HOME").map(|h| {
95                    h.join(".local")
96                        .join("share")
97                        .join("arcsec")
98                        .join("catalogs")
99                })
100            })
101    };
102
103    platform.unwrap_or_else(|| {
104        get("HOME").or_else(|| get("USERPROFILE")).map_or_else(
105            || PathBuf::from("catalogs"),
106            |h| h.join(".arcsec").join("catalogs"),
107        )
108    })
109}
110
111/// Every arcsec blind index (`*.arcsecix`) directly in `dir`, sorted by name.
112/// Empty when there are none or `dir` cannot be read.
113#[must_use]
114pub fn index_files(dir: &Path) -> Vec<PathBuf> {
115    let mut v: Vec<PathBuf> = std::fs::read_dir(dir)
116        .map(|rd| {
117            rd.filter_map(core::result::Result::ok)
118                .map(|e| e.path())
119                .filter(|p| {
120                    p.extension()
121                        .is_some_and(|e| e == crate::index::format::EXTENSION)
122                })
123                .collect()
124        })
125        .unwrap_or_default();
126    v.sort();
127    v
128}
129
130// ── Binning ─────────────────────────────────────────────────────────────────────
131
132/// Smallest image side, in (binned) pixels, that reaches the solver. Detection
133/// cannot run on a one-pixel-wide image, and would otherwise panic on it.
134pub const MIN_SOLVE_DIM: usize = 2;
135
136/// The binning factor: `requested` if given and non-zero, else automatic.
137///
138/// Automatic binning brings a sampling finer than 1"/px back to about 1"/px, up to
139/// 16×. Either way the factor is capped so the binned image keeps at least
140/// [`MIN_SOLVE_DIM`] pixels a side: binning past the image size leaves nothing to
141/// detect in, and used to crash.
142#[must_use]
143pub fn choose_binning(
144    requested: Option<usize>,
145    arcsec_per_px: f64,
146    width: usize,
147    height: usize,
148) -> usize {
149    let binning = match requested {
150        Some(0) | None => {
151            if arcsec_per_px < 1.0 {
152                (1.0 / arcsec_per_px).round().clamp(1.0, 16.0) as usize
153            } else {
154                1
155            }
156        }
157        Some(z) => z,
158    };
159    binning.min((width.min(height) / MIN_SOLVE_DIM).max(1))
160}
161
162// ── The request and its plan ────────────────────────────────────────────────────
163
164/// What the caller knows about an image and how it wants it solved, in the
165/// caller's terms. Every field has the CLI's default ([`SolveRequest::default`]).
166#[derive(Debug, Clone)]
167pub struct SolveRequest {
168    /// Approximate centre (RA, Dec), radians. `None` for no hint: the search
169    /// starts at (0, 0), and an installed blind index is used as for any wide
170    /// search.
171    pub hint: Option<(f64, f64)>,
172    /// Field of view along the image *height*, radians (ASTAP's `-fov`). Takes
173    /// precedence over [`Self::pixel_scale`].
174    pub fov_height: Option<f64>,
175    /// Pixel scale of the unbinned image, arcseconds per pixel (for instance from
176    /// the FOCALLEN and XPIXSZ header keywords). Without it or a field of view, 1″/px
177    /// is assumed.
178    pub pixel_scale: Option<f64>,
179    /// Search radius around the hint, radians. Negative or NaN means 0.
180    pub search_radius: f64,
181    /// Binning factor; `None` or `Some(0)` chooses one ([`choose_binning`]).
182    pub downsample: Option<usize>,
183    /// Star database directory; `None` for [`default_db_path`].
184    pub db_path: Option<PathBuf>,
185    /// Star database name (`"d50"`); `None` to choose by field size
186    /// ([`select_db_for_fov`]).
187    pub db_name: Option<String>,
188    /// Blind index to use (the CLI's `--index`): an arcsec `.arcsecix` file, a
189    /// directory holding one, or Astrometry.net `index-*.fits` files. `None` still
190    /// uses an arcsec index installed in the catalogue directory for a wide search,
191    /// unless [`Self::auto_index`] is off.
192    pub index: Option<PathBuf>,
193    /// With a hint, whether the index named in [`Self::index`] is tried before
194    /// the search round the hint (the CLI's `--index`: true, the default), or only
195    /// after the spiral has searched a few fields round it (false), as an
196    /// installed index is. Without a hint the index always comes first.
197    pub index_first: bool,
198    /// Consult an arcsec index installed in the catalogue directory (or beside the
199    /// star database) when the search is wider than a few fields and at least 10°.
200    /// On by default, as in the CLI.
201    pub auto_index: bool,
202    /// Minimum star size (HFD), arcseconds.
203    pub hfd_min_arcsec: f64,
204    /// Pattern-matching tolerance.
205    pub quad_tolerance: f64,
206    /// Maximum number of image stars to use.
207    pub max_stars: usize,
208    /// Pattern-matching algorithm.
209    pub method: SolveMethod,
210    /// Catalogue window per spiral position.
211    pub speed: SearchSpeed,
212    /// Worker threads for this solve; 0 for the process-wide limit
213    /// ([`crate::max_threads`]).
214    pub threads: usize,
215    /// Fit SIP distortion polynomials to the solution ([`crate::wcs::fit_sip`]).
216    pub sip: bool,
217    /// Stop early when this token is cancelled ([`ArcsecError::Cancelled`]).
218    pub cancel: Option<CancelToken>,
219}
220
221impl Default for SolveRequest {
222    /// The CLI's defaults: no hint, a 180° radius (the whole sky), 500 stars,
223    /// tolerance 0.007, minimum HFD 1.5″, automatic binning and database.
224    fn default() -> Self {
225        Self {
226            hint: None,
227            fov_height: None,
228            pixel_scale: None,
229            search_radius: PI,
230            downsample: None,
231            db_path: None,
232            db_name: None,
233            index: None,
234            index_first: true,
235            auto_index: true,
236            hfd_min_arcsec: 1.5,
237            quad_tolerance: 0.007,
238            max_stars: 500,
239            method: SolveMethod::Quads,
240            speed: SearchSpeed::Auto,
241            threads: 0,
242            sip: false,
243            cancel: None,
244        }
245    }
246}
247
248/// A [`SolveRequest`] with every decision made, for an image of a given size.
249///
250/// Built by [`Plan::new`] without touching the pixels, so a caller can report what
251/// will happen (the CLI prints it, as ASTAP does) before [`Plan::solve`] runs.
252#[derive(Debug, Clone)]
253pub struct Plan {
254    /// Start of the search (RA, Dec), radians: the hint, or (0, 0).
255    pub start: (f64, f64),
256    /// Whether the request had a hint.
257    pub has_hint: bool,
258    /// Pixel scale of the unbinned image, arcseconds per pixel.
259    pub arcsec_per_px: f64,
260    /// Whether the scale came from the request rather than the 1″/px fallback.
261    pub scale_known: bool,
262    /// Field of view along the image height, radians.
263    pub fov_height: f64,
264    /// Binning factor the image is solved at.
265    pub binning: usize,
266    /// Unbinned image size, pixels.
267    pub image_size: (usize, usize),
268    /// Minimum star size, arcseconds (as requested).
269    pub hfd_min_arcsec: f64,
270    /// The catalogue solve's parameters: the start, the field along the longer
271    /// side, the database, and the minimum HFD in binned pixels.
272    pub params: SolveParams,
273    index: Option<PathBuf>,
274    index_first: bool,
275    auto_index: bool,
276    sip: bool,
277    cancel: Option<CancelToken>,
278}
279
280/// Something [`Plan::solve_with`] reports while it runs.
281#[derive(Debug, Clone, Copy, PartialEq)]
282#[non_exhaustive]
283pub enum Event {
284    /// The Astrometry.net blind solver estimated the position (RA, Dec), radians;
285    /// the catalogue search starts there.
286    IndexEstimate(f64, f64),
287}
288
289/// A solution from [`Plan::solve`].
290#[derive(Debug, Clone)]
291pub struct Solved {
292    /// The WCS, on the unbinned image's pixel grid; SIP included if requested
293    /// and the fit was worthwhile.
294    pub wcs: WcsSolution,
295    /// The Astrometry.net blind solver's position estimate, if one was used.
296    pub index_estimate: Option<(f64, f64)>,
297}
298
299impl Plan {
300    /// Make every decision for a `width` × `height` image (unbinned).
301    ///
302    /// # Errors
303    ///
304    /// [`ArcsecError::InvalidParameter`] for an empty image or a field of view or
305    /// pixel scale that is not positive and finite.
306    pub fn new(req: &SolveRequest, width: usize, height: usize) -> Result<Self> {
307        if width == 0 || height == 0 {
308            return Err(ArcsecError::InvalidParameter(format!(
309                "image is {width}×{height} pixels"
310            )));
311        }
312        let bad = |v: f64| !(v.is_finite() && v > 0.0);
313        if req.fov_height.is_some_and(bad) || req.pixel_scale.is_some_and(bad) {
314            return Err(ArcsecError::InvalidParameter(
315                "field of view and pixel scale must be positive".into(),
316            ));
317        }
318        // Priority: an explicit field of view > header optics > 1"/px fallback.
319        // `fov` (the field along the longer side) is what database selection and
320        // the search window use.
321        let naxis = width.max(height) as f64;
322        let h = height as f64;
323        let (arcsec_per_px, scale_known) = match (req.fov_height, req.pixel_scale) {
324            (Some(fov_h), _) => (fov_h.to_degrees() * 3600.0 / h, true),
325            (None, Some(ps)) => (ps, true),
326            (None, None) => (1.0, false),
327        };
328        let fov = match req.fov_height {
329            Some(fov_h) => fov_h * (naxis / h),
330            None => (naxis * arcsec_per_px / 3600.0).to_radians(),
331        };
332        let binning = choose_binning(req.downsample, arcsec_per_px, width, height);
333
334        let db_path = req.db_path.clone().unwrap_or_else(default_db_path);
335        // Resolved from the field size: the D-series covers 0.15°–6°, G05 3°–20°
336        // and W08 20°–80°.
337        let db_name = req.db_name.clone().unwrap_or_else(|| {
338            select_db_for_fov(&db_path, fov.to_degrees()).unwrap_or_else(|| "d80".to_string())
339        });
340        let hfd_min = (req.hfd_min_arcsec / (binning as f64 * arcsec_per_px)).max(0.8);
341        let start = req.hint.unwrap_or((0.0, 0.0));
342        Ok(Self {
343            start,
344            has_hint: req.hint.is_some(),
345            arcsec_per_px,
346            scale_known,
347            fov_height: fov * (h / naxis),
348            binning,
349            image_size: (width, height),
350            hfd_min_arcsec: req.hfd_min_arcsec,
351            params: SolveParams {
352                ra_hint: start.0,
353                dec_hint: start.1,
354                fov,
355                // ASTAP treats a negative (or NaN) radius as zero and solves at the
356                // start position; `max` maps NaN to 0.
357                search_radius: req.search_radius.max(0.0),
358                quad_tolerance: req.quad_tolerance,
359                hfd_min,
360                max_stars: req.max_stars,
361                db_path,
362                db_name,
363                binning,
364                method: req.method,
365                speed: req.speed,
366                threads: req.threads,
367            },
368            index: req.index.clone(),
369            index_first: req.index_first,
370            auto_index: req.auto_index,
371            sip: req.sip,
372            cancel: req.cancel.clone(),
373        })
374    }
375
376    /// Size of the image as solved, after binning.
377    #[must_use]
378    pub fn binned_size(&self) -> (usize, usize) {
379        let b = self.binning.max(1);
380        (self.image_size.0 / b, self.image_size.1 / b)
381    }
382
383    /// Solve `img`, the unbinned image this plan was made for, with its pixels
384    /// already normalised ([`ImageBuffer::normalize_for_detection`]).
385    ///
386    /// # Errors
387    ///
388    /// Everything [`solve_image`] returns, and:
389    /// - [`ArcsecError::InvalidParameter`] if `img` is not the size planned for;
390    /// - [`ArcsecError::InsufficientStars`] if the binned image is too small;
391    /// - [`ArcsecError::IndexNotFound`] if [`SolveRequest::index`] names nothing;
392    /// - [`ArcsecError::OutsideSearchRadius`] if an index found the field beyond
393    ///   the search radius;
394    /// - [`ArcsecError::Cancelled`] if the request's token fired.
395    pub fn solve(&self, img: &ImageBuffer) -> Result<Solved> {
396        self.solve_with(img, |_| {})
397    }
398
399    /// [`Plan::solve`], reporting [`Event`]s to `on_event` as they happen.
400    ///
401    /// # Errors
402    ///
403    /// As [`Plan::solve`].
404    pub fn solve_with(&self, img: &ImageBuffer, on_event: impl FnMut(Event)) -> Result<Solved> {
405        if (img.width, img.height) != self.image_size || img.data.len() != img.width * img.height {
406            return Err(ArcsecError::InvalidParameter(format!(
407                "image is {}×{}, planned for {}×{}",
408                img.width, img.height, self.image_size.0, self.image_size.1
409            )));
410        }
411        crate::cancel::with_optional(self.cancel.as_ref(), || {
412            crate::with_max_threads(self.params.threads, || self.run(img, on_event))
413        })
414    }
415
416    fn run(&self, img: &ImageBuffer, mut on_event: impl FnMut(Event)) -> Result<Solved> {
417        let (bw, bh) = self.binned_size();
418        if bw < MIN_SOLVE_DIM || bh < MIN_SOLVE_DIM {
419            return Err(ArcsecError::InsufficientStars {
420                found: 0,
421                required: 5,
422            });
423        }
424        let img: Cow<'_, ImageBuffer> = if self.binning > 1 {
425            log::info!(
426                "Creating grayscale x {0} binning image for solving/star alignment.",
427                self.binning
428            );
429            Cow::Owned(img.bin_image(self.binning))
430        } else {
431            Cow::Borrowed(img)
432        };
433        let template = &self.params;
434        let (ra_hint, dec_hint) = self.start;
435
436        // arcsec's own blind index, named by `index` or, for a search wider than a
437        // few fields, found in the catalogue directory: it finds the field and the
438        // hinted solver accepts it (see blind::index_stage). With Astrometry.net
439        // files, the blind solver estimates the position first, and that estimate
440        // becomes the hint for the catalogue spiral solver.
441        // A hint, and an index named only as a fallback: the index waits until the
442        // spiral has searched round the hint, exactly as an installed one does.
443        let fallback = self.has_hint && !self.index_first;
444        let own_index = blind::arcsec_index_for(self.index.as_ref(), template, self.auto_index)
445            .map(|mut ix| {
446                ix.explicit &= !fallback;
447                ix
448            });
449        let index_wcs = match own_index.as_ref().map(|ix| {
450            blind::index_stage(
451                &img,
452                ix,
453                template,
454                self.has_hint,
455                self.arcsec_per_px * self.binning as f64,
456                self.scale_known,
457            )
458        }) {
459            Some(blind::IndexOutcome::Solved(w)) => Some(*w),
460            Some(blind::IndexOutcome::Elsewhere(separation_deg)) => {
461                return Err(ArcsecError::OutsideSearchRadius { separation_deg });
462            }
463            Some(blind::IndexOutcome::Cancelled) => return Err(ArcsecError::Cancelled),
464            Some(blind::IndexOutcome::NotFound) | None => None,
465        };
466
467        let mut index_estimate = None;
468        let (ra, dec, search_radius) = match &self.index {
469            None => (ra_hint, dec_hint, template.search_radius),
470            _ if index_wcs.is_some() => (ra_hint, dec_hint, template.search_radius),
471            Some(_) if own_index.is_some() => {
472                log::warn!("Blind index found no verified position. Falling back to hint.");
473                (ra_hint, dec_hint, template.search_radius)
474            }
475            Some(idx_root) => {
476                let index_files = collect_index_files(idx_root, template.fov.to_degrees());
477                if index_files.is_empty() {
478                    return Err(ArcsecError::IndexNotFound(idx_root.clone()));
479                }
480                // As a fallback, the index waits for a search round the hint.
481                if fallback {
482                    let near = SolveParams {
483                        search_radius: blind::stage_one_radius(template)
484                            .min(template.search_radius),
485                        ..template.clone()
486                    };
487                    log::info!(
488                        "Searching {:.1}° round the hint before the blind index.",
489                        near.search_radius.to_degrees()
490                    );
491                    match solve_image(&img, &near) {
492                        Ok(mut wcs) => {
493                            if self.sip {
494                                wcs.sip =
495                                    crate::wcs::fit_sip(&wcs, self.image_size.0, self.image_size.1);
496                            }
497                            return Ok(Solved {
498                                wcs,
499                                index_estimate: None,
500                            });
501                        }
502                        Err(
503                            e @ (ArcsecError::Cancelled
504                            | ArcsecError::CatalogNotFound(_)
505                            | ArcsecError::CatalogIo(_)
506                            | ArcsecError::InsufficientStars { .. }),
507                        ) => return Err(e),
508                        Err(_) => {}
509                    }
510                }
511                let params = BlindSolveParams {
512                    quad_tolerance: template.quad_tolerance,
513                    hfd_min: template.hfd_min,
514                    max_stars: template.max_stars,
515                    binning: self.binning,
516                    // The blind scale filter maps this through the image height.
517                    fov_deg: self.fov_height.to_degrees(),
518                };
519                match blind::estimate_position(&img, &index_files, &params) {
520                    blind::BlindOutcome::Found(ra, dec) => {
521                        index_estimate = Some((ra, dec));
522                        on_event(Event::IndexEstimate(ra, dec));
523                        // Narrow the catalog search so the spiral checks step 0 (the
524                        // blind position) and at most a few neighbours: the blind
525                        // position is off by at most one image width, so 2× fov is a
526                        // generous ceiling.
527                        (ra, dec, (template.fov * 2.0).max(5.0_f64.to_radians()))
528                    }
529                    blind::BlindOutcome::InsufficientStars { found, required } => {
530                        return Err(ArcsecError::InsufficientStars { found, required });
531                    }
532                    blind::BlindOutcome::NotFound => {
533                        if crate::cancel::is_cancelled() {
534                            return Err(ArcsecError::Cancelled);
535                        }
536                        log::warn!(
537                            "Blind position estimate failed for all index files. Falling back to hint."
538                        );
539                        (ra_hint, dec_hint, template.search_radius)
540                    }
541                }
542            }
543        };
544
545        let mut wcs = match index_wcs {
546            Some(w) => w,
547            None => solve_image(
548                &img,
549                &SolveParams {
550                    ra_hint: ra,
551                    dec_hint: dec,
552                    search_radius,
553                    ..template.clone()
554                },
555            )?,
556        };
557        if self.sip {
558            wcs.sip = crate::wcs::fit_sip(&wcs, self.image_size.0, self.image_size.1);
559        }
560        Ok(Solved {
561            wcs,
562            index_estimate,
563        })
564    }
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570
571    #[test]
572    fn catalog_dir_is_namespaced() {
573        // Deliberately not asserting the exact path: it is platform dependent.
574        let d = default_catalog_dir();
575        assert!(
576            d.to_string_lossy().contains("arcsec"),
577            "catalogue dir should be namespaced: {}",
578            d.display()
579        );
580    }
581
582    #[test]
583    fn env_override_wins() {
584        let env = |k: &str| match k {
585            "ARCSEC_CATALOG_DIR" => Some("/data/catalogs".to_string()),
586            "HOME" => Some("/home/u".to_string()),
587            _ => None,
588        };
589        assert_eq!(
590            default_catalog_dir_from(env),
591            PathBuf::from("/data/catalogs")
592        );
593    }
594
595    #[test]
596    fn empty_variables_count_as_unset() {
597        let env = |k: &str| match k {
598            "ARCSEC_CATALOG_DIR" | "XDG_DATA_HOME" | "LOCALAPPDATA" => Some(String::new()),
599            "HOME" => Some("/home/u".to_string()),
600            _ => None,
601        };
602        let d = default_catalog_dir_from(env);
603        assert!(d.starts_with("/home/u"), "got {}", d.display());
604        assert!(d.ends_with("catalogs"));
605    }
606
607    #[test]
608    fn no_home_at_all_still_yields_a_path() {
609        assert_eq!(
610            default_catalog_dir_from(|_| None),
611            PathBuf::from("catalogs")
612        );
613    }
614
615    #[test]
616    fn binning_is_automatic_below_one_arcsec_per_pixel() {
617        assert_eq!(choose_binning(None, 2.0, 4000, 3000), 1);
618        assert_eq!(choose_binning(Some(0), 0.5, 4000, 3000), 2);
619        assert_eq!(choose_binning(None, 0.01, 4000, 3000), 16);
620        assert_eq!(choose_binning(Some(3), 2.0, 4000, 3000), 3);
621    }
622
623    #[test]
624    fn binning_never_exceeds_the_image() {
625        assert_eq!(choose_binning(Some(100), 1.0, 4, 4), 2);
626        assert_eq!(choose_binning(Some(usize::MAX), 1.0, 4, 4), 2);
627        assert_eq!(choose_binning(None, 0.01, 10, 50), 5);
628        assert_eq!(choose_binning(Some(4), 1.0, 1, 1), 1);
629    }
630
631    #[test]
632    fn a_plan_follows_the_cli_rules() {
633        let dir = crate::test_support::TempDir::new("auto_plan");
634        let req = SolveRequest {
635            hint: Some((1.0, 0.5)),
636            fov_height: Some(1.0_f64.to_radians()),
637            search_radius: -3.0,
638            db_path: Some(dir.path().to_path_buf()),
639            ..SolveRequest::default()
640        };
641        let p = Plan::new(&req, 4000, 2000).unwrap();
642        assert_eq!(p.start, (1.0, 0.5));
643        assert!(p.has_hint && p.scale_known);
644        // Field along the longer side; scale from the height.
645        assert!((p.params.fov.to_degrees() - 2.0).abs() < 1e-12);
646        assert!((p.arcsec_per_px - 1.8).abs() < 1e-12);
647        assert!((p.fov_height.to_degrees() - 1.0).abs() < 1e-12);
648        assert_eq!(p.binning, 1);
649        assert_eq!(p.params.search_radius, 0.0, "a negative radius is zero");
650        assert_eq!(p.params.db_name, "d80", "nothing installed: the fallback");
651
652        // No scale at all: 1"/px, unknown; fine sampling bins.
653        let p = Plan::new(
654            &SolveRequest {
655                pixel_scale: Some(0.25),
656                db_path: Some(dir.path().to_path_buf()),
657                ..SolveRequest::default()
658            },
659            1000,
660            1000,
661        )
662        .unwrap();
663        assert_eq!(p.binning, 4);
664        assert_eq!(p.binned_size(), (250, 250));
665        assert_eq!(p.start, (0.0, 0.0));
666        assert!(!p.has_hint);
667
668        assert!(Plan::new(&SolveRequest::default(), 0, 10).is_err());
669        let bad = SolveRequest {
670            pixel_scale: Some(f64::NAN),
671            ..SolveRequest::default()
672        };
673        assert!(Plan::new(&bad, 10, 10).is_err());
674    }
675
676    #[test]
677    fn a_plan_refuses_an_image_of_another_size() {
678        let dir = crate::test_support::TempDir::new("auto_size");
679        let req = SolveRequest {
680            db_path: Some(dir.path().to_path_buf()),
681            ..SolveRequest::default()
682        };
683        let p = Plan::new(&req, 100, 80).unwrap();
684        let err = p.solve(&ImageBuffer::new(80, 100)).unwrap_err();
685        assert!(matches!(err, ArcsecError::InvalidParameter(_)), "{err}");
686    }
687
688    #[test]
689    fn index_files_lists_only_arcsec_indexes() {
690        let dir = crate::test_support::TempDir::new("auto_ix");
691        for name in [
692            "b.arcsecix",
693            "a.arcsecix",
694            "index-4107.fits",
695            "d50_0101.1476",
696        ] {
697            std::fs::write(dir.path().join(name), b"x").unwrap();
698        }
699        let names: Vec<_> = index_files(dir.path())
700            .iter()
701            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
702            .collect();
703        assert_eq!(names, ["a.arcsecix", "b.arcsecix"]);
704        assert!(index_files(Path::new("/nonexistent/arcsec")).is_empty());
705        assert!(has_star_database(dir.path()));
706        assert_eq!(available_dbs(dir.path()), ["d50"]);
707    }
708}