1use std::{
2 path::{Path, PathBuf},
3 sync::{
4 Arc,
5 mpsc::{self, Receiver, Sender, SyncSender},
6 },
7 thread::{self, JoinHandle},
8};
9
10use laddu_physics::vectors::RealVec4;
11use oxyroot::{Branch, ReaderTree, RootFile, WriterTree};
12
13use crate::{
14 LadduDataError, LadduDataResult, Name,
15 data::EventBatch,
16 io::{
17 DataFragment, EventSink, EventSource, FragmentedSource, OutputMode, OutputPath, ReadPlan,
18 SourceCapabilities, WritePlan, fragmented_batches,
19 },
20 schema::{
21 ColumnInfo, ColumnType, Precision, Schema, SchemaColumnNames, SchemaInferenceOptions,
22 SchemaWriteOptions, WriteWeightColumn,
23 },
24};
25
26#[derive(Clone, Debug)]
28pub struct RootSource {
29 files: Arc<[Arc<PathBuf>]>,
30 tree_name: Name,
31 schema: Arc<Schema>,
32 options: RootReadOptions,
33}
34
35#[derive(Clone, Debug)]
37pub struct RootReadOptions {
38 pub infer_schema: bool,
40 pub validate_all_files: bool,
42 pub sort_glob: bool,
44 pub tree: RootTreeSelection,
46 pub schema_inference: SchemaInferenceOptions,
48}
49
50impl Default for RootReadOptions {
51 fn default() -> Self {
52 Self {
53 infer_schema: true,
54 validate_all_files: true,
55 sort_glob: true,
56 tree: RootTreeSelection::First,
57 schema_inference: SchemaInferenceOptions::default(),
58 }
59 }
60}
61
62#[derive(Clone, Debug, Default)]
64pub enum RootTreeSelection {
65 #[default]
67 First,
68 Named(Name),
70}
71
72#[derive(Clone, Debug)]
74pub struct RootFragmentKey {
75 pub file: Arc<PathBuf>,
77 pub tree_name: Name,
79}
80
81#[derive(Clone, Debug)]
83pub struct RootColumnInfo {
84 pub name: Name,
86 pub item_type_name: String,
88 pub interpretation: String,
90 pub entries: i64,
92}
93
94impl RootSource {
95 pub fn open(pattern: impl AsRef<str>) -> LadduDataResult<Self> {
102 Self::builder(pattern).build()
103 }
104
105 pub fn builder(pattern: impl AsRef<str>) -> RootSourceBuilder {
107 RootSourceBuilder {
108 pattern: pattern.as_ref().to_owned(),
109 schema: None,
110 options: RootReadOptions::default(),
111 }
112 }
113
114 pub fn files(&self) -> &[Arc<PathBuf>] {
116 &self.files
117 }
118
119 pub fn tree_name(&self) -> &str {
121 self.tree_name.as_ref()
122 }
123
124 pub fn tree_names(path: impl AsRef<Path>) -> LadduDataResult<Vec<Name>> {
130 let mut file = RootFile::open(path.as_ref()).map_err(root_source_error)?;
131 let key_names: Vec<String> = file.keys_name().map(str::to_owned).collect();
132
133 let mut out = Vec::new();
134
135 for name in key_names {
136 if file.get_tree(&name).is_ok() {
137 out.push(Name::from(name));
138 }
139 }
140
141 Ok(out)
142 }
143
144 pub fn columns(
151 path: impl AsRef<Path>,
152 tree: Option<&str>,
153 ) -> LadduDataResult<Vec<RootColumnInfo>> {
154 let mut file = RootFile::open(path.as_ref()).map_err(root_source_error)?;
155
156 let tree_name = match tree {
157 Some(name) => Name::from(name),
158 None => first_tree_name(&mut file)?,
159 };
160
161 let tree = file
162 .get_tree(tree_name.as_ref())
163 .map_err(root_source_error)?;
164
165 Ok(tree
166 .branches_r()
167 .into_iter()
168 .map(|branch| RootColumnInfo {
169 name: Name::from(branch.name()),
170 item_type_name: branch.item_type_name(),
171 interpretation: branch.interpretation(),
172 entries: branch.entries(),
173 })
174 .collect())
175 }
176}
177
178pub struct RootSourceBuilder {
180 pattern: String,
181 schema: Option<Arc<Schema>>,
182 options: RootReadOptions,
183}
184
185impl RootSourceBuilder {
186 pub fn schema(mut self, schema: Arc<Schema>) -> Self {
188 self.schema = Some(schema);
189 self.options.infer_schema = false;
190 self
191 }
192
193 pub fn infer_schema(mut self, value: bool) -> Self {
195 self.options.infer_schema = value;
196 self
197 }
198
199 pub fn tree(mut self, name: impl Into<Name>) -> Self {
201 self.options.tree = RootTreeSelection::Named(name.into());
202 self
203 }
204
205 pub fn first_tree(mut self) -> Self {
207 self.options.tree = RootTreeSelection::First;
208 self
209 }
210
211 pub fn require_weight(mut self, value: bool) -> Self {
213 self.options.schema_inference.require_weight = value;
214 self
215 }
216
217 pub fn validate_all_files(mut self, value: bool) -> Self {
219 self.options.validate_all_files = value;
220 self
221 }
222
223 pub fn sort_glob(mut self, value: bool) -> Self {
225 self.options.sort_glob = value;
226 self
227 }
228
229 pub fn schema_inference(mut self, options: SchemaInferenceOptions) -> Self {
231 self.options.schema_inference = options;
232 self
233 }
234
235 pub fn build(self) -> LadduDataResult<RootSource> {
242 let RootSourceBuilder {
243 pattern,
244 schema,
245 options,
246 } = self;
247
248 let mut files: Vec<PathBuf> = glob::glob(&pattern)
249 .map_err(|e| LadduDataError::Source(e.to_string()))?
250 .collect::<std::result::Result<_, _>>()
251 .map_err(|e| LadduDataError::Source(e.to_string()))?;
252
253 if options.sort_glob {
254 files.sort();
255 }
256
257 if files.is_empty() {
258 return Err(LadduDataError::Source("no ROOT files matched glob".into()));
259 }
260
261 let files: Arc<[Arc<PathBuf>]> = files.into_iter().map(Arc::new).collect();
262
263 let tree_name = {
264 let path: &Path = files[0].as_ref();
265 resolve_tree_name(path, &options.tree)?
266 };
267
268 let schema = match schema {
269 Some(schema) => schema,
270 None if options.infer_schema => {
271 let path: &Path = files[0].as_ref();
272 let columns = root_columns(path, tree_name.as_ref())?;
273
274 Arc::new(Schema::infer_from_columns(
275 columns.iter().map(OwnedColumnInfo::as_column_info),
276 &options.schema_inference,
277 )?)
278 }
279 None => return Err(LadduDataError::InvalidArgument("schema required")),
280 };
281
282 if options.validate_all_files {
283 for file in files.iter() {
284 let path: &Path = file.as_ref();
285 validate_root_file(path, tree_name.as_ref(), &schema, &options.schema_inference)?;
286 }
287 }
288
289 Ok(RootSource {
290 files,
291 tree_name,
292 schema,
293 options,
294 })
295 }
296}
297
298impl EventSource for RootSource {
299 fn schema(&self) -> LadduDataResult<Arc<Schema>> {
300 Ok(Arc::clone(&self.schema))
301 }
302
303 fn capabilities(&self) -> SourceCapabilities {
304 SourceCapabilities {
305 exact_len: true,
306 exact_weighted_total: false,
307 random_access: false,
308 deterministic_partitioning: true,
309 predicate_pushdown: false,
310 projection_pushdown: true,
311 streaming: true,
312 }
313 }
314
315 fn num_events(&self) -> LadduDataResult<Option<u64>> {
316 Ok(Some(self.fragments()?.iter().map(|f| f.rows).sum()))
317 }
318
319 fn batches(
320 &self,
321 plan: ReadPlan,
322 ) -> LadduDataResult<Box<dyn Iterator<Item = LadduDataResult<EventBatch>> + Send>> {
323 fragmented_batches(Arc::new(self.clone()), plan)
324 }
325}
326
327impl FragmentedSource for RootSource {
328 type Key = RootFragmentKey;
329
330 fn fragments(&self) -> LadduDataResult<Vec<DataFragment<Self::Key>>> {
331 let mut fragments = Vec::new();
332 let mut global_start = 0_u64;
333
334 for path in self.files.iter() {
335 let mut file = RootFile::open(path.as_ref()).map_err(root_source_error)?;
336 let tree = file
337 .get_tree(self.tree_name.as_ref())
338 .map_err(root_source_error)?;
339
340 let rows = usize_from_i64(tree.entries(), "negative TTree entry count")? as u64;
341
342 fragments.push(DataFragment {
343 key: RootFragmentKey {
344 file: Arc::clone(path),
345 tree_name: self.tree_name.clone(),
346 },
347 global_start,
348 rows,
349 });
350
351 global_start += rows;
352 }
353
354 Ok(fragments)
355 }
356
357 fn read_fragment_range(
358 &self,
359 key: &Self::Key,
360 local_start: usize,
361 local_len: usize,
362 chunk_size: Option<usize>,
363 ) -> LadduDataResult<Box<dyn Iterator<Item = LadduDataResult<EventBatch>> + Send>> {
364 if matches!(chunk_size, Some(0)) {
365 return Err(LadduDataError::InvalidArgument(
366 "chunk_size must be nonzero",
367 ));
368 }
369
370 Ok(Box::new(RootBatchIter::spawn(
371 Arc::clone(&self.schema),
372 self.options.clone(),
373 key.clone(),
374 local_start,
375 local_len,
376 chunk_size,
377 )))
378 }
379}
380
381struct RootBatchIter {
382 rx: Receiver<LadduDataResult<EventBatch>>,
383 handle: Option<JoinHandle<()>>,
384 joined: bool,
385}
386
387impl RootBatchIter {
388 fn spawn(
389 schema: Arc<Schema>,
390 options: RootReadOptions,
391 key: RootFragmentKey,
392 local_start: usize,
393 local_len: usize,
394 chunk_size: Option<usize>,
395 ) -> Self {
396 let (tx, rx) = mpsc::sync_channel(1);
399
400 let handle = thread::spawn(move || {
401 if let Err(err) = read_root_range_and_send_batches(
402 schema,
403 options,
404 key,
405 local_start,
406 local_len,
407 chunk_size,
408 tx.clone(),
409 ) {
410 let _ = tx.send(Err(err));
411 }
412 });
413
414 Self {
415 rx,
416 handle: Some(handle),
417 joined: false,
418 }
419 }
420
421 fn join_if_needed(&mut self) -> Option<LadduDataResult<EventBatch>> {
422 if self.joined {
423 return None;
424 }
425
426 self.joined = true;
427
428 if let Some(handle) = self.handle.take()
429 && handle.join().is_err()
430 {
431 return Some(Err(LadduDataError::Source(
432 "ROOT reader thread panicked".into(),
433 )));
434 }
435
436 None
437 }
438}
439
440impl Iterator for RootBatchIter {
441 type Item = LadduDataResult<EventBatch>;
442
443 fn next(&mut self) -> Option<Self::Item> {
444 match self.rx.recv() {
445 Ok(item) => Some(item),
446 Err(_) => self.join_if_needed(),
447 }
448 }
449}
450
451fn read_root_range_and_send_batches(
452 schema: Arc<Schema>,
453 options: RootReadOptions,
454 key: RootFragmentKey,
455 local_start: usize,
456 local_len: usize,
457 chunk_size: Option<usize>,
458 tx: SyncSender<LadduDataResult<EventBatch>>,
459) -> LadduDataResult<()> {
460 let mut file = RootFile::open(key.file.as_ref()).map_err(root_source_error)?;
461 let tree = file
462 .get_tree(key.tree_name.as_ref())
463 .map_err(root_source_error)?;
464
465 let mut readers =
466 RootColumnReaders::new(&tree, &schema, &options.schema_inference.column_names)?;
467
468 for _ in 0..local_start {
469 readers.skip_one()?;
470 }
471
472 let mut remaining = local_len;
473 let batch_size = chunk_size.unwrap_or(local_len.max(1));
474
475 while remaining > 0 {
476 let take = remaining.min(batch_size);
477 let batch = readers.read_batch(Arc::clone(&schema), take)?;
478
479 tx.send(Ok(batch))
480 .map_err(|e| LadduDataError::Source(e.to_string()))?;
481
482 remaining -= take;
483 }
484
485 Ok(())
486}
487
488struct RootColumnReaders<'a> {
489 p4s: Vec<[RootFloatIter<'a>; 4]>,
490 scalars: Vec<RootFloatIter<'a>>,
491 weights: Option<RootFloatIter<'a>>,
492}
493
494impl<'a> RootColumnReaders<'a> {
495 fn new(
496 tree: &'a ReaderTree,
497 schema: &Schema,
498 column_names: &SchemaColumnNames,
499 ) -> LadduDataResult<Self> {
500 let mut p4s = Vec::with_capacity(schema.n_p4s());
501 let mut scalars = Vec::with_capacity(schema.n_scalars());
502
503 for name in schema.p4s() {
504 let [e, px, py, pz] = column_names.p4_suffixes.physical_p4_names(name);
505
506 p4s.push([
507 open_float_reader(tree, e.as_ref())?,
508 open_float_reader(tree, px.as_ref())?,
509 open_float_reader(tree, py.as_ref())?,
510 open_float_reader(tree, pz.as_ref())?,
511 ]);
512 }
513
514 for name in schema.scalars() {
515 scalars.push(open_float_reader(tree, name.as_ref())?);
516 }
517
518 let weights = if schema.has_weight() {
519 Some(open_float_reader(
520 tree,
521 column_names.weight_column.as_ref(),
522 )?)
523 } else {
524 None
525 };
526
527 Ok(Self {
528 p4s,
529 scalars,
530 weights,
531 })
532 }
533
534 fn skip_one(&mut self) -> LadduDataResult<()> {
535 for [e, px, py, pz] in self.p4s.iter_mut() {
536 e.next_f64()?;
537 px.next_f64()?;
538 py.next_f64()?;
539 pz.next_f64()?;
540 }
541
542 for scalar in self.scalars.iter_mut() {
543 scalar.next_f64()?;
544 }
545
546 if let Some(weights) = self.weights.as_mut() {
547 weights.next_f64()?;
548 }
549
550 Ok(())
551 }
552
553 fn read_batch(&mut self, schema: Arc<Schema>, len: usize) -> LadduDataResult<EventBatch> {
554 let mut p4s = Vec::with_capacity(schema.n_p4s());
555 let mut scalars = Vec::with_capacity(schema.n_scalars());
556
557 for [e, px, py, pz] in self.p4s.iter_mut() {
558 let mut col = Vec::with_capacity(len);
559
560 for _ in 0..len {
561 col.push(RealVec4 {
562 e: e.next_f64()?,
563 px: px.next_f64()?,
564 py: py.next_f64()?,
565 pz: pz.next_f64()?,
566 });
567 }
568
569 p4s.push(Arc::from(col));
570 }
571
572 for reader in self.scalars.iter_mut() {
573 let mut col = Vec::with_capacity(len);
574
575 for _ in 0..len {
576 col.push(reader.next_f64()?);
577 }
578
579 scalars.push(Arc::from(col));
580 }
581
582 let weights = if let Some(reader) = self.weights.as_mut() {
583 let mut col = Vec::with_capacity(len);
584
585 for _ in 0..len {
586 col.push(reader.next_f64()?);
587 }
588
589 Some(Arc::from(col))
590 } else {
591 None
592 };
593
594 EventBatch::new(schema, p4s, scalars, weights)
595 }
596}
597
598enum RootFloatIter<'a> {
599 F64(Box<dyn Iterator<Item = f64> + 'a>),
600 F32(Box<dyn Iterator<Item = f32> + 'a>),
601}
602
603impl<'a> RootFloatIter<'a> {
604 fn next_f64(&mut self) -> LadduDataResult<f64> {
605 match self {
606 Self::F64(iter) => iter
607 .next()
608 .ok_or_else(|| LadduDataError::Source("ROOT branch ended early".into())),
609 Self::F32(iter) => iter
610 .next()
611 .map(f64::from)
612 .ok_or_else(|| LadduDataError::Source("ROOT branch ended early".into())),
613 }
614 }
615}
616
617fn open_float_reader<'a>(tree: &'a ReaderTree, name: &str) -> LadduDataResult<RootFloatIter<'a>> {
618 let branch =
619 find_branch(tree, name).ok_or_else(|| LadduDataError::MissingColumn(Name::from(name)))?;
620
621 match root_column_type(branch) {
622 ColumnType::F64 => Ok(RootFloatIter::F64(Box::new(
623 branch.as_iter::<f64>().map_err(root_source_error)?,
624 ))),
625 ColumnType::F32 => Ok(RootFloatIter::F32(Box::new(
626 branch.as_iter::<f32>().map_err(root_source_error)?,
627 ))),
628 ColumnType::Other => Err(LadduDataError::Source(format!(
629 "column {name} has unsupported ROOT type {} interpreted as {}",
630 branch.item_type_name(),
631 branch.interpretation()
632 ))),
633 }
634}
635
636fn find_branch<'a>(tree: &'a ReaderTree, name: &str) -> Option<&'a Branch> {
637 tree.branch(name).or_else(|| {
638 tree.branches_r()
639 .into_iter()
640 .find(|branch| branch.name() == name)
641 })
642}
643
644#[derive(Clone, Debug)]
645struct OwnedColumnInfo {
646 name: Name,
647 dtype: ColumnType,
648}
649
650impl OwnedColumnInfo {
651 fn as_column_info(&self) -> ColumnInfo<'_> {
652 ColumnInfo {
653 name: self.name.as_ref(),
654 dtype: self.dtype,
655 }
656 }
657}
658
659fn root_columns(path: &Path, tree_name: &str) -> LadduDataResult<Vec<OwnedColumnInfo>> {
660 let mut file = RootFile::open(path).map_err(root_source_error)?;
661 let tree = file.get_tree(tree_name).map_err(root_source_error)?;
662
663 Ok(tree
664 .branches_r()
665 .into_iter()
666 .map(|branch| OwnedColumnInfo {
667 name: Name::from(branch.name()),
668 dtype: root_column_type(branch),
669 })
670 .collect())
671}
672
673fn validate_root_file(
674 path: &Path,
675 tree_name: &str,
676 schema: &Schema,
677 options: &SchemaInferenceOptions,
678) -> LadduDataResult<()> {
679 let columns = root_columns(path, tree_name)?;
680
681 schema.validate_required_columns(columns.iter().map(OwnedColumnInfo::as_column_info), options)
682}
683
684fn root_column_type(branch: &Branch) -> ColumnType {
685 match branch.interpretation().as_str() {
686 "f64" => ColumnType::F64,
687 "f32" => ColumnType::F32,
688 _ => match branch.item_type_name().as_str() {
689 "double" | "Double_t" | "ROOT::Double_t" => ColumnType::F64,
690 "float" | "Float_t" | "ROOT::Float_t" => ColumnType::F32,
691 _ => ColumnType::Other,
692 },
693 }
694}
695
696fn resolve_tree_name(path: &Path, selection: &RootTreeSelection) -> LadduDataResult<Name> {
697 let mut file = RootFile::open(path).map_err(root_source_error)?;
698
699 match selection {
700 RootTreeSelection::Named(name) => {
701 file.get_tree(name.as_ref()).map_err(root_source_error)?;
702 Ok(name.clone())
703 }
704 RootTreeSelection::First => first_tree_name(&mut file),
705 }
706}
707
708fn first_tree_name(file: &mut RootFile) -> LadduDataResult<Name> {
709 let key_names: Vec<String> = file.keys_name().map(str::to_owned).collect();
710
711 for name in key_names {
712 if file.get_tree(&name).is_ok() {
713 return Ok(Name::from(name));
714 }
715 }
716
717 Err(LadduDataError::Source("no TTree found in ROOT file".into()))
718}
719
720fn usize_from_i64(value: i64, message: &'static str) -> LadduDataResult<usize> {
721 if value < 0 {
722 return Err(LadduDataError::Source(message.into()));
723 }
724
725 usize::try_from(value).map_err(|_| LadduDataError::Source("entry count overflows usize".into()))
726}
727
728fn root_source_error(e: impl std::fmt::Display) -> LadduDataError {
729 LadduDataError::Source(e.to_string())
730}
731
732fn root_sink_error(e: impl std::fmt::Display) -> LadduDataError {
733 LadduDataError::Sink(e.to_string())
734}
735
736pub struct RootSink {
738 output: OutputPath,
739 options: RootWriteOptions,
740 resolved_path: Option<PathBuf>,
741 event_schema: Option<Arc<Schema>>,
742 senders: Option<RootColumnSenders>,
743 writer_thread: Option<JoinHandle<LadduDataResult<()>>>,
744}
745
746#[derive(Clone, Debug)]
748pub struct RootWriteOptions {
749 pub tree_name: Name,
751 pub schema_write: SchemaWriteOptions,
753}
754
755impl Default for RootWriteOptions {
756 fn default() -> Self {
757 Self {
758 tree_name: Name::from("tree"),
759 schema_write: SchemaWriteOptions::default(),
760 }
761 }
762}
763
764impl RootSink {
765 pub fn create(path: impl Into<PathBuf>) -> Self {
767 Self::builder(path).build()
768 }
769
770 pub fn builder(path: impl Into<PathBuf>) -> RootSinkBuilder {
772 RootSinkBuilder {
773 output: OutputPath::new(path),
774 options: RootWriteOptions::default(),
775 }
776 }
777
778 pub fn resolved_path(&self) -> Option<&Path> {
780 self.resolved_path.as_deref()
781 }
782}
783
784pub struct RootSinkBuilder {
786 output: OutputPath,
787 options: RootWriteOptions,
788}
789
790impl RootSinkBuilder {
791 pub fn output_mode(mut self, mode: OutputMode) -> Self {
793 self.output = self.output.with_mode(mode);
794 self
795 }
796
797 pub fn single_file(self) -> Self {
799 self.output_mode(OutputMode::SingleFile)
800 }
801
802 pub fn per_rank_files(self) -> Self {
804 self.output_mode(OutputMode::PerRankFiles)
805 }
806
807 pub fn auto_output(self) -> Self {
809 self.output_mode(OutputMode::Auto)
810 }
811
812 pub fn tree(mut self, name: impl Into<Name>) -> Self {
814 self.options.tree_name = name.into();
815 self
816 }
817
818 pub fn schema_write(mut self, options: SchemaWriteOptions) -> Self {
820 self.options.schema_write = options;
821 self
822 }
823
824 pub fn column_names(mut self, column_names: SchemaColumnNames) -> Self {
826 self.options.schema_write.column_names = column_names;
827 self
828 }
829
830 pub fn precision(mut self, precision: Precision) -> Self {
832 self.options.schema_write.precision = precision;
833 self
834 }
835
836 pub fn write_weight_column(mut self, value: WriteWeightColumn) -> Self {
838 self.options.schema_write.write_weight_column = value;
839 self
840 }
841
842 pub fn build(self) -> RootSink {
844 RootSink {
845 output: self.output,
846 options: self.options,
847 resolved_path: None,
848 event_schema: None,
849 senders: None,
850 writer_thread: None,
851 }
852 }
853}
854
855impl EventSink for RootSink {
856 fn begin(&mut self, schema: Arc<Schema>, plan: WritePlan) -> LadduDataResult<()> {
857 if self.writer_thread.is_some() {
858 return Err(LadduDataError::Sink("ROOT sink already initialized".into()));
859 }
860
861 let path = self.output.resolve(plan, "root")?;
862 OutputPath::create_parent_dirs(&path)?;
863
864 let columns = root_output_columns(
865 &schema,
866 self.options.schema_write.write_weight_column,
867 &self.options.schema_write,
868 );
869
870 let (senders, receivers) = root_channels(&columns, self.options.schema_write.precision);
871
872 let writer_path = path.clone();
873 let tree_name = self.options.tree_name.clone();
874
875 let handle = thread::spawn(move || write_root_tree(writer_path, tree_name, receivers));
876
877 self.resolved_path = Some(path);
878 self.event_schema = Some(schema);
879 self.senders = Some(senders);
880 self.writer_thread = Some(handle);
881
882 Ok(())
883 }
884
885 fn write_batch(&mut self, batch: &EventBatch) -> LadduDataResult<()> {
886 let event_schema = self
887 .event_schema
888 .as_ref()
889 .ok_or_else(|| LadduDataError::Sink("ROOT sink not initialized".into()))?;
890
891 if event_schema.as_ref() != batch.schema().as_ref() {
892 return Err(LadduDataError::Sink(
893 "batch schema does not match ROOT sink schema".into(),
894 ));
895 }
896
897 let senders = self
898 .senders
899 .as_ref()
900 .ok_or_else(|| LadduDataError::Sink("ROOT sink not initialized".into()))?;
901
902 let should_write_weight = matches!(
903 self.options.schema_write.write_weight_column,
904 WriteWeightColumn::Always
905 ) || batch.schema().has_weight();
906
907 for row in 0..batch.len() {
908 let mut index = 0;
909
910 for col in 0..batch.schema().n_p4s() {
911 let p = batch.p4_at(col, row);
912
913 senders.send(index, p.e)?;
914 index += 1;
915
916 senders.send(index, p.px)?;
917 index += 1;
918
919 senders.send(index, p.py)?;
920 index += 1;
921
922 senders.send(index, p.pz)?;
923 index += 1;
924 }
925
926 for col in 0..batch.schema().n_scalars() {
927 senders.send(index, batch.scalar_at(col, row))?;
928 index += 1;
929 }
930
931 if should_write_weight {
932 senders.send(index, batch.weights_at(row))?;
933 }
934 }
935
936 Ok(())
937 }
938
939 fn finish(&mut self) -> LadduDataResult<()> {
940 self.senders.take();
941
942 if let Some(handle) = self.writer_thread.take() {
943 match handle.join() {
944 Ok(result) => result?,
945 Err(_) => return Err(LadduDataError::Sink("ROOT writer thread panicked".into())),
946 }
947 }
948
949 Ok(())
950 }
951}
952
953impl Drop for RootSink {
954 fn drop(&mut self) {
955 let _ = self.finish();
956 }
957}
958
959enum RootColumnSenders {
960 F64(Vec<Sender<f64>>),
961 F32(Vec<Sender<f32>>),
962}
963
964impl RootColumnSenders {
965 fn send(&self, index: usize, value: f64) -> LadduDataResult<()> {
966 match self {
967 Self::F64(senders) => senders[index]
968 .send(value)
969 .map_err(|e| LadduDataError::Sink(e.to_string())),
970 Self::F32(senders) => senders[index]
971 .send(value as f32)
972 .map_err(|e| LadduDataError::Sink(e.to_string())),
973 }
974 }
975}
976
977enum RootColumnReceivers {
978 F64(Vec<(Name, Receiver<f64>)>),
979 F32(Vec<(Name, Receiver<f32>)>),
980}
981
982fn root_channels(
983 columns: &[Name],
984 precision: Precision,
985) -> (RootColumnSenders, RootColumnReceivers) {
986 match precision {
987 Precision::F64 => {
988 let mut senders = Vec::with_capacity(columns.len());
989 let mut receivers = Vec::with_capacity(columns.len());
990
991 for name in columns {
992 let (tx, rx) = mpsc::channel();
993 senders.push(tx);
994 receivers.push((name.clone(), rx));
995 }
996
997 (
998 RootColumnSenders::F64(senders),
999 RootColumnReceivers::F64(receivers),
1000 )
1001 }
1002 Precision::F32 => {
1003 let mut senders = Vec::with_capacity(columns.len());
1004 let mut receivers = Vec::with_capacity(columns.len());
1005
1006 for name in columns {
1007 let (tx, rx) = mpsc::channel();
1008 senders.push(tx);
1009 receivers.push((name.clone(), rx));
1010 }
1011
1012 (
1013 RootColumnSenders::F32(senders),
1014 RootColumnReceivers::F32(receivers),
1015 )
1016 }
1017 }
1018}
1019
1020fn write_root_tree(
1021 path: PathBuf,
1022 tree_name: Name,
1023 receivers: RootColumnReceivers,
1024) -> LadduDataResult<()> {
1025 let mut file = RootFile::create(&path).map_err(root_sink_error)?;
1026 let mut tree = WriterTree::new(tree_name.as_ref());
1027
1028 match receivers {
1029 RootColumnReceivers::F64(receivers) => {
1030 for (name, rx) in receivers {
1031 tree.new_branch(name.as_ref(), rx.into_iter());
1032 }
1033 }
1034 RootColumnReceivers::F32(receivers) => {
1035 for (name, rx) in receivers {
1036 tree.new_branch(name.as_ref(), rx.into_iter());
1037 }
1038 }
1039 }
1040
1041 tree.write(&mut file).map_err(root_sink_error)?;
1042 file.close().map_err(root_sink_error)?;
1043
1044 Ok(())
1045}
1046
1047fn root_output_columns(
1048 schema: &Schema,
1049 write_weight: WriteWeightColumn,
1050 options: &SchemaWriteOptions,
1051) -> Vec<Name> {
1052 let should_write_weight =
1053 matches!(write_weight, WriteWeightColumn::Always) || schema.has_weight();
1054
1055 let mut columns = Vec::with_capacity(
1056 4 * schema.n_p4s() + schema.n_scalars() + usize::from(should_write_weight),
1057 );
1058
1059 for name in schema.p4s() {
1060 let [e, px, py, pz] = options.column_names.p4_suffixes.physical_p4_names(name);
1061 columns.push(e.into());
1062 columns.push(px.into());
1063 columns.push(py.into());
1064 columns.push(pz.into());
1065 }
1066
1067 for name in schema.scalars() {
1068 columns.push(name.clone());
1069 }
1070
1071 if should_write_weight {
1072 columns.push(options.column_names.weight_column.clone());
1073 }
1074
1075 columns
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080 use std::sync::atomic::{AtomicU64, Ordering};
1081
1082 use super::*;
1083 use crate::data::{Dataset, EventBatchBuilder};
1084
1085 fn temp_path(ext: &str) -> PathBuf {
1086 static NEXT_TEMP_FILE_ID: AtomicU64 = AtomicU64::new(0);
1087
1088 let nanos = std::time::SystemTime::now()
1089 .duration_since(std::time::UNIX_EPOCH)
1090 .unwrap()
1091 .as_nanos();
1092 let id = NEXT_TEMP_FILE_ID.fetch_add(1, Ordering::Relaxed);
1093
1094 std::env::temp_dir().join(format!(
1095 "laddu-root-test-{}-{nanos}-{id}.{ext}",
1096 std::process::id()
1097 ))
1098 }
1099
1100 fn v(x: f64) -> RealVec4 {
1101 RealVec4 {
1102 e: x + 0.3,
1103 px: x,
1104 py: x + 0.1,
1105 pz: x + 0.2,
1106 }
1107 }
1108
1109 fn schema() -> Arc<Schema> {
1110 Arc::new(Schema::new(["p"], ["mass"], true).unwrap())
1111 }
1112
1113 fn batch() -> EventBatch {
1114 let schema = schema();
1115 let mut builder = EventBatchBuilder::new(schema);
1116
1117 for i in 0..4 {
1118 builder
1119 .push_weighted([v(i as f64)], [100.0 + i as f64], 10.0 + i as f64)
1120 .unwrap();
1121 }
1122
1123 builder.finish().unwrap()
1124 }
1125
1126 #[test]
1127 fn root_sink_and_source_roundtrip_named_tree_with_f32_precision() {
1128 let path = temp_path("root");
1129 let batch = batch();
1130
1131 let mut sink = RootSink::builder(path.clone())
1132 .tree("events")
1133 .precision(Precision::F32)
1134 .build();
1135
1136 sink.begin(Arc::clone(batch.schema()), WritePlan::default())
1137 .unwrap();
1138 sink.write_batch(&batch).unwrap();
1139 sink.finish().unwrap();
1140
1141 let tree_names = RootSource::tree_names(&path).unwrap();
1142 assert!(tree_names.iter().any(|name| name.as_ref() == "events"));
1143
1144 let columns = RootSource::columns(&path, Some("events")).unwrap();
1145 let names = columns
1146 .iter()
1147 .map(|col| col.name.to_string())
1148 .collect::<Vec<_>>();
1149
1150 for expected in ["p_e", "p_px", "p_py", "p_pz", "mass", "weight"] {
1151 assert!(
1152 names.iter().any(|name| name == expected),
1153 "missing {expected}"
1154 );
1155 }
1156
1157 let source = RootSource::builder(path.to_str().unwrap())
1158 .tree("events")
1159 .build()
1160 .unwrap();
1161
1162 assert_eq!(source.tree_name(), "events");
1163 assert_eq!(source.num_events().unwrap(), Some(4));
1164
1165 let read_batches: Vec<EventBatch> = source
1166 .batches(ReadPlan {
1167 chunk_size: Some(2),
1168 #[cfg(feature = "mpi")]
1169 distribution: Default::default(),
1170 })
1171 .unwrap()
1172 .map(Result::unwrap)
1173 .collect();
1174
1175 assert_eq!(
1176 read_batches.iter().map(EventBatch::len).collect::<Vec<_>>(),
1177 vec![2, 2]
1178 );
1179
1180 let read = EventBatch::concat(&read_batches).unwrap();
1181
1182 assert_eq!(read.scalar_column(0), &[100.0, 101.0, 102.0, 103.0]);
1183 assert_eq!(read.weights_column().unwrap(), &[10.0, 11.0, 12.0, 13.0]);
1184 assert!((read.p4_at(0, 2).e - 2.3).abs() < 1.0e-6);
1185
1186 let _ = std::fs::remove_file(path);
1187 }
1188
1189 #[test]
1190 fn root_source_infers_first_tree_when_no_tree_is_named() {
1191 let path = temp_path("root");
1192 let batch = batch();
1193
1194 let mut sink = RootSink::builder(path.clone()).tree("first_tree").build();
1195
1196 sink.begin(Arc::clone(batch.schema()), WritePlan::default())
1197 .unwrap();
1198 sink.write_batch(&batch).unwrap();
1199 sink.finish().unwrap();
1200
1201 let source = RootSource::builder(path.to_str().unwrap())
1202 .first_tree()
1203 .build()
1204 .unwrap();
1205
1206 assert_eq!(source.tree_name(), "first_tree");
1207
1208 let read = EventBatch::concat(
1209 &source
1210 .batches(ReadPlan::default())
1211 .unwrap()
1212 .map(Result::unwrap)
1213 .collect::<Vec<_>>(),
1214 )
1215 .unwrap();
1216
1217 assert_eq!(read.scalar_column(0), &[100.0, 101.0, 102.0, 103.0]);
1218
1219 let _ = std::fs::remove_file(path);
1220 }
1221
1222 #[test]
1223 fn root_source_named_missing_tree_fails() {
1224 let path = temp_path("root");
1225 let batch = batch();
1226
1227 let mut sink = RootSink::builder(path.clone()).tree("events").build();
1228
1229 sink.begin(Arc::clone(batch.schema()), WritePlan::default())
1230 .unwrap();
1231 sink.write_batch(&batch).unwrap();
1232 sink.finish().unwrap();
1233
1234 let err = RootSource::builder(path.to_str().unwrap())
1235 .tree("missing")
1236 .build()
1237 .unwrap_err();
1238
1239 assert!(matches!(err, LadduDataError::Source(_)));
1240
1241 let _ = std::fs::remove_file(path);
1242 }
1243
1244 #[test]
1245 fn root_sink_rejects_batches_with_different_schema() {
1246 let path = temp_path("root");
1247 let batch = batch();
1248
1249 let mut sink = RootSink::builder(path.clone()).tree("events").build();
1250
1251 sink.begin(Arc::clone(batch.schema()), WritePlan::default())
1252 .unwrap();
1253
1254 let other_schema = Arc::new(Schema::new(["q"], ["mass"], true).unwrap());
1255 let mut builder = EventBatchBuilder::new(other_schema);
1256 builder.push_weighted([v(1.0)], [1.0], 1.0).unwrap();
1257 let other = builder.finish().unwrap();
1258
1259 let err = sink.write_batch(&other).unwrap_err();
1260 assert!(matches!(err, LadduDataError::Sink(msg) if msg.contains("schema")));
1261
1262 sink.finish().unwrap();
1263 let _ = std::fs::remove_file(path);
1264 }
1265
1266 #[test]
1267 fn dataset_write_to_root_applies_dataset_transformations_before_writing() {
1268 let path = temp_path("root");
1269
1270 let dataset = Dataset::from_batch(batch()).filter(|ev| ev.scalar(0) >= 102.0);
1271
1272 let mut sink = RootSink::builder(path.clone()).tree("events").build();
1273
1274 dataset.write_to(&mut sink).unwrap();
1275
1276 let source = RootSource::builder(path.to_str().unwrap())
1277 .tree("events")
1278 .build()
1279 .unwrap();
1280
1281 let read = EventBatch::concat(
1282 &source
1283 .batches(ReadPlan::default())
1284 .unwrap()
1285 .map(Result::unwrap)
1286 .collect::<Vec<_>>(),
1287 )
1288 .unwrap();
1289
1290 assert_eq!(read.scalar_column(0), &[102.0, 103.0]);
1291 assert_eq!(read.weights_column().unwrap(), &[12.0, 13.0]);
1292
1293 let _ = std::fs::remove_file(path);
1294 }
1295}