1use alloc::sync::Arc;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use crate::ArcsecError;
10use crate::catalog::{load_anet_index, peek_anet_scale};
11use crate::pipeline::{BlindSolveParams, blind_solve};
12use crate::types::ImageBuffer;
13
14const BLIND_MAX_INDEXES: usize = 2;
17
18pub(crate) enum BlindOutcome {
20 Found(f64, f64),
22 NotFound,
24 InsufficientStars { found: usize, required: usize },
26}
27
28fn is_index_name(name: &str) -> bool {
30 name.starts_with("index-") && name.ends_with(".fits")
31}
32
33#[must_use]
43pub fn collect_index_files(path: &Path, fov_deg: f64) -> Vec<PathBuf> {
44 if path.is_file() {
45 return vec![path.to_path_buf()];
46 }
47 let Ok(rd) = fs::read_dir(path) else {
48 return vec![];
49 };
50 let mut raw: Vec<PathBuf> = rd
51 .filter_map(Result::ok)
52 .map(|e| e.path())
53 .filter(|p| {
54 p.is_file()
55 && p.file_name()
56 .and_then(|n| n.to_str())
57 .is_some_and(is_index_name)
58 })
59 .collect();
60 raw.sort();
61
62 if fov_deg <= 0.0 {
63 return raw;
64 }
65
66 let fov_lo = fov_deg * 0.2;
69 let fov_hi = fov_deg * 1.5;
70 let ideal = fov_deg / 2.0;
71
72 let mut ranked: Vec<(PathBuf, f64)> = raw
73 .into_iter()
74 .filter_map(|p| {
75 let (_dq, lo_rad, hi_rad) = std::panic::catch_unwind(|| peek_anet_scale(&p))
78 .ok()?
79 .ok()?;
80 let lo_deg = lo_rad.to_degrees();
81 let hi_deg = hi_rad.to_degrees();
82 if hi_deg < fov_lo || lo_deg > fov_hi {
83 log::info!(
84 "Blind: skipping {} (scale {lo_deg:.2}°–{hi_deg:.2}°, outside [{fov_lo:.2}°–{fov_hi:.2}°])",
85 p.display(),
86 );
87 return None;
88 }
89 let mid = (lo_deg + hi_deg) / 2.0;
90 Some((p, (mid - ideal).abs()))
91 })
92 .collect();
93
94 ranked.sort_by(|a, b| a.1.total_cmp(&b.1));
96 ranked.into_iter().map(|(p, _)| p).collect()
97}
98
99pub(crate) fn estimate_position(
106 img: &ImageBuffer,
107 index_files: &[PathBuf],
108 params: &BlindSolveParams,
109) -> BlindOutcome {
110 let max_indexes = BLIND_MAX_INDEXES.min(crate::max_threads().max(1));
111 let img = Arc::new(img.clone());
112 let cancel = crate::cancel::current();
113 let local_threads = crate::local_max_threads();
114
115 let handles: Vec<_> = index_files
116 .iter()
117 .take(max_indexes)
118 .map(|idx_path| {
119 let idx_path = idx_path.clone();
120 let img = Arc::clone(&img);
121 let params = params.clone();
122 let cancel = cancel.clone();
123 std::thread::spawn(move || -> Result<(f64, f64, usize), ArcsecError> {
124 log::info!("Blind: trying index {}", idx_path.display());
125 let anet_index = load_anet_index(&idx_path)?;
126 let res = crate::cancel::with_optional(cancel.as_ref(), || {
127 crate::with_max_threads(local_threads, || {
128 blind_solve(&img, &anet_index, ¶ms)
129 })
130 });
131 if let Ok((ra, dec, score)) = &res {
132 log::info!(
133 "Blind: {} → RA={:.3}° Dec={:.3}° score={score}",
134 idx_path
135 .file_name()
136 .map(|n| n.to_string_lossy().into_owned())
137 .unwrap_or_default(),
138 ra.to_degrees(),
139 dec.to_degrees(),
140 );
141 }
142 res
143 })
144 })
145 .collect();
146
147 let mut best: Option<(f64, f64, usize)> = None;
148 let mut too_few: Option<(usize, usize)> = None;
149 for handle in handles {
150 match handle.join() {
151 Ok(Ok((ra, dec, score))) => {
152 if best.is_none_or(|(_, _, s)| score > s) {
153 best = Some((ra, dec, score));
154 }
155 }
156 Ok(Err(ArcsecError::InsufficientStars { found, required })) => {
157 too_few = Some((found, required));
158 }
159 Ok(Err(e)) => log::info!("Blind: did not solve: {e}"),
160 Err(_) => log::info!("Blind: thread panicked"),
161 }
162 }
163
164 match (best, too_few) {
166 (Some((ra, dec, _)), _) => BlindOutcome::Found(ra, dec),
167 (None, Some((found, required))) => BlindOutcome::InsufficientStars { found, required },
168 (None, None) => BlindOutcome::NotFound,
169 }
170}
171
172const SCALE_SLACK: f64 = 1.2;
178
179const SCALE_UNKNOWN: (f64, f64) = (0.3, 60.0);
181
182#[must_use]
186pub fn find_arcsec_index(path: &Path) -> Option<PathBuf> {
187 if path.is_file() {
188 return crate::index::is_blind_index(path).then(|| path.to_path_buf());
189 }
190 preferred_index(path).or_else(|| {
194 super::index_files(path)
195 .into_iter()
196 .find(|p| crate::index::is_blind_index(p))
197 })
198}
199
200pub const SOURCES: [&str; 6] = ["d80", "d50", "d20", "d05", "g05", "w08"];
203
204#[must_use]
206pub fn depth_rank(db: &str) -> usize {
207 SOURCES
208 .iter()
209 .position(|s| s.eq_ignore_ascii_case(db))
210 .unwrap_or(SOURCES.len())
211}
212
213#[must_use]
216pub fn preferred_index(dir: &Path) -> Option<PathBuf> {
217 let mut v: Vec<(usize, PathBuf)> = super::index_files(dir)
218 .into_iter()
219 .filter_map(|p| {
220 let ix = crate::index::BlindIndex::open(&p).ok()?;
221 Some((depth_rank(ix.source()), p))
222 })
223 .collect();
224 v.sort();
225 v.into_iter().next().map(|(_, p)| p)
226}
227
228#[must_use]
232pub fn wants_installed_index(template: &crate::pipeline::SolveParams) -> bool {
233 template.search_radius > stage_one_radius(template) && template.search_radius >= AUTO_MIN_RADIUS
234}
235
236pub(crate) const AUTO_SPIRAL_FIELDS: f64 = 5.0;
241
242const AUTO_SPIRAL_MIN: f64 = 1.0 * core::f64::consts::PI / 180.0;
244
245const AUTO_MIN_RADIUS: f64 = 10.0 * core::f64::consts::PI / 180.0;
250
251pub(crate) struct OwnIndex {
253 path: PathBuf,
254 pub(crate) explicit: bool,
257}
258
259pub(crate) fn arcsec_index_for(
264 explicit: Option<&PathBuf>,
265 template: &crate::pipeline::SolveParams,
266 automatic: bool,
267) -> Option<OwnIndex> {
268 if let Some(p) = explicit {
269 return find_arcsec_index(p).map(|path| OwnIndex {
270 path,
271 explicit: true,
272 });
273 }
274 if !automatic || !wants_installed_index(template) {
275 return None;
276 }
277 find_arcsec_index(&super::default_catalog_dir())
278 .or_else(|| find_arcsec_index(&template.db_path))
279 .map(|path| OwnIndex {
280 path,
281 explicit: false,
282 })
283}
284
285pub(crate) fn stage_one_radius(template: &crate::pipeline::SolveParams) -> f64 {
288 (template.fov * AUTO_SPIRAL_FIELDS).max(AUTO_SPIRAL_MIN)
289}
290
291pub(crate) enum IndexOutcome {
293 Solved(Box<crate::types::WcsSolution>),
296 Elsewhere(f64),
300 NotFound,
302 Cancelled,
304}
305
306pub(crate) fn index_stage(
325 img: &ImageBuffer,
326 ix: &OwnIndex,
327 template: &crate::pipeline::SolveParams,
328 has_hint: bool,
329 scale: f64,
330 scale_known: bool,
331) -> IndexOutcome {
332 use core::f64::consts::PI;
333 if !ix.explicit && has_hint {
334 let r0 = stage_one_radius(template);
335 log::info!(
336 "Searching {:.1}° round the hint before the blind index.",
337 r0.to_degrees()
338 );
339 let near = crate::pipeline::SolveParams {
340 search_radius: r0,
341 ..template.clone()
342 };
343 match crate::pipeline::solve_image(img, &near) {
344 Ok(w) => return IndexOutcome::Solved(Box::new(w)),
345 Err(ArcsecError::Cancelled) => return IndexOutcome::Cancelled,
346 Err(_) => {}
347 }
348 }
349 let within = (!ix.explicit && has_hint && template.search_radius < PI).then_some((
352 template.ra_hint,
353 template.dec_hint,
354 template.search_radius + template.fov,
355 ));
356 let from_hint = |w: &crate::types::WcsSolution| {
357 crate::math::coords::ang_sep(w.ra0, w.dec0, template.ra_hint, template.dec_hint)
358 };
359 if let Some(mut wcs) =
360 solve_with_arcsec_index(img, &ix.path, template, scale, scale_known, within)
361 {
362 wcs.search_dist_deg = from_hint(&wcs).to_degrees();
365 return IndexOutcome::Solved(Box::new(wcs));
366 }
367 if within.is_some()
368 && let Some(wcs) =
369 solve_with_arcsec_index(img, &ix.path, template, scale, scale_known, None)
370 {
371 let sep = from_hint(&wcs);
372 if beyond_reach(sep, template) {
373 log::info!(
374 "Blind index: the field is at RA={:.4}° Dec={:.4}°, {:.1}° from the hint, \
375 outside the search radius; not searching it.",
376 wcs.ra0.to_degrees(),
377 wcs.dec0.to_degrees(),
378 sep.to_degrees()
379 );
380 return IndexOutcome::Elsewhere(sep.to_degrees());
381 }
382 }
383 if crate::cancel::is_cancelled() {
384 return IndexOutcome::Cancelled;
385 }
386 IndexOutcome::NotFound
387}
388
389const ELSEWHERE_FIELDS: f64 = 2.0;
394
395fn beyond_reach(sep: f64, template: &crate::pipeline::SolveParams) -> bool {
398 sep > template.search_radius + ELSEWHERE_FIELDS * template.fov
399}
400
401fn solve_with_arcsec_index(
403 img: &ImageBuffer,
404 path: &Path,
405 template: &crate::pipeline::SolveParams,
406 scale: f64,
407 scale_known: bool,
408 within: Option<(f64, f64, f64)>,
409) -> Option<crate::types::WcsSolution> {
410 let t0 = std::time::Instant::now();
411 let index = match crate::index::BlindIndex::open(path) {
412 Ok(ix) => ix,
413 Err(e) => {
414 log::warn!("Blind index {}: {e}", path.display());
415 return None;
416 }
417 };
418 let (scale_lo, scale_hi) = if scale_known {
419 (scale / SCALE_SLACK, scale * SCALE_SLACK)
420 } else {
421 SCALE_UNKNOWN
422 };
423 log::info!(
424 "Blind index {} ({} patterns), pixel scale {scale_lo:.3}–{scale_hi:.3}\"/px",
425 path.display(),
426 index.n_patterns()
427 );
428 let params = crate::pipeline::IndexSolveParams {
429 scale_lo,
430 scale_hi,
431 within,
432 };
433 match crate::pipeline::index_solve(img, &index, template, ¶ms) {
434 Ok((wcs, stats)) => {
435 log::info!(
436 "Blind index: solved in {:.2} s (hypothesis rank {:?}, score {}, {} hinted solves)",
437 t0.elapsed().as_secs_f64(),
438 stats.accepted_rank,
439 stats.best_score,
440 stats.verified
441 );
442 Some(wcs)
443 }
444 Err(e) => {
445 log::info!(
446 "Blind index: no solution after {:.2} s: {e}",
447 t0.elapsed().as_secs_f64()
448 );
449 None
450 }
451 }
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457
458 #[test]
459 fn index_names_are_recognised() {
460 assert!(is_index_name("index-4107.fits"));
461 assert!(is_index_name("index-5200-07.fits"));
462 assert!(!is_index_name("index-4107.fits.part"));
463 assert!(!is_index_name("d50_0101.1476"));
464 }
465
466 #[test]
467 fn only_a_field_two_fields_past_the_radius_is_beyond_reach() {
468 use crate::pipeline::{SearchSpeed, SolveMethod, SolveParams};
469 let deg = f64::to_radians;
470 let t = SolveParams {
471 ra_hint: 0.0,
472 dec_hint: 0.0,
473 fov: deg(0.5),
474 search_radius: deg(10.0),
475 quad_tolerance: 0.007,
476 hfd_min: 1.5,
477 max_stars: 500,
478 db_path: PathBuf::new(),
479 db_name: "d80".into(),
480 binning: 1,
481 method: SolveMethod::Quads,
482 speed: SearchSpeed::Auto,
483 threads: 1,
484 };
485 assert!(!beyond_reach(deg(5.0), &t));
486 assert!(!beyond_reach(deg(10.9), &t));
487 assert!(beyond_reach(deg(11.1), &t));
488 assert!(beyond_reach(deg(40.0), &t));
489 }
490
491 #[test]
492 fn a_missing_path_yields_no_indexes() {
493 assert!(collect_index_files(Path::new("/nonexistent/arcsec/indexes"), 1.0).is_empty());
494 }
495}