Skip to main content

clawft_kernel/sensor_graph/
graph.rs

1//! Sensor array graph: buoys as nodes, acoustic links as edges.
2//!
3//! Nodes carry physics priors + static acoustic features. Edges store
4//! Euclidean distance and approximate acoustic propagation delay. Temporal
5//! ring buffers hold per-node acoustic energy (or other scalar series).
6
7use std::collections::{HashMap, HashSet};
8
9/// Default number of ocean-physics prior channels (K-STEMIT → sonobuoy map).
10///
11/// Order: sound_speed (m/s, normalized), thermocline_depth_m (norm), sea_state,
12/// current_velocity (norm), bottom_type (0–1 categorical proxy).
13pub const PHYSICS_DIM: usize = 5;
14
15/// A buoy node in the sensor graph.
16#[derive(Debug, Clone)]
17pub struct SensorNode {
18    /// Unique identifier for this buoy.
19    pub id: String,
20    /// Position as (latitude, longitude, depth_m).
21    pub position: (f64, f64, f64),
22    /// Node features: physics priors first ([`PHYSICS_DIM`]), then optional
23    /// extra static channels. Spatial GraphSAGE consumes the full vector;
24    /// the temporal branch never sees position or static GPS identity.
25    pub features: Vec<f32>,
26}
27
28impl SensorNode {
29    /// Physics prior slice (first [`PHYSICS_DIM`] entries, zero-padded if short).
30    pub fn physics(&self) -> [f32; PHYSICS_DIM] {
31        let mut out = [0.0f32; PHYSICS_DIM];
32        for (i, slot) in out.iter_mut().enumerate() {
33            *slot = self.features.get(i).copied().unwrap_or(0.0);
34        }
35        out
36    }
37}
38
39/// An edge connecting two buoys.
40#[derive(Debug, Clone)]
41pub struct SensorEdge {
42    /// Index of the source node.
43    pub from: usize,
44    /// Index of the target node.
45    pub to: usize,
46    /// Euclidean distance between buoys in meters (fixture units may be
47    /// synthetic lat/lon deltas treated as metres for unit tests).
48    pub distance_m: f64,
49    /// Estimated acoustic propagation delay in milliseconds.
50    pub propagation_delay_ms: f64,
51}
52
53/// Graph-based sensor fusion substrate for distributed buoy arrays.
54#[derive(Debug, Clone)]
55pub struct SensorGraph {
56    /// Buoy positions and node features.
57    pub nodes: Vec<SensorNode>,
58    /// Inter-buoy connections.
59    pub edges: Vec<SensorEdge>,
60    /// Temporal feature buffer per node (ring of scalar samples; typically
61    /// short-time acoustic energy or a spectral band).
62    pub temporal_buffers: Vec<Vec<f32>>,
63}
64
65impl Default for SensorGraph {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71impl SensorGraph {
72    /// Create an empty sensor graph.
73    pub fn new() -> Self {
74        Self {
75            nodes: Vec::new(),
76            edges: Vec::new(),
77            temporal_buffers: Vec::new(),
78        }
79    }
80
81    /// Add a buoy node. Returns its index.
82    pub fn add_buoy(&mut self, node: SensorNode) -> usize {
83        let idx = self.nodes.len();
84        self.nodes.push(node);
85        self.temporal_buffers.push(Vec::new());
86        idx
87    }
88
89    /// Connect two buoys; distance and delay derived from positions.
90    ///
91    /// Uses Euclidean distance in (lat, lon, depth) space and a constant
92    /// 1500 m/s sound speed. Production deployments should swap in geodesic
93    /// + SSP / BELLHOP-style propagation.
94    ///
95    /// # Panics
96    /// Panics if `from` or `to` are out of bounds.
97    pub fn connect(&mut self, from: usize, to: usize) {
98        assert!(from < self.nodes.len(), "from index out of bounds");
99        assert!(to < self.nodes.len(), "to index out of bounds");
100        let (distance_m, propagation_delay_ms) = Self::link_metrics(
101            self.nodes[from].position,
102            self.nodes[to].position,
103        );
104        self.edges.push(SensorEdge {
105            from,
106            to,
107            distance_m,
108            propagation_delay_ms,
109        });
110    }
111
112    /// Connect with caller-supplied metrics (tests / offline graph builders).
113    pub fn connect_with(
114        &mut self,
115        from: usize,
116        to: usize,
117        distance_m: f64,
118        propagation_delay_ms: f64,
119    ) {
120        assert!(from < self.nodes.len(), "from index out of bounds");
121        assert!(to < self.nodes.len(), "to index out of bounds");
122        self.edges.push(SensorEdge {
123            from,
124            to,
125            distance_m,
126            propagation_delay_ms,
127        });
128    }
129
130    /// Build a radius graph: undirected edges when distance ≤ `radius`.
131    pub fn connect_within_radius(&mut self, radius: f64) {
132        let n = self.nodes.len();
133        for i in 0..n {
134            for j in (i + 1)..n {
135                let (d, delay) = Self::link_metrics(self.nodes[i].position, self.nodes[j].position);
136                if d <= radius {
137                    self.edges.push(SensorEdge {
138                        from: i,
139                        to: j,
140                        distance_m: d,
141                        propagation_delay_ms: delay,
142                    });
143                }
144            }
145        }
146    }
147
148    /// Fully connected undirected graph (K-STEMIT-style small arrays).
149    pub fn connect_fully(&mut self) {
150        let n = self.nodes.len();
151        for i in 0..n {
152            for j in (i + 1)..n {
153                self.connect(i, j);
154            }
155        }
156    }
157
158    fn link_metrics(a: (f64, f64, f64), b: (f64, f64, f64)) -> (f64, f64) {
159        let dlat = b.0 - a.0;
160        let dlon = b.1 - a.1;
161        let ddep = b.2 - a.2;
162        let distance_m = (dlat * dlat + dlon * dlon + ddep * ddep).sqrt();
163        let speed_of_sound = 1500.0_f64;
164        let propagation_delay_ms = (distance_m / speed_of_sound) * 1000.0;
165        (distance_m, propagation_delay_ms)
166    }
167
168    /// Append a feature snapshot to a node's temporal buffer.
169    ///
170    /// # Panics
171    /// Panics if `node_idx` is out of bounds.
172    pub fn push_temporal(&mut self, node_idx: usize, values: &[f32]) {
173        assert!(
174            node_idx < self.temporal_buffers.len(),
175            "node index out of bounds"
176        );
177        self.temporal_buffers[node_idx].extend_from_slice(values);
178    }
179
180    /// Cap each temporal buffer to the last `max_len` samples.
181    pub fn trim_temporal(&mut self, max_len: usize) {
182        for buf in &mut self.temporal_buffers {
183            if buf.len() > max_len {
184                let start = buf.len() - max_len;
185                *buf = buf[start..].to_vec();
186            }
187        }
188    }
189
190    /// Neighbor indices for a node (undirected).
191    pub fn neighbor_indices(&self, node_idx: usize) -> Vec<usize> {
192        let mut neighbors = HashSet::new();
193        for edge in &self.edges {
194            if edge.from == node_idx {
195                neighbors.insert(edge.to);
196            }
197            if edge.to == node_idx {
198                neighbors.insert(edge.from);
199            }
200        }
201        let mut v: Vec<usize> = neighbors.into_iter().collect();
202        v.sort_unstable();
203        v
204    }
205
206    /// Direct-edge distance map from `node_idx` to each neighbor.
207    pub fn neighbor_distances(&self, node_idx: usize) -> HashMap<usize, f64> {
208        let mut m = HashMap::new();
209        for e in &self.edges {
210            if e.from == node_idx {
211                m.insert(e.to, e.distance_m);
212            } else if e.to == node_idx {
213                m.insert(e.from, e.distance_m);
214            }
215        }
216        m
217    }
218
219    /// GraphSAGE-style mean aggregation (unlearned baseline, hop-BFs).
220    ///
221    /// Weighted by inverse distance on direct edges; multi-hop nodes without
222    /// a direct edge use weight `1.0`. Prefer [`crate::sensor_graph::GraphSageLayer`]
223    /// for the learned path.
224    pub fn aggregate_neighbors(&self, node_idx: usize, hop: usize) -> Vec<f32> {
225        if node_idx >= self.nodes.len() || hop == 0 {
226            return self
227                .nodes
228                .get(node_idx)
229                .map(|n| n.features.clone())
230                .unwrap_or_default();
231        }
232
233        let mut visited = HashSet::new();
234        let mut frontier = vec![node_idx];
235        visited.insert(node_idx);
236
237        for _ in 0..hop {
238            let mut next_frontier = Vec::new();
239            for &n in &frontier {
240                for &nb in &self.neighbor_indices(n) {
241                    if visited.insert(nb) {
242                        next_frontier.push(nb);
243                    }
244                }
245            }
246            frontier = next_frontier;
247        }
248
249        let neighbors: Vec<usize> = visited.into_iter().filter(|&n| n != node_idx).collect();
250        if neighbors.is_empty() {
251            return self.nodes[node_idx].features.clone();
252        }
253
254        let edge_distances = self.neighbor_distances(node_idx);
255        let feat_dim = self.nodes[node_idx].features.len();
256        let mut agg = vec![0.0f32; feat_dim];
257        let mut total_weight = 0.0f32;
258
259        for &nb in &neighbors {
260            let dist = edge_distances.get(&nb).copied().unwrap_or(1.0);
261            let w = 1.0 / (dist as f32 + 1e-6);
262            total_weight += w;
263            let feats = &self.nodes[nb].features;
264            for (i, &f) in feats.iter().enumerate().take(feat_dim) {
265                agg[i] += w * f;
266            }
267        }
268
269        if total_weight > 0.0 {
270            for v in &mut agg {
271                *v /= total_weight;
272            }
273        }
274        agg
275    }
276
277    /// Last `window` temporal samples (left zero-padded).
278    pub fn temporal_features(&self, node_idx: usize, window: usize) -> Vec<f32> {
279        if node_idx >= self.temporal_buffers.len() {
280            return vec![0.0; window];
281        }
282        let buf = &self.temporal_buffers[node_idx];
283        if buf.len() >= window {
284            buf[buf.len() - window..].to_vec()
285        } else {
286            let mut result = vec![0.0f32; window - buf.len()];
287            result.extend_from_slice(buf);
288            result
289        }
290    }
291
292    /// Baseline fused feature: own + 1-hop mean-agg + last-16 temporal.
293    pub fn fused_features(&self, node_idx: usize) -> Vec<f32> {
294        let own = self
295            .nodes
296            .get(node_idx)
297            .map(|n| n.features.clone())
298            .unwrap_or_default();
299        let spatial = self.aggregate_neighbors(node_idx, 1);
300        let temporal = self.temporal_features(node_idx, 16);
301        let mut fused = Vec::with_capacity(own.len() + spatial.len() + temporal.len());
302        fused.extend_from_slice(&own);
303        fused.extend_from_slice(&spatial);
304        fused.extend_from_slice(&temporal);
305        fused
306    }
307
308    /// Number of nodes.
309    pub fn node_count(&self) -> usize {
310        self.nodes.len()
311    }
312
313    /// Number of edges.
314    pub fn edge_count(&self) -> usize {
315        self.edges.len()
316    }
317
318    /// Feature dimension of node 0 (0 if empty). Assumes homogeneous features.
319    pub fn feature_dim(&self) -> usize {
320        self.nodes.first().map(|n| n.features.len()).unwrap_or(0)
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    fn make_buoy(id: &str, lat: f64, lon: f64, depth: f64, features: Vec<f32>) -> SensorNode {
329        SensorNode {
330            id: id.to_string(),
331            position: (lat, lon, depth),
332            features,
333        }
334    }
335
336    #[test]
337    fn new_graph_is_empty() {
338        let g = SensorGraph::new();
339        assert_eq!(g.node_count(), 0);
340        assert_eq!(g.edge_count(), 0);
341    }
342
343    #[test]
344    fn add_buoy_increments_count() {
345        let mut g = SensorGraph::new();
346        let idx = g.add_buoy(make_buoy("B1", 0.0, 0.0, 10.0, vec![1.0, 2.0]));
347        assert_eq!(idx, 0);
348        assert_eq!(g.node_count(), 1);
349        assert_eq!(g.temporal_buffers.len(), 1);
350    }
351
352    #[test]
353    fn connect_creates_edge_with_distance() {
354        let mut g = SensorGraph::new();
355        g.add_buoy(make_buoy("B1", 0.0, 0.0, 0.0, vec![1.0]));
356        g.add_buoy(make_buoy("B2", 3.0, 4.0, 0.0, vec![2.0]));
357        g.connect(0, 1);
358
359        assert_eq!(g.edge_count(), 1);
360        let edge = &g.edges[0];
361        assert!((edge.distance_m - 5.0).abs() < 1e-6);
362        assert!(edge.propagation_delay_ms > 0.0);
363    }
364
365    #[test]
366    fn connect_within_radius_filters() {
367        let mut g = SensorGraph::new();
368        g.add_buoy(make_buoy("A", 0.0, 0.0, 0.0, vec![0.0; 5]));
369        g.add_buoy(make_buoy("B", 1.0, 0.0, 0.0, vec![0.0; 5]));
370        g.add_buoy(make_buoy("C", 10.0, 0.0, 0.0, vec![0.0; 5]));
371        g.connect_within_radius(2.0);
372        assert_eq!(g.edge_count(), 1);
373        assert_eq!(g.edges[0].from, 0);
374        assert_eq!(g.edges[0].to, 1);
375    }
376
377    #[test]
378    fn connect_fully_complete() {
379        let mut g = SensorGraph::new();
380        for i in 0..4 {
381            g.add_buoy(make_buoy(&format!("B{i}"), i as f64, 0.0, 0.0, vec![0.0; 5]));
382        }
383        g.connect_fully();
384        // C(4,2) = 6
385        assert_eq!(g.edge_count(), 6);
386    }
387
388    #[test]
389    fn aggregate_neighbors_single_hop() {
390        let mut g = SensorGraph::new();
391        g.add_buoy(make_buoy("B1", 0.0, 0.0, 0.0, vec![1.0, 0.0]));
392        g.add_buoy(make_buoy("B2", 1.0, 0.0, 0.0, vec![0.0, 1.0]));
393        g.add_buoy(make_buoy("B3", 0.0, 1.0, 0.0, vec![0.0, 1.0]));
394        g.connect(0, 1);
395        g.connect(0, 2);
396
397        let agg = g.aggregate_neighbors(0, 1);
398        assert_eq!(agg.len(), 2);
399        assert!((agg[0] - 0.0).abs() < 0.01);
400        assert!((agg[1] - 1.0).abs() < 0.01);
401    }
402
403    #[test]
404    fn aggregate_neighbors_no_neighbors_returns_self() {
405        let mut g = SensorGraph::new();
406        g.add_buoy(make_buoy("B1", 0.0, 0.0, 0.0, vec![5.0, 3.0]));
407        let agg = g.aggregate_neighbors(0, 1);
408        assert_eq!(agg, vec![5.0, 3.0]);
409    }
410
411    #[test]
412    fn aggregate_neighbors_hop_zero_returns_self() {
413        let mut g = SensorGraph::new();
414        g.add_buoy(make_buoy("B1", 0.0, 0.0, 0.0, vec![5.0]));
415        g.add_buoy(make_buoy("B2", 1.0, 0.0, 0.0, vec![9.0]));
416        g.connect(0, 1);
417        let agg = g.aggregate_neighbors(0, 0);
418        assert_eq!(agg, vec![5.0]);
419    }
420
421    #[test]
422    fn temporal_features_zero_padded() {
423        let mut g = SensorGraph::new();
424        g.add_buoy(make_buoy("B1", 0.0, 0.0, 0.0, vec![1.0]));
425        g.push_temporal(0, &[0.5, 0.6, 0.7]);
426        let tf = g.temporal_features(0, 5);
427        assert_eq!(tf, vec![0.0, 0.0, 0.5, 0.6, 0.7]);
428    }
429
430    #[test]
431    fn temporal_features_exact_window() {
432        let mut g = SensorGraph::new();
433        g.add_buoy(make_buoy("B1", 0.0, 0.0, 0.0, vec![1.0]));
434        g.push_temporal(0, &[1.0, 2.0, 3.0]);
435        assert_eq!(g.temporal_features(0, 3), vec![1.0, 2.0, 3.0]);
436    }
437
438    #[test]
439    fn temporal_features_larger_buffer_takes_tail() {
440        let mut g = SensorGraph::new();
441        g.add_buoy(make_buoy("B1", 0.0, 0.0, 0.0, vec![1.0]));
442        g.push_temporal(0, &[1.0, 2.0, 3.0, 4.0, 5.0]);
443        assert_eq!(g.temporal_features(0, 3), vec![3.0, 4.0, 5.0]);
444    }
445
446    #[test]
447    fn fused_features_length() {
448        let mut g = SensorGraph::new();
449        g.add_buoy(make_buoy("B1", 0.0, 0.0, 0.0, vec![1.0, 2.0, 3.0]));
450        g.add_buoy(make_buoy("B2", 1.0, 0.0, 0.0, vec![4.0, 5.0, 6.0]));
451        g.connect(0, 1);
452        g.push_temporal(0, &[0.1; 20]);
453        let fused = g.fused_features(0);
454        assert_eq!(fused.len(), 22);
455    }
456
457    #[test]
458    fn fused_features_isolated_node() {
459        let mut g = SensorGraph::new();
460        g.add_buoy(make_buoy("B1", 0.0, 0.0, 0.0, vec![1.0, 2.0]));
461        let fused = g.fused_features(0);
462        assert_eq!(fused.len(), 20);
463    }
464
465    #[test]
466    #[should_panic(expected = "from index out of bounds")]
467    fn connect_out_of_bounds_panics() {
468        let mut g = SensorGraph::new();
469        g.add_buoy(make_buoy("B1", 0.0, 0.0, 0.0, vec![1.0]));
470        g.connect(5, 0);
471    }
472
473    #[test]
474    fn multi_hop_aggregation() {
475        let mut g = SensorGraph::new();
476        g.add_buoy(make_buoy("A", 0.0, 0.0, 0.0, vec![1.0]));
477        g.add_buoy(make_buoy("B", 1.0, 0.0, 0.0, vec![2.0]));
478        g.add_buoy(make_buoy("C", 2.0, 0.0, 0.0, vec![3.0]));
479        g.connect(0, 1);
480        g.connect(1, 2);
481
482        let agg1 = g.aggregate_neighbors(0, 1);
483        assert!((agg1[0] - 2.0).abs() < 0.01);
484
485        let agg2 = g.aggregate_neighbors(0, 2);
486        assert!(agg2[0] > 1.0 && agg2[0] < 3.0);
487    }
488
489    #[test]
490    fn physics_slice_padded() {
491        let n = make_buoy("x", 0.0, 0.0, 0.0, vec![1.0, 2.0]);
492        let p = n.physics();
493        assert_eq!(p[0], 1.0);
494        assert_eq!(p[1], 2.0);
495        assert_eq!(p[2], 0.0);
496    }
497}