mirror of
https://github.com/rdkit/rdkit.git
synced 2026-06-04 21:54:27 +08:00
584 lines
20 KiB
C++
584 lines
20 KiB
C++
//
|
|
// Copyright (C) 2023 Novartis Biomedical Research
|
|
//
|
|
// @@ All Rights Reserved @@
|
|
// This file is part of the RDKit.
|
|
// The contents are covered by the terms of the BSD license
|
|
// which is included in the file license.txt, found at the root
|
|
// of the RDKit source tree.
|
|
//
|
|
|
|
#include <cmath>
|
|
#include <regex>
|
|
#include <sstream>
|
|
#include "Pipeline.h"
|
|
#include "Validate.h"
|
|
#include "Metal.h"
|
|
#include "Normalize.h"
|
|
#include "Charge.h"
|
|
#include "Fragment.h"
|
|
#include <RDGeneral/FileParseException.h>
|
|
#include <GraphMol/FileParsers/FileParsers.h>
|
|
#include <GraphMol/SmilesParse/SmilesParse.h>
|
|
#include <GraphMol/SmilesParse/SmilesWrite.h>
|
|
#include <GraphMol/Chirality.h>
|
|
|
|
namespace RDKit {
|
|
namespace MolStandardize {
|
|
|
|
void PipelineResult::append(PipelineStatus newStatus, const std::string &info) {
|
|
status = static_cast<PipelineStatus>(status | newStatus);
|
|
log.push_back({newStatus, info});
|
|
}
|
|
|
|
PipelineResult Pipeline::run(const std::string &molblock) const {
|
|
PipelineResult result;
|
|
result.status = NO_EVENT;
|
|
result.inputMolData = molblock;
|
|
|
|
// parse the molblock into an RWMol instance
|
|
result.stage = static_cast<uint32_t>(PipelineStage::PARSING_INPUT);
|
|
RWMOL_SPTR mol = parse(molblock, result, options);
|
|
if (!mol || ((result.status & PIPELINE_ERROR) != NO_EVENT &&
|
|
!options.reportAllFailures)) {
|
|
return result;
|
|
}
|
|
|
|
RWMOL_SPTR_PAIR output;
|
|
|
|
// we try sanitization and validation on a copy, because we want to preserve
|
|
// the original input molecule for later
|
|
RWMOL_SPTR molCopy{new RWMol(*mol)};
|
|
for (const auto &[stage, operation] : validationSteps) {
|
|
result.stage = stage;
|
|
molCopy = operation(molCopy, result, options);
|
|
if (!molCopy || ((result.status & PIPELINE_ERROR) != NO_EVENT &&
|
|
!options.reportAllFailures)) {
|
|
return result;
|
|
}
|
|
}
|
|
|
|
for (const auto &[stage, operation] : standardizationSteps) {
|
|
result.stage = stage;
|
|
mol = operation(mol, result, options);
|
|
if (!mol || ((result.status & PIPELINE_ERROR) != NO_EVENT &&
|
|
!options.reportAllFailures)) {
|
|
return result;
|
|
}
|
|
}
|
|
if (makeParent) {
|
|
result.stage = static_cast<uint32_t>(PipelineStage::MAKE_PARENT);
|
|
output = makeParent(mol, result, options);
|
|
if (!output.first || !output.second ||
|
|
((result.status & PIPELINE_ERROR) != NO_EVENT &&
|
|
!options.reportAllFailures)) {
|
|
return result;
|
|
}
|
|
} else {
|
|
output = {mol, mol};
|
|
}
|
|
|
|
// serialize as MolBlocks
|
|
result.stage = static_cast<uint32_t>(PipelineStage::SERIALIZING_OUTPUT);
|
|
serialize(output, result, options);
|
|
if ((result.status & PIPELINE_ERROR) != NO_EVENT &&
|
|
!options.reportAllFailures) {
|
|
return result;
|
|
}
|
|
|
|
result.stage = static_cast<uint32_t>(PipelineStage::COMPLETED);
|
|
|
|
return result;
|
|
}
|
|
|
|
namespace Operations {
|
|
RWMOL_SPTR parse(const std::string &molblock, PipelineResult &result,
|
|
const PipelineOptions &options) {
|
|
v2::FileParsers::MolFileParserParams params;
|
|
// we don't want to sanitize the molecule at this stage
|
|
params.sanitize = false;
|
|
// Hs wouldn't be anyway removed if the mol is not sanitized
|
|
params.removeHs = false;
|
|
// strict parsing is configurable via the pipeline options
|
|
params.strictParsing = options.strictParsing;
|
|
|
|
RWMOL_SPTR mol{};
|
|
|
|
try {
|
|
mol.reset(v2::FileParsers::MolFromMolBlock(molblock, params).release());
|
|
} catch (FileParseException &e) {
|
|
result.append(INPUT_ERROR, e.what());
|
|
}
|
|
|
|
if (!mol) {
|
|
result.append(INPUT_ERROR,
|
|
"Could not instantiate a valid molecule from input");
|
|
}
|
|
|
|
return mol;
|
|
}
|
|
|
|
void serialize(RWMOL_SPTR_PAIR output, PipelineResult &result,
|
|
const PipelineOptions &options) {
|
|
const ROMol &outputMol = *output.first;
|
|
const ROMol &parentMol = *output.second;
|
|
|
|
try {
|
|
if (!options.outputV2000) {
|
|
result.outputMolData = MolToV3KMolBlock(outputMol);
|
|
result.parentMolData = MolToV3KMolBlock(parentMol);
|
|
} else {
|
|
try {
|
|
result.outputMolData = MolToV2KMolBlock(outputMol);
|
|
result.parentMolData = MolToV2KMolBlock(parentMol);
|
|
} catch (ValueErrorException &e) {
|
|
result.append(OUTPUT_ERROR,
|
|
"Can't write molecule to V2000 output format: " +
|
|
std::string(e.what()));
|
|
}
|
|
}
|
|
} catch (const std::exception &e) {
|
|
result.append(OUTPUT_ERROR, "Can't write molecule to output format: " +
|
|
std::string(e.what()));
|
|
} catch (...) {
|
|
result.append(
|
|
OUTPUT_ERROR,
|
|
"An unexpected error occurred while serializing the output structures.");
|
|
}
|
|
}
|
|
RWMOL_SPTR prepareForValidation(RWMOL_SPTR mol, PipelineResult &result,
|
|
const PipelineOptions &) {
|
|
// Prepare the mol for validation.
|
|
|
|
try {
|
|
// The general intention is about validating the original input, and
|
|
// therefore limit the sanitization to the minimum, but it's not very useful
|
|
// to record a valence validation error for issues like a badly drawn nitro
|
|
// group that would be later fixed during by the normalization step.
|
|
//
|
|
// Some sanitization also needs to be performed in order to assign the
|
|
// stereochemistry (which needs to happen prior to reapplying the wedging,
|
|
// see below), and we need to find radicals, in order to support the
|
|
// corresponding validation criterion.
|
|
constexpr unsigned int sanitizeOps =
|
|
(MolOps::SANITIZE_CLEANUP | MolOps::SANITIZE_SYMMRINGS |
|
|
MolOps::SANITIZE_CLEANUP_ORGANOMETALLICS |
|
|
MolOps::SANITIZE_FINDRADICALS);
|
|
unsigned int failedOp = 0;
|
|
MolOps::sanitizeMol(*mol, failedOp, sanitizeOps);
|
|
|
|
// We want to restore the original MolBlock wedging, but this step may in
|
|
// some cases overwrite the ENDDOWNRIGHT/ENDUPRIGHT info that describes the
|
|
// configuration of double bonds adjacent to stereocenters. We therefore
|
|
// first assign the stereochemistry, and then restore the wedging.
|
|
constexpr bool cleanIt = true;
|
|
constexpr bool force = true;
|
|
constexpr bool flagPossible = true;
|
|
MolOps::assignStereochemistry(*mol, cleanIt, force, flagPossible);
|
|
Chirality::reapplyMolBlockWedging(*mol);
|
|
} catch (MolSanitizeException &) {
|
|
result.append(
|
|
PREPARE_FOR_VALIDATION_ERROR,
|
|
"An error occurred while preparing the molecule for validation.");
|
|
}
|
|
|
|
return mol;
|
|
}
|
|
|
|
namespace {
|
|
// The error messages from the ValidationMethod classes include some metadata
|
|
// in a string prefix that are not particularly useful within the context of
|
|
// this Pipeline. The function below removes that prefix.
|
|
static const std::regex prefix("^(ERROR|INFO): \\[.+\\] ");
|
|
std::string removeErrorPrefix(const std::string &message) {
|
|
return std::regex_replace(message, prefix, "");
|
|
}
|
|
} // namespace
|
|
|
|
RWMOL_SPTR validate(RWMOL_SPTR mol, PipelineResult &result,
|
|
const PipelineOptions &options) {
|
|
auto applyValidation = [&mol, &result, &options](
|
|
const ValidationMethod &v,
|
|
PipelineStatus status) -> bool {
|
|
auto errors = v.validate(*mol, options.reportAllFailures);
|
|
for (const auto &error : errors) {
|
|
result.append(status, removeErrorPrefix(error));
|
|
}
|
|
return errors.empty();
|
|
};
|
|
|
|
// check for undesired features in the input molecule (e.g., query
|
|
// atoms/bonds)
|
|
FeaturesValidation featuresValidation(options.allowEnhancedStereo,
|
|
options.allowAromaticBondType,
|
|
options.allowDativeBondType);
|
|
if (!applyValidation(featuresValidation, FEATURES_VALIDATION_ERROR) &&
|
|
!options.reportAllFailures) {
|
|
return mol;
|
|
}
|
|
|
|
// check the number of atoms and valence status
|
|
RDKitValidation rdkitValidation(options.allowEmptyMolecules);
|
|
if (!applyValidation(rdkitValidation, BASIC_VALIDATION_ERROR) &&
|
|
!options.reportAllFailures) {
|
|
return mol;
|
|
}
|
|
|
|
// disallow radicals
|
|
DisallowedRadicalValidation radicalValidation;
|
|
if (!applyValidation(radicalValidation, BASIC_VALIDATION_ERROR) &&
|
|
!options.reportAllFailures) {
|
|
return mol;
|
|
}
|
|
|
|
// validate the isotopic numbers (if any are specified)
|
|
IsotopeValidation isotopeValidation(true);
|
|
if (!applyValidation(isotopeValidation, BASIC_VALIDATION_ERROR) &&
|
|
!options.reportAllFailures) {
|
|
return mol;
|
|
}
|
|
|
|
// verify that the input is a 2D structure
|
|
Is2DValidation is2DValidation(options.is2DZeroThreshold);
|
|
if (!applyValidation(is2DValidation, IS2D_VALIDATION_ERROR) &&
|
|
!options.reportAllFailures) {
|
|
return mol;
|
|
}
|
|
|
|
// validate the 2D layout (check for clashing atoms and abnormally long bonds)
|
|
Layout2DValidation layout2DValidation(
|
|
options.atomClashLimit, options.bondLengthLimit,
|
|
options.allowLongBondsInRings, options.allowAtomBondClashExemption,
|
|
options.minMedianBondLength);
|
|
if (!applyValidation(layout2DValidation, LAYOUT2D_VALIDATION_ERROR) &&
|
|
!options.reportAllFailures) {
|
|
return mol;
|
|
}
|
|
|
|
// verify that the specified stereochemistry is formally correct
|
|
StereoValidation stereoValidation;
|
|
if (!applyValidation(stereoValidation, STEREO_VALIDATION_ERROR) &&
|
|
!options.reportAllFailures) {
|
|
return mol;
|
|
}
|
|
|
|
return mol;
|
|
}
|
|
|
|
RWMOL_SPTR prepareForStandardization(RWMOL_SPTR mol, PipelineResult &result,
|
|
const PipelineOptions &) {
|
|
// Prepare the mol for standardization.
|
|
|
|
try {
|
|
MolOps::sanitizeMol(*mol);
|
|
} catch (MolSanitizeException &) {
|
|
result.append(
|
|
PREPARE_FOR_STANDARDIZATION_ERROR,
|
|
"An error occurred while preparing the molecule for standardization.");
|
|
}
|
|
|
|
return mol;
|
|
}
|
|
|
|
RWMOL_SPTR standardize(RWMOL_SPTR mol, PipelineResult &result,
|
|
const PipelineOptions &options) {
|
|
auto smiles = MolToSmiles(*mol);
|
|
auto reference = smiles;
|
|
|
|
// bonding to metals
|
|
try {
|
|
MetalDisconnectorOptions mdOpts;
|
|
MetalDisconnector metalDisconnector(mdOpts);
|
|
std::unique_ptr<ROMol> metalNof{SmartsToMol(options.metalNof)};
|
|
metalDisconnector.setMetalNof(*metalNof);
|
|
std::unique_ptr<ROMol> metalNon{SmartsToMol(options.metalNon)};
|
|
metalDisconnector.setMetalNon(*metalNon);
|
|
metalDisconnector.disconnectInPlace(*mol);
|
|
} catch (...) {
|
|
result.append(
|
|
METAL_STANDARDIZATION_ERROR,
|
|
"An error occurred while processing the bonding of metal species.");
|
|
return mol;
|
|
}
|
|
|
|
smiles = MolToSmiles(*mol);
|
|
if (smiles != reference) {
|
|
result.append(METALS_DISCONNECTED,
|
|
"One or more metal atoms were disconnected.");
|
|
}
|
|
reference = smiles;
|
|
|
|
// functional groups
|
|
try {
|
|
std::unique_ptr<Normalizer> normalizer{};
|
|
if (options.normalizerData.empty()) {
|
|
normalizer.reset(new Normalizer);
|
|
} else {
|
|
std::istringstream sstr(options.normalizerData);
|
|
normalizer.reset(new Normalizer(sstr, options.normalizerMaxRestarts));
|
|
}
|
|
// normalizeInPlace() may return an ill-formed molecule if
|
|
// the sanitization of a transformed structure failed
|
|
// => use normalize() instead (also see GitHub #7189)
|
|
mol.reset(static_cast<RWMol *>(normalizer->normalize(*mol)));
|
|
mol->updatePropertyCache(false);
|
|
} catch (...) {
|
|
result.append(
|
|
NORMALIZER_STANDARDIZATION_ERROR,
|
|
"An error occurred while normalizing the representation of some functional groups");
|
|
return mol;
|
|
}
|
|
|
|
smiles = MolToSmiles(*mol);
|
|
if (smiles != reference) {
|
|
result.append(NORMALIZATION_APPLIED,
|
|
"The representation of some functional groups was adjusted.");
|
|
}
|
|
reference = smiles;
|
|
|
|
// keep the largest fragment
|
|
try {
|
|
LargestFragmentChooser fragmentChooser;
|
|
fragmentChooser.chooseInPlace(*mol);
|
|
} catch (...) {
|
|
result.append(
|
|
FRAGMENT_STANDARDIZATION_ERROR,
|
|
"An error occurred while removing the disconnected fragments");
|
|
return mol;
|
|
}
|
|
|
|
smiles = MolToSmiles(*mol);
|
|
if (smiles != reference) {
|
|
result.append(
|
|
FRAGMENTS_REMOVED,
|
|
"One or more disconnected fragments (e.g., counterions) were removed.");
|
|
}
|
|
|
|
// The stereochemistry is not assigned until after we are done modifying the
|
|
// molecular graph:
|
|
constexpr bool cleanIt = true;
|
|
constexpr bool force = true;
|
|
constexpr bool flagPossible = true;
|
|
MolOps::assignStereochemistry(*mol, cleanIt, force, flagPossible);
|
|
|
|
return mol;
|
|
}
|
|
|
|
RWMOL_SPTR reapplyWedging(RWMOL_SPTR mol, PipelineResult &result,
|
|
const PipelineOptions &) {
|
|
// in general, we want to restore the bond wedging from the input molblock,
|
|
// but we prefer to not use any wavy bonds, because of their ambiguity
|
|
// in some configurations.
|
|
|
|
// we therefore proceed in two steps, we first reapply the molblock wedging
|
|
// and then revert the changes related to double bonds with undefined/unknown
|
|
// stereochemistry and change single bonds with "unknown" direction into plain
|
|
// single bonds.
|
|
|
|
// in order to do so, we need to keep track of the current bond configuration
|
|
// settings.
|
|
using BondInfo = std::tuple<Bond::BondType, Bond::BondDir, Bond::BondStereo>;
|
|
std::map<unsigned int, BondInfo> oldBonds;
|
|
for (auto bond : mol->bonds()) {
|
|
oldBonds[bond->getIdx()] = {bond->getBondType(), bond->getBondDir(),
|
|
bond->getStereo()};
|
|
}
|
|
|
|
// 1) restore the original wedging from the input MolBlock
|
|
Chirality::reapplyMolBlockWedging(*mol);
|
|
|
|
// 2) revert the changes related to double bonds with stereo type "either":
|
|
// restore the STEREOANY direction of double bonds that have a substituent
|
|
// with direction UNKNOWN and are now STEREONONE
|
|
for (auto bond : mol->bonds()) {
|
|
if (bond->getBondType() != Bond::DOUBLE) {
|
|
continue;
|
|
}
|
|
Bond::BondStereo oldStereo = std::get<2>(oldBonds[bond->getIdx()]);
|
|
Bond::BondStereo newStereo = bond->getStereo();
|
|
bool hasAdjacentWavy{false};
|
|
for (auto atom : {bond->getBeginAtom(), bond->getEndAtom()}) {
|
|
for (auto adjacentBond : mol->atomBonds(atom)) {
|
|
if (adjacentBond == bond) {
|
|
continue;
|
|
}
|
|
if (adjacentBond->getBondDir() == Bond::UNKNOWN) {
|
|
hasAdjacentWavy = true;
|
|
}
|
|
}
|
|
}
|
|
if (hasAdjacentWavy && oldStereo == Bond::STEREOANY &&
|
|
newStereo == Bond::STEREONONE) {
|
|
bond->setStereo(Bond::STEREOANY);
|
|
result.append(
|
|
NORMALIZATION_APPLIED,
|
|
"Double bond " + std::to_string(bond->getIdx()) +
|
|
" was assigned an undefined/unknown stereochemical configuration");
|
|
}
|
|
}
|
|
|
|
// 3) set the bond direction to NONE for bonds with direction UNKNOWN
|
|
for (auto bond : mol->bonds()) {
|
|
if (bond->getBondDir() != Bond::UNKNOWN) {
|
|
continue;
|
|
}
|
|
bond->setBondDir(Bond::NONE);
|
|
result.append(NORMALIZATION_APPLIED, "The \"wavy\" style of bond " +
|
|
std::to_string(bond->getIdx()) +
|
|
" was removed");
|
|
}
|
|
|
|
return mol;
|
|
}
|
|
|
|
RWMOL_SPTR cleanup2D(RWMOL_SPTR mol, PipelineResult & /*result*/,
|
|
const PipelineOptions &options) {
|
|
// scale the atoms coordinates
|
|
// and make sure that z coords are set to 0 (some z coords may be non-null
|
|
// albeit smaller than the validation threshold - these noisy coords may in
|
|
// some cases also interfere with the perception of stereochemistry by some
|
|
// tools e.g., inchi)
|
|
if (options.scaledMedianBondLength > 0. && mol->getNumConformers()) {
|
|
auto &conf = mol->getConformer();
|
|
double medianBondLength =
|
|
sqrt(Layout2DValidation::squaredMedianBondLength(*mol, conf));
|
|
if (medianBondLength > options.minMedianBondLength) {
|
|
double scaleFactor = options.scaledMedianBondLength / medianBondLength;
|
|
unsigned int natoms = conf.getNumAtoms();
|
|
for (unsigned int i = 0; i < natoms; ++i) {
|
|
auto pos = conf.getAtomPos(i) * scaleFactor;
|
|
pos.z = 0.;
|
|
conf.setAtomPos(i, pos);
|
|
}
|
|
}
|
|
}
|
|
|
|
return mol;
|
|
}
|
|
|
|
namespace {
|
|
void replaceDativeBonds(RWMOL_SPTR mol) {
|
|
bool modified{false};
|
|
for (auto bond : mol->bonds()) {
|
|
if (bond->getBondType() != Bond::BondType::DATIVE) {
|
|
continue;
|
|
}
|
|
auto donor = bond->getBeginAtom();
|
|
donor->setFormalCharge(donor->getFormalCharge() + 1);
|
|
auto acceptor = bond->getEndAtom();
|
|
acceptor->setFormalCharge(acceptor->getFormalCharge() - 1);
|
|
bond->setBondType(Bond::BondType::SINGLE);
|
|
modified = true;
|
|
}
|
|
if (modified) {
|
|
mol->updatePropertyCache(false);
|
|
}
|
|
}
|
|
|
|
void removeHsAtProtonatedSites(RWMOL_SPTR mol) {
|
|
boost::dynamic_bitset<> protons{mol->getNumAtoms(), 0};
|
|
for (auto atom : mol->atoms()) {
|
|
if (atom->getAtomicNum() != 1 || atom->getDegree() != 1) {
|
|
continue;
|
|
}
|
|
for (auto neighbor : mol->atomNeighbors(atom)) {
|
|
if (neighbor->getFormalCharge() > 0) {
|
|
protons.set(atom->getIdx());
|
|
}
|
|
}
|
|
}
|
|
if (protons.any()) {
|
|
for (int idx = mol->getNumAtoms() - 1; idx >= 0; --idx) {
|
|
if (!protons[idx]) {
|
|
continue;
|
|
}
|
|
auto atom = mol->getAtomWithIdx(idx);
|
|
for (auto bond : mol->atomBonds(atom)) {
|
|
auto neighbor = bond->getOtherAtom(atom);
|
|
neighbor->setNumExplicitHs(neighbor->getNumExplicitHs() + 1);
|
|
break; // there are no other bonds anyways
|
|
}
|
|
mol->removeAtom(atom);
|
|
}
|
|
mol->updatePropertyCache(false);
|
|
}
|
|
}
|
|
} // namespace
|
|
|
|
RWMOL_SPTR_PAIR makeParent(RWMOL_SPTR mol, PipelineResult &result,
|
|
const PipelineOptions &) {
|
|
auto reference = MolToSmiles(*mol);
|
|
|
|
RWMOL_SPTR parent{new RWMol(*mol)};
|
|
|
|
// A "parent" structure is constructed here, in order to provide a
|
|
// representation of the original input that may be more suitable for
|
|
// identification purposes even though it may not reflect the most stable
|
|
// physical state or nicest representation for the compound.
|
|
//
|
|
// The two steps that are currently implemented for this procedure consist in
|
|
// normalizing the overall charge status and replacing any explicit dative
|
|
// bonds.
|
|
//
|
|
// If the input was submitted in an unsuitable protonation status, the
|
|
// neutralized parent structure may become the actual output from the
|
|
// standardization.
|
|
|
|
// overall charge status
|
|
try {
|
|
// The Uncharger implementation wouldn't identify the positively
|
|
// charged sites with adjacent explicit Hs correctly (it's a quite
|
|
// unlikely configuration, but potentially possible considering that
|
|
// the pipeline operates on unsanitized input).
|
|
//
|
|
// If present, these Hs are therefore removed from the molecular graph
|
|
// prior to neutralization.
|
|
removeHsAtProtonatedSites(parent);
|
|
|
|
static const bool canonicalOrdering = false;
|
|
static const bool force = true;
|
|
static const bool protonationOnly = true;
|
|
Uncharger uncharger(canonicalOrdering, force, protonationOnly);
|
|
uncharger.unchargeInPlace(*parent);
|
|
} catch (...) {
|
|
result.append(
|
|
CHARGE_STANDARDIZATION_ERROR,
|
|
"An error occurred while normalizing the compound's charge status");
|
|
return {{}, {}};
|
|
}
|
|
|
|
// Check if `mol` was submitted in a suitable ionization state
|
|
int parentCharge{};
|
|
for (auto atom : parent->atoms()) {
|
|
parentCharge += atom->getFormalCharge();
|
|
}
|
|
|
|
int molCharge{};
|
|
for (auto atom : mol->atoms()) {
|
|
molCharge += atom->getFormalCharge();
|
|
}
|
|
|
|
// If mol is neutral or in a protonation state that partially or fully
|
|
// balances the non-neutralizable charged sites in the parent structure,
|
|
// then mol is accepted. Otherwise, it is replaced by its parent.
|
|
if ((molCharge > 0 && molCharge > parentCharge) ||
|
|
(molCharge < 0 && molCharge < parentCharge)) {
|
|
mol = parent;
|
|
}
|
|
|
|
auto smiles = MolToSmiles(*mol);
|
|
if (smiles != reference) {
|
|
result.append(PROTONATION_CHANGED, "The protonation state was adjusted.");
|
|
}
|
|
reference = smiles;
|
|
|
|
// normalize the dative bonds
|
|
replaceDativeBonds(parent);
|
|
|
|
return {mol, parent};
|
|
}
|
|
} // namespace Operations
|
|
|
|
} // namespace MolStandardize
|
|
} // namespace RDKit
|