Skip to main content

sensor_fusion/
trilaterate_3d.rs

1use num_traits::float::FloatCore;
2use vqm::{Matrix3x3f32, Vector3, Vector3f32};
3
4/// 3-dimensional anchor of `f32` values<br><br>
5pub type Anchor3df32 = Anchor3d<f32>;
6/// 3-dimensional anchor of `f64` values<br><br>
7pub type Anchor3df64 = Anchor3d<f64>;
8
9#[derive(Clone, Copy, Debug, PartialEq)]
10//#[cfg_attr(feature = "std", derive(derive_more::Display))]
11//#[cfg_attr(feature = "std", display("V{{x:{x}, y:{y}}}"))]
12pub struct Anchor3d<T> {
13    pub pos: Vector3<T>,
14    pub distance: T,
15    /// Reliability multiplier. 1.0 is default, 0.0 ignores the sensor entirely.
16    pub weight: T,
17}
18
19impl<T> Default for Anchor3d<T>
20where
21    T: FloatCore,
22{
23    fn default() -> Self {
24        Self::new(T::zero(), T::zero(), T::zero())
25    }
26}
27
28impl<T> Anchor3d<T>
29where
30    T: FloatCore,
31{
32    /// Constructor.
33    #[must_use]
34    pub fn new(x: T, y: T, z: T) -> Self {
35        Anchor3d { pos: Vector3 { x, y, z }, distance: T::zero(), weight: T::one() }
36    }
37}
38
39/// Trilaterates a 3D position using the least squares method.
40#[must_use]
41pub fn trilaterate_3d_weighted(anchors: &[Anchor3df32]) -> Option<Vector3f32> {
42    if anchors.len() < 4 {
43        return None;
44    }
45
46    // Find the anchor with the greatest weight. In the event of a tie, choose the nearest anchor.
47    // This maximizes stability against sensor noise.
48    let mut base_idx = 0;
49    let mut min_distance = anchors[0].distance;
50    let mut max_weight = anchors[0].weight;
51
52    for (ii, anchor) in anchors.iter().enumerate().skip(1) {
53        #[allow(clippy::float_cmp)]
54        if anchor.weight > max_weight || (anchor.weight == max_weight && anchor.distance < min_distance) {
55            max_weight = anchor.weight;
56            min_distance = anchor.distance;
57            base_idx = ii;
58        }
59    }
60    // If the best anchor has no weight, the whole system is untrustworthy
61    if max_weight < f32::EPSILON {
62        return None;
63    }
64
65    let base = anchors[base_idx];
66    let base_norm_squared = base.pos.norm_squared();
67    let base_distance_squared = base.distance * base.distance;
68
69    // For a system of `n` anchors,
70    // We have `A * x = b`, where `A` is an overdetermined `(n-1)x3` matrix and `b` is an `(n-1)`-dimensional vector.
71    // We use the least squares formula to solve for `x`, ie `x = (A^T * A) * (A^T*b)`,
72    // or `x = (A^T * W * A)^-1 * (A^T * W * b)` when we are using weighted distances.
73    // Note that `(A^T * W * A)` is a 3x3 matrix and (A^T * W * b), is a 3d vector.
74
75    // Initialize accumulation structure for the 3x3 matrix (A^T * W * A)
76    // Since A^T * W * A is symmetric we only need to calculate the upper right corner of the matrix.
77    let mut atwa_00 = 0.0;
78    let mut atwa_01 = 0.0;
79    let mut atwa_02 = 0.0;
80    let mut atwa_11 = 0.0;
81    let mut atwa_12 = 0.0;
82    let mut atwa_22 = 0.0;
83
84    // Initialize 3x1 vector (A^T * W * B)
85    let mut atwb = Vector3f32::default();
86
87    // Accumulate all rows, skipping the base anchor
88    for (ii, anchor) in anchors.iter().enumerate() {
89        if ii == base_idx {
90            continue;
91        }
92
93        // Combine anchor weights
94        let weight = anchor.weight * base.weight;
95        if weight < f32::EPSILON {
96            continue;
97        }
98
99        // Calculate Matrix A row values
100        let a_row = (base.pos - anchor.pos) * 2.0;
101        // Accumulate into the (A^T * W * A) 3x3 matrix
102        atwa_00 += a_row.x * a_row.x * weight;
103        atwa_01 += a_row.x * a_row.y * weight;
104        atwa_02 += a_row.x * a_row.z * weight;
105        atwa_11 += a_row.y * a_row.y * weight;
106        atwa_12 += a_row.y * a_row.z * weight;
107        atwa_22 += a_row.z * a_row.z * weight;
108
109        // Calculate vector B row values
110        let b_row = ((anchor.distance * anchor.distance - base_distance_squared)
111            - (anchor.pos.norm_squared() - base_norm_squared))
112            * weight;
113        // Accumulate into the (A^T * W * B) 3d vector
114        atwb += a_row * b_row;
115    }
116
117    let m = Matrix3x3f32::new([
118        atwa_00, atwa_01, atwa_02, //
119        atwa_01, atwa_11, atwa_12, //
120        atwa_02, atwa_12, atwa_22, //
121    ]);
122
123    let (adjugate, determinant) = m.adjugate_symmetric();
124    if determinant.abs() < f32::EPSILON { None } else { Some((adjugate * atwb) / determinant) }
125}
126
127#[cfg(test)]
128mod tests {
129    #![allow(clippy::expect_used)]
130    use super::*;
131
132    /// Generates true distances from a known target position to an anchor layout.
133    fn calculate_distances(target: Vector3f32, anchors: &mut [Anchor3df32]) {
134        for anchor in anchors.iter_mut() {
135            anchor.distance = anchor.pos.distance(target);
136        }
137    }
138
139    fn _is_normal<T: Sized + Send + Sync + Unpin>() {}
140    fn is_full<T: Sized + Send + Sync + Unpin + Copy + Clone + Default + PartialEq>() {}
141
142    #[test]
143    fn normal_types() {
144        is_full::<Anchor3df32>();
145    }
146
147    #[test]
148    fn test_perfect_geometric_data() {
149        // Position of the tracked object
150        let position = Vector3f32::new(4.5, 2.1, 7.8);
151
152        // Define 5 distributed anchors spanning 3D space
153        let mut anchors = [
154            Anchor3df32::new(0.0, 0.0, 0.0),
155            Anchor3df32::new(10.0, 0.0, 2.0),
156            Anchor3df32::new(0.0, 12.0, 1.0),
157            Anchor3df32::new(2.0, 1.0, 15.0),
158            Anchor3df32::new(8.0, 9.0, 5.0),
159        ];
160
161        calculate_distances(position, &mut anchors);
162
163        let result = trilaterate_3d_weighted(&anchors).expect("Should successfully calculate coordinate");
164
165        // Validate that calculated values match the true location within tolerance
166        let epsilon = 0.007;
167        assert!((result.x - position.x).abs() < epsilon, "X mismatch: expected {}, got {}", position.x, result.x);
168        assert!((result.y - position.y).abs() < epsilon, "Y mismatch: expected {}, got {}", position.y, result.y);
169        assert!((result.z - position.z).abs() < epsilon, "Z mismatch: expected {}, got {}", position.z, result.z);
170    }
171
172    #[test]
173    fn test_insufficient_anchors_fails() {
174        // 3D space mathematically demands at least 4 anchors to resolve the system
175        let anchors = [
176            Anchor3df32 { pos: Vector3f32 { x: 0.0, y: 0.0, z: 0.0 }, distance: 5.0, weight: 1.0 },
177            Anchor3df32 { pos: Vector3f32 { x: 10.0, y: 0.0, z: 0.0 }, distance: 5.0, weight: 1.0 },
178            Anchor3df32 { pos: Vector3f32 { x: 0.0, y: 10.0, z: 0.0 }, distance: 5.0, weight: 1.0 },
179        ];
180
181        let result = trilaterate_3d_weighted(&anchors);
182        assert!(result.is_none(), "Should fail with fewer than 4 anchors");
183    }
184
185    #[test]
186    fn test_outlier_suppression_via_weights() {
187        let position = Vector3f32::new(5.0, 5.0, 5.0);
188
189        let mut anchors = [
190            Anchor3df32 { pos: Vector3f32 { x: 0.0, y: 0.0, z: 0.0 }, distance: 0.0, weight: 1.0 },
191            Anchor3df32 { pos: Vector3f32 { x: 10.0, y: 0.0, z: 2.0 }, distance: 0.0, weight: 1.0 },
192            Anchor3df32 { pos: Vector3f32 { x: 0.0, y: 10.0, z: 1.0 }, distance: 0.0, weight: 1.0 },
193            Anchor3df32 { pos: Vector3f32 { x: 2.0, y: 1.0, z: 10.0 }, distance: 0.0, weight: 1.0 },
194            // Anchor 5 will be our corrupted "outlier" sensor mimicking multipath reflection
195            Anchor3df32 { pos: Vector3f32 { x: 8.0, y: 8.0, z: 8.0 }, distance: 0.0, weight: 0.001 },
196        ];
197
198        calculate_distances(position, &mut anchors);
199
200        // Artificially corrupt the distance on the 5th anchor by adding 15 meters of noise
201        anchors[4].distance += 15.0;
202
203        let result = trilaterate_3d_weighted(&anchors).expect("Should compute coordinate despite noise");
204
205        // The target coordinates should stay highly accurate because the bad sensor's weight is near zero
206        assert!((result.x - position.x).abs() < 0.05, "X deviated too far due to outlier: {}", result.x);
207        assert!((result.y - position.y).abs() < 0.05, "Y deviated too far due to outlier: {}", result.y);
208        assert!((result.z - position.z).abs() < 0.05, "Z deviated too far due to outlier: {}", result.z);
209    }
210
211    #[test]
212    fn test_coplanar_anchors_fail() {
213        // When all tracking nodes lie flat on a single ceiling plane (identical Z coordinate),
214        // the 3D matrix system becomes non-invertible due to spatial ambiguity.
215        let anchors = [
216            Anchor3df32 { pos: Vector3f32 { x: 0.0, y: 0.0, z: 3.0 }, distance: 5.0, weight: 1.0 },
217            Anchor3df32 { pos: Vector3f32 { x: 10.0, y: 0.0, z: 3.0 }, distance: 5.0, weight: 1.0 },
218            Anchor3df32 { pos: Vector3f32 { x: 0.0, y: 10.0, z: 3.0 }, distance: 5.0, weight: 1.0 },
219            Anchor3df32 { pos: Vector3f32 { x: 10.0, y: 10.0, z: 3.0 }, distance: 5.0, weight: 1.0 },
220        ];
221
222        let result = trilaterate_3d_weighted(&anchors);
223        assert!(result.is_none(), "Matrix inversion should fail when anchors are flatly coplanar");
224    }
225
226    #[test]
227    fn test_zero_weight_anchors_ignored() {
228        let position = Vector3f32::new(3.0, 3.0, 3.0);
229
230        let mut anchors = [
231            Anchor3df32 { pos: Vector3f32 { x: 0.0, y: 0.0, z: 0.0 }, distance: 0.0, weight: 1.0 },
232            Anchor3df32 { pos: Vector3f32 { x: 6.0, y: 0.0, z: 1.0 }, distance: 0.0, weight: 1.0 },
233            Anchor3df32 { pos: Vector3f32 { x: 0.0, y: 6.0, z: 2.0 }, distance: 0.0, weight: 1.0 },
234            Anchor3df32 { pos: Vector3f32 { x: 2.0, y: 1.0, z: 6.0 }, distance: 0.0, weight: 1.0 },
235            // This anchor is entirely dead and turned off (weight = 0.0). Its data should be completely bypassed.
236            Anchor3df32 { pos: Vector3f32 { x: 99.0, y: 99.0, z: 99.0 }, distance: 999.0, weight: 0.0 },
237        ];
238
239        calculate_distances(position, &mut anchors);
240        // Ensure its absolute junk data stays corrupted
241        anchors[4].distance = 999.0;
242
243        let result = trilaterate_3d_weighted(&anchors).expect("Should compute coordinate");
244        let epsilon = 0.002;
245        assert!((result.x - position.x).abs() < epsilon);
246        assert!((result.y - position.y).abs() < epsilon);
247        assert!((result.z - position.z).abs() < epsilon);
248    }
249}