use std::path::PathBuf;
use std::time::Instant;
use anyhow::Context;
use clap::Parser;
use koth_ms::{
config::{KothConfig, OutputFormat},
mem::log_mem,
output::{
build_features_report, build_hills_report, write_features_parquet, write_features_tsv,
write_hills_parquet, write_hills_tsv, write_ms2_hills_parquet, write_ms2_hills_tsv,
write_report, MobilityReport, RunReport,
},
run_features, run_hills_streaming, run_ms2_hills_streaming, run_scoring,
};
#[derive(Parser, Debug)]
#[command(
name = "koth_ff",
about = "High-performance LC-MS feature finder for timsTOF and mzML data",
version = koth_ms::VERSION
)]
struct Args {
input: PathBuf,
#[arg(short, long, default_value = ".")]
output: PathBuf,
#[arg(short, long)]
config: Option<PathBuf>,
#[arg(long)]
no_scoring: bool,
#[arg(long)]
ms2: bool,
#[arg(long)]
recalibrate: bool,
#[arg(long)]
filter_baseline_hills: bool,
#[arg(long, default_value = "info")]
log_level: String,
#[arg(long)]
threads: Option<usize>,
}
fn main() -> anyhow::Result<()> {
let args = Args::parse();
let log_filter = format!("koth_ff={0},koth_ms={0}", args.log_level);
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(&log_filter)).init();
let mut config = match &args.config {
Some(cfg_path) => KothConfig::from_toml(cfg_path)
.with_context(|| format!("Failed to load config from {}", cfg_path.display()))?,
None => KothConfig::default(),
};
if args.ms2 {
config.file.ms2_hills_enabled = true;
}
if args.recalibrate {
config.file.mz_recalibration = true;
}
if args.filter_baseline_hills {
config.hills.filter_large_baseline_hills = true;
}
if args.threads.is_some() {
config.file.n_threads = args.threads;
}
if let Some(n) = config.file.n_threads {
rayon::ThreadPoolBuilder::new()
.num_threads(n)
.build_global()
.context("Failed to configure the global thread pool")?;
log::info!("Thread pool: {n} threads");
}
let file_stem = args
.input
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("output");
let out_dir = args.output.join(file_stem);
std::fs::create_dir_all(&out_dir)
.with_context(|| format!("Failed to create output directory {}", out_dir.display()))?;
log::info!("Input: {}", args.input.display());
log_mem("startup");
log::info!("Output directory: {}", out_dir.display());
let total_start = Instant::now();
log::info!("Detecting hills (streaming)...");
let t = Instant::now();
let hills = run_hills_streaming(&args.input, &config.hills, &config.file)
.with_context(|| format!("Failed to detect hills from {}", args.input.display()))?;
log::info!(
"[timing] hill detection: {:.2?} ({} hills)",
t.elapsed(),
hills.len()
);
log_mem(&format!("after hill detection ({} hills)", hills.len()));
let hills_ext = match config.output.format {
OutputFormat::Tsv => "tsv",
OutputFormat::Parquet => "parquet",
};
let hills_path = out_dir.join(format!("hills.{hills_ext}"));
let t = Instant::now();
match config.output.format {
OutputFormat::Tsv => write_hills_tsv(&hills, &hills_path),
OutputFormat::Parquet => write_hills_parquet(&hills, &hills_path),
}
.with_context(|| format!("Failed to write hills to {}", hills_path.display()))?;
log::info!("[timing] write hills.{hills_ext}: {:.2?}", t.elapsed());
if config.file.ms2_hills_enabled {
log::info!("Detecting MS2 hills (streaming, per isolation window)...");
let t = Instant::now();
let ms2_hills = run_ms2_hills_streaming(&args.input, &config.ms2_hills(), &config.file)
.with_context(|| format!("Failed to detect MS2 hills from {}", args.input.display()))?;
log::info!(
"[timing] MS2 hill detection: {:.2?} ({} hills)",
t.elapsed(),
ms2_hills.len()
);
log_mem(&format!(
"after MS2 hill detection ({} hills)",
ms2_hills.len()
));
if !ms2_hills.is_empty() {
let ms2_path = out_dir.join(format!("hills_ms2.{hills_ext}"));
let t = Instant::now();
match config.output.format {
OutputFormat::Tsv => write_ms2_hills_tsv(&ms2_hills, &ms2_path),
OutputFormat::Parquet => write_ms2_hills_parquet(&ms2_hills, &ms2_path),
}
.with_context(|| format!("Failed to write MS2 hills to {}", ms2_path.display()))?;
log::info!("[timing] write hills_ms2.{hills_ext}: {:.2?}", t.elapsed());
} else {
log::info!("No MS2 hills produced (input may have no MS2 spectra).");
}
}
log::info!("Detecting features...");
let t = Instant::now();
let features =
run_features(&hills, &config.features, &config.file).context("Feature detection failed")?;
log::info!(
"[timing] feature detection: {:.2?} ({} features)",
t.elapsed(),
features.len()
);
let hills_report = build_hills_report(&hills);
drop(hills);
log_mem(&format!(
"after feature detection ({} features)",
features.len()
));
let scored = if args.no_scoring {
features
.iter()
.map(|f| koth_ms::models::ScoredFeature {
feature: f.clone(),
neutron_offset: 0,
isotope_score: 0.0,
cosine_score: f.cosine_score,
combined_score: 0.0,
theoretical_pattern: Vec::new(),
})
.collect::<Vec<_>>()
} else {
log::info!("Scoring features...");
let t = Instant::now();
let s = run_scoring(
&features,
&config.scoring,
&config.features,
config.file.polarity,
);
log::info!("[timing] scoring: {:.2?}", t.elapsed());
s
};
let features_ext = match config.output.format {
OutputFormat::Tsv => "tsv",
OutputFormat::Parquet => "parquet",
};
let features_path = out_dir.join(format!("features.{features_ext}"));
let t = Instant::now();
match config.output.format {
OutputFormat::Tsv => write_features_tsv(&scored, &features_path, config.file.polarity),
OutputFormat::Parquet => {
write_features_parquet(&scored, &features_path, config.file.polarity)
}
}
.with_context(|| format!("Failed to write features to {}", features_path.display()))?;
log::info!(
"[timing] write features.{features_ext}: {:.2?}",
t.elapsed()
);
let features_report = build_features_report(&scored);
let report = RunReport {
hills: hills_report,
features: features_report,
mobility: mobility_report(&args.input, &config)?,
};
let report_path = out_dir.join("report.json");
write_report(&report, &report_path).context("Failed to write report")?;
log::info!("[timing] total: {:.2?}", total_start.elapsed());
let config_path = out_dir.join("config.toml");
let config_str = config
.to_toml_string()
.context("Failed to serialize config")?;
std::fs::write(&config_path, config_str)
.with_context(|| format!("Failed to write config to {}", config_path.display()))?;
log::info!("Done. Results in {}", out_dir.display());
Ok(())
}
fn mobility_report(input: &std::path::Path, config: &KothConfig) -> anyhow::Result<MobilityReport> {
match koth_ms::io::detect_format(input) {
#[cfg(feature = "tdf")]
koth_ms::io::InputFormat::BrukerD => {
let conv =
koth_ms::io::tims_calibration::load(input, config.file.bruker_mobility_scale)
.map_err(anyhow::Error::msg)
.context("Failed to read the run's mobility calibration")?;
Ok(MobilityReport::bruker(&conv))
}
_ => {
let _ = config;
Ok(MobilityReport::as_read())
}
}
}