Skip to main content

powerio_matrix/io/
mtx.rs

1//! Matrix Market I/O.
2//!
3//! `sprs::io::write_matrix_market_sym` writes the *upper* triangle, but the
4//! Matrix Market spec calls for the *lower* triangle (i ≥ j). To stay
5//! compatible with strict readers (e.g. `fast_matrix_market`), we hand roll
6//! the symmetric writer. We delegate to `sprs` for general (non symmetric)
7//! output and for reading.
8
9use std::io::{BufWriter, Write};
10use std::path::Path;
11
12use sprs::CsMat;
13
14use crate::{Error, Result};
15
16pub fn write_mtx(matrix: &CsMat<f64>, path: impl AsRef<Path>) -> Result<()> {
17    let path = path.as_ref();
18    if is_exactly_symmetric(matrix) {
19        write_symmetric_mtx(matrix, path)
20    } else {
21        sprs::io::write_matrix_market(path, matrix.view()).map_err(|e| Error::Mtx(e.to_string()))
22    }
23}
24
25fn write_symmetric_mtx(matrix: &CsMat<f64>, path: &Path) -> Result<()> {
26    let f = std::fs::File::create(path)?;
27    let mut w = BufWriter::new(f);
28    writeln!(w, "%%MatrixMarket matrix coordinate real symmetric")?;
29    writeln!(w, "% written by powerio")?;
30
31    // Two-pass: count entries first so the header can carry the exact nnz.
32    let nnz = matrix
33        .iter()
34        .filter(|&(_, (i, j))| i >= j)
35        .filter(|&(&v, _)| v != 0.0)
36        .count();
37    writeln!(w, "{} {} {}", matrix.rows(), matrix.cols(), nnz)?;
38
39    for (&v, (i, j)) in matrix {
40        if i < j || v == 0.0 {
41            continue;
42        }
43        writeln!(w, "{} {} {:.16e}", i + 1, j + 1, v)?;
44    }
45    Ok(())
46}
47
48/// Read a Matrix Market file into a CSR matrix.
49pub fn read_mtx(path: impl AsRef<Path>) -> Result<CsMat<f64>> {
50    let tri: sprs::TriMat<f64> =
51        sprs::io::read_matrix_market(path).map_err(|e| Error::Mtx(e.to_string()))?;
52    Ok(tri.to_csr())
53}
54
55/// Read a dense vector written by [`write_vector_mtx`] (`array real general`):
56/// `%`-comment lines, a `<len> 1` dimensions line, then one value per line.
57pub fn read_vector_mtx(path: impl AsRef<Path>) -> Result<Vec<f64>> {
58    let text = std::fs::read_to_string(path)?;
59    let mut lines = text.lines().filter(|l| !l.starts_with('%'));
60    let header = lines
61        .next()
62        .ok_or_else(|| Error::Mtx("empty vector file".into()))?;
63    let len: usize = header
64        .split_whitespace()
65        .next()
66        .and_then(|t| t.parse().ok())
67        .ok_or_else(|| Error::Mtx(format!("bad vector dimensions line: {header:?}")))?;
68    let values = lines
69        .take(len)
70        .map(|l| {
71            l.trim()
72                .parse::<f64>()
73                .map_err(|_| Error::Mtx(format!("bad vector entry: {l:?}")))
74        })
75        .collect::<Result<Vec<_>>>()?;
76    if values.len() != len {
77        return Err(Error::Mtx(format!(
78            "expected {len} entries, got {}",
79            values.len()
80        )));
81    }
82    Ok(values)
83}
84
85/// Write a dense vector as Matrix Market `array real general`.
86pub fn write_vector_mtx(values: &[f64], path: impl AsRef<Path>) -> Result<()> {
87    let f = std::fs::File::create(path)?;
88    let mut w = BufWriter::new(f);
89    writeln!(w, "%%MatrixMarket matrix array real general")?;
90    writeln!(w, "% written by powerio")?;
91    writeln!(w, "{} 1", values.len())?;
92    for v in values {
93        writeln!(w, "{v:.16e}")?;
94    }
95    Ok(())
96}
97
98/// Whether the `symmetric` header would round trip: every stored entry has a
99/// stored mirror holding the identical bits.
100///
101/// The writer emits only the lower triangle under that header, so deciding this
102/// on a tolerance sent a merely close matrix out as symmetric and read it back
103/// changed by up to the tolerance. Anything short of exact goes out `general`.
104fn is_exactly_symmetric(a: &CsMat<f64>) -> bool {
105    if a.rows() != a.cols() {
106        return false;
107    }
108    for (i, row) in a.outer_iterator().enumerate() {
109        for (j, &v) in row.iter() {
110            // Bit equality, so a mirrored pair differing only in the sign of
111            // zero is `general` too: the symmetric form would not carry it.
112            match a.get(j, i) {
113                Some(&mirror) if mirror.to_bits() == v.to_bits() => {}
114                _ => return false,
115            }
116        }
117    }
118    true
119}
120
121#[cfg(test)]
122mod tests {
123    use sprs::TriMat;
124
125    use super::write_mtx;
126
127    #[test]
128    fn value_asymmetric_matrix_writes_general_mtx() {
129        let mut tri = TriMat::new((2, 2));
130        tri.add_triplet(0, 0, 2.0);
131        tri.add_triplet(0, 1, -1.0);
132        tri.add_triplet(1, 0, -2.0);
133        tri.add_triplet(1, 1, 2.0);
134        let matrix = tri.to_csr();
135
136        let path = temp_path("value-asymmetric");
137        write_mtx(&matrix, &path).unwrap();
138        let text = std::fs::read_to_string(&path).unwrap();
139        let _ = std::fs::remove_file(&path);
140
141        assert!(
142            text.lines().next().unwrap().ends_with("general"),
143            "value-asymmetric matrices must not be written with a symmetric header"
144        );
145    }
146
147    #[test]
148    fn a_matrix_asymmetric_below_the_old_tolerance_writes_general() {
149        // #292. The pair differs by 1e-15 relative, which the old 1e-12
150        // tolerance called symmetric, so a reader mirrored 3.0 back over the
151        // 3.000000000000001 that was assembled. A `Bp` in BX mode with a small
152        // phase shifter is asymmetric by exactly this little.
153        let mut tri = TriMat::new((2, 2));
154        tri.add_triplet(0, 0, 5.0);
155        tri.add_triplet(0, 1, 3.000_000_000_000_001);
156        tri.add_triplet(1, 0, 3.0);
157        tri.add_triplet(1, 1, 5.0);
158        let matrix = tri.to_csr();
159
160        let path = temp_path("near-symmetric");
161        write_mtx(&matrix, &path).unwrap();
162        let text = std::fs::read_to_string(&path).unwrap();
163        let _ = std::fs::remove_file(&path);
164
165        assert!(
166            text.lines().next().unwrap().ends_with("general"),
167            "a matrix that is only nearly symmetric must carry both triangles:\n{text}"
168        );
169    }
170
171    #[test]
172    fn a_structurally_asymmetric_matrix_writes_general() {
173        // The mirror is absent rather than unequal: the old check read it as a
174        // stored 0.0 and compared equal whenever the entry was itself 0.0.
175        let mut tri = TriMat::new((2, 2));
176        tri.add_triplet(0, 0, 5.0);
177        tri.add_triplet(0, 1, 0.0);
178        tri.add_triplet(1, 1, 5.0);
179        let matrix = tri.to_csr();
180
181        let path = temp_path("structurally-asymmetric");
182        write_mtx(&matrix, &path).unwrap();
183        let text = std::fs::read_to_string(&path).unwrap();
184        let _ = std::fs::remove_file(&path);
185
186        assert!(
187            text.lines().next().unwrap().ends_with("general"),
188            "an unmirrored stored entry must not claim a symmetric header:\n{text}"
189        );
190    }
191
192    #[test]
193    fn an_exactly_symmetric_matrix_still_writes_symmetric() {
194        let mut tri = TriMat::new((2, 2));
195        tri.add_triplet(0, 0, 5.0);
196        tri.add_triplet(0, 1, -3.0);
197        tri.add_triplet(1, 0, -3.0);
198        tri.add_triplet(1, 1, 5.0);
199        let matrix = tri.to_csr();
200
201        let path = temp_path("exactly-symmetric");
202        write_mtx(&matrix, &path).unwrap();
203        let text = std::fs::read_to_string(&path).unwrap();
204        let _ = std::fs::remove_file(&path);
205
206        assert!(
207            text.lines().next().unwrap().ends_with("symmetric"),
208            "an exactly symmetric matrix keeps the compact form:\n{text}"
209        );
210    }
211
212    fn temp_path(stem: &str) -> std::path::PathBuf {
213        let mut path = std::env::temp_dir();
214        let nanos = std::time::SystemTime::now()
215            .duration_since(std::time::UNIX_EPOCH)
216            .map_or(0, |d| d.as_nanos());
217        path.push(format!("powerio-{stem}-{nanos}.mtx"));
218        path
219    }
220}