use anyhow::{Context, Result};
use std::path::Path;
use std::time::Instant;
use koth_ms::config::KothConfig;
use uno_core::{Feature, FeatureMatrix};
pub const DEFAULT_KOTH_CONFIG: &str = include_str!("koth_default.toml");
pub const BRUKER_KOTH_CONFIG: &str = include_str!("koth_bruker.toml");
pub fn default_config() -> Result<KothConfig> {
let cfg: KothConfig =
toml::from_str(DEFAULT_KOTH_CONFIG).context("parsing bundled default koth config")?;
cfg.features.validate()?;
Ok(cfg)
}
pub fn bruker_config() -> Result<KothConfig> {
let cfg: KothConfig =
toml::from_str(BRUKER_KOTH_CONFIG).context("parsing bundled Bruker koth config")?;
cfg.features.validate()?;
Ok(cfg)
}
pub fn is_bruker(path: &Path) -> bool {
path.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("d"))
}
pub fn default_config_for(path: &Path) -> Result<KothConfig> {
if is_bruker(path) {
bruker_config()
} else {
default_config()
}
}
pub fn features_from_raw(path: &Path, config: &KothConfig) -> Result<FeatureMatrix> {
let mut cfg = config.clone();
cfg.file.decoy_mode = false;
let t = Instant::now();
let out = koth_ms::run_pipeline(
path,
&cfg,
&koth_ms::PipelineOptions {
scoring: true,
emit_ms2: false,
},
)
.with_context(|| format!("koth feature finding on {}", path.display()))?;
log::info!(
"koth: {} hills, {} scored features ({:.1}s)",
out.hills.len(),
out.features.len(),
t.elapsed().as_secs_f64()
);
let scored = out.features;
let run_name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("run")
.to_string();
let features = scored
.iter()
.filter_map(|sf| {
let f = &sf.feature;
let mass = sf.monoisotopic_neutral_mass(cfg.file.polarity)?;
let im = f.im_apex();
Some(Feature {
mass,
mz: sf.monoisotopic_mz(),
charge: f.charge,
rt: f.rt_apex(),
im: if im != 0.0 { Some(im) } else { None },
combined_score: sf.combined_score,
n_contributing_runs: 1,
n_isotopes: Some(f.hills.len() as f64),
n_scans: Some(f.n_scans_total() as f64),
isoerror: None,
isoerror2: None,
rt_width: None,
cosine_score: Some(sf.cosine_score),
isotope_score: Some(sf.isotope_score),
ppm_error: Some(f.ppm_error),
neutron_offset: Some(sf.neutron_offset as f64),
intensities: vec![f.total_intensity_at_apex()],
})
})
.collect::<Vec<_>>();
Ok(FeatureMatrix {
run_names: vec![run_name],
features,
matrix_evidence: None,
joint_evidence: None,
joint_cell_evidence: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn koth070_profiles_and_lfq_config_are_valid() {
let orbitrap = default_config().unwrap();
let bruker = bruker_config().unwrap();
for cfg in [&orbitrap, &bruker] {
assert_eq!(cfg.features.isotope_evidence_ratio_sigma, 0.75);
assert_eq!(cfg.features.isotope_evidence_cosine_shape, 2.0);
}
let standalone: KothConfig =
toml::from_str(include_str!("../../../tests/fixtures/koth_uno.toml")).unwrap();
assert_eq!(
toml::to_string(&standalone).unwrap(),
toml::to_string(&orbitrap).unwrap()
);
let align: koth_ms::config::AlignConfig =
toml::from_str(include_str!("../../../tests/fixtures/koth_align.toml")).unwrap();
align.lfq.consensus.validate().unwrap();
assert_eq!(align.lfq.consensus.max_group_qvalue, 0.05);
assert_eq!(align.lfq.rt_window_pct, 0.005);
assert_eq!(align.lfq.im_tolerance, 0.015);
assert!(align.output.export_long);
}
#[test]
fn legacy_lfq_admission_flags_are_rejected() {
for setting in [
"min_group_size = 1",
"allow_replicated_weak_seeds = true",
"min_seed_combined_score = 0.75",
"min_member_combined_score = 0.5",
] {
let text = format!("[lfq.consensus]\n{setting}\n");
assert!(toml::from_str::<koth_ms::config::AlignConfig>(&text).is_err());
}
}
}