Skip to main content

oxiphysics_io/trajectory/
types.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5#![allow(clippy::needless_range_loop)]
6#[allow(unused_imports)]
7use super::functions::*;
8use crate::xdmf;
9use oxiphysics_core::math::Vec3;
10use std::io::Write;
11
12/// Compute the velocity autocorrelation function (VACF) from velocity frames.
13///
14/// VACF(lag) = ⟨ v(t) · v(t + lag) ⟩ / ⟨ v(0) · v(0) ⟩
15#[allow(dead_code)]
16pub struct VacfCalculator;
17#[allow(dead_code)]
18impl VacfCalculator {
19    /// Compute the normalised VACF up to `max_lag` frames.
20    ///
21    /// Returns a vector of `(lag, vacf)` pairs.  At lag=0, VACF=1.0 by definition.
22    pub fn compute_normalized(frames: &[VelocityFrame], max_lag: usize) -> Vec<(usize, f64)> {
23        if frames.is_empty() {
24            return Vec::new();
25        }
26        let effective_lag = max_lag.min(frames.len() - 1);
27        let n_atoms = frames[0].n_atoms();
28        let c0 = Self::vacf_at_lag(frames, 0, n_atoms);
29        if c0.abs() < 1e-30 {
30            return (0..=effective_lag).map(|l| (l, 0.0)).collect();
31        }
32        (0..=effective_lag)
33            .map(|lag| {
34                let c = Self::vacf_at_lag(frames, lag, n_atoms);
35                (lag, c / c0)
36            })
37            .collect()
38    }
39    fn vacf_at_lag(frames: &[VelocityFrame], lag: usize, n_atoms: usize) -> f64 {
40        let mut sum = 0.0_f64;
41        let mut count = 0_usize;
42        for t0 in 0..(frames.len().saturating_sub(lag)) {
43            let t1 = t0 + lag;
44            let n = n_atoms.min(frames[t0].n_atoms()).min(frames[t1].n_atoms());
45            for i in 0..n {
46                let v0 = frames[t0].velocities[i];
47                let v1 = frames[t1].velocities[i];
48                sum += v0[0] * v1[0] + v0[1] * v1[1] + v0[2] * v1[2];
49                count += 1;
50            }
51        }
52        if count > 0 { sum / count as f64 } else { 0.0 }
53    }
54    /// Integrate the VACF using the trapezoidal rule to estimate the
55    /// self-diffusion coefficient.  `dt` is the time-step.
56    ///
57    /// D = (1/3) ∫ C(t) dt  (unnormalized VACF)
58    pub fn integrate_vacf(vacf: &[(usize, f64)], dt: f64) -> f64 {
59        if vacf.len() < 2 || dt <= 0.0 {
60            return 0.0;
61        }
62        let mut integral = 0.0_f64;
63        for i in 0..vacf.len() - 1 {
64            integral += (vacf[i].1 + vacf[i + 1].1) * 0.5 * dt;
65        }
66        integral / 3.0
67    }
68}
69/// Reads particle trajectories from XYZ-format strings.
70#[allow(dead_code)]
71pub struct XyzReader;
72#[allow(dead_code)]
73impl XyzReader {
74    /// Parse a single XYZ frame from the start of `data`.
75    pub fn read_frame(data: &str) -> Result<TrajectoryFrame, crate::Error> {
76        let mut lines = data.lines();
77        let count_line = lines
78            .next()
79            .ok_or_else(|| crate::Error::Parse("XYZ: empty input".to_string()))?;
80        let n_atoms: usize = count_line
81            .trim()
82            .parse()
83            .map_err(|_| crate::Error::Parse(format!("XYZ: bad atom count '{}'", count_line)))?;
84        let comment = lines
85            .next()
86            .ok_or_else(|| crate::Error::Parse("XYZ: missing comment line".to_string()))?;
87        let (timestep, time) = parse_xyz_comment(comment);
88        let mut positions = Vec::with_capacity(n_atoms);
89        let mut atom_types = Vec::with_capacity(n_atoms);
90        for i in 0..n_atoms {
91            let line = lines.next().ok_or_else(|| {
92                crate::Error::Parse(format!("XYZ: missing atom line {} of {}", i + 1, n_atoms))
93            })?;
94            let parts: Vec<&str> = line.split_whitespace().collect();
95            if parts.len() < 4 {
96                return Err(crate::Error::Parse(format!(
97                    "XYZ: atom line {} has only {} fields",
98                    i,
99                    parts.len()
100                )));
101            }
102            atom_types.push(parts[0].to_string());
103            let x: f64 = parts[1]
104                .parse()
105                .map_err(|_| crate::Error::Parse(format!("XYZ: bad x coord on atom line {}", i)))?;
106            let y: f64 = parts[2]
107                .parse()
108                .map_err(|_| crate::Error::Parse(format!("XYZ: bad y coord on atom line {}", i)))?;
109            let z: f64 = parts[3]
110                .parse()
111                .map_err(|_| crate::Error::Parse(format!("XYZ: bad z coord on atom line {}", i)))?;
112            positions.push([x, y, z]);
113        }
114        Ok(TrajectoryFrame {
115            timestep,
116            time,
117            positions,
118            atom_types,
119        })
120    }
121    /// Parse all frames from a concatenated XYZ string.
122    pub fn read_all_frames(data: &str) -> Result<Vec<TrajectoryFrame>, crate::Error> {
123        let mut frames = Vec::new();
124        let mut remaining = data;
125        loop {
126            remaining = remaining.trim_start_matches('\n');
127            remaining = remaining.trim_start_matches('\r');
128            if remaining.is_empty() {
129                break;
130            }
131            let mut it = remaining.lines();
132            let count_line = match it.next() {
133                Some(l) => l,
134                None => break,
135            };
136            let n_atoms: usize = count_line.trim().parse().map_err(|_| {
137                crate::Error::Parse(format!("XYZ: bad atom count '{}'", count_line))
138            })?;
139            let frame_lines = 2 + n_atoms;
140            let frame_text: String = remaining
141                .lines()
142                .take(frame_lines)
143                .collect::<Vec<&str>>()
144                .join("\n");
145            let frame = Self::read_frame(&frame_text)?;
146            frames.push(frame);
147            let consumed = count_line_bytes(remaining, frame_lines);
148            remaining = &remaining[consumed..];
149        }
150        Ok(frames)
151    }
152}
153/// Compute statistics over trajectory frames.
154#[allow(dead_code)]
155pub struct TrajectoryStatistics;
156#[allow(dead_code)]
157impl TrajectoryStatistics {
158    /// Compute the mean position of each atom across all frames.
159    pub fn mean_positions(frames: &[TrajectoryFrame]) -> Vec<[f64; 3]> {
160        if frames.is_empty() {
161            return Vec::new();
162        }
163        let n_atoms = frames[0].n_atoms();
164        let mut mean = vec![[0.0_f64; 3]; n_atoms];
165        let mut count = vec![0_usize; n_atoms];
166        for frame in frames {
167            let n = n_atoms.min(frame.n_atoms());
168            for i in 0..n {
169                mean[i][0] += frame.positions[i][0];
170                mean[i][1] += frame.positions[i][1];
171                mean[i][2] += frame.positions[i][2];
172                count[i] += 1;
173            }
174        }
175        for i in 0..n_atoms {
176            if count[i] > 0 {
177                let c = count[i] as f64;
178                mean[i][0] /= c;
179                mean[i][1] /= c;
180                mean[i][2] /= c;
181            }
182        }
183        mean
184    }
185    /// Compute the per-atom RMSF (root mean square fluctuation).
186    pub fn rmsf(frames: &[TrajectoryFrame]) -> Vec<f64> {
187        if frames.is_empty() {
188            return Vec::new();
189        }
190        let mean = Self::mean_positions(frames);
191        let n_atoms = mean.len();
192        let mut sum_sq = vec![0.0_f64; n_atoms];
193        let mut count = vec![0_usize; n_atoms];
194        for frame in frames {
195            let n = n_atoms.min(frame.n_atoms());
196            for i in 0..n {
197                let dx = frame.positions[i][0] - mean[i][0];
198                let dy = frame.positions[i][1] - mean[i][1];
199                let dz = frame.positions[i][2] - mean[i][2];
200                sum_sq[i] += dx * dx + dy * dy + dz * dz;
201                count[i] += 1;
202            }
203        }
204        sum_sq
205            .iter()
206            .zip(count.iter())
207            .map(|(&sq, &c)| if c > 0 { (sq / c as f64).sqrt() } else { 0.0 })
208            .collect()
209    }
210    /// Compute the radius of gyration for a frame.
211    pub fn radius_of_gyration(frame: &TrajectoryFrame, masses: &[f64]) -> f64 {
212        let n = frame.n_atoms().min(masses.len());
213        if n == 0 {
214            return 0.0;
215        }
216        let com = center_of_mass(&frame.positions[..n], &masses[..n]);
217        let total_mass: f64 = masses[..n].iter().sum();
218        if total_mass < 1e-30 {
219            return 0.0;
220        }
221        let mut sum = 0.0_f64;
222        for i in 0..n {
223            let dx = frame.positions[i][0] - com[0];
224            let dy = frame.positions[i][1] - com[1];
225            let dz = frame.positions[i][2] - com[2];
226            sum += masses[i] * (dx * dx + dy * dy + dz * dz);
227        }
228        (sum / total_mass).sqrt()
229    }
230    /// Compute the end-to-end distance of a chain (first to last atom).
231    pub fn end_to_end_distance(frame: &TrajectoryFrame) -> f64 {
232        if frame.n_atoms() < 2 {
233            return 0.0;
234        }
235        let first = frame.positions[0];
236        let last = frame.positions[frame.n_atoms() - 1];
237        let dx = last[0] - first[0];
238        let dy = last[1] - first[1];
239        let dz = last[2] - first[2];
240        (dx * dx + dy * dy + dz * dz).sqrt()
241    }
242}
243/// Compute the radial distribution function g(r) from one or more frames.
244#[allow(dead_code)]
245pub struct RdfCalculator;
246#[allow(dead_code)]
247impl RdfCalculator {
248    /// Compute g(r) for all atom pairs within `r_max`, using `n_bins` bins.
249    ///
250    /// `box_size` is required for the normalisation (number density).
251    /// Returns `(r_values, g_values)` both of length `n_bins`.
252    pub fn compute(
253        frames: &[TrajectoryFrame],
254        r_max: f64,
255        n_bins: usize,
256        box_size: [f64; 3],
257    ) -> (Vec<f64>, Vec<f64>) {
258        if frames.is_empty() || n_bins == 0 || r_max <= 0.0 {
259            return (Vec::new(), Vec::new());
260        }
261        let dr = r_max / n_bins as f64;
262        let mut hist = vec![0_u64; n_bins];
263        let mut n_frames = 0_u64;
264        let mut n_atoms_total = 0_u64;
265        for frame in frames {
266            let n = frame.n_atoms();
267            if n < 2 {
268                continue;
269            }
270            n_frames += 1;
271            n_atoms_total += n as u64;
272            for i in 0..n {
273                for j in (i + 1)..n {
274                    let mut dx = frame.positions[j][0] - frame.positions[i][0];
275                    let mut dy = frame.positions[j][1] - frame.positions[i][1];
276                    let mut dz = frame.positions[j][2] - frame.positions[i][2];
277                    for (d, l) in [
278                        (&mut dx, box_size[0]),
279                        (&mut dy, box_size[1]),
280                        (&mut dz, box_size[2]),
281                    ] {
282                        if l > 1e-30 {
283                            *d -= (*d / l).round() * l;
284                        }
285                    }
286                    let r = (dx * dx + dy * dy + dz * dz).sqrt();
287                    if r < r_max {
288                        let bin = (r / dr) as usize;
289                        if bin < n_bins {
290                            hist[bin] += 2;
291                        }
292                    }
293                }
294            }
295        }
296        let vol = box_size[0] * box_size[1] * box_size[2];
297        let avg_n = if n_frames > 0 {
298            n_atoms_total as f64 / n_frames as f64
299        } else {
300            1.0
301        };
302        let rho = avg_n / vol;
303        let r_vals: Vec<f64> = (0..n_bins).map(|b| (b as f64 + 0.5) * dr).collect();
304        let g_vals: Vec<f64> = (0..n_bins)
305            .map(|b| {
306                let r = r_vals[b];
307                let shell_vol = 4.0 * std::f64::consts::PI * r * r * dr;
308                let ideal = rho * shell_vol * avg_n;
309                if ideal < 1e-30 || n_frames == 0 {
310                    0.0
311                } else {
312                    hist[b] as f64 / (n_frames as f64 * ideal)
313                }
314            })
315            .collect();
316        (r_vals, g_vals)
317    }
318}
319/// Compute the Mean Square Displacement (MSD) as a function of lag time
320/// from a trajectory.
321///
322/// Returns a vector of `(lag_index, msd_value)` pairs.
323/// `lag_index` runs from 0 to `max_lag` (inclusive); at lag 0 the MSD is 0.
324#[allow(dead_code)]
325pub struct MsdCalculator;
326#[allow(dead_code)]
327impl MsdCalculator {
328    /// Compute MSD for all atoms combined.
329    ///
330    /// `max_lag` is the maximum lag (number of frames) to compute.
331    pub fn compute(frames: &[TrajectoryFrame], max_lag: usize) -> Vec<(usize, f64)> {
332        if frames.len() < 2 || max_lag == 0 {
333            return vec![(0, 0.0)];
334        }
335        let n_atoms = frames[0].n_atoms();
336        let effective_lag = max_lag.min(frames.len() - 1);
337        let mut result = Vec::with_capacity(effective_lag + 1);
338        for lag in 0..=effective_lag {
339            let mut sum = 0.0_f64;
340            let mut count = 0_usize;
341            for t0 in 0..(frames.len() - lag) {
342                let t1 = t0 + lag;
343                let n = n_atoms.min(frames[t0].n_atoms()).min(frames[t1].n_atoms());
344                for i in 0..n {
345                    let dx = frames[t1].positions[i][0] - frames[t0].positions[i][0];
346                    let dy = frames[t1].positions[i][1] - frames[t0].positions[i][1];
347                    let dz = frames[t1].positions[i][2] - frames[t0].positions[i][2];
348                    sum += dx * dx + dy * dy + dz * dz;
349                    count += 1;
350                }
351            }
352            let msd = if count > 0 { sum / count as f64 } else { 0.0 };
353            result.push((lag, msd));
354        }
355        result
356    }
357    /// Estimate the self-diffusion coefficient D from MSD data using
358    /// the Einstein relation: `MSD = 6 D t` in 3-D.
359    ///
360    /// Performs a linear fit to `msd_data` (pairs of `(lag, msd)`),
361    /// using `dt` as the frame time-step.  Returns `D` (in units of
362    /// distance²/time).
363    pub fn diffusion_coefficient(msd_data: &[(usize, f64)], dt: f64) -> f64 {
364        if msd_data.len() < 2 || dt <= 0.0 {
365            return 0.0;
366        }
367        let n = msd_data.len() as f64;
368        let sum_x: f64 = msd_data.iter().map(|(lag, _)| *lag as f64 * dt).sum();
369        let sum_y: f64 = msd_data.iter().map(|(_, msd)| *msd).sum();
370        let sum_xy: f64 = msd_data
371            .iter()
372            .map(|(lag, msd)| *lag as f64 * dt * msd)
373            .sum();
374        let sum_x2: f64 = msd_data
375            .iter()
376            .map(|(lag, _)| (*lag as f64 * dt).powi(2))
377            .sum();
378        let denom = n * sum_x2 - sum_x * sum_x;
379        if denom.abs() < 1e-30 {
380            return 0.0;
381        }
382        let slope = (n * sum_xy - sum_x * sum_y) / denom;
383        slope / 6.0
384    }
385}
386/// Analyse bond lengths from trajectory data.
387#[allow(dead_code)]
388pub struct BondLengthAnalyser;
389#[allow(dead_code)]
390impl BondLengthAnalyser {
391    /// Compute the distance between atoms `i` and `j` in a frame.
392    pub fn bond_length(frame: &TrajectoryFrame, i: usize, j: usize) -> f64 {
393        let pi = frame.positions[i];
394        let pj = frame.positions[j];
395        let dx = pi[0] - pj[0];
396        let dy = pi[1] - pj[1];
397        let dz = pi[2] - pj[2];
398        (dx * dx + dy * dy + dz * dz).sqrt()
399    }
400    /// Compute bond length over all frames and return the time series.
401    pub fn time_series(frames: &[TrajectoryFrame], i: usize, j: usize) -> Vec<f64> {
402        frames
403            .iter()
404            .filter(|f| f.n_atoms() > i.max(j))
405            .map(|f| Self::bond_length(f, i, j))
406            .collect()
407    }
408    /// Compute average bond length and standard deviation over all frames.
409    pub fn mean_and_std(frames: &[TrajectoryFrame], i: usize, j: usize) -> (f64, f64) {
410        let series = Self::time_series(frames, i, j);
411        if series.is_empty() {
412            return (0.0, 0.0);
413        }
414        let mean = series.iter().sum::<f64>() / series.len() as f64;
415        let var =
416            series.iter().map(|&v| (v - mean) * (v - mean)).sum::<f64>() / series.len() as f64;
417        (mean, var.sqrt())
418    }
419    /// Return the bond angle (in degrees) for atoms i–j–k in a frame.
420    ///
421    /// The angle is at atom `j` (the middle atom).
422    pub fn bond_angle_deg(frame: &TrajectoryFrame, i: usize, j: usize, k: usize) -> f64 {
423        let pi = frame.positions[i];
424        let pj = frame.positions[j];
425        let pk = frame.positions[k];
426        let v1 = [pi[0] - pj[0], pi[1] - pj[1], pi[2] - pj[2]];
427        let v2 = [pk[0] - pj[0], pk[1] - pj[1], pk[2] - pj[2]];
428        let l1 = (v1[0] * v1[0] + v1[1] * v1[1] + v1[2] * v1[2]).sqrt();
429        let l2 = (v2[0] * v2[0] + v2[1] * v2[1] + v2[2] * v2[2]).sqrt();
430        if l1 < 1e-30 || l2 < 1e-30 {
431            return 0.0;
432        }
433        let cos_theta = (v1[0] * v2[0] + v1[1] * v2[1] + v1[2] * v2[2]) / (l1 * l2);
434        cos_theta.clamp(-1.0, 1.0).acos().to_degrees()
435    }
436}
437/// Resample a trajectory to a different time resolution.
438#[allow(dead_code)]
439pub struct TrajectoryResampler;
440#[allow(dead_code)]
441impl TrajectoryResampler {
442    /// Resample frames at uniform time intervals.
443    ///
444    /// Linearly interpolates positions between the two nearest frames
445    /// for each target time. Returns the resampled frames.
446    pub fn resample_uniform(frames: &[TrajectoryFrame], target_dt: f64) -> Vec<TrajectoryFrame> {
447        if frames.is_empty() || target_dt <= 0.0 {
448            return Vec::new();
449        }
450        if frames.len() == 1 {
451            return vec![frames[0].clone()];
452        }
453        let t_start = frames[0].time;
454        let t_end = frames.last().expect("collection should not be empty").time;
455        if t_end <= t_start {
456            return vec![frames[0].clone()];
457        }
458        let n_samples = ((t_end - t_start) / target_dt).ceil() as usize + 1;
459        let mut result = Vec::with_capacity(n_samples);
460        for s in 0..n_samples {
461            let t = t_start + s as f64 * target_dt;
462            if t > t_end {
463                break;
464            }
465            let frame = Self::interpolate_at_time(frames, t);
466            result.push(frame);
467        }
468        result
469    }
470    /// Interpolate a frame at a specific time.
471    fn interpolate_at_time(frames: &[TrajectoryFrame], t: f64) -> TrajectoryFrame {
472        let mut lo = 0;
473        let mut hi = frames.len() - 1;
474        for i in 0..frames.len() - 1 {
475            if frames[i].time <= t && frames[i + 1].time >= t {
476                lo = i;
477                hi = i + 1;
478                break;
479            }
480        }
481        if lo == hi || (frames[hi].time - frames[lo].time).abs() < 1e-30 {
482            return frames[lo].clone();
483        }
484        let alpha = (t - frames[lo].time) / (frames[hi].time - frames[lo].time);
485        let alpha = alpha.clamp(0.0, 1.0);
486        let n_atoms = frames[lo].n_atoms().min(frames[hi].n_atoms());
487        let mut positions = Vec::with_capacity(n_atoms);
488        for i in 0..n_atoms {
489            positions.push([
490                frames[lo].positions[i][0] * (1.0 - alpha) + frames[hi].positions[i][0] * alpha,
491                frames[lo].positions[i][1] * (1.0 - alpha) + frames[hi].positions[i][1] * alpha,
492                frames[lo].positions[i][2] * (1.0 - alpha) + frames[hi].positions[i][2] * alpha,
493            ]);
494        }
495        TrajectoryFrame {
496            timestep: (t / 1.0) as u64,
497            time: t,
498            positions,
499            atom_types: frames[lo].atom_types[..n_atoms].to_vec(),
500        }
501    }
502    /// Subsample: keep every n-th frame.
503    pub fn subsample(frames: &[TrajectoryFrame], every_n: usize) -> Vec<TrajectoryFrame> {
504        if every_n == 0 {
505            return Vec::new();
506        }
507        frames.iter().step_by(every_n).cloned().collect()
508    }
509}
510/// One snapshot in a particle trajectory.
511#[derive(Debug, Clone)]
512#[allow(dead_code)]
513pub struct TrajectoryFrame {
514    /// Integer timestep index.
515    pub timestep: u64,
516    /// Simulation time (e.g. in ps or fs).
517    pub time: f64,
518    /// Atom positions as `[x, y, z]` triples.
519    pub positions: Vec<[f64; 3]>,
520    /// Atom type labels (e.g. element symbols like `"C"`, `"H"`, `"O"`).
521    pub atom_types: Vec<String>,
522}
523#[allow(dead_code)]
524impl TrajectoryFrame {
525    /// Create a new `TrajectoryFrame`.
526    pub fn new(
527        timestep: u64,
528        time: f64,
529        positions: Vec<[f64; 3]>,
530        atom_types: Vec<String>,
531    ) -> Self {
532        Self {
533            timestep,
534            time,
535            positions,
536            atom_types,
537        }
538    }
539    /// Number of atoms in this frame.
540    pub fn n_atoms(&self) -> usize {
541        self.positions.len()
542    }
543}
544/// Writes particle trajectories in the standard XYZ file format.
545///
546/// Format per frame:
547/// ```text
548/// `N`
549/// <comment line>
550/// `type` `x` `y` `z`
551/// ...
552/// ```
553#[allow(dead_code)]
554pub struct XyzWriter;
555#[allow(dead_code)]
556impl XyzWriter {
557    /// Render one frame as an XYZ-format string.
558    pub fn write_frame(frame: &TrajectoryFrame) -> String {
559        let mut out = String::new();
560        out.push_str(&format!("{}\n", frame.n_atoms()));
561        out.push_str(&format!(
562            "Timestep {} time={}\n",
563            frame.timestep, frame.time
564        ));
565        for (pos, atom_type) in frame.positions.iter().zip(frame.atom_types.iter()) {
566            out.push_str(&format!("{} {} {} {}\n", atom_type, pos[0], pos[1], pos[2]));
567        }
568        out
569    }
570    /// Render multiple frames as a concatenated XYZ string.
571    pub fn write_frames(frames: &[TrajectoryFrame]) -> String {
572        frames.iter().map(Self::write_frame).collect()
573    }
574}
575/// Writes `TrajectoryFrame` data in LAMMPS dump format.
576///
577/// Note: this is distinct from `LammpsDumpWriter` in `lammps_dump.rs`, which
578/// operates on `LammpsDumpFrame` (with integer atom IDs and type indices).
579/// This writer uses the string atom types from `TrajectoryFrame`.
580#[allow(dead_code)]
581pub struct TrajLammpsWriter;
582#[allow(dead_code)]
583impl TrajLammpsWriter {
584    /// Render one frame in LAMMPS dump format.
585    ///
586    /// `box_lo` and `box_hi` are the simulation box extents as `[xlo, ylo, zlo]`
587    /// and `[xhi, yhi, zhi]`.
588    pub fn write_dump_frame(frame: &TrajectoryFrame, box_lo: [f64; 3], box_hi: [f64; 3]) -> String {
589        let mut out = String::new();
590        out.push_str("ITEM: TIMESTEP\n");
591        out.push_str(&format!("{}\n", frame.timestep));
592        out.push_str("ITEM: NUMBER OF ATOMS\n");
593        out.push_str(&format!("{}\n", frame.n_atoms()));
594        out.push_str("ITEM: BOX BOUNDS pp pp pp\n");
595        for i in 0..3 {
596            out.push_str(&format!("{} {}\n", box_lo[i], box_hi[i]));
597        }
598        out.push_str("ITEM: ATOMS id type x y z\n");
599        for (i, (pos, atom_type)) in frame
600            .positions
601            .iter()
602            .zip(frame.atom_types.iter())
603            .enumerate()
604        {
605            out.push_str(&format!(
606                "{} {} {} {} {}\n",
607                i + 1,
608                atom_type,
609                pos[0],
610                pos[1],
611                pos[2]
612            ));
613        }
614        out
615    }
616}
617/// Convert trajectory data between different representations.
618#[allow(dead_code)]
619pub struct TrajectoryConverter;
620#[allow(dead_code)]
621impl TrajectoryConverter {
622    /// Convert a `TrajectoryFrame` to a flat `[x0,y0,z0, x1,y1,z1, ...]` array.
623    pub fn to_flat_xyz(frame: &TrajectoryFrame) -> Vec<f64> {
624        let mut out = Vec::with_capacity(frame.n_atoms() * 3);
625        for pos in &frame.positions {
626            out.extend_from_slice(pos);
627        }
628        out
629    }
630    /// Build a `TrajectoryFrame` from a flat xyz array.
631    pub fn from_flat_xyz(
632        flat: &[f64],
633        atom_types: Vec<String>,
634        timestep: u64,
635        time: f64,
636    ) -> std::result::Result<TrajectoryFrame, crate::Error> {
637        if !flat.len().is_multiple_of(3) {
638            return Err(crate::Error::Parse(format!(
639                "from_flat_xyz: length {} not divisible by 3",
640                flat.len()
641            )));
642        }
643        let n_atoms = flat.len() / 3;
644        if atom_types.len() != n_atoms {
645            return Err(crate::Error::Parse(format!(
646                "from_flat_xyz: {} atom types but {} positions",
647                atom_types.len(),
648                n_atoms
649            )));
650        }
651        let positions: Vec<[f64; 3]> = flat.chunks(3).map(|c| [c[0], c[1], c[2]]).collect();
652        Ok(TrajectoryFrame::new(timestep, time, positions, atom_types))
653    }
654    /// Compute the displacement between two frames (atom-by-atom).
655    ///
656    /// Returns displacement vectors `[[dx, dy, dz\], ...]` for each atom.
657    pub fn frame_displacement(
658        frame_a: &TrajectoryFrame,
659        frame_b: &TrajectoryFrame,
660    ) -> Vec<[f64; 3]> {
661        let n = frame_a.n_atoms().min(frame_b.n_atoms());
662        (0..n)
663            .map(|i| {
664                [
665                    frame_b.positions[i][0] - frame_a.positions[i][0],
666                    frame_b.positions[i][1] - frame_a.positions[i][1],
667                    frame_b.positions[i][2] - frame_a.positions[i][2],
668                ]
669            })
670            .collect()
671    }
672    /// Translate all atoms in a frame by vector `delta`.
673    pub fn translate(frame: &TrajectoryFrame, delta: [f64; 3]) -> TrajectoryFrame {
674        let positions = frame
675            .positions
676            .iter()
677            .map(|p| [p[0] + delta[0], p[1] + delta[1], p[2] + delta[2]])
678            .collect();
679        TrajectoryFrame::new(
680            frame.timestep,
681            frame.time,
682            positions,
683            frame.atom_types.clone(),
684        )
685    }
686    /// Scale all atom positions by a scalar factor.
687    pub fn scale(frame: &TrajectoryFrame, factor: f64) -> TrajectoryFrame {
688        let positions = frame
689            .positions
690            .iter()
691            .map(|p| [p[0] * factor, p[1] * factor, p[2] * factor])
692            .collect();
693        TrajectoryFrame::new(
694            frame.timestep,
695            frame.time,
696            positions,
697            frame.atom_types.clone(),
698        )
699    }
700}
701/// Concatenate multiple trajectories into one.
702#[allow(dead_code)]
703pub struct TrajectoryConcatenator;
704#[allow(dead_code)]
705impl TrajectoryConcatenator {
706    /// Concatenate trajectories, adjusting timestamps to be continuous.
707    pub fn concatenate(trajectories: &[Vec<TrajectoryFrame>]) -> Vec<TrajectoryFrame> {
708        let mut result = Vec::new();
709        let mut time_offset = 0.0_f64;
710        let mut step_offset = 0_u64;
711        for traj in trajectories {
712            for frame in traj {
713                let mut new_frame = frame.clone();
714                new_frame.time += time_offset;
715                new_frame.timestep += step_offset;
716                result.push(new_frame);
717            }
718            if let Some(last) = traj.last() {
719                time_offset += last.time;
720                step_offset += last.timestep + 1;
721            }
722        }
723        result
724    }
725    /// Merge trajectories by interleaving based on time.
726    pub fn merge_sorted(trajectories: &[Vec<TrajectoryFrame>]) -> Vec<TrajectoryFrame> {
727        let mut all: Vec<&TrajectoryFrame> = trajectories.iter().flat_map(|t| t.iter()).collect();
728        all.sort_by(|a, b| {
729            a.time
730                .partial_cmp(&b.time)
731                .unwrap_or(std::cmp::Ordering::Equal)
732        });
733        all.into_iter().cloned().collect()
734    }
735}
736/// Accumulates simulation frames (time + particle positions) and writes
737/// animation/trajectory files in multiple formats.
738pub struct TrajectoryWriter {
739    pub(super) frames: Vec<(f64, Vec<Vec3>)>,
740}
741impl TrajectoryWriter {
742    /// Create a new empty `TrajectoryWriter`.
743    pub fn new() -> Self {
744        Self { frames: Vec::new() }
745    }
746    /// Append a frame to the trajectory.
747    ///
748    /// `time` is the simulation time of this frame; `positions` are the
749    /// particle positions at that time.
750    pub fn add_frame(&mut self, time: f64, positions: &[Vec3]) {
751        self.frames.push((time, positions.to_vec()));
752    }
753    /// Return the number of frames currently stored.
754    pub fn frame_count(&self) -> usize {
755        self.frames.len()
756    }
757    /// Write all frames as an XDMF temporal collection.
758    pub fn write_xdmf<W: Write>(&self, writer: &mut W) -> std::io::Result<()> {
759        let steps: Vec<(f64, &[Vec3])> = self
760            .frames
761            .iter()
762            .map(|(t, positions)| (*t, positions.as_slice()))
763            .collect();
764        xdmf::write_xdmf_temporal(writer, &steps)
765    }
766    /// Write all frames in simple XYZ trajectory format.
767    ///
768    /// Each frame is written as:
769    /// ```text
770    /// `N`
771    /// Frame `i` time=`t`
772    /// X  x  y  z
773    /// X  x  y  z
774    /// ...
775    /// ```
776    pub fn write_xyz<W: Write>(&self, writer: &mut W) -> std::io::Result<()> {
777        for (i, (time, positions)) in self.frames.iter().enumerate() {
778            writeln!(writer, "{}", positions.len())?;
779            writeln!(writer, "Frame {} time={}", i, time)?;
780            for p in positions {
781                writeln!(writer, "X  {}  {}  {}", p.x, p.y, p.z)?;
782            }
783        }
784        Ok(())
785    }
786}
787/// High-level analysis operations on multi-frame trajectories.
788#[allow(dead_code)]
789pub struct Trajectory;
790#[allow(dead_code)]
791impl Trajectory {
792    /// Compute the per-frame Root-Mean-Square Deviation (RMSD) relative to a
793    /// reference frame.
794    ///
795    /// For each frame *i* the RMSD is:
796    ///
797    /// ```text
798    /// RMSD_i = sqrt( (1/N) * sum_k |r_k^i - r_k^ref|^2 )
799    /// ```
800    ///
801    /// Returns a `Vec`f64` with one entry per input frame.  If `frames` is
802    /// empty or `reference` has a different atom count, the corresponding
803    /// entry is `f64::NAN`.
804    pub fn compute_rmsd_trajectory(
805        frames: &[TrajectoryFrame],
806        reference: &TrajectoryFrame,
807    ) -> Vec<f64> {
808        let n_ref = reference.n_atoms();
809        frames
810            .iter()
811            .map(|frame| {
812                let n = frame.n_atoms();
813                if n != n_ref || n == 0 {
814                    return f64::NAN;
815                }
816                let msd: f64 = frame
817                    .positions
818                    .iter()
819                    .zip(reference.positions.iter())
820                    .map(|(p, r)| {
821                        let dx = p[0] - r[0];
822                        let dy = p[1] - r[1];
823                        let dz = p[2] - r[2];
824                        dx * dx + dy * dy + dz * dz
825                    })
826                    .sum::<f64>()
827                    / n as f64;
828                msd.sqrt()
829            })
830            .collect()
831    }
832    /// Compute the radius of gyration for each frame.
833    ///
834    /// The radius of gyration is defined as:
835    ///
836    /// ```text
837    /// Rg = sqrt( (1/N) * sum_k |r_k - r_com|^2 )
838    /// ```
839    ///
840    /// where `r_com` is the centre of mass (equal mass assumed for all atoms).
841    ///
842    /// Returns a `Vec`f64` with one value per input frame.
843    pub fn compute_radius_of_gyration(frames: &[TrajectoryFrame]) -> Vec<f64> {
844        frames
845            .iter()
846            .map(|frame| {
847                let n = frame.n_atoms();
848                if n == 0 {
849                    return 0.0;
850                }
851                let inv_n = 1.0 / n as f64;
852                let com: [f64; 3] = {
853                    let mut s = [0.0_f64; 3];
854                    for p in &frame.positions {
855                        s[0] += p[0];
856                        s[1] += p[1];
857                        s[2] += p[2];
858                    }
859                    [s[0] * inv_n, s[1] * inv_n, s[2] * inv_n]
860                };
861                let rg2 = frame
862                    .positions
863                    .iter()
864                    .map(|p| {
865                        let dx = p[0] - com[0];
866                        let dy = p[1] - com[1];
867                        let dz = p[2] - com[2];
868                        dx * dx + dy * dy + dz * dz
869                    })
870                    .sum::<f64>()
871                    * inv_n;
872                rg2.sqrt()
873            })
874            .collect()
875    }
876    /// Translate each frame so its centre of mass coincides with that of
877    /// `reference`, then apply a *simplified* least-squares rotation using
878    /// the Kabsch algorithm (iterative gradient-free 3×3 SVD-free version).
879    ///
880    /// **Note:** This implementation uses an analytical closed-form
881    /// solution for the 3-D case that is exact for rigid bodies when N ≥ 3.
882    /// For very small N the result is still the minimum-RMSD superposition.
883    ///
884    /// Returns a new `Vec<TrajectoryFrame>` with aligned coordinates.
885    /// Frames whose atom count differs from `reference` are returned unchanged.
886    pub fn align_to_reference(
887        frames: &[TrajectoryFrame],
888        reference: &TrajectoryFrame,
889    ) -> Vec<TrajectoryFrame> {
890        let n_ref = reference.n_atoms();
891        let ref_com = Self::centre_of_mass(&reference.positions);
892        let ref_centred: Vec<[f64; 3]> = reference
893            .positions
894            .iter()
895            .map(|p| [p[0] - ref_com[0], p[1] - ref_com[1], p[2] - ref_com[2]])
896            .collect();
897        frames
898            .iter()
899            .map(|frame| {
900                if frame.n_atoms() != n_ref {
901                    return frame.clone();
902                }
903                let com = Self::centre_of_mass(&frame.positions);
904                let centred: Vec<[f64; 3]> = frame
905                    .positions
906                    .iter()
907                    .map(|p| [p[0] - com[0], p[1] - com[1], p[2] - com[2]])
908                    .collect();
909                let mut h = [[0.0_f64; 3]; 3];
910                for (c, r) in centred.iter().zip(ref_centred.iter()) {
911                    for i in 0..3 {
912                        for j in 0..3 {
913                            h[i][j] += c[i] * r[j];
914                        }
915                    }
916                }
917                let rot = polar_rotation_3x3(h);
918                let positions: Vec<[f64; 3]> = centred
919                    .iter()
920                    .map(|c| {
921                        let x = rot[0][0] * c[0] + rot[0][1] * c[1] + rot[0][2] * c[2] + ref_com[0];
922                        let y = rot[1][0] * c[0] + rot[1][1] * c[1] + rot[1][2] * c[2] + ref_com[1];
923                        let z = rot[2][0] * c[0] + rot[2][1] * c[1] + rot[2][2] * c[2] + ref_com[2];
924                        [x, y, z]
925                    })
926                    .collect();
927                TrajectoryFrame::new(
928                    frame.timestep,
929                    frame.time,
930                    positions,
931                    frame.atom_types.clone(),
932                )
933            })
934            .collect()
935    }
936    fn centre_of_mass(positions: &[[f64; 3]]) -> [f64; 3] {
937        let n = positions.len();
938        if n == 0 {
939            return [0.0; 3];
940        }
941        let inv_n = 1.0 / n as f64;
942        let mut s = [0.0_f64; 3];
943        for p in positions {
944            s[0] += p[0];
945            s[1] += p[1];
946            s[2] += p[2];
947        }
948        [s[0] * inv_n, s[1] * inv_n, s[2] * inv_n]
949    }
950}
951/// Filter trajectory frames by various criteria.
952#[allow(dead_code)]
953pub struct TrajectoryFilter;
954#[allow(dead_code)]
955impl TrajectoryFilter {
956    /// Keep only frames within a time range \[t_start, t_end\].
957    pub fn time_range(
958        frames: &[TrajectoryFrame],
959        t_start: f64,
960        t_end: f64,
961    ) -> Vec<TrajectoryFrame> {
962        frames
963            .iter()
964            .filter(|f| f.time >= t_start && f.time <= t_end)
965            .cloned()
966            .collect()
967    }
968    /// Keep only certain atom types in each frame.
969    pub fn filter_atom_types(
970        frames: &[TrajectoryFrame],
971        keep_types: &[&str],
972    ) -> Vec<TrajectoryFrame> {
973        frames
974            .iter()
975            .map(|frame| {
976                let mut new_positions = Vec::new();
977                let mut new_types = Vec::new();
978                for (pos, atype) in frame.positions.iter().zip(frame.atom_types.iter()) {
979                    if keep_types.iter().any(|&t| t == atype) {
980                        new_positions.push(*pos);
981                        new_types.push(atype.clone());
982                    }
983                }
984                TrajectoryFrame {
985                    timestep: frame.timestep,
986                    time: frame.time,
987                    positions: new_positions,
988                    atom_types: new_types,
989                }
990            })
991            .collect()
992    }
993    /// Remove frames where no atoms are present.
994    pub fn remove_empty(frames: &[TrajectoryFrame]) -> Vec<TrajectoryFrame> {
995        frames
996            .iter()
997            .filter(|f| !f.positions.is_empty())
998            .cloned()
999            .collect()
1000    }
1001}
1002/// Trajectory frame with per-atom velocity data.
1003#[allow(dead_code)]
1004#[derive(Debug, Clone)]
1005pub struct VelocityFrame {
1006    /// Timestep index.
1007    pub timestep: u64,
1008    /// Simulation time.
1009    pub time: f64,
1010    /// Per-atom velocities `[vx, vy, vz]`.
1011    pub velocities: Vec<[f64; 3]>,
1012}
1013#[allow(dead_code)]
1014impl VelocityFrame {
1015    /// Create a new velocity frame.
1016    pub fn new(timestep: u64, time: f64, velocities: Vec<[f64; 3]>) -> Self {
1017        Self {
1018            timestep,
1019            time,
1020            velocities,
1021        }
1022    }
1023    /// Number of atoms.
1024    pub fn n_atoms(&self) -> usize {
1025        self.velocities.len()
1026    }
1027}
1028/// Handle periodic boundary conditions in trajectory analysis.
1029#[allow(dead_code)]
1030pub struct PeriodicImageHandler;
1031#[allow(dead_code)]
1032impl PeriodicImageHandler {
1033    /// Wrap positions into the primary box \[0, box_size\].
1034    pub fn wrap_positions(positions: &mut [[f64; 3]], box_size: [f64; 3]) {
1035        for pos in positions.iter_mut() {
1036            for d in 0..3 {
1037                if box_size[d] > 1e-30 {
1038                    pos[d] = pos[d] - (pos[d] / box_size[d]).floor() * box_size[d];
1039                }
1040            }
1041        }
1042    }
1043    /// Compute the minimum image distance between two positions.
1044    pub fn minimum_image_distance(a: [f64; 3], b: [f64; 3], box_size: [f64; 3]) -> f64 {
1045        let mut r2 = 0.0_f64;
1046        for d in 0..3 {
1047            let mut dx = b[d] - a[d];
1048            if box_size[d] > 1e-30 {
1049                dx -= (dx / box_size[d]).round() * box_size[d];
1050            }
1051            r2 += dx * dx;
1052        }
1053        r2.sqrt()
1054    }
1055    /// Unwrap a trajectory to remove periodic jumps.
1056    ///
1057    /// Adjusts positions so that each atom moves continuously
1058    /// (no jumps across box boundaries).
1059    pub fn unwrap_trajectory(frames: &mut [TrajectoryFrame], box_size: [f64; 3]) {
1060        if frames.len() < 2 {
1061            return;
1062        }
1063        let n_atoms = frames[0].n_atoms();
1064        for i in 1..frames.len() {
1065            let n = n_atoms.min(frames[i].n_atoms());
1066            for j in 0..n {
1067                for d in 0..3 {
1068                    if box_size[d] > 1e-30 {
1069                        let dx = frames[i].positions[j][d] - frames[i - 1].positions[j][d];
1070                        let shift = (dx / box_size[d]).round() * box_size[d];
1071                        frames[i].positions[j][d] -= shift;
1072                    }
1073                }
1074            }
1075        }
1076    }
1077}