1mod blind;
38mod db;
39mod scale;
40
41use alloc::borrow::Cow;
42use core::f64::consts::PI;
43use std::path::{Path, PathBuf};
44
45pub use blind::{
46 SOURCES, collect_index_files, depth_rank, find_arcsec_index, preferred_index,
47 wants_installed_index,
48};
49pub use db::{
50 ASTAP_EXTS, DB_FOV_RANGES, available_dbs, default_db_path, has_star_database, select_db_for_fov,
51};
52pub use scale::{
53 Hypothesis, INACCURATE_SCALE, LADDER_FIELDS, SCALE_STEP, ScaleSearch, UNKNOWN_STEPS,
54 WRONG_STEPS, inaccurate_scale_warning, ladder,
55};
56
57use crate::cancel::CancelToken;
58use crate::error::{ArcsecError, Result};
59use crate::pipeline::solver::{
60 Detected, ScaleTrust, detect, search_in_order, solve_detected, solve_image_with,
61};
62use crate::pipeline::{BlindSolveParams, SearchSpeed, SolveMethod, SolveParams, solve_image};
63use crate::types::{ImageBuffer, WcsSolution};
64
65#[must_use]
78pub fn default_catalog_dir() -> PathBuf {
79 default_catalog_dir_from(|k| std::env::var(k).ok())
80}
81
82fn default_catalog_dir_from(var: impl Fn(&str) -> Option<String>) -> PathBuf {
85 let get = |k: &str| var(k).filter(|v| !v.is_empty()).map(PathBuf::from);
86
87 if let Some(p) = get("ARCSEC_CATALOG_DIR") {
88 return p;
89 }
90
91 let platform = if cfg!(target_os = "windows") {
92 get("LOCALAPPDATA").map(|p| p.join("arcsec").join("catalogs"))
93 } else if cfg!(target_os = "macos") {
94 get("HOME").map(|h| {
95 h.join("Library")
96 .join("Application Support")
97 .join("arcsec")
98 .join("catalogs")
99 })
100 } else {
101 get("XDG_DATA_HOME")
102 .map(|p| p.join("arcsec").join("catalogs"))
103 .or_else(|| {
104 get("HOME").map(|h| {
105 h.join(".local")
106 .join("share")
107 .join("arcsec")
108 .join("catalogs")
109 })
110 })
111 };
112
113 platform.unwrap_or_else(|| {
114 get("HOME").or_else(|| get("USERPROFILE")).map_or_else(
115 || PathBuf::from("catalogs"),
116 |h| h.join(".arcsec").join("catalogs"),
117 )
118 })
119}
120
121#[must_use]
124pub fn index_files(dir: &Path) -> Vec<PathBuf> {
125 let mut v: Vec<PathBuf> = std::fs::read_dir(dir)
126 .map(|rd| {
127 rd.filter_map(core::result::Result::ok)
128 .map(|e| e.path())
129 .filter(|p| {
130 p.extension()
131 .is_some_and(|e| e == crate::index::format::EXTENSION)
132 })
133 .collect()
134 })
135 .unwrap_or_default();
136 v.sort();
137 v
138}
139
140pub const MIN_SOLVE_DIM: usize = 2;
145
146#[must_use]
153pub fn choose_binning(
154 requested: Option<usize>,
155 arcsec_per_px: f64,
156 width: usize,
157 height: usize,
158) -> usize {
159 let binning = match requested {
160 Some(0) | None => {
161 if arcsec_per_px < 1.0 {
162 (1.0 / arcsec_per_px).round().clamp(1.0, 16.0) as usize
163 } else {
164 1
165 }
166 }
167 Some(z) => z,
168 };
169 binning.min((width.min(height) / MIN_SOLVE_DIM).max(1))
170}
171
172#[derive(Debug, Clone)]
177pub struct SolveRequest {
178 pub hint: Option<(f64, f64)>,
182 pub fov_height: Option<f64>,
185 pub pixel_scale: Option<f64>,
189 pub scale_search: ScaleSearch,
192 pub search_radius: f64,
194 pub downsample: Option<usize>,
196 pub db_path: Option<PathBuf>,
198 pub db_name: Option<String>,
201 pub index: Option<PathBuf>,
206 pub index_first: bool,
211 pub auto_index: bool,
215 pub hfd_min_arcsec: f64,
217 pub quad_tolerance: f64,
219 pub max_stars: usize,
221 pub method: SolveMethod,
223 pub speed: SearchSpeed,
225 pub threads: usize,
228 pub sip: bool,
230 pub cancel: Option<CancelToken>,
232}
233
234impl Default for SolveRequest {
235 fn default() -> Self {
238 Self {
239 hint: None,
240 fov_height: None,
241 pixel_scale: None,
242 scale_search: ScaleSearch::default(),
243 search_radius: PI,
244 downsample: None,
245 db_path: None,
246 db_name: None,
247 index: None,
248 index_first: true,
249 auto_index: true,
250 hfd_min_arcsec: 1.5,
251 quad_tolerance: 0.007,
252 max_stars: 500,
253 method: SolveMethod::Quads,
254 speed: SearchSpeed::Auto,
255 threads: 0,
256 sip: false,
257 cancel: None,
258 }
259 }
260}
261
262#[derive(Debug, Clone)]
267pub struct Plan {
268 pub start: (f64, f64),
270 pub has_hint: bool,
272 pub arcsec_per_px: f64,
274 pub scale_known: bool,
276 pub fov_height: f64,
278 pub binning: usize,
280 pub image_size: (usize, usize),
282 pub hfd_min_arcsec: f64,
284 pub params: SolveParams,
287 index: Option<PathBuf>,
288 index_first: bool,
289 auto_index: bool,
290 sip: bool,
291 cancel: Option<CancelToken>,
292 request: SolveRequest,
294}
295
296#[derive(Debug, Clone, Copy, PartialEq)]
298#[non_exhaustive]
299pub enum Event {
300 IndexEstimate(f64, f64),
303}
304
305#[derive(Debug, Clone)]
307pub struct Solved {
308 pub wcs: WcsSolution,
311 pub index_estimate: Option<(f64, f64)>,
313}
314
315impl Plan {
316 pub fn new(req: &SolveRequest, width: usize, height: usize) -> Result<Self> {
323 if width == 0 || height == 0 {
324 return Err(ArcsecError::InvalidParameter(format!(
325 "image is {width}×{height} pixels"
326 )));
327 }
328 let bad = |v: f64| !(v.is_finite() && v > 0.0);
329 if req.fov_height.is_some_and(bad) || req.pixel_scale.is_some_and(bad) {
330 return Err(ArcsecError::InvalidParameter(
331 "field of view and pixel scale must be positive".into(),
332 ));
333 }
334 let naxis = width.max(height) as f64;
338 let h = height as f64;
339 let (arcsec_per_px, scale_known) = match (req.fov_height, req.pixel_scale) {
340 (Some(fov_h), _) => (fov_h.to_degrees() * 3600.0 / h, true),
341 (None, Some(ps)) => (ps, true),
342 (None, None) => (1.0, false),
343 };
344 let fov = match req.fov_height {
345 Some(fov_h) => fov_h * (naxis / h),
346 None => (naxis * arcsec_per_px / 3600.0).to_radians(),
347 };
348 let binning = choose_binning(req.downsample, arcsec_per_px, width, height);
349
350 let db_path = req.db_path.clone().unwrap_or_else(default_db_path);
351 let db_name = req.db_name.clone().unwrap_or_else(|| {
354 select_db_for_fov(&db_path, fov.to_degrees()).unwrap_or_else(|| "d80".to_string())
355 });
356 let hfd_min = (req.hfd_min_arcsec / (binning as f64 * arcsec_per_px)).max(0.8);
357 let start = req.hint.unwrap_or((0.0, 0.0));
358 Ok(Self {
359 start,
360 has_hint: req.hint.is_some(),
361 arcsec_per_px,
362 scale_known,
363 fov_height: fov * (h / naxis),
364 binning,
365 image_size: (width, height),
366 hfd_min_arcsec: req.hfd_min_arcsec,
367 params: SolveParams {
368 ra_hint: start.0,
369 dec_hint: start.1,
370 fov,
371 search_radius: req.search_radius.max(0.0),
374 quad_tolerance: req.quad_tolerance,
375 hfd_min,
376 max_stars: req.max_stars,
377 db_path,
378 db_name,
379 binning,
380 method: req.method,
381 speed: req.speed,
382 threads: req.threads,
383 },
384 index: req.index.clone(),
385 index_first: req.index_first,
386 auto_index: req.auto_index,
387 sip: req.sip,
388 cancel: req.cancel.clone(),
389 request: req.clone(),
390 })
391 }
392
393 #[must_use]
397 pub fn scale_warning(&self, wcs: &WcsSolution) -> Option<String> {
398 let solved = (wcs.cd1_1 * wcs.cd2_2 - wcs.cd1_2 * wcs.cd2_1).abs().sqrt() * 3600.0;
399 inaccurate_scale_warning(self.arcsec_per_px, solved, self.image_size.1)
400 }
401
402 #[must_use]
407 pub fn searches_scales(&self) -> bool {
408 match self.request.scale_search {
409 ScaleSearch::Never => false,
410 ScaleSearch::IfUnknown => !self.scale_known,
411 ScaleSearch::AlsoIfWrong => true,
412 }
413 }
414
415 #[must_use]
417 pub fn binned_size(&self) -> (usize, usize) {
418 let b = self.binning.max(1);
419 (self.image_size.0 / b, self.image_size.1 / b)
420 }
421
422 pub fn solve(&self, img: &ImageBuffer) -> Result<Solved> {
435 self.solve_with(img, |_| {})
436 }
437
438 pub fn solve_with(&self, img: &ImageBuffer, on_event: impl FnMut(Event)) -> Result<Solved> {
444 if (img.width, img.height) != self.image_size || img.data.len() != img.width * img.height {
445 return Err(ArcsecError::InvalidParameter(format!(
446 "image is {}×{}, planned for {}×{}",
447 img.width, img.height, self.image_size.0, self.image_size.1
448 )));
449 }
450 if !self.scale_known {
451 log::warn!(
452 "No pixel scale given (a field of view, or FOCALLEN and XPIXSZ in the header): {}",
453 if self.searches_scales() {
454 "searching scales from 0.25 to 64\"/px round the hint, then 1\"/px"
455 } else {
456 "assuming 1\"/px, which will not solve unless it is roughly right"
457 }
458 );
459 }
460 crate::cancel::with_optional(self.cancel.as_ref(), || {
461 crate::with_max_threads(self.params.threads, || self.run(img, on_event))
462 })
463 }
464
465 fn run(&self, unbinned: &ImageBuffer, mut on_event: impl FnMut(Event)) -> Result<Solved> {
466 let img = unbinned;
467 let (bw, bh) = self.binned_size();
468 if bw < MIN_SOLVE_DIM || bh < MIN_SOLVE_DIM {
469 return Err(ArcsecError::InsufficientStars {
470 found: 0,
471 required: 5,
472 });
473 }
474 let img: Cow<'_, ImageBuffer> = if self.binning > 1 {
475 log::info!(
476 "Creating grayscale x {0} binning image for solving/star alignment.",
477 self.binning
478 );
479 Cow::Owned(img.bin_image(self.binning))
480 } else {
481 Cow::Borrowed(img)
482 };
483 let template = &self.params;
484 let (ra_hint, dec_hint) = self.start;
485
486 let fallback = self.has_hint && !self.index_first;
494 let own_index = blind::arcsec_index_for(self.index.as_ref(), template, self.auto_index)
495 .map(|mut ix| {
496 ix.explicit &= !fallback;
497 ix
498 });
499 let index_wcs = match own_index.as_ref().map(|ix| {
500 blind::index_stage(
501 &img,
502 ix,
503 template,
504 self.has_hint,
505 self.arcsec_per_px * self.binning as f64,
506 self.scale_known,
507 )
508 }) {
509 Some(blind::IndexOutcome::Solved(w)) => Some(*w),
510 Some(blind::IndexOutcome::Elsewhere(separation_deg)) => {
511 return Err(ArcsecError::OutsideSearchRadius { separation_deg });
512 }
513 Some(blind::IndexOutcome::Cancelled) => return Err(ArcsecError::Cancelled),
514 Some(blind::IndexOutcome::NotFound) | None => None,
515 };
516
517 let mut index_estimate = None;
518 let (ra, dec, search_radius) = match &self.index {
519 None => (ra_hint, dec_hint, template.search_radius),
520 _ if index_wcs.is_some() => (ra_hint, dec_hint, template.search_radius),
521 Some(_) if own_index.is_some() => {
522 log::warn!("Blind index found no verified position. Falling back to hint.");
523 (ra_hint, dec_hint, template.search_radius)
524 }
525 Some(idx_root) => {
526 let index_files = collect_index_files(idx_root, template.fov.to_degrees());
527 if index_files.is_empty() {
528 return Err(ArcsecError::IndexNotFound(idx_root.clone()));
529 }
530 if fallback {
532 let near = SolveParams {
533 search_radius: blind::stage_one_radius(template)
534 .min(template.search_radius),
535 ..template.clone()
536 };
537 log::info!(
538 "Searching {:.1}° round the hint before the blind index.",
539 near.search_radius.to_degrees()
540 );
541 match solve_image(&img, &near) {
542 Ok(mut wcs) => {
543 if self.sip {
544 wcs.sip =
545 crate::wcs::fit_sip(&wcs, self.image_size.0, self.image_size.1);
546 }
547 return Ok(Solved {
548 wcs,
549 index_estimate: None,
550 });
551 }
552 Err(
553 e @ (ArcsecError::Cancelled
554 | ArcsecError::CatalogNotFound(_)
555 | ArcsecError::CatalogIo(_)
556 | ArcsecError::InsufficientStars { .. }),
557 ) => return Err(e),
558 Err(_) => {}
559 }
560 }
561 let params = BlindSolveParams {
562 quad_tolerance: template.quad_tolerance,
563 hfd_min: template.hfd_min,
564 max_stars: template.max_stars,
565 binning: self.binning,
566 fov_deg: self.fov_height.to_degrees(),
568 };
569 match blind::estimate_position(&img, &index_files, ¶ms) {
570 blind::BlindOutcome::Found(ra, dec) => {
571 index_estimate = Some((ra, dec));
572 on_event(Event::IndexEstimate(ra, dec));
573 (ra, dec, (template.fov * 2.0).max(5.0_f64.to_radians()))
578 }
579 blind::BlindOutcome::InsufficientStars { found, required } => {
580 return Err(ArcsecError::InsufficientStars { found, required });
581 }
582 blind::BlindOutcome::NotFound => {
583 if crate::cancel::is_cancelled() {
584 return Err(ArcsecError::Cancelled);
585 }
586 log::warn!(
587 "Blind position estimate failed for all index files. Falling back to hint."
588 );
589 (ra_hint, dec_hint, template.search_radius)
590 }
591 }
592 }
593 };
594
595 let mut wcs = match index_wcs {
596 Some(w) => w,
597 None => self.catalogue_solve(unbinned, &img, (ra, dec), search_radius)?,
598 };
599 if self.sip {
600 wcs.sip = crate::wcs::fit_sip(&wcs, self.image_size.0, self.image_size.1);
601 }
602 Ok(Solved {
603 wcs,
604 index_estimate,
605 })
606 }
607}
608
609impl Plan {
610 fn catalogue_solve(
615 &self,
616 unbinned: &ImageBuffer,
617 img: &ImageBuffer,
618 start: (f64, f64),
619 radius: f64,
620 ) -> Result<WcsSolution> {
621 let wcs = self.catalogue_search(unbinned, img, start, radius)?;
622 Ok(self.refine(unbinned, &wcs).unwrap_or(wcs))
623 }
624
625 fn refine(&self, unbinned: &ImageBuffer, wcs: &WcsSolution) -> Option<WcsSolution> {
640 let solved = (wcs.cd1_1 * wcs.cd2_2 - wcs.cd1_2 * wcs.cd2_1).abs().sqrt() * 3600.0;
641 let off = (solved / self.arcsec_per_px - 1.0).abs();
642 if off.is_nan() || off <= INACCURATE_SCALE {
643 return None;
644 }
645 let (w, h) = self.image_size;
646 let req = SolveRequest {
647 hint: Some((wcs.ra0, wcs.dec0)),
648 fov_height: None,
649 pixel_scale: Some(solved),
650 scale_search: ScaleSearch::Never,
651 search_radius: 0.0,
652 index: None,
653 auto_index: false,
654 sip: false,
655 cancel: None,
656 ..self.request.clone()
657 };
658 let p = Plan::new(&req, w, h).ok()?;
659 let img: Cow<'_, ImageBuffer> = if p.binning > 1 {
660 Cow::Owned(unbinned.bin_image(p.binning))
661 } else {
662 Cow::Borrowed(unbinned)
663 };
664 let mut r = solve_image_with(&img, &p.params, ScaleTrust::Hypothesis).ok()?;
665 let sep = crate::math::coords::ang_sep(r.ra0, r.dec0, wcs.ra0, wcs.dec0);
666 log::info!(
667 "Solved again at {solved:.3}\"/px: {} stars verified (first {}), {:.1}\" from the first solution",
668 r.stars_matched,
669 wcs.stars_matched,
670 sep.to_degrees() * 3600.0
671 );
672 if sep > 0.1 * p.params.fov {
673 return None;
674 }
675 r.search_dist_deg = wcs.search_dist_deg;
677 r.step_distances.clone_from(&wcs.step_distances);
678 Some(r)
679 }
680
681 fn catalogue_search(
690 &self,
691 unbinned: &ImageBuffer,
692 img: &ImageBuffer,
693 start: (f64, f64),
694 radius: f64,
695 ) -> Result<WcsSolution> {
696 let params = SolveParams {
697 ra_hint: start.0,
698 dec_hint: start.1,
699 search_radius: radius,
700 ..self.params.clone()
701 };
702 if !self.searches_scales() {
703 return solve_image(img, ¶ms);
704 }
705 if !self.scale_known {
706 let (lo, hi) = UNKNOWN_STEPS;
707 let hyps = ladder(self.arcsec_per_px, lo, hi, false);
708 if let Some(w) = self.scale_ladder(unbinned, img, start, radius, &hyps)? {
709 return Ok(w);
710 }
711 log::info!(
712 "No scale solved near the hint; searching {:.1}° at {:.2}\"/px.",
713 radius.to_degrees(),
714 self.arcsec_per_px
715 );
716 return solve_image(img, ¶ms);
717 }
718 match solve_image(img, ¶ms) {
719 Err(e @ ArcsecError::InsufficientQuads { .. }) => {
720 let hyps = ladder(self.arcsec_per_px, -WRONG_STEPS, WRONG_STEPS, true);
721 log::info!(
722 "No solution at {:.3}\"/px; trying other scales round the hint.",
723 self.arcsec_per_px
724 );
725 self.scale_ladder(unbinned, img, start, radius, &hyps)?
726 .ok_or(e)
727 }
728 r => r,
729 }
730 }
731
732 fn scale_ladder(
750 &self,
751 unbinned: &ImageBuffer,
752 img: &ImageBuffer,
753 start: (f64, f64),
754 radius: f64,
755 hyps: &[Hypothesis],
756 ) -> Result<Option<WcsSolution>> {
757 let (w, h) = self.image_size;
758 let naxis = w.max(h) as f64;
759 let installed = available_dbs(&self.params.db_path);
760 let covers = |fov_deg: f64| {
761 let fits = |name: &str| {
762 DB_FOV_RANGES.iter().find(|r| r.0 == name).map_or(
763 self.request.db_name.is_some(),
765 |&(_, lo, hi, _)| fov_deg >= lo / 2.0 && fov_deg <= hi * 2.0,
766 )
767 };
768 match &self.request.db_name {
769 Some(name) => fits(name),
770 None => installed.iter().any(|d| fits(d)),
771 }
772 };
773 let plans: Vec<(Hypothesis, Plan)> = hyps
774 .iter()
775 .filter_map(|&hy| {
776 let fov = (naxis * hy.scale / 3600.0).to_radians();
777 if !covers(fov.to_degrees()) {
778 return None;
779 }
780 let req = SolveRequest {
781 hint: Some(start),
782 fov_height: None,
783 pixel_scale: Some(hy.scale),
784 scale_search: ScaleSearch::Never,
785 search_radius: radius.min(LADDER_FIELDS * fov),
786 index: None,
787 auto_index: false,
788 sip: false,
789 cancel: None,
790 ..self.request.clone()
791 };
792 let p = Plan::new(&req, w, h).ok()?;
793 let (bw, bh) = p.binned_size();
794 (bw >= MIN_SOLVE_DIM && bh >= MIN_SOLVE_DIM).then_some((hy, p))
795 })
796 .collect();
797 if plans.is_empty() {
798 return Ok(None);
799 }
800 log::info!(
801 "Trying {} pixel scales, {:.3}–{:.3}\"/px, {} field round the hint each.",
802 plans.len(),
803 plans
804 .iter()
805 .map(|(h, _)| h.scale)
806 .fold(f64::INFINITY, f64::min),
807 plans.iter().map(|(h, _)| h.scale).fold(0.0, f64::max),
808 LADDER_FIELDS
809 );
810
811 let mut binned: Vec<(usize, Cow<'_, ImageBuffer>)> =
813 vec![(self.binning, Cow::Borrowed(img))];
814 for (_, p) in &plans {
815 if binned.iter().all(|(b, _)| *b != p.binning) {
816 let b = if p.binning > 1 {
817 Cow::Owned(unbinned.bin_image(p.binning))
818 } else {
819 Cow::Borrowed(unbinned)
820 };
821 binned.push((p.binning, b));
822 }
823 }
824 let image_for = |b: usize| {
825 binned.iter().find(|(bb, _)| *bb == b).map_or_else(
826 || unreachable!("binning {b} was made above"),
827 |(_, i)| i.as_ref(),
828 )
829 };
830
831 let n_threads = if self.params.threads > 0 {
832 self.params.threads
833 } else {
834 crate::max_threads()
835 }
836 .clamp(1, 64);
837 let key = |p: &Plan| (p.binning, p.params.hfd_min.to_bits());
842 let mut keys: Vec<(usize, u64)> = plans.iter().map(|(_, p)| key(p)).collect();
843 keys.sort_unstable();
844 keys.dedup();
845 let detections: Vec<std::sync::OnceLock<Detected>> =
846 keys.iter().map(|_| std::sync::OnceLock::new()).collect();
847 let detect_threads = (n_threads / keys.len()).max(1);
848
849 let cancel = crate::cancel::current();
850 let found = alloc::sync::Arc::new(core::sync::atomic::AtomicUsize::new(usize::MAX));
853 let (_, winner) = search_in_order(plans.len(), n_threads, |i| {
854 use core::sync::atomic::Ordering::Relaxed;
855 let (hy, p) = &plans[i];
856 if crate::cancel::fired(cancel.as_ref()) || found.load(Relaxed) < i {
857 return (None, None);
858 }
859 let token = {
860 let (found, outer) = (alloc::sync::Arc::clone(&found), cancel.clone());
861 CancelToken::with_poll(move || {
862 found.load(Relaxed) < i || crate::cancel::fired(outer.as_ref())
863 })
864 };
865 let threads = if i == 0 { n_threads } else { 1 };
866 let params = SolveParams {
867 threads,
868 ..p.params.clone()
869 };
870 log::info!(
871 "Scale hypothesis {:.3}\"/px ({:.2}° field, binning {}, star database {})",
872 hy.scale,
873 p.params.fov.to_degrees(),
874 p.binning,
875 p.params.db_name.to_uppercase()
876 );
877 let img = image_for(p.binning);
878 let k = keys
879 .binary_search(&key(p))
880 .unwrap_or_else(|_| unreachable!());
881 let solved = crate::cancel::with_token(&token, || {
882 let stars = detections[k].get_or_init(|| {
883 let t = if i == 0 { n_threads } else { detect_threads };
884 crate::with_max_threads(t, || detect(img, ¶ms))
885 });
886 crate::with_max_threads(threads, || {
887 solve_detected(img, ¶ms, ScaleTrust::Hypothesis, stars)
888 })
889 });
890 if solved.is_ok() {
891 found.fetch_min(i, Relaxed);
892 }
893 (None, solved.ok().map(|w| (hy.scale, w)))
894 });
895 if crate::cancel::fired(cancel.as_ref()) {
896 return Err(ArcsecError::Cancelled);
897 }
898 Ok(winner.map(|(_, (scale, wcs))| {
899 log::info!("Solved at the scale hypothesis {scale:.3}\"/px.");
900 wcs
901 }))
902 }
903}
904
905#[cfg(test)]
906mod tests {
907 use super::*;
908
909 #[test]
910 fn catalog_dir_is_namespaced() {
911 let d = default_catalog_dir();
913 assert!(
914 d.to_string_lossy().contains("arcsec"),
915 "catalogue dir should be namespaced: {}",
916 d.display()
917 );
918 }
919
920 #[test]
921 fn env_override_wins() {
922 let env = |k: &str| match k {
923 "ARCSEC_CATALOG_DIR" => Some("/data/catalogs".to_string()),
924 "HOME" => Some("/home/u".to_string()),
925 _ => None,
926 };
927 assert_eq!(
928 default_catalog_dir_from(env),
929 PathBuf::from("/data/catalogs")
930 );
931 }
932
933 #[test]
934 fn empty_variables_count_as_unset() {
935 let env = |k: &str| match k {
936 "ARCSEC_CATALOG_DIR" | "XDG_DATA_HOME" | "LOCALAPPDATA" => Some(String::new()),
937 "HOME" => Some("/home/u".to_string()),
938 _ => None,
939 };
940 let d = default_catalog_dir_from(env);
941 assert!(d.starts_with("/home/u"), "got {}", d.display());
942 assert!(d.ends_with("catalogs"));
943 }
944
945 #[test]
946 fn no_home_at_all_still_yields_a_path() {
947 assert_eq!(
948 default_catalog_dir_from(|_| None),
949 PathBuf::from("catalogs")
950 );
951 }
952
953 #[test]
954 fn binning_is_automatic_below_one_arcsec_per_pixel() {
955 assert_eq!(choose_binning(None, 2.0, 4000, 3000), 1);
956 assert_eq!(choose_binning(Some(0), 0.5, 4000, 3000), 2);
957 assert_eq!(choose_binning(None, 0.01, 4000, 3000), 16);
958 assert_eq!(choose_binning(Some(3), 2.0, 4000, 3000), 3);
959 }
960
961 #[test]
962 fn binning_never_exceeds_the_image() {
963 assert_eq!(choose_binning(Some(100), 1.0, 4, 4), 2);
964 assert_eq!(choose_binning(Some(usize::MAX), 1.0, 4, 4), 2);
965 assert_eq!(choose_binning(None, 0.01, 10, 50), 5);
966 assert_eq!(choose_binning(Some(4), 1.0, 1, 1), 1);
967 }
968
969 #[test]
970 fn a_plan_follows_the_cli_rules() {
971 let dir = crate::test_support::TempDir::new("auto_plan");
972 let req = SolveRequest {
973 hint: Some((1.0, 0.5)),
974 fov_height: Some(1.0_f64.to_radians()),
975 search_radius: -3.0,
976 db_path: Some(dir.path().to_path_buf()),
977 ..SolveRequest::default()
978 };
979 let p = Plan::new(&req, 4000, 2000).unwrap();
980 assert_eq!(p.start, (1.0, 0.5));
981 assert!(p.has_hint && p.scale_known);
982 assert!((p.params.fov.to_degrees() - 2.0).abs() < 1e-12);
984 assert!((p.arcsec_per_px - 1.8).abs() < 1e-12);
985 assert!((p.fov_height.to_degrees() - 1.0).abs() < 1e-12);
986 assert_eq!(p.binning, 1);
987 assert_eq!(p.params.search_radius, 0.0, "a negative radius is zero");
988 assert_eq!(p.params.db_name, "d80", "nothing installed: the fallback");
989
990 let p = Plan::new(
992 &SolveRequest {
993 pixel_scale: Some(0.25),
994 db_path: Some(dir.path().to_path_buf()),
995 ..SolveRequest::default()
996 },
997 1000,
998 1000,
999 )
1000 .unwrap();
1001 assert_eq!(p.binning, 4);
1002 assert_eq!(p.binned_size(), (250, 250));
1003 assert_eq!(p.start, (0.0, 0.0));
1004 assert!(!p.has_hint);
1005
1006 assert!(Plan::new(&SolveRequest::default(), 0, 10).is_err());
1007 let bad = SolveRequest {
1008 pixel_scale: Some(f64::NAN),
1009 ..SolveRequest::default()
1010 };
1011 assert!(Plan::new(&bad, 10, 10).is_err());
1012 }
1013
1014 #[test]
1015 fn a_plan_refuses_an_image_of_another_size() {
1016 let dir = crate::test_support::TempDir::new("auto_size");
1017 let req = SolveRequest {
1018 db_path: Some(dir.path().to_path_buf()),
1019 ..SolveRequest::default()
1020 };
1021 let p = Plan::new(&req, 100, 80).unwrap();
1022 let err = p.solve(&ImageBuffer::new(80, 100)).unwrap_err();
1023 assert!(matches!(err, ArcsecError::InvalidParameter(_)), "{err}");
1024 }
1025
1026 #[test]
1027 fn index_files_lists_only_arcsec_indexes() {
1028 let dir = crate::test_support::TempDir::new("auto_ix");
1029 for name in [
1030 "b.arcsecix",
1031 "a.arcsecix",
1032 "index-4107.fits",
1033 "d50_0101.1476",
1034 ] {
1035 std::fs::write(dir.path().join(name), b"x").unwrap();
1036 }
1037 let names: Vec<_> = index_files(dir.path())
1038 .iter()
1039 .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
1040 .collect();
1041 assert_eq!(names, ["a.arcsecix", "b.arcsecix"]);
1042 assert!(index_files(Path::new("/nonexistent/arcsec")).is_empty());
1043 assert!(has_star_database(dir.path()));
1044 assert_eq!(available_dbs(dir.path()), ["d50"]);
1045 }
1046}