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, SourceStamp, 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/// Stops with [`crate::ArcsecError::Cancelled`] before the next declination strip
336/// once the thread's [`crate::cancel`] token is cancelled.
337///
338/// # Errors
339///
340/// [`crate::ArcsecError::CatalogIo`] if a database file cannot be read, or
341/// [`crate::ArcsecError::Cancelled`].
342pub fn build_index(
343    params: &BuildParams,
344    mut progress: impl FnMut(&BuildProgress),
345) -> Result<BuiltIndex> {
346    let threads = if params.threads > 0 {
347        params.threads
348    } else {
349        crate::max_threads()
350    }
351    .max(1);
352
353    let mut tiers = params.tiers.clone();
354    tiers.sort_by(|a, b| b.radius_deg.total_cmp(&a.radius_deg));
355
356    let mut out = BuiltIndex {
357        source: params.db_name.clone(),
358        source_stamp: SourceStamp::of_database(&params.db_path, &params.db_name)?,
359        ..BuiltIndex::default()
360    };
361    // Stars as appended (duplicates across strips are merged at the end).
362    let mut stars: Vec<IndexStar> = Vec::new();
363
364    for (ti, spec) in tiers.iter().enumerate() {
365        progress(&BuildProgress::Tier {
366            index: ti,
367            of: tiers.len(),
368            spec: *spec,
369        });
370        let r = spec.radius_deg.to_radians();
371        let cos_r = r.cos();
372        // Wide tiers have few stars: one strip. Deep ones go 5° at a time.
373        let strip = if spec.mag_cap <= 11.0 {
374            PI
375        } else {
376            5f64.to_radians()
377        };
378        let n_strips = (PI / strip).ceil() as usize;
379        let first_pattern = out.keys.len();
380        let mut tier_pats: Vec<(u64, [u32; 4])> = Vec::new();
381        let mut n_anchors = 0u64;
382        let mut stars_read = 0usize;
383
384        for si in 0..n_strips {
385            // A strip of a deep tier takes seconds: the place to notice a cancel.
386            if crate::cancel::is_cancelled() {
387                return Err(crate::ArcsecError::Cancelled);
388            }
389            let lo = -FRAC_PI_2 + si as f64 * strip;
390            let hi = (lo + strip).min(FRAC_PI_2);
391            let src = read_strip(params, lo - r, hi + r, spec.mag_cap)?;
392            stars_read += src.len();
393            let grid = Grid::new(r, &src);
394            // Anchors are the strip's own stars; the margin only supplies
395            // neighbours. The top strip owns +90° itself.
396            let core: Vec<usize> = (0..src.len())
397                .filter(|&i| src[i].dec >= lo && (src[i].dec < hi || si + 1 == n_strips))
398                .collect();
399            let chunk = core.len().div_ceil(threads).max(1);
400            let results: Vec<Vec<AnchorOut>> = std::thread::scope(|scope| {
401                let handles: Vec<_> = core
402                    .chunks(chunk)
403                    .map(|part| {
404                        let (src, grid) = (&src, &grid);
405                        scope.spawn(move || {
406                            part.iter()
407                                .filter_map(|&i| anchor_patterns(i, src, grid, cos_r, spec.members))
408                                .collect::<Vec<_>>()
409                        })
410                    })
411                    .collect();
412                handles
413                    .into_iter()
414                    .map(|h| h.join().unwrap_or_else(|e| std::panic::resume_unwind(e)))
415                    .collect()
416            });
417
418            // Strip-local star index → global (pre-merge) index.
419            let mut local: HashMap<u32, u32> = HashMap::new();
420            for (group, pats) in results.into_iter().flatten() {
421                n_anchors += 1;
422                for &l in &group {
423                    local.entry(l).or_insert_with(|| {
424                        let s = &src[l as usize];
425                        stars.push(IndexStar {
426                            ra: s.ra as f32,
427                            dec: s.dec as f32,
428                            mag: (s.mag * 100.0).round().clamp(-32768.0, 32767.0) as i16,
429                            tier: ti as u8,
430                        });
431                        (stars.len() - 1) as u32
432                    });
433                }
434                for (k, ids) in pats {
435                    tier_pats.push((k, ids.map(|l| local[&l])));
436                }
437            }
438            progress(&BuildProgress::Strip {
439                done: si + 1,
440                of: n_strips,
441                patterns: tier_pats.len(),
442            });
443        }
444
445        tier_pats.sort_unstable();
446        let info = TierInfo {
447            radius: r,
448            mag_cap: spec.mag_cap as f32,
449            members: spec.members as u32,
450            first_pattern: first_pattern as u64,
451            n_patterns: tier_pats.len() as u64,
452            n_anchors,
453        };
454        out.keys.extend(tier_pats.iter().map(|p| p.0));
455        out.quads.extend(tier_pats.iter().map(|p| p.1));
456        drop(tier_pats);
457        out.tiers.push(info);
458        progress(&BuildProgress::TierDone { info, stars_read });
459    }
460
461    // Merge stars: sort by (band, RA, Dec), collapse exact duplicates (the same
462    // database record reached from two strips or two tiers, keeping the widest
463    // tier), and renumber the quads.
464    let mut order: Vec<u32> = (0..stars.len() as u32).collect();
465    let band_of = |s: &IndexStar| star_band(f64::from(s.dec));
466    order.sort_unstable_by(|&a, &b| {
467        let (x, y) = (&stars[a as usize], &stars[b as usize]);
468        band_of(x)
469            .cmp(&band_of(y))
470            .then(x.ra.total_cmp(&y.ra))
471            .then(x.dec.total_cmp(&y.dec))
472            .then(x.tier.cmp(&y.tier))
473    });
474    let mut remap = vec![0u32; stars.len()];
475    let mut merged: Vec<IndexStar> = Vec::with_capacity(stars.len());
476    for &i in &order {
477        let s = stars[i as usize];
478        match merged.last() {
479            Some(m) if m.ra.to_bits() == s.ra.to_bits() && m.dec.to_bits() == s.dec.to_bits() => {}
480            _ => merged.push(s),
481        }
482        remap[i as usize] = (merged.len() - 1) as u32;
483    }
484    drop(stars);
485    for q in &mut out.quads {
486        *q = q.map(|i| remap[i as usize]);
487    }
488    let mut dir = vec![0u32; STAR_BANDS as usize + 1];
489    for s in &merged {
490        dir[star_band(f64::from(s.dec)) as usize + 1] += 1;
491    }
492    for b in 1..dir.len() {
493        dir[b] += dir[b - 1];
494    }
495    out.star_dir = dir;
496    out.stars = merged;
497    Ok(out)
498}
499
500/// Default index file for database `db_name` in directory `dir`.
501#[must_use]
502pub fn default_index_path(dir: &Path, db_name: &str) -> std::path::PathBuf {
503    dir.join(format!("{db_name}.{}", super::format::EXTENSION))
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use crate::test_support::{Rng, SkyStar, TempDir, write_1476_db};
510
511    fn deg(d: f64) -> f64 {
512        d.to_radians()
513    }
514
515    /// Six stars inside a 0.1° disc round (ra, dec), brightest at the centre.
516    fn disc(ra: f64, dec: f64) -> Vec<SkyStar> {
517        let offs = [
518            (0.0, 0.0),
519            (0.05, 0.01),
520            (-0.03, 0.04),
521            (0.02, -0.06),
522            (-0.06, -0.02),
523            (0.01, 0.07),
524        ];
525        offs.iter()
526            .enumerate()
527            .map(|(i, &(dx, dy))| SkyStar {
528                ra: deg(ra + dx / deg(dec).cos()).rem_euclid(2.0 * PI),
529                dec: deg(dec + dy),
530                mag: 9.0 + 0.3 * i as f64,
531            })
532            .collect()
533    }
534
535    fn build(dir: &Path, stars: &[SkyStar], threads: usize) -> BuiltIndex {
536        write_1476_db(dir, "t", stars);
537        build_index(
538            &BuildParams {
539                db_path: dir.to_path_buf(),
540                db_name: "t".into(),
541                tiers: vec![TierSpec {
542                    radius_deg: 0.1,
543                    mag_cap: 14.0, // deep: processed in 5° strips
544                    members: 6,
545                }],
546                threads,
547            },
548            |_| {},
549        )
550        .unwrap()
551    }
552
553    #[test]
554    fn a_lone_disc_gives_all_its_four_subsets_across_strip_and_ra_seams() {
555        // At a strip boundary (Dec 0°), across RA 0°, and in open sky.
556        for (ra, dec) in [(0.0, 0.0), (120.0, 30.0), (240.0, -5.0)] {
557            let dir = TempDir::new("ixbuild_disc");
558            let ix = build(dir.path(), &disc(ra, dec), 2);
559            assert_eq!(ix.tiers[0].n_anchors, 1, "({ra}, {dec})");
560            assert_eq!(ix.keys.len(), 15, "({ra}, {dec})");
561            assert_eq!(ix.stars.len(), 6);
562            for q in &ix.quads {
563                let mut s = q.to_vec();
564                s.sort_unstable();
565                s.dedup();
566                assert_eq!(s.len(), 4, "four distinct stars");
567            }
568        }
569    }
570
571    #[test]
572    fn only_the_brightest_star_of_a_disc_anchors() {
573        let dir = TempDir::new("ixbuild_two");
574        let mut stars = disc(50.0, 20.0);
575        stars.extend(disc(50.0, 20.4));
576        let ix = build(dir.path(), &stars, 1);
577        assert_eq!(ix.tiers[0].n_anchors, 2);
578        // A brighter star beside the second disc's centre takes over its anchoring.
579        stars.push(SkyStar {
580            ra: deg(50.0),
581            dec: deg(20.405),
582            mag: 5.0,
583        });
584        let dir2 = TempDir::new("ixbuild_two_b");
585        let ix2 = build(dir2.path(), &stars, 1);
586        assert_eq!(ix2.tiers[0].n_anchors, 2);
587        assert!(ix2.stars.iter().any(|s| s.mag == 500));
588    }
589
590    #[test]
591    fn the_result_does_not_depend_on_the_thread_count() {
592        let mut rng = Rng::new(7);
593        let stars: Vec<SkyStar> = (0..3000)
594            .map(|_| SkyStar {
595                ra: deg(rng.range(10.0, 14.0)),
596                dec: deg(rng.range(-6.0, 3.0)),
597                mag: rng.range(6.0, 14.0),
598            })
599            .collect();
600        let (d1, d2) = (TempDir::new("ixbuild_t1"), TempDir::new("ixbuild_t2"));
601        let a = build(d1.path(), &stars, 1);
602        let b = build(d2.path(), &stars, 5);
603        assert!(a.keys.len() > 100);
604        assert_eq!(a.keys, b.keys);
605        assert_eq!(a.quads, b.quads);
606        assert_eq!(a.stars, b.stars);
607        assert_eq!(a.star_dir, b.star_dir);
608        assert!(a.keys.windows(2).all(|w| w[0] <= w[1]));
609        assert_eq!(*a.star_dir.last().unwrap() as usize, a.stars.len());
610    }
611}