diff --git a/.github/workflows/base-ci.yaml b/.github/workflows/base-ci.yaml new file mode 100644 index 00000000..f6fcac98 --- /dev/null +++ b/.github/workflows/base-ci.yaml @@ -0,0 +1,77 @@ +name: Base tests +on: + push: + branches: + - main + pull_request: + branches: + - main + schedule: + # weekly tests, Sundays at midnight + - cron: "0 0 * * 0" + +concurrency: + # Specific group naming so CI is only cancelled + # within same PR or on merge to main + group: ${{ github.ref }}-${{ github.head_ref }}-${{ github.workflow }} + cancel-in-progress: true + +defaults: + run: + shell: bash -l {0} + +env: + OE_LICENSE: ${{ github.workspace }}/oe_license.txt + +jobs: + main_tests: + name: CI (${{ matrix.os }}, py-${{ matrix.python-version }}, openeye=${{ matrix.include-openeye }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macOS-13, macOS-latest, ubuntu-latest] + python-version: ["3.10", "3.11", "3.12"] + include-openeye: [false, true] + + steps: + - uses: actions/checkout@v4 + + - name: Build information + run: | + uname -a + df -h + ulimit -a + + - name: Install environment + uses: mamba-org/setup-micromamba@v2 + with: + environment-file: devtools/conda-envs/base.yaml + create-args: >- + python=${{ matrix.python-version }} + + - name: Install package + run: | + python -m pip install . --no-deps + + - uses: ./.github/actions/include-openeye + if: matrix.include-openeye == true + with: + openeye-license-text: ${{ secrets.OE_LICENSE }} + openeye-license-file: ${{ env.OE_LICENSE }} + + + - name: Uninstall OpenEye + if: matrix.include-openeye == false + run: conda remove --force openeye-toolkits --yes || echo "openeye not installed" + + - name: Check toolkit installations + shell: bash -l -c "python -u {0}" + run: | + from openff.toolkit.utils.toolkits import OPENEYE_AVAILABLE, RDKIT_AVAILABLE + assert str(OPENEYE_AVAILABLE).lower() == '${{ matrix.include-openeye }}' + assert str(RDKIT_AVAILABLE).lower() == 'true' + + - name: Run tests + run: | + python -m pytest -n 4 -v --cov=openff/nagl --cov-config=setup.cfg --cov-append --cov-report=xml --color=yes openff/nagl/ diff --git a/.github/workflows/dev-ci.yaml b/.github/workflows/dev-ci.yaml index dfa6c369..6b4e4407 100644 --- a/.github/workflows/dev-ci.yaml +++ b/.github/workflows/dev-ci.yaml @@ -14,6 +14,10 @@ concurrency: defaults: run: shell: bash -l {0} + +env: + OE_LICENSE: ${{ github.workspace }}/oe_license.txt + DGL_HOME: ${{ github.workspace }}/dgl jobs: nightly_check: @@ -22,8 +26,8 @@ jobs: strategy: fail-fast: false matrix: - os: [macOS-12, ubuntu-latest] - python-version: ["3.10", "3.11", "3.12"] + os: [macOS-13, macOS-latest, ubuntu-latest] + python-version: ["3.11", "3.12"] steps: - uses: actions/checkout@v4 @@ -42,25 +46,63 @@ jobs: python=${{ matrix.python-version }} pydantic=2 - - name: Install nightly pytorch-lightning and DGL + - name: Install nightly pytorch run: | - python -m pip install --pre dgl -f https://data.dgl.ai/wheels-test/repo.html - python -m pip install --pre dglgo -f https://data.dgl.ai/wheels-test/repo.html - python -m pip install https://github.com/Lightning-AI/lightning/archive/refs/heads/master.zip -U + python -m pip install https://github.com/Lightning-AI/lightning/archive/refs/heads/master.zip -U - - name: Install package + - name: Download DGL source run: | - python -m pip install . --no-deps + git clone --recurse-submodules https://github.com/dmlc/dgl.git + + - name: Set up DGL + if: matrix.os != 'ubuntu-latest' + run: | + # from https://docs.dgl.ai/en/latest/install/index.html#macos + cd dgl/ + mkdir build && cd build + cmake -DUSE_OPENMP=off -DUSE_LIBXSMM=OFF .. + make -j4 + cd ../.. + + - name: Set up DGL (Ubuntu) + if: matrix.os == 'ubuntu-latest' + run: | + cd dgl/ + mkdir build && cd build + cmake -DBUILD_TYPE=dev -DUSE_CUDA=OFF -DUSE_LIBXSMM=OFF .. + make + cd ../.. + + - name: Install DGL Python bindings + run: | + cd dgl/python + python setup.py install + python setup.py build_ext --inplace + cd ../.. - - name: Python information + - uses: ./.github/actions/include-openeye + with: + openeye-license-text: ${{ secrets.OE_LICENSE }} + openeye-license-file: ${{ env.OE_LICENSE }} + + - name: Check toolkit installations + shell: bash -l -c "python -u {0}" run: | - which python - conda info - conda list + from openff.toolkit.utils.toolkits import OPENEYE_AVAILABLE, RDKIT_AVAILABLE + assert str(OPENEYE_AVAILABLE).lower() == 'true' + assert str(RDKIT_AVAILABLE).lower() == 'true' + - name: Check Python environment + run: | + pip list + - name: Import DGL run: | - python -c "import dgl" + python -c "import dgl; import dgl.nn" + + - name: Install package + run: | + python -m pip install . --no-deps - name: Run tests run: | diff --git a/.github/workflows/gh-ci.yaml b/.github/workflows/gh-ci.yaml index 296cc6d4..fe3ae8e4 100644 --- a/.github/workflows/gh-ci.yaml +++ b/.github/workflows/gh-ci.yaml @@ -30,7 +30,7 @@ jobs: strategy: fail-fast: false matrix: - os: [macOS-12, ubuntu-latest] + os: [macOS-13, macOS-latest, ubuntu-latest] python-version: ["3.10", "3.11", "3.12"] pydantic-version: ["1", "2"] include-rdkit: [false, true] @@ -39,12 +39,6 @@ jobs: exclude: - include-rdkit: false include-openeye: false - # no openeye for 3.12 yet - - include-openeye: true - python-version: "3.12" - # no builds? - - include-dgl: true - python-version: "3.12" steps: @@ -57,7 +51,7 @@ jobs: ulimit -a - name: Install environment - uses: mamba-org/setup-micromamba@v1 + uses: mamba-org/setup-micromamba@v2 with: environment-file: devtools/conda-envs/test_env_dgl_${{ matrix.include-dgl }}.yaml create-args: >- @@ -77,11 +71,11 @@ jobs: - name: Uninstall OpenEye if: matrix.include-openeye == false - run: conda remove --force openeye-toolkits --yes || echo "openeye not installed" + run: micromamba remove --force openeye-toolkits --yes || echo "openeye not installed" - name: Uninstall RDKit if: matrix.include-rdkit == false - run: conda remove --force rdkit --yes || echo "rdkit not installed" + run: micromamba remove --force rdkit --yes || echo "rdkit not installed" # See https://github.com/openforcefield/openff-nagl/issues/103 - name: Rewrite DGL config @@ -90,12 +84,6 @@ jobs: mkdir -p ~/.dgl echo '{"backend": "pytorch"}' > ~/.dgl/config.json - - name: Python information - run: | - which python - conda info - conda list - - name: Check toolkit installations shell: bash -l -c "python -u {0}" run: | @@ -103,9 +91,14 @@ jobs: assert str(OPENEYE_AVAILABLE).lower() == '${{ matrix.include-openeye }}' assert str(RDKIT_AVAILABLE).lower() == '${{ matrix.include-rdkit }}' + - name: Check DGL installation + if: matrix.include-dgl == true + run: | + python -c "import dgl" + - name: Run tests run: | - python -m pytest -n 4 -v --cov=openff/nagl --cov-config=setup.cfg --cov-append --cov-report=xml --color=yes openff/nagl/ + python -m pytest -v --cov=openff/nagl --cov-config=setup.cfg --cov-append --cov-report=xml --color=yes openff/nagl/ - name: codecov uses: codecov/codecov-action@v4 @@ -177,29 +170,19 @@ jobs: - uses: actions/checkout@v4 - name: Install conda - uses: conda-incubator/setup-miniconda@v3 + uses: mamba-org/setup-micromamba@v2 with: - python-version: ${{ matrix.python-version }} - add-pip-as-python-dependency: true - architecture: x64 - miniforge-variant: Mambaforge - use-mamba: true - auto-update-conda: true - show-channel-urls: true - channels: conda-forge, defaults + environment-name: openff-nagl + create-args: >- + python=${{ matrix.python-version }} - name: Build from source run: | - conda create --name openff-nagl - conda activate openff-nagl - conda list - - mamba env update --name openff-nagl --file devtools/conda-envs/docs_env.yaml + micromamba env update --name openff-nagl --file devtools/conda-envs/docs_env.yaml python --version python -m pip install . --no-deps - conda list + micromamba list - name: Check success run: | - conda activate openff-nagl python -c "import openff.nagl ; print(openff.nagl.__version__)" diff --git a/devtools/conda-envs/base.yaml b/devtools/conda-envs/base.yaml new file mode 100644 index 00000000..a26378f8 --- /dev/null +++ b/devtools/conda-envs/base.yaml @@ -0,0 +1,35 @@ +name: openff-nagl-test +channels: + - conda-forge + - defaults +dependencies: + # Base depends + - python + - pip + - importlib_resources + + # UI + - click + - click-option-group + - rich + - tqdm + + # chemistry + - openff-toolkit-base >=0.11.1 + - openff-units + - pydantic <3 + - rdkit + - scipy + + # files + - pyyaml + + # gcn + - pytorch >=2.0 + - pytorch-lightning + + # Testing + - pytest + - pytest-cov + - pytest-xdist + - codecov diff --git a/devtools/conda-envs/nightly.yaml b/devtools/conda-envs/nightly.yaml index 0cdad8f2..61e71e85 100644 --- a/devtools/conda-envs/nightly.yaml +++ b/devtools/conda-envs/nightly.yaml @@ -1,8 +1,8 @@ name: openff-nagl-test channels: - openeye - - pytorch-nightly - conda-forge + - pytorch-nightly - defaults dependencies: # Base depends @@ -18,13 +18,16 @@ dependencies: - pydantic <3 - rdkit - scipy + - openeye-toolkits + + # gnn + - pytorch + - torchvision + - torchaudio # database - pyarrow - # gcn - - pytorch - # parallelism - dask-jobqueue @@ -49,6 +52,11 @@ dependencies: - sqlite - xmltodict + # building + - make + - cmake + - cython + # Pip-only installs - pip: - git+https://github.com/openforcefield/openff-utilities.git@main diff --git a/devtools/conda-envs/test_env_dgl_false.yaml b/devtools/conda-envs/test_env_dgl_false.yaml index 4ee79048..ebee3a64 100644 --- a/devtools/conda-envs/test_env_dgl_false.yaml +++ b/devtools/conda-envs/test_env_dgl_false.yaml @@ -2,7 +2,6 @@ name: openff-nagl-test channels: - openeye - conda-forge - - defaults dependencies: # Base depends - python diff --git a/devtools/conda-envs/test_env_dgl_true.yaml b/devtools/conda-envs/test_env_dgl_true.yaml index 4536289d..9376d111 100644 --- a/devtools/conda-envs/test_env_dgl_true.yaml +++ b/devtools/conda-envs/test_env_dgl_true.yaml @@ -1,9 +1,7 @@ name: openff-nagl-test channels: - openeye - - dglteam - conda-forge - - defaults dependencies: # Base depends - python diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ec5acf35..4c921d87 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -27,6 +27,7 @@ The rules for this file: - Removed unused, undocumented code paths, and updated docs (PR #132) ### Fixed +- Fixed molecule normalization issues (Issue #119, PR #149) - Check lookup tables for allowed molecules before ChemicalDomain for forbidden ones (PR #145, Issue #144) - Add support for single atoms (PR #146, Issue #138) diff --git a/openff/nagl/nn/_dataset.py b/openff/nagl/nn/_dataset.py index 8551cdbd..2fe03d3e 100644 --- a/openff/nagl/nn/_dataset.py +++ b/openff/nagl/nn/_dataset.py @@ -268,9 +268,15 @@ class _LazyDGLMoleculeDataset(Dataset): @property def schema(self): + self.get_schema() + + @classmethod + def get_schema(cls): import pyarrow as pa return pa.schema([pa.field("pickled", pa.binary())]) + + def __len__(self): return self.n_entries @@ -370,7 +376,7 @@ def from_arrow_dataset( input_dataset = ds.dataset(path, format=format) with pa.OSFile(str(output_path), "wb") as sink: - with pa.ipc.new_file(sink, cls.schema) as writer: + with pa.ipc.new_file(sink, cls.get_schema()) as writer: input_batches = input_dataset.to_batches(columns=columns) for input_batch in input_batches: with get_mapper_to_processes(n_processes=n_processes) as mapper: @@ -378,7 +384,7 @@ def from_arrow_dataset( output_batch = pa.RecordBatch.from_arrays( [pa.array(pickled)], - schema=cls.schema + schema=cls.get_schema() ) writer.write_batch(output_batch) diff --git a/openff/nagl/tests/nn/__init__.py b/openff/nagl/tests/nn/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openff/nagl/tests/utils/test_openff.py b/openff/nagl/tests/utils/test_openff.py index 831260f1..a988e548 100644 --- a/openff/nagl/tests/utils/test_openff.py +++ b/openff/nagl/tests/utils/test_openff.py @@ -7,7 +7,7 @@ from openff.nagl.toolkits import NAGLRDKitToolkitWrapper from openff.toolkit import RDKitToolkitWrapper from openff.toolkit.utils.toolkit_registry import toolkit_registry_manager, ToolkitRegistry -from openff.toolkit.utils.toolkits import RDKIT_AVAILABLE, OPENEYE_AVAILABLE, AMBERTOOLS_AVAILABLE +from openff.toolkit.utils.toolkits import RDKIT_AVAILABLE, OPENEYE_AVAILABLE from openff.units import unit from openff.nagl.toolkits.openff import ( @@ -26,6 +26,36 @@ ) from openff.nagl.utils._utils import transform_coordinates +def _load_rdkit_molecule_exactly(mapped_smiles: str): + """ + Load a molecule from a mapped SMILES string using RDKit, without any normalization. + """ + from rdkit import Chem + + # load into RDKit + params = Chem.SmilesParserParams() + params.removeHs = False + params.sanitize = False + rdmol = Chem.MolFromSmiles(mapped_smiles, params) + Chem.MolToSmiles(rdmol) + + atom_indices = [atom.GetAtomMapNum() - 1 for atom in rdmol.GetAtoms()] + ordering = [atom_indices.index(i) for i in range(rdmol.GetNumAtoms())] + rdmol = Chem.RenumberAtoms(rdmol, ordering) + Chem.SanitizeMol(rdmol, Chem.SANITIZE_SYMMRINGS) + Chem.Kekulize(rdmol) + + molecule = Molecule.from_rdkit(rdmol, allow_undefined_stereo=True) + + # copy over formal charges and bonds again; from_rdkit sanitizes the rdmol + for atom, rdatom in zip(molecule.atoms, rdmol.GetAtoms()): + atom.formal_charge = rdatom.GetFormalCharge() * unit.elementary_charge + for rdbond in rdmol.GetBonds(): + i, j = rdbond.GetBeginAtomIdx(), rdbond.GetEndAtomIdx() + bond = molecule.get_bond_between(i, j) + bond._bond_order = int(rdbond.GetBondTypeAsDouble()) + + return molecule def test_get_openff_molecule_bond_indices(openff_methane_charged): bond_indices = get_openff_molecule_bond_indices(openff_methane_charged) @@ -46,21 +76,86 @@ def test_smiles_to_inchi_key(smiles, expected): assert smiles_to_inchi_key(smiles) == expected +NORMALIZATION_MOLECULE_TESTS = [ + ( + r"[H:6][C:1]([H:7])([H:8])[S+2:2]([C:5]([H:9])([H:10])[H:11])([O-:3])[O-:4]", + r"[H:6][C:1]([H:7])([H:8])[S:2](=[O:3])(=[O:4])[C:5]([H:9])([H:10])[H:11]", + + ), + ( + r"[H:22][c:7]1[c:6]([c:12]([n:10](=[O:11])[c:9]([n:8]1)[H:23])[C:13]([H:24])([H:25])[N:14](=[O:15])=[O:16])[C:5]([H:20])([H:21])[S+2:2]([C:1]([H:17])([H:18])[H:19])([O-:3])[O-:4]", + r"[H:22][c:7]1[c:6]([c:12]([n+:10]([c:9]([n:8]1)[H:23])[O-:11])[C:13]([H:24])([H:25])[N+:14](=[O:16])[O-:15])[C:5]([H:20])([H:21])[S:2](=[O:3])(=[O:4])[C:1]([H:17])([H:18])[H:19]" + ), + # Issue 119 + ( + r"[H:1][C:2]([H:3])([H:4])[c:5]1[c:6]2=[N:7][O:8][N+:9](=[c:10]2[c:11]([n+:12]([n+:13]1[O-:14])[O-:15])[C:16]([H:17])([H:18])[H:19])[O-:20]", + r"[H:1][C:2]([H:3])([H:4])[c:5]1[c:6]2=[N:7][O:8][N+:9](=[c:10]2[c:11]([n:12](=[O:15])[n:13]1=[O:14])[C:16]([H:17])([H:18])[H:19])[O-:20]", + ), + ( + r"[H:1][c:2]1[c:3]([c:4]([c:5]2[c:6]([c:7]1[H:8])/[C:9](=[N:10]/[C:11](=[O:12])[c:13]3[c:14]([c:15]([c:16]([c:17]([c:18]3[N+:19](=[O:20])[O-:21])[H:22])[N+:23](=[O:24])[O-:25])[H:26])[H:27])/[N-:28][c:29]4[c:30]([c:31]([c:32]([c:33]([n+:34]4[C:35]2([H:36])[H:37])[H:38])[Br:39])[H:40])[H:41])[H:42])[H:43]", + r"[H:1][c:2]1[c:3]([c:4]([c:5]2[c:6]([c:7]1[H:8])/[C:9](=[N:10]/[C:11](=[O:12])[c:13]3[c:14]([c:15]([c:16]([c:17]([c:18]3[N+:19](=[O:20])[O-:21])[H:22])[N+:23](=[O:24])[O-:25])[H:26])[H:27])/[N:28]=[C:29]4[C:30](=[C:31]([C:32](=[C:33]([N:34]4[C:35]2([H:36])[H:37])[H:38])[Br:39])[H:40])[H:41])[H:42])[H:43]" + ), + ( + r"[H:21][c:1]1[c:2]([c:3]([c:4]([c:5]([c:6]1[C:7]2=[N:8][N+:9]3=[C:15]([S:16]2)[N:14]([C:12](=[O:13])[C:11](=[C:10]3[O-:17])[H:25])[H:26])[H:24])[H:23])[N:18](=[O:19])=[O:20])[H:22]", + r"[H:21][c:1]1[c:2]([c:3]([c:4]([c:5]([c:6]1[C:7]2=[N:8][N:9]3[C:10](=[C:11]([C:12](=[O:13])[N+:14](=[C:15]3[S:16]2)[H:26])[H:25])[O-:17])[H:24])[H:23])[N+:18](=[O:20])[O-:19])[H:22]" + ) + +] + +@pytest.mark.skipif(not OPENEYE_AVAILABLE, reason="requires openeye") @pytest.mark.parametrize( - "expected_smiles, given_smiles", - [ - ("CS(=O)(=O)C", "C[S+2]([O-])([O-])C"), - ], + "given_smiles, expected_smiles", + NORMALIZATION_MOLECULE_TESTS ) -def test_normalize_molecule(expected_smiles, given_smiles): +def test_normalize_molecule_openeye(given_smiles, expected_smiles): from openff.toolkit.topology.molecule import Molecule - expected_molecule = Molecule.from_smiles(expected_smiles) + expected_molecule = Molecule.from_mapped_smiles(expected_smiles) - molecule = Molecule.from_smiles(given_smiles) + molecule = Molecule.from_mapped_smiles(given_smiles) assert not Molecule.are_isomorphic(molecule, expected_molecule)[0] output_molecule = normalize_molecule(molecule) - assert Molecule.are_isomorphic(output_molecule, expected_molecule)[0] + output_smiles = output_molecule.to_smiles(mapped=True) + # reload molecule to avoid spurious failures from different kekulization + output_molecule = Molecule.from_mapped_smiles(output_smiles) + is_isomorphic = Molecule.are_isomorphic( + output_molecule, expected_molecule, + )[0] + assert is_isomorphic, output_molecule.to_smiles(mapped=True) + + +@pytest.mark.skipif(not RDKIT_AVAILABLE, reason="requires rdkit") +@pytest.mark.parametrize( + "given_smiles, expected_smiles", + NORMALIZATION_MOLECULE_TESTS +) +def test_normalize_molecule_bypasses_rdkit_normalization( + given_smiles, + expected_smiles, +): + from openff.toolkit.topology.molecule import Molecule + + expected_molecule = _load_rdkit_molecule_exactly(expected_smiles) + molecule = _load_rdkit_molecule_exactly(given_smiles) + + assert not Molecule.are_isomorphic(molecule, expected_molecule)[0] + output_molecule = normalize_molecule(molecule) + is_isomorphic = Molecule.are_isomorphic(output_molecule, expected_molecule)[0] + + # this may fail spuriously due to kekulization error, in which case + # we reload the molecule and try again + if not is_isomorphic: + output_smiles = output_molecule.to_smiles(mapped=True) + # reload molecule to avoid spurious failures from different kekulization + output_molecule = _load_rdkit_molecule_exactly(output_smiles) + is_isomorphic = Molecule.are_isomorphic( + output_molecule, expected_molecule, + )[0] + + assert is_isomorphic, output_molecule.to_smiles(mapped=True) + + + @pytest.mark.parametrize( diff --git a/openff/nagl/toolkits/openeye.py b/openff/nagl/toolkits/openeye.py index 4e081f43..89e642ed 100644 --- a/openff/nagl/toolkits/openeye.py +++ b/openff/nagl/toolkits/openeye.py @@ -47,6 +47,7 @@ def _run_normalization_reactions( for reaction_smarts in normalization_reactions: reaction = oechem.OEUniMolecularRxn(reaction_smarts) + reaction.SetValidateKekule(False) reaction(oemol) molecule = self.from_openeye( diff --git a/openff/nagl/toolkits/rdkit.py b/openff/nagl/toolkits/rdkit.py index 874962ef..85d1285d 100644 --- a/openff/nagl/toolkits/rdkit.py +++ b/openff/nagl/toolkits/rdkit.py @@ -76,7 +76,16 @@ def _run_normalization_reactions( ) for atom in rdmol.GetAtoms(): - atom.SetAtomMapNum(atom.GetIntProp("react_atom_idx") + 1) + # reorder the rdkit mol following mapping + original_atom_indices = [ + atom.GetIntProp("react_atom_idx") + for atom in rdmol.GetAtoms() + ] + new_order = [original_atom_indices.index(i) for i in range(rdmol.GetNumAtoms())] + rdmol = Chem.RenumberAtoms(rdmol, new_order) + # RDKit can assign stereochemistry differently + # and toolkit doesn't allow STEREOCIS and STEREOTRANS bonds + Chem.AssignStereochemistry(rdmol, cleanIt=True) new_smiles = Chem.MolToSmiles(Chem.AddHs(rdmol)) # stop changing when smiles converges to same product @@ -87,12 +96,28 @@ def _run_normalization_reactions( f"Reaction {reaction_smarts} did not converge after " f"{max_iter} iterations for molecule {original_smiles}" ) + + for i, atom in enumerate(rdmol.GetAtoms(), 1): + atom.SetAtomMapNum(i) + # required to calculate explicit atom valence + # for kekulize call + Chem.SanitizeMol(rdmol, Chem.SANITIZE_SYMMRINGS) + Chem.Kekulize(rdmol) + Chem.AssignStereochemistry(rdmol) + new_mol = self.from_rdkit( rdmol, allow_undefined_stereo=True, - _cls=molecule.__class__, ) + # copy over formal charges and bonds again; from_rdkit sanitizes the rdmol + for atom, rdatom in zip(new_mol.atoms, rdmol.GetAtoms()): + atom.formal_charge = rdatom.GetFormalCharge() * unit.elementary_charge + for rdbond in rdmol.GetBonds(): + i, j = rdbond.GetBeginAtomIdx(), rdbond.GetEndAtomIdx() + bond = new_mol.get_bond_between(i, j) + bond._bond_order = int(rdbond.GetBondTypeAsDouble()) + mapping = new_mol.properties.pop("atom_map") adjusted_mapping = dict((current, new - 1) for current, new in mapping.items())