koth_core/features/
mod.rs1pub mod cosine;
2pub mod recalibration;
3
4mod assemble;
5
6use std::cmp::Ordering;
7
8use crate::config::{FeaturesConfig, FileConfig, ToleranceType};
9use crate::models::{Feature, Hill};
10use assemble::{resolve_exhaustive, ChainCtx};
11use cosine::cosine_similarity;
12use recalibration::{MzRecalBuilder, MzRecalModel};
13
14pub fn detect_features(hills: &[Hill], config: &FeaturesConfig, file: &FileConfig) -> Vec<Feature> {
27 detect_features_with_recal(hills, config, file, None)
28}
29
30pub fn detect_features_with_recal(
35 hills: &[Hill],
36 config: &FeaturesConfig,
37 file: &FileConfig,
38 recal: Option<&MzRecalModel>,
39) -> Vec<Feature> {
40 if hills.is_empty() {
41 return Vec::new();
42 }
43
44 log::info!("Detecting isotope features from {} hills", hills.len());
45
46 let mut order: Vec<usize> = (0..hills.len()).collect();
47 order.sort_by(|&a, &b| {
48 hills[a]
49 .mz
50 .partial_cmp(&hills[b].mz)
51 .unwrap_or(Ordering::Equal)
52 });
53 let sorted_hills: Vec<&Hill> = order.iter().map(|&i| &hills[i]).collect();
54 let mz_array: Vec<f64> = sorted_hills.iter().map(|h| h.mz).collect();
55 let im_array: Vec<f64> = sorted_hills.iter().map(|h| h.im).collect();
56 let scan_starts: Vec<usize> = sorted_hills.iter().map(|h| h.scan_start).collect();
57 let scan_ends: Vec<usize> = sorted_hills.iter().map(|h| h.scan_end).collect();
58
59 let use_im = im_array.iter().any(|&x| x != 0.0);
60
61 let min_intensity = {
70 let mut maxes: Vec<f64> = hills.iter().map(|h| h.intensity_max).collect();
71 maxes.sort_by(|a, b| a.partial_cmp(b).unwrap());
72 if maxes.is_empty() {
73 0.0
74 } else {
75 let idx = (maxes.len() as f64 * 0.05) as usize;
76 maxes[idx] * 0.8
77 }
78 };
79
80 let ctx = ChainCtx {
86 sorted_hills: &sorted_hills,
87 mz_array: &mz_array,
88 im_array: &im_array,
89 scan_starts: &scan_starts,
90 scan_ends: &scan_ends,
91 config,
92 file,
93 use_im,
94 min_intensity,
95 recal,
96 };
97 let accepted = resolve_exhaustive(&ctx);
98
99 let features: Vec<Feature> = accepted
101 .into_iter()
102 .map(|c| {
103 let mut chain_hills: Vec<Hill> = c
104 .hill_indices
105 .iter()
106 .map(|&i| (*sorted_hills[i]).clone())
107 .collect();
108 chain_hills.sort_by(|a, b| a.mz.partial_cmp(&b.mz).unwrap_or(Ordering::Equal));
109
110 let cosine_sims: Vec<f64> = (0..chain_hills.len().saturating_sub(1))
111 .map(|k| {
112 cosine_similarity(
113 &chain_hills[k],
114 &chain_hills[k + 1],
115 config.min_scan_overlap,
116 )
117 })
118 .collect();
119
120 let ppm_errors: Vec<f64> = if c.charge > 0 && chain_hills.len() > 1 {
121 let step = config.neutron_mass / c.charge as f64;
122 (0..chain_hills.len() - 1)
123 .map(|k| {
124 let theo = chain_hills[k].mz + step;
125 let expt = chain_hills[k + 1].mz;
126 (expt - theo) / theo * 1e6
127 })
128 .collect()
129 } else {
130 Vec::new()
131 };
132
133 let mean_cosine = if cosine_sims.is_empty() {
134 0.0
135 } else {
136 cosine_sims.iter().sum::<f64>() / cosine_sims.len() as f64
137 };
138 let mean_ppm = if ppm_errors.is_empty() {
139 0.0
140 } else {
141 ppm_errors.iter().sum::<f64>() / ppm_errors.len() as f64
142 };
143
144 let charge = if c.hill_indices.len() > 1 {
145 c.charge
146 } else {
147 0
148 };
149
150 Feature {
151 hills: chain_hills,
152 charge,
153 cosine_score: mean_cosine,
154 ppm_error: mean_ppm,
155 }
156 })
157 .collect();
158
159 log::info!("Detected {} isotope features", features.len());
160 features
161}
162
163pub fn learn_recal_model(
170 hills: &[Hill],
171 config: &FeaturesConfig,
172 file: &FileConfig,
173) -> Option<MzRecalModel> {
174 let features = detect_features_with_recal(hills, config, file, None);
175
176 let max_abs_ppm = if matches!(file.mz_tolerance_type, ToleranceType::Ppm) {
180 file.mz_tolerance * 4.0
181 } else {
182 40.0
183 };
184 let mut builder = MzRecalBuilder::new(max_abs_ppm);
185
186 for f in &features {
187 if f.charge < 1 || f.hills.len() < 2 {
188 continue;
189 }
190 let step = config.neutron_mass / f.charge as f64;
191 for w in f.hills.windows(2) {
195 let observed = w[1].mz - w[0].mz;
196 if (observed - step).abs() > 0.5 * step {
197 continue;
198 }
199 let resid_ppm = (observed - step) / w[1].mz * 1e6;
200 builder.add(w[1].mz, w[1].rt, resid_ppm);
201 }
202 }
203
204 builder.finalize(
205 file.mz_recalibration_mz_bins,
206 file.mz_recalibration_rt_bins,
207 file.mz_recalibration_min_samples,
208 )
209}