Skip to main content

oxiphysics_core/multiscale_methods/
functions.rs

1//! Auto-generated module
2//!
3//! πŸ€– Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5#![allow(clippy::needless_range_loop, clippy::ptr_arg)]
6use std::f64::consts::PI;
7
8use super::types::{Atom, HmmConfig, HomogenizationResult, NebImage, PhaseFieldParams, UnitCell};
9
10/// Dot product of two 3D vectors.
11#[inline]
12pub fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
13    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
14}
15/// Element-wise addition of two 3D vectors.
16#[inline]
17pub fn add3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
18    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
19}
20/// Element-wise subtraction of two 3D vectors.
21#[inline]
22pub fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
23    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
24}
25/// Scale a 3D vector by a scalar.
26#[inline]
27pub fn scale3(v: [f64; 3], s: f64) -> [f64; 3] {
28    [v[0] * s, v[1] * s, v[2] * s]
29}
30/// Euclidean norm of a 3D vector.
31#[inline]
32pub fn norm3(v: [f64; 3]) -> f64 {
33    dot3(v, v).sqrt()
34}
35/// Normalize a 3D vector to unit length.
36#[inline]
37pub fn normalize3(v: [f64; 3]) -> [f64; 3] {
38    let n = norm3(v);
39    if n < 1e-300 {
40        [0.0; 3]
41    } else {
42        scale3(v, 1.0 / n)
43    }
44}
45/// Multiply a 3Γ—3 row-major matrix by a 3-vector.
46#[inline]
47pub fn mat3_vec(m: &[f64; 9], v: [f64; 3]) -> [f64; 3] {
48    [
49        m[0] * v[0] + m[1] * v[1] + m[2] * v[2],
50        m[3] * v[0] + m[4] * v[1] + m[5] * v[2],
51        m[6] * v[0] + m[7] * v[1] + m[8] * v[2],
52    ]
53}
54/// Add two 3Γ—3 matrices stored row-major.
55#[inline]
56pub fn mat3_add(a: &[f64; 9], b: &[f64; 9]) -> [f64; 9] {
57    let mut r = [0.0f64; 9];
58    for i in 0..9 {
59        r[i] = a[i] + b[i];
60    }
61    r
62}
63/// Scale a 3Γ—3 matrix.
64#[inline]
65pub fn mat3_scale(m: &[f64; 9], s: f64) -> [f64; 9] {
66    let mut r = [0.0f64; 9];
67    for i in 0..9 {
68        r[i] = m[i] * s;
69    }
70    r
71}
72/// Identity 3Γ—3 matrix.
73#[inline]
74pub fn mat3_identity() -> [f64; 9] {
75    [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]
76}
77/// Frobenius norm of a 3Γ—3 matrix.
78#[inline]
79pub fn mat3_frob(m: &[f64; 9]) -> f64 {
80    m.iter().map(|x| x * x).sum::<f64>().sqrt()
81}
82/// Compute homogenized (effective) properties of a two-phase unit cell.
83///
84/// Uses arithmetic/harmonic averaging for Voigt/Reuss bounds and the
85/// classical Hashin-Shtrikman estimates for a two-phase composite.
86///
87/// # Arguments
88/// * `cell`  – The periodic unit cell.
89/// * `prop1` – Property of phase 1 (larger value).
90/// * `prop2` – Property of phase 2 (smaller value).
91pub fn homogenize_two_phase(cell: &UnitCell, prop1: f64, prop2: f64) -> HomogenizationResult {
92    let n = cell.property.len() as f64;
93    let n1 = cell
94        .property
95        .iter()
96        .filter(|&&p| (p - prop1).abs() < 1e-10)
97        .count();
98    let f1 = n1 as f64 / n;
99    let f2 = 1.0 - f1;
100    let voigt = f1 * prop1 + f2 * prop2;
101    let reuss = 1.0 / (f1 / prop1 + f2 / prop2);
102    let hs_upper = prop1 + f2 / (1.0 / (prop2 - prop1) + f1 / (3.0 * prop1));
103    let hs_lower = prop2 + f1 / (1.0 / (prop1 - prop2) + f2 / (3.0 * prop2));
104    let effective = 0.5 * (hs_upper + hs_lower);
105    HomogenizationResult {
106        effective_property: effective,
107        voigt_bound: voigt,
108        reuss_bound: reuss,
109        hs_upper,
110        hs_lower,
111        volume_fraction: f1,
112    }
113}
114/// Solve the scalar periodic cell problem via finite differences to obtain
115/// the effective conductivity in the x-direction.
116///
117/// The cell problem: -βˆ‡Β·(k βˆ‡Ο‡) = βˆ‚k/βˆ‚x with periodic BCs.
118/// Here we use a simple 1-D iterative solve across a column of voxels.
119///
120/// # Arguments
121/// * `cell`       – Unit cell with nx Γ— ny voxels (nz=1 assumed for 2-D).
122/// * `max_iter`   – Maximum Gauss-Seidel iterations.
123/// * `tol`        – Convergence tolerance on relative residual.
124pub fn cell_problem_1d(cell: &UnitCell, max_iter: usize, tol: f64) -> Vec<f64> {
125    let n = cell.nx;
126    let mut chi = vec![0.0f64; n];
127    for _iter in 0..max_iter {
128        let mut max_change = 0.0f64;
129        for i in 0..n {
130            let im = (i + n - 1) % n;
131            let ip = (i + 1) % n;
132            let km = 0.5 * (cell.property[i] + cell.property[im]);
133            let kp = 0.5 * (cell.property[i] + cell.property[ip]);
134            let rhs = (kp - km) / cell.h;
135            let new_val = (km * chi[im] + kp * chi[ip] - rhs * cell.h * cell.h) / (km + kp);
136            max_change = max_change.max((new_val - chi[i]).abs());
137            chi[i] = new_val;
138        }
139        if max_change < tol {
140            break;
141        }
142    }
143    chi
144}
145/// Compress micro-scale simulation data into a macro-scale flux estimate.
146///
147/// This implements the data-estimation step of HMM: run the micro solver
148/// for `config.micro_steps` steps, then average the micro fluxes over the
149/// steady-state window.
150///
151/// # Arguments
152/// * `config`       – HMM configuration parameters.
153/// * `macro_strain` – Applied strain at the macro quadrature point.
154/// * `property_fn`  – Function giving the local micro property at position x ∈ \[0, L\].
155pub fn hmm_estimate_flux<F>(config: &HmmConfig, macro_strain: f64, property_fn: F) -> f64
156where
157    F: Fn(f64) -> f64,
158{
159    let n = 16usize;
160    let h = config.micro_domain_size / n as f64;
161    let mut t = vec![0.0f64; n];
162    for i in 0..n {
163        let x = (i as f64 + 0.5) * h;
164        t[i] = macro_strain * x;
165    }
166    for _step in 0..config.micro_steps {
167        let mut t_new = t.clone();
168        for i in 1..(n - 1) {
169            let x = (i as f64) * h;
170            let km = property_fn(x - 0.5 * h);
171            let kp = property_fn(x + 0.5 * h);
172            t_new[i] = (km * t[i - 1] + kp * t[i + 1]) / (km + kp);
173        }
174        t = t_new;
175    }
176    let flux_sum: f64 = (0..n - 1)
177        .map(|i| {
178            let x = (i as f64 + 0.5) * h;
179            let k = property_fn(x);
180            -k * (t[i + 1] - t[i]) / h
181        })
182        .sum();
183    flux_sum / (n - 1) as f64
184}
185/// Lennard-Jones pair potential energy between two atoms.
186///
187/// `U(r) = 4Ξ΅[(Οƒ/r)^12 - (Οƒ/r)^6]`
188pub fn lennard_jones_potential(r: f64, epsilon: f64, sigma: f64) -> f64 {
189    let s_over_r = sigma / r.max(1e-15);
190    let sr6 = s_over_r.powi(6);
191    4.0 * epsilon * (sr6 * sr6 - sr6)
192}
193/// Lennard-Jones force magnitude (scalar, positive = repulsive).
194///
195/// `F(r) = 24Ξ΅/r [2(Οƒ/r)^12 - (Οƒ/r)^6]`
196pub fn lennard_jones_force(r: f64, epsilon: f64, sigma: f64) -> f64 {
197    let s_over_r = sigma / r.max(1e-15);
198    let sr6 = s_over_r.powi(6);
199    24.0 * epsilon / r * (2.0 * sr6 * sr6 - sr6)
200}
201/// Cauchy-Born rule: compute the first Piola-Kirchhoff stress from a
202/// deformation gradient `F` using a linearised atomistic model.
203///
204/// For a simple cubic lattice with LJ bonds, the effective stress in the
205/// x-direction (1D simplification) is:
206///
207/// `P β‰ˆ C * (F - 1)` where `C` is the tangent modulus at the reference bond length.
208pub fn cauchy_born_stress_1d(
209    f_deformation: f64,
210    epsilon: f64,
211    sigma: f64,
212    lattice_const: f64,
213) -> f64 {
214    let r0 = lattice_const;
215    let r = f_deformation * r0;
216    let force = lennard_jones_force(r, epsilon, sigma);
217    force * f_deformation
218}
219/// Hardy stress tensor estimator for the atomistic region.
220///
221/// Computes the virial stress tensor (3Γ—3) by summing pair contributions
222/// from a list of atom pairs.
223///
224/// # Arguments
225/// * `atoms`   – Slice of all atoms.
226/// * `pairs`   – Pairs `(i, j)` of interacting atoms.
227/// * `epsilon` – LJ well depth.
228/// * `sigma`   – LJ length scale.
229/// * `volume`  – Reference volume of the atomistic region.
230pub fn virial_stress_tensor(
231    atoms: &[Atom],
232    pairs: &[(usize, usize)],
233    epsilon: f64,
234    sigma: f64,
235    volume: f64,
236) -> [f64; 9] {
237    let mut sigma_v = [0.0f64; 9];
238    for &(i, j) in pairs {
239        if i >= atoms.len() || j >= atoms.len() {
240            continue;
241        }
242        let r_vec = sub3(atoms[j].pos, atoms[i].pos);
243        let r = norm3(r_vec);
244        if r < 1e-12 {
245            continue;
246        }
247        let f_mag = lennard_jones_force(r, epsilon, sigma);
248        let f_vec = scale3(normalize3(r_vec), f_mag);
249        for a in 0..3 {
250            for b in 0..3 {
251                sigma_v[a * 3 + b] -= r_vec[a] * f_vec[b];
252            }
253        }
254    }
255    mat3_scale(&sigma_v, 1.0 / volume.max(1e-300))
256}
257/// Solve 1-D steady-state diffusion with heterogeneous effective conductivity
258/// using a finite-difference discretisation.
259///
260/// `-d/dx [K_eff(x) dT/dx] = f(x)` with T(0) = T_left, T(L) = T_right.
261///
262/// # Arguments
263/// * `n`          – Number of internal nodes (total nodes = n+2 including BCs).
264/// * `length`     – Domain length L.
265/// * `k_eff`      – Effective conductivity at each of the n internal nodes.
266/// * `source`     – Source term at each internal node.
267/// * `t_left`     – Left Dirichlet BC.
268/// * `t_right`    – Right Dirichlet BC.
269///
270/// Returns the temperature field at all n+2 nodes (including boundaries).
271pub fn solve_macro_diffusion_1d(
272    n: usize,
273    length: f64,
274    k_eff: &[f64],
275    source: &[f64],
276    t_left: f64,
277    t_right: f64,
278) -> Vec<f64> {
279    let h = length / (n + 1) as f64;
280    let mut t = vec![0.0f64; n + 2];
281    t[0] = t_left;
282    t[n + 1] = t_right;
283    for _iter in 0..10_000 {
284        let mut max_change = 0.0f64;
285        for i in 1..=n {
286            let ii = i - 1;
287            let km = if ii == 0 {
288                k_eff[0]
289            } else {
290                0.5 * (k_eff[ii] + k_eff[ii - 1])
291            };
292            let kp = if ii + 1 >= k_eff.len() {
293                k_eff[k_eff.len() - 1]
294            } else {
295                0.5 * (k_eff[ii] + k_eff[ii + 1])
296            };
297            let rhs = source[ii] * h * h;
298            let t_new = (km * t[i - 1] + kp * t[i + 1] + rhs) / (km + kp);
299            max_change = max_change.max((t_new - t[i]).abs());
300            t[i] = t_new;
301        }
302        if max_change < 1e-12 {
303            break;
304        }
305    }
306    t
307}
308/// Compute the centre-of-mass velocity of a group of atoms.
309pub fn centre_of_mass_velocity(atoms: &[Atom]) -> [f64; 3] {
310    let mut mom = [0.0f64; 3];
311    let mut total_mass = 0.0f64;
312    for a in atoms {
313        mom = add3(mom, scale3(a.vel, a.mass));
314        total_mass += a.mass;
315    }
316    if total_mass < 1e-300 {
317        [0.0; 3]
318    } else {
319        scale3(mom, 1.0 / total_mass)
320    }
321}
322/// Compute the instantaneous temperature of a group of atoms via the
323/// equipartition theorem: `T = 2 KE / (3 N k_B)`.
324pub fn atomistic_temperature(atoms: &[Atom], k_boltzmann: f64) -> f64 {
325    let ke: f64 = atoms.iter().map(|a| a.kinetic_energy()).sum();
326    let n = atoms.len() as f64;
327    if n < 1.0 || k_boltzmann < 1e-300 {
328        0.0
329    } else {
330        2.0 * ke / (3.0 * n * k_boltzmann)
331    }
332}
333/// Compute the mean squared displacement (MSD) of atoms relative to their
334/// reference positions.
335pub fn mean_squared_displacement(atoms: &[Atom], reference: &[[f64; 3]]) -> f64 {
336    let n = atoms.len().min(reference.len());
337    if n == 0 {
338        return 0.0;
339    }
340    let msd: f64 = atoms
341        .iter()
342        .take(n)
343        .zip(reference.iter())
344        .map(|(a, r)| {
345            let d = sub3(a.pos, *r);
346            dot3(d, d)
347        })
348        .sum();
349    msd / n as f64
350}
351/// Restriction operator: project a fine-grid vector onto a coarse grid
352/// by L2-projection (average over blocks).
353pub fn restriction_l2(fine: &[f64], ratio: usize) -> Vec<f64> {
354    fine.chunks(ratio)
355        .map(|chunk| chunk.iter().sum::<f64>() / chunk.len() as f64)
356        .collect()
357}
358/// Prolongation operator: interpolate a coarse-grid vector to the fine grid
359/// using piecewise-linear interpolation.
360pub fn prolongation_linear(coarse: &[f64], ratio: usize) -> Vec<f64> {
361    let n_fine = coarse.len() * ratio;
362    let mut fine = vec![0.0f64; n_fine];
363    for fi in 0..n_fine {
364        let ci_f = fi as f64 / ratio as f64;
365        let ci0 = (ci_f as usize).min(coarse.len() - 1);
366        let ci1 = (ci0 + 1).min(coarse.len() - 1);
367        let alpha = ci_f - ci0 as f64;
368        fine[fi] = (1.0 - alpha) * coarse[ci0] + alpha * coarse[ci1];
369    }
370    fine
371}
372/// Multigrid V-cycle for a 1-D Poisson problem.
373///
374/// Recursively applies pre-smoothing, coarse-grid correction, and
375/// post-smoothing via Gauss-Seidel.
376///
377/// # Arguments
378/// * `u`         – Current solution approximation (modified in place).
379/// * `f`         – Right-hand side.
380/// * `h`         – Grid spacing.
381/// * `n_smooth`  – Number of Gauss-Seidel smoothing steps.
382/// * `depth`     – Remaining recursion depth (0 = direct solve).
383pub fn multigrid_vcycle(u: &mut Vec<f64>, f: &[f64], h: f64, n_smooth: usize, depth: usize) {
384    let n = u.len();
385    for _ in 0..n_smooth {
386        for i in 1..(n - 1) {
387            u[i] = 0.5 * (u[i - 1] + u[i + 1] - h * h * f[i]);
388        }
389    }
390    if depth == 0 || n <= 3 {
391        return;
392    }
393    let mut res = vec![0.0f64; n];
394    for i in 1..(n - 1) {
395        res[i] = f[i] - (u[i + 1] - 2.0 * u[i] + u[i - 1]) / (h * h);
396    }
397    let coarse_res = restriction_l2(&res, 2);
398    let nc = coarse_res.len();
399    let mut e_coarse = vec![0.0f64; nc];
400    multigrid_vcycle(&mut e_coarse, &coarse_res, 2.0 * h, n_smooth, depth - 1);
401    let e_fine = prolongation_linear(&e_coarse, 2);
402    for i in 0..n.min(e_fine.len()) {
403        u[i] += e_fine[i];
404    }
405    for _ in 0..n_smooth {
406        for i in 1..(n - 1) {
407            u[i] = 0.5 * (u[i - 1] + u[i + 1] - h * h * f[i]);
408        }
409    }
410}
411/// Advance the Allen-Cahn phase-field `Ο†` by one explicit time step.
412///
413/// `βˆ‚Ο†/βˆ‚t = M [Ρ² βˆ‡Β²Ο† - W Ο†(Ο†-1)(Ο†-0.5) Γ— 2]`
414///
415/// Uses periodic boundary conditions.
416///
417/// # Arguments
418/// * `phi`    – Phase-field (in-place update).
419/// * `params` – Phase-field parameters.
420/// * `dx`     – Grid spacing.
421/// * `dt`     – Time step.
422pub fn allen_cahn_step(phi: &mut Vec<f64>, params: &PhaseFieldParams, dx: f64, dt: f64) {
423    let n = phi.len();
424    let mut dphi = vec![0.0f64; n];
425    for i in 0..n {
426        let im = (i + n - 1) % n;
427        let ip = (i + 1) % n;
428        let laplacian = (phi[im] - 2.0 * phi[i] + phi[ip]) / (dx * dx);
429        let p = phi[i];
430        let dw = 2.0 * params.well_height * p * (p - 1.0) * (2.0 * p - 1.0);
431        dphi[i] = params.mobility * (params.epsilon_sq * laplacian - dw);
432    }
433    for i in 0..n {
434        phi[i] += dt * dphi[i];
435        phi[i] = phi[i].clamp(0.0, 1.0);
436    }
437}
438/// Compute the coarse-grained potential energy surface along a reaction coordinate.
439///
440/// Uses umbrella sampling with a harmonic bias potential to reconstruct
441/// the free energy profile via the weighted histogram analysis.
442///
443/// # Arguments
444/// * `samples` – Sampled values of the reaction coordinate ΞΎ.
445/// * `xi_0`    – Centre of the harmonic bias window.
446/// * `k_bias`  – Spring constant of the bias potential.
447/// * `beta`    – Inverse temperature 1/(kT).
448pub fn umbrella_free_energy(samples: &[f64], xi_0: f64, k_bias: f64, beta: f64) -> f64 {
449    let n = samples.len() as f64;
450    if n < 1.0 {
451        return 0.0;
452    }
453    let mean_bias: f64 = samples
454        .iter()
455        .map(|&xi| 0.5 * k_bias * (xi - xi_0).powi(2))
456        .sum::<f64>()
457        / n;
458    let mean_xi: f64 = samples.iter().sum::<f64>() / n;
459    let var_xi: f64 = samples
460        .iter()
461        .map(|&xi| (xi - mean_xi).powi(2))
462        .sum::<f64>()
463        / n;
464    let entropy_term = if var_xi > 1e-300 {
465        -0.5 * (2.0 * PI * var_xi).ln() / beta
466    } else {
467        0.0
468    };
469    entropy_term + mean_bias
470}
471/// Check scale separation: verifies that the micro length scale is at
472/// least `factor` times smaller than the macro length scale.
473pub fn check_scale_separation(micro_length: f64, macro_length: f64, factor: f64) -> bool {
474    macro_length >= factor * micro_length
475}
476/// Compute the Hill-Mandel macrohomogeneity condition error.
477///
478/// For a periodic unit cell under macroscopic strain Ξ΅*, the condition
479/// requires `<Οƒ : δΡ> = Οƒ* : δΡ*`. Returns the relative violation.
480pub fn hill_mandel_error(
481    micro_stress: &[f64],
482    micro_strain: &[f64],
483    macro_stress: [f64; 9],
484    macro_strain: [f64; 9],
485) -> f64 {
486    let n = micro_stress.len().min(micro_strain.len()) as f64;
487    if n < 1.0 {
488        return 0.0;
489    }
490    let micro_power: f64 = micro_stress
491        .iter()
492        .zip(micro_strain.iter())
493        .map(|(s, e)| s * e)
494        .sum::<f64>()
495        / n;
496    let macro_power: f64 = macro_stress
497        .iter()
498        .zip(macro_strain.iter())
499        .map(|(s, e)| s * e)
500        .sum();
501    (micro_power - macro_power).abs() / macro_power.abs().max(1e-300)
502}
503/// Compute harmonic bond force between two CG beads.
504///
505/// `F = -k (r - r0) * r_hat`
506///
507/// Returns force on bead `a` (force on `b` is equal and opposite).
508pub fn cg_bond_force(pos_a: [f64; 3], pos_b: [f64; 3], k: f64, r0: f64) -> [f64; 3] {
509    let r_vec = sub3(pos_b, pos_a);
510    let r = norm3(r_vec);
511    if r < 1e-12 {
512        return [0.0; 3];
513    }
514    let f_mag = k * (r - r0);
515    scale3(normalize3(r_vec), f_mag)
516}
517/// Linear elasticity tangent modulus from a set of atom-pair bond stiffnesses.
518///
519/// Under the Cauchy-Born assumption on a simple cubic lattice, the elastic
520/// modulus (Young's modulus in 1D) is:
521///
522/// `E = (1/Vβ‚€) Ξ£_bonds k_bond * (r_hat βŠ— r_hat) : I`
523pub fn cauchy_born_modulus(bond_stiffnesses: &[f64], bond_lengths: &[f64], volume: f64) -> f64 {
524    let n = bond_stiffnesses.len().min(bond_lengths.len());
525    let sum: f64 = (0..n)
526        .map(|i| bond_stiffnesses[i] * bond_lengths[i].powi(2))
527        .sum();
528    sum / volume.max(1e-300)
529}
530/// Handshake zone energy correction to remove double-counting in
531/// concurrent multiscale coupling.
532///
533/// The Arlequin method blends atomistic and continuum energies in an
534/// overlap zone using a weight function `w(x) ∈ [0,1]`.
535///
536/// Returns blended energy density.
537pub fn arlequin_blend_energy(e_atomistic: f64, e_continuum: f64, weight: f64) -> f64 {
538    let w = weight.clamp(0.0, 1.0);
539    w * e_atomistic + (1.0 - w) * e_continuum
540}
541/// Perform one NEB step with spring forces.
542///
543/// # Arguments
544/// * `images`          – Mutable slice of NEB images.
545/// * `spring_constant` – Spring constant k.
546/// * `grad_fn`         – Returns -(gradient of energy) at a configuration.
547/// * `dt`              – Step size.
548#[allow(clippy::too_many_arguments)]
549pub fn neb_step<F>(images: &mut Vec<NebImage>, spring_constant: f64, grad_fn: &F, dt: f64)
550where
551    F: Fn(&[f64]) -> (f64, Vec<f64>),
552{
553    let n = images.len();
554    if n < 3 {
555        return;
556    }
557    for img in images.iter_mut() {
558        let (e, g) = grad_fn(&img.coords);
559        img.energy = e;
560        img.force = g;
561    }
562    for i in 1..(n - 1) {
563        let prev = images[i - 1].coords.clone();
564        let next = images[i + 1].coords.clone();
565        let curr = images[i].coords.clone();
566        let tau: Vec<f64> = curr
567            .iter()
568            .zip(prev.iter())
569            .zip(next.iter())
570            .map(|((&c, &p), &nx)| {
571                let dp = c - p;
572                let dn = nx - c;
573                0.5 * (dn + dp)
574            })
575            .collect();
576        let tau_norm: f64 = tau.iter().map(|t| t * t).sum::<f64>().sqrt().max(1e-300);
577        let d_prev: f64 = curr
578            .iter()
579            .zip(prev.iter())
580            .map(|(&c, &p)| (c - p).powi(2))
581            .sum::<f64>()
582            .sqrt();
583        let d_next: f64 = next
584            .iter()
585            .zip(curr.iter())
586            .map(|(&nx, &c)| (nx - c).powi(2))
587            .sum::<f64>()
588            .sqrt();
589        let f_spring = spring_constant * (d_next - d_prev);
590        let f_real = images[i].force.clone();
591        let f_dot_tau: f64 = f_real
592            .iter()
593            .zip(tau.iter())
594            .map(|(&f, &t)| f * t / tau_norm)
595            .sum();
596        let f_perp: Vec<f64> = f_real
597            .iter()
598            .zip(tau.iter())
599            .map(|(&f, &t)| f - f_dot_tau * t / tau_norm)
600            .collect();
601        let f_spring_vec: Vec<f64> = tau.iter().map(|&t| f_spring * t / tau_norm).collect();
602        let f_neb: Vec<f64> = f_perp
603            .iter()
604            .zip(f_spring_vec.iter())
605            .map(|(fp, fs)| fp + fs)
606            .collect();
607        for (c, f) in images[i].coords.iter_mut().zip(f_neb.iter()) {
608            *c += dt * f;
609        }
610    }
611}