Skip to main content

arcsec_core/index/
build.rs

1//! Building a blind index from an installed ASTAP star database.
2//!
3//! **Disc-anchored patterns.** For each tier — a disc radius `r` and a magnitude
4//! cap — a star *anchors* patterns only if it is the brightest star within `r` of
5//! itself (among stars no fainter than the cap). Its group is itself plus the next
6//! brightest stars of its disc, and every 4-subset of the group is a pattern. An
7//! image wide enough to contain the disc sees the same locally-brightest stars, so
8//! it can rebuild the same group without knowing where it is; the 4-subsets give
9//! redundancy against one member going undetected or ranking differently in the
10//! image. The idea comes from seiza's blind index (Apache-2.0); this is an
11//! independent implementation over ASTAP's databases.
12//!
13//! No two anchors share a pattern: a pattern contains its anchor, and an anchor
14//! cannot be inside another anchor's disc (one of the two would be brighter). So
15//! nothing needs de-duplicating within a tier.
16//!
17//! **Memory.** The sky is processed a declination strip at a time, reading the
18//! strip plus a margin of `r` from the database, so even the deepest tier never
19//! holds the whole catalogue: peak memory is the strip's stars plus the tier's
20//! patterns (24 bytes each).
21
22use core::f64::consts::{FRAC_PI_2, PI};
23use std::collections::HashMap;
24use std::path::Path;
25
26use super::format::{BuiltIndex, IndexStar, STAR_BANDS, TierInfo, star_band};
27use super::pattern::{Tangent, canonical, descriptor, key, unit};
28use crate::catalog::format_1476::for_each_star_in_dec_band;
29use crate::error::Result;
30
31/// One tier to build.
32#[derive(Debug, Clone, Copy, PartialEq)]
33pub struct TierSpec {
34    /// Disc radius, degrees.
35    pub radius_deg: f64,
36    /// Faintest magnitude used.
37    pub mag_cap: f64,
38    /// Group size, anchor included (5 gives 5 patterns per anchor, 6 gives 15).
39    pub members: usize,
40}
41
42/// The tier ladder. Each disc radius is paired with the magnitude at which a disc
43/// of that size holds roughly 15–20 stars on average, so the anchor's group is
44/// drawn from stars an image of that field will show. Radii step by about 2×, so
45/// any field from ~0.15° up sees at least one tier at a comfortable size (a tier
46/// is used for fields between about 2.5 and 12 disc radii across).
47///
48/// Measured on D80 (Gaia BP): mag ≤ 6.1 → 0.1 stars/deg², ≤ 9.2 → 7, ≤ 12.7 →
49/// 150, ≤ 14.2 → 600, ≤ 16 → 1900. The wider tiers use 6-star groups (15
50/// patterns per anchor); the two deepest use 5 (5 patterns), which keeps the
51/// index size in check where anchors are most numerous.
52pub const DEFAULT_TIERS: [TierSpec; 9] = [
53    TierSpec {
54        radius_deg: 12.0,
55        mag_cap: 4.6,
56        members: 6,
57    },
58    TierSpec {
59        radius_deg: 6.0,
60        mag_cap: 6.1,
61        members: 6,
62    },
63    TierSpec {
64        radius_deg: 3.0,
65        mag_cap: 7.6,
66        members: 6,
67    },
68    TierSpec {
69        radius_deg: 1.5,
70        mag_cap: 9.2,
71        members: 6,
72    },
73    TierSpec {
74        radius_deg: 0.75,
75        mag_cap: 10.7,
76        members: 6,
77    },
78    TierSpec {
79        radius_deg: 0.4,
80        mag_cap: 11.8,
81        members: 6,
82    },
83    TierSpec {
84        radius_deg: 0.2,
85        mag_cap: 12.7,
86        members: 6,
87    },
88    TierSpec {
89        radius_deg: 0.1,
90        mag_cap: 14.2,
91        members: 5,
92    },
93    TierSpec {
94        radius_deg: 0.06,
95        mag_cap: 16.0,
96        members: 5,
97    },
98];
99
100/// The smallest image field (degrees, short side) a tier serves, and the largest.
101#[must_use]
102pub fn tier_fov_range(radius_deg: f64) -> (f64, f64) {
103    (2.5 * radius_deg, 12.0 * radius_deg)
104}
105
106/// What to build.
107#[derive(Debug, Clone)]
108pub struct BuildParams {
109    /// Directory holding the database.
110    pub db_path: std::path::PathBuf,
111    /// Database name (`d80`, `g05`, ...).
112    pub db_name: String,
113    /// Tiers, any order (they are stored widest first).
114    pub tiers: Vec<TierSpec>,
115    /// Worker threads; 0 = [`crate::max_threads`].
116    pub threads: usize,
117}
118
119/// Progress reported by [`build_index`].
120#[derive(Debug, Clone)]
121pub enum BuildProgress {
122    /// A tier is starting.
123    Tier {
124        /// Its position (0-based) and the number of tiers.
125        index: usize,
126        /// Number of tiers.
127        of: usize,
128        /// The tier.
129        spec: TierSpec,
130    },
131    /// A declination strip of the current tier is done.
132    Strip {
133        /// Strips done.
134        done: usize,
135        /// Strips in the tier.
136        of: usize,
137        /// Patterns so far in the tier.
138        patterns: usize,
139    },
140    /// A tier is finished.
141    TierDone {
142        /// The finished tier.
143        info: TierInfo,
144        /// Stars read from the database for it.
145        stars_read: usize,
146    },
147}
148
149/// A star of the strip being processed.
150#[derive(Clone, Copy)]
151struct Src {
152    u: [f64; 3],
153    ra: f64,
154    dec: f64,
155    mag: f64,
156}
157
158/// Grid of a strip's stars in cells about `r` on a side, for disc queries.
159struct Grid {
160    r: f64,
161    cells: HashMap<(i32, i32), Vec<u32>>,
162}
163
164impl Grid {
165    fn band(&self, dec: f64) -> i32 {
166        ((dec + FRAC_PI_2) / self.r).floor() as i32
167    }
168    fn ra_cells(&self, band: i32) -> i32 {
169        let lo = f64::from(band) * self.r - FRAC_PI_2;
170        let hi = lo + self.r;
171        // Narrowest cell at the band's edge nearest the equator, so a cell is never
172        // narrower than r on the sky.
173        let cos_max = if lo <= 0.0 && hi >= 0.0 {
174            1.0
175        } else {
176            lo.cos().max(hi.cos())
177        };
178        ((2.0 * PI * cos_max / self.r).floor() as i32).max(1)
179    }
180    fn cell(&self, ra: f64, dec: f64) -> (i32, i32) {
181        let b = self.band(dec);
182        let n = self.ra_cells(b);
183        let c =
184            ((ra.rem_euclid(2.0 * PI) / (2.0 * PI) * f64::from(n)).floor() as i32).rem_euclid(n);
185        (b, c)
186    }
187    fn new(r: f64, stars: &[Src]) -> Self {
188        let mut g = Self {
189            r,
190            cells: HashMap::new(),
191        };
192        for (i, s) in stars.iter().enumerate() {
193            let k = g.cell(s.ra, s.dec);
194            g.cells.entry(k).or_default().push(i as u32);
195        }
196        g
197    }
198    /// Every star index that could lie within `r` of (`ra`, `dec`).
199    fn near(&self, ra: f64, dec: f64, mut f: impl FnMut(u32)) {
200        let b0 = self.band(dec);
201        for b in b0 - 1..=b0 + 1 {
202            let n = self.ra_cells(b);
203            let lo = f64::from(b) * self.r - FRAC_PI_2;
204            let hi = lo + self.r;
205            if hi < -FRAC_PI_2 - self.r || lo > FRAC_PI_2 + self.r {
206                continue;
207            }
208            // Worst-case RA half-width of a disc of radius r anywhere in this band.
209            let d_extreme = dec.abs().max(lo.abs()).max(hi.abs()).min(FRAC_PI_2);
210            let cos_d = d_extreme.cos();
211            let all = cos_d * PI <= self.r * 1.01 || n <= 3;
212            let (c_lo, c_hi) = if all {
213                (0, n - 1)
214            } else {
215                let half = (self.r / cos_d).min(PI);
216                let w = 2.0 * PI / f64::from(n);
217                let c = ra.rem_euclid(2.0 * PI) / w;
218                ((c - half / w).floor() as i32, (c + half / w).floor() as i32)
219            };
220            let span = (c_hi - c_lo + 1).min(n);
221            for k in 0..span {
222                let c = (c_lo + k).rem_euclid(n);
223                if let Some(v) = self.cells.get(&(b, c)) {
224                    for &i in v {
225                        f(i);
226                    }
227                }
228            }
229        }
230    }
231}
232
233/// Patterns found for one anchor: its group (strip-local indices) and the keys of
234/// its 4-subsets with their members in canonical order.
235type AnchorOut = (Vec<u32>, Vec<(u64, [u32; 4])>);
236
237/// Build the group and patterns of star `i`, if it is an anchor.
238fn anchor_patterns(
239    i: usize,
240    stars: &[Src],
241    grid: &Grid,
242    cos_r: f64,
243    members: usize,
244) -> Option<AnchorOut> {
245    let s = &stars[i];
246    let mut group: Vec<u32> = Vec::new();
247    let mut brighter = false;
248    grid.near(s.ra, s.dec, |j| {
249        if brighter || j as usize == i {
250            return;
251        }
252        let o = &stars[j as usize];
253        if s.u[0] * o.u[0] + s.u[1] * o.u[1] + s.u[2] * o.u[2] >= cos_r {
254            if (j as usize) < i {
255                brighter = true;
256            } else {
257                group.push(j);
258            }
259        }
260    });
261    if brighter || group.len() < 3 {
262        return None;
263    }
264    group.sort_unstable();
265    group.truncate(members - 1);
266    group.insert(0, i as u32);
267
268    let m = group.len();
269    let mut pats = Vec::new();
270    for a in 0..m {
271        for b in a + 1..m {
272            for c in b + 1..m {
273                for d in c + 1..m {
274                    let ids = [group[a], group[b], group[c], group[d]];
275                    let mut csum = [0.0f64; 3];
276                    for &id in &ids {
277                        let u = stars[id as usize].u;
278                        csum[0] += u[0];
279                        csum[1] += u[1];
280                        csum[2] += u[2];
281                    }
282                    let Some(tp) = Tangent::at(csum) else {
283                        continue;
284                    };
285                    let mut p = [(0.0, 0.0); 4];
286                    let mut ok = true;
287                    for (k, &id) in ids.iter().enumerate() {
288                        match tp.project(&stars[id as usize].u) {
289                            Some(xy) => p[k] = xy,
290                            None => ok = false,
291                        }
292                    }
293                    if !ok {
294                        continue;
295                    }
296                    let Some((order, _, _)) = canonical(&p) else {
297                        continue;
298                    };
299                    let ordered = order.map(|k| p[k]);
300                    pats.push((key(&descriptor(&ordered)), order.map(|k| ids[k])));
301                }
302            }
303        }
304    }
305    Some((group, pats))
306}
307
308/// Read one strip's stars (core plus margin), sorted brightest first with a total
309/// order, so "brighter" means the same thing in every strip.
310fn read_strip(params: &BuildParams, lo: f64, hi: f64, cap: f64) -> Result<Vec<Src>> {
311    let mut v = Vec::new();
312    for_each_star_in_dec_band(&params.db_path, &params.db_name, lo, hi, cap, |s| {
313        v.push(Src {
314            u: unit(s.ra, s.dec),
315            ra: s.ra,
316            dec: s.dec,
317            mag: s.mag,
318        });
319    })?;
320    v.sort_by(|a, b| {
321        a.mag
322            .total_cmp(&b.mag)
323            .then(a.ra.total_cmp(&b.ra))
324            .then(a.dec.total_cmp(&b.dec))
325    });
326    // A star present twice (the band reader is called once per strip, but a
327    // database may hold an exact duplicate) would block its own anchoring.
328    v.dedup_by(|a, b| a.ra == b.ra && a.dec == b.dec);
329    Ok(v)
330}
331
332/// Build an index. Deterministic: the same database and tiers give the same file
333/// (apart from the build time), whatever the thread count.
334///
335/// # Errors
336///
337/// [`crate::ArcsecError::CatalogIo`] if a database file cannot be read.
338pub fn build_index(
339    params: &BuildParams,
340    mut progress: impl FnMut(&BuildProgress),
341) -> Result<BuiltIndex> {
342    let threads = if params.threads > 0 {
343        params.threads
344    } else {
345        crate::max_threads()
346    }
347    .max(1);
348
349    let mut tiers = params.tiers.clone();
350    tiers.sort_by(|a, b| b.radius_deg.total_cmp(&a.radius_deg));
351
352    let mut out = BuiltIndex {
353        source: params.db_name.clone(),
354        ..BuiltIndex::default()
355    };
356    // Stars as appended (duplicates across strips are merged at the end).
357    let mut stars: Vec<IndexStar> = Vec::new();
358
359    for (ti, spec) in tiers.iter().enumerate() {
360        progress(&BuildProgress::Tier {
361            index: ti,
362            of: tiers.len(),
363            spec: *spec,
364        });
365        let r = spec.radius_deg.to_radians();
366        let cos_r = r.cos();
367        // Wide tiers have few stars: one strip. Deep ones go 5° at a time.
368        let strip = if spec.mag_cap <= 11.0 {
369            PI
370        } else {
371            5f64.to_radians()
372        };
373        let n_strips = (PI / strip).ceil() as usize;
374        let first_pattern = out.keys.len();
375        let mut tier_pats: Vec<(u64, [u32; 4])> = Vec::new();
376        let mut n_anchors = 0u64;
377        let mut stars_read = 0usize;
378
379        for si in 0..n_strips {
380            let lo = -FRAC_PI_2 + si as f64 * strip;
381            let hi = (lo + strip).min(FRAC_PI_2);
382            let src = read_strip(params, lo - r, hi + r, spec.mag_cap)?;
383            stars_read += src.len();
384            let grid = Grid::new(r, &src);
385            // Anchors are the strip's own stars; the margin only supplies
386            // neighbours. The top strip owns +90° itself.
387            let core: Vec<usize> = (0..src.len())
388                .filter(|&i| src[i].dec >= lo && (src[i].dec < hi || si + 1 == n_strips))
389                .collect();
390            let chunk = core.len().div_ceil(threads).max(1);
391            let results: Vec<Vec<AnchorOut>> = std::thread::scope(|scope| {
392                let handles: Vec<_> = core
393                    .chunks(chunk)
394                    .map(|part| {
395                        let (src, grid) = (&src, &grid);
396                        scope.spawn(move || {
397                            part.iter()
398                                .filter_map(|&i| anchor_patterns(i, src, grid, cos_r, spec.members))
399                                .collect::<Vec<_>>()
400                        })
401                    })
402                    .collect();
403                handles
404                    .into_iter()
405                    .map(|h| h.join().unwrap_or_else(|e| std::panic::resume_unwind(e)))
406                    .collect()
407            });
408
409            // Strip-local star index → global (pre-merge) index.
410            let mut local: HashMap<u32, u32> = HashMap::new();
411            for (group, pats) in results.into_iter().flatten() {
412                n_anchors += 1;
413                for &l in &group {
414                    local.entry(l).or_insert_with(|| {
415                        let s = &src[l as usize];
416                        stars.push(IndexStar {
417                            ra: s.ra as f32,
418                            dec: s.dec as f32,
419                            mag: (s.mag * 100.0).round().clamp(-32768.0, 32767.0) as i16,
420                            tier: ti as u8,
421                        });
422                        (stars.len() - 1) as u32
423                    });
424                }
425                for (k, ids) in pats {
426                    tier_pats.push((k, ids.map(|l| local[&l])));
427                }
428            }
429            progress(&BuildProgress::Strip {
430                done: si + 1,
431                of: n_strips,
432                patterns: tier_pats.len(),
433            });
434        }
435
436        tier_pats.sort_unstable();
437        let info = TierInfo {
438            radius: r,
439            mag_cap: spec.mag_cap as f32,
440            members: spec.members as u32,
441            first_pattern: first_pattern as u64,
442            n_patterns: tier_pats.len() as u64,
443            n_anchors,
444        };
445        out.keys.extend(tier_pats.iter().map(|p| p.0));
446        out.quads.extend(tier_pats.iter().map(|p| p.1));
447        drop(tier_pats);
448        out.tiers.push(info);
449        progress(&BuildProgress::TierDone { info, stars_read });
450    }
451
452    // Merge stars: sort by (band, RA, Dec), collapse exact duplicates (the same
453    // database record reached from two strips or two tiers, keeping the widest
454    // tier), and renumber the quads.
455    let mut order: Vec<u32> = (0..stars.len() as u32).collect();
456    let band_of = |s: &IndexStar| star_band(f64::from(s.dec));
457    order.sort_unstable_by(|&a, &b| {
458        let (x, y) = (&stars[a as usize], &stars[b as usize]);
459        band_of(x)
460            .cmp(&band_of(y))
461            .then(x.ra.total_cmp(&y.ra))
462            .then(x.dec.total_cmp(&y.dec))
463            .then(x.tier.cmp(&y.tier))
464    });
465    let mut remap = vec![0u32; stars.len()];
466    let mut merged: Vec<IndexStar> = Vec::with_capacity(stars.len());
467    for &i in &order {
468        let s = stars[i as usize];
469        match merged.last() {
470            Some(m) if m.ra.to_bits() == s.ra.to_bits() && m.dec.to_bits() == s.dec.to_bits() => {}
471            _ => merged.push(s),
472        }
473        remap[i as usize] = (merged.len() - 1) as u32;
474    }
475    drop(stars);
476    for q in &mut out.quads {
477        *q = q.map(|i| remap[i as usize]);
478    }
479    let mut dir = vec![0u32; STAR_BANDS as usize + 1];
480    for s in &merged {
481        dir[star_band(f64::from(s.dec)) as usize + 1] += 1;
482    }
483    for b in 1..dir.len() {
484        dir[b] += dir[b - 1];
485    }
486    out.star_dir = dir;
487    out.stars = merged;
488    Ok(out)
489}
490
491/// Default index file for database `db_name` in directory `dir`.
492#[must_use]
493pub fn default_index_path(dir: &Path, db_name: &str) -> std::path::PathBuf {
494    dir.join(format!("{db_name}.{}", super::format::EXTENSION))
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use crate::test_support::{Rng, SkyStar, TempDir, write_1476_db};
501
502    fn deg(d: f64) -> f64 {
503        d.to_radians()
504    }
505
506    /// Six stars inside a 0.1° disc round (ra, dec), brightest at the centre.
507    fn disc(ra: f64, dec: f64) -> Vec<SkyStar> {
508        let offs = [
509            (0.0, 0.0),
510            (0.05, 0.01),
511            (-0.03, 0.04),
512            (0.02, -0.06),
513            (-0.06, -0.02),
514            (0.01, 0.07),
515        ];
516        offs.iter()
517            .enumerate()
518            .map(|(i, &(dx, dy))| SkyStar {
519                ra: deg(ra + dx / deg(dec).cos()).rem_euclid(2.0 * PI),
520                dec: deg(dec + dy),
521                mag: 9.0 + 0.3 * i as f64,
522            })
523            .collect()
524    }
525
526    fn build(dir: &Path, stars: &[SkyStar], threads: usize) -> BuiltIndex {
527        write_1476_db(dir, "t", stars);
528        build_index(
529            &BuildParams {
530                db_path: dir.to_path_buf(),
531                db_name: "t".into(),
532                tiers: vec![TierSpec {
533                    radius_deg: 0.1,
534                    mag_cap: 14.0, // deep: processed in 5° strips
535                    members: 6,
536                }],
537                threads,
538            },
539            |_| {},
540        )
541        .unwrap()
542    }
543
544    #[test]
545    fn a_lone_disc_gives_all_its_four_subsets_across_strip_and_ra_seams() {
546        // At a strip boundary (Dec 0°), across RA 0°, and in open sky.
547        for (ra, dec) in [(0.0, 0.0), (120.0, 30.0), (240.0, -5.0)] {
548            let dir = TempDir::new("ixbuild_disc");
549            let ix = build(dir.path(), &disc(ra, dec), 2);
550            assert_eq!(ix.tiers[0].n_anchors, 1, "({ra}, {dec})");
551            assert_eq!(ix.keys.len(), 15, "({ra}, {dec})");
552            assert_eq!(ix.stars.len(), 6);
553            for q in &ix.quads {
554                let mut s = q.to_vec();
555                s.sort_unstable();
556                s.dedup();
557                assert_eq!(s.len(), 4, "four distinct stars");
558            }
559        }
560    }
561
562    #[test]
563    fn only_the_brightest_star_of_a_disc_anchors() {
564        let dir = TempDir::new("ixbuild_two");
565        let mut stars = disc(50.0, 20.0);
566        stars.extend(disc(50.0, 20.4));
567        let ix = build(dir.path(), &stars, 1);
568        assert_eq!(ix.tiers[0].n_anchors, 2);
569        // A brighter star beside the second disc's centre takes over its anchoring.
570        stars.push(SkyStar {
571            ra: deg(50.0),
572            dec: deg(20.405),
573            mag: 5.0,
574        });
575        let dir2 = TempDir::new("ixbuild_two_b");
576        let ix2 = build(dir2.path(), &stars, 1);
577        assert_eq!(ix2.tiers[0].n_anchors, 2);
578        assert!(ix2.stars.iter().any(|s| s.mag == 500));
579    }
580
581    #[test]
582    fn the_result_does_not_depend_on_the_thread_count() {
583        let mut rng = Rng::new(7);
584        let stars: Vec<SkyStar> = (0..3000)
585            .map(|_| SkyStar {
586                ra: deg(rng.range(10.0, 14.0)),
587                dec: deg(rng.range(-6.0, 3.0)),
588                mag: rng.range(6.0, 14.0),
589            })
590            .collect();
591        let (d1, d2) = (TempDir::new("ixbuild_t1"), TempDir::new("ixbuild_t2"));
592        let a = build(d1.path(), &stars, 1);
593        let b = build(d2.path(), &stars, 5);
594        assert!(a.keys.len() > 100);
595        assert_eq!(a.keys, b.keys);
596        assert_eq!(a.quads, b.quads);
597        assert_eq!(a.stars, b.stars);
598        assert_eq!(a.star_dir, b.star_dir);
599        assert!(a.keys.windows(2).all(|w| w[0] <= w[1]));
600        assert_eq!(*a.star_dir.last().unwrap() as usize, a.stars.len());
601    }
602}