@@ -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+
9301095def _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.
0 commit comments