Skip to main content

clawft_kernel/sensor_graph/
fixture.rs

1//! Synthetic sonobuoy-like arrays for train / eval scaffolding.
2//!
3//! Generates a small fleet of buoys with physics priors, radius-graph edges,
4//! and temporal energy series that embed class-conditional acoustic events:
5//!
6//! | Class | Name        | Temporal signature              |
7//! |-------|-------------|---------------------------------|
8//! | 0     | ambient     | low-level noise only            |
9//! | 1     | vessel      | mid-band periodic bumps         |
10//! | 2     | mammal_call | sparse high-amplitude impulses  |
11
12use super::graph::{SensorGraph, SensorNode, PHYSICS_DIM};
13
14/// Labels used by the synthetic fixture (matches default `num_classes = 3`).
15pub const CLASS_AMBIENT: usize = 0;
16pub const CLASS_VESSEL: usize = 1;
17pub const CLASS_MAMMAL: usize = 2;
18
19/// Human-readable class name.
20pub fn class_name(label: usize) -> &'static str {
21    match label {
22        CLASS_AMBIENT => "ambient",
23        CLASS_VESSEL => "vessel",
24        CLASS_MAMMAL => "mammal_call",
25        _ => "unknown",
26    }
27}
28
29/// Parameters for [`generate_synthetic_array`].
30#[derive(Debug, Clone)]
31pub struct SyntheticConfig {
32    /// Number of buoys.
33    pub n_buoys: usize,
34    /// Temporal series length per buoy.
35    pub series_len: usize,
36    /// Radius for undirected edges (position units).
37    pub connect_radius: f64,
38    /// Ambient noise amplitude.
39    pub noise: f32,
40    /// RNG seed.
41    pub seed: u64,
42}
43
44impl Default for SyntheticConfig {
45    fn default() -> Self {
46        Self {
47            n_buoys: 6,
48            series_len: 32,
49            connect_radius: 3.5,
50            noise: 0.05,
51            seed: 0x50b007,
52        }
53    }
54}
55
56/// One labelled observation: which node, and ground-truth class.
57#[derive(Debug, Clone)]
58pub struct SyntheticSample {
59    pub node_idx: usize,
60    pub label: usize,
61}
62
63/// Full synthetic dataset: shared array graph + per-node labels.
64#[derive(Debug, Clone)]
65pub struct SyntheticDataset {
66    pub graph: SensorGraph,
67    pub samples: Vec<SyntheticSample>,
68    pub config: SyntheticConfig,
69}
70
71/// Generate a synthetic sonobuoy array with class-conditional temporal events.
72///
73/// Buoys are placed on a rough grid. Roughly one third of nodes are labelled
74/// ambient / vessel / mammal. Vessel and mammal events are injected into the
75/// temporal buffer; neighbors share a weak bleed of the same event to make
76/// spatial aggregation informative.
77pub fn generate_synthetic_array(cfg: SyntheticConfig) -> SyntheticDataset {
78    let mut rng = cfg.seed;
79    let mut graph = SensorGraph::new();
80
81    // Grid placement: ~sqrt(N) columns.
82    let cols = (cfg.n_buoys as f64).sqrt().ceil() as usize;
83    let mut labels = Vec::with_capacity(cfg.n_buoys);
84
85    for i in 0..cfg.n_buoys {
86        let row = i / cols;
87        let col = i % cols;
88        // Slight jitter so distances are not perfectly uniform.
89        let jx = f64::from(unit(&mut rng)) * 0.3;
90        let jy = f64::from(unit(&mut rng)) * 0.3;
91        let lat = row as f64 + jx;
92        let lon = col as f64 + jy;
93        let depth = 8.0 + f64::from(unit(&mut rng)) * 4.0;
94
95        // Physics priors (normalized-ish): SSP, thermocline, sea_state, current, bottom.
96        let features = vec![
97            0.4 + 0.2 * unit(&mut rng), // sound speed proxy
98            0.3 + 0.3 * unit(&mut rng), // thermocline
99            0.1 + 0.4 * unit(&mut rng), // sea state
100            unit(&mut rng) * 0.5,       // current
101            if unit(&mut rng) > 0.5 { 0.8 } else { 0.2 }, // bottom type
102        ];
103        debug_assert_eq!(features.len(), PHYSICS_DIM);
104
105        graph.add_buoy(SensorNode {
106            id: format!("buoy-{i:02}"),
107            position: (lat, lon, depth),
108            features,
109        });
110
111        // Round-robin class assignment with a bit of randomness.
112        let label = match i % 3 {
113            0 => CLASS_AMBIENT,
114            1 => CLASS_VESSEL,
115            _ => CLASS_MAMMAL,
116        };
117        labels.push(label);
118    }
119
120    graph.connect_within_radius(cfg.connect_radius);
121    // Ensure connectivity for tiny fleets: if isolated, fall back to full mesh.
122    if graph.edge_count() == 0 && cfg.n_buoys > 1 {
123        graph.connect_fully();
124    }
125
126    // Temporal series per class.
127    for (i, &label) in labels.iter().enumerate() {
128        let series = match label {
129            CLASS_VESSEL => vessel_series(cfg.series_len, cfg.noise, &mut rng),
130            CLASS_MAMMAL => mammal_series(cfg.series_len, cfg.noise, &mut rng),
131            _ => ambient_series(cfg.series_len, cfg.noise, &mut rng),
132        };
133        graph.push_temporal(i, &series);
134    }
135
136    // Spatial bleed: add 20% of each event node's series to 1-hop neighbors
137    // (simulates shared propagation of the same acoustic source).
138    let bleed: Vec<(usize, Vec<f32>)> = (0..cfg.n_buoys)
139        .filter(|&i| labels[i] != CLASS_AMBIENT)
140        .map(|i| {
141            let src = graph.temporal_buffers[i].clone();
142            (i, src)
143        })
144        .collect();
145    for (src_idx, src_series) in bleed {
146        for nb in graph.neighbor_indices(src_idx) {
147            let buf = &mut graph.temporal_buffers[nb];
148            let n = buf.len().min(src_series.len());
149            for t in 0..n {
150                buf[t] += 0.2 * src_series[t];
151            }
152        }
153    }
154
155    let samples = labels
156        .into_iter()
157        .enumerate()
158        .map(|(node_idx, label)| SyntheticSample { node_idx, label })
159        .collect();
160
161    SyntheticDataset {
162        graph,
163        samples,
164        config: cfg,
165    }
166}
167
168fn ambient_series(len: usize, noise: f32, rng: &mut u64) -> Vec<f32> {
169    (0..len).map(|_| (unit(rng) - 0.5) * 2.0 * noise).collect()
170}
171
172fn vessel_series(len: usize, noise: f32, rng: &mut u64) -> Vec<f32> {
173    // Periodic mid-amplitude bumps (propeller-like cadence).
174    let period = 6.0f32;
175    (0..len)
176        .map(|t| {
177            let phase = (t as f32) * std::f32::consts::TAU / period;
178            let sig = 0.35 * (phase.sin().max(0.0));
179            sig + (unit(rng) - 0.5) * 2.0 * noise
180        })
181        .collect()
182}
183
184fn mammal_series(len: usize, noise: f32, rng: &mut u64) -> Vec<f32> {
185    // Sparse high impulses (call-like).
186    let mut s: Vec<f32> = (0..len)
187        .map(|_| (unit(rng) - 0.5) * 2.0 * noise)
188        .collect();
189    let n_clicks = 2 + (*rng as usize % 3);
190    for _ in 0..n_clicks {
191        let t = (*rng as usize) % len;
192        *rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
193        s[t] += 0.9;
194        if t + 1 < len {
195            s[t + 1] += 0.4;
196        }
197    }
198    s
199}
200
201/// LCG unit float in [0, 1).
202fn unit(rng: &mut u64) -> f32 {
203    *rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
204    ((*rng >> 33) as f32) / (u32::MAX as f32)
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn generates_expected_counts() {
213        let ds = generate_synthetic_array(SyntheticConfig {
214            n_buoys: 9,
215            series_len: 24,
216            connect_radius: 2.5,
217            noise: 0.05,
218            seed: 1,
219        });
220        assert_eq!(ds.graph.node_count(), 9);
221        assert_eq!(ds.samples.len(), 9);
222        assert!(ds.graph.edge_count() > 0);
223        for s in &ds.samples {
224            assert!(s.label < 3);
225            assert_eq!(
226                ds.graph.temporal_buffers[s.node_idx].len(),
227                24
228            );
229        }
230    }
231
232    #[test]
233    fn class_names() {
234        assert_eq!(class_name(0), "ambient");
235        assert_eq!(class_name(1), "vessel");
236        assert_eq!(class_name(2), "mammal_call");
237    }
238
239    #[test]
240    fn vessel_nodes_have_higher_energy_than_ambient() {
241        let ds = generate_synthetic_array(SyntheticConfig {
242            n_buoys: 6,
243            series_len: 32,
244            connect_radius: 5.0,
245            noise: 0.02,
246            seed: 42,
247        });
248        let mut amb_e = 0.0f32;
249        let mut amb_n = 0usize;
250        let mut ves_e = 0.0f32;
251        let mut ves_n = 0usize;
252        for s in &ds.samples {
253            let e: f32 = ds.graph.temporal_buffers[s.node_idx]
254                .iter()
255                .map(|v| v.abs())
256                .sum();
257            if s.label == CLASS_AMBIENT {
258                amb_e += e;
259                amb_n += 1;
260            } else if s.label == CLASS_VESSEL {
261                ves_e += e;
262                ves_n += 1;
263            }
264        }
265        assert!(amb_n > 0 && ves_n > 0);
266        assert!(ves_e / ves_n as f32 > amb_e / amb_n as f32);
267    }
268}