1pub mod averagine;
2pub mod elements;
3pub mod model;
4
5use crate::config::ScoringConfig;
6use crate::models::{Feature, Polarity, ScoredFeature};
7use averagine::bhattacharyya_score;
8use model::IsotopeModel;
9
10pub fn score_features(
18 features: &[Feature],
19 config: &ScoringConfig,
20 sulfur_offsets: &[i8],
21 model: &IsotopeModel,
22 polarity: Polarity,
23) -> Vec<ScoredFeature> {
24 log::info!("Scoring {} features", features.len());
25
26 let all_scored: Vec<ScoredFeature> = features
27 .iter()
28 .map(|feature| score_one(feature, config, sulfur_offsets, model, polarity))
29 .collect();
30
31 if log::log_enabled!(log::Level::Debug) {
32 let charged: Vec<&ScoredFeature> = all_scored
33 .iter()
34 .filter(|sf| sf.feature.charge > 0)
35 .collect();
36 if !charged.is_empty() {
37 let max_score = charged
38 .iter()
39 .map(|sf| sf.combined_score)
40 .fold(f64::NEG_INFINITY, f64::max);
41 let mean_score =
42 charged.iter().map(|sf| sf.combined_score).sum::<f64>() / charged.len() as f64;
43 let above = charged
44 .iter()
45 .filter(|sf| sf.isotope_score >= config.min_isotope_score_for_offset)
46 .count();
47 log::debug!(
48 "score diag: {} charged features, max_combined={:.3}, mean_combined={:.3}, isotope>={}: {}",
49 charged.len(), max_score, mean_score, config.min_isotope_score_for_offset, above
50 );
51 for sf in charged.iter().take(5) {
53 let obs = sf.feature.isotope_profile_apex();
54 log::debug!(
55 " charge={} n_isotopes={} obs={:?} iso={:.4} cos={:.4} comb={:.4}",
56 sf.feature.charge,
57 sf.feature.hills.len(),
58 obs.iter().map(|x| format!("{:.0}", x)).collect::<Vec<_>>(),
59 sf.isotope_score,
60 sf.cosine_score,
61 sf.combined_score,
62 );
63 }
64 }
65 }
66
67 let scored: Vec<ScoredFeature> = all_scored
71 .into_iter()
72 .filter(|sf| sf.feature.charge > 0)
73 .collect();
74
75 log::info!("Scored {} features", scored.len());
76 scored
77}
78
79fn score_one(
80 feature: &Feature,
81 config: &ScoringConfig,
82 sulfur_offsets: &[i8],
83 model: &IsotopeModel,
84 polarity: Polarity,
85) -> ScoredFeature {
86 let neutral_mass = match feature.monoisotopic_neutral_mass(polarity) {
87 Some(m) => m,
88 None => {
89 return ScoredFeature {
91 feature: feature.clone(),
92 neutron_offset: 0,
93 isotope_score: 0.0,
94 cosine_score: 0.0,
95 combined_score: 0.0,
96 theoretical_pattern: Vec::new(),
97 };
98 }
99 };
100
101 let template = model.distribution(neutral_mass);
102 let obs = feature.isotope_profile_apex();
103 let k = obs.len().min(10);
104
105 let mut best_combined = f64::NEG_INFINITY;
106 let mut best_bc = 0.0f64;
107 let mut best_offset: i8 = 0;
108
109 let score_obs = |o: &[f64]| -> f64 {
110 if sulfur_offsets.is_empty() {
111 bhattacharyya_score(o, &template)
112 } else {
113 averagine::bhattacharyya_score_best_sulfur(o, neutral_mass, sulfur_offsets, model).0
114 }
115 };
116
117 let (offset_min, offset_max): (i8, i8) = if config.isotope_offset_enabled {
120 (-1, 1)
121 } else {
122 (0, 0)
123 };
124 for o in offset_min..=offset_max {
125 let mut obs_aligned = vec![0.0f64; k];
127 for j in 0..k {
128 let src = j as i32 + o as i32;
129 if src >= 0 && (src as usize) < k {
130 obs_aligned[j] = obs[src as usize];
131 }
132 }
133
134 let sc = score_obs(&obs_aligned);
135 let combined = sc
137 + if o == 0 {
138 config.offset_zero_bonus
139 } else {
140 0.0
141 };
142 if combined > best_combined {
143 best_combined = combined;
144 best_bc = sc;
145 best_offset = o;
146 }
147 }
148
149 if best_combined < config.min_isotope_score_for_offset {
151 best_offset = 0;
152 best_bc = score_obs(&obs);
153 }
154
155 let theo_slice = &template[..k];
157 let theo_sum: f64 = theo_slice.iter().sum();
158 let theoretical_pattern: Vec<f64> = if theo_sum > 0.0 {
159 theo_slice.iter().map(|&x| x / theo_sum).collect()
160 } else {
161 vec![0.0; k]
162 };
163
164 let isotope_score = best_bc.clamp(0.0, 1.0);
177 let cosine_score = feature.cosine_score.clamp(0.0, 1.0);
178 let combined_score = (isotope_score * cosine_score).clamp(0.0, 1.0);
179
180 ScoredFeature {
181 feature: feature.clone(),
182 neutron_offset: best_offset,
183 isotope_score,
184 cosine_score,
185 combined_score,
186 theoretical_pattern,
187 }
188}