diff --git a/crates/io/src/mass_spec.rs b/crates/io/src/mass_spec.rs index ed8ca44..3ec6507 100644 --- a/crates/io/src/mass_spec.rs +++ b/crates/io/src/mass_spec.rs @@ -141,6 +141,86 @@ pub struct ChromatogramChannel { pub values: Vec, } +/// One programmed composition in a liquid-chromatography gradient method. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LcGradientPoint { + pub time_min: f64, + pub flow_ml_min: f64, + pub percent_b: f64, +} + +/// The method information needed to relate a chromatographic retention time to +/// the programmed mobile-phase composition. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LiquidChromatographyMethod { + pub name: Option, + pub run_time_min: f64, + pub solvent_a: Option, + pub solvent_b: Option, + pub gradient: Vec, + pub detector_wavelengths_nm: Vec, + pub column: Option, +} + +impl LiquidChromatographyMethod { + pub fn validate(&self) -> Result<(), String> { + if !self.run_time_min.is_finite() || self.run_time_min <= 0.0 { + return Err("LC method has an invalid run time".to_owned()); + } + if self.gradient.len() < 2 { + return Err("LC method needs at least two gradient points".to_owned()); + } + let mut previous = f64::NEG_INFINITY; + for point in &self.gradient { + if !point.time_min.is_finite() + || point.time_min < 0.0 + || point.time_min <= previous + || !point.flow_ml_min.is_finite() + || point.flow_ml_min <= 0.0 + || !point.percent_b.is_finite() + || !(0.0..=100.0).contains(&point.percent_b) + { + return Err("LC method has an invalid gradient point".to_owned()); + } + previous = point.time_min; + } + if self + .gradient + .last() + .is_some_and(|point| point.time_min > self.run_time_min) + { + return Err("LC method gradient extends past its run time".to_owned()); + } + if self + .detector_wavelengths_nm + .iter() + .any(|value| !value.is_finite() || *value <= 0.0) + { + return Err("LC method has an invalid detector wavelength".to_owned()); + } + Ok(()) + } + + /// Linearly interpolate the programmed B composition between time points. + pub fn percent_b_at(&self, time_min: f64) -> Option { + if !time_min.is_finite() || time_min < 0.0 { + return None; + } + let first = self.gradient.first()?; + if time_min <= first.time_min { + return Some(first.percent_b); + } + for pair in self.gradient.windows(2) { + let [left, right] = pair else { continue }; + if time_min <= right.time_min { + let fraction = (time_min - left.time_min) / (right.time_min - left.time_min); + return Some(left.percent_b + fraction * (right.percent_b - left.percent_b)); + } + } + self.gradient.last().map(|point| point.percent_b) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MassSpecRun { pub source: String, diff --git a/crates/io/src/waters.rs b/crates/io/src/waters.rs index 57ff0fc..987de3b 100644 --- a/crates/io/src/waters.rs +++ b/crates/io/src/waters.rs @@ -11,6 +11,8 @@ use std::path::{Path, PathBuf}; mod chromatograms; use chromatograms::parse_auxiliary_channels; +mod inlet; +pub use inlet::load as load_inlet_method; mod metadata; use metadata::{ FunctionRecord, classify_function, parse_function_table, parse_header, parse_polarities, @@ -702,10 +704,16 @@ fn unsupported(id: AcquisitionStreamId, layout: &Layout, instrument: Option<&str } fn provenance(bundle: &Bundle) -> Provenance { - let mut parameter_paths = ["_header.txt", "_functns.inf", "_extern.inf", "_chroms.inf"] - .into_iter() - .filter_map(|name| bundle.file(name).cloned()) - .collect::>(); + let mut parameter_paths = [ + "_header.txt", + "_functns.inf", + "_extern.inf", + "_chroms.inf", + "_inlet.inf", + ] + .into_iter() + .filter_map(|name| bundle.file(name).cloned()) + .collect::>(); parameter_paths.sort(); let mut companion_paths = bundle .functions diff --git a/crates/io/src/waters/inlet.rs b/crates/io/src/waters/inlet.rs new file mode 100644 index 0000000..3e0895b --- /dev/null +++ b/crates/io/src/waters/inlet.rs @@ -0,0 +1,161 @@ +use super::*; +use crate::{LcGradientPoint, LiquidChromatographyMethod}; + +/// Read the human-readable inlet method embedded in a MassLynx RAW bundle. +/// A missing method is distinct from a malformed method because callers can +/// supply an explicit method for otherwise valid LC–MS data. +pub fn load(path: &Path) -> Result, IoError> { + let bundle = Bundle::discover(path)?; + let Some(path) = bundle.file("_inlet.inf") else { + return Ok(None); + }; + parse(&std::fs::read(path)?).map(Some) +} + +pub(super) fn parse(bytes: &[u8]) -> Result { + let text = String::from_utf8_lossy(bytes); + let mut name = None; + let mut run_time_min = None; + let mut solvent_a = None; + let mut solvent_b = None; + let mut gradient = Vec::new(); + let mut detector_wavelengths_nm = Vec::new(); + let mut column = None; + let mut in_gradient = false; + + for raw_line in text.lines() { + let line = raw_line.trim(); + if let Some(value) = value_after(line, "Inlet Method File:") { + name = Some(clean(value)); + } else if run_time_min.is_none() + && let Some(value) = value_after(line, "Run Time:") + { + run_time_min = first_number(value); + } else if let Some(value) = value_after(line, "Solvent Name A:") { + solvent_a = Some(clean(value)); + } else if let Some(value) = value_after(line, "Solvent Name B:") { + solvent_b = Some(clean(value)); + } else if line.eq_ignore_ascii_case("[Gradient Table]") { + in_gradient = true; + } else if in_gradient && line.starts_with("Run Events:") { + in_gradient = false; + } else if in_gradient { + if let Some(point) = gradient_point(line)? { + gradient.push(point); + } + } else if let Some(value) = value_after(line, "Wavelength:") + && let Some(wavelength) = first_number(value) + { + detector_wavelengths_nm.push(wavelength); + } else if let Some(value) = value_after(line, "Column Type:") { + column = Some(clean(value)); + } + } + + detector_wavelengths_nm.sort_by(f64::total_cmp); + detector_wavelengths_nm.dedup_by(|left, right| left.total_cmp(right).is_eq()); + let method = LiquidChromatographyMethod { + name, + run_time_min: run_time_min.ok_or_else(|| invalid("_INLET.INF has no pump run time"))?, + solvent_a, + solvent_b, + gradient, + detector_wavelengths_nm, + column, + }; + method.validate().map_err(invalid)?; + Ok(method) +} + +fn gradient_point(line: &str) -> Result, IoError> { + let mut tokens = line.split_whitespace(); + let Some(index) = tokens.next() else { + return Ok(None); + }; + if !index.ends_with('.') + || !index[..index.len() - 1] + .bytes() + .all(|byte| byte.is_ascii_digit()) + { + return Ok(None); + } + let values = tokens.collect::>(); + let (time, flow, percent_b) = if values + .first() + .is_some_and(|value| value.eq_ignore_ascii_case("Initial")) + { + ( + 0.0, + parse_number(values.get(1), "initial flow rate")?, + parse_number(values.get(3), "initial %B")?, + ) + } else { + ( + parse_number(values.first(), "gradient time")?, + parse_number(values.get(1), "gradient flow rate")?, + parse_number(values.get(3), "gradient %B")?, + ) + }; + Ok(Some(LcGradientPoint { + time_min: time, + flow_ml_min: flow, + percent_b, + })) +} + +fn parse_number(value: Option<&&str>, label: &str) -> Result { + value + .and_then(|value| value.parse().ok()) + .filter(|value: &f64| value.is_finite()) + .ok_or_else(|| invalid(format!("_INLET.INF has invalid {label}"))) +} + +fn first_number(value: &str) -> Option { + value + .split_whitespace() + .find_map(|token| token.parse::().ok()) + .filter(|value| value.is_finite()) +} + +fn value_after<'a>(line: &'a str, label: &str) -> Option<&'a str> { + line.get(..label.len()) + .filter(|prefix| prefix.eq_ignore_ascii_case(label)) + .map(|_| line[label.len()..].trim()) + .filter(|value| !value.is_empty()) +} + +fn clean(value: &str) -> String { + value.replace('\u{fffd}', "").trim().to_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_acquity_gradient_and_detector_details() { + let method = parse( + br#"Inlet Method File: d:\methods\5-95 +-- PUMP -- + Run Time: 10.00 min + Solvent Name A: Water + acid + Solvent Name B: Acetonitrile + acid + [Gradient Table] + Time(min) Flow Rate %A %B Curve + 1. Initial 0.300 95.0 5.0 Initial + 2. 6.00 0.300 5.0 95.0 6 + 3. 8.00 0.300 5.0 95.0 1 + 4. 10.00 0.300 95.0 5.0 1 + Run Events: Yes + Wavelength: 214 nm + Wavelength: 254 nm + Column Type: ACQUITY Protein BEH C4 +"#, + ) + .unwrap(); + assert_eq!(method.gradient.len(), 4); + assert_eq!(method.percent_b_at(3.0), Some(50.0)); + assert_eq!(method.detector_wavelengths_nm, [214.0, 254.0]); + assert_eq!(method.column.as_deref(), Some("ACQUITY Protein BEH C4")); + } +}