Fix residues silently dropped or mismatched by residue identity handling - #115
Open
NDoering99 wants to merge 2 commits into
Open
Fix residues silently dropped or mismatched by residue identity handling#115NDoering99 wants to merge 2 commits into
NDoering99 wants to merge 2 commits into
Conversation
Two independent causes of residues being lost or swapped without any warning. 1. DihedralAngles located residues positionally, as `traj.residues[res_id - first_res_num]`. That is only valid when residue numbers form a contiguous, unique run. With a numbering gap (a missing loop) or a repeated number, every residue after the discontinuity reads its dihedral from the wrong residue, and the tail of the chain is never processed at all. Real case: a CHARMM-GUI system with insertion codes 205A-205O. The PSF has no insertion-code field, so all 16 residues came through as resid 205. MDPath then processed 296 of 311 residues and misassigned everything past 205 -- silently, with a full progress bar and complete-looking output. Residues are now looked up through a resid -> position map built from the topology, the parallel loop iterates the resids that actually exist rather than range(first, last + 1), and duplicate numbers raise instead of corrupting. Non-protein residues may still reuse the protein's numbering; only amino acids have to be unique, since only they become graph nodes. 2. Bio.PDB.Polypeptide.is_aa() only knows PDB chemical-component names, so it rejects the protonation-state names MD force fields write: CHARMM HSD/HSP, AMBER HID/HIE/HIP, CYX, ASH and friends. Residues it rejects were dropped from the graph, from the CA coordinates and from generic numbering. It also accepts HSE, but as homoserine rather than epsilon-protonated histidine. Adds is_amino_acid(), which normalises force field aliases before the is_aa() check, and uses it at all four call sites. AAMAPPING lookups are normalised the same way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
res_num_from_pdb() seeded first_res_num with float("inf") and returned
int(first_res_num) unconditionally, so a topology in which nothing is
recognised as an amino acid failed with
OverflowError: cannot convert float infinity to integer
which says nothing about the cause. Reached in practice by passing a
coordinate-only file to -top: MDAnalysis has no residue names to read from a
DCD or XTC, so it defaults every residue to UNK, mdpath writes that into
first_frame.pdb, and no residue is recognised as protein.
Raises a ValueError naming the file and the likely cause instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Three bugs in how residues are identified. The first two silently drop or swap residues; the third turns a common user error into an uninterpretable traceback.
1. Residues were located by index arithmetic
DihedralAngleslooked residues up positionally:That is only correct when residue numbers form a contiguous, unique run starting at
first_res_num. Two common situations break it:The failure is silent. The progress bar fills, every output file is written, and the numbers look plausible.
How it showed up
A CHARMM-GUI system with insertion codes
205A–205O. The PSF format has no insertion-code field, so all 16 residues arrive as resid205:MDPath processed 296 of 311 residues, mapped everything past 205 onto a residue 15 positions earlier, and never touched the last 15 residues. Nothing in the output indicated a problem.
Fix
resid -> positionmap built from the topology.range(first_res_num, last_res_num + 1), so a gap no longer queues residues that aren't there.ValueErrornaming the offending resids and the usual cause, instead of producing corrupt results.Only amino acids have to be unique. Ligands, ions and waters routinely reuse the protein's numbering and are not graph nodes, so they are excluded from the uniqueness check —
mdpath/tests/test_topology.pdbitself has anUNKin chain B numbered 1, alongsideILE 1in chain A.2. Force field residue names were rejected as non-protein
All four residue filters used
Bio.PDB.Polypeptide.is_aa(), which only knows PDB chemical-component names. It rejects the protonation-state names MD force fields write:is_aa()HSDFalseHSPFalseHID,HIEFalseCYXFalseASHFalseHSETrueRejected residues were dropped from the graph skeleton, from
residue_CA_coordinates, and fromres_num_from_pdb. In the system above this removed 4 histidines from the network — they could not appear in any pathway, with no warning.AAMAPPINGinvisualization.pyhad the same gap, so those residues were also skipped when assigning generic numbers.Fix
Adds
is_amino_acid()inmdpath/src/structure.py, which normalises force field aliases throughFORCEFIELD_RESNAME_ALIASESbefore theis_aa()check, and uses it at all four call sites.AAMAPPINGlookups are normalised the same way.3. A topology with no protein failed with
OverflowErrorres_num_from_pdb()seededfirst_res_numwithfloat("inf")and returnedint(first_res_num)unconditionally, so a topology in which nothing was recognised as an amino acid failed with:which says nothing about the cause. This is easy to reach by accident: passing a coordinate-only file to
-top. MDAnalysis has no residue names to read from a DCD or XTC, so it defaults every residue toUNK,mdpath.pywrites that intofirst_frame.pdb, and nothing downstream is recognised as protein.Now raises a
ValueErrornaming the file and the likely cause.Tests
Seven new tests in
mdpath/tests/test_structure.pycovering force field resname acceptance, non-protein rejection, the duplicate-resid guard, gap-tolerant lookup, non-protein residues reusing protein numbering, and the empty-topology error.The
mock_trajfixture used adictfortraj.residues, which never modelled MDAnalysis' positionally-indexedResidueGroup— with the old codetraj.residues[1 - 1]raisedKeyErrorand was swallowed by theexceptclause, so the lookup was never actually exercised. Replaced with something indexable.Full suite: 107 passed, 1 failed. The failure is pre-existing on
master— a last-ULP float comparison intest_mutual_information.py::test_hist_range_none_is_exact_no_op(0.6667438140756162 != 0.6667438140756161), unrelated to this change.Compatibility
No API changes.
DihedralAngles.__init__keeps its signature;num_residuesis still accepted and stored. Topologies that were already contiguous and unique behave exactly as before — the only behaviour change for them is that force field residue names are no longer discarded.🤖 Generated with Claude Code