Skip to main content

arcsec_core/auto/
db.rs

1//! Choosing a star database: which directory (`-d`) and which database (`-D`)
2//! when the user does not say.
3
4use std::fs;
5use std::path::{Path, PathBuf};
6
7/// File extensions of the star database formats, with the number of sky cells
8/// each layout has: `.1476` and `.290` tile the sky, `.001` is one all-sky file.
9pub const ASTAP_EXTS: &[(&str, usize)] = &[(".1476", 1476), (".290", 290), (".001", 1)];
10
11/// The star database directory to use when none is named.
12///
13/// The directory `arcsec catalog install` writes to ([`super::default_catalog_dir`]),
14/// so a user who installed a catalogue never has to say where it went — provided a
15/// star database is actually there. Otherwise the working directory, which is
16/// ASTAP's behaviour. Blind indexes do not count: a managed directory holding only
17/// `anet-4100` would otherwise hide the ASTAP databases the user keeps in the
18/// working directory.
19#[must_use]
20pub fn default_db_path() -> PathBuf {
21    let managed = super::default_catalog_dir();
22    if has_star_database(&managed) {
23        managed
24    } else {
25        PathBuf::from(".")
26    }
27}
28
29/// Whether `dir` holds any of the star databases arcsec knows (D05 … W08), judged by
30/// the file each ships for its first cell (`d50_0101.1476`, `w08_0101.001`, …).
31#[must_use]
32pub fn has_star_database(dir: &Path) -> bool {
33    DB_FOV_RANGES.iter().any(|(prefix, ..)| {
34        ASTAP_EXTS
35            .iter()
36            .any(|(ext, _)| dir.join(format!("{prefix}_0101{ext}")).exists())
37    })
38}
39
40/// Field-of-view range each ASTAP database is built for, and how much we prefer it
41/// when several are eligible (higher = denser, so a better fit).
42///
43/// Ranges are as published on the ASTAP download page. The D-series stops at 6°; G05
44/// and W08 exist precisely to cover wider fields, and before `.290`/`.001` support
45/// they could not be read at all, which is why fields beyond ~6° never solved.
46pub const DB_FOV_RANGES: &[(&str, f64, f64, u8)] = &[
47    // prefix, min FOV (deg), max FOV (deg), preference
48    ("d80", 0.15, 6.0, 8),
49    ("v50", 0.20, 6.0, 6),
50    ("d50", 0.20, 6.0, 5),
51    ("d20", 0.30, 6.0, 4),
52    ("v05", 0.60, 6.0, 3),
53    ("d05", 0.60, 6.0, 2),
54    ("g05", 3.00, 20.0, 7),
55    ("w08", 20.0, 80.0, 7),
56];
57
58/// Database prefix of a star database file name, if it is one.
59///
60/// Every supported format is `<prefix>_<cell>.<ext>`; see [`ASTAP_EXTS`].
61fn db_prefix(file_name: &str) -> Option<&str> {
62    if ASTAP_EXTS.iter().any(|(ext, _)| file_name.ends_with(ext)) {
63        file_name.split('_').next()
64    } else {
65        None
66    }
67}
68
69/// Every database prefix present in `db_path`, in any supported format, sorted.
70#[must_use]
71pub fn available_dbs(db_path: &Path) -> Vec<String> {
72    let mut out: Vec<String> = fs::read_dir(db_path)
73        .into_iter()
74        .flatten()
75        .flatten()
76        .filter_map(|e| db_prefix(&e.file_name().to_string_lossy()).map(str::to_string))
77        .collect();
78    out.sort();
79    out.dedup();
80    out
81}
82
83/// Pick the installed database best suited to a `fov_deg` field (the longer side,
84/// degrees): the densest whose published range contains it, else the nearest.
85/// `None` when `db_path` holds no star database at all.
86#[must_use]
87pub fn select_db_for_fov(db_path: &Path, fov_deg: f64) -> Option<String> {
88    select_from(&available_dbs(db_path), fov_deg)
89}
90
91/// Pick from the databases in `present` for a `fov_deg` field.
92///
93/// Prefers the densest database whose published range contains the field, then any
94/// database whose range merely comes closest — so an unusual field size still gets
95/// the least-bad option rather than nothing.
96fn select_from(present: &[String], fov_deg: f64) -> Option<String> {
97    if present.is_empty() {
98        return None;
99    }
100    let installed = DB_FOV_RANGES
101        .iter()
102        .filter(|(prefix, ..)| present.iter().any(|p| p == prefix));
103
104    let mut best: Option<(u8, &str)> = None;
105    for &(prefix, lo, hi, pref) in installed.clone() {
106        if fov_deg >= lo && fov_deg <= hi && best.is_none_or(|(bp, _)| pref > bp) {
107            best = Some((pref, prefix));
108        }
109    }
110    if let Some((_, prefix)) = best {
111        return Some(prefix.to_string());
112    }
113
114    // Nothing covers this field: take the database whose range is nearest.
115    let mut fallback: Option<(f64, &str)> = None;
116    for &(prefix, lo, hi, _) in installed {
117        let dist = if fov_deg < lo {
118            lo - fov_deg
119        } else {
120            fov_deg - hi
121        };
122        if fallback.is_none_or(|(bd, _)| dist < bd) {
123            fallback = Some((dist, prefix));
124        }
125    }
126    fallback
127        .map(|(_, p)| p.to_string())
128        .or_else(|| present.first().cloned())
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    fn owned(v: &[&str]) -> Vec<String> {
136        v.iter().map(|s| (*s).to_string()).collect()
137    }
138
139    #[test]
140    fn prefixes_come_from_every_database_format() {
141        assert_eq!(db_prefix("d50_0101.1476"), Some("d50"));
142        assert_eq!(db_prefix("g05_0101.290"), Some("g05"));
143        assert_eq!(db_prefix("w08_0101.001"), Some("w08"));
144        assert_eq!(db_prefix("index-4107.fits"), None);
145        assert_eq!(db_prefix("readme.txt"), None);
146    }
147
148    #[test]
149    fn the_densest_covering_database_wins() {
150        let all = owned(&["d05", "d50", "d80", "g05", "w08"]);
151        assert_eq!(select_from(&all, 1.0).as_deref(), Some("d80"));
152        assert_eq!(select_from(&all, 10.0).as_deref(), Some("g05"));
153        assert_eq!(select_from(&all, 40.0).as_deref(), Some("w08"));
154        // 3°–6° is covered by both the D-series and G05; D80 is denser.
155        assert_eq!(select_from(&all, 4.0).as_deref(), Some("d80"));
156    }
157
158    #[test]
159    fn an_uncovered_field_gets_the_nearest_range() {
160        let d = owned(&["d50", "g05"]);
161        assert_eq!(select_from(&d, 0.05).as_deref(), Some("d50"));
162        assert_eq!(select_from(&d, 30.0).as_deref(), Some("g05"));
163    }
164
165    #[test]
166    fn unknown_prefixes_are_a_last_resort() {
167        assert_eq!(select_from(&owned(&["h18"]), 1.0).as_deref(), Some("h18"));
168        assert_eq!(select_from(&[], 1.0), None);
169    }
170}