1use super::DR_SHIN_2021;
60use crate::Grid;
61#[cfg(feature = "parallel")]
62use rayon::prelude::*;
63
64use std::f64::consts::PI;
65
66const GAMMA_BAR: f64 = 42.577e6;
69
70const CHI_I: f64 = -0.06e-6; const CHI_A: f64 = -0.10e-6; const E_EXCH: f64 = 0.02e-6; const G_RATIO: f64 = 0.7;
75const T2_M: f64 = 10e-3; const T2_A: f64 = 64e-3; const T2_E: f64 = 48e-3; const F_AXON: f64 = 0.55; const CHI_NEG_REF: f64 = 0.038; const MWF_REF: f64 = 0.12;
83const MWF_MIN: f64 = 0.03;
84const MWF_MAX: f64 = 0.25;
85
86const SMOOTH_CLS: [f64; 2] = [0.75, 1.0]; const SMOOTH_FIT: f64 = 1.0;
89const W_CENTER: f64 = 1.5;
90const W_SCALE: f64 = 0.3;
91const CHI_GATE_C: f64 = 0.01;
92const CHI_GATE_S: f64 = 0.01;
93const LAM: f64 = 0.7;
94const NCONV_SIGMA: f64 = 1.5;
95const W_SUPPORT: f64 = 0.02;
96const NO_BEAT_FRAC: f64 = 0.01;
97
98const K_CHI: f64 = CHI_NEG_REF / MWF_REF;
100
101const NT: usize = 61; const NM: usize = 45; #[cfg_attr(feature = "introspection", derive(serde::Serialize))]
107#[derive(Clone, Debug)]
108pub struct HcChisepParams {
109 pub b0: f64,
111 pub se_echo_times: Vec<f64>,
113 pub dr_pos_3t: f64,
115 pub bin_hz: f64,
117}
118
119impl Default for HcChisepParams {
120 fn default() -> Self {
121 Self {
122 b0: 7.0,
123 se_echo_times: Vec::new(),
124 dr_pos_3t: DR_SHIN_2021,
125 bin_hz: 0.25,
126 }
127 }
128}
129
130#[allow(clippy::too_many_arguments)]
151pub fn hc_chisep(
152 chi_total: &[f64],
153 r2prime: &[f64],
154 magnitude: &[f64],
155 echo_times: &[f64],
156 se_magnitude: Option<&[f64]>,
157 mask: &[u8],
158 grid: &Grid,
159 params: &HcChisepParams,
160 mut progress: impl FnMut(usize, usize),
161) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
162 let dims = grid.dims;
163 let n = dims.0 * dims.1 * dims.2;
164 let ne = echo_times.len();
165 assert_eq!(chi_total.len(), n, "chi_total length must match grid");
166 assert_eq!(r2prime.len(), n, "r2prime length must match grid");
167 assert_eq!(
168 magnitude.len(),
169 n * ne,
170 "magnitude must be n_voxels * n_echoes"
171 );
172 assert_eq!(mask.len(), n, "mask length must match grid");
173
174 let b0 = params.b0;
175
176 let mag_em = to_echo_major(magnitude, n, ne);
179 let se_e_len = params.se_echo_times.len();
180 let se_em: Option<Vec<f64>> = match se_magnitude {
181 Some(s) if se_e_len >= 2 && s.len() == n * se_e_len => Some(to_echo_major(s, n, se_e_len)),
182 _ => None,
183 };
184
185 let n_stages = 5;
186 let mut stage = 0usize;
187 let tick = |s: &mut usize, p: &mut dyn FnMut(usize, usize)| {
188 *s += 1;
189 p(*s, n_stages);
190 };
191
192 let dr_default = params.dr_pos_3t * b0 / 3.0;
194 let dr_pos = {
195 let ratios: Vec<f64> = (0..n)
196 .filter(|&i| mask[i] != 0 && chi_total[i] > 0.02)
197 .map(|i| r2prime[i] / chi_total[i])
198 .collect();
199 if ratios.len() > 5000 {
200 let p5 = percentile(&ratios, 5.0);
201 if (0.3 * dr_default..=3.0 * dr_default).contains(&p5) {
202 p5
203 } else {
204 dr_default
205 }
206 } else {
207 dr_default
208 }
209 };
210 tick(&mut stage, &mut progress);
211
212 let mut cf_pos = vec![0.0_f64; n];
217 let mut cf_neg = vec![0.0_f64; n];
218 for i in 0..n {
219 let p = (r2prime[i] / dr_pos).max(0.0);
220 let neg = p - chi_total[i];
221 if neg < 0.0 {
222 cf_pos[i] = chi_total[i].max(0.0);
223 } else {
224 cf_pos[i] = p;
225 }
226 cf_neg[i] = (cf_pos[i] - chi_total[i]).max(0.0);
227 }
228 tick(&mut stage, &mut progress);
229
230 let fitter = AnchoredFitter::new(echo_times, b0, ¶ms.se_echo_times);
232 let have_se = se_em.is_some() && fitter.seml.is_some();
233 let se_e = params.se_echo_times.len();
234
235 let bii: Vec<usize> = (0..n).filter(|&i| mask[i] != 0).collect();
237 if bii.is_empty() {
238 return finish(&cf_pos, &cf_neg, mask, n);
239 }
240 let rho_b: Vec<f64> = bii.iter().map(|&i| r2prime[i]).collect();
241
242 let maskf: Vec<f64> = mask.iter().map(|&m| m as f64).collect();
246 let mut lr_best: Option<Vec<f64>> = None;
247 for &s in &SMOOTH_CLS {
248 let m_s = smooth_stack(&mag_em, dims, ne, s);
249 let sn = gather_normalised(&m_s, &bii, ne);
250 let (_t, _m, sse_hc) = fitter.fit(&sn, &rho_b, None, None, params.bin_hz);
251 let mut num = sse_hc;
252 let mut den: Vec<f64> = (0..bii.len())
253 .map(|v| mono_sse(&sn[v * ne..v * ne + ne], echo_times))
254 .collect();
255 if have_se {
256 let se_s = smooth_stack(se_em.as_ref().unwrap(), dims, se_e, s);
257 let sen = gather_normalised(&se_s, &bii, se_e);
258 let seml = fitter.seml.as_ref().unwrap();
259 for v in 0..bii.len() {
260 let mut best = f64::INFINITY;
262 for mi in 0..NM {
263 let mut acc = 0.0;
264 for e in 0..se_e {
265 let d = sen[v * se_e + e] - seml[mi * se_e + e];
266 acc += d * d;
267 }
268 best = best.min(acc);
269 }
270 num[v] += best;
271 den[v] += mono_sse(&sen[v * se_e..v * se_e + se_e], ¶ms.se_echo_times);
272 }
273 }
274 let lr: Vec<f64> = (0..bii.len())
275 .map(|v| (num[v] / den[v].max(1e-12)).max(1e-6).log10())
276 .collect();
277 lr_best = Some(match lr_best {
278 None => lr,
279 Some(prev) => (0..bii.len()).map(|v| prev[v].min(lr[v])).collect(),
280 });
281 }
282 let lr_best = lr_best.unwrap();
283
284 let lr_vol = volmap(&lr_best, &bii, n, 10.0);
286 let lr_med = median_filter_3(&lr_vol, dims);
287
288 let chi_masked: Vec<f64> = (0..n).map(|i| chi_total[i] * maskf[i]).collect();
290 let chs_num = gaussian_filter_3d(&chi_masked, dims, 1.0);
291 let chs_den = gaussian_filter_3d(&maskf, dims, 1.0);
292 let chs: Vec<f64> = (0..n).map(|i| chs_num[i] / chs_den[i].max(1e-6)).collect();
293
294 let mut w = vec![0.0_f64; n];
296 for i in 0..n {
297 let s1 = 1.0 / (1.0 + ((lr_med[i] - W_CENTER) / W_SCALE).exp());
298 let s2 = 1.0 / (1.0 + ((chs[i] - CHI_GATE_C) / CHI_GATE_S).exp());
299 w[i] = s1 * s2 * maskf[i];
300 }
301 let beat_frac = {
302 let cnt = bii.iter().filter(|&&i| w[i] > 0.5).count();
303 cnt as f64 / bii.len() as f64
304 };
305 tick(&mut stage, &mut progress);
306 if beat_frac < NO_BEAT_FRAC {
307 return finish(&cf_pos, &cf_neg, mask, n);
309 }
310
311 let sii: Vec<usize> = bii.iter().cloned().filter(|&i| w[i] > W_SUPPORT).collect();
313 let rho_s: Vec<f64> = sii.iter().map(|&i| r2prime[i]).collect();
314
315 let m_fit = smooth_stack(&mag_em, dims, ne, SMOOTH_FIT);
316 let sn_sm = gather_normalised(&m_fit, &sii, ne);
317 let sn_raw = gather_normalised(&mag_em, &sii, ne);
318 let (sen_sm, sen_raw) = if have_se {
319 let se = se_em.as_ref().unwrap();
320 let se_fit = smooth_stack(se, dims, se_e, SMOOTH_FIT);
321 (
322 Some(gather_normalised(&se_fit, &sii, se_e)),
323 Some(gather_normalised(se, &sii, se_e)),
324 )
325 } else {
326 (None, None)
327 };
328
329 let (th_sm, _m_sm, _s_sm) = fitter.fit(&sn_sm, &rho_s, sen_sm.as_deref(), None, params.bin_hz);
331 let th_fill = volmap(&th_sm, &sii, n, 45.0);
333 let th_reg = median_filter_3(&th_fill, dims);
334 let th_pin: Vec<f64> = sii.iter().map(|&i| th_reg[i]).collect();
335
336 let (_t1, mw_raw, _s1) = fitter.fit(
338 &sn_raw,
339 &rho_s,
340 sen_raw.as_deref(),
341 Some(&th_pin),
342 params.bin_hz,
343 );
344 let (_t2, mw_sm, _s2) = fitter.fit(
345 &sn_sm,
346 &rho_s,
347 sen_sm.as_deref(),
348 Some(&th_pin),
349 params.bin_hz,
350 );
351 let mw_est: Vec<f64> = (0..sii.len())
352 .map(|v| {
353 let bnd = (-chi_total[sii[v]] / K_CHI).clamp(MWF_MIN, MWF_MAX);
354 0.5 * (mw_raw[v].max(bnd) + mw_sm[v].max(bnd))
355 })
356 .collect();
357 let mw_vol = volmap(&mw_est, &sii, n, 0.0);
358
359 let wv: Vec<f64> = w.iter().map(|&x| x.clamp(0.0, 1.0)).collect();
361 let mw_w: Vec<f64> = (0..n).map(|i| mw_vol[i] * wv[i]).collect();
362 let mw_num = gaussian_filter_3d(&mw_w, dims, NCONV_SIGMA);
363 let mw_den = gaussian_filter_3d(&wv, dims, NCONV_SIGMA);
364 let mw_reg: Vec<f64> = (0..n).map(|i| mw_num[i] / mw_den[i].max(1e-3)).collect();
365 tick(&mut stage, &mut progress);
366
367 let route: Vec<f64> = (0..n).map(|i| chi_total[i] + K_CHI * mw_reg[i]).collect();
369 let conf: Vec<usize> = (0..n).filter(|&i| w[i] > 0.5).collect();
370 let c0 = if conf.len() > 100 {
371 let vals: Vec<f64> = conf.iter().map(|&i| route[i].max(0.0)).collect();
372 median(&vals)
373 } else {
374 0.005
375 };
376 let mut chi_pos = vec![0.0_f64; n];
377 let mut chi_neg = vec![0.0_f64; n];
378 let mut chi_out = vec![0.0_f64; n];
379 for i in 0..n {
380 if mask[i] == 0 {
381 continue;
382 }
383 let wm_pos = (LAM * route[i] + (1.0 - LAM) * c0).max(0.0);
384 let wm_neg = (wm_pos - chi_total[i]).max(0.0);
385 let pos = w[i] * wm_pos + (1.0 - w[i]) * cf_pos[i];
386 let neg = w[i] * wm_neg + (1.0 - w[i]) * cf_neg[i];
387 chi_pos[i] = pos;
388 chi_neg[i] = -neg; chi_out[i] = pos - neg;
390 }
391 tick(&mut stage, &mut progress);
392 (chi_pos, chi_neg, chi_out)
393}
394
395fn finish(cf_pos: &[f64], cf_neg: &[f64], mask: &[u8], n: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
397 let mut chi_pos = vec![0.0_f64; n];
398 let mut chi_neg = vec![0.0_f64; n];
399 let mut chi_out = vec![0.0_f64; n];
400 for i in 0..n {
401 if mask[i] != 0 {
402 chi_pos[i] = cf_pos[i];
403 chi_neg[i] = -cf_neg[i];
404 chi_out[i] = cf_pos[i] - cf_neg[i];
405 }
406 }
407 (chi_pos, chi_neg, chi_out)
408}
409
410fn hc_compartment_freqs(theta: f64, b0: f64) -> (f64, f64) {
417 let s2 = theta.sin() * theta.sin();
418 let w0 = GAMMA_BAR * b0;
419 let ln_term = 0.75 * CHI_A * (1.0 / G_RATIO).ln() * s2;
420 let f_my = w0
421 * (CHI_I * (2.0 / 3.0 - s2) / 2.0
422 + CHI_A * (1.0 / 12.0 - 5.0 / 12.0 * s2)
423 + ln_term
424 + E_EXCH);
425 let f_ax = w0 * ln_term;
426 (f_my, f_ax)
427}
428
429fn hc_wm_signal_mag(te: f64, theta: f64, b0: f64, mwf: f64, r2p_meso: f64) -> f64 {
432 let f_m = mwf;
433 let rest = 1.0 - f_m;
434 let f_a = rest * F_AXON;
435 let f_e = rest * (1.0 - F_AXON);
436 let (dfm, dfa) = hc_compartment_freqs(theta, b0);
437 let (mut re, mut im) = (0.0, 0.0);
438 let terms = [(f_m, T2_M, dfm), (f_a, T2_A, dfa), (f_e, T2_E, 0.0)];
439 for (f, t2, df) in terms {
440 let amp = f * (-te / t2).exp();
441 let ph = 2.0 * PI * df * te;
442 re += amp * ph.cos();
443 im += amp * ph.sin();
444 }
445 let mag = (re * re + im * im).sqrt();
446 mag * (-r2p_meso * te).exp()
447}
448
449fn hc_wm_se_signal(te: f64, mwf: f64) -> f64 {
451 let rest = 1.0 - mwf;
452 mwf * (-te / T2_M).exp()
453 + rest * F_AXON * (-te / T2_A).exp()
454 + rest * (1.0 - F_AXON) * (-te / T2_E).exp()
455}
456
457fn hc_wm_r2prime(theta: f64, mwf: f64, tes: &[f64], b0: f64) -> f64 {
460 let tbar = tes.iter().sum::<f64>() / tes.len() as f64;
461 let denom: f64 = tes.iter().map(|&t| (t - tbar) * (t - tbar)).sum();
462 let mut acc = 0.0;
463 for &te in tes {
464 let gre = hc_wm_signal_mag(te, theta, b0, mwf, 0.0);
465 let se = hc_wm_se_signal(te, mwf).max(1e-12);
466 let ratio = (gre / se).max(1e-12);
467 acc += ratio.ln() * (te - tbar);
468 }
469 (-acc / denom).max(0.0)
470}
471
472struct AnchoredFitter {
474 e: usize,
475 pn: Vec<f64>,
477 h: Vec<f64>,
479 dte: Vec<f64>,
481 seml: Option<Vec<f64>>,
483}
484
485impl AnchoredFitter {
486 fn new(tes: &[f64], b0: f64, se_tes: &[f64]) -> Self {
487 let e = tes.len();
488 let mut pn = vec![0.0_f64; NT * NM * e];
489 let mut h = vec![0.0_f64; NT * NM];
490 for ti in 0..NT {
491 let theta = (ti as f64 * 1.5).to_radians();
492 for mi in 0..NM {
493 let mwf = MWF_MIN + mi as f64 * 0.005;
494 let base = (ti * NM + mi) * e;
495 let first = hc_wm_signal_mag(tes[0], theta, b0, mwf, 0.0).max(1e-30);
496 for (k, &te) in tes.iter().enumerate() {
497 pn[base + k] = hc_wm_signal_mag(te, theta, b0, mwf, 0.0) / first;
498 }
499 h[ti * NM + mi] = hc_wm_r2prime(theta, mwf, tes, b0);
500 }
501 }
502 let dte: Vec<f64> = tes.iter().map(|&t| t - tes[0]).collect();
503 let seml = if se_tes.len() >= 2 {
504 let se_e = se_tes.len();
505 let mut m = vec![0.0_f64; NM * se_e];
506 for mi in 0..NM {
507 let mwf = MWF_MIN + mi as f64 * 0.005;
508 let first = hc_wm_se_signal(se_tes[0], mwf).max(1e-30);
509 for (k, &te) in se_tes.iter().enumerate() {
510 m[mi * se_e + k] = hc_wm_se_signal(te, mwf) / first;
511 }
512 }
513 Some(m)
514 } else {
515 None
516 };
517 Self {
518 e,
519 pn,
520 h,
521 dte,
522 seml,
523 }
524 }
525
526 fn fit(
529 &self,
530 sig_n: &[f64],
531 rho: &[f64],
532 se_n: Option<&[f64]>,
533 theta_pin: Option<&[f64]>,
534 bin_hz: f64,
535 ) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
536 let e = self.e;
537 let n = rho.len();
538 let l = NT * NM;
539
540 let se_e = se_n.map(|s| s.len() / n).unwrap_or(0);
542 let sse_se: Option<Vec<f64>> = match (se_n, &self.seml) {
543 (Some(sn), Some(seml)) => {
544 let mut out = vec![0.0_f64; n * NM];
545 for v in 0..n {
546 for mi in 0..NM {
547 let mut acc = 0.0;
548 for k in 0..se_e {
549 let d = sn[v * se_e + k] - seml[mi * se_e + k];
550 acc += d * d;
551 }
552 out[v * NM + mi] = acc;
553 }
554 }
555 Some(out)
556 }
557 _ => None,
558 };
559
560 use std::collections::HashMap;
562 let mut groups: HashMap<i64, Vec<usize>> = HashMap::new();
563 for (v, &r) in rho.iter().enumerate() {
564 let b = (r.clamp(0.0, 200.0) / bin_hz).round() as i64;
565 groups.entry(b).or_default().push(v);
566 }
567
568 let group_vec: Vec<(i64, Vec<usize>)> = groups.into_iter().collect();
569 let per_bin: Vec<Vec<(usize, f64, f64, f64)>> = maybe_par_iter!(group_vec)
571 .map(|(b, vs)| {
572 let mut flat = vec![0.0_f64; l * e];
574 let mut m2 = vec![0.0_f64; l];
575 let mut bad = vec![false; l];
576 let rho_bin = *b as f64 * bin_hz;
577 for li in 0..l {
578 let meso = rho_bin - self.h[li];
579 bad[li] = meso < -1.0;
580 let decay = meso.max(0.0);
581 let mut acc = 0.0;
582 for k in 0..e {
583 let val = self.pn[li * e + k] * (-decay * self.dte[k]).exp();
584 flat[li * e + k] = val;
585 acc += val * val;
586 }
587 m2[li] = 0.5 * acc;
588 }
589 let all_bad = bad.iter().all(|&x| x);
590
591 let mut out = Vec::with_capacity(vs.len());
592 for &v in vs {
593 let sig = &sig_n[v * e..v * e + e];
594 let se_row = sse_se.as_ref().map(|s| &s[v * NM..v * NM + NM]);
595 let pin_row = theta_pin.map(|tp| {
596 ((tp[v] / 1.5).round() as isize).clamp(0, NT as isize - 1) as usize
597 });
598
599 let (mut best_score, mut best_l) = (f64::NEG_INFINITY, 0usize);
600 let (lo, hi) = match pin_row {
601 Some(r) => (r * NM, r * NM + NM),
602 None => (0, l),
603 };
604 for li in lo..hi {
605 if bad[li] && !all_bad {
606 continue;
607 }
608 let mut s = -m2[li];
609 for k in 0..e {
610 s += sig[k] * flat[li * e + k];
611 }
612 if let Some(se_row) = se_row {
613 s -= 0.5 * se_row[li % NM];
614 }
615 if s > best_score {
616 best_score = s;
617 best_l = li;
618 }
619 }
620 let mut sse = 0.0;
622 for k in 0..e {
623 let d = sig[k] - flat[best_l * e + k];
624 sse += d * d;
625 }
626 let theta_deg = (best_l / NM) as f64 * 1.5;
627 let mwf = MWF_MIN + (best_l % NM) as f64 * 0.005;
628 out.push((v, theta_deg, mwf, sse));
629 }
630 out
631 })
632 .collect();
633
634 let mut out_t = vec![0.0_f64; n];
635 let mut out_m = vec![MWF_REF; n];
636 let mut out_s = vec![f64::INFINITY; n];
637 for bin in per_bin {
638 for (v, t, m, s) in bin {
639 out_t[v] = t;
640 out_m[v] = m;
641 out_s[v] = s;
642 }
643 }
644 (out_t, out_m, out_s)
645 }
646}
647
648fn mono_sse(sig: &[f64], tes: &[f64]) -> f64 {
650 let e = tes.len();
651 let tbar = tes.iter().sum::<f64>() / e as f64;
652 let logs: Vec<f64> = sig.iter().map(|&s| s.max(1e-9).ln()).collect();
653 let den: f64 = tes.iter().map(|&t| (t - tbar) * (t - tbar)).sum();
654 let b: f64 = (0..e).map(|k| logs[k] * (tes[k] - tbar)).sum::<f64>() / den;
655 let a: f64 = logs.iter().sum::<f64>() / e as f64;
656 (0..e)
657 .map(|k| {
658 let pred = (a + b * (tes[k] - tbar)).exp();
659 let d = sig[k] - pred;
660 d * d
661 })
662 .sum()
663}
664
665fn to_echo_major(vm: &[f64], n: usize, ne: usize) -> Vec<f64> {
668 let mut out = vec![0.0_f64; n * ne];
669 for i in 0..n {
670 for e in 0..ne {
671 out[e * n + i] = vm[i * ne + e];
672 }
673 }
674 out
675}
676
677fn gather_normalised(stack: &[f64], idx: &[usize], ne: usize) -> Vec<f64> {
680 let n = stack.len() / ne;
681 let mut out = vec![0.0_f64; idx.len() * ne];
682 for (k, &i) in idx.iter().enumerate() {
683 let first = stack[i].max(1e-9); for e in 0..ne {
685 out[k * ne + e] = stack[e * n + i] / first;
686 }
687 }
688 out
689}
690
691fn volmap(vals: &[f64], idx: &[usize], n: usize, fill: f64) -> Vec<f64> {
693 let mut out = vec![fill; n];
694 for (k, &i) in idx.iter().enumerate() {
695 out[i] = vals[k];
696 }
697 out
698}
699
700#[inline]
706fn reflect_index(mut p: isize, len: usize) -> usize {
707 let l = len as isize;
708 loop {
709 if p < 0 {
710 p = -p - 1;
711 } else if p >= l {
712 p = 2 * l - p - 1;
713 } else {
714 return p as usize;
715 }
716 }
717}
718
719fn smooth_stack(stack: &[f64], dims: (usize, usize, usize), ne: usize, sigma: f64) -> Vec<f64> {
721 let n = dims.0 * dims.1 * dims.2;
722 let mut out = vec![0.0_f64; n * ne];
723 for e in 0..ne {
724 let sm = gaussian_filter_3d(&stack[e * n..e * n + n], dims, sigma);
725 out[e * n..e * n + n].copy_from_slice(&sm);
726 }
727 out
728}
729
730fn gaussian_filter_3d(data: &[f64], dims: (usize, usize, usize), sigma: f64) -> Vec<f64> {
733 if sigma <= 0.0 {
734 return data.to_vec();
735 }
736 let (nx, ny, nz) = dims;
737 let radius = (4.0 * sigma + 0.5) as usize;
738 let mut kernel = vec![0.0_f64; 2 * radius + 1];
739 let mut ksum = 0.0;
740 for (k, w) in kernel.iter_mut().enumerate() {
741 let x = k as f64 - radius as f64;
742 *w = (-0.5 * (x / sigma) * (x / sigma)).exp();
743 ksum += *w;
744 }
745 for w in kernel.iter_mut() {
746 *w /= ksum;
747 }
748
749 let idx = |x: usize, y: usize, z: usize| (z * ny + y) * nx + x;
750 let mut a = data.to_vec();
751 let mut b = vec![0.0_f64; a.len()];
752
753 for z in 0..nz {
755 for y in 0..ny {
756 for x in 0..nx {
757 let mut s = 0.0;
758 for (kk, &w) in kernel.iter().enumerate() {
759 let xi = reflect_index(x as isize + kk as isize - radius as isize, nx);
760 s += w * a[idx(xi, y, z)];
761 }
762 b[idx(x, y, z)] = s;
763 }
764 }
765 }
766 std::mem::swap(&mut a, &mut b);
767 for z in 0..nz {
769 for y in 0..ny {
770 for x in 0..nx {
771 let mut s = 0.0;
772 for (kk, &w) in kernel.iter().enumerate() {
773 let yi = reflect_index(y as isize + kk as isize - radius as isize, ny);
774 s += w * a[idx(x, yi, z)];
775 }
776 b[idx(x, y, z)] = s;
777 }
778 }
779 }
780 std::mem::swap(&mut a, &mut b);
781 for z in 0..nz {
783 for y in 0..ny {
784 for x in 0..nx {
785 let mut s = 0.0;
786 for (kk, &w) in kernel.iter().enumerate() {
787 let zi = reflect_index(z as isize + kk as isize - radius as isize, nz);
788 s += w * a[idx(x, y, zi)];
789 }
790 b[idx(x, y, z)] = s;
791 }
792 }
793 }
794 b
795}
796
797fn median_filter_3(data: &[f64], dims: (usize, usize, usize)) -> Vec<f64> {
799 let (nx, ny, nz) = dims;
800 let idx = |x: usize, y: usize, z: usize| (z * ny + y) * nx + x;
801 let mut out = vec![0.0_f64; data.len()];
802 for z in 0..nz {
803 for y in 0..ny {
804 for x in 0..nx {
805 let mut win = [0.0_f64; 27];
806 let mut c = 0;
807 for dz in -1..=1_isize {
808 let zi = reflect_index(z as isize + dz, nz);
809 for dy in -1..=1_isize {
810 let yi = reflect_index(y as isize + dy, ny);
811 for dx in -1..=1_isize {
812 let xi = reflect_index(x as isize + dx, nx);
813 win[c] = data[idx(xi, yi, zi)];
814 c += 1;
815 }
816 }
817 }
818 win.sort_by(|a, b| a.partial_cmp(b).unwrap());
819 out[idx(x, y, z)] = win[13];
820 }
821 }
822 }
823 out
824}
825
826fn percentile(vals: &[f64], q: f64) -> f64 {
832 if vals.is_empty() {
833 return 0.0;
834 }
835 let mut v = vals.to_vec();
836 v.sort_by(|a, b| a.partial_cmp(b).unwrap());
837 let rank = q / 100.0 * (v.len() as f64 - 1.0);
838 let lo = rank.floor() as usize;
839 let hi = rank.ceil() as usize;
840 if lo == hi {
841 v[lo]
842 } else {
843 let frac = rank - lo as f64;
844 v[lo] * (1.0 - frac) + v[hi] * frac
845 }
846}
847
848fn median(vals: &[f64]) -> f64 {
849 if vals.is_empty() {
850 return 0.0;
851 }
852 let mut v = vals.to_vec();
853 v.sort_by(|a, b| a.partial_cmp(b).unwrap());
854 let m = v.len() / 2;
855 if v.len() % 2 == 1 {
856 v[m]
857 } else {
858 0.5 * (v[m - 1] + v[m])
859 }
860}
861
862#[cfg(test)]
863mod tests {
864 use super::*;
865
866 #[test]
867 fn reflect_index_matches_scipy() {
868 let len = 4;
871 let got: Vec<usize> = (-2..6).map(|p| reflect_index(p, len)).collect();
872 assert_eq!(got, vec![1, 0, 0, 1, 2, 3, 3, 2]);
873 }
874
875 #[test]
876 fn gaussian_preserves_constant() {
877 let dims = (8, 8, 8);
878 let n = 8 * 8 * 8;
879 let data = vec![3.0_f64; n];
880 let out = gaussian_filter_3d(&data, dims, 1.0);
881 for &v in &out {
882 assert!((v - 3.0).abs() < 1e-9, "constant not preserved: {v}");
883 }
884 }
885
886 #[test]
887 fn median_removes_impulse() {
888 let dims = (5, 5, 5);
889 let n = 125;
890 let mut data = vec![1.0_f64; n];
891 let idx = |x: usize, y: usize, z: usize| (z * 5 + y) * 5 + x;
892 data[idx(2, 2, 2)] = 100.0; let out = median_filter_3(&data, dims);
894 assert!(
895 (out[idx(2, 2, 2)] - 1.0).abs() < 1e-9,
896 "impulse survived median"
897 );
898 }
899
900 #[test]
901 fn percentile_and_median_basic() {
902 let v = [1.0, 2.0, 3.0, 4.0];
903 assert!((percentile(&v, 0.0) - 1.0).abs() < 1e-12);
904 assert!((percentile(&v, 100.0) - 4.0).abs() < 1e-12);
905 assert!((median(&v) - 2.5).abs() < 1e-12);
906 let v2 = [1.0, 2.0, 3.0];
907 assert!((median(&v2) - 2.0).abs() < 1e-12);
908 }
909
910 #[test]
913 fn fitter_recovers_planted_theta_mwf() {
914 let tes = [0.004, 0.012, 0.020, 0.028];
915 let b0 = 7.0;
916 let fitter = AnchoredFitter::new(&tes, b0, &[]);
917
918 let theta_deg = 30.0_f64;
919 let mwf = 0.10_f64;
920 let theta = theta_deg.to_radians();
921 let meso = 8.0;
923 let h = hc_wm_r2prime(theta, mwf, &tes, b0);
924 let rho = h + meso;
925 let first = hc_wm_signal_mag(tes[0], theta, b0, mwf, meso);
927 let sig: Vec<f64> = tes
928 .iter()
929 .map(|&t| hc_wm_signal_mag(t, theta, b0, mwf, meso) / first)
930 .collect();
931
932 let (t, m, _s) = fitter.fit(&sig, &[rho], None, None, 0.25);
933 assert!((m[0] - mwf).abs() <= 0.02, "mwf {} vs {}", m[0], mwf);
937 assert!(
938 (t[0] - theta_deg).abs() <= 12.0,
939 "theta {} vs {}",
940 t[0],
941 theta_deg
942 );
943 }
944 type SynthPhantom = (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<u8>);
946
947
948 fn synth_phantom(
954 dims: (usize, usize, usize),
955 wm_x_max: usize,
956 b0: f64,
957 tes: &[f64],
958 se_tes: &[f64],
959 ) -> SynthPhantom {
960 let (nx, ny, nz) = dims;
961 let n = nx * ny * nz;
962 let ne = tes.len();
963 let se_e = se_tes.len();
964 let dr = 137.0 * b0 / 3.0;
965 let theta = 40.0_f64.to_radians();
966 let mwf = 0.12;
967 let meso = 6.0;
968 let h = hc_wm_r2prime(theta, mwf, tes, b0);
969 let r2star = 25.0;
970
971 let mut chi = vec![0.0_f64; n];
972 let mut r2p = vec![0.0_f64; n];
973 let mut mag = vec![0.0_f64; n * ne];
974 let mut se = vec![0.0_f64; n * se_e];
975 let mask = vec![1u8; n];
976 for k in 0..nz {
977 for j in 0..ny {
978 for i in 0..nx {
979 let idx = (k * ny + j) * nx + i;
980 if i < wm_x_max {
981 chi[idx] = -0.03;
982 r2p[idx] = h + meso;
983 for (e, &t) in tes.iter().enumerate() {
984 mag[idx * ne + e] = hc_wm_signal_mag(t, theta, b0, mwf, meso);
985 }
986 for (e, &t) in se_tes.iter().enumerate() {
987 se[idx * se_e + e] = hc_wm_se_signal(t, mwf);
988 }
989 } else {
990 chi[idx] = 0.05;
991 r2p[idx] = dr * 0.05;
992 for (e, &t) in tes.iter().enumerate() {
993 mag[idx * ne + e] = (-r2star * t).exp();
994 }
995 for (e, &t) in se_tes.iter().enumerate() {
996 se[idx * se_e + e] = (-r2star * t).exp();
997 }
998 }
999 }
1000 }
1001 }
1002 (chi, r2p, mag, se, mask)
1003 }
1004
1005 #[test]
1009 fn hc_chisep_end_to_end_wm_and_gm() {
1010 let tes = [0.004, 0.012, 0.020, 0.028];
1011 let se_tes = [0.01, 0.03, 0.05, 0.07];
1012 let dims = (20, 20, 20);
1013 let n = dims.0 * dims.1 * dims.2;
1014 let (chi, r2p, mag, se, mask) = synth_phantom(dims, 6, 7.0, &tes, &se_tes);
1015 let grid = Grid::new(dims.0, dims.1, dims.2, 1.0, 1.0, 1.0);
1016 let params = HcChisepParams { b0: 7.0, se_echo_times: se_tes.to_vec(), ..Default::default() };
1017
1018 let mut stages = 0;
1019 let (pos, neg, tot) = hc_chisep(
1020 &chi, &r2p, &mag, &tes, Some(&se), &mask, &grid, ¶ms, |i, _| stages = i,
1021 );
1022 assert_eq!(stages, 5, "all pipeline stages ran (beat detected)");
1023
1024 for i in 0..n {
1026 assert!(pos[i] >= 0.0 && neg[i] <= 0.0, "signs at {i}");
1027 assert!((tot[i] - (pos[i] + neg[i])).abs() < 1e-9, "invariant at {i}");
1028 }
1029 assert!(pos.iter().any(|&v| v > 1e-3), "some χ+ present");
1031 assert!(neg.iter().any(|&v| v < -1e-3), "some χ− present (WM)");
1032 }
1033
1034 #[test]
1037 fn hc_chisep_no_beat_falls_back_to_closed_form() {
1038 let tes = [0.004, 0.012, 0.020, 0.028];
1039 let dims = (10, 10, 10);
1040 let n = dims.0 * dims.1 * dims.2;
1041 let (chi, r2p, mag, _se, mask) = synth_phantom(dims, 0, 7.0, &tes, &[]);
1042 let grid = Grid::new(dims.0, dims.1, dims.2, 1.0, 1.0, 1.0);
1043 let params = HcChisepParams { b0: 7.0, ..Default::default() };
1044
1045 let (pos, neg, _tot) =
1046 hc_chisep(&chi, &r2p, &mag, &tes, None, &mask, &grid, ¶ms, |_, _| {});
1047 for i in 0..n {
1049 assert!((pos[i] - 0.05).abs() < 1e-6, "closed-form χ+ at {i}: {}", pos[i]);
1050 assert!(neg[i].abs() < 1e-9, "closed-form χ− at {i}: {}", neg[i]);
1051 }
1052 }
1053
1054 #[test]
1056 fn hc_chisep_empty_mask_returns_zero() {
1057 let tes = [0.004, 0.012, 0.020, 0.028];
1058 let dims = (6, 6, 6);
1059 let n = dims.0 * dims.1 * dims.2;
1060 let (chi, r2p, mag, _se, _m) = synth_phantom(dims, 0, 7.0, &tes, &[]);
1061 let mask = vec![0u8; n];
1062 let grid = Grid::new(dims.0, dims.1, dims.2, 1.0, 1.0, 1.0);
1063 let params = HcChisepParams { b0: 7.0, ..Default::default() };
1064
1065 let (pos, neg, tot) =
1066 hc_chisep(&chi, &r2p, &mag, &tes, None, &mask, &grid, ¶ms, |_, _| {});
1067 assert!(pos.iter().all(|&v| v == 0.0), "χ+ all zero");
1068 assert!(neg.iter().all(|&v| v == 0.0), "χ− all zero");
1069 assert!(tot.iter().all(|&v| v == 0.0), "χ_total all zero");
1070 }
1071}