use std::cmp::Ordering;
use std::collections::BTreeSet;
use std::fs::File;
use std::io::BufReader;
use std::path::Path;
use opentfraw::generic_data::GenericValue;
use opentfraw::{MsPower, RawFileReader};
use crate::error::KothError;
use crate::models::{IsolationWindow, Peak, Spectrum};
#[cfg(test)]
#[path = "thermo_tests.rs"]
mod tests;
pub(crate) struct RawSource {
raw: RawFileReader,
file: BufReader<File>,
events_aligned: bool,
}
impl RawSource {
pub(crate) fn open(path: &Path) -> Result<Self, KothError> {
let open_err = |e: &dyn std::fmt::Display| {
KothError::ThermoError(format!(
"could not open Thermo .raw file '{}': {e}",
path.display()
))
};
let raw = RawFileReader::open_path(path).map_err(|e| open_err(&e))?;
let file = File::open(path).map_err(|e| open_err(&e))?;
let mut source = Self {
raw,
file: BufReader::new(file),
events_aligned: true,
};
source.events_aligned = source.check_event_alignment();
if !source.events_aligned {
log::warn!(
"Thermo '{}': scan events are inconsistent with the per-scan trailer; \
MS levels come from the trailer's Master Scan Number and scan-event \
values (precursor m/z, profile calibration) are not used",
path.display()
);
}
Ok(source)
}
fn check_event_alignment(&self) -> bool {
if self.raw.flat_peaks {
return true;
}
let (mut dependent, mut contradicted) = (0usize, 0usize);
for idx in 0..self.n_scans() {
if self.master_scan(idx).is_some_and(|m| m > 0) {
dependent += 1;
if self.event_level(idx) == 1 {
contradicted += 1;
}
}
}
contradicted * 1000 <= dependent
}
pub(crate) fn n_scans(&self) -> u32 {
self.raw.num_scans
}
fn scan_number(&self, idx: u32) -> u32 {
self.raw.run_header.sample_info.first_scan_number + idx
}
fn event_level(&self, idx: u32) -> u32 {
match self
.raw
.scan_events
.get(idx as usize)
.and_then(|e| e.preamble.ms_power())
{
None | Some(MsPower::Undefined) | Some(MsPower::Ms1) => 1,
Some(MsPower::Ms2) => 2,
Some(_) => 3,
}
}
pub(crate) fn ms_level(&self, idx: u32) -> u32 {
if self.raw.flat_peaks {
return 2;
}
if self.events_aligned {
return self.event_level(idx);
}
match self.master_scan(idx) {
Some(m) if m > 0 => 2,
_ => 1,
}
}
fn master_scan(&self, idx: u32) -> Option<i32> {
self.raw
.scan_params(self.scan_number(idx))
.and_then(|p| p.master_scan_number())
}
pub(crate) fn retention_time(&self, idx: u32) -> f64 {
self.raw
.scan_index
.get(idx as usize)
.map_or(f64::NAN, |e| e.start_time)
}
pub(crate) fn faims_cv(&self, idx: u32) -> Option<f32> {
let record = self.raw.scan_parameters(self.scan_number(idx))?;
record
.values
.iter()
.find(|(label, _)| label.trim_end_matches(':').trim() == "FAIMS CV")
.and_then(|(_, value)| faims_value(value))
}
pub(crate) fn peaks(&mut self, idx: u32) -> Result<Vec<Peak>, KothError> {
let scan_number = self.scan_number(idx);
let read_err = |e: opentfraw::Error| {
KothError::ThermoError(format!("could not read scan {scan_number}: {e}"))
};
let raw_peaks = self
.raw
.read_peaks_only(&mut self.file, scan_number)
.map_err(read_err)?;
if !raw_peaks.is_empty() || self.raw.flat_peaks {
return Ok(to_koth_peaks(raw_peaks.iter().map(|p| (p.mz, p.abundance))));
}
if !self.events_aligned {
log::warn!(
"Thermo scan {scan_number}: no centroid list and no trusted profile \
calibration; skipping"
);
return Ok(Vec::new());
}
let packet = self
.raw
.read_scan(&mut self.file, scan_number)
.map_err(read_err)?;
let Some(profile) = packet.profile else {
return Ok(Vec::new());
};
let coefficients = self
.raw
.scan_events
.get(idx as usize)
.map(|e| e.coefficients.as_slice())
.unwrap_or(&[]);
let (mz, intensity): (Vec<f64>, Vec<f32>) = profile
.to_mz_intensity(coefficients)
.into_iter()
.filter(|&(m, _)| m > 0.0)
.map(|(m, i)| (m, i as f32))
.unzip();
Ok(pick_profile_peaks(&mz, &intensity))
}
pub(crate) fn isolation_window(&self, idx: u32) -> Option<IsolationWindow> {
let reaction = self
.raw
.scan_events
.get(idx as usize)
.filter(|_| self.events_aligned)
.and_then(|e| e.reactions.first());
let params = self.raw.scan_params(self.scan_number(idx));
let fallback = params
.as_ref()
.and_then(|p| p.monoisotopic_mz())
.unwrap_or(f64::NAN);
let center = reaction
.map(|r| r.precursor_mz)
.filter(|mz| mz.is_finite() && *mz > 0.0)
.unwrap_or(fallback);
let width = params
.as_ref()
.and_then(|p| p.isolation_width_mz())
.unwrap_or(f64::NAN);
let (lower, upper) = if width.is_finite() && width > 0.0 {
(center - width / 2.0, center + width / 2.0)
} else {
(f64::NAN, f64::NAN)
};
derive_isolation_window(center, lower, upper, fallback)
}
}
fn faims_value(value: &GenericValue) -> Option<f32> {
let cv = match value {
GenericValue::Float32(v) => *v,
GenericValue::Float64(v) => *v as f32,
GenericValue::Int8(v) => f32::from(*v),
GenericValue::Int16(v) => f32::from(*v),
GenericValue::Int32(v) => *v as f32,
GenericValue::String(s) => return parse_faims_cv(s),
_ => return None,
};
cv.is_finite().then_some(if cv == 0.0 { 0.0 } else { cv })
}
fn parse_faims_cv(value: &str) -> Option<f32> {
let token = value.split_whitespace().next()?;
let cv = token.parse::<f32>().ok()?;
cv.is_finite().then_some(if cv == 0.0 { 0.0 } else { cv })
}
fn to_koth_peaks(pairs: impl Iterator<Item = (f64, f32)>) -> Vec<Peak> {
let mut peaks: Vec<Peak> = pairs
.filter(|&(_, it)| it > 0.0)
.map(|(m, it)| Peak {
mz: m as f32,
intensity: it,
ion_mobility: 0.0,
})
.collect();
peaks.sort_by(|a, b| a.mz.partial_cmp(&b.mz).unwrap_or(Ordering::Equal));
peaks
}
fn pick_profile_peaks(mz: &[f64], intensity: &[f32]) -> Vec<Peak> {
use mzdata::mzsignal::{PeakFitType, PeakPicker};
if mz.len() < 3 {
return Vec::new();
}
let picker = PeakPicker {
fit_type: PeakFitType::Quadratic,
signal_to_noise_threshold: 1.0,
..Default::default()
};
if mz.windows(2).any(|w| w[1] < w[0]) {
log::warn!("Thermo profile scan m/z is not sorted; skipping peak picking");
return Vec::new();
}
let mut fitted = Vec::new();
if let Err(e) = picker.discover_peaks(mz, intensity, &mut fitted) {
log::warn!("Thermo profile peak picking failed: {e}");
return Vec::new();
}
to_koth_peaks(fitted.iter().map(|p| (p.mz, p.intensity)))
}
pub fn read_thermo(path: &Path) -> Result<Vec<Spectrum>, KothError> {
stream_thermo(path)?.collect()
}
pub fn stream_thermo(path: &Path) -> Result<super::SpectrumStream, KothError> {
let mut source = RawSource::open(path)?;
let mut indices = Vec::new();
for i in 0..source.n_scans() {
if source.ms_level(i) == 1 {
let rt = source.retention_time(i);
if !rt.is_finite() {
return Err(KothError::ThermoError(format!(
"non-finite RT for scan {i}"
)));
}
indices.push((i, rt));
}
}
indices.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal));
let mut indices = indices.into_iter();
let mut count = 0;
let mut finished = false;
Ok(Box::new(std::iter::from_fn(move || {
if finished {
return None;
}
for (i, retention_time) in indices.by_ref() {
let peaks = match source.peaks(i) {
Ok(p) => p,
Err(e) => {
finished = true;
return Some(Err(e));
}
};
if peaks.is_empty() {
continue;
}
let spectrum = Spectrum {
scan_index: count,
retention_time,
peaks,
ms_level: 1,
isolation_window: None,
faims_cv: source.faims_cv(i),
};
count += 1;
return Some(Ok(spectrum));
}
finished = true;
if count == 0 {
Some(Err(KothError::NoSpectra))
} else {
None
}
})))
}
const MIN_MS2_FOR_CLASSIFICATION: usize = 8;
const MAX_DIA_WINDOWS: usize = 2_000;
const MIN_RECURRENCE: f64 = 3.0;
#[derive(Debug, Clone, Copy)]
struct ScheduleStats {
n_ms2: usize,
distinct: usize,
recurrence: f64,
}
fn schedule_stats(windows: &[IsolationWindow]) -> ScheduleStats {
let n_ms2 = windows.len();
let distinct = windows
.iter()
.map(|w| w.key())
.collect::<BTreeSet<_>>()
.len();
let recurrence = if distinct == 0 {
0.0
} else {
n_ms2 as f64 / distinct as f64
};
ScheduleStats {
n_ms2,
distinct,
recurrence,
}
}
fn is_dia_schedule(windows: &[IsolationWindow]) -> bool {
let s = schedule_stats(windows);
s.n_ms2 >= MIN_MS2_FOR_CLASSIFICATION
&& s.distinct >= 1
&& s.distinct <= MAX_DIA_WINDOWS
&& s.recurrence >= MIN_RECURRENCE
}
fn derive_isolation_window(
target: f64,
lower: f64,
upper: f64,
precursor_mz: f64,
) -> Option<IsolationWindow> {
let center = if target.is_finite() && target > 0.0 {
target
} else if precursor_mz.is_finite() && precursor_mz > 0.0 {
precursor_mz
} else {
return None;
};
let (lo, hi) = if lower.is_finite() && upper.is_finite() && upper > lower && lower > 0.0 {
(lower, upper)
} else {
(center, center)
};
Some(IsolationWindow {
target: center,
lower: lo,
upper: hi,
})
}
pub fn read_thermo_ms2(path: &Path) -> Result<Vec<Spectrum>, KothError> {
let mut source = RawSource::open(path)?;
let mut ms2_scans: Vec<(u32, IsolationWindow)> = Vec::new();
for i in 0..source.n_scans() {
if source.ms_level(i) != 2 {
continue;
}
if let Some(w) = source.isolation_window(i) {
ms2_scans.push((i, w));
}
}
if ms2_scans.is_empty() {
log::warn!(
"Thermo .raw '{}' has no MS2 scans with an isolation window; emitting no MS2 \
(MS1 output is unaffected)",
path.display()
);
return Ok(Vec::new());
}
let scan_windows: Vec<IsolationWindow> = ms2_scans.iter().map(|&(_, w)| w).collect();
let stats = schedule_stats(&scan_windows);
if !is_dia_schedule(&scan_windows) {
log::warn!(
"Thermo .raw '{}' does not look like DIA ({} MS2 scans, {} distinct isolation \
windows, {:.1}x mean recurrence) — treating as DDA and emitting no MS2 \
(DDA precursor reconstruction is out of scope; MS1 output is unaffected)",
path.display(),
stats.n_ms2,
stats.distinct,
stats.recurrence,
);
return Ok(Vec::new());
}
let mut spectra: Vec<Spectrum> = Vec::with_capacity(ms2_scans.len());
for (i, window) in ms2_scans {
let peaks = source.peaks(i)?;
if peaks.is_empty() {
continue;
}
spectra.push(Spectrum {
scan_index: i as usize,
retention_time: source.retention_time(i), peaks,
ms_level: 2,
isolation_window: Some(window),
faims_cv: None,
});
}
spectra.sort_by(|a, b| {
a.retention_time
.partial_cmp(&b.retention_time)
.unwrap_or(Ordering::Equal)
});
let n_windows = spectra
.iter()
.filter_map(|s| s.isolation_window.map(|w| w.key()))
.collect::<BTreeSet<_>>()
.len();
log::info!(
"Read {} MS2 spectra across {} isolation windows from Thermo .raw \
(DIA, {:.1}x mean recurrence)",
spectra.len(),
n_windows,
stats.recurrence,
);
Ok(spectra)
}