1use 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#[derive(Debug, Clone, Copy, PartialEq)]
33pub struct TierSpec {
34 pub radius_deg: f64,
36 pub mag_cap: f64,
38 pub members: usize,
40}
41
42pub 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#[must_use]
102pub fn tier_fov_range(radius_deg: f64) -> (f64, f64) {
103 (2.5 * radius_deg, 12.0 * radius_deg)
104}
105
106#[derive(Debug, Clone)]
108pub struct BuildParams {
109 pub db_path: std::path::PathBuf,
111 pub db_name: String,
113 pub tiers: Vec<TierSpec>,
115 pub threads: usize,
117}
118
119#[derive(Debug, Clone)]
121pub enum BuildProgress {
122 Tier {
124 index: usize,
126 of: usize,
128 spec: TierSpec,
130 },
131 Strip {
133 done: usize,
135 of: usize,
137 patterns: usize,
139 },
140 TierDone {
142 info: TierInfo,
144 stars_read: usize,
146 },
147}
148
149#[derive(Clone, Copy)]
151struct Src {
152 u: [f64; 3],
153 ra: f64,
154 dec: f64,
155 mag: f64,
156}
157
158struct 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 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 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 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
233type AnchorOut = (Vec<u32>, Vec<(u64, [u32; 4])>);
236
237fn 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
308fn 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(¶ms.db_path, ¶ms.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 v.dedup_by(|a, b| a.ra == b.ra && a.dec == b.dec);
329 Ok(v)
330}
331
332pub 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 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 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 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 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 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#[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 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, 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 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 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}