Skip to content

Commit 9edb416

Browse files
authored
Merge pull request #14 from OpenBioSim/backport_13
2 parents d7c12d8 + 206a849 commit 9edb416

3 files changed

Lines changed: 41 additions & 166 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
Changelog
22
=========
33

4-
[2026.1.0](https://github.com/openbiosim/loch/compare/2025.2.0...2026.1.0) - Jun 2026
4+
[2026.2.0](https://github.com/openbiosim/ghostly/compare/2026.1.0...2026.2.0) - ********
5+
-------------------------------------------------------------------------------------
6+
7+
* Please add an item to this CHANGELOG for any new features or bug fixes when creating a PR.
8+
* Revert auto-zeroing of anchor dihedrals for ring-constrained immediate ghosts: a singly-attached ring still has one unconstrained rotational degree of freedom about the bridge-ghost bond, so removing the anchor reintroduces flapping risk.
9+
10+
[2026.1.0](https://github.com/openbiosim/ghostly/compare/2025.2.0...2026.1.0) - Jun 2026
511
-------------------------------------------------------------------------------------
612

713
* Add linear spacer modification for ring-breaking ghost bridges.
8-
* Remove cross-bond angles spanning ring-making/breaking bonds in the state where the bond is absent.
914
* Fixed missing removal of bridge-extension dihedrals (`real–ghost–ghost–ghost`) that arise when a ghost group contains a ring, e.g. cyclopropyl, where the ring topology creates spurious torsional coupling between the real scaffold and the ghost ring interior.
1015
* Auto-zero anchor dihedrals when the immediate ghost atom lies on a ring within the ghost subgraph. The ring topology already constrains the ghost orientation relative to the bridge, making the anchor redundant.
1116

12-
[2025.2.0](https://github.com/openbiosim/loch/compare/2025.1.0...2025.2.0) - Mar 2026
17+
[2025.2.0](https://github.com/openbiosim/ghostly/compare/2025.1.0...2025.2.0) - Mar 2026
1318
-------------------------------------------------------------------------------------
1419

1520
* Ensure that there are independent, per-state, physical neighbours. This removes the spurious cross-state ghost filter that resulted in a downgrading of junction types when multiple junctions were present in the molecule.
@@ -23,7 +28,7 @@ Changelog
2328
* Fixed angle removal logic for ring and sp2 bridge atoms: when stiffening is skipped (``stiffen_ring_bridges=False`` or ``stiffen_sp2_bridges=False``), all ghost-containing angles are now preserved at their original force field values rather than removing the intraghost angles (dual junctions) or the sacrificial physical-bridge-ghost angle (triple junctions).
2429
* Changed the default for ``--optimise-angles`` to ``False``. Angle optimisation is conformer-dependent: different input geometries can yield different equilibrium angles for the same perturbation, adding variability to the resulting force field. The original force field theta0 is sufficient given the small ``k_soft`` value. Optimisation can still be enabled explicitly to follow Boresch et al. (JCTC 2021) strictly.
2530

26-
[2025.1.0](https://github.com/OpenBioSim/loch/releases/tag/2025.1.0) - Nov 2025
31+
[2025.1.0](https://github.com/OpenBioSim/ghostly/releases/tag/2025.1.0) - Nov 2025
2732
-------------------------------------------------------------------------------
2833

2934
* Initial public release.

‎src/ghostly/_ghostly.py‎

Lines changed: 9 additions & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -429,20 +429,12 @@ def modify(
429429
mol, ghosts0, modifications, skip_ghosts=linearised0, is_lambda1=False
430430
)
431431

432-
# Identify immediate ghost atoms that lie on a ring in the ghost
433-
# subgraph: anchor dihedrals through these are redundant and will be
434-
# zeroed automatically. Appearing ghosts (ghost at λ=0) form their
435-
# ring at λ=1, so connectivity1 is used since that is where the ring
436-
# bonds actually exist.
437-
ring_ghosts0 = _ring_constrained_ghosts(bridges0, ghosts0, connectivity1)
438-
439432
# Soften any surviving mixed ghost/physical dihedrals.
440433
mol = _soften_mixed_dihedrals(
441434
mol,
442435
ghosts0,
443436
modifications,
444437
soften_anchors=soften_anchors,
445-
ring_ghosts=ring_ghosts0,
446438
is_lambda1=False,
447439
)
448440

@@ -551,19 +543,12 @@ def modify(
551543
mol, ghosts1, modifications, skip_ghosts=linearised1, is_lambda1=True
552544
)
553545

554-
# Identify immediate ghost atoms that lie on a ring in the ghost
555-
# subgraph: anchor dihedrals through these are redundant and will be
556-
# zeroed automatically. Disappearing ghosts (ghost at λ=1) have their
557-
# ring bonds in connectivity0 since that is where they are real.
558-
ring_ghosts1 = _ring_constrained_ghosts(bridges1, ghosts1, connectivity0)
559-
560546
# Soften any surviving mixed ghost/physical dihedrals.
561547
mol = _soften_mixed_dihedrals(
562548
mol,
563549
ghosts1,
564550
modifications,
565551
soften_anchors=soften_anchors,
566-
ring_ghosts=ring_ghosts1,
567552
is_lambda1=True,
568553
)
569554

@@ -2444,93 +2429,8 @@ def _remove_ghost_centre_angles(
24442429
return mol
24452430

24462431

2447-
def _ghost_adjacency(ghosts, connectivity):
2448-
"""Build an adjacency dict for the ghost-atom subgraph, keyed by integer atom index.
2449-
2450-
Using integer indices avoids any AtomIdx Python-wrapper hashing
2451-
inconsistencies when looking up atoms returned by connections_to().
2452-
"""
2453-
ghost_indices = {g.value() for g in ghosts}
2454-
adj = {i: [] for i in ghost_indices}
2455-
for g in ghosts:
2456-
i = g.value()
2457-
for c in connectivity.connections_to(g):
2458-
j = c.value()
2459-
if j in ghost_indices:
2460-
adj[i].append(j)
2461-
return adj
2462-
2463-
2464-
def _ghost_in_cycle(atom_idx, adj):
2465-
"""
2466-
Return True if ``atom_idx`` lies on a cycle in the ghost subgraph described
2467-
by ``adj`` (an integer-keyed adjacency dict).
2468-
2469-
Removes the atom from the graph and does a BFS from each neighbour in turn.
2470-
If any neighbour can reach another neighbour of the atom without passing
2471-
through the atom itself, they lie on a cycle together.
2472-
2473-
We must try each neighbour as a start rather than only ``neighbors[0]``
2474-
because pendant neighbours (bonded only to ``atom_idx`` within the ghost
2475-
subgraph) cannot reach the cycle members — e.g. a methyl substituent on a
2476-
cyclopropyl carbon would cause a false-negative if picked first.
2477-
"""
2478-
neighbors = adj.get(atom_idx, [])
2479-
if len(neighbors) < 2:
2480-
return False
2481-
2482-
for start in neighbors:
2483-
visited = {start}
2484-
queue = [start]
2485-
while queue:
2486-
node = queue.pop(0)
2487-
for n in adj.get(node, []):
2488-
if n != atom_idx and n not in visited:
2489-
visited.add(n)
2490-
queue.append(n)
2491-
if any(n in visited for n in neighbors if n != start):
2492-
return True
2493-
2494-
return False
2495-
2496-
2497-
def _ring_constrained_ghosts(bridges, ghosts, connectivity):
2498-
"""
2499-
Return the set of immediate ghost atoms (directly bonded to a bridge)
2500-
that lie on a cycle within the ghost subgraph.
2501-
2502-
For these atoms the ring topology already constrains their orientation
2503-
relative to the bridge, making anchor dihedrals through them redundant.
2504-
The ring bonds prevent free rotation around the bridge-ghost bond just as
2505-
well as an anchor dihedral would, so zeroing the anchor introduces no
2506-
flapping risk while removing a potential free-energy bias.
2507-
2508-
Pass the connectivity of the end state where the ghost atoms are real,
2509-
since that is where their ring bonds exist: connectivity1 for appearing
2510-
ghosts (ghost at lambda=0), connectivity0 for disappearing ghosts
2511-
(ghost at lambda=1).
2512-
"""
2513-
if not ghosts:
2514-
return set()
2515-
2516-
adj = _ghost_adjacency(ghosts, connectivity)
2517-
ring_ghosts = set()
2518-
for ghost_list in bridges.values():
2519-
for g in ghost_list:
2520-
if _ghost_in_cycle(g.value(), adj):
2521-
ring_ghosts.add(g.value())
2522-
2523-
if ring_ghosts:
2524-
_logger.debug(
2525-
f"Ring-constrained immediate ghosts (anchors will be zeroed): "
2526-
f"[{', '.join(str(i) for i in sorted(ring_ghosts))}]"
2527-
)
2528-
2529-
return ring_ghosts
2530-
2531-
25322432
def _soften_mixed_dihedrals(
2533-
mol, ghosts, modifications, soften_anchors=1.0, ring_ghosts=None, is_lambda1=False
2433+
mol, ghosts, modifications, soften_anchors=1.0, is_lambda1=False
25342434
):
25352435
r"""
25362436
Soften surviving mixed ghost/physical dihedral terms by scaling their
@@ -2543,15 +2443,9 @@ def _soften_mixed_dihedrals(
25432443
atoms start gaining softcore nonbonded interactions but are constrained
25442444
too tightly by bonded terms.
25452445
2546-
When ``soften_anchors`` is 1.0 (default) and ``ring_ghosts`` is empty,
2547-
no modifications are made. When ``soften_anchors`` is 0.0, all mixed
2548-
dihedrals are removed. Intermediate values scale the force constants.
2549-
2550-
Anchor dihedrals whose bridge-adjacent ghost atom lies on a ring within
2551-
the ghost subgraph (supplied via ``ring_ghosts``) are always zeroed,
2552-
regardless of ``soften_anchors``: the ring topology already constrains
2553-
the ghost orientation, so the anchor is redundant and may introduce a
2554-
free-energy bias.
2446+
When ``soften_anchors`` is 1.0 (default), no modifications are made.
2447+
When 0.0, all mixed dihedrals are removed. Intermediate values scale
2448+
the force constants.
25552449
25562450
Parameters
25572451
----------
@@ -2568,12 +2462,6 @@ def _soften_mixed_dihedrals(
25682462
soften_anchors : float, optional
25692463
Scale factor for mixed dihedral force constants (0.0 to 1.0).
25702464
2571-
ring_ghosts : set of int, optional
2572-
Integer atom indices of ghost atoms that are both directly bonded to a
2573-
bridge and part of a ring in the ghost subgraph. Any surviving mixed
2574-
dihedral whose bridge-adjacent ghost atom is in this set is removed
2575-
regardless of ``soften_anchors``.
2576-
25772465
is_lambda1 : bool, optional
25782466
Whether to modify dihedrals at lambda = 1.
25792467
@@ -2584,12 +2472,8 @@ def _soften_mixed_dihedrals(
25842472
The updated molecule.
25852473
"""
25862474

2587-
if ring_ghosts is None:
2588-
ring_ghosts = set()
2589-
2590-
# Nothing to do if there are no ghost atoms, no softening is requested,
2591-
# and no ring-constrained ghosts require automatic zeroing.
2592-
if not ghosts or (soften_anchors >= 1.0 and not ring_ghosts):
2475+
# Nothing to do if there are no ghost atoms or no softening is requested.
2476+
if not ghosts or soften_anchors >= 1.0:
25932477
return mol
25942478

25952479
# Store the molecular info.
@@ -2625,31 +2509,12 @@ def _soften_mixed_dihedrals(
26252509

26262510
if has_ghost and has_physical:
26272511
dih_idx_str = ",".join(str(a.value()) for a in atoms)
2628-
2629-
# Determine the effective scale for this dihedral. If the
2630-
# bridge-adjacent ghost (the ghost atom bonded to a physical atom
2631-
# within the four-atom sequence) lies on a ring in the ghost
2632-
# subgraph, zero it unconditionally: the ring constrains the ghost
2633-
# orientation and the anchor is redundant.
2634-
effective_scale = soften_anchors
2635-
if ring_ghosts:
2636-
for i, a in enumerate(atoms):
2637-
if a.value() in ring_ghosts:
2638-
neighbors_in_dih = []
2639-
if i > 0:
2640-
neighbors_in_dih.append(atoms[i - 1])
2641-
if i < 3:
2642-
neighbors_in_dih.append(atoms[i + 1])
2643-
if any(n not in ghosts for n in neighbors_in_dih):
2644-
effective_scale = 0.0
2645-
break
2646-
2647-
if effective_scale > 0.0:
2648-
scaled = p.function() * effective_scale
2512+
if soften_anchors > 0.0:
2513+
scaled = p.function() * soften_anchors
26492514
new_dihedrals.set(idx0, idx1, idx2, idx3, scaled)
26502515
_logger.debug(
26512516
f" Softening mixed dihedral: [{dih_idx_str}], "
2652-
f"scale={effective_scale}"
2517+
f"scale={soften_anchors}"
26532518
)
26542519
modifications[mod_key]["softened_dihedrals"].append(dih_idx_str)
26552520
else:

‎tests/test_ghostly.py‎

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -323,12 +323,10 @@ def test_ejm49_to_ejm31():
323323
# The number of angles should remain the same at lambda = 1.
324324
assert angles1.num_functions() == new_angles1.num_functions()
325325

326-
# The number of dihedrals should be ten fewer at lambda = 1: four caught
327-
# by the triple junction handler, four bridge-extension terms
328-
# (17-20-{21,25}-{22,24,34,38}), plus two anchor dihedrals
329-
# (16-17-20-{21,25}) that are auto-zeroed because atom 20 (the immediate
330-
# ghost) lies on a ring within the ghost subgraph.
331-
assert dihedrals1.num_functions() - 10 == new_dihedrals1.num_functions()
326+
# The number of dihedrals should be eight fewer at lambda = 1: four caught
327+
# by the triple junction handler, plus four bridge-extension terms
328+
# (17-20-{21,25}-{22,24,34,38}).
329+
assert dihedrals1.num_functions() - 8 == new_dihedrals1.num_functions()
332330

333331
# The number of impropers should be six fewer at lambda = 1.
334332
assert improper1.num_functions() - 6 == new_improper1.num_functions()
@@ -371,9 +369,6 @@ def test_ejm49_to_ejm31():
371369
(AtomIdx(17), AtomIdx(20), AtomIdx(21), AtomIdx(34)),
372370
(AtomIdx(17), AtomIdx(20), AtomIdx(25), AtomIdx(24)),
373371
(AtomIdx(17), AtomIdx(20), AtomIdx(25), AtomIdx(38)),
374-
# Anchor dihedrals auto-zeroed: atom 20 is ring-constrained.
375-
(AtomIdx(16), AtomIdx(17), AtomIdx(20), AtomIdx(21)),
376-
(AtomIdx(16), AtomIdx(17), AtomIdx(20), AtomIdx(25)),
377372
]
378373

379374
# Check that the missing dihedrals are in the original dihedrals at lambda = 1.
@@ -388,6 +383,16 @@ def test_ejm49_to_ejm31():
388383
for dihedral in missing_dihedrals1
389384
)
390385

386+
# The anchor dihedrals (16-17-20-{21,25}) must survive: they are
387+
# real-real-ghost-ghost and are kept to prevent flapping, even though
388+
# atom 20 lies on a ring within the ghost subgraph.
389+
anchor1 = [
390+
(AtomIdx(16), AtomIdx(17), AtomIdx(20), AtomIdx(21)),
391+
(AtomIdx(16), AtomIdx(17), AtomIdx(20), AtomIdx(25)),
392+
]
393+
394+
assert all(check_dihedral(info, new_dihedrals1.potentials(), *d) for d in anchor1)
395+
391396
# Bridge atom 17 is sp2 and not in a ring. With stiffen_sp2_bridges=False
392397
# (the default), stiffening is skipped to avoid ~30° strain. Check that
393398
# these angles are unchanged at both end states.
@@ -512,10 +517,8 @@ def test_ejm31_to_jmc28():
512517

513518
# At lambda = 0, the cyclopropyl group (atoms 32-42) is appearing (ghost).
514519
# The per-bridge handlers remove five dihedrals; the bridge-extension pass
515-
# removes six more (17-32-33-{34,35,38} and 17-32-34-{33,36,37}); and the
516-
# three anchor dihedrals (16-17-32-{33,34,42}) are auto-zeroed because
517-
# atom 32 lies on a ring in the ghost subgraph.
518-
assert dihedrals0.num_functions() - 14 == new_dihedrals0.num_functions()
520+
# removes six more (17-32-33-{34,35,38} and 17-32-34-{33,36,37}).
521+
assert dihedrals0.num_functions() - 11 == new_dihedrals0.num_functions()
519522

520523
# These six bridge-extension dihedrals should be absent after modification.
521524
bridge_extension0 = [
@@ -537,17 +540,19 @@ def test_ejm31_to_jmc28():
537540
check_dihedral(info, new_dihedrals0.potentials(), *d) for d in bridge_extension0
538541
)
539542

540-
# The anchor dihedrals (16-17-32-{33,34,42}) should also be absent: atom 32
541-
# is ring-constrained so the ring topology already prevents flapping.
543+
# The anchor dihedrals (16-17-32-{33,34,42}) must survive: they are
544+
# real-real-ghost-ghost and are intentionally kept to prevent flapping,
545+
# even though atom 32 lies on a ring (the cyclopropyl) in the ghost
546+
# subgraph. A single-bond attachment to the bridge leaves one rotational
547+
# degree of freedom (rigid rotation of the ring about the bridge-ghost
548+
# bond) that the ring's internal bonds do not constrain.
542549
anchor0 = [
543550
(AtomIdx(16), AtomIdx(17), AtomIdx(32), AtomIdx(33)),
544551
(AtomIdx(16), AtomIdx(17), AtomIdx(32), AtomIdx(34)),
545552
(AtomIdx(16), AtomIdx(17), AtomIdx(32), AtomIdx(42)),
546553
]
547554

548-
assert not any(
549-
check_dihedral(info, new_dihedrals0.potentials(), *d) for d in anchor0
550-
)
555+
assert all(check_dihedral(info, new_dihedrals0.potentials(), *d) for d in anchor0)
551556

552557
# At lambda = 1, the single-carbon group (atom 19) is disappearing (ghost).
553558
# The per-bridge handlers remove five dihedrals; no bridge-extension terms

0 commit comments

Comments
 (0)