use crate::codec::try_encode_frame_type2;
use crate::crop::CropGate;
use crate::dia_ms1::DiaMs1Gate;
use crate::error::{DnoiseError, Result};
use crate::frame::FlatFrame;
use crate::mobility::{self, MobilityScale, ScanToMobility};
use crate::msms::build_msms_keep;
use crate::neighbor;
use crate::params::{CropParams, DiaMs1WindowParams, FilterParams, Ms1PolygonParams, Stages};
use crate::polygon::PolygonGate;
use crate::provenance::NeighborUsage;
use crate::tdf::{self, DiaWindows, FrameUpdate};
use crate::tsr::ConvertableDomain;
use crate::tsr::{FrameReader, MetadataReader};
use dnoise_core::{DecodedFrame, Denoiser};
use rayon::prelude::*;
use std::fs;
use std::io::{BufWriter, Read, Write};
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use tracing::{debug, info, warn};
const CHUNK: usize = 2048;
#[derive(Debug, Clone, Copy, Default, serde::Serialize)]
#[non_exhaustive]
pub struct DenoiseStats {
pub frames: usize,
pub ms1_frames: usize,
pub msms_frames: usize,
pub cropped_frames: usize,
pub processed_frames: usize,
pub raw_points: u64,
pub kept_points: u64,
pub raw_ms1_points: u64,
pub kept_ms1_points: u64,
pub raw_summed_intensity: u64,
pub kept_summed_intensity: u64,
pub raw_ms1_summed_intensity: u64,
pub kept_ms1_summed_intensity: u64,
pub raw_msms_summed_intensity: u64,
pub kept_msms_summed_intensity: u64,
pub ms1_neighbor_usage: NeighborUsage,
pub msms_neighbor_usage: NeighborUsage,
pub dry_run: bool,
pub input_binary_bytes: u64,
pub output_binary_bytes: u64,
pub elapsed_seconds: f64,
pub worker_threads: usize,
pub active_gates: crate::provenance::ActiveGates,
pub multiple_calibrations: bool,
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct Progress {
pub frames_done: usize,
pub frames_total: usize,
}
#[derive(Debug, Clone, Copy)]
pub struct SampleSpec {
pub fraction: f64,
pub seed: u64,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct RunOptions<'a> {
pub force: bool,
pub dry_run: bool,
pub crop: Option<&'a CropParams>,
pub crop_only: bool,
pub sample: Option<SampleSpec>,
pub cancel: Option<&'a AtomicBool>,
pub frame_batch_size: Option<usize>,
pub skip_validation: bool,
}
fn frame_sampled(index: usize, seed: u64, fraction: f64) -> bool {
let mut z = seed
.wrapping_add(index as u64)
.wrapping_mul(0x9E37_79B9_7F4A_7C15);
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
let threshold = (fraction.clamp(0.0, 1.0) * u64::MAX as f64) as u64;
z <= threshold
}
pub fn denoise(
input: &Path,
output: &Path,
params: &FilterParams,
stages: &Stages,
force: bool,
) -> Result<DenoiseStats> {
let opts = RunOptions {
force,
..RunOptions::default()
};
denoise_with_options(input, output, params, stages, &opts, |_| {})
}
pub fn denoise_with_options<F: FnMut(Progress)>(
input: &Path,
output: &Path,
params: &FilterParams,
stages: &Stages,
options: &RunOptions,
progress: F,
) -> Result<DenoiseStats> {
execute(input, output, params, stages, options, progress, false)
}
pub fn denoise_in_place<F: FnMut(Progress)>(
input: &Path,
params: &FilterParams,
stages: &Stages,
options: &RunOptions,
progress: F,
) -> Result<DenoiseStats> {
if options.dry_run {
return Err(DnoiseError::InvalidInput(
"in-place cannot be a dry run".into(),
));
}
execute(input, input, params, stages, options, progress, true)
}
#[allow(clippy::too_many_arguments)]
fn execute<F: FnMut(Progress)>(
input: &Path,
output: &Path,
params: &FilterParams,
stages: &Stages,
options: &RunOptions,
progress: F,
in_place: bool,
) -> Result<DenoiseStats> {
let started = std::time::Instant::now();
crate::validation::parameters(params, stages, options)?;
if !options.dry_run && !in_place {
crate::output::check_disjoint(input, output)?;
}
if options.cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
return Err(DnoiseError::Cancelled);
}
crate::validation::inspect_cancellable(input, !options.skip_validation, options.cancel)?;
crate::provenance::read(input)?;
if options.dry_run {
let mut stats = run(input, output, params, stages, options, progress)?;
stats.elapsed_seconds = started.elapsed().as_secs_f64();
return Ok(stats);
}
let tx = crate::output::OutputTransaction::begin(
input,
output,
options.force || in_place,
in_place,
)?;
let mut stats = run(input, tx.path(), params, stages, options, progress)?;
crate::validation::inspect_cancellable(tx.path(), !options.skip_validation, options.cancel)?;
if options.cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
return Err(DnoiseError::Cancelled);
}
stats.elapsed_seconds = started.elapsed().as_secs_f64();
crate::provenance::write(input, tx.path(), params, stages, options, &stats)?;
tx.commit()?;
Ok(stats)
}
pub fn denoise_with_progress<F: FnMut(Progress)>(
input: &Path,
output: &Path,
params: &FilterParams,
stages: &Stages,
force: bool,
progress: F,
) -> Result<DenoiseStats> {
let opts = RunOptions {
force,
..RunOptions::default()
};
denoise_with_options(input, output, params, stages, &opts, progress)
}
fn run<F: FnMut(Progress)>(
input: &Path,
output: &Path,
params: &FilterParams,
stages: &Stages,
options: &RunOptions,
mut progress: F,
) -> Result<DenoiseStats> {
let &RunOptions {
force: _,
dry_run,
crop,
crop_only,
sample,
cancel,
frame_batch_size,
skip_validation: _,
} = options;
let in_tdf = input.join("analysis.tdf");
let in_bin = input.join("analysis.tdf_bin");
if !in_tdf.is_file() || !in_bin.is_file() {
return Err(DnoiseError::NotADotD(input.to_path_buf()));
}
let meta = tdf::read_frame_meta(&in_tdf)?;
let acquisition = tdf::inspect_acquisition(&in_tdf, &meta)?;
let scheme = acquisition.kind;
let reader = FrameReader::new(input).map_err(|e| DnoiseError::OpenFrames(e.to_string()))?;
let n_frames = reader.len();
let mut denoiser = Denoiser::new(params, stages, scheme, meta.clone(), crop_only)?;
let effective_stages = *denoiser.stages();
let stages = &effective_stages;
let &Stages {
halo,
denoise_msms,
dia_window,
dda_window,
dia_per_window,
dia_ms1,
ms1_polygon,
..
} = stages;
let neighbors = if crop_only {
None
} else {
neighbor::build(&in_tdf, &meta, scheme, stages)?
};
if !dry_run {
copy_dir_except(input, output, "analysis.tdf_bin")?;
}
let n_ms1 = meta.iter().filter(|m| m.is_ms1()).count();
let n_empty = meta.iter().filter(|m| m.num_peaks == 0).count();
if n_ms1 == 0 && !crop_only && stages.denoise_msms.is_none() && !stages.filter_all_frames {
warn!("no MS1 frames; MS1-only denoising will retain all points");
}
info!(input = %input.display(), output = %output.display(), "denoise: starting");
info!(
scheme = %scheme,
frames = n_frames,
ms1 = n_ms1,
msms = n_frames - n_ms1,
empty = n_empty,
"denoise: frame inventory"
);
if !crop_only {
match crate::units::for_run(&in_tdf, params, stages) {
Ok(eqs) => {
for e in eqs {
info!("units: {}", e.line());
}
}
Err(e) => debug!("units: physical equivalents unavailable ({e})"),
}
}
let prm_windows = (scheme == crate::Acquisition::PrmPasef && denoise_msms.is_some())
.then_some(acquisition.prm_windows);
if prm_windows.is_some() {
warn!(
"experimental prm-PASEF MS/MS denoising: target boundaries enforced; validate downstream quantification"
);
}
let (msms_keep, dia_msms) = match denoise_msms {
Some(_) if prm_windows.is_some() => (None, None),
Some(mp) => {
let windows = tdf::read_pasef_msms(&in_tdf)?;
if windows.is_empty() {
info!("MS/MS denoise: diaPASEF whole-frame path (no PasefFrameMsMsInfo)");
(None, Some(mp))
} else {
let keep = build_msms_keep(&reader, &meta, &windows, mp, halo, cancel)?;
info!(
isolation_events = windows.len(),
"MS/MS denoise: ddaPASEF per-precursor path"
);
(Some(keep), None)
}
}
None => (None, None),
};
let dia_windows: Option<DiaWindows> = if dia_window.is_some() || dia_per_window {
let w = tdf::read_dia_windows(&in_tdf)?;
if w.is_empty() {
debug!("diaPASEF window feature requested but no windows found (ddaPASEF?) — skipped");
None
} else {
info!("diaPASEF isolation-window scheme loaded");
Some(w)
}
} else {
None
};
let dia_regions = if scheme == crate::Acquisition::DiaPasef
&& dia_per_window
&& (denoise_msms.is_some() || stages.filter_all_frames)
&& !crop_only
{
Some(tdf::dia::read(&in_tdf)?)
} else {
None
};
let dda_windows: Option<DiaWindows> = if dda_window.is_some() {
let w = DiaWindows::from_pasef(&tdf::read_pasef_msms(&in_tdf)?);
if w.is_empty() {
debug!(
"ddaPASEF window gate requested but no isolation events found (diaPASEF?) — skipped"
);
None
} else {
info!("ddaPASEF MS/MS out-of-window gate active");
Some(w)
}
} else {
None
};
let dia_ms1_gate = match dia_ms1.filter(|_| !crop_only) {
Some(mp) => build_dia_ms1_gate(&in_tdf, mp, &meta, stages.mobility_scale)?,
None => None,
};
if dia_ms1.is_some() {
match &dia_ms1_gate {
Some(_) => info!("diaPASEF MS1 out-of-window gate active"),
None => debug!("diaPASEF MS1 gate requested but no isolation windows — skipped"),
}
}
let polygon_gate = match ms1_polygon.filter(|_| !crop_only) {
Some(pp) => build_polygon_gate(&in_tdf, pp, &meta, stages.mobility_scale)?,
None => None,
};
if ms1_polygon.is_some() {
match &polygon_gate {
Some(_) => info!("MS1 selection-polygon gate active"),
None => {
debug!("MS1 polygon gate requested but run stores no usable polygon — skipped")
}
}
}
let crop_gate = match crop {
Some(cp) if !cp.is_empty() => {
require_single_calibration(
&in_tdf,
cp.mz_min.is_some() || cp.mz_max.is_some(),
cp.im_min.is_some() || cp.im_max.is_some(),
"physical crop (remove the m/z or mobility bounds)",
)?;
let md =
MetadataReader::new(&in_tdf).map_err(|e| DnoiseError::Metadata(e.to_string()))?;
let num_scans = meta.iter().map(|m| m.num_scans).max().unwrap_or(0);
let im = if cp.im_min.is_some() || cp.im_max.is_some() {
mobility::load(&in_tdf, stages.mobility_scale, md.im_converter)?
} else {
ScanToMobility::Linear(md.im_converter)
};
let g = CropGate::build(
cp,
num_scans,
|mz| md.mz_converter.invert(mz),
|k0| im.invert(k0),
);
info!(
point_crop = g.is_active(),
rt_crop = cp.has_rt(),
crop_only,
"crop: region-of-interest gate built"
);
g.is_active().then_some(g)
}
_ => None,
};
denoiser.set_neighbors(neighbors);
denoiser.set_msms_keep(msms_keep);
denoiser.set_prm_windows(prm_windows);
denoiser.set_whole_frame_msms(dia_msms.is_some());
denoiser.set_dia_windows(dia_windows);
denoiser.set_dia_regions(dia_regions);
denoiser.set_dda_windows(dda_windows);
denoiser.set_dia_ms1_gate(dia_ms1_gate);
denoiser.set_polygon_gate(polygon_gate);
denoiser.set_crop_gate(crop_gate);
denoiser.set_rt_crop(crop);
let denoiser = denoiser;
let selected: Vec<usize> = match sample {
Some(s) if dry_run => (0..n_frames)
.filter(|&i| frame_sampled(i, s.seed, s.fraction))
.collect(),
_ => (0..n_frames).collect(),
};
match sample {
Some(_) if !dry_run => {
warn!("--sample ignored without --dry-run (a real run must process every frame)")
}
Some(s) => info!(
sampled = selected.len(),
total = n_frames,
fraction = s.fraction,
"dry-run: processing a frame sample"
),
None => {}
}
let n_process = selected.len();
let header_len = tdf::binary_header_len(&in_tdf)?;
let mut bin = if dry_run {
None
} else {
let mut b = BufWriter::new(fs::File::create(output.join("analysis.tdf_bin"))?);
if header_len > 0 {
let mut header = vec![0u8; header_len as usize];
fs::File::open(&in_bin).and_then(|mut f| f.read_exact(&mut header))?;
b.write_all(&header)?;
}
Some(b)
};
progress(Progress {
frames_done: 0,
frames_total: n_process,
});
let mut offset: u64 = header_len;
let mut updates: Vec<FrameUpdate> = Vec::with_capacity(n_process);
let mut raw_points: u64 = 0;
let mut kept_points: u64 = 0;
let mut raw_ms1: u64 = 0;
let mut kept_ms1: u64 = 0;
let mut raw_summed: u64 = 0;
let mut kept_summed: u64 = 0;
let mut raw_ms1_summed = 0;
let mut kept_ms1_summed = 0;
let mut ms1_neighbor_usage = NeighborUsage::default();
let mut msms_neighbor_usage = NeighborUsage::default();
let mut cropped_frames: usize = 0;
let mut frames_done: usize = 0;
for chunk in selected.chunks(frame_batch_size.unwrap_or(CHUNK)) {
if let Some(c) = cancel
&& c.load(Ordering::Relaxed)
{
return Err(DnoiseError::Cancelled);
}
let processed: Vec<ProcessedFrame> = chunk
.par_iter()
.map(|&i| {
if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
return Err(DnoiseError::Cancelled);
}
process_frame(&reader, &denoiser, i, cancel)
})
.collect::<Result<_>>()?;
for pf in processed {
if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
return Err(DnoiseError::Cancelled);
}
raw_points += pf.raw_points;
kept_points += pf.num_peaks;
raw_summed += pf.raw_summed;
kept_summed += pf.summed_intensities;
if pf.is_ms1 {
raw_ms1 += pf.raw_points;
kept_ms1 += pf.num_peaks;
raw_ms1_summed += pf.raw_summed;
kept_ms1_summed += pf.summed_intensities;
ms1_neighbor_usage.add(pf.neighbor_usage);
} else {
msms_neighbor_usage.add(pf.neighbor_usage);
}
if pf.cropped {
cropped_frames += 1;
}
if let Some(b) = bin.as_mut() {
b.write_all(&pf.record)?;
updates.push(FrameUpdate {
frame_id: pf.frame_id,
tims_id: offset,
num_peaks: pf.num_peaks,
max_intensity: pf.max_intensity,
summed_intensities: pf.summed_intensities,
});
offset += pf.record.len() as u64;
}
frames_done += 1;
progress(Progress {
frames_done,
frames_total: n_process,
});
}
}
if let Some(b) = bin.as_mut() {
b.flush()?;
b.get_ref().sync_all()?;
}
drop(bin);
if !dry_run {
tdf::update_metadata(&output.join("analysis.tdf"), &updates)?;
}
let kept_pct = if raw_points > 0 {
((10_000.0 * kept_points as f64 / raw_points as f64).round()) / 100.0
} else {
0.0
};
info!(
dry_run,
processed_frames = n_process,
raw_points,
kept_points,
kept_pct,
"denoise: complete"
);
Ok(DenoiseStats {
frames: n_frames,
ms1_frames: n_ms1,
msms_frames: n_frames - n_ms1,
cropped_frames,
processed_frames: n_process,
raw_points,
kept_points,
raw_ms1_points: raw_ms1,
kept_ms1_points: kept_ms1,
raw_summed_intensity: raw_summed,
kept_summed_intensity: kept_summed,
raw_ms1_summed_intensity: raw_ms1_summed,
kept_ms1_summed_intensity: kept_ms1_summed,
raw_msms_summed_intensity: raw_summed - raw_ms1_summed,
kept_msms_summed_intensity: kept_summed - kept_ms1_summed,
ms1_neighbor_usage,
msms_neighbor_usage,
dry_run,
input_binary_bytes: fs::metadata(&in_bin)?.len(),
output_binary_bytes: if dry_run {
0
} else {
fs::metadata(output.join("analysis.tdf_bin"))?.len()
},
elapsed_seconds: 0.0,
worker_threads: rayon::current_num_threads(),
active_gates: crate::provenance::ActiveGates {
ms1_neighbors: denoiser.neighbors().is_some()
&& stages.frame_half_width > 0
&& n_ms1 > 0,
prm_neighbors: denoiser.neighbors().is_some()
&& stages.neighbors.prm_radius > 0
&& scheme == crate::Acquisition::PrmPasef,
dia_neighbors: denoiser.neighbors().is_some()
&& stages.neighbors.dia_radius > 0
&& scheme == crate::Acquisition::DiaPasef,
dia_scan_varying: denoiser.dia_regions().is_some_and(|r| r.has_scanning()),
prm_per_event: denoiser.prm_windows().is_some() && !crop_only,
ms1_polygon: denoiser.polygon_gate().is_some() && !crop_only,
dia_ms1: denoiser.dia_ms1_gate().is_some() && !crop_only,
dia_window: denoiser.dia_windows().is_some()
&& dia_window.is_some()
&& !crop_only
&& (stages.filter_all_frames || denoise_msms.is_some()),
dda_window: denoiser.dda_windows().is_some()
&& !crop_only
&& (stages.filter_all_frames || denoise_msms.is_some()),
dia_per_window: denoiser.dia_windows().is_some()
&& dia_per_window
&& !crop_only
&& (stages.filter_all_frames || denoise_msms.is_some()),
},
multiple_calibrations: {
let (mz, im) = tdf::count_calibration_segments(&in_tdf)?;
mz > 1 || im > 1
},
})
}
struct ProcessedFrame {
neighbor_usage: NeighborUsage,
frame_id: usize,
record: Vec<u8>,
raw_points: u64,
num_peaks: u64,
max_intensity: u32,
summed_intensities: u64,
raw_summed: u64,
is_ms1: bool,
cropped: bool,
}
fn read_flat(reader: &FrameReader, j: usize) -> dnoise_core::Result<FlatFrame> {
reader
.get(j)
.map(|f| FlatFrame::from_frame(&f))
.map_err(|e| dnoise_core::Error::FrameRead {
index: j,
message: e.to_string(),
})
}
fn process_frame(
reader: &FrameReader,
denoiser: &Denoiser,
i: usize,
cancel: Option<&AtomicBool>,
) -> Result<ProcessedFrame> {
let d = denoiser.process(i, &|j| read_flat(reader, j), cancel)?;
let num_peaks = d.survivors.len() as u64;
let summed_intensities: u64 = d.survivors.iter().map(|&(_, _, it)| it as u64).sum();
let max_intensity = d.survivors.iter().map(|&(_, _, it)| it).max().unwrap_or(0);
let record = try_encode_frame_type2(d.num_scans, &d.survivors)?;
Ok(ProcessedFrame {
neighbor_usage: d.neighbor_usage,
frame_id: d.frame_id,
record,
raw_points: d.raw_points,
num_peaks,
max_intensity,
summed_intensities,
raw_summed: d.raw_summed,
is_ms1: d.is_ms1,
cropped: d.cropped,
})
}
fn require_single_calibration(path: &Path, mz: bool, im: bool, operation: &str) -> Result<()> {
if !mz && !im {
return Ok(());
}
let (mz_count, im_count) = tdf::count_calibration_segments(path)?;
if (mz && mz_count > 1) || (im && im_count > 1) {
return Err(DnoiseError::InvalidInput(format!(
"{operation} cannot use run-level conversion with multiple calibration references (m/z: {mz_count}, mobility: {im_count}); per-calibration physical filtering is not supported"
)));
}
Ok(())
}
fn build_dia_ms1_gate(
in_tdf: &Path,
p: &DiaMs1WindowParams,
meta: &[tdf::FrameMeta],
scale: MobilityScale,
) -> Result<Option<DiaMs1Gate>> {
let boxes = tdf::read_dia_ms1_boxes(in_tdf)?;
if boxes.is_empty() {
return Ok(None);
}
require_single_calibration(
in_tdf,
true,
true,
"DIA MS1 gate (disable dia_ms1_window / --no-dia-ms1-window)",
)?;
let md = MetadataReader::new(in_tdf).map_err(|e| DnoiseError::Metadata(e.to_string()))?;
let num_scans = meta.iter().map(|m| m.num_scans).max().unwrap_or(0);
if num_scans == 0 {
return Ok(None);
}
let im = mobility::load(in_tdf, scale, md.im_converter)?;
let mz_to_tof = |mz: f64| md.mz_converter.invert(mz);
Ok(DiaMs1Gate::from_windows(
&boxes, p, mz_to_tof, &im, num_scans,
))
}
fn build_polygon_gate(
in_tdf: &Path,
p: &Ms1PolygonParams,
meta: &[tdf::FrameMeta],
scale: MobilityScale,
) -> Result<Option<PolygonGate>> {
if !tdf::read_dia_windows(in_tdf)?.is_empty() {
return Ok(None); }
let Some((mz, im)) = tdf::read_selection_polygon(in_tdf)? else {
return Ok(None);
};
require_single_calibration(
in_tdf,
true,
true,
"MS1 polygon gate (disable ms1_polygon / --no-ms1-polygon)",
)?;
let md = MetadataReader::new(in_tdf).map_err(|e| DnoiseError::Metadata(e.to_string()))?;
let num_scans = meta.iter().map(|m| m.num_scans).max().unwrap_or(0);
if num_scans == 0 {
return Ok(None);
}
let k0 = mobility::load(in_tdf, scale, md.im_converter)?;
let im_at_scan = |s: u32| k0.convert(s as f64);
let mz_to_tof = |mz: f64| md.mz_converter.invert(mz);
let Some(mut gate) = PolygonGate::build(
&mz, &im, num_scans, im_at_scan, mz_to_tof, p.mz_pad, p.im_pad,
) else {
return Ok(None);
};
gate.check_contains_unpadded(&mz, &im, im_at_scan, mz_to_tof)
.map_err(|e| {
DnoiseError::InvalidInput(format!(
"{}: MS1 polygon gate self-check failed (m/z pad {} Th, 1/K0 pad {}): the \
padded gate does not contain the selection polygon ({e}). Pads must be >= 0; \
otherwise this is a dnoise bug, please report it. Disable the gate with \
ms1_polygon = false / --no-ms1-polygon to proceed.",
in_tdf.display(),
p.mz_pad,
p.im_pad
))
})?;
gate.overlap = p.overlap;
Ok(Some(gate))
}
fn copy_dir_except(src: &Path, dst: &Path, skip_top: &str) -> Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let name = entry.file_name();
let from = entry.path();
let to = dst.join(&name);
if !entry.file_type()?.is_dir() && !entry.file_type()?.is_file() {
return Err(DnoiseError::InvalidInput(format!(
"non-regular files (including symlinks) inside acquisitions are unsupported: {}",
from.display()
)));
}
if from.is_dir() {
copy_dir_recursive(&from, &to)?;
} else if name != skip_top {
fs::copy(&from, &to)?;
}
}
Ok(())
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let from = entry.path();
let to = dst.join(entry.file_name());
if !entry.file_type()?.is_dir() && !entry.file_type()?.is_file() {
return Err(DnoiseError::InvalidInput(format!(
"non-regular files (including symlinks) inside acquisitions are unsupported: {}",
from.display()
)));
}
if from.is_dir() {
copy_dir_recursive(&from, &to)?;
} else {
fs::copy(&from, &to)?;
}
}
Ok(())
}
pub struct Calibration {
tof2mz: crate::tsr::Tof2MzConverter,
scan2im: ScanToMobility,
}
impl Calibration {
pub fn tof_to_mz(&self, tof: u32) -> f64 {
self.tof2mz.convert(tof as f64)
}
pub fn scan_to_im(&self, scan: u32) -> f64 {
self.scan2im.convert(scan as f64)
}
}
pub struct RunContext<'a> {
reader: FrameReader,
denoiser: Denoiser<'a>,
calibration: Calibration,
n_ms1: usize,
}
impl<'a> RunContext<'a> {
pub fn open(input: &Path, params: &FilterParams, stages: &'a Stages<'a>) -> Result<Self> {
crate::validation::parameters(params, stages, &RunOptions::default())?;
let in_tdf = input.join("analysis.tdf");
let in_bin = input.join("analysis.tdf_bin");
if !in_tdf.is_file() || !in_bin.is_file() {
return Err(DnoiseError::NotADotD(input.to_path_buf()));
}
let meta = tdf::read_frame_meta(&in_tdf)?;
let acquisition = tdf::inspect_acquisition(&in_tdf, &meta)?;
let scheme = acquisition.kind;
let reader = FrameReader::new(input).map_err(|e| DnoiseError::OpenFrames(e.to_string()))?;
let mut denoiser = Denoiser::new(params, stages, scheme, meta.clone(), false)?;
let effective_stages = *denoiser.stages();
let stages = &effective_stages;
let n_ms1 = meta.iter().filter(|m| m.is_ms1()).count();
let neighbors = neighbor::build(&in_tdf, &meta, scheme, stages)?;
let prm_windows = (scheme == crate::Acquisition::PrmPasef && stages.denoise_msms.is_some())
.then_some(acquisition.prm_windows);
let (msms_keep, dia_msms) = match stages.denoise_msms {
Some(_) if prm_windows.is_some() => (None, None),
Some(mp) => {
let windows = tdf::read_pasef_msms(&in_tdf)?;
if windows.is_empty() {
(None, Some(mp))
} else {
let keep = build_msms_keep(&reader, &meta, &windows, mp, stages.halo, None)?;
(Some(keep), None)
}
}
None => (None, None),
};
let dia_windows = if stages.dia_window.is_some() || stages.dia_per_window {
let w = tdf::read_dia_windows(&in_tdf)?;
if w.is_empty() { None } else { Some(w) }
} else {
None
};
let dia_regions = if scheme == crate::Acquisition::DiaPasef
&& stages.dia_per_window
&& (stages.denoise_msms.is_some() || stages.filter_all_frames)
{
Some(tdf::dia::read(&in_tdf)?)
} else {
None
};
let dda_windows = if stages.dda_window.is_some() {
let w = DiaWindows::from_pasef(&tdf::read_pasef_msms(&in_tdf)?);
if w.is_empty() { None } else { Some(w) }
} else {
None
};
let dia_ms1_gate = match stages.dia_ms1 {
Some(mp) => build_dia_ms1_gate(&in_tdf, mp, &meta, stages.mobility_scale)?,
None => None,
};
let polygon_gate = match stages.ms1_polygon {
Some(pp) => build_polygon_gate(&in_tdf, pp, &meta, stages.mobility_scale)?,
None => None,
};
denoiser.set_neighbors(neighbors);
denoiser.set_msms_keep(msms_keep);
denoiser.set_prm_windows(prm_windows);
denoiser.set_whole_frame_msms(dia_msms.is_some());
denoiser.set_dia_windows(dia_windows);
denoiser.set_dia_regions(dia_regions);
denoiser.set_dda_windows(dda_windows);
denoiser.set_dia_ms1_gate(dia_ms1_gate);
denoiser.set_polygon_gate(polygon_gate);
let md = MetadataReader::new(&in_tdf).map_err(|e| DnoiseError::Metadata(e.to_string()))?;
let (_, im_segments) = tdf::count_calibration_segments(&in_tdf)?;
let scan2im = if im_segments > 1 {
warn!(
"run has {im_segments} mobility calibrations; Calibration::scan_to_im uses the linear scale"
);
ScanToMobility::Linear(md.im_converter)
} else {
mobility::load(&in_tdf, stages.mobility_scale, md.im_converter).unwrap_or_else(|e| {
warn!("{e}; Calibration::scan_to_im uses the linear scale");
ScanToMobility::Linear(md.im_converter)
})
};
let calibration = Calibration {
tof2mz: md.mz_converter,
scan2im,
};
Ok(RunContext {
reader,
denoiser,
calibration,
n_ms1,
})
}
pub fn len(&self) -> usize {
self.denoiser.len()
}
pub fn is_empty(&self) -> bool {
self.denoiser.is_empty()
}
pub fn ms1_frames(&self) -> usize {
self.n_ms1
}
pub fn is_ms1(&self, i: usize) -> bool {
self.denoiser.is_ms1(i)
}
pub fn calibration(&self) -> &Calibration {
&self.calibration
}
pub fn process(&self, i: usize) -> Result<DecodedFrame> {
Ok(self
.denoiser
.process(i, &|j| read_flat(&self.reader, j), None)?)
}
pub fn denoiser(&self) -> &Denoiser<'a> {
&self.denoiser
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frame_sampled_fraction_one_keeps_every_frame() {
assert!((0..1000).all(|i| frame_sampled(i, 42, 1.0)));
}
#[test]
fn frame_sampled_fraction_zero_keeps_essentially_none() {
let kept = (0..1000).filter(|&i| frame_sampled(i, 42, 0.0)).count();
assert!(kept <= 1, "expected ~0 kept at fraction 0.0, got {kept}");
}
#[test]
fn frame_sampled_is_deterministic() {
assert!((0..500).all(|i| frame_sampled(i, 7, 0.5) == frame_sampled(i, 7, 0.5)));
}
#[test]
fn frame_sampled_roughly_matches_the_requested_fraction() {
let n = 20_000;
let kept = (0..n).filter(|&i| frame_sampled(i, 123, 0.25)).count();
let frac = kept as f64 / n as f64;
assert!((0.22..0.28).contains(&frac), "fraction {frac} out of band");
}
#[test]
fn denoise_stats_default_is_zeroed() {
let s = DenoiseStats::default();
assert_eq!(s.frames, 0);
assert_eq!(s.raw_points, 0);
assert_eq!(s.kept_points, 0);
assert!(!s.dry_run);
}
#[test]
fn copy_dir_except_skips_named_top_level_entry_and_recurses() {
let base = std::env::temp_dir().join(format!(
"dnoise_copy_except_{}_{}",
std::process::id(),
"writer"
));
let src = base.join("src");
let dst = base.join("dst");
let _ = fs::remove_dir_all(&base);
fs::create_dir_all(src.join("sub")).unwrap();
fs::write(src.join("keep.txt"), b"a").unwrap();
fs::write(src.join("analysis.tdf_bin"), b"skip me").unwrap();
fs::write(src.join("sub").join("nested.txt"), b"b").unwrap();
copy_dir_except(&src, &dst, "analysis.tdf_bin").unwrap();
assert!(dst.join("keep.txt").is_file());
assert!(dst.join("sub").join("nested.txt").is_file());
assert!(
!dst.join("analysis.tdf_bin").exists(),
"the skipped top-level entry must not be copied"
);
let _ = fs::remove_dir_all(&base);
}
}