Skip to main content

qsm_core/utils/
multi_echo.rs

1//! Multi-echo field mapping utilities
2//!
3//! Provides phase offset removal, weighted B0 estimation, bipolar correction,
4//! and multi-echo linear fit for multi-echo GRE data.
5//!
6//! Phase offset removal is based on the HIP (Hermitian Inner Product) technique
7//! from ASPIRE/MCPC-3D-S:
8//! Eckstein, K., et al. (2018). "Computationally Efficient Combination of
9//! Multi-channel Phase Data From Multi-echo Acquisitions (ASPIRE)."
10//! Magnetic Resonance in Medicine, 79:2996-3006. https://doi.org/10.1002/mrm.26963
11
12/// Parameters for phase offset removal.
13#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
14#[derive(Clone, Debug)]
15pub struct PhaseOffsetParams {
16    /// Gaussian smoothing sigma in voxels [x, y, z] for phase offset estimation
17    pub sigma: [f64; 3],
18}
19
20impl Default for PhaseOffsetParams {
21    fn default() -> Self {
22        Self {
23            sigma: [4.0, 4.0, 4.0],
24        }
25    }
26}
27
28/// Backward-compatible alias.
29pub type Mcpc3dsParams = PhaseOffsetParams;
30
31/// Parameters for multi-echo linear fit.
32#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
33#[derive(Clone, Debug)]
34pub struct LinearFitParams {
35    /// Estimate and remove constant phase offset
36    pub estimate_offset: bool,
37    /// Percentile threshold for reliability-based voxel exclusion (degrees)
38    pub reliability_threshold_percentile: f64,
39}
40
41impl Default for LinearFitParams {
42    fn default() -> Self {
43        Self {
44            estimate_offset: true,
45            reliability_threshold_percentile: 90.0,
46        }
47    }
48}
49
50use std::f64::consts::PI;
51use crate::Grid;
52use crate::unwrap::romeo::{unwrap_romeo, RomeoParams};
53use crate::unwrap::laplacian::laplacian_unwrap;
54use crate::unwrap::UnwrapMethod;
55
56const TWO_PI: f64 = 2.0 * PI;
57
58/// Wrap angle to [-π, π]
59#[inline]
60fn wrap_to_pi(angle: f64) -> f64 {
61    let mut a = angle % TWO_PI;
62    if a > PI {
63        a -= TWO_PI;
64    } else if a < -PI {
65        a += TWO_PI;
66    }
67    a
68}
69
70/// Index into 3D array (Fortran/column-major order)
71#[inline(always)]
72fn idx3d(i: usize, j: usize, k: usize, nx: usize, ny: usize) -> usize {
73    i + j * nx + k * nx * ny
74}
75
76/// B0 weighting types matching MriResearchTools.jl
77#[derive(Clone, Copy, Debug, PartialEq)]
78pub enum B0WeightType {
79    /// mag * TE - optimal for phase SNR (default)
80    PhaseSNR,
81    /// mag² * TE² - based on phase variance
82    PhaseVar,
83    /// Uniform weights
84    Average,
85    /// TE only
86    TEs,
87    /// Magnitude only
88    Mag,
89}
90
91impl B0WeightType {
92    pub fn from_str(s: &str) -> Self {
93        match s.to_lowercase().as_str() {
94            "phase_snr" | "phasesnr" => B0WeightType::PhaseSNR,
95            "phase_var" | "phasevar" => B0WeightType::PhaseVar,
96            "average" | "uniform" => B0WeightType::Average,
97            "tes" | "te" => B0WeightType::TEs,
98            "mag" | "magnitude" => B0WeightType::Mag,
99            _ => B0WeightType::PhaseSNR, // default
100        }
101    }
102}
103
104/// 3D Gaussian smoothing for phase data (handles phase wrapping)
105///
106/// Implements gaussiansmooth3d_phase from MriResearchTools.jl
107/// Uses separable Gaussian filtering with phase-aware averaging
108///
109/// # Arguments
110/// * `phase` - Input phase data (nx * ny * nz)
111/// * `sigma` - Smoothing sigma in voxels [sx, sy, sz]
112/// * `mask` - Binary mask (1 = include, 0 = exclude)
113/// * `grid` - Volume grid (dimensions and voxel sizes)
114///
115/// # Returns
116/// Smoothed phase data
117pub fn gaussian_smooth_3d_phase(
118    phase: &[f64],
119    sigma: [f64; 3],
120    mask: &[u8],
121    grid: &Grid,
122) -> Vec<f64> {
123    let (nx, ny, nz) = grid.dims;
124    let n_total = nx * ny * nz;
125
126    // For phase smoothing, we smooth the complex representation
127    // and extract the angle to handle wrapping correctly
128    let mut real = vec![0.0; n_total];
129    let mut imag = vec![0.0; n_total];
130
131    // Convert phase to complex (unit vectors)
132    for i in 0..n_total {
133        if mask[i] > 0 {
134            real[i] = phase[i].cos();
135            imag[i] = phase[i].sin();
136        }
137    }
138
139    // Apply separable Gaussian smoothing to real and imaginary parts
140    let real_smoothed = gaussian_smooth_3d_separable(&real, sigma, mask, nx, ny, nz);
141    let imag_smoothed = gaussian_smooth_3d_separable(&imag, sigma, mask, nx, ny, nz);
142
143    // Convert back to phase
144    let mut result = vec![0.0; n_total];
145    for i in 0..n_total {
146        if mask[i] > 0 {
147            result[i] = imag_smoothed[i].atan2(real_smoothed[i]);
148        }
149    }
150
151    result
152}
153
154/// Separable 3D Gaussian smoothing
155fn gaussian_smooth_3d_separable(
156    data: &[f64],
157    sigma: [f64; 3],
158    mask: &[u8],
159    nx: usize, ny: usize, nz: usize,
160) -> Vec<f64> {
161    let n_total = nx * ny * nz;
162    let mut result = data.to_vec();
163    let mut temp = vec![0.0; n_total];
164
165    // X direction
166    if sigma[0] > 0.0 {
167        let kernel = make_gaussian_kernel(sigma[0]);
168        let half = kernel.len() / 2;
169
170        for k in 0..nz {
171            for j in 0..ny {
172                for i in 0..nx {
173                    let idx = idx3d(i, j, k, nx, ny);
174                    if mask[idx] == 0 {
175                        temp[idx] = 0.0;
176                        continue;
177                    }
178
179                    let mut sum = 0.0;
180                    let mut weight_sum = 0.0;
181
182                    for (ki, &kv) in kernel.iter().enumerate() {
183                        let ii = i as isize + ki as isize - half as isize;
184                        if ii >= 0 && ii < nx as isize {
185                            let nidx = idx3d(ii as usize, j, k, nx, ny);
186                            if mask[nidx] > 0 {
187                                sum += result[nidx] * kv;
188                                weight_sum += kv;
189                            }
190                        }
191                    }
192
193                    temp[idx] = if weight_sum > 0.0 { sum / weight_sum } else { 0.0 };
194                }
195            }
196        }
197        std::mem::swap(&mut result, &mut temp);
198    }
199
200    // Y direction
201    if sigma[1] > 0.0 {
202        let kernel = make_gaussian_kernel(sigma[1]);
203        let half = kernel.len() / 2;
204
205        for k in 0..nz {
206            for j in 0..ny {
207                for i in 0..nx {
208                    let idx = idx3d(i, j, k, nx, ny);
209                    if mask[idx] == 0 {
210                        temp[idx] = 0.0;
211                        continue;
212                    }
213
214                    let mut sum = 0.0;
215                    let mut weight_sum = 0.0;
216
217                    for (ki, &kv) in kernel.iter().enumerate() {
218                        let jj = j as isize + ki as isize - half as isize;
219                        if jj >= 0 && jj < ny as isize {
220                            let nidx = idx3d(i, jj as usize, k, nx, ny);
221                            if mask[nidx] > 0 {
222                                sum += result[nidx] * kv;
223                                weight_sum += kv;
224                            }
225                        }
226                    }
227
228                    temp[idx] = if weight_sum > 0.0 { sum / weight_sum } else { 0.0 };
229                }
230            }
231        }
232        std::mem::swap(&mut result, &mut temp);
233    }
234
235    // Z direction
236    if sigma[2] > 0.0 {
237        let kernel = make_gaussian_kernel(sigma[2]);
238        let half = kernel.len() / 2;
239
240        for k in 0..nz {
241            for j in 0..ny {
242                for i in 0..nx {
243                    let idx = idx3d(i, j, k, nx, ny);
244                    if mask[idx] == 0 {
245                        temp[idx] = 0.0;
246                        continue;
247                    }
248
249                    let mut sum = 0.0;
250                    let mut weight_sum = 0.0;
251
252                    for (ki, &kv) in kernel.iter().enumerate() {
253                        let kk = k as isize + ki as isize - half as isize;
254                        if kk >= 0 && kk < nz as isize {
255                            let nidx = idx3d(i, j, kk as usize, nx, ny);
256                            if mask[nidx] > 0 {
257                                sum += result[nidx] * kv;
258                                weight_sum += kv;
259                            }
260                        }
261                    }
262
263                    temp[idx] = if weight_sum > 0.0 { sum / weight_sum } else { 0.0 };
264                }
265            }
266        }
267        std::mem::swap(&mut result, &mut temp);
268    }
269
270    result
271}
272
273/// Create 1D Gaussian kernel
274fn make_gaussian_kernel(sigma: f64) -> Vec<f64> {
275    let radius = (3.0 * sigma).ceil() as usize;
276    let size = 2 * radius + 1;
277    let mut kernel = vec![0.0; size];
278
279    let two_sigma_sq = 2.0 * sigma * sigma;
280    let mut sum = 0.0;
281
282    for i in 0..size {
283        let x = i as f64 - radius as f64;
284        kernel[i] = (-x * x / two_sigma_sq).exp();
285        sum += kernel[i];
286    }
287
288    // Normalize
289    for k in kernel.iter_mut() {
290        *k /= sum;
291    }
292
293    kernel
294}
295
296/// Compute Hermitian Inner Product (HIP) between two echoes
297///
298/// HIP = conj(echo1) * echo2 = mag1 * mag2 * exp(i * (phase2 - phase1))
299///
300/// Returns (hip_phase, hip_mag) where:
301/// - hip_phase = phase2 - phase1 (wrapped to [-π, π])
302/// - hip_mag = mag1 * mag2
303pub fn hermitian_inner_product(
304    phase1: &[f64], mag1: &[f64],
305    phase2: &[f64], mag2: &[f64],
306    mask: &[u8],
307    n: usize,
308) -> (Vec<f64>, Vec<f64>) {
309    let mut hip_phase = vec![0.0; n];
310    let mut hip_mag = vec![0.0; n];
311
312    for i in 0..n {
313        if mask[i] > 0 {
314            hip_phase[i] = wrap_to_pi(phase2[i] - phase1[i]);
315            hip_mag[i] = mag1[i] * mag2[i];
316        }
317    }
318
319    (hip_phase, hip_mag)
320}
321
322/// MCPC-3D-S phase offset estimation for single-coil multi-echo data
323///
324/// Implements the MCPC-3D-S algorithm from MriResearchTools.jl for single-coil data.
325/// This estimates and removes the phase offset (φ₀) from each echo.
326///
327/// # Arguments
328/// * `phases` - Phase data for all echoes, shape [n_echoes][nx*ny*nz]
329/// * `mags` - Magnitude data for all echoes, shape [n_echoes][nx*ny*nz]
330/// * `tes` - Echo times in ms
331/// * `mask` - Binary mask
332/// * `sigma` - Smoothing sigma in voxels [sx, sy, sz], default [10, 10, 5]
333/// * `echoes` - Which echoes to use for HIP calculation, default [0, 1] (first two)
334/// * `nx`, `ny`, `nz` - Dimensions
335///
336/// # Returns
337/// (corrected_phases, phase_offset) where:
338/// - corrected_phases: phases with offset removed
339/// - phase_offset: estimated phase offset
340/// Remove phase offset from multi-echo phase data using HIP (Hermitian Inner Product).
341///
342/// Estimates the spatially-varying phase offset from the phase difference between two
343/// echoes, smooths it with a Gaussian filter, and subtracts it from all echoes.
344///
345/// # Arguments
346/// * `phases` - Wrapped phase per echo (n_echoes arrays of nx*ny*nz)
347/// * `mags` - Magnitude per echo
348/// * `tes` - Echo times (any consistent unit)
349/// * `mask` - Binary mask
350/// * `sigma` - Gaussian smoothing sigma in voxels [x, y, z]
351/// * `echoes` - Which two echoes to use for HIP [e1, e2]
352/// * `unwrap_method` - Method to unwrap the HIP phase (Romeo or Laplacian)
353/// * `grid` - Volume grid (dimensions and voxel sizes)
354///
355/// # Returns
356/// `(corrected_phases, phase_offset)` — offset-corrected phases and the estimated offset
357pub fn phase_offset_removal(
358    phases: &[impl AsRef<[f64]>],
359    mags: &[impl AsRef<[f64]>],
360    tes: &[f64],
361    mask: &[u8],
362    sigma: [f64; 3],
363    echoes: [usize; 2],
364    unwrap_method: UnwrapMethod,
365    grid: &Grid,
366) -> (Vec<Vec<f64>>, Vec<f64>) {
367    let (nx, ny, nz) = grid.dims;
368    let n_echoes = phases.len();
369    let n_total = nx * ny * nz;
370
371    let e1 = echoes[0];
372    let e2 = echoes[1];
373
374    // ΔTE = TEs[echo2] - TEs[echo1]
375    let delta_te = tes[e2] - tes[e1];
376
377    // Compute HIP between the two echoes
378    // HIP = conj(echo1) * echo2, so hip_phase = phase2 - phase1
379    let (hip_phase, hip_mag) = hermitian_inner_product(
380        phases[e1].as_ref(), mags[e1].as_ref(),
381        phases[e2].as_ref(), mags[e2].as_ref(),
382        mask, n_total
383    );
384
385    // Unwrap HIP phase
386    let unwrapped_hip = match unwrap_method {
387        UnwrapMethod::Romeo => {
388            let weight: Vec<f64> = hip_mag.iter().map(|&x| x.sqrt()).collect();
389            unwrap_romeo(&hip_phase, &weight, None, 0.0, 0.0, mask, &RomeoParams::default(), grid)
390        }
391        UnwrapMethod::Laplacian => {
392            // The HIP is a phase *difference*; removing its harmonic component would
393            // discard part of the offset this function exists to estimate.
394            laplacian_unwrap(&hip_phase, mask, grid)
395        }
396    };
397    drop(hip_phase);
398    drop(hip_mag);
399
400    // Phase evolution at TE1: (TE1 / ΔTE) * unwrapped_hip
401    // This gives the phase that would have evolved from TE=0 to TE=TE1
402    let scale = tes[e1] / delta_te;
403    let mut phase_offset = vec![0.0; n_total];
404    for i in 0..n_total {
405        if mask[i] > 0 {
406            // Phase offset = phase[echo1] - phase_evolution
407            // phase_evolution = scale * unwrapped_hip
408            // IMPORTANT: Do NOT wrap here! Julia line 49 does raw subtraction
409            phase_offset[i] = phases[e1].as_ref()[i] - scale * unwrapped_hip[i];
410        }
411    }
412    drop(unwrapped_hip); // Free ~82 MB early
413
414    // Smooth the phase offset (handles wrapping via complex representation)
415    // Julia line 51: po[:,:,:,icha] .= gaussiansmooth3d_phase(view(po,:,:,:,icha), sigma; mask)
416    let phase_offset_smoothed = gaussian_smooth_3d_phase(&phase_offset, sigma, mask, grid);
417    drop(phase_offset); // Free ~82 MB early
418
419    // Remove phase offset from all echoes
420    // Julia combinewithPO does: exp.(1im .* (phase - po)) then angle()
421    // This is equivalent to wrap_to_pi(phase - po)
422    let mut corrected_phases = Vec::with_capacity(n_echoes);
423    for e in 0..n_echoes {
424        let mut corrected = vec![0.0; n_total];
425        for i in 0..n_total {
426            if mask[i] > 0 {
427                corrected[i] = wrap_to_pi(phases[e].as_ref()[i] - phase_offset_smoothed[i]);
428            }
429        }
430        corrected_phases.push(corrected);
431    }
432
433    (corrected_phases, phase_offset_smoothed)
434}
435
436/// Box-filter widths approximating a Gaussian of `sigma` (voxels) with `n` passes — the
437/// `getboxsizes` of MriResearchTools.jl (Kovesi's "fast almost-Gaussian" construction).
438/// Returns all-ones (i.e. no smoothing) when `sigma` is not positive.
439pub fn gaussian_box_sizes(sigma: f64, n: usize) -> Vec<usize> {
440    fn round_half_even(x: f64) -> f64 {
441        let r = x.round();
442        if (x - x.trunc()).abs() == 0.5 && r % 2.0 != 0.0 { r - x.signum() } else { r }
443    }
444    if !(sigma > 0.0) || n == 0 {
445        return vec![1; n];
446    }
447    let nf = n as f64;
448    let w_ideal = (12.0 * sigma * sigma / nf + 1.0).sqrt();
449    let wl = round_half_even(w_ideal - (w_ideal + 1.0) % 2.0); // next lower odd integer
450    let wu = wl + 2.0;
451    let m_ideal = (12.0 * sigma * sigma - nf * wl * wl - 4.0 * nf * wl - 3.0 * nf) / (-4.0 * wl - 4.0);
452    let m = round_half_even(m_ideal);
453    (1..=n).map(|i| if (i as f64) <= m { wl as usize } else { wu as usize }).collect()
454}
455
456/// Running-average line filter that treats `NaN` as "no data": the box only starts once it
457/// holds `boxsize` valid samples, and when it runs into `NaN` it extrapolates linearly for up
458/// to `boxsize/2` samples ("fill" mode) before waiting for data again. Positions the filter
459/// never reaches keep their input value. Port of `nanboxfilterline!` (MriResearchTools.jl);
460/// `orig` is scratch space that is resized as needed.
461pub fn nan_box_filter_line(line: &mut [f64], boxsize: usize, orig: &mut Vec<f64>) {
462    #[derive(PartialEq, Clone, Copy)]
463    enum Mode { Nan, Normal, Fill }
464    let n = line.len();
465    let r = boxsize / 2;
466    if n == 0 || boxsize < 3 {
467        return;
468    }
469    let maxfills = r;
470    orig.clear();
471    orig.resize(n + boxsize - 1, f64::NAN);
472    orig[r..r + n].copy_from_slice(line);
473
474    let mut lsum: f64 = orig[r..2 * r].iter().sum();
475    if lsum.is_nan() {
476        lsum = 0.0;
477    }
478    let (mut nfills, mut nvalids) = (0usize, 0usize);
479    let mut mode = Mode::Nan;
480    let bs = boxsize as f64;
481
482    // `i` is the 1-based output index, as in the reference; orig[k] here is orig[k+1] there.
483    for i in 1..=n {
484        if lsum.is_nan() {
485            break;
486        }
487        let lead = orig[i - 1 + 2 * r];
488        match mode {
489            Mode::Normal => {
490                if lead.is_nan() {
491                    mode = Mode::Fill;
492                }
493            }
494            Mode::Nan => {
495                if lead.is_nan() { nvalids = 0; } else { nvalids += 1; }
496                if nvalids == boxsize {
497                    mode = Mode::Normal;
498                    lsum = orig[i - 1..i + 2 * r].iter().sum();
499                    line[i - 1] = lsum / bs;
500                    continue;
501                }
502            }
503            Mode::Fill => {
504                if lead.is_nan() {
505                    nfills += 1;
506                    if nfills > maxfills {
507                        mode = Mode::Nan;
508                        nfills = 0;
509                        lsum = 0.0;
510                        nvalids = 0;
511                    }
512                } else {
513                    mode = Mode::Normal;
514                    nfills = 0;
515                }
516            }
517        }
518        match mode {
519            Mode::Normal => {
520                let trailing = if i >= 2 { orig[i - 2] } else { 0.0 };
521                lsum += orig[i - 1 + 2 * r] - trailing;
522                line[i - 1] = lsum / bs;
523            }
524            Mode::Fill => {
525                let trailing = if i >= 2 { orig[i - 2] } else { 0.0 };
526                lsum -= trailing;
527                line[i - 1] = (lsum - orig[i - 1]) / (bs - 2.0);
528                let prev = if i - 1 >= r { line[i - 1 - r] } else { line[i - 1] };
529                let extrapolated = 2.0 * line[i - 1] - prev;
530                orig[i - 1 + 2 * r] = extrapolated;
531                if i + r < n {
532                    line[i - 1 + r] = extrapolated;
533                }
534                lsum += extrapolated;
535            }
536            Mode::Nan => {}
537        }
538    }
539}
540
541/// Masked 3D smoothing by repeated NaN-aware box filtering — the `mask` branch of
542/// `gaussiansmooth3d!` in MriResearchTools.jl (4 passes per axis, alternating direction on
543/// even passes, box widths from [`gaussian_box_sizes`]). Voxels outside `mask` become `NaN`
544/// before filtering and are only given values where the filter extrapolates across the
545/// boundary; the result is written in place.
546pub fn nan_box_smooth_3d(image: &mut [f64], sigma: [f64; 3], mask: &[u8], grid: &Grid) {
547    let (nx, ny, nz) = grid.dims;
548    let dims = [nx, ny, nz];
549    let n_total = nx * ny * nz;
550    assert_eq!(image.len(), n_total);
551    assert_eq!(mask.len(), n_total);
552    const NBOX: usize = 4;
553    for i in 0..n_total {
554        if mask[i] == 0 {
555            image[i] = f64::NAN;
556        }
557    }
558    let mut boxsizes: Vec<Vec<usize>> = sigma.iter().map(|&s| gaussian_box_sizes(s, NBOX)).collect();
559    // checkboxsizes!: odd widths, no wider than half the axis
560    for d in 0..3 {
561        for b in boxsizes[d].iter_mut() {
562            if *b % 2 == 0 { *b += 1; }
563            if *b as f64 > dims[d] as f64 / 2.0 {
564                let mut v = dims[d] / 2;
565                if v % 2 == 0 { v += 1; }
566                *b = v;
567            }
568        }
569    }
570    let mut line: Vec<f64> = Vec::new();
571    let mut scratch: Vec<f64> = Vec::new();
572    for ibox in 0..NBOX {
573        for d in 0..3 {
574            let bsize = boxsizes[d][ibox];
575            let len = dims[d];
576            if len == 1 || bsize < 3 {
577                continue;
578            }
579            let reverse = ibox % 2 == 1;
580            let (stride, n_lines_a, stride_a, n_lines_b, stride_b) = match d {
581                0 => (1, ny, nx, nz, nx * ny),
582                1 => (nx, nx, 1, nz, nx * ny),
583                _ => (nx * ny, nx, 1, ny, nx),
584            };
585            line.resize(len, 0.0);
586            for b in 0..n_lines_b {
587                for a in 0..n_lines_a {
588                    let base = a * stride_a + b * stride_b;
589                    for k in 0..len {
590                        let kk = if reverse { len - 1 - k } else { k };
591                        line[k] = image[base + kk * stride];
592                    }
593                    nan_box_filter_line(&mut line, bsize, &mut scratch);
594                    for k in 0..len {
595                        let kk = if reverse { len - 1 - k } else { k };
596                        image[base + kk * stride] = line[k];
597                    }
598                }
599            }
600        }
601    }
602}
603
604/// Masked phase smoothing via the complex representation, using [`nan_box_smooth_3d`] —
605/// `gaussiansmooth3d_phase(phase, sigma; mask)` of MriResearchTools.jl. Returns `NaN` where
606/// the smoothed complex value is undefined (far outside the mask).
607pub fn nan_box_smooth_3d_phase(phase: &[f64], sigma: [f64; 3], mask: &[u8], grid: &Grid) -> Vec<f64> {
608    let n = phase.len();
609    let mut re: Vec<f64> = phase.iter().map(|p| p.cos()).collect();
610    let mut im: Vec<f64> = phase.iter().map(|p| p.sin()).collect();
611    nan_box_smooth_3d(&mut re, sigma, mask, grid);
612    nan_box_smooth_3d(&mut im, sigma, mask, grid);
613    (0..n).map(|i| im[i].atan2(re[i])).collect()
614}
615
616/// Result of MCPC-3D-S multi-coil combination ([`mcpc3ds_combine`]).
617#[derive(Debug, Clone)]
618pub struct CoilCombinationResult {
619    /// Combined phase per echo, wrapped to [-π, π]
620    pub phases: Vec<Vec<f64>>,
621    /// Combined magnitude per echo: `sqrt(|Σ_c |S_c|² · exp(i(φ_c − po_c))|)`
622    pub magnitudes: Vec<Vec<f64>>,
623    /// Mask used for the phase-offset estimation (robust threshold on √|HIP|)
624    pub mask: Vec<u8>,
625}
626
627/// MCPC-3D-S multi-coil phase combination (Eckstein et al., MRM 2018), as in
628/// `MriResearchTools.jl`'s `mcpc3ds` for 5D (multi-echo, uncombined) input.
629///
630/// Each receive coil carries its own TE-independent phase offset, so uncombined channels
631/// cannot simply be summed. The algorithm estimates the offsets from the coil-summed
632/// Hermitian inner product (HIP) of two echoes and combines the channels coherently:
633///
634/// 1. `HIP = Σ_c |S_{1,c}| |S_{2,c}| exp(i(φ_{2,c} − φ_{1,c}))` — the coil offsets cancel in
635///    the inter-echo phase difference, so the HIP phase is pure field evolution over ΔTE.
636/// 2. The HIP phase is unwrapped once (weighted by √|HIP|, on a robust mask of that weight).
637/// 3. Per coil, `po_c = φ_{1,c} − (TE₁/ΔTE)·unwrapped_HIP`, smoothed in the complex domain
638///    with the same masked box-filter Gaussian approximation as MriResearchTools
639///    ([`nan_box_smooth_3d_phase`]; `sigma` in voxels, the reference default is [10, 10, 5]).
640/// 4. Per echo, `S_e = Σ_c |S_{e,c}|² exp(i(φ_{e,c} − po_c))`; output phase `arg(S_e)` and
641///    magnitude `sqrt(|S_e|)`.
642///
643/// With a single coil this reduces to [`phase_offset_removal`] (with the robust HIP mask).
644///
645/// # Arguments
646/// * `phases` - Wrapped phase, coil-major: `phases[coil][echo]` (each `nx*ny*nz`)
647/// * `mags` - Magnitude, same layout as `phases`
648/// * `tes` - Echo times (any consistent unit; only the ratio `TE₁/ΔTE` is used)
649/// * `sigma` - Gaussian smoothing sigma in voxels [x, y, z] for the phase offsets
650/// * `echoes` - Which two echoes form the HIP, default `[0, 1]`
651/// * `unwrap_method` - How to unwrap the HIP phase (ROMEO or Laplacian)
652/// * `grid` - Volume grid
653///
654/// # Panics
655/// If there are no coils, the coils do not all have the same number of echoes, magnitude
656/// and phase layouts differ, or `echoes` is out of range.
657pub fn mcpc3ds_combine<P: AsRef<[f64]>, M: AsRef<[f64]>>(
658    phases: &[Vec<P>],
659    mags: &[Vec<M>],
660    tes: &[f64],
661    sigma: [f64; 3],
662    echoes: [usize; 2],
663    unwrap_method: UnwrapMethod,
664    grid: &Grid,
665) -> CoilCombinationResult {
666    let (nx, ny, nz) = grid.dims;
667    let n = nx * ny * nz;
668    let n_coils = phases.len();
669    assert!(n_coils > 0, "mcpc3ds_combine: no coils");
670    assert_eq!(mags.len(), n_coils, "mcpc3ds_combine: magnitude/phase coil count differ");
671    let n_echoes = phases[0].len();
672    assert!(n_echoes >= 2, "mcpc3ds_combine: at least two echoes are required");
673    assert_eq!(tes.len(), n_echoes, "mcpc3ds_combine: echo time count must match echoes");
674    let [e1, e2] = echoes;
675    assert!(e1 < n_echoes && e2 < n_echoes && e1 != e2, "mcpc3ds_combine: HIP echoes out of range");
676    for c in 0..n_coils {
677        assert_eq!(phases[c].len(), n_echoes, "mcpc3ds_combine: coil {} has a different echo count", c);
678        assert_eq!(mags[c].len(), n_echoes, "mcpc3ds_combine: coil {} magnitude echo count differs", c);
679        for e in 0..n_echoes {
680            assert_eq!(phases[c][e].as_ref().len(), n, "mcpc3ds_combine: coil {} echo {} phase size", c, e);
681            assert_eq!(mags[c][e].as_ref().len(), n, "mcpc3ds_combine: coil {} echo {} magnitude size", c, e);
682        }
683    }
684
685    // 1. Coil-summed Hermitian inner product between the two HIP echoes.
686    let mut hip_re = vec![0.0f64; n];
687    let mut hip_im = vec![0.0f64; n];
688    for c in 0..n_coils {
689        let (p1, p2) = (phases[c][e1].as_ref(), phases[c][e2].as_ref());
690        let (m1, m2) = (mags[c][e1].as_ref(), mags[c][e2].as_ref());
691        for i in 0..n {
692            let a = m1[i] * m2[i];
693            let d = p2[i] - p1[i];
694            hip_re[i] += a * d.cos();
695            hip_im[i] += a * d.sin();
696        }
697    }
698    let hip_phase: Vec<f64> = (0..n).map(|i| hip_im[i].atan2(hip_re[i])).collect();
699    // weight = sqrt(|HIP|)
700    let weight: Vec<f64> = (0..n).map(|i| (hip_re[i] * hip_re[i] + hip_im[i] * hip_im[i]).sqrt().sqrt()).collect();
701    drop(hip_re);
702    drop(hip_im);
703
704    // 2. Robust mask on the HIP weight, then unwrap the HIP phase once.
705    let mask = crate::utils::bias_correction::robust_mask(&weight, grid);
706    let unwrapped_hip = match unwrap_method {
707        UnwrapMethod::Romeo => unwrap_romeo(&hip_phase, &weight, None, 0.0, 0.0, &mask, &RomeoParams::default(), grid),
708        UnwrapMethod::Laplacian => laplacian_unwrap(&hip_phase, &mask, grid),
709    };
710    drop(hip_phase);
711    drop(weight);
712
713    // 3./4. Per-coil offset → smooth → accumulate the magnitude²-weighted complex sum per echo.
714    let scale = tes[e1] / (tes[e2] - tes[e1]);
715    let mut acc_re: Vec<Vec<f64>> = (0..n_echoes).map(|_| vec![0.0f64; n]).collect();
716    let mut acc_im: Vec<Vec<f64>> = (0..n_echoes).map(|_| vec![0.0f64; n]).collect();
717    let mut po = vec![0.0f64; n];
718    for c in 0..n_coils {
719        let p1 = phases[c][e1].as_ref();
720        for i in 0..n {
721            po[i] = if mask[i] > 0 { p1[i] - scale * unwrapped_hip[i] } else { 0.0 };
722        }
723        // Smooth as MriResearchTools does (NaN-aware box passes); NaN far outside the mask → 0.
724        let mut po_s = nan_box_smooth_3d_phase(&po, sigma, &mask, grid);
725        for v in po_s.iter_mut() {
726            if !v.is_finite() { *v = 0.0; }
727        }
728        for e in 0..n_echoes {
729            let (p, m) = (phases[c][e].as_ref(), mags[c][e].as_ref());
730            let (re, im) = (&mut acc_re[e], &mut acc_im[e]);
731            for i in 0..n {
732                let w = m[i] * m[i];
733                let ang = p[i] - po_s[i];
734                re[i] += w * ang.cos();
735                im[i] += w * ang.sin();
736            }
737        }
738    }
739    drop(po);
740    drop(unwrapped_hip);
741
742    let mut phases_out = Vec::with_capacity(n_echoes);
743    let mut mags_out = Vec::with_capacity(n_echoes);
744    for e in 0..n_echoes {
745        let (re, im) = (&acc_re[e], &acc_im[e]);
746        phases_out.push((0..n).map(|i| im[i].atan2(re[i])).collect());
747        mags_out.push((0..n).map(|i| (re[i] * re[i] + im[i] * im[i]).sqrt().sqrt()).collect());
748    }
749
750    CoilCombinationResult { phases: phases_out, magnitudes: mags_out, mask }
751}
752
753/// Calculate B0 field from unwrapped phase using weighted averaging
754///
755/// Implements calculateB0_unwrapped from MriResearchTools.jl
756///
757/// Formula: B0 = (1000 / 2π) * Σ(phase / TE * weight) / Σ(weight)
758///
759/// # Arguments
760/// * `unwrapped_phases` - Unwrapped phase for each echo [n_echoes][nx*ny*nz]
761/// * `mags` - Magnitude for each echo (used for some weighting types)
762/// * `tes` - Echo times in seconds
763/// * `mask` - Binary mask
764/// * `weight_type` - Type of weighting to use
765/// * `grid` - Volume grid (dimensions and voxel sizes)
766///
767/// # Returns
768/// B0 field in Hz
769pub fn calculate_b0_weighted(
770    unwrapped_phases: &[impl AsRef<[f64]>],
771    mags: &[impl AsRef<[f64]>],
772    tes: &[f64],
773    mask: &[u8],
774    weight_type: B0WeightType,
775    grid: &Grid,
776) -> Vec<f64> {
777    let n_total = grid.n_total();
778    let n_echoes = unwrapped_phases.len();
779    let mut b0 = vec![0.0; n_total];
780
781    // Compute inline to avoid allocating per-echo weight arrays
782
783    // B0 = (1 / 2π) * Σ(phase / TE * weight) / Σ(weight)
784    let scale = 1.0 / TWO_PI;
785
786    for i in 0..n_total {
787        if mask[i] == 0 {
788            continue;
789        }
790
791        let mut weighted_sum = 0.0;
792        let mut weight_sum = 0.0;
793
794        for e in 0..n_echoes {
795            let te = tes[e];
796            let mag_val = mags[e].as_ref()[i];
797            let phase_over_te = unwrapped_phases[e].as_ref()[i] / te;
798
799            let w = match weight_type {
800                B0WeightType::PhaseSNR => mag_val * te,
801                B0WeightType::PhaseVar => mag_val * mag_val * te * te,
802                B0WeightType::Average => 1.0,
803                B0WeightType::TEs => te,
804                B0WeightType::Mag => mag_val,
805            };
806
807            weighted_sum += phase_over_te * w;
808            weight_sum += w;
809        }
810
811        if weight_sum > 1e-10 {
812            b0[i] = scale * weighted_sum / weight_sum;
813        }
814    }
815
816    b0
817}
818
819// =========================================================================
820// Bipolar Correction
821// =========================================================================
822
823/// Bipolar gradient correction for multi-echo phase data.
824///
825/// Removes linear phase artefact caused by bipolar readout gradients.
826/// Requires at least 3 echoes.
827///
828/// Reference: Eckstein PhD thesis (2021), Section 3.1.3
829/// https://doi.org/10.34726/hss.2021.43447
830///
831/// # Arguments
832/// * `phases` - Mutable phase data per echo (modified in-place)
833/// * `mags` - Magnitude data per echo
834/// * `tes` - Echo times (any consistent unit; only ratios are used)
835/// * `mask` - Binary mask
836/// * `sigma` - Smoothing sigma for artefact estimation
837/// * `grid` - Volume grid (dimensions and voxel sizes)
838pub fn bipolar_correction<P: AsMut<[f64]> + AsRef<[f64]>>(
839    phases: &mut [P],
840    mags: &[impl AsRef<[f64]>],
841    tes: &[f64],
842    mask: &[u8],
843    sigma: [f64; 3],
844    grid: &Grid,
845) {
846    let (nx, ny, nz) = grid.dims;
847    let n_echoes = phases.len();
848    if n_echoes < 3 {
849        return; // Need at least 3 echoes
850    }
851
852    let n_total = nx * ny * nz;
853    let delta_te = tes[1] - tes[0];
854    let m = tes[0] / delta_te;
855    let k = (tes[0] + tes[2]) / tes[1];
856
857    // Step 1: Compute artefact phase = φ1 + φ3 - k*φ2
858    // If k is near-integer, unwrap φ2 first to avoid wrap issues
859    let phi2 = if (k - k.round()).abs() < 0.01 {
860        // k is integer-ish: unwrap φ2 with ROMEO
861        let mag2 = if mags.is_empty() { &[] as &[f64] } else { mags[1].as_ref() };
862        unwrap_romeo(
863            phases[1].as_ref(), mag2, None, 0.0, 0.0,
864            mask, &RomeoParams::default(), grid,
865        )
866    } else {
867        phases[1].as_ref().to_vec()
868    };
869
870    let mut artefact = vec![0.0; n_total];
871    for i in 0..n_total {
872        if mask[i] > 0 {
873            artefact[i] = wrap_to_pi(
874                phases[0].as_ref()[i] + phases[2].as_ref()[i] - k * phi2[i]
875            );
876        }
877    }
878
879    // Step 2: Smooth the artefact
880    artefact = gaussian_smooth_3d_phase(&artefact, sigma, mask, grid);
881
882    // Step 3: Unwrap the artefact with ROMEO
883    let mag1 = if mags.is_empty() { &[] as &[f64] } else { mags[0].as_ref() };
884    let romeo_params = RomeoParams {
885        correct_global: true,
886        ..Default::default()
887    };
888    artefact = unwrap_romeo(
889        &artefact, mag1, None, 0.0, 0.0,
890        mask, &romeo_params, grid,
891    );
892
893    // Step 4: Remove artefact from each echo
894    // f = (2 - k) * m - k
895    // even echoes: t = (m + 1) / f
896    // odd echoes:  t = m / f
897    let f = (2.0 - k) * m - k;
898    if f.abs() < 1e-10 {
899        return; // Degenerate case
900    }
901
902    for ieco in 0..n_echoes {
903        // Julia is 1-indexed: iseven(ieco) checks 1-indexed echo number
904        // Echo 1 (idx 0) is odd, echo 2 (idx 1) is even, etc.
905        let t = if (ieco + 1) % 2 == 0 { (m + 1.0) / f } else { m / f };
906        for i in 0..n_total {
907            if mask[i] > 0 {
908                phases[ieco].as_mut()[i] = wrap_to_pi(
909                    phases[ieco].as_ref()[i] - t * artefact[i]
910                );
911            }
912        }
913    }
914}
915
916
917//=============================================================================
918// Multi-Echo Linear Fit
919//=============================================================================
920
921/// Result of multi-echo linear fit
922pub struct LinearFitResult {
923    /// Field map (slope) in rad/s (divide by 2π for Hz)
924    pub field: Vec<f64>,
925    /// Phase offset (intercept) in radians
926    pub phase_offset: Vec<f64>,
927    /// Fit residual (normalized by magnitude sum)
928    pub fit_residual: Vec<f64>,
929    /// Reliability mask (1 = reliable, 0 = unreliable)
930    pub reliability_mask: Vec<u8>,
931}
932
933/// Multi-echo linear fit with magnitude weighting
934///
935/// Fits a linear model: phase = slope * TE + intercept
936/// using weighted least squares with magnitude as weights.
937///
938/// Based on QSM.jl multi_echo_linear_fit and QSMART echofit.m
939///
940/// # Arguments
941/// * `unwrapped_phases` - Unwrapped phase for each echo [n_echoes][nx*ny*nz]
942/// * `mags` - Magnitude for each echo [n_echoes][nx*ny*nz]
943/// * `tes` - Echo times in seconds
944/// * `mask` - Binary mask
945/// * `estimate_offset` - If true, estimate phase offset (intercept)
946/// * `reliability_threshold_percentile` - Percentile for reliability masking (0-100, 0=disable)
947///
948/// # Returns
949/// LinearFitResult containing field, phase_offset, fit_residual, reliability_mask
950pub fn multi_echo_linear_fit(
951    unwrapped_phases: &[impl AsRef<[f64]>],
952    mags: &[impl AsRef<[f64]>],
953    tes: &[f64],
954    mask: &[u8],
955    estimate_offset: bool,
956    reliability_threshold_percentile: f64,
957) -> LinearFitResult {
958    let n_echoes = unwrapped_phases.len();
959    let n_total = unwrapped_phases[0].as_ref().len();
960
961    let mut field = vec![0.0; n_total];
962    let mut phase_offset = vec![0.0; n_total];
963    let mut fit_residual = vec![0.0; n_total];
964
965    if estimate_offset {
966        // Weighted linear fit with intercept: phase = α + β * TE
967        // Using centered data approach for numerical stability
968        //
969        // β = Σ w*(TE - TE_mean)*(phase - phase_mean) / Σ w*(TE - TE_mean)²
970        // α = phase_mean - β * TE_mean (weighted means)
971
972        // Precompute weighted TE mean and sum of squared deviations
973        // (These are per-voxel because weights vary)
974        for v in 0..n_total {
975            if mask[v] == 0 {
976                continue;
977            }
978
979            // Compute weighted means
980            let mut sum_w = 0.0;
981            let mut sum_w_te = 0.0;
982            let mut sum_w_phase = 0.0;
983
984            for e in 0..n_echoes {
985                let w = mags[e].as_ref()[v];
986                sum_w += w;
987                sum_w_te += w * tes[e];
988                sum_w_phase += w * unwrapped_phases[e].as_ref()[v];
989            }
990
991            if sum_w < 1e-10 {
992                continue;
993            }
994
995            let te_mean = sum_w_te / sum_w;
996            let phase_mean = sum_w_phase / sum_w;
997
998            // Compute slope using centered data
999            let mut sum_w_te_centered_sq = 0.0;
1000            let mut sum_w_te_centered_phase_centered = 0.0;
1001
1002            for e in 0..n_echoes {
1003                let w = mags[e].as_ref()[v];
1004                let te_centered = tes[e] - te_mean;
1005                let phase_centered = unwrapped_phases[e].as_ref()[v] - phase_mean;
1006                sum_w_te_centered_sq += w * te_centered * te_centered;
1007                sum_w_te_centered_phase_centered += w * te_centered * phase_centered;
1008            }
1009
1010            if sum_w_te_centered_sq > 1e-10 {
1011                let slope = sum_w_te_centered_phase_centered / sum_w_te_centered_sq;
1012                let intercept = phase_mean - slope * te_mean;
1013                field[v] = slope;
1014                phase_offset[v] = intercept;
1015
1016                // Compute weighted residual
1017                let mut sum_w_resid_sq = 0.0;
1018                for e in 0..n_echoes {
1019                    let w = mags[e].as_ref()[v];
1020                    let predicted = intercept + slope * tes[e];
1021                    let diff = unwrapped_phases[e].as_ref()[v] - predicted;
1022                    sum_w_resid_sq += w * diff * diff;
1023                }
1024                // Normalize by sum of weights and number of echoes (matching echofit.m)
1025                fit_residual[v] = sum_w_resid_sq / sum_w * n_echoes as f64;
1026            }
1027        }
1028    } else {
1029        // Weighted linear fit through origin: phase = β * TE
1030        // β = Σ w*TE*phase / Σ w*TE²
1031        // (matching echofit.m line 40)
1032
1033        for v in 0..n_total {
1034            if mask[v] == 0 {
1035                continue;
1036            }
1037
1038            let mut sum_w_te_phase = 0.0;
1039            let mut sum_w_te_sq = 0.0;
1040            let mut sum_w = 0.0;
1041
1042            for e in 0..n_echoes {
1043                let w = mags[e].as_ref()[v];
1044                let te = tes[e];
1045                let phase = unwrapped_phases[e].as_ref()[v];
1046                sum_w_te_phase += w * te * phase;
1047                sum_w_te_sq += w * te * te;
1048                sum_w += w;
1049            }
1050
1051            if sum_w_te_sq > 1e-10 {
1052                let slope = sum_w_te_phase / sum_w_te_sq;
1053                field[v] = slope;
1054
1055                // Compute weighted residual
1056                let mut sum_w_resid_sq = 0.0;
1057                for e in 0..n_echoes {
1058                    let w = mags[e].as_ref()[v];
1059                    let predicted = slope * tes[e];
1060                    let diff = unwrapped_phases[e].as_ref()[v] - predicted;
1061                    sum_w_resid_sq += w * diff * diff;
1062                }
1063                // Normalize by sum of weights and number of echoes
1064                if sum_w > 1e-10 {
1065                    fit_residual[v] = sum_w_resid_sq / sum_w * n_echoes as f64;
1066                }
1067            }
1068        }
1069    }
1070
1071    // Create reliability mask based on fit residuals
1072    let reliability_mask = if reliability_threshold_percentile > 0.0 {
1073        compute_reliability_mask(&fit_residual, mask, reliability_threshold_percentile)
1074    } else {
1075        // All masked voxels are reliable
1076        mask.to_vec()
1077    };
1078
1079    LinearFitResult {
1080        field,
1081        phase_offset,
1082        fit_residual,
1083        reliability_mask,
1084    }
1085}
1086
1087/// Compute reliability mask by thresholding fit residuals
1088///
1089/// Applies Gaussian smoothing to residuals before thresholding (matching echofit.m)
1090fn compute_reliability_mask(
1091    fit_residual: &[f64],
1092    mask: &[u8],
1093    threshold_percentile: f64,
1094) -> Vec<u8> {
1095    let n_total = fit_residual.len();
1096
1097    // Collect non-zero residuals for percentile calculation
1098    let mut residuals: Vec<f64> = fit_residual.iter()
1099        .enumerate()
1100        .filter(|(i, &r)| mask[*i] > 0 && r > 0.0 && r.is_finite())
1101        .map(|(_, &r)| r)
1102        .collect();
1103
1104    if residuals.is_empty() {
1105        return mask.to_vec();
1106    }
1107
1108    // Sort and find threshold at given percentile
1109    residuals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1110    let percentile_idx = ((threshold_percentile / 100.0) * residuals.len() as f64) as usize;
1111    let threshold = residuals[percentile_idx.min(residuals.len() - 1)];
1112
1113    // Create reliability mask
1114    let mut reliability = vec![0u8; n_total];
1115    for i in 0..n_total {
1116        if mask[i] > 0 && fit_residual[i] < threshold {
1117            reliability[i] = 1;
1118        }
1119    }
1120
1121    reliability
1122}
1123
1124/// Convert field from rad/s to Hz
1125#[inline]
1126pub fn field_to_hz(field: &[f64]) -> Vec<f64> {
1127    field.iter().map(|&f| f / TWO_PI).collect()
1128}
1129
1130#[cfg(test)]
1131mod tests {
1132    use super::*;
1133
1134    fn grid(nx: usize, ny: usize, nz: usize) -> Grid {
1135        Grid::new(nx, ny, nz, 1.0, 1.0, 1.0)
1136    }
1137
1138    #[test]
1139    fn test_wrap_to_pi() {
1140        assert!((wrap_to_pi(0.0) - 0.0).abs() < 1e-10);
1141        assert!((wrap_to_pi(PI) - PI).abs() < 1e-10);
1142        assert!((wrap_to_pi(-PI) - (-PI)).abs() < 1e-10);
1143        assert!((wrap_to_pi(3.0 * PI) - PI).abs() < 1e-10);
1144        assert!((wrap_to_pi(-3.0 * PI) - (-PI)).abs() < 1e-10);
1145    }
1146
1147    #[test]
1148    fn test_gaussian_kernel() {
1149        let kernel = make_gaussian_kernel(1.0);
1150        let sum: f64 = kernel.iter().sum();
1151        assert!((sum - 1.0).abs() < 1e-10);
1152    }
1153
1154    #[test]
1155    fn test_hip() {
1156        let n = 8;
1157        let phase1 = vec![0.1; n];
1158        let phase2 = vec![0.3; n];
1159        let mag1 = vec![1.0; n];
1160        let mag2 = vec![1.0; n];
1161        let mask = vec![1u8; n];
1162
1163        let (hip_phase, hip_mag) = hermitian_inner_product(&phase1, &mag1, &phase2, &mag2, &mask, n);
1164
1165        for i in 0..n {
1166            assert!((hip_phase[i] - 0.2).abs() < 1e-10);
1167            assert!((hip_mag[i] - 1.0).abs() < 1e-10);
1168        }
1169    }
1170
1171    // =========================================================================
1172    // Helper to build synthetic multi-echo data on a small 3D grid
1173    // =========================================================================
1174
1175    /// Build synthetic multi-echo phase/magnitude data.
1176    ///
1177    /// The phase at each voxel is: phase_offset + slope * TE
1178    /// where `slope` is a spatially-varying linear ramp along x.
1179    /// TEs are in seconds. Magnitude is uniform (1.0) inside the mask.
1180    fn make_synthetic_multi_echo(
1181        nx: usize, ny: usize, nz: usize,
1182        tes: &[f64],
1183    ) -> (Vec<Vec<f64>>, Vec<Vec<f64>>, Vec<u8>) {
1184        let n = nx * ny * nz;
1185        let n_echoes = tes.len();
1186
1187        // Constant phase offset (small, well within [-pi, pi])
1188        let phase_offset_val = 0.3;
1189        // Slope (rad/s) as a function of x: gentle ramp so phases stay in [-pi, pi]
1190        // Max slope = 50 rad/s at x=nx-1, so max phase ~ 0.3 + 50*7*0.015 = 5.55
1191        // which will wrap but that is fine.
1192        let slope_scale = 50.0;
1193
1194        let mask = vec![1u8; n];
1195        let mut phases: Vec<Vec<f64>> = Vec::with_capacity(n_echoes);
1196        let mut mags: Vec<Vec<f64>> = Vec::with_capacity(n_echoes);
1197
1198        for e in 0..n_echoes {
1199            let mut p = vec![0.0; n];
1200            let m = vec![1.0; n]; // uniform magnitude
1201            for k in 0..nz {
1202                for j in 0..ny {
1203                    for i in 0..nx {
1204                        let idx = idx3d(i, j, k, nx, ny);
1205                        let slope = slope_scale * i as f64;
1206                        p[idx] = wrap_to_pi(phase_offset_val + slope * tes[e]);
1207                    }
1208                }
1209            }
1210            phases.push(p);
1211            mags.push(m);
1212        }
1213
1214        (phases, mags, mask)
1215    }
1216
1217    // =========================================================================
1218    // idx3d
1219    // =========================================================================
1220
1221    #[test]
1222    fn test_idx3d_basic() {
1223        assert_eq!(idx3d(0, 0, 0, 4, 4), 0);
1224        assert_eq!(idx3d(1, 0, 0, 4, 4), 1);
1225        assert_eq!(idx3d(0, 1, 0, 4, 4), 4);
1226        assert_eq!(idx3d(0, 0, 1, 4, 4), 16);
1227        assert_eq!(idx3d(3, 3, 3, 4, 4), 63);
1228    }
1229
1230    // =========================================================================
1231    // B0WeightType::from_str
1232    // =========================================================================
1233
1234    #[test]
1235    fn test_b0_weight_type_from_str() {
1236        assert_eq!(B0WeightType::from_str("phase_snr"), B0WeightType::PhaseSNR);
1237        assert_eq!(B0WeightType::from_str("phasesnr"), B0WeightType::PhaseSNR);
1238        assert_eq!(B0WeightType::from_str("PhaseSNR"), B0WeightType::PhaseSNR);
1239        assert_eq!(B0WeightType::from_str("phase_var"), B0WeightType::PhaseVar);
1240        assert_eq!(B0WeightType::from_str("phasevar"), B0WeightType::PhaseVar);
1241        assert_eq!(B0WeightType::from_str("average"), B0WeightType::Average);
1242        assert_eq!(B0WeightType::from_str("uniform"), B0WeightType::Average);
1243        assert_eq!(B0WeightType::from_str("tes"), B0WeightType::TEs);
1244        assert_eq!(B0WeightType::from_str("te"), B0WeightType::TEs);
1245        assert_eq!(B0WeightType::from_str("mag"), B0WeightType::Mag);
1246        assert_eq!(B0WeightType::from_str("magnitude"), B0WeightType::Mag);
1247        // Unknown string should default to PhaseSNR
1248        assert_eq!(B0WeightType::from_str("unknown"), B0WeightType::PhaseSNR);
1249    }
1250
1251    // =========================================================================
1252    // gaussian_smooth_3d_phase
1253    // =========================================================================
1254
1255    #[test]
1256    fn test_gaussian_smooth_3d_phase_uniform_input() {
1257        let (nx, ny, nz) = (8, 8, 8);
1258        let n = nx * ny * nz;
1259        // Uniform phase should remain (approximately) constant after smoothing
1260        let phase = vec![1.0; n];
1261        let mask = vec![1u8; n];
1262        let sigma = [1.0, 1.0, 1.0];
1263
1264        let smoothed = gaussian_smooth_3d_phase(&phase, sigma, &mask, &grid(nx, ny, nz));
1265
1266        assert_eq!(smoothed.len(), n);
1267        for v in &smoothed {
1268            assert!(v.is_finite(), "smoothed value must be finite");
1269            assert!((v - 1.0).abs() < 0.05, "uniform phase should remain ~1.0, got {}", v);
1270        }
1271    }
1272
1273    #[test]
1274    fn test_gaussian_smooth_3d_phase_zero_sigma() {
1275        let (nx, ny, nz) = (4, 4, 4);
1276        let n = nx * ny * nz;
1277        let phase: Vec<f64> = (0..n).map(|i| wrap_to_pi(i as f64 * 0.1)).collect();
1278        let mask = vec![1u8; n];
1279        let sigma = [0.0, 0.0, 0.0];
1280
1281        let smoothed = gaussian_smooth_3d_phase(&phase, sigma, &mask, &grid(nx, ny, nz));
1282
1283        // With zero sigma, output should equal input (no smoothing applied)
1284        assert_eq!(smoothed.len(), n);
1285        for i in 0..n {
1286            assert!((smoothed[i] - phase[i]).abs() < 1e-10,
1287                "zero-sigma smoothing should be identity, voxel {}: got {} expected {}",
1288                i, smoothed[i], phase[i]);
1289        }
1290    }
1291
1292    #[test]
1293    fn test_gaussian_smooth_3d_phase_masked_zeros() {
1294        let (nx, ny, nz) = (8, 8, 8);
1295        let n = nx * ny * nz;
1296        let phase = vec![0.5; n];
1297        let mut mask = vec![1u8; n];
1298        // Set half the voxels to 0
1299        for i in 0..n / 2 {
1300            mask[i] = 0;
1301        }
1302
1303        let sigma = [1.0, 1.0, 1.0];
1304        let smoothed = gaussian_smooth_3d_phase(&phase, sigma, &mask, &grid(nx, ny, nz));
1305
1306        assert_eq!(smoothed.len(), n);
1307        // Masked-out voxels should remain 0
1308        for i in 0..n / 2 {
1309            assert_eq!(smoothed[i], 0.0, "masked-out voxel {} should be 0", i);
1310        }
1311        // Masked-in voxels should be finite
1312        for i in n / 2..n {
1313            assert!(smoothed[i].is_finite());
1314        }
1315    }
1316
1317    // =========================================================================
1318    // gaussian_smooth_3d_separable (tested indirectly through phase smoothing
1319    // but let's also exercise multi-axis sigma)
1320    // =========================================================================
1321
1322    #[test]
1323    fn test_gaussian_smooth_anisotropic_sigma() {
1324        let (nx, ny, nz) = (8, 8, 8);
1325        let n = nx * ny * nz;
1326        let phase: Vec<f64> = (0..n).map(|i| wrap_to_pi(0.3 * (i as f64))).collect();
1327        let mask = vec![1u8; n];
1328        let sigma = [2.0, 0.5, 1.0]; // anisotropic
1329
1330        let smoothed = gaussian_smooth_3d_phase(&phase, sigma, &mask, &grid(nx, ny, nz));
1331        assert_eq!(smoothed.len(), n);
1332        for v in &smoothed {
1333            assert!(v.is_finite());
1334            assert!(*v >= -PI && *v <= PI, "smoothed phase should be in [-pi, pi], got {}", v);
1335        }
1336    }
1337
1338    // =========================================================================
1339    // make_gaussian_kernel
1340    // =========================================================================
1341
1342    #[test]
1343    fn test_gaussian_kernel_symmetry() {
1344        let kernel = make_gaussian_kernel(2.0);
1345        let len = kernel.len();
1346        for i in 0..len / 2 {
1347            assert!((kernel[i] - kernel[len - 1 - i]).abs() < 1e-12,
1348                "kernel should be symmetric");
1349        }
1350    }
1351
1352    #[test]
1353    fn test_gaussian_kernel_peak_at_center() {
1354        let kernel = make_gaussian_kernel(1.5);
1355        let center = kernel.len() / 2;
1356        for (i, &v) in kernel.iter().enumerate() {
1357            if i != center {
1358                assert!(v <= kernel[center], "center should be peak");
1359            }
1360        }
1361    }
1362
1363    // =========================================================================
1364    // hermitian_inner_product (additional tests)
1365    // =========================================================================
1366
1367    #[test]
1368    fn test_hip_with_mask() {
1369        let n = 4;
1370        let phase1 = vec![0.5; n];
1371        let phase2 = vec![1.0; n];
1372        let mag1 = vec![2.0; n];
1373        let mag2 = vec![3.0; n];
1374        let mask = vec![1, 0, 1, 0];
1375
1376        let (hip_phase, hip_mag) = hermitian_inner_product(
1377            &phase1, &mag1, &phase2, &mag2, &mask, n
1378        );
1379
1380        // Masked-in voxels
1381        assert!((hip_phase[0] - 0.5).abs() < 1e-10);
1382        assert!((hip_mag[0] - 6.0).abs() < 1e-10);
1383        assert!((hip_phase[2] - 0.5).abs() < 1e-10);
1384        assert!((hip_mag[2] - 6.0).abs() < 1e-10);
1385
1386        // Masked-out voxels
1387        assert_eq!(hip_phase[1], 0.0);
1388        assert_eq!(hip_mag[1], 0.0);
1389        assert_eq!(hip_phase[3], 0.0);
1390        assert_eq!(hip_mag[3], 0.0);
1391    }
1392
1393    #[test]
1394    fn test_hip_wrapping() {
1395        // Test that phase difference wraps correctly
1396        let n = 1;
1397        let phase1 = vec![PI - 0.1];
1398        let phase2 = vec![-PI + 0.1];
1399        let mag1 = vec![1.0];
1400        let mag2 = vec![1.0];
1401        let mask = vec![1u8];
1402
1403        let (hip_phase, _) = hermitian_inner_product(
1404            &phase1, &mag1, &phase2, &mag2, &mask, n
1405        );
1406
1407        // phase2 - phase1 = (-PI + 0.1) - (PI - 0.1) = -2PI + 0.2 -> wraps to 0.2
1408        assert!((hip_phase[0] - 0.2).abs() < 1e-10,
1409            "HIP should wrap phase difference, got {}", hip_phase[0]);
1410    }
1411
1412    // =========================================================================
1413    // find_seed_point (now in romeo.rs, tested here for coverage)
1414    // =========================================================================
1415
1416    #[test]
1417    fn test_find_seed_point_full_mask() {
1418        use crate::unwrap::romeo::find_seed_point;
1419        let (nx, ny, nz) = (8, 8, 8);
1420        let mask = vec![1u8; nx * ny * nz];
1421        let (si, sj, sk) = find_seed_point(&mask, nx, ny, nz);
1422        // Center of mass of a fully-filled cube should be approximately center
1423        assert_eq!(si, 3); // mean of 0..7 = 3.5, integer division = 3
1424        assert_eq!(sj, 3);
1425        assert_eq!(sk, 3);
1426    }
1427
1428    #[test]
1429    fn test_find_seed_point_empty_mask() {
1430        use crate::unwrap::romeo::find_seed_point;
1431        let (nx, ny, nz) = (8, 8, 8);
1432        let mask = vec![0u8; nx * ny * nz];
1433        let (si, sj, sk) = find_seed_point(&mask, nx, ny, nz);
1434        // Fallback: center of volume
1435        assert_eq!(si, 4);
1436        assert_eq!(sj, 4);
1437        assert_eq!(sk, 4);
1438    }
1439
1440    #[test]
1441    fn test_find_seed_point_corner_mask() {
1442        use crate::unwrap::romeo::find_seed_point;
1443        let (nx, ny, nz) = (8, 8, 8);
1444        let mut mask = vec![0u8; nx * ny * nz];
1445        // Only set voxel (0,0,0)
1446        mask[idx3d(0, 0, 0, nx, ny)] = 1;
1447        let (si, sj, sk) = find_seed_point(&mask, nx, ny, nz);
1448        assert_eq!(si, 0);
1449        assert_eq!(sj, 0);
1450        assert_eq!(sk, 0);
1451    }
1452
1453    // =========================================================================
1454    // field_to_hz
1455    // =========================================================================
1456
1457    #[test]
1458    fn test_field_to_hz() {
1459        let field = vec![TWO_PI, -TWO_PI, 0.0, PI];
1460        let hz = field_to_hz(&field);
1461        assert!((hz[0] - 1.0).abs() < 1e-10);
1462        assert!((hz[1] - (-1.0)).abs() < 1e-10);
1463        assert!((hz[2] - 0.0).abs() < 1e-10);
1464        assert!((hz[3] - 0.5).abs() < 1e-10);
1465    }
1466
1467    // =========================================================================
1468    // calculate_b0_weighted
1469    // =========================================================================
1470
1471    #[test]
1472    fn test_calculate_b0_weighted_phase_snr() {
1473        // For a constant slope (rad/s), all weight types should recover it.
1474        // phase[e] = slope * TE[e], so phase/TE = slope for each echo.
1475        // Weighted average of identical values = same value.
1476        // B0 = (1 / 2pi) * slope (Hz)
1477        let n = 64;
1478        let tes = [0.005, 0.010, 0.015]; // seconds
1479        let slope = 200.0; // rad/s
1480        let mask = vec![1u8; n];
1481
1482        let phases: Vec<Vec<f64>> = tes.iter()
1483            .map(|&te| vec![slope * te; n])
1484            .collect();
1485        let mags: Vec<Vec<f64>> = tes.iter()
1486            .map(|_| vec![1.0; n])
1487            .collect();
1488
1489        let b0 = calculate_b0_weighted(&phases, &mags, &tes, &mask, B0WeightType::PhaseSNR, &grid(n, 1, 1));
1490
1491        let expected_hz = 1.0 / TWO_PI * slope;
1492        assert_eq!(b0.len(), n);
1493        for v in &b0 {
1494            assert!(v.is_finite());
1495            assert!((v - expected_hz).abs() < 1e-8,
1496                "expected {} Hz, got {}", expected_hz, v);
1497        }
1498    }
1499
1500    #[test]
1501    fn test_calculate_b0_weighted_all_weight_types() {
1502        let n = 16;
1503        let tes = [0.005, 0.010, 0.015]; // seconds
1504        let slope = 100.0; // rad/s
1505        let mask = vec![1u8; n];
1506
1507        let phases: Vec<Vec<f64>> = tes.iter()
1508            .map(|&te| vec![slope * te; n])
1509            .collect();
1510        let mags: Vec<Vec<f64>> = tes.iter()
1511            .map(|_| vec![2.0; n])
1512            .collect();
1513
1514        let expected_hz = 1.0 / TWO_PI * slope;
1515
1516        for wt in &[
1517            B0WeightType::PhaseSNR,
1518            B0WeightType::PhaseVar,
1519            B0WeightType::Average,
1520            B0WeightType::TEs,
1521            B0WeightType::Mag,
1522        ] {
1523            let b0 = calculate_b0_weighted(&phases, &mags, &tes, &mask, *wt, &grid(n, 1, 1));
1524            assert_eq!(b0.len(), n);
1525            for v in &b0 {
1526                assert!(v.is_finite(), "weight type {:?} produced non-finite", wt);
1527                assert!((v - expected_hz).abs() < 1e-8,
1528                    "weight type {:?}: expected {} Hz, got {}", wt, expected_hz, v);
1529            }
1530        }
1531    }
1532
1533    #[test]
1534    fn test_calculate_b0_weighted_masked_out() {
1535        let n = 8;
1536        let tes = [0.005, 0.010]; // seconds
1537        let mask = vec![0u8; n]; // all masked out
1538
1539        let phases: Vec<Vec<f64>> = tes.iter()
1540            .map(|&te| vec![500.0 * te; n])
1541            .collect();
1542        let mags: Vec<Vec<f64>> = tes.iter()
1543            .map(|_| vec![1.0; n])
1544            .collect();
1545
1546        let b0 = calculate_b0_weighted(&phases, &mags, &tes, &mask, B0WeightType::PhaseSNR, &grid(n, 1, 1));
1547
1548        for v in &b0 {
1549            assert_eq!(*v, 0.0, "masked-out voxels should have B0=0");
1550        }
1551    }
1552
1553    #[test]
1554    fn test_calculate_b0_weighted_zero_magnitude() {
1555        // When magnitude is zero, weight is zero; result should be 0
1556        let n = 4;
1557        let tes = [0.005, 0.010, 0.015];
1558        let mask = vec![1u8; n];
1559
1560        let phases: Vec<Vec<f64>> = tes.iter()
1561            .map(|&te| vec![0.2 * te; n])
1562            .collect();
1563        let mags: Vec<Vec<f64>> = tes.iter()
1564            .map(|_| vec![0.0; n]) // zero magnitude
1565            .collect();
1566
1567        // PhaseSNR weight = mag * te = 0
1568        let b0 = calculate_b0_weighted(&phases, &mags, &tes, &mask, B0WeightType::PhaseSNR, &grid(n, 1, 1));
1569        for v in &b0 {
1570            assert_eq!(*v, 0.0, "zero-magnitude voxels should yield B0=0");
1571        }
1572
1573        // Average weight = 1.0, should still work
1574        let b0_avg = calculate_b0_weighted(&phases, &mags, &tes, &mask, B0WeightType::Average, &grid(n, 1, 1));
1575        let expected = 1.0 / TWO_PI * 0.2;
1576        for v in &b0_avg {
1577            assert!((v - expected).abs() < 1e-8);
1578        }
1579    }
1580
1581    // =========================================================================
1582    // multi_echo_linear_fit
1583    // =========================================================================
1584
1585    #[test]
1586    fn test_multi_echo_linear_fit_no_offset() {
1587        // phase = slope * TE (no intercept)
1588        // Should recover the slope exactly.
1589        let n = 32;
1590        let tes = [0.005, 0.010, 0.015]; // in seconds
1591        let slope = 100.0; // rad/s
1592        let mask = vec![1u8; n];
1593
1594        let phases: Vec<Vec<f64>> = tes.iter()
1595            .map(|&te| vec![slope * te; n])
1596            .collect();
1597        let mags: Vec<Vec<f64>> = tes.iter()
1598            .map(|_| vec![1.0; n])
1599            .collect();
1600
1601        let result = multi_echo_linear_fit(
1602            &phases, &mags, &tes, &mask,
1603            false, // no offset estimation
1604            0.0,   // no reliability threshold
1605        );
1606
1607        assert_eq!(result.field.len(), n);
1608        assert_eq!(result.phase_offset.len(), n);
1609        assert_eq!(result.fit_residual.len(), n);
1610        assert_eq!(result.reliability_mask.len(), n);
1611
1612        for i in 0..n {
1613            assert!((result.field[i] - slope).abs() < 1e-6,
1614                "slope: expected {}, got {}", slope, result.field[i]);
1615            assert_eq!(result.phase_offset[i], 0.0,
1616                "offset should be 0 when estimate_offset=false");
1617            assert!(result.fit_residual[i] < 1e-10,
1618                "residual should be ~0 for perfect linear data");
1619            assert_eq!(result.reliability_mask[i], 1,
1620                "reliability should match mask when threshold=0");
1621        }
1622    }
1623
1624    #[test]
1625    fn test_multi_echo_linear_fit_with_offset() {
1626        // phase = intercept + slope * TE
1627        let n = 16;
1628        let tes = [0.005, 0.010, 0.015, 0.020];
1629        let slope = 200.0;     // rad/s
1630        let intercept = 0.5;   // rad
1631        let mask = vec![1u8; n];
1632
1633        let phases: Vec<Vec<f64>> = tes.iter()
1634            .map(|&te| vec![intercept + slope * te; n])
1635            .collect();
1636        let mags: Vec<Vec<f64>> = tes.iter()
1637            .map(|_| vec![1.0; n])
1638            .collect();
1639
1640        let result = multi_echo_linear_fit(
1641            &phases, &mags, &tes, &mask,
1642            true, // estimate offset
1643            0.0,
1644        );
1645
1646        for i in 0..n {
1647            assert!((result.field[i] - slope).abs() < 1e-4,
1648                "slope: expected {}, got {}", slope, result.field[i]);
1649            assert!((result.phase_offset[i] - intercept).abs() < 1e-4,
1650                "intercept: expected {}, got {}", intercept, result.phase_offset[i]);
1651            assert!(result.fit_residual[i] < 1e-8,
1652                "residual should be ~0 for perfect linear data, got {}", result.fit_residual[i]);
1653        }
1654    }
1655
1656    #[test]
1657    fn test_multi_echo_linear_fit_masked_out() {
1658        let n = 8;
1659        let tes = [0.005, 0.010, 0.015];
1660        let mask = vec![0u8; n];
1661
1662        let phases: Vec<Vec<f64>> = tes.iter()
1663            .map(|&te| vec![100.0 * te; n])
1664            .collect();
1665        let mags: Vec<Vec<f64>> = tes.iter()
1666            .map(|_| vec![1.0; n])
1667            .collect();
1668
1669        let result = multi_echo_linear_fit(&phases, &mags, &tes, &mask, true, 0.0);
1670
1671        for i in 0..n {
1672            assert_eq!(result.field[i], 0.0);
1673            assert_eq!(result.phase_offset[i], 0.0);
1674            assert_eq!(result.fit_residual[i], 0.0);
1675        }
1676    }
1677
1678    #[test]
1679    fn test_multi_echo_linear_fit_varying_slope() {
1680        // Each voxel has a different slope
1681        let n = 8;
1682        let tes = [0.005, 0.010, 0.015];
1683        let mask = vec![1u8; n];
1684
1685        let slopes: Vec<f64> = (0..n).map(|i| 50.0 * (i as f64 + 1.0)).collect();
1686
1687        let phases: Vec<Vec<f64>> = tes.iter()
1688            .map(|&te| {
1689                slopes.iter().map(|&s| s * te).collect()
1690            })
1691            .collect();
1692        let mags: Vec<Vec<f64>> = tes.iter()
1693            .map(|_| vec![1.0; n])
1694            .collect();
1695
1696        let result = multi_echo_linear_fit(&phases, &mags, &tes, &mask, false, 0.0);
1697
1698        for i in 0..n {
1699            assert!((result.field[i] - slopes[i]).abs() < 1e-6,
1700                "voxel {}: expected slope {}, got {}", i, slopes[i], result.field[i]);
1701        }
1702    }
1703
1704    #[test]
1705    fn test_multi_echo_linear_fit_with_reliability_threshold() {
1706        // Create data where some voxels have noisy fits
1707        let n = 100;
1708        let tes = [0.005, 0.010, 0.015];
1709        let mask = vec![1u8; n];
1710        let slope = 100.0;
1711
1712        let mut phases: Vec<Vec<f64>> = tes.iter()
1713            .map(|&te| vec![slope * te; n])
1714            .collect();
1715        let mags: Vec<Vec<f64>> = tes.iter()
1716            .map(|_| vec![1.0; n])
1717            .collect();
1718
1719        // Add large noise to last 10 voxels to increase their residuals
1720        for e in 0..tes.len() {
1721            for i in 90..100 {
1722                phases[e][i] += if e % 2 == 0 { 2.0 } else { -2.0 };
1723            }
1724        }
1725
1726        // Use 80th percentile threshold
1727        let result = multi_echo_linear_fit(&phases, &mags, &tes, &mask, false, 80.0);
1728
1729        assert_eq!(result.reliability_mask.len(), n);
1730
1731        // Clean voxels (0..90) have residual=0, noisy voxels (90..100) have residual>0.
1732        // The threshold is computed from non-zero residuals only.
1733        // Voxels with residual=0 satisfy 0 < threshold, but compute_reliability_mask
1734        // checks `fit_residual[i] < threshold` -- 0 < any positive threshold => reliable=1.
1735        // However, residual might not be exactly 0 due to floating point.
1736        // Just verify that the noisy voxels have higher residuals than clean ones.
1737        let max_clean_resid = result.fit_residual[0..90].iter()
1738            .cloned().fold(0.0f64, f64::max);
1739        let min_noisy_resid = result.fit_residual[90..100].iter()
1740            .cloned().fold(f64::INFINITY, f64::min);
1741        assert!(max_clean_resid < min_noisy_resid,
1742            "clean residuals ({}) should be less than noisy residuals ({})",
1743            max_clean_resid, min_noisy_resid);
1744
1745        // The reliability mask should exist and have valid values
1746        for &v in &result.reliability_mask {
1747            assert!(v == 0 || v == 1);
1748        }
1749    }
1750
1751    #[test]
1752    fn test_multi_echo_linear_fit_zero_magnitude() {
1753        let n = 4;
1754        let tes = [0.005, 0.010, 0.015];
1755        let mask = vec![1u8; n];
1756
1757        let phases: Vec<Vec<f64>> = tes.iter()
1758            .map(|&te| vec![100.0 * te; n])
1759            .collect();
1760        let mags: Vec<Vec<f64>> = tes.iter()
1761            .map(|_| vec![0.0; n]) // zero magnitude
1762            .collect();
1763
1764        // Should not crash; field should be 0 because sum_w_te_sq ~ 0
1765        let result = multi_echo_linear_fit(&phases, &mags, &tes, &mask, false, 0.0);
1766        for v in &result.field {
1767            assert!(v.is_finite());
1768        }
1769
1770        let result2 = multi_echo_linear_fit(&phases, &mags, &tes, &mask, true, 0.0);
1771        for v in &result2.field {
1772            assert!(v.is_finite());
1773        }
1774    }
1775
1776    // =========================================================================
1777    // compute_reliability_mask
1778    // =========================================================================
1779
1780    #[test]
1781    fn test_compute_reliability_mask_basic() {
1782        let n = 10;
1783        let mask = vec![1u8; n];
1784        // Residuals in ascending order: 0.1, 0.2, ..., 1.0
1785        let fit_residual: Vec<f64> = (1..=n).map(|i| i as f64 * 0.1).collect();
1786
1787        // 50th percentile: threshold ~ 0.5
1788        let reliability = compute_reliability_mask(&fit_residual, &mask, 50.0);
1789        assert_eq!(reliability.len(), n);
1790
1791        // Voxels with residual < threshold should be reliable
1792        let reliable_count: usize = reliability.iter().map(|&v| v as usize).sum();
1793        assert!(reliable_count > 0 && reliable_count < n,
1794            "some but not all should be reliable, got {}/{}", reliable_count, n);
1795    }
1796
1797    #[test]
1798    fn test_compute_reliability_mask_all_zero_residual() {
1799        let n = 5;
1800        let mask = vec![1u8; n];
1801        let fit_residual = vec![0.0; n];
1802
1803        // When all residuals are 0, the filter skips them (r > 0.0 check fails)
1804        // so residuals vec is empty and mask is returned as-is
1805        let reliability = compute_reliability_mask(&fit_residual, &mask, 50.0);
1806        assert_eq!(reliability, mask);
1807    }
1808
1809    // =========================================================================
1810    // mcpc3ds_single_coil
1811    // =========================================================================
1812
1813    #[test]
1814    fn test_phase_offset_removal_output_sizes() {
1815        let (nx, ny, nz) = (8, 8, 8);
1816        let n = nx * ny * nz;
1817        let tes = [0.005, 0.010, 0.015];
1818        let (phases, mags, mask) = make_synthetic_multi_echo(nx, ny, nz, &tes);
1819
1820        let sigma = [1.0, 1.0, 1.0];
1821        let (corrected, offset) = phase_offset_removal(
1822            &phases, &mags, &tes, &mask, sigma, [0, 1], UnwrapMethod::Romeo, &grid(nx, ny, nz),
1823        );
1824
1825        // Check sizes
1826        assert_eq!(corrected.len(), tes.len(), "should have one corrected phase per echo");
1827        for (e, cp) in corrected.iter().enumerate() {
1828            assert_eq!(cp.len(), n, "echo {} corrected phase should have {} voxels", e, n);
1829        }
1830        assert_eq!(offset.len(), n, "phase offset should have {} voxels", n);
1831    }
1832
1833    #[test]
1834    fn test_phase_offset_removal_finite_output() {
1835        let (nx, ny, nz) = (8, 8, 8);
1836        let tes = [0.005, 0.010, 0.015];
1837        let (phases, mags, mask) = make_synthetic_multi_echo(nx, ny, nz, &tes);
1838
1839        let sigma = [1.0, 1.0, 1.0];
1840        let (corrected, offset) = phase_offset_removal(
1841            &phases, &mags, &tes, &mask, sigma, [0, 1], UnwrapMethod::Romeo, &grid(nx, ny, nz),
1842        );
1843
1844        for v in &offset {
1845            assert!(v.is_finite(), "phase offset should be finite");
1846        }
1847        for cp in &corrected {
1848            for v in cp {
1849                assert!(v.is_finite(), "corrected phase should be finite");
1850            }
1851        }
1852    }
1853
1854    #[test]
1855    fn test_phase_offset_removal_corrected_in_range() {
1856        let (nx, ny, nz) = (8, 8, 8);
1857        let tes = [0.005, 0.010, 0.015];
1858        let (phases, mags, mask) = make_synthetic_multi_echo(nx, ny, nz, &tes);
1859
1860        let sigma = [1.0, 1.0, 1.0];
1861        let (corrected, _) = phase_offset_removal(
1862            &phases, &mags, &tes, &mask, sigma, [0, 1], UnwrapMethod::Romeo, &grid(nx, ny, nz),
1863        );
1864
1865        // Corrected phases should be in [-pi, pi] since wrap_to_pi is applied
1866        for cp in &corrected {
1867            for &v in cp {
1868                assert!(v >= -PI - 1e-10 && v <= PI + 1e-10,
1869                    "corrected phase should be in [-pi, pi], got {}", v);
1870            }
1871        }
1872    }
1873
1874    #[test]
1875    fn test_phase_offset_removal_uniform_phase() {
1876        // Uniform phase across all echoes => offset should be approximately that phase
1877        let (nx, ny, nz) = (8, 8, 8);
1878        let n = nx * ny * nz;
1879        let tes = [0.005, 0.010, 0.015];
1880
1881        // All echoes have constant phase 0.5 (no TE dependence)
1882        let phases: Vec<Vec<f64>> = (0..3).map(|_| vec![0.5; n]).collect();
1883        let mags: Vec<Vec<f64>> = (0..3).map(|_| vec![1.0; n]).collect();
1884        let mask = vec![1u8; n];
1885
1886        let sigma = [1.0, 1.0, 1.0];
1887        let (corrected, _offset) = phase_offset_removal(
1888            &phases, &mags, &tes, &mask, sigma, [0, 1], UnwrapMethod::Romeo, &grid(nx, ny, nz),
1889        );
1890
1891        // After removing offset, corrected phases should be close to 0
1892        for cp in &corrected {
1893            for &v in cp {
1894                assert!(v.abs() < 1.0,
1895                    "after offset removal of uniform phase, corrected should be ~0, got {}", v);
1896            }
1897        }
1898    }
1899
1900    // =========================================================================
1901    // Composed field mapping pipeline (offset removal → unwrap → B0)
1902    // =========================================================================
1903
1904    #[test]
1905    fn test_field_mapping_composed() {
1906        use crate::unwrap::romeo::{unwrap_romeo_multi_echo, RomeoParams};
1907        let (nx, ny, nz) = (8, 8, 8);
1908        let n = nx * ny * nz;
1909        let tes = [0.005, 0.010, 0.015];
1910        let (phases, mags, mask) = make_synthetic_multi_echo(nx, ny, nz, &tes);
1911        let sigma = [1.0, 1.0, 1.0];
1912
1913        // Step 1: Phase offset removal
1914        let (corrected, offset) = phase_offset_removal(
1915            &phases, &mags, &tes, &mask, sigma, [0, 1],
1916            UnwrapMethod::Romeo, &grid(nx, ny, nz),
1917        );
1918        assert_eq!(corrected.len(), tes.len());
1919        assert_eq!(offset.len(), n);
1920
1921        // Step 2: Multi-echo unwrapping
1922        let mag_refs: Vec<&[f64]> = mags.iter().map(|m| m.as_slice()).collect();
1923        let unwrapped = unwrap_romeo_multi_echo(
1924            &corrected, &mag_refs, &tes, &mask,
1925            &RomeoParams::default(), &grid(nx, ny, nz),
1926        );
1927        assert_eq!(unwrapped.len(), tes.len());
1928
1929        // Step 3: Weighted B0
1930        let b0 = calculate_b0_weighted(
1931            &unwrapped, &mags, &tes, &mask, B0WeightType::PhaseSNR, &grid(nx, ny, nz),
1932        );
1933        assert_eq!(b0.len(), n);
1934        for v in &b0 {
1935            assert!(v.is_finite(), "B0 should be finite");
1936        }
1937    }
1938
1939    #[test]
1940    fn test_field_mapping_different_weight_types() {
1941        use crate::unwrap::romeo::{unwrap_romeo_multi_echo, RomeoParams};
1942        let (nx, ny, nz) = (8, 8, 8);
1943        let n = nx * ny * nz;
1944        let tes = [0.005, 0.010, 0.015];
1945        let (phases, mags, mask) = make_synthetic_multi_echo(nx, ny, nz, &tes);
1946        let sigma = [1.0, 1.0, 1.0];
1947
1948        let (corrected, _) = phase_offset_removal(
1949            &phases, &mags, &tes, &mask, sigma, [0, 1],
1950            UnwrapMethod::Romeo, &grid(nx, ny, nz),
1951        );
1952        let mag_refs: Vec<&[f64]> = mags.iter().map(|m| m.as_slice()).collect();
1953        let unwrapped = unwrap_romeo_multi_echo(
1954            &corrected, &mag_refs, &tes, &mask,
1955            &RomeoParams::default(), &grid(nx, ny, nz),
1956        );
1957
1958        for wt in &[
1959            B0WeightType::PhaseSNR,
1960            B0WeightType::Average,
1961            B0WeightType::TEs,
1962            B0WeightType::Mag,
1963            B0WeightType::PhaseVar,
1964        ] {
1965            let b0 = calculate_b0_weighted(&unwrapped, &mags, &tes, &mask, *wt, &grid(n, 1, 1));
1966            for v in &b0 {
1967                assert!(v.is_finite(), "B0 with {:?} should be finite", wt);
1968            }
1969        }
1970    }
1971
1972    // =========================================================================
1973    // wrap_to_pi edge cases
1974    // =========================================================================
1975
1976    #[test]
1977    fn test_wrap_to_pi_near_boundaries() {
1978        // Values just beyond PI and -PI
1979        let v1 = wrap_to_pi(PI + 0.001);
1980        assert!(v1 < PI && v1 > -PI, "should wrap back into range");
1981
1982        let v2 = wrap_to_pi(-PI - 0.001);
1983        assert!(v2 > -PI && v2 < PI, "should wrap back into range");
1984
1985        // Large positive and negative values
1986        let v3 = wrap_to_pi(100.0 * PI);
1987        assert!(v3 >= -PI && v3 <= PI, "should be in [-pi, pi], got {}", v3);
1988
1989        let v4 = wrap_to_pi(-100.0 * PI);
1990        assert!(v4 >= -PI && v4 <= PI, "should be in [-pi, pi], got {}", v4);
1991    }
1992
1993    // =========================================================================
1994    // unwrap_romeo (tested indirectly through phase_offset_removal
1995    // but let's also test directly via the public API)
1996    // =========================================================================
1997
1998    #[test]
1999    fn test_unwrap_romeo_smooth_data() {
2000        use crate::unwrap::romeo::{unwrap_romeo, RomeoParams};
2001        let (nx, ny, nz) = (8, 8, 8);
2002        let n = nx * ny * nz;
2003        // Smooth phase that doesn't need unwrapping
2004        let phase: Vec<f64> = (0..n).map(|i| {
2005            let x = (i % nx) as f64 / nx as f64;
2006            0.5 * x // small smooth phase
2007        }).collect();
2008        let mag = vec![1.0; n];
2009        let mask = vec![1u8; n];
2010
2011        let unwrapped = unwrap_romeo(
2012            &phase, &mag, None, 0.0, 0.0,
2013            &mask, &RomeoParams::default(), &grid(nx, ny, nz),
2014        );
2015
2016        assert_eq!(unwrapped.len(), n);
2017        for (i, &v) in unwrapped.iter().enumerate() {
2018            assert!(v.is_finite(), "unwrapped voxel {} should be finite", i);
2019        }
2020    }
2021
2022    // =========================================================================
2023    // Integration: linear fit on mcpc3ds output
2024    // =========================================================================
2025
2026    #[test]
2027    fn test_linear_fit_on_mcpc3ds_output() {
2028        let (nx, ny, nz) = (8, 8, 8);
2029        let n = nx * ny * nz;
2030        let tes = [0.005, 0.010, 0.015];
2031        let (phases, mags, mask) = make_synthetic_multi_echo(nx, ny, nz, &tes);
2032
2033        let sigma = [1.0, 1.0, 1.0];
2034        let (corrected, _offset) = phase_offset_removal(
2035            &phases, &mags, &tes, &mask, sigma, [0, 1], UnwrapMethod::Romeo, &grid(nx, ny, nz),
2036        );
2037
2038        // Run linear fit on corrected phases (tes in seconds for fit)
2039        let tes_s: Vec<f64> = tes.iter().map(|&t| t / 1000.0).collect();
2040        let result = multi_echo_linear_fit(
2041            &corrected, &mags, &tes_s, &mask, true, 0.0,
2042        );
2043
2044        assert_eq!(result.field.len(), n);
2045        assert_eq!(result.phase_offset.len(), n);
2046        assert_eq!(result.fit_residual.len(), n);
2047        assert_eq!(result.reliability_mask.len(), n);
2048
2049        for v in &result.field {
2050            assert!(v.is_finite(), "field should be finite");
2051        }
2052        for v in &result.phase_offset {
2053            assert!(v.is_finite(), "phase_offset should be finite");
2054        }
2055    }
2056
2057    // =========================================================================
2058    // bipolar_correction
2059    // =========================================================================
2060
2061    #[test]
2062    fn test_bipolar_correction_3_echoes() {
2063        let (nx, ny, nz) = (8, 8, 8);
2064        let n = nx * ny * nz;
2065        let tes = [0.005, 0.010, 0.015];
2066        let (phases, mags, mask) = make_synthetic_multi_echo(nx, ny, nz, &tes);
2067
2068        let mut phases_mut = phases;
2069        let sigma = [1.0, 1.0, 1.0];
2070        bipolar_correction(
2071            &mut phases_mut, &mags, &tes, &mask,
2072            sigma, &grid(nx, ny, nz),
2073        );
2074
2075        // All values should remain finite
2076        for echo in &phases_mut {
2077            for &v in echo {
2078                assert!(v.is_finite(), "bipolar-corrected phase should be finite");
2079            }
2080        }
2081    }
2082
2083    #[test]
2084    fn test_bipolar_correction_2_echoes_noop() {
2085        // With only 2 echoes, bipolar correction should be a no-op
2086        let (nx, ny, nz) = (8, 8, 8);
2087        let n = nx * ny * nz;
2088        let tes = [0.005, 0.010];
2089        let (phases, mags, mask) = make_synthetic_multi_echo(nx, ny, nz, &tes);
2090
2091        let original: Vec<Vec<f64>> = phases.iter().map(|p| p.clone()).collect();
2092        let mut phases_mut = phases;
2093        bipolar_correction(
2094            &mut phases_mut, &mags, &tes, &mask,
2095            [1.0, 1.0, 1.0], &grid(nx, ny, nz),
2096        );
2097
2098        // Should be unchanged (2 echoes = noop)
2099        for (e, echo) in phases_mut.iter().enumerate() {
2100            for (i, &v) in echo.iter().enumerate() {
2101                assert_eq!(v, original[e][i],
2102                    "2-echo bipolar correction should be no-op");
2103            }
2104        }
2105    }
2106
2107    #[test]
2108    fn test_field_mapping_with_bipolar() {
2109        use crate::unwrap::romeo::{unwrap_romeo_multi_echo, RomeoParams};
2110        let (nx, ny, nz) = (8, 8, 8);
2111        let n = nx * ny * nz;
2112        let tes = [0.005, 0.010, 0.015];
2113        let (phases, mags, mask) = make_synthetic_multi_echo(nx, ny, nz, &tes);
2114        let sigma = [1.0, 1.0, 1.0];
2115
2116        let (mut corrected, _) = phase_offset_removal(
2117            &phases, &mags, &tes, &mask, sigma, [0, 1],
2118            UnwrapMethod::Romeo, &grid(nx, ny, nz),
2119        );
2120        let mag_refs: Vec<&[f64]> = mags.iter().map(|m| m.as_slice()).collect();
2121        bipolar_correction(&mut corrected, &mag_refs, &tes, &mask, sigma, &grid(nx, ny, nz));
2122        let unwrapped = unwrap_romeo_multi_echo(
2123            &corrected, &mag_refs, &tes, &mask,
2124            &RomeoParams::default(), &grid(nx, ny, nz),
2125        );
2126        let b0 = calculate_b0_weighted(&unwrapped, &mags, &tes, &mask, B0WeightType::PhaseSNR, &grid(n, 1, 1));
2127
2128        assert_eq!(b0.len(), n);
2129        for v in &b0 {
2130            assert!(v.is_finite(), "B0 with bipolar correction should be finite");
2131        }
2132    }
2133
2134    // --- mcpc3ds_combine ---
2135
2136    /// Two coils with distinct smooth phase offsets over a linear field: the combined phase
2137    /// must be the pure field evolution 2π·f·TE_e, offsets gone.
2138    fn synthetic_coils(nx: usize, ny: usize, nz: usize, tes: &[f64]) -> (Vec<Vec<Vec<f64>>>, Vec<Vec<Vec<f64>>>, Vec<f64>) {
2139        let n = nx * ny * nz;
2140        let offsets: Vec<Box<dyn Fn(usize, usize, usize) -> f64>> = vec![
2141            Box::new(|x, _, _| 0.3 + 0.01 * x as f64),
2142            Box::new(|_, y, _| -1.0 + 0.02 * y as f64),
2143        ];
2144        let mut field = vec![0.0; n]; // Hz
2145        let mut phases = Vec::new();
2146        let mut mags = Vec::new();
2147        for (c, po) in offsets.iter().enumerate() {
2148            let mut cp = Vec::new();
2149            let mut cm = Vec::new();
2150            for &te in tes {
2151                let mut p = vec![0.0; n];
2152                let mut m = vec![0.0; n];
2153                for z in 0..nz { for y in 0..ny { for x in 0..nx {
2154                    let i = x + y * nx + z * nx * ny;
2155                    let f = 2.0 + 1.5 * (x as f64 / nx as f64) - 1.0 * (z as f64 / nz as f64);
2156                    field[i] = f;
2157                    p[i] = wrap_to_pi(2.0 * PI * f * te + po(x, y, z));
2158                    m[i] = 100.0 * (1.0 + 0.3 * (if c == 0 { x as f64 / nx as f64 } else { y as f64 / ny as f64 }));
2159                }}}
2160                cp.push(p);
2161                cm.push(m);
2162            }
2163            phases.push(cp);
2164            mags.push(cm);
2165        }
2166        (phases, mags, field)
2167    }
2168
2169    #[test]
2170    fn test_mcpc3ds_combine_removes_coil_offsets() {
2171        let (nx, ny, nz) = (20, 20, 10);
2172        let tes = [0.005, 0.010];
2173        let g = grid(nx, ny, nz);
2174        let (phases, mags, field) = synthetic_coils(nx, ny, nz, &tes);
2175        let res = mcpc3ds_combine(&phases, &mags, &tes, [2.0, 2.0, 2.0], [0, 1], UnwrapMethod::Romeo, &g);
2176        assert_eq!(res.phases.len(), 2);
2177        assert_eq!(res.magnitudes.len(), 2);
2178        let n_mask = res.mask.iter().filter(|&&m| m > 0).count();
2179        assert!(n_mask > n_mask_min(nx, ny, nz), "robust mask should cover the object: {}", n_mask);
2180        // Interior voxels (away from the smoothing boundary): combined phase == field evolution.
2181        let mut checked = 0;
2182        for z in 3..nz - 3 { for y in 4..ny - 4 { for x in 4..nx - 4 {
2183            let i = x + y * nx + z * nx * ny;
2184            if res.mask[i] == 0 { continue; }
2185            for (e, &te) in tes.iter().enumerate() {
2186                let expected = wrap_to_pi(2.0 * PI * field[i] * te);
2187                let err = wrap_to_pi(res.phases[e][i] - expected).abs();
2188                assert!(err < 0.05, "echo {} voxel ({},{},{}): got {:.4}, expected {:.4}", e, x, y, z, res.phases[e][i], expected);
2189            }
2190            checked += 1;
2191        }}}
2192        assert!(checked > 100, "too few interior voxels checked: {}", checked);
2193        // Magnitude: sqrt(|Σ m_c² e^{iθ}|) with aligned phases = sqrt(m0² + m1²) ≥ each coil.
2194        for i in 0..nx * ny * nz {
2195            let m0 = mags[0][0][i];
2196            let m1 = mags[1][0][i];
2197            let expected = (m0 * m0 + m1 * m1).sqrt();
2198            assert!((res.magnitudes[0][i] - expected).abs() / expected < 0.02, "magnitude at {}: {} vs {}", i, res.magnitudes[0][i], expected);
2199        }
2200    }
2201
2202    fn n_mask_min(nx: usize, ny: usize, nz: usize) -> usize { nx * ny * nz / 2 }
2203
2204    #[test]
2205    fn test_mcpc3ds_combine_single_coil_removes_offset_keeps_magnitude() {
2206        let (nx, ny, nz) = (24, 24, 12);
2207        let tes = [0.005, 0.010];
2208        let g = grid(nx, ny, nz);
2209        let (mut phases, mut mags, field) = synthetic_coils(nx, ny, nz, &tes);
2210        phases.truncate(1);
2211        mags.truncate(1);
2212        let res = mcpc3ds_combine(&phases, &mags, &tes, [2.0, 2.0, 2.0], [0, 1], UnwrapMethod::Romeo, &g);
2213        let n = nx * ny * nz;
2214        for e in 0..2 {
2215            for i in 0..n {
2216                // one coil: |S| is untouched, and the inter-echo phase difference is preserved
2217                // exactly (a TE-independent offset cancels in it)
2218                assert!((res.magnitudes[e][i] - mags[0][e][i]).abs() < 1e-9);
2219            }
2220        }
2221        for i in 0..n {
2222            let d_in = wrap_to_pi(phases[0][1][i] - phases[0][0][i]);
2223            let d_out = wrap_to_pi(res.phases[1][i] - res.phases[0][i]);
2224            assert!(wrap_to_pi(d_out - d_in).abs() < 1e-9, "voxel {}: {} vs {}", i, d_out, d_in);
2225        }
2226        // and the offset itself is gone: interior phase == field evolution
2227        let mut checked = 0;
2228        for z in 3..nz - 3 { for y in 5..ny - 5 { for x in 5..nx - 5 {
2229            let i = x + y * nx + z * nx * ny;
2230            if res.mask[i] == 0 { continue; }
2231            let err = wrap_to_pi(res.phases[0][i] - 2.0 * PI * field[i] * tes[0]).abs();
2232            assert!(err < 0.05, "voxel ({},{},{}): {}", x, y, z, err);
2233            checked += 1;
2234        }}}
2235        assert!(checked > 100);
2236    }
2237
2238    #[test]
2239    fn test_mcpc3ds_combine_identical_coils_scale_magnitude() {
2240        let (nx, ny, nz) = (12, 12, 6);
2241        let tes = [0.004, 0.009];
2242        let g = grid(nx, ny, nz);
2243        let (mut phases, mut mags, _) = synthetic_coils(nx, ny, nz, &tes);
2244        phases.truncate(1);
2245        mags.truncate(1);
2246        let (p, m) = (phases[0].clone(), mags[0].clone());
2247        phases.push(p);
2248        mags.push(m);
2249        let res = mcpc3ds_combine(&phases, &mags, &tes, [2.0, 2.0, 2.0], [0, 1], UnwrapMethod::Laplacian, &g);
2250        for i in 0..nx * ny * nz {
2251            let expected = (2.0f64).sqrt() * mags[0][1][i];
2252            assert!((res.magnitudes[1][i] - expected).abs() < 1e-6);
2253        }
2254    }
2255
2256    // --- nan-box smoothing (MriResearchTools port) ---
2257
2258    #[test]
2259    fn test_gaussian_box_sizes_match_reference() {
2260        // values computed from getboxsizes(sigma, 4) in MriResearchTools.jl
2261        assert_eq!(gaussian_box_sizes(10.0, 4), vec![17, 17, 17, 19]);
2262        assert_eq!(gaussian_box_sizes(5.0, 4), vec![7, 9, 9, 9]);
2263        assert_eq!(gaussian_box_sizes(4.0, 4), vec![7, 7, 7, 7]);
2264        assert_eq!(gaussian_box_sizes(0.0, 4), vec![1, 1, 1, 1]);
2265        assert_eq!(gaussian_box_sizes(5.0, 3), vec![9, 9, 11]); // Julia round() ties to even: m = round(1.5) = 2
2266        assert_eq!(gaussian_box_sizes(2.0, 4), vec![3, 3, 3, 5]);
2267    }
2268
2269    #[test]
2270    fn test_nan_box_filter_line_constant_with_gap() {
2271        // constant segments stay constant, and the NaN gap is filled by extrapolation
2272        let mut line = vec![3.0; 30];
2273        for v in line.iter_mut().take(20).skip(12) { *v = f64::NAN; }
2274        let mut scratch = Vec::new();
2275        nan_box_filter_line(&mut line, 5, &mut scratch);
2276        for (i, v) in line.iter().enumerate() {
2277            if v.is_finite() {
2278                assert!((v - 3.0).abs() < 1e-12, "position {} = {}", i, v);
2279            }
2280        }
2281        // filled up to r=2 samples into the gap from the left
2282        assert!(line[12].is_finite() && line[13].is_finite(), "{:?}", &line[10..16]);
2283        assert!(line[16].is_nan());
2284    }
2285
2286    #[test]
2287    fn test_nan_box_filter_line_linear_ramp_interior() {
2288        let n = 40;
2289        let mut line: Vec<f64> = (0..n).map(|i| 0.5 * i as f64).collect();
2290        let mut scratch = Vec::new();
2291        nan_box_filter_line(&mut line, 7, &mut scratch);
2292        // box mean of a linear ramp is the ramp itself where the box is fully inside
2293        for i in 7..n - 3 {
2294            assert!((line[i] - 0.5 * i as f64).abs() < 1e-9, "position {} = {}", i, line[i]);
2295        }
2296    }
2297
2298    #[test]
2299    fn test_nan_box_smooth_3d_phase_constant_inside_mask() {
2300        let (nx, ny, nz) = (48, 48, 24);
2301        let g = grid(nx, ny, nz);
2302        let n = nx * ny * nz;
2303        let mut mask = vec![0u8; n];
2304        for z in 8..nz - 8 { for y in 16..ny - 16 { for x in 16..nx - 16 {
2305            mask[x + y * nx + z * nx * ny] = 1;
2306        }}}
2307        let phase = vec![1.2f64; n];
2308        let out = nan_box_smooth_3d_phase(&phase, [3.0, 3.0, 2.0], &mask, &g);
2309        let mut checked = 0;
2310        for i in 0..n {
2311            if mask[i] > 0 && out[i].is_finite() {
2312                assert!((out[i] - 1.2).abs() < 1e-9, "voxel {}: {}", i, out[i]);
2313                checked += 1;
2314            }
2315        }
2316        let n_mask = mask.iter().filter(|&&m| m > 0).count();
2317        assert_eq!(checked, n_mask, "every mask voxel must be defined and unchanged");
2318        // fills extend at most a few box radii past the mask: the corner stays undefined
2319        assert!(out[0].is_nan());
2320        let outside_defined = (0..n).filter(|&i| mask[i] == 0 && out[i].is_finite()).count();
2321        let outside = (0..n).filter(|&i| mask[i] == 0).count();
2322        assert!(outside_defined < outside / 2, "{} of {}", outside_defined, outside);
2323    }
2324}