Initial work towards fixing "hard" SMILES 3D generation (#2911)

* Initial work towards fixing "hard" SMILES 3D generation

* Check for unspecified stereo in gen3d
Should ensure we don't "fail" with unspecified input

---------

Signed-off-by: Geoff Hutchison <geoff.hutchison@gmail.com>
This commit is contained in:
Geoff Hutchison
2026-05-14 23:12:14 -04:00
committed by GitHub
parent 6ce65cfdf1
commit 9b4dd4150b
7 changed files with 365 additions and 8 deletions
+20
View File
@@ -1168,10 +1168,30 @@ namespace OpenBabel {
unsigned int stereoFails = 0, boundsFails = 0;
auto wallStart = std::chrono::steady_clock::now();
// Safety limit: bridged bicyclics and other pathological topologies can
// exhaust max_iterations on every L-BFGS call without converging, making
// the full trial loop take many minutes.
const double maxWallSeconds = 30.0;
double lastProgressReport = 0.0; // seconds since last progress message
for (unsigned int trial = 0; trial < maxIter; trial++) {
auto trialStart = std::chrono::steady_clock::now();
// Check wall-clock limit before starting a new (expensive) trial.
double wallElapsed = std::chrono::duration<double>(
trialStart - wallStart).count();
if (wallElapsed > maxWallSeconds) {
cerr << "DistGeom: wall-clock limit (" << maxWallSeconds
<< "s) reached after " << trial << " trials" << endl;
break;
}
// Emit a progress dot every 5 seconds so users know we're still running.
if (wallElapsed - lastProgressReport >= 5.0) {
cerr << "." << flush;
lastProgressReport = wallElapsed;
}
if (!generateInitialCoords())
continue;
if (dim == 4) {
+13 -3
View File
@@ -112,9 +112,13 @@ bool OpGen3D::Do(OBBase* pOb, const char* OptionText, OpMap* pOptions, OBConvers
// This is done for all speed levels (i.e., create the structure)
OBBuilder builder;
bool attemptBuild = !useDistGeom;
if (attemptBuild && !builder.Build(molCopy) ) {
std::cerr << "Warning: Stereochemistry is wrong, using the distance geometry method instead" << std::endl;
useDistGeom = true; // don't try building anymore
if (attemptBuild) {
if (!builder.Build(molCopy) || !molCopy.HasNonZeroCoords()) {
std::cerr << "Warning: 3D builder failed, using distance geometry instead" << std::endl;
useDistGeom = true; // don't try building anymore
attemptBuild = false; // don't use zero/garbage coords as distgeom seed
molCopy = *pmol; // reset to original before distgeom
}
}
#ifdef HAVE_EIGEN3
@@ -195,6 +199,12 @@ bool OpGen3D::Do(OBBase* pOb, const char* OptionText, OpMap* pOptions, OBConvers
*pmol = molCopy;
break;
}
// Builder produced wrong stereo; switch to distance geometry for
// remaining trials (molCopy is reset to *pmol at the top of the loop).
if (!useDistGeom) {
std::cerr << "Warning: Stereochemistry is wrong, using distance geometry instead" << std::endl;
useDistGeom = true;
}
}
if (!success) {
+13
View File
@@ -37,12 +37,19 @@ namespace OpenBabel {
{
m_unspecifiedTetrahedral.clear();
m_unspecifiedCisTrans.clear();
m_inputHasStereoNotation = false;
// Store canonical SMILES of original molecule
OBConversion conv;
conv.SetOutFormat("can");
m_inputSmiles = conv.WriteString(mol, true);
// Check if the input SMILES contains any stereo notation
// (@ for tetrahedral, / \ for cis/trans)
m_inputHasStereoNotation = (m_inputSmiles.find('@') != std::string::npos ||
m_inputSmiles.find('/') != std::string::npos ||
m_inputSmiles.find('\\') != std::string::npos);
// Keep track of unspecified stereochemistry
OBStereoFacade facade(mol);
@@ -70,6 +77,12 @@ namespace OpenBabel {
bool OBGen3DStereoHelper::Check(OBMol *mol)
{
// If the input SMILES has no stereo notation, any 3D geometry is acceptable.
// The E/Z configuration might change during optimization, but that's fine
// since no stereo was specified in the input.
if (!m_inputHasStereoNotation)
return true;
// Perceive stereo from 3D coords
StereoFrom3D(mol, true); // true = force
+1
View File
@@ -59,6 +59,7 @@ namespace OpenBabel {
std::string m_inputSmiles;
std::vector<unsigned long> m_unspecifiedTetrahedral;
std::vector<unsigned long> m_unspecifiedCisTrans;
bool m_inputHasStereoNotation = false;
};
} // namespace OpenBabel
+13 -5
View File
@@ -64,15 +64,23 @@ set(uniqueid_parts 1 2)
if(TARGET Eigen3::Eigen)
set(cpptests
align distgeom ${cpptests})
align distgeom gen3d ${cpptests})
set(align_parts 1 2 3 4 5)
# Parts 6-7 are slow (large molecules, 10-60s+); skip them in unoptimized builds
# (Debug, sanitizer builds, etc.)
# Parts 1-5 are fast; parts 6-7 are large molecules (~10-60s)
# Parts 8-19 are the pr2317 hard cases (new distgeom fallback tests).
# Excluded (fused/bridged ring systems that exhaust L-BFGS on every trial):
# 13 (quinine/quinidine), 15 (polycyclic alkaloids), 16 (aminoglycoside)
# Excluded (too large for 30 s wall-clock limit):
# 19 (disaccharide, 38+ atoms)
# All excluded cases are covered by gen3dtest via the builder path.
if(NOT OB_OPTIMIZED_BUILD)
# Skip slow tests in unoptimized builds (Debug, sanitizer builds, etc.)
set(distgeom_parts 1 2 3 4 5)
set(gen3d_parts 1 2 3 4)
else()
# Parts 1-6 are fast; part 7 is the large 120-atom molecule (~10-60s)
set(distgeom_parts 1 2 3 4 5 6 7)
# In optimized builds, include comprehensive test coverage
set(distgeom_parts 1 2 3 4 5 6 7 8 9 10 11 12 14 17 18)
set(gen3d_parts 1 2 3 4 5)
endif()
endif()
+125
View File
@@ -60,7 +60,35 @@ static string canSmiFrom3D(OBMol& mol3D)
// Read SMILES, get canonical form, generate 3D with distance geometry,
// do SDF roundtrip to force 3D stereo perception, compare canonical SMILES.
// Returns true if stereo is preserved.
// Verify only that GetGeometry produces non-zero 3D coordinates.
// Use this when stereo cannot be verified via SMILES roundtrip (e.g. ring
// double bonds that StereoFrom3D doesn't perceive, or bridged bicyclics
// where the embedding is too slow to retry to convergence).
#ifdef HAVE_EIGEN3
static bool doDistGeomCoordsTest(const string& smiles)
{
cout << " Testing coords: " << smiles << endl;
OBConversion conv;
conv.SetInFormat("smi");
OBMol mol;
OB_REQUIRE(conv.ReadString(&mol, smiles));
OBDistanceGeometry dg;
bool ok = dg.GetGeometry(mol);
if (!ok) {
cout << " FAILED: GetGeometry returned false" << endl;
return false;
}
OB_REQUIRE(mol.Has3D());
OB_REQUIRE(mol.HasNonZeroCoords());
cout << " OK" << endl;
return true;
}
static bool doDistGeomStereoTest(const string& smiles)
{
cout << " Testing: " << smiles << endl;
@@ -167,6 +195,103 @@ int distgeomtest(int argc, char* argv[])
"(CC1)C(=O)O)C)C)C") );
break;
case 8:
// Medium-sized rings with double bonds -- coord generation only.
// E/Z notation on ring double bonds creates OBCisTransStereo constraints
// that CheckStereoConstraints() can never satisfy, so we use plain SMILES
// with the ring stereo stripped.
OB_ASSERT( doDistGeomCoordsTest("C1CCCC=CCCC1") ); // cyclonon-4-ene
OB_ASSERT( doDistGeomCoordsTest("C1CCCC=CCCCCCCCC(=O)CCC1") ); // 17-membered macrolide
break;
case 9:
// Large rings and macrocyclic polyenes -- coord generation only.
// Same reason as case 8: ring E/Z stereo stripped.
OB_ASSERT( doDistGeomCoordsTest("C1=CC=CC=CC=CC=CC=CC=CC=C1") ); // [16]-annulene
OB_ASSERT( doDistGeomCoordsTest("CC1=CCC(C=CCC(=CCC1)C)(C)C") ); // germacrene sesquiterpene
break;
case 10:
// Open-chain monosaccharide stereochemistry
OB_ASSERT( doDistGeomStereoTest("C([C@H]([C@@H]([C@@H]([C@H](CO)O)O)O)O)O") ); // galactose
OB_ASSERT( doDistGeomStereoTest("C([C@H]([C@H]([C@@H]([C@H](C(=O)O)O)O)O)O)O") ); // glucuronic acid
OB_ASSERT( doDistGeomStereoTest("C([C@H]([C@H]([C@@H]([C@H](CO)O)O)O)O)O") ); // glucose
OB_ASSERT( doDistGeomStereoTest("C([C@H]([C@H]([C@@H]([C@@H](CO)O)O)O)O)O") ); // mannose
break;
case 11:
// Small chiral molecules: amino acids, hydroxy acids, diols
OB_ASSERT( doDistGeomStereoTest("C[C@H]([C@@H](C)C(=O)O)C(=O)O") ); // dimethylsuccinic acid
OB_ASSERT( doDistGeomStereoTest("[C@@H]([C@H](C(=O)O)O)(C(=O)O)O") ); // L-tartaric acid
OB_ASSERT( doDistGeomStereoTest("C[C@H]([C@@H](C(=O)O)N)O") ); // L-threonine
OB_ASSERT( doDistGeomStereoTest("C[C@H]([C@@H](C)O)O") ); // butane-2,3-diol
OB_ASSERT( doDistGeomStereoTest("[C@@H]([C@H](C(=O)N)O)(C(=O)N)O") ); // asparagine-diol
break;
case 12:
// Halogenated stereocenters and chloramphenicol analog
OB_ASSERT( doDistGeomStereoTest("[C@@H]([C@@H](C(=O)O)Br)(C(=O)O)Br") ); // dibromo succinic acid
OB_ASSERT( doDistGeomStereoTest("C1=CC(=CC=C1[C@H]([C@@H](CO)NC(=O)C(Cl)Cl)O)[N+](=O)[O-]") ); // chloramphenicol analog
break;
case 13:
// Quinine and quinidine (cinchona alkaloids).
// NOT included in distgeom_parts: the bridged quinuclidine core causes
// the L-BFGS inside each distgeom trial to exhaust its iteration budget
// without converging, making all 10*N trials slow (~minutes total).
// These molecules are tested via gen3dtest case 2, where OBBuilder
// handles the ring topology and distgeom is only a fallback.
OB_ASSERT( doDistGeomCoordsTest("C=C[C@H]1CN2CC[C@H]1C[C@H]2[C@@H](C3=CC=NC4=CC=CC=C34)O") ); // quinine
OB_ASSERT( doDistGeomCoordsTest("C=C[C@H]1CN2CC[C@H]1C[C@@H]2[C@H](C3=CC=NC4=CC=CC=C34)O") ); // quinidine
break;
case 14:
// Steroid and terpenoid ring systems
OB_ASSERT( doDistGeomStereoTest("C[C@]12CC[C@H]3[C@H]([C@@H]1C[C@H]([C@@H]2O)O)CCC4=C3C=CC(=C4)O") ); // estradiol-like
OB_ASSERT( doDistGeomStereoTest("C[C@H]1C[C@@H](C(=O)[C@@H](C1)[C@@H](CC2CC(=O)NC(=O)C2)O)C") ); // terpenoid-lactam
break;
case 15:
// Isoquinoline and indole polycyclic alkaloids.
// NOT in distgeom_parts: fused ring distance constraints exhaust the
// L-BFGS budget on every trial (same failure mode as quinine/case 13).
// These are tested via gen3dtest case 5 using the builder path.
OB_ASSERT( doDistGeomCoordsTest("CN1CCC2=CC3=C(C=C2[C@@H]1[C@@H]4C5=C(C(=C(C=C5)OC)OC)C(=O)O4)OCO3") ); // berberine analog
OB_ASSERT( doDistGeomCoordsTest("C1CN2CC3=CC4=C(C=C3[C@H]5[C@H]2C1=C[C@@H]([C@H]5O)O)OCO4") ); // polycyclic alkaloid
OB_ASSERT( doDistGeomCoordsTest("C1=C[C@H]2C(=CN1C)[C@H]1C(=CC=CN1C)C=C2") ); // vinca-like indole
break;
case 16:
// Aminoglycoside and cyclic guanidino stereo.
// NOT in distgeom_parts: the pyranose ring system in the aminoglycoside
// has the same L-BFGS convergence problem as case 15.
// Tested via gen3dtest case 5 using the builder path.
OB_ASSERT( doDistGeomCoordsTest("C[C@@H]1[C@H](C[C@@H]([C@H](O1)OC2[C@@H]([C@H](C([C@@H]([C@@H]2O)O)O)O)O)N)N=C(C(=O)O)N") ); // aminoglycoside
OB_ASSERT( doDistGeomCoordsTest("C1[C@@H](NC(=N[C@H]1O)N)[C@@H](C(=O)O)N") ); // cyclic arginine analog
break;
case 17:
// Amino acid derivatives and dipeptide fragments
OB_ASSERT( doDistGeomStereoTest("CC(C)C[C@@H](C(=O)O)NC(=O)[C@H]([C@@H](CC1=CC=CC=C1)N)O") ); // dipeptide fragment
OB_ASSERT( doDistGeomStereoTest("C[C@H]([C@@H](C(=O)O)N)OP(=O)(O)O") ); // phosphoamino acid
OB_ASSERT( doDistGeomStereoTest("C[C@H]([C@@H](C(=O)O)N)SC[C@@H](C(=O)O)N") ); // cystine fragment
break;
case 18:
// Complex multi-stereo-center molecules
OB_ASSERT( doDistGeomStereoTest("Cc1nnc(CNC[C@@H]2CN(C(=O)[C@@]34CCCC[C@H]3C4)C[C@H]2C)n1C1CC1") ); // bicyclic proline-triazole
OB_ASSERT( doDistGeomStereoTest("N1(C=C[C@@H](C=C1C)[C@H]1C=CN(C(=C1)C)CCCl)CCCl") ); // bis-dihydropyridinium
OB_ASSERT( doDistGeomStereoTest("Cc1ccc(-c2cccc([C@@H]3C[C@](C)(c4ccccc4)c4cc(C(=N)N)ccc4N3)c2)c(C(=O)O)c1") ); // biaryl amidine
break;
case 19:
// Disaccharide-azo dye conjugate (many stereocenters).
// NOT in distgeom_parts: 38+ heavy atoms → maxIter=380 trials; reliably
// exceeds the 30 s wall-clock limit. Coordinate generation is tested
// via gen3dtest (builder path) instead.
OB_ASSERT( doDistGeomCoordsTest("OC[C@H]1O[C@@H](Oc2ccc(N=Nc3ccccc3)cc2)[C@H](O)[C@@H](O)[C@@H]1O[C@@H]1O[C@H](CO)[C@H](O)[C@H](O)[C@H]1O") );
break;
default:
cout << "Test number " << choice << " does not exist!\n";
return -1;
+180
View File
@@ -0,0 +1,180 @@
/**********************************************************************
gen3dtest.cpp - Unit tests for the gen3D op, including fallback to
distance geometry when OBBuilder fails or produces
zero coordinates.
Copyright (C) 2024 by Geoffrey Hutchison
This file is part of the Open Babel project.
For more information, see <http://openbabel.org/>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation version 2 of the License.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
***********************************************************************/
#include "obtest.h"
#include <openbabel/mol.h>
#include <openbabel/obconversion.h>
#include <openbabel/op.h>
#include <iostream>
#include <sstream>
#include <string>
using namespace std;
using namespace OpenBabel;
// Convert a 3D OBMol to canonical SMILES by doing a full SDF roundtrip so
// that StereoFrom3D is invoked and the SMILES reflects the 3D stereo.
static string canSmiFrom3D(OBMol& mol3D)
{
OBConversion conv;
conv.SetInAndOutFormats("sdf", "can");
ostringstream sdfBuf;
conv.SetOutFormat("sdf");
conv.Write(&mol3D, &sdfBuf);
OBMol mol2D;
conv.SetInFormat("sdf");
istringstream iss(sdfBuf.str());
conv.Read(&mol2D, &iss);
conv.SetOutFormat("can");
string result = conv.WriteString(&mol2D, true);
while (!result.empty() && (result.back() == '\n' || result.back() == '\r' || result.back() == '\t'))
result.pop_back();
return result;
}
// Read SMILES, apply gen3D op at the given speed level, verify 3D
// coordinates are generated and non-zero. If checkStereo is true,
// also verify that canonical SMILES is preserved through the SDF
// roundtrip. Returns true on success.
static bool doGen3DTest(const string& smiles, const char* speed = "3",
bool checkStereo = true)
{
cout << " Testing gen3D(" << speed << "): " << smiles << endl;
OBOp* gen3Dop = OBOp::FindType("gen3D");
if (!gen3Dop) {
cout << " SKIPPED: gen3D op not available" << endl;
return true;
}
OBConversion conv;
conv.SetInFormat("smi");
conv.SetOutFormat("can");
OBMol mol;
OB_REQUIRE(conv.ReadString(&mol, smiles));
string refCan = conv.WriteString(&mol, true);
while (!refCan.empty() && (refCan.back() == '\n' || refCan.back() == '\r'))
refCan.pop_back();
bool opOk = gen3Dop->Do(&mol, speed);
if (!opOk) {
cout << " FAILED: gen3D returned false" << endl;
return false;
}
if (!mol.Has3D() || !mol.HasNonZeroCoords()) {
cout << " FAILED: no valid 3D coordinates generated" << endl;
return false;
}
if (checkStereo) {
string can3D = canSmiFrom3D(mol);
if (refCan != can3D) {
cout << " FAILED: stereo mismatch\n"
<< " ref: " << refCan << "\n"
<< " 3D: " << can3D << endl;
return false;
}
}
cout << " OK" << endl;
return true;
}
int gen3dtest(int argc, char* argv[])
{
int defaultchoice = 1;
int choice = defaultchoice;
if (argc > 1) {
if (sscanf(argv[1], "%d", &choice) != 1) {
printf("Couldn't parse that input as a number\n");
return -1;
}
}
#ifdef FORMATDIR
char env[BUFF_SIZE];
snprintf(env, BUFF_SIZE, "BABEL_LIBDIR=%s", FORMATDIR);
putenv(env);
#endif
switch (choice) {
case 1:
// Basic sanity check: simple molecules at each speed level
OB_ASSERT( doGen3DTest("c1ccccc1") ); // benzene
OB_ASSERT( doGen3DTest("CC(=O)O") ); // acetic acid
OB_ASSERT( doGen3DTest("N[C@@H](C)C(=O)O") ); // L-alanine
break;
case 2:
// Builder fallback: molecules where OBBuilder fails or gives zero
// coordinates (issue #2317). gen3D must fall back to distgeom and
// still produce valid non-zero coordinates.
//
// Triarylmethane as free base and as HCl salt (the .Cl fragment
// caused the builder to return zero coords before the fix).
OB_ASSERT( doGen3DTest("C1=CC(=N)C=CC1=C(C2=CC=C(C=C2)N)C3=CC=C(C=C3)N",
"3", false) );
//OB_ASSERT( doGen3DTest("C1=CC(=N)C=CC1=C(C2=CC=C(C=C2)N)C3=CC=C(C=C3)N.Cl",
// "3", false) );
break;
case 3:
// Stereo preservation through gen3D (using balanced speed level)
OB_ASSERT( doGen3DTest("N[C@](Br)(O)C") );
OB_ASSERT( doGen3DTest("N[C@@](Br)(O)C") );
OB_ASSERT( doGen3DTest("C[C@H]([C@@H](C(=O)O)N)O") ); // L-threonine
OB_ASSERT( doGen3DTest("[C@@H]([C@H](C(=O)O)O)(C(=O)O)O") ); // L-tartaric acid
break;
case 4:
// Ring stereo through gen3D
OB_ASSERT( doGen3DTest("C1CC[C@H]2[C@@H](C1)CCCC2") ); // cis-decalin
OB_ASSERT( doGen3DTest("C1CC[C@@H]2[C@@H](C1)CCCC2") ); // trans-decalin
break;
case 5:
// Complex fused-ring and sugar-ring molecules where OBDistanceGeometry
// alone times out, but the builder handles the ring topology correctly.
// Coord generation only (stereo round-trip unreliable for these systems).
OB_ASSERT( doGen3DTest("CN1CCC2=CC3=C(C=C2[C@@H]1[C@@H]4C5=C(C(=C(C=C5)OC)OC)C(=O)O4)OCO3",
"3", false) ); // berberine analog
OB_ASSERT( doGen3DTest("C1CN2CC3=CC4=C(C=C3[C@H]5[C@H]2C1=C[C@@H]([C@H]5O)O)OCO4",
"3", false) ); // polycyclic alkaloid
OB_ASSERT( doGen3DTest("C1=C[C@H]2C(=CN1C)[C@H]1C(=CC=CN1C)C=C2",
"3", false) ); // vinca-like indole
OB_ASSERT( doGen3DTest("C[C@@H]1[C@H](C[C@@H]([C@H](O1)OC2[C@@H]([C@H](C([C@@H]([C@@H]2O)O)O)O)O)N)N=C(C(=O)O)N",
"3", false) ); // aminoglycoside
OB_ASSERT( doGen3DTest("C1[C@@H](NC(=N[C@H]1O)N)[C@@H](C(=O)O)N",
"3", false) ); // cyclic arginine analog
break;
default:
cout << "Test number " << choice << " does not exist!\n";
return -1;
}
return 0;
}