macro_rules! timed {
($bucket:expr, $e:expr) => {{
#[cfg(feature = "profile")]
let __t0 = std::time::Instant::now();
let __res = $e;
#[cfg(feature = "profile")]
$crate::solver::profiling::record($bucket, __t0.elapsed().as_nanos(), 1);
__res
}};
}
pub(crate) mod combinations;
pub(crate) mod database;
pub(crate) mod matching;
pub(crate) mod pattern;
#[cfg(feature = "profile")]
pub mod profiling;
pub(crate) mod solve;
pub(crate) mod track;
pub(crate) mod wcs_refine;
use serde::{Deserialize, Serialize};
use crate::camera_model::CameraModel;
use crate::distortion::Distortion;
use crate::{Quaternion, StarCatalog};
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[repr(C)]
pub struct PatternEntry {
pub star_indices: [u32; 4],
pub largest_edge: f32,
pub key_hash: u16,
_pad: u16,
}
impl PatternEntry {
pub const EMPTY: Self = Self {
star_indices: [0, 0, 0, 0],
largest_edge: 0.0,
key_hash: 0,
_pad: 0,
};
#[inline]
pub fn new(star_indices: [u32; 4], largest_edge: f32, key_hash: u16) -> Self {
Self {
star_indices,
largest_edge,
key_hash,
_pad: 0,
}
}
#[inline]
pub fn is_empty(&self) -> bool {
self.star_indices == [0, 0, 0, 0]
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PatternCatalog {
pub entries: Vec<PatternEntry>,
}
impl PatternCatalog {
pub fn with_capacity(capacity: usize) -> Self {
Self {
entries: vec![PatternEntry::EMPTY; capacity],
}
}
#[inline]
pub fn len(&self) -> usize {
self.entries.len()
}
#[inline]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
#[inline]
pub fn get(&self, idx: usize) -> &PatternEntry {
&self.entries[idx]
}
#[inline]
pub fn get_mut(&mut self, idx: usize) -> &mut PatternEntry {
&mut self.entries[idx]
}
}
#[cfg(test)]
mod pattern_catalog_tests {
use super::*;
#[test]
fn small_catalog() {
let mut cat = PatternCatalog::with_capacity(100);
assert_eq!(cat.len(), 100);
*cat.get_mut(42) = PatternEntry::new([1, 2, 3, 4], 0.5, 0xabcd);
let e = cat.get(42);
assert_eq!(e.star_indices, [1, 2, 3, 4]);
assert!((e.largest_edge - 0.5).abs() < 1e-6);
assert_eq!(e.key_hash, 0xabcd);
assert!(cat.get(0).is_empty());
}
#[test]
fn empty_catalog() {
let cat = PatternCatalog::with_capacity(0);
assert_eq!(cat.len(), 0);
assert!(cat.is_empty());
}
#[test]
fn postcard_roundtrip_small() {
let mut cat = PatternCatalog::with_capacity(1024);
*cat.get_mut(0) = PatternEntry::new([10, 20, 30, 40], 0.1, 0x1111);
*cat.get_mut(1023) = PatternEntry::new([1, 2, 3, 4], 0.9, 0xffff);
let bytes = postcard::to_allocvec(&cat).expect("serialize");
let restored: PatternCatalog = postcard::from_bytes(&bytes).expect("deserialize");
assert_eq!(restored.len(), 1024);
assert_eq!(restored.get(0).star_indices, [10, 20, 30, 40]);
assert_eq!(restored.get(1023).key_hash, 0xffff);
assert!(restored.get(500).is_empty());
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SolveStatus {
NoMatch,
Timeout,
TooFew,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct SolveFailure {
pub status: SolveStatus,
pub solve_time_ms: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatabaseProperties {
pub pattern_bins: u32,
pub pattern_max_error: f32,
pub max_fov_rad: f32,
pub min_fov_rad: f32,
pub star_max_magnitude: f32,
pub num_patterns: u32,
pub epoch_equinox: u16,
pub epoch_proper_motion_year: f32,
pub verification_stars_per_fov: u32,
pub lattice_field_oversampling: u32,
pub patterns_per_lattice_field: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SolverDatabase {
pub star_catalog: StarCatalog,
pub star_vectors: Vec<[f32; 3]>,
pub star_catalog_ids: Vec<i64>,
pub pattern_catalog: PatternCatalog,
pub props: DatabaseProperties,
}
pub struct GenerateDatabaseConfig {
pub max_fov_deg: f32,
pub min_fov_deg: Option<f32>,
pub star_max_magnitude: Option<f32>,
pub pattern_max_error: f32,
pub lattice_field_oversampling: u32,
pub patterns_per_lattice_field: u32,
pub verification_stars_per_fov: u32,
pub multiscale_step: f32,
pub epoch_proper_motion_year: Option<f64>,
pub catalog_nside: u32,
}
impl Default for GenerateDatabaseConfig {
fn default() -> Self {
Self {
max_fov_deg: 30.0,
min_fov_deg: None,
star_max_magnitude: None,
pattern_max_error: 0.001,
lattice_field_oversampling: 100,
patterns_per_lattice_field: 50,
verification_stars_per_fov: 150,
multiscale_step: 1.5,
epoch_proper_motion_year: Some(2025.0),
catalog_nside: 16,
}
}
}
impl GenerateDatabaseConfig {
pub fn validate(&self) -> crate::Result<()> {
use crate::Error::InvalidInput;
if !(self.max_fov_deg.is_finite() && self.max_fov_deg > 0.0 && self.max_fov_deg < 180.0) {
return Err(InvalidInput(format!(
"max_fov_deg must be in (0, 180), got {}",
self.max_fov_deg
)));
}
if let Some(min_fov) = self.min_fov_deg {
if !(min_fov.is_finite() && min_fov > 0.0 && min_fov <= self.max_fov_deg) {
return Err(InvalidInput(format!(
"min_fov_deg must be in (0, max_fov_deg={}], got {}",
self.max_fov_deg, min_fov
)));
}
}
if !(self.pattern_max_error.is_finite()
&& self.pattern_max_error > 0.0
&& self.pattern_max_error <= 0.25)
{
return Err(InvalidInput(format!(
"pattern_max_error must be finite and in (0, 0.25], got {}",
self.pattern_max_error
)));
}
if !(self.multiscale_step.is_finite() && self.multiscale_step > 1.0) {
return Err(InvalidInput(format!(
"multiscale_step must be finite and > 1.0, got {}",
self.multiscale_step
)));
}
if self.verification_stars_per_fov == 0 {
return Err(InvalidInput(
"verification_stars_per_fov must be >= 1".into(),
));
}
if self.catalog_nside == 0 {
return Err(InvalidInput("catalog_nside must be >= 1".into()));
}
Ok(())
}
}
pub struct SolveConfig {
pub camera_model: CameraModel,
pub match_radius: f32,
pub match_threshold: f64,
pub solve_timeout_ms: Option<u64>,
pub fov_max_error_rad: Option<f32>,
pub match_max_error: Option<f32>,
pub attitude_hint: Option<Quaternion>,
pub hint_uncertainty_rad: f32,
pub strict_hint: bool,
pub observer_velocity_km_s: Option<[f64; 3]>,
}
impl Default for SolveConfig {
fn default() -> Self {
Self {
fov_max_error_rad: None,
match_radius: 0.01,
match_threshold: 1e-5,
solve_timeout_ms: Some(5000),
match_max_error: None,
camera_model: CameraModel {
focal_length_px: 1.0,
image_width: 0,
image_height: 0,
crpix: [0.0, 0.0],
parity_flip: false,
distortion: Distortion::None,
},
observer_velocity_km_s: None,
attitude_hint: None,
hint_uncertainty_rad: 1.0_f32.to_radians(),
strict_hint: false,
}
}
}
impl SolveConfig {
pub fn new(fov_estimate_rad: f32, image_width: u32, image_height: u32) -> Self {
Self::with_camera_model(CameraModel::from_fov(
fov_estimate_rad as f64,
image_width,
image_height,
))
}
pub fn with_camera_model(camera_model: CameraModel) -> Self {
Self {
camera_model,
..Default::default()
}
}
pub fn fov_estimate_rad(&self) -> f32 {
self.camera_model.fov_rad() as f32
}
pub fn image_width(&self) -> u32 {
self.camera_model.image_width
}
pub fn image_height(&self) -> u32 {
self.camera_model.image_height
}
pub(crate) fn pixel_scale(&self) -> f32 {
if self.camera_model.image_width > 0 && self.camera_model.focal_length_px > 0.0 {
self.camera_model.pixel_scale() as f32
} else {
0.0
}
}
}
pub type SolveResult = Result<Solution, SolveFailure>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Solution {
pub qicrs2cam: Quaternion,
pub fov_rad: f32,
pub num_matches: u32,
pub rmse_rad: f32,
pub p90e_rad: f32,
pub max_err_rad: f32,
pub prob: f64,
pub solve_time_ms: f32,
pub parity_flip: bool,
pub matched_catalog_ids: Vec<i64>,
pub matched_centroid_indices: Vec<usize>,
pub cd_matrix: [[f64; 2]; 2],
pub crval_rad: [f64; 2],
pub camera_model: CameraModel,
pub theta_rad: f64,
}
impl Solution {
pub fn pixel_to_world(&self, x: f64, y: f64) -> (f64, f64) {
let (xi_cam, eta_cam) = self.camera_model.pixel_to_tanplane(x, y);
let cos_t = self.theta_rad.cos();
let sin_t = self.theta_rad.sin();
let xi = cos_t * xi_cam - sin_t * eta_cam;
let eta = sin_t * xi_cam + cos_t * eta_cam;
let (ra, dec) =
wcs_refine::inverse_tan_project(xi, eta, self.crval_rad[0], self.crval_rad[1]);
(ra.to_degrees().rem_euclid(360.0), dec.to_degrees())
}
pub fn world_to_pixel(&self, ra_deg: f64, dec_deg: f64) -> Option<(f64, f64)> {
let ra = ra_deg.to_radians();
let dec = dec_deg.to_radians();
let (xi, eta) = wcs_refine::tan_project(ra, dec, self.crval_rad[0], self.crval_rad[1])?;
let cos_t = self.theta_rad.cos();
let sin_t = self.theta_rad.sin();
let xi_cam = cos_t * xi + sin_t * eta;
let eta_cam = -sin_t * xi + cos_t * eta;
Some(self.camera_model.tanplane_to_pixel(xi_cam, eta_cam))
}
}
pub(crate) fn focal_length_from_fov(image_width: u32, fov_rad: f64) -> f64 {
(image_width.max(1) as f64 / 2.0) / (fov_rad / 2.0).tan()
}
pub(crate) fn pixel_scale_from_fov(image_width: u32, fov_rad: f64) -> f64 {
1.0 / focal_length_from_fov(image_width, fov_rad)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generate_config_validate_accepts_default() {
assert!(GenerateDatabaseConfig::default().validate().is_ok());
}
#[test]
fn generate_config_validate_rejects_bad_values() {
let bad = |f: fn(&mut GenerateDatabaseConfig)| {
let mut c = GenerateDatabaseConfig::default();
f(&mut c);
assert!(c.validate().is_err());
};
bad(|c| c.multiscale_step = 1.0); bad(|c| c.pattern_max_error = 0.0); bad(|c| c.pattern_max_error = -0.001);
bad(|c| c.verification_stars_per_fov = 0); bad(|c| c.catalog_nside = 0);
bad(|c| c.max_fov_deg = 0.0);
bad(|c| c.min_fov_deg = Some(40.0)); }
}