Repository: rdkit/rdkit — pull request #4248 (cpp)
Title: Fixes #3998

In order to fix this problem I needed to add a function which kekulizes a fragment of a molecule instead of the whole thing.

That new function is currently in the `MolOps::details` namespace since I really don't think it should be used from other code until we've tested it more thoroughly and are sure we're happy with the behavior.

Resolved issues:
title: MolFragmentToSmiles with kekuleSmiles=True raises AtomKekulizeException
body: **Describe the bug**
Release 2021_03_1 raises `AtomKekulizeException` by the following code.

**To Reproduce**
```python
from rdkit import Chem
mol = Chem.MolFromSmiles("CC(c1ccccc1)")
Chem.MolFragmentToSmiles(mol, [0], kekuleSmiles=False)
Chem.MolFragmentToSmiles(mol, [2,3,4,5,6,7], kekuleSmiles=False)
Chem.MolFragmentToSmiles(mol, [0], kekuleSmiles=True)  # Error is raised here
Chem.MolFragmentToSmiles(mol, [2,3,4,5,6,7], kekuleSmiles=True)
```

**Expected behavior**
```
'C'
'c1ccccc1'
'C'
'C1=CC=CC=C1'
```

**Screenshots**
```python
>>> import rdkit
>>> rdkit.__version__
'2021.03.1'
>>> from rdkit import Chem
>>> mol = Chem.MolFromSmiles("CC(c1ccccc1)")
>>> Chem.MolFragmentToSmiles(mol, [0], kekuleSmiles=False)
'C'
>>> Chem.MolFragmentToSmiles(mol, [2,3,4,5,6,7], kekuleSmiles=False)
'c1ccccc1'
>>> Chem.MolFragmentToSmiles(mol, [0], kekuleSmiles=True)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
rdkit.Chem.rdchem.AtomKekulizeException: non-ring atom 2 marked aromatic
>>> Chem.MolFragmentToSmiles(mol, [2,3,4,5,6,7], kekuleSmiles=True)
'C1=CC=CC=C1'
```

**Configuration (please complete the following information):**
 - RDKit version: 2021.03.1 
 - OS: Windows 10
 - Python version (if relevant): 3.8.5
 - Are you using conda? : Yes
 - If you are using conda, which channel did you install the rdkit from?: conda-forge
 - If you are not using conda: how did you install the RDKit? : conda install rdkit==2021.03.1

**Additional context**
The result with Release_2020_09_5 is below.
```python
>>> import rdkit
>>> rdkit.__version__
'2020.09.5'
>>> from rdkit import Chem
>>> mol = Chem.MolFromSmiles("CC(c1ccccc1)")
>>> Chem.MolFragmentToSmiles(mol, [0], kekuleSmiles=False)
'C'
>>> Chem.MolFragmentToSmiles(mol, [2,3,4,5,6,7], kekuleSmiles=False)
'c1ccccc1'
>>> Chem.MolFragmentToSmiles(mol, [0], kekuleSmiles=True)
'C'
>>> Chem.MolFragmentToSmiles(mol, [2,3,4,5,6,7], kekuleSmiles=True)
'C1:C:C:C:C:C:1'
```


Hints:

Fix patch:
diff --git a/Code/GraphMol/Kekulize.cpp b/Code/GraphMol/Kekulize.cpp
index b20429ea16e..a4da0686cf4 100644
--- a/Code/GraphMol/Kekulize.cpp
+++ b/Code/GraphMol/Kekulize.cpp
@@ -495,28 +495,42 @@ void kekulizeFused(RWMol &mol, const VECT_INT_VECT &arings,
 }  // namespace
 
 namespace MolOps {
-void Kekulize(RWMol &mol, bool markAtomsBonds, unsigned int maxBackTracks) {
+namespace details {
+void KekulizeFragment(RWMol &mol, const boost::dynamic_bitset<> &atomsToUse,
+                      const boost::dynamic_bitset<> &bondsToUse,
+                      bool markAtomsBonds, unsigned int maxBackTracks) {
+  PRECONDITION(atomsToUse.size() == mol.getNumAtoms(),
+               "atomsToUse is wrong size");
+  PRECONDITION(bondsToUse.size() == mol.getNumBonds(),
+               "bondsToUse is wrong size");
+
   // there's no point doing kekulization if there are no aromatic bonds:
   bool foundAromatic = false;
-  for (ROMol::BondIterator bi = mol.beginBonds();
-       bi != mol.endBonds() && !foundAromatic; ++bi) {
-    if ((*bi)->getIsAromatic()) {
+  for (const auto bond : mol.bonds()) {
+    if (bondsToUse[bond->getIdx()] && bond->getIsAromatic()) {
       foundAromatic = true;
+      break;
     }
   }
 
   // before everything do implicit valence calculation and store them
   // we will repeat after kekulization and compare for the sake of error
   // checking
-  INT_VECT valences;
   int numAtoms = mol.getNumAtoms();
-  valences.reserve(numAtoms);
-  for (ROMol::AtomIterator ai = mol.beginAtoms(); ai != mol.endAtoms(); ++ai) {
-    (*ai)->calcImplicitValence(false);
-    valences.push_back((*ai)->getTotalValence());
-    if (!foundAromatic && (*ai)->getIsAromatic()) {
+  INT_VECT valences(numAtoms);
+  boost::dynamic_bitset<> dummyAts(mol.getNumAtoms());
+  for (auto atom : mol.atoms()) {
+    if (!atomsToUse[atom->getIdx()]) {
+      continue;
+    }
+    atom->calcImplicitValence(false);
+    valences[atom->getIdx()] = atom->getTotalValence();
+    if (atom->getIsAromatic()) {
       foundAromatic = true;
     }
+    if (!atom->getAtomicNum()) {
+      dummyAts[atom->getIdx()] = 1;
+    }
   }
   if (!foundAromatic) {
     return;
@@ -533,41 +547,46 @@ void Kekulize(RWMol &mol, bool markAtomsBonds, unsigned int maxBackTracks) {
 
   // first find the all the simple rings in the molecule that are not
   // completely composed of dummy atoms
-  VECT_INT_VECT arings;
-  boost::dynamic_bitset<> dummyAts(mol.getNumAtoms());
-  for (ROMol::AtomIterator atit = mol.beginAtoms(); atit != mol.endAtoms();
-       ++atit) {
-    if (!(*atit)->getAtomicNum()) {
-      dummyAts[(*atit)->getIdx()] = 1;
-    }
+  VECT_INT_VECT allrings;
+  if (mol.getRingInfo()->isInitialized()) {
+    allrings = mol.getRingInfo()->atomRings();
+  } else {
+    MolOps::findSSSR(mol, allrings);
   }
-  if (dummyAts.any()) {
-    VECT_INT_VECT allrings;
-    if (mol.getRingInfo()->isInitialized()) {
-      allrings = mol.getRingInfo()->atomRings();
-    } else {
-      MolOps::findSSSR(mol, allrings);
-    }
-    arings.reserve(allrings.size());
-    for (auto &ring : allrings) {
-      for (auto ai : ring) {
-        if (!dummyAts[ai]) {
-          arings.push_back(ring);
-          break;
-        }
+  VECT_INT_VECT arings;
+  arings.reserve(allrings.size());
+  for (auto &ring : allrings) {
+    bool ringOk = false;
+    for (auto ai : ring) {
+      if (!atomsToUse[ai]) {
+        ringOk = false;
+        break;
+      }
+      if (!dummyAts[ai]) {
+        ringOk = true;
       }
     }
-  } else {
-    if (mol.getRingInfo()->isInitialized()) {
-      arings = mol.getRingInfo()->atomRings();
-    } else {
-      MolOps::findSSSR(mol, arings);
+    if (ringOk) {
+      arings.push_back(ring);
     }
   }
 
+  VECT_INT_VECT allbrings;
+  RingUtils::convertToBonds(arings, allbrings, mol);
   VECT_INT_VECT brings;
-  // brings = mol.getRingInfo()->bondRings();
-  RingUtils::convertToBonds(arings, brings, mol);
+  brings.reserve(allbrings.size());
+  for (auto &ring : allbrings) {
+    bool ringOk = true;
+    for (auto bi : ring) {
+      if (!bondsToUse[bi]) {
+        ringOk = false;
+        break;
+      }
+    }
+    if (ringOk) {
+      brings.push_back(ring);
+    }
+  }
 
   // make a neighbor map for the rings i.e. a ring is a
   // neighbor to another candidate ring if it shares at least
@@ -602,28 +621,31 @@ void Kekulize(RWMol &mol, bool markAtomsBonds, unsigned int maxBackTracks) {
   if (markAtomsBonds) {
     // if we want the atoms and bonds to be marked non-aromatic do
     // that here.
-    for (ROMol::BondIterator bi = mol.beginBonds(); bi != mol.endBonds();
-         ++bi) {
-      (*bi)->setIsAromatic(false);
+    for (auto bond : mol.bonds()) {
+      if (bondsToUse[bond->getIdx()]) {
+        bond->setIsAromatic(false);
+      }
     }
-    for (ROMol::AtomIterator ai = mol.beginAtoms(); ai != mol.endAtoms();
-         ++ai) {
-      if ((*ai)->getIsAromatic()) {
-        if (!mol.getRingInfo()->numAtomRings((*ai)->getIdx())) {
+    for (auto atom : mol.atoms()) {
+      if (atomsToUse[atom->getIdx()] && atom->getIsAromatic()) {
+        // if we're doing the full molecule and there are aromatic atoms not in
+        // a ring, throw an exception
+        if (atomsToUse.all() && bondsToUse.all() &&
+            !mol.getRingInfo()->numAtomRings(atom->getIdx())) {
           std::ostringstream errout;
-          errout << "non-ring atom " << (*ai)->getIdx() << " marked aromatic";
+          errout << "non-ring atom " << atom->getIdx() << " marked aromatic";
           std::string msg = errout.str();
           BOOST_LOG(rdErrorLog) << msg << std::endl;
-          throw AtomKekulizeException(msg, (*ai)->getIdx());
+          throw AtomKekulizeException(msg, atom->getIdx());
         }
-        (*ai)->setIsAromatic(false);
+        atom->setIsAromatic(false);
         // make sure "explicit" Hs on things like pyrroles don't hang around
         // this was Github Issue 141
-        if (((*ai)->getAtomicNum() == 7 || (*ai)->getAtomicNum() == 15) &&
-            (*ai)->getFormalCharge() == 0 && (*ai)->getNumExplicitHs() == 1) {
-          (*ai)->setNoImplicit(false);
-          (*ai)->setNumExplicitHs(0);
-          (*ai)->updatePropertyCache(false);
+        if ((atom->getAtomicNum() == 7 || atom->getAtomicNum() == 15) &&
+            atom->getFormalCharge() == 0 && atom->getNumExplicitHs() == 1) {
+          atom->setNoImplicit(false);
+          atom->setNumExplicitHs(0);
+          atom->updatePropertyCache(false);
         }
       }
     }
@@ -632,19 +654,29 @@ void Kekulize(RWMol &mol, bool markAtomsBonds, unsigned int maxBackTracks) {
   // ok some error checking here force a implicit valence
   // calculation that should do some error checking by itself. In
   // addition compare them to what they were before kekulizing
-  int i = 0;
-  for (ROMol::AtomIterator ai = mol.beginAtoms(); ai != mol.endAtoms(); ++ai) {
-    int val = (*ai)->getTotalValence();
-    if (val != valences[i]) {
+  for (auto atom : mol.atoms()) {
+    if (!atomsToUse[atom->getIdx()]) {
+      continue;
+    }
+    int val = atom->getTotalValence();
+    if (val != valences[atom->getIdx()]) {
       std::ostringstream errout;
-      errout << "Kekulization somehow screwed up valence on " << (*ai)->getIdx()
-             << ": " << val << "!=" << valences[i] << std::endl;
+      errout << "Kekulization somehow screwed up valence on " << atom->getIdx()
+             << ": " << val << "!=" << valences[atom->getIdx()] << std::endl;
       std::string msg = errout.str();
       BOOST_LOG(rdErrorLog) << msg << std::endl;
-      throw AtomKekulizeException(msg, (*ai)->getIdx());
+      throw AtomKekulizeException(msg, atom->getIdx());
     }
-    i++;
   }
 }
-}  // end of namespace MolOps
-}  // end of namespace RDKit
+}  // namespace details
+void Kekulize(RWMol &mol, bool markAtomsBonds, unsigned int maxBackTracks) {
+  boost::dynamic_bitset<> atomsToUse(mol.getNumAtoms());
+  atomsToUse.set();
+  boost::dynamic_bitset<> bondsToUse(mol.getNumBonds());
+  bondsToUse.set();
+  details::KekulizeFragment(mol, atomsToUse, bondsToUse, markAtomsBonds,
+                            maxBackTracks);
+}
+}  // namespace MolOps
+}  // namespace RDKit
diff --git a/Code/GraphMol/MolOps.h b/Code/GraphMol/MolOps.h
index ecf07fdf7be..6a7bf3d7d9f 100644
--- a/Code/GraphMol/MolOps.h
+++ b/Code/GraphMol/MolOps.h
@@ -998,7 +998,15 @@ RDKIT_GRAPHMOL_EXPORT unsigned getNumAtomsWithDistinctProperty(
 //! returns whether or not a molecule needs to have Hs added to it.
 RDKIT_GRAPHMOL_EXPORT bool needsHs(const ROMol &mol);
 
-};  // end of namespace MolOps
-};  // end of namespace RDKit
+namespace details {
+//! not recommended for use in other code
+RDKIT_GRAPHMOL_EXPORT void KekulizeFragment(
+    RWMol &mol, const boost::dynamic_bitset<> &atomsToUse,
+    const boost::dynamic_bitset<> &bondsToUse, bool markAtomsBonds = true,
+    unsigned int maxBackTracks = 100);
+}  // namespace details
+
+}  // namespace MolOps
+}  // namespace RDKit
 
 #endif
diff --git a/Code/GraphMol/SmilesParse/SmilesWrite.cpp b/Code/GraphMol/SmilesParse/SmilesWrite.cpp
index fe8f331a51c..1931584c2ab 100644
--- a/Code/GraphMol/SmilesParse/SmilesWrite.cpp
+++ b/Code/GraphMol/SmilesParse/SmilesWrite.cpp
@@ -294,9 +294,12 @@ std::string FragmentSmilesConstruct(
     const UINT_VECT &ranks, bool doKekule, bool canonical,
     bool doIsomericSmiles, bool allBondsExplicit, bool allHsExplicit,
     bool doRandom, std::vector<unsigned int> &atomOrdering,
+    const boost::dynamic_bitset<> *atomsInPlay = nullptr,
     const boost::dynamic_bitset<> *bondsInPlay = nullptr,
     const std::vector<std::string> *atomSymbols = nullptr,
     const std::vector<std::string> *bondSymbols = nullptr) {
+  PRECONDITION(!atomsInPlay || atomsInPlay->size() >= mol.getNumAtoms(),
+               "bad atomsInPlay");
   PRECONDITION(!bondsInPlay || bondsInPlay->size() >= mol.getNumBonds(),
                "bad bondsInPlay");
   PRECONDITION(!atomSymbols || atomSymbols->size() >= mol.getNumAtoms(),
@@ -304,7 +307,12 @@ std::string FragmentSmilesConstruct(
   PRECONDITION(!bondSymbols || bondSymbols->size() >= mol.getNumBonds(),
                "bad bondSymbols");
   if (doKekule) {
-    MolOps::Kekulize(static_cast<RWMol &>(mol));
+    if (atomsInPlay && bondsInPlay) {
+      MolOps::details::KekulizeFragment(static_cast<RWMol &>(mol), *atomsInPlay,
+                                        *bondsInPlay);
+    } else {
+      MolOps::Kekulize(static_cast<RWMol &>(mol));
+    }
   }
 
   Canon::MolStack molStack;
@@ -750,8 +758,8 @@ std::string MolFragmentToSmiles(const ROMol &mol,
     bool doRandom = false;
     auto subSmi = SmilesWrite::FragmentSmilesConstruct(
         tmol, nextAtomIdx, colors, ranks, doKekule, canonical, doIsomericSmiles,
-        allBondsExplicit, allHsExplicit, doRandom, atomOrdering, &bondsInPlay,
-        atomSymbols, bondSymbols);
+        allBondsExplicit, allHsExplicit, doRandom, atomOrdering, &atomsInPlay,
+        &bondsInPlay, atomSymbols, bondSymbols);
 
     res += subSmi;
     colorIt = std::find(colors.begin(), colors.end(), Canon::WHITE_NODE);
diff --git a/Code/GraphMol/catch_graphmol.cpp b/Code/GraphMol/catch_graphmol.cpp
index d7ec1804699..09ee1716e6d 100644
--- a/Code/GraphMol/catch_graphmol.cpp
+++ b/Code/GraphMol/catch_graphmol.cpp
@@ -2046,3 +2046,27 @@ TEST_CASE(
   CHECK_THROWS_AS(conf.setAtomPos(std::numeric_limits<unsigned>::max(), pt),
                   ValueErrorException);
 }
+
+TEST_CASE("KekulizeFragment", "[graphmol]") {
+  SECTION("basics") {
+    auto mol = "CCc1ccccc1"_smiles;
+    REQUIRE(mol);
+    boost::dynamic_bitset<> atomsInPlay(mol->getNumAtoms());
+    for (auto aidx : std::vector<size_t>{0, 1, 2, 3}) {
+      atomsInPlay.set(aidx);
+    }
+    boost::dynamic_bitset<> bondsInPlay(mol->getNumBonds());
+    for (auto bidx : std::vector<size_t>{0, 1, 2}) {
+      bondsInPlay.set(bidx);
+    }
+    MolOps::details::KekulizeFragment(*mol, atomsInPlay, bondsInPlay);
+    CHECK(!mol->getAtomWithIdx(2)->getIsAromatic());
+    CHECK(mol->getAtomWithIdx(4)->getIsAromatic());
+    CHECK(!mol->getBondWithIdx(2)->getIsAromatic());
+    // at the moment that bond still has an aromatic bond order, which isn't
+    // optimal, but that will have to wait until we add a feature to allow
+    // kekulization of conjugated chains.
+    CHECK(mol->getBondWithIdx(2)->getBondType() == Bond::AROMATIC);
+    CHECK(mol->getBondWithIdx(4)->getIsAromatic());
+  }
+}


Test patch:
diff --git a/Code/GraphMol/SmilesParse/catch_tests.cpp b/Code/GraphMol/SmilesParse/catch_tests.cpp
index 67807a0ccdc..821754f58b3 100644
--- a/Code/GraphMol/SmilesParse/catch_tests.cpp
+++ b/Code/GraphMol/SmilesParse/catch_tests.cpp
@@ -855,4 +855,32 @@ TEST_CASE("Github #2788: doKekule=true should kekulize the molecule",
 TEST_CASE("bogus recursive SMARTS", "[smarts]") {
   std::string sma = "C)foo";
   CHECK(SmartsToMol(sma) == nullptr);
-}
\ No newline at end of file
+}
+
+TEST_CASE(
+    "Github #3998 MolFragmentToSmiles failing in Kekulization with "
+    "kekuleSmiles=true") {
+  auto mol = "Cc1ccccc1"_smiles;
+  REQUIRE(mol);
+  SECTION("normal") {
+    std::vector<int> ats{0};
+    std::string smi = MolFragmentToSmiles(*mol, ats);
+    CHECK(smi == "C");
+  }
+  SECTION("kekulized") {
+    std::vector<int> ats{0};
+    bool doIsomericSmiles = true;
+    bool doKekule = true;
+    std::string smi = MolFragmentToSmiles(*mol, ats, nullptr, nullptr, nullptr,
+                                          doIsomericSmiles, doKekule);
+    CHECK(smi == "C");
+  }
+  SECTION("including ring parts") {
+    std::vector<int> ats{0, 1, 2};
+    bool doIsomericSmiles = true;
+    bool doKekule = true;
+    std::string smi = MolFragmentToSmiles(*mol, ats, nullptr, nullptr, nullptr,
+                                          doIsomericSmiles, doKekule);
+    CHECK(smi == "C:CC");
+  }
+}