Skip to main content

rsomics_quantile_transform/
io.rs

1//! Dense feature-matrix I/O — tab-separated `n×d`, header auto-detected.
2
3use std::io::Read;
4use std::path::Path;
5
6use rsomics_common::{Result, RsomicsError};
7
8/// Row-major dense matrix with optional row/column labels.
9pub struct Matrix {
10    pub n_rows: usize,
11    pub n_cols: usize,
12    pub row_names: Vec<String>,
13    pub col_names: Vec<String>,
14    pub data: Vec<f64>,
15}
16
17impl Matrix {
18    #[must_use]
19    pub fn row(&self, i: usize) -> &[f64] {
20        &self.data[i * self.n_cols..(i + 1) * self.n_cols]
21    }
22
23    /// Materialise column `j` into a fresh `Vec` (row-major storage strides).
24    #[must_use]
25    pub fn column(&self, j: usize) -> Vec<f64> {
26        (0..self.n_rows)
27            .map(|i| self.data[i * self.n_cols + j])
28            .collect()
29    }
30
31    /// Read from a file (`-` or `None` = stdin).
32    ///
33    /// # Errors
34    /// Missing file, empty input, ragged body, non-numeric cell.
35    pub fn read(path: Option<&Path>) -> Result<Matrix> {
36        let mut buf = String::new();
37        match path {
38            Some(p) if p.as_os_str() != "-" => {
39                std::fs::File::open(p)
40                    .map_err(|e| RsomicsError::InvalidInput(format!("{}: {e}", p.display())))?
41                    .read_to_string(&mut buf)
42                    .map_err(RsomicsError::Io)?;
43            }
44            _ => {
45                std::io::stdin()
46                    .lock()
47                    .read_to_string(&mut buf)
48                    .map_err(RsomicsError::Io)?;
49            }
50        }
51        Matrix::parse(&buf)
52    }
53
54    /// Parse tab-separated matrix. Header detected by leading tab (empty top-left).
55    ///
56    /// # Errors
57    /// Empty input, ragged body, non-numeric cell.
58    pub fn parse(text: &str) -> Result<Matrix> {
59        let rows: Vec<&str> = text
60            .lines()
61            .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
62            .collect();
63        if rows.is_empty() {
64            return Err(RsomicsError::InvalidInput("empty matrix".into()));
65        }
66
67        let has_header = rows[0].starts_with('\t');
68        let (col_names, body) = if has_header {
69            let names: Vec<String> = rows[0].split('\t').skip(1).map(str::to_string).collect();
70            (names, &rows[1..])
71        } else {
72            let ncol = rows[0].split('\t').count();
73            ((1..=ncol).map(|j| j.to_string()).collect(), &rows[..])
74        };
75
76        let n_cols = col_names.len();
77        if n_cols == 0 {
78            return Err(RsomicsError::InvalidInput("matrix has no columns".into()));
79        }
80
81        let mut row_names = Vec::with_capacity(body.len());
82        let mut data = Vec::with_capacity(body.len() * n_cols);
83        for (li, line) in body.iter().enumerate() {
84            let mut fields = line.split('\t');
85            if has_header {
86                row_names.push(fields.next().unwrap_or("").to_string());
87            } else {
88                row_names.push((li + 1).to_string());
89            }
90            let mut seen = 0;
91            for cell in fields {
92                data.push(parse_cell(cell)?);
93                seen += 1;
94            }
95            if seen != n_cols {
96                return Err(RsomicsError::InvalidInput(format!(
97                    "row {} has {seen} value columns, expected {n_cols}",
98                    li + 1
99                )));
100            }
101        }
102
103        let n_rows = row_names.len();
104        if n_rows == 0 {
105            return Err(RsomicsError::InvalidInput("matrix has no data rows".into()));
106        }
107
108        Ok(Matrix {
109            n_rows,
110            n_cols,
111            row_names,
112            col_names,
113            data,
114        })
115    }
116}
117
118fn parse_cell(cell: &str) -> Result<f64> {
119    let t = cell.trim();
120    match t {
121        "" | "NA" | "NaN" | "na" | "nan" => Ok(f64::NAN),
122        "Inf" | "inf" | "+Inf" => Ok(f64::INFINITY),
123        "-Inf" | "-inf" => Ok(f64::NEG_INFINITY),
124        _ => fast_float2::parse(t)
125            .map_err(|_| RsomicsError::InvalidInput(format!("non-numeric cell '{cell}'"))),
126    }
127}
128
129/// Shortest round-trip decimal; `NA`/`Inf`/`-Inf` for non-finite.
130#[must_use]
131pub fn fmt_value(v: f64) -> String {
132    if v.is_nan() {
133        "NA".to_string()
134    } else if v.is_infinite() {
135        if v > 0.0 { "Inf".into() } else { "-Inf".into() }
136    } else if v != 0.0 && (v.abs() < 1e-4 || v.abs() >= 1e16) {
137        format!("{v:e}")
138    } else {
139        format!("{v}")
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn parse_with_header() {
149        let m = Matrix::parse("\tc1\tc2\tc3\nr1\t1\t2\t3\nr2\t4\t5\t6\n").unwrap();
150        assert_eq!(m.n_rows, 2);
151        assert_eq!(m.n_cols, 3);
152        assert_eq!(m.row_names, ["r1", "r2"]);
153        assert_eq!(m.col_names, ["c1", "c2", "c3"]);
154        assert_eq!(m.row(1), &[4.0, 5.0, 6.0]);
155        assert_eq!(m.column(0), [1.0, 4.0]);
156    }
157
158    #[test]
159    fn parse_headerless() {
160        let m = Matrix::parse("1\t2\n3\t4\n").unwrap();
161        assert_eq!(m.n_rows, 2);
162        assert_eq!(m.n_cols, 2);
163        assert_eq!(m.row(0), &[1.0, 2.0]);
164        assert_eq!(m.col_names, ["1", "2"]);
165    }
166
167    #[test]
168    fn ragged_row_errors() {
169        assert!(Matrix::parse("1\t2\t3\n4\t5\n").is_err());
170    }
171}