Skip to main content

oxiphysics_io/amber/
types.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5#![allow(
6    clippy::if_same_then_else,
7    clippy::manual_strip,
8    clippy::should_implement_trait
9)]
10#[allow(unused_imports)]
11use super::functions::*;
12
13/// A covalent bond entry.
14#[allow(dead_code)]
15pub struct AmberBond {
16    /// Zero-based index of the first atom.
17    pub i: usize,
18    /// Zero-based index of the second atom.
19    pub j: usize,
20    /// Force constant (kcal mol⁻¹ Å⁻²).
21    pub k: f64,
22    /// Equilibrium bond length (Å).
23    pub r0: f64,
24}
25/// A parsed AMBER atom mask selector.
26///
27/// AMBER masks use a simplified selection language:
28/// - `:1-10` → residues 1 through 10
29/// - `@CA` → atom named CA
30/// - `:1-10@CA` → atom CA in residues 1-10
31/// - `@C,CA,N` → atoms C, CA, or N
32#[allow(dead_code)]
33#[derive(Debug, Clone, PartialEq)]
34pub struct AmberMask {
35    /// Residue selection (list of residue numbers, 1-based).
36    pub residues: Vec<usize>,
37    /// Atom name selection (empty = all atoms).
38    pub atom_names: Vec<String>,
39}
40#[allow(dead_code)]
41impl AmberMask {
42    /// Parse an AMBER mask string.
43    ///
44    /// Supported forms:
45    /// - `:N` – single residue N
46    /// - `:N-M` – residue range N to M
47    /// - `:N,M` – residues N and M
48    /// - `@name` – single atom name
49    /// - `@n1,n2` – multiple atom names
50    /// - `:N-M@name` – combined residue + atom
51    ///
52    /// Returns `Err` if the mask cannot be parsed.
53    pub fn parse(mask: &str) -> Result<Self, String> {
54        let mask = mask.trim();
55        if mask.is_empty() {
56            return Ok(AmberMask {
57                residues: Vec::new(),
58                atom_names: Vec::new(),
59            });
60        }
61        let (res_part, atom_part) = if let Some(at_pos) = mask.find('@') {
62            (&mask[..at_pos], Some(&mask[at_pos + 1..]))
63        } else {
64            (mask, None)
65        };
66        let residues = if res_part.starts_with(':') {
67            parse_residue_selection(&res_part[1..])?
68        } else if res_part.is_empty() {
69            Vec::new()
70        } else {
71            return Err(format!(
72                "Invalid mask: expected ':' before residue selection, got '{}'",
73                res_part
74            ));
75        };
76        let atom_names = if let Some(ap) = atom_part {
77            ap.split(',')
78                .map(|s| s.trim().to_string())
79                .filter(|s| !s.is_empty())
80                .collect()
81        } else {
82            Vec::new()
83        };
84        Ok(AmberMask {
85            residues,
86            atom_names,
87        })
88    }
89    /// Whether a residue number (1-based) is selected by this mask.
90    pub fn matches_residue(&self, res_num: usize) -> bool {
91        if self.residues.is_empty() {
92            true
93        } else {
94            self.residues.contains(&res_num)
95        }
96    }
97    /// Whether an atom name is selected by this mask.
98    pub fn matches_atom(&self, atom_name: &str) -> bool {
99        if self.atom_names.is_empty() {
100            true
101        } else {
102            self.atom_names.iter().any(|n| n == atom_name)
103        }
104    }
105    /// Whether a given (residue_num, atom_name) pair is selected.
106    pub fn matches(&self, res_num: usize, atom_name: &str) -> bool {
107        self.matches_residue(res_num) && self.matches_atom(atom_name)
108    }
109}
110/// All topology data parsed from a prmtop file.
111pub struct AmberTopology {
112    /// Title string from the `%TITLE` or `TITLE` flag section.
113    pub title: String,
114    /// Per-atom data.
115    pub atoms: Vec<AmberAtom>,
116    /// Bond list with parameters.
117    pub bonds: Vec<AmberBond>,
118    /// Angle list with parameters.
119    pub angles: Vec<AmberAngle>,
120    /// Number of atoms (from `POINTERS`).
121    pub n_atoms: usize,
122    /// Number of bonds including hydrogen (from `POINTERS`).
123    pub n_bonds: usize,
124}
125impl AmberTopology {
126    /// Parse a prmtop file (given as a string) into an `AmberTopology`.
127    ///
128    /// Recognises `%FLAG` / `%FORMAT` blocks.  Only the sections needed for
129    /// basic force-field data are extracted; unknown flags are silently skipped.
130    pub fn from_prmtop_str(s: &str) -> Result<Self, String> {
131        let title = {
132            let raw = parse_flag_section(s, "TITLE")
133                .or_else(|| parse_flag_section(s, "title"))
134                .unwrap_or_default();
135            raw.trim().to_string()
136        };
137        let pointers = parse_flag_section(s, "POINTERS")
138            .map(|raw| parse_fortran_ints(&raw))
139            .unwrap_or_default();
140        let n_atoms = pointers.first().copied().unwrap_or(0) as usize;
141        let n_bonds = pointers.get(2).copied().unwrap_or(0) as usize;
142        let n_angles = pointers.get(4).copied().unwrap_or(0) as usize;
143        let atom_names: Vec<String> = parse_flag_section(s, "ATOM_NAME")
144            .map(|raw| parse_fortran_strings(&raw))
145            .unwrap_or_default();
146        const AMBER_CHARGE_SCALE: f64 = 18.2223;
147        let raw_charges: Vec<f64> = parse_flag_section(s, "CHARGE")
148            .map(|raw| parse_fortran_reals(&raw))
149            .unwrap_or_default();
150        let charges: Vec<f64> = raw_charges
151            .iter()
152            .map(|&q| q / AMBER_CHARGE_SCALE)
153            .collect();
154        let masses: Vec<f64> = parse_flag_section(s, "MASS")
155            .map(|raw| parse_fortran_reals(&raw))
156            .unwrap_or_default();
157        let atom_types: Vec<String> = parse_flag_section(s, "AMBER_ATOM_TYPE")
158            .map(|raw| parse_fortran_strings(&raw))
159            .unwrap_or_default();
160        let residue_labels: Vec<String> = parse_flag_section(s, "RESIDUE_LABEL")
161            .map(|raw| parse_fortran_strings(&raw))
162            .unwrap_or_default();
163        let residue_ptrs: Vec<i64> = parse_flag_section(s, "RESIDUE_POINTER")
164            .map(|raw| parse_fortran_ints(&raw))
165            .unwrap_or_default();
166        let atom_residue: Vec<String> = (0..n_atoms)
167            .map(|atom_idx| {
168                let res_idx = residue_ptrs
169                    .iter()
170                    .rposition(|&p| (p as usize) <= atom_idx + 1)
171                    .unwrap_or(0);
172                residue_labels
173                    .get(res_idx)
174                    .cloned()
175                    .unwrap_or_else(|| "UNK".to_string())
176            })
177            .collect();
178        let atoms: Vec<AmberAtom> = (0..n_atoms)
179            .map(|i| AmberAtom {
180                name: atom_names.get(i).cloned().unwrap_or_default(),
181                residue_name: atom_residue.get(i).cloned().unwrap_or_default(),
182                charge: charges.get(i).copied().unwrap_or(0.0),
183                mass: masses.get(i).copied().unwrap_or(0.0),
184                atom_type: atom_types.get(i).cloned().unwrap_or_default(),
185            })
186            .collect();
187        let bond_ints_h: Vec<i64> = parse_flag_section(s, "BONDS_INC_HYDROGEN")
188            .map(|raw| parse_fortran_ints(&raw))
189            .unwrap_or_default();
190        let bond_ints_no_h: Vec<i64> = parse_flag_section(s, "BONDS_WITHOUT_HYDROGEN")
191            .map(|raw| parse_fortran_ints(&raw))
192            .unwrap_or_default();
193        let bond_force_k: Vec<f64> = parse_flag_section(s, "BOND_FORCE_CONSTANT")
194            .map(|raw| parse_fortran_reals(&raw))
195            .unwrap_or_default();
196        let bond_equil_r: Vec<f64> = parse_flag_section(s, "BOND_EQUIL_VALUE")
197            .map(|raw| parse_fortran_reals(&raw))
198            .unwrap_or_default();
199        let mut bonds: Vec<AmberBond> = build_bonds(&bond_ints_h, &bond_force_k, &bond_equil_r);
200        bonds.extend(build_bonds(&bond_ints_no_h, &bond_force_k, &bond_equil_r));
201        let angle_ints_h: Vec<i64> = parse_flag_section(s, "ANGLES_INC_HYDROGEN")
202            .map(|raw| parse_fortran_ints(&raw))
203            .unwrap_or_default();
204        let angle_ints_no_h: Vec<i64> = parse_flag_section(s, "ANGLES_WITHOUT_HYDROGEN")
205            .map(|raw| parse_fortran_ints(&raw))
206            .unwrap_or_default();
207        let angle_force_k: Vec<f64> = parse_flag_section(s, "ANGLE_FORCE_CONSTANT")
208            .map(|raw| parse_fortran_reals(&raw))
209            .unwrap_or_default();
210        let angle_equil_t: Vec<f64> = parse_flag_section(s, "ANGLE_EQUIL_VALUE")
211            .map(|raw| parse_fortran_reals(&raw))
212            .unwrap_or_default();
213        let mut angles: Vec<AmberAngle> =
214            build_angles(&angle_ints_h, &angle_force_k, &angle_equil_t);
215        angles.extend(build_angles(
216            &angle_ints_no_h,
217            &angle_force_k,
218            &angle_equil_t,
219        ));
220        let n_bonds_actual = n_bonds.max(bonds.len());
221        let _ = n_angles;
222        Ok(AmberTopology {
223            title,
224            atoms,
225            bonds,
226            angles,
227            n_atoms,
228            n_bonds: n_bonds_actual,
229        })
230    }
231    /// Sum of all partial charges (should be close to an integer for a physical system).
232    pub fn total_charge(&self) -> f64 {
233        self.atoms.iter().map(|a| a.charge).sum()
234    }
235    /// Sum of all atomic masses.
236    pub fn total_mass(&self) -> f64 {
237        self.atoms.iter().map(|a| a.mass).sum()
238    }
239    /// Sorted, deduplicated list of residue names.
240    pub fn residue_names(&self) -> Vec<String> {
241        let mut names: Vec<String> = self.atoms.iter().map(|a| a.residue_name.clone()).collect();
242        names.sort();
243        names.dedup();
244        names
245    }
246    /// Human-readable summary of the topology.
247    pub fn write_summary(&self) -> String {
248        let mut s = String::new();
249        s.push_str(&format!("Title      : {}\n", self.title));
250        s.push_str(&format!("Atoms      : {}\n", self.atoms.len()));
251        s.push_str(&format!("Bonds      : {}\n", self.bonds.len()));
252        s.push_str(&format!("Angles     : {}\n", self.angles.len()));
253        s.push_str(&format!("Total charge : {:.4} e\n", self.total_charge()));
254        s.push_str(&format!("Total mass   : {:.4} amu\n", self.total_mass()));
255        let res = self.residue_names();
256        s.push_str(&format!("Residues   : {}\n", res.join(", ")));
257        s
258    }
259}
260impl AmberTopology {
261    /// Sorted, deduplicated list of atom type names.
262    #[allow(dead_code)]
263    pub fn atom_type_names(&self) -> Vec<String> {
264        let mut names: Vec<String> = self.atoms.iter().map(|a| a.atom_type.clone()).collect();
265        names.sort();
266        names.dedup();
267        names
268    }
269    /// Number of unique atom types.
270    #[allow(dead_code)]
271    pub fn n_atom_types(&self) -> usize {
272        self.atom_type_names().len()
273    }
274    /// Get atoms belonging to a specific residue name.
275    #[allow(dead_code)]
276    pub fn atoms_in_residue(&self, res_name: &str) -> Vec<usize> {
277        self.atoms
278            .iter()
279            .enumerate()
280            .filter(|(_, a)| a.residue_name == res_name)
281            .map(|(i, _)| i)
282            .collect()
283    }
284}
285/// A parsed energy term line from an AMBER output or mdout file.
286///
287/// Lines typically look like:
288/// ```text
289///  NSTEP =       50  TIME(PS) =  0.050000  TEMP(K) =  300.1  PRESS =     0.0
290///  Etot   =   -1234.56  EKtot   =    456.78  EPtot   =   -1691.34
291/// ```
292#[allow(dead_code)]
293#[derive(Debug, Clone)]
294pub struct AmberEnergyFrame {
295    /// Simulation step number.
296    pub step: u64,
297    /// Simulation time in picoseconds.
298    pub time_ps: f64,
299    /// Temperature in Kelvin.
300    pub temp_k: f64,
301    /// Total energy (kcal mol⁻¹).
302    pub e_tot: f64,
303    /// Kinetic energy (kcal mol⁻¹).
304    pub e_kin: f64,
305    /// Potential energy (kcal mol⁻¹).
306    pub e_pot: f64,
307}
308/// A dihedral entry from an AMBER FRCMOD file.
309#[allow(dead_code)]
310#[derive(Debug, Clone)]
311pub struct FrcmodDihedral {
312    /// Atom types quadruplet (e.g., "X-CT-CT-X").
313    pub atom_types: String,
314    /// Dividing factor (n-fold).
315    pub n_fold: f64,
316    /// Barrier height (kcal mol⁻¹).
317    pub v_n: f64,
318    /// Phase offset (degrees).
319    pub gamma_deg: f64,
320    /// Periodicity (may be negative to continue with next term).
321    pub periodicity: f64,
322}
323/// An angle entry from an AMBER FRCMOD file.
324#[allow(dead_code)]
325#[derive(Debug, Clone)]
326pub struct FrcmodAngle {
327    /// Atom types triplet (e.g., "HC-CT-HC").
328    pub atom_types: String,
329    /// Force constant (kcal mol⁻¹ rad⁻²).
330    pub k: f64,
331    /// Equilibrium angle (degrees).
332    pub theta0_deg: f64,
333}
334/// A non-bonded (Lennard-Jones) entry from an AMBER FRCMOD file.
335#[allow(dead_code)]
336#[derive(Debug, Clone)]
337pub struct FrcmodNonbonded {
338    /// Atom type.
339    pub atom_type: String,
340    /// R* (van der Waals radius, Å).
341    pub r_star: f64,
342    /// ε well depth (kcal mol⁻¹).
343    pub epsilon: f64,
344}
345/// A single frame from an AMBER MDCRD trajectory.
346#[allow(dead_code)]
347#[derive(Debug, Clone)]
348pub struct MdcrdFrame {
349    /// Cartesian coordinates (Å), layout `[x0, y0, z0, x1, y1, z1, ...]`.
350    pub coords: Vec<f64>,
351    /// Optional periodic box `[a, b, c]` (last line if present).
352    pub box_dims: Option<[f64; 3]>,
353}
354#[allow(dead_code)]
355impl MdcrdFrame {
356    /// Get position of atom `i` as `[x, y, z]`.
357    pub fn position(&self, i: usize) -> Option<[f64; 3]> {
358        let base = i * 3;
359        if base + 2 < self.coords.len() {
360            Some([
361                self.coords[base],
362                self.coords[base + 1],
363                self.coords[base + 2],
364            ])
365        } else {
366            None
367        }
368    }
369    /// Number of atoms in this frame.
370    pub fn n_atoms(&self) -> usize {
371        self.coords.len() / 3
372    }
373}
374/// Parsed AMBER FRCMOD (force field modification) file.
375#[allow(dead_code)]
376#[derive(Debug, Clone, Default)]
377pub struct FrcmodFile {
378    /// Title/comment line.
379    pub title: String,
380    /// Modified bond parameters.
381    pub bonds: Vec<FrcmodBond>,
382    /// Modified angle parameters.
383    pub angles: Vec<FrcmodAngle>,
384    /// Modified dihedral parameters.
385    pub dihedrals: Vec<FrcmodDihedral>,
386    /// Modified non-bonded (LJ) parameters.
387    pub nonbonded: Vec<FrcmodNonbonded>,
388}
389#[allow(dead_code)]
390impl FrcmodFile {
391    /// Parse an AMBER FRCMOD file from a string.
392    ///
393    /// Sections are:
394    /// - `MASS`  – atom masses (skipped here)
395    /// - `BOND`  – bond parameters
396    /// - `ANGL`  – angle parameters
397    /// - `DIHE`  – dihedral parameters
398    /// - `IMPR`  – improper dihedral parameters
399    /// - `NONB`  – non-bonded (LJ) parameters
400    pub fn from_str(s: &str) -> Result<Self, String> {
401        let mut frc = FrcmodFile::default();
402        let mut section = FrcmodSection::None;
403        let mut first_line = true;
404        for line in s.lines() {
405            let trimmed = line.trim();
406            if first_line {
407                frc.title = trimmed.to_string();
408                first_line = false;
409                continue;
410            }
411            let upper = trimmed.to_uppercase();
412            if upper.starts_with("MASS") {
413                section = FrcmodSection::Mass;
414                continue;
415            } else if upper.starts_with("BOND") {
416                section = FrcmodSection::Bond;
417                continue;
418            } else if upper.starts_with("ANGL") {
419                section = FrcmodSection::Angle;
420                continue;
421            } else if upper.starts_with("DIHE") {
422                section = FrcmodSection::Dihe;
423                continue;
424            } else if upper.starts_with("IMPR") {
425                section = FrcmodSection::Impr;
426                continue;
427            } else if upper.starts_with("NONB") {
428                section = FrcmodSection::Nonb;
429                continue;
430            } else if upper.starts_with("END") {
431                section = FrcmodSection::None;
432                continue;
433            }
434            if trimmed.is_empty() {
435                continue;
436            }
437            match section {
438                FrcmodSection::Bond => {
439                    let data = strip_amber_comment(trimmed);
440                    let parts: Vec<&str> = data
441                        .splitn(3, char::is_whitespace)
442                        .filter(|s| !s.is_empty())
443                        .collect();
444                    let nums = collect_numbers(data);
445                    if !parts.is_empty() && nums.len() >= 2 {
446                        frc.bonds.push(FrcmodBond {
447                            atom_types: parts[0].to_string(),
448                            k: nums[0],
449                            r0: nums[1],
450                        });
451                    }
452                }
453                FrcmodSection::Angle => {
454                    let data = strip_amber_comment(trimmed);
455                    let parts: Vec<&str> = data
456                        .splitn(2, char::is_whitespace)
457                        .filter(|s| !s.is_empty())
458                        .collect();
459                    let nums = collect_numbers(data);
460                    if !parts.is_empty() && nums.len() >= 2 {
461                        frc.angles.push(FrcmodAngle {
462                            atom_types: parts[0].to_string(),
463                            k: nums[0],
464                            theta0_deg: nums[1],
465                        });
466                    }
467                }
468                FrcmodSection::Dihe | FrcmodSection::Impr => {
469                    let data = strip_amber_comment(trimmed);
470                    let parts: Vec<&str> = data
471                        .splitn(2, char::is_whitespace)
472                        .filter(|s| !s.is_empty())
473                        .collect();
474                    let nums = collect_numbers(data);
475                    if !parts.is_empty() && nums.len() >= 4 {
476                        frc.dihedrals.push(FrcmodDihedral {
477                            atom_types: parts[0].to_string(),
478                            n_fold: nums[0],
479                            v_n: nums[1],
480                            gamma_deg: nums[2],
481                            periodicity: nums[3],
482                        });
483                    }
484                }
485                FrcmodSection::Nonb => {
486                    let nums = collect_numbers(trimmed);
487                    let parts: Vec<&str> = trimmed.split_whitespace().collect();
488                    if !parts.is_empty() && nums.len() >= 2 {
489                        frc.nonbonded.push(FrcmodNonbonded {
490                            atom_type: parts[0].to_string(),
491                            r_star: nums[0],
492                            epsilon: nums[1],
493                        });
494                    }
495                }
496                _ => {}
497            }
498        }
499        Ok(frc)
500    }
501    /// Number of bond entries.
502    pub fn n_bonds(&self) -> usize {
503        self.bonds.len()
504    }
505    /// Number of angle entries.
506    pub fn n_angles(&self) -> usize {
507        self.angles.len()
508    }
509    /// Number of dihedral entries.
510    pub fn n_dihedrals(&self) -> usize {
511        self.dihedrals.len()
512    }
513    /// Number of nonbonded entries.
514    pub fn n_nonbonded(&self) -> usize {
515        self.nonbonded.len()
516    }
517    /// Look up a bond by atom type pair string (e.g., "CT-HC").
518    pub fn get_bond(&self, types: &str) -> Option<&FrcmodBond> {
519        self.bonds.iter().find(|b| {
520            b.atom_types == types || {
521                let parts: Vec<&str> = types.split('-').collect();
522                if parts.len() == 2 {
523                    let rev = format!("{}-{}", parts[1], parts[0]);
524                    b.atom_types == rev
525                } else {
526                    false
527                }
528            }
529        })
530    }
531    /// Look up nonbonded parameters by atom type.
532    pub fn get_nonbonded(&self, atom_type: &str) -> Option<&FrcmodNonbonded> {
533        self.nonbonded.iter().find(|n| n.atom_type == atom_type)
534    }
535}
536/// FRCMOD section types.
537#[derive(Debug, Clone, PartialEq)]
538pub(super) enum FrcmodSection {
539    None,
540    Mass,
541    Bond,
542    Angle,
543    Dihe,
544    Impr,
545    Nonb,
546}
547/// A minimal AMBER RST7 restart record (coordinates + optional velocities).
548#[allow(dead_code)]
549#[derive(Debug, Clone, Default)]
550pub struct AmberRst7 {
551    /// Title string.
552    pub title: String,
553    /// Atom positions (Å).
554    pub positions: Vec<[f64; 3]>,
555    /// Atom velocities (Å ps⁻¹), optional.
556    pub velocities: Vec<[f64; 3]>,
557}
558#[allow(dead_code)]
559impl AmberRst7 {
560    /// Create a new RST7 record.
561    pub fn new(title: &str, positions: Vec<[f64; 3]>) -> Self {
562        Self {
563            title: title.to_string(),
564            positions,
565            velocities: Vec::new(),
566        }
567    }
568    /// Attach velocity data to this restart record.
569    ///
570    /// `velocities` must have the same length as `positions`; returns an error
571    /// otherwise.
572    pub fn write_velocity(&mut self, velocities: Vec<[f64; 3]>) -> std::result::Result<(), String> {
573        if velocities.len() != self.positions.len() {
574            return Err(format!(
575                "velocity count {} != position count {}",
576                velocities.len(),
577                self.positions.len()
578            ));
579        }
580        self.velocities = velocities;
581        Ok(())
582    }
583    /// Serialize to AMBER inpcrd/rst7 format string.
584    pub fn to_string_repr(&self) -> String {
585        write_inpcrd(
586            &self.title,
587            &self.positions,
588            if self.velocities.is_empty() {
589                None
590            } else {
591                Some(&self.velocities)
592            },
593        )
594    }
595    /// Number of atoms.
596    pub fn n_atoms(&self) -> usize {
597        self.positions.len()
598    }
599    /// Whether velocity data is present.
600    pub fn has_velocities(&self) -> bool {
601        !self.velocities.is_empty()
602    }
603}
604/// One atom entry from an AMBER topology file.
605#[allow(dead_code)]
606pub struct AmberAtom {
607    /// Atom name (from `ATOM_NAME`).
608    pub name: String,
609    /// Residue name (from `RESIDUE_LABEL`).
610    pub residue_name: String,
611    /// Partial charge in electron-charge units (AMBER stores as e / 18.2223).
612    pub charge: f64,
613    /// Atomic mass in amu.
614    pub mass: f64,
615    /// AMBER atom-type string (from `AMBER_ATOM_TYPE`).
616    pub atom_type: String,
617}
618/// A dihedral (torsion) parameter from AMBER topology.
619#[allow(dead_code)]
620#[derive(Debug, Clone)]
621pub struct AmberDihedral {
622    /// Zero-based index of atom i (first).
623    pub i: usize,
624    /// Zero-based index of atom j.
625    pub j: usize,
626    /// Zero-based index of atom k.
627    pub k: usize,
628    /// Zero-based index of atom l (last).
629    pub l: usize,
630    /// Barrier height (kcal mol⁻¹).
631    pub v_n: f64,
632    /// Phase offset (radians).
633    pub gamma: f64,
634    /// Periodicity (integer, usually 1–6).
635    pub n: i32,
636    /// `true` if this is a 1-4 non-bonded exclusion.
637    pub is_improper: bool,
638}
639/// A bond entry from an AMBER FRCMOD file.
640#[allow(dead_code)]
641#[derive(Debug, Clone)]
642pub struct FrcmodBond {
643    /// Atom types (e.g., "CT-HC").
644    pub atom_types: String,
645    /// Force constant (kcal mol⁻¹ Å⁻²).
646    pub k: f64,
647    /// Equilibrium bond length (Å).
648    pub r0: f64,
649}
650/// Parsed AMBER coordinate (inpcrd / restart) data.
651#[allow(dead_code)]
652pub struct AmberCoordinates {
653    /// Title line.
654    pub title: String,
655    /// Number of atoms.
656    pub n_atoms: usize,
657    /// Cartesian coordinates (Angstroms), flattened `[x0, y0, z0, x1, y1, z1, ...]`.
658    pub coords: Vec<f64>,
659    /// Optional velocities (same layout as coords).
660    pub velocities: Option<Vec<f64>>,
661    /// Optional box dimensions `[a, b, c, alpha, beta, gamma]`.
662    pub box_dimensions: Option<[f64; 6]>,
663}
664#[allow(dead_code)]
665impl AmberCoordinates {
666    /// Parse an AMBER coordinate / restart file from a string.
667    ///
668    /// Format: title, natom, then coords in 12.7 format (6 per line).
669    /// If the file has twice as many numbers as 3*natom, the second half
670    /// is treated as velocities.
671    pub fn from_str(s: &str) -> Result<Self, String> {
672        let lines: Vec<&str> = s.lines().collect();
673        if lines.is_empty() {
674            return Err("Empty coordinate file".to_string());
675        }
676        let title = lines[0].trim().to_string();
677        if lines.len() < 2 {
678            return Err("Missing atom count line".to_string());
679        }
680        let n_atoms: usize = lines[1]
681            .split_whitespace()
682            .next()
683            .ok_or("Missing atom count")?
684            .parse()
685            .map_err(|e: std::num::ParseIntError| e.to_string())?;
686        let mut all_vals: Vec<f64> = Vec::new();
687        for line in lines.iter().skip(2) {
688            for tok in line.split_whitespace() {
689                if let Ok(v) = tok.parse::<f64>() {
690                    all_vals.push(v);
691                }
692            }
693        }
694        let n_coords = n_atoms * 3;
695        if all_vals.len() < n_coords {
696            return Err(format!(
697                "Expected {} coordinate values, got {}",
698                n_coords,
699                all_vals.len()
700            ));
701        }
702        let coords = all_vals[..n_coords].to_vec();
703        let velocities = if all_vals.len() >= n_coords * 2 {
704            Some(all_vals[n_coords..n_coords * 2].to_vec())
705        } else {
706            None
707        };
708        let remaining = if velocities.is_some() {
709            &all_vals[n_coords * 2..]
710        } else {
711            &all_vals[n_coords..]
712        };
713        let box_dimensions = if remaining.len() >= 6 {
714            Some([
715                remaining[0],
716                remaining[1],
717                remaining[2],
718                remaining[3],
719                remaining[4],
720                remaining[5],
721            ])
722        } else {
723            None
724        };
725        Ok(AmberCoordinates {
726            title,
727            n_atoms,
728            coords,
729            velocities,
730            box_dimensions,
731        })
732    }
733    /// Get the position of atom `i` as `[x, y, z]`.
734    pub fn position(&self, i: usize) -> [f64; 3] {
735        let base = i * 3;
736        [
737            self.coords[base],
738            self.coords[base + 1],
739            self.coords[base + 2],
740        ]
741    }
742    /// Write coordinates in AMBER restart format.
743    pub fn to_restart_string(&self) -> String {
744        let mut s = format!("{}\n", self.title);
745        s.push_str(&format!("{:5}\n", self.n_atoms));
746        for (i, &v) in self.coords.iter().enumerate() {
747            s.push_str(&format!("{:12.7}", v));
748            if (i + 1) % 6 == 0 {
749                s.push('\n');
750            }
751        }
752        if !self.coords.len().is_multiple_of(6) {
753            s.push('\n');
754        }
755        if let Some(ref vels) = self.velocities {
756            for (i, &v) in vels.iter().enumerate() {
757                s.push_str(&format!("{:12.7}", v));
758                if (i + 1) % 6 == 0 {
759                    s.push('\n');
760                }
761            }
762            if vels.len() % 6 != 0 {
763                s.push('\n');
764            }
765        }
766        if let Some(ref bx) = self.box_dimensions {
767            for &v in bx {
768                s.push_str(&format!("{:12.7}", v));
769            }
770            s.push('\n');
771        }
772        s
773    }
774}
775/// A small inline AMBER force-field parameter record for a bond type.
776#[allow(dead_code)]
777#[derive(Debug, Clone)]
778pub struct FfBondParam {
779    /// Type string of atom A (e.g., `"CT"`).
780    pub type_a: String,
781    /// Type string of atom B (e.g., `"HC"`).
782    pub type_b: String,
783    /// Force constant (kcal mol⁻¹ Å⁻²).
784    pub k: f64,
785    /// Equilibrium length (Å).
786    pub r0: f64,
787}
788/// A distance restraint entry from an AMBER NMR restraint file.
789#[allow(dead_code)]
790#[derive(Debug, Clone)]
791pub struct AmberRestraint {
792    /// Atom 1 index (0-based).
793    pub iat1: usize,
794    /// Atom 2 index (0-based).
795    pub iat2: usize,
796    /// Lower bound (Å).
797    pub r1: f64,
798    /// Lower bound inner (Å).
799    pub r2: f64,
800    /// Upper bound inner (Å).
801    pub r3: f64,
802    /// Upper bound outer (Å).
803    pub r4: f64,
804    /// Force constant (kcal mol⁻¹ Å⁻²).
805    pub rk2: f64,
806    /// Force constant upper (kcal mol⁻¹ Å⁻²).
807    pub rk3: f64,
808}
809impl AmberRestraint {
810    /// Compute the flat-bottom restraint penalty for a given distance `r`.
811    ///
812    /// Returns 0 for `r2 ≤ r ≤ r3`, quadratic outside.
813    #[allow(dead_code)]
814    pub fn energy(&self, r: f64) -> f64 {
815        if r < self.r1 {
816            self.rk2 * (r - self.r2).powi(2)
817        } else if r < self.r2 {
818            self.rk2 * (r - self.r2).powi(2)
819        } else if r <= self.r3 {
820            0.0
821        } else if r <= self.r4 {
822            self.rk3 * (r - self.r3).powi(2)
823        } else {
824            self.rk3 * (r - self.r3).powi(2)
825        }
826    }
827}
828/// A single Lennard-Jones parameter entry (per atom-type pair).
829#[allow(dead_code)]
830#[derive(Debug, Clone)]
831pub struct LjParameter {
832    /// Index of atom type A.
833    pub type_a: usize,
834    /// Index of atom type B.
835    pub type_b: usize,
836    /// A coefficient (kcal mol⁻¹ ʲ).
837    pub a_coeff: f64,
838    /// B coefficient (kcal mol⁻¹ Å⁶).
839    pub b_coeff: f64,
840}
841impl LjParameter {
842    /// Compute the equilibrium distance r_min from A/B coefficients.
843    ///
844    /// r_min = (2A/B)^(1/6)
845    pub fn r_min(&self) -> f64 {
846        if self.b_coeff.abs() < 1e-30 {
847            return 0.0;
848        }
849        (2.0 * self.a_coeff / self.b_coeff).powf(1.0 / 6.0)
850    }
851    /// Well depth ε = B² / (4A).
852    pub fn epsilon(&self) -> f64 {
853        if self.a_coeff.abs() < 1e-30 {
854            return 0.0;
855        }
856        self.b_coeff * self.b_coeff / (4.0 * self.a_coeff)
857    }
858}
859/// A small inline AMBER force-field parameter record for an angle type.
860#[allow(dead_code)]
861#[derive(Debug, Clone)]
862pub struct FfAngleParam {
863    /// Type string of atom i.
864    pub type_i: String,
865    /// Type string of central atom j.
866    pub type_j: String,
867    /// Type string of atom k.
868    pub type_k: String,
869    /// Force constant (kcal mol⁻¹ rad⁻²).
870    pub k: f64,
871    /// Equilibrium angle (degrees).
872    pub theta0_deg: f64,
873}
874/// A valence angle entry.
875#[allow(dead_code)]
876pub struct AmberAngle {
877    /// Zero-based index of the first atom.
878    pub i: usize,
879    /// Zero-based index of the central atom.
880    pub j: usize,
881    /// Index into the angle parameter array.
882    pub k_idx: usize,
883    /// Force constant (kcal mol⁻¹ rad⁻²).
884    pub k: f64,
885    /// Equilibrium angle (radians).
886    pub theta0: f64,
887}
888/// Periodic box dimensions for AMBER simulations.
889#[allow(dead_code)]
890#[derive(Debug, Clone)]
891pub struct AmberBox {
892    /// Box length in X direction (Å).
893    pub a: f64,
894    /// Box length in Y direction (Å).
895    pub b: f64,
896    /// Box length in Z direction (Å).
897    pub c: f64,
898    /// Box angle alpha (degrees).
899    pub alpha: f64,
900    /// Box angle beta (degrees).
901    pub beta: f64,
902    /// Box angle gamma (degrees).
903    pub gamma: f64,
904}
905#[allow(dead_code)]
906impl AmberBox {
907    /// Create a cubic box with side length `a` (Å).
908    pub fn cubic(a: f64) -> Self {
909        AmberBox {
910            a,
911            b: a,
912            c: a,
913            alpha: 90.0,
914            beta: 90.0,
915            gamma: 90.0,
916        }
917    }
918    /// Create an orthorhombic box.
919    pub fn orthorhombic(a: f64, b: f64, c: f64) -> Self {
920        AmberBox {
921            a,
922            b,
923            c,
924            alpha: 90.0,
925            beta: 90.0,
926            gamma: 90.0,
927        }
928    }
929    /// Whether the box is cubic (all sides equal, all angles 90°).
930    pub fn is_cubic(&self) -> bool {
931        (self.a - self.b).abs() < 1e-8
932            && (self.b - self.c).abs() < 1e-8
933            && (self.alpha - 90.0).abs() < 1e-8
934            && (self.beta - 90.0).abs() < 1e-8
935            && (self.gamma - 90.0).abs() < 1e-8
936    }
937    /// Whether the box is orthorhombic (all angles 90°).
938    pub fn is_orthorhombic(&self) -> bool {
939        (self.alpha - 90.0).abs() < 1e-8
940            && (self.beta - 90.0).abs() < 1e-8
941            && (self.gamma - 90.0).abs() < 1e-8
942    }
943    /// Compute the volume (ų) using the general parallelepiped formula.
944    pub fn volume(&self) -> f64 {
945        use std::f64::consts::PI;
946        let to_rad = |deg: f64| deg * PI / 180.0;
947        let (ca, cb, cg) = (
948            to_rad(self.alpha).cos(),
949            to_rad(self.beta).cos(),
950            to_rad(self.gamma).cos(),
951        );
952        let v = (1.0 + 2.0 * ca * cb * cg - ca * ca - cb * cb - cg * cg).sqrt();
953        self.a * self.b * self.c * v
954    }
955    /// Convert box dimensions to Å (already in Å, returns same object).
956    pub fn in_angstrom(&self) -> Self {
957        self.clone()
958    }
959    /// Convert box dimensions to nm (divide all lengths by 10).
960    pub fn in_nm(&self) -> [f64; 6] {
961        [
962            self.a / 10.0,
963            self.b / 10.0,
964            self.c / 10.0,
965            self.alpha,
966            self.beta,
967            self.gamma,
968        ]
969    }
970}
971/// Simplified DCD trajectory writer (CHARMM/NAMD binary format).
972///
973/// Writes a self-contained binary header on construction, then one frame
974/// at a time via [`AmberDcd::write_frame`].  The output is a valid DCD
975/// (little-endian FORTRAN record format) readable by MDAnalysis / VMD.
976///
977/// Note: this implementation writes a simplified 84-byte header without
978/// the full CHARMM title block; it is intended for testing and round-trip
979/// verification in OxiPhysics.
980#[allow(dead_code)]
981pub struct AmberDcd {
982    /// Accumulated frame data (flat x, y, z per frame, f32 LE).
983    pub frames: Vec<Vec<f32>>,
984    /// Number of atoms (fixed across frames).
985    pub n_atoms: usize,
986}
987#[allow(dead_code)]
988impl AmberDcd {
989    /// Create a new DCD trajectory writer for `n_atoms` atoms.
990    pub fn new(n_atoms: usize) -> Self {
991        Self {
992            frames: Vec::new(),
993            n_atoms,
994        }
995    }
996    /// Append one frame of coordinates.
997    ///
998    /// `positions` must have length `n_atoms`; returns an error otherwise.
999    pub fn write_frame(&mut self, positions: &[[f64; 3]]) -> std::result::Result<(), String> {
1000        if positions.len() != self.n_atoms {
1001            return Err(format!(
1002                "expected {} atoms, got {}",
1003                self.n_atoms,
1004                positions.len()
1005            ));
1006        }
1007        let flat: Vec<f32> = positions
1008            .iter()
1009            .flat_map(|p| p.iter().map(|&v| v as f32))
1010            .collect();
1011        self.frames.push(flat);
1012        Ok(())
1013    }
1014    /// Number of frames written.
1015    pub fn n_frames(&self) -> usize {
1016        self.frames.len()
1017    }
1018    /// Get position of atom `atom_idx` in frame `frame_idx` as f32.
1019    pub fn get_position(&self, frame_idx: usize, atom_idx: usize) -> Option<[f32; 3]> {
1020        let frame = self.frames.get(frame_idx)?;
1021        let base = atom_idx * 3;
1022        if base + 2 >= frame.len() {
1023            return None;
1024        }
1025        Some([frame[base], frame[base + 1], frame[base + 2]])
1026    }
1027    /// Serialize to a simplified DCD binary representation.
1028    ///
1029    /// Layout (little-endian):
1030    /// - 4 bytes: n_atoms (u32)
1031    /// - 4 bytes: n_frames (u32)
1032    /// - For each frame: 4 bytes record_len (u32), then n_atoms*3*4 bytes (f32 LE)
1033    pub fn to_bytes(&self) -> Vec<u8> {
1034        let mut out = Vec::new();
1035        let n_atoms = self.n_atoms as u32;
1036        let n_frames = self.frames.len() as u32;
1037        out.extend_from_slice(&n_atoms.to_le_bytes());
1038        out.extend_from_slice(&n_frames.to_le_bytes());
1039        for frame in &self.frames {
1040            let record_len = (frame.len() * 4) as u32;
1041            out.extend_from_slice(&record_len.to_le_bytes());
1042            for &v in frame {
1043                out.extend_from_slice(&v.to_le_bytes());
1044            }
1045        }
1046        out
1047    }
1048    /// Deserialize from the simplified DCD binary representation produced by
1049    /// [`AmberDcd::to_bytes`].
1050    pub fn from_bytes(data: &[u8]) -> std::result::Result<Self, String> {
1051        if data.len() < 8 {
1052            return Err("DCD: too short".into());
1053        }
1054        let n_atoms = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize;
1055        let n_frames = u32::from_le_bytes([data[4], data[5], data[6], data[7]]) as usize;
1056        let mut dcd = AmberDcd::new(n_atoms);
1057        let mut offset = 8;
1058        for _ in 0..n_frames {
1059            if offset + 4 > data.len() {
1060                return Err("DCD: truncated frame record length".into());
1061            }
1062            let record_len = u32::from_le_bytes([
1063                data[offset],
1064                data[offset + 1],
1065                data[offset + 2],
1066                data[offset + 3],
1067            ]) as usize;
1068            offset += 4;
1069            if offset + record_len > data.len() {
1070                return Err("DCD: truncated frame data".into());
1071            }
1072            let n_floats = record_len / 4;
1073            let mut frame = Vec::with_capacity(n_floats);
1074            for i in 0..n_floats {
1075                let b = &data[offset + i * 4..offset + i * 4 + 4];
1076                frame.push(f32::from_le_bytes([b[0], b[1], b[2], b[3]]));
1077            }
1078            dcd.frames.push(frame);
1079            offset += record_len;
1080        }
1081        Ok(dcd)
1082    }
1083}