1use 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#[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(
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(¶ms.db_path, ¶ms.db_name)?,
359 ..BuiltIndex::default()
360 };
361 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 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 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 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 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 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#[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 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, 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 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 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}