Skip to content

Commit dcff2b0

Browse files
authored
Merge pull request #544 from OpenBioSim/feature_mapping_diagnostic
2 parents 73342c7 + 934131b commit dcff2b0

5 files changed

Lines changed: 1008 additions & 9 deletions

File tree

‎src/BioSimSpace/Align/_align.py‎

Lines changed: 264 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -927,6 +927,171 @@ def matchAtoms(
927927
)
928928

929929

930+
def _format_flagged(flagged, max_show=5):
931+
"""
932+
Internal helper to format a list of atom indices for use in a warning
933+
message, truncating so that the message stays readable for large
934+
molecules.
935+
936+
Parameters
937+
----------
938+
939+
flagged : [int]
940+
The indices to format.
941+
942+
max_show : int
943+
The maximum number of indices to show.
944+
945+
Returns
946+
-------
947+
948+
string : str
949+
The formatted indices.
950+
"""
951+
if len(flagged) <= max_show:
952+
return str(flagged)
953+
else:
954+
shown = ", ".join(str(x) for x in flagged[:max_show])
955+
return f"[{shown}, ... ({len(flagged)} in total)]"
956+
957+
958+
def _flag_unmapped_attachments(
959+
molecule0, molecule1, mapping, property_map0={}, property_map1={}
960+
):
961+
"""
962+
Internal function to find mapped atoms that have an unmapped heavy atom
963+
neighbour of a common element in both molecules. These are attachment
964+
points where the MCS stopped on both sides, which usually means that a
965+
pairable atom was missed.
966+
967+
Parameters
968+
----------
969+
970+
molecule0 : :class:`Molecule <BioSimSpace._SireWrappers.Molecule>`
971+
The first molecule.
972+
973+
molecule1 : :class:`Molecule <BioSimSpace._SireWrappers.Molecule>`
974+
The second molecule.
975+
976+
mapping : dict
977+
The atom mapping between the two molecules.
978+
979+
property_map0 : dict
980+
A dictionary that maps "properties" in molecule0 to their user
981+
defined values. This allows the user to refer to properties with
982+
their own naming scheme, e.g. { "charge" : "my-charge" }
983+
984+
property_map1 : dict
985+
A dictionary that maps "properties" in molecule1 to their user
986+
defined values. This allows the user to refer to properties with
987+
their own naming scheme, e.g. { "charge" : "my-charge" }
988+
989+
Returns
990+
-------
991+
992+
flagged : [int]
993+
The indices of the flagged atoms in molecule0.
994+
"""
995+
from sire.legacy import Mol as _SireMol
996+
997+
mol0 = molecule0._getSireObject()
998+
mol1 = molecule1._getSireObject()
999+
1000+
# Build the connectivity explicitly, since the molecules aren't guaranteed
1001+
# to have a stored "connectivity" property.
1002+
conn0 = _SireMol.Connectivity(mol0, _SireMol.CovalentBondHunter())
1003+
conn1 = _SireMol.Connectivity(mol1, _SireMol.CovalentBondHunter())
1004+
1005+
element0 = property_map0.get("element", "element")
1006+
element1 = property_map1.get("element", "element")
1007+
1008+
def _heavy_elements(mol, conn, idx, mapped, element):
1009+
elements = set()
1010+
for i in conn.connections_to(_SireMol.AtomIdx(idx)):
1011+
if i.value() not in mapped:
1012+
protons = mol.atom(i).property(element).num_protons()
1013+
if protons > 1:
1014+
elements.add(protons)
1015+
return elements
1016+
1017+
mapped0 = set(mapping)
1018+
mapped1 = set(mapping.values())
1019+
flagged = []
1020+
1021+
for idx0, idx1 in mapping.items():
1022+
elements0 = _heavy_elements(mol0, conn0, idx0, mapped0, element0)
1023+
if not elements0:
1024+
continue
1025+
elements1 = _heavy_elements(mol1, conn1, idx1, mapped1, element1)
1026+
if elements0 & elements1:
1027+
flagged.append(idx0)
1028+
1029+
return flagged
1030+
1031+
1032+
def _is_sensible_extension(
1033+
molecule0, molecule1, mapping, extended, property_map0={}, property_map1={}
1034+
):
1035+
"""
1036+
Internal function to test whether the atoms that 'extended' adds relative
1037+
to 'mapping' pair like with like. The MCS uses CompareAny, so it is free to
1038+
pair a heavy atom with a hydrogen, which grows the common core without
1039+
improving the mapping.
1040+
1041+
Parameters
1042+
----------
1043+
1044+
molecule0 : :class:`Molecule <BioSimSpace._SireWrappers.Molecule>`
1045+
The first molecule.
1046+
1047+
molecule1 : :class:`Molecule <BioSimSpace._SireWrappers.Molecule>`
1048+
The second molecule.
1049+
1050+
mapping : dict
1051+
The original atom mapping.
1052+
1053+
extended : dict
1054+
The larger atom mapping to test.
1055+
1056+
property_map0 : dict
1057+
A dictionary that maps "properties" in molecule0 to their user
1058+
defined values. This allows the user to refer to properties with
1059+
their own naming scheme, e.g. { "charge" : "my-charge" }
1060+
1061+
property_map1 : dict
1062+
A dictionary that maps "properties" in molecule1 to their user
1063+
defined values. This allows the user to refer to properties with
1064+
their own naming scheme, e.g. { "charge" : "my-charge" }
1065+
1066+
Returns
1067+
-------
1068+
1069+
is_sensible : bool
1070+
Whether the added atoms pair heavy with heavy and hydrogen with
1071+
hydrogen.
1072+
"""
1073+
from sire.legacy import Mol as _SireMol
1074+
1075+
mol0 = molecule0._getSireObject()
1076+
mol1 = molecule1._getSireObject()
1077+
1078+
element0 = property_map0.get("element", "element")
1079+
element1 = property_map1.get("element", "element")
1080+
1081+
for idx0, idx1 in extended.items():
1082+
if idx0 not in mapping:
1083+
protons0 = (
1084+
mol0.atom(_SireMol.AtomIdx(idx0)).property(element0).num_protons()
1085+
)
1086+
protons1 = (
1087+
mol1.atom(_SireMol.AtomIdx(idx1)).property(element1).num_protons()
1088+
)
1089+
if (protons0 > 1) != (protons1 > 1):
1090+
return False
1091+
1092+
return True
1093+
1094+
9301095
def _matchAtoms(
9311096
molecule0,
9321097
molecule1,
@@ -937,13 +1102,14 @@ def _matchAtoms(
9371102
timeout=5 * _Units.Time.second,
9381103
complete_rings_only=True,
9391104
max_scoring_matches=1000,
940-
roi=None,
9411105
prune_perturbed_constraints=False,
9421106
prune_crossing_constraints=False,
9431107
prune_atom_types=False,
9441108
property_map0={},
9451109
property_map1={},
9461110
mcs_kwargs={},
1111+
*,
1112+
_check_mapping=True,
9471113
):
9481114
import sys as _sys
9491115

@@ -1035,7 +1201,10 @@ def _matchAtoms(
10351201
mol0 = molecule0._getSireObject()
10361202
mol1 = molecule1._getSireObject()
10371203

1038-
# Convert the timeout to seconds and take the value as an integer.
1204+
# Convert the timeout to seconds and take the value as an integer. Keep
1205+
# the original, since the mapping check below re-enters this function,
1206+
# which expects a Time object.
1207+
orig_timeout = timeout
10391208
timeout = int(timeout.seconds().value())
10401209

10411210
# Use RDKkit to find the maximum common substructure.
@@ -1153,6 +1322,87 @@ def _matchAtoms(
11531322
property_map1,
11541323
)
11551324

1325+
# Warn if the mapping stopped short at an attachment point where a pairable
1326+
# atom exists. This is done before the pruning below, since pruning deletes
1327+
# correctly mapped heavy atom pairs, which manufactures exactly the
1328+
# signature that the check looks for. Only check a mapping generated from
1329+
# the defaults. If the user has configured the MCS then both the baseline
1330+
# and our idea of a sensible mapping may not match their intent. This also
1331+
# stops the retry from recursing, since it passes 'mcs_kwargs'. Skip when a
1332+
# prematch is given, since the retry could then fall back on Sire MCS,
1333+
# which ignores 'mcs_kwargs', making the comparison meaningless.
1334+
if _check_mapping and not mcs_kwargs and not prematch and mappings:
1335+
# This is a diagnostic, so a failure inside it is reported rather than
1336+
# raised. Note that this only holds while warnings are warnings: if the
1337+
# user has promoted them to errors then either notice below will raise
1338+
# out of here. The warning itself is emitted outside the guard, since
1339+
# it would otherwise be swallowed and re-reported as a failure.
1340+
message = None
1341+
try:
1342+
best = mappings[0]
1343+
1344+
# Attachment points where the MCS stopped on both sides.
1345+
flagged = _flag_unmapped_attachments(
1346+
molecule0, molecule1, best, property_map0, property_map1
1347+
)
1348+
1349+
if flagged:
1350+
# Retry with ring matching relaxed to see if it does better.
1351+
# Note that the RDKit documentation implies that
1352+
# 'completeRingsOnly' forces 'ringMatchesRingOnly', which would
1353+
# make this a no-op. It doesn't: as of RDKit 2026.03.4 the
1354+
# relaxed search still returns a larger MCS with
1355+
# 'completeRingsOnly' enabled. If a future RDKit changes this,
1356+
# the feature will silently stop firing.
1357+
#
1358+
# Pruning is disabled so that the retry is compared like for
1359+
# like against the unpruned mapping above. 'matches' and
1360+
# 'return_scores' are passed explicitly so that 'retry' is
1361+
# always a plain dict, which the comparison below relies on.
1362+
# 'prematch' is omitted since the enclosing guard means it's
1363+
# always empty.
1364+
retry = _matchAtoms(
1365+
molecule0=molecule0,
1366+
molecule1=molecule1,
1367+
scoring_function=scoring_function,
1368+
matches=1,
1369+
return_scores=False,
1370+
timeout=orig_timeout,
1371+
complete_rings_only=complete_rings_only,
1372+
max_scoring_matches=max_scoring_matches,
1373+
prune_perturbed_constraints=False,
1374+
prune_crossing_constraints=False,
1375+
prune_atom_types=False,
1376+
property_map0=property_map0,
1377+
property_map1=property_map1,
1378+
mcs_kwargs={"ringMatchesRingOnly": False},
1379+
_check_mapping=False,
1380+
)
1381+
1382+
# Only trust the retry if it extends the mapping, i.e. keeps
1383+
# every existing pair and adds sensible ones. The subset test is
1384+
# sensitive to relabelling: an equivalent mapping that traverses
1385+
# a ring the other way, or permutes hydrogens, is discarded.
1386+
if (
1387+
len(retry) > len(best)
1388+
and set(best.items()) <= set(retry.items())
1389+
and _is_sensible_extension(
1390+
molecule0, molecule1, best, retry, property_map0, property_map1
1391+
)
1392+
):
1393+
message = (
1394+
f"Mapping leaves heavy atoms unmapped on both sides "
1395+
f"of atom(s) {_format_flagged(flagged)} in molecule0. "
1396+
f"Relaxing 'ringMatchesRingOnly' gives a common core "
1397+
f"of {len(retry)} rather than {len(best)}. Consider "
1398+
f"passing mcs_kwargs={{'ringMatchesRingOnly': False}}."
1399+
)
1400+
except Exception as e:
1401+
_warnings.warn(f"Unable to check the quality of the mapping: {e}")
1402+
1403+
if message is not None:
1404+
_warnings.warn(message)
1405+
11561406
# Optionally post-process the MCS for use with AMBER.
11571407
if prune_perturbed_constraints:
11581408
mappings = [
@@ -1490,9 +1740,12 @@ def _roiMatch(
14901740
)
14911741
mapping = None
14921742
else:
1493-
mapping = matchAtoms(
1743+
mapping = _matchAtoms(
14941744
res0_extracted,
14951745
res1_extracted,
1746+
# The mapping check would report indices that are local to the
1747+
# extracted residue, not to molecule0 as its message claims.
1748+
_check_mapping=False,
14961749
)
14971750

14981751
# Look up the absolute atom indices in the molecule if not using a custom ROI mapping.
@@ -1711,11 +1964,14 @@ def _rmsdAlign(molecule0, molecule1, mapping=None, property_map0={}, property_ma
17111964

17121965
# Get the best match atom mapping.
17131966
else:
1714-
mapping = matchAtoms(
1967+
mapping = _matchAtoms(
17151968
molecule0,
17161969
molecule1,
17171970
property_map0=property_map0,
17181971
property_map1=property_map1,
1972+
# This function doesn't take 'mcs_kwargs', so the advice from the
1973+
# mapping check can't be acted on here.
1974+
_check_mapping=False,
17191975
)
17201976

17211977
# Extract the Sire molecule from each BioSimSpace molecule.
@@ -1913,11 +2169,14 @@ def _flexAlign(
19132169

19142170
# Get the best match atom mapping.
19152171
else:
1916-
mapping = matchAtoms(
2172+
mapping = _matchAtoms(
19172173
molecule0,
19182174
molecule1,
19192175
property_map0=property_map0,
19202176
property_map1=property_map1,
2177+
# This function doesn't take 'mcs_kwargs', so the advice from the
2178+
# mapping check can't be acted on here.
2179+
_check_mapping=False,
19212180
)
19222181

19232182
# Convert the mapping to AtomIdx key:value pairs.

‎src/BioSimSpace/FreeEnergy/_atm.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -593,7 +593,10 @@ def _makeSystemFromThree(protein, ligand_bound, ligand_free, displacement):
593593
BioSimSpace._SireWrappers.System
594594
The system for the ATM simulation.
595595
"""
596-
from ..Align import matchAtoms as _matchAtoms
596+
# The private form is used so that the mapping check can be switched
597+
# off below. It is otherwise identical to 'matchAtoms', which is a
598+
# dispatcher on 'roi' and is never given one here.
599+
from ..Align._align import _matchAtoms
597600
from ..Align import rmsdAlign as _rmsdAlign
598601
from ..Types import Vector as _Vector
599602

@@ -683,7 +686,9 @@ def _findTranslationVector(system, displacement, protein, ligand):
683686
out_of_protein = displacement.value() * initial_normal_vector
684687
return out_of_protein
685688

686-
mapping = _matchAtoms(ligand_free, ligand_bound)
689+
# ATM doesn't expose 'mcs_kwargs', nor a way to pass in a mapping, so
690+
# the advice from the mapping check can't be acted on here.
691+
mapping = _matchAtoms(ligand_free, ligand_bound, _check_mapping=False)
687692
ligand_free_aligned = _rmsdAlign(ligand_free, ligand_bound, mapping)
688693
prot_lig1 = (protein + ligand_bound).toSystem()
689694

0 commit comments

Comments
 (0)