Skip to content

Commit a4012cc

Browse files
committed
add FFT-based Buccaneer-inspired C-alpha detection with ranked candidate output and validation on 6VXX and 1JYX
1 parent db6f283 commit a4012cc

11 files changed

Lines changed: 958 additions & 2 deletions
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# C-alpha finder
2+
3+
## Contract
4+
5+
The first model-building slice adds `simple_exec prg=detect_calpha`. It locates
6+
oriented C-alpha seed candidates in a cryo-EM map using a built-in expected
7+
backbone density target.
8+
9+
Inputs:
10+
11+
- `vol1`: work map to search.
12+
- `smpd`: sampling distance in Angstroms per voxel.
13+
- `angstep`: coarse SO(3) angular sampling step.
14+
- `npeaks`: maximum number of non-overlapping candidates.
15+
- `thres`: minimum weighted normalized-correlation score.
16+
17+
Outputs:
18+
19+
- `pdbout`: candidate C-alpha coordinates in PDB format, with the score in
20+
the B-factor field.
21+
- A same-stem CSV file containing coordinates, scores, and orientation
22+
matrices. Each matrix maps the canonical target frame into the work
23+
map frame.
24+
- `outvol`: maximum score over orientations at every work-map voxel.
25+
26+
The initial coordinate contract follows the existing SIMPLE atom/map
27+
convention: voxel `(1,1,1)` corresponds to `(0,0,0)` Angstrom. MRC start and
28+
origin metadata are not interpreted by this slice. The FFT-searched work map
29+
must have even dimensions.
30+
31+
## Numerical method
32+
33+
1. Construct an ideal local `N-CA-C` geometry using 1.458 Angstrom `N-CA` and
34+
1.525 Angstrom `CA-C` bonds and a 111.2 degree `N-CA-C` angle.
35+
2. Represent the three backbone atoms with Gaussian density. The C-alpha lobe
36+
is slightly stronger to anchor the reported coordinate at the target center.
37+
Gaussian sigma is `max(0.85 Angstrom, 0.75 * smpd)` to remain sampled on
38+
coarser voxel grids.
39+
3. Sample this expected density inside a 4 Angstrom sphere and use a cosine
40+
taper as the target reliability weight.
41+
4. Generate a coarse SO(3) grid from Fibonacci-sphere axes and uniformly
42+
sampled roll angles.
43+
5. For every orientation, rotate the analytic target and evaluate weighted
44+
normalized cross-correlation at every translation. Three 3-D correlations
45+
provide the weighted data sum, squared-data sum, and target-data sum.
46+
6. Retain the best orientation per voxel and select score maxima with a
47+
2 Angstrom exclusion radius.
48+
49+
The normalized score fits a local scale and offset implicitly and is the first
50+
SIMPLE analogue of Buccaneer's cryo-EM correlation mode. Chain growth,
51+
fragment joining, sequence assignment, side-chain classification, and model
52+
refinement are deliberately outside this slice.
53+
54+
## Review findings
55+
56+
- `image%ccf` supplies the required FFT correlation primitive and preserves
57+
SIMPLE's phase-origin convention. A new `image%ccf_into` variant writes into
58+
an already allocated image so the search reuses FFT plans and buffers rather
59+
than rebuilding them for every orientation.
60+
- An isolated spherical C-alpha Gaussian would make orientation search
61+
redundant and provide poor discrimination. Including the expected adjacent
62+
N and C density preserves a useful oriented Buccaneer-style target without
63+
requiring `vol2` or a reference model.
64+
- The existing atom-centered validation code assumes the same zero-origin
65+
coordinate convention adopted here.
66+
- The algorithm belongs in a new model-building domain. The commander owns
67+
I/O and lifecycle; the domain object owns analytic target construction and
68+
numerical search.
69+
70+
## Validation criteria
71+
72+
- Static Fortran checks report no overlong lines or invalid multiline
73+
diagnostic macros.
74+
- The UI registry contains `detect_calpha` and the execution API routes it to
75+
one commander.
76+
- `simple_test_exec test=detect_calpha` builds repeated analytic backbone
77+
density in memory at an orientation in the search grid, searches it, and
78+
verifies that a reported candidate is within 1.5 Angstrom of a known
79+
C-alpha position.
80+
- User validation remains: compile the affected targets, run the synthetic
81+
test, and evaluate precision/recall on representative SIMPLE maps.
82+
83+
## Built-in molecule benchmark
84+
85+
`simple_test_exec test=detect_calpha_molecules` is an opt-in accuracy benchmark
86+
using SIMPLE's hard-coded 6VXX spike-protein and 1JYX beta-galactosidase
87+
coordinates. The fixtures contain 2,916 and 4,044 protein C-alpha atoms,
88+
respectively. For each structure the benchmark:
89+
90+
1. creates a tightly padded, even-dimension density map at the requested
91+
sampling distance;
92+
2. runs the same analytic-target search used by `simple_exec`;
93+
3. performs greedy one-to-one matching of score-ordered predictions to the
94+
ground truth within 2 Angstrom; and
95+
4. reports truth, prediction, unique-match, missed, false-positive, recall,
96+
and precision counts while retaining the input map, centered model,
97+
candidate PDB/CSV, and score volume.
98+
99+
The default benchmark settings are `smpd=1.3`, `angstep=45`, and `thres=0.25`.
100+
They can be overridden on the test command line. No accuracy threshold is a
101+
test failure yet; the first observed run establishes the baseline. Candidate
102+
selection sorts threshold-eligible scores once before non-maximum suppression,
103+
avoiding the previous full-volume scan for every requested peak.
104+
105+
### 2026-09-10 baseline
106+
107+
The Release `simple_test_exec` benchmark was run with eight threads and the
108+
default settings. Matches are greedy, one-to-one, and within 2 Angstrom.
109+
110+
| Structure | Truth | Top-N matches | Top-N recall/precision | Top-2N matches | Top-2N recall | Top-2N precision |
111+
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
112+
| 6VXX | 2,916 | 1,753 | 0.601 | 2,755 | 0.945 | 0.472 |
113+
| 1JYX | 4,044 | 2,582 | 0.638 | 3,482 | 0.861 | 0.431 |
114+
115+
Both structures reached the `2 * ntruth` output cap. At `thres=0.25`, 103,078
116+
6VXX voxels and 126,413 1JYX voxels were score-eligible, so this threshold does
117+
not determine the reported operating points. Follow-up calibration should use
118+
the score-ranked outputs to choose a precision/recall tradeoff before setting a
119+
production threshold.

src/main/commanders/simple/simple_commanders_atoms.f90

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ module simple_commanders_atoms
44
use simple_nanoparticle_utils
55
use simple_nanoparticle, only: nanoparticle
66
use simple_atoms, only: atoms
7+
use simple_calpha_finder, only: calpha_finder
78
implicit none
89
#include "simple_local_flags.inc"
910

@@ -42,6 +43,11 @@ module simple_commanders_atoms
4243
procedure :: execute => exec_detect_atoms
4344
end type commander_detect_atoms
4445

46+
type, extends(commander_base) :: commander_detect_calpha
47+
contains
48+
procedure :: execute => exec_detect_calpha
49+
end type commander_detect_calpha
50+
4551
type, extends(commander_base) :: commander_map2model_fsc
4652
contains
4753
procedure :: execute => exec_map2model_fsc
@@ -416,6 +422,36 @@ subroutine exec_detect_atoms( self, cline )
416422
call simple_end('**** SIMPLE_DETECT_ATOMS NORMAL STOP ****')
417423
end subroutine exec_detect_atoms
418424

425+
subroutine exec_detect_calpha(self, cline)
426+
class(commander_detect_calpha), intent(inout) :: self
427+
class(cmdline), intent(inout) :: cline
428+
type(parameters) :: params
429+
type(image) :: workvol
430+
type(calpha_finder) :: finder
431+
integer :: work_ldim(3), nsections
432+
433+
if(.not.cline%defined('angstep')) call cline%set('angstep', 45)
434+
if(.not.cline%defined('npeaks')) call cline%set('npeaks', 100)
435+
if(.not.cline%defined('thres')) call cline%set('thres', 0.25)
436+
if(.not.cline%defined('pdbout')) call cline%set('pdbout', 'calpha_candidates.pdb')
437+
if(.not.cline%defined('outvol')) call cline%set('outvol', 'calpha_scores.mrc')
438+
call params%new(cline)
439+
if(params%smpd <= 0.) THROW_HARD('smpd must be positive for detect_calpha')
440+
441+
call find_ldim_nptcls(params%vols(1), work_ldim, nsections)
442+
if(work_ldim(3) <= 1) THROW_HARD('vol1 must contain a 3D search volume')
443+
444+
call workvol%new(work_ldim, params%smpd)
445+
call workvol%read(params%vols(1))
446+
call finder%new(params%smpd, 4.0)
447+
call finder%search(workvol, real(params%angstep), params%npeaks, params%thres, &
448+
params%pdbout, params%outvol)
449+
450+
call finder%kill()
451+
call workvol%kill()
452+
call simple_end('**** SIMPLE_DETECT_CALPHA NORMAL STOP ****')
453+
end subroutine exec_detect_calpha
454+
419455
subroutine exec_map2model_fsc( self, cline )
420456
use simple_commanders_resolest, only: commander_fsc
421457
class(commander_map2model_fsc), intent(inout) :: self

src/main/commanders/test/simple_commanders_test_single.f90

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@ module simple_commanders_test_single
1313
procedure :: execute => exec_test_detect_atoms
1414
end type commander_test_detect_atoms
1515

16+
type, extends(commander_base) :: commander_test_detect_calpha
17+
contains
18+
procedure :: execute => exec_test_detect_calpha
19+
end type commander_test_detect_calpha
20+
21+
type, extends(commander_base) :: commander_test_detect_calpha_molecules
22+
contains
23+
procedure :: execute => exec_test_detect_calpha_molecules
24+
end type commander_test_detect_calpha_molecules
25+
1626
type, extends(commander_base) :: commander_test_simulate_nanoparticle
1727
contains
1828
procedure :: execute => exec_test_simulate_nanoparticle
@@ -93,6 +103,202 @@ subroutine exec_test_detect_atoms( self, cline )
93103
call simple_end('**** SIMPLE_TEST_DETECT_ATOMS NORMAL STOP ****')
94104
end subroutine exec_test_detect_atoms
95105

106+
subroutine exec_test_detect_calpha( self, cline )
107+
use simple_atoms, only: atoms
108+
use simple_calpha_finder, only: calpha_finder
109+
class(commander_test_detect_calpha), intent(inout) :: self
110+
class(cmdline), intent(inout) :: cline
111+
character(len=*), parameter :: PDB_FILE = 'test_calpha_candidates.pdb'
112+
character(len=*), parameter :: CSV_FILE = 'test_calpha_candidates.csv'
113+
character(len=*), parameter :: MRC_FILE = 'test_calpha_scores.mrc'
114+
integer, parameter :: TEST_BOX = 32, NRES = 3
115+
type(image) :: workvol
116+
type(atoms) :: candidates
117+
type(calpha_finder) :: finder
118+
real(kind=c_float), pointer :: density(:,:,:)
119+
real :: centers(3,NRES), atom_sites(3,3), amplitudes(3), rotation(3,3)
120+
real :: xyz(3), site(3), delta(3), distance, closest
121+
integer :: ldim(3), ires, iatom, ix, iy, iz
122+
123+
write(logfhandle,'(A)') '>>> TEST_DETECT_CALPHA:'
124+
ldim = [TEST_BOX,TEST_BOX,TEST_BOX]
125+
centers(:,1) = [8.,8.,8.]
126+
centers(:,2) = [16.,16.,16.]
127+
centers(:,3) = [24.,24.,24.]
128+
atom_sites(:,1) = [0.,0.,0.]
129+
atom_sites(:,2) = [1.458*cos(111.2*PI/180.), 1.458*sin(111.2*PI/180.), 0.]
130+
atom_sites(:,3) = [1.525,0.,0.]
131+
amplitudes = [1.25,1.0,1.0]
132+
rotation(:,1) = [0.,1.,0.]
133+
rotation(:,2) = [-0.5,0.,sqrt(0.75)]
134+
rotation(:,3) = [sqrt(0.75),0.,0.5]
135+
call workvol%new(ldim, 1.0)
136+
call workvol%get_rmat_ptr(density)
137+
density = 0.
138+
do ires = 1, NRES
139+
do iatom = 1, size(atom_sites,2)
140+
site = centers(:,ires) + matmul(rotation, atom_sites(:,iatom))
141+
do iz = 1, TEST_BOX
142+
do iy = 1, TEST_BOX
143+
do ix = 1, TEST_BOX
144+
xyz = real([ix,iy,iz] - 1)
145+
delta = xyz - site
146+
density(ix,iy,iz) = density(ix,iy,iz) + amplitudes(iatom) * &
147+
exp(-0.5 * sum(delta * delta) / 0.85**2)
148+
enddo
149+
enddo
150+
enddo
151+
enddo
152+
enddo
153+
154+
call finder%new(1.0, 4.0)
155+
call finder%search(workvol, 180.0, 10, 0.5, string(PDB_FILE), string(MRC_FILE))
156+
if(nlines(string(PDB_FILE)) == 0) THROW_HARD('TEST_DETECT_CALPHA FAILED: no candidates')
157+
call candidates%new(string(PDB_FILE))
158+
closest = huge(1.)
159+
do iatom = 1, candidates%get_n()
160+
do ires = 1, NRES
161+
distance = sqrt(sum((candidates%get_coord(iatom) - centers(:,ires))**2))
162+
closest = min(closest, distance)
163+
enddo
164+
enddo
165+
if( closest > 1.5 ) THROW_HARD('TEST_DETECT_CALPHA FAILED: peak is displaced')
166+
167+
call candidates%kill()
168+
call finder%kill()
169+
call workvol%kill()
170+
if(file_exists(PDB_FILE)) call del_file(PDB_FILE)
171+
if(file_exists(CSV_FILE)) call del_file(CSV_FILE)
172+
if(file_exists(MRC_FILE)) call del_file(MRC_FILE)
173+
write(logfhandle,'(A,F7.3,A)') '>>> TEST_DETECT_CALPHA: PASS (closest peak ', closest, ' A)'
174+
call simple_end('**** SIMPLE_TEST_DETECT_CALPHA NORMAL STOP ****')
175+
176+
end subroutine exec_test_detect_calpha
177+
178+
subroutine exec_test_detect_calpha_molecules( self, cline )
179+
use simple_atoms, only: atoms
180+
use simple_calpha_finder, only: calpha_finder
181+
use simple_molecule_data, only: molecule_data, betagal_1jyx, sars_cov2_spkgp_6vxx
182+
class(commander_test_detect_calpha_molecules), intent(inout) :: self
183+
class(cmdline), intent(inout) :: cline
184+
type(parameters) :: params
185+
type(molecule_data) :: mol
186+
187+
if( .not.cline%defined('smpd') ) call cline%set('smpd', 1.3)
188+
if( .not.cline%defined('angstep') ) call cline%set('angstep', 45)
189+
if( .not.cline%defined('thres') ) call cline%set('thres', 0.25)
190+
call params%new(cline)
191+
192+
write(logfhandle,'(A)') '>>> C-ALPHA MOLECULE BENCHMARK:'
193+
write(logfhandle,'(A,F6.2,A,I0,A,F6.3)') ' smpd=', params%smpd, &
194+
' A, angstep=', params%angstep, ' degrees, threshold=', params%thres
195+
mol = sars_cov2_spkgp_6vxx()
196+
call evaluate_molecule('6VXX', mol, 2916, params%smpd, params%angstep, params%thres)
197+
mol = betagal_1jyx()
198+
call evaluate_molecule('1JYX', mol, 4044, params%smpd, params%angstep, params%thres)
199+
call simple_end('**** SIMPLE_TEST_DETECT_CALPHA_MOLECULES NORMAL STOP ****')
200+
201+
contains
202+
203+
subroutine evaluate_molecule( label, molecule_data_in, expected_truth, smpd, angstep, threshold )
204+
character(len=*), intent(in) :: label
205+
type(molecule_data), intent(in) :: molecule_data_in
206+
integer, intent(in) :: expected_truth, angstep
207+
real, intent(in) :: smpd, threshold
208+
real, parameter :: MAP_PADDING = 12.0, MATCH_RADIUS = 2.0
209+
type(atoms) :: molecule, candidates
210+
type(calpha_finder) :: finder
211+
type(image) :: workvol
212+
type(string) :: source_file, truth_file, vol_file, candidate_file, score_file
213+
real, allocatable :: truth_xyz(:,:)
214+
logical, allocatable :: truth_matched(:)
215+
real :: span(3), delta(3), best_distance_sq, recall, precision
216+
real :: recall_top_n, precision_top_n
217+
integer :: ldim(3), iatom, itruth, ipred, ntruth, npred, nmatched, best_truth
218+
integer :: top_n_count, nmatched_top_n
219+
220+
source_file = trim(label)//'.pdb'
221+
truth_file = trim(label)//'_calpha_truth.pdb'
222+
vol_file = trim(label)//'_calpha_input.mrc'
223+
candidate_file = trim(label)//'_calpha_candidates.pdb'
224+
score_file = trim(label)//'_calpha_scores.mrc'
225+
span = maxval(molecule_data_in%xyz, dim=1) - minval(molecule_data_in%xyz, dim=1)
226+
ldim = max(round2even((span + 2. * MAP_PADDING) / smpd), 16)
227+
call molecule%pdb2mrc(pdbfile=source_file, volfile=vol_file, smpd=smpd, &
228+
center_pdb=.true., pdb_out=truth_file, vol_dim=ldim, mol=molecule_data_in)
229+
230+
ntruth = 0
231+
do iatom = 1, molecule%get_n()
232+
if(molecule%get_name(iatom) == ' CA ' .and. molecule%get_element(iatom) == 'C ') &
233+
ntruth = ntruth + 1
234+
enddo
235+
if(ntruth /= expected_truth) THROW_HARD('Unexpected built-in C-alpha count')
236+
allocate(truth_xyz(3,ntruth), source=0.)
237+
allocate(truth_matched(ntruth), source=.false.)
238+
itruth = 0
239+
do iatom = 1, molecule%get_n()
240+
if(molecule%get_name(iatom) /= ' CA ' .or. molecule%get_element(iatom) /= 'C ') cycle
241+
itruth = itruth + 1
242+
truth_xyz(:,itruth) = molecule%get_coord(iatom)
243+
enddo
244+
245+
call workvol%new(ldim, smpd)
246+
call workvol%read(vol_file)
247+
call finder%new(smpd, 4.0)
248+
call finder%search(workvol, real(angstep), 2 * ntruth, threshold, candidate_file, score_file)
249+
250+
npred = 0
251+
if(nlines(candidate_file) > 0)then
252+
call candidates%new(candidate_file)
253+
npred = candidates%get_n()
254+
endif
255+
nmatched = 0
256+
nmatched_top_n = 0
257+
top_n_count = min(ntruth, npred)
258+
do ipred = 1, npred
259+
best_truth = 0
260+
best_distance_sq = huge(1.)
261+
do itruth = 1, ntruth
262+
if(truth_matched(itruth)) cycle
263+
delta = candidates%get_coord(ipred) - truth_xyz(:,itruth)
264+
if(sum(delta * delta) < best_distance_sq)then
265+
best_distance_sq = sum(delta * delta)
266+
best_truth = itruth
267+
endif
268+
enddo
269+
if(best_truth > 0 .and. best_distance_sq <= MATCH_RADIUS**2)then
270+
truth_matched(best_truth) = .true.
271+
nmatched = nmatched + 1
272+
endif
273+
if(ipred == top_n_count) nmatched_top_n = nmatched
274+
enddo
275+
recall_top_n = real(nmatched_top_n) / real(ntruth)
276+
precision_top_n = 0.
277+
if(top_n_count > 0) precision_top_n = real(nmatched_top_n) / real(top_n_count)
278+
recall = real(nmatched) / real(ntruth)
279+
precision = 0.
280+
if(npred > 0) precision = real(nmatched) / real(npred)
281+
282+
write(logfhandle,'(A,A)') '>>> ', trim(label)
283+
write(logfhandle,'(A,I0,A,I0)') ' truth=', ntruth, ', candidate cap=', 2 * ntruth
284+
write(logfhandle,'(A,I0,A,I0,A,F7.3,A,F7.3)') ' top-N: predicted=', top_n_count, &
285+
', matched=', nmatched_top_n, ', recall=', recall_top_n, ', precision=', precision_top_n
286+
write(logfhandle,'(A,I0,A,I0,A,I0,A,F7.3,A,F7.3)') ' top-2N: predicted=', npred, &
287+
', matched=', nmatched, ', missed=', ntruth - nmatched, ', recall=', recall, &
288+
', precision=', precision
289+
write(logfhandle,'(A,3(I0,1X))') ' map dimensions=', ldim
290+
write(logfhandle,'(A,A)') ' candidates: ', candidate_file%to_char()
291+
write(logfhandle,'(A,A)') ' score volume: ', score_file%to_char()
292+
293+
if(npred > 0) call candidates%kill()
294+
call finder%kill()
295+
call workvol%kill()
296+
call molecule%kill()
297+
deallocate(truth_xyz, truth_matched)
298+
end subroutine evaluate_molecule
299+
300+
end subroutine exec_test_detect_calpha_molecules
301+
96302
subroutine exec_test_simulate_nanoparticle( self, cline )
97303
use simple_commanders_sim, only: commander_simulate_nanoparticle
98304
class(commander_test_simulate_nanoparticle), intent(inout) :: self

0 commit comments

Comments
 (0)