Audit follow-ups: bug fix, doc refresh, exception taxonomy, test hardening

Bug fix:
- PrincipalMomentsDescriptor.clampNonNegative now also clamps NaN. The
  v<0 check was false for NaN, so a NaN eigenvalue (possible if a future
  code path bypasses GridGenerator.isFiniteBox) would have propagated
  to the CSV output.

Doc refresh:
- breaking-changes.md: 2.6 entry for the multi-column descriptor
  migration + the -vis_pocket_grid / pocket_grid_vis_* renames.
- export-pocket-descriptors.md: step 4 rewrites a self-contradicting
  rationale — adding to the default list IS a breaking change for
  index-based parsers; recommends parse-by-name + breaking-changes.md
  note for future additions.
- export-pocket-grid.md: added "Adding a new per-grid-point descriptor"
  recipe (parallel to the per-pocket one); unified √3/2 precision to
  0.866 across docs and Params.groovy.
- README.md: added an "Opt-in tabular exports" subsection mentioning
  -export_pocket_descriptors, -export_pocket_grid, -vis_pocket_grid.
- testsets.sh "Full descriptor menu" now lists all seven shipped
  descriptors (was six).

Exception taxonomy:
- PocketDescriptorsRows.groovy and PocketGridBuilder.java now throw
  PrankException (was IllegalArgumentException) for user-facing config
  errors, matching the rest of the codebase.

Registry hardening:
- Both PocketDescriptorRegistry and PocketGridPointDescriptorRegistry
  now assert columnNames.size() == columnTypes.size() in register().
  A future descriptor with mismatched lists fails fast at class-load.

Quality fixes:
- PocketGridRows.getColumn uses BASE_COLS-1 instead of literal 3 for
  the pocket column. Removed dead 2-arg PocketGridRows constructor
  (only 3 test sites used it; now inlined).
- PocketGridPointContext gets a compact-constructor validator that
  rejects negative pointIndex/pocketRank, limiting blast radius of an
  int-arg swap.

Test hardening:
- VolsiteSmoothGridPointDescriptorTest + VolsiteGridPointDescriptorTest
  now pin sigma/radius in @BeforeEach AND restore in @AfterEach, so
  the Params singleton is clean for subsequent test classes.
- New tests: HIS ND1 double-flag (single atom setting donor+acceptor),
  PrincipalMoments at cardinality=2, PrincipalMoments two coincident
  points, GridGenerator NaN-box throw, PocketDescriptorRegistry
  register/unregister round-trip, MorphologicalCloser maxIters=1.
- Renamed respectsMaxIters → maxItersZeroIsNoOp (the test only covered
  the maxIters=0 case despite the general name); added maxIters=1
  companion that verifies one iteration of fill actually runs.
- Extracted RendererTestFixtures.tinyGrid (was byte-identical in both
  renderer test files); unified the volsite atomAt signatures so the
  parameter order can't get swapped between the two volsite tests.
This commit is contained in:
rdk
2026-05-19 15:36:12 +02:00
parent cb6f7f75eb
commit 6fad858bc6
22 changed files with 317 additions and 67 deletions

View File

@@ -121,6 +121,10 @@ prank predict -c alphafold test.ds # use alphafold config and model (confi
* **SAS points data**: coordinates and ligandability scores for solvent-accessible surface (SAS) points are saved in `visualizations/data/{protein_file}_points.pdb.gz`. Here: * **SAS points data**: coordinates and ligandability scores for solvent-accessible surface (SAS) points are saved in `visualizations/data/{protein_file}_points.pdb.gz`. Here:
* Residue sequence number (position 23-26) represents the pocket rank (0 indicates no pocket). * Residue sequence number (position 23-26) represents the pocket rank (0 indicates no pocket).
* B-factor column contains predicted ligandability score. * B-factor column contains predicted ligandability score.
* **Opt-in tabular exports** (off by default):
* `-export_pocket_descriptors 1` → per-pocket geometric descriptors (volume, sphericity, radius of gyration, residue/atom counts, principal moments) in CSV/Arrow/Parquet. See [`documentation/export-pocket-descriptors.md`](documentation/export-pocket-descriptors.md).
* `-export_pocket_grid 1` → per-pocket 3D grid of points covering the empty space around predicted pockets, with optional per-grid-point descriptors (e.g. `volsite` pharmacophore indicators). See [`documentation/export-pocket-grid.md`](documentation/export-pocket-grid.md).
* `-vis_pocket_grid 1` → additional PyMOL/ChimeraX overlays of the grid for visualization.
### Configuration ### Configuration

View File

@@ -27,6 +27,18 @@ All changes of that type should be rare and should be all listed here.
* For additional internal evaluation-criterion fixes during the 2.6 dev cycle see * For additional internal evaluation-criterion fixes during the 2.6 dev cycle see
[`documentation/dev/evaluation-metric-fixes-2.6.md`](documentation/dev/evaluation-metric-fixes-2.6.md). [`documentation/dev/evaluation-metric-fixes-2.6.md`](documentation/dev/evaluation-metric-fixes-2.6.md).
###### Pocket-descriptors export (opt-in feature)
* Per-pocket descriptors `-export_pocket_descriptors` underwent a multi-column interface migration. The shipped default
list now contains **seven** descriptors (previously six), adds `principal_moments` (a 3-column descriptor emitting
`principal_moments.lambda1/lambda2/lambda3`), and reorders the existing six so `num_*` come first.
Scripts parsing the descriptors CSV/Arrow/Parquet output by **column name** are unaffected;
scripts parsing by **column index** need updating. See [`documentation/export-pocket-descriptors.md`](documentation/export-pocket-descriptors.md).
* New opt-in `-vis_pocket_grid` (renamed from `-export_pocket_grid_pml`) emits both PyMOL `.pml` and ChimeraX `.cxc`
overlay scripts. The two viz-tuning knobs were renamed for namespace consistency:
`pocket_grid_vis_volume_radius``vis_pocket_grid_volume_radius` and
`pocket_grid_vis_gaussian_iso``vis_pocket_grid_gaussian_iso`. Old names hard-fail at startup with no aliases.
### 2.5.1 ### 2.5.1
none none

View File

@@ -124,10 +124,15 @@ Implementations live under
4. **To include it in the default output**, also add the name to the 4. **To include it in the default output**, also add the name to the
`pocket_descriptors` default list in `Params.groovy`. The default is `pocket_descriptors` default list in `Params.groovy`. The default is
declared explicitly rather than derived from `Registry.knownNames()` declared explicitly (rather than derived from `Registry.knownNames()`)
so that adding a descriptor doesn't silently change every existing so each addition to the default schema is a conscious choice — but
user's output schema — that's intentional; skip step 4 if the new adding to the default IS a user-visible breaking change for anyone
descriptor is opt-in only. parsing the output by column index. Two recommendations:
- Parse the descriptors file by column **name**, not by column index.
- When you add a descriptor to the default list, note it in
[`breaking-changes.md`](../breaking-changes.md).
Skip step 4 if the new descriptor is opt-in only.
INT columns return their value as a `double` that the writer downcasts at INT columns return their value as a `double` that the writer downcasts at
output time, matching the existing `TableData` convention. Implementations output time, matching the existing `TableData` convention. Implementations

View File

@@ -96,6 +96,36 @@ Descriptor params:
| `pocket_grid_volsite_radius` | `4.0` Å | Cutoff radius for the `volsite` indicator. Standard VolSite pharmacophore search distance. | | `pocket_grid_volsite_radius` | `4.0` Å | Cutoff radius for the `volsite` indicator. Standard VolSite pharmacophore search distance. |
| `pocket_grid_volsite_sigma` | `2.0` Å | Gaussian σ for `volsite_smooth`. Kernel truncated at `4σ`. | | `pocket_grid_volsite_sigma` | `2.0` Å | Gaussian σ for `volsite_smooth`. Kernel truncated at `4σ`. |
### Adding a new per-grid-point descriptor
Implementations live under
`src/main/groovy/cz/siret/prank/program/routines/predict/output/grid/descriptors/`.
1. Implement the `PocketGridPointDescriptor` interface (`name`, `columnNames`,
`columnTypes`, `compute`). The shape mirrors the per-pocket
`PocketDescriptor` (see
[`export-pocket-descriptors.md`](export-pocket-descriptors.md#adding-a-new-descriptor)
for the full recipe), with two differences:
- **No `needsGrid()` method.** Every grid-point descriptor needs the grid
by definition — the grid is the substrate that defines what a grid point
is. The orchestrator always builds the grid when any grid-point
descriptor is selected.
- **No `AbstractScalarPocketDescriptor`-style adapter.** Both shipped
descriptors (`volsite`, `volsite_smooth`) are multi-column; if you add
the first scalar grid-point descriptor and it's the only one, implement
`columnNames()` as a single-element list and rely on the bare `name()`
output convention. If a second arrives, factor out an adapter then.
2. Register in `PocketGridPointDescriptorRegistry`'s static initializer. The
registry rejects descriptors with duplicate `columnNames` at registration
time.
3. Users opt in by name: `-pocket_grid_point_descriptors "volsite,my_new_descriptor"`.
4. Default-empty is deliberate — adding a per-grid-point descriptor to the
default would expand the row count by columns × rows, which is materially
non-free (see cost rationale above). New descriptors should ship opt-in.
## Parameters ## Parameters
| Parameter | Default | Notes | | Parameter | Default | Notes |
@@ -151,7 +181,7 @@ The volume surface is rendered as a vdW-style surface (solvent probe = 0)
of radius `vis_pocket_grid_volume_radius` (Å, auto-scaled to of radius `vis_pocket_grid_volume_radius` (Å, auto-scaled to
`0.85 × spacing` when the param is left at its `-1` sentinel; ≈ 1.02 Å `0.85 × spacing` when the param is left at its `-1` sentinel; ≈ 1.02 Å
at default spacing) around each grid point. The default sits just above at default spacing) around each grid point. The default sits just above
the 3D-diagonal merge threshold (`spacing × √3 / 2 ≈ 0.87 × spacing`), the 3D-diagonal merge threshold (`spacing × √3 / 2 ≈ 0.866 × spacing`),
so neighbors overlap in every direction and the surface reads as one so neighbors overlap in every direction and the surface reads as one
clean continuous blob per pocket. Going much below `~spacing/2` leaves clean continuous blob per pocket. Going much below `~spacing/2` leaves
the spheres too disconnected for PyMOL's surface algorithm — most of the spheres too disconnected for PyMOL's surface algorithm — most of

View File

@@ -561,8 +561,8 @@ pocket_grid() {
test ./prank.sh predict -f distro/test_data/1fbl.pdb -export_pocket_grid 1 -pocket_grid_format arrow.zst -out_subdir TEST/POCKET_GRID test ./prank.sh predict -f distro/test_data/1fbl.pdb -export_pocket_grid 1 -pocket_grid_format arrow.zst -out_subdir TEST/POCKET_GRID
test ./prank.sh predict -f distro/test_data/1fbl.pdb -export_pocket_grid 1 -pocket_grid_format parquet -out_subdir TEST/POCKET_GRID test ./prank.sh predict -f distro/test_data/1fbl.pdb -export_pocket_grid 1 -pocket_grid_format parquet -out_subdir TEST/POCKET_GRID
# Full descriptor menu # Full descriptor menu (all seven shipped, including principal_moments)
test ./prank.sh predict -f distro/test_data/1fbl.pdb -export_pocket_descriptors 1 -pocket_descriptors 'volume,sphericity,radius_of_gyration,num_residues,num_surface_atoms,num_grid_points' -out_subdir TEST/POCKET_GRID test ./prank.sh predict -f distro/test_data/1fbl.pdb -export_pocket_descriptors 1 -pocket_descriptors 'volume,sphericity,radius_of_gyration,num_residues,num_surface_atoms,num_grid_points,principal_moments' -out_subdir TEST/POCKET_GRID
# Grid-free descriptors only — exercises the grid-build short-circuit (no # Grid-free descriptors only — exercises the grid-build short-circuit (no
# "PocketGrid built" log line should appear for these invocations). # "PocketGrid built" log line should appear for these invocations).

View File

@@ -78,7 +78,7 @@ final class PocketDescriptorsRows implements TableData {
if (grid == null) { if (grid == null) {
for (PocketDescriptor d : descriptors) { for (PocketDescriptor d : descriptors) {
if (d.needsGrid()) { if (d.needsGrid()) {
throw new IllegalArgumentException( throw new cz.siret.prank.program.PrankException(
"Descriptor '${d.name()}' declares needsGrid()=true but a null " + "Descriptor '${d.name()}' declares needsGrid()=true but a null " +
"PocketGrid was passed to PocketDescriptorsRows. Either build the " + "PocketGrid was passed to PocketDescriptorsRows. Either build the " +
"grid upstream or drop this descriptor from -pocket_descriptors.") "grid upstream or drop this descriptor from -pocket_descriptors.")

View File

@@ -40,10 +40,6 @@ final class PocketGridRows implements TableData {
/** [rowIndex][descriptorColumn] — flat across all descriptors; null when no descriptors. */ /** [rowIndex][descriptorColumn] — flat across all descriptors; null when no descriptors. */
private final double[][] descriptorValues private final double[][] descriptorValues
PocketGridRows(PocketGrid grid, boolean includeUnassigned) {
this(grid, includeUnassigned, null, null, Collections.<String> emptyList())
}
PocketGridRows(PocketGrid grid, boolean includeUnassigned, PocketGridRows(PocketGrid grid, boolean includeUnassigned,
Protein protein, List<? extends Pocket> pockets, Protein protein, List<? extends Pocket> pockets,
List<String> descriptorNames) { List<String> descriptorNames) {
@@ -178,7 +174,7 @@ final class PocketGridRows implements TableData {
double[] getColumn(int colIndex) { double[] getColumn(int colIndex) {
int n = rowPointIdx.length int n = rowPointIdx.length
double[] out = new double[n] double[] out = new double[n]
if (colIndex == 3) { if (colIndex == BASE_COLS - 1) { // pocket column — INT
for (int i = 0; i < n; i++) out[i] = rowPocket[i] for (int i = 0; i < n; i++) out[i] = rowPocket[i]
return out return out
} }

View File

@@ -45,6 +45,12 @@ public final class PocketDescriptorRegistry {
*/ */
public static void register(PocketDescriptor d) { public static void register(PocketDescriptor d) {
List<String> cols = d.columnNames(); List<String> cols = d.columnNames();
List<?> types = d.columnTypes();
if (cols.size() != types.size()) {
throw new IllegalStateException(
"Descriptor '" + d.name() + "' has columnNames.size()=" + cols.size()
+ " but columnTypes.size()=" + types.size() + "; they must be parallel.");
}
if (cols.size() > 1 && new HashSet<>(cols).size() != cols.size()) { if (cols.size() > 1 && new HashSet<>(cols).size() != cols.size()) {
throw new IllegalStateException( throw new IllegalStateException(
"Descriptor '" + d.name() + "' declares duplicate columnNames: " + cols); "Descriptor '" + d.name() + "' declares duplicate columnNames: " + cols);

View File

@@ -107,9 +107,15 @@ public final class PrincipalMomentsDescriptor implements PocketDescriptor {
return new double[] { l1, l2, l3 }; return new double[] { l1, l2, l3 };
} }
/** PSD eigenvalues should be ≥ 0; numerical noise can push them slightly negative. */ /**
* PSD eigenvalues should be ≥ 0; numerical noise can push them slightly negative.
* NaN is also clamped to 0 as defense-in-depth: {@code NaN < 0} is false, so a NaN
* eigenvalue would otherwise slip through and propagate to the CSV. The upstream
* {@code GridGenerator.isFiniteBox} guard already rejects non-finite inputs, but
* a future code path could bypass it.
*/
private static double clampNonNegative(double v) { private static double clampNonNegative(double v) {
return v < 0d ? 0d : v; return (v < 0d || !Double.isFinite(v)) ? 0d : v;
} }
} }

View File

@@ -120,7 +120,7 @@ public final class PocketGridBuilder {
case "morph_closing": return new MorphologicalCloser(); case "morph_closing": return new MorphologicalCloser();
case "none": return new NoOpFiller(); case "none": return new NoOpFiller();
default: default:
throw new IllegalArgumentException( throw new cz.siret.prank.program.PrankException(
"Unknown pocket_grid_fill strategy: '" + strategy "Unknown pocket_grid_fill strategy: '" + strategy
+ "'. Expected one of: morph_closing, none."); + "'. Expected one of: morph_closing, none.");
} }

View File

@@ -23,4 +23,17 @@ public record PocketGridPointContext(
@Nullable Pocket pocket, @Nullable Pocket pocket,
Protein protein, Protein protein,
PocketGrid grid) { PocketGrid grid) {
// Compact validator — limits the blast radius of an int-arg swap. Doesn't catch
// pointIndex ↔ pocketRank swapped when both happen to be non-negative, but does
// catch the common cases (negative index or rank from a misuse).
public PocketGridPointContext {
if (pointIndex < 0) {
throw new IllegalArgumentException("pointIndex must be >= 0 (got " + pointIndex + ")");
}
if (pocketRank < 0) {
throw new IllegalArgumentException(
"pocketRank must be >= 0 (0 = unassigned; got " + pocketRank + ")");
}
}
} }

View File

@@ -36,6 +36,12 @@ public final class PocketGridPointDescriptorRegistry {
*/ */
public static void register(PocketGridPointDescriptor d) { public static void register(PocketGridPointDescriptor d) {
List<String> cols = d.columnNames(); List<String> cols = d.columnNames();
List<?> types = d.columnTypes();
if (cols.size() != types.size()) {
throw new IllegalStateException(
"Descriptor '" + d.name() + "' has columnNames.size()=" + cols.size()
+ " but columnTypes.size()=" + types.size() + "; they must be parallel.");
}
if (cols.size() > 1 && new HashSet<>(cols).size() != cols.size()) { if (cols.size() > 1 && new HashSet<>(cols).size() != cols.size()) {
throw new IllegalStateException( throw new IllegalStateException(
"Descriptor '" + d.name() + "' declares duplicate columnNames: " + cols); "Descriptor '" + d.name() + "' declares duplicate columnNames: " + cols);

View File

@@ -117,6 +117,24 @@ class GridGeneratorBetweenTest {
} }
} }
@Test
void nanCoordInSasPointsThrowsClearError() {
// GridGenerator's (Box, edge) ctor guards against NaN/Inf input — without it,
// IEEEremainder(NaN, edge) silently produces NaN origins and a NaN-everywhere
// lattice. This test pins the throw so a future refactor that drops the guard
// can't reintroduce silent NaN propagation.
Atoms atoms = new Atoms([carbonAt(0d, 0d, 0d)])
Atoms sasWithNaN = new Atoms([
new Point(0d, 0d, 0d) as Atom,
new Point(Double.NaN, 0d, 0d) as Atom
])
IllegalArgumentException e = assertThrows(IllegalArgumentException.class) {
GridGenerator.sampleGridPointsBetween(atoms, sasWithNaN, 1.0d, 3.0d, 0.5d)
} as IllegalArgumentException
assertTrue(e.message.toLowerCase().contains('non-finite'),
"expected non-finite-box error, got: ${e.message}")
}
@Test @Test
void returnedOriginMatchesGridShift() { void returnedOriginMatchesGridShift() {
// Sampler exposes the lattice origin it picked so downstream callers don't // Sampler exposes the lattice origin it picked so downstream callers don't

View File

@@ -45,7 +45,7 @@ class PocketGridRowsTest {
@Test @Test
void multiPocketMembershipProducesMultipleRows() { void multiPocketMembershipProducesMultipleRows() {
// Point b is in both pockets → it appears twice (once per pocket). // Point b is in both pockets → it appears twice (once per pocket).
PocketGridRows data = new PocketGridRows(buildTwoPocketGrid(), false) PocketGridRows data = new PocketGridRows(buildTwoPocketGrid(), false, null, null, [] as List<String>)
assertEquals(4, data.rowCount) // 2 + 2 assignments assertEquals(4, data.rowCount) // 2 + 2 assignments
assertEquals(['x', 'y', 'z', 'pocket'], data.header) assertEquals(['x', 'y', 'z', 'pocket'], data.header)
} }
@@ -62,16 +62,16 @@ class PocketGridRowsTest {
assigned.put(1, bits(0)) assigned.put(1, bits(0))
PocketGrid grid = new PocketGrid(new Atoms([a, unassigned]), 1.0d, 0d, 0d, 0d, idx, assigned) PocketGrid grid = new PocketGrid(new Atoms([a, unassigned]), 1.0d, 0d, 0d, 0d, idx, assigned)
PocketGridRows included = new PocketGridRows(grid, true) PocketGridRows included = new PocketGridRows(grid, true, null, null, [] as List<String>)
assertEquals(2, included.rowCount) // 1 assigned + 1 unassigned assertEquals(2, included.rowCount) // 1 assigned + 1 unassigned
PocketGridRows omitted = new PocketGridRows(grid, false) PocketGridRows omitted = new PocketGridRows(grid, false, null, null, [] as List<String>)
assertEquals(1, omitted.rowCount) assertEquals(1, omitted.rowCount)
} }
@Test @Test
void sortOrderIsPocketThenCoords() { void sortOrderIsPocketThenCoords() {
PocketGridRows data = new PocketGridRows(buildTwoPocketGrid(), false) PocketGridRows data = new PocketGridRows(buildTwoPocketGrid(), false, null, null, [] as List<String>)
// Expected sort: (pocket=1, x=1,2), then (pocket=2, x=2,3). // Expected sort: (pocket=1, x=1,2), then (pocket=2, x=2,3).
double[] r0 = data.getRow(0); assertEquals(1.0d, r0[0], 0.0d); assertEquals(1, (int) r0[3]) double[] r0 = data.getRow(0); assertEquals(1.0d, r0[0], 0.0d); assertEquals(1, (int) r0[3])
double[] r1 = data.getRow(1); assertEquals(2.0d, r1[0], 0.0d); assertEquals(1, (int) r1[3]) double[] r1 = data.getRow(1); assertEquals(2.0d, r1[0], 0.0d); assertEquals(1, (int) r1[3])
@@ -81,7 +81,7 @@ class PocketGridRowsTest {
@Test @Test
void columnTypes() { void columnTypes() {
PocketGridRows data = new PocketGridRows(buildTwoPocketGrid(), false) PocketGridRows data = new PocketGridRows(buildTwoPocketGrid(), false, null, null, [] as List<String>)
assertEquals(TableData.ColumnType.DOUBLE, data.getColumnType(0)) assertEquals(TableData.ColumnType.DOUBLE, data.getColumnType(0))
assertEquals(TableData.ColumnType.DOUBLE, data.getColumnType(1)) assertEquals(TableData.ColumnType.DOUBLE, data.getColumnType(1))
assertEquals(TableData.ColumnType.DOUBLE, data.getColumnType(2)) assertEquals(TableData.ColumnType.DOUBLE, data.getColumnType(2))

View File

@@ -270,6 +270,33 @@ class PocketDescriptorsTest {
} }
} }
/**
* Round-trip the register/unregister API: register a fixture, see it land in
* get/knownNames, unregister it, see it gone. Exercises the unregister code
* path that production callers never touch (it exists for tests + future
* descriptor plugins).
*/
@Test
void registryUnregisterRemovesAddedDescriptor() {
String fixtureName = "__test_unregister_fixture__"
PocketDescriptor fixture = new AbstractScalarPocketDescriptor() {
@Override String name() { fixtureName }
@Override protected ColumnType scalarType() { ColumnType.DOUBLE }
@Override protected double computeScalar(PocketGridContext ctx) { 0d }
}
PocketDescriptorRegistry.register(fixture)
try {
assertEquals(fixture, PocketDescriptorRegistry.get(fixtureName))
assertTrue(PocketDescriptorRegistry.knownNames().contains(fixtureName))
} finally {
PocketDescriptorRegistry.unregister(fixtureName)
}
assertFalse(PocketDescriptorRegistry.knownNames().contains(fixtureName))
assertThrows(PrankException) {
PocketDescriptorRegistry.get(fixtureName)
}
}
@Test @Test
void registryListsKnownNames() { void registryListsKnownNames() {
Set<String> known = PocketDescriptorRegistry.knownNames() Set<String> known = PocketDescriptorRegistry.knownNames()
@@ -375,4 +402,46 @@ class PocketDescriptorsTest {
assertEquals(rg * rg, sum, 1e-9d) assertEquals(rg * rg, sum, 1e-9d)
} }
/**
* Boundary between the short-circuit ({@code n < 2}) and the full path.
* Two distinct points span exactly one axis, so λ > 0 and the other two
* eigenvalues are 0. Per-dim variance of {0, 2} = mean((-1)² + 1²) = 1.
*/
@Test
void principalMomentsAtExactlyTwoDistinctPointsHitsFullPath() {
List<Atom> pts = [new Point(0d, 0d, 0d), new Point(2d, 0d, 0d)]
PocketGrid grid = gridOfPoints(pts)
TestPocket p = new TestPocket(); p.rank = 1
double[] lambdas = new PrincipalMomentsDescriptor().compute(ctx(grid, p))
assertEquals(1.0d, lambdas[0], 1e-9d)
assertEquals(0.0d, lambdas[1], 1e-9d)
assertEquals(0.0d, lambdas[2], 1e-9d)
}
/**
* Two coincident points: cardinality=2 takes the full path, but every
* delta-from-centroid is 0, so the gyration tensor is zero and all
* eigenvalues come out zero. Verifies the full path handles the
* degenerate non-short-circuit case without producing NaN / negative
* eigenvalues from numerical noise.
*/
@Test
void principalMomentsOfTwoCoincidentPointsIsAllZeros() {
// LongIntHashMap can't hold two entries with the same packed key — for
// a true "two atoms at the same coord" fixture we need two distinct
// lattice slots whose Atom positions happen to coincide. Build by hand.
Atom a = new Point(0d, 0d, 0d)
Atom b = new Point(0d, 0d, 0d)
LongIntHashMap idx = new LongIntHashMap()
idx.put(PocketGrid.pack(0, 0, 0), 0)
idx.put(PocketGrid.pack(1, 0, 0), 1) // arbitrary distinct lattice slot
BitSet bs = new BitSet(); bs.set(0, 2)
Map<Integer, BitSet> assigned = [(1): bs] as LinkedHashMap
PocketGrid grid = new PocketGrid(new Atoms([a, b]), 1.0d, 0d, 0d, 0d, idx, assigned)
TestPocket p = new TestPocket(); p.rank = 1
double[] lambdas = new PrincipalMomentsDescriptor().compute(ctx(grid, p))
assertArrayEquals([0.0d, 0.0d, 0.0d] as double[], lambdas, 1e-9d)
}
} }

View File

@@ -110,9 +110,11 @@ class MorphologicalCloserTest {
} }
@Test @Test
void respectsMaxIters() { void maxItersZeroIsNoOp() {
// With max_iters=0 the closer should return the raw shell unchanged // With max_iters=0 the closer should return the raw shell unchanged
// (no iteration runs). // (no iteration runs). Also pins the silent-non-convergence-warning fix
// in 0e044f6b — the warning must NOT fire when maxIters=0 (a valid
// "disable fill" configuration).
PocketGrid grid = buildCubeGrid(2, 2, 2) PocketGrid grid = buildCubeGrid(2, 2, 2)
int centerIdx = grid.latticeIndex.get(PocketGrid.pack(1, 1, 1)) int centerIdx = grid.latticeIndex.get(PocketGrid.pack(1, 1, 1))
BitSet raw = new BitSet() BitSet raw = new BitSet()
@@ -124,4 +126,20 @@ class MorphologicalCloserTest {
assertFalse(result.get(centerIdx), "no fill when max_iters=0") assertFalse(result.get(centerIdx), "no fill when max_iters=0")
} }
@Test
void maxItersOneRunsExactlyOneIteration() {
// One iteration of fill should be enough to close a single-cell U concavity:
// the surrounded center cell has many filled neighbors and gets promoted on
// iter 0. Pins behavior between the no-op (0) and converged cases.
PocketGrid grid = buildCubeGrid(2, 2, 2)
int centerIdx = grid.latticeIndex.get(PocketGrid.pack(1, 1, 1))
BitSet raw = new BitSet()
for (int i = 0; i < grid.pointCount; i++) {
if (i != centerIdx) raw.set(i)
}
BitSet result = CLOSER.fill(raw, grid, 3, 1)
assertTrue(result.get(centerIdx), "the surrounded center should fill in one iter")
}
} }

View File

@@ -136,7 +136,7 @@ class PocketGridBuilderTest {
TestPocket p = new TestPocket() TestPocket p = new TestPocket()
p.rank = 1 p.rank = 1
p.sasPoints = sasAt(0d, 0d, 0d) p.sasPoints = sasAt(0d, 0d, 0d)
assertThrows(IllegalArgumentException) { assertThrows(cz.siret.prank.program.PrankException) {
PocketGridBuilder.build(protein, [p] as List<Pocket>, bad) PocketGridBuilder.build(protein, [p] as List<Pocket>, bad)
} }
} }

View File

@@ -3,12 +3,15 @@ package cz.siret.prank.program.routines.predict.output.grid.descriptors
import cz.siret.prank.domain.Protein import cz.siret.prank.domain.Protein
import cz.siret.prank.geom.Atoms import cz.siret.prank.geom.Atoms
import cz.siret.prank.geom.Point import cz.siret.prank.geom.Point
import cz.siret.prank.program.params.Params
import groovy.transform.CompileStatic import groovy.transform.CompileStatic
import org.biojava.nbio.structure.Atom import org.biojava.nbio.structure.Atom
import org.biojava.nbio.structure.AtomImpl import org.biojava.nbio.structure.AtomImpl
import org.biojava.nbio.structure.Element import org.biojava.nbio.structure.Element
import org.biojava.nbio.structure.Group import org.biojava.nbio.structure.Group
import org.biojava.nbio.structure.AminoAcidImpl import org.biojava.nbio.structure.AminoAcidImpl
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import static org.junit.jupiter.api.Assertions.* import static org.junit.jupiter.api.Assertions.*
@@ -28,6 +31,22 @@ class VolsiteGridPointDescriptorTest {
private static final int AROMATIC = 0, CATION = 1, ANION = 2, private static final int AROMATIC = 0, CATION = 1, ANION = 2,
HYDROPHOBIC = 3, ACCEPTOR = 4, DONOR = 5 HYDROPHOBIC = 3, ACCEPTOR = 4, DONOR = 5
/** Radius the boundary tests build their atom distances around. */
private static final double RADIUS = 4.0d
private double savedRadius
@BeforeEach
void setRadius() {
savedRadius = Params.inst.pocket_grid_volsite_radius
Params.inst.pocket_grid_volsite_radius = RADIUS
}
@AfterEach
void restoreRadius() {
Params.inst.pocket_grid_volsite_radius = savedRadius
}
private static Atom atomAt(String element, String resName, String atomName, private static Atom atomAt(String element, String resName, String atomName,
double x, double y, double z) { double x, double y, double z) {
AtomImpl a = new AtomImpl() AtomImpl a = new AtomImpl()
@@ -94,4 +113,23 @@ class VolsiteGridPointDescriptorTest {
for (int i = 0; i < 6; i++) assertEquals(0d, out[i], 0d) for (int i = 0; i < 6; i++) assertEquals(0d, out[i], 0d)
} }
@Test
void singleAtomWithTwoPharmacophoreFlagsLightsBothColumns() {
// ND1 in HIS sets BOTH donor and acceptor (VolSitePharmacophore.java).
// The 6-flag aggregator's "two flags from one atom" path is non-trivial;
// a regression that overwrites one with the other would slip through if
// every other test only exercises one-flag atoms.
Atom nd1His = atomAt("N", "HIS", "ND1", 1d, 0d, 0d)
double[] out = new VolsiteGridPointDescriptor().compute(
ctxAt(0d, 0d, 0d, new Atoms([nd1His])))
assertEquals(1d, out[DONOR])
assertEquals(1d, out[ACCEPTOR])
// The other four must remain off.
assertEquals(0d, out[AROMATIC])
assertEquals(0d, out[CATION])
assertEquals(0d, out[ANION])
assertEquals(0d, out[HYDROPHOBIC])
}
} }

View File

@@ -10,6 +10,7 @@ import org.biojava.nbio.structure.AtomImpl
import org.biojava.nbio.structure.Element import org.biojava.nbio.structure.Element
import org.biojava.nbio.structure.Group import org.biojava.nbio.structure.Group
import org.biojava.nbio.structure.AminoAcidImpl import org.biojava.nbio.structure.AminoAcidImpl
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -34,14 +35,28 @@ class VolsiteSmoothGridPointDescriptorTest {
private static final int AROMATIC = 0, CATION = 1, ANION = 2, private static final int AROMATIC = 0, CATION = 1, ANION = 2,
HYDROPHOBIC = 3, ACCEPTOR = 4, DONOR = 5 HYDROPHOBIC = 3, ACCEPTOR = 4, DONOR = 5
private double savedSigma
@BeforeEach @BeforeEach
void setSigma() { void setSigma() {
savedSigma = Params.inst.pocket_grid_volsite_sigma
Params.inst.pocket_grid_volsite_sigma = SIGMA Params.inst.pocket_grid_volsite_sigma = SIGMA
} }
private static Atom atomAt(String atomName, String resName, double x, double y, double z) { @AfterEach
void restoreSigma() {
// Restore the global Params singleton so test classes that run after this
// one (and that read pocket_grid_volsite_sigma without pinning it) aren't
// affected by our pin.
Params.inst.pocket_grid_volsite_sigma = savedSigma
}
// Signature matches VolsiteGridPointDescriptorTest.atomAt (element, resName, atomName, x, y, z)
// — both tests build atoms the same way so calls don't get swapped between files.
private static Atom atomAt(String element, String resName, String atomName,
double x, double y, double z) {
AtomImpl a = new AtomImpl() AtomImpl a = new AtomImpl()
a.element = Element.C a.element = Element.valueOfIgnoreCase(element)
a.name = atomName a.name = atomName
a.x = x; a.y = y; a.z = z a.x = x; a.y = y; a.z = z
Group g = new AminoAcidImpl() Group g = new AminoAcidImpl()
@@ -59,7 +74,7 @@ class VolsiteSmoothGridPointDescriptorTest {
@Test @Test
void weightAtZeroDistanceIsOne() { void weightAtZeroDistanceIsOne() {
// exp(0) = 1.0 exactly. Atom name "C" is hydrophobic. // exp(0) = 1.0 exactly. Atom name "C" is hydrophobic.
Atom c = atomAt("C", "ALA", 0d, 0d, 0d) Atom c = atomAt("C", "ALA", "C", 0d, 0d, 0d)
double[] out = new VolsiteSmoothGridPointDescriptor().compute( double[] out = new VolsiteSmoothGridPointDescriptor().compute(
ctxAt(0d, 0d, 0d, new Atoms([c]))) ctxAt(0d, 0d, 0d, new Atoms([c])))
assertEquals(1.0d, out[HYDROPHOBIC], DELTA) assertEquals(1.0d, out[HYDROPHOBIC], DELTA)
@@ -69,7 +84,7 @@ class VolsiteSmoothGridPointDescriptorTest {
void weightAtSigmaMatchesGaussianFormula() { void weightAtSigmaMatchesGaussianFormula() {
// At distance r = σ, weight = exp(-r²/(2σ²)) = exp(-1/2) ≈ 0.6065. // At distance r = σ, weight = exp(-r²/(2σ²)) = exp(-1/2) ≈ 0.6065.
double sigma = SIGMA double sigma = SIGMA
Atom c = atomAt("C", "ALA", sigma, 0d, 0d) Atom c = atomAt("C", "ALA", "C", sigma, 0d, 0d)
double[] out = new VolsiteSmoothGridPointDescriptor().compute( double[] out = new VolsiteSmoothGridPointDescriptor().compute(
ctxAt(0d, 0d, 0d, new Atoms([c]))) ctxAt(0d, 0d, 0d, new Atoms([c])))
assertEquals(Math.exp(-0.5d), out[HYDROPHOBIC], DELTA) assertEquals(Math.exp(-0.5d), out[HYDROPHOBIC], DELTA)
@@ -80,8 +95,8 @@ class VolsiteSmoothGridPointDescriptorTest {
// Two hydrophobic atoms at distance σ each → sum = 2 × exp(-0.5). // Two hydrophobic atoms at distance σ each → sum = 2 × exp(-0.5).
double sigma = SIGMA double sigma = SIGMA
Atoms protein = new Atoms([ Atoms protein = new Atoms([
atomAt("C", "ALA", sigma, 0d, 0d), atomAt("C", "ALA", "C", sigma, 0d, 0d),
atomAt("C", "ALA", 0d, sigma, 0d), atomAt("C", "ALA", "C", 0d, sigma, 0d),
]) ])
double[] out = new VolsiteSmoothGridPointDescriptor().compute( double[] out = new VolsiteSmoothGridPointDescriptor().compute(
ctxAt(0d, 0d, 0d, protein)) ctxAt(0d, 0d, 0d, protein))
@@ -94,7 +109,7 @@ class VolsiteSmoothGridPointDescriptorTest {
// exactly 4σ IS included and contributes exp(-(4σ)²/(2σ²)) = exp(-8) ≈ 3.354e-4. // exactly 4σ IS included and contributes exp(-(4σ)²/(2σ²)) = exp(-8) ≈ 3.354e-4.
// Pins the boundary semantic (cutoff is inclusive, not strict). // Pins the boundary semantic (cutoff is inclusive, not strict).
double sigma = SIGMA double sigma = SIGMA
Atom c = atomAt("C", "ALA", 4d * sigma, 0d, 0d) Atom c = atomAt("C", "ALA", "C", 4d * sigma, 0d, 0d)
double[] out = new VolsiteSmoothGridPointDescriptor().compute( double[] out = new VolsiteSmoothGridPointDescriptor().compute(
ctxAt(0d, 0d, 0d, new Atoms([c]))) ctxAt(0d, 0d, 0d, new Atoms([c])))
assertEquals(Math.exp(-8d), out[HYDROPHOBIC], DELTA) assertEquals(Math.exp(-8d), out[HYDROPHOBIC], DELTA)
@@ -105,7 +120,7 @@ class VolsiteSmoothGridPointDescriptorTest {
// 4σ is the hard cutoff (cutoutSphere is the gate). At 5σ the atom isn't even // 4σ is the hard cutoff (cutoutSphere is the gate). At 5σ the atom isn't even
// in the kdtree result. Zero contribution. // in the kdtree result. Zero contribution.
double sigma = SIGMA double sigma = SIGMA
Atom c = atomAt("C", "ALA", 5d * sigma, 0d, 0d) Atom c = atomAt("C", "ALA", "C", 5d * sigma, 0d, 0d)
double[] out = new VolsiteSmoothGridPointDescriptor().compute( double[] out = new VolsiteSmoothGridPointDescriptor().compute(
ctxAt(0d, 0d, 0d, new Atoms([c]))) ctxAt(0d, 0d, 0d, new Atoms([c])))
assertEquals(0d, out[HYDROPHOBIC], DELTA) assertEquals(0d, out[HYDROPHOBIC], DELTA)

View File

@@ -21,24 +21,8 @@ class PocketGridChimeraXRendererTest {
@TempDir @TempDir
Path tempDir Path tempDir
private static BitSet bits(int... values) {
BitSet b = new BitSet()
for (int v : values) b.set(v)
return b
}
private static PocketGrid tinyGrid() { private static PocketGrid tinyGrid() {
Atom a = new Point(0d, 0d, 0d) return RendererTestFixtures.tinyGrid()
Atom b = new Point(1d, 0d, 0d)
Atom c = new Point(2d, 0d, 0d)
LongIntHashMap idx = new LongIntHashMap()
idx.put(PocketGrid.pack(0, 0, 0), 0)
idx.put(PocketGrid.pack(1, 0, 0), 1)
idx.put(PocketGrid.pack(2, 0, 0), 2)
Map<Integer, BitSet> assigned = new LinkedHashMap<>()
assigned.put(1, bits(0, 1))
assigned.put(2, bits(1, 2))
return new PocketGrid(new Atoms([a, b, c]), 1.0d, 0d, 0d, 0d, idx, assigned)
} }
@Test @Test

View File

@@ -26,24 +26,8 @@ class PocketGridPymolRendererTest {
@TempDir @TempDir
Path tempDir Path tempDir
private static BitSet bits(int... values) {
BitSet b = new BitSet()
for (int v : values) b.set(v)
return b
}
private static PocketGrid tinyGrid() { private static PocketGrid tinyGrid() {
Atom a = new Point(0d, 0d, 0d) return RendererTestFixtures.tinyGrid()
Atom b = new Point(1d, 0d, 0d)
Atom c = new Point(2d, 0d, 0d)
LongIntHashMap idx = new LongIntHashMap()
idx.put(PocketGrid.pack(0, 0, 0), 0)
idx.put(PocketGrid.pack(1, 0, 0), 1)
idx.put(PocketGrid.pack(2, 0, 0), 2)
Map<Integer, BitSet> assigned = new LinkedHashMap<>()
assigned.put(1, bits(0, 1))
assigned.put(2, bits(1, 2))
return new PocketGrid(new Atoms([a, b, c]), 1.0d, 0d, 0d, 0d, idx, assigned)
} }
@Test @Test
@@ -162,7 +146,7 @@ class PocketGridPymolRendererTest {
new Atoms([new Point(0d, 0d, 0d) as Atom]), new Atoms([new Point(0d, 0d, 0d) as Atom]),
3.0d, 0d, 0d, 0d, 3.0d, 0d, 0d, 0d,
makeIdx(0), makeIdx(0),
[(1 as Integer): bits(0)] as LinkedHashMap<Integer, BitSet>) [(1 as Integer): RendererTestFixtures.bits(0)] as LinkedHashMap<Integer, BitSet>)
PocketGridPymolRenderer.render(coarse, 2.5d, GAUSSIAN_ISO, tempDir.toString(), "coarse") PocketGridPymolRenderer.render(coarse, 2.5d, GAUSSIAN_ISO, tempDir.toString(), "coarse")
String pml = new File("${tempDir}/visualizations/coarse_pocket_grid.pml").text String pml = new File("${tempDir}/visualizations/coarse_pocket_grid.pml").text
assertTrue(pml.contains("alter pocket_vol_*, vdw=2.500"), assertTrue(pml.contains("alter pocket_vol_*, vdw=2.500"),

View File

@@ -0,0 +1,46 @@
package cz.siret.prank.program.visualization.renderers
import com.carrotsearch.hppc.LongIntHashMap
import cz.siret.prank.geom.Atoms
import cz.siret.prank.geom.Point
import cz.siret.prank.program.routines.predict.output.grid.PocketGrid
import groovy.transform.CompileStatic
import org.biojava.nbio.structure.Atom
/**
* Shared test fixtures for the pocket-grid renderer tests. Both
* {@code PocketGridChimeraXRendererTest} and {@code PocketGridPymolRendererTest}
* exercised an identical 3-atom / 2-pocket-overlap grid; extracted here so
* a fixture change touches one site.
*/
@CompileStatic
final class RendererTestFixtures {
private RendererTestFixtures() {}
static BitSet bits(int... values) {
BitSet b = new BitSet()
for (int v : values) b.set(v)
return b
}
/**
* 3 grid points (a=0,0,0 — b=1,0,0 — c=2,0,0), spacing 1.0,
* pocket 1 = {a, b}, pocket 2 = {b, c}. Used by the renderer tests to
* verify per-pocket coloring, multi-membership behavior, etc.
*/
static PocketGrid tinyGrid() {
Atom a = new Point(0d, 0d, 0d)
Atom b = new Point(1d, 0d, 0d)
Atom c = new Point(2d, 0d, 0d)
LongIntHashMap idx = new LongIntHashMap()
idx.put(PocketGrid.pack(0, 0, 0), 0)
idx.put(PocketGrid.pack(1, 0, 0), 1)
idx.put(PocketGrid.pack(2, 0, 0), 2)
Map<Integer, BitSet> assigned = new LinkedHashMap<>()
assigned.put(1, bits(0, 1))
assigned.put(2, bits(1, 2))
return new PocketGrid(new Atoms([a, b, c]), 1.0d, 0d, 0d, 0d, idx, assigned)
}
}