rdml-qpcr 0.1.1

Read, write, and validate RDML (Real-time PCR Data Markup Language) qPCR data files
Documentation
//! Thermal cycling protocols (root-level master element
//! `thermalCyclingConditions`).

use std::num::NonZeroU32;
use std::time::Duration;

use serde::{Deserialize, Serialize};

use crate::enums::Measure;
use crate::types::{DocumentationRef, ExperimenterRef, Id, TccRef};

/// A cycling program for PCR or cDNA synthesis.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ThermalCyclingConditions {
    /// Unique id this protocol is referenced by.
    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>,
    /// Lid temperature during cycling, in °C.
    #[serde(
        rename = "lidTemperature",
        skip_serializing_if = "Option::is_none",
        default
    )]
    pub lid_temperature: Option<f64>,
    /// The people who made or use this protocol.
    #[serde(
        rename = "experimenter",
        skip_serializing_if = "Vec::is_empty",
        default
    )]
    pub experimenters: Vec<ExperimenterRef>,
    /// The steps of the protocol. The schema requires at least one;
    /// [`validate`](crate::Rdml::validate) reports an empty list. Step
    /// numbers must be unique; the first step should be `nr = 1`,
    /// incrementing by one.
    #[serde(rename = "step")]
    pub steps: Vec<Step>,
}

impl ThermalCyclingConditions {
    /// Creates a protocol with the given id and an empty step list — add
    /// at least one [`Step`] before writing.
    #[must_use]
    pub fn new(id: Id) -> Self {
        Self {
            id,
            description: None,
            documentation: Vec::new(),
            lid_temperature: None,
            experimenters: Vec::new(),
            steps: Vec::new(),
        }
    }

    /// A typed reference to this protocol.
    #[must_use]
    pub fn reference(&self) -> TccRef {
        self.id.clone().into()
    }

    /// Appends a step, numbering it one past the current highest step
    /// number, and returns it for further adjustment.
    ///
    /// # Panics
    ///
    /// Does not panic in practice: the only `expect` is on the element
    /// this call has just pushed.
    pub fn push_step(&mut self, kind: StepKind) -> &mut Step {
        let next = self
            .steps
            .iter()
            .map(|s| s.nr)
            .max()
            .map_or(NonZeroU32::MIN, |max| max.saturating_add(1));
        self.steps.push(Step {
            nr: next,
            description: None,
            kind,
        });
        self.steps.last_mut().expect("pushed in this call")
    }
}

/// One step of a thermal cycling protocol (schema `stepType`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Step {
    /// The step number. Steps are numbered from 1, incrementing by one,
    /// and must be unique within the protocol.
    pub nr: NonZeroU32,
    /// Free-text description.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub description: Option<String>,
    /// What the step does. The schema expresses this as a choice of
    /// exactly one of five step elements.
    pub kind: StepKind,
}

impl Step {
    /// Creates a step.
    #[must_use]
    pub fn new(nr: NonZeroU32, kind: StepKind) -> Self {
        Self {
            nr,
            description: None,
            kind,
        }
    }
}

/// The action a protocol [`Step`] performs (the schema's `xs:choice` of
/// `temperature` / `gradient` / `loop` / `pause` / `lidOpen`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum StepKind {
    /// Hold a constant temperature on the block.
    #[serde(rename = "temperature")]
    Temperature(TemperatureStep),
    /// Form a temperature gradient across the block.
    #[serde(rename = "gradient")]
    Gradient(GradientStep),
    /// Jump back to an earlier step a number of times (or skip forward).
    #[serde(rename = "loop")]
    Loop(LoopStep),
    /// Pause at a temperature (typically the final hold).
    #[serde(rename = "pause")]
    Pause(PauseStep),
    /// Wait for the user to open the lid (e.g. to add enzymes), keeping
    /// the previous step's temperature, then continue.
    #[serde(rename = "lidOpen")]
    LidOpen,
}

/// A constant-temperature step (schema `temperatureType`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TemperatureStep {
    /// The temperature of the step in °C.
    pub temperature: f64,
    /// Duration of the step in whole seconds.
    ///
    /// The schema types this as `xs:positiveInteger`, so sub-second holds
    /// are not expressible in RDML. See [`TemperatureStep::hold`] for a
    /// [`std::time::Duration`] view.
    pub duration: NonZeroU32,
    /// Cycle-dependent temperature change:
    /// `actual = temperature + temperature_change × cycle`.
    #[serde(
        rename = "temperatureChange",
        skip_serializing_if = "Option::is_none",
        default
    )]
    pub temperature_change: Option<f64>,
    /// Cycle-dependent duration change in seconds:
    /// `actual = duration + duration_change × cycle`.
    #[serde(
        rename = "durationChange",
        skip_serializing_if = "Option::is_none",
        default
    )]
    pub duration_change: Option<i32>,
    /// If set, acquire a measurement during this step and store it as
    /// real-time or melting-curve data.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub measure: Option<Measure>,
    /// Allowed temperature change towards this step in °C per second;
    /// absent means the instrument's maximal ramp rate.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub ramp: Option<f64>,
}

impl TemperatureStep {
    /// Creates a hold at `temperature` °C for `duration` seconds.
    #[must_use]
    pub fn new(temperature: f64, duration: NonZeroU32) -> Self {
        Self {
            temperature,
            duration,
            temperature_change: None,
            duration_change: None,
            measure: None,
            ramp: None,
        }
    }

    /// The hold time as a [`std::time::Duration`].
    #[must_use]
    pub fn hold(&self) -> Duration {
        Duration::from_secs(self.duration.get().into())
    }
}

/// A gradient step: different temperatures across the block's columns
/// (schema `gradientType`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GradientStep {
    /// The high end of the gradient in °C.
    #[serde(rename = "highTemperature")]
    pub high_temperature: f64,
    /// The low end of the gradient in °C.
    #[serde(rename = "lowTemperature")]
    pub low_temperature: f64,
    /// Duration of the step in whole seconds (see
    /// [`TemperatureStep::duration`] on the second granularity).
    pub duration: NonZeroU32,
    /// Cycle-dependent change applied to both gradient ends.
    #[serde(
        rename = "temperatureChange",
        skip_serializing_if = "Option::is_none",
        default
    )]
    pub temperature_change: Option<f64>,
    /// Cycle-dependent duration change in seconds.
    #[serde(
        rename = "durationChange",
        skip_serializing_if = "Option::is_none",
        default
    )]
    pub duration_change: Option<i32>,
    /// If set, acquire a measurement during this step.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub measure: Option<Measure>,
    /// Allowed ramp rate in °C per second; absent means maximal.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub ramp: Option<f64>,
}

impl GradientStep {
    /// Creates a gradient from `low_temperature` to `high_temperature` °C
    /// held for `duration` seconds.
    #[must_use]
    pub fn new(high_temperature: f64, low_temperature: f64, duration: NonZeroU32) -> Self {
        Self {
            high_temperature,
            low_temperature,
            duration,
            temperature_change: None,
            duration_change: None,
            measure: None,
            ramp: None,
        }
    }

    /// The hold time as a [`std::time::Duration`].
    #[must_use]
    pub fn hold(&self) -> Duration {
        Duration::from_secs(self.duration.get().into())
    }
}

/// A loop step (schema `loopType`): jump to step [`goto`](Self::goto) and
/// run the enclosed steps [`repeat`](Self::repeat) times in total.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LoopStep {
    /// The step number to jump to.
    pub goto: NonZeroU32,
    /// How many times the loop is run in total (the first pass counts as
    /// run 0; the loop body executes exactly `repeat` times).
    ///
    /// Note a self-contradiction in the schema: the type is
    /// `xs:positiveInteger`, but the schema's own documentation says a
    /// *forward* `goto` (skipping steps) requires `repeat = 0`. This crate
    /// therefore accepts 0 and [`validate`](crate::Rdml::validate) does
    /// not reject it.
    pub repeat: u32,
}

impl LoopStep {
    /// Creates a loop returning to step `goto`, executing `repeat` times.
    #[must_use]
    pub fn new(goto: NonZeroU32, repeat: u32) -> Self {
        Self { goto, repeat }
    }
}

/// A pause step (schema `pauseType`), typically the final hold of an
/// amplification protocol.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PauseStep {
    /// The temperature maintained during the pause, in °C.
    pub temperature: f64,
}

impl PauseStep {
    /// Creates a pause holding `temperature` °C.
    #[must_use]
    pub fn new(temperature: f64) -> Self {
        Self { temperature }
    }
}