1use crate::corpus::ConCorpus;
6use crate::error::Result;
7use crate::select::Select;
8
9#[derive(Clone, Debug)]
11pub struct H5mdArrays {
12 pub n_frames: usize,
13 pub natoms: usize,
14 pub positions: Vec<f64>,
16 pub edges: Vec<f64>,
18 pub species_z: Vec<i32>,
20 pub forces: Option<Vec<f64>>,
22 pub velocities: Option<Vec<f64>>,
24 pub velocity_unit: String,
25 pub boundary: [String; 3],
27 pub times: Vec<f64>,
29 pub time_unit: String,
30 pub length_unit: String,
31 pub force_unit: String,
32}
33
34pub const H5MD_LENGTH_CORE: &str = "angstrom";
36pub const H5MD_LENGTH_ATTR: &str = "Angstrom";
37pub const H5MD_TIME_CORE: &str = "ps";
38pub const H5MD_TIME_ATTR: &str = "ps";
39pub const CON_TIME_DEFAULT: &str = "fs";
45
46#[derive(Clone, Debug, Default)]
54pub struct H5mdCollectOpts {
55 pub velocity_time_unit: Option<String>,
56}
57pub const H5MD_FORCE_ATTR: &str = "kJ mol-1 Angstrom-1";
58pub const H5MD_VELOCITY_ATTR: &str = "Angstrom ps-1";
59const KJ_MOL_ANGSTROM_SI: f64 = (1000.0 / 6.022_140_76e23) / 1e-10;
61
62fn boundary_from_pbc(pbc: Option<[bool; 3]>) -> [String; 3] {
63 let p = pbc.unwrap_or([true, true, true]);
64 std::array::from_fn(|i| {
65 if p[i] {
66 "periodic".into()
67 } else {
68 "none".into()
69 }
70 })
71}
72
73fn boxl_to_edges33(boxl: &[f64; 3]) -> [f64; 9] {
74 [boxl[0], 0.0, 0.0, 0.0, boxl[1], 0.0, 0.0, 0.0, boxl[2]]
75}
76
77fn uc(from: &str, to: &str) -> Result<f64> {
78 readcon_core::units::unit_conversion_factor(from, to)
79 .map_err(|e| crate::error::Error::Message(e.to_string()))
80}
81
82fn header_unit(h: &readcon_core::types::FrameHeader, dim: &str, default: &str) -> String {
83 h.unit_for(dim)
84 .map(str::to_string)
85 .unwrap_or_else(|| default.to_string())
86}
87
88fn force_scale_to_engine(energy_u: &str, length_u: &str) -> Result<f64> {
90 let e_j = uc(energy_u, "J")?;
91 let l_m = uc(length_u, "m")?;
92 Ok((e_j / l_m) / KJ_MOL_ANGSTROM_SI)
93}
94
95fn time_scale_to_ps(from: &str) -> Result<f64> {
96 match uc(from, H5MD_TIME_CORE) {
97 Ok(f) => Ok(f),
98 Err(_) if from.eq_ignore_ascii_case("ns") || from.eq_ignore_ascii_case("nanosecond") => {
99 Ok(1e3)
100 }
101 Err(e) => Err(e),
102 }
103}
104
105fn declared_time_unit(h: &readcon_core::types::FrameHeader) -> Option<String> {
106 h.unit_for("time")
107 .map(str::trim)
108 .filter(|u| !u.is_empty())
109 .map(str::to_string)
110}
111
112fn positive_timestep(h: &readcon_core::types::FrameHeader) -> Option<f64> {
113 h.timestep().filter(|x| x.is_finite() && *x > 0.0)
114}
115
116fn frame_time_ps(
117 h: &readcon_core::types::FrameHeader,
118 frame_idx: u32,
119 override_unit: Option<&str>,
120) -> Result<f64> {
121 let clock = h.time().is_some() || positive_timestep(h).is_some();
122 if !clock {
123 return Ok(f64::from(frame_idx));
124 }
125 let from = if let Some(unit) = declared_time_unit(h) {
126 unit
127 } else if crate::units_canon::velocity_time_is_declared(h) {
128 match override_unit.map(str::trim).filter(|u| !u.is_empty()) {
129 Some(unit) => unit.to_string(),
130 None => {
131 return Err(crate::error::Error::Message(
132 "v3.1 frame has a clock but no units.time (D10)".into(),
133 ));
134 }
135 }
136 } else {
137 CON_TIME_DEFAULT.to_string()
138 };
139 let scale = time_scale_to_ps(&from)?;
140 if let Some(t) = h.time() {
141 return Ok(t * scale);
142 }
143 if let Some(dt) = positive_timestep(h) {
144 return Ok(f64::from(frame_idx) * dt * scale);
145 }
146 Ok(f64::from(frame_idx))
147}
148
149pub(crate) fn edges33_from_header(h: &readcon_core::types::FrameHeader) -> [f64; 9] {
150 if let Some(arr) = h.metadata.get("lattice_vectors").and_then(|v| v.as_array()) {
151 if arr.len() == 3 {
152 let mut out = [0.0f64; 9];
153 let mut ok = true;
154 for (i, row) in arr.iter().enumerate() {
155 let Some(r) = row.as_array() else {
156 ok = false;
157 break;
158 };
159 if r.len() != 3 {
160 ok = false;
161 break;
162 }
163 for (j, c) in r.iter().enumerate() {
164 let Some(x) = c.as_f64() else {
165 ok = false;
166 break;
167 };
168 out[i * 3 + j] = x;
169 }
170 }
171 if ok {
172 return out;
173 }
174 }
175 }
176 boxl_angles_to_edges33(&h.boxl, &h.angles)
177}
178
179fn boxl_angles_to_edges33(boxl: &[f64; 3], angles: &[f64; 3]) -> [f64; 9] {
180 let ortho = angles.iter().all(|&a| a == 0.0 || (a - 90.0).abs() < 1e-9);
181 if ortho {
182 return boxl_to_edges33(boxl);
183 }
184 let deg = |a: f64| a * std::f64::consts::PI / 180.0;
185 let (lx, ly, lz) = (boxl[0], boxl[1], boxl[2]);
186 let (alpha, beta, gamma) = (deg(angles[0]), deg(angles[1]), deg(angles[2]));
187 let ax = lx;
188 let bx = ly * gamma.cos();
189 let by = ly * gamma.sin();
190 let cx = lz * beta.cos();
191 let cy = lz * (alpha.cos() - beta.cos() * gamma.cos()) / gamma.sin();
192 let cz = (lz * lz - cx * cx - cy * cy).max(0.0).sqrt();
193 [ax, 0.0, 0.0, bx, by, 0.0, cx, cy, cz]
194}
195
196fn velocity_time_unit_for(
197 header: &readcon_core::types::FrameHeader,
198 has_velocities: bool,
199 override_unit: Option<&str>,
200) -> Result<String> {
201 if let Some(unit) = override_unit {
202 let _ = time_scale_to_ps(unit)?;
203 return Ok(unit.to_string());
204 }
205 if crate::units_canon::velocity_time_is_declared(header) {
206 match header.unit_for("time") {
207 Some(unit) if !unit.trim().is_empty() => Ok(unit.to_string()),
208 _ if has_velocities => Err(crate::error::Error::Message(
209 "v3.1 frame has velocities but no units.time (D10)".into(),
210 )),
211 _ => Ok(CON_TIME_DEFAULT.to_string()),
213 }
214 } else {
215 Ok(crate::units_canon::NATURAL_TIME_UNIT.to_string())
216 }
217}
218
219impl ConCorpus {
220 pub fn collect_h5md(&self, traj_id: u64) -> Result<H5mdArrays> {
227 self.collect_h5md_with(traj_id, &H5mdCollectOpts::default())
228 }
229
230 pub fn collect_h5md_with(&self, traj_id: u64, opts: &H5mdCollectOpts) -> Result<H5mdArrays> {
232 let keys = self.select(&Select::new().trajectory(traj_id))?;
233 if keys.is_empty() {
234 return Err(crate::error::Error::Message("no frames for traj".into()));
235 }
236 let first = self.get_frame(keys[0])?;
237 let natoms = first.atom_data.len();
238 let species_z: Vec<i32> = first
239 .atom_data
240 .iter()
241 .map(|a| readcon_core::helpers::symbol_to_atomic_number(a.symbol.as_ref()) as i32)
242 .collect();
243 let n_frames = keys.len();
244 let boundary = boundary_from_pbc(first.header.pbc());
245 let mut positions = Vec::with_capacity(n_frames * natoms * 3);
246 let mut edges = Vec::with_capacity(n_frames * 9);
247 let mut times = Vec::with_capacity(n_frames);
248 let mut force_rows: Vec<Option<Vec<[f64; 3]>>> = Vec::with_capacity(n_frames);
249 let mut vel_rows: Vec<Option<Vec<[f64; 3]>>> = Vec::with_capacity(n_frames);
250 for (i, k) in keys.iter().enumerate() {
251 let fr = if i == 0 {
252 first.clone()
253 } else {
254 self.get_frame(*k)?
255 };
256 let length_u = header_unit(&fr.header, "length", H5MD_LENGTH_CORE);
257 let energy_u = header_unit(&fr.header, "energy", "eV");
258 let len_scale = uc(&length_u, H5MD_LENGTH_CORE)?;
259 times.push(frame_time_ps(
260 &fr.header,
261 k.frame_idx,
262 opts.velocity_time_unit.as_deref(),
263 )?);
264 let e33 = edges33_from_header(&fr.header);
265 for x in e33 {
266 edges.push(x * len_scale);
267 }
268 if fr.atom_data.len() != natoms {
269 return Err(crate::error::Error::Message(
270 "H5MD export needs fixed natoms in the trajectory".into(),
271 ));
272 }
273 if boundary_from_pbc(fr.header.pbc()) != boundary {
274 return Err(crate::error::Error::Message(
275 "H5MD export needs fixed box/boundary in the trajectory".into(),
276 ));
277 }
278 let z_here: Vec<i32> = fr
279 .atom_data
280 .iter()
281 .map(|a| readcon_core::helpers::symbol_to_atomic_number(a.symbol.as_ref()) as i32)
282 .collect();
283 if z_here != species_z {
284 return Err(crate::error::Error::Message(
285 "H5MD export needs fixed species Z in the trajectory".into(),
286 ));
287 }
288 let (pos_src, vel_src, frc_src) = if let Some(c) = self.get_cooked_soa(*k)? {
289 (c.positions, c.velocities, c.forces)
290 } else {
291 let pos: Vec<[f64; 3]> = fr.atom_data.iter().map(|a| [a.x, a.y, a.z]).collect();
292 let vel = if fr.atom_data.iter().any(|a| a.velocity().is_some()) {
293 Some(
294 fr.atom_data
295 .iter()
296 .map(|a| a.velocity().unwrap_or([0.0; 3]))
297 .collect(),
298 )
299 } else {
300 None
301 };
302 let frc = if fr.atom_data.iter().any(|a| a.force().is_some()) {
303 Some(
304 fr.atom_data
305 .iter()
306 .map(|a| a.force().unwrap_or([0.0; 3]))
307 .collect(),
308 )
309 } else {
310 None
311 };
312 (pos, vel, frc)
313 };
314 let vel_time = velocity_time_unit_for(
315 &fr.header,
316 vel_src.is_some(),
317 opts.velocity_time_unit.as_deref(),
318 )?;
319 let vel_scale = len_scale / time_scale_to_ps(&vel_time)?;
320 for p in &pos_src {
321 positions.extend_from_slice(&[
322 p[0] * len_scale,
323 p[1] * len_scale,
324 p[2] * len_scale,
325 ]);
326 }
327 vel_rows.push(match vel_src {
328 Some(rows) => Some(
329 rows.into_iter()
330 .map(|r| [r[0] * vel_scale, r[1] * vel_scale, r[2] * vel_scale])
331 .collect(),
332 ),
333 None => None,
334 });
335 force_rows.push(match frc_src {
336 Some(rows) => {
337 let fscale = force_scale_to_engine(&energy_u, &length_u)?;
338 Some(
339 rows.into_iter()
340 .map(|r| [r[0] * fscale, r[1] * fscale, r[2] * fscale])
341 .collect(),
342 )
343 }
344 None => None,
345 });
346 }
347 let forces = if force_rows.iter().any(|f| f.is_some()) {
348 let mut fbuf = vec![0.0f64; n_frames * natoms * 3];
349 for (ti, fo) in force_rows.iter().enumerate() {
350 if let Some(rows) = fo {
351 let off = ti * natoms * 3;
352 for (i, row) in rows.iter().enumerate() {
353 fbuf[off + i * 3] = row[0];
354 fbuf[off + i * 3 + 1] = row[1];
355 fbuf[off + i * 3 + 2] = row[2];
356 }
357 }
358 }
359 Some(fbuf)
360 } else {
361 None
362 };
363 let velocities = if vel_rows.iter().any(|v| v.is_some()) {
364 let mut vbuf = vec![0.0f64; n_frames * natoms * 3];
365 for (ti, vo) in vel_rows.iter().enumerate() {
366 if let Some(rows) = vo {
367 let off = ti * natoms * 3;
368 for (i, row) in rows.iter().enumerate() {
369 vbuf[off + i * 3] = row[0];
370 vbuf[off + i * 3 + 1] = row[1];
371 vbuf[off + i * 3 + 2] = row[2];
372 }
373 }
374 }
375 Some(vbuf)
376 } else {
377 None
378 };
379 Ok(H5mdArrays {
380 n_frames,
381 natoms,
382 positions,
383 edges,
384 species_z,
385 forces,
386 velocities,
387 velocity_unit: H5MD_VELOCITY_ATTR.into(),
388 boundary,
389 times,
390 time_unit: H5MD_TIME_ATTR.into(),
391 length_unit: H5MD_LENGTH_ATTR.into(),
392 force_unit: H5MD_FORCE_ATTR.into(),
393 })
394 }
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400 use std::path::PathBuf;
401
402 fn fixture(name: &str) -> PathBuf {
403 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
404 .join("resources/test")
405 .join(name)
406 }
407
408 #[test]
409 fn collect_h5md_tn3_from_con() {
410 let dir = tempfile::tempdir().unwrap();
411 let db = ConCorpus::open(dir.path()).unwrap();
412 let n = db
413 .append_trajectory_path(1, fixture("tiny_multi_cuh2.con"))
414 .unwrap();
415 assert!(n >= 2);
416 let a = db.collect_h5md(1).unwrap();
417 assert_eq!(a.n_frames, n as usize);
418 assert!(a.natoms >= 1);
419 assert_eq!(a.positions.len(), a.n_frames * a.natoms * 3);
420 assert_eq!(a.edges.len(), a.n_frames * 9);
421 assert_eq!(a.species_z.len(), a.natoms);
422 assert!(a.species_z.iter().all(|&z| z > 0));
423 assert_eq!(a.boundary.len(), 3);
424 assert_eq!(
425 a.boundary,
426 [
427 "periodic".to_string(),
428 "periodic".to_string(),
429 "periodic".to_string()
430 ]
431 );
432 assert_eq!(a.times.len(), a.n_frames);
433 for (i, t) in a.times.iter().enumerate() {
434 assert!(
435 (*t - i as f64).abs() < 1e-12,
436 "frame-index dest ps: times[{i}]={t}"
437 );
438 }
439 assert!((a.positions[0] - 0.6394).abs() < 1e-4);
440 let i_h1 = a.natoms * 3 + 2 * 3;
441 assert!(
442 (a.positions[i_h1] - 8.8549).abs() < 1e-4,
443 "tn3 frame1 H dest Å x={}",
444 a.positions[i_h1]
445 );
446 }
447
448 #[test]
449 fn export_extxyz_writes_pbc_and_triclinic_lattice() {
450 let dir = tempfile::tempdir().unwrap();
451 let db = ConCorpus::open(dir.path()).unwrap();
452 let text = std::fs::read_to_string(fixture("tiny_cuh2.con")).unwrap();
453 let mut frames = Vec::new();
454 for item in readcon_core::iterators::ConFrameIterator::new(&text) {
455 frames.push(item.unwrap());
456 }
457 frames[0].header.angles = [60.0, 90.0, 70.0];
458 frames[0]
459 .header
460 .metadata
461 .insert("pbc".into(), serde_json::json!([false, true, false]));
462 db.append_trajectory_frames(1, &frames, "t").unwrap();
463 let keys = db
464 .select(&crate::select::Select::new().trajectory(1))
465 .unwrap();
466 let xyz = dir.path().join("t.xyz");
467 db.export_extxyz(&keys, &xyz, "energy").unwrap();
468 let out = std::fs::read_to_string(&xyz).unwrap();
469 assert!(out.contains("pbc=\"F T F\""), "{out}");
470 assert!(!out.contains("pbc=\"T T T\""));
471 let e = crate::export_h5md::edges33_from_header(&frames[0].header);
472 assert!(e[3].abs() > 1e-9, "triclinic b_x");
473 assert!(out.contains(&format!("{:.10}", e[3])), "{out}");
474 }
475
476 #[test]
477 fn collect_h5md_rejects_changing_natoms() {
478 let dir = tempfile::tempdir().unwrap();
479 let db = ConCorpus::open(dir.path()).unwrap();
480 db.append_trajectory_path(1, fixture("tiny_cuh2.con"))
481 .unwrap();
482 db.extend_trajectory_path(1, fixture("sulfolene.con"))
483 .unwrap();
484 let err = db.collect_h5md(1).unwrap_err();
485 assert!(err.to_string().contains("fixed natoms"), "{err}");
486 }
487
488 #[test]
489 fn collect_h5md_rejects_changing_pbc() {
490 let dir = tempfile::tempdir().unwrap();
491 let db = ConCorpus::open(dir.path()).unwrap();
492 let text = std::fs::read_to_string(fixture("tiny_multi_cuh2.con")).unwrap();
493 let mut frames = Vec::new();
494 for item in readcon_core::iterators::ConFrameIterator::new(&text) {
495 frames.push(item.unwrap());
496 }
497 assert!(frames.len() >= 2);
498 frames[0]
499 .header
500 .metadata
501 .insert("pbc".into(), serde_json::json!([true, true, true]));
502 frames[1]
503 .header
504 .metadata
505 .insert("pbc".into(), serde_json::json!([false, false, false]));
506 db.append_trajectory_frames(1, &frames, "t").unwrap();
507 let err = db.collect_h5md(1).unwrap_err();
508 assert!(err.to_string().contains("fixed box/boundary"), "{err}");
509 }
510
511 #[test]
512 fn collect_h5md_open_readonly() {
513 let dir = tempfile::tempdir().unwrap();
514 {
515 let db = ConCorpus::open(dir.path()).unwrap();
516 db.append_trajectory_path(1, fixture("tiny_multi_cuh2.con"))
517 .unwrap();
518 db.close();
519 }
520 let ro = ConCorpus::open_readonly(dir.path()).unwrap();
521 let a = ro.collect_h5md(1).unwrap();
522 assert!(a.n_frames >= 2);
523 assert_eq!(a.positions.len(), a.n_frames * a.natoms * 3);
524 assert!((a.positions[0] - 0.6394).abs() < 1e-4);
525 let i_h1 = a.natoms * 3 + 2 * 3;
526 assert!(
527 (a.positions[i_h1] - 8.8549).abs() < 1e-4,
528 "readonly frame1 H dest Å x={}",
529 a.positions[i_h1]
530 );
531 }
532
533 #[test]
534 fn collect_h5md_two_frame_distinct_boxl() {
535 let dir = tempfile::tempdir().unwrap();
536 let db = ConCorpus::open(dir.path()).unwrap();
537 let text = std::fs::read_to_string(fixture("tiny_cuh2.con")).unwrap();
538 let mut frames = Vec::new();
539 for item in readcon_core::iterators::ConFrameIterator::new(&text) {
540 frames.push(item.unwrap());
541 }
542 let mut f1 = frames[0].clone();
543 f1.header.boxl = [20.0, 21.702, 100.0];
544 frames.push(f1);
545 db.append_trajectory_frames(1, &frames, "t").unwrap();
546 let a = db.collect_h5md(1).unwrap();
547 assert!(a.n_frames >= 2);
548 assert!((a.edges[0] - 15.3456).abs() < 1e-4, "e0={}", a.edges[0]);
549 assert!((a.edges[9] - 20.0).abs() < 1e-9, "e9={}", a.edges[9]);
550 }
551
552 #[test]
553 fn collect_h5md_mixed_pbc_f_t_f() {
554 let dir = tempfile::tempdir().unwrap();
555 let db = ConCorpus::open(dir.path()).unwrap();
556 let text = std::fs::read_to_string(fixture("tiny_cuh2.con")).unwrap();
557 let mut frames = Vec::new();
558 for item in readcon_core::iterators::ConFrameIterator::new(&text) {
559 frames.push(item.unwrap());
560 }
561 frames[0]
562 .header
563 .metadata
564 .insert("pbc".into(), serde_json::json!([false, true, false]));
565 db.append_trajectory_frames(1, &frames, "t").unwrap();
566 let a = db.collect_h5md(1).unwrap();
567 assert_eq!(
568 a.boundary,
569 [
570 "none".to_string(),
571 "periodic".to_string(),
572 "none".to_string()
573 ]
574 );
575 }
576
577 #[test]
578 fn collect_h5md_boundary_none_from_pbc_false() {
579 let dir = tempfile::tempdir().unwrap();
580 let db = ConCorpus::open(dir.path()).unwrap();
581 let text = std::fs::read_to_string(fixture("tiny_cuh2.con")).unwrap();
582 let mut frames = Vec::new();
583 for item in readcon_core::iterators::ConFrameIterator::new(&text) {
584 frames.push(item.unwrap());
585 }
586 frames[0]
587 .header
588 .metadata
589 .insert("pbc".into(), serde_json::json!([false, false, false]));
590 db.append_trajectory_frames(1, &frames, "t").unwrap();
591 let a = db.collect_h5md(1).unwrap();
592 assert_eq!(
593 a.boundary,
594 ["none".to_string(), "none".to_string(), "none".to_string()]
595 );
596 }
597
598 #[test]
599 fn collect_h5md_pads_mixed_forces() {
600 let dir = tempfile::tempdir().unwrap();
601 let db = ConCorpus::open(dir.path()).unwrap();
602 db.append_trajectory_path(1, fixture("tiny_cuh2.con"))
603 .unwrap();
604 db.extend_trajectory_path(1, fixture("tiny_cuh2_forces.con"))
605 .unwrap();
606 let a = db.collect_h5md(1).unwrap();
607 let f = a.forces.expect("second frame carries forces");
608 assert_eq!(f.len(), a.n_frames * a.natoms * 3);
609 assert!(a.n_frames >= 2);
610 assert!(f[..a.natoms * 3].iter().all(|&x| x == 0.0));
611 assert!(f[a.natoms * 3..].iter().any(|&x| x != 0.0));
612 }
613
614 #[test]
615 fn collect_h5md_con_fallback_matches_rcso() {
616 let dir = tempfile::tempdir().unwrap();
617 let db = ConCorpus::open(dir.path()).unwrap();
618 db.append_trajectory_path(1, fixture("tiny_multi_cuh2.con"))
619 .unwrap();
620 let key = crate::keys::FrameKey {
621 traj_id: 1,
622 frame_idx: 0,
623 };
624 assert!(!db.has_valid_cooked_soa(key).unwrap());
625 let from_con = db.collect_h5md(1).unwrap();
626 db.recook_all().unwrap();
627 assert!(db.has_valid_cooked_soa(key).unwrap());
628 let from_rcso = db.collect_h5md(1).unwrap();
629 assert_eq!(from_con.n_frames, from_rcso.n_frames);
630 assert_eq!(from_con.positions, from_rcso.positions);
631 assert_eq!(from_con.edges, from_rcso.edges);
632 assert_eq!(from_con.species_z, from_rcso.species_z);
633 let blob = db.get_cooked_soa_bytes(key).unwrap().expect("stored RCSO");
634 let decoded = crate::cooked_soa::CookedSoa::decode(&blob).unwrap();
635 assert_eq!(decoded.positions.len(), from_rcso.natoms);
636 assert!((decoded.positions[0][0] - 0.6394).abs() < 1e-4);
637 assert!((from_rcso.positions[0] - 0.6394).abs() < 1e-4);
638 assert_eq!(decoded.positions[0][0], from_con.positions[0]);
639 let i_h1 = from_rcso.natoms * 3 + 2 * 3;
641 assert!(
642 (from_rcso.positions[i_h1] - 8.8549).abs() < 1e-4,
643 "frame1 H x={}",
644 from_rcso.positions[i_h1]
645 );
646 assert!((from_con.positions[i_h1] - 8.8549).abs() < 1e-4);
647 }
648
649 #[test]
650 fn collect_h5md_uses_con_time_and_fs_unit() {
651 let dir = tempfile::tempdir().unwrap();
652 let db = ConCorpus::open(dir.path()).unwrap();
653 let text = std::fs::read_to_string(fixture("tiny_cuh2.con")).unwrap();
654 let mut frames = Vec::new();
655 for item in readcon_core::iterators::ConFrameIterator::new(&text) {
656 frames.push(item.unwrap());
657 }
658 frames[0].header.set_time(12.5);
659 frames[0].header.metadata.insert(
660 "units".into(),
661 serde_json::json!({"length":"angstrom","energy":"eV","mass":"amu","time":"fs"}),
662 );
663 db.append_trajectory_frames(1, &frames, "t").unwrap();
664 let a = db.collect_h5md(1).unwrap();
665 assert!(
666 (a.times[0] - 0.0125).abs() < 1e-12,
667 "12.5 fs -> ps, got {}",
668 a.times[0]
669 );
670 assert_eq!(a.time_unit, "ps");
671 }
672
673 #[test]
674 fn collect_h5md_scales_nm_length_to_angstrom() {
675 let dir = tempfile::tempdir().unwrap();
676 let db = ConCorpus::open(dir.path()).unwrap();
677 let text = std::fs::read_to_string(fixture("tiny_cuh2.con")).unwrap();
678 let mut frames = Vec::new();
679 for item in readcon_core::iterators::ConFrameIterator::new(&text) {
680 frames.push(item.unwrap());
681 }
682 let box0 = frames[0].header.boxl[0];
683 frames[0].header.metadata.insert(
684 "units".into(),
685 serde_json::json!({"length":"nm","energy":"eV","mass":"amu","time":"fs"}),
686 );
687 db.append_trajectory_frames(1, &frames, "t").unwrap();
688 let a = db.collect_h5md(1).unwrap();
689 let scale = uc("nm", "angstrom").unwrap();
690 assert!((scale - 10.0).abs() < 1e-12);
691 assert!((a.edges[0] - box0 * scale).abs() < 1e-9);
692 assert!(
693 (a.positions[0] - 0.6394 * scale).abs() < 1e-4,
694 "dest Å x0 from nm-labelled CON, got {}",
695 a.positions[0]
696 );
697 assert_eq!(a.length_unit, "Angstrom");
698 }
699
700 #[test]
701 fn collect_h5md_ns_time_to_ps() {
702 let dir = tempfile::tempdir().unwrap();
703 let db = ConCorpus::open(dir.path()).unwrap();
704 let text = std::fs::read_to_string(fixture("tiny_cuh2.con")).unwrap();
705 let mut frames = Vec::new();
706 for item in readcon_core::iterators::ConFrameIterator::new(&text) {
707 frames.push(item.unwrap());
708 }
709 frames[0].header.set_time(2.0);
710 frames[0].header.metadata.insert(
711 "units".into(),
712 serde_json::json!({"length":"angstrom","energy":"eV","mass":"amu","time":"ns"}),
713 );
714 db.append_trajectory_frames(1, &frames, "t").unwrap();
715 let a = db.collect_h5md(1).unwrap();
716 assert!(
717 (a.times[0] - 2000.0).abs() < 1e-9,
718 "2 ns -> ps, got {}",
719 a.times[0]
720 );
721 assert_eq!(a.time_unit, "ps");
722 }
723
724 #[test]
725 fn collect_h5md_converts_force_via_core_units() {
726 let factor = force_scale_to_engine("eV", "angstrom").unwrap();
727 assert!((factor - 96.485_332).abs() < 1e-3, "got {factor}");
728 let dir = tempfile::tempdir().unwrap();
729 let db = ConCorpus::open(dir.path()).unwrap();
730 db.append_trajectory_path(1, fixture("tiny_cuh2_forces.con"))
731 .unwrap();
732 let a = db.collect_h5md(1).unwrap();
733 let f = a.forces.expect("forces");
734 let cooked = crate::cooked_soa::CookedSoa::decode(
735 &db.pack_frame(crate::keys::FrameKey {
736 traj_id: 1,
737 frame_idx: 0,
738 })
739 .unwrap(),
740 )
741 .unwrap();
742 let native = cooked.forces.expect("native");
743 assert!((f[0] - native[0][0] * factor).abs() < 1e-8);
744 assert_eq!(a.force_unit, "kJ mol-1 Angstrom-1");
745 assert_eq!(a.length_unit, "Angstrom");
746 }
747
748 #[test]
749 fn collect_h5md_triclinic_edges_from_angles() {
750 let dir = tempfile::tempdir().unwrap();
751 let db = ConCorpus::open(dir.path()).unwrap();
752 let text = std::fs::read_to_string(fixture("tiny_cuh2.con")).unwrap();
753 let mut frames = Vec::new();
754 for item in readcon_core::iterators::ConFrameIterator::new(&text) {
755 frames.push(item.unwrap());
756 }
757 frames[0].header.angles = [60.0, 90.0, 70.0];
758 db.append_trajectory_frames(1, &frames, "t").unwrap();
759 let a = db.collect_h5md(1).unwrap();
760 assert_eq!(a.edges.len(), 9);
761 assert!(a.edges[3].abs() > 1e-9, "b_x from gamma != 90");
762 let ortho = boxl_to_edges33(&frames[0].header.boxl);
763 assert_ne!(a.edges, ortho.to_vec());
764 }
765
766 #[test]
767 fn collect_h5md_undeclared_time_is_con_fs() {
768 let dir = tempfile::tempdir().unwrap();
769 let db = ConCorpus::open(dir.path()).unwrap();
770 let text = std::fs::read_to_string(fixture("tiny_cuh2.con")).unwrap();
771 let mut frames = Vec::new();
772 for item in readcon_core::iterators::ConFrameIterator::new(&text) {
773 frames.push(item.unwrap());
774 }
775 frames[0].header.set_time(12.5);
776 frames[0].header.metadata.remove("units");
777 db.append_trajectory_frames(1, &frames, "t").unwrap();
778 let a = db.collect_h5md(1).unwrap();
779 assert!(
780 (a.times[0] - 0.0125).abs() < 1e-12,
781 "v2 12.5 with no units.time is fs -> 0.0125 ps, got {}",
782 a.times[0]
783 );
784 }
785
786 #[test]
787 fn collect_h5md_uses_i_times_timestep() {
788 let dir = tempfile::tempdir().unwrap();
789 let db = ConCorpus::open(dir.path()).unwrap();
790 let text = std::fs::read_to_string(fixture("tiny_multi_cuh2.con")).unwrap();
791 let mut frames = Vec::new();
792 for item in readcon_core::iterators::ConFrameIterator::new(&text) {
793 frames.push(item.unwrap());
794 }
795 assert!(frames.len() >= 2);
796 for fr in &mut frames {
797 fr.header.metadata.remove("time");
798 fr.header.set_timestep(10.0);
799 fr.header.metadata.remove("units");
800 }
801 db.append_trajectory_frames(1, &frames, "t").unwrap();
802 let a = db.collect_h5md(1).unwrap();
803 assert!(a.times.len() >= 2);
804 assert!((a.times[0] - 0.0).abs() < 1e-12, "got {}", a.times[0]);
805 assert!(
806 (a.times[1] - 0.01).abs() < 1e-12,
807 "i=1 * 10 fs -> 0.01 ps, got {}",
808 a.times[1]
809 );
810 assert!((a.positions[0] - 0.6394).abs() < 1e-4);
811 let i_h1 = a.natoms * 3 + 2 * 3;
812 assert!(
813 (a.positions[i_h1] - 8.8549).abs() < 1e-4,
814 "frame1 H dest Å x={}",
815 a.positions[i_h1]
816 );
817 }
818
819 #[test]
820 fn collect_h5md_set_units_converts_then_export_matches() {
821 let dir = tempfile::tempdir().unwrap();
822 let db = ConCorpus::open(dir.path()).unwrap();
823 let text = std::fs::read_to_string(fixture("tiny_cuh2.con")).unwrap();
824 let mut frames = Vec::new();
825 for item in readcon_core::iterators::ConFrameIterator::new(&text) {
826 frames.push(item.unwrap());
827 }
828 let box0 = frames[0].header.boxl[0];
829 frames[0].header.metadata.insert(
830 "units".into(),
831 serde_json::json!({"length":"angstrom","energy":"eV"}),
832 );
833 db.append_trajectory_frames(1, &frames, "t").unwrap();
834 let before = db.collect_h5md(1).unwrap();
835 db.set_trajectory_units(1, serde_json::json!({"length":"nm","energy":"eV"}))
836 .unwrap();
837 let after = db.collect_h5md(1).unwrap();
838 assert!((before.edges[0] - box0).abs() < 1e-9);
839 assert!((after.edges[0] - box0).abs() < 1e-9);
840 let u = db
841 .frame_units(crate::keys::FrameKey {
842 traj_id: 1,
843 frame_idx: 0,
844 })
845 .unwrap()
846 .unwrap();
847 assert_eq!(u["length"], "nm");
848 assert_eq!(before.times.len(), after.times.len());
849 for (bt, at) in before.times.iter().zip(&after.times) {
850 assert!(
851 (bt - at).abs() < 1e-12,
852 "dest time after set_units {bt} vs {at}"
853 );
854 }
855 }
856
857 #[test]
858 fn collect_h5md_rejects_changing_species_z() {
859 let dir = tempfile::tempdir().unwrap();
860 let db = ConCorpus::open(dir.path()).unwrap();
861 let text = std::fs::read_to_string(fixture("tiny_multi_cuh2.con")).unwrap();
862 let mut frames = Vec::new();
863 for item in readcon_core::iterators::ConFrameIterator::new(&text) {
864 frames.push(item.unwrap());
865 }
866 assert!(frames.len() >= 2);
867 assert!(!frames[1].atom_data.is_empty());
868 frames[1].atom_data[0].symbol = std::sync::Arc::from("Au");
869 db.append_trajectory_frames(1, &frames, "t").unwrap();
870 let err = db.collect_h5md(1).unwrap_err();
871 assert!(err.to_string().contains("fixed species Z"), "{err}");
872 }
873
874 #[test]
875 fn collect_h5md_after_cook_set_units_keeps_dest_vel() {
876 let dir = tempfile::tempdir().unwrap();
877 let db = ConCorpus::open(dir.path()).unwrap();
878 db.append_trajectory_path(1, fixture("tiny_cuh2.convel"))
879 .unwrap();
880 db.cook_frame(crate::keys::FrameKey {
881 traj_id: 1,
882 frame_idx: 0,
883 })
884 .unwrap();
885 let before = db.collect_h5md(1).unwrap();
886 db.set_trajectory_units(1, serde_json::json!({"length": "nm", "energy": "eV"}))
887 .unwrap();
888 assert!(!db
889 .has_valid_cooked_soa(crate::keys::FrameKey {
890 traj_id: 1,
891 frame_idx: 0,
892 })
893 .unwrap());
894 let native_xyz = db
895 .get_positions(crate::keys::FrameKey {
896 traj_id: 1,
897 frame_idx: 0,
898 })
899 .unwrap();
900 assert!(
901 (native_xyz[0][0] - 0.06394).abs() < 1e-5,
902 "native nm x0={}",
903 native_xyz[0][0]
904 );
905 let after = db.collect_h5md(1).unwrap();
906 let bv = before.velocities.expect("vel");
907 let av = after.velocities.expect("vel");
908 assert_eq!(bv.len(), av.len());
909 let native = db
910 .get_velocities(crate::keys::FrameKey {
911 traj_id: 1,
912 frame_idx: 0,
913 })
914 .unwrap()
915 .expect("native vel");
916 let u = db
917 .frame_units(crate::keys::FrameKey {
918 traj_id: 1,
919 frame_idx: 0,
920 })
921 .unwrap();
922 for (i, (b, a)) in bv.iter().zip(av.iter()).enumerate() {
923 assert!(
924 (b - a).abs() < 1e-9,
925 "dest vel[{i}]: before={b} after={a} native0={} units={u:?}",
926 native[0][0]
927 );
928 }
929 db.set_trajectory_units(
930 1,
931 serde_json::json!({"length": "nm", "energy": "eV", "time": "ps"}),
932 )
933 .unwrap();
934 let after_t = db.collect_h5md(1).unwrap();
935 let avt = after_t.velocities.expect("vel after time relabel");
936 for (i, (b, a)) in bv.iter().zip(avt.iter()).enumerate() {
937 assert!(
938 (b - a).abs() < 1e-9,
939 "dest vel after set_units time[{i}]: {b} vs {a}"
940 );
941 }
942 assert_eq!(before.times.len(), after_t.times.len());
943 for (bt, at) in before.times.iter().zip(&after_t.times) {
944 assert!((bt - at).abs() < 1e-12, "dest time {bt} vs {at}");
945 }
946 }
947
948 #[test]
949 fn collect_h5md_after_cook_and_set_units() {
950 let dir = tempfile::tempdir().unwrap();
951 let db = ConCorpus::open(dir.path()).unwrap();
952 db.append_trajectory_path(1, fixture("tiny_cuh2_forces.con"))
953 .unwrap();
954 db.cook_frame(crate::keys::FrameKey {
955 traj_id: 1,
956 frame_idx: 0,
957 })
958 .unwrap();
959 assert!(db
960 .has_valid_cooked_soa(crate::keys::FrameKey {
961 traj_id: 1,
962 frame_idx: 0,
963 })
964 .unwrap());
965 let before = db.collect_h5md(1).unwrap();
966 db.set_trajectory_units(1, serde_json::json!({"length": "nm", "energy": "eV"}))
967 .unwrap();
968 let after = db.collect_h5md(1).unwrap();
969 assert!((before.edges[0] - after.edges[0]).abs() < 1e-9);
970 assert_eq!(before.positions.len(), after.positions.len());
971 for (i, (b, a)) in before
972 .positions
973 .iter()
974 .zip(after.positions.iter())
975 .enumerate()
976 {
977 assert!(
978 (b - a).abs() < 1e-9,
979 "dest Å positions after cook+set_units[{i}]: {b} vs {a}"
980 );
981 }
982 let bf = before.forces.expect("dest forces");
983 let af = after.forces.expect("dest forces after set_units");
984 assert_eq!(bf.len(), af.len());
985 let factor = force_scale_to_engine("eV", "angstrom").unwrap();
986 assert!(
987 (bf[0] - 0.123456 * factor).abs() < 1e-6,
988 "dest force0={}",
989 bf[0]
990 );
991 for (i, (b, a)) in bf.iter().zip(af.iter()).enumerate() {
992 assert!(
993 (b - a).abs() < 1e-8,
994 "dest force after cook+set_units[{i}]: {b} vs {a}"
995 );
996 }
997 db.set_trajectory_units(1, serde_json::json!({"length": "nm", "energy": "hartree"}))
998 .unwrap();
999 let after_e = db.collect_h5md(1).unwrap();
1000 let after_energy = after_e.forces.expect("dest forces after set_units energy");
1001 assert_eq!(bf.len(), after_energy.len());
1002 for (i, (b, a)) in bf.iter().zip(after_energy.iter()).enumerate() {
1003 assert!(
1004 (b - a).abs() < 1e-8,
1005 "dest force after set_units energy[{i}]: {b} vs {a}"
1006 );
1007 }
1008 assert_eq!(
1009 db.frame_units(crate::keys::FrameKey {
1010 traj_id: 1,
1011 frame_idx: 0,
1012 })
1013 .unwrap()
1014 .unwrap()["length"],
1015 "nm"
1016 );
1017 assert_eq!(
1018 db.frame_units(crate::keys::FrameKey {
1019 traj_id: 1,
1020 frame_idx: 0,
1021 })
1022 .unwrap()
1023 .unwrap()["energy"],
1024 "hartree"
1025 );
1026 }
1027
1028 #[test]
1029 fn collect_h5md_species_z_cu_h() {
1030 let dir = tempfile::tempdir().unwrap();
1031 let db = ConCorpus::open(dir.path()).unwrap();
1032 db.append_trajectory_path(1, fixture("tiny_cuh2.con"))
1033 .unwrap();
1034 let a = db.collect_h5md(1).unwrap();
1035 assert!(a.species_z.iter().any(|&z| z == 29), "{:?}", a.species_z);
1036 assert!(a.species_z.iter().any(|&z| z == 1), "{:?}", a.species_z);
1037 }
1038
1039 #[test]
1040 fn collect_h5md_lattice_vectors_win() {
1041 let dir = tempfile::tempdir().unwrap();
1042 let db = ConCorpus::open(dir.path()).unwrap();
1043 let text = std::fs::read_to_string(fixture("tiny_cuh2.con")).unwrap();
1044 let mut frames = Vec::new();
1045 for item in readcon_core::iterators::ConFrameIterator::new(&text) {
1046 frames.push(item.unwrap());
1047 }
1048 frames[0].header.metadata.insert(
1049 "lattice_vectors".into(),
1050 serde_json::json!([[2.0, 0.0, 0.0], [0.5, 2.0, 0.0], [0.0, 0.0, 3.0]]),
1051 );
1052 db.append_trajectory_frames(1, &frames, "t").unwrap();
1053 let a = db.collect_h5md(1).unwrap();
1054 assert!((a.edges[0] - 2.0).abs() < 1e-12);
1055 assert!((a.edges[3] - 0.5).abs() < 1e-12);
1056 assert!((a.edges[8] - 3.0).abs() < 1e-12);
1057 }
1058
1059 #[test]
1060 fn collect_h5md_writes_velocities() {
1061 let dir = tempfile::tempdir().unwrap();
1062 let db = ConCorpus::open(dir.path()).unwrap();
1063 db.append_trajectory_path(1, fixture("tiny_cuh2.convel"))
1064 .unwrap();
1065 let a = db.collect_h5md(1).unwrap();
1066 let v = a.velocities.expect("velocities");
1067 assert_eq!(v.len(), a.n_frames * a.natoms * 3);
1068 assert!(v.iter().any(|&x| x != 0.0));
1069 assert_eq!(a.velocity_unit, "Angstrom ps-1");
1070 let scale = natural_velocity_scale();
1071 assert!(
1072 (v[0] - 0.001234 * scale).abs() < 1e-9,
1073 "v2 convel is the natural unit (D10), got {}",
1074 v[0]
1075 );
1076 }
1077
1078 #[test]
1079 fn collect_h5md_pads_mixed_velocities() {
1080 let dir = tempfile::tempdir().unwrap();
1081 let db = ConCorpus::open(dir.path()).unwrap();
1082 db.append_trajectory_path(1, fixture("tiny_cuh2.con"))
1083 .unwrap();
1084 db.extend_trajectory_path(1, fixture("tiny_cuh2.convel"))
1085 .unwrap();
1086 let a = db.collect_h5md(1).unwrap();
1087 let v = a.velocities.expect("velocities");
1088 assert!(a.n_frames >= 2);
1089 assert!(v[..a.natoms * 3].iter().all(|&x| x == 0.0));
1090 assert!(v[a.natoms * 3..].iter().any(|&x| x != 0.0));
1091 }
1092
1093 #[test]
1094 fn extend_trajectory_path_units_stamps() {
1095 let dir = tempfile::tempdir().unwrap();
1096 let db = ConCorpus::open(dir.path()).unwrap();
1097 db.append_trajectory_path(1, fixture("tiny_cuh2.con"))
1098 .unwrap();
1099 db.extend_trajectory_path_units(
1100 1,
1101 fixture("tiny_cuh2_forces.con"),
1102 Some(serde_json::json!({"length":"A","energy":"ev"})),
1103 )
1104 .unwrap();
1105 let u = db
1106 .frame_units(crate::keys::FrameKey {
1107 traj_id: 1,
1108 frame_idx: 1,
1109 })
1110 .unwrap()
1111 .unwrap();
1112 assert_eq!(u["length"], "angstrom");
1113 assert_eq!(u["energy"], "eV");
1114 }
1115
1116 fn natural_velocity_scale() -> f64 {
1117 let t_ps = time_scale_to_ps(crate::units_canon::NATURAL_TIME_UNIT).unwrap();
1118 1.0 / t_ps
1119 }
1120
1121 fn with_line2(src: &str, line2: &str) -> String {
1122 let mut lines: Vec<String> = src.lines().map(str::to_string).collect();
1123 lines[1] = line2.to_string();
1124 let mut out = lines.join("\n");
1125 out.push('\n');
1126 out
1127 }
1128
1129 #[test]
1130 fn collect_h5md_v30_ignores_fs_label() {
1131 let dir = tempfile::tempdir().unwrap();
1132 let db = ConCorpus::open(dir.path()).unwrap();
1133 let src = std::fs::read_to_string(fixture("tiny_cuh2.convel")).unwrap();
1134 let text = with_line2(
1135 &src,
1136 r#"{"con_spec_version":3,"sections":["velocities"],"units":{"length":"angstrom","energy":"eV","mass":"amu","time":"fs"}}"#,
1137 );
1138 db.append_trajectory_str(1, &text, "v30").unwrap();
1139 let a = db.collect_h5md(1).unwrap();
1140 let v = a.velocities.expect("vel");
1141 let scale = natural_velocity_scale();
1142 assert!(
1143 (v[0] - 0.001234 * scale).abs() < 1e-9,
1144 "v3.0 fs label is not the velocity unit, got {}",
1145 v[0]
1146 );
1147 }
1148
1149 #[test]
1150 fn collect_h5md_v31_uses_units_time_fs() {
1151 let dir = tempfile::tempdir().unwrap();
1152 let db = ConCorpus::open(dir.path()).unwrap();
1153 let src = std::fs::read_to_string(fixture("tiny_cuh2.convel")).unwrap();
1154 let text = with_line2(
1155 &src,
1156 r#"{"con_spec_version":3,"con_spec_minor":1,"sections":["velocities"],"units":{"length":"angstrom","energy":"eV","mass":"amu","time":"fs"}}"#,
1157 );
1158 db.append_trajectory_str(1, &text, "v31").unwrap();
1159 let a = db.collect_h5md(1).unwrap();
1160 let v = a.velocities.expect("vel");
1161 assert!(
1162 (v[0] - 1.234).abs() < 1e-9,
1163 "v3.1 units.time fs -> A/ps, got {}",
1164 v[0]
1165 );
1166 }
1167
1168 #[test]
1169 fn collect_h5md_v31_velocities_without_time_fail() {
1170 let dir = tempfile::tempdir().unwrap();
1171 let db = ConCorpus::open(dir.path()).unwrap();
1172 let src = std::fs::read_to_string(fixture("tiny_cuh2.convel")).unwrap();
1173 let text = with_line2(
1174 &src,
1175 r#"{"con_spec_version":3,"con_spec_minor":1,"sections":["velocities"],"units":{"length":"angstrom","energy":"eV","mass":"amu"}}"#,
1176 );
1177 let err = db
1179 .append_trajectory_str(1, &text, "v31-notime")
1180 .unwrap_err();
1181 assert!(err.to_string().contains("requires units.time"), "{err}");
1182 }
1183
1184 #[test]
1185 fn collect_h5md_v30_clock_without_time_is_fs() {
1186 let dir = tempfile::tempdir().unwrap();
1187 let db = ConCorpus::open(dir.path()).unwrap();
1188 let src = std::fs::read_to_string(fixture("tiny_cuh2.con")).unwrap();
1189 let text = with_line2(
1190 &src,
1191 r#"{"con_spec_version":3,"time":12.5,"units":{"length":"angstrom","energy":"eV"}}"#,
1192 );
1193 db.append_trajectory_str(1, &text, "v30-clock").unwrap();
1194 let a = db.collect_h5md(1).unwrap();
1195 assert!(
1196 (a.times[0] - 0.0125).abs() < 1e-12,
1197 "v3.0 clock with no units.time is fs, got {}",
1198 a.times[0]
1199 );
1200 }
1201
1202 #[test]
1203 fn collect_h5md_v31_clock_without_time_fails() {
1204 let dir = tempfile::tempdir().unwrap();
1205 let db = ConCorpus::open(dir.path()).unwrap();
1206 let src = std::fs::read_to_string(fixture("tiny_cuh2.con")).unwrap();
1207 let text = with_line2(
1208 &src,
1209 r#"{"con_spec_version":3,"con_spec_minor":1,"time":12.5,"units":{"length":"angstrom","energy":"eV"}}"#,
1210 );
1211 db.append_trajectory_str(1, &text, "v31-clock").unwrap();
1212 let err = db.collect_h5md(1).unwrap_err();
1213 let msg = err.to_string();
1214 assert!(msg.contains("no units.time"), "{err}");
1215 assert!(msg.contains("clock"), "{err}");
1216 }
1217
1218 #[test]
1219 fn collect_h5md_v31_timestep_without_time_fails() {
1220 let dir = tempfile::tempdir().unwrap();
1221 let db = ConCorpus::open(dir.path()).unwrap();
1222 let src = std::fs::read_to_string(fixture("tiny_cuh2.con")).unwrap();
1223 let text = with_line2(
1224 &src,
1225 r#"{"con_spec_version":3,"con_spec_minor":1,"timestep":10.0,"units":{"length":"angstrom","energy":"eV"}}"#,
1226 );
1227 db.append_trajectory_str(1, &text, "v31-dt").unwrap();
1228 let err = db.collect_h5md(1).unwrap_err();
1229 let msg = err.to_string();
1230 assert!(msg.contains("no units.time"), "{err}");
1231 assert!(msg.contains("clock"), "{err}");
1232 }
1233
1234 #[test]
1235 fn collect_h5md_v31_clock_override_is_fs() {
1236 let dir = tempfile::tempdir().unwrap();
1237 let db = ConCorpus::open(dir.path()).unwrap();
1238 let src = std::fs::read_to_string(fixture("tiny_cuh2.con")).unwrap();
1239 let text = with_line2(
1240 &src,
1241 r#"{"con_spec_version":3,"con_spec_minor":1,"time":12.5,"units":{"length":"angstrom","energy":"eV"}}"#,
1242 );
1243 db.append_trajectory_str(1, &text, "v31-clock-fs").unwrap();
1244 let opts = H5mdCollectOpts {
1245 velocity_time_unit: Some("fs".into()),
1246 };
1247 let a = db.collect_h5md_with(1, &opts).unwrap();
1248 assert!(
1249 (a.times[0] - 0.0125).abs() < 1e-12,
1250 "override fs on a v3.1 clock, got {}",
1251 a.times[0]
1252 );
1253 }
1254
1255 #[test]
1256 fn collect_h5md_v31_present_time_ignores_clock_override() {
1257 let dir = tempfile::tempdir().unwrap();
1258 let db = ConCorpus::open(dir.path()).unwrap();
1259 let src = std::fs::read_to_string(fixture("tiny_cuh2.con")).unwrap();
1260 let text = with_line2(
1261 &src,
1262 r#"{"con_spec_version":3,"con_spec_minor":1,"time":2.0,"units":{"length":"angstrom","energy":"eV","time":"ns"}}"#,
1263 );
1264 db.append_trajectory_str(1, &text, "v31-ns").unwrap();
1265 let opts = H5mdCollectOpts {
1266 velocity_time_unit: Some("fs".into()),
1267 };
1268 let a = db.collect_h5md_with(1, &opts).unwrap();
1269 assert!(
1270 (a.times[0] - 2000.0).abs() < 1e-9,
1271 "present units.time ns still scales the clock, got {}",
1272 a.times[0]
1273 );
1274 }
1275
1276 #[test]
1277 fn collect_h5md_v31_without_clock_is_frame_index() {
1278 let dir = tempfile::tempdir().unwrap();
1279 let db = ConCorpus::open(dir.path()).unwrap();
1280 let src = std::fs::read_to_string(fixture("tiny_cuh2.con")).unwrap();
1281 let text = with_line2(
1282 &src,
1283 r#"{"con_spec_version":3,"con_spec_minor":1,"units":{"length":"angstrom","energy":"eV"}}"#,
1284 );
1285 db.append_trajectory_str(1, &text, "v31-noclock").unwrap();
1286 let a = db.collect_h5md(1).unwrap();
1287 assert!((a.times[0] - 0.0).abs() < 1e-12, "got {}", a.times[0]);
1288 }
1289
1290 #[test]
1291 fn collect_h5md_velocity_time_override_fs_on_v2() {
1292 let dir = tempfile::tempdir().unwrap();
1293 let db = ConCorpus::open(dir.path()).unwrap();
1294 db.append_trajectory_path(1, fixture("tiny_cuh2.convel"))
1295 .unwrap();
1296 let opts = H5mdCollectOpts {
1297 velocity_time_unit: Some("fs".into()),
1298 };
1299 let a = db.collect_h5md_with(1, &opts).unwrap();
1300 let v = a.velocities.expect("vel");
1301 assert!(
1302 (v[0] - 1.234).abs() < 1e-9,
1303 "override fs on a v2 file, got {}",
1304 v[0]
1305 );
1306 }
1307
1308 #[test]
1312 fn eon36_convel_h5md_ke_matches_printed() {
1313 let printed = 1.5004770201120763e-2;
1314 let dir = tempfile::tempdir().unwrap();
1315 let db = ConCorpus::open(dir.path()).unwrap();
1316 db.append_trajectory_path(1, fixture("eon36_lj_dimer.convel"))
1317 .unwrap();
1318 let fr = db
1319 .get_frame(crate::keys::FrameKey {
1320 traj_id: 1,
1321 frame_idx: 0,
1322 })
1323 .unwrap();
1324 assert_eq!(fr.header.spec_version, 3);
1325 assert!(crate::units_canon::con_spec_minor(&fr.header).is_none());
1326 assert_eq!(fr.header.unit_for("time"), Some("fs"));
1327 let mut masses = Vec::new();
1328 for (n, m) in fr
1329 .header
1330 .natms_per_type
1331 .iter()
1332 .zip(&fr.header.masses_per_type)
1333 {
1334 for _ in 0..*n {
1335 masses.push(*m);
1336 }
1337 }
1338 let native: Vec<[f64; 3]> = fr
1339 .atom_data
1340 .iter()
1341 .map(|a| a.velocity().expect("vel"))
1342 .collect();
1343 assert_eq!(masses.len(), native.len());
1344 let half_ulp = 5e-7;
1345 let mut bound = 0.0;
1346 for (m, v) in masses.iter().zip(&native) {
1347 for c in v {
1348 bound += m * c.abs() * half_ulp + 0.5 * m * half_ulp * half_ulp;
1349 }
1350 }
1351 let a = db.collect_h5md(1).unwrap();
1352 let dest = a.velocities.expect("h5md vel");
1353 let t_ps = time_scale_to_ps(crate::units_canon::NATURAL_TIME_UNIT).unwrap();
1354 let mut ke = 0.0;
1355 for (i, m) in masses.iter().enumerate() {
1356 let vx = dest[i * 3] * t_ps;
1357 let vy = dest[i * 3 + 1] * t_ps;
1358 let vz = dest[i * 3 + 2] * t_ps;
1359 ke += 0.5 * m * (vx * vx + vy * vy + vz * vz);
1360 }
1361 assert!(
1362 (ke - printed).abs() <= bound,
1363 "H5MD KE {ke} vs eOn {printed}, 6-decimal bound {bound}"
1364 );
1365 assert_eq!(a.velocity_unit, "Angstrom ps-1");
1366 }
1367}