sidereon_core/astro/math/
robust.rs1pub const HUBER_K: f64 = 1.345;
15
16pub const MAD_NORMAL_CONST: f64 = 1.4826;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
22pub enum RobustError {
23 #[error("invalid robust statistic {field}: {reason}")]
24 InvalidInput {
25 field: &'static str,
26 reason: &'static str,
27 },
28}
29
30impl RobustError {
31 pub const fn field(&self) -> &'static str {
32 match self {
33 Self::InvalidInput { field, .. } => field,
34 }
35 }
36
37 pub const fn reason(&self) -> &'static str {
38 match self {
39 Self::InvalidInput { reason, .. } => reason,
40 }
41 }
42}
43
44pub fn median(values: &[f64]) -> Result<f64, RobustError> {
49 validate_finite_slice(values, "values")?;
50 if values.is_empty() {
51 return Ok(0.0);
52 }
53 let mut v: Vec<f64> = values.to_vec();
54 v.sort_by(|a, b| a.total_cmp(b));
55 let n = v.len();
56 if n % 2 == 1 {
57 Ok(v[n / 2])
58 } else {
59 Ok((v[n / 2 - 1] + v[n / 2]) / 2.0)
60 }
61}
62
63pub fn mad_scale(residuals: &[f64], scale_floor: f64) -> Result<f64, RobustError> {
71 validate_finite_positive(scale_floor, "scale_floor")?;
72 let med = median(residuals)?;
73 let abs_dev: Vec<f64> = residuals.iter().map(|r| (r - med).abs()).collect();
74 let mad = median(&abs_dev)?;
75 let scaled = MAD_NORMAL_CONST * mad;
76 if scaled > scale_floor {
77 Ok(scaled)
78 } else {
79 Ok(scale_floor)
80 }
81}
82
83pub fn huber_weight(u: f64, k: f64) -> f64 {
90 let a = u.abs();
91 if a <= k {
92 1.0
93 } else {
94 k / a
95 }
96}
97
98fn validate_finite_slice(values: &[f64], field: &'static str) -> Result<(), RobustError> {
99 if values.iter().all(|value| value.is_finite()) {
100 Ok(())
101 } else {
102 Err(invalid_input(field, "not finite"))
103 }
104}
105
106fn validate_finite_positive(value: f64, field: &'static str) -> Result<(), RobustError> {
107 if !value.is_finite() {
108 Err(invalid_input(field, "not finite"))
109 } else if value <= 0.0 {
110 Err(invalid_input(field, "not positive"))
111 } else {
112 Ok(())
113 }
114}
115
116fn invalid_input(field: &'static str, reason: &'static str) -> RobustError {
117 RobustError::InvalidInput { field, reason }
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123
124 #[test]
125 fn median_odd_even() {
126 assert_eq!(median(&[3.0, 1.0, 2.0]).unwrap(), 2.0);
127 assert_eq!(median(&[1.0, 2.0, 3.0, 4.0]).unwrap(), 2.5);
128 assert_eq!(median(&[]).unwrap(), 0.0);
129 }
130
131 #[test]
132 fn median_rejects_nonfinite_sample() {
133 assert_eq!(
134 median(&[1.0, f64::NAN]),
135 Err(RobustError::InvalidInput {
136 field: "values",
137 reason: "not finite"
138 })
139 );
140 }
141
142 #[test]
143 fn huber_weight_breaks_at_k() {
144 assert_eq!(huber_weight(0.0, HUBER_K), 1.0);
145 assert_eq!(huber_weight(HUBER_K, HUBER_K), 1.0);
146 let w = huber_weight(2.0 * HUBER_K, HUBER_K);
147 assert!((w - 0.5).abs() < 1e-15);
148 }
149
150 #[test]
151 fn mad_scale_floored() {
152 assert_eq!(mad_scale(&[5.0, 5.0, 5.0], 0.25).unwrap(), 0.25);
154 }
155
156 #[test]
157 fn mad_scale_rejects_nonfinite_sample() {
158 assert_eq!(
159 mad_scale(&[5.0, f64::INFINITY], 0.25),
160 Err(RobustError::InvalidInput {
161 field: "values",
162 reason: "not finite"
163 })
164 );
165 }
166}