Thank you for reporting this issue! You are absolutely correct - this is a bug in how convergence status is determined.
The inconsistency exists because:
-
During optimization (
optimization_engine.py), structures converge via three criteria:- Force convergence:
fmax ≤ opttol(default: 0.01 eV/Å) - Energy stability: Energy change < 1e-4 eV for 3 consecutive steps AND
fmax < 10×opttol(0.1 eV/Å) - Oscillation detection: Structures that don't improve for
patiencesteps are dropped
- Force convergence:
-
When writing output (
batchopt.pyline 252), the code only checked:convergence_mask = list(map(lambda x: (x <= self._config_dict['opttol']), fmax))
This only considers force convergence and ignores energy-based convergence and oscillating structures.
Structures that:
- Converged via energy stability (with
0.01 eV/Å < fmax < 0.1 eV/Å) - Were dropped due to oscillation (
oscillating_count >= patience)
...were incorrectly labeled in the output SDF file.
The fix involves three changes:
return dict(
coord=...,
energy=...,
fmax=...,
converged_mask=state['converged_mask'].tolist(), # NEW
oscillating_count=state['oscillating_count'].tolist() # NEW
)converged_mask = optdict['converged_mask']
oscillating_count = optdict['oscillating_count']
patience = self._config_dict['patience']
# Determine true convergence status:
# - Converged: converged_mask=True AND not oscillating
# - Dropped: converged_mask=True AND oscillating
# - Not converged: converged_mask=False
convergence_mask = [
converged and osc_count < patience
for converged, osc_count in zip(converged_mask, oscillating_count)
]is_oscillating = converged_mask[i] and oscillating_count[i] >= patience
mol.SetProp('Dropped_Oscillating', str(is_oscillating))Added comprehensive tests in tests/test_batchopt.py that verify:
ensemble_optreturnsconverged_maskandoscillating_count- Oscillating structures are correctly excluded from convergence
- Energy-stable structures are correctly marked as converged
All tests pass:
tests/test_batchopt.py::TestConvergenceStatus::test_ensemble_opt_returns_convergence_info PASSED
tests/test_batchopt.py::TestConvergenceStatus::test_convergence_mask_excludes_oscillating PASSED
tests/test_batchopt.py::TestConvergenceStatus::test_convergence_with_energy_stability PASSED
- Modified
src/Auto3D/batch_opt/batchopt.py:ensemble_optnow returnsconverged_maskandoscillating_count- Convergence determination uses proper logic combining all criteria
- Added
Dropped_Oscillatingproperty for diagnostics
- Added tests in
tests/test_batchopt.py
You can verify the fix by checking the Converged and Dropped_Oscillating properties in the output SDF:
from rdkit import Chem
mols = list(Chem.SDMolSupplier("output.sdf"))
for mol in mols:
converged = mol.GetProp("Converged")
dropped = mol.GetProp("Dropped_Oscillating")
fmax = float(mol.GetProp("fmax"))
print(f"{mol.GetProp('_Name')}: Converged={converged}, Dropped={dropped}, fmax={fmax:.4f}")Thank you again for catching this! The fix ensures that all three convergence criteria are properly reflected in the output.