Skip to content

Commit 7ca4e9d

Browse files
committed
feat: port RNA-annotation + fusion-prep scripts from feature/rna-integration
Ports two self-contained additions (no changes to existing scripts) from the modules repo feature/rna-integration branch: - annotate_rna.py: annotate neoantigens with RNA data (reads rna_* columns from the MAF, kallisto abundance + GTF for expression/VAF) -> console annotate_rna.py - prepare_fusion_fasta.py: build fusion neoantigen FASTAs from AGFusion output -> console prepare_fusion_fasta.py Both with their pytest suites (imports repointed to the package). pytest 101 passed.
1 parent 9cc438e commit 7ca4e9d

5 files changed

Lines changed: 467 additions & 0 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ dev = ["pytest"]
3535
"convertannotjson.py" = "neoantigen_utils.convertannotjson:main"
3636
"format_netmhcpan_output.py" = "neoantigen_utils.format_netmhcpan_output:console"
3737
"generateHLAString.sh" = "neoantigen_utils.hla_string:console"
38+
"annotate_rna.py" = "neoantigen_utils.annotate_rna:main"
39+
"prepare_fusion_fasta.py" = "neoantigen_utils.prepare_fusion_fasta:main"
3840

3941
[tool.hatch.build.targets.wheel]
4042
packages = ["src/neoantigen_utils"]
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
#!/usr/bin/env python3
2+
"""Annotate neoantigen TSV with RNA-seq data from FORTE outputs."""
3+
4+
import argparse
5+
import gzip
6+
import re
7+
import sys
8+
9+
import pandas as pd
10+
11+
VERSION = "1.0.0"
12+
13+
14+
def build_mutation_id(row):
15+
"""Reconstruct mutation_id from MAF columns.
16+
17+
Matches the format used by generate_input.py:
18+
- SNP/DNP/TNP: chr_pos_ref_alt
19+
- DEL: chr_pos_ref_D
20+
- INS: chr_pos_I_alt
21+
"""
22+
chrom = str(row["Chromosome"])
23+
pos = str(row["Start_Position"])
24+
ref = str(row["Reference_Allele"])
25+
alt = str(row["Tumor_Seq_Allele2"])
26+
vtype = str(row["Variant_Type"])
27+
28+
if vtype == "DEL":
29+
return f"{chrom}_{pos}_{ref}_D"
30+
elif vtype == "INS":
31+
return f"{chrom}_{pos}_I_{alt}"
32+
else:
33+
return f"{chrom}_{pos}_{ref}_{alt}"
34+
35+
36+
def extract_rna_columns_from_maf(maf_df):
37+
"""Extract RNA VAF columns from MAF if present.
38+
39+
Returns DataFrame with mutation_id, rna_alt_count, rna_ref_count, rna_vaf.
40+
If rna columns are absent, returns DataFrame with NaN values.
41+
"""
42+
maf_df = maf_df.copy()
43+
maf_df["mutation_id"] = maf_df.apply(build_mutation_id, axis=1)
44+
45+
has_rna = "rna_t_alt_count" in maf_df.columns
46+
47+
result = pd.DataFrame({"mutation_id": maf_df["mutation_id"]})
48+
49+
if has_rna:
50+
result["rna_alt_count"] = maf_df["rna_t_alt_count"].values
51+
result["rna_ref_count"] = maf_df["rna_t_ref_count"].values
52+
result["rna_vaf"] = maf_df["rna_t_variant_frequency"].values
53+
else:
54+
result["rna_alt_count"] = pd.NA
55+
result["rna_ref_count"] = pd.NA
56+
result["rna_vaf"] = pd.NA
57+
58+
return result
59+
60+
61+
def parse_gtf_gene_map(gtf_path):
62+
"""Parse GTF to build transcript_id -> gene_name mapping."""
63+
tx2gene = {}
64+
opener = gzip.open if gtf_path.endswith(".gz") else open
65+
with opener(gtf_path, "rt") as f:
66+
for line in f:
67+
if line.startswith("#"):
68+
continue
69+
fields = line.strip().split("\t")
70+
if len(fields) < 9:
71+
continue
72+
attrs = fields[8]
73+
tx_match = re.search(r'transcript_id "([^"]+)"', attrs)
74+
gene_match = re.search(r'gene_name "([^"]+)"', attrs)
75+
if tx_match and gene_match:
76+
tx_id = tx_match.group(1).split(".")[0]
77+
gene_name = gene_match.group(1)
78+
tx2gene[tx_id] = gene_name
79+
return tx2gene
80+
81+
82+
def annotate_expression(abundance_path, gtf_path):
83+
"""Parse Kallisto abundance and map to gene-level TPM.
84+
85+
Returns DataFrame with Gene, rna_tpm columns.
86+
Returns None if abundance_path is None.
87+
"""
88+
if abundance_path is None:
89+
return None
90+
91+
abundance = pd.read_csv(abundance_path, sep="\t")
92+
tx2gene = parse_gtf_gene_map(gtf_path)
93+
94+
abundance["transcript_id"] = abundance["target_id"].str.split(".").str[0]
95+
abundance["Gene"] = abundance["transcript_id"].map(tx2gene)
96+
97+
gene_tpm = (
98+
abundance.dropna(subset=["Gene"])
99+
.groupby("Gene")["tpm"]
100+
.sum()
101+
.reset_index()
102+
.rename(columns={"tpm": "rna_tpm"})
103+
)
104+
105+
return gene_tpm
106+
107+
108+
def merge_annotations(neo_tsv, rna_vaf_df, expression_df, tpm_threshold=1.0):
109+
"""Merge RNA annotations onto neoantigen TSV.
110+
111+
Returns annotated DataFrame with rna_tpm, rna_vaf, rna_alt_count,
112+
rna_ref_count, rna_expressed columns.
113+
"""
114+
result = neo_tsv.copy()
115+
116+
if rna_vaf_df is not None:
117+
result = result.merge(rna_vaf_df, on="mutation_id", how="left")
118+
else:
119+
result["rna_alt_count"] = pd.NA
120+
result["rna_ref_count"] = pd.NA
121+
result["rna_vaf"] = pd.NA
122+
123+
if expression_df is not None:
124+
result = result.merge(expression_df, on="Gene", how="left")
125+
else:
126+
result["rna_tpm"] = pd.NA
127+
128+
tpm_ok = result["rna_tpm"].notna() & (result["rna_tpm"] > tpm_threshold)
129+
vaf_ok = result["rna_alt_count"].isna() | (result["rna_alt_count"] > 0)
130+
result["rna_expressed"] = tpm_ok & vaf_ok
131+
132+
return result
133+
134+
135+
def main():
136+
parser = argparse.ArgumentParser(description="Annotate neoantigens with RNA data")
137+
parser.add_argument("--neoantigen_tsv", required=True, help="Neoantigen TSV from convertannotjson")
138+
parser.add_argument("--maf", required=True, help="Input MAF (may contain rna_* columns)")
139+
parser.add_argument("--kallisto_abundance", default=None, help="Kallisto abundance.tsv (optional)")
140+
parser.add_argument("--gtf", default=None, help="GTF file for transcript-to-gene mapping")
141+
parser.add_argument("--tpm_threshold", type=float, default=1.0, help="TPM threshold for expressed")
142+
parser.add_argument("--output_annotated", required=True, help="Output annotated TSV")
143+
parser.add_argument("--output_report", required=True, help="Output detailed RNA report")
144+
parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {VERSION}")
145+
args = parser.parse_args()
146+
147+
neo_tsv = pd.read_csv(args.neoantigen_tsv, sep="\t")
148+
maf_df = pd.read_csv(args.maf, sep="\t", comment="#")
149+
150+
rna_vaf_df = extract_rna_columns_from_maf(maf_df)
151+
expression_df = annotate_expression(args.kallisto_abundance, args.gtf)
152+
153+
annotated = merge_annotations(neo_tsv, rna_vaf_df, expression_df, args.tpm_threshold)
154+
annotated.to_csv(args.output_annotated, sep="\t", index=False)
155+
156+
report_cols = ["mutation_id", "Gene", "rna_tpm", "rna_vaf", "rna_alt_count", "rna_ref_count", "rna_expressed"]
157+
available_cols = [c for c in report_cols if c in annotated.columns]
158+
report = annotated[available_cols].drop_duplicates()
159+
report.to_csv(args.output_report, sep="\t", index=False)
160+
161+
162+
if __name__ == "__main__":
163+
main()
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
#!/usr/bin/env python3
2+
"""Convert AGFusion output to pipeline-compatible FASTA format."""
3+
4+
import argparse
5+
import os
6+
7+
VERSION = "1.0.0"
8+
9+
10+
def parse_agfusion_dir(agfusion_dir):
11+
"""Parse AGFusion output directory for fusion protein sequences.
12+
13+
AGFusion creates subdirectories per fusion event, each containing
14+
*_protein.fa files with fusion protein sequences.
15+
"""
16+
fusions = []
17+
for entry in sorted(os.listdir(agfusion_dir)):
18+
subdir = os.path.join(agfusion_dir, entry)
19+
if not os.path.isdir(subdir):
20+
continue
21+
gene_pair = entry
22+
for fname in sorted(os.listdir(subdir)):
23+
if fname.endswith("_protein.fa"):
24+
fpath = os.path.join(subdir, fname)
25+
sequences = read_fasta(fpath)
26+
for seq_id, seq in sequences:
27+
fusions.append({
28+
"gene_pair": gene_pair,
29+
"transcript_pair": seq_id,
30+
"sequence": seq,
31+
})
32+
return fusions
33+
34+
35+
def read_fasta(path):
36+
"""Read FASTA file, return list of (header, sequence) tuples."""
37+
sequences = []
38+
current_header = None
39+
current_seq = []
40+
with open(path) as f:
41+
for line in f:
42+
line = line.strip()
43+
if line.startswith(">"):
44+
if current_header:
45+
sequences.append((current_header, "".join(current_seq)))
46+
current_header = line[1:]
47+
current_seq = []
48+
elif line:
49+
current_seq.append(line)
50+
if current_header:
51+
sequences.append((current_header, "".join(current_seq)))
52+
return sequences
53+
54+
55+
def extract_junction_peptides(fusion_seq, junction_pos, peptide_lengths=None):
56+
"""Extract peptide windows spanning the fusion junction.
57+
58+
For each peptide length, slide a window across the junction point.
59+
Each peptide must include at least 1 AA from each fusion partner.
60+
"""
61+
if peptide_lengths is None:
62+
peptide_lengths = [9, 10, 11]
63+
peptides = []
64+
for plen in peptide_lengths:
65+
if len(fusion_seq) < plen:
66+
continue
67+
start_min = max(0, junction_pos - plen + 1)
68+
start_max = min(junction_pos, len(fusion_seq) - plen)
69+
for start in range(start_min, start_max + 1):
70+
pep = fusion_seq[start:start + plen]
71+
if len(pep) == plen:
72+
peptides.append(pep)
73+
return peptides
74+
75+
76+
def infer_junction_position(fusion_seq, five_prime_len=None):
77+
"""Infer junction position from AGFusion sequence.
78+
79+
AGFusion marks the junction with '*' in some outputs.
80+
If not present, use five_prime_len if provided, else midpoint.
81+
"""
82+
if "*" in fusion_seq:
83+
return fusion_seq.index("*")
84+
if five_prime_len is not None:
85+
return five_prime_len
86+
return len(fusion_seq) // 2
87+
88+
89+
def write_fasta_pair(peptides, mut_path, wt_path):
90+
"""Write MUT and WT FASTA files for pipeline compatibility."""
91+
with open(mut_path, "w") as mut_f, open(wt_path, "w") as wt_f:
92+
for pep in peptides:
93+
mut_f.write(f">{pep['id']}_M\n{pep['mut_seq']}\n")
94+
wt_f.write(f">{pep['id']}_W\n{pep['wt_seq']}\n")
95+
96+
97+
def main():
98+
parser = argparse.ArgumentParser(description="Prepare fusion FASTAs for neoantigen pipeline")
99+
parser.add_argument("--agfusion_dir", required=True, help="AGFusion output directory")
100+
parser.add_argument("--output_prefix", required=True, help="Output file prefix")
101+
parser.add_argument("--peptide_lengths", default="9,10,11", help="Comma-separated peptide lengths")
102+
parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {VERSION}")
103+
args = parser.parse_args()
104+
105+
peptide_lengths = [int(x) for x in args.peptide_lengths.split(",")]
106+
fusions = parse_agfusion_dir(args.agfusion_dir)
107+
108+
all_peptides = []
109+
pep_counter = 0
110+
for fusion in fusions:
111+
seq = fusion["sequence"].replace("*", "")
112+
junction = infer_junction_position(fusion["sequence"])
113+
junction_peps = extract_junction_peptides(seq, junction, peptide_lengths)
114+
for pep in junction_peps:
115+
pep_counter += 1
116+
all_peptides.append({
117+
"id": f"{fusion['gene_pair']}_{pep_counter}",
118+
"mut_seq": pep,
119+
"wt_seq": pep,
120+
})
121+
122+
mut_path = f"{args.output_prefix}.SV.MUT.fa"
123+
wt_path = f"{args.output_prefix}.SV.WT.fa"
124+
write_fasta_pair(all_peptides, mut_path, wt_path)
125+
126+
127+
if __name__ == "__main__":
128+
main()

0 commit comments

Comments
 (0)