1#[path = "support/lfq_matrices.rs"]
3mod lfq_matrices;
4use clap::Parser;
5use koth_ms::{
6 alignment::{align_runs, RunInput},
7 config::AlignConfig,
8 input::{check_batch_mobility_scales, discover_runs, read_features, read_hills},
9 lfq::{
10 consensus::{audit_consensus, VARIANT_NAMES},
11 quantify_consensus,
12 },
13 output::{build_align_report, write_align_report, AlignTiming},
14};
15use lfq_matrices::matrices;
16use std::{io::Write, path::PathBuf, time::Instant};
17
18#[derive(Parser)]
19struct Args {
20 batch: PathBuf,
21 config: PathBuf,
22 output: PathBuf,
23 #[arg(long)]
25 take: Option<usize>,
26 #[arg(long, default_value = "")]
28 quantify: String,
29 #[arg(long)]
31 quick: bool,
32 #[arg(long)]
34 conditioned: bool,
35}
36
37fn main() -> anyhow::Result<()> {
38 env_logger::init();
39 let args = Args::parse();
40 anyhow::ensure!(
41 !(args.quick && args.conditioned),
42 "conditioned controls require the full audit"
43 );
44 let config = AlignConfig::from_toml(&args.config)?;
45 let all_paths = discover_runs(&args.batch)?;
46 anyhow::ensure!(!all_paths.is_empty(), "no runs");
47 let count = args.take.unwrap_or(all_paths.len());
48 anyhow::ensure!(
49 count >= 2 && count <= all_paths.len(),
50 "take must be 2..=number of runs"
51 );
52 let selected: Vec<_> = (0..count).map(|i| i * all_paths.len() / count).collect();
53 let paths: Vec<_> = selected.iter().map(|&i| &all_paths[i]).collect();
54 let mut runs = Vec::new();
55 for p in &paths {
56 runs.push(RunInput {
57 name: p.name.clone(),
58 features: read_features(&p.features_path)?,
59 hills: Vec::new(),
60 scan_times: Vec::new(),
61 rt_bounds: None,
62 });
63 }
64 check_batch_mobility_scales(
65 paths
66 .iter()
67 .zip(&runs)
68 .map(|(p, r)| (*p, r.features.as_slice())),
69 )?;
70 if let Some(name) = &config.alignment.reference_run {
71 anyhow::ensure!(
72 runs.iter().any(|r| &r.name == name),
73 "reference run is not selected"
74 );
75 }
76 std::fs::create_dir_all(&args.output)?;
77 std::fs::write(args.output.join("runs.json"),serde_json::to_vec_pretty(&paths.iter().enumerate().map(|(id,p)|
78 serde_json::json!({"run_id":id,"name":p.name,"features":p.features_path})).collect::<Vec<_>>())?)?;
79 let t = Instant::now();
80 let alignment = align_runs(&runs, &config.alignment);
81 let alignment_sec = t.elapsed().as_secs_f64();
82 let t = Instant::now();
83 let audit = audit_consensus(&runs, &alignment, &config.lfq, !args.quick);
84 let audit_sec = t.elapsed().as_secs_f64();
85 std::fs::write(
86 args.output.join("audit.json"),
87 serde_json::to_vec_pretty(&serde_json::json!({
88 "variants":VARIANT_NAMES,"controls":audit.null_group_counts,"full_controls":!args.quick,"alignment_sec":alignment_sec,"audit_sec":audit_sec,
89 "n_runs":runs.len(),"n_groups":audit.groups.len(),"reference":alignment.reference_name}))?,
90 )?;
91 let mut out = csv::WriterBuilder::new()
92 .delimiter(b'\t')
93 .from_path(args.output.join("candidates.tsv"))?;
94 let mut header: Vec<String> = [
95 "candidate_id",
96 "massCalib",
97 "mz",
98 "charge",
99 "rtApex",
100 "im",
101 "combined_score",
102 "n_contributing_runs",
103 "weak_members",
104 ]
105 .map(String::from)
106 .to_vec();
107 for v in VARIANT_NAMES {
108 for field in [
109 "score",
110 "global",
111 "repeat",
112 "density",
113 "pooled",
114 "conditioned",
115 ] {
116 header.push(format!("{v}_{field}"));
117 }
118 }
119 out.write_record(header)?;
120 let mut members = csv::WriterBuilder::new()
121 .delimiter(b'\t')
122 .from_path(args.output.join("members.tsv"))?;
123 members.write_record(["candidate_id", "run_id", "feature_idx", "is_primary"])?;
124 for (i, g) in audit.groups.iter().enumerate() {
125 let weak = g
126 .members
127 .iter()
128 .filter(|&&(r, f)| runs[r].features[f as usize].combined_score < 0.5)
129 .count();
130 let mut row = vec![
131 i.to_string(),
132 g.neutral_mass.to_string(),
133 g.ref_mz.to_string(),
134 g.charge.to_string(),
135 g.ref_rt.to_string(),
136 g.ref_im.to_string(),
137 g.seed_combined_score.to_string(),
138 g.n_contributing_runs.to_string(),
139 weak.to_string(),
140 ];
141 for v in 0..4 {
142 row.extend(
143 [
144 audit.scores[i][v],
145 audit.q_global[i][v],
146 audit.q_repeat[i][v],
147 audit.q_density[i][v],
148 audit.q_pooled[i][v],
149 audit.q_conditioned[i][v],
150 ]
151 .map(|x| x.to_string()),
152 );
153 }
154 out.write_record(row)?;
155 for &(r, f) in &g.members {
156 members.write_record([
157 i.to_string(),
158 r.to_string(),
159 f.to_string(),
160 (g.per_run_feature[r] == Some(f)).to_string(),
161 ])?;
162 }
163 }
164 out.flush()?;
165 members.flush()?;
166 let selected_q = if args.conditioned {
167 &audit.q_conditioned
168 } else {
169 &audit.q_global
170 };
171 for variant in args.quantify.split(',').filter(|s| !s.is_empty()) {
172 let v: usize = variant.parse()?;
173 anyhow::ensure!(v < 4, "variant must be 0..3");
174 let folder = args.output.join(VARIANT_NAMES[v]);
175 std::fs::create_dir_all(&folder)?;
176 let groups = audit
177 .groups
178 .iter()
179 .enumerate()
180 .filter(|(i, g)| {
181 g.n_contributing_runs >= 2
182 && selected_q[*i][v] <= config.lfq.consensus.max_group_qvalue
183 })
184 .map(|(i, g)| {
185 let mut g = g.clone();
186 g.group_score = audit.scores[i][v];
187 g.group_qvalue = selected_q[i][v];
188 g
189 })
190 .collect();
191 let t = Instant::now();
192 let matrix = quantify_consensus(&runs, &alignment, &config.lfq, groups, |i| {
193 read_hills(&paths[i].hills_path).expect("read hills")
194 });
195 let lfq_sec = t.elapsed().as_secs_f64();
196 matrices(&matrix, &folder)?;
197 std::fs::write(folder.join("align_config.toml"), config.to_toml_string()?)?;
198 let report = build_align_report(
199 &runs,
200 &alignment,
201 &matrix,
202 &config,
203 AlignTiming {
204 alignment_sec,
205 lfq_sec,
206 total_sec: alignment_sec + audit_sec + lfq_sec,
207 },
208 );
209 write_align_report(&report, &folder.join("align_report.json"))?;
210 let mut meta = std::fs::File::create(folder.join("variant.json"))?;
211 writeln!(
212 meta,
213 "{}",
214 serde_json::json!({"variant":VARIANT_NAMES[v],"group_gate":config.lfq.consensus.max_group_qvalue,"control": if args.conditioned { "ten quality-conditioned rank shifts" } else { "first five global shifts" }})
215 )?;
216 }
217 Ok(())
218}