1#![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
12pub 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}
31pub 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}
37pub fn parse_fortran_ints(s: &str) -> Vec<i64> {
39 s.split_whitespace()
40 .filter_map(|tok| tok.parse::<i64>().ok())
41 .collect()
42}
43pub(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}
69pub(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}
84pub(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#[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#[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#[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#[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#[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#[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#[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}
869pub(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}
877pub(super) fn collect_numbers(s: &str) -> Vec<f64> {
879 s.split_whitespace()
880 .filter_map(|tok| tok.parse::<f64>().ok())
881 .collect()
882}
883pub(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#[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#[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#[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#[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#[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#[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#[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#[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#[allow(dead_code)]
1481pub fn bond_energy(k: f64, r0: f64, r: f64) -> f64 {
1482 k * (r - r0).powi(2)
1483}
1484#[allow(dead_code)]
1488pub fn angle_energy(k: f64, theta0: f64, theta: f64) -> f64 {
1489 k * (theta - theta0).powi(2)
1490}
1491#[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#[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#[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}
1514pub(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#[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#[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#[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#[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#[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#[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#[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#[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#[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}