use super::{grid::XicGrid, LfqConfig, ScoreMode};
use crate::scoring::averagine::bhattacharyya_score;
use crate::scoring::elements::K_PATTERN;
#[derive(Debug, Clone)]
pub struct ColumnScores {
pub rt: Vec<f32>,
pub intensity: Vec<f32>,
pub bhattacharyya: Vec<f32>,
pub hybrid: Vec<f32>,
pub projected: Vec<f32>,
pub coelution: f32,
}
impl ColumnScores {
pub fn new(n_cols: usize) -> Self {
Self {
rt: vec![0.0f32; n_cols],
intensity: vec![0.0f32; n_cols],
bhattacharyya: vec![0.0f32; n_cols],
hybrid: vec![0.0f32; n_cols],
projected: vec![0.0f32; n_cols],
coelution: 1.0,
}
}
}
pub fn score_grid(
grid: &XicGrid,
theoretical_pattern: &[f64],
bc_template: &[f64; K_PATTERN],
config: &LfqConfig,
scores: &mut ColumnScores,
col_totals: &mut [f32],
obs: &mut [f64],
rt_sigma_cols: Option<f32>,
) {
let n_cols = grid.n_cols();
let n_rows = grid.n_rows();
let center = (n_cols as f64 - 1.0) / 2.0;
for c in 0..n_cols {
col_totals[c] = grid.intensities.iter().map(|row| row[c]).sum();
}
let max_total = col_totals[..n_cols].iter().cloned().fold(0.0f32, f32::max);
let mut theory = [0.0f64; 8]; let theory = &mut theory[..n_rows];
{
let mut sum = 0.0f64;
for (i, &v) in theoretical_pattern.iter().take(n_rows).enumerate() {
theory[i] = v;
sum += v;
}
if sum > 0.0 {
for v in theory.iter_mut() {
*v /= sum;
}
} else {
let uniform = 1.0 / n_rows as f64;
for v in theory.iter_mut() {
*v = uniform;
}
}
}
let coelution: f64 = isotope_coelution(
&grid.intensities,
theory,
n_rows,
n_cols,
config.lone_coelution,
);
scores.coelution = coelution as f32;
let theory_l2: f64 = if config.averagine_projection {
theory.iter().map(|&t| t * t).sum::<f64>().sqrt()
} else {
0.0
};
for col in 0..n_cols {
let rt_dev = if center > 0.0 {
(col as f64 - center).abs() / center
} else {
0.0
};
let rt_score = match rt_sigma_cols {
Some(sigma) if sigma > 0.0 => {
let z = (col as f64 - center) / sigma as f64;
(-0.5 * z * z).exp() as f32
}
_ => (1.0 - rt_dev.cbrt()).max(0.0) as f32,
};
scores.rt[col] = rt_score;
let int_score = if max_total > 0.0 {
(col_totals[col] / max_total).sqrt()
} else {
0.0
};
scores.intensity[col] = int_score;
for (r, v) in obs[..n_rows].iter_mut().enumerate() {
*v = grid.intensities[r][col] as f64;
}
let bhattacharyya = bhattacharyya_score(&obs[..n_rows], bc_template) as f32;
scores.bhattacharyya[col] = bhattacharyya;
if theory_l2 > 0.0 {
let dot: f64 = obs[..n_rows]
.iter()
.zip(theory.iter())
.map(|(&o, &t)| o * t)
.sum();
scores.projected[col] = (dot / theory_l2) as f32;
}
let hybrid = match config.score_mode {
ScoreMode::Hybrid => {
let base = rt_score as f64 * int_score as f64 * bhattacharyya as f64 * coelution;
base.powf(0.25) as f32
}
ScoreMode::Rt => rt_score,
ScoreMode::Intensity => int_score,
ScoreMode::Spectral => bhattacharyya,
};
scores.hybrid[col] = hybrid;
}
}
fn isotope_coelution(
intensities: &[Vec<f32>],
theory: &[f64],
n_rows: usize,
n_cols: usize,
lone_value: f64,
) -> f64 {
if n_rows < 2 {
return lone_value;
}
let row0 = &intensities[0];
let (mut wsum, mut csum) = (0.0f64, 0.0f64);
for k in 1..n_rows {
let w = theory.get(k).copied().unwrap_or(0.0);
if w <= 0.0 {
continue;
}
let rowk = &intensities[k];
let (mut dot, mut n0, mut nk) = (0.0f64, 0.0f64, 0.0f64);
for c in 0..n_cols {
let a = row0[c] as f64;
let b = rowk[c] as f64;
dot += a * b;
n0 += a * a;
nk += b * b;
}
if nk <= 0.0 {
continue; }
let cos = if n0 > 1e-12 {
(dot / (n0.sqrt() * nk.sqrt())).clamp(0.0, 1.0)
} else {
0.0
};
csum += w * cos;
wsum += w;
}
if wsum > 0.0 {
csum / wsum
} else {
lone_value
}
}
#[cfg(test)]
mod spectral_tests {
use super::*;
use crate::lfq::grid::XicGrid;
fn lone_mono_grid() -> XicGrid {
let mut g = XicGrid::empty(3, 3, 0.0, 1.0);
g.intensities[0][1] = 1.0e7;
g.n_slots_filled = 1;
g
}
#[test]
fn bhattacharyya_penalises_lone_monoisotope_decoy() {
let grid = lone_mono_grid();
let neutral_mass = 1500.0; let bc_template = crate::scoring::model::PEPTIDE.distribution(neutral_mass);
let theory = vec![0.5, 0.3, 0.2];
let mut scores = ColumnScores::new(3);
let mut col_totals = vec![0.0f32; 3];
let mut obs = vec![0.0f64; 3];
let mut cfg = LfqConfig::default();
cfg.score_mode = ScoreMode::Spectral;
score_grid(
&grid,
&theory,
&bc_template,
&cfg,
&mut scores,
&mut col_totals,
&mut obs,
None,
);
let bc_center = scores.bhattacharyya[1];
assert!(
bc_center > 0.0 && bc_center < 0.85,
"bhattacharyya should penalise a lone monoisotope (0 < s < 0.85), got {bc_center}"
);
}
#[test]
fn coelution_separates_coeluting_from_interfering() {
let theory = [0.5, 0.3, 0.2];
let coel = vec![
vec![1.0f32, 9.0, 1.0],
vec![0.5f32, 4.5, 0.5],
vec![0.2f32, 1.8, 0.2],
];
let good = isotope_coelution(&coel, &theory, 3, 3, 1.0);
let interfere = vec![
vec![9.0f32, 1.0, 0.0],
vec![0.0f32, 1.0, 9.0],
vec![0.0f32, 0.0, 0.0],
];
let bad = isotope_coelution(&interfere, &theory, 3, 3, 1.0);
assert!(good > 0.95, "co-eluting rows should score ~1, got {good}");
assert!(bad < 0.5, "interfering rows should score low, got {bad}");
assert!(good > bad);
let lone = vec![vec![0.0f32, 5.0, 0.0], vec![0.0f32; 3], vec![0.0f32; 3]];
assert_eq!(isotope_coelution(&lone, &theory, 3, 3, 1.0), 1.0);
assert_eq!(isotope_coelution(&lone, &theory, 3, 3, 0.5), 0.5);
}
use crate::lfq::integrate::integrate;
fn centre_grid(mono: f32, m1: f32, m2: f32) -> XicGrid {
let mut g = XicGrid::empty(3, 3, 0.0, 1.0);
g.intensities[0][1] = mono;
g.intensities[1][1] = m1;
g.intensities[2][1] = m2;
g.n_slots_filled = 3;
g
}
fn integrate_grid(grid: &XicGrid, theory: &[f64], projection: bool) -> f64 {
let bc_template = crate::scoring::model::PEPTIDE.distribution(1500.0);
let mut cfg = LfqConfig::default();
cfg.averagine_projection = projection;
let mut scores = ColumnScores::new(3);
let mut col_totals = vec![0.0f32; 3];
let mut obs = vec![0.0f64; 3];
score_grid(
grid,
theory,
&bc_template,
&cfg,
&mut scores,
&mut col_totals,
&mut obs,
None,
);
integrate(grid, &scores, &col_totals, &cfg).intensity
}
#[test]
fn projection_off_is_raw_sum_on_is_projection() {
let theory = [0.5, 0.3, 0.2];
let grid = centre_grid(50.0, 30.0, 20.0);
let raw = integrate_grid(&grid, &theory, false);
assert_eq!(
raw, 100.0,
"flag off must report the raw box-sum (50+30+20)"
);
let proj = integrate_grid(&grid, &theory, true);
assert!((proj - 61.65).abs() < 0.5, "projection ≈ 61.65, got {proj}");
}
#[test]
fn projection_downweights_off_pattern_contamination() {
let theory = [0.5, 0.3, 0.2];
let clean = centre_grid(50.0, 30.0, 20.0); let contam = centre_grid(50.0, 30.0, 60.0);
let raw_ratio =
integrate_grid(&contam, &theory, false) / integrate_grid(&clean, &theory, false);
let proj_ratio =
integrate_grid(&contam, &theory, true) / integrate_grid(&clean, &theory, true);
assert!(
raw_ratio > 1.35,
"raw sum tracks the contamination (140/100), got {raw_ratio}"
);
assert!(
proj_ratio < raw_ratio,
"projection must be less inflated by off-pattern signal (proj {proj_ratio} vs raw {raw_ratio})"
);
}
}