Skip to content

Add calcSignatureCavities/calcSignatureChannels for ensemble signatur… - #2268

Open
stoneandbone wants to merge 6 commits into
prody:mainfrom
stoneandbone:feature/signature-cavity-channel-ensemble
Open

stoneandbone wants to merge 6 commits into
prody:mainfrom
stoneandbone:feature/signature-cavity-channel-ensemble

Conversation

@stoneandbone

Copy link
Copy Markdown
Contributor

Added calcSignatureCavities/calcSignatureChannels for ensemble signature detection.

It would superpose homologous structures onto a reference via ProDy's own CE-align, auto-discover homologues via DALI when none are given, and compute a consensus signature cavity/channel across the ensemble mapped onto of the reference. The reference is colored by per-residue Shannon entropy, computed either from an auto-built MSA or a user-supplied one (msa_fasta). DALI's discovery filter cutoffs are overridable via dali_filter_kwargs.

  • 18 unit tests in prody/tests/proteins/test_channels.py
  • Validated with curated list of PDB structures and/or automatic DaliSearch for non-receptor tyrosine kinases, fibroblast growth factor receptor kinases, and Lox families during development.

…e detection

Superpose homologous structures onto a reference via ProDy's own CE-align, auto-discover homologues via DALI when none are given, and compute a consensus signature cavity/channel across the ensemble. The reference is colored by per-residue Shannon entropy, computed either from an auto-built MSA or a user-supplied one (msa_fasta). DALI's discovery filter cutoffs are overridable via dali_filter_kwargs.

18 tests in prody/tests/proteins/test_channels.py, all local-fixture-seeded (no network required).

@karolamik13 karolamik13 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I tested basic options. I had a problem with MSA data when directly fetched from Pfam.

I used this code:
from prody import *

ref_structure = '1tqnA'

DALI

dali_rec = searchDali('1tqn', 'A')
while not dali_rec.isSuccess:
dali_rec.fetch()
targets = dali_rec.filter(cutoff_len=0.8, cutoff_rmsd=2.0, cutoff_Z=20)
targets = targets[:10]
if ref_structure not in targets:
targets.insert(0, ref_structure)

Pfam

pfam_ID = searchPfam(ref_structure)
pfam_acc = list(pfam_ID.keys())[0]
print("Pfam:", pfam_acc)
pfam_sth = fetchPfamMSA(pfam_acc, alignment='full', outname='CYP3A4')
msa = parseMSA(pfam_sth)
print("MSA:", msa)
print("Number of sequences:", msa.numSequences())
print("Number of columns:", msa.numResidues())

print("P08684:", [x for x in msa.getLabels() if 'P08684' in x])
print("CP3A4_HUMAN:", [x for x in msa.getLabels() if 'CP3A4_HUMAN' in x])
msa_fasta = writeMSA('CYP3A4_full.fasta', msa)

Signature channels

result = calcSignatureChannels(
structures=targets,
ref_structure=ref_structure,
msa_fasta=msa_fasta,
output_path='ensemble_out_ch3b')

When msa_fasta=msa_fasta is not used, then the code works fine.
Am I doing something wrong?

@karolamik13

Copy link
Copy Markdown
Contributor

@jamesmkrieger maybe you would like to take a look too?

@jamesmkrieger jamesmkrieger left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This feels like it has too much that doesn't need to be there. Lots of reinventing the wheel and nested functions. I made some comments on some of these.

There also seems to be a lot of parsePDB calls as well

It's probably better to build an actual PDBEnsemble, although I understand that this may not work if not all the structures have the same atoms.

Try to simplify and modularise and have as many generalisable pieces as possible rather than just one big piece of code that needs maintaining and understanding and is only usable for a specific purpose

Comment thread prody/proteins/channels.py Outdated

norm_count = float(count) / float(len(pqr_files))

# PDB serials are a fixed 5-column field; wrap past 99999 rather than

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

no, don't have you're own pdb writer for this. Use existing pdb format strings that use hexadecimal or hybrid36

return axes[0] if len(axes) == 1 else axes


def _calcSignatureEnsemble(structures, ref_structure, output_path, voxel_res,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

if this has is specific to channels/cavities/etc then give it a name related to that

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is general to both channels and cavities. They follow a similar analytic process with different sets of functions. So I thought it may worth clumping them together instead of writing the separatly.

Comment thread prody/proteins/channels.py
Comment thread prody/proteins/channels.py Outdated
Comment thread prody/proteins/channels.py Outdated
@jamesmkrieger

Copy link
Copy Markdown
Contributor

I'd also say don't base everything on CE-align as it performs worse than DALI in many cases. If you have dali mappings then let them be usable and let MSA's drive the mappings too. So much of this machinery already exists in buildPDBEnsemble and SignDy already

@jamesmkrieger

Copy link
Copy Markdown
Contributor

I tested basic options. I had a problem with MSA data when directly fetched from Pfam.

I used this code: from prody import *

ref_structure = '1tqnA'

DALI

dali_rec = searchDali('1tqn', 'A') while not dali_rec.isSuccess: dali_rec.fetch() targets = dali_rec.filter(cutoff_len=0.8, cutoff_rmsd=2.0, cutoff_Z=20) targets = targets[:10] if ref_structure not in targets: targets.insert(0, ref_structure)

Pfam

pfam_ID = searchPfam(ref_structure) pfam_acc = list(pfam_ID.keys())[0] print("Pfam:", pfam_acc) pfam_sth = fetchPfamMSA(pfam_acc, alignment='full', outname='CYP3A4') msa = parseMSA(pfam_sth) print("MSA:", msa) print("Number of sequences:", msa.numSequences()) print("Number of columns:", msa.numResidues())

print("P08684:", [x for x in msa.getLabels() if 'P08684' in x]) print("CP3A4_HUMAN:", [x for x in msa.getLabels() if 'CP3A4_HUMAN' in x]) msa_fasta = writeMSA('CYP3A4_full.fasta', msa)

Signature channels

result = calcSignatureChannels( structures=targets, ref_structure=ref_structure, msa_fasta=msa_fasta, output_path='ensemble_out_ch3b')

When msa_fasta=msa_fasta is not used, then the code works fine. Am I doing something wrong?

This looks reasonable. What error do you get with msa_fasta=msa_fasta?

@karolamik13

Copy link
Copy Markdown
Contributor

I tested basic options. I had a problem with MSA data when directly fetched from Pfam.
I used this code: from prody import *
ref_structure = '1tqnA'

DALI

dali_rec = searchDali('1tqn', 'A') while not dali_rec.isSuccess: dali_rec.fetch() targets = dali_rec.filter(cutoff_len=0.8, cutoff_rmsd=2.0, cutoff_Z=20) targets = targets[:10] if ref_structure not in targets: targets.insert(0, ref_structure)

Pfam

pfam_ID = searchPfam(ref_structure) pfam_acc = list(pfam_ID.keys())[0] print("Pfam:", pfam_acc) pfam_sth = fetchPfamMSA(pfam_acc, alignment='full', outname='CYP3A4') msa = parseMSA(pfam_sth) print("MSA:", msa) print("Number of sequences:", msa.numSequences()) print("Number of columns:", msa.numResidues())
print("P08684:", [x for x in msa.getLabels() if 'P08684' in x]) print("CP3A4_HUMAN:", [x for x in msa.getLabels() if 'CP3A4_HUMAN' in x]) msa_fasta = writeMSA('CYP3A4_full.fasta', msa)

Signature channels

result = calcSignatureChannels( structures=targets, ref_structure=ref_structure, msa_fasta=msa_fasta, output_path='ensemble_out_ch3b')
When msa_fasta=msa_fasta is not used, then the code works fine. Am I doing something wrong?

This looks reasonable. What error do you get with msa_fasta=msa_fasta?

This is the error:
4832 best_aln = (aligned_ref, aligned_row)
4834 if best_idx is None:
-> 4835 raise ValueError(
4836 "msa_fasta: no row in the supplied MSA could be aligned "
4837 "to the reference structure {0!r}. Review or supply an "
4838 "MSA that includes or aligns to the reference structure."
4839 .format(ref_label))
4841 mismatches = len(ref_seq) - best_matches
4842 mismatch_pct = 100.0 * mismatches / max(len(ref_seq), 1)

ValueError: msa_fasta: no row in the supplied MSA could be aligned to the reference structure '1tqnA'. Review or supply an MSA that includes or aligns to the reference structure.

@stoneandbone

stoneandbone commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor Author

I know what may be the issue. Let me incorporate @jamesmkrieger comments as well as a fix for @karolamik13 .

My concern with buildPDBEnsemble is that it works best with only calphas while the calculation for detection of cavities/channels perform best with full atomic. The resulted MSA from full atomic PDB ensemble (from my prior experience) will be repeated single-letter codes (e.g. a run of Leucine side-chain atoms all reading as L) for every single position. I will work to see if I can get around with this. It may be also nice to return a PDBensemble for downstream SignDy workflow anyhow :).

@jamesmkrieger

Copy link
Copy Markdown
Contributor

It would be nice to have an argument on PDBEnsemble getMSA that returns based on residues rather than atoms.

I suppose the bigger issue here is that unless all your structures have the same sequence and have all been sufficiently well modelled and fixed then you can’t actually build a good ensemble.

We had a similar problem for signature interactions and managed to find a way around it though, so maybe you can reuse and extend the code from there

@jamesmkrieger

Copy link
Copy Markdown
Contributor

Something else that might help is my extension of clustenm to take multiple structures as input and apply pdbfixer to all of them. I think it’s still limited to structures that all have the same residues but that could potentially be generalised further too

- calcChannelSurfaceOverlaps now writes overlap voxels through AtomGroup
  + writePDB(hybrid36=True) instead of a hand-rolled PDB line writer.
- _calcSignatureEnsemble batches all per-structure parsePDB calls into one
  parsePDB(list_of_labels) call instead of one call per structure.
- msa_fasta now goes through prody's own parseMSA/calcShannonEntropy
  instead of a manual FASTA parse and a hand-built MSA array.
- Document threshold, distA, voxel_res, output_path, structures,
  ref_structure and kwargs on _calcSignatureEnsemble, which only
  documented kind/detect_records_fn/overlap_fn before.

Also fixes the Pfam-MSA anchoring bug karolamik13 reported: a Pfam 'full'
alignment labels its rows by UniProt idcode/range (e.g.
'CP3A4_HUMAN/38-493'), not by accession, so accession-only matching never
found the reference's row. Try idcode before accession, matching how
refineMSA already resolves DBREF labels. Also widen the compiled-parser
fallback to catch ValueError, not just OSError/IOError.
writeFasta() (prody/sequence/msaio.c) measured the MSA array's total
character buffer with strlen() and stored the result in an int. The
buffer is numseq*lenseq raw bytes with no null terminators between
rows, so strlen() was never the right way to measure it, and once that
product exceeds INT_MAX (as with a real Pfam 'full' family alignment,
e.g. PF00067 at ~362k rows x ~6k columns = ~2.19 billion characters)
the truncated/garbage result left every count < lenmsa check false, so
writeMSA silently produced a FASTA file of headers followed by empty
sequences, with no error raised.

This was the actual root cause behind a bug report on PR prody#2268: a Pfam
full-alignment FASTA produced by fetchPfamMSA + writeMSA had zero-length
sequences for every row (including the reference's own), so
calcSignatureChannels/calcSignatureCavities could never align a row to
the reference. Use numseq*lenseq directly as the buffer length instead
of strlen(), and widen the index/length variables to long.
parsePDB already resolves and restricts each 'PDBID'+'ChainID' label
structures are now passed straight through to the batch parsePDB() call, and
per-structure Chain objects are taken directly from each already
chain-restricted AtomGroup's hierview instead of re-indexing by the
chain letter. Also tightens _getMappingResnums's docstring.
stoneandbone added a commit to stoneandbone/cavitracer-signature that referenced this pull request Sep 24, 2026
writeFasta() (prody/sequence/msaio.c) measured the MSA array's total
character buffer with strlen() and stored the result in an int. The
buffer is numseq*lenseq raw bytes with no null terminators between
rows, so strlen() was never the right way to measure it, and once that
product exceeds INT_MAX (as with a real Pfam 'full' family alignment,
e.g. PF00067 at ~362k rows x ~6k columns = ~2.19 billion characters)
the truncated/garbage result left every count < lenmsa check false, so
writeMSA silently produced a FASTA file of headers followed by empty
sequences, with no error raised.

This was the actual root cause behind a bug report on PR prody#2268: a Pfam
full-alignment FASTA produced by fetchPfamMSA + writeMSA had zero-length
sequences for every row (including the reference's own), so
calcSignatureChannels/calcSignatureCavities could never align a row to
the reference. Use numseq*lenseq directly as the buffer length instead
of strlen(), and widen the index/length variables to long.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ity-channel-ensemble

# Conflicts:
#	prody/proteins/channels.py
@stoneandbone

Copy link
Copy Markdown
Contributor Author

Dear @karolamik13

I tracked this down to two separate bugs, both now fixed on this branch:

Reference row never matched (prody/proteins/channels.py) — the reference structure's row in msa_fasta was looked up by UniProt accession only (e.g. P08684). And the Pfam 'full' Stockholm alignment labels it differently by UniProt idcode/mnemonic + range instead (e.g. CP3A4_HUMAN/38-493)

Another issue about the MSA: corrupted FASTA output (prody/sequence/msaio.c) — writeMSA's compiled FASTA writer measured its output buffer with strlen() and stored the result in a 32-bit int. For a 'full' Pfam alignment like PF00067 (362,663 rows × 6,052 columns ≈ 2.19 billion characters, just over INT_MAX), therefore writeMSA wrote out headers followed by empty sequences.

I reran your exact script against 1tqnA/CYP3A4 and it completes successfully now.

@karolamik13

Copy link
Copy Markdown
Contributor

Dear @karolamik13

I tracked this down to two separate bugs, both now fixed on this branch:

Reference row never matched (prody/proteins/channels.py) — the reference structure's row in msa_fasta was looked up by UniProt accession only (e.g. P08684). And the Pfam 'full' Stockholm alignment labels it differently by UniProt idcode/mnemonic + range instead (e.g. CP3A4_HUMAN/38-493)

Another issue about the MSA: corrupted FASTA output (prody/sequence/msaio.c) — writeMSA's compiled FASTA writer measured its output buffer with strlen() and stored the result in a 32-bit int. For a 'full' Pfam alignment like PF00067 (362,663 rows × 6,052 columns ≈ 2.19 billion characters, just over INT_MAX), therefore writeMSA wrote out headers followed by empty sequences.

I reran your exact script against 1tqnA/CYP3A4 and it completes successfully now.

Thank you. I will test it today or during the weekend.

@karolamik13 karolamik13 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I tested those functions, and now I am able to analyze the structure in a correct way.

I would have three suggestions:
(1) Could you include a parameter with selection in your functions? I am analyzing 1TQN, which contains a heme group, but some people might want to analyze "clean" protein or just remove some crystalographic molecules/inhibitors, etc., from their structures. The current CaviTracer code includes everything that is not water, so if you have inhibitors in PDBs or some other small molecules, peptides, etc., in your structures, you will not be able to properly analyze the whole family.

(2) Also, it is advisable to save in a file information about a list of structures, detection parameters, threshold, resolution, MSA source, selected reference sequence, and the status of each structure: aligned, detection successful, missing channels, or error.

(3) Except for the ref.pdb file, where you have conservation in the beta column, I would suggest saving a PNG file and raw data with the results from Shannon Entropy.

Apart from that, I am happy with the code.

@jamesmkrieger Do you have any suggestions?

@jamesmkrieger

Copy link
Copy Markdown
Contributor

I’d say things like saving pngs is for a script running the code, not the core code itself.

Otherwise it also makes sense. I can have a look in more detail late next week when the new changes come in. I am very busy right now

@stoneandbone
stoneandbone force-pushed the feature/signature-cavity-channel-ensemble branch from c6760e2 to 48adfb7 Compare October 24, 2026 16:36
… ensembles

Address the remaining review requests on PR prody#2268.

selection: calcSignatureCavities and calcSignatureChannels take a ProDy
selection string (default 'protein', the previous behaviour). The atoms it
picks are written to the aligned and signature PDB files and are what
detection sees, so a cofactor such as a heme can be kept with
'protein or resname HEM', or an inhibitor dropped. CE-align, the
superposition fit, residue mapping, the MSA and the entropy stay on protein
atoms only; kept hetero atoms move with the protein but does not affect the superposition.

Entropy table: the per-residue Shannon entropy is written to
signature_<kind>_entropy.csv (resnum, resname, entropy; NaN where the MSA has
no coverage) and returned as result['entropy'].

Run summary: signature_<kind>_run_summary.json records the reference label
and sequence, the MSA source, path and anchor row, the run parameters, and one
entry per input structure with its alignment metrics and detection status
(ok, none_found, error or skipped) and message. A structure that cannot be
aligned or fails detection is now reported instead of only logged.

No images are written. Plotting the entropy is left to the calling script,
as suggested in James' comment.
@stoneandbone

Copy link
Copy Markdown
Contributor Author

Thanks both for the reviews.
Dear @karolamik13, Your three new suggestions:

  1. selection (default 'protein', same as before), for example selection='protein or resname HEM'. Alignment, the fit, the MSA and the entropy stay protein-only.
  2. signature_<kind>_run_summary.json lists the reference, the MSA source and anchor row, the parameters, and each structure’s alignment metrics and detection status (ok, none_found, error, skipped).
  3. Entropy is saved as signature_<kind>_entropy.csv and returned as result['entropy']. Note that, there is no PNG in the library, per James’s comment. A script can plot it with ax.plot(result['entropy']['resnum'], result['entropy']['entropy']).

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.

3 participants