pub mod averagine;
pub mod elements;
pub mod model;
use crate::config::ScoringConfig;
use crate::models::{Feature, Polarity, ScoredFeature};
use averagine::bhattacharyya_score;
use model::IsotopeModel;
pub fn score_features(
features: &[Feature],
config: &ScoringConfig,
sulfur_offsets: &[i8],
model: &IsotopeModel,
polarity: Polarity,
) -> Vec<ScoredFeature> {
log::info!("Scoring {} features", features.len());
let all_scored: Vec<ScoredFeature> = features
.iter()
.map(|feature| score_one(feature, config, sulfur_offsets, model, polarity))
.collect();
if log::log_enabled!(log::Level::Debug) {
let charged: Vec<&ScoredFeature> = all_scored
.iter()
.filter(|sf| sf.feature.charge > 0)
.collect();
if !charged.is_empty() {
let max_score = charged
.iter()
.map(|sf| sf.combined_score)
.fold(f64::NEG_INFINITY, f64::max);
let mean_score =
charged.iter().map(|sf| sf.combined_score).sum::<f64>() / charged.len() as f64;
let above = charged
.iter()
.filter(|sf| sf.isotope_score >= config.min_isotope_score_for_offset)
.count();
log::debug!(
"score diag: {} charged features, max_combined={:.3}, mean_combined={:.3}, isotope>={}: {}",
charged.len(), max_score, mean_score, config.min_isotope_score_for_offset, above
);
for sf in charged.iter().take(5) {
let obs = sf.feature.isotope_profile_apex();
log::debug!(
" charge={} n_isotopes={} obs={:?} iso={:.4} cos={:.4} comb={:.4}",
sf.feature.charge,
sf.feature.hills.len(),
obs.iter().map(|x| format!("{:.0}", x)).collect::<Vec<_>>(),
sf.isotope_score,
sf.cosine_score,
sf.combined_score,
);
}
}
}
let scored: Vec<ScoredFeature> = all_scored
.into_iter()
.filter(|sf| sf.feature.charge > 0)
.collect();
log::info!("Scored {} features", scored.len());
scored
}
fn score_one(
feature: &Feature,
config: &ScoringConfig,
sulfur_offsets: &[i8],
model: &IsotopeModel,
polarity: Polarity,
) -> ScoredFeature {
let neutral_mass = match feature.monoisotopic_neutral_mass(polarity) {
Some(m) => m,
None => {
return ScoredFeature {
feature: feature.clone(),
neutron_offset: 0,
isotope_score: 0.0,
cosine_score: 0.0,
combined_score: 0.0,
theoretical_pattern: Vec::new(),
};
}
};
let template = model.distribution(neutral_mass);
let obs = feature.isotope_profile_apex();
let k = obs.len().min(10);
let mut best_combined = f64::NEG_INFINITY;
let mut best_bc = 0.0f64;
let mut best_offset: i8 = 0;
let score_obs = |o: &[f64]| -> f64 {
if sulfur_offsets.is_empty() {
bhattacharyya_score(o, &template)
} else {
averagine::bhattacharyya_score_best_sulfur(o, neutral_mass, sulfur_offsets, model).0
}
};
let (offset_min, offset_max): (i8, i8) = if config.isotope_offset_enabled {
(-1, 1)
} else {
(0, 0)
};
for o in offset_min..=offset_max {
let mut obs_aligned = vec![0.0f64; k];
for j in 0..k {
let src = j as i32 + o as i32;
if src >= 0 && (src as usize) < k {
obs_aligned[j] = obs[src as usize];
}
}
let sc = score_obs(&obs_aligned);
let combined = sc
+ if o == 0 {
config.offset_zero_bonus
} else {
0.0
};
if combined > best_combined {
best_combined = combined;
best_bc = sc;
best_offset = o;
}
}
if best_combined < config.min_isotope_score_for_offset {
best_offset = 0;
best_bc = score_obs(&obs);
}
let theo_slice = &template[..k];
let theo_sum: f64 = theo_slice.iter().sum();
let theoretical_pattern: Vec<f64> = if theo_sum > 0.0 {
theo_slice.iter().map(|&x| x / theo_sum).collect()
} else {
vec![0.0; k]
};
let isotope_score = best_bc.clamp(0.0, 1.0);
let cosine_score = feature.cosine_score.clamp(0.0, 1.0);
let combined_score = (isotope_score * cosine_score).clamp(0.0, 1.0);
ScoredFeature {
feature: feature.clone(),
neutron_offset: best_offset,
isotope_score,
cosine_score,
combined_score,
theoretical_pattern,
}
}