molop.structure¶
本页面提供了 molop.structure 模块的 API 参考。
Core molecule topology, editing, geometry, and validation helpers.
SubstituentReplacementError
¶
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
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
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
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.
get_formal_charges(mol)
¶
get_formal_num_radicals(mol)
¶
get_total_charge(mol)
¶
get_total_multiplicity(mol)
¶
get_total_num_radical(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
@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
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
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)