1use std::{collections::HashSet, mem};
2
3use chemical_elements::{isotopic_pattern::TheoreticalIsotopicPattern, neutral_mass};
4
5use identity_hash::BuildIdentityHasher;
6use itertools::Itertools;
7use mzpeaks::{
8 coordinate::{BoundingBox, CoordinateRange, QuadTree, Span1D},
9 feature::{ChargedFeature, Feature, TimeInterval},
10 feature_map::FeatureMap,
11 prelude::*,
12 CoordinateLikeMut, Mass, MZ,
13};
14
15use mzdeisotope::{
16 charge::ChargeRange,
17 isotopic_model::{
18 IsotopicPatternGenerator, TheoreticalIsotopicDistributionScalingMethod, PROTON,
19 },
20 scorer::{IsotopicFitFilter, IsotopicPatternScorer, ScoreInterpretation, ScoreType},
21};
22use mzsignal::feature_mapping::graph::FeatureGraphBuilder;
23use tracing::{debug, trace};
24
25use crate::{
26 dependency_graph::FeatureDependenceGraph, feature_fit::{FeatureSetFit, MapCoordinate}, fmap::IndexedFeature, solution::{reflow_feature, DeconvolvedSolutionFeature, FeatureMerger, MZPointSeries}, traits::{
27 DeconvolutionError, FeatureIsotopicFitter, FeatureMapMatch, FeatureMapType,
28 FeatureSearchParams, FeatureType, GraphFeatureDeconvolution, GraphStepResult,
29 }, FeatureSetIter
30};
31
32#[derive(Debug)]
33pub struct EnvelopeConformer {
34 pub minimum_theoretical_abundance: f64,
35}
36
37impl EnvelopeConformer {
38 pub fn new(minimum_theoretical_abundance: f64) -> Self {
39 Self {
40 minimum_theoretical_abundance,
41 }
42 }
43
44 pub fn conform_into<
45 C: CentroidLike + Default + CoordinateLikeMut<MZ> + IntensityMeasurementMut,
46 >(
47 &self,
48 experimental: Vec<Option<C>>,
49 theoretical: &mut TheoreticalIsotopicPattern,
50 cleaned_eid: &mut Vec<C>,
51 ) -> usize {
52 let mut n_missing: usize = 0;
53 let mut total_intensity = 0.0f32;
54
55 for (observed_peak, peak) in experimental.into_iter().zip(theoretical.iter()) {
56 if observed_peak.is_none() {
57 let mut templated_peak = C::default();
58 *templated_peak.coordinate_mut() = peak.mz();
59 *templated_peak.intensity_mut() = 1.0;
60 if peak.intensity > self.minimum_theoretical_abundance {
61 n_missing += 1;
62 }
63 total_intensity += 1.0;
64 cleaned_eid.push(templated_peak)
65 } else {
66 let observed_peak = observed_peak.unwrap();
67 total_intensity += observed_peak.intensity();
68 cleaned_eid.push(observed_peak)
69 }
70 }
71
72 let total_intensity = total_intensity as f64;
73 theoretical.iter_mut().for_each(|p| {
74 p.intensity *= total_intensity;
75 });
76 n_missing
77 }
78
79 pub fn conform<C: CentroidLike + Default + CoordinateLikeMut<MZ> + IntensityMeasurementMut>(
80 &self,
81 experimental: Vec<Option<C>>,
82 theoretical: &mut TheoreticalIsotopicPattern,
83 ) -> (Vec<C>, usize) {
84 let mut cleaned_eid = Vec::with_capacity(experimental.len());
85 let n_missing = self.conform_into(experimental, theoretical, &mut cleaned_eid);
86 (cleaned_eid, n_missing)
87 }
88}
89
90const MAX_COMBINATIONS: usize = 1000;
91
92#[derive(Debug)]
93pub struct FeatureProcessorBuilder<
94 Y: Clone + Default,
95 I: IsotopicPatternGenerator,
96 S: IsotopicPatternScorer,
97 F: IsotopicFitFilter,
98> {
99 pub feature_map: FeatureMap<MZ, Y, Feature<MZ, Y>>,
100 pub isotopic_model: Option<I>,
101 pub scorer: Option<S>,
102 pub fit_filter: Option<F>,
103 pub scaling_method: TheoreticalIsotopicDistributionScalingMethod,
104 pub prefer_multiply_charged: bool,
105 pub minimum_size: usize,
106 pub maximum_time_gap: f64,
107 pub minimum_intensity: f32,
108}
109
110impl<
111 Y: Clone + Default,
112 I: IsotopicPatternGenerator,
113 S: IsotopicPatternScorer,
114 F: IsotopicFitFilter,
115 > FeatureProcessorBuilder<Y, I, S, F>
116{
117 pub fn feature_map(&mut self, feature_map: FeatureMap<MZ, Y, Feature<MZ, Y>>) -> &mut Self {
118 self.feature_map = feature_map;
119 self
120 }
121
122 pub fn isotopic_model(&mut self, isotopic_model: I) -> &mut Self {
123 self.isotopic_model = Some(isotopic_model);
124 self
125 }
126
127 pub fn fit_filter(&mut self, fit_filter: F) -> &mut Self {
128 self.fit_filter = Some(fit_filter);
129 self
130 }
131
132 pub fn scorer(&mut self, scorer: S) -> &mut Self {
133 self.scorer = Some(scorer);
134 self
135 }
136
137 pub fn build(self) -> FeatureProcessor<Y, I, S, F> {
138 FeatureProcessor::new(
139 self.feature_map,
140 self.isotopic_model.unwrap(),
141 self.scorer.unwrap(),
142 self.fit_filter.unwrap(),
143 self.minimum_size,
144 self.maximum_time_gap,
145 self.minimum_intensity,
146 self.prefer_multiply_charged,
147 )
148 }
149}
150
151impl<
152 Y: Clone + Default,
153 I: IsotopicPatternGenerator,
154 S: IsotopicPatternScorer,
155 F: IsotopicFitFilter,
156 > Default for FeatureProcessorBuilder<Y, I, S, F>
157{
158 fn default() -> Self {
159 Self {
160 feature_map: Default::default(),
161 isotopic_model: Default::default(),
162 scorer: Default::default(),
163 fit_filter: Default::default(),
164 scaling_method: Default::default(),
165 prefer_multiply_charged: true,
166 minimum_size: 3,
167 maximum_time_gap: 0.25,
168 minimum_intensity: 5.0,
169 }
170 }
171}
172
173#[derive(Debug)]
174pub struct FeatureProcessor<
175 Y: Clone + Default,
176 I: IsotopicPatternGenerator,
177 S: IsotopicPatternScorer,
178 F: IsotopicFitFilter,
179> {
180 pub feature_map: FeatureMapType<Y>,
181 pub isotopic_model: I,
182 pub scorer: S,
183 pub fit_filter: F,
184 pub scaling_method: TheoreticalIsotopicDistributionScalingMethod,
185 pub prefer_multiply_charged: bool,
186 pub minimum_size: usize,
187 pub maximum_time_gap: f64,
188 pub minimum_intensity: f32,
189 feature_buffer: Vec<IndexedFeature<Y>>,
190 envelope_conformer: EnvelopeConformer,
191 dependency_graph: FeatureDependenceGraph,
192}
193
194impl<
195 Y: Clone + Default,
196 I: IsotopicPatternGenerator,
197 S: IsotopicPatternScorer,
198 F: IsotopicFitFilter,
199 > FeatureIsotopicFitter<Y> for FeatureProcessor<Y, I, S, F>
200{
201 fn make_isotopic_pattern(
202 &mut self,
203 mz: f64,
204 charge: i32,
205 search_params: &FeatureSearchParams,
206 ) -> TheoreticalIsotopicPattern {
207 self.isotopic_model.isotopic_cluster(
208 mz,
209 charge,
210 PROTON,
211 search_params.truncate_after,
212 search_params.ignore_below,
213 )
214 }
215
216 fn fit_theoretical_distribution_on_features(
217 &self,
218 mz: f64,
219 error_tolerance: Tolerance,
220 charge: i32,
221 base_tid: TheoreticalIsotopicPattern,
222 max_missed_peaks: usize,
223 threshold_scale: f32,
224 feature: &Option<CoordinateRange<Y>>,
225 ) -> Vec<FeatureSetFit> {
226 let feature_groups =
227 self.match_theoretical_isotopic_distribution(&base_tid, error_tolerance, feature);
228 let n_real = feature_groups
229 .iter()
230 .flatten()
231 .map(|s| s.len())
232 .sum::<usize>();
233
234 if n_real == 0 {
235 return Vec::new();
236 }
237 let fgi = feature_groups
238 .into_iter()
239 .map(|g| {
240 if g.is_none() {
241 vec![None].into_iter()
242 } else {
243 let v: Vec<_> = g.unwrap().into_iter().map(Some).collect();
244 v.into_iter()
245 }
246 })
247 .multi_cartesian_product()
248 .enumerate();
249 let mut snapped_tid: TheoreticalIsotopicPattern;
250 let mut fits = Vec::new();
251 for (i, features) in fgi.take(MAX_COMBINATIONS) {
252 if i % 100 == 0 && i > 0 {
253 debug!("... Considering combination {i} of {n_real} features for {mz}@{charge}");
254 }
255
256 if features.iter().all(|f| f.is_none()) {
257 continue;
258 }
259
260 if let Some((_, f)) = features.first().unwrap() {
261 snapped_tid = base_tid.clone().shift(mz - f.mz());
262 } else {
263 snapped_tid = base_tid.clone();
264 };
265
266 let mut counter = 0;
267 let mut score_max: ScoreType = 0.0;
268
269 let mut score_vec: Vec<ScoreType> = Vec::new();
270 let mut time_vec: Vec<f64> = Vec::new();
271
272 let mut features_vec: Vec<_> = Vec::with_capacity(features.len());
273 let mut indices_vec: Vec<_> = Vec::with_capacity(features.len());
274 for opt in features.into_iter() {
275 if let Some((j, f)) = opt {
276 indices_vec.push(Some(j));
277 features_vec.push(Some(f.as_ref()));
278 } else {
279 features_vec.push(None);
280 indices_vec.push(None);
281 }
282 }
283
284 let it = FeatureSetIter::new(&features_vec);
285 let mut cleaned_eid = Vec::with_capacity(snapped_tid.len());
286 let mut tid = snapped_tid.clone();
287 for (time, eid) in it {
288 counter += 1;
289 if counter % 500 == 0 && counter > 0 {
290 debug!("Step {counter} at {time} for feature combination {i} of {mz}@{charge}");
291 }
292 tid.clone_from(&snapped_tid);
293
294 cleaned_eid.clear();
295 let n_missing =
296 self.envelope_conformer
297 .conform_into(eid, &mut tid, &mut cleaned_eid);
298 if n_missing > max_missed_peaks {
299 continue;
300 }
301
302 let score = self.scorer.score(&cleaned_eid, &tid);
303 if score.is_nan() {
304 continue;
305 }
306 score_max = score_max.max(score);
307 score_vec.push(score);
308 time_vec.push(time);
309 }
310
311 if score_vec.is_empty() {
312 continue;
313 }
314
315 let final_score = self.find_thresholded_score(&score_vec, score_max, threshold_scale);
316
317 if !self.fit_filter.test_score(final_score) {
318 continue;
319 }
320
321 let missing_features: usize = features_vec.iter().map(|f| f.is_none() as usize).sum();
322
323 let (start, end) = features_vec
324 .iter()
325 .flatten()
326 .map(|f| {
327 let pt = f.iter().next().unwrap();
328 let start = MapCoordinate::new(pt.0, pt.1);
329 let pt = f.iter().last().unwrap();
330 let end = MapCoordinate::new(pt.0, pt.1);
331 (start, end)
332 })
333 .next()
334 .unwrap();
335
336 let neutral_mass = neutral_mass(snapped_tid.origin, charge, PROTON);
337 let fit = FeatureSetFit::new(
338 indices_vec,
339 snapped_tid,
340 start,
341 end,
342 final_score,
343 charge,
344 missing_features,
345 neutral_mass,
346 counter,
347 score_vec,
348 time_vec,
349 );
350
351 fits.push(fit);
352 }
353
354 fits
355 }
356}
357
358impl<
359 Y: Clone + Default,
360 I: IsotopicPatternGenerator,
361 S: IsotopicPatternScorer,
362 F: IsotopicFitFilter,
363 > FeatureMapMatch<Y> for FeatureProcessor<Y, I, S, F>
364{
365 #[inline(always)]
366 fn feature_map(&self) -> &FeatureMapType<Y> {
367 &self.feature_map
368 }
369
370 fn feature_map_mut(&mut self) -> &mut FeatureMapType<Y> {
371 &mut self.feature_map
372 }
373}
374
375impl<
376 Y: Clone + Default,
377 I: IsotopicPatternGenerator,
378 S: IsotopicPatternScorer,
379 F: IsotopicFitFilter,
380 > GraphFeatureDeconvolution<Y> for FeatureProcessor<Y, I, S, F>
381{
382 #[inline(always)]
383 fn score_interpretation(&self) -> ScoreInterpretation {
384 self.scorer.interpretation()
385 }
386
387 #[inline(always)]
388 fn add_fit_to_graph(&mut self, fit: FeatureSetFit) {
389 self.dependency_graph.add_fit(fit)
390 }
391
392 #[inline(always)]
393 fn prefer_multiply_charged(&self) -> bool {
394 self.prefer_multiply_charged
395 }
396
397 fn skip_feature(&self, feature: &FeatureType<Y>) -> bool {
398 if self.minimum_size > feature.len() {
399 debug!(
400 "Skipping feature {} with {} points",
401 feature.mz(),
402 feature.len()
403 );
404 true
405 } else {
406 false
407 }
408 }
409
410 fn dependency_graph_mut(&mut self) -> &mut FeatureDependenceGraph {
411 &mut self.dependency_graph
412 }
413}
414
415pub(crate) type IndexSet = HashSet<usize, BuildIdentityHasher<usize>>;
416
417impl<
418 Y: Clone + Default,
419 I: IsotopicPatternGenerator,
420 S: IsotopicPatternScorer,
421 F: IsotopicFitFilter,
422 > FeatureProcessor<Y, I, S, F>
423{
424 #[allow(clippy::too_many_arguments)]
425 pub fn new(
426 feature_map: FeatureMap<MZ, Y, Feature<MZ, Y>>,
427 isotopic_model: I,
428 scorer: S,
429 fit_filter: F,
430 minimum_size: usize,
431 maximum_time_gap: f64,
432 minimum_intensity: f32,
433 prefer_multiply_charged: bool,
434 ) -> Self {
435 let dependency_graph = FeatureDependenceGraph::new(scorer.interpretation());
436 Self {
437 feature_map: feature_map.into_iter().map(FeatureType::from).collect(),
438 isotopic_model,
439 scorer,
440 fit_filter,
441 scaling_method: TheoreticalIsotopicDistributionScalingMethod::default(),
442 envelope_conformer: EnvelopeConformer::new(0.05),
443 minimum_size,
444 maximum_time_gap,
445 minimum_intensity: minimum_intensity.max(1.001),
446 prefer_multiply_charged,
447 dependency_graph,
448 feature_buffer: Vec::new(),
449 }
450 }
451
452 pub fn builder() -> FeatureProcessorBuilder<Y, I, S, F> {
453 FeatureProcessorBuilder::default()
454 }
455
456 fn find_thresholded_score(
457 &self,
458 scores: &[ScoreType],
459 maximum_score: ScoreType,
460 percentage: ScoreType,
461 ) -> ScoreType {
462 let threshold = maximum_score * percentage;
463 let (acc, count) = scores.iter().fold((0.0, 0usize), |(total, count), val| {
464 if *val > threshold {
465 (total + val, count + 1)
466 } else {
467 (total, count)
468 }
469 });
470 if count == 0 {
471 0.0
472 } else {
473 acc / count as ScoreType
474 }
475 }
476
477 pub fn finalize_fit(
478 &mut self,
479 fit: &FeatureSetFit,
480 detection_threshold: f32,
481 max_missed_peaks: usize,
482 ) -> DeconvolvedSolutionFeature<Y> {
483 let (time_range, _segments) = fit.find_separation(&self.feature_map, detection_threshold);
484 let features: Vec<_> = fit
485 .features
486 .iter()
487 .map(|i| i.map(|i| self.feature_map.get_item(i).as_ref()))
488 .collect();
489 let feat_iter = FeatureSetIter::new_with_time_interval(
490 &features,
491 time_range.start().unwrap(),
492 time_range.end().unwrap(),
493 );
494
495 let base_tid = &fit.theoretical;
496 let charge = fit.charge;
497 let abs_charge = charge.abs();
498
499 let mut scores = Vec::new();
500
501 let mut envelope_features = Vec::with_capacity(base_tid.len());
503 let mut residuals = Vec::with_capacity(base_tid.len());
505
506 for _ in base_tid.iter() {
508 envelope_features.push(MZPointSeries::default());
509 residuals.push(Vec::new());
510 }
511
512 let mut deconv_feature = ChargedFeature::empty(charge);
513 for (time, eid) in feat_iter {
514 let mut tid = base_tid.clone();
515 let (cleaned_eid, n_missing) = self.envelope_conformer.conform(eid, &mut tid);
516 let n_real_peaks = cleaned_eid.len() - n_missing;
517 if n_real_peaks == 0
518 || (n_real_peaks == 1 && abs_charge > 1)
519 || n_missing > max_missed_peaks
520 {
521 continue;
522 }
523
524 let score = self.scorer.score(&cleaned_eid, &tid);
525 if score < 0.0 || score.is_nan() {
526 continue;
527 }
528 scores.push(score);
529
530 let mut total_intensity = 0.0;
532 cleaned_eid
533 .iter()
534 .zip(tid.iter())
535 .enumerate()
536 .for_each(|(i, (e, t))| {
537 let intens = e.intensity().min(t.intensity());
538 total_intensity += intens;
539
540 envelope_features
542 .get_mut(i)
543 .unwrap()
544 .push_raw(e.mz(), intens);
545
546 if e.intensity() * 0.7 < t.intensity() {
548 residuals[i].push(1.0);
549 } else {
550 residuals[i].push((e.intensity() - intens).max(1.0));
551 }
552 if tracing::enabled!(tracing::Level::TRACE) {
553 trace!(
554 "Resid({}): at time {time} slot {i} e:{}/t:{} -> {intens} with residual {}",
555 fit.features[i].map(|x| x.to_string()).unwrap_or("?".to_string()),
556 e.intensity(),
557 t.intensity(),
558 *residuals[i].last().unwrap()
559 );
560 }
561 debug_assert!(
562 e.intensity() >= *residuals[i].last().unwrap(),
563 "{} < {}",
564 e.intensity(),
565 *residuals[i].last().unwrap()
566 );
567 });
568 let neutral_mass = neutral_mass(tid[0].mz, charge, PROTON);
569 deconv_feature.push_raw(neutral_mass, time, total_intensity);
570 }
571
572 drop(features);
573
574 self.do_subtraction(fit, &residuals, &deconv_feature);
575
576 DeconvolvedSolutionFeature::new(
577 deconv_feature,
578 fit.score,
579 scores,
580 envelope_features.into_boxed_slice(),
581 )
582 }
583
584 fn do_subtraction(
585 &mut self,
586 fit: &FeatureSetFit,
587 residuals: &[Vec<f32>],
588 deconv_feature: &ChargedFeature<Mass, Y>,
589 ) {
590 for (i, fidx) in fit.features.iter().enumerate() {
592 if fidx.is_none() {
593 continue;
594 }
595 let fidx = fidx.unwrap();
596
597 let tstart = self.feature_map[fidx].start_time().unwrap();
599 let tend = self.feature_map[fidx].end_time().unwrap();
600
601 let feature_to_reduce = &mut self.feature_map[fidx];
602 let n_of = feature_to_reduce.len();
603 let residual_intensity = &residuals[i];
604 let n_residual = residual_intensity.len();
605 let mz_of = feature_to_reduce.mz();
606
607 for (time, res_int) in deconv_feature
608 .iter_time()
609 .zip(residual_intensity.iter().copied())
610 {
611 if let (Some(j), terr) = feature_to_reduce.find_time(time) {
612 if let Some((mz_at, time_at, int_at)) = feature_to_reduce.at_mut(j) {
613 if terr.abs() > 1e-3 {
614 let terr = time_at - time;
615 trace!(
616 "Did not find a coordinate {mz_of} for {time} ({time_at} {terr} {j}) in {i} ({tstart:0.3}-{tend:0.3})",
617 );
618 } else {
619 trace!(
620 "Residual({fidx}) {int_at} => {res_int} @ {mz_at}|{time}|{time_at} {n_residual}/{n_of}",
621 );
622 *int_at = res_int.min(*int_at);
624 }
625 } else {
626 debug!("{i} unable to update {time}");
627 }
628 } else {
629 debug!("{i} unable to update {time}");
630 }
631 }
632
633 feature_to_reduce.invalidate();
634 }
635 }
636
637 fn find_unused_features(
638 &self,
639 fits: &[FeatureSetFit],
640 min_width_mz: f64,
641 min_width_time: f64,
642 ) -> Vec<usize> {
643 let quads: QuadTree<f64, f64, BoundingBox<f64, f64>> = fits
644 .iter()
645 .map(|f| {
646 BoundingBox::new(
647 (f.start.coord - min_width_mz, f.start.time - min_width_time),
648 (f.end.coord + min_width_mz, f.end.time + min_width_time),
649 )
650 })
651 .collect();
652 self.feature_map
653 .iter()
654 .enumerate()
655 .filter(|(_, f)| {
656 let bb_f = BoundingBox::new(
657 (f.mz(), f.start_time().unwrap()),
658 (f.mz(), f.end_time().unwrap()),
659 );
660 quads.overlaps(&bb_f).is_empty()
661 })
662 .map(|(i, _)| i)
663 .collect()
664 }
665
666 fn mask_features_at(&mut self, indices_to_mask: &[usize]) {
667 for i in indices_to_mask.iter().copied() {
668 let f = &mut self.feature_map[i];
669 for (_, _, z) in f.iter_mut() {
670 *z = 1.0;
671 }
672 }
673 }
674
675 fn remove_dead_points(&mut self, indices_to_mask: Option<&IndexSet>) {
676 let n_before = self.feature_map.len();
677 let n_points_before: usize = self.feature_map.iter().map(|f| f.len()).sum();
678
679 let mut tmp = FeatureMapType::empty();
680
681 mem::swap(&mut tmp, &mut self.feature_map);
682
683 if self.feature_buffer.capacity() < tmp.len() {
684 self.feature_buffer.reserve(tmp.len() - self.feature_buffer.capacity());
685 }
686 let mut features_acc = mem::take(&mut self.feature_buffer);
687
688 let mut inner: Vec<_> = tmp.into_inner().into_inner();
689 for (i, f) in inner.drain(..).enumerate() {
690 let process = indices_to_mask
691 .map(|mask| mask.contains(&i))
692 .unwrap_or(true);
693 if process {
694 let parts: Vec<_> = f
695 .feature
696 .split_when(|_, (_, _, cur_int)| cur_int <= self.minimum_intensity)
697 .into_iter()
698 .filter(|s| s.len() >= self.minimum_size)
699 .collect();
700 if parts.len() == 1 {
701 if parts[0].len() == f.len() {
702 features_acc.push(f)
703 } else {
704 let p: FeatureType<Y> = parts[0].to_owned().into();
705 features_acc.push(p)
706 }
707 } else {
708 for s in parts {
709 let p: FeatureType<Y> = s.to_owned().into();
710 features_acc.push(p);
711 }
712 }
713 } else if f.len() >= self.minimum_size {
714 features_acc.push(f);
715 }
716 }
717 self.feature_buffer = inner;
718 self.feature_map = FeatureMapType::new(features_acc);
719 let n_after = self.feature_map.len();
720 let n_points_after: usize = self.feature_map.iter().map(|f| f.len()).sum();
721 debug!("{n_before} features, {n_points_before} points before, {n_after} features, {n_points_after} points after");
722 }
723
724 #[allow(clippy::too_many_arguments)]
725 pub fn deconvolve(
726 &mut self,
727 error_tolerance: Tolerance,
728 charge_range: ChargeRange,
729 left_search_limit: i8,
730 right_search_limit: i8,
731 search_params: &FeatureSearchParams,
732 convergence: f32,
733 max_iterations: u32,
734 ) -> Result<FeatureMap<Mass, Y, DeconvolvedSolutionFeature<Y>>, DeconvolutionError> {
735 let mut before_tic: f32 = self.feature_map.iter().map(|f| f.total_intensity()).sum();
736 let ref_tic = before_tic;
737 if ref_tic == 0.0 || self.feature_map.is_empty() {
738 debug!("The TIC of the feature map was zero or the feature map was empty. Skipping processing.");
739 return Ok(Default::default());
740 }
741 let mut deconvoluted_features = Vec::new();
742 let mut converged = false;
743 let mut convergence_check = f32::MAX;
744
745 let mut indices_modified = IndexSet::default();
746
747 for i in 0..max_iterations {
748 if i > 0 {
749 self.remove_dead_points(Some(&indices_modified));
750 indices_modified.clear();
751 } else {
752 self.remove_dead_points(None);
753 }
754 debug!(
755 "Starting iteration {i} with remaining TIC {before_tic:0.4e} ({:0.3}%), {} feature fit",
756 before_tic / ref_tic * 100.0,
757 deconvoluted_features.len()
758 );
759
760 let GraphStepResult {
761 selected_fits: fits,
762 } = self.graph_step_deconvolve(
763 error_tolerance,
764 charge_range,
765 left_search_limit,
766 right_search_limit,
767 search_params,
768 )?;
769
770 debug!("Found {} fits", fits.len());
771
772 let minimum_size = self.minimum_size;
773 let max_gap_size = self.maximum_time_gap;
774 let n_before = deconvoluted_features.len();
775 deconvoluted_features.extend(
776 fits.iter()
777 .filter(|fit| fit.n_points >= minimum_size)
778 .map(|fit| {
779 let solution = self.finalize_fit(
780 fit,
781 search_params.detection_threshold,
782 search_params.max_missed_peaks,
783 );
784 indices_modified.extend(fit.features.iter().flatten().copied());
785
786 solution
787 })
788 .filter(|f| !f.is_empty())
789 .flat_map(|f| f.split_sparse(max_gap_size))
790 .filter(|fit| fit.len() >= minimum_size),
791 );
792
793 let n_new_features = deconvoluted_features.len() - n_before;
794
795 if n_new_features == 0 {
796 debug!("No new features were extracted on iteration {i} with remaining TIC {before_tic:0.4e}, {n_before} features fit");
797 break;
798 }
799
800 if i == 0 {
801 let min_width_mz = self.isotopic_model.largest_isotopic_width();
802 let indices_to_mask = self.find_unused_features(&fits, min_width_mz, max_gap_size);
803 debug!("{} features unused", indices_to_mask.len());
804 self.mask_features_at(&indices_to_mask);
805 indices_modified.extend(indices_to_mask);
806 }
807
808 let after_tic = self
809 .feature_map
810 .iter()
811 .map(|f| f.as_ref().total_intensity())
812 .sum();
813
814 convergence_check = (before_tic - after_tic) / after_tic;
815 if convergence_check <= convergence {
816 debug!(
817 "Converged on iteration {i} with remaining TIC {before_tic:0.4e} - {after_tic:0.4e} = {:0.4e} ({convergence_check}), {} features fit",
818 before_tic - after_tic,
819 deconvoluted_features.len()
820 );
821 converged = true;
822 break;
823 } else {
824 before_tic = after_tic;
825 }
826 self.dependency_graph.reset();
827 }
828 if !converged && !deconvoluted_features.is_empty() {
829 debug!(
830 "Failed to converge after {max_iterations} iterations with remaining TIC {before_tic:0.4e} ({convergence_check}), {} features fit",
831 deconvoluted_features.len()
832 );
833 }
834
835 let map: FeatureMap<_, _, _> = deconvoluted_features
836 .into_iter()
837 .filter(|f| self.fit_filter.test_score(f.score))
838 .collect();
839
840 if map.is_empty() {
841 return Ok(map);
842 }
843
844 debug!("Building merge graph over {} features", map.len());
845 let merger = FeatureMerger::<Y>::default();
846 let map_merged = merger
847 .bridge_feature_gaps(map, Tolerance::PPM(2.0), self.maximum_time_gap)
848 .features
849 .into_iter()
850 .map(reflow_feature)
851 .collect();
852 Ok(map_merged)
853 }
854}