跳转至

molop.structure

本页面提供了 molop.structure 模块的 API 参考。

Core molecule topology, editing, geometry, and validation helpers.

SubstituentReplacementError

Bases: RuntimeError

Raised when no geometry candidate satisfies the replacement contract.

Source code in src/molop/structure/substitution.py
Python
class SubstituentReplacementError(RuntimeError):
    """Raised when no geometry candidate satisfies the replacement contract."""

build_mol_from_atoms_and_bonds(atoms, bonds, formal_charges=None, formal_num_radicals=None, *, coords=None)

Build an RDKit molecule from MolOP atom, bond, and optional coordinate fields.

Source code in src/molop/structure/topology.py
Python
def build_mol_from_atoms_and_bonds(
    atoms: Sequence[int | str],
    bonds: Sequence[BondRecord],
    formal_charges: Sequence[int] | None = None,
    formal_num_radicals: Sequence[int] | None = None,
    *,
    coords: Sequence[tuple[float, float, float]] | None = None,
) -> RdMol:
    """Build an RDKit molecule from MolOP atom, bond, and optional coordinate fields."""
    atom_count = len(atoms)
    if formal_charges is None:
        formal_charges = [0] * atom_count
    if formal_num_radicals is None:
        formal_num_radicals = [0] * atom_count
    if len(formal_charges) != atom_count or len(formal_num_radicals) != atom_count:
        raise ValueError("Atom properties must have the same length as atoms.")

    if coords is None:
        mol = Chem.RWMol()
        for element, formal_charge, formal_num_radical in zip(
            atoms, formal_charges, formal_num_radicals, strict=True
        ):
            atom = Chem.Atom(element if isinstance(element, str) else pt.GetElementSymbol(element))
            atom.SetFormalCharge(formal_charge)
            atom.SetNumRadicalElectrons(formal_num_radical)
            mol.AddAtom(atom)
    else:
        if len(coords) != atom_count:
            raise ValueError("Coordinates must have the same length as atoms.")
        temp_xyz = (
            f"{atom_count}\n\n"
            + "\n".join(
                f"{element if isinstance(element, str) else pt.GetElementSymbol(element)} "
                f"{x:15.6f} {y:15.6f} {z:15.6f}"
                for element, (x, y, z) in zip(atoms, coords, strict=True)
            )
            + "\n"
        )
        xyz_mol = Chem.MolFromXYZBlock(temp_xyz)
        if xyz_mol is None:
            raise ValueError("Invalid input coordinates.")
        mol = Chem.RWMol(xyz_mol)
        for atom_idx, (formal_charge, formal_num_radical) in enumerate(
            zip(formal_charges, formal_num_radicals, strict=True)
        ):
            atom = mol.GetAtomWithIdx(atom_idx)
            atom.SetFormalCharge(formal_charge)
            atom.SetNumRadicalElectrons(formal_num_radical)

    for begin_atom_idx, end_atom_idx, bond_type_idx, _bond_stereo_idx in bonds:
        if not 0 <= begin_atom_idx < atom_count or not 0 <= end_atom_idx < atom_count:
            raise ValueError("Bond atom index is out of range.")
        mol.AddBond(
            beginAtomIdx=begin_atom_idx,
            endAtomIdx=end_atom_idx,
            order=bond_list[bond_type_idx],
        )

    Chem.SanitizeMol(mol, catchErrors=True)
    Chem.SetAromaticity(mol)
    Chem.DetectBondStereochemistry(mol)
    Chem.SetBondStereoFromDirections(mol)
    Chem.AssignStereochemistryFrom3D(mol)
    Chem.AssignCIPLabels(mol)
    for begin_atom_idx, end_atom_idx, _bond_type_idx, bond_stereo_idx in bonds:
        bond = mol.GetBondBetweenAtoms(begin_atom_idx, end_atom_idx)
        if bond is not None:
            bond.SetStereo(bond_stereo_list[bond_stereo_idx])
    return mol.GetMol()

check_crowding(mol, threshold=0.5)

Return whether all atom pairs satisfy the threshold distance.

Source code in src/molop/structure/validation.py
Python
def check_crowding(mol: RdMol, threshold: float = 0.5) -> bool:
    """Return whether all atom pairs satisfy the threshold distance."""
    distances = Chem.Get3DDistanceMatrix(mol)
    for start_atom, end_atom in itertools.combinations(mol.GetAtoms(), 2):
        bond = mol.GetBondBetweenAtoms(start_atom.GetIdx(), end_atom.GetIdx())
        bond_type = (
            bond.GetBondType()
            if bond is not None and bond.GetBondType() in _SUPPORTED_DISTANCE_BONDS
            else Chem.rdchem.BondType.SINGLE
        )
        if distances[start_atom.GetIdx()][end_atom.GetIdx()] < threshold * estimate_bond_length(
            start_atom.GetAtomicNum(),
            end_atom.GetAtomicNum(),
            bond_type,
        ):
            return False
    return True

get_bond_pairs(mol)

Return RDKit bonds in MolOP’s serialized four-integer representation.

Source code in src/molop/structure/topology.py
Python
def get_bond_pairs(mol: RdMol) -> list[BondRecord]:
    """Return RDKit bonds in MolOP's serialized four-integer representation."""
    return [
        (
            bond.GetBeginAtomIdx(),
            bond.GetEndAtomIdx(),
            bond_list.index(bond.GetBondType()),
            bond_stereo_list.index(bond.GetStereo()),
        )
        for bond in mol.GetBonds()
    ]

get_crowding_score(mol)

Return the negative UFF energy used to rank replacement conformers.

Source code in src/molop/structure/validation.py
Python
def get_crowding_score(mol: RdMol) -> float:
    """Return the negative UFF energy used to rank replacement conformers."""
    Chem.SanitizeMol(mol)
    return -rdForceFieldHelpers.UFFGetMoleculeForceField(mol).CalcEnergy()

get_formal_charges(mol)

Return formal charges in atom-index order.

Source code in src/molop/structure/topology.py
Python
def get_formal_charges(mol: RdMol) -> list[int]:
    """Return formal charges in atom-index order."""
    return [atom.GetFormalCharge() for atom in mol.GetAtoms()]

get_formal_num_radicals(mol)

Return radical-electron counts in atom-index order.

Source code in src/molop/structure/topology.py
Python
def get_formal_num_radicals(mol: RdMol) -> list[int]:
    """Return radical-electron counts in atom-index order."""
    return [atom.GetNumRadicalElectrons() for atom in mol.GetAtoms()]

get_total_charge(mol)

Return the total formal charge.

Source code in src/molop/structure/topology.py
Python
def get_total_charge(mol: RdMol) -> int:
    """Return the total formal charge."""
    return sum(get_formal_charges(mol))

get_total_multiplicity(mol)

Return MolOP’s spin-multiplicity convention for a molecule.

Source code in src/molop/structure/topology.py
Python
def get_total_multiplicity(mol: RdMol) -> int:
    """Return MolOP's spin-multiplicity convention for a molecule."""
    return get_total_num_radical(mol) + 1

get_total_num_radical(mol)

Return the total number of radical electrons.

Source code in src/molop/structure/topology.py
Python
def get_total_num_radical(mol: RdMol) -> int:
    """Return the total number of radical electrons."""
    return sum(get_formal_num_radicals(mol))

replace_multisite_substituent(mol, query, replacement, replacement_absolute_indices=None, *, attempt_num=10, crowding_threshold=0.75, angle_split=12, randomSeed=114514, anchor_tolerance=0.25)

Experimentally replace one or more parent fragments through two or more anchors.

The replacement must contain two or more [*] markers, one for each new attachment bond. Marker order follows input atom order by default; replacement_absolute_indices may explicitly provide that order using the input dummy-atom indices. The query may be connected or disconnected, and must identify exactly as many external single-bond attachments as markers. This permits several independent parent-side fragments to be replaced by one coordinate-bearing replacement assembly. Two anchors sample axial rotation; three or more anchors require non-collinear anchor geometries and use one rigid fit. The parent scaffold coordinates are frozen.

Source code in src/molop/structure/substitution.py
Python
@experimental
def replace_multisite_substituent(
    mol: RdMol,
    query: str | RdMol,
    replacement: str | RdMol,
    replacement_absolute_indices: Sequence[int] | None = None,
    *,
    attempt_num: int = 10,
    crowding_threshold: float = 0.75,
    angle_split: int = 12,
    randomSeed: int = 114514,
    anchor_tolerance: float = 0.25,
) -> RdMol:
    """Experimentally replace one or more parent fragments through two or more anchors.

    The replacement must contain two or more ``[*]`` markers, one for each
    new attachment bond. Marker order follows input atom order by default;
    ``replacement_absolute_indices`` may explicitly provide that order using
    the input dummy-atom indices. The query may be connected or disconnected,
    and must identify exactly as many external single-bond attachments as
    markers. This permits several independent parent-side fragments to be
    replaced by one coordinate-bearing replacement assembly.
    Two anchors sample axial rotation; three or more anchors require
    non-collinear anchor geometries and use one rigid fit. The parent scaffold
    coordinates are frozen.
    """
    if anchor_tolerance < 0:
        raise ValueError("anchor_tolerance must be >= 0")
    replacement_mol = normalize_replacement(replacement)
    replacement_marker_indices = _resolve_dummy_indices(
        replacement_mol,
        requested_indices=(
            tuple(int(index) for index in replacement_absolute_indices)
            if replacement_absolute_indices is not None
            else None
        ),
    )
    if len(replacement_marker_indices) < 2:
        raise ValueError("the experimental prototype requires at least two [*] markers")
    query_mol = normalize_query(query, require_connected=False)
    sites = find_multisite_sites(mol, query_mol, len(replacement_marker_indices))
    if not sites:
        raise ValueError("query does not identify a unique multi-anchor replacement site")
    if len(sites) > 1:
        raise ValueError(
            "query identifies multiple multi-anchor substituents; use a more specific query"
        )
    options = ReplacementOptions(
        attempt_num=attempt_num,
        crowding_threshold=crowding_threshold,
        angle_split=angle_split,
        random_seed=randomSeed,
    )

    failures: list[str] = []
    for seed in _seed_order(options):
        try:
            result = _assemble_multisite_candidate(
                mol,
                sites[0],
                replacement_mol,
                replacement_marker_indices,
                options,
                seed,
                anchor_tolerance=anchor_tolerance,
            )
        except (SubstituentReplacementError, ValueError, RuntimeError) as exc:
            failures.append(f"seed {seed}: {exc}")
            continue
        moloplogger.debug(
            f"{DEBUG_TAG} | Experimental multi-anchor replacement succeeded at seed {seed}."
        )
        return result
    detail = "; ".join(failures[-3:])
    raise SubstituentReplacementError(
        f"no valid multi-anchor replacement was generated after {options.attempt_num} attempts"
        + (f": {detail}" if detail else "")
    )

replace_substituent(mol, query, replacement, bind_idx=None, replace_all=False, attempt_num=10, crowding_threshold=0.75, angle_split=10, randomSeed=114514, start_idx=None, end_idx=None, *, stereo_policy='preserve')

Replace one or more pendant substituents with a dummy-marked replacement.

Query-based replacement requires a connected query match with exactly one external non-ring bond. bind_idx is the atom index on the retained scaffold. Alternatively, start_idx and end_idx explicitly name the scaffold and substituent sides of one bond. The replacement SMILES must contain exactly one [*] marker; the atom next to that marker is connected to the retained scaffold.

Source code in src/molop/structure/substitution.py
Python
def replace_substituent(
    mol: RdMol,
    query: str | RdMol | None,
    replacement: str | RdMol,
    bind_idx: int | None = None,
    replace_all: bool = False,
    attempt_num: int = 10,
    crowding_threshold: float = 0.75,
    angle_split: int = 10,
    randomSeed: int = 114514,
    start_idx: int | None = None,
    end_idx: int | None = None,
    *,
    stereo_policy: StereoPolicy = "preserve",
) -> RdMol:
    """Replace one or more pendant substituents with a dummy-marked replacement.

    Query-based replacement requires a connected query match with exactly one
    external non-ring bond. ``bind_idx`` is the atom index on the retained
    scaffold. Alternatively, ``start_idx`` and ``end_idx`` explicitly name
    the scaffold and substituent sides of one bond. The replacement SMILES
    must contain exactly one ``[*]`` marker; the atom next to that marker is
    connected to the retained scaffold.
    """
    if (start_idx is None) != (end_idx is None):
        raise ValueError("start_idx and end_idx must be provided together")
    if replace_all and (start_idx is not None or bind_idx is not None):
        raise ValueError("replace_all cannot be combined with explicit site selection")
    replacement_mol = normalize_replacement(replacement)
    query_mol: RdMol | None = None
    options = ReplacementOptions(
        attempt_num=attempt_num,
        crowding_threshold=crowding_threshold,
        angle_split=angle_split,
        random_seed=randomSeed,
        stereo_policy=stereo_policy,
    )

    if start_idx is not None and end_idx is not None:
        sites = [explicit_site(mol, start_idx, end_idx)]
    else:
        if query is None:
            raise ValueError("query is required when the replacement site is not explicit")
        query_mol = normalize_query(query)
        sites = find_sites(mol, query_mol, bind_idx=bind_idx)
        if not sites:
            raise ValueError("query does not identify a unique pendant substituent")
        if not replace_all and len(sites) > 1:
            raise ValueError(
                "query identifies multiple pendant substituents; use bind_idx or replace_all"
            )

    failures: list[str] = []
    for seed in _seed_order(options):
        try:
            if replace_all:
                if query_mol is None:
                    raise RuntimeError("replace_all requires a query-based replacement")
                result = _replace_all_once(
                    mol,
                    query_mol,
                    replacement_mol,
                    options,
                    seed=seed,
                )
            else:
                result = replace_substituent_once(
                    mol,
                    replacement_mol,
                    sites[0],
                    options,
                    seed=seed,
                )
        except (SubstituentReplacementError, ValueError, RuntimeError) as exc:
            failures.append(f"seed {seed}: {exc}")
            continue
        moloplogger.debug(f"{DEBUG_TAG} | Substituent replacement succeeded at seed {seed}.")
        return result
    detail = "; ".join(failures[-3:])
    raise SubstituentReplacementError(
        f"no valid substituent replacement was generated after {options.attempt_num} attempts"
        + (f": {detail}" if detail else "")
    )

reset_atom_index(mol, mapping)

Move the selected old atom indices to the front, preserving their order.

Source code in src/molop/structure/topology.py
Python
def reset_atom_index(mol: Chem.rdchem.Mol, mapping: Sequence[int]) -> Chem.rdchem.Mol:
    """Move the selected old atom indices to the front, preserving their order."""
    selected = list(mapping)
    atom_count = mol.GetNumAtoms()
    if len(set(selected)) != len(selected) or any(idx < 0 or idx >= atom_count for idx in selected):
        raise ValueError("mapping must contain unique valid atom indices.")
    new_idx = selected + [idx for idx in range(atom_count) if idx not in selected]
    return Chem.RenumberAtoms(mol, new_idx)