Source code for synkit.Graph.Mech.electron_accounting

from __future__ import annotations

from dataclasses import dataclass
from typing import Any

import networkx as nx
from rdkit import Chem


[docs] @dataclass(frozen=True) class ChargeRefresh: """VE/NBE/B charge refresh report for one atom map.""" atom_map: int node: Any previous_charge: int | float refreshed_charge: int | float valence_electrons: float nonbonding_electrons: float bond_electrons: float
[docs] @dataclass(frozen=True) class ChargeEdit: """Incremental local formal-charge edit for one atom map.""" atom_map: int node: Any delta: int | float previous_charge: int | float new_charge: int | float
[docs] def bond_order_sum(graph: nx.Graph, node: Any) -> float: """Return the sigma-plus-pi bond-order sum around one node.""" total = 0.0 for _, _, data in graph.edges(node, data=True): total += float(data.get("sigma_order", 0.0)) + float(data.get("pi_order", 0.0)) return total
[docs] def nonbonding_electron_count(graph: nx.Graph, node: Any) -> float: """Return the nonbonding-electron count for one atom.""" attrs = graph.nodes[node] return 2 * float(attrs.get("lone_pairs", 0)) + float(attrs.get("radical", 0))
[docs] def bond_electron_count(graph: nx.Graph, node: Any) -> float: """Return the bonding-electron count, including implicit hydrogens.""" return float(graph.nodes[node].get("hcount", 0)) + bond_order_sum(graph, node)
[docs] def recompute_charge(graph: nx.Graph, node: Any) -> int | float: """Recompute formal charge from stored electron-state fields.""" attrs = graph.nodes[node] charge = ( float(attrs["valence_electrons"]) - nonbonding_electron_count( graph, node, ) - bond_electron_count(graph, node) ) return int(charge) if charge.is_integer() else charge
[docs] def atom_map_to_node(graph: nx.Graph) -> dict[int, Any]: """Build a unique atom-map-to-node lookup for a molecular graph.""" lookup: dict[int, Any] = {} duplicates: dict[int, list[Any]] = {} for node, attrs in graph.nodes(data=True): atom_map = attrs.get("atom_map", node) if atom_map in (None, 0, "0"): continue atom_map_int = int(atom_map) if atom_map_int in lookup: duplicates.setdefault(atom_map_int, [lookup[atom_map_int]]).append(node) else: lookup[atom_map_int] = node if duplicates: raise ValueError(f"Duplicate atom maps in graph: {duplicates}") return lookup
[docs] def refresh_changed_atom_charge( graph: nx.Graph, atom_maps: list[int] | tuple[int, ...] | set[int], ) -> list[ChargeRefresh]: """Refresh formal charges for selected mapped atoms in place.""" lookup = atom_map_to_node(graph) reports: list[ChargeRefresh] = [] for atom_map in sorted({int(value) for value in atom_maps}): if atom_map not in lookup: raise ValueError(f"Atom map {atom_map} is missing from graph.") node = lookup[atom_map] attrs = graph.nodes[node] if "valence_electrons" not in attrs: raise ValueError(f"Atom map {atom_map} has no valence_electrons field.") previous_charge = attrs.get("charge", 0) refreshed_charge = recompute_charge(graph, node) attrs["charge"] = refreshed_charge attrs["bond_order_sum"] = bond_order_sum(graph, node) attrs["recomputed_charge"] = refreshed_charge attrs["charge_mismatch"] = False reports.append( ChargeRefresh( atom_map=atom_map, node=node, previous_charge=previous_charge, refreshed_charge=refreshed_charge, valence_electrons=float(attrs["valence_electrons"]), nonbonding_electrons=nonbonding_electron_count(graph, node), bond_electrons=bond_electron_count(graph, node), ) ) return reports
[docs] def change_atom_charge( graph: nx.Graph, atom_maps: list[int] | tuple[int, ...] | set[int], *, delta: int | float, ) -> list[ChargeEdit]: """Apply a local formal-charge delta to selected mapped atoms.""" lookup = atom_map_to_node(graph) reports: list[ChargeEdit] = [] for atom_map in [int(value) for value in atom_maps]: if atom_map not in lookup: raise ValueError(f"Atom map {atom_map} is missing from graph.") node = lookup[atom_map] attrs = graph.nodes[node] previous_charge = attrs.get("charge", 0) new_charge = previous_charge + delta if isinstance(new_charge, float) and new_charge.is_integer(): new_charge = int(new_charge) attrs["charge"] = new_charge reports.append( ChargeEdit( atom_map=atom_map, node=node, delta=delta, previous_charge=previous_charge, new_charge=new_charge, ) ) return reports
[docs] def refresh_electron_fields(graph: nx.Graph, *, in_place: bool = False) -> nx.Graph: """Refresh derived electron bookkeeping on a molecular graph. The graph is expected to store scalar ``sigma_order`` and ``pi_order`` edge fields plus node-level electron state. Presentation-facing ``order`` is not rewritten here; RDKit reconstruction remains responsible for aromatic re-perception at the product boundary. """ target = graph if in_place else graph.copy() for _, _, data in target.edges(data=True): sigma = float(data.get("sigma_order", 0.0)) pi = float(data.get("pi_order", 0.0)) data["kekule_order"] = sigma + pi for node, attrs in target.nodes(data=True): attrs["bond_order_sum"] = bond_order_sum(target, node) if "valence_electrons" not in attrs: continue attrs["recomputed_charge"] = recompute_charge(target, node) represented_charge = float(attrs.get("charge", 0)) attrs["charge_mismatch"] = represented_charge != attrs["recomputed_charge"] return target
[docs] def graph_to_sanitized_kekule_mol(graph: nx.Graph) -> Chem.Mol: """Reconstruct a product from ``kekule_order`` and let RDKit sanitize it.""" from synkit.IO.graph_to_mol import GraphToMol refreshed = refresh_electron_fields(graph) return GraphToMol(edge_attributes={"order": "kekule_order"}).graph_to_mol( refreshed, sanitize=True, use_h_count=True, )