Skip to main content

oxiphysics_io/amber/
functions.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5#![allow(clippy::manual_strip)]
6#![allow(clippy::manual_range_contains)]
7use super::types::{
8    AmberAngle, AmberBond, AmberCoordinates, AmberDihedral, AmberEnergyFrame, AmberRestraint,
9    AmberTopology, FfAngleParam, FfBondParam, LjParameter, MdcrdFrame,
10};
11
12/// Extract the raw text body of a `%FLAG `name` section.
13///
14/// Returns the lines after `%FORMAT(...)` up to the next `%FLAG` or EOF.
15pub fn parse_flag_section(content: &str, flag_name: &str) -> Option<String> {
16    let target = format!("%FLAG {}", flag_name.to_uppercase());
17    let lines: Vec<&str> = content.lines().collect();
18    let flag_pos = lines.iter().position(|l| l.trim() == target.trim())?;
19    let data_start = flag_pos + 2;
20    let mut body = String::new();
21    for line in lines.iter().skip(data_start) {
22        let trimmed = line.trim();
23        if trimmed.starts_with("%FLAG") || trimmed.starts_with("%VERSION") {
24            break;
25        }
26        body.push_str(line);
27        body.push('\n');
28    }
29    Some(body)
30}
31/// Parse Fortran-formatted real numbers (fixed-width or space-separated).
32pub fn parse_fortran_reals(s: &str) -> Vec<f64> {
33    s.split_whitespace()
34        .filter_map(|tok| tok.replace('D', "E").replace('d', "e").parse::<f64>().ok())
35        .collect()
36}
37/// Parse Fortran-formatted integers (space-separated).
38pub fn parse_fortran_ints(s: &str) -> Vec<i64> {
39    s.split_whitespace()
40        .filter_map(|tok| tok.parse::<i64>().ok())
41        .collect()
42}
43/// Parse Fortran-formatted string tokens (fixed-width 4-char columns or whitespace separated).
44pub(super) fn parse_fortran_strings(s: &str) -> Vec<String> {
45    let mut result = Vec::new();
46    for line in s.lines() {
47        if line.trim().is_empty() {
48            continue;
49        }
50        let chars: Vec<char> = line.chars().collect();
51        if chars.len() >= 4 {
52            let mut i = 0;
53            while i + 4 <= chars.len() {
54                let tok: String = chars[i..i + 4].iter().collect();
55                let trimmed = tok.trim().to_string();
56                if !trimmed.is_empty() {
57                    result.push(trimmed);
58                }
59                i += 4;
60            }
61        } else {
62            for tok in line.split_whitespace() {
63                result.push(tok.to_string());
64            }
65        }
66    }
67    result
68}
69/// Build `AmberBond` entries from a flat integer array (i*3, j*3, type_idx triplets).
70pub(super) fn build_bonds(ints: &[i64], force_k: &[f64], equil_r: &[f64]) -> Vec<AmberBond> {
71    let mut bonds = Vec::new();
72    let mut idx = 0;
73    while idx + 2 < ints.len() {
74        let i = (ints[idx] / 3) as usize;
75        let j = (ints[idx + 1] / 3) as usize;
76        let type_idx = (ints[idx + 2] - 1).max(0) as usize;
77        let k = force_k.get(type_idx).copied().unwrap_or(0.0);
78        let r0 = equil_r.get(type_idx).copied().unwrap_or(0.0);
79        bonds.push(AmberBond { i, j, k, r0 });
80        idx += 3;
81    }
82    bonds
83}
84/// Build `AmberAngle` entries from a flat integer array (i*3, j*3, k*3, type_idx quads).
85pub(super) fn build_angles(ints: &[i64], force_k: &[f64], equil_t: &[f64]) -> Vec<AmberAngle> {
86    let mut angles = Vec::new();
87    let mut idx = 0;
88    while idx + 3 < ints.len() {
89        let i = (ints[idx] / 3) as usize;
90        let j = (ints[idx + 1] / 3) as usize;
91        let k_idx = (ints[idx + 3] - 1).max(0) as usize;
92        let k = force_k.get(k_idx).copied().unwrap_or(0.0);
93        let theta0 = equil_t.get(k_idx).copied().unwrap_or(0.0);
94        angles.push(AmberAngle {
95            i,
96            j,
97            k_idx,
98            k,
99            theta0,
100        });
101        idx += 4;
102    }
103    angles
104}
105/// Parse Lennard-Jones A/B coefficient pairs from a raw prmtop string.
106///
107/// Looks for the `%FLAG LENNARD_JONES_ACOEF` and `%FLAG LENNARD_JONES_BCOEF`
108/// sections and returns one [`LjParameter`] per pair.
109///
110/// This is a best-effort parser; it handles well-formed AMBER 7+ files only.
111#[allow(dead_code)]
112pub fn extract_lj_parameters(prmtop: &str) -> Vec<LjParameter> {
113    let a_vals = extract_flag_floats(prmtop, "LENNARD_JONES_ACOEF");
114    let b_vals = extract_flag_floats(prmtop, "LENNARD_JONES_BCOEF");
115    let n = a_vals.len().min(b_vals.len());
116    (0..n)
117        .map(|i| LjParameter {
118            type_a: i,
119            type_b: i,
120            a_coeff: a_vals[i],
121            b_coeff: b_vals[i],
122        })
123        .collect()
124}
125/// Parse dihedral entries from `%FLAG DIHEDRALS_INC_HYDROGEN` and
126/// `%FLAG DIHEDRALS_WITHOUT_HYDROGEN` sections.
127///
128/// Returns a flat list of [`AmberDihedral`]s in the order they appear.
129#[allow(dead_code)]
130pub fn extract_dihedrals(prmtop: &str) -> Vec<AmberDihedral> {
131    let mut out = Vec::new();
132    for flag in &["DIHEDRALS_INC_HYDROGEN", "DIHEDRALS_WITHOUT_HYDROGEN"] {
133        let raw = extract_flag_integers(prmtop, flag);
134        let chunks = raw.chunks_exact(5);
135        for chunk in chunks {
136            let is_improper = chunk[2] < 0;
137            out.push(AmberDihedral {
138                i: (chunk[0].abs() / 3) as usize,
139                j: (chunk[1].abs() / 3) as usize,
140                k: (chunk[2].abs() / 3) as usize,
141                l: (chunk[3].abs() / 3) as usize,
142                v_n: 0.0,
143                gamma: 0.0,
144                n: chunk[4].abs(),
145                is_improper,
146            });
147        }
148    }
149    out
150}
151/// Parse a sequence of energy frames from an AMBER mdout-style string.
152///
153/// Only lines containing `NSTEP =` and `Etot` are considered; the rest are
154/// skipped.  Returns one frame per `NSTEP` block.
155#[allow(dead_code)]
156pub fn parse_energy_frames(mdout: &str) -> Vec<AmberEnergyFrame> {
157    let mut frames = Vec::new();
158    let mut current_step: Option<u64> = None;
159    let mut current_time: f64 = 0.0;
160    let mut current_temp: f64 = 0.0;
161    let mut e_tot = 0.0f64;
162    let mut e_kin = 0.0f64;
163    let mut e_pot = 0.0f64;
164    let mut have_energy = false;
165    for line in mdout.lines() {
166        let t = line.trim();
167        if t.contains("NSTEP =") {
168            if let Some(step) = current_step.take() {
169                if have_energy {
170                    frames.push(AmberEnergyFrame {
171                        step,
172                        time_ps: current_time,
173                        temp_k: current_temp,
174                        e_tot,
175                        e_kin,
176                        e_pot,
177                    });
178                }
179                have_energy = false;
180            }
181            current_step = parse_field(t, "NSTEP =").and_then(|s| s.parse().ok());
182            current_time = parse_field(t, "TIME(PS) =")
183                .and_then(|s| s.parse().ok())
184                .unwrap_or(0.0);
185            current_temp = parse_field(t, "TEMP(K) =")
186                .and_then(|s| s.parse().ok())
187                .unwrap_or(0.0);
188        } else if t.starts_with("Etot") {
189            e_tot = parse_field(t, "Etot   =")
190                .and_then(|s| s.parse().ok())
191                .unwrap_or(0.0);
192            e_kin = parse_field(t, "EKtot   =")
193                .and_then(|s| s.parse().ok())
194                .unwrap_or(0.0);
195            e_pot = parse_field(t, "EPtot   =")
196                .and_then(|s| s.parse().ok())
197                .unwrap_or(0.0);
198            have_energy = true;
199        }
200    }
201    if let Some(step) = current_step
202        && have_energy
203    {
204        frames.push(AmberEnergyFrame {
205            step,
206            time_ps: current_time,
207            temp_k: current_temp,
208            e_tot,
209            e_kin,
210            e_pot,
211        });
212    }
213    frames
214}
215/// Write a minimal AMBER restart (.inpcrd) string from positions and optional
216/// velocities.
217///
218/// Each coordinate triplet is formatted to 12.7f in groups of 2 per line
219/// (6 values per line), which matches the standard AMBER inpcrd layout.
220#[allow(dead_code)]
221pub fn write_inpcrd(
222    title: &str,
223    positions: &[[f64; 3]],
224    velocities: Option<&[[f64; 3]]>,
225) -> String {
226    let mut out = String::new();
227    out.push_str(title);
228    out.push('\n');
229    out.push_str(&format!("{:6}\n", positions.len()));
230    let flat_pos: Vec<f64> = positions.iter().flat_map(|p| p.iter().copied()).collect();
231    for chunk in flat_pos.chunks(6) {
232        let line: String = chunk.iter().map(|v| format!("{:12.7}", v)).collect();
233        out.push_str(&line);
234        out.push('\n');
235    }
236    if let Some(vels) = velocities {
237        let flat_vel: Vec<f64> = vels.iter().flat_map(|v| v.iter().copied()).collect();
238        for chunk in flat_vel.chunks(6) {
239            let line: String = chunk.iter().map(|v| format!("{:12.7}", v)).collect();
240            out.push_str(&line);
241            out.push('\n');
242        }
243    }
244    out
245}
246/// Minimal built-in AMBER99SB bond parameter table.
247///
248/// Only the most common backbone bonds are included for testing/demo purposes.
249#[allow(dead_code)]
250pub fn amber99sb_bonds() -> Vec<FfBondParam> {
251    vec![
252        FfBondParam {
253            type_a: "N".into(),
254            type_b: "H".into(),
255            k: 434.0,
256            r0: 1.010,
257        },
258        FfBondParam {
259            type_a: "N".into(),
260            type_b: "CT".into(),
261            k: 337.0,
262            r0: 1.449,
263        },
264        FfBondParam {
265            type_a: "CT".into(),
266            type_b: "CT".into(),
267            k: 310.0,
268            r0: 1.526,
269        },
270        FfBondParam {
271            type_a: "CT".into(),
272            type_b: "HC".into(),
273            k: 340.0,
274            r0: 1.090,
275        },
276        FfBondParam {
277            type_a: "CT".into(),
278            type_b: "C".into(),
279            k: 317.0,
280            r0: 1.522,
281        },
282        FfBondParam {
283            type_a: "C".into(),
284            type_b: "O".into(),
285            k: 570.0,
286            r0: 1.229,
287        },
288        FfBondParam {
289            type_a: "C".into(),
290            type_b: "N".into(),
291            k: 490.0,
292            r0: 1.335,
293        },
294        FfBondParam {
295            type_a: "OH".into(),
296            type_b: "HO".into(),
297            k: 553.0,
298            r0: 0.960,
299        },
300    ]
301}
302/// Minimal built-in AMBER99SB angle parameter table.
303#[allow(dead_code)]
304pub fn amber99sb_angles() -> Vec<FfAngleParam> {
305    vec![
306        FfAngleParam {
307            type_i: "H".into(),
308            type_j: "N".into(),
309            type_k: "H".into(),
310            k: 35.0,
311            theta0_deg: 120.0,
312        },
313        FfAngleParam {
314            type_i: "H".into(),
315            type_j: "N".into(),
316            type_k: "CT".into(),
317            k: 50.0,
318            theta0_deg: 118.0,
319        },
320        FfAngleParam {
321            type_i: "N".into(),
322            type_j: "CT".into(),
323            type_k: "C".into(),
324            k: 63.0,
325            theta0_deg: 111.2,
326        },
327        FfAngleParam {
328            type_i: "N".into(),
329            type_j: "CT".into(),
330            type_k: "CT".into(),
331            k: 80.0,
332            theta0_deg: 111.2,
333        },
334        FfAngleParam {
335            type_i: "CT".into(),
336            type_j: "C".into(),
337            type_k: "O".into(),
338            k: 80.0,
339            theta0_deg: 120.4,
340        },
341        FfAngleParam {
342            type_i: "CT".into(),
343            type_j: "C".into(),
344            type_k: "N".into(),
345            k: 70.0,
346            theta0_deg: 116.6,
347        },
348        FfAngleParam {
349            type_i: "CT".into(),
350            type_j: "CT".into(),
351            type_k: "HC".into(),
352            k: 50.0,
353            theta0_deg: 109.5,
354        },
355        FfAngleParam {
356            type_i: "HC".into(),
357            type_j: "CT".into(),
358            type_k: "HC".into(),
359            k: 35.0,
360            theta0_deg: 109.5,
361        },
362    ]
363}
364/// Compute summary statistics over a slice of energy frames.
365///
366/// Returns `(mean_etot, min_etot, max_etot)`.
367#[allow(dead_code)]
368pub fn energy_summary(frames: &[AmberEnergyFrame]) -> (f64, f64, f64) {
369    if frames.is_empty() {
370        return (0.0, 0.0, 0.0);
371    }
372    let vals: Vec<f64> = frames.iter().map(|f| f.e_tot).collect();
373    let mean = vals.iter().sum::<f64>() / vals.len() as f64;
374    let min = vals.iter().cloned().fold(f64::INFINITY, f64::min);
375    let max = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
376    (mean, min, max)
377}
378pub(super) fn parse_field<'a>(line: &'a str, key: &str) -> Option<&'a str> {
379    let pos = line.find(key)?;
380    let rest = line[pos + key.len()..].trim_start();
381    let end = rest.find([' ', '\t', '\n']).unwrap_or(rest.len());
382    Some(&rest[..end])
383}
384pub(super) fn extract_flag_floats(prmtop: &str, flag: &str) -> Vec<f64> {
385    let marker = format!("%FLAG {flag}");
386    let mut in_section = false;
387    let mut out = Vec::new();
388    for line in prmtop.lines() {
389        let t = line.trim();
390        if t == marker {
391            in_section = true;
392            continue;
393        }
394        if in_section {
395            if t.starts_with("%FORMAT") {
396                continue;
397            }
398            if t.starts_with('%') {
399                break;
400            }
401            for tok in t.split_whitespace() {
402                if let Ok(v) = tok.parse::<f64>() {
403                    out.push(v);
404                }
405            }
406        }
407    }
408    out
409}
410pub(super) fn extract_flag_integers(prmtop: &str, flag: &str) -> Vec<i32> {
411    let marker = format!("%FLAG {flag}");
412    let mut in_section = false;
413    let mut out = Vec::new();
414    for line in prmtop.lines() {
415        let t = line.trim();
416        if t == marker {
417            in_section = true;
418            continue;
419        }
420        if in_section {
421            if t.starts_with("%FORMAT") {
422                continue;
423            }
424            if t.starts_with('%') {
425                break;
426            }
427            for tok in t.split_whitespace() {
428                if let Ok(v) = tok.parse::<i32>() {
429                    out.push(v);
430                }
431            }
432        }
433    }
434    out
435}
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    use crate::amber::types::*;
441    fn minimal_prmtop() -> String {
442        let n_charge = -0.4157 * 18.2223;
443        let h_charge = 0.2719 * 18.2223;
444        format!(
445            "%VERSION  VERSION_STAMP = V0001.000\n\
446             %FLAG TITLE\n\
447             %FORMAT(20a4)\n\
448             test molecule\n\
449             %FLAG POINTERS\n\
450             %FORMAT(10I8)\n\
451               2    0    1    0    1    0    0    0    0    0\n\
452               0    0    0    0    0    0    0    0    0    0\n\
453               0    0    0    0    0    0    0    0    0    0\n\
454               0\n\
455             %FLAG ATOM_NAME\n\
456             %FORMAT(20a4)\n\
457             N   H   \n\
458             %FLAG CHARGE\n\
459             %FORMAT(5E16.8)\n\
460               {:.8E}  {:.8E}\n\
461             %FLAG MASS\n\
462             %FORMAT(5E16.8)\n\
463                1.40100000E+01   1.00800000E+00\n\
464             %FLAG AMBER_ATOM_TYPE\n\
465             %FORMAT(20a4)\n\
466             N   H   \n\
467             %FLAG RESIDUE_LABEL\n\
468             %FORMAT(20a4)\n\
469             ALA \n\
470             %FLAG RESIDUE_POINTER\n\
471             %FORMAT(10I8)\n\
472               1\n\
473             %FLAG BONDS_INC_HYDROGEN\n\
474             %FORMAT(10I8)\n\
475               0    3    1\n\
476             %FLAG BONDS_WITHOUT_HYDROGEN\n\
477             %FORMAT(10I8)\n\
478             %FLAG BOND_FORCE_CONSTANT\n\
479             %FORMAT(5E16.8)\n\
480                4.34000000E+02\n\
481             %FLAG BOND_EQUIL_VALUE\n\
482             %FORMAT(5E16.8)\n\
483                1.01000000E+00\n\
484             %FLAG ANGLES_INC_HYDROGEN\n\
485             %FORMAT(10I8)\n\
486             %FLAG ANGLES_WITHOUT_HYDROGEN\n\
487             %FORMAT(10I8)\n\
488             %FLAG ANGLE_FORCE_CONSTANT\n\
489             %FORMAT(5E16.8)\n\
490             %FLAG ANGLE_EQUIL_VALUE\n\
491             %FORMAT(5E16.8)\n",
492            n_charge, h_charge
493        )
494    }
495    fn parse_minimal() -> AmberTopology {
496        AmberTopology::from_prmtop_str(&minimal_prmtop()).unwrap()
497    }
498    #[test]
499    fn test_parse_flag_section_found() {
500        let s = "%FLAG TITLE\n%FORMAT(20a4)\nhello\n%FLAG OTHER\n%FORMAT\ndata\n";
501        let result = parse_flag_section(s, "TITLE");
502        assert!(result.is_some());
503        assert!(result.unwrap().contains("hello"));
504    }
505    #[test]
506    fn test_parse_flag_section_not_found() {
507        let s = "%FLAG TITLE\n%FORMAT(20a4)\nhello\n";
508        let result = parse_flag_section(s, "MISSING");
509        assert!(result.is_none());
510    }
511    #[test]
512    fn test_parse_fortran_reals_basic() {
513        let s = "   1.23000000E+00  -4.56000000E+00\n";
514        let vals = parse_fortran_reals(s);
515        assert_eq!(vals.len(), 2);
516        assert!((vals[0] - 1.23).abs() < 1e-6);
517        assert!((vals[1] + 4.56).abs() < 1e-6);
518    }
519    #[test]
520    fn test_parse_fortran_reals_d_exponent() {
521        let s = "1.0D+00  2.5D-01\n";
522        let vals = parse_fortran_reals(s);
523        assert_eq!(vals.len(), 2);
524        assert!((vals[1] - 0.25).abs() < 1e-9);
525    }
526    #[test]
527    fn test_parse_fortran_ints_basic() {
528        let s = "   2    0    1    0\n";
529        let vals = parse_fortran_ints(s);
530        assert_eq!(vals, vec![2, 0, 1, 0]);
531    }
532    #[test]
533    fn test_parse_title() {
534        let top = parse_minimal();
535        assert!(top.title.contains("test molecule"), "title='{}'", top.title);
536    }
537    #[test]
538    fn test_n_atoms() {
539        let top = parse_minimal();
540        assert_eq!(top.n_atoms, 2);
541    }
542    #[test]
543    fn test_atom_names() {
544        let top = parse_minimal();
545        assert_eq!(top.atoms[0].name, "N");
546        assert_eq!(top.atoms[1].name, "H");
547    }
548    #[test]
549    fn test_atom_masses() {
550        let top = parse_minimal();
551        assert!(
552            (top.atoms[0].mass - 14.01).abs() < 0.01,
553            "N mass={}",
554            top.atoms[0].mass
555        );
556        assert!(
557            (top.atoms[1].mass - 1.008).abs() < 0.01,
558            "H mass={}",
559            top.atoms[1].mass
560        );
561    }
562    #[test]
563    fn test_atom_charges_scaled() {
564        let top = parse_minimal();
565        assert!(
566            (top.atoms[0].charge - (-0.4157)).abs() < 0.001,
567            "N charge={:.4}",
568            top.atoms[0].charge
569        );
570    }
571    #[test]
572    fn test_atom_types() {
573        let top = parse_minimal();
574        assert_eq!(top.atoms[0].atom_type, "N");
575        assert_eq!(top.atoms[1].atom_type, "H");
576    }
577    #[test]
578    fn test_residue_names() {
579        let top = parse_minimal();
580        let res = top.residue_names();
581        assert!(res.contains(&"ALA".to_string()), "residues={res:?}");
582    }
583    #[test]
584    fn test_bonds_parsed() {
585        let top = parse_minimal();
586        assert_eq!(top.bonds.len(), 1, "bonds={}", top.bonds.len());
587        assert_eq!(top.bonds[0].i, 0);
588        assert_eq!(top.bonds[0].j, 1);
589    }
590    #[test]
591    fn test_bond_force_constant() {
592        let top = parse_minimal();
593        assert!((top.bonds[0].k - 434.0).abs() < 1.0, "k={}", top.bonds[0].k);
594    }
595    #[test]
596    fn test_bond_equil_value() {
597        let top = parse_minimal();
598        assert!(
599            (top.bonds[0].r0 - 1.01).abs() < 0.01,
600            "r0={}",
601            top.bonds[0].r0
602        );
603    }
604    #[test]
605    fn test_total_charge() {
606        let top = parse_minimal();
607        let q = top.total_charge();
608        assert!((q - (-0.1438)).abs() < 0.01, "total_charge={q:.4}");
609    }
610    #[test]
611    fn test_total_mass() {
612        let top = parse_minimal();
613        let m = top.total_mass();
614        assert!((m - 15.018).abs() < 0.1, "total_mass={m:.3}");
615    }
616    #[test]
617    fn test_write_summary_contains_atoms() {
618        let top = parse_minimal();
619        let summary = top.write_summary();
620        assert!(summary.contains("Atoms"), "summary={summary}");
621        assert!(summary.contains('2'), "summary={summary}");
622    }
623    #[test]
624    fn test_from_prmtop_empty_string() {
625        let top = AmberTopology::from_prmtop_str("").unwrap();
626        assert_eq!(top.n_atoms, 0);
627        assert!(top.atoms.is_empty());
628    }
629    fn minimal_inpcrd() -> String {
630        "test coords\n    2\n   1.0000000   2.0000000   3.0000000   4.0000000   5.0000000   6.0000000\n"
631            .to_string()
632    }
633    #[test]
634    fn test_amber_coords_parse_basic() {
635        let coords = AmberCoordinates::from_str(&minimal_inpcrd()).unwrap();
636        assert_eq!(coords.n_atoms, 2);
637        assert_eq!(coords.coords.len(), 6);
638    }
639    #[test]
640    fn test_amber_coords_position() {
641        let coords = AmberCoordinates::from_str(&minimal_inpcrd()).unwrap();
642        let p0 = coords.position(0);
643        assert!((p0[0] - 1.0).abs() < 1e-6);
644        assert!((p0[1] - 2.0).abs() < 1e-6);
645        assert!((p0[2] - 3.0).abs() < 1e-6);
646    }
647    #[test]
648    fn test_amber_coords_position_atom1() {
649        let coords = AmberCoordinates::from_str(&minimal_inpcrd()).unwrap();
650        let p1 = coords.position(1);
651        assert!((p1[0] - 4.0).abs() < 1e-6);
652    }
653    #[test]
654    fn test_amber_coords_no_velocities() {
655        let coords = AmberCoordinates::from_str(&minimal_inpcrd()).unwrap();
656        assert!(coords.velocities.is_none());
657    }
658    #[test]
659    fn test_amber_coords_restart_roundtrip() {
660        let coords = AmberCoordinates::from_str(&minimal_inpcrd()).unwrap();
661        let restart = coords.to_restart_string();
662        assert!(restart.contains("test coords"));
663        assert!(restart.contains("2"));
664    }
665    #[test]
666    fn test_amber_coords_empty() {
667        let result = AmberCoordinates::from_str("");
668        assert!(result.is_err());
669    }
670    #[test]
671    fn test_amber_coords_with_velocities() {
672        let s = "test\n    1\n   1.0000000   2.0000000   3.0000000   0.1000000   0.2000000   0.3000000\n";
673        let coords = AmberCoordinates::from_str(s).unwrap();
674        assert_eq!(coords.n_atoms, 1);
675        assert!(coords.velocities.is_some());
676        let vels = coords.velocities.unwrap();
677        assert!((vels[0] - 0.1).abs() < 1e-6);
678    }
679    #[test]
680    fn test_extract_lj_empty() {
681        let params = extract_lj_parameters("");
682        assert!(params.is_empty());
683    }
684    #[test]
685    fn test_extract_dihedrals_empty() {
686        let dihedrals = extract_dihedrals("");
687        assert!(dihedrals.is_empty());
688    }
689    #[test]
690    fn test_atom_type_names() {
691        let top = parse_minimal();
692        let types = top.atom_type_names();
693        assert!(types.contains(&"N".to_string()));
694        assert!(types.contains(&"H".to_string()));
695    }
696    #[test]
697    fn test_n_atom_types() {
698        let top = parse_minimal();
699        assert_eq!(top.n_atom_types(), 2);
700    }
701    #[test]
702    fn test_atoms_in_residue() {
703        let top = parse_minimal();
704        let ala_atoms = top.atoms_in_residue("ALA");
705        assert_eq!(ala_atoms.len(), 2);
706    }
707    #[test]
708    fn test_atoms_in_residue_missing() {
709        let top = parse_minimal();
710        let gly_atoms = top.atoms_in_residue("GLY");
711        assert!(gly_atoms.is_empty());
712    }
713    #[test]
714    fn test_extract_lj_basic() {
715        let prmtop = "%FLAG LENNARD_JONES_ACOEF\n%FORMAT(5E16.8)\n1.0  2.0  3.0\n%FLAG LENNARD_JONES_BCOEF\n%FORMAT(5E16.8)\n0.5  1.0  1.5\n";
716        let params = extract_lj_parameters(prmtop);
717        assert_eq!(params.len(), 3);
718        assert!((params[0].a_coeff - 1.0).abs() < 1e-10);
719        assert!((params[0].b_coeff - 0.5).abs() < 1e-10);
720    }
721    #[test]
722    fn test_lj_r_min() {
723        let lj = LjParameter {
724            type_a: 0,
725            type_b: 0,
726            a_coeff: 2.0,
727            b_coeff: 2.0,
728        };
729        let r_min = lj.r_min();
730        assert!((r_min - 2.0f64.powf(1.0 / 6.0)).abs() < 1e-10);
731    }
732    #[test]
733    fn test_lj_epsilon() {
734        let lj = LjParameter {
735            type_a: 0,
736            type_b: 0,
737            a_coeff: 4.0,
738            b_coeff: 4.0,
739        };
740        let eps = lj.epsilon();
741        assert!((eps - 1.0).abs() < 1e-10);
742    }
743    #[test]
744    fn test_lj_zero_b_coeff_r_min() {
745        let lj = LjParameter {
746            type_a: 0,
747            type_b: 0,
748            a_coeff: 1.0,
749            b_coeff: 0.0,
750        };
751        assert_eq!(lj.r_min(), 0.0);
752    }
753    #[test]
754    fn test_extract_dihedrals_basic() {
755        let prmtop = "%FLAG DIHEDRALS_INC_HYDROGEN\n%FORMAT(10I8)\n0  3  6  9  1\n";
756        let dihedrals = extract_dihedrals(prmtop);
757        assert_eq!(dihedrals.len(), 1);
758        assert_eq!(dihedrals[0].n, 1);
759        assert!(!dihedrals[0].is_improper);
760    }
761    #[test]
762    fn test_extract_dihedrals_improper_flag() {
763        let prmtop = "%FLAG DIHEDRALS_WITHOUT_HYDROGEN\n%FORMAT(10I8)\n0  3  -6  9  2\n";
764        let dihedrals = extract_dihedrals(prmtop);
765        assert_eq!(dihedrals.len(), 1);
766        assert!(dihedrals[0].is_improper);
767    }
768    fn sample_mdout() -> &'static str {
769        " NSTEP =       10  TIME(PS) =  0.010000  TEMP(K) =  298.5  PRESS =    0.0\n\
770         Etot   =    -100.00  EKtot   =     50.00  EPtot   =    -150.00\n"
771    }
772    #[test]
773    fn test_parse_energy_frames_single() {
774        let frames = parse_energy_frames(sample_mdout());
775        assert_eq!(frames.len(), 1);
776        assert_eq!(frames[0].step, 10);
777        assert!((frames[0].time_ps - 0.01).abs() < 1e-6);
778        assert!((frames[0].temp_k - 298.5).abs() < 1e-3);
779        assert!((frames[0].e_tot - (-100.0)).abs() < 1e-6);
780        assert!((frames[0].e_kin - 50.0).abs() < 1e-6);
781        assert!((frames[0].e_pot - (-150.0)).abs() < 1e-6);
782    }
783    #[test]
784    fn test_parse_energy_frames_empty() {
785        let frames = parse_energy_frames("");
786        assert!(frames.is_empty());
787    }
788    #[test]
789    fn test_parse_energy_frames_two_frames() {
790        let mdout = " NSTEP =        1  TIME(PS) =  0.001000  TEMP(K) =  300.0  PRESS =   0.0\n\
791                     Etot   =   -200.00  EKtot   =   100.00  EPtot   =   -300.00\n\
792                     \n\
793                     NSTEP =        2  TIME(PS) =  0.002000  TEMP(K) =  301.0  PRESS =   0.0\n\
794                     Etot   =   -190.00  EKtot   =   110.00  EPtot   =   -300.00\n";
795        let frames = parse_energy_frames(mdout);
796        assert_eq!(frames.len(), 2);
797        assert_eq!(frames[1].step, 2);
798    }
799    #[test]
800    fn test_energy_summary_basic() {
801        let frames = vec![
802            AmberEnergyFrame {
803                step: 1,
804                time_ps: 0.0,
805                temp_k: 300.0,
806                e_tot: -100.0,
807                e_kin: 50.0,
808                e_pot: -150.0,
809            },
810            AmberEnergyFrame {
811                step: 2,
812                time_ps: 0.1,
813                temp_k: 300.0,
814                e_tot: -200.0,
815                e_kin: 50.0,
816                e_pot: -250.0,
817            },
818        ];
819        let (mean, min, max) = energy_summary(&frames);
820        assert!((mean - (-150.0)).abs() < 1e-10);
821        assert!((min - (-200.0)).abs() < 1e-10);
822        assert!((max - (-100.0)).abs() < 1e-10);
823    }
824    #[test]
825    fn test_energy_summary_empty() {
826        let (mean, min, max) = energy_summary(&[]);
827        assert_eq!(mean, 0.0);
828        assert_eq!(min, 0.0);
829        assert_eq!(max, 0.0);
830    }
831    #[test]
832    fn test_write_inpcrd_basic() {
833        let positions = vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
834        let s = write_inpcrd("test restart", &positions, None);
835        assert!(s.starts_with("test restart"));
836        assert!(s.contains("2"));
837        assert!(s.contains("1.0000000"));
838    }
839    #[test]
840    fn test_write_inpcrd_with_velocities() {
841        let positions = vec![[0.0, 0.0, 0.0]];
842        let velocities = vec![[0.1, 0.2, 0.3]];
843        let s = write_inpcrd("vel test", &positions, Some(&velocities));
844        assert!(s.contains("0.1000000"));
845    }
846    #[test]
847    fn test_amber99sb_bonds_not_empty() {
848        let bonds = amber99sb_bonds();
849        assert!(!bonds.is_empty());
850        let ct_ct = bonds.iter().find(|b| b.type_a == "CT" && b.type_b == "CT");
851        assert!(ct_ct.is_some());
852    }
853    #[test]
854    fn test_amber99sb_angles_not_empty() {
855        let angles = amber99sb_angles();
856        assert!(!angles.is_empty());
857        for a in &angles {
858            assert!(
859                a.k > 0.0,
860                "k should be positive for {}-{}-{}",
861                a.type_i,
862                a.type_j,
863                a.type_k
864            );
865            assert!(a.theta0_deg > 0.0);
866        }
867    }
868}
869/// Strip AMBER-style inline comment (everything from `;` onward).
870pub(super) fn strip_amber_comment(s: &str) -> &str {
871    if let Some(pos) = s.find(';') {
872        s[..pos].trim()
873    } else {
874        s.trim()
875    }
876}
877/// Collect all floating-point numbers from a string, ignoring non-numeric tokens.
878pub(super) fn collect_numbers(s: &str) -> Vec<f64> {
879    s.split_whitespace()
880        .filter_map(|tok| tok.parse::<f64>().ok())
881        .collect()
882}
883/// Parse a residue selection like "1", "1-10", "1,3,5", "1-5,8,10-12".
884pub(super) fn parse_residue_selection(s: &str) -> Result<Vec<usize>, String> {
885    let mut result = Vec::new();
886    for token in s.split(',') {
887        let t = token.trim();
888        if t.is_empty() {
889            continue;
890        }
891        if let Some(dash_pos) = t.find('-') {
892            let start: usize = t[..dash_pos]
893                .trim()
894                .parse()
895                .map_err(|_| format!("Invalid range start: '{}'", &t[..dash_pos]))?;
896            let end: usize = t[dash_pos + 1..]
897                .trim()
898                .parse()
899                .map_err(|_| format!("Invalid range end: '{}'", &t[dash_pos + 1..]))?;
900            if end < start {
901                return Err(format!("Range end {} < start {}", end, start));
902            }
903            result.extend(start..=end);
904        } else {
905            let n: usize = t
906                .parse()
907                .map_err(|_| format!("Invalid residue number: '{}'", t))?;
908            result.push(n);
909        }
910    }
911    Ok(result)
912}
913/// Parse AMBER MDCRD trajectory data from a string.
914///
915/// MDCRD format: title line, then coordinates in groups of 10 per line
916/// (8.3f format), with optional box at the end of each frame.
917#[allow(dead_code)]
918pub fn parse_mdcrd(s: &str, n_atoms: usize, has_box: bool) -> Vec<MdcrdFrame> {
919    let mut frames = Vec::new();
920    let mut lines = s.lines();
921    let _ = lines.next();
922    let n_coords = n_atoms * 3;
923    let coords_per_line = 10_usize;
924    let coords_lines = n_coords.div_ceil(coords_per_line);
925    let mut remaining_lines: Vec<&str> = lines.collect();
926    let box_lines = if has_box { 1 } else { 0 };
927    let frame_lines = coords_lines + box_lines;
928    let mut i = 0;
929    while i + coords_lines <= remaining_lines.len() {
930        let mut coords: Vec<f64> = Vec::with_capacity(n_coords);
931        for line in &remaining_lines[i..i + coords_lines] {
932            for tok in line.split_whitespace() {
933                if let Ok(v) = tok.parse::<f64>() {
934                    coords.push(v);
935                    if coords.len() == n_coords {
936                        break;
937                    }
938                }
939            }
940        }
941        let box_dims = if has_box && i + frame_lines <= remaining_lines.len() {
942            let box_line = remaining_lines[i + coords_lines];
943            let vals: Vec<f64> = box_line
944                .split_whitespace()
945                .filter_map(|t| t.parse().ok())
946                .collect();
947            if vals.len() >= 3 {
948                Some([vals[0], vals[1], vals[2]])
949            } else {
950                None
951            }
952        } else {
953            None
954        };
955        if coords.len() >= n_coords {
956            frames.push(MdcrdFrame { coords, box_dims });
957        }
958        i += frame_lines;
959        if frame_lines == 0 {
960            break;
961        }
962        remaining_lines = remaining_lines[i..].to_vec();
963        i = 0;
964    }
965    frames
966}
967/// Serialises a minimal AMBER prmtop-format string from an `AmberTopology`.
968///
969/// The output contains `%VERSION`, `%FLAG TITLE`, `%FLAG POINTERS`,
970/// `%FLAG ATOM_NAME`, `%FLAG CHARGE`, and `%FLAG MASS` sections so that it
971/// can be round-tripped through `AmberTopology::from_prmtop_str`.
972#[allow(dead_code)]
973pub fn write_prmtop(topo: &AmberTopology) -> String {
974    let mut out = String::new();
975    out.push_str("%VERSION  VERSION_STAMP = V0001.000  DATE = 01/01/26  00:00:00\n");
976    out.push_str("%FLAG TITLE\n");
977    out.push_str("%FORMAT(20a4)\n");
978    out.push_str(&format!("{}\n", topo.title));
979    out.push_str("%FLAG POINTERS\n");
980    out.push_str("%FORMAT(10I8)\n");
981    let n_atoms = topo.atoms.len() as i64;
982    let n_types = 1i64;
983    let n_bonds = topo.bonds.len() as i64;
984    let n_bonds_nh = n_bonds;
985    let n_angles = topo.angles.len() as i64;
986    let pointers: Vec<i64> = vec![
987        n_atoms, n_types, n_bonds, n_bonds_nh, n_angles, n_angles, 0, 0, 0, 0,
988    ];
989    for (i, p) in pointers.iter().enumerate() {
990        out.push_str(&format!("{:8}", p));
991        if (i + 1) % 10 == 0 {
992            out.push('\n');
993        }
994    }
995    if !pointers.len().is_multiple_of(10) {
996        out.push('\n');
997    }
998    out.push_str("%FLAG ATOM_NAME\n");
999    out.push_str("%FORMAT(20a4)\n");
1000    for (i, atom) in topo.atoms.iter().enumerate() {
1001        out.push_str(&format!("{:<4}", &atom.name[..atom.name.len().min(4)]));
1002        if (i + 1) % 20 == 0 {
1003            out.push('\n');
1004        }
1005    }
1006    if !topo.atoms.is_empty() && !topo.atoms.len().is_multiple_of(20) {
1007        out.push('\n');
1008    }
1009    pub(super) const AMBER_CHARGE_SCALE: f64 = 18.2223;
1010    out.push_str("%FLAG CHARGE\n");
1011    out.push_str("%FORMAT(5E16.8)\n");
1012    for (i, atom) in topo.atoms.iter().enumerate() {
1013        out.push_str(&format!("{:16.8E}", atom.charge * AMBER_CHARGE_SCALE));
1014        if (i + 1) % 5 == 0 {
1015            out.push('\n');
1016        }
1017    }
1018    if !topo.atoms.is_empty() && !topo.atoms.len().is_multiple_of(5) {
1019        out.push('\n');
1020    }
1021    out.push_str("%FLAG MASS\n");
1022    out.push_str("%FORMAT(5E16.8)\n");
1023    for (i, atom) in topo.atoms.iter().enumerate() {
1024        out.push_str(&format!("{:16.8E}", atom.mass));
1025        if (i + 1) % 5 == 0 {
1026            out.push('\n');
1027        }
1028    }
1029    if !topo.atoms.is_empty() && !topo.atoms.len().is_multiple_of(5) {
1030        out.push('\n');
1031    }
1032    out
1033}
1034#[cfg(test)]
1035mod tests_amber_ext {
1036    use super::*;
1037    use crate::amber::AmberAtom;
1038    use crate::amber::AmberBox;
1039    use crate::amber::AmberDcd;
1040    use crate::amber::AmberMask;
1041    use crate::amber::AmberRst7;
1042    use crate::amber::FrcmodFile;
1043
1044    fn sample_frcmod() -> &'static str {
1045        "Modified force field for custom ligand\n\
1046         MASS\n\
1047         CX  12.011\n\
1048         \n\
1049         BOND\n\
1050         CX-HC  340.0  1.090\n\
1051         CX-CX  310.0  1.526\n\
1052         \n\
1053         ANGL\n\
1054         HC-CX-HC  35.0  109.5\n\
1055         CX-CX-HC  50.0  109.5\n\
1056         \n\
1057         DIHE\n\
1058         X-CX-CX-X   9  1.4  0.0  3.0\n\
1059         \n\
1060         NONB\n\
1061         CX    1.9080  0.1094\n\
1062         \n\
1063         END\n"
1064    }
1065    #[test]
1066    fn test_frcmod_parse_title() {
1067        let frc = FrcmodFile::from_str(sample_frcmod()).unwrap();
1068        assert!(frc.title.contains("Modified"), "title={}", frc.title);
1069    }
1070    #[test]
1071    fn test_frcmod_bonds_count() {
1072        let frc = FrcmodFile::from_str(sample_frcmod()).unwrap();
1073        assert_eq!(frc.n_bonds(), 2, "bonds={}", frc.n_bonds());
1074    }
1075    #[test]
1076    fn test_frcmod_bond_values() {
1077        let frc = FrcmodFile::from_str(sample_frcmod()).unwrap();
1078        let b = frc.get_bond("CX-HC").unwrap();
1079        assert!((b.k - 340.0).abs() < 1.0, "k={}", b.k);
1080        assert!((b.r0 - 1.090).abs() < 0.001, "r0={}", b.r0);
1081    }
1082    #[test]
1083    fn test_frcmod_bond_reverse_lookup() {
1084        let frc = FrcmodFile::from_str(sample_frcmod()).unwrap();
1085        assert!(frc.get_bond("HC-CX").is_some());
1086    }
1087    #[test]
1088    fn test_frcmod_angles_count() {
1089        let frc = FrcmodFile::from_str(sample_frcmod()).unwrap();
1090        assert_eq!(frc.n_angles(), 2, "angles={}", frc.n_angles());
1091    }
1092    #[test]
1093    fn test_frcmod_angle_values() {
1094        let frc = FrcmodFile::from_str(sample_frcmod()).unwrap();
1095        let a = &frc.angles[0];
1096        assert!((a.k - 35.0).abs() < 1.0, "k={}", a.k);
1097        assert!(
1098            (a.theta0_deg - 109.5).abs() < 0.1,
1099            "theta0={}",
1100            a.theta0_deg
1101        );
1102    }
1103    #[test]
1104    fn test_frcmod_dihedrals_count() {
1105        let frc = FrcmodFile::from_str(sample_frcmod()).unwrap();
1106        assert_eq!(frc.n_dihedrals(), 1, "dihedrals={}", frc.n_dihedrals());
1107    }
1108    #[test]
1109    fn test_frcmod_nonbonded_count() {
1110        let frc = FrcmodFile::from_str(sample_frcmod()).unwrap();
1111        assert_eq!(frc.n_nonbonded(), 1, "nonbonded={}", frc.n_nonbonded());
1112    }
1113    #[test]
1114    fn test_frcmod_nonbonded_values() {
1115        let frc = FrcmodFile::from_str(sample_frcmod()).unwrap();
1116        let nb = frc.get_nonbonded("CX").unwrap();
1117        assert!((nb.r_star - 1.9080).abs() < 0.001, "r_star={}", nb.r_star);
1118        assert!(
1119            (nb.epsilon - 0.1094).abs() < 0.001,
1120            "epsilon={}",
1121            nb.epsilon
1122        );
1123    }
1124    #[test]
1125    fn test_frcmod_empty_file() {
1126        let frc = FrcmodFile::from_str("").unwrap();
1127        assert_eq!(frc.n_bonds(), 0);
1128        assert_eq!(frc.n_angles(), 0);
1129    }
1130    #[test]
1131    fn test_mask_single_residue() {
1132        let m = AmberMask::parse(":5").unwrap();
1133        assert_eq!(m.residues, vec![5]);
1134        assert!(m.atom_names.is_empty());
1135    }
1136    #[test]
1137    fn test_mask_residue_range() {
1138        let m = AmberMask::parse(":1-5").unwrap();
1139        assert_eq!(m.residues, vec![1, 2, 3, 4, 5]);
1140    }
1141    #[test]
1142    fn test_mask_residue_list() {
1143        let m = AmberMask::parse(":1,3,5").unwrap();
1144        assert_eq!(m.residues, vec![1, 3, 5]);
1145    }
1146    #[test]
1147    fn test_mask_atom_name() {
1148        let m = AmberMask::parse("@CA").unwrap();
1149        assert!(m.residues.is_empty());
1150        assert_eq!(m.atom_names, vec!["CA"]);
1151    }
1152    #[test]
1153    fn test_mask_multiple_atom_names() {
1154        let m = AmberMask::parse("@CA,N,C").unwrap();
1155        assert_eq!(m.atom_names.len(), 3);
1156        assert!(m.atom_names.contains(&"CA".to_string()));
1157        assert!(m.atom_names.contains(&"N".to_string()));
1158    }
1159    #[test]
1160    fn test_mask_residue_and_atom() {
1161        let m = AmberMask::parse(":1-10@CA").unwrap();
1162        assert_eq!(m.residues.len(), 10);
1163        assert_eq!(m.atom_names, vec!["CA"]);
1164    }
1165    #[test]
1166    fn test_mask_matches() {
1167        let m = AmberMask::parse(":1-3@CA").unwrap();
1168        assert!(m.matches(1, "CA"));
1169        assert!(m.matches(3, "CA"));
1170        assert!(!m.matches(4, "CA"));
1171        assert!(!m.matches(1, "N"));
1172    }
1173    #[test]
1174    fn test_mask_empty() {
1175        let m = AmberMask::parse("").unwrap();
1176        assert!(m.matches_residue(1));
1177        assert!(m.matches_atom("CA"));
1178    }
1179    #[test]
1180    fn test_mask_invalid_prefix() {
1181        assert!(AmberMask::parse("1-5@CA").is_err());
1182    }
1183    #[test]
1184    fn test_amber_box_cubic() {
1185        let b = AmberBox::cubic(50.0);
1186        assert!(b.is_cubic());
1187        assert!(b.is_orthorhombic());
1188        assert!((b.volume() - 50.0_f64.powi(3)).abs() < 1e-5);
1189    }
1190    #[test]
1191    fn test_amber_box_orthorhombic() {
1192        let b = AmberBox::orthorhombic(10.0, 20.0, 30.0);
1193        assert!(!b.is_cubic());
1194        assert!(b.is_orthorhombic());
1195        assert!((b.volume() - 6000.0).abs() < 1e-6);
1196    }
1197    #[test]
1198    fn test_amber_box_volume_cubic() {
1199        let b = AmberBox::cubic(10.0);
1200        assert!((b.volume() - 1000.0).abs() < 1e-8);
1201    }
1202    #[test]
1203    fn test_amber_box_in_nm() {
1204        let b = AmberBox::cubic(50.0);
1205        let nm = b.in_nm();
1206        assert!((nm[0] - 5.0).abs() < 1e-10);
1207    }
1208    fn sample_mdcrd() -> &'static str {
1209        "test trajectory\n\
1210           1.0000000  2.0000000  3.0000000  4.0000000  5.0000000  6.0000000\n"
1211    }
1212    #[test]
1213    fn test_mdcrd_parse_one_frame() {
1214        let frames = parse_mdcrd(sample_mdcrd(), 2, false);
1215        assert_eq!(frames.len(), 1);
1216        assert_eq!(frames[0].n_atoms(), 2);
1217    }
1218    #[test]
1219    fn test_mdcrd_position() {
1220        let frames = parse_mdcrd(sample_mdcrd(), 2, false);
1221        let p0 = frames[0].position(0).unwrap();
1222        assert!((p0[0] - 1.0).abs() < 1e-6);
1223        assert!((p0[1] - 2.0).abs() < 1e-6);
1224        assert!((p0[2] - 3.0).abs() < 1e-6);
1225    }
1226    #[test]
1227    fn test_mdcrd_position_atom1() {
1228        let frames = parse_mdcrd(sample_mdcrd(), 2, false);
1229        let p1 = frames[0].position(1).unwrap();
1230        assert!((p1[0] - 4.0).abs() < 1e-6);
1231    }
1232    #[test]
1233    fn test_mdcrd_no_frames_on_empty() {
1234        let frames = parse_mdcrd("title\n", 3, false);
1235        assert!(frames.is_empty());
1236    }
1237    #[test]
1238    fn test_amber99sb_bond_ct_ct() {
1239        let bonds = amber99sb_bonds();
1240        let ct_ct = bonds.iter().find(|b| b.type_a == "CT" && b.type_b == "CT");
1241        assert!(ct_ct.is_some());
1242        let b = ct_ct.unwrap();
1243        assert!((b.r0 - 1.526).abs() < 0.001);
1244    }
1245    #[test]
1246    fn test_amber99sb_angle_hc_ct_hc() {
1247        let angles = amber99sb_angles();
1248        let a = angles
1249            .iter()
1250            .find(|a| a.type_i == "HC" && a.type_j == "CT" && a.type_k == "HC");
1251        assert!(a.is_some());
1252        assert!((a.unwrap().theta0_deg - 109.5).abs() < 0.1);
1253    }
1254    #[test]
1255    fn test_write_prmtop_contains_title() {
1256        let topo = AmberTopology {
1257            title: "TestMol".into(),
1258            atoms: vec![],
1259            bonds: vec![],
1260            angles: vec![],
1261            n_atoms: 0,
1262            n_bonds: 0,
1263        };
1264        let text = write_prmtop(&topo);
1265        assert!(text.contains("%FLAG TITLE"), "should contain FLAG TITLE");
1266        assert!(text.contains("TestMol"), "should contain the title string");
1267    }
1268    #[test]
1269    fn test_write_prmtop_atom_names() {
1270        let topo = AmberTopology {
1271            title: "mol".into(),
1272            atoms: vec![
1273                AmberAtom {
1274                    name: "CA".into(),
1275                    residue_name: "ALA".into(),
1276                    charge: 0.1,
1277                    mass: 12.0,
1278                    atom_type: "CT".into(),
1279                },
1280                AmberAtom {
1281                    name: "N".into(),
1282                    residue_name: "ALA".into(),
1283                    charge: -0.4,
1284                    mass: 14.0,
1285                    atom_type: "N".into(),
1286                },
1287            ],
1288            bonds: vec![],
1289            angles: vec![],
1290            n_atoms: 2,
1291            n_bonds: 0,
1292        };
1293        let text = write_prmtop(&topo);
1294        assert!(text.contains("%FLAG ATOM_NAME"));
1295        assert!(text.contains("CA  ") || text.contains("CA"));
1296    }
1297    #[test]
1298    fn test_write_prmtop_pointers_section() {
1299        let topo = AmberTopology {
1300            title: "t".into(),
1301            atoms: vec![AmberAtom {
1302                name: "C".into(),
1303                residue_name: "R".into(),
1304                charge: 0.0,
1305                mass: 12.0,
1306                atom_type: "C".into(),
1307            }],
1308            bonds: vec![],
1309            angles: vec![],
1310            n_atoms: 1,
1311            n_bonds: 0,
1312        };
1313        let text = write_prmtop(&topo);
1314        assert!(text.contains("%FLAG POINTERS"));
1315    }
1316    #[test]
1317    fn test_amber_rst7_write_velocity_ok() {
1318        let positions = vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
1319        let mut rst7 = AmberRst7::new("test", positions);
1320        let velocities = vec![[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]];
1321        assert!(rst7.write_velocity(velocities).is_ok());
1322        assert!(rst7.has_velocities());
1323    }
1324    #[test]
1325    fn test_amber_rst7_write_velocity_mismatch_error() {
1326        let positions = vec![[1.0, 2.0, 3.0]];
1327        let mut rst7 = AmberRst7::new("test", positions);
1328        let result = rst7.write_velocity(vec![[0.0; 3], [0.0; 3]]);
1329        assert!(result.is_err());
1330    }
1331    #[test]
1332    fn test_amber_rst7_to_string_with_velocity() {
1333        let pos = vec![[1.0, 2.0, 3.0]];
1334        let mut rst7 = AmberRst7::new("mol", pos);
1335        rst7.write_velocity(vec![[0.5, 0.6, 0.7]]).unwrap();
1336        let text = rst7.to_string_repr();
1337        assert!(text.contains("mol"));
1338        assert!(text.contains("1.0000000") || text.contains("1."));
1339    }
1340    #[test]
1341    fn test_amber_dcd_write_frame_roundtrip() {
1342        let mut dcd = AmberDcd::new(2);
1343        let frame0 = [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
1344        dcd.write_frame(&frame0).unwrap();
1345        assert_eq!(dcd.n_frames(), 1);
1346        let pos = dcd.get_position(0, 0).unwrap();
1347        assert!((pos[0] - 1.0f32).abs() < 1e-5);
1348    }
1349    #[test]
1350    fn test_amber_dcd_write_frame_wrong_atom_count() {
1351        let mut dcd = AmberDcd::new(3);
1352        let result = dcd.write_frame(&[[0.0; 3], [0.0; 3]]);
1353        assert!(result.is_err());
1354    }
1355    #[test]
1356    fn test_amber_dcd_bytes_roundtrip() {
1357        let mut dcd = AmberDcd::new(2);
1358        dcd.write_frame(&[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]])
1359            .unwrap();
1360        dcd.write_frame(&[[2.0, 0.0, 0.0], [0.0, 2.0, 0.0]])
1361            .unwrap();
1362        let bytes = dcd.to_bytes();
1363        let loaded = AmberDcd::from_bytes(&bytes).unwrap();
1364        assert_eq!(loaded.n_frames(), 2);
1365        assert_eq!(loaded.n_atoms, 2);
1366        let p = loaded.get_position(1, 0).unwrap();
1367        assert!((p[0] - 2.0f32).abs() < 1e-5);
1368    }
1369}
1370/// Validate that an `AmberTopology` is self-consistent.
1371///
1372/// Checks include: atom indices in bonds are in range, bond force constants
1373/// are non-negative, and angle equilibrium values are in [0, π].
1374#[allow(dead_code)]
1375pub fn validate_topology(topo: &AmberTopology) -> Vec<String> {
1376    let mut issues = Vec::new();
1377    let n = topo.atoms.len();
1378    for (idx, bond) in topo.bonds.iter().enumerate() {
1379        if bond.i >= n || bond.j >= n {
1380            issues.push(format!(
1381                "Bond {}: atom index out of range (i={}, j={}, n_atoms={})",
1382                idx, bond.i, bond.j, n
1383            ));
1384        }
1385        if bond.k < 0.0 {
1386            issues.push(format!("Bond {}: negative force constant {}", idx, bond.k));
1387        }
1388        if bond.r0 <= 0.0 {
1389            issues.push(format!(
1390                "Bond {}: non-positive equilibrium length {}",
1391                idx, bond.r0
1392            ));
1393        }
1394    }
1395    for (idx, angle) in topo.angles.iter().enumerate() {
1396        if angle.i >= n || angle.j >= n {
1397            issues.push(format!("Angle {}: atom index out of range", idx));
1398        }
1399        if angle.theta0 < 0.0 || angle.theta0 > std::f64::consts::PI {
1400            issues.push(format!(
1401                "Angle {}: equilibrium angle {} rad out of range [0, π]",
1402                idx, angle.theta0
1403            ));
1404        }
1405    }
1406    for atom in &topo.atoms {
1407        if atom.mass < 0.0 {
1408            issues.push(format!(
1409                "Atom '{}' has negative mass {}",
1410                atom.name, atom.mass
1411            ));
1412        }
1413    }
1414    issues
1415}
1416/// Count the number of bonds for each atom index.
1417#[allow(dead_code)]
1418pub fn atom_bond_counts(topo: &AmberTopology) -> Vec<usize> {
1419    let mut counts = vec![0usize; topo.atoms.len()];
1420    for bond in &topo.bonds {
1421        if bond.i < counts.len() {
1422            counts[bond.i] += 1;
1423        }
1424        if bond.j < counts.len() {
1425            counts[bond.j] += 1;
1426        }
1427    }
1428    counts
1429}
1430/// Return sorted list of (atom_idx, bond_count) pairs for atoms with ≥ min_bonds bonds.
1431#[allow(dead_code)]
1432pub fn highly_connected_atoms(topo: &AmberTopology, min_bonds: usize) -> Vec<(usize, usize)> {
1433    atom_bond_counts(topo)
1434        .into_iter()
1435        .enumerate()
1436        .filter(|(_, count)| *count >= min_bonds)
1437        .collect()
1438}
1439/// Compute the Coulomb energy (kcal mol⁻¹) between two point charges.
1440///
1441/// Uses the AMBER convention: `E = 332.0636 * q_i * q_j / r` where `r` is
1442/// in Ångströms and charges are in elementary charge units.
1443#[allow(dead_code)]
1444pub fn coulomb_energy(q_i: f64, q_j: f64, r: f64) -> f64 {
1445    pub(super) const COULOMB_FACTOR: f64 = 332.0636;
1446    if r < 1e-10 {
1447        return 0.0;
1448    }
1449    COULOMB_FACTOR * q_i * q_j / r
1450}
1451/// Compute the Lennard-Jones 12-6 energy between two atoms.
1452///
1453/// `e_ij = A / r^12 - B / r^6`  where A and B come from combining rules.
1454#[allow(dead_code)]
1455pub fn lj_energy(a_coeff: f64, b_coeff: f64, r: f64) -> f64 {
1456    if r < 1e-10 {
1457        return 0.0;
1458    }
1459    let r6 = r.powi(6);
1460    let r12 = r6 * r6;
1461    a_coeff / r12 - b_coeff / r6
1462}
1463/// Lorentz-Berthelot combining rules: compute A and B for a pair (i, j) from
1464/// individual sigma_i, sigma_j, eps_i, eps_j values.
1465///
1466/// Returns `(a_coeff, b_coeff)`.
1467#[allow(dead_code)]
1468pub fn lorentz_berthelot(sigma_i: f64, sigma_j: f64, eps_i: f64, eps_j: f64) -> (f64, f64) {
1469    let sigma_ij = (sigma_i + sigma_j) * 0.5;
1470    let eps_ij = (eps_i * eps_j).sqrt();
1471    let s6 = sigma_ij.powi(6);
1472    let s12 = s6 * s6;
1473    let b_coeff = 4.0 * eps_ij * s6;
1474    let a_coeff = 4.0 * eps_ij * s12;
1475    (a_coeff, b_coeff)
1476}
1477/// Compute the harmonic bond potential energy for a single bond.
1478///
1479/// `E = k * (r - r0)^2`
1480#[allow(dead_code)]
1481pub fn bond_energy(k: f64, r0: f64, r: f64) -> f64 {
1482    k * (r - r0).powi(2)
1483}
1484/// Compute the harmonic angle potential energy.
1485///
1486/// `E = k * (theta - theta0)^2`
1487#[allow(dead_code)]
1488pub fn angle_energy(k: f64, theta0: f64, theta: f64) -> f64 {
1489    k * (theta - theta0).powi(2)
1490}
1491/// Compute the AMBER dihedral (torsion) energy.
1492///
1493/// `E = V_n/2 * (1 + cos(n*phi - gamma))`
1494#[allow(dead_code)]
1495pub fn dihedral_energy(v_n: f64, n: f64, phi: f64, gamma: f64) -> f64 {
1496    0.5 * v_n * (1.0 + (n * phi - gamma).cos())
1497}
1498/// Compute Euclidean distance between two 3D points (Å or any consistent unit).
1499#[allow(dead_code)]
1500pub fn distance(a: [f64; 3], b: [f64; 3]) -> f64 {
1501    let dx = a[0] - b[0];
1502    let dy = a[1] - b[1];
1503    let dz = a[2] - b[2];
1504    (dx * dx + dy * dy + dz * dz).sqrt()
1505}
1506/// Compute minimum-image distance between two points in an orthorhombic box.
1507#[allow(dead_code)]
1508pub fn min_image_distance(a: [f64; 3], b: [f64; 3], box_a: f64, box_b: f64, box_c: f64) -> f64 {
1509    let dx = wrap(a[0] - b[0], box_a);
1510    let dy = wrap(a[1] - b[1], box_b);
1511    let dz = wrap(a[2] - b[2], box_c);
1512    (dx * dx + dy * dy + dz * dz).sqrt()
1513}
1514/// Wrap a displacement into `\[-L/2, L/2)`.
1515pub(super) fn wrap(d: f64, l: f64) -> f64 {
1516    if l < 1e-30 {
1517        return d;
1518    }
1519    let mut v = d;
1520    while v > l * 0.5 {
1521        v -= l;
1522    }
1523    while v < -l * 0.5 {
1524        v += l;
1525    }
1526    v
1527}
1528/// Compute the angle (radians) A–B–C.
1529#[allow(dead_code)]
1530pub fn compute_angle(a: [f64; 3], b: [f64; 3], c: [f64; 3]) -> f64 {
1531    let ba = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
1532    let bc = [c[0] - b[0], c[1] - b[1], c[2] - b[2]];
1533    let dot = ba[0] * bc[0] + ba[1] * bc[1] + ba[2] * bc[2];
1534    let mag_ba = (ba[0] * ba[0] + ba[1] * ba[1] + ba[2] * ba[2]).sqrt();
1535    let mag_bc = (bc[0] * bc[0] + bc[1] * bc[1] + bc[2] * bc[2]).sqrt();
1536    if mag_ba < 1e-10 || mag_bc < 1e-10 {
1537        return 0.0;
1538    }
1539    let cos_theta = (dot / (mag_ba * mag_bc)).clamp(-1.0, 1.0);
1540    cos_theta.acos()
1541}
1542/// Compute the dihedral angle (radians) A–B–C–D.
1543#[allow(dead_code)]
1544pub fn compute_dihedral(a: [f64; 3], b: [f64; 3], c: [f64; 3], d: [f64; 3]) -> f64 {
1545    let b1 = sub3(b, a);
1546    let b2 = sub3(c, b);
1547    let b3 = sub3(d, c);
1548    let n1 = cross3(b1, b2);
1549    let n2 = cross3(b2, b3);
1550    let mag1 = mag3(n1);
1551    let mag2 = mag3(n2);
1552    if mag1 < 1e-10 || mag2 < 1e-10 {
1553        return 0.0;
1554    }
1555    let cos_phi = dot3(n1, n2) / (mag1 * mag2);
1556    let cos_phi = cos_phi.clamp(-1.0, 1.0);
1557    let sign = dot3(cross3(n1, n2), b2);
1558    let phi = cos_phi.acos();
1559    if sign < 0.0 { -phi } else { phi }
1560}
1561pub(super) fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
1562    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
1563}
1564pub(super) fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
1565    [
1566        a[1] * b[2] - a[2] * b[1],
1567        a[2] * b[0] - a[0] * b[2],
1568        a[0] * b[1] - a[1] * b[0],
1569    ]
1570}
1571pub(super) fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
1572    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
1573}
1574pub(super) fn mag3(a: [f64; 3]) -> f64 {
1575    (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt()
1576}
1577/// Generate a geometric temperature ladder for AMBER REMD simulations.
1578///
1579/// Returns `n_replicas` temperatures between `t_low` and `t_high` (K),
1580/// spaced geometrically (equal exchange acceptance rate approximation).
1581#[allow(dead_code)]
1582pub fn remd_temperature_ladder(t_low: f64, t_high: f64, n_replicas: usize) -> Vec<f64> {
1583    if n_replicas == 0 {
1584        return Vec::new();
1585    }
1586    if n_replicas == 1 {
1587        return vec![t_low];
1588    }
1589    let ratio = (t_high / t_low).powf(1.0 / (n_replicas - 1) as f64);
1590    (0..n_replicas)
1591        .map(|i| t_low * ratio.powi(i as i32))
1592        .collect()
1593}
1594/// Compute the (approximate) REMD exchange probability between two replicas.
1595///
1596/// `P = min(1, exp(-delta))` where
1597/// `delta = (1/kT_i - 1/kT_j) * (E_j - E_i)`.
1598///
1599/// `k_b` = Boltzmann constant (kcal mol⁻¹ K⁻¹) ≈ 0.001987207.
1600#[allow(dead_code)]
1601pub fn remd_exchange_probability(e_i: f64, e_j: f64, t_i: f64, t_j: f64) -> f64 {
1602    pub(super) const K_B: f64 = 0.001987207;
1603    if t_i < 1e-10 || t_j < 1e-10 {
1604        return 0.0;
1605    }
1606    let beta_i = 1.0 / (K_B * t_i);
1607    let beta_j = 1.0 / (K_B * t_j);
1608    let delta = (beta_j - beta_i) * (e_i - e_j);
1609    let exponent = -delta;
1610    if exponent >= 0.0 { 1.0 } else { exponent.exp() }
1611}
1612/// Parse a minimal AMBER NMR distance restraint file (RST format).
1613///
1614/// Each restraint block looks like:
1615/// ```text
1616///  &rst  iat=1,2, r1=1.5,r2=2.0,r3=3.0,r4=4.0, rk2=2.0,rk3=2.0 /
1617/// ```
1618#[allow(dead_code)]
1619pub fn parse_restraints(s: &str) -> Vec<AmberRestraint> {
1620    let mut restraints = Vec::new();
1621    let mut in_rst = false;
1622    let mut block = String::new();
1623    for line in s.lines() {
1624        let t = line.trim();
1625        if t.starts_with("&rst") || t.starts_with("&RST") {
1626            in_rst = true;
1627            block.clear();
1628            block.push_str(t);
1629            block.push(' ');
1630            if t.contains('/') {
1631                in_rst = false;
1632                if let Some(rst) = parse_rst_block(&block) {
1633                    restraints.push(rst);
1634                }
1635                block.clear();
1636            }
1637            continue;
1638        }
1639        if in_rst {
1640            block.push_str(t);
1641            block.push(' ');
1642            if t.ends_with('/') || t.contains('/') {
1643                in_rst = false;
1644                if let Some(rst) = parse_rst_block(&block) {
1645                    restraints.push(rst);
1646                }
1647                block.clear();
1648            }
1649        }
1650    }
1651    restraints
1652}
1653pub(super) fn parse_rst_block(block: &str) -> Option<AmberRestraint> {
1654    fn get_val(block: &str, key: &str) -> Option<f64> {
1655        let klen = key.len();
1656        let pos = block.find(key)?;
1657        let rest = block[pos + klen..].trim_start_matches(|c: char| c == '=' || c.is_whitespace());
1658        let end = rest
1659            .find(|c: char| c == ',' || c == '/' || c.is_whitespace())
1660            .unwrap_or(rest.len());
1661        rest[..end].parse().ok()
1662    }
1663    fn get_int_pair(block: &str, key: &str) -> Option<(usize, usize)> {
1664        let klen = key.len();
1665        let pos = block.find(key)?;
1666        let rest = block[pos + klen..].trim_start_matches(|c: char| c == '=' || c.is_whitespace());
1667        let pair_str: String = rest
1668            .chars()
1669            .take_while(|c| c.is_ascii_digit() || *c == ',')
1670            .collect();
1671        let parts: Vec<&str> = pair_str.split(',').filter(|s| !s.is_empty()).collect();
1672        if parts.len() >= 2 {
1673            let a: usize = parts[0].trim().parse().ok()?;
1674            let b: usize = parts[1].trim().parse().ok()?;
1675            Some((a.saturating_sub(1), b.saturating_sub(1)))
1676        } else if parts.len() == 1 {
1677            let a: usize = parts[0].trim().parse().ok()?;
1678            Some((a.saturating_sub(1), 0))
1679        } else {
1680            None
1681        }
1682    }
1683    let (iat1, iat2) = get_int_pair(block, "iat")?;
1684    let r1 = get_val(block, "r1").unwrap_or(0.0);
1685    let r2 = get_val(block, "r2").unwrap_or(0.0);
1686    let r3 = get_val(block, "r3").unwrap_or(0.0);
1687    let r4 = get_val(block, "r4").unwrap_or(0.0);
1688    let rk2 = get_val(block, "rk2").unwrap_or(1.0);
1689    let rk3 = get_val(block, "rk3").unwrap_or(1.0);
1690    Some(AmberRestraint {
1691        iat1,
1692        iat2,
1693        r1,
1694        r2,
1695        r3,
1696        r4,
1697        rk2,
1698        rk3,
1699    })
1700}
1701/// List all `%FLAG` section names found in a prmtop string.
1702#[allow(dead_code)]
1703pub fn list_prmtop_flags(prmtop: &str) -> Vec<String> {
1704    prmtop
1705        .lines()
1706        .filter_map(|line| {
1707            let t = line.trim();
1708            if t.starts_with("%FLAG ") {
1709                Some(t[6..].trim().to_string())
1710            } else {
1711                None
1712            }
1713        })
1714        .collect()
1715}
1716/// Check whether a given `%FLAG` section is present in a prmtop string.
1717#[allow(dead_code)]
1718pub fn has_prmtop_flag(prmtop: &str, flag: &str) -> bool {
1719    let target = format!("%FLAG {}", flag.to_uppercase());
1720    prmtop.lines().any(|l| l.trim() == target.as_str())
1721}
1722/// Compute the centre of mass (CoM) of all atoms in a topology given their
1723/// positions from an `AmberCoordinates` record.
1724#[allow(dead_code)]
1725pub fn centre_of_mass(topo: &AmberTopology, coords: &AmberCoordinates) -> [f64; 3] {
1726    let mut total_mass = 0.0_f64;
1727    let mut com = [0.0_f64; 3];
1728    for (i, atom) in topo.atoms.iter().enumerate() {
1729        if i >= coords.n_atoms {
1730            break;
1731        }
1732        let pos = coords.position(i);
1733        let m = atom.mass;
1734        com[0] += m * pos[0];
1735        com[1] += m * pos[1];
1736        com[2] += m * pos[2];
1737        total_mass += m;
1738    }
1739    if total_mass > 1e-30 {
1740        com[0] /= total_mass;
1741        com[1] /= total_mass;
1742        com[2] /= total_mass;
1743    }
1744    com
1745}
1746/// Compute the radius of gyration (Å) of atoms in a topology.
1747#[allow(dead_code)]
1748pub fn radius_of_gyration(topo: &AmberTopology, coords: &AmberCoordinates) -> f64 {
1749    let com = centre_of_mass(topo, coords);
1750    let mut total_mass = 0.0_f64;
1751    let mut sum = 0.0_f64;
1752    for (i, atom) in topo.atoms.iter().enumerate() {
1753        if i >= coords.n_atoms {
1754            break;
1755        }
1756        let pos = coords.position(i);
1757        let m = atom.mass;
1758        let d2 = (pos[0] - com[0]).powi(2) + (pos[1] - com[1]).powi(2) + (pos[2] - com[2]).powi(2);
1759        sum += m * d2;
1760        total_mass += m;
1761    }
1762    if total_mass > 1e-30 {
1763        (sum / total_mass).sqrt()
1764    } else {
1765        0.0
1766    }
1767}
1768#[cfg(test)]
1769mod tests_amber_geo {
1770    use super::*;
1771    use crate::amber::AmberAtom;
1772
1773    use crate::amber::types::*;
1774    use std::f64::consts::PI;
1775    #[test]
1776    fn test_validate_topology_clean() {
1777        let topo = AmberTopology {
1778            title: "valid".into(),
1779            atoms: vec![
1780                AmberAtom {
1781                    name: "C".into(),
1782                    residue_name: "ALA".into(),
1783                    charge: 0.0,
1784                    mass: 12.0,
1785                    atom_type: "CT".into(),
1786                },
1787                AmberAtom {
1788                    name: "N".into(),
1789                    residue_name: "ALA".into(),
1790                    charge: 0.0,
1791                    mass: 14.0,
1792                    atom_type: "N".into(),
1793                },
1794            ],
1795            bonds: vec![AmberBond {
1796                i: 0,
1797                j: 1,
1798                k: 300.0,
1799                r0: 1.5,
1800            }],
1801            angles: vec![],
1802            n_atoms: 2,
1803            n_bonds: 1,
1804        };
1805        let issues = validate_topology(&topo);
1806        assert!(issues.is_empty(), "unexpected issues: {:?}", issues);
1807    }
1808    #[test]
1809    fn test_validate_topology_bad_bond_index() {
1810        let topo = AmberTopology {
1811            title: "bad".into(),
1812            atoms: vec![AmberAtom {
1813                name: "C".into(),
1814                residue_name: "R".into(),
1815                charge: 0.0,
1816                mass: 12.0,
1817                atom_type: "C".into(),
1818            }],
1819            bonds: vec![AmberBond {
1820                i: 0,
1821                j: 99,
1822                k: 300.0,
1823                r0: 1.5,
1824            }],
1825            angles: vec![],
1826            n_atoms: 1,
1827            n_bonds: 1,
1828        };
1829        let issues = validate_topology(&topo);
1830        assert!(!issues.is_empty(), "should flag out-of-range atom index");
1831    }
1832    #[test]
1833    fn test_validate_topology_negative_mass() {
1834        let topo = AmberTopology {
1835            title: "neg".into(),
1836            atoms: vec![AmberAtom {
1837                name: "X".into(),
1838                residue_name: "R".into(),
1839                charge: 0.0,
1840                mass: -1.0,
1841                atom_type: "X".into(),
1842            }],
1843            bonds: vec![],
1844            angles: vec![],
1845            n_atoms: 1,
1846            n_bonds: 0,
1847        };
1848        let issues = validate_topology(&topo);
1849        assert!(!issues.is_empty(), "should flag negative mass");
1850    }
1851    #[test]
1852    fn test_atom_bond_counts_basic() {
1853        let topo = AmberTopology {
1854            title: "t".into(),
1855            atoms: vec![
1856                AmberAtom {
1857                    name: "A".into(),
1858                    residue_name: "R".into(),
1859                    charge: 0.0,
1860                    mass: 1.0,
1861                    atom_type: "A".into(),
1862                },
1863                AmberAtom {
1864                    name: "B".into(),
1865                    residue_name: "R".into(),
1866                    charge: 0.0,
1867                    mass: 1.0,
1868                    atom_type: "B".into(),
1869                },
1870                AmberAtom {
1871                    name: "C".into(),
1872                    residue_name: "R".into(),
1873                    charge: 0.0,
1874                    mass: 1.0,
1875                    atom_type: "C".into(),
1876                },
1877            ],
1878            bonds: vec![
1879                AmberBond {
1880                    i: 0,
1881                    j: 1,
1882                    k: 1.0,
1883                    r0: 1.0,
1884                },
1885                AmberBond {
1886                    i: 1,
1887                    j: 2,
1888                    k: 1.0,
1889                    r0: 1.0,
1890                },
1891            ],
1892            angles: vec![],
1893            n_atoms: 3,
1894            n_bonds: 2,
1895        };
1896        let counts = atom_bond_counts(&topo);
1897        assert_eq!(counts[0], 1);
1898        assert_eq!(counts[1], 2);
1899        assert_eq!(counts[2], 1);
1900    }
1901    #[test]
1902    fn test_coulomb_energy_unit_charges() {
1903        let e = coulomb_energy(1.0, 1.0, 1.0);
1904        assert!((e - 332.0636).abs() < 1e-3, "e={}", e);
1905    }
1906    #[test]
1907    fn test_coulomb_energy_opposite_charges() {
1908        let e = coulomb_energy(1.0, -1.0, 1.0);
1909        assert!(e < 0.0, "opposite charges should give negative energy");
1910    }
1911    #[test]
1912    fn test_lj_energy_at_r_min() {
1913        let a = 4.0_f64;
1914        let b = 4.0_f64;
1915        let r_min = (2.0 * a / b).powf(1.0 / 6.0);
1916        let e = lj_energy(a, b, r_min);
1917        let expected = -b * b / (4.0 * a);
1918        assert!(
1919            (e - expected).abs() < 1e-8,
1920            "e={}, expected={}",
1921            e,
1922            expected
1923        );
1924    }
1925    #[test]
1926    fn test_lorentz_berthelot_same_atoms() {
1927        let sigma = 3.0;
1928        let eps = 0.1;
1929        let (a, b) = lorentz_berthelot(sigma, sigma, eps, eps);
1930        let s6 = sigma.powi(6);
1931        let s12 = s6 * s6;
1932        let expected_b = 4.0 * eps * s6;
1933        let expected_a = 4.0 * eps * s12;
1934        assert!((a - expected_a).abs() < 1e-8, "a={}", a);
1935        assert!((b - expected_b).abs() < 1e-8, "b={}", b);
1936    }
1937    #[test]
1938    fn test_bond_energy_at_equilibrium() {
1939        assert!((bond_energy(300.0, 1.5, 1.5)).abs() < 1e-12);
1940    }
1941    #[test]
1942    fn test_bond_energy_displaced() {
1943        let e = bond_energy(300.0, 1.5, 1.6);
1944        assert!((e - 3.0).abs() < 1e-8, "e={}", e);
1945    }
1946    #[test]
1947    fn test_angle_energy_at_equilibrium() {
1948        let theta0 = 109.5 * PI / 180.0;
1949        assert!((angle_energy(50.0, theta0, theta0)).abs() < 1e-12);
1950    }
1951    #[test]
1952    fn test_dihedral_energy_at_zero_phase() {
1953        let e = dihedral_energy(2.0, 1.0, 0.0, 0.0);
1954        assert!((e - 2.0).abs() < 1e-8, "e={}", e);
1955    }
1956    #[test]
1957    fn test_distance_basic() {
1958        let a = [0.0, 0.0, 0.0];
1959        let b = [1.0, 0.0, 0.0];
1960        assert!((distance(a, b) - 1.0).abs() < 1e-10);
1961    }
1962    #[test]
1963    fn test_distance_3d() {
1964        let a = [1.0, 2.0, 3.0];
1965        let b = [4.0, 6.0, 3.0];
1966        let d = distance(a, b);
1967        assert!((d - 5.0).abs() < 1e-10, "d={}", d);
1968    }
1969    #[test]
1970    fn test_min_image_distance_no_wrap() {
1971        let a = [1.0, 0.0, 0.0];
1972        let b = [3.0, 0.0, 0.0];
1973        let d = min_image_distance(a, b, 100.0, 100.0, 100.0);
1974        assert!((d - 2.0).abs() < 1e-10, "d={}", d);
1975    }
1976    #[test]
1977    fn test_min_image_distance_wrap() {
1978        let a = [0.5, 0.0, 0.0];
1979        let b = [9.5, 0.0, 0.0];
1980        let d = min_image_distance(a, b, 10.0, 10.0, 10.0);
1981        assert!((d - 1.0).abs() < 1e-8, "d={}", d);
1982    }
1983    #[test]
1984    fn test_compute_angle_linear() {
1985        let a = [0.0, 0.0, 0.0];
1986        let b = [1.0, 0.0, 0.0];
1987        let c = [2.0, 0.0, 0.0];
1988        let theta = compute_angle(a, b, c);
1989        assert!((theta - PI).abs() < 1e-8, "theta={}", theta);
1990    }
1991    #[test]
1992    fn test_compute_angle_right() {
1993        let a = [1.0, 0.0, 0.0];
1994        let b = [0.0, 0.0, 0.0];
1995        let c = [0.0, 1.0, 0.0];
1996        let theta = compute_angle(a, b, c);
1997        assert!((theta - PI / 2.0).abs() < 1e-8, "theta={}", theta);
1998    }
1999    #[test]
2000    fn test_remd_temperature_ladder_two_replicas() {
2001        let temps = remd_temperature_ladder(300.0, 600.0, 2);
2002        assert_eq!(temps.len(), 2);
2003        assert!((temps[0] - 300.0).abs() < 1e-6);
2004        assert!((temps[1] - 600.0).abs() < 1e-6);
2005    }
2006    #[test]
2007    fn test_remd_temperature_ladder_geometric() {
2008        let temps = remd_temperature_ladder(300.0, 1200.0, 3);
2009        assert_eq!(temps.len(), 3);
2010        assert!((temps[1] / temps[0] - temps[2] / temps[1]).abs() < 1e-6);
2011    }
2012    #[test]
2013    fn test_remd_exchange_probability_identical() {
2014        let p = remd_exchange_probability(-100.0, -100.0, 300.0, 300.0);
2015        assert!((p - 1.0).abs() < 1e-10);
2016    }
2017    #[test]
2018    fn test_remd_exchange_probability_unfavorable() {
2019        let p = remd_exchange_probability(0.0, -1000.0, 300.0, 310.0);
2020        assert!(p >= 0.0 && p <= 1.0, "p={}", p);
2021    }
2022    #[test]
2023    fn test_parse_restraints_basic() {
2024        let rst_text = " &rst  iat=1,2, r1=1.5,r2=2.0,r3=3.0,r4=4.0, rk2=2.0,rk3=2.0 /\n";
2025        let rests = parse_restraints(rst_text);
2026        assert_eq!(rests.len(), 1);
2027        assert_eq!(rests[0].iat1, 0);
2028        assert_eq!(rests[0].iat2, 1);
2029        assert!((rests[0].r2 - 2.0).abs() < 1e-6);
2030        assert!((rests[0].rk2 - 2.0).abs() < 1e-6);
2031    }
2032    #[test]
2033    fn test_restraint_energy_flat_bottom() {
2034        let r = AmberRestraint {
2035            iat1: 0,
2036            iat2: 1,
2037            r1: 1.5,
2038            r2: 2.0,
2039            r3: 3.0,
2040            r4: 4.0,
2041            rk2: 1.0,
2042            rk3: 1.0,
2043        };
2044        assert!((r.energy(2.5)).abs() < 1e-10);
2045        let e_out = r.energy(3.5);
2046        assert!((e_out - 0.25).abs() < 1e-8, "e={}", e_out);
2047    }
2048    #[test]
2049    fn test_list_prmtop_flags() {
2050        let prmtop = "%FLAG TITLE\n%FORMAT(20a4)\nhello\n%FLAG POINTERS\n%FORMAT(10I8)\n1\n";
2051        let flags = list_prmtop_flags(prmtop);
2052        assert!(flags.contains(&"TITLE".to_string()));
2053        assert!(flags.contains(&"POINTERS".to_string()));
2054    }
2055    #[test]
2056    fn test_has_prmtop_flag_true() {
2057        let prmtop = "%FLAG CHARGE\n%FORMAT(5E16.8)\n1.0\n";
2058        assert!(has_prmtop_flag(prmtop, "CHARGE"));
2059    }
2060    #[test]
2061    fn test_has_prmtop_flag_false() {
2062        let prmtop = "%FLAG CHARGE\n%FORMAT(5E16.8)\n1.0\n";
2063        assert!(!has_prmtop_flag(prmtop, "MISSING_SECTION"));
2064    }
2065    #[test]
2066    fn test_centre_of_mass_equal_masses() {
2067        let topo = AmberTopology {
2068            title: "t".into(),
2069            atoms: vec![
2070                AmberAtom {
2071                    name: "A".into(),
2072                    residue_name: "R".into(),
2073                    charge: 0.0,
2074                    mass: 1.0,
2075                    atom_type: "A".into(),
2076                },
2077                AmberAtom {
2078                    name: "B".into(),
2079                    residue_name: "R".into(),
2080                    charge: 0.0,
2081                    mass: 1.0,
2082                    atom_type: "B".into(),
2083                },
2084            ],
2085            bonds: vec![],
2086            angles: vec![],
2087            n_atoms: 2,
2088            n_bonds: 0,
2089        };
2090        let coords_str = "test\n    2\n   0.0000000   0.0000000   0.0000000   2.0000000   0.0000000   0.0000000\n";
2091        let coords = AmberCoordinates::from_str(coords_str).unwrap();
2092        let com = centre_of_mass(&topo, &coords);
2093        assert!((com[0] - 1.0).abs() < 1e-8, "com_x={}", com[0]);
2094        assert!((com[1]).abs() < 1e-8);
2095        assert!((com[2]).abs() < 1e-8);
2096    }
2097    #[test]
2098    fn test_radius_of_gyration_dumbbell() {
2099        let topo = AmberTopology {
2100            title: "t".into(),
2101            atoms: vec![
2102                AmberAtom {
2103                    name: "A".into(),
2104                    residue_name: "R".into(),
2105                    charge: 0.0,
2106                    mass: 1.0,
2107                    atom_type: "A".into(),
2108                },
2109                AmberAtom {
2110                    name: "B".into(),
2111                    residue_name: "R".into(),
2112                    charge: 0.0,
2113                    mass: 1.0,
2114                    atom_type: "B".into(),
2115                },
2116            ],
2117            bonds: vec![],
2118            angles: vec![],
2119            n_atoms: 2,
2120            n_bonds: 0,
2121        };
2122        let coords_str = "test\n    2\n  -1.0000000   0.0000000   0.0000000   1.0000000   0.0000000   0.0000000\n";
2123        let coords = AmberCoordinates::from_str(coords_str).unwrap();
2124        let rg = radius_of_gyration(&topo, &coords);
2125        assert!((rg - 1.0).abs() < 1e-8, "rg={}", rg);
2126    }
2127}