Skip to main content

laddu_data/io/
parquet.rs

1use std::{
2    fs::File,
3    path::{Path, PathBuf},
4    sync::Arc,
5};
6
7use arrow::{
8    array::{Array, ArrayRef, Float32Array, Float64Array},
9    datatypes::{DataType, Field, Schema as ArrowSchema, SchemaRef},
10    record_batch::RecordBatch,
11};
12use laddu_physics::vectors::RealVec4;
13use parquet::{
14    arrow::{ArrowWriter, ProjectionMask, arrow_reader::ParquetRecordBatchReaderBuilder},
15    file::properties::WriterProperties,
16    schema::types::SchemaDescriptor,
17};
18
19use crate::{
20    LadduDataError, LadduDataResult, Name,
21    data::EventBatch,
22    io::{
23        DataFragment, EventSink, EventSource, FragmentedSource, OutputMode, OutputPath, ReadPlan,
24        SliceBatchIter, SourceCapabilities, WritePlan, fragmented_batches,
25    },
26    schema::{
27        ColumnInfo, ColumnType, Precision, Schema, SchemaColumnNames, SchemaInferenceOptions,
28        SchemaWriteOptions, WriteWeightColumn,
29    },
30};
31
32/// Event source backed by one or more Parquet files.
33#[derive(Clone, Debug)]
34pub struct ParquetSource {
35    files: Arc<[Arc<PathBuf>]>,
36    schema: Arc<Schema>,
37    options: ParquetReadOptions,
38}
39
40/// Schema inference, validation, null, and glob options for Parquet reads.
41#[derive(Clone, Debug)]
42pub struct ParquetReadOptions {
43    /// Infer a logical schema when none is supplied.
44    pub infer_schema: bool,
45    /// Validate required columns in every matched file.
46    pub validate_all_files: bool,
47    /// Policy for null floating-point cells.
48    pub null_handling: NullHandling,
49    /// Sort glob results for deterministic global row order.
50    pub sort_glob: bool,
51    /// Logical schema inference options.
52    pub schema_inference: SchemaInferenceOptions,
53}
54
55impl Default for ParquetReadOptions {
56    fn default() -> Self {
57        Self {
58            infer_schema: true,
59            validate_all_files: true,
60            null_handling: NullHandling::Error,
61            sort_glob: true,
62            schema_inference: SchemaInferenceOptions::default(),
63        }
64    }
65}
66
67/// Policy for null floating-point cells in Parquet input.
68#[derive(Clone, Copy, Debug)]
69pub enum NullHandling {
70    /// Return an error on the first null.
71    Error,
72    /// Convert nulls to NaN.
73    NaN,
74}
75
76/// Key identifying one Parquet row group.
77#[derive(Clone, Debug)]
78pub struct ParquetFragmentKey {
79    /// Input file path.
80    pub file: Arc<PathBuf>,
81    /// Zero-based row-group index.
82    pub row_group: usize,
83}
84
85impl ParquetSource {
86    /// Opens files matching a glob with default options.
87    ///
88    /// # Errors
89    ///
90    /// Returns [`LadduDataError`] when the glob is invalid or empty, a file
91    /// cannot be read, or schemas are incompatible.
92    pub fn open(pattern: impl AsRef<str>) -> LadduDataResult<Self> {
93        Self::builder(pattern).build()
94    }
95
96    /// Creates a configurable source builder for a file glob.
97    pub fn builder(pattern: impl AsRef<str>) -> ParquetSourceBuilder {
98        ParquetSourceBuilder {
99            pattern: pattern.as_ref().to_owned(),
100            schema: None,
101            options: ParquetReadOptions::default(),
102        }
103    }
104
105    /// Returns matched files in global row order.
106    pub fn files(&self) -> &[Arc<PathBuf>] {
107        &self.files
108    }
109}
110
111/// Builder for a [`ParquetSource`].
112pub struct ParquetSourceBuilder {
113    pattern: String,
114    schema: Option<Arc<Schema>>,
115    options: ParquetReadOptions,
116}
117
118impl ParquetSourceBuilder {
119    /// Supplies an explicit logical schema and disables inference.
120    pub fn schema(mut self, schema: Arc<Schema>) -> Self {
121        self.schema = Some(schema);
122        self.options.infer_schema = false;
123        self
124    }
125
126    /// Enables or disables logical schema inference.
127    pub fn infer_schema(mut self, value: bool) -> Self {
128        self.options.infer_schema = value;
129        self
130    }
131
132    /// Requires a physical weight column during inference.
133    pub fn require_weight(mut self, value: bool) -> Self {
134        self.options.schema_inference.require_weight = value;
135        self
136    }
137
138    /// Chooses whether every matched file is schema-validated eagerly.
139    pub fn validate_all_files(mut self, value: bool) -> Self {
140        self.options.validate_all_files = value;
141        self
142    }
143
144    /// Converts null floating-point cells to NaN.
145    pub fn nulls_as_nan(mut self) -> Self {
146        self.options.null_handling = NullHandling::NaN;
147        self
148    }
149
150    /// Returns an error on null floating-point cells.
151    pub fn error_on_nulls(mut self) -> Self {
152        self.options.null_handling = NullHandling::Error;
153        self
154    }
155
156    /// Chooses whether matched paths are sorted.
157    pub fn sort_glob(mut self, value: bool) -> Self {
158        self.options.sort_glob = value;
159        self
160    }
161
162    /// Replaces logical schema inference options.
163    pub fn schema_inference(mut self, options: SchemaInferenceOptions) -> Self {
164        self.options.schema_inference = options;
165        self
166    }
167
168    /// Resolves files, validates schema, and builds the source.
169    ///
170    /// # Errors
171    ///
172    /// Returns [`LadduDataError`] when the glob is invalid or empty, Parquet
173    /// metadata cannot be read, schema inference fails, or files disagree.
174    pub fn build(self) -> LadduDataResult<ParquetSource> {
175        let mut files: Vec<PathBuf> = glob::glob(&self.pattern)
176            .map_err(|e| LadduDataError::Source(e.to_string()))?
177            .collect::<std::result::Result<_, _>>()
178            .map_err(|e| LadduDataError::Source(e.to_string()))?;
179
180        if self.options.sort_glob {
181            files.sort();
182        }
183
184        if files.is_empty() {
185            return Err(LadduDataError::Source(
186                "no parquet files matched glob".into(),
187            ));
188        }
189
190        let files: Arc<[Arc<PathBuf>]> = files.into_iter().map(Arc::new).collect();
191
192        let schema = match self.schema {
193            Some(schema) => schema,
194            None if self.options.infer_schema => Arc::new({
195                let path: &Path = files[0].as_ref();
196                let options: &ParquetReadOptions = &self.options;
197                let arrow_schema = parquet_arrow_schema(path)?;
198                Schema::infer_from_columns(arrow_columns(&arrow_schema), &options.schema_inference)
199            }?),
200            None => return Err(LadduDataError::InvalidArgument("schema required")),
201        };
202
203        if self.options.validate_all_files {
204            for file in files.iter() {
205                {
206                    let path: &Path = file.as_ref();
207                    let schema: &Schema = &schema;
208                    let options: &ParquetReadOptions = &self.options;
209                    let arrow_schema = parquet_arrow_schema(path)?;
210                    schema.validate_required_columns(
211                        arrow_columns(&arrow_schema),
212                        &options.schema_inference,
213                    )
214                }?;
215            }
216        }
217
218        Ok(ParquetSource {
219            files,
220            schema,
221            options: self.options,
222        })
223    }
224}
225
226impl EventSource for ParquetSource {
227    fn schema(&self) -> LadduDataResult<Arc<Schema>> {
228        Ok(Arc::clone(&self.schema))
229    }
230
231    fn capabilities(&self) -> SourceCapabilities {
232        SourceCapabilities {
233            exact_len: true,
234            exact_weighted_total: false,
235            random_access: false,
236            deterministic_partitioning: true,
237            predicate_pushdown: false,
238            projection_pushdown: true,
239            streaming: true,
240        }
241    }
242
243    fn num_events(&self) -> LadduDataResult<Option<u64>> {
244        Ok(Some(self.fragments()?.iter().map(|f| f.rows).sum()))
245    }
246
247    fn batches(
248        &self,
249        plan: ReadPlan,
250    ) -> LadduDataResult<Box<dyn Iterator<Item = LadduDataResult<EventBatch>> + Send>> {
251        fragmented_batches(Arc::new(self.clone()), plan)
252    }
253}
254
255impl FragmentedSource for ParquetSource {
256    type Key = ParquetFragmentKey;
257
258    fn fragments(&self) -> LadduDataResult<Vec<DataFragment<Self::Key>>> {
259        let mut fragments = Vec::new();
260        let mut global_start = 0_u64;
261
262        for path in self.files.iter() {
263            let file =
264                File::open(path.as_ref()).map_err(|e| LadduDataError::Source(e.to_string()))?;
265
266            let builder = ParquetRecordBatchReaderBuilder::try_new(file)
267                .map_err(|e| LadduDataError::Source(e.to_string()))?;
268
269            let metadata = builder.metadata();
270
271            for row_group in 0..metadata.num_row_groups() {
272                let rows = metadata.row_group(row_group).num_rows() as u64;
273
274                fragments.push(DataFragment {
275                    key: ParquetFragmentKey {
276                        file: Arc::clone(path),
277                        row_group,
278                    },
279                    global_start,
280                    rows,
281                });
282
283                global_start += rows;
284            }
285        }
286
287        Ok(fragments)
288    }
289
290    fn read_fragment_range(
291        &self,
292        key: &Self::Key,
293        local_start: usize,
294        local_len: usize,
295        chunk_size: Option<usize>,
296    ) -> LadduDataResult<Box<dyn Iterator<Item = LadduDataResult<EventBatch>> + Send>> {
297        open_parquet_fragment_reader(
298            Arc::clone(&self.schema),
299            self.options.clone(),
300            key.clone(),
301            local_start,
302            local_len,
303            chunk_size,
304        )
305    }
306}
307
308fn open_parquet_fragment_reader(
309    schema: Arc<Schema>,
310    options: ParquetReadOptions,
311    key: ParquetFragmentKey,
312    local_start: usize,
313    local_len: usize,
314    chunk_size: Option<usize>,
315) -> LadduDataResult<Box<dyn Iterator<Item = LadduDataResult<EventBatch>> + Send>> {
316    let file = File::open(key.file.as_ref()).map_err(|e| LadduDataError::Source(e.to_string()))?;
317
318    let mut builder = ParquetRecordBatchReaderBuilder::try_new(file)
319        .map_err(|e| LadduDataError::Source(e.to_string()))?
320        .with_row_groups(vec![key.row_group]);
321
322    let projection = parquet_projection_for_schema(
323        builder.parquet_schema(),
324        builder.schema().as_ref(),
325        &schema,
326        &options.schema_inference.column_names,
327    )?;
328    builder = builder.with_projection(projection);
329
330    if matches!(chunk_size, Some(0)) {
331        return Err(LadduDataError::InvalidArgument(
332            "chunk_size must be nonzero",
333        ));
334    }
335    let end = local_start
336        .checked_add(local_len)
337        .ok_or(LadduDataError::InvalidArgument(
338            "slice range overflows usize",
339        ))?;
340    let batch_size = chunk_size.unwrap_or(end.max(1));
341    builder = builder.with_batch_size(batch_size);
342
343    let reader = builder
344        .build()
345        .map_err(|e| LadduDataError::Source(e.to_string()))?;
346
347    let batches = reader.map(move |rb| {
348        let rb = rb.map_err(|e| LadduDataError::Source(e.to_string()))?;
349        record_batch_to_event_batch(rb, Arc::clone(&schema), &options)
350    });
351
352    Ok(Box::new(SliceBatchIter::new(
353        batches,
354        local_start,
355        local_len,
356    )?))
357}
358
359fn parquet_projection_for_schema(
360    parquet_schema: &SchemaDescriptor,
361    arrow_schema: &ArrowSchema,
362    schema: &Schema,
363    column_names: &SchemaColumnNames,
364) -> LadduDataResult<ProjectionMask> {
365    let mut indices = Vec::new();
366
367    for name in schema.physical_columns(column_names) {
368        let index = arrow_schema
369            .index_of(name.as_ref())
370            .map_err(|_| LadduDataError::MissingColumn(name))?;
371
372        indices.push(index);
373    }
374
375    indices.sort_unstable();
376    indices.dedup();
377
378    Ok(ProjectionMask::roots(parquet_schema, indices))
379}
380
381fn parquet_arrow_schema(path: &Path) -> LadduDataResult<SchemaRef> {
382    let file = File::open(path).map_err(|e| LadduDataError::Source(e.to_string()))?;
383
384    let builder = ParquetRecordBatchReaderBuilder::try_new(file)
385        .map_err(|e| LadduDataError::Source(e.to_string()))?;
386
387    Ok(builder.schema().clone())
388}
389
390fn arrow_columns(schema: &ArrowSchema) -> impl Iterator<Item = ColumnInfo<'_>> {
391    schema.fields().iter().map(|field| ColumnInfo {
392        name: field.name().as_str(),
393        dtype: arrow_column_type(field.data_type()),
394    })
395}
396
397fn arrow_column_type(data_type: &DataType) -> ColumnType {
398    match data_type {
399        DataType::Float64 => ColumnType::F64,
400        DataType::Float32 => ColumnType::F32,
401        _ => ColumnType::Other,
402    }
403}
404
405fn record_batch_to_event_batch(
406    rb: RecordBatch,
407    schema: Arc<Schema>,
408    options: &ParquetReadOptions,
409) -> LadduDataResult<EventBatch> {
410    let arrow_schema = rb.schema();
411
412    let mut p4s = Vec::with_capacity(schema.n_p4s());
413    let mut scalars = Vec::with_capacity(schema.n_scalars());
414
415    for name in schema.p4s() {
416        let [e_name, px_name, py_name, pz_name] = options
417            .schema_inference
418            .column_names
419            .p4_suffixes
420            .physical_p4_names(name);
421
422        let e = read_f64_column(&rb, &arrow_schema, &e_name, options)?;
423        let px = read_f64_column(&rb, &arrow_schema, &px_name, options)?;
424        let py = read_f64_column(&rb, &arrow_schema, &py_name, options)?;
425        let pz = read_f64_column(&rb, &arrow_schema, &pz_name, options)?;
426
427        let col: Arc<[RealVec4]> = (0..rb.num_rows())
428            .map(|i| RealVec4 {
429                e: e[i],
430                px: px[i],
431                py: py[i],
432                pz: pz[i],
433            })
434            .collect();
435
436        p4s.push(col);
437    }
438
439    for name in schema.scalars() {
440        scalars.push(read_f64_column(&rb, &arrow_schema, name, options)?.into());
441    }
442
443    let weights = if schema.has_weight() {
444        Some(
445            read_f64_column(
446                &rb,
447                &arrow_schema,
448                &options.schema_inference.column_names.weight_column,
449                options,
450            )?
451            .into(),
452        )
453    } else {
454        None
455    };
456
457    EventBatch::new(schema, p4s, scalars, weights)
458}
459
460fn read_f64_column(
461    rb: &RecordBatch,
462    arrow_schema: &SchemaRef,
463    name: &str,
464    options: &ParquetReadOptions,
465) -> LadduDataResult<Vec<f64>> {
466    let index = arrow_schema
467        .index_of(name)
468        .map_err(|_| LadduDataError::MissingColumn(Name::from(name)))?;
469
470    let array = rb.column(index);
471
472    match array.data_type() {
473        DataType::Float64 => {
474            let array = array
475                .as_any()
476                .downcast_ref::<Float64Array>()
477                .ok_or_else(|| LadduDataError::Source(format!("failed to read {name} as f64")))?;
478
479            collect_f64(array, name, options.null_handling)
480        }
481
482        DataType::Float32 => {
483            let array = array
484                .as_any()
485                .downcast_ref::<Float32Array>()
486                .ok_or_else(|| LadduDataError::Source(format!("failed to read {name} as f32")))?;
487
488            collect_f32(array, name, options.null_handling)
489        }
490
491        other => Err(LadduDataError::Source(format!(
492            "column {name} has unsupported type {other:?}"
493        ))),
494    }
495}
496
497fn collect_f64(array: &Float64Array, name: &str, nulls: NullHandling) -> LadduDataResult<Vec<f64>> {
498    let mut out = Vec::with_capacity(array.len());
499
500    for i in 0..array.len() {
501        if array.is_null(i) {
502            match nulls {
503                NullHandling::Error => {
504                    return Err(LadduDataError::Source(format!("null in column {name}")));
505                }
506                NullHandling::NaN => out.push(f64::NAN),
507            }
508        } else {
509            out.push(array.value(i));
510        }
511    }
512
513    Ok(out)
514}
515
516fn collect_f32(array: &Float32Array, name: &str, nulls: NullHandling) -> LadduDataResult<Vec<f64>> {
517    let mut out = Vec::with_capacity(array.len());
518
519    for i in 0..array.len() {
520        if array.is_null(i) {
521            match nulls {
522                NullHandling::Error => {
523                    return Err(LadduDataError::Source(format!("null in column {name}")));
524                }
525                NullHandling::NaN => out.push(f64::NAN),
526            }
527        } else {
528            out.push(array.value(i) as f64);
529        }
530    }
531
532    Ok(out)
533}
534
535/// Event sink that writes Arrow record batches to Parquet.
536pub struct ParquetSink {
537    output: OutputPath,
538    writer: Option<ArrowWriter<File>>,
539    arrow_schema: Option<SchemaRef>,
540    event_schema: Option<Arc<Schema>>,
541    options: ParquetWriteOptions,
542    resolved_path: Option<PathBuf>,
543}
544
545/// Parquet writer and physical schema options.
546#[derive(Clone, Debug, Default)]
547pub struct ParquetWriteOptions {
548    /// Optional low-level Parquet writer properties.
549    pub writer_properties: Option<WriterProperties>,
550    /// Physical schema write options.
551    pub schema_write: SchemaWriteOptions,
552}
553
554impl ParquetSink {
555    /// Creates a sink with default options.
556    pub fn create(path: impl Into<PathBuf>) -> Self {
557        Self::builder(path).build()
558    }
559
560    /// Creates a configurable sink builder.
561    pub fn builder(path: impl Into<PathBuf>) -> ParquetSinkBuilder {
562        ParquetSinkBuilder {
563            output: OutputPath::new(path),
564            options: ParquetWriteOptions::default(),
565        }
566    }
567
568    /// Returns the concrete path after writing has begun.
569    pub fn resolved_path(&self) -> Option<&Path> {
570        self.resolved_path.as_deref()
571    }
572}
573
574/// Builder for a [`ParquetSink`].
575pub struct ParquetSinkBuilder {
576    output: OutputPath,
577    options: ParquetWriteOptions,
578}
579
580impl ParquetSinkBuilder {
581    /// Sets the output path mode.
582    pub fn output_mode(mut self, mode: OutputMode) -> Self {
583        self.output = self.output.with_mode(mode);
584        self
585    }
586
587    /// Selects single-file output.
588    pub fn single_file(self) -> Self {
589        self.output_mode(OutputMode::SingleFile)
590    }
591
592    /// Selects one output file per rank.
593    pub fn per_rank_files(self) -> Self {
594        self.output_mode(OutputMode::PerRankFiles)
595    }
596
597    /// Selects output mode from the write plan.
598    pub fn auto_output(self) -> Self {
599        self.output_mode(OutputMode::Auto)
600    }
601
602    /// Replaces physical schema write options.
603    pub fn schema_write(mut self, options: SchemaWriteOptions) -> Self {
604        self.options.schema_write = options;
605        self
606    }
607
608    /// Sets physical column naming conventions.
609    pub fn column_names(mut self, column_names: SchemaColumnNames) -> Self {
610        self.options.schema_write.column_names = column_names;
611        self
612    }
613
614    /// Sets floating-point output precision.
615    pub fn precision(mut self, precision: Precision) -> Self {
616        self.options.schema_write.precision = precision;
617        self
618    }
619
620    /// Sets low-level Parquet writer properties.
621    pub fn writer_properties(mut self, props: WriterProperties) -> Self {
622        self.options.writer_properties = Some(props);
623        self
624    }
625
626    /// Sets the weight-column emission policy.
627    pub fn write_weight_column(mut self, value: WriteWeightColumn) -> Self {
628        self.options.schema_write.write_weight_column = value;
629        self
630    }
631
632    /// Builds the sink.
633    pub fn build(self) -> ParquetSink {
634        ParquetSink {
635            output: self.output,
636            writer: None,
637            arrow_schema: None,
638            event_schema: None,
639            options: self.options,
640            resolved_path: None,
641        }
642    }
643}
644
645impl EventSink for ParquetSink {
646    fn begin(&mut self, schema: Arc<Schema>, plan: WritePlan) -> LadduDataResult<()> {
647        let path = self.output.resolve(plan, "parquet")?;
648        OutputPath::create_parent_dirs(&path)?;
649
650        let arrow_schema = Arc::new(arrow_schema_from_event_schema(
651            &schema,
652            self.options.schema_write.write_weight_column,
653            &self.options.schema_write,
654        ));
655
656        let file = File::create(&path).map_err(|e| LadduDataError::Sink(e.to_string()))?;
657
658        let writer = ArrowWriter::try_new(
659            file,
660            Arc::clone(&arrow_schema),
661            self.options.writer_properties.clone(),
662        )
663        .map_err(|e| LadduDataError::Sink(e.to_string()))?;
664
665        self.arrow_schema = Some(arrow_schema);
666        self.event_schema = Some(schema);
667        self.writer = Some(writer);
668        self.resolved_path = Some(path);
669
670        Ok(())
671    }
672
673    fn write_batch(&mut self, batch: &EventBatch) -> LadduDataResult<()> {
674        let arrow_schema = self
675            .arrow_schema
676            .as_ref()
677            .ok_or_else(|| LadduDataError::Sink("parquet sink not initialized".into()))?;
678
679        let event_schema = self
680            .event_schema
681            .as_ref()
682            .ok_or_else(|| LadduDataError::Sink("parquet sink not initialized".into()))?;
683        if event_schema.as_ref() != batch.schema().as_ref() {
684            return Err(LadduDataError::Sink(
685                "batch schema does not match parquet sink schema".into(),
686            ));
687        }
688
689        let rb = event_batch_to_record_batch(
690            batch,
691            Arc::clone(arrow_schema),
692            self.options.schema_write.write_weight_column,
693            self.options.schema_write.precision,
694        )?;
695
696        self.writer
697            .as_mut()
698            .ok_or_else(|| LadduDataError::Sink("parquet sink not initialized".into()))?
699            .write(&rb)
700            .map_err(|e| LadduDataError::Sink(e.to_string()))
701    }
702
703    fn finish(&mut self) -> LadduDataResult<()> {
704        if let Some(writer) = self.writer.take() {
705            writer
706                .close()
707                .map_err(|e| LadduDataError::Sink(e.to_string()))?;
708        }
709
710        Ok(())
711    }
712}
713
714fn arrow_schema_from_event_schema(
715    schema: &Schema,
716    write_weight: WriteWeightColumn,
717    options: &SchemaWriteOptions,
718) -> ArrowSchema {
719    let should_write_weight =
720        matches!(write_weight, WriteWeightColumn::Always) || schema.has_weight();
721
722    let mut fields = Vec::with_capacity(
723        4 * schema.n_p4s() + schema.n_scalars() + usize::from(should_write_weight),
724    );
725
726    let data_type = match options.precision {
727        Precision::F64 => DataType::Float64,
728        Precision::F32 => DataType::Float32,
729    };
730
731    for name in schema.p4s() {
732        let [e, px, py, pz] = options.column_names.p4_suffixes.physical_p4_names(name);
733        fields.push(Field::new(e, data_type.clone(), false));
734        fields.push(Field::new(px, data_type.clone(), false));
735        fields.push(Field::new(py, data_type.clone(), false));
736        fields.push(Field::new(pz, data_type.clone(), false));
737    }
738
739    for name in schema.scalars() {
740        fields.push(Field::new(name.as_ref(), data_type.clone(), false));
741    }
742
743    if should_write_weight {
744        fields.push(Field::new(
745            options.column_names.weight_column.as_ref(),
746            data_type,
747            false,
748        ));
749    }
750
751    ArrowSchema::new(fields)
752}
753
754fn array_from_iter<I: IntoIterator<Item = f64>>(iter: I, precision: Precision) -> ArrayRef {
755    match precision {
756        Precision::F64 => Arc::new(Float64Array::from_iter_values(iter)),
757        Precision::F32 => Arc::new(Float32Array::from_iter_values(
758            iter.into_iter().map(|f| f as f32),
759        )),
760    }
761}
762
763fn event_batch_to_record_batch(
764    batch: &EventBatch,
765    arrow_schema: SchemaRef,
766    write_weight: WriteWeightColumn,
767    precision: Precision,
768) -> LadduDataResult<RecordBatch> {
769    let mut columns: Vec<ArrayRef> = Vec::with_capacity(arrow_schema.fields().len());
770
771    for col in 0..batch.schema().n_p4s() {
772        let p = batch.vec4_column(col);
773
774        columns.push(array_from_iter(p.iter().map(|x| x.e), precision));
775        columns.push(array_from_iter(p.iter().map(|x| x.px), precision));
776        columns.push(array_from_iter(p.iter().map(|x| x.py), precision));
777        columns.push(array_from_iter(p.iter().map(|x| x.pz), precision));
778    }
779
780    for col in 0..batch.schema().n_scalars() {
781        columns.push(array_from_iter(
782            batch.scalar_column(col).iter().copied(),
783            precision,
784        ));
785    }
786
787    let should_write_weight =
788        matches!(write_weight, WriteWeightColumn::Always) || batch.schema().has_weight();
789
790    if should_write_weight {
791        columns.push(array_from_iter(
792            (0..batch.len()).map(|i| batch.weights_at(i)),
793            precision,
794        ));
795    }
796
797    RecordBatch::try_new(arrow_schema, columns).map_err(|e| LadduDataError::Sink(e.to_string()))
798}
799
800#[cfg(test)]
801mod tests {
802    use arrow::array::{Float32Array, Float64Array};
803
804    use super::*;
805    use crate::data::{Dataset, EventBatchBuilder};
806
807    fn temp_path(ext: &str) -> PathBuf {
808        let nanos = std::time::SystemTime::now()
809            .duration_since(std::time::UNIX_EPOCH)
810            .unwrap()
811            .as_nanos();
812
813        std::env::temp_dir().join(format!(
814            "laddu-parquet-test-{}-{nanos}.{ext}",
815            std::process::id()
816        ))
817    }
818
819    fn v(x: f64) -> RealVec4 {
820        RealVec4 {
821            e: x + 0.3,
822            px: x,
823            py: x + 0.1,
824            pz: x + 0.2,
825        }
826    }
827
828    fn schema() -> Arc<Schema> {
829        Arc::new(Schema::new(["p"], ["mass"], true).unwrap())
830    }
831
832    fn batch() -> EventBatch {
833        let schema = schema();
834        let mut builder = EventBatchBuilder::new(schema);
835
836        for i in 0..4 {
837            builder
838                .push_weighted([v(i as f64)], [100.0 + i as f64], 10.0 + i as f64)
839                .unwrap();
840        }
841
842        builder.finish().unwrap()
843    }
844
845    #[test]
846    fn parquet_sink_and_source_roundtrip_with_f32_write_and_schema_inference() {
847        let path = temp_path("parquet");
848        let batch = batch();
849
850        let mut sink = ParquetSink::builder(path.clone())
851            .precision(Precision::F32)
852            .build();
853
854        sink.begin(Arc::clone(batch.schema()), WritePlan::default())
855            .unwrap();
856        sink.write_batch(&batch).unwrap();
857        sink.finish().unwrap();
858
859        let source = ParquetSource::builder(path.to_str().unwrap())
860            .infer_schema(true)
861            .validate_all_files(true)
862            .build()
863            .unwrap();
864
865        let inferred = source.schema().unwrap();
866        assert_eq!(
867            inferred
868                .p4s()
869                .iter()
870                .map(|n| n.to_string())
871                .collect::<Vec<_>>(),
872            vec!["p"]
873        );
874        assert_eq!(
875            inferred
876                .scalars()
877                .iter()
878                .map(|n| n.to_string())
879                .collect::<Vec<_>>(),
880            vec!["mass"]
881        );
882        assert!(inferred.has_weight());
883
884        let read_batches: Vec<EventBatch> = source
885            .batches(ReadPlan {
886                chunk_size: Some(2),
887                #[cfg(feature = "mpi")]
888                distribution: Default::default(),
889            })
890            .unwrap()
891            .map(Result::unwrap)
892            .collect();
893
894        assert_eq!(
895            read_batches.iter().map(EventBatch::len).collect::<Vec<_>>(),
896            vec![2, 2]
897        );
898
899        let read = EventBatch::concat(&read_batches).unwrap();
900
901        assert_eq!(read.scalar_column(0), &[100.0, 101.0, 102.0, 103.0]);
902        assert_eq!(read.weights_column().unwrap(), &[10.0, 11.0, 12.0, 13.0]);
903        assert!((read.p4_at(0, 2).e - 2.3).abs() < 1.0e-6);
904
905        let _ = std::fs::remove_file(path);
906    }
907
908    #[test]
909    fn parquet_source_require_weight_fails_when_written_without_weight_column() {
910        let path = temp_path("parquet");
911        let schema = Arc::new(Schema::new(["p"], ["mass"], false).unwrap());
912
913        let mut builder = EventBatchBuilder::new(Arc::clone(&schema));
914        builder.push([v(1.0)], [5.0]).unwrap();
915        let batch = builder.finish().unwrap();
916
917        let mut sink = ParquetSink::builder(path.clone())
918            .write_weight_column(WriteWeightColumn::OnlyIfPresent)
919            .build();
920
921        sink.begin(schema, WritePlan::default()).unwrap();
922        sink.write_batch(&batch).unwrap();
923        sink.finish().unwrap();
924
925        let err = ParquetSource::builder(path.to_str().unwrap())
926            .require_weight(true)
927            .build()
928            .unwrap_err();
929
930        assert!(matches!(err, LadduDataError::MissingColumn(name) if name.as_ref() == "weight"));
931
932        let _ = std::fs::remove_file(path);
933    }
934
935    #[test]
936    fn record_batch_to_event_batch_handles_nulls_as_error_or_nan_and_reads_f32_as_f64() {
937        let arrow_schema = Arc::new(ArrowSchema::new(vec![
938            Field::new("p_e", DataType::Float64, true),
939            Field::new("p_px", DataType::Float32, true),
940            Field::new("p_py", DataType::Float64, true),
941            Field::new("p_pz", DataType::Float32, true),
942            Field::new("mass", DataType::Float32, true),
943            Field::new("weight", DataType::Float64, true),
944        ]));
945
946        let rb = RecordBatch::try_new(
947            Arc::clone(&arrow_schema),
948            vec![
949                Arc::new(Float64Array::from(vec![Some(1.0), None])) as ArrayRef,
950                Arc::new(Float32Array::from(vec![Some(0.1), Some(0.2)])) as ArrayRef,
951                Arc::new(Float64Array::from(vec![Some(0.3), Some(0.4)])) as ArrayRef,
952                Arc::new(Float32Array::from(vec![Some(0.5), Some(0.6)])) as ArrayRef,
953                Arc::new(Float32Array::from(vec![Some(2.0), Some(3.0)])) as ArrayRef,
954                Arc::new(Float64Array::from(vec![Some(4.0), Some(5.0)])) as ArrayRef,
955            ],
956        )
957        .unwrap();
958
959        let schema = schema();
960
961        let error_options = ParquetReadOptions {
962            null_handling: NullHandling::Error,
963            ..ParquetReadOptions::default()
964        };
965
966        let err = record_batch_to_event_batch(rb.clone(), Arc::clone(&schema), &error_options)
967            .unwrap_err();
968
969        assert!(matches!(err, LadduDataError::Source(msg) if msg.contains("null in column p_e")));
970
971        let nan_options = ParquetReadOptions {
972            null_handling: NullHandling::NaN,
973            ..ParquetReadOptions::default()
974        };
975
976        let batch = record_batch_to_event_batch(rb, schema, &nan_options).unwrap();
977
978        assert!(batch.p4_at(0, 1).e.is_nan());
979        assert!((batch.p4_at(0, 1).px - 0.2).abs() < 1.0e-6);
980        assert_eq!(batch.scalar_column(0), &[2.0, 3.0]);
981        assert_eq!(batch.weights_column().unwrap(), &[4.0, 5.0]);
982    }
983
984    #[test]
985    fn parquet_sink_rejects_batches_with_different_schema() {
986        let path = temp_path("parquet");
987        let batch = batch();
988
989        let mut sink = ParquetSink::builder(path.clone()).build();
990        sink.begin(Arc::clone(batch.schema()), WritePlan::default())
991            .unwrap();
992
993        let other_schema = Arc::new(Schema::new(["q"], ["mass"], true).unwrap());
994        let mut builder = EventBatchBuilder::new(other_schema);
995        builder.push_weighted([v(1.0)], [1.0], 1.0).unwrap();
996        let other = builder.finish().unwrap();
997
998        let err = sink.write_batch(&other).unwrap_err();
999        assert!(matches!(err, LadduDataError::Sink(msg) if msg.contains("schema")));
1000
1001        sink.finish().unwrap();
1002        let _ = std::fs::remove_file(path);
1003    }
1004
1005    #[test]
1006    fn dataset_write_to_parquet_applies_dataset_transformations_before_writing() {
1007        let path = temp_path("parquet");
1008
1009        let dataset = Dataset::from_batch(batch()).filter(|ev| ev.scalar(0) >= 102.0);
1010
1011        let mut sink = ParquetSink::builder(path.clone()).build();
1012        dataset.write_to(&mut sink).unwrap();
1013
1014        let source = ParquetSource::open(path.to_str().unwrap()).unwrap();
1015        let read = EventBatch::concat(
1016            &source
1017                .batches(ReadPlan::default())
1018                .unwrap()
1019                .map(Result::unwrap)
1020                .collect::<Vec<_>>(),
1021        )
1022        .unwrap();
1023
1024        assert_eq!(read.scalar_column(0), &[102.0, 103.0]);
1025        assert_eq!(read.weights_column().unwrap(), &[12.0, 13.0]);
1026
1027        let _ = std::fs::remove_file(path);
1028    }
1029}