1use std::path::{Path, PathBuf};
26use std::sync::atomic::{AtomicU64, Ordering};
27use std::sync::mpsc::{self, Sender};
28use std::sync::Arc;
29use std::thread::JoinHandle;
30
31use readcon_core::helpers::atomic_number_to_symbol;
32use readcon_core::types::ConFrameBuilder;
33use readcon_core::writer::FloatFormat;
34
35use crate::corpus::ConCorpus;
36use crate::error::{Error, Result};
37use crate::keys::FrameKey;
38
39struct Row {
40 positions: Vec<f64>,
41 forces: Vec<f64>,
42 energy: f64,
43}
44
45enum Msg {
46 Row(Row),
47 Flush(Sender<()>),
48}
49
50pub struct ObservationArchive {
52 corpus: Arc<ConCorpus>,
53 tx: Option<Sender<Msg>>,
54 worker: Option<JoinHandle<()>>,
55 committed: Arc<AtomicU64>,
56 dropped: Arc<AtomicU64>,
57 appended: AtomicU64,
61 natoms: usize,
62 dir: PathBuf,
63}
64
65impl ObservationArchive {
66 pub fn open(dir: impl AsRef<Path>, z: Vec<u32>, cell: [f64; 3]) -> Result<Self> {
71 let dir = dir.as_ref().to_path_buf();
72 std::fs::create_dir_all(&dir)?;
73 let corpus = Arc::new(ConCorpus::open(dir.join("observations.rdb"))?);
74 let existing = corpus.list_frame_keys()?.len() as u64;
75 let committed = Arc::new(AtomicU64::new(existing));
76 let dropped = Arc::new(AtomicU64::new(0));
77 let (tx, rx) = mpsc::channel::<Msg>();
78 let natoms = z.len();
79
80 let worker_corpus = Arc::clone(&corpus);
81 let worker_committed = Arc::clone(&committed);
82 let worker_dropped = Arc::clone(&dropped);
83 let worker = std::thread::Builder::new()
84 .name("rkrdb-archive".into())
85 .spawn(move || {
86 while let Ok(msg) = rx.recv() {
87 match msg {
88 Msg::Row(row) => {
89 let traj = worker_committed.load(Ordering::Relaxed);
90 match commit_row(&worker_corpus, traj, &z, cell, &row) {
91 Ok(()) => {
92 worker_committed.store(traj + 1, Ordering::Relaxed);
93 }
94 Err(_) => {
95 worker_dropped.fetch_add(1, Ordering::Relaxed);
96 }
97 }
98 }
99 Msg::Flush(ack) => {
100 let _ = ack.send(());
101 }
102 }
103 }
104 })
105 .map_err(|e| Error::Message(format!("archive worker spawn failed: {e}")))?;
106
107 Ok(Self {
108 corpus,
109 tx: Some(tx),
110 worker: Some(worker),
111 committed,
112 dropped,
113 appended: AtomicU64::new(existing),
114 natoms,
115 dir,
116 })
117 }
118
119 pub fn append(&self, positions: &[f64], forces: &[f64], energy: f64) -> bool {
124 if positions.len() != 3 * self.natoms || forces.len() != 3 * self.natoms {
125 return false;
126 }
127 let Some(tx) = self.tx.as_ref() else {
128 return false;
129 };
130 let sent = tx
131 .send(Msg::Row(Row {
132 positions: positions.to_vec(),
133 forces: forces.to_vec(),
134 energy,
135 }))
136 .is_ok();
137 if sent {
138 self.appended.fetch_add(1, Ordering::Relaxed);
139 }
140 sent
141 }
142
143 pub fn flush(&self) {
146 let Some(tx) = self.tx.as_ref() else { return };
147 let (ack_tx, ack_rx) = mpsc::channel();
148 if tx.send(Msg::Flush(ack_tx)).is_ok() {
149 let _ = ack_rx.recv();
150 }
151 }
152
153 pub fn committed(&self) -> u64 {
155 self.committed.load(Ordering::Relaxed)
156 }
157
158 pub fn appended(&self) -> u64 {
162 self.appended.load(Ordering::Relaxed)
163 }
164
165 pub fn dropped(&self) -> u64 {
167 self.dropped.load(Ordering::Relaxed)
168 }
169
170 pub fn natoms(&self) -> usize {
172 self.natoms
173 }
174
175 pub fn dir(&self) -> &Path {
177 &self.dir
178 }
179
180 pub fn fetch(&self, index: u64) -> Result<(Vec<f64>, Vec<f64>, f64)> {
184 let frame = self.corpus.get_frame(FrameKey {
185 traj_id: index,
186 frame_idx: 0,
187 })?;
188 let n = self.natoms;
189 if frame.atom_ids.len() != n {
190 return Err(Error::Message(format!(
191 "archive row {index}: expected {n} atoms, found {}",
192 frame.atom_ids.len()
193 )));
194 }
195 if frame.positions.nrows() != n || frame.forces.nrows() != n {
196 return Err(Error::Parse(format!(
197 "archive row {index}: expected {n} position and force rows, \
198 found {} and {}",
199 frame.positions.nrows(),
200 frame.forces.nrows()
201 )));
202 }
203 let mut positions = vec![0.0; 3 * n];
204 let mut forces = vec![0.0; 3 * n];
205 for (stored, &atom_id) in frame.atom_ids.iter().enumerate() {
206 let orig = atom_id as usize;
207 if orig >= n {
208 return Err(Error::Parse(format!(
209 "archive row {index}: atom id {orig} out of range"
210 )));
211 }
212 positions[3 * orig..3 * orig + 3].copy_from_slice(&frame.positions.as_f64_row(stored));
213 forces[3 * orig..3 * orig + 3].copy_from_slice(&frame.forces.as_f64_row(stored));
214 }
215 let energy = frame
216 .header
217 .energy()
218 .ok_or_else(|| Error::Message(format!("archive row {index} has no energy")))?;
219 Ok((positions, forces, energy))
220 }
221}
222
223impl Drop for ObservationArchive {
224 fn drop(&mut self) {
225 self.tx.take();
227 if let Some(worker) = self.worker.take() {
228 let _ = worker.join();
229 }
230 }
231}
232
233fn commit_row(corpus: &ConCorpus, traj: u64, z: &[u32], cell: [f64; 3], row: &Row) -> Result<()> {
234 let mut builder = ConFrameBuilder::new(cell, [90.0, 90.0, 90.0]);
235 builder.prebox_header("observation archive");
236 builder.set_energy(row.energy);
237 for (i, &zi) in z.iter().enumerate() {
238 builder.add_atom(
239 atomic_number_to_symbol(u64::from(zi)),
240 row.positions[3 * i],
241 row.positions[3 * i + 1],
242 row.positions[3 * i + 2],
243 [false; 3],
244 i as u64,
245 f64::from(zi),
246 );
247 }
248 builder
249 .set_forces_from_flat(&row.forces)
250 .map_err(|e| Error::Message(format!("archive forces: {e}")))?;
251 let frame = builder
252 .build()
253 .map_err(|e| Error::Message(format!("archive build: {e}")))?;
254 corpus.append_trajectory_frames_with_float_format(
255 traj,
256 &[frame],
257 "observation-archive",
258 FloatFormat::RoundTrip,
259 )?;
260 Ok(())
261}
262
263use std::ffi::CStr;
268use std::os::raw::{c_char, c_int};
269use std::sync::Mutex;
270
271use crate::ffi::{RKRDB_ERR, RKRDB_NOT_FOUND, RKRDB_NULL, RKRDB_OK};
272
273static ARCHIVES: Mutex<Vec<Option<Box<ObservationArchive>>>> = Mutex::new(Vec::new());
274
275fn push_archive(a: ObservationArchive) -> usize {
276 let mut g = ARCHIVES.lock().unwrap();
277 for (i, slot) in g.iter_mut().enumerate() {
278 if slot.is_none() {
279 *slot = Some(Box::new(a));
280 return i;
281 }
282 }
283 g.push(Some(Box::new(a)));
284 g.len() - 1
285}
286
287fn with_archive<F, T>(id: usize, f: F) -> std::result::Result<T, c_int>
288where
289 F: FnOnce(&ObservationArchive) -> std::result::Result<T, c_int>,
290{
291 let g = ARCHIVES.lock().unwrap();
292 let slot = g.get(id).ok_or(RKRDB_NULL)?;
293 let a = slot.as_ref().ok_or(RKRDB_NULL)?;
294 f(a)
295}
296
297#[no_mangle]
302pub unsafe extern "C" fn rkrdb_archive_open(
303 dir: *const c_char,
304 z: *const u32,
305 natoms: u32,
306 cell3: *const f64,
307 out_id: *mut usize,
308) -> c_int {
309 if dir.is_null() || z.is_null() || cell3.is_null() || out_id.is_null() || natoms == 0 {
310 return RKRDB_NULL;
311 }
312 let cdir = unsafe { CStr::from_ptr(dir) };
313 let Ok(dir) = cdir.to_str() else {
314 return RKRDB_ERR;
315 };
316 let z = unsafe { std::slice::from_raw_parts(z, natoms as usize) }.to_vec();
317 let cell = unsafe { [*cell3, *cell3.add(1), *cell3.add(2)] };
318 match ObservationArchive::open(dir, z, cell) {
319 Ok(archive) => {
320 unsafe { *out_id = push_archive(archive) };
321 RKRDB_OK
322 }
323 Err(_) => RKRDB_ERR,
324 }
325}
326
327#[no_mangle]
331pub unsafe extern "C" fn rkrdb_archive_append(
332 id: usize,
333 positions: *const f64,
334 forces: *const f64,
335 energy: f64,
336) -> c_int {
337 if positions.is_null() || forces.is_null() {
338 return RKRDB_NULL;
339 }
340 with_archive(id, |a| {
341 let n3 = 3 * a.natoms();
342 let pos = unsafe { std::slice::from_raw_parts(positions, n3) };
343 let frc = unsafe { std::slice::from_raw_parts(forces, n3) };
344 Ok(if a.append(pos, frc, energy) {
345 RKRDB_OK
346 } else {
347 RKRDB_ERR
348 })
349 })
350 .unwrap_or(RKRDB_NULL)
351}
352
353#[no_mangle]
355pub unsafe extern "C" fn rkrdb_archive_flush(id: usize) -> c_int {
356 with_archive(id, |a| {
357 a.flush();
358 Ok(RKRDB_OK)
359 })
360 .unwrap_or(RKRDB_NULL)
361}
362
363#[no_mangle]
365pub unsafe extern "C" fn rkrdb_archive_count(id: usize, out_count: *mut u64) -> c_int {
366 if out_count.is_null() {
367 return RKRDB_NULL;
368 }
369 with_archive(id, |a| {
370 unsafe { *out_count = a.committed() };
371 Ok(RKRDB_OK)
372 })
373 .unwrap_or(RKRDB_NULL)
374}
375
376#[no_mangle]
380pub unsafe extern "C" fn rkrdb_archive_appended(id: usize, out_count: *mut u64) -> c_int {
381 if out_count.is_null() {
382 return RKRDB_NULL;
383 }
384 with_archive(id, |a| {
385 unsafe { *out_count = a.appended() };
386 Ok(RKRDB_OK)
387 })
388 .unwrap_or(RKRDB_NULL)
389}
390
391#[no_mangle]
393pub unsafe extern "C" fn rkrdb_archive_dropped(id: usize, out_count: *mut u64) -> c_int {
394 if out_count.is_null() {
395 return RKRDB_NULL;
396 }
397 with_archive(id, |a| {
398 unsafe { *out_count = a.dropped() };
399 Ok(RKRDB_OK)
400 })
401 .unwrap_or(RKRDB_NULL)
402}
403
404#[no_mangle]
408pub unsafe extern "C" fn rkrdb_archive_fetch(
409 id: usize,
410 index: u64,
411 positions: *mut f64,
412 forces: *mut f64,
413 capacity_atoms: u32,
414 out_energy: *mut f64,
415) -> c_int {
416 if positions.is_null() || forces.is_null() || out_energy.is_null() {
417 return RKRDB_NULL;
418 }
419 with_archive(id, |a| {
420 if (capacity_atoms as usize) < a.natoms() {
421 return Ok(RKRDB_ERR);
422 }
423 match a.fetch(index) {
424 Ok((pos, frc, energy)) => {
425 unsafe {
426 std::ptr::copy_nonoverlapping(pos.as_ptr(), positions, pos.len());
427 std::ptr::copy_nonoverlapping(frc.as_ptr(), forces, frc.len());
428 *out_energy = energy;
429 }
430 Ok(RKRDB_OK)
431 }
432 Err(Error::MissingFrame(_)) => Ok(RKRDB_NOT_FOUND),
433 Err(_) => Ok(RKRDB_ERR),
434 }
435 })
436 .unwrap_or(RKRDB_NULL)
437}
438
439#[no_mangle]
441pub unsafe extern "C" fn rkrdb_archive_close(id: usize) -> c_int {
442 let mut g = ARCHIVES.lock().unwrap();
443 match g.get_mut(id) {
444 Some(slot) => {
445 *slot = None;
446 RKRDB_OK
447 }
448 None => RKRDB_NULL,
449 }
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455
456 #[test]
457 fn roundtrip_preserves_caller_order_and_energy() {
458 let dir = tempfile::tempdir().unwrap();
459 let z = vec![1_u32, 8, 1];
462 let cell = [25.0, 25.0, 25.0];
463 let archive = ObservationArchive::open(dir.path(), z.clone(), cell).unwrap();
464
465 let pos: Vec<f64> = (0..9).map(|i| i as f64 * 0.1).collect();
466 let frc: Vec<f64> = (0..9).map(|i| -(i as f64) * 0.01).collect();
467 assert!(archive.append(&pos, &frc, -76.4));
468 assert!(archive.append(&frc, &pos, -75.9));
469 assert_eq!(archive.appended(), 2);
471 archive.flush();
472 assert_eq!(archive.appended(), 2);
473 assert_eq!(archive.committed(), 2);
474 assert_eq!(archive.dropped(), 0);
475
476 let (p0, f0, e0) = archive.fetch(0).unwrap();
477 assert_eq!(p0, pos);
478 assert_eq!(f0, frc);
479 assert!((e0 - (-76.4)).abs() < 1e-12);
480 let (p1, _f1, e1) = archive.fetch(1).unwrap();
481 assert_eq!(p1, frc);
482 assert!((e1 - (-75.9)).abs() < 1e-12);
483 }
484
485 #[test]
486 fn roundtrip_preserves_small_observations_exactly() {
487 let dir = tempfile::tempdir().unwrap();
488 let z = vec![1_u32, 8, 1];
489 let cell = [25.0, 25.0, 25.0];
490 let positions = vec![
491 0.001_527_890_189_282_565_8,
492 -0.0,
493 1.0,
494 1.234_567_890_123_456_7e-20,
495 2.0,
496 3.0,
497 4.0,
498 5.0,
499 6.0,
500 ];
501 let forces = vec![
502 0.001_527_890_189_282_565_8,
503 -0.001_527_890_189_282_565_8,
504 -0.0,
505 f64::MIN_POSITIVE,
506 f64::from_bits(1),
507 -f64::from_bits(1),
508 1.234_567_890_123_456_7e30,
509 f64::MAX,
510 -f64::MAX,
511 ];
512 let energy = 0.001_527_890_189_282_565_8_f64;
513 {
514 let archive = ObservationArchive::open(dir.path(), z.clone(), cell).unwrap();
515 assert!(archive.append(&positions, &forces, energy));
516 archive.flush();
517 assert_eq!(archive.committed(), 1);
518 assert_eq!(archive.dropped(), 0);
519 }
520 let archive = ObservationArchive::open(dir.path(), z, cell).unwrap();
521 let (actual_positions, actual_forces, actual_energy) = archive.fetch(0).unwrap();
522 for (actual, expected) in actual_positions.iter().zip(&positions) {
523 assert_eq!(
524 actual.to_bits(),
525 expected.to_bits(),
526 "position {expected:e}"
527 );
528 }
529 for (actual, expected) in actual_forces.iter().zip(&forces) {
530 assert_eq!(actual.to_bits(), expected.to_bits(), "force {expected:e}");
531 }
532 assert_eq!(actual_energy.to_bits(), energy.to_bits());
533 }
534
535 #[test]
536 fn restart_continues_trajectory_ids() {
537 let dir = tempfile::tempdir().unwrap();
538 let z = vec![6_u32, 1];
539 let cell = [10.0, 10.0, 10.0];
540 let pos = vec![0.0; 6];
541 let frc = vec![0.0; 6];
542 {
543 let archive = ObservationArchive::open(dir.path(), z.clone(), cell).unwrap();
544 assert!(archive.append(&pos, &frc, 1.0));
545 archive.flush();
546 assert_eq!(archive.committed(), 1);
547 }
548 {
549 let archive = ObservationArchive::open(dir.path(), z, cell).unwrap();
550 assert_eq!(archive.committed(), 1);
551 assert!(archive.append(&pos, &frc, 2.0));
552 archive.flush();
553 assert_eq!(archive.committed(), 2);
554 let (_, _, e) = archive.fetch(1).unwrap();
555 assert!((e - 2.0).abs() < 1e-12);
556 }
557 }
558
559 #[test]
560 fn append_rejects_wrong_length() {
561 let dir = tempfile::tempdir().unwrap();
562 let archive = ObservationArchive::open(dir.path(), vec![1, 1], [5.0, 5.0, 5.0]).unwrap();
563 assert!(!archive.append(&[0.0; 3], &[0.0; 6], 0.0));
564 }
565}