use std::borrow::Cow;
use std::time::Instant;
use numeris::{Matrix3, Quaternion, Vector3};
use tracing::{debug, warn};
use crate::Centroid;
use super::combinations::BreadthFirstCombinations;
use super::database::separation_for_density;
use super::matching;
use super::pattern::{
compute_edge_ratios, compute_pattern_key, compute_pattern_key_hash, compute_sorted_edge_angles,
hash_to_index, sort_pattern_by_centroid_distance, NUM_EDGES, NUM_EDGE_RATIOS, PATTERN_SIZE,
};
use super::wcs_refine;
use super::{
pixel_scale_from_fov, Solution, SolveConfig, SolveFailure, SolveResult, SolveStatus,
SolverDatabase,
};
#[cfg(feature = "profile")]
use crate::solver::profiling::{self, buckets};
pub(super) const C_KM_S: f64 = 299_792.458;
pub(super) fn aberration_correct(sv: &[f32; 3], beta: &[f64; 3]) -> [f32; 3] {
let ax = sv[0] as f64 + beta[0];
let ay = sv[1] as f64 + beta[1];
let az = sv[2] as f64 + beta[2];
let norm = (ax * ax + ay * ay + az * az).sqrt();
[(ax / norm) as f32, (ay / norm) as f32, (az / norm) as f32]
}
impl SolverDatabase {
pub fn solve_from_centroids(
&self,
centroids: &[Centroid],
config: &SolveConfig,
) -> SolveResult {
let t0 = Instant::now();
let cam = &config.camera_model;
let focal_ok = cam.focal_length_px.is_finite() && cam.focal_length_px > 0.0;
if cam.image_width == 0 || cam.image_height == 0 || !focal_ok {
warn!(
"camera model appears unconfigured (image {}x{}, focal_length_px {}); \
solve will not match — build SolveConfig via new()/with_camera_model()",
cam.image_width, cam.image_height, cam.focal_length_px
);
}
let star_vecs: Cow<[[f32; 3]]> = match config.observer_velocity_km_s {
Some(v) => {
let beta = [v[0] / C_KM_S, v[1] / C_KM_S, v[2] / C_KM_S];
Cow::Owned(
self.star_vectors
.iter()
.map(|sv| aberration_correct(sv, &beta))
.collect(),
)
}
None => Cow::Borrowed(&self.star_vectors),
};
let n_input = centroids.len();
let mut preprocessed: Vec<Centroid> = Vec::with_capacity(n_input);
let mut orig_indices: Vec<usize> = Vec::with_capacity(n_input);
for (idx, c) in centroids.iter().enumerate() {
if !(c.x.is_finite() && c.y.is_finite()) {
continue;
}
let cx = c.x as f64 - cam.crpix[0];
let cy = c.y as f64 - cam.crpix[1];
let (ux, uy) = cam.distortion.undistort(cx, cy);
let (ux, uy) = (ux as f32, uy as f32);
if !(ux.is_finite() && uy.is_finite()) {
continue;
}
preprocessed.push(Centroid {
x: ux,
y: uy,
mass: c.mass,
cov: c.cov,
});
orig_indices.push(idx);
}
if preprocessed.len() < n_input {
debug!(
"Dropped {} non-finite centroid(s) before solve",
n_input - preprocessed.len()
);
}
let working_centroids: &[Centroid] = &preprocessed;
if let Some(ref hint) = config.attitude_hint {
match self.solve_with_hint(working_centroids, &star_vecs, config, hint, t0) {
Ok(mut solution) => {
remap_matched_indices(&mut solution, &orig_indices);
debug!(
"Hinted solve succeeded in {:.1} ms ({} matches)",
solution.solve_time_ms, solution.num_matches
);
return Ok(solution);
}
Err(fail) => {
if config.strict_hint {
debug!("Hinted solve failed and strict_hint is set — returning failure");
return Err(fail);
}
debug!("Hinted solve failed; falling back to lost-in-space");
}
}
}
if working_centroids.len() <= PATTERN_SIZE {
return failure(SolveStatus::TooFew, t0);
}
let sorted_indices = sort_indices_by_brightness(working_centroids);
let fov_values = build_fov_sweep(
config.fov_estimate_rad(),
config.fov_max_error_rad,
self.props.pattern_max_error,
diagonal_factor(config),
);
let mut candidates_tested: u64 = 0;
debug!(
"FOV sweep: {} values from {:.2}° to {:.2}°",
fov_values.len(),
fov_values
.iter()
.cloned()
.reduce(f32::min)
.unwrap_or(0.0)
.to_degrees(),
fov_values
.iter()
.cloned()
.reduce(f32::max)
.unwrap_or(0.0)
.to_degrees(),
);
let mut last_status = SolveStatus::NoMatch;
for &fov_try in &fov_values {
if let Some(t) = config.solve_timeout_ms {
if elapsed_ms(t0) > t as f32 {
return failure(SolveStatus::Timeout, t0);
}
}
debug!("Trying FOV = {:.3}°", fov_try.to_degrees());
let result = self.solve_at_fov(
working_centroids,
&sorted_indices,
config,
fov_try,
&star_vecs,
&mut candidates_tested,
t0,
);
match result {
Ok(mut solution) => {
remap_matched_indices(&mut solution, &orig_indices);
return Ok(solution);
}
Err(fail) => last_status = fail.status,
}
}
failure(last_status, t0)
}
#[allow(clippy::too_many_arguments)]
fn solve_at_fov(
&self,
centroids: &[Centroid],
sorted_indices: &[usize],
config: &SolveConfig,
fov_estimate: f32,
star_vectors: &[[f32; 3]],
candidates_tested: &mut u64,
t0: Instant,
) -> SolveResult {
#[cfg(feature = "profile")]
profiling::count(buckets::FOV_PASS, 1);
let pixel_scale = if config.image_width() > 0 && fov_estimate > 0.0 {
pixel_scale_from_fov(config.image_width(), fov_estimate as f64) as f32
} else {
0.0
};
let num_centroids = sorted_indices.len();
let centroid_vectors = centroid_unit_vectors(centroids, sorted_indices, pixel_scale, 1.0);
let mut flipped_vectors: Option<Vec<[f32; 3]>> = None;
let mut rebuilt_vectors: Vec<[f32; 3]> = Vec::new();
let mut match_xy: Vec<(f32, f32)> = Vec::new();
let mut match_scratch = matching::MatchScratch::<f32>::default();
let verification_stars = self.props.verification_stars_per_fov;
let separation = separation_for_density(fov_estimate, verification_stars);
let cos_sep = separation.cos();
let mut keep_for_patterns = vec![false; num_centroids];
for i in 0..num_centroids {
let vi = ¢roid_vectors[i];
let mut occupied = false;
for j in 0..i {
if keep_for_patterns[j] {
let vj = ¢roid_vectors[j];
let dot = vi[0] * vj[0] + vi[1] * vj[1] + vi[2] * vj[2];
if dot > cos_sep {
occupied = true;
break;
}
}
}
if !occupied {
keep_for_patterns[i] = true;
}
}
let pattern_centroid_inds: Vec<usize> = (0..num_centroids)
.filter(|&i| keep_for_patterns[i])
.collect();
let num_pattern_centroids = pattern_centroid_inds.len();
debug!(
"Centroids: {} total, {} for patterns after cluster busting",
num_centroids, num_pattern_centroids
);
if num_pattern_centroids < PATTERN_SIZE {
return failure(SolveStatus::TooFew, t0);
}
let match_centroid_count = num_centroids.min(verification_stars as usize);
let p_bins = self.props.pattern_bins;
let p_max_err = match config.match_max_error {
Some(user_err) if user_err < self.props.pattern_max_error => {
debug!(
"match_max_error {:.2e} below database pattern_max_error {:.2e}; using the latter",
user_err, self.props.pattern_max_error
);
self.props.pattern_max_error
}
Some(user_err) => user_err,
None => self.props.pattern_max_error,
};
const MAX_KEY_SPAN_BINS: f32 = 16.0;
let err_ceiling =
(MAX_KEY_SPAN_BINS / (2.0 * p_bins as f32)).max(self.props.pattern_max_error);
let p_max_err = if p_max_err > err_ceiling {
debug!(
"match_max_error {:.2e} exceeds enumeration ceiling {:.2e} ({} bins); clamping",
p_max_err, err_ceiling, p_bins
);
err_ceiling
} else {
p_max_err
};
let timeout_ms = config.solve_timeout_ms;
let table_len = self.pattern_catalog.len() as u64;
if table_len == 0 || self.props.num_patterns == 0 {
return failure(SolveStatus::NoMatch, t0);
}
debug!(
"Checking up to C({},{}) = {} image patterns",
num_pattern_centroids,
PATTERN_SIZE,
n_choose_k(num_pattern_centroids, PATTERN_SIZE)
);
let mut status = SolveStatus::NoMatch;
let mut pattern_key_list: Vec<(u32, [u32; NUM_EDGE_RATIOS])> = Vec::new();
for image_pattern_local in
BreadthFirstCombinations::<PATTERN_SIZE>::new(&pattern_centroid_inds)
{
if let Some(t) = timeout_ms {
if elapsed_ms(t0) > t as f32 {
debug!("Timeout after {:.1}ms", elapsed_ms(t0));
status = SolveStatus::Timeout;
break;
}
}
let image_vecs: [[f32; 3]; 4] = [
centroid_vectors[image_pattern_local[0]],
centroid_vectors[image_pattern_local[1]],
centroid_vectors[image_pattern_local[2]],
centroid_vectors[image_pattern_local[3]],
];
#[cfg(feature = "profile")]
profiling::count(buckets::COMBOS, 1);
let (edge_angles, image_ratios) = timed!(buckets::IMAGE_EDGES, {
let ea = compute_sorted_edge_angles(&image_vecs);
let ir = compute_edge_ratios(&ea);
(ea, ir)
});
let image_largest_edge = edge_angles[NUM_EDGES - 1];
let ratio_min: [f32; NUM_EDGE_RATIOS] =
std::array::from_fn(|i| image_ratios[i] - p_max_err);
let ratio_max: [f32; NUM_EDGE_RATIOS] =
std::array::from_fn(|i| image_ratios[i] + p_max_err);
let image_key = compute_pattern_key(&image_ratios, p_bins);
let key_min: [u32; NUM_EDGE_RATIOS] =
std::array::from_fn(|i| (ratio_min[i] * p_bins as f32).max(0.0) as u32);
let key_max: [u32; NUM_EDGE_RATIOS] =
std::array::from_fn(|i| (ratio_max[i] * p_bins as f32).min(p_bins as f32) as u32);
pattern_key_list.clear();
timed!(buckets::KEY_ENUM, {
enumerate_key_range(&key_min, &key_max, &image_key, &mut pattern_key_list);
pattern_key_list.sort_unstable_by_key(|&(dist, _)| dist);
});
for (_, pkey) in &pattern_key_list {
let pkey_hash = compute_pattern_key_hash(pkey, p_bins);
let hidx = hash_to_index(pkey_hash, table_len);
let key_hash16 = (pkey_hash & 0xFFFF) as u16;
for c in 0u64..table_len {
let tidx = ((hidx.wrapping_add(c.wrapping_mul(c))) % table_len) as usize;
let entry = self.pattern_catalog.get(tidx);
if entry.is_empty() {
break; }
if entry.key_hash != key_hash16 {
continue;
}
#[cfg(feature = "profile")]
profiling::count(buckets::CANDIDATES, 1);
let cat_largest = entry.largest_edge;
if let Some(fov_err) = config.fov_max_error_rad {
let implied_fov = cat_largest / image_largest_edge * fov_estimate;
if (implied_fov - fov_estimate).abs() > fov_err {
continue;
}
}
let cat_pat = entry.star_indices;
let cat_vecs: [[f32; 3]; 4] = [
star_vectors[cat_pat[0] as usize],
star_vectors[cat_pat[1] as usize],
star_vectors[cat_pat[2] as usize],
star_vectors[cat_pat[3] as usize],
];
let (cat_edges, cat_ratios) = timed!(buckets::CAT_EDGES, {
let ce = compute_sorted_edge_angles(&cat_vecs);
let cr = compute_edge_ratios(&ce);
(ce, cr)
});
let cat_largest_edge = cat_edges[NUM_EDGES - 1];
let ratios_ok = (0..NUM_EDGE_RATIOS)
.all(|i| cat_ratios[i] > ratio_min[i] && cat_ratios[i] < ratio_max[i]);
if !ratios_ok {
continue;
}
let fov = cat_largest_edge / image_largest_edge * fov_estimate;
let scale_mismatch = (fov / fov_estimate - 1.0).abs();
let rebuild = scale_mismatch > 0.25 * config.match_radius;
let (pat_vecs, ps_meas): ([[f32; 3]; 4], f32) = if rebuild {
let ps = pixel_scale_from_fov(config.image_width(), fov as f64) as f32;
(
std::array::from_fn(|k| {
unit_vector_from_pixels(
¢roids[sorted_indices[image_pattern_local[k]]],
ps,
1.0,
)
}),
ps,
)
} else {
(image_vecs, pixel_scale)
};
let mut img_order: [usize; 4] = [0, 1, 2, 3];
sort_pattern_by_centroid_distance(&mut img_order, |i| pat_vecs[i]);
let matched_img: [[f32; 3]; 4] =
std::array::from_fn(|i| pat_vecs[img_order[i]]);
let matched_cat: [[f32; 3]; 4] = std::array::from_fn(|i| cat_vecs[i]);
#[cfg(feature = "profile")]
profiling::count(buckets::RATIO_PASS, 1);
let Some(mut rotation_matrix) = timed!(
buckets::SVD,
wahba_rotation(matched_img.iter().zip(matched_cat.iter()))
) else {
continue;
};
let parity_flip;
let working_vectors: &[[f32; 3]];
if rotation_matrix.det() < 0.0 {
parity_flip = true;
rotation_matrix[(0, 0)] = -rotation_matrix[(0, 0)];
rotation_matrix[(0, 1)] = -rotation_matrix[(0, 1)];
rotation_matrix[(0, 2)] = -rotation_matrix[(0, 2)];
} else {
parity_flip = false;
}
if rebuild {
let sign = if parity_flip { -1.0f32 } else { 1.0 };
rebuilt_vectors.clear();
rebuilt_vectors.extend(
sorted_indices
.iter()
.map(|&i| unit_vector_from_pixels(¢roids[i], ps_meas, sign)),
);
working_vectors = &rebuilt_vectors;
} else if parity_flip {
working_vectors = flipped_vectors.get_or_insert_with(|| {
centroid_vectors
.iter()
.map(|v| [-v[0], v[1], v[2]])
.collect()
});
} else {
working_vectors = ¢roid_vectors;
}
let hypothesis = image_pattern_local
.iter()
.filter(|&&i| i < match_centroid_count)
.count();
let (current_matches, prob_mismatch) = self.verify_attitude(
&rotation_matrix,
working_vectors,
match_centroid_count,
fov,
config,
star_vectors,
hypothesis,
None,
&mut match_xy,
&mut match_scratch,
);
const PREGATE_CEILING: f64 = 1e-2;
*candidates_tested += 1;
if prob_mismatch >= config.match_threshold.max(PREGATE_CEILING) {
continue;
}
debug!(
"Candidate {}: {} matches, p={:.2e}, fov={:.3}° — refining",
*candidates_tested,
current_matches.len(),
prob_mismatch,
fov.to_degrees()
);
let Some(mut result) = self.refine_and_finalize(
&rotation_matrix,
¤t_matches,
centroids,
sorted_indices,
star_vectors,
config,
parity_flip,
fov,
pixel_scale_from_fov(config.image_width(), fov as f64),
match_centroid_count,
4,
prob_mismatch,
t0,
) else {
continue;
};
let refined_rotation = wcs_refine::rotation_from_theta_crval(
result.theta_rad,
result.crval_rad[0],
result.crval_rad[1],
);
let ps_refined = (1.0 / result.camera_model.focal_length_px) as f32;
let refined_radius = (5.0 * result.rmse_rad)
.max(2.5 * ps_refined)
.min(config.match_radius * result.fov_rad);
let (refined_matches, p_refined) = self.verify_attitude(
&refined_rotation,
working_vectors,
match_centroid_count,
result.fov_rad,
config,
star_vectors,
hypothesis,
Some(refined_radius),
&mut match_xy,
&mut match_scratch,
);
let corrected_prob = p_refined * *candidates_tested as f64;
if corrected_prob >= config.match_threshold {
debug!(
"Candidate {} rejected after refinement: {} → {} matches, corrected p={:.2e}",
*candidates_tested,
current_matches.len(),
refined_matches.len(),
corrected_prob,
);
continue;
}
debug!(
"MATCH: {} verified matches (candidate {}), corrected p={:.2e}, fov={:.3}°",
refined_matches.len(),
*candidates_tested,
corrected_prob,
result.fov_rad.to_degrees()
);
result.prob = corrected_prob;
return Ok(result);
}
}
}
failure(status, t0)
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
pub(super) fn verify_attitude(
&self,
rotation_matrix: &Matrix3<f32>,
centroid_vectors: &[[f32; 3]],
match_centroid_count: usize,
fov: f32,
config: &SolveConfig,
star_vectors: &[[f32; 3]],
hypothesis_matches: usize,
match_radius_rad_override: Option<f32>,
centroid_xy: &mut Vec<(f32, f32)>,
scratch: &mut matching::MatchScratch<f32>,
) -> (Vec<(usize, usize)>, f64) {
let fov_diagonal = fov * diagonal_factor(config);
let match_radius_rad = match_radius_rad_override.unwrap_or(config.match_radius * fov);
let image_center_icrs = rotation_matrix.transpose() * Vector3::from_array([0.0, 0.0, 1.0]);
let nearby_inds = timed!(
buckets::VERIFY_QUERY,
self.star_catalog.query_indices_from_uvec_cached(
image_center_icrs,
fov_diagonal / 2.0,
&self.star_vectors,
)
);
#[cfg(feature = "profile")]
profiling::count(buckets::VERIFY_QUERY_STARS, nearby_inds.len() as u64);
let mut nearby_cam_positions: Vec<(usize, f32, f32)> = Vec::new();
for &cat_idx in &nearby_inds {
let sv = &star_vectors[cat_idx];
let icrs_v = Vector3::from_array([sv[0], sv[1], sv[2]]);
let cam_v = *rotation_matrix * icrs_v;
if cam_v[2] > 0.0 {
nearby_cam_positions.push((cat_idx, cam_v[0] / cam_v[2], cam_v[1] / cam_v[2]));
}
}
nearby_cam_positions.truncate(2 * match_centroid_count);
let matches = timed!(
buckets::VERIFY_MATCH,
find_centroid_matches(
¢roid_vectors[..match_centroid_count.min(centroid_vectors.len())],
&nearby_cam_positions,
match_radius_rad,
centroid_xy,
scratch,
)
);
let half_x = ((fov as f64) / 2.0).tan();
let half_y = half_x * (config.image_height() as f64 / config.image_width().max(1) as f64);
let margin = match_radius_rad as f64;
let n_in = nearby_cam_positions
.iter()
.filter(|&&(_, x, y)| {
(x as f64).abs() <= half_x + margin && (y as f64).abs() <= half_y + margin
})
.count();
let region_area = (2.0 * (half_x + margin)) * (2.0 * (half_y + margin));
let prob_single = n_in as f64 * std::f64::consts::PI * margin * margin / region_area;
let h = hypothesis_matches.min(match_centroid_count);
let trials = match_centroid_count - h;
let evidence = matches.len().saturating_sub(h).min(trials);
let prob_mismatch = binomial_cdf(
(trials - evidence) as u32,
trials as u32,
1.0 - prob_single.min(1.0),
);
(matches, prob_mismatch)
}
#[allow(clippy::too_many_arguments)]
pub(super) fn refine_and_finalize(
&self,
rotation_matrix: &Matrix3<f32>,
verify_matches: &[(usize, usize)],
centroids: &[Centroid],
sorted_indices: &[usize],
star_vectors: &[[f32; 3]],
config: &SolveConfig,
parity_flip: bool,
fov: f32,
pixel_scale: f64,
match_centroid_count: usize,
min_matches: usize,
prob: f64,
t0: Instant,
) -> Option<Solution> {
let parity_sign: f64 = if parity_flip { -1.0 } else { 1.0 };
let centroids_px: Vec<(f64, f64)> = sorted_indices
.iter()
.map(|&i| (parity_sign * centroids[i].x as f64, centroids[i].y as f64))
.collect();
let match_radius_rad = config.match_radius * fov;
#[cfg(feature = "profile")]
profiling::count(buckets::WCS_REFINE, 1);
let wcs_result = timed!(
buckets::WCS_REFINE,
wcs_refine::wcs_refine(
rotation_matrix,
verify_matches,
¢roids_px,
star_vectors,
&self.star_catalog,
pixel_scale,
parity_flip,
match_radius_rad,
match_centroid_count,
10,
)
);
if wcs_result.matches.len() < min_matches {
return None;
}
Some(self.finalize_solve_result(
&wcs_result,
star_vectors,
sorted_indices,
¢roids_px,
config,
parity_flip,
prob,
t0,
))
}
#[allow(clippy::too_many_arguments)]
fn finalize_solve_result(
&self,
wcs_result: &wcs_refine::WcsRefineResult,
star_vectors: &[[f32; 3]],
sorted_indices: &[usize],
centroids_px: &[(f64, f64)],
config: &SolveConfig,
parity_flip: bool,
prob: f64,
t0: Instant,
) -> Solution {
let refined_rotation = wcs_refine::rotation_from_theta_crval(
wcs_result.theta_rad,
wcs_result.crval_rad[0],
wcs_result.crval_rad[1],
);
let ps = wcs_result.pixel_scale as f32;
let refined_fov =
(2.0 * ((wcs_result.pixel_scale * config.image_width() as f64) / 2.0).atan()) as f32;
let mut matched_cat_ids: Vec<i64> = Vec::with_capacity(wcs_result.matches.len());
let mut matched_cent_inds: Vec<usize> = Vec::with_capacity(wcs_result.matches.len());
let mut angular_residuals: Vec<f32> = Vec::with_capacity(wcs_result.matches.len());
for &(cent_local_idx, cat_star_idx) in &wcs_result.matches {
matched_cat_ids.push(self.star_catalog_ids[cat_star_idx]);
matched_cent_inds.push(sorted_indices[cent_local_idx]);
let (px, py) = centroids_px[cent_local_idx];
let ix = px as f32 * ps;
let iy = py as f32 * ps;
let iz = 1.0f32;
let norm = (ix * ix + iy * iy + iz * iz).sqrt();
let img_v = refined_rotation.transpose()
* Vector3::from_array([ix / norm, iy / norm, iz / norm]);
let sv = &star_vectors[cat_star_idx];
let cat_v = Vector3::from_array([sv[0], sv[1], sv[2]]);
let cross = img_v.cross(&cat_v);
let ang = cross.norm().atan2(img_v.dot(&cat_v));
angular_residuals.push(ang);
}
angular_residuals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let rmse = if angular_residuals.is_empty() {
0.0
} else {
(angular_residuals.iter().map(|r| r * r).sum::<f32>() / angular_residuals.len() as f32)
.sqrt()
};
let p90e = if angular_residuals.is_empty() {
0.0
} else {
angular_residuals[(0.9 * (angular_residuals.len() - 1) as f32) as usize]
};
let max_err = angular_residuals.last().copied().unwrap_or(0.0);
let quat = Quaternion::from_rotation_matrix(&refined_rotation);
let mut result_cam = config.camera_model.clone();
result_cam.focal_length_px = 1.0 / wcs_result.pixel_scale;
result_cam.parity_flip = parity_flip;
Solution {
qicrs2cam: quat,
fov_rad: refined_fov,
num_matches: wcs_result.matches.len() as u32,
rmse_rad: rmse,
p90e_rad: p90e,
max_err_rad: max_err,
prob,
solve_time_ms: elapsed_ms(t0),
parity_flip,
matched_catalog_ids: matched_cat_ids,
matched_centroid_indices: matched_cent_inds,
cd_matrix: wcs_result.cd_matrix,
crval_rad: wcs_result.crval_rad,
camera_model: result_cam,
theta_rad: wcs_result.theta_rad,
}
}
}
fn build_fov_sweep(
fov_estimate: f32,
fov_max_error: Option<f32>,
pattern_max_error: f32,
diag_factor: f32,
) -> Vec<f32> {
let mut values = vec![fov_estimate];
if let Some(max_error) = fov_max_error {
if max_error > 0.0 {
let theta_hd = ((fov_estimate as f64 / 2.0).tan() * diag_factor as f64).atan();
let curvature = (theta_hd * theta_hd / 3.0).max(1e-12);
let step_rel = pattern_max_error as f64 / curvature;
let step = ((step_rel * fov_estimate as f64) as f32).max(0.001_f32.to_radians());
let mut offset = step;
while offset <= max_error {
values.push(fov_estimate + offset);
if fov_estimate - offset > 0.0 {
values.push(fov_estimate - offset);
}
offset += step;
}
}
}
values
}
pub(super) fn elapsed_ms(t0: Instant) -> f32 {
t0.elapsed().as_secs_f32() * 1000.0
}
fn remap_matched_indices(solution: &mut Solution, orig_indices: &[usize]) {
for idx in solution.matched_centroid_indices.iter_mut() {
*idx = orig_indices[*idx];
}
}
pub(super) fn failure(status: SolveStatus, t0: Instant) -> SolveResult {
Err(SolveFailure {
status,
solve_time_ms: elapsed_ms(t0),
})
}
pub(super) fn diagonal_factor(config: &SolveConfig) -> f32 {
let aspect = config.image_height() as f32 / config.image_width().max(1) as f32;
(1.0 + aspect * aspect).sqrt().max(1.42)
}
fn n_choose_k(n: usize, k: usize) -> usize {
if k > n {
return 0;
}
let mut result = 1usize;
for i in 0..k {
result = result.saturating_mul(n - i) / (i + 1);
}
result
}
fn enumerate_key_range(
key_min: &[u32; NUM_EDGE_RATIOS],
key_max: &[u32; NUM_EDGE_RATIOS],
center: &[u32; NUM_EDGE_RATIOS],
out: &mut Vec<(u32, [u32; NUM_EDGE_RATIOS])>,
) {
let mut current = [0u32; NUM_EDGE_RATIOS];
enumerate_key_range_recursive(key_min, key_max, center, 0, &mut current, out);
}
fn enumerate_key_range_recursive(
key_min: &[u32; NUM_EDGE_RATIOS],
key_max: &[u32; NUM_EDGE_RATIOS],
center: &[u32; NUM_EDGE_RATIOS],
dim: usize,
current: &mut [u32; NUM_EDGE_RATIOS],
out: &mut Vec<(u32, [u32; NUM_EDGE_RATIOS])>,
) {
if dim == NUM_EDGE_RATIOS {
let dist_sq: u32 = (0..NUM_EDGE_RATIOS)
.map(|i| {
let d = current[i] as i32 - center[i] as i32;
(d * d) as u32
})
.sum();
out.push((dist_sq, *current));
return;
}
let lo = if dim > 0 {
key_min[dim].max(current[dim - 1])
} else {
key_min[dim]
};
for v in lo..=key_max[dim] {
current[dim] = v;
enumerate_key_range_recursive(key_min, key_max, center, dim + 1, current, out);
}
}
pub(super) fn sort_indices_by_brightness(centroids: &[Centroid]) -> Vec<usize> {
let mut sorted_indices: Vec<usize> = (0..centroids.len()).collect();
sorted_indices.sort_by(|&a, &b| {
let ma = centroids[a].mass.unwrap_or(f32::MIN);
let mb = centroids[b].mass.unwrap_or(f32::MIN);
mb.partial_cmp(&ma).unwrap_or(std::cmp::Ordering::Equal)
});
sorted_indices
}
pub(super) fn centroid_unit_vectors(
centroids: &[Centroid],
sorted_indices: &[usize],
pixel_scale: f32,
parity_sign: f32,
) -> Vec<[f32; 3]> {
sorted_indices
.iter()
.map(|&i| unit_vector_from_pixels(¢roids[i], pixel_scale, parity_sign))
.collect()
}
#[inline]
pub(super) fn unit_vector_from_pixels(
centroid: &Centroid,
pixel_scale: f32,
parity_sign: f32,
) -> [f32; 3] {
let x = parity_sign * centroid.x * pixel_scale;
let y = centroid.y * pixel_scale;
let z = 1.0f32;
let norm = (x * x + y * y + z * z).sqrt();
[x / norm, y / norm, z / norm]
}
pub(super) fn wahba_rotation<'a>(
pairs: impl IntoIterator<Item = (&'a [f32; 3], &'a [f32; 3])>,
) -> Option<Matrix3<f32>> {
let mut h = numeris::Matrix3::<f64>::zeros();
for (img, cat) in pairs {
let img_v =
numeris::Vector3::<f64>::from_array([img[0] as f64, img[1] as f64, img[2] as f64]);
let cat_v =
numeris::Vector3::<f64>::from_array([cat[0] as f64, cat[1] as f64, cat[2] as f64]);
h += img_v.outer(&cat_v);
}
let svd = h.svd().ok()?;
let u = svd.u();
let v_t = svd.vt();
let r64 = *u * *v_t;
Some(r64.cast::<f32>())
}
pub(super) fn find_centroid_matches(
centroid_vectors: &[[f32; 3]],
catalog_positions: &[(usize, f32, f32)], match_radius: f32,
centroid_xy: &mut Vec<(f32, f32)>,
scratch: &mut matching::MatchScratch<f32>,
) -> Vec<(usize, usize)> {
centroid_xy.clear();
centroid_xy.extend(centroid_vectors.iter().map(|v| {
if v[2] > 0.0 {
(v[0] / v[2], v[1] / v[2])
} else {
(f32::MAX, f32::MAX)
}
}));
let n = centroid_xy.len();
matching::greedy_unique_matches(
centroid_xy,
n,
catalog_positions,
match_radius * match_radius,
scratch,
);
scratch.take_matches()
}
pub(super) fn binomial_cdf(k: u32, n: u32, p: f64) -> f64 {
if k >= n {
return 1.0;
}
if p <= 0.0 {
return 1.0;
}
if p >= 1.0 {
return 0.0; }
let q = 1.0 - p;
let mut cdf = 0.0;
let mut log_term = n as f64 * q.ln(); cdf += log_term.exp();
for i in 1..=k as u64 {
log_term += ((n as u64 - i + 1) as f64).ln() - (i as f64).ln() + p.ln() - q.ln();
cdf += log_term.exp();
}
cdf.min(1.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_aberration_correct_shift_direction() {
let star = [1.0f32, 0.0, 0.0];
let beta = [0.0, 30.0 / C_KM_S, 0.0];
let apparent = aberration_correct(&star, &beta);
let norm = (apparent[0] as f64 * apparent[0] as f64
+ apparent[1] as f64 * apparent[1] as f64
+ apparent[2] as f64 * apparent[2] as f64)
.sqrt();
assert!((norm - 1.0).abs() < 1e-6, "output not unit length: {norm}");
assert!(
apparent[1] > 0.0,
"expected positive Y shift, got {}",
apparent[1]
);
let shift_rad = (apparent[1] as f64).atan2(apparent[0] as f64);
let expected = 30.0 / C_KM_S; assert!(
(shift_rad - expected).abs() < 1e-6,
"shift {shift_rad:.2e} rad, expected ~{expected:.2e} rad"
);
}
#[test]
fn test_enumerate_key_range_monotone_pruning() {
let cases: [([u32; NUM_EDGE_RATIOS], [u32; NUM_EDGE_RATIOS]); 3] = [
([2, 2, 2, 2, 2], [5, 5, 5, 5, 5]),
([0, 2, 4, 6, 8], [1, 3, 5, 7, 9]),
([1, 2, 2, 4, 4], [3, 4, 5, 5, 6]),
];
for (key_min, key_max) in cases {
let center: [u32; NUM_EDGE_RATIOS] =
std::array::from_fn(|i| (key_min[i] + key_max[i]) / 2);
let mut got: Vec<(u32, [u32; NUM_EDGE_RATIOS])> = Vec::new();
enumerate_key_range(&key_min, &key_max, ¢er, &mut got);
let mut expected: Vec<(u32, [u32; NUM_EDGE_RATIOS])> = Vec::new();
for a in key_min[0]..=key_max[0] {
for b in key_min[1]..=key_max[1] {
for c in key_min[2]..=key_max[2] {
for d in key_min[3]..=key_max[3] {
for e in key_min[4]..=key_max[4] {
let k = [a, b, c, d, e];
if k.windows(2).all(|w| w[0] <= w[1]) {
let dist_sq: u32 = (0..NUM_EDGE_RATIOS)
.map(|i| {
let dd = k[i] as i32 - center[i] as i32;
(dd * dd) as u32
})
.sum();
expected.push((dist_sq, k));
}
}
}
}
}
}
assert_eq!(got, expected, "min={key_min:?} max={key_max:?}");
}
}
#[test]
fn test_aberration_correct_zero_velocity() {
let s = 1.0f32 / 3.0f32.sqrt();
let star = [s, s, s];
let beta = [0.0, 0.0, 0.0];
let apparent = aberration_correct(&star, &beta);
for i in 0..3 {
assert!(
(apparent[i] - star[i]).abs() < 1e-6,
"component {i} changed: {} -> {}",
star[i],
apparent[i]
);
}
}
#[test]
fn test_aberration_correct_parallel_velocity() {
let star = [1.0f32, 0.0, 0.0];
let beta = [30.0 / C_KM_S, 0.0, 0.0];
let apparent = aberration_correct(&star, &beta);
assert!(apparent[1].abs() < 1e-7, "Y not zero: {}", apparent[1]);
assert!(apparent[2].abs() < 1e-7, "Z not zero: {}", apparent[2]);
assert!((apparent[0] - 1.0).abs() < 1e-6);
}
}