extern crate alloc;
mod catalog_cmd;
mod cli;
mod extract;
mod logger;
use core::f64::consts::PI;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process;
use std::time::Instant;
use arcsec_core::ArcsecError;
use arcsec_core::auto::{Event, MIN_SOLVE_DIM, Plan, SolveRequest};
use arcsec_core::pipeline::{SearchSpeed, SolveMethod, format_dec, format_ra, format_radec};
use arcsec_core::types::{ImageBuffer, WcsSolution};
use arcsec_core::wcs::TanWcs;
use arcsec_io::{fits_io, image_io};
use clap::ArgMatches;
use crate::cli::VERSION;
use crate::extract::Extract2;
static UNSOLVED_INI: std::sync::Mutex<Option<(PathBuf, String)>> = std::sync::Mutex::new(None);
fn main() {
if let Err(panic) = std::panic::catch_unwind(run) {
eprintln!(
"Error: internal error ({}). This is a bug in arcsec; please report it.",
image_io::panic_message(panic.as_ref())
);
if let Some((ini, cmdline)) = UNSOLVED_INI.lock().ok().and_then(|mut u| u.take()) {
let _ = fits_io::write_unsolved_ini_file(&ini, &cmdline);
}
process::exit(1);
}
}
fn run() {
if std::env::args_os().nth(1).is_some_and(|a| a == "catalog") {
process::exit(catalog_cmd::run(std::env::args_os().skip(1)));
}
let argv: Vec<OsString> = std::env::args_os().collect();
let matches = cli::solver_command()
.try_get_matches_from(cli::normalize_astap_args(argv.clone()))
.unwrap_or_else(|e| {
if e.use_stderr() {
let _ = e.print();
process::exit(1);
}
e.exit()
});
let Some(file) = matches.get_one::<PathBuf>("file") else {
eprintln!("Error: -f <filename> is required");
process::exit(16);
};
let threads = arg::<usize>(&matches, "threads");
arcsec_core::set_max_threads(threads);
let do_progress = matches.get_flag("progress");
let out_base = output_base(file, matches.get_one::<PathBuf>("output"));
let mut unsolved = Unsolved {
ini_path: with_extension(&out_base, "ini"),
cmdline: argv
.iter()
.map(|a| a.to_string_lossy())
.collect::<Vec<_>>()
.join(" "),
extract2: None,
};
if let Ok(mut u) = UNSOLVED_INI.lock() {
*u = Some((unsolved.ini_path.clone(), unsolved.cmdline.clone()));
}
let log_path = matches
.get_flag("log")
.then(|| with_extension(&out_base, "log"));
logger::install(do_progress, log_path.as_deref());
if do_progress || log_path.is_some() {
let line: Vec<String> = argv
.iter()
.map(|a| a.to_string_lossy().into_owned())
.collect();
log::info!("{}", line.join(" "));
}
let db_path: PathBuf = matches
.get_one::<PathBuf>("database")
.cloned()
.unwrap_or_else(arcsec_core::auto::default_db_path);
let db_abbrev: Option<String> = matches.get_one::<String>("db-abbrev").cloned();
log::info!("Creating grayscale image for solving");
let mut img = match image_io::read_image(file) {
Ok(i) => i,
Err(e) => {
eprintln!("Error reading image: {e}");
process::exit(16);
}
};
if let Some((scale, offset)) = img.normalize_for_detection() {
log::info!(
"Rescaled pixel data for detection: value * {scale:.6} + {offset:.3} (float input with a range too small for the 16-bit histogram)"
);
}
let max_stars = arg::<usize>(&matches, "stars");
let analyse = matches.get_one::<f64>("analyse").copied();
let extract = matches.get_one::<f64>("extract").copied();
if analyse.is_some() || extract.is_some() {
run_analysis(file, &img, analyse, extract, max_stars);
}
if matches.get_one::<String>("check").is_some_and(|v| v == "y") {
if image_io::read_channels(file) > 1 {
log::info!("Skipping check pattern filter. This filter works only for raw OSC images!");
} else if img.check_pattern_filter() {
log::info!("Applying check pattern filter.");
}
}
unsolved.extract2 = matches.get_one::<f64>("extract2").map(|&v| Extract2 {
snr_min: extract::snr_min(v),
max_stars,
csv: extract::csv_path(file),
img: img.clone(),
header_wcs: image_io::read_header_wcs(file),
});
let want_sip =
matches.get_one::<String>("sip").is_some_and(|v| v != "n") || unsolved.extract2.is_some();
let speed = match matches.get_one::<String>("speed").map(String::as_str) {
Some("slow") => SearchSpeed::Slow,
_ => SearchSpeed::Auto,
};
let hint = pointing_hint(&matches, file);
let (ra_hint_rad, dec_hint_rad) = hint.unwrap_or((0.0, 0.0));
let fov_hint = matches.get_one::<f64>("fov").copied().filter(|v| *v > 0.0);
let quad_tol = arg::<f64>(&matches, "tolerance");
let request = SolveRequest {
hint,
fov_height: fov_hint.map(f64::to_radians),
pixel_scale: if fov_hint.is_some() {
None
} else {
image_io::read_pixel_scale(file)
},
search_radius: arg::<f64>(&matches, "radius").to_radians(),
downsample: matches
.get_one::<u32>("downsample")
.map(|&z| usize::try_from(z).unwrap_or(usize::MAX)),
db_path: Some(db_path),
db_name: db_abbrev,
index: matches.get_one::<PathBuf>("index").cloned(),
index_first: true,
auto_index: true,
hfd_min_arcsec: arg::<f64>(&matches, "hfd-min"),
quad_tolerance: quad_tol,
max_stars,
method: match matches.get_one::<String>("method").map(String::as_str) {
Some("tetra") => SolveMethod::Tetra,
_ => SolveMethod::Quads,
},
speed,
threads,
sip: want_sip,
cancel: None,
};
let plan = match Plan::new(&request, img.width, img.height) {
Ok(p) => p,
Err(e) => {
eprintln!("Solver error: {e}");
unsolved.exit(1);
}
};
let binning = plan.binning;
let (binned_w, binned_h) = plan.binned_size();
log::info!(
"Using star database {} for a {:.2}° field",
plan.params.db_name.to_uppercase(),
plan.params.fov.to_degrees()
);
println!("arcsec astrometric solver version {VERSION}");
println!(
"Search radius: {:.0} degrees, ",
plan.params.search_radius.to_degrees()
);
println!(
"Start position: {}, {}",
format_ra(ra_hint_rad),
format_dec(dec_hint_rad)
);
println!("Image height: {:.2} degrees", plan.fov_height.to_degrees());
println!("Binning: {binning}x{binning}");
println!(
"Image dimensions: {}x{}",
binned_w * binning,
binned_h * binning
);
println!("Quad tolerance: {quad_tol:.3}");
println!("Minimum star size: {:.1}\"", plan.hfd_min_arcsec);
println!(
"Speed: {}",
if speed == SearchSpeed::Slow {
"slow"
} else {
"normal"
}
);
if binned_w < MIN_SOLVE_DIM || binned_h < MIN_SOLVE_DIM {
eprintln!(
"Insufficient stars: the image is too small to solve ({binned_w}x{binned_h} pixels)"
);
unsolved.exit(2);
}
if let Some(hint) =
catalog_cmd::index_cmd::missing_index_hint(request.index.as_ref(), &plan.params)
{
eprintln!("{hint}");
}
let t0 = Instant::now();
let solved = plan.solve_with(&img, |event| {
if let Event::IndexEstimate(ra, dec) = event {
println!(
"Index position estimate: RA={:.3}°, Dec={:.3}°",
ra.to_degrees(),
dec.to_degrees()
);
}
});
let wcs = match solved {
Ok(s) => s.wcs,
Err(e) => report_failure(&unsolved, e),
};
let elapsed_s = t0.elapsed().as_secs_f64();
print_solution(
&wcs,
quad_tol,
elapsed_s,
(ra_hint_rad, dec_hint_rad),
do_progress,
);
if let Ok(mut u) = UNSOLVED_INI.lock() {
u.take();
}
let wcs_path = with_extension(&out_base, "wcs");
let ini_path = &unsolved.ini_path;
if let Err(e) = fits_io::write_wcs_file(&wcs_path, &wcs) {
eprintln!("Warning: could not write {}: {e}", wcs_path.display());
}
if let Err(e) = fits_io::write_ini_file(ini_path, &wcs, max_stars, &unsolved.cmdline) {
eprintln!("Warning: could not write {}: {e}", ini_path.display());
}
if matches.get_flag("update") {
if let Err(e) = image_io::update_wcs(file, &wcs) {
eprintln!("Warning: --update failed: {e}");
} else {
log::info!("WCS written to FITS header");
}
}
if let Some(job) = &unsolved.extract2 {
job.run(Some(&TanWcs::from(&wcs)));
}
process::exit(0);
}
fn run_analysis(
file: &Path,
img: &ImageBuffer,
analyse: Option<f64>,
extract: Option<f64>,
max_stars: usize,
) -> ! {
let snr_min = extract::snr_min(extract.or(analyse).unwrap_or(0.0));
let (analysis, hfd) = extract::analyse_and_report(img, snr_min, max_stars);
if extract.is_some() {
let csv = extract::csv_path(file);
let header_wcs = image_io::read_header_wcs(file);
if let Err(e) = extract::write_csv(&csv, &analysis.stars, header_wcs.as_ref()) {
eprintln!("Error: could not write {}: {e}", csv.display());
process::exit(16);
}
}
if cfg!(windows) && analyse.is_some() {
process::exit(extract::analyse_exit_code(hfd, analysis.stars.len()));
}
process::exit(0);
}
fn arg<T: Clone + Send + Sync + 'static>(matches: &ArgMatches, id: &str) -> T {
matches
.get_one::<T>(id)
.cloned()
.unwrap_or_else(|| unreachable!("--{id} has a default value"))
}
fn output_base(file: &Path, output: Option<&PathBuf>) -> PathBuf {
output.map_or_else(|| file.with_extension(""), Clone::clone)
}
fn with_extension(base: &Path, ext: &str) -> PathBuf {
let mut s = base.as_os_str().to_owned();
s.push(".");
s.push(ext);
PathBuf::from(s)
}
fn pointing_hint(matches: &ArgMatches, file: &Path) -> Option<(f64, f64)> {
let cli_ra = matches.get_one::<f64>("ra").copied();
let cli_spd = matches.get_one::<f64>("spd").copied();
if cli_ra.is_some() || cli_spd.is_some() {
let ra = cli_ra.map_or(0.0, |h| h * PI / 12.0);
let dec = cli_spd.map_or(0.0, |spd| (spd - 90.0).clamp(-90.0, 90.0) * PI / 180.0);
Some((ra, dec))
} else {
image_io::read_ra_dec(file)
.map(|(ra_deg, dec_deg)| (ra_deg * PI / 180.0, dec_deg * PI / 180.0))
}
}
fn report_failure(unsolved: &Unsolved, err: ArcsecError) -> ! {
match err {
ArcsecError::InsufficientStars { found, required } => {
eprintln!("Insufficient stars: found {found}, required {required}");
unsolved.exit(2);
}
ArcsecError::InsufficientQuads { .. } => {
println!("No solution found.");
unsolved.exit(1);
}
ArcsecError::OutsideSearchRadius { separation_deg } => {
eprintln!(
"The blind index places this field {separation_deg:.1}° from the start position, \
outside the search radius."
);
println!("No solution found.");
unsolved.exit(1);
}
ArcsecError::IndexNotFound(p) => {
eprintln!("No index files found at {}", p.display());
unsolved.exit(32);
}
ArcsecError::CatalogNotFound(p) => {
eprintln!("Star database not found: {}", p.display());
unsolved.exit(32);
}
ArcsecError::CatalogIo(e) => {
eprintln!("Star database read error: {e}");
unsolved.exit(33);
}
e => {
eprintln!("Solver error: {e}");
unsolved.exit(1);
}
}
}
struct Unsolved {
ini_path: PathBuf,
cmdline: String,
extract2: Option<Extract2>,
}
impl Unsolved {
fn exit(&self, code: i32) -> ! {
if let Err(e) = fits_io::write_unsolved_ini_file(&self.ini_path, &self.cmdline) {
eprintln!("Warning: could not write {}: {e}", self.ini_path.display());
}
if let Some(job) = &self.extract2 {
job.run(None);
}
process::exit(code);
}
}
fn print_solution(
wcs: &WcsSolution,
quad_tol: f64,
elapsed_s: f64,
(ra_hint_rad, dec_hint_rad): (f64, f64),
progress: bool,
) {
if !progress && !wcs.step_distances.is_empty() {
let dots: Vec<String> = wcs
.step_distances
.iter()
.map(|d| format!("{d:.0}d"))
.collect();
println!("{},", dots.join(","));
}
let p = &wcs.plate;
let n_matched = wcs.stars_matched;
let n_raw = wcs.raw_matches;
println!("{n_matched} of {n_raw} quads selected matching within {quad_tol:.3} tolerance.");
println!(
"Solution[\"] x:={:.6}*x+ {:.6}*y+ {:.6}, y:={:.6}*x+ {:.6}*y+ {:.6}",
p.a, p.b, p.c, p.d, p.e, p.f
);
let sol_str = format_radec(wcs.ra0, wcs.dec0);
println!("Solution found: {sol_str}");
log::info!("Solution found: {sol_str}");
let delta_str = if wcs.search_dist_deg >= 1.0 {
format!("{:.1}d", wcs.search_dist_deg)
} else {
format!("{:.1}\"", wcs.search_dist_deg * 3600.0)
};
let dra_arcsec = (wcs.ra0 - ra_hint_rad) * wcs.dec0.cos() * (180.0 / PI) * 3600.0;
let ddec_arcsec = (wcs.dec0 - dec_hint_rad) * (180.0 / PI) * 3600.0;
println!(
"Solved in {elapsed_s:.1} sec. Δ was {delta_str}. Mount Δα={dra_arcsec:.1}\", Δδ={ddec_arcsec:.1}\". Used stars down to magnitude: {:.1}",
wcs.mag_limit,
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn output_base_strips_only_the_last_extension() {
let b = |f: &str| output_base(Path::new(f), None);
assert_eq!(b("dir/30.00s_0018.fits"), Path::new("dir/30.00s_0018"));
assert_eq!(b("light.fit"), Path::new("light"));
assert_eq!(
b("/data/2026.09.25/image"),
Path::new("/data/2026.09.25/image")
);
let o = PathBuf::from("out/solved");
assert_eq!(output_base(Path::new("x.fits"), Some(&o)), o);
}
#[test]
fn output_files_append_their_extension() {
assert_eq!(
with_extension(Path::new("dir/30.00s_0018"), "wcs"),
Path::new("dir/30.00s_0018.wcs")
);
}
#[cfg(unix)]
#[test]
fn non_utf8_image_paths_survive() {
use std::os::unix::ffi::OsStrExt as _;
let f = Path::new(std::ffi::OsStr::from_bytes(b"dir/caf\xe9.fits"));
let base = output_base(f, None);
assert_eq!(base.as_os_str().as_bytes(), b"dir/caf\xe9");
assert_eq!(
with_extension(&base, "ini").as_os_str().as_bytes(),
b"dir/caf\xe9.ini"
);
}
}