Repository: rdkit/rdkit — pull request #7426 (cpp)
Title: Fixes #7367

Fixes #7367

The cause of the issue is simply that we don't consider the `Chem.CXSmilesFields.CX_BOND_ATROPISOMER` SMILES extension when computing the registration hash. Fixing the issue is as simple as adding it to the flags we use.

While working on this, I noticed that `wedgeBondsFromAtropisomers()` is missing a check for ring info, since both `WedgeBondFromAtropisomerOneBond2d()` and `WedgeBondFromAtropisomerOneBond3d` require ring info and call `minBondRingSize()`.



Resolved issues:
title: RegistrationHash.GetMolLayers does not distinguish atropisomers
body: The registration hash should be able to distinguish between atropisomers, but that's currently not the case:

```python3
In [1]: from rdkit import Chem

In [2]: from rdkit.Chem import RegistrationHash

# This is one of the examples from the initial atropisomers PR
In [3]: m = Chem.MolFromMolFile('RP-6306_atrop1.sdf')

In [4]: b = m.GetBondWithIdx(3)

# The atropisomer was correctly identified
In [5]: b.GetStereo()
Out[5]: rdkit.Chem.rdchem.BondStereo.STEREOATROPCW

In [6]: hash1 = RegistrationHash.GetMolLayers(m, enable_tautomer_hash_v2=True)

# No atropisomer info in the hash: for SMILES, it's encoded in the CX extension, which is not part of the hash
In [7]: print(hash1)
{
<HashLayer.CANONICAL_SMILES: 1>: 'Cc1cc2c(C(N)=O)c(N)n(-c3c(C)ccc(O)c3C)c2nc1C', 
<HashLayer.ESCAPE: 2>: '', 
<HashLayer.FORMULA: 3>: 'C18H20N4O2', 
<HashLayer.NO_STEREO_SMILES: 4>: 'Cc1cc2c(C(N)=O)c(N)n(-c3c(C)ccc(O)c3C)c2nc1C', 
<HashLayer.NO_STEREO_TAUTOMER_HASH: 5>: '[C]:[C]1:[N]:[C]2:[C](:[C]:[C]:1-[CH3]):[C](:[C](:[N]):[O]):[C](:[N]):[N]:2:[C]1:[C](-[CH3]):[C]:[C]:[C](:[O]):[C]:1-[CH3]_11_0', 
<HashLayer.SGROUP_DATA: 6>: '[]', 
<HashLayer.TAUTOMER_HASH: 7>: '[C]:[C]1:[N]:[C]2:[C](:[C]:[C]:1-[CH3]):[C](:[C](:[N]):[O]):[C](:[N]):[N]:2:[C]1:[C](-[CH3]):[C]:[C]:[C](:[O]):[C]:1-[CH3]_11_0'
}

# Now, reverse the atropisomer
In [8]: b.SetStereo(Chem.BondStereo.STEREOATROPCCW)

In [9]: hash2 = RegistrationHash.GetMolLayers(m, enable_tautomer_hash_v2=True)

# No atropisomer info either
In [10]: print(hash2)
{
<HashLayer.CANONICAL_SMILES: 1>: 'Cc1cc2c(C(N)=O)c(N)n(-c3c(C)ccc(O)c3C)c2nc1C', 
<HashLayer.ESCAPE: 2>: '', 
<HashLayer.FORMULA: 3>: 'C18H20N4O2', 
<HashLayer.NO_STEREO_SMILES: 4>: 'Cc1cc2c(C(N)=O)c(N)n(-c3c(C)ccc(O)c3C)c2nc1C', 
<HashLayer.NO_STEREO_TAUTOMER_HASH: 5>: '[C]:[C]1:[N]:[C]2:[C](:[C]:[C]:1-[CH3]):[C](:[C](:[N]):[O]):[C](:[N]):[N]:2:[C]1:[C](-[CH3]):[C]:[C]:[C](:[O]):[C]:1-[CH3]_11_0', 
<HashLayer.SGROUP_DATA: 6>: '[]', 
<HashLayer.TAUTOMER_HASH: 7>: '[C]:[C]1:[N]:[C]2:[C](:[C]:[C]:1-[CH3]):[C](:[C](:[N]):[O]):[C](:[N]):[N]:2:[C]1:[C](-[CH3]):[C]:[C]:[C](:[O]):[C]:1-[CH3]_11_0'
}

# We'd want these to be different to deduplicate the atropisomers, but ...
In [11]: hash1 != hash2
Out[11]: False
```

Hints:

Fix patch:
diff --git a/Code/GraphMol/Atropisomers.cpp b/Code/GraphMol/Atropisomers.cpp
index 1ea48d7b588..e82f86bffd4 100644
--- a/Code/GraphMol/Atropisomers.cpp
+++ b/Code/GraphMol/Atropisomers.cpp
@@ -1166,6 +1166,12 @@ void wedgeBondsFromAtropisomers(
         &wedgeBonds) {
   PRECONDITION(conf == nullptr || &(conf->getOwningMol()) == &mol,
                "conformer does not belong to molecule");
+
+  // WedgeBondFromAtropisomerOneBond 2d/3d requires ring bond counts
+  if (!mol.getRingInfo()->isSssrOrBetter()) {
+    RDKit::MolOps::findSSSR(mol);
+  }
+
   for (auto bond : mol.bonds()) {
     auto bondStereo = bond->getStereo();
 
diff --git a/rdkit/Chem/RegistrationHash.py b/rdkit/Chem/RegistrationHash.py
index 23c483d9eb4..0d70cc8e71b 100644
--- a/rdkit/Chem/RegistrationHash.py
+++ b/rdkit/Chem/RegistrationHash.py
@@ -35,7 +35,8 @@
 
 logger = logging.getLogger(__name__)
 
-DEFAULT_CXFLAG = Chem.CXSmilesFields.CX_ATOM_LABELS | Chem.CXSmilesFields.CX_ENHANCEDSTEREO
+DEFAULT_CXFLAG = (Chem.CXSmilesFields.CX_ATOM_LABELS | Chem.CXSmilesFields.CX_ENHANCEDSTEREO
+                  | Chem.CXSmilesFields.CX_BOND_ATROPISOMER)
 
 ENHANCED_STEREO_GROUP_REGEX = re.compile(r'((?:a|[&o]\d+):\d+(?:,\d+)*)')
 


Test patch:
diff --git a/Code/GraphMol/SmilesParse/catch_tests.cpp b/Code/GraphMol/SmilesParse/catch_tests.cpp
index 7495ecb61da..9a74f6b1562 100644
--- a/Code/GraphMol/SmilesParse/catch_tests.cpp
+++ b/Code/GraphMol/SmilesParse/catch_tests.cpp
@@ -2801,9 +2801,34 @@ TEST_CASE("Github #7295") {
     auto smi2 = MolToSmiles(*m);
     CHECK(smi1 == smi2);
     auto m2(*m);
-    bool cleanIt=true;
+    bool cleanIt = true;
     MolOps::assignStereochemistry(m2, cleanIt);
     auto smi3 = MolToSmiles(m2);
     CHECK(smi1 == smi3);
   }
-}
\ No newline at end of file
+}
+
+TEST_CASE("CX_BOND_ATROPISOMER option requires ring info", "[bug][cxsmiles]") {
+  std::string rdbase = getenv("RDBASE");
+  std::string fName =
+      rdbase +
+      "/Code/GraphMol/FileParsers/test_data/atropisomers/RP-6306_atrop1.sdf";
+
+  auto m = v2::FileParsers::MolFromMolFile(fName);
+  REQUIRE(m);
+
+  auto atropBond = m->getBondWithIdx(3);
+  REQUIRE(atropBond->getStereo() == Bond::STEREOATROPCW);
+
+  // Clear ring info to check that atropisomer wedging doesn't fail
+  // if the info is not present
+  bool includeRingInfo = true;
+  m->clearComputedProps(includeRingInfo);
+
+  auto ps = SmilesWriteParams();
+  auto flags = SmilesWrite::CXSmilesFields::CX_BOND_ATROPISOMER;
+
+  // This will fail if there's no ring information
+  auto smi = MolToCXSmiles(*m, ps, flags);
+  CHECK(smi == "Cc1cc2c(C(N)=O)c(N)n(-c3c(C)ccc(O)c3C)c2nc1C |wD:10.9|");
+}
diff --git a/rdkit/Chem/UnitTestRegistrationHash.py b/rdkit/Chem/UnitTestRegistrationHash.py
index 152b7b68f11..de9c13eb394 100644
--- a/rdkit/Chem/UnitTestRegistrationHash.py
+++ b/rdkit/Chem/UnitTestRegistrationHash.py
@@ -5,9 +5,11 @@
 are actually the same.
 """
 
+import os
 import unittest
 
 from rdkit import Chem
+from rdkit import RDConfig
 from rdkit.Chem import RegistrationHash
 from rdkit.Chem.RegistrationHash import HashLayer
 
@@ -806,6 +808,42 @@ def test_tautomer_v2_hash(self):
       RegistrationHash.GetMolHash(
         keto_layers, hash_scheme=RegistrationHash.HashScheme.TAUTOMER_INSENSITIVE_LAYERS))
 
+  def testAtropisomer(self):
+    atropTestFile = os.path.join(
+      RDConfig.RDBaseDir, 'Code/GraphMol/FileParsers/test_data/atropisomers/RP-6306_atrop1.sdf')
+    mol = Chem.MolFromMolFile(atropTestFile)
+
+    bond = mol.GetBondWithIdx(3)
+    self.assertEqual(bond.GetStereo(), Chem.BondStereo.STEREOATROPCW)
+
+    layersCw = RegistrationHash.GetMolLayers(mol)
+
+    smiles1, smiExt1 = layersCw[HashLayer.CANONICAL_SMILES].split()
+    taut1, tautExt1 = layersCw[HashLayer.TAUTOMER_HASH].split()
+    self.assertEqual(smiles1, 'Cc1cc2c(C(N)=O)c(N)n(-c3c(C)ccc(O)c3C)c2nc1C')
+    self.assertEqual(
+      taut1,
+      'C[C]1[CH][C]2[C]([C]([N])[O])[C]([N])N([C]3[C](C)[CH][CH][C]([O])[C]3C)[C]2[N][C]1C_5_0')
+    self.assertEqual(smiExt1, '|wD:10.9|')
+    self.assertEqual(smiExt1, tautExt1)
+
+    # Now look at the other atropisomer
+    bond = mol.GetBondWithIdx(3)
+    bond.SetStereo(Chem.BondStereo.STEREOATROPCCW)
+
+    # Hashes should not match
+    layersCcw = RegistrationHash.GetMolLayers(mol)
+    self.assertNotEqual(layersCw, layersCcw)
+
+    smiles2, smiExt2 = layersCcw[HashLayer.CANONICAL_SMILES].split()
+    taut2, tautExt2 = layersCcw[HashLayer.TAUTOMER_HASH].split()
+
+    # SMILES and tautomer hash should be the same, but the extensions must be different
+    self.assertEqual(smiles2, smiles1)
+    self.assertEqual(taut2, taut1)
+    self.assertEqual(smiExt2, '|wD:10.20|')  # same atom, but "down" wedge on a different bond
+    self.assertEqual(smiExt2, tautExt2)
+
 
 if __name__ == '__main__':  # pragma: nocover
   unittest.main()