Skip to main content

readcon_db/
export_h5md.rs

1//! Cooked H5MD arrays collected from CON/RCSO. The HDF5 file is written
2//! by the Python binding (h5py). This module owns layout so tests can
3//! check `[T][N][3]` without h5py.
4
5use crate::corpus::ConCorpus;
6use crate::error::Result;
7use crate::select::Select;
8
9/// Time-dependent cooked trajectory for one `traj_id`.
10#[derive(Clone, Debug)]
11pub struct H5mdArrays {
12    pub n_frames: usize,
13    pub natoms: usize,
14    /// `[T][N][3]` row-major f64.
15    pub positions: Vec<f64>,
16    /// `[T][3][3]` H5MD box/edges (lattice vectors, or boxl+angles).
17    pub edges: Vec<f64>,
18    /// Integer Z, length `N`.
19    pub species_z: Vec<i32>,
20    /// `[T][N][3]` if any frame has forces; frames without forces are zeros.
21    pub forces: Option<Vec<f64>>,
22    /// `[T][N][3]` if any frame has velocities; frames without are zeros.
23    pub velocities: Option<Vec<f64>>,
24    pub velocity_unit: String,
25    /// H5MD `box` boundary strings, from CON `pbc` (periodic when absent).
26    pub boundary: [String; 3],
27    /// `[T]` times in [`H5MD_TIME_ATTR`] (CON time or `i * timestep`, else frame index).
28    pub times: Vec<f64>,
29    pub time_unit: String,
30    pub length_unit: String,
31    pub force_unit: String,
32}
33
34/// MDA/H5MD engine units (one dest system, same split as metatomic model vs engine).
35pub 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";
39/// Historical clock unit for v1, v2, and v3.0 when `units.time` is absent.
40///
41/// Spec v3.1 does not default a missing clock unit. CON-header.3 defaults
42/// only angstrom and amu. CON-units.4 forbids a silent `time: fs`. A v3.1
43/// clock without `units.time` is an error.
44pub const CON_TIME_DEFAULT: &str = "fs";
45
46/// Options for [`ConCorpus::collect_h5md_with`].
47///
48/// `velocity_time_unit` overrides D10. Use it for a file that breaks the
49/// rule: natural-unit numbers under a v3.1 `fs` label, or true fs numbers
50/// on a v1, v2, or v3.0 frame. The same string is the clock unit when a
51/// v3.1 frame has `time` or `timestep` and no `units.time`. A present
52/// `units.time` still scales the clock.
53#[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";
59/// 1 kJ mol^{-1} Å^{-1} in N. CODATA 2018 N_A.
60const 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
88/// `value_h5 = factor * value_con` for force (energy/length → kJ mol^{-1} Å^{-1}).
89fn 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            // No velocity section. This string is not the clock unit.
212            _ => 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    /// Collect one trajectory as H5MD-shaped arrays (fixed `N`).
221    /// Times are dest `ps`: CON `header.time()`, or `i * timestep`, else
222    /// the frame index. A v3.1 clock with no `units.time` is an error
223    /// unless [`H5mdCollectOpts::velocity_time_unit`] is set. v1, v2, and
224    /// v3.0 clocks with no `units.time` use `fs`. Velocities use D10
225    /// (see [`H5mdCollectOpts`]).
226    pub fn collect_h5md(&self, traj_id: u64) -> Result<H5mdArrays> {
227        self.collect_h5md_with(traj_id, &H5mdCollectOpts::default())
228    }
229
230    /// [`collect_h5md`] with an explicit velocity time override.
231    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        // tiny_multi_cuh2.con frame 1 first H: dest Å x = 8.8549
640        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        // readcon-core 0.17 rejects this header at parse time, before export.
1178        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    /// eonclient 3.6.0 (conda-forge, c4efc62) `setThermalVelocity` on a
1309    /// two-atom ljcluster frame, then `matter2convel`. Printed KE is
1310    /// `getKineticEnergy` before the 6-decimal write.
1311    #[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}