-
Notifications
You must be signed in to change notification settings - Fork 9
Add ProForma 2.0 peptide exporter (-epepproforma) #80
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Luxxii
merged 4 commits into
mpc-bioinformatics:master
from
okohlbacher:proforma-peptide-export
Sep 7, 2026
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
ea72dd5
Add ProForma 2.0 peptide exporter (-epepproforma)
6610ad5
Apply review patch: qualifiers column, header row, merged-node fix, 0…
c853850
Take start/end from sequence-bearing nodes
09a6a3f
Place merged-node mods per node and position; qualifiers as one column
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,189 @@ | ||
| from decimal import Decimal | ||
| from math import isfinite | ||
|
|
||
| from Bio.SeqFeature import UnknownPosition | ||
|
|
||
| from protgraph.export.peptides.pep_fasta import PepFasta | ||
| from protgraph.graph_collapse_edges import Or | ||
|
|
||
|
|
||
| class PepProForma(PepFasta): | ||
| """ | ||
| Peptide exporter emitting ProForma 2.0 strings (HUPO-PSI standard notation | ||
| for proteoforms and peptidoforms, LeDuc et al., J. Proteome Res. 2022). | ||
|
|
||
| Writes one tab-separated line per exported peptide path with columns: | ||
| accession, proforma, start, end, misscleavages, qualifiers | ||
|
|
||
| ProtGraph knows modifications only by their delta mass (-fm/-vm), so | ||
| modifications are emitted as mass-delta tags by default ("S[+79.966]"), | ||
| which is valid ProForma. A user-declared mapping (--pep_proforma_mod_names | ||
| "79.966=UNIMOD:21") replaces a delta by a named tag ("S[UNIMOD:21]"). | ||
| Terminal modifications (NPEPTERM etc.) use ProForma terminal syntax | ||
| ("[+42.010565]-PEPTIDE" or "[UNIMOD:1]-PEPTIDE"). | ||
|
|
||
| A modification is placed on the residue its edge points at: FIXMOD/VARMOD | ||
| features are attached to the in-edges of the (cloned) modified node when | ||
| the graph is generated, so the first residue of the traversed edge's | ||
| target node is the modified one. This also holds for residues introduced | ||
| by VARIANT/MUTAGEN/CONFLICT, whose features carry no usable reference | ||
| location, and for nodes merged after annotation. | ||
|
|
||
| NOTE: as with the FASTA exporter, this exports all possible paths, so make | ||
| sure the traversal can terminate in forseeable future! | ||
| """ | ||
|
|
||
| def start_up(self, **kwargs): | ||
| super(PepProForma, self).start_up(**kwargs) | ||
| self.output_file = kwargs["pep_proforma_out"] | ||
| self.mod_names = dict(kwargs["pep_proforma_mod_names"] or []) | ||
| self.warned_or_once = False | ||
| self.write_qualifiers = kwargs["pep_proforma_write_qualifiers"] | ||
|
|
||
| def export(self, prot_graph, queue): | ||
| tsv_header = [ | ||
| "accession", "proforma", "start", "end", "misscleavages" | ||
| ] + (["qualifiers"] if self.write_qualifiers else []) | ||
|
|
||
| queue.put((self.output_file, "\t".join(tsv_header) + "\n", True, "w")) | ||
| super().export(prot_graph, queue) | ||
|
|
||
| def export_peptides(self, prot_graph, l_path_nodes, l_path_edges, l_peptide, l_miscleavages, queue): | ||
| entries = "" | ||
| for nodes, edges, misses in zip(l_path_nodes, l_path_edges, l_miscleavages): | ||
| # start/end come from the first/last node carrying sequence: with | ||
| # terminal modifications applied, nodes[1] (or nodes[-2]) can be an | ||
| # empty helper node (see annotate_ptms) whose position 0 does not | ||
| # exist in 1-based protein coordinates | ||
| first = next(n for n in nodes[1:-1] if prot_graph.vs[n]["aminoacid"]) | ||
| last = next(n for n in nodes[-2:0:-1] if prot_graph.vs[n]["aminoacid"]) | ||
| acc = self._get_accession_or_isoform(prot_graph.vs[first]) | ||
| start_pos = self._get_position_or_isoform_position(prot_graph.vs[first]) | ||
| end_pos = self._get_position_or_isoform_position(prot_graph.vs[last], end=True) | ||
| proforma = self._build_proforma(prot_graph, nodes[1:-1], edges) | ||
| entries += "\t".join( | ||
| [acc, proforma, str(start_pos), str(end_pos), str(misses)] | ||
| # one 'qualifiers' COLUMN, matching the header: the individual | ||
| # qualifier strings are comma-joined (as in the FASTA headers), | ||
| # and the field is present, possibly empty, on every row | ||
| + ([",".join(self._get_qualifiers(prot_graph, edges))] | ||
| if self.write_qualifiers else []) | ||
| ) + "\n" | ||
|
|
||
| # w: ensures to have no stale entries, overwriting the file, if it exists. | ||
| queue.put((self.output_file, entries, False, "w")) | ||
|
Luxxii marked this conversation as resolved.
|
||
|
|
||
| def _build_proforma(self, prot_graph, inner_nodes, edges): | ||
| """ ProForma string for one peptide path. """ | ||
| # Get all modifications by iterating over the edges | ||
| n_term, c_term, by_residue = [], [], {} | ||
| for edge_id in edges: | ||
| edge = prot_graph.es[edge_id] | ||
| for key, delta, loc_end in self._edge_mods(edge): | ||
| tag = self.mod_names.get(float(delta)) or self._delta_tag(delta) | ||
| if key in ("NPEPTERM", "NPROTERM"): | ||
| n_term.append(tag) | ||
| elif key in ("CPEPTERM", "CPROTERM"): | ||
| c_term.append(tag) | ||
| else: | ||
| by_residue.setdefault(edge.target, []).append((key, tag, loc_end)) | ||
|
|
||
| # Build the final proforma sequence | ||
| parts = [] | ||
| if n_term: | ||
| parts.append("".join("[{}]".format(t) for t in n_term) + "-") | ||
| for ie in inner_nodes: | ||
| aas = prot_graph.vs[ie]["aminoacid"] | ||
| node_mods = by_residue.get(ie, ()) | ||
| if not node_mods: | ||
| parts.append(aas) | ||
| elif len(aas) == 1: | ||
| # Simply append UniMod | ||
| parts.append(aas) | ||
| parts.extend("[{}]".format(t) for _, t, _ in node_mods) | ||
| else: | ||
| # We need to add each PTM on its correct position in the merged | ||
| # node: e.g. merged node "EIN", modification I->+23.123 should | ||
| # yield "EI[+23.123]N" and NOT "EIN[+23.123]". Only THIS node's | ||
| # modifications are placed (pooling across the whole path | ||
| # duplicated same-letter tags), each exactly once, located via | ||
| # the feature's position relative to the node start; a feature | ||
| # without a usable position (e.g. on a variant residue) falls | ||
| # back to the first matching residue letter. | ||
| node_pos = self._get_position_or_isoform_position(prot_graph.vs[ie]) | ||
| tags_at = {} | ||
| for key, tag, loc_end in node_mods: | ||
| idx = None | ||
| if loc_end is not None and isinstance(node_pos, int): | ||
| offset = loc_end - node_pos | ||
| if 0 <= offset < len(aas): | ||
| idx = offset | ||
| if idx is None: | ||
| idx = aas.find(key[-1]) # positional keys end in the residue letter | ||
| if idx == -1: | ||
| idx = 0 # never drop a modification the path carries | ||
| tags_at.setdefault(idx, []).append(tag) | ||
| parts.append("".join( | ||
| aa + "".join("[{}]".format(t) for t in tags_at.get(i, ())) | ||
| for i, aa in enumerate(aas) | ||
| )) | ||
|
|
||
| if c_term: | ||
| parts.append("-" + "".join("[{}]".format(t) for t in c_term)) | ||
| return "".join(parts) | ||
|
|
||
| def _edge_mods(self, edge): | ||
| """ Deduplicated (key, delta, position) modifications of one traversed edge. | ||
|
|
||
| Or-wrapped qualifiers (collapsed parallel edges) are alternatives, not | ||
| a conjunction: only modifications present in EVERY branch are certain | ||
| for this traversal and get exported; branch-specific ones are dropped | ||
| with a one-time warning rather than fabricated onto every path. | ||
| """ | ||
| if "qualifiers" not in edge.attributes(): | ||
| return [] | ||
| mods = [] | ||
| for f in edge["qualifiers"] or []: | ||
| if isinstance(f, Or): | ||
| branches = [self._collect_mods(branch) for branch in f] | ||
| common = set.intersection(*map(set, branches)) if branches else set() | ||
| if any(set(b) - common for b in branches) and not self.warned_or_once: | ||
| self.warned_or_once = True | ||
| print( | ||
| "Warning: modifications differ between collapsed edge alternatives; " | ||
| "only modifications shared by all alternatives are exported. " | ||
| "Re-run with --no_collapsing_edges for exact per-path modifications." | ||
| ) | ||
| mods.extend(m for m in dict.fromkeys(x for b in branches for x in b) if m in common) | ||
| elif getattr(f, "type", None) in ("FIXMOD", "VARMOD"): | ||
| mods.append(self._feature_mod(f)) | ||
| return list(dict.fromkeys(mods)) | ||
|
|
||
| def _collect_mods(self, qualifier): | ||
| out = [] | ||
| for f in qualifier or []: | ||
| if isinstance(f, Or): | ||
| for branch in f: | ||
| out.extend(self._collect_mods(branch)) | ||
| elif getattr(f, "type", None) in ("FIXMOD", "VARMOD"): | ||
| out.append(self._feature_mod(f)) | ||
| return out | ||
|
|
||
| def _feature_mod(self, feature): | ||
| key, _, delta = feature.qualifiers["note"].rpartition(":") | ||
| end = feature.location.end | ||
| return key, delta, None if isinstance(end, UnknownPosition) else int(end) | ||
|
|
||
| def _delta_tag(self, delta): | ||
| """ '79.966' -> '+79.966': explicitly signed, plain decimal notation. | ||
|
|
||
| ProForma's mass grammar has no exponent and no non-finite forms, so | ||
| '1e-05' is rewritten as '+0.00001' and nan/inf are refused. | ||
| """ | ||
| if not isfinite(float(delta)): | ||
| raise ValueError( | ||
| "Cannot express the delta mass '{}' as a ProForma mass tag".format(delta) | ||
| ) | ||
| if "e" in delta.lower(): | ||
| delta = format(Decimal(delta), "f") | ||
| return delta if delta.startswith("-") else "+" + delta | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| ID MERGED_TEST Reviewed; 11 AA. | ||
| AC X9MRG0; | ||
| DT 01-JAN-2020, integrated into UniProtKB/Swiss-Prot. | ||
| DT 01-JAN-2020, sequence version 1. | ||
| DT 01-JAN-2020, entry version 1. | ||
| DE RecName: Full=Merged node modification test; | ||
| OS Homo sapiens (Human). | ||
| OC Eukaryota; Metazoa; Chordata. | ||
| OX NCBI_TaxID=9606; | ||
| SQ SEQUENCE 11 AA; 1200 MW; 0123456789ABCDEF CRC64; | ||
| KEINKACKACK | ||
| // |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| ID TEST_HUMAN Reviewed; 8 AA. | ||
| AC X9TEST; | ||
| DT 01-JAN-2020, integrated into UniProtKB/Swiss-Prot. | ||
| DT 01-JAN-2020, sequence version 1. | ||
| DT 01-JAN-2020, entry version 1. | ||
| DE RecName: Full=Minimal test protein; | ||
| GN Name=TEST; | ||
| OS Homo sapiens (Human). | ||
| OC Eukaryota; Metazoa; Chordata. | ||
| OX NCBI_TaxID=9606; | ||
| FT VARIANT 4 | ||
| FT /note="T -> M (test variant)" | ||
| FT /id="VAR_900001" | ||
| SQ SEQUENCE 8 AA; 920 MW; 0123456789ABCDEF CRC64; | ||
| MKCTMSAK | ||
| // |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.