use numeris::{Matrix3, Vector3};
use tracing::debug;
use crate::starcatalog::StarCatalog;
#[cfg(feature = "profile")]
use crate::solver::profiling::{self, buckets};
#[inline]
pub fn tan_project(ra: f64, dec: f64, crval_ra: f64, crval_dec: f64) -> Option<(f64, f64)> {
let da = ra - crval_ra;
let sin_dec = dec.sin();
let cos_dec = dec.cos();
let sin_dec0 = crval_dec.sin();
let cos_dec0 = crval_dec.cos();
let cos_da = da.cos();
let denom = sin_dec * sin_dec0 + cos_dec * cos_dec0 * cos_da;
if denom <= 1e-12 {
return None; }
let xi = cos_dec * da.sin() / denom;
let eta = (sin_dec * cos_dec0 - cos_dec * sin_dec0 * cos_da) / denom;
Some((xi, eta))
}
#[inline]
pub fn inverse_tan_project(xi: f64, eta: f64, crval_ra: f64, crval_dec: f64) -> (f64, f64) {
let sin_dec0 = crval_dec.sin();
let cos_dec0 = crval_dec.cos();
let rho_sq = xi * xi + eta * eta;
if rho_sq < 1e-30 {
return (crval_ra, crval_dec);
}
let rho = rho_sq.sqrt();
let c = rho.atan(); let sin_c = c.sin();
let cos_c = c.cos();
let dec = (cos_c * sin_dec0 + eta * sin_c * cos_dec0 / rho).asin();
let ra = crval_ra + (xi * sin_c).atan2(rho * cos_dec0 * cos_c - eta * sin_dec0 * sin_c);
(ra, dec)
}
#[cfg(test)]
#[inline]
pub fn cd_inverse(cd: &[[f64; 2]; 2]) -> Option<[[f64; 2]; 2]> {
let det = cd[0][0] * cd[1][1] - cd[0][1] * cd[1][0];
if det.abs() < 1e-30 {
return None;
}
let inv_det = 1.0 / det;
Some([
[cd[1][1] * inv_det, -cd[0][1] * inv_det],
[-cd[1][0] * inv_det, cd[0][0] * inv_det],
])
}
pub fn cd_from_theta(theta: f64, pixel_scale: f64, parity_flip: bool) -> [[f64; 2]; 2] {
let cos_t = theta.cos();
let sin_t = theta.sin();
let ps = pixel_scale;
if parity_flip {
[[-ps * cos_t, -ps * sin_t], [-ps * sin_t, ps * cos_t]]
} else {
[[ps * cos_t, -ps * sin_t], [ps * sin_t, ps * cos_t]]
}
}
#[allow(clippy::needless_range_loop)]
fn solve_3x3(a: &[[f64; 3]; 3], b: &[f64; 3]) -> Option<[f64; 3]> {
let mut m = *a;
let mut rhs = *b;
for col in 0..3 {
let mut max_abs = m[col][col].abs();
let mut max_row = col;
for row in (col + 1)..3 {
let v = m[row][col].abs();
if v > max_abs {
max_abs = v;
max_row = row;
}
}
if max_abs < 1e-30 {
return None; }
if max_row != col {
m.swap(col, max_row);
rhs.swap(col, max_row);
}
let pivot = m[col][col];
for row in (col + 1)..3 {
let factor = m[row][col] / pivot;
for j in col..3 {
m[row][j] -= factor * m[col][j];
}
rhs[row] -= factor * rhs[col];
}
}
let mut x = [0.0f64; 3];
for i in (0..3).rev() {
let mut sum = rhs[i];
for j in (i + 1)..3 {
sum -= m[i][j] * x[j];
}
if m[i][i].abs() < 1e-30 {
return None;
}
x[i] = sum / m[i][i];
}
Some(x)
}
#[derive(Default)]
struct MatchScratch {
candidates: Vec<(f64, usize, usize)>,
used_cent: Vec<bool>,
used_pred: Vec<bool>,
matches: Vec<(usize, usize)>,
}
fn find_pixel_matches<'a>(
centroid_pixels: &[(f64, f64)],
max_centroids: usize,
predicted: &[(usize, f64, f64)], radius_px: f64,
scratch: &'a mut MatchScratch,
) -> &'a [(usize, usize)] {
let radius_sq = radius_px * radius_px;
let n_cent = centroid_pixels.len().min(max_centroids);
let candidates = &mut scratch.candidates;
candidates.clear();
for (cent_idx, &(cx, cy)) in centroid_pixels[..n_cent].iter().enumerate() {
for (pred_idx, &(_cat_idx, px, py)) in predicted.iter().enumerate() {
let dx = cx - px;
let dy = cy - py;
let d2 = dx * dx + dy * dy;
if d2 <= radius_sq {
candidates.push((d2, cent_idx, pred_idx));
}
}
}
candidates.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
let used_cent = &mut scratch.used_cent;
used_cent.clear();
used_cent.resize(n_cent, false);
let used_pred = &mut scratch.used_pred;
used_pred.clear();
used_pred.resize(predicted.len(), false);
let matches = &mut scratch.matches;
matches.clear();
for &(_, cent_idx, pred_idx) in candidates.iter() {
if !used_cent[cent_idx] && !used_pred[pred_idx] {
used_cent[cent_idx] = true;
used_pred[pred_idx] = true;
matches.push((cent_idx, predicted[pred_idx].0));
}
}
matches
}
#[inline]
fn predict_tanplane(px: f64, py: f64, cos_t: f64, sin_t: f64, ps: f64) -> (f64, f64) {
let xi = ps * (cos_t * px - sin_t * py);
let eta = ps * (sin_t * px + cos_t * py);
(xi, eta)
}
#[derive(Clone, Copy)]
struct StarRaDec {
ra: f64,
sin_dec: f64,
cos_dec: f64,
}
#[inline]
fn star_radec(sv: &[f32; 3]) -> StarRaDec {
#[cfg(feature = "profile")]
profiling::count(buckets::WCS_RADEC, 1);
let ra = (sv[1] as f64).atan2(sv[0] as f64);
let dec = (sv[2] as f64).asin();
StarRaDec {
ra,
sin_dec: dec.sin(),
cos_dec: dec.cos(),
}
}
#[inline]
fn tan_project_pre(
s: &StarRaDec,
crval_ra: f64,
sin_dec0: f64,
cos_dec0: f64,
) -> Option<(f64, f64)> {
let da = s.ra - crval_ra;
let cos_da = da.cos();
let denom = s.sin_dec * sin_dec0 + s.cos_dec * cos_dec0 * cos_da;
if denom <= 1e-12 {
return None;
}
let xi = s.cos_dec * da.sin() / denom;
let eta = (s.sin_dec * cos_dec0 - s.cos_dec * sin_dec0 * cos_da) / denom;
Some((xi, eta))
}
#[inline]
#[allow(clippy::too_many_arguments)]
fn accumulate_normal_equations(
ata: &mut [[f64; 3]; 3],
atb: &mut [f64; 3],
px: f64,
py: f64,
cos_t: f64,
sin_t: f64,
ps: f64,
r_xi: f64,
r_eta: f64,
) {
let j_xi_theta = ps * (-sin_t * px - cos_t * py);
let j_eta_theta = ps * (cos_t * px - sin_t * py);
let jxi = [j_xi_theta, 1.0, 0.0];
let jeta = [j_eta_theta, 0.0, 1.0];
for i in 0..3 {
for j in 0..3 {
ata[i][j] += jxi[i] * jxi[j] + jeta[i] * jeta[j];
}
atb[i] += jxi[i] * r_xi + jeta[i] * r_eta;
}
}
#[inline]
fn predict_pixel(xi: f64, eta: f64, cos_t: f64, sin_t: f64, inv_ps: f64) -> (f64, f64) {
let px = inv_ps * (cos_t * xi + sin_t * eta);
let py = inv_ps * (-sin_t * xi + cos_t * eta);
(px, py)
}
pub struct WcsRefineResult {
pub cd_matrix: [[f64; 2]; 2],
pub crval_rad: [f64; 2],
pub theta_rad: f64,
pub pixel_scale: f64,
pub matches: Vec<(usize, usize)>,
pub rmse_rad: f64,
}
const MAD_SCALE: f64 = 1.4826;
fn residual_median_sigma(residuals: &[(usize, f64)]) -> (f64, f64) {
let cmp = |a: &f64, b: &f64| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal);
let mut res_vals: Vec<f64> = residuals.iter().map(|&(_, r)| r).collect();
let mid = res_vals.len() / 2;
res_vals.select_nth_unstable_by(mid, cmp);
let median = res_vals[mid];
let mut abs_devs: Vec<f64> = res_vals.iter().map(|r| (r - median).abs()).collect();
let mid_dev = abs_devs.len() / 2;
abs_devs.select_nth_unstable_by(mid_dev, cmp);
let mad = abs_devs[mid_dev];
(median, MAD_SCALE * mad)
}
fn compute_residuals(
matches: &[(usize, usize)],
match_radec: &[StarRaDec],
centroids_px: &[(f64, f64)],
theta: f64,
crval_ra: f64,
crval_dec: f64,
ps: f64,
) -> Vec<(usize, f64)> {
let cos_t = theta.cos();
let sin_t = theta.sin();
let sin_dec0 = crval_dec.sin();
let cos_dec0 = crval_dec.cos();
let mut residuals: Vec<(usize, f64)> = Vec::with_capacity(matches.len());
for (match_idx, &(cent_idx, _)) in matches.iter().enumerate() {
if let Some((xi_cat, eta_cat)) =
tan_project_pre(&match_radec[match_idx], crval_ra, sin_dec0, cos_dec0)
{
let (px, py) = centroids_px[cent_idx];
let (xi_pred, eta_pred) = predict_tanplane(px, py, cos_t, sin_t, ps);
let dxi = xi_pred - xi_cat;
let deta = eta_pred - eta_cat;
residuals.push((match_idx, (dxi * dxi + deta * deta).sqrt()));
}
}
residuals
}
fn ls_fit_once(
matches: &[(usize, usize)],
match_radec: &[StarRaDec],
centroids_px: &[(f64, f64)],
theta: f64,
crval_ra: f64,
crval_dec: f64,
ps: f64,
) -> Option<[f64; 3]> {
let cos_t = theta.cos();
let sin_t = theta.sin();
let sin_dec0 = crval_dec.sin();
let cos_dec0 = crval_dec.cos();
let mut ata = [[0.0f64; 3]; 3];
let mut atb = [0.0f64; 3];
let mut n_valid = 0u32;
for (i, &(cent_idx, _)) in matches.iter().enumerate() {
let Some((xi_cat, eta_cat)) =
tan_project_pre(&match_radec[i], crval_ra, sin_dec0, cos_dec0)
else {
continue;
};
let (px, py) = centroids_px[cent_idx];
let (xi_pred, eta_pred) = predict_tanplane(px, py, cos_t, sin_t, ps);
accumulate_normal_equations(
&mut ata,
&mut atb,
px,
py,
cos_t,
sin_t,
ps,
xi_cat - xi_pred,
eta_cat - eta_pred,
);
n_valid += 1;
}
if n_valid < 3 {
return None;
}
solve_3x3(&ata, &atb)
}
#[allow(clippy::too_many_arguments)]
pub fn wcs_refine(
initial_rotation: &Matrix3<f32>,
initial_matches: &[(usize, usize)],
centroids_px: &[(f64, f64)],
star_vectors: &[[f32; 3]],
star_catalog: &StarCatalog,
pixel_scale: f64,
parity_flip: bool,
match_radius_rad: f32,
max_match_centroids: usize,
max_iterations: u32,
) -> WcsRefineResult {
const CLIP_NSIGMA: f64 = 3.0;
const CONVERGENCE_RAD: f64 = 1e-12;
let ps = pixel_scale;
let inv_ps = 1.0 / ps;
let bx = initial_rotation[(2, 0)] as f64;
let by = initial_rotation[(2, 1)] as f64;
let bz = initial_rotation[(2, 2)] as f64;
let mut crval_ra = by.atan2(bx);
let mut crval_dec = bz.asin();
let cam_x_icrs = Vector3::<f64>::from_array([
initial_rotation[(0, 0)] as f64,
initial_rotation[(0, 1)] as f64,
initial_rotation[(0, 2)] as f64,
]);
let sin_a = crval_ra.sin();
let cos_a = crval_ra.cos();
let sin_d = crval_dec.sin();
let cos_d = crval_dec.cos();
let e_xi = Vector3::<f64>::from_array([-sin_a, cos_a, 0.0]);
let e_eta = Vector3::<f64>::from_array([-sin_d * cos_a, -sin_d * sin_a, cos_d]);
let xi_comp = cam_x_icrs.dot(&e_xi);
let eta_comp = cam_x_icrs.dot(&e_eta);
let mut theta = eta_comp.atan2(xi_comp);
debug!(
"WCS refine: initial CRVAL = ({:.4}°, {:.4}°), θ = {:.4}°, ps = {:.6e} rad/px, {} matches, {} centroids",
crval_ra.to_degrees(),
crval_dec.to_degrees(),
theta.to_degrees(),
ps,
initial_matches.len(),
centroids_px.len(),
);
let mut current_matches: Vec<(usize, usize)> = initial_matches.to_vec();
let max_cent_dist_px = centroids_px
.iter()
.map(|(x, y)| (x * x + y * y).sqrt())
.fold(0.0f64, f64::max);
let search_radius = (ps * max_cent_dist_px * 1.5).max(match_radius_rad as f64 * 2.0);
let requery_margin = match_radius_rad as f64 * 2.0;
let requery_cos = requery_margin.cos();
let mut reassoc_cache: Option<(Vector3<f64>, Vec<usize>, Vec<StarRaDec>)> = None;
let mut predicted: Vec<(usize, f64, f64)> = Vec::new();
let mut match_scratch = MatchScratch::default();
for outer_iter in 0..max_iterations {
#[cfg(feature = "profile")]
profiling::count(buckets::WCS_OUTER, 1);
let match_radec: Vec<StarRaDec> = current_matches
.iter()
.map(|&(_, cat_idx)| star_radec(&star_vectors[cat_idx]))
.collect();
for inner_iter in 0..10 {
if current_matches.len() < 3 {
break;
}
#[cfg(feature = "profile")]
profiling::count(buckets::WCS_INNER, 1);
let Some(sol) = ls_fit_once(
¤t_matches,
&match_radec,
centroids_px,
theta,
crval_ra,
crval_dec,
ps,
) else {
debug!("WCS refine: LS fit failed (too few valid or singular), aborting");
break;
};
let [d_theta, dxi_0, deta_0] = sol;
theta += d_theta;
let (new_ra, new_dec) = inverse_tan_project(dxi_0, deta_0, crval_ra, crval_dec);
crval_ra = new_ra;
crval_dec = new_dec;
debug!(
" inner {}: δθ={:.3e}°, offset=({:.3e}, {:.3e}) rad",
inner_iter,
d_theta.to_degrees(),
dxi_0,
deta_0,
);
if d_theta.abs() < 1e-10 && dxi_0.abs() + deta_0.abs() < CONVERGENCE_RAD {
break;
}
}
let residuals = compute_residuals(
¤t_matches,
&match_radec,
centroids_px,
theta,
crval_ra,
crval_dec,
ps,
);
let mad_stats = if residuals.len() >= 6 {
Some(residual_median_sigma(&residuals))
} else {
None
};
if let Some((median, sigma_est)) = mad_stats {
let clip_threshold = median + CLIP_NSIGMA * sigma_est;
let old_len = current_matches.len();
let mut keep_matches: Vec<(usize, usize)> = Vec::new();
for &(match_idx, residual) in &residuals {
if residual <= clip_threshold {
keep_matches.push(current_matches[match_idx]);
}
}
if keep_matches.len() < old_len && keep_matches.len() >= 4 {
debug!(
" outer {}: MAD clip: {} → {} matches (σ={:.2e} rad, threshold={:.2e} rad)",
outer_iter,
old_len,
keep_matches.len(),
sigma_est,
clip_threshold,
);
current_matches = keep_matches;
}
}
{
let cos_t = theta.cos();
let sin_t = theta.sin();
let radius_px = match_radius_rad as f64 / ps;
let adaptive_radius_px = if let Some((_, sigma_est)) = mad_stats {
(5.0 * sigma_est / ps).max(2.5).min(radius_px)
} else {
radius_px
};
let boresight = Vector3::from_array([
crval_dec.cos() * crval_ra.cos(),
crval_dec.cos() * crval_ra.sin(),
crval_dec.sin(),
]);
let need_query = match &reassoc_cache {
Some((qb, _, _)) => qb.dot(&boresight) < requery_cos,
None => true,
};
if need_query {
let idx = timed!(
buckets::WCS_REASSOC_QUERY,
star_catalog.query_indices_from_uvec(
Vector3::from_array([
boresight[0] as f32,
boresight[1] as f32,
boresight[2] as f32,
]),
(search_radius + requery_margin) as f32,
)
);
#[cfg(feature = "profile")]
{
profiling::count(buckets::WCS_REASSOC_CALL, 1);
profiling::count(buckets::WCS_REASSOC_STARS, idx.len() as u64);
}
let radec: Vec<StarRaDec> =
idx.iter().map(|&i| star_radec(&star_vectors[i])).collect();
reassoc_cache = Some((boresight, idx, radec));
}
let (_, nearby_indices, nearby_radec) = reassoc_cache.as_ref().unwrap();
let prune_r = max_cent_dist_px + radius_px;
let prune_r2 = prune_r * prune_r;
let sin_dec0 = crval_dec.sin();
let cos_dec0 = crval_dec.cos();
timed!(buckets::WCS_REASSOC_PROJECT, {
predicted.clear();
for (k, &cat_idx) in nearby_indices.iter().enumerate() {
if let Some((xi, eta)) =
tan_project_pre(&nearby_radec[k], crval_ra, sin_dec0, cos_dec0)
{
let (pred_x, pred_y) = predict_pixel(xi, eta, cos_t, sin_t, inv_ps);
if pred_x * pred_x + pred_y * pred_y <= prune_r2 {
predicted.push((cat_idx, pred_x, pred_y));
}
}
}
});
let new_matches: &[(usize, usize)] = timed!(
buckets::WCS_REASSOC_MATCH,
find_pixel_matches(
centroids_px,
max_match_centroids,
&predicted,
adaptive_radius_px,
&mut match_scratch,
)
);
if new_matches.len() >= 4 {
let mut sorted_new = new_matches.to_vec();
sorted_new.sort();
let mut sorted_cur = current_matches.clone();
sorted_cur.sort();
if sorted_new != sorted_cur {
debug!(
" outer {}: re-associate: {} → {} matches (radius={:.1} px)",
outer_iter,
current_matches.len(),
new_matches.len(),
adaptive_radius_px,
);
current_matches = new_matches.to_vec();
continue;
}
}
}
debug!(" outer {}: converged", outer_iter);
break;
}
for clip_pass in 0..3 {
if current_matches.len() < 6 {
break;
}
let match_radec: Vec<StarRaDec> = current_matches
.iter()
.map(|&(_, cat_idx)| star_radec(&star_vectors[cat_idx]))
.collect();
let residuals = compute_residuals(
¤t_matches,
&match_radec,
centroids_px,
theta,
crval_ra,
crval_dec,
ps,
);
if residuals.len() < 6 {
break;
}
let (median, sigma_est) = residual_median_sigma(&residuals);
let clip_threshold = median + CLIP_NSIGMA * sigma_est;
let mut keep: Vec<(usize, usize)> = Vec::new();
for &(match_idx, residual) in &residuals {
if residual <= clip_threshold {
keep.push(current_matches[match_idx]);
}
}
let n_clipped = current_matches.len() - keep.len();
if n_clipped == 0 || keep.len() < 4 {
break;
}
debug!(
" final clip {}: {} → {} matches",
clip_pass,
current_matches.len(),
keep.len(),
);
current_matches = keep;
let keep_radec: Vec<StarRaDec> = current_matches
.iter()
.map(|&(_, cat_idx)| star_radec(&star_vectors[cat_idx]))
.collect();
if let Some(sol) = ls_fit_once(
¤t_matches,
&keep_radec,
centroids_px,
theta,
crval_ra,
crval_dec,
ps,
) {
theta += sol[0];
let (new_ra, new_dec) = inverse_tan_project(sol[1], sol[2], crval_ra, crval_dec);
crval_ra = new_ra;
crval_dec = new_dec;
}
}
let final_radec: Vec<StarRaDec> = current_matches
.iter()
.map(|&(_, cat_idx)| star_radec(&star_vectors[cat_idx]))
.collect();
let mut final_residuals: Vec<f64> = compute_residuals(
¤t_matches,
&final_radec,
centroids_px,
theta,
crval_ra,
crval_dec,
ps,
)
.into_iter()
.map(|(_, r)| r)
.collect();
final_residuals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let rmse = if final_residuals.is_empty() {
0.0
} else {
(final_residuals.iter().map(|r| r * r).sum::<f64>() / final_residuals.len() as f64).sqrt()
};
let p90e = if final_residuals.is_empty() {
0.0
} else {
final_residuals[(0.9 * (final_residuals.len() - 1) as f64) as usize]
};
let max_err = final_residuals.last().copied().unwrap_or(0.0);
let cd = cd_from_theta(theta, ps, parity_flip);
debug!(
"WCS refine done: {} matches, θ={:.4}°, RMSE={:.2}\" p90={:.2}\" max={:.2}\"",
current_matches.len(),
theta.to_degrees(),
rmse.to_degrees() * 3600.0,
p90e.to_degrees() * 3600.0,
max_err.to_degrees() * 3600.0,
);
WcsRefineResult {
cd_matrix: cd,
crval_rad: [crval_ra, crval_dec],
theta_rad: theta,
pixel_scale: ps,
matches: current_matches,
rmse_rad: rmse,
}
}
pub fn rotation_from_theta_crval(theta: f64, crval_ra: f64, crval_dec: f64) -> Matrix3<f32> {
let sin_a = crval_ra.sin();
let cos_a = crval_ra.cos();
let sin_d = crval_dec.sin();
let cos_d = crval_dec.cos();
let e_xi = Vector3::<f64>::from_array([-sin_a, cos_a, 0.0]);
let e_eta = Vector3::<f64>::from_array([-sin_d * cos_a, -sin_d * sin_a, cos_d]);
let boresight = Vector3::<f64>::from_array([cos_d * cos_a, cos_d * sin_a, sin_d]);
let cos_t = theta.cos();
let sin_t = theta.sin();
let cam_x = (e_xi * cos_t + e_eta * sin_t).normalize();
let cam_y = (e_xi * -sin_t + e_eta * cos_t).normalize();
Matrix3::new([
[cam_x[0] as f32, cam_x[1] as f32, cam_x[2] as f32],
[cam_y[0] as f32, cam_y[1] as f32, cam_y[2] as f32],
[
boresight[0] as f32,
boresight[1] as f32,
boresight[2] as f32,
],
])
}
pub fn wcs_to_rotation(
cd: &[[f64; 2]; 2],
crval_ra: f64,
crval_dec: f64,
image_width: u32,
) -> (Matrix3<f32>, f32, bool) {
let sin_a = crval_ra.sin();
let cos_a = crval_ra.cos();
let sin_d = crval_dec.sin();
let cos_d = crval_dec.cos();
let e_xi = Vector3::from_array([-sin_a, cos_a, 0.0]);
let e_eta = Vector3::from_array([-sin_d * cos_a, -sin_d * sin_a, cos_d]);
let boresight = Vector3::from_array([cos_d * cos_a, cos_d * sin_a, sin_d]);
let det_cd = cd[0][0] * cd[1][1] - cd[0][1] * cd[1][0];
let parity_flip = det_cd < 0.0;
let cam_x_icrs_raw = if parity_flip {
-(e_xi * cd[0][0] + e_eta * cd[1][0])
} else {
e_xi * cd[0][0] + e_eta * cd[1][0]
};
let cam_y_icrs_raw = e_xi * cd[0][1] + e_eta * cd[1][1];
let cam_x_icrs = cam_x_icrs_raw.normalize();
let cam_y_icrs = cam_y_icrs_raw.normalize();
let rot = Matrix3::new([
[
cam_x_icrs[0] as f32,
cam_x_icrs[1] as f32,
cam_x_icrs[2] as f32,
],
[
cam_y_icrs[0] as f32,
cam_y_icrs[1] as f32,
cam_y_icrs[2] as f32,
],
[
boresight[0] as f32,
boresight[1] as f32,
boresight[2] as f32,
],
]);
let ps_x = cam_x_icrs_raw.norm(); let fov = (2.0 * ((ps_x * image_width as f64) / 2.0).atan()) as f32;
(rot, fov, parity_flip)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tan_project_roundtrip() {
let crval_ra = 1.2_f64;
let crval_dec = 0.3_f64;
let test_points = [(1.21, 0.31), (1.25, 0.25), (1.15, 0.35), (1.0, 0.0)];
for &(ra, dec) in &test_points {
let (xi, eta) = tan_project(ra, dec, crval_ra, crval_dec).unwrap();
let (ra2, dec2) = inverse_tan_project(xi, eta, crval_ra, crval_dec);
assert!(
(ra - ra2).abs() < 1e-12 && (dec - dec2).abs() < 1e-12,
"Roundtrip failed for ({}, {}): got ({}, {})",
ra,
dec,
ra2,
dec2,
);
}
}
#[test]
fn test_tan_project_at_reference() {
let crval_ra = 2.0;
let crval_dec = -0.5;
let (xi, eta) = tan_project(crval_ra, crval_dec, crval_ra, crval_dec).unwrap();
assert!(xi.abs() < 1e-15 && eta.abs() < 1e-15);
}
#[test]
fn test_tan_project_behind() {
let crval_ra = 0.0;
let crval_dec = 0.0;
assert!(tan_project(std::f64::consts::PI, 0.0, crval_ra, crval_dec).is_none());
}
#[test]
fn test_inverse_tan_project_at_origin() {
let crval_ra = 1.5;
let crval_dec = 0.7;
let (ra, dec) = inverse_tan_project(0.0, 0.0, crval_ra, crval_dec);
assert!((ra - crval_ra).abs() < 1e-15);
assert!((dec - crval_dec).abs() < 1e-15);
}
#[test]
fn test_solve_3x3_identity() {
let a = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
let b = [3.0, 5.0, 7.0];
let x = solve_3x3(&a, &b).unwrap();
assert!((x[0] - 3.0).abs() < 1e-12);
assert!((x[1] - 5.0).abs() < 1e-12);
assert!((x[2] - 7.0).abs() < 1e-12);
}
#[test]
fn test_solve_3x3_known() {
let a = [[2.0, 3.0, 1.0], [1.0, 1.0, 1.0], [1.0, 2.0, 3.0]];
let b = [11.0, 6.0, 14.0];
let x = solve_3x3(&a, &b).unwrap();
assert!((x[0] - 1.0).abs() < 1e-10);
assert!((x[1] - 2.0).abs() < 1e-10);
assert!((x[2] - 3.0).abs() < 1e-10);
}
#[test]
fn test_solve_3x3_singular() {
let a = [[1.0, 2.0, 3.0], [2.0, 4.0, 6.0], [1.0, 1.0, 1.0]];
let b = [6.0, 12.0, 3.0];
assert!(solve_3x3(&a, &b).is_none());
}
#[test]
fn test_cd_inverse_roundtrip() {
let cd = [[1.2e-5, -3.0e-6], [2.5e-6, 1.1e-5]];
let inv = cd_inverse(&cd).unwrap();
let i00 = cd[0][0] * inv[0][0] + cd[0][1] * inv[1][0];
let i01 = cd[0][0] * inv[0][1] + cd[0][1] * inv[1][1];
let i10 = cd[1][0] * inv[0][0] + cd[1][1] * inv[1][0];
let i11 = cd[1][0] * inv[0][1] + cd[1][1] * inv[1][1];
assert!((i00 - 1.0).abs() < 1e-12);
assert!(i01.abs() < 1e-12);
assert!(i10.abs() < 1e-12);
assert!((i11 - 1.0).abs() < 1e-12);
}
#[test]
fn test_cd_from_theta_no_parity() {
let theta = 0.3_f64; let ps = 1.7e-5;
let cd = cd_from_theta(theta, ps, false);
let det = cd[0][0] * cd[1][1] - cd[0][1] * cd[1][0];
assert!(det > 0.0);
let (c, s) = (theta.cos(), theta.sin());
let expected = [[ps * c, -ps * s], [ps * s, ps * c]];
for i in 0..2 {
for j in 0..2 {
assert!(
(cd[i][j] - expected[i][j]).abs() < 1e-18,
"CD[{i}][{j}]: {:.6e} vs {:.6e}",
cd[i][j],
expected[i][j]
);
}
}
}
#[test]
fn test_cd_from_theta_with_parity() {
let theta = -0.5_f64;
let ps = 2.0e-5;
let cd = cd_from_theta(theta, ps, true);
let det = cd[0][0] * cd[1][1] - cd[0][1] * cd[1][0];
assert!(det < 0.0);
let (c, s) = (theta.cos(), theta.sin());
let expected = [[-ps * c, -ps * s], [-ps * s, ps * c]];
for i in 0..2 {
for j in 0..2 {
assert!(
(cd[i][j] - expected[i][j]).abs() < 1e-18,
"CD[{i}][{j}]: {:.6e} vs {:.6e}",
cd[i][j],
expected[i][j]
);
}
}
}
#[test]
fn test_predict_tanplane_roundtrip() {
let cos_t = 0.3_f64.cos();
let sin_t = 0.3_f64.sin();
let ps = 1.5e-5;
let inv_ps = 1.0 / ps;
let (px, py) = (100.0, -200.0);
let (xi, eta) = predict_tanplane(px, py, cos_t, sin_t, ps);
let (px2, py2) = predict_pixel(xi, eta, cos_t, sin_t, inv_ps);
assert!((px - px2).abs() < 1e-10);
assert!((py - py2).abs() < 1e-10);
}
#[test]
fn test_wcs_to_rotation_simple() {
let crval_ra = std::f64::consts::FRAC_PI_2;
let crval_dec = 0.0;
let fov_deg = 10.0_f64;
let image_width = 1000u32;
let f = (image_width as f64 / 2.0) / (fov_deg.to_radians() / 2.0).tan();
let ps = 1.0 / f;
let cd = [[ps, 0.0], [0.0, ps]];
let (rot, fov, parity) = wcs_to_rotation(&cd, crval_ra, crval_dec, image_width);
assert!(!parity);
assert!(
(fov.to_degrees() - 10.0).abs() < 0.01,
"FOV: {}",
fov.to_degrees()
);
let bore_cam = rot * Vector3::from_array([0.0_f32, 1.0, 0.0]);
assert!(bore_cam[2] > 0.99, "boresight z = {}", bore_cam[2]);
}
fn rotation_angle_between(a: &Matrix3<f32>, b: &Matrix3<f32>) -> f64 {
let rel = *a * b.transpose();
let trace = (rel[(0, 0)] + rel[(1, 1)] + rel[(2, 2)]) as f64;
((trace - 1.0) / 2.0).clamp(-1.0, 1.0).acos()
}
#[test]
fn test_rotation_from_theta_crval_always_proper() {
for &theta in &[0.0_f64, 0.3, -0.5, 1.2, 3.0] {
let rot = rotation_from_theta_crval(theta, 1.1, 0.4);
assert!(
(rot.det() - 1.0).abs() < 1e-5,
"det = {} for theta = {}",
rot.det(),
theta
);
}
}
#[test]
fn test_parity_conventions_consistent() {
let crval_ra = 1.1_f64;
let crval_dec = 0.4_f64;
let theta = 0.3_f64; let ps = 10.0_f64.to_radians() / 1000.0;
let rot = rotation_from_theta_crval(theta, crval_ra, crval_dec);
let cos_t = theta.cos();
let sin_t = theta.sin();
for &(px, py) in &[(300.0_f64, -200.0_f64), (-450.0, 100.0), (50.0, 425.0)] {
let norm = (px * px * ps * ps + py * py * ps * ps + 1.0).sqrt();
let v_pix = Vector3::<f32>::from_array([
(px * ps / norm) as f32,
(py * ps / norm) as f32,
(1.0 / norm) as f32,
]);
let v_icrs = rot.transpose() * v_pix;
let ra = (v_icrs[1] as f64).atan2(v_icrs[0] as f64);
let dec = (v_icrs[2] as f64).asin();
let (xi_cat, eta_cat) = tan_project(ra, dec, crval_ra, crval_dec).unwrap();
let (xi_fit, eta_fit) = predict_tanplane(px, py, cos_t, sin_t, ps);
assert!(
(xi_cat - xi_fit).abs() < 1e-6 && (eta_cat - eta_fit).abs() < 1e-6,
"rotation/fit mismatch at ({px}, {py}): cat=({xi_cat:.3e}, {eta_cat:.3e}) fit=({xi_fit:.3e}, {eta_fit:.3e})"
);
let (x_obs, y_obs) = (-px, py);
let cd = cd_from_theta(theta, ps, true);
let xi_cd = cd[0][0] * x_obs + cd[0][1] * y_obs;
let eta_cd = cd[1][0] * x_obs + cd[1][1] * y_obs;
assert!(
(xi_cd - xi_fit).abs() < 1e-12 && (eta_cd - eta_fit).abs() < 1e-12,
"CD/fit mismatch at ({px}, {py})"
);
}
let cd = cd_from_theta(theta, ps, true);
let (rot_back, _fov, parity) = wcs_to_rotation(&cd, crval_ra, crval_dec, 1000);
assert!(parity, "det(CD) < 0 must report parity_flip");
assert!((rot_back.det() - 1.0).abs() < 1e-5);
let ang = rotation_angle_between(&rot, &rot_back);
assert!(
ang < 1e-6,
"wcs_to_rotation disagrees with rotation_from_theta_crval by {ang:.2e} rad"
);
}
}