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> {
50 validate_finite_slice(values, "values")?;
51 if values.is_empty() {
52 return Ok(0.0);
53 }
54 let mut v: Vec<f64> = values.to_vec();
55 Ok(median_sorting_in_place(&mut v).unwrap_or(0.0))
56}
57
58pub(crate) fn median_sorting_in_place(values: &mut [f64]) -> Option<f64> {
63 if values.is_empty() {
64 return None;
65 }
66 values.sort_by(|a, b| a.total_cmp(b));
67 let n = values.len();
68 if n % 2 == 1 {
69 Some(values[n / 2])
70 } else {
71 let lower = values[n / 2 - 1];
72 let upper = values[n / 2];
73 let sum = lower + upper;
74 Some(if sum.is_finite() {
75 sum / 2.0
76 } else {
77 lower / 2.0 + upper / 2.0
78 })
79 }
80}
81
82pub fn mad_scale(residuals: &[f64], scale_floor: f64) -> Result<f64, RobustError> {
90 validate_finite_positive(scale_floor, "scale_floor")?;
91 let med = median(residuals)?;
92 let abs_dev: Vec<f64> = residuals.iter().map(|r| (r - med).abs()).collect();
93 let mad = median(&abs_dev)?;
94 let scaled = MAD_NORMAL_CONST * mad;
95 if scaled > scale_floor {
96 Ok(scaled)
97 } else {
98 Ok(scale_floor)
99 }
100}
101
102pub fn huber_weight(u: f64, k: f64) -> f64 {
109 let a = u.abs();
110 if a <= k {
111 1.0
112 } else {
113 k / a
114 }
115}
116
117fn validate_finite_slice(values: &[f64], field: &'static str) -> Result<(), RobustError> {
118 if values.iter().all(|value| value.is_finite()) {
119 Ok(())
120 } else {
121 Err(invalid_input(field, "not finite"))
122 }
123}
124
125fn validate_finite_positive(value: f64, field: &'static str) -> Result<(), RobustError> {
126 if !value.is_finite() {
127 Err(invalid_input(field, "not finite"))
128 } else if value <= 0.0 {
129 Err(invalid_input(field, "not positive"))
130 } else {
131 Ok(())
132 }
133}
134
135fn invalid_input(field: &'static str, reason: &'static str) -> RobustError {
136 RobustError::InvalidInput { field, reason }
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 #[test]
144 fn median_odd_even() {
145 assert_eq!(median(&[3.0, 1.0, 2.0]).unwrap(), 2.0);
146 assert_eq!(median(&[1.0, 2.0, 3.0, 4.0]).unwrap(), 2.5);
147 assert_eq!(median(&[]).unwrap(), 0.0);
148 }
149
150 #[test]
151 fn median_does_not_overflow_for_finite_central_values() {
152 assert_eq!(median(&[f64::MAX, f64::MAX]).unwrap(), f64::MAX);
153 assert_eq!(median(&[-f64::MAX, -f64::MAX]).unwrap(), -f64::MAX);
154 }
155
156 #[test]
157 fn median_rejects_nonfinite_sample() {
158 assert_eq!(
159 median(&[1.0, f64::NAN]),
160 Err(RobustError::InvalidInput {
161 field: "values",
162 reason: "not finite"
163 })
164 );
165 }
166
167 #[test]
168 fn huber_weight_breaks_at_k() {
169 assert_eq!(huber_weight(0.0, HUBER_K), 1.0);
170 assert_eq!(huber_weight(HUBER_K, HUBER_K), 1.0);
171 let w = huber_weight(2.0 * HUBER_K, HUBER_K);
172 assert!((w - 0.5).abs() < 1e-15);
173 }
174
175 #[test]
176 fn mad_scale_floored() {
177 assert_eq!(mad_scale(&[5.0, 5.0, 5.0], 0.25).unwrap(), 0.25);
179 }
180
181 #[test]
182 fn mad_scale_rejects_nonfinite_sample() {
183 assert_eq!(
184 mad_scale(&[5.0, f64::INFINITY], 0.25),
185 Err(RobustError::InvalidInput {
186 field: "values",
187 reason: "not finite"
188 })
189 );
190 }
191}