rdml-qpcr 0.1.1

Read, write, and validate RDML (Real-time PCR Data Markup Language) qPCR data files
Documentation
//! Digital-PCR partition tables: the TSV sidecar files that carry raw
//! per-partition endpoint fluorescence.
//!
//! The XML document holds only partition *counts*
//! ([`PartitionData`](crate::PartitionData)); the per-partition values
//! live in tab-separated files inside the archive's `partitions/` folder,
//! referenced by [`Partitions::end_pt_table`](crate::Partitions::end_pt_table).
//!
//! File format (from the consortium's format notes): tab column
//! separators, `.` decimal separators, one line per partition. Each
//! fluorescence contributes two columns — the endpoint value and a
//! one-character score — and the header line repeats the target id over
//! both:
//!
//! ```text
//! GAPDH <tab> GAPDH <tab> HBV <tab> HBV
//! 3412.32 <tab> p <tab> 121.89 <tab> n
//! 239.23 <tab> n <tab> 3459.27 <tab> p
//! ```

use std::fmt;
use std::str::FromStr;

use serde::{Deserialize, Serialize};

use crate::error::Error;
use crate::types::TargetRef;

/// The score of one partition for one target.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PartitionScore {
    /// `u` — undefined, or not yet scored.
    #[serde(rename = "u")]
    Undefined,
    /// `p` — positive.
    #[serde(rename = "p")]
    Positive,
    /// `n` — negative.
    #[serde(rename = "n")]
    Negative,
    /// `e` — excluded; should be ignored in analysis.
    #[serde(rename = "e")]
    Excluded,
}

impl PartitionScore {
    /// The one-character code used in the table file.
    #[must_use]
    pub fn as_char(self) -> char {
        match self {
            Self::Undefined => 'u',
            Self::Positive => 'p',
            Self::Negative => 'n',
            Self::Excluded => 'e',
        }
    }
}

impl fmt::Display for PartitionScore {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.as_char())
    }
}

impl TryFrom<char> for PartitionScore {
    type Error = Error;
    fn try_from(c: char) -> Result<Self, Error> {
        match c {
            'u' => Ok(Self::Undefined),
            'p' => Ok(Self::Positive),
            'n' => Ok(Self::Negative),
            'e' => Ok(Self::Excluded),
            other => Err(Error::InvalidValue(format!(
                "`{other}` is not a partition score (expected u, p, n, or e)"
            ))),
        }
    }
}

impl FromStr for PartitionScore {
    type Err = Error;
    fn from_str(s: &str) -> Result<Self, Error> {
        let mut chars = s.chars();
        match (chars.next(), chars.next()) {
            (Some(c), None) => c.try_into(),
            _ => Err(Error::InvalidValue(format!(
                "`{s}` is not a partition score (expected a single character u, p, n, or e)"
            ))),
        }
    }
}

/// One partition's measurement for one target: endpoint fluorescence and
/// score.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct PartitionPoint {
    /// The endpoint fluorescence value.
    pub fluor: f64,
    /// The partition's score.
    pub score: PartitionScore,
}

/// The per-target column of a [`PartitionTable`]: one
/// [`PartitionPoint`] per partition.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PartitionColumn {
    /// The target these values belong to (the header id).
    pub target: TargetRef,
    /// One entry per partition, in file order.
    pub points: Vec<PartitionPoint>,
}

/// A parsed partition table: per-target endpoint fluorescence for every
/// partition of one reaction.
///
/// All columns hold the same number of points (one line per partition);
/// [`push_column`](Self::push_column) enforces this.
///
/// ```
/// use rdml_qpcr::{PartitionPoint, PartitionScore, PartitionTable};
///
/// let mut table = PartitionTable::new();
/// table.push_column(
///     "HBV".parse()?,
///     vec![
///         PartitionPoint { fluor: 3412.32, score: PartitionScore::Positive },
///         PartitionPoint { fluor: 239.23, score: PartitionScore::Negative },
///     ],
/// )?;
/// assert_eq!(table.partition_count(), 2);
/// let tsv = table.to_tsv();
/// assert_eq!(tsv, "HBV\tHBV\n3412.32\tp\n239.23\tn\n");
/// assert_eq!(PartitionTable::parse(tsv.as_bytes(), "example.tsv")?, table);
/// # Ok::<(), rdml_qpcr::Error>(())
/// ```
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct PartitionTable {
    columns: Vec<PartitionColumn>,
}

impl PartitionTable {
    /// Creates an empty table.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Appends a column, requiring the same partition count as the
    /// existing columns.
    ///
    /// # Errors
    ///
    /// Fails if `points` differs in length from the existing columns.
    pub fn push_column(
        &mut self,
        target: TargetRef,
        points: Vec<PartitionPoint>,
    ) -> Result<(), Error> {
        if let Some(first) = self.columns.first()
            && first.points.len() != points.len()
        {
            return Err(Error::InvalidValue(format!(
                "column for `{target}` has {} partitions but the table has {} \
                 (one line per partition, all columns equal length)",
                points.len(),
                first.points.len()
            )));
        }
        self.columns.push(PartitionColumn { target, points });
        Ok(())
    }

    /// The columns, in file order.
    #[must_use]
    pub fn columns(&self) -> &[PartitionColumn] {
        &self.columns
    }

    /// The column for a target, if present.
    #[must_use]
    pub fn column(&self, target: &str) -> Option<&PartitionColumn> {
        self.columns.iter().find(|c| c.target.as_str() == target)
    }

    /// Number of partitions (lines) in the table.
    #[must_use]
    pub fn partition_count(&self) -> usize {
        self.columns.first().map_or(0, |c| c.points.len())
    }

    /// Parses the TSV sidecar format. `name` is used in error messages
    /// (typically the archive member name).
    ///
    /// # Errors
    ///
    /// Fails with [`Error::PartitionTable`] — carrying `name` and a line
    /// number — on non-UTF-8 content, a malformed header, ragged rows,
    /// or invalid values/scores.
    pub fn parse(bytes: &[u8], name: &str) -> Result<Self, Error> {
        let text = std::str::from_utf8(bytes).map_err(|_| Error::PartitionTable {
            name: name.to_string(),
            line: 0,
            message: "not valid UTF-8".into(),
        })?;
        let mut lines = text.lines();
        let Some(header) = lines.next() else {
            return Err(Error::PartitionTable {
                name: name.to_string(),
                line: 0,
                message: "empty file (a header line of target ids is required)".into(),
            });
        };
        let header: Vec<&str> = header.split('\t').map(str::trim).collect();
        if !header.len().is_multiple_of(2) {
            return Err(Error::PartitionTable {
                name: name.to_string(),
                line: 0,
                message: format!(
                    "header has {} columns; expected two per target (value and score)",
                    header.len()
                ),
            });
        }
        let mut table = PartitionTable::new();
        for pair in header.chunks_exact(2) {
            if pair[0] != pair[1] {
                return Err(Error::PartitionTable {
                    name: name.to_string(),
                    line: 0,
                    message: format!(
                        "header pair `{}`/`{}` does not repeat the same target id over \
                         its value and score columns",
                        pair[0], pair[1]
                    ),
                });
            }
            let target = TargetRef::new(pair[0]).map_err(|_| Error::PartitionTable {
                name: name.to_string(),
                line: 0,
                message: "empty target id in header".into(),
            })?;
            table.columns.push(PartitionColumn {
                target,
                points: Vec::new(),
            });
        }
        for (i, line) in lines.enumerate() {
            let line_no = i + 2; // 1-based, after the header
            if line.is_empty() {
                continue; // tolerate a trailing blank line
            }
            let fields: Vec<&str> = line.split('\t').map(str::trim).collect();
            if fields.len() != table.columns.len() * 2 {
                return Err(Error::PartitionTable {
                    name: name.to_string(),
                    line: line_no,
                    message: format!(
                        "expected {} columns, found {}",
                        table.columns.len() * 2,
                        fields.len()
                    ),
                });
            }
            for (column, pair) in table.columns.iter_mut().zip(fields.chunks_exact(2)) {
                let fluor: f64 = pair[0].parse().map_err(|_| Error::PartitionTable {
                    name: name.to_string(),
                    line: line_no,
                    message: format!("`{}` is not a valid fluorescence value", pair[0]),
                })?;
                let score: PartitionScore = pair[1].parse().map_err(|_| Error::PartitionTable {
                    name: name.to_string(),
                    line: line_no,
                    message: format!(
                        "`{}` is not a partition score (expected u, p, n, or e)",
                        pair[1]
                    ),
                })?;
                column.points.push(PartitionPoint { fluor, score });
            }
        }
        Ok(table)
    }

    /// Serialises to the TSV sidecar format: tab separators, `.`
    /// decimals, `\n` newlines, header line of repeated target ids.
    #[must_use]
    pub fn to_tsv(&self) -> String {
        let mut out = String::new();
        for (i, column) in self.columns.iter().enumerate() {
            if i > 0 {
                out.push('\t');
            }
            out.push_str(column.target.as_str());
            out.push('\t');
            out.push_str(column.target.as_str());
        }
        out.push('\n');
        for row in 0..self.partition_count() {
            for (i, column) in self.columns.iter().enumerate() {
                if i > 0 {
                    out.push('\t');
                }
                let point = &column.points[row];
                out.push_str(&crate::xml::format_float(point.fluor));
                out.push('\t');
                out.push(point.score.as_char());
            }
            out.push('\n');
        }
        out
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    // Exact decimal parsing is the property under test.
    #[allow(clippy::float_cmp)]
    fn parses_the_format_notes_example() {
        let tsv = "GAPDH\tGAPDH\tHBV\tHBV\n3412.32\tp\t121.89\tn\n239.23\tn\t3459.27\tp\n";
        let table = PartitionTable::parse(tsv.as_bytes(), "x.tsv").unwrap();
        assert_eq!(table.columns().len(), 2);
        assert_eq!(table.partition_count(), 2);
        let gapdh = table.column("GAPDH").unwrap();
        assert_eq!(gapdh.points[0].fluor, 3412.32);
        assert_eq!(gapdh.points[0].score, PartitionScore::Positive);
        assert_eq!(gapdh.points[1].score, PartitionScore::Negative);
        assert_eq!(table.column("HBV").unwrap().points[1].fluor, 3459.27);
        // Round-trips byte-exactly.
        assert_eq!(table.to_tsv(), tsv);
    }

    #[test]
    fn crlf_and_trailing_blank_tolerated() {
        let tsv = "T\tT\r\n1.5\tu\r\n2.5\te\r\n\r\n";
        let table = PartitionTable::parse(tsv.as_bytes(), "x.tsv").unwrap();
        assert_eq!(table.partition_count(), 2);
        assert_eq!(
            table.column("T").unwrap().points[1].score,
            PartitionScore::Excluded
        );
    }

    #[test]
    fn errors_carry_line_numbers() {
        let tsv = "T\tT\n1.5\tu\noops\tp\n";
        let err = PartitionTable::parse(tsv.as_bytes(), "x.tsv").unwrap_err();
        match err {
            Error::PartitionTable { line, .. } => assert_eq!(line, 3),
            other => panic!("unexpected error {other}"),
        }
        let tsv = "T\tX\n";
        assert!(PartitionTable::parse(tsv.as_bytes(), "x.tsv").is_err());
        let tsv = "T\tT\n1.5\n";
        assert!(PartitionTable::parse(tsv.as_bytes(), "x.tsv").is_err());
        let tsv = "T\tT\n1.5\tq\n";
        assert!(PartitionTable::parse(tsv.as_bytes(), "x.tsv").is_err());
    }

    #[test]
    fn push_column_enforces_equal_length() {
        let mut table = PartitionTable::new();
        table
            .push_column(
                "A".parse().unwrap(),
                vec![PartitionPoint {
                    fluor: 1.0,
                    score: PartitionScore::Positive,
                }],
            )
            .unwrap();
        let err = table.push_column("B".parse().unwrap(), vec![]).unwrap_err();
        assert!(err.to_string().contains("equal length"));
    }
}