1use std::f64::consts::PI;
109use num_complex::Complex64;
110#[cfg(test)]
111use crate::fft::{fft3d, ifft3d};
112use crate::fft::{fftfreq, Fft3dWorkspace};
113use crate::Grid;
114
115#[inline]
117pub(crate) fn wrap(x: f64) -> f64 {
118 let mut y = x % (2.0 * PI);
119 if y > PI {
120 y -= 2.0 * PI;
121 } else if y < -PI {
122 y += 2.0 * PI;
123 }
124 y
125}
126
127#[cfg(test)]
131pub(crate) fn wrapped_laplacian_periodic(
132 phase: &[f64],
133 nx: usize, ny: usize, nz: usize,
134 vsx: f64, vsy: f64, vsz: f64,
135) -> Vec<f64> {
136 periodic_laplacian_of_differences(phase, nx, ny, nz, vsx, vsy, vsz, wrap)
137}
138
139#[cfg(test)]
142#[allow(clippy::too_many_arguments)]
143#[inline(always)]
144fn periodic_laplacian_of_differences(
145 phase: &[f64],
146 nx: usize, ny: usize, nz: usize,
147 vsx: f64, vsy: f64, vsz: f64,
148 wrap: impl Fn(f64) -> f64,
149) -> Vec<f64> {
150 let n_total = nx * ny * nz;
151 let mut d2u = vec![0.0; n_total];
152
153 let dx2 = 1.0 / (vsx * vsx);
154 let dy2 = 1.0 / (vsy * vsy);
155 let dz2 = 1.0 / (vsz * vsz);
156
157 for k in 0..nz {
158 let km1 = if k == 0 { nz - 1 } else { k - 1 };
159 let kp1 = if k + 1 >= nz { 0 } else { k + 1 };
160
161 for j in 0..ny {
162 let jm1 = if j == 0 { ny - 1 } else { j - 1 };
163 let jp1 = if j + 1 >= ny { 0 } else { j + 1 };
164
165 for i in 0..nx {
166 let im1 = if i == 0 { nx - 1 } else { i - 1 };
167 let ip1 = if i + 1 >= nx { 0 } else { i + 1 };
168
169 let idx = i + j * nx + k * nx * ny;
170 let u_ijk = phase[idx];
171
172 let idx_im1 = im1 + j * nx + k * nx * ny;
173 let idx_ip1 = ip1 + j * nx + k * nx * ny;
174 let idx_jm1 = i + jm1 * nx + k * nx * ny;
175 let idx_jp1 = i + jp1 * nx + k * nx * ny;
176 let idx_km1 = i + j * nx + km1 * nx * ny;
177 let idx_kp1 = i + j * nx + kp1 * nx * ny;
178
179 let lap_x = (wrap(phase[idx_ip1] - u_ijk) - wrap(u_ijk - phase[idx_im1])) * dx2;
180 let lap_y = (wrap(phase[idx_jp1] - u_ijk) - wrap(u_ijk - phase[idx_jm1])) * dy2;
181 let lap_z = (wrap(phase[idx_kp1] - u_ijk) - wrap(u_ijk - phase[idx_km1])) * dz2;
182
183 d2u[idx] = lap_x + lap_y + lap_z;
184 }
185 }
186 }
187
188 d2u
189}
190
191#[cfg(test)]
196pub(crate) fn solve_poisson_fft(
197 f: &[f64],
198 nx: usize, ny: usize, nz: usize,
199 vsx: f64, vsy: f64, vsz: f64,
200) -> Vec<f64> {
201 let mut f_complex: Vec<Complex64> = f.iter()
202 .map(|&x| Complex64::new(x, 0.0))
203 .collect();
204 fft3d(&mut f_complex, nx, ny, nz);
205
206 let idx2 = 1.0 / (vsx * vsx);
207 let idy2 = 1.0 / (vsy * vsy);
208 let idz2 = 1.0 / (vsz * vsz);
209
210 for k in 0..nz {
211 let fk = if k <= nz / 2 { k as f64 / nz as f64 } else { (k as f64 - nz as f64) / nz as f64 };
212 let lam_z = 2.0 * ((2.0 * PI * fk).cos() - 1.0) * idz2;
213
214 for j in 0..ny {
215 let fj = if j <= ny / 2 { j as f64 / ny as f64 } else { (j as f64 - ny as f64) / ny as f64 };
216 let lam_y = 2.0 * ((2.0 * PI * fj).cos() - 1.0) * idy2;
217
218 for i in 0..nx {
219 let fi = if i <= nx / 2 { i as f64 / nx as f64 } else { (i as f64 - nx as f64) / nx as f64 };
220 let lam_x = 2.0 * ((2.0 * PI * fi).cos() - 1.0) * idx2;
221
222 let lam = lam_x + lam_y + lam_z;
223 let idx = i + j * nx + k * nx * ny;
224
225 if lam.abs() > 1e-20 {
226 f_complex[idx] /= lam;
227 } else {
228 f_complex[idx] = Complex64::new(0.0, 0.0);
229 }
230 }
231 }
232 }
233
234 ifft3d(&mut f_complex, nx, ny, nz);
235 f_complex.iter().map(|c| c.re).collect()
236}
237
238pub(crate) fn wrapped_laplacian_neumann(
244 phase: &[f64],
245 nx: usize, ny: usize, nz: usize,
246 vsx: f64, vsy: f64, vsz: f64,
247) -> Vec<f64> {
248 let n_total = nx * ny * nz;
249 let mut d2u = vec![0.0; n_total];
250 let (dx2, dy2, dz2) = (1.0 / (vsx * vsx), 1.0 / (vsy * vsy), 1.0 / (vsz * vsz));
251
252 for k in 0..nz {
253 let km1 = k.saturating_sub(1);
254 let kp1 = if k + 1 >= nz { k } else { k + 1 };
255 for j in 0..ny {
256 let jm1 = j.saturating_sub(1);
257 let jp1 = if j + 1 >= ny { j } else { j + 1 };
258 for i in 0..nx {
259 let im1 = i.saturating_sub(1);
260 let ip1 = if i + 1 >= nx { i } else { i + 1 };
261 let idx = i + j * nx + k * nx * ny;
262 let u = phase[idx];
263 let lap_x = (wrap(phase[ip1 + j * nx + k * nx * ny] - u) - wrap(u - phase[im1 + j * nx + k * nx * ny])) * dx2;
264 let lap_y = (wrap(phase[i + jp1 * nx + k * nx * ny] - u) - wrap(u - phase[i + jm1 * nx + k * nx * ny])) * dy2;
265 let lap_z = (wrap(phase[i + j * nx + kp1 * nx * ny] - u) - wrap(u - phase[i + j * nx + km1 * nx * ny])) * dz2;
266 d2u[idx] = lap_x + lap_y + lap_z;
267 }
268 }
269 }
270 d2u
271}
272
273pub(crate) fn solve_poisson_dct(
283 f: &[f64],
284 nx: usize, ny: usize, nz: usize,
285 vsx: f64, vsy: f64, vsz: f64,
286) -> Vec<f64> {
287 use rustdct::DctPlanner;
288 let nxy = nx * ny;
289 let mut data = f.to_vec();
290 let mut planner = DctPlanner::<f64>::new();
291
292 let dct2_x = planner.plan_dct2(nx);
294 let dct2_y = planner.plan_dct2(ny);
295 let dct2_z = planner.plan_dct2(nz);
296 {
297 let mut scratch = vec![0.0; dct2_x.get_scratch_len()];
298 for row in data.chunks_mut(nx) {
299 dct2_x.process_dct2_with_scratch(row, &mut scratch);
300 }
301 }
302 {
303 let mut buf = vec![0.0; ny];
304 let mut scratch = vec![0.0; dct2_y.get_scratch_len()];
305 for k in 0..nz {
306 for i in 0..nx {
307 for j in 0..ny { buf[j] = data[i + j * nx + k * nxy]; }
308 dct2_y.process_dct2_with_scratch(&mut buf, &mut scratch);
309 for j in 0..ny { data[i + j * nx + k * nxy] = buf[j]; }
310 }
311 }
312 }
313 {
314 let mut buf = vec![0.0; nz];
315 let mut scratch = vec![0.0; dct2_z.get_scratch_len()];
316 for j in 0..ny {
317 for i in 0..nx {
318 for k in 0..nz { buf[k] = data[i + j * nx + k * nxy]; }
319 dct2_z.process_dct2_with_scratch(&mut buf, &mut scratch);
320 for k in 0..nz { data[i + j * nx + k * nxy] = buf[k]; }
321 }
322 }
323 }
324
325 let (ix2, iy2, iz2) = (1.0 / (vsx * vsx), 1.0 / (vsy * vsy), 1.0 / (vsz * vsz));
327 let lam_x: Vec<f64> = (0..nx).map(|i| 2.0 * ((PI * i as f64 / nx as f64).cos() - 1.0) * ix2).collect();
328 let lam_y: Vec<f64> = (0..ny).map(|j| 2.0 * ((PI * j as f64 / ny as f64).cos() - 1.0) * iy2).collect();
329 let lam_z: Vec<f64> = (0..nz).map(|k| 2.0 * ((PI * k as f64 / nz as f64).cos() - 1.0) * iz2).collect();
330 for (k, &lz) in lam_z.iter().enumerate() {
331 for (j, &ly) in lam_y.iter().enumerate() {
332 let row = j * nx + k * nxy;
333 for (i, &lx) in lam_x.iter().enumerate() {
334 let lam = lx + ly + lz;
335 let idx = row + i;
336 data[idx] = if lam.abs() > 1e-20 { data[idx] / lam } else { 0.0 };
337 }
338 }
339 }
340
341 let dct3_x = planner.plan_dct3(nx);
343 let dct3_y = planner.plan_dct3(ny);
344 let dct3_z = planner.plan_dct3(nz);
345 {
346 let mut buf = vec![0.0; nz];
347 let mut scratch = vec![0.0; dct3_z.get_scratch_len()];
348 for j in 0..ny {
349 for i in 0..nx {
350 for k in 0..nz { buf[k] = data[i + j * nx + k * nxy]; }
351 dct3_z.process_dct3_with_scratch(&mut buf, &mut scratch);
352 for k in 0..nz { data[i + j * nx + k * nxy] = buf[k]; }
353 }
354 }
355 }
356 {
357 let mut buf = vec![0.0; ny];
358 let mut scratch = vec![0.0; dct3_y.get_scratch_len()];
359 for k in 0..nz {
360 for i in 0..nx {
361 for j in 0..ny { buf[j] = data[i + j * nx + k * nxy]; }
362 dct3_y.process_dct3_with_scratch(&mut buf, &mut scratch);
363 for j in 0..ny { data[i + j * nx + k * nxy] = buf[j]; }
364 }
365 }
366 }
367 {
368 let mut scratch = vec![0.0; dct3_x.get_scratch_len()];
369 for row in data.chunks_mut(nx) {
370 dct3_x.process_dct3_with_scratch(row, &mut scratch);
371 }
372 }
373 let norm = 8.0 / (nx as f64 * ny as f64 * nz as f64);
374 for v in data.iter_mut() { *v *= norm; }
375 data
376}
377
378#[cfg(test)]
382fn laplacian_unwrap_even_extended_reference(
383 phase: &[f64],
384 mask: &[u8],
385 grid: &Grid,
386) -> Vec<f64> {
387 let (nx, ny, nz) = grid.dims;
388 let (vsx, vsy, vsz) = grid.voxel_size;
389 let n_total = nx * ny * nz;
390
391 let (px, py, pz) = (2 * nx, 2 * ny, 2 * nz);
394 let mut ext = vec![0.0f64; px * py * pz];
395 for k in 0..pz {
396 let sk = if k < nz { k } else { 2 * nz - 1 - k };
397 for j in 0..py {
398 let sj = if j < ny { j } else { 2 * ny - 1 - j };
399 for i in 0..px {
400 let si = if i < nx { i } else { 2 * nx - 1 - i };
401 ext[i + j * px + k * px * py] = phase[si + sj * nx + sk * nx * ny];
402 }
403 }
404 }
405
406 let d2u = wrapped_laplacian_periodic(&ext, px, py, pz, vsx, vsy, vsz);
409 let solved = solve_poisson_fft(&d2u, px, py, pz, vsx, vsy, vsz);
410
411 let mut result = vec![0.0; n_total];
412 for k in 0..nz {
413 for j in 0..ny {
414 for i in 0..nx {
415 let dst = i + j * nx + k * nx * ny;
416 if mask[dst] != 0 {
417 result[dst] = solved[i + j * px + k * px * py];
418 }
419 }
420 }
421 }
422 result
423}
424
425#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
427#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
428pub enum LaplacianSolver {
429 #[default]
433 Dct,
434 Fft {
438 pad: [usize; 3],
440 },
441 Mirror,
447}
448
449impl LaplacianSolver {
450 pub const FFT_DEFAULT_PAD: LaplacianSolver = LaplacianSolver::Fft { pad: [12, 12, 12] };
452}
453
454pub fn laplacian_unwrap(
482 phase: &[f64],
483 mask: &[u8],
484 grid: &Grid,
485 solver: LaplacianSolver,
486) -> Vec<f64> {
487 let (nx, ny, nz) = grid.dims;
488 let (vsx, vsy, vsz) = grid.voxel_size;
489 let u = match solver {
490 LaplacianSolver::Dct => {
491 let d2u = wrapped_laplacian_neumann(phase, nx, ny, nz, vsx, vsy, vsz);
492 solve_poisson_dct(&d2u, nx, ny, nz, vsx, vsy, vsz)
493 }
494 LaplacianSolver::Fft { pad } => laplacian_unwrap_fft_padded(phase, grid, pad),
495 LaplacianSolver::Mirror => laplacian_unwrap_fft_mirrored(phase, grid),
496 };
497 u.iter().zip(mask).map(|(&v, &m)| if m != 0 { v } else { 0.0 }).collect()
498}
499
500fn laplacian_unwrap_fft_padded(phase: &[f64], grid: &Grid, pad: [usize; 3]) -> Vec<f64> {
503 let (nx, ny, nz) = grid.dims;
504 let (vsx, vsy, vsz) = grid.voxel_size;
505 assert_eq!(phase.len(), nx * ny * nz, "phase length does not match grid");
506 let [px, py, pz] = pad;
507 let even = |n: usize| n + (n % 2);
508 let (mx, my, mz) = (even(nx + 2 * px), even(ny + 2 * py), even(nz + 2 * pz));
509 let mxy = mx * my;
510
511 let k2 = |n: usize, h: f64| -> Vec<f64> { fftfreq(n, h).into_iter().map(|f| f * f).collect() };
512 let (kx, ky, kz) = (k2(mx, vsx), k2(my, vsy), k2(mz, vsz));
513 let sincos = |i: usize, j: usize, k: usize| -> (f64, f64) {
515 match (i.checked_sub(px), j.checked_sub(py), k.checked_sub(pz)) {
516 (Some(i), Some(j), Some(k)) if i < nx && j < ny && k < nz => phase[i + j * nx + k * nx * ny].sin_cos(),
517 _ => (0.0, 1.0),
518 }
519 };
520
521 let mut ws = Fft3dWorkspace::new(mx, my, mz);
522 let mut buf = vec![Complex64::new(0.0, 0.0); mxy * mz];
523
524 for k in 0..mz {
527 for j in 0..my {
528 for i in 0..mx {
529 let (s, c) = sincos(i, j, k);
530 buf[i + j * mx + k * mxy] = Complex64::new(s, c);
531 }
532 }
533 }
534 ws.fft3d(&mut buf);
535 apply_k2(&mut buf, &kx, &ky, &kz, false);
536 ws.ifft3d(&mut buf);
537
538 for k in 0..mz {
540 for j in 0..my {
541 for i in 0..mx {
542 let idx = i + j * mx + k * mxy;
543 let (s, c) = sincos(i, j, k);
544 let v = buf[idx];
545 buf[idx] = Complex64::new(c * v.re - s * v.im, 0.0);
546 }
547 }
548 }
549
550 ws.fft3d(&mut buf);
552 apply_k2(&mut buf, &kx, &ky, &kz, true);
553 ws.ifft3d(&mut buf);
554
555 let mut out = vec![0.0; nx * ny * nz];
556 for k in 0..nz {
557 for j in 0..ny {
558 for i in 0..nx {
559 out[i + j * nx + k * nx * ny] = buf[(i + px) + (j + py) * mx + (k + pz) * mxy].re;
560 }
561 }
562 }
563 out
564}
565
566fn laplacian_unwrap_fft_mirrored(phase: &[f64], grid: &Grid) -> Vec<f64> {
569 let (nx, ny, nz) = grid.dims;
570 let (vsx, vsy, vsz) = grid.voxel_size;
571 assert_eq!(phase.len(), nx * ny * nz, "phase length does not match grid");
572 let (mx, my, mz) = (2 * nx, 2 * ny, 2 * nz);
573 let mxy = mx * my;
574 let k2 = |n: usize, h: f64| -> Vec<f64> { fftfreq(n, h).into_iter().map(|f| f * f).collect() };
575 let (kx, ky, kz) = (k2(mx, vsx), k2(my, vsy), k2(mz, vsz));
576 let fold = |i: usize, n: usize| if i < n { i } else { 2 * n - 1 - i };
577 let sincos = |i: usize, j: usize, k: usize| phase[fold(i, nx) + fold(j, ny) * nx + fold(k, nz) * nx * ny].sin_cos();
578
579 let mut ws = Fft3dWorkspace::new(mx, my, mz);
580 let mut buf = vec![Complex64::new(0.0, 0.0); mxy * mz];
581 for k in 0..mz {
582 for j in 0..my {
583 for i in 0..mx {
584 let (s, c) = sincos(i, j, k);
585 buf[i + j * mx + k * mxy] = Complex64::new(s, c);
586 }
587 }
588 }
589 ws.fft3d(&mut buf);
590 apply_k2(&mut buf, &kx, &ky, &kz, false);
591 ws.ifft3d(&mut buf);
592 for k in 0..mz {
593 for j in 0..my {
594 for i in 0..mx {
595 let idx = i + j * mx + k * mxy;
596 let (s, c) = sincos(i, j, k);
597 let v = buf[idx];
598 buf[idx] = Complex64::new(c * v.re - s * v.im, 0.0);
599 }
600 }
601 }
602 ws.fft3d(&mut buf);
603 apply_k2(&mut buf, &kx, &ky, &kz, true);
604 ws.ifft3d(&mut buf);
605
606 let mut out = vec![0.0; nx * ny * nz];
607 for k in 0..nz {
608 for j in 0..ny {
609 for i in 0..nx {
610 out[i + j * nx + k * nx * ny] = buf[i + j * mx + k * mxy].re;
611 }
612 }
613 }
614 out
615}
616
617fn apply_k2(buf: &mut [Complex64], kx: &[f64], ky: &[f64], kz: &[f64], invert: bool) {
619 let (mx, my) = (kx.len(), ky.len());
620 let plane = |(k, slab): (usize, &mut [Complex64])| {
621 for j in 0..my {
622 let kyz = ky[j] + kz[k];
623 for (v, &kxi) in slab[j * mx..(j + 1) * mx].iter_mut().zip(kx) {
624 let kk = kxi + kyz;
625 if invert {
626 *v = if kk == 0.0 { Complex64::new(0.0, 0.0) } else { *v / kk };
627 } else {
628 *v *= kk;
629 }
630 }
631 }
632 };
633 #[cfg(feature = "parallel")]
634 {
635 use rayon::prelude::*;
636 buf.par_chunks_mut(mx * my).enumerate().for_each(plane);
637 }
638 #[cfg(not(feature = "parallel"))]
639 buf.chunks_mut(mx * my).enumerate().for_each(plane);
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645
646 #[test]
647 fn mirror_matches_lapunwrap() {
648 let (nx, ny, nz) = (8, 6, 5);
651 let grid = Grid::new(nx, ny, nz, 1.0, 1.5, 2.0);
652 let phase: Vec<f64> = (0..nx * ny * nz)
653 .map(|i| {
654 let (a, b, c) = ((i % nx) as f64, ((i / nx) % ny) as f64, (i / (nx * ny)) as f64);
655 let t = 0.9 * a * a / 4.0 + 1.7 * b - 0.6 * c * c + 0.3 * (i as f64).sin();
656 t.sin().atan2(t.cos())
657 })
658 .collect();
659 let mut u = laplacian_unwrap(&phase, &vec![1; phase.len()], &grid, LaplacianSolver::Mirror);
660 let mean = u.iter().sum::<f64>() / u.len() as f64;
661 u.iter_mut().for_each(|v| *v -= mean);
662 let close = |a: f64, b: f64| assert!((a - b).abs() <= 1e-10 * b.abs().max(1.0), "{a} vs {b}");
663 close(u.iter().map(|v| v.abs()).sum(), 809.9262085647304);
664 close(u.iter().map(|v| v * v).sum(), 4077.856347779972);
665 close(u[0], -5.778520101297622);
666 close(u[7], 2.016668604865187);
667 close(u[100], -4.332690703087126);
668 }
669
670 fn grid(n: usize) -> Grid {
671 Grid::new(n, n, n, 1.0, 1.0, 1.0)
672 }
673
674 #[test]
675 fn test_wrap() {
676 assert!((wrap(0.0) - 0.0).abs() < 1e-10);
677 assert!((wrap(PI) - PI).abs() < 1e-10);
678 assert!((wrap(-PI) - (-PI)).abs() < 1e-10);
679 assert!((wrap(2.0 * PI) - 0.0).abs() < 1e-10);
680 assert!((wrap(3.0 * PI) - PI).abs() < 1e-10);
681 assert!((wrap(-3.0 * PI) - (-PI)).abs() < 1e-10);
682 }
683
684 fn corr_slope(obs: &[f64], want: &[f64], mask: &[u8]) -> (f64, f64) {
686 let (mut sa, mut sb, mut n) = (0.0, 0.0, 0usize);
687 for i in 0..obs.len() {
688 if mask[i] != 0 { sa += obs[i]; sb += want[i]; n += 1; }
689 }
690 let nf = n as f64;
691 let (ma, mb) = (sa / nf, sb / nf);
692 let (mut cov, mut va, mut vb) = (0.0, 0.0, 0.0);
693 for i in 0..obs.len() {
694 if mask[i] == 0 { continue; }
695 let (da, db) = (obs[i] - ma, want[i] - mb);
696 cov += da * db;
697 va += da * da;
698 vb += db * db;
699 }
700 (cov / (va.sqrt() * vb.sqrt()), cov / va)
701 }
702 type RampPlusBlob = (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<u8>);
704
705
706 fn ramp_plus_blob(n: usize) -> RampPlusBlob {
710 let c = n as f64 / 2.0;
711 let total = n * n * n;
712 let (mut truth, mut ramp, mut blob) = (vec![0.0; total], vec![0.0; total], vec![0.0; total]);
713 let mut mask = vec![0u8; total];
714 for k in 0..n {
715 for j in 0..n {
716 for i in 0..n {
717 let idx = i + j * n + k * n * n;
718 let (x, y, z) = (i as f64 - c, j as f64 - c, k as f64 - c);
719 let r = (x * x + y * y + z * z).sqrt();
720 ramp[idx] = 0.35 * x + 0.20 * y + 0.15 * z;
721 blob[idx] = 12.0 * (-(r * r) / (2.0 * 8.0f64.powi(2))).exp();
722 truth[idx] = ramp[idx] + blob[idx];
723 if r < 22.0 { mask[idx] = 1; }
724 }
725 }
726 }
727 let wrapped: Vec<f64> = truth.iter().map(|&v| wrap(v)).collect();
728 (wrapped, truth, ramp, blob, mask)
729 }
730
731 #[test]
732 fn laplacian_unwrap_recovers_the_whole_field_including_background() {
733 let n = 64;
734 let (wrapped, truth, _, _, mask) = ramp_plus_blob(n);
735
736 let out = laplacian_unwrap(&wrapped, &mask, &grid(n), LaplacianSolver::Dct);
737 let (r, slope) = corr_slope(&out, &truth, &mask);
738
739 assert!(r > 0.99, "should recover the total field, got r = {r}");
740 assert!((slope - 1.0).abs() < 0.05, "expected unit slope, got {slope}");
741 }
742
743 #[test]
744 fn dct_solve_matches_the_even_extended_solve() {
745 let n = 64;
748 let (wrapped, _, _, _, mask) = ramp_plus_blob(n);
749 let padded = laplacian_unwrap_even_extended_reference(&wrapped, &mask, &grid(n));
750 let dct = laplacian_unwrap(&wrapped, &mask, &grid(n), LaplacianSolver::Dct);
751 let scale = padded.iter().fold(0.0f64, |m, &v| m.max(v.abs()));
752 let max_diff = padded.iter().zip(&dct).fold(0.0f64, |m, (&a, &b)| m.max((a - b).abs()));
753 assert!(max_diff < 1e-9 * scale, "DCT and padded solves differ: max |diff| = {max_diff} (scale {scale})");
754 }
755
756 fn fft(p: &[f64], grid: &Grid, pad: [usize; 3]) -> Vec<f64> {
760 laplacian_unwrap(p, &vec![1u8; p.len()], grid, LaplacianSolver::Fft { pad })
761 }
762
763 #[test]
764 fn dct_is_the_default_solver() {
765 assert_eq!(LaplacianSolver::default(), LaplacianSolver::Dct);
766 }
767
768 fn sti_golden_input() -> (Vec<f64>, Grid) {
771 let (nx, ny, nz) = (8, 8, 6);
772 let mut p = vec![0.0; nx * ny * nz];
773 for k in 0..nz {
774 for j in 0..ny {
775 for i in 0..nx {
776 let (a, b, c) = ((i + 1) as f64, (j + 1) as f64, (k + 1) as f64);
777 let t = 2.1 * (0.7 * a).sin() + 1.3 * (0.4 * b * c).cos() + 0.9 * a - 0.6 * c + 0.05 * a * b;
778 p[i + j * nx + k * nx * ny] = t.sin().atan2(t.cos());
779 }
780 }
781 }
782 (p, Grid::new(nx, ny, nz, 1.0, 1.0, 2.0))
783 }
784
785 #[test]
786 fn fft_matches_sti_golden_values() {
787 let (p, grid) = sti_golden_input();
790 let u = fft(&p, &grid, [2, 3, 1]);
791 let sum: f64 = u.iter().sum();
792 let sumsq: f64 = u.iter().map(|v| v * v).sum();
793 assert!((sum - STI_GOLDEN.0).abs() < 1e-9, "sum {sum} vs STI {}", STI_GOLDEN.0);
794 assert!((sumsq - STI_GOLDEN.1).abs() < 1e-9, "sum sq {sumsq} vs STI {}", STI_GOLDEN.1);
795 for &(idx, want) in STI_GOLDEN.2 {
796 assert!((u[idx] - want).abs() < 1e-12, "voxel {idx}: {} vs STI {want}", u[idx]);
797 }
798 }
799
800 #[allow(clippy::type_complexity, clippy::excessive_precision)]
802 const STI_GOLDEN: (f64, f64, &[(usize, f64)]) = (
803 -47.110605048249184,
804 278.05516319999214,
805 &[
806 (0, 0.035944170373255008), (1, 0.54603271528608766), (8, -0.12289609617968449),
807 (64, 0.17121148831819896), (99, -0.58598953742943161), (199, 0.35334342671042662),
808 (332, -0.18693470845945748), (383, 1.1196905106581743),
809 ],
810 );
811
812 #[test]
813 fn fft_invariant_to_two_pi_jumps() {
814 let (p, grid) = sti_golden_input();
816 let jumped: Vec<f64> = p.iter().enumerate()
817 .map(|(i, &v)| v + 2.0 * PI * ((i * 7919) % 5) as f64 - 4.0 * PI)
818 .collect();
819 let a = fft(&p, &grid, [12; 3]);
820 let b = fft(&jumped, &grid, [12; 3]);
821 let d = a.iter().zip(&b).fold(0.0f64, |m, (x, y)| m.max((x - y).abs()));
822 assert!(d < 1e-12, "max |diff| {d}");
823 }
824
825 fn wrapped_blob_vs(n: (usize, usize, usize), vs: (f64, f64, f64)) -> (Vec<f64>, Vec<f64>, Grid) {
827 let grid = Grid::new(n.0, n.1, n.2, vs.0, vs.1, vs.2);
828 let c = (n.0 as f64 / 2.0 * vs.0, n.1 as f64 / 2.0 * vs.1, n.2 as f64 / 2.0 * vs.2);
829 let mut truth = vec![0.0; grid.n_total()];
830 for k in 0..n.2 {
831 for j in 0..n.1 {
832 for i in 0..n.0 {
833 let (x, y, z) = (i as f64 * vs.0 - c.0, j as f64 * vs.1 - c.1, k as f64 * vs.2 - c.2);
834 truth[i + j * n.0 + k * n.0 * n.1] = 20.0 * (-(x * x + y * y + z * z) / (2.0 * 9.0f64.powi(2))).exp();
835 }
836 }
837 }
838 let wrapped = truth.iter().map(|&v| wrap(v)).collect();
839 (wrapped, truth, grid)
840 }
841
842 fn max_dev_after_mean(a: &[f64], b: &[f64]) -> f64 {
843 let n = a.len() as f64;
844 let off = a.iter().zip(b).map(|(x, y)| x - y).sum::<f64>() / n;
845 a.iter().zip(b).fold(0.0f64, |m, (x, y)| m.max((x - y - off).abs()))
846 }
847
848 #[test]
849 fn fft_unwraps_a_smooth_field() {
850 for vs in [(1.0, 1.0, 1.0), (0.8, 0.8, 1.2), (1.2, 0.9, 1.0)] {
855 let (wrapped, truth, grid) = wrapped_blob_vs((64, 64, 48), vs);
856 let u = fft(&wrapped, &grid, [12; 3]);
857 let d = max_dev_after_mean(&u, &truth);
858 assert!(d < 0.05, "voxel size {vs:?}: max deviation {d} rad");
859 }
860 }
861
862 #[test]
863 fn fft_odd_dimensions_keep_the_input_size() {
864 let (wrapped, truth, grid) = wrapped_blob_vs((61, 63, 47), (1.0, 1.0, 1.0));
865 for pad in [[0, 0, 0], [3, 4, 5]] {
866 let u = fft(&wrapped, &grid, pad);
867 assert_eq!(u.len(), grid.n_total());
868 assert!(max_dev_after_mean(&u, &truth) < 0.05);
869 }
870 }
871
872 #[test]
873 fn fft_dc_of_the_padded_box_is_zero() {
874 let (wrapped, _, grid) = wrapped_blob_vs((32, 32, 24), (1.0, 1.0, 1.0));
876 let u = fft(&wrapped, &grid, [0, 0, 0]);
877 let mean = u.iter().sum::<f64>() / u.len() as f64;
878 assert!(mean.abs() < 1e-12, "mean {mean}");
879 }
880
881 #[test]
882 fn fft_zero_padding_costs_total_field_fidelity() {
883 let n = 48;
888 let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
889 let c = n as f64 / 2.0;
890 let mut truth = vec![0.0; n * n * n];
891 for k in 0..n { for j in 0..n { for i in 0..n {
892 let (x, y, z) = (i as f64 - c, j as f64 - c, k as f64 - c);
893 truth[i + j * n + k * n * n] = 0.35 * x + 0.2 * y + 0.15 * z
894 + 12.0 * (-(x * x + y * y + z * z) / 128.0).exp();
895 }}}
896 let wrapped: Vec<f64> = truth.iter().map(|&v| wrap(v)).collect();
897 let mask = vec![1u8; truth.len()];
898 let r = |u: &[f64]| {
899 let m = |v: &[f64]| v.iter().sum::<f64>() / v.len() as f64;
900 let (mu, mt) = (m(u), m(&truth));
901 let (mut cov, mut vu, mut vt) = (0.0, 0.0, 0.0);
902 for (a, b) in u.iter().zip(&truth) { cov += (a - mu) * (b - mt); vu += (a - mu).powi(2); vt += (b - mt).powi(2); }
903 cov / (vu * vt).sqrt()
904 };
905 let sti = fft(&wrapped, &grid, [12; 3]);
906 let neumann = laplacian_unwrap(&wrapped, &mask, &grid, LaplacianSolver::Dct);
907 assert!(r(&neumann) > 0.99, "Neumann r = {}", r(&neumann));
908 assert!(r(&sti) < 0.9, "STI r = {}", r(&sti));
909 }
910}