Fix conformer search correctness bugs and speed up hot pure-Python paths - #927
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #927 +/- ##
==========================================
+ Coverage 64.20% 64.34% +0.13%
==========================================
Files 119 119
Lines 39578 39601 +23
Branches 10266 10269 +3
==========================================
+ Hits 25412 25482 +70
+ Misses 11196 11137 -59
- Partials 2970 2982 +12
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR fixes multiple correctness issues in ARC’s conformer generation/deduplication pipeline (several of which affected scientific output), and then applies targeted performance improvements to hot pure-Python/numpy paths (RDKit conformer construction, distance-matrix usage, bond perception).
Changes:
- Fixes conformer energy/geometry mispairing and dedup scan logic, and makes the iterative “lowest conformer” descent actually advance and terminate on non-improvement.
- Hardens RDKit force-field optimization against infinite retry/hang scenarios and keeps xyz/energy lists index-aligned across fallbacks.
- Speeds up key paths by avoiding redundant coordinate/distance-matrix construction and vectorizing
Molecule.connect_the_dots().
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
arc/species/converter.py |
Avoid redundant coordinate array builds; construct RDKit conformers directly from provided xyz; add precompute hooks to first/last-atom prefilter; reduce repeated dmat work in RMSD clustering. |
arc/species/converter_test.py |
Adds regression tests for xyz_to_dmat, pinned-coordinate conformer construction, and compare_confs_fl precompute equivalence. |
arc/species/conformers.py |
Correctness fixes in conformer descent/dedup and energy handling; deprecates/ignores legacy optimize arg in dihedral+FF helper; imports and uses AllChem explicitly; hardens rdkit_force_field. |
arc/species/conformers_test.py |
Updates/expands tests to pin the corrected conformer search behavior and prevent RDKit FF hang/misalignment regressions. |
arc/molecule/molecule.py |
Vectorizes connect_the_dots(), fixes bond-removal preamble, and adds explicit validation for bad coordinates. |
arc/molecule/molecule_test.py |
Adds comprehensive connect_the_dots() tests including idempotency, NaN/inf handling, and reference-algorithm equivalence. |
arc/common.py |
Reduces allocation in distance_matrix() by squaring in-place. |
arc/common_test.py |
Adds unit tests for distance_matrix() including trivial, normal, and error cases. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| return xyzs, energies | ||
| for i in range(rd_mol.GetNumConformers()): | ||
| if output is not None and output[i][0] == 0: # The optimization converged. | ||
| if output is None: |
There was a problem hiding this comment.
It seems there are two cases of output is None but this currently only handles one of them.
optimize=False, never run UFF and no energies to reportoptimize=True, MMFF gives us nothing and then UFF was called and raise andtry_ob=False. So output is stillNonebut we return it with geoms and zero energies but are treated as if it were optimized
There was a problem hiding this comment.
Good catch — fixed.
You're right that output is None was covering two unrelated situations. Case 1 (optimize=False) is the intended one: no optimization was asked for, so geometries are kept and there are no energies to report. Case 2 is the bug: optimization was requested, UFF raised, and with try_ob=False the exception was swallowed, leaving output at None and dropping straight into the same branch — so unoptimized geometries were emitted as though they had been optimized, with an empty energies list.
The failure path now logs a warning and returns empty lists instead, so by the time control reaches the loop below, output is None can only mean "optimization was not requested" — which is what the comment there claims.
Two tests pin the pair apart so they can't be conflated again:
test_rdkit_force_field_uff_failure_without_openbabel_reports_nothing— asserts both lists come back empty (fails before the fix; it returned all 3 geometries).test_rdkit_force_field_unoptimized_keeps_geometries_in_uff_fallback— pins thatoptimize=Falsestill yields every geometry and no energies, so the fix doesn't over-correct into dropping the legitimate case.
| xyzs, energies = change_dihedrals_and_force_field_it(label, mol, xyz=base_xyz, torsions=[tor], | ||
| new_dihedrals=[[sp] for sp in sampling_points], | ||
| force_field=force_field, optimize=False) | ||
| force_field=force_field) |
There was a problem hiding this comment.
So since we are not optimizing, we are then getting the FF-relaced structures instead which I suppose means many sampling points fall into the same energy well (and thus duplicates are thrown away) which in turn gets us ferew, different conformers? This was intended?
There was a problem hiding this comment.
Why are we dropping the optimization being True for the conformer generation?
There was a problem hiding this comment.
Answering both of your comments in this thread together, since they have the same root.
optimize never controlled whether optimization happened. The inner call was hardcoded:
xyz_, energy = get_force_field_energies(..., optimize=True, ...) # always optimizes
if energy and xyz_:
energies.append(energy[0]) # ALWAYS the relaxed energy
if optimize:
xyzs.append(xyz_[0]) # relaxed geometry
else:
xyzs.append(xyz_dihedrals) # rigid geometryThe flag only chose which geometry was returned alongside an always-relaxed energy. This call site passed optimize=False, so it stored the rigid geometry paired with the relaxed structure's energy — the two described different molecules. That mispairing was worth 0.49 kcal/mol on the case I measured. So the argument is gone rather than pinned to True because there was never a path here that skipped optimization; keeping the flag would only have preserved the ability to request an inconsistent pair.
On getting fewer conformers — yes, intended. The sampling points are seeds for the minimization, not measurement points. Several seeds relaxing into one well means those seeds located the same minimum, and dropping the duplicates is the correct outcome rather than a loss. For the NCC torsion in your other comment, the six seeds collapse to three because that torsion genuinely has three staggered minima.
The acceptance criterion is whether the search still reaches the global minimum, not how many conformers survive. I checked that across 18 species × 2 modes: there is no species where this branch's lowest conformer is higher in energy than main's.
| 'FF energy': energy, | ||
| 'source': f'Changing dihedrals on most stable conformer, iteration {i}', | ||
| 'torsion': tor, | ||
| 'dihedral': round(dihedral, 2), |
There was a problem hiding this comment.
So the dihedral we asked for will not be the dihedral of the xyz since the xyz is a relaxed structure. We may then need to re-measure the dihedral?
On NCC, torsion (1,2,3,8):
| requested | rigid (old optimize=False xyz) |
relaxed (new xyz) | drift | energy |
|---|---|---|---|---|
| 0 | 360.00 | 59.91 | 59.9 | -6.150 |
| 60 | 60.00 | 59.91 | 0.1 | -6.150 |
| 120 | 120.00 | 179.53 | 59.5 | -6.150 |
| 180 | 180.00 | 179.53 | 0.5 | -6.150 |
| 240 | 240.00 | 299.16 | 59.2 | -6.150 |
| 300 | 300.00 | 299.16 | 0.8 | -6.150 |
Or we could add a torsion constraintt to the MMFF optimisation for the torsions being scanned?
There was a problem hiding this comment.
Reproduced your table exactly, to the decimal (0→59.91, 120→179.53, 240→299.16). Two things worth adding, then the fix.
The flat profile predates this PR. All six points report the same energy (−6.150) — and they do on main too, because main also took its energy from the relaxed structure while returning the rigid geometry. So main had geometry at the requested angle paired with an energy belonging to a different structure; this branch has geometry and energy agreeing, with the dihedral field now being the odd one out. Neither version ever produced a real torsion profile, so what you've found is a labelling defect rather than a regression in the scan.
Re-measuring the dihedral looks like the obvious fix but breaks the consumer. plotter.py:1088 matches the stored dihedral against the sampling point within 0.1° and then calls min(y); storing measured values drops most of those matches and can empty y outright. So in this PR I've left dihedral as the seed label and documented it as such in change_dihedrals_and_force_field_it — a seed for the minimization, not a measurement of the returned geometry, whose actual dihedral may differ because a well's minimum doesn't generally sit on the sampling grid.
Your constraint suggestion is the right answer, and it's what I'm implementing — in #932. That branch already does exactly this for ring puckers, in optimize_conformer_with_frozen_ring, whose docstring names the identical failure mode: the seed's pucker, "which an unconstrained optimization would otherwise erase", is held through stage 1 while everything else relaxes, then stage 2 releases the constraint and polishes into that basin's true local minimum. Torsion seeds have the same problem and should get the same treatment (ff.MMFFAddTorsionConstraint for stage 1).
One refinement on the form it should take: a constrained scan — reporting the constrained energies — would compare badly against base_energy in the descent loop, which is unconstrained. Constrained energies are always ≥ their unconstrained counterparts, so lowest_conf_i['FF energy'] < base_energy - BASE_CONFORMER_ENERGY_TOL would be biased against advancing and could stall the search. The two-stage form avoids that, because stage 2's energy is a genuine unconstrained minimum and stays directly comparable.
Worth flagging what this actually buys, since the ethylamine case doesn't show it: the real cost of releasing the constraint immediately is that a rigid rotation into a sterically strained arrangement can throw the minimizer clean out of the basin you were aiming at, landing it on a conformer you already have — where it is silently deduplicated as a success. That failure grows with substituent bulk, i.e. exactly the molecules where conformer search earns its keep. #932 has the global-minimum-recovery metric to measure whether the two-stage version recovers basins that are currently lost.
There was a problem hiding this comment.
Correcting my reply above: I said I'd implement the constrained pre-relax in #932. I measured it first, and the measurement doesn't support doing it — so we're not, and I'd rather post the numbers than quietly drop it.
One-stage (current) vs two-stage (constrained pre-relax, then release and polish), over every rotor ARC detects, seeded every 30°:
| species | rotors | distinct geometries 1→2 stage | global min |
|---|---|---|---|
| n-butane | 3 | 7 → 7 | −5.076 = −5.076 |
| 2,3-dimethylbutane | 5 | 11 → 11 | 6.401 = 6.401 |
| neopentyl alcohol | 5 | 11 → 11 | 12.045 = 12.045 |
| 2,2,3,3-tetramethylbutane | 7 | 43 → 38 | 29.762 = 29.762 |
| glycerol | 5 | 8 → 10 | 34.319 = 34.319 |
| 1-hexanol | 6 | 13 → 13 | −2.417 = −2.417 |
| 1,2-dichloroethane | 1 | 3 → 3 | 5.014 = 5.014 |
| ethylene glycol | 3 | 6 → 7 | 16.065 = 16.065 |
| iso-octane | 7 | 20 → 25 | 18.432 = 18.432 |
| n-heptane | 6 | 13 → 13 | −5.680 = −5.680 |
The global minimum is identical in all ten — not lower in a single case. The constraint changes which basins get visited (more in three species, fewer in one), but not the answer the search exists to produce.
It also specifically contradicts the mechanism I proposed to you. I argued that steric bulk makes the released minimizer overshoot its intended basin, so crowded molecules should lose minima. The most crowded species here, 2,2,3,3-tetramethylbutane, finds fewer distinct geometries with the constraint, not more.
Against that, the cost is concrete: two FF minimizations per seed instead of one, in the hottest loop of the conformer search — which would hand back a large share of the 1.6–2.3× end-to-end speedup this PR is for.
So the torsion path stays as it is, with dihedral documented as a seed label. The ring case in #932 remains different on purpose: a pucker seed is a distinct chemical state that an unconstrained relax collapses, so holding it changes which minima exist at all; a torsion seed is only a starting point on the way to minima that are reachable regardless.
Probe and raw output are kept outside the repo (torsion_two_stage_probe.py / torsion_two_stage_results.txt) — happy to paste the script or run it over any species set you think would break the conclusion. Thanks for pushing on this one; it was worth measuring.
| fl_distance1, dmat1_, conf, similar = converter.compare_confs_fl( | ||
| xyz, conf, fl_distance1=fl_distance1, dmat1=dmat1) | ||
| if dmat1_ is not None: | ||
| dmat1 = dmat1_ |
There was a problem hiding this comment.
Can dmat1_ be None? I may have missed it but it appears compare_confs_fl returns the dmat1 it was passed unchanged on the dissimilar path, and a computed one on a similar path.
There was a problem hiding this comment.
Correct — that guard was dead code, and it's removed.
compare_confs_fl() returns the dmat1 it was handed on the dissimilar path and a freshly computed one on the similar path, so dmat1_ is None only when dmat1 was already None. if dmat1_ is not None: dmat1 = dmat1_ was therefore equivalent to a plain assignment in every case, and it now unpacks straight into dmat1, with a comment recording why that can't clobber an already-computed matrix.
577cb23 to
0aed531
Compare
## Symptom CI has been failing repo-wide **before a single test runs**, in the `Set up micromamba` step — tonight on `perf_hotspots` (#927) twice, `fix_nmd_aa_reaction_atom_count`, and `input_schema`: ``` ##[error]Could not find a micromamba binary for release 2.9.0-0 on platform linux-64. ##[error]fetch failed ##[error]Unexpected end of JSON input ``` The message is misleading. Release `2.9.0-0` **does** ship `micromamba-linux-64` — uploaded 2026-08-07, `state: uploaded`, 18 MB — and a `HEAD` on that URL answers `302 -> 200`. Nothing upstream is missing. The *lookup* is what fails. ## Cause `v3.2.0` (#310, "Support prereleases of micromamba", 2026-08-05) replaced the direct download URL with a resolution step in `src/micromamba-version.ts`: list releases over `api.github.com`, then probe the asset with a `HEAD` via `urlExists()`. `fetchLatestStableMicromambaTag()` wraps its call in `try/catch` and falls back to `/releases/latest/download`. `resolveGithubAssetUrl()` has no such guard: ```ts const resolveGithubAssetUrl = async (arch, tag) => { for (const url of githubAssetUrls(arch, tag)) { if (await urlExists(url)) return url } throw new Error(`Could not find a micromamba binary for release ${tag} on platform ${arch}.`) } ``` One flaky `HEAD` and the step dies. The `v3` floating tag has pointed at this code since 2026-08-06, which is why it started biting now — and why it is intermittent (`linear_ts_bugfix` passed at 17:59 between two failures). ## Fix Pin all three `setup-micromamba` steps — one in `ci.yml`, two in `gh-pages.yml` — to **`v3.1.0`**, which predates #310. There `src/util.ts` returns the download URL directly: ```ts return `https://github.com/mamba-org/micromamba-releases/releases/download/${version}/micromamba-${arch}` ``` No API call, no `HEAD` probe, so the failure mode does not exist. `micromamba-version.ts` isn't even in that tree. Every input we pass (`environment-name`, `environment-file`, `create-args`, `condarc`, `cache-environment`, `cache-environment-key`, `cache-downloads`, `generate-run-shell`) is supported in `v3.1.0`. ## What was ruled out - **`GITHUB_TOKEN` on the step** — first attempt, on the theory that `githubApiHeaders()` sending unauthenticated requests meant 60/hr per-runner-IP throttling. Pushed it, CI failed **identically**. Not the cause; dropped. - **Pinning `micromamba-version` instead** — does not help. That path still calls `resolveGithubAssetUrl()`, i.e. the same `HEAD`. It only skips the list call, which was already the one with a fallback. - **A broken upstream release** — refuted by the asset metadata and a live `302 -> 200`. ## Verification Both files parse; all three steps pinned, no leftover `env`: ``` .github/workflows/ci.yml | build-and-test | Set up micromamba (arc_env) -> mamba-org/setup-micromamba@v3.1.0 .github/workflows/gh-pages.yml | deploy | Set up micromamba (rmg_env) -> mamba-org/setup-micromamba@v3.1.0 .github/workflows/gh-pages.yml | deploy | Set up micromamba (arc_env) -> mamba-org/setup-micromamba@v3.1.0 steps: 3 ``` The real check is this PR's own CI getting past `Set up micromamba` into the test steps. Worth reporting upstream as a missing fallback in `resolveGithubAssetUrl()`; this pin holds until it's fixed.
1319b73 to
811c146
Compare
| rd_conf.SetAtomPosition(i, xyz['coords'][i]) | ||
| conf_id = rd_mol.AddConformer(rd_conf, assignId=True) | ||
| conf = rd_mol.GetConformer(id=conf_id) | ||
| return conf, rd_mol |
There was a problem hiding this comment.
conf can no longer be None on any path - the function either raises or returns a conformer - so the if conf is not None: guards at conformers.py:401 and :784 are now dead. Same situation as the dmat1_ guard you dropped earlier in this PR.
There was a problem hiding this comment.
Correct on both counts, and there was a third one — fixed.
rdkit_conf_from_mol() used to reach a None through the embed:
try:
AllChem.EmbedMolecule(rd_mol)
except:
pass
conf = None
if rd_mol.GetNumConformers(): # 0 when the embed failed -> conf stays None
conf = rd_mol.GetConformer(id=0)The bare except: pass meant a failed embed left zero conformers and the function returned (None, rd_mol). That is what the guards were for. Now the conformer is constructed directly and always added, so the function either raises ConverterError or returns a real Conformer — rd_mol.GetConformer(id=conf_id) raises ValueError on a bad id rather than returning None, so there is no surviving path to a None.
Verified rather than assumed, since "no path returns None" is the kind of claim that is cheap to check and expensive to get wrong:
NCC / C / O / c1ccccc1 / CC(C)(C)C(C)(C)C / [OH] / O=C=O -> conf is None? False (all)
GetConformer(valid id) is None? False
GetConformer(bogus id) -> raises ValueError
coord/atom count mismatch -> raises ConverterError
Removed: the guards at conformers.py:401 and :784 — neither had an else, so dropping them is behaviour-preserving.
The third: plotter.py:118, if conf is None: return False in show_sticks(), same shape and equally dead. Worth noting it was the only way show_sticks() returned False for a failed embed; with the embed gone, the failure it guarded cannot occur.
The invariant is pinned by an existing test rather than left implicit — test_rdkit_conf_from_mol_coordinates_pinned asserts assertIsNotNone(conf) across four species including C12(CC3)CC(CC1)CC3C2, a cage chosen precisely because ETKDG fails to converge on it. That is the exact input that produced conf = None on main, so if anyone reintroduces a None path the test catches it.
Folded into Drop the discarded ETKDG embed in rdkit_conf_from_mol() rather than added as a follow-up commit, since that is the commit that made the guards dead — so it is now self-contained: removes the None-producing path, its dead guards, and pins the invariant.
One thing I deliberately did not change, to flag rather than silently fix: show_sticks() catches (ValueError, AttributeError), and ConverterError subclasses Exception, not ValueError — so a malformed xyz propagates out of show_sticks() instead of returning False. That is pre-existing (main raises ConverterError for a wrong-type xyz too) and orthogonal to this PR, so it seemed wrong to widen the scope here. Happy to fix it in this PR if you'd prefer.
MaroGol
left a comment
There was a problem hiding this comment.
Apart from the one comment I had, the optimizations look good.
rdkit_conf_from_mol() ran a full AllChem.EmbedMolecule() distance-geometry embedding and then immediately overwrote every atom position from the given xyz, so the embedding result was always discarded. Build the conformer directly instead, the way conformers.embed_rdkit() already does. This function sits on the conformer search hot path: it is called once per torsion per sampled dihedral inside change_dihedrals_and_force_field_it(), which itself runs up to MAX_COMBINATION_ITERATIONS times. Measured 6.5x-70x faster on real species (ethane 12x, a 57-atom species 70x). Behavior change: previously, when the embedding failed (returns -1 for some strained structures), no conformer existed and the function returned None, which silently skipped the requested dihedral change at conformers.py:397 and conformers.py:761. A conformer is now always returned, so those dihedral changes are always applied.
cluster_confs_by_rmsd() called compare_confs(), which rebuilt BOTH conformers' distance matrices from scratch on every pairwise comparison, so each conformer's matrix was recomputed once per representative rather than once. Precompute each conformer's upper-triangular distance matrix up front and feed it through compare_confs()' existing skip_conversion/dmat1/dmat2 path. Measured 3.7x-8x faster (10-150 conformers). xyz_to_dmat() built the coordinate array twice per call, and each build ran check_xyz_dict() again; build it once. 1.1x-1.4x faster. distance_matrix() materialized both an (N, M, 3) difference array and an (N, M, 3) squared copy; square in place to drop the second temporary. Output is bitwise identical (np.einsum was tried first and rejected: it differs from the original by 1-2 ULP).
Replace the per-pair Python double loop with a single numpy pairwise computation. The z-sorted early-break is preserved exactly as a mask (|dz| > 4.0 excludes the pair), the inclusive/exclusive distance bounds are unchanged, and bonds are still added in z-sorted (i, j > i) order so atom edge insertion order is untouched. Bond sets are identical on every species tested. The win is modest on real geometries (~3-6%): real molecules have genuine z-spread, so the original's early-break already pruned most of the inner loop. It only looks dramatic on artificially planar coordinates, where that break never fires. Adds the first direct tests for connect_the_dots(), covering 1 atom, H2, methane, a heteroatom ring, separated fragments and an empty molecule.
The MMFF optimization retry loop only incremented its counter in the `else` clause, so a call that raised on every attempt spun forever: `j` never advanced and `v` never changed. Break out of the loop on failure instead, and narrow the bare `except`, which also swallowed KeyboardInterrupt and SystemExit. Guard `ff is not None` before evaluating its energy, and import AllChem explicitly. The module called `Chem.AllChem.*` in five places while only importing `Chem`, which happened to work solely because converter.py imports the submodule first and thereby sets the attribute on the parent package. Without the bare `except` swallowing it, that latent AttributeError would otherwise escape into the conformer pipeline.
… the dedup scan Two correctness bugs in the iterative conformer search. Both change ARC's scientific output. 1. change_dihedrals_and_force_field_it() called get_force_field_energies() with optimize=True unconditionally, so the energy it returned always belonged to the FF-optimized geometry. When its own `optimize` argument was False it nevertheless stored the *unoptimized* torsion geometry alongside that energy, so every conformer it produced carried an energy describing a different structure. conformers_combinations_by_lowest_conformer() is exactly that caller, and get_lowest_confs() then ranked geometries by energies that were not theirs. Measured on ethylamine, the reported energy was off by up to 0.49 kcal/mol from the true energy of the reported geometry. Always store the matching optimized pair. The `optimize` argument is removed rather than left as a no-op: the FF optimization was performed either way, so it never saved any work, and its only effect was to decide whether the stored geometry was a lie. Conformers are now local minima labelled with their own energies. 2. The duplicate scan in conformers_combinations_by_lowest_conformer() treated compare_confs_fl(), a cheap first/last-atom-distance prefilter, as if a negative result ended the search. It only means the one candidate under test is not a duplicate, and the list is in generation order with no ordering invariant to exploit, so `break` skipped every conformer past the first dissimilar one and let duplicates through. Use `continue`, and carry the candidate's distance matrix across iterations, since compare_confs_fl() returns it as None precisely when the prefilter rejects. The conformer count for the deduce_new_conformers test drops from 9 to 6: those three were duplicates that the truncated scan had been admitting.
conformers_combinations_by_lowest_conformer() is an iterative descent: each round samples dihedrals from the lowest conformer found so far. It updated base_energy but never reassigned base_xyz, so all MAX_COMBINATION_ITERATIONS rounds re-sampled dihedrals from the original geometry and regenerated the same conformers, which were then discarded as duplicates. The loop-bottom exit could not stop this: it required the round's lowest conformer to compare equal to the frozen base geometry, which it essentially never does. So the loop always ran its full budget doing redundant force field work. Reassign base_xyz whenever the round improves on the base energy, and stop as soon as a round fails to improve. Also round base_energy at initialisation: per-conformer energies are stored via round(energy, 3), so comparing them against a full-precision base mixed precisions and made the convergence test unreliable. The old `if not newest_conformer_list: newest_conformer_list = [lowest_conf_i]` fallback is replaced by a plain break. It reused the previous round's lowest conformer, which is None on the first iteration. Measured via ARCSpecies.generate_conformers(n_confs=10) against official/main, the conformers produced are bitwise identical for ethanol, n-butanol, n-hexanol, iso-octane, glycerol and hexyl radical, while wall time drops 1.6x-2.3x on the species with several rotors (n-hexanol 11.61s -> 5.11s, glycerol 3.03s -> 1.37s, hexyl radical 2.68s -> 1.63s).
…ol to kcal/mol RDKit's MMFF/UFF and OpenBabel's MMFF94/UFF/GAFF implementations all report energies in kcal/mol, but DE_THRESHOLD and the de_threshold/e_confs docstrings described them as kJ/mol. The thresholds are compared directly against those energies, so the documented units were off by a factor of ~4.2.
list_available_nodes() lowercased cluster_soft before indexing list_available_nodes_command, whose keys are 'OGE'/'Slurm'/'PBS'. Every other method in ssh.py indexes with the original-cased value, so status/submit/delete worked while the node-change troubleshooting path raised KeyError on any non-HTCondor cluster (surfaced on a PBS server, Zeus). Index with the original-cased key; PBS then reaches its existing 'not yet implemented -> return empty' branch and troubleshooting falls back gracefully.
What this is
Seven commits. Four of them change ARC's scientific output and are described first;
the behaviour-preserving speedups follow. Everything was measured on one machine against
main(b0dd288c7) using a pristine baseline worktree, interleaved A/B where possible,Behaviour changes (please review these first)
1. Conformer geometries were paired with energies belonging to a different structure
change_dihedrals_and_force_field_it()always calledget_force_field_energies()withoptimize=True, so the energy it returned described the FF-optimized geometry. But whenits own
optimizeargument wasFalseit stored the unoptimized torsion geometrynext to that energy.
conformers_combinations_by_lowest_conformer()is exactly thatcaller, so every conformer it produced carried an energy that was not its own, and
get_lowest_confs()ranked geometries by foreign energies.Measured on ethylamine, the reported energy was off by up to 0.49 kcal/mol from the
true energy of the reported geometry. It is now 0.000000 on every sample.
The
optimizeargument is kept as a deprecated, ignored keyword rather than removed:dropping it would have silently rebound a legacy positional
Falseontoforce_field.2. The duplicate scan stopped at the first non-duplicate
compare_confs_fl()is a cheap first/last-atom-distance prefilter. A negative result meansonly that that one conformer is not a duplicate, but the loop treated it as
break, soevery conformer past the first dissimilar one went unchecked and duplicates were admitted.
The list is in generation order with no ordering invariant that would justify the early
exit. Now
continue, carrying the candidate's distance matrix across iterations sincecompare_confs_fl()returns it asNoneprecisely when the prefilter rejects.Because
continueremoves the short-circuit, this scan became a full pass per candidate, sothe first/last-atom distance and the candidate's distance matrix are now computed once per
candidate and threaded into
compare_confs_fl()rather than being rebuilt on everycomparison. On hexanediol that cuts
xyz_to_dmat()builds by 37% (76 -> 48) with identicalconformers returned.
3. The iterative conformer descent never advanced its base geometry
conformers_combinations_by_lowest_conformer()updatedbase_energybut never reassignedbase_xyz, so allMAX_COMBINATION_ITERATIONS(25) rounds re-sampled dihedrals from theoriginal geometry and regenerated the same conformers, which were then discarded as
duplicates. The loop-bottom exit required the round's lowest conformer to compare equal to
the frozen base, which essentially never happens, so the full budget was always spent on
redundant force-field work.
base_xyznow advances whenever a round improves on the base energy, and the loop stopswhen a round fails to improve. Energies are no longer rounded to 3 decimals before the
internal comparison, which had made the convergence test degenerate against a 1e-3
tolerance.
4.
rdkit_force_field()could hang foreverThe MMFF retry loop incremented its counter only in the
elseclause, so a call thatraised on every attempt spun forever with no progress. The bare
exceptalso swallowedKeyboardInterrupt/SystemExit, and a failed optimization was still reported as anoptimized conformer. Fixed, narrowed, and the conformer is now skipped so the existing
UFF/OpenBabel fallback can engage.
The same function's UFF fallback had the identical geometry/energy pairing defect as #1: it
appended the xyz unconditionally but the energy only on convergence, so with three conformers
where 0 and 2 converge and 1 does not you got
energies == [E0, E2]againstxyzs == [X0, X1, X2]. Both call sites zip them, so X1 was handed E2 and X2 was droppedentirely. Non-converged conformers are now excluded from both lists together.
This also required importing
AllChemexplicitly: the module calledChem.AllChem.*infive places while importing only
Chem, which worked solely becauseconverter.pyimportsthe submodule first and thereby sets the attribute on the parent package. With the bare
exceptgone, that latentAttributeErrorwould have escaped into the conformer pipeline.Blast radius of 1-3
generate_conformers(n_confs=10)), the delivered conformers areidentical for ethanol, n-butanol, n-hexanol, iso-octane, glycerol and hexyl radical,
while wall time drops 1.6x-2.3x on the species with several rotors (n-hexanol
11.61s -> 5.11s, glycerol 3.03s -> 1.37s, hexyl radical 2.68s -> 1.63s).
(
combination_threshold=10,n_confs=50,e_confs=50), fewer conformers aredelivered (e.g. hexanediol 21 -> 15,
OCC(O)CC(O)CO50 -> 15) because the duplicates thetruncated scan had been admitting are now caught. In every case tested the lowest-energy
conformer is preserved to within 3e-4 kcal/mol.
Does the greedy stop cost us the global minimum?
The goal of this search is to find the global minimum, so the only acceptance criterion that
matters is whether the new early stop can return a higher minimum than before. It cannot,
and this is provable rather than merely measured.
The round is deterministic in
base_xyz. The only randomness anywhere in conformer generationis
EmbedMultipleConfs(..., randomSeed=1), a fixed seed, and it is used only by the earlierrandom-conformer stage, not here - this path is handed an explicit
xyz. So if a round failsto improve,
base_xyzis unchanged, and the next round re-samples the same dihedrals from thesame geometry and produces bit-identical conformers, which then dedup away. Continuing cannot
reach a new basin; it is exactly the redundancy described above. The conformers generated by
the final, non-improving round are still kept - they are appended before the loop exits - so
nothing is discarded either.
Verified empirically across 18 species (alcohols, diols, polyols, glycols, amines,
diamines, an acid, an ester, a thiol, an aldehyde, a branched alkane and a radical), under
both default settings and forced settings (
combination_threshold=10,n_confs=50,e_confs=50): the baseline ran its full 25 rounds and never found a lower minimum thanthe early-terminating branch. In all 36 comparisons the largest deviation is 4e-4 kcal/mol,
which is the de-rounding described above, not a search difference.
Performance (behaviour-preserving)
rdkit_conf_from_mol()- the largest winIt ran a full ETKDG
AllChem.EmbedMolecule()and then overwrote every atom position fromthe supplied xyz, so the entire embedding was computed and thrown away - once per torsion
per sampled dihedral, inside the 25-iteration loop above. It now builds the conformer
directly. Also
Set3D(True),AddConformer(assignId=True), and a coordinate-count check(previously a short xyz raised a raw
IndexErrorand a long one was silently truncated).Molecule.connect_the_dots()- vectorizedReplaces the O(N^2) Python double loop with a numpy pairwise formulation. Measured
compiled against compiled (this module is cythonized, so timing a Python copy of the
old function would measure the Cython compiler rather than the change):
The function's "delete all bonds and set them again" preamble never worked:
get_bonds()returns a
{atom: bond}dict, so iterating it yielded atoms, whichremove_edge()rejects.Any call on an already-bonded molecule raised. It is fixed here (collect the bonds once,
deduplicating, then remove them) since this PR is the one rewriting and documenting the
function, and
connect_the_dots()is now covered by an idempotency test.Two deliberate details: the squared distance accumulates one axis at a time rather than
materializing an
(N, N, 3)cube, and it iterates overcoords.shape[1]rather thanhardcoding x/y/z so it stays equivalent to the original
sum((c1 - c2) ** 2)for anydimensionality. Non-finite coordinates now raise
ValueError: previously every comparisonagainst
NaNevaluatedFalse, control fell through to theelsebranch, and a bond wasadded between every such pair, silently producing a nonsense fully-bonded molecule.
cluster_confs_by_rmsd()- compute each fingerprint onceEach conformer's
np.triu(dmat)is computed once and passed throughcompare_confs()instead of both distance matrices being rebuilt on every pairwise comparison.
3.7x (10 conformers) rising to 11.1x (150).
xyz_to_dmat()builds its coordinatearray once instead of twice: 1.11x-1.46x.
common.distance_matrix()squares in place rather than allocating a second(N, N, 3)temporary. This is bitwise identical (
np.einsumwas tried first and rejected - it differsby 1-2 ULP, and this function is on a production path via
perceive.py:78). It shows nomeasurable speed change; it is included for the reduced allocation only, not as a win.
Units
DE_THRESHOLD,e_confsand thede_thresholddocstrings described force field energiesas kJ/mol. RDKit's MMFF/UFF and OpenBabel's MMFF94/UFF/GAFF all report kcal/mol, and these
thresholds are compared directly against those energies, so the documented units were off
by a factor of ~4.2. Comments only; no behaviour change.
Testing
Full unit suite: 2493 passed, 3 skipped (the 3 skips are pre-existing, UMA env
unavailable), up from 2477 on
main. 16 new tests.One existing expectation changed:
test_deduce_new_conformersconformer count, because ithad encoded the truncated dedup scan.
Notes for reviewers
connect_the_dots()is reachable only vias_bonds_mol_from_xyz()(test-only callers)and
Molecule.from_xyz()(no callers), andcluster_confs_by_rmsd()has no productioncall sites. Their speedups are real but do not move ARC's wall-clock. The wins that do are
rdkit_conf_from_mol()and the conformer descent fix.MMFFOptimizeMoleculeConfs(), reusing one RDKitMolacross the dihedral loop, theO(N^2)
.index()into_rdkit_mol(), andcolliding_atoms().