use core::f64::consts::PI;
use std::path::PathBuf;
use crate::catalog::read_catalog_stars;
use crate::catalog::{CatalogLayout, CatalogStar};
use crate::detection::get_background;
use crate::detection::stars::find_stars_with_background;
use crate::error::{ArcsecError, Result};
use crate::math::coords::{ang_sep, equatorial_standard, standard_equatorial};
use crate::math::lsq::{fit_affine, solve_plate_constants};
use crate::quads::{
TETRA_TOL_FACTOR, bijective_filter, build_quads, build_quads_presorted, build_triangles,
extract_star_pairs, extract_triangle_pairs, filter_by_scale, filter_triangles_by_scale,
find_matches_sorted, find_triangle_matches, vote_filter,
};
use crate::types::{MatchedStar, PairedPositions, PlateConstants, Star, StarList, WcsSolution};
use crate::wcs::output::derive_wcs;
use super::distortion::{Pair, Refined, StarGrid, best_linear, max_departure_px, refine};
use super::spiral::SpiralSearch;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SolveMethod {
#[default]
Quads,
Tetra,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SearchSpeed {
#[default]
Auto,
Slow,
}
#[derive(Debug, Clone)]
pub struct SolveParams {
pub ra_hint: f64,
pub dec_hint: f64,
pub fov: f64,
pub search_radius: f64,
pub quad_tolerance: f64,
pub hfd_min: f64,
pub max_stars: usize,
pub db_path: PathBuf,
pub db_name: String,
pub binning: usize,
pub method: SolveMethod,
pub speed: SearchSpeed,
pub threads: usize,
}
fn sigma_clip_pairs(
mut img_pos: Vec<(f64, f64)>,
mut cat_pos: Vec<(f64, f64)>,
sigma: f64,
min_count: usize,
) -> PairedPositions {
let mut first_pass = true;
for _ in 0..10 {
if img_pos.len() < min_count.max(3) {
break;
}
let Ok(plate) = fit_affine(&img_pos, &cat_pos) else {
break;
};
let residuals: Vec<f64> = img_pos
.iter()
.zip(cat_pos.iter())
.map(|(&(xi, yi), &(xc, yc))| {
let xp = plate.a * xi + plate.b * yi + plate.c;
let yp = plate.d * xi + plate.e * yi + plate.f;
((xp - xc).powi(2) + (yp - yc).powi(2)).sqrt()
})
.collect();
let rms = (residuals.iter().map(|r| r * r).sum::<f64>() / residuals.len() as f64).sqrt();
let threshold = if first_pass {
first_pass = false;
let cdelt = (plate.a.powi(2) + plate.d.powi(2)).sqrt();
let mut sorted = residuals.clone();
sorted.sort_unstable_by(f64::total_cmp);
let median = sorted[sorted.len() / 2];
(10.0 * cdelt).max(10.0).max(3.0 * 1.4826 * median)
} else {
sigma * rms
};
let before = img_pos.len();
let mut new_img = Vec::with_capacity(before);
let mut new_cat = Vec::with_capacity(before);
for ((&ip, &cp), &r) in img_pos.iter().zip(cat_pos.iter()).zip(residuals.iter()) {
if r <= threshold {
new_img.push(ip);
new_cat.push(cp);
}
}
if new_img.len() == before {
break; }
img_pos = new_img;
cat_pos = new_cat;
}
(img_pos, cat_pos)
}
fn fit_pattern_pairs(
img_pos: Vec<(f64, f64)>,
cat_pos: Vec<(f64, f64)>,
min_count: usize,
) -> Option<(PlateConstants, usize)> {
let (img_pos, cat_pos) = sigma_clip_pairs(img_pos, cat_pos, 3.0, min_count);
if img_pos.len() < min_count {
return None;
}
let plate = solve_plate_constants(&img_pos, &cat_pos).ok()?;
Some((plate, img_pos.len()))
}
const MIN_VERIFIED_STARS: usize = 30;
fn min_verified_stars(nrstars_image: usize) -> usize {
nrstars_image
.saturating_mul(15)
.div_ceil(100)
.clamp(10, MIN_VERIFIED_STARS)
}
const RELAXED_SCALE_TOL: f64 = 0.10;
const RELAXED_MAX_RMS_PX: f64 = 0.5;
struct Acceptance {
min_stars: usize,
expected_scale: f64,
}
impl Acceptance {
fn new(nrstars_image: usize, params: &SolveParams, img: &crate::types::ImageBuffer) -> Self {
Self {
min_stars: min_verified_stars(nrstars_image),
expected_scale: params.fov.to_degrees() * 3600.0
/ img.width.max(img.height).max(1) as f64,
}
}
fn accepts(&self, v: &Verified, spread: f64) -> bool {
if v.n() < self.min_stars || spread < MIN_VERIFY_SPREAD {
return false;
}
if v.n() >= MIN_VERIFIED_STARS {
return true;
}
let p = &v.plate;
let scale = (p.a * p.e - p.b * p.d).abs().sqrt();
let ok = (scale / self.expected_scale - 1.0).abs() <= RELAXED_SCALE_TOL
&& v.rms <= RELAXED_MAX_RMS_PX * scale;
log::info!(
"{} stars verified, scale {:.4}\"/px against {:.4} expected, residual {:.2} px: {}",
v.n(),
scale,
self.expected_scale,
v.rms / scale,
if ok { "accepted" } else { "refused" }
);
ok
}
}
const VERIFY_RADII: [f64; 3] = [6.0, 3.0, 2.0];
const MIN_VERIFY_SPREAD: f64 = 0.20;
struct Verified {
plate: PlateConstants,
rms: f64,
img_pos: Vec<(f64, f64)>,
cat_pos: Vec<(f64, f64)>,
}
impl Verified {
fn n(&self) -> usize {
self.img_pos.len()
}
}
fn verify_and_refit(
img_stars: &StarList,
cat_stars: &StarList,
plate: &PlateConstants,
img_w: usize,
img_h: usize,
accept: &Acceptance,
) -> Option<Verified> {
if img_stars.is_empty() || cat_stars.is_empty() {
return None;
}
let grid = StarGrid::new(img_stars, VERIFY_RADII[0])?;
let mut current = plate.clone();
let mut best: Option<(Verified, f64)> = None;
for &radius in &VERIFY_RADII {
let det = current.a * current.e - current.b * current.d;
if det.abs() < 1e-12 {
return None;
}
let r2 = radius * radius;
let mut img_pos: Vec<(f64, f64)> = Vec::new();
let mut cat_pos: Vec<(f64, f64)> = Vec::new();
let mut used = vec![false; img_stars.len()];
for cs in &cat_stars.0 {
let dx = cs.x - current.c;
let dy = cs.y - current.f;
let px = (current.e * dx - current.b * dy) / det;
let py = (-current.d * dx + current.a * dy) / det;
if !grid.near(px, py, radius) {
continue;
}
if let Some(i) = grid.nearest(px, py, r2, &used) {
used[i] = true; img_pos.push(grid.pos(i));
cat_pos.push((cs.x, cs.y));
}
}
if img_pos.len() < 4 {
break;
}
let Ok(refined) = solve_plate_constants(&img_pos, &cat_pos) else {
break;
};
let mut sq = 0.0;
for (&(xi, yi), &(xc, yc)) in img_pos.iter().zip(cat_pos.iter()) {
let xp = refined.a * xi + refined.b * yi + refined.c;
let yp = refined.d * xi + refined.e * yi + refined.f;
sq += (xp - xc).powi(2) + (yp - yc).powi(2);
}
let rms = (sq / img_pos.len() as f64).sqrt();
let spread = spread_of(&img_pos, img_w, img_h);
log::debug!(
"verify: {} stars, spread {:.3}, rms {:.2}\"",
img_pos.len(),
spread,
rms
);
current = refined.clone();
best = Some((
Verified {
plate: refined,
rms,
img_pos,
cat_pos,
},
spread,
));
}
best.filter(|(v, spread)| accept.accepts(v, *spread))
.map(|(v, _)| v)
}
fn density_star_limit(params: &SolveParams, img: &crate::types::ImageBuffer) -> usize {
let Some(density) = crate::catalog::database_density(¶ms.db_name) else {
return params.max_stars;
};
let fov_deg = params.fov.to_degrees();
let (w, h) = (img.width as f64, img.height as f64);
let area = fov_deg * fov_deg * w.min(h) / w.max(h).max(1.0);
let cap = (density * area).round();
if cap < params.max_stars as f64 {
cap as usize
} else {
params.max_stars
}
}
struct SpiralCtx<'a> {
params: &'a SolveParams,
img: &'a crate::types::ImageBuffer,
stars: &'a StarList,
img_quads: &'a crate::types::QuadList,
img_tris: &'a crate::quads::TriangleList,
nrstars_image: usize,
star_limit: usize,
nrstars_required: usize,
oversize: f64,
min_quads: usize,
step_size: f64,
accept: Acceptance,
aspect: f64,
}
struct PositionOutcome {
idx: usize,
ra_db: f64,
dec_db: f64,
sep_deg: f64,
verified: Verified,
n_matched: usize,
n_raw: usize,
mag_limit: f64,
refused: bool,
}
struct PositionTry {
sep_deg: Option<f64>,
outcome: Option<PositionOutcome>,
}
impl PositionTry {
const NONE: Self = Self {
sep_deg: None,
outcome: None,
};
}
fn try_position(ctx: &SpiralCtx<'_>, idx: usize, sx: i32, sy: i32) -> PositionTry {
let params = ctx.params;
let step_size = ctx.step_size;
let dec_db_raw = params.dec_hint + step_size * sy as f64;
let (dec_db, flip) = if dec_db_raw > PI / 2.0 {
(PI - dec_db_raw, PI)
} else if dec_db_raw < -PI / 2.0 {
(-PI - dec_db_raw, PI)
} else {
(dec_db_raw, 0.0)
};
let extra = if dec_db > 0.0 {
step_size * 0.5
} else {
-step_size * 0.5
};
let ra_offset = step_size * sx as f64 / (dec_db - extra).cos();
if ra_offset > PI / 2.0 + step_size * 0.5 || ra_offset < -PI / 2.0 {
return PositionTry::NONE;
}
let ra_db = (flip + params.ra_hint + ra_offset).rem_euclid(2.0 * PI);
let sep = ang_sep(ra_db, dec_db, params.ra_hint, params.dec_hint);
if sep > params.search_radius + step_size / 2.0 {
return PositionTry::NONE;
}
let cat_raw = match read_catalog_stars(
¶ms.db_path,
¶ms.db_name,
ra_db,
dec_db,
params.fov * ctx.oversize,
ctx.nrstars_required,
) {
Ok(v) if !v.is_empty() => v,
Ok(_) | Err(_) => return PositionTry::NONE,
};
let sep_deg = sep.to_degrees();
let mag_limit = cat_raw
.iter()
.map(|s| s.mag)
.fold(f64::NEG_INFINITY, f64::max);
log::info!(
"Search {}, [{},{}], position: {} Down to magn {:.1} {} database stars {} database quads to compare.",
idx,
sx,
sy,
format_radec(ra_db, dec_db),
mag_limit,
cat_raw.len(),
cat_raw.len(),
);
let mut cat_stars: Vec<Star> = cat_raw
.iter()
.map(|s| {
let (x, y) = equatorial_standard(ra_db, dec_db, s.ra, s.dec, 1.0);
Star {
x,
y,
snr: 1.0,
hfd: 2.0,
}
})
.collect();
cat_stars.sort_unstable_by(|a, b| a.x.total_cmp(&b.x));
let cat_star_list = StarList(cat_stars);
let failed = PositionTry {
sep_deg: Some(sep_deg),
outcome: None,
};
let (img_pos, cat_pos, n_raw) = match params.method {
SolveMethod::Quads => {
let mut cat_quads = build_quads_presorted(&cat_star_list, ctx.nrstars_image);
if ctx.nrstars_image < ctx.star_limit {
add_density_matched_quads(ctx, &cat_raw, ra_db, dec_db, &mut cat_quads);
}
if cat_quads.is_empty() {
return failed;
}
crate::quads::r#match::sort_catalog_quads(&mut cat_quads);
let raw = find_matches_sorted(ctx.img_quads, &cat_quads, params.quad_tolerance);
let n_raw = raw.len();
log::info!("Found {n_raw} references");
let mut filtered = vote_filter(ctx.img_quads, &cat_quads, &raw, params.quad_tolerance);
if filtered.len() < ctx.min_quads {
let (by_scale, _) = filter_by_scale(&raw, params.quad_tolerance);
if by_scale.len() > filtered.len() {
filtered = by_scale;
}
}
if filtered.len() < ctx.min_quads {
return failed;
}
let (ip, cp) = extract_star_pairs(ctx.img_quads, &cat_quads, &filtered);
(ip, cp, n_raw)
}
SolveMethod::Tetra => {
let cat_tris = build_triangles(&cat_star_list);
if cat_tris.is_empty() {
return failed;
}
let tol = params.quad_tolerance * TETRA_TOL_FACTOR;
let raw = find_triangle_matches(ctx.img_tris, &cat_tris, tol);
let n_raw = raw.len();
log::info!("Found {n_raw} triangle references");
let biject = bijective_filter(&raw, ctx.img_tris, &cat_tris);
let (filtered, _) = filter_triangles_by_scale(&biject, params.quad_tolerance);
if filtered.len() < ctx.min_quads {
return failed;
}
let (ip, cp) = extract_triangle_pairs(ctx.img_tris, &cat_tris, &filtered);
(ip, cp, n_raw)
}
};
let seeds = Seeds {
img: img_pos.clone(),
cat: cat_pos.clone(),
ra: ra_db,
dec: dec_db,
};
let Some((plate, n_matched)) = fit_pattern_pairs(img_pos, cat_pos, ctx.min_quads) else {
return failed;
};
let found = |verified, ra_db, dec_db, refused| PositionTry {
sep_deg: Some(sep_deg),
outcome: Some(PositionOutcome {
idx,
ra_db,
dec_db,
sep_deg,
verified,
n_matched,
n_raw,
mag_limit,
refused,
}),
};
let Some(verified) = verify_and_refit(
ctx.stars,
&cat_star_list,
&plate,
ctx.img.width,
ctx.img.height,
&ctx.accept,
) else {
log::info!("Verification failed at this position; continuing search.");
if n_matched >= STRONG_VOTE
&& let Some((verified, ra_c, dec_c)) =
second_chance(ctx, &cat_raw, &seeds, &plate, ra_db, dec_db)
{
return found(verified, ra_c, dec_c, false);
}
return failed;
};
log::info!(
"Verified {} stars against the catalogue, residual {:.2}\"",
verified.n(),
verified.rms
);
let (verified, ra_db, dec_db) = recentre(ctx, &cat_raw, verified, ra_db, dec_db);
match model_distortion(ctx, &cat_raw, &seeds, verified, ra_db, dec_db) {
Modelled::Linear(v) => found(v, ra_db, dec_db, false),
Modelled::Distorted(v, ra_c, dec_c) => found(v, ra_c, dec_c, false),
Modelled::Refused(v) => found(v, ra_db, dec_db, true),
}
}
const STRONG_VOTE: usize = 50;
fn project(cat_raw: &[CatalogStar], ra: f64, dec: f64) -> StarList {
StarList(
cat_raw
.iter()
.map(|s| {
let (x, y) = equatorial_standard(ra, dec, s.ra, s.dec, 1.0);
Star {
x,
y,
snr: 1.0,
hfd: 2.0,
}
})
.collect(),
)
}
struct Seeds {
img: Vec<(f64, f64)>,
cat: Vec<(f64, f64)>,
ra: f64,
dec: f64,
}
impl Seeds {
fn in_plane(&self, ra: f64, dec: f64) -> Vec<Pair> {
self.img
.iter()
.zip(&self.cat)
.map(|(&i, &(x, y))| {
if ra == self.ra && dec == self.dec {
return (i, (x, y));
}
let (sra, sdec) = standard_equatorial(self.ra, self.dec, x, y, 1.0);
(i, equatorial_standard(ra, dec, sra, sdec, 1.0))
})
.collect()
}
}
fn fit_distortion(
ctx: &SpiralCtx<'_>,
cat_raw: &[CatalogStar],
seeds: &Seeds,
plate: &PlateConstants,
ra: f64,
dec: f64,
) -> Option<Refined> {
let (w, h) = (ctx.img.width as f64, ctx.img.height as f64);
let (xs, ys) = (
plate.a * (w - 1.0) * 0.5 + plate.b * (h - 1.0) * 0.5 + plate.c,
plate.d * (w - 1.0) * 0.5 + plate.e * (h - 1.0) * 0.5 + plate.f,
);
let (ra_c, dec_c) = standard_equatorial(ra, dec, xs, ys, 1.0);
let window = w.hypot(h) / w.max(h);
let cat = match read_catalog_stars(
&ctx.params.db_path,
&ctx.params.db_name,
ra_c,
dec_c,
ctx.params.fov * window,
(ctx.params.max_stars as f64 * window * window).round() as usize,
) {
Ok(v) if !v.is_empty() => project(&v, ra, dec),
_ => project(cat_raw, ra, dec),
};
let grid = StarGrid::new(ctx.stars, VERIFY_RADII[0])?;
let r = refine(
&grid,
&cat,
&seeds.in_plane(ra, dec),
plate,
ctx.img.width,
ctx.img.height,
VERIFY_RADII[VERIFY_RADII.len() - 1],
)?;
log::info!(
"Distortion model: {} terms, {} stars within {} px, rms {:.2} px, F {:.1} over linear, {} of 9 cells",
r.model.n_terms,
r.img_pos.len(),
VERIFY_RADII[VERIFY_RADII.len() - 1],
r.rms / r.model.scale(),
r.f_linear,
r.cells
);
Some(r)
}
fn linear_from_model(
ctx: &SpiralCtx<'_>,
r: &Refined,
ra: f64,
dec: f64,
) -> Option<(Verified, f64, f64)> {
let (w, h) = (ctx.img.width, ctx.img.height);
let (xs, ys) = r
.model
.apply((w as f64 - 1.0) * 0.5, (h as f64 - 1.0) * 0.5);
let (ra_c, dec_c) = standard_equatorial(ra, dec, xs, ys, 1.0);
let moved = |(x, y): (f64, f64)| {
let (sra, sdec) = standard_equatorial(ra, dec, x, y, 1.0);
equatorial_standard(ra_c, dec_c, sra, sdec, 1.0)
};
let plate = best_linear(|x, y| moved(r.model.apply(x, y)), w, h)?;
let cat_pos = r.cat_pos.iter().map(|&p| moved(p)).collect();
Some((
Verified {
plate,
rms: r.rms,
img_pos: r.img_pos.clone(),
cat_pos,
},
ra_c,
dec_c,
))
}
enum Modelled {
Linear(Verified),
Distorted(Verified, f64, f64),
Refused(Verified),
}
const MIN_REPORT_F: f64 = 30.0;
const MIN_DEPARTURE_PX: f64 = 1.0;
const REFUSE_F: f64 = 100.0;
const REFUSE_DEPARTURE_PX: f64 = 3.0;
fn model_distortion(
ctx: &SpiralCtx<'_>,
cat_raw: &[CatalogStar],
seeds: &Seeds,
verified: Verified,
ra: f64,
dec: f64,
) -> Modelled {
let Some(r) = fit_distortion(ctx, cat_raw, seeds, &verified.plate, ra, dec) else {
return Modelled::Linear(verified);
};
let (w, h) = (ctx.img.width, ctx.img.height);
let departure = max_departure_px(&r.model, &verified.plate, w, h);
log::info!(
"Distortion: verified plate departs {departure:.2} px from the model; {} stars against {} verified",
r.img_pos.len(),
verified.n(),
);
let min_cells = if r.model.n_terms == 10 { 9 } else { 7 };
let usable = r.model.n_terms > 3
&& r.cells >= min_cells
&& r.f_linear >= MIN_REPORT_F
&& r.img_pos.len() * 10 >= verified.n() * 9;
if usable {
if departure >= MIN_DEPARTURE_PX
&& let Some((v, ra_c, dec_c)) = linear_from_model(ctx, &r, ra, dec)
{
log::info!("Reporting the linear plate closest to the distortion model.");
return Modelled::Distorted(v, ra_c, dec_c);
}
return Modelled::Linear(verified);
}
let (wide_f, wide_dep) = r.unmodelled();
log::info!("Where the stars are: a cubic with F {wide_f:.1}, {wide_dep:.2} px from the plate.");
if wide_f >= REFUSE_F && wide_dep >= REFUSE_DEPARTURE_PX {
log::info!(
"The field is distorted by {wide_dep:.1} px where it has stars, and the distortion \
cannot be modelled over the whole frame: refusing a linear solution."
);
return Modelled::Refused(verified);
}
Modelled::Linear(verified)
}
fn spread_of(img_pos: &[(f64, f64)], img_w: usize, img_h: usize) -> f64 {
let n = img_pos.len() as f64;
let mx = img_pos.iter().map(|p| p.0).sum::<f64>() / n;
let my = img_pos.iter().map(|p| p.1).sum::<f64>() / n;
let var = img_pos
.iter()
.map(|&(x, y)| (x - mx) * (x - mx) + (y - my) * (y - my))
.sum::<f64>()
/ n;
let half_diag = 0.5 * ((img_w * img_w + img_h * img_h) as f64).sqrt();
var.sqrt() / half_diag
}
fn second_chance(
ctx: &SpiralCtx<'_>,
cat_raw: &[CatalogStar],
seeds: &Seeds,
plate: &PlateConstants,
ra: f64,
dec: f64,
) -> Option<(Verified, f64, f64)> {
log::info!("Strong pattern match: retrying verification with a distortion model.");
let r = fit_distortion(ctx, cat_raw, seeds, plate, ra, dec)?;
if r.cells < if r.model.n_terms == 10 { 9 } else { 7 } {
log::info!("The distortion model's stars do not cover the frame.");
return None;
}
let probe = Verified {
plate: r.model.linear_part(),
rms: r.rms,
img_pos: r.img_pos.clone(),
cat_pos: r.cat_pos.clone(),
};
let spread = spread_of(&r.img_pos, ctx.img.width, ctx.img.height);
if !ctx.accept.accepts(&probe, spread) {
log::info!("The distortion model did not verify either.");
return None;
}
log::info!(
"Verified {} stars with the distortion model.",
r.img_pos.len()
);
linear_from_model(ctx, &r, ra, dec)
}
const DENSITY_MATCH_MIN_RATIO: f64 = 2.5;
fn add_density_matched_quads(
ctx: &SpiralCtx<'_>,
cat_raw: &[CatalogStar],
ra_db: f64,
dec_db: f64,
cat_quads: &mut crate::types::QuadList,
) {
let k = (ctx.nrstars_image as f64 * ctx.oversize * ctx.oversize * ctx.aspect).round() as usize;
if k < 5 || (k as f64) * DENSITY_MATCH_MIN_RATIO > cat_raw.len() as f64 {
return;
}
let mut sub: Vec<Star> = cat_raw[..k]
.iter()
.map(|s| {
let (x, y) = equatorial_standard(ra_db, dec_db, s.ra, s.dec, 1.0);
Star {
x,
y,
snr: 1.0,
hfd: 2.0,
}
})
.collect();
sub.sort_unstable_by(|a, b| a.x.total_cmp(&b.x));
let extra = build_quads_presorted(&StarList(sub), ctx.nrstars_image);
let key = |q: &crate::types::Quad| {
(
(q.center_x * 1000.0).round() as i64,
(q.center_y * 1000.0).round() as i64,
(q.d1 * 1000.0).round() as i64,
)
};
let seen: std::collections::HashSet<_> = cat_quads.0.iter().map(key).collect();
let before = cat_quads.len();
cat_quads
.0
.extend(extra.0.into_iter().filter(|q| !seen.contains(&key(q))));
log::info!(
"{} more database quads from its {k} brightest stars, the image's density.",
cat_quads.len() - before
);
}
fn recentre(
ctx: &SpiralCtx<'_>,
cat_raw: &[CatalogStar],
mut verified: Verified,
mut ra_db: f64,
mut dec_db: f64,
) -> (Verified, f64, f64) {
let (w, h) = (ctx.img.width as f64, ctx.img.height as f64);
let (cx, cy) = ((w - 1.0) * 0.5, (h - 1.0) * 0.5);
let apply =
|p: &PlateConstants, x: f64, y: f64| (p.a * x + p.b * y + p.c, p.d * x + p.e * y + p.f);
for _ in 0..2 {
let plate = &verified.plate;
let (xs, ys) = apply(plate, cx, cy);
if xs.hypot(ys) < 1e-3 {
break;
}
let (ra0, dec0) = standard_equatorial(ra_db, dec_db, xs, ys, 1.0);
let det = plate.a * plate.e - plate.b * plate.d;
if det.abs() < 1e-12 {
break;
}
let r2 = VERIFY_RADII[0] * VERIFY_RADII[0];
let mut used = vec![false; ctx.stars.len()];
let mut img_pos = Vec::new();
let mut new_pos = Vec::new();
let mut cat = Vec::with_capacity(cat_raw.len());
for s in cat_raw {
let (nx, ny) = equatorial_standard(ra0, dec0, s.ra, s.dec, 1.0);
cat.push(Star {
x: nx,
y: ny,
snr: 1.0,
hfd: 2.0,
});
let (ox, oy) = equatorial_standard(ra_db, dec_db, s.ra, s.dec, 1.0);
let (dx, dy) = (ox - plate.c, oy - plate.f);
let px = (plate.e * dx - plate.b * dy) / det;
let py = (-plate.d * dx + plate.a * dy) / det;
let nearest = ctx
.stars
.0
.iter()
.enumerate()
.filter(|&(i, _)| !used[i])
.map(|(i, st)| (i, (st.x - px).powi(2) + (st.y - py).powi(2)))
.filter(|&(_, d2)| d2 < r2)
.min_by(|a, b| a.1.total_cmp(&b.1));
if let Some((i, _)) = nearest {
used[i] = true;
img_pos.push((ctx.stars.0[i].x, ctx.stars.0[i].y));
new_pos.push((nx, ny));
}
}
let Ok(guess) = solve_plate_constants(&img_pos, &new_pos) else {
break;
};
let cat = StarList(cat);
let Some(v) = verify_and_refit(
ctx.stars,
&cat,
&guess,
ctx.img.width,
ctx.img.height,
&ctx.accept,
) else {
log::info!("Re-centring on the image centre did not verify; keeping the fit.");
break;
};
log::info!(
"Re-centred on the image centre: verified {} stars, residual {:.2}\"",
v.n(),
v.rms
);
(verified, ra_db, dec_db) = (v, ra0, dec0);
}
(verified, ra_db, dec_db)
}
pub fn solve_image(img: &crate::types::ImageBuffer, params: &SolveParams) -> Result<WcsSolution> {
if !(params.fov.is_finite() && params.fov > 0.0) {
return Err(ArcsecError::InvalidParameter(format!(
"field of view must be positive, got {} rad",
params.fov
)));
}
if !(params.search_radius.is_finite() && params.search_radius >= 0.0) {
return Err(ArcsecError::InvalidParameter(format!(
"search radius must be non-negative, got {} rad",
params.search_radius
)));
}
if !crate::catalog::catalog_present(¶ms.db_path, ¶ms.db_name) {
return Err(ArcsecError::CatalogNotFound(params.db_path.clone()));
}
let bg = get_background(img, params.max_stars);
log::info!("Start finding stars");
let (stars, stars_raw) = find_stars_with_background(
img,
&bg,
params.hfd_min,
params.max_stars,
img.width,
img.height,
);
log::info!(
"{} stars found of the requested {}. Background value is {:.0}. \
Detection level used {:.0} above background. Star level is {:.0} above background. \
Noise level is {:.0}",
stars_raw,
params.max_stars,
bg.mean,
bg.star_level,
bg.star_level,
bg.noise,
);
if stars_raw > params.max_stars {
log::info!("Selecting the {} brightest stars only.", params.max_stars);
}
let star_limit = density_star_limit(params, img);
let mut stars = stars;
if stars.len() > star_limit {
stars.0.sort_by(|a, b| b.snr.total_cmp(&a.snr));
stars.0.truncate(star_limit);
log::info!(
"Database limit for this field is {star_limit} stars; using the {star_limit} brightest."
);
}
let nrstars_image = stars.len();
if nrstars_image < 5 {
return Err(ArcsecError::InsufficientStars {
found: nrstars_image,
required: 5,
});
}
let img_quads = build_quads(&stars, nrstars_image);
let nr_quads = img_quads.len();
let img_tris = if params.method == SolveMethod::Tetra {
build_triangles(&stars)
} else {
crate::quads::TriangleList::default()
};
let patterns_empty = match params.method {
SolveMethod::Quads => nr_quads == 0,
SolveMethod::Tetra => img_tris.is_empty(),
};
if patterns_empty {
return Err(ArcsecError::InsufficientQuads {
found: 0,
required: 3,
});
}
let min_quads: usize = 3 + nrstars_image / 140;
let oversize: f64 = match params.speed {
SearchSpeed::Auto if nrstars_image < 35 => 2.0,
SearchSpeed::Auto if nrstars_image > 140 => 1.0,
SearchSpeed::Auto => 2.0 * (35.0 / nrstars_image as f64).sqrt(),
SearchSpeed::Slow => {
let max_fov_deg = match crate::catalog::detect_layout(¶ms.db_path, ¶ms.db_name)
{
CatalogLayout::Areas1476 => 5.142_857_143_f64,
CatalogLayout::Areas290 => 9.53,
CatalogLayout::AllSky001 => 180.0,
};
2.0_f64.min(max_fov_deg.to_radians() / params.fov).max(1.0)
}
};
let nrstars_required = (params.max_stars as f64 * oversize * oversize).round() as usize;
let step_size = params.fov;
let fov_deg = step_size.to_degrees();
let max_distance = (params.search_radius / step_size + 2.0) as i32;
log::info!(
"{} stars, {} quads selected in the image. {} database stars, {} database quads required \
for the {:.2}d square search window. Step size {:.2}d. Oversize {:.2}",
nrstars_image,
nr_quads,
nrstars_required,
nrstars_required,
fov_deg * oversize,
fov_deg,
oversize,
);
let ctx = SpiralCtx {
params,
img,
stars: &stars,
img_quads: &img_quads,
img_tris: &img_tris,
nrstars_image,
star_limit,
nrstars_required,
oversize,
min_quads,
step_size,
accept: Acceptance::new(nrstars_image, params, img),
aspect: img.width.max(img.height) as f64 / img.width.min(img.height).max(1) as f64,
};
let n_threads = if params.threads > 0 {
params.threads
} else {
crate::max_threads()
}
.clamp(1, 64);
let positions: Vec<(i32, i32)> = SpiralSearch::new(max_distance).collect();
let mut step_distances: Vec<f64> = Vec::new();
let mut winner: Option<PositionOutcome> = None;
let mut start_idx = 0usize;
while start_idx < positions.len() && winner.is_none() {
let batch_len = if start_idx == 0 {
1
} else {
n_threads.min(positions.len() - start_idx)
};
let batch = &positions[start_idx..start_idx + batch_len];
let tries: Vec<PositionTry> = if n_threads == 1 || batch.len() == 1 {
batch
.iter()
.enumerate()
.map(|(k, &(sx, sy))| try_position(&ctx, start_idx + k, sx, sy))
.collect()
} else {
std::thread::scope(|scope| {
let handles: Vec<_> = batch
.iter()
.enumerate()
.map(|(k, &(sx, sy))| {
let ctx = &ctx;
scope.spawn(move || try_position(ctx, start_idx + k, sx, sy))
})
.collect();
handles
.into_iter()
.map(|h| h.join().unwrap_or_else(|e| std::panic::resume_unwind(e)))
.collect()
})
};
for t in tries {
if let Some(d) = t.sep_deg {
step_distances.push(d);
}
if let Some(o) = t.outcome
&& winner.as_ref().is_none_or(|w| o.idx < w.idx)
{
winner = Some(o);
}
}
start_idx += batch_len;
}
if let Some(o) = winner.as_ref().filter(|o| o.refused) {
log::info!(
"No solution: the field at search position {} is too distorted for a linear plate.",
o.idx
);
return Err(ArcsecError::InsufficientQuads {
found: 0,
required: min_quads,
});
}
if let Some(o) = winner {
log::info!(
"{} of {} patterns selected matching within {:.3} tolerance.",
o.n_matched,
o.n_raw,
params.quad_tolerance,
);
let v = o.verified;
let mut wcs = derive_wcs(o.ra_db, o.dec_db, &v.plate, img.width, img.height);
let b = params.binning.max(1) as f64;
wcs.matched_stars = v
.img_pos
.iter()
.zip(&v.cat_pos)
.map(|(&(x, y), &(sx, sy))| {
let (ra, dec) = standard_equatorial(o.ra_db, o.dec_db, sx, sy, 1.0);
MatchedStar {
x: (x + 0.5) * b + 0.5,
y: (y + 0.5) * b + 0.5,
ra,
dec,
}
})
.collect();
if params.binning > 1 {
let b = params.binning as f64;
wcs.crpix1 = (wcs.crpix1 - 0.5) * b + 0.5;
wcs.crpix2 = (wcs.crpix2 - 0.5) * b + 0.5;
wcs.cd1_1 /= b;
wcs.cd1_2 /= b;
wcs.cd2_1 /= b;
wcs.cd2_2 /= b;
wcs.cdelt1 /= b;
wcs.cdelt2 /= b;
}
wcs.residual_rms = v.rms;
wcs.stars_matched = v.n();
wcs.raw_matches = o.n_raw;
wcs.plate = v.plate;
wcs.mag_limit = o.mag_limit;
wcs.search_dist_deg = o.sep_deg;
wcs.step_distances = step_distances;
return Ok(wcs);
}
Err(ArcsecError::InsufficientQuads {
found: 0,
required: min_quads,
})
}
#[must_use]
pub fn format_ra(ra_rad: f64) -> String {
const TENTHS_PER_DAY: f64 = 24.0 * 36_000.0;
let ra_tenths = ((ra_rad.to_degrees() / 15.0 * 36_000.0)
.round()
.rem_euclid(TENTHS_PER_DAY)) as u64;
let h = ra_tenths / 36_000;
let m = ra_tenths / 600 % 60;
let s = ra_tenths % 600 / 10;
let tenths = ra_tenths % 10;
format!("{h:02}: {m:02} {s:02}.{tenths}")
}
#[must_use]
pub fn format_dec(dec_rad: f64) -> String {
let dec_deg = dec_rad.to_degrees();
let sign = if dec_deg < 0.0 { '-' } else { '+' };
let dec_secs = (dec_deg.abs() * 3600.0).round() as u64;
let dd = dec_secs / 3600;
let dm = dec_secs / 60 % 60;
let ds = dec_secs % 60;
format!("{sign}{dd:02}d {dm:02} {ds:02}")
}
#[must_use]
pub fn format_radec(ra_rad: f64, dec_rad: f64) -> String {
format!("{} {}", format_ra(ra_rad), format_dec(dec_rad))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::math::coords::{ang_sep, standard_equatorial};
use crate::test_support::{
Rng, SkySpec, SkyStar, TempDir, TruthWcs, random_sky, render, write_001_db, write_290_db,
write_1476_db,
};
use crate::types::{ImageBuffer, PlateConstants};
use crate::wcs::output::derive_wcs;
use core::f64::consts::PI;
fn deg(d: f64) -> f64 {
d * PI / 180.0
}
fn make_test_scene(
n_stars: usize,
ra_center: f64,
dec_center: f64,
cdelt_arcsec: f64,
width: usize,
height: usize,
) -> (ImageBuffer, Vec<(f64, f64)>, PlateConstants) {
let mut data = vec![100.0f32; width * height];
let mut catalog_sky: Vec<(f64, f64)> = Vec::new();
let stars_per_row = (n_stars as f64).sqrt().ceil() as usize;
let spacing = 40.0;
let cx = (width as f64 - 1.0) / 2.0;
let cy = (height as f64 - 1.0) / 2.0;
let a = cdelt_arcsec;
let c = -a * cx;
let e = cdelt_arcsec;
let f_offset = -e * cy;
let plate = PlateConstants {
a,
b: 0.0,
c,
d: 0.0,
e,
f: f_offset,
};
let mut count = 0;
'outer: for row in 0..stars_per_row {
for col in 0..stars_per_row {
if count >= n_stars {
break 'outer;
}
let px = 20.0 + col as f64 * spacing;
let py = 20.0 + row as f64 * spacing;
if px >= width as f64 - 20.0 || py >= height as f64 - 20.0 {
continue;
}
let x_std = a * px + c;
let y_std = e * py + f_offset;
let (ra, dec) = standard_equatorial(ra_center, dec_center, x_std, y_std, 1.0);
catalog_sky.push((ra, dec));
let sigma = 2.0;
let amp = 30000.0f32;
for dy in -8i32..=8 {
for dx in -8i32..=8 {
let x = (px as i32 + dx) as usize;
let y = (py as i32 + dy) as usize;
if x < width && y < height {
let r2 = (dx * dx + dy * dy) as f64 / (2.0 * sigma * sigma);
data[y * width + x] += amp * (-r2).exp() as f32;
}
}
}
count += 1;
}
}
let img = ImageBuffer {
data,
width,
height,
};
(img, catalog_sky, plate)
}
#[test]
fn derive_wcs_recovers_position() {
let ra_center = deg(45.0);
let dec_center = deg(30.0);
let (img, _cat, plate) = make_test_scene(16, ra_center, dec_center, 2.0, 300, 300);
let wcs = derive_wcs(ra_center, dec_center, &plate, img.width, img.height);
let sep_arcsec = ang_sep(wcs.ra0, wcs.dec0, ra_center, dec_center) * (180.0 / PI * 3600.0);
assert!(sep_arcsec < 0.5, "centre offset = {sep_arcsec} arcsec");
}
#[test]
fn spiral_covers_origin_first() {
assert_eq!(SpiralSearch::new(5).next(), Some((0, 0)));
}
#[test]
fn oversize_formula_limits() {
for n in [10, 35, 70, 140, 200] {
let ov: f64 = if n < 35 {
2.0
} else if n > 140 {
1.0
} else {
2.0 * (35.0 / n as f64).sqrt()
};
assert!((1.0..=2.0).contains(&ov), "oversize={ov} for n={n}");
}
}
#[test]
fn format_radec_carries_rounded_seconds() {
let ra = deg((1.0 + 59.0 / 60.0 + 59.97 / 3600.0) * 15.0);
let dec = deg(10.0 + 59.0 / 60.0 + 59.7 / 3600.0);
assert_eq!(format_radec(ra, dec), "02: 00 00.0 +11d 00 00");
let s = format_radec(deg(359.999_999_9), deg(-0.5));
assert_eq!(s, "00: 00 00.0 -00d 30 00");
assert_eq!(
format_radec(deg((5.0 + 35.0 / 60.0 + 17.3 / 3600.0) * 15.0), deg(-5.39)),
"05: 35 17.3 -05d 23 24"
);
}
#[test]
fn ra_and_dec_are_formatted_as_astap_cli_prints_them() {
let ra = deg(65.0); let dec = deg(35.0);
assert_eq!(format_ra(ra), "04: 20 00.0");
assert_eq!(format_dec(dec), "+35d 00 00");
assert_eq!(format_radec(ra, dec), "04: 20 00.0 +35d 00 00");
assert_eq!(
format_radec(
deg((13.0 + 7.0 / 60.0 + 9.25 / 3600.0) * 15.0),
-deg(89.0 + 1.0 / 60.0 + 2.0 / 3600.0)
),
"13: 07 09.3 -89d 01 02"
);
assert_eq!(format_dec(deg(-0.0001)), "-00d 00 00");
}
#[test]
fn solve_image_rejects_a_non_positive_fov() {
let img = ImageBuffer::new(64, 64);
let params = SolveParams {
ra_hint: 0.0,
dec_hint: 0.0,
fov: 0.0,
search_radius: 0.1,
quad_tolerance: 0.007,
hfd_min: 1.5,
max_stars: 500,
db_path: std::path::PathBuf::from("/nonexistent"),
db_name: "d50".into(),
binning: 1,
method: SolveMethod::Quads,
threads: 1,
speed: SearchSpeed::Auto,
};
assert!(matches!(
solve_image(&img, ¶ms),
Err(ArcsecError::InvalidParameter(_))
));
}
fn known_plate() -> PlateConstants {
let (s, r) = (3.2_f64, 0.61_f64);
PlateConstants {
a: -s * r.cos(),
b: s * r.sin(),
c: 640.0,
d: s * r.sin(),
e: s * r.cos(),
f: -512.0,
}
}
fn apply(p: &PlateConstants, (x, y): (f64, f64)) -> (f64, f64) {
(p.a * x + p.b * y + p.c, p.d * x + p.e * y + p.f)
}
fn plate_close(p: &PlateConstants, q: &PlateConstants, tol: f64) -> bool {
[
(p.a, q.a),
(p.b, q.b),
(p.c, q.c),
(p.d, q.d),
(p.e, q.e),
(p.f, q.f),
]
.iter()
.all(|(u, v)| (u - v).abs() <= tol)
}
const STRICT: Acceptance = Acceptance {
min_stars: MIN_VERIFIED_STARS,
expected_scale: 3.2,
};
fn star_at(x: f64, y: f64) -> Star {
Star {
x,
y,
snr: 50.0,
hfd: 2.5,
}
}
fn pairs_with_outliers(outlier: impl Fn(usize, (f64, f64)) -> (f64, f64)) -> PairedPositions {
let plate = known_plate();
let mut rng = Rng::new(7);
let mut img = Vec::new();
let mut cat = Vec::new();
for _ in 0..40 {
let p = (rng.range(0.0, 500.0), rng.range(0.0, 500.0));
img.push(p);
cat.push(apply(&plate, p));
}
for k in 0..5 {
let p = (rng.range(0.0, 500.0), rng.range(0.0, 500.0));
img.push(p);
cat.push(outlier(k, apply(&plate, p)));
}
(img, cat)
}
#[test]
fn sigma_clip_pairs_rejects_outliers_and_keeps_the_rest() {
let (img, cat) = pairs_with_outliers(|k, (x, y)| {
let a = k as f64 * 1.3;
(x + 100.0 * a.cos(), y + 100.0 * a.sin())
});
let (ci, cc) = sigma_clip_pairs(img, cat, 3.0, 3);
assert_eq!(ci.len(), 40, "all and only the true pairs survive");
let fit = solve_plate_constants(&ci, &cc).unwrap();
assert!(plate_close(&fit, &known_plate(), 1e-6), "{fit:?}");
}
#[test]
fn sigma_clip_pairs_rejects_gross_outliers() {
let (img, cat) =
pairs_with_outliers(|k, _| (1000.0 + 150.0 * k as f64, -900.0 + 70.0 * k as f64));
assert!(matches!(
solve_plate_constants(&img, &cat),
Err(ArcsecError::BadSolution { .. })
));
let (ci, _) = sigma_clip_pairs(img, cat, 3.0, 3);
assert_eq!(ci.len(), 40, "the five gross outliers should be clipped");
}
#[test]
fn fit_pattern_pairs_recovers_a_plate_the_plain_fit_refuses() {
let (img, cat) =
pairs_with_outliers(|k, _| (1000.0 + 150.0 * k as f64, -900.0 + 70.0 * k as f64));
assert!(solve_plate_constants(&img, &cat).is_err());
let (plate, n) = fit_pattern_pairs(img.clone(), cat.clone(), 3).expect("clipped fit");
assert_eq!(n, 40);
assert!(plate_close(&plate, &known_plate(), 1e-6), "{plate:?}");
let (plate, n) = fit_pattern_pairs(img[..40].to_vec(), cat[..40].to_vec(), 3).unwrap();
assert_eq!(n, 40);
assert!(plate_close(&plate, &known_plate(), 1e-6));
assert!(fit_pattern_pairs(img, cat, 41).is_none());
}
#[test]
fn sigma_clip_pairs_leaves_too_few_pairs_alone() {
let img = vec![(0.0, 0.0), (1.0, 0.0)];
let cat = vec![(5.0, 5.0), (9.0, 9.0)];
let (ci, cc) = sigma_clip_pairs(img.clone(), cat.clone(), 3.0, 3);
assert_eq!((ci, cc), (img, cat));
}
#[test]
fn verify_and_refit_recovers_the_plate_from_a_rough_guess() {
let truth = known_plate();
let mut rng = Rng::new(11);
let mut img_stars = Vec::new();
let mut cat_stars = Vec::new();
for _ in 0..60 {
let (x, y) = (rng.range(5.0, 395.0), rng.range(5.0, 295.0));
img_stars.push(star_at(x, y));
let (cx, cy) = apply(&truth, (x, y));
cat_stars.push(star_at(cx, cy));
}
for k in 0..20 {
let (cx, cy) = apply(&truth, (-300.0 - 10.0 * k as f64, 900.0));
cat_stars.push(star_at(cx, cy));
}
let mut rough = truth.clone();
rough.c += 2.0 * truth.a;
rough.f += 2.0 * truth.e;
rough.b += 0.01;
let v = verify_and_refit(
&StarList(img_stars),
&StarList(cat_stars),
&rough,
400,
300,
&STRICT,
)
.expect("a correct plate must verify");
assert_eq!(v.n(), 60);
assert_eq!(v.cat_pos.len(), 60);
assert!(v.rms < 1e-6, "rms {}", v.rms);
assert!(plate_close(&v.plate, &truth, 1e-6), "{:?}", v.plate);
for (&(x, y), &(cx, cy)) in v.img_pos.iter().zip(&v.cat_pos) {
let (px, py) = apply(&truth, (x, y));
assert!((px - cx).hypot(py - cy) < 1e-6);
}
}
#[test]
fn verify_and_refit_rejects_too_few_or_clustered_matches() {
let truth = known_plate();
let mut rng = Rng::new(12);
let build = |pts: &[(f64, f64)]| {
let img = StarList(pts.iter().map(|&(x, y)| star_at(x, y)).collect());
let cat = StarList(
pts.iter()
.map(|&p| apply(&truth, p))
.map(|(x, y)| star_at(x, y))
.collect(),
);
(img, cat)
};
let few: Vec<_> = (0..20)
.map(|_| (rng.range(0.0, 400.0), rng.range(0.0, 300.0)))
.collect();
let (img, cat) = build(&few);
assert!(verify_and_refit(&img, &cat, &truth, 400, 300, &STRICT).is_none());
let clustered: Vec<_> = (0..80)
.map(|_| (rng.range(0.0, 40.0), rng.range(0.0, 40.0)))
.collect();
let (img, cat) = build(&clustered);
assert!(verify_and_refit(&img, &cat, &truth, 400, 300, &STRICT).is_none());
let spread: Vec<_> = (0..80)
.map(|_| (rng.range(0.0, 400.0), rng.range(0.0, 300.0)))
.collect();
let (img, cat) = build(&spread);
assert!(verify_and_refit(&img, &cat, &truth, 400, 300, &STRICT).is_some());
let empty = StarList::default();
assert!(verify_and_refit(&empty, &cat, &truth, 400, 300, &STRICT).is_none());
let mut singular = truth.clone();
singular.a = 0.0;
singular.b = 0.0;
assert!(verify_and_refit(&img, &cat, &singular, 400, 300, &STRICT).is_none());
}
#[derive(Clone, Copy)]
enum Db {
Areas1476,
Areas290,
AllSky001,
}
struct Scene {
dir: TempDir,
img: ImageBuffer,
truth: TruthWcs,
sky: Vec<SkyStar>,
}
fn scene(truth: TruthWcs, db: Db, n_in_frame: usize, seed: u64) -> Scene {
let mut rng = Rng::new(seed);
let scale_deg = truth.cd[1].hypot(truth.cd[3]);
let (w_deg, h_deg) = (
truth.width as f64 * scale_deg,
truth.height as f64 * scale_deg,
);
let side = 6.0 * w_deg.max(h_deg);
let sky = random_sky(
&mut rng,
&SkySpec {
ra0: truth.ra0,
dec0: truth.dec0,
side_deg: side,
n: (n_in_frame as f64 * side * side / (w_deg * h_deg)) as usize,
min_sep_deg: 12.0 * scale_deg,
mag_lo: 10.0,
mag_hi: 14.5,
},
);
let sigma = 1.3 * 5.0 / (scale_deg * 3600.0);
let img = render(
&truth,
&sky,
sigma.max(1.3),
1000.0,
8.0,
30_000.0,
&mut rng,
);
let dir = TempDir::new("solve");
match db {
Db::Areas1476 => write_1476_db(dir.path(), "t50", &sky),
Db::Areas290 => write_290_db(dir.path(), "t50", &sky),
Db::AllSky001 => write_001_db(dir.path(), "t50", &sky),
}
Scene {
dir,
img,
truth,
sky,
}
}
fn params_for_blank() -> SolveParams {
SolveParams {
ra_hint: 0.0,
dec_hint: 0.0,
fov: deg(1.0),
search_radius: 0.0,
quad_tolerance: 0.007,
hfd_min: 1.5,
max_stars: 500,
db_path: std::path::PathBuf::from("/nonexistent"),
db_name: "d50".into(),
binning: 1,
method: SolveMethod::Quads,
threads: 1,
speed: SearchSpeed::Auto,
}
}
fn params_for(s: &Scene, ra_hint: f64, dec_hint: f64) -> SolveParams {
SolveParams {
ra_hint,
dec_hint,
fov: (s.truth.height as f64 * s.truth.cd[1].hypot(s.truth.cd[3])).to_radians(),
search_radius: deg(2.0),
quad_tolerance: 0.007,
hfd_min: 1.5,
max_stars: 500,
db_path: s.dir.path().to_path_buf(),
db_name: "t50".into(),
binning: 1,
method: SolveMethod::Quads,
threads: 1,
speed: SearchSpeed::Auto,
}
}
fn assert_solved(s: &Scene, wcs: &WcsSolution, tol_arcsec: f64) {
let err = s.truth.max_error_arcsec(wcs);
assert!(
err < tol_arcsec,
"worst centre/corner error {err:.3}\" (matched {}, rms {:.3})",
wcs.stars_matched,
wcs.residual_rms
);
assert!(wcs.stars_matched >= 10);
let scale_arcsec = s.truth.cd[1].hypot(s.truth.cd[3]) * 3600.0;
assert!(
wcs.residual_rms < 0.3 * scale_arcsec,
"rms {}",
wcs.residual_rms
);
assert!(wcs.raw_matches > 0);
assert_matches_agree(wcs, 0.3, 1.0);
assert!(wcs.mag_limit > 10.0 && wcs.mag_limit <= 14.5);
assert!(
wcs.cdelt1 < 0.0 && wcs.cdelt2 > 0.0,
"CDELT sign convention"
);
}
fn assert_matches_agree(wcs: &WcsSolution, rms_px: f64, binning: f64) {
assert_eq!(wcs.matched_stars.len(), wcs.stars_matched);
assert!(wcs.sip.is_none(), "solve_image never fits SIP");
let tan = crate::wcs::TanWcs::from(wcs);
let mut sq = 0.0;
for m in &wcs.matched_stars {
let (x, y) = tan.sky_to_pixel(m.ra, m.dec).unwrap();
let d = (x - m.x).hypot(y - m.y);
assert!(
d < VERIFY_RADII[VERIFY_RADII.len() - 1] * binning,
"pair at ({:.2},{:.2}) projects to ({x:.2},{y:.2})",
m.x,
m.y
);
sq += d * d;
}
let rms = (sq / wcs.matched_stars.len() as f64).sqrt();
assert!(rms < rms_px, "pair rms {rms} px");
}
#[test]
fn solves_a_1476_database_from_an_offset_hint() {
let truth = TruthWcs::new(deg(84.3), deg(-5.2), 5.0, 23.0, false, 400, 320);
let s = scene(truth, Db::Areas1476, 130, 1);
let mut p = params_for(&s, deg(84.3 + 0.6), deg(-5.2 - 0.45));
p.threads = 4;
let wcs = solve_image(&s.img, &p).expect("solve");
assert_solved(&s, &wcs, 1.0);
assert!(wcs.search_dist_deg > 0.1, "solved at the hint itself?");
assert!(wcs.step_distances.len() > 1);
assert!((wcs.cdelt2 * 3600.0 - 5.0).abs() < 0.01, "{}", wcs.cdelt2);
assert!((wcs.crota2 - 23.0).abs() < 0.05, "crota2 {}", wcs.crota2);
}
#[test]
fn solves_a_mirrored_image_on_a_290_database() {
let truth = TruthWcs::new(deg(201.0), deg(47.5), 6.0, 160.0, true, 360, 360);
let s = scene(truth, Db::Areas290, 120, 2);
let wcs = solve_image(&s.img, ¶ms_for(&s, truth.ra0, truth.dec0)).expect("solve");
assert_solved(&s, &wcs, 1.0);
assert!(wcs.search_dist_deg < 1e-9, "should solve at the hint");
assert!(wcs.cd1_1 * wcs.cd2_2 - wcs.cd1_2 * wcs.cd2_1 > 0.0);
}
#[test]
fn solves_across_ra_zero_with_an_all_sky_001_database() {
let truth = TruthWcs::new(deg(0.05), deg(21.0), 5.0, -70.0, false, 360, 300);
let s = scene(truth, Db::AllSky001, 120, 3);
let wcs = solve_image(&s.img, ¶ms_for(&s, truth.ra0, truth.dec0)).expect("solve");
assert_solved(&s, &wcs, 1.0);
}
#[test]
fn solves_across_ra_zero_with_a_1476_database() {
let truth = TruthWcs::new(deg(359.97), deg(-33.0), 5.0, 95.0, false, 360, 300);
let s = scene(truth, Db::Areas1476, 120, 4);
let wcs = solve_image(&s.img, ¶ms_for(&s, truth.ra0, truth.dec0)).expect("solve");
assert_solved(&s, &wcs, 1.0);
}
#[test]
fn solves_a_field_near_the_celestial_pole() {
let truth = TruthWcs::new(deg(40.0), deg(88.9), 5.0, 10.0, false, 360, 300);
let s = scene(truth, Db::Areas1476, 120, 5);
let wcs = solve_image(&s.img, ¶ms_for(&s, truth.ra0, truth.dec0)).expect("solve");
assert_solved(&s, &wcs, 1.0);
}
#[test]
fn accuracy_does_not_depend_on_the_hint_offset() {
let truth = TruthWcs::new(deg(150.0), deg(30.0), 15.0, 20.0, false, 360, 300);
let s = scene(truth, Db::Areas1476, 120, 21);
let off = 0.4;
let p = params_for(&s, deg(150.0 + off / deg(30.0).cos()), deg(30.0 + off));
let wcs = solve_image(&s.img, &p).expect("solve");
assert!(wcs.search_dist_deg < 1e-9, "solved at the hint");
let err = s.truth.max_error_arcsec(&wcs);
assert!(
err < 5.0,
"worst corner error {err:.2}\" with a {off}° hint offset"
);
}
#[test]
fn solves_with_the_tetra_method() {
let truth = TruthWcs::new(deg(150.0), deg(2.0), 5.0, 45.0, false, 360, 300);
let s = scene(truth, Db::Areas1476, 110, 6);
let mut p = params_for(&s, truth.ra0, truth.dec0);
p.method = SolveMethod::Tetra;
let wcs = solve_image(&s.img, &p).expect("solve");
assert_solved(&s, &wcs, 1.0);
}
#[test]
fn slow_speed_solves_from_an_offset_hint() {
let truth = TruthWcs::new(deg(84.3), deg(-5.2), 5.0, 23.0, false, 400, 320);
let s = scene(truth, Db::Areas1476, 130, 1);
let mut p = params_for(&s, deg(84.3 + 0.6), deg(-5.2 - 0.45));
p.speed = SearchSpeed::Slow;
let wcs = solve_image(&s.img, &p).expect("solve");
assert_solved(&s, &wcs, 1.0);
}
#[test]
fn binned_solve_is_reported_on_the_unbinned_pixel_grid() {
let truth = TruthWcs::new(deg(10.0), deg(40.0), 2.5, 30.0, false, 720, 600);
let s = scene(truth, Db::Areas1476, 120, 7);
let binned = s.img.bin_image(2);
assert_eq!((binned.width, binned.height), (360, 300));
let mut p = params_for(&s, truth.ra0, truth.dec0);
p.binning = 2;
let wcs = solve_image(&binned, &p).expect("solve");
assert!((wcs.crpix1 - 360.5).abs() < 1e-9, "crpix1 {}", wcs.crpix1);
assert!((wcs.crpix2 - 300.5).abs() < 1e-9, "crpix2 {}", wcs.crpix2);
assert!((wcs.cdelt2 * 3600.0 - 2.5).abs() < 0.01, "{}", wcs.cdelt2);
let err = s.truth.max_error_arcsec(&wcs);
assert!(err < 2.0, "worst corner error {err:.3}\"");
assert_matches_agree(&wcs, 0.6, 2.0);
}
#[test]
fn the_star_limit_is_the_database_density_times_the_field_area() {
let params = |fov_deg: f64, db: &str, max_stars: usize| SolveParams {
fov: deg(fov_deg),
max_stars,
db_name: db.into(),
..params_for_blank()
};
let square = ImageBuffer::new(200, 200);
let wide = ImageBuffer::new(400, 200);
assert_eq!(density_star_limit(¶ms(0.2, "d80", 500), &square), 320);
assert_eq!(density_star_limit(¶ms(0.2, "d80", 500), &wide), 160);
assert_eq!(density_star_limit(¶ms(1.0, "d80", 500), &square), 500);
assert_eq!(density_star_limit(¶ms(0.2, "d80", 100), &square), 100);
assert_eq!(density_star_limit(¶ms(0.8, "g05", 500), &square), 320);
assert_eq!(density_star_limit(¶ms(20.0, "w08", 500), &wide), 200);
assert_eq!(density_star_limit(¶ms(0.1, "v17", 500), &square), 500);
}
#[test]
fn a_frame_deeper_than_the_database_solves_at_the_database_limit() {
let truth = TruthWcs::new(deg(250.0), deg(36.0), 3.0, 12.0, false, 600, 500);
let s = scene(truth, Db::Areas1476, 450, 31);
let mut sky = s.sky.clone();
sky.sort_by(|a, b| a.mag.total_cmp(&b.mag));
sky.truncate(200 * 9);
write_1476_db(s.dir.path(), "t02", &sky);
write_1476_db(s.dir.path(), "t17", &sky);
let mut p = params_for(&s, truth.ra0, truth.dec0);
p.fov = (600.0 * 3.0 / 3600.0_f64).to_radians();
p.search_radius = 0.0;
p.db_name = "t17".into();
assert!(
solve_image(&s.img, &p).is_err(),
"every detection: should not match"
);
p.db_name = "t02".into();
let wcs = solve_image(&s.img, &p).expect("solve at the database limit");
assert!(s.truth.max_error_arcsec(&wcs) < 2.0);
}
#[test]
fn min_verified_stars_relaxes_only_for_sparse_images() {
for (n, want) in [
(0, 10),
(5, 10),
(66, 10),
(67, 11),
(100, 15),
(193, 29),
(194, 30),
(200, 30),
(500, 30),
(usize::MAX, 30),
] {
assert_eq!(min_verified_stars(n), want, "{n} detections");
}
}
#[test]
fn a_sparse_match_must_have_the_expected_scale_and_a_tight_fit() {
let truth = known_plate(); let verified = |n: usize, rms_px: f64, scale: f64| {
let mut plate = truth.clone();
for c in [&mut plate.a, &mut plate.b, &mut plate.d, &mut plate.e] {
*c *= scale;
}
Verified {
plate,
rms: rms_px * 3.2 * scale,
img_pos: vec![(0.0, 0.0); n],
cat_pos: vec![(0.0, 0.0); n],
}
};
let accept = Acceptance {
min_stars: 12,
expected_scale: 3.2,
};
assert!(accept.accepts(&verified(30, 3.9, 1.36), 0.5));
assert!(accept.accepts(&verified(12, 0.3, 1.0), 0.5));
assert!(accept.accepts(&verified(20, 0.49, 1.09), 0.5));
assert!(accept.accepts(&verified(20, 0.49, 0.91), 0.5));
assert!(!accept.accepts(&verified(20, 0.3, 1.11), 0.5));
assert!(!accept.accepts(&verified(20, 0.3, 0.89), 0.5));
assert!(!accept.accepts(&verified(29, 0.51, 1.0), 0.5));
assert!(!accept.accepts(&verified(11, 0.1, 1.0), 0.5));
assert!(!accept.accepts(&verified(20, 0.1, 1.0), 0.1));
assert!(!accept.accepts(&verified(12, 2.9, 1.36), 0.5));
}
#[test]
fn a_sparse_frame_solves_at_the_hint_scale_only() {
let truth = TruthWcs::new(deg(30.0), deg(-12.0), 5.0, 40.0, false, 360, 300);
let s = scene(truth, Db::Areas1476, 150, 41);
let mut bright = s.sky.clone();
bright.sort_by(|a, b| a.mag.total_cmp(&b.mag));
let bright: Vec<SkyStar> = bright
.into_iter()
.filter(|st| {
s.truth
.sky_to_pixel(st.ra, st.dec)
.is_some_and(|(x, y)| (5.0..355.0).contains(&x) && (5.0..295.0).contains(&y))
})
.take(22)
.collect();
let mut rng = Rng::new(42);
let img = render(&s.truth, &bright, 1.3, 1000.0, 8.0, 30_000.0, &mut rng);
let mut p = params_for(&s, truth.ra0, truth.dec0);
p.fov = (360.0 * 5.0 / 3600.0_f64).to_radians(); p.search_radius = 0.0;
let wcs = solve_image(&img, &p).expect("sparse solve");
assert!(
wcs.stars_matched < MIN_VERIFIED_STARS,
"{}",
wcs.stars_matched
);
assert!(s.truth.max_error_arcsec(&wcs) < 2.0);
p.fov *= 1.2;
assert!(matches!(
solve_image(&img, &p),
Err(ArcsecError::InsufficientQuads { .. })
));
}
#[test]
fn a_shallow_frame_matches_the_density_matched_catalogue_quads() {
let truth = TruthWcs::new(deg(140.0), deg(55.0), 5.0, -25.0, true, 360, 300);
let s = scene(truth, Db::Areas1476, 500, 51);
let mut bright = s.sky.clone();
bright.sort_by(|a, b| a.mag.total_cmp(&b.mag));
let in_frame = |st: &SkyStar| {
s.truth
.sky_to_pixel(st.ra, st.dec)
.is_some_and(|(x, y)| (0.0..360.0).contains(&x) && (0.0..300.0).contains(&y))
};
let n_frame = bright.iter().filter(|st| in_frame(st)).count();
bright.truncate(bright.len() * 40 / n_frame.max(1));
let mut rng = Rng::new(52);
let img = render(&s.truth, &bright, 1.3, 1000.0, 8.0, 30_000.0, &mut rng);
let mut p = params_for(&s, truth.ra0, truth.dec0);
p.fov = (360.0 * 5.0 / 3600.0_f64).to_radians();
p.search_radius = 0.0;
let wcs = solve_image(&img, &p).expect("shallow solve");
assert!(s.truth.max_error_arcsec(&wcs) < 2.0);
}
fn distorted_scene(corner_px: f64, seed: u64) -> Scene {
let truth = TruthWcs::new(deg(84.3), deg(-5.2), 10.0, 23.0, false, 1024, 768)
.with_corner_distortion(corner_px);
scene(truth, Db::Areas1476, 300, seed)
}
fn sip_error_arcsec(s: &Scene, wcs: &WcsSolution) -> f64 {
let tan = crate::wcs::TanWcs::from(wcs);
let (w, h) = (s.truth.width as f64 - 1.0, s.truth.height as f64 - 1.0);
let mut worst: f64 = 0.0;
for (fx, fy) in [
(0.5, 0.5),
(0.0, 0.0),
(1.0, 0.0),
(0.0, 1.0),
(1.0, 1.0),
(0.5, 0.0),
(0.0, 0.5),
] {
let (x, y) = (w * fx, h * fy);
let (ra_t, dec_t) = s.truth.pixel_to_sky(x, y);
let (ra_s, dec_s) = tan.pixel_to_sky(x + 1.0, y + 1.0);
let sep = crate::test_support::separation(ra_t, dec_t, ra_s, dec_s);
worst = worst.max(sep.to_degrees() * 3600.0);
}
worst
}
#[test]
fn a_distorted_field_reports_the_best_linear_plate_over_the_frame() {
for (hint_ra, hint_dec) in [(84.3, -5.2), (84.3 + 0.9, -5.2 - 0.7)] {
let s = distorted_scene(30.0, 7);
let wcs =
solve_image(&s.img, ¶ms_for(&s, deg(hint_ra), deg(hint_dec))).expect("solve");
let floor = s.truth.linear_floor_arcsec();
let err = s.truth.max_error_arcsec(&wcs);
assert!(floor > 80.0, "floor {floor:.1}\"");
assert!(
err < floor + 5.0,
"corner error {err:.1}\" against a linear floor of {floor:.1}\""
);
assert!(wcs.sip.is_none(), "solve_image never fits SIP");
assert!(wcs.stars_matched > 150, "{} stars", wcs.stars_matched);
let mut with_sip = wcs.clone();
with_sip.sip = crate::wcs::fit_sip(&wcs, 1024, 768);
assert!(with_sip.sip.is_some(), "the distortion is significant");
let sip_err = sip_error_arcsec(&s, &with_sip);
assert!(sip_err < 3.0, "SIP error {sip_err:.2}\"");
}
}
fn part_empty_scene(corner_px: f64) -> Scene {
let mut s = distorted_scene(corner_px, 7);
let w = s.img.width;
for y in 0..s.img.height {
for x in (2 * w / 3)..w {
s.img.data[y * w + x] = 1000.0;
}
}
s
}
#[test]
fn strong_distortion_that_cannot_be_modelled_over_the_frame_is_refused() {
let s = part_empty_scene(30.0);
let r = solve_image(&s.img, ¶ms_for(&s, deg(84.3), deg(-5.2)));
assert!(
matches!(r, Err(ArcsecError::InsufficientQuads { .. })),
"{:?}",
r.map(|w| s.truth.max_error_arcsec(&w))
);
}
#[test]
fn an_undistorted_field_with_an_empty_third_still_solves() {
let s = part_empty_scene(0.0);
let wcs = solve_image(&s.img, ¶ms_for(&s, deg(84.3), deg(-5.2))).expect("solve");
assert_solved(&s, &wcs, 1.0);
}
#[test]
fn mild_distortion_is_modelled_too() {
let s = distorted_scene(3.0, 11);
let wcs = solve_image(&s.img, ¶ms_for(&s, deg(84.3), deg(-5.2))).expect("solve");
let floor = s.truth.linear_floor_arcsec();
let err = s.truth.max_error_arcsec(&wcs);
assert!(
err < floor + 2.0,
"corner error {err:.1}\" against a floor of {floor:.1}\""
);
}
#[test]
fn a_field_absent_from_the_catalogue_does_not_solve() {
let truth = TruthWcs::new(deg(120.0), deg(-40.0), 5.0, 0.0, false, 360, 300);
let s = scene(truth, Db::Areas1476, 120, 8);
let decoy = TempDir::new("decoy");
let mut rng = Rng::new(99);
let other = random_sky(
&mut rng,
&SkySpec {
ra0: truth.ra0,
dec0: truth.dec0,
side_deg: 3.0,
n: 4000,
min_sep_deg: 0.015,
mag_lo: 10.0,
mag_hi: 14.5,
},
);
write_1476_db(decoy.path(), "t50", &other);
let mut p = params_for(&s, truth.ra0, truth.dec0);
p.db_path = decoy.path().to_path_buf();
p.search_radius = deg(0.5);
match solve_image(&s.img, &p) {
Err(ArcsecError::InsufficientQuads { found: 0, required }) => {
assert!(required >= 3);
}
other => panic!("expected InsufficientQuads, got {other:?}"),
}
}
#[test]
fn a_corrupt_catalogue_tile_is_skipped_not_fatal() {
let truth = TruthWcs::new(deg(120.0), deg(-40.0), 5.0, 0.0, false, 360, 300);
let s = scene(truth, Db::Areas1476, 120, 9);
for entry in std::fs::read_dir(s.dir.path()).unwrap() {
let path = entry.unwrap().path();
let mut bytes = std::fs::read(&path).unwrap();
bytes[109] = 7;
std::fs::write(&path, bytes).unwrap();
}
let mut p = params_for(&s, truth.ra0, truth.dec0);
p.search_radius = 0.0;
assert!(matches!(
solve_image(&s.img, &p),
Err(ArcsecError::InsufficientQuads { .. })
));
}
#[test]
fn a_blank_frame_reports_insufficient_stars() {
let dir = TempDir::new("blank");
write_1476_db(dir.path(), "t50", &[]);
let mut rng = Rng::new(3);
let img = ImageBuffer {
data: (0..200 * 200)
.map(|_| (1000.0 + 5.0 * rng.gauss()) as f32)
.collect(),
width: 200,
height: 200,
};
let p = SolveParams {
ra_hint: 0.0,
dec_hint: 0.0,
fov: deg(0.3),
search_radius: deg(1.0),
quad_tolerance: 0.007,
hfd_min: 1.5,
max_stars: 500,
db_path: dir.path().to_path_buf(),
db_name: "t50".into(),
binning: 1,
method: SolveMethod::Quads,
threads: 1,
speed: SearchSpeed::Auto,
};
match solve_image(&img, &p) {
Err(ArcsecError::InsufficientStars { found, required: 5 }) => assert!(found < 5),
other => panic!("expected InsufficientStars, got {other:?}"),
}
}
#[test]
fn a_missing_database_is_reported_before_any_detection() {
let dir = TempDir::new("nodb");
let p = SolveParams {
ra_hint: 0.0,
dec_hint: 0.0,
fov: deg(1.0),
search_radius: deg(1.0),
quad_tolerance: 0.007,
hfd_min: 1.5,
max_stars: 500,
db_path: dir.path().to_path_buf(),
db_name: "d50".into(),
binning: 1,
method: SolveMethod::Quads,
threads: 1,
speed: SearchSpeed::Auto,
};
match solve_image(&ImageBuffer::new(64, 64), &p) {
Err(ArcsecError::CatalogNotFound(path)) => assert_eq!(path, dir.path()),
other => panic!("expected CatalogNotFound, got {other:?}"),
}
}
#[test]
fn solve_image_rejects_a_bad_search_radius_or_fov() {
let base = SolveParams {
ra_hint: 0.0,
dec_hint: 0.0,
fov: deg(1.0),
search_radius: 0.1,
quad_tolerance: 0.007,
hfd_min: 1.5,
max_stars: 500,
db_path: std::path::PathBuf::from("/nonexistent"),
db_name: "d50".into(),
binning: 1,
method: SolveMethod::Quads,
threads: 1,
speed: SearchSpeed::Auto,
};
let img = ImageBuffer::new(64, 64);
for (fov, radius) in [
(f64::NAN, 0.1),
(-1.0, 0.1),
(f64::INFINITY, 0.1),
(0.01, -0.1),
(0.01, f64::NAN),
(0.01, f64::INFINITY),
] {
let p = SolveParams {
fov,
search_radius: radius,
..base.clone()
};
assert!(
matches!(solve_image(&img, &p), Err(ArcsecError::InvalidParameter(_))),
"fov {fov}, radius {radius}"
);
}
}
#[test]
fn format_radec_roundtrip() {
let s = format_radec(deg(160.875), deg(-59.524));
assert!(s.contains("10:"), "RA hours: {s}");
assert!(s.contains('-'), "dec sign: {s}");
}
}