use std::ffi::OsString;
use std::path::PathBuf;
use clap::{Arg, ArgAction, Command, value_parser};
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
const ASTAP_SINGLE_DASH: &[&str] = &[
"fov", "ra", "spd", "check", "sip", "speed", "wcs", "log", "update", "progress", "analyse",
"extract", "extract2",
];
pub fn normalize_astap_args<I>(args: I) -> Vec<OsString>
where
I: IntoIterator<Item = OsString>,
{
let mut out = Vec::new();
let mut rest_verbatim = false;
for (i, arg) in args.into_iter().enumerate() {
if i == 0 || rest_verbatim {
out.push(arg);
continue;
}
if arg == "--" {
rest_verbatim = true;
out.push(arg);
continue;
}
let rewritten = arg
.to_str()
.and_then(|s| s.strip_prefix('-'))
.filter(|name| ASTAP_SINGLE_DASH.contains(name))
.map(|name| OsString::from(format!("--{name}")));
out.push(rewritten.unwrap_or(arg));
}
out
}
pub fn solver_command() -> Command {
Command::new("arcsec")
.version(VERSION)
.about("Astrometric plate solver with an ASTAP-compatible command line")
.after_help(
"ASTAP's single-dash spellings (-fov, -ra, -spd, -progress, ...) are accepted too.\n\
Catalogues: run `arcsec catalog --help`.\n\
Exit codes: 0 solved, 1 no solution or usage error, 2 too few stars, 16 file error,\n\
32 star database or index not found, 33 star database read error.",
)
.arg(
Arg::new("file")
.short('f')
.value_name("FILE")
.value_parser(value_parser!(PathBuf))
.help("Image to solve: FITS, XISF or ASDF"),
)
.arg(
Arg::new("radius")
.short('r')
.value_name("DEG")
.value_parser(value_parser!(f64))
.allow_hyphen_values(true)
.default_value("180")
.help("Radius of the area to search, in degrees"),
)
.arg(
Arg::new("fov")
.long("fov")
.value_name("DEG")
.value_parser(value_parser!(f64))
.allow_hyphen_values(true)
.help("Field height in degrees; 0 or absent to take it from the header"),
)
.arg(
Arg::new("ra")
.long("ra")
.value_name("HOURS")
.value_parser(value_parser!(f64))
.allow_hyphen_values(true)
.help("Right ascension of the search centre, in hours"),
)
.arg(
Arg::new("spd")
.long("spd")
.value_name("DEG")
.value_parser(value_parser!(f64))
.allow_hyphen_values(true)
.help("South pole distance of the search centre (90 + Dec), in degrees"),
)
.arg(
Arg::new("stars")
.short('s')
.value_name("N")
.value_parser(value_parser!(usize))
.default_value("500")
.help("Maximum number of stars to use"),
)
.arg(
Arg::new("tolerance")
.short('t')
.value_name("TOL")
.value_parser(value_parser!(f64))
.default_value("0.007")
.help("Quad matching tolerance"),
)
.arg(
Arg::new("hfd-min")
.short('m')
.value_name("ARCSEC")
.value_parser(value_parser!(f64))
.default_value("1.5")
.help("Minimum star size (HFD), in arcseconds"),
)
.arg(
Arg::new("downsample")
.short('z')
.value_name("FACTOR")
.value_parser(value_parser!(u32))
.help("Downsample (bin) factor; 0 or absent for automatic"),
)
.arg(
Arg::new("check")
.long("check")
.action(ArgAction::SetTrue)
.help("[not implemented] Apply the check-pattern filter before solving"),
)
.arg(
Arg::new("database")
.short('d')
.value_name("DIR")
.value_parser(value_parser!(PathBuf))
.help("Star database directory [default: the `arcsec catalog` directory if it holds one, else the current directory]"),
)
.arg(
Arg::new("db-abbrev")
.short('D')
.value_name("NAME")
.help("Star database to use: d80, d50, g05, w08, ... [default: chosen by field size]"),
)
.arg(
Arg::new("output")
.short('o')
.value_name("BASE")
.value_parser(value_parser!(PathBuf))
.help("Base path for the output files [default: the image path without its extension]"),
)
.arg(
Arg::new("sip")
.long("sip")
.action(ArgAction::SetTrue)
.help("[not implemented] Add SIP (Simple Imaging Polynomial) distortion coefficients"),
)
.arg(
Arg::new("speed")
.long("speed")
.value_name("MODE")
.help("Search mode, auto or slow; only auto is implemented"),
)
.arg(
Arg::new("index")
.long("index")
.short('i')
.value_name("PATH")
.value_parser(value_parser!(PathBuf))
.help("Astrometry.net index file, or a directory of index-*.fits files, for blind solving"),
)
.arg(
Arg::new("threads")
.long("threads")
.value_name("N")
.value_parser(value_parser!(usize))
.default_value("0")
.help("Maximum worker threads; 0 for one per core"),
)
.arg(
Arg::new("method")
.long("method")
.value_name("METHOD")
.value_parser(["quads", "tetra"])
.default_value("quads")
.help("Catalogue matching method; tetra (three-star triangles) is experimental"),
)
.arg(
Arg::new("wcs")
.long("wcs")
.action(ArgAction::SetTrue)
.help("Write an Astrometry.net-style .wcs file (always written; accepted for compatibility)"),
)
.arg(
Arg::new("log")
.long("log")
.action(ArgAction::SetTrue)
.help("Write the solver log to <BASE>.log"),
)
.arg(
Arg::new("update")
.long("update")
.action(ArgAction::SetTrue)
.help("Write the solution into the input FITS header"),
)
.arg(
Arg::new("progress")
.long("progress")
.action(ArgAction::SetTrue)
.help("Log every progress step to stderr"),
)
.arg(
Arg::new("analyse")
.long("analyse")
.value_name("SNR_MIN")
.value_parser(value_parser!(f64))
.help("[not implemented] Only analyse: report the median HFD and number of stars"),
)
.arg(
Arg::new("extract")
.long("extract")
.value_name("SNR_MIN")
.value_parser(value_parser!(f64))
.help("[not implemented] As --analyse, and export the star list to .csv"),
)
.arg(
Arg::new("extract2")
.long("extract2")
.value_name("SNR_MIN")
.value_parser(value_parser!(f64))
.help("[not implemented] Solve, and export the star list with RA/Dec to .csv"),
)
}
pub fn catalog_command() -> Command {
Command::new("catalog")
.bin_name("arcsec catalog")
.about("Install and manage star catalogues")
.subcommand_required(true)
.arg_required_else_help(true)
.arg(
Arg::new("dir")
.long("dir")
.global(true)
.value_name("DIR")
.value_parser(value_parser!(PathBuf))
.help("Catalogue directory [default: $ARCSEC_CATALOG_DIR, else the per-platform data directory]"),
)
.subcommand(Command::new("list").about("Show every catalogue and whether it is installed"))
.subcommand(Command::new("path").about("Print the catalogue directory"))
.subcommand(
Command::new("recommend")
.about("Suggest catalogues for a field size")
.arg(
Arg::new("fov")
.long("fov")
.value_name("DEG")
.value_parser(value_parser!(f64))
.help("Field of view in degrees"),
)
.arg(
Arg::new("like")
.long("like")
.value_name("IMAGE")
.value_parser(value_parser!(PathBuf))
.help("Take the field size from this image's header instead"),
)
.arg(
Arg::new("photometry")
.long("photometry")
.action(ArgAction::SetTrue)
.help("Also suggest a photometric catalogue (colour calibration)"),
),
)
.subcommand(
Command::new("install")
.about("Download and unpack catalogues")
.arg(
Arg::new("names")
.value_name("NAME")
.required(true)
.num_args(1..)
.help("Catalogue names, e.g. d50 v05 g05"),
)
.arg(
Arg::new("yes")
.long("yes")
.short('y')
.action(ArgAction::SetTrue)
.help("Do not ask for confirmation"),
)
.arg(
Arg::new("keep")
.long("keep-archive")
.action(ArgAction::SetTrue)
.help("Keep the downloaded archive after extracting"),
),
)
.subcommand(
Command::new("remove")
.about("Delete installed catalogue files")
.arg(
Arg::new("names")
.value_name("NAME")
.required(true)
.num_args(1..)
.help("Catalogue names, as shown by `arcsec catalog list`"),
)
.arg(
Arg::new("yes")
.long("yes")
.short('y')
.action(ArgAction::SetTrue)
.help("Do not ask for confirmation"),
),
)
.subcommand(
Command::new("verify")
.about("Check installed catalogues for missing or truncated files"),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn norm(args: &[&str]) -> Vec<String> {
normalize_astap_args(args.iter().map(OsString::from))
.into_iter()
.map(|s| s.into_string().unwrap())
.collect()
}
#[test]
fn command_definitions_are_valid() {
solver_command().debug_assert();
catalog_command().debug_assert();
}
#[test]
fn astap_single_dash_options_are_rewritten() {
assert_eq!(
norm(&[
"arcsec",
"-f",
"x.fits",
"-fov",
"1.2",
"-ra",
"5.5",
"-spd",
"97",
"-progress"
]),
[
"arcsec",
"-f",
"x.fits",
"--fov",
"1.2",
"--ra",
"5.5",
"--spd",
"97",
"--progress"
]
);
}
#[test]
fn everything_else_is_left_alone() {
let args = [
"arcsec",
"-f",
"-fov.fits",
"-r",
"-30",
"--fov",
"0",
"-z",
"2",
"-D",
"d50",
];
assert_eq!(norm(&args), args);
assert_eq!(norm(&["arcsec", "--", "-fov"]), ["arcsec", "--", "-fov"]);
assert_eq!(norm(&["-log"]), ["-log"]);
}
#[test]
fn astap_command_line_parses() {
let m = solver_command()
.try_get_matches_from(normalize_astap_args(
[
"astap_cli",
"-f",
"a.fits",
"-fov",
"0",
"-r",
"30",
"-ra",
"5.5",
"-spd",
"97",
"-z",
"0",
"-s",
"500",
"-wcs",
"-log",
"-update",
"-speed",
"auto",
]
.map(OsString::from),
))
.expect("an astap_cli command line must parse");
assert_eq!(m.get_one::<f64>("fov"), Some(&0.0));
assert_eq!(m.get_one::<f64>("spd"), Some(&97.0));
assert!(m.get_flag("wcs") && m.get_flag("log") && m.get_flag("update"));
}
#[test]
fn an_unknown_method_is_rejected() {
let r = solver_command().try_get_matches_from(["arcsec", "--method", "triangles"]);
assert!(r.is_err());
}
}