Skip to content

Commit 85d0f7c

Browse files
committed
Fix side-effects of scoring and bridge exclusion updates.
1 parent 5963dcc commit 85d0f7c

1 file changed

Lines changed: 66 additions & 19 deletions

File tree

src/ghostly/_ghostly.py

Lines changed: 66 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -208,25 +208,33 @@ def modify(
208208
bridges0[c].append(ghost)
209209
# Work out the indices of the other physical atoms that are connected to
210210
# the bridge atoms, sorted by the atom index. These are "core" physical
211-
# atoms, i.e. they are physical in both end states and are not
212-
# themselves bridge atoms. Other bridges are not stable anchors for
213-
# junction classification since they have their own ghost connections.
211+
# atoms, i.e. they are physical in both end states. Bridge atoms that
212+
# share a ghost with the current bridge are excluded: they couple
213+
# through the shared ghost and are not independent anchors. Bridge atoms
214+
# that do NOT share a ghost are retained since they anchor independent
215+
# ghost groups and are needed for proper junction classification.
214216
physical0 = {}
215-
bridge_idx_set0 = set(b.value() for b in bridges0)
216217
for b in bridges0:
218+
# Build the set of bridge atoms that share a ghost with b.
219+
shared_ghost_bridges = set()
220+
for b2, ghosts2 in bridges0.items():
221+
if b2 != b and set(bridges0[b]) & set(ghosts2):
222+
shared_ghost_bridges.add(b2.value())
217223
all_phys = []
218-
non_bridge_phys = []
224+
non_shared_bridge_phys = []
219225
for c in connectivity0.connections_to(b):
220226
if (
221227
not _is_ghost(mol, [c])[0]
222228
and not _is_ghost(mol, [c], is_lambda1=True)[0]
223229
):
224230
all_phys.append(c)
225-
if c.value() not in bridge_idx_set0:
226-
non_bridge_phys.append(c)
227-
# Prefer non-bridge physical neighbours, but fall back to
228-
# including bridges if they are the only anchors available.
229-
physical0[b] = non_bridge_phys if non_bridge_phys else all_phys
231+
if c.value() not in shared_ghost_bridges:
232+
non_shared_bridge_phys.append(c)
233+
# Prefer excluding shared-ghost bridges, but fall back to
234+
# including them if they are the only anchors available.
235+
physical0[b] = (
236+
non_shared_bridge_phys if non_shared_bridge_phys else all_phys
237+
)
230238
for b in physical0:
231239
physical0[b].sort(key=lambda x: x.value())
232240

@@ -240,19 +248,24 @@ def modify(
240248
else:
241249
bridges1[c].append(ghost)
242250
physical1 = {}
243-
bridge_idx_set1 = set(b.value() for b in bridges1)
244251
for b in bridges1:
252+
shared_ghost_bridges = set()
253+
for b2, ghosts2 in bridges1.items():
254+
if b2 != b and set(bridges1[b]) & set(ghosts2):
255+
shared_ghost_bridges.add(b2.value())
245256
all_phys = []
246-
non_bridge_phys = []
257+
non_shared_bridge_phys = []
247258
for c in connectivity1.connections_to(b):
248259
if (
249260
not _is_ghost(mol, [c])[0]
250261
and not _is_ghost(mol, [c], is_lambda1=True)[0]
251262
):
252263
all_phys.append(c)
253-
if c.value() not in bridge_idx_set1:
254-
non_bridge_phys.append(c)
255-
physical1[b] = non_bridge_phys if non_bridge_phys else all_phys
264+
if c.value() not in shared_ghost_bridges:
265+
non_shared_bridge_phys.append(c)
266+
physical1[b] = (
267+
non_shared_bridge_phys if non_shared_bridge_phys else all_phys
268+
)
256269
for b in physical1:
257270
physical1[b].sort(key=lambda x: x.value())
258271

@@ -836,9 +849,21 @@ def _dual(
836849
if bridge_indices is not None:
837850
phys_scores = {p: _score_atom(mol, p, bridge_indices) for p in physical}
838851
best_phys_score = min(phys_scores.values())
852+
853+
# Determine which physical atoms are heavy (non-hydrogen at either end state).
854+
# We only skip a poorly-scoring atom when at least one heavy atom remains
855+
# to provide an adequate structural anchor for ghost orientation.
856+
def _is_heavy(atom_idx):
857+
a = mol.atom(atom_idx)
858+
e0 = a.property("element0").symbol()
859+
e1 = a.property("element1").symbol()
860+
return (e0 not in ("H", "Xx")) or (e1 not in ("H", "Xx"))
861+
862+
heavy_phys = {p for p in physical if _is_heavy(p)}
839863
else:
840864
phys_scores = None
841865
best_phys_score = 0
866+
heavy_phys = set(physical)
842867

843868
# Choose the force constant for angle stiffening.
844869
k = k_hard_ring if bridge_in_ring else k_hard
@@ -907,8 +932,15 @@ def _dual(
907932
# Identify the physical atom in this angle.
908933
phys_atom = idx2 if idx0 in ghosts else idx0
909934

910-
# Skip stiffening through poorly-scoring atoms if better ones exist.
911-
if phys_scores is not None and phys_scores[phys_atom] > best_phys_score:
935+
# Skip stiffening through poorly-scoring atoms if better ones
936+
# exist AND at least one heavy atom would remain as anchor.
937+
# Without a heavy anchor, the ghost is under-constrained.
938+
other_heavy = heavy_phys - {phys_atom}
939+
if (
940+
phys_scores is not None
941+
and phys_scores[phys_atom] > best_phys_score
942+
and other_heavy
943+
):
912944
new_angles.set(idx0, idx1, idx2, p.function())
913945
_logger.debug(
914946
f" Skipping stiffening for angle "
@@ -1166,9 +1198,18 @@ def _triple(
11661198
if bridge_indices is not None:
11671199
phys_scores = {p: _score_atom(mol, p, bridge_indices) for p in physical}
11681200
best_phys_score = min(phys_scores.values())
1201+
1202+
def _is_heavy(atom_idx):
1203+
a = mol.atom(atom_idx)
1204+
e0 = a.property("element0").symbol()
1205+
e1 = a.property("element1").symbol()
1206+
return (e0 not in ("H", "Xx")) or (e1 not in ("H", "Xx"))
1207+
1208+
heavy_phys = {p for p in physical if _is_heavy(p)}
11691209
else:
11701210
phys_scores = None
11711211
best_phys_score = 0
1212+
heavy_phys = set(physical)
11721213

11731214
# Non-planar junction.
11741215
if hybridisation in (
@@ -1204,8 +1245,14 @@ def _triple(
12041245
# Identify the physical atom in this angle.
12051246
phys_atom = idx2 if idx0 in ghosts else idx0
12061247

1207-
# Skip softening through poorly-scoring atoms if better ones exist.
1208-
if phys_scores is not None and phys_scores[phys_atom] > best_phys_score:
1248+
# Skip softening through poorly-scoring atoms if better ones
1249+
# exist AND at least one heavy atom would remain as anchor.
1250+
other_heavy = heavy_phys - {phys_atom}
1251+
if (
1252+
phys_scores is not None
1253+
and phys_scores[phys_atom] > best_phys_score
1254+
and other_heavy
1255+
):
12091256
new_angles.set(idx0, idx1, idx2, p.function())
12101257
_logger.debug(
12111258
f" Skipping softening for angle "

0 commit comments

Comments
 (0)