Skip to main content

dspk_synapse/io/probe/
wiring.rs

1//! Probe files that say which recording channel each contact is wired to, read as a
2//! [`ChannelMap`] (the data in contact order) plus the geometry in that order, and a probe's
3//! geometry following a map.
4//!
5//! - **probeinterface** JSON (`"specification": "probeinterface"`): contact `i` at
6//!   `contact_positions[i]`, wired to recording channel `device_channel_indices[i]` (`-1`: not
7//!   connected, left out); `contact_ids` name the outputs; several probes are concatenated.
8//! - **Kilosort** probe JSON (Kilosort 4: `chanMap`, `xc`, `yc`, `kcoords`, 0-based): site `i`
9//!   at `(xc[i], yc[i])` on shank `kcoords[i]` reads data row `chanMap[i]`; sites with
10//!   `connected[i] = false` (when given) are left out.
11//!
12//! Only `.json` files are read; MATLAB `.mat` probe files are refused (converting them is
13//! neuro-converter's job, not dspk's).
14
15use std::collections::BTreeMap;
16use std::path::Path;
17
18use dspk_core::{ChannelMap, ChannelPair, DspError, DspResult};
19use serde::Deserialize;
20use serde_json::Value;
21
22use super::{Position3D, SensorLayout, SensorSite};
23
24/// A probe file read as a map and its geometry: output channel `i` reads recording channel
25/// `map.order()[i]` and sits at `layout`'s site `i` (channel ids `0..n`).
26#[derive(Debug, Clone, PartialEq)]
27pub struct ProbeWiring {
28    pub map: ChannelMap,
29    pub layout: SensorLayout,
30}
31
32fn bad(path: &str, m: impl std::fmt::Display) -> DspError {
33    DspError::InvalidConfig(format!("{path}: {m}"))
34}
35
36/// Reads a probeinterface or Kilosort probe JSON file (detected from its keys); any other
37/// extension (e.g. a MATLAB `.mat`) is refused.
38pub fn read_probe_wiring(path: &Path) -> DspResult<ProbeWiring> {
39    if !path.extension().is_some_and(|e| e.eq_ignore_ascii_case("json")) {
40        return Err(bad(&path.display().to_string(), "a probe file is a .json file (probeinterface or Kilosort 4); .mat files are not read (neuro-converter converts them)"));
41    }
42    let text = std::fs::read_to_string(path).map_err(|e| bad(&path.display().to_string(), e))?;
43    let name = path.file_stem().map_or_else(|| "probe".into(), |s| s.to_string_lossy().into_owned());
44    parse_probe_wiring(&text, &name).map_err(|e| match e {
45        DspError::InvalidConfig(m) => bad(&path.display().to_string(), m),
46        other => other,
47    })
48}
49
50/// [`read_probe_wiring`] of a file's text; `name` names the layout.
51pub fn parse_probe_wiring(text: &str, name: &str) -> DspResult<ProbeWiring> {
52    let v: Value = serde_json::from_str(text).map_err(|e| bad(name, e))?;
53    if v.get("specification").and_then(Value::as_str) == Some("probeinterface") {
54        probeinterface(v, name)
55    } else if v.get("chanMap").is_some() {
56        kilosort(v, name)
57    } else {
58        Err(bad(name, "neither a probeinterface file (\"specification\": \"probeinterface\") nor a Kilosort probe (\"chanMap\")"))
59    }
60}
61
62/// One wired contact: recording channel, position, shank, name.
63struct Contact {
64    channel: usize,
65    position: Position3D,
66    shank: usize,
67    name: Option<String>,
68}
69
70fn wiring(contacts: Vec<Contact>, name: &str) -> DspResult<ProbeWiring> {
71    if contacts.is_empty() {
72        return Err(bad(name, "no connected contacts"));
73    }
74    let pairs = contacts.iter().enumerate().map(|(to, c)| ChannelPair { from: c.channel, to, name: c.name.clone() }).collect();
75    let map = ChannelMap { base: 0, pairs };
76    map.order().map_err(|e| bad(name, e))?;
77    let sites = contacts.into_iter().enumerate().map(|(i, c)| SensorSite::new(i, c.position, c.shank)).collect();
78    Ok(ProbeWiring { map, layout: SensorLayout::new(name, sites) })
79}
80
81fn probeinterface(v: Value, name: &str) -> DspResult<ProbeWiring> {
82    #[derive(Deserialize)]
83    struct Probe {
84        contact_positions: Vec<Vec<f32>>,
85        #[serde(default)]
86        device_channel_indices: Option<Vec<i64>>,
87        #[serde(default)]
88        shank_ids: Option<Vec<String>>,
89        #[serde(default)]
90        contact_ids: Option<Vec<String>>,
91    }
92    #[derive(Deserialize)]
93    struct File {
94        probes: Vec<Probe>,
95    }
96    let file: File = serde_json::from_value(v).map_err(|e| bad(name, e))?;
97    // Shanks numbered across probes in order of appearance
98    let mut shanks: BTreeMap<(usize, String), usize> = BTreeMap::new();
99    let mut contacts = Vec::new();
100    for (p, probe) in file.probes.iter().enumerate() {
101        let n = probe.contact_positions.len();
102        let Some(dci) = &probe.device_channel_indices else {
103            return Err(bad(name, format!("probe {p} has no device_channel_indices (which channel each contact is wired to)")));
104        };
105        if dci.len() != n {
106            return Err(bad(name, format!("probe {p}: {} device_channel_indices for {n} contacts", dci.len())));
107        }
108        for (i, pos) in probe.contact_positions.iter().enumerate() {
109            let Ok(channel) = usize::try_from(dci[i]) else { continue };
110            let shank_key = (p, probe.shank_ids.as_ref().and_then(|s| s.get(i)).cloned().unwrap_or_default());
111            let next = shanks.len();
112            let shank = *shanks.entry(shank_key).or_insert(next);
113            let at = |k: usize| pos.get(k).copied().unwrap_or(0.0);
114            let name = probe.contact_ids.as_ref().and_then(|c| c.get(i)).filter(|c| !c.is_empty()).cloned();
115            contacts.push(Contact { channel, position: Position3D::new(at(0), at(1), at(2)), shank, name });
116        }
117    }
118    wiring(contacts, name)
119}
120
121fn kilosort(v: Value, name: &str) -> DspResult<ProbeWiring> {
122    #[derive(Deserialize)]
123    #[serde(rename_all = "camelCase")]
124    struct Probe {
125        chan_map: Vec<f64>,
126        #[serde(rename = "xc")]
127        xc: Vec<f32>,
128        #[serde(rename = "yc")]
129        yc: Vec<f32>,
130        #[serde(default)]
131        kcoords: Option<Vec<f64>>,
132        #[serde(default)]
133        connected: Option<Vec<Value>>,
134    }
135    let probe: Probe = serde_json::from_value(v).map_err(|e| bad(name, e))?;
136    let n = probe.chan_map.len();
137    if probe.xc.len() != n || probe.yc.len() != n {
138        return Err(bad(name, format!("chanMap has {n} sites but xc / yc have {} / {}", probe.xc.len(), probe.yc.len())));
139    }
140    let connected = |i: usize| match probe.connected.as_ref().and_then(|c| c.get(i)) {
141        Some(Value::Bool(b)) => *b,
142        Some(Value::Number(x)) => x.as_f64() != Some(0.0),
143        _ => true,
144    };
145    let mut contacts = Vec::new();
146    for i in (0..n).filter(|&i| connected(i)) {
147        let c = probe.chan_map[i];
148        if c < 0.0 || c.fract() != 0.0 {
149            return Err(bad(name, format!("chanMap[{i}] = {c} is not a channel index (0-based)")));
150        }
151        let shank = probe.kcoords.as_ref().and_then(|k| k.get(i)).map_or(0, |&k| k.max(0.0) as usize);
152        contacts.push(Contact { channel: c as usize, position: Position3D::new(probe.xc[i], probe.yc[i], 0.0), shank, name: None });
153    }
154    wiring(contacts, name)
155}
156
157impl SensorLayout {
158    /// The geometry of the channels after `map` (whose inputs are this layout's channel ids):
159    /// output channel `i` sits where input `order[i]` did, renamed `i`.
160    pub fn remap(&self, map: &ChannelMap) -> DspResult<SensorLayout> {
161        let order = map.resolve(self.total_channels())?;
162        for &c in &order {
163            self.get_site(c)?;
164        }
165        Ok(self.select_channels(&order))
166    }
167
168    /// A map that sorts the channels by shank, then depth (`y`, from the tip up), then `x`:
169    /// neighbouring rows of the data become neighbouring sites.
170    pub fn depth_order(&self) -> ChannelMap {
171        let mut sites: Vec<&SensorSite> = self.contacts.iter().collect();
172        sites.sort_by(|a, b| {
173            (a.shank_id, a.position.y_um, a.position.x_um)
174                .partial_cmp(&(b.shank_id, b.position.y_um, b.position.x_um))
175                .unwrap_or(std::cmp::Ordering::Equal)
176        });
177        ChannelMap::from_order(&sites.iter().map(|s| s.channel_id).collect::<Vec<_>>())
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    const PROBEINTERFACE: &str = r#"{"specification": "probeinterface", "version": "0.2.21",
186        "probes": [{"ndim": 2, "si_units": "um",
187                    "contact_positions": [[0, 0], [0, 20], [16, 10], [16, 30]],
188                    "device_channel_indices": [3, 1, -1, 0],
189                    "shank_ids": ["", "", "", ""],
190                    "contact_ids": ["e0", "e1", "e2", "e3"]}]}"#;
191
192    #[test]
193    fn probeinterface_contacts_in_order_unconnected_left_out() {
194        let w = parse_probe_wiring(PROBEINTERFACE, "p").unwrap();
195        assert_eq!(w.map.order().unwrap(), vec![3, 1, 0]);
196        assert_eq!(w.map.names(), vec![Some("e0".into()), Some("e1".into()), Some("e3".into())]);
197        let ys: Vec<f32> = w.layout.contacts.iter().map(|s| s.position.y_um).collect();
198        assert_eq!(ys, vec![0.0, 20.0, 30.0]);
199        assert_eq!(w.layout.get_site(2).unwrap().position.x_um, 16.0);
200    }
201
202    #[test]
203    fn kilosort_probe_json() {
204        let ks = r#"{"chanMap": [2, 0, 1], "xc": [0, 32, 0], "yc": [0, 0, 15], "kcoords": [0, 0, 1], "n_chan": 3}"#;
205        let w = parse_probe_wiring(ks, "ks").unwrap();
206        assert_eq!(w.map.order().unwrap(), vec![2, 0, 1]);
207        assert_eq!(w.layout.get_site(2).unwrap().shank_id, 1);
208        let unwired = r#"{"chanMap": [0, 1], "xc": [0, 0], "yc": [0, 20], "connected": [true, false]}"#;
209        assert_eq!(parse_probe_wiring(unwired, "ks").unwrap().map.order().unwrap(), vec![0]);
210        assert!(parse_probe_wiring(r#"{"chanMap": [0, 0], "xc": [0, 0], "yc": [0, 1]}"#, "ks").is_err(), "a channel twice");
211        assert!(parse_probe_wiring(r#"{"x": 1}"#, "?").is_err());
212    }
213
214    #[test]
215    fn only_json_files_are_read() {
216        let dir = std::env::temp_dir().join(format!("dspk-wiring-{}", std::process::id()));
217        std::fs::create_dir_all(&dir).unwrap();
218        let mat = dir.join("chanMap.mat");
219        std::fs::write(&mat, b"MATLAB 5.0 MAT-file").unwrap();
220        let err = read_probe_wiring(&mat).unwrap_err().to_string();
221        assert!(err.contains(".json"), "{err}");
222        let json = dir.join("probe.json");
223        std::fs::write(&json, PROBEINTERFACE).unwrap();
224        assert_eq!(read_probe_wiring(&json).unwrap().layout.name, "probe");
225        std::fs::remove_dir_all(&dir).unwrap();
226    }
227
228    #[test]
229    fn geometry_follows_a_map_and_depth_order_sorts_by_shank_and_depth() {
230        let np = crate::io::probe::neuropixels_1_0();
231        let map = ChannelMap::from_order(&[10, 3, 200]);
232        assert_eq!(np.remap(&map).unwrap(), np.select_channels(&[10, 3, 200]));
233        assert!(np.remap(&ChannelMap::from_order(&[384])).is_err());
234        // Shuffled sites come back sorted by depth
235        let shuffled = np.select_channels(&[7, 2, 5, 0]);
236        let order = shuffled.depth_order().order().unwrap();
237        let ys: Vec<f32> = order.iter().map(|&c| shuffled.get_site(c).unwrap().position.y_um).collect();
238        assert!(ys.windows(2).all(|w| w[0] <= w[1]), "{ys:?}");
239    }
240}