Source code for synkit.Graph.Hyrogen.hcomplete

import itertools
import networkx as nx
from dataclasses import dataclass
from copy import copy
from joblib import Parallel, delayed
from typing import Any, Dict, Iterator, List, Literal, Tuple, Iterable, Optional
from operator import eq
from networkx.algorithms.isomorphism import generic_edge_match, generic_node_match

from synkit.IO.debug import setup_logging
from synkit.IO.chem_converter import detect_its_format
from synkit.Graph.ITS.its_construction import ITSConstruction
from synkit.Graph.ITS.its_decompose import get_rc, its_decompose
from synkit.Graph.ITS.its_reverter import ITSReverter
from synkit.Graph.ITS.rc_extractor import RCExtractor
from synkit.Graph.Hyrogen._misc import (
    check_hcount_change,
    check_explicit_hydrogen,
    get_priority,
    check_equivariant_graph,
)

logger = setup_logging()

ITSFormat = Literal["typesGH", "tuple"]
ITSFormatInput = Literal["auto", "typesGH", "tuple"]


[docs] @dataclass class HCompletionResult: """Container returned by graph-first hydrogen completion.""" its: Optional[nx.Graph] rc: Optional[nx.Graph] reactant: Optional[nx.Graph] = None product: Optional[nx.Graph] = None signature: Optional[str] = None candidates: int = 0 reason: str = "" format: str = "typesGH" @property def ok(self) -> bool: return ( isinstance(self.its, nx.Graph) and isinstance(self.rc, nx.Graph) and self.rc.number_of_nodes() > 0 )
[docs] class HComplete: """A class for infering hydrogen to complete reaction center or ITS graph.""" TUPLE_NODE_ATTRS = [ "element", "aromatic", "hcount", "charge", "atom_map", "lone_pairs", "radical", "valence_electrons", ] TUPLE_EDGE_ATTRS = [ "order", "kekule_order", "sigma_order", "pi_order", ]
[docs] @staticmethod def process_single_graph_data( graph_data: Dict[str, nx.Graph], its_key: str = "ITS", rc_key: str = "RC", ignore_aromaticity: bool = False, balance_its: bool = True, get_priority_graph: bool = False, max_hydrogen: int = 7, format: ITSFormatInput = "auto", max_candidates: Optional[int] = None, ) -> Dict[str, Optional[nx.Graph]]: """Processes a single graph data dictionary by modifying hydrogen counts and other features based on configuration settings. Parameters: - graph_data (Dict[str, nx.Graph]): Dictionary containing the graph data. - its_key (str): Key where the ITS graph is stored. - rc_key (str): Key where the RC graph is stored. - ignore_aromaticity (bool): If True, aromaticity is ignored during processing. Default is False. - balance_its (bool): If True, the ITS is balanced. Default is True. - get_priority_graph (bool): If True, priority is given to graph data during processing. Default is False. - max_hydrogen (int): Maximum number of hydrogens that can be handled in the inference step. - format (str): ITS representation: "auto", "typesGH", or "tuple". - max_candidates (Optional[int]): Optional cap for enumerated hydrogen assignments. Returns: - Dict[str, Optional[nx.Graph]]: Dictionary with updated ITS and RC graph data, or None if processing fails. """ graphs = copy(graph_data) its = graphs.get(its_key, None) result = HComplete.complete_its( its, ignore_aromaticity=ignore_aromaticity, balance_its=balance_its, get_priority_graph=get_priority_graph, max_hydrogen=max_hydrogen, format=format, max_candidates=max_candidates, ) graphs[its_key] = result.its if result.ok else None graphs[rc_key] = result.rc if result.ok else None return graphs
[docs] def process_graph_data_parallel( self, graph_data_list: List[Dict[str, nx.Graph]], its_key: str = "ITS", rc_key: str = "RC", n_jobs: int = 1, verbose: int = 0, ignore_aromaticity: bool = False, balance_its: bool = True, get_priority_graph: bool = False, max_hydrogen: int = 7, format: ITSFormatInput = "auto", max_candidates: Optional[int] = None, ) -> List[Dict[str, Optional[nx.Graph]]]: """Processes a list of graph data dictionaries in parallel to optimize the hydrogen completion and other graph modifications. Parameters: - graph_data_list (List[Dict[str, nx.Graph]]): List of dictionaries containing the graph data. - its_key (str): Key where the ITS graph is stored. - rc_key (str): Key where the RC graph is stored. - n_jobs (int): Number of parallel jobs to run. - verbose (int): Verbosity level for the parallel process. - ignore_aromaticity (bool): If True, aromaticity is ignored during processing. Default is False. - balance_its (bool): If True, the ITS is balanced. Default is True. - get_priority_graph (bool): If True, priority is given to graph data during processing. Default is False. - max_hydrogen (int): Maximum number of hydrogens that can be handled in the inference step. - format (str): ITS representation: "auto", "typesGH", or "tuple". - max_candidates (Optional[int]): Optional cap for enumerated hydrogen assignments. Returns: - List[Dict[str, Optional[nx.Graph]]]: List of dictionaries with updated ITS and RC graph data, or None if processing fails. """ processed_data = Parallel(n_jobs=n_jobs, verbose=verbose)( delayed(self.process_single_graph_data)( graph_data, its_key, rc_key, ignore_aromaticity, balance_its, get_priority_graph, max_hydrogen, format, max_candidates, ) for graph_data in graph_data_list ) return processed_data
[docs] @staticmethod def complete_its( its: nx.Graph, ignore_aromaticity: bool = False, balance_its: bool = True, get_priority_graph: bool = False, max_hydrogen: int = 7, format: ITSFormatInput = "auto", max_candidates: Optional[int] = None, ) -> HCompletionResult: """Complete hydrogens for a bare ITS graph. This is the graph-first API. It accepts either legacy ``typesGH`` ITS graphs or tuple ITS graphs and returns completed ITS/RC graphs without requiring a dictionary entry wrapper. """ if not isinstance(its, nx.Graph) or its.number_of_nodes() == 0: return HCompletionResult(None, None, reason="invalid_its") resolved_format = HComplete._resolve_format(its, format) react_graph, prod_graph = HComplete._decompose_its(its, resolved_format) hcount_change = check_hcount_change(react_graph, prod_graph) if hcount_change == 0: rc = HComplete._extract_rc(its, resolved_format) if not HComplete._valid_rc(rc): return HCompletionResult( None, None, react_graph, prod_graph, candidates=1, reason="empty_rc", format=resolved_format, ) return HCompletionResult( its, rc, react_graph, prod_graph, signature=HComplete._rc_signature(rc, resolved_format), candidates=1, format=resolved_format, ) if hcount_change > max_hydrogen: return HCompletionResult( None, None, react_graph, prod_graph, reason="max_hydrogen_exceeded", format=resolved_format, ) return HComplete._complete_from_side_graphs( react_graph, prod_graph, ignore_aromaticity=ignore_aromaticity, balance_its=balance_its, get_priority_graph=get_priority_graph, format=resolved_format, max_candidates=max_candidates, )
[docs] @staticmethod def process_multiple_hydrogens( graph_data: Dict[str, nx.Graph], its_key: str, rc_key: str, react_graph: nx.Graph, prod_graph: nx.Graph, ignore_aromaticity: bool, balance_its: bool, get_priority_graph: bool = False, format: ITSFormatInput = "typesGH", max_candidates: Optional[int] = None, ) -> Dict[str, Optional[nx.Graph]]: """Handles significant hydrogen count changes between reactant and product graphs, adjusting hydrogen nodes accordingly and assessing graph equivalence. Parameters: - graph_data (Dict[str, nx.Graph]): Dictionary containing the graph data. - its_key (str): Key for the ITS graph in the dictionary. - rc_key (str): Key for the RC graph in the dictionary. - react_graph (nx.Graph): Graph representing the reactants. - prod_graph (nx.Graph): Graph representing the products. - ignore_aromaticity (bool): If True, aromaticity will not be considered in processing. - balance_its (bool): If True, balances the ITS graph. - get_priority_graph (bool): If True, processes graphs with priority considerations. - format (str): ITS representation: "auto", "typesGH", or "tuple". - max_candidates (Optional[int]): Optional cap for enumerated hydrogen assignments. Returns: - Dict[str, Optional[nx.Graph]]: Updated graph dictionary with potentially modified ITS and RC graphs. """ result = HComplete._complete_from_side_graphs( react_graph, prod_graph, ignore_aromaticity=ignore_aromaticity, balance_its=balance_its, get_priority_graph=get_priority_graph, format=HComplete._resolve_graph_pair_format( react_graph, prod_graph, format ), max_candidates=max_candidates, ) graph_data[its_key] = result.its if result.ok else None graph_data[rc_key] = result.rc if result.ok else None return graph_data
@staticmethod def _complete_from_side_graphs( react_graph: nx.Graph, prod_graph: nx.Graph, ignore_aromaticity: bool, balance_its: bool, get_priority_graph: bool, format: ITSFormat, max_candidates: Optional[int] = None, ) -> HCompletionResult: candidates = [] first = None valid_seen = 0 for candidate in HComplete._iter_hydrogen_node_completions( react_graph, prod_graph, ignore_aromaticity, balance_its, format=format, max_candidates=max_candidates, ): react, prod, its, rc, sig = candidate if not HComplete._valid_rc(rc): continue valid_seen += 1 if get_priority_graph: candidates.append(candidate) continue if first is None: first = candidate continue first_equiv = HComplete._equivariant_count([first[3], rc], format) if sig != first[4] or first_equiv != 1: return HCompletionResult( None, None, react_graph, prod_graph, candidates=valid_seen, reason="non_equivariant_rc", format=format, ) if get_priority_graph: selected = HComplete._select_priority_candidate(candidates, format) else: selected = first if selected is None: return HCompletionResult( None, None, react_graph, prod_graph, candidates=valid_seen, reason="no_valid_candidate", format=format, ) react, prod, its, rc, sig = selected return HCompletionResult( its, rc, react, prod, signature=sig, candidates=valid_seen, format=format, ) @staticmethod def _select_priority_candidate( candidates: List[Tuple[nx.Graph, nx.Graph, nx.Graph, nx.Graph, str]], format: ITSFormat, ) -> Optional[Tuple[nx.Graph, nx.Graph, nx.Graph, nx.Graph, str]]: if not candidates: return None rc_list = [candidate[3] for candidate in candidates] rc_sig = [candidate[4] for candidate in candidates] if len(set(rc_sig)) == 1: equivariant = HComplete._equivariant_count(rc_list, format) else: equivariant = 0 if equivariant == len(rc_list) - 1: return candidates[0] priority_indices = get_priority(rc_list) priority_candidates = [candidates[i] for i in priority_indices] priority_rc = [candidate[3] for candidate in priority_candidates] priority_sig = [candidate[4] for candidate in priority_candidates] if len(set(priority_sig)) == 1: equivariant = HComplete._equivariant_count(priority_rc, format) if equivariant == len(priority_rc) - 1: return priority_candidates[0] return None
[docs] @staticmethod def add_hydrogen_nodes_multiple( react_graph: nx.Graph, prod_graph: nx.Graph, ignore_aromaticity: bool, balance_its: bool, get_priority_graph: bool = False, format: ITSFormatInput = "typesGH", max_candidates: Optional[int] = None, ) -> List[Tuple[nx.Graph, nx.Graph, nx.Graph, nx.Graph, str]]: """Generates multiple permutations of reactant and product graphs by adjusting hydrogen counts, exploring all possible configurations of hydrogen node additions or removals. Parameters: - react_graph (nx.Graph): The reactant graph. - prod_graph (nx.Graph): The product graph. - ignore_aromaticity (bool): If True, aromaticity is ignored. - balance_its (bool): If True, attempts to balance the ITS by adjusting hydrogen nodes. - get_priority_graph (bool): If True, additional priority-based processing is applied to select optimal graph configurations. - format (str): ITS representation: "auto", "typesGH", or "tuple". - max_candidates (Optional[int]): Optional cap for enumerated hydrogen assignments. Returns: - List[Tuple[nx.Graph, nx.Graph, nx.Graph, nx.Graph, str]]: Candidate reactant, product, ITS, RC, and RC-signature tuples. """ resolved_format = HComplete._resolve_graph_pair_format( react_graph, prod_graph, format ) updated_graphs = [] for candidate in HComplete._iter_hydrogen_node_completions( react_graph, prod_graph, ignore_aromaticity, balance_its, format=resolved_format, max_candidates=max_candidates, ): if get_priority_graph is False and updated_graphs: if candidate[-1] != updated_graphs[-1][-1]: return [] updated_graphs.append(candidate) return updated_graphs
@staticmethod def _iter_hydrogen_node_completions( react_graph: nx.Graph, prod_graph: nx.Graph, ignore_aromaticity: bool, balance_its: bool, format: ITSFormat, max_candidates: Optional[int] = None, ) -> Iterator[Tuple[nx.Graph, nx.Graph, nx.Graph, nx.Graph, str]]: for ( current_react_graph, current_prod_graph, ) in HComplete._iter_hydrogen_side_graph_completions( react_graph, prod_graph, max_candidates=max_candidates, ): its = HComplete._construct_its( current_react_graph, current_prod_graph, ignore_aromaticity, balance_its, format, ) rc = HComplete._extract_rc(its, format) sig = HComplete._rc_signature(rc, format) yield ((current_react_graph, current_prod_graph, its, rc, sig)) @staticmethod def _iter_hydrogen_side_graph_completions( react_graph: nx.Graph, prod_graph: nx.Graph, max_candidates: Optional[int] = None, ) -> Iterator[Tuple[nx.Graph, nx.Graph]]: react_graph_copy = react_graph.copy() prod_graph_copy = prod_graph.copy() _, react_hydrogen_nodes = check_explicit_hydrogen(react_graph_copy) _, prod_hydrogen_nodes = check_explicit_hydrogen(prod_graph_copy) hydrogen_nodes_break, hydrogen_nodes_form = HComplete._hcount_change_atoms( react_graph_copy, prod_graph_copy ) n_break = len(hydrogen_nodes_break) n_form = len(hydrogen_nodes_form) n_hydrogen_needed = max(n_break, n_form) if n_hydrogen_needed == 0: return hydrogen_nodes = HComplete._hydrogen_node_ids( react_graph_copy, prod_graph_copy, sorted(set(react_hydrogen_nodes) | set(prod_hydrogen_nodes)), n_hydrogen_needed, ) for candidate_index, permutation in enumerate( itertools.permutations(hydrogen_nodes, n_hydrogen_needed) ): if max_candidates is not None and candidate_index >= max_candidates: break current_react_graph, current_prod_graph = react_graph_copy, prod_graph_copy current_react_graph = HComplete.add_hydrogen_nodes_multiple_utils( current_react_graph, zip(hydrogen_nodes_break, permutation[:n_break]), ) current_prod_graph = HComplete.add_hydrogen_nodes_multiple_utils( current_prod_graph, zip(hydrogen_nodes_form, permutation[:n_form]), ) yield current_react_graph, current_prod_graph @staticmethod def _hcount_change_atoms( react_graph: nx.Graph, prod_graph: nx.Graph, ) -> Tuple[List[int], List[int]]: hydrogen_nodes_break: List[int] = [] hydrogen_nodes_form: List[int] = [] for node_id in sorted(set(react_graph.nodes) & set(prod_graph.nodes)): react_hcount = react_graph.nodes[node_id].get("hcount", 0) prod_hcount = prod_graph.nodes[node_id].get("hcount", 0) hcount_diff = react_hcount - prod_hcount if hcount_diff > 0: hydrogen_nodes_break.extend([node_id] * hcount_diff) elif hcount_diff < 0: hydrogen_nodes_form.extend([node_id] * -hcount_diff) return hydrogen_nodes_break, hydrogen_nodes_form @staticmethod def _hydrogen_node_ids( react_graph: nx.Graph, prod_graph: nx.Graph, existing_hydrogen_nodes: List[int], n_hydrogen_needed: int, ) -> List[int]: used_nodes = set(react_graph.nodes) | set(prod_graph.nodes) hydrogen_nodes = [ node_id for node_id in existing_hydrogen_nodes if node_id in used_nodes ] n_new_needed = max(0, n_hydrogen_needed - len(hydrogen_nodes)) max_index = max(used_nodes, default=0) hydrogen_nodes.extend(range(max_index + 1, max_index + 1 + n_new_needed)) return hydrogen_nodes @staticmethod def _resolve_format(its: nx.Graph, format: ITSFormatInput) -> ITSFormat: if format == "auto": return detect_its_format(its) if format in ("typesGH", "tuple"): return format raise ValueError("format must be one of 'auto', 'typesGH', or 'tuple'.") @staticmethod def _resolve_graph_pair_format( react_graph: nx.Graph, prod_graph: nx.Graph, format: ITSFormatInput, ) -> ITSFormat: if format == "auto": return "typesGH" if format in ("typesGH", "tuple"): return format raise ValueError("format must be one of 'auto', 'typesGH', or 'tuple'.") @staticmethod def _decompose_its(its: nx.Graph, format: ITSFormat) -> Tuple[nx.Graph, nx.Graph]: if format == "typesGH": return its_decompose(its) reverter = ITSReverter(its) return reverter.to_reactant_graph(), reverter.to_product_graph() @staticmethod def _construct_its( react_graph: nx.Graph, prod_graph: nx.Graph, ignore_aromaticity: bool, balance_its: bool, format: ITSFormat, ) -> nx.Graph: if format == "typesGH": return ITSConstruction().ITSGraph( react_graph, prod_graph, ignore_aromaticity=ignore_aromaticity, balance_its=balance_its, ) return ITSConstruction.construct( react_graph, prod_graph, ignore_aromaticity=ignore_aromaticity, balance_its=balance_its, store=True, node_attrs=HComplete.TUPLE_NODE_ATTRS, edge_attrs=HComplete.TUPLE_EDGE_ATTRS, ) @staticmethod def _extract_rc(its: nx.Graph, format: ITSFormat) -> nx.Graph: if format == "typesGH": return get_rc(its) return RCExtractor(preserve_full_attrs=True).extract(its) @staticmethod def _valid_rc(rc: Optional[nx.Graph]) -> bool: return isinstance(rc, nx.Graph) and rc.number_of_nodes() > 0 @staticmethod def _rc_signature(rc: nx.Graph, format: ITSFormat = "typesGH") -> str: return HComplete._comparison_graph_signature( HComplete._comparison_graph(rc, format) ) @staticmethod def _comparison_graph_signature(graph: nx.Graph) -> str: return nx.weisfeiler_lehman_graph_hash( graph, node_attr="cmp_node", edge_attr="cmp_order", iterations=3, ) @staticmethod def _equivariant_count(rc_list: List[nx.Graph], format: ITSFormat) -> int: if format == "typesGH": _, equivariant = check_equivariant_graph(rc_list) return equivariant graphs = [HComplete._comparison_graph(rc, format) for rc in rc_list] node_match = generic_node_match( ["cmp_element", "cmp_charge"], ["*", 0], [eq, eq], ) edge_match = generic_edge_match("cmp_order", 1, eq) equivariant = 0 for graph in graphs[1:]: if nx.is_isomorphic( graphs[0], graph, node_match=node_match, edge_match=edge_match, ): equivariant += 1 return equivariant @staticmethod def _comparison_graph(rc: nx.Graph, format: ITSFormat) -> nx.Graph: graph = nx.Graph() for node, attrs in rc.nodes(data=True): cmp_element = HComplete._comparison_node_value(attrs.get("element")) cmp_charge = HComplete._comparison_pair_value(attrs.get("charge", 0)) graph.add_node( node, cmp_element=cmp_element, cmp_charge=cmp_charge, cmp_node=f"{cmp_element}|{cmp_charge}", ) for u, v, attrs in rc.edges(data=True): graph.add_edge( u, v, cmp_order=HComplete._comparison_pair_value(attrs.get("order", 1)), ) return graph @staticmethod def _comparison_node_value(value): if isinstance(value, (tuple, list)) and len(value) == 2: if "H" in value: return "H" if value[0] == value[1]: return value[0] return tuple(value) return value @staticmethod def _comparison_pair_value(value): if isinstance(value, (tuple, list)) and len(value) == 2: if value[0] == value[1]: return value[0] return tuple(value) return value @staticmethod def _candidate_comparison_graph( react_graph: nx.Graph, prod_graph: nx.Graph, format: ITSFormat, ignore_aromaticity: bool = False, static_tuple_nodes: Optional[set] = None, ) -> nx.Graph: """Build the RC comparison graph directly from candidate side graphs.""" if format == "tuple": return HComplete._tuple_candidate_comparison_graph( react_graph, prod_graph, ignore_aromaticity, static_tuple_nodes=static_tuple_nodes, ) return HComplete._typesgh_candidate_comparison_graph( react_graph, prod_graph, ignore_aromaticity ) @staticmethod def _typesgh_candidate_comparison_graph( react_graph: nx.Graph, prod_graph: nx.Graph, ignore_aromaticity: bool, ) -> nx.Graph: graph = nx.Graph() for u, v in HComplete._candidate_edge_keys(react_graph, prod_graph): react_order, prod_order = HComplete._candidate_edge_order_pair( react_graph, prod_graph, u, v ) standard_order = HComplete._candidate_standard_order( react_order, prod_order, ignore_aromaticity ) if standard_order == 0 and not HComplete._candidate_typesgh_hh_pair( react_graph, u, v ): continue HComplete._add_candidate_comparison_node( graph, react_graph, prod_graph, u, "typesGH" ) HComplete._add_candidate_comparison_node( graph, react_graph, prod_graph, v, "typesGH" ) HComplete._add_candidate_comparison_edge( graph, u, v, react_order, prod_order ) return graph @staticmethod def _tuple_candidate_comparison_graph( react_graph: nx.Graph, prod_graph: nx.Graph, ignore_aromaticity: bool, static_tuple_nodes: Optional[set] = None, ) -> nx.Graph: graph = nx.Graph() rc_nodes = set(static_tuple_nodes or ()) edge_keys = HComplete._candidate_edge_keys(react_graph, prod_graph) if static_tuple_nodes is None: for node_id in set(react_graph.nodes) | set(prod_graph.nodes): if HComplete._candidate_tuple_node_changed( react_graph, prod_graph, node_id ): rc_nodes.add(node_id) for u, v in edge_keys: react_order, prod_order = HComplete._candidate_edge_order_pair( react_graph, prod_graph, u, v ) standard_order = HComplete._candidate_standard_order( react_order, prod_order, ignore_aromaticity ) if standard_order != 0: rc_nodes.add(u) rc_nodes.add(v) for node_id in rc_nodes: HComplete._add_candidate_comparison_node( graph, react_graph, prod_graph, node_id, "tuple" ) for u, v in edge_keys: if u not in rc_nodes or v not in rc_nodes: continue react_order, prod_order = HComplete._candidate_edge_order_pair( react_graph, prod_graph, u, v ) HComplete._add_candidate_comparison_edge( graph, u, v, react_order, prod_order ) return graph @staticmethod def _tuple_static_node_changes( react_graph: nx.Graph, prod_graph: nx.Graph, ) -> set: """Return tuple RC node changes that are independent of H assignment.""" rc_nodes = set() for node_id in set(react_graph.nodes) | set(prod_graph.nodes): for attr in ("element", "charge", "radical", "valence_electrons"): pair = ( HComplete._candidate_node_attr(react_graph, node_id, attr), HComplete._candidate_node_attr(prod_graph, node_id, attr), ) if RCExtractor._pair_diff(pair): rc_nodes.add(node_id) break if node_id in rc_nodes: continue lp_pair = ( HComplete._candidate_lone_pair_attr(react_graph, node_id), HComplete._candidate_lone_pair_attr(prod_graph, node_id), ) if RCExtractor._pair_diff(lp_pair): rc_nodes.add(node_id) return rc_nodes @staticmethod def _candidate_edge_keys( react_graph: nx.Graph, prod_graph: nx.Graph ) -> List[Tuple[int, int]]: edge_keys = {tuple(sorted(edge)) for edge in react_graph.edges} edge_keys.update(tuple(sorted(edge)) for edge in prod_graph.edges) return sorted(edge_keys) @staticmethod def _candidate_edge_order_pair( react_graph: nx.Graph, prod_graph: nx.Graph, u: int, v: int, ) -> Tuple[Any, Any]: return ( HComplete._candidate_edge_attr(react_graph, u, v, "order", 0.0), HComplete._candidate_edge_attr(prod_graph, u, v, "order", 0.0), ) @staticmethod def _candidate_standard_order( react_order: Any, prod_order: Any, ignore_aromaticity: bool, ) -> Any: try: standard_order = react_order - prod_order except TypeError: return 0 if ignore_aromaticity and abs(standard_order) < 1: return 0 return standard_order @staticmethod def _candidate_typesgh_hh_pair(react_graph: nx.Graph, u: int, v: int) -> bool: return ( HComplete._candidate_node_attr(react_graph, u, "element") == "H" and HComplete._candidate_node_attr(react_graph, v, "element") == "H" ) @staticmethod def _candidate_tuple_node_changed( react_graph: nx.Graph, prod_graph: nx.Graph, node_id: int ) -> bool: for attr in ("element", "charge", "radical", "valence_electrons"): pair = ( HComplete._candidate_node_attr(react_graph, node_id, attr), HComplete._candidate_node_attr(prod_graph, node_id, attr), ) if RCExtractor._pair_diff(pair): return True hcount_pair = ( HComplete._candidate_node_attr(react_graph, node_id, "hcount"), HComplete._candidate_node_attr(prod_graph, node_id, "hcount"), ) if RCExtractor._hcount_diff(hcount_pair): return True lp_pair = ( HComplete._candidate_lone_pair_attr(react_graph, node_id), HComplete._candidate_lone_pair_attr(prod_graph, node_id), ) return RCExtractor._pair_diff(lp_pair) @staticmethod def _add_candidate_comparison_node( graph: nx.Graph, react_graph: nx.Graph, prod_graph: nx.Graph, node_id: int, format: ITSFormat, ) -> None: if graph.has_node(node_id): return if format == "typesGH": element = HComplete._candidate_node_attr(react_graph, node_id, "element") charge = HComplete._candidate_node_attr(react_graph, node_id, "charge") else: element = ( HComplete._candidate_node_attr(react_graph, node_id, "element"), HComplete._candidate_node_attr(prod_graph, node_id, "element"), ) charge = ( HComplete._candidate_node_attr(react_graph, node_id, "charge"), HComplete._candidate_node_attr(prod_graph, node_id, "charge"), ) cmp_element = HComplete._comparison_node_value(element) cmp_charge = HComplete._comparison_pair_value(charge) graph.add_node( node_id, cmp_element=cmp_element, cmp_charge=cmp_charge, cmp_node=f"{cmp_element}|{cmp_charge}", ) @staticmethod def _add_candidate_comparison_edge( graph: nx.Graph, u: int, v: int, react_order: Any, prod_order: Any, ) -> None: graph.add_edge( u, v, cmp_order=HComplete._comparison_pair_value((react_order, prod_order)), ) @staticmethod def _candidate_node_attr( graph: nx.Graph, node_id: int, attr: str, default: Any = None ) -> Any: if default is None: default = HComplete._candidate_default( ITSConstruction.CORE_NODE_DEFAULTS, attr ) if node_id not in graph: return default return graph.nodes[node_id].get(attr, default) @staticmethod def _candidate_edge_attr( graph: nx.Graph, u: int, v: int, attr: str, default: Any = None, ) -> Any: if default is None: default = HComplete._candidate_default( ITSConstruction.CORE_EDGE_DEFAULTS, attr ) if not graph.has_edge(u, v): return default return graph.edges[u, v].get(attr, default) @staticmethod def _candidate_lone_pair_attr(graph: nx.Graph, node_id: int) -> Any: if node_id not in graph: return HComplete._candidate_default( ITSConstruction.CORE_NODE_DEFAULTS, "lone_pairs" ) attrs = graph.nodes[node_id] return attrs.get("lone_pairs", attrs.get("lp", 0)) @staticmethod def _candidate_default(defaults: Dict[str, Any], attr: str) -> Any: value = defaults.get(attr) return value() if callable(value) else value
[docs] @staticmethod def add_hydrogen_nodes_multiple_utils( graph: nx.Graph, node_id_pairs: Iterable[Tuple[int, int]], atom_map_update: bool = True, ) -> nx.Graph: """Creates and returns a new graph with added hydrogen nodes based on the input graph and node ID pairs. Parameters: - graph (nx.Graph): The base graph to which the nodes will be added. - node_id_pairs (Iterable[Tuple[int, int]]): Pairs of node IDs (original node, new hydrogen node) to link with hydrogen. - atom_map_update (bool): If True, update the 'atom_map' attribute with the new hydrogen node ID; otherwise, retain the original node's 'atom_map'. Returns: - nx.Graph: A new graph instance with the added hydrogen nodes. """ new_graph = graph.copy() for node_id, new_hydrogen_node_id in node_id_pairs: atom_map_val = ( new_hydrogen_node_id if atom_map_update else new_graph.nodes[node_id].get("atom_map", 0) ) new_graph.add_node( new_hydrogen_node_id, charge=0, hcount=0, aromatic=False, element="H", atom_map=atom_map_val, # isomer="N", # partial_charge=0, # hybridization=0, # in_ring=False, # explicit_valence=0, # implicit_hcount=0, ) new_graph.add_edge( node_id, new_hydrogen_node_id, order=1.0, # ez_isomer="N", bond_type="SINGLE", # conjugated=False, # in_ring=False, ) new_graph.nodes[node_id]["hcount"] -= 1 return new_graph