1use std::{
4 collections::{BTreeMap, HashSet},
5 path::Path,
6};
7
8use anyhow::{bail, ensure, Context, Result};
9use serde::Serialize;
10
11use super::{consensus::ConsensusFeature, IntensityMatrix, LfqConfig};
12use crate::{
13 alignment::{AlignmentResult, RunInput},
14 models::PROTON_MASS,
15 output::long_matrix::{sha256, source_rows},
16};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
19#[serde(rename_all = "snake_case")]
20pub enum TargetFormat {
21 Generic,
22 Sage,
23}
24
25#[derive(Debug, Clone, Serialize)]
26pub struct Identification {
27 pub modified_peptide: String,
28 pub run_idx: usize,
29 pub charge: u8,
30 pub neutral_mass: f64,
31 pub rt_minutes: f64,
32 pub im: f64,
33 pub id_qvalue: f64,
34 pub source_row: usize,
35}
36
37#[derive(Debug, Clone, Serialize)]
38pub struct ImportSummary {
39 pub path: String,
40 pub sha256: String,
41 pub format: TargetFormat,
42 pub max_id_qvalue: f64,
43 pub ignore_target_im: bool,
44 pub rows_read: usize,
45 pub rows_filtered: usize,
46 pub rows_retained: usize,
47}
48
49fn run_key(name: &str) -> String {
51 let name = name.rsplit(['/', '\\']).next().unwrap_or(name);
52 let mut key = name.to_owned();
53 for suffix in [".gz", ".mzml", ".raw", ".d"] {
54 if key.to_ascii_lowercase().ends_with(suffix) {
55 key.truncate(key.len() - suffix.len());
56 }
57 }
58 key
59}
60
61pub fn read_identifications(
62 path: &Path,
63 format: TargetFormat,
64 runs: &[RunInput],
65 max_q: f64,
66 ignore_target_im: bool,
67) -> Result<(Vec<Identification>, ImportSummary)> {
68 ensure!(
69 max_q.is_finite() && (0.0..=1.0).contains(&max_q),
70 "max ID q-value must be in [0, 1]"
71 );
72 let mut run_ids = BTreeMap::new();
73 for (i, run) in runs.iter().enumerate() {
74 ensure!(
75 run_ids.insert(run_key(&run.name), i).is_none(),
76 "Ambiguous normalized run name: {}",
77 run.name
78 );
79 }
80 let mut summary = ImportSummary {
81 path: path.display().to_string(),
82 sha256: sha256(path)?,
83 format,
84 max_id_qvalue: max_q,
85 ignore_target_im,
86 rows_read: 0,
87 rows_filtered: 0,
88 rows_retained: 0,
89 };
90 let mut records = Vec::new();
91 source_rows(path, |header, row| {
92 summary.rows_read += 1;
93 let parse = || -> Result<Option<Identification>> {
94 let unique: HashSet<_> = header.iter().collect();
95 ensure!(unique.len() == header.len(), "duplicate column names");
96 let get = |name: &str| -> Result<&str> {
97 let i = header
98 .iter()
99 .position(|h| h == name)
100 .with_context(|| format!("missing column '{name}'"))?;
101 row.get(i)
102 .with_context(|| format!("missing value for '{name}'"))
103 };
104 let number = |name: &str| -> Result<f64> {
105 let v: f64 = get(name)?
106 .parse()
107 .with_context(|| format!("invalid {name}"))?;
108 ensure!(v.is_finite(), "{name} must be finite");
109 Ok(v)
110 };
111 let qvalue = |name: &str| -> Result<f64> {
112 let v = number(name)?;
113 ensure!((0.0..=1.0).contains(&v), "{name} must be in [0, 1]");
114 Ok(v)
115 };
116 let (peptide_col, run_col, mass_col, rt_col, q) = match format {
117 TargetFormat::Generic => (
118 "modified_peptide",
119 "run",
120 "neutral_mass",
121 "rt_minutes",
122 qvalue("id_qvalue")?,
123 ),
124 TargetFormat::Sage => {
125 let decoy = if header.iter().any(|h| h == "is_decoy") {
126 match get("is_decoy")?.to_ascii_lowercase().as_str() {
127 "true" | "1" => true,
128 "false" | "0" => false,
129 _ => bail!("is_decoy must be true/false or 1/0"),
130 }
131 } else {
132 match get("label")? {
133 "1" => false,
134 "-1" => true,
135 _ => bail!("label must be 1 or -1"),
136 }
137 };
138 let rank: u32 = get("rank")?.parse().context("invalid rank")?;
139 if decoy || rank != 1 {
140 return Ok(None);
141 }
142 (
143 "peptide",
144 "filename",
145 "calcmass",
146 "rt",
147 qvalue("peptide_q")?.max(qvalue("spectrum_q")?),
148 )
149 }
150 };
151 if q > max_q {
152 return Ok(None);
153 }
154 let modified_peptide = get(peptide_col)?.trim().to_owned();
155 ensure!(!modified_peptide.is_empty(), "empty modified peptide");
156 let key = run_key(get(run_col)?);
157 let run_idx = *run_ids
158 .get(&key)
159 .with_context(|| format!("unknown run '{}'", get(run_col).unwrap_or("")))?;
160 let charge: u8 = get("charge")?.parse().context("invalid charge")?;
161 ensure!(charge > 0, "charge must be positive");
162 let neutral_mass = number(mass_col)?;
163 let rt_minutes = number(rt_col)?;
164 ensure!(
165 neutral_mass > 0.0 && rt_minutes >= 0.0,
166 "mass must be positive and RT nonnegative"
167 );
168 let im_col = if format == TargetFormat::Sage {
169 "ion_mobility"
170 } else {
171 "im"
172 };
173 let im = if !ignore_target_im
174 && header.iter().any(|h| h == im_col)
175 && !get(im_col)?.is_empty()
176 {
177 number(im_col)?
178 } else {
179 0.0
180 };
181 ensure!(im >= 0.0, "IM must be nonnegative native 1/K0, or empty");
182 Ok(Some(Identification {
183 modified_peptide,
184 run_idx,
185 charge,
186 neutral_mass,
187 rt_minutes,
188 im,
189 id_qvalue: q,
190 source_row: summary.rows_read,
191 }))
192 };
193 match parse()
194 .with_context(|| format!("{}: data row {}", path.display(), summary.rows_read))?
195 {
196 Some(id) => records.push(id),
197 None => summary.rows_filtered += 1,
198 }
199 Ok(())
200 })?;
201 summary.rows_retained = records.len();
202 ensure!(
203 !records.is_empty(),
204 "No identifications passed the input filters"
205 );
206 Ok((records, summary))
207}
208
209#[derive(Debug, Clone, Serialize)]
210pub struct SearchTarget {
211 pub modified_peptide: String,
212 pub charge: u8,
213 pub donors: Vec<Option<Identification>>,
215 pub seed_run_idx: usize,
216 pub transfer_eligible: bool,
217 #[serde(skip_serializing_if = "Vec::is_empty")]
220 pub transfer_blocked_runs: Vec<usize>,
221 #[serde(skip_serializing_if = "Option::is_none")]
222 pub rt_rescue: Option<RtRescue>,
223 #[serde(skip_serializing_if = "Option::is_none")]
224 pub inferred_charge: Option<InferredCharge>,
225}
226
227#[derive(Debug, Clone, Serialize)]
228pub struct InferredCharge {
229 pub seed: Identification,
231 pub native_anchors: Vec<Option<Identification>>,
232 pub mz_eligible: Vec<bool>,
233}
234
235#[derive(Debug, Clone, Serialize)]
236pub struct RtRescue {
237 pub excluded_source_rows: Vec<usize>,
238 pub cross_run_rt_ambiguous: bool,
239}
240
241fn supported_rt_ids(
246 ids: Vec<Identification>,
247 runs: &[RunInput],
248 rt_ranges: &[(f64, f64)],
249 fraction: f64,
250) -> (Vec<Identification>, Vec<usize>, Vec<bool>) {
251 let mut retained = Vec::new();
252 let mut excluded = Vec::new();
253 let mut withheld = vec![false; runs.len()];
256 for r in 0..runs.len() {
257 let mut rows: Vec<_> = ids.iter().filter(|id| id.run_idx == r).collect();
258 rows.sort_by(|a, b| {
259 a.rt_minutes
260 .total_cmp(&b.rt_minutes)
261 .then(a.source_row.cmp(&b.source_row))
262 });
263 let (lo, hi) = rt_ranges[r];
264 let limit = (hi - lo) * fraction;
265 let mut windows = Vec::new();
266 for start in 0..rows.len() {
267 let end = rows.partition_point(|id| id.rt_minutes <= rows[start].rt_minutes + limit);
268 windows.push((end - start, start, end));
269 }
270 let maximum = windows.iter().map(|w| w.0).max().unwrap_or(0);
271 let best: Vec<_> = windows.iter().filter(|w| w.0 == maximum).collect();
272 let supported = best.len() == 1
273 && (maximum == rows.len() || (maximum >= 2 && maximum * 2 > rows.len()));
274 for (i, id) in rows.iter().enumerate() {
275 if supported
276 && i >= best[0].1
277 && i < best[0].2
278 && id.rt_minutes >= lo
279 && id.rt_minutes <= hi
280 {
281 retained.push((*id).clone());
282 } else {
283 excluded.push(id.source_row);
284 }
285 }
286 }
287 excluded.sort_unstable();
288 for (r, flag) in withheld.iter_mut().enumerate() {
289 let offered = ids.iter().any(|id| id.run_idx == r);
290 *flag = offered && !retained.iter().any(|id| id.run_idx == r);
291 }
292 (retained, excluded, withheld)
293}
294
295#[derive(Debug, Clone, Serialize)]
296pub struct RejectedTarget {
297 pub modified_peptide: String,
298 pub charge: u8,
299 pub reason: String,
300}
301
302#[derive(Debug, Clone, Serialize)]
303pub struct SearchGuidance {
304 pub import: ImportSummary,
305 pub mbr: bool,
306 pub aligned_runs: Vec<bool>,
307 pub targets: Vec<SearchTarget>,
308 pub rejected_targets: Vec<RejectedTarget>,
309}
310
311impl SearchGuidance {
312 pub fn is_direct(&self, target: usize, run: usize) -> bool {
313 self.targets[target].donors[run].is_some()
314 }
315 pub fn attempted(&self, target: usize, run: usize) -> bool {
316 self.is_direct(target, run)
317 || self.native_anchor(target, run).is_some()
318 || (self.mbr
319 && self.aligned_runs[run]
320 && self.targets[target].transfer_eligible
321 && !self.targets[target].transfer_blocked_runs.contains(&run)
322 && self.targets[target]
323 .inferred_charge
324 .as_ref()
325 .is_none_or(|i| i.mz_eligible[run]))
326 }
327 pub fn native_anchor(&self, target: usize, run: usize) -> Option<&Identification> {
328 self.targets[target].donors[run].as_ref().or_else(|| {
329 self.targets[target]
330 .inferred_charge
331 .as_ref()
332 .and_then(|i| i.native_anchors[run].as_ref())
333 })
334 }
335 pub fn is_inferred(&self, target: usize, run: usize) -> bool {
336 let t = &self.targets[target];
337 !self.is_direct(target, run)
338 && t.inferred_charge.as_ref().is_some_and(|i| {
339 i.native_anchors[run].is_some() || t.donors.iter().all(Option::is_none)
340 })
341 }
342}
343
344pub fn build_targets(
348 records: Vec<Identification>,
349 import: ImportSummary,
350 runs: &[RunInput],
351 alignment: &AlignmentResult,
352 config: &LfqConfig,
353 min_anchors: usize,
354 mbr: bool,
355) -> Result<(Vec<ConsensusFeature>, SearchGuidance)> {
356 ensure!(
357 !runs.is_empty() && alignment.reference_idx < runs.len(),
358 "invalid reference run"
359 );
360 let aligned_runs: Vec<_> = runs
361 .iter()
362 .enumerate()
363 .map(|(r, run)| {
364 r == alignment.reference_idx
365 || alignment.alignments.get(&run.name).is_some_and(|a| {
366 a.rt_active.iter().filter(|&&v| v).count() >= min_anchors.max(2)
367 })
368 })
369 .collect();
370 let mut grouped: BTreeMap<(String, u8), Vec<Identification>> = BTreeMap::new();
371 for id in records {
372 grouped
373 .entry((id.modified_peptide.clone(), id.charge))
374 .or_default()
375 .push(id);
376 }
377 let mut guidance = SearchGuidance {
378 import,
379 mbr,
380 aligned_runs,
381 targets: Vec::new(),
382 rejected_targets: Vec::new(),
383 };
384 let mut consensus = Vec::new();
385 let rt_ranges: Vec<(f64, f64)> = runs.iter().map(|r| r.rt_range()).collect();
389 let range = rt_ranges[alignment.reference_idx];
390 let rt_limit = (range.1 - range.0) * config.consensus.rt_window_pct;
391 for ((peptide, charge), mut ids) in grouped {
392 let mass_min = ids
394 .iter()
395 .map(|id| id.neutral_mass)
396 .fold(f64::INFINITY, f64::min);
397 let mass_max = ids
398 .iter()
399 .map(|id| id.neutral_mass)
400 .fold(f64::NEG_INFINITY, f64::max);
401 let mass_conflict = (mass_max - mass_min) / mass_min * 1e6 > config.consensus.mz_ppm;
402 let mut excluded_source_rows = Vec::new();
403 let mut withheld_runs = vec![false; runs.len()];
404 if config.search_rt_rescue && !mass_conflict && ids.iter().all(|id| id.im == 0.0) {
405 (ids, excluded_source_rows, withheld_runs) =
406 supported_rt_ids(ids, runs, &rt_ranges, config.consensus.rt_window_pct);
407 if ids.is_empty() {
408 guidance.rejected_targets.push(RejectedTarget {
409 modified_peptide: peptide,
410 charge,
411 reason: "no_supported_native_rt_cluster".into(),
412 });
413 continue;
414 }
415 }
416 ids.sort_by(|a, b| {
417 a.id_qvalue
418 .total_cmp(&b.id_qvalue)
419 .then_with(|| runs[a.run_idx].name.cmp(&runs[b.run_idx].name))
420 .then_with(|| a.rt_minutes.total_cmp(&b.rt_minutes))
421 .then_with(|| a.im.total_cmp(&b.im))
422 .then_with(|| a.neutral_mass.total_cmp(&b.neutral_mass))
423 .then(a.source_row.cmp(&b.source_row))
424 });
425 let transferable = |id: &Identification| {
426 let (lo, hi) = rt_ranges[id.run_idx];
427 guidance.aligned_runs[id.run_idx] && id.rt_minutes >= lo && id.rt_minutes <= hi
428 };
429 let seed = ids.iter().find(|id| transferable(id)).unwrap_or(&ids[0]);
430 let project = |id: &Identification| {
431 if id.run_idx == alignment.reference_idx {
432 (id.rt_minutes, id.im)
433 } else {
434 let a = &alignment.alignments[&runs[id.run_idx].name];
435 (
436 a.warp_rt(id.rt_minutes),
437 if id.im > 0.0 {
438 a.correct_im(id.im, id.rt_minutes)
439 } else {
440 0.0
441 },
442 )
443 }
444 };
445 let (ref_rt, ref_im) = project(seed);
446 let mut reason = None;
447 let mut rt_values = Vec::new();
448 let mut im_values = Vec::new();
449 for id in &ids {
450 if (id.neutral_mass - seed.neutral_mass).abs() / seed.neutral_mass * 1e6
451 > config.consensus.mz_ppm
452 {
453 reason = Some("inconsistent_mass");
454 }
455 if id.run_idx == seed.run_idx || guidance.aligned_runs[id.run_idx] {
457 let (rt, im) = project(id);
458 rt_values.push(rt);
459 if im > 0.0 {
460 im_values.push(im);
461 }
462 }
463 }
464 let span = |v: &[f64]| {
465 v.iter().copied().fold(f64::NEG_INFINITY, f64::max)
466 - v.iter().copied().fold(f64::INFINITY, f64::min)
467 };
468 if span(&rt_values) > rt_limit {
469 reason = Some("ambiguous_retention_time");
470 }
471 if span(&im_values) > config.consensus.im_tolerance {
472 reason = Some("ambiguous_ion_mobility");
473 }
474 for r in 0..runs.len() {
476 let native_rt: Vec<_> = ids
477 .iter()
478 .filter(|id| id.run_idx == r)
479 .map(|id| id.rt_minutes)
480 .collect();
481 let native_im: Vec<_> = ids
482 .iter()
483 .filter(|id| id.run_idx == r && id.im > 0.0)
484 .map(|id| id.im)
485 .collect();
486 let range = rt_ranges[r];
487 if span(&native_rt) > (range.1 - range.0) * config.consensus.rt_window_pct {
488 reason = Some("ambiguous_retention_time");
489 }
490 if span(&native_im) > config.consensus.im_tolerance {
491 reason = Some("ambiguous_ion_mobility");
492 }
493 }
494 let rt_rescued = config.search_rt_rescue
495 && !mass_conflict
496 && ids.iter().all(|id| id.im == 0.0)
497 && reason == Some("ambiguous_retention_time");
498 if let Some(reason) = reason.filter(|_| !rt_rescued) {
499 guidance.rejected_targets.push(RejectedTarget {
500 modified_peptide: peptide,
501 charge,
502 reason: reason.into(),
503 });
504 continue;
505 }
506 let mut donors = vec![None; runs.len()];
507 for id in &ids {
508 if donors[id.run_idx].is_none() {
509 donors[id.run_idx] = Some(id.clone());
510 }
511 }
512 consensus.push(ConsensusFeature {
513 ref_mz: seed.neutral_mass / f64::from(charge) + PROTON_MASS,
514 ref_rt,
515 ref_im,
516 charge,
517 theoretical_pattern: config
518 .isotope_model
519 .model()
520 .distribution(seed.neutral_mass)
521 .to_vec(),
522 neutral_mass: seed.neutral_mass,
523 seed_combined_score: f64::NAN,
524 seed_run_idx: seed.run_idx,
525 n_contributing_runs: 0,
526 per_run_feature: vec![None; runs.len()],
527 members: Vec::new(),
528 seed_feature_idx: u32::MAX,
529 group_score: f64::NAN,
530 group_qvalue: f64::NAN,
531 });
532 let per_run = config.search_rt_rescue && config.search_rt_rescue_per_run_transfers;
536 let transfer_eligible =
537 transferable(seed) && !rt_rescued && (per_run || excluded_source_rows.is_empty());
538 let transfer_blocked_runs: Vec<usize> = if per_run {
539 withheld_runs
540 .iter()
541 .enumerate()
542 .filter(|(_, blocked)| **blocked)
543 .map(|(r, _)| r)
544 .collect()
545 } else {
546 Vec::new()
547 };
548 guidance.targets.push(SearchTarget {
549 modified_peptide: peptide,
550 charge,
551 donors,
552 seed_run_idx: seed.run_idx,
553 transfer_eligible,
554 transfer_blocked_runs,
555 rt_rescue: config.search_rt_rescue.then_some(RtRescue {
556 excluded_source_rows,
557 cross_run_rt_ambiguous: rt_rescued,
558 }),
559 inferred_charge: None,
560 });
561 }
562 if config.search_expand_charges {
563 expand_charges(
564 &mut consensus,
565 &mut guidance,
566 runs,
567 &rt_ranges,
568 config,
569 rt_limit,
570 );
571 }
572 Ok((consensus, guidance))
574}
575
576fn expand_charges(
579 consensus: &mut Vec<ConsensusFeature>,
580 g: &mut SearchGuidance,
581 runs: &[RunInput],
582 rt_ranges: &[(f64, f64)],
583 config: &LfqConfig,
584 rt_limit: f64,
585) {
586 let original = g.targets.clone();
587 let mut groups: BTreeMap<String, Vec<usize>> = BTreeMap::new();
588 for (i, t) in original.iter().enumerate() {
589 groups
590 .entry(t.modified_peptide.clone())
591 .or_default()
592 .push(i);
593 }
594 let bounds: Vec<_> = runs
595 .iter()
596 .map(|r| {
597 let mz: Vec<_> = r
598 .features
599 .iter()
600 .map(|f| f.feature.monoisotopic_mz())
601 .chain(r.hills.iter().map(|h| h.mz))
602 .filter(|v| v.is_finite())
603 .collect();
604 (
605 mz.iter().copied().fold(f64::INFINITY, f64::min),
606 mz.iter().copied().fold(f64::NEG_INFINITY, f64::max),
607 )
608 })
609 .collect();
610 for (peptide, indices) in groups {
611 if g.rejected_targets
613 .iter()
614 .any(|t| t.modified_peptide == peptide)
615 {
616 continue;
617 }
618 let seed_idx = indices[0];
619 let source = consensus[seed_idx].clone();
620 let rt_min = indices
621 .iter()
622 .map(|&i| consensus[i].ref_rt)
623 .fold(f64::INFINITY, f64::min);
624 let rt_max = indices
625 .iter()
626 .map(|&i| consensus[i].ref_rt)
627 .fold(f64::NEG_INFINITY, f64::max);
628 if rt_max - rt_min > rt_limit
629 || indices.iter().any(|&i| {
630 !original[i].transfer_eligible
631 || consensus[i].ref_im != 0.0
632 || (consensus[i].neutral_mass - source.neutral_mass).abs() / source.neutral_mass
633 * 1e6
634 > config.consensus.mz_ppm
635 })
636 {
637 continue;
638 }
639 let Some(seed) = original[seed_idx].donors[source.seed_run_idx].clone() else {
640 continue;
641 };
642 for charge in 2..=4 {
643 let mz = source.neutral_mass / f64::from(charge) + PROTON_MASS;
644 let anchors: Vec<_> = runs
645 .iter()
646 .enumerate()
647 .map(|(r, _run)| {
648 if mz < bounds[r].0 || mz > bounds[r].1 {
649 return None;
650 }
651 let ids: Vec<_> = indices
652 .iter()
653 .filter_map(|&i| original[i].donors[r].as_ref())
654 .collect();
655 let lo = ids
656 .iter()
657 .map(|id| id.rt_minutes)
658 .fold(f64::INFINITY, f64::min);
659 let hi = ids
660 .iter()
661 .map(|id| id.rt_minutes)
662 .fold(f64::NEG_INFINITY, f64::max);
663 let range = rt_ranges[r];
664 if hi - lo > (range.1 - range.0) * config.consensus.rt_window_pct
665 || ids.iter().any(|id| id.im != 0.0)
666 {
667 return None;
668 }
669 ids.into_iter()
670 .min_by(|a, b| {
671 a.id_qvalue
672 .total_cmp(&b.id_qvalue)
673 .then(a.charge.cmp(&b.charge))
674 .then(a.source_row.cmp(&b.source_row))
675 })
676 .cloned()
677 })
678 .collect();
679 if anchors.iter().all(Option::is_none) {
680 continue;
681 }
682 let provenance = InferredCharge {
683 seed: seed.clone(),
684 native_anchors: anchors,
685 mz_eligible: bounds
686 .iter()
687 .map(|&(lo, hi)| mz >= lo && mz <= hi)
688 .collect(),
689 };
690 if let Some(&i) = indices.iter().find(|&&i| original[i].charge == charge) {
691 g.targets[i].inferred_charge = Some(provenance);
692 } else {
693 let mut cf = source.clone();
694 cf.charge = charge;
695 cf.ref_mz = mz;
696 consensus.push(cf);
697 g.targets.push(SearchTarget {
698 modified_peptide: peptide.clone(),
699 charge,
700 donors: vec![None; runs.len()],
701 seed_run_idx: source.seed_run_idx,
702 transfer_eligible: true,
703 transfer_blocked_runs: Vec::new(),
704 rt_rescue: None,
705 inferred_charge: Some(provenance),
706 });
707 }
708 }
709 }
710}
711
712fn finite(v: f64) -> String {
713 if v.is_finite() {
714 v.to_string()
715 } else {
716 String::new()
717 }
718}
719
720pub fn write_search_outputs(
723 matrix: &IntensityMatrix,
724 out: &Path,
725 max_cell_q: f64,
726 run_tdc: bool,
727 gate_transfers_only: bool,
728) -> Result<()> {
729 let g = matrix
730 .search_guidance
731 .as_ref()
732 .context("missing search guidance")?;
733 let mut w = csv::WriterBuilder::new()
734 .delimiter(b'\t')
735 .from_path(out.join("peptide_quant.tsv"))?;
736 w.write_record([
737 "target_id",
738 "modified_peptide",
739 "charge",
740 "run",
741 "evidence",
742 "status",
743 "intensity",
744 "accepted_intensity",
745 "extraction_qvalue",
746 "direct_id_qvalue",
747 "seed_run",
748 "seed_id_qvalue",
749 "direct_source_row",
750 "seed_source_row",
751 ])?;
752 let mut wide = csv::WriterBuilder::new()
753 .delimiter(b'\t')
754 .from_path(out.join("peptide_intensity_matrix.tsv"))?;
755 let mut header = vec![
756 "target_id".into(),
757 "modified_peptide".into(),
758 "charge".into(),
759 ];
760 header.extend(matrix.run_names.clone());
761 wide.write_record(header)?;
762 for (i, t) in g.targets.iter().enumerate() {
763 let seed = t.donors[t.seed_run_idx]
764 .as_ref()
765 .or_else(|| t.inferred_charge.as_ref().map(|i| &i.seed))
766 .context("target missing seed ID")?;
767 let mut row = vec![
768 i.to_string(),
769 t.modified_peptide.clone(),
770 t.charge.to_string(),
771 ];
772 for r in 0..matrix.n_runs {
773 let direct = t.donors[r].as_ref();
774 let attempted = g.attempted(i, r);
775 let intensity = matrix.intensity(i, r);
776 let q = matrix.q_value(i, r);
777 let gated = !(gate_transfers_only && direct.is_some());
780 let accepted = attempted
781 && intensity > 0.0
782 && run_tdc
783 && (!gated || (q.is_finite() && q <= max_cell_q));
784 let value = if accepted { intensity } else { 0.0 };
785 row.push(value.to_string());
786 let status = if !attempted {
787 "not_attempted"
788 } else if intensity <= 0.0 {
789 "no_signal"
790 } else if !run_tdc {
791 "unscored"
792 } else if accepted {
793 "accepted"
794 } else {
795 "rejected"
796 };
797 w.write_record(vec![
798 i.to_string(),
799 t.modified_peptide.clone(),
800 t.charge.to_string(),
801 matrix.run_names[r].clone(),
802 if direct.is_some() {
803 "direct_ms2"
804 } else if g.is_inferred(i, r) {
805 if g.native_anchor(i, r).is_some() {
806 "inferred_charge"
807 } else {
808 "mbr_inferred_charge"
809 }
810 } else {
811 "mbr"
812 }
813 .into(),
814 status.into(),
815 intensity.to_string(),
816 value.to_string(),
817 if attempted && run_tdc {
818 finite(q)
819 } else {
820 String::new()
821 },
822 direct.map(|d| finite(d.id_qvalue)).unwrap_or_default(),
823 matrix.run_names[t.seed_run_idx].clone(),
824 finite(seed.id_qvalue),
825 direct.map(|d| d.source_row.to_string()).unwrap_or_default(),
826 seed.source_row.to_string(),
827 ])?;
828 }
829 wide.write_record(row)?;
830 }
831 w.flush()?;
832 wide.flush()?;
833 let mut rejected = csv::WriterBuilder::new()
834 .delimiter(b'\t')
835 .from_path(out.join("search_rejections.tsv"))?;
836 rejected.write_record(["modified_peptide", "charge", "reason"])?;
837 for t in &g.rejected_targets {
838 rejected.write_record([
839 t.modified_peptide.clone(),
840 t.charge.to_string(),
841 t.reason.clone(),
842 ])?;
843 }
844 rejected.flush()?;
845 let manifest = serde_json::json!({"schema_version":1,"koth_version":crate::VERSION,"search":g,
846 "run_names":matrix.run_names,"max_cell_qvalue":max_cell_q,"run_tdc":run_tdc,
847 "gate_transfers_only":gate_transfers_only,
848 "confidence":"ID q-values are imported; extraction q-values are exploratory and separately ranked for direct and transferred cells. No calibrated peptide-transfer FDR is claimed.",
849 "outputs":{"peptide_quant.tsv":sha256(&out.join("peptide_quant.tsv"))?,
850 "peptide_intensity_matrix.tsv":sha256(&out.join("peptide_intensity_matrix.tsv"))?,
851 "search_rejections.tsv":sha256(&out.join("search_rejections.tsv"))?}});
852 let temp = out.join("search_manifest.json.tmp");
853 std::fs::write(&temp, serde_json::to_vec_pretty(&manifest)?)?;
854 std::fs::rename(temp, out.join("search_manifest.json"))?;
855 Ok(())
856}
857
858#[cfg(test)]
859#[path = "targets_tests.rs"]
860mod tests;