Skip to main content

exg_source/
resolution.rs

1//! Resolution matrix computation.
2//!
3//! The resolution matrix `R = K @ G` describes how source activity is
4//! mapped through the inverse and forward operators. It characterises
5//! the spatial blurring (leakage) of the inverse solution:
6//!
7//! - For an ideal inverse, `R = I` (perfect localisation).
8//! - In practice, each row of `R` shows how activity at one source
9//!   "leaks" to all other sources.
10//!
11//! ## Metrics
12//!
13//! - **Peak localisation error**: distance between the true source and
14//!   the peak of the corresponding PSF.
15//! - **Spatial spread**: width of the PSF (e.g., half-max radius).
16//! - **Relative amplitude**: ratio of peak to off-peak activity.
17//!
18//! ## References
19//!
20//! Hauk et al. (2011). "Comparison of noise-normalized minimum norm
21//! estimates for MEG analysis using a visual paradigm." NeuroImage.
22//!
23//! Ported from MNE-Python's `mne.minimum_norm.resolution_matrix`.
24
25use anyhow::Result;
26use ndarray::{Array1, Array2};
27
28use super::inverse::prepare_inverse;
29use super::{ForwardOperator, InverseMethod, InverseOperator};
30
31/// Compute the resolution matrix `R = K @ G`.
32///
33/// # Arguments
34///
35/// * `inv`     — Inverse operator.
36/// * `fwd`     — Forward operator (must match the one used to build `inv`).
37/// * `lambda2` — Regularisation parameter.
38/// * `method`  — Inverse method.
39///
40/// # Returns
41///
42/// Resolution matrix of shape `[n_sources, n_sources]` for fixed orientation,
43/// or `[n_sources, n_sources]` (XYZ combined) for free orientation.
44pub fn make_resolution_matrix(
45    inv: &InverseOperator,
46    fwd: &ForwardOperator,
47    lambda2: f64,
48    method: InverseMethod,
49) -> Result<Array2<f64>> {
50    let prepared = prepare_inverse(inv, lambda2, method, None)?;
51    let kernel = &prepared.kernel; // [n_src*n_orient, n_chan]
52    let gain = &fwd.gain; // [n_chan, n_src*n_orient]
53
54    // R_raw = K @ G  [n_src*n_orient, n_src*n_orient]
55    let r_raw = kernel.dot(gain);
56
57    let n_orient = fwd.n_orient();
58    let n_src = fwd.n_sources;
59
60    if n_orient == 1 {
61        // Apply noise normalisation if present
62        let mut r = r_raw;
63        if let Some(ref nn) = prepared.noisenorm {
64            for i in 0..r.nrows() {
65                for j in 0..r.ncols() {
66                    r[[i, j]] *= nn[i];
67                }
68            }
69        }
70        Ok(r)
71    } else {
72        // Free orientation: combine XYZ → [n_src, n_src]
73        // R_combined[i,j] = ‖R_raw[3i..3i+3, 3j..3j+3]‖_F
74        let mut r = Array2::zeros((n_src, n_src));
75        for i in 0..n_src {
76            for j in 0..n_src {
77                let mut sum_sq = 0.0;
78                for oi in 0..3 {
79                    for oj in 0..3 {
80                        let v = r_raw[[i * 3 + oi, j * 3 + oj]];
81                        sum_sq += v * v;
82                    }
83                }
84                r[[i, j]] = sum_sq.sqrt();
85            }
86        }
87        // Apply noise normalisation
88        if let Some(ref nn) = prepared.noisenorm {
89            for i in 0..n_src {
90                for j in 0..n_src {
91                    r[[i, j]] *= nn[i];
92                }
93            }
94        }
95        Ok(r)
96    }
97}
98
99/// Point-spread function (PSF) for a given source index.
100///
101/// Returns a column of the resolution matrix: how activity at source `idx`
102/// appears across all sources after inverse modelling.
103///
104/// Shape: `[n_sources]`.
105pub fn get_point_spread(resolution: &Array2<f64>, source_idx: usize) -> Array1<f64> {
106    resolution.column(source_idx).to_owned()
107}
108
109/// Cross-talk function (CTF) for a given source index.
110///
111/// Returns a row of the resolution matrix: how activity at all other
112/// sources leaks into the estimate at source `idx`.
113///
114/// Shape: `[n_sources]`.
115pub fn get_cross_talk(resolution: &Array2<f64>, source_idx: usize) -> Array1<f64> {
116    resolution.row(source_idx).to_owned()
117}
118
119/// Compute peak localisation error for each source.
120///
121/// For each source `i`, finds the index of the maximum in the PSF
122/// and reports the index offset `|argmax(R[:, i]) − i|`.
123///
124/// Returns an array of length `n_sources`.
125pub fn peak_localisation_error(resolution: &Array2<f64>) -> Array1<usize> {
126    let n = resolution.ncols();
127    let mut errors = Array1::zeros(n);
128    for j in 0..n {
129        let col = resolution.column(j);
130        let peak = col
131            .iter()
132            .enumerate()
133            .max_by(|(_, a), (_, b)| a.abs().partial_cmp(&b.abs()).unwrap())
134            .map(|(idx, _)| idx)
135            .unwrap_or(j);
136        errors[j] = if peak > j { peak - j } else { j - peak };
137    }
138    errors
139}
140
141/// Compute spatial spread (half-max width) for each PSF.
142///
143/// For each source, counts how many sources in the PSF have amplitude
144/// ≥ 50% of the peak amplitude. Smaller is better.
145///
146/// Returns an array of length `n_sources`.
147pub fn spatial_spread(resolution: &Array2<f64>) -> Array1<usize> {
148    let n = resolution.ncols();
149    let mut widths = Array1::zeros(n);
150    for j in 0..n {
151        let col = resolution.column(j);
152        let peak_abs = col.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
153        if peak_abs > 0.0 {
154            let threshold = peak_abs * 0.5;
155            widths[j] = col.iter().filter(|v| v.abs() >= threshold).count();
156        }
157    }
158    widths
159}
160
161/// Compute relative amplitude for each PSF.
162///
163/// Ratio of the peak value to the sum of absolute values in the PSF.
164/// A value of 1.0 means perfect localisation (delta function).
165///
166/// Returns an array of length `n_sources`.
167pub fn relative_amplitude(resolution: &Array2<f64>) -> Array1<f64> {
168    let n = resolution.ncols();
169    let mut rel_amp = Array1::zeros(n);
170    for j in 0..n {
171        let col = resolution.column(j);
172        let peak_abs = col.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
173        let total_abs: f64 = col.iter().map(|v| v.abs()).sum();
174        rel_amp[j] = if total_abs > 0.0 {
175            peak_abs / total_abs
176        } else {
177            0.0
178        };
179    }
180    rel_amp
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::{make_inverse_operator, ForwardOperator, InverseMethod, NoiseCov};
187    use ndarray::Array2;
188
189    fn make_test_setup() -> (ForwardOperator, NoiseCov) {
190        let n_chan = 16;
191        let n_src = 20;
192        let mut gain = Array2::zeros((n_chan, n_src));
193        for i in 0..n_chan {
194            for j in 0..n_src {
195                let dist =
196                    ((i as f64 - j as f64 * n_chan as f64 / n_src as f64).powi(2) + 1.0).sqrt();
197                gain[[i, j]] = 1e-8 / dist;
198            }
199        }
200        (
201            ForwardOperator::new_fixed(gain),
202            NoiseCov::diagonal(vec![1e-12; n_chan]),
203        )
204    }
205
206    #[test]
207    fn test_resolution_matrix_shape() {
208        let (fwd, cov) = make_test_setup();
209        let inv = make_inverse_operator(&fwd, &cov, None).unwrap();
210        let r = make_resolution_matrix(&inv, &fwd, 1.0 / 9.0, InverseMethod::MNE).unwrap();
211        assert_eq!(r.dim(), (20, 20));
212    }
213
214    #[test]
215    fn test_resolution_matrix_diagonal_dominance() {
216        let (fwd, cov) = make_test_setup();
217        let inv = make_inverse_operator(&fwd, &cov, None).unwrap();
218        let r = make_resolution_matrix(&inv, &fwd, 1.0 / 9.0, InverseMethod::MNE).unwrap();
219
220        // For many sources the diagonal should be among the largest values in each column
221        let mut diag_is_large = 0;
222        for j in 0..20 {
223            let col = r.column(j);
224            let diag = col[j].abs();
225            let max = col.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
226            if diag >= max * 0.5 {
227                diag_is_large += 1;
228            }
229        }
230        assert!(
231            diag_is_large >= 10,
232            "At least half the sources should have diag ≥ 50% of max, got {diag_is_large}"
233        );
234    }
235
236    #[test]
237    fn test_psf_ctf() {
238        let (fwd, cov) = make_test_setup();
239        let inv = make_inverse_operator(&fwd, &cov, None).unwrap();
240        let r = make_resolution_matrix(&inv, &fwd, 1.0 / 9.0, InverseMethod::MNE).unwrap();
241
242        let psf = get_point_spread(&r, 10);
243        let ctf = get_cross_talk(&r, 10);
244        assert_eq!(psf.len(), 20);
245        assert_eq!(ctf.len(), 20);
246    }
247
248    #[test]
249    fn test_peak_localisation_error() {
250        let (fwd, cov) = make_test_setup();
251        let inv = make_inverse_operator(&fwd, &cov, None).unwrap();
252        let r = make_resolution_matrix(&inv, &fwd, 1.0 / 9.0, InverseMethod::MNE).unwrap();
253
254        let errors = peak_localisation_error(&r);
255        assert_eq!(errors.len(), 20);
256        // Most sources should have small localisation error
257        let mean_error: f64 = errors.iter().map(|&e| e as f64).sum::<f64>() / 20.0;
258        assert!(
259            mean_error < 5.0,
260            "Mean peak error = {mean_error}, expected < 5"
261        );
262    }
263
264    #[test]
265    fn test_spatial_spread_and_relative_amplitude() {
266        let (fwd, cov) = make_test_setup();
267        let inv = make_inverse_operator(&fwd, &cov, None).unwrap();
268        let r = make_resolution_matrix(&inv, &fwd, 1.0 / 9.0, InverseMethod::MNE).unwrap();
269
270        let spread = spatial_spread(&r);
271        let rel_amp = relative_amplitude(&r);
272        assert_eq!(spread.len(), 20);
273        assert_eq!(rel_amp.len(), 20);
274
275        // Relative amplitude should be in (0, 1]
276        for &v in rel_amp.iter() {
277            assert!(v >= 0.0 && v <= 1.0, "rel_amp = {v} out of range");
278        }
279    }
280
281    #[test]
282    fn test_resolution_with_dspm() {
283        let (fwd, cov) = make_test_setup();
284        let inv = make_inverse_operator(&fwd, &cov, None).unwrap();
285        let r = make_resolution_matrix(&inv, &fwd, 1.0 / 9.0, InverseMethod::DSPM).unwrap();
286        assert_eq!(r.dim(), (20, 20));
287        assert!(r.iter().all(|v| v.is_finite()));
288    }
289}