use core::f64::consts::PI;
use std::path::PathBuf;
use crate::catalog::CatalogStar;
use crate::catalog::read_catalog_stars;
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::{PairedPositions, PlateConstants, Star, StarList, WcsSolution};
use crate::wcs::output::derive_wcs;
use super::spiral::SpiralSearch;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SolveMethod {
#[default]
Quads,
Tetra,
}
#[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 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)
}
const MIN_VERIFIED_STARS: usize = 30;
const VERIFY_RADII: [f64; 3] = [6.0, 3.0, 2.0];
const MIN_VERIFY_SPREAD: f64 = 0.20;
type VerifyPass = (PlateConstants, usize, f64, f64);
fn verify_and_refit(
img_stars: &StarList,
cat_stars: &StarList,
plate: &PlateConstants,
img_w: usize,
img_h: usize,
) -> Option<(PlateConstants, usize, f64)> {
if img_stars.is_empty() || cat_stars.is_empty() {
return None;
}
let (mut min_x, mut min_y) = (f64::INFINITY, f64::INFINITY);
let (mut max_x, mut max_y) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
for st in &img_stars.0 {
min_x = min_x.min(st.x);
max_x = max_x.max(st.x);
min_y = min_y.min(st.y);
max_y = max_y.max(st.y);
}
if !(min_x.is_finite() && min_y.is_finite() && max_x > min_x && max_y > min_y) {
return None;
}
let cell = VERIFY_RADII[0].max(1.0);
let nx = (((max_x - min_x) / cell).ceil() as usize + 1).max(1);
let ny = (((max_y - min_y) / cell).ceil() as usize + 1).max(1);
let mut grid: Vec<Vec<u32>> = vec![Vec::new(); nx * ny];
for (i, st) in img_stars.0.iter().enumerate() {
let gx = ((st.x - min_x) / cell) as usize;
let gy = ((st.y - min_y) / cell) as usize;
grid[gy.min(ny - 1) * nx + gx.min(nx - 1)].push(i as u32);
}
let mut current = plate.clone();
let mut best: Option<VerifyPass> = 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 px < min_x - radius
|| px > max_x + radius
|| py < min_y - radius
|| py > max_y + radius
{
continue;
}
let gx = (((px - min_x) / cell) as isize).clamp(0, nx as isize - 1);
let gy = (((py - min_y) / cell) as isize).clamp(0, ny as isize - 1);
let mut best_i: Option<usize> = None;
let mut best_d2 = r2;
for oy in -1isize..=1 {
for ox in -1isize..=1 {
let cx = gx + ox;
let cy = gy + oy;
if cx < 0 || cy < 0 || cx >= nx as isize || cy >= ny as isize {
continue;
}
for &i in &grid[cy as usize * nx + cx as usize] {
let i = i as usize;
if used[i] {
continue;
}
let st = &img_stars.0[i];
let d2 = (st.x - px) * (st.x - px) + (st.y - py) * (st.y - py);
if d2 < best_d2 {
best_d2 = d2;
best_i = Some(i);
}
}
}
}
if let Some(i) = best_i {
used[i] = true; img_pos.push((img_stars.0[i].x, img_stars.0[i].y));
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 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();
let spread = var.sqrt() / half_diag;
log::debug!(
"verify: {} stars, spread {:.3}, rms {:.2}\"",
img_pos.len(),
spread,
rms
);
best = Some((refined.clone(), img_pos.len(), rms, spread));
current = refined;
}
best.filter(|&(_, n, _, spread)| n >= MIN_VERIFIED_STARS && spread >= MIN_VERIFY_SPREAD)
.map(|(p, n, r, _)| (p, n, r))
}
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,
nrstars_required: usize,
oversize: f64,
min_quads: usize,
step_size: f64,
}
struct PositionOutcome {
idx: usize,
ra_db: f64,
dec_db: f64,
sep_deg: f64,
plate: PlateConstants,
n_verified: usize,
rms: f64,
n_matched: usize,
n_raw: usize,
mag_limit: f64,
}
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_matched, n_raw) = match params.method {
SolveMethod::Quads => {
let mut cat_quads = build_quads_presorted(&cat_star_list, ctx.nrstars_image);
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, filtered.len(), 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);
let (ip, cp) = sigma_clip_pairs(ip, cp, 3.0, ctx.min_quads);
if ip.len() < ctx.min_quads {
return failed;
}
let n_clean = ip.len();
(ip, cp, n_clean, n_raw)
}
};
let Ok(plate) = solve_plate_constants(&img_pos, &cat_pos) else {
return failed;
};
let Some((plate, n_verified, rms)) = verify_and_refit(
ctx.stars,
&cat_star_list,
&plate,
ctx.img.width,
ctx.img.height,
) else {
log::info!("Verification failed at this position; continuing search.");
return failed;
};
log::info!("Verified {n_verified} stars against the catalogue, residual {rms:.2}\"");
let (plate, ra_db, dec_db, n_verified, rms) =
recentre(ctx, &cat_raw, plate, ra_db, dec_db, n_verified, rms);
PositionTry {
sep_deg: Some(sep_deg),
outcome: Some(PositionOutcome {
idx,
ra_db,
dec_db,
sep_deg,
plate,
n_verified,
rms,
n_matched,
n_raw,
mag_limit,
}),
}
}
fn recentre(
ctx: &SpiralCtx<'_>,
cat_raw: &[CatalogStar],
mut plate: PlateConstants,
mut ra_db: f64,
mut dec_db: f64,
mut n_verified: usize,
mut rms: f64,
) -> (PlateConstants, f64, f64, usize, 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 (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((p, n, r)) =
verify_and_refit(ctx.stars, &cat, &guess, ctx.img.width, ctx.img.height)
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 {n} stars, residual {r:.2}\"");
(plate, ra_db, dec_db, n_verified, rms) = (p, ra0, dec0, n, r);
}
(plate, ra_db, dec_db, n_verified, rms)
}
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 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 = if nrstars_image < 35 {
2.0
} else if nrstars_image > 140 {
1.0
} else {
2.0 * (35.0 / nrstars_image as f64).sqrt()
};
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,
nrstars_required,
oversize,
min_quads,
step_size,
};
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 {
log::info!(
"{} of {} patterns selected matching within {:.3} tolerance.",
o.n_matched,
o.n_raw,
params.quad_tolerance,
);
let mut wcs = derive_wcs(o.ra_db, o.dec_db, &o.plate, img.width, img.height);
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 = o.rms;
wcs.stars_matched = o.n_verified;
wcs.raw_matches = o.n_raw;
wcs.plate = o.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_radec(ra_rad: f64, dec_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) as f64 / 10.0;
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!("{h}: {m:02} {s:.1} {sign}{dd}d {dm:02} {ds}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::math::coords::{ang_sep, standard_equatorial};
use crate::test_support::{
Rng, SkySpec, 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), "2: 00 0.0 +11d 00 0");
let s = format_radec(deg(359.999_999_9), deg(-0.5));
assert!(s.starts_with("0: 00 0.0 -0d 30 0"), "{s}");
assert_eq!(
format_radec(deg((5.0 + 35.0 / 60.0 + 17.3 / 3600.0) * 15.0), deg(-5.39)),
"5: 35 17.3 -5d 23 24"
);
}
#[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,
};
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)
}
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 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 (refined, n, rms) =
verify_and_refit(&StarList(img_stars), &StarList(cat_stars), &rough, 400, 300)
.expect("a correct plate must verify");
assert_eq!(n, 60);
assert!(rms < 1e-6, "rms {rms}");
assert!(plate_close(&refined, &truth, 1e-6), "{refined:?}");
}
#[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).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).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).is_some());
let empty = StarList::default();
assert!(verify_and_refit(&empty, &cat, &truth, 400, 300).is_none());
let mut singular = truth.clone();
singular.a = 0.0;
singular.b = 0.0;
assert!(verify_and_refit(&img, &cat, &singular, 400, 300).is_none());
}
#[derive(Clone, Copy)]
enum Db {
Areas1476,
Areas290,
AllSky001,
}
struct Scene {
dir: TempDir,
img: ImageBuffer,
truth: TruthWcs,
}
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 }
}
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,
}
}
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 >= MIN_VERIFIED_STARS);
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!(wcs.mag_limit > 10.0 && wcs.mag_limit <= 14.5);
assert!(
wcs.cdelt1 < 0.0 && wcs.cdelt2 > 0.0,
"CDELT sign convention"
);
}
#[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 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}\"");
}
#[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,
};
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,
};
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,
};
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}");
}
}