mod blind;
mod db;
mod scale;
use alloc::borrow::Cow;
use core::f64::consts::PI;
use std::path::{Path, PathBuf};
pub use blind::{
SOURCES, collect_index_files, depth_rank, find_arcsec_index, preferred_index,
wants_installed_index,
};
pub use db::{
ASTAP_EXTS, DB_FOV_RANGES, available_dbs, default_db_path, has_star_database, select_db_for_fov,
};
pub use scale::{
Hypothesis, INACCURATE_SCALE, LADDER_FIELDS, SCALE_STEP, ScaleSearch, UNKNOWN_STEPS,
WRONG_STEPS, inaccurate_scale_warning, ladder,
};
use crate::cancel::CancelToken;
use crate::error::{ArcsecError, Result};
use crate::pipeline::solver::{
Detected, ScaleTrust, detect, search_in_order, solve_detected, solve_image_with,
};
use crate::pipeline::{BlindSolveParams, SearchSpeed, SolveMethod, SolveParams, solve_image};
use crate::types::{ImageBuffer, WcsSolution};
#[must_use]
pub fn default_catalog_dir() -> PathBuf {
default_catalog_dir_from(|k| std::env::var(k).ok())
}
fn default_catalog_dir_from(var: impl Fn(&str) -> Option<String>) -> PathBuf {
let get = |k: &str| var(k).filter(|v| !v.is_empty()).map(PathBuf::from);
if let Some(p) = get("ARCSEC_CATALOG_DIR") {
return p;
}
let platform = if cfg!(target_os = "windows") {
get("LOCALAPPDATA").map(|p| p.join("arcsec").join("catalogs"))
} else if cfg!(target_os = "macos") {
get("HOME").map(|h| {
h.join("Library")
.join("Application Support")
.join("arcsec")
.join("catalogs")
})
} else {
get("XDG_DATA_HOME")
.map(|p| p.join("arcsec").join("catalogs"))
.or_else(|| {
get("HOME").map(|h| {
h.join(".local")
.join("share")
.join("arcsec")
.join("catalogs")
})
})
};
platform.unwrap_or_else(|| {
get("HOME").or_else(|| get("USERPROFILE")).map_or_else(
|| PathBuf::from("catalogs"),
|h| h.join(".arcsec").join("catalogs"),
)
})
}
#[must_use]
pub fn index_files(dir: &Path) -> Vec<PathBuf> {
let mut v: Vec<PathBuf> = std::fs::read_dir(dir)
.map(|rd| {
rd.filter_map(core::result::Result::ok)
.map(|e| e.path())
.filter(|p| {
p.extension()
.is_some_and(|e| e == crate::index::format::EXTENSION)
})
.collect()
})
.unwrap_or_default();
v.sort();
v
}
pub const MIN_SOLVE_DIM: usize = 2;
#[must_use]
pub fn choose_binning(
requested: Option<usize>,
arcsec_per_px: f64,
width: usize,
height: usize,
) -> usize {
let binning = match requested {
Some(0) | None => {
if arcsec_per_px < 1.0 {
(1.0 / arcsec_per_px).round().clamp(1.0, 16.0) as usize
} else {
1
}
}
Some(z) => z,
};
binning.min((width.min(height) / MIN_SOLVE_DIM).max(1))
}
#[derive(Debug, Clone)]
pub struct SolveRequest {
pub hint: Option<(f64, f64)>,
pub fov_height: Option<f64>,
pub pixel_scale: Option<f64>,
pub scale_search: ScaleSearch,
pub search_radius: f64,
pub downsample: Option<usize>,
pub db_path: Option<PathBuf>,
pub db_name: Option<String>,
pub index: Option<PathBuf>,
pub index_first: bool,
pub auto_index: bool,
pub hfd_min_arcsec: f64,
pub quad_tolerance: f64,
pub max_stars: usize,
pub method: SolveMethod,
pub speed: SearchSpeed,
pub threads: usize,
pub sip: bool,
pub cancel: Option<CancelToken>,
}
impl Default for SolveRequest {
fn default() -> Self {
Self {
hint: None,
fov_height: None,
pixel_scale: None,
scale_search: ScaleSearch::default(),
search_radius: PI,
downsample: None,
db_path: None,
db_name: None,
index: None,
index_first: true,
auto_index: true,
hfd_min_arcsec: 1.5,
quad_tolerance: 0.007,
max_stars: 500,
method: SolveMethod::Quads,
speed: SearchSpeed::Auto,
threads: 0,
sip: false,
cancel: None,
}
}
}
#[derive(Debug, Clone)]
pub struct Plan {
pub start: (f64, f64),
pub has_hint: bool,
pub arcsec_per_px: f64,
pub scale_known: bool,
pub fov_height: f64,
pub binning: usize,
pub image_size: (usize, usize),
pub hfd_min_arcsec: f64,
pub params: SolveParams,
index: Option<PathBuf>,
index_first: bool,
auto_index: bool,
sip: bool,
cancel: Option<CancelToken>,
request: SolveRequest,
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub enum Event {
IndexEstimate(f64, f64),
}
#[derive(Debug, Clone)]
pub struct Solved {
pub wcs: WcsSolution,
pub index_estimate: Option<(f64, f64)>,
}
impl Plan {
pub fn new(req: &SolveRequest, width: usize, height: usize) -> Result<Self> {
if width == 0 || height == 0 {
return Err(ArcsecError::InvalidParameter(format!(
"image is {width}×{height} pixels"
)));
}
let bad = |v: f64| !(v.is_finite() && v > 0.0);
if req.fov_height.is_some_and(bad) || req.pixel_scale.is_some_and(bad) {
return Err(ArcsecError::InvalidParameter(
"field of view and pixel scale must be positive".into(),
));
}
let naxis = width.max(height) as f64;
let h = height as f64;
let (arcsec_per_px, scale_known) = match (req.fov_height, req.pixel_scale) {
(Some(fov_h), _) => (fov_h.to_degrees() * 3600.0 / h, true),
(None, Some(ps)) => (ps, true),
(None, None) => (1.0, false),
};
let fov = match req.fov_height {
Some(fov_h) => fov_h * (naxis / h),
None => (naxis * arcsec_per_px / 3600.0).to_radians(),
};
let binning = choose_binning(req.downsample, arcsec_per_px, width, height);
let db_path = req.db_path.clone().unwrap_or_else(default_db_path);
let db_name = req.db_name.clone().unwrap_or_else(|| {
select_db_for_fov(&db_path, fov.to_degrees()).unwrap_or_else(|| "d80".to_string())
});
let hfd_min = (req.hfd_min_arcsec / (binning as f64 * arcsec_per_px)).max(0.8);
let start = req.hint.unwrap_or((0.0, 0.0));
Ok(Self {
start,
has_hint: req.hint.is_some(),
arcsec_per_px,
scale_known,
fov_height: fov * (h / naxis),
binning,
image_size: (width, height),
hfd_min_arcsec: req.hfd_min_arcsec,
params: SolveParams {
ra_hint: start.0,
dec_hint: start.1,
fov,
search_radius: req.search_radius.max(0.0),
quad_tolerance: req.quad_tolerance,
hfd_min,
max_stars: req.max_stars,
db_path,
db_name,
binning,
method: req.method,
speed: req.speed,
threads: req.threads,
},
index: req.index.clone(),
index_first: req.index_first,
auto_index: req.auto_index,
sip: req.sip,
cancel: req.cancel.clone(),
request: req.clone(),
})
}
#[must_use]
pub fn scale_warning(&self, wcs: &WcsSolution) -> Option<String> {
let solved = (wcs.cd1_1 * wcs.cd2_2 - wcs.cd1_2 * wcs.cd2_1).abs().sqrt() * 3600.0;
inaccurate_scale_warning(self.arcsec_per_px, solved, self.image_size.1)
}
#[must_use]
pub fn searches_scales(&self) -> bool {
match self.request.scale_search {
ScaleSearch::Never => false,
ScaleSearch::IfUnknown => !self.scale_known,
ScaleSearch::AlsoIfWrong => true,
}
}
#[must_use]
pub fn binned_size(&self) -> (usize, usize) {
let b = self.binning.max(1);
(self.image_size.0 / b, self.image_size.1 / b)
}
pub fn solve(&self, img: &ImageBuffer) -> Result<Solved> {
self.solve_with(img, |_| {})
}
pub fn solve_with(&self, img: &ImageBuffer, on_event: impl FnMut(Event)) -> Result<Solved> {
if (img.width, img.height) != self.image_size || img.data.len() != img.width * img.height {
return Err(ArcsecError::InvalidParameter(format!(
"image is {}×{}, planned for {}×{}",
img.width, img.height, self.image_size.0, self.image_size.1
)));
}
if !self.scale_known {
log::warn!(
"No pixel scale given (a field of view, or FOCALLEN and XPIXSZ in the header): {}",
if self.searches_scales() {
"searching scales from 0.25 to 64\"/px round the hint, then 1\"/px"
} else {
"assuming 1\"/px, which will not solve unless it is roughly right"
}
);
}
crate::cancel::with_optional(self.cancel.as_ref(), || {
crate::with_max_threads(self.params.threads, || self.run(img, on_event))
})
}
fn run(&self, unbinned: &ImageBuffer, mut on_event: impl FnMut(Event)) -> Result<Solved> {
let img = unbinned;
let (bw, bh) = self.binned_size();
if bw < MIN_SOLVE_DIM || bh < MIN_SOLVE_DIM {
return Err(ArcsecError::InsufficientStars {
found: 0,
required: 5,
});
}
let img: Cow<'_, ImageBuffer> = if self.binning > 1 {
log::info!(
"Creating grayscale x {0} binning image for solving/star alignment.",
self.binning
);
Cow::Owned(img.bin_image(self.binning))
} else {
Cow::Borrowed(img)
};
let template = &self.params;
let (ra_hint, dec_hint) = self.start;
let fallback = self.has_hint && !self.index_first;
let own_index = blind::arcsec_index_for(self.index.as_ref(), template, self.auto_index)
.map(|mut ix| {
ix.explicit &= !fallback;
ix
});
let index_wcs = match own_index.as_ref().map(|ix| {
blind::index_stage(
&img,
ix,
template,
self.has_hint,
self.arcsec_per_px * self.binning as f64,
self.scale_known,
)
}) {
Some(blind::IndexOutcome::Solved(w)) => Some(*w),
Some(blind::IndexOutcome::Elsewhere(separation_deg)) => {
return Err(ArcsecError::OutsideSearchRadius { separation_deg });
}
Some(blind::IndexOutcome::Cancelled) => return Err(ArcsecError::Cancelled),
Some(blind::IndexOutcome::NotFound) | None => None,
};
let mut index_estimate = None;
let (ra, dec, search_radius) = match &self.index {
None => (ra_hint, dec_hint, template.search_radius),
_ if index_wcs.is_some() => (ra_hint, dec_hint, template.search_radius),
Some(_) if own_index.is_some() => {
log::warn!("Blind index found no verified position. Falling back to hint.");
(ra_hint, dec_hint, template.search_radius)
}
Some(idx_root) => {
let index_files = collect_index_files(idx_root, template.fov.to_degrees());
if index_files.is_empty() {
return Err(ArcsecError::IndexNotFound(idx_root.clone()));
}
if fallback {
let near = SolveParams {
search_radius: blind::stage_one_radius(template)
.min(template.search_radius),
..template.clone()
};
log::info!(
"Searching {:.1}° round the hint before the blind index.",
near.search_radius.to_degrees()
);
match solve_image(&img, &near) {
Ok(mut wcs) => {
if self.sip {
wcs.sip =
crate::wcs::fit_sip(&wcs, self.image_size.0, self.image_size.1);
}
return Ok(Solved {
wcs,
index_estimate: None,
});
}
Err(
e @ (ArcsecError::Cancelled
| ArcsecError::CatalogNotFound(_)
| ArcsecError::CatalogIo(_)
| ArcsecError::InsufficientStars { .. }),
) => return Err(e),
Err(_) => {}
}
}
let params = BlindSolveParams {
quad_tolerance: template.quad_tolerance,
hfd_min: template.hfd_min,
max_stars: template.max_stars,
binning: self.binning,
fov_deg: self.fov_height.to_degrees(),
};
match blind::estimate_position(&img, &index_files, ¶ms) {
blind::BlindOutcome::Found(ra, dec) => {
index_estimate = Some((ra, dec));
on_event(Event::IndexEstimate(ra, dec));
(ra, dec, (template.fov * 2.0).max(5.0_f64.to_radians()))
}
blind::BlindOutcome::InsufficientStars { found, required } => {
return Err(ArcsecError::InsufficientStars { found, required });
}
blind::BlindOutcome::NotFound => {
if crate::cancel::is_cancelled() {
return Err(ArcsecError::Cancelled);
}
log::warn!(
"Blind position estimate failed for all index files. Falling back to hint."
);
(ra_hint, dec_hint, template.search_radius)
}
}
}
};
let mut wcs = match index_wcs {
Some(w) => w,
None => self.catalogue_solve(unbinned, &img, (ra, dec), search_radius)?,
};
if self.sip {
wcs.sip = crate::wcs::fit_sip(&wcs, self.image_size.0, self.image_size.1);
}
Ok(Solved {
wcs,
index_estimate,
})
}
}
impl Plan {
fn catalogue_solve(
&self,
unbinned: &ImageBuffer,
img: &ImageBuffer,
start: (f64, f64),
radius: f64,
) -> Result<WcsSolution> {
let wcs = self.catalogue_search(unbinned, img, start, radius)?;
Ok(self.refine(unbinned, &wcs).unwrap_or(wcs))
}
fn refine(&self, unbinned: &ImageBuffer, wcs: &WcsSolution) -> Option<WcsSolution> {
let solved = (wcs.cd1_1 * wcs.cd2_2 - wcs.cd1_2 * wcs.cd2_1).abs().sqrt() * 3600.0;
let off = (solved / self.arcsec_per_px - 1.0).abs();
if off.is_nan() || off <= INACCURATE_SCALE {
return None;
}
let (w, h) = self.image_size;
let req = SolveRequest {
hint: Some((wcs.ra0, wcs.dec0)),
fov_height: None,
pixel_scale: Some(solved),
scale_search: ScaleSearch::Never,
search_radius: 0.0,
index: None,
auto_index: false,
sip: false,
cancel: None,
..self.request.clone()
};
let p = Plan::new(&req, w, h).ok()?;
let img: Cow<'_, ImageBuffer> = if p.binning > 1 {
Cow::Owned(unbinned.bin_image(p.binning))
} else {
Cow::Borrowed(unbinned)
};
let mut r = solve_image_with(&img, &p.params, ScaleTrust::Hypothesis).ok()?;
let sep = crate::math::coords::ang_sep(r.ra0, r.dec0, wcs.ra0, wcs.dec0);
log::info!(
"Solved again at {solved:.3}\"/px: {} stars verified (first {}), {:.1}\" from the first solution",
r.stars_matched,
wcs.stars_matched,
sep.to_degrees() * 3600.0
);
if sep > 0.1 * p.params.fov {
return None;
}
r.search_dist_deg = wcs.search_dist_deg;
r.step_distances.clone_from(&wcs.step_distances);
Some(r)
}
fn catalogue_search(
&self,
unbinned: &ImageBuffer,
img: &ImageBuffer,
start: (f64, f64),
radius: f64,
) -> Result<WcsSolution> {
let params = SolveParams {
ra_hint: start.0,
dec_hint: start.1,
search_radius: radius,
..self.params.clone()
};
if !self.searches_scales() {
return solve_image(img, ¶ms);
}
if !self.scale_known {
let (lo, hi) = UNKNOWN_STEPS;
let hyps = ladder(self.arcsec_per_px, lo, hi, false);
if let Some(w) = self.scale_ladder(unbinned, img, start, radius, &hyps)? {
return Ok(w);
}
log::info!(
"No scale solved near the hint; searching {:.1}° at {:.2}\"/px.",
radius.to_degrees(),
self.arcsec_per_px
);
return solve_image(img, ¶ms);
}
match solve_image(img, ¶ms) {
Err(e @ ArcsecError::InsufficientQuads { .. }) => {
let hyps = ladder(self.arcsec_per_px, -WRONG_STEPS, WRONG_STEPS, true);
log::info!(
"No solution at {:.3}\"/px; trying other scales round the hint.",
self.arcsec_per_px
);
self.scale_ladder(unbinned, img, start, radius, &hyps)?
.ok_or(e)
}
r => r,
}
}
fn scale_ladder(
&self,
unbinned: &ImageBuffer,
img: &ImageBuffer,
start: (f64, f64),
radius: f64,
hyps: &[Hypothesis],
) -> Result<Option<WcsSolution>> {
let (w, h) = self.image_size;
let naxis = w.max(h) as f64;
let installed = available_dbs(&self.params.db_path);
let covers = |fov_deg: f64| {
let fits = |name: &str| {
DB_FOV_RANGES.iter().find(|r| r.0 == name).map_or(
self.request.db_name.is_some(),
|&(_, lo, hi, _)| fov_deg >= lo / 2.0 && fov_deg <= hi * 2.0,
)
};
match &self.request.db_name {
Some(name) => fits(name),
None => installed.iter().any(|d| fits(d)),
}
};
let plans: Vec<(Hypothesis, Plan)> = hyps
.iter()
.filter_map(|&hy| {
let fov = (naxis * hy.scale / 3600.0).to_radians();
if !covers(fov.to_degrees()) {
return None;
}
let req = SolveRequest {
hint: Some(start),
fov_height: None,
pixel_scale: Some(hy.scale),
scale_search: ScaleSearch::Never,
search_radius: radius.min(LADDER_FIELDS * fov),
index: None,
auto_index: false,
sip: false,
cancel: None,
..self.request.clone()
};
let p = Plan::new(&req, w, h).ok()?;
let (bw, bh) = p.binned_size();
(bw >= MIN_SOLVE_DIM && bh >= MIN_SOLVE_DIM).then_some((hy, p))
})
.collect();
if plans.is_empty() {
return Ok(None);
}
log::info!(
"Trying {} pixel scales, {:.3}–{:.3}\"/px, {} field round the hint each.",
plans.len(),
plans
.iter()
.map(|(h, _)| h.scale)
.fold(f64::INFINITY, f64::min),
plans.iter().map(|(h, _)| h.scale).fold(0.0, f64::max),
LADDER_FIELDS
);
let mut binned: Vec<(usize, Cow<'_, ImageBuffer>)> =
vec![(self.binning, Cow::Borrowed(img))];
for (_, p) in &plans {
if binned.iter().all(|(b, _)| *b != p.binning) {
let b = if p.binning > 1 {
Cow::Owned(unbinned.bin_image(p.binning))
} else {
Cow::Borrowed(unbinned)
};
binned.push((p.binning, b));
}
}
let image_for = |b: usize| {
binned.iter().find(|(bb, _)| *bb == b).map_or_else(
|| unreachable!("binning {b} was made above"),
|(_, i)| i.as_ref(),
)
};
let n_threads = if self.params.threads > 0 {
self.params.threads
} else {
crate::max_threads()
}
.clamp(1, 64);
let key = |p: &Plan| (p.binning, p.params.hfd_min.to_bits());
let mut keys: Vec<(usize, u64)> = plans.iter().map(|(_, p)| key(p)).collect();
keys.sort_unstable();
keys.dedup();
let detections: Vec<std::sync::OnceLock<Detected>> =
keys.iter().map(|_| std::sync::OnceLock::new()).collect();
let detect_threads = (n_threads / keys.len()).max(1);
let cancel = crate::cancel::current();
let found = alloc::sync::Arc::new(core::sync::atomic::AtomicUsize::new(usize::MAX));
let (_, winner) = search_in_order(plans.len(), n_threads, |i| {
use core::sync::atomic::Ordering::Relaxed;
let (hy, p) = &plans[i];
if crate::cancel::fired(cancel.as_ref()) || found.load(Relaxed) < i {
return (None, None);
}
let token = {
let (found, outer) = (alloc::sync::Arc::clone(&found), cancel.clone());
CancelToken::with_poll(move || {
found.load(Relaxed) < i || crate::cancel::fired(outer.as_ref())
})
};
let threads = if i == 0 { n_threads } else { 1 };
let params = SolveParams {
threads,
..p.params.clone()
};
log::info!(
"Scale hypothesis {:.3}\"/px ({:.2}° field, binning {}, star database {})",
hy.scale,
p.params.fov.to_degrees(),
p.binning,
p.params.db_name.to_uppercase()
);
let img = image_for(p.binning);
let k = keys
.binary_search(&key(p))
.unwrap_or_else(|_| unreachable!());
let solved = crate::cancel::with_token(&token, || {
let stars = detections[k].get_or_init(|| {
let t = if i == 0 { n_threads } else { detect_threads };
crate::with_max_threads(t, || detect(img, ¶ms))
});
crate::with_max_threads(threads, || {
solve_detected(img, ¶ms, ScaleTrust::Hypothesis, stars)
})
});
if solved.is_ok() {
found.fetch_min(i, Relaxed);
}
(None, solved.ok().map(|w| (hy.scale, w)))
});
if crate::cancel::fired(cancel.as_ref()) {
return Err(ArcsecError::Cancelled);
}
Ok(winner.map(|(_, (scale, wcs))| {
log::info!("Solved at the scale hypothesis {scale:.3}\"/px.");
wcs
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn catalog_dir_is_namespaced() {
let d = default_catalog_dir();
assert!(
d.to_string_lossy().contains("arcsec"),
"catalogue dir should be namespaced: {}",
d.display()
);
}
#[test]
fn env_override_wins() {
let env = |k: &str| match k {
"ARCSEC_CATALOG_DIR" => Some("/data/catalogs".to_string()),
"HOME" => Some("/home/u".to_string()),
_ => None,
};
assert_eq!(
default_catalog_dir_from(env),
PathBuf::from("/data/catalogs")
);
}
#[test]
fn empty_variables_count_as_unset() {
let env = |k: &str| match k {
"ARCSEC_CATALOG_DIR" | "XDG_DATA_HOME" | "LOCALAPPDATA" => Some(String::new()),
"HOME" => Some("/home/u".to_string()),
_ => None,
};
let d = default_catalog_dir_from(env);
assert!(d.starts_with("/home/u"), "got {}", d.display());
assert!(d.ends_with("catalogs"));
}
#[test]
fn no_home_at_all_still_yields_a_path() {
assert_eq!(
default_catalog_dir_from(|_| None),
PathBuf::from("catalogs")
);
}
#[test]
fn binning_is_automatic_below_one_arcsec_per_pixel() {
assert_eq!(choose_binning(None, 2.0, 4000, 3000), 1);
assert_eq!(choose_binning(Some(0), 0.5, 4000, 3000), 2);
assert_eq!(choose_binning(None, 0.01, 4000, 3000), 16);
assert_eq!(choose_binning(Some(3), 2.0, 4000, 3000), 3);
}
#[test]
fn binning_never_exceeds_the_image() {
assert_eq!(choose_binning(Some(100), 1.0, 4, 4), 2);
assert_eq!(choose_binning(Some(usize::MAX), 1.0, 4, 4), 2);
assert_eq!(choose_binning(None, 0.01, 10, 50), 5);
assert_eq!(choose_binning(Some(4), 1.0, 1, 1), 1);
}
#[test]
fn a_plan_follows_the_cli_rules() {
let dir = crate::test_support::TempDir::new("auto_plan");
let req = SolveRequest {
hint: Some((1.0, 0.5)),
fov_height: Some(1.0_f64.to_radians()),
search_radius: -3.0,
db_path: Some(dir.path().to_path_buf()),
..SolveRequest::default()
};
let p = Plan::new(&req, 4000, 2000).unwrap();
assert_eq!(p.start, (1.0, 0.5));
assert!(p.has_hint && p.scale_known);
assert!((p.params.fov.to_degrees() - 2.0).abs() < 1e-12);
assert!((p.arcsec_per_px - 1.8).abs() < 1e-12);
assert!((p.fov_height.to_degrees() - 1.0).abs() < 1e-12);
assert_eq!(p.binning, 1);
assert_eq!(p.params.search_radius, 0.0, "a negative radius is zero");
assert_eq!(p.params.db_name, "d80", "nothing installed: the fallback");
let p = Plan::new(
&SolveRequest {
pixel_scale: Some(0.25),
db_path: Some(dir.path().to_path_buf()),
..SolveRequest::default()
},
1000,
1000,
)
.unwrap();
assert_eq!(p.binning, 4);
assert_eq!(p.binned_size(), (250, 250));
assert_eq!(p.start, (0.0, 0.0));
assert!(!p.has_hint);
assert!(Plan::new(&SolveRequest::default(), 0, 10).is_err());
let bad = SolveRequest {
pixel_scale: Some(f64::NAN),
..SolveRequest::default()
};
assert!(Plan::new(&bad, 10, 10).is_err());
}
#[test]
fn a_plan_refuses_an_image_of_another_size() {
let dir = crate::test_support::TempDir::new("auto_size");
let req = SolveRequest {
db_path: Some(dir.path().to_path_buf()),
..SolveRequest::default()
};
let p = Plan::new(&req, 100, 80).unwrap();
let err = p.solve(&ImageBuffer::new(80, 100)).unwrap_err();
assert!(matches!(err, ArcsecError::InvalidParameter(_)), "{err}");
}
#[test]
fn index_files_lists_only_arcsec_indexes() {
let dir = crate::test_support::TempDir::new("auto_ix");
for name in [
"b.arcsecix",
"a.arcsecix",
"index-4107.fits",
"d50_0101.1476",
] {
std::fs::write(dir.path().join(name), b"x").unwrap();
}
let names: Vec<_> = index_files(dir.path())
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(names, ["a.arcsecix", "b.arcsecix"]);
assert!(index_files(Path::new("/nonexistent/arcsec")).is_empty());
assert!(has_star_database(dir.path()));
assert_eq!(available_dbs(dir.path()), ["d50"]);
}
}