1#[cfg(feature = "formats")]
34use crate::error::{DatasetsError, Result};
35#[cfg(feature = "formats")]
36use crate::utils::Dataset;
37#[cfg(feature = "formats")]
38use scirs2_core::ndarray::{Array1, Array2};
39#[cfg(feature = "formats")]
40use std::path::Path;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum FormatType {
45 Parquet,
47 Arrow,
49 Hdf5,
51 Csv,
53}
54
55impl FormatType {
56 pub fn from_extension(path: &str) -> Option<Self> {
58 let lower = path.to_lowercase();
59 if lower.ends_with(".parquet") || lower.ends_with(".pq") {
60 Some(FormatType::Parquet)
61 } else if lower.ends_with(".arrow") {
62 Some(FormatType::Arrow)
63 } else if lower.ends_with(".h5") || lower.ends_with(".hdf5") {
64 Some(FormatType::Hdf5)
65 } else if lower.ends_with(".csv") {
66 Some(FormatType::Csv)
67 } else {
68 None
69 }
70 }
71
72 pub fn extension(&self) -> &'static str {
74 match self {
75 FormatType::Parquet => "parquet",
76 FormatType::Arrow => "arrow",
77 FormatType::Hdf5 => "h5",
78 FormatType::Csv => "csv",
79 }
80 }
81}
82
83#[derive(Debug, Clone)]
85pub struct FormatConfig {
86 pub chunk_size: usize,
88 pub compression: Option<CompressionCodec>,
90 pub use_mmap: bool,
92 pub buffer_size: usize,
94}
95
96impl Default for FormatConfig {
97 fn default() -> Self {
98 Self {
99 chunk_size: 10_000,
100 compression: Some(CompressionCodec::Snappy),
101 use_mmap: true,
102 buffer_size: 8 * 1024 * 1024, }
104 }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum CompressionCodec {
110 None,
112 Snappy,
114 Gzip,
116 Lz4,
118 Zstd,
120}
121
122impl CompressionCodec {
123 pub fn level(&self) -> Option<i32> {
125 match self {
126 CompressionCodec::None | CompressionCodec::Snappy | CompressionCodec::Lz4 => None,
127 CompressionCodec::Gzip => Some(6), CompressionCodec::Zstd => Some(3), }
130 }
131}
132
133#[cfg(feature = "formats")]
139const PARQUET_TARGET_COLUMN: &str = "__target__";
140
141#[cfg(feature = "formats")]
144fn feature_column_names(dataset: &Dataset) -> Vec<String> {
145 let n = dataset.n_features();
146 match &dataset.featurenames {
147 Some(names) if names.len() == n => names.clone(),
148 _ => (0..n).map(|i| format!("feature_{i}")).collect(),
149 }
150}
151
152#[cfg(feature = "formats")]
157fn parquet_data_to_dataset(pdata: &scirs2_io::parquet::ParquetData) -> Result<Dataset> {
158 let all_columns = pdata.schema().column_names();
161 let n_rows = pdata.num_rows();
162
163 let feat_names: Vec<String> = all_columns
165 .iter()
166 .filter(|n| n.as_str() != PARQUET_TARGET_COLUMN)
167 .cloned()
168 .collect();
169
170 if feat_names.is_empty() {
171 return Err(DatasetsError::InvalidFormat(
172 "Parquet file contains no feature columns (only '__target__' found)".to_string(),
173 ));
174 }
175
176 let n_features = feat_names.len();
177
178 let mut flat: Vec<f64> = Vec::with_capacity(n_rows * n_features);
180 for col_name in &feat_names {
181 let col = pdata.get_column_f64(col_name).map_err(|e| {
182 DatasetsError::InvalidFormat(format!(
183 "Failed to read feature column '{}': {}",
184 col_name, e
185 ))
186 })?;
187 flat.extend(col.iter());
188 }
189
190 let column_major = Array2::from_shape_vec((n_features, n_rows), flat).map_err(|e| {
193 DatasetsError::InvalidFormat(format!("Failed to shape feature matrix: {e}"))
194 })?;
195 let data = column_major.t().to_owned();
196
197 let target: Option<Array1<f64>> = if all_columns
199 .iter()
200 .any(|n| n.as_str() == PARQUET_TARGET_COLUMN)
201 {
202 let col = pdata.get_column_f64(PARQUET_TARGET_COLUMN).map_err(|e| {
203 DatasetsError::InvalidFormat(format!("Failed to read target column: {e}"))
204 })?;
205 Some(Array1::from_vec(col.to_vec()))
206 } else {
207 None
208 };
209
210 let mut ds = Dataset::new(data, target);
211 ds.featurenames = Some(feat_names);
212 Ok(ds)
213}
214
215#[cfg(feature = "formats")]
220fn write_dataset_parquet<P: AsRef<Path>>(dataset: &Dataset, path: P) -> Result<()> {
221 use arrow::array::Float64Array;
222 use arrow::datatypes::{DataType, Field, Schema};
223 use arrow::record_batch::RecordBatch;
224 use scirs2_io::parquet::{ParquetWriteOptions, ParquetWriter as IoParquetWriter};
225 use std::sync::Arc;
226
227 let col_names = feature_column_names(dataset);
228 let n_rows = dataset.n_samples();
229 let n_feats = dataset.n_features();
230
231 let mut fields: Vec<Field> = col_names
233 .iter()
234 .map(|name| Field::new(name.as_str(), DataType::Float64, false))
235 .collect();
236 let has_target = dataset.target.is_some();
237 if has_target {
238 fields.push(Field::new(PARQUET_TARGET_COLUMN, DataType::Float64, false));
239 }
240 let schema = Arc::new(Schema::new(fields));
241
242 let mut arrays: Vec<Arc<dyn arrow::array::Array>> = Vec::with_capacity(n_feats + 1);
244 for col_idx in 0..n_feats {
245 let col_data: Vec<f64> = (0..n_rows)
246 .map(|row| dataset.data[[row, col_idx]])
247 .collect();
248 arrays.push(Arc::new(Float64Array::from(col_data)));
249 }
250 if let Some(target) = &dataset.target {
251 let tgt_data: Vec<f64> = target.to_vec();
252 arrays.push(Arc::new(Float64Array::from(tgt_data)));
253 }
254
255 let batch = RecordBatch::try_new(Arc::clone(&schema), arrays)
256 .map_err(|e| DatasetsError::InvalidFormat(format!("Failed to build RecordBatch: {e}")))?;
257
258 let options = ParquetWriteOptions::default();
260 let mut writer = IoParquetWriter::from_path(path, schema, options)
261 .map_err(|e| DatasetsError::InvalidFormat(format!("Parquet writer creation error: {e}")))?;
262
263 writer
264 .write_batch(&batch)
265 .map_err(|e| DatasetsError::InvalidFormat(format!("Parquet write error: {e}")))?;
266
267 writer
268 .close()
269 .map_err(|e| DatasetsError::InvalidFormat(format!("Parquet close error: {e}")))
270}
271
272#[cfg(feature = "formats")]
273pub struct ParquetReader {
275 config: FormatConfig,
276}
277
278#[cfg(feature = "formats")]
279impl ParquetReader {
280 pub fn new() -> Self {
282 Self {
283 config: FormatConfig::default(),
284 }
285 }
286
287 pub fn with_config(config: FormatConfig) -> Self {
289 Self { config }
290 }
291
292 pub fn read<P: AsRef<Path>>(&self, path: P) -> Result<Dataset> {
297 let pdata = scirs2_io::parquet::read_parquet(path)
298 .map_err(|e| DatasetsError::InvalidFormat(format!("Parquet read error: {e}")))?;
299 let _ = &self.config; parquet_data_to_dataset(&pdata)
301 }
302}
303
304#[cfg(feature = "formats")]
305impl Default for ParquetReader {
306 fn default() -> Self {
307 Self::new()
308 }
309}
310
311#[cfg(feature = "formats")]
312pub struct ParquetWriter {
314 config: FormatConfig,
315}
316
317#[cfg(feature = "formats")]
318impl ParquetWriter {
319 pub fn new() -> Self {
321 Self {
322 config: FormatConfig::default(),
323 }
324 }
325
326 pub fn with_config(config: FormatConfig) -> Self {
328 Self { config }
329 }
330
331 pub fn write<P: AsRef<Path>>(&self, dataset: &Dataset, path: P) -> Result<()> {
336 let _ = &self.config; write_dataset_parquet(dataset, path)
338 }
339}
340
341#[cfg(feature = "formats")]
342impl Default for ParquetWriter {
343 fn default() -> Self {
344 Self::new()
345 }
346}
347
348#[cfg(feature = "formats")]
354fn hdf5_err(msg: impl std::fmt::Display) -> DatasetsError {
355 DatasetsError::InvalidFormat(format!("HDF5 error: {msg}"))
356}
357
358#[cfg(feature = "formats")]
363fn read_dataset_hdf5<P: AsRef<Path>>(path: P, dataset_name: &str) -> Result<Dataset> {
364 use scirs2_io::hdf5::read_hdf5;
365
366 let root = read_hdf5(path).map_err(hdf5_err)?;
367
368 let ds = root.datasets.get(dataset_name).ok_or_else(|| {
370 DatasetsError::InvalidFormat(format!("Dataset '{}' not found in HDF5 file", dataset_name))
371 })?;
372
373 let shape = &ds.shape;
374 if shape.len() != 2 {
375 return Err(DatasetsError::InvalidFormat(format!(
376 "Expected 2-D dataset for '{}', got {}-D",
377 dataset_name,
378 shape.len()
379 )));
380 }
381 let n_rows = shape[0];
382 let n_cols = shape[1];
383
384 let float_data = ds.as_float_vec().ok_or_else(|| {
385 DatasetsError::InvalidFormat(format!(
386 "Dataset '{}' contains non-numeric data",
387 dataset_name
388 ))
389 })?;
390
391 let data = Array2::from_shape_vec((n_rows, n_cols), float_data).map_err(|e| {
392 DatasetsError::InvalidFormat(format!("Failed to shape feature matrix: {e}"))
393 })?;
394
395 let target_name = format!("{}_target", dataset_name);
397 let target: Option<Array1<f64>> = if let Some(tds) = root.datasets.get(&target_name) {
398 let tvec = tds.as_float_vec().ok_or_else(|| {
399 DatasetsError::InvalidFormat(format!(
400 "Target dataset '{}' contains non-numeric data",
401 target_name
402 ))
403 })?;
404 Some(Array1::from_vec(tvec))
405 } else {
406 None
407 };
408
409 Ok(Dataset::new(data, target))
410}
411
412#[cfg(feature = "formats")]
417fn write_dataset_hdf5<P: AsRef<Path>>(
418 dataset: &Dataset,
419 path: P,
420 dataset_name: &str,
421) -> Result<()> {
422 use scirs2_core::ndarray::IxDyn;
423 use scirs2_io::hdf5::write_hdf5;
424 use std::collections::HashMap;
425
426 let mut map: HashMap<String, scirs2_core::ndarray::ArrayD<f64>> = HashMap::new();
427
428 let n_rows = dataset.n_samples();
430 let n_cols = dataset.n_features();
431 let flat: Vec<f64> = dataset.data.iter().cloned().collect();
432 let arr_dyn = scirs2_core::ndarray::ArrayD::from_shape_vec(IxDyn(&[n_rows, n_cols]), flat)
433 .map_err(|e| {
434 DatasetsError::InvalidFormat(format!("Failed to convert data to ArrayD: {e}"))
435 })?;
436 map.insert(dataset_name.to_string(), arr_dyn);
437
438 if let Some(target) = &dataset.target {
440 let tvec: Vec<f64> = target.to_vec();
441 let tlen = tvec.len();
442 let tarr =
443 scirs2_core::ndarray::ArrayD::from_shape_vec(IxDyn(&[tlen]), tvec).map_err(|e| {
444 DatasetsError::InvalidFormat(format!("Failed to convert target to ArrayD: {e}"))
445 })?;
446 map.insert(format!("{}_target", dataset_name), tarr);
447 }
448
449 write_hdf5(path, map).map_err(hdf5_err)
450}
451
452#[cfg(feature = "formats")]
453pub struct Hdf5Reader {
455 config: FormatConfig,
456}
457
458#[cfg(feature = "formats")]
459impl Hdf5Reader {
460 pub fn new() -> Self {
462 Self {
463 config: FormatConfig::default(),
464 }
465 }
466
467 pub fn with_config(config: FormatConfig) -> Self {
469 Self { config }
470 }
471
472 pub fn read<P: AsRef<Path>>(&self, path: P, dataset_name: &str) -> Result<Dataset> {
478 let _ = &self.config;
479 read_dataset_hdf5(path, dataset_name)
480 }
481}
482
483#[cfg(feature = "formats")]
484impl Default for Hdf5Reader {
485 fn default() -> Self {
486 Self::new()
487 }
488}
489
490#[cfg(feature = "formats")]
491pub struct Hdf5Writer {
493 config: FormatConfig,
494}
495
496#[cfg(feature = "formats")]
497impl Hdf5Writer {
498 pub fn new() -> Self {
500 Self {
501 config: FormatConfig::default(),
502 }
503 }
504
505 pub fn with_config(config: FormatConfig) -> Self {
507 Self { config }
508 }
509
510 pub fn write<P: AsRef<Path>>(
515 &self,
516 dataset: &Dataset,
517 path: P,
518 dataset_name: &str,
519 ) -> Result<()> {
520 let _ = &self.config;
521 write_dataset_hdf5(dataset, path, dataset_name)
522 }
523}
524
525#[cfg(feature = "formats")]
526impl Default for Hdf5Writer {
527 fn default() -> Self {
528 Self::new()
529 }
530}
531
532#[derive(Debug, Clone)]
538pub struct CsvConfig {
539 pub has_header: bool,
542 pub delimiter: char,
544 pub float_precision: usize,
547}
548
549impl Default for CsvConfig {
550 fn default() -> Self {
551 Self {
552 has_header: true,
553 delimiter: ',',
554 float_precision: 17,
555 }
556 }
557}
558
559fn commit_row(
572 current_field: &mut String,
573 current_row: &mut Vec<String>,
574 out_headers: &mut Vec<String>,
575 out_rows: &mut Vec<Vec<String>>,
576 has_header: bool,
577 first_row: bool,
578) -> bool {
579 let last_field = current_field.clone();
581 current_field.clear();
582 current_row.push(last_field);
583
584 let is_comment = current_row
585 .first()
586 .map(|f| f.trim_start().starts_with('#'))
587 .unwrap_or(false);
588
589 let is_blank = current_row.iter().all(|f| f.is_empty());
590
591 let new_first_row = if !is_blank && !is_comment {
592 if first_row && has_header {
593 *out_headers = current_row.clone();
594 false
595 } else {
596 out_rows.push(current_row.clone());
597 false
598 }
599 } else {
600 first_row
601 };
602
603 current_row.clear();
604 new_first_row
605}
606
607pub(crate) fn parse_csv_text(
617 text: &str,
618 has_header: bool,
619 delimiter: char,
620) -> (Vec<String>, Vec<Vec<String>>) {
621 let mut out_headers: Vec<String> = Vec::new();
624 let mut out_rows: Vec<Vec<String>> = Vec::new();
625
626 let mut current_field = String::new();
627 let mut current_row: Vec<String> = Vec::new();
628 let mut in_quotes = false;
629 let mut first_row = true;
632
633 let chars: Vec<char> = text.chars().collect();
634 let len = chars.len();
635 let mut i = 0;
636
637 while i < len {
638 let ch = chars[i];
639
640 if in_quotes {
641 if ch == '"' {
642 if i + 1 < len && chars[i + 1] == '"' {
643 current_field.push('"');
645 i += 2;
646 } else {
647 in_quotes = false;
649 i += 1;
650 }
651 } else {
652 current_field.push(ch);
654 i += 1;
655 }
656 } else {
657 if ch == '"' {
658 in_quotes = true;
659 i += 1;
660 } else if ch == delimiter {
661 current_row.push(current_field.clone());
662 current_field.clear();
663 i += 1;
664 } else if ch == '\n' {
665 first_row = commit_row(
666 &mut current_field,
667 &mut current_row,
668 &mut out_headers,
669 &mut out_rows,
670 has_header,
671 first_row,
672 );
673 i += 1;
674 } else if ch == '\r' {
675 i += 1;
677 } else {
678 current_field.push(ch);
679 i += 1;
680 }
681 }
682 }
683
684 if !current_row.is_empty() || !current_field.is_empty() {
686 commit_row(
687 &mut current_field,
688 &mut current_row,
689 &mut out_headers,
690 &mut out_rows,
691 has_header,
692 first_row,
693 );
694 }
695
696 (out_headers, out_rows)
697}
698
699pub(crate) fn csv_encode_field(field: &str, delimiter: char) -> String {
704 let needs_quoting = field.contains(delimiter)
705 || field.contains('"')
706 || field.contains('\n')
707 || field.contains('\r');
708
709 if needs_quoting {
710 let escaped = field.replace('"', "\"\"");
711 format!("\"{escaped}\"")
712 } else {
713 field.to_owned()
714 }
715}
716
717pub(crate) fn format_csv_rows(headers: &[String], rows: &[Vec<String>], delimiter: char) -> String {
722 let mut out = String::new();
723
724 if !headers.is_empty() {
725 let encoded: Vec<String> = headers
726 .iter()
727 .map(|h| csv_encode_field(h, delimiter))
728 .collect();
729 out.push_str(&encoded.join(&delimiter.to_string()));
730 out.push('\n');
731 }
732
733 for row in rows {
734 let encoded: Vec<String> = row.iter().map(|f| csv_encode_field(f, delimiter)).collect();
735 out.push_str(&encoded.join(&delimiter.to_string()));
736 out.push('\n');
737 }
738
739 out
740}
741
742#[cfg(feature = "formats")]
750fn read_dataset_csv<P: AsRef<Path>>(path: P, config: &CsvConfig) -> Result<Dataset> {
751 use std::fs;
752
753 let text = fs::read_to_string(path)
754 .map_err(|e| DatasetsError::InvalidFormat(format!("CSV read error: {e}")))?;
755
756 let (headers, rows) = parse_csv_text(&text, config.has_header, config.delimiter);
757
758 let n_cols = if !headers.is_empty() {
762 headers.len()
763 } else if let Some(first) = rows.first() {
764 first.len()
765 } else {
766 return Err(DatasetsError::InvalidFormat(
768 "CSV file is empty or contains only comments".to_string(),
769 ));
770 };
771
772 let col_names: Vec<String> = if !headers.is_empty() {
774 headers.clone()
775 } else {
776 (0..n_cols).map(|i| format!("feature_{i}")).collect()
777 };
778
779 let target_col_idx: Option<usize> = col_names.iter().position(|n| n == PARQUET_TARGET_COLUMN);
780
781 let feat_indices: Vec<usize> = (0..n_cols).filter(|&i| Some(i) != target_col_idx).collect();
782
783 let feat_names: Vec<String> = feat_indices.iter().map(|&i| col_names[i].clone()).collect();
784
785 let n_features = feat_indices.len();
786 let n_rows = rows.len();
787
788 let mut flat: Vec<f64> = Vec::with_capacity(n_rows * n_features);
791 let mut target_vals: Vec<f64> = Vec::with_capacity(n_rows);
792
793 for (row_idx, row) in rows.iter().enumerate() {
794 if row.len() != n_cols {
796 return Err(DatasetsError::InvalidFormat(format!(
797 "CSV row {} has {} fields, expected {}",
798 row_idx + 1,
799 row.len(),
800 n_cols
801 )));
802 }
803
804 for &col_idx in &feat_indices {
806 let s = row[col_idx].trim();
807 let v = s.parse::<f64>().map_err(|e| {
808 DatasetsError::InvalidFormat(format!(
809 "CSV cell at row {}, col {} is not numeric ('{s}'): {e}",
810 row_idx + 1,
811 col_idx
812 ))
813 })?;
814 flat.push(v);
815 }
816
817 if let Some(t_idx) = target_col_idx {
819 let s = row[t_idx].trim();
820 let v = s.parse::<f64>().map_err(|e| {
821 DatasetsError::InvalidFormat(format!(
822 "CSV target cell at row {} is not numeric ('{s}'): {e}",
823 row_idx + 1,
824 ))
825 })?;
826 target_vals.push(v);
827 }
828 }
829
830 let data = if n_rows == 0 {
831 Array2::zeros((0, n_features))
832 } else {
833 Array2::from_shape_vec((n_rows, n_features), flat).map_err(|e| {
834 DatasetsError::InvalidFormat(format!("Failed to shape CSV feature matrix: {e}"))
835 })?
836 };
837
838 let target: Option<Array1<f64>> = if target_col_idx.is_some() && !target_vals.is_empty() {
839 Some(Array1::from_vec(target_vals))
840 } else {
841 None
842 };
843
844 let mut ds = Dataset::new(data, target);
845 ds.featurenames = Some(feat_names);
846 Ok(ds)
847}
848
849#[cfg(feature = "formats")]
854fn write_dataset_csv<P: AsRef<Path>>(dataset: &Dataset, path: P, config: &CsvConfig) -> Result<()> {
855 use std::fs;
856 use std::io::Write;
857
858 let col_names = feature_column_names(dataset);
859 let n_rows = dataset.n_samples();
860 let n_feats = dataset.n_features();
861 let has_target = dataset.target.is_some();
862 let prec = config.float_precision;
863 let delim = config.delimiter;
864
865 let mut headers: Vec<String> = col_names;
867 if has_target {
868 headers.push(PARQUET_TARGET_COLUMN.to_string());
869 }
870
871 let mut data_rows: Vec<Vec<String>> = Vec::with_capacity(n_rows);
873 for row_idx in 0..n_rows {
874 let mut fields: Vec<String> = (0..n_feats)
875 .map(|col_idx| format!("{:.prec$e}", dataset.data[[row_idx, col_idx]], prec = prec))
876 .collect();
877 if let Some(target) = &dataset.target {
878 fields.push(format!("{:.prec$e}", target[row_idx], prec = prec));
879 }
880 data_rows.push(fields);
881 }
882
883 let csv_text = format_csv_rows(&headers, &data_rows, delim);
884
885 let mut file = fs::File::create(path)
886 .map_err(|e| DatasetsError::InvalidFormat(format!("CSV create error: {e}")))?;
887 file.write_all(csv_text.as_bytes())
888 .map_err(|e| DatasetsError::InvalidFormat(format!("CSV write error: {e}")))?;
889 file.flush()
890 .map_err(|e| DatasetsError::InvalidFormat(format!("CSV flush error: {e}")))?;
891
892 Ok(())
893}
894
895#[cfg(feature = "formats")]
897pub struct CsvReader {
898 config: CsvConfig,
899}
900
901#[cfg(feature = "formats")]
902impl CsvReader {
903 pub fn new() -> Self {
905 Self {
906 config: CsvConfig::default(),
907 }
908 }
909
910 pub fn with_config(config: CsvConfig) -> Self {
912 Self { config }
913 }
914
915 pub fn read<P: AsRef<Path>>(&self, path: P) -> Result<Dataset> {
917 read_dataset_csv(path, &self.config)
918 }
919}
920
921#[cfg(feature = "formats")]
922impl Default for CsvReader {
923 fn default() -> Self {
924 Self::new()
925 }
926}
927
928#[cfg(feature = "formats")]
930pub struct CsvWriter {
931 config: CsvConfig,
932}
933
934#[cfg(feature = "formats")]
935impl CsvWriter {
936 pub fn new() -> Self {
938 Self {
939 config: CsvConfig::default(),
940 }
941 }
942
943 pub fn with_config(config: CsvConfig) -> Self {
945 Self { config }
946 }
947
948 pub fn write<P: AsRef<Path>>(&self, dataset: &Dataset, path: P) -> Result<()> {
950 write_dataset_csv(dataset, path, &self.config)
951 }
952}
953
954#[cfg(feature = "formats")]
955impl Default for CsvWriter {
956 fn default() -> Self {
957 Self::new()
958 }
959}
960
961#[cfg(feature = "formats")]
966pub struct FormatConverter {
968 config: FormatConfig,
969}
970
971#[cfg(feature = "formats")]
972impl FormatConverter {
973 pub fn new() -> Self {
975 Self {
976 config: FormatConfig::default(),
977 }
978 }
979
980 pub fn convert<P1: AsRef<Path>, P2: AsRef<Path>>(
982 &self,
983 input_path: P1,
984 input_format: FormatType,
985 output_path: P2,
986 output_format: FormatType,
987 ) -> Result<()> {
988 let dataset = match input_format {
990 FormatType::Parquet => ParquetReader::new().read(input_path)?,
991 FormatType::Hdf5 => Hdf5Reader::new().read(input_path, "data")?,
992 FormatType::Csv => CsvReader::new().read(input_path)?,
993 FormatType::Arrow => {
994 return Err(DatasetsError::InvalidFormat(
995 "Arrow format not yet supported".to_string(),
996 ))
997 }
998 };
999
1000 match output_format {
1002 FormatType::Parquet => ParquetWriter::new().write(&dataset, output_path)?,
1003 FormatType::Hdf5 => Hdf5Writer::new().write(&dataset, output_path, "data")?,
1004 FormatType::Csv => CsvWriter::new().write(&dataset, output_path)?,
1005 FormatType::Arrow => {
1006 return Err(DatasetsError::InvalidFormat(
1007 "Arrow format not yet supported".to_string(),
1008 ))
1009 }
1010 }
1011
1012 Ok(())
1013 }
1014
1015 pub fn read_auto<P: AsRef<Path>>(&self, path: P) -> Result<Dataset> {
1017 let path_str = path
1018 .as_ref()
1019 .to_str()
1020 .ok_or_else(|| DatasetsError::InvalidFormat("Invalid path".to_string()))?;
1021
1022 let format = FormatType::from_extension(path_str)
1023 .ok_or_else(|| DatasetsError::InvalidFormat("Could not detect format".to_string()))?;
1024
1025 match format {
1026 FormatType::Parquet => ParquetReader::new().read(path),
1027 FormatType::Hdf5 => Hdf5Reader::new().read(path, "data"),
1028 FormatType::Csv => CsvReader::new().read(path),
1029 FormatType::Arrow => Err(DatasetsError::InvalidFormat(format!(
1030 "Unsupported format: {:?}",
1031 format
1032 ))),
1033 }
1034 }
1035}
1036
1037#[cfg(feature = "formats")]
1038impl Default for FormatConverter {
1039 fn default() -> Self {
1040 Self::new()
1041 }
1042}
1043
1044#[cfg(feature = "formats")]
1050pub fn read_parquet<P: AsRef<Path>>(path: P) -> Result<Dataset> {
1051 ParquetReader::new().read(path)
1052}
1053
1054#[cfg(feature = "formats")]
1056pub fn write_parquet<P: AsRef<Path>>(dataset: &Dataset, path: P) -> Result<()> {
1057 ParquetWriter::new().write(dataset, path)
1058}
1059
1060#[cfg(feature = "formats")]
1062pub fn read_hdf5<P: AsRef<Path>>(path: P, dataset_name: &str) -> Result<Dataset> {
1063 Hdf5Reader::new().read(path, dataset_name)
1064}
1065
1066#[cfg(feature = "formats")]
1068pub fn write_hdf5<P: AsRef<Path>>(dataset: &Dataset, path: P, dataset_name: &str) -> Result<()> {
1069 Hdf5Writer::new().write(dataset, path, dataset_name)
1070}
1071
1072#[cfg(feature = "formats")]
1074pub fn read_auto<P: AsRef<Path>>(path: P) -> Result<Dataset> {
1075 FormatConverter::new().read_auto(path)
1076}
1077
1078#[cfg(feature = "formats")]
1080pub fn read_csv<P: AsRef<Path>>(path: P) -> Result<Dataset> {
1081 CsvReader::new().read(path)
1082}
1083
1084#[cfg(feature = "formats")]
1086pub fn write_csv<P: AsRef<Path>>(dataset: &Dataset, path: P) -> Result<()> {
1087 CsvWriter::new().write(dataset, path)
1088}
1089
1090#[cfg(test)]
1091mod tests {
1092 use super::*;
1093
1094 #[test]
1095 fn test_format_detection() {
1096 assert_eq!(
1097 FormatType::from_extension("data.parquet"),
1098 Some(FormatType::Parquet)
1099 );
1100 assert_eq!(
1101 FormatType::from_extension("data.h5"),
1102 Some(FormatType::Hdf5)
1103 );
1104 assert_eq!(
1105 FormatType::from_extension("data.csv"),
1106 Some(FormatType::Csv)
1107 );
1108 assert_eq!(FormatType::from_extension("data.txt"), None);
1109 }
1110
1111 #[test]
1112 fn test_format_extension() {
1113 assert_eq!(FormatType::Parquet.extension(), "parquet");
1114 assert_eq!(FormatType::Hdf5.extension(), "h5");
1115 assert_eq!(FormatType::Csv.extension(), "csv");
1116 }
1117
1118 #[test]
1119 fn test_compression_codec() {
1120 assert_eq!(CompressionCodec::None.level(), None);
1121 assert_eq!(CompressionCodec::Snappy.level(), None);
1122 assert_eq!(CompressionCodec::Gzip.level(), Some(6));
1123 assert_eq!(CompressionCodec::Zstd.level(), Some(3));
1124 }
1125
1126 #[test]
1127 fn test_format_config() {
1128 let config = FormatConfig::default();
1129 assert_eq!(config.chunk_size, 10_000);
1130 assert_eq!(config.compression, Some(CompressionCodec::Snappy));
1131 assert!(config.use_mmap);
1132 }
1133
1134 #[cfg(feature = "formats")]
1140 #[test]
1141 fn test_parquet_roundtrip_no_target() {
1142 use scirs2_core::ndarray::Array2;
1143 let data = Array2::from_shape_vec(
1144 (4, 3),
1145 vec![
1146 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0,
1147 ],
1148 )
1149 .expect("shape");
1150 let ds = Dataset::new(data.clone(), None);
1151
1152 let mut tmp = std::env::temp_dir();
1153 tmp.push("scirs2_test_parquet_roundtrip_no_target.parquet");
1154
1155 write_parquet(&ds, &tmp).expect("parquet write");
1156 let recovered = read_parquet(&tmp).expect("parquet read");
1157
1158 assert_eq!(recovered.n_samples(), 4, "n_samples mismatch");
1159 assert_eq!(recovered.n_features(), 3, "n_features mismatch");
1160 assert!(recovered.target.is_none(), "unexpected target");
1161
1162 for row in 0..4 {
1164 for col in 0..3 {
1165 let expected = data[[row, col]];
1166 let actual = recovered.data[[row, col]];
1167 assert!(
1168 (expected - actual).abs() < 1e-10,
1169 "mismatch at [{row},{col}]: expected {expected}, got {actual}"
1170 );
1171 }
1172 }
1173
1174 let _ = std::fs::remove_file(&tmp);
1175 }
1176
1177 #[cfg(feature = "formats")]
1179 #[test]
1180 fn test_parquet_roundtrip_with_target() {
1181 use scirs2_core::ndarray::{Array1, Array2};
1182 let data =
1183 Array2::from_shape_vec((3, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).expect("shape");
1184 let target = Some(Array1::from_vec(vec![0.0, 1.0, 0.0]));
1185 let ds = Dataset::new(data.clone(), target.clone());
1186
1187 let mut tmp = std::env::temp_dir();
1188 tmp.push("scirs2_test_parquet_roundtrip_with_target.parquet");
1189
1190 write_parquet(&ds, &tmp).expect("parquet write");
1191 let recovered = read_parquet(&tmp).expect("parquet read");
1192
1193 assert_eq!(recovered.n_samples(), 3);
1194 assert_eq!(recovered.n_features(), 2);
1195 assert!(
1196 recovered.target.is_some(),
1197 "target missing after round-trip"
1198 );
1199
1200 let rtarget = recovered.target.as_ref().expect("target");
1201 assert_eq!(rtarget.len(), 3);
1202 for (i, (&expected, &actual)) in target
1203 .as_ref()
1204 .expect("t")
1205 .iter()
1206 .zip(rtarget.iter())
1207 .enumerate()
1208 {
1209 assert!(
1210 (expected - actual).abs() < 1e-10,
1211 "target mismatch at [{i}]: expected {expected}, got {actual}"
1212 );
1213 }
1214
1215 let _ = std::fs::remove_file(&tmp);
1216 }
1217
1218 #[cfg(feature = "formats")]
1220 #[test]
1221 fn test_parquet_roundtrip_feature_names() {
1222 use scirs2_core::ndarray::Array2;
1223 let data = Array2::from_shape_vec((2, 2), vec![10.0, 20.0, 30.0, 40.0]).expect("shape");
1224 let mut ds = Dataset::new(data, None);
1225 ds.featurenames = Some(vec!["alpha".to_string(), "beta".to_string()]);
1226
1227 let mut tmp = std::env::temp_dir();
1228 tmp.push("scirs2_test_parquet_feature_names.parquet");
1229
1230 write_parquet(&ds, &tmp).expect("parquet write");
1231 let recovered = read_parquet(&tmp).expect("parquet read");
1232
1233 let names = recovered.featurenames.as_ref().expect("featurenames");
1234 assert_eq!(names, &["alpha", "beta"]);
1235
1236 let _ = std::fs::remove_file(&tmp);
1237 }
1238
1239 #[cfg(feature = "formats")]
1245 #[test]
1246 fn test_hdf5_roundtrip_no_target() {
1247 use scirs2_core::ndarray::Array2;
1248 let data = Array2::from_shape_vec(
1249 (3, 4),
1250 vec![
1251 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0,
1252 ],
1253 )
1254 .expect("shape");
1255 let ds = Dataset::new(data.clone(), None);
1256
1257 let mut tmp = std::env::temp_dir();
1258 tmp.push("scirs2_test_hdf5_roundtrip_no_target.h5");
1259
1260 write_hdf5(&ds, &tmp, "mydata").expect("hdf5 write");
1261 let recovered = read_hdf5(&tmp, "mydata").expect("hdf5 read");
1262
1263 assert_eq!(recovered.n_samples(), 3, "n_samples mismatch");
1264 assert_eq!(recovered.n_features(), 4, "n_features mismatch");
1265 assert!(recovered.target.is_none());
1266
1267 for row in 0..3 {
1268 for col in 0..4 {
1269 let expected = data[[row, col]];
1270 let actual = recovered.data[[row, col]];
1271 assert!(
1272 (expected - actual).abs() < 1e-10,
1273 "mismatch [{row},{col}]: {expected} != {actual}"
1274 );
1275 }
1276 }
1277
1278 let _ = std::fs::remove_file(&tmp);
1279 let sidecar = format!("{}.json", tmp.to_string_lossy());
1281 let _ = std::fs::remove_file(&sidecar);
1282 }
1283
1284 #[test]
1290 fn test_csv_parse_roundtrip_strings() {
1291 let text = "a,b,c\n1,2,3\n4,5,6\n7,8,9\n";
1293 let (headers, rows) = parse_csv_text(text, true, ',');
1294 assert_eq!(headers, ["a", "b", "c"]);
1295 assert_eq!(rows.len(), 3);
1296 assert_eq!(rows[0], ["1", "2", "3"]);
1297 assert_eq!(rows[2], ["7", "8", "9"]);
1298 }
1299
1300 #[test]
1302 fn test_csv_quoted_field_with_comma() {
1303 let original_field = "hello, world";
1305 let headers = vec!["phrase".to_string(), "num".to_string()];
1306 let rows = vec![vec![original_field.to_string(), "42".to_string()]];
1307 let csv_text = format_csv_rows(&headers, &rows, ',');
1308
1309 assert!(csv_text.contains('"'), "expected quoting in: {csv_text:?}");
1311
1312 let (h2, r2) = parse_csv_text(&csv_text, true, ',');
1314 assert_eq!(h2, ["phrase", "num"]);
1315 assert_eq!(r2.len(), 1);
1316 assert_eq!(r2[0][0], original_field, "quoted field not preserved");
1317 assert_eq!(r2[0][1], "42");
1318 }
1319
1320 #[test]
1322 fn test_csv_skip_comments_and_blanks() {
1323 let text = "# file-level comment\n\nx,y\n# row comment\n1,2\n\n3,4\n";
1324 let (headers, rows) = parse_csv_text(text, true, ',');
1325 assert_eq!(headers, ["x", "y"]);
1326 assert_eq!(rows.len(), 2);
1327 assert_eq!(rows[0], ["1", "2"]);
1328 assert_eq!(rows[1], ["3", "4"]);
1329 }
1330
1331 #[cfg(feature = "formats")]
1333 #[test]
1334 fn test_csv_dataset_roundtrip_3col_5row() {
1335 use scirs2_core::ndarray::Array2;
1336
1337 let vals: Vec<f64> = (0..15).map(|x| x as f64 * 1.1).collect();
1338 let data = Array2::from_shape_vec((5, 3), vals).expect("shape");
1339 let ds = Dataset::new(data.clone(), None);
1340
1341 let mut tmp = std::env::temp_dir();
1342 tmp.push("scirs2_test_csv_roundtrip_3col_5row.csv");
1343
1344 write_csv(&ds, &tmp).expect("csv write");
1345 let recovered = read_csv(&tmp).expect("csv read");
1346
1347 assert_eq!(recovered.n_samples(), 5, "n_samples mismatch");
1348 assert_eq!(recovered.n_features(), 3, "n_features mismatch");
1349 assert!(recovered.target.is_none());
1350
1351 for row in 0..5 {
1352 for col in 0..3 {
1353 let expected = data[[row, col]];
1354 let actual = recovered.data[[row, col]];
1355 assert!(
1356 (expected - actual).abs() < 1e-10,
1357 "mismatch at [{row},{col}]: expected {expected}, got {actual}"
1358 );
1359 }
1360 }
1361
1362 let _ = std::fs::remove_file(&tmp);
1363 }
1364
1365 #[cfg(feature = "formats")]
1368 #[test]
1369 fn test_csv_dataset_roundtrip_with_target() {
1370 use scirs2_core::ndarray::{Array1, Array2};
1371
1372 let data =
1373 Array2::from_shape_vec((3, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).expect("shape");
1374 let target = Some(Array1::from_vec(vec![0.0, 1.0, 0.0]));
1375 let ds = Dataset::new(data.clone(), target.clone());
1376
1377 let mut tmp = std::env::temp_dir();
1378 tmp.push("scirs2_test_csv_roundtrip_with_target.csv");
1379
1380 write_csv(&ds, &tmp).expect("csv write");
1381 let recovered = read_csv(&tmp).expect("csv read");
1382
1383 assert_eq!(recovered.n_samples(), 3);
1384 assert_eq!(recovered.n_features(), 2);
1385 assert!(
1386 recovered.target.is_some(),
1387 "target missing after CSV round-trip"
1388 );
1389
1390 let rtarget = recovered.target.as_ref().expect("target");
1391 assert_eq!(rtarget.len(), 3);
1392 for (i, (&e, &a)) in target
1393 .as_ref()
1394 .expect("target")
1395 .iter()
1396 .zip(rtarget.iter())
1397 .enumerate()
1398 {
1399 assert!(
1400 (e - a).abs() < 1e-10,
1401 "target mismatch at [{i}]: expected {e}, got {a}"
1402 );
1403 }
1404
1405 let _ = std::fs::remove_file(&tmp);
1406 }
1407
1408 #[cfg(feature = "formats")]
1410 #[test]
1411 fn test_csv_float_precision() {
1412 use scirs2_core::ndarray::Array2;
1413
1414 let vals = vec![
1417 std::f64::consts::PI,
1418 std::f64::consts::E,
1419 1.0 / 3.0,
1420 1.0 / 7.0,
1421 std::f64::consts::SQRT_2,
1422 0.1 + 0.2, ];
1424 let data = Array2::from_shape_vec((2, 3), vals.clone()).expect("shape");
1425 let ds = Dataset::new(data, None);
1426
1427 let mut tmp = std::env::temp_dir();
1428 tmp.push("scirs2_test_csv_float_precision.csv");
1429
1430 write_csv(&ds, &tmp).expect("csv write");
1431 let recovered = read_csv(&tmp).expect("csv read");
1432
1433 assert_eq!(recovered.n_samples(), 2);
1434 assert_eq!(recovered.n_features(), 3);
1435
1436 for (idx, &original) in vals.iter().enumerate() {
1437 let row = idx / 3;
1438 let col = idx % 3;
1439 let got = recovered.data[[row, col]];
1440 assert!(
1441 (original - got).abs() < 1e-10,
1442 "float precision failure at [{row},{col}]: original={original}, got={got}"
1443 );
1444 }
1445
1446 let _ = std::fs::remove_file(&tmp);
1447 }
1448
1449 #[cfg(feature = "formats")]
1451 #[test]
1452 fn test_csv_header_only_no_panic() {
1453 let mut tmp = std::env::temp_dir();
1454 tmp.push("scirs2_test_csv_header_only.csv");
1455
1456 std::fs::write(&tmp, "feature_0,feature_1,feature_2\n").expect("write header-only csv");
1458
1459 let recovered = read_csv(&tmp).expect("csv read must not fail on header-only input");
1460 assert_eq!(recovered.n_samples(), 0, "expected zero rows");
1461 assert_eq!(recovered.n_features(), 3, "expected 3 feature columns");
1462 assert!(recovered.target.is_none());
1463
1464 let _ = std::fs::remove_file(&tmp);
1465 }
1466
1467 #[cfg(feature = "formats")]
1469 #[test]
1470 fn test_hdf5_roundtrip_with_target() {
1471 use scirs2_core::ndarray::{Array1, Array2};
1472 let data =
1473 Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).expect("shape");
1474 let target = Some(Array1::from_vec(vec![1.0, 0.0]));
1475 let ds = Dataset::new(data.clone(), target.clone());
1476
1477 let mut tmp = std::env::temp_dir();
1478 tmp.push("scirs2_test_hdf5_roundtrip_with_target.h5");
1479
1480 write_hdf5(&ds, &tmp, "experiment").expect("hdf5 write");
1481 let recovered = read_hdf5(&tmp, "experiment").expect("hdf5 read");
1482
1483 assert_eq!(recovered.n_samples(), 2);
1484 assert_eq!(recovered.n_features(), 3);
1485 assert!(recovered.target.is_some());
1486
1487 let rtarget = recovered.target.as_ref().expect("target");
1488 assert_eq!(rtarget.len(), 2);
1489 for (i, (&e, &a)) in target
1490 .as_ref()
1491 .expect("t")
1492 .iter()
1493 .zip(rtarget.iter())
1494 .enumerate()
1495 {
1496 assert!((e - a).abs() < 1e-10, "target mismatch [{i}]: {e} != {a}");
1497 }
1498
1499 let _ = std::fs::remove_file(&tmp);
1500 let sidecar = format!("{}.json", tmp.to_string_lossy());
1501 let _ = std::fs::remove_file(&sidecar);
1502 }
1503}