1mod blind;
36mod db;
37
38use alloc::borrow::Cow;
39use core::f64::consts::PI;
40use std::path::{Path, PathBuf};
41
42pub use blind::{
43 SOURCES, collect_index_files, depth_rank, find_arcsec_index, preferred_index,
44 wants_installed_index,
45};
46pub use db::{
47 ASTAP_EXTS, DB_FOV_RANGES, available_dbs, default_db_path, has_star_database, select_db_for_fov,
48};
49
50use crate::cancel::CancelToken;
51use crate::error::{ArcsecError, Result};
52use crate::pipeline::{BlindSolveParams, SearchSpeed, SolveMethod, SolveParams, solve_image};
53use crate::types::{ImageBuffer, WcsSolution};
54
55#[must_use]
68pub fn default_catalog_dir() -> PathBuf {
69 default_catalog_dir_from(|k| std::env::var(k).ok())
70}
71
72fn default_catalog_dir_from(var: impl Fn(&str) -> Option<String>) -> PathBuf {
75 let get = |k: &str| var(k).filter(|v| !v.is_empty()).map(PathBuf::from);
76
77 if let Some(p) = get("ARCSEC_CATALOG_DIR") {
78 return p;
79 }
80
81 let platform = if cfg!(target_os = "windows") {
82 get("LOCALAPPDATA").map(|p| p.join("arcsec").join("catalogs"))
83 } else if cfg!(target_os = "macos") {
84 get("HOME").map(|h| {
85 h.join("Library")
86 .join("Application Support")
87 .join("arcsec")
88 .join("catalogs")
89 })
90 } else {
91 get("XDG_DATA_HOME")
92 .map(|p| p.join("arcsec").join("catalogs"))
93 .or_else(|| {
94 get("HOME").map(|h| {
95 h.join(".local")
96 .join("share")
97 .join("arcsec")
98 .join("catalogs")
99 })
100 })
101 };
102
103 platform.unwrap_or_else(|| {
104 get("HOME").or_else(|| get("USERPROFILE")).map_or_else(
105 || PathBuf::from("catalogs"),
106 |h| h.join(".arcsec").join("catalogs"),
107 )
108 })
109}
110
111#[must_use]
114pub fn index_files(dir: &Path) -> Vec<PathBuf> {
115 let mut v: Vec<PathBuf> = std::fs::read_dir(dir)
116 .map(|rd| {
117 rd.filter_map(core::result::Result::ok)
118 .map(|e| e.path())
119 .filter(|p| {
120 p.extension()
121 .is_some_and(|e| e == crate::index::format::EXTENSION)
122 })
123 .collect()
124 })
125 .unwrap_or_default();
126 v.sort();
127 v
128}
129
130pub const MIN_SOLVE_DIM: usize = 2;
135
136#[must_use]
143pub fn choose_binning(
144 requested: Option<usize>,
145 arcsec_per_px: f64,
146 width: usize,
147 height: usize,
148) -> usize {
149 let binning = match requested {
150 Some(0) | None => {
151 if arcsec_per_px < 1.0 {
152 (1.0 / arcsec_per_px).round().clamp(1.0, 16.0) as usize
153 } else {
154 1
155 }
156 }
157 Some(z) => z,
158 };
159 binning.min((width.min(height) / MIN_SOLVE_DIM).max(1))
160}
161
162#[derive(Debug, Clone)]
167pub struct SolveRequest {
168 pub hint: Option<(f64, f64)>,
172 pub fov_height: Option<f64>,
175 pub pixel_scale: Option<f64>,
179 pub search_radius: f64,
181 pub downsample: Option<usize>,
183 pub db_path: Option<PathBuf>,
185 pub db_name: Option<String>,
188 pub index: Option<PathBuf>,
193 pub index_first: bool,
198 pub auto_index: bool,
202 pub hfd_min_arcsec: f64,
204 pub quad_tolerance: f64,
206 pub max_stars: usize,
208 pub method: SolveMethod,
210 pub speed: SearchSpeed,
212 pub threads: usize,
215 pub sip: bool,
217 pub cancel: Option<CancelToken>,
219}
220
221impl Default for SolveRequest {
222 fn default() -> Self {
225 Self {
226 hint: None,
227 fov_height: None,
228 pixel_scale: None,
229 search_radius: PI,
230 downsample: None,
231 db_path: None,
232 db_name: None,
233 index: None,
234 index_first: true,
235 auto_index: true,
236 hfd_min_arcsec: 1.5,
237 quad_tolerance: 0.007,
238 max_stars: 500,
239 method: SolveMethod::Quads,
240 speed: SearchSpeed::Auto,
241 threads: 0,
242 sip: false,
243 cancel: None,
244 }
245 }
246}
247
248#[derive(Debug, Clone)]
253pub struct Plan {
254 pub start: (f64, f64),
256 pub has_hint: bool,
258 pub arcsec_per_px: f64,
260 pub scale_known: bool,
262 pub fov_height: f64,
264 pub binning: usize,
266 pub image_size: (usize, usize),
268 pub hfd_min_arcsec: f64,
270 pub params: SolveParams,
273 index: Option<PathBuf>,
274 index_first: bool,
275 auto_index: bool,
276 sip: bool,
277 cancel: Option<CancelToken>,
278}
279
280#[derive(Debug, Clone, Copy, PartialEq)]
282#[non_exhaustive]
283pub enum Event {
284 IndexEstimate(f64, f64),
287}
288
289#[derive(Debug, Clone)]
291pub struct Solved {
292 pub wcs: WcsSolution,
295 pub index_estimate: Option<(f64, f64)>,
297}
298
299impl Plan {
300 pub fn new(req: &SolveRequest, width: usize, height: usize) -> Result<Self> {
307 if width == 0 || height == 0 {
308 return Err(ArcsecError::InvalidParameter(format!(
309 "image is {width}×{height} pixels"
310 )));
311 }
312 let bad = |v: f64| !(v.is_finite() && v > 0.0);
313 if req.fov_height.is_some_and(bad) || req.pixel_scale.is_some_and(bad) {
314 return Err(ArcsecError::InvalidParameter(
315 "field of view and pixel scale must be positive".into(),
316 ));
317 }
318 let naxis = width.max(height) as f64;
322 let h = height as f64;
323 let (arcsec_per_px, scale_known) = match (req.fov_height, req.pixel_scale) {
324 (Some(fov_h), _) => (fov_h.to_degrees() * 3600.0 / h, true),
325 (None, Some(ps)) => (ps, true),
326 (None, None) => (1.0, false),
327 };
328 let fov = match req.fov_height {
329 Some(fov_h) => fov_h * (naxis / h),
330 None => (naxis * arcsec_per_px / 3600.0).to_radians(),
331 };
332 let binning = choose_binning(req.downsample, arcsec_per_px, width, height);
333
334 let db_path = req.db_path.clone().unwrap_or_else(default_db_path);
335 let db_name = req.db_name.clone().unwrap_or_else(|| {
338 select_db_for_fov(&db_path, fov.to_degrees()).unwrap_or_else(|| "d80".to_string())
339 });
340 let hfd_min = (req.hfd_min_arcsec / (binning as f64 * arcsec_per_px)).max(0.8);
341 let start = req.hint.unwrap_or((0.0, 0.0));
342 Ok(Self {
343 start,
344 has_hint: req.hint.is_some(),
345 arcsec_per_px,
346 scale_known,
347 fov_height: fov * (h / naxis),
348 binning,
349 image_size: (width, height),
350 hfd_min_arcsec: req.hfd_min_arcsec,
351 params: SolveParams {
352 ra_hint: start.0,
353 dec_hint: start.1,
354 fov,
355 search_radius: req.search_radius.max(0.0),
358 quad_tolerance: req.quad_tolerance,
359 hfd_min,
360 max_stars: req.max_stars,
361 db_path,
362 db_name,
363 binning,
364 method: req.method,
365 speed: req.speed,
366 threads: req.threads,
367 },
368 index: req.index.clone(),
369 index_first: req.index_first,
370 auto_index: req.auto_index,
371 sip: req.sip,
372 cancel: req.cancel.clone(),
373 })
374 }
375
376 #[must_use]
378 pub fn binned_size(&self) -> (usize, usize) {
379 let b = self.binning.max(1);
380 (self.image_size.0 / b, self.image_size.1 / b)
381 }
382
383 pub fn solve(&self, img: &ImageBuffer) -> Result<Solved> {
396 self.solve_with(img, |_| {})
397 }
398
399 pub fn solve_with(&self, img: &ImageBuffer, on_event: impl FnMut(Event)) -> Result<Solved> {
405 if (img.width, img.height) != self.image_size || img.data.len() != img.width * img.height {
406 return Err(ArcsecError::InvalidParameter(format!(
407 "image is {}×{}, planned for {}×{}",
408 img.width, img.height, self.image_size.0, self.image_size.1
409 )));
410 }
411 crate::cancel::with_optional(self.cancel.as_ref(), || {
412 crate::with_max_threads(self.params.threads, || self.run(img, on_event))
413 })
414 }
415
416 fn run(&self, img: &ImageBuffer, mut on_event: impl FnMut(Event)) -> Result<Solved> {
417 let (bw, bh) = self.binned_size();
418 if bw < MIN_SOLVE_DIM || bh < MIN_SOLVE_DIM {
419 return Err(ArcsecError::InsufficientStars {
420 found: 0,
421 required: 5,
422 });
423 }
424 let img: Cow<'_, ImageBuffer> = if self.binning > 1 {
425 log::info!(
426 "Creating grayscale x {0} binning image for solving/star alignment.",
427 self.binning
428 );
429 Cow::Owned(img.bin_image(self.binning))
430 } else {
431 Cow::Borrowed(img)
432 };
433 let template = &self.params;
434 let (ra_hint, dec_hint) = self.start;
435
436 let fallback = self.has_hint && !self.index_first;
444 let own_index = blind::arcsec_index_for(self.index.as_ref(), template, self.auto_index)
445 .map(|mut ix| {
446 ix.explicit &= !fallback;
447 ix
448 });
449 let index_wcs = match own_index.as_ref().map(|ix| {
450 blind::index_stage(
451 &img,
452 ix,
453 template,
454 self.has_hint,
455 self.arcsec_per_px * self.binning as f64,
456 self.scale_known,
457 )
458 }) {
459 Some(blind::IndexOutcome::Solved(w)) => Some(*w),
460 Some(blind::IndexOutcome::Elsewhere(separation_deg)) => {
461 return Err(ArcsecError::OutsideSearchRadius { separation_deg });
462 }
463 Some(blind::IndexOutcome::Cancelled) => return Err(ArcsecError::Cancelled),
464 Some(blind::IndexOutcome::NotFound) | None => None,
465 };
466
467 let mut index_estimate = None;
468 let (ra, dec, search_radius) = match &self.index {
469 None => (ra_hint, dec_hint, template.search_radius),
470 _ if index_wcs.is_some() => (ra_hint, dec_hint, template.search_radius),
471 Some(_) if own_index.is_some() => {
472 log::warn!("Blind index found no verified position. Falling back to hint.");
473 (ra_hint, dec_hint, template.search_radius)
474 }
475 Some(idx_root) => {
476 let index_files = collect_index_files(idx_root, template.fov.to_degrees());
477 if index_files.is_empty() {
478 return Err(ArcsecError::IndexNotFound(idx_root.clone()));
479 }
480 if fallback {
482 let near = SolveParams {
483 search_radius: blind::stage_one_radius(template)
484 .min(template.search_radius),
485 ..template.clone()
486 };
487 log::info!(
488 "Searching {:.1}° round the hint before the blind index.",
489 near.search_radius.to_degrees()
490 );
491 match solve_image(&img, &near) {
492 Ok(mut wcs) => {
493 if self.sip {
494 wcs.sip =
495 crate::wcs::fit_sip(&wcs, self.image_size.0, self.image_size.1);
496 }
497 return Ok(Solved {
498 wcs,
499 index_estimate: None,
500 });
501 }
502 Err(
503 e @ (ArcsecError::Cancelled
504 | ArcsecError::CatalogNotFound(_)
505 | ArcsecError::CatalogIo(_)
506 | ArcsecError::InsufficientStars { .. }),
507 ) => return Err(e),
508 Err(_) => {}
509 }
510 }
511 let params = BlindSolveParams {
512 quad_tolerance: template.quad_tolerance,
513 hfd_min: template.hfd_min,
514 max_stars: template.max_stars,
515 binning: self.binning,
516 fov_deg: self.fov_height.to_degrees(),
518 };
519 match blind::estimate_position(&img, &index_files, ¶ms) {
520 blind::BlindOutcome::Found(ra, dec) => {
521 index_estimate = Some((ra, dec));
522 on_event(Event::IndexEstimate(ra, dec));
523 (ra, dec, (template.fov * 2.0).max(5.0_f64.to_radians()))
528 }
529 blind::BlindOutcome::InsufficientStars { found, required } => {
530 return Err(ArcsecError::InsufficientStars { found, required });
531 }
532 blind::BlindOutcome::NotFound => {
533 if crate::cancel::is_cancelled() {
534 return Err(ArcsecError::Cancelled);
535 }
536 log::warn!(
537 "Blind position estimate failed for all index files. Falling back to hint."
538 );
539 (ra_hint, dec_hint, template.search_radius)
540 }
541 }
542 }
543 };
544
545 let mut wcs = match index_wcs {
546 Some(w) => w,
547 None => solve_image(
548 &img,
549 &SolveParams {
550 ra_hint: ra,
551 dec_hint: dec,
552 search_radius,
553 ..template.clone()
554 },
555 )?,
556 };
557 if self.sip {
558 wcs.sip = crate::wcs::fit_sip(&wcs, self.image_size.0, self.image_size.1);
559 }
560 Ok(Solved {
561 wcs,
562 index_estimate,
563 })
564 }
565}
566
567#[cfg(test)]
568mod tests {
569 use super::*;
570
571 #[test]
572 fn catalog_dir_is_namespaced() {
573 let d = default_catalog_dir();
575 assert!(
576 d.to_string_lossy().contains("arcsec"),
577 "catalogue dir should be namespaced: {}",
578 d.display()
579 );
580 }
581
582 #[test]
583 fn env_override_wins() {
584 let env = |k: &str| match k {
585 "ARCSEC_CATALOG_DIR" => Some("/data/catalogs".to_string()),
586 "HOME" => Some("/home/u".to_string()),
587 _ => None,
588 };
589 assert_eq!(
590 default_catalog_dir_from(env),
591 PathBuf::from("/data/catalogs")
592 );
593 }
594
595 #[test]
596 fn empty_variables_count_as_unset() {
597 let env = |k: &str| match k {
598 "ARCSEC_CATALOG_DIR" | "XDG_DATA_HOME" | "LOCALAPPDATA" => Some(String::new()),
599 "HOME" => Some("/home/u".to_string()),
600 _ => None,
601 };
602 let d = default_catalog_dir_from(env);
603 assert!(d.starts_with("/home/u"), "got {}", d.display());
604 assert!(d.ends_with("catalogs"));
605 }
606
607 #[test]
608 fn no_home_at_all_still_yields_a_path() {
609 assert_eq!(
610 default_catalog_dir_from(|_| None),
611 PathBuf::from("catalogs")
612 );
613 }
614
615 #[test]
616 fn binning_is_automatic_below_one_arcsec_per_pixel() {
617 assert_eq!(choose_binning(None, 2.0, 4000, 3000), 1);
618 assert_eq!(choose_binning(Some(0), 0.5, 4000, 3000), 2);
619 assert_eq!(choose_binning(None, 0.01, 4000, 3000), 16);
620 assert_eq!(choose_binning(Some(3), 2.0, 4000, 3000), 3);
621 }
622
623 #[test]
624 fn binning_never_exceeds_the_image() {
625 assert_eq!(choose_binning(Some(100), 1.0, 4, 4), 2);
626 assert_eq!(choose_binning(Some(usize::MAX), 1.0, 4, 4), 2);
627 assert_eq!(choose_binning(None, 0.01, 10, 50), 5);
628 assert_eq!(choose_binning(Some(4), 1.0, 1, 1), 1);
629 }
630
631 #[test]
632 fn a_plan_follows_the_cli_rules() {
633 let dir = crate::test_support::TempDir::new("auto_plan");
634 let req = SolveRequest {
635 hint: Some((1.0, 0.5)),
636 fov_height: Some(1.0_f64.to_radians()),
637 search_radius: -3.0,
638 db_path: Some(dir.path().to_path_buf()),
639 ..SolveRequest::default()
640 };
641 let p = Plan::new(&req, 4000, 2000).unwrap();
642 assert_eq!(p.start, (1.0, 0.5));
643 assert!(p.has_hint && p.scale_known);
644 assert!((p.params.fov.to_degrees() - 2.0).abs() < 1e-12);
646 assert!((p.arcsec_per_px - 1.8).abs() < 1e-12);
647 assert!((p.fov_height.to_degrees() - 1.0).abs() < 1e-12);
648 assert_eq!(p.binning, 1);
649 assert_eq!(p.params.search_radius, 0.0, "a negative radius is zero");
650 assert_eq!(p.params.db_name, "d80", "nothing installed: the fallback");
651
652 let p = Plan::new(
654 &SolveRequest {
655 pixel_scale: Some(0.25),
656 db_path: Some(dir.path().to_path_buf()),
657 ..SolveRequest::default()
658 },
659 1000,
660 1000,
661 )
662 .unwrap();
663 assert_eq!(p.binning, 4);
664 assert_eq!(p.binned_size(), (250, 250));
665 assert_eq!(p.start, (0.0, 0.0));
666 assert!(!p.has_hint);
667
668 assert!(Plan::new(&SolveRequest::default(), 0, 10).is_err());
669 let bad = SolveRequest {
670 pixel_scale: Some(f64::NAN),
671 ..SolveRequest::default()
672 };
673 assert!(Plan::new(&bad, 10, 10).is_err());
674 }
675
676 #[test]
677 fn a_plan_refuses_an_image_of_another_size() {
678 let dir = crate::test_support::TempDir::new("auto_size");
679 let req = SolveRequest {
680 db_path: Some(dir.path().to_path_buf()),
681 ..SolveRequest::default()
682 };
683 let p = Plan::new(&req, 100, 80).unwrap();
684 let err = p.solve(&ImageBuffer::new(80, 100)).unwrap_err();
685 assert!(matches!(err, ArcsecError::InvalidParameter(_)), "{err}");
686 }
687
688 #[test]
689 fn index_files_lists_only_arcsec_indexes() {
690 let dir = crate::test_support::TempDir::new("auto_ix");
691 for name in [
692 "b.arcsecix",
693 "a.arcsecix",
694 "index-4107.fits",
695 "d50_0101.1476",
696 ] {
697 std::fs::write(dir.path().join(name), b"x").unwrap();
698 }
699 let names: Vec<_> = index_files(dir.path())
700 .iter()
701 .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
702 .collect();
703 assert_eq!(names, ["a.arcsecix", "b.arcsecix"]);
704 assert!(index_files(Path::new("/nonexistent/arcsec")).is_empty());
705 assert!(has_star_database(dir.path()));
706 assert_eq!(available_dbs(dir.path()), ["d50"]);
707 }
708}