Skip to content

Commit f26d21f

Browse files
committed
Compute and output Grantham distance, SO terms/impact and MNV shift
Wire the biologist annotations into the indel engine's SNP/MNV path: compute the Grantham distance and MNV-vs-SNV consequence shift in gene_path::process_codon, map every variant to a Sequence Ontology term and impact (so_consequence, covering the engine's StartLost and FrameshiftDownstreamOfStop change types), and emit them as new TSV columns (SO Term, Impact, Grantham, MNV Consequence Shift) and VCF INFO fields (SO, IMPACT, GD, MNVSHIFT) with headers. Output matches the standalone 1.1.5 feature branch on the example dataset.
1 parent 7e8fb6d commit f26d21f

3 files changed

Lines changed: 156 additions & 19 deletions

File tree

src/output/common.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,45 @@ fn is_reserved_info_key(key: &str) -> bool {
3434
| "ERR"
3535
| "EDP"
3636
| "EFREQ"
37+
| "SO"
38+
| "IMPACT"
39+
| "GD"
40+
| "MNVSHIFT"
3741
)
3842
}
3943

44+
/// Map a variant to a Sequence Ontology consequence term and its impact level
45+
/// (`HIGH` / `MODERATE` / `LOW` / `MODIFIER`), following SnpEff/VEP conventions.
46+
pub(crate) fn so_consequence(variant: &VariantInfo) -> (&'static str, &'static str) {
47+
use crate::variants::ChangeType;
48+
if variant.gene == "intergenic" {
49+
return ("intergenic_variant", "MODIFIER");
50+
}
51+
match variant.change_type {
52+
ChangeType::Synonymous => ("synonymous_variant", "LOW"),
53+
ChangeType::NonSynonymous => ("missense_variant", "MODERATE"),
54+
ChangeType::StartLost => ("start_lost", "HIGH"),
55+
ChangeType::StopGained | ChangeType::FrameshiftStopGained => ("stop_gained", "HIGH"),
56+
ChangeType::StopLost | ChangeType::FrameshiftStopLost => ("stop_lost", "HIGH"),
57+
ChangeType::FrameshiftIndel
58+
| ChangeType::FrameshiftSynonymous
59+
| ChangeType::FrameshiftNonSynonymous
60+
| ChangeType::FrameshiftUnknown => ("frameshift_variant", "HIGH"),
61+
// A frameshift after the stop codon does not alter the protein.
62+
ChangeType::FrameshiftDownstreamOfStop => ("coding_sequence_variant", "MODIFIER"),
63+
ChangeType::InFrameIndel => {
64+
let ref_len = variant.ref_bases.first().map_or(0, |b| b.len());
65+
let alt_len = variant.base_changes.first().map_or(0, |b| b.len());
66+
if alt_len > ref_len {
67+
("inframe_insertion", "MODERATE")
68+
} else {
69+
("inframe_deletion", "MODERATE")
70+
}
71+
}
72+
ChangeType::IndelOverlap | ChangeType::Unknown => ("coding_sequence_variant", "MODIFIER"),
73+
}
74+
}
75+
4076
#[derive(Default)]
4177
struct InfoBuilder {
4278
fields: Vec<(String, String)>,
@@ -192,6 +228,15 @@ pub(crate) fn build_info_string(
192228
}
193229
builder.push_text("CT", &variant.change_type.to_string());
194230
builder.push_text("TYPE", variant_type);
231+
let (so_term, impact) = so_consequence(variant);
232+
builder.push_text("SO", so_term);
233+
builder.push_text("IMPACT", impact);
234+
if let Some(gd) = variant.annotations.grantham {
235+
builder.push("GD", gd);
236+
}
237+
if variant.annotations.consequence_shift != crate::variants::ConsequenceShift::NotApplicable {
238+
builder.push_text("MNVSHIFT", variant.annotations.consequence_shift.as_str());
239+
}
195240

196241
if let Some((sr, srf, srr)) = snp_metrics {
197242
builder.push("SR", sr);
@@ -534,6 +579,22 @@ pub(crate) fn write_info_header(
534579
writer,
535580
"##INFO=<ID=CT,Number=1,Type=String,Description=\"Change type\">"
536581
)?;
582+
writeln!(
583+
writer,
584+
"##INFO=<ID=SO,Number=1,Type=String,Description=\"Sequence Ontology consequence term\">"
585+
)?;
586+
writeln!(
587+
writer,
588+
"##INFO=<ID=IMPACT,Number=1,Type=String,Description=\"Predicted impact (HIGH, MODERATE, LOW, MODIFIER)\">"
589+
)?;
590+
writeln!(
591+
writer,
592+
"##INFO=<ID=GD,Number=1,Type=Integer,Description=\"Grantham distance of the (combined) missense change\">"
593+
)?;
594+
writeln!(
595+
writer,
596+
"##INFO=<ID=MNVSHIFT,Number=1,Type=String,Description=\"Combined MNV consequence vs. individual SNVs (MNV-gained / MNV-masked / Concordant)\">"
597+
)?;
537598
writeln!(
538599
writer,
539600
"##INFO=<ID=EC,Number=1,Type=String,Description=\"Canonical allele event class derived from REF/ALT\">"

src/output/tsv.rs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,22 @@ fn is_intergenic(variant: &VariantInfo) -> bool {
1515
variant.gene == "intergenic"
1616
}
1717

18+
/// Trailing annotation cells appended to every TSV row: SO consequence term,
19+
/// impact, Grantham distance (with category), and MNV-vs-SNV consequence shift.
20+
fn annotation_cells(variant: &VariantInfo) -> [String; 4] {
21+
let grantham = match variant.annotations.grantham {
22+
Some(d) => format!("{d} ({})", crate::utils::grantham_category(d)),
23+
None => "-".to_string(),
24+
};
25+
let (so_term, impact) = super::common::so_consequence(variant);
26+
[
27+
so_term.to_string(),
28+
impact.to_string(),
29+
grantham,
30+
variant.annotations.consequence_shift.to_string(),
31+
]
32+
}
33+
1834
/// Render a "Local …" column. Falls back to the protein-wide column when the
1935
/// local vector is empty (e.g. records produced before this field existed and
2036
/// then deserialized via `#[serde(default)]`).
@@ -431,6 +447,10 @@ impl TsvWriter {
431447
"Event Reverse Reads",
432448
"Event Depth",
433449
"Event Frequency",
450+
"SO Term",
451+
"Impact",
452+
"Grantham",
453+
"MNV Consequence Shift",
434454
]
435455
} else {
436456
vec![
@@ -450,6 +470,10 @@ impl TsvWriter {
450470
"MNV Codon",
451471
"Event Class",
452472
"Event Components",
473+
"SO Term",
474+
"Impact",
475+
"Grantham",
476+
"MNV Consequence Shift",
453477
]
454478
};
455479
writer.write_record(&header)?;
@@ -474,10 +498,12 @@ impl TsvWriter {
474498
if !passes_filters(variant, self.filters)? {
475499
continue;
476500
}
477-
let row = build_tsv_row_with_reads(variant)?;
501+
let mut row = build_tsv_row_with_reads(variant)?;
502+
row.extend(annotation_cells(variant));
478503
self.writer.write_record(&row)?;
479504
} else {
480-
let row = build_tsv_row_without_reads(variant)?;
505+
let mut row = build_tsv_row_without_reads(variant)?;
506+
row.extend(annotation_cells(variant));
481507
self.writer.write_record(&row)?;
482508
}
483509
}

src/variants/codon/gene_path.rs

Lines changed: 67 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,46 @@
11
//! Genomic-coordinate codon construction, processing, and codon-bounds math.
22
3-
use crate::utils::{determine_change_type, iupac_aa, reverse_complement};
4-
use crate::variants::{ChangeType, CodonInfo, Gene, Snp, Strand, VariantInfo, VariantType};
3+
use crate::utils::{determine_change_type, grantham_distance, iupac_aa, reverse_complement};
4+
5+
/// Coarse severity of an amino-acid change: stop (3) > missense (2) >
6+
/// synonymous (1) > ambiguous (0).
7+
fn aa_severity(orig: char, mutated: char) -> u8 {
8+
if orig == 'X' || mutated == 'X' {
9+
0
10+
} else if orig == '*' || mutated == '*' {
11+
3
12+
} else if orig == mutated {
13+
1
14+
} else {
15+
2
16+
}
17+
}
18+
19+
/// Compare the combined MNV consequence against its individual SNVs.
20+
fn compute_consequence_shift(
21+
orig: char,
22+
combined_mut: char,
23+
single_muts: &[char],
24+
) -> ConsequenceShift {
25+
if single_muts.len() <= 1 || orig == 'X' {
26+
return ConsequenceShift::NotApplicable;
27+
}
28+
let combined = aa_severity(orig, combined_mut);
29+
let max_individual = single_muts
30+
.iter()
31+
.map(|&m| aa_severity(orig, m))
32+
.max()
33+
.unwrap_or(0);
34+
match combined.cmp(&max_individual) {
35+
std::cmp::Ordering::Greater => ConsequenceShift::Gained,
36+
std::cmp::Ordering::Less => ConsequenceShift::Masked,
37+
std::cmp::Ordering::Equal => ConsequenceShift::Concordant,
38+
}
39+
}
40+
use crate::variants::{
41+
ChangeType, CodonInfo, ConsequenceShift, Gene, Snp, Strand, VariantAnnotations, VariantInfo,
42+
VariantType,
43+
};
544

645
use super::grouping::{collect_all_f64, collect_all_usize, merge_original_info};
746

@@ -123,7 +162,9 @@ pub fn process_codon(
123162
let combined_aa_local = iupac_aa(&combined_change_local);
124163
let change_type = ChangeType::from_label(&determine_change_type(&combined_change));
125164

126-
let snp_changes: Vec<String> = codon_info
165+
// Single-residue result of each SNV on its own (reused for the per-SNP
166+
// columns and the MNV-vs-SNV consequence comparison).
167+
let single_aas: Vec<char> = codon_info
127168
.codon_list
128169
.iter()
129170
.map(|snp| {
@@ -132,23 +173,32 @@ pub fn process_codon(
132173
Strand::Minus => reverse_complement(&single_codon),
133174
Strand::Plus => single_codon,
134175
};
135-
let single_aa = genetic_code.translate_seq(single.as_bytes());
136-
iupac_aa(&format!("{orig_aa}{aa_pos}{single_aa}"))
176+
genetic_code
177+
.translate_seq(single.as_bytes())
178+
.chars()
179+
.next()
180+
.unwrap_or('X')
137181
})
138182
.collect();
139-
let snp_changes_local: Vec<String> = codon_info
140-
.codon_list
183+
let snp_changes: Vec<String> = single_aas
141184
.iter()
142-
.map(|snp| {
143-
let single_codon = construct_codon(&codon_info, &[snp]);
144-
let single = match strand {
145-
Strand::Minus => reverse_complement(&single_codon),
146-
Strand::Plus => single_codon,
147-
};
148-
let single_aa = genetic_code.translate_seq(single.as_bytes());
149-
iupac_aa(&format!("{orig_aa}{local_aa_pos}{single_aa}"))
150-
})
185+
.map(|aa| iupac_aa(&format!("{orig_aa}{aa_pos}{aa}")))
151186
.collect();
187+
let snp_changes_local: Vec<String> = single_aas
188+
.iter()
189+
.map(|aa| iupac_aa(&format!("{orig_aa}{local_aa_pos}{aa}")))
190+
.collect();
191+
192+
let orig_c = orig_aa.chars().next().unwrap_or('X');
193+
let mut_c = mut_aa.chars().next().unwrap_or('X');
194+
let annotations = VariantAnnotations {
195+
grantham: if change_type == ChangeType::NonSynonymous {
196+
grantham_distance(orig_c, mut_c)
197+
} else {
198+
None
199+
},
200+
consequence_shift: compute_consequence_shift(orig_c, mut_c, &single_aas),
201+
};
152202

153203
VariantInfo {
154204
chrom: chrom.to_string(),
@@ -202,7 +252,7 @@ pub fn process_codon(
202252
.iter()
203253
.map(|s| format!("SNV:{}:{}>{}", s.position, s.ref_base, s.base))
204254
.collect(),
205-
annotations: crate::variants::VariantAnnotations::default(),
255+
annotations,
206256
}
207257
}
208258

0 commit comments

Comments
 (0)