use nalgebra::{Cholesky, Matrix6, Vector6, QR};
use crate::outfit_errors::OutfitError;
#[derive(Debug, Clone)]
pub struct OrbitalUncertainty {
pub normal_matrix: Matrix6<f64>,
pub covariance: Matrix6<f64>,
pub inversion_succeeded: bool,
}
#[derive(Debug, Clone)]
pub struct ObservationEquation {
pub d_ra_d_elem: Vector6<f64>,
pub d_dec_d_elem: Vector6<f64>,
pub residual_ra: f64,
pub residual_dec: f64,
pub weight_ra: f64,
pub weight_dec: f64,
pub weight_cross: f64,
pub active: bool,
}
impl ObservationEquation {
#[allow(clippy::too_many_arguments)]
pub fn uncorrelated(
d_ra_d_elem: Vector6<f64>,
d_dec_d_elem: Vector6<f64>,
residual_ra: f64,
residual_dec: f64,
sigma_ra: f64,
sigma_dec: f64,
active: bool,
) -> Self {
Self {
d_ra_d_elem,
d_dec_d_elem,
residual_ra,
residual_dec,
weight_ra: 1.0 / sigma_ra.powi(2),
weight_dec: 1.0 / sigma_dec.powi(2),
weight_cross: 0.0,
active,
}
}
}
#[derive(Debug, Clone)]
pub struct DifferentialCorrectionResult {
pub element_correction: Vector6<f64>,
pub uncertainty: OrbitalUncertainty,
pub normalised_rms: f64,
pub num_measurements: usize,
}
pub fn angular_diff(a: f64, b: f64) -> f64 {
use std::f64::consts::{PI, TAU};
let mut d = a - b;
while d > PI {
d -= TAU;
}
while d < -PI {
d += TAU;
}
d
}
pub fn solve_weighted_least_squares(
equations: &[ObservationEquation],
free_elements: &[bool; 6],
) -> Result<DifferentialCorrectionResult, OutfitError> {
let num_measurements: usize = equations.iter().filter(|e| e.active).count() * 2;
let mut normal_mat = Matrix6::<f64>::zeros();
let mut right_hand_side = Vector6::<f64>::zeros();
let mut weighted_sq_sum = 0.0_f64;
for eq in equations.iter().filter(|e| e.active) {
let partials_ra = &eq.d_ra_d_elem;
let partials_dec = &eq.d_dec_d_elem;
let weight_ra = eq.weight_ra;
let weight_dec = eq.weight_dec;
let weight_cross = eq.weight_cross;
let residual_ra = eq.residual_ra;
let residual_dec = eq.residual_dec;
for j in 0..6 {
for k in 0..6 {
normal_mat[(j, k)] += partials_ra[j] * weight_ra * partials_ra[k]
+ partials_dec[j] * weight_dec * partials_dec[k]
+ weight_cross
* (partials_dec[j] * partials_ra[k] + partials_ra[j] * partials_dec[k]);
}
right_hand_side[j] += (partials_ra[j] * weight_ra + partials_dec[j] * weight_cross)
* residual_ra
+ (partials_ra[j] * weight_cross + partials_dec[j] * weight_dec) * residual_dec;
}
weighted_sq_sum += weight_ra * residual_ra * residual_ra
+ weight_dec * residual_dec * residual_dec
+ 2.0 * weight_cross * residual_ra * residual_dec;
}
for j in 0..6 {
if !free_elements[j] {
for k in 0..6 {
normal_mat[(j, k)] = 0.0;
normal_mat[(k, j)] = 0.0;
}
normal_mat[(j, j)] = 1.0; right_hand_side[j] = 0.0;
}
}
let (covariance_mat, inversion_succeeded) = invert_normal_matrix(normal_mat);
let mut element_correction = if inversion_succeeded {
covariance_mat * right_hand_side
} else {
Vector6::zeros()
};
for j in 0..6 {
if !free_elements[j] {
element_correction[j] = 0.0;
}
}
let normalised_rms = if num_measurements > 0 {
(weighted_sq_sum / num_measurements as f64).sqrt()
} else {
0.0
};
Ok(DifferentialCorrectionResult {
element_correction,
uncertainty: OrbitalUncertainty {
normal_matrix: normal_mat,
covariance: covariance_mat,
inversion_succeeded,
},
normalised_rms,
num_measurements,
})
}
fn invert_normal_matrix(m: Matrix6<f64>) -> (Matrix6<f64>, bool) {
if let Some(chol) = Cholesky::new(m) {
return (chol.inverse(), true);
}
let qr = QR::new(m);
match qr.try_inverse() {
Some(inv) => (inv, true),
None => (Matrix6::zeros(), false),
}
}
pub fn rescale_covariance(
uncertainty: &mut OrbitalUncertainty,
num_free_params: usize,
num_measurements: usize,
normalised_rms: f64,
) {
let mu = if num_free_params < num_measurements {
let factor =
((num_measurements as f64) / (num_measurements - num_free_params) as f64).sqrt();
if normalised_rms > 1.0 {
normalised_rms * factor
} else {
factor
}
} else {
1.0
};
let mu2 = mu * mu;
uncertainty.covariance *= mu2;
uncertainty.normal_matrix /= mu2;
}
#[cfg(test)]
mod least_square_tests {
use super::*;
use approx::assert_abs_diff_eq;
const ALL_FREE: [bool; 6] = [true; 6];
fn make_identity_equations(n: usize, sigma: f64) -> Vec<ObservationEquation> {
(0..n)
.map(|i| {
let k = i % 6;
let mut partials_ra = Vector6::zeros();
let mut partials_dec = Vector6::zeros();
partials_ra[k] = 1.0;
partials_dec[(k + 1) % 6] = 1.0;
ObservationEquation::uncorrelated(
partials_ra,
partials_dec,
0.0,
0.0,
sigma,
sigma,
true,
)
})
.collect()
}
#[test]
fn test_zero_residuals_give_zero_correction() {
let equations = make_identity_equations(6, 1e-5);
let result = solve_weighted_least_squares(&equations, &ALL_FREE).unwrap();
assert_abs_diff_eq!(result.normalised_rms, 0.0, epsilon = 1e-15);
for j in 0..6 {
assert_abs_diff_eq!(result.element_correction[j], 0.0, epsilon = 1e-15);
}
assert!(result.uncertainty.inversion_succeeded);
assert_eq!(result.num_measurements, 12);
}
#[test]
fn test_covariance_times_normal_is_identity() {
let equations = make_identity_equations(6, 1e-5);
let result = solve_weighted_least_squares(&equations, &ALL_FREE).unwrap();
let product = result.uncertainty.covariance * result.uncertainty.normal_matrix;
let diff = product - Matrix6::identity();
assert!(
diff.norm() < 1e-10,
"Γ·GᵀWG must be close to I, error={}",
diff.norm()
);
}
#[test]
fn test_rejected_observations_have_no_contribution() {
let sigma = 1e-5;
let rejected = ObservationEquation::uncorrelated(
Vector6::new(1.0, 1.0, 1.0, 1.0, 1.0, 1.0),
Vector6::new(1.0, 1.0, 1.0, 1.0, 1.0, 1.0),
1e-4,
1e-4,
sigma,
sigma,
false, );
let mut equations = make_identity_equations(6, sigma);
equations.push(rejected);
let result = solve_weighted_least_squares(&equations, &ALL_FREE).unwrap();
assert_abs_diff_eq!(result.normalised_rms, 0.0, epsilon = 1e-15);
assert_eq!(result.num_measurements, 12); }
#[test]
fn test_fixed_element_has_zero_correction() {
let sigma = 1e-5;
let equations: Vec<ObservationEquation> = (0..6)
.map(|i| {
let k = i % 6;
let mut partials_ra = Vector6::zeros();
let mut partials_dec = Vector6::zeros();
partials_ra[k] = 1.0;
partials_dec[(k + 1) % 6] = 1.0;
ObservationEquation::uncorrelated(
partials_ra,
partials_dec,
1e-4,
1e-4,
sigma,
sigma,
true,
)
})
.collect();
let mut free_elements = [true; 6];
free_elements[0] = false;
let result = solve_weighted_least_squares(&equations, &free_elements).unwrap();
assert_abs_diff_eq!(result.element_correction[0], 0.0, epsilon = 1e-15);
}
#[test]
fn test_correction_magnitude_matches_residual() {
let sigma = 1e-5;
let r = 1e-5_f64;
let equations: Vec<ObservationEquation> = (0..6)
.map(|i| {
let k = i % 6;
let mut partials_ra = Vector6::zeros();
let mut partials_dec = Vector6::zeros();
partials_ra[k] = 1.0;
partials_dec[(k + 1) % 6] = 1.0;
ObservationEquation::uncorrelated(
partials_ra,
partials_dec,
r,
r,
sigma,
sigma,
true,
)
})
.collect();
let result = solve_weighted_least_squares(&equations, &ALL_FREE).unwrap();
for j in 0..6 {
assert_abs_diff_eq!(result.element_correction[j], r, epsilon = 1e-12);
}
}
#[test]
fn test_num_measurements_counts_active_only() {
let sigma = 1e-5;
let mut equations = make_identity_equations(6, sigma);
for _ in 0..3 {
let mut e = equations[0].clone();
e.active = false;
equations.push(e);
}
let result = solve_weighted_least_squares(&equations, &ALL_FREE).unwrap();
assert_eq!(result.num_measurements, 12); }
#[test]
fn test_angular_diff_basic() {
use std::f64::consts::{PI, TAU};
assert_abs_diff_eq!(angular_diff(0.5, 0.3), 0.2, epsilon = 1e-15);
assert_abs_diff_eq!(angular_diff(0.1, TAU - 0.1), 0.2, epsilon = 1e-14);
assert_abs_diff_eq!(angular_diff(TAU - 0.1, 0.1), -0.2, epsilon = 1e-14);
let d = angular_diff(PI, 0.0);
assert!(d > -PI && d <= PI);
}
#[test]
fn test_rescale_identity_when_underdetermined() {
let mut uncertainty = OrbitalUncertainty {
normal_matrix: Matrix6::identity(),
covariance: Matrix6::identity(),
inversion_succeeded: true,
};
rescale_covariance(&mut uncertainty, 6, 6, 1.5);
assert_abs_diff_eq!(
(uncertainty.covariance - Matrix6::identity()).norm(),
0.0,
epsilon = 1e-15
);
}
#[test]
fn test_rescale_good_fit() {
let mut uncertainty = OrbitalUncertainty {
normal_matrix: Matrix6::identity(),
covariance: Matrix6::identity(),
inversion_succeeded: true,
};
rescale_covariance(&mut uncertainty, 6, 12, 0.5);
let mu2 = 2.0_f64; assert_abs_diff_eq!(
(uncertainty.covariance - Matrix6::identity() * mu2).norm(),
0.0,
epsilon = 1e-14
);
assert_abs_diff_eq!(
(uncertainty.normal_matrix - Matrix6::identity() / mu2).norm(),
0.0,
epsilon = 1e-14
);
}
#[test]
fn test_rescale_bad_fit() {
let mut uncertainty = OrbitalUncertainty {
normal_matrix: Matrix6::identity(),
covariance: Matrix6::identity(),
inversion_succeeded: true,
};
rescale_covariance(&mut uncertainty, 6, 12, 2.0);
let mu = 2.0 * 2.0_f64.sqrt();
let mu2 = mu * mu;
assert_abs_diff_eq!(
(uncertainty.covariance - Matrix6::identity() * mu2).norm(),
0.0,
epsilon = 1e-14
);
}
#[test]
fn test_oracle_min_sol() {
use nalgebra::Vector6;
let obs1 = ObservationEquation::uncorrelated(
Vector6::new(1.0, 0.0, 0.0, 0.0, 0.0, 0.0), Vector6::new(0.0, 1.0, 0.0, 0.0, 0.0, 0.0), 2.0e-5, 3.0e-5, 1.0e-5, 1.0e-5, true,
);
let obs2 = ObservationEquation::uncorrelated(
Vector6::new(0.0, 0.0, 1.0, 0.0, 0.0, 0.0), Vector6::new(0.0, 0.0, 0.0, 1.0, 0.0, 0.0), -1.0e-5, 5.0e-5, 2.0e-5, 1.5e-5, true,
);
let obs3 = ObservationEquation::uncorrelated(
Vector6::new(0.0, 0.0, 0.0, 0.0, 1.0, 0.0), Vector6::new(0.0, 0.0, 0.0, 0.0, 0.0, 1.0), 4.0e-5, -2.0e-5, 1.5e-5, 2.0e-5, true,
);
let obs4 = ObservationEquation::uncorrelated(
Vector6::new(0.5, 0.5, 0.5, 0.5, 0.5, 0.5), Vector6::new(0.5, -0.5, 0.5, -0.5, 0.5, -0.5), 1.0e-5, 1.0e-5, 1.0e-5, 1.0e-5, true,
);
let equations = [obs1, obs2, obs3, obs4];
let free = [true; 6];
let result = solve_weighted_least_squares(&equations, &free)
.expect("solve_weighted_least_squares failed");
let oracle_dx = [
1.6756756756756757e-5_f64,
2.3513513513513514e-5,
-2.297_297_297_297_299e-5,
3.5405405405405403e-5,
3.2702702702702714e-5,
-4.594_594_594_594_595e-5,
];
let oracle_csinor: f64 = 2.075_819_784_513_525;
let eps = 1e-10;
for (i, &expected) in oracle_dx.iter().enumerate() {
assert_abs_diff_eq!(result.element_correction[i], expected, epsilon = eps);
}
assert_abs_diff_eq!(result.normalised_rms, oracle_csinor, epsilon = eps);
}
}