Skip to content

fix dihedral_backbone() returning garbage angles across chain breaks - #933

Merged
padix-key merged 3 commits into
biotite-dev:mainfrom
steps-re:fix/dihedral-backbone-chain-breaks
Aug 11, 2026
Merged

fix dihedral_backbone() returning garbage angles across chain breaks#933
padix-key merged 3 commits into
biotite-dev:mainfrom
steps-re:fix/dihedral-backbone-chain-breaks

Conversation

@steps-re

Copy link
Copy Markdown
Contributor

fixes #744

dihedral_backbone() builds its phi/psi/omega coordinate arrays purely by positional adjacency between residues in the AtomArray. it never checks whether two consecutive residues are actually bonded. so if a structure has a missing loop, or you concatenate two unrelated fragments into one AtomArray, the dihedrals spanning that gap come out as finite garbage instead of NaN, exactly like padix-key called out in the issue thread.

went with the approach discussed in the thread: check the C-N distance between consecutive residues directly on the coordinates dihedral_backbone() already extracted, rather than calling check_backbone_continuity() separately (which would mean re-filtering the whole array again). used the same 1.2-1.8 A tolerance that check_backbone_continuity() / filter_linear_bond_continuity() already use elsewhere in the codebase, so the threshold is consistent across the library. where the C(i)-N(i+1) distance falls outside that range, psi[i], omega[i], and phi[i+1] get set to NaN since those are the three angles that straddle the junction between residue i and i+1.

while testing this i found nucleotide_dihedral_backbone() has the exact same bug for the O3'-P bond between residues, so I fixed that too with the same approach (epsilon[i], zeta[i], alpha[i+1] get NaN'd on a broken O3'(i)-P(i+1) junction). that one isn't filed anywhere, just the same root cause turning up twice.

verification:

  • added test_dihedral_backbone_chain_break and test_nucleotide_dihedral_backbone_chain_break to tests/structure/test_geometry.py. both build a synthetic structure from two real fragments of 1l2y / 4p5j, renumber the second fragment's res_id to be perfectly continuous with the first (so the test can't accidentally pass just because of a res_id jump), then translate it 1000 A away so the two fragments are not bonded. confirmed both tests fail on current main (angles come back as finite numbers) and pass with this fix.
  • ran the full tests/structure/ suite (4176 passed, 13 skipped, no failures) to check for regressions, plus the existing multi-model, MDTraj-consistency, and barnaba-consistency dihedral tests specifically.
  • ruff check + ruff format clean on the touched files.

disclosure: this fix (code + tests) was put together with AI assistance (Claude), then reviewed and verified by me before opening the PR.

dihedral_backbone() and nucleotide_dihedral_backbone() built their phi/psi/omega
(and alpha/beta/gamma/delta/epsilon/zeta) coordinate arrays purely from positional
adjacency between residues, with no check that consecutive residues were actually
bonded. When two residues were adjacent in the AtomArray but not chemically
connected (missing loop, concatenated chains), the dihedrals spanning that gap
came out as finite garbage instead of NaN.

Add a bond-distance check (C-N for the peptide backbone, O3'-P for the
phosphate backbone) using the same 1.2-1.8 A tolerance already used by
check_backbone_continuity()/filter_linear_bond_continuity(), and NaN out the
angles that span a junction where the distance falls outside that range.
Applied to both dihedral_backbone() (fixes biotite-dev#744) and the identical defect in
nucleotide_dihedral_backbone(), which was not filed but has the same root cause.

Fixes biotite-dev#744.

@padix-key padix-key left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, sorry for the delayed response, I was busy the last few days.
This looks quite good, I would only clean up the tests a bit.

Comment thread tests/structure/test_geometry.py Outdated


@pytest.mark.parametrize("multi_model", [False, True])
def test_dihedral_backbone_chain_break(multi_model):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The setup of both test function is basically the same, so could you maybe combine them into one function parametrized on ["dihedral_backbone", "nucleotide_dihedral_backbone"]?

Comment thread tests/structure/test_geometry.py Outdated
Comment on lines +134 to +139
assert np.all(np.isfinite(phi[..., 1]))
assert np.all(np.isfinite(psi[..., 0]))
assert np.all(np.isfinite(omega[..., 0]))
assert np.all(np.isfinite(psi[..., 2]))
assert np.all(np.isfinite(omega[..., 2]))
assert np.all(np.isfinite(phi[..., 3]))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The order is a bit odd here 😉. I'd say we either sort by angle name or by index.

The two chain break tests differed only in the input structure and the
names of the returned angles, so parametrize a single test on
dihedral_backbone/nucleotide_dihedral_backbone instead. Look the returned
angles up by name so the assertions can be grouped per angle in ascending
residue index order.
@steps-re

steps-re commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

both done. it's one test now, parametrized on the two functions, with the pdb id and the angle names picked per case. the returned angles go into a dict keyed by name, so the asserts group per angle in ascending index order instead of hopping around.

full test_geometry.py green locally, 31 passed.

@padix-key padix-key left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot 👍

@codspeed-hq

codspeed-hq Bot commented Aug 7, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 11.59%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 4 improved benchmarks
✅ 102 untouched benchmarks
⏩ 14 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
benchmark_match_kmer_selection[KmerTable-None] 279.3 µs 243.9 µs +14.51%
benchmark_match[KmerTable-None] 328.2 µs 295 µs +11.25%
benchmark_match_kmer_selection[BucketKmerTable(10000)-11*11*1*1***111] 353.2 µs 320 µs +10.38%
benchmark_match_kmer_selection[BucketKmerTable(10000)-None] 353.7 µs 320.7 µs +10.28%

Tip

Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.


Comparing steps-re:fix/dihedral-backbone-chain-breaks (74200bb) with main (d9e7f49)

Open in CodSpeed

Footnotes

  1. 14 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@steps-re

steps-re commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

The CodSpeed report here looks like runner noise rather than a real regression.

The three flagged benchmarks are all benchmark_cell_list_compute_contacts. This PR only touches geometry.py and test_geometry.py, and nothing in benchmarks/ calls dihedral_backbone or nucleotide_dihedral_backbone, so no benchmark in the suite exercises the changed code.

The same pattern shows up elsewhere. #935 reports the same cell_list_compute_contacts[MASK] benchmark going 16.2 ms to 18.6 ms off the identical base, and #929 (setting a Vina seed) reports a 16% improvement in the k-mer benchmarks. Both carry the same "Different runtime environments detected" warning this one does.

For what it's worth, the added check is one np.linalg.norm over the residue count. Locally it costs about 11 us on a 1294-residue structure where dihedral_backbone itself takes 938 us, so roughly 1%.

Happy to acknowledge it on CodSpeed instead if you'd rather clear the check that way.

@padix-key

Copy link
Copy Markdown
Member

The CodSpeed report here looks like runner noise rather than a real regression

Indeed, specifically these tests have a noisy run time, where I did not find the cause yet. I acknowledged this regression.

@padix-key

Copy link
Copy Markdown
Member

pyright still complains in the CI. Could you have another look?

@steps-re
steps-re force-pushed the fix/dihedral-backbone-chain-breaks branch from b28c764 to 74200bb Compare August 10, 2026 12:39
@steps-re

Copy link
Copy Markdown
Contributor Author

Good catch. The remaining pyright failures were six reportIndexIssue errors (geometry.py lines 721-723 and 979-981), all "__getitem__" method not defined on type "floating[Any]".

Root cause is the same phantom branch we already annotate on the return statements: dihedral() is typed NDArray1 | NDArray2 | np.floating, so phi/psi/omg (and alpha/epsilon/zeta) still carry the scalar np.floating member. The new in-place NaN masking indexes those results, and pyright rejects the subscript against the scalar branch, even though it is unreachable here (the inputs are always at least 2D).

I narrowed the indexed results with np.asarray(...), which drops the scalar branch and leaves a plain ndarray. It is a runtime no-op on arrays (no copy), so the numbers are unchanged, and it keeps this as a real narrowing rather than another suppression. This mirrors how the identical displacement() union is handled before indexing in rings.py. The existing # pyright: ignore[reportReturnType] on the two return lines still applies, since the shaped NDArray1/NDArray2 aliases don't match a bare ndarray.

Verified locally with the pinned pyright == 1.1.409: zero reportIndexIssue on geometry.py, and pytest tests/structure/test_geometry.py -k dihedral stays green (14 passed), including the chain-break cases.

@padix-key
padix-key merged commit af1c4bb into biotite-dev:main Aug 11, 2026
23 of 24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dihedral Backbone Does Not Account for Gaps, Returns Incorrect Values

2 participants