1use std::fs::File;
9use std::io::BufWriter;
10use std::path::Path;
11use std::time::Instant;
12
13use rand::seq::SliceRandom;
14use rand::SeedableRng;
15use rayon::prelude::*;
16use serde::Serialize;
17
18const MAX_ANCHORS_PER_RUN: usize = 500;
24
25use crate::alignment::{drift::DriftFit, warp::RtWarp, AlignmentResult, RunAlignment, RunInput};
26use crate::config::AlignConfig;
27use crate::error::KothError;
28use crate::lfq::IntensityMatrix;
29
30fn median_sorted(sorted: &[f64]) -> f64 {
33 let n = sorted.len();
34 if n == 0 {
35 return f64::NAN;
36 }
37 if n % 2 == 0 {
38 (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0
39 } else {
40 sorted[n / 2]
41 }
42}
43
44fn percentile_sorted(sorted: &[f64], p: f64) -> f64 {
45 let n = sorted.len();
46 if n == 0 {
47 return f64::NAN;
48 }
49 let idx = ((p * (n as f64 - 1.0)).round() as usize).min(n - 1);
50 sorted[idx]
51}
52
53fn summary(values: &[f64]) -> ResidualSummary {
54 if values.is_empty() {
55 return ResidualSummary {
56 n: 0,
57 median: f64::NAN,
58 mad: f64::NAN,
59 std: f64::NAN,
60 };
61 }
62 let mut sorted: Vec<f64> = values.iter().copied().filter(|v| v.is_finite()).collect();
63 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
64 let med = median_sorted(&sorted);
65 let mut abs_dev: Vec<f64> = sorted.iter().map(|x| (x - med).abs()).collect();
66 abs_dev.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
67 let mad = median_sorted(&abs_dev);
68 ResidualSummary {
69 n: sorted.len(),
70 median: med,
71 mad,
72 std: crate::stats::std_dev(&sorted),
73 }
74}
75
76#[derive(Serialize)]
79pub struct ResidualSummary {
80 pub n: usize,
81 pub median: f64,
82 pub mad: f64,
83 pub std: f64,
84}
85
86#[derive(Serialize)]
87pub struct AnchorRecord {
88 pub ref_rt_norm: f64,
89 pub run_rt_norm: f64,
90 pub ref_rt: f64,
91 pub run_rt: f64,
92 pub ref_mz: f64,
93 pub run_mz: f64,
94 pub ppm_error: f64,
95 pub ref_im: f64,
96 pub run_im: f64,
97 pub im_delta: f64,
98 pub rt_residual_after_warp: f64,
99 pub ppm_residual_after_fit: f64,
100 pub im_residual_after_fit: f64,
101 pub rt_active: bool,
102 pub mass_active: bool,
103 pub im_active: bool,
104}
105
106#[derive(Serialize)]
107pub struct ResidualStats {
108 pub ppm_before: ResidualSummary,
109 pub ppm_after: ResidualSummary,
110 pub im_before: ResidualSummary,
111 pub im_after: ResidualSummary,
112 pub rt_norm_before: ResidualSummary,
113 pub rt_norm_after: ResidualSummary,
114}
115
116#[derive(Serialize)]
117pub struct AlignmentDiagnostics {
118 pub n_anchors: usize,
119 pub n_anchors_rt_active: usize,
120 pub n_anchors_mass_active: usize,
121 pub n_anchors_im_active: usize,
122 pub rt_warp: RtWarp,
123 pub mass_drift: DriftFit,
124 pub im_drift: DriftFit,
125 pub anchors: Vec<AnchorRecord>,
126 pub residual_stats: ResidualStats,
127}
128
129#[derive(Serialize)]
130pub struct LfqRunSummary {
131 pub n_detected: usize,
132 pub detection_rate: f64,
133 pub qvalue_percentiles: QvaluePercentiles,
134}
135
136#[derive(Serialize)]
137pub struct QvaluePercentiles {
138 pub p25: f64,
139 pub p50: f64,
140 pub p75: f64,
141 pub p95: f64,
142}
143
144#[derive(Serialize)]
145pub struct RunAlignReport {
146 pub name: String,
147 pub is_reference: bool,
148 pub rt_range: [f64; 2],
149 pub n_features: usize,
150 pub n_features_high_score: usize,
151 pub n_hills: usize,
152 #[serde(skip_serializing_if = "Option::is_none")]
154 pub alignment: Option<AlignmentDiagnostics>,
155 pub lfq: LfqRunSummary,
156}
157
158#[derive(Serialize)]
159pub struct Timing {
160 pub alignment_sec: f64,
161 pub lfq_sec: f64,
162 pub total_sec: f64,
163}
164
165#[derive(Serialize)]
166pub struct AlignReport {
167 pub schema_version: u32,
168 pub reference_run: String,
169 pub n_runs: usize,
170 pub n_consensus_features: usize,
171 pub config: AlignConfig,
172 pub runs: Vec<RunAlignReport>,
173 pub timing: Timing,
174}
175
176fn build_anchors_block(al: &RunAlignment) -> AlignmentDiagnostics {
179 let (run_lo, run_hi) = al.run_rt_range;
180 let (ref_lo, ref_hi) = al.ref_rt_range;
181 let run_span = run_hi - run_lo;
182 let ref_span = ref_hi - ref_lo;
183
184 let mut records: Vec<AnchorRecord> = Vec::with_capacity(al.anchors.len());
185
186 let mut ppm_before: Vec<f64> = Vec::with_capacity(al.anchors.len());
187 let mut ppm_after: Vec<f64> = Vec::with_capacity(al.anchors.len());
188 let mut im_before: Vec<f64> = Vec::new();
189 let mut im_after: Vec<f64> = Vec::new();
190 let mut rt_before: Vec<f64> = Vec::with_capacity(al.anchors.len());
191 let mut rt_after: Vec<f64> = Vec::with_capacity(al.anchors.len());
192
193 for (i, a) in al.anchors.iter().enumerate() {
194 let ppm = a.ppm_error();
195 let im_d = a.im_delta();
196 let rt_norm_delta = a.ref_rt_norm - a.run_rt_norm;
197
198 let warp_predicted_delta = al.rt_warp.delta_at_run_norm(a.run_rt_norm);
200 let rt_resid = rt_norm_delta - warp_predicted_delta;
201
202 let ppm_resid = ppm - al.mass_drift.predict(a.ref_rt_norm);
203
204 let im_resid = if a.ref_im != 0.0 && a.run_im != 0.0 {
206 im_d - al.im_drift.predict(a.ref_rt_norm)
207 } else {
208 f64::NAN
209 };
210
211 let run_rt_abs = run_lo + a.run_rt_norm * run_span;
213 let ref_rt_abs = ref_lo + a.ref_rt_norm * ref_span;
214
215 let rt_active = *al.rt_active.get(i).unwrap_or(&false);
216 let mass_active = *al.mass_active.get(i).unwrap_or(&false);
217 let im_active = *al.im_active.get(i).unwrap_or(&false);
218
219 records.push(AnchorRecord {
220 ref_rt_norm: a.ref_rt_norm,
221 run_rt_norm: a.run_rt_norm,
222 ref_rt: ref_rt_abs,
223 run_rt: run_rt_abs,
224 ref_mz: a.ref_mz,
225 run_mz: a.run_mz,
226 ppm_error: ppm,
227 ref_im: a.ref_im,
228 run_im: a.run_im,
229 im_delta: im_d,
230 rt_residual_after_warp: rt_resid,
231 ppm_residual_after_fit: ppm_resid,
232 im_residual_after_fit: im_resid,
233 rt_active,
234 mass_active,
235 im_active,
236 });
237
238 ppm_before.push(ppm);
239 ppm_after.push(ppm_resid);
240 if a.ref_im != 0.0 && a.run_im != 0.0 {
241 im_before.push(im_d);
242 im_after.push(im_resid);
243 }
244 rt_before.push(rt_norm_delta);
245 rt_after.push(rt_resid);
246 }
247
248 let n_total = records.len();
254 let sampled_records = if n_total > MAX_ANCHORS_PER_RUN {
255 let mut rng = rand::rngs::StdRng::seed_from_u64(0x0a11_c0de);
256 records.shuffle(&mut rng);
257 records.truncate(MAX_ANCHORS_PER_RUN);
258 records
259 } else {
260 records
261 };
262
263 AlignmentDiagnostics {
264 n_anchors: al.n_anchors,
265 n_anchors_rt_active: al.rt_active.iter().filter(|&&v| v).count(),
266 n_anchors_mass_active: al.mass_active.iter().filter(|&&v| v).count(),
267 n_anchors_im_active: al.im_active.iter().filter(|&&v| v).count(),
268 rt_warp: al.rt_warp.clone(),
269 mass_drift: al.mass_drift.clone(),
270 im_drift: al.im_drift.clone(),
271 anchors: sampled_records,
272 residual_stats: ResidualStats {
273 ppm_before: summary(&ppm_before),
274 ppm_after: summary(&ppm_after),
275 im_before: summary(&im_before),
276 im_after: summary(&im_after),
277 rt_norm_before: summary(&rt_before),
278 rt_norm_after: summary(&rt_after),
279 },
280 }
281}
282
283fn build_lfq_summary(matrix: &IntensityMatrix, run_idx: usize) -> LfqRunSummary {
284 let mut detected_q: Vec<f64> = Vec::new();
285 for feat in 0..matrix.n_features {
286 let intensity = matrix.intensity(feat, run_idx);
287 if intensity > 0.0 {
288 detected_q.push(matrix.q_value(feat, run_idx));
289 }
290 }
291 let n_detected = detected_q.len();
292 detected_q.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
293
294 let qp = QvaluePercentiles {
295 p25: percentile_sorted(&detected_q, 0.25),
296 p50: percentile_sorted(&detected_q, 0.50),
297 p75: percentile_sorted(&detected_q, 0.75),
298 p95: percentile_sorted(&detected_q, 0.95),
299 };
300
301 let detection_rate = if matrix.n_features == 0 {
302 0.0
303 } else {
304 n_detected as f64 / matrix.n_features as f64
305 };
306
307 LfqRunSummary {
308 n_detected,
309 detection_rate,
310 qvalue_percentiles: qp,
311 }
312}
313
314pub fn build_align_report(
315 runs: &[RunInput],
316 alignment: &AlignmentResult,
317 matrix: &IntensityMatrix,
318 config: &AlignConfig,
319 timing: Timing,
320) -> AlignReport {
321 let t_outer = Instant::now();
322 let min_combined = config.alignment.min_anchor_combined_score;
323
324 let rt_ranges: Vec<(f64, f64)> = runs
333 .par_iter()
334 .enumerate()
335 .map(|(run_idx, ri)| {
336 if run_idx == alignment.reference_idx {
337 ri.rt_range()
338 } else {
339 alignment
340 .alignments
341 .get(&ri.name)
342 .map(|al| al.run_rt_range)
343 .unwrap_or_else(|| ri.rt_range())
344 }
345 })
346 .collect();
347
348 let runs_out: Vec<RunAlignReport> = runs
349 .par_iter()
350 .enumerate()
351 .map(|(run_idx, ri)| {
352 let is_reference = run_idx == alignment.reference_idx;
353 let rt_range = rt_ranges[run_idx];
354 let n_features = ri.features.len();
355 let n_features_high_score = ri
356 .features
357 .iter()
358 .filter(|f| f.combined_score >= min_combined)
359 .count();
360 let n_hills = matrix.hills_per_run.get(run_idx).copied().unwrap_or(0);
363
364 let alignment_block = if is_reference {
365 None
366 } else {
367 alignment.alignments.get(&ri.name).map(build_anchors_block)
368 };
369
370 let lfq = build_lfq_summary(matrix, run_idx);
371
372 RunAlignReport {
373 name: ri.name.clone(),
374 is_reference,
375 rt_range: [rt_range.0, rt_range.1],
376 n_features,
377 n_features_high_score,
378 n_hills,
379 alignment: alignment_block,
380 lfq,
381 }
382 })
383 .collect();
384
385 let report = AlignReport {
386 schema_version: 1,
387 reference_run: alignment.reference_name.clone(),
388 n_runs: runs.len(),
389 n_consensus_features: matrix.n_features,
390 config: config.clone(),
391 runs: runs_out,
392 timing,
393 };
394 log::info!(
395 "[timing] build_align_report inner: {:.2?}",
396 t_outer.elapsed()
397 );
398 report
399}
400
401pub fn write_align_report(report: &AlignReport, path: &Path) -> Result<(), KothError> {
404 let file = File::create(path)?;
410 let mut writer = BufWriter::with_capacity(1 << 20, file); serde_json::to_writer(&mut writer, report)?;
412 use std::io::Write;
413 writer.flush()?;
414 log::info!("Wrote align report to {}", path.display());
415 Ok(())
416}