rdml-qpcr 0.1.1

Read, write, and validate RDML (Real-time PCR Data Markup Language) qPCR data files
Documentation
//! Samples and their annotations (root-level master element `sample`).

use serde::{Deserialize, Serialize};

use crate::enums::{Nucleotide, PrimingMethod, QuantityUnit, SampleType};
use crate::types::{DocumentationRef, Id, SampleRef, TargetRef, TccRef};

use super::target::XRef;

/// A defined template solution.
///
/// Dilutions of the same material differ in concentration and are
/// considered *different* samples. Technical replicates share one sample
/// (reactions are performed on the same material); biological replicates
/// are different samples. Serial dilutions in a standard curve must each
/// have their own sample.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Sample {
    /// Unique id this sample is referenced by. Short human-readable name;
    /// see [`Id`].
    pub id: Id,
    /// Free-text description.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub description: Option<String>,
    /// References to shared [`Documentation`](crate::Documentation) blocks.
    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    pub documentation: Vec<DocumentationRef>,
    /// References to entries in external databases.
    #[serde(rename = "xRef", skip_serializing_if = "Vec::is_empty", default)]
    pub x_refs: Vec<XRef>,
    /// Free-form property/value annotations. *(RDML ≥ 1.2)*
    ///
    /// The consortium's changelogs direct template-quality information
    /// (the removed 1.0/1.1 `templateRNAQuality`/`templateDNAQuality`
    /// elements) here.
    #[serde(rename = "annotation", skip_serializing_if = "Vec::is_empty", default)]
    pub annotations: Vec<Annotation>,
    /// The sample's type, optionally per target.
    ///
    /// Since RDML 1.3 a sample may carry several entries — e.g. a positive
    /// control (`pos`) for one target and no-target-present (`ntp`) for
    /// another — each linked via
    /// [`SampleTypeEntry::target_id`]. An entry without a target applies to
    /// all targets. If the list is empty, the sample type is
    /// [`SampleType::Unknown`] (the schema default); use
    /// [`Sample::type_for`] to resolve this.
    #[serde(rename = "type", skip_serializing_if = "Vec::is_empty", default)]
    pub types: Vec<SampleTypeEntry>,
    /// True if this sample is an inter-run calibrator: an identical sample
    /// measured in different runs to detect and correct inter-run
    /// differences.
    ///
    /// `None` means the element is absent, which the schema defines as
    /// `false`; use [`Sample::is_inter_run_calibrator`] to resolve.
    /// Presence is preserved so third-party files round-trip.
    #[serde(
        rename = "interRunCalibrator",
        skip_serializing_if = "Option::is_none",
        default
    )]
    pub inter_run_calibrator: Option<bool>,
    /// True if the nucleotides in this sample start the reaction
    /// double-stranded (genomic DNA, plasmids, restriction fragments);
    /// absent or `false` means single-stranded (cDNA, oligos).
    /// Use [`Sample::is_double_stranded`] to resolve the default.
    /// *(RDML 1.4, candidate recommendation)*
    #[serde(
        rename = "doubleStranded",
        skip_serializing_if = "Option::is_none",
        default
    )]
    pub double_stranded: Option<bool>,
    /// Reference quantities, used when the sample is part of a standard
    /// curve. Only true linear numbers are valid (1, 10, 100 or 1, 0.1,
    /// 0.01) — never exponents (1, 2, 3 meaning 10¹, 10², 10³).
    ///
    /// Since RDML 1.3 several entries are allowed, optionally per target
    /// (e.g. 1000 copies/µl for one target, 1 fold for all others).
    #[serde(rename = "quantity", skip_serializing_if = "Vec::is_empty", default)]
    pub quantities: Vec<Quantity>,
    /// True if this sample is a calibrator sample: a reference sample
    /// within a run to which results are rescaled. Absent means `false`;
    /// use [`Sample::is_calibrator_sample`] to resolve.
    #[serde(
        rename = "calibratorSample",
        skip_serializing_if = "Option::is_none",
        default
    )]
    pub calibrator_sample: Option<bool>,
    /// How cDNA was synthesised from RNA, if applicable.
    #[serde(
        rename = "cdnaSynthesisMethod",
        skip_serializing_if = "Option::is_none",
        default
    )]
    pub cdna_synthesis_method: Option<CdnaSynthesisMethod>,
    /// Concentration and kind of the template nucleotide. *(RDML ≥ 1.2)*
    #[serde(
        rename = "templateQuantity",
        skip_serializing_if = "Option::is_none",
        default
    )]
    pub template_quantity: Option<TemplateQuantity>,
}

impl Sample {
    /// Creates a sample with the given id and no optional detail. A sample
    /// without any [`types`](Self::types) entry is an unknown sample.
    #[must_use]
    pub fn new(id: Id) -> Self {
        Self {
            id,
            description: None,
            documentation: Vec::new(),
            x_refs: Vec::new(),
            annotations: Vec::new(),
            types: Vec::new(),
            inter_run_calibrator: None,
            double_stranded: None,
            quantities: Vec::new(),
            calibrator_sample: None,
            cdna_synthesis_method: None,
            template_quantity: None,
        }
    }

    /// A typed reference to this sample, for use in
    /// [`React::sample`](crate::React::sample).
    #[must_use]
    pub fn reference(&self) -> SampleRef {
        self.id.clone().into()
    }

    /// Resolves the sample type for a given target, applying the schema
    /// rules: a target-specific entry wins over a general entry, and no
    /// entry at all means [`SampleType::Unknown`].
    #[must_use]
    pub fn type_for(&self, target: &str) -> SampleType {
        self.types
            .iter()
            .find(|t| {
                t.target_id
                    .as_ref()
                    .is_some_and(|tid| tid.as_str() == target)
            })
            .or_else(|| self.types.iter().find(|t| t.target_id.is_none()))
            .map_or(SampleType::Unknown, |t| t.value)
    }

    /// Whether this sample is an inter-run calibrator (absent element =
    /// `false`).
    #[must_use]
    pub fn is_inter_run_calibrator(&self) -> bool {
        self.inter_run_calibrator.unwrap_or(false)
    }

    /// Whether this sample is a calibrator sample (absent element =
    /// `false`).
    #[must_use]
    pub fn is_calibrator_sample(&self) -> bool {
        self.calibrator_sample.unwrap_or(false)
    }

    /// Whether the template starts the reaction double-stranded (absent
    /// element = `false`, i.e. single-stranded). *(RDML 1.4)*
    #[must_use]
    pub fn is_double_stranded(&self) -> bool {
        self.double_stranded.unwrap_or(false)
    }
}

/// One sample-type entry, optionally scoped to a target (schema
/// `sampleTargetType`: the enumeration value with a `targetId` attribute).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SampleTypeEntry {
    /// The sample type.
    pub value: SampleType,
    /// The target this entry applies to; `None` means it applies to all
    /// targets in the run. *(attribute available since RDML 1.3)*
    #[serde(rename = "targetId", skip_serializing_if = "Option::is_none", default)]
    pub target_id: Option<TargetRef>,
}

impl SampleTypeEntry {
    /// An entry that applies to all targets.
    #[must_use]
    pub fn for_all_targets(value: SampleType) -> Self {
        Self {
            value,
            target_id: None,
        }
    }

    /// An entry scoped to one target.
    #[must_use]
    pub fn for_target(value: SampleType, target: TargetRef) -> Self {
        Self {
            value,
            target_id: Some(target),
        }
    }
}

/// A free-form property/value annotation on a sample, e.g. property
/// `sex`, value `M`. *(RDML ≥ 1.2)*
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Annotation {
    /// The property being annotated.
    pub property: String,
    /// The property's value.
    pub value: String,
}

impl Annotation {
    /// Creates an annotation.
    pub fn new(property: impl Into<String>, value: impl Into<String>) -> Self {
        Self {
            property: property.into(),
            value: value.into(),
        }
    }
}

/// A value with its unit (schema `quantityType`), optionally scoped to a
/// target.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Quantity {
    /// The numeric value. Must be a true linear number (see
    /// [`Sample::quantities`]).
    pub value: f64,
    /// The unit of the value.
    pub unit: QuantityUnit,
    /// The target this quantity applies to; `None` means all targets.
    /// *(attribute available since RDML 1.3)*
    #[serde(rename = "targetId", skip_serializing_if = "Option::is_none", default)]
    pub target_id: Option<TargetRef>,
}

impl Quantity {
    /// Creates a quantity applying to all targets.
    #[must_use]
    pub fn new(value: f64, unit: QuantityUnit) -> Self {
        Self {
            value,
            unit,
            target_id: None,
        }
    }
}

/// Description of the cDNA synthesis method (schema
/// `cdnaSynthesisMethodType`).
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct CdnaSynthesisMethod {
    /// Enzyme used for reverse transcription.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub enzyme: Option<String>,
    /// The priming method used.
    #[serde(
        rename = "primingMethod",
        skip_serializing_if = "Option::is_none",
        default
    )]
    pub priming_method: Option<PrimingMethod>,
    /// True if the RNA was `DNase` treated prior to cDNA synthesis.
    #[serde(
        rename = "dnaseTreatment",
        skip_serializing_if = "Option::is_none",
        default
    )]
    pub dnase_treatment: Option<bool>,
    /// The thermal cycling program used for synthesis.
    #[serde(
        rename = "thermalCyclingConditions",
        skip_serializing_if = "Option::is_none",
        default
    )]
    pub thermal_cycling_conditions: Option<TccRef>,
}

/// Concentration and kind of template nucleotide (schema
/// `templateQuantityType`). *(RDML ≥ 1.2)*
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TemplateQuantity {
    /// Concentration of the template in nanogram per microliter in the
    /// final reaction mix.
    pub conc: f64,
    /// The kind of nucleotide used as template.
    pub nucleotide: Nucleotide,
}