1use std::collections::{HashMap, HashSet};
8
9pub const PHYSICS_DIM: usize = 5;
14
15#[derive(Debug, Clone)]
17pub struct SensorNode {
18 pub id: String,
20 pub position: (f64, f64, f64),
22 pub features: Vec<f32>,
26}
27
28impl SensorNode {
29 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#[derive(Debug, Clone)]
41pub struct SensorEdge {
42 pub from: usize,
44 pub to: usize,
46 pub distance_m: f64,
49 pub propagation_delay_ms: f64,
51}
52
53#[derive(Debug, Clone)]
55pub struct SensorGraph {
56 pub nodes: Vec<SensorNode>,
58 pub edges: Vec<SensorEdge>,
60 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 pub fn new() -> Self {
74 Self {
75 nodes: Vec::new(),
76 edges: Vec::new(),
77 temporal_buffers: Vec::new(),
78 }
79 }
80
81 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 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 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 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 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 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 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 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 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 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 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 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 pub fn node_count(&self) -> usize {
310 self.nodes.len()
311 }
312
313 pub fn edge_count(&self) -> usize {
315 self.edges.len()
316 }
317
318 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 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}