From 983e5b6e890accd7fe9153c661314a4c49cef8d9 Mon Sep 17 00:00:00 2001 From: reginaib Date: Thu, 31 Oct 2024 13:05:57 +0100 Subject: [PATCH 01/67] periodic table refactored. --- chython/periodictable/__init__.py | 14 +- .../{element => base}/__init__.py | 9 +- .../{element => base}/dynamic.py | 56 +- .../{element => base}/element.py | 152 ++++-- chython/periodictable/{ => base}/groups.py | 2 +- chython/periodictable/{ => base}/periods.py | 2 +- chython/periodictable/base/query.py | 479 ++++++++++++++++++ chython/periodictable/element/core.py | 118 ----- chython/periodictable/element/query.py | 318 ------------ chython/periodictable/groupI.py | 8 +- chython/periodictable/groupII.py | 8 +- chython/periodictable/groupIII.py | 8 +- chython/periodictable/groupIV.py | 8 +- chython/periodictable/groupIX.py | 8 +- chython/periodictable/groupV.py | 8 +- chython/periodictable/groupVI.py | 8 +- chython/periodictable/groupVII.py | 8 +- chython/periodictable/groupVIII.py | 8 +- chython/periodictable/groupX.py | 6 +- chython/periodictable/groupXI.py | 6 +- chython/periodictable/groupXII.py | 8 +- chython/periodictable/groupXIII.py | 8 +- chython/periodictable/groupXIV.py | 6 +- chython/periodictable/groupXV.py | 6 +- chython/periodictable/groupXVI.py | 6 +- chython/periodictable/groupXVII.py | 6 +- chython/periodictable/groupXVIII.py | 8 +- 27 files changed, 700 insertions(+), 582 deletions(-) rename chython/periodictable/{element => base}/__init__.py (71%) rename chython/periodictable/{element => base}/dynamic.py (73%) rename chython/periodictable/{element => base}/element.py (79%) rename chython/periodictable/{ => base}/groups.py (95%) rename chython/periodictable/{ => base}/periods.py (93%) create mode 100644 chython/periodictable/base/query.py delete mode 100644 chython/periodictable/element/core.py delete mode 100644 chython/periodictable/element/query.py diff --git a/chython/periodictable/__init__.py b/chython/periodictable/__init__.py index 304f6e44..5f272d31 100644 --- a/chython/periodictable/__init__.py +++ b/chython/periodictable/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Ramil Nugmanov +# Copyright 2018-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -17,9 +17,9 @@ # along with this program; if not, see . # from abc import ABCMeta -from .element import * -from .groups import * -from .periods import * +from .base import * +from .base.groups import * +from .base.periods import * from .groupI import * from .groupII import * from .groupIII import * @@ -51,9 +51,9 @@ for _class in (DynamicElement, QueryElement): for k, v in elements.items(): name = f'{_class.__name__[:-7]}{k}' - globals()[name] = cls = type(name, (_class, *v.__mro__[-3:-1]), - {'__module__': v.__module__, '__slots__': (), 'atomic_number': v.atomic_number, - 'atomic_radius': v.atomic_radius}) + globals()[name] = cls = type(name, + (_class, *v.__mro__[-3:-1]), + {'__module__': v.__module__, '__slots__': (), 'atomic_number': v.atomic_number}) setattr(modules[v.__module__], name, cls) modules[v.__module__].__all__.append(name) __all__.append(name) diff --git a/chython/periodictable/element/__init__.py b/chython/periodictable/base/__init__.py similarity index 71% rename from chython/periodictable/element/__init__.py rename to chython/periodictable/base/__init__.py index 1fecc8f4..f63b3bb6 100644 --- a/chython/periodictable/element/__init__.py +++ b/chython/periodictable/base/__init__.py @@ -1,8 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2019-2021 Ramil Nugmanov -# Copyright 2019 Tagir Akhmetshin -# Copyright 2019 Dayana Bashirova +# Copyright 2019-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -18,10 +16,9 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from .core import * +from .dynamic import * from .element import * from .query import * -from .dynamic import * -__all__ = ['Core', 'Element', 'DynamicElement', 'QueryElement', 'AnyElement', 'AnyMetal', 'ListElement'] +__all__ = ['Element', 'DynamicElement', 'QueryElement', 'AnyElement', 'AnyMetal', 'ListElement'] diff --git a/chython/periodictable/element/dynamic.py b/chython/periodictable/base/dynamic.py similarity index 73% rename from chython/periodictable/element/dynamic.py rename to chython/periodictable/base/dynamic.py index 70aaaabd..d0989547 100644 --- a/chython/periodictable/element/dynamic.py +++ b/chython/periodictable/base/dynamic.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2020-2022 Ramil Nugmanov +# Copyright 2020-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -16,20 +16,32 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from abc import ABC -from typing import Type, Union -from .core import Core +from abc import ABC, abstractmethod +from typing import Type, Union, Optional from .element import Element -from ...exceptions import IsNotConnectedAtom -class DynamicElement(Core, ABC): - __slots__ = ('__p_charge', '__p_is_radical') +class DynamicElement(ABC): + __slots__ = ('_charge', '_is_radical', '_p_charge', '_p_is_radical', '_isotope') + + def __init__(self, isotope: Optional[int]): + self._isotope = isotope + + @property + def isotope(self): + return self._isotope @property def atomic_symbol(self) -> str: return self.__class__.__name__[7:] + @property + @abstractmethod + def atomic_number(self) -> int: + """ + Element number + """ + @classmethod def from_symbol(cls, symbol: str) -> Type['DynamicElement']: """ @@ -63,19 +75,21 @@ def from_atom(cls, atom: Union['Element', 'DynamicElement']) -> 'DynamicElement' raise TypeError('Element or DynamicElement expected') return atom.copy() + @property + def charge(self) -> int: + return self._charge + + @property + def is_radical(self) -> bool: + return self._is_radical + @property def p_charge(self) -> int: - try: - return self._graph()._p_charges[self._n] - except AttributeError: - raise IsNotConnectedAtom + return self._p_charge @property def p_is_radical(self) -> bool: - try: - return self._graph()._p_radicals[self._n] - except AttributeError: - raise IsNotConnectedAtom + return self._p_is_radical def __eq__(self, other): """ @@ -96,5 +110,17 @@ def is_dynamic(self) -> bool: """ return self.charge != self.p_charge or self.is_radical != self.p_is_radical + def copy(self): + copy = object.__new__(self.__class__) + copy._isotope = self.isotope + copy._charge = self.charge + copy._is_radical = self.is_radical + copy._p_is_radical = self.p_is_radical + copy._p_charge = self.p_charge + return copy + + def __copy__(self): + return self.copy() + __all__ = ['DynamicElement'] diff --git a/chython/periodictable/element/element.py b/chython/periodictable/base/element.py similarity index 79% rename from chython/periodictable/element/element.py rename to chython/periodictable/base/element.py index 22a28386..c3703336 100644 --- a/chython/periodictable/element/element.py +++ b/chython/periodictable/base/element.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2020-2023 Ramil Nugmanov +# Copyright 2020-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -20,12 +20,11 @@ from CachedMethods import class_cached_property from collections import defaultdict from typing import Dict, List, Optional, Set, Tuple, Type -from .core import Core from ...exceptions import IsNotConnectedAtom, ValenceError -class Element(Core, ABC): - __slots__ = () +class Element(ABC): + __slots__ = ('_isotope', '_charge', '_is_radical', '_x', '_y', '_implicit_hydrogens') __class_cache__ = {} def __init__(self, isotope: Optional[int] = None): @@ -39,12 +38,35 @@ def __init__(self, isotope: Optional[int] = None): raise ValueError(f'isotope number {isotope} impossible or not stable for {self.atomic_symbol}') elif isotope is not None: raise TypeError('integer isotope number required') - super().__init__(isotope) + self._isotope = isotope + self._charge = 0 + self._is_radical = False + self._x = self._y = 0 + self._implicit_hydrogens = None + + def __repr__(self): + if self._isotope: + return f'{self.__class__.__name__}({self._isotope})' + return f'{self.__class__.__name__}()' @property def atomic_symbol(self) -> str: return self.__class__.__name__ + @property + @abstractmethod + def atomic_number(self) -> int: + """ + Element number + """ + + @property + def isotope(self) -> Optional[int]: + """ + Isotope number + """ + return self._isotope + @property def atomic_mass(self) -> float: mass = self.isotopes_masses @@ -73,72 +95,103 @@ def atomic_radius(self) -> float: Valence radius of atom """ - @Core.charge.setter - def charge(self, charge: int): - if not isinstance(charge, int): + @property + def charge(self) -> int: + """ + Charge of atom + """ + return self._charge + + @charge.setter + def charge(self, value: int): + """ + Update charge of atom. Make sure to flush cache and recalculate hydrogens count and stereo. + Or use context manager on molecule: + + with mol: + mol.atom(1).charge = 1 + """ + if not isinstance(value, int): raise TypeError('formal charge should be int in range [-4, 4]') - elif charge > 4 or charge < -4: + elif value > 4 or value < -4: raise ValueError('formal charge should be in range [-4, 4]') - try: - g = self._graph() - g._charges[self._n] = charge - except AttributeError: - raise IsNotConnectedAtom - else: - g._calc_implicit(self._n) - g.flush_cache() - g.fix_stereo() + self._charge = value + + @property + def is_radical(self) -> bool: + """ + Radical state of atoms + """ + return self._is_radical + + @is_radical.setter + def is_radical(self, value: bool): + """ + Update radical state of atom. Make sure to flush cache and recalculate hydrogens count and stereo. + Or use context manager on molecule: - @Core.is_radical.setter - def is_radical(self, is_radical: bool): - if not isinstance(is_radical, bool): + with mol: + mol.atom(1).is_radical = True + """ + if not isinstance(value, bool): raise TypeError('bool expected') - try: - g = self._graph() - g._radicals[self._n] = is_radical - except AttributeError: - raise IsNotConnectedAtom - else: - g._calc_implicit(self._n) - g.flush_cache() - g.fix_stereo() + self._is_radical = value @property def x(self) -> float: """ X coordinate of atom on 2D plane """ - try: - return self._graph()._plane[self._n][0] - except AttributeError: - raise IsNotConnectedAtom + return self._x + + @x.setter + def x(self, value: float): + if not isinstance(value, float): + raise TypeError('float expected') + self._x = value @property def y(self) -> float: """ Y coordinate of atom on 2D plane """ - try: - return self._graph()._plane[self._n][1] - except AttributeError: - raise IsNotConnectedAtom + return self._y + + @y.setter + def y(self, value: float): + if not isinstance(value, float): + raise TypeError('float expected') + self._y = value @property def xy(self) -> Tuple[float, float]: """ (X, Y) coordinates of atom on 2D plane """ - try: - return self._graph()._plane[self._n] - except AttributeError: - raise IsNotConnectedAtom + return self._x, self._y + + @xy.setter + def xy(self, value: Tuple[float, float]): + if (not isinstance(value, (tuple, list)) + or len(value) != 2 + or not isinstance(value[0], float) + or not isinstance(value[1], float)): + raise TypeError('tuple of 2 floats expected') + self._x, self._y = value @property def implicit_hydrogens(self) -> Optional[int]: - try: - return self._graph()._hydrogens[self._n] - except AttributeError: - raise IsNotConnectedAtom + return self._implicit_hydrogens + + def copy(self): + copy = object.__new__(self.__class__) + copy._isotope = self.isotope + copy._charge = self.charge + copy._is_radical = self.is_radical + return copy + + def __copy__(self): + return self.copy() @property def explicit_hydrogens(self) -> int: @@ -149,10 +202,9 @@ def explicit_hydrogens(self) -> int: @property def total_hydrogens(self) -> int: - try: - return self._graph().total_hydrogens(self._n) - except AttributeError: - raise IsNotConnectedAtom + if self._implicit_hydrogens is None: + raise ValenceError + return self._implicit_hydrogens + self.explicit_hydrogens @property def heteroatoms(self) -> int: diff --git a/chython/periodictable/groups.py b/chython/periodictable/base/groups.py similarity index 95% rename from chython/periodictable/groups.py rename to chython/periodictable/base/groups.py index 912c9ae3..75809c61 100644 --- a/chython/periodictable/groups.py +++ b/chython/periodictable/base/groups.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2019-2021 Ramil Nugmanov +# Copyright 2019-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify diff --git a/chython/periodictable/periods.py b/chython/periodictable/base/periods.py similarity index 93% rename from chython/periodictable/periods.py rename to chython/periodictable/base/periods.py index 2f3e6cba..f05e6d08 100644 --- a/chython/periodictable/periods.py +++ b/chython/periodictable/base/periods.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2019-2021 Ramil Nugmanov +# Copyright 2019-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify diff --git a/chython/periodictable/base/query.py b/chython/periodictable/base/query.py new file mode 100644 index 00000000..5ae7adb5 --- /dev/null +++ b/chython/periodictable/base/query.py @@ -0,0 +1,479 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2020-2024 Ramil Nugmanov +# Copyright 2021 Dmitrij Zanadvornykh +# This file is part of chython. +# +# chython is free software; you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, see . +# +from abc import ABC, abstractmethod +from functools import cached_property +from typing import Tuple, Type, List, Union, Optional +from .element import Element + + +_inorganic = {'He', 'Ne', 'Ar', 'Kr', 'Xe', 'F', 'Cl', 'Br', 'I', 'B', 'C', 'N', 'O', + 'H', 'Si', 'P', 'S', 'Se', 'Ge', 'As', 'Sb', 'Te', 'At'} + + +def _validate(value, prop): + if value is None: + return () + elif isinstance(value, int): + if value < 0 or value > 14: + raise ValueError(f'{prop} should be in range [0, 14]') + return (value,) + elif isinstance(value, (tuple, list)): + if not all(isinstance(x, int) for x in value): + raise TypeError(f'{prop} should be list or tuple of ints') + if any(x < 0 or x > 14 for x in value): + raise ValueError(f'{prop} should be in range [0, 14]') + if len(set(value)) != len(value): + raise ValueError(f'{prop} should be unique') + return tuple(sorted(value)) + else: + raise TypeError(f'{prop} should be int or list or tuple of ints') + + +class Query(ABC): + __slots__ = ('_neighbors', '_hybridization', '_masked') + + def __init__(self): + self._neighbors = () + self._hybridization = () + self._masked = False + + @property + def neighbors(self) -> Tuple[int, ...]: + return self._neighbors + + @neighbors.setter + def neighbors(self, value): + self._neighbors = _validate(value, 'neighbors') + + @property + def hybridization(self) -> Tuple[int, ...]: + return self._hybridization + + @hybridization.setter + def hybridization(self, value): + if value is None: + self._hybridization = () + elif isinstance(value, int): + if value < 1 or value > 4: + raise ValueError('hybridization should be in range [1, 4]') + self._hybridization = (value,) + elif isinstance(value, (tuple, list)): + if not all(isinstance(h, int) for h in value): + raise TypeError('hybridizations should be list or tuple of ints') + if any(h < 1 or h > 4 for h in value): + raise ValueError('hybridizations should be in range [1, 4]') + if len(set(value)) != len(value): + raise ValueError('hybridizations should be unique') + self._hybridization = tuple(sorted(value)) + else: + raise TypeError('hybridization should be int or list or tuple of ints') + + @property + def masked(self): + return self._masked + + @masked.setter + def masked(self, value): + if not isinstance(value, bool): + raise TypeError('masked should be bool') + self._masked = value + + def copy(self): + copy = object.__new__(self.__class__) + copy._neighbors = self.neighbors + copy._hybridization = self.hybridization + copy._masked = self.masked + return copy + + def __copy__(self): + return self.copy() + + def __repr__(self): + return f'{self.__class__.__name__}()' + + +class ExtendedQuery(Query, ABC): + __slots__ = ('_charge', '_is_radical', '_heteroatoms', '_ring_sizes', '_implicit_hydrogens') + + def __init__(self): + super().__init__() + self._charge = 0 + self._is_radical = False + self._heteroatoms = () + self._ring_sizes = () + self._implicit_hydrogens = () + + @property + def charge(self) -> int: + """ + Charge of atom + """ + return self._charge + + @charge.setter + def charge(self, value: int): + if not isinstance(value, int): + raise TypeError('formal charge should be int in range [-4, 4]') + elif value > 4 or value < -4: + raise ValueError('formal charge should be in range [-4, 4]') + self._charge = value + + @property + def is_radical(self) -> bool: + """ + Radical state of atoms + """ + return self._is_radical + + @is_radical.setter + def is_radical(self, value: bool): + if not isinstance(value, bool): + raise TypeError('bool expected') + self._is_radical = value + + @property + def heteroatoms(self) -> Tuple[int, ...]: + return self._heteroatoms + + @heteroatoms.setter + def heteroatoms(self, value): + self._heteroatoms = _validate(value, 'heteroatoms') + + @property + def implicit_hydrogens(self) -> Tuple[int, ...]: + return self._implicit_hydrogens + + @implicit_hydrogens.setter + def implicit_hydrogens(self, value): + self._implicit_hydrogens = _validate(value, 'implicit hydrogens') + + @property + def ring_sizes(self) -> Tuple[int, ...]: + """ + Atom rings sizes. + """ + return self._ring_sizes + + @ring_sizes.setter + def ring_sizes(self, value): + if value is None: + self._ring_sizes = () + elif isinstance(value, int): + if value < 3 and value != 0: + raise ValueError('rings should be greater or equal 3. ring equal to zero is no ring atom mark') + self._ring_sizes = (value,) + elif isinstance(value, (tuple, list)): + if not all(isinstance(x, int) for x in value): + raise TypeError('rings should be list or tuple of ints') + if any(x < 3 for x in value): + raise ValueError('rings should be greater or equal 3') + if len(set(value)) != len(value): + raise ValueError('rings should be unique') + self._ring_sizes = tuple(sorted(value)) + else: + raise TypeError('rings should be int or list or tuple of ints') + + def copy(self): + copy = super().copy() + copy._charge = self.charge + copy._is_radical = self.is_radical + copy._heteroatoms = self.heteroatoms + copy._implicit_hydrogens = self.implicit_hydrogens + copy._ring_sizes = self.ring_sizes + return copy + + +class AnyMetal(Query): + """ + Charge and radical ignored any metal. Rings, hydrogens and heteroatoms count also ignored. + + Class designed for d-elements matching in standardization. + """ + __slots__ = () + + @property + def atomic_symbol(self) -> str: + return 'M' + + def __eq__(self, other): + if isinstance(other, Element): + if other.atomic_symbol in _inorganic: + return False + if self.neighbors and other.neighbors not in self.neighbors: + return False + if self.hybridization and other.hybridization not in self.hybridization: + return False + return True + # metal is subset of metal. only + return (isinstance(other, AnyMetal) + and self.neighbors == other.neighbors + and self.hybridization == other.hybridization) + + def __hash__(self): + return hash((self.neighbors, self.hybridization)) + + +class AnyElement(ExtendedQuery): + __slots__ = () + + @property + def atomic_symbol(self) -> str: + return 'A' + + def __eq__(self, other): + """ + Compare attached to molecules elements and query elements + """ + if isinstance(other, Element): + if self.charge != other.charge: + return False + if self.is_radical != other.is_radical: + return False + if self.neighbors and other.neighbors not in self.neighbors: + return False + if self.hybridization and other.hybridization not in self.hybridization: + return False + if self.ring_sizes: + if self.ring_sizes[0]: + if set(self.ring_sizes).isdisjoint(other.ring_sizes): + return False + elif other.ring_sizes: # not in ring expected + return False + if self.implicit_hydrogens and other.implicit_hydrogens not in self.implicit_hydrogens: + return False + if self.heteroatoms and other.heteroatoms not in self.heteroatoms: + return False + return True + # any is subset of any. only + return (isinstance(other, AnyElement) + and self.charge == other.charge + and self.is_radical == other.is_radical + and self.neighbors == other.neighbors + and self.hybridization == other.hybridization + and self.ring_sizes == other.ring_sizes + and self.implicit_hydrogens == other.implicit_hydrogens + and self.heteroatoms == other.heteroatoms) + + def __hash__(self): + return hash((self.charge, self.is_radical, self.neighbors, self.hybridization, + self.ring_sizes, self.implicit_hydrogens, self.heteroatoms)) + + +class ListElement(ExtendedQuery): + __slots__ = ('_elements', '__dict__') + + def __init__(self, elements: List[str]): + """ + Elements list + """ + if not isinstance(elements, (list, tuple)) or not elements: + raise ValueError('invalid elements list') + super().__init__() + self._elements = tuple(elements) + + @property + def atomic_symbol(self) -> str: + return ','.join(self._elements) + + @cached_property + def atomic_numbers(self): + return tuple(x.atomic_number.fget(None) for x in Element.__subclasses__() if x.__name__ in self._elements) + + def copy(self): + copy = super().copy() + copy._elements = self._elements + return copy + + def __eq__(self, other): + """ + Compare attached to molecules elements and query elements + """ + if isinstance(other, Element): + if other.atomic_number not in self.atomic_numbers: + return False + if self.charge != other.charge: + return False + if self.is_radical != other.is_radical: + return False + if self.neighbors and other.neighbors not in self.neighbors: + return False + if self.hybridization and other.hybridization not in self.hybridization: + return False + if self.ring_sizes: + if self.ring_sizes[0]: + if set(self.ring_sizes).isdisjoint(other.ring_sizes): + return False + elif other.ring_sizes: # not in ring expected + return False + if self.implicit_hydrogens and other.implicit_hydrogens not in self.implicit_hydrogens: + return False + if self.heteroatoms and other.heteroatoms not in self.heteroatoms: + return False + return True + # List is subset of Any and List + elif (isinstance(other, (ListElement, AnyElement)) + and self.charge == other.charge + and self.is_radical == other.is_radical + and self.neighbors == other.neighbors + and self.hybridization == other.hybridization + and self.ring_sizes == other.ring_sizes + and self.implicit_hydrogens == other.implicit_hydrogens + and self.heteroatoms == other.heteroatoms): + # list should contain all elements of other list + if isinstance(other, ListElement): + return set(self.atomic_numbers).issubset(other.atomic_numbers) + return True + return False + + def __hash__(self): + return hash((self.atomic_numbers, self.charge, self.is_radical, self.neighbors, self.hybridization, + self.ring_sizes, self.implicit_hydrogens, self.heteroatoms)) + + def __repr__(self): + return f'{self.__class__.__name__}([{",".join(self._elements)}])' + + +class QueryElement(ExtendedQuery, ABC): + __slots__ = ('_isotope',) + + def __init__(self, isotope: Optional[int]): + if isotope is not None and not isinstance(isotope, int): + raise TypeError('isotope must be an int') + super().__init__() + self._isotope = isotope + + def __repr__(self): + if self._isotope: + return f'{self.__class__.__name__}({self._isotope})' + return f'{self.__class__.__name__}()' + + @property + def atomic_symbol(self) -> str: + return self.__class__.__name__[5:] + + @property + @abstractmethod + def atomic_number(self) -> int: + """ + Element number + """ + + @property + def isotope(self): + return self._isotope + + @classmethod + def from_symbol(cls, symbol: str) -> Type[Union['QueryElement', 'AnyElement', 'AnyMetal']]: + """ + get Element class by its symbol + """ + if symbol == 'A': + return AnyElement + elif symbol == 'M': + return AnyMetal + try: + element = next(x for x in QueryElement.__subclasses__() if x.__name__ == f'Query{symbol}') + except StopIteration: + raise ValueError(f'QueryElement with symbol "{symbol}" not found') + return element + + @classmethod + def from_atomic_number(cls, number: int) -> Type['QueryElement']: + """ + get Element class by its number + """ + try: + element = next(x for x in QueryElement.__subclasses__() if x.atomic_number.fget(None) == number) + except StopIteration: + raise ValueError(f'QueryElement with number "{number}" not found') + return element + + @classmethod + def from_atom(cls, atom: Union['Element', 'Query']) -> 'Query': + """ + get QueryElement or AnyElement object from Element object or copy of QueryElement or AnyElement + """ + if isinstance(atom, Element): + # transfer true atomic props + query = cls.from_atomic_number(atom.atomic_number)(atom.isotope) + query._charge = atom.charge + query._is_radical = atom.is_radical + return query + elif not isinstance(atom, Query): + raise TypeError('Element or Query expected') + return atom.copy() + + def copy(self): + copy = super().copy() + copy._isotope = self.isotope + return copy + + def __eq__(self, other): + """ + compare attached to molecules elements and query elements + """ + if isinstance(other, Element): + if self.atomic_number != other.atomic_number: + return False + if self.charge != other.charge: + return False + if self.is_radical != other.is_radical: + return False + if self.isotope and self.isotope != other.isotope: + return False + if self.neighbors and other.neighbors not in self.neighbors: + return False + if self.hybridization and other.hybridization not in self.hybridization: + return False + if self.ring_sizes: + if self.ring_sizes[0]: + if set(self.ring_sizes).isdisjoint(other.ring_sizes): + return False + elif other.ring_sizes: # not in ring expected + return False + if self.implicit_hydrogens and other.implicit_hydrogens not in self.implicit_hydrogens: + return False + if self.heteroatoms and other.heteroatoms not in self.heteroatoms: + return False + return True + elif (isinstance(other, ExtendedQuery) + and self.charge == other.charge + and self.is_radical == other.is_radical + and self.neighbors == other.neighbors + and self.hybridization == other.hybridization + and self.ring_sizes == other.ring_sizes + and self.implicit_hydrogens == other.implicit_hydrogens + and self.heteroatoms == other.heteroatoms): + # query element should fully match other query element + if isinstance(other, QueryElement): + return self.atomic_number == other.atomic_number and self.isotope == other.isotope + # query element is subset of any element + elif isinstance(other, AnyElement): + return True + # query element should be in list + return isinstance(other, ListElement) and self.atomic_number in other.atomic_numbers + return False + + def __hash__(self): + return hash((self.isotope or 0, self.atomic_number, self.charge, self.is_radical, self.neighbors, + self.hybridization, self.ring_sizes, self.implicit_hydrogens, self.heteroatoms)) + + +__all__ = ['Query', 'QueryElement', 'AnyElement', 'AnyMetal', 'ListElement'] diff --git a/chython/periodictable/element/core.py b/chython/periodictable/element/core.py deleted file mode 100644 index f5ab05ca..00000000 --- a/chython/periodictable/element/core.py +++ /dev/null @@ -1,118 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright 2020-2022 Ramil Nugmanov -# This file is part of chython. -# -# chython is free software; you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation; either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with this program; if not, see . -# -from abc import ABC, abstractmethod -from typing import Optional, TypeVar -from weakref import ref -from ...exceptions import IsConnectedAtom, IsNotConnectedAtom - - -T = TypeVar('T') - - -class Core(ABC): - __slots__ = ('__isotope', '_graph', '_n') - - def __init__(self, isotope: Optional[int] = None): - self.__isotope = isotope - - def __repr__(self): - if self.__isotope: - return f'{self.__class__.__name__}({self.__isotope})' - return f'{self.__class__.__name__}()' - - def __getstate__(self): - return {'isotope': self.__isotope} - - def __setstate__(self, state): - self.__isotope = state['isotope'] - - @abstractmethod - def __hash__(self): - """ - Atom hash used in Morgan atom numbering algorithm. - """ - - @property - @abstractmethod - def atomic_symbol(self) -> str: - """ - Element symbol - """ - - @property - @abstractmethod - def atomic_number(self) -> int: - """ - Element number - """ - - @property - def isotope(self) -> Optional[int]: - """ - Isotope number - """ - return self.__isotope - - @property - def charge(self) -> int: - """ - Charge of atom - """ - try: - return self._graph()._charges[self._n] - except AttributeError: - raise IsNotConnectedAtom - - @property - def is_radical(self) -> bool: - """ - Radical state of atoms - """ - try: - return self._graph()._radicals[self._n] - except AttributeError: - raise IsNotConnectedAtom - - def copy(self: T) -> T: - """ - Detached from graph copy of element - """ - copy = object.__new__(self.__class__) - copy._Core__isotope = self.__isotope - return copy - - def _attach_graph(self, graph, n): - try: - self._graph - except AttributeError: - self._graph = ref(graph) - self._n = n - else: - raise IsConnectedAtom - - def _change_map(self, n): - try: - self._graph - except AttributeError: - raise IsNotConnectedAtom - else: - self._n = n - - -__all__ = ['Core'] diff --git a/chython/periodictable/element/query.py b/chython/periodictable/element/query.py deleted file mode 100644 index 94b9edca..00000000 --- a/chython/periodictable/element/query.py +++ /dev/null @@ -1,318 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright 2020-2024 Ramil Nugmanov -# Copyright 2021 Dmitrij Zanadvornykh -# This file is part of chython. -# -# chython is free software; you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation; either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with this program; if not, see . -# -from abc import ABC -from typing import Tuple, Type, List, Union -from .core import Core -from .element import Element -from ...exceptions import IsNotConnectedAtom - - -_inorganic = {'He', 'Ne', 'Ar', 'Kr', 'Xe', 'F', 'Cl', 'Br', 'I', 'B', 'C', 'N', 'O', - 'H', 'Si', 'P', 'S', 'Se', 'Ge', 'As', 'Sb', 'Te', 'At'} - - -class Query(Core, ABC): - __slots__ = () - - @property - def neighbors(self) -> Tuple[int, ...]: - try: - return self._graph()._neighbors[self._n] - except AttributeError: - raise IsNotConnectedAtom - - @property - def hybridization(self): - try: - return self._graph()._hybridizations[self._n] - except AttributeError: - raise IsNotConnectedAtom - - @property - def heteroatoms(self) -> Tuple[int, ...]: - try: - return self._graph()._heteroatoms[self._n] - except AttributeError: - raise IsNotConnectedAtom - - @property - def ring_sizes(self) -> Tuple[int, ...]: - """ - Atom rings sizes. - """ - try: - return self._graph()._rings_sizes[self._n] - except AttributeError: - raise IsNotConnectedAtom - except KeyError: - return () - - @property - def implicit_hydrogens(self) -> Tuple[int, ...]: - try: - return self._graph()._hydrogens[self._n] - except AttributeError: - raise IsNotConnectedAtom - - -class QueryElement(Query, ABC): - __slots__ = () - - @property - def atomic_symbol(self) -> str: - return self.__class__.__name__[5:] - - @classmethod - def from_symbol(cls, symbol: str) -> Type[Union['QueryElement', 'AnyElement', 'AnyMetal']]: - """ - get Element class by its symbol - """ - if symbol == 'A': - return AnyElement - elif symbol == 'M': - return AnyMetal - try: - element = next(x for x in QueryElement.__subclasses__() if x.__name__ == f'Query{symbol}') - except StopIteration: - raise ValueError(f'QueryElement with symbol "{symbol}" not found') - return element - - @classmethod - def from_atomic_number(cls, number: int) -> Type['QueryElement']: - """ - get Element class by its number - """ - try: - element = next(x for x in QueryElement.__subclasses__() if x.atomic_number.fget(None) == number) - except StopIteration: - raise ValueError(f'QueryElement with number "{number}" not found') - return element - - @classmethod - def from_atom(cls, atom: Union['Element', 'Query']) -> 'Query': - """ - get QueryElement or AnyElement object from Element object or copy of QueryElement or AnyElement - """ - if isinstance(atom, Element): - return cls.from_atomic_number(atom.atomic_number)(atom.isotope) - elif not isinstance(atom, Query): - raise TypeError('Element or Query expected') - return atom.copy() - - def __eq__(self, other): - """ - compare attached to molecules elements and query elements - """ - if isinstance(other, Element): - if self.atomic_number == other.atomic_number and self.charge == other.charge and \ - self.is_radical == other.is_radical: - if self.isotope and self.isotope != other.isotope: - return False - if self.neighbors and other.neighbors not in self.neighbors: - return False - if self.hybridization and other.hybridization not in self.hybridization: - return False - if self.ring_sizes: - if self.ring_sizes[0]: - if set(self.ring_sizes).isdisjoint(other.ring_sizes): - return False - elif other.ring_sizes: # not in ring expected - return False - if self.implicit_hydrogens and other.implicit_hydrogens not in self.implicit_hydrogens: - return False - if self.heteroatoms and other.heteroatoms not in self.heteroatoms: - return False - return True - elif isinstance(other, QueryElement) and self.atomic_number == other.atomic_number and \ - self.isotope == other.isotope and self.charge == other.charge and self.is_radical == other.is_radical \ - and self.neighbors == other.neighbors and self.hybridization == other.hybridization \ - and self.ring_sizes == other.ring_sizes and self.implicit_hydrogens == other.implicit_hydrogens \ - and self.heteroatoms == other.heteroatoms: - # equal query element has equal query marks - return True - return False - - def __hash__(self): - return hash((self.isotope or 0, self.atomic_number, self.charge, self.is_radical, self.neighbors, - self.hybridization, self.ring_sizes, self.implicit_hydrogens, self.heteroatoms)) - - -class AnyElement(Query): - __slots__ = () - - def __init__(self, *args, **kwargs): - super().__init__() - - @property - def atomic_symbol(self) -> str: - return 'A' - - @property - def atomic_number(self) -> int: - return 0 - - def __eq__(self, other): - """ - Compare attached to molecules elements and query elements - """ - if isinstance(other, Element): - if self.charge == other.charge and self.is_radical == other.is_radical: - if self.neighbors and other.neighbors not in self.neighbors: - return False - if self.hybridization and other.hybridization not in self.hybridization: - return False - if self.ring_sizes: - if self.ring_sizes[0]: - if set(self.ring_sizes).isdisjoint(other.ring_sizes): - return False - elif other.ring_sizes: # not in ring expected - return False - if self.implicit_hydrogens and other.implicit_hydrogens not in self.implicit_hydrogens: - return False - if self.heteroatoms and other.heteroatoms not in self.heteroatoms: - return False - return True - elif isinstance(other, AnyMetal): - return False - elif isinstance(other, Query) and self.charge == other.charge and self.is_radical == other.is_radical \ - and self.neighbors == other.neighbors and self.hybridization == other.hybridization \ - and self.ring_sizes == other.ring_sizes and self.implicit_hydrogens == other.implicit_hydrogens \ - and self.heteroatoms == other.heteroatoms: - return True - return False - - def __hash__(self): - return hash((self.charge, self.is_radical, self.neighbors, self.hybridization, self.ring_sizes, - self.implicit_hydrogens, self.heteroatoms)) - - -class AnyMetal(Query): - """ - Charge and radical ignored any metal. Rings, hydrogens and heteroatoms count also ignored. - - Class designed for d-elements matching in standardization. - """ - def __init__(self, *args, **kwargs): - super().__init__() - - @property - def atomic_symbol(self) -> str: - return 'M' - - @property - def atomic_number(self) -> int: - return 0 - - def __eq__(self, other): - if isinstance(other, Element): - if other.atomic_symbol not in _inorganic: - if self.neighbors and other.neighbors not in self.neighbors: - return False - if self.hybridization and other.hybridization not in self.hybridization: - return False - return True - elif isinstance(other, AnyMetal) and self.neighbors == other.neighbors \ - and self.hybridization == other.hybridization: - return True - return False - - def __hash__(self): - return hash((self.neighbors, self.hybridization)) - - -class ListElement(Query): - __slots__ = ('_elements', '_numbers') - - def __init__(self, elements: List[str], *args, **kwargs): - """ - Elements list - """ - super().__init__() - self._elements = tuple(elements) - self._numbers = tuple(x.atomic_number.fget(None) for x in Element.__subclasses__() if x.__name__ in elements) - - @property - def atomic_symbol(self) -> str: - return ','.join(self._elements) - - @property - def atomic_number(self) -> int: - return 0 - - def copy(self): - copy = super().copy() - copy._elements = self._elements - copy._numbers = self._numbers - return copy - - def __eq__(self, other): - """ - Compare attached to molecules elements and query elements - """ - if isinstance(other, Element): - if other.atomic_number in self._numbers: - if self.charge != other.charge or self.is_radical != other.is_radical: - return False - if self.neighbors and other.neighbors not in self.neighbors: - return False - if self.hybridization and other.hybridization not in self.hybridization: - return False - if self.ring_sizes: - if self.ring_sizes[0]: - if set(self.ring_sizes).isdisjoint(other.ring_sizes): - return False - elif other.ring_sizes: # not in ring expected - return False - if self.implicit_hydrogens and other.implicit_hydrogens not in self.implicit_hydrogens: - return False - if self.heteroatoms and other.heteroatoms not in self.heteroatoms: - return False - return True - elif isinstance(other, (AnyElement, AnyMetal)): - return False - elif isinstance(other, Query) and self.charge == other.charge and self.is_radical == other.is_radical \ - and self.neighbors == other.neighbors and self.hybridization == other.hybridization \ - and self.ring_sizes == other.ring_sizes and self.implicit_hydrogens == other.implicit_hydrogens \ - and self.heteroatoms == other.heteroatoms: - if isinstance(other, ListElement): - return self._numbers == other._numbers - return other.atomic_number in self._numbers - return False - - def __hash__(self): - return hash((self._numbers, self.charge, self.is_radical, self.neighbors, self.hybridization, - self.ring_sizes, self.implicit_hydrogens, self.heteroatoms)) - - def __getstate__(self): - state = super().__getstate__() - state['elements'] = self._elements - return state - - def __setstate__(self, state): - self._elements = state['elements'] - self._numbers = tuple(x.atomic_number.fget(None) for x in Element.__subclasses__() - if x.__name__ in state['elements']) - super().__setstate__(state) - - def __repr__(self): - return f'{self.__class__.__name__}([{",".join(self._elements)}])' - - -__all__ = ['Query', 'QueryElement', 'AnyElement', 'AnyMetal', 'ListElement'] diff --git a/chython/periodictable/groupI.py b/chython/periodictable/groupI.py index 9b06949d..a7c10f55 100644 --- a/chython/periodictable/groupI.py +++ b/chython/periodictable/groupI.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2019-2021 Ramil Nugmanov +# Copyright 2019-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -16,9 +16,9 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from .element import Element -from .groups import GroupI -from .periods import * +from .base import Element +from .base.groups import GroupI +from .base.periods import * class H(Element, PeriodI, GroupI): diff --git a/chython/periodictable/groupII.py b/chython/periodictable/groupII.py index 0df4a674..bae2cf65 100644 --- a/chython/periodictable/groupII.py +++ b/chython/periodictable/groupII.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2019-2021 Ramil Nugmanov +# Copyright 2019-2024 Ramil Nugmanov # Copyright 2019 Tagir Akhmetshin # This file is part of chython. # @@ -17,9 +17,9 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from .element import Element -from .groups import GroupII -from .periods import PeriodII, PeriodIII, PeriodIV, PeriodV, PeriodVI, PeriodVII +from .base import Element +from .base.groups import GroupII +from .base.periods import PeriodII, PeriodIII, PeriodIV, PeriodV, PeriodVI, PeriodVII class Be(Element, PeriodII, GroupII): diff --git a/chython/periodictable/groupIII.py b/chython/periodictable/groupIII.py index 60c57630..a2683f8d 100644 --- a/chython/periodictable/groupIII.py +++ b/chython/periodictable/groupIII.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2019-2023 Ramil Nugmanov +# Copyright 2019-2024 Ramil Nugmanov # Copyright 2019 Tagir Akhmetshin # This file is part of chython. # @@ -17,9 +17,9 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from .element import Element -from .groups import GroupIII -from .periods import PeriodIV, PeriodV, PeriodVI, PeriodVII +from .base import Element +from .base.groups import GroupIII +from .base.periods import PeriodIV, PeriodV, PeriodVI, PeriodVII class Sc(Element, PeriodIV, GroupIII): diff --git a/chython/periodictable/groupIV.py b/chython/periodictable/groupIV.py index cc22146a..c80e1482 100644 --- a/chython/periodictable/groupIV.py +++ b/chython/periodictable/groupIV.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2019-2023 Ramil Nugmanov +# Copyright 2019-2024 Ramil Nugmanov # Copyright 2019 Tagir Akhmetshin # This file is part of chython. # @@ -17,9 +17,9 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from .element import Element -from .groups import GroupIV -from .periods import PeriodIV, PeriodV, PeriodVI, PeriodVII +from .base import Element +from .base.groups import GroupIV +from .base.periods import PeriodIV, PeriodV, PeriodVI, PeriodVII class Ti(Element, PeriodIV, GroupIV): diff --git a/chython/periodictable/groupIX.py b/chython/periodictable/groupIX.py index 6cf22449..97608fd9 100644 --- a/chython/periodictable/groupIX.py +++ b/chython/periodictable/groupIX.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2019-2023 Ramil Nugmanov +# Copyright 2019-2024 Ramil Nugmanov # Copyright 2019 Tagir Akhmetshin # Copyright 2019 Tansu Nasyrova # This file is part of chython. @@ -18,9 +18,9 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from .element import Element -from .groups import GroupIX -from .periods import PeriodIV, PeriodV, PeriodVI, PeriodVII +from .base import Element +from .base.groups import GroupIX +from .base.periods import PeriodIV, PeriodV, PeriodVI, PeriodVII class Co(Element, PeriodIV, GroupIX): diff --git a/chython/periodictable/groupV.py b/chython/periodictable/groupV.py index e923cec1..66036c63 100644 --- a/chython/periodictable/groupV.py +++ b/chython/periodictable/groupV.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2019-2021 Ramil Nugmanov +# Copyright 2019-2024 Ramil Nugmanov # Copyright 2019 Alexander Nikanshin <17071996sasha@gmail.com> # Copyright 2019 Tagir Akhmetshin # This file is part of chython. @@ -18,9 +18,9 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from .element import Element -from .groups import GroupV -from .periods import PeriodIV, PeriodV, PeriodVI, PeriodVII +from .base import Element +from .base.groups import GroupV +from .base.periods import PeriodIV, PeriodV, PeriodVI, PeriodVII class V(Element, PeriodIV, GroupV): diff --git a/chython/periodictable/groupVI.py b/chython/periodictable/groupVI.py index 6fa24b94..03b76191 100644 --- a/chython/periodictable/groupVI.py +++ b/chython/periodictable/groupVI.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2019-2021 Ramil Nugmanov +# Copyright 2019-2024 Ramil Nugmanov # Copyright 2019 Tagir Akhmetshin # Copyright 2019 Dayana Bashirova # This file is part of chython. @@ -18,9 +18,9 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from .element import Element -from .groups import GroupVI -from .periods import PeriodIV, PeriodV, PeriodVI, PeriodVII +from .base import Element +from .base.groups import GroupVI +from .base.periods import PeriodIV, PeriodV, PeriodVI, PeriodVII class Cr(Element, PeriodIV, GroupVI): diff --git a/chython/periodictable/groupVII.py b/chython/periodictable/groupVII.py index c66e89d9..3fceee40 100644 --- a/chython/periodictable/groupVII.py +++ b/chython/periodictable/groupVII.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2019-2021 Ramil Nugmanov +# Copyright 2019-2024 Ramil Nugmanov # Copyright 2019 Tagir Akhmetshin # Copyright 2019 Alexander Nikanshin <17071996sasha@gmail.com> # This file is part of chython. @@ -18,9 +18,9 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from .element import Element -from .groups import GroupVII -from .periods import PeriodIV, PeriodV, PeriodVI, PeriodVII +from .base import Element +from .base.groups import GroupVII +from .base.periods import PeriodIV, PeriodV, PeriodVI, PeriodVII class Mn(Element, PeriodIV, GroupVII): diff --git a/chython/periodictable/groupVIII.py b/chython/periodictable/groupVIII.py index 3d88324b..ea510d60 100644 --- a/chython/periodictable/groupVIII.py +++ b/chython/periodictable/groupVIII.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2019-2023 Ramil Nugmanov +# Copyright 2019-2024 Ramil Nugmanov # Copyright 2019 Tagir Akhmetshin # This file is part of chython. # @@ -17,9 +17,9 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from .element import Element -from .groups import GroupVIII -from .periods import PeriodIV, PeriodV, PeriodVI, PeriodVII +from .base import Element +from .base.groups import GroupVIII +from .base.periods import PeriodIV, PeriodV, PeriodVI, PeriodVII class Fe(Element, PeriodIV, GroupVIII): diff --git a/chython/periodictable/groupX.py b/chython/periodictable/groupX.py index 80a499a4..0ca6aa05 100644 --- a/chython/periodictable/groupX.py +++ b/chython/periodictable/groupX.py @@ -18,9 +18,9 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from .element import Element -from .groups import GroupX -from .periods import PeriodIV, PeriodV, PeriodVI, PeriodVII +from .base import Element +from .base.groups import GroupX +from .base.periods import PeriodIV, PeriodV, PeriodVI, PeriodVII class Ni(Element, PeriodIV, GroupX): diff --git a/chython/periodictable/groupXI.py b/chython/periodictable/groupXI.py index 40bc7c91..96be94af 100644 --- a/chython/periodictable/groupXI.py +++ b/chython/periodictable/groupXI.py @@ -18,9 +18,9 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from .element import Element -from .groups import GroupXI -from .periods import PeriodIV, PeriodV, PeriodVI, PeriodVII +from .base import Element +from .base.groups import GroupXI +from .base.periods import PeriodIV, PeriodV, PeriodVI, PeriodVII class Cu(Element, PeriodIV, GroupXI): diff --git a/chython/periodictable/groupXII.py b/chython/periodictable/groupXII.py index 7b48dfad..17a3e8cf 100644 --- a/chython/periodictable/groupXII.py +++ b/chython/periodictable/groupXII.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2019-2023 Ramil Nugmanov +# Copyright 2019-2024 Ramil Nugmanov # Copyright 2019 Dayana Bashirova # This file is part of chython. # @@ -17,9 +17,9 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from .element import Element -from .groups import GroupXII -from .periods import PeriodIV, PeriodV, PeriodVI, PeriodVII +from .base import Element +from .base.groups import GroupXII +from .base.periods import PeriodIV, PeriodV, PeriodVI, PeriodVII class Zn(Element, PeriodIV, GroupXII): diff --git a/chython/periodictable/groupXIII.py b/chython/periodictable/groupXIII.py index dd5d728c..c0d3f507 100644 --- a/chython/periodictable/groupXIII.py +++ b/chython/periodictable/groupXIII.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2019-2023 Ramil Nugmanov +# Copyright 2019-2024 Ramil Nugmanov # Copyright 2019 Tagir Akhmetshin # Copyright 2019 Tansu Nasyrova # This file is part of chython. @@ -18,9 +18,9 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from .element import Element -from .groups import GroupXIII -from .periods import PeriodII, PeriodIII, PeriodIV, PeriodV, PeriodVI, PeriodVII +from .base import Element +from .base.groups import GroupXIII +from .base.periods import PeriodII, PeriodIII, PeriodIV, PeriodV, PeriodVI, PeriodVII class B(Element, PeriodII, GroupXIII): diff --git a/chython/periodictable/groupXIV.py b/chython/periodictable/groupXIV.py index ae2be925..0a18f705 100644 --- a/chython/periodictable/groupXIV.py +++ b/chython/periodictable/groupXIV.py @@ -18,9 +18,9 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from .element import Element -from .groups import GroupXIV -from .periods import PeriodII, PeriodIII, PeriodIV, PeriodV, PeriodVI, PeriodVII +from .base import Element +from .base.groups import GroupXIV +from .base.periods import PeriodII, PeriodIII, PeriodIV, PeriodV, PeriodVI, PeriodVII class C(Element, PeriodII, GroupXIV): diff --git a/chython/periodictable/groupXV.py b/chython/periodictable/groupXV.py index 52f9b545..218aeecc 100644 --- a/chython/periodictable/groupXV.py +++ b/chython/periodictable/groupXV.py @@ -18,9 +18,9 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from .element import Element -from .groups import GroupXV -from .periods import PeriodII, PeriodIII, PeriodIV, PeriodV, PeriodVI, PeriodVII +from .base import Element +from .base.groups import GroupXV +from .base.periods import PeriodII, PeriodIII, PeriodIV, PeriodV, PeriodVI, PeriodVII class N(Element, PeriodII, GroupXV): diff --git a/chython/periodictable/groupXVI.py b/chython/periodictable/groupXVI.py index fd060971..4791eb2a 100644 --- a/chython/periodictable/groupXVI.py +++ b/chython/periodictable/groupXVI.py @@ -19,9 +19,9 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from .element import Element -from .groups import GroupXVI -from .periods import PeriodII, PeriodIII, PeriodIV, PeriodV, PeriodVI, PeriodVII +from .base import Element +from .base.groups import GroupXVI +from .base.periods import PeriodII, PeriodIII, PeriodIV, PeriodV, PeriodVI, PeriodVII class O(Element, PeriodII, GroupXVI): diff --git a/chython/periodictable/groupXVII.py b/chython/periodictable/groupXVII.py index 064722c2..da6ce4c0 100644 --- a/chython/periodictable/groupXVII.py +++ b/chython/periodictable/groupXVII.py @@ -18,9 +18,9 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from .element import Element -from .groups import GroupXVII -from .periods import PeriodII, PeriodIII, PeriodIV, PeriodV, PeriodVI, PeriodVII +from .base import Element +from .base.groups import GroupXVII +from .base.periods import PeriodII, PeriodIII, PeriodIV, PeriodV, PeriodVI, PeriodVII class F(Element, PeriodII, GroupXVII): diff --git a/chython/periodictable/groupXVIII.py b/chython/periodictable/groupXVIII.py index 692fd9b4..849a893c 100644 --- a/chython/periodictable/groupXVIII.py +++ b/chython/periodictable/groupXVIII.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2019-2021 Ramil Nugmanov +# Copyright 2019-2024 Ramil Nugmanov # Copyright 2019 Tagir Akhmetshin # This file is part of chython. # @@ -17,9 +17,9 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from .element import Element -from .groups import GroupXVIII -from .periods import * +from .base import Element +from .base.groups import GroupXVIII +from .base.periods import * class He(Element, PeriodI, GroupXVIII): From 688a27a285b0f1ef70b2cfaed8797c86c0cadbc5 Mon Sep 17 00:00:00 2001 From: reginaib Date: Thu, 31 Oct 2024 16:29:13 +0100 Subject: [PATCH 02/67] saved --- chython/containers/graph.py | 89 +++--------- chython/containers/molecule.py | 194 ++++++++++--------------- chython/containers/query.py | 134 +---------------- chython/periodictable/base/__init__.py | 2 +- chython/periodictable/base/element.py | 73 ++++++---- chython/periodictable/base/query.py | 27 ++-- 6 files changed, 160 insertions(+), 359 deletions(-) diff --git a/chython/containers/graph.py b/chython/containers/graph.py index 4d9ad441..17f7a175 100644 --- a/chython/containers/graph.py +++ b/chython/containers/graph.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2023 Ramil Nugmanov +# Copyright 2018-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -29,25 +29,16 @@ class Graph(Generic[Atom, Bond], Morgan, Rings, ABC): - __slots__ = ('_atoms', '_bonds', '_charges', '_radicals', '_atoms_stereo', '_cis_trans_stereo', '_allenes_stereo', - '__dict__', '__weakref__') + __slots__ = ('_atoms', '_bonds', '_cis_trans_stereo', '__dict__', '__weakref__') __class_cache__ = {} _atoms: Dict[int, Atom] _bonds: Dict[int, Dict[int, Bond]] - _charges: Dict[int, int] - _radicals: Dict[int, bool] - _atoms_stereo: Dict[int, bool] - _allenes_stereo: Dict[int, bool] _cis_trans_stereo: Dict[Tuple[int, int], bool] def __init__(self): self._atoms = {} self._bonds = {} - self._charges = {} - self._radicals = {} - self._atoms_stereo = {} - self._allenes_stereo = {} self._cis_trans_stereo = {} def atom(self, n: int) -> Atom: @@ -99,7 +90,7 @@ def bonds_count(self) -> int: return sum(len(x) for x in self._bonds.values()) // 2 @abstractmethod - def add_atom(self, atom: Atom, n: Optional[int] = None, *, charge: int = 0, is_radical: bool = False) -> int: + def add_atom(self, atom: Atom, n: Optional[int] = None) -> int: """ new atom addition """ @@ -109,19 +100,10 @@ def add_atom(self, atom: Atom, n: Optional[int] = None, *, charge: int = 0, is_r raise TypeError('mapping should be integer') elif n in self._atoms: raise MappingError('atom with same number exists') - elif not isinstance(is_radical, bool): - raise TypeError('bool expected') - elif not isinstance(charge, int): - raise TypeError('formal charge should be int in range [-4, 4]') - elif charge > 4 or charge < -4: - raise ValueError('formal charge should be in range [-4, 4]') - - atom._attach_graph(self, n) + self._atoms[n] = atom - self._charges[n] = charge - self._radicals[n] = is_radical self._bonds[n] = {} - self.__dict__.clear() + self.flush_cache() return n @abstractmethod @@ -137,7 +119,7 @@ def add_bond(self, n: int, m: int, bond: Bond): raise MappingError('atoms already bonded') self._bonds[n][m] = self._bonds[m][n] = bond - self.__dict__.clear() + self.flush_cache() @abstractmethod def copy(self): @@ -145,14 +127,16 @@ def copy(self): copy of graph """ copy = object.__new__(self.__class__) - copy._charges = self._charges.copy() - copy._radicals = self._radicals.copy() - - copy._atoms = ca = {} - for n, atom in self._atoms.items(): - atom = atom.copy() - ca[n] = atom - atom._attach_graph(copy, n) + copy._atoms = {n: atom.copy(full=True) for n, atom in self._atoms.items()} + + copy._bonds = cb = {} + for n, m_bond in self._bonds.items(): + cb[n] = cbn = {} + for m, bond in m_bond.items(): + if m in cb: # bond partially exists. need back-connection. + cbn[m] = cb[m][n] + else: + cbn[m] = bond.copy() return copy @abstractmethod @@ -168,56 +152,19 @@ def remap(self, mapping: Dict[int, int], *, copy=False): raise ValueError('mapping overlap') mg = mapping.get - sc = self._charges - sr = self._radicals - if copy: h = self.__class__() - ha = h._atoms - hc = h._charges - hr = h._radicals - has = h._atoms_stereo - hal = h._allenes_stereo + h._atoms = {mg(n, n): atom.copy(full=True) for n, atom in self._atoms.items()} hcs = h._cis_trans_stereo - - for n, atom in self._atoms.items(): - m = mg(n, n) - atom = atom.copy() - ha[m] = atom - atom._attach_graph(h, m) else: - ha = {} - hc = {} - hr = {} - has = {} - hal = {} + self._atoms = {mg(n, n): atom for n, atom in self._atoms.items()} hcs = {} - for n, atom in self._atoms.items(): - m = mg(n, n) - ha[m] = atom - atom._change_map(m) # change mapping number - - for n in self._atoms: - m = mg(n, n) - hc[m] = sc[n] - hr[m] = sr[n] - - for n, stereo in self._atoms_stereo.items(): - has[mg(n, n)] = stereo - for n, stereo in self._allenes_stereo.items(): - hal[mg(n, n)] = stereo for (n, m), stereo in self._cis_trans_stereo.items(): hcs[(mg(n, n), mg(m, m))] = stereo if copy: return h # noqa - - self._atoms = ha - self._charges = hc - self._radicals = hr - self._atoms_stereo = has - self._allenes_stereo = hal self._cis_trans_stereo = hcs self.flush_cache() return self diff --git a/chython/containers/molecule.py b/chython/containers/molecule.py index 56d6987b..2c67fed2 100644 --- a/chython/containers/molecule.py +++ b/chython/containers/molecule.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2017-2023 Ramil Nugmanov +# Copyright 2017-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -19,7 +19,6 @@ from CachedMethods import cached_args_method from collections import Counter, defaultdict from functools import cached_property -from numpy import uint, zeros from typing import Dict, Iterable, List, Optional, Tuple, Union from weakref import ref from zlib import compress, decompress @@ -45,37 +44,29 @@ class MoleculeContainer(MoleculeStereo, Graph[Element, Bond], MoleculeIsomorphism, Aromatize, StandardizeMolecule, MoleculeSmiles, DepictMolecule, Calculate2DMolecule, Fingerprints, Tautomers, MCS, X3domMolecule): - __slots__ = ('_plane', '_conformers', '_hydrogens', '_parsed_mapping', '_backup', '__meta', '__name') - - _conformers: List[Dict[int, Tuple[float, float, float]]] - _hydrogens: Dict[int, Optional[int]] - _parsed_mapping: Dict[int, int] - _plane: Dict[int, Tuple[float, float]] + __slots__ = ('_backup', '_meta', '_name', '_changed') def __init__(self): super().__init__() - self._conformers = [] - self._hydrogens = {} - self._parsed_mapping = {} - self._plane = {} - self.__meta = None - self.__name = None + self._meta = None + self._name = None + self._changed = None @property def meta(self) -> Dict: - if self.__meta is None: - self.__meta = {} # lazy - return self.__meta + if self._meta is None: + self._meta = {} # lazy + return self._meta @property def name(self) -> str: - return self.__name or '' + return self._name or '' @name.setter def name(self, name): if not isinstance(name, str): - raise TypeError('name should be string up to 80 symbols') - self.__name = name + raise TypeError('name should be a string preferably up to 80 symbols') + self._name = name def environment(self, atom: int, include_bond: bool = True, include_atom: bool = True) -> \ Tuple[Union[Tuple[int, Bond, Element], @@ -101,10 +92,9 @@ def environment(self, atom: int, include_bond: bool = True, include_atom: bool = return tuple(self._bonds[atom].items()) return tuple(self._bonds[atom]) - @cached_args_method def neighbors(self, n: int) -> int: """number of neighbors atoms excluding any-bonded""" - return sum(b.order != 8 for b in self._bonds[n].values()) + return self._atoms[n].neighbors @cached_args_method def hybridization(self, n: int) -> int: @@ -135,8 +125,7 @@ def heteroatoms(self, n: int) -> int: """ Number of neighbored heteroatoms (not carbon or hydrogen) except any-bond connected. """ - atoms = self._atoms - return sum(atoms[m].atomic_number not in (1, 6) for m, b in self._bonds[n].items() if b.order != 8) + return self._atoms[n].heteroatoms def implicit_hydrogens(self, n: int) -> Optional[int]: """ @@ -144,26 +133,23 @@ def implicit_hydrogens(self, n: int) -> Optional[int]: Returns None if count are ambiguous. """ - return self._hydrogens[n] + return self._atoms[n].implicit_hydrogens - @cached_args_method def explicit_hydrogens(self, n: int) -> int: """ Number of explicit hydrogen atoms connected to atom. Take into account any type of bonds with hydrogen atoms. """ - atoms = self._atoms - return sum(atoms[m].atomic_number == 1 for m in self._bonds[n]) + return self._atoms[n].explicit_hydrogens - @cached_args_method def total_hydrogens(self, n: int) -> int: """ Number of hydrogen atoms connected to atom. Take into account any type of bonds with hydrogen atoms. """ - return self._hydrogens[n] + self.explicit_hydrogens(n) + return self._atoms[n].total_hydrogens @cached_args_method def adjacency_matrix(self, set_bonds=False, /): @@ -172,6 +158,8 @@ def adjacency_matrix(self, set_bonds=False, /): :param set_bonds: if True set bond orders instead of 1. """ + from numpy import uint, zeros + adj = zeros((len(self), len(self)), dtype=uint) mapping = {n: x for x, n in enumerate(self._atoms)} if set_bonds: @@ -191,24 +179,25 @@ def molecular_charge(self) -> int: """ Total charge of molecule """ - return sum(self._charges.values()) + return sum(a.charge for a in self._atoms.values()) @cached_property def is_radical(self) -> bool: """ True if at least one atom is radical """ - return any(self._radicals.values()) + return any(a.is_radical for a in self._atoms.values()) @cached_property def molecular_mass(self) -> float: - return sum(x.atomic_mass for x in self._atoms.values()) + sum(self._hydrogens.values()) * H().atomic_mass + h = H().atomic_mass + return sum(a.atomic_mass + a.implicit_hydrogens * h for a in self._atoms.values()) @cached_property def brutto(self) -> Dict[str, int]: """Counted atoms dict""" - c = Counter(x.atomic_symbol for x in self._atoms.values()) - c['H'] += sum(self._hydrogens.values()) + c = Counter(a.atomic_symbol for a in self._atoms.values()) + c['H'] += sum(a.implicit_hydrogens for a in self._atoms.values()) return dict(c) @cached_property @@ -220,8 +209,7 @@ def aromatic_rings(self) -> Tuple[Tuple[int, ...], ...]: return tuple(ring for ring in self.sssr if bonds[ring[0]][ring[-1]] == 4 and all(bonds[n][m] == 4 for n, m in zip(ring, ring[1:]))) - def add_atom(self, atom: Union[Element, int, str], *args, charge=0, is_radical=False, - xy: Tuple[float, float] = (0., 0.), _skip_hydrogen_calculation=False, **kwargs): + def add_atom(self, atom: Union[Element, int, str], *args, _skip_calculation=False, **kwargs): """ Add new atom. """ @@ -232,27 +220,17 @@ def add_atom(self, atom: Union[Element, int, str], *args, charge=0, is_radical=F atom = Element.from_atomic_number(atom)() else: raise TypeError('Element object expected') - if not isinstance(xy, tuple) or len(xy) != 2 or not isinstance(xy[0], float) or not isinstance(xy[1], float): - raise TypeError('XY should be tuple with 2 float') - - n = super().add_atom(atom, *args, charge=charge, is_radical=is_radical, **kwargs) - self._plane[n] = xy - self._conformers.clear() # clean conformers. need full recalculation for new system - if _skip_hydrogen_calculation: - self._hydrogens[n] = None - elif atom.atomic_number != 1: - try: - rules = atom.valence_rules(charge, is_radical, 0) - except ValenceError: - self._hydrogens[n] = None - else: - self._hydrogens[n] = rules[0][2] # first rule without neighbors + n = super().add_atom(atom, *args, **kwargs) + if self._changed is None: + self._changed = [n] else: - self._hydrogens[n] = 0 + self._changed.append(n) + if not _skip_calculation: + self.fix_labels() return n - def add_bond(self, n, m, bond: Union[Bond, int], *, _skip_hydrogen_calculation=False): + def add_bond(self, n, m, bond: Union[Bond, int], *, _skip_calculation=False): """ Connect atoms with bonds. @@ -263,21 +241,18 @@ def add_bond(self, n, m, bond: Union[Bond, int], *, _skip_hydrogen_calculation=F if not isinstance(bond, Bond): bond = Bond(bond) - bond._attach_graph(self, n, m) super().add_bond(n, m, bond) - self._conformers.clear() # clean conformers. need full recalculation for new system - - if _skip_hydrogen_calculation: # skip stereo fixing too - return - - self._calc_implicit(n) - self._calc_implicit(m) - - if self._atoms[n].atomic_number != 1 and self._atoms[m].atomic_number != 1: # not hydrogen - # fix stereo if formed not to hydrogen bond - self.fix_stereo() + if bond.order == 8: + return # any bond doesn't change anything + if self._changed is None: + self._changed = [n, n] + else: + self._changed.append(n) + self._changed.append(m) + if not _skip_calculation: + self.fix_labels() - def delete_atom(self, n: int, *, _skip_hydrogen_calculation=False): + def delete_atom(self, n: int, *, _skip_calculation=False): """ Remove atom. @@ -285,25 +260,25 @@ def delete_atom(self, n: int, *, _skip_hydrogen_calculation=False): Implicit hydrogens marks will not be set if atoms in aromatic rings. Call `kekule()` and `thiele()` in sequence to fix marks. """ + atoms = self._atoms ngb = self._bonds.pop(n) - fix = self._atoms.pop(n).atomic_number != 1 and ngb and not _skip_hydrogen_calculation - - del self._charges[n] - del self._radicals[n] - del self._hydrogens[n] - del self._plane[n] + atom_n = atoms.pop(n) - for m in ngb: + for m, bond in self._bonds.pop(n).items(): del self._bonds[m][n] - if not _skip_hydrogen_calculation: + if bond.order == 8: + continue + if self._changed is None: + self._changed = [m] + else: + self._changed.append(m) + atom_m = atoms[m] + atom_m._neighbors -= 1 + if atom_n.atomic_number not in (1, 6): + atom_m._heteroatoms -= 1 + if not _skip_calculation: self._calc_implicit(m) - self._conformers.clear() # clean conformers. need full recalculation for new system - try: - del self._parsed_mapping[n] - except KeyError: - pass - if fix: # hydrogen atom not used for stereo coding self.fix_stereo() self.flush_cache() @@ -396,28 +371,13 @@ def remap(self, mapping: Dict[int, int], *, copy: bool = False) -> 'MoleculeCont def copy(self) -> 'MoleculeContainer': copy = super().copy() - - copy._bonds = cb = {} - for n, m_bond in self._bonds.items(): - cb[n] = cbn = {} - for m, bond in m_bond.items(): - if m in cb: # bond partially exists. need back-connection. - cbn[m] = cb[m][n] - else: - cbn[m] = bond = bond.copy() - bond._attach_graph(copy, n, m) - - copy._MoleculeContainer__name = self.__name - if self.__meta is None: - copy._MoleculeContainer__meta = None + copy._name = self._name + if self._meta is None: + copy._meta = None else: - copy._MoleculeContainer__meta = self.__meta.copy() - copy._plane = self._plane.copy() - copy._hydrogens = self._hydrogens.copy() + copy._meta = self._meta.copy() copy._parsed_mapping = self._parsed_mapping.copy() copy._conformers = [c.copy() for c in self._conformers] - copy._atoms_stereo = self._atoms_stereo.copy() - copy._allenes_stereo = self._allenes_stereo.copy() copy._cis_trans_stereo = self._cis_trans_stereo.copy() return copy @@ -951,7 +911,7 @@ def _cpack(self, order=None, check=True): def _augmented_substructure(self, atoms: Iterable[int], deep: int): atoms = set(atoms) bonds = self._bonds - if atoms - self._atoms.keys(): + if atoms - bonds.keys(): raise ValueError('invalid atom numbers') nodes = [atoms] for _ in range(deep): @@ -967,22 +927,20 @@ def _calc_implicit(self, n: int): """ atoms = self._atoms atom = atoms[n] - if (an := atom.atomic_number) == 1: # hydrogen nether has implicit H - self._hydrogens[n] = 0 + if atom.atomic_number == 1: # hydrogen nether has implicit H + atom._implicit_hydrogens = 0 return - charge: int = self._charges[n] - is_radical = self._radicals[n] explicit_sum = 0 explicit_dict = defaultdict(int) aroma = 0 for m, bond in self._bonds[n].items(): order = bond.order if order == 4: # only neutral carbon aromatic rings supported - if not charge and not is_radical and an == 6: + if not atom.charge and not atom.is_radical and atom.atomic_number == 6: aroma += 1 else: # use `kekule()` to calculate proper implicit hydrogens count - self._hydrogens[n] = None + atom._implicit_hydrogens = None return elif order != 8: # any bond used for complexes explicit_sum += order @@ -990,32 +948,32 @@ def _calc_implicit(self, n: int): if aroma == 2: if explicit_sum == 0: # H-Ar - self._hydrogens[n] = 1 + atom._implicit_hydrogens = 1 elif explicit_sum == 1: # R-Ar - self._hydrogens[n] = 0 + atom._implicit_hydrogens = 0 else: # invalid aromaticity - self._hydrogens[n] = None + atom._implicit_hydrogens = None return elif aroma == 3: # condensed rings if explicit_sum: # invalid aromaticity - self._hydrogens[n] = None + atom._implicit_hydrogens = None else: - self._hydrogens[n] = 0 + atom._implicit_hydrogens = 0 return elif aroma: - self._hydrogens[n] = None + atom._implicit_hydrogens = None return try: - rules = atom.valence_rules(charge, is_radical, explicit_sum) + rules = atom.valence_rules(explicit_sum) except ValenceError: - self._hydrogens[n] = None + atom._implicit_hydrogens = None return for s, d, h in rules: if s.issubset(explicit_dict) and all(explicit_dict[k] >= c for k, c in d.items()): - self._hydrogens[n] = h + atom._implicit_hydrogens = h return - self._hydrogens[n] = None # rule not found + atom._implicit_hydrogens = None # rule not found def _check_implicit(self, n: int, h: int) -> bool: atoms = self._atoms @@ -1035,7 +993,7 @@ def _check_implicit(self, n: int, h: int) -> bool: explicit_dict[(order, atoms[m].atomic_number)] += 1 try: - rules = atom.valence_rules(self._charges[n], self._radicals[n], explicit_sum) + rules = atom.valence_rules(explicit_sum) except ValenceError: return False for s, d, _h in rules: diff --git a/chython/containers/query.py b/chython/containers/query.py index abe4dcaf..5024e915 100644 --- a/chython/containers/query.py +++ b/chython/containers/query.py @@ -24,157 +24,35 @@ from ..algorithms.smiles import QuerySmiles from ..algorithms.stereo import Stereo from ..periodictable import Element, ListElement, QueryElement -from ..periodictable.element import Query - - -def _validate_neighbors(neighbors): - if neighbors is None: - neighbors = () - elif isinstance(neighbors, int): - if neighbors < 0 or neighbors > 14: - raise ValueError('neighbors should be in range [0, 14]') - neighbors = (neighbors,) - elif isinstance(neighbors, (tuple, list)): - if not all(isinstance(n, int) for n in neighbors): - raise TypeError('neighbors should be list or tuple of ints') - if any(n < 0 or n > 14 for n in neighbors): - raise ValueError('neighbors should be in range [0, 14]') - if len(set(neighbors)) != len(neighbors): - raise ValueError('neighbors should be unique') - neighbors = tuple(sorted(neighbors)) - else: - raise TypeError('neighbors should be int or list or tuple of ints') - return neighbors +from ..periodictable.base import Query class QueryContainer(Stereo, Graph[Query, QueryBond], QueryIsomorphism, QuerySmiles): - __slots__ = ('_neighbors', '_hybridizations', '_hydrogens', '_rings_sizes', '_heteroatoms', '_masked') - - _neighbors: Dict[int, Tuple[int, ...]] - _hybridizations: Dict[int, Tuple[int, ...]] - _hydrogens: Dict[int, Tuple[int, ...]] - _rings_sizes: Dict[int, Tuple[int, ...]] - _heteroatoms: Dict[int, Tuple[int, ...]] - _masked: Dict[int, bool] - - def __init__(self): - super().__init__() - self._neighbors = {} - self._hybridizations = {} - self._hydrogens = {} - self._rings_sizes = {} - self._heteroatoms = {} - self._masked = {} - - def add_atom(self, atom: Union[Query, Element, int, str], *args, - neighbors: Union[int, List[int], Tuple[int, ...], None] = None, - hybridization: Union[int, List[int], Tuple[int, ...], None] = None, - hydrogens: Union[int, List[int], Tuple[int, ...], None] = None, - rings_sizes: Union[int, List[int], Tuple[int, ...], None] = None, - heteroatoms: Union[int, List[int], Tuple[int, ...], None] = None, - masked: bool = False, **kwargs): - if hybridization is None: - hybridization = () - elif isinstance(hybridization, int): - if hybridization < 1 or hybridization > 4: - raise ValueError('hybridization should be in range [1, 4]') - hybridization = (hybridization,) - elif isinstance(hybridization, (tuple, list)): - if not all(isinstance(h, int) for h in hybridization): - raise TypeError('hybridizations should be list or tuple of ints') - if any(h < 1 or h > 4 for h in hybridization): - raise ValueError('hybridizations should be in range [1, 4]') - if len(set(hybridization)) != len(hybridization): - raise ValueError('hybridizations should be unique') - hybridization = tuple(sorted(hybridization)) - else: - raise TypeError('hybridization should be int or list or tuple of ints') - - if rings_sizes is None: - rings_sizes = () - elif isinstance(rings_sizes, int): - if rings_sizes < 3 and rings_sizes != 0: - raise ValueError('rings should be greater or equal 3. ring equal to zero is no ring atom mark') - rings_sizes = (rings_sizes,) - elif isinstance(rings_sizes, (tuple, list)): - if not all(isinstance(n, int) for n in rings_sizes): - raise TypeError('rings should be list or tuple of ints') - if any(n < 3 for n in rings_sizes): - raise ValueError('rings should be greater or equal 3') - if len(set(rings_sizes)) != len(rings_sizes): - raise ValueError('rings should be unique') - rings_sizes = tuple(sorted(rings_sizes)) - else: - raise TypeError('rings should be int or list or tuple of ints') - - neighbors = _validate_neighbors(neighbors) - hydrogens = _validate_neighbors(hydrogens) - heteroatoms = _validate_neighbors(heteroatoms) + __slots__ = () + def add_atom(self, atom: Union[Query, Element, int, str], *args, **kwargs): if not isinstance(atom, Query): + # set only basic labels: charge, radical, isotope. use Query object directly for the full control. if isinstance(atom, Element): - atom = QueryElement.from_atomic_number(atom.atomic_number)(atom.isotope) + atom = QueryElement.from_atom(atom) elif isinstance(atom, str): atom = QueryElement.from_symbol(atom)() elif isinstance(atom, int): atom = QueryElement.from_atomic_number(atom)() else: raise TypeError('QueryElement object expected') - - n = super().add_atom(atom, *args, **kwargs) - self._neighbors[n] = neighbors - self._hybridizations[n] = hybridization - self._hydrogens[n] = hydrogens - self._rings_sizes[n] = rings_sizes - self._heteroatoms[n] = heteroatoms - self._masked[n] = masked - return n + return super().add_atom(atom, *args, **kwargs) def add_bond(self, n, m, bond: Union[QueryBond, Bond, int, Tuple[int, ...]]): if isinstance(bond, Bond): bond = QueryBond.from_bond(bond) elif not isinstance(bond, QueryBond): bond = QueryBond(bond) - - sct = self._stereo_cis_trans_paths # save - sa = self._stereo_allenes_paths - super().add_bond(n, m, bond) - # remove stereo marks on bonded atoms and all its bonds - if n in self._atoms_stereo: - del self._atoms_stereo[n] - if m in self._atoms_stereo: - del self._atoms_stereo[m] - if self._cis_trans_stereo: - for nm, path in sct.items(): - if (n in path or m in path) and nm in self._cis_trans_stereo: - del self._cis_trans_stereo[nm] - if self._allenes_stereo: - for c, path in sa.items(): - if (n in path or m in path) and c in self._allenes_stereo: - del self._allenes_stereo[c] def copy(self) -> 'QueryContainer': copy = super().copy() - - copy._bonds = cb = {} - for n, m_bond in self._bonds.items(): - cb[n] = cbn = {} - for m, bond in m_bond.items(): - if m in cb: # bond partially exists. need back-connection. - cbn[m] = cb[m][n] - else: - cbn[m] = bond.copy() - - copy._neighbors = self._neighbors.copy() - copy._hybridizations = self._hybridizations.copy() - copy._hydrogens = self._hydrogens.copy() - copy._heteroatoms = self._heteroatoms.copy() - copy._rings_sizes = self._rings_sizes.copy() - copy._atoms_stereo = self._atoms_stereo.copy() - copy._allenes_stereo = self._allenes_stereo.copy() copy._cis_trans_stereo = self._cis_trans_stereo.copy() - copy._masked = self._masked.copy() return copy def union(self, other: 'QueryContainer', *, remap: bool = False, copy: bool = True) -> 'QueryContainer': diff --git a/chython/periodictable/base/__init__.py b/chython/periodictable/base/__init__.py index f63b3bb6..f8ca87e8 100644 --- a/chython/periodictable/base/__init__.py +++ b/chython/periodictable/base/__init__.py @@ -21,4 +21,4 @@ from .query import * -__all__ = ['Element', 'DynamicElement', 'QueryElement', 'AnyElement', 'AnyMetal', 'ListElement'] +__all__ = ['Element', 'DynamicElement', 'Query', 'QueryElement', 'AnyElement', 'AnyMetal', 'ListElement'] diff --git a/chython/periodictable/base/element.py b/chython/periodictable/base/element.py index c3703336..d1c1edd0 100644 --- a/chython/periodictable/base/element.py +++ b/chython/periodictable/base/element.py @@ -24,7 +24,9 @@ class Element(ABC): - __slots__ = ('_isotope', '_charge', '_is_radical', '_x', '_y', '_implicit_hydrogens') + __slots__ = ('_isotope', '_charge', '_is_radical', '_x', '_y', '_implicit_hydrogens', + '_explicit_hydrogens', '_stereo', '_parsed_mapping', '_xyz', + '_neighbors', '_heteroatoms', '_hybridization') __class_cache__ = {} def __init__(self, isotope: Optional[int] = None): @@ -43,6 +45,11 @@ def __init__(self, isotope: Optional[int] = None): self._is_radical = False self._x = self._y = 0 self._implicit_hydrogens = None + self._explicit_hydrogens = 0 + self._neighbors = 0 + self._heteroatoms = 0 + self._hybridization = 1 + self._stereo = None def __repr__(self): if self._isotope: @@ -183,45 +190,33 @@ def xy(self, value: Tuple[float, float]): def implicit_hydrogens(self) -> Optional[int]: return self._implicit_hydrogens - def copy(self): - copy = object.__new__(self.__class__) - copy._isotope = self.isotope - copy._charge = self.charge - copy._is_radical = self.is_radical - return copy - - def __copy__(self): - return self.copy() - @property def explicit_hydrogens(self) -> int: - try: - return self._graph().explicit_hydrogens(self._n) - except AttributeError: - raise IsNotConnectedAtom + return self._explicit_hydrogens @property def total_hydrogens(self) -> int: - if self._implicit_hydrogens is None: + if self.implicit_hydrogens is None: raise ValenceError - return self._implicit_hydrogens + self.explicit_hydrogens + return self.implicit_hydrogens + self.explicit_hydrogens + + @property + def stereo(self): + """ + Tetrahedron or allene stereo label + """ + return self._stereo @property def heteroatoms(self) -> int: - try: - return self._graph().heteroatoms(self._n) - except AttributeError: - raise IsNotConnectedAtom + return self._heteroatoms @property def neighbors(self) -> int: """ Neighbors count of atom """ - try: - return self._graph().neighbors(self._n) - except AttributeError: - raise IsNotConnectedAtom + return self._neighbors @property def hybridization(self): @@ -230,10 +225,26 @@ def hybridization(self): of single bonded, 3 - if has one triple bonded and any amount of double and single bonded neighbors or two double bonded and any amount of single bonded neighbors, 4 - if atom in aromatic ring. """ - try: - return self._graph().hybridization(self._n) - except AttributeError: - raise IsNotConnectedAtom + return self._hybridization + + def copy(self, full=False): + copy = object.__new__(self.__class__) + copy._isotope = self.isotope + copy._charge = self.charge + copy._is_radical = self.is_radical + if full: + copy._x = self.x + copy._y = self.y + copy._implicit_hydrogens = self.implicit_hydrogens + copy._explicit_hydrogens = self.explicit_hydrogens + copy._stereo = self.stereo + copy._neighbors = self.neighbors + copy._heteroatoms = self.heteroatoms + copy._hybridization = self.hybridization + return copy + + def __copy__(self): + return self.copy() @property def ring_sizes(self) -> Tuple[int, ...]: @@ -302,13 +313,13 @@ def __eq__(self, other): def __hash__(self): return hash((self.isotope or 0, self.atomic_number, self.charge, self.is_radical, self.implicit_hydrogens or 0)) - def valence_rules(self, charge: int, is_radical: bool, valence: int) -> \ + def valence_rules(self, valence: int) -> \ List[Tuple[Set[Tuple[int, 'Element']], Dict[Tuple[int, 'Element'], int], int]]: """ valence rules for element with specific charge/radical state """ try: - return self._compiled_valence_rules[(charge, is_radical, valence)] + return self._compiled_valence_rules[(self.charge, self.is_radical, valence)] except KeyError: raise ValenceError diff --git a/chython/periodictable/base/query.py b/chython/periodictable/base/query.py index 5ae7adb5..2cc55367 100644 --- a/chython/periodictable/base/query.py +++ b/chython/periodictable/base/query.py @@ -47,12 +47,13 @@ def _validate(value, prop): class Query(ABC): - __slots__ = ('_neighbors', '_hybridization', '_masked') + __slots__ = ('_neighbors', '_hybridization', '_masked', '_stereo') def __init__(self): self._neighbors = () self._hybridization = () self._masked = False + self._stereo = None @property def neighbors(self) -> Tuple[int, ...]: @@ -95,11 +96,17 @@ def masked(self, value): raise TypeError('masked should be bool') self._masked = value - def copy(self): + @property + def stereo(self): + return self._stereo + + def copy(self, full=False): copy = object.__new__(self.__class__) copy._neighbors = self.neighbors copy._hybridization = self.hybridization - copy._masked = self.masked + if full: + copy._masked = self.masked + copy._stereo = self.stereo return copy def __copy__(self): @@ -190,8 +197,8 @@ def ring_sizes(self, value): else: raise TypeError('rings should be int or list or tuple of ints') - def copy(self): - copy = super().copy() + def copy(self, full=False): + copy = super().copy(full=full) copy._charge = self.charge copy._is_radical = self.is_radical copy._heteroatoms = self.heteroatoms @@ -296,8 +303,8 @@ def atomic_symbol(self) -> str: def atomic_numbers(self): return tuple(x.atomic_number.fget(None) for x in Element.__subclasses__() if x.__name__ in self._elements) - def copy(self): - copy = super().copy() + def copy(self, full=False): + copy = super().copy(full=full) copy._elements = self._elements return copy @@ -353,7 +360,7 @@ def __repr__(self): class QueryElement(ExtendedQuery, ABC): __slots__ = ('_isotope',) - def __init__(self, isotope: Optional[int]): + def __init__(self, isotope: Optional[int] = None): if isotope is not None and not isinstance(isotope, int): raise TypeError('isotope must be an int') super().__init__() @@ -420,8 +427,8 @@ def from_atom(cls, atom: Union['Element', 'Query']) -> 'Query': raise TypeError('Element or Query expected') return atom.copy() - def copy(self): - copy = super().copy() + def copy(self, full=False): + copy = super().copy(full=full) copy._isotope = self.isotope return copy From 9430396318951b9e56ac7cae0ed8acef7ad18a42 Mon Sep 17 00:00:00 2001 From: stsouko Date: Fri, 1 Nov 2024 11:45:20 +0100 Subject: [PATCH 03/67] Refactor and clean up molecule and bond handling Refactored molecule.py, bonds.py, graph.py, and query.py for improved clarity and efficiency. Removed unused methods and redundant code, consolidated bond handling logic, and replaced lists with sets for tracking changes. --- chython/containers/bonds.py | 162 ++++++++++++----------- chython/containers/graph.py | 73 ++--------- chython/containers/molecule.py | 178 ++++---------------------- chython/containers/query.py | 157 +---------------------- chython/periodictable/base/element.py | 50 ++++---- chython/periodictable/base/query.py | 6 +- pyproject.toml | 2 +- 7 files changed, 145 insertions(+), 483 deletions(-) diff --git a/chython/containers/bonds.py b/chython/containers/bonds.py index cb61af29..e6014c1e 100644 --- a/chython/containers/bonds.py +++ b/chython/containers/bonds.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2019-2022 Ramil Nugmanov +# Copyright 2019-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -17,94 +17,74 @@ # along with this program; if not, see . # from typing import Optional, Tuple, Union, List, Set -from weakref import ref -from ..exceptions import IsConnectedBond, IsNotConnectedBond class Bond: - __slots__ = ('__order', '__graph', '__n', '__m') + __slots__ = ('_order', '_in_ring', '_stereo') def __init__(self, order: int): if not isinstance(order, int): raise TypeError('invalid order value') elif order not in (1, 4, 2, 3, 8): raise ValueError('order should be from [1, 2, 3, 4, 8]') - self.__order = order + self._order = order + self._in_ring = False + self._stereo = None def __eq__(self, other): if isinstance(other, Bond): - return self.__order == other.order + return self.order == other.order elif isinstance(other, int): - return self.__order == other + return self.order == other return False def __repr__(self): - return f'{self.__class__.__name__}({self.__order})' + return f'{self.__class__.__name__}({self.order})' def __int__(self): """ Bond order. """ - return self.__order + return self.order def __hash__(self): """ Bond order. Used in Morgan atoms ordering. """ - return self.__order - - def __getstate__(self): - return {'order': self.__order} - - def __setstate__(self, state): - self.__order = state['order'] + return self.order @property def order(self) -> int: - return self.__order + return self._order + + @property + def stereo(self) -> Optional[bool]: + return self._stereo @property def in_ring(self) -> bool: - try: - return self.__graph().is_ring_bond(self.__n, self.__m) - except AttributeError: - raise IsNotConnectedBond + return self._in_ring - def copy(self) -> 'Bond': + def copy(self, full=False) -> 'Bond': copy = object.__new__(self.__class__) - copy._Bond__order = self.__order + copy._order = self.order + if full: + copy._stereo = self.stereo + copy._in_ring = self.in_ring return copy + def __copy__(self): + return self.copy() + @classmethod def from_bond(cls, bond): - if isinstance(bond, cls): - copy = object.__new__(cls) - copy._Bond__order = bond.order - return copy + if isinstance(bond, Bond): + return cls(bond.order) raise TypeError('Bond expected') - def _attach_graph(self, graph, n, m): - try: - self.__graph - except AttributeError: - self.__graph = ref(graph) - self.__n = n - self.__m = m - else: - raise IsConnectedBond - - def _change_map(self, n, m): - try: - self.__graph - except AttributeError: - raise IsNotConnectedBond - else: - self.__n = n - self.__m = m - class DynamicBond: - __slots__ = ('__order', '__p_order') + __slots__ = ('_order', '_p_order') def __init__(self, order=None, p_order=None): if order is None: @@ -118,16 +98,16 @@ def __init__(self, order=None, p_order=None): if order not in (1, 4, 2, 3, None, 8) or p_order not in (1, 4, 2, 3, None, 8): raise ValueError('order or p_order should be from [1, 2, 3, 4, 8]') - self.__order = order - self.__p_order = p_order + self._order = order + self._p_order = p_order def __eq__(self, other): if isinstance(other, DynamicBond): - return self.__order == other.order and self.__p_order == other.p_order + return self.order == other.order and self.p_order == other.p_order return False def __repr__(self): - return f'{self.__class__.__name__}({self.__order}, {self.__p_order})' + return f'{self.__class__.__name__}({self.order}, {self.p_order})' def __int__(self): """ @@ -139,47 +119,51 @@ def __hash__(self): """ Hash of bond orders. """ - return hash((self.__order or 0, self.__p_order or 0)) + return hash((self.order or 0, self.p_order or 0)) @property def is_dynamic(self) -> bool: """ Bond has dynamic features """ - return self.__order != self.__p_order + return self.order != self.p_order @property def order(self) -> Optional[int]: - return self.__order + return self._order @property def p_order(self) -> Optional[int]: - return self.__p_order + return self._p_order def copy(self) -> 'DynamicBond': copy = object.__new__(self.__class__) - copy._DynamicBond__order = self.__order - copy._DynamicBond__p_order = self.__p_order + copy._order = self.order + copy._p_order = self.p_order return copy + def __copy__(self): + return self.copy() + @classmethod def from_bond(cls, bond): if isinstance(bond, Bond): copy = object.__new__(cls) - copy._DynamicBond__order = copy._DynamicBond__p_order = bond.order + copy._order = copy._p_order = bond.order return copy elif isinstance(bond, cls): copy = object.__new__(cls) - copy._DynamicBond__order = bond.order - copy._DynamicBond__p_order = bond.p_order + copy._order = bond.order + copy._p_order = bond.p_order return copy raise TypeError('DynamicBond expected') class QueryBond: - __slots__ = ('__order', '__in_ring') + __slots__ = ('_order', '_in_ring', '_stereo') - def __init__(self, order: Union[int, List[int], Set[int], Tuple[int, ...]], in_ring: Optional[bool] = None): + def __init__(self, order: Union[int, List[int], Set[int], Tuple[int, ...]], + in_ring: Optional[bool] = None, stereo: Optional[bool] = None): if isinstance(order, (list, tuple, set)): if not all(isinstance(x, int) for x in order): raise TypeError('invalid order value') @@ -194,63 +178,75 @@ def __init__(self, order: Union[int, List[int], Set[int], Tuple[int, ...]], in_r raise TypeError('invalid order value') if in_ring is not None and not isinstance(in_ring, bool): raise TypeError('in_ring mark should be boolean or None') - self.__order = order - self.__in_ring = in_ring + if stereo is not None and not isinstance(stereo, bool): + raise TypeError('stereo mark should be boolean or None') + self._order = order + self._in_ring = in_ring + self._stereo = stereo def __eq__(self, other): if isinstance(other, Bond): - if self.__in_ring is not None: - if self.__in_ring != other.in_ring: + if self.in_ring is not None: + if self.in_ring != other.in_ring: return False - return other.order in self.__order + return other.order in self.order elif isinstance(other, QueryBond): - return self.__order == other.order and self.__in_ring == other.in_ring + return self.order == other.order and self.in_ring == other.in_ring elif isinstance(other, int): - return other in self.__order + return other in self.order return False def __repr__(self): - return f'{self.__class__.__name__}({self.__order}, {self.__in_ring})' + return f'{self.__class__.__name__}({self.order}, {self.in_ring})' def __int__(self): """ Simple bond order or hash of sorted tuple of orders. """ - if len(self.__order) == 1: - return self.__order[0] - return hash(self.__order) + if len(self.order) == 1: + return self.order[0] + return hash(self.order) def __hash__(self): """ Hash of orders and cycle mark. Used in Morgan atoms ordering. """ - return hash((self.__order, self.__in_ring)) + return hash((self.order, self.in_ring)) @property def order(self) -> Tuple[int, ...]: - return self.__order + return self._order @property def in_ring(self) -> Optional[bool]: - return self.__in_ring + return self._in_ring + + @property + def stereo(self): + return self._stereo - def copy(self) -> 'QueryBond': + def copy(self, full=False) -> 'QueryBond': copy = object.__new__(self.__class__) - copy._QueryBond__order = self.__order - copy._QueryBond__in_ring = self.__in_ring + copy._order = self.order + copy._in_ring = self.in_ring + if full: + copy._stereo = self.stereo return copy + def __copy__(self): + return self.copy() + @classmethod def from_bond(cls, bond): if isinstance(bond, Bond): copy = object.__new__(cls) - copy._QueryBond__order = (bond.order,) - copy._QueryBond__in_ring = None + copy._order = (bond.order,) + copy._in_ring = None return copy elif isinstance(bond, cls): copy = object.__new__(cls) - copy._QueryBond__order = bond.order - copy._QueryBond__in_ring = bond.in_ring + copy._order = bond.order + copy._in_ring = bond.in_ring return copy raise TypeError('QueryBond or Bond expected') diff --git a/chython/containers/graph.py b/chython/containers/graph.py index 17f7a175..54470b35 100644 --- a/chython/containers/graph.py +++ b/chython/containers/graph.py @@ -29,17 +29,15 @@ class Graph(Generic[Atom, Bond], Morgan, Rings, ABC): - __slots__ = ('_atoms', '_bonds', '_cis_trans_stereo', '__dict__', '__weakref__') + __slots__ = ('_atoms', '_bonds', '__dict__') __class_cache__ = {} _atoms: Dict[int, Atom] _bonds: Dict[int, Dict[int, Bond]] - _cis_trans_stereo: Dict[Tuple[int, int], bool] def __init__(self): self._atoms = {} self._bonds = {} - self._cis_trans_stereo = {} def atom(self, n: int) -> Atom: return self._atoms[n] @@ -121,14 +119,12 @@ def add_bond(self, n: int, m: int, bond: Bond): self._bonds[n][m] = self._bonds[m][n] = bond self.flush_cache() - @abstractmethod def copy(self): """ copy of graph """ copy = object.__new__(self.__class__) copy._atoms = {n: atom.copy(full=True) for n, atom in self._atoms.items()} - copy._bonds = cb = {} for n, m_bond in self._bonds.items(): cb[n] = cbn = {} @@ -139,63 +135,39 @@ def copy(self): cbn[m] = bond.copy() return copy - @abstractmethod - def remap(self, mapping: Dict[int, int], *, copy=False): + def remap(self, mapping: Dict[int, int]): """ Change atom numbers :param mapping: mapping of old numbers to the new - :param copy: keep original graph """ if len(mapping) != len(set(mapping.values())) or \ not (self._atoms.keys() - mapping.keys()).isdisjoint(mapping.values()): raise ValueError('mapping overlap') mg = mapping.get - if copy: - h = self.__class__() - h._atoms = {mg(n, n): atom.copy(full=True) for n, atom in self._atoms.items()} - hcs = h._cis_trans_stereo - else: - self._atoms = {mg(n, n): atom for n, atom in self._atoms.items()} - hcs = {} - - for (n, m), stereo in self._cis_trans_stereo.items(): - hcs[(mg(n, n), mg(m, m))] = stereo - - if copy: - return h # noqa - self._cis_trans_stereo = hcs + self._atoms = {mg(n, n): atom for n, atom in self._atoms.items()} + self._bonds = {mg(n, n): {mg(m, m): bond for m, bond in m_bond.items()} for n, m_bond in self._bonds.items()} self.flush_cache() - return self - @abstractmethod def union(self, other: 'Graph', *, remap: bool = False, copy: bool = True): """ Merge Graphs into one. :param remap: if atoms has collisions then remap other graph atoms else raise exception. - :param copy: keep original structure and return new object + :param copy: keep original structure and return a new object """ if self._atoms.keys() & other._atoms.keys(): - if remap: - other = other.remap({n: i for i, n in enumerate(other, start=max(self._atoms) + 1)}, copy=True) - else: + if not remap: raise MappingError('mapping of graphs is not disjoint') - + other = other.copy() + other.remap({n: i for i, n in enumerate(other, start=max(self._atoms) + 1)}) + else: + other = other.copy() # make a copy u = self.copy() if copy else self - u._charges.update(other._charges) - u._radicals.update(other._radicals) - - ua = u._atoms - for n, atom in other._atoms.items(): - ua[n] = atom = atom.copy() - atom._attach_graph(u, n) - - u._atoms_stereo.update(other._atoms_stereo) - u._allenes_stereo.update(other._allenes_stereo) - u._cis_trans_stereo.update(other._cis_trans_stereo) - return u, other + u._atoms.update(other._atoms) + u._bonds.update(other._bonds) + return u def flush_cache(self): self.__dict__.clear() @@ -224,24 +196,5 @@ def __iter__(self) -> Iterator[int]: def __bool__(self): return bool(self._atoms) - def __getstate__(self): - state = {'atoms': self._atoms, 'bonds': self._bonds, 'charges': self._charges, - 'radicals': self._radicals} - from chython import pickle_cache - - if pickle_cache: - state['cache'] = {k: v for k, v in self.__dict__.items() if k != '__cached_method___hash__'} - return state - - def __setstate__(self, state): - self._atoms = state['atoms'] - for n, a in state['atoms'].items(): - a._attach_graph(self, n) - self._charges = state['charges'] - self._radicals = state['radicals'] - self._bonds = state['bonds'] - if 'cache' in state: - self.__dict__.update(state['cache']) - __all__ = ['Graph'] diff --git a/chython/containers/molecule.py b/chython/containers/molecule.py index 2c67fed2..a4b5c8ef 100644 --- a/chython/containers/molecule.py +++ b/chython/containers/molecule.py @@ -105,20 +105,7 @@ def hybridization(self, n: int) -> int: of single bonded, 3 - if has one triple bonded and any amount of double and single bonded neighbors or two and more double bonded and any amount of single bonded neighbors, 4 - if atom in aromatic ring. """ - hybridization = 1 - for bond in self._bonds[n].values(): - order = bond.order - if order == 4: - return 4 - elif order == 3: - if hybridization != 3: - hybridization = 3 - elif order == 2: - if hybridization == 1: - hybridization = 2 - elif hybridization == 2: - hybridization = 3 - return hybridization + return self._atoms[n].hybridization @cached_args_method def heteroatoms(self, n: int) -> int: @@ -223,9 +210,9 @@ def add_atom(self, atom: Union[Element, int, str], *args, _skip_calculation=Fals n = super().add_atom(atom, *args, **kwargs) if self._changed is None: - self._changed = [n] + self._changed = {n} else: - self._changed.append(n) + self._changed.add(n) if not _skip_calculation: self.fix_labels() return n @@ -245,10 +232,10 @@ def add_bond(self, n, m, bond: Union[Bond, int], *, _skip_calculation=False): if bond.order == 8: return # any bond doesn't change anything if self._changed is None: - self._changed = [n, n] + self._changed = {n, m} else: - self._changed.append(n) - self._changed.append(m) + self._changed.add(n) + self._changed.add(m) if not _skip_calculation: self.fix_labels() @@ -260,30 +247,19 @@ def delete_atom(self, n: int, *, _skip_calculation=False): Implicit hydrogens marks will not be set if atoms in aromatic rings. Call `kekule()` and `thiele()` in sequence to fix marks. """ - atoms = self._atoms - ngb = self._bonds.pop(n) - atom_n = atoms.pop(n) - + del self._atoms[n] for m, bond in self._bonds.pop(n).items(): del self._bonds[m][n] if bond.order == 8: continue if self._changed is None: - self._changed = [m] + self._changed = {m} else: - self._changed.append(m) - atom_m = atoms[m] - atom_m._neighbors -= 1 - if atom_n.atomic_number not in (1, 6): - atom_m._heteroatoms -= 1 - if not _skip_calculation: - self._calc_implicit(m) - - if fix: # hydrogen atom not used for stereo coding - self.fix_stereo() - self.flush_cache() - - def delete_bond(self, n: int, m: int, *, _skip_hydrogen_calculation=False): + self._changed.add(m) + if not _skip_calculation: + self.fix_labels() + + def delete_bond(self, n: int, m: int, *, _skip_calculation=False): """ Disconnect atoms. @@ -292,82 +268,14 @@ def delete_bond(self, n: int, m: int, *, _skip_hydrogen_calculation=False): Call `kekule()` and `thiele()` in sequence to fix marks. """ del self._bonds[n][m] - del self._bonds[m][n] - self._conformers.clear() # clean conformers. need full recalculation for new system - - if not _skip_hydrogen_calculation: - self._calc_implicit(n) - self._calc_implicit(m) - - if self._atoms[n].atomic_number != 1 and self._atoms[m].atomic_number != 1 and not _skip_hydrogen_calculation: - self.fix_stereo() - self.flush_cache() - - def remap(self, mapping: Dict[int, int], *, copy: bool = False) -> 'MoleculeContainer': - atoms = self._atoms # keep original atoms dict - h = super().remap(mapping, copy=copy) - - mg = mapping.get - sp = self._plane - shg = self._hydrogens - - if copy: - h._MoleculeContainer__name = self.__name - if self.__meta is not None: - h._MoleculeContainer__meta = self.__meta.copy() - hb = h._bonds - hp = h._plane - hhg = h._hydrogens - hcf = h._conformers - hm = h._parsed_mapping - - # deep copy of bonds - for n, m_bond in self._bonds.items(): - n = mg(n, n) - hb[n] = hbn = {} - for m, bond in m_bond.items(): - m = mg(m, m) - if m in hb: # bond partially exists. need back-connection. - hbn[m] = hb[m][n] - else: - hbn[m] = bond = bond.copy() - bond._attach_graph(h, n, m) - else: - hb = {} - hp = {} - hhg = {} - hcf = [] - hm = {} - - for n, m_bond in self._bonds.items(): - n = mg(n, n) - hb[n] = hbn = {} - for m, bond in m_bond.items(): - m = mg(m, m) - if m in hb: # bond partially exists. need back-connection. - hbn[m] = hb[m][n] - else: - hbn[m] = bond - bond._change_map(n, m) - - for n in atoms: - m = mg(n, n) - hp[m] = sp[n] - hhg[m] = shg[n] - - hcf.extend({mg(n, n): x for n, x in c.items()} for c in self._conformers) - for n, m in self._parsed_mapping.items(): - hm[mg(n, n)] = m - - if copy: - return h - - self._bonds = hb - self._plane = hp - self._hydrogens = hhg - self._conformers = hcf - self._parsed_mapping = hm - return self + if self._bonds[m].pop(n).order != 8: + if self._changed is None: + self._changed = {n, m} + else: + self._changed.add(n) + self._changed.add(m) + if not _skip_calculation: + self.fix_labels() def copy(self) -> 'MoleculeContainer': copy = super().copy() @@ -376,32 +284,12 @@ def copy(self) -> 'MoleculeContainer': copy._meta = None else: copy._meta = self._meta.copy() - copy._parsed_mapping = self._parsed_mapping.copy() - copy._conformers = [c.copy() for c in self._conformers] - copy._cis_trans_stereo = self._cis_trans_stereo.copy() return copy def union(self, other: 'MoleculeContainer', *, remap: bool = False, copy: bool = True) -> 'MoleculeContainer': if not isinstance(other, MoleculeContainer): raise TypeError('MoleculeContainer expected') - u, o = super().union(other, remap=remap, copy=copy) - - ub = u._bonds - for n, m_bond in o._bonds.items(): - ub[n] = ubn = {} - for m, bond in m_bond.items(): - if m in ub: # bond partially exists. need back-connection. - ubn[m] = ub[m][n] - else: - ubn[m] = bond = bond.copy() - bond._attach_graph(u, n, m) - - u._MoleculeContainer__name = u._MoleculeContainer__meta = None - u._conformers.clear() - u._plane.update(o._plane) - u._hydrogens.update(o._hydrogens) - u._parsed_mapping.update(o._parsed_mapping) - return u + return super().union(other, remap=remap, copy=copy) def substructure(self, atoms: Iterable[int], *, as_query: bool = False, recalculate_hydrogens=True, skip_neighbors_marks=False, skip_hybridizations_marks=False, skip_hydrogens_marks=False, @@ -1078,27 +966,5 @@ def __exit__(self, exc_type, exc_val, exc_tb): self.flush_cache() del self._backup - def __getstate__(self): - return {'conformers': self._conformers, 'hydrogens': self._hydrogens, 'atoms_stereo': self._atoms_stereo, - 'allenes_stereo': self._allenes_stereo, 'cis_trans_stereo': self._cis_trans_stereo, - 'parsed_mapping': self._parsed_mapping, 'meta': self.__meta, 'name': self.__name, - 'plane': self._plane, **super().__getstate__()} - - def __setstate__(self, state): - super().__setstate__(state) - self._conformers = state['conformers'] - self._atoms_stereo = state['atoms_stereo'] - self._allenes_stereo = state['allenes_stereo'] - self._cis_trans_stereo = state['cis_trans_stereo'] - self._hydrogens = state['hydrogens'] - self._parsed_mapping = state['parsed_mapping'] - self._plane = state['plane'] - self.__meta = state['meta'] - self.__name = state['name'] - - # attach bonds to graph - for n, m, b in self.bonds(): - b._attach_graph(self, n, m) - __all__ = ['MoleculeContainer'] diff --git a/chython/containers/query.py b/chython/containers/query.py index 5024e915..7a218786 100644 --- a/chython/containers/query.py +++ b/chython/containers/query.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2023 Ramil Nugmanov +# Copyright 2018-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -16,14 +16,13 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from itertools import chain, product -from typing import Dict, List, Tuple, Union +from typing import Tuple, Union from .bonds import Bond, QueryBond from .graph import Graph from ..algorithms.isomorphism import QueryIsomorphism from ..algorithms.smiles import QuerySmiles from ..algorithms.stereo import Stereo -from ..periodictable import Element, ListElement, QueryElement +from ..periodictable import Element, QueryElement from ..periodictable.base import Query @@ -50,158 +49,10 @@ def add_bond(self, n, m, bond: Union[QueryBond, Bond, int, Tuple[int, ...]]): bond = QueryBond(bond) super().add_bond(n, m, bond) - def copy(self) -> 'QueryContainer': - copy = super().copy() - copy._cis_trans_stereo = self._cis_trans_stereo.copy() - return copy - def union(self, other: 'QueryContainer', *, remap: bool = False, copy: bool = True) -> 'QueryContainer': if not isinstance(other, QueryContainer): raise TypeError('QueryContainer expected') - u, o = super().union(other, remap=remap, copy=copy) - - ub = u._bonds - for n, m_bond in o._bonds.items(): - ub[n] = ubn = {} - for m, bond in m_bond.items(): - if m in ub: # bond partially exists. need back-connection. - ubn[m] = ub[m][n] - else: - ubn[m] = bond.copy() - - u._neighbors.update(o._neighbors) - u._hybridizations.update(o._hybridizations) - u._hydrogens.update(o._hydrogens) - u._rings_sizes.update(o._rings_sizes) - u._heteroatoms.update(o._heteroatoms) - u._masked.update(o._masked) - return u - - def remap(self, mapping: Dict[int, int], *, copy=False) -> 'QueryContainer': - atoms = self._atoms # keep original atoms dict - h = super().remap(mapping, copy=copy) - - mg = mapping.get - hydrogens = self._hydrogens - neighbors = self._neighbors - hybridizations = self._hybridizations - heteroatoms = self._heteroatoms - rings_sizes = self._rings_sizes - masked = self._masked - - if copy: - hb = h._bonds - hhg = h._hydrogens - hn = h._neighbors - hh = h._hybridizations - hx = h._heteroatoms - hrs = h._rings_sizes - hm = h._masked - - # deep copy of bonds - for n, m_bond in self._bonds.items(): - n = mg(n, n) - hb[n] = hbn = {} - for m, bond in m_bond.items(): - m = mg(m, m) - if m in hb: # bond partially exists. need back-connection. - hbn[m] = hb[m][n] - else: - hbn[m] = bond.copy() - else: - hb = {} - hhg = {} - hn = {} - hh = {} - hx = {} - hrs = {} - hm = {} - - for n, m_bond in self._bonds.items(): - n = mg(n, n) - hb[n] = hbn = {} - for m, bond in m_bond.items(): - m = mg(m, m) - if m in hb: # bond partially exists. need back-connection. - hbn[m] = hb[m][n] - else: - hbn[m] = bond - - for n in atoms: - m = mg(n, n) - hhg[m] = hydrogens[n] - hn[m] = neighbors[n] - hh[m] = hybridizations[n] - hx[m] = heteroatoms[n] - hrs[m] = rings_sizes[n] - hm[m] = masked[n] - - if copy: - return h # noqa - - self._bonds = hb - self._hydrogens = hhg - self._neighbors = hn - self._hybridizations = hh - self._heteroatoms = hx - self._rings_sizes = hrs - self._masked = hm - return self - - def enumerate_queries(self, *, enumerate_marks: bool = False): - """ - Enumerate complex queries into multiple simple ones. For example `[N,O]-C` into `NC` and `OC`. - - :param enumerate_marks: enumerate multiple marks to separate queries - """ - atoms = [(n, a._numbers) for n, a in self._atoms.items() if isinstance(a, ListElement)] - bonds = [(n, m, b.order) for n, m, b in self.bonds() if len(b.order) > 1] - for combo in product(*(x for *_, x in chain(atoms, bonds))): - copy = self.copy() - for (n, _), a in zip(atoms, combo): - copy._atoms[n] = a = QueryElement.from_atomic_number(a)() - a._attach_graph(copy, n) - for (n, m, _), b in zip(bonds, combo[len(atoms):]): - copy._bonds[n][m]._QueryBond__order = (b,) # noqa - - if enumerate_marks: - c = 0 - slices = [] - data = [] - for attr in ('_neighbors', '_hybridizations', '_hydrogens', '_heteroatoms', '_rings_sizes'): - tmp = [(n, v) for n, v in getattr(self, attr).items() if len(v) > 1] - if tmp: - data.extend(tmp) - slices.append((attr, c, c + len(tmp))) - c += len(tmp) - - for combo2 in product(*(x for _, x in data)): - copy2 = copy.copy() - for attr, i, j in slices: - attr = getattr(copy2, attr) - for (n, _), v in zip(data[i: j], combo2[i: j]): - attr[n] = (v,) - yield copy2 - else: - yield copy - - def __getstate__(self): - return {'atoms_stereo': self._atoms_stereo, 'allenes_stereo': self._allenes_stereo, - 'cis_trans_stereo': self._cis_trans_stereo, 'neighbors': self._neighbors, - 'hybridizations': self._hybridizations, 'hydrogens': self._hydrogens, 'masked': self._masked, - 'rings_sizes': self._rings_sizes, 'heteroatoms': self._heteroatoms, **super().__getstate__()} - - def __setstate__(self, state): - super().__setstate__(state) - self._atoms_stereo = state['atoms_stereo'] - self._allenes_stereo = state['allenes_stereo'] - self._cis_trans_stereo = state['cis_trans_stereo'] - self._neighbors = state['neighbors'] - self._hybridizations = state['hybridizations'] - self._hydrogens = state['hydrogens'] - self._rings_sizes = state['rings_sizes'] - self._heteroatoms = state['heteroatoms'] - self._masked = state['masked'] + return super().union(other, remap=remap, copy=copy) __all__ = ['QueryContainer'] diff --git a/chython/periodictable/base/element.py b/chython/periodictable/base/element.py index d1c1edd0..d65e039d 100644 --- a/chython/periodictable/base/element.py +++ b/chython/periodictable/base/element.py @@ -20,13 +20,13 @@ from CachedMethods import class_cached_property from collections import defaultdict from typing import Dict, List, Optional, Set, Tuple, Type -from ...exceptions import IsNotConnectedAtom, ValenceError +from ...exceptions import ValenceError class Element(ABC): __slots__ = ('_isotope', '_charge', '_is_radical', '_x', '_y', '_implicit_hydrogens', '_explicit_hydrogens', '_stereo', '_parsed_mapping', '_xyz', - '_neighbors', '_heteroatoms', '_hybridization') + '_neighbors', '_heteroatoms', '_hybridization', '_ring_sizes', '_in_ring') __class_cache__ = {} def __init__(self, isotope: Optional[int] = None): @@ -50,10 +50,12 @@ def __init__(self, isotope: Optional[int] = None): self._heteroatoms = 0 self._hybridization = 1 self._stereo = None + self._ring_sizes = () + self._in_ring = False def __repr__(self): - if self._isotope: - return f'{self.__class__.__name__}({self._isotope})' + if self.isotope: + return f'{self.__class__.__name__}({self.isotope})' return f'{self.__class__.__name__}()' @property @@ -201,7 +203,7 @@ def total_hydrogens(self) -> int: return self.implicit_hydrogens + self.explicit_hydrogens @property - def stereo(self): + def stereo(self) -> Optional[bool]: """ Tetrahedron or allene stereo label """ @@ -227,6 +229,20 @@ def hybridization(self): """ return self._hybridization + @property + def ring_sizes(self) -> Tuple[int, ...]: + """ + Atom rings sizes. + """ + return self._ring_sizes + + @property + def in_ring(self) -> bool: + """ + Atom in any ring. + """ + return self._in_ring + def copy(self, full=False): copy = object.__new__(self.__class__) copy._isotope = self.isotope @@ -241,33 +257,13 @@ def copy(self, full=False): copy._neighbors = self.neighbors copy._heteroatoms = self.heteroatoms copy._hybridization = self.hybridization + copy._ring_sizes = self.ring_sizes + copy._in_ring = self.in_ring return copy def __copy__(self): return self.copy() - @property - def ring_sizes(self) -> Tuple[int, ...]: - """ - Atom rings sizes. - """ - try: - return self._graph().atoms_rings_sizes[self._n] - except AttributeError: - raise IsNotConnectedAtom - except KeyError: - return () - - @property - def in_ring(self) -> bool: - """ - Atom in any ring. - """ - try: - return self._n in self._graph().ring_atoms - except AttributeError: - raise IsNotConnectedAtom - @classmethod def from_symbol(cls, symbol: str) -> Type['Element']: """ diff --git a/chython/periodictable/base/query.py b/chython/periodictable/base/query.py index 2cc55367..4145acf5 100644 --- a/chython/periodictable/base/query.py +++ b/chython/periodictable/base/query.py @@ -354,7 +354,7 @@ def __hash__(self): self.ring_sizes, self.implicit_hydrogens, self.heteroatoms)) def __repr__(self): - return f'{self.__class__.__name__}([{",".join(self._elements)}])' + return f'{self.__class__.__name__}([{self.atomic_symbol}])' class QueryElement(ExtendedQuery, ABC): @@ -367,8 +367,8 @@ def __init__(self, isotope: Optional[int] = None): self._isotope = isotope def __repr__(self): - if self._isotope: - return f'{self.__class__.__name__}({self._isotope})' + if self.isotope: + return f'{self.__class__.__name__}({self.isotope})' return f'{self.__class__.__name__}()' @property diff --git a/pyproject.toml b/pyproject.toml index 02c177e2..bf8fd347 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = 'chython' -version = '1.81' +version = '2.0' description = 'Library for processing molecules and reactions in python way' authors = ['Ramil Nugmanov '] license = 'LGPLv3' From dcf8c8a5708f7fb8247f3a414ad352ea139125de Mon Sep 17 00:00:00 2001 From: stsouko Date: Fri, 1 Nov 2024 13:29:38 +0100 Subject: [PATCH 04/67] Refactor molecule structure handling and backup procedures. Simplify the molecule structure by removing redundant charge and radical attributes and streamline the backup procedure by utilizing the copy method. Improved bond copying with added stereo support, and refined element creation from atomic data. --- chython/containers/bonds.py | 56 ++++++++++---------- chython/containers/cgr.py | 55 ++------------------ chython/containers/graph.py | 2 +- chython/containers/molecule.py | 74 +++++---------------------- chython/periodictable/base/dynamic.py | 39 +++++++++++--- chython/periodictable/base/element.py | 38 +++++++++----- chython/periodictable/base/query.py | 29 +++++++---- 7 files changed, 120 insertions(+), 173 deletions(-) diff --git a/chython/containers/bonds.py b/chython/containers/bonds.py index e6014c1e..88cedd85 100644 --- a/chython/containers/bonds.py +++ b/chython/containers/bonds.py @@ -71,17 +71,14 @@ def copy(self, full=False) -> 'Bond': if full: copy._stereo = self.stereo copy._in_ring = self.in_ring + else: + copy._in_ring = False + copy._stereo = None return copy def __copy__(self): return self.copy() - @classmethod - def from_bond(cls, bond): - if isinstance(bond, Bond): - return cls(bond.order) - raise TypeError('Bond expected') - class DynamicBond: __slots__ = ('_order', '_p_order') @@ -146,17 +143,12 @@ def __copy__(self): return self.copy() @classmethod - def from_bond(cls, bond): - if isinstance(bond, Bond): - copy = object.__new__(cls) - copy._order = copy._p_order = bond.order - return copy - elif isinstance(bond, cls): - copy = object.__new__(cls) - copy._order = bond.order - copy._p_order = bond.p_order - return copy - raise TypeError('DynamicBond expected') + def from_bond(cls, bond: 'Bond') -> 'DynamicBond': + if not isinstance(bond, Bond): + raise TypeError('Bond expected') + copy = object.__new__(cls) + copy._order = copy._p_order = bond.order + return copy class QueryBond: @@ -222,33 +214,37 @@ def in_ring(self) -> Optional[bool]: return self._in_ring @property - def stereo(self): + def stereo(self) -> Optional[bool]: return self._stereo def copy(self, full=False) -> 'QueryBond': copy = object.__new__(self.__class__) copy._order = self.order - copy._in_ring = self.in_ring if full: + copy._in_ring = self.in_ring copy._stereo = self.stereo + else: + copy._in_ring = copy._stereo = None return copy def __copy__(self): return self.copy() @classmethod - def from_bond(cls, bond): - if isinstance(bond, Bond): - copy = object.__new__(cls) - copy._order = (bond.order,) - copy._in_ring = None - return copy - elif isinstance(bond, cls): - copy = object.__new__(cls) - copy._order = bond.order + def from_bond(cls, bond: 'Bond', stereo=False, in_ring=False) -> 'QueryBond': + if not isinstance(bond, Bond): + raise TypeError('Bond expected') + copy = object.__new__(cls) + copy._order = (bond.order,) + if in_ring: copy._in_ring = bond.in_ring - return copy - raise TypeError('QueryBond or Bond expected') + else: + copy._in_ring = None + if stereo: + copy._stereo = bond.stereo + else: + copy._stereo = None + return copy __all__ = ['Bond', 'DynamicBond', 'QueryBond'] diff --git a/chython/containers/cgr.py b/chython/containers/cgr.py index 24959c80..9bdc697d 100644 --- a/chython/containers/cgr.py +++ b/chython/containers/cgr.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2017-2023 Ramil Nugmanov +# Copyright 2017-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -28,21 +28,13 @@ class CGRContainer(CGRSmiles, Morgan, Rings, Isomorphism, FingerprintsCGR): - __slots__ = ('_atoms', '_bonds', '_charges', '_radicals', '_p_charges', '_p_radicals', '__dict__', '__weakref__') + __slots__ = ('_atoms', '_bonds', '__dict__') _atoms: Dict[int, DynamicElement] _bonds: Dict[int, Dict[int, DynamicBond]] - _charges: Dict[int, int] - _radicals: Dict[int, bool] - _p_charges: Dict[int, int] - _p_radicals: Dict[int, bool] def __init__(self): self._atoms = {} self._bonds = {} - self._charges = {} - self._radicals = {} - self._p_charges = {} - self._p_radicals = {} def bonds(self) -> Iterator[Tuple[int, int, DynamicBond]]: """ @@ -59,19 +51,8 @@ def bonds(self) -> Iterator[Tuple[int, int, DynamicBond]]: def center_atoms(self) -> Tuple[int, ...]: """ Get list of atoms of reaction center (atoms with dynamic: bonds, charges, radicals). """ - radicals = self._radicals - p_charges = self._p_charges - p_radicals = self._p_radicals - - center = set() - for n, c in self._charges.items(): - if c != p_charges[n] or radicals[n] != p_radicals[n]: - center.add(n) - - for n, m_bond in self._bonds.items(): - if any(bond.order != bond.p_order for bond in m_bond.values()): - center.add(n) - + center = {n for n, a in self._atoms.items() if a.is_dynamic} + center.update(n for n, m_bond in self._bonds.items() if any(bond.is_dynamic for bond in m_bond.values())) return tuple(center) def substructure(self, atoms) -> 'CGRContainer': @@ -82,22 +63,10 @@ def substructure(self, atoms) -> 'CGRContainer': """ atoms = set(atoms) sa = self._atoms - sc = self._charges - sr = self._radicals sb = self._bonds - spc = self._p_charges - spr = self._p_radicals sub = object.__new__(self.__class__) - sub._charges = {n: sc[n] for n in atoms} - sub._radicals = {n: sr[n] for n in atoms} - sub._p_charges = {n: spc[n] for n in atoms} - sub._p_radicals = {n: spr[n] for n in atoms} - - sub._atoms = ca = {} - for n in atoms: - ca[n] = atom = sa[n].copy() - atom._attach_graph(sub, n) + sub._atoms = {n: sa[n].copy() for n in atoms} sub._bonds = cb = {} for n in atoms: @@ -136,19 +105,5 @@ def get_mapping(self, other: 'CGRContainer', /, *, automorphism_filter: bool = T def __iter__(self): return iter(self._atoms) - def __getstate__(self): - return {'atoms': self._atoms, 'bonds': self._bonds, 'charges': self._charges, 'radicals': self._radicals, - 'p_charges': self._p_charges, 'p_radicals': self._p_radicals} - - def __setstate__(self, state): - self._atoms = state['atoms'] - for n, a in state['atoms'].items(): - a._attach_graph(self, n) - self._charges = state['charges'] - self._radicals = state['radicals'] - self._bonds = state['bonds'] - self._p_charges = state['p_charges'] - self._p_radicals = state['p_radicals'] - __all__ = ['CGRContainer'] diff --git a/chython/containers/graph.py b/chython/containers/graph.py index 54470b35..fe3dc720 100644 --- a/chython/containers/graph.py +++ b/chython/containers/graph.py @@ -132,7 +132,7 @@ def copy(self): if m in cb: # bond partially exists. need back-connection. cbn[m] = cb[m][n] else: - cbn[m] = bond.copy() + cbn[m] = bond.copy(full=True) return copy def remap(self, mapping: Dict[int, int]): diff --git a/chython/containers/molecule.py b/chython/containers/molecule.py index a4b5c8ef..5ccf06fc 100644 --- a/chython/containers/molecule.py +++ b/chython/containers/molecule.py @@ -443,16 +443,12 @@ def compose(self, other: 'MoleculeContainer') -> 'CGRContainer': if not isinstance(other, MoleculeContainer): raise TypeError('MoleculeContainer expected') sa = self._atoms - sc = self._charges - sr = self._radicals sb = self._bonds bonds = [] adj = defaultdict(lambda: defaultdict(lambda: [None, None])) oa = other._atoms - oc = other._charges - or_ = other._radicals ob = other._bonds common = sa.keys() & oa.keys() @@ -460,38 +456,27 @@ def compose(self, other: 'MoleculeContainer') -> 'CGRContainer': h = CGRContainer() ha = h._atoms hb = h._bonds - hc = h._charges - hpc = h._p_charges - hr = h._radicals - hpr = h._p_radicals for n in sa.keys() - common: # cleavage atoms - hc[n] = hpc[n] = sc[n] - hr[n] = hpr[n] = sr[n] + ha[n] = DynamicElement.from_atom(sa[n]) hb[n] = {} - ha[n] = a = DynamicElement.from_atom(sa[n]) - a._attach_graph(h, n) - for m, bond in sb[n].items(): if m not in ha: if m in common: # bond to common atoms is broken bond bond = DynamicBond(bond.order, None) else: - bond = DynamicBond(bond.order, bond.order) + bond = DynamicBond.from_bond(bond) bonds.append((n, m, bond)) for n in oa.keys() - common: # coupling atoms - hc[n] = hpc[n] = oc[n] - hr[n] = hpr[n] = or_[n] + ha[n] = DynamicElement.from_atom(oa[n]) hb[n] = {} - ha[n] = a = DynamicElement.from_atom(oa[n]) - a._attach_graph(h, n) for m, bond in ob[n].items(): if m not in ha: if m in common: # bond to common atoms is formed bond bond = DynamicBond(None, bond.order) else: - bond = DynamicBond(bond.order, bond.order) + bond = DynamicBond.from_bond(bond) bonds.append((n, m, bond)) for n in common: an = adj[n] @@ -502,17 +487,8 @@ def compose(self, other: 'MoleculeContainer') -> 'CGRContainer': if m in common: an[m][1] = bond.order for n in common: - san = sa[n] - if san.atomic_number != oa[n].atomic_number or san.isotope != oa[n].isotope: - raise MappingError(f'atoms with number {n} not equal') - - hc[n] = sc[n] - hpc[n] = oc[n] - hr[n] = sr[n] - hpr[n] = or_[n] + ha[n] = DynamicElement.from_atoms(sa[n], oa[n]) hb[n] = {} - ha[n] = a = DynamicElement.from_atom(san) - a._attach_graph(h, n) for m, (o1, o2) in adj[n].items(): if m not in ha: @@ -926,44 +902,20 @@ def __enter__(self): """ Transaction of changes. Keep current state for restoring on errors. """ - atoms = {} - for n, atom in self._atoms.items(): - atom = atom.copy() - atoms[n] = atom - atom._attach_graph(self, n) - - bonds = {} - for n, m_bond in self._bonds.items(): - bonds[n] = cbn = {} - for m, bond in m_bond.items(): - if m in bonds: # bond partially exists. need back-connection. - cbn[m] = bonds[m][n] - else: - cbn[m] = bond = bond.copy() - bond._attach_graph(self, n, m) - - self._backup = {'atoms': atoms, 'bonds': bonds, 'parsed_mapping': self._parsed_mapping.copy(), - 'plane': self._plane.copy(), 'charges': self._charges.copy(), 'radicals': self._radicals.copy(), - 'hydrogens': self._hydrogens.copy(), 'conformers': [x.copy() for x in self._conformers], - 'atoms_stereo': self._atoms_stereo.copy(), 'allenes_stereo': self._allenes_stereo.copy(), - 'cis_trans_stereo': self._cis_trans_stereo.copy()} + self._backup = self.copy() return self def __exit__(self, exc_type, exc_val, exc_tb): if exc_type: # restore state backup = self._backup - self._atoms = backup['atoms'] - self._bonds = backup['bonds'] - self._parsed_mapping = backup['parsed_mapping'] - self._plane = backup['plane'] - self._charges = backup['charges'] - self._radicals = backup['radicals'] - self._hydrogens = backup['hydrogens'] - self._conformers = backup['conformers'] - self._atoms_stereo = backup['atoms_stereo'] - self._allenes_stereo = backup['allenes_stereo'] - self._cis_trans_stereo = backup['cis_trans_stereo'] + self._atoms = backup._atoms + self._bonds = backup._bonds + self._meta = backup._meta + self._name = backup._name self.flush_cache() + else: # update internal state + self.fix_labels() + self.fix_stereo() del self._backup diff --git a/chython/periodictable/base/dynamic.py b/chython/periodictable/base/dynamic.py index d0989547..c7af1a7a 100644 --- a/chython/periodictable/base/dynamic.py +++ b/chython/periodictable/base/dynamic.py @@ -17,7 +17,7 @@ # along with this program; if not, see . # from abc import ABC, abstractmethod -from typing import Type, Union, Optional +from typing import Type, Optional from .element import Element @@ -26,6 +26,8 @@ class DynamicElement(ABC): def __init__(self, isotope: Optional[int]): self._isotope = isotope + self._charge = self._p_charge = 0 + self._is_radical = self._p_is_radical = False @property def isotope(self): @@ -65,15 +67,36 @@ def from_atomic_number(cls, number: int) -> Type['DynamicElement']: return element @classmethod - def from_atom(cls, atom: Union['Element', 'DynamicElement']) -> 'DynamicElement': + def from_atom(cls, atom: 'Element') -> 'DynamicElement': """ - get DynamicElement object from Element object or copy of DynamicElement object + get DynamicElement object from Element object """ - if isinstance(atom, Element): - return cls.from_atomic_number(atom.atomic_number)(atom.isotope) - elif not isinstance(atom, DynamicElement): - raise TypeError('Element or DynamicElement expected') - return atom.copy() + if not isinstance(atom, Element): + raise TypeError('Element expected') + dynamic = object.__new__(cls.from_atomic_number(atom.atomic_number)) + dynamic._isotope = atom.isotope + dynamic._charge = dynamic._p_charge = atom.charge + dynamic._is_radical = dynamic._p_is_radical = atom.is_radical + return dynamic + + @classmethod + def from_atoms(cls, atom1: 'Element', atom2: 'Element') -> 'DynamicElement': + """ + get DynamicElement object from pair of Element objects + """ + if not isinstance(atom1, Element) or not isinstance(atom2, Element): + raise TypeError('Element expected') + if atom1.atomic_number != atom2.atomic_number: + raise ValueError('elements should be of the same type') + if atom1.isotope != atom2.isotope: + raise ValueError('elements should be of the same isotope') + dynamic = object.__new__(cls.from_atomic_number(atom1.atomic_number)) + dynamic._isotope = atom1.isotope + dynamic._charge = atom1.charge + dynamic._p_charge = atom2.charge + dynamic._is_radical = atom1.is_radical + dynamic._p_is_radical = atom2.is_radical + return dynamic @property def charge(self) -> int: diff --git a/chython/periodictable/base/element.py b/chython/periodictable/base/element.py index d65e039d..943d1128 100644 --- a/chython/periodictable/base/element.py +++ b/chython/periodictable/base/element.py @@ -45,11 +45,12 @@ def __init__(self, isotope: Optional[int] = None): self._is_radical = False self._x = self._y = 0 self._implicit_hydrogens = None + self._stereo = None + self._explicit_hydrogens = 0 self._neighbors = 0 self._heteroatoms = 0 self._hybridization = 1 - self._stereo = None self._ring_sizes = () self._in_ring = False @@ -243,22 +244,40 @@ def in_ring(self) -> bool: """ return self._in_ring - def copy(self, full=False): + def copy(self, full=False, hydrogens=False, stereo=False) -> 'Element': + """ + Get a copy of the Element object with attribute copy control. + """ copy = object.__new__(self.__class__) copy._isotope = self.isotope copy._charge = self.charge copy._is_radical = self.is_radical + copy._x = self.x + copy._y = self.y if full: - copy._x = self.x - copy._y = self.y copy._implicit_hydrogens = self.implicit_hydrogens - copy._explicit_hydrogens = self.explicit_hydrogens copy._stereo = self.stereo + copy._explicit_hydrogens = self.explicit_hydrogens copy._neighbors = self.neighbors copy._heteroatoms = self.heteroatoms copy._hybridization = self.hybridization copy._ring_sizes = self.ring_sizes copy._in_ring = self.in_ring + else: + copy._explicit_hydrogens = 0 + copy._neighbors = 0 + copy._heteroatoms = 0 + copy._hybridization = 1 + copy._ring_sizes = () + copy._in_ring = False + if hydrogens: + copy._implicit_hydrogens = self.implicit_hydrogens + else: + copy._implicit_hydrogens = None + if stereo: + copy._stereo = self.stereo + else: + copy._stereo = None return copy def __copy__(self): @@ -290,15 +309,6 @@ def from_atomic_number(cls, number: int) -> Type['Element']: except KeyError: raise ValueError(f'Element with number "{number}" not found') - @classmethod - def from_atom(cls, atom: 'Element') -> 'Element': - """ - get Element copy - """ - if not isinstance(atom, Element): - raise TypeError('Element expected') - return atom.copy() - def __eq__(self, other): """ compare attached to molecules elements diff --git a/chython/periodictable/base/query.py b/chython/periodictable/base/query.py index 4145acf5..19b5e66b 100644 --- a/chython/periodictable/base/query.py +++ b/chython/periodictable/base/query.py @@ -413,19 +413,30 @@ def from_atomic_number(cls, number: int) -> Type['QueryElement']: return element @classmethod - def from_atom(cls, atom: Union['Element', 'Query']) -> 'Query': + def from_atom(cls, atom: 'Element', neighbors=False, hybridization=False, heteroatoms=False, + hydrogens=False, ring_sizes=False) -> 'QueryElement': """ get QueryElement or AnyElement object from Element object or copy of QueryElement or AnyElement """ - if isinstance(atom, Element): - # transfer true atomic props - query = cls.from_atomic_number(atom.atomic_number)(atom.isotope) - query._charge = atom.charge - query._is_radical = atom.is_radical - return query - elif not isinstance(atom, Query): + if not isinstance(atom, Element): raise TypeError('Element or Query expected') - return atom.copy() + + # transfer true atomic props + query = cls.from_atomic_number(atom.atomic_number)(atom.isotope) + query._charge = atom.charge + query._is_radical = atom.is_radical + + if neighbors: + query._neighbors == (atom.neighbors,) + if hybridization: + query._hybridization == (atom.hybridization,) + if heteroatoms: + query._heteroatoms = (atom.heteroatoms,) + if ring_sizes: + query._ring_sizes = atom.ring_sizes + if hydrogens and atom.implicit_hydrogens is not None: + query._implicit_hydrogens = (atom.implicit_hydrogens,) + return query def copy(self, full=False): copy = super().copy(full=full) From e0fb2c5b91f01da54f0b76e8f523c9675e1ff648 Mon Sep 17 00:00:00 2001 From: stsouko Date: Fri, 1 Nov 2024 14:54:16 +0100 Subject: [PATCH 05/67] Enhance molecule container: retain stereo info and fix labels. Updated MoleculeContainer to retain stereo information during atom/bond operations by introducing conditions in the fix_labels method. Expanded substructure method allowing customizable mark settings and improved copy methods in Bond and QueryElement to optionally retain stereo data. --- chython/containers/bonds.py | 7 +- chython/containers/molecule.py | 178 +++++++++++----------------- chython/periodictable/base/query.py | 11 +- 3 files changed, 79 insertions(+), 117 deletions(-) diff --git a/chython/containers/bonds.py b/chython/containers/bonds.py index 88cedd85..79f13cad 100644 --- a/chython/containers/bonds.py +++ b/chython/containers/bonds.py @@ -65,7 +65,7 @@ def stereo(self) -> Optional[bool]: def in_ring(self) -> bool: return self._in_ring - def copy(self, full=False) -> 'Bond': + def copy(self, full=False, stereo=False) -> 'Bond': copy = object.__new__(self.__class__) copy._order = self.order if full: @@ -73,7 +73,10 @@ def copy(self, full=False) -> 'Bond': copy._in_ring = self.in_ring else: copy._in_ring = False - copy._stereo = None + if stereo: + copy._stereo = self.stereo + else: + copy._stereo = None return copy def __copy__(self): diff --git a/chython/containers/molecule.py b/chython/containers/molecule.py index 5ccf06fc..40205489 100644 --- a/chython/containers/molecule.py +++ b/chython/containers/molecule.py @@ -37,20 +37,21 @@ from ..algorithms.stereo import MoleculeStereo from ..algorithms.tautomers import Tautomers from ..algorithms.x3dom import X3domMolecule -from ..exceptions import MappingError, ValenceError +from ..exceptions import ValenceError from ..periodictable import DynamicElement, Element, QueryElement, H class MoleculeContainer(MoleculeStereo, Graph[Element, Bond], MoleculeIsomorphism, Aromatize, StandardizeMolecule, MoleculeSmiles, DepictMolecule, Calculate2DMolecule, Fingerprints, Tautomers, MCS, X3domMolecule): - __slots__ = ('_backup', '_meta', '_name', '_changed') + __slots__ = ('_meta', '_name', '_changed', '_backup') def __init__(self): super().__init__() self._meta = None self._name = None self._changed = None + self._backup = None @property def meta(self) -> Dict: @@ -213,7 +214,7 @@ def add_atom(self, atom: Union[Element, int, str], *args, _skip_calculation=Fals self._changed = {n} else: self._changed.add(n) - if not _skip_calculation: + if not _skip_calculation and self._backup is None: self.fix_labels() return n @@ -236,8 +237,9 @@ def add_bond(self, n, m, bond: Union[Bond, int], *, _skip_calculation=False): else: self._changed.add(n) self._changed.add(m) - if not _skip_calculation: + if not _skip_calculation and self._backup is None: self.fix_labels() + self.fix_stereo() def delete_atom(self, n: int, *, _skip_calculation=False): """ @@ -256,8 +258,9 @@ def delete_atom(self, n: int, *, _skip_calculation=False): self._changed = {m} else: self._changed.add(m) - if not _skip_calculation: + if not _skip_calculation and self._backup is None: self.fix_labels() + self.fix_stereo() def delete_bond(self, n: int, m: int, *, _skip_calculation=False): """ @@ -274,8 +277,9 @@ def delete_bond(self, n: int, m: int, *, _skip_calculation=False): else: self._changed.add(n) self._changed.add(m) - if not _skip_calculation: + if not _skip_calculation and self._backup is None: self.fix_labels() + self.fix_stereo() def copy(self) -> 'MoleculeContainer': copy = super().copy() @@ -293,7 +297,8 @@ def union(self, other: 'MoleculeContainer', *, remap: bool = False, copy: bool = def substructure(self, atoms: Iterable[int], *, as_query: bool = False, recalculate_hydrogens=True, skip_neighbors_marks=False, skip_hybridizations_marks=False, skip_hydrogens_marks=False, - skip_rings_sizes_marks=False, skip_heteroatoms_marks=False) -> \ + skip_rings_sizes_marks=False, skip_heteroatoms_marks=False, skip_in_ring_bond_marks=False, + skip_stereo_marks=False) -> \ Union['MoleculeContainer', 'QueryContainer']: """ Create substructure containing atoms from atoms list. @@ -310,6 +315,8 @@ def substructure(self, atoms: Iterable[int], *, as_query: bool = False, recalcul :param skip_hydrogens_marks: Don't set hydrogens count marks on substructured queries :param skip_rings_sizes_marks: Don't set rings_sizes marks on substructured queries :param skip_heteroatoms_marks: Don't set heteroatoms count marks + :param skip_in_ring_bond_marks: Don't set in_ring bond marks + :param skip_stereo_marks: Don't set stereo marks on substructured queries """ if not atoms: raise ValueError('empty atoms list not allowed') @@ -317,97 +324,51 @@ def substructure(self, atoms: Iterable[int], *, as_query: bool = False, recalcul raise ValueError('invalid atom numbers') atoms = tuple(n for n in self._atoms if n in atoms) # save original order if as_query: - atom_type = QueryElement - bond_type = QueryBond sub = object.__new__(QueryContainer) - else: - atom_type = Element - bond_type = Bond - sub = object.__new__(self.__class__) - sub._MoleculeContainer__name = sub._MoleculeContainer__meta = None - - sa = self._atoms - sb = self._bonds - sc = self._charges - sr = self._radicals - - sub._charges = {n: sc[n] for n in atoms} - sub._radicals = {n: sr[n] for n in atoms} - sub._atoms = ca = {} + lost = {n for n, a in self._atoms.items() if a.atomic_number != 1} - set(atoms) # atoms not in substructure + # atoms with fully present neighbors + not_skin = {n for n in atoms if lost.isdisjoint(self._bonds[n])} + + # check for full presence of cumulene chains and terminal attachments + for p in self._stereo_cumulenes.values(): + if not not_skin.issuperset(p): + not_skin.difference_update(p) + + sub._atoms = {n: QueryElement.from_atom(self._atoms[n], + neighbors=not skip_neighbors_marks, + hybridization=not skip_hybridizations_marks, + hydrogens=not skip_hydrogens_marks, + ring_sizes=not skip_rings_sizes_marks, + heteroatoms=not skip_heteroatoms_marks, + stereo=not skip_stereo_marks and n in not_skin) + for n in atoms} + sub._bonds = sb = {} + for n in atoms: + sb[n] = sbn = {} + for m, bond in self._bonds[n].items(): + if m in sb: # bond partially exists. need back-connection. + sbn[m] = sb[m][n] + elif m in atoms: + sbn[m] = QueryBond.from_bond(bond, + in_ring=not skip_in_ring_bond_marks, + stereo=not skip_stereo_marks and n in not_skin and m in not_skin) + return sub + + # molecule substructure + sub = object.__new__(self.__class__) + sub._name = sub._meta = sub._changed = None + sub._atoms = {n: self._atoms[n].copy(hydrogens=not recalculate_hydrogens, stereo=True) for n in atoms} + sub._bonds = sb = {} for n in atoms: - ca[n] = atom = atom_type.from_atom(sa[n]) - atom._attach_graph(sub, n) - - sub._bonds = cb = {} - for n in atoms: - cb[n] = cbn = {} - for m, bond in sb[n].items(): - if m in cb: # bond partially exists. need back-connection. - cbn[m] = cb[m][n] + sb[n] = sbn = {} + for m, bond in self._bonds[n].items(): + if m in sb: # bond partially exists. need back-connection. + sbn[m] = sb[m][n] elif m in atoms: - cbn[m] = bond = bond_type.from_bond(bond) - if not as_query: - bond._attach_graph(sub, n, m) - - if as_query: - lost = {n for n, a in sa.items() if a.atomic_number != 1} - set(atoms) # atoms not in substructure - not_skin = {n for n in atoms if lost.isdisjoint(sb[n])} - sub._atoms_stereo = {n: s for n, s in self._atoms_stereo.items() if n in not_skin} - sub._allenes_stereo = {n: s for n, s in self._allenes_stereo.items() - if not_skin.issuperset(self._stereo_allenes_paths[n]) and - not_skin.issuperset(x for x in self._stereo_allenes[n] if x)} - sub._cis_trans_stereo = {nm: s for nm, s in self._cis_trans_stereo.items() - if not_skin.issuperset(self._stereo_cis_trans_paths[nm]) and - not_skin.issuperset(x for x in self._stereo_cis_trans[nm] if x)} - - sub._masked = {n: False for n in atoms} - if skip_heteroatoms_marks: - sub._heteroatoms = {n: () for n in atoms} - else: - sha = self.heteroatoms - sub._heteroatoms = {n: (sha(n),) for n in atoms} - - if skip_hybridizations_marks: - sub._hybridizations = {n: () for n in atoms} - else: - sh = self.hybridization - sub._hybridizations = {n: (sh(n),) for n in atoms} - if skip_neighbors_marks: - sub._neighbors = {n: () for n in atoms} - else: - sn = self.neighbors - sub._neighbors = {n: (sn(n),) for n in atoms} - if skip_hydrogens_marks: - sub._hydrogens = {n: () for n in atoms} - else: - shg = self._hydrogens - sub._hydrogens = {n: () if shg[n] is None else (shg[n],) for n in atoms} - if skip_rings_sizes_marks: - sub._rings_sizes = {n: () for n in atoms} - else: - rs = self.atoms_rings_sizes - sub._rings_sizes = {n: rs.get(n, ()) for n in atoms} - else: - sub._conformers = [{n: c[n] for n in atoms} for c in self._conformers] - - if recalculate_hydrogens: - sub._hydrogens = {} - for n in atoms: - sub._calc_implicit(n) - else: - hg = self._hydrogens - sub._hydrogens = {n: hg[n] for n in atoms} - - sp = self._plane - sub._plane = {n: sp[n] for n in atoms} - sub._parsed_mapping = {n: m for n, m in self._parsed_mapping.items() if n in atoms} - - # fix_stereo will repair data - sub._atoms_stereo = self._atoms_stereo.copy() - sub._allenes_stereo = self._allenes_stereo.copy() - sub._cis_trans_stereo = self._cis_trans_stereo.copy() - sub.fix_stereo() + sbn[m] = bond.copy(stereo=True) + sub.fix_labels(recalculate_hydrogens=recalculate_hydrogens) + sub.fix_stereo() return sub def augmented_substructure(self, atoms: Iterable[int], deep: int = 1, **kwargs) -> 'MoleculeContainer': @@ -442,36 +403,29 @@ def compose(self, other: 'MoleculeContainer') -> 'CGRContainer': """ if not isinstance(other, MoleculeContainer): raise TypeError('MoleculeContainer expected') - sa = self._atoms - sb = self._bonds - bonds = [] adj = defaultdict(lambda: defaultdict(lambda: [None, None])) - - oa = other._atoms - ob = other._bonds - - common = sa.keys() & oa.keys() + common = self._atoms.keys() & other._atoms.keys() h = CGRContainer() ha = h._atoms hb = h._bonds - for n in sa.keys() - common: # cleavage atoms - ha[n] = DynamicElement.from_atom(sa[n]) + for n in self._atoms.keys() - common: # cleavage atoms + ha[n] = DynamicElement.from_atom(self._atoms[n]) hb[n] = {} - for m, bond in sb[n].items(): + for m, bond in self._bonds[n].items(): if m not in ha: if m in common: # bond to common atoms is broken bond bond = DynamicBond(bond.order, None) else: bond = DynamicBond.from_bond(bond) bonds.append((n, m, bond)) - for n in oa.keys() - common: # coupling atoms - ha[n] = DynamicElement.from_atom(oa[n]) + for n in other._atoms.keys() - common: # coupling atoms + ha[n] = DynamicElement.from_atom(other._atoms[n]) hb[n] = {} - for m, bond in ob[n].items(): + for m, bond in other._bonds[n].items(): if m not in ha: if m in common: # bond to common atoms is formed bond bond = DynamicBond(None, bond.order) @@ -480,14 +434,14 @@ def compose(self, other: 'MoleculeContainer') -> 'CGRContainer': bonds.append((n, m, bond)) for n in common: an = adj[n] - for m, bond in sb[n].items(): + for m, bond in self._bonds[n].items(): if m in common: an[m][0] = bond.order - for m, bond in ob[n].items(): + for m, bond in other._bonds[n].items(): if m in common: an[m][1] = bond.order for n in common: - ha[n] = DynamicElement.from_atoms(sa[n], oa[n]) + ha[n] = DynamicElement.from_atoms(self._atoms[n], other._atoms[n]) hb[n] = {} for m, (o1, o2) in adj[n].items(): @@ -916,7 +870,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): else: # update internal state self.fix_labels() self.fix_stereo() - del self._backup + self._backup = None # drop backup __all__ = ['MoleculeContainer'] diff --git a/chython/periodictable/base/query.py b/chython/periodictable/base/query.py index 19b5e66b..fc26c962 100644 --- a/chython/periodictable/base/query.py +++ b/chython/periodictable/base/query.py @@ -107,6 +107,9 @@ def copy(self, full=False): if full: copy._masked = self.masked copy._stereo = self.stereo + else: + copy._masked = False + copy._stereo = None return copy def __copy__(self): @@ -414,7 +417,7 @@ def from_atomic_number(cls, number: int) -> Type['QueryElement']: @classmethod def from_atom(cls, atom: 'Element', neighbors=False, hybridization=False, heteroatoms=False, - hydrogens=False, ring_sizes=False) -> 'QueryElement': + hydrogens=False, ring_sizes=False, stereo=False) -> 'QueryElement': """ get QueryElement or AnyElement object from Element object or copy of QueryElement or AnyElement """ @@ -427,15 +430,17 @@ def from_atom(cls, atom: 'Element', neighbors=False, hybridization=False, hetero query._is_radical = atom.is_radical if neighbors: - query._neighbors == (atom.neighbors,) + query._neighbors = (atom.neighbors,) if hybridization: - query._hybridization == (atom.hybridization,) + query._hybridization = (atom.hybridization,) if heteroatoms: query._heteroatoms = (atom.heteroatoms,) if ring_sizes: query._ring_sizes = atom.ring_sizes if hydrogens and atom.implicit_hydrogens is not None: query._implicit_hydrogens = (atom.implicit_hydrogens,) + if stereo: + query._stereo = atom.stereo return query def copy(self, full=False): From fb08fdc75d6f287bfade1b0ce35386e67a3be236 Mon Sep 17 00:00:00 2001 From: stsouko Date: Fri, 1 Nov 2024 16:01:19 +0100 Subject: [PATCH 06/67] Refactor stereo and chemical attributes handling Centralize chemical attributes like charge and radicals within the `atom` object. Simplify stereo data management by directly setting stereochemistry on atom and bond objects and remove unnecessary lookups. Add `ExtendedQuery` to public API and streamline related imports. --- chython/algorithms/isomorphism.py | 2 +- chython/algorithms/smiles.py | 132 +++++++++++-------------- chython/algorithms/stereo/graph.py | 14 +-- chython/periodictable/base/__init__.py | 3 +- chython/periodictable/base/query.py | 31 +++--- 5 files changed, 85 insertions(+), 97 deletions(-) diff --git a/chython/algorithms/isomorphism.py b/chython/algorithms/isomorphism.py index 76791e70..e2d95da3 100644 --- a/chython/algorithms/isomorphism.py +++ b/chython/algorithms/isomorphism.py @@ -22,7 +22,7 @@ from itertools import permutations from typing import Any, Collection, Dict, Iterator, Optional, TYPE_CHECKING, Union from .._functions import lazy_product -from ..periodictable.element import Element, Query, AnyElement, AnyMetal, ListElement +from ..periodictable import Element, Query, AnyElement, AnyMetal, ListElement if TYPE_CHECKING: diff --git a/chython/algorithms/smiles.py b/chython/algorithms/smiles.py index e4b8dfdd..412c76e0 100644 --- a/chython/algorithms/smiles.py +++ b/chython/algorithms/smiles.py @@ -26,6 +26,7 @@ from itertools import product from random import random from typing import Callable, Optional, Tuple, TYPE_CHECKING, Union +from ..periodictable import ExtendedQuery, QueryElement if TYPE_CHECKING: @@ -382,15 +383,11 @@ def _smiles_order(self: 'MoleculeContainer', stereo=True) -> Callable: def _format_cxsmiles(self: 'MoleculeContainer', order): if self.is_radical: - radical = self._radicals - return f'|^1:{",".join(str(n) for n, m in enumerate(order) if radical[m])}|' + return f'|^1:{",".join(str(n) for n, m in enumerate(order) if self._atoms[m].is_radical)}|' return def _format_atom(self: 'MoleculeContainer', n, adjacency, **kwargs): atom = self._atoms[n] - charge = self._charges[n] - ih = self._hydrogens[n] - hyb = self.hybridization(n) smi = ['', # [ str(atom.isotope) if atom.isotope else '', # isotope @@ -401,50 +398,51 @@ def _format_atom(self: 'MoleculeContainer', n, adjacency, **kwargs): f':{n}' if kwargs.get('mapping', False) else '', # mapping ''] # ] - if kwargs.get('stereo', True): - if n in self._atoms_stereo: - if ih and next(x for x in adjacency) == n: # first atom in smiles has reversed chiral mark - smi[3] = '@@' if self._translate_tetrahedron_sign(n, adjacency[n]) else '@' - else: - smi[3] = '@' if self._translate_tetrahedron_sign(n, adjacency[n]) else '@@' - elif n in self._allenes_stereo: + if atom.stereo is not None and kwargs.get('stereo', True): + # allene + if n in self._stereo_allenes_terminals: t1, t2 = self._stereo_allenes_terminals[n] env = self._stereo_allenes[n] n1 = next(x for x in adjacency[t1] if x in env) n2 = next(x for x in adjacency[t2] if x in env) smi[3] = '@' if self._translate_allene_sign(n, n1, n2) else '@@' - elif charge and kwargs.get('charges', True): - smi[5] = charge_str[charge] - elif charge and kwargs.get('charges', True): - smi[5] = charge_str[charge] + # tetrahedron + elif atom.implicit_hydrogens and next(x for x in adjacency) == n: + # first atom in smiles has reversed chiral mark + smi[3] = '@@' if self._translate_tetrahedron_sign(n, adjacency[n]) else '@' + else: + smi[3] = '@' if self._translate_tetrahedron_sign(n, adjacency[n]) else '@@' + + if atom.charge and kwargs.get('charges', True): + smi[5] = charge_str[atom.charge] - if any(smi) or atom.atomic_symbol not in organic_set or self._radicals[n] or kwargs.get('hydrogens', False): + if any(smi) or atom.atomic_symbol not in organic_set or atom.is_radical or kwargs.get('hydrogens', False): smi[0] = '[' smi[-1] = ']' - if ih == 1: + if atom.implicit_hydrogens == 1: smi[4] = 'H' - elif ih: - smi[4] = f'H{ih}' - elif hyb == 4 and ih and atom.atomic_number in (5, 7, 15): # pyrrole + elif atom.implicit_hydrogens: + smi[4] = f'H{atom.implicit_hydrogens}' + elif atom.hybridization == 4 and atom.implicit_hydrogens and atom.atomic_number in (5, 7, 15): # pyrrole smi[0] = '[' smi[-1] = ']' - if ih == 1: + if atom.implicit_hydrogens == 1: smi[4] = 'H' else: - smi[4] = f'H{ih}' - elif not ih and atom.atomic_number in (5, 6, 15, 16) and not self.not_special_connectivity[n]: + smi[4] = f'H{atom.implicit_hydrogens}' + elif not atom.implicit_hydrogens and atom.atomic_number in (5, 6, 15, 16) and not self.not_special_connectivity[n]: # elemental B, C, P, S smi[0] = '[' smi[-1] = ']' - elif ih and atom.atomic_number == 15 and hyb != 1: + elif atom.implicit_hydrogens and atom.atomic_number == 15 and atom.hybridization != 1: smi[0] = '[' smi[-1] = ']' - if ih == 1: + if atom.implicit_hydrogens == 1: smi[4] = 'H' else: - smi[4] = f'H{ih}' + smi[4] = f'H{atom.implicit_hydrogens}' - if kwargs.get('aromatic', True) and hyb == 4: + if kwargs.get('aromatic', True) and atom.hybridization == 4: smi[2] = atom.atomic_symbol.lower() else: smi[2] = atom.atomic_symbol @@ -453,14 +451,13 @@ def _format_atom(self: 'MoleculeContainer', n, adjacency, **kwargs): def _format_bond(self: 'MoleculeContainer', n, m, adjacency, **kwargs): if not kwargs.get('bonds', True): return '' - bonds = self._bonds - order = bonds[n][m].order + order = self._bonds[n][m].order if order == 4: if kwargs.get('aromatic', True): return '' return ':' elif order == 1: # cis-trans /\ - if kwargs.get('aromatic', True) and self.hybridization(n) == self.hybridization(m) == 4: + if kwargs.get('aromatic', True) and self._atoms[n].hybridization == self._atoms[m].hybridization == 4: return '-' if kwargs.get('stereo', True): if 'cache' in adjacency: @@ -531,19 +528,15 @@ class CGRSmiles(Smiles): def _format_atom(self: 'CGRContainer', n, adjacency, **kwargs): atom = self._atoms[n] - charge = self._charges[n] - is_radical = self._radicals[n] - p_charge = self._p_charges[n] - p_is_radical = self._p_radicals[n] if atom.isotope: smi = [str(atom.isotope), atom.atomic_symbol] else: smi = [atom.atomic_symbol] - if charge or p_charge: - smi.append(dyn_charge_str[(charge, p_charge)]) - if is_radical or p_is_radical: - smi.append(dyn_radical_str[(is_radical, p_is_radical)]) + if atom.charge or atom.p_charge: + smi.append(dyn_charge_str[(atom.charge, atom.p_charge)]) + if atom.is_radical or atom.p_is_radical: + smi.append(dyn_radical_str[(atom.is_radical, atom.p_is_radical)]) if len(smi) != 1 or atom.atomic_symbol not in organic_set: smi.insert(0, '[') @@ -559,22 +552,19 @@ class QuerySmiles(Smiles): __slots__ = () def _format_cxsmiles(self: 'QueryContainer', order): - hybridization = self._hybridizations - heteroatoms = self._heteroatoms - masked = self._masked - radical = self._radicals - hh = ['atomProp'] cx = [] - if any(radical.values()): - cx.append(f'^1:{",".join(str(n) for n, m in enumerate(order) if radical[m])}') + rad = [str(n) for n, m in enumerate(order) if isinstance(a:=self._atoms[m], ExtendedQuery) and a.is_radical] + if rad: + cx.append('^1:' + ','.join(rad)) for n, m in enumerate(order): - if len(hb := hybridization[m]) > 1 or (hb and hb[0] != 4): - hh.append(f'{n}.hyb.{"".join(hybridization_str[x] for x in hb)}') - if ha := heteroatoms[m]: - hh.append(f'{n}.het.{"".join(str(x) for x in ha)}') - if masked[m]: + atom = self._atoms[m] + if len(hb := atom.hybridization) > 1 or (hb and hb[0] != 4): + hh.append(f'{n}.hyb.' + ''.join(hybridization_str[x] for x in hb)) + if isinstance(atom, ExtendedQuery) and (ha := atom.heteroatoms): + hh.append(f'{n}.het.' + ''.join(str(x) for x in ha)) + if atom.masked: hh.append(f'{n}.msk.1') if len(hh) > 1: cx.append(':'.join(hh)) @@ -583,42 +573,36 @@ def _format_cxsmiles(self: 'QueryContainer', order): def _format_atom(self: 'QueryContainer', n, adjacency, **kwargs): atom = self._atoms[n] - charge = self._charges[n] - hybridization = self._hybridizations[n] - neighbors = self._neighbors[n] - hydrogens = self._hydrogens[n] - rings = self._rings_sizes[n] - - if atom.isotope: + if isinstance(atom, QueryElement) and atom.isotope: smi = ['[', str(atom.isotope), atom.atomic_symbol] else: smi = ['[', atom.atomic_symbol] - if n in self._atoms_stereo: # mark atom as chiral. it's too difficult to set correct sign - smi.append(';@?') - if n in self._allenes_stereo: - smi.append(';@?') + if isinstance(atom, ExtendedQuery): + if atom.stereo is not None: + # mark atom as chiral. it's too difficult to set correct sign + smi.append(';@?') - if charge: - smi.append(';') - smi.append(charge_str[charge]) + if atom.charge: + smi.append(';') + smi.append(charge_str[atom.charge]) - if hydrogens: # h implicit-H-count implicit hydrogens - smi.append(';') - smi.append(','.join(f'h{x}' for x in hydrogens)) + if atom.implicit_hydrogens: # h implicit-H-count implicit hydrogens + smi.append(';') + smi.append(','.join(f'h{x}' for x in atom.implicit_hydrogens)) - if neighbors: # D degree explicit connections + if atom.neighbors: # D degree explicit connections smi.append(';') - smi.append(','.join(f'D{x}' for x in neighbors)) + smi.append(','.join(f'D{x}' for x in atom.neighbors)) - if rings: + if isinstance(atom, ExtendedQuery) and atom.ring_sizes: smi.append(';') - if rings[0]: - smi.append(','.join(f'r{x}' for x in rings)) + if atom.ring_sizes[0]: + smi.append(','.join(f'r{x}' for x in atom.ring_sizes)) else: smi.append('!R') - if len(hybridization) == 1 and hybridization[0] == 4: # only aromatic. other marks in cx extension + if len(atom.hybridization) == 1 and atom.hybridization[0] == 4: # only aromatic. other marks in cx extension smi.append(';a') smi.append(']') diff --git a/chython/algorithms/stereo/graph.py b/chython/algorithms/stereo/graph.py index 01dbd26e..6fe91b76 100644 --- a/chython/algorithms/stereo/graph.py +++ b/chython/algorithms/stereo/graph.py @@ -72,12 +72,10 @@ def tetrahedrons(self: 'Container') -> Tuple[int, ...]: """ atoms = self._atoms bonds = self._bonds - charges = self._charges - radicals = self._radicals tetra = [] for n, atom in atoms.items(): - if atom.atomic_number == 6 and not charges[n] and not radicals[n]: + if atom.atomic_number == 6 and not atom.charge and not atom.is_radical: env = bonds[n] if all(int(x) == 1 for x in env.values()): if sum(int(x) for x in env.values()) > 4: @@ -89,9 +87,11 @@ def clean_stereo(self: 'Container'): """ Remove stereo data. """ - self._atoms_stereo.clear() - self._allenes_stereo.clear() - self._cis_trans_stereo.clear() + for a in self._atoms.values(): + a._stereo = None + for _, bs in self._bonds: + for b in bs.values(): + b._stereo = None # flush twice, but it should be still faster self.flush_cache() def get_mapping(self: 'Container', other: 'Container', **kwargs): @@ -156,7 +156,7 @@ def _translate_tetrahedron_sign(self: 'Container', n, env, s=None): :param s: if None, use existing sign else translate given to molecule """ if s is None: - s = self._atoms_stereo[n] + s = self._atoms[n].stereo order = self._stereo_tetrahedrons[n] if len(order) == 3: diff --git a/chython/periodictable/base/__init__.py b/chython/periodictable/base/__init__.py index f8ca87e8..75806828 100644 --- a/chython/periodictable/base/__init__.py +++ b/chython/periodictable/base/__init__.py @@ -21,4 +21,5 @@ from .query import * -__all__ = ['Element', 'DynamicElement', 'Query', 'QueryElement', 'AnyElement', 'AnyMetal', 'ListElement'] +__all__ = ['Element', 'DynamicElement', 'Query', 'ExtendedQuery', + 'QueryElement', 'AnyElement', 'AnyMetal', 'ListElement'] diff --git a/chython/periodictable/base/query.py b/chython/periodictable/base/query.py index fc26c962..325c0947 100644 --- a/chython/periodictable/base/query.py +++ b/chython/periodictable/base/query.py @@ -47,13 +47,17 @@ def _validate(value, prop): class Query(ABC): - __slots__ = ('_neighbors', '_hybridization', '_masked', '_stereo') + __slots__ = ('_neighbors', '_hybridization', '_masked') def __init__(self): self._neighbors = () self._hybridization = () self._masked = False - self._stereo = None + + @property + @abstractmethod + def atomic_symbol(self) -> str: + ... @property def neighbors(self) -> Tuple[int, ...]: @@ -96,20 +100,12 @@ def masked(self, value): raise TypeError('masked should be bool') self._masked = value - @property - def stereo(self): - return self._stereo - def copy(self, full=False): copy = object.__new__(self.__class__) copy._neighbors = self.neighbors copy._hybridization = self.hybridization - if full: - copy._masked = self.masked - copy._stereo = self.stereo - else: - copy._masked = False - copy._stereo = None + + copy._masked = self.masked if full else False return copy def __copy__(self): @@ -120,7 +116,7 @@ def __repr__(self): class ExtendedQuery(Query, ABC): - __slots__ = ('_charge', '_is_radical', '_heteroatoms', '_ring_sizes', '_implicit_hydrogens') + __slots__ = ('_charge', '_is_radical', '_heteroatoms', '_ring_sizes', '_implicit_hydrogens', '_stereo') def __init__(self): super().__init__() @@ -129,6 +125,7 @@ def __init__(self): self._heteroatoms = () self._ring_sizes = () self._implicit_hydrogens = () + self._stereo = None @property def charge(self) -> int: @@ -200,6 +197,10 @@ def ring_sizes(self, value): else: raise TypeError('rings should be int or list or tuple of ints') + @property + def stereo(self): + return self._stereo + def copy(self, full=False): copy = super().copy(full=full) copy._charge = self.charge @@ -207,6 +208,8 @@ def copy(self, full=False): copy._heteroatoms = self.heteroatoms copy._implicit_hydrogens = self.implicit_hydrogens copy._ring_sizes = self.ring_sizes + + copy._stereo = self.stereo if full else None return copy @@ -499,4 +502,4 @@ def __hash__(self): self.hybridization, self.ring_sizes, self.implicit_hydrogens, self.heteroatoms)) -__all__ = ['Query', 'QueryElement', 'AnyElement', 'AnyMetal', 'ListElement'] +__all__ = ['Query', 'ExtendedQuery', 'QueryElement', 'AnyElement', 'AnyMetal', 'ListElement'] From 04ef91e9f42219c8efaca4fecb8c972b25c2e525 Mon Sep 17 00:00:00 2001 From: stsouko Date: Fri, 1 Nov 2024 16:16:41 +0100 Subject: [PATCH 07/67] kekule adapted --- chython/algorithms/aromatics/kekule.py | 141 ++++++++++++------------- 1 file changed, 67 insertions(+), 74 deletions(-) diff --git a/chython/algorithms/aromatics/kekule.py b/chython/algorithms/aromatics/kekule.py index ef9834e9..de51744b 100644 --- a/chython/algorithms/aromatics/kekule.py +++ b/chython/algorithms/aromatics/kekule.py @@ -46,7 +46,7 @@ def kekule(self: Union['Kekule', 'MoleculeContainer'], *, buffer_size=7) -> bool bonds = self._bonds atoms = set() for n, m, b in kekule: - bonds[n][m]._Bond__order = b # noqa + bonds[n][m]._order = b atoms.add(n) atoms.add(m) for n in atoms: @@ -65,7 +65,7 @@ def enumerate_kekule(self: Union['Kekule', 'MoleculeContainer']): bonds = copy._bonds atoms = set() for n, m, b in form: - bonds[n][m]._Bond__order = b # noqa + bonds[n][m]._order = b atoms.add(n) atoms.add(m) for n in atoms: @@ -73,8 +73,8 @@ def enumerate_kekule(self: Union['Kekule', 'MoleculeContainer']): yield copy def __fix_rings(self: 'MoleculeContainer'): + atoms = self._atoms bonds = self._bonds - charges = self._charges seen = set() for q, af, bf, mm in rules: for mapping in q.get_mapping(self, automorphism_filter=False): @@ -85,11 +85,11 @@ def __fix_rings(self: 'MoleculeContainer'): for n, c in af.items(): n = mapping[n] - charges[n] = c + atoms[n]._charge = c for n, m, b in bf: n = mapping[n] m = mapping[m] - bonds[n][m]._Bond__order = b # noqa + bonds[n][m]._order = b if seen: self.flush_cache() return True @@ -97,11 +97,7 @@ def __fix_rings(self: 'MoleculeContainer'): def __prepare_rings(self: 'MoleculeContainer'): atoms = self._atoms - charges = self._charges - radicals = self._radicals bonds = self._bonds - hydrogens = self._hydrogens - neighbors = self.neighbors rings = defaultdict(list) # aromatic skeleton pyrroles = set() @@ -168,133 +164,130 @@ def __prepare_rings(self: 'MoleculeContainer'): if any(len(rings[n]) != 2 for n in double_bonded): # double bonded never condensed raise InvalidAromaticRing('quinone valence error') for n in double_bonded: - if atoms[n].atomic_number == 7: - if charges[n] != 1: + atom = atoms[n] + if atom.atomic_number == 7: + if atom.charge != 1: raise InvalidAromaticRing('quinone should be charged N atom') - elif atoms[n].atomic_number not in (6, 15, 16, 33, 34, 52) or charges[n]: + elif atom.atomic_number not in (6, 15, 16, 33, 34, 52) or atom.charge: raise InvalidAromaticRing('quinone should be neutral S, Se, Te, C, P, As atom') for n in rings: - an = atoms[n].atomic_number - ac = charges[n] - ab = neighbors(n) - if an == 6: # carbon - if ac == 0: - if ab not in (2, 3): + atom = atoms[n] + if atom.atomic_number == 6: # carbon + if atom.charge == 0: + if atom.neighbors not in (2, 3): raise InvalidAromaticRing - elif ac in (-1, 1): - if radicals[n]: - if ab == 2: + elif atom.charge in (-1, 1): + if atom.is_radical: + if atom.neighbors == 2: double_bonded.add(n) else: raise InvalidAromaticRing - elif ab == 3: + elif atom.neighbors == 3: double_bonded.add(n) - elif ab == 2: # benzene (an|cat)ion or pyrrole + elif atom.neighbors == 2: # benzene (an|cat)ion or pyrrole pyrroles.add(n) else: raise InvalidAromaticRing else: raise InvalidAromaticRing - elif an in (7, 15, 33): - if ac == 0: # pyrrole or pyridine. include radical pyrrole - if radicals[n]: - if ab != 2: # only pyrrole radical + elif atom.atomic_number in (7, 15, 33): + if atom.charge == 0: # pyrrole or pyridine. include radical pyrrole + if atom.is_radical: + if atom.neighbors != 2: # only pyrrole radical raise InvalidAromaticRing double_bonded.add(n) - elif ab == 3: - if an == 7: # pyrrole only possible + elif atom.neighbors == 3: + if atom.atomic_number == 7: # pyrrole only possible double_bonded.add(n) else: # P(III) or P(V)H pyrroles.add(n) - elif ab == 2: - ah = hydrogens[n] - if ah is None: # pyrrole or pyridine + elif atom.neighbors == 2: + if atom.implicit_hydrogens is None: # pyrrole or pyridine pyrroles.add(n) - elif ah == 1: # only pyrrole + elif atom.implicit_hydrogens == 1: # only pyrrole double_bonded.add(n) - elif ah: # too many hydrogens for aromatic rings + elif atom.implicit_hydrogens: # too many hydrogens for aromatic rings raise InvalidAromaticRing - elif ab != 4 or an not in (15, 33): # P(V) in ring [P;a](-R1)-R2 + elif atom.neighbors != 4 or atom.atomic_number not in (15, 33): # P(V) in ring [P;a](-R1)-R2 raise InvalidAromaticRing - elif ac == -1: # pyrrole only - if ab != 2 or radicals[n]: + elif atom.charge == -1: # pyrrole only + if atom.neighbors != 2 or atom.is_radical: raise InvalidAromaticRing double_bonded.add(n) - elif ac != 1: + elif atom.charge != 1: raise InvalidAromaticRing - elif radicals[n]: - if ab != 2: # not cation-radical pyridine + elif atom.is_radical: + if atom.neighbors != 2: # not cation-radical pyridine raise InvalidAromaticRing - elif ab == 2: # pyrrole cation or protonated pyridine + elif atom.neighbors == 2: # pyrrole cation or protonated pyridine pyrroles.add(n) - elif ab != 3: # not pyridine oxyde + elif atom.neighbors != 3: # not pyridine oxyde raise InvalidAromaticRing - elif an == 8: # furan - if ab == 2: - if ac == 0: - if radicals[n]: + elif atom.atomic_number == 8: # furan + if atom.neighbors == 2: + if atom.charge == 0: + if atom.is_radical: raise InvalidAromaticRing('radical oxygen') double_bonded.add(n) - elif ac == 1: - if radicals[n]: # furan cation-radical + elif atom.charge == 1: + if atom.is_radical: # furan cation-radical double_bonded.add(n) # pyrylium else: raise InvalidAromaticRing('invalid oxygen charge') else: raise InvalidAromaticRing('Triple-bonded oxygen') - elif an in (16, 34, 52): # thiophene + elif atom.atomic_number in (16, 34, 52): # thiophene if n not in double_bonded: # not sulphoxyde nor sulphone - if ab == 2: - if radicals[n]: - if ac == 1: + if atom.neighbors == 2: + if atom.is_radical: + if atom.charge == 1: double_bonded.add(n) else: raise InvalidAromaticRing('S, Se, Te cation-radical expected') - if ac == 0: + if atom.charge == 0: double_bonded.add(n) - elif ac != 1: + elif atom.charge != 1: raise InvalidAromaticRing('S, Se, Te cation in benzene like ring expected') - elif ab == 3: - if radicals[n]: - if ac: + elif atom.neighbors == 3: + if atom.is_radical: + if atom.charge: raise InvalidAromaticRing('S, Se, Te ion-radical ring') double_bonded.add(n) - elif ac == 1: + elif atom.charge == 1: double_bonded.add(n) - elif ac: + elif atom.charge: raise InvalidAromaticRing('S, Se, Te invalid charge ring') else: raise InvalidAromaticRing('S, Se, Te hypervalent ring') - elif an == 5: # boron - if ac == 0: - if ab == 2: - if radicals[n]: # C=1O[B]OC=1 + elif atom.atomic_number == 5: # boron + if atom.charge == 0: + if atom.neighbors == 2: + if atom.is_radical: # C=1O[B]OC=1 double_bonded.add(n) else: - ah = hydrogens[n] - if ah is None: # b1ccccc1, C=1OBOC=1 or B1C=CC=N1 + if atom.implicit_hydrogens is None: # b1ccccc1, C=1OBOC=1 or B1C=CC=N1 pyrroles.add(n) - elif ah == 1: # C=1O[BH]OC=1 or [BH]1C=CC=N1 + elif atom.implicit_hydrogens == 1: # C=1O[BH]OC=1 or [BH]1C=CC=N1 double_bonded.add(n) - elif ah: + elif atom.implicit_hydrogens: raise InvalidAromaticRing - elif not radicals[n]: + elif not atom.is_radical: double_bonded.add(n) else: raise InvalidAromaticRing - elif ac == 1: - if ab == 2 and not radicals[n]: + elif atom.charge == 1: + if atom.neighbors == 2 and not atom.is_radical: double_bonded.add(n) else: raise InvalidAromaticRing - elif ac == -1: - if ab == 2: - if not radicals[n]: # C=1O[B-]OC=1 or [bH-]1ccccc1 + elif atom.charge == -1: + if atom.neighbors == 2: + if not atom.is_radical: # C=1O[B-]OC=1 or [bH-]1ccccc1 pyrroles.add(n) # anion-radical is benzene like - elif radicals[n]: # C=1O[B-*](R)OC=1 + elif atom.is_radical: # C=1O[B-*](R)OC=1 double_bonded.add(n) else: pyrroles.add(n) From e5a2eaede1c3c138ea037d68dce013d7a5e403a7 Mon Sep 17 00:00:00 2001 From: stsouko Date: Sat, 2 Nov 2024 14:11:12 +0100 Subject: [PATCH 08/67] molecule constructor refactored --- chython/containers/molecule.py | 58 +---------- chython/files/_convert.py | 137 ++++++++++++++++++++------ chython/files/daylight/smiles.py | 108 +++----------------- chython/files/daylight/tokenize.py | 15 ++- chython/periodictable/base/element.py | 18 ++-- 5 files changed, 142 insertions(+), 194 deletions(-) diff --git a/chython/containers/molecule.py b/chython/containers/molecule.py index 40205489..09fa158a 100644 --- a/chython/containers/molecule.py +++ b/chython/containers/molecule.py @@ -44,7 +44,7 @@ class MoleculeContainer(MoleculeStereo, Graph[Element, Bond], MoleculeIsomorphism, Aromatize, StandardizeMolecule, MoleculeSmiles, DepictMolecule, Calculate2DMolecule, Fingerprints, Tautomers, MCS, X3domMolecule): - __slots__ = ('_meta', '_name', '_changed', '_backup') + __slots__ = ('_meta', '_name', '_conformers', '_changed', '_backup') def __init__(self): super().__init__() @@ -93,52 +93,6 @@ def environment(self, atom: int, include_bond: bool = True, include_atom: bool = return tuple(self._bonds[atom].items()) return tuple(self._bonds[atom]) - def neighbors(self, n: int) -> int: - """number of neighbors atoms excluding any-bonded""" - return self._atoms[n].neighbors - - @cached_args_method - def hybridization(self, n: int) -> int: - """ - Atom hybridization. - - 1 - if atom has zero or only single bonded neighbors, 2 - if has only one double bonded neighbor and any amount - of single bonded, 3 - if has one triple bonded and any amount of double and single bonded neighbors or - two and more double bonded and any amount of single bonded neighbors, 4 - if atom in aromatic ring. - """ - return self._atoms[n].hybridization - - @cached_args_method - def heteroatoms(self, n: int) -> int: - """ - Number of neighbored heteroatoms (not carbon or hydrogen) except any-bond connected. - """ - return self._atoms[n].heteroatoms - - def implicit_hydrogens(self, n: int) -> Optional[int]: - """ - Number of implicit hydrogen atoms connected to atom. - - Returns None if count are ambiguous. - """ - return self._atoms[n].implicit_hydrogens - - def explicit_hydrogens(self, n: int) -> int: - """ - Number of explicit hydrogen atoms connected to atom. - - Take into account any type of bonds with hydrogen atoms. - """ - return self._atoms[n].explicit_hydrogens - - def total_hydrogens(self, n: int) -> int: - """ - Number of hydrogen atoms connected to atom. - - Take into account any type of bonds with hydrogen atoms. - """ - return self._atoms[n].total_hydrogens - @cached_args_method def adjacency_matrix(self, set_bonds=False, /): """ @@ -743,8 +697,7 @@ def _calc_implicit(self, n: int): """ Set firs possible hydrogens count based on rules """ - atoms = self._atoms - atom = atoms[n] + atom = self._atoms[n] if atom.atomic_number == 1: # hydrogen nether has implicit H atom._implicit_hydrogens = 0 return @@ -762,7 +715,7 @@ def _calc_implicit(self, n: int): return elif order != 8: # any bond used for complexes explicit_sum += order - explicit_dict[(order, atoms[m].atomic_number)] += 1 + explicit_dict[(order, self._atoms[m].atomic_number)] += 1 if aroma == 2: if explicit_sum == 0: # H-Ar @@ -794,8 +747,7 @@ def _calc_implicit(self, n: int): atom._implicit_hydrogens = None # rule not found def _check_implicit(self, n: int, h: int) -> bool: - atoms = self._atoms - atom = atoms[n] + atom = self._atoms[n] if atom.atomic_number == 1: # hydrogen nether has implicit H return h == 0 @@ -808,7 +760,7 @@ def _check_implicit(self, n: int, h: int) -> bool: return False elif order != 8: # any bond used for complexes explicit_sum += order - explicit_dict[(order, atoms[m].atomic_number)] += 1 + explicit_dict[(order, self._atoms[m].atomic_number)] += 1 try: rules = atom.valence_rules(explicit_sum) diff --git a/chython/files/_convert.py b/chython/files/_convert.py index 2de1ff2b..819389e1 100644 --- a/chython/files/_convert.py +++ b/chython/files/_convert.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2023 Ramil Nugmanov +# Copyright 2023, 2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -22,31 +22,27 @@ from ..periodictable import Element -def create_molecule(data, *, skip_calc_implicit=False, ignore_bad_isotopes=False, _cls=MoleculeContainer): - g = object.__new__(_cls) - pm = {} - atoms = {} - plane = {} - charges = {} - radicals = {} - bonds = {} +def create_molecule(data, *, ignore_bad_isotopes=False, skip_calc_implicit=False, + keep_implicit=False, keep_radicals=True, ignore_aromatic_radicals=True, ignore=True, + ignore_carbon_radicals=False, _cls=MoleculeContainer): + g = _cls() + atoms = g._atoms + bonds = g._bonds mapping = data['mapping'] for n, atom in enumerate(data['atoms']): + if abs(atom['charge']) > 4: + raise ValueError('formal charge should be in range [-4, 4]') n = mapping[n] - e = Element.from_symbol(atom['element']) + e = Element.from_symbol(atom.pop('element')) try: - atoms[n] = e(atom['isotope']) + atoms[n] = e(**atom) except ValueError: if not ignore_bad_isotopes: raise - atoms[n] = e() # reset isotope mark on errors. + del atom['isotope'] # reset isotope mark on errors. + atoms[n] = e(**atom) bonds[n] = {} - if (charge := atom['charge']) > 4 or charge < -4: - raise ValueError('formal charge should be in range [-4, 4]') - charges[n] = charge - radicals[n] = atom['is_radical'] - plane[n] = (atom['x'], atom['y']) - pm[n] = atom['mapping'] + for n, m, b in data['bonds']: n, m = mapping[n], mapping[m] if n == m: @@ -57,26 +53,108 @@ def create_molecule(data, *, skip_calc_implicit=False, ignore_bad_isotopes=False raise ValueError('atoms already bonded') bonds[n][m] = bonds[m][n] = Bond(b) if any(a['z'] for a in data['atoms']): - conformers = [{mapping[n]: (a['x'], a['y'], a['z']) for n, a in enumerate(data['atoms'])}] - else: - conformers = [] + # store conformer + g._conformers = [{mapping[n]: (a['x'], a['y'], a['z']) for n, a in enumerate(data['atoms'])}] if data['log']: # store log to the meta if data['meta'] is None: data['meta'] = {} data['meta']['chython_parsing_log'] = data['log'] + g._meta = data['meta'] - g.__setstate__({'atoms': atoms, 'bonds': bonds, 'meta': data['meta'], 'plane': plane, 'parsed_mapping': pm, - 'charges': charges, 'radicals': radicals, 'name': data['title'], 'conformers': conformers, - 'atoms_stereo': {}, 'allenes_stereo': {}, 'cis_trans_stereo': {}, 'hydrogens': {}}) - if not skip_calc_implicit: - for n in atoms: + if skip_calc_implicit: # don't calc Hs. e.g. INCHI + return g + + implicit_mismatch = {} + radicalized = [] + # precalculate Hs + for n, a in atoms.items(): + if a.implicit_hydrogens is None: + # let's try to calculate. in case of errors just keep as is. radicals in smiles should be in [brackets], + # thus has implicit Hs value g._calc_implicit(n) + elif keep_implicit: + # keep given Hs count as is + continue + else: # recheck given Hs count + h = a.implicit_hydrogens # parsed Hs + g._calc_implicit(n) # recalculate + if a.implicit_hydrogens is None: # atom has invalid valence or aromatic ring. + if a.hybridization == 4: + # this is aromatic ring. just restore given H count. + a._implicit_hydrogens = h + # rare H0 case + if (not keep_radicals and not ignore_aromatic_radicals + and not h and not a.charge and not a.is_radical and a.atomic_number in (5, 6, 7, 15) + and sum(b.order != 8 for b in bonds[n].values()) == 2): + # c[c]c - aromatic B,C,N,P radical + a._is_radical = True + radicalized.append(n) + elif not keep_radicals and not a.is_radical: # CXSMILES radical not set. + # SMILES doesn't code radicals. so, let's try to guess. + a._is_radical = True + if g._check_implicit(n, h): # radical form is valid + radicalized.append(n) + a._implicit_hydrogens = h + elif ignore: # radical state also has errors. + a._is_radical = False # reset radical state + implicit_mismatch[n] = h + data['log'].append(f'implicit hydrogen count ({h}) mismatch with calculated on atom {n}') + else: + raise ValueError(f'implicit hydrogen count ({h}) mismatch with calculated on atom {n}') + elif h != a.implicit_hydrogens: # H count mismatch. + if a.hybridization == 4: + if (not keep_radicals + and not h and not a.charge and not a.is_radical and a.atomic_number in (5, 6, 7, 15) + and sum(b.order != 8 for b in bonds[n].values()) == 2): + # c[c]c - aromatic B,C,N,P radical + a._implicit_hydrogens = 0 + a._is_radical = True + radicalized.append(n) + elif ignore: + implicit_mismatch[n] = h + data['log'].append(f'implicit hydrogen count ({h}) mismatch with calculated on atom {n}') + else: + raise ValueError(f'implicit hydrogen count ({h}) mismatch with calculated on atom {n}') + elif g._check_implicit(n, h): # set another possible implicit state. probably Al, P + a._implicit_hydrogens = h + elif not keep_radicals and not a.is_radical: # CXSMILES radical is not set. try radical form + a._is_radical = True + if g._check_implicit(n, h): + a._implicit_hydrogens = h + radicalized.append(n) + # radical state also has errors. + elif ignore: + a._is_radical = False # reset radical state + implicit_mismatch[n] = h + data['log'].append(f'implicit hydrogen count ({h}) mismatch with calculated on atom {n}') + else: + raise ValueError(f'implicit hydrogen count ({h}) mismatch with calculated on atom {n}') + elif ignore: # just ignore it + implicit_mismatch[n] = h + data['log'].append(f'implicit hydrogen count ({h}) mismatch with calculated on atom {n}') + else: + raise ValueError(f'implicit hydrogen count ({h}) mismatch with calculated on atom {n}') + + if ignore_carbon_radicals: + for n in radicalized: + a = atoms[n] + if a.atomic_number == 6: + a._is_radical = False + a._implicit_hydrogens += 1 + data['log'].append(f'carbon radical {n} replaced with implicit hydrogen') + elif radicalized: + g.meta['chython_radicalized_atoms'] = radicalized + if data['log'] and 'chython_parsing_log' not in g.meta: + g.meta['chython_parsing_log'] = data['log'] + if implicit_mismatch: + g.meta['chython_implicit_mismatch'] = implicit_mismatch return g def create_reaction(data, *, ignore=True, skip_calc_implicit=False, ignore_bad_isotopes=False, - _r_cls=ReactionContainer, _m_cls=MoleculeContainer): + keep_implicit=False, keep_radicals=True, ignore_aromatic_radicals=True, + ignore_carbon_radicals=False, _r_cls=ReactionContainer, _m_cls=MoleculeContainer): rc, pr, rg = [], [], [] for ms, pms, gr in ((rc, data['reactants'], 'reactant'), (pr, data['products'], 'products'), @@ -85,7 +163,10 @@ def create_reaction(data, *, ignore=True, skip_calc_implicit=False, ignore_bad_i for n, m in enumerate(pms): try: ms.append(create_molecule(m, skip_calc_implicit=skip_calc_implicit, - ignore_bad_isotopes=ignore_bad_isotopes, _cls=_m_cls)) + ignore_bad_isotopes=ignore_bad_isotopes, keep_implicit=keep_implicit, + keep_radicals=keep_radicals, + ignore_aromatic_radicals=ignore_aromatic_radicals, ignore=ignore, + ignore_carbon_radicals=ignore_carbon_radicals, _cls=_m_cls)) except ValueError as e: if not ignore: raise diff --git a/chython/files/daylight/smiles.py b/chython/files/daylight/smiles.py index 2271a052..d491c866 100644 --- a/chython/files/daylight/smiles.py +++ b/chython/files/daylight/smiles.py @@ -143,11 +143,12 @@ def smiles(data, /, *, ignore: bool = True, remap: bool = False, ignore_stereo: atom_map[x]['is_radical'] = True postprocess_parsed_reaction(record, remap=remap, ignore=ignore) - rxn = create_reaction(record, ignore_bad_isotopes=ignore_bad_isotopes, _r_cls=_r_cls, _m_cls=_m_cls) + rxn = create_reaction(record, ignore_bad_isotopes=ignore_bad_isotopes, keep_radicals=False, + ignore_carbon_radicals=ignore_carbon_radicals, keep_implicit=keep_implicit, + ignore_aromatic_radicals=ignore_aromatic_radicals, ignore=ignore, + _r_cls=_r_cls, _m_cls=_m_cls) for mol, tmp in zip(rxn.molecules(), chain(record['reactants'], record['reagents'], record['products'])): - postprocess_molecule(mol, tmp, ignore=ignore, ignore_stereo=ignore_stereo, - ignore_carbon_radicals=ignore_carbon_radicals, keep_implicit=keep_implicit, - ignore_aromatic_radicals=ignore_aromatic_radicals) + postprocess_molecule(mol, tmp, ignore_stereo=ignore_stereo) return rxn else: record = parser(smiles_tokenize(smi), not ignore) @@ -156,104 +157,17 @@ def smiles(data, /, *, ignore: bool = True, remap: bool = False, ignore_stereo: record['log'].extend(log) postprocess_parsed_molecule(record, remap=remap, ignore=ignore) - mol = create_molecule(record, ignore_bad_isotopes=ignore_bad_isotopes, _cls=_m_cls) - postprocess_molecule(mol, record, ignore=ignore, ignore_stereo=ignore_stereo, - ignore_carbon_radicals=ignore_carbon_radicals, keep_implicit=keep_implicit, - ignore_aromatic_radicals=ignore_aromatic_radicals) + mol = create_molecule(record, ignore_bad_isotopes=ignore_bad_isotopes, keep_radicals=False, + ignore_carbon_radicals=ignore_carbon_radicals, keep_implicit=keep_implicit, + ignore_aromatic_radicals=ignore_aromatic_radicals, ignore=ignore, + _cls=_m_cls) + postprocess_molecule(mol, record, ignore_stereo=ignore_stereo) return mol -def postprocess_molecule(molecule, data, *, ignore=True, ignore_stereo=False, ignore_carbon_radicals=False, - keep_implicit=False, ignore_aromatic_radicals=True): +def postprocess_molecule(molecule, data, *, ignore_stereo=False): mapping = data['mapping'] - atoms = molecule._atoms - bonds = molecule._bonds - charges = molecule._charges - hydrogens = molecule._hydrogens - radicals = molecule._radicals - hyb = molecule.hybridization - radicalized = [] - - implicit_mismatch = {} - if 'chython_parsing_log' in molecule.meta: - log = molecule.meta['chython_parsing_log'] - else: - log = [] - - for n, a in enumerate(data['atoms']): - h = a['hydrogen'] - if h is None: # simple atom token - continue - # bracket token should always contain implicit hydrogens count. - n = mapping[n] - if keep_implicit: # override any calculated hydrogens count. - hydrogens[n] = h - elif (hc := hydrogens[n]) is None: # atom has invalid valence or aromatic ring. - if hyb(n) == 4: # this is aromatic rings. just store given H count. - hydrogens[n] = h - # rare H0 case - if (not ignore_aromatic_radicals and not h and not charges[n] and not radicals[n] and - atoms[n].atomic_number in (5, 6, 7, 15) and sum(b.order != 8 for b in bonds[n].values()) == 2): - # c[c]c - aromatic B,C,N,P radical - radicals[n] = True - radicalized.append(n) - elif not radicals[n]: # CXSMILES radical not set. - # SMILES doesn't code radicals. so, let's try to guess. - radicals[n] = True - if molecule._check_implicit(n, h): # radical form is valid - radicalized.append(n) - hydrogens[n] = h - elif ignore: # radical state also has errors. - radicals[n] = False # reset radical state - implicit_mismatch[n] = h - log.append(f'implicit hydrogen count ({h}) mismatch with calculated on atom {n}') - else: - raise ValueError(f'implicit hydrogen count ({h}) mismatch with calculated on atom {n}') - elif hc != h: # H count mismatch. - if hyb(n) == 4: - if not h and not charges[n] and not radicals[n] and atoms[n].atomic_number in (5, 6, 7, 15) and \ - sum(b.order != 8 for b in bonds[n].values()) == 2: - # c[c]c - aromatic B,C,N,P radical - hydrogens[n] = 0 - radicals[n] = True - radicalized.append(n) - elif ignore: - implicit_mismatch[n] = h - log.append(f'implicit hydrogen count ({h}) mismatch with calculated on atom {n}') - else: - raise ValueError(f'implicit hydrogen count ({h}) mismatch with calculated on atom {n}') - elif molecule._check_implicit(n, h): # set another possible implicit state. probably Al, P - hydrogens[n] = h - elif not radicals[n]: # CXSMILES radical is not set. try radical form - radicals[n] = True - if molecule._check_implicit(n, h): - hydrogens[n] = h - radicalized.append(n) - # radical state also has errors. - elif ignore: - radicals[n] = False # reset radical state - implicit_mismatch[n] = h - log.append(f'implicit hydrogen count ({h}) mismatch with calculated on atom {n}') - else: - raise ValueError(f'implicit hydrogen count ({h}) mismatch with calculated on atom {n}') - elif ignore: # just ignore it - implicit_mismatch[n] = h - log.append(f'implicit hydrogen count ({h}) mismatch with calculated on atom {n}') - else: - raise ValueError(f'implicit hydrogen count ({h}) mismatch with calculated on atom {n}') - - if ignore_carbon_radicals: - for n in radicalized: - if atoms[n].atomic_number == 6: - radicals[n] = False - hydrogens[n] += 1 - log.append(f'carbon radical {n} replaced with implicit hydrogen') - - if implicit_mismatch: - molecule.meta['chython_implicit_mismatch'] = implicit_mismatch - if log and 'chython_parsing_log' not in molecule.meta: - molecule.meta['chython_parsing_log'] = log if ignore_stereo: return diff --git a/chython/files/daylight/tokenize.py b/chython/files/daylight/tokenize.py index 645d87e9..6bf1eb8c 100644 --- a/chython/files/daylight/tokenize.py +++ b/chython/files/daylight/tokenize.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2022, 2023 Ramil Nugmanov +# Copyright 2022-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -16,7 +16,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from re import compile, fullmatch, match, search +from re import compile, match, search from .._mdl import common_isotopes from ...containers.bonds import QueryBond from ...exceptions import IncorrectSmiles, IncorrectSmarts @@ -243,7 +243,7 @@ def _tokenize(smiles): def _atom_parse(token): # [isotope]Element[element][@[@]][H[n]][+-charge][:mapping] - _match = fullmatch(atom_re, token) + _match = atom_re.fullmatch(token) if _match is None: raise IncorrectSmiles(f'atom token invalid {token}') isotope, element, stereo, hydrogen, charge, mapping = _match.groups() @@ -275,16 +275,14 @@ def _atom_parse(token): mapping = int(mapping[1:]) except ValueError: raise IncorrectSmiles('invalid mapping token') - else: - mapping = 0 if element in ('c', 'n', 'o', 'p', 's', 'as', 'se', 'b', 'te'): _type = 8 element = element.capitalize() else: _type = 0 - return _type, {'element': element, 'isotope': isotope, 'mapping': mapping, 'charge': charge, 'is_radical': False, - 'x': 0., 'y': 0., 'z': 0., 'hydrogen': hydrogen, 'stereo': stereo} + return _type, {'element': element, 'isotope': isotope, 'parsed_mapping': mapping, 'charge': charge, + 'implicit_hydrogens': hydrogen, 'stereo': stereo} def _query_parse(token): @@ -372,8 +370,7 @@ def smiles_tokenize(smi): out = [] for token_type, token in tokens: if token_type in (0, 8): # simple atom - out.append((token_type, {'element': token, 'isotope': None, 'mapping': 0, 'charge': 0, 'is_radical': False, - 'x': 0., 'y': 0., 'z': 0., 'hydrogen': None, 'stereo': None})) + out.append((token_type, {'element': token})) elif token_type == 5: out.append(_atom_parse(token)) elif token_type == 10: diff --git a/chython/periodictable/base/element.py b/chython/periodictable/base/element.py index 943d1128..04deaba2 100644 --- a/chython/periodictable/base/element.py +++ b/chython/periodictable/base/element.py @@ -25,11 +25,14 @@ class Element(ABC): __slots__ = ('_isotope', '_charge', '_is_radical', '_x', '_y', '_implicit_hydrogens', - '_explicit_hydrogens', '_stereo', '_parsed_mapping', '_xyz', + '_explicit_hydrogens', '_stereo', '_parsed_mapping', '_neighbors', '_heteroatoms', '_hybridization', '_ring_sizes', '_in_ring') __class_cache__ = {} - def __init__(self, isotope: Optional[int] = None): + def __init__(self, isotope: Optional[int] = None, *, + charge: int = 0, is_radical: bool = False, x: float = 0, y: float = 0, + implicit_hydrogens: Optional[int] = None, stereo: Optional[bool] = None, + parsed_mapping: Optional[int] = None): """ Element object with specified isotope @@ -41,11 +44,12 @@ def __init__(self, isotope: Optional[int] = None): elif isotope is not None: raise TypeError('integer isotope number required') self._isotope = isotope - self._charge = 0 - self._is_radical = False - self._x = self._y = 0 - self._implicit_hydrogens = None - self._stereo = None + self._charge = charge + self._is_radical = is_radical + self._x, self._y = x, y + self._implicit_hydrogens = implicit_hydrogens + self._stereo = stereo + self._parsed_mapping = parsed_mapping self._explicit_hydrogens = 0 self._neighbors = 0 From e8a4986e65b0315fb8a84f77fe802753590d08f1 Mon Sep 17 00:00:00 2001 From: stsouko Date: Sat, 2 Nov 2024 20:09:07 +0100 Subject: [PATCH 09/67] some progress in stereo --- chython/algorithms/isomorphism.py | 2 +- chython/algorithms/stereo/graph.py | 96 ++++++++++++++------------- chython/algorithms/stereo/molecule.py | 18 ++--- chython/files/_convert.py | 8 +-- chython/files/_mapping.py | 8 +-- chython/files/daylight/smarts.py | 19 +++--- chython/files/daylight/smiles.py | 9 ++- chython/files/daylight/tokenize.py | 57 +++++----------- chython/files/libinchi/wrapper.py | 4 +- chython/periodictable/base/element.py | 25 ++++--- chython/periodictable/base/query.py | 55 +++++++++------ 11 files changed, 151 insertions(+), 150 deletions(-) diff --git a/chython/algorithms/isomorphism.py b/chython/algorithms/isomorphism.py index e2d95da3..a40188a6 100644 --- a/chython/algorithms/isomorphism.py +++ b/chython/algorithms/isomorphism.py @@ -361,7 +361,7 @@ def _cython_compiled_query(self): else: if isinstance(a, ListElement): v1 = v2 = 0 - for n in a._numbers: + for n in a.atomic_numbers: if n > 56: if n > 116: # Ts, Og n = 116 diff --git a/chython/algorithms/stereo/graph.py b/chython/algorithms/stereo/graph.py index 6fe91b76..bb7e5ebb 100644 --- a/chython/algorithms/stereo/graph.py +++ b/chython/algorithms/stereo/graph.py @@ -70,13 +70,10 @@ def tetrahedrons(self: 'Container') -> Tuple[int, ...]: """ Carbon sp3 atoms numbers. """ - atoms = self._atoms - bonds = self._bonds - tetra = [] - for n, atom in atoms.items(): + for n, atom in self._atoms.items(): if atom.atomic_number == 6 and not atom.charge and not atom.is_radical: - env = bonds[n] + env = self._bonds[n] if all(int(x) == 1 for x in env.values()): if sum(int(x) for x in env.values()) > 4: continue @@ -157,14 +154,15 @@ def _translate_tetrahedron_sign(self: 'Container', n, env, s=None): """ if s is None: s = self._atoms[n].stereo + if s is None: + raise KeyError order = self._stereo_tetrahedrons[n] if len(order) == 3: if len(env) == 4: # hydrogen atom passed to env - atoms = self._atoms # hydrogen always last in order try: - order = (*order, next(x for x in env if atoms[x].atomic_number == 1)) # see translate scheme + order = (*order, next(x for x in env if self._atoms[x].atomic_number == 1)) # see translate scheme except StopIteration: raise KeyError elif len(env) != 3: # pyramid or tetrahedron expected @@ -187,21 +185,24 @@ def _translate_cis_trans_sign(self: 'Container', n, m, nn, nm, s=None): :param nm: neighbor of last atom :param s: if None, use existing sign else translate given to molecule """ + try: + n0, n1, n2, n3 = self._stereo_cis_trans[(n, m)] + except KeyError: + n0, n1, n2, n3 = self._stereo_cis_trans[(m, n)] + n, m = m, n # in alkenes sign not order depended + nn, nm = nm, nn + if s is None: - try: - s = self._cis_trans_stereo[(n, m)] - except KeyError: - s = self._cis_trans_stereo[(m, n)] - n, m = m, n # in alkenes sign not order depended - nn, nm = nm, nn + i, j = self._stereo_cis_trans_centers[n] + s = self._bonds[i][j].stereo + if s is None: + raise KeyError - atoms = self._atoms - n0, n1, n2, n3 = self._stereo_cis_trans[(n, m)] if nn == n0: # same start t0 = 0 if nm == n1: t1 = 1 - elif nm == n3 or n3 is None and atoms[nm].atomic_number == 1: + elif nm == n3 or n3 is None and self._atoms[nm].atomic_number == 1: t1 = 3 else: raise KeyError @@ -209,23 +210,23 @@ def _translate_cis_trans_sign(self: 'Container', n, m, nn, nm, s=None): t0 = 1 if nm == n0: t1 = 0 - elif nm == n2 or n2 is None and atoms[nm].atomic_number == 1: + elif nm == n2 or n2 is None and self._atoms[nm].atomic_number == 1: t1 = 2 else: raise KeyError - elif nn == n2 or n2 is None and atoms[nn].atomic_number == 1: + elif nn == n2 or n2 is None and self._atoms[nn].atomic_number == 1: t0 = 2 if nm == n1: t1 = 1 - elif nm == n3 or n3 is None and atoms[nm].atomic_number == 1: + elif nm == n3 or n3 is None and self._atoms[nm].atomic_number == 1: t1 = 3 else: raise KeyError - elif nn == n3 or n3 is None and atoms[nn].atomic_number == 1: + elif nn == n3 or n3 is None and self._atoms[nn].atomic_number == 1: t0 = 3 if nm == n0: t1 = 0 - elif nm == n2 or n2 is None and atoms[nm].atomic_number == 1: + elif nm == n2 or n2 is None and self._atoms[nm].atomic_number == 1: t1 = 2 else: raise KeyError @@ -246,15 +247,16 @@ def _translate_allene_sign(self: 'Container', c, nn, nm, s=None): :param s: if None, use existing sign else translate given to molecule """ if s is None: - s = self._allenes_stereo[c] + s = self._atoms[c].stereo + if s is None: + raise KeyError - atoms = self._atoms n0, n1, n2, n3 = self._stereo_allenes[c] if nn == n0: # same start t0 = 0 if nm == n1: t1 = 1 - elif nm == n3 or n3 is None and atoms[nm].atomic_number == 1: + elif nm == n3 or n3 is None and self._atoms[nm].atomic_number == 1: t1 = 3 else: raise KeyError @@ -262,23 +264,23 @@ def _translate_allene_sign(self: 'Container', c, nn, nm, s=None): t0 = 1 if nm == n0: t1 = 0 - elif nm == n2 or n2 is None and atoms[nm].atomic_number == 1: + elif nm == n2 or n2 is None and self._atoms[nm].atomic_number == 1: t1 = 2 else: raise KeyError - elif nn == n2 or n2 is None and atoms[nn].atomic_number == 1: + elif nn == n2 or n2 is None and self._atoms[nn].atomic_number == 1: t0 = 2 if nm == n1: t1 = 1 - elif nm == n3 or n3 is None and atoms[nm].atomic_number == 1: + elif nm == n3 or n3 is None and self._atoms[nm].atomic_number == 1: t1 = 3 else: raise KeyError - elif nn == n3 or n3 is None and atoms[nn].atomic_number == 1: + elif nn == n3 or n3 is None and self._atoms[nn].atomic_number == 1: t0 = 3 if nm == n0: t1 = 0 - elif nm == n2 or n2 is None and atoms[nm].atomic_number == 1: + elif nm == n2 or n2 is None and self._atoms[nm].atomic_number == 1: t1 = 2 else: raise KeyError @@ -388,21 +390,25 @@ def _stereo_cis_trans(self) -> Dict[Tuple[int, int], Tuple[int, int, Optional[in """ Cis-trans bonds which contains at least one non-hydrogen neighbor on both ends """ - return {(n, m): env for (n, *mid, m), env in self._stereo_cumulenes.items() if not len(mid) % 2} - - @cached_property - def _stereo_cis_trans_paths(self) -> Dict[Tuple[int, int], Tuple[int, ...]]: - return {(path[0], path[-1]): path for path in self._stereo_cumulenes if not len(path) % 2} + stereo = {} + for path, env in self._stereo_cumulenes.items(): + if len(path) % 2: + continue + stereo[(path[0], path[-1])] = env + return stereo @cached_property - def _stereo_cis_trans_terminals(self) -> Dict[int, Tuple[int, int]]: + def _stereo_cis_trans_centers(self) -> Dict[int, Tuple[int, int]]: """ - Cis-Trans terminal atoms to cis-trans key mapping + Cis-Trans terminal atoms to cis-trans key mapping. Key is central double bond in a cumulene chain. """ terminals = {} - for nm in self._stereo_cis_trans_paths: - n, m = nm - terminals[n] = terminals[m] = nm + for path in self._stereo_cumulenes: + if len(path) % 2: + continue + n, m = path[0], path[-1] + i = len(path) // 2 + terminals[n] = terminals[m] = (path[i - 1], path[i]) return terminals @cached_property @@ -411,8 +417,10 @@ def _stereo_cis_trans_counterpart(self) -> Dict[int, int]: Cis-Trans terminal atoms counterparts """ counterpart = {} - for nm in self._stereo_cis_trans_paths: - n, m = nm + for path in self._stereo_cumulenes: + if len(path) % 2: + continue + n, m = path[0], path[-1] counterpart[n] = m counterpart[m] = n return counterpart @@ -439,11 +447,7 @@ def _stereo_allenes_terminals(self) -> Dict[int, Tuple[int, int]]: """ Allene center atom to terminals mapping """ - return {c: (path[0], path[-1]) for c, path in self._stereo_allenes_paths.items()} - - @cached_property - def _stereo_allenes_paths(self) -> Dict[int, Tuple[int, ...]]: - return {path[len(path) // 2]: path for path in self._stereo_cumulenes if len(path) % 2} + return {path[len(path) // 2]: (path[0], path[-1]) for path in self._stereo_cumulenes if len(path) % 2} __all__ = ['Stereo'] diff --git a/chython/algorithms/stereo/molecule.py b/chython/algorithms/stereo/molecule.py index 016df003..7c443a0b 100644 --- a/chython/algorithms/stereo/molecule.py +++ b/chython/algorithms/stereo/molecule.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2019-2023 Ramil Nugmanov +# Copyright 2019-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -434,11 +434,9 @@ def _wedge_map(self: Union['MoleculeContainer', 'MoleculeStereo']): return solved def __wedge_sign(self: 'MoleculeContainer', order): - plane = self._plane - if order[-1]: # allene s = self._translate_allene_sign(order[-2], *order[:2]) - v = _allene_sign(1, plane[order[2]], plane[order[3]], plane[order[1]]) + v = _allene_sign(1, self._atoms[order[2]].xy, self._atoms[order[3]].xy, self._atoms[order[1]].xy) if not v: logger.info(f'need 2d clean. allenes wedge stereo ambiguous for atom {order[-2]}') if s: @@ -450,11 +448,15 @@ def __wedge_sign(self: 'MoleculeContainer', order): s = self._translate_tetrahedron_sign(n, order[:-2]) # need recalculation if XY changed if len(order) == 5: - v = _pyramid_sign((*plane[n], 0), - (*plane[order[0]], 1), (*plane[order[1]], 0), (*plane[order[2]], 0)) + v = _pyramid_sign((*self._atoms[n].xy, 0), + (*self._atoms[order[0]].xy, 1), + (*self._atoms[order[1]].xy, 0), + (*self._atoms[order[2]].xy, 0)) else: - v = _pyramid_sign((*plane[order[3]], 0), - (*plane[order[0]], 1), (*plane[order[1]], 0), (*plane[order[2]], 0)) + v = _pyramid_sign((*self._atoms[order[3]].xy, 0), + (*self._atoms[order[0]].xy, 1), + (*self._atoms[order[1]].xy, 0), + (*self._atoms[order[2]].xy, 0)) if not v: logger.info(f'need 2d clean. tetrahedron wedge stereo ambiguous for atom {n}') if s: diff --git a/chython/files/_convert.py b/chython/files/_convert.py index 819389e1..a450146e 100644 --- a/chython/files/_convert.py +++ b/chython/files/_convert.py @@ -30,16 +30,14 @@ def create_molecule(data, *, ignore_bad_isotopes=False, skip_calc_implicit=False bonds = g._bonds mapping = data['mapping'] for n, atom in enumerate(data['atoms']): - if abs(atom['charge']) > 4: - raise ValueError('formal charge should be in range [-4, 4]') n = mapping[n] e = Element.from_symbol(atom.pop('element')) try: atoms[n] = e(**atom) - except ValueError: + except (ValueError, TypeError): if not ignore_bad_isotopes: raise - del atom['isotope'] # reset isotope mark on errors. + del atom['isotope'] # reset isotope mark on errors and try again. atoms[n] = e(**atom) bonds[n] = {} @@ -52,7 +50,7 @@ def create_molecule(data, *, ignore_bad_isotopes=False, skip_calc_implicit=False if n in bonds[m]: raise ValueError('atoms already bonded') bonds[n][m] = bonds[m][n] = Bond(b) - if any(a['z'] for a in data['atoms']): + if any(a.get('z') for a in data['atoms']): # store conformer g._conformers = [{mapping[n]: (a['x'], a['y'], a['z']) for n, a in enumerate(data['atoms'])}] diff --git a/chython/files/_mapping.py b/chython/files/_mapping.py index e8d5915c..331eaa3e 100644 --- a/chython/files/_mapping.py +++ b/chython/files/_mapping.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2014-2023 Ramil Nugmanov +# Copyright 2014-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -24,10 +24,10 @@ def postprocess_parsed_molecule(data, *, remap=False, ignore=True): if remap: remapped = list(range(1, len(data['atoms']) + 1)) else: - length = count(max(x['mapping'] for x in data['atoms']) + 1) + length = count(max(x.get('parsed_mapping') or 0 for x in data['atoms']) + 1) remapped, used = [], set() for n, atom in enumerate(data['atoms']): - m = atom['mapping'] + m = atom.get('parsed_mapping') if not m: remapped.append(next(length)) elif m in used: @@ -47,7 +47,7 @@ def postprocess_parsed_reaction(data, *, remap=False, ignore=True): for molecule in data[i]: used = set() for atom in molecule['atoms']: - m = atom['mapping'] + m = atom.get('parsed_mapping') if m: if m in used: if not ignore: diff --git a/chython/files/daylight/smarts.py b/chython/files/daylight/smarts.py index 2885b8a2..4f095e03 100644 --- a/chython/files/daylight/smarts.py +++ b/chython/files/daylight/smarts.py @@ -21,7 +21,7 @@ from .parser import parser from .tokenize import smarts_tokenize from ...containers import QueryContainer -from ...periodictable import QueryElement +from ...periodictable import ListElement, QueryElement cx_radicals = compile(r'\^[1-7]:[0-9]+(?:,[0-9]+)*') @@ -104,16 +104,17 @@ def smarts(data: str): g = QueryContainer() mapping = {} - free = count(max(a['mapping'] for a in data['atoms']) + 1) + free = count(max(a.get('parsed_mapping', 0) for a in data['atoms']) + 1) for i, a in enumerate(data['atoms']): - mapping[i] = n = a.pop('mapping') or next(global_free_masked if a['masked'] else free) + mapping[i] = n = a.pop('parsed_mapping', 0) or next(global_free_masked if a.get('masked') else free) e = a.pop('element') - if it := a.pop('isotope'): - if isinstance(e, int): - e = QueryElement.from_atomic_number(e)(it) - else: - e = QueryElement.from_symbol(e)(it) - g.add_atom(e, n, **a) + if isinstance(e, int): + e = QueryElement.from_atomic_number(e) + elif isinstance(e, str): + e = QueryElement.from_symbol(e) + else: + e = ListElement(e) + g.add_atom(e(**a), n) for n, m, b in data['bonds']: g.add_bond(mapping[n], mapping[m], b) diff --git a/chython/files/daylight/smiles.py b/chython/files/daylight/smiles.py index d491c866..82687724 100644 --- a/chython/files/daylight/smiles.py +++ b/chython/files/daylight/smiles.py @@ -171,14 +171,14 @@ def postprocess_molecule(molecule, data, *, ignore_stereo=False): if ignore_stereo: return - stereo_atoms = [(n, s) for n, a in enumerate(data['atoms']) if (s := a['stereo']) is not None] + stereo_atoms = [(n, s) for n, a in enumerate(data['atoms']) if (s := a.get('stereo')) is not None] if not stereo_atoms and not data['stereo_bonds']: return st = molecule._stereo_tetrahedrons sa = molecule._stereo_allenes sat = molecule._stereo_allenes_terminals - ctt = molecule._stereo_cis_trans_terminals + ctc = molecule._stereo_cis_trans_counterpart order = {mapping[n]: [mapping[m] for m in ms] for n, ms in data['order'].items()} @@ -203,9 +203,8 @@ def postprocess_molecule(molecule, data, *, ignore_stereo=False): for n, ns in stereo_bonds.items(): if n in seen: continue - if n in ctt: - nm = ctt[n] - m = nm[1] if nm[0] == n else nm[0] + if n in ctc: + m = ctc[n] if m in stereo_bonds: seen.add(m) n2, s2 = stereo_bonds[m].popitem() diff --git a/chython/files/daylight/tokenize.py b/chython/files/daylight/tokenize.py index 6bf1eb8c..e8f3c7e6 100644 --- a/chython/files/daylight/tokenize.py +++ b/chython/files/daylight/tokenize.py @@ -17,10 +17,8 @@ # along with this program; if not, see . # from re import compile, match, search -from .._mdl import common_isotopes from ...containers.bonds import QueryBond from ...exceptions import IncorrectSmiles, IncorrectSmarts -from ...periodictable.element import ListElement # -,= OR bonds supported @@ -49,7 +47,6 @@ # 12: in ring bond -atomic_numbers = dict(enumerate(common_isotopes, 1)) iso_re = compile(r'^[0-9]+') chg_re = compile(r'[+-][1-4+-]?') mpp_re = compile(r':[1-9][0-9]*$') @@ -286,19 +283,18 @@ def _atom_parse(token): def _query_parse(token): + out = {} if isotope := match(iso_re, token): token = token[isotope.end():] # remove isotope substring - isotope = int(isotope.group()) + out['isotope'] = int(isotope.group()) if charge := search(chg_re, token): token = token[:charge.start()] + token[charge.end():] # remove charge substring - charge = charge_dict[charge.group()] - else: - charge = 0 + out['charge'] = charge_dict[charge.group()] + if mapping := search(mpp_re, token): token = token[:mapping.start()] - mapping = int(mapping.group()[1:]) - else: - mapping = 0 + out['parsed_mapping'] = int(mapping.group()[1:]) + if stereo := search(str_re, token): # drop stereo mark. unsupported token = token[:stereo.start()] + token[stereo.end():] @@ -308,35 +304,21 @@ def _query_parse(token): element = [int(x[1:]) if x.startswith('#') else x for x in element.split(',')] if len(element) == 1: element = element[0] - else: # only atoms supported - tmp = [] - for x in element: - if isinstance(x, int): - try: - tmp.append(atomic_numbers[x]) - except KeyError as e: - raise IncorrectSmiles('Invalid atomic number') from e - elif x in common_isotopes: - tmp.append(x) - else: - raise IncorrectSmarts('Invalid element symbol') - element = ListElement(tmp) else: raise IncorrectSmarts('Empty element') + out['element'] = element - hybridization = rings_sizes = neighbors = hydrogens = heteroatoms = None - masked = False for p in primitives[1:]: # parse hydrogens (h), neighbors (D), rings_sizes (r or !R), hybridization == 4 (a) if not p: continue elif p == 'a': # aromatic atom - hybridization = 4 + out['hybridization'] = 4 elif p == 'A': # ignore aliphatic mark. Ad-Hoc for Marwin. continue elif p == '!R': - rings_sizes = 0 + out['ring_sizes'] = 0 elif p == 'M': - masked = True + out['masked'] = True else: p = p.split(',') if len(p) != 1 and len({x[0] for x in p}) > 1: @@ -350,19 +332,16 @@ def _query_parse(token): raise IncorrectSmarts('Unsupported SMARTS primitive') if t == 'D': - neighbors = p + out['neighbors'] = p elif t == 'h': - hydrogens = p + out['implicit_hydrogens'] = p elif t == 'r': # r - rings_sizes = p + out['ring_sizes'] = p elif t == 'x': - heteroatoms = p + out['heteroatoms'] = p else: # z - hybridization = p - - return 0, {'element': element, 'isotope': isotope, 'mapping': mapping, 'charge': charge, 'is_radical': False, - 'heteroatoms': heteroatoms, 'hydrogens': hydrogens, 'neighbors': neighbors, - 'rings_sizes': rings_sizes, 'hybridization': hybridization, 'masked': masked} + out['hybridization'] = p + return 0, out def smiles_tokenize(smi): @@ -385,9 +364,7 @@ def smarts_tokenize(smi): out = [] for token_type, token in tokens: if token_type in (0, 8): # simple atom - out.append((0, {'element': token, 'isotope': None, 'mapping': 0, 'charge': 0, 'is_radical': False, - 'heteroatoms': None, 'hydrogens': None, 'neighbors': None, - 'rings_sizes': None, 'hybridization': None, 'masked': False})) + out.append((0, {'element': token})) elif token_type == 5: out.append(_query_parse(token)) else: diff --git a/chython/files/libinchi/wrapper.py b/chython/files/libinchi/wrapper.py index 0fb7daf3..55749f34 100644 --- a/chython/files/libinchi/wrapper.py +++ b/chython/files/libinchi/wrapper.py @@ -138,7 +138,7 @@ def postprocess_molecule(molecule, data, *, ignore_stereo=False): st = molecule._stereo_tetrahedrons sa = molecule._stereo_allenes - ctt = molecule._stereo_cis_trans_terminals + ctc = molecule._stereo_cis_trans_counterpart stereo = [] for n, ngb, s in data['stereo_atoms']: @@ -151,7 +151,7 @@ def postprocess_molecule(molecule, data, *, ignore_stereo=False): stereo.append((molecule.add_atom_stereo, n, nn + 1, mn + 1, s)) for n, m, nn, nm, s in data['stereo_cumulenes']: n += 1 - if n in ctt: + if n in ctc: stereo.append((molecule.add_cis_trans_stereo, n, m + 1, nn + 1, nm + 1, s)) while stereo: diff --git a/chython/periodictable/base/element.py b/chython/periodictable/base/element.py index 04deaba2..56e9f3d3 100644 --- a/chython/periodictable/base/element.py +++ b/chython/periodictable/base/element.py @@ -30,7 +30,7 @@ class Element(ABC): __class_cache__ = {} def __init__(self, isotope: Optional[int] = None, *, - charge: int = 0, is_radical: bool = False, x: float = 0, y: float = 0, + charge: int = 0, is_radical: bool = False, x: float = 0., y: float = 0., implicit_hydrogens: Optional[int] = None, stereo: Optional[bool] = None, parsed_mapping: Optional[int] = None): """ @@ -38,15 +38,11 @@ def __init__(self, isotope: Optional[int] = None, *, :param isotope: Isotope number of element """ - if isinstance(isotope, int): - if isotope not in self.isotopes_distribution: - raise ValueError(f'isotope number {isotope} impossible or not stable for {self.atomic_symbol}') - elif isotope is not None: - raise TypeError('integer isotope number required') - self._isotope = isotope - self._charge = charge - self._is_radical = is_radical - self._x, self._y = x, y + self.isotope = isotope + self.charge = charge + self.is_radical = is_radical + self.x, self.y = x, y + self._implicit_hydrogens = implicit_hydrogens self._stereo = stereo self._parsed_mapping = parsed_mapping @@ -81,6 +77,15 @@ def isotope(self) -> Optional[int]: """ return self._isotope + @isotope.setter + def isotope(self, value: Optional[int]): + if isinstance(value, int): + if value not in self.isotopes_distribution: + raise ValueError(f'isotope number {value} impossible or not stable for {self.atomic_symbol}') + elif value is not None: + raise TypeError('integer isotope number required') + self._isotope = value + @property def atomic_mass(self) -> float: mass = self.isotopes_masses diff --git a/chython/periodictable/base/query.py b/chython/periodictable/base/query.py index 325c0947..2089bc17 100644 --- a/chython/periodictable/base/query.py +++ b/chython/periodictable/base/query.py @@ -49,10 +49,11 @@ def _validate(value, prop): class Query(ABC): __slots__ = ('_neighbors', '_hybridization', '_masked') - def __init__(self): - self._neighbors = () - self._hybridization = () - self._masked = False + def __init__(self, neighbors: Union[int, Tuple[int, ...], None] = None, + hybridization: Union[int, Tuple[int, ...], None] = None, masked: bool = False): + self.neighbors = neighbors + self.hybridization = hybridization + self.masked = masked @property @abstractmethod @@ -118,14 +119,16 @@ def __repr__(self): class ExtendedQuery(Query, ABC): __slots__ = ('_charge', '_is_radical', '_heteroatoms', '_ring_sizes', '_implicit_hydrogens', '_stereo') - def __init__(self): - super().__init__() - self._charge = 0 - self._is_radical = False - self._heteroatoms = () - self._ring_sizes = () - self._implicit_hydrogens = () - self._stereo = None + def __init__(self, charge: int = 0, is_radical: bool = False, heteroatoms: Union[int, Tuple[int, ...], None] = None, + ring_sizes: Union[int, Tuple[int, ...], None] = None, + implicit_hydrogens: Union[int, Tuple[int, ...], None] = None, stereo: Optional[bool] = None, **kwargs): + super().__init__(**kwargs) + self.charge = charge + self.is_radical = is_radical + self.heteroatoms = heteroatoms + self.ring_sizes = ring_sizes + self.implicit_hydrogens = implicit_hydrogens + self._stereo = stereo @property def charge(self) -> int: @@ -292,14 +295,22 @@ def __hash__(self): class ListElement(ExtendedQuery): __slots__ = ('_elements', '__dict__') - def __init__(self, elements: List[str]): + def __init__(self, elements: List[str], **kwargs): """ Elements list """ if not isinstance(elements, (list, tuple)) or not elements: raise ValueError('invalid elements list') - super().__init__() - self._elements = tuple(elements) + tmp = [] + for x in elements: + if isinstance(x, int): + tmp.append(Element.from_atomic_number(x).__name__) + elif isinstance(x, str): + tmp.append(Element.from_symbol(x).__name__) + else: + raise ValueError(f'invalid element: {x}') + super().__init__(**kwargs) + self._elements = tuple(tmp) @property def atomic_symbol(self) -> str: @@ -366,11 +377,9 @@ def __repr__(self): class QueryElement(ExtendedQuery, ABC): __slots__ = ('_isotope',) - def __init__(self, isotope: Optional[int] = None): - if isotope is not None and not isinstance(isotope, int): - raise TypeError('isotope must be an int') - super().__init__() - self._isotope = isotope + def __init__(self, isotope: Optional[int] = None, **kwargs): + super().__init__(**kwargs) + self.isotope = isotope def __repr__(self): if self.isotope: @@ -392,6 +401,12 @@ def atomic_number(self) -> int: def isotope(self): return self._isotope + @isotope.setter + def isotope(self, value: Optional[int]): + if value is not None and not isinstance(value, int): + raise TypeError('isotope must be an int') + self._isotope = value + @classmethod def from_symbol(cls, symbol: str) -> Type[Union['QueryElement', 'AnyElement', 'AnyMetal']]: """ From 0252529b90ffb3d3ce45f3bdc3dae4d8e0ad5265 Mon Sep 17 00:00:00 2001 From: stsouko Date: Sat, 2 Nov 2024 20:15:35 +0100 Subject: [PATCH 10/67] revert --- chython/algorithms/stereo/graph.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/chython/algorithms/stereo/graph.py b/chython/algorithms/stereo/graph.py index bb7e5ebb..8ad032fd 100644 --- a/chython/algorithms/stereo/graph.py +++ b/chython/algorithms/stereo/graph.py @@ -411,6 +411,19 @@ def _stereo_cis_trans_centers(self) -> Dict[int, Tuple[int, int]]: terminals[n] = terminals[m] = (path[i - 1], path[i]) return terminals + @cached_property + def _stereo_cis_trans_terminals(self) -> Dict[int, Tuple[int, int]]: + """ + Cis-Trans terminal atoms to terminal pair mapping. + """ + terminals = {} + for path in self._stereo_cumulenes: + if len(path) % 2: + continue + n, m = path[0], path[-1] + terminals[n] = terminals[m] = (n, m) + return terminals + @cached_property def _stereo_cis_trans_counterpart(self) -> Dict[int, int]: """ From 932a30f8a0a422e4fe7904780e2037a10941f26f Mon Sep 17 00:00:00 2001 From: stsouko Date: Sun, 3 Nov 2024 14:36:38 +0100 Subject: [PATCH 11/67] another portion of fixes. --- chython/algorithms/aromatics/kekule.py | 4 +- chython/algorithms/aromatics/thiele.py | 34 +++++----- chython/algorithms/calculate2d/__init__.py | 70 +++++++++---------- chython/algorithms/depict.py | 69 +++++++++---------- chython/algorithms/smiles.py | 25 +++---- chython/algorithms/standardize/molecule.py | 4 +- chython/algorithms/standardize/resonance.py | 2 +- chython/algorithms/stereo/graph.py | 5 +- chython/algorithms/stereo/molecule.py | 74 +++++++++++++-------- chython/containers/molecule.py | 52 ++++++++++++++- chython/files/MRVrw.py | 12 ++-- chython/files/RDFrw.py | 18 ++--- chython/files/SDFrw.py | 10 +-- chython/files/_convert.py | 13 ++-- chython/files/_mdl/stereo.py | 39 ++--------- chython/files/daylight/smarts.py | 3 +- chython/files/xyz.py | 19 ++---- chython/periodictable/base/element.py | 13 ---- chython/reactor/base.py | 4 +- 19 files changed, 240 insertions(+), 230 deletions(-) diff --git a/chython/algorithms/aromatics/kekule.py b/chython/algorithms/aromatics/kekule.py index de51744b..f1df888c 100644 --- a/chython/algorithms/aromatics/kekule.py +++ b/chython/algorithms/aromatics/kekule.py @@ -50,7 +50,7 @@ def kekule(self: Union['Kekule', 'MoleculeContainer'], *, buffer_size=7) -> bool atoms.add(n) atoms.add(m) for n in atoms: - self._calc_implicit(n) + self.calc_implicit(n) self.flush_cache() return True return fixed @@ -69,7 +69,7 @@ def enumerate_kekule(self: Union['Kekule', 'MoleculeContainer']): atoms.add(n) atoms.add(m) for n in atoms: - copy._calc_implicit(n) + copy.calc_implicit(n) yield copy def __fix_rings(self: 'MoleculeContainer'): diff --git a/chython/algorithms/aromatics/thiele.py b/chython/algorithms/aromatics/thiele.py index 43030a86..0b2ce586 100644 --- a/chython/algorithms/aromatics/thiele.py +++ b/chython/algorithms/aromatics/thiele.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2021-2023 Ramil Nugmanov +# Copyright 2021-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -56,9 +56,6 @@ def thiele(self: 'MoleculeContainer', *, fix_tautomers=True) -> bool: atoms = self._atoms bonds = self._bonds nsc = self.not_special_connectivity - sh = self.hybridization - charges = self._charges - hydrogens = self._hydrogens rings = defaultdict(set) # aromatic? skeleton. include quinones tetracycles = [] @@ -73,13 +70,13 @@ def thiele(self: 'MoleculeContainer', *, fix_tautomers=True) -> bool: # only B C N O P S with 2-3 neighbors. detects this: C1=CC=CP12=CC=CC=C2 if any(atoms[n].atomic_number not in (6, 7, 8, 16, 5, 15) or len(nsc[n]) > 3 for n in ring): continue - sp2 = sum(sh(n) == 2 for n in ring) + sp2 = sum(atoms[n].hybridization == 2 for n in ring) if sp2 == lr: # benzene like if lr == 4: # two bonds condensed aromatic rings tetracycles.append(ring) else: if fix_tautomers and lr % 2: # find potential pyrroles - acceptors.update(n for n in ring if atoms[n].atomic_number == 7 and not charges[n]) + acceptors.update(n for n in ring if (a := atoms[n]).atomic_number == 7 and not a.charge) n, *_, m = ring rings[n].add(m) rings[m].add(n) @@ -88,11 +85,12 @@ def thiele(self: 'MoleculeContainer', *, fix_tautomers=True) -> bool: rings[m].add(n) elif 4 < lr == sp2 + 1: # pyrroles, furanes, etc try: - n = next(n for n in ring if sh(n) == 1) + n = next(n for n in ring if atoms[n].hybridization == 1) except StopIteration: # exotic, just skip continue - an = atoms[n].atomic_number - if (c := charges[n]) == -1: + a = atoms[n] + an = a.atomic_number + if (c := a.charge) == -1: if an != 6 or lr != 5: # skip any but ferrocene continue elif c: # skip any charged @@ -149,8 +147,8 @@ def thiele(self: 'MoleculeContainer', *, fix_tautomers=True) -> bool: acceptors.discard(current) pyrroles.discard(start) pyrroles.add(current) - hydrogens[current] = 1 - hydrogens[start] = 0 + atoms[current]._implicit_hydrogens = 1 + atoms[start]._implicit_hydrogens = 0 break else: continue @@ -163,7 +161,7 @@ def thiele(self: 'MoleculeContainer', *, fix_tautomers=True) -> bool: else: # path not found continue for n, m, o in path: - bonds[n][m]._Bond__order = o # noqa + bonds[n][m]._order = o if not acceptors: break @@ -205,24 +203,24 @@ def thiele(self: 'MoleculeContainer', *, fix_tautomers=True) -> bool: for ring in tetracycles: if seen.issuperset(ring): n, *_, m = ring - bonds[n][m]._Bond__order = 1 # noqa + bonds[n][m]._order = 1 for n, m in zip(ring, ring[1:]): - bonds[n][m]._Bond__order = 1 # noqa + bonds[n][m]._order = 1 for ring in rings: n, *_, m = ring - bonds[n][m]._Bond__order = 4 # noqa + bonds[n][m]._order = 4 for n, m in zip(ring, ring[1:]): - bonds[n][m]._Bond__order = 4 # noqa + bonds[n][m]._order = 4 self.flush_cache() for ring in freaks: # aromatize rule based for q in freak_rules: if next(q.get_mapping(self, searching_scope=ring, automorphism_filter=False), None): n, *_, m = ring - bonds[n][m]._Bond__order = 4 # noqa + bonds[n][m]._order = 4 for n, m in zip(ring, ring[1:]): - bonds[n][m]._Bond__order = 4 # noqa + bonds[n][m]._order = 4 break if freaks: self.flush_cache() # flush again diff --git a/chython/algorithms/calculate2d/__init__.py b/chython/algorithms/calculate2d/__init__.py index bef7b1f0..a787abc5 100644 --- a/chython/algorithms/calculate2d/__init__.py +++ b/chython/algorithms/calculate2d/__init__.py @@ -77,9 +77,11 @@ def clean2d(self: Union['MoleculeContainer', 'Calculate2DMolecule']): else: bond_reduce = 1. - self_plane = self._plane + atoms = self._atoms for n, (x, y) in plane.items(): - self_plane[n] = (x / bond_reduce, y / bond_reduce) + a = atoms[n] + a._x = x / bond_reduce + a._y = y / bond_reduce if self.connected_components_count > 1: shift_x = 0. @@ -88,27 +90,28 @@ def clean2d(self: Union['MoleculeContainer', 'Calculate2DMolecule']): self.__dict__.pop('__cached_method__repr_svg_', None) def _fix_plane_mean(self: 'MoleculeContainer', shift_x: float, shift_y=0., component=None) -> float: - plane = self._plane + atoms = self._atoms if component is None: - component = plane + component = atoms - left_atom = min(component, key=lambda x: plane[x][0]) - right_atom = max(component, key=lambda x: plane[x][0]) + left_atom = atoms[min(component, key=lambda x: atoms[x].x)] + right_atom = atoms[max(component, key=lambda x: atoms[x].x)] - min_x = plane[left_atom][0] - shift_x - if len(self._atoms[left_atom].atomic_symbol) == 2: + min_x = left_atom.x - shift_x + if len(left_atom.atomic_symbol) == 2: min_x -= .2 - max_x = plane[right_atom][0] - min_x - min_y = min(plane[x][1] for x in component) - max_y = max(plane[x][1] for x in component) + max_x = right_atom.x - min_x + min_y = min(atoms[x].y for x in component) + max_y = max(atoms[x].y for x in component) mean_y = (max_y + min_y) / 2 - shift_y for n in component: - x, y = plane[n] - plane[n] = (x - min_x, y - mean_y) + a = atoms[n] + a._x -= min_x + a._y -= mean_y - if -.18 <= plane[right_atom][1] <= .18: - factor = self._hydrogens[right_atom] + if -.18 <= right_atom.y <= .18: + factor = right_atom.implicit_hydrogens if factor == 1: max_x += .15 elif factor: @@ -116,21 +119,22 @@ def _fix_plane_mean(self: 'MoleculeContainer', shift_x: float, shift_y=0., compo return max_x def _fix_plane_min(self: 'MoleculeContainer', shift_x: float, shift_y=0., component=None) -> float: - plane = self._plane + atoms = self._atoms if component is None: - component = plane + component = atoms - right_atom = max(component, key=lambda x: plane[x][0]) - min_x = min(plane[x][0] for x in component) - shift_x - max_x = plane[right_atom][0] - min_x - min_y = min(plane[x][1] for x in component) - shift_y + right_atom = atoms[max(component, key=lambda x: atoms[x].x)] + min_x = min(atoms[x].x for x in component) - shift_x + max_x = right_atom.x - min_x + min_y = min(atoms[x].y for x in component) - shift_y for n in component: - x, y = plane[n] - plane[n] = (x - min_x, y - min_y) + a = atoms[n] + a._x -= min_x + a._y -= min_y - if shift_y - .18 <= plane[right_atom][1] <= shift_y + .18: - factor = self._hydrogens[right_atom] + if shift_y - .18 <= right_atom.y <= shift_y + .18: + factor = right_atom.implicit_hydrogens if factor == 1: max_x += .15 elif factor: @@ -138,21 +142,9 @@ def _fix_plane_min(self: 'MoleculeContainer', shift_x: float, shift_y=0., compon return max_x def __clean2d_prepare(self: 'MoleculeContainer', entry): - hydrogens = self._hydrogens - charges = self._charges - allenes_stereo = self._allenes_stereo - atoms_stereo = self._atoms_stereo - self._charges = self._hydrogens = {n: 0 for n in hydrogens} - self._atoms_stereo = self._allenes_stereo = {} - w = {n: random() for n in hydrogens} + w = {n: random() for n in self._atoms} w[entry] = -1 - try: - smiles, order = self._smiles(w.__getitem__, random=True, _return_order=True) - finally: - self._hydrogens = hydrogens - self._charges = charges - self._allenes_stereo = allenes_stereo - self._atoms_stereo = atoms_stereo + smiles, order = self._smiles(w.__getitem__, random=True, charges=False, stereo=False, _return_order=True) return ''.join(smiles).replace('~', '-'), order diff --git a/chython/algorithms/depict.py b/chython/algorithms/depict.py index 4eab3f82..1189d32a 100644 --- a/chython/algorithms/depict.py +++ b/chython/algorithms/depict.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2022 Ramil Nugmanov +# Copyright 2018-2024 Ramil Nugmanov # Copyright 2019-2020 Dinar Batyrshin # This file is part of chython. # @@ -206,17 +206,17 @@ def depict(self: Union['MoleculeContainer', 'DepictMolecule'], *, width=None, he :param clean2d: calculate coordinates if necessary. """ uid = str(uuid4()) - values = self._plane.values() - min_x = min(x for x, _ in values) - max_x = max(x for x, _ in values) - min_y = min(y for _, y in values) - max_y = max(y for _, y in values) + atoms = self._atoms.values() + min_x = min(a.x for a in atoms) + max_x = max(a.x for a in atoms) + min_y = min(a.y for a in atoms) + max_y = max(a.y for a in atoms) if clean2d and len(self) > 1 and max_y - min_y < .01 and max_x - min_x < 0.01: self.clean2d() - min_x = min(x for x, _ in values) - max_x = max(x for x, _ in values) - min_y = min(y for _, y in values) - max_y = max(y for _, y in values) + min_x = min(a.x for a in atoms) + max_x = max(a.x for a in atoms) + min_y = min(a.y for a in atoms) + max_y = max(a.y for a in atoms) bonds = self.__render_bonds() atoms, define, masks = self.__render_atoms(uid) @@ -247,8 +247,8 @@ def _repr_svg_(self): return self.depict() def __render_bonds(self: Union['MoleculeContainer', 'DepictMolecule']): + atoms = self._atoms svg = [] - plane = self._plane double_space = _render_config['double_space'] triple_space = _render_config['triple_space'] wedge_space = _render_config['wedge_space'] @@ -260,8 +260,8 @@ def __render_bonds(self: Union['MoleculeContainer', 'DepictMolecule']): wedge[n].add(m) wedge[m].add(n) - nx, ny = plane[n] - mx, my = plane[m] + nx, ny = atoms[n].xy + mx, my = atoms[m].xy ny, my = -ny, -my dx, dy = _rotate_vector(0, wedge_space, mx - nx, ny - my) @@ -272,8 +272,8 @@ def __render_bonds(self: Union['MoleculeContainer', 'DepictMolecule']): if m in wedge[n]: continue order = bond.order - nx, ny = plane[n] - mx, my = plane[m] + nx, ny = atoms[n].xy + mx, my = atoms[m].xy ny, my = -ny, -my if order in (1, 4): svg.append(f' ') @@ -291,18 +291,18 @@ def __render_bonds(self: Union['MoleculeContainer', 'DepictMolecule']): f'stroke-dasharray="{dash1:.2f} {dash2:.2f}"/>') for ring in self.aromatic_rings: - cx = sum(plane[n][0] for n in ring) / len(ring) - cy = sum(plane[n][1] for n in ring) / len(ring) + cx = sum(atoms[n].x for n in ring) / len(ring) + cy = sum(atoms[n].y for n in ring) / len(ring) for n, m in zip(ring, ring[1:]): - nx, ny = plane[n] - mx, my = plane[m] + nx, ny = atoms[n].xy + mx, my = atoms[m].xy aromatic = _render_aromatic_bond(nx, ny, mx, my, cx, cy) if aromatic: svg.append(aromatic) - nx, ny = plane[ring[-1]] - mx, my = plane[ring[0]] + nx, ny = atoms[ring[-1]].xy + mx, my = atoms[ring[0]].xy aromatic = _render_aromatic_bond(nx, ny, mx, my, cx, cy) if aromatic: svg.append(aromatic) @@ -310,10 +310,6 @@ def __render_bonds(self: Union['MoleculeContainer', 'DepictMolecule']): def __render_atoms(self: 'MoleculeContainer', uid): bonds = self._bonds - plane = self._plane - charges = self._charges - radicals = self._radicals - hydrogens = self._hydrogens carbon = _render_config['carbon'] mapping = _render_config['mapping'] @@ -360,14 +356,13 @@ def __render_atoms(self: 'MoleculeContainer', uid): mask = [] for n, atom in self._atoms.items(): - x, y = plane[n] - y = -y + x, y = atom.x, -atom.y symbol = atom.atomic_symbol - if not bonds[n] or symbol != 'C' or carbon or charges[n] or radicals[n] or atom.isotope or n in cumulenes: - if charges[n]: + if not bonds[n] or symbol != 'C' or carbon or atom.charge or atom.is_radical or atom.isotope or n in cumulenes: + if atom.charge: others.append(f' ' - f'{_render_charge[charges[n]]}{"↑" if radicals[n] else ""}') - elif radicals[n]: + f'{_render_charge[atom.charge]}{"↑" if atom.is_radical else ""}') + elif atom.is_radical: others.append(f' ') if atom.isotope: others.append(f' ') - h = hydrogens[n] + h = atom.implicit_hydrogens if h == 1: h = 'H' elif h: @@ -463,11 +458,11 @@ def depict(self: 'ReactionContainer', *, width=None, height=None, clean2d: bool if clean2d: for m in self.molecules(): if len(m) > 1: - values = m._plane.values() # noqa - min_x = min(x for x, _ in values) - max_x = max(x for x, _ in values) - min_y = min(y for _, y in values) - max_y = max(y for _, y in values) + atoms = m._atoms.values() + min_x = min(a.x for a in atoms) + max_x = max(a.x for a in atoms) + min_y = min(a.y for a in atoms) + max_y = max(a.y for a in atoms) if max_y - min_y < .01 and max_x - min_x < 0.01: m.clean2d() self.fix_positions() diff --git a/chython/algorithms/smiles.py b/chython/algorithms/smiles.py index 412c76e0..b400a259 100644 --- a/chython/algorithms/smiles.py +++ b/chython/algorithms/smiles.py @@ -476,19 +476,20 @@ def _format_bond(self: 'MoleculeContainer', n, m, adjacency, **kwargs): return '~' def __ct_map(self, adjacency): + stereo_bonds = {n for n, mb in self._bonds.items() if any(b.stereo is not None for m, b in mb.items())} + if not stereo_bonds: + return {} ct_map = {} - cts = self._cis_trans_stereo - if not cts: - return ct_map + ctc = self._stereo_cis_trans_centers ctt = self._stereo_cis_trans_terminals sct = self._stereo_cis_trans - ctc = self._stereo_cis_trans_counterpart + ctcp = self._stereo_cis_trans_counterpart seen = set() for k, vs in adjacency.items(): seen.add(k) - if (ts := ctt.get(k)) and ts in cts: - env = sct[ts] + if (cs := ctc.get(k)) and stereo_bonds.issuperset(cs): + env = sct[ctt[k]] for v in vs: if v in env: if (k, v) in ct_map: @@ -497,11 +498,11 @@ def __ct_map(self, adjacency): s = ct_map[(k, x)] ct_map[(k, v)] = not s # X/C(/R)=, C(\X)(/R)=, C(=C(\X)/R)=C= ct_map[(v, k)] = s - if y := ctt.get(v): # =C(\X)/R=, C(\X)(/R=)= + if y := ctc.get(v): # =C(\X)/R=, C(\X)(/R=)= ct_map[v] = k seen.add(y) - elif ts in seen: - o = ctc[k] + elif cs in seen: + o = ctcp[k] on = ct_map[o] s = ct_map[(o, on)] if not self._translate_cis_trans_sign(k, o, v, on): @@ -509,17 +510,17 @@ def __ct_map(self, adjacency): ct_map[(k, v)] = s ct_map[k] = v ct_map[(v, k)] = not s # C/R=, R\1...C/1 - if y := ctt.get(v): + if y := ctc.get(v): ct_map[v] = k seen.add(y) else: # left entry to double bond - if y := ctt.get(v): # 1,3-diene case + if y := ctc.get(v): # 1,3-diene case ct_map[v] = k seen.add(y) ct_map[(v, k)] = True # R/C=, C\1=...R/1, C(/R=)=, C(=C(/R=))=C= ct_map[(k, v)] = False # first DOWN ct_map[k] = v - seen.add(ts) + seen.add(cs) return ct_map diff --git a/chython/algorithms/standardize/molecule.py b/chython/algorithms/standardize/molecule.py index 89bf57f5..c9fb0893 100644 --- a/chython/algorithms/standardize/molecule.py +++ b/chython/algorithms/standardize/molecule.py @@ -426,7 +426,7 @@ def clean_isotopes(self: 'MoleculeContainer') -> bool: isotopes = [x for x in atoms.values() if x.isotope] if isotopes: for i in isotopes: - i._Core__isotope = None + i._isotope = None self.flush_cache() self.fix_stereo() return True @@ -436,7 +436,7 @@ def __standardize(self: 'MoleculeContainer', rules, fix_tautomers): bonds = self._bonds charges = self._charges radicals = self._radicals - calc_implicit = self._calc_implicit + calc_implicit = self.calc_implicit log = [] fixed = set() diff --git a/chython/algorithms/standardize/resonance.py b/chython/algorithms/standardize/resonance.py index 1270e3dd..696b977c 100644 --- a/chython/algorithms/standardize/resonance.py +++ b/chython/algorithms/standardize/resonance.py @@ -38,7 +38,7 @@ def fix_resonance(self: Union['MoleculeContainer', 'Resonance'], *, logging=Fals charges = self._charges radicals = self._radicals bonds = self._bonds - calc_implicit = self._calc_implicit + calc_implicit = self.calc_implicit entries, exits, rads, constrains, nitrogen_cat, nitrogen_ani, sulfur_cat = self.__entries() hs = set() while len(rads) > 1: diff --git a/chython/algorithms/stereo/graph.py b/chython/algorithms/stereo/graph.py index 8ad032fd..59523deb 100644 --- a/chython/algorithms/stereo/graph.py +++ b/chython/algorithms/stereo/graph.py @@ -414,14 +414,15 @@ def _stereo_cis_trans_centers(self) -> Dict[int, Tuple[int, int]]: @cached_property def _stereo_cis_trans_terminals(self) -> Dict[int, Tuple[int, int]]: """ - Cis-Trans terminal atoms to terminal pair mapping. + Cis-Trans terminal and central atoms to terminal pair mapping. """ terminals = {} for path in self._stereo_cumulenes: if len(path) % 2: continue n, m = path[0], path[-1] - terminals[n] = terminals[m] = (n, m) + i = len(path) // 2 + terminals[n] = terminals[m] = terminals[path[i]] = terminals[path[i - 1]] = (n, m) return terminals @cached_property diff --git a/chython/algorithms/stereo/molecule.py b/chython/algorithms/stereo/molecule.py index 7c443a0b..9415d551 100644 --- a/chython/algorithms/stereo/molecule.py +++ b/chython/algorithms/stereo/molecule.py @@ -204,19 +204,19 @@ def calculate_cis_trans_from_2d(self: 'MoleculeContainer', *, clean_cache=True): """ Calculate cis-trans stereo bonds from given 2d coordinates. Unusable for SMILES and INCHI. """ - cis_trans_stereo = self._cis_trans_stereo - plane = self._plane + atoms = self._atoms flag = False while self._chiral_cis_trans: - stereo = {} + stereo = False for nm in self._chiral_cis_trans: n, m = nm n1, m1, *_ = self._stereo_cis_trans[nm] - s = _cis_trans_sign(plane[n1], plane[n], plane[m], plane[m1]) + s = _cis_trans_sign(atoms[n1].xy, atoms[n].xy, atoms[m].xy, atoms[m1].xy) if s: - stereo[nm] = s > 0 + stereo = True + i, j = self._stereo_cis_trans_centers[n] + self._bonds[i][j]._stereo = s > 0 if stereo: - cis_trans_stereo.update(stereo) flag = True self.flush_stereo_cache() else: @@ -234,19 +234,21 @@ def add_atom_stereo(self: 'MoleculeContainer', n: int, env: Tuple[int, ...], mar See and """ - if n not in self._atoms: + try: + atom = self._atoms[n] + except KeyError: raise AtomNotFound - if n in self._atoms_stereo or n in self._allenes_stereo: + if atom.stereo is not None: raise IsChiral if not isinstance(mark, bool): raise TypeError('stereo mark should be bool') if n in self._chiral_tetrahedrons: - self._atoms_stereo[n] = self._translate_tetrahedron_sign(n, env, mark) + atom._stereo = self._translate_tetrahedron_sign(n, env, mark) if clean_cache: self.flush_cache() elif n in self._chiral_allenes: - self._allenes_stereo[n] = self._translate_allene_sign(n, *env, mark) + atom._stereo = self._translate_allene_sign(n, *env, mark) if clean_cache: self.flush_cache() else: # only tetrahedrons supported @@ -272,15 +274,19 @@ def add_cis_trans_stereo(self: 'MoleculeContainer', n: int, m: int, n1: int, n2: raise AtomNotFound if not isinstance(mark, bool): raise TypeError('stereo mark should be bool') - if (n, m) in self._cis_trans_stereo or (m, n) in self._cis_trans_stereo: + + if n not in self._stereo_cis_trans_counterpart or self._stereo_cis_trans_counterpart[n] != m: + raise NotChiral + i, j = self._stereo_cis_trans_centers[n] + if self._bonds[i][j].stereo is not None: raise IsChiral if (n, m) in self._chiral_cis_trans: - self._cis_trans_stereo[(n, m)] = self._translate_cis_trans_sign(n, m, n1, n2, mark) + self._bonds[i][j] = self._translate_cis_trans_sign(n, m, n1, n2, mark) if clean_cache: self.flush_cache() elif (m, n) in self._chiral_cis_trans: - self._cis_trans_stereo[(m, n)] = self._translate_cis_trans_sign(m, n, n2, n1, mark) + self._bonds[i][j] = self._translate_cis_trans_sign(m, n, n2, n1, mark) if clean_cache: self.flush_cache() else: @@ -372,7 +378,7 @@ def _wedge_map(self: Union['MoleculeContainer', 'MoleculeStereo']): if env[3]: orders.append((env[3], env[0], *term[::-1], n, True)) space.append(orders) - for n, s in self._atoms_stereo.items(): + for n, s in atoms_stereo.items(): order = list(self._stereo_tetrahedrons[n]) orders = [(*order, n, False)] for _ in range(1, len(order)): @@ -478,12 +484,18 @@ def _chiral_allenes(self) -> Set[int]: @cached_property def _chiral_morgan(self: Union['MoleculeContainer', 'MoleculeStereo']) -> Dict[int, int]: - if not self._atoms_stereo and not self._allenes_stereo and not self._cis_trans_stereo: + stereo_atoms = {n for n, a in self._atoms.items() if a.stereo is not None} + stereo_bonds = {n for n, mb in self._bonds.items() if any(b.stereo is not None for m, b in mb.items())} + if not stereo_atoms and not stereo_bonds: return self.atoms_order + morgan = self.atoms_order.copy() - atoms_stereo = set(self._atoms_stereo) - cis_trans_stereo = set(self._cis_trans_stereo) - allenes_stereo = set(self._allenes_stereo) + atoms_stereo = stereo_atoms.intersection(self.tetrahedrons) + allenes_stereo = stereo_atoms - atoms_stereo + + cis_trans_terminals = self._stereo_cis_trans_terminals + cis_trans_stereo = {cis_trans_terminals[n] for n in stereo_bonds} + while True: # try iteratively differentiate stereo atoms. morgan, atoms_stereo, cis_trans_stereo, allenes_stereo, atoms_groups, cis_trans_groups, allenes_groups = \ @@ -599,6 +611,7 @@ def __chiral_centers(self: Union['MoleculeStereo', 'MoleculeContainer']): cis_trans = self._stereo_cis_trans allenes_centers = self._stereo_allenes_centers cis_trans_terminals = self._stereo_cis_trans_terminals + cis_trans_centers = self._stereo_cis_trans_centers morgan = self._chiral_morgan # find new chiral atoms and bonds. @@ -623,20 +636,22 @@ def __chiral_centers(self: Union['MoleculeStereo', 'MoleculeContainer']): if len(path) % 2: chiral_a.add(path[len(path) // 2]) else: - chiral_c.add((n, m)) + chiral_c.add(n) stereogenic.add(n) stereogenic.add(m) # ring cumulenes always chiral. can be already added. for nm in self._rings_cumulenes: n, m = nm if any(len(x) < 8 for x in atoms_rings[n]): # skip small rings. - if nm in chiral_c: # remove already added small rings cumulenes. - chiral_c.discard(nm) + if n in chiral_c: # remove already added small rings cumulenes. + chiral_c.discard(n) + if m in chiral_c: + chiral_c.discard(m) elif n in allenes_centers and (c := allenes_centers[n]) in chiral_a: chiral_a.discard(c) continue elif nm in cis_trans: - chiral_c.add(nm) + chiral_c.add(n) else: chiral_a.add(allenes_centers[n]) pseudo[m] = n @@ -697,13 +712,18 @@ def __chiral_centers(self: Union['MoleculeStereo', 'MoleculeContainer']): elif n in allenes_centers: chiral_a.add(allenes_centers[n]) else: - chiral_c.add(cis_trans_terminals[n]) + chiral_c.add(n) # skip already marked. - chiral_t.difference_update(self._atoms_stereo) - chiral_a.difference_update(self._allenes_stereo) - chiral_c.difference_update(self._cis_trans_stereo) - return chiral_t, chiral_c, chiral_a + stereo_atoms = {n for n, a in self._atoms.items() if a.stereo is not None} + chiral_t.difference_update(stereo_atoms) + chiral_a.difference_update(stereo_atoms) + diff = set() + for n in chiral_c: + i, j = cis_trans_centers[n] + if self._bonds[i][j].stereo is None: + diff.add(cis_trans_terminals[n]) + return chiral_t, diff, chiral_a def __differentiation(self: Union['MoleculeStereo', 'MoleculeContainer'], morgan, atoms_stereo, cis_trans_stereo, allenes_stereo): diff --git a/chython/containers/molecule.py b/chython/containers/molecule.py index 09fa158a..c96fb713 100644 --- a/chython/containers/molecule.py +++ b/chython/containers/molecule.py @@ -693,7 +693,55 @@ def _augmented_substructure(self, atoms: Iterable[int], deep: int): nodes.append(n) return nodes - def _calc_implicit(self, n: int): + def fix_labels(self, recalculate_hydrogens=True): + """ + Fix molecule internal represenation + """ + if not self._changed: + return + + self.calc_labels() # refresh all labels + + if recalculate_hydrogens: + for n in self._changed: + self.calc_implicit(n) # fix Hs count + self._changed = None + + def calc_labels(self): + atoms = self._atoms + for n, m_bond in self._bonds.items(): + neighbors = 0 + heteroatoms = 0 + hybridization = 1 + explicit_hydrogens = 0 + for m, bond in m_bond.items(): + order = bond.order + if order == 8: + continue + elif order == 4: + hybridization = 4 + elif hybridization != 4: + if order == 3: + hybridization = 3 + elif order == 2: + if hybridization == 1: + hybridization = 2 + elif hybridization == 2: + hybridization = 3 + + neighbors += 1 + an = atoms[m].atomic_number + if an == 1: + explicit_hydrogens += 1 + elif an != 6: + heteroatoms += 1 + atom = atoms[n] + atom._neighbors = neighbors + atom._heteroatoms = heteroatoms + atom._hybridization = hybridization + atom._explicit_hydrogens = explicit_hydrogens + + def calc_implicit(self, n: int): """ Set firs possible hydrogens count based on rules """ @@ -746,7 +794,7 @@ def _calc_implicit(self, n: int): return atom._implicit_hydrogens = None # rule not found - def _check_implicit(self, n: int, h: int) -> bool: + def check_implicit(self, n: int, h: int) -> bool: atom = self._atoms[n] if atom.atomic_number == 1: # hydrogen nether has implicit H return h == 0 diff --git a/chython/files/MRVrw.py b/chython/files/MRVrw.py index c8db572a..0a589410 100644 --- a/chython/files/MRVrw.py +++ b/chython/files/MRVrw.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2017-2023 Ramil Nugmanov +# Copyright 2017-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -138,8 +138,8 @@ def read_structure(self, *, current: bool = True): postprocess_parsed_molecule(tmp, remap=self.__remap, ignore=self.__ignore) parse_sgroup(data, tmp) mol = create_molecule(tmp, ignore_bad_isotopes=self.__ignore_bad_isotopes, _cls=self.molecule_cls) - postprocess_molecule(mol, tmp, ignore=self.__ignore, ignore_stereo=self.__ignore_stereo, - calc_cis_trans=self.__calc_cis_trans) + if not self.__ignore_stereo: + postprocess_molecule(mol, tmp, calc_cis_trans=self.__calc_cis_trans) mol.meta.update(meta) return mol elif 'reaction' in data and isinstance(data['reaction'], dict): @@ -171,9 +171,9 @@ def read_structure(self, *, current: bool = True): postprocess_parsed_reaction(tmp, remap=self.__remap, ignore=self.__ignore) rxn = create_reaction(tmp, ignore_bad_isotopes=self.__ignore_bad_isotopes, _m_cls=self.molecule_cls, _r_cls=self.reaction_cls) - for mol, tmp in zip(rxn.molecules(), chain(tmp['reactants'], tmp['reagents'], tmp['products'])): - postprocess_molecule(mol, tmp, ignore=self.__ignore, ignore_stereo=self.__ignore_stereo, - calc_cis_trans=self.__calc_cis_trans) + if not self.__ignore_stereo: + for mol, tmp in zip(rxn.molecules(), chain(tmp['reactants'], tmp['reagents'], tmp['products'])): + postprocess_molecule(mol, tmp, calc_cis_trans=self.__calc_cis_trans) rxn.meta.update(meta) return rxn else: diff --git a/chython/files/RDFrw.py b/chython/files/RDFrw.py index 0d4475bc..62bebbae 100644 --- a/chython/files/RDFrw.py +++ b/chython/files/RDFrw.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2014-2023 Ramil Nugmanov +# Copyright 2014-2024 Ramil Nugmanov # Copyright 2019 Dinar Batyrshin # This file is part of chython. # @@ -74,9 +74,9 @@ def read_structure(self, *, current=True) -> Union[ReactionContainer, MoleculeCo postprocess_parsed_reaction(tmp, remap=self._remap, ignore=self._ignore) rxn = create_reaction(tmp, ignore_bad_isotopes=self._ignore_bad_isotopes, _m_cls=self.molecule_cls, _r_cls=self.reaction_cls) - for mol, tmp in zip(rxn.molecules(), chain(tmp['reactants'], tmp['reagents'], tmp['products'])): - postprocess_molecule(mol, tmp, ignore=self._ignore, ignore_stereo=self._ignore_stereo, - calc_cis_trans=self._calc_cis_trans) + if not self._ignore_stereo: + for mol, tmp in zip(rxn.molecules(), chain(tmp['reactants'], tmp['reagents'], tmp['products'])): + postprocess_molecule(mol, tmp, calc_cis_trans=self._calc_cis_trans) if meta: rxn.meta.update(meta) return rxn @@ -87,8 +87,8 @@ def read_structure(self, *, current=True) -> Union[ReactionContainer, MoleculeCo postprocess_parsed_molecule(tmp) mol = create_molecule(tmp, ignore_bad_isotopes=self._ignore_bad_isotopes, _cls=self.molecule_cls) - postprocess_molecule(mol, tmp, ignore=self._ignore, ignore_stereo=self._ignore_stereo, - calc_cis_trans=self._calc_cis_trans) + if not self._ignore_stereo: + postprocess_molecule(mol, tmp, calc_cis_trans=self._calc_cis_trans) if meta: mol.meta.update(meta) return mol @@ -289,9 +289,9 @@ def mdl_rxn(data: str, /, *, ignore=True, calc_cis_trans=False, ignore_stereo=Fa postprocess_parsed_reaction(tmp, remap=remap, ignore=ignore) rxn = create_reaction(tmp, ignore_bad_isotopes=ignore_bad_isotopes, _m_cls=_m_cls, _r_cls=_r_cls) - for mol, tmp in zip(rxn.molecules(), chain(tmp['reactants'], tmp['reagents'], tmp['products'])): - postprocess_molecule(mol, tmp, ignore=ignore, ignore_stereo=ignore_stereo, - calc_cis_trans=calc_cis_trans) + if not ignore_stereo: + for mol, tmp in zip(rxn.molecules(), chain(tmp['reactants'], tmp['reagents'], tmp['products'])): + postprocess_molecule(mol, tmp, calc_cis_trans=calc_cis_trans) return rxn diff --git a/chython/files/SDFrw.py b/chython/files/SDFrw.py index 6ef8e638..04edb0ad 100644 --- a/chython/files/SDFrw.py +++ b/chython/files/SDFrw.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2014-2023 Ramil Nugmanov +# Copyright 2014-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -71,8 +71,8 @@ def read_structure(self, *, current=True) -> MoleculeContainer: postprocess_parsed_molecule(tmp, remap=self._remap, ignore=self._ignore) mol = create_molecule(tmp, ignore_bad_isotopes=self._ignore_bad_isotopes, _cls=self.molecule_cls) - postprocess_molecule(mol, tmp, ignore=self._ignore, ignore_stereo=self._ignore_stereo, - calc_cis_trans=self._calc_cis_trans) + if not self._ignore_stereo: + postprocess_molecule(mol, tmp, calc_cis_trans=self._calc_cis_trans) meta = self.read_metadata() if meta: mol.meta.update(meta) @@ -213,8 +213,8 @@ def mdl_mol(data: str, /, *, ignore=True, calc_cis_trans=False, ignore_stereo=Fa postprocess_parsed_molecule(tmp, remap=remap, ignore=ignore) mol = create_molecule(tmp, ignore_bad_isotopes=ignore_bad_isotopes, _cls=_cls) - postprocess_molecule(mol, tmp, ignore=ignore, ignore_stereo=ignore_stereo, - calc_cis_trans=calc_cis_trans) + if not ignore_stereo: + postprocess_molecule(mol, tmp, calc_cis_trans=calc_cis_trans) return mol diff --git a/chython/files/_convert.py b/chython/files/_convert.py index a450146e..6da1ffd6 100644 --- a/chython/files/_convert.py +++ b/chython/files/_convert.py @@ -50,6 +50,9 @@ def create_molecule(data, *, ignore_bad_isotopes=False, skip_calc_implicit=False if n in bonds[m]: raise ValueError('atoms already bonded') bonds[n][m] = bonds[m][n] = Bond(b) + + g.calc_labels() # set all labels except rings + if any(a.get('z') for a in data['atoms']): # store conformer g._conformers = [{mapping[n]: (a['x'], a['y'], a['z']) for n, a in enumerate(data['atoms'])}] @@ -70,13 +73,13 @@ def create_molecule(data, *, ignore_bad_isotopes=False, skip_calc_implicit=False if a.implicit_hydrogens is None: # let's try to calculate. in case of errors just keep as is. radicals in smiles should be in [brackets], # thus has implicit Hs value - g._calc_implicit(n) + g.calc_implicit(n) elif keep_implicit: # keep given Hs count as is continue else: # recheck given Hs count h = a.implicit_hydrogens # parsed Hs - g._calc_implicit(n) # recalculate + g.calc_implicit(n) # recalculate if a.implicit_hydrogens is None: # atom has invalid valence or aromatic ring. if a.hybridization == 4: # this is aromatic ring. just restore given H count. @@ -91,7 +94,7 @@ def create_molecule(data, *, ignore_bad_isotopes=False, skip_calc_implicit=False elif not keep_radicals and not a.is_radical: # CXSMILES radical not set. # SMILES doesn't code radicals. so, let's try to guess. a._is_radical = True - if g._check_implicit(n, h): # radical form is valid + if g.check_implicit(n, h): # radical form is valid radicalized.append(n) a._implicit_hydrogens = h elif ignore: # radical state also has errors. @@ -114,11 +117,11 @@ def create_molecule(data, *, ignore_bad_isotopes=False, skip_calc_implicit=False data['log'].append(f'implicit hydrogen count ({h}) mismatch with calculated on atom {n}') else: raise ValueError(f'implicit hydrogen count ({h}) mismatch with calculated on atom {n}') - elif g._check_implicit(n, h): # set another possible implicit state. probably Al, P + elif g.check_implicit(n, h): # set another possible implicit state. probably Al, P a._implicit_hydrogens = h elif not keep_radicals and not a.is_radical: # CXSMILES radical is not set. try radical form a._is_radical = True - if g._check_implicit(n, h): + if g.check_implicit(n, h): a._implicit_hydrogens = h radicalized.append(n) # radical state also has errors. diff --git a/chython/files/_mdl/stereo.py b/chython/files/_mdl/stereo.py index 67dd52aa..ce9a651c 100644 --- a/chython/files/_mdl/stereo.py +++ b/chython/files/_mdl/stereo.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2020-2023 Ramil Nugmanov +# Copyright 2020-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -19,47 +19,16 @@ from ...exceptions import NotChiral, IsChiral, ValenceError -def postprocess_molecule(molecule, data, *, ignore=True, ignore_stereo=False, calc_cis_trans=False, - keep_implicit=False): +def postprocess_molecule(molecule, data, *, ignore_stereo=False, calc_cis_trans=False): + if ignore_stereo: + return mapping = data['mapping'] - hydrogens = molecule._hydrogens - hyb = molecule.hybridization - implicit_mismatch = {} if 'chython_parsing_log' in molecule.meta: log = molecule.meta['chython_parsing_log'] else: log = [] - for n, h in data['hydrogens'].items(): - n = mapping[n] - if keep_implicit: # override any calculated hydrogens count. - hydrogens[n] = h - if (hc := hydrogens[n]) is None: # aromatic rings or valence errors - if hyb(n) == 4: # this is aromatic rings. just store given H count. - hydrogens[n] = h - elif hc != h: - if hyb(n) == 4: - if ignore: - implicit_mismatch[n] = h - log.append(f'implicit hydrogen count ({h}) mismatch with calculated on atom {n}') - else: - raise ValueError(f'implicit hydrogen count ({h}) mismatch with calculated on atom {n}') - elif molecule._check_implicit(n, h): # set another possible implicit state. probably Al, P - hydrogens[n] = h - elif ignore: # just ignore it - implicit_mismatch[n] = h - log.append(f'implicit hydrogen count ({h}) mismatch with calculated on atom {n}') - else: - raise ValueError(f'implicit hydrogen count ({h}) mismatch with calculated on atom {n}') - - if implicit_mismatch: - molecule.meta['chython_implicit_mismatch'] = implicit_mismatch - if log and 'chython_parsing_log' not in molecule.meta: - molecule.meta['chython_parsing_log'] = log - if ignore_stereo: - return - if calc_cis_trans: molecule.calculate_cis_trans_from_2d() diff --git a/chython/files/daylight/smarts.py b/chython/files/daylight/smarts.py index 4f095e03..3a409505 100644 --- a/chython/files/daylight/smarts.py +++ b/chython/files/daylight/smarts.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # +from functools import partial from itertools import count from re import compile, findall, search from .parser import parser @@ -113,7 +114,7 @@ def smarts(data: str): elif isinstance(e, str): e = QueryElement.from_symbol(e) else: - e = ListElement(e) + e = partial(ListElement, e) g.add_atom(e(**a), n) for n, m, b in data['bonds']: diff --git a/chython/files/xyz.py b/chython/files/xyz.py index 42ec82e7..612415bc 100644 --- a/chython/files/xyz.py +++ b/chython/files/xyz.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2020-2023 Ramil Nugmanov +# Copyright 2020-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -35,22 +35,17 @@ def xyz(matrix: Sequence[Tuple[str, float, float, float]], charge=0, radical=0, atoms = mol._atoms bonds = mol._bonds - plane = mol._plane - hydrogens = mol._hydrogens - radicals = mol._radicals for n, (a, x, y, z) in enumerate(matrix, 1): atoms[n] = atom = Element.from_symbol(a)() - atom._attach_graph(mol, n) bonds[n] = {} - plane[n] = (x, y) + atom.x = x + atom.y = y + atom._implicit_hydrogens = 0 conformer[n] = (x, y, z) - hydrogens[n] = 0 # implicit hydrogens not supported. - radicals[n] = False # set default value - if atom_charge is None or None in atom_charge: - mol._charges = {n: 0 for n in atoms} # reset charges - else: - mol._charges = dict(enumerate(atom_charge, 1)) + if atom_charge is not None and None not in atom_charge: + for n, c in enumerate(atom_charge, 1): + atoms[n]._charge = c charge = sum(atom_charge) pb = possible_bonds(array(list(conformer.values())), array([a.atomic_radius for a in atoms.values()]), diff --git a/chython/periodictable/base/element.py b/chython/periodictable/base/element.py index 56e9f3d3..6b89b226 100644 --- a/chython/periodictable/base/element.py +++ b/chython/periodictable/base/element.py @@ -47,13 +47,6 @@ def __init__(self, isotope: Optional[int] = None, *, self._stereo = stereo self._parsed_mapping = parsed_mapping - self._explicit_hydrogens = 0 - self._neighbors = 0 - self._heteroatoms = 0 - self._hybridization = 1 - self._ring_sizes = () - self._in_ring = False - def __repr__(self): if self.isotope: return f'{self.__class__.__name__}({self.isotope})' @@ -273,12 +266,6 @@ def copy(self, full=False, hydrogens=False, stereo=False) -> 'Element': copy._ring_sizes = self.ring_sizes copy._in_ring = self.in_ring else: - copy._explicit_hydrogens = 0 - copy._neighbors = 0 - copy._heteroatoms = 0 - copy._hybridization = 1 - copy._ring_sizes = () - copy._in_ring = False if hydrogens: copy._implicit_hydrogens = self.implicit_hydrogens else: diff --git a/chython/reactor/base.py b/chython/reactor/base.py index 30212b08..073713e4 100644 --- a/chython/reactor/base.py +++ b/chython/reactor/base.py @@ -100,7 +100,7 @@ def _patcher(self, structure: MoleculeContainer, mapping): # replace atom copy._atoms[n] = a = atom.copy() # noqa a._attach_graph(copy, n) # noqa - copy._calc_implicit(n) # noqa + copy.calc_implicit(n) # noqa if self.__fix_rings: copy.kekule() if not copy.thiele(fix_tautomers=self.__fix_tautomers): @@ -194,7 +194,7 @@ def __prepare_skeleton(self, structure, mapping): new._hydrogens.update(keep_hydrogens) # noqa for n in new: if n not in keep_hydrogens: - new._calc_implicit(n) # noqa + new.calc_implicit(n) # noqa return new def __set_stereo(self, new, structure, mapping): From 0e33370e4d85337c5685ca74f313258b650d4fe0 Mon Sep 17 00:00:00 2001 From: Ramil Nugmanov Date: Fri, 8 Nov 2024 16:27:06 +0100 Subject: [PATCH 12/67] Add delta_isotope support for elements Implemented delta_isotope to manage isotopic modifications and added corresponding mdl_isotope properties for multiple elements. Removed common_isotopes and updated relevant assertions for consistent isotope handling. Also updated copyright years for multiple files. --- chython/algorithms/aromatics/kekule.py | 2 +- chython/algorithms/calculate2d/__init__.py | 2 +- chython/algorithms/isomorphism.py | 16 +-- chython/files/_mdl/__init__.py | 4 +- chython/files/_mdl/mol.py | 26 +---- chython/files/libinchi/wrapper.py | 20 ++-- chython/periodictable/__init__.py | 28 +++-- chython/periodictable/base/element.py | 13 ++- chython/periodictable/groupI.py | 28 +++++ chython/periodictable/groupII.py | 24 ++++ chython/periodictable/groupIII.py | 128 +++++++++++++++++++++ chython/periodictable/groupIV.py | 16 +++ chython/periodictable/groupIX.py | 16 +++ chython/periodictable/groupV.py | 16 +++ chython/periodictable/groupVI.py | 16 +++ chython/periodictable/groupVII.py | 16 +++ chython/periodictable/groupVIII.py | 16 +++ chython/periodictable/groupX.py | 16 +++ chython/periodictable/groupXI.py | 16 +++ chython/periodictable/groupXII.py | 16 +++ chython/periodictable/groupXIII.py | 24 ++++ chython/periodictable/groupXIV.py | 24 ++++ chython/periodictable/groupXV.py | 24 ++++ chython/periodictable/groupXVI.py | 24 ++++ chython/periodictable/groupXVII.py | 24 ++++ chython/periodictable/groupXVIII.py | 28 +++++ 26 files changed, 526 insertions(+), 57 deletions(-) diff --git a/chython/algorithms/aromatics/kekule.py b/chython/algorithms/aromatics/kekule.py index f1df888c..5a7cc494 100644 --- a/chython/algorithms/aromatics/kekule.py +++ b/chython/algorithms/aromatics/kekule.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2021-2023 Ramil Nugmanov +# Copyright 2021-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify diff --git a/chython/algorithms/calculate2d/__init__.py b/chython/algorithms/calculate2d/__init__.py index a787abc5..c8fe17a5 100644 --- a/chython/algorithms/calculate2d/__init__.py +++ b/chython/algorithms/calculate2d/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2019-2023 Ramil Nugmanov +# Copyright 2019-2024 Ramil Nugmanov # Copyright 2019, 2020 Dinar Batyrshin # This file is part of chython. # diff --git a/chython/algorithms/isomorphism.py b/chython/algorithms/isomorphism.py index a40188a6..ce9193bc 100644 --- a/chython/algorithms/isomorphism.py +++ b/chython/algorithms/isomorphism.py @@ -185,15 +185,6 @@ def _cython_compiled_structure(self): # long IV: # ring_sizes: not-in-ring bit, 3-atom ring, 4-...., 65-atom ring - from ..files._mdl.mol import common_isotopes - - charges = self._charges - radicals = self._radicals - hydrogens = self._hydrogens - neighbors = self.neighbors - heteroatoms = self.heteroatoms - rings_sizes = self.atoms_rings_sizes - hybridization = self.hybridization mapping = {} numbers = [] @@ -204,7 +195,7 @@ def _cython_compiled_structure(self): for i, (n, a) in enumerate(self._atoms.items()): mapping[n] = i numbers.append(n) - v2 = 1 << (hybridization(n) - 1) + v2 = 1 << (a.hybridization - 1) if (an := a.atomic_number) > 56: if an > 116: # Ts, Og an = 116 @@ -214,7 +205,7 @@ def _cython_compiled_structure(self): v1 = 1 << (57 - an) if a.isotope: - v3 = 1 << (a.isotope - common_isotopes[a.atomic_symbol] + 54) + v3 = 1 << (a.isotope - a.mdl_isotope + 54) if radicals[n]: v3 |= 0x200000000000 else: @@ -337,7 +328,6 @@ def _cython_compiled_query(self): # padding: 1 bit # bond: single, double, triple, aromatic, special = 5 bit # bond in ring: 2 bit - from ..files._mdl.mol import common_isotopes _components, _closures = self._compiled_query components = [] @@ -378,7 +368,7 @@ def _cython_compiled_query(self): v1 = 1 << (57 - n) v2 = 0 if a.isotope: - v3 = 1 << (a.isotope - common_isotopes[a.atomic_symbol] + 54) + v3 = 1 << (a.isotope - a.mdl_isotope + 54) if a.is_radical: v3 |= 0x200000000000 else: diff --git a/chython/files/_mdl/__init__.py b/chython/files/_mdl/__init__.py index d941f381..2310481a 100644 --- a/chython/files/_mdl/__init__.py +++ b/chython/files/_mdl/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2017-2023 Ramil Nugmanov +# Copyright 2017-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -16,7 +16,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from .mol import parse_mol_v2000, common_isotopes +from .mol import parse_mol_v2000 from .emol import parse_mol_v3000 from .rxn import parse_rxn_v2000 from .erxn import parse_rxn_v3000 diff --git a/chython/files/_mdl/mol.py b/chython/files/_mdl/mol.py index 3879b7ea..3e15cbf9 100644 --- a/chython/files/_mdl/mol.py +++ b/chython/files/_mdl/mol.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2020-2023 Ramil Nugmanov +# Copyright 2020-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -19,19 +19,6 @@ from ...exceptions import EmptyMolecule, InvalidCharge, InvalidV2000 -common_isotopes = {'H': 1, 'He': 4, 'Li': 7, 'Be': 9, 'B': 11, 'C': 12, 'N': 14, 'O': 16, 'F': 19, 'Ne': 20, 'Na': 23, - 'Mg': 24, 'Al': 27, 'Si': 28, 'P': 31, 'S': 32, 'Cl': 35, 'Ar': 40, 'K': 39, 'Ca': 40, 'Sc': 45, - 'Ti': 48, 'V': 51, 'Cr': 52, 'Mn': 55, 'Fe': 56, 'Co': 59, 'Ni': 59, 'Cu': 64, 'Zn': 65, 'Ga': 70, - 'Ge': 73, 'As': 75, 'Se': 79, 'Br': 80, 'Kr': 84, 'Rb': 85, 'Sr': 88, 'Y': 89, 'Zr': 91, 'Nb': 93, - 'Mo': 96, 'Tc': 98, 'Ru': 101, 'Rh': 103, 'Pd': 106, 'Ag': 108, 'Cd': 112, 'In': 115, 'Sn': 119, - 'Sb': 122, 'Te': 128, 'I': 127, 'Xe': 131, 'Cs': 133, 'Ba': 137, 'La': 139, 'Ce': 140, 'Pr': 141, - 'Nd': 144, 'Pm': 145, 'Sm': 150, 'Eu': 152, 'Gd': 157, 'Tb': 159, 'Dy': 163, 'Ho': 165, 'Er': 167, - 'Tm': 169, 'Yb': 173, 'Lu': 175, 'Hf': 178, 'Ta': 181, 'W': 184, 'Re': 186, 'Os': 190, 'Ir': 192, - 'Pt': 195, 'Au': 197, 'Hg': 201, 'Tl': 204, 'Pb': 207, 'Bi': 209, 'Po': 209, 'At': 210, 'Rn': 222, - 'Fr': 223, 'Ra': 226, 'Ac': 227, 'Th': 232, 'Pa': 231, 'U': 238, 'Np': 237, 'Pu': 244, 'Am': 243, - 'Cm': 247, 'Bk': 247, 'Cf': 251, 'Es': 252, 'Fm': 257, 'Md': 258, 'No': 259, 'Lr': 260, 'Rf': 261, - 'Db': 270, 'Sg': 269, 'Bh': 270, 'Hs': 270, 'Mt': 278, 'Ds': 281, 'Rg': 281, 'Cn': 285, 'Nh': 278, - 'Fl': 289, 'Mc': 289, 'Lv': 293, 'Ts': 297, 'Og': 294} _ctf_data = {'R': 'is_radical', 'C': 'charge', 'I': 'isotope'} _charge_map = {' 0': 0, ' 1': 3, ' 2': 2, ' 3': 1, ' 4': 0, ' 5': -1, ' 6': -2, ' 7': -3} @@ -59,6 +46,7 @@ def parse_mol_v2000(data): raise InvalidCharge element = line[31:34].strip() isotope = line[34:36] + delta_isotope = None if element in 'AL': raise ValueError('queries not supported') @@ -68,17 +56,15 @@ def parse_mol_v2000(data): raise ValueError('isotope on deuterium atom') isotope = 2 elif isotope != ' 0': - try: - isotope = common_isotopes[element] + int(isotope) - except KeyError: - raise ValueError('invalid element symbol') + delta_isotope = int(isotope) + isotope = None else: isotope = None mapping = line[60:63] atoms.append({'element': element, 'charge': charge, 'isotope': isotope, 'is_radical': False, 'mapping': int(mapping) if mapping else 0, 'x': float(line[0:10]), 'y': float(line[10:20]), - 'z': float(line[20:30])}) + 'z': float(line[20:30]), 'delta_isotope': delta_isotope}) for line in data[4 + atoms_count: 4 + atoms_count + bonds_count]: a1, a2 = int(line[0:3]) - 1, int(line[3:6]) - 1 @@ -157,4 +143,4 @@ def parse_mol_v2000(data): 'meta': None, 'log': log} -__all__ = ['parse_mol_v2000', 'common_isotopes'] +__all__ = ['parse_mol_v2000'] diff --git a/chython/files/libinchi/wrapper.py b/chython/files/libinchi/wrapper.py index 55749f34..a3504a0b 100644 --- a/chython/files/libinchi/wrapper.py +++ b/chython/files/libinchi/wrapper.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2023 Ramil Nugmanov +# Copyright 2018-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -21,7 +21,6 @@ from sysconfig import get_platform from warnings import warn from .._convert import create_molecule -from .._mdl import common_isotopes from ...containers import MoleculeContainer from ...containers.bonds import Bond from ...exceptions import ValenceError, IsChiral, NotChiral @@ -54,8 +53,8 @@ def inchi(data, /, *, ignore_stereo: bool = False, _cls=MoleculeContainer) -> Mo atoms.append({'element': atom.atomic_symbol, 'charge': atom.charge, 'mapping': 0, 'x': atom.x, 'y': atom.y, 'z': atom.z, 'isotope': atom.isotope, 'is_radical': atom.is_radical, - 'hydrogens': atom.implicit_hydrogens, 'p': atom.implicit_protium, 'd': atom.implicit_deuterium, - 't': atom.implicit_tritium}) + 'hydrogens': atom.implicit_hydrogens, 'delta_isotope': atom.delta_isotope, + 'p': atom.implicit_protium, 'd': atom.implicit_deuterium, 't': atom.implicit_tritium}) for k in range(atom.num_bonds): m = atom.neighbor[k] @@ -200,12 +199,13 @@ def atomic_symbol(self): @property def isotope(self): - isotope = self.isotopic_mass - if not isotope: - isotope = None - elif isotope > 9000: # OVER NINE THOUSANDS! - isotope += common_isotopes[self.atomic_symbol] - 10000 - return isotope + if 0 < self.isotopic_mass < 9000: # OVER NINE THOUSANDS! + return self.isotopic_mass + + @property + def delta_isotope(self): + if self.isotope > 9000: + return self.isotope - 10_000 @property def is_radical(self): diff --git a/chython/periodictable/__init__.py b/chython/periodictable/__init__.py index 5f272d31..d494564e 100644 --- a/chython/periodictable/__init__.py +++ b/chython/periodictable/__init__.py @@ -39,6 +39,7 @@ from .groupXVII import * from .groupXVIII import * + modules = {v.__name__: v for k, v in globals().items() if k.startswith('group') and k != 'groups'} elements = {k: v for k, v in globals().items() if isinstance(v, ABCMeta) and k != 'Element' and issubclass(v, Element)} @@ -48,12 +49,21 @@ __all__.extend(elements) -for _class in (DynamicElement, QueryElement): - for k, v in elements.items(): - name = f'{_class.__name__[:-7]}{k}' - globals()[name] = cls = type(name, - (_class, *v.__mro__[-3:-1]), - {'__module__': v.__module__, '__slots__': (), 'atomic_number': v.atomic_number}) - setattr(modules[v.__module__], name, cls) - modules[v.__module__].__all__.append(name) - __all__.append(name) +for k, v in elements.items(): + name = f'Dynamic{k}' + globals()[name] = cls = type(name, (DynamicElement,), + {'__module__': v.__module__, '__slots__': (), + 'atomic_number': v.atomic_number}) + setattr(modules[v.__module__], name, cls) + modules[v.__module__].__all__.append(name) + __all__.append(name) + +for k, v in elements.items(): + name = f'Query{k}' + globals()[name] = cls = type(name, (QueryElement,), + {'__module__': v.__module__, '__slots__': (), + 'atomic_number': v.atomic_number, + 'mdl_isotope': v.mdl_isotope}) + setattr(modules[v.__module__], name, cls) + modules[v.__module__].__all__.append(name) + __all__.append(name) diff --git a/chython/periodictable/base/element.py b/chython/periodictable/base/element.py index 6b89b226..9014e064 100644 --- a/chython/periodictable/base/element.py +++ b/chython/periodictable/base/element.py @@ -32,12 +32,16 @@ class Element(ABC): def __init__(self, isotope: Optional[int] = None, *, charge: int = 0, is_radical: bool = False, x: float = 0., y: float = 0., implicit_hydrogens: Optional[int] = None, stereo: Optional[bool] = None, - parsed_mapping: Optional[int] = None): + parsed_mapping: Optional[int] = None, delta_isotope: Optional[int] = None): """ Element object with specified isotope :param isotope: Isotope number of element """ + if delta_isotope is not None: + assert isotope is None, 'isotope absolute value and delta value provided' + isotope = self.mdl_isotope + delta_isotope + self.isotope = isotope self.charge = charge self.is_radical = is_radical @@ -107,6 +111,13 @@ def atomic_radius(self) -> float: Valence radius of atom """ + @property + @abstractmethod + def mdl_isotope(self) -> int: + """ + MDL MOL common isotope + """ + @property def charge(self) -> int: """ diff --git a/chython/periodictable/groupI.py b/chython/periodictable/groupI.py index a7c10f55..a0505f20 100644 --- a/chython/periodictable/groupI.py +++ b/chython/periodictable/groupI.py @@ -48,6 +48,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 0.53 + @property + def mdl_isotope(self): + return 1 + class Li(Element, PeriodII, GroupI): __slots__ = () @@ -76,6 +80,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 167 + @property + def mdl_isotope(self): + return 7 + class Na(Element, PeriodIII, GroupI): __slots__ = () @@ -104,6 +112,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.9 + @property + def mdl_isotope(self): + return 23 + class K(Element, PeriodIV, GroupI): __slots__ = () @@ -132,6 +144,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.43 + @property + def mdl_isotope(self): + return 39 + class Rb(Element, PeriodV, GroupI): __slots__ = () @@ -160,6 +176,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.65 + @property + def mdl_isotope(self): + return 85 + class Cs(Element, PeriodVI, GroupI): __slots__ = () @@ -188,6 +208,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.98 + @property + def mdl_isotope(self): + return 133 + class Fr(Element, PeriodVII, GroupI): __slots__ = () @@ -216,5 +240,9 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.98 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 223 + __all__ = ['H', 'Li', 'Na', 'K', 'Rb', 'Cs', 'Fr'] diff --git a/chython/periodictable/groupII.py b/chython/periodictable/groupII.py index bae2cf65..8b6337d0 100644 --- a/chython/periodictable/groupII.py +++ b/chython/periodictable/groupII.py @@ -49,6 +49,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.12 + @property + def mdl_isotope(self): + return 9 + class Mg(Element, PeriodIII, GroupII): __slots__ = () @@ -81,6 +85,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.45 + @property + def mdl_isotope(self): + return 24 + class Ca(Element, PeriodIV, GroupII): __slots__ = () @@ -110,6 +118,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.94 + @property + def mdl_isotope(self): + return 40 + class Sr(Element, PeriodV, GroupII): __slots__ = () @@ -138,6 +150,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.19 + @property + def mdl_isotope(self): + return 88 + class Ba(Element, PeriodVI, GroupII): __slots__ = () @@ -167,6 +183,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.53 + @property + def mdl_isotope(self): + return 137 + class Ra(Element, PeriodVII, GroupII): __slots__ = () @@ -195,5 +215,9 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.53 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 226 + __all__ = ['Be', 'Mg', 'Ca', 'Sr', 'Ba', 'Ra'] diff --git a/chython/periodictable/groupIII.py b/chython/periodictable/groupIII.py index a2683f8d..ca11c5f1 100644 --- a/chython/periodictable/groupIII.py +++ b/chython/periodictable/groupIII.py @@ -49,6 +49,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.84 + @property + def mdl_isotope(self): + return 45 + class Y(Element, PeriodV, GroupIII): __slots__ = () @@ -77,6 +81,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.12 + @property + def mdl_isotope(self): + return 89 + class La(Element, PeriodVI, GroupIII): __slots__ = () @@ -105,6 +113,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.12 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 139 + class Ce(Element, PeriodVI, GroupIII): __slots__ = () @@ -137,6 +149,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.12 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 140 + class Pr(Element, PeriodVI, GroupIII): __slots__ = () @@ -167,6 +183,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.47 + @property + def mdl_isotope(self): + return 141 + class Nd(Element, PeriodVI, GroupIII): __slots__ = () @@ -208,6 +228,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.06 + @property + def mdl_isotope(self): + return 144 + class Pm(Element, PeriodVI, GroupIII): __slots__ = () @@ -236,6 +260,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.05 + @property + def mdl_isotope(self): + return 145 + class Sm(Element, PeriodVI, GroupIII): __slots__ = () @@ -277,6 +305,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.38 + @property + def mdl_isotope(self): + return 150 + class Eu(Element, PeriodVI, GroupIII): __slots__ = () @@ -316,6 +348,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.31 + @property + def mdl_isotope(self): + return 152 + class Gd(Element, PeriodVI, GroupIII): __slots__ = () @@ -345,6 +381,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.33 + @property + def mdl_isotope(self): + return 157 + class Tb(Element, PeriodVI, GroupIII): __slots__ = () @@ -375,6 +415,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.25 + @property + def mdl_isotope(self): + return 159 + class Dy(Element, PeriodVI, GroupIII): __slots__ = () @@ -406,6 +450,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.28 + @property + def mdl_isotope(self): + return 163 + class Ho(Element, PeriodVI, GroupIII): __slots__ = () @@ -445,6 +493,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.26 + @property + def mdl_isotope(self): + return 165 + class Er(Element, PeriodVI, GroupIII): __slots__ = () @@ -473,6 +525,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.26 + @property + def mdl_isotope(self): + return 167 + class Tm(Element, PeriodVI, GroupIII): __slots__ = () @@ -512,6 +568,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.22 + @property + def mdl_isotope(self): + return 169 + class Yb(Element, PeriodVI, GroupIII): __slots__ = () @@ -552,6 +612,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.22 + @property + def mdl_isotope(self): + return 173 + class Lu(Element, PeriodVI, GroupIII): __slots__ = () @@ -580,6 +644,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.17 + @property + def mdl_isotope(self): + return 175 + class Ac(Element, PeriodVII, GroupIII): __slots__ = () @@ -608,6 +676,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.17 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 227 + class Th(Element, PeriodVII, GroupIII): __slots__ = () @@ -641,6 +713,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.17 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 232 + class Pa(Element, PeriodVII, GroupIII): __slots__ = () @@ -671,6 +747,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.17 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 231 + class U(Element, PeriodVII, GroupIII): __slots__ = () @@ -700,6 +780,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.17 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 238 + class Np(Element, PeriodVII, GroupIII): __slots__ = () @@ -730,6 +814,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.17 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 237 + class Pu(Element, PeriodVII, GroupIII): __slots__ = () @@ -768,6 +856,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.17 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 244 + class Am(Element, PeriodVII, GroupIII): __slots__ = () @@ -796,6 +888,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.17 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 243 + class Cm(Element, PeriodVII, GroupIII): __slots__ = () @@ -824,6 +920,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.17 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 247 + class Bk(Element, PeriodVII, GroupIII): __slots__ = () @@ -852,6 +952,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.17 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 247 + class Cf(Element, PeriodVII, GroupIII): __slots__ = () @@ -880,6 +984,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.17 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 251 + class Es(Element, PeriodVII, GroupIII): __slots__ = () @@ -908,6 +1016,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.17 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 252 + class Fm(Element, PeriodVII, GroupIII): __slots__ = () @@ -936,6 +1048,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.17 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 257 + class Md(Element, PeriodVII, GroupIII): __slots__ = () @@ -964,6 +1080,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.17 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 258 + class No(Element, PeriodVII, GroupIII): __slots__ = () @@ -992,6 +1112,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.17 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 259 + class Lr(Element, PeriodVII, GroupIII): __slots__ = () @@ -1020,6 +1144,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.17 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 260 + __all__ = ['Sc', 'Y', 'La', 'Ce', 'Pr', 'Nd', 'Pm', 'Sm', 'Eu', 'Gd', 'Tb', 'Dy', 'Ho', 'Er', 'Tm', 'Yb', 'Lu', diff --git a/chython/periodictable/groupIV.py b/chython/periodictable/groupIV.py index c80e1482..70c626b8 100644 --- a/chython/periodictable/groupIV.py +++ b/chython/periodictable/groupIV.py @@ -80,6 +80,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.76 + @property + def mdl_isotope(self): + return 48 + class Zr(Element, PeriodV, GroupIV): __slots__ = () @@ -127,6 +131,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.06 + @property + def mdl_isotope(self): + return 91 + class Hf(Element, PeriodVI, GroupIV): __slots__ = () @@ -162,6 +170,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.08 + @property + def mdl_isotope(self): + return 178 + class Rf(Element, PeriodVII, GroupIV): __slots__ = () @@ -190,5 +202,9 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.08 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 261 + __all__ = ['Ti', 'Zr', 'Hf', 'Rf'] diff --git a/chython/periodictable/groupIX.py b/chython/periodictable/groupIX.py index 97608fd9..b1fe8055 100644 --- a/chython/periodictable/groupIX.py +++ b/chython/periodictable/groupIX.py @@ -71,6 +71,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.52 + @property + def mdl_isotope(self): + return 59 + class Rh(Element, PeriodV, GroupIX): __slots__ = () @@ -108,6 +112,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.73 + @property + def mdl_isotope(self): + return 103 + class Ir(Element, PeriodVI, GroupIX): __slots__ = () @@ -148,6 +156,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.8 + @property + def mdl_isotope(self): + return 192 + class Mt(Element, PeriodVII, GroupIX): __slots__ = () @@ -176,5 +188,9 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.8 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 278 + __all__ = ['Co', 'Rh', 'Ir', 'Mt'] diff --git a/chython/periodictable/groupV.py b/chython/periodictable/groupV.py index 66036c63..67e56d7d 100644 --- a/chython/periodictable/groupV.py +++ b/chython/periodictable/groupV.py @@ -68,6 +68,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.71 + @property + def mdl_isotope(self): + return 51 + class Nb(Element, PeriodV, GroupV): __slots__ = () @@ -111,6 +115,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.98 + @property + def mdl_isotope(self): + return 93 + class Ta(Element, PeriodVI, GroupV): __slots__ = () @@ -144,6 +152,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.0 + @property + def mdl_isotope(self): + return 181 + class Db(Element, PeriodVII, GroupV): __slots__ = () @@ -172,5 +184,9 @@ def _valences_exceptions(self): def atomic_radius(self): return 2.0 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 270 + __all__ = ['V', 'Nb', 'Ta', 'Db'] diff --git a/chython/periodictable/groupVI.py b/chython/periodictable/groupVI.py index 03b76191..0511d734 100644 --- a/chython/periodictable/groupVI.py +++ b/chython/periodictable/groupVI.py @@ -59,6 +59,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.66 + @property + def mdl_isotope(self): + return 52 + class Mo(Element, PeriodV, GroupVI): __slots__ = () @@ -102,6 +106,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.90 + @property + def mdl_isotope(self): + return 96 + class W(Element, PeriodVI, GroupVI): __slots__ = () @@ -135,6 +143,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.93 + @property + def mdl_isotope(self): + return 184 + class Sg(Element, PeriodVII, GroupVI): __slots__ = () @@ -163,5 +175,9 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.93 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 269 + __all__ = ['Cr', 'Mo', 'W', 'Sg'] diff --git a/chython/periodictable/groupVII.py b/chython/periodictable/groupVII.py index 3fceee40..f754b97e 100644 --- a/chython/periodictable/groupVII.py +++ b/chython/periodictable/groupVII.py @@ -57,6 +57,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.61 + @property + def mdl_isotope(self): + return 55 + class Tc(Element, PeriodV, GroupVII): __slots__ = () @@ -86,6 +90,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.83 + @property + def mdl_isotope(self): + return 98 + class Re(Element, PeriodVI, GroupVII): __slots__ = () @@ -114,6 +122,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.88 + @property + def mdl_isotope(self): + return 186 + class Bh(Element, PeriodVII, GroupVII): __slots__ = () @@ -142,5 +154,9 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.88 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 270 + __all__ = ['Mn', 'Tc', 'Re', 'Bh'] diff --git a/chython/periodictable/groupVIII.py b/chython/periodictable/groupVIII.py index ea510d60..15056c3f 100644 --- a/chython/periodictable/groupVIII.py +++ b/chython/periodictable/groupVIII.py @@ -49,6 +49,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.56 + @property + def mdl_isotope(self): + return 56 + class Ru(Element, PeriodV, GroupVIII): __slots__ = () @@ -81,6 +85,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.78 + @property + def mdl_isotope(self): + return 101 + class Os(Element, PeriodVI, GroupVIII): __slots__ = () @@ -113,6 +121,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.85 + @property + def mdl_isotope(self): + return 190 + class Hs(Element, PeriodVII, GroupVIII): __slots__ = () @@ -141,5 +153,9 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.85 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 270 + __all__ = ['Fe', 'Ru', 'Os', 'Hs'] diff --git a/chython/periodictable/groupX.py b/chython/periodictable/groupX.py index 0ca6aa05..8c8b2c08 100644 --- a/chython/periodictable/groupX.py +++ b/chython/periodictable/groupX.py @@ -52,6 +52,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.49 + @property + def mdl_isotope(self): + return 59 + class Pd(Element, PeriodV, GroupX): __slots__ = () @@ -85,6 +89,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.69 + @property + def mdl_isotope(self): + return 106 + class Pt(Element, PeriodVI, GroupX): __slots__ = () @@ -118,6 +126,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.77 + @property + def mdl_isotope(self): + return 195 + class Ds(Element, PeriodVII, GroupX): __slots__ = () @@ -146,5 +158,9 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.77 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 281 + __all__ = ['Ni', 'Pd', 'Pt', 'Ds'] diff --git a/chython/periodictable/groupXI.py b/chython/periodictable/groupXI.py index 96be94af..1c80d3d5 100644 --- a/chython/periodictable/groupXI.py +++ b/chython/periodictable/groupXI.py @@ -52,6 +52,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.45 + @property + def mdl_isotope(self): + return 64 + class Ag(Element, PeriodV, GroupXI): __slots__ = () @@ -84,6 +88,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.65 + @property + def mdl_isotope(self): + return 108 + class Au(Element, PeriodVI, GroupXI): __slots__ = () @@ -116,6 +124,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.74 + @property + def mdl_isotope(self): + return 197 + class Rg(Element, PeriodVII, GroupXI): __slots__ = () @@ -144,5 +156,9 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.74 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 281 + __all__ = ['Cu', 'Ag', 'Au', 'Rg'] diff --git a/chython/periodictable/groupXII.py b/chython/periodictable/groupXII.py index 17a3e8cf..2b59c90b 100644 --- a/chython/periodictable/groupXII.py +++ b/chython/periodictable/groupXII.py @@ -50,6 +50,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.42 + @property + def mdl_isotope(self): + return 65 + class Cd(Element, PeriodV, GroupXII): __slots__ = () @@ -80,6 +84,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.61 + @property + def mdl_isotope(self): + return 112 + class Hg(Element, PeriodVI, GroupXII): __slots__ = () @@ -110,6 +118,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.71 + @property + def mdl_isotope(self): + return 201 + class Cn(Element, PeriodVII, GroupXII): __slots__ = () @@ -138,5 +150,9 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.71 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 285 + __all__ = ['Zn', 'Cd', 'Hg', 'Cn'] diff --git a/chython/periodictable/groupXIII.py b/chython/periodictable/groupXIII.py index c0d3f507..ef5243a6 100644 --- a/chython/periodictable/groupXIII.py +++ b/chython/periodictable/groupXIII.py @@ -51,6 +51,10 @@ def _valences_exceptions(self): def atomic_radius(self): return .87 + @property + def mdl_isotope(self): + return 11 + class Al(Element, PeriodIII, GroupXIII): __slots__ = () @@ -81,6 +85,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.18 + @property + def mdl_isotope(self): + return 27 + class Ga(Element, PeriodIV, GroupXIII): __slots__ = () @@ -115,6 +123,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.36 + @property + def mdl_isotope(self): + return 70 + class In(Element, PeriodV, GroupXIII): __slots__ = () @@ -145,6 +157,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.56 + @property + def mdl_isotope(self): + return 115 + class Tl(Element, PeriodVI, GroupXIII): __slots__ = () @@ -175,6 +191,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.56 + @property + def mdl_isotope(self): + return 204 + class Nh(Element, PeriodVII, GroupXIII): __slots__ = () @@ -203,5 +223,9 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.56 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 278 + __all__ = ['B', 'Al', 'Ga', 'In', 'Tl', 'Nh'] diff --git a/chython/periodictable/groupXIV.py b/chython/periodictable/groupXIV.py index 0a18f705..bd94ad60 100644 --- a/chython/periodictable/groupXIV.py +++ b/chython/periodictable/groupXIV.py @@ -50,6 +50,10 @@ def _valences_exceptions(self): def atomic_radius(self): return .67 + @property + def mdl_isotope(self): + return 12 + class Si(Element, PeriodIII, GroupXIV): __slots__ = () @@ -78,6 +82,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.11 + @property + def mdl_isotope(self): + return 28 + class Ge(Element, PeriodIV, GroupXIV): __slots__ = () @@ -106,6 +114,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.25 + @property + def mdl_isotope(self): + return 73 + class Sn(Element, PeriodV, GroupXIV): __slots__ = () @@ -144,6 +156,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.45 + @property + def mdl_isotope(self): + return 119 + class Pb(Element, PeriodVI, GroupXIV): __slots__ = () @@ -182,6 +198,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.54 + @property + def mdl_isotope(self): + return 207 + class Fl(Element, PeriodVII, GroupXIV): __slots__ = () @@ -210,5 +230,9 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.54 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 289 + __all__ = ['C', 'Si', 'Ge', 'Sn', 'Pb', 'Fl'] diff --git a/chython/periodictable/groupXV.py b/chython/periodictable/groupXV.py index 218aeecc..700efe89 100644 --- a/chython/periodictable/groupXV.py +++ b/chython/periodictable/groupXV.py @@ -51,6 +51,10 @@ def _valences_exceptions(self): def atomic_radius(self): return .56 + @property + def mdl_isotope(self): + return 14 + class P(Element, PeriodIII, GroupXV): __slots__ = () @@ -86,6 +90,10 @@ def _valences_exceptions(self): def atomic_radius(self): return .98 + @property + def mdl_isotope(self): + return 31 + class As(Element, PeriodIV, GroupXV): __slots__ = () @@ -114,6 +122,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.14 + @property + def mdl_isotope(self): + return 75 + class Sb(Element, PeriodV, GroupXV): __slots__ = () @@ -143,6 +155,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.33 + @property + def mdl_isotope(self): + return 122 + class Bi(Element, PeriodVI, GroupXV): __slots__ = () @@ -188,6 +204,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.43 + @property + def mdl_isotope(self): + return 209 + class Mc(Element, PeriodVII, GroupXV): __slots__ = () @@ -216,5 +236,9 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.43 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 289 + __all__ = ['N', 'P', 'As', 'Sb', 'Bi', 'Mc'] diff --git a/chython/periodictable/groupXVI.py b/chython/periodictable/groupXVI.py index 4791eb2a..85f72a23 100644 --- a/chython/periodictable/groupXVI.py +++ b/chython/periodictable/groupXVI.py @@ -51,6 +51,10 @@ def _valences_exceptions(self): def atomic_radius(self): return .48 + @property + def mdl_isotope(self): + return 16 + class S(Element, PeriodIII, GroupXVI): __slots__ = () @@ -227,6 +231,10 @@ def _valences_exceptions(self): def atomic_radius(self): return .87 + @property + def mdl_isotope(self): + return 32 + class Se(Element, PeriodIV, GroupXVI): __slots__ = () @@ -286,6 +294,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.03 + @property + def mdl_isotope(self): + return 79 + class Te(Element, PeriodV, GroupXVI): __slots__ = () @@ -336,6 +348,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.23 + @property + def mdl_isotope(self): + return 128 + class Po(Element, PeriodVI, GroupXVI): __slots__ = () @@ -369,6 +385,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.35 + @property + def mdl_isotope(self): + return 209 + class Lv(Element, PeriodVII, GroupXVI): __slots__ = () @@ -397,5 +417,9 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.35 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 293 + __all__ = ['O', 'S', 'Se', 'Te', 'Po', 'Lv'] diff --git a/chython/periodictable/groupXVII.py b/chython/periodictable/groupXVII.py index da6ce4c0..3eecfc17 100644 --- a/chython/periodictable/groupXVII.py +++ b/chython/periodictable/groupXVII.py @@ -50,6 +50,10 @@ def _valences_exceptions(self): def atomic_radius(self): return .42 + @property + def mdl_isotope(self): + return 19 + class Cl(Element, PeriodIII, GroupXVII): __slots__ = () @@ -89,6 +93,10 @@ def _valences_exceptions(self): def atomic_radius(self): return .79 + @property + def mdl_isotope(self): + return 35 + class Br(Element, PeriodIV, GroupXVII): __slots__ = () @@ -135,6 +143,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 0.94 + @property + def mdl_isotope(self): + return 80 + class I(Element, PeriodV, GroupXVII): __slots__ = () @@ -203,6 +215,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.15 + @property + def mdl_isotope(self): + return 127 + class At(Element, PeriodVI, GroupXVII): __slots__ = () @@ -232,6 +248,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.27 + @property + def mdl_isotope(self): + return 210 + class Ts(Element, PeriodVII, GroupXVII): __slots__ = () @@ -260,5 +280,9 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.27 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 297 + __all__ = ['F', 'Cl', 'Br', 'I', 'At', 'Ts'] diff --git a/chython/periodictable/groupXVIII.py b/chython/periodictable/groupXVIII.py index 849a893c..b8137593 100644 --- a/chython/periodictable/groupXVIII.py +++ b/chython/periodictable/groupXVIII.py @@ -49,6 +49,10 @@ def _valences_exceptions(self): def atomic_radius(self): return .31 + @property + def mdl_isotope(self): + return 4 + class Ne(Element, PeriodII, GroupXVIII): __slots__ = () @@ -77,6 +81,10 @@ def _valences_exceptions(self): def atomic_radius(self): return .38 + @property + def mdl_isotope(self): + return 20 + class Ar(Element, PeriodIII, GroupXVIII): __slots__ = () @@ -105,6 +113,10 @@ def _valences_exceptions(self): def atomic_radius(self): return .71 + @property + def mdl_isotope(self): + return 40 + class Kr(Element, PeriodIV, GroupXVIII): __slots__ = () @@ -133,6 +145,10 @@ def _valences_exceptions(self): def atomic_radius(self): return .87 + @property + def mdl_isotope(self): + return 84 + class Xe(Element, PeriodV, GroupXVIII): __slots__ = () @@ -172,6 +188,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.08 + @property + def mdl_isotope(self): + return 131 + class Rn(Element, PeriodVI, GroupXVIII): __slots__ = () @@ -200,6 +220,10 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.2 + @property + def mdl_isotope(self): + return 222 + class Og(Element, PeriodVII, GroupXVIII): __slots__ = () @@ -228,5 +252,9 @@ def _valences_exceptions(self): def atomic_radius(self): return 1.2 # unknown, taken radius of previous element in group + @property + def mdl_isotope(self): + return 294 + __all__ = ['He', 'Ne', 'Ar', 'Kr', 'Xe', 'Rn', 'Og'] From b0921dbb1ee7ebce21a9609add59c211c57321e2 Mon Sep 17 00:00:00 2001 From: stsouko Date: Sat, 9 Nov 2024 15:00:50 +0100 Subject: [PATCH 13/67] cache invalidation fixed for kekule and thiele --- chython/algorithms/aromatics/kekule.py | 3 +++ chython/algorithms/aromatics/thiele.py | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/chython/algorithms/aromatics/kekule.py b/chython/algorithms/aromatics/kekule.py index 5a7cc494..2452d320 100644 --- a/chython/algorithms/aromatics/kekule.py +++ b/chython/algorithms/aromatics/kekule.py @@ -52,6 +52,7 @@ def kekule(self: Union['Kekule', 'MoleculeContainer'], *, buffer_size=7) -> bool for n in atoms: self.calc_implicit(n) self.flush_cache() + self.calc_labels() return True return fixed @@ -70,6 +71,7 @@ def enumerate_kekule(self: Union['Kekule', 'MoleculeContainer']): atoms.add(m) for n in atoms: copy.calc_implicit(n) + copy.calc_labels() yield copy def __fix_rings(self: 'MoleculeContainer'): @@ -92,6 +94,7 @@ def __fix_rings(self: 'MoleculeContainer'): bonds[n][m]._order = b if seen: self.flush_cache() + self.calc_labels() return True return False diff --git a/chython/algorithms/aromatics/thiele.py b/chython/algorithms/aromatics/thiele.py index 0b2ce586..9c791ddc 100644 --- a/chython/algorithms/aromatics/thiele.py +++ b/chython/algorithms/aromatics/thiele.py @@ -164,6 +164,8 @@ def thiele(self: 'MoleculeContainer', *, fix_tautomers=True) -> bool: bonds[n][m]._order = o if not acceptors: break + self.flush_cache() + self.calc_labels() if double_bonded: # delete quinones for n in double_bonded: @@ -214,6 +216,7 @@ def thiele(self: 'MoleculeContainer', *, fix_tautomers=True) -> bool: bonds[n][m]._order = 4 self.flush_cache() + self.calc_labels() for ring in freaks: # aromatize rule based for q in freak_rules: if next(q.get_mapping(self, searching_scope=ring, automorphism_filter=False), None): @@ -224,6 +227,7 @@ def thiele(self: 'MoleculeContainer', *, fix_tautomers=True) -> bool: break if freaks: self.flush_cache() # flush again + self.calc_labels() self.fix_stereo() # check if any stereo centers vanished. return True From 3d082ef906c706a6425b96aa145a078786d602a3 Mon Sep 17 00:00:00 2001 From: stsouko Date: Sun, 10 Nov 2024 10:49:37 +0100 Subject: [PATCH 14/67] api changes. all isomorphism labels now maintained --- chython/algorithms/rings.py | 46 ++++++++------------------- chython/containers/graph.py | 25 +++++++++++++-- chython/containers/molecule.py | 26 ++++++++------- chython/periodictable/base/element.py | 4 +-- 4 files changed, 52 insertions(+), 49 deletions(-) diff --git a/chython/algorithms/rings.py b/chython/algorithms/rings.py index 0b50b2a4..d2cecf1d 100644 --- a/chython/algorithms/rings.py +++ b/chython/algorithms/rings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2017-2022 Ramil Nugmanov +# Copyright 2017-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -16,7 +16,6 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from CachedMethods import cached_args_method from collections import defaultdict, deque from functools import cached_property from itertools import combinations @@ -33,7 +32,7 @@ class Rings: __slots__ = () @cached_property - def sssr(self) -> Tuple[Tuple[int, ...], ...]: + def sssr(self) -> List[Tuple[int, ...]]: """ Smallest Set of Smallest Rings. Special bonds ignored. @@ -47,10 +46,10 @@ def sssr(self) -> Tuple[Tuple[int, ...], ...]: """ if self.rings_count: return _sssr(self.not_special_connectivity, self.rings_count) - return () + return [] @cached_property - def atoms_rings(self) -> Dict[int, Tuple[Tuple[int, ...]]]: + def atoms_rings(self) -> Dict[int, List[Tuple[int, ...]]]: """ Dict of atoms rings which contains it. """ @@ -58,28 +57,17 @@ def atoms_rings(self) -> Dict[int, Tuple[Tuple[int, ...]]]: for r in self.sssr: for n in r: rings[n].append(r) - return {n: tuple(rs) for n, rs in rings.items()} + return dict(rings) @cached_property - def atoms_rings_sizes(self) -> Dict[int, Tuple[int, ...]]: + def atoms_rings_sizes(self) -> Dict[int, Set[int]]: """ Sizes of rings containing atom. """ - return {n: tuple(len(r) for r in rs) for n, rs in self.atoms_rings.items()} - - @cached_args_method - def is_ring_bond(self: 'Graph', n: int, m: int, /) -> bool: - """ - Check is bond in any ring. - """ - self.bond(n, m) # check if bond exists - try: - return not set(self.atoms_rings[n]).isdisjoint(self.atoms_rings[m]) - except KeyError: - return False + return {n: {len(r) for r in rs} for n, rs in self.atoms_rings.items()} @cached_property - def ring_atoms(self): + def ring_atoms(self) -> Set[int]: """ Atoms in rings. Not SSSR based fast algorithm. """ @@ -136,13 +124,11 @@ def not_special_connectivity(self: 'Graph') -> Dict[int, Set[int]]: return bonds @cached_property - def connected_components(self: 'Graph') -> Tuple[Tuple[int, ...], ...]: + def connected_components(self: 'Graph') -> List[Set[int]]: """ Isolated components of single graph. E.g. salts as ion pair. """ - if not self._atoms: - return () - return tuple(tuple(x) for x in self._connected_components) + return _connected_components(self._bonds) @property def connected_components_count(self) -> int: @@ -158,12 +144,8 @@ def skin_graph(self: 'Graph') -> Dict[int, Set[int]]: """ return _skin_graph(self._bonds) - @cached_property - def _connected_components(self: 'Graph') -> List[Set[int]]: - return _connected_components(self._bonds) - -def _sssr(bonds: Dict[int, Union[Set[int], Dict[int, Any]]], n_sssr: int) -> Tuple[Tuple[int, ...], ...]: +def _sssr(bonds: Dict[int, Union[Set[int], Dict[int, Any]]], n_sssr: int) -> List[Tuple[int, ...]]: """ Smallest Set of Smallest Rings of any adjacency matrix. Number of rings required. @@ -529,7 +511,7 @@ def _connected_rings(rings, seen_rings): def _rings_filter(rings, n_sssr): c = next(rings) if n_sssr == 1: - return c, + return [c] seen_rings = {c} sssr_atoms = set(c) @@ -545,7 +527,7 @@ def _rings_filter(rings, n_sssr): sssr_atoms.update(c) sssr.append(c) if len(sssr) == n_sssr: - return tuple(sssr) + return sssr # now we have set of plug rings (cuban fullerene), besiege rings and condensed trash seen_rings = {c: _ring_adjacency(c) for c in seen_rings} # prepare adjacency @@ -558,7 +540,7 @@ def _rings_filter(rings, n_sssr): condensed_rings = _connected_rings(condensed_rings, seen_rings) sssr.append(c) if len(sssr) == n_sssr: - return tuple(sorted(sssr, key=len)) + return sorted(sssr, key=len) raise ImplementationError('SSSR count not reached') diff --git a/chython/containers/graph.py b/chython/containers/graph.py index fe3dc720..7fa5dead 100644 --- a/chython/containers/graph.py +++ b/chython/containers/graph.py @@ -101,7 +101,7 @@ def add_atom(self, atom: Atom, n: Optional[int] = None) -> int: self._atoms[n] = atom self._bonds[n] = {} - self.flush_cache() + self.flush_cache(keep_sssr=True) return n @abstractmethod @@ -169,8 +169,27 @@ def union(self, other: 'Graph', *, remap: bool = False, copy: bool = True): u._bonds.update(other._bonds) return u - def flush_cache(self): - self.__dict__.clear() + def flush_cache(self, *, keep_sssr=False, keep_components=False): + backup = {} + if keep_sssr: + # good to keep if no new bonds or bonds deletions or bonds to/from any change + if 'sssr' in self.__dict__: + backup['sssr'] = self.sssr + if 'atoms_rings' in self.__dict__: + backup['atoms_rings'] = self.atoms_rings + if 'atoms_rings_sizes' in self.__dict__: + backup['atoms_rings_sizes'] = self.atoms_rings_sizes + if 'ring_atoms' in self.__dict__: + backup['ring_atoms'] = self.ring_atoms + if 'not_special_connectivity' in self.__dict__: + backup['not_special_connectivity'] = self.not_special_connectivity + if 'rings_count' in self.__dict__: + backup['rings_count'] = self.rings_count + if keep_components: + # good to keep if no new bonds or bonds deletions + if 'connected_components' in self.__dict__: + backup['connected_components'] = self.connected_components + self.__dict__ = backup def __copy__(self): return self.copy() diff --git a/chython/containers/molecule.py b/chython/containers/molecule.py index c96fb713..be079c02 100644 --- a/chython/containers/molecule.py +++ b/chython/containers/molecule.py @@ -169,7 +169,7 @@ def add_atom(self, atom: Union[Element, int, str], *args, _skip_calculation=Fals else: self._changed.add(n) if not _skip_calculation and self._backup is None: - self.fix_labels() + self.fix_structure() return n def add_bond(self, n, m, bond: Union[Bond, int], *, _skip_calculation=False): @@ -192,7 +192,7 @@ def add_bond(self, n, m, bond: Union[Bond, int], *, _skip_calculation=False): self._changed.add(n) self._changed.add(m) if not _skip_calculation and self._backup is None: - self.fix_labels() + self.fix_structure() self.fix_stereo() def delete_atom(self, n: int, *, _skip_calculation=False): @@ -213,7 +213,7 @@ def delete_atom(self, n: int, *, _skip_calculation=False): else: self._changed.add(m) if not _skip_calculation and self._backup is None: - self.fix_labels() + self.fix_structure() self.fix_stereo() def delete_bond(self, n: int, m: int, *, _skip_calculation=False): @@ -232,7 +232,7 @@ def delete_bond(self, n: int, m: int, *, _skip_calculation=False): self._changed.add(n) self._changed.add(m) if not _skip_calculation and self._backup is None: - self.fix_labels() + self.fix_structure() self.fix_stereo() def copy(self) -> 'MoleculeContainer': @@ -321,7 +321,7 @@ def substructure(self, atoms: Iterable[int], *, as_query: bool = False, recalcul sbn[m] = sb[m][n] elif m in atoms: sbn[m] = bond.copy(stereo=True) - sub.fix_labels(recalculate_hydrogens=recalculate_hydrogens) + sub.fix_structure(recalculate_hydrogens=recalculate_hydrogens) sub.fix_stereo() return sub @@ -693,22 +693,21 @@ def _augmented_substructure(self, atoms: Iterable[int], deep: int): nodes.append(n) return nodes - def fix_labels(self, recalculate_hydrogens=True): + def fix_structure(self, recalculate_hydrogens=True): """ - Fix molecule internal represenation + Fix molecule internal representation """ - if not self._changed: - return - self.calc_labels() # refresh all labels if recalculate_hydrogens: - for n in self._changed: + for n in (self._changed or self._atoms): self.calc_implicit(n) # fix Hs count self._changed = None def calc_labels(self): atoms = self._atoms + atoms_rings_sizes = self.atoms_rings_sizes # expensive: sssr based + for n, m_bond in self._bonds.items(): neighbors = 0 heteroatoms = 0 @@ -741,6 +740,9 @@ def calc_labels(self): atom._hybridization = hybridization atom._explicit_hydrogens = explicit_hydrogens + atom._in_ring = n in atoms_rings_sizes + atom._ring_sizes = atoms_rings_sizes.get(n) or set() + def calc_implicit(self, n: int): """ Set firs possible hydrogens count based on rules @@ -868,7 +870,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): self._name = backup._name self.flush_cache() else: # update internal state - self.fix_labels() + self.fix_structure() self.fix_stereo() self._backup = None # drop backup diff --git a/chython/periodictable/base/element.py b/chython/periodictable/base/element.py index 9014e064..88ca210e 100644 --- a/chython/periodictable/base/element.py +++ b/chython/periodictable/base/element.py @@ -244,7 +244,7 @@ def hybridization(self): return self._hybridization @property - def ring_sizes(self) -> Tuple[int, ...]: + def ring_sizes(self) -> Set[int]: """ Atom rings sizes. """ @@ -274,7 +274,7 @@ def copy(self, full=False, hydrogens=False, stereo=False) -> 'Element': copy._neighbors = self.neighbors copy._heteroatoms = self.heteroatoms copy._hybridization = self.hybridization - copy._ring_sizes = self.ring_sizes + copy._ring_sizes = self.ring_sizes.copy() copy._in_ring = self.in_ring else: if hydrogens: From 269da1a42ea0dc98ec6c847ffb5b97658c54ceaa Mon Sep 17 00:00:00 2001 From: stsouko Date: Sun, 10 Nov 2024 10:53:03 +0100 Subject: [PATCH 15/67] fixed aromaticity handling --- chython/algorithms/aromatics/kekule.py | 8 ++++++-- chython/algorithms/aromatics/thiele.py | 6 +++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/chython/algorithms/aromatics/kekule.py b/chython/algorithms/aromatics/kekule.py index 2452d320..f7d90918 100644 --- a/chython/algorithms/aromatics/kekule.py +++ b/chython/algorithms/aromatics/kekule.py @@ -51,7 +51,7 @@ def kekule(self: Union['Kekule', 'MoleculeContainer'], *, buffer_size=7) -> bool atoms.add(m) for n in atoms: self.calc_implicit(n) - self.flush_cache() + self.flush_cache(keep_sssr=True, keep_components=True) self.calc_labels() return True return fixed @@ -78,6 +78,7 @@ def __fix_rings(self: 'MoleculeContainer'): atoms = self._atoms bonds = self._bonds seen = set() + keep = True for q, af, bf, mm in rules: for mapping in q.get_mapping(self, automorphism_filter=False): match = set(mapping.values()) @@ -92,8 +93,11 @@ def __fix_rings(self: 'MoleculeContainer'): n = mapping[n] m = mapping[m] bonds[n][m]._order = b + if b == 8: + # flush sssr and components cache + keep = False if seen: - self.flush_cache() + self.flush_cache(keep_sssr=keep, keep_components=keep) self.calc_labels() return True return False diff --git a/chython/algorithms/aromatics/thiele.py b/chython/algorithms/aromatics/thiele.py index 9c791ddc..f236e887 100644 --- a/chython/algorithms/aromatics/thiele.py +++ b/chython/algorithms/aromatics/thiele.py @@ -164,7 +164,7 @@ def thiele(self: 'MoleculeContainer', *, fix_tautomers=True) -> bool: bonds[n][m]._order = o if not acceptors: break - self.flush_cache() + self.flush_cache(keep_sssr=True, keep_components=True) self.calc_labels() if double_bonded: # delete quinones @@ -215,7 +215,7 @@ def thiele(self: 'MoleculeContainer', *, fix_tautomers=True) -> bool: for n, m in zip(ring, ring[1:]): bonds[n][m]._order = 4 - self.flush_cache() + self.flush_cache(keep_sssr=True, keep_components=True) self.calc_labels() for ring in freaks: # aromatize rule based for q in freak_rules: @@ -226,7 +226,7 @@ def thiele(self: 'MoleculeContainer', *, fix_tautomers=True) -> bool: bonds[n][m]._order = 4 break if freaks: - self.flush_cache() # flush again + self.flush_cache(keep_sssr=True, keep_components=True) # flush again self.calc_labels() self.fix_stereo() # check if any stereo centers vanished. return True From f7d8e899bd85e194cdba63ca381756d5d290d6ac Mon Sep 17 00:00:00 2001 From: stsouko Date: Sun, 10 Nov 2024 12:54:45 +0100 Subject: [PATCH 16/67] refactored standardization --- chython/algorithms/morgan.py | 11 +- chython/algorithms/standardize/molecule.py | 165 ++++++++----------- chython/algorithms/standardize/resonance.py | 61 ++++--- chython/algorithms/standardize/salts.py | 37 ++--- chython/algorithms/standardize/saturation.py | 30 ++-- 5 files changed, 130 insertions(+), 174 deletions(-) diff --git a/chython/algorithms/morgan.py b/chython/algorithms/morgan.py index 659c50c8..36086ada 100644 --- a/chython/algorithms/morgan.py +++ b/chython/algorithms/morgan.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2017-2022 Ramil Nugmanov +# Copyright 2017-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -40,13 +40,12 @@ def atoms_order(self: 'Graph') -> Dict[int, int]: :return: dict of atom-order pairs """ - atoms = self._atoms - if not atoms: # for empty containers + if not self._atoms: # for empty containers return {} - elif len(atoms) == 1: # optimize single atom containers - return dict.fromkeys(atoms, 1) + elif len(self._atoms) == 1: # optimize single atom containers + return dict.fromkeys(self._atoms, 1) ring = self.ring_atoms - return _morgan({n: hash((hash(a), n in ring)) for n, a in atoms.items()}, self.int_adjacency) + return _morgan({n: hash((hash(a), n in ring)) for n, a in self._atoms.items()}, self.int_adjacency) @cached_property def int_adjacency(self: 'Graph') -> Dict[int, Dict[int, int]]: diff --git a/chython/algorithms/standardize/molecule.py b/chython/algorithms/standardize/molecule.py index c9fb0893..049671a2 100644 --- a/chython/algorithms/standardize/molecule.py +++ b/chython/algorithms/standardize/molecule.py @@ -50,7 +50,7 @@ def canonicalize(self: 'MoleculeContainer', *, fix_tautomers=True, keep_kekule=F h, changed = self.implicify_hydrogens(_fix_stereo=False, logging=True) if fix_tautomers and (logging or keep_kekule): # thiele can change tautomeric form - hgs = self._hydrogens.copy() + hgs = {n: a.implicit_hydrogens for n, a in self._atoms.items()} if keep_kekule: # save bond orders bonds = [(b, b.order) for _, _, b in self.bonds()] @@ -65,8 +65,9 @@ def canonicalize(self: 'MoleculeContainer', *, fix_tautomers=True, keep_kekule=F self.kekule() # we need to do full kekule again else: for b, o in bonds: # noqa - b._Bond__order = o # noqa - self.flush_cache() + b._order = o + self.flush_cache() + self.calc_labels() if logging: if k: @@ -75,13 +76,12 @@ def canonicalize(self: 'MoleculeContainer', *, fix_tautomers=True, keep_kekule=F s.append((tuple(changed), -1, 'implicified')) if t: s.append(((), -1, 'aromatized')) - if fix_tautomers and hgs != self._hydrogens: - s.append((tuple(x for x, y in self._hydrogens.items() if hgs[x] != y), - -1, 'aromatic tautomer found')) + if fix_tautomers and (x := tuple(n for n, a in self._atoms.items() if hgs[n] != a.implicit_hydrogens)): + s.append((x, -1, 'aromatic tautomer found')) if c: s.append((tuple(c), -1, 'recharged')) if keep_kekule and t: - if c or fix_tautomers and hgs != self._hydrogens: + if c or fix_tautomers and any(hgs[n] != a.implicit_hydrogens for n, a in self._atoms.items()): s.append(((), -1, 'kekulized again')) else: s.append(((), -1, 'kekule form restored')) @@ -118,16 +118,14 @@ def standardize(self: Union['MoleculeContainer', 'Standardize'], *, logging=Fals log.extend(l) fixed.update(f) - if b := fixed.intersection(n for n, h in self._hydrogens.items() if h is None): + if b := fixed.intersection(n for n, a in self._atoms.items() if a.implicit_hydrogens is None): if ignore: log.append((tuple(b), -1, 'standardization failed')) else: raise ImplementationError(f'standardization leads to invalid valences: {b}') - if fixed: - self.flush_cache() - if _fix_stereo: - self.fix_stereo() + if fixed and _fix_stereo: + self.fix_stereo() if logging: if fixed: @@ -146,10 +144,7 @@ def standardize_charges(self: 'MoleculeContainer', *, logging=False, prepare_mol changed: List[int] = [] bonds = self._bonds nsc = self.not_special_connectivity - hydrogens = self._hydrogens - charges = self._charges atoms = self._atoms - hybridization = self.hybridization if prepare_molecule: self.thiele() @@ -165,25 +160,25 @@ def standardize_charges(self: 'MoleculeContainer', *, logging=False, prepare_mol # if not 2 neighbors and 1 hydrogen or 3 neighbors within 1st and second atoms - break atom_1, atom_2 = mapping[1], mapping[2] if len(bonds[atom_1]) == 2: - if not hydrogens[atom_1]: + if not atoms[atom_1].implicit_hydrogens: continue elif all(x == 4 for x in bonds[atom_1].values()): continue if len(bonds[atom_2]) == 2: - if not hydrogens[atom_2]: + if not atoms[atom_2].implicit_hydrogens: continue elif all(x == 4 for x in bonds[atom_2].values()): continue if fix: atom_3 = mapping[3] - charges[atom_3] = 0 + atoms[atom_3]._charge = 0 changed.append(atom_3) else: - charges[atom_1] = 0 + atoms[atom_1]._charge = 0 changed.append(atom_1) - charges[atom_2] = 1 + atoms[atom_2]._charge = 1 changed.append(atom_2) # add atoms to changed # morgan @@ -196,36 +191,36 @@ def standardize_charges(self: 'MoleculeContainer', *, logging=False, prepare_mol seen.update(match) atom_1, atom_2 = mapping[1], mapping[2] if len(bonds[atom_1]) == 2: - if not hydrogens[atom_1]: + if not atoms[atom_1].implicit_hydrogens: continue elif all(x == 4 for x in bonds[atom_1].values()): continue if len(bonds[atom_2]) == 2: - if not hydrogens[atom_2]: + if not atoms[atom_2].implicit_hydrogens: continue elif all(x == 4 for x in bonds[atom_2].values()): continue if fix: atom_3 = mapping[3] - charges[atom_3] = 0 + atoms[atom_3]._charge = 0 changed.append(atom_3) else: # remove charge from 1st N atom - charges[atom_1] = 0 + atoms[atom_1]._charge = 0 pairs.append((atom_1, atom_2, fix)) if pairs: self.__dict__.pop('atoms_order', None) # remove cached morgan for atom_1, atom_2, fix in pairs: if self.atoms_order[atom_1] > self.atoms_order[atom_2]: - charges[atom_2] = 1 + atoms[atom_2]._charge = 1 changed.append(atom_2) if not fix: changed.append(atom_1) else: - charges[atom_1] = 1 + atoms[atom_1]._charge = 1 if fix: changed.append(atom_1) del self.__dict__['atoms_order'] # remove invalid morgan @@ -233,9 +228,9 @@ def standardize_charges(self: 'MoleculeContainer', *, logging=False, prepare_mol # ferrocene fcr = [] for r in self.sssr: - if len(r) != 5 or not all(hybridization(n) == 4 for n in r): + if len(r) != 5 or not all(atoms[n].hybridization == 4 for n in r): continue - ch = [(n, x) for n in r if (x := charges[n])] + ch = [(n, x) for n in r if (x := atoms[n].charge)] if len(ch) != 1 or ch[0][1] != -1: continue ch = ch[0][0] @@ -243,19 +238,19 @@ def standardize_charges(self: 'MoleculeContainer', *, logging=False, prepare_mol (len(bs := nsc[n]) == 2 or len(bs) == 3 and any(b.order == 1 for b in bonds[n].values()))] if len(ca) < 2 or ch not in ca: continue - charges[ch] = 0 # reset charge for morgan recalculation + atoms[ch]._charge = 0 # reset charge for morgan recalculation fcr.append(ca) changed.append(ch) if fcr: self.__dict__.pop('atoms_order', None) # remove cached morgan for ca in fcr: n = min(ca, key=self.atoms_order.get) - charges[n] = -1 + atoms[n]._charge = -1 changed.append(n) del self.__dict__['atoms_order'] # remove invalid morgan if changed: - self.flush_cache() # clear cache + self.flush_cache(keep_sssr=True, keep_components=True) # clear cache if _fix_stereo: self.fix_stereo() if logging: @@ -284,7 +279,8 @@ def remove_coordinate_bonds(self: 'MoleculeContainer', *, keep_to_terminal=True, del bonds[n][m], bonds[m][n] if ab: - self.flush_cache() + self.flush_cache(keep_sssr=True) + self.calc_labels() if _fix_stereo: self.fix_stereo() return len(ab) @@ -299,12 +295,7 @@ def implicify_hydrogens(self: 'MoleculeContainer', *, logging=False, _fix_stereo :param logging: return list of changed atoms. """ atoms = self._atoms - charges = self._charges - radicals = self._radicals bonds = self._bonds - plane = self._plane - hydrogens = self._hydrogens - parsed_mapping = self._parsed_mapping explicit = defaultdict(list) for n, atom in atoms.items(): @@ -322,8 +313,6 @@ def implicify_hydrogens(self: 'MoleculeContainer', *, logging=False, _fix_stereo fixed = {} for n, hs in explicit.items(): atom = atoms[n] - charge = charges[n] - is_radical = radicals[n] len_h = len(hs) for i in range(len_h, 0, -1): hi = hs[:i] @@ -335,7 +324,7 @@ def implicify_hydrogens(self: 'MoleculeContainer', *, logging=False, _fix_stereo explicit_dict[(bond.order, atoms[m].atomic_number)] += 1 try: # aromatic rings don't match any rule - rules = atom.valence_rules(charge, is_radical, explicit_sum) + rules = atom.valence_rules(explicit_sum) except ValenceError: break for s, d, h in rules: @@ -349,23 +338,15 @@ def implicify_hydrogens(self: 'MoleculeContainer', *, logging=False, _fix_stereo for n in to_remove: del atoms[n] - del charges[n] - del radicals[n] - del plane[n] - del hydrogens[n] for m in bonds.pop(n): del bonds[m][n] - try: - del parsed_mapping[n] - except KeyError: - pass for n, h in fixed.items(): - hydrogens[n] = h + atoms[n]._implicit_hydrogens = h if to_remove: - self.flush_cache() - self._conformers = [{x: y for x, y in c.items() if x not in to_remove} for c in self._conformers] # noqa + self.flush_cache(keep_sssr=True) + self.calc_labels() if _fix_stereo: self.fix_stereo() @@ -380,26 +361,28 @@ def explicify_hydrogens(self: 'MoleculeContainer', *, start_map=None, _return_ma :return: number of added atoms """ - hydrogens = self._hydrogens + atoms = self._atoms to_add = [] - for n, h in hydrogens.items(): + for n, a in atoms.items(): try: - to_add.extend([n] * h) + to_add.extend([n] * a.implicit_hydrogens) except TypeError: raise ValenceError(f'atom {n} has valence error') if to_add: log = [] bonds = self._bonds - m = start_map + m = start_map if start_map is not None else max(atoms) + 1 for n in to_add: - m = self.add_atom(H(), m) - bonds[n][m] = bonds[m][n] = b = Bond(1) - b._attach_graph(self, n, m) - hydrogens[n] = 0 + atoms[m] = H(implicit_hydrogens=0) + bonds[n][m] = b = Bond(1) + bonds[m] = {n: b} + atoms[n]._implicit_hydrogens = 0 log.append((n, m)) m += 1 + self.flush_cache(keep_sssr=True) + self.calc_labels() if _fix_stereo: self.fix_stereo() if _return_map: @@ -415,35 +398,33 @@ def check_valence(self: 'MoleculeContainer') -> List[int]: :return: list of invalid atoms """ - return [n for n, h in self._hydrogens.items() if h is None] # only invalid atoms have None hydrogens. + # only invalid atoms have None hydrogens. + return [n for n, a in self._atoms.items() if a.implicit_hydrogens is None] def clean_isotopes(self: 'MoleculeContainer') -> bool: """ Clean isotope marks from molecule. Return True if any isotope found. """ - atoms = self._atoms - isotopes = [x for x in atoms.values() if x.isotope] + isotopes = [x for x in self._atoms.values() if x.isotope] if isotopes: for i in isotopes: i._isotope = None - self.flush_cache() + self.flush_cache(keep_sssr=True, keep_components=True) self.fix_stereo() return True return False def __standardize(self: 'MoleculeContainer', rules, fix_tautomers): + atoms = self._atoms bonds = self._bonds - charges = self._charges - radicals = self._radicals - calc_implicit = self.calc_implicit log = [] fixed = set() - flush = False for r, (pattern, atom_fix, bonds_fix, any_atoms, is_tautomer) in enumerate(rules): if not fix_tautomers and is_tautomer: continue + keep_sssr = keep_components = True hs = set() seen = set() for mapping in pattern.get_mapping(self, automorphism_filter=False): @@ -457,53 +438,37 @@ def __standardize(self: 'MoleculeContainer', rules, fix_tautomers): for n, (ch, ir) in atom_fix.items(): n = mapping[n] hs.add(n) - charges[n] += ch - if charges[n] > 4: - charges[n] -= ch + a = atoms[n] + a._charge += ch + if a.charge > 4: + a._charge -= ch log.append((tuple(match), r, f'bad charge formed. changes omitted: {pattern}')) break # skip changes if ir is not None: - radicals[n] = ir + a._is_radical = ir else: - for n, m, b in bonds_fix: + for n, m, bo in bonds_fix: n = mapping[n] m = mapping[m] hs.add(n) hs.add(m) if m in bonds[n]: - bonds[n][m]._Bond__order = b # noqa - if b == 8: - # expected original molecule don't contain `any` bonds or these bonds not changed - flush = True - else: - if b != 8: - flush = True - bonds[n][m] = bonds[m][n] = b = Bond(b) - b._attach_graph(self, n, m) + b = bonds[n][m] + if b.order == 8 or b == 8: + keep_sssr = False + b._order = bo + else: # new bond + keep_sssr = keep_components = False + bonds[n][m] = bonds[m][n] = Bond(bo) log.append((tuple(match), r, str(pattern))) if not hs: # not matched continue - # flush cache only for changed atoms. - if flush: # neighbors count changed - ngb = self.__dict__['__cached_args_method_neighbors'] - for n in hs: - try: - del ngb[(n,)] - except KeyError: - pass - del self.__dict__['bonds_count'] - flush = False - # need hybridization recalculation - hyb = self.__dict__['__cached_args_method_hybridization'] - for n in hs: - try: - del hyb[(n,)] - except KeyError: # already flushed before - pass + self.flush_cache(keep_sssr=keep_sssr, keep_components=keep_components) + # recalculate isomorphism labels + self.calc_labels() for n in hs: # hydrogens count recalculation - calc_implicit(n) - del self.__dict__['_cython_compiled_structure'] + self.calc_implicit(n) fixed.update(hs) return log, fixed diff --git a/chython/algorithms/standardize/resonance.py b/chython/algorithms/standardize/resonance.py index 696b977c..31f0a0da 100644 --- a/chython/algorithms/standardize/resonance.py +++ b/chython/algorithms/standardize/resonance.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2021, 2022 Ramil Nugmanov +# Copyright 2021-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -35,21 +35,18 @@ def fix_resonance(self: Union['MoleculeContainer', 'Resonance'], *, logging=Fals :param logging: return list of changed atoms. """ atoms = self._atoms - charges = self._charges - radicals = self._radicals bonds = self._bonds - calc_implicit = self.calc_implicit entries, exits, rads, constrains, nitrogen_cat, nitrogen_ani, sulfur_cat = self.__entries() hs = set() while len(rads) > 1: n = rads.pop() for path in self.__find_delocalize_path(n, rads, constrains, True): - radicals[n] = False + atoms[n]._is_radical = False hs.add(n) for n, m, b in path: hs.add(m) - bonds[n][m]._Bond__order = b # noqa - radicals[m] = False # noqa + bonds[n][m]._order = b + atoms[m]._is_radical = False # noqa rads.discard(m) break # path found # path not found. atom n keep as is @@ -60,29 +57,31 @@ def fix_resonance(self: Union['MoleculeContainer', 'Resonance'], *, logging=Fals if n in nitrogen_cat and m in nitrogen_ani: continue - c_m = charges[m] - 1 if m in sulfur_cat: # prevent X-[S+]=X >> X=S=X if b != 1: continue + atoms[m]._charge -= 1 else: # check cations end valence. + atoms[m]._charge -= 1 # reduce atom change and check valence try: - atoms[m].valence_rules(c_m, radicals[m], sum(int(y) for x, y in bonds[m].items() if x != l) + b) + atoms[m].valence_rules(sum(int(y) for x, y in bonds[m].items() if x != l) + b) except ValenceError: + atoms[m]._charge += 1 # roll back continue - charges[n] += 1 + # succeed! + atoms[n]._charge += 1 hs.add(n) for n, m, b in path: hs.add(m) - bonds[n][m]._Bond__order = b # noqa - charges[m] = c_m + bonds[n][m]._order = b exits.discard(m) break # path from negative atom to positive atom found. # path not found. keep negative atom n as is if hs: for n in hs: - calc_implicit(n) - self.flush_cache() + self.calc_implicit(n) + self.flush_cache(keep_sssr=True, keep_components=True) if _fix_stereo: self.fix_stereo() if logging: @@ -121,13 +120,9 @@ def __find_delocalize_path(self: 'MoleculeContainer', start, finish, constrains, if n not in seen and n in constrains and 1 <= (bo := b.order + diff) <= 3) def __entries(self: 'MoleculeContainer'): - hybridization = self.hybridization - neighbors = self.neighbors - charges = self._charges - radicals = self._radicals - bonds = self._bonds atoms = self._atoms - errors = {n for n, h in self._hydrogens.items() if h is None} + bonds = self._bonds + errors = {n for n, a in atoms.items() if a.implicit_hydrogens is None} transfer = set() entries = set() @@ -140,9 +135,9 @@ def __entries(self: 'MoleculeContainer'): if a.atomic_number not in {5, 6, 7, 8, 14, 15, 16, 33, 34, 52}: # filter non-organic set, halogens and aromatics continue - elif radicals[n]: + elif a.is_radical: rads.add(n) - elif charges[n] == -1: + elif a.charge == -1: if (lb := len(bonds[n])) == 4 and a.atomic_number == 5: # skip boron continue elif lb == 6 and a.atomic_number == 15: # skip [P-]X6 @@ -150,35 +145,37 @@ def __entries(self: 'MoleculeContainer'): if n in errors: # only valid anions accepted continue entries.add(n) - elif charges[n] == 1: + elif a.charge == 1: lb = len(bonds[n]) if a.atomic_number == 7: if lb == 4: # skip ammonia continue - elif lb == 2 and hybridization(n) == 3: # skip Azide + elif lb == 2 and a.hybridization == 3: # skip Azide (n1, b1), (n2, b2) = bonds[n].items() - if b1.order == b2.order == 2 and (charges[n1] == -1 and atoms[n1].atomic_number == 7 or - charges[n2] == -1 and atoms[n2].atomic_number == 7): + an1 = atoms[n1] + an2 = atoms[n2] + if b1.order == b2.order == 2 and (an1.charge == -1 and an1.atomic_number == 7 or + an2.charge == -1 and an2.atomic_number == 7): continue - elif lb == 3 and hybridization(n) == 2: # X=[N+](-X)-X - prevent N-N migration + elif lb == 3 and a.hybridization == 2: # X=[N+](-X)-X - prevent N-N migration nitrogen_ani.add(n) elif a.atomic_number == 15 and lb == 4: # skip [P+]R4 continue elif a.atomic_number == 16: - if lb == 2 and hybridization(n) == 2: # ad-hoc for X-[S+]=X + if lb == 2 and a.hybridization == 2: # ad-hoc for X-[S+]=X sulfur_cat.add(n) - elif lb == 3 and hybridization(n) == 1: # ad-hoc for X-[S+](-X)-X + elif lb == 3 and a.hybridization == 1: # ad-hoc for X-[S+](-X)-X continue exits.add(n) transfer.add(n) if exits or entries: # try to move cation to nitrogen. saturation fixup. for n, a in self._atoms.items(): - if a.atomic_number == 7 and not charges[n]: - if hybridization(n) == 1 and neighbors(n) <= 3: # any amine - potential e-donor + if a.atomic_number == 7 and not a.charge: + if a.hybridization == 1 and a.neighbors <= 3: # any amine - potential e-donor entries.add(n) nitrogen_cat.add(n) - elif hybridization(n) == 3 and neighbors(n) == 1: # N#X-[X-] >> [N-]=X=X + elif a.hybridization == 3 and a.neighbors == 1: # N#X-[X-] >> [N-]=X=X exits.add(n) nitrogen_ani.add(n) return entries, exits, rads, transfer, nitrogen_cat, nitrogen_ani, sulfur_cat diff --git a/chython/algorithms/standardize/salts.py b/chython/algorithms/standardize/salts.py index 08a34250..d281b593 100644 --- a/chython/algorithms/standardize/salts.py +++ b/chython/algorithms/standardize/salts.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2021-2023 Ramil Nugmanov +# Copyright 2021-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -33,16 +33,20 @@ def remove_metals(self: 'MoleculeContainer', *, logging=False) -> Union[bool, Li :param logging: return deleted atoms list. """ + atoms = self._atoms bonds = self._bonds metals = [] - for n, a in self._atoms.items(): - if a.atomic_symbol not in {7, 3, 4, 11, 12, 19, 20, 37, 38, 55, 56} and not bonds[n]: + for n, a in atoms.items(): + if a.atomic_number in {7, 3, 4, 11, 12, 19, 20, 37, 38, 55, 56} and not bonds[n]: metals.append(n) if 0 < len(metals) < len(self): for n in metals: - self.delete_atom(n) + del atoms[n] + del bonds[n] + + self.flush_cache(keep_sssr=True) if logging: return metals return True @@ -64,27 +68,12 @@ def remove_acids(self: 'MoleculeContainer', *, logging=False) -> Union[bool, Lis log.extend(c) if 0 < len(log) < len(self): # prevent singularity atoms = self._atoms - charges = self._charges - radicals = self._radicals - hydrogens = self._hydrogens - plane = self._plane bonds = self._bonds - parsed_mapping = self._parsed_mapping - - self._conformers.clear() # clean conformers. for n in log: del atoms[n] - del charges[n] - del radicals[n] - del hydrogens[n] - del plane[n] del bonds[n] - try: - del parsed_mapping[n] - except KeyError: - pass self.flush_cache() if logging: return log @@ -99,10 +88,10 @@ def split_metal_salts(self: 'MoleculeContainer', *, logging=False) -> Union[bool :param logging: return deleted bonds list. """ + atoms = self._atoms bonds = self._bonds - charges = self._charges - metals = [n for n, a in self._atoms.items() if a.atomic_number in + metals = [n for n, a in atoms.items() if a.atomic_number in {3, 4, 11, 12, 19, 20, 37, 38, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102}] if metals: @@ -114,12 +103,12 @@ def split_metal_salts(self: 'MoleculeContainer', *, logging=False) -> Union[bool for n in metals: for m in acceptors & bonds[n].keys(): - if charges[n] == 4: # prevent overcharging + if atoms[n].charge == 4: # prevent overcharging break del bonds[n][m] del bonds[m][n] - charges[n] += 1 - charges[m] -= 1 + atoms[n]._charge += 1 + atoms[m]._charge -= 1 log.append((n, m)) if log: self.flush_cache() diff --git a/chython/algorithms/standardize/saturation.py b/chython/algorithms/standardize/saturation.py index df9de68a..38c5bb1e 100644 --- a/chython/algorithms/standardize/saturation.py +++ b/chython/algorithms/standardize/saturation.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2021, 2022 Ramil Nugmanov +# Copyright 2021-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -72,13 +72,17 @@ def saturate(self: 'MoleculeContainer', neighbors_distances: Optional[Dict[int, raise ValenceError('only single bonded skeleton can be saturated') atoms = self._atoms if not reset_electrons: - expected_radicals_count = any(self._radicals.values()) + expected_radicals_count = sum(a.is_radical for a in atoms.values()) expected_charge = int(self) + if reset_electrons: + charges = {x: None for x in self._atoms} + radicals = {x: None for x in self._atoms} + else: + charges = {n: a.charge for n, a in self._atoms.items()} + radicals = {n: a.is_radical for n, a in self._atoms.items()} sat, adjacency = _find_possible_valences(atoms, neighbors_distances or self._bonds, - {x: None for x in self._atoms} if reset_electrons else self._charges, - {x: None for x in self._atoms} if reset_electrons else self._radicals, - neighbors_distances is not None) + charges, radicals, neighbors_distances is not None) charges = {} # new charge states radicals = {} # new radical states bonds = {n: {} for n in atoms} # new bonds @@ -95,8 +99,7 @@ def saturate(self: 'MoleculeContainer', neighbors_distances: Optional[Dict[int, radicals[n] = r for m in env: if m not in seen: - bonds[n][m] = bonds[m][n] = b = Bond(1) - b._attach_graph(self, n, m) + bonds[n][m] = bonds[m][n] = Bond(1) else: unsaturated[n] = [(c, r, h)] else: @@ -142,8 +145,7 @@ def saturate(self: 'MoleculeContainer', neighbors_distances: Optional[Dict[int, return False for n, m, b in sb: - bonds[n][m] = bonds[m][n] = b = Bond(b) - b._attach_graph(self, n, m) + bonds[n][m] = bonds[m][n] = Bond(b) for n, c, r in sa: charges[n] = c radicals[n] = r @@ -155,10 +157,14 @@ def saturate(self: 'MoleculeContainer', neighbors_distances: Optional[Dict[int, return False # reset molecule self._bonds = bonds - self._radicals = radicals - self._charges = charges - self._hydrogens = {x: 0 for x in atoms} # reset invalid hydrogens counts. + for n, r in radicals.items(): + atoms[n]._is_radical = r + for n, c in charges.items(): + atoms[n]._charge = c + for a in atoms.values(): + a._implicit_hydrogens = 0 # reset invalid hydrogens counts. self.flush_cache() + self.calc_labels() if logging: if not log: # check for errors log.append('Saturated successfully') From 0f46bc23ef72e56d0300c8e7ead355c58ec1d2b1 Mon Sep 17 00:00:00 2001 From: stsouko Date: Sun, 10 Nov 2024 12:57:58 +0100 Subject: [PATCH 17/67] fix --- chython/algorithms/standardize/reaction.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/chython/algorithms/standardize/reaction.py b/chython/algorithms/standardize/reaction.py index 17128417..1cb20f28 100644 --- a/chython/algorithms/standardize/reaction.py +++ b/chython/algorithms/standardize/reaction.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2022 Ramil Nugmanov +# Copyright 2018-2024 Ramil Nugmanov # Copyright 2021 Timur Gimadiev # Copyright 2024 Philippe Gantzer # This file is part of chython. @@ -90,7 +90,7 @@ def thiele(self: 'ReactionContainer', *, fix_tautomers=True) -> bool: """ total = False for m in self.molecules(): - if m.thiele(fix_tautomers=fix_tautomers) and not total: + if m.thiele(fix_tautomers=fix_tautomers): total = True if total: self.flush_cache() @@ -105,7 +105,7 @@ def kekule(self: 'ReactionContainer', *, buffer_size=7) -> bool: """ total = False for m in self.molecules(): - if m.kekule(buffer_size=buffer_size) and not total: + if m.kekule(buffer_size=buffer_size): total = True if total: self.flush_cache() @@ -118,7 +118,7 @@ def clean_isotopes(self: 'ReactionContainer') -> bool: """ flag = False for m in self.molecules(): - if m.clean_isotopes() and not flag: + if m.clean_isotopes(): flag = True if flag: self.flush_cache() From 177373e57e94932bbc0081eb32b10f742715efd4 Mon Sep 17 00:00:00 2001 From: stsouko Date: Sun, 10 Nov 2024 13:17:50 +0100 Subject: [PATCH 18/67] isomorphism fixed --- chython/algorithms/isomorphism.py | 20 ++++++++++---------- chython/containers/bonds.py | 2 -- chython/containers/molecule.py | 4 ++++ 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/chython/algorithms/isomorphism.py b/chython/algorithms/isomorphism.py index ce9193bc..eb44cc71 100644 --- a/chython/algorithms/isomorphism.py +++ b/chython/algorithms/isomorphism.py @@ -106,7 +106,7 @@ def _get_mapping(self, other, /, *, automorphism_filter=True, searching_scope=No seen = set() if len(components) == 1: - for candidate in other._connected_components: + for candidate in other.connected_components: if searching_scope: candidate = searching_scope.intersection(candidate) if not candidate: @@ -119,7 +119,7 @@ def _get_mapping(self, other, /, *, automorphism_filter=True, searching_scope=No seen.add(atoms) yield mapping else: - for candidates in permutations(other._connected_components, len(components)): + for candidates in permutations(other.connected_components, len(components)): mappers = [] for component, candidate in zip(components, candidates): if searching_scope: @@ -206,23 +206,23 @@ def _cython_compiled_structure(self): if a.isotope: v3 = 1 << (a.isotope - a.mdl_isotope + 54) - if radicals[n]: + if a.is_radical: v3 |= 0x200000000000 else: v3 |= 0x100000000000 - elif radicals[n]: + elif a.is_radical: v3 = 0x8000200000000000 else: v3 = 0x8000100000000000 - v3 |= 1 << (charges[n] + 39) - v3 |= 1 << ((hydrogens[n] or 0) + 30) - v3 |= 1 << (neighbors(n) + 15) - v3 |= 1 << heteroatoms(n) + v3 |= 1 << (a.charge + 39) + v3 |= 1 << ((a.implicit_hydrogens or 0) + 30) + v3 |= 1 << (a.neighbors + 15) + v3 |= 1 << a.heteroatoms - if n in rings_sizes: + if a.ring_sizes: v4 = 0 - for r in rings_sizes[n]: + for r in a.ring_sizes: if r > 65: # big rings not supported continue v4 |= 1 << (65 - r) diff --git a/chython/containers/bonds.py b/chython/containers/bonds.py index 79f13cad..a6ce7721 100644 --- a/chython/containers/bonds.py +++ b/chython/containers/bonds.py @@ -28,7 +28,6 @@ def __init__(self, order: int): elif order not in (1, 4, 2, 3, 8): raise ValueError('order should be from [1, 2, 3, 4, 8]') self._order = order - self._in_ring = False self._stereo = None def __eq__(self, other): @@ -72,7 +71,6 @@ def copy(self, full=False, stereo=False) -> 'Bond': copy._stereo = self.stereo copy._in_ring = self.in_ring else: - copy._in_ring = False if stereo: copy._stereo = self.stereo else: diff --git a/chython/containers/molecule.py b/chython/containers/molecule.py index be079c02..9c47cf46 100644 --- a/chython/containers/molecule.py +++ b/chython/containers/molecule.py @@ -707,13 +707,17 @@ def fix_structure(self, recalculate_hydrogens=True): def calc_labels(self): atoms = self._atoms atoms_rings_sizes = self.atoms_rings_sizes # expensive: sssr based + atoms_rings = {n: set(r) for n, r in self.atoms_rings.items()} for n, m_bond in self._bonds.items(): neighbors = 0 heteroatoms = 0 hybridization = 1 explicit_hydrogens = 0 + ar = atoms_rings[n] for m, bond in m_bond.items(): + bond._in_ring = not ar.isdisjoint(atoms_rings[m]) # have common rings + order = bond.order if order == 8: continue From d983bb5bc3d3b4f021d1a8c5fea258397161d1eb Mon Sep 17 00:00:00 2001 From: stsouko Date: Sun, 10 Nov 2024 13:38:58 +0100 Subject: [PATCH 19/67] isomorphism fixed --- chython/algorithms/isomorphism.py | 4 ++-- chython/containers/molecule.py | 4 ++-- chython/periodictable/base/query.py | 11 ++++++++--- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/chython/algorithms/isomorphism.py b/chython/algorithms/isomorphism.py index eb44cc71..4f8c1e74 100644 --- a/chython/algorithms/isomorphism.py +++ b/chython/algorithms/isomorphism.py @@ -22,7 +22,7 @@ from itertools import permutations from typing import Any, Collection, Dict, Iterator, Optional, TYPE_CHECKING, Union from .._functions import lazy_product -from ..periodictable import Element, Query, AnyElement, AnyMetal, ListElement +from ..periodictable import Element, Query, AnyElement, AnyMetal, ListElement, QueryElement if TYPE_CHECKING: @@ -367,7 +367,7 @@ def _cython_compiled_query(self): else: v1 = 1 << (57 - n) v2 = 0 - if a.isotope: + if isinstance(a, QueryElement) and a.isotope: v3 = 1 << (a.isotope - a.mdl_isotope + 54) if a.is_radical: v3 |= 0x200000000000 diff --git a/chython/containers/molecule.py b/chython/containers/molecule.py index 9c47cf46..fc2c7cb2 100644 --- a/chython/containers/molecule.py +++ b/chython/containers/molecule.py @@ -714,9 +714,9 @@ def calc_labels(self): heteroatoms = 0 hybridization = 1 explicit_hydrogens = 0 - ar = atoms_rings[n] + anr = atoms_rings.get(n) or False for m, bond in m_bond.items(): - bond._in_ring = not ar.isdisjoint(atoms_rings[m]) # have common rings + bond._in_ring = anr and (amr := atoms_rings.get(m) or False) and not anr.isdisjoint(amr) # have common rings order = bond.order if order == 8: diff --git a/chython/periodictable/base/query.py b/chython/periodictable/base/query.py index 2089bc17..1d00a29b 100644 --- a/chython/periodictable/base/query.py +++ b/chython/periodictable/base/query.py @@ -268,7 +268,7 @@ def __eq__(self, other): return False if self.ring_sizes: if self.ring_sizes[0]: - if set(self.ring_sizes).isdisjoint(other.ring_sizes): + if other.ring_sizes.isdisjoint(self.ring_sizes): return False elif other.ring_sizes: # not in ring expected return False @@ -342,7 +342,7 @@ def __eq__(self, other): return False if self.ring_sizes: if self.ring_sizes[0]: - if set(self.ring_sizes).isdisjoint(other.ring_sizes): + if other.ring_sizes.isdisjoint(self.ring_sizes): return False elif other.ring_sizes: # not in ring expected return False @@ -407,6 +407,11 @@ def isotope(self, value: Optional[int]): raise TypeError('isotope must be an int') self._isotope = value + @property + @abstractmethod + def mdl_isotope(self) -> int: + ... + @classmethod def from_symbol(cls, symbol: str) -> Type[Union['QueryElement', 'AnyElement', 'AnyMetal']]: """ @@ -485,7 +490,7 @@ def __eq__(self, other): return False if self.ring_sizes: if self.ring_sizes[0]: - if set(self.ring_sizes).isdisjoint(other.ring_sizes): + if other.ring_sizes.isdisjoint(self.ring_sizes): return False elif other.ring_sizes: # not in ring expected return False From acb4ad90da5f6d6447102f0aa8599838edb06e07 Mon Sep 17 00:00:00 2001 From: stsouko Date: Mon, 11 Nov 2024 22:18:53 +0100 Subject: [PATCH 20/67] fixed depict --- chython/algorithms/depict.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/chython/algorithms/depict.py b/chython/algorithms/depict.py index 1189d32a..a48eb6c7 100644 --- a/chython/algorithms/depict.py +++ b/chython/algorithms/depict.py @@ -344,9 +344,6 @@ def __render_atoms(self: 'MoleculeContainer', uid): stroke_width_o = other_size * .1 stroke_width_m = mapping_size * .1 - # for cumulenes - cumulenes = {y for x in self._cumulenes(heteroatoms=True) if len(x) > 2 for y in x[1:-1]} - svg = [] maps = [] symbols = [] @@ -358,7 +355,8 @@ def __render_atoms(self: 'MoleculeContainer', uid): for n, atom in self._atoms.items(): x, y = atom.x, -atom.y symbol = atom.atomic_symbol - if not bonds[n] or symbol != 'C' or carbon or atom.charge or atom.is_radical or atom.isotope or n in cumulenes: + if (symbol != 'C' or atom.charge or atom.is_radical or atom.isotope or carbon + or not bonds[n] or sum(b == 2 for b in bonds[n].values()) == 2): if atom.charge: others.append(f' ' f'{_render_charge[atom.charge]}{"↑" if atom.is_radical else ""}') From d1a3909b6487dd69a13bffcbfbca607944fdadc4 Mon Sep 17 00:00:00 2001 From: stsouko Date: Mon, 11 Nov 2024 22:23:44 +0100 Subject: [PATCH 21/67] new attrs of atoms. fixes. --- chython/algorithms/rings.py | 2 +- chython/algorithms/x3dom.py | 10 ++++++-- chython/files/xyz.py | 8 +++--- chython/periodictable/base/element.py | 14 +++++++++++ chython/periodictable/groupI.py | 4 +++ chython/periodictable/groupXIII.py | 8 ++++++ chython/periodictable/groupXIV.py | 24 ++++++++++++++++++ chython/periodictable/groupXV.py | 32 ++++++++++++++++++++++++ chython/periodictable/groupXVI.py | 32 ++++++++++++++++++++++++ chython/periodictable/groupXVII.py | 36 +++++++++++++++++++++++++++ 10 files changed, 162 insertions(+), 8 deletions(-) diff --git a/chython/algorithms/rings.py b/chython/algorithms/rings.py index d2cecf1d..37cde6dc 100644 --- a/chython/algorithms/rings.py +++ b/chython/algorithms/rings.py @@ -51,7 +51,7 @@ def sssr(self) -> List[Tuple[int, ...]]: @cached_property def atoms_rings(self) -> Dict[int, List[Tuple[int, ...]]]: """ - Dict of atoms rings which contains it. + A dictionary with atom numbers as keys and a list of tuples (representing rings) as values. """ rings = defaultdict(list) for r in self.sssr: diff --git a/chython/algorithms/x3dom.py b/chython/algorithms/x3dom.py index f5da216d..2118899b 100644 --- a/chython/algorithms/x3dom.py +++ b/chython/algorithms/x3dom.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2020-2022 Ramil Nugmanov +# Copyright 2020-2024 Ramil Nugmanov # Copyright 2020 Dinar Batyrshin # This file is part of chython. # @@ -141,7 +141,13 @@ def depict3d(self: Union['MoleculeContainer', 'X3domMolecule'], index: int = 0) :param index: index of conformer """ - xyz = self._conformers[index] + if not hasattr(self, '_conformers'): + raise ValueError('No conformers stored within structure') + try: + xyz = self._conformers[index] + except IndexError: + raise IndexError('Invalid conformer index') + mx = sum(x for x, _, _ in xyz.values()) / len(xyz) my = sum(y for _, y, _ in xyz.values()) / len(xyz) mz = sum(z for _, _, z in xyz.values()) / len(xyz) diff --git a/chython/files/xyz.py b/chython/files/xyz.py index 612415bc..a77a8489 100644 --- a/chython/files/xyz.py +++ b/chython/files/xyz.py @@ -31,16 +31,13 @@ def xyz(matrix: Sequence[Tuple[str, float, float, float]], charge=0, radical=0, mol = _cls() conformer = {} - mol._conformers.append(conformer) + mol._conformers = [conformer] atoms = mol._atoms bonds = mol._bonds for n, (a, x, y, z) in enumerate(matrix, 1): - atoms[n] = atom = Element.from_symbol(a)() + atoms[n] = Element.from_symbol(a)(x=x, y=y, implicit_hydrogens=0) bonds[n] = {} - atom.x = x - atom.y = y - atom._implicit_hydrogens = 0 conformer[n] = (x, y, z) if atom_charge is not None and None not in atom_charge: @@ -48,6 +45,7 @@ def xyz(matrix: Sequence[Tuple[str, float, float, float]], charge=0, radical=0, atoms[n]._charge = c charge = sum(atom_charge) + mol.calc_labels() pb = possible_bonds(array(list(conformer.values())), array([a.atomic_radius for a in atoms.values()]), radius_multiplier) diff --git a/chython/periodictable/base/element.py b/chython/periodictable/base/element.py index 88ca210e..7818af9a 100644 --- a/chython/periodictable/base/element.py +++ b/chython/periodictable/base/element.py @@ -118,6 +118,20 @@ def mdl_isotope(self) -> int: MDL MOL common isotope """ + @property + def is_forming_single_bonds(self) -> bool: + """ + Atom can form stable covalent single bonds in molecules + """ + return False + + @property + def is_forming_double_bonds(self) -> bool: + """ + Atom can form stable covalent double bonds in molecules + """ + return False + @property def charge(self) -> int: """ diff --git a/chython/periodictable/groupI.py b/chython/periodictable/groupI.py index a0505f20..df3631f2 100644 --- a/chython/periodictable/groupI.py +++ b/chython/periodictable/groupI.py @@ -52,6 +52,10 @@ def atomic_radius(self): def mdl_isotope(self): return 1 + @property + def is_forming_single_bonds(self): + return True + class Li(Element, PeriodII, GroupI): __slots__ = () diff --git a/chython/periodictable/groupXIII.py b/chython/periodictable/groupXIII.py index ef5243a6..e89d745f 100644 --- a/chython/periodictable/groupXIII.py +++ b/chython/periodictable/groupXIII.py @@ -55,6 +55,14 @@ def atomic_radius(self): def mdl_isotope(self): return 11 + @property + def is_forming_single_bonds(self): + return True + + @property + def is_forming_double_bonds(self): + return True + class Al(Element, PeriodIII, GroupXIII): __slots__ = () diff --git a/chython/periodictable/groupXIV.py b/chython/periodictable/groupXIV.py index bd94ad60..43cca943 100644 --- a/chython/periodictable/groupXIV.py +++ b/chython/periodictable/groupXIV.py @@ -54,6 +54,14 @@ def atomic_radius(self): def mdl_isotope(self): return 12 + @property + def is_forming_single_bonds(self): + return True + + @property + def is_forming_double_bonds(self): + return True + class Si(Element, PeriodIII, GroupXIV): __slots__ = () @@ -86,6 +94,14 @@ def atomic_radius(self): def mdl_isotope(self): return 28 + @property + def is_forming_single_bonds(self): + return True + + @property + def is_forming_double_bonds(self): + return True + class Ge(Element, PeriodIV, GroupXIV): __slots__ = () @@ -118,6 +134,14 @@ def atomic_radius(self): def mdl_isotope(self): return 73 + @property + def is_forming_single_bonds(self): + return True + + @property + def is_forming_double_bonds(self): + return True + class Sn(Element, PeriodV, GroupXIV): __slots__ = () diff --git a/chython/periodictable/groupXV.py b/chython/periodictable/groupXV.py index 700efe89..5f031016 100644 --- a/chython/periodictable/groupXV.py +++ b/chython/periodictable/groupXV.py @@ -55,6 +55,14 @@ def atomic_radius(self): def mdl_isotope(self): return 14 + @property + def is_forming_single_bonds(self): + return True + + @property + def is_forming_double_bonds(self): + return True + class P(Element, PeriodIII, GroupXV): __slots__ = () @@ -94,6 +102,14 @@ def atomic_radius(self): def mdl_isotope(self): return 31 + @property + def is_forming_single_bonds(self): + return True + + @property + def is_forming_double_bonds(self): + return True + class As(Element, PeriodIV, GroupXV): __slots__ = () @@ -126,6 +142,14 @@ def atomic_radius(self): def mdl_isotope(self): return 75 + @property + def is_forming_single_bonds(self): + return True + + @property + def is_forming_double_bonds(self): + return True + class Sb(Element, PeriodV, GroupXV): __slots__ = () @@ -159,6 +183,14 @@ def atomic_radius(self): def mdl_isotope(self): return 122 + @property + def is_forming_single_bonds(self): + return True + + @property + def is_forming_double_bonds(self): + return True + class Bi(Element, PeriodVI, GroupXV): __slots__ = () diff --git a/chython/periodictable/groupXVI.py b/chython/periodictable/groupXVI.py index 85f72a23..0c782531 100644 --- a/chython/periodictable/groupXVI.py +++ b/chython/periodictable/groupXVI.py @@ -55,6 +55,14 @@ def atomic_radius(self): def mdl_isotope(self): return 16 + @property + def is_forming_single_bonds(self): + return True + + @property + def is_forming_double_bonds(self): + return True + class S(Element, PeriodIII, GroupXVI): __slots__ = () @@ -235,6 +243,14 @@ def atomic_radius(self): def mdl_isotope(self): return 32 + @property + def is_forming_single_bonds(self): + return True + + @property + def is_forming_double_bonds(self): + return True + class Se(Element, PeriodIV, GroupXVI): __slots__ = () @@ -298,6 +314,14 @@ def atomic_radius(self): def mdl_isotope(self): return 79 + @property + def is_forming_single_bonds(self): + return True + + @property + def is_forming_double_bonds(self): + return True + class Te(Element, PeriodV, GroupXVI): __slots__ = () @@ -352,6 +376,14 @@ def atomic_radius(self): def mdl_isotope(self): return 128 + @property + def is_forming_single_bonds(self): + return True + + @property + def is_forming_double_bonds(self): + return True + class Po(Element, PeriodVI, GroupXVI): __slots__ = () diff --git a/chython/periodictable/groupXVII.py b/chython/periodictable/groupXVII.py index 3eecfc17..3be4f6a7 100644 --- a/chython/periodictable/groupXVII.py +++ b/chython/periodictable/groupXVII.py @@ -54,6 +54,10 @@ def atomic_radius(self): def mdl_isotope(self): return 19 + @property + def is_forming_single_bonds(self): + return True + class Cl(Element, PeriodIII, GroupXVII): __slots__ = () @@ -97,6 +101,14 @@ def atomic_radius(self): def mdl_isotope(self): return 35 + @property + def is_forming_single_bonds(self): + return True + + @property + def is_forming_double_bonds(self): + return True + class Br(Element, PeriodIV, GroupXVII): __slots__ = () @@ -147,6 +159,14 @@ def atomic_radius(self): def mdl_isotope(self): return 80 + @property + def is_forming_single_bonds(self): + return True + + @property + def is_forming_double_bonds(self): + return True + class I(Element, PeriodV, GroupXVII): __slots__ = () @@ -219,6 +239,14 @@ def atomic_radius(self): def mdl_isotope(self): return 127 + @property + def is_forming_single_bonds(self): + return True + + @property + def is_forming_double_bonds(self): + return True + class At(Element, PeriodVI, GroupXVII): __slots__ = () @@ -252,6 +280,14 @@ def atomic_radius(self): def mdl_isotope(self): return 210 + @property + def is_forming_single_bonds(self): + return True + + @property + def is_forming_double_bonds(self): + return True + class Ts(Element, PeriodVII, GroupXVII): __slots__ = () From ebedc7f2b3d6b10a9d918bb43d4c2e899f3543eb Mon Sep 17 00:00:00 2001 From: stsouko Date: Mon, 11 Nov 2024 22:30:19 +0100 Subject: [PATCH 22/67] stereo refactored. simplified stereo in queries. now it's users problem to set it right. query isomorphism reduced to query to atom. --- chython/algorithms/isomorphism.py | 4 +- chython/algorithms/mapping/fixmapper.py | 4 +- chython/algorithms/smiles.py | 2 +- .../{stereo/molecule.py => stereo.py} | 864 +++++++++++++----- chython/algorithms/stereo/__init__.py | 23 - chython/algorithms/stereo/graph.py | 467 ---------- chython/containers/molecule.py | 4 +- chython/containers/query.py | 3 +- chython/files/daylight/smiles.py | 4 +- chython/files/libinchi/wrapper.py | 4 +- chython/periodictable/base/query.py | 189 ++-- chython/reactor/base.py | 26 +- chython/utils/rdkit.py | 2 +- 13 files changed, 722 insertions(+), 874 deletions(-) rename chython/algorithms/{stereo/molecule.py => stereo.py} (52%) delete mode 100644 chython/algorithms/stereo/__init__.py delete mode 100644 chython/algorithms/stereo/graph.py diff --git a/chython/algorithms/isomorphism.py b/chython/algorithms/isomorphism.py index 4f8c1e74..8c0de0a5 100644 --- a/chython/algorithms/isomorphism.py +++ b/chython/algorithms/isomorphism.py @@ -271,9 +271,9 @@ class QueryIsomorphism(Isomorphism): def get_mapping(self, other: Union['MoleculeContainer', 'QueryContainer'], /, *, automorphism_filter: bool = True, searching_scope: Optional[Collection[int]] = None, _cython=True): """ - Get self to other Molecule or Query substructure mapping generator. + Get Query to Molecule substructure mapping generator. - :param other: Molecule or Query + :param other: Molecule :param automorphism_filter: Skip matches to the same atoms. :param searching_scope: substructure atoms list to localize isomorphism. """ diff --git a/chython/algorithms/mapping/fixmapper.py b/chython/algorithms/mapping/fixmapper.py index 84768bdc..251eea95 100644 --- a/chython/algorithms/mapping/fixmapper.py +++ b/chython/algorithms/mapping/fixmapper.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2022 Ramil Nugmanov +# Copyright 2022-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -50,7 +50,7 @@ def fix_mapping(self: 'ReactionContainer', *, logging: bool = False) -> \ free_number = count(max(cgr) + 1) components = [(cgr.substructure(c), cgr.augmented_substructure(c, 2), # deep DEPENDS on rules! - set(c)) + c) for c in cgr.substructure(cgr.center_atoms).connected_components] r_atoms = ChainMap(*(x._atoms for x in self.reactants)) diff --git a/chython/algorithms/smiles.py b/chython/algorithms/smiles.py index b400a259..bbd43dfa 100644 --- a/chython/algorithms/smiles.py +++ b/chython/algorithms/smiles.py @@ -402,7 +402,7 @@ def _format_atom(self: 'MoleculeContainer', n, adjacency, **kwargs): # allene if n in self._stereo_allenes_terminals: t1, t2 = self._stereo_allenes_terminals[n] - env = self._stereo_allenes[n] + env = self.stereogenic_allenes[n] n1 = next(x for x in adjacency[t1] if x in env) n2 = next(x for x in adjacency[t2] if x in env) smi[3] = '@' if self._translate_allene_sign(n, n1, n2) else '@@' diff --git a/chython/algorithms/stereo/molecule.py b/chython/algorithms/stereo.py similarity index 52% rename from chython/algorithms/stereo/molecule.py rename to chython/algorithms/stereo.py index 9415d551..7421d3f5 100644 --- a/chython/algorithms/stereo/molecule.py +++ b/chython/algorithms/stereo.py @@ -20,10 +20,9 @@ from functools import cached_property from itertools import combinations, product from logging import getLogger, INFO -from typing import Dict, Set, Tuple, Union, TYPE_CHECKING -from .graph import Stereo -from ..morgan import _morgan -from ...exceptions import AtomNotFound, IsChiral, NotChiral +from typing import Dict, Set, Tuple, Union, List, Optional, TYPE_CHECKING +from .morgan import _morgan +from ..exceptions import AtomNotFound, IsChiral, NotChiral logger = getLogger('chython.stereo') @@ -34,6 +33,30 @@ from chython import MoleculeContainer +# 1 2 +# \ | +# \| +# n---3 +# / +# / +# 0 +_tetrahedron_translate = {(0, 1, 2): False, (1, 2, 0): False, (2, 0, 1): False, + (0, 2, 1): True, (1, 0, 2): True, (2, 1, 0): True, + (0, 3, 1): False, (3, 1, 0): False, (1, 0, 3): False, + (0, 1, 3): True, (1, 3, 0): True, (3, 0, 1): True, + (0, 2, 3): False, (2, 3, 0): False, (3, 0, 2): False, + (0, 3, 2): True, (3, 2, 0): True, (2, 0, 3): True, + (1, 3, 2): False, (3, 2, 1): False, (2, 1, 3): False, + (1, 2, 3): True, (2, 3, 1): True, (3, 1, 2): True} +# 2 1 +# \ / +# n---m +# / \ +# 0 3 +_alkene_translate = {(0, 1): False, (1, 0): False, (0, 3): True, (3, 0): True, + (2, 3): False, (3, 2): False, (2, 1): True, (1, 2): True} + + def _pyramid_sign(n, u, v, w): # # | n / @@ -121,9 +144,245 @@ def _allene_sign(mark, u, v, w): return 0 -class MoleculeStereo(Stereo): +class MoleculeStereo: __slots__ = () + def clean_stereo(self: 'MoleculeContainer'): + """ + Remove stereo data. + """ + for a in self._atoms.values(): + a._stereo = None + for _, bs in self._bonds: + for b in bs.values(): + b._stereo = None # flush twice, but it should be still faster + self.flush_cache(keep_sssr=True, keep_components=True) + + @cached_property + def tetrahedrons(self: 'MoleculeContainer') -> Tuple[int, ...]: + """ + Carbon sp3 atom numbers. + """ + tetra = [] + for n, atom in self._atoms.items(): + if atom.atomic_number == 6 and not atom.charge and not atom.is_radical: + env = self._bonds[n] + if all(b == 1 for b in env.values()): + if sum(int(b) for b in env.values()) > 4: + continue + tetra.append(n) + return tuple(tetra) + + @cached_property + def cumulenes(self: 'MoleculeContainer') -> List[Tuple[int, ...]]: + """ + All double-bonds chains (e.g. alkenes, allenes, cumulenes). + """ + atoms = self._atoms + bonds = self._bonds + + adj = defaultdict(set) # double bonds adjacency matrix + for n, atom in atoms.items(): + if atom.is_forming_double_bonds: + adj_n = adj[n].add + for m, bond in bonds[n].items(): + if bond == 2 and atoms[m].is_forming_double_bonds: + adj_n(m) + if not adj: + return [] + + terminals = [x for x, y in adj.items() if len(y) == 1] # list to keep atoms order! + cumulenes = [] + while terminals: + n = terminals.pop() + m = adj[n].pop() + path = [n, m] + while m not in terminals: + if len(bonds[m]) > 2: # not cumulene. SO3, SO4- etc. + cumulenes.extend(zip(path, path[1:])) # keep single double bonds instead of cumulene chain. + break + adj_m = adj[m] + adj_m.discard(n) + n, m = m, adj_m.pop() + path.append(m) + else: + terminals.remove(m) + adj[m].pop() + cumulenes.append(tuple(path)) + return cumulenes + + @cached_property + def stereogenic_tetrahedrons(self: 'MoleculeContainer') -> Dict[int, Union[Tuple[int, int, int], Tuple[int, int, int, int]]]: + """ + Tetrahedrons which contains at least 3 non-hydrogen neighbors and corresponding neighbors order. + """ + # 2 + # | + # 1--K--3 + # | + # 4? + atoms = self._atoms + bonds = self._bonds + tetrahedrons = {} + for n in self.tetrahedrons: + if any(not atoms[x].is_forming_single_bonds for x in bonds[n]): + continue # skip metal-carbon complexes + env = tuple(x for x in bonds[n] if atoms[x].atomic_number != 1) + if len(env) in (3, 4): + tetrahedrons[n] = env + return tetrahedrons + + @cached_property + def stereogenic_cumulenes(self: 'MoleculeContainer') -> Dict[Tuple[int, ...], Tuple[int, int, Optional[int], Optional[int]]]: + """ + Cumulenes which contains at least one non-hydrogen neighbor on both ends and corresponding neighbors order. + """ + # 5 4 + # \ / + # 2---3 + # / \ + # 1 6 + bonds = self._bonds + atoms = self._atoms + cumulenes = {} + for path in self.cumulenes: + nf = bonds[path[0]] + nl = bonds[path[-1]] + n1, m1 = path[1], path[-2] + if any(b == 3 or not atoms[m].is_forming_single_bonds and b != 8 + for m, b in nf.items() if m != n1): + continue # skip X=C=C structures and metal-carbon complexes + if any(b == 3 or not atoms[m].is_forming_single_bonds and b != 8 + for m, b in nl.items() if m != m1): + continue # skip X=C=C structures and metal-carbon complexes + nn = [x for x, b in nf.items() if x != n1 and atoms[x].atomic_number != 1 and b != 8] + mn = [x for x, b in nl.items() if x != m1 and atoms[x].atomic_number != 1 and b != 8] + if nn and mn: + sn = nn[1] if len(nn) == 2 else None + sm = mn[1] if len(mn) == 2 else None + cumulenes[path] = (nn[0], mn[0], sn, sm) + return cumulenes + + @cached_property + def stereogenic_allenes(self) -> Dict[int, Tuple[int, int, Optional[int], Optional[int]]]: + """ + Allenes which contains at least one non-hydrogen neighbor on both ends and corresponding neighbors order. + """ + return {path[len(path) // 2]: env for path, env in self.stereogenic_cumulenes.items() if len(path) % 2} + + @cached_property + def stereogenic_cis_trans(self) -> Dict[Tuple[int, int], Tuple[int, int, Optional[int], Optional[int]]]: + """ + Cis-trans bonds which contains at least one non-hydrogen neighbor on both ends and corresponding neighbors order. + """ + stereo = {} + for path, env in self.stereogenic_cumulenes.items(): + if len(path) % 2: + continue + stereo[(path[0], path[-1])] = env + return stereo + + @cached_property + def ring_tetrahedrons(self: 'MoleculeContainer') -> Dict[int, Union[Tuple[int, int], Tuple[int], Tuple]]: + """ + Tetrahedrons in rings, except ring-linkers. Values are non-ring atoms. + """ + out = {} + atoms_rings = self.atoms_rings + tetrahedrons = self.stereogenic_tetrahedrons + points = self.rings_linker_tetrahedrons + environment = self.not_special_connectivity + for n, r in atoms_rings.items(): + if n in tetrahedrons and n not in points: + out[n] = tuple(environment[n].difference(atoms_rings)) + return out + + @cached_property + def rings_linker_tetrahedrons(self: 'MoleculeContainer') -> Dict[int, Tuple[int, int, int, int]]: + """ + A dictionary where the keys are tetrahedron atoms shared between two rings (not condensed rings) and the values + are tuples representing their neighbors in the first and second rings respectively. + """ + out = {} + tetrahedrons = self.stereogenic_tetrahedrons + for n, r in self.atoms_rings.items(): + if n in tetrahedrons: + for nr, mr in combinations(r, 2): + if len(set(nr).intersection(mr)) == 1: + ni = nr.index(n) + mi = mr.index(n) + out[n] = (nr[ni - 1], nr[ni - len(nr) + 1], mr[mi - 1], mr[mi - len(mr) + 1]) + break + return out + + @cached_property + def ring_cumulenes_terminals(self: 'MoleculeContainer') -> Set[Tuple[int, int]]: + """ + Terminal atoms of inside ring cumulenes. + """ + out = set() + ar = self.atoms_rings + for n, *_, m in self.stereogenic_cumulenes: + if n in ar and m in ar and not set(ar[n]).isdisjoint(ar[m]): + out.add((n, m)) + return out + + @cached_property + def rings_linker_cumulenes_terminals(self: 'MoleculeContainer') -> Dict[Tuple[int, int], Tuple[int, int, int, int]]: + """ + Terminal atoms of cumulenes connecting two rings. Values are neighbors in first and second rings. + """ + out = {} + ar = self.atoms_rings + chord = self.ring_cumulenes_terminals + for (n, *_, m), (n1, m1, n2, m2) in self.stereogenic_cumulenes.items(): + if n in ar and m in ar and (n, m) not in chord: + out[(n, m)] = (n1, n2, m1, m2) + return out + + @cached_property + def ring_attached_cumulenes(self: 'MoleculeContainer') -> Dict[Tuple[int, int], Union[Tuple[int, int], Tuple[int]]]: + """ + Cumulenes attached to rings from one side. Values are out of ring neighbor atoms. + """ + ar = self.atoms_rings + out = {} + for (n, *_, m), (n1, m1, n2, m2) in self.stereogenic_cumulenes.items(): + if n in ar: + if m in ar: + continue + if m2: + out[(n, m)] = (m1, m2) + else: + out[(n, m)] = (m1,) + elif m in ar: + if n2: + out[(n, m)] = (n1, n2) + else: + out[(n, m)] = (n1,) + return out + + @property + def chiral_tetrahedrons(self) -> Set[int]: + """ + Chiral tetrahedrons except already labeled ones. + """ + return self.__chiral_centers[0] + + @property + def chiral_cis_trans(self) -> Set[Tuple[int, int]]: + """ + Chiral cis-trans bonds except already labeled ones. + """ + return self.__chiral_centers[1] + + @property + def chiral_allenes(self) -> Set[int]: + """ + Chiral allenes except already labeled ones. + """ + return self.__chiral_centers[2] + def add_wedge(self: 'MoleculeContainer', n: int, m: int, mark: int, *, clean_cache=True): """ Add stereo data by wedge notation of bonds. Use it for tetrahedrons of allenes. @@ -132,73 +391,78 @@ def add_wedge(self: 'MoleculeContainer', n: int, m: int, mark: int, *, clean_cac :param m: number of atom to which wedge bond coming :param mark: up bond is 1, down is -1 """ - if n not in self._atoms: + atoms = self._atoms + if n not in atoms or m not in atoms or m not in self._bonds[n]: raise AtomNotFound - if n in self._atoms_stereo: + elif atoms[n].stereo is not None: raise IsChiral + elif c := self._stereo_allenes_centers.get(n): + # allenes + if atoms[c].stereo is not None: + raise IsChiral + elif c not in self.chiral_allenes: + raise NotChiral - plane = self._plane - if n in self._chiral_tetrahedrons: - if m not in self._bonds[n]: - raise AtomNotFound - th = self._stereo_tetrahedrons[n] - if self._atoms[m].atomic_number == 1: - s = _pyramid_sign((*plane[m], mark), *((*plane[x], 0) for x in th)) + t1, t2 = self._stereo_allenes_terminals[c] + order = self.stereogenic_allenes[c] + if atoms[m].atomic_number == 1: + if t1 == n: + m1 = order[1] + else: + t1, t2 = t2, t1 + m1 = order[0] + r = True else: - order = [(*plane[x], mark if x == m else 0) for x in th] + w = order.index(m) + if w == 0: + m1 = order[1] + r = False + elif w == 1: + m1 = order[0] + t1, t2 = t2, t1 + r = False + elif w == 2: + m1 = order[1] + r = True + else: + m1 = order[0] + t1, t2 = t2, t1 + r = True + if s := _allene_sign(mark, atoms[t1].xy, atoms[t2].xy, atoms[m1].xy): + atoms[c]._stereo = s < 0 if r else s > 0 + if clean_cache: + self.flush_cache(keep_sssr=True, keep_components=True) + # tetrahedrons + elif n in self.chiral_tetrahedrons: + th = self.stereogenic_tetrahedrons[n] + am = atoms[m] + if am.atomic_number == 1: + order = [] + for x in th: + ax = atoms[x] + order.append((ax.x, ax.y, 0)) + s = _pyramid_sign((am.x, am.y, mark), *order) + else: + order = [] + for x in th: + ax = atoms[x] + order.append((ax.x, ax.y, mark if x == m else 0)) if len(order) == 3: if len(self._bonds[n]) == 4: # explicit hydrogen x = next(x for x in self._bonds[n] if x not in th) - s = _pyramid_sign((*plane[x], 0), *order) + ax = atoms[x] + s = _pyramid_sign((ax.x, ax.y, 0), *order) else: - s = _pyramid_sign((*plane[n], 0), *order) + an = atoms[n] + s = _pyramid_sign((an.x, an.y, 0), *order) else: s = _pyramid_sign(order[-1], *order[:3]) if s: - self._atoms_stereo[n] = s > 0 + atoms[n]._stereo = s > 0 if clean_cache: - self.flush_cache() + self.flush_cache(keep_components=True, keep_sssr=True) else: - c = self._stereo_allenes_centers.get(n) - if c: - if c in self._allenes_stereo: - raise IsChiral - elif c not in self._chiral_allenes: - raise NotChiral - - t1, t2 = self._stereo_allenes_terminals[c] - order = self._stereo_allenes[c] - if self._atoms[m].atomic_number == 1: - if t1 == n: - m1 = order[1] - else: - t1, t2 = t2, t1 - m1 = order[0] - r = True - else: - w = order.index(m) - if w == 0: - m1 = order[1] - r = False - elif w == 1: - m1 = order[0] - t1, t2 = t2, t1 - r = False - elif w == 2: - m1 = order[1] - r = True - else: - m1 = order[0] - t1, t2 = t2, t1 - r = True - s = _allene_sign(mark, plane[t1], plane[t2], plane[m1]) - if s: - self._allenes_stereo[c] = s < 0 if r else s > 0 - if clean_cache: - self.flush_cache() - else: - # only tetrahedrons and allenes supported - raise NotChiral + raise NotChiral def calculate_cis_trans_from_2d(self: 'MoleculeContainer', *, clean_cache=True): """ @@ -206,11 +470,11 @@ def calculate_cis_trans_from_2d(self: 'MoleculeContainer', *, clean_cache=True): """ atoms = self._atoms flag = False - while self._chiral_cis_trans: + while self.chiral_cis_trans: stereo = False - for nm in self._chiral_cis_trans: + for nm in self.chiral_cis_trans: n, m = nm - n1, m1, *_ = self._stereo_cis_trans[nm] + n1, m1, *_ = self.stereogenic_cis_trans[nm] s = _cis_trans_sign(atoms[n1].xy, atoms[n].xy, atoms[m].xy, atoms[m1].xy) if s: stereo = True @@ -222,7 +486,7 @@ def calculate_cis_trans_from_2d(self: 'MoleculeContainer', *, clean_cache=True): else: break if flag and clean_cache: - self.flush_cache() + self.flush_cache(keep_components=True, keep_sssr=True) def add_atom_stereo(self: 'MoleculeContainer', n: int, env: Tuple[int, ...], mark: bool, *, clean_cache=True): """ @@ -243,14 +507,14 @@ def add_atom_stereo(self: 'MoleculeContainer', n: int, env: Tuple[int, ...], mar if not isinstance(mark, bool): raise TypeError('stereo mark should be bool') - if n in self._chiral_tetrahedrons: + if n in self.chiral_tetrahedrons: atom._stereo = self._translate_tetrahedron_sign(n, env, mark) if clean_cache: - self.flush_cache() - elif n in self._chiral_allenes: + self.flush_cache(keep_components=True, keep_sssr=True) + elif n in self.chiral_allenes: atom._stereo = self._translate_allene_sign(n, *env, mark) if clean_cache: - self.flush_cache() + self.flush_cache(keep_components=True, keep_sssr=True) else: # only tetrahedrons supported raise NotChiral @@ -281,14 +545,14 @@ def add_cis_trans_stereo(self: 'MoleculeContainer', n: int, m: int, n1: int, n2: if self._bonds[i][j].stereo is not None: raise IsChiral - if (n, m) in self._chiral_cis_trans: - self._bonds[i][j] = self._translate_cis_trans_sign(n, m, n1, n2, mark) + if (n, m) in self.chiral_cis_trans: + self._bonds[i][j]._stereo = self._translate_cis_trans_sign(n, m, n1, n2, mark) if clean_cache: - self.flush_cache() - elif (m, n) in self._chiral_cis_trans: - self._bonds[i][j] = self._translate_cis_trans_sign(m, n, n2, n1, mark) + self.flush_cache(keep_components=True, keep_sssr=True) + elif (m, n) in self.chiral_cis_trans: + self._bonds[i][j]._stereo = self._translate_cis_trans_sign(m, n, n2, n1, mark) if clean_cache: - self.flush_cache() + self.flush_cache(keep_components=True, keep_sssr=True) else: raise NotChiral @@ -303,55 +567,58 @@ def fix_stereo(self: 'MoleculeContainer'): """ Reset stereo marks. """ - if self._atoms_stereo: # filter tetrahedrons - stereo_tetrahedrons = self._stereo_tetrahedrons - atoms_stereo = {k: v for k, v in self._atoms_stereo.items() if k in stereo_tetrahedrons} - self._atoms_stereo = self_atoms_stereo = {} - else: - atoms_stereo = {} - - if self._allenes_stereo: # filter allenes - stereo_allenes = self._stereo_allenes - allenes_stereo = {k: v for k, v in self._allenes_stereo.items() if k in stereo_allenes} - self._allenes_stereo = self_allenes_stereo = {} - else: - allenes_stereo = {} - - if self._cis_trans_stereo: # filter cis-trans - stereo_cis_trans = self._stereo_cis_trans - cis_trans_stereo = {k: v for k, v in self._cis_trans_stereo.items() if k in stereo_cis_trans} - self._cis_trans_stereo = self_stereo_cis_trans = {} - else: - cis_trans_stereo = {} + stereo_tetrahedrons = self.stereogenic_tetrahedrons + stereo_allenes = self.stereogenic_allenes + stereo_cis_trans = self._stereo_cis_trans_terminals + atoms_stereo = [] + allenes_stereo = [] + cis_trans_stereo = [] + for n, a in self._atoms.items(): + if a.stereo is None: + continue + elif n in stereo_tetrahedrons: + atoms_stereo.append((n, a, a.stereo)) + elif n in stereo_allenes: + allenes_stereo.append((n, a, a.stereo)) + a._stereo = None # flush stereo label + + for n, m, b in self.bonds(): + if b.stereo is None: + continue + elif ta := stereo_cis_trans.get(n): + cis_trans_stereo.append((ta, b, b.stereo)) + b._stereo = None # flush stereo label + self.flush_stereo_cache() old_stereo = len(atoms_stereo) + len(allenes_stereo) + len(cis_trans_stereo) while old_stereo: - chiral_tetrahedrons = self._chiral_tetrahedrons - chiral_allenes = self._chiral_allenes - chiral_cis_trans = self._chiral_cis_trans + chiral_tetrahedrons = self.chiral_tetrahedrons + chiral_allenes = self.chiral_allenes + chiral_cis_trans = self.chiral_cis_trans - tmp = {} - for n, s in atoms_stereo.items(): + # filter out resolved + tmp = [] + for n, a, s in atoms_stereo: if n in chiral_tetrahedrons: - self_atoms_stereo[n] = s + a._stereo = s # restore stereo else: - tmp[n] = s + tmp.append((n, a, s)) atoms_stereo = tmp - tmp = {} - for n, s in allenes_stereo.items(): + tmp = [] + for n, a, s in allenes_stereo: if n in chiral_allenes: - self_allenes_stereo[n] = s + a._stereo = s # restore stereo else: - tmp[n] = s + tmp.append((n, a, s)) allenes_stereo = tmp - tmp = {} - for n, s in cis_trans_stereo.items(): - if n in chiral_cis_trans: - self_stereo_cis_trans[n] = s + tmp = [] + for ta, b, s in cis_trans_stereo: + if ta in chiral_cis_trans: + b._stereo = s else: - tmp[n] = s + tmp.append((ta, b, s)) cis_trans_stereo = tmp fail_stereo = len(atoms_stereo) + len(allenes_stereo) + len(cis_trans_stereo) @@ -360,26 +627,236 @@ def fix_stereo(self: 'MoleculeContainer'): old_stereo = fail_stereo self.flush_stereo_cache() + @cached_property + def _stereo_cis_trans_centers(self) -> Dict[int, Tuple[int, int]]: + """ + Cis-Trans terminal atoms to cis-trans key mapping. Key is central double bond in a cumulene chain. + """ + terminals = {} + for path in self.stereogenic_cumulenes: + if len(path) % 2: + continue + n, m = path[0], path[-1] + i = len(path) // 2 + terminals[n] = terminals[m] = (path[i - 1], path[i]) + return terminals + + @cached_property + def _stereo_cis_trans_terminals(self) -> Dict[int, Tuple[int, int]]: + """ + Cis-Trans terminal and central atoms to terminal pair mapping. + """ + terminals = {} + for path in self.stereogenic_cumulenes: + if len(path) % 2: + continue + n, m = path[0], path[-1] + i = len(path) // 2 + terminals[n] = terminals[m] = terminals[path[i]] = terminals[path[i - 1]] = (n, m) + return terminals + + @cached_property + def _stereo_cis_trans_counterpart(self) -> Dict[int, int]: + """ + Cis-Trans terminal atoms counterparts + """ + counterpart = {} + for path in self.stereogenic_cumulenes: + if len(path) % 2: + continue + n, m = path[0], path[-1] + counterpart[n] = m + counterpart[m] = n + return counterpart + + @cached_property + def _stereo_allenes_centers(self) -> Dict[int, int]: + """ + Allene terminal atom to center mapping + """ + terminals = {} + for c, (n, m) in self._stereo_allenes_terminals.items(): + terminals[n] = terminals[m] = c + return terminals + + @cached_property + def _stereo_allenes_terminals(self) -> Dict[int, Tuple[int, int]]: + """ + Allene center atom to terminals mapping + """ + return {path[len(path) // 2]: (path[0], path[-1]) for path in self.stereogenic_cumulenes if len(path) % 2} + + def _translate_tetrahedron_sign(self: 'MoleculeContainer', n, env, s=None): + """ + Get sign of chiral tetrahedron atom for specified neighbors order + + :param n: stereo atom + :param env: neighbors order + :param s: if None, use existing sign else translate given to molecule + """ + if s is None: + s = self._atoms[n].stereo + if s is None: + raise KeyError + + order = self.stereogenic_tetrahedrons[n] + if len(order) == 3: + if len(env) == 4: # hydrogen atom passed to env + # hydrogen always last in order + try: + order = (*order, next(x for x in env if self._atoms[x].atomic_number == 1)) # see translate scheme + except StopIteration: + raise KeyError + elif len(env) != 3: # pyramid or tetrahedron expected + raise ValueError('invalid atoms list') + elif len(env) not in (3, 4): # pyramid or tetrahedron expected + raise ValueError('invalid atoms list') + + translate = tuple(order.index(x) for x in env[:3]) + if _tetrahedron_translate[translate]: + return not s + return s + + def _translate_cis_trans_sign(self: 'MoleculeContainer', n, m, nn, nm, s=None): + """ + Get sign for specified opposite neighbors + + :param n: first double bonded atom + :param m: last double bonded atom + :param nn: neighbor of first atom + :param nm: neighbor of last atom + :param s: if None, use existing sign else translate given to molecule + """ + try: + n0, n1, n2, n3 = self.stereogenic_cis_trans[(n, m)] + except KeyError: + n0, n1, n2, n3 = self.stereogenic_cis_trans[(m, n)] + n, m = m, n # in alkenes sign not order depended + nn, nm = nm, nn + + if s is None: + i, j = self._stereo_cis_trans_centers[n] + s = self._bonds[i][j].stereo + if s is None: + raise KeyError + + if nn == n0: # same start + t0 = 0 + if nm == n1: + t1 = 1 + elif nm == n3 or n3 is None and self._atoms[nm].atomic_number == 1: + t1 = 3 + else: + raise KeyError + elif nn == n1: + t0 = 1 + if nm == n0: + t1 = 0 + elif nm == n2 or n2 is None and self._atoms[nm].atomic_number == 1: + t1 = 2 + else: + raise KeyError + elif nn == n2 or n2 is None and self._atoms[nn].atomic_number == 1: + t0 = 2 + if nm == n1: + t1 = 1 + elif nm == n3 or n3 is None and self._atoms[nm].atomic_number == 1: + t1 = 3 + else: + raise KeyError + elif nn == n3 or n3 is None and self._atoms[nn].atomic_number == 1: + t0 = 3 + if nm == n0: + t1 = 0 + elif nm == n2 or n2 is None and self._atoms[nm].atomic_number == 1: + t1 = 2 + else: + raise KeyError + else: + raise KeyError + + if _alkene_translate[(t0, t1)]: + return not s + return s + + def _translate_allene_sign(self: 'MoleculeContainer', c, nn, nm, s=None): + """ + get sign for specified opposite neighbors + + :param c: central double bonded atom + :param nn: neighbor of first double bonded atom + :param nm: neighbor of last double bonded atom + :param s: if None, use existing sign else translate given to molecule + """ + if s is None: + s = self._atoms[c].stereo + if s is None: + raise KeyError + + n0, n1, n2, n3 = self.stereogenic_allenes[c] + if nn == n0: # same start + t0 = 0 + if nm == n1: + t1 = 1 + elif nm == n3 or n3 is None and self._atoms[nm].atomic_number == 1: + t1 = 3 + else: + raise KeyError + elif nn == n1: + t0 = 1 + if nm == n0: + t1 = 0 + elif nm == n2 or n2 is None and self._atoms[nm].atomic_number == 1: + t1 = 2 + else: + raise KeyError + elif nn == n2 or n2 is None and self._atoms[nn].atomic_number == 1: + t0 = 2 + if nm == n1: + t1 = 1 + elif nm == n3 or n3 is None and self._atoms[nm].atomic_number == 1: + t1 = 3 + else: + raise KeyError + elif nn == n3 or n3 is None and self._atoms[nn].atomic_number == 1: + t0 = 3 + if nm == n0: + t1 = 0 + elif nm == n2 or n2 is None and self._atoms[nm].atomic_number == 1: + t1 = 2 + else: + raise KeyError + else: + raise KeyError + + if _alkene_translate[(t0, t1)]: + return not s + return s + @cached_property def _wedge_map(self: Union['MoleculeContainer', 'MoleculeStereo']): - atoms_stereo = self._atoms_stereo - allenes_centers = self._stereo_allenes_centers atoms = self._atoms + overlap = set() space = [] solved = [] seen = set() - for n, s in self._allenes_stereo.items(): - env = self._stereo_allenes[n] + for n, env in self.stereogenic_allenes.items(): + if atoms[n].stereo is None: + continue term = self._stereo_allenes_terminals[n] + overlap.update(term) # don't allow incoming wedge to allenes terminals orders = [(*env[:2], *term, n, True), (*env[1::-1], *term[::-1], n, True)] if env[2]: orders.append((env[2], env[1], *term, n, True)) if env[3]: orders.append((env[3], env[0], *term[::-1], n, True)) space.append(orders) - for n, s in atoms_stereo.items(): - order = list(self._stereo_tetrahedrons[n]) + for n, env in self.stereogenic_tetrahedrons.items(): + if atoms[n].stereo is None: + continue + overlap.add(n) # don't allow incoming wedge to stereo tetrahedrons + order = list(env) orders = [(*order, n, False)] for _ in range(1, len(order)): order = order.copy() @@ -394,20 +871,22 @@ def _wedge_map(self: Union['MoleculeContainer', 'MoleculeStereo']): good = [] if orders[0][-1]: for x in orders: - if (x0 := x[0]) in seen or x0 not in atoms_stereo and x0 not in allenes_centers: + x0 = x[0] + if x0 in seen or x0 not in overlap: good.append(x) seen.add(x[2]) if good: - solved.append(max(good, key=lambda x: (atoms[x[0]].in_ring, atoms[x[0]].atomic_number))) + solved.append(max(good, key=lambda x: (not atoms[x[0]].in_ring, atoms[x[0]].atomic_number))) else: unsolved.append(orders) else: for x in orders: - if (x0 := x[0]) in seen or x0 not in atoms_stereo and x0 not in allenes_centers: + x0 = x[0] + if x0 in seen or x0 not in overlap: good.append(x) if good: seen.add(x[-2]) - solved.append(max(good, key=lambda x: (atoms[x[0]].in_ring, atoms[x[0]].atomic_number))) + solved.append(max(good, key=lambda x: (not atoms[x[0]].in_ring, atoms[x[0]].atomic_number))) else: unsolved.append(orders) space = unsolved @@ -441,7 +920,7 @@ def _wedge_map(self: Union['MoleculeContainer', 'MoleculeStereo']): def __wedge_sign(self: 'MoleculeContainer', order): if order[-1]: # allene - s = self._translate_allene_sign(order[-2], *order[:2]) + s = self._translate_allene_sign(order[-2], order[0], order[1]) v = _allene_sign(1, self._atoms[order[2]].xy, self._atoms[order[3]].xy, self._atoms[order[1]].xy) if not v: logger.info(f'need 2d clean. allenes wedge stereo ambiguous for atom {order[-2]}') @@ -453,16 +932,21 @@ def __wedge_sign(self: 'MoleculeContainer', order): n = order[-2] s = self._translate_tetrahedron_sign(n, order[:-2]) # need recalculation if XY changed + ao0 = self._atoms[order[0]] + ao1 = self._atoms[order[1]] + ao2 = self._atoms[order[2]] if len(order) == 5: - v = _pyramid_sign((*self._atoms[n].xy, 0), - (*self._atoms[order[0]].xy, 1), - (*self._atoms[order[1]].xy, 0), - (*self._atoms[order[2]].xy, 0)) + an = self._atoms[n] + v = _pyramid_sign((an.x, an.y, 0), + (ao0.x, ao0.y, 1), + (ao1.x, ao1.y, 0), + (ao2.x, ao2.y, 0)) else: - v = _pyramid_sign((*self._atoms[order[3]].xy, 0), - (*self._atoms[order[0]].xy, 1), - (*self._atoms[order[1]].xy, 0), - (*self._atoms[order[2]].xy, 0)) + ao3 = self._atoms[order[3]] + v = _pyramid_sign((ao3.x, ao3.y, 0), + (ao0.x, ao0.y, 1), + (ao1.x, ao1.y, 0), + (ao2.x, ao2.y, 0)) if not v: logger.info(f'need 2d clean. tetrahedron wedge stereo ambiguous for atom {n}') if s: @@ -470,18 +954,6 @@ def __wedge_sign(self: 'MoleculeContainer', order): else: return n, order[0], -v - @property - def _chiral_tetrahedrons(self) -> Set[int]: - return self.__chiral_centers[0] - - @property - def _chiral_cis_trans(self) -> Set[Tuple[int, int]]: - return self.__chiral_centers[1] - - @property - def _chiral_allenes(self) -> Set[int]: - return self.__chiral_centers[2] - @cached_property def _chiral_morgan(self: Union['MoleculeContainer', 'MoleculeStereo']) -> Dict[int, int]: stereo_atoms = {n for n, a in self._atoms.items() if a.stereo is not None} @@ -516,99 +988,11 @@ def _chiral_morgan(self: Union['MoleculeContainer', 'MoleculeStereo']) -> Dict[i morgan = _morgan(morgan, self.int_adjacency) return morgan - @cached_property - def _rings_tetrahedrons_linkers(self: 'MoleculeContainer') -> Dict[int, Tuple[int, int, int, int]]: - """ - Ring-linkers tetrahedrons. - - Values are neighbors in first and second rings. - """ - out = {} - tetrahedrons = self._stereo_tetrahedrons - for n, r in self.atoms_rings.items(): - if n in tetrahedrons: - for nr, mr in combinations(r, 2): - if len(set(nr).intersection(mr)) == 1: - ni = nr.index(n) - mi = mr.index(n) - out[n] = (nr[ni - 1], nr[ni - len(nr) + 1], mr[mi - 1], mr[mi - len(mr) + 1]) - break - return out - - @cached_property - def _rings_tetrahedrons(self: 'MoleculeContainer') -> Dict[int, Union[Tuple[int, int], Tuple[int], Tuple]]: - """ - Tetrahedrons in rings, except ring-linkers. - - Values are out of ring atoms. - """ - out = {} - atoms_rings = self.atoms_rings - tetrahedrons = self._stereo_tetrahedrons - points = self._rings_tetrahedrons_linkers - environment = self.not_special_connectivity - for n, r in atoms_rings.items(): - if n in tetrahedrons and n not in points: - out[n] = tuple(environment[n].difference(atoms_rings)) - return out - - @cached_property - def _rings_cumulenes_linkers(self: 'MoleculeContainer') -> Dict[Tuple[int, int], Tuple[int, int, int, int]]: - """ - Ring-linkers cumulenes except chords. - - Values are neighbors in first and second rings. - """ - out = {} - ar = self.atoms_rings - chord = self._rings_cumulenes - for (n, *_, m), (n1, m1, n2, m2) in self._stereo_cumulenes.items(): - if n in ar and m in ar and (n, m) not in chord: - out[(n, m)] = (n1, n2, m1, m2) - return out - - @cached_property - def _rings_cumulenes(self: 'MoleculeContainer') -> Set[Tuple[int, int]]: - """ - Cumulenes in rings always chiral. - """ - out = set() - ar = self.atoms_rings - for n, *_, m in self._stereo_cumulenes: - if n in ar and m in ar and not set(ar[n]).isdisjoint(ar[m]): - out.add((n, m)) - return out - - @cached_property - def _rings_cumulenes_attached(self: 'MoleculeContainer') -> Dict[Tuple[int, int], - Union[Tuple[int, int], Tuple[int]]]: - """ - Cumulenes attached to rings. - - Values are out of ring atoms. - """ - ar = self.atoms_rings - out = {} - for (n, *_, m), (n1, m1, n2, m2) in self._stereo_cumulenes.items(): - if n in ar: - if m in ar: - continue - if m2: - out[(n, m)] = (m1, m2) - else: - out[(n, m)] = (m1,) - elif m in ar: - if n2: - out[(n, m)] = (n1, n2) - else: - out[(n, m)] = (n1,) - return out - @cached_property def __chiral_centers(self: Union['MoleculeStereo', 'MoleculeContainer']): atoms_rings = self.atoms_rings - tetrahedrons = self._stereo_tetrahedrons - cis_trans = self._stereo_cis_trans + tetrahedrons = self.stereogenic_tetrahedrons + cis_trans = self.stereogenic_cis_trans allenes_centers = self._stereo_allenes_centers cis_trans_terminals = self._stereo_cis_trans_terminals cis_trans_centers = self._stereo_cis_trans_centers @@ -618,7 +1002,7 @@ def __chiral_centers(self: Union['MoleculeStereo', 'MoleculeContainer']): # tetrahedron is chiral if all its neighbors are unique. chiral_t = {n for n, env in tetrahedrons.items() if len({morgan[x] for x in env}) == len(env)} # tetrahedrons-linkers is chiral if in each rings neighbors are unique. - chiral_t.update(n for n, (n1, n2, m1, m2) in self._rings_tetrahedrons_linkers.items() + chiral_t.update(n for n, (n1, n2, m1, m2) in self.rings_linker_tetrahedrons.items() if morgan[n1] != morgan[n2] and morgan[m1] != morgan[m2]) # required for axes detection. @@ -630,7 +1014,7 @@ def __chiral_centers(self: Union['MoleculeStereo', 'MoleculeContainer']): # ring-linkers and rings-attached also takes into account. chiral_c = set() chiral_a = set() - for path, (n1, m1, n2, m2) in self._stereo_cumulenes.items(): + for path, (n1, m1, n2, m2) in self.stereogenic_cumulenes.items(): if morgan[n1] != morgan.get(n2, 0) and morgan[m1] != morgan.get(m2, 0): n, m = path[0], path[-1] if len(path) % 2: @@ -640,7 +1024,7 @@ def __chiral_centers(self: Union['MoleculeStereo', 'MoleculeContainer']): stereogenic.add(n) stereogenic.add(m) # ring cumulenes always chiral. can be already added. - for nm in self._rings_cumulenes: + for nm in self.ring_cumulenes_terminals: n, m = nm if any(len(x) < 8 for x in atoms_rings[n]): # skip small rings. if n in chiral_c: # remove already added small rings cumulenes. @@ -660,22 +1044,22 @@ def __chiral_centers(self: Union['MoleculeStereo', 'MoleculeContainer']): # find chiral axes. build graph of stereogenic atoms in rings. # atoms connected then located in same ring or cumulene. - for n, env in self._rings_tetrahedrons.items(): + for n, env in self.ring_tetrahedrons.items(): if len(env) == 2: # one or zero non-ring neighbors stereogenic. n1, n2 = env if morgan[n1] == morgan[n2]: # only unique non-ring members required. continue graph[n] = set() stereogenic.add(n) # non-linker tetrahedrons in rings - stereogenic. - for n, (n1, n2, m1, m2) in self._rings_tetrahedrons_linkers.items(): + for n, (n1, n2, m1, m2) in self.rings_linker_tetrahedrons.items(): graph[n] = set() if morgan[n1] != morgan[n2] or morgan[m1] != morgan[m2]: stereogenic.add(n) # linkers with at least one unsymmetric ring. - for n, m in self._rings_cumulenes_linkers: + for n, m in self.rings_linker_cumulenes_terminals: graph[n] = {m} graph[m] = {n} # stereogenic atoms already found. - for (n, m), env in self._rings_cumulenes_attached.items(): + for (n, m), env in self.ring_attached_cumulenes.items(): if len(env) == 2: n1, n2 = env if morgan[n1] == morgan[n2]: # only unique non-ring members required. @@ -729,9 +1113,9 @@ def __differentiation(self: Union['MoleculeStereo', 'MoleculeContainer'], morgan atoms_stereo, cis_trans_stereo, allenes_stereo): bonds = self.int_adjacency - tetrahedrons = self._stereo_tetrahedrons - cis_trans = self._stereo_cis_trans - allenes = self._stereo_allenes + tetrahedrons = self.stereogenic_tetrahedrons + cis_trans = self.stereogenic_cis_trans + allenes = self.stereogenic_allenes translate_tetrahedron = self._translate_tetrahedron_sign translate_cis_trans = self._translate_cis_trans_sign diff --git a/chython/algorithms/stereo/__init__.py b/chython/algorithms/stereo/__init__.py deleted file mode 100644 index 18f784a7..00000000 --- a/chython/algorithms/stereo/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright 2021 Ramil Nugmanov -# This file is part of chython. -# -# chython is free software; you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation; either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with this program; if not, see . -# -from .graph import * -from .molecule import * - - -__all__ = ['MoleculeStereo', 'Stereo'] diff --git a/chython/algorithms/stereo/graph.py b/chython/algorithms/stereo/graph.py deleted file mode 100644 index 59523deb..00000000 --- a/chython/algorithms/stereo/graph.py +++ /dev/null @@ -1,467 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright 2019-2024 Ramil Nugmanov -# This file is part of chython. -# -# chython is free software; you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation; either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with this program; if not, see . -# -from collections import defaultdict -from functools import cached_property -from typing import Dict, Optional, Tuple, TYPE_CHECKING, Union - - -if TYPE_CHECKING: - from chython import MoleculeContainer, QueryContainer - Container = Union[MoleculeContainer, QueryContainer] - - -_heteroatoms = {5, 6, 7, 8, 14, 15, 16, 17, 33, 34, 35, 52, 53} - -# 1 2 -# \ | -# \| -# n---3 -# / -# / -# 0 -_tetrahedron_translate = {(0, 1, 2): False, (1, 2, 0): False, (2, 0, 1): False, - (0, 2, 1): True, (1, 0, 2): True, (2, 1, 0): True, - (0, 3, 1): False, (3, 1, 0): False, (1, 0, 3): False, - (0, 1, 3): True, (1, 3, 0): True, (3, 0, 1): True, - (0, 2, 3): False, (2, 3, 0): False, (3, 0, 2): False, - (0, 3, 2): True, (3, 2, 0): True, (2, 0, 3): True, - (1, 3, 2): False, (3, 2, 1): False, (2, 1, 3): False, - (1, 2, 3): True, (2, 3, 1): True, (3, 1, 2): True} -# 2 1 -# \ / -# n---m -# / \ -# 0 3 -_alkene_translate = {(0, 1): False, (1, 0): False, (0, 3): True, (3, 0): True, - (2, 3): False, (3, 2): False, (2, 1): True, (1, 2): True} - -# allowed atoms. these atoms have stable covalent bonds. -_organic_subset = {1, 5, 6, 7, 8, 9, 14, 15, 16, 17, 33, 34, 35, 52, 53, 85} - - -class Stereo: - __slots__ = () - - @cached_property - def cumulenes(self) -> Tuple[Tuple[int, ...], ...]: - """ - Alkenes, allenes and cumulenes atoms numbers. - """ - return tuple(self._cumulenes()) - - @cached_property - def tetrahedrons(self: 'Container') -> Tuple[int, ...]: - """ - Carbon sp3 atoms numbers. - """ - tetra = [] - for n, atom in self._atoms.items(): - if atom.atomic_number == 6 and not atom.charge and not atom.is_radical: - env = self._bonds[n] - if all(int(x) == 1 for x in env.values()): - if sum(int(x) for x in env.values()) > 4: - continue - tetra.append(n) - return tuple(tetra) - - def clean_stereo(self: 'Container'): - """ - Remove stereo data. - """ - for a in self._atoms.values(): - a._stereo = None - for _, bs in self._bonds: - for b in bs.values(): - b._stereo = None # flush twice, but it should be still faster - self.flush_cache() - - def get_mapping(self: 'Container', other: 'Container', **kwargs): - atoms_stereo = self._atoms_stereo - allenes_stereo = self._allenes_stereo - cis_trans_stereo = self._cis_trans_stereo - if atoms_stereo or allenes_stereo or cis_trans_stereo: - other_atoms_stereo = other._atoms_stereo - other_allenes_stereo = other._allenes_stereo - other_cis_trans_stereo = other._cis_trans_stereo - other_translate_tetrahedron_sign = other._translate_tetrahedron_sign - other_translate_allene_sign = other._translate_allene_sign - other_translate_cis_trans_sign = other._translate_cis_trans_sign - - tetrahedrons = self._stereo_tetrahedrons - cis_trans = self._stereo_cis_trans - allenes = self._stereo_allenes - - for mapping in super().get_mapping(other, **kwargs): - for n, s in atoms_stereo.items(): - m = mapping[n] - if m not in other_atoms_stereo: # self stereo atom not stereo in other - break - # translate stereo mark in other in order of self tetrahedron - if other_translate_tetrahedron_sign(m, [mapping[x] for x in tetrahedrons[n]]) != s: - break - else: - for n, s in allenes_stereo.items(): - m = mapping[n] - if m not in other_allenes_stereo: # self stereo allene not stereo in other - break - # translate stereo mark in other in order of self allene - nn, nm, *_ = allenes[n] - if other_translate_allene_sign(m, mapping[nn], mapping[nm]) != s: - break - else: - for nm, s in cis_trans_stereo.items(): - n, m = nm - on, om = mapping[n], mapping[m] - if (on, om) not in other_cis_trans_stereo: - if (om, on) not in other_cis_trans_stereo: - break # self stereo cis_trans not stereo in other - else: - nn, nm, *_ = cis_trans[nm] - if other_translate_cis_trans_sign(om, on, mapping[nm], mapping[nn]) != s: - break - else: - nn, nm, *_ = cis_trans[nm] - if other_translate_cis_trans_sign(on, om, mapping[nn], mapping[nm]) != s: - break - else: - yield mapping - else: - yield from super().get_mapping(other, **kwargs) - - def _translate_tetrahedron_sign(self: 'Container', n, env, s=None): - """ - Get sign of chiral tetrahedron atom for specified neighbors order - - :param n: stereo atom - :param env: neighbors order - :param s: if None, use existing sign else translate given to molecule - """ - if s is None: - s = self._atoms[n].stereo - if s is None: - raise KeyError - - order = self._stereo_tetrahedrons[n] - if len(order) == 3: - if len(env) == 4: # hydrogen atom passed to env - # hydrogen always last in order - try: - order = (*order, next(x for x in env if self._atoms[x].atomic_number == 1)) # see translate scheme - except StopIteration: - raise KeyError - elif len(env) != 3: # pyramid or tetrahedron expected - raise ValueError('invalid atoms list') - elif len(env) not in (3, 4): # pyramid or tetrahedron expected - raise ValueError('invalid atoms list') - - translate = tuple(order.index(x) for x in env[:3]) - if _tetrahedron_translate[translate]: - return not s - return s - - def _translate_cis_trans_sign(self: 'Container', n, m, nn, nm, s=None): - """ - Get sign for specified opposite neighbors - - :param n: first double bonded atom - :param m: last double bonded atom - :param nn: neighbor of first atom - :param nm: neighbor of last atom - :param s: if None, use existing sign else translate given to molecule - """ - try: - n0, n1, n2, n3 = self._stereo_cis_trans[(n, m)] - except KeyError: - n0, n1, n2, n3 = self._stereo_cis_trans[(m, n)] - n, m = m, n # in alkenes sign not order depended - nn, nm = nm, nn - - if s is None: - i, j = self._stereo_cis_trans_centers[n] - s = self._bonds[i][j].stereo - if s is None: - raise KeyError - - if nn == n0: # same start - t0 = 0 - if nm == n1: - t1 = 1 - elif nm == n3 or n3 is None and self._atoms[nm].atomic_number == 1: - t1 = 3 - else: - raise KeyError - elif nn == n1: - t0 = 1 - if nm == n0: - t1 = 0 - elif nm == n2 or n2 is None and self._atoms[nm].atomic_number == 1: - t1 = 2 - else: - raise KeyError - elif nn == n2 or n2 is None and self._atoms[nn].atomic_number == 1: - t0 = 2 - if nm == n1: - t1 = 1 - elif nm == n3 or n3 is None and self._atoms[nm].atomic_number == 1: - t1 = 3 - else: - raise KeyError - elif nn == n3 or n3 is None and self._atoms[nn].atomic_number == 1: - t0 = 3 - if nm == n0: - t1 = 0 - elif nm == n2 or n2 is None and self._atoms[nm].atomic_number == 1: - t1 = 2 - else: - raise KeyError - else: - raise KeyError - - if _alkene_translate[(t0, t1)]: - return not s - return s - - def _translate_allene_sign(self: 'Container', c, nn, nm, s=None): - """ - get sign for specified opposite neighbors - - :param c: central double bonded atom - :param nn: neighbor of first double bonded atom - :param nm: neighbor of last double bonded atom - :param s: if None, use existing sign else translate given to molecule - """ - if s is None: - s = self._atoms[c].stereo - if s is None: - raise KeyError - - n0, n1, n2, n3 = self._stereo_allenes[c] - if nn == n0: # same start - t0 = 0 - if nm == n1: - t1 = 1 - elif nm == n3 or n3 is None and self._atoms[nm].atomic_number == 1: - t1 = 3 - else: - raise KeyError - elif nn == n1: - t0 = 1 - if nm == n0: - t1 = 0 - elif nm == n2 or n2 is None and self._atoms[nm].atomic_number == 1: - t1 = 2 - else: - raise KeyError - elif nn == n2 or n2 is None and self._atoms[nn].atomic_number == 1: - t0 = 2 - if nm == n1: - t1 = 1 - elif nm == n3 or n3 is None and self._atoms[nm].atomic_number == 1: - t1 = 3 - else: - raise KeyError - elif nn == n3 or n3 is None and self._atoms[nn].atomic_number == 1: - t0 = 3 - if nm == n0: - t1 = 0 - elif nm == n2 or n2 is None and self._atoms[nm].atomic_number == 1: - t1 = 2 - else: - raise KeyError - else: - raise KeyError - - if _alkene_translate[(t0, t1)]: - return not s - return s - - def _cumulenes(self: 'Container', heteroatoms=False): - atoms = self._atoms - bonds = self._bonds - - adj = defaultdict(set) # double bonds adjacency matrix - if heteroatoms: - for n, atom in atoms.items(): - if atom.atomic_number in _heteroatoms: - adj_n = adj[n].add - for m, bond in bonds[n].items(): - if int(bond) == 2 and atoms[m].atomic_number in _heteroatoms: - adj_n(m) - else: - for n, atom in atoms.items(): - if atom.atomic_number == 6: - adj_n = adj[n].add - for m, bond in bonds[n].items(): - if int(bond) == 2 and atoms[m].atomic_number == 6: - adj_n(m) - if not adj: - return () - - terminals = [x for x, y in adj.items() if len(y) == 1] - cumulenes = [] - while terminals: - n = terminals.pop(0) - m = adj[n].pop() - path = [n, m] - while m not in terminals: - adj_m = adj[m] - if len(adj_m) > 2: # not cumulene. SO3 etc. - cumulenes.extend(zip(path, path[1:])) # keep single double bonds. - break - adj_m.discard(n) - n, m = m, adj_m.pop() - path.append(m) - else: - terminals.remove(m) - adj[m].pop() - cumulenes.append(tuple(path)) - return cumulenes - - @cached_property - def _stereo_cumulenes(self: 'Container') -> Dict[Tuple[int, ...], Tuple[int, int, Optional[int], Optional[int]]]: - """ - Cumulenes which contains at least one non-hydrogen neighbor on both ends - """ - # 5 4 - # \ / - # 2---3 - # / \ - # 1 6 - bonds = self._bonds - atoms = self._atoms - cumulenes = {} - for path in self.cumulenes: - nf = bonds[path[0]] - nl = bonds[path[-1]] - n1, m1 = path[1], path[-2] - if any(b.order == 3 or atoms[m].atomic_number not in _organic_subset and b.order != 8 - for m, b in nf.items() if m != n1): - continue # skip X=C=C structures and metal-carbon complexes - if any(b.order == 3 or atoms[m].atomic_number not in _organic_subset and b.order != 8 - for m, b in nl.items() if m != m1): - continue # skip X=C=C structures and metal-carbon complexes - nn = [x for x, b in nf.items() if x != n1 and atoms[x].atomic_number != 1 and b.order != 8] - mn = [x for x, b in nl.items() if x != m1 and atoms[x].atomic_number != 1 and b.order != 8] - if nn and mn: - sn = nn[1] if len(nn) == 2 else None - sm = mn[1] if len(mn) == 2 else None - cumulenes[path] = (nn[0], mn[0], sn, sm) - return cumulenes - - @cached_property - def _stereo_tetrahedrons(self: 'Container') -> Dict[int, Union[Tuple[int, int, int], Tuple[int, int, int, int]]]: - """ - Tetrahedrons which contains at least 3 non-hydrogen neighbors - """ - # 2 - # | - # 1--K--3 - # | - # 4? - atoms = self._atoms - bonds = self._bonds - tetrahedrons = {} - for n in self.tetrahedrons: - if any(atoms[x].atomic_number not in _organic_subset for x in bonds[n]): - continue # skip metal-carbon complexes - env = tuple(x for x in bonds[n] if atoms[x].atomic_number != 1) - if len(env) in (3, 4): - tetrahedrons[n] = env - return tetrahedrons - - @cached_property - def _stereo_cis_trans(self) -> Dict[Tuple[int, int], Tuple[int, int, Optional[int], Optional[int]]]: - """ - Cis-trans bonds which contains at least one non-hydrogen neighbor on both ends - """ - stereo = {} - for path, env in self._stereo_cumulenes.items(): - if len(path) % 2: - continue - stereo[(path[0], path[-1])] = env - return stereo - - @cached_property - def _stereo_cis_trans_centers(self) -> Dict[int, Tuple[int, int]]: - """ - Cis-Trans terminal atoms to cis-trans key mapping. Key is central double bond in a cumulene chain. - """ - terminals = {} - for path in self._stereo_cumulenes: - if len(path) % 2: - continue - n, m = path[0], path[-1] - i = len(path) // 2 - terminals[n] = terminals[m] = (path[i - 1], path[i]) - return terminals - - @cached_property - def _stereo_cis_trans_terminals(self) -> Dict[int, Tuple[int, int]]: - """ - Cis-Trans terminal and central atoms to terminal pair mapping. - """ - terminals = {} - for path in self._stereo_cumulenes: - if len(path) % 2: - continue - n, m = path[0], path[-1] - i = len(path) // 2 - terminals[n] = terminals[m] = terminals[path[i]] = terminals[path[i - 1]] = (n, m) - return terminals - - @cached_property - def _stereo_cis_trans_counterpart(self) -> Dict[int, int]: - """ - Cis-Trans terminal atoms counterparts - """ - counterpart = {} - for path in self._stereo_cumulenes: - if len(path) % 2: - continue - n, m = path[0], path[-1] - counterpart[n] = m - counterpart[m] = n - return counterpart - - @cached_property - def _stereo_allenes(self) -> Dict[int, Tuple[int, int, Optional[int], Optional[int]]]: - """ - Allenes which contains at least one non-hydrogen neighbor on both ends - """ - return {path[len(path) // 2]: env for path, env in self._stereo_cumulenes.items() if len(path) % 2} - - @cached_property - def _stereo_allenes_centers(self) -> Dict[int, int]: - """ - Allene terminal atom to center mapping - """ - terminals = {} - for c, (n, m) in self._stereo_allenes_terminals.items(): - terminals[n] = terminals[m] = c - return terminals - - @cached_property - def _stereo_allenes_terminals(self) -> Dict[int, Tuple[int, int]]: - """ - Allene center atom to terminals mapping - """ - return {path[len(path) // 2]: (path[0], path[-1]) for path in self._stereo_cumulenes if len(path) % 2} - - -__all__ = ['Stereo'] diff --git a/chython/containers/molecule.py b/chython/containers/molecule.py index fc2c7cb2..b7969687 100644 --- a/chython/containers/molecule.py +++ b/chython/containers/molecule.py @@ -285,7 +285,7 @@ def substructure(self, atoms: Iterable[int], *, as_query: bool = False, recalcul not_skin = {n for n in atoms if lost.isdisjoint(self._bonds[n])} # check for full presence of cumulene chains and terminal attachments - for p in self._stereo_cumulenes.values(): + for p in self.stereogenic_cumulenes.values(): if not not_skin.issuperset(p): not_skin.difference_update(p) @@ -554,8 +554,6 @@ def unpack(cls, data: Union[bytes, memoryview], /, *, compressed=True, mol._allenes_stereo = allenes_stereo mol._cis_trans_stereo = cis_trans_stereo - mol._conformers = [] - mol._parsed_mapping = {} mol._MoleculeContainer__meta = None mol._MoleculeContainer__name = None mol._atoms = atoms = {} diff --git a/chython/containers/query.py b/chython/containers/query.py index 7a218786..757925f2 100644 --- a/chython/containers/query.py +++ b/chython/containers/query.py @@ -21,12 +21,11 @@ from .graph import Graph from ..algorithms.isomorphism import QueryIsomorphism from ..algorithms.smiles import QuerySmiles -from ..algorithms.stereo import Stereo from ..periodictable import Element, QueryElement from ..periodictable.base import Query -class QueryContainer(Stereo, Graph[Query, QueryBond], QueryIsomorphism, QuerySmiles): +class QueryContainer(Graph[Query, QueryBond], QueryIsomorphism, QuerySmiles): __slots__ = () def add_atom(self, atom: Union[Query, Element, int, str], *args, **kwargs): diff --git a/chython/files/daylight/smiles.py b/chython/files/daylight/smiles.py index 82687724..61f2d6cd 100644 --- a/chython/files/daylight/smiles.py +++ b/chython/files/daylight/smiles.py @@ -175,8 +175,8 @@ def postprocess_molecule(molecule, data, *, ignore_stereo=False): if not stereo_atoms and not data['stereo_bonds']: return - st = molecule._stereo_tetrahedrons - sa = molecule._stereo_allenes + st = molecule.stereogenic_tetrahedrons + sa = molecule.stereogenic_allenes sat = molecule._stereo_allenes_terminals ctc = molecule._stereo_cis_trans_counterpart diff --git a/chython/files/libinchi/wrapper.py b/chython/files/libinchi/wrapper.py index a3504a0b..aaefb948 100644 --- a/chython/files/libinchi/wrapper.py +++ b/chython/files/libinchi/wrapper.py @@ -135,8 +135,8 @@ def postprocess_molecule(molecule, data, *, ignore_stereo=False): if ignore_stereo or not data['stereo_atoms'] and not data['stereo_cumulenes'] and not data['stereo_allenes']: return - st = molecule._stereo_tetrahedrons - sa = molecule._stereo_allenes + st = molecule.stereogenic_tetrahedrons + sa = molecule.stereogenic_allenes ctc = molecule._stereo_cis_trans_counterpart stereo = [] diff --git a/chython/periodictable/base/query.py b/chython/periodictable/base/query.py index 1d00a29b..70d1588e 100644 --- a/chython/periodictable/base/query.py +++ b/chython/periodictable/base/query.py @@ -21,10 +21,7 @@ from functools import cached_property from typing import Tuple, Type, List, Union, Optional from .element import Element - - -_inorganic = {'He', 'Ne', 'Ar', 'Kr', 'Xe', 'F', 'Cl', 'Br', 'I', 'B', 'C', 'N', 'O', - 'H', 'Si', 'P', 'S', 'Se', 'Ge', 'As', 'Sb', 'Te', 'At'} +from .groups import GroupXVIII def _validate(value, prop): @@ -229,18 +226,15 @@ def atomic_symbol(self) -> str: return 'M' def __eq__(self, other): - if isinstance(other, Element): - if other.atomic_symbol in _inorganic: - return False - if self.neighbors and other.neighbors not in self.neighbors: - return False - if self.hybridization and other.hybridization not in self.hybridization: - return False - return True - # metal is subset of metal. only - return (isinstance(other, AnyMetal) - and self.neighbors == other.neighbors - and self.hybridization == other.hybridization) + if not isinstance(other, Element): + return False + if other.is_forming_single_bonds or isinstance(other, GroupXVIII): + return False + if self.neighbors and other.neighbors not in self.neighbors: + return False + if self.hybridization and other.hybridization not in self.hybridization: + return False + return True def __hash__(self): return hash((self.neighbors, self.hybridization)) @@ -257,35 +251,27 @@ def __eq__(self, other): """ Compare attached to molecules elements and query elements """ - if isinstance(other, Element): - if self.charge != other.charge: - return False - if self.is_radical != other.is_radical: - return False - if self.neighbors and other.neighbors not in self.neighbors: - return False - if self.hybridization and other.hybridization not in self.hybridization: - return False - if self.ring_sizes: - if self.ring_sizes[0]: - if other.ring_sizes.isdisjoint(self.ring_sizes): - return False - elif other.ring_sizes: # not in ring expected + if not isinstance(other, Element): + return False + if self.charge != other.charge: + return False + if self.is_radical != other.is_radical: + return False + if self.neighbors and other.neighbors not in self.neighbors: + return False + if self.hybridization and other.hybridization not in self.hybridization: + return False + if self.ring_sizes: + if self.ring_sizes[0]: + if other.ring_sizes.isdisjoint(self.ring_sizes): return False - if self.implicit_hydrogens and other.implicit_hydrogens not in self.implicit_hydrogens: + elif other.ring_sizes: # not in ring expected return False - if self.heteroatoms and other.heteroatoms not in self.heteroatoms: - return False - return True - # any is subset of any. only - return (isinstance(other, AnyElement) - and self.charge == other.charge - and self.is_radical == other.is_radical - and self.neighbors == other.neighbors - and self.hybridization == other.hybridization - and self.ring_sizes == other.ring_sizes - and self.implicit_hydrogens == other.implicit_hydrogens - and self.heteroatoms == other.heteroatoms) + if self.implicit_hydrogens and other.implicit_hydrogens not in self.implicit_hydrogens: + return False + if self.heteroatoms and other.heteroatoms not in self.heteroatoms: + return False + return True def __hash__(self): return hash((self.charge, self.is_radical, self.neighbors, self.hybridization, @@ -329,42 +315,29 @@ def __eq__(self, other): """ Compare attached to molecules elements and query elements """ - if isinstance(other, Element): - if other.atomic_number not in self.atomic_numbers: - return False - if self.charge != other.charge: - return False - if self.is_radical != other.is_radical: - return False - if self.neighbors and other.neighbors not in self.neighbors: - return False - if self.hybridization and other.hybridization not in self.hybridization: - return False - if self.ring_sizes: - if self.ring_sizes[0]: - if other.ring_sizes.isdisjoint(self.ring_sizes): - return False - elif other.ring_sizes: # not in ring expected + if not isinstance(other, Element): + return False + if other.atomic_number not in self.atomic_numbers: + return False + if self.charge != other.charge: + return False + if self.is_radical != other.is_radical: + return False + if self.neighbors and other.neighbors not in self.neighbors: + return False + if self.hybridization and other.hybridization not in self.hybridization: + return False + if self.ring_sizes: + if self.ring_sizes[0]: + if other.ring_sizes.isdisjoint(self.ring_sizes): return False - if self.implicit_hydrogens and other.implicit_hydrogens not in self.implicit_hydrogens: + elif other.ring_sizes: # not in ring expected return False - if self.heteroatoms and other.heteroatoms not in self.heteroatoms: - return False - return True - # List is subset of Any and List - elif (isinstance(other, (ListElement, AnyElement)) - and self.charge == other.charge - and self.is_radical == other.is_radical - and self.neighbors == other.neighbors - and self.hybridization == other.hybridization - and self.ring_sizes == other.ring_sizes - and self.implicit_hydrogens == other.implicit_hydrogens - and self.heteroatoms == other.heteroatoms): - # list should contain all elements of other list - if isinstance(other, ListElement): - return set(self.atomic_numbers).issubset(other.atomic_numbers) - return True - return False + if self.implicit_hydrogens and other.implicit_hydrogens not in self.implicit_hydrogens: + return False + if self.heteroatoms and other.heteroatoms not in self.heteroatoms: + return False + return True def __hash__(self): return hash((self.atomic_numbers, self.charge, self.is_radical, self.neighbors, self.hybridization, @@ -475,47 +448,31 @@ def __eq__(self, other): """ compare attached to molecules elements and query elements """ - if isinstance(other, Element): - if self.atomic_number != other.atomic_number: - return False - if self.charge != other.charge: - return False - if self.is_radical != other.is_radical: - return False - if self.isotope and self.isotope != other.isotope: - return False - if self.neighbors and other.neighbors not in self.neighbors: - return False - if self.hybridization and other.hybridization not in self.hybridization: - return False - if self.ring_sizes: - if self.ring_sizes[0]: - if other.ring_sizes.isdisjoint(self.ring_sizes): - return False - elif other.ring_sizes: # not in ring expected + if not isinstance(other, Element): + return False + if self.atomic_number != other.atomic_number: + return False + if self.charge != other.charge: + return False + if self.is_radical != other.is_radical: + return False + if self.isotope and self.isotope != other.isotope: + return False + if self.neighbors and other.neighbors not in self.neighbors: + return False + if self.hybridization and other.hybridization not in self.hybridization: + return False + if self.ring_sizes: + if self.ring_sizes[0]: + if other.ring_sizes.isdisjoint(self.ring_sizes): return False - if self.implicit_hydrogens and other.implicit_hydrogens not in self.implicit_hydrogens: - return False - if self.heteroatoms and other.heteroatoms not in self.heteroatoms: + elif other.ring_sizes: # not in ring expected return False - return True - elif (isinstance(other, ExtendedQuery) - and self.charge == other.charge - and self.is_radical == other.is_radical - and self.neighbors == other.neighbors - and self.hybridization == other.hybridization - and self.ring_sizes == other.ring_sizes - and self.implicit_hydrogens == other.implicit_hydrogens - and self.heteroatoms == other.heteroatoms): - # query element should fully match other query element - if isinstance(other, QueryElement): - return self.atomic_number == other.atomic_number and self.isotope == other.isotope - # query element is subset of any element - elif isinstance(other, AnyElement): - return True - # query element should be in list - return isinstance(other, ListElement) and self.atomic_number in other.atomic_numbers - return False + if self.implicit_hydrogens and other.implicit_hydrogens not in self.implicit_hydrogens: + return False + if self.heteroatoms and other.heteroatoms not in self.heteroatoms: + return False + return True def __hash__(self): return hash((self.isotope or 0, self.atomic_number, self.charge, self.is_radical, self.neighbors, diff --git a/chython/reactor/base.py b/chython/reactor/base.py index 073713e4..16f8b918 100644 --- a/chython/reactor/base.py +++ b/chython/reactor/base.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2014-2023 Ramil Nugmanov +# Copyright 2014-2024 Ramil Nugmanov # Copyright 2019 Adelia Fatykhova # This file is part of chython. # @@ -206,52 +206,52 @@ def __set_stereo(self, new, structure, mapping): for n, s in products._atoms_stereo.items(): m = mapping[n] new._atoms_stereo[m] = products._translate_tetrahedron_sign(n, [r_mapping[x] for x in - new._stereo_tetrahedrons[m]], s) + new.stereogenic_tetrahedrons[m]], s) stereo_override.add(m) for n, s in products._allenes_stereo.items(): m = mapping[n] - t1, t2, *_ = new._stereo_allenes[m] + t1, t2, *_ = new.stereogenic_allenes[m] new._allenes_stereo[m] = products._translate_allene_sign(n, r_mapping[t1], r_mapping[t2], s) stereo_override.add(m) for (n, m), s in products._cis_trans_stereo.items(): nm = (mapping[n], mapping[m]) try: - t1, t2, *_ = new._stereo_cis_trans[nm] + t1, t2, *_ = new.stereogenic_cis_trans[nm] except KeyError: nm = nm[::-1] - t2, t1, *_ = new._stereo_cis_trans[nm] + t2, t1, *_ = new.stereogenic_cis_trans[nm] new._cis_trans_stereo[nm] = products._translate_cis_trans_sign(n, m, r_mapping[t1], r_mapping[t2], s) stereo_override.update(nm) # set unmatched part stereo and not overridden by patch. for n, s in structure._atoms_stereo.items(): - if n in stereo_override or n not in new._stereo_tetrahedrons or \ + if n in stereo_override or n not in new.stereogenic_tetrahedrons or \ new._bonds[n].keys() != structure._bonds[n].keys(): # skip atoms with changed neighbors continue - new._atoms_stereo[n] = structure._translate_tetrahedron_sign(n, new._stereo_tetrahedrons[n], s) + new._atoms_stereo[n] = structure._translate_tetrahedron_sign(n, new.stereogenic_tetrahedrons[n], s) for n, s in structure._allenes_stereo.items(): - if n in stereo_override or n not in new._stereo_allenes or \ - set(new._stereo_allenes[n]) != set(structure._stereo_allenes[n]): + if n in stereo_override or n not in new.stereogenic_allenes or \ + set(new.stereogenic_allenes[n]) != set(structure.stereogenic_allenes[n]): # skip changed allenes continue - t1, t2, *_ = new._stereo_allenes[n] + t1, t2, *_ = new.stereogenic_allenes[n] new._allenes_stereo[n] = structure._translate_allene_sign(n, t1, t2, s) for nm, s in structure._cis_trans_stereo.items(): n, m = nm if n in stereo_override or m in stereo_override: continue - env = structure._stereo_cis_trans[nm] + env = structure.stereogenic_cis_trans[nm] try: - new_env = new._stereo_cis_trans[nm] + new_env = new.stereogenic_cis_trans[nm] except KeyError: nm = nm[::-1] try: - new_env = new._stereo_cis_trans[nm] + new_env = new.stereogenic_cis_trans[nm] except KeyError: continue t2, t1, *_ = new_env diff --git a/chython/utils/rdkit.py b/chython/utils/rdkit.py index 826387f6..bae12fd9 100644 --- a/chython/utils/rdkit.py +++ b/chython/utils/rdkit.py @@ -152,7 +152,7 @@ def to_rdkit_molecule(data: MoleculeContainer, *, keep_mapping=True): for nm, s in data._cis_trans_stereo.items(): n, m = nm if m in bonds[n]: # cumulenes unsupported - nn, nm, *_ = data._stereo_cis_trans[nm] + nn, nm, *_ = data.stereogenic_cis_trans[nm] b = mol.GetBondBetweenAtoms(mapping[n], mapping[m]) b.SetStereoAtoms(mapping[nn], mapping[nm]) b.SetStereo(_cis if s else _trans) From 1bf7a687b649fd5361c0c4ecff2c3ab69c400578 Mon Sep 17 00:00:00 2001 From: stsouko Date: Mon, 11 Nov 2024 22:42:13 +0100 Subject: [PATCH 23/67] fixes --- chython/algorithms/isomorphism.py | 34 +++++++++++++++---------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/chython/algorithms/isomorphism.py b/chython/algorithms/isomorphism.py index 8c0de0a5..65a20c0e 100644 --- a/chython/algorithms/isomorphism.py +++ b/chython/algorithms/isomorphism.py @@ -280,25 +280,25 @@ def get_mapping(self, other: Union['MoleculeContainer', 'QueryContainer'], /, *, # _cython - by default cython implementation enabled. # disable it by overriding method if Query Atoms or Containers logic changed. # Lv, Ts and Og in cython optimized mode treated as equal. - if isinstance(other, QueryIsomorphism): - return self._get_mapping(other, automorphism_filter=automorphism_filter, searching_scope=searching_scope) - elif isinstance(other, MoleculeIsomorphism): - if _cython: - try: # windows? ;) - from ._isomorphism import get_mapping as _cython_get_mapping - except ImportError: - components = get_mapping = None - else: - components = self._cython_compiled_query # override to cython data + if not isinstance(other, MoleculeIsomorphism): + raise TypeError('MoleculeContainer expected') - def get_mapping(query, scope): - return _cython_get_mapping(*query, *other._cython_compiled_structure, - array('I', [n in scope for n in other])) - else: + if _cython: + try: # windows? ;) + from ._isomorphism import get_mapping as _cython_get_mapping + except ImportError: components = get_mapping = None - return self._get_mapping(other, automorphism_filter=automorphism_filter, searching_scope=searching_scope, - components=components, get_mapping=get_mapping) - raise TypeError('MoleculeContainer or QueryContainer expected') + else: + components = self._cython_compiled_query # override to cython data + + def get_mapping(query, scope): + return _cython_get_mapping(*query, *other._cython_compiled_structure, + array('I', [n in scope for n in other])) + else: + components = get_mapping = None + # todo: implement stereo + return self._get_mapping(other, automorphism_filter=automorphism_filter, searching_scope=searching_scope, + components=components, get_mapping=get_mapping) @cached_property def _cython_compiled_query(self): From faf88ac28f27ac7840471eba39f98510023e1773 Mon Sep 17 00:00:00 2001 From: stsouko Date: Mon, 11 Nov 2024 22:56:43 +0100 Subject: [PATCH 24/67] smiles parser fixed --- chython/files/daylight/smiles.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/chython/files/daylight/smiles.py b/chython/files/daylight/smiles.py index 61f2d6cd..410df35a 100644 --- a/chython/files/daylight/smiles.py +++ b/chython/files/daylight/smiles.py @@ -175,6 +175,7 @@ def postprocess_molecule(molecule, data, *, ignore_stereo=False): if not stereo_atoms and not data['stereo_bonds']: return + atoms = molecule._atoms st = molecule.stereogenic_tetrahedrons sa = molecule.stereogenic_allenes sat = molecule._stereo_allenes_terminals @@ -182,10 +183,11 @@ def postprocess_molecule(molecule, data, *, ignore_stereo=False): order = {mapping[n]: [mapping[m] for m in ms] for n, ms in data['order'].items()} + log = [] stereo = [] for i, s in stereo_atoms: n = mapping[i] - if not i and hydrogens[n]: # first atom in smiles has reversed chiral mark + if not i and atoms[n].implicit_hydrogens: # first atom in smiles has reversed chiral mark s = not s if n in st: @@ -196,6 +198,8 @@ def postprocess_molecule(molecule, data, *, ignore_stereo=False): n1 = next(x for x in order[t1] if x in env) n2 = next(x for x in order[t2] if x in env) stereo.append((molecule.add_atom_stereo, n, (n1, n2), s)) + else: + log.append(f'non chiral atom {n} has stereo label in smiles') stereo_bonds = {mapping[n]: {mapping[m]: s for m, s in ms.items()} for n, ms in data['stereo_bonds'].items()} From b3fa72ece43fccfaf31e620df92909679a7a23f5 Mon Sep 17 00:00:00 2001 From: stsouko Date: Mon, 11 Nov 2024 23:08:47 +0100 Subject: [PATCH 25/67] smiles generator fixed --- chython/algorithms/smiles.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/chython/algorithms/smiles.py b/chython/algorithms/smiles.py index bbd43dfa..4f2e14ae 100644 --- a/chython/algorithms/smiles.py +++ b/chython/algorithms/smiles.py @@ -448,7 +448,7 @@ def _format_atom(self: 'MoleculeContainer', n, adjacency, **kwargs): smi[2] = atom.atomic_symbol return ''.join(smi) - def _format_bond(self: 'MoleculeContainer', n, m, adjacency, **kwargs): + def _format_bond(self: Union['MoleculeContainer', 'MoleculeSmiles'], n, m, adjacency, **kwargs): if not kwargs.get('bonds', True): return '' order = self._bonds[n][m].order @@ -475,14 +475,14 @@ def _format_bond(self: 'MoleculeContainer', n, m, adjacency, **kwargs): else: # order == 8 return '~' - def __ct_map(self, adjacency): + def __ct_map(self: 'MoleculeContainer', adjacency): stereo_bonds = {n for n, mb in self._bonds.items() if any(b.stereo is not None for m, b in mb.items())} if not stereo_bonds: return {} ct_map = {} + sct = self.stereogenic_cis_trans ctc = self._stereo_cis_trans_centers ctt = self._stereo_cis_trans_terminals - sct = self._stereo_cis_trans ctcp = self._stereo_cis_trans_counterpart seen = set() From 57ef18d0d277992e9cb57c48a3602626021b5b46 Mon Sep 17 00:00:00 2001 From: Ramil Nugmanov Date: Wed, 13 Nov 2024 11:02:03 +0100 Subject: [PATCH 26/67] morgan and rings refactored. no need for queries. dropped. --- chython/algorithms/isomorphism.py | 66 +++++++++++++++---------------- chython/algorithms/morgan.py | 6 +-- chython/algorithms/rings.py | 8 ++-- chython/algorithms/smiles.py | 14 +++++-- chython/containers/graph.py | 29 ++------------ chython/containers/molecule.py | 30 ++++++++++++-- 6 files changed, 82 insertions(+), 71 deletions(-) diff --git a/chython/algorithms/isomorphism.py b/chython/algorithms/isomorphism.py index 65a20c0e..a6ddea3e 100644 --- a/chython/algorithms/isomorphism.py +++ b/chython/algorithms/isomorphism.py @@ -27,7 +27,7 @@ if TYPE_CHECKING: from chython.containers.graph import Graph - from chython.containers import MoleculeContainer, QueryContainer + from chython.containers import MoleculeContainer class Isomorphism: @@ -49,14 +49,6 @@ def __gt__(self, other): def __ge__(self, other): return other.is_substructure(self) - def __contains__(self: 'Graph', other: Union[Element, Query, str]): - """ - Atom in Structure test. - """ - if isinstance(other, str): - return any(other == x.atomic_symbol for x in self._atoms.values()) - return any(other == x for x in self._atoms.values()) - def is_substructure(self, other, /) -> bool: """ Test self is substructure of other @@ -79,23 +71,7 @@ def is_equal(self, other, /) -> bool: return False return True - def is_automorphic(self): - """ - Test for automorphism symmetry of graph. - """ - try: - next(self.get_automorphism_mapping()) - except StopIteration: - return False - return True - - def get_automorphism_mapping(self: 'Graph') -> Iterator[Dict[int, int]]: - """ - Iterator of all possible automorphism mappings. - """ - return _get_automorphism_mapping(self.atoms_order, self._bonds) - - def _get_mapping(self, other, /, *, automorphism_filter=True, searching_scope=None, + def _get_mapping(self, other: 'MoleculeContainer', /, *, automorphism_filter=True, searching_scope=None, components=None, get_mapping=None) -> Iterator[Dict[int, int]]: if components is None: # ad-hoc for QueryContainer components, closures = self._compiled_query @@ -141,14 +117,36 @@ def _get_mapping(self, other, /, *, automorphism_filter=True, searching_scope=No @cached_property def _compiled_query(self: 'Graph'): - components, closures = _compile_query(self._atoms, self._bonds) - if self.connected_components_count > 1: - order = {x: n for n, c in enumerate(self.connected_components) for x in c} - components.sort(key=lambda x: order[x[0][0]]) - return components, closures + return _compile_query(self._atoms, self._bonds) class MoleculeIsomorphism(Isomorphism): + __slots__ = () + + def __contains__(self: 'MoleculeContainer', other: Union[Element, Query, str]): + """ + Atom in Structure test. + """ + if isinstance(other, str): + return any(other == x.atomic_symbol for x in self._atoms.values()) + return any(other == x for x in self._atoms.values()) + + def is_automorphic(self): + """ + Test for automorphism symmetry of graph. + """ + try: + next(self.get_automorphism_mapping()) + except StopIteration: + return False + return True + + def get_automorphism_mapping(self: 'MoleculeContainer') -> Iterator[Dict[int, int]]: + """ + Iterator of all possible automorphism mappings. + """ + return _get_automorphism_mapping(self.atoms_order, self._bonds) + def get_mapping(self, other: 'MoleculeContainer', /, *, automorphism_filter: bool = True, searching_scope: Optional[Collection[int]] = None): """ @@ -163,7 +161,7 @@ def get_mapping(self, other: 'MoleculeContainer', /, *, automorphism_filter: boo raise TypeError('MoleculeContainer expected') @cached_property - def _cython_compiled_structure(self): + def _cython_compiled_structure(self: 'MoleculeContainer'): # long I: # bond: single, double, triple, aromatic, special = 5 bit # bond in ring: 2 bit @@ -268,7 +266,9 @@ def _cython_compiled_structure(self): class QueryIsomorphism(Isomorphism): - def get_mapping(self, other: Union['MoleculeContainer', 'QueryContainer'], /, *, automorphism_filter: bool = True, + __slots__ = () + + def get_mapping(self, other: 'MoleculeContainer', /, *, automorphism_filter: bool = True, searching_scope: Optional[Collection[int]] = None, _cython=True): """ Get Query to Molecule substructure mapping generator. diff --git a/chython/algorithms/morgan.py b/chython/algorithms/morgan.py index 36086ada..e200cbc3 100644 --- a/chython/algorithms/morgan.py +++ b/chython/algorithms/morgan.py @@ -27,14 +27,14 @@ if TYPE_CHECKING: - from chython.containers.graph import Graph + from chython.containers import MoleculeContainer class Morgan: __slots__ = () @cached_property - def atoms_order(self: 'Graph') -> Dict[int, int]: + def atoms_order(self: 'MoleculeContainer') -> Dict[int, int]: """ Morgan like algorithm for graph nodes ordering @@ -48,7 +48,7 @@ def atoms_order(self: 'Graph') -> Dict[int, int]: return _morgan({n: hash((hash(a), n in ring)) for n, a in self._atoms.items()}, self.int_adjacency) @cached_property - def int_adjacency(self: 'Graph') -> Dict[int, Dict[int, int]]: + def int_adjacency(self: 'MoleculeContainer') -> Dict[int, Dict[int, int]]: """ Adjacency with integer-coded bonds. """ diff --git a/chython/algorithms/rings.py b/chython/algorithms/rings.py index 37cde6dc..4871d5fa 100644 --- a/chython/algorithms/rings.py +++ b/chython/algorithms/rings.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: - from chython.containers.graph import Graph + from chython.containers import MoleculeContainer class Rings: @@ -111,7 +111,7 @@ def rings_count(self) -> int: return sum(len(x) for x in bonds.values()) // 2 - len(bonds) + len(_connected_components(bonds)) @cached_property - def not_special_connectivity(self: 'Graph') -> Dict[int, Set[int]]: + def not_special_connectivity(self: 'MoleculeContainer') -> Dict[int, Set[int]]: """ Graph connectivity without special bonds. """ @@ -124,7 +124,7 @@ def not_special_connectivity(self: 'Graph') -> Dict[int, Set[int]]: return bonds @cached_property - def connected_components(self: 'Graph') -> List[Set[int]]: + def connected_components(self: 'MoleculeContainer') -> List[Set[int]]: """ Isolated components of single graph. E.g. salts as ion pair. """ @@ -138,7 +138,7 @@ def connected_components_count(self) -> int: return len(self.connected_components) @cached_property - def skin_graph(self: 'Graph') -> Dict[int, Set[int]]: + def skin_graph(self: 'MoleculeContainer') -> Dict[int, Set[int]]: """ Graph without terminal atoms. Only rings and linkers """ diff --git a/chython/algorithms/smiles.py b/chython/algorithms/smiles.py index 4f2e14ae..fc0e7d01 100644 --- a/chython/algorithms/smiles.py +++ b/chython/algorithms/smiles.py @@ -322,8 +322,9 @@ def _format_atom(self, n, adjacency, **kwargs): def _format_bond(self, n, m, adjacency, **kwargs): ... - def _smiles_order(self: 'Graph', stereo=True) -> Callable: - return self.atoms_order.__getitem__ + @abstractmethod + def _smiles_order(self, stereo=True) -> Callable: + ... def _format_cxsmiles(self, order) -> Optional[str]: ... @@ -375,7 +376,7 @@ def sticky_smiles(self: Union['MoleculeContainer', 'MoleculeSmiles'], left: int, smiles = smiles[2:] return ''.join(smiles) - def _smiles_order(self: 'MoleculeContainer', stereo=True) -> Callable: + def _smiles_order(self: 'MoleculeContainer', stereo=True): if stereo: return self._chiral_morgan.__getitem__ else: @@ -527,6 +528,9 @@ def __ct_map(self: 'MoleculeContainer', adjacency): class CGRSmiles(Smiles): __slots__ = () + def _smiles_order(self: 'CGRContainer', stereo=True): + return self.atoms_order.__getitem__ + def _format_atom(self: 'CGRContainer', n, adjacency, **kwargs): atom = self._atoms[n] if atom.isotope: @@ -552,6 +556,10 @@ def _format_bond(self: 'CGRContainer', n, m, adjacency, **kwargs): class QuerySmiles(Smiles): __slots__ = () + def _smiles_order(self: 'QueryContainer', stereo=True): + # try to keep atoms order + return {n: i for i, n in enumerate(self._atoms)}.__getitem__ + def _format_cxsmiles(self: 'QueryContainer', order): hh = ['atomProp'] cx = [] diff --git a/chython/containers/graph.py b/chython/containers/graph.py index 7fa5dead..4586969e 100644 --- a/chython/containers/graph.py +++ b/chython/containers/graph.py @@ -19,8 +19,6 @@ from abc import ABC, abstractmethod from functools import cached_property from typing import Dict, Generic, Iterator, Optional, Tuple, TypeVar -from ..algorithms.morgan import Morgan -from ..algorithms.rings import Rings from ..exceptions import AtomNotFound, MappingError, BondNotFound @@ -28,7 +26,7 @@ Bond = TypeVar('Bond') -class Graph(Generic[Atom, Bond], Morgan, Rings, ABC): +class Graph(Generic[Atom, Bond], ABC): __slots__ = ('_atoms', '_bonds', '__dict__') __class_cache__ = {} @@ -101,7 +99,7 @@ def add_atom(self, atom: Atom, n: Optional[int] = None) -> int: self._atoms[n] = atom self._bonds[n] = {} - self.flush_cache(keep_sssr=True) + self.flush_cache() return n @abstractmethod @@ -169,27 +167,8 @@ def union(self, other: 'Graph', *, remap: bool = False, copy: bool = True): u._bonds.update(other._bonds) return u - def flush_cache(self, *, keep_sssr=False, keep_components=False): - backup = {} - if keep_sssr: - # good to keep if no new bonds or bonds deletions or bonds to/from any change - if 'sssr' in self.__dict__: - backup['sssr'] = self.sssr - if 'atoms_rings' in self.__dict__: - backup['atoms_rings'] = self.atoms_rings - if 'atoms_rings_sizes' in self.__dict__: - backup['atoms_rings_sizes'] = self.atoms_rings_sizes - if 'ring_atoms' in self.__dict__: - backup['ring_atoms'] = self.ring_atoms - if 'not_special_connectivity' in self.__dict__: - backup['not_special_connectivity'] = self.not_special_connectivity - if 'rings_count' in self.__dict__: - backup['rings_count'] = self.rings_count - if keep_components: - # good to keep if no new bonds or bonds deletions - if 'connected_components' in self.__dict__: - backup['connected_components'] = self.connected_components - self.__dict__ = backup + def flush_cache(self): + self.__dict__.clear() def __copy__(self): return self.copy() diff --git a/chython/containers/molecule.py b/chython/containers/molecule.py index b7969687..f80a453d 100644 --- a/chython/containers/molecule.py +++ b/chython/containers/molecule.py @@ -32,6 +32,8 @@ from ..algorithms.isomorphism import MoleculeIsomorphism from ..algorithms.fingerprints import Fingerprints from ..algorithms.mcs import MCS +from ..algorithms.morgan import Morgan +from ..algorithms.rings import Rings from ..algorithms.smiles import MoleculeSmiles from ..algorithms.standardize import StandardizeMolecule from ..algorithms.stereo import MoleculeStereo @@ -41,9 +43,9 @@ from ..periodictable import DynamicElement, Element, QueryElement, H -class MoleculeContainer(MoleculeStereo, Graph[Element, Bond], MoleculeIsomorphism, Aromatize, StandardizeMolecule, - MoleculeSmiles, DepictMolecule, Calculate2DMolecule, Fingerprints, Tautomers, MCS, - X3domMolecule): +class MoleculeContainer(MoleculeStereo, Graph[Element, Bond], Morgan, Rings, MoleculeIsomorphism, + Aromatize, StandardizeMolecule, MoleculeSmiles, DepictMolecule, Calculate2DMolecule, + Fingerprints, Tautomers, MCS, X3domMolecule): __slots__ = ('_meta', '_name', '_conformers', '_changed', '_backup') def __init__(self): @@ -823,6 +825,28 @@ def check_implicit(self, n: int, h: int) -> bool: return True return False + def flush_cache(self, *, keep_sssr=False, keep_components=False): + backup = {} + if keep_sssr: + # good to keep if no new bonds or bonds deletions or bonds to/from any change + if 'sssr' in self.__dict__: + backup['sssr'] = self.sssr + if 'atoms_rings' in self.__dict__: + backup['atoms_rings'] = self.atoms_rings + if 'atoms_rings_sizes' in self.__dict__: + backup['atoms_rings_sizes'] = self.atoms_rings_sizes + if 'ring_atoms' in self.__dict__: + backup['ring_atoms'] = self.ring_atoms + if 'not_special_connectivity' in self.__dict__: + backup['not_special_connectivity'] = self.not_special_connectivity + if 'rings_count' in self.__dict__: + backup['rings_count'] = self.rings_count + if keep_components: + # good to keep if no new bonds or bonds deletions + if 'connected_components' in self.__dict__: + backup['connected_components'] = self.connected_components + self.__dict__ = backup + def __int__(self): """ Total charge of molecule From 38e0bd1a409e50e09679bf6e0eae984303d246eb Mon Sep 17 00:00:00 2001 From: Ramil Nugmanov Date: Wed, 13 Nov 2024 11:16:55 +0100 Subject: [PATCH 27/67] optimizations added --- chython/containers/molecule.py | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/chython/containers/molecule.py b/chython/containers/molecule.py index f80a453d..d56c122d 100644 --- a/chython/containers/molecule.py +++ b/chython/containers/molecule.py @@ -237,13 +237,22 @@ def delete_bond(self, n: int, m: int, *, _skip_calculation=False): self.fix_structure() self.fix_stereo() - def copy(self) -> 'MoleculeContainer': + def copy(self, *, keep_sssr=False, keep_components=False) -> 'MoleculeContainer': copy = super().copy() copy._name = self._name if self._meta is None: copy._meta = None else: copy._meta = self._meta.copy() + + if keep_sssr: + for k, v in self.__dict__.items(): + if k in ('sssr', 'atoms_rings', 'atoms_rings_sizes', + 'ring_atoms', 'not_special_connectivity', 'rings_count'): + copy.__dict__[k] = v + if keep_components: + if 'connected_components' in self.__dict__: + copy.__dict__['connected_components'] = self.connected_components return copy def union(self, other: 'MoleculeContainer', *, remap: bool = False, copy: bool = True) -> 'MoleculeContainer': @@ -829,18 +838,10 @@ def flush_cache(self, *, keep_sssr=False, keep_components=False): backup = {} if keep_sssr: # good to keep if no new bonds or bonds deletions or bonds to/from any change - if 'sssr' in self.__dict__: - backup['sssr'] = self.sssr - if 'atoms_rings' in self.__dict__: - backup['atoms_rings'] = self.atoms_rings - if 'atoms_rings_sizes' in self.__dict__: - backup['atoms_rings_sizes'] = self.atoms_rings_sizes - if 'ring_atoms' in self.__dict__: - backup['ring_atoms'] = self.ring_atoms - if 'not_special_connectivity' in self.__dict__: - backup['not_special_connectivity'] = self.not_special_connectivity - if 'rings_count' in self.__dict__: - backup['rings_count'] = self.rings_count + for k, v in self.__dict__.items(): + if k in ('sssr', 'atoms_rings', 'atoms_rings_sizes', + 'ring_atoms', 'not_special_connectivity', 'rings_count'): + backup[k] = v if keep_components: # good to keep if no new bonds or bonds deletions if 'connected_components' in self.__dict__: @@ -884,7 +885,7 @@ def __enter__(self): """ Transaction of changes. Keep current state for restoring on errors. """ - self._backup = self.copy() + self._backup = self.copy(keep_sssr=True, keep_components=True) return self def __exit__(self, exc_type, exc_val, exc_tb): @@ -894,7 +895,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): self._bonds = backup._bonds self._meta = backup._meta self._name = backup._name - self.flush_cache() + self.__dict__ = backup.__dict__ else: # update internal state self.fix_structure() self.fix_stereo() From bdef5809ff6ec805b5f08637d2e7d6ca560d04a1 Mon Sep 17 00:00:00 2001 From: Ramil Nugmanov Date: Wed, 13 Nov 2024 11:37:46 +0100 Subject: [PATCH 28/67] tautomers refactored --- chython/algorithms/aromatics/kekule.py | 2 +- chython/algorithms/tautomers/__init__.py | 104 +++-------------- chython/algorithms/tautomers/acid_base.py | 111 +++++++++++-------- chython/algorithms/tautomers/heteroarenes.py | 22 ++-- chython/algorithms/tautomers/keto_enol.py | 35 +++--- 5 files changed, 108 insertions(+), 166 deletions(-) diff --git a/chython/algorithms/aromatics/kekule.py b/chython/algorithms/aromatics/kekule.py index f7d90918..6848638c 100644 --- a/chython/algorithms/aromatics/kekule.py +++ b/chython/algorithms/aromatics/kekule.py @@ -62,7 +62,7 @@ def enumerate_kekule(self: Union['Kekule', 'MoleculeContainer']): """ self.__fix_rings() # fix bad aromatic rings for form in self.__kekule_full(0): - copy = self.copy() + copy = self.copy(keep_sssr=True, keep_components=True) bonds = copy._bonds atoms = set() for n, m, b in form: diff --git a/chython/algorithms/tautomers/__init__.py b/chython/algorithms/tautomers/__init__.py index 7a628c6d..e180eaef 100644 --- a/chython/algorithms/tautomers/__init__.py +++ b/chython/algorithms/tautomers/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2020-2022 Ramil Nugmanov +# Copyright 2020-2024 Ramil Nugmanov # Copyright 2020 Nail Samikaev # This file is part of chython. # @@ -51,47 +51,25 @@ def enumerate_tautomers(self: Union['MoleculeContainer', 'Tautomers'], *, prepar """ if limit < 1: raise ValueError('limit should be greater or equal 1') - - has_stereo = bool(self._atoms_stereo or self._allenes_stereo or self._cis_trans_stereo) counter = 0 - copy = self.copy() - copy.clean_stereo() - # sssr, neighbors and heteroatoms are same for all tautomers. - # prevent recalculation by sharing cache. - self.__set_cache(copy) + copy = self.copy(keep_sssr=True, keep_components=True) if prepare_molecules: # transform to kekule form without hydrogens - k = copy.kekule() - i = copy.implicify_hydrogens(_fix_stereo=False) - if k or i: # reset cache after flush - self.__set_cache(copy) - - thiele = copy.copy() # transform to thiele to prevent duplicates and dearomatization - self.__set_cache(thiele) - if thiele.thiele(fix_tautomers=False): - self.__set_cache(thiele) - - # return origin structure as first tautomer - if has_stereo: - yield self.__set_stereo(thiele.copy()) - else: - yield thiele + copy.kekule() + copy.implicify_hydrogens(_fix_stereo=False) + + # transform to thiele to prevent duplicates and dearomatization + thiele = copy.copy(keep_sssr=True, keep_components=True) + thiele.thiele(fix_tautomers=False) + yield thiele # return original structure as first tautomer seen = {thiele: None} # value is parent molecule - required for preventing migrations in sugars. # first try to neutralize if copy.neutralize(_fix_stereo=False): # found neutral form - thiele = copy.copy() - self.__set_cache(copy) # restore cache - self.__set_cache(thiele) - if thiele.thiele(fix_tautomers=False): - self.__set_cache(thiele) - - # return found neutral form - if has_stereo: - yield self.__set_stereo(thiele.copy()) - else: - yield thiele + thiele = copy.copy(keep_sssr=True, keep_components=True) + thiele.thiele(fix_tautomers=False) + yield thiele counter += 1 seen[thiele] = None @@ -107,11 +85,8 @@ def enumerate_tautomers(self: Union['MoleculeContainer', 'Tautomers'], *, prepar while queue: current, thiele_current = queue.popleft() for mol, ket in current._enumerate_keto_enol_tautomers(partial): - thiele = mol.copy() - self.__set_cache(mol) - self.__set_cache(thiele) - if thiele.thiele(fix_tautomers=False): # reset cache after flush_cache. - self.__set_cache(thiele) + thiele = mol.copy(keep_sssr=True, keep_components=True) + thiele.thiele(fix_tautomers=False) if thiele not in seen: seen[thiele] = current @@ -124,10 +99,7 @@ def enumerate_tautomers(self: Union['MoleculeContainer', 'Tautomers'], *, prepar queue = deque([(mol, thiele)]) new_queue = [thiele] copy = mol # new entry point. - if has_stereo: - yield self.__set_stereo(thiele.copy()) - else: - yield thiele + yield thiele break if keep_sugars and current is not copy and ket: # prevent carbonyl migration in sugars. skip entry point. @@ -138,10 +110,7 @@ def enumerate_tautomers(self: Union['MoleculeContainer', 'Tautomers'], *, prepar queue.append((mol, thiele)) new_queue.append(thiele) - if has_stereo: - yield self.__set_stereo(thiele.copy()) - else: - yield thiele + yield thiele counter += 1 if counter == limit: return @@ -152,15 +121,11 @@ def enumerate_tautomers(self: Union['MoleculeContainer', 'Tautomers'], *, prepar while queue: current = queue.popleft() for mol in current._enumerate_hetero_arene_tautomers(): - self.__set_cache(mol) if mol not in seen: seen[mol] = None queue.append(mol) new_queue.append(mol) # new hetero-arenes also should be included to this list. - if has_stereo: - yield self.__set_stereo(mol.copy()) - else: - yield mol + yield mol counter += 1 if counter == limit: return @@ -171,14 +136,10 @@ def enumerate_tautomers(self: Union['MoleculeContainer', 'Tautomers'], *, prepar while queue: current = queue.popleft() for mol in current._enumerate_zwitter_tautomers(): - self.__set_cache(mol) if mol not in seen: seen[mol] = None queue.append(mol) - if has_stereo: - yield self.__set_stereo(mol.copy()) - else: - yield mol + yield mol counter += 1 if counter == limit: return @@ -206,34 +167,5 @@ def enumerate_charged_tautomers(self: 'MoleculeContainer', *, prepare_molecules= if count == limit: return - def __set_cache(self: 'MoleculeContainer', mol): - try: - neighbors = self.__dict__['__cached_args_method_neighbors'] - except KeyError: - neighbors = self.__dict__['__cached_args_method_neighbors'] = {} - try: - heteroatoms = self.__dict__['__cached_args_method_heteroatoms'] - except KeyError: - heteroatoms = self.__dict__['__cached_args_method_heteroatoms'] = {} - try: - is_ring_bond = self.__dict__['__cached_args_method_is_ring_bond'] - except KeyError: - is_ring_bond = self.__dict__['__cached_args_method_is_ring_bond'] = {} - - mol.__dict__['sssr'] = self.sssr # thiele/kekule - mol.__dict__['ring_atoms'] = self.ring_atoms # morgan - mol.__dict__['_connected_components'] = self._connected_components # isomorphism - mol.__dict__['atoms_rings_sizes'] = self.atoms_rings_sizes # isomorphism - mol.__dict__['__cached_args_method_neighbors'] = neighbors # isomorphism - mol.__dict__['__cached_args_method_heteroatoms'] = heteroatoms # isomorphism - mol.__dict__['__cached_args_method_is_ring_bond'] = is_ring_bond # isomorphism - - def __set_stereo(self: 'MoleculeContainer', mol): - mol._atoms_stereo.update(self._atoms_stereo) - mol._allenes_stereo.update(self._allenes_stereo) - mol._cis_trans_stereo.update(self._cis_trans_stereo) - mol.fix_stereo() - return mol - __all__ = ['Tautomers'] diff --git a/chython/algorithms/tautomers/acid_base.py b/chython/algorithms/tautomers/acid_base.py index bb1a672f..c901cbcd 100644 --- a/chython/algorithms/tautomers/acid_base.py +++ b/chython/algorithms/tautomers/acid_base.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2022 Ramil Nugmanov +# Copyright 2022-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -44,9 +44,8 @@ def neutralize(self: 'MoleculeContainer', *, keep_charge=True, logging=False, return [] return False - self._charges.update(mol._charges) - self._hydrogens.update(mol._hydrogens) - self.flush_cache() + self._atoms.update(mol._atoms) + self.flush_cache(keep_sssr=True, keep_components=True) if _fix_stereo: self.fix_stereo() if logging: @@ -85,14 +84,16 @@ def enumerate_charged_forms(self: 'MoleculeContainer', *, deep: int = 4, limit: continue uniq.add(dc) seen_combo.add((dc, ac)) - mol = self.copy() + mol = self.copy(keep_sssr=True, keep_components=True) for n in ac: - mol._hydrogens[n] += 1 - mol._charges[n] += 1 + a = mol._atoms[n] + a._implicit_hydrogens += 1 + a._charge += 1 for n in dc: if n is not None: - mol._hydrogens[n] -= 1 - mol._charges[n] -= 1 + a = mol._atoms[n] + a._implicit_hydrogens -= 1 + a._charge -= 1 if mol not in seen: seen.add(mol) yield mol @@ -109,15 +110,17 @@ def enumerate_charged_forms(self: 'MoleculeContainer', *, deep: int = 4, limit: uniq.add(ac) if (dc, ac) in seen_combo: continue - mol = self.copy() + mol = self.copy(keep_sssr=True, keep_components=True) for n in ac: if n is not None: - mol._hydrogens[n] += 1 - mol._charges[n] += 1 + a = mol._atoms[n] + a._implicit_hydrogens += 1 + a._charge += 1 for n in dc: if n is not None: - mol._hydrogens[n] -= 1 - mol._charges[n] -= 1 + a = mol._atoms[n] + a._implicit_hydrogens -= 1 + a._charge -= 1 if mol not in seen: seen.add(mol) yield mol @@ -139,44 +142,52 @@ def _neutralize(self: 'MoleculeContainer', keep_charge=True): if not donors or not acceptors: return # neutralization impossible elif len(donors) > len(acceptors): - copy = self.copy() - for a in acceptors: - copy._hydrogens[a] += 1 - copy._charges[a] += 1 + copy = self.copy(keep_sssr=True, keep_components=True) + for n in acceptors: + a = copy._atoms[n] + a._implicit_hydrogens += 1 + a._charge += 1 for c in combinations(donors, len(acceptors)): - mol = copy.copy() - for d in c: - mol._hydrogens[d] -= 1 - mol._charges[d] -= 1 + mol = copy.copy(keep_sssr=True, keep_components=True) + for n in c: + a = mol._atoms[n] + a._implicit_hydrogens -= 1 + a._charge -= 1 yield mol, acceptors.union(c) elif len(donors) < len(acceptors): - copy = self.copy() - for d in donors: - copy._hydrogens[d] -= 1 - copy._charges[d] -= 1 + copy = self.copy(keep_sssr=True, keep_components=True) + for n in donors: + a = copy._atoms[n] + a._implicit_hydrogens -= 1 + a._charge -= 1 for c in combinations(acceptors, len(donors)): - mol = copy.copy() - for a in c: - mol._hydrogens[a] += 1 - mol._charges[a] += 1 + mol = copy.copy(keep_sssr=True, keep_components=True) + for n in c: + a = mol._atoms[n] + a._implicit_hydrogens += 1 + a._charge += 1 yield mol, donors.union(c) else: # balanced! - mol = self.copy() - for d in donors: - mol._hydrogens[d] -= 1 - mol._charges[d] -= 1 - for a in acceptors: - mol._hydrogens[a] += 1 - mol._charges[a] += 1 + mol = self.copy(keep_sssr=True, keep_components=True) + for n in donors: + a = mol._atoms[n] + a._implicit_hydrogens -= 1 + a._charge -= 1 + for n in acceptors: + a = mol._atoms[n] + a._implicit_hydrogens += 1 + a._charge += 1 yield mol, donors | acceptors elif donors or acceptors: - mol = self.copy() - for d in donors: - mol._hydrogens[d] -= 1 - mol._charges[d] -= 1 - for a in acceptors: - mol._hydrogens[a] += 1 - mol._charges[a] += 1 + mol = self.copy(keep_sssr=True, keep_components=True) + for n in donors: + a = mol._atoms[n] + a._implicit_hydrogens -= 1 + a._charge -= 1 + for n in acceptors: + a = mol._atoms[n] + a._implicit_hydrogens += 1 + a._charge += 1 yield mol, donors | acceptors def _enumerate_zwitter_tautomers(self: 'MoleculeContainer'): @@ -190,11 +201,13 @@ def _enumerate_zwitter_tautomers(self: 'MoleculeContainer'): acceptors.add(mapping[1]) for d, a in product(donors, acceptors): - mol = self.copy() - mol._hydrogens[d] -= 1 - mol._hydrogens[a] += 1 - mol._charges[d] -= 1 - mol._charges[a] += 1 + mol = self.copy(keep_sssr=True, keep_components=True) + d = mol._atoms[d] + a = mol._atoms[a] + d._implicit_hydrogens -= 1 + a._implicit_hydrogens += 1 + d._charge -= 1 + a._charge += 1 yield mol diff --git a/chython/algorithms/tautomers/heteroarenes.py b/chython/algorithms/tautomers/heteroarenes.py index 81837438..3e6ac345 100644 --- a/chython/algorithms/tautomers/heteroarenes.py +++ b/chython/algorithms/tautomers/heteroarenes.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2022 Ramil Nugmanov +# Copyright 2022-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -33,9 +33,6 @@ class HeteroArenes: def _enumerate_hetero_arene_tautomers(self: 'MoleculeContainer'): atoms = self._atoms bonds = self._bonds - hydrogens = self._hydrogens - charges = self._charges - radicals = self._radicals rings = defaultdict(list) # aromatic skeleton for n, m_bond in bonds.items(): @@ -49,19 +46,20 @@ def _enumerate_hetero_arene_tautomers(self: 'MoleculeContainer'): donors = set() single_bonded = set() for n, ms in rings.items(): + a = atoms[n] if len(ms) == 2: - if atoms[n].atomic_number in (5, 7, 15): - if not charges[n] and not radicals[n]: + if a.atomic_number in (5, 7, 15): + if not a.charge and not a.is_radical: # only neutral B, N, P - if hydrogens[n]: # pyrrole + if a.implicit_hydrogens: # pyrrole donors.add(n) elif len(bonds[n]) == 2: # pyridine acceptors.add(n) else: single_bonded.add(n) - elif charges[n] == -1 and atoms[n].atomic_number == 6: # ferrocene + elif a.charge == -1 and a.atomic_number == 6: # ferrocene single_bonded.add(n) - elif len(ms) == 3 and atoms[n].atomic_number in (5, 7, 15) and not charges[n] and not radicals[n]: + elif len(ms) == 3 and a.atomic_number in (5, 7, 15) and not a.charge and not a.is_radical: single_bonded.add(n) if not donors or not acceptors: return @@ -94,9 +92,9 @@ def _enumerate_hetero_arene_tautomers(self: 'MoleculeContainer'): next(_kekule_component(component, sb, (), 0)) except InvalidAromaticRing: continue - mol = self.copy() - mol._hydrogens[d] = 0 - mol._hydrogens[a] = 1 + mol = self.copy(keep_sssr=True, keep_components=True) + mol._atoms[d]._implicit_hydrogens = 0 + mol._atoms[a]._implicit_hydrogens = 1 yield mol diff --git a/chython/algorithms/tautomers/keto_enol.py b/chython/algorithms/tautomers/keto_enol.py index acad2241..f9fd582b 100644 --- a/chython/algorithms/tautomers/keto_enol.py +++ b/chython/algorithms/tautomers/keto_enol.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2022 Ramil Nugmanov +# Copyright 2022-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -39,13 +39,13 @@ def _enumerate_keto_enol_tautomers(self: Union['MoleculeContainer', 'KetoEnol'], a = fix[0][0] d = fix[-1][1] - mol = self.copy() + mol = self.copy(keep_sssr=True, keep_components=True) m_bonds = mol._bonds for n, m, b in fix: - m_bonds[n][m]._Bond__order = b + m_bonds[n][m]._order = b - mol._hydrogens[a] += 1 - mol._hydrogens[d] -= 1 + mol._atoms[a]._implicit_hydrogens += 1 + mol._atoms[d]._implicit_hydrogens -= 1 yield mol, ket @cached_property @@ -59,8 +59,6 @@ def _sugar_groups(self): def __enumerate_bonds(self: 'MoleculeContainer', partial): atoms = self._atoms bonds = self._bonds - hydrogens = self._hydrogens - hybridization = self.hybridization rings = self.atoms_rings_sizes # search neutral oxygen and nitrogen @@ -83,11 +81,12 @@ def __enumerate_bonds(self: 'MoleculeContainer', partial): if partial and path and not len(path) % 2 and \ (hydrogen or # enol > ketone - hydrogens[(x := path[-1][1])] and (x not in rings or all(x > 7 for x in rings[x]))): # ketone> + atoms[(x := path[-1][1])].implicit_hydrogens and + (x not in rings or all(x > 7 for x in rings[x]))): # ketone> # return partial hops. ignore allenes in small rings. yield path, hydrogen if len(path) > depth: # fork found - if not partial and not len(path) % 2 and (hydrogen or hydrogens[path[-1][1]]): + if not partial and not len(path) % 2 and (hydrogen or atoms[path[-1][1]].implicit_hydrogens): # end of path found. return it and start new one. yield path, hydrogen seen.difference_update(x for _, x, _ in path[depth:]) @@ -110,32 +109,32 @@ def __enumerate_bonds(self: 'MoleculeContainer', partial): continue elif n in anti: # enol-ketone switch if current in anti[n]: - if hydrogens: - if b.order == 2: + if hydrogen: + if b == 2: cp = path.copy() cp.append((current, n, 1)) yield cp, True - elif b.order == 1: + elif b == 1: cp = path.copy() cp.append((current, n, 2)) yield cp, False - elif b.order == bond and atoms[n].atomic_number == 6: # classic keto-enol route - hb = hybridization(n) - if hb == 2: # grow up + elif b.order == bond and (a := atoms[n]).atomic_number == 6: # classic keto-enol route + if a.hybridization == 2: # grow up stack.append((current, n, next_bond, depth)) elif hydrogen: - if hb == 3: # OC=CC=C=C case + if a.hybridization == 3: # OC=CC=C=C case cp = path.copy() cp.append((current, n, 1)) yield cp, True # ketone found - elif hb == 1 and hydrogens[n]: # ketone >> enol + elif a.hybridization == 1 and a.implicit_hydrogens: # ketone >> enol cp = path.copy() cp.append((current, n, 2)) yield cp, False if path and not len(path) % 2 and \ (hydrogen or # enol > ketone - hydrogens[(x := path[-1][1])] and (x not in rings or all(x > 7 for x in rings[x]))): + atoms[(x := path[-1][1])].implicit_hydrogens and + (x not in rings or all(x > 7 for x in rings[x]))): yield path, hydrogen From 109c8de189a2af6ac5c423a73fc232a34cb7b54b Mon Sep 17 00:00:00 2001 From: Ramil Nugmanov Date: Wed, 13 Nov 2024 16:36:08 +0100 Subject: [PATCH 29/67] fixes --- chython/algorithms/tautomers/acid_base.py | 2 +- chython/algorithms/tautomers/heteroarenes.py | 2 +- chython/algorithms/tautomers/keto_enol.py | 23 +++++++++++--------- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/chython/algorithms/tautomers/acid_base.py b/chython/algorithms/tautomers/acid_base.py index c901cbcd..4323b0c8 100644 --- a/chython/algorithms/tautomers/acid_base.py +++ b/chython/algorithms/tautomers/acid_base.py @@ -44,7 +44,7 @@ def neutralize(self: 'MoleculeContainer', *, keep_charge=True, logging=False, return [] return False - self._atoms.update(mol._atoms) + self._atoms = mol._atoms self.flush_cache(keep_sssr=True, keep_components=True) if _fix_stereo: self.fix_stereo() diff --git a/chython/algorithms/tautomers/heteroarenes.py b/chython/algorithms/tautomers/heteroarenes.py index 3e6ac345..4115d6a3 100644 --- a/chython/algorithms/tautomers/heteroarenes.py +++ b/chython/algorithms/tautomers/heteroarenes.py @@ -37,7 +37,7 @@ def _enumerate_hetero_arene_tautomers(self: 'MoleculeContainer'): rings = defaultdict(list) # aromatic skeleton for n, m_bond in bonds.items(): for m, bond in m_bond.items(): - if bond.order == 4: + if bond == 4: rings[n].append(m) if not rings: return diff --git a/chython/algorithms/tautomers/keto_enol.py b/chython/algorithms/tautomers/keto_enol.py index f9fd582b..ddcd14d7 100644 --- a/chython/algorithms/tautomers/keto_enol.py +++ b/chython/algorithms/tautomers/keto_enol.py @@ -44,8 +44,12 @@ def _enumerate_keto_enol_tautomers(self: Union['MoleculeContainer', 'KetoEnol'], for n, m, b in fix: m_bonds[n][m]._order = b - mol._atoms[a]._implicit_hydrogens += 1 - mol._atoms[d]._implicit_hydrogens -= 1 + a = mol._atoms[a] + d = mol._atoms[d] + a._implicit_hydrogens += 1 + d._implicit_hydrogens -= 1 + a._hybridization -= 1 # -C=X>=C-X or -C=C=X>=C-C=X + d._hybridization += 1 yield mol, ket @cached_property @@ -108,17 +112,16 @@ def __enumerate_bonds(self: 'MoleculeContainer', partial): elif n in seen: # aromatic ring destruction. pyridine double bonds shift continue elif n in anti: # enol-ketone switch - if current in anti[n]: + if current in anti[n]: # keton or enol bond if hydrogen: - if b == 2: - cp = path.copy() - cp.append((current, n, 1)) - yield cp, True - elif b == 1: cp = path.copy() - cp.append((current, n, 2)) + cp.append((current, n, 1)) # double to single in keton end + yield cp, True + else: + cp = path.copy() + cp.append((current, n, 2)) # single to double in enol end yield cp, False - elif b.order == bond and (a := atoms[n]).atomic_number == 6: # classic keto-enol route + elif b == bond and (a := atoms[n]).atomic_number == 6: # classic keto-enol route if a.hybridization == 2: # grow up stack.append((current, n, next_bond, depth)) elif hydrogen: From d52d062620e58bf9761cd4662504665877e4c665 Mon Sep 17 00:00:00 2001 From: Ramil Nugmanov Date: Wed, 13 Nov 2024 18:47:20 +0100 Subject: [PATCH 30/67] bond assessment streamlined through operator overloading --- chython/algorithms/aromatics/kekule.py | 9 +++--- chython/algorithms/aromatics/thiele.py | 5 ++-- chython/algorithms/depict.py | 7 ++--- chython/algorithms/isomorphism.py | 14 ++++----- chython/algorithms/smiles.py | 12 ++++---- chython/algorithms/standardize/molecule.py | 12 ++++---- chython/algorithms/standardize/resonance.py | 4 +-- chython/algorithms/x3dom.py | 7 ++--- chython/containers/bonds.py | 6 ++-- chython/containers/molecule.py | 33 ++++++++++----------- chython/files/_convert.py | 4 +-- 11 files changed, 52 insertions(+), 61 deletions(-) diff --git a/chython/algorithms/aromatics/kekule.py b/chython/algorithms/aromatics/kekule.py index 6848638c..13905644 100644 --- a/chython/algorithms/aromatics/kekule.py +++ b/chython/algorithms/aromatics/kekule.py @@ -113,12 +113,11 @@ def __prepare_rings(self: 'MoleculeContainer'): triple_bonded = set() for n, m_bond in bonds.items(): for m, bond in m_bond.items(): - bo = bond.order - if bo == 4: + if bond == 4: rings[n].append(m) - elif bo == 2: + elif bond == 2: double_bonded[n].append(m) - elif bo == 3: + elif bond == 3: triple_bonded.add(n) if not rings: @@ -160,7 +159,7 @@ def __prepare_rings(self: 'MoleculeContainer'): if m not in seen: rings[n].remove(m) rings[m].remove(n) - bonds[n][m]._Bond__order = 1 # noqa + bonds[n][m]._order = 1 if any(len(ms) not in (2, 3) for ms in rings.values()): raise InvalidAromaticRing('not in ring aromatic bond or hypercondensed rings: ' diff --git a/chython/algorithms/aromatics/thiele.py b/chython/algorithms/aromatics/thiele.py index f236e887..c8034bcb 100644 --- a/chython/algorithms/aromatics/thiele.py +++ b/chython/algorithms/aromatics/thiele.py @@ -127,8 +127,7 @@ def thiele(self: 'MoleculeContainer', *, fix_tautomers=True) -> bool: return False # check out-of-ring double bonds - double_bonded = {n for n in rings if any(m not in rings[n] and b.order == 2 - for m, b in bonds[n].items())} + double_bonded = {n for n in rings if any(m not in rings[n] and b == 2 for m, b in bonds[n].items())} # fix_tautomers if fix_tautomers and acceptors and donors: @@ -157,7 +156,7 @@ def thiele(self: 'MoleculeContainer', *, fix_tautomers=True) -> bool: seen.add(current) new_order = 1 if order == 2 else 2 stack.extend((current, n, depth, new_order) for n in rings[current] if - n not in seen and n not in double_bonded and bonds[current][n].order == order) + n not in seen and n not in double_bonded and bonds[current][n] == order) else: # path not found continue for n, m, o in path: diff --git a/chython/algorithms/depict.py b/chython/algorithms/depict.py index a48eb6c7..73cf2319 100644 --- a/chython/algorithms/depict.py +++ b/chython/algorithms/depict.py @@ -271,17 +271,16 @@ def __render_bonds(self: Union['MoleculeContainer', 'DepictMolecule']): for n, m, bond in self.bonds(): if m in wedge[n]: continue - order = bond.order nx, ny = atoms[n].xy mx, my = atoms[m].xy ny, my = -ny, -my - if order in (1, 4): + if bond in (1, 4): svg.append(f' ') - elif order == 2: + elif bond == 2: dx, dy = _rotate_vector(0, double_space, mx - nx, ny - my) svg.append(f' ') svg.append(f' ') - elif order == 3: + elif bond == 3: dx, dy = _rotate_vector(0, triple_space, mx - nx, ny - my) svg.append(f' ') svg.append(f' ') diff --git a/chython/algorithms/isomorphism.py b/chython/algorithms/isomorphism.py index a6ddea3e..30243690 100644 --- a/chython/algorithms/isomorphism.py +++ b/chython/algorithms/isomorphism.py @@ -245,15 +245,14 @@ def _cython_compiled_structure(self: 'MoleculeContainer'): for j, (m, b) in enumerate(ms.items(), start): indices[j] = x = mapping[m] v = bits1[x] - o = b.order - if o == 1: + if b == 1: v |= 0x0800000000000000 - elif o == 4: - v |= 0x4000000000000000 - elif o == 2: + elif b == 2: v |= 0x1000000000000000 - elif o == 3: + elif b == 3: v |= 0x2000000000000000 + elif b == 4: + v |= 0x4000000000000000 else: v |= 0x8000000000000000 v |= 0x0400000000000000 if b.in_ring else 0x0200000000000000 @@ -488,8 +487,7 @@ def _get_automorphism_mapping(atoms: Dict[int, int], bonds: Dict[int, Dict[int, return # all atoms unique components, closures = _compile_query(atoms, bonds) - mappers = [_get_mapping(order, closures, atoms, bonds, {x for x, *_ in order}) - for order in components] + mappers = [_get_mapping(order, closures, atoms, bonds, {x for x, *_ in order}) for order in components] if len(mappers) == 1: for mapping in mappers[0]: if any(k != v for k, v in mapping.items()): diff --git a/chython/algorithms/smiles.py b/chython/algorithms/smiles.py index fc0e7d01..8569ff1f 100644 --- a/chython/algorithms/smiles.py +++ b/chython/algorithms/smiles.py @@ -452,12 +452,12 @@ def _format_atom(self: 'MoleculeContainer', n, adjacency, **kwargs): def _format_bond(self: Union['MoleculeContainer', 'MoleculeSmiles'], n, m, adjacency, **kwargs): if not kwargs.get('bonds', True): return '' - order = self._bonds[n][m].order - if order == 4: + bond = self._bonds[n][m] + if bond == 4: if kwargs.get('aromatic', True): return '' return ':' - elif order == 1: # cis-trans /\ + elif bond == 1: # cis-trans /\ if kwargs.get('aromatic', True) and self._atoms[n].hybridization == self._atoms[m].hybridization == 4: return '-' if kwargs.get('stereo', True): @@ -469,11 +469,11 @@ def _format_bond(self: Union['MoleculeContainer', 'MoleculeSmiles'], n, m, adjac if (x := ct_map.get((n, m))) is not None: return '/' if x else '\\' return '' - elif order == 2: + elif bond == 2: return '=' - elif order == 3: + elif bond == 3: return '#' - else: # order == 8 + else: # bond == 8 return '~' def __ct_map(self: 'MoleculeContainer', adjacency): diff --git a/chython/algorithms/standardize/molecule.py b/chython/algorithms/standardize/molecule.py index 049671a2..a69db682 100644 --- a/chython/algorithms/standardize/molecule.py +++ b/chython/algorithms/standardize/molecule.py @@ -235,7 +235,7 @@ def standardize_charges(self: 'MoleculeContainer', *, logging=False, prepare_mol continue ch = ch[0][0] ca = [n for n in r if atoms[n].atomic_number == 6 and - (len(bs := nsc[n]) == 2 or len(bs) == 3 and any(b.order == 1 for b in bonds[n].values()))] + (len(bs := nsc[n]) == 2 or len(bs) == 3 and any(b == 1 for b in bonds[n].values()))] if len(ca) < 2 or ch not in ca: continue atoms[ch]._charge = 0 # reset charge for morgan recalculation @@ -268,7 +268,7 @@ def remove_coordinate_bonds(self: 'MoleculeContainer', *, keep_to_terminal=True, """ bonds = self._bonds - ab = [(n, m) for n, m, b in self.bonds() if b.order == 8] + ab = [(n, m) for n, m, b in self.bonds() if b == 8] if keep_to_terminal: skeleton = self.not_special_connectivity @@ -303,10 +303,10 @@ def implicify_hydrogens(self: 'MoleculeContainer', *, logging=False, _fix_stereo if len(bonds[n]) > 1: raise ValenceError(f'Hydrogen atom {n} has invalid valence. Try to use remove_coordinate_bonds()') for m, b in bonds[n].items(): - if b.order == 1: + if b == 1: if atoms[m].atomic_number != 1: # not H-H explicit[m].append(n) - elif b.order != 8: + elif b != 8: raise ValenceError(f'Hydrogen atom {n} has invalid valence {b.order}.') to_remove = set() @@ -319,7 +319,7 @@ def implicify_hydrogens(self: 'MoleculeContainer', *, logging=False, _fix_stereo explicit_sum = 0 explicit_dict = defaultdict(int) for m, bond in bonds[n].items(): - if m not in hi and bond.order != 8: + if m not in hi and bond != 8: explicit_sum += bond.order explicit_dict[(bond.order, atoms[m].atomic_number)] += 1 try: @@ -454,7 +454,7 @@ def __standardize(self: 'MoleculeContainer', rules, fix_tautomers): hs.add(m) if m in bonds[n]: b = bonds[n][m] - if b.order == 8 or b == 8: + if b == 8 or bo == 8: keep_sssr = False b._order = bo else: # new bond diff --git a/chython/algorithms/standardize/resonance.py b/chython/algorithms/standardize/resonance.py index 31f0a0da..d703083f 100644 --- a/chython/algorithms/standardize/resonance.py +++ b/chython/algorithms/standardize/resonance.py @@ -154,8 +154,8 @@ def __entries(self: 'MoleculeContainer'): (n1, b1), (n2, b2) = bonds[n].items() an1 = atoms[n1] an2 = atoms[n2] - if b1.order == b2.order == 2 and (an1.charge == -1 and an1.atomic_number == 7 or - an2.charge == -1 and an2.atomic_number == 7): + if b1 == b2 == 2 and (an1.charge == -1 and an1.atomic_number == 7 or + an2.charge == -1 and an2.atomic_number == 7): continue elif lb == 3 and a.hybridization == 2: # X=[N+](-X)-X - prevent N-N migration nitrogen_ani.add(n) diff --git a/chython/algorithms/x3dom.py b/chython/algorithms/x3dom.py index 2118899b..9d59160d 100644 --- a/chython/algorithms/x3dom.py +++ b/chython/algorithms/x3dom.py @@ -221,7 +221,6 @@ def __render_bonds(self: 'MoleculeContainer', xyz): doubles = {} half_triple = triple_space / 2 for n, m, bond in self.bonds(): - order = bond.order nx, ny, nz = xyz[n] mx, my, mz = xyz[m] @@ -233,13 +232,13 @@ def __render_bonds(self: 'MoleculeContainer', xyz): rotation_angle = acos(nmy / length) lengths[(n, m)] = lengths[(m, n)] = (length, rotation_angle) x, y, z = nx + nmx / 2, ny + nmy / 2, nz + nmz / 2 - if order in (1, 4): + if bond in (1, 4): xml.append(f" \n \n \n" f" \n \n" f" \n \n" " \n \n \n") - elif order == 2: + elif bond == 2: if n in doubles: # normal for plane n m o norm_x, norm_y, norm_z = plane_normal(nmx, nmy, nmz, *doubles[n]) @@ -286,7 +285,7 @@ def __render_bonds(self: 'MoleculeContainer', xyz): f" \n \n" f" \n \n" " \n \n \n") - elif order == 3: + elif bond == 3: nox, noy, noz = vector_normal(nmx, nmy, nmz) # normal for plane n m o diff --git a/chython/containers/bonds.py b/chython/containers/bonds.py index a6ce7721..43847d51 100644 --- a/chython/containers/bonds.py +++ b/chython/containers/bonds.py @@ -31,10 +31,10 @@ def __init__(self, order: int): self._stereo = None def __eq__(self, other): - if isinstance(other, Bond): - return self.order == other.order - elif isinstance(other, int): + if isinstance(other, int): return self.order == other + elif isinstance(other, Bond): + return self.order == other.order return False def __repr__(self): diff --git a/chython/containers/molecule.py b/chython/containers/molecule.py index d56c122d..474490ee 100644 --- a/chython/containers/molecule.py +++ b/chython/containers/molecule.py @@ -186,7 +186,7 @@ def add_bond(self, n, m, bond: Union[Bond, int], *, _skip_calculation=False): bond = Bond(bond) super().add_bond(n, m, bond) - if bond.order == 8: + if bond == 8: return # any bond doesn't change anything if self._changed is None: self._changed = {n, m} @@ -208,7 +208,7 @@ def delete_atom(self, n: int, *, _skip_calculation=False): del self._atoms[n] for m, bond in self._bonds.pop(n).items(): del self._bonds[m][n] - if bond.order == 8: + if bond == 8: continue if self._changed is None: self._changed = {m} @@ -227,7 +227,7 @@ def delete_bond(self, n: int, m: int, *, _skip_calculation=False): Call `kekule()` and `thiele()` in sequence to fix marks. """ del self._bonds[n][m] - if self._bonds[m].pop(n).order != 8: + if self._bonds[m].pop(n) != 8: if self._changed is None: self._changed = {n, m} else: @@ -727,15 +727,14 @@ def calc_labels(self): for m, bond in m_bond.items(): bond._in_ring = anr and (amr := atoms_rings.get(m) or False) and not anr.isdisjoint(amr) # have common rings - order = bond.order - if order == 8: + if bond == 8: continue - elif order == 4: + elif bond == 4: hybridization = 4 elif hybridization != 4: - if order == 3: + if bond == 3: hybridization = 3 - elif order == 2: + elif bond == 2: if hybridization == 1: hybridization = 2 elif hybridization == 2: @@ -769,16 +768,15 @@ def calc_implicit(self, n: int): explicit_dict = defaultdict(int) aroma = 0 for m, bond in self._bonds[n].items(): - order = bond.order - if order == 4: # only neutral carbon aromatic rings supported + if bond == 4: # only neutral carbon aromatic rings supported if not atom.charge and not atom.is_radical and atom.atomic_number == 6: aroma += 1 else: # use `kekule()` to calculate proper implicit hydrogens count atom._implicit_hydrogens = None return - elif order != 8: # any bond used for complexes - explicit_sum += order - explicit_dict[(order, self._atoms[m].atomic_number)] += 1 + elif bond != 8: # any bond used for complexes + explicit_sum += bond.order + explicit_dict[(bond.order, self._atoms[m].atomic_number)] += 1 if aroma == 2: if explicit_sum == 0: # H-Ar @@ -818,12 +816,11 @@ def check_implicit(self, n: int, h: int) -> bool: explicit_dict = defaultdict(int) for m, bond in self._bonds[n].items(): - order = bond.order - if order == 4: # can't check aromatic rings + if bond == 4: # can't check aromatic rings return False - elif order != 8: # any bond used for complexes - explicit_sum += order - explicit_dict[(order, self._atoms[m].atomic_number)] += 1 + elif bond != 8: # any bond used for complexes + explicit_sum += bond.order + explicit_dict[(bond.order, self._atoms[m].atomic_number)] += 1 try: rules = atom.valence_rules(explicit_sum) diff --git a/chython/files/_convert.py b/chython/files/_convert.py index 6da1ffd6..422a46a9 100644 --- a/chython/files/_convert.py +++ b/chython/files/_convert.py @@ -87,7 +87,7 @@ def create_molecule(data, *, ignore_bad_isotopes=False, skip_calc_implicit=False # rare H0 case if (not keep_radicals and not ignore_aromatic_radicals and not h and not a.charge and not a.is_radical and a.atomic_number in (5, 6, 7, 15) - and sum(b.order != 8 for b in bonds[n].values()) == 2): + and sum(b != 8 for b in bonds[n].values()) == 2): # c[c]c - aromatic B,C,N,P radical a._is_radical = True radicalized.append(n) @@ -107,7 +107,7 @@ def create_molecule(data, *, ignore_bad_isotopes=False, skip_calc_implicit=False if a.hybridization == 4: if (not keep_radicals and not h and not a.charge and not a.is_radical and a.atomic_number in (5, 6, 7, 15) - and sum(b.order != 8 for b in bonds[n].values()) == 2): + and sum(b != 8 for b in bonds[n].values()) == 2): # c[c]c - aromatic B,C,N,P radical a._implicit_hydrogens = 0 a._is_radical = True From 36f6fbdefdd422010eacbf95ab29e2e1c5673783 Mon Sep 17 00:00:00 2001 From: Ramil Nugmanov Date: Wed, 13 Nov 2024 21:25:24 +0100 Subject: [PATCH 31/67] atom matching streamlined through operator overloading. constants added for better readability. --- chython/algorithms/aromatics/kekule.py | 32 ++++++++++----- chython/algorithms/aromatics/thiele.py | 29 +++++++++----- chython/algorithms/smiles.py | 13 ++++-- chython/algorithms/standardize/molecule.py | 17 +++++--- chython/algorithms/standardize/resonance.py | 30 +++++++++----- chython/algorithms/standardize/salts.py | 14 ++++--- chython/algorithms/stereo.py | 42 +++++++++++--------- chython/algorithms/tautomers/heteroarenes.py | 13 ++++-- chython/algorithms/tautomers/keto_enol.py | 6 ++- chython/containers/molecule.py | 24 ++++++----- chython/files/_convert.py | 14 +++++-- chython/periodictable/base/element.py | 4 ++ 12 files changed, 155 insertions(+), 83 deletions(-) diff --git a/chython/algorithms/aromatics/kekule.py b/chython/algorithms/aromatics/kekule.py index 13905644..7f3cbd6c 100644 --- a/chython/algorithms/aromatics/kekule.py +++ b/chython/algorithms/aromatics/kekule.py @@ -27,6 +27,18 @@ from chython import MoleculeContainer +# atomic number constants +B = 5 +C = 6 +N = 7 +O = 8 +P = 15 +S = 16 +As = 33 +Se = 34 +Te = 52 + + class Kekule: __slots__ = () @@ -170,16 +182,14 @@ def __prepare_rings(self: 'MoleculeContainer'): if any(len(rings[n]) != 2 for n in double_bonded): # double bonded never condensed raise InvalidAromaticRing('quinone valence error') for n in double_bonded: - atom = atoms[n] - if atom.atomic_number == 7: + if (atom := atoms[n]) == N: if atom.charge != 1: raise InvalidAromaticRing('quinone should be charged N atom') - elif atom.atomic_number not in (6, 15, 16, 33, 34, 52) or atom.charge: + elif atom not in (C, P, S, As, Se, Te) or atom.charge: raise InvalidAromaticRing('quinone should be neutral S, Se, Te, C, P, As atom') for n in rings: - atom = atoms[n] - if atom.atomic_number == 6: # carbon + if (atom := atoms[n]) == C: # carbon if atom.charge == 0: if atom.neighbors not in (2, 3): raise InvalidAromaticRing @@ -197,14 +207,14 @@ def __prepare_rings(self: 'MoleculeContainer'): raise InvalidAromaticRing else: raise InvalidAromaticRing - elif atom.atomic_number in (7, 15, 33): + elif atom in (N, P, As): if atom.charge == 0: # pyrrole or pyridine. include radical pyrrole if atom.is_radical: if atom.neighbors != 2: # only pyrrole radical raise InvalidAromaticRing double_bonded.add(n) elif atom.neighbors == 3: - if atom.atomic_number == 7: # pyrrole only possible + if atom == N: # pyrrole only possible double_bonded.add(n) else: # P(III) or P(V)H pyrroles.add(n) @@ -215,7 +225,7 @@ def __prepare_rings(self: 'MoleculeContainer'): double_bonded.add(n) elif atom.implicit_hydrogens: # too many hydrogens for aromatic rings raise InvalidAromaticRing - elif atom.neighbors != 4 or atom.atomic_number not in (15, 33): # P(V) in ring [P;a](-R1)-R2 + elif atom.neighbors != 4 or atom not in (P, As): # P(V) in ring [P;a](-R1)-R2 raise InvalidAromaticRing elif atom.charge == -1: # pyrrole only if atom.neighbors != 2 or atom.is_radical: @@ -230,7 +240,7 @@ def __prepare_rings(self: 'MoleculeContainer'): pyrroles.add(n) elif atom.neighbors != 3: # not pyridine oxyde raise InvalidAromaticRing - elif atom.atomic_number == 8: # furan + elif atom == O: # furan if atom.neighbors == 2: if atom.charge == 0: if atom.is_radical: @@ -244,7 +254,7 @@ def __prepare_rings(self: 'MoleculeContainer'): raise InvalidAromaticRing('invalid oxygen charge') else: raise InvalidAromaticRing('Triple-bonded oxygen') - elif atom.atomic_number in (16, 34, 52): # thiophene + elif atom in (S, Se, Te): # thiophene if n not in double_bonded: # not sulphoxyde nor sulphone if atom.neighbors == 2: if atom.is_radical: @@ -267,7 +277,7 @@ def __prepare_rings(self: 'MoleculeContainer'): raise InvalidAromaticRing('S, Se, Te invalid charge ring') else: raise InvalidAromaticRing('S, Se, Te hypervalent ring') - elif atom.atomic_number == 5: # boron + elif atom == B: if atom.charge == 0: if atom.neighbors == 2: if atom.is_radical: # C=1O[B]OC=1 diff --git a/chython/algorithms/aromatics/thiele.py b/chython/algorithms/aromatics/thiele.py index c8034bcb..37ef5704 100644 --- a/chython/algorithms/aromatics/thiele.py +++ b/chython/algorithms/aromatics/thiele.py @@ -41,6 +41,15 @@ def _freaks(): freak_rules = Proxy(_freaks) +# atomic number constants +B = 5 +C = 6 +N = 7 +O = 8 +P = 15 +S = 16 +Se = 34 + class Thiele: __slots__ = () @@ -68,7 +77,7 @@ def thiele(self: 'MoleculeContainer', *, fix_tautomers=True) -> bool: if not 3 < lr < 8: # skip 3-membered and big rings continue # only B C N O P S with 2-3 neighbors. detects this: C1=CC=CP12=CC=CC=C2 - if any(atoms[n].atomic_number not in (6, 7, 8, 16, 5, 15) or len(nsc[n]) > 3 for n in ring): + if any(atoms[n] not in (C, N, O, S, B, P) or len(nsc[n]) > 3 for n in ring): continue sp2 = sum(atoms[n].hybridization == 2 for n in ring) if sp2 == lr: # benzene like @@ -76,7 +85,7 @@ def thiele(self: 'MoleculeContainer', *, fix_tautomers=True) -> bool: tetracycles.append(ring) else: if fix_tautomers and lr % 2: # find potential pyrroles - acceptors.update(n for n in ring if (a := atoms[n]).atomic_number == 7 and not a.charge) + acceptors.update(n for n in ring if (a := atoms[n]) == N and not a.charge) n, *_, m = ring rings[n].add(m) rings[m].add(n) @@ -88,26 +97,24 @@ def thiele(self: 'MoleculeContainer', *, fix_tautomers=True) -> bool: n = next(n for n in ring if atoms[n].hybridization == 1) except StopIteration: # exotic, just skip continue - a = atoms[n] - an = a.atomic_number - if (c := a.charge) == -1: - if an != 6 or lr != 5: # skip any but ferrocene + if (a := atoms[n]).charge == -1: + if a != C or lr != 5: # skip any but ferrocene continue - elif c: # skip any charged + elif a.charge: # skip any charged continue elif lr == 7: # skip electron-rich 7-membered rings - if an != 5: # not B? + if a != 5: # not B? continue # below lr == 5 or 6 only - elif an in (8, 16, 34): # O, S, Se + elif a in (O, S, Se): if len(bonds[n]) != 2: # like CS1(C)C=CC=C1 continue - elif an == 7: + elif a == N: if (b := len(bonds[n])) > 3: # extra check for invalid N(IV) continue elif fix_tautomers and lr == 6 and b == 2: donors.append(n) - elif an in (5, 15): # B, P + elif a in (B, P): if len(bonds[n]) > 3: continue else: # only B, [C-], N, O, P, S, Se diff --git a/chython/algorithms/smiles.py b/chython/algorithms/smiles.py index 8569ff1f..fecbae0d 100644 --- a/chython/algorithms/smiles.py +++ b/chython/algorithms/smiles.py @@ -50,6 +50,13 @@ dyn_radical_str = {(True, True): '*', (True, False): '*>^', (False, True): '^>*'} +# atomic number constants +B = 5 +C = 6 +N = 7 +P = 15 +S = 16 + class Smiles(ABC): __slots__ = () @@ -424,18 +431,18 @@ def _format_atom(self: 'MoleculeContainer', n, adjacency, **kwargs): smi[4] = 'H' elif atom.implicit_hydrogens: smi[4] = f'H{atom.implicit_hydrogens}' - elif atom.hybridization == 4 and atom.implicit_hydrogens and atom.atomic_number in (5, 7, 15): # pyrrole + elif atom.hybridization == 4 and atom.implicit_hydrogens and atom in (B, N, P): # pyrrole smi[0] = '[' smi[-1] = ']' if atom.implicit_hydrogens == 1: smi[4] = 'H' else: smi[4] = f'H{atom.implicit_hydrogens}' - elif not atom.implicit_hydrogens and atom.atomic_number in (5, 6, 15, 16) and not self.not_special_connectivity[n]: + elif not atom.implicit_hydrogens and atom in (B, C, P, S) and not self.not_special_connectivity[n]: # elemental B, C, P, S smi[0] = '[' smi[-1] = ']' - elif atom.implicit_hydrogens and atom.atomic_number == 15 and atom.hybridization != 1: + elif atom.implicit_hydrogens and atom == P and atom.hybridization != 1: smi[0] = '[' smi[-1] = ']' if atom.implicit_hydrogens == 1: diff --git a/chython/algorithms/standardize/molecule.py b/chython/algorithms/standardize/molecule.py index a69db682..5f4e5e77 100644 --- a/chython/algorithms/standardize/molecule.py +++ b/chython/algorithms/standardize/molecule.py @@ -25,13 +25,18 @@ from ._metal_organics import rules as metal_rules from ...containers.bonds import Bond from ...exceptions import ValenceError, ImplementationError -from ...periodictable import H +from ...periodictable import H as _H if TYPE_CHECKING: from chython import MoleculeContainer +# atomic number constants +H = 5 +C = 6 + + class Standardize: __slots__ = () @@ -234,7 +239,7 @@ def standardize_charges(self: 'MoleculeContainer', *, logging=False, prepare_mol if len(ch) != 1 or ch[0][1] != -1: continue ch = ch[0][0] - ca = [n for n in r if atoms[n].atomic_number == 6 and + ca = [n for n in r if atoms[n] == C and (len(bs := nsc[n]) == 2 or len(bs) == 3 and any(b == 1 for b in bonds[n].values()))] if len(ca) < 2 or ch not in ca: continue @@ -272,7 +277,7 @@ def remove_coordinate_bonds(self: 'MoleculeContainer', *, keep_to_terminal=True, if keep_to_terminal: skeleton = self.not_special_connectivity - hs = {n for n, a in self._atoms.items() if a.atomic_number == 1 and not skeleton[n]} + hs = {n for n, a in self._atoms.items() if a == H and not skeleton[n]} ab = [(n, m) for n, m in ab if n not in hs and m not in hs] for n, m in ab: @@ -299,12 +304,12 @@ def implicify_hydrogens(self: 'MoleculeContainer', *, logging=False, _fix_stereo explicit = defaultdict(list) for n, atom in atoms.items(): - if atom.atomic_number == 1 and (atom.isotope is None or atom.isotope == 1): + if atom == H and (atom.isotope is None or atom.isotope == 1): if len(bonds[n]) > 1: raise ValenceError(f'Hydrogen atom {n} has invalid valence. Try to use remove_coordinate_bonds()') for m, b in bonds[n].items(): if b == 1: - if atoms[m].atomic_number != 1: # not H-H + if atoms[m] != H: # not H-H explicit[m].append(n) elif b != 8: raise ValenceError(f'Hydrogen atom {n} has invalid valence {b.order}.') @@ -374,7 +379,7 @@ def explicify_hydrogens(self: 'MoleculeContainer', *, start_map=None, _return_ma bonds = self._bonds m = start_map if start_map is not None else max(atoms) + 1 for n in to_add: - atoms[m] = H(implicit_hydrogens=0) + atoms[m] = _H(implicit_hydrogens=0) bonds[n][m] = b = Bond(1) bonds[m] = {n: b} atoms[n]._implicit_hydrogens = 0 diff --git a/chython/algorithms/standardize/resonance.py b/chython/algorithms/standardize/resonance.py index d703083f..2283540f 100644 --- a/chython/algorithms/standardize/resonance.py +++ b/chython/algorithms/standardize/resonance.py @@ -24,6 +24,19 @@ from chython import MoleculeContainer +# atomic number constants +B = 5 +C = 6 +N = 7 +O = 8 +Si = 14 +P = 15 +S = 16 +As = 33 +Se = 34 +Te = 52 + + class Resonance: __slots__ = () @@ -132,36 +145,35 @@ def __entries(self: 'MoleculeContainer'): nitrogen_ani = set() sulfur_cat = set() for n, a in atoms.items(): - if a.atomic_number not in {5, 6, 7, 8, 14, 15, 16, 33, 34, 52}: + if a not in (B, C, N, O, Si, P, S, As, Se, Te): # filter non-organic set, halogens and aromatics continue elif a.is_radical: rads.add(n) elif a.charge == -1: - if (lb := len(bonds[n])) == 4 and a.atomic_number == 5: # skip boron + if (lb := len(bonds[n])) == 4 and a == B: # skip boron continue - elif lb == 6 and a.atomic_number == 15: # skip [P-]X6 + elif lb == 6 and a == P: # skip [P-]X6 continue if n in errors: # only valid anions accepted continue entries.add(n) elif a.charge == 1: lb = len(bonds[n]) - if a.atomic_number == 7: + if a == N: if lb == 4: # skip ammonia continue elif lb == 2 and a.hybridization == 3: # skip Azide (n1, b1), (n2, b2) = bonds[n].items() an1 = atoms[n1] an2 = atoms[n2] - if b1 == b2 == 2 and (an1.charge == -1 and an1.atomic_number == 7 or - an2.charge == -1 and an2.atomic_number == 7): + if b1 == b2 == 2 and (an1.charge == -1 and an1 == N or an2.charge == -1 and an2 == N): continue elif lb == 3 and a.hybridization == 2: # X=[N+](-X)-X - prevent N-N migration nitrogen_ani.add(n) - elif a.atomic_number == 15 and lb == 4: # skip [P+]R4 + elif a == P and lb == 4: # skip [P+]R4 continue - elif a.atomic_number == 16: + elif a == S: if lb == 2 and a.hybridization == 2: # ad-hoc for X-[S+]=X sulfur_cat.add(n) elif lb == 3 and a.hybridization == 1: # ad-hoc for X-[S+](-X)-X @@ -171,7 +183,7 @@ def __entries(self: 'MoleculeContainer'): if exits or entries: # try to move cation to nitrogen. saturation fixup. for n, a in self._atoms.items(): - if a.atomic_number == 7 and not a.charge: + if a == N and not a.charge: if a.hybridization == 1 and a.neighbors <= 3: # any amine - potential e-donor entries.add(n) nitrogen_cat.add(n) diff --git a/chython/algorithms/standardize/salts.py b/chython/algorithms/standardize/salts.py index d281b593..48cec856 100644 --- a/chython/algorithms/standardize/salts.py +++ b/chython/algorithms/standardize/salts.py @@ -18,12 +18,18 @@ # from typing import TYPE_CHECKING, List, Tuple, Union from ._salts import acids, rules +from ...periodictable import GroupI, GroupII if TYPE_CHECKING: from chython import MoleculeContainer +# atomic number constants +H = 1 +N = 7 + + class Salts: __slots__ = () @@ -38,7 +44,7 @@ def remove_metals(self: 'MoleculeContainer', *, logging=False) -> Union[bool, Li metals = [] for n, a in atoms.items(): - if a.atomic_number in {7, 3, 4, 11, 12, 19, 20, 37, 38, 55, 56} and not bonds[n]: + if not bonds[n] and (a == N or isinstance(a, (GroupI, GroupII)) and a != H): metals.append(n) if 0 < len(metals) < len(self): @@ -84,16 +90,14 @@ def remove_acids(self: 'MoleculeContainer', *, logging=False) -> Union[bool, Lis def split_metal_salts(self: 'MoleculeContainer', *, logging=False) -> Union[bool, List[Tuple[int, int]]]: """ - Split connected S-metal/lanthanides/actinides salts to cation/anion pairs. + Split connected S-metal salts to cation/anion pairs. :param logging: return deleted bonds list. """ atoms = self._atoms bonds = self._bonds - metals = [n for n, a in atoms.items() if a.atomic_number in - {3, 4, 11, 12, 19, 20, 37, 38, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 87, 88, - 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102}] + metals = [n for n, a in atoms.items() if isinstance(a, (GroupI, GroupII)) and a != H] if metals: acceptors = set() log = [] diff --git a/chython/algorithms/stereo.py b/chython/algorithms/stereo.py index 7421d3f5..e243d6a2 100644 --- a/chython/algorithms/stereo.py +++ b/chython/algorithms/stereo.py @@ -33,6 +33,10 @@ from chython import MoleculeContainer +# atomic number constants +H = 1 +C = 6 + # 1 2 # \ | # \| @@ -165,7 +169,7 @@ def tetrahedrons(self: 'MoleculeContainer') -> Tuple[int, ...]: """ tetra = [] for n, atom in self._atoms.items(): - if atom.atomic_number == 6 and not atom.charge and not atom.is_radical: + if atom == C and not atom.charge and not atom.is_radical: env = self._bonds[n] if all(b == 1 for b in env.values()): if sum(int(b) for b in env.values()) > 4: @@ -227,7 +231,7 @@ def stereogenic_tetrahedrons(self: 'MoleculeContainer') -> Dict[int, Union[Tuple for n in self.tetrahedrons: if any(not atoms[x].is_forming_single_bonds for x in bonds[n]): continue # skip metal-carbon complexes - env = tuple(x for x in bonds[n] if atoms[x].atomic_number != 1) + env = tuple(x for x in bonds[n] if atoms[x] != H) if len(env) in (3, 4): tetrahedrons[n] = env return tetrahedrons @@ -255,8 +259,8 @@ def stereogenic_cumulenes(self: 'MoleculeContainer') -> Dict[Tuple[int, ...], Tu if any(b == 3 or not atoms[m].is_forming_single_bonds and b != 8 for m, b in nl.items() if m != m1): continue # skip X=C=C structures and metal-carbon complexes - nn = [x for x, b in nf.items() if x != n1 and atoms[x].atomic_number != 1 and b != 8] - mn = [x for x, b in nl.items() if x != m1 and atoms[x].atomic_number != 1 and b != 8] + nn = [x for x, b in nf.items() if x != n1 and atoms[x] != H and b != 8] + mn = [x for x, b in nl.items() if x != m1 and atoms[x] != H and b != 8] if nn and mn: sn = nn[1] if len(nn) == 2 else None sm = mn[1] if len(mn) == 2 else None @@ -405,7 +409,7 @@ def add_wedge(self: 'MoleculeContainer', n: int, m: int, mark: int, *, clean_cac t1, t2 = self._stereo_allenes_terminals[c] order = self.stereogenic_allenes[c] - if atoms[m].atomic_number == 1: + if atoms[m] == H: if t1 == n: m1 = order[1] else: @@ -436,7 +440,7 @@ def add_wedge(self: 'MoleculeContainer', n: int, m: int, mark: int, *, clean_cac elif n in self.chiral_tetrahedrons: th = self.stereogenic_tetrahedrons[n] am = atoms[m] - if am.atomic_number == 1: + if am == H: order = [] for x in th: ax = atoms[x] @@ -704,7 +708,7 @@ def _translate_tetrahedron_sign(self: 'MoleculeContainer', n, env, s=None): if len(env) == 4: # hydrogen atom passed to env # hydrogen always last in order try: - order = (*order, next(x for x in env if self._atoms[x].atomic_number == 1)) # see translate scheme + order = (*order, next(x for x in env if self._atoms[x] == H)) # see translate scheme except StopIteration: raise KeyError elif len(env) != 3: # pyramid or tetrahedron expected @@ -744,7 +748,7 @@ def _translate_cis_trans_sign(self: 'MoleculeContainer', n, m, nn, nm, s=None): t0 = 0 if nm == n1: t1 = 1 - elif nm == n3 or n3 is None and self._atoms[nm].atomic_number == 1: + elif nm == n3 or n3 is None and self._atoms[nm] == H: t1 = 3 else: raise KeyError @@ -752,23 +756,23 @@ def _translate_cis_trans_sign(self: 'MoleculeContainer', n, m, nn, nm, s=None): t0 = 1 if nm == n0: t1 = 0 - elif nm == n2 or n2 is None and self._atoms[nm].atomic_number == 1: + elif nm == n2 or n2 is None and self._atoms[nm] == H: t1 = 2 else: raise KeyError - elif nn == n2 or n2 is None and self._atoms[nn].atomic_number == 1: + elif nn == n2 or n2 is None and self._atoms[nn] == H: t0 = 2 if nm == n1: t1 = 1 - elif nm == n3 or n3 is None and self._atoms[nm].atomic_number == 1: + elif nm == n3 or n3 is None and self._atoms[nm] == H: t1 = 3 else: raise KeyError - elif nn == n3 or n3 is None and self._atoms[nn].atomic_number == 1: + elif nn == n3 or n3 is None and self._atoms[nn] == H: t0 = 3 if nm == n0: t1 = 0 - elif nm == n2 or n2 is None and self._atoms[nm].atomic_number == 1: + elif nm == n2 or n2 is None and self._atoms[nm] == H: t1 = 2 else: raise KeyError @@ -798,7 +802,7 @@ def _translate_allene_sign(self: 'MoleculeContainer', c, nn, nm, s=None): t0 = 0 if nm == n1: t1 = 1 - elif nm == n3 or n3 is None and self._atoms[nm].atomic_number == 1: + elif nm == n3 or n3 is None and self._atoms[nm] == H: t1 = 3 else: raise KeyError @@ -806,23 +810,23 @@ def _translate_allene_sign(self: 'MoleculeContainer', c, nn, nm, s=None): t0 = 1 if nm == n0: t1 = 0 - elif nm == n2 or n2 is None and self._atoms[nm].atomic_number == 1: + elif nm == n2 or n2 is None and self._atoms[nm] == H: t1 = 2 else: raise KeyError - elif nn == n2 or n2 is None and self._atoms[nn].atomic_number == 1: + elif nn == n2 or n2 is None and self._atoms[nn] == H: t0 = 2 if nm == n1: t1 = 1 - elif nm == n3 or n3 is None and self._atoms[nm].atomic_number == 1: + elif nm == n3 or n3 is None and self._atoms[nm] == H: t1 = 3 else: raise KeyError - elif nn == n3 or n3 is None and self._atoms[nn].atomic_number == 1: + elif nn == n3 or n3 is None and self._atoms[nn] == H: t0 = 3 if nm == n0: t1 = 0 - elif nm == n2 or n2 is None and self._atoms[nm].atomic_number == 1: + elif nm == n2 or n2 is None and self._atoms[nm] == H: t1 = 2 else: raise KeyError diff --git a/chython/algorithms/tautomers/heteroarenes.py b/chython/algorithms/tautomers/heteroarenes.py index 4115d6a3..99a154f4 100644 --- a/chython/algorithms/tautomers/heteroarenes.py +++ b/chython/algorithms/tautomers/heteroarenes.py @@ -27,6 +27,13 @@ from chython import MoleculeContainer +# atomic number constants +B = 5 +C = 6 +N = 7 +P = 15 + + class HeteroArenes: __slots__ = () @@ -48,7 +55,7 @@ def _enumerate_hetero_arene_tautomers(self: 'MoleculeContainer'): for n, ms in rings.items(): a = atoms[n] if len(ms) == 2: - if a.atomic_number in (5, 7, 15): + if a in (B, N, P): if not a.charge and not a.is_radical: # only neutral B, N, P if a.implicit_hydrogens: # pyrrole @@ -57,9 +64,9 @@ def _enumerate_hetero_arene_tautomers(self: 'MoleculeContainer'): acceptors.add(n) else: single_bonded.add(n) - elif a.charge == -1 and a.atomic_number == 6: # ferrocene + elif a.charge == -1 and a == C: # ferrocene single_bonded.add(n) - elif len(ms) == 3 and a.atomic_number in (5, 7, 15) and not a.charge and not a.is_radical: + elif len(ms) == 3 and a in (B, N, P) and not a.charge and not a.is_radical: single_bonded.add(n) if not donors or not acceptors: return diff --git a/chython/algorithms/tautomers/keto_enol.py b/chython/algorithms/tautomers/keto_enol.py index ddcd14d7..ba80f63b 100644 --- a/chython/algorithms/tautomers/keto_enol.py +++ b/chython/algorithms/tautomers/keto_enol.py @@ -27,6 +27,10 @@ from chython import MoleculeContainer +# atomic number constants +C = 6 + + class KetoEnol: __slots__ = () @@ -121,7 +125,7 @@ def __enumerate_bonds(self: 'MoleculeContainer', partial): cp = path.copy() cp.append((current, n, 2)) # single to double in enol end yield cp, False - elif b == bond and (a := atoms[n]).atomic_number == 6: # classic keto-enol route + elif b == bond and (a := atoms[n]) == C: # classic keto-enol route if a.hybridization == 2: # grow up stack.append((current, n, next_bond, depth)) elif hydrogen: diff --git a/chython/containers/molecule.py b/chython/containers/molecule.py index 474490ee..e6263811 100644 --- a/chython/containers/molecule.py +++ b/chython/containers/molecule.py @@ -40,7 +40,12 @@ from ..algorithms.tautomers import Tautomers from ..algorithms.x3dom import X3domMolecule from ..exceptions import ValenceError -from ..periodictable import DynamicElement, Element, QueryElement, H +from ..periodictable import DynamicElement, Element, QueryElement, H as _H + + +# atomic number constants +H = 5 +C = 6 class MoleculeContainer(MoleculeStereo, Graph[Element, Bond], Morgan, Rings, MoleculeIsomorphism, @@ -134,7 +139,7 @@ def is_radical(self) -> bool: @cached_property def molecular_mass(self) -> float: - h = H().atomic_mass + h = _H().atomic_mass return sum(a.atomic_mass + a.implicit_hydrogens * h for a in self._atoms.values()) @cached_property @@ -291,7 +296,7 @@ def substructure(self, atoms: Iterable[int], *, as_query: bool = False, recalcul if as_query: sub = object.__new__(QueryContainer) - lost = {n for n, a in self._atoms.items() if a.atomic_number != 1} - set(atoms) # atoms not in substructure + lost = {n for n, a in self._atoms.items() if a != H} - set(atoms) # atoms not in substructure # atoms with fully present neighbors not_skin = {n for n in atoms if lost.isdisjoint(self._bonds[n])} @@ -741,10 +746,9 @@ def calc_labels(self): hybridization = 3 neighbors += 1 - an = atoms[m].atomic_number - if an == 1: + if (a := atoms[m]) == H: explicit_hydrogens += 1 - elif an != 6: + elif a != C: heteroatoms += 1 atom = atoms[n] atom._neighbors = neighbors @@ -759,8 +763,7 @@ def calc_implicit(self, n: int): """ Set firs possible hydrogens count based on rules """ - atom = self._atoms[n] - if atom.atomic_number == 1: # hydrogen nether has implicit H + if (atom := self._atoms[n]) == H: # hydrogen nether has implicit H atom._implicit_hydrogens = 0 return @@ -769,7 +772,7 @@ def calc_implicit(self, n: int): aroma = 0 for m, bond in self._bonds[n].items(): if bond == 4: # only neutral carbon aromatic rings supported - if not atom.charge and not atom.is_radical and atom.atomic_number == 6: + if not atom.charge and not atom.is_radical and atom == C: aroma += 1 else: # use `kekule()` to calculate proper implicit hydrogens count atom._implicit_hydrogens = None @@ -808,8 +811,7 @@ def calc_implicit(self, n: int): atom._implicit_hydrogens = None # rule not found def check_implicit(self, n: int, h: int) -> bool: - atom = self._atoms[n] - if atom.atomic_number == 1: # hydrogen nether has implicit H + if (atom := self._atoms[n]) == H: # hydrogen nether has implicit H return h == 0 explicit_sum = 0 diff --git a/chython/files/_convert.py b/chython/files/_convert.py index 422a46a9..c23ee616 100644 --- a/chython/files/_convert.py +++ b/chython/files/_convert.py @@ -22,6 +22,13 @@ from ..periodictable import Element +# atomic number constants +B = 5 +C = 6 +N = 7 +P = 15 + + def create_molecule(data, *, ignore_bad_isotopes=False, skip_calc_implicit=False, keep_implicit=False, keep_radicals=True, ignore_aromatic_radicals=True, ignore=True, ignore_carbon_radicals=False, _cls=MoleculeContainer): @@ -86,7 +93,7 @@ def create_molecule(data, *, ignore_bad_isotopes=False, skip_calc_implicit=False a._implicit_hydrogens = h # rare H0 case if (not keep_radicals and not ignore_aromatic_radicals - and not h and not a.charge and not a.is_radical and a.atomic_number in (5, 6, 7, 15) + and not h and not a.charge and not a.is_radical and a in (B, C, N, P) and sum(b != 8 for b in bonds[n].values()) == 2): # c[c]c - aromatic B,C,N,P radical a._is_radical = True @@ -106,7 +113,7 @@ def create_molecule(data, *, ignore_bad_isotopes=False, skip_calc_implicit=False elif h != a.implicit_hydrogens: # H count mismatch. if a.hybridization == 4: if (not keep_radicals - and not h and not a.charge and not a.is_radical and a.atomic_number in (5, 6, 7, 15) + and not h and not a.charge and not a.is_radical and a in (B, C, N, P) and sum(b != 8 for b in bonds[n].values()) == 2): # c[c]c - aromatic B,C,N,P radical a._implicit_hydrogens = 0 @@ -139,8 +146,7 @@ def create_molecule(data, *, ignore_bad_isotopes=False, skip_calc_implicit=False if ignore_carbon_radicals: for n in radicalized: - a = atoms[n] - if a.atomic_number == 6: + if (a := atoms[n]) == C: a._is_radical = False a._implicit_hydrogens += 1 data['log'].append(f'carbon radical {n} replaced with implicit hydrogen') diff --git a/chython/periodictable/base/element.py b/chython/periodictable/base/element.py index 7818af9a..1185d661 100644 --- a/chython/periodictable/base/element.py +++ b/chython/periodictable/base/element.py @@ -334,6 +334,10 @@ def __eq__(self, other): """ compare attached to molecules elements """ + if isinstance(other, int): + return self.atomic_number == other + elif isinstance(other, str): + return self.atomic_symbol == other return isinstance(other, Element) and self.atomic_number == other.atomic_number and \ self.isotope == other.isotope and self.charge == other.charge and self.is_radical == other.is_radical From 80d21299d0582be126b34e923336ed42a3031fac Mon Sep 17 00:00:00 2001 From: stsouko Date: Thu, 14 Nov 2024 09:09:31 +0100 Subject: [PATCH 32/67] saved --- chython/algorithms/aromatics/_rules.py | 18 ++++++- chython/algorithms/aromatics/thiele.py | 17 +------ chython/algorithms/isomorphism.py | 67 ++++++++++++++++++++++++-- chython/files/_mdl/stereo.py | 2 +- chython/files/libinchi/wrapper.py | 8 +-- 5 files changed, 84 insertions(+), 28 deletions(-) diff --git a/chython/algorithms/aromatics/_rules.py b/chython/algorithms/aromatics/_rules.py index 02b061aa..49b69cd6 100644 --- a/chython/algorithms/aromatics/_rules.py +++ b/chython/algorithms/aromatics/_rules.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2021-2023 Ramil Nugmanov +# Copyright 2021-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -104,7 +104,21 @@ def _rules(): return rules +def _freaks(): + from ... import smarts + + rules = [] + + q = smarts('[N,O,S;D2;r5;z1]1[A;r5]=,:[A;r5][A;r5]:[A;r5]1') + rules.append(q) + + q = smarts('[N;D3;r5;z1]1[A;r5]=,:[A;r5][A;r5]:[A;r5]1') + rules.append(q) + return rules + + rules = Proxy(_rules) +freak_rules = Proxy(_freaks) -__all__ = ['rules'] +__all__ = ['rules', 'freak_rules'] diff --git a/chython/algorithms/aromatics/thiele.py b/chython/algorithms/aromatics/thiele.py index 37ef5704..c6682247 100644 --- a/chython/algorithms/aromatics/thiele.py +++ b/chython/algorithms/aromatics/thiele.py @@ -17,8 +17,8 @@ # along with this program; if not, see . # from collections import defaultdict -from lazy_object_proxy import Proxy from typing import TYPE_CHECKING +from ._rules import freak_rules from ..rings import _sssr, _connected_components @@ -26,21 +26,6 @@ from chython import MoleculeContainer -def _freaks(): - from ... import smarts - - rules = [] - - q = smarts('[N,O,S;D2;r5;z1]1[A;r5]=,:[A;r5][A;r5]:[A;r5]1') - rules.append(q) - - q = smarts('[N;D3;r5;z1]1[A;r5]=,:[A;r5][A;r5]:[A;r5]1') - rules.append(q) - return rules - - -freak_rules = Proxy(_freaks) - # atomic number constants B = 5 C = 6 diff --git a/chython/algorithms/isomorphism.py b/chython/algorithms/isomorphism.py index 30243690..2a64bdf8 100644 --- a/chython/algorithms/isomorphism.py +++ b/chython/algorithms/isomorphism.py @@ -156,9 +156,9 @@ def get_mapping(self, other: 'MoleculeContainer', /, *, automorphism_filter: boo :param automorphism_filter: Skip matches to the same atoms. :param searching_scope: substructure atoms list to localize isomorphism. """ - if isinstance(other, MoleculeIsomorphism): - return self._get_mapping(other, automorphism_filter=automorphism_filter, searching_scope=searching_scope) - raise TypeError('MoleculeContainer expected') + if not isinstance(other, MoleculeIsomorphism): + raise TypeError('MoleculeContainer expected') + return self._get_mapping(other, automorphism_filter=automorphism_filter, searching_scope=searching_scope) @cached_property def _cython_compiled_structure(self: 'MoleculeContainer'): @@ -299,6 +299,67 @@ def get_mapping(query, scope): return self._get_mapping(other, automorphism_filter=automorphism_filter, searching_scope=searching_scope, components=components, get_mapping=get_mapping) + atoms_stereo = self._atoms_stereo + allenes_stereo = self._allenes_stereo + cis_trans_stereo = self._cis_trans_stereo + + other_atoms_stereo = other._atoms_stereo + other_allenes_stereo = other._allenes_stereo + other_cis_trans_stereo = other._cis_trans_stereo + other_translate_tetrahedron_sign = other._translate_tetrahedron_sign + other_translate_allene_sign = other._translate_allene_sign + other_translate_cis_trans_sign = other._translate_cis_trans_sign + + tetrahedrons = self.stereogenic_tetrahedrons + cis_trans = self.stereogenic_cis_trans + allenes = self.stereogenic_allenes + + oatoms = other._atoms + + for mapping in self._get_mapping(other, automorphism_filter=automorphism_filter, + searching_scope=searching_scope): + for n, a in self._atoms.items(): + if a.stereo is None: + continue + m = mapping[n] + oa = oatoms[m] + if oa.stereo is None: # stereo in query should match only stereo atom + break + other._translate_tetrahedron_sign(m, [mapping[x] for x in tetrahedrons[n]]) + for n, s in atoms_stereo.items(): + m = mapping[n] + if m not in other_atoms_stereo: # self stereo atom not stereo in other + break + # translate stereo mark in other in order of self tetrahedron + if other_translate_tetrahedron_sign(m, [mapping[x] for x in tetrahedrons[n]]) != s: + break + else: + for n, s in allenes_stereo.items(): + m = mapping[n] + if m not in other_allenes_stereo: # self stereo allene not stereo in other + break + # translate stereo mark in other in order of self allene + nn, nm, *_ = allenes[n] + if other_translate_allene_sign(m, mapping[nn], mapping[nm]) != s: + break + else: + for nm, s in cis_trans_stereo.items(): + n, m = nm + on, om = mapping[n], mapping[m] + if (on, om) not in other_cis_trans_stereo: + if (om, on) not in other_cis_trans_stereo: + break # self stereo cis_trans not stereo in other + else: + nn, nm, *_ = cis_trans[nm] + if other_translate_cis_trans_sign(om, on, mapping[nm], mapping[nn]) != s: + break + else: + nn, nm, *_ = cis_trans[nm] + if other_translate_cis_trans_sign(on, om, mapping[nn], mapping[nm]) != s: + break + else: + yield mapping + @cached_property def _cython_compiled_query(self): # long I: diff --git a/chython/files/_mdl/stereo.py b/chython/files/_mdl/stereo.py index ce9a651c..761b6503 100644 --- a/chython/files/_mdl/stereo.py +++ b/chython/files/_mdl/stereo.py @@ -30,7 +30,7 @@ def postprocess_molecule(molecule, data, *, ignore_stereo=False, calc_cis_trans= log = [] if calc_cis_trans: - molecule.calculate_cis_trans_from_2d() + molecule.calculate_cis_trans_from_2d(clean_cache=False) stereo = [(mapping[n], mapping[m], s) for n, m, s in data['stereo']] while stereo: diff --git a/chython/files/libinchi/wrapper.py b/chython/files/libinchi/wrapper.py index aaefb948..3941428e 100644 --- a/chython/files/libinchi/wrapper.py +++ b/chython/files/libinchi/wrapper.py @@ -53,7 +53,7 @@ def inchi(data, /, *, ignore_stereo: bool = False, _cls=MoleculeContainer) -> Mo atoms.append({'element': atom.atomic_symbol, 'charge': atom.charge, 'mapping': 0, 'x': atom.x, 'y': atom.y, 'z': atom.z, 'isotope': atom.isotope, 'is_radical': atom.is_radical, - 'hydrogens': atom.implicit_hydrogens, 'delta_isotope': atom.delta_isotope, + 'implicit_hydrogens': atom.implicit_hydrogens, 'delta_isotope': atom.delta_isotope, 'p': atom.implicit_protium, 'd': atom.implicit_deuterium, 't': atom.implicit_tritium}) for k in range(atom.num_bonds): @@ -92,16 +92,12 @@ def inchi(data, /, *, ignore_stereo: bool = False, _cls=MoleculeContainer) -> Mo def postprocess_molecule(molecule, data, *, ignore_stereo=False): atoms = molecule._atoms bonds = molecule._bonds - charges = molecule._charges - radicals = molecule._radicals - hydrogens = molecule._hydrogens - plane = molecule._plane # set hydrogen atoms. INCHI designed for hydrogens handling. hope correctly. free = count(len(atoms) + 1) for n, atom in enumerate(data['atoms'], 1): if atom['element'] != 'H': - hydrogens[n] = atom['hydrogens'] + atoms[n]._implicit_hydrogens = atom['hydrogens'] # in chython hydrogens never have implicit H. elif atom['hydrogens']: # >[xH]-H case m = next(free) From bf8132739934a2586cfc4eed74a458de0b0a2881 Mon Sep 17 00:00:00 2001 From: Ramil Nugmanov Date: Wed, 20 Nov 2024 18:04:40 +0100 Subject: [PATCH 33/67] parsers refactored --- chython/files/MRVrw.py | 45 ++++++++----------- chython/files/PDBrw.py | 9 +++- chython/files/_convert.py | 40 +++++++++++------ chython/files/_mapping.py | 6 +++ chython/files/_mdl/emol.py | 10 ++--- chython/files/_mdl/erxn.py | 4 +- chython/files/_mdl/mol.py | 10 ++--- chython/files/_mdl/rxn.py | 4 +- chython/files/_mdl/stereo.py | 13 +++--- chython/files/_mdl/write.py | 36 ++++++--------- chython/files/daylight/parser.py | 3 +- chython/files/daylight/smiles.py | 9 ++-- chython/files/libinchi/wrapper.py | 73 ++++++++++++++++--------------- 13 files changed, 136 insertions(+), 126 deletions(-) diff --git a/chython/files/MRVrw.py b/chython/files/MRVrw.py index 0a589410..3c808746 100644 --- a/chython/files/MRVrw.py +++ b/chython/files/MRVrw.py @@ -140,12 +140,12 @@ def read_structure(self, *, current: bool = True): mol = create_molecule(tmp, ignore_bad_isotopes=self.__ignore_bad_isotopes, _cls=self.molecule_cls) if not self.__ignore_stereo: postprocess_molecule(mol, tmp, calc_cis_trans=self.__calc_cis_trans) - mol.meta.update(meta) + if meta: + mol.meta.update(meta) return mol elif 'reaction' in data and isinstance(data['reaction'], dict): data = data['reaction'] - tmp = {'reactants': [], 'products': [], 'reagents': [], - 'meta': None, 'log': log, 'title': data.get('@title')} + tmp = {'reactants': [], 'products': [], 'reagents': [], 'log': log, 'title': data.get('@title')} n = 0 for tag, group in (('reactantList', 'reactants'), ('productList', 'products'), ('agentList', 'reagents')): @@ -174,7 +174,8 @@ def read_structure(self, *, current: bool = True): if not self.__ignore_stereo: for mol, tmp in zip(rxn.molecules(), chain(tmp['reactants'], tmp['reagents'], tmp['products'])): postprocess_molecule(mol, tmp, calc_cis_trans=self.__calc_cis_trans) - rxn.meta.update(meta) + if meta: + rxn.meta.update(meta) return rxn else: raise ValueError('reaction or molecule expected') @@ -263,7 +264,6 @@ def _read_block(self, *, current: bool = True) -> dict: def parse_molecule(data): atoms, bonds, stereo = [], [], [] log = [] - hydrogens = {} atom_map = {} if 'atom' in data['atomArray']: da = data['atomArray']['atom'] @@ -275,20 +275,20 @@ def parse_molecule(data): 'isotope': int(atom['@isotope']) if '@isotope' in atom else None, 'charge': int(atom.get('@formalCharge', 0)), 'is_radical': '@radical' in atom, - 'mapping': int(atom.get('@mrvMap', 0))}) + 'parsed_mapping': int(atom.get('@mrvMap', 0))}) if '@z3' in atom: atoms[-1].update(x=float(atom['@x3']), y=float(atom['@y3']), z=float(atom['@z3'])) else: - atoms[-1].update(x=float(atom['@x2']) / 2, y=float(atom['@y2']) / 2, z=0.) + atoms[-1].update(x=float(atom['@x2']) / 2, y=float(atom['@y2']) / 2) if '@mrvQueryProps' in atom: raise ValueError('queries unsupported') if '@hydrogenCount' in atom: - hydrogens[n] = int(atom['@hydrogenCount']) + atoms[-1]['implicit_hydrogens'] = int(atom['@hydrogenCount']) else: atom = data['atomArray'] for n, (_id, e) in enumerate(zip(atom['@atomID'].split(), atom['@elementType'].split())): atom_map[_id] = n - atoms.append({'element': e, 'charge': 0, 'mapping': 0, 'isotope': None, 'is_radical': False}) + atoms.append({'element': e}) if '@z3' in atom: for a, x, y, z in zip(atoms, atom['@x3'].split(), atom['@y3'].split(), atom['@z3'].split()): a['x'] = float(x) @@ -298,7 +298,6 @@ def parse_molecule(data): for a, x, y in zip(atoms, atom['@x2'].split(), atom['@y2'].split()): a['x'] = float(x) / 2 a['y'] = float(y) / 2 - a['z'] = 0. if '@isotope' in atom: for a, x in zip(atoms, atom['@isotope'].split()): if x != '0': @@ -310,7 +309,7 @@ def parse_molecule(data): if '@mrvMap' in atom: for a, x in zip(atoms, atom['@mrvMap'].split()): if x != '0': - a['mapping'] = int(x) + a['parsed_mapping'] = int(x) if '@radical' in atom: for a, x in zip(atoms, atom['@radical'].split()): if x != '0': @@ -340,8 +339,8 @@ def parse_molecule(data): log.append('incorrect bondStereo tag') bonds.append((atom_map[a1], atom_map[a2], order)) - return {'atoms': atoms, 'bonds': bonds, 'stereo': stereo, 'hydrogens': hydrogens, - 'meta': None, 'title': data.get('@title'), 'log': log, 'atom_map': atom_map} + return {'atoms': atoms, 'bonds': bonds, 'stereo': stereo, + 'title': data.get('@title'), 'log': log, 'atom_map': atom_map} def parse_sgroup(data, molecule): @@ -486,30 +485,24 @@ def __write(self, data): file.write('\n') def __write_molecule(self, g): - gp = g._plane - gc = g._charges - gr = g._radicals bg = g._bonds - hg = g._hydrogens - hb = g.hybridization mapping = self.__mapping file = self.__file file.write('') - for n, atom in g._atoms.items(): - x, y = gp[n] - ih = hg[n] + for n, atom in g.atoms(): + x, y = atom.x, atom.y file.write(f'') file.write('') diff --git a/chython/files/PDBrw.py b/chython/files/PDBrw.py index a761e3cb..01ad869f 100644 --- a/chython/files/PDBrw.py +++ b/chython/files/PDBrw.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2020-2023 Ramil Nugmanov +# Copyright 2020-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -178,6 +178,8 @@ def read_structure(self, *, current: bool = True) -> MoleculeContainer: atom_charge=charges, _cls=self.molecule_cls) mol.meta['RESIDUE'] = dict(enumerate(res, 1)) + if log: + mol.meta['chython_parsing_log'] = log if self.__parse_as_single: self.__parsed_first = mol.copy() return mol @@ -191,6 +193,11 @@ def read_structure(self, *, current: bool = True) -> MoleculeContainer: c[n] = (x, y, z) mol = self.__parsed_first.copy() mol._conformers[0] = c + if log: + if 'chython_parsing_log' in mol.meta: + mol.meta['chython_parsing_log'] = mol.meta['chython_parsing_log'] + log + else: + mol.meta['chython_parsing_log'] = log return mol def close(self, force: bool = False): diff --git a/chython/files/_convert.py b/chython/files/_convert.py index c23ee616..e25a93cd 100644 --- a/chython/files/_convert.py +++ b/chython/files/_convert.py @@ -33,12 +33,18 @@ def create_molecule(data, *, ignore_bad_isotopes=False, skip_calc_implicit=False keep_implicit=False, keep_radicals=True, ignore_aromatic_radicals=True, ignore=True, ignore_carbon_radicals=False, _cls=MoleculeContainer): g = _cls() + g._name = data.get('title') atoms = g._atoms bonds = g._bonds mapping = data['mapping'] - for n, atom in enumerate(data['atoms']): - n = mapping[n] + + if any(a.get('z') for a in data['atoms']): + # store conformer + g._conformers = [{n: (a['x'], a['y'], a['z']) for n, a in zip(mapping, data['atoms'])}] + + for n, atom in zip(mapping, data['atoms']): e = Element.from_symbol(atom.pop('element')) + atom.pop('z', None) # clean up MDL try: atoms[n] = e(**atom) except (ValueError, TypeError): @@ -60,15 +66,11 @@ def create_molecule(data, *, ignore_bad_isotopes=False, skip_calc_implicit=False g.calc_labels() # set all labels except rings - if any(a.get('z') for a in data['atoms']): - # store conformer - g._conformers = [{mapping[n]: (a['x'], a['y'], a['z']) for n, a in enumerate(data['atoms'])}] - - if data['log']: # store log to the meta - if data['meta'] is None: + if data.get('log'): # store log to the meta + if data.get('meta') is None: data['meta'] = {} data['meta']['chython_parsing_log'] = data['log'] - g._meta = data['meta'] + g._meta = data.get('meta') or None if skip_calc_implicit: # don't calc Hs. e.g. INCHI return g @@ -107,6 +109,8 @@ def create_molecule(data, *, ignore_bad_isotopes=False, skip_calc_implicit=False elif ignore: # radical state also has errors. a._is_radical = False # reset radical state implicit_mismatch[n] = h + if data.get('log') is None: + data['log'] = [] data['log'].append(f'implicit hydrogen count ({h}) mismatch with calculated on atom {n}') else: raise ValueError(f'implicit hydrogen count ({h}) mismatch with calculated on atom {n}') @@ -121,6 +125,8 @@ def create_molecule(data, *, ignore_bad_isotopes=False, skip_calc_implicit=False radicalized.append(n) elif ignore: implicit_mismatch[n] = h + if data.get('log') is None: + data['log'] = [] data['log'].append(f'implicit hydrogen count ({h}) mismatch with calculated on atom {n}') else: raise ValueError(f'implicit hydrogen count ({h}) mismatch with calculated on atom {n}') @@ -135,11 +141,15 @@ def create_molecule(data, *, ignore_bad_isotopes=False, skip_calc_implicit=False elif ignore: a._is_radical = False # reset radical state implicit_mismatch[n] = h + if data.get('log') is None: + data['log'] = [] data['log'].append(f'implicit hydrogen count ({h}) mismatch with calculated on atom {n}') else: raise ValueError(f'implicit hydrogen count ({h}) mismatch with calculated on atom {n}') elif ignore: # just ignore it implicit_mismatch[n] = h + if data.get('log') is None: + data['log'] = [] data['log'].append(f'implicit hydrogen count ({h}) mismatch with calculated on atom {n}') else: raise ValueError(f'implicit hydrogen count ({h}) mismatch with calculated on atom {n}') @@ -149,10 +159,12 @@ def create_molecule(data, *, ignore_bad_isotopes=False, skip_calc_implicit=False if (a := atoms[n]) == C: a._is_radical = False a._implicit_hydrogens += 1 + if data.get('log') is None: + data['log'] = [] data['log'].append(f'carbon radical {n} replaced with implicit hydrogen') elif radicalized: g.meta['chython_radicalized_atoms'] = radicalized - if data['log'] and 'chython_parsing_log' not in g.meta: + if data.get('log') and 'chython_parsing_log' not in g.meta: g.meta['chython_parsing_log'] = data['log'] if implicit_mismatch: g.meta['chython_implicit_mismatch'] = implicit_mismatch @@ -177,17 +189,19 @@ def create_reaction(data, *, ignore=True, skip_calc_implicit=False, ignore_bad_i except ValueError as e: if not ignore: raise + if data.get('log') is None: + data['log'] = [] data['log'].append(f'ignored {gr} molecule {n} with {e}') tdl.append(n) if tdl: # ad-hoc for later postprocessing for n in reversed(tdl): del pms[n] - if data['log']: # store log to the meta - if data['meta'] is None: + if data.get('log'): # store log to the meta + if data.get('meta') is None: data['meta'] = {} data['meta']['chython_parsing_log'] = data['log'] - return _r_cls(rc, pr, rg, meta=data['meta'], name=data['title']) + return _r_cls(rc, pr, rg, meta=data.get('meta') or None, name=data.get('title')) __all__ = ['create_molecule'] diff --git a/chython/files/_mapping.py b/chython/files/_mapping.py index 331eaa3e..c142676e 100644 --- a/chython/files/_mapping.py +++ b/chython/files/_mapping.py @@ -34,6 +34,8 @@ def postprocess_parsed_molecule(data, *, remap=False, ignore=True): if not ignore: raise MappingError('mapping in molecules should be unique') remapped.append(next(length)) + if data.get('log') is None: + data['log'] = [] data['log'].append(f'mapping in molecule changed from {m} to {remapped[n]}') else: remapped.append(m) @@ -72,6 +74,8 @@ def postprocess_parsed_reaction(data, *, remap=False, ignore=True): raise MappingError('mapping in reagents or products or reactants should be unique') # force remap non unique atoms in molecules. _remap.append(next(length)) + if data.get('log') is None: + data['log'] = [] data['log'].append(f'mapping in {i} changed from {m} to {_remap[-1]}') else: _remap.append(m) @@ -83,6 +87,8 @@ def postprocess_parsed_reaction(data, *, remap=False, ignore=True): e = f'reagents has map intersection with reactants or products: {tmp}' if not ignore: raise MappingError(e) + if data.get('log') is None: + data['log'] = [] data['log'].append(e) maps['reagents'] = [x if x not in tmp else next(length) for x in maps['reagents']] diff --git a/chython/files/_mdl/emol.py b/chython/files/_mdl/emol.py index 9e6b4437..03b15a6a 100644 --- a/chython/files/_mdl/emol.py +++ b/chython/files/_mdl/emol.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2020-2023 Ramil Nugmanov +# Copyright 2020-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -36,7 +36,6 @@ def parse_mol_v3000(data, *, _header=True): atoms = [] bonds = [] stereo = [] - hydrogens = {} meta = {} atom_map = {} star_points = [] @@ -95,7 +94,7 @@ def parse_mol_v3000(data, *, _header=True): atom_map[n] = len(atoms) atoms.append({'element': a, 'isotope': i, 'charge': c, 'is_radical': r, - 'x': float(x), 'y': float(y), 'z': float(z), 'mapping': int(m)}) + 'x': float(x), 'y': float(y), 'z': float(z), 'parsed_mapping': int(m)}) for line in data[2 + atom_count: 2 + atom_count + bonds_count]: _, t, a1, a2, *kvs = split(line) @@ -172,14 +171,13 @@ def parse_mol_v3000(data, *, _header=True): d = v.strip('"') if a and f and d: if f == 'MRV_IMPLICIT_H': - hydrogens[a[0]] = int(d[6:]) + atoms[a[0]]['implicit_hydrogens'] = int(d[6:]) else: log.append(f'ignored SGROUP DAT {i}: {a}\t{f}\t{d}') elif _type.startswith('SRU'): raise ValueError('Polymers not supported') - return {'title': title, 'atoms': atoms, 'bonds': bonds, 'stereo': stereo, 'hydrogens': hydrogens, - 'meta': meta or None, 'log': log} + return {'title': title, 'atoms': atoms, 'bonds': bonds, 'stereo': stereo, 'meta': meta, 'log': log} def split(line): # todo optimize diff --git a/chython/files/_mdl/erxn.py b/chython/files/_mdl/erxn.py index 25354f9b..6b707b3e 100644 --- a/chython/files/_mdl/erxn.py +++ b/chython/files/_mdl/erxn.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2020-2023 Ramil Nugmanov +# Copyright 2020-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -61,7 +61,7 @@ def parse_rxn_v3000(data, *, ignore=True): reagents_count -= 1 return {'reactants': molecules[:reactants_count], 'products': molecules[reactants_count:products_count], - 'reagents': molecules[products_count:], 'title': title, 'meta': None, 'log': log} + 'reagents': molecules[products_count:], 'title': title, 'log': log} __all__ = ['parse_rxn_v3000'] diff --git a/chython/files/_mdl/mol.py b/chython/files/_mdl/mol.py index 3e15cbf9..db819f2b 100644 --- a/chython/files/_mdl/mol.py +++ b/chython/files/_mdl/mol.py @@ -36,7 +36,6 @@ def parse_mol_v2000(data): atoms = [] bonds = [] stereo = [] - hydrogens = {} dat = {} for line in data[4: 4 + atoms_count]: @@ -62,8 +61,8 @@ def parse_mol_v2000(data): isotope = None mapping = line[60:63] - atoms.append({'element': element, 'charge': charge, 'isotope': isotope, 'is_radical': False, - 'mapping': int(mapping) if mapping else 0, 'x': float(line[0:10]), 'y': float(line[10:20]), + atoms.append({'element': element, 'charge': charge, 'isotope': isotope, + 'parsed_mapping': int(mapping) if mapping else 0, 'x': float(line[0:10]), 'y': float(line[10:20]), 'z': float(line[20:30]), 'delta_isotope': delta_isotope}) for line in data[4 + atoms_count: 4 + atoms_count + bonds_count]: @@ -133,14 +132,13 @@ def parse_mol_v2000(data): value = x['value'] if len(_atoms) != 1 or _atoms[0] == -1 or not value: raise InvalidV2000(f'MRV_IMPLICIT_H spec invalid {x}') - hydrogens[_atoms[0]] = int(value[6:]) + atoms[_atoms[0]]['implicit_hydrogens'] = int(value[6:]) else: log.append(f'ignored data: {x}') except KeyError: raise InvalidV2000(f'Invalid SGROUP {x}') - return {'title': title, 'atoms': atoms, 'bonds': bonds, 'stereo': stereo, 'hydrogens': hydrogens, - 'meta': None, 'log': log} + return {'title': title, 'atoms': atoms, 'bonds': bonds, 'stereo': stereo, 'log': log} __all__ = ['parse_mol_v2000'] diff --git a/chython/files/_mdl/rxn.py b/chython/files/_mdl/rxn.py index d81ee459..50df40e4 100644 --- a/chython/files/_mdl/rxn.py +++ b/chython/files/_mdl/rxn.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2020-2023 Ramil Nugmanov +# Copyright 2020-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -61,7 +61,7 @@ def parse_rxn_v2000(data, *, ignore=True): reagents_count -= 1 return {'reactants': molecules[:reactants_count], 'products': molecules[reactants_count:products_count], - 'reagents': molecules[products_count:], 'title': title, 'meta': None, 'log': log} + 'reagents': molecules[products_count:], 'title': title, 'log': log} __all__ = ['parse_rxn_v2000'] diff --git a/chython/files/_mdl/stereo.py b/chython/files/_mdl/stereo.py index 761b6503..212cb77d 100644 --- a/chython/files/_mdl/stereo.py +++ b/chython/files/_mdl/stereo.py @@ -23,11 +23,7 @@ def postprocess_molecule(molecule, data, *, ignore_stereo=False, calc_cis_trans= if ignore_stereo: return mapping = data['mapping'] - - if 'chython_parsing_log' in molecule.meta: - log = molecule.meta['chython_parsing_log'] - else: - log = [] + log = [] if calc_cis_trans: molecule.calculate_cis_trans_from_2d(clean_cache=False) @@ -57,8 +53,11 @@ def postprocess_molecule(molecule, data, *, ignore_stereo=False, calc_cis_trans= continue break - if log and 'chython_parsing_log' not in molecule.meta: - molecule.meta['chython_parsing_log'] = log + if log: + if 'chython_parsing_log' not in molecule.meta: + molecule.meta['chython_parsing_log'] = log + else: + molecule.meta['chython_parsing_log'].extend(log) __all__ = ['postprocess_molecule'] diff --git a/chython/files/_mdl/write.py b/chython/files/_mdl/write.py index c6bfc1bd..3319c60d 100644 --- a/chython/files/_mdl/write.py +++ b/chython/files/_mdl/write.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2021-2023 Ramil Nugmanov +# Copyright 2021-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -77,10 +77,7 @@ def _write_molecule(self, g, write3d=None): else: z = 0 - gc = g._charges - gr = g._radicals - gp = g._plane - gb = g._bonds + bonds = g._bonds file = self._file file.write(f'M V30 BEGIN CTAB\nM V30 COUNTS {g.atoms_count} {g.bonds_count} 0 0 0\nM V30 BEGIN ATOM\n') @@ -90,11 +87,10 @@ def _write_molecule(self, g, write3d=None): x, y, z = xyz[m] z = f'{z:.4f}' else: - x, y = gp[m] + x, y = a.x, a.y - c = gc[m] - c = f' CHG={c}' if c else '' - r = ' RAD=2' if gr[m] else '' + c = f' CHG={a.charge}' if a.charge else '' + r = ' RAD=2' if a.is_radical else '' i = f' MASS={a.isotope}' if a.isotope else '' if not self._mapping: @@ -107,7 +103,7 @@ def _write_molecule(self, g, write3d=None): wedge = defaultdict(set) i = 0 # trick for empty wedge_map for i, (n, m, s) in enumerate(g._wedge_map, start=1): - file.write(f'M V30 {i} {gb[n][m].order} {mapping[n]} {mapping[m]} CFG={s == 1 and "1" or "3"}\n') + file.write(f'M V30 {i} {bonds[n][m].order} {mapping[n]} {mapping[m]} CFG={s == 1 and "1" or "3"}\n') wedge[n].add(m) wedge[m].add(n) @@ -130,10 +126,7 @@ def _write_molecule(self, g, write3d=None): else: z = 0. - gc = g._charges - gr = g._radicals - gp = g._plane - gb = g._bonds + bonds = g._bonds file = self._file file.write(f'{g.name}\n\n\n{g.atoms_count:3d}{g.bonds_count:3d} 0 0 0 0 999 V2000\n') @@ -142,9 +135,9 @@ def _write_molecule(self, g, write3d=None): if write3d is not None: x, y, z = xyz[m] else: - x, y = gp[m] + x, y = a.x, a.y - c = charge_map[gc[m]] + c = charge_map[a.charge] if not self._mapping: m = 0 file.write(f'{x:10.4f}{y:10.4f}{z:10.4f} {a.atomic_symbol:3s} 0{c} 0 0 0 0 0 0 0{m:3d} 0 0\n') @@ -152,21 +145,20 @@ def _write_molecule(self, g, write3d=None): atoms = {m: n for n, m in enumerate(g._atoms, start=1)} wedge = defaultdict(set) for n, m, s in g._wedge_map: - file.write(f'{atoms[n]:3d}{atoms[m]:3d} {gb[n][m].order} {s == 1 and "1" or "6"} 0 0 0\n') + file.write(f'{atoms[n]:3d}{atoms[m]:3d} {bonds[n][m].order} {s == 1 and "1" or "6"} 0 0 0\n') wedge[n].add(m) wedge[m].add(n) for n, m, b in g.bonds(): if m not in wedge[n]: file.write(f'{atoms[n]:3d}{atoms[m]:3d} {b.order} 0 0 0 0\n') - for n, (m, a) in enumerate(g._atoms.items(), start=1): + for n, a in enumerate(g._atoms.values(), start=1): if a.isotope: file.write(f'M ISO 1 {n:3d} {a.isotope:3d}\n') - if gr[m]: + if a.is_radical: file.write(f'M RAD 1 {n:3d} 2\n') # invalid for carbenes - c = gc[m] - if c in (-4, 4): - file.write(f'M CHG 1 {n:3d} {c:3d}\n') + if a.charge in (-4, 4): + file.write(f'M CHG 1 {n:3d} {a.charge:3d}\n') file.write('M END\n') diff --git a/chython/files/daylight/parser.py b/chython/files/daylight/parser.py index 3cab6272..f685a359 100644 --- a/chython/files/daylight/parser.py +++ b/chython/files/daylight/parser.py @@ -147,8 +147,7 @@ def parser(tokens, strong_cycle): elif previous: raise IncorrectSmiles('bond on the end') - return {'atoms': atoms, 'bonds': bonds, 'order': order, 'stereo_bonds': stereo_bonds, 'log': log, - 'title': None, 'meta': None} + return {'atoms': atoms, 'bonds': bonds, 'order': order, 'stereo_bonds': stereo_bonds, 'log': log} __all__ = ['parser'] diff --git a/chython/files/daylight/smiles.py b/chython/files/daylight/smiles.py index 410df35a..442195f8 100644 --- a/chython/files/daylight/smiles.py +++ b/chython/files/daylight/smiles.py @@ -78,7 +78,7 @@ def smiles(data, /, *, ignore: bool = True, remap: bool = False, ignore_stereo: contract = None if '>' in smi: - record = {'reactants': [], 'reagents': [], 'products': [], 'log': log, 'meta': None, 'title': None} + record = {'reactants': [], 'reagents': [], 'products': [], 'log': log} try: reactants, reagents, products = smi.split('>') except ValueError as e: @@ -237,8 +237,11 @@ def postprocess_molecule(molecule, data, *, ignore_stereo=False): continue break - if log and 'chython_parsing_log' not in molecule.meta: - molecule.meta['chython_parsing_log'] = log + if log: + if 'chython_parsing_log' not in molecule.meta: + molecule.meta['chython_parsing_log'] = log + else: + molecule.meta['chython_parsing_log'].extend(log) __all__ = ['smiles'] diff --git a/chython/files/libinchi/wrapper.py b/chython/files/libinchi/wrapper.py index 3941428e..215a2ba7 100644 --- a/chython/files/libinchi/wrapper.py +++ b/chython/files/libinchi/wrapper.py @@ -24,7 +24,7 @@ from ...containers import MoleculeContainer from ...containers.bonds import Bond from ...exceptions import ValenceError, IsChiral, NotChiral -from ...periodictable import H +from ...periodictable import H as _H try: @@ -33,6 +33,9 @@ from importlib_resources import files, as_file +H = 1 + + def inchi(data, /, *, ignore_stereo: bool = False, _cls=MoleculeContainer) -> MoleculeContainer: """ INCHI string parser @@ -46,15 +49,23 @@ def inchi(data, /, *, ignore_stereo: bool = False, _cls=MoleculeContainer) -> Mo raise ValueError('invalid INCHI') atoms, bonds = [], [] + protium = {} + deuterium = {} + tritium = {} seen = set() for n in range(structure.num_atoms): seen.add(n) atom = structure.atom[n] - atoms.append({'element': atom.atomic_symbol, 'charge': atom.charge, 'mapping': 0, 'x': atom.x, 'y': atom.y, + atoms.append({'element': atom.atomic_symbol, 'charge': atom.charge, 'x': atom.x, 'y': atom.y, 'z': atom.z, 'isotope': atom.isotope, 'is_radical': atom.is_radical, - 'implicit_hydrogens': atom.implicit_hydrogens, 'delta_isotope': atom.delta_isotope, - 'p': atom.implicit_protium, 'd': atom.implicit_deuterium, 't': atom.implicit_tritium}) + 'implicit_hydrogens': atom.implicit_hydrogens, 'delta_isotope': atom.delta_isotope}) + if atom.implicit_protium: + protium[n] = atom.implicit_protium + if atom.implicit_deuterium: + deuterium[n] = atom.implicit_deuterium + if atom.implicit_tritium: + tritium[n] = atom.implicit_tritium for k in range(atom.num_bonds): m = atom.neighbor[k] @@ -82,8 +93,9 @@ def inchi(data, /, *, ignore_stereo: bool = False, _cls=MoleculeContainer) -> Mo lib.FreeStructFromINCHI(byref(structure)) - tmp = {'atoms': atoms, 'bonds': bonds, 'stereo_atoms': stereo_atoms, 'stereo_allenes': stereo_allenes, 'log': [], - 'stereo_cumulenes': stereo_cumulenes, 'mapping': list(range(1, len(atoms) + 1)), 'title': None, 'meta': None} + tmp = {'atoms': atoms, 'bonds': bonds, 'stereo_atoms': stereo_atoms, 'stereo_allenes': stereo_allenes, + 'stereo_cumulenes': stereo_cumulenes, 'mapping': list(range(1, len(atoms) + 1)), + 'protium': protium, 'deuterium': deuterium, 'tritium': tritium} mol = create_molecule(tmp, skip_calc_implicit=True, _cls=_cls) postprocess_molecule(mol, tmp, ignore_stereo=ignore_stereo) return mol @@ -95,38 +107,27 @@ def postprocess_molecule(molecule, data, *, ignore_stereo=False): # set hydrogen atoms. INCHI designed for hydrogens handling. hope correctly. free = count(len(atoms) + 1) - for n, atom in enumerate(data['atoms'], 1): - if atom['element'] != 'H': - atoms[n]._implicit_hydrogens = atom['hydrogens'] - # in chython hydrogens never have implicit H. - elif atom['hydrogens']: # >[xH]-H case - m = next(free) - charges[m] = 0 - radicals[m] = False - plane[m] = (0., 0.) - hydrogens[n] = 0 - hydrogens[m] = 0 - atoms[m] = a = H() - a._attach_graph(molecule, m) + to_add = [] + for n, atom in atoms.items(): + # in chython hydrogens never have implicit H. convert to explicit + if atom == H and atom.implicit_hydrogens: + for _ in range(atom.implicit_hydrogens): + to_add.append((n, next(free), _H(implicit_hydrogens=0))) + atom._implicit_hydrogens = 0 + + for n, p in data['protium'].items(): + to_add.append((n + 1, next(free), _H(isotope=1, implicit_hydrogens=0))) + for n, p in data['deuterium'].items(): + to_add.append((n + 1, next(free), _H(isotope=2, implicit_hydrogens=0))) + for n, p in data['tritium'].items(): + to_add.append((n + 1, next(free), _H(isotope=3, implicit_hydrogens=0))) + + if to_add: + for n, m, a in to_add: + atoms[m] = a bonds[n][m] = b = Bond(1) bonds[m] = {n: b} - b._attach_graph(molecule, n, m) - else: # H+, H* or >H-[xH] cases - hydrogens[n] = 0 - # convert isotopic implicit hydrogens to explicit - for i, k in enumerate(('p', 'd', 't'), 1): - if atom[k]: - for _ in range(atom[k]): - m = next(free) - charges[m] = 0 - radicals[m] = False - plane[m] = (0., 0.) - hydrogens[m] = 0 - atoms[m] = a = H(i) - a._attach_graph(molecule, m) - bonds[n][m] = b = Bond(1) - bonds[m] = {n: b} - b._attach_graph(molecule, n, m) + molecule.calc_labels() # reset labels if ignore_stereo or not data['stereo_atoms'] and not data['stereo_cumulenes'] and not data['stereo_allenes']: return From 90176ac8c1404b6cb76fdcb36bcc1532f51045e5 Mon Sep 17 00:00:00 2001 From: stsouko Date: Wed, 20 Nov 2024 19:51:49 +0100 Subject: [PATCH 34/67] fixes --- chython/containers/reaction.py | 6 +++--- chython/files/MRVrw.py | 1 - chython/files/_mdl/emol.py | 6 +++--- chython/files/_mdl/mol.py | 2 +- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/chython/containers/reaction.py b/chython/containers/reaction.py index bbb6509f..e34185e8 100644 --- a/chython/containers/reaction.py +++ b/chython/containers/reaction.py @@ -277,7 +277,7 @@ def __format__(self, format_spec): sig = [] count = 0 contract = [] - orders = [] + radicals = [] for ml in (self.__reactants, self.__reagents, self.__products): mso = [(m, *m.__format__(format_spec, _return_order=True)) for m in ml] @@ -292,13 +292,13 @@ def __format__(self, format_spec): else: count += 1 - orders.append((m, o)) + radicals.extend(m.atom(n).is_radical for n in o) ss.append(s) sig.append('.'.join(ss)) if not format_spec or '!x' not in format_spec: cx = [] - if r := ','.join(str(n) for n, (m, a) in enumerate((m, a) for m, o in orders for a in o) if m._radicals[a]): + if r := ','.join(str(n) for n, r in enumerate(radicals) if r): cx.append(f'^1:{r}') if contract: cx.append(f"f:{','.join('.'.join(x) for x in contract)}") diff --git a/chython/files/MRVrw.py b/chython/files/MRVrw.py index 3c808746..ab969b21 100644 --- a/chython/files/MRVrw.py +++ b/chython/files/MRVrw.py @@ -29,7 +29,6 @@ from ..exceptions import EmptyMolecule, EmptyReaction -organic_set = {'B', 'C', 'N', 'O', 'P', 'S', 'Se', 'F', 'Cl', 'Br', 'I'} bond_map = {8: '1" queryType="Any', 4: 'A', 1: '1', 2: '2', 3: '3', 'Any': 8, 'any': 8, 'A': 4, 'a': 4, '1': 1, '2': 2, '3': 3} diff --git a/chython/files/_mdl/emol.py b/chython/files/_mdl/emol.py index 03b15a6a..a5a5475b 100644 --- a/chython/files/_mdl/emol.py +++ b/chython/files/_mdl/emol.py @@ -149,13 +149,13 @@ def parse_mol_v3000(data, *, _header=True): drop = True for line in data[3 + atom_count + bonds_count:]: - if line.startswith('M V30 END CTAB'): + if line.startswith('END CTAB'): break elif drop: - if line.startswith('M V30 BEGIN SGROUP'): + if line.startswith('BEGIN SGROUP'): drop = False continue - elif line.startswith('M V30 END SGROUP'): + elif line.startswith('END SGROUP'): break _, _type, i, *kvs = split(line) diff --git a/chython/files/_mdl/mol.py b/chython/files/_mdl/mol.py index db819f2b..93913a89 100644 --- a/chython/files/_mdl/mol.py +++ b/chython/files/_mdl/mol.py @@ -122,7 +122,7 @@ def parse_mol_v2000(data): log.append(f'ignored line: {line}') for a in atoms: - if a['is_radical']: # int to bool + if 'is_radical' in a: # int to bool a['is_radical'] = True for x in dat.values(): try: From 6b5d5418cfcf176e30a09a95f41c4faebe85812a Mon Sep 17 00:00:00 2001 From: stsouko Date: Wed, 20 Nov 2024 21:14:35 +0100 Subject: [PATCH 35/67] Refactor container attribute access and remove unused methods Replaced double underscore attributes with single underscore to simplify access. Removed hashed bytes representation method and streamlined `pack` and `unpack` functions across multiple classes by adding shorthand equivalents `pach` and `unpach`. Additionally, updated copyrights and deleted unnecessary import and cache flushes. --- chython/algorithms/mapping/attention.py | 3 +- chython/algorithms/smiles.py | 5 - chython/algorithms/standardize/reaction.py | 21 ++-- chython/containers/molecule.py | 13 +++ chython/containers/reaction.py | 119 ++++++++++----------- 5 files changed, 78 insertions(+), 83 deletions(-) diff --git a/chython/algorithms/mapping/attention.py b/chython/algorithms/mapping/attention.py index e8c75ff0..bc4e870a 100644 --- a/chython/algorithms/mapping/attention.py +++ b/chython/algorithms/mapping/attention.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2022, 2023 Ramil Nugmanov +# Copyright 2022-2024 Ramil Nugmanov # Copyright 2024 Philippe Gantzer # This file is part of chython. # @@ -33,6 +33,7 @@ class Attention: __slots__ = () + __class_cache__ = {} def reset_mapping(self: Union['ReactionContainer', 'Attention'], *, return_score: bool = False, multiplier=1.75, keep_reactants_numbering=False) -> Union[bool, float]: diff --git a/chython/algorithms/smiles.py b/chython/algorithms/smiles.py index fecbae0d..5f463035 100644 --- a/chython/algorithms/smiles.py +++ b/chython/algorithms/smiles.py @@ -21,7 +21,6 @@ from CachedMethods import cached_method from collections import defaultdict from functools import cached_property -from hashlib import sha512 from heapq import heappop, heappush from itertools import product from random import random @@ -148,10 +147,6 @@ def __eq__(self, other): def __hash__(self): return hash(str(self)) - @cached_method - def __bytes__(self): - return sha512(str(self).encode()).digest() - @cached_property def smiles_atoms_order(self) -> Tuple[int, ...]: """ diff --git a/chython/algorithms/standardize/reaction.py b/chython/algorithms/standardize/reaction.py index 1cb20f28..8f5ab282 100644 --- a/chython/algorithms/standardize/reaction.py +++ b/chython/algorithms/standardize/reaction.py @@ -272,10 +272,9 @@ def __remove_reagents_rules(self: 'ReactionContainer', keep_reagents): tmp.extend(reagents_st2) reagents = tuple(tmp) if keep_reagents else () - self._ReactionContainer__reactants = tuple(reactants_st2) - self._ReactionContainer__products = tuple(products_st2) - self._ReactionContainer__reagents = reagents - self.flush_cache() + self._reactants = tuple(reactants_st2) + self._products = tuple(products_st2) + self._reagents = reagents self.fix_positions() return True @@ -307,10 +306,9 @@ def __remove_reagents_mapping(self: 'ReactionContainer', keep_reagents): reagents = tuple(tmp) if keep_reagents else () if len(reactants) != len(self.reactants) or len(products) != len(self.products) or len(reagents) != len(self.reagents): - self._ReactionContainer__reactants = tuple(reactants) - self._ReactionContainer__products = tuple(products) - self._ReactionContainer__reagents = reagents - self.flush_cache() + self._reactants = tuple(reactants) + self._products = tuple(products) + self._reagents = reagents self.fix_positions() return True return False @@ -327,7 +325,7 @@ def contract_ions(self: 'ReactionContainer') -> bool: salts = _contract_ions(anions, cations, total) if salts: neutral.extend(salts) - self._ReactionContainer__reagents = tuple(neutral) + self._reagents = tuple(neutral) changed = True else: changed = False @@ -338,7 +336,7 @@ def contract_ions(self: 'ReactionContainer') -> bool: anions_order = {frozenset(m): n for n, m in enumerate(anions)} cations_order = {frozenset(m): n for n, m in enumerate(cations)} neutral.extend(salts) - self._ReactionContainer__reactants = tuple(neutral) + self._reactants = tuple(neutral) changed = True else: anions_order = cations_order = {} @@ -350,11 +348,10 @@ def contract_ions(self: 'ReactionContainer') -> bool: salts = _contract_ions(anions, cations, total) if salts: neutral.extend(salts) - self._ReactionContainer__products = tuple(neutral) + self._products = tuple(neutral) changed = True if changed: - self.flush_cache() self.fix_positions() return True return False diff --git a/chython/containers/molecule.py b/chython/containers/molecule.py index e6263811..5ac7fb43 100644 --- a/chython/containers/molecule.py +++ b/chython/containers/molecule.py @@ -527,6 +527,9 @@ def pack(self, *, compressed=True, check=True, version=2, order: List[int] = Non return compress(data, 9) return data + def pach(self, *, compressed=True, check=True, version=2, order: List[int] = None) -> bytes: + return self.pack(compressed=compressed, check=check, version=version, order=order) + @classmethod def pack_len(cls, data: bytes, /, *, compressed=True) -> int: """ @@ -586,6 +589,16 @@ def unpack(cls, data: Union[bytes, memoryview], /, *, compressed=True, return mol, pack_length return mol + @classmethod + def unpach(cls, data: Union[bytes, memoryview], /, *, compressed=True) -> 'MoleculeContainer': + """ + Unpack from compressed bytes. + """ + return cls.unpack(data, compressed=compressed) + + def __bytes__(self): + return self.pack() + def _cpack(self, order=None, check=True): if order is None: order = list(self._atoms) diff --git a/chython/containers/reaction.py b/chython/containers/reaction.py index e34185e8..2d154924 100644 --- a/chython/containers/reaction.py +++ b/chython/containers/reaction.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2017-2022 Ramil Nugmanov +# Copyright 2017-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -18,11 +18,10 @@ # from CachedMethods import cached_method from functools import reduce -from hashlib import sha512 from itertools import chain from math import ceil from operator import itemgetter, or_ -from typing import Dict, Iterable, Iterator, Optional, Tuple, List +from typing import Dict, Iterator, Optional, Tuple, List, Sequence from zlib import compress, decompress from .cgr import CGRContainer from .molecule import MoleculeContainer @@ -38,11 +37,10 @@ class ReactionContainer(StandardizeReaction, Mapping, Calculate2DReaction, Depic Reaction storage hashable and comparable. based on reaction unique signature (SMILES). """ - __slots__ = ('__reactants', '__products', '__reagents', '__meta', '__name', '_arrow', '_signs', '__dict__') - __class_cache__ = {} + __slots__ = ('_reactants', '_products', '_reagents', '_meta', '_name', '_arrow', '_signs', '__dict__') - def __init__(self, reactants: Iterable[MoleculeContainer] = (), products: Iterable[MoleculeContainer] = (), - reagents: Iterable[MoleculeContainer] = (), meta: Optional[Dict] = None, name: Optional[str] = None): + def __init__(self, reactants: Sequence[MoleculeContainer] = (), products: Sequence[MoleculeContainer] = (), + reagents: Sequence[MoleculeContainer] = (), meta: Optional[Dict] = None, name: Optional[str] = None): """ New reaction object creation @@ -60,15 +58,15 @@ def __init__(self, reactants: Iterable[MoleculeContainer] = (), products: Iterab elif not all(isinstance(x, MoleculeContainer) for x in chain(reactants, products, reagents)): raise TypeError(f'MoleculeContainers expected') - self.__reactants = reactants - self.__products = products - self.__reagents = reagents + self._reactants = reactants + self._products = products + self._reagents = reagents if meta is None: - self.__meta = None + self._meta = None else: - self.__meta = dict(meta) + self._meta = dict(meta) if name is None: - self.__name = None + self._name = None else: self.name = name self._arrow = None @@ -76,21 +74,21 @@ def __init__(self, reactants: Iterable[MoleculeContainer] = (), products: Iterab @property def reactants(self) -> Tuple[MoleculeContainer, ...]: - return self.__reactants + return self._reactants @property def reagents(self) -> Tuple[MoleculeContainer, ...]: - return self.__reagents + return self._reagents @property def products(self) -> Tuple[MoleculeContainer, ...]: - return self.__products + return self._products def molecules(self) -> Iterator[MoleculeContainer]: """ Iterator of all reaction molecules """ - return chain(self.__reactants, self.__reagents, self.__products) + return chain(self.reactants, self.reagents, self.products) @property def meta(self) -> Dict: @@ -98,33 +96,33 @@ def meta(self) -> Dict: Dictionary of metadata. Like DTYPE-DATUM in RDF """ - if self.__meta is None: - self.__meta = {} # lazy - return self.__meta + if self._meta is None: + self._meta = {} # lazy + return self._meta @property def name(self) -> str: - return self.__name or '' + return self._name or '' @name.setter def name(self, name: str): if not isinstance(name, str): raise TypeError('name should be string up to 80 symbols') - self.__name = name + self._name = name def copy(self) -> 'ReactionContainer': """ Get copy of object """ copy = object.__new__(self.__class__) - copy._ReactionContainer__reactants = tuple(x.copy() for x in self.__reactants) - copy._ReactionContainer__products = tuple(x.copy() for x in self.__products) - copy._ReactionContainer__reagents = tuple(x.copy() for x in self.__reagents) - copy._ReactionContainer__name = self.__name - if self.__meta is None: - copy._ReactionContainer__meta = None + copy._reactants = tuple(x.copy() for x in self.reactants) + copy._products = tuple(x.copy() for x in self.products) + copy._reagents = tuple(x.copy() for x in self.reagents) + copy._name = self._name + if self._meta is None: + copy._meta = None else: - copy._ReactionContainer__meta = self.__meta.copy() + copy._meta = self._meta.copy() copy._arrow = self._arrow copy._signs = self._signs return copy @@ -137,23 +135,23 @@ def compose(self) -> CGRContainer: Reagents will be presented as unchanged molecules :return: CGRContainer """ - rr = self.__reagents + self.__reactants + rr = self.reagents + self.reactants if rr: r = reduce(or_, rr) else: r = MoleculeContainer() - if self.__products: - p = reduce(or_, self.__products) + if self.products: + p = reduce(or_, self.products) else: p = MoleculeContainer() return r ^ p - def flush_cache(self): + def flush_cache(self, **kwargs): self.__dict__.clear() for m in self.molecules(): - m.flush_cache() + m.flush_cache(**kwargs) - def pack(self, *, compressed=True, check=True): + def pack(self, *, compressed=True, check=True) -> bytes: """ Pack into compressed bytes. @@ -172,12 +170,18 @@ def pack(self, *, compressed=True, check=True): :param compressed: return zlib-compressed pack. :param check: check molecules for format restrictions. """ - data = b''.join((bytearray((1, len(self.__reactants), len(self.__reagents), len(self.__products))), + data = b''.join((bytearray((1, len(self.reactants), len(self.reagents), len(self.products))), *(m.pack(compressed=False, check=check) for m in self.molecules()))) if compressed: return compress(data, 9) return data + def pach(self, *, compressed=True, check=True) -> bytes: + """ + Pack into compressed bytes. + """ + return self.pack(compressed=compressed, check=check) + @classmethod def pack_len(cls, data: bytes, /, *, compressed=True) -> Tuple[List[int], List[int], List[int]]: """ @@ -225,7 +229,7 @@ def unpack(cls, data: bytes, /, *, compressed=True) -> 'ReactionContainer': raise ValueError('invalid pack header') reactants, reagents, products = data[1], data[2], data[3] - molecules = [] + molecules: List[MoleculeContainer] = [] shift = 4 for _ in range(reactants + reagents + products): m, pl = MoleculeContainer.unpack(data[shift:], compressed=False, _return_pack_length=True) @@ -233,6 +237,16 @@ def unpack(cls, data: bytes, /, *, compressed=True) -> 'ReactionContainer': shift += pl return cls(molecules[:reactants], molecules[-products:], molecules[reactants: -products]) + @classmethod + def unpach(cls, data: bytes, /, *, compressed=True) -> 'ReactionContainer': + """ + Unpack from compressed bytes. + """ + return cls.unpack(data, compressed=compressed) + + def __bytes__(self): + return self.pack() + def __invert__(self) -> CGRContainer: """ Get CGR of reaction @@ -246,15 +260,11 @@ def __eq__(self, other): def __hash__(self): return hash(str(self)) - @cached_method - def __bytes__(self): - return sha512(str(self).encode()).digest() - def __bool__(self): """ Exists both reactants and products """ - return bool(self.__reactants and self.__products) + return bool(self.reactants and self.products) @cached_method def __str__(self): @@ -279,7 +289,7 @@ def __format__(self, format_spec): contract = [] radicals = [] - for ml in (self.__reactants, self.__reagents, self.__products): + for ml in (self.reactants, self.reagents, self.products): mso = [(m, *m.__format__(format_spec, _return_order=True)) for m in ml] if not format_spec or '!c' not in format_spec: mso.sort(key=itemgetter(1)) @@ -306,29 +316,8 @@ def __format__(self, format_spec): return f"{'>'.join(sig)} |{','.join(cx)}|" return '>'.join(sig) - @cached_method def __len__(self): - return len(self.__reactants) + len(self.__products) + len(self.__reagents) - - def __getstate__(self): - state = {'reactants': self.__reactants, 'products': self.__products, 'reagents': self.__reagents, - 'meta': self.__meta, 'name': self.__name, 'arrow': self._arrow, 'signs': self._signs} - from chython import pickle_cache - - if pickle_cache: - state['cache'] = self.__dict__ - return state - - def __setstate__(self, state): - self.__reactants = state['reactants'] - self.__products = state['products'] - self.__reagents = state['reagents'] - self.__meta = state['meta'] - self.__name = state['name'] - self._arrow = state['arrow'] - self._signs = state['signs'] - if 'cache' in state: - self.__dict__.update(state['cache']) + return len(self.reactants) + len(self.products) + len(self.reagents) __all__ = ['ReactionContainer'] From 3ab3629273e3bfbc2bb7271bd88c6aec4382454c Mon Sep 17 00:00:00 2001 From: stsouko Date: Fri, 22 Nov 2024 08:41:43 +0100 Subject: [PATCH 36/67] saved --- chython/reactor/base.py | 6 +++++- chython/reactor/reactor.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/chython/reactor/base.py b/chython/reactor/base.py index 16f8b918..fae981bc 100644 --- a/chython/reactor/base.py +++ b/chython/reactor/base.py @@ -21,7 +21,7 @@ from itertools import product from ..containers import MoleculeContainer, QueryContainer from ..containers.bonds import Bond -from ..periodictable import Element, ListElement, AnyElement +from ..periodictable import Element, ListElement, AnyElement, QueryElement class BaseReactor: @@ -34,6 +34,10 @@ def __init__(self, reactants, products, delete_atoms, fix_rings, fix_tautomers): self.__variable = variable = [] atoms = defaultdict(dict) + if isinstance(products, MoleculeContainer): + # full replacement of atoms + for n, atom in products.atoms(): + elements[n] = atom.copy(hydrogens=True, stereo=True) for n, atom in products.atoms(): atoms[n].update(charge=atom.charge, is_radical=atom.is_radical) if atom.atomic_number: # replace atom diff --git a/chython/reactor/reactor.py b/chython/reactor/reactor.py index 08cb024c..ce74a6d1 100644 --- a/chython/reactor/reactor.py +++ b/chython/reactor/reactor.py @@ -69,7 +69,7 @@ def __init__(self, patterns: Tuple[QueryContainer, ...], self.__polymerise_limit = polymerise_limit self.__products_atoms = tuple(set(m) for m in products) self.__automorphism_filter = automorphism_filter - super().__init__({n for x in patterns for n, h in x._masked.items() if not h}, reduce(or_, products), + super().__init__({n for x in patterns for n, a in x.atoms() if not a.masked}, reduce(or_, products), delete_atoms, fix_aromatic_rings, fix_tautomers) def __call__(self, *structures: MoleculeContainer): From 41e2b1fcb9f4764d66516001073c772a2b7034c2 Mon Sep 17 00:00:00 2001 From: Ramil Nugmanov Date: Fri, 22 Nov 2024 10:00:36 +0100 Subject: [PATCH 37/67] fixes. removed overoptimizations. --- chython/algorithms/depict.py | 2 +- chython/algorithms/fingerprints/__init__.py | 2 +- chython/algorithms/isomorphism.py | 4 ++-- chython/algorithms/mcs.py | 4 ++-- chython/algorithms/morgan.py | 8 ++++---- chython/algorithms/standardize/molecule.py | 18 ++++++++---------- chython/algorithms/standardize/resonance.py | 6 +++--- chython/algorithms/standardize/saturation.py | 8 ++++---- chython/algorithms/stereo.py | 8 ++++---- chython/algorithms/x3dom.py | 4 ++-- chython/containers/graph.py | 4 ++-- chython/containers/molecule.py | 4 ++-- chython/files/_mdl/emol.py | 2 +- chython/files/_mdl/erxn.py | 2 +- chython/files/_mdl/mol.py | 2 +- chython/files/_mdl/rxn.py | 2 +- chython/files/_mdl/write.py | 8 ++++---- chython/files/libinchi/wrapper.py | 4 ++-- 18 files changed, 45 insertions(+), 47 deletions(-) diff --git a/chython/algorithms/depict.py b/chython/algorithms/depict.py index 73cf2319..b0819b03 100644 --- a/chython/algorithms/depict.py +++ b/chython/algorithms/depict.py @@ -351,7 +351,7 @@ def __render_atoms(self: 'MoleculeContainer', uid): define = [] mask = [] - for n, atom in self._atoms.items(): + for n, atom in self.atoms(): x, y = atom.x, -atom.y symbol = atom.atomic_symbol if (symbol != 'C' or atom.charge or atom.is_radical or atom.isotope or carbon diff --git a/chython/algorithms/fingerprints/__init__.py b/chython/algorithms/fingerprints/__init__.py index 0f6febf1..ec2121fa 100644 --- a/chython/algorithms/fingerprints/__init__.py +++ b/chython/algorithms/fingerprints/__init__.py @@ -32,7 +32,7 @@ class Fingerprints(LinearFingerprint, MorganFingerprint): @property def _atom_identifiers(self: 'MoleculeContainer'): return {idx: hash((atom.isotope or 0, atom.atomic_number, atom.charge, atom.is_radical)) - for idx, atom in self._atoms.items()} + for idx, atom in self.atoms()} class FingerprintsCGR(LinearFingerprint, MorganFingerprint): diff --git a/chython/algorithms/isomorphism.py b/chython/algorithms/isomorphism.py index 2a64bdf8..43586504 100644 --- a/chython/algorithms/isomorphism.py +++ b/chython/algorithms/isomorphism.py @@ -190,7 +190,7 @@ def _cython_compiled_structure(self: 'MoleculeContainer'): bits2 = [] bits3 = [] bits4 = [] - for i, (n, a) in enumerate(self._atoms.items()): + for i, (n, a) in enumerate(self.atoms()): mapping[n] = i numbers.append(n) v2 = 1 << (a.hybridization - 1) @@ -318,7 +318,7 @@ def get_mapping(query, scope): for mapping in self._get_mapping(other, automorphism_filter=automorphism_filter, searching_scope=searching_scope): - for n, a in self._atoms.items(): + for n, a in self.atoms(): if a.stereo is None: continue m = mapping[n] diff --git a/chython/algorithms/mcs.py b/chython/algorithms/mcs.py index 437d2dcf..8e1bf41b 100644 --- a/chython/algorithms/mcs.py +++ b/chython/algorithms/mcs.py @@ -97,10 +97,10 @@ def __get_product(self: 'molecule.MoleculeContainer', other: 'molecule.MoleculeC o_bonds = other._bonds s_equal = defaultdict(list) # equal self atoms - for n, atom in self._atoms.items(): + for n, atom in self.atoms(): s_equal[atom].append(n) p_equal = defaultdict(list) # equal other atoms - for n, atom in other._atoms.items(): + for n, atom in other.atoms(): p_equal[atom].append(n) full_product = {} diff --git a/chython/algorithms/morgan.py b/chython/algorithms/morgan.py index e200cbc3..c56b5572 100644 --- a/chython/algorithms/morgan.py +++ b/chython/algorithms/morgan.py @@ -40,12 +40,12 @@ def atoms_order(self: 'MoleculeContainer') -> Dict[int, int]: :return: dict of atom-order pairs """ - if not self._atoms: # for empty containers + if not self: # for empty containers return {} - elif len(self._atoms) == 1: # optimize single atom containers - return dict.fromkeys(self._atoms, 1) + elif len(self) == 1: # optimize single atom containers + return dict.fromkeys(self, 1) ring = self.ring_atoms - return _morgan({n: hash((hash(a), n in ring)) for n, a in self._atoms.items()}, self.int_adjacency) + return _morgan({n: hash((hash(a), n in ring)) for n, a in self.atoms()}, self.int_adjacency) @cached_property def int_adjacency(self: 'MoleculeContainer') -> Dict[int, Dict[int, int]]: diff --git a/chython/algorithms/standardize/molecule.py b/chython/algorithms/standardize/molecule.py index 5f4e5e77..92840c9b 100644 --- a/chython/algorithms/standardize/molecule.py +++ b/chython/algorithms/standardize/molecule.py @@ -55,7 +55,7 @@ def canonicalize(self: 'MoleculeContainer', *, fix_tautomers=True, keep_kekule=F h, changed = self.implicify_hydrogens(_fix_stereo=False, logging=True) if fix_tautomers and (logging or keep_kekule): # thiele can change tautomeric form - hgs = {n: a.implicit_hydrogens for n, a in self._atoms.items()} + hgs = {n: a.implicit_hydrogens for n, a in self.atoms()} if keep_kekule: # save bond orders bonds = [(b, b.order) for _, _, b in self.bonds()] @@ -66,7 +66,7 @@ def canonicalize(self: 'MoleculeContainer', *, fix_tautomers=True, keep_kekule=F if keep_kekule and t: # restore # check ring charge/hydrogen moving - if c or fix_tautomers and hgs != self._hydrogens: # noqa + if c or fix_tautomers and any(hgs[n] != a.implicit_hydrogens for n, a in self.atoms()): self.kekule() # we need to do full kekule again else: for b, o in bonds: # noqa @@ -81,12 +81,12 @@ def canonicalize(self: 'MoleculeContainer', *, fix_tautomers=True, keep_kekule=F s.append((tuple(changed), -1, 'implicified')) if t: s.append(((), -1, 'aromatized')) - if fix_tautomers and (x := tuple(n for n, a in self._atoms.items() if hgs[n] != a.implicit_hydrogens)): + if fix_tautomers and (x := tuple(n for n, a in self.atoms() if hgs[n] != a.implicit_hydrogens)): s.append((x, -1, 'aromatic tautomer found')) if c: s.append((tuple(c), -1, 'recharged')) if keep_kekule and t: - if c or fix_tautomers and any(hgs[n] != a.implicit_hydrogens for n, a in self._atoms.items()): + if c or fix_tautomers and any(hgs[n] != a.implicit_hydrogens for n, a in self.atoms()): s.append(((), -1, 'kekulized again')) else: s.append(((), -1, 'kekule form restored')) @@ -123,7 +123,7 @@ def standardize(self: Union['MoleculeContainer', 'Standardize'], *, logging=Fals log.extend(l) fixed.update(f) - if b := fixed.intersection(n for n, a in self._atoms.items() if a.implicit_hydrogens is None): + if b := fixed.intersection(n for n, a in self.atoms() if a.implicit_hydrogens is None): if ignore: log.append((tuple(b), -1, 'standardization failed')) else: @@ -271,17 +271,15 @@ def remove_coordinate_bonds(self: 'MoleculeContainer', *, keep_to_terminal=True, :param keep_to_terminal: Keep any bonds to terminal hydrogens :return: removed bonds count """ - bonds = self._bonds - ab = [(n, m) for n, m, b in self.bonds() if b == 8] if keep_to_terminal: skeleton = self.not_special_connectivity - hs = {n for n, a in self._atoms.items() if a == H and not skeleton[n]} + hs = {n for n, a in self.atoms() if a == H and not skeleton[n]} ab = [(n, m) for n, m in ab if n not in hs and m not in hs] for n, m in ab: - del bonds[n][m], bonds[m][n] + self.delete_bond(n, m, _skip_calculation=True) if ab: self.flush_cache(keep_sssr=True) @@ -404,7 +402,7 @@ def check_valence(self: 'MoleculeContainer') -> List[int]: :return: list of invalid atoms """ # only invalid atoms have None hydrogens. - return [n for n, a in self._atoms.items() if a.implicit_hydrogens is None] + return [n for n, a in self.atoms() if a.implicit_hydrogens is None] def clean_isotopes(self: 'MoleculeContainer') -> bool: """ diff --git a/chython/algorithms/standardize/resonance.py b/chython/algorithms/standardize/resonance.py index 2283540f..593b4ef5 100644 --- a/chython/algorithms/standardize/resonance.py +++ b/chython/algorithms/standardize/resonance.py @@ -135,7 +135,7 @@ def __find_delocalize_path(self: 'MoleculeContainer', start, finish, constrains, def __entries(self: 'MoleculeContainer'): atoms = self._atoms bonds = self._bonds - errors = {n for n, a in atoms.items() if a.implicit_hydrogens is None} + errors = {n for n, a in self.atoms() if a.implicit_hydrogens is None} transfer = set() entries = set() @@ -144,7 +144,7 @@ def __entries(self: 'MoleculeContainer'): nitrogen_cat = set() nitrogen_ani = set() sulfur_cat = set() - for n, a in atoms.items(): + for n, a in self.atoms(): if a not in (B, C, N, O, Si, P, S, As, Se, Te): # filter non-organic set, halogens and aromatics continue @@ -182,7 +182,7 @@ def __entries(self: 'MoleculeContainer'): transfer.add(n) if exits or entries: # try to move cation to nitrogen. saturation fixup. - for n, a in self._atoms.items(): + for n, a in self.atoms(): if a == N and not a.charge: if a.hybridization == 1 and a.neighbors <= 3: # any amine - potential e-donor entries.add(n) diff --git a/chython/algorithms/standardize/saturation.py b/chython/algorithms/standardize/saturation.py index 38c5bb1e..03fa9c1e 100644 --- a/chython/algorithms/standardize/saturation.py +++ b/chython/algorithms/standardize/saturation.py @@ -76,11 +76,11 @@ def saturate(self: 'MoleculeContainer', neighbors_distances: Optional[Dict[int, expected_charge = int(self) if reset_electrons: - charges = {x: None for x in self._atoms} - radicals = {x: None for x in self._atoms} + charges = {x: None for x in self} + radicals = {x: None for x in self} else: - charges = {n: a.charge for n, a in self._atoms.items()} - radicals = {n: a.is_radical for n, a in self._atoms.items()} + charges = {n: a.charge for n, a in self.atoms()} + radicals = {n: a.is_radical for n, a in self.atoms()} sat, adjacency = _find_possible_valences(atoms, neighbors_distances or self._bonds, charges, radicals, neighbors_distances is not None) charges = {} # new charge states diff --git a/chython/algorithms/stereo.py b/chython/algorithms/stereo.py index e243d6a2..1be70e3a 100644 --- a/chython/algorithms/stereo.py +++ b/chython/algorithms/stereo.py @@ -168,7 +168,7 @@ def tetrahedrons(self: 'MoleculeContainer') -> Tuple[int, ...]: Carbon sp3 atom numbers. """ tetra = [] - for n, atom in self._atoms.items(): + for n, atom in self.atoms(): if atom == C and not atom.charge and not atom.is_radical: env = self._bonds[n] if all(b == 1 for b in env.values()): @@ -577,7 +577,7 @@ def fix_stereo(self: 'MoleculeContainer'): atoms_stereo = [] allenes_stereo = [] cis_trans_stereo = [] - for n, a in self._atoms.items(): + for n, a in self.atoms(): if a.stereo is None: continue elif n in stereo_tetrahedrons: @@ -960,7 +960,7 @@ def __wedge_sign(self: 'MoleculeContainer', order): @cached_property def _chiral_morgan(self: Union['MoleculeContainer', 'MoleculeStereo']) -> Dict[int, int]: - stereo_atoms = {n for n, a in self._atoms.items() if a.stereo is not None} + stereo_atoms = {n for n, a in self.atoms() if a.stereo is not None} stereo_bonds = {n for n, mb in self._bonds.items() if any(b.stereo is not None for m, b in mb.items())} if not stereo_atoms and not stereo_bonds: return self.atoms_order @@ -1103,7 +1103,7 @@ def __chiral_centers(self: Union['MoleculeStereo', 'MoleculeContainer']): chiral_c.add(n) # skip already marked. - stereo_atoms = {n for n, a in self._atoms.items() if a.stereo is not None} + stereo_atoms = {n for n, a in self.atoms() if a.stereo is not None} chiral_t.difference_update(stereo_atoms) chiral_a.difference_update(stereo_atoms) diff = set() diff --git a/chython/algorithms/x3dom.py b/chython/algorithms/x3dom.py index 9d59160d..73779280 100644 --- a/chython/algorithms/x3dom.py +++ b/chython/algorithms/x3dom.py @@ -181,7 +181,7 @@ def __render_atoms(self: 'MoleculeContainer', xyz): atoms = [] if carbon: - for n, a in self._atoms.items(): + for n, a in self.atoms(): r = radius or a.atomic_radius * multiplier fr = r * 0.71 atoms.append(f" \n" @@ -197,7 +197,7 @@ def __render_atoms(self: 'MoleculeContainer', xyz): f" \n \n" " \n \n \n \n") else: - for n, a in self._atoms.items(): + for n, a in self.atoms(): r = radius or a.atomic_radius * multiplier atoms.append(f" \n" " \n \n" diff --git a/chython/containers/graph.py b/chython/containers/graph.py index 4586969e..f644ebb6 100644 --- a/chython/containers/graph.py +++ b/chython/containers/graph.py @@ -122,7 +122,7 @@ def copy(self): copy of graph """ copy = object.__new__(self.__class__) - copy._atoms = {n: atom.copy(full=True) for n, atom in self._atoms.items()} + copy._atoms = {n: atom.copy(full=True) for n, atom in self.atoms()} copy._bonds = cb = {} for n, m_bond in self._bonds.items(): cb[n] = cbn = {} @@ -144,7 +144,7 @@ def remap(self, mapping: Dict[int, int]): raise ValueError('mapping overlap') mg = mapping.get - self._atoms = {mg(n, n): atom for n, atom in self._atoms.items()} + self._atoms = {mg(n, n): atom for n, atom in self.atoms()} self._bonds = {mg(n, n): {mg(m, m): bond for m, bond in m_bond.items()} for n, m_bond in self._bonds.items()} self.flush_cache() diff --git a/chython/containers/molecule.py b/chython/containers/molecule.py index 5ac7fb43..a8bd6094 100644 --- a/chython/containers/molecule.py +++ b/chython/containers/molecule.py @@ -292,11 +292,11 @@ def substructure(self, atoms: Iterable[int], *, as_query: bool = False, recalcul raise ValueError('empty atoms list not allowed') if set(atoms) - self._atoms.keys(): raise ValueError('invalid atom numbers') - atoms = tuple(n for n in self._atoms if n in atoms) # save original order + atoms = tuple(n for n in self if n in atoms) # save original order if as_query: sub = object.__new__(QueryContainer) - lost = {n for n, a in self._atoms.items() if a != H} - set(atoms) # atoms not in substructure + lost = {n for n, a in self.atoms() if a != H} - set(atoms) # atoms not in substructure # atoms with fully present neighbors not_skin = {n for n in atoms if lost.isdisjoint(self._bonds[n])} diff --git a/chython/files/_mdl/emol.py b/chython/files/_mdl/emol.py index a5a5475b..e8390a9c 100644 --- a/chython/files/_mdl/emol.py +++ b/chython/files/_mdl/emol.py @@ -21,7 +21,7 @@ def parse_mol_v3000(data, *, _header=True): if _header: - title = data[1].strip() or None + title = data[0].strip() or None data = data[4:] else: title = None diff --git a/chython/files/_mdl/erxn.py b/chython/files/_mdl/erxn.py index 6b707b3e..d088cabe 100644 --- a/chython/files/_mdl/erxn.py +++ b/chython/files/_mdl/erxn.py @@ -29,7 +29,7 @@ def parse_rxn_v3000(data, *, ignore=True): if not reagents_count: raise EmptyReaction - title = data[2].strip() or None + title = data[1].strip() or None log = [] molecules = [] diff --git a/chython/files/_mdl/mol.py b/chython/files/_mdl/mol.py index 93913a89..88b21373 100644 --- a/chython/files/_mdl/mol.py +++ b/chython/files/_mdl/mol.py @@ -32,7 +32,7 @@ def parse_mol_v2000(data): raise EmptyMolecule log = [] - title = data[1].strip() or None + title = data[0].strip() or None atoms = [] bonds = [] stereo = [] diff --git a/chython/files/_mdl/rxn.py b/chython/files/_mdl/rxn.py index 50df40e4..56977fe1 100644 --- a/chython/files/_mdl/rxn.py +++ b/chython/files/_mdl/rxn.py @@ -29,7 +29,7 @@ def parse_rxn_v2000(data, *, ignore=True): if not reagents_count: raise EmptyReaction - title = data[2].strip() or None + title = data[1].strip() or None log = [] molecules = [] diff --git a/chython/files/_mdl/write.py b/chython/files/_mdl/write.py index 3319c60d..a998251e 100644 --- a/chython/files/_mdl/write.py +++ b/chython/files/_mdl/write.py @@ -82,7 +82,7 @@ def _write_molecule(self, g, write3d=None): file = self._file file.write(f'M V30 BEGIN CTAB\nM V30 COUNTS {g.atoms_count} {g.bonds_count} 0 0 0\nM V30 BEGIN ATOM\n') - for n, (m, a) in enumerate(g._atoms.items(), start=1): + for n, (m, a) in enumerate(g.atoms(), start=1): if write3d is not None: x, y, z = xyz[m] z = f'{z:.4f}' @@ -131,7 +131,7 @@ def _write_molecule(self, g, write3d=None): file = self._file file.write(f'{g.name}\n\n\n{g.atoms_count:3d}{g.bonds_count:3d} 0 0 0 0 999 V2000\n') - for n, (m, a) in enumerate(g._atoms.items(), start=1): + for n, (m, a) in enumerate(g.atoms(), start=1): if write3d is not None: x, y, z = xyz[m] else: @@ -142,7 +142,7 @@ def _write_molecule(self, g, write3d=None): m = 0 file.write(f'{x:10.4f}{y:10.4f}{z:10.4f} {a.atomic_symbol:3s} 0{c} 0 0 0 0 0 0 0{m:3d} 0 0\n') - atoms = {m: n for n, m in enumerate(g._atoms, start=1)} + atoms = {m: n for n, m in enumerate(g, start=1)} wedge = defaultdict(set) for n, m, s in g._wedge_map: file.write(f'{atoms[n]:3d}{atoms[m]:3d} {bonds[n][m].order} {s == 1 and "1" or "6"} 0 0 0\n') @@ -152,7 +152,7 @@ def _write_molecule(self, g, write3d=None): if m not in wedge[n]: file.write(f'{atoms[n]:3d}{atoms[m]:3d} {b.order} 0 0 0 0\n') - for n, a in enumerate(g._atoms.values(), start=1): + for n, (_, a) in enumerate(g.atoms(), start=1): if a.isotope: file.write(f'M ISO 1 {n:3d} {a.isotope:3d}\n') if a.is_radical: diff --git a/chython/files/libinchi/wrapper.py b/chython/files/libinchi/wrapper.py index 215a2ba7..8d583fb6 100644 --- a/chython/files/libinchi/wrapper.py +++ b/chython/files/libinchi/wrapper.py @@ -201,8 +201,8 @@ def isotope(self): @property def delta_isotope(self): - if self.isotope > 9000: - return self.isotope - 10_000 + if self.isotopic_mass > 9000: + return self.isotopic_mass - 10_000 @property def is_radical(self): From 41e1486778d58677104a68060c84261ae3267142 Mon Sep 17 00:00:00 2001 From: Ramil Nugmanov Date: Fri, 22 Nov 2024 10:52:18 +0100 Subject: [PATCH 38/67] removed overoptimizations. --- chython/algorithms/depict.py | 26 ++++++++++------------ chython/algorithms/isomorphism.py | 4 ++-- chython/algorithms/standardize/molecule.py | 2 +- chython/algorithms/stereo.py | 7 +++--- chython/containers/molecule.py | 10 ++++----- chython/utils/grid.py | 23 ++++++++++--------- chython/utils/retro.py | 19 ++++++++-------- 7 files changed, 45 insertions(+), 46 deletions(-) diff --git a/chython/algorithms/depict.py b/chython/algorithms/depict.py index b0819b03..5b00406b 100644 --- a/chython/algorithms/depict.py +++ b/chython/algorithms/depict.py @@ -206,17 +206,16 @@ def depict(self: Union['MoleculeContainer', 'DepictMolecule'], *, width=None, he :param clean2d: calculate coordinates if necessary. """ uid = str(uuid4()) - atoms = self._atoms.values() - min_x = min(a.x for a in atoms) - max_x = max(a.x for a in atoms) - min_y = min(a.y for a in atoms) - max_y = max(a.y for a in atoms) + min_x = min(a.x for _, a in self.atoms()) + max_x = max(a.x for _, a in self.atoms()) + min_y = min(a.y for _, a in self.atoms()) + max_y = max(a.y for _, a in self.atoms()) if clean2d and len(self) > 1 and max_y - min_y < .01 and max_x - min_x < 0.01: self.clean2d() - min_x = min(a.x for a in atoms) - max_x = max(a.x for a in atoms) - min_y = min(a.y for a in atoms) - max_y = max(a.y for a in atoms) + min_x = min(a.x for _, a in self.atoms()) + max_x = max(a.x for _, a in self.atoms()) + min_y = min(a.y for _, a in self.atoms()) + max_y = max(a.y for _, a in self.atoms()) bonds = self.__render_bonds() atoms, define, masks = self.__render_atoms(uid) @@ -455,11 +454,10 @@ def depict(self: 'ReactionContainer', *, width=None, height=None, clean2d: bool if clean2d: for m in self.molecules(): if len(m) > 1: - atoms = m._atoms.values() - min_x = min(a.x for a in atoms) - max_x = max(a.x for a in atoms) - min_y = min(a.y for a in atoms) - max_y = max(a.y for a in atoms) + min_x = min(a.x for _, a in m.atoms()) + max_x = max(a.x for _, a in m.atoms()) + min_y = min(a.y for _, a in m.atoms()) + max_y = max(a.y for _, a in m.atoms()) if max_y - min_y < .01 and max_x - min_x < 0.01: m.clean2d() self.fix_positions() diff --git a/chython/algorithms/isomorphism.py b/chython/algorithms/isomorphism.py index 43586504..23257e18 100644 --- a/chython/algorithms/isomorphism.py +++ b/chython/algorithms/isomorphism.py @@ -128,8 +128,8 @@ def __contains__(self: 'MoleculeContainer', other: Union[Element, Query, str]): Atom in Structure test. """ if isinstance(other, str): - return any(other == x.atomic_symbol for x in self._atoms.values()) - return any(other == x for x in self._atoms.values()) + return any(other == a.atomic_symbol for _, a in self.atoms()) + return any(other == a for _, a in self.atoms()) def is_automorphic(self): """ diff --git a/chython/algorithms/standardize/molecule.py b/chython/algorithms/standardize/molecule.py index 92840c9b..65ce7e85 100644 --- a/chython/algorithms/standardize/molecule.py +++ b/chython/algorithms/standardize/molecule.py @@ -409,7 +409,7 @@ def clean_isotopes(self: 'MoleculeContainer') -> bool: Clean isotope marks from molecule. Return True if any isotope found. """ - isotopes = [x for x in self._atoms.values() if x.isotope] + isotopes = [a for _, a in self.atoms() if a.isotope] if isotopes: for i in isotopes: i._isotope = None diff --git a/chython/algorithms/stereo.py b/chython/algorithms/stereo.py index 1be70e3a..fd19fa75 100644 --- a/chython/algorithms/stereo.py +++ b/chython/algorithms/stereo.py @@ -155,11 +155,10 @@ def clean_stereo(self: 'MoleculeContainer'): """ Remove stereo data. """ - for a in self._atoms.values(): + for _, a in self.atoms(): a._stereo = None - for _, bs in self._bonds: - for b in bs.values(): - b._stereo = None # flush twice, but it should be still faster + for *_, b in self.bonds(): + b._stereo = None self.flush_cache(keep_sssr=True, keep_components=True) @cached_property diff --git a/chython/containers/molecule.py b/chython/containers/molecule.py index a8bd6094..984f845c 100644 --- a/chython/containers/molecule.py +++ b/chython/containers/molecule.py @@ -128,25 +128,25 @@ def molecular_charge(self) -> int: """ Total charge of molecule """ - return sum(a.charge for a in self._atoms.values()) + return sum(a.charge for _, a in self.atoms()) @cached_property def is_radical(self) -> bool: """ True if at least one atom is radical """ - return any(a.is_radical for a in self._atoms.values()) + return any(a.is_radical for _, a in self.atoms()) @cached_property def molecular_mass(self) -> float: h = _H().atomic_mass - return sum(a.atomic_mass + a.implicit_hydrogens * h for a in self._atoms.values()) + return sum(a.atomic_mass + a.implicit_hydrogens * h for _, a in self.atoms()) @cached_property def brutto(self) -> Dict[str, int]: """Counted atoms dict""" - c = Counter(a.atomic_symbol for a in self._atoms.values()) - c['H'] += sum(a.implicit_hydrogens for a in self._atoms.values()) + c = Counter(a.atomic_symbol for _, a in self.atoms()) + c['H'] += sum(a.implicit_hydrogens for _, a in self.atoms()) return dict(c) @cached_property diff --git a/chython/utils/grid.py b/chython/utils/grid.py index cc15d718..1a771718 100644 --- a/chython/utils/grid.py +++ b/chython/utils/grid.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2021-2023 Ramil Nugmanov +# Copyright 2021-2024 Ramil Nugmanov # Copyright 2024 Philippe Gantzer # This file is part of chython. # @@ -52,11 +52,10 @@ def grid_depict(molecules: List[MoleculeContainer], labels: Optional[List[str]] if clean2d: for m in molecules: if len(m) > 1: - values = m._plane.values() - min_x = min(x for x, _ in values) - max_x = max(x for x, _ in values) - min_y = min(y for _, y in values) - max_y = max(y for _, y in values) + min_x = min(a.x for _, a in m.atoms()) + max_x = max(a.x for _, a in m.atoms()) + min_y = min(a.y for _, a in m.atoms()) + max_y = max(a.y for _, a in m.atoms()) if max_y - min_y < .01 and max_x - min_x < 0.01: m.clean2d() @@ -65,12 +64,12 @@ def grid_depict(molecules: List[MoleculeContainer], labels: Optional[List[str]] for m in ms: if m is None: break - min_y = min(y for x, y in m._plane.values()) - max_y = max(y for x, y in m._plane.values()) + min_y = min(a.y for _, a in m.atoms()) + max_y = max(a.y for _, a in m.atoms()) h = max_y - min_y if row_height < h: # get height of row row_height = h - planes.append(m._plane.copy()) + planes.append([a.xy for _, a in m.atoms()]) max_x = 0. for m in ms: @@ -88,8 +87,10 @@ def grid_depict(molecules: List[MoleculeContainer], labels: Optional[List[str]] shift_y -= row_height + 4. * font_size # restore planes - for p, m in zip(planes, molecules): - m._plane = p + for m, p in zip(molecules, planes): + for (_, a), (x, y) in zip(m.atoms(), p): + a.x = x + a.y = y _width = shift_x - 1.5 * font_size _height = -shift_y - 1.5 * font_size diff --git a/chython/utils/retro.py b/chython/utils/retro.py index d94ec666..8fa1aaec 100644 --- a/chython/utils/retro.py +++ b/chython/utils/retro.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2021-2023 Ramil Nugmanov +# Copyright 2021-2024 Ramil Nugmanov # Copyright 2021 Alexander Sizov # This file is part of chython. # @@ -66,22 +66,21 @@ def retro_depict(tree: Tree, *, y_gap=3., x_gap=5., width=None, height=None, cle if clean2d: for m in column: if len(m) > 1: - values = m._plane.values() - min_x = min(x for x, _ in values) - max_x = max(x for x, _ in values) - min_y = min(y for _, y in values) - max_y = max(y for _, y in values) + min_x = min(a.x for _, a in m.atoms()) + max_x = max(a.x for _, a in m.atoms()) + min_y = min(a.y for _, a in m.atoms()) + max_y = max(a.y for _, a in m.atoms()) if max_y - min_y < .01 and max_x - min_x < 0.01: m.clean2d() - heights = [max(y for _, y in m._plane.values()) - min(y for _, y in m._plane.values()) for m in column] + heights = [max(a.y for _, a in m.atoms()) - min(a.y for _, a in m.atoms()) for m in column] y_shift = sum(heights) + y_gap * (len(heights) - 1) # column height with gaps if y_shift > c_max_y: c_max_y = y_shift y_shift /= 2. # center align for m, h in zip(column, heights): - plane = m._plane.copy() # backup + plane = [a.xy for _, a in m.atoms()] # backup mx = m._fix_plane_min(x_shift, -y_shift) if mx > c_max_x: c_max_x = mx @@ -92,7 +91,9 @@ def retro_depict(tree: Tree, *, y_gap=3., x_gap=5., width=None, height=None, cle y_shift -= h + y_gap render.append(m.depict(_embedding=True)[:5]) - m._plane = plane # restore + for (_, a), (x, y) in zip(m.atoms(), plane): # restore + a.x = x + a.y = y x_shift = c_max_x + x_gap # between columns gap last_layer = current_layer From 534c983eeb62ef8ef9f37a1b63badfea01750ee2 Mon Sep 17 00:00:00 2001 From: Ramil Nugmanov Date: Fri, 22 Nov 2024 10:59:34 +0100 Subject: [PATCH 39/67] fixed FWA --- chython/utils/free_wilson.py | 38 ++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/chython/utils/free_wilson.py b/chython/utils/free_wilson.py index e836aa6d..9ee415c3 100644 --- a/chython/utils/free_wilson.py +++ b/chython/utils/free_wilson.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2022 Ramil Nugmanov +# Copyright 2022-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -42,31 +42,31 @@ def fw_prepare_groups(core: Union[MoleculeContainer, QueryContainer], molecule: cs = set(core_map.values()) groups = molecule.substructure(molecule._atoms.keys() - cs, recalculate_hydrogens=False) gs = set(groups) - hs = molecule._hydrogens - hgs = groups._hydrogens - plane = molecule._plane cf = molecule.substructure(cs, recalculate_hydrogens=False) - chs = cf._hydrogens for n, m, b in molecule.bonds(): if n in cs: if m in gs: - h = H() - h._Core__isotope = reverse[n] # mark mapping to isotope - groups.add_bond(groups.add_atom(h, xy=plane[n]), m, b.copy()) - hgs[m] = hs[m] # restore H count - - cf.add_bond(cf.add_atom(h.copy(), xy=plane[m]), n, b.copy()) - chs[n] = hs[n] + a = molecule.atom(n) + h = H(x=a.x, y=a.y) + h._isotope = reverse[n] # mark mapping to isotope + groups.add_bond(groups.add_atom(h, _skip_calculation=True), m, b.copy(), _skip_calculation=True) + + a = molecule.atom(m) + h = H(x=a.x, y=a.y) + h._isotope = reverse[n] # mark mapping to isotope + cf.add_bond(cf.add_atom(h, _skip_calculation=True), n, b.copy(), _skip_calculation=True) elif m in cs and n in gs: - h = H() - h._Core__isotope = reverse[m] - groups.add_bond(groups.add_atom(h, xy=plane[m]), n, b.copy()) - hgs[n] = hs[n] - - cf.add_bond(cf.add_atom(h.copy(), xy=plane[n]), m, b.copy()) - chs[m] = hs[m] + a = molecule.atom(m) + h = H(x=a.x, y=a.y) + h._isotope = reverse[m] + groups.add_bond(groups.add_atom(h, _skip_calculation=True), n, b.copy(), _skip_calculation=True) + + a = molecule.atom(n) + h = H(x=a.x, y=a.y) + h._isotope = reverse[m] # mark mapping to isotope + cf.add_bond(cf.add_atom(h.copy(), _skip_calculation=True), n, b.copy(), _skip_calculation=True) groups = groups.split() groups.insert(0, cf) return groups From f3e302dbb20f28f02b9093c332da228643da7837 Mon Sep 17 00:00:00 2001 From: Ramil Nugmanov Date: Fri, 22 Nov 2024 11:02:06 +0100 Subject: [PATCH 40/67] cleaning --- chython/exceptions.py | 26 +------------------------- 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/chython/exceptions.py b/chython/exceptions.py index 891340fc..6f47d503 100644 --- a/chython/exceptions.py +++ b/chython/exceptions.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2017-2023 Ramil Nugmanov +# Copyright 2017-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -66,30 +66,6 @@ class InvalidAromaticRing(ValueError): """ -class IsConnectedAtom(Exception): - """ - Atom is already attached to graph - """ - - -class IsNotConnectedAtom(Exception): - """ - Atom is not attached to graph - """ - - -class IsConnectedBond(Exception): - """ - Bond is already attached to graph - """ - - -class IsNotConnectedBond(Exception): - """ - Bond is not attached to graph - """ - - class ValenceError(Exception): """ Atom has valence error From a12b4b35fe0e952331603a02094cf86372d02144 Mon Sep 17 00:00:00 2001 From: Ramil Nugmanov Date: Fri, 22 Nov 2024 21:20:13 +0100 Subject: [PATCH 41/67] fixes. better cache management --- chython/algorithms/standardize/reaction.py | 16 ++++++++-------- chython/containers/bonds.py | 4 ++-- chython/containers/graph.py | 2 ++ chython/containers/reaction.py | 7 ++++--- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/chython/algorithms/standardize/reaction.py b/chython/algorithms/standardize/reaction.py index 8f5ab282..e6e5ddff 100644 --- a/chython/algorithms/standardize/reaction.py +++ b/chython/algorithms/standardize/reaction.py @@ -50,7 +50,7 @@ def canonicalize(self: 'ReactionContainer', *, fix_mapping: bool = True, logging total.extend((-1, x, -1, m) for m, x in self.fix_groups_mapping(logging=True)) if total: - self.flush_cache() + self.flush_cache(keep_molecule_cache=True) if logging: return total return bool(total) @@ -76,7 +76,7 @@ def standardize(self: 'ReactionContainer', *, fix_mapping: bool = True, logging= total.extend((-1, x, -1, m) for m, x in self.fix_groups_mapping(logging=True)) if total: - self.flush_cache() + self.flush_cache(keep_molecule_cache=True) if logging: return total return bool(total) @@ -93,7 +93,7 @@ def thiele(self: 'ReactionContainer', *, fix_tautomers=True) -> bool: if m.thiele(fix_tautomers=fix_tautomers): total = True if total: - self.flush_cache() + self.flush_cache(keep_molecule_cache=True) return total def kekule(self: 'ReactionContainer', *, buffer_size=7) -> bool: @@ -108,7 +108,7 @@ def kekule(self: 'ReactionContainer', *, buffer_size=7) -> bool: if m.kekule(buffer_size=buffer_size): total = True if total: - self.flush_cache() + self.flush_cache(keep_molecule_cache=True) return total def clean_isotopes(self: 'ReactionContainer') -> bool: @@ -121,7 +121,7 @@ def clean_isotopes(self: 'ReactionContainer') -> bool: if m.clean_isotopes(): flag = True if flag: - self.flush_cache() + self.flush_cache(keep_molecule_cache=True) return flag def clean_stereo(self: 'ReactionContainer'): @@ -130,7 +130,7 @@ def clean_stereo(self: 'ReactionContainer'): """ for m in self.molecules(): m.clean_stereo() - self.flush_cache() + self.flush_cache(keep_molecule_cache=True) def check_valence(self: 'ReactionContainer') -> List[Tuple[int, Tuple[int, ...]]]: """ @@ -155,7 +155,7 @@ def implicify_hydrogens(self: 'ReactionContainer') -> int: for m in self.molecules(): total += m.implicify_hydrogens() if total: - self.flush_cache() + self.flush_cache(keep_molecule_cache=True) return total def explicify_hydrogens(self: 'ReactionContainer') -> int: @@ -203,7 +203,7 @@ def explicify_hydrogens(self: 'ReactionContainer') -> int: m.remap(remap) if total: - self.flush_cache() + self.flush_cache(keep_molecule_cache=True) return total def remove_reagents(self, *, keep_reagents: bool = False, mapping: bool = True) -> bool: diff --git a/chython/containers/bonds.py b/chython/containers/bonds.py index 43847d51..76e408ce 100644 --- a/chython/containers/bonds.py +++ b/chython/containers/bonds.py @@ -22,13 +22,13 @@ class Bond: __slots__ = ('_order', '_in_ring', '_stereo') - def __init__(self, order: int): + def __init__(self, order: int, *, stereo: Optional[bool] = None): if not isinstance(order, int): raise TypeError('invalid order value') elif order not in (1, 4, 2, 3, 8): raise ValueError('order should be from [1, 2, 3, 4, 8]') self._order = order - self._stereo = None + self._stereo = stereo def __eq__(self, other): if isinstance(other, int): diff --git a/chython/containers/graph.py b/chython/containers/graph.py index f644ebb6..51fb0412 100644 --- a/chython/containers/graph.py +++ b/chython/containers/graph.py @@ -165,6 +165,8 @@ def union(self, other: 'Graph', *, remap: bool = False, copy: bool = True): u = self.copy() if copy else self u._atoms.update(other._atoms) u._bonds.update(other._bonds) + if not copy: + self.flush_cache() return u def flush_cache(self): diff --git a/chython/containers/reaction.py b/chython/containers/reaction.py index 2d154924..ca13c5e5 100644 --- a/chython/containers/reaction.py +++ b/chython/containers/reaction.py @@ -146,10 +146,11 @@ def compose(self) -> CGRContainer: p = MoleculeContainer() return r ^ p - def flush_cache(self, **kwargs): + def flush_cache(self, keep_molecule_cache=False, **kwargs): self.__dict__.clear() - for m in self.molecules(): - m.flush_cache(**kwargs) + if not keep_molecule_cache: + for m in self.molecules(): + m.flush_cache(**kwargs) def pack(self, *, compressed=True, check=True) -> bytes: """ From 057d615e2b967fa941ba0352ef3a4f4a48c8acdc Mon Sep 17 00:00:00 2001 From: Ramil Nugmanov Date: Fri, 22 Nov 2024 22:30:13 +0100 Subject: [PATCH 42/67] saved --- chython/algorithms/isomorphism.py | 6 +- chython/reactor/base.py | 281 ++++++++++++++---------------- chython/reactor/transformer.py | 3 +- 3 files changed, 135 insertions(+), 155 deletions(-) diff --git a/chython/algorithms/isomorphism.py b/chython/algorithms/isomorphism.py index 23257e18..dc062591 100644 --- a/chython/algorithms/isomorphism.py +++ b/chython/algorithms/isomorphism.py @@ -295,10 +295,10 @@ def get_mapping(query, scope): array('I', [n in scope for n in other])) else: components = get_mapping = None + yield from self._get_mapping(other, automorphism_filter=automorphism_filter, searching_scope=searching_scope, + components=components, get_mapping=get_mapping) + return # todo: implement stereo - return self._get_mapping(other, automorphism_filter=automorphism_filter, searching_scope=searching_scope, - components=components, get_mapping=get_mapping) - atoms_stereo = self._atoms_stereo allenes_stereo = self._allenes_stereo cis_trans_stereo = self._cis_trans_stereo diff --git a/chython/reactor/base.py b/chython/reactor/base.py index fae981bc..ca128cbf 100644 --- a/chython/reactor/base.py +++ b/chython/reactor/base.py @@ -19,189 +19,170 @@ # from collections import defaultdict from itertools import product +from typing import Union from ..containers import MoleculeContainer, QueryContainer from ..containers.bonds import Bond -from ..periodictable import Element, ListElement, AnyElement, QueryElement +from ..periodictable import Element, ListElement, AnyElement, QueryElement, AnyMetal class BaseReactor: - def __init__(self, reactants, products, delete_atoms, fix_rings, fix_tautomers): - self.__to_delete = reactants.difference(products) if delete_atoms else () - - # prepare atoms patch - self.__elements = elements = {} - self.__hydrogens = hydrogens = {} - self.__variable = variable = [] - - atoms = defaultdict(dict) - if isinstance(products, MoleculeContainer): - # full replacement of atoms - for n, atom in products.atoms(): - elements[n] = atom.copy(hydrogens=True, stereo=True) - for n, atom in products.atoms(): - atoms[n].update(charge=atom.charge, is_radical=atom.is_radical) - if atom.atomic_number: # replace atom - elements[n] = Element.from_atomic_number(atom.atomic_number)(atom.isotope) - if n not in reactants and isinstance(products, MoleculeContainer): - atoms[n]['xy'] = atom.xy - if atom.implicit_hydrogens is not None: - hydrogens[n] = atom.implicit_hydrogens # save available H count - elif n not in reactants: - if not isinstance(atom, ListElement): - raise ValueError('New atom should be defined') - elements[n] = [Element.from_symbol(x)() for x in atom._elements] - variable.append(n) - else: # use atom from reactant - if not isinstance(atom, AnyElement): - raise ValueError('Only AnyElement can be used for matched atom propagation') - elements[n] = None - - if isinstance(products, QueryContainer): - bonds = [] - for n, m, b in products.bonds(): + def __init__(self, pattern, replacement, delete_atoms, fix_rings, fix_tautomers): + if isinstance(replacement, QueryContainer): + for n, a in replacement.atoms(): + if not isinstance(a, (AnyElement, QueryElement)): + raise TypeError('Unsupported query atom type') + for *_, b in replacement.bonds(): if len(b.order) > 1: - raise ValueError('bond list in patch not supported') - else: - bonds.append((n, m, Bond(b.order[0]))) - else: - bonds = [(n, m, b.copy()) for n, m, b in products.bonds()] + raise ValueError('Variable bond in replacement') - self.__bonds = bonds - self.__atom_attrs = dict(atoms) - self.__products = products - self.__fix_rings = fix_rings - self.__fix_tautomers = fix_tautomers + self._to_delete = {n for n, a in pattern.atoms() if not a.masked} - set(replacement) if delete_atoms else () + self._replacement = replacement + self._fix_rings = fix_rings + self._fix_tautomers = fix_tautomers def _patcher(self, structure: MoleculeContainer, mapping): - elements = self.__elements - variable = self.__variable - - new = self.__prepare_skeleton(structure, mapping) - self.__set_stereo(new, structure, mapping) + new = self._prepare_skeleton(structure, mapping) + self._fix_stereo(new, structure, mapping) - if not variable: - if self.__fix_rings: - new.kekule() # keeps stereo as is - if not new.thiele(fix_tautomers=self.__fix_tautomers): # fixes stereo if any ring aromatized - new.fix_stereo() - else: + if self._fix_rings: + new.kekule() # keeps stereo as is + if not new.thiele(fix_tautomers=self._fix_tautomers): # fixes stereo if any ring aromatized new.fix_stereo() - yield new else: - copy = new.copy() - if self.__fix_rings: - copy.kekule() - if not copy.thiele(fix_tautomers=self.__fix_tautomers): - copy.fix_stereo() - else: - copy.fix_stereo() - yield copy + new.fix_stereo() + yield new + + def _get_deleted(self, structure, mapping): + if not self._to_delete: + return set() - for atoms in product(*(elements[x][1:] for x in variable)): - copy = new.copy() - for n, atom in zip(variable, atoms): - n = mapping[n] - # replace atom - copy._atoms[n] = a = atom.copy() # noqa - a._attach_graph(copy, n) # noqa - copy.calc_implicit(n) # noqa - if self.__fix_rings: - copy.kekule() - if not copy.thiele(fix_tautomers=self.__fix_tautomers): - copy.fix_stereo() - else: - copy.fix_stereo() + bonds = structure._bonds + to_delete = {mapping[x] for x in self._to_delete} + # if deleted atoms have another path to remain fragment, the path is preserved + remain = set(mapping.values()).difference(to_delete) + delete, global_seen = set(), set() + for x in to_delete: + for n in bonds[x]: + if n in global_seen or n in remain: + continue + seen = {n} + global_seen.add(n) + stack = [x for x in bonds[n] if x not in global_seen] + while stack: + current = stack.pop() + if current in remain: + break + if current in to_delete: + continue + seen.add(current) + global_seen.add(current) + stack.extend([x for x in bonds[current] if x not in global_seen]) else: - copy.fix_stereo() - yield copy + delete.update(seen) - def __prepare_skeleton(self, structure, mapping): - elements = self.__elements - patch_hydrogens = self.__hydrogens - patch_bonds = self.__bonds - variable = self.__variable + to_delete.update(delete) + return to_delete + def _prepare_skeleton(self, structure, mapping): atoms = structure._atoms - plane = structure._plane bonds = structure._bonds - charges = structure._charges - radicals = structure._radicals - hydrogens = structure._hydrogens - - to_delete = {mapping[x] for x in self.__to_delete} - if to_delete: - # if deleted atoms have another path to remain fragment, the path is preserved - remain = set(mapping.values()).difference(to_delete) - delete, global_seen = set(), set() - for x in to_delete: - for n in bonds[x]: - if n in global_seen or n in remain: - continue - seen = {n} - global_seen.add(n) - stack = [x for x in bonds[n] if x not in global_seen] - while stack: - current = stack.pop() - if current in remain: - break - if current in to_delete: - continue - seen.add(current) - global_seen.add(current) - stack.extend([x for x in bonds[current] if x not in global_seen]) - else: - delete.update(seen) - - to_delete.update(delete) + to_delete = self._get_deleted(structure, mapping) new = structure.__class__() - keep_hydrogens = {} + natoms = new._atoms + nbonds = new._bonds max_atom = max(atoms) - for n, atom in self.__atom_attrs.items(): - if n in mapping: # add matched atoms - m = mapping[n] - e = elements[n] - if e is None: - e = atoms[m] - new.add_atom(e.copy(), m, xy=plane[m], _skip_hydrogen_calculation=True, **atom) - else: # new atoms - max_atom += 1 - if n in variable: - # use first from the list - mapping[n] = new.add_atom(elements[n][0].copy(), max_atom, _skip_hydrogen_calculation=True, **atom) + stereo_atoms = [] + stereo_bonds = [] + + for n, a in self._replacement.atoms(): + if isinstance(a, AnyElement): + if n := mapping.get(n): + # keep matched atom type and isotope + e = atoms[n].copy(stereo=True) + e.charge = a.charge + e.is_radical = a.is_radical + if a.stereo is not None: # override stereo + e._stereo = a.stereo + elif e.stereo is not None: # keep original stereo + stereo_atoms.append(n) # mark for stereo fix + natoms[n] = e + nbonds[n] = {} + else: + raise ValueError("AnyElement doesn't match to pattern") + else: # QueryElement or Element + a: Union[QueryElement, Element] # typehint + e = Element.from_atomic_number(a.atomic_number) + e = e(a.isotope, charge=a.charge, is_radical=a.is_radical, stereo=a.stereo) + if not (m := mapping.get(n)): # new atom + m = max_atom + 1 + max_atom += 1 + mapping[n] = m + if isinstance(a, Element): + e._implicit_hydrogens = a.implicit_hydrogens # keep H count from patch + e.x = a.x # keep coordinates from patch + e.y = a.y + elif len(a.implicit_hydrogens) == 1: + e._implicit_hydrogens = a.implicit_hydrogens[0] + elif a.implicit_hydrogens: + raise ValueError('Query element in patch has more than one implicit hydrogen') + else: # existing atoms + b = atoms[m] + e.x = b.x # preserve existing coordinates + e.y = b.y + if a.stereo is None and b.stereo is not None: # keep original stereo + e._stereo = b.stereo + stereo_atoms.append(m) + natoms[m] = e + nbonds[m] = {} + + # preserve connectivity order + for n, bs in self._replacement._bonds.items(): + n = mapping[n] + for m, b in bs.items(): + m = mapping[m] + if n in nbonds[m]: + nbonds[n][m] = nbonds[m][n] else: - mapping[n] = new.add_atom(elements[n].copy(), max_atom, _skip_hydrogen_calculation=True, **atom) - if n in patch_hydrogens: # keep patch aromatic atoms hydrogens count - keep_hydrogens[max_atom] = patch_hydrogens[n] + nbonds[n][m] = b = Bond(int(b), stereo=b.stereo) + if b.stereo is None: + if not (nb := bonds.get(n)): + continue + if not (mb := nb.get(m)): + continue + if mb.stereo is None: + continue + # original structure has stereo bond + b._stereo = mb.stereo + stereo_bonds.append((n, m)) patch_atoms = set(new) # don't move! - for n, atom in structure.atoms(): # add unmatched atoms + for n, a in atoms.items(): # add unmatched or masked atoms if n not in patch_atoms and n not in to_delete: - new.add_atom(atom.copy(), n, charge=charges[n], is_radical=radicals[n], xy=plane[n], - _skip_hydrogen_calculation=True) - keep_hydrogens[n] = hydrogens[n] # keep hydrogens on unmatched atoms as is. - - for n, m, bond in patch_bonds: # add patch bonds - new.add_bond(mapping[n], mapping[m], bond.copy(), _skip_hydrogen_calculation=True) + natoms[n] = a.copy(hydrogens=True, stereo=True) + nbonds[n] = {} - for n, m_bond in bonds.items(): + for n, bs in bonds.items(): if n in to_delete: # atoms for removing continue - to_delete.add(n) # reuse to_delete set for seen atoms - for m, bond in m_bond.items(): + for m, b in bs.items(): # ignore deleted atoms and patch atoms if m in to_delete or n in patch_atoms and m in patch_atoms: continue - new.add_bond(n, m, bond.copy(), _skip_hydrogen_calculation=True) - - # fix hydrogens count. - new._hydrogens.update(keep_hydrogens) # noqa - for n in new: - if n not in keep_hydrogens: - new.calc_implicit(n) # noqa + elif n in nbonds[m]: + nbonds[n][m] = nbonds[m][n] + else: + nbonds[n][m] = b.copy(stereo=True) + if b.stereo is not None and (n in patch_atoms or m in patch_atoms): + stereo_bonds.append((n, m)) + + for n, a in new.atoms(): + if a.implicit_hydrogens is None: + new.calc_implicit(n) + new.calc_labels() return new - def __set_stereo(self, new, structure, mapping): + def _fix_stereo(self, new, structure, mapping): products = self.__products stereo_override = set() r_mapping = {m: n for n, m in mapping.items()} diff --git a/chython/reactor/transformer.py b/chython/reactor/transformer.py index d2be81e7..5852cc47 100644 --- a/chython/reactor/transformer.py +++ b/chython/reactor/transformer.py @@ -47,8 +47,7 @@ def __init__(self, pattern: QueryContainer, replacement: Union[MoleculeContainer self.replacement = replacement self.__automorphism_filter = automorphism_filter self.__copy_metadata = copy_metadata - super().__init__({n for n, h in pattern._masked.items() if not h}, replacement, delete_atoms, - fix_aromatic_rings, fix_tautomers) + super().__init__(pattern, replacement, delete_atoms, fix_aromatic_rings, fix_tautomers) def __call__(self, structure: MoleculeContainer): if not isinstance(structure, MoleculeContainer): From f294c3c44f644eab3c0db730374df5656ee3a923 Mon Sep 17 00:00:00 2001 From: Ramil Nugmanov Date: Sat, 23 Nov 2024 17:52:47 +0100 Subject: [PATCH 43/67] Refactor: Update mdl module import paths Renamed '_mdl' directory to 'mdl' and updated all corresponding import statements. This change improves code readability and aligns directory naming conventions across the project. --- chython/files/MRVrw.py | 2 +- chython/files/RDFrw.py | 4 ++-- chython/files/SDFrw.py | 2 +- chython/files/{_mdl => mdl}/__init__.py | 0 chython/files/{_mdl => mdl}/emol.py | 0 chython/files/{_mdl => mdl}/erxn.py | 0 chython/files/{_mdl => mdl}/mol.py | 0 chython/files/{_mdl => mdl}/read.py | 0 chython/files/{_mdl => mdl}/rxn.py | 0 chython/files/{_mdl => mdl}/stereo.py | 0 chython/files/{_mdl => mdl}/write.py | 0 11 files changed, 4 insertions(+), 4 deletions(-) rename chython/files/{_mdl => mdl}/__init__.py (100%) rename chython/files/{_mdl => mdl}/emol.py (100%) rename chython/files/{_mdl => mdl}/erxn.py (100%) rename chython/files/{_mdl => mdl}/mol.py (100%) rename chython/files/{_mdl => mdl}/read.py (100%) rename chython/files/{_mdl => mdl}/rxn.py (100%) rename chython/files/{_mdl => mdl}/stereo.py (100%) rename chython/files/{_mdl => mdl}/write.py (100%) diff --git a/chython/files/MRVrw.py b/chython/files/MRVrw.py index ab969b21..543f33dd 100644 --- a/chython/files/MRVrw.py +++ b/chython/files/MRVrw.py @@ -24,7 +24,7 @@ from typing import Union, List, Iterator, Dict, Optional from ._convert import create_molecule, create_reaction from ._mapping import postprocess_parsed_molecule, postprocess_parsed_reaction -from ._mdl import postprocess_molecule +from .mdl import postprocess_molecule from ..containers import MoleculeContainer, ReactionContainer from ..exceptions import EmptyMolecule, EmptyReaction diff --git a/chython/files/RDFrw.py b/chython/files/RDFrw.py index 62bebbae..9e8a20f2 100644 --- a/chython/files/RDFrw.py +++ b/chython/files/RDFrw.py @@ -25,8 +25,8 @@ from sys import platform from time import strftime from typing import Union, Dict, List -from ._mdl import (MDLRead, MOLWrite, EMOLWrite, parse_mol_v2000, parse_mol_v3000, parse_rxn_v2000, parse_rxn_v3000, - postprocess_molecule) +from .mdl import (MDLRead, MOLWrite, EMOLWrite, parse_mol_v2000, parse_mol_v3000, parse_rxn_v2000, parse_rxn_v3000, + postprocess_molecule) from ._convert import create_molecule, create_reaction from ._mapping import postprocess_parsed_molecule, postprocess_parsed_reaction from ..containers import ReactionContainer, MoleculeContainer diff --git a/chython/files/SDFrw.py b/chython/files/SDFrw.py index 04edb0ad..232f3fe6 100644 --- a/chython/files/SDFrw.py +++ b/chython/files/SDFrw.py @@ -23,7 +23,7 @@ from subprocess import check_output from sys import platform from typing import Optional, List -from ._mdl import MDLRead, MOLWrite, EMOLWrite, parse_mol_v2000, parse_mol_v3000, postprocess_molecule +from .mdl import MDLRead, MOLWrite, EMOLWrite, parse_mol_v2000, parse_mol_v3000, postprocess_molecule from ._convert import create_molecule from ._mapping import postprocess_parsed_molecule from ..containers import MoleculeContainer diff --git a/chython/files/_mdl/__init__.py b/chython/files/mdl/__init__.py similarity index 100% rename from chython/files/_mdl/__init__.py rename to chython/files/mdl/__init__.py diff --git a/chython/files/_mdl/emol.py b/chython/files/mdl/emol.py similarity index 100% rename from chython/files/_mdl/emol.py rename to chython/files/mdl/emol.py diff --git a/chython/files/_mdl/erxn.py b/chython/files/mdl/erxn.py similarity index 100% rename from chython/files/_mdl/erxn.py rename to chython/files/mdl/erxn.py diff --git a/chython/files/_mdl/mol.py b/chython/files/mdl/mol.py similarity index 100% rename from chython/files/_mdl/mol.py rename to chython/files/mdl/mol.py diff --git a/chython/files/_mdl/read.py b/chython/files/mdl/read.py similarity index 100% rename from chython/files/_mdl/read.py rename to chython/files/mdl/read.py diff --git a/chython/files/_mdl/rxn.py b/chython/files/mdl/rxn.py similarity index 100% rename from chython/files/_mdl/rxn.py rename to chython/files/mdl/rxn.py diff --git a/chython/files/_mdl/stereo.py b/chython/files/mdl/stereo.py similarity index 100% rename from chython/files/_mdl/stereo.py rename to chython/files/mdl/stereo.py diff --git a/chython/files/_mdl/write.py b/chython/files/mdl/write.py similarity index 100% rename from chython/files/_mdl/write.py rename to chython/files/mdl/write.py From df0b08c3baaebe2399d4a2635eded5c9813d38d2 Mon Sep 17 00:00:00 2001 From: Ramil Nugmanov Date: Sat, 23 Nov 2024 17:55:22 +0100 Subject: [PATCH 44/67] Fixed stereo parsing bug --- chython/files/daylight/parser.py | 6 +++++- chython/files/daylight/smiles.py | 6 ++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/chython/files/daylight/parser.py b/chython/files/daylight/parser.py index f685a359..42d1583b 100644 --- a/chython/files/daylight/parser.py +++ b/chython/files/daylight/parser.py @@ -37,6 +37,7 @@ def parser(tokens, strong_cycle): last_num = 0 stack = [] cycles = {} + stereo_atoms = {} stereo_bonds = defaultdict(dict) previous = None @@ -135,6 +136,8 @@ def parser(tokens, strong_cycle): # else bt == 4 - skip dot previous = None + if 'stereo' in token: + stereo_atoms[atom_num] = token.pop('stereo') atoms.append(token) atoms_types.append(token_type) last_num = atom_num @@ -147,7 +150,8 @@ def parser(tokens, strong_cycle): elif previous: raise IncorrectSmiles('bond on the end') - return {'atoms': atoms, 'bonds': bonds, 'order': order, 'stereo_bonds': stereo_bonds, 'log': log} + return {'atoms': atoms, 'bonds': bonds, 'order': order, 'stereo_atoms': stereo_atoms, + 'stereo_bonds': stereo_bonds, 'log': log} __all__ = ['parser'] diff --git a/chython/files/daylight/smiles.py b/chython/files/daylight/smiles.py index 442195f8..60630ba0 100644 --- a/chython/files/daylight/smiles.py +++ b/chython/files/daylight/smiles.py @@ -170,9 +170,7 @@ def postprocess_molecule(molecule, data, *, ignore_stereo=False): if ignore_stereo: return - - stereo_atoms = [(n, s) for n, a in enumerate(data['atoms']) if (s := a.get('stereo')) is not None] - if not stereo_atoms and not data['stereo_bonds']: + elif not data['stereo_atoms'] or not data['stereo_bonds']: return atoms = molecule._atoms @@ -185,7 +183,7 @@ def postprocess_molecule(molecule, data, *, ignore_stereo=False): log = [] stereo = [] - for i, s in stereo_atoms: + for i, s in data['stereo_atoms'].items(): n = mapping[i] if not i and atoms[n].implicit_hydrogens: # first atom in smiles has reversed chiral mark s = not s From 3e1799e28ecace6e12f9771e96cad5a580f362e1 Mon Sep 17 00:00:00 2001 From: Ramil Nugmanov Date: Sat, 23 Nov 2024 17:59:35 +0100 Subject: [PATCH 45/67] Reactors refactoring started --- chython/reactor/base.py | 204 +++++++++++++-------------------- chython/reactor/transformer.py | 17 ++- 2 files changed, 86 insertions(+), 135 deletions(-) diff --git a/chython/reactor/base.py b/chython/reactor/base.py index ca128cbf..acfe4cc0 100644 --- a/chython/reactor/base.py +++ b/chython/reactor/base.py @@ -17,12 +17,10 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from collections import defaultdict -from itertools import product from typing import Union from ..containers import MoleculeContainer, QueryContainer from ..containers.bonds import Bond -from ..periodictable import Element, ListElement, AnyElement, QueryElement, AnyMetal +from ..periodictable import Element, AnyElement, QueryElement class BaseReactor: @@ -40,18 +38,6 @@ def __init__(self, pattern, replacement, delete_atoms, fix_rings, fix_tautomers) self._fix_rings = fix_rings self._fix_tautomers = fix_tautomers - def _patcher(self, structure: MoleculeContainer, mapping): - new = self._prepare_skeleton(structure, mapping) - self._fix_stereo(new, structure, mapping) - - if self._fix_rings: - new.kekule() # keeps stereo as is - if not new.thiele(fix_tautomers=self._fix_tautomers): # fixes stereo if any ring aromatized - new.fix_stereo() - else: - new.fix_stereo() - yield new - def _get_deleted(self, structure, mapping): if not self._to_delete: return set() @@ -83,7 +69,7 @@ def _get_deleted(self, structure, mapping): to_delete.update(delete) return to_delete - def _prepare_skeleton(self, structure, mapping): + def _patcher(self, structure: MoleculeContainer, mapping): atoms = structure._atoms bonds = structure._bonds @@ -95,156 +81,122 @@ def _prepare_skeleton(self, structure, mapping): stereo_atoms = [] stereo_bonds = [] - for n, a in self._replacement.atoms(): - if isinstance(a, AnyElement): - if n := mapping.get(n): + for n, ra in self._replacement.atoms(): + if isinstance(ra, AnyElement): + if m := mapping.get(n): # keep matched atom type and isotope - e = atoms[n].copy(stereo=True) - e.charge = a.charge - e.is_radical = a.is_radical - if a.stereo is not None: # override stereo - e._stereo = a.stereo - elif e.stereo is not None: # keep original stereo - stereo_atoms.append(n) # mark for stereo fix - natoms[n] = e - nbonds[n] = {} + sa = atoms[m] + a = sa.copy() + a.charge = ra.charge + a.is_radical = ra.is_radical + if ra.stereo is not None: # override stereo + a._stereo = ra.stereo + elif sa.stereo is not None: # keep original stereo + stereo_atoms.append(m) # mark for stereo fix else: raise ValueError("AnyElement doesn't match to pattern") else: # QueryElement or Element - a: Union[QueryElement, Element] # typehint - e = Element.from_atomic_number(a.atomic_number) - e = e(a.isotope, charge=a.charge, is_radical=a.is_radical, stereo=a.stereo) + ra: Union[QueryElement, Element] # typehint + e = Element.from_atomic_number(ra.atomic_number) + a = e(ra.isotope, charge=ra.charge, is_radical=ra.is_radical) if not (m := mapping.get(n)): # new atom m = max_atom + 1 max_atom += 1 mapping[n] = m - if isinstance(a, Element): - e._implicit_hydrogens = a.implicit_hydrogens # keep H count from patch - e.x = a.x # keep coordinates from patch - e.y = a.y - elif len(a.implicit_hydrogens) == 1: - e._implicit_hydrogens = a.implicit_hydrogens[0] - elif a.implicit_hydrogens: + a._stereo = ra.stereo # keep stereo from patch for new atoms + if isinstance(ra, Element): + a._implicit_hydrogens = ra.implicit_hydrogens # keep H count from patch + a.x = ra.x # keep coordinates from patch + a.y = ra.y + elif len(ra.implicit_hydrogens) == 1: # keep H count from patch + a._implicit_hydrogens = ra.implicit_hydrogens[0] + elif ra.implicit_hydrogens: raise ValueError('Query element in patch has more than one implicit hydrogen') else: # existing atoms - b = atoms[m] - e.x = b.x # preserve existing coordinates - e.y = b.y - if a.stereo is None and b.stereo is not None: # keep original stereo - e._stereo = b.stereo + sa = atoms[m] + a.x = sa.x # preserve existing coordinates + a.y = sa.y + if ra.stereo is not None: + a._stereo = ra.stereo + elif sa.stereo is not None: # keep original stereo stereo_atoms.append(m) - natoms[m] = e - nbonds[m] = {} + natoms[m] = a + nbonds[m] = {} # preserve connectivity order for n, bs in self._replacement._bonds.items(): n = mapping[n] - for m, b in bs.items(): + for m, rb in bs.items(): m = mapping[m] - if n in nbonds[m]: + if n in nbonds[m]: # back-link nbonds[n][m] = nbonds[m][n] else: - nbonds[n][m] = b = Bond(int(b), stereo=b.stereo) - if b.stereo is None: - if not (nb := bonds.get(n)): - continue - if not (mb := nb.get(m)): - continue - if mb.stereo is None: - continue - # original structure has stereo bond - b._stereo = mb.stereo + nbonds[n][m] = b = Bond(int(rb)) + if rb.stereo is not None: # override stereo + b._stereo = rb.stereo + elif (sbn := bonds.get(n)) is None or (sb := sbn.get(m)) is None or sb.stereo is None: + continue + else: # original structure has stereo bond stereo_bonds.append((n, m)) - patch_atoms = set(new) # don't move! + patched_atoms = set(new) for n, a in atoms.items(): # add unmatched or masked atoms - if n not in patch_atoms and n not in to_delete: + if n not in patched_atoms and n not in to_delete: natoms[n] = a.copy(hydrogens=True, stereo=True) nbonds[n] = {} - for n, bs in bonds.items(): + for n, bs in bonds.items(): # preserve connectivity order for keeping stereo labels as is if n in to_delete: # atoms for removing continue for m, b in bs.items(): # ignore deleted atoms and patch atoms - if m in to_delete or n in patch_atoms and m in patch_atoms: + if m in to_delete or n in patched_atoms and m in patched_atoms: continue - elif n in nbonds[m]: + elif n in nbonds[m]: # back-link nbonds[n][m] = nbonds[m][n] + elif b.stereo is not None and (n in patched_atoms or m in patched_atoms): + # unmatched/masked atoms to patched atoms linker bonds + # stereo label should be recalculated + nbonds[n][m] = b.copy() + stereo_bonds.append((n, m)) else: nbonds[n][m] = b.copy(stereo=True) - if b.stereo is not None and (n in patch_atoms or m in patch_atoms): - stereo_bonds.append((n, m)) for n, a in new.atoms(): if a.implicit_hydrogens is None: new.calc_implicit(n) new.calc_labels() - return new - def _fix_stereo(self, new, structure, mapping): - products = self.__products - stereo_override = set() - r_mapping = {m: n for n, m in mapping.items()} - - # set patch atoms stereo - for n, s in products._atoms_stereo.items(): - m = mapping[n] - new._atoms_stereo[m] = products._translate_tetrahedron_sign(n, [r_mapping[x] for x in - new.stereogenic_tetrahedrons[m]], s) - stereo_override.add(m) - - for n, s in products._allenes_stereo.items(): - m = mapping[n] - t1, t2, *_ = new.stereogenic_allenes[m] - new._allenes_stereo[m] = products._translate_allene_sign(n, r_mapping[t1], r_mapping[t2], s) - stereo_override.add(m) - - for (n, m), s in products._cis_trans_stereo.items(): - nm = (mapping[n], mapping[m]) - try: - t1, t2, *_ = new.stereogenic_cis_trans[nm] - except KeyError: - nm = nm[::-1] - t2, t1, *_ = new.stereogenic_cis_trans[nm] - new._cis_trans_stereo[nm] = products._translate_cis_trans_sign(n, m, r_mapping[t1], r_mapping[t2], s) - stereo_override.update(nm) - - # set unmatched part stereo and not overridden by patch. - for n, s in structure._atoms_stereo.items(): - if n in stereo_override or n not in new.stereogenic_tetrahedrons or \ - new._bonds[n].keys() != structure._bonds[n].keys(): - # skip atoms with changed neighbors - continue - new._atoms_stereo[n] = structure._translate_tetrahedron_sign(n, new.stereogenic_tetrahedrons[n], s) - - for n, s in structure._allenes_stereo.items(): - if n in stereo_override or n not in new.stereogenic_allenes or \ - set(new.stereogenic_allenes[n]) != set(structure.stereogenic_allenes[n]): - # skip changed allenes - continue - t1, t2, *_ = new.stereogenic_allenes[n] - new._allenes_stereo[n] = structure._translate_allene_sign(n, t1, t2, s) + # translate stereo sign from old order to new order + for n in stereo_atoms: + if n in new.stereogenic_tetrahedrons: + if bonds[n].keys() != nbonds[n].keys(): + # flush stereo from reaction center. should be explicitly set in replacement. + continue + s = new._translate_tetrahedron_sign(n, structure.stereogenic_tetrahedrons[n], atoms[n].stereo) + natoms[n]._stereo = s + elif n in new.stereogenic_allenes: + if set(new.stereogenic_allenes[n]) != set(structure.stereogenic_allenes[n]): + # flush stereo for changed allene substituents + continue + s = new._translate_allene_sign(n, *structure.stereogenic_allenes[n][:2], atoms[n].stereo) + natoms[n]._stereo = s + # else: ignore label - for nm, s in structure._cis_trans_stereo.items(): - n, m = nm - if n in stereo_override or m in stereo_override: - continue - env = structure.stereogenic_cis_trans[nm] - try: - new_env = new.stereogenic_cis_trans[nm] - except KeyError: - nm = nm[::-1] - try: - new_env = new.stereogenic_cis_trans[nm] - except KeyError: + for n, m in stereo_bonds: + if (t12 := new._stereo_cis_trans_terminals.get(n, True)) == new._stereo_cis_trans_terminals.get(m, False): + if set(new.stereogenic_cis_trans[t12]) != set(structure.stereogenic_cis_trans[t12]): continue - t2, t1, *_ = new_env - else: - t1, t2, *_ = new_env - if set(env) != set(new_env): - continue - new._cis_trans_stereo[nm] = structure._translate_cis_trans_sign(n, m, t1, t2, s) + new._translate_cis_trans_sign(*t12, *structure.stereogenic_cis_trans[t12][:2], bonds[n][m].stereo) + # else: ignore label + + if self._fix_rings: + new.kekule() # keeps stereo as is + if not new.thiele(fix_tautomers=self._fix_tautomers): # fixes stereo if any ring aromatized + new.fix_stereo() + else: + new.fix_stereo() + return new __all__ = ['BaseReactor'] diff --git a/chython/reactor/transformer.py b/chython/reactor/transformer.py index 5852cc47..1ca11099 100644 --- a/chython/reactor/transformer.py +++ b/chython/reactor/transformer.py @@ -43,21 +43,20 @@ def __init__(self, pattern: QueryContainer, replacement: Union[MoleculeContainer if not isinstance(pattern, QueryContainer) or not isinstance(replacement, (MoleculeContainer, QueryContainer)): raise TypeError('invalid params') - self.pattern = pattern - self.replacement = replacement - self.__automorphism_filter = automorphism_filter - self.__copy_metadata = copy_metadata + self._pattern = pattern + self._automorphism_filter = automorphism_filter + self._copy_metadata = copy_metadata super().__init__(pattern, replacement, delete_atoms, fix_aromatic_rings, fix_tautomers) def __call__(self, structure: MoleculeContainer): if not isinstance(structure, MoleculeContainer): raise TypeError('only Molecules possible') - for mapping in self.pattern.get_mapping(structure, automorphism_filter=self.__automorphism_filter): - for transformed in self._patcher(structure, mapping): - if self.__copy_metadata: - transformed.meta.update(structure.meta) - yield transformed + for mapping in self._pattern.get_mapping(structure, automorphism_filter=self._automorphism_filter): + transformed = self._patcher(structure, mapping) + if self._copy_metadata: + transformed.meta.update(structure.meta) + yield transformed __all__ = ['Transformer'] From 907ed2cac0dacb79f8c155b405b121e031358f8e Mon Sep 17 00:00:00 2001 From: Ramil Nugmanov Date: Sat, 23 Nov 2024 18:47:50 +0100 Subject: [PATCH 46/67] fixes --- chython/files/daylight/parser.py | 2 +- chython/files/daylight/smiles.py | 2 +- chython/reactor/base.py | 44 ++++++++++++++++++-------------- 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/chython/files/daylight/parser.py b/chython/files/daylight/parser.py index 42d1583b..f45d020c 100644 --- a/chython/files/daylight/parser.py +++ b/chython/files/daylight/parser.py @@ -136,7 +136,7 @@ def parser(tokens, strong_cycle): # else bt == 4 - skip dot previous = None - if 'stereo' in token: + if token.get('stereo') is not None: stereo_atoms[atom_num] = token.pop('stereo') atoms.append(token) atoms_types.append(token_type) diff --git a/chython/files/daylight/smiles.py b/chython/files/daylight/smiles.py index 60630ba0..293597ac 100644 --- a/chython/files/daylight/smiles.py +++ b/chython/files/daylight/smiles.py @@ -170,7 +170,7 @@ def postprocess_molecule(molecule, data, *, ignore_stereo=False): if ignore_stereo: return - elif not data['stereo_atoms'] or not data['stereo_bonds']: + elif not data['stereo_atoms'] and not data['stereo_bonds']: return atoms = molecule._atoms diff --git a/chython/reactor/base.py b/chython/reactor/base.py index acfe4cc0..ca39685a 100644 --- a/chython/reactor/base.py +++ b/chython/reactor/base.py @@ -81,6 +81,8 @@ def _patcher(self, structure: MoleculeContainer, mapping): stereo_atoms = [] stereo_bonds = [] + # let's preserve connectivity order from replacement to keep stereo signs as is. + # stereo labels from original structure will be recalculated after full molecule construction. for n, ra in self._replacement.atoms(): if isinstance(ra, AnyElement): if m := mapping.get(n): @@ -140,10 +142,17 @@ def _patcher(self, structure: MoleculeContainer, mapping): stereo_bonds.append((n, m)) patched_atoms = set(new) - for n, a in atoms.items(): # add unmatched or masked atoms + for n, sa in atoms.items(): # add unmatched or masked atoms if n not in patched_atoms and n not in to_delete: - natoms[n] = a.copy(hydrogens=True, stereo=True) + natoms[n] = a = sa.copy(hydrogens=True) nbonds[n] = {} + if sa.stereo is not None: + # in case of allenes label can disappear/change, thus, requires recalculation + # for tetrahedrons label can be stored as is + if len(bonds[n]) >= 3: + a._stereo = sa.stereo + else: + stereo_atoms.append(n) for n, bs in bonds.items(): # preserve connectivity order for keeping stereo labels as is if n in to_delete: # atoms for removing @@ -154,13 +163,11 @@ def _patcher(self, structure: MoleculeContainer, mapping): continue elif n in nbonds[m]: # back-link nbonds[n][m] = nbonds[m][n] - elif b.stereo is not None and (n in patched_atoms or m in patched_atoms): - # unmatched/masked atoms to patched atoms linker bonds - # stereo label should be recalculated - nbonds[n][m] = b.copy() - stereo_bonds.append((n, m)) else: - nbonds[n][m] = b.copy(stereo=True) + nbonds[n][m] = b.copy() + if b.stereo is not None: + # stereo label should be recalculated + stereo_bonds.append((n, m)) for n, a in new.atoms(): if a.implicit_hydrogens is None: @@ -170,24 +177,23 @@ def _patcher(self, structure: MoleculeContainer, mapping): # translate stereo sign from old order to new order for n in stereo_atoms: if n in new.stereogenic_tetrahedrons: - if bonds[n].keys() != nbonds[n].keys(): + if bonds[n].keys() == nbonds[n].keys(): # flush stereo from reaction center. should be explicitly set in replacement. - continue - s = new._translate_tetrahedron_sign(n, structure.stereogenic_tetrahedrons[n], atoms[n].stereo) - natoms[n]._stereo = s + s = new._translate_tetrahedron_sign(n, structure.stereogenic_tetrahedrons[n], atoms[n].stereo) + natoms[n]._stereo = s elif n in new.stereogenic_allenes: - if set(new.stereogenic_allenes[n]) != set(structure.stereogenic_allenes[n]): + if set(new.stereogenic_allenes[n]) == set(structure.stereogenic_allenes[n]): # flush stereo for changed allene substituents - continue - s = new._translate_allene_sign(n, *structure.stereogenic_allenes[n][:2], atoms[n].stereo) - natoms[n]._stereo = s + s = new._translate_allene_sign(n, *structure.stereogenic_allenes[n][:2], atoms[n].stereo) + natoms[n]._stereo = s # else: ignore label for n, m in stereo_bonds: if (t12 := new._stereo_cis_trans_terminals.get(n, True)) == new._stereo_cis_trans_terminals.get(m, False): - if set(new.stereogenic_cis_trans[t12]) != set(structure.stereogenic_cis_trans[t12]): - continue - new._translate_cis_trans_sign(*t12, *structure.stereogenic_cis_trans[t12][:2], bonds[n][m].stereo) + if set(new.stereogenic_cis_trans[t12]) == set(env := structure.stereogenic_cis_trans[t12]): + # connected to cumulenes atoms should be the same + s = new._translate_cis_trans_sign(*t12, *env[:2], bonds[n][m].stereo) + nbonds[n][m]._stereo = s # else: ignore label if self._fix_rings: From 8d3994eed0e186e88dfe4866a55cb2f7752843e4 Mon Sep 17 00:00:00 2001 From: Ramil Nugmanov Date: Wed, 11 Dec 2024 09:22:44 +0100 Subject: [PATCH 47/67] WIP: pach support fixes --- chython/__init__.py | 4 +- chython/algorithms/stereo.py | 4 ++ chython/containers/__init__.py | 7 ++- chython/containers/_pack.pyx | 107 +++++++++++++++++---------------- 4 files changed, 65 insertions(+), 57 deletions(-) diff --git a/chython/__init__.py b/chython/__init__.py index 0c860191..b695b7b2 100644 --- a/chython/__init__.py +++ b/chython/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2014-2023 Ramil Nugmanov +# Copyright 2014-2024 Ramil Nugmanov # Copyright 2014-2019 Timur Madzhidov tmadzhidov@gmail.com features and API discussion # Copyright 2014-2019 Alexandre Varnek base idea of CGR approach # This file is part of chython. @@ -25,7 +25,7 @@ from .utils import * -pickle_cache = False # store cached attributes in pickle torch_device = 'cpu' # AAM model device. Change before first `reset_mapping` call! + __all__ = [] diff --git a/chython/algorithms/stereo.py b/chython/algorithms/stereo.py index fd19fa75..80f87049 100644 --- a/chython/algorithms/stereo.py +++ b/chython/algorithms/stereo.py @@ -630,6 +630,10 @@ def fix_stereo(self: 'MoleculeContainer'): old_stereo = fail_stereo self.flush_stereo_cache() + @cached_property + def _cis_trans_count(self) -> int: + return sum(b.stereo is not None for *_, b in self.bonds()) + @cached_property def _stereo_cis_trans_centers(self) -> Dict[int, Tuple[int, int]]: """ diff --git a/chython/containers/__init__.py b/chython/containers/__init__.py index 6658eeaa..0f2f3dbb 100644 --- a/chython/containers/__init__.py +++ b/chython/containers/__init__.py @@ -36,7 +36,8 @@ def unpach(data: bytes, /, *, compressed=True) -> Union[MoleculeContainer, React return ReactionContainer.unpack(data, compressed=False) +unpack = unpach + + __all__ = [x for x in locals() if x.endswith('Container')] -__all__.append('Bond') -__all__.append('QueryBond') -__all__.append('unpach') +__all__.extend(['Bond', 'QueryBond', 'unpack', 'unpach']) diff --git a/chython/containers/_pack.pyx b/chython/containers/_pack.pyx index fa61afc0..fe024654 100644 --- a/chython/containers/_pack.pyx +++ b/chython/containers/_pack.pyx @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2022 Ramil Nugmanov +# Copyright 2022-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -53,35 +53,26 @@ from libc.math cimport ldexp, frexp @cython.cdivision(True) @cython.wraparound(False) def pack(object molecule): - cdef bint b # binary flag + cdef bint b = True # binary flag cdef char charge - cdef unsigned char atomic_number, isotope, bond, s = 0, buffer_b, buffer_o - cdef unsigned char *p, *data + cdef unsigned char atomic_number, ngb_count, isotope, bond, s = 0, buffer_b, buffer_o, stereo, hcr + cdef unsigned char *data cdef unsigned short atoms_count, bonds_count = 0, cis_trans_count, n, m cdef unsigned int size, atoms_shift = 4, bonds_shift, order_shift, cis_trans_shift # can be > 2^16 - cdef unsigned char[4096] stereo, hcr, seen - cdef unsigned int[4096] xy # 2 * 16bit + cdef unsigned char[4096] seen cdef bytes py_pack - cdef dict py_ngb, py_atoms, py_bonds, py_charges, py_radicals, py_hydrogens, py_plane - cdef dict py_cis_trans_stereo, py_atoms_stereo, py_allenes_stereo + cdef dict py_ngb, py_atoms, py_bonds cdef tuple py_tuple cdef object py_atom, py_bond, py_nan_int, py_obj # map molecule to vars py_atoms = molecule._atoms py_bonds = molecule._bonds - py_charges = molecule._charges - py_radicals = molecule._radicals - py_hydrogens = molecule._hydrogens - py_cis_trans_stereo = molecule._cis_trans_stereo - py_atoms_stereo = molecule._atoms_stereo - py_allenes_stereo = molecule._allenes_stereo - py_plane = molecule._plane # calculate elements count atoms_count = len(py_atoms) - cis_trans_count = len(py_cis_trans_stereo) + cis_trans_count = molecule._cis_trans_count for py_ngb in py_bonds.values(): bonds_count += len(py_ngb) @@ -103,64 +94,76 @@ def pack(object molecule): if not data: raise MemoryError() - # precalculate atom attrs - # should be done independently, due to possible randomness in dicts order. - # 3 bit - hydrogens (0-7) | 4 bit - charge | 1 bit - radical - for n, py_nan_int in py_hydrogens.items(): - if py_nan_int is None: - hcr[n] = 0xe0 # 0b11100000 - else: - hcr[n] = py_nan_int << 5 - for n, charge in py_charges.items(): - hcr[n] |= (charge + 4) << 1 - for n, b in py_radicals.items(): - if b: # lazy memory access - hcr[n] |= 1 - - # 2 float16 big endian - for n, py_tuple in py_plane.items(): - p = &xy[n] - double_to_float16(py_tuple[0], &p[0]) - double_to_float16(py_tuple[1], &p[2]) - - # erase random data - seen[n] = 0 - stereo[n] = 0 - - # 2 bit tetrahedron | 2 bit allene | 0000 - for n, b in py_atoms_stereo.items(): - stereo[n] = 0xc0 if b else 0x80 - for n, b in py_allenes_stereo.items(): - stereo[n] = 0x30 if b else 0x20 - # start pack collection data[0] = 2 # header. specification version 2 data[1] = atoms_count >> 4 # 5-12b of atom count value data[2] = atoms_count << 4 | cis_trans_count >> 8 # 1-4b of atom count value, 9-12b of cis-trans count value data[3] = cis_trans_count # 1-8b of cis-trans count value - b = True # init connection table flag for py_obj, py_atom in py_atoms.items(): py_ngb = py_bonds[py_obj] + ngb_count = len(py_ngb) n = py_obj # cast to C seen[n] = 1 - p = &xy[n] # XY atomic_number = py_atom.atomic_number - py_nan_int = py_atom._Core__isotope # direct access + + py_nan_int = py_atom._isotope # direct access if py_nan_int is None: isotope = 0 else: isotope = py_nan_int - common_isotopes[atomic_number] + py_nan_int = py_atom._stereo + if py_nan_int is None: + stereo = 0 + # V2 specification + # 2 bit tetrahedron | 2 bit allene | 0000 + elif py_nan_int: + if ngb_count == 2: + stereo = 0x30 + else: + stereo = 0xc0 + else: + if ngb_count == 2: + stereo = 0x20 + else: + stereo = 0x80 + + # precalculate atom attrs + # should be done independently, due to possible randomness in dicts order. + # 3 bit - hydrogens (0-7) | 4 bit - charge | 1 bit - radical + py_nan_int = py_atom._implicit_hydrogens + if py_nan_int is None: + hcr = 0xe0 # 0b11100000 + else: + hcr = py_nan_int << 5 + + charge = py_atom._charge + hcr |= (charge + 4) << 1 + if py_atom._is_radical: + hcr |= 1 + data[atoms_shift] = n >> 4 # 5-12b AN - data[atoms_shift + 1] = n << 4 | len(py_ngb) # 1-4b AN, 4b NC - data[atoms_shift + 2] = stereo[n] | isotope >> 1 # TS , AS , 4b I + data[atoms_shift + 1] = n << 4 | ngb_count # 1-4b AN, 4b NC + data[atoms_shift + 2] = stereo | isotope >> 1 # TS , AS , 4b I data[atoms_shift + 3] = isotope << 7 | atomic_number # 1bI , A + + # 2 float16 big endian + for n, py_tuple in py_plane.items(): + p = &xy[n] + double_to_float16(py_tuple[0], &p[0]) + double_to_float16(py_tuple[1], &p[2]) + + # erase random data + seen[n] = 0 + stereo[n] = 0 + data[atoms_shift + 4] = p[0] data[atoms_shift + 5] = p[1] data[atoms_shift + 6] = p[2] data[atoms_shift + 7] = p[3] - data[atoms_shift + 8] = hcr[n] + + data[atoms_shift + 8] = hcr atoms_shift += 9 # collect connection table From 5916a7b9a2631cc2f8184d442d2328707f72747b Mon Sep 17 00:00:00 2001 From: walderhu Date: Wed, 18 Dec 2024 13:31:59 +0000 Subject: [PATCH 48/67] rewrite clean2d algorithm on Python --- chython/algorithms/calculate2d/Calculate2d.py | 2642 +++++++++++++++++ chython/algorithms/calculate2d/KKLayout.py | 410 +++ chython/algorithms/calculate2d/MathHelper.py | 689 +++++ chython/algorithms/calculate2d/Properties.py | 611 ++++ chython/algorithms/calculate2d/__init__.py | 206 +- chython/algorithms/calculate2d/clean2d.js | 1 - clean2d/README | 3 - clean2d/package-lock.json | 2521 ---------------- clean2d/package.json | 23 - clean2d/src/index.js | 20 - clean2d/webpack.config.js | 12 - pyproject.toml | 1 - 12 files changed, 4353 insertions(+), 2786 deletions(-) create mode 100644 chython/algorithms/calculate2d/Calculate2d.py create mode 100644 chython/algorithms/calculate2d/KKLayout.py create mode 100644 chython/algorithms/calculate2d/MathHelper.py create mode 100644 chython/algorithms/calculate2d/Properties.py delete mode 100644 chython/algorithms/calculate2d/clean2d.js delete mode 100644 clean2d/README delete mode 100644 clean2d/package-lock.json delete mode 100644 clean2d/package.json delete mode 100644 clean2d/src/index.js delete mode 100644 clean2d/webpack.config.js diff --git a/chython/algorithms/calculate2d/Calculate2d.py b/chython/algorithms/calculate2d/Calculate2d.py new file mode 100644 index 00000000..54d776c0 --- /dev/null +++ b/chython/algorithms/calculate2d/Calculate2d.py @@ -0,0 +1,2642 @@ +""" +Class for calculating the 2D layout of a molecular graph, returning the coordinates of atom +vertices in a molecular container. + +This class provides methods for calculating and optimizing the 2D structure of a molecule, +including determining atom coordinates, handling collisions, and defining properties of rings +and bonds between atoms. Key functions include: + +- Calculating the initial positions of atoms and their subsequent adjustment to minimize +overlaps. +- Defining and classifying rings within the molecule, including handling bridged, spiro-fused, +and condensed rings. +- Handling cis-trans isomerism and atom configurations. +- Working with various types of bonds (single, double, triple) and their impact on atom +orientation. +- Calculating atom positions in ring structures and aromatic compounds. +- Handling collisions between atoms to improve molecule visualization. + +The class uses auxiliary functions for vector operations, determining atom neighbors, +calculating angles, and handling specific cases such as atoms with one, two, three, or four +neighbors. It also includes methods for working with ring structures, including determining the +ring center and positioning atoms within the ring. + +Utilizes AtomProperties, BondProperties, RingProperties, RingOverlap classes to represent atoms, +bonds, and rings, respectively. Additionally, the KKLayout class, which implements the +Kamada-Kawai algorithm, is used to minimize the system's energy by representing atoms as masses +connected by springs with a certain stiffness (used for calculating coordinates in bridged +cyclic molecules). +""" +from typing import List, Dict, Optional, Set, Tuple, Union, Generator, TYPE_CHECKING +from .MathHelper import Vector, Polygon +from .KKLayout import KKLayout +from .Properties import * +import math + +if TYPE_CHECKING: + from ...containers import MoleculeContainer + + +class Calculate2d: + """ + Class for calculating the 2D layout of a molecular graph, returning the coordinates of atom + vertices in a molecular container. + """ + + def __init__(self) -> None: + """ + The initial attributes initialization of the class includes: + bond_length: int: The bond length between atoms in the molecular graph. Used to determine + the distance between atoms when calculating their coordinates. + overlap_sensitivity: float: Sensitivity to atom overlap. Used to determine how close atoms + can be to each other before they are considered to overlap. + overlap_resolution_iterations: int: The number of iterations for resolving atom overlaps. + Indicates how many times the algorithm will attempt to improve atom positioning to + minimize overlaps. + ring_overlaps: List['RingOverlap']: A list of RingOverlap objects representing overlaps + between rings in the molecule. Used for identifying and handling ring overlaps. + total_overlap_score: float: The total overlap score in the molecule. Used for evaluating the + quality of atom positioning and minimizing overlaps. + finetune: bool: A flag indicating the need for detailed adjustment (finetuning) of atom + positions after the main calculation. Currently always set to True, but can be changed + to disable detailed adjustment. + ring_overlap_id_tracker: int: A counter for assigning unique identifiers to RingOverlap + objects. Used for the unique identification of ring overlaps. + ring_id_tracker: int: A counter for assigning unique identifiers to rings. Simplifies ring + management in the structure. + rings: List['RingProperties']: A list of RingProperties objects representing all rings in + the molecule. Used for working with ring structures and their attributes. + id_to_ring: Dict[int, 'RingProperties']: A dictionary mapping ring identifiers to their + RingProperties objects. Facilitates access to ring properties by their identifiers. + """ + + self.bond_length: int = 15 + self.overlap_sensitivity: float = 0.10 + self.overlap_resolution_iterations: int = 5 + self.ring_overlaps: List['RingOverlap'] = [] + self.total_overlap_score: float = 0.0 + # self.finetune: bool = True # используется в ветвлении, но всегда тру, бесполезная вещь + self.ring_overlap_id_tracker: int = 0 + self.ring_id_tracker: int = 0 + self.rings: List['RingProperties'] = [] + self.id_to_ring: Dict[int, 'RingProperties'] = {} + + + + def _calculate2d_coord(self, order: List[int], mc: 'MoleculeContainer') -> List[List[float]]: + """ + Calculates the coordinates of the vertices of the atoms of the graph and returns the + coordinates as + a two-dimensional array. + + This method computes the 2D coordinates for each atom in the molecular graph based on + the provided order and molecular container. It initializes the properties of the + molecular container, + defines the rings within the molecule, performs an initial approximation of atom + positions, handles collisions between atoms, and finally returns the calculated + coordinates. + + Parameters: + :param order: List[int]: + A list of integers representing the order in which atoms should be processed. This + order determines the sequence for calculating and adjusting atom positions to + minimize overlaps. + :param mc: MoleculeContainer: + An instance of MoleculeContainer that holds the molecular graph, including atoms, + bonds, and rings information necessary for the 2D layout calculation. + + Returns List[List[float]]: + A two-dimensional list where each inner list contains the x and y coordinates of an + atom in the 2D space. The order of coordinates corresponds to the order of atoms as + processed. + """ + self.create_property_attributes(mc) + self.define_rings() + + self.initial_approximation() ##main + self.collision_handling() + return self.get_coord(order) + + + + + def create_property_attributes(self, mc: 'MoleculeContainer') -> None: + """ + Initializes the property attributes for the molecular container, including atoms, bonds, and their + relationships. + + This method sets up the initial properties for the molecular container by creating dictionaries for + atoms and bonds based on the adjacency information provided by the molecular container. It also + refreshes the neighbours list for each atom to ensure accurate representation of the molecular + graph. + + Parameters + :param mc: MoleculeContainer: + An instance of MoleculeContainer that holds the molecular graph, including atoms, bonds, and + rings information necessary for the 2D layout calculation. + + Notes: + - Initializes the `atoms` dictionary with atom indices as keys and AtomProperties instances as + values, where each AtomProperties instance is created with the atom index and its corresponding + symbol. + - Refreshes the neighbours list for each atom based on the adjacency information from the molecular + container. + - Initializes the `bonds` dictionary with tuples of atom indices as keys and BondProperties + instances as values, representing the bonds between atoms. + - Sets up the `graph` dictionary to map each atom to its list of neighbouring atoms, facilitating + the representation of the molecular structure. + + The method is crucial for preparing the molecular container for further calculations by establishing + the basic properties and relationships between atoms and bonds, which are essential for the 2D + layout calculation. + """ + self.mc: 'MoleculeContainer' = mc + + self.atoms: Dict[int, AtomProperties] = {} + for atom in self.mc.int_adjacency: + symbol = self.get_symbol(atom) + self.atoms[atom] = AtomProperties(atom, symbol) + self.refresh_neighbours(self.mc.int_adjacency) + + self.graph: Dict['AtomProperties', List['AtomProperties']] = {} + for atom in self.atoms.values(): + self.graph[atom] = atom.neighbours + + self.bonds: Dict[Tuple[int], 'BondProperties'] = {} + for n, m, bond in self.mc.bonds(): + self.bonds[(n, m)] = BondProperties(self.atoms[n], self.atoms[m], bond) + + + ## creating and refreshing property attributes + + def refresh_neighbours(self, graph: Dict[int, Dict[int, int]]) -> None: + """ + Refreshes the neighbours list for each atom based on the provided graph adjacency information. + + This method updates the neighbours list for each atom in the molecular graph to ensure an accurate + representation of the molecular structure. It iterates through the graph, which is a dictionary + mapping atom indices to their adjacent atom indices, and assigns the corresponding AtomProperties + instances to the neighbours list of each atom. + + Parameters + :param graph: Dict[int, Dict[int, int]]: + A dictionary where keys are atom indices and values are dictionaries mapping to adjacent atom + indices. This structure represents the adjacency information of the molecular graph, indicating + which atoms are directly connected. + """ + for atom_index, neighbor_indexes in graph.items(): + neighbours: List['AtomProperties'] = [] + for neighbour_index in neighbor_indexes: + neighbours.append(self.atoms[neighbour_index]) + self.atoms[atom_index].neighbours = neighbours + + + def get_symbol(self, atom_index: int) -> str: + """ + Returns the atomic symbol of the atom corresponding to the given index. + + This method retrieves the atomic symbol of an atom in the molecular container based on its index. It + is a utility function used to identify the type of atom by its atomic symbol, which is essential for + various calculations and representations in the molecular graph. + + Parameters + :param atom_index: int: + The index of the atom for which the atomic symbol is to be retrieved. This index is used to + access the atom within the molecular container. + + Returns str: + The atomic symbol of the atom as a string, representing the element type of the atom (e.g., 'C' + for Carbon, 'H' for Hydrogen, etc.). + + """ + return self.mc.atom(atom_index).atomic_symbol + + + + def bond_lookup(self, atom: 'AtomProperties', next_atom: 'AtomProperties') -> Optional['BondProperties']: + """ + Возвращает связь, которая находится между этими двумя атомами + """ + return self.bonds.get((atom.id, next_atom.id)) or self.bonds.get((next_atom.id, atom.id)) + + + def get_configuration(self, atom1: 'AtomProperties', atom2: 'AtomProperties') -> Optional[str]: + """ + Проверяет есть ли конфигурация между этими атомами, + в случае отсутствия возвращает None, в ином случае + возвращает строку 'cis' или 'trans' + + self._cis_trans_stereo = {(2, 3): False} это словарь, хранящий значения + о конфигурациях молекулы, его ключами являются тюплы атомов, между которыми + есть могут быть конформации, сверху условие что эта связь обязательно должна + быть двойной, значениями является булевое значение, True если Цис конфигурация, + False если Транс, если конфигурации не предусмотренно вообще, то словарь будет пустым. + """ + if (atom1.id, atom2.id) not in list(self.mc._cis_trans_stereo.keys()): + return None + else: + configuration = self.mc._cis_trans_stereo[(atom1.id, atom2.id)] + return 'cis' if configuration else 'trans' + + +# поиск в базе колец и их классификация +# в структуре кайтона не обрабатываются случаи с мостиковыми кольцами + def define_rings(self) -> None: + """ + Defines the rings within the molecule, identifies ring overlaps, and handles bridged ring systems. + + This method performs several key steps in the process of analyzing the molecular structure: + 1. It initializes the rings present in the molecule by converting the simple cycle list (SSSR) from + the molecular container into RingProperties objects. + 2. Identifies overlaps between rings and creates RingOverlap objects for them. + 3. Finds and processes bridged ring systems, creating a unified representation for interconnected + rings that share atoms. + + Notes + ----- + - Initially, it retrieves the simple cycle list (SSSR) from the molecular container and converts + each cycle into a RingProperties object, adding them to the class's ring list. + - It then iterates through all pairs of rings to identify overlaps, creating RingOverlap objects for + those that share atoms and adding them to the class's ring overlaps list. + - For each ring, it updates the list of neighbouring rings based on identified overlaps, enhancing + the representation of the molecular structure's connectivity. + - The method also handles bridged ring systems by identifying rings that are part of a larger, + interconnected system and merges them into a single RingProperties object, ensuring a coherent + representation of complex cyclic structures. + - This process involves finding all rings involved in a bridged system, removing the original rings + from the list, and adding a new RingProperties object that represents the bridged system. + - Finally, it iterates through all rings to find any bridged systems not yet processed and repeats + the merging process, ensuring that all interconnected rings are represented as unified entities. + """ + rings = self.mc.sssr + if not rings: + return + + for neighbor_indexes in rings: + members_ring: List['AtomProperties'] = [self.atoms[atom_index] for atom_index in neighbor_indexes] + ring = RingProperties(members_ring) + self.add_ring(ring) + + for i, ring_1 in enumerate(self.rings[:-1]): + for ring_2 in self.rings[i + 1:]: + ring_overlap = RingOverlap(ring_1, ring_2) + if len(ring_overlap.atoms) > 0: + self.add_ring_overlap(ring_overlap) + + for ring in self.rings: + neighbouring_rings = self.find_neighbouring_rings(self.ring_overlaps, ring.id) + ring.neighbouring_rings = neighbouring_rings + + while True: + ring_id: int = -1 + for ring in self.rings: + if self.is_part_of_bridged_ring(ring.id) and not ring.bridged: + ring_id: int = ring.id + if ring_id == -1: + break + ring: 'RingProperties' = self.id_to_ring[ring_id] + + involved_ring_ids: Union[list[int], Set[int]] = [] + self.get_bridged_ring_subrings(ring.id, involved_ring_ids) + involved_ring_ids = set(involved_ring_ids) + + self.has_bridged_ring = True + self.create_bridged_ring(involved_ring_ids) + + for involved_ring_id in involved_ring_ids: + involved_ring = self.id_to_ring[involved_ring_id] + self.remove_ring(involved_ring) + + + bridged_systems = self.find_bridged_systems(self.rings, self.ring_overlaps) + if bridged_systems and not self.has_bridged_ring: + self.has_bridged_ring = True + for bridged_system in bridged_systems: + involved_ring_ids = set(bridged_system) + self.create_bridged_ring(involved_ring_ids) + for involved_ring_id in involved_ring_ids: + involved_ring = self.id_to_ring[involved_ring_id] + self.remove_ring(involved_ring) + + + + def add_ring_overlap(self, ring_overlap: 'RingOverlap') -> None: + """ + Adds a new ring overlap to the list of ring overlaps and assigns it a unique identifier. + + This method assigns a unique identifier to the given ring overlap and appends it to the class's list + of ring overlaps. It ensures that each ring overlap is uniquely identifiable and can be tracked + throughout the calculation process. + + Parameters + :param ring_overlap: RingOverlap + The ring overlap to be added to the list of overlaps. This object represents the intersection + between two rings in the molecule, which may need special handling during the layout calculation + to avoid visual clutter or incorrect representation. + """ + ring_overlap.id = self.ring_overlap_id_tracker + self.ring_overlaps.append(ring_overlap) + self.ring_overlap_id_tracker += 1 + + + + def is_part_of_bridged_ring(self, ring_id: int) -> bool: + """ + Determines if a given ring is part of a bridged ring system. + + This method checks if a ring, identified by its ID, is involved in a bridged ring system by + examining the list of ring overlaps. It returns True if the ring is part of a bridged system, + indicating that it is connected to another ring through a bridge, and False otherwise. + + Parameters + :param ring_id: int: + The identifier of the ring to check for involvement in a bridged ring system. + + Returns bool: + True if the ring is part of a bridged ring system, indicating that it is interconnected with + another ring through a bridge, and False otherwise. + """ + return any(ring_overlap.involves_ring(ring_id) and ring_overlap.is_bridge() \ + for ring_overlap in self.ring_overlaps) + + + + def get_bridged_ring_subrings(self, ring_id: int, involved_ring_ids: List[int]) -> None: + """ + Recursively identifies and collects the IDs of all rings involved in a bridged ring system starting + from a given ring ID. + + This method is used to find all rings that are interconnected as part of a bridged ring system, + starting from a specified ring ID. It recursively explores neighboring rings to identify all rings + that are connected through bridges, adding their IDs to a list of involved ring IDs. + + Parameters + :param ring_id: int + The identifier of the starting ring from which to begin the search for interconnected rings in a + bridged system. + :param involved_ring_ids: List[int] + A list to which the IDs of rings involved in the bridged system are appended. This list is + populated with the IDs of all rings found to be part of the bridged ring system. + """ + involved_ring_ids.append(ring_id) + ring = self.id_to_ring[ring_id] + for neighbour_id in ring.neighbouring_rings: + if neighbour_id not in involved_ring_ids and neighbour_id != ring_id and \ + self.rings_connected_by_bridge(self.ring_overlaps, ring_id, neighbour_id): + self.get_bridged_ring_subrings(neighbour_id, involved_ring_ids) + + + + @staticmethod + def rings_connected_by_bridge(ring_overlaps: List['RingOverlap'], ring_id_1: int, ring_id_2: int): + """ + Determines if two rings are connected by a bridge based on the list of ring overlaps. + + This method checks if two rings, identified by their IDs, are connected through a bridge by + examining the list of ring overlaps. It returns True if a bridge connection is found between the + specified rings, and False otherwise. + + Parameters + :param ring_overlaps: List['RingOverlap'] + A list of RingOverlap objects representing overlaps between rings in the molecule. Each + RingOverlap object contains information about the rings involved in the overlap and whether it + constitutes a bridge. + :param ring_id_1: int + The identifier of the first ring to check for a bridge connection. + :param ring_id_2: int + The identifier of the second ring to check for a bridge connection. + + Returns bool: + True if the specified rings are connected by a bridge, indicating a direct connection that forms + part of a bridged ring system, and False otherwise. + """ + for ring_overlap in ring_overlaps: + if ring_id_1 == ring_overlap.ring_id_1 and ring_id_2 == ring_overlap.ring_id_2: + return ring_overlap.is_bridge() + if ring_id_2 == ring_overlap.ring_id_1 and ring_id_1 == ring_overlap.ring_id_2: + return ring_overlap.is_bridge() + return False + + + + + + def create_bridged_ring(self, involved_ring_ids: Set[int]) -> None: + """ + Creates a unified representation for a bridged ring system by merging the specified rings into a + single RingProperties object. + + This method processes a set of ring IDs that are part of a bridged ring system, creating a new + RingProperties object that represents the interconnected rings as a single entity. It involves + identifying all atoms and neighbours involved in the bridged system, determining their roles (e.g., + bridge atoms), and updating the molecular structure to reflect this unified representation. + + Parameters + : param involved_ring_ids: Set[int] + A set of ring IDs that are part of a bridged ring system to be merged into a single + RingProperties object. + + Notes + - Initializes sets for atoms and neighbours involved in the bridged ring system. + - Iterates through each ring ID in the provided set, marking each as part of a subring of the ridged + system and collecting all member atoms and their neighbouring rings. + - Identifies atoms that are part of the bridged system and classifies them based on their nvolvement + in the ring system, distinguishing between those that are bridge atoms and those that are part of he + bridged ring itself. + - Creates a new RingProperties object for the bridged ring, adding it to the class's list of rings + and updating its attributes to reflect its bridged nature and interconnectedness. + - Updates the molecular structure to incorporate the new bridged ring, including updating atom + memberships and removing overlaps between the original rings that are now part of the bridged + system. + - This process is crucial for accurately representing complex cyclic structures within the molecule, + where rings are interconnected in a way that they share atoms, forming a bridged system. It ensures + that the molecular graph accurately reflects the topology of such systems, which is essential for + the correct calculation of atom positions and the overall layout in 2D space. + """ + atoms: Set['AtomProperties'] = set() + neighbours: Set[int] = set() + for ring_id in involved_ring_ids: + ring: 'RingProperties' = self.id_to_ring[ring_id] + ring.subring_of_bridged = True + for atom in ring.members: + atoms.add(atom) + for neighbour_id in ring.neighbouring_rings: + neighbours.add(neighbour_id) + leftovers: Set['AtomProperties'] = set() + ring_members: Set['AtomProperties'] = set() + for atom in atoms: + atom_rings_members_id: Set[int] = {ring.id for ring in atom.rings} + intersect = involved_ring_ids.intersection(atom_rings_members_id) + if len(atom.rings) == 1 or len(intersect) == 1: + ring_members.add(atom) + else: + leftovers.add(atom) + for atom in leftovers: + is_on_ring = False + for bond in self.get_bonds_of_atom(atom): + bond_associated_rings = min(len(bond.atom1.rings), len(bond.atom2.rings)) + if bond_associated_rings == 1: + is_on_ring = True + if is_on_ring: + atom.is_bridge_atom = True + ring_members.add(atom) + else: + atom.is_bridge = True + ring_members.add(atom) + bridged_ring = RingProperties(list(ring_members)) + self.add_ring(bridged_ring) + bridged_ring.bridged = True + bridged_ring.neighbouring_rings = list(neighbours) + for ring_id in involved_ring_ids: + ring = self.id_to_ring[ring_id] + bridged_ring.subrings.append(ring.copy()) + for atom in ring_members: + atom.bridged_ring = bridged_ring.id + for ring_id in involved_ring_ids: + if self.id_to_ring[ring_id] in atom.rings: + atom.rings.remove(self.id_to_ring[ring_id]) + atom.rings.append(bridged_ring) + involved_ring_ids: List[int] = list(involved_ring_ids) + for i, ring_id_1 in enumerate(involved_ring_ids): + for ring_id_2 in involved_ring_ids[i + 1:]: + self.remove_ring_overlaps_between(ring_id_1, ring_id_2) + for neighbour_id in neighbours: + ring_overlaps: List['RingOverlap'] = self.get_ring_overlaps(neighbour_id, involved_ring_ids) + for ring_overlap in ring_overlaps: + + ring_overlap.update_other(bridged_ring.id, neighbour_id) + neighbour = self.id_to_ring[neighbour_id] + neighbour.neighbouring_rings.append(bridged_ring.id) + + + + def remove_ring_overlaps_between(self, ring_id_1: int, ring_id_2: int) -> None: + """ + Removes ring overlaps between two specified rings from the list of ring overlaps. + + This method identifies and removes any ring overlaps between two rings, specified by their IDs, from + the class's list of ring overlaps. It ensures that once rings are merged or otherwise processed in a + way that eliminates their overlap, the record of their previous overlap is removed to maintain an + accurate representation of the molecular structure. + + Parameters + :param ring_id_1: int + The identifier of the first ring for which overlaps should be removed. + :param ring_id_2: int + The identifier of the second ring for which overlaps should be removed. + """ + to_remove = [] + for ring_overlap in self.ring_overlaps: + if (ring_overlap.ring_id_1 == ring_id_1 and ring_overlap.ring_id_2 == ring_id_2) or\ + (ring_overlap.ring_id_2 == ring_id_1 and ring_overlap.ring_id_1 == ring_id_2): + to_remove.append(ring_overlap) + for ring_overlap in to_remove: + self.ring_overlaps.remove(ring_overlap) + + + + def get_ring_overlaps(self, ring_id: int, ring_ids: List[int]) -> List['RingOverlap']: + """ + Retrieves a list of ring overlaps involving a specified ring and a list of other ring IDs. + + Parameters + :param ring_id: int + The identifier of the ring for which overlaps with other rings are to be found. + :param ring_ids: List[int] + A list of ring identifiers to check for overlaps with the specified ring. + + Returns List['RingOverlap'] + A list of RingOverlap objects representing the overlaps between the specified ring and any of + the rings identified by the IDs in the ring_ids list. Each RingOverlap object contains + information about the rings involved in the overlap and the nature of their intersection. + """ + ring_overlaps: List['RingOverlap'] = [] + for ring_overlap in self.ring_overlaps: + for ring_id_2 in ring_ids: + if (ring_overlap.ring_id_1 == ring_id and ring_overlap.ring_id_2 == ring_id_2) or\ + (ring_overlap.ring_id_2 == ring_id and ring_overlap.ring_id_1 == ring_id_2): + ring_overlaps.append(ring_overlap) + return ring_overlaps + + + + def remove_ring(self, ring: 'RingProperties') -> None: + """ + Removes a specified ring from the list of rings and updates the list of ring overlaps accordingly. + + Parameters + :param ring: RingProperties + The RingProperties object to be removed from the list of rings. + """ + self.rings.remove(ring) + overlaps_to_remove = [] + for ring_overlap in self.ring_overlaps: + if ring_overlap.ring_id_1 == ring.id or ring_overlap.ring_id_2 == ring.id: + overlaps_to_remove.append(ring_overlap) + for ring_overlap in overlaps_to_remove: + self.ring_overlaps.remove(ring_overlap) + for neighbouring_ring in self.rings: + if ring.id in neighbouring_ring.neighbouring_rings: + neighbouring_ring.neighbouring_rings.remove(ring.id) + + + + def find_bridged_systems(self, rings: List['RingProperties'], ring_overlaps: 'RingOverlap') -> List: + """ + Identifies bridged ring systems within the molecule based on the provided rings and their overlaps. + + Parameters + :param rings : List['RingProperties'] + A list of RingProperties objects representing the rings within the molecule to be analyzed. + :param ring_overlaps : List['RingOverlap'] + A list of RingOverlap objects representing overlaps between rings, which is used to determine + the interconnectedness of the rings. + + Returns List[List[int]] + A list of ring groups, where each group is represented as a list of ring IDs. Each group is + identified as a bridged system based on the criteria that the number of overlaps is at least as + great as the number of rings in the group, indicating a high likelihood of forming a bridged + ring system. + """ + bridged_systems: List = [] + ring_groups = self.get_ring_groups(rings, ring_overlaps) + for ring_group in ring_groups: + ring_nr: int = len(ring_group) + overlap_nr: int = self.get_group_overlap_nr(ring_group, ring_overlaps) + if overlap_nr >= ring_nr: + bridged_systems.append(ring_group) + return bridged_systems + + + # @TODO: непонятный докстринг, переписать + + def get_ring_groups(self, rings: List['RingProperties'], ring_overlaps: List['RingOverlap']) -> List: + """ + Organizes rings into groups based on their overlaps, identifying interconnected ring systems within + the molecule. + + Parameters + :param rings: List['RingProperties'] + A list of RingProperties objects representing the rings within the molecule to be analyzed. + :param ring_overlaps: List['RingOverlap'] + A list of RingOverlap objects representing overlaps between rings, which is used to determine + the interconnectedness of the rings. + + Returns List[List[int]] + A list of ring groups, where each group is represented as a list of ring IDs. Rings within a + group are interconnected, either directly or through a series of overlaps, indicating potential + bridged or fused ring systems. + + Notes + - Initializes a list of ring groups, starting with each ring as a separate group. + - Iteratively merges groups that have overlaps, indicating a structural relationship between rings, + until no more merges are possible. This is determined by comparing the number of groups before and + after attempting merges. + - Uses a helper method, ring_groups_have_overlap, to identify if two groups share an overlap, + suggesting they should be merged into a single group. + - Merging is done by creating a union of the two groups and removing the original groups from the + list, then adding the merged group. This process simplifies the representation of the molecule's + ring structure by consolidating interconnected rings. + - The merging process continues until the number of groups stabilizes, indicating that all + interconnected rings have been grouped together. + - This method is crucial for simplifying the analysis of molecular structures with complex cyclic + components, as it reduces the complexity of the ring structure by grouping interconnected rings. + This simplification aids in the identification of bridged and fused ring systems, which are + important for accurate layout calculations and visualization. + - By organizing rings into groups, it provides a basis for further analysis, such as identifying + bridged ring systems or resolving the layout of rings in a way that reflects their + interconnectedness, which is essential for the accurate representation of molecular topology in 2D + space. + - The final list of ring groups represents a simplified view of the molecule's cyclic structure, + where each group may correspond to a bridged, fused, or independent ring system, depending on the + overlaps between rings. + """ + ring_groups = [] + for ring in rings: + ring_groups.append([ring.id]) + + current_ring_nr = 0 + previous_ring_nr = -1 + while current_ring_nr != previous_ring_nr: + previous_ring_nr = current_ring_nr + indices = None + new_group = None + for i, ring_group_1 in enumerate(ring_groups): + ring_group_1_found = False + for j, ring_group_2 in enumerate(ring_groups): + if i != j: + if self.ring_groups_have_overlap(ring_group_1, ring_group_2, ring_overlaps): + indices = [i, j] + new_group = list(set(ring_group_1 + ring_group_2)) + ring_group_1_found = True + break + if ring_group_1_found: + break + + if new_group: + indices.sort(reverse=True) + for index in indices: + ring_groups.pop(index) + ring_groups.append(new_group) + + current_ring_nr = len(ring_groups) + return ring_groups + + + + def ring_groups_have_overlap(self, group_1: List[int], group_2: List[int], \ + ring_overlaps: List['RingOverlap']) -> bool: + """ + Determines if two ring groups have an overlap based on the list of ring overlaps. + + Parameters + :param group_1: List[int] + The first group of ring IDs to check for overlaps. + :param group_2: List[int] + The second group of ring IDs to check for overlaps. + :param ring_overlaps: List['RingOverlap'] + A list of RingOverlap objects representing overlaps between rings in the molecule. + Each RingOverlap object contains information about the rings involved in the overlap. + + Returns bool + True if an overlap is found between any rings from the two groups, indicating a structural + relationship, and False otherwise. + """ + # for ring_1 in group_1: + # for ring_2 in group_2: + # if ring_1 in self.find_neighbouring_rings(ring_overlaps, ring_2): + # return True + # return False + # @TODO: ниже моя версия + return any(ring_1 in self.find_neighbouring_rings(ring_overlaps, ring_2) \ + for ring_1 in group_1 for ring_2 in group_2) + + + @staticmethod + def get_group_overlap_nr(ring_group, ring_overlaps: List['RingOverlap']) -> int: + """ + Calculates the number of overlaps within a group of rings based on a list of ring overlaps. + + Parameters: + :param ring_group List[int] + A list of ring identifiers (IDs) representing a group of rings to check for overlaps among. + :param ring_overlaps List['RingOverlap']: + A list of `RingOverlap` objects, where each object represents an overlap between two rings, + identified by their IDs (`ring_id_1` and `ring_id_2`). + + Returns int The total number of overlaps found within the `ring_group`, where an overlap is + counted if both rings involved are members of the group. + """ + # overlaps = 0 + # ring_group = set(ring_group) + # for ring_overlap in ring_overlaps: + # if ring_overlap.ring_id_1 in ring_group and ring_overlap.ring_id_2 in ring_group: + # overlaps += 1 + # return overlaps + # @TODO: моя версия укороченная версиянадо потестировать + ring_group_set = set(ring_group) + return sum(overlap.ring_id_1 in ring_group_set and overlap.ring_id_2 in ring_group_set\ + for overlap in ring_overlaps) + + + def get_bonds_of_atom(self, atom: 'AtomProperties') -> List['BondProperties']: + """ + Retrieves all bonds associated with a specified atom within the molecular graph. + + This method searches the molecular graph for bonds that involve a given atom, returning + a list of all bonds connected to it. Each bond is represented by a BondProperties + object, which encapsulates details about the bond type and the atoms it connects. By + iterating through the collection of all bonds in the graph and checking if the specified + atom is involved in each bond, the method accurately identifies all connections of the + atom, regardless of the atom's role (whether as the starting or ending atom of the + bond). + + Parameters: + :param atom AtomProperties: + The atom whose bonds are to be retrieved. This atom is identified by its unique + identifier within the molecular graph. + + Returns List[BondProperties]: + A list of BondProperties objects representing all bonds connected to the specified + atom. Each entry in the list corresponds to a distinct bond involving the atom, + providing comprehensive information about the atom's connectivity within the + molecular structure. + """ + # bonds: List['BondProperties'] = [] + # for (atom1_id, atom2_id), bond in self.bonds.items(): + # if atom.id in (atom1_id, atom2_id): + # bonds.append(bond) + # return bonds + # @TODO: моя сокращенная версия + return [bond for (atom1_id, atom2_id), bond in self.bonds.items() \ + if atom.id in (atom1_id, atom2_id)] + + + + def add_ring(self, ring: 'RingProperties') -> None: + """ + Adds a new ring to the class's collection and updates the internal tracking of ring + identifiers. + + Parameters: + :param ring 'RingProperties': + The `RingProperties` object representing the ring to be added. This + object encapsulates the properties and characteristics of the ring, such as its member atoms, size, and type (e.g., aromatic, bridged). + """ + ring.id = self.ring_id_tracker + self.rings.append(ring) + self.id_to_ring[ring.id] = ring + self.ring_id_tracker += 1 + + + ##первое приближение + def initial_approximation(self) -> None: + """ + Determines the initial atom from which to start the layout calculation process for a molecular + graph in 2D space. + + This method iterates through the molecular graph to find an appropriate starting atom based on + several criteria: + 1. Prefers an atom that is part of a bridged ring system, indicating complex cyclic structures + that require careful handling. + 2. If no such atom is found, it looks for an atom that belongs to a bridged ring, which suggests + a connection between rings that might need special attention during layout. + 3. If still no suitable atom is found, and if there are rings defined, it selects the first + member of the first ring in the class's ring list. + 4. If no rings are defined or none of the above conditions are met, it selects a terminal atom, + which is an atom with no more than one bond, simplifying the starting conditions. + 5. As a last resort, if no terminal atom is found, it defaults to the first atom in the graph. + + After selecting the starting atom, it initiates the bond creation process by calling + `create_next_bond` with the selected atom, setting the stage for further layout calculations. + """ + start_atom = None + + for atom in self.graph: + if atom.bridged_ring is not None: + start_atom = atom + break + + if start_atom is None: + for ring in self.rings: + if ring.bridged: + start_atom = ring.members[0] + + if start_atom is None: + if len(self.rings) > 0: + start_ring: 'RingProperties' = self.id_to_ring[0] + start_atom = start_ring.members[0] + + if start_atom is None: + for atom in self.graph: + if atom.is_terminal(): + start_atom = atom + break + + if start_atom is None: + start_atom = self.graph[0] + self.create_next_bond(start_atom, None, 0.0) + + + + def create_next_bond(self, atom: 'AtomProperties', previous_atom: Optional['AtomProperties']=None, \ + angle: float=0.0, previous_branch_shortest: bool = False) -> None: + """ + Creates the next bond for an atom in the molecular structure, updating its position + based on the previous atom and angle. + + Parameters: + :param atom: AtomProperties: + The atom for which the next bond is being created. + :param previous_atom: Optional[AtomProperties]: + The previous atom connected to the current atom. If None, it is assumed that the + current atom is the first in the molecular structure. + :param angle: float: + The angle between the previous atom and the current atom in radians. Default is 0.0. + :param previous_branch_shortest: bool: + A flag indicating if the previous branch is the shortest. Default is False. + + Logic: + 1. If the atom is already positioned, the method ends without changes. + 2. If there is no previous atom, a special method for the first atom is used. + 3. If the previous atom is connected to one or more rings, a method for calculating the + atom's position in ring structures is used. + 4. Otherwise, if the previous atom is not connected to rings, a method for calculating + the atom's position without considering rings is used. + 5. If the atom has connected rings, a method for atoms in ring structures is applied. + 6. Depending on the number of neighbors the atom has (from 1 to 4), the corresponding + method is chosen to calculate its position, considering various neighbor configurations. + """ + if atom.positioned: + return + if previous_atom is None: + self.calculate_first_atom(atom) + elif len(previous_atom.rings) > 0: + self.calculate_rings(previous_atom, atom) + else: + self.calculate_NOT_first_atom(atom, previous_atom, angle) + + if len(atom.rings) > 0: + self.calculate_some_rings(atom) + else: + neighbours: List['AtomProperties'] = atom.neighbours[:] + if previous_atom and previous_atom in neighbours: + neighbours.remove(previous_atom) + previous_angle: float = atom.get_angle() + if len(neighbours) == 1: + self.calculate_1_neighbours(neighbours, atom, previous_atom, \ + previous_angle, previous_branch_shortest) + elif len(neighbours) == 2: + self.calculate_2_neighbours(atom, neighbours, previous_atom, previous_angle) + elif len(neighbours) == 3: + self.calculate_3_neighbours(atom, neighbours, previous_atom, previous_angle) + elif len(neighbours) == 4: + self.calculate_4_neighbours(atom , neighbours, previous_angle) + + + def calculate_first_atom(self, atom: 'AtomProperties') -> None: + """ + Calculates the initial position for the first atom in a molecule. + + This method sets the initial position for the first atom in the molecular structure. It + assigns a default position based on the class's bond length and rotates it to a standard + orientation. The atom is marked as positioned if it is not part of a bridged ring + system, ensuring it's ready for further calculations in the molecular layout process. + + Parameters: + :param atom AtomProperties: + The first atom in the molecule to calculate the initial position for. + """ + dummy: Vector = Vector(self.bond_length, 0) + dummy.rotate(math.radians(-60.0)) + atom.previous_position = dummy + atom.previous_atom = None + atom.set_position(Vector(self.bond_length, 0)) + atom.angle = math.radians(-60.0) + if atom.bridged_ring is None: + atom.positioned = True + + + # @TODO: Дать нормальное название + def calculate_NOT_first_atom(self, atom: 'AtomProperties', + previous_atom: 'AtomProperties', angle: float) -> None: + """ + Calculates the position for an atom that is not the first in the molecule, based on its + previous atom and a given angle. + + Parameters: + :param atom AtomProperties: + The atom for which the position is being calculated. + :param previous_atom AtomProperties: + The atom preceding the current atom in the molecular structure, used as a reference + for positioning. + :param angle float: + The angle in radians by which the position vector should be rotated to align the + atom correctly relative to the previous atom. + """ + position: Vector = Vector(self.bond_length, 0) + position.rotate(angle) + position.add(previous_atom.position) + atom.set_position(position) + atom.set_previous_position(previous_atom) + atom.positioned = True + + + + # @TODO: разбить конкретную функцию на несколько логически обоснованных частей + # например отдельно 2-3 связи отдельно кольца и отдельно остальные случаи + def calculate_1_neighbours(self, neighbours: List[int], atom: 'AtomProperties', \ + previous_atom: 'AtomProperties', previous_angle: float, \ + previous_branch_shortest: bool) -> None: + """ + Calculates the position for an atom with exactly one neighbor in the molecular + structure, considering various bonding scenarios and configurations. + + This method is designed to handle the placement of an atom that has only one neighbor + within the molecular structure, taking into account the type of bonds it forms with its + previous atom and the presence of any rings. It adjusts the atom's position based on the + bond type (single, double, or triple) and the configuration of the molecule, including + handling cis and trans isomerism in specific scenarios. The method also considers the + angle of the previous bond and the shortest branch condition to correctly orient the + atom in space. + + Parameters: + :param neighbours List[int]: + A list of atom indices representing the neighbors of the current atom. Since the + atom has only one neighbor, this list should contain a single element. + :param atom AtomProperties: + The atom for which the position is being calculated. + :param previous_atom AtomProperties: + The atom preceding the current atom in the molecular structure, used as a reference + for positioning. + :param previous_angle float: + The angle in radians between the previous atom and the current atom. + :param previous_branch_shortest bool: + Indicates if the previous branch is the shortest, affecting the orientation of the next bond. + """ + next_atom: 'AtomProperties' = neighbours[0] + current_bond: Optional['BondProperties'] = self.bond_lookup(atom, next_atom) + previous_bond: Optional['BondProperties'] = None + if previous_atom: + previous_bond = self.bond_lookup(previous_atom, atom) + if current_bond.type == 'triple' or (previous_bond and previous_bond.type == 'triple') or \ + (current_bond.type == 'double' and previous_bond and previous_bond.type == 'double'\ + and previous_atom and len(previous_atom.rings) == 0 and len(atom.neighbours) == 2): + if current_bond.type == 'double' and previous_bond.type == 'double': + atom.draw_explicit = True + if current_bond.type == 'triple': + atom.draw_explicit = True + next_atom.draw_explicit = True + if current_bond.type == 'double' or current_bond.type == 'triple' or \ + (previous_atom and previous_bond.type == 'triple'): + next_atom.angle = math.radians(0) + angle_ = previous_angle + next_atom.angle + self.create_next_bond(next_atom, atom, angle_) + elif previous_atom and len(previous_atom.rings) > 0: + proposed_angle_1: float = math.radians(60.0) + proposed_angle_2: float = proposed_angle_1 * -1 + + proposed_vector_1: 'Vector' = Vector(self.bond_length, 0) + proposed_vector_2: 'Vector' = Vector(self.bond_length, 0) + proposed_vector_1.rotate(proposed_angle_1 + atom.get_angle()) + proposed_vector_2.rotate(proposed_angle_2 + atom.get_angle()) + proposed_vector_1.add(atom.position) + proposed_vector_2.add(atom.position) + centre_of_mass: Vector = self.get_current_centre_of_mass() + distance_1: float = proposed_vector_1.get_squared_distance(centre_of_mass) + distance_2: float = proposed_vector_2.get_squared_distance(centre_of_mass) + if distance_1 < distance_2: + previous_atom.angle = proposed_angle_2 + else: + previous_atom.angle = proposed_angle_1 + angle_: float = previous_angle + previous_atom.angle + self.create_next_bond(next_atom, atom, angle_) + else: + proposed_angle: float = atom.angle + + if previous_atom and len(previous_atom.neighbours) > 3: + if round(proposed_angle, 2) > 0.00: + proposed_angle: float = min([math.radians(60), proposed_angle]) + elif round(proposed_angle, 2) < 0.00: + proposed_angle: float = max([-math.radians(60), proposed_angle]) + else: + proposed_angle: float = math.radians(60) + elif proposed_angle in (0, None): + last_angled_atom: 'AtomProperties' = self.get_last_atom_with_angle(atom) + proposed_angle: float = last_angled_atom.angle + if proposed_angle is None: + proposed_angle: float = math.radians(60) + + rotatable: bool = True + if previous_atom: + bond: 'BondProperties' = self.bond_lookup(previous_atom, atom) + # This handles cases where there are no second explicit atoms in the + # configuration # of carbons between which cis and trans isomerism can occur + # For example smile = "F/C=C/F" or "F/C=C\F". + if bond.type == 'double': + rotatable: bool = False + previous_previous_atom: 'AtomProperties' = previous_atom.previous_atom + if previous_previous_atom: + if (configuration := self.get_configuration(previous_atom, atom)) is not None: + if configuration == 'cis': + proposed_angle = -proposed_angle + if rotatable: + next_atom.angle = proposed_angle if previous_branch_shortest else -proposed_angle + else: + next_atom.angle = -proposed_angle + self.create_next_bond(next_atom, atom, previous_angle + next_atom.angle) + + + + def calculate_2_neighbours(self, atom: 'AtomProperties', neighbours: List['AtomProperties'], \ + previous_atom: 'AtomProperties', previous_angle: float) -> None: + """ + Calculates the positions for an atom with exactly two neighbours in the molecular + structure, considering cis and trans isomerism and the shortest branch condition. + + This method is responsible for determining the positions of an atom that has exactly two + neighbours within the molecular structure. It takes into account the possibility of cis + and trans isomerism and adjusts the atom's orientation based on the shortest branch + condition to ensure correct spatial arrangement. The method first checks for the + presence of a proposed angle for the atom; if none is found, a default angle is + assigned. It then handles cis and trans isomerism by adjusting the angles of the atom + and its neighbours accordingly. The method also determines whether the previous branch + is the shortest by comparing the sizes of subgraphs involving the previous atom and the + neighbours, which influences the orientation of the new bonds created. Finally, it + creates the next bonds for the atom with its neighbours, incorporating the calculated + angles and the shortest branch condition. + + Parameters: + :param atom AtomProperties: + The atom for which the positions are being calculated. + :param neighbours List[AtomProperties]: + A list of the atom's neighbours, which should contain exactly two elements. + :param previous_atom AtomProperties: + The atom preceding the current atom in the molecular structure, used as a reference + for positioning. + :param previous_angle float: + The angle in radians between the previous atom and the current atom. + """ + proposed_angle = atom.angle + if not proposed_angle: + proposed_angle = math.radians(60) + + self.handle_cis_trans_isomery(atom, neighbours, previous_atom, proposed_angle) + + if previous_atom: + subgraph_3_size: int = self.get_subgraph_size(previous_atom, {atom}) + else: + subgraph_3_size: int = 0 + + previous_branch_shortest = False + if subgraph_3_size < self.get_subgraph_size(neighbours[0], {atom}) and \ + subgraph_3_size < self.get_subgraph_size(neighbours[1], {atom}): + previous_branch_shortest = True + + self.create_next_bond(neighbours[0], atom, previous_angle + neighbours[0].angle, previous_branch_shortest) + self.create_next_bond(neighbours[1], atom, previous_angle + neighbours[1].angle, previous_branch_shortest) + + + + def handle_cis_trans_isomery(self, atom: 'AtomProperties', neighbours: List['AtomProperties'], \ + previous_atom: 'AtomProperties', proposed_angle: float) -> None: + """ + Handles the case of cis and trans isomerism for an atom with two neighbours, adjusting + their angles based on the isomeric configuration. + + This method addresses the specific scenario of cis and trans isomerism for an atom + connected to two neighbours, determining the correct spatial orientation based on the + isomeric configuration and the types of bonds involved. It calculates the subgraph sizes + for each neighbour relative to the atom to identify the cis and trans positions, + adjusting their angles accordingly. The method also considers the bond types between the + atom and its neighbours to further refine the orientation in cases where both bonds are + single, potentially adjusting angles based on the configuration of the previous atom in + the molecular structure. + + Parameters: + :param atom AtomProperties: + The central atom for which cis and trans isomerism is being evaluated. + :param neighbours List[AtomProperties]: + A list containing exactly two neighbours of the atom, between which cis and trans + isomerism is considered. + :param previous_atom AtomProperties: + The atom preceding the current atom in the molecular structure, used for additional + configuration checks. + :param proposed_angle: float: The initial proposed angle for orientation, in radians. + """ + neighbour_1, neighbour_2 = neighbours + subgraph_1_size: int = self.get_subgraph_size(neighbour_1, {atom}) + subgraph_2_size: int = self.get_subgraph_size(neighbour_2, {atom}) + + cis_atom_index: int = 0 + trans_atom_index: int = 1 + + if neighbour_2.symbol == 'C' and neighbour_1.symbol != 'C' and subgraph_2_size > 1 and subgraph_1_size < 5: + cis_atom_index = 1 + trans_atom_index = 0 + elif neighbour_2.symbol != 'C' and neighbour_1.symbol == 'C' and subgraph_1_size > 1 and subgraph_2_size < 5: + cis_atom_index = 0 + trans_atom_index = 1 + elif subgraph_2_size > subgraph_1_size: + cis_atom_index = 1 + trans_atom_index = 0 + + cis_atom: 'AtomProperties' = neighbours[cis_atom_index] + trans_atom: 'AtomProperties' = neighbours[trans_atom_index] + + trans_atom.angle = proposed_angle + cis_atom.angle = -proposed_angle + + cis_bond: 'BondProperties' = self.bond_lookup(atom, cis_atom) + trans_bond: 'BondProperties' = self.bond_lookup(atom, trans_atom) + + if cis_bond.type == 'single' and trans_bond.type == 'single': + if previous_atom: + previous_bond: 'BondProperties' = self.bond_lookup(atom, previous_atom) + if previous_bond.type == 'double': + if previous_atom.previous_atom: + atom1, atom2 = previous_atom, atom + configuration_cis_atom: Optional[str] = self.get_configuration(atom1, atom2) + if configuration_cis_atom == 'trans': + trans_atom.angle = -proposed_angle + cis_atom.angle = proposed_angle + + + + def calculate_3_neighbours(self, atom: 'AtomProperties', neighbours: List['AtomProperties'], \ + previous_atom: 'AtomProperties', previous_angle: float) -> None: + """ + Calculates the positions for an atom with exactly three neighbours in the molecular + structure, adjusting angles based on subgraph sizes and ring involvement. + + This method is designed to handle the placement of an atom that has exactly three + neighbours within the molecular structure. It determines the orientation of these + neighbours based on the sizes of their subgraphs relative to the central atom and + adjusts their angles accordingly. The method identifies a 'straight' atom, which is + considered the primary direction of extension from the central atom, and two side atoms. + The orientation of these atoms is adjusted based on whether they are involved in any + rings and the overall structure of the molecule, ensuring a correct spatial arrangement + that minimizes overlaps and maintains the integrity of the molecular geometry. + + Parameters: + :param atom: AtomProperties + The central atom for which the positions of its neighbours are being calculated. + :param neighbours List[AtomProperties]: + A list of the atom's neighbours, which should contain exactly three elements. + :param previous_atom AtomProperties: + The atom preceding the current atom in the molecular structure, used as a reference + for positioning. + :param previous_angle float: + The angle in radians between the previous atom and the current atom, influencing + the orientation of the neighbours. + """ + subgraph_1_size = self.get_subgraph_size(neighbours[0], {atom}) + subgraph_2_size = self.get_subgraph_size(neighbours[1], {atom}) + subgraph_3_size = self.get_subgraph_size(neighbours[2], {atom}) + straight_atom: 'AtomProperties' = neighbours[0] + left_atom: 'AtomProperties' = neighbours[1] + right_atom: 'AtomProperties' = neighbours[2] + if subgraph_2_size > subgraph_1_size and subgraph_2_size > subgraph_3_size: + straight_atom = neighbours[1] + left_atom = neighbours[0] + right_atom = neighbours[2] + elif subgraph_3_size > subgraph_1_size and subgraph_3_size > subgraph_2_size: + straight_atom = neighbours[2] + left_atom = neighbours[0] + right_atom = neighbours[1] + if previous_atom and len(previous_atom.rings) < 1\ + and len(straight_atom.rings) < 1\ + and len(left_atom.rings) < 1\ + and len(right_atom.rings) < 1\ + and self.get_subgraph_size(left_atom, {atom}) == 1\ + and self.get_subgraph_size(right_atom, {atom}) == 1\ + and self.get_subgraph_size(straight_atom, {atom}) > 1: + straight_atom.angle = atom.angle * -1 #maybe bug + if atom.angle >= 0: + left_atom.angle = math.radians(30) + right_atom.angle = math.radians(90) + else: + left_atom.angle = math.radians(-30) + right_atom.angle = math.radians(-90) + else: + straight_atom.angle = math.radians(0) + left_atom.angle = math.radians(90) + right_atom.angle = math.radians(-90) + self.create_next_bond(straight_atom, atom, previous_angle + straight_atom.angle) + self.create_next_bond(left_atom, atom, previous_angle + left_atom.angle) + self.create_next_bond(right_atom, atom, previous_angle + right_atom.angle) + + + + def calculate_4_neighbours(self, atom: 'AtomProperties', neighbours: List['AtomProperties'], \ + previous_angle: float) -> None: + """ + Handles the case when an atom has exactly four neighbours, adjusting their positions and + angles for correct spatial arrangement. + + This method is responsible for calculating the positions and angles of an atom that is + connected to four neighbours within the molecular structure. It determines the optimal + arrangement of these neighbours based on the sizes of their subgraphs relative to the + central atom, ensuring a non-overlapping and structurally sound configuration. The + method assigns specific angles to each neighbour to maintain a consistent and clear + representation of the molecular geometry, especially in complex molecular structures + where an atom is central to four other atoms. + + Parameters: + :param atom AtomProperties: + The central atom around which the neighbours are positioned. + :param neighbours List[AtomProperties]: + A list of the atom's neighbours, which should contain exactly four elements. + :param previous_angle float: + The angle in radians between the previous atom and the current atom, used as a + reference for positioning the neighbours. + """ + subgraph_1_size = self.get_subgraph_size(neighbours[0], {atom}) + subgraph_2_size = self.get_subgraph_size(neighbours[1], {atom}) + subgraph_3_size = self.get_subgraph_size(neighbours[2], {atom}) + subgraph_4_size = self.get_subgraph_size(neighbours[3], {atom}) + atom_1: 'AtomProperties' = neighbours[0] + atom_2: 'AtomProperties' = neighbours[1] + atom_3: 'AtomProperties' = neighbours[2] + atom_4: 'AtomProperties' = neighbours[3] + if subgraph_2_size > subgraph_1_size and subgraph_2_size > subgraph_3_size\ + and subgraph_2_size > subgraph_4_size: + atom_1 = neighbours[1] + atom_2 = neighbours[0] + elif subgraph_3_size > subgraph_1_size and subgraph_3_size > subgraph_2_size\ + and subgraph_3_size > subgraph_4_size: + atom_1 = neighbours[2] + atom_2 = neighbours[0] + atom_3 = neighbours[1] + elif subgraph_4_size > subgraph_1_size and subgraph_4_size > subgraph_2_size\ + and subgraph_4_size > subgraph_3_size: + atom_1 = neighbours[3] + atom_2 = neighbours[0] + atom_3 = neighbours[1] + atom_4 = neighbours[2] + + atom_1.angle = math.radians(-36) + atom_2.angle = math.radians(36) + atom_3.angle = math.radians(-108) + atom_4.angle = math.radians(108) + self.create_next_bond(atom_1, atom, previous_angle + atom_1.angle) + self.create_next_bond(atom_2, atom, previous_angle + atom_2.angle) + self.create_next_bond(atom_3, atom, previous_angle + atom_3.angle) + self.create_next_bond(atom_4, atom, previous_angle + atom_4.angle) + + + + def get_subgraph_size(self, atom: 'AtomProperties', \ + masked_atoms: Set['AtomProperties']) -> int: + """ + Calculates and returns the size of a subtree rooted at a given atom, excluding bonds adjacent to atoms specified in masked_atoms. + + This method computes the size of a subtree within a molecular graph, starting from a + specified atom and excluding any bonds connected to atoms listed in the `masked_atoms` + set. It recursively explores the molecular structure, adding each visited atom to the + `masked_atoms` set to avoid revisiting, and counts the total number of unique atoms in + the subtree. The size of the subtree is determined by the number of atoms it contains, + excluding the initial atom itself. + + Parameters: + :param atom AtomProperties: + The root atom from which the subtree size is calculated. + :param masked_atoms Set[AtomProperties]: + A set of atoms to be excluded from the subtree calculation, typically used to avoid + counting atoms that have already been considered in previous calculations or are not + relevant to the current analysis. + """ + masked_atoms.add(atom) + for neighbour in atom.neighbours: + if neighbour not in masked_atoms: + self.get_subgraph_size(neighbour, masked_atoms) + return len(masked_atoms) - 1 + + + ## rings calculated + # @TODO: Дать нормальное название + def calculate_rings(self, previous_atom: 'AtomProperties', \ + atom: 'AtomProperties') -> None: + """ + Calculates the positions for atoms within rings and aromatic systems, treating the + calculation as if it's performed from within the ring itself. + + This method is designed to determine the coordinates of atoms that are part of ring + structures or aromatic systems within a molecular graph. It operates under the + assumption that the calculation is being performed from the perspective of being inside + the ring, allowing for accurate positioning of atoms based on their connectivity and the + geometry of the ring. The method takes into account whether the previous atom is part of + a bridged ring and adjusts the position of the current atom accordingly, ensuring that + the ring's integrity and aromaticity are preserved in the molecular representation. It + involves identifying a 'joined vertex' if the previous atom is part of multiple rings, + adjusting the position based on the relative positions of neighbours, and setting the + atom's position to maintain the ring's structure. + + Parameters: + :param previous_atom AtomProperties: + The atom preceding the current atom in the ring, used as a reference for + calculating the current atom's position. + :param atom AtomProperties: + The atom for which the position is being calculated, ensuring it fits correctly + within the ring structure. + """ + neighbours: List['AtomProperties'] = previous_atom.neighbours + joined_vertex: Optional['AtomProperties'] = None + position: Vector = Vector(0, 0) + if previous_atom.bridged_ring is None and len(previous_atom.rings) > 1: + for neighbour in neighbours: + if len(set(neighbour.rings).intersection(set(previous_atom.rings))) == len(previous_atom.rings): + joined_vertex: 'AtomProperties' = neighbour + break + + + if not joined_vertex: + for neighbour in neighbours: + if neighbour.positioned and self.atoms_are_in_same_ring(neighbour, previous_atom): + position.add(Vector.subtract_vectors(neighbour.position, previous_atom.position)) + position.invert() + position.normalise() + position.multiply_by_scalar(self.bond_length) + position.add(previous_atom.position) + else: + position = joined_vertex.position.copy() + position.rotate_around_vector(math.pi, previous_atom.position) + atom.set_previous_position(previous_atom) + atom.set_position(position) + atom.positioned = True + + + # @TODO: Дать нормальное название + def calculate_some_rings(self, atom: 'AtomProperties') -> None: + """ + Calculates the coordinates for an atom connected to a ring, handling both bridged and + non-bridged ring scenarios. + + This method is responsible for determining the coordinates of an atom that is part of a + ring structure within a molecule. It distinguishes between atoms connected to bridged + rings and those that are part of regular rings, adjusting the calculation accordingly to + ensure accurate positioning within the molecular structure. For atoms connected to + bridged rings, it retrieves the specific ring properties to handle the complexity of + bridged systems, while for atoms in regular rings, it defaults to the first ring in the + atom's ring list. The method then calculates the center position of the ring based on + the atom's previous position and the ring's geometry, ensuring the atom is correctly + placed relative to the ring's center. This involves inverting the vector from the atom's + previous position to its current position, normalizing it, and scaling it according to + the ring's radius to find the new center. Finally, it creates the ring with the + calculated center, ensuring the atom's position is accurately represented within the + ring structure. + + Parameters: + :param atom AtomProperties: + The atom for which the ring coordinates are being calculated. This atom is assumed to be part of a ring structure, either directly or through a bridged connection. + """ + if atom.bridged_ring: + next_ring: 'RingProperties' = self.id_to_ring[atom.bridged_ring] + else: + next_ring: 'RingProperties' = atom.rings[0] + + if not next_ring.positioned: + next_center = Vector.subtract_vectors(atom.previous_position, atom.position) + next_center.invert() + next_center.normalise() + radius: float = Polygon.find_polygon_radius(self.bond_length, len(next_ring.members)) + next_center.multiply_by_scalar(radius) + next_center.add(atom.position) + self.create_ring(next_ring, next_center, atom) + + + + def create_ring(self, ring: 'RingProperties', center: Optional[Vector] = None, + start_atom: Optional['AtomProperties'] = None, + previous_atom: Optional['AtomProperties'] = None) -> None: + """ + Creates a ring within a molecular structure, considering its geometry and interaction + with other rings. + + This method is responsible for creating and positioning atoms in ring structures of a + molecule. It takes into account whether the ring is bridged, determines its center, and + arranges atoms according to the geometry of the ring, as well as handles interactions + with other rings through common vertices. For bridged rings, a special algorithm is used + to correctly position atoms relative to the ring center. For non-bridged rings, atoms + are positioned based on a given angle and radius calculated from the bond length and + number of members in the ring. The method also handles cases where ring atoms intersect + with other rings, ensuring correct connections and preventing overlaps. + + Parameters: + :param ring RingProperties: + Properties of the ring for which atom coordinates are being created. + :param center Optional[Vector]: + Center of the ring. If not specified, the origin (0, 0) is used. + :param start_atom Optional[AtomProperties]: + Starting atom for calculating positions of other atoms in the ring. + :param previous_atom Optional[AtomProperties]: + Previous atom used to determine the starting angle. + """ + if ring.positioned: + return + + if center is None: + center = Vector(0, 0) + ordered_neighbour_ids: List[int] = self.get_ordered_neighbours(ring, self.ring_overlaps) + starting_angle: float = 0 + if start_atom: + starting_angle = Vector.subtract_vectors(start_atom.position, center).angle() + ring_size: int = len(ring.members) + radius: float = Polygon.find_polygon_radius(self.bond_length, ring_size) + angle: float = Polygon.get_central_angle(ring_size) + ring.central_angle = angle + if start_atom not in ring.members: + if start_atom: + start_atom.positioned = False + start_atom = ring.members[0] + + + if ring.bridged: + KKLayout(structure=self, + atoms=ring.members, + center=center, + start_atom=start_atom, + bond_length=self.bond_length) + + + ring.positioned = True + self.set_ring_center(ring) + center = ring.center + for subring in ring.subrings: + self.set_ring_center(subring) + else: + self.set_member_positions(ring, start_atom, previous_atom, center, starting_angle, radius, angle) + ring.positioned = True + ring.center = center + + for neighbour_id in ordered_neighbour_ids: + neighbour: 'RingProperties' = self.id_to_ring[neighbour_id] + if neighbour.positioned: + continue + atoms: Optional[List['AtomProperties']] = self.get_vertices(self.ring_overlaps, ring.id, neighbour.id) + if len(atoms) == 2: + self.handle_fused_rings(ring, neighbour, atoms, center) + elif len(atoms) == 1: + self.handle_spiro_rings(ring, neighbour, atoms[0], center) + + for atom in ring.members: + for neighbour in atom.neighbours: + if neighbour.positioned: + continue + atom.connected_to_ring = True + self.create_next_bond(neighbour, atom, 0.0) + + + + def handle_fused_rings(self, ring: 'RingProperties', neighbour: 'RingProperties', + atoms: List['AtomProperties'], center: Vector) -> None: + """ + Handles the processing of fused cyclic systems within molecular structures, such as + decalin ('C12CCCCC1CCCC2'). + + This method addresses the specific challenges of dealing with fused ring systems in + molecular structures, where two rings share common atoms, creating complex cyclic + compounds like decalin. It marks both rings involved as fused, calculates the midpoint + between two shared atoms, determines the normals at this midpoint, and adjusts these + normals based on the apothem of the neighboring ring to find potential centers for the + next ring positions. By comparing distances from a given center to these adjusted + normals, it selects the most suitable center for creating a new ring configuration that + accommodates the fused nature of the system. Depending on the orientation of the shared + atoms, it then creates a new ring with the selected center, ensuring the integrity of + the molecular structure is maintained during the fusion process. + + Parameters: + :param ring RingProperties: + One of the rings involved in the fusion. + :param neighbour RingProperties: + The neighboring ring involved in the fusion. + :param atoms List[AtomProperties]: + A list of atoms shared between the fused rings, typically two atoms common to both + rings. + :param center Vector: + The center point around which the fusion is considered, influencing the orientation + of the newly formed ring structure. + """ + ring.fused = True + neighbour.fused = True + atom_1: 'AtomProperties' = atoms[0] + atom_2: 'AtomProperties' = atoms[1] + midpoint: 'Vector' = Vector.get_midpoint(atom_1.position, atom_2.position) + normals: List['Vector'] = Vector.get_normals(atom_1.position, atom_2.position) + normals[0].normalise() + normals[1].normalise() + + apothem: float = Polygon.get_apothem_from_side_length(self.bond_length, len(neighbour.members)) + normals[0].multiply_by_scalar(apothem) + normals[1].multiply_by_scalar(apothem) + normals[0].add(midpoint) + normals[1].add(midpoint) + next_center: 'Vector' = normals[0] + distance_to_center_1 = Vector.subtract_vectors(center, normals[0]).get_squared_length() + distance_to_center_2 = Vector.subtract_vectors(center, normals[1]).get_squared_length() + if distance_to_center_2 > distance_to_center_1: + next_center = normals[1] + position_1: 'Vector' = Vector.subtract_vectors(atom_1.position, next_center) + position_2: 'Vector' = Vector.subtract_vectors(atom_2.position, next_center) + if position_1.get_clockwise_orientation(position_2) == 'clockwise': + if not neighbour.positioned: + self.create_ring(neighbour, next_center, atom_1, atom_2) + else: + if not neighbour.positioned: + self.create_ring(neighbour, next_center, atom_2, atom_1) + + + + def handle_spiro_rings(self, ring: 'RingProperties', neighbour: 'RingProperties', + atom: 'AtomProperties', center: Vector) -> None: + """ + Handles spirocyclic systems within molecular structures, such as 'C1CCCC11CC1'. + + Spirocyclic systems are characterized by two rings that share a single common atom, + forming a spiro junction. This method marks both rings as spirocyclic, calculates a new + center for the neighboring ring based on the position of the shared atom and the center + of the current ring, and adjusts the position of the neighboring ring accordingly to + maintain the integrity of the spirocyclic structure. + + Parameters: + :param ring RingProperties: + The current ring involved in the spirocyclic system. + :param neighbour RingProperties: + The neighboring ring involved in the spirocyclic system. + :param atom AtomProperties: + The atom shared by both rings at the spiro junction. + :param center Vector: + The center of the current ring, used as a reference for calculating the new center + for the neighboring ring. + """ + ring.spiro = True + neighbour.spiro = True + next_center: 'Vector' = Vector.subtract_vectors(center, atom.position) + next_center.invert() + next_center.normalise() + distance_to_center: float = Polygon.find_polygon_radius(self.bond_length, len(neighbour.members)) + next_center.multiply_by_scalar(distance_to_center) + next_center.add(atom.position) + if not neighbour.positioned: + self.create_ring(neighbour, next_center, atom) + + + ##auxiliary functions for rings calculated + def set_ring_center(self, ring: 'RingProperties') -> None: + """ + Calculates and sets the geometric center of a ring within a molecular structure. + + This method computes the center of a ring by averaging the positions of all atoms that + are members of the ring. It iterates through each atom in the ring, summing their + positions vectorially, and then divides the total by the number of atoms to find the + average position, which represents the ring's center. This center is then assigned to + the ring's `center` attribute, providing a reference point for further calculations and + manipulations involving the ring. + + Parameters: + :param ring RingProperties: + The ring for which the center is to be calculated. This object represents a cyclic + structure within the molecule, containing a list of atoms that are part of the ring. + """ + total: Vector = Vector(0, 0) + for atom in ring.members: + total.add(atom.position) + total.divide(len(ring.members)) + ring.center = total + + + + def atoms_are_in_same_ring(self, atom_1: 'AtomProperties', atom_2: 'AtomProperties') -> bool: + """ + Determines if two atoms are part of the same ring within a molecular structure. + + This method checks if two given atoms are part of the same ring by comparing their ring + memberships. It iterates through the rings of the first atom and checks if any of these + rings are also present in the list of rings for the second atom. If any ring is common + between the two atoms, it indicates that they are part of the same ring structure. + + Parameters: + :param atom_1 AtomProperties: + The first atom to check for ring membership. + :param atom_2 AtomProperties: + The second atom to check for ring membership. + + Returns bool: + True if the atoms are in the same ring, False otherwise. + """ + return any(ring_id_1 == ring_id_2 for ring_id_1 in atom_1.rings \ + for ring_id_2 in atom_2.rings) + + + + def get_vertices(self, ring_overlaps: List['RingOverlap'], ring_id_1: int, \ + ring_id_2: int) -> Optional[List['AtomProperties']]: + """ + Searches for and returns atoms that are in the overlap between two rings identified by ring_id_1 and ring_id_2. + + This method looks for atoms that are located in the overlap between two specified rings. If an overlap is found, it returns a list of atoms that are part of this overlap. If no overlap is found, the method concludes without returning any value, implying a default return of None. + + Parameters: + :param ring_overlaps List[RingOverlap]: + A list of RingOverlap objects representing overlaps between rings in the molecule. + :param ring_id_1 int: + The identifier of the first ring to check for overlaps. + :param ring_id_2 int: + The identifier of the second ring to check for overlaps. + + Returns Optional[List[AtomProperties]: + A list of AtomProperties objects representing atoms in the overlap between the two + rings, or None if no overlap is found. + """ + for ring_overlap in ring_overlaps: + if set((ring_overlap.ring_id_1, ring_overlap.ring_id_2)) == set((ring_id_1, ring_id_2)): + return [atom for atom in ring_overlap.atoms] + + + + def get_ordered_neighbours(self, ring: 'RingProperties', \ + ring_overlaps: List['RingOverlap']) -> List[int]: + """ + Retrieves an ordered list of neighboring rings based on the number of atoms they share in common with the specified ring. + + This method is designed to obtain an ordered list of neighboring rings (or structures) + based on the number of atoms they have in intersection with the given ring. It returns a + list of identifiers for the neighboring rings, sorted by the number of shared atoms in + descending order, allowing for the identification of the most closely related rings in + terms of atomic overlap. + + Parameters: + :param ring RingProperties: + The ring for which neighboring rings are to be identified and ordered. + :param ring_overlaps List[RingOverlap]: + A list of RingOverlap objects representing overlaps between rings in the molecule, + used to determine the intersection of atoms between rings. + + Returns List[int]: + A list of identifiers for neighboring rings, ordered by the number of shared atoms + in descending order. Rings with more shared atoms are listed first. + """ + ordered_neighbours_and_atom_nrs = [] + for neighbour_id in ring.neighbouring_rings: + atoms: Optional[List['AtomProperties']] = self.get_vertices(ring_overlaps, ring.id, neighbour_id) + ordered_neighbours_and_atom_nrs.append((len(atoms), neighbour_id)) + + ordered_neighbours_and_atom_nrs = sorted(ordered_neighbours_and_atom_nrs, key=lambda x: x[0], reverse=True) + ordered_neighbour_ids = [x[1] for x in ordered_neighbours_and_atom_nrs] + return ordered_neighbour_ids + + + + def set_member_positions(self, ring: 'RingProperties', start_atom: 'AtomProperties', \ + previous_atom: Optional['AtomProperties'], center: 'Vector', \ + starting_angle: float, radius: float, angle: float) -> None: + """ + Positions atoms within a ring structure using polar coordinates (center, radius, angle) + and incrementally increases the angle between atoms. + + The primary goal of this method is to arrange atoms in a ring structure by utilizing + polar coordinates, where each atom's position is determined relative to a central point, + at a specified radius, and with a progressively increasing angle to ensure even + distribution around the center. This method iterates through the atoms of the ring, + setting their positions based on these polar coordinates until all atoms are positioned + or a maximum iteration limit is reached. + + Parameters: + :param ring RingProperties: + The ring whose member atoms are to be positioned. + :param start_atom AtomProperties: + The starting atom for positioning within the ring. + :param previous_atom Optional[AtomProperties]: + The atom preceding the current atom in the positioning sequence. Used as a reference + for the first atom's position if it hasn't been positioned yet. + :param center Vector: + The central point around which atoms are positioned. + :param starting_angle float: + The initial angle in radians for the first atom's position relative to the center. + :param radius float: + The distance from the center to the atom's position. + :param angle float: + The angular increment in radians between consecutive atoms in the ring. + """ + current_atom = start_atom + iteration = 0 + while current_atom != None and iteration < 100: + previous = current_atom + if not previous.positioned: + x = center.x + math.cos(starting_angle) * radius + y = center.y + math.sin(starting_angle) * radius + previous.set_position(Vector(x, y)) + starting_angle += angle + + if len(ring.subrings) < 3: + previous.angle = starting_angle + previous.positioned = True + + current_atom = self.get_next_in_ring(ring, current_atom, previous_atom) + previous_atom = previous + + if current_atom == start_atom: + current_atom = None + iteration += 1 + + + + def get_next_in_ring(self, ring: 'RingProperties', current_atom: 'AtomProperties', \ + previous_atom: 'AtomProperties') -> Optional['AtomProperties']: + """ + Searches for the next atom in the ring, excluding the previous atom. + + This method iterates through the neighbors of the current atom to find the next atom + that is a member of the specified ring, excluding the atom that was previously + considered. It checks each neighbor to see if it belongs to the ring and is not the same + as the previous atom. If a suitable atom is found, it is returned; otherwise, None is + returned. This is useful for traversing the atoms in a ring structure while ensuring + that the traversal does not immediately return to the previous atom, allowing for a + continuous loop through the ring's members. + + Parameters: + :param ring RingProperties: + The ring within which to search for the next atom. + :param current_atom AtomProperties: + The current atom from which to start the search. + :param previous_atom AtomProperties: + The atom to exclude from the search, typically the atom preceding the current atom + in the traversal. + + Returns Optional[AtomProperties]: + The next atom in the ring that is different from the previous atom, or None if no + such atom is found. + """ + neighbours: List[int] = current_atom.neighbours + for neighbour in neighbours: + for member in ring.members: + if neighbour == member: + if previous_atom != neighbour: + return neighbour + + + @staticmethod + def find_neighbouring_rings(ring_overlaps: List['RingOverlap'], ring_id: int) -> List[int]: + """ + Finds and returns a list of identifiers for rings neighboring the specified ring. + + This method searches through a list of ring overlaps to identify rings that are adjacent + to a given ring, identified by its ID. It examines each overlap to determine if the + specified ring is involved and collects the identifiers of neighboring rings, excluding + the specified ring itself. This is useful for understanding the connectivity and + structure of rings within a molecular graph, especially in complex molecules where rings + may overlap or share atoms. + + Parameters: + :param ring_overlaps List[RingOverlap]: + A list of RingOverlap objects, each representing an overlap between two rings in the + molecule. These objects contain information about which rings are involved in each + overlap. + :param ring_id int: + The identifier of the ring for which neighboring rings are to be found. + + Returns List[int]: + A list of identifiers for rings that are neighbors to the specified ring, based on + the overlaps. Each identifier represents a ring that shares at least one atom with + the specified ring, indicating a direct connection or overlap. + """ + neighbouring_rings = [] + for ring_overlap in ring_overlaps: + if ring_overlap.ring_id_1 == ring_id: + neighbouring_rings.append(ring_overlap.ring_id_2) + elif ring_overlap.ring_id_2 == ring_id: + neighbouring_rings.append(ring_overlap.ring_id_1) + return neighbouring_rings + + + + def get_current_centre_of_mass(self) -> Vector: + """ + Calculates and returns the current center of mass of the molecular graph. + + This method computes the center of mass of the molecular graph by summing the positions + of all positioned atoms and dividing by the number of positioned atoms. It iterates + through each atom in the graph, adding the position of each atom to a total if the atom + is positioned, and then divides this total by the count of positioned atoms to find the + average position, which represents the center of mass. This center of mass can be used + as a reference point for various calculations and manipulations within the molecular + structure, such as aligning or repositioning atoms relative to the overall structure. + + Returns Vector: + A Vector object representing the coordinates of the center of mass of the molecular + graph, based on the positions of all positioned atoms. + """ + total = Vector(0, 0) + count = 0 + for atom in self.graph: + if atom.positioned: + total.add(atom.position) + count += 1 + total.divide(count) + return total + + + + def get_last_atom_with_angle(self, atom: 'AtomProperties') -> Optional['AtomProperties']: + """ + Retrieves the last atom in a chain that has a defined angle relative to the initial atom. + + This method traverses backwards from a given atom through its predecessors until it + finds an atom with a defined angle or reaches an atom without a predecessor. It starts + with the initial atom's immediate predecessor and continues to trace back through the + chain of previous atoms until it encounters an atom with a non-zero angle or reaches the + beginning of the chain. + + Parameters: + :param atom AtomProperties: + The starting atom from which to begin the search for an atom with a defined angle. + + Returns Optional[AtomProperties]: + The last atom in the chain that has a defined angle, or None if no such atom is + found before reaching the start of the chain. + """ + parent_atom: Optional['AtomProperties'] = atom.previous_atom + angle: float = parent_atom.angle + while parent_atom and not angle: + parent_atom = parent_atom.previous_atom + angle = parent_atom.angle + return parent_atom + + + # упаковка и возвращение координат + def get_coord(self, order: List[int]) -> List[List[float]]: + """ + Packs and returns the coordinates of atoms in a two-dimensional array based on the + specified order. + + Parameters: + :param order List[int]: + A list of integers representing the order in which atom coordinates should be + packed. Each integer corresponds to an atom index in the class's atom dictionary. + + Returns List[List[float]]: + A two-dimensional list where each inner list contains the x and y coordinates of an + atom, following the order specified in the input list. + """ + xy: List[List[float]] = [] + for ord in order: + vector = self.atoms[ord].position + xy.append([vector.x, vector.y]) + return xy + + + # обработка коллизий + def collision_handling(self) -> None: + """ + Handles the positioning of all atoms and resolves any overlaps within the molecular structure. + + This method is responsible for adjusting the positions of atoms to minimize overlaps and + ensure a visually coherent representation of the molecule. It begins by resolving + primary overlaps through a preliminary adjustment phase, followed by an iterative + process to refine the positions of atoms based on their bonds and connectivity. The + process involves calculating the total overlap score, identifying atoms that can be + rotated around their bonds, and adjusting their positions to reduce overlaps. It also + considers the complexity of the subgraphs connected to each atom, preferring to rotate + smaller subgraphs to minimize disruption. Special handling is given to atoms connected + by double bonds, which are less flexible. The method iteratively adjusts the positions + of atoms based on their overlap scores, sensitivity thresholds, and the ability to + rotate around bonds, aiming to find a configuration that minimizes the total overlap + score. Additionally, it accounts for atoms within rings and their specific constraints, + applying rotations to subtrees of atoms to resolve overlaps while maintaining the + integrity of the molecular structure. The process is repeated for a set number of + iterations to gradually improve the layout, with an option for fine-tuning overlaps if + enabled. + + The collision handling process includes: + - Resolving primary overlaps to ensure atoms do not occupy the same space. + - Calculating the total overlap score and identifying atoms that can be rotated to + reduce overlaps. + - Adjusting atom positions based on their connectivity and the presence of double bonds, + which restrict rotation. + - Considering the depth of subgraphs connected to each atom to decide which atom to + rotate in cases of overlap. + - Applying rotations to subtrees to resolve overlaps, with specific logic for atoms + connected by single or double bonds. + - Repeatedly recalculating overlap scores and adjusting positions until a satisfactory + layout is achieved or the maximum number of iterations is reached. + - Optionally, fine-tuning the positions for further refinement if the `finetune` flag is + set. + """ + self.resolve_primary_overlaps() + + self.total_overlap_score, sorted_overlap_scores, atom_to_scores = self.get_overlap_score() + for i in range(self.overlap_resolution_iterations): + for (atom1_index, atom2_index), bond in self.bonds.items(): + n: 'AtomProperties' = self.atoms[atom1_index] + m: 'AtomProperties' = self.atoms[atom2_index] + if self.can_rotate_around_bond(bond): + tree_depth_1: int = self.get_subgraph_size(n, {m}) + tree_depth_2: int = self.get_subgraph_size(m, {n}) + atom_1_rotatable: bool = True + atom_2_rotatable: bool = True + for neighbouring_bond in self.get_bonds_of_atom(n): + if neighbouring_bond.type == 'double': + atom_1_rotatable = False + for neighbouring_bond in self.get_bonds_of_atom(m): + if neighbouring_bond.type == 'double': + atom_2_rotatable = False + if not atom_1_rotatable and not atom_2_rotatable: + continue + elif atom_1_rotatable and not atom_2_rotatable: + atom_2: 'AtomProperties' = n + atom_1: 'AtomProperties' = m + elif atom_2_rotatable and not atom_1_rotatable: + atom_1: 'AtomProperties' = n + atom_2: 'AtomProperties' = m + else: + atom_1: 'AtomProperties' = m + atom_2: 'AtomProperties' = n + if tree_depth_1 > tree_depth_2: + atom_1: 'AtomProperties' = n + atom_2: 'AtomProperties' = m + subtree_overlap_score, _ = self.get_subtree_overlap_score(atom_2, atom_1, atom_to_scores) + if subtree_overlap_score > self.overlap_sensitivity: + + neighbours_2 = atom_2.neighbours[:] + neighbours_2.remove(atom_1) + + if len(neighbours_2) == 1: + neighbour = neighbours_2[0] + angle = neighbour.position.get_rotation_away_from_vector(atom_1.position, atom_2.position, math.radians(120)) + self.rotate_subtree(neighbour, atom_2, angle, atom_2.position) + new_overlap_score, _, _ = self.get_overlap_score() + if new_overlap_score > self.total_overlap_score: + self.rotate_subtree(neighbour, atom_2, -angle, atom_2.position) + else: + self.total_overlap_score = new_overlap_score + elif len(neighbours_2) == 2: + if atom_2.rings and atom_1.rings: + continue + neighbour_1: 'AtomProperties' = neighbours_2[0] + neighbour_2: 'AtomProperties' = neighbours_2[1] + if len(neighbour_1.rings) == 1 and len(neighbour_2.rings) == 1: + if neighbour_1.rings[0] != neighbour_2.rings[0]: + continue + elif neighbour_1.rings or neighbour_2.rings: + continue + else: + angle_1 = neighbour_1.position.get_rotation_away_from_vector(atom_1.position, atom_2.position, math.radians(120)) + angle_2 = neighbour_2.position.get_rotation_away_from_vector(atom_1.position, atom_2.position, math.radians(120)) + self.rotate_subtree(neighbour_1, atom_2, angle_1, atom_2.position) + self.rotate_subtree(neighbour_2, atom_2, angle_2, atom_2.position) + new_overlap_score, _, _ = self.get_overlap_score() + if new_overlap_score > self.total_overlap_score: + self.rotate_subtree(neighbour_1, atom_2, -angle_1, atom_2.position) + self.rotate_subtree(neighbour_2, atom_2, -angle_2, atom_2.position) + else: + self.total_overlap_score = new_overlap_score + self.total_overlap_score, sorted_overlap_scores, atom_to_scores = self.get_overlap_score() + for _ in range(self.overlap_resolution_iterations): + self._finetune_overlap_resolution() + self.total_overlap_score, sorted_overlap_scores, atom_to_scores = self.get_overlap_score() + for i in range(self.overlap_resolution_iterations): + self.resolve_secondary_overlaps(sorted_overlap_scores) + + + ## вспомогательные функции для collision_handling + def resolve_primary_overlaps(self) -> None: + """ + Resolves initial overlaps in the molecular structure, focusing on cases where a ring has + two outgoing edges. + + This method addresses the issue of overlaps that occur when a ring within the molecular + structure has two edges extending outwards, which can lead to collisions in the layout, + especially noticeable in representations of cyclohexane in a quarter-staggered + conformation. It identifies atoms that are part of such overlaps by examining each ring + and its members, and then resolves these overlaps by adjusting the positions of the + involved atoms. The resolution process involves calculating the angle of rotation needed + to minimize the overlap and applying this rotation to the subtrees connected to the + overlapping atoms. + """ + overlaps: List = [] + resolved_atoms: Dict[int, bool] = {atom.id: False for atom in self.graph} + + for ring in self.rings: + for atom in ring.members: + if resolved_atoms[atom.id]: + continue + resolved_atoms[atom.id] = True + non_ring_neighbours: List['AtomProperties'] = self.get_non_ring_neighbours(atom) + if len(non_ring_neighbours) > 1 or (len(non_ring_neighbours) == 1 and len(atom.rings) == 2): + overlaps.append({'common': atom, 'rings': atom.rings, 'vertices': non_ring_neighbours}) + for overlap in overlaps: + branches_to_adjust: List['AtomProperties'] = overlap['vertices'] + rings: List['RingProperties'] = overlap['rings'] + root: 'AtomProperties' = overlap['common'] + if len(branches_to_adjust) == 2: + atom_1, atom_2 = branches_to_adjust + angle = (2 * math.pi - rings[0].get_angle()) / 6.0 + self.rotate_subtree(atom_1, root, angle, root.position) + self.rotate_subtree(atom_2, root, -angle, root.position) + total, sorted_scores, atom_to_score = self.get_overlap_score() + subtree_overlap_atom_1_1, _ = self.get_subtree_overlap_score(atom_1, root, atom_to_score) + subtree_overlap_atom_2_1, _ = self.get_subtree_overlap_score(atom_2, root, atom_to_score) + total_score = subtree_overlap_atom_1_1 + subtree_overlap_atom_2_1 + self.rotate_subtree(atom_1, root, -2.0 * angle, root.position) + self.rotate_subtree(atom_2, root, 2.0 * angle, root.position) + total, sorted_scores, atom_to_score = self.get_overlap_score() + subtree_overlap_atom_1_2, _ = self.get_subtree_overlap_score(atom_1, root, atom_to_score) + subtree_overlap_atom_2_2, _ = self.get_subtree_overlap_score(atom_2, root, atom_to_score) + total_score_2 = subtree_overlap_atom_1_2 + subtree_overlap_atom_2_2 + if total_score_2 > total_score: + self.rotate_subtree(atom_1, root, 2.0 * angle, root.position) + self.rotate_subtree(atom_2, root, -2.0 * angle, root.position) + + ## вспомогательные функции для resolve_primary_overlaps + @staticmethod + def get_non_ring_neighbours(atom: 'AtomProperties') -> List['AtomProperties']: + """ + Identifies and returns a list of neighbours of the specified atom that are not part of + any ring it belongs to. + + This method examines the neighbours of a given atom and filters out those that are part + of the same rings as the atom, focusing on neighbours that are not involved in any ring + structure with the atom. It is useful for understanding the connectivity of an atom + within the molecular graph, especially in contexts where the atom's interactions outside + of cyclic structures are of interest. By comparing the ring memberships of the atom and + its neighbours, it identifies neighbours that do not share any rings with the atom and + are not considered bridge atoms, providing insight into the atom's connections to other + parts of the molecule that are not part of its immediate cyclic environment. + + Parameters: + :param atom AtomProperties: + The atom for which non-ring neighbours are to be identified. + + Returns: List[AtomProperties]: + A list of AtomProperties objects representing neighbours of the specified atom that + are not part of any ring the atom is a member of, excluding bridge atoms. + """ + non_ring_neighbours: List['AtomProperties'] = [] + for neighbour in atom.neighbours: + nr_overlapping_rings = len(set(atom.ring_indexes).intersection(set(neighbour.ring_indexes))) + if nr_overlapping_rings == 0 and not neighbour.is_bridge: + non_ring_neighbours.append(neighbour) + return non_ring_neighbours + + ## вспомогательные функции для resolve_primary_overlaps + def rotate_subtree(self, root: 'AtomProperties', root_parent: 'AtomProperties', \ + angle: float, center: 'Vector') -> None: + """ + Rotates a subtree of the molecular structure around a specified center by a given angle. + + This method rotates a subtree within the molecular graph, starting from a root atom, + around a specified center point by a given angle. It is used to adjust the positions of + atoms and their associated structures, such as anchored rings, to resolve overlaps or to + achieve a desired orientation. The rotation is applied to the root atom and all atoms + connected to it within the subtree, excluding the root's parent to maintain the + integrity of the molecular structure. This is particularly useful in the layout process + to minimize overlaps or to align parts of the molecule according to specific + requirements. The rotation affects not only the positions of atoms in the subtree but + also updates the centers of any anchored rings associated with the atoms, ensuring that + the entire subtree is cohesively repositioned. + + Parameters: + :param root AtomProperties: + The root atom of the subtree to be rotated. This atom serves as the starting point + for the rotation. + :param root_parent AtomProperties: + The parent atom of the root, which is excluded from the rotation to maintain the + connection to the rest of the molecular structure. + :param angle float: + The angle in radians by which the subtree will be rotated around the center. + :param center Vector: + The point around which the rotation is performed. This center is typically the + position of a pivotal atom or a calculated point that serves as the axis of rotation. + """ + for atom in self.traverse_substructure(root, {root_parent}): + atom.position.rotate_around_vector(angle, center) + for anchored_ring in atom.anchored_rings: + if anchored_ring.center: + anchored_ring.center.rotate_around_vector(angle, center) + + + ## вспомогательные функции для rotate_subtree + def traverse_substructure(self, atom: 'AtomProperties', visited: Set['AtomProperties']) \ + -> Generator['AtomProperties', None, None]: + """ + Traverses a substructure of the molecular graph starting from a given atom, yielding + atoms in a depth-first manner. + + This method performs a depth-first traversal of the molecular graph, starting from a + specified atom, and yields atoms that are reachable from it, excluding those already + visited to avoid cycles. It is a generator function that explores the molecular + structure by recursively visiting each atom's neighbours, ensuring that each atom is + visited only once. + + Parameters: + :param atom AtomProperties: + The starting atom for the traversal. The traversal begins from this atom and + explores the connected substructure. + :param visited Set[AtomProperties]: + A set of atoms that have already been visited during the traversal. This is used to + avoid revisiting atoms and to ensure that each atom is processed only once. + + Yields AtomProperties: + Atoms in the connected substructure of the starting atom, yielded one at a time. + This allows for iterative processing or analysis of the substructure without the + need to construct and return a complete list of atoms upfront, which can be + beneficial for large molecular graphs. + """ + yield atom + visited.add(atom) + for neighbour in atom.neighbours: + if neighbour not in visited: + yield from self.traverse_substructure(neighbour, visited) + + + ## вспомогательные функции для resolve_primary_overlaps + def get_overlap_score(self) -> Tuple[float, List[Tuple[float, 'AtomProperties']], Dict[int, float]]: + """ + Calculates the total overlap score and returns a sorted list of atoms by their overlap + scores along with the score dictionary. + + This method computes the total overlap score for the molecular graph represented by the + class and returns a tuple containing the total overlap score, a list of atoms sorted by + their overlap scores in descending order, and a dictionary mapping atom IDs to their + individual overlap scores. The overlap score is a measure of how much atoms overlap with + each other, indicating the compactness or congestion within the molecular structure. It + is calculated based on the distances between atoms, with closer atoms contributing more + significantly to the score. The method iterates through all pairs of atoms, calculates + the overlap score for each pair based on their distance, and aggregates these scores to + determine the total overlap and individual atom overlap scores. The atoms are then + sorted by their scores to identify those with the highest overlap, which can be critical + for resolving spatial conflicts in the molecular layout. + + Returns Tuple[float, List[Tuple[float, 'AtomProperties'], Dict[int, float]]: + A tuple containing: + - The total overlap score for the molecular graph, representing the overall + compactness or congestion. + - A list of tuples, each containing an atom's overlap score and the atom itself, + sorted by the score in descending order. This list helps identify atoms that are + most affected by overlaps and may require adjustment. + - A dictionary mapping atom IDs to their individual overlap scores, providing + detailed insights into the distribution of overlaps across the structure. + """ + total: float = 0.0 + overlap_scores : Dict[int, float]= {} + for atom in self.graph: + overlap_scores[atom.id] = 0.0 + + atoms: List['AtomProperties'] = list(self.graph.keys()) + for i, atom_1 in enumerate(atoms): + for j, atom_2 in enumerate(atoms[i+1:], start=i+1): + distance: float = Vector.subtract_vectors(atom_1.position, atom_2.position).get_squared_length() + if distance < (self.bond_length ** 2): + weight = (self.bond_length - math.sqrt(distance)) / self.bond_length + total += weight + overlap_scores[atom_1.id] += weight + overlap_scores[atom_2.id] += weight + sorted_overlaps: List[Tuple[float, 'AtomProperties']] = [] + for atom in atoms: + sorted_overlaps.append((overlap_scores[atom.id], atom)) + sorted_overlaps.sort(key=lambda x: x[0], reverse=True) + return total, sorted_overlaps, overlap_scores + + + ## вспомогательные функции для resolve_primary_overlaps + def get_subtree_overlap_score(self, root: 'AtomProperties', root_parent: 'AtomProperties', + atom_to_score: Dict[int, float]) -> Tuple[float, Vector]: + """ + Calculates the weighted center and total overlap score for a subtree rooted at a given + atom, excluding its parent. + + This method computes the total overlap score and the weighted center position for a + subtree within the molecular graph, starting from a specified root atom and excluding + its parent. The subtree's overlap score is a measure of how much the atoms within the + subtree overlap with others, indicating the compactness or congestion of the subtree's + layout. The weighted center is calculated based on the positions of atoms that + contribute significantly to the overlap, providing a central point that can be used for + adjusting the subtree's position to reduce overlaps. The method iterates through the + subtree, accumulating the overlap scores of atoms that exceed a sensitivity threshold + and adjusting their positions relative to this score to find a central point of gravity. + This central point, along with the total score, can guide the repositioning of the + subtree to minimize spatial conflicts within the molecular structure. + + Parameters: + :param root AtomProperties: + The root atom of the subtree for which the overlap score and weighted center are + calculated. This atom serves as the starting point for the traversal and score + calculation. + :param root_parent AtomProperties: + The parent atom of the root, which is excluded from the subtree to maintain the + integrity of the molecular graph's structure during calculations. + :param atom_to_score Dict[int, float]: + A dictionary mapping atom IDs to their individual overlap scores, used to determine + the contribution of each atom in the subtree to the total overlap score. + + Returns Tuple[float, Vector]: A tuple containing: + - The average overlap score for the subtree, calculated as the sum of individual + atom scores divided by the number of contributing atoms. This score indicates the + subtree's overall compactness or the extent of overlap among its atoms. + - The weighted center position (Vector) of the subtree, derived from the positions + of atoms with significant overlap scores. This center is calculated by summing the + positions of contributing atoms, each weighted by its overlap score, and then + dividing by the total score to find a central point for potential repositioning. + """ + score = 0.0 + center = Vector(0, 0) + count = 0 + for atom in self.traverse_substructure(root, {root_parent}): + subscore = atom_to_score[atom.id] + if subscore > self.overlap_sensitivity: + score += subscore + count += 1 + position = atom.position.copy() + position.multiply_by_scalar(subscore) + center.add(position) + if score: + center.divide(score) + if count == 0: + count = 1 + return score / count, center + + + @staticmethod + def can_rotate_around_bond(bond: 'BondProperties') -> bool: + """ + Determines whether a bond can be rotated to adjust the molecular structure without + breaking its integrity. + + This method evaluates whether a given bond within a molecular structure can be rotated + as part of layout adjustments, such as resolving overlaps or optimizing the spatial + arrangement of atoms. Rotation around a bond is a common operation in molecular graph + manipulation, but it's constrained by several factors to maintain the molecule's + structural integrity. The method checks the type of the bond, the number of neighbours + each atom involved in the bond has, and their involvement in ring structures. + Specifically, it assesses whether the bond is a single bond (allowing for rotation), + whether either atom has only one neighbour (which would prevent meaningful rotation), + and whether both atoms are part of the same ring (which could disrupt the ring's + geometry if rotated). + + Parameters: + :param bond BondProperties: + The bond to evaluate for potential rotation. This object contains information about + the bond type and the atoms it connects. + + Returns bool: + True if the bond can be safely rotated, indicating that it is a single bond not + involving atoms that are solely connected through this bond or are part of the same + ring, thereby allowing for adjustments that maintain the molecule's integrity. False + otherwise, indicating constraints that prevent rotation to avoid disrupting the + molecular structure. + """ + if bond.type != 'single': + return False + if len(bond.atom1.neighbours) == 1 or len(bond.atom2.neighbours) == 1: + return False + if bond.atom1.rings and bond.atom2.rings and len(set(bond.atom1.rings).intersection(set(bond.atom2.rings))) > 0: + return False + return True + + + def _finetune_overlap_resolution(self) -> None: + """ + Fine-tunes the resolution of overlaps between atoms in the molecular structure by + iteratively adjusting the positions of atoms to minimize the total overlap score. + + This method is designed to refine the positioning of atoms within the molecular + structure to reduce overlaps, focusing on atoms that are too close to each other. It + operates by identifying pairs of clashing atoms, determining the shortest path between + them, and then attempting to rotate the atoms around their bonds to find a configuration + that minimizes the overlap. The process involves several steps: + + 1. Identifies pairs of atoms that are clashing, i.e., too close to each other based on a + predefined sensitivity threshold. + 2. For each pair of clashing atoms, it finds the shortest path connecting them in the + molecular graph. + 3. Along this path, it identifies bonds that are rotatable (excluding double bonds) and + calculates a distance metric for each bond based on its position in the path. + 4. Selects the bond with the smallest distance metric as the best candidate for rotation + to reduce overlap. + 5. Rotates the subtree of atoms around the best bond in increments, evaluating the + overlap score after each rotation to find the optimal rotation angle. + 6. Applies the optimal rotation to minimize the total overlap score. + + The method iteratively adjusts the positions of atoms connected by rotatable bonds to + resolve overlaps, with a preference for rotating smaller subtrees to minimize structural + disruption. It uses a scoring system to evaluate the effectiveness of each rotation and + selects the rotation that results in the lowest overlap score. This process is repeated + for all identified clashing atom pairs until the total overlap score is below a + sensitivity threshold or no further improvement can be made. + """ + if self.total_overlap_score > self.overlap_sensitivity: + clashing_atoms: List[Tuple['AtomProperties', 'AtomProperties']] = self._find_clashing_atoms() + best_bonds: List['BondProperties'] = [] + for atom_1, atom_2 in clashing_atoms: + if self.is_connected(atom_1, atom_2): + shortest_path: List[Union['BondProperties', 'AtomProperties']] = self.find_shortest_path(atom_1, atom_2) + rotatable_bonds: List['BondProperties'] = [] + distances: List[float] = [] + for i, bond in enumerate(shortest_path): + distance_1: int = i + distance_2: int = len(shortest_path) - i + average_distance = len(shortest_path) / 2 + distance_metric = abs(average_distance - distance_1) + abs(average_distance - distance_2) + if self.bond_is_rotatable(bond): # я не дореализовал #fix it + rotatable_bonds.append(bond) + distances.append(distance_metric) + best_bond: Optional['BondProperties'] = None + optimal_distance: float = float('inf') + for i, distance in enumerate(distances): + if distance < optimal_distance: + best_bond: 'BondProperties' = rotatable_bonds[i] + optimal_distance: float = distance + if best_bond is not None: + best_bonds.append(best_bond) + best_bonds = list(set(best_bonds)) + for best_bond in best_bonds: + if self.total_overlap_score > self.overlap_sensitivity: + atom_1, atom_2 = best_bond.atom1, best_bond.atom2 + subtree_size_1: int = self.get_subgraph_size(atom_1, {atom_2}) + subtree_size_2: int = self.get_subgraph_size(atom_2, {atom_1}) + if subtree_size_1 < subtree_size_2: + rotating_atom = atom_1 + parent_atom = atom_2 + else: + rotating_atom = atom_2 + parent_atom = atom_1 + overlap_score, _, _ = self.get_overlap_score() + scores: List[float] = [overlap_score] + # Attempt 12 rotations + for i in range(12): + self.rotate_subtree(rotating_atom, parent_atom, math.radians(30), parent_atom.position) + new_overlap_score, _, _ = self.get_overlap_score() + scores.append(new_overlap_score) + assert len(scores) == 13 + scores = scores[:12] + best_i = 0 + best_score = scores[0] + for i, score in enumerate(scores): + if score < best_score: + best_score = score + best_i = i + self.total_overlap_score = best_score + self.rotate_subtree(rotating_atom, parent_atom, math.radians(30 * best_i + 1), parent_atom.position) + + + + def _find_clashing_atoms(self) -> List[Tuple['AtomProperties', 'AtomProperties']]: + """ + Identifies and returns a list of atom pairs that are clashing, i.e., positioned too + close to each other based on a distance threshold. + + This method scans through all pairs of atoms in the molecular graph to find those that + are closer than a specified distance threshold, indicating a clash or overlap. It + calculates the squared distance between each pair of atoms and compares it against a + threshold value to determine if they are clashing. The threshold is defined as 80% of + the squared bond length, aiming to identify atoms that are significantly closer than + they should be, considering the typical bond length in the molecular structure. + + Returns List[Tuple['AtomProperties', 'AtomProperties']: + A list of tuples, where each tuple contains two 'AtomProperties' objects + representing a pair of atoms that are clashing. Each tuple indicates a pair of atoms + that are positioned too close to each other, based on the distance threshold. + """ + clashing_atoms: List[Tuple['AtomProperties', 'AtomProperties']] = [] + atoms: List['AtomProperties'] = list(self.graph.keys()) + for i, atom_1 in enumerate(atoms): + for j, atom_2 in enumerate(atoms[i+1:], start=i+1): + if self.bond_lookup(atom_1, atom_2) is None: + distance = Vector.subtract_vectors(atom_1.position, atom_2.position).get_squared_length() + if distance < 0.8 * (self.bond_length**2): + clashing_atoms.append((atom_1, atom_2)) + return clashing_atoms + + + + def is_connected(self, atom_1, atom_2) -> bool: + """ + Determines if two atoms are connected within the molecular graph, i.e., part of the same + molecular structure. + + Parameters: + atom_1 AtomProperties: The first atom to check for connectivity. + atom_2 AtomProperties: The second atom to check for connectivity. + + Returns bool: + True if both atoms are present in the molecular graph, indicating they are part of + the same molecular structure, and False otherwise. + """ + return atom_1 in self.graph and atom_2 in self.graph + + + + def bond_is_rotatable(self, bond: 'BondProperties') -> bool: + """ + Determines if a bond can be rotated in the molecular structure drawing, based on its + type and the atoms it connects. + + This method evaluates whether a given bond is rotatable, which is crucial for adjusting the molecular layout to resolve overlaps or achieve a more accurate representation. A bond is considered rotatable if it is not constrained by stereochemical considerations, such as being part of a ring or having a specific type that restricts rotation (e.g., double or triple bonds). The method checks if the bond connects atoms that are part of the same ring, which would prevent rotation, and if the bond type is not a single bond, it further checks the number of neighbors each atom has to determine if rotation is possible. Additionally, it considers chiral centers and specific stereochemical markers that might restrict rotation. The presence of these conditions indicates that the bond is not rotatable, and the method returns False. Otherwise, it returns True, indicating the bond can be rotated to adjust the molecular structure. + + Parameters + :param bond BondProperties: The bond to evaluate for rotatability. + + Returns bool: + True if the bond is rotatable, meaning it can be rotated in the drawing to adjust the molecular structure without violating stereochemical constraints; False if the bond is fixed in place due to being part of a ring, being a non-single bond, or involving chiral centers. + """ + atom_1, atom_2 = bond.atom1, bond.atom2 + if atom_1.rings and atom_2.rings and len(set(atom_1.rings).intersection(set(atom_2.rings))) > 0: + return False + if bond.type != 'single': + if len(atom_1.neighbours) > 1 and len(atom_2.neighbours) > 1: + return False + chiral = False + self.get_bonds_of_atom(atom_1) + # for bond_1 in self.get_bonds_of_atom(atom_1): + # if self.chiral[bond_1]: + # chiral = True + # break + # for bond_2 in self.get_bonds_of_atom(atom_2): + # if self.chiral[bond_2]: + # chiral = True + # break + if chiral: + return False + # if self.chiral_symbol[bond]: + # return False + return True + + + + def find_shortest_path(self, atom_1: 'AtomProperties', atom_2: 'AtomProperties', \ + path_type: str = 'bond') -> List[Union['BondProperties', 'AtomProperties']]: + """ + Finds the shortest path between two atoms in the molecular graph, returning either the + sequence of bonds or atoms along the path. + + This method implements a shortest path algorithm to determine the most direct route + between two specified atoms within the molecular structure. It can return the path as a + list of either the bonds connecting the atoms or the atoms themselves, depending on the + `path_type` parameter. The algorithm initializes by setting the distance to all atoms as + infinite, except for the starting atom, which is set to zero. It then iteratively + selects the atom with the smallest distance that has not been visited, updates the + distances to its neighbors, and marks it as visited. This process continues until the + destination atom is reached or all atoms have been visited. Finally, it constructs the + path from the destination atom back to the starting atom using the recorded previous + hops. + + Parameters + :param atom_1 AtomProperties: + The starting atom from which to find the shortest path. + :param atom_2 AtomProperties: + The destination atom to which to find the shortest path. + :param path_type str: + Specifies the type of elements to return in the path. 'bond' returns the bonds along the path, 'atom' returns the atoms. Default is 'bond'. + + Returns List[Union['BondProperties', 'AtomProperties']: + A list representing the shortest path between `atom_1` and `atom_2`. If `path_type` + is 'bond', the list contains `BondProperties` objects; if 'atom', it contains + `AtomProperties` objects. + + Raises ValueError: + If `path_type` is neither 'bond' nor 'atom'. + """ + distances: Dict['AtomProperties', float] = {} + previous_hop: Dict['AtomProperties', Optional['AtomProperties']] = {} + unvisited: Set['AtomProperties'] = set() + for atom in self.graph: + distances[atom] = float('inf') + previous_hop[atom] = None + unvisited.add(atom) + distances[atom_1] = 0.0 + while unvisited: + current_atom: Optional['AtomProperties'] = None + minimum: float = float('inf') + # Find the atom with the smallest distance value that has not yet been visited + for atom in unvisited: + dist: float = distances[atom] + if dist < minimum: + current_atom: 'AtomProperties' = atom + minimum = dist + if current_atom is None: + break + if current_atom == atom_2: + break + unvisited.remove(current_atom) + # If there exists a shorter path between the source atom and the neighbours, update distance + for neighbour in self.graph[current_atom]: + if neighbour in unvisited: + alternative_distance: float = distances[current_atom] + 1.0 + + if alternative_distance < distances[neighbour]: + distances[neighbour] = alternative_distance + previous_hop[neighbour] = current_atom + # Construct the path of atoms + path_atoms: List['AtomProperties'] = [] + current_atom: Optional['AtomProperties'] = atom_2 + if previous_hop[current_atom] or current_atom == atom_1: + while current_atom: + path_atoms.insert(0, current_atom) + current_atom = previous_hop[current_atom] + if path_type == 'bond': + path: List[Union['BondProperties', 'AtomProperties']] = [] + for i in range(1, len(path_atoms)): + atom_1 = path_atoms[i - 1] + atom_2 = path_atoms[i] + bond = self.bond_lookup(atom_1, atom_2) + path.append(bond) + return path + elif path_type == 'atom': + return path_atoms + else: + raise ValueError("Path type must be 'bond' or 'atom'.") + + + + def resolve_secondary_overlaps(self, sorted_scores: List[Tuple[float, 'AtomProperties']]) -> None: + """ + Resolves secondary overlaps in the molecular structure by adjusting the positions of + atoms based on their overlap scores. + + This method addresses secondary overlaps in the molecular layout by iteratively + adjusting the positions of atoms that have been identified as overlapping beyond a + specified sensitivity threshold. It processes a list of atoms sorted by their overlap + scores, focusing on atoms with scores higher than a predefined sensitivity level. For + each atom, it determines the appropriate action based on the atom's connectivity and + proximity to other atoms to minimize overlaps. The process involves finding the closest + atom if the target atom has only one neighbor or is isolated, calculating a new position + that reduces overlap, and then rotating the atom to this new position. The rotation is + designed to move the atom away from its closest neighbor or a specified position to + alleviate the overlap. + + Parameters + :param sorted_scores List[Tuple[float, AtomProperties]]: + A list of tuples, where each tuple contains an overlap score and an `AtomProperties` + object. The list is sorted by score, with the highest scores (indicating more + significant overlaps) first. + + The steps involved are as follows: + 1. Iterate through atoms sorted by their overlap scores, focusing on those with scores + exceeding the sensitivity threshold. + 2. For atoms with one or no neighbors, find the closest atom in the structure to + determine a direction for rotation. + 3. Calculate a new position for the atom based on the closest atom's position or a + specified reference point, considering the previous positions of both the atom and its + closest neighbor. + 4. Rotate the atom to the new position to reduce overlap, using a predefined angle to + ensure minimal disruption to the molecular structure. + """ + for score, atom in sorted_scores: + if score > self.overlap_sensitivity: + if len(atom.neighbours) <= 1: + if atom.neighbours: + continue + closest_atom: 'AtomProperties' = self.get_closest_atom(atom) + neighbours = closest_atom.neighbours + if len(neighbours) <= 1: + if not closest_atom.previous_position: + closest_position: float = atom.neighbours[0].position + else: + closest_position: float = closest_atom.previous_position + else: + if not closest_atom.previous_position: + closest_position: float = atom.neighbours[0].position + else: + closest_position: float = closest_atom.position + if not atom.previous_position: + atom_previous_position: float = atom.neighbours[0].position + else: + atom_previous_position: float = atom.previous_position + atom.position.rotate_away_from_vector(closest_position, \ + atom_previous_position, math.radians(20)) + + + + def get_closest_atom(self, atom: 'AtomProperties') -> 'AtomProperties': + """ + Identifies and returns the atom closest to a given atom within the molecular graph. + + This method calculates the distance between a specified atom and all other atoms in the + molecular graph to find the closest one. It iterates through each atom in the graph, + comparing their distances to the given atom and updates the closest atom found so far + based on the smallest squared distance. The squared distance is used for efficiency, + avoiding the computational cost of square root calculations. The method is useful for + determining the spatial relationship between atoms, which can be crucial for layout + adjustments, overlap resolution, or identifying nearby atoms for various analyses. + + Parameters + :param atom AtomProperties: + The reference atom from which to find the closest atom in the molecular graph. + + Returns AtomProperties: + The atom determined to be closest to the specified atom based on the shortest + distance. If no closer atom is found (e.g., if the graph only contains the specified + atom), the method returns None. + """ + minimal_distance = float('inf') + closest_atom: Optional['AtomProperties'] = None + for atom_2 in self.graph: + if atom == atom_2: + continue + squared_distance: float = atom.position.get_squared_distance(atom_2.position) + if squared_distance < minimal_distance: + minimal_distance: float = squared_distance + closest_atom: 'AtomProperties' = atom_2 + return closest_atom + +__all__ = ['Calculate2d'] \ No newline at end of file diff --git a/chython/algorithms/calculate2d/KKLayout.py b/chython/algorithms/calculate2d/KKLayout.py new file mode 100644 index 00000000..eb7ec6b3 --- /dev/null +++ b/chython/algorithms/calculate2d/KKLayout.py @@ -0,0 +1,410 @@ +""" +Defines the KKLayout class, utilized for arranging molecular structures using the Kamada-Kawai +algorithm. + +Description: +The KKLayout class is designed to optimize the layout of molecular structures through the +application of the Kamada-Kawai algorithm, a graph drawing method that models atoms as physical +bodies connected by springs, aiming to find a low-energy configuration. This approach allows for +the visualization of molecular structures in a two-dimensional plane, where atoms are positioned +according to calculated coordinates to reflect their interconnections and minimize the system's +total energy. The class is initialized with essential details about the molecular structure, +enabling the creation of matrices for storing atomic distances, bond stiffness, and interaction +energies. These matrices support the iterative process of finding an optimal layout that +minimizes energy, thereby enhancing the stability and clarity of the molecular representation. + +Outcome: +Achieves a stable molecular layout where atomic positions are optimized for minimal energy, +enhancing the interpretability of complex molecular structures. This optimized arrangement not +only reflects the underlying chemistry but also simplifies the visual analysis of molecular +architecture, making it invaluable for scientific and educational purposes. +""" +from typing import Dict, List, TYPE_CHECKING, Tuple +from .MathHelper import Vector, Polygon +import math +if TYPE_CHECKING: + from .Calculate2d import Calculate2d + from .Properties import * + +class KKLayout: + """ + Class for calculating the optimal arrangement of atoms in a molecular structure + using the Kamada-Kawai algorithm. + """ + def __init__(self, structure: 'Calculate2d', atoms: List['AtomProperties'], \ + center: 'Vector', start_atom: 'AtomProperties', bond_length: float, \ + threshold: float=0.1, inner_threshold: float=0.1, max_iteration: int=2000, + max_inner_iteration: int=50, max_energy: int=1e9): + """ + Initializes the KKLayout object with the necessary parameters to calculate the optimal + arrangement of atoms in a molecular structure using the Kamada-Kawai algorithm. + + Parameters: + :param structure Calculate2d: + An instance of the Calculate2d class used for performing 2D space calculations. + :param atoms List[AtomProperties]: + A list containing instances of the AtomProperties class representing the atoms + in the molecular structure. + :param center Vector: + An instance of the Vector class specifying the geometric center of the molecular + structure. + :param start_atom AtomProperties: + An instance of the AtomProperties class designating the starting atom for + constructing the layout. + :param bond_length float: + The specified length of bonds between atoms in the molecular structure. + :param threshold Optional[float]: + The energy threshold value used to determine when to stop iterations during the + calculation. Defaults to 0.1. + :param inner_threshold Optional[float]: + The inner energy threshold value used to determine when to stop internal iterations + during the calculation. Defaults to 0.1. + :param max_iteration Optional[int]: + The maximum number of iterations allowed for the calculation process. Defaults to + 2000. + :param max_inner_iteration Optional[int]: + The maximum number of inner iterations allowed for the calculation process. + Defaults to 50. + :param max_energy Optional[int]: + The maximum allowable energy level for the system being calculated. Defaults to 1e9. + + Attributes: + self.structure: Stores the Calculate2d instance passed as a parameter. + self.atoms: Stores the list of AtomProperties instances representing the atoms in the + structure. + self.center: Stores the Vector instance representing the center of the molecular + structure. + self.start_atom: Stores the AtomProperties instance representing the starting atom for + the layout construction. + self.edge_strength: Stores the bond length between atoms. + self.threshold: Stores the energy threshold value for stopping iterations. + self.inner_threshold: Stores the inner energy threshold value for internal iterations. + self.max_iteration: Stores the maximum number of iterations allowed. + self.max_inner_iteration: Stores the maximum number of inner iterations allowed. + self.max_energy: Stores the maximum allowable energy level for the system. + + Additional Attributes: + self.x_positions, self.y_positions: Dictionaries storing the X and Y coordinates of each + atom in the structure. + self.positioned: A dictionary indicating whether each atom has been positioned. + self.length_matrix: A matrix storing the lengths of bonds between pairs of atoms. + self.distance_matrix: A matrix storing the distances between pairs of atoms. + self.spring_strengths: A matrix storing the stiffness values of links between pairs of + atoms. + self.energy_matrix: A matrix storing the interaction energy between pairs of atoms. + self.energy_sums_x, self.energy_sums_y: Dictionaries storing the summations of energies + along the X and Y axes for each atom. + """ + self.structure: 'Calculate2d' = structure + self.atoms: List['AtomProperties'] = atoms + self.center: 'Vector' = center + self.start_atom: 'AtomProperties' = start_atom + self.edge_strength: int = bond_length + self.threshold: float = threshold + self.inner_threshold: float = inner_threshold + self.max_iteration: int = max_iteration + self.max_inner_iteration: int = max_inner_iteration + self.max_energy: int = max_energy + + self.x_positions: Dict['AtomProperties', float] = {} + self.y_positions: Dict['AtomProperties', float] = {} + self.positioned: Dict['AtomProperties', bool] = {} + self.length_matrix: Dict['AtomProperties', Dict['AtomProperties', float]] = {} + self.distance_matrix: Dict[int, Dict[int, float]] = {} + self.spring_strengths: Dict['AtomProperties', Dict['AtomProperties', float]] = {} + self.energy_matrix: Dict['AtomProperties', Dict['AtomProperties', Optional[float]]] = {} + self.energy_sums_x: Dict['AtomProperties', Dict['AtomProperties', Optional[float]]] = {} + self.energy_sums_y: Dict['AtomProperties', Dict['AtomProperties', Optional[float]]] = {} + + self.initialise_matrices() + self.get_kk_layout() + + + def initialise_matrices(self) -> None: + """ + Initializes various matrices required for calculating the layout of a molecular + structure. + + This method computes the initial positions of atoms based on the center of the molecule + and bond length. It creates matrices to store bond lengths, link stiffnesses, + interaction energies, + and sums of energy along the X and Y axes. The initialization process involves + calculating the distance matrix, determining initial atom positions, + and preparing matrices for further calculations in the Kamada-Kawai algorithm. + + Steps involved: + 1. Compute the distance matrix to understand the connectivity and distances between + atoms. + 2. Determine initial positions for atoms based on a circular layout around the + molecule's center, considering unpositioned atoms. + 3. Initialize matrices for bond lengths, spring strengths, and interaction energies + between atoms. + 4. Calculate the initial energy matrix based on atom positions and bond lengths. + """ + self.distance_matrix = self.get_subgraph_distance_matrix(self.atoms) + length = len(self.atoms) + radius = Polygon.find_polygon_radius(500, length) + angle = Polygon.get_central_angle(length) + a: float = 0.0 + for atom in self.atoms: + if not atom.positioned: + self.x_positions[atom] = self.center.x + math.cos(a) * radius + self.y_positions[atom] = self.center.y + math.sin(a) * radius + else: + self.x_positions[atom] = atom.position.x + self.y_positions[atom] = atom.position.y + self.positioned[atom] = atom.positioned + a += angle + for atom_1 in self.atoms: + self.length_matrix[atom_1] = {} + self.spring_strengths[atom_1] = {} + self.energy_matrix[atom_1] = {} + self.energy_sums_x[atom_1] = None + self.energy_sums_y[atom_1] = None + for atom_2 in self.atoms: + self.length_matrix[atom_1][atom_2] = self.edge_strength * self.distance_matrix[atom_1][atom_2] + self.spring_strengths[atom_1][atom_2] = self.edge_strength * self.distance_matrix[atom_1][atom_2] ** -2.0 + self.energy_matrix[atom_1][atom_2] = None + for atom_1 in self.atoms: + ux = self.x_positions[atom_1] + uy = self.y_positions[atom_1] + d_ex = 0.0 + d_ey = 0.0 + for atom_2 in self.atoms: + if atom_1 == atom_2: + continue + vx = self.x_positions[atom_2] + vy = self.y_positions[atom_2] + denom = 1.0 / math.sqrt((ux - vx) ** 2 + (uy - vy) ** 2) + self.energy_matrix[atom_1][atom_2] = (self.spring_strengths[atom_1][atom_2] * ((ux - vx) - self.length_matrix[atom_1][atom_2] * (ux - vx) * denom), + self.spring_strengths[atom_1][atom_2] * ((uy - vy) - self.length_matrix[atom_1][atom_2] * (uy - vy) * denom)) + self.energy_matrix[atom_2][atom_1] = self.energy_matrix[atom_1][atom_2] + d_ex += self.energy_matrix[atom_1][atom_2][0] + d_ey += self.energy_matrix[atom_1][atom_2][1] + self.energy_sums_x[atom_1] = d_ex + self.energy_sums_y[atom_1] = d_ey + + + def get_kk_layout(self) -> None: + """ + Initiates the iterative process to find the optimal arrangement of atoms in a molecular + structure using the Kamada-Kawai algorithm. + + This method performs iterations until the system's energy falls below a threshold value + or the maximum number of iterations is reached. At each iteration, + the `update()` method is called to move atoms with the highest energy, aiming to + minimize the overall system energy through gradual adjustments. + + Description: + The Kamada-Kawai algorithm is employed to iteratively refine the positions of atoms + within a molecular structure, seeking a configuration that minimizes the system's + energy. This method orchestrates the iterative process, + adjusting atom positions based on their energy states until a satisfactory layout is + achieved or predefined limits are met. It operates by repeatedly identifying atoms with + the highest energy contributions + and adjusting their positions to reduce strain within the molecular structure, thereby + optimizing the layout towards a state of lower potential energy. + + Process: + - Iterations continue until either the system's energy drops below a specified + threshold, indicating an acceptable level of stability, or the maximum iteration count + is reached, preventing infinite loops. + - At each iteration, the atom contributing most significantly to the system's energy is + identified, and its position is adjusted to decrease overall energy. + - Inner iterations within each main iteration further refine the position of the most + energetic atom, stopping once the change in energy falls below an inner threshold or the + maximum number of inner iterations is reached, + ensuring fine-tuning of atomic positions for optimal placement. + - After concluding iterations, final positions are assigned to atoms, marking them as + positioned and forcing their placement to prevent further adjustments. + + Outcome: + - Achieves a stable molecular layout where atomic positions are optimized to minimize + energy, reflecting the algorithm's goal of balance and stability. + - Marks atoms as positioned and forcibly placed, indicating completion and preventing + further adjustments, ensuring structural integrity. + """ + iteration = 0 + max_energy = self.max_energy + while max_energy > self.threshold and self.max_iteration > iteration: + iteration += 1 + max_energy_atom, max_energy, d_ex, d_ey = self.highest_energy() + delta = max_energy + inner_iteration = 0 + while delta > self.inner_threshold and self.max_inner_iteration > inner_iteration: + inner_iteration += 1 + self.update(max_energy_atom, d_ex, d_ey) + delta, d_ex, d_ey = self.energy(max_energy_atom) + for atom in self.atoms: + atom.position.x = self.x_positions[atom] + atom.position.y = self.y_positions[atom] + atom.positioned = True + atom.force_positioned = True + + + def energy(self, atom: 'AtomProperties') -> List[float]: + """ + Calculates the energy of the system for a given atom. + + The energy is defined as the sum of the squares of the energy components along the X and + Y axes, as well as the components themselves. This allows for an evaluation of the + atom's overall state within the system and its contribution to the total energy. + + Parameters: + :param atom AtomProperties: + The atom for which the energy is calculated. + + Returns List[float]: + A list containing: + - Total energy (sum of the squares of the components), + - Energy along the X-axis, + - Energy along the Y-axis. + """ + energy: List[float] = [self.energy_sums_x[atom]**2 + self.energy_sums_y[atom]**2, \ + self.energy_sums_x[atom], self.energy_sums_y[atom]] + return energy + + + def highest_energy(self) -> Tuple['AtomProperties', float, float, float]: + """ + Identifies the atom with the highest energy among those not yet positioned. + + This method scans through all atoms in the molecular structure to find the one with the + greatest energy contribution that has not been positioned yet. It is crucial for the + iterative process of optimizing the layout according to the Kamada-Kawai algorithm, as + it targets atoms requiring adjustment to minimize overall system energy. + + Returns Tuple[AtomProperties, float, float, float]: + A tuple containing: + - AtomProperties: The atom identified as having the highest energy among those not yet positioned. + - float: The maximum energy value associated with this atom. + - float: The energy component along the X-axis for the atom with the highest energy. + - float: The energy component along the Y-axis for the atom with the highest energy. + """ + max_energy = 0.0 + max_energy_atom = None + max_d_ex = 0.0 + max_d_ey = 0.0 + for atom in self.atoms: + delta, d_ex, d_ey = self.energy(atom) + if delta > max_energy and not self.positioned[atom]: + max_energy = delta + max_energy_atom = atom + max_d_ex = d_ex + max_d_ey = d_ey + return max_energy_atom, max_energy, max_d_ex, max_d_ey + + + def update(self, atom: 'AtomProperties', d_ex: float, d_ey: float) -> None: + """ + Updates the position of a specified atom based on its energy. + + Parameters: + :param atom AtomProperties: + The atom whose position needs to be updated. + :param d_ex float: + Energy along the X-axis for the atom. + :param d_ey float: + Energy along the Y-axis for the atom. + + Description: + This method recalculates and adjusts the position of a given atom within the molecular structure based on its current energy state, aiming to minimize the overall system energy through iterative refinement. It incorporates the Kamada-Kawai algorithm principles, adjusting atomic positions to reduce strain and achieve a stable configuration. By considering the energies along the X and Y axes, the method computes new coordinates that reflect a balance between the atom's interactions with other atoms, effectively reducing its contribution to the system's total energy. The process involves calculating forces acting on the atom due to its connections, represented by springs with specific strengths, and updating its position accordingly. The adjustments are made in both X and Y directions, aiming to move the atom towards a state of lower potential energy, thereby contributing to the optimization of the entire molecular layout. + """ + dxx = 0.0 + dyy = 0.0 + dxy = 0.0 + ux = self.x_positions[atom] + uy = self.y_positions[atom] + lengths_array = self.length_matrix[atom] + strengths_array = self.spring_strengths[atom] + for atom_2 in self.atoms: + if atom == atom_2: + continue + vx = self.x_positions[atom_2] + vy = self.y_positions[atom_2] + length = lengths_array[atom_2] + strength = strengths_array[atom_2] + squared_xdiff = (ux - vx) ** 2 + squared_ydiff = (uy - vy) ** 2 + denom = 1.0 / (squared_xdiff + squared_ydiff) ** 1.5 + dxx += strength * (1 - length * squared_ydiff * denom) + dyy += strength * (1 - length * squared_xdiff * denom) + dxy += strength * (length * (ux - vx) * (uy - vy) * denom) + if dxx == 0: + dxx = 0.1 + if dyy == 0: + dyy = 0.1 + if dxy == 0: + dxy = 0.1 + dy = (d_ex / dxx + d_ey / dxy) / (dxy / dxx - dyy / dxy) + dx = -(dxy * dy + d_ex) / dxx + self.x_positions[atom] += dx + self.y_positions[atom] += dy + d_ex = 0.0 + d_ey = 0.0 + ux = self.x_positions[atom] + uy = self.y_positions[atom] + for atom_2 in self.atoms: + if atom == atom_2: + continue + vx = self.x_positions[atom_2] + vy = self.y_positions[atom_2] + previous_ex = self.energy_matrix[atom][atom_2][0] + previous_ey = self.energy_matrix[atom][atom_2][1] + denom = 1.0 / math.sqrt((ux - vx) ** 2 + (uy - vy) ** 2) + dx = strengths_array[atom_2] * ((ux - vx) - lengths_array[atom_2] * (ux - vx) * denom) + dy = strengths_array[atom_2] * ((uy - vy) - lengths_array[atom_2] * (uy - vy) * denom) + self.energy_matrix[atom][atom_2] = [dx, dy] + d_ex += dx + d_ey += dy + self.energy_sums_x[atom_2] += dx - previous_ex + self.energy_sums_y[atom_2] += dy - previous_ey + self.energy_sums_x[atom] = d_ex + self.energy_sums_y[atom] = d_ey + + + def get_subgraph_distance_matrix(self, atoms: List['AtomProperties']) \ + -> Dict[int, Dict[int, float]]: + """ + Computes the distance matrix between atoms in a subgraph. + + Parameters: + :param atoms List[AtomProperties]: + Specifies the subset of atoms to analyze, focusing the calculation on relevant + components of the molecular structure. + + Returns Dict[int, Dict[int, float]]: + Provides a comprehensive view of atomic distances, where keys represent atoms and + values are dictionaries mapping to other atoms with corresponding shortest path + distances. This structured output facilitates targeted adjustments, ensuring that + atomic placements minimize overall system energy. + + Description: + This method calculates the shortest path distances between all pairs of atoms within a + given subset of a molecular structure, forming a subgraph. It initializes the distance + matrix with infinite distances for all atom pairs except those directly connected, which + are set to 1, indicating a bond exists. It then applies a variation of the + Floyd-Warshall algorithm to find the shortest paths between all atoms, updating the + matrix to reflect the shortest distances found. This process is crucial for + understanding the connectivity and spatial relationships within the molecular structure, + aiding in layout optimization by identifying the most efficient paths between atoms. The + resulting matrix provides insights into the molecular topology, guiding the arrangement + of atoms to minimize overall energy and enhance structural stability. + """ + distance_matrix: Dict[int, Dict[int, float]] = {} + for atom_1 in atoms: + if atom_1 not in distance_matrix: + distance_matrix[atom_1] = {} + + for atom_2 in atoms: + if self.structure.bond_lookup(atom_1, atom_2): + distance_matrix[atom_1][atom_2] = 1 + else: + distance_matrix[atom_1][atom_2] = float('inf') + + for atom_1 in atoms: + for atom_2 in atoms: + for atom_3 in atoms: + if distance_matrix[atom_2][atom_3] > distance_matrix[atom_2][atom_1] + distance_matrix[atom_1][atom_3]: + distance_matrix[atom_2][atom_3] = distance_matrix[atom_2][atom_1] + distance_matrix[atom_1][atom_3] + return distance_matrix \ No newline at end of file diff --git a/chython/algorithms/calculate2d/MathHelper.py b/chython/algorithms/calculate2d/MathHelper.py new file mode 100644 index 00000000..32f433ba --- /dev/null +++ b/chython/algorithms/calculate2d/MathHelper.py @@ -0,0 +1,689 @@ +""" +This module introduces the `Vector` and `Polygon` classes, designed to perform mathematical +calculations relevant to two-dimensional Cartesian coordinate systems and regular polygons. + +The `Vector` class facilitates operations with coordinates, including vector arithmetic +(addition, subtraction, multiplication/division by scalars), normalization, rotation, and +distance calculations. It also supports methods for determining the angle of a vector, its +length, and whether it lies in a certain quadrant. Additionally, it includes functions for +reflecting vectors about lines, finding the closest atom or point, and rotating vectors around +other vectors or points. + +The `Polygon` class focuses on properties and calculations related to regular polygons, such as +finding the circumradius, calculating central angles, determining the apothem, and identifying +the type of polygon based on its number of sides. It also includes static methods for +calculating normals to lines defined by two vectors and for adding, averaging, and mirroring +vectors. + +Together, these classes provide a comprehensive toolkit for performing geometric computations +essential in fields such as computer graphics, physics simulations, and computational chemistry, +where precise manipulation and analysis of spatial relationships are required. +""" +import math +from typing import Union, TYPE_CHECKING, List + +if TYPE_CHECKING: + from .Properties import AtomProperties + + +class Vector: + """ + The `Vector` class facilitates operations with coordinates, including vector arithmetic + (addition, subtraction, multiplication/division by scalars), normalization, rotation, and + distance calculations. It also supports methods for determining the angle of a vector, its + length, and whether it lies in a certain quadrant. Additionally, it includes functions for + reflecting vectors about lines, finding the closest atom or point, and rotating vectors around + other vectors or points. + """ + def __init__(self, x: Union[int, float], y: Union[int, float]) -> None: + """ + Constructor of the vector class + + Parameters: + :param x Union[int, float]: + The coordinate of the vector along the abscissa axis + :param y Union[int, float]: + The coordinate of the vector along the ordinate axis + + Attributes: + x: the coordinate of the vector along the abscissa axis + y: the coordinate of the vector along the ordinate axis + """ + self.x: float = float(x) + self.y: float = float(y) + + + def __repr__(self) -> str: + """ + The method needed for debugging the code + + Returns a string containing the coordinates of the point + """ + return str(self.x) + ', ' + str(self.y) + + + def copy(self) -> 'Vector': + """ + Creates a copy of the current class object + + Returns a copy of the object + """ + return Vector(self.x, self.y) + + + def subtract(self, vector: 'Vector'): + """ + A method for the operation of subtraction between vectors + + Parameters: + :param vector 'Vector': + Another object of the current class + """ + self.x -= vector.x + self.y -= vector.y + + + def rotate(self, angle: float) -> None: + """ + A method that rotates the vector by the appropriate angle from the signature + of the function and updates the coordinates of the current class object + + Parameters: + :param angle float: + The angle by which the vector should be rotated + """ + new_x: float = self.x * math.cos(angle) - self.y * math.sin(angle) + new_y: float = self.x * math.sin(angle) + self.y * math.cos(angle) + + self.x = new_x + self.y = new_y + + + def add(self, vector: 'Vector') -> None: + """ + A class method that adds vectors and updates the coordinates of the current class object + + Parameters: + :param vector 'Vector': + Another object of the current class + """ + self.x += vector.x + self.y += vector.y + + + def invert(self) -> None: + """ + A class method that inverts the current coordinates of objects of the class + """ + self.x = self.x * -1 + self.y = self.y * -1 + + + def divide(self, scalar: float) -> None: + """ + A class method that divides the coordinates of the current class object + vectors for an arbitrary number + + Parameters: + :param scalar float: + Number divider + """ + self.x = self.x / scalar + self.y = self.y / scalar + + + def normalise(self) -> None: + """ + Normalization of coordinates (dividing them by the length of the vector itself) + """ + if self.length() != 0: + self.divide(self.length()) + + + def angle(self) -> float: + """ + A method that calculates the angle of inclination of the current vector + + Returns float the angle of inclination of the vector + """ + return math.atan2(self.y, self.x) + + + def length(self) -> float: + """ + Calculates the length of the current vector + + Returns float + """ + return math.sqrt((self.x**2) + (self.y**2)) + + + def multiply_by_scalar(self, scalar: float) -> None: + """ + Multiplies the coordinates of the current vector by an arbitrary real number + + Parameters: + :param scalar float + """ + self.x = self.x * scalar + self.y = self.y * scalar + + + def rotate_around_vector(self, angle: float, vector: 'Vector') -> None: + """ + Rotates a point (or vector) around a given vector by a specified angle. + + Parameters: + :param angle float: + The angle by which to rotate the point, typically measured in radians. + :param vector 'Vector': + The vector around which the rotation occurs. This vector serves as the reference + point. + """ + self.x -= vector.x + self.y -= vector.y + + x = self.x * math.cos(angle) - self.y * math.sin(angle) + y = self.x * math.sin(angle) + self.y * math.cos(angle) + + self.x = x + vector.x + self.y = y + vector.y + + + def get_closest_atom(self, atom_1: 'AtomProperties', atom_2: 'AtomProperties') -> 'AtomProperties': + """ + This method determines which of the two atoms (represented by the objects atom_1 and atom_2) + is closer to the current object (represented by self). + + Parameters: + :param atom_1: 'AtomProperties': + The first atom to compare. + :param atom_2: 'AtomProperties': + The second atom to compare. + + Returns 'AtomProperties': + The closest atom. + """ + distance_1 = self.get_squared_distance(atom_1.position) + distance_2 = self.get_squared_distance(atom_2.position) + return atom_1 if distance_1 < distance_2 else atom_2 + + + def get_closest_point_index(self, point_1: 'Vector', point_2: 'Vector') -> int: + """ + The method is designed to determine which of the two specified coordinates (point_1: 'Vector', point_2: 'Vector') + closer to the current point. + + Parameters + :param point_1 'Vector': + The first point to be compared with. It can be a tuple, a list, or an object + representing coordinates. + :param point_2 'Vector': + The second point to compare with. Similarly, it can be a tuple, a list, or an + object. + + Returns int: + The index of the nearest point: 0 for point_1 and 1 for point_2. + """ + distance_1 = self.get_squared_distance(point_1) + distance_2 = self.get_squared_distance(point_2) + return 0 if distance_1 < distance_2 else 1 + + + def get_squared_length(self) -> float: + """ + Calculates the length squared + + Returns float: + Vector length squared + """ + return self.x ** 2 + self.y ** 2 + + + def get_squared_distance(self, vector: 'Vector') -> float: + """ + The method is designed to calculate the square of the distance between the current vector + (represented by self) and the specified vector (or point) represented by the vector object. + + Parameters + :param vector: 'Vector': + An object representing a vector or point from which to calculate the distance. + + Returns float: + The square of the distance + """ + return (vector.x - self.x) ** 2 + (vector.y - self.y) ** 2 + + + def get_distance(self, vector: 'Vector') -> float: + """ + The method is designed to calculate the distance between the current vector (represented by self) and + the specified vector (or point) represented by the vector object. + + Parameters + :param vector: 'Vector': + An object representing a vector or point from which to calculate the distance. + + Returns float: + The distance between the coordinates of the current vector and the passed parameter + """ + return math.sqrt(self.get_squared_distance(vector)) + + + def get_rotation_away_from_vector(self, vector: 'Vector', center: 'Vector', angle: float) -> float: + """ + The method is designed to determine how much the angle of rotation (in a positive or negative direction) + from a given vector measures the distance to this vector. + + Parameters + :param vector 'Vector': + The vector to "move away from". It can be a point or a direction, relative to which + the rotation is taking place. + :param center 'Vector': + The center of rotation around which the object (represented by self) rotates. + :param angle float: + The angle at which the rotation occurs. This value can be positive or negative. + + Returns returns the rotation angle that minimizes the distance to the vector, + either in a positive or negative direction. + """ + tmp = self.copy() + + tmp.rotate_around_vector(angle, center) + squared_distance_1 = tmp.get_squared_distance(vector) + + tmp.rotate_around_vector(-2.0 * angle, center) + squared_distance_2 = tmp.get_squared_distance(vector) + return angle if squared_distance_2 < squared_distance_1 else -angle + + + def rotate_away_from_vector(self, vector: 'Vector', center: 'Vector', angle: float) -> None: + """ + The method is designed to rotate the current object (represented by self) around a given + one center in such a way as to minimize the distance to the specified vector. + If rotation in one direction leads to a decrease in the distance, the function corrects + the rotation,to ensure maximum distance from the vector. + + Parameters + :param vector 'Vector': + The vector to "move away from". It can be a point or a direction, relative to which + the rotation is taking place. + :param center 'Vector': + The center of rotation around which the object rotates. + :param angle float: + The angle at which the rotation occurs. This value can be positive or negative. + """ + self.rotate_around_vector(angle, center) + squared_distance_1 = self.get_squared_distance(vector) + self.rotate_around_vector(-2.0 * angle, center) + squared_distance_2 = self.get_squared_distance(vector) + + if squared_distance_2 < squared_distance_1: + self.rotate_around_vector(2.0 * angle, center) + + + def get_clockwise_orientation(self, vector: 'Vector') -> str: + """ + The method is designed to determine the orientation (positive or negative) between + the current object (represented by self) and the specified vector (represented by + the vector object). + + Parameters + :param vector 'Vector': + The vector relative to which the orientation is determined. + + Returns str: + A string indicating whether the orientation is "clockwise", "counterclockwise" + or "neutral". + """ + a: float = self.y * vector.x + b: float = self.x * vector.y + + if a > b: + return 'clockwise' + elif a == b: + return 'neutral' + else: + return 'counterclockwise' + + + def mirror_about_line(self, line_point_1: 'Vector', line_point_2: 'Vector') -> None: + """ + The method is designed to reflect the current object (represented by self) relative to a + given line, defined by two points (line_point_1 and line_point_2). After performing this + function, the coordinates of the object will be changed so that it is on the opposite + side of the line, keeping the same distance to the line. + + Parameters + :param line_point_1: 'Vector': + The first point defining the line. + :param line_point_2: 'Vector': + The second point defining the line. + """ + dx = line_point_2.x - line_point_1.x + dy = line_point_2.y - line_point_1.y + + a = (dx * dx - dy * dy) / (dx * dx + dy * dy) + b = 2 * dx * dy / (dx * dx + dy * dy) + + new_x = a * (self.x - line_point_1.x) + b * (self.y - line_point_1.y) + line_point_1.x + new_y = b * (self.x - line_point_1.x) - a * (self.y - line_point_1.y) + line_point_1.y + + self.x = new_x + self.y = new_y + + + @staticmethod + def get_position_relative_to_line(vector_start: 'Vector', vector_end: 'Vector', vector: 'Vector') -> int: + """ + Determines the position of a vector relative to a line defined by two points. + + Parameters: + :param vector_start 'Vector': + The start point of the line. + :param vector_end 'Vector': + The end point of the line. + :param vector 'Vector': + The vector whose position relative to the line is to be determined. + + Returns int: + 1 if the vector is to the left of the line, -1 if the vector is to the right of the + line, 0 if the vector lies on the line. + """ + d = (vector.x - vector_start.x) * (vector_end.y - vector_start.y) - (vector.y - vector_start.y) * (vector_end.x - vector_start.x) + if d > 0: + return 1 + elif d < 0: + return -1 + else: + return 0 + + + @staticmethod + def get_directionality_triangle(vector_a: 'Vector', vector_b: 'Vector', vector_c: 'Vector') -> str: + """ + Determines the directionality of the triangle formed by three vectors (or points). + + Parameters: + :param vector_a 'Vector': + The first vertex of the triangle. + :param vector_b 'Vector': + The second vertex of the triangle. + :param vector_c 'Vector': + The third vertex of the triangle. + + Returns str: + - 'clockwise' if the triangle is oriented in a clockwise direction. + - 'counterclockwise' if the triangle is oriented in a counterclockwise direction. + - None if the three points are collinear (lie on the same line). + """ + determinant = (vector_b.x - vector_a.x) * (vector_c.y - vector_a.y) - \ + (vector_c.x - vector_a.x) * (vector_b.y - vector_a.y) + if determinant < 0: + return 'clockwise' + elif determinant == 0: + return None + else: + return 'counterclockwise' + + + @staticmethod + def mirror_vector_about_line(line_point_1: 'Vector', line_point_2: 'Vector', point: 'Vector')-> 'Vector': + """ + Mirrors a point (or vector) across a line defined by two points. + + Parameters: + :param line_point_1 'Vector': + The first point defining the line. + :param line_point_2 'Vector': + The second point defining the line. + :param point 'Vector': + The point to be mirrored across the line. + + Returns Vector: + A new Vector representing the mirrored point across the line. + """ + dx = line_point_2.x - line_point_1.x + dy = line_point_2.y - line_point_1.y + + a = (dx * dx - dy * dy) / (dx * dx + dy * dy) + b = 2 * dx * dy / (dx * dx + dy * dy) + + x_new = a * (point.x - line_point_1.x) + b * (point.y - line_point_1.y) + line_point_1.x + y_new = b * (point.x - line_point_1.x) - a * (point.y - line_point_1.y) + line_point_1.y + return Vector(x_new, y_new) + + + @staticmethod + def get_line_angle(point_1: 'Vector', point_2: 'Vector') -> float: + """ + Calculates the angle of a line defined by two points with respect to the positive x-axis. + + Parameters: + point_1 'Vector': + The first point defining the line. + point_2 'Vector': + The second point defining the line. + + Returns float: + The angle of the line in radians, in the range [-π, π]. + """ + difference = Vector.subtract_vectors(point_2, point_1) + return difference.angle() + + + @staticmethod + def subtract_vectors(vector_1: 'Vector', vector_2: 'Vector')-> 'Vector': + """ + Subtracts one vector from another. + + Parameters: + vector_1 'Vector': + The vector from which to subtract. + vector_2 'Vector': + The vector to subtract. + + Returns Vector: + A new Vector representing the result of the subtraction (vector_1 - vector_2). + """ + x = vector_1.x - vector_2.x + y = vector_1.y - vector_2.y + return Vector(x, y) + + + @staticmethod + def add_vectors(vector_1: 'Vector', vector_2: 'Vector') -> 'Vector': + """ + Adds two vectors together. + + Parameters: + vector_1 'Vector': + The first vector to add. + vector_2 'Vector': + The second vector to add. + + Returns Vector: + A new Vector representing the result of the addition (vector_1 + vector_2). + """ + x = vector_1.x + vector_2.x + y = vector_1.y + vector_2.y + return Vector(x, y) + + + @staticmethod + def get_midpoint(vector_1: 'Vector', vector_2: 'Vector') -> 'Vector': + """ + Calculates the midpoint between two vectors. + + Parameters: + vector_1 'Vector': + The first vector. + vector_2 'Vector': + The second vector. + + Returns Vector: + A new Vector representing the midpoint between vector_1 and vector_2. + """ + x = (vector_1.x + vector_2.x) / 2 + y = (vector_1.y + vector_2.y) / 2 + return Vector(x, y) + + + @staticmethod + def get_average(vectors: List['Vector']) -> 'Vector': + """ + Calculates the average of a list of vectors. + + Parameters: + :param vectors List[Vector]: + A list of vectors for which the average is to be calculated. + + Returns: + Vector: A new Vector representing the average of the input vectors. + """ + average_x = 0.0 + average_y = 0.0 + for vector in vectors: + average_x += vector.x + average_y += vector.y + return Vector(average_x / len(vectors), average_y / len(vectors)) + + + @staticmethod + def get_normals(vector_1: 'Vector', vector_2: 'Vector') -> List['Vector']: + """ + Calculates the normal vectors to the line defined by two vectors. + + Parameters: + :param vector_1 'Vector': + The first vector defining the line. + :param vector_2 'Vector': + The second vector defining the line. + + Returns List[Vector]: + A list containing two normal vectors to the line defined by vector_1 and vector_2. + """ + delta = Vector.subtract_vectors(vector_2, vector_1) + return [Vector(-delta.y, delta.x), Vector(delta.y, -delta.x)] + + + @staticmethod + def get_angle_between_vectors(vector_1: 'Vector', vector_2: 'Vector', origin: 'Vector') -> float: + """ + Calculates the angle between two vectors relative to a given origin point. + + Parameters: + :param vector_1 'Vector': + The first vector. + :param vector_2 'Vector': + The second vector. + :param origin 'Vector': + The origin point relative to which the angle is calculated. + + Returns: + float: The angle between vector_1 and vector_2 in radians, in the range [0, π]. + """ + v1_x_diff: float = vector_1.x - origin.x + v1_y_diff: float = vector_1.y - origin.y + v2_x_diff: float = vector_2.x - origin.x + v2_y_diff: float = vector_2.y - origin.y + + dot_product: float = v1_x_diff * v2_x_diff + v1_y_diff * v2_y_diff + length_v1: float = math.sqrt(v1_x_diff ** 2 + v1_y_diff ** 2) + length_v2: float = math.sqrt(v2_x_diff ** 2 + v2_y_diff ** 2) + + cos_angle = dot_product / (length_v1 * length_v2) + return math.acos(cos_angle) + + + + + + + + + + + + + +class Polygon: + """ + The `Polygon` class focuses on properties and calculations related to regular polygons, such as + finding the circumradius, calculating central angles, determining the apothem, and identifying + the type of polygon based on its number of sides. It also includes static methods for + calculating normals to lines defined by two vectors and for adding, averaging, and mirroring + vectors. + """ + def __init__(self, edge_number: float) -> None: + """ + Initializes a Polygon with a specified number of edges. + + Parameters: + :param edge_number float: + The number of edges (sides) of the polygon. + """ + self.edge_number: float = edge_number + + @staticmethod + def find_polygon_radius(edge_length: float, edge_number: float) -> float: + """ + Calculates the radius of the circumcircle of a regular polygon. + + Parameters: + :param edge_length float: + The length of one edge of the polygon. + :param edge_number float: + The number of edges (sides) of the polygon. + + Returns float: + The radius of the circumcircle. + """ + return edge_length / (2 * math.sin(math.pi / edge_number)) + + @staticmethod + def get_central_angle(edge_number: float) -> float: + """ + Calculates the central angle of a regular polygon. + + Parameters: + :param edge_number float: + The number of edges (sides) of the polygon. + + Returns float: + The central angle in radians. + """ + return math.radians(float(360) / edge_number) + + @staticmethod + def get_apothem(radius: float, edge_number: float) -> float: + """ + Calculates the apothem of a regular polygon. + + Parameters: + :param radius float: + The radius of the circumcircle of the polygon. + :param edge_number float: + The number of edges (sides) of the polygon. + + Returns float: + The length of the apothem. + """ + return radius * math.cos(math.pi / edge_number) + + @staticmethod + def get_apothem_from_side_length(length: float, edge_number: float) -> float: + """ + Calculates the apothem of a regular polygon given the side length. + + Parameters: + :param length float: + The length of one edge of the polygon. + :param edge_number float: + The number of edges (sides) of the polygon. + + Returns float: + The length of the apothem. + """ + radius: float = Polygon.find_polygon_radius(length, edge_number) + return Polygon.get_apothem(radius, edge_number) \ No newline at end of file diff --git a/chython/algorithms/calculate2d/Properties.py b/chython/algorithms/calculate2d/Properties.py new file mode 100644 index 00000000..d8f16930 --- /dev/null +++ b/chython/algorithms/calculate2d/Properties.py @@ -0,0 +1,611 @@ +""" +This module defines classes that extend the properties of rings, atoms, and bonds within the +Kaiton structure, focusing on attributes and methods necessary for coordinate calculations. + +The classes contained herein serve as supplements to the existing Kaiton structure, offering +additional functionalities tailored for computational chemistry applications. They are designed +to facilitate the calculation of molecular geometries by providing detailed attributes and +methods specific to rings, atoms, and bonds, thereby enhancing the structure's utility in +algorithms that require precise spatial information. These classes include RingProperties, +AtomProperties, BondProperties, and RingOverlap, each tailored to represent different aspects of +molecular structures with attributes and methods that aid in determining spatial relationships +and characteristics inherent to chemical compounds. + +Classes: +- RingProperties: Represents the properties of rings within a molecule, including identifiers, + member atoms, positioning status, geometric center, presence of subrings, and types of rings + (e.g., bridged, spiro, fused). +- AtomProperties: Encapsulates atomic properties crucial for molecular geometry calculations, + such as atomic symbols, positions, and connectivity. +- BondProperties: Details the computational parameters of chemical bonds, including atom + references and bond types. +- RingOverlap: Handles overlaps between rings, identifying shared atoms and determining + structural characteristics like bridging. + +These classes are integral for algorithms that necessitate a deep understanding of molecular +topology and geometry, offering a structured approach to manipulating and analyzing chemical +structures programmatically. They facilitate the representation of complex molecular features +such as ring systems, atomic configurations, and bond characteristics, making them indispensable +for cheminformatics and computational chemistry applications. +""" + +from typing import List, Optional, Tuple +from .MathHelper import Vector +import math + +class RingProperties: + """ + A class on computing parameters of rings + """ + def __init__(self: 'RingProperties', ring: List['AtomProperties']) -> None: + """ + Constructor of the class that complements information about rings in the Kaiton + structure, creating new properties or converting existing ones into a more convenient + form for use in coordinate calculation algorithms. + + Parameters: + :param ring List['AtomProperties']: + A list of AtomProperties objects forming the current ring. + + Attributes: + - id Optional[int]: Contains information about the ring identifier, its sequential + number. + - members List['AtomProperties']: A list of references to corresponding AtomProperties + objects which are participants of this ring. + - members_id List[int]: A list of identifiers for AtomProperties objects which are + participants of this ring. + - positioned bool: A boolean value corresponding to the state of the ring calculation, + returns True if all atoms of this ring have received their coordinates. + - center 'Vector': The center of the ring in coordinates. + - subrings List: A boolean value indicating whether the ring contains subrings. + - bridged bool: A boolean value indicating whether the ring is a bridge ring. + - spiro bool: A boolean value indicating whether the ring is a spirocycle. + - fused bool: A boolean value indicating whether it is part of a condensed cyclic system. + - subring_of_bridged bool: A boolean value indicating whether the subrings are bridged. + - central_angle float: The central angle of the ring. + - neighbouring_rings List[int]: A list of identifiers of neighboring rings to the + current one. + """ + self.id: Optional[int] = None + self.members: List['AtomProperties'] = ring + self.members_id: List[int] = [atom.id for atom in self.members] + self.positioned: bool = False + self.center: 'Vector' = Vector(0, 0) + self.subrings: List = [] + self.bridged = False + self.spiro: bool = False + self.fused: bool = False + self.subring_of_bridged = False + self.central_angle: float = 0.0 + self.neighbouring_rings: List[int] = [] + + # добавляем в свойства атомов то что они находятся в этом кольце + for atom in self.members: + atom.ring_indexes.append(self.id) + atom.rings.append(self) + + + def __eq__(self, other: 'RingProperties') -> bool: + """ + Compares two RingProperties instances for equality based on their identifiers. + + This method checks if the identifiers of the two RingProperties instances being compared + are equal, returning True if they match and False otherwise. It serves as a quick way to + determine if two rings refer to the same entity in terms of their unique identifier. + + Parameters: + :param other RingProperties: + The instance to compare with the current instance. + + Returns bool: + True if the identifiers of the two instances are equal, indicating they represent + the same ring. False otherwise. + """ + return False if other is None else self.id == other.id + + + def __hash__(self) -> int: + """ + Returns the hash value of the current object, which is the unique identifier of the ring. + + This method is used when the object needs to be inserted into a hash-based collection + such as a set or dictionary. + The hash value is derived from the ring's unique identifier, allowing for efficient + storage and retrieval of ring objects + in collections that rely on hashing. + + Returns int: + The unique identifier of the current object of the class, used as the hash value. + """ + return self.id + + + def get_angle(self) -> float: + """ + Calculates the exterior angle of the polygon formed by the ring in radians. + + The exterior angle is determined by subtracting the central angle of the ring from π + (pi), providing a measure + of the angle formed outside the ring by extending one of its sides. This method is + particularly useful for + understanding the geometry of the ring within the context of its surrounding environment. + + Returns float: + The exterior angle of the polygon formed by the ring in radians. + """ + return math.pi - self.central_angle + + + def __repr__(self) -> str: + """ + Provides a human-readable representation of the RingProperties object, primarily + intended for debugging purposes. + + The representation includes the ring's identifier followed by the identifiers of the + atoms that are part of the ring, separated by hyphens. This format offers a concise yet + informative overview of the ring's composition, aiding in the identification and + analysis of rings during development and debugging sessions. + + Returns str: + A string combining the ring's identifier and the identifiers of the atoms that make + up the ring, separated by hyphens. + """ + members: str = '-'.join(str(member) for member in self.members) + return f'{self.id} {members}' + + + def copy(self) -> 'RingProperties': + """ + Creates a deep copy of the current RingProperties instance, duplicating all its + attributes and relationships. + + This method constructs a new RingProperties object that mirrors the current + instance exactly, including the list of member atoms, their identifiers, the + ring's position status, geometric center, presence of subrings, + and various boolean flags indicating the ring's characteristics (e.g., whether it + is bridged, spiro, fused). + Additionally, it copies over the list of neighboring rings and any subrings + associated with the ring. + + Returns RingProperties: + A new instance of the RingProperties class that is a deep copy of the current + instance, complete with all attributes and relationships duplicated. + """ + new_members: List['AtomProperties'] = [] + for atom in self.members: + new_members.append(atom.copy()) + + new_ring = RingProperties(new_members) + new_ring.id = self.id + for ring_id in self.neighbouring_rings: + new_ring.neighbouring_rings.append(ring_id) + + new_ring.positioned = self.positioned + for subring in self.subrings: + new_ring.subrings.append(subring) + new_ring.bridged = self.bridged + new_ring.subring_of_bridged = self.subring_of_bridged + new_ring.spiro = self.spiro + new_ring.fused = self.fused + new_ring.central_angle = self.central_angle + return new_ring + + + + + + + + + + + + + + + + + + + + + + + + +class BondProperties: + """ + A class about computational parameters of links + """ + def __init__(self: 'BondProperties', atom1: 'AtomProperties', \ + atom2: 'AtomProperties', bond) -> None: + """ + Constructor of the class that complements information about bonds in the Kaiton + structure, creating new properties or converting existing ones into a more + convenient form for use in coordinate calculation algorithms. + + Parameters: + :param atom1 'AtomProperties': + Reference to the object of the class of the first atom forming this bond. + :param atom2 'AtomProperties': + Reference to the object of the class of the second atom forming this bond. + :param bond: + Reference to the original Kaiton bond class. + + Attributes: + - id Tuple['AtomProperties']: Identifier of the current bond, which is a tuple of + atom identifiers between which this bond exists. + - n int: Identifier of the first atom of this bond. + - m int: Identifier of the second atom of this bond. + - atom1 'AtomProperties': Reference to the object of the class of the first atom of + this bond. + - atom2 'AtomProperties': Reference to the object of the class of the second atom of + this bond. + - type str: String that characterizes the type of bond, primary, secondary, or + tertiary. + + # center (bool): Placeholder for future expansion. + # chiral (bool): Placeholder for future expansion. + # chiral_symbol (Optional[str]): Placeholder for future expansion. + + The constructor initializes the bond properties based on the provided atoms and + determines its type (single, double, triple) based on the order of the bond. + + """ + self.id: Tuple['AtomProperties'] = (atom1.id, atom2.id) + self.n: int = atom1.id #atom1 index + self.m: int = atom2.id #atom2 index + + self.atom1: 'AtomProperties' = atom1 + self.atom2: 'AtomProperties' = atom2 + + # self.center: bool = False # рудименты кода + # self.chiral: bool = False # рудименты кода + # self.chiral_symbol: Optional[str] = None # # рудименты кода + + self.type = Optional[None] + if bond.order == 1: + self.type = 'single' + elif bond.order == 2: + self.type = 'double' + elif bond.order == 3: + self.type = 'triple' + + + + + + + + + + + + + + + + + + + + + + + + + + +class AtomProperties: + """ + A class about computing parameters of atoms + """ + def __init__(self: 'AtomProperties', atom_index: int, symbol: str) -> None: + """ + Initializes an instance of the AtomProperties class with data about an atom. + + Parameters: + :param atom_index int: + The index of the current atom within the molecular structure. + :param symbol str: + Symbol representing the element according to the periodic table. + + Attributes: + - id int: Unique identifier for the atom. + - symbol str: String characterizing the name of the element according to the periodic + table. + - ring_indexes List[int]: List of identifiers for rings in which the atom is a + participant. + - rings List['RingProperties']: List of references to RingProperties objects + representing the rings in which the atom is involved. + - is_bridge_atom bool: Flag indicating whether the atom is a bridging atom. + - is_bridge bool: Flag indicating whether the atom is a bridging atom. + - bridged_ring Optional['RingProperties']: RingProperties object representing the ring + through which a bridge passes. + - positioned bool: Flag indicating whether the coordinates for the current atom have + been calculated. + - previous_position 'Vector': Coordinates of the preceding atom. + - position 'Vector': Current coordinates of the atom. + - angle Optional[float]: Angle between the current and preceding atom. + - force_positioned bool: Flag indicating whether the atom's position was calculated + forcibly. + - connected_to_ring bool: Flag indicating whether the atom is connected to a ring. + - draw_explicit bool: Flag indicating whether the atom should be drawn explicitly. + - neighbours List['AtomProperties']: List of AtomProperties objects representing atoms + with which the current atom forms bonds. + - previous_atom Optional['AtomProperties']: Reference to an AtomProperties object + representing the preceding atom in the chain. + """ + + self.id: int = atom_index + self.symbol: str = symbol + self.ring_indexes: List[int] = [] + self.rings: List['RingProperties'] = [] + + # self.original_rings: List['RingProperties'] = [] + self.anchored_rings: List['RingProperties'] = [] + self.is_bridge_atom: bool = False + self.is_bridge: bool = False + + self.bridged_ring = None + self.positioned: bool = False + + self.previous_position: 'Vector' = Vector(0, 0) + self.position: 'Vector' = Vector(0, 0) + self.angle: Optional[float] = None + self.force_positioned: bool = False + self.connected_to_ring: bool = False + self.draw_explicit: bool = False + self.neighbours: List['AtomProperties'] = [] + self.previous_atom: Optional['AtomProperties'] = None + + + def __eq__(self, other: 'AtomProperties') -> bool: + """ + Compares two AtomProperties instances for equality based on their identifiers. + + Parameters: + :param other 'AtomProperties': + Another instance of the AtomProperties class. + + Returns bool: + True if both instances represent atoms with the same identifier, otherwise False. + """ + return False if other is None else self.id == other.id + + + def set_position(self, vector: 'Vector') -> None: + """ + Sets the position of the current atom to the specified vector. + + Parameters: + :param vector 'Vector': + An instance of the Vector class, whose coordinates are assigned as the position of the current atom. + """ + self.position: 'Vector' = vector + + + def __hash__(self) -> int: + """ + Returns the hash value of the current object, which is the unique identifier of the atom. + + This method is used when the object needs to be inserted into a hash-based collection + such as a set or dictionary. + + Returns int: + The unique identifier of the current object of the class, used as the hash value. + """ + return self.id + + + def __repr__(self) -> str: + """ + Provides a human-readable representation of the AtomProperties object, primarily + intended for debugging purposes. + + The representation includes the atomic symbol followed by the atomic index, adjusted by + subtracting 1 due to the indexing convention in Chython where numbering starts from 1 + instead of 0. + + Returns str: + A string combining the atomic symbol and the adjusted atomic index. + """ + return f'{self.symbol}_{self.id - 1}' + + + def get_angle(self, reference_vector: Optional['Vector']=None) -> float: + """ + Calculates the angle between the current atom and either the previous atom or a + specified reference vector. + + By default, the angle is calculated between the current atom and the previous atom. + However, if a reference_vector is provided, the angle between the current atom and the + reference_vector will be calculated instead. + + Parameters: + :param reference_vector Optional['Vector']: + An object of the Vector class representing the coordinates with which the angle will + be calculated. If None, the angle between the current atom and the previous atom is + calculated. Defaults to None. + + Returns float: + The angle between the current atom and either the previous atom or the specified + reference vector, depending on the parameter provided. + """ + vector_1: float = self.position + vector_2: float = self.previous_position if not reference_vector else reference_vector + vector = Vector.subtract_vectors(vector_1, vector_2) + return vector.angle() + + + def copy(self) -> 'AtomProperties': + """ + Creates a deep copy of the current AtomProperties instance and returns it as a new + object of the same class. + + This method duplicates all attributes of the current atom, including its position, + connections, and identifiers, ensuring that modifications to the copy do not affect the + original atom object. + + Returns AtomProperties: + A new instance of the AtomProperties class with identical properties to the original + atom, but as a separate object in memory. + """ + new_atom = AtomProperties(self.id, self.symbol) + new_atom.ring_indexes =self.ring_indexes + new_atom.rings = self.rings + # new_atom.original_rings = self.original_rings + new_atom.anchored_rings = self.anchored_rings + new_atom.is_bridge_atom = self.is_bridge_atom + new_atom.is_bridge = self.is_bridge + new_atom.positioned = self.positioned + new_atom.previous_position = self.previous_position + new_atom.position = self.position + new_atom.angle = self.angle + new_atom.force_positioned = self.force_positioned + new_atom.connected_to_ring = self.connected_to_ring + new_atom.draw_explicit = self.draw_explicit + new_atom.neighbours = self.neighbours + new_atom.previous_atom = self.previous_atom + return new_atom + + + def is_terminal(self) -> bool: + "Returns boolean whether a given atom is terminal (has no more than one bond)." + return len(self.neighbours) <= 1 + + def set_previous_position(self, previous_atom: 'AtomProperties') -> None: + "Set previous position atom" + self.previous_position = previous_atom.position + self.previous_atom = previous_atom + + + + + + + + + + + + + +class RingOverlap: + """ + Initializes an instance of the RingOverlap class, which represents the overlap between + two rings. + """ + def __init__(self, ring_1: 'RingProperties', ring_2: 'RingProperties') -> None: + """ + This class is designed to handle situations where two rings share common atoms, + indicating an overlap or intersection between them. + + Parameters: + :param ring_1 RingProperties: + An instance of the RingProperties class representing the first ring involved in the + overlap. + :param ring_2 RingProperties: + An instance of the RingProperties class representing the second ring involved in the + overlap. + + Attributes: + - id: A unique identifier for the overlap instance. Initially set to None, indicating + that the overlap ID may need to be assigned externally. + - ring_id_1 int: The identifier of the first ring participating in the overlap. + - ring_id_2 int: The identifier of the second ring participating in the overlap. + - atoms List['AtomProperties']: A list of AtomProperties instances that are common to + both rings, representing the atoms where the overlap occurs. + + The constructor identifies the common atoms between the two rings by intersecting the + members of both rings and stores their identifiers for reference. + """ + self.id = None + self.ring_id_1: int = ring_1.id + self.ring_id_2: int = ring_2.id + self.atoms: List['AtomProperties'] = set(ring_1.members).intersection(set(ring_2.members)) + + + def __repr__(self) -> str: + """ + Provides a human-readable representation of the RingOverlap object, primarily intended + for debugging purposes. + + Returns a string that includes the overlap identifier and the identifiers of the rings + involved in the overlap, offering a convenient way to inspect the state of the object + quickly. + + Returns str: + A formatted string containing the overlap's unique identifier (`id`) and the + identifiers of the two rings (`ring_id_1` and `ring_id_2`) participating in the overlap. + This representation aids in quickly identifying the instance's state, especially useful + during debugging sessions. + """ + return f'{self.id=}, {self.ring_id_1=}, {self.ring_id_2=}' + + + def is_bridge(self) -> bool: + """ + Determines whether the overlap represents a bridge between rings based on the number of + common atoms and their ring memberships. + + This method checks if the current overlap involves more than two atoms or if any atom + within the overlap participates in more than two rings, indicating a bridging structure. + + Returns bool: + True if the overlap is considered part of a single bridge ring, otherwise False. + Specifically, returns True if either the number of atoms involved in the overlap + exceeds two or if any atom in the overlap belongs to more than two rings, suggesting + a complex bridging configuration. + """ + return len(self.atoms) > 2 or any(len(atom.rings) > 2 for atom in self.atoms) + # ниже старая версия функции + # if len(self.atoms) > 2: + # return True + # for atom in self.atoms: + # if len(atom.rings) > 2: + # return True + # return False + + + def involves_ring(self, ring_id: int) -> bool: + """ + Checks if a ring identified by `ring_id` is involved in the current overlap. + + This method determines whether the specified ring identifier matches either of the two + rings participating in the overlap represented by the current instance of RingOverlap. + + Parameters: + :param ring_id int: + The identifier of the ring to check for involvement in the overlap. + + Returns bool: + True if the specified ring identifier matches either `ring_id_1` or `ring_id_2`, + indicating that the ring is part of the current overlap. False otherwise. + """ + return self.ring_id_1 == ring_id or self.ring_id_2 == ring_id + + + def update_other(self, ring_id: int, other_ring_id: int) -> None: + """ + Updates the current attributes of the class depending on the other ring. + + This method adjusts the internal identifiers of the RingOverlap instance based on the + provided ring identifiers, ensuring that the instance accurately reflects the rings + involved in the overlap. + + Parameters: + :param ring_id int: + Identifier of the first ring to be considered for updating. + :param other_ring_id int: + Identifier of another ring, used to determine which attribute (ring_id_1 or + ring_id_2) needs to be updated. + + If the current instance's ring_id_1 matches other_ring_id, then ring_id is assigned to + ring_id_2, and vice versa. This ensures that the RingOverlap instance correctly tracks + the two rings involved in the overlap. + """ + if self.ring_id_1 == other_ring_id: + self.ring_id_2 = ring_id + else: + self.ring_id_1 = ring_id \ No newline at end of file diff --git a/chython/algorithms/calculate2d/__init__.py b/chython/algorithms/calculate2d/__init__.py index c8fe17a5..9cb04b96 100644 --- a/chython/algorithms/calculate2d/__init__.py +++ b/chython/algorithms/calculate2d/__init__.py @@ -1,205 +1 @@ -# -*- coding: utf-8 -*- -# -# Copyright 2019-2024 Ramil Nugmanov -# Copyright 2019, 2020 Dinar Batyrshin -# This file is part of chython. -# -# chython is free software; you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation; either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with this program; if not, see . -# -from math import sqrt -from random import random -from typing import TYPE_CHECKING, Union -from ...exceptions import ImplementationError - - -try: - from importlib.resources import files -except ImportError: # python3.8 - from importlib_resources import files - - -if TYPE_CHECKING: - from chython import ReactionContainer, MoleculeContainer - -try: - from py_mini_racer.py_mini_racer import MiniRacer, JSEvalException - - ctx = MiniRacer() - ctx.eval('const self = this') - ctx.eval(files(__package__).joinpath('clean2d.js').read_text()) -except RuntimeError: - ctx = None - - -class Calculate2DMolecule: - __slots__ = () - - def clean2d(self: Union['MoleculeContainer', 'Calculate2DMolecule']): - """ - Calculate 2d layout of graph. https://pubs.acs.org/doi/10.1021/acs.jcim.7b00425 JS implementation used. - """ - if ctx is None: - raise ImportError('py_mini_racer is not installed or broken') - plane = {} - entry = iter(sorted(self, key=lambda n: len(self._bonds[n]))) - for _ in range(min(5, len(self))): - smiles, order = self.__clean2d_prepare(next(entry)) - try: - xy = ctx.call('$.clean2d', smiles) - except JSEvalException: - continue - break - else: - raise ImplementationError - - shift_x, shift_y = xy[0] - for n, (x, y) in zip(order, xy): - plane[n] = (x - shift_x, shift_y - y) - - bonds = [] - for n, m, _ in self.bonds(): - xn, yn = plane[n] - xm, ym = plane[m] - bonds.append(sqrt((xm - xn) ** 2 + (ym - yn) ** 2)) - if bonds: - bond_reduce = sum(bonds) / len(bonds) / .825 - else: - bond_reduce = 1. - - atoms = self._atoms - for n, (x, y) in plane.items(): - a = atoms[n] - a._x = x / bond_reduce - a._y = y / bond_reduce - - if self.connected_components_count > 1: - shift_x = 0. - for c in self.connected_components: - shift_x = self._fix_plane_mean(shift_x, component=c) + .9 - self.__dict__.pop('__cached_method__repr_svg_', None) - - def _fix_plane_mean(self: 'MoleculeContainer', shift_x: float, shift_y=0., component=None) -> float: - atoms = self._atoms - if component is None: - component = atoms - - left_atom = atoms[min(component, key=lambda x: atoms[x].x)] - right_atom = atoms[max(component, key=lambda x: atoms[x].x)] - - min_x = left_atom.x - shift_x - if len(left_atom.atomic_symbol) == 2: - min_x -= .2 - - max_x = right_atom.x - min_x - min_y = min(atoms[x].y for x in component) - max_y = max(atoms[x].y for x in component) - mean_y = (max_y + min_y) / 2 - shift_y - for n in component: - a = atoms[n] - a._x -= min_x - a._y -= mean_y - - if -.18 <= right_atom.y <= .18: - factor = right_atom.implicit_hydrogens - if factor == 1: - max_x += .15 - elif factor: - max_x += .25 - return max_x - - def _fix_plane_min(self: 'MoleculeContainer', shift_x: float, shift_y=0., component=None) -> float: - atoms = self._atoms - if component is None: - component = atoms - - right_atom = atoms[max(component, key=lambda x: atoms[x].x)] - min_x = min(atoms[x].x for x in component) - shift_x - max_x = right_atom.x - min_x - min_y = min(atoms[x].y for x in component) - shift_y - - for n in component: - a = atoms[n] - a._x -= min_x - a._y -= min_y - - if shift_y - .18 <= right_atom.y <= shift_y + .18: - factor = right_atom.implicit_hydrogens - if factor == 1: - max_x += .15 - elif factor: - max_x += .25 - return max_x - - def __clean2d_prepare(self: 'MoleculeContainer', entry): - w = {n: random() for n in self._atoms} - w[entry] = -1 - smiles, order = self._smiles(w.__getitem__, random=True, charges=False, stereo=False, _return_order=True) - return ''.join(smiles).replace('~', '-'), order - - -class Calculate2DReaction: - __slots__ = () - - def clean2d(self: 'ReactionContainer'): - """ - Recalculate 2d coordinates - """ - for m in self.molecules(): - m.clean2d() - self.fix_positions() - - def fix_positions(self: 'ReactionContainer'): - """ - Fix coordinates of molecules in reaction - """ - shift_x = 0 - reactants = self.reactants - amount = len(reactants) - 1 - signs = [] - for m in reactants: - max_x = m._fix_plane_mean(shift_x) - if amount: - max_x += .2 - signs.append(max_x) - amount -= 1 - shift_x = max_x + 1 - arrow_min = shift_x - - if self.reagents: - shift_x += .4 - for m in self.reagents: - max_x = m._fix_plane_min(shift_x, .5) - shift_x = max_x + 1 - shift_x += .4 - if shift_x - arrow_min < 3: - shift_x = arrow_min + 3 - else: - shift_x += 3 - arrow_max = shift_x - 1 - - products = self.products - amount = len(products) - 1 - for m in products: - max_x = m._fix_plane_mean(shift_x) - if amount: - max_x += .2 - signs.append(max_x) - amount -= 1 - shift_x = max_x + 1 - self._arrow = (arrow_min, arrow_max) - self._signs = tuple(signs) - self.flush_cache() - - -__all__ = ['Calculate2DMolecule', 'Calculate2DReaction'] +from .clean2d import Calculate2DMolecule, Calculate2DReaction \ No newline at end of file diff --git a/chython/algorithms/calculate2d/clean2d.js b/chython/algorithms/calculate2d/clean2d.js deleted file mode 100644 index 6c60ef9b..00000000 --- a/chython/algorithms/calculate2d/clean2d.js +++ /dev/null @@ -1 +0,0 @@ -!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.$=e():t.$=e()}(self,(function(){return(()=>{var t={348:t=>{class e{static clone(t){let i=Array.isArray(t)?Array():{};for(let r in t){let n=t[r];"function"==typeof n.clone?i[r]=n.clone():i[r]="object"==typeof n?e.clone(n):n}return i}static equals(t,e){if(t.length!==e.length)return!1;let i=t.slice().sort(),r=e.slice().sort();for(var n=0;n-1&&t.splice(i,1),t}static removeAll(t,e){return t.filter((function(t){return-1===e.indexOf(t)}))}static merge(t,e){let i=new Array(t.length+e.length);for(let e=0;e{const r=i(348);i(843),i(421);class n{constructor(t,e="-"){this.element=1===t.length?t.toUpperCase():t,this.drawExplicit=!1,this.ringbonds=Array(),this.rings=Array(),this.bondType=e,this.branchBond=null,this.isBridge=!1,this.isBridgeNode=!1,this.originalRings=Array(),this.bridgedRing=null,this.anchoredRings=Array(),this.bracket=null,this.plane=0,this.attachedPseudoElements={},this.hasAttachedPseudoElements=!1,this.isDrawn=!0,this.isConnectedToRing=!1,this.neighbouringElements=Array(),this.isPartOfAromaticRing=t!==this.element,this.bondCount=0,this.chirality="",this.isStereoCenter=!1,this.priority=0,this.mainChain=!1,this.hydrogenDirection="down",this.subtreeDepth=1,this.hasHydrogen=!1,this.class=void 0}addNeighbouringElement(t){this.neighbouringElements.push(t)}attachPseudoElement(t,e,i=0,r=0){null===i&&(i=0),null===r&&(r=0);let n=i+t+r;this.attachedPseudoElements[n]?this.attachedPseudoElements[n].count+=1:this.attachedPseudoElements[n]={element:t,count:1,hydrogenCount:i,previousElement:e,charge:r},this.hasAttachedPseudoElements=!0}getAttachedPseudoElements(){let t={},e=this;return Object.keys(this.attachedPseudoElements).sort().forEach((function(i){t[i]=e.attachedPseudoElements[i]})),t}getAttachedPseudoElementsCount(){return Object.keys(this.attachedPseudoElements).length}isHeteroAtom(){return"C"!==this.element&&"H"!==this.element}addAnchoredRing(t){r.contains(this.anchoredRings,{value:t})||this.anchoredRings.push(t)}getRingbondCount(){return this.ringbonds.length}backupRings(){this.originalRings=Array(this.rings.length);for(let t=0;t{const r=i(474),n=i(614),{getChargeText:s}=(i(929),i(843),i(421),i(537));t.exports=class{constructor(t,e,i){this.canvas="string"==typeof t||t instanceof String?document.getElementById(t):t,this.ctx=this.canvas.getContext("2d"),this.themeManager=e,this.opts=i,this.drawingWidth=0,this.drawingHeight=0,this.offsetX=0,this.offsetY=0,this.fontLarge=this.opts.fontSizeLarge+"pt Helvetica, Arial, sans-serif",this.fontSmall=this.opts.fontSizeSmall+"pt Helvetica, Arial, sans-serif",this.updateSize(this.opts.width,this.opts.height),this.ctx.font=this.fontLarge,this.hydrogenWidth=this.ctx.measureText("H").width,this.halfHydrogenWidth=this.hydrogenWidth/2,this.halfBondThickness=this.opts.bondThickness/2}updateSize(t,e){this.devicePixelRatio=window.devicePixelRatio||1,this.backingStoreRatio=this.ctx.webkitBackingStorePixelRatio||this.ctx.mozBackingStorePixelRatio||this.ctx.msBackingStorePixelRatio||this.ctx.oBackingStorePixelRatio||this.ctx.backingStorePixelRatio||1,this.ratio=this.devicePixelRatio/this.backingStoreRatio,1!==this.ratio?(this.canvas.width=t*this.ratio,this.canvas.height=e*this.ratio,this.canvas.style.width=t+"px",this.canvas.style.height=e+"px",this.ctx.setTransform(this.ratio,0,0,this.ratio,0,0)):(this.canvas.width=t*this.ratio,this.canvas.height=e*this.ratio)}setTheme(t){this.colors=t}scale(t){let e=-Number.MAX_VALUE,i=-Number.MAX_VALUE,r=Number.MAX_VALUE,n=Number.MAX_VALUE;for(var s=0;so.x&&(r=o.x),n>o.y&&(n=o.y)}var o=this.opts.padding;e+=o,i+=o,r-=o,n-=o,this.drawingWidth=e-r,this.drawingHeight=i-n;var h=this.canvas.offsetWidth/this.drawingWidth,a=this.canvas.offsetHeight/this.drawingHeight,l=h.5&&(e.stroke(),e.beginPath(),e.strokeStyle=this.themeManager.getColor(t.getRightElement())||this.themeManager.getColor("C"),m=!0),r.subtract(o),e.moveTo(r.x,r.y),r.add(n.multiplyScalar(o,2)),e.lineTo(r.x,r.y)}e.stroke(),e.restore()}drawDebugText(t,e,i){let r=this.ctx;r.save(),r.font="5px Droid Sans, sans-serif",r.textAlign="start",r.textBaseline="top",r.fillStyle="#ff0000",r.fillText(i,t+this.offsetX,e+this.offsetY),r.restore()}drawBall(t,e,i){let n=this.ctx;n.save(),n.beginPath(),n.arc(t+this.offsetX,e+this.offsetY,this.opts.bondLength/4.5,0,r.twoPI,!1),n.fillStyle=this.themeManager.getColor(i),n.fill(),n.restore()}drawPoint(t,e,i){let n=this.ctx,s=this.offsetX,o=this.offsetY;n.save(),n.globalCompositeOperation="destination-out",n.beginPath(),n.arc(t+s,e+o,1.5,0,r.twoPI,!0),n.closePath(),n.fill(),n.globalCompositeOperation="source-over",n.beginPath(),n.arc(t+this.offsetX,e+this.offsetY,.75,0,r.twoPI,!1),n.fillStyle=this.themeManager.getColor(i),n.fill(),n.restore()}drawText(t,e,i,n,o,h,a,l,g,d={}){let u=this.ctx,c=this.offsetX,p=this.offsetY;u.save(),u.textAlign="start",u.textBaseline="alphabetic";let f="",v=0;a&&(f=s(a),u.font=this.fontSmall,v=u.measureText(f).width);let m="0",b=0;l>0&&(m=l.toString(),u.font=this.fontSmall,b=u.measureText(m).width),1===a&&"N"===i&&d.hasOwnProperty("0O")&&d.hasOwnProperty("0O-1")&&(d={"0O":{element:"O",count:2,hydrogenCount:0,previousElement:"C",charge:""}},a=0),u.font=this.fontLarge,u.fillStyle=this.themeManager.getColor("BACKGROUND");let y=u.measureText(i);y.totalWidth=y.width+v,y.height=parseInt(this.fontLarge,10);let x=y.width>this.opts.fontSizeLarge?y.width:this.opts.fontSizeLarge;x/=1.5,u.globalCompositeOperation="destination-out",u.beginPath(),u.arc(t+c,e+p,x,0,r.twoPI,!0),u.closePath(),u.fill(),u.globalCompositeOperation="source-over";let S=-y.width/2,A=-y.width/2;u.fillStyle=this.themeManager.getColor(i),u.fillText(i,t+c+S,e+this.opts.halfFontSizeLarge+p),S+=y.width,a&&(u.font=this.fontSmall,u.fillText(f,t+c+S,e-this.opts.fifthFontSizeSmall+p),S+=v),l>0&&(u.font=this.fontSmall,u.fillText(m,t+c+A-b,e-this.opts.fifthFontSizeSmall+p),A-=b),u.font=this.fontLarge;let C=0,R=0;if(1===n){let i=t+c,r=e+p+this.opts.halfFontSizeLarge;C=this.hydrogenWidth,A-=C,"left"===o?i+=A:"right"===o||"up"===o&&h||"down"===o&&h?i+=S:"up"!==o||h?"down"!==o||h||(r+=this.opts.fontSizeLarge+this.opts.quarterFontSizeLarge,i-=this.halfHydrogenWidth):(r-=this.opts.fontSizeLarge+this.opts.quarterFontSizeLarge,i-=this.halfHydrogenWidth),u.fillText("H",i,r),S+=C}else if(n>1){let i=t+c,r=e+p+this.opts.halfFontSizeLarge;C=this.hydrogenWidth,u.font=this.fontSmall,R=u.measureText(n).width,A-=C+R,"left"===o?i+=A:"right"===o||"up"===o&&h||"down"===o&&h?i+=S:"up"!==o||h?"down"!==o||h||(r+=this.opts.fontSizeLarge+this.opts.quarterFontSizeLarge,i-=this.halfHydrogenWidth):(r-=this.opts.fontSizeLarge+this.opts.quarterFontSizeLarge,i-=this.halfHydrogenWidth),u.font=this.fontLarge,u.fillText("H",i,r),u.font=this.fontSmall,u.fillText(n,i+this.halfHydrogenWidth+R,r+this.opts.fifthFontSizeSmall),S+=C+this.halfHydrogenWidth+R}for(let i in d){if(!d.hasOwnProperty(i))continue;let r=0,n=0,h=d[i].element,a=d[i].count,l=d[i].hydrogenCount,g=d[i].charge;u.font=this.fontLarge,a>1&&l>0&&(r=u.measureText("(").width,n=u.measureText(")").width);let f=u.measureText(h).width,v=0,m="",b=0;C=0,l>0&&(C=this.hydrogenWidth),u.font=this.fontSmall,a>1&&(v=u.measureText(a).width),0!==g&&(m=s(g),b=u.measureText(m).width),R=0,l>1&&(R=u.measureText(l).width),u.font=this.fontLarge;let y=t+c,x=e+p+this.opts.halfFontSizeLarge;u.fillStyle=this.themeManager.getColor(h),a>0&&(A-=v),a>1&&l>0&&("left"===o?(A-=n,u.fillText(")",y+A,x)):(u.fillText("(",y+S,x),S+=r)),"left"===o?(A-=f,u.fillText(h,y+A,x)):(u.fillText(h,y+S,x),S+=f),l>0&&("left"===o?(A-=C+R,u.fillText("H",y+A,x),l>1&&(u.font=this.fontSmall,u.fillText(l,y+A+C,x+this.opts.fifthFontSizeSmall))):(u.fillText("H",y+S,x),S+=C,l>1&&(u.font=this.fontSmall,u.fillText(l,y+S,x+this.opts.fifthFontSizeSmall),S+=R))),u.font=this.fontLarge,a>1&&l>0&&("left"===o?(A-=r,u.fillText("(",y+A,x)):(u.fillText(")",y+S,x),S+=n)),u.font=this.fontSmall,a>1&&("left"===o?u.fillText(a,y+A+r+n+C+R+f,x+this.opts.fifthFontSizeSmall):(u.fillText(a,y+S,x+this.opts.fifthFontSizeSmall),S+=v)),0!==g&&("left"===o?u.fillText(m,y+A+r+n+C+R+f,e-this.opts.fifthFontSizeSmall+p):(u.fillText(m,y+S,e-this.opts.fifthFontSizeSmall+p),S+=b))}u.restore()}getChargeText(t){return 1===t?"+":2===t?"2+":-1===t?"-":-2===t?"2-":""}drawDebugPoint(t,e,i="",r="#f00"){this.drawCircle(t,e,2,r,!0,!0,i)}drawAromaticityRing(t){let e=this.ctx,i=r.apothemFromSideLength(this.opts.bondLength,t.getSize());e.save(),e.strokeStyle=this.themeManager.getColor("C"),e.lineWidth=this.opts.bondThickness,e.beginPath(),e.arc(t.center.x+this.offsetX,t.center.y+this.offsetY,i-this.opts.bondSpacing,0,2*Math.PI,!0),e.closePath(),e.stroke(),e.restore()}clear(){this.ctx.clearRect(0,0,this.canvas.offsetWidth,this.canvas.offsetHeight)}}},237:(t,e,i)=>{const r=i(474),n=i(348),s=i(614),o=i(929),h=(i(843),i(826)),a=i(427),l=i(421),g=i(333),d=i(841),u=i(707),c=i(473),p=i(654),f=i(207);t.exports=class{constructor(t){this.graph=null,this.doubleBondConfigCount=0,this.doubleBondConfig=null,this.ringIdCounter=0,this.ringConnectionIdCounter=0,this.canvasWrapper=null,this.totalOverlapScore=0,this.defaultOptions={width:500,height:500,scale:0,bondThickness:1,bondLength:30,shortBondLength:.8,bondSpacing:.17*30,atomVisualization:"default",isomeric:!0,debug:!1,terminalCarbons:!1,explicitHydrogens:!0,overlapSensitivity:.42,overlapResolutionIterations:1,compactDrawing:!0,fontFamily:"Arial, Helvetica, sans-serif",fontSizeLarge:11,fontSizeSmall:3,padding:10,experimentalSSSR:!1,kkThreshold:.1,kkInnerThreshold:.1,kkMaxIteration:2e4,kkMaxInnerIteration:50,kkMaxEnergy:1e9,themes:{dark:{C:"#fff",O:"#e74c3c",N:"#3498db",F:"#27ae60",CL:"#16a085",BR:"#d35400",I:"#8e44ad",P:"#d35400",S:"#f1c40f",B:"#e67e22",SI:"#e67e22",H:"#aaa",BACKGROUND:"#141414"},light:{C:"#222",O:"#e74c3c",N:"#3498db",F:"#27ae60",CL:"#16a085",BR:"#d35400",I:"#8e44ad",P:"#d35400",S:"#f1c40f",B:"#e67e22",SI:"#e67e22",H:"#666",BACKGROUND:"#fff"},oldschool:{C:"#000",O:"#000",N:"#000",F:"#000",CL:"#000",BR:"#000",I:"#000",P:"#000",S:"#000",B:"#000",SI:"#000",H:"#000",BACKGROUND:"#fff"},solarized:{C:"#586e75",O:"#dc322f",N:"#268bd2",F:"#859900",CL:"#16a085",BR:"#cb4b16",I:"#6c71c4",P:"#d33682",S:"#b58900",B:"#2aa198",SI:"#2aa198",H:"#657b83",BACKGROUND:"#fff"},"solarized-dark":{C:"#93a1a1",O:"#dc322f",N:"#268bd2",F:"#859900",CL:"#16a085",BR:"#cb4b16",I:"#6c71c4",P:"#d33682",S:"#b58900",B:"#2aa198",SI:"#2aa198",H:"#839496",BACKGROUND:"#fff"},matrix:{C:"#678c61",O:"#2fc079",N:"#4f7e7e",F:"#90d762",CL:"#82d967",BR:"#23755a",I:"#409931",P:"#c1ff8a",S:"#faff00",B:"#50b45a",SI:"#409931",H:"#426644",BACKGROUND:"#fff"},github:{C:"#24292f",O:"#cf222e",N:"#0969da",F:"#2da44e",CL:"#6fdd8b",BR:"#bc4c00",I:"#8250df",P:"#bf3989",S:"#d4a72c",B:"#fb8f44",SI:"#bc4c00",H:"#57606a",BACKGROUND:"#fff"},carbon:{C:"#161616",O:"#da1e28",N:"#0f62fe",F:"#198038",CL:"#007d79",BR:"#fa4d56",I:"#8a3ffc",P:"#ff832b",S:"#f1c21b",B:"#8a3800",SI:"#e67e22",H:"#525252",BACKGROUND:"#fff"},cyberpunk:{C:"#ea00d9",O:"#ff3131",N:"#0abdc6",F:"#00ff9f",CL:"#00fe00",BR:"#fe9f20",I:"#ff00ff",P:"#fe7f00",S:"#fcee0c",B:"#ff00ff",SI:"#ffffff",H:"#913cb1",BACKGROUND:"#fff"},gruvbox:{C:"#665c54",O:"#cc241d",N:"#458588",F:"#98971a",CL:"#79740e",BR:"#d65d0e",I:"#b16286",P:"#af3a03",S:"#d79921",B:"#689d6a",SI:"#427b58",H:"#7c6f64",BACKGROUND:"#fbf1c7"},"gruvbox-dark":{C:"#ebdbb2",O:"#cc241d",N:"#458588",F:"#98971a",CL:"#b8bb26",BR:"#d65d0e",I:"#b16286",P:"#fe8019",S:"#d79921",B:"#8ec07c",SI:"#83a598",H:"#bdae93",BACKGROUND:"#282828"},custom:{C:"#222",O:"#e74c3c",N:"#3498db",F:"#27ae60",CL:"#16a085",BR:"#d35400",I:"#8e44ad",P:"#d35400",S:"#f1c40f",B:"#e67e22",SI:"#e67e22",H:"#666",BACKGROUND:"#fff"}}},this.opts=f.extend(!0,this.defaultOptions,t),this.opts.halfBondSpacing=this.opts.bondSpacing/2,this.opts.bondLengthSq=this.opts.bondLength*this.opts.bondLength,this.opts.halfFontSizeLarge=this.opts.fontSizeLarge/2,this.opts.quarterFontSizeLarge=this.opts.fontSizeLarge/4,this.opts.fifthFontSizeSmall=this.opts.fontSizeSmall/5,this.theme=this.opts.themes.dark}draw(t,e,i="light",r=!1){this.initDraw(t,i,r),this.infoOnly||(this.themeManager=new p(this.opts.themes,i),this.canvasWrapper=new d(e,this.themeManager,this.opts)),r||(this.processGraph(),this.canvasWrapper.scale(this.graph.vertices),this.drawEdges(this.opts.debug),this.drawVertices(this.opts.debug),this.canvasWrapper.reset(),this.opts.debug&&(console.log(this.graph),console.log(this.rings),console.log(this.ringConnections)))}edgeRingCount(t){let e=this.graph.edges[t],i=this.graph.vertices[e.sourceId],r=this.graph.vertices[e.targetId];return Math.min(i.value.rings.length,r.value.rings.length)}getBridgedRings(){let t=Array();for(var e=0;ei&&(i=h,t=r,e=n)}}let o=-s.subtract(this.graph.vertices[t].position,this.graph.vertices[e].position).angle();if(!isNaN(o)){let t=o%.523599;for(t<.2617995?o-=t:o+=.523599-t,r=0;r1?t:""),i.delete("C")}if(i.has("H")){let t=i.get("H");e+="H"+(t>1?t:""),i.delete("H")}return Object.keys(a.atomicNumbers).sort().map((t=>{if(i.has(t)){let r=i.get(t);e+=t+(r>1?r:"")}})),e}getRingbondType(t,e){if(t.value.getRingbondCount()<1||e.value.getRingbondCount()<1)return null;for(var i=0;in&&(s=e.sourceId,o=e.targetId),this.getSubtreeOverlapScore(o,s,t.vertexScores).value>this.opts.overlapSensitivity){let e=this.graph.vertices[s],i=this.graph.vertices[o],n=i.getNeighbours(s);if(1===n.length){let t=this.graph.vertices[n[0]],s=t.position.getRotateAwayFromAngle(e.position,i.position,r.toRad(120));this.rotateSubtree(t.id,i.id,s,i.position);let o=this.getOverlapScore().total;o>this.totalOverlapScore?this.rotateSubtree(t.id,i.id,-s,i.position):this.totalOverlapScore=o}else if(2===n.length){if(0!==i.value.rings.length&&0!==e.value.rings.length)continue;let t=this.graph.vertices[n[0]],s=this.graph.vertices[n[1]];if(1===t.value.rings.length&&1===s.value.rings.length){if(t.value.rings[0]!==s.value.rings[0])continue}else{if(0!==t.value.rings.length||0!==s.value.rings.length)continue;{let n=t.position.getRotateAwayFromAngle(e.position,i.position,r.toRad(120)),o=s.position.getRotateAwayFromAngle(e.position,i.position,r.toRad(120));this.rotateSubtree(t.id,i.id,n,i.position),this.rotateSubtree(s.id,i.id,o,i.position);let h=this.getOverlapScore().total;h>this.totalOverlapScore?(this.rotateSubtree(t.id,i.id,-n,i.position),this.rotateSubtree(s.id,i.id,-o,i.position)):this.totalOverlapScore=h}}}t=this.getOverlapScore()}}}this.resolveSecondaryOverlaps(t.scores),this.opts.isomeric&&this.annotateStereochemistry(),this.opts.compactDrawing&&"default"===this.opts.atomVisualization&&this.initPseudoElements(),this.rotateDrawing()}initRings(){let t=new Map;for(var e=this.graph.vertices.length-1;e>=0;e--){let r=this.graph.vertices[e];if(0!==r.value.ringbonds.length)for(var i=0;i0&&this.addRingConnection(n)}for(e=0;e0;){let t=-1;for(e=0;er&&(r=e,n=t)}return n}getVerticesAt(t,e,i){let r=Array();for(var n=0;ni;){let n=this.graph.vertices[i],o=this.graph.vertices[r];if(!n.value.isDrawn||!o.value.isDrawn)continue;let h=s.subtract(n.position,o.position).lengthSq();if(hd[1]?0:1,sideCount:l,position:l[0]>l[1]?0:1,anCount:o,bnCount:h}}setRingCenter(t){let e=t.getSize(),i=new s(0,0);for(var r=0;r1||0==e.bnCount&&e.anCount>1){c[0].multiplyScalar(i.opts.halfBondSpacing),c[1].multiplyScalar(i.opts.halfBondSpacing);let t=new o(s.add(d,c[0]),s.add(u,c[0]),l,g),e=new o(s.add(d,c[1]),s.add(u,c[1]),l,g);this.canvasWrapper.drawLine(t),this.canvasWrapper.drawLine(e)}else if(e.sideCount[0]>e.sideCount[1]){c[0].multiplyScalar(i.opts.bondSpacing),c[1].multiplyScalar(i.opts.bondSpacing);let t=new o(s.add(d,c[0]),s.add(u,c[0]),l,g);t.shorten(this.opts.bondLength-this.opts.shortBondLength*this.opts.bondLength),this.canvasWrapper.drawLine(t),this.canvasWrapper.drawLine(new o(d,u,l,g))}else if(e.sideCount[0]e.totalSideCount[1]){c[0].multiplyScalar(i.opts.bondSpacing),c[1].multiplyScalar(i.opts.bondSpacing);let t=new o(s.add(d,c[0]),s.add(u,c[0]),l,g);t.shorten(this.opts.bondLength-this.opts.shortBondLength*this.opts.bondLength),this.canvasWrapper.drawLine(t),this.canvasWrapper.drawLine(new o(d,u,l,g))}else if(e.totalSideCount[0]<=e.totalSideCount[1]){c[0].multiplyScalar(i.opts.bondSpacing),c[1].multiplyScalar(i.opts.bondSpacing);let t=new o(s.add(d,c[1]),s.add(u,c[1]),l,g);t.shorten(this.opts.bondLength-this.opts.shortBondLength*this.opts.bondLength),this.canvasWrapper.drawLine(t),this.canvasWrapper.drawLine(new o(d,u,l,g))}}else if("#"===r.bondType){c[0].multiplyScalar(i.opts.bondSpacing/1.5),c[1].multiplyScalar(i.opts.bondSpacing/1.5);let t=new o(s.add(d,c[0]),s.add(u,c[0]),l,g),e=new o(s.add(d,c[1]),s.add(u,c[1]),l,g);this.canvasWrapper.drawLine(t),this.canvasWrapper.drawLine(e),this.canvasWrapper.drawLine(new o(d,u,l,g))}else if("."===r.bondType);else{let t=h.value.isStereoCenter,e=a.value.isStereoCenter;"up"===r.wedge?this.canvasWrapper.drawWedge(new o(d,u,l,g,t,e)):"down"===r.wedge?this.canvasWrapper.drawDashedWedge(new o(d,u,l,g,t,e)):this.canvasWrapper.drawLine(new o(d,u,l,g,t,e))}if(e){let e=s.midpoint(d,u);this.canvasWrapper.drawDebugText(e.x,e.y,"e: "+t)}}drawVertices(t){var e=this.graph.vertices.length;for(e=0;e0&&(t=this.graph.vertices[this.rings[0].members[0]]),null===t&&(t=this.graph.vertices[0]),this.createNextBond(t,null,0)}backupRingInformation(){this.originalRings=Array(),this.originalRingConnections=Array();for(var t=0;ts.subtract(e,l[0]).lengthSq()&&(u=l[1]);let c=s.subtract(o.position,u),p=s.subtract(h.position,u);-1===c.clockwise(p)?i.positioned||this.createRing(i,u,o,h):i.positioned||this.createRing(i,u,h,o)}else if(1===n.length){t.isSpiro=!0,i.isSpiro=!0;let o=this.graph.vertices[n[0]],h=s.subtract(e,o.position);h.invert(),h.normalize();let a=r.polyCircumradius(this.opts.bondLength,i.getSize());h.multiplyScalar(a),h.add(o.position),i.positioned||this.createRing(i,h,o)}}for(p=0;pr.opts.overlapSensitivity&&(n+=e,h++);let s=r.graph.vertices[t.id].position.clone();s.multiplyScalar(e),o.add(s)})),o.divide(n),{value:n/h,center:o}}getCurrentCenterOfMass(){let t=new s(0,0),e=0;for(var i=0;i1){let e=Array();for(var n=0;nh&&(this.rotateSubtree(t.id,e.common.id,2*r,e.common.position),this.rotateSubtree(i.id,e.common.id,-2*r,e.common.position))}else 1===e.vertices.length&&e.rings.length}}resolveSecondaryOverlaps(t){for(var e=0;ethis.opts.overlapSensitivity){let i=this.graph.vertices[t[e].id];if(i.isTerminal()){let t=this.getClosestVertex(i);if(t){let e=null;e=t.isTerminal()?0===t.id?this.graph.vertices[1].position:t.previousPosition:0===t.id?this.graph.vertices[1].position:t.position;let n=0===i.id?this.graph.vertices[1].position:i.previousPosition;i.position.rotateAwayFrom(e,n,r.toRad(20))}}}}getLastVertexWithAngle(t){let e=0,i=null;for(;!e&&t;)i=this.graph.vertices[t],e=i.angle,t=i.parentVertexId;return i}createNextBond(t,e=null,i=0,o=!1,h=!1){if(t.positioned&&!h)return;let a=!1;if(e){let i=this.graph.getEdge(t.id,e.id);"/"!==i.bondType&&"\\"!==i.bondType||++this.doubleBondConfigCount%2!=1||null===this.doubleBondConfig&&(this.doubleBondConfig=i.bondType,a=!0,null===e.parentVertexId&&t.value.branchBond&&("/"===this.doubleBondConfig?this.doubleBondConfig="\\":"\\"===this.doubleBondConfig&&(this.doubleBondConfig="/")))}if(!h)if(e)if(e.value.rings.length>0){let i=e.neighbours,r=null,o=new s(0,0);if(null===e.value.bridgedRing&&e.value.rings.length>1)for(var l=0;l0){let e=this.getRing(t.value.rings[0]);if(!e.positioned){let i=s.subtract(t.previousPosition,t.position);i.invert(),i.normalize();let n=r.polyCircumradius(this.opts.bondLength,e.getSize());i.multiplyScalar(n),i.add(t.position),this.createRing(e,i,t)}}else{t.value.isStereoCenter;let i=t.getNeighbours(),h=Array();for(l=0;l0){let e=r.toRad(60),n=-e,o=new s(this.opts.bondLength,0),h=new s(this.opts.bondLength,0);o.rotate(e).add(t.position),h.rotate(n).add(t.position);let a=this.getCurrentCenterOfMass(),l=o.distanceSq(a),d=h.distanceSq(a);i.angle=l3?r=r>0?Math.min(1.0472,r):r<0?Math.max(-1.0472,r):1.0472:r||(r=this.getLastVertexWithAngle(t.id).angle,r||(r=1.0472)),e&&!a){let e=this.graph.getEdge(t.id,i.id).bondType;"/"===e?("/"===this.doubleBondConfig||"\\"===this.doubleBondConfig&&(r=-r),this.doubleBondConfig=null):"\\"===e&&("/"===this.doubleBondConfig?r=-r:this.doubleBondConfig,this.doubleBondConfig=null)}i.angle=o?r:-r,this.createNextBond(i,t,g+i.angle)}}else if(2===h.length){let i=t.angle;i||(i=1.0472);let r=this.graph.getTreeDepth(h[0],t.id),n=this.graph.getTreeDepth(h[1],t.id),s=this.graph.vertices[h[0]],o=this.graph.vertices[h[1]];s.value.subtreeDepth=r,o.value.subtreeDepth=n;let a=this.graph.getTreeDepth(e?e.id:null,t.id);e&&(e.value.subtreeDepth=a);let l=0,d=1;"C"===o.value.element&&"C"!==s.value.element&&n>1&&r<5?(l=1,d=0):"C"!==o.value.element&&"C"===s.value.element&&r>1&&n<5?(l=0,d=1):n>r&&(l=1,d=0);let u=this.graph.vertices[h[l]],c=this.graph.vertices[h[d]],p=(this.graph.getEdge(t.id,u.id),this.graph.getEdge(t.id,c.id),!1);ai&&n>s?(o=this.graph.vertices[h[1]],a=this.graph.vertices[h[0]],l=this.graph.vertices[h[2]]):s>i&&s>n&&(o=this.graph.vertices[h[2]],a=this.graph.vertices[h[0]],l=this.graph.vertices[h[1]]),e&&e.value.rings.length<1&&o.value.rings.length<1&&a.value.rings.length<1&&l.value.rings.length<1&&1===this.graph.getTreeDepth(a.id,t.id)&&1===this.graph.getTreeDepth(l.id,t.id)&&this.graph.getTreeDepth(o.id,t.id)>1?(o.angle=-t.angle,t.angle>=0?(a.angle=r.toRad(30),l.angle=r.toRad(90)):(a.angle=-r.toRad(30),l.angle=-r.toRad(90)),this.createNextBond(o,t,g+o.angle),this.createNextBond(a,t,g+a.angle),this.createNextBond(l,t,g+l.angle)):(o.angle=0,a.angle=r.toRad(90),l.angle=-r.toRad(90),this.createNextBond(o,t,g+o.angle),this.createNextBond(a,t,g+a.angle),this.createNextBond(l,t,g+l.angle))}else if(4===h.length){let e=this.graph.getTreeDepth(h[0],t.id),i=this.graph.getTreeDepth(h[1],t.id),n=this.graph.getTreeDepth(h[2],t.id),s=this.graph.getTreeDepth(h[3],t.id),o=this.graph.vertices[h[0]],a=this.graph.vertices[h[1]],l=this.graph.vertices[h[2]],d=this.graph.vertices[h[3]];o.value.subtreeDepth=e,a.value.subtreeDepth=i,l.value.subtreeDepth=n,d.value.subtreeDepth=s,i>e&&i>n&&i>s?(o=this.graph.vertices[h[1]],a=this.graph.vertices[h[0]],l=this.graph.vertices[h[2]],d=this.graph.vertices[h[3]]):n>e&&n>i&&n>s?(o=this.graph.vertices[h[2]],a=this.graph.vertices[h[0]],l=this.graph.vertices[h[1]],d=this.graph.vertices[h[3]]):s>e&&s>i&&s>n&&(o=this.graph.vertices[h[3]],a=this.graph.vertices[h[0]],l=this.graph.vertices[h[1]],d=this.graph.vertices[h[2]]),o.angle=-r.toRad(36),a.angle=r.toRad(36),l.angle=-r.toRad(108),d.angle=r.toRad(108),this.createNextBond(o,t,g+o.angle),this.createNextBond(a,t,g+a.angle),this.createNextBond(l,t,g+l.angle),this.createNextBond(d,t,g+d.angle)}}}getCommonRingbondNeighbour(t){let e=t.neighbours;for(var i=0;i0&&i.value.rings.length>0&&this.areVerticesInSameRing(e,i))}isRingAromatic(t){for(var e=0;el&&(l=a[e][1].length),i=0;ig&&(g=a[e][1][i].length);for(e=0;ee[1][i][r])return-1;if(t[1][i][r]1&&s.value.hasHydrogen,C=s.value.hasHydrogen?1:0;for(e=0;ee[0]?-1:t[0]=0&&(i=i===y?x:y,o[d[e]]!==t);e--);this.graph.getEdge(s.id,t).wedge=i}}s.value.chirality=b}}visitStereochemistry(t,e,i,r,n,s,o=0){i[t]=1;let h=this.graph.vertices[t],a=h.value.getAtomicNumber();r.length<=s&&r.push(Array());for(var l=0;l0)continue;if("P"===i.value.element)continue;if("C"===i.value.element&&3===n.length&&"N"===n[0].value.element&&"N"===n[1].value.element&&"N"===n[2].value.element)continue;let s=0,o=0;for(e=0;e1&&o++}if(o>1||s<2)continue;let h=null;for(e=0;e1&&(h=t)}for(e=0;e1)continue;t.value.isDrawn=!1;let r=a.maxBonds[t.value.element]-t.value.bondCount,s="";t.value.bracket&&(r=t.value.bracket.hcount,s=t.value.bracket.charge||0),i.value.attachPseudoElement(t.value.element,h?h.value.element:null,r,s)}}for(t=0;t{class e{constructor(t,e,i=1){this.id=null,this.sourceId=t,this.targetId=e,this.weight=i,this.bondType="-",this.isPartOfAromaticRing=!1,this.center=!1,this.wedge=""}setBondType(t){this.bondType=t,this.weight=e.bonds[t]}static get bonds(){return{"-":1,"/":1,"\\":1,"=":2,"#":3,$:4}}}t.exports=e},707:(t,e,i)=>{const r=i(474),n=(i(614),i(843)),s=i(826),o=(i(421),i(427));class h{constructor(t,e=!1){this.vertices=Array(),this.edges=Array(),this.vertexIdsToEdgeId={},this.isomeric=e,this._time=0,this._init(t)}_init(t,e=0,i=null,r=!1){let h=new o(t.atom.element?t.atom.element:t.atom,t.bond);h.branchBond=t.branchBond,h.ringbonds=t.ringbonds,h.bracket=t.atom.element?t.atom:null,h.class=t.atom.class;let a=new n(h),l=this.vertices[i];if(this.addVertex(a),null!==i){a.setParentVertexId(i),a.value.addNeighbouringElement(l.value.element),l.addChild(a.id),l.value.addNeighbouringElement(h.element),l.spanningTreeChildren.push(a.id);let t=new s(i,a.id,1),e=null;r?(t.setBondType(a.value.branchBond||"-"),e=a.id,t.setBondType(a.value.branchBond||"-"),e=a.id):(t.setBondType(l.value.bondType||"-"),e=l.id),this.addEdge(t)}let g=t.ringbondCount+1;h.bracket&&(g+=h.bracket.hcount);let d=0;if(h.bracket&&h.bracket.chirality){h.isStereoCenter=!0,d=h.bracket.hcount;for(var u=0;ui[r][s]+i[s][n]&&(i[r][n]=i[r][s]+i[s][n]);return i}getSubgraphDistanceMatrix(t){let e=t.length,i=this.getSubgraphAdjacencyMatrix(t),r=Array(e);for(var n=0;nr[n][o]+r[o][s]&&(r[n][s]=r[n][o]+r[o][s]);return r}getAdjacencyList(){let t=this.vertices.length,e=Array(t);for(var i=0;i0;){let t=n.shift(),i=this.vertices[t];e(i);for(var s=0;sr&&(r=s)}return r+1}traverseTree(t,e,i,r=999999,n=!1,s=1,o=null){if(null===o&&(o=new Uint8Array(this.vertices.length)),s>r+1||1===o[t])return;o[t]=1;let h=this.vertices[t],a=h.getNeighbours(e);(!n||s>1)&&i(h);for(var l=0;lt&&!1===S[u]&&(t=n,e=u,i=s,r=o)}return[e,t,i,r]},D=function(t,e,i){let r=0,n=0,s=0,o=y[t],h=x[t],a=A[t],l=C[t];for(u=f;u--;){if(u===t)continue;let e=y[u],i=x[u],g=a[u],d=l[u],c=(o-e)*(o-e),p=1/Math.pow(c+(h-i)*(h-i),1.5);r+=d*(1-g*(h-i)*(h-i)*p),n+=d*(1-g*c*p),s+=d*(g*(o-e)*(h-i)*p)}0===r&&(r=.1),0===n&&(n=.1),0===s&&(s=.1);let g=e/r+i/s;g/=s/r-n/s;let d=-(s*g+e)/r;y[t]+=d,x[t]+=g;let c,p,v,m,b,S=N[t];for(e=0,i=0,o=y[t],h=x[t],u=f;u--;)t!==u&&(c=y[u],p=x[u],v=S[u][0],m=S[u][1],b=1/Math.sqrt((o-c)*(o-c)+(h-p)*(h-p)),d=l[u]*(o-c-a[u]*(o-c)*b),g=l[u]*(h-p-a[u]*(h-p)*b),S[u]=[d,g],e+=d,i+=g,O[u]+=d-v,M[u]+=g-m);O[t]=e,M[t]=i},F=0,z=0,H=0,V=0,W=0,U=0;for(;g>o&&a>W;)for(W++,[F,g,z,H]=E(),V=g,U=0;V>h&&l>U;)U++,D(F,z,H),[V,z,H]=k(F);for(u=f;u--;){let e=t[u],i=this.vertices[e];i.position.x=y[u],i.position.y=x[u],i.positioned=!0,i.forcePositioned=!0}}_bridgeDfs(t,e,i,r,n,s,o){e[t]=!0,i[t]=r[t]=++this._time;for(var h=0;hi[t]&&o.push([t,a]))}}static getConnectedComponents(t){let e=t.length,i=new Array(e),r=new Array;i.fill(!1);for(var n=0;n1&&r.push(e)}return r}static getConnectedComponentCount(t){let e=t.length,i=new Array(e),r=0;i.fill(!1);for(var n=0;n{const r=i(614);class n{constructor(t=new r(0,0),e=new r(0,0),i=null,n=null,s=!1,o=!1){this.from=t,this.to=e,this.elementFrom=i,this.elementTo=n,this.chiralFrom=s,this.chiralTo=o}clone(){return new n(this.from.clone(),this.to.clone(),this.elementFrom,this.elementTo)}getLength(){return Math.sqrt(Math.pow(this.to.x-this.from.x,2)+Math.pow(this.to.y-this.from.y,2))}getAngle(){return r.subtract(this.getRightVector(),this.getLeftVector()).angle()}getRightVector(){return this.from.x{class e{static round(t,e){return e=e||1,Number(Math.round(t+"e"+e)+"e-"+e)}static meanAngle(t){let e=0,i=0;for(var r=0;r{t.exports=class{static extend(){let t=this,e={},i=!1,r=0,n=arguments.length;"[object Boolean]"===Object.prototype.toString.call(arguments[0])&&(i=arguments[0],r++);let s=function(r){for(var n in r)Object.prototype.hasOwnProperty.call(r,n)&&(i&&"[object Object]"===Object.prototype.toString.call(r[n])?e[n]=t.extend(!0,e[n],r[n]):e[n]=r[n])};for(;r{t.exports=function(){"use strict";function t(e,i,r,n){this.message=e,this.expected=i,this.found=r,this.location=n,this.name="SyntaxError","function"==typeof Error.captureStackTrace&&Error.captureStackTrace(this,t)}return function(t,e){function i(){this.constructor=t}i.prototype=e.prototype,t.prototype=new i}(t,Error),t.buildMessage=function(t,e){var i={literal:function(t){return'"'+n(t.text)+'"'},class:function(t){var e,i="";for(e=0;e0){for(e=1,r=1;ett&&(tt=J,et=[]),et.push(t))}function ht(e,i){return new t(e,null,null,i)}function at(){var t,i,r,n,s,o,a,l,g;if(J,t=J,i=function(){var t;return J,t=function(){var t,i,r,n;return J,t=J,66===e.charCodeAt(J)?(i="B",J++):(i=h,ot(b)),i!==h?(114===e.charCodeAt(J)?(r="r",J++):(r=h,ot(y)),r===h&&(r=null),r!==h?t=i=[i,r]:(J=t,t=h)):(J=t,t=h),t===h&&(t=J,67===e.charCodeAt(J)?(i="C",J++):(i=h,ot(x)),i!==h?(108===e.charCodeAt(J)?(r="l",J++):(r=h,ot(S)),r===h&&(r=null),r!==h?t=i=[i,r]:(J=t,t=h)):(J=t,t=h),t===h&&(A.test(e.charAt(J))?(t=e.charAt(J),J++):(t=h,ot(C)))),t!==h&&(t=(n=t).length>1?n.join(""):n),t}(),t===h&&(t=dt())===h&&(t=function(){var t,i,r,n,s,o,a,l,g,d;return J,t=J,91===e.charCodeAt(J)?(i="[",J++):(i=h,ot(p)),i!==h?(r=function(){var t,i,r,n;return J,t=J,O.test(e.charAt(J))?(i=e.charAt(J),J++):(i=h,ot(M)),i!==h?(k.test(e.charAt(J))?(r=e.charAt(J),J++):(r=h,ot(E)),r===h&&(r=null),r!==h?(k.test(e.charAt(J))?(n=e.charAt(J),J++):(n=h,ot(E)),n===h&&(n=null),n!==h?t=i=[i,r,n]:(J=t,t=h)):(J=t,t=h)):(J=t,t=h),t!==h&&(t=Number(t.join(""))),t}(),r===h&&(r=null),r!==h?("se"===e.substr(J,2)?(n="se",J+=2):(n=h,ot(f)),n===h&&("as"===e.substr(J,2)?(n="as",J+=2):(n=h,ot(v)),n===h&&(n=dt())===h&&(n=function(){var t,i,r;return J,t=J,B.test(e.charAt(J))?(i=e.charAt(J),J++):(i=h,ot(I)),i!==h?(P.test(e.charAt(J))?(r=e.charAt(J),J++):(r=h,ot(L)),r===h&&(r=null),r!==h?t=i=[i,r]:(J=t,t=h)):(J=t,t=h),t!==h&&(t=t.join("")),t}(),n===h&&(n=ut()))),n!==h?(s=function(){var t,i,r,n,s,o,a;return J,t=J,64===e.charCodeAt(J)?(i="@",J++):(i=h,ot(D)),i!==h?(64===e.charCodeAt(J)?(r="@",J++):(r=h,ot(D)),r===h&&(r=J,"TH"===e.substr(J,2)?(n="TH",J+=2):(n=h,ot(F)),n!==h?(z.test(e.charAt(J))?(s=e.charAt(J),J++):(s=h,ot(H)),s!==h?r=n=[n,s]:(J=r,r=h)):(J=r,r=h),r===h&&(r=J,"AL"===e.substr(J,2)?(n="AL",J+=2):(n=h,ot(V)),n!==h?(z.test(e.charAt(J))?(s=e.charAt(J),J++):(s=h,ot(H)),s!==h?r=n=[n,s]:(J=r,r=h)):(J=r,r=h),r===h&&(r=J,"SP"===e.substr(J,2)?(n="SP",J+=2):(n=h,ot(W)),n!==h?(U.test(e.charAt(J))?(s=e.charAt(J),J++):(s=h,ot(q)),s!==h?r=n=[n,s]:(J=r,r=h)):(J=r,r=h),r===h&&(r=J,"TB"===e.substr(J,2)?(n="TB",J+=2):(n=h,ot(j)),n!==h?(O.test(e.charAt(J))?(s=e.charAt(J),J++):(s=h,ot(M)),s!==h?(k.test(e.charAt(J))?(o=e.charAt(J),J++):(o=h,ot(E)),o===h&&(o=null),o!==h?r=n=[n,s,o]:(J=r,r=h)):(J=r,r=h)):(J=r,r=h),r===h&&(r=J,"OH"===e.substr(J,2)?(n="OH",J+=2):(n=h,ot(_)),n!==h?(O.test(e.charAt(J))?(s=e.charAt(J),J++):(s=h,ot(M)),s!==h?(k.test(e.charAt(J))?(o=e.charAt(J),J++):(o=h,ot(E)),o===h&&(o=null),o!==h?r=n=[n,s,o]:(J=r,r=h)):(J=r,r=h)):(J=r,r=h)))))),r===h&&(r=null),r!==h?t=i=[i,r]:(J=t,t=h)):(J=t,t=h),t!==h&&(t=(a=t)[1]?"@"==a[1]?"@@":a[1].join("").replace(",",""):"@"),t}(),s===h&&(s=null),s!==h?(o=function(){var t,i,r,n;return J,t=J,72===e.charCodeAt(J)?(i="H",J++):(i=h,ot(K)),i!==h?(k.test(e.charAt(J))?(r=e.charAt(J),J++):(r=h,ot(E)),r===h&&(r=null),r!==h?t=i=[i,r]:(J=t,t=h)):(J=t,t=h),t!==h&&(t=(n=t)[1]?Number(n[1]):1),t}(),o===h&&(o=null),o!==h?(a=function(){var t;return J,t=function(){var t,i,r,n,s,o;return J,t=J,43===e.charCodeAt(J)?(i="+",J++):(i=h,ot(G)),i!==h?(43===e.charCodeAt(J)?(r="+",J++):(r=h,ot(G)),r===h&&(r=J,O.test(e.charAt(J))?(n=e.charAt(J),J++):(n=h,ot(M)),n!==h?(k.test(e.charAt(J))?(s=e.charAt(J),J++):(s=h,ot(E)),s===h&&(s=null),s!==h?r=n=[n,s]:(J=r,r=h)):(J=r,r=h)),r===h&&(r=null),r!==h?t=i=[i,r]:(J=t,t=h)):(J=t,t=h),t!==h&&(t=(o=t)[1]?"+"!=o[1]?Number(o[1].join("")):2:1),t}(),t===h&&(t=function(){var t,i,r,n,s,o;return J,t=J,45===e.charCodeAt(J)?(i="-",J++):(i=h,ot(X)),i!==h?(45===e.charCodeAt(J)?(r="-",J++):(r=h,ot(X)),r===h&&(r=J,O.test(e.charAt(J))?(n=e.charAt(J),J++):(n=h,ot(M)),n!==h?(k.test(e.charAt(J))?(s=e.charAt(J),J++):(s=h,ot(E)),s===h&&(s=null),s!==h?r=n=[n,s]:(J=r,r=h)):(J=r,r=h)),r===h&&(r=null),r!==h?t=i=[i,r]:(J=t,t=h)):(J=t,t=h),t!==h&&(t=(o=t)[1]?"-"!=o[1]?-Number(o[1].join("")):-2:-1),t}()),t}(),a===h&&(a=null),a!==h?(l=function(){var t,i,r,n,s,o,a;if(J,t=J,58===e.charCodeAt(J)?(i=":",J++):(i=h,ot(Y)),i!==h){if(r=J,O.test(e.charAt(J))?(n=e.charAt(J),J++):(n=h,ot(M)),n!==h){for(s=[],k.test(e.charAt(J))?(o=e.charAt(J),J++):(o=h,ot(E));o!==h;)s.push(o),k.test(e.charAt(J))?(o=e.charAt(J),J++):(o=h,ot(E));s!==h?r=n=[n,s]:(J=r,r=h)}else J=r,r=h;r===h&&(Z.test(e.charAt(J))?(r=e.charAt(J),J++):(r=h,ot($))),r!==h?t=i=[i,r]:(J=t,t=h)}else J=t,t=h;return t!==h&&(a=t,t=Number(a[1][0]+a[1][1].join(""))),t}(),l===h&&(l=null),l!==h?(93===e.charCodeAt(J)?(g="]",J++):(g=h,ot(m)),g!==h?t=i=[i,r,n,s,o,a,l,g]:(J=t,t=h)):(J=t,t=h)):(J=t,t=h)):(J=t,t=h)):(J=t,t=h)):(J=t,t=h)):(J=t,t=h)):(J=t,t=h),t!==h&&(t={isotope:(d=t)[1],element:d[2],chirality:d[3],hcount:d[4],charge:d[5],class:d[6]}),t}(),t===h&&(t=ut())),t}(),i!==h){for(r=[],n=lt();n!==h;)r.push(n),n=lt();if(r!==h){for(n=[],s=J,(o=gt())===h&&(o=null),o!==h&&(a=ct())!==h?s=o=[o,a]:(J=s,s=h);s!==h;)n.push(s),s=J,(o=gt())===h&&(o=null),o!==h&&(a=ct())!==h?s=o=[o,a]:(J=s,s=h);if(n!==h){for(s=[],o=lt();o!==h;)s.push(o),o=lt();if(s!==h)if((o=gt())===h&&(o=null),o!==h)if((a=at())===h&&(a=null),a!==h){for(l=[],g=lt();g!==h;)l.push(g),g=lt();l!==h?t=i=[i,r,n,s,o,a,l]:(J=t,t=h)}else J=t,t=h;else J=t,t=h;else J=t,t=h}else J=t,t=h}else J=t,t=h}else J=t,t=h;return t!==h&&(t=function(t){for(var e=[],i=[],r=0;r{const r=i(348),n=i(614),s=(i(843),i(333));class o{constructor(t){this.id=null,this.members=t,this.edges=[],this.insiders=[],this.neighbours=[],this.positioned=!1,this.center=new n(0,0),this.rings=[],this.isBridged=!1,this.isPartOfBridged=!1,this.isSpiro=!1,this.isFused=!1,this.centralAngle=0,this.canFlip=!0}clone(){let t=new o(this.members);return t.id=this.id,t.insiders=r.clone(this.insiders),t.neighbours=r.clone(this.neighbours),t.positioned=this.positioned,t.center=this.center.clone(),t.rings=r.clone(this.rings),t.isBridged=this.isBridged,t.isPartOfBridged=this.isPartOfBridged,t.isSpiro=this.isSpiro,t.isFused=this.isFused,t.centralAngle=this.centralAngle,t.canFlip=this.canFlip,t}getSize(){return this.members.length}getPolygon(t){let e=[];for(let i=0;i{i(843),i(421),t.exports=class{constructor(t,e){this.id=null,this.firstRingId=t.id,this.secondRingId=e.id,this.vertices=new Set;for(var i=0;i2)return!0;for(let e of this.vertices)if(t[e].value.rings.length>2)return!0;return!1}static isBridge(t,e,i,r){let n=null;for(let s=0;s{const r=i(707);class n{static getRings(t,e=!1){let i=t.getComponentsAdjacencyMatrix();if(0===i.length)return null;let s=r.getConnectedComponents(i),o=Array();for(var h=0;he){if(t===e+1)for(n[a][l]=[r[a][l].length],s=r[a][l].length;s--;)for(n[a][l][s]=[r[a][l][s].length],o=r[a][l][s].length;o--;)for(n[a][l][s][o]=[r[a][l][s][o].length],h=r[a][l][s][o].length;h--;)n[a][l][s][o][h]=[r[a][l][s][o][0],r[a][l][s][o][1]];else n[a][l]=Array();for(i[a][l]=e,r[a][l]=[[]],s=r[a][g][0].length;s--;)r[a][l][0].push(r[a][g][0][s]);for(s=r[g][l][0].length;s--;)r[a][l][0].push(r[g][l][0][s])}else if(t===e){if(r[a][g].length&&r[g][l].length)if(r[a][l].length){let t=Array();for(s=r[a][g][0].length;s--;)t.push(r[a][g][0][s]);for(s=r[g][l][0].length;s--;)t.push(r[g][l][0][s]);r[a][l].push(t)}else{let t=Array();for(s=r[a][g][0].length;s--;)t.push(r[a][g][0][s]);for(s=r[g][l][0].length;s--;)t.push(r[g][l][0][s]);r[a][l][0]=t}}else if(t===e-1)if(n[a][l].length){let t=Array();for(s=r[a][g][0].length;s--;)t.push(r[a][g][0][s]);for(s=r[g][l][0].length;s--;)t.push(r[g][l][0][s]);n[a][l].push(t)}else{let t=Array();for(s=r[a][g][0].length;s--;)t.push(r[a][g][0][s]);for(s=r[g][l][0].length;s--;)t.push(r[g][l][0][s]);n[a][l][0]=t}}return{d:i,pe:r,pe_prime:n}}static getRingCandidates(t,e,i){let r=t.length,n=Array(),s=0;for(let o=0;oa)return l}else for(let r=0;ra)return l}return l}static getEdgeCount(t){let e=0,i=t.length;for(var r=i-1;r--;)for(var n=i;n--;)1===t[r][n]&&e++;return e}static getEdgeList(t){let e=t.length,i=Array();for(var r=e-1;r--;)for(var n=e;n--;)1===t[r][n]&&i.push([r,n]);return i}static bondsToAtoms(t){let e=new Set;for(var i=t.length;i--;)e.add(t[i][0]),e.add(t[i][1]);return e}static getBondCount(t,e){let i=0;for(let r of t)for(let n of t)r!==n&&(i+=e[r][n]);return i/2}static pathSetsContain(t,e,i,r,s,o){for(var h=t.length;h--;){if(n.isSupersetOf(e,t[h]))return!0;if(t[h].size===e.size&&n.areSetsEqual(t[h],e))return!0}let a=0,l=!1;for(h=i.length;h--;)for(var g=r.length;g--;)(i[h][0]===r[g][0]&&i[h][1]===r[g][1]||i[h][1]===r[g][0]&&i[h][0]===r[g][1])&&a++,a===i.length&&(l=!0);let d=!1;if(l)for(let t of e)if(o[t]{t.exports=class{constructor(t,e){this.colors=t,this.theme=this.colors[e]}getColor(t){return t&&(t=t.toUpperCase())in this.theme?this.theme[t]:this.theme.C}setTheme(t){this.colors.hasOwnProperty(t)&&(this.theme=this.colors[t])}}},537:t=>{t.exports={getChargeText:function(t){return 1===t?"+":2===t?"2+":-1===t?"-":-2===t?"2-":""}}},614:t=>{class e{constructor(t,e){0==arguments.length?(this.x=0,this.y=0):1==arguments.length?(this.x=t.x,this.y=t.y):(this.x=t,this.y=e)}clone(){return new e(this.x,this.y)}toString(){return"("+this.x+","+this.y+")"}add(t){return this.x+=t.x,this.y+=t.y,this}subtract(t){return this.x-=t.x,this.y-=t.y,this}divide(t){return this.x/=t,this.y/=t,this}multiply(t){return this.x*=t.x,this.y*=t.y,this}multiplyScalar(t){return this.x*=t,this.y*=t,this}invert(){return this.x=-this.x,this.y=-this.y,this}angle(){return Math.atan2(this.y,this.x)}distance(t){return Math.sqrt((t.x-this.x)*(t.x-this.x)+(t.y-this.y)*(t.y-this.y))}distanceSq(t){return(t.x-this.x)*(t.x-this.x)+(t.y-this.y)*(t.y-this.y)}clockwise(t){let e=this.y*t.x,i=this.x*t.y;return e>i?-1:e===i?0:1}relativeClockwise(t,e){let i=(this.y-t.y)*(e.x-t.x),r=(this.x-t.x)*(e.y-t.y);return i>r?-1:i===r?0:1}rotate(t){let i=new e(0,0),r=Math.cos(t),n=Math.sin(t);return i.x=this.x*r-this.y*n,i.y=this.x*n+this.y*r,this.x=i.x,this.y=i.y,this}rotateAround(t,e){let i=Math.sin(t),r=Math.cos(t);this.x-=e.x,this.y-=e.y;let n=this.x*r-this.y*i,s=this.x*i+this.y*r;return this.x=n+e.x,this.y=s+e.y,this}rotateTo(t,i,r=0){this.x+=.001,this.y-=.001;let n=e.subtract(this,i),s=e.subtract(t,i),o=e.angle(s,n);return this.rotateAround(o+r,i),this}rotateAwayFrom(t,e,i){this.rotateAround(i,e);let r=this.distanceSq(t);this.rotateAround(-2*i,e),this.distanceSq(t)n?i:-i}getRotateToAngle(t,i){let r=e.subtract(this,i),n=e.subtract(t,i),s=e.angle(n,r);return Number.isNaN(s)?0:s}isInPolygon(t){let e=!1;for(let i=0,r=t.length-1;ithis.y!=t[r].y>this.y&&this.x<(t[r].x-t[i].x)*(this.y-t[i].y)/(t[r].y-t[i].y)+t[i].x&&(e=!e);return e}length(){return Math.sqrt(this.x*this.x+this.y*this.y)}lengthSq(){return this.x*this.x+this.y*this.y}normalize(){return this.divide(this.length()),this}normalized(){return e.divideScalar(this,this.length())}whichSide(t,e){return(this.x-t.x)*(e.y-t.y)-(this.y-t.y)*(e.x-t.x)}sameSideAs(t,e,i){let r=this.whichSide(t,e),n=i.whichSide(t,e);return r<0&&n<0||0==r&&0==n||r>0&&n>0}static add(t,i){return new e(t.x+i.x,t.y+i.y)}static subtract(t,i){return new e(t.x-i.x,t.y-i.y)}static multiply(t,i){return new e(t.x*i.x,t.y*i.y)}static multiplyScalar(t,i){return new e(t.x,t.y).multiplyScalar(i)}static midpoint(t,i){return new e((t.x+i.x)/2,(t.y+i.y)/2)}static normals(t,i){let r=e.subtract(i,t);return[new e(-r.y,r.x),new e(r.y,-r.x)]}static units(t,i){let r=e.subtract(i,t);return[new e(-r.y,r.x).normalize(),new e(r.y,-r.x).normalize()]}static divide(t,i){return new e(t.x/i.x,t.y/i.y)}static divideScalar(t,i){return new e(t.x/i,t.y/i)}static dot(t,e){return t.x*e.x+t.y*e.y}static angle(t,i){let r=e.dot(t,i);return Math.acos(r/(t.length()*i.length()))}static threePointangle(t,i,r){let n=e.subtract(i,t),s=e.subtract(r,i),o=t.distance(i),h=i.distance(r);return Math.acos(e.dot(n,s)/(o*h))}static scalarProjection(t,i){let r=i.normalized();return e.dot(t,r)}static averageDirection(t){let i=new e(0,0);for(var r=0;r{const r=i(474),n=i(348),s=i(614);i(427);class o{constructor(t,e=0,i=0){this.id=null,this.value=t,this.position=new s(e||0,i||0),this.previousPosition=new s(0,0),this.parentVertexId=null,this.children=Array(),this.spanningTreeChildren=Array(),this.edges=Array(),this.positioned=!1,this.angle=null,this.dir=1,this.neighbourCount=0,this.neighbours=Array(),this.neighbouringElements=Array(),this.forcePositioned=!1}setPosition(t,e){this.position.x=t,this.position.y=e}setPositionFromVector(t){this.position.x=t.x,this.position.y=t.y}addChild(t){this.children.push(t),this.neighbours.push(t),this.neighbourCount++}addRingbondChild(t,e){if(this.children.push(t),this.value.bracket){let i=1;0===this.id&&0===this.value.bracket.hcount&&(i=0),1===this.value.bracket.hcount&&0===e&&(i=2),1===this.value.bracket.hcount&&1===e&&(i=this.neighbours.length<3?2:3),null===this.value.bracket.hcount&&0===e&&(i=1),null===this.value.bracket.hcount&&1===e&&(i=this.neighbours.length<3?1:2),this.neighbours.splice(i,0,t)}else this.neighbours.push(t);this.neighbourCount++}setParentVertexId(t){this.neighbourCount++,this.parentVertexId=t,this.neighbours.push(t)}isTerminal(){return!!this.value.hasAttachedPseudoElements||null===this.parentVertexId&&this.children.length<2||0===this.children.length}clone(){let t=new o(this.value,this.position.x,this.position.y);return t.id=this.id,t.previousPosition=new s(this.previousPosition.x,this.previousPosition.y),t.parentVertexId=this.parentVertexId,t.children=n.clone(this.children),t.spanningTreeChildren=n.clone(this.spanningTreeChildren),t.edges=n.clone(this.edges),t.positioned=this.positioned,t.angle=this.angle,t.forcePositioned=this.forcePositioned,t}equals(t){return this.id===t.id}getAngle(t=null,e=!1){let i=null;return i=t?s.subtract(this.position,t):s.subtract(this.position,this.previousPosition),e?r.toDeg(i.angle()):i.angle()}getTextDirection(t){let e=this.getDrawnNeighbours(t),i=Array();if(1===t.length)return"right";for(let r=0;r{var e=t&&t.__esModule?()=>t.default:()=>t;return i.d(e,{a:e}),e},i.d=(t,e)=>{for(var r in e)i.o(e,r)&&!i.o(t,r)&&Object.defineProperty(t,r,{enumerable:!0,get:e[r]})},i.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e),i.r=t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})};var r={};return(()=>{"use strict";i.r(r),i.d(r,{clean2d:()=>o});var t=i(237),e=i.n(t),n=i(19),s=i.n(n);function o(t){const i=new(e())({}),r=s().parse(t);i.initDraw(r,"light",!1),i.processGraph();let n=i.graph.vertices,o=Array();for(let t=0;t Date: Wed, 18 Dec 2024 13:45:39 +0000 Subject: [PATCH 49/67] add copyright and license --- chython/algorithms/calculate2d/Calculate2d.py | 20 +++++++++++++++++++ chython/algorithms/calculate2d/KKLayout.py | 20 +++++++++++++++++++ chython/algorithms/calculate2d/MathHelper.py | 20 +++++++++++++++++++ chython/algorithms/calculate2d/Properties.py | 20 +++++++++++++++++++ chython/algorithms/calculate2d/__init__.py | 20 +++++++++++++++++++ 5 files changed, 100 insertions(+) diff --git a/chython/algorithms/calculate2d/Calculate2d.py b/chython/algorithms/calculate2d/Calculate2d.py index 54d776c0..d67bbd19 100644 --- a/chython/algorithms/calculate2d/Calculate2d.py +++ b/chython/algorithms/calculate2d/Calculate2d.py @@ -1,3 +1,23 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2024 Denis Lipatov +# Copyright 2024 Vyacheslav Grigorev +# Copyright 2024 Timur Gimadiev +# This file is part of chython. +# +# chython is free software; you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, see . +# """ Class for calculating the 2D layout of a molecular graph, returning the coordinates of atom vertices in a molecular container. diff --git a/chython/algorithms/calculate2d/KKLayout.py b/chython/algorithms/calculate2d/KKLayout.py index eb7ec6b3..a6f8b8e7 100644 --- a/chython/algorithms/calculate2d/KKLayout.py +++ b/chython/algorithms/calculate2d/KKLayout.py @@ -1,3 +1,23 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2024 Denis Lipatov +# Copyright 2024 Vyacheslav Grigorev +# Copyright 2024 Timur Gimadiev +# This file is part of chython. +# +# chython is free software; you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, see . +# """ Defines the KKLayout class, utilized for arranging molecular structures using the Kamada-Kawai algorithm. diff --git a/chython/algorithms/calculate2d/MathHelper.py b/chython/algorithms/calculate2d/MathHelper.py index 32f433ba..7e95ac03 100644 --- a/chython/algorithms/calculate2d/MathHelper.py +++ b/chython/algorithms/calculate2d/MathHelper.py @@ -1,3 +1,23 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2024 Denis Lipatov +# Copyright 2024 Vyacheslav Grigorev +# Copyright 2024 Timur Gimadiev +# This file is part of chython. +# +# chython is free software; you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, see . +# """ This module introduces the `Vector` and `Polygon` classes, designed to perform mathematical calculations relevant to two-dimensional Cartesian coordinate systems and regular polygons. diff --git a/chython/algorithms/calculate2d/Properties.py b/chython/algorithms/calculate2d/Properties.py index d8f16930..9ae9f31f 100644 --- a/chython/algorithms/calculate2d/Properties.py +++ b/chython/algorithms/calculate2d/Properties.py @@ -1,3 +1,23 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2024 Denis Lipatov +# Copyright 2024 Vyacheslav Grigorev +# Copyright 2024 Timur Gimadiev +# This file is part of chython. +# +# chython is free software; you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, see . +# """ This module defines classes that extend the properties of rings, atoms, and bonds within the Kaiton structure, focusing on attributes and methods necessary for coordinate calculations. diff --git a/chython/algorithms/calculate2d/__init__.py b/chython/algorithms/calculate2d/__init__.py index 9cb04b96..193ea427 100644 --- a/chython/algorithms/calculate2d/__init__.py +++ b/chython/algorithms/calculate2d/__init__.py @@ -1 +1,21 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2024 Denis Lipatov +# Copyright 2024 Vyacheslav Grigorev +# Copyright 2024 Timur Gimadiev +# This file is part of chython. +# +# chython is free software; you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, see . +# from .clean2d import Calculate2DMolecule, Calculate2DReaction \ No newline at end of file From f0ed48019258cfd9f1642f4e2f21e88d0f8c2941 Mon Sep 17 00:00:00 2001 From: denis lipatov Date: Mon, 23 Dec 2024 11:55:10 +0000 Subject: [PATCH 50/67] update feature cleand2d algorithm in python to branch V2 --- chython/algorithms/calculate2d/clean2d.py | 180 ++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 chython/algorithms/calculate2d/clean2d.py diff --git a/chython/algorithms/calculate2d/clean2d.py b/chython/algorithms/calculate2d/clean2d.py new file mode 100644 index 00000000..0fd77b72 --- /dev/null +++ b/chython/algorithms/calculate2d/clean2d.py @@ -0,0 +1,180 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2024 Denis Lipatov +# Copyright 2024 Vyacheslav Grigorev +# Copyright 2024 Timur Gimadiev +# This file is part of chython. +# +# chython is free software; you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, see . +# +from typing import TYPE_CHECKING, Union, List +from .Calculate2d import Calculate2d +from math import sqrt + +if TYPE_CHECKING: + from ...containers import ReactionContainer, MoleculeContainer + +try: + from importlib.resources import files +except ImportError: # python3.8 + from importlib_resources import files + + +class Calculate2DMolecule: + __slots__ = () + + def clean2d(self: Union['MoleculeContainer', 'Calculate2DMolecule']): + """ + Calculate 2d layout of graph. https://pubs.acs.org/doi/10.1021/acs.jcim.7b00425 JS implementation used. + """ + plane = {} + entry = iter(sorted(self, key=lambda n: len(self._bonds[n]))) + smiles, order = self.__clean2d_prepare(next(entry)) + + obj = Calculate2d() + xy: List[List[float, float]] = obj._calculate2d_coord(order, self) + + + shift_x, shift_y = xy[0] + for n, (x, y) in zip(order, xy): + plane[n] = (x - shift_x, shift_y - y) + + bonds = [] + for n, m, _ in self.bonds(): + xn, yn = plane[n] + xm, ym = plane[m] + bonds.append(sqrt((xm - xn) ** 2 + (ym - yn) ** 2)) + if bonds: + bond_reduce = sum(bonds) / len(bonds) / .825 + else: + bond_reduce = 1. + + atoms = self._atoms + for n, (x, y) in plane.items(): + a = atoms[n] + a._x = x / bond_reduce + a._y = y / bond_reduce + + if self.connected_components_count > 1: + shift_x = 0. + for c in self.connected_components: + shift_x = self._fix_plane_mean(shift_x, component=c) + .9 + self.__dict__.pop('__cached_method__repr_svg_', None) + + def _fix_plane_mean(self: 'MoleculeContainer', shift_x: float, shift_y=0., component=None) -> float: + plane = self._plane + if component is None: + component = plane + + left_atom = min(component, key=lambda x: plane[x][0]) + right_atom = max(component, key=lambda x: plane[x][0]) + + min_x = plane[left_atom][0] - shift_x + if len(self._atoms[left_atom].atomic_symbol) == 2: + min_x -= 0.2 + + max_x = plane[right_atom][0] - min_x + min_y = min(plane[x][1] for x in component) + max_y = max(plane[x][1] for x in component) + mean_y = (max_y + min_y) / 2 - shift_y + for n in component: + x, y = plane[n] + plane[n] = (x - min_x, y - mean_y) + + if -0.18 <= plane[right_atom][1] <= 0.18: + factor = self._hydrogens[right_atom] + if factor == 1: + max_x += 0.15 + elif factor: + max_x += 0.25 + return max_x + + def _fix_plane_min(self: 'MoleculeContainer', shift_x: float, shift_y=0., component=None) -> float: + plane = self._plane + if component is None: + component = plane + + right_atom = max(component, key=lambda x: plane[x][0]) + min_x = min(plane[x][0] for x in component) - shift_x + max_x = plane[right_atom][0] - min_x + min_y = min(plane[x][1] for x in component) - shift_y + + for n in component: + x, y = plane[n] + plane[n] = (x - min_x, y - min_y) + + if shift_y - 0.18 <= plane[right_atom][1] <= shift_y + 0.18: + factor = self._hydrogens[right_atom] + if factor == 1: + max_x += 0.15 + elif factor: + max_x += 0.25 + return max_x + + + def __clean2d_prepare(self: 'MoleculeContainer', entry): + w = {n: i for i, n in enumerate(self._atoms)} + w[entry] = -1 + smiles, order = self._smiles(w.__getitem__, random=True, charges=False, stereo=False, _return_order=True) + return ''.join(smiles).replace('~', '-'), order + +class Calculate2DReaction: + __slots__ = () + + def clean2d(self: 'ReactionContainer'): + for m in self.molecules(): + m.clean2d() + self.fix_positions() + + def fix_positions(self: 'ReactionContainer'): + shift_x = 0 + reactants = self.reactants + amount = len(reactants) - 1 + signs = [] + for m in reactants: + max_x = m._fix_plane_mean(shift_x) + if amount: + max_x += .2 + signs.append(max_x) + amount -= 1 + shift_x = max_x + 1 + arrow_min = shift_x + + if self.reagents: + shift_x += .4 + for m in self.reagents: + max_x = m._fix_plane_min(shift_x, .5) + shift_x = max_x + 1 + shift_x += .4 + if shift_x - arrow_min < 3: + shift_x = arrow_min + 3 + else: + shift_x += 3 + arrow_max = shift_x - 1 + + products = self.products + amount = len(products) - 1 + for m in products: + max_x = m._fix_plane_mean(shift_x) + if amount: + max_x += .2 + signs.append(max_x) + amount -= 1 + shift_x = max_x + 1 + self._arrow = (arrow_min, arrow_max) + self._signs = tuple(signs) + self.flush_cache() + + +__all__ = ['Calculate2DMolecule', 'Calculate2DReaction'] \ No newline at end of file From e31fe418dd9796fd34397542b7b9408a3db99f5e Mon Sep 17 00:00:00 2001 From: denis lipatov Date: Mon, 23 Dec 2024 11:56:38 +0000 Subject: [PATCH 51/67] fix readme to for the new version of clean2d on python --- README.rst | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.rst b/README.rst index bbe8d4e8..94262b2a 100644 --- a/README.rst +++ b/README.rst @@ -13,7 +13,7 @@ Features: - Produce template based reactions and molecules - Atom-to-atom mapping, checking and rule-based fixing - Perform MCS search - - 2d coordinates generation (based on `SmilesDrawer `_) + - 2d coordinates generation - 2d/3d depiction with Jupyter support - SMARTS parser with restrictions - Protective groups remover @@ -31,8 +31,6 @@ Install Only python 3.8+. -Note: for using `clean2d` install NodeJS into system. - * **stable version available through PyPI**:: pip install chython From 8e6b9a78fd0d38f0d3e93a5d5bee55a7e2cd3e2d Mon Sep 17 00:00:00 2001 From: stsouko Date: Mon, 23 Dec 2024 14:53:58 +0100 Subject: [PATCH 52/67] packing reimplemented unpacking WIP --- chython/algorithms/stereo.py | 2 +- chython/containers/_pack.pyx | 69 +++++++----- chython/containers/_unpack.pyx | 194 ++++++++++++++------------------- chython/containers/molecule.py | 33 +----- 4 files changed, 126 insertions(+), 172 deletions(-) diff --git a/chython/algorithms/stereo.py b/chython/algorithms/stereo.py index 80f87049..6cd814a2 100644 --- a/chython/algorithms/stereo.py +++ b/chython/algorithms/stereo.py @@ -197,7 +197,7 @@ def cumulenes(self: 'MoleculeContainer') -> List[Tuple[int, ...]]: terminals = [x for x, y in adj.items() if len(y) == 1] # list to keep atoms order! cumulenes = [] while terminals: - n = terminals.pop() + n = terminals.pop(0) m = adj[n].pop() path = [n, m] while m not in terminals: diff --git a/chython/containers/_pack.pyx b/chython/containers/_pack.pyx index fe024654..37b42b34 100644 --- a/chython/containers/_pack.pyx +++ b/chython/containers/_pack.pyx @@ -20,7 +20,7 @@ cimport cython from cpython.mem cimport PyMem_Malloc, PyMem_Free from libc.math cimport ldexp, frexp -# Format specification:: +# Format V2 specification:: # # Big endian bytes order # 8 bit - 0x02 (current format specification) @@ -48,6 +48,25 @@ from libc.math cimport ldexp, frexp # 7 bit - zero padding. in future can be used for extra bond-level stereo, like atropoisomers. # 1 bit - sign +# stereo block: +# 0000 - no stereo +# 0001 - not in use +# 0010 - allene +# 0011 - allene +# 0100 - not in use +# 0101 - not in use +# 0110 - not in use +# 0111 - not in use +# 1000 - tetrahedron +# 1001 - not in use +# 1010 - not in use +# 1011 - not in use +# 1100 - tetrahedron +# 1101 - not in use +# 1110 - not in use +# 1111 - not in use + + @cython.nonecheck(False) @cython.boundscheck(False) @cython.cdivision(True) @@ -57,18 +76,19 @@ def pack(object molecule): cdef char charge cdef unsigned char atomic_number, ngb_count, isotope, bond, s = 0, buffer_b, buffer_o, stereo, hcr cdef unsigned char *data - cdef unsigned short atoms_count, bonds_count = 0, cis_trans_count, n, m + cdef unsigned short atoms_count, bonds_count = 0, cis_trans_count, n, m, tn, tm cdef unsigned int size, atoms_shift = 4, bonds_shift, order_shift, cis_trans_shift # can be > 2^16 - cdef unsigned char[4096] seen + cdef unsigned char[4096] seen # atom number is 12 bit, thus, can be any value up to 4095. numbers are not continuous cdef bytes py_pack - cdef dict py_ngb, py_atoms, py_bonds + cdef dict py_ngb, py_atoms, py_bonds, py_stereo cdef tuple py_tuple cdef object py_atom, py_bond, py_nan_int, py_obj # map molecule to vars py_atoms = molecule._atoms py_bonds = molecule._bonds + py_stereo = molecule._stereo_cis_trans_terminals # calculate elements count atoms_count = len(py_atoms) @@ -94,6 +114,8 @@ def pack(object molecule): if not data: raise MemoryError() + seen[:] = 0 # erase random data + # start pack collection data[0] = 2 # header. specification version 2 data[1] = atoms_count >> 4 # 5-12b of atom count value @@ -119,12 +141,12 @@ def pack(object molecule): # V2 specification # 2 bit tetrahedron | 2 bit allene | 0000 elif py_nan_int: - if ngb_count == 2: + if ngb_count == 2: # allene stereo = 0x30 else: stereo = 0xc0 else: - if ngb_count == 2: + if ngb_count == 2: # allene stereo = 0x20 else: stereo = 0x80 @@ -149,19 +171,8 @@ def pack(object molecule): data[atoms_shift + 3] = isotope << 7 | atomic_number # 1bI , A # 2 float16 big endian - for n, py_tuple in py_plane.items(): - p = &xy[n] - double_to_float16(py_tuple[0], &p[0]) - double_to_float16(py_tuple[1], &p[2]) - - # erase random data - seen[n] = 0 - stereo[n] = 0 - - data[atoms_shift + 4] = p[0] - data[atoms_shift + 5] = p[1] - data[atoms_shift + 6] = p[2] - data[atoms_shift + 7] = p[3] + double_to_float16(py_atom._x, &data[atoms_shift + 4]) + double_to_float16(py_atom._y, &data[atoms_shift + 6]) data[atoms_shift + 8] = hcr atoms_shift += 9 @@ -181,7 +192,7 @@ def pack(object molecule): b = True if not seen[m]: - bond = py_bond._Bond__order - 1 + bond = py_bond._order - 1 # 3 3 2 | 1 3 3 1 | 2 3 3 if s == 0: buffer_o = bond << 5 @@ -213,17 +224,19 @@ def pack(object molecule): order_shift += 1 s = 0 + py_nan_int = py_bond._stereo + if py_nan_int is not None: + py_tuple = py_stereo[py_obj] + tn, tm = py_tuple + data[cis_trans_shift] = tn >> 4 + data[cis_trans_shift + 1] = tn << 4 | tm >> 8 + data[cis_trans_shift + 2] = tm + data[cis_trans_shift + 3] = py_nan_int + cis_trans_shift += 4 + if s: # flush buffer data[order_shift] = buffer_o - for py_tuple, b in py_cis_trans_stereo.items(): - n, m = py_tuple - data[cis_trans_shift] = n >> 4 - data[cis_trans_shift + 1] = n << 4 | m >> 8 - data[cis_trans_shift + 2] = m - data[cis_trans_shift + 3] = b - cis_trans_shift += 4 - try: py_pack = data[:size] finally: diff --git a/chython/containers/_unpack.pyx b/chython/containers/_unpack.pyx index 670f1f7b..aba7ca34 100644 --- a/chython/containers/_unpack.pyx +++ b/chython/containers/_unpack.pyx @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # cython: language_level=3 # -# Copyright 2021-2023 Ramil Nugmanov +# Copyright 2021-2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -21,7 +21,15 @@ cimport cython from cpython.mem cimport PyMem_Malloc, PyMem_Free from libc.math cimport ldexp +from chython.containers import MoleculeContainer from chython.containers.bonds import Bond +from chython.periodictable import (H, He, Li, Be, B, C, N, O, F, Ne, Na, Mg, Al, Si, P, S, Cl, Ar, K, Ca, Sc, Ti, V, Cr, + Mn, Fe, Co, Ni, Cu, Zn, Ga, Ge, As, Se, Br, Kr, Rb, Sr, Y, Zr, Nb, Mo, Tc, Ru, Rh, + Pd, Ag, Cd, In, Sn, Sb, Te, I, Xe, Cs, Ba, La, Ce, Pr, Nd, Pm, Sm, Eu, Gd, Tb, Dy, + Ho, Er, Tm, Yb, Lu, Hf, Ta, W, Re, Os, Ir, Pt, Au, Hg, Tl, Pb, Bi, Po, At, Rn, Fr, + Ra, Ac, Th, Pa, U, Np, Pu, Am, Cm, Bk, Cf, Es, Fm, Md, No, Lr, Rf, Db, Sg, Bh, Hs, + Mt, Ds, Rg, Cn, Nh, Fl, Mc, Lv, Ts, Og) + # Format specification:: # @@ -57,20 +65,17 @@ from chython.containers.bonds import Bond @cython.wraparound(False) def unpack(const unsigned char[::1] data not None): cdef char *charges - cdef unsigned char a, b, c, d, isotope, atomic_number, neighbors_count, s = 0, nc, version - cdef unsigned char *atoms, *hydrogens, *neighbors, *orders, *is_tet, *is_all - cdef bint *stereo_sign, *ct_sign, *radicals + cdef unsigned char a, b, c, d, isotope, atomic_number, neighbors_count, s = 0, version, stereo, hydrogens + cdef unsigned char *neighbors, *orders + cdef bint *ct_sign cdef unsigned short atoms_count, bonds_count = 0, cis_trans_count, order_count cdef unsigned short i, j, k = 0, n, m, buffer_b, shift = 0 - cdef unsigned short *mapping, *isotopes, *cis_trans_1, *cis_trans_2, *connections + cdef unsigned short *mapping, *cis_trans_1, *cis_trans_2, *connections cdef unsigned int size, atoms_shift = 4, bonds_shift, order_shift, cis_trans_shift - cdef double *x_coord, *y_coord cdef unsigned char[4096] seen - cdef object bond, py_n, py_m - cdef dict py_charges, py_radicals, py_hydrogens, py_plane, py_bonds, py_ngb - cdef dict py_atoms_stereo, py_allenes_stereo, py_cis_trans_stereo - cdef list py_mapping, py_atoms, py_isotopes, py_bonds_flat + cdef object py_mol, py_bond, py_n, py_m, py_atom, py_nan_bool + cdef dict py_atoms, py_bonds, py_ngb # read header version = data[0] @@ -79,24 +84,16 @@ def unpack(const unsigned char[::1] data not None): cis_trans_count = (b & 0x0f) << 8 | c # allocate memory - charges = PyMem_Malloc(atoms_count * sizeof(char)) - radicals = PyMem_Malloc(atoms_count * sizeof(bint)) - atoms = PyMem_Malloc(atoms_count * sizeof(unsigned char)) - hydrogens = PyMem_Malloc(atoms_count * sizeof(unsigned char)) neighbors = PyMem_Malloc(atoms_count * sizeof(unsigned char)) - is_tet = PyMem_Malloc(atoms_count * sizeof(unsigned char)) - is_all = PyMem_Malloc(atoms_count * sizeof(unsigned char)) - stereo_sign = PyMem_Malloc(atoms_count * sizeof(bint)) mapping = PyMem_Malloc(atoms_count * sizeof(unsigned short)) - isotopes = PyMem_Malloc(atoms_count * sizeof(unsigned short)) - x_coord = PyMem_Malloc(atoms_count * sizeof(double)) - y_coord = PyMem_Malloc(atoms_count * sizeof(double)) - if not charges or not radicals or not atoms or not hydrogens or not neighbors or not is_tet or not is_all: - raise MemoryError() - if not stereo_sign or not mapping or not isotopes or not x_coord or not y_coord: + if not neighbors or not mapping: raise MemoryError() + py_mol = MoleculeContainer() + py_atoms = py_mol._atoms + py_bonds = py_mol._bonds + # unpack atom block to separate attributes arrays for i in range(atoms_count): a, b = data[atoms_shift], data[atoms_shift + 1] @@ -106,34 +103,47 @@ def unpack(const unsigned char[::1] data not None): bonds_count += neighbors_count a, b = data[atoms_shift + 2], data[atoms_shift + 3] - if a >> 7: # tetrahedron bit set - is_tet[i] = 1 - is_all[i] = 0 - stereo_sign[i] = a & 0x40 # mask th bit - else: - is_tet[i] = 0 - if a >> 5: # allene bit set - is_all[i] = 1 - stereo_sign[i] = a & 0x10 # mask al bit - else: - is_all[i] = 0 - - atoms[i] = atomic_number = b & 0x7f + stereo = a >> 4 + if stereo == 0: + py_nan_bool = None + elif stereo == 0b0010: + py_nan_bool = False + elif stereo == 0b0011: + py_nan_bool = True + elif stereo == 0b1000: + py_nan_bool = False + else: # if stereo == 0b1100: + py_nan_bool = True + + atomic_number = b & 0x7f + py_atom = object.__new__(elements[atomic_number]) + py_atoms[n] = py_atom + + py_atom._stereo = py_nan_bool + isotope = (a & 0x0f) << 1 | b >> 7 if isotope: - isotopes[i] = common_isotopes[atomic_number] + isotope + py_atom._isotope = common_isotopes[atomic_number] + isotope else: - isotopes[i] = 0 + py_atom._isotope = None a, b = data[atoms_shift + 4], data[atoms_shift + 5] - x_coord[i] = double_from_bytes(a, b) + py_atom._x = double_from_bytes(a, b) a, b = data[atoms_shift + 6], data[atoms_shift + 7] - y_coord[i] = double_from_bytes(a, b) + py_atom._y = double_from_bytes(a, b) a = data[atoms_shift + 8] - hydrogens[i] = a >> 5 - charges[i] = ((a >> 1) & 0x0f) - 4 - radicals[i] = a & 0x01 + hydrogens = a >> 5 + if hydrogens == 7: + py_atom._hydrogens = None + else: + py_atom._hydrogens = hydrogens + + py_atom._charge = ((a >> 1) & 0x0f) - 4 + if a & 0x01: + py_atom._is_radical = True + else: + py_atom._is_radical = False atoms_shift += 9 # calculate bonds count and pack sections @@ -145,7 +155,7 @@ def unpack(const unsigned char[::1] data not None): order_count = order_count / 8 + 1 else: order_count /= 8 - elif version == 0: + else: # if version == 0: order_count = bonds_count / 5 if bonds_count % 5: order_count += 1 @@ -193,7 +203,7 @@ def unpack(const unsigned char[::1] data not None): buffer_b = (a & 0x3) << 1 s = 1 i += 2 - elif version == 0: + else: # if version == 0: for j in range(order_shift, cis_trans_shift, 2): # 0 3 3 1 | 2 3 3 a, b = data[j], data[j + 1] @@ -219,77 +229,31 @@ def unpack(const unsigned char[::1] data not None): ct_sign[i] = d # d = 0x01 or 0x00 cis_trans_shift += 4 - # define returned data - py_mapping = [] - py_atoms = [] - py_isotopes = [] - py_charges = {} - py_radicals = {} - py_hydrogens = {} - py_plane = {} - py_atoms_stereo = {} - py_allenes_stereo = {} - py_cis_trans_stereo = {} - py_bonds = {} - py_bonds_flat = [] + for i in range(atoms_count): + n = mapping[i] + py_n = n # shared py int obj - for i in range(atoms_count): - n = mapping[i] - py_n = n # shared py int obj - - # fill intermediate data - py_mapping.append(py_n) - py_atoms.append(atoms[i]) - py_isotopes.append(isotopes[i] or None) - - py_charges[py_n] = charges[i] - py_radicals[py_n] = radicals[i] - if hydrogens[i] == 7: - py_hydrogens[py_n] = None - else: - py_hydrogens[py_n] = hydrogens[i] - - py_plane[py_n] = (x_coord[i], y_coord[i]) - - if is_tet[i]: - py_atoms_stereo[py_n] = stereo_sign[i] - elif is_all[i]: - py_allenes_stereo[py_n] = stereo_sign[i] - - py_bonds[py_n] = py_ngb = {} - seen[n] = 1 - - nc = neighbors[i] - for j in range(shift, shift + nc): - m = connections[j] - py_m = m - if seen[m]: # bond partially exists. need back-connection. - py_ngb[py_m] = py_bonds[py_m][py_n] - else: - bond = object.__new__(Bond) - bond._Bond__order = orders[k] + 1 - bond._Bond__n = py_n - bond._Bond__m = py_m - py_ngb[py_m] = bond - py_bonds_flat.append(bond) - k += 1 - shift += nc + py_bonds[py_n] = py_ngb = {} + seen[n] = 1 + + neighbors_count = neighbors[i] + for j in range(shift, shift + neighbors_count): + m = connections[j] + py_m = m + if seen[m]: # bond partially exists. need back-connection. + py_ngb[py_m] = py_bonds[py_m][py_n] + else: + bond = object.__new__(Bond) + bond._order = orders[k] + 1 + py_ngb[py_m] = bond + k += 1 + shift += neighbors_count for i in range(cis_trans_count): py_cis_trans_stereo[(cis_trans_1[i], cis_trans_2[i])] = ct_sign[i] - PyMem_Free(charges) - PyMem_Free(radicals) - PyMem_Free(atoms) - PyMem_Free(hydrogens) PyMem_Free(neighbors) - PyMem_Free(is_tet) - PyMem_Free(is_all) - PyMem_Free(stereo_sign) PyMem_Free(mapping) - PyMem_Free(isotopes) - PyMem_Free(x_coord) - PyMem_Free(y_coord) if bonds_count: PyMem_Free(connections) PyMem_Free(orders) @@ -297,9 +261,7 @@ def unpack(const unsigned char[::1] data not None): PyMem_Free(cis_trans_1) PyMem_Free(cis_trans_2) PyMem_Free(ct_sign) - return (py_mapping, py_atoms, py_isotopes, - py_charges, py_radicals, py_hydrogens, py_plane, py_bonds, - py_atoms_stereo, py_allenes_stereo, py_cis_trans_stereo, size, py_bonds_flat) + return py_mol, size cdef short[119] common_isotopes @@ -312,6 +274,14 @@ common_isotopes[:] = [0, -15, -12, -9, -7, -5, -4, -2, 0, 3, 4, 7, 8, 11, 12, 15 254, 262, 265, 265, 269, 262, 273, 273, 277, 281, 278] +cdef object[119] elements +elements[:] = [None, H, He, Li, Be, B, C, N, O, F, Ne, Na, Mg, Al, Si, P, S, Cl, Ar, K, Ca, Sc, Ti, V, Cr, Mn, Fe, Co, + Ni, Cu, Zn, Ga, Ge, As, Se, Br, Kr, Rb, Sr, Y, Zr, Nb, Mo, Tc, Ru, Rh, Pd, Ag, Cd, In, Sn, Sb, Te, I, Xe, + Cs, Ba, La, Ce, Pr, Nd, Pm, Sm, Eu, Gd, Tb, Dy, Ho, Er, Tm, Yb, Lu, Hf, Ta, W, Re, Os, Ir, Pt, Au, Hg, + Tl, Pb, Bi, Po, At, Rn, Fr, Ra, Ac, Th, Pa, U, Np, Pu, Am, Cm, Bk, Cf, Es, Fm, Md, No, Lr, Rf, Db, Sg, + Bh, Hs, Mt, Ds, Rg, Cn, Nh, Fl, Mc, Lv, Ts, Og] + + cdef double double_from_bytes(unsigned char a, unsigned char b): cdef bint sign cdef int e diff --git a/chython/containers/molecule.py b/chython/containers/molecule.py index 984f845c..16cabc46 100644 --- a/chython/containers/molecule.py +++ b/chython/containers/molecule.py @@ -555,36 +555,12 @@ def unpack(cls, data: Union[bytes, memoryview], /, *, compressed=True, if compressed: data = decompress(data) if data[0] in (0, 2): - (mapping, atom_numbers, isotopes, charges, radicals, hydrogens, plane, bonds, - atoms_stereo, allenes_stereo, cis_trans_stereo, pack_length, bonds_flat) = unpack(data) + mol, pack_length = unpack(data) elif data[0] == 3: - (mapping, atom_numbers, isotopes, charges, radicals, hydrogens, plane, bonds, - atoms_stereo, allenes_stereo, cis_trans_stereo, pack_length, bonds_flat) = cpack(data) + mol, pack_length = cpack(data) else: raise ValueError('invalid pack header') - mol = object.__new__(cls) - mol._bonds = bonds - mol._plane = plane - mol._charges = charges - mol._radicals = radicals - mol._hydrogens = hydrogens - mol._atoms_stereo = atoms_stereo - mol._allenes_stereo = allenes_stereo - mol._cis_trans_stereo = cis_trans_stereo - - mol._MoleculeContainer__meta = None - mol._MoleculeContainer__name = None - mol._atoms = atoms = {} - - for n, a, i in zip(mapping, atom_numbers, isotopes): - atoms[n] = a = object.__new__(Element.from_atomic_number(a)) - a._Core__isotope = i - a._graph = ref(mol) - a._n = n - for b in bonds_flat: - b._Bond__graph = ref(mol) - if _return_pack_length: return mol, pack_length return mol @@ -610,11 +586,6 @@ def _cpack(self, order=None, check=True): atoms = self._atoms bonds = self._bonds - charges = self._charges - radicals = self._radicals - hydrogens = self._hydrogens - atoms_stereo = self._atoms_stereo - allenes_stereo = self._allenes_stereo allenes_terminals = self._stereo_allenes_terminals cumulenes = {} From 4ab39841563c5832fc91b61ce8f772665f49bd26 Mon Sep 17 00:00:00 2001 From: stsouko Date: Mon, 23 Dec 2024 18:11:08 +0100 Subject: [PATCH 53/67] unpacking reimplemented --- chython/containers/_pack.pyx | 3 +- chython/containers/_unpack.pyx | 70 +++++++++++++++------------------- chython/containers/molecule.py | 11 ++++-- 3 files changed, 40 insertions(+), 44 deletions(-) diff --git a/chython/containers/_pack.pyx b/chython/containers/_pack.pyx index 37b42b34..30ccd1bc 100644 --- a/chython/containers/_pack.pyx +++ b/chython/containers/_pack.pyx @@ -19,6 +19,7 @@ cimport cython from cpython.mem cimport PyMem_Malloc, PyMem_Free from libc.math cimport ldexp, frexp +from libc.string cimport memset # Format V2 specification:: # @@ -114,7 +115,7 @@ def pack(object molecule): if not data: raise MemoryError() - seen[:] = 0 # erase random data + memset(seen, 0, 4096 * sizeof(unsigned char)) # erase random data # start pack collection data[0] = 2 # header. specification version 2 diff --git a/chython/containers/_unpack.pyx b/chython/containers/_unpack.pyx index aba7ca34..80ab6c59 100644 --- a/chython/containers/_unpack.pyx +++ b/chython/containers/_unpack.pyx @@ -64,18 +64,17 @@ from chython.periodictable import (H, He, Li, Be, B, C, N, O, F, Ne, Na, Mg, Al, @cython.cdivision(True) @cython.wraparound(False) def unpack(const unsigned char[::1] data not None): - cdef char *charges cdef unsigned char a, b, c, d, isotope, atomic_number, neighbors_count, s = 0, version, stereo, hydrogens cdef unsigned char *neighbors, *orders - cdef bint *ct_sign cdef unsigned short atoms_count, bonds_count = 0, cis_trans_count, order_count cdef unsigned short i, j, k = 0, n, m, buffer_b, shift = 0 - cdef unsigned short *mapping, *cis_trans_1, *cis_trans_2, *connections + cdef unsigned short *mapping, *connections cdef unsigned int size, atoms_shift = 4, bonds_shift, order_shift, cis_trans_shift cdef unsigned char[4096] seen cdef object py_mol, py_bond, py_n, py_m, py_atom, py_nan_bool cdef dict py_atoms, py_bonds, py_ngb + cdef list py_cis_trans # read header version = data[0] @@ -93,6 +92,7 @@ def unpack(const unsigned char[::1] data not None): py_mol = MoleculeContainer() py_atoms = py_mol._atoms py_bonds = py_mol._bonds + py_cis_trans = [] # unpack atom block to separate attributes arrays for i in range(atoms_count): @@ -135,9 +135,9 @@ def unpack(const unsigned char[::1] data not None): a = data[atoms_shift + 8] hydrogens = a >> 5 if hydrogens == 7: - py_atom._hydrogens = None + py_atom._implicit_hydrogens = None else: - py_atom._hydrogens = hydrogens + py_atom._implicit_hydrogens = hydrogens py_atom._charge = ((a >> 1) & 0x0f) - 4 if a & 0x01: @@ -214,21 +214,6 @@ def unpack(const unsigned char[::1] data not None): orders[i + 4] = b & 0x7 i += 5 - if cis_trans_count: - cis_trans_1 = PyMem_Malloc(cis_trans_count * sizeof(unsigned short)) - cis_trans_2 = PyMem_Malloc(cis_trans_count * sizeof(unsigned short)) - ct_sign = PyMem_Malloc(cis_trans_count * sizeof(bint)) - if not cis_trans_1 or not cis_trans_2 or not ct_sign: - raise MemoryError() - - for i in range(cis_trans_count): - a, b = data[cis_trans_shift], data[cis_trans_shift + 1] - c, d = data[cis_trans_shift + 2], data[cis_trans_shift + 3] - cis_trans_1[i] = a << 4 | b >> 4 - cis_trans_2[i] = (b & 0x0f) << 8 | c - ct_sign[i] = d # d = 0x01 or 0x00 - cis_trans_shift += 4 - for i in range(atoms_count): n = mapping[i] py_n = n # shared py int obj @@ -243,25 +228,31 @@ def unpack(const unsigned char[::1] data not None): if seen[m]: # bond partially exists. need back-connection. py_ngb[py_m] = py_bonds[py_m][py_n] else: - bond = object.__new__(Bond) - bond._order = orders[k] + 1 - py_ngb[py_m] = bond + py_bond = object.__new__(Bond) + py_bond._order = orders[k] + 1 + py_bond._stereo = None + py_ngb[py_m] = py_bond k += 1 shift += neighbors_count - for i in range(cis_trans_count): - py_cis_trans_stereo[(cis_trans_1[i], cis_trans_2[i])] = ct_sign[i] + PyMem_Free(orders) + PyMem_Free(connections) + + if cis_trans_count: + for i in range(cis_trans_count): + a, b = data[cis_trans_shift], data[cis_trans_shift + 1] + c, d = data[cis_trans_shift + 2], data[cis_trans_shift + 3] + py_n = a << 4 | b >> 4 + py_m = (b & 0x0f) << 8 | c + if d: + py_cis_trans.append((py_n, py_m, True)) + else: + py_cis_trans.append((py_n, py_m, False)) + cis_trans_shift += 4 PyMem_Free(neighbors) PyMem_Free(mapping) - if bonds_count: - PyMem_Free(connections) - PyMem_Free(orders) - if cis_trans_count: - PyMem_Free(cis_trans_1) - PyMem_Free(cis_trans_2) - PyMem_Free(ct_sign) - return py_mol, size + return py_mol, py_cis_trans, size cdef short[119] common_isotopes @@ -273,13 +264,12 @@ common_isotopes[:] = [0, -15, -12, -9, -7, -5, -4, -2, 0, 3, 4, 7, 8, 11, 12, 15 222, 221, 228, 227, 231, 231, 235, 236, 241, 242, 243, 244, 245, 254, 253, 254, 254, 262, 265, 265, 269, 262, 273, 273, 277, 281, 278] - -cdef object[119] elements -elements[:] = [None, H, He, Li, Be, B, C, N, O, F, Ne, Na, Mg, Al, Si, P, S, Cl, Ar, K, Ca, Sc, Ti, V, Cr, Mn, Fe, Co, - Ni, Cu, Zn, Ga, Ge, As, Se, Br, Kr, Rb, Sr, Y, Zr, Nb, Mo, Tc, Ru, Rh, Pd, Ag, Cd, In, Sn, Sb, Te, I, Xe, - Cs, Ba, La, Ce, Pr, Nd, Pm, Sm, Eu, Gd, Tb, Dy, Ho, Er, Tm, Yb, Lu, Hf, Ta, W, Re, Os, Ir, Pt, Au, Hg, - Tl, Pb, Bi, Po, At, Rn, Fr, Ra, Ac, Th, Pa, U, Np, Pu, Am, Cm, Bk, Cf, Es, Fm, Md, No, Lr, Rf, Db, Sg, - Bh, Hs, Mt, Ds, Rg, Cn, Nh, Fl, Mc, Lv, Ts, Og] +cdef list elements +elements = [None, H, He, Li, Be, B, C, N, O, F, Ne, Na, Mg, Al, Si, P, S, Cl, Ar, K, Ca, Sc, Ti, V, Cr, Mn, Fe, Co, + Ni, Cu, Zn, Ga, Ge, As, Se, Br, Kr, Rb, Sr, Y, Zr, Nb, Mo, Tc, Ru, Rh, Pd, Ag, Cd, In, Sn, Sb, Te, I, Xe, + Cs, Ba, La, Ce, Pr, Nd, Pm, Sm, Eu, Gd, Tb, Dy, Ho, Er, Tm, Yb, Lu, Hf, Ta, W, Re, Os, Ir, Pt, Au, Hg, + Tl, Pb, Bi, Po, At, Rn, Fr, Ra, Ac, Th, Pa, U, Np, Pu, Am, Cm, Bk, Cf, Es, Fm, Md, No, Lr, Rf, Db, Sg, + Bh, Hs, Mt, Ds, Rg, Cn, Nh, Fl, Mc, Lv, Ts, Og] cdef double double_from_bytes(unsigned char a, unsigned char b): diff --git a/chython/containers/molecule.py b/chython/containers/molecule.py index 16cabc46..1f607829 100644 --- a/chython/containers/molecule.py +++ b/chython/containers/molecule.py @@ -542,7 +542,7 @@ def pack_len(cls, data: bytes, /, *, compressed=True) -> int: return int.from_bytes(data[1:3], 'big') >> 4 @classmethod - def unpack(cls, data: Union[bytes, memoryview], /, *, compressed=True, + def unpack(cls, data: Union[bytes, memoryview], /, *, compressed=True, skip_labels_calculation=False, _return_pack_length=False) -> 'MoleculeContainer': """ Unpack from compressed bytes. @@ -555,12 +555,17 @@ def unpack(cls, data: Union[bytes, memoryview], /, *, compressed=True, if compressed: data = decompress(data) if data[0] in (0, 2): - mol, pack_length = unpack(data) + mol, cis_trans, pack_length = unpack(data) + for n, m, s in cis_trans: + mol.bond(*mol._stereo_cis_trans_centers[n])._stereo = s elif data[0] == 3: - mol, pack_length = cpack(data) + mol, cis_trans, pack_length = cpack(data) else: raise ValueError('invalid pack header') + if not skip_labels_calculation: + mol.calc_labels() + if _return_pack_length: return mol, pack_length return mol From 24ce4ece6523d381e10f3eb22e84589163050431 Mon Sep 17 00:00:00 2001 From: stsouko Date: Tue, 24 Dec 2024 13:41:41 +0100 Subject: [PATCH 54/67] modules structure refactored --- build.py | 14 +++++++------- chython/containers/{_pack.pyx => _pack_v2.pyx} | 18 ------------------ .../{_unpack.pyx => _unpack_v0v2.pyx} | 0 .../containers/{_cpack.pyx => _unpack_v3.pyx} | 0 chython/containers/molecule.py | 16 ++++++++-------- 5 files changed, 15 insertions(+), 33 deletions(-) rename chython/containers/{_pack.pyx => _pack_v2.pyx} (96%) rename chython/containers/{_unpack.pyx => _unpack_v0v2.pyx} (100%) rename chython/containers/{_cpack.pyx => _unpack_v3.pyx} (100%) diff --git a/build.py b/build.py index f43339df..7f484611 100644 --- a/build.py +++ b/build.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2023 Ramil Nugmanov +# Copyright 2023, 2024 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -48,14 +48,14 @@ Extension('chython.algorithms._isomorphism', ['chython/algorithms/_isomorphism.pyx'], extra_compile_args=extra_compile_args), - Extension('chython.containers._pack', - ['chython/containers/_pack.pyx'], + Extension('chython.containers._pack_v2', + ['chython/containers/_pack_v2.pyx'], extra_compile_args=extra_compile_args), - Extension('chython.containers._unpack', - ['chython/containers/_unpack.pyx'], + Extension('chython.containers._unpack_v0v2', + ['chython/containers/_unpack_v0v2.pyx'], extra_compile_args=extra_compile_args), - Extension('chython.containers._cpack', - ['chython/containers/_cpack.pyx'], + Extension('chython.containers._unpack_v3', + ['chython/containers/_unpack_v3.pyx'], extra_compile_args=extra_compile_args), Extension('chython.files._xyz', ['chython/files/_xyz.pyx'], diff --git a/chython/containers/_pack.pyx b/chython/containers/_pack_v2.pyx similarity index 96% rename from chython/containers/_pack.pyx rename to chython/containers/_pack_v2.pyx index 30ccd1bc..6e2a8b19 100644 --- a/chython/containers/_pack.pyx +++ b/chython/containers/_pack_v2.pyx @@ -49,24 +49,6 @@ from libc.string cimport memset # 7 bit - zero padding. in future can be used for extra bond-level stereo, like atropoisomers. # 1 bit - sign -# stereo block: -# 0000 - no stereo -# 0001 - not in use -# 0010 - allene -# 0011 - allene -# 0100 - not in use -# 0101 - not in use -# 0110 - not in use -# 0111 - not in use -# 1000 - tetrahedron -# 1001 - not in use -# 1010 - not in use -# 1011 - not in use -# 1100 - tetrahedron -# 1101 - not in use -# 1110 - not in use -# 1111 - not in use - @cython.nonecheck(False) @cython.boundscheck(False) diff --git a/chython/containers/_unpack.pyx b/chython/containers/_unpack_v0v2.pyx similarity index 100% rename from chython/containers/_unpack.pyx rename to chython/containers/_unpack_v0v2.pyx diff --git a/chython/containers/_cpack.pyx b/chython/containers/_unpack_v3.pyx similarity index 100% rename from chython/containers/_cpack.pyx rename to chython/containers/_unpack_v3.pyx diff --git a/chython/containers/molecule.py b/chython/containers/molecule.py index 1f607829..ba8234a6 100644 --- a/chython/containers/molecule.py +++ b/chython/containers/molecule.py @@ -506,7 +506,7 @@ def pack(self, *, compressed=True, check=True, version=2, order: List[int] = Non :param version: format version :param order: atom order in V3 """ - from ._pack import pack + from ._pack_v2 import pack as pack_v2 if check: bonds = self._bonds @@ -518,9 +518,9 @@ def pack(self, *, compressed=True, check=True, version=2, order: List[int] = Non raise ValueError('To many neighbors not supported') if version == 2: - data = pack(self) + data = pack_v2(self) elif version == 3: - data = self._cpack(order, check) + data = self._pack_v3(order, check) else: raise ValueError('invalid specification version') if compressed: @@ -549,17 +549,17 @@ def unpack(cls, data: Union[bytes, memoryview], /, *, compressed=True, skip_labe :param compressed: decompress data before processing. """ - from ._unpack import unpack - from ._cpack import unpack as cpack + from ._unpack_v0v2 import unpack as unpack_v0v2 + from ._unpack_v3 import unpack as unpack_v3 if compressed: data = decompress(data) if data[0] in (0, 2): - mol, cis_trans, pack_length = unpack(data) + mol, cis_trans, pack_length = unpack_v0v2(data) for n, m, s in cis_trans: mol.bond(*mol._stereo_cis_trans_centers[n])._stereo = s elif data[0] == 3: - mol, cis_trans, pack_length = cpack(data) + mol, cis_trans, pack_length = unpack_v3(data) else: raise ValueError('invalid pack header') @@ -580,7 +580,7 @@ def unpach(cls, data: Union[bytes, memoryview], /, *, compressed=True) -> 'Molec def __bytes__(self): return self.pack() - def _cpack(self, order=None, check=True): + def _pack_v3(self, order=None, check=True): if order is None: order = list(self._atoms) elif check: From 7281fe81ebfb52499609c8a16c78f815eb2f6ba9 Mon Sep 17 00:00:00 2001 From: stsouko Date: Tue, 24 Dec 2024 14:03:14 +0100 Subject: [PATCH 55/67] V3 specification under change --- chython/containers/molecule.py | 117 ++++++++++++++++++--------------- 1 file changed, 65 insertions(+), 52 deletions(-) diff --git a/chython/containers/molecule.py b/chython/containers/molecule.py index ba8234a6..695852b7 100644 --- a/chython/containers/molecule.py +++ b/chython/containers/molecule.py @@ -20,7 +20,6 @@ from collections import Counter, defaultdict from functools import cached_property from typing import Dict, Iterable, List, Optional, Tuple, Union -from weakref import ref from zlib import compress, decompress from .bonds import Bond, DynamicBond, QueryBond from .cgr import CGRContainer @@ -476,31 +475,6 @@ def pack(self, *, compressed=True, check=True, version=2, order: List[int] = Non 7 bit - zero padding. in future can be used for extra bond-level stereo, like atropoisomers. 1 bit - sign - Format V3 specification:: - - Big endian bytes order - 8 bit - 0x03 (format specification version) - Atom block 3 bytes (repeated): - 1 bit - atom entrance flag (always 1) - 7 bit - atomic number (<=118) - 3 bit - hydrogens (0-7). Note: 7 == None - 4 bit - charge (charge + 4. possible range -4 - 4) - 1 bit - radical state - 1 bit padding - 3 bit tetrahedron/allene sign - (000 - not stereo or unknown, 001 - pure-unknown-enantiomer, 010 or 011 - has stereo) - 4 bit - number of following bonds and CT blocks (0-15) - - Bond block 2 bytes (repeated 0-15 times) - 12 bit - negative shift from current atom to connected (e.g. 0x001 = -1 - connected to previous atom) - 4 bit - bond order: 0000 - single, 0001 - double, 0010 - triple, 0011 - aromatic, 0111 - special - - Cis-Trans 2 bytes - 12 bit - negative shift from current atom to connected (e.g. 0x001 = -1 - connected to previous atom) - 4 bit - CT sign: 1000 or 1001 - to avoid overlap with bond - - V2 format is faster than V3. V3 format doesn't include isotopes, atom numbers and XY coordinates. - :param compressed: return zlib-compressed pack. :param check: check molecule for format restrictions. :param version: format version @@ -581,6 +555,44 @@ def __bytes__(self): return self.pack() def _pack_v3(self, order=None, check=True): + """ + Format V3 specification: + Big endian bytes order + 8 bit - 0x03 (format specification version) + Atom block 3 bytes (repeated): + 1 bit - atom entrance flag (always 1) + 7 bit - atomic number (<=118) + 3 bit - hydrogens (0-7). Note: 7 == None + 4 bit - charge (charge + 4. possible range -4 - 4) + 1 bit - radical state + 4 bit - atom stereo + ANDx and ORx encode only sign. X value stored in the same order in Stereo group block. + 0000 [same as V2] - no stereo or unknown + 0001 - not used + 0010 - absolute sign False + 0011 - absolute sign True + 0100 - sign False OR1 group + 0101 - sign True OR1 group + 0110 - sign False AND1 group + 0111 - sign True AND1 group + 1000 - sign False OR2 group + 1001 - sign True OR2 group + 1010 - sign False AND2 group + 1011 - sign True AND2 group + 1100 - sign False ORx group + 1101 - sign True ORx group + 1110 - sign False ANDx group + 1111 - sign True ANDx group + 4 bit - number of following bonds and CT blocks (0-15) + + Bond block 2 bytes (repeated 0-15 times) + 12 bit - negative shift from current atom to connected (e.g. 0x001 = -1 - connected to previous atom) + 4 bit - bond order: 0000 - single, 0001 - double, 0010 - triple, 0011 - aromatic, 0111 - special + + Cis-Trans 2 bytes + 12 bit - negative shift from current atom to connected (e.g. 0x001 = -1 - connected to previous atom) + 4 bit - CT sign: 1000 or 1001 - to avoid overlap with bond + """ if order is None: order = list(self._atoms) elif check: @@ -610,42 +622,43 @@ def _pack_v3(self, order=None, check=True): data = [b'\x03'] for i, n in enumerate(order): seen[n] = i + atom = atoms[n] env = bonds[n] - data.append((0x80 | atoms[n].atomic_number).to_bytes(1, 'big')) + data.append((0x80 | atom.atomic_number).to_bytes(1, 'big')) # 3 bit - hydrogens (0-6, None) | 4 bit - charge | 1 bit - radical - hcr = (charges[n] + 4) << 1 | radicals[n] - if (h := hydrogens[n]) is None: + hcr = (atom.charge + 4) << 1 | atom.is_radical + if atom.implicit_hydrogens is None: hcr |= 0b11100000 else: - hcr |= h << 5 + hcr |= atom.implicit_hydrogens << 5 data.append(hcr.to_bytes(1, 'big')) - if n in atoms_stereo: - if self._translate_tetrahedron_sign(n, [x for x in order if x in env]): - s = 0b0011_0000 - else: - s = 0b0010_0000 - elif n in allenes_stereo: - t1, t2 = allenes_terminals[n] - nn = None - for x in order: - if nn is None: - if x in cumulenes[t1]: - nn = x - flag = True - elif x in cumulenes[t2]: - flag = False - nn = x - elif flag: # noqa - if x in cumulenes[t2]: + if atom.stereo is not None: + if len(env) == 2: + t1, t2 = allenes_terminals[n] + nn = None + for x in order: + if nn is None: + if x in cumulenes[t1]: + nn = x + flag = True + elif x in cumulenes[t2]: + flag = False + nn = x + elif flag: # noqa + if x in cumulenes[t2]: + nm = x + break + elif x in cumulenes[t1]: nm = x break - elif x in cumulenes[t1]: - nm = x - break - if self._translate_allene_sign(n, nn, nm): # noqa + if self._translate_allene_sign(n, nn, nm): # noqa + s = 0b0011_0000 + else: + s = 0b0010_0000 + elif self._translate_tetrahedron_sign(n, [x for x in order if x in env]): s = 0b0011_0000 else: s = 0b0010_0000 From f6155e1d5d954aa8f1fd686e77684a1d20fbaecc Mon Sep 17 00:00:00 2001 From: stsouko Date: Sun, 22 Dec 2024 12:01:12 +0100 Subject: [PATCH 56/67] entry point not found. reverted back api --- chython/algorithms/calculate2d/__init__.py | 129 ++++++++++++++++++++- 1 file changed, 128 insertions(+), 1 deletion(-) diff --git a/chython/algorithms/calculate2d/__init__.py b/chython/algorithms/calculate2d/__init__.py index 193ea427..d9fa3690 100644 --- a/chython/algorithms/calculate2d/__init__.py +++ b/chython/algorithms/calculate2d/__init__.py @@ -1,8 +1,10 @@ # -*- coding: utf-8 -*- # +# Copyright 2019-2024 Ramil Nugmanov # Copyright 2024 Denis Lipatov # Copyright 2024 Vyacheslav Grigorev # Copyright 2024 Timur Gimadiev +# Copyright 2019, 2020 Dinar Batyrshin # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -18,4 +20,129 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from .clean2d import Calculate2DMolecule, Calculate2DReaction \ No newline at end of file +from typing import TYPE_CHECKING, Union + + +if TYPE_CHECKING: + from chython import ReactionContainer, MoleculeContainer + + +class Calculate2DMolecule: + __slots__ = () + + def clean2d(self: Union['MoleculeContainer', 'Calculate2DMolecule']): + """ + Calculate 2d layout of graph. + https://pubs.acs.org/doi/10.1021/acs.jcim.7b00425 JS implementation used as a reference. + """ + # todo: reimplement + self.__dict__.pop('__cached_method__repr_svg_', None) + + def _fix_plane_mean(self: 'MoleculeContainer', shift_x: float, shift_y=0., component=None) -> float: + atoms = self._atoms + if component is None: + component = atoms + + left_atom = atoms[min(component, key=lambda x: atoms[x].x)] + right_atom = atoms[max(component, key=lambda x: atoms[x].x)] + + min_x = left_atom.x - shift_x + if len(left_atom.atomic_symbol) == 2: + min_x -= .2 + + max_x = right_atom.x - min_x + min_y = min(atoms[x].y for x in component) + max_y = max(atoms[x].y for x in component) + mean_y = (max_y + min_y) / 2 - shift_y + for n in component: + a = atoms[n] + a._x -= min_x + a._y -= mean_y + + if -.18 <= right_atom.y <= .18: + factor = right_atom.implicit_hydrogens + if factor == 1: + max_x += .15 + elif factor: + max_x += .25 + return max_x + + def _fix_plane_min(self: 'MoleculeContainer', shift_x: float, shift_y=0., component=None) -> float: + atoms = self._atoms + if component is None: + component = atoms + + right_atom = atoms[max(component, key=lambda x: atoms[x].x)] + min_x = min(atoms[x].x for x in component) - shift_x + max_x = right_atom.x - min_x + min_y = min(atoms[x].y for x in component) - shift_y + + for n in component: + a = atoms[n] + a._x -= min_x + a._y -= min_y + + if shift_y - .18 <= right_atom.y <= shift_y + .18: + factor = right_atom.implicit_hydrogens + if factor == 1: + max_x += .15 + elif factor: + max_x += .25 + return max_x + + +class Calculate2DReaction: + __slots__ = () + + def clean2d(self: 'ReactionContainer'): + """ + Recalculate 2d coordinates + """ + for m in self.molecules(): + m.clean2d() + self.fix_positions() + + def fix_positions(self: 'ReactionContainer'): + """ + Fix coordinates of molecules in reaction + """ + shift_x = 0 + reactants = self.reactants + amount = len(reactants) - 1 + signs = [] + for m in reactants: + max_x = m._fix_plane_mean(shift_x) + if amount: + max_x += .2 + signs.append(max_x) + amount -= 1 + shift_x = max_x + 1 + arrow_min = shift_x + + if self.reagents: + shift_x += .4 + for m in self.reagents: + max_x = m._fix_plane_min(shift_x, .5) + shift_x = max_x + 1 + shift_x += .4 + if shift_x - arrow_min < 3: + shift_x = arrow_min + 3 + else: + shift_x += 3 + arrow_max = shift_x - 1 + + products = self.products + amount = len(products) - 1 + for m in products: + max_x = m._fix_plane_mean(shift_x) + if amount: + max_x += .2 + signs.append(max_x) + amount -= 1 + shift_x = max_x + 1 + self._arrow = (arrow_min, arrow_max) + self._signs = tuple(signs) + self.flush_cache() + + +__all__ = ['Calculate2DMolecule', 'Calculate2DReaction'] From a7374e5494ff99de88c3d016e6885b695a8f714b Mon Sep 17 00:00:00 2001 From: stsouko Date: Wed, 25 Dec 2024 23:03:18 +0100 Subject: [PATCH 57/67] saved --- chython/algorithms/calculate2d/__init__.py | 45 +- chython/algorithms/calculate2d/_templates.py | 72 +++ chython/algorithms/calculate2d/_vector.py | 519 +++++++++++++++++++ chython/algorithms/calculate2d/clean2d.py | 180 ------- 4 files changed, 632 insertions(+), 184 deletions(-) create mode 100644 chython/algorithms/calculate2d/_templates.py create mode 100644 chython/algorithms/calculate2d/_vector.py delete mode 100644 chython/algorithms/calculate2d/clean2d.py diff --git a/chython/algorithms/calculate2d/__init__.py b/chython/algorithms/calculate2d/__init__.py index d9fa3690..3817a942 100644 --- a/chython/algorithms/calculate2d/__init__.py +++ b/chython/algorithms/calculate2d/__init__.py @@ -1,9 +1,6 @@ # -*- coding: utf-8 -*- # # Copyright 2019-2024 Ramil Nugmanov -# Copyright 2024 Denis Lipatov -# Copyright 2024 Vyacheslav Grigorev -# Copyright 2024 Timur Gimadiev # Copyright 2019, 2020 Dinar Batyrshin # This file is part of chython. # @@ -21,12 +18,16 @@ # along with this program; if not, see . # from typing import TYPE_CHECKING, Union +from ._templates import rules if TYPE_CHECKING: from chython import ReactionContainer, MoleculeContainer +BL = .825 + + class Calculate2DMolecule: __slots__ = () @@ -35,9 +36,45 @@ def clean2d(self: Union['MoleculeContainer', 'Calculate2DMolecule']): Calculate 2d layout of graph. https://pubs.acs.org/doi/10.1021/acs.jcim.7b00425 JS implementation used as a reference. """ - # todo: reimplement + shift_x = 0 + groups = None + for component in self.connected_components: + if len(component) == 2: # 2-atom mols always stored horizontally + n, m = component + a = self._atoms[n] + a._x = a._y = 0 + a = self._atoms[m] + a._x, a._y = BL, 0 + elif len(component) > 2: + if groups is None: + # apply templates with predefined layout: rings and hard cases. + groups, seen = self._apply_2d_templates() + super_rings = [r for r in self.sssr if any(n not in seen for n in r)] + + + + # else len == 1: just a dot. no need for layout calculation + shift_x = self._fix_plane_mean(shift_x, component=component) + .9 self.__dict__.pop('__cached_method__repr_svg_', None) + def _apply_2d_templates(self): + atoms = self._atoms + seen = set() + groups = [] + for q, layout in rules: + for m in q.get_mapping(self, automorphism_filter=False): + if not seen.isdisjoint(m.values()): # avoid any overlap. rules preordered from complex to simple + continue + seen.update(m.values()) + groups.append(list(m.values())) + for n, xy in zip(m.values(), layout): + atom = atoms[n] + atom._x, atom._y = xy + return groups, seen + + def _apply_kamada_kawai(self, group): + pass + def _fix_plane_mean(self: 'MoleculeContainer', shift_x: float, shift_y=0., component=None) -> float: atoms = self._atoms if component is None: diff --git a/chython/algorithms/calculate2d/_templates.py b/chython/algorithms/calculate2d/_templates.py new file mode 100644 index 00000000..f1f9d887 --- /dev/null +++ b/chython/algorithms/calculate2d/_templates.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2024 Ramil Nugmanov +# This file is part of chython. +# +# chython is free software; you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, see . +# +from lazy_object_proxy import Proxy + + +def _rules(): + from ... import smarts + + rules = [] + + + # C + # / \ + # C C + # | | + # C C + # \ / + # C + q = smarts('[A;r6]!#;@1!#;@[A]!#;@[A]!#;@[A]!#;@[A]!#;@[A]1') + xy = [(0, 2.31), (0, 0.77), (1.3337, 0), (2.6674, 0.77), (2.6674, 2.31), (1.3337, 3.08)] + rules.append((q, xy)) + + # + # C-,=C + # || || + # C C + # \ / + # C1 + q = smarts('[A;r5]-,:;@1-,:;@[A]!#;@[A]!#;@[A]!#;@[A]1') + xy = [(1.2459, 0), (0, 0.9052), (0.4759, 2.3698), (2.0159, 2.3698), (2.4918, 0.9052)] + rules.append((q, xy)) + + # + # C=,-C + # \ / + # C + # + q = smarts('[A;r3]1-,=;@[A]-;@[A]1') + xy = [(0, 0), (1.54, 0), (.77, -1.333)] + rules.append((q, xy)) + + # + # C=,-C + # | | + # C=,-C + # + q = smarts('[A;r4]-;@1-,=;@[A]-;@[A]-,=;@[A]1') + xy = [(0, 0), (1.54, 0), (1.54, -1.54), (0, -1.54)] + rules.append((q, xy)) + return rules + + +rules = Proxy(_rules) + + +__all__ = ['rules'] diff --git a/chython/algorithms/calculate2d/_vector.py b/chython/algorithms/calculate2d/_vector.py new file mode 100644 index 00000000..3ced4c17 --- /dev/null +++ b/chython/algorithms/calculate2d/_vector.py @@ -0,0 +1,519 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2024 Denis Lipatov +# Copyright 2024 Vyacheslav Grigorev +# Copyright 2024 Timur Gimadiev +# Copyright 2024 Ramil Nugmanov +# This file is part of chython. +# +# chython is free software; you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, see . +# +from dataclasses import dataclass +from math import cos, sin, hypot, atan2 +from typing import List + + +@dataclass(frozen=True, slots=True) +class Vector: + """ + The `Vector` class facilitates operations with coordinates, including vector arithmetic + (addition, subtraction, multiplication/division by scalars), normalization, rotation, and + distance calculations. It also supports methods for determining the angle of a vector, its + length, and whether it lies in a certain quadrant. Additionally, it includes functions for + reflecting vectors about lines, finding the closest atom or point, and rotating vectors around + other vectors or points. + """ + x: float + y: float + + def __neg__(self): + """ + A class method that inverts the current coordinates of objects of the class + """ + return Vector(-self.x, -self.y) + + def __sub__(self, vector: 'Vector'): + """ + A method for the operation of subtraction between vectors + """ + return Vector(self.x - vector.x, self.y - vector.y) + + def __add__(self, vector: 'Vector'): + """ + A method for the operation of addition between vectors + """ + return Vector(self.x + vector.x, self.y + vector.y) + + def __truediv__(self, scalar: float): + """ + A class method that divides the coordinates of the vector by a given scalar + """ + return Vector(self.x / scalar, self.y / scalar) + + def __mul__(self, scalar: float): + """ + Multiplies the coordinates of the current vector by an arbitrary real number + """ + return Vector(self.x * scalar, self.y * scalar) + + def __float__(self): + """ + Calculates the length of the current vector + + Returns float + """ + return hypot(self.x, self.y) + + def __iter__(self): + yield self.x + yield self.y + + def rotate(self, angle: float): + """ + A method that rotates the vector by the angle in radians + """ + c = cos(angle) + s = sin(angle) + return Vector(self.x * c - self.y * s, self.x * s + self.y * c) + + def normalise(self): + """ + Normalization of coordinates (dividing them by the length of the vector itself) + """ + if ln := float(self): + return self / ln + return self + + def angle(self, vector: 'Vector' = None) -> float: + """ + A method calculates the angle of inclination of the current vector + or the vector between given vector and the current vector. + """ + if vector is None: + return atan2(self.y, self.x) + else: + return atan2(self.y - vector.y, self.x - vector.x) + + + + def rotate_around_vector(self, angle: float, vector: 'Vector') -> None: + """ + Rotates a point (or vector) around a given vector by a specified angle. + + Parameters: + :param angle float: + The angle by which to rotate the point, typically measured in radians. + :param vector 'Vector': + The vector around which the rotation occurs. This vector serves as the reference + point. + """ + self.x -= vector.x + self.y -= vector.y + + x = self.x * math.cos(angle) - self.y * math.sin(angle) + y = self.x * math.sin(angle) + self.y * math.cos(angle) + + self.x = x + vector.x + self.y = y + vector.y + + def get_closest_atom(self, atom_1: 'AtomProperties', atom_2: 'AtomProperties') -> 'AtomProperties': + """ + This method determines which of the two atoms (represented by the objects atom_1 and atom_2) + is closer to the current object (represented by self). + + Parameters: + :param atom_1: 'AtomProperties': + The first atom to compare. + :param atom_2: 'AtomProperties': + The second atom to compare. + + Returns 'AtomProperties': + The closest atom. + """ + distance_1 = self.get_squared_distance(atom_1.position) + distance_2 = self.get_squared_distance(atom_2.position) + return atom_1 if distance_1 < distance_2 else atom_2 + + def get_closest_point_index(self, point_1: 'Vector', point_2: 'Vector') -> int: + """ + The method is designed to determine which of the two specified coordinates (point_1: 'Vector', point_2: 'Vector') + closer to the current point. + + Parameters + :param point_1 'Vector': + The first point to be compared with. It can be a tuple, a list, or an object + representing coordinates. + :param point_2 'Vector': + The second point to compare with. Similarly, it can be a tuple, a list, or an + object. + + Returns int: + The index of the nearest point: 0 for point_1 and 1 for point_2. + """ + distance_1 = self.get_squared_distance(point_1) + distance_2 = self.get_squared_distance(point_2) + return 0 if distance_1 < distance_2 else 1 + + def get_squared_length(self) -> float: + """ + Calculates the length squared + + Returns float: + Vector length squared + """ + return self.x ** 2 + self.y ** 2 + + def get_squared_distance(self, vector: 'Vector') -> float: + """ + The method is designed to calculate the square of the distance between the current vector + (represented by self) and the specified vector (or point) represented by the vector object. + + Parameters + :param vector: 'Vector': + An object representing a vector or point from which to calculate the distance. + + Returns float: + The square of the distance + """ + return (vector.x - self.x) ** 2 + (vector.y - self.y) ** 2 + + def get_distance(self, vector: 'Vector') -> float: + """ + The method is designed to calculate the distance between the current vector (represented by self) and + the specified vector (or point) represented by the vector object. + + Parameters + :param vector: 'Vector': + An object representing a vector or point from which to calculate the distance. + + Returns float: + The distance between the coordinates of the current vector and the passed parameter + """ + return math.sqrt(self.get_squared_distance(vector)) + + def get_rotation_away_from_vector(self, vector: 'Vector', center: 'Vector', angle: float) -> float: + """ + The method is designed to determine how much the angle of rotation (in a positive or negative direction) + from a given vector measures the distance to this vector. + + Parameters + :param vector 'Vector': + The vector to "move away from". It can be a point or a direction, relative to which + the rotation is taking place. + :param center 'Vector': + The center of rotation around which the object (represented by self) rotates. + :param angle float: + The angle at which the rotation occurs. This value can be positive or negative. + + Returns returns the rotation angle that minimizes the distance to the vector, + either in a positive or negative direction. + """ + tmp = self.copy() + + tmp.rotate_around_vector(angle, center) + squared_distance_1 = tmp.get_squared_distance(vector) + + tmp.rotate_around_vector(-2.0 * angle, center) + squared_distance_2 = tmp.get_squared_distance(vector) + return angle if squared_distance_2 < squared_distance_1 else -angle + + def rotate_away_from_vector(self, vector: 'Vector', center: 'Vector', angle: float) -> None: + """ + The method is designed to rotate the current object (represented by self) around a given + one center in such a way as to minimize the distance to the specified vector. + If rotation in one direction leads to a decrease in the distance, the function corrects + the rotation,to ensure maximum distance from the vector. + + Parameters + :param vector 'Vector': + The vector to "move away from". It can be a point or a direction, relative to which + the rotation is taking place. + :param center 'Vector': + The center of rotation around which the object rotates. + :param angle float: + The angle at which the rotation occurs. This value can be positive or negative. + """ + self.rotate_around_vector(angle, center) + squared_distance_1 = self.get_squared_distance(vector) + self.rotate_around_vector(-2.0 * angle, center) + squared_distance_2 = self.get_squared_distance(vector) + + if squared_distance_2 < squared_distance_1: + self.rotate_around_vector(2.0 * angle, center) + + def get_clockwise_orientation(self, vector: 'Vector') -> str: + """ + The method is designed to determine the orientation (positive or negative) between + the current object (represented by self) and the specified vector (represented by + the vector object). + + Parameters + :param vector 'Vector': + The vector relative to which the orientation is determined. + + Returns str: + A string indicating whether the orientation is "clockwise", "counterclockwise" + or "neutral". + """ + a: float = self.y * vector.x + b: float = self.x * vector.y + + if a > b: + return 'clockwise' + elif a == b: + return 'neutral' + else: + return 'counterclockwise' + + def mirror_about_line(self, line_point_1: 'Vector', line_point_2: 'Vector') -> None: + """ + The method is designed to reflect the current object (represented by self) relative to a + given line, defined by two points (line_point_1 and line_point_2). After performing this + function, the coordinates of the object will be changed so that it is on the opposite + side of the line, keeping the same distance to the line. + + Parameters + :param line_point_1: 'Vector': + The first point defining the line. + :param line_point_2: 'Vector': + The second point defining the line. + """ + dx = line_point_2.x - line_point_1.x + dy = line_point_2.y - line_point_1.y + + a = (dx * dx - dy * dy) / (dx * dx + dy * dy) + b = 2 * dx * dy / (dx * dx + dy * dy) + + new_x = a * (self.x - line_point_1.x) + b * (self.y - line_point_1.y) + line_point_1.x + new_y = b * (self.x - line_point_1.x) - a * (self.y - line_point_1.y) + line_point_1.y + + self.x = new_x + self.y = new_y + + @staticmethod + def get_position_relative_to_line(vector_start: 'Vector', vector_end: 'Vector', vector: 'Vector') -> int: + """ + Determines the position of a vector relative to a line defined by two points. + + Parameters: + :param vector_start 'Vector': + The start point of the line. + :param vector_end 'Vector': + The end point of the line. + :param vector 'Vector': + The vector whose position relative to the line is to be determined. + + Returns int: + 1 if the vector is to the left of the line, -1 if the vector is to the right of the + line, 0 if the vector lies on the line. + """ + d = (vector.x - vector_start.x) * (vector_end.y - vector_start.y) - (vector.y - vector_start.y) * ( + vector_end.x - vector_start.x) + if d > 0: + return 1 + elif d < 0: + return -1 + else: + return 0 + + @staticmethod + def get_directionality_triangle(vector_a: 'Vector', vector_b: 'Vector', vector_c: 'Vector') -> str: + """ + Determines the directionality of the triangle formed by three vectors (or points). + + Parameters: + :param vector_a 'Vector': + The first vertex of the triangle. + :param vector_b 'Vector': + The second vertex of the triangle. + :param vector_c 'Vector': + The third vertex of the triangle. + + Returns str: + - 'clockwise' if the triangle is oriented in a clockwise direction. + - 'counterclockwise' if the triangle is oriented in a counterclockwise direction. + - None if the three points are collinear (lie on the same line). + """ + determinant = (vector_b.x - vector_a.x) * (vector_c.y - vector_a.y) - (vector_c.x - vector_a.x) * ( + vector_b.y - vector_a.y) + if determinant < 0: + return 'clockwise' + elif determinant == 0: + return None + else: + return 'counterclockwise' + + @staticmethod + def mirror_vector_about_line(line_point_1: 'Vector', line_point_2: 'Vector', point: 'Vector') -> 'Vector': + """ + Mirrors a point (or vector) across a line defined by two points. + + Parameters: + :param line_point_1 'Vector': + The first point defining the line. + :param line_point_2 'Vector': + The second point defining the line. + :param point 'Vector': + The point to be mirrored across the line. + + Returns Vector: + A new Vector representing the mirrored point across the line. + """ + dx = line_point_2.x - line_point_1.x + dy = line_point_2.y - line_point_1.y + + a = (dx * dx - dy * dy) / (dx * dx + dy * dy) + b = 2 * dx * dy / (dx * dx + dy * dy) + + x_new = a * (point.x - line_point_1.x) + b * (point.y - line_point_1.y) + line_point_1.x + y_new = b * (point.x - line_point_1.x) - a * (point.y - line_point_1.y) + line_point_1.y + return Vector(x_new, y_new) + + @staticmethod + def get_line_angle(point_1: 'Vector', point_2: 'Vector') -> float: + """ + Calculates the angle of a line defined by two points with respect to the positive x-axis. + + Parameters: + point_1 'Vector': + The first point defining the line. + point_2 'Vector': + The second point defining the line. + + Returns float: + The angle of the line in radians, in the range [-π, π]. + """ + difference = Vector.subtract_vectors(point_2, point_1) + return difference.angle() + + @staticmethod + def subtract_vectors(vector_1: 'Vector', vector_2: 'Vector') -> 'Vector': + """ + Subtracts one vector from another. + + Parameters: + vector_1 'Vector': + The vector from which to subtract. + vector_2 'Vector': + The vector to subtract. + + Returns Vector: + A new Vector representing the result of the subtraction (vector_1 - vector_2). + """ + x = vector_1.x - vector_2.x + y = vector_1.y - vector_2.y + return Vector(x, y) + + @staticmethod + def add_vectors(vector_1: 'Vector', vector_2: 'Vector') -> 'Vector': + """ + Adds two vectors together. + + Parameters: + vector_1 'Vector': + The first vector to add. + vector_2 'Vector': + The second vector to add. + + Returns Vector: + A new Vector representing the result of the addition (vector_1 + vector_2). + """ + x = vector_1.x + vector_2.x + y = vector_1.y + vector_2.y + return Vector(x, y) + + @staticmethod + def get_midpoint(vector_1: 'Vector', vector_2: 'Vector') -> 'Vector': + """ + Calculates the midpoint between two vectors. + + Parameters: + vector_1 'Vector': + The first vector. + vector_2 'Vector': + The second vector. + + Returns Vector: + A new Vector representing the midpoint between vector_1 and vector_2. + """ + x = (vector_1.x + vector_2.x) / 2 + y = (vector_1.y + vector_2.y) / 2 + return Vector(x, y) + + @staticmethod + def get_average(vectors: List['Vector']) -> 'Vector': + """ + Calculates the average of a list of vectors. + + Parameters: + :param vectors List[Vector]: + A list of vectors for which the average is to be calculated. + + Returns: + Vector: A new Vector representing the average of the input vectors. + """ + average_x = 0.0 + average_y = 0.0 + for vector in vectors: + average_x += vector.x + average_y += vector.y + return Vector(average_x / len(vectors), average_y / len(vectors)) + + @staticmethod + def get_normals(vector_1: 'Vector', vector_2: 'Vector') -> List['Vector']: + """ + Calculates the normal vectors to the line defined by two vectors. + + Parameters: + :param vector_1 'Vector': + The first vector defining the line. + :param vector_2 'Vector': + The second vector defining the line. + + Returns List[Vector]: + A list containing two normal vectors to the line defined by vector_1 and vector_2. + """ + delta = Vector.subtract_vectors(vector_2, vector_1) + return [Vector(-delta.y, delta.x), Vector(delta.y, -delta.x)] + + @staticmethod + def get_angle_between_vectors(vector_1: 'Vector', vector_2: 'Vector', origin: 'Vector') -> float: + """ + Calculates the angle between two vectors relative to a given origin point. + + Parameters: + :param vector_1 'Vector': + The first vector. + :param vector_2 'Vector': + The second vector. + :param origin 'Vector': + The origin point relative to which the angle is calculated. + + Returns: + float: The angle between vector_1 and vector_2 in radians, in the range [0, π]. + """ + v1_x_diff: float = vector_1.x - origin.x + v1_y_diff: float = vector_1.y - origin.y + v2_x_diff: float = vector_2.x - origin.x + v2_y_diff: float = vector_2.y - origin.y + + dot_product: float = v1_x_diff * v2_x_diff + v1_y_diff * v2_y_diff + length_v1: float = math.sqrt(v1_x_diff ** 2 + v1_y_diff ** 2) + length_v2: float = math.sqrt(v2_x_diff ** 2 + v2_y_diff ** 2) + + cos_angle = dot_product / (length_v1 * length_v2) + return math.acos(cos_angle) + + +__all__ = ['Vector'] diff --git a/chython/algorithms/calculate2d/clean2d.py b/chython/algorithms/calculate2d/clean2d.py deleted file mode 100644 index 0fd77b72..00000000 --- a/chython/algorithms/calculate2d/clean2d.py +++ /dev/null @@ -1,180 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright 2024 Denis Lipatov -# Copyright 2024 Vyacheslav Grigorev -# Copyright 2024 Timur Gimadiev -# This file is part of chython. -# -# chython is free software; you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation; either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with this program; if not, see . -# -from typing import TYPE_CHECKING, Union, List -from .Calculate2d import Calculate2d -from math import sqrt - -if TYPE_CHECKING: - from ...containers import ReactionContainer, MoleculeContainer - -try: - from importlib.resources import files -except ImportError: # python3.8 - from importlib_resources import files - - -class Calculate2DMolecule: - __slots__ = () - - def clean2d(self: Union['MoleculeContainer', 'Calculate2DMolecule']): - """ - Calculate 2d layout of graph. https://pubs.acs.org/doi/10.1021/acs.jcim.7b00425 JS implementation used. - """ - plane = {} - entry = iter(sorted(self, key=lambda n: len(self._bonds[n]))) - smiles, order = self.__clean2d_prepare(next(entry)) - - obj = Calculate2d() - xy: List[List[float, float]] = obj._calculate2d_coord(order, self) - - - shift_x, shift_y = xy[0] - for n, (x, y) in zip(order, xy): - plane[n] = (x - shift_x, shift_y - y) - - bonds = [] - for n, m, _ in self.bonds(): - xn, yn = plane[n] - xm, ym = plane[m] - bonds.append(sqrt((xm - xn) ** 2 + (ym - yn) ** 2)) - if bonds: - bond_reduce = sum(bonds) / len(bonds) / .825 - else: - bond_reduce = 1. - - atoms = self._atoms - for n, (x, y) in plane.items(): - a = atoms[n] - a._x = x / bond_reduce - a._y = y / bond_reduce - - if self.connected_components_count > 1: - shift_x = 0. - for c in self.connected_components: - shift_x = self._fix_plane_mean(shift_x, component=c) + .9 - self.__dict__.pop('__cached_method__repr_svg_', None) - - def _fix_plane_mean(self: 'MoleculeContainer', shift_x: float, shift_y=0., component=None) -> float: - plane = self._plane - if component is None: - component = plane - - left_atom = min(component, key=lambda x: plane[x][0]) - right_atom = max(component, key=lambda x: plane[x][0]) - - min_x = plane[left_atom][0] - shift_x - if len(self._atoms[left_atom].atomic_symbol) == 2: - min_x -= 0.2 - - max_x = plane[right_atom][0] - min_x - min_y = min(plane[x][1] for x in component) - max_y = max(plane[x][1] for x in component) - mean_y = (max_y + min_y) / 2 - shift_y - for n in component: - x, y = plane[n] - plane[n] = (x - min_x, y - mean_y) - - if -0.18 <= plane[right_atom][1] <= 0.18: - factor = self._hydrogens[right_atom] - if factor == 1: - max_x += 0.15 - elif factor: - max_x += 0.25 - return max_x - - def _fix_plane_min(self: 'MoleculeContainer', shift_x: float, shift_y=0., component=None) -> float: - plane = self._plane - if component is None: - component = plane - - right_atom = max(component, key=lambda x: plane[x][0]) - min_x = min(plane[x][0] for x in component) - shift_x - max_x = plane[right_atom][0] - min_x - min_y = min(plane[x][1] for x in component) - shift_y - - for n in component: - x, y = plane[n] - plane[n] = (x - min_x, y - min_y) - - if shift_y - 0.18 <= plane[right_atom][1] <= shift_y + 0.18: - factor = self._hydrogens[right_atom] - if factor == 1: - max_x += 0.15 - elif factor: - max_x += 0.25 - return max_x - - - def __clean2d_prepare(self: 'MoleculeContainer', entry): - w = {n: i for i, n in enumerate(self._atoms)} - w[entry] = -1 - smiles, order = self._smiles(w.__getitem__, random=True, charges=False, stereo=False, _return_order=True) - return ''.join(smiles).replace('~', '-'), order - -class Calculate2DReaction: - __slots__ = () - - def clean2d(self: 'ReactionContainer'): - for m in self.molecules(): - m.clean2d() - self.fix_positions() - - def fix_positions(self: 'ReactionContainer'): - shift_x = 0 - reactants = self.reactants - amount = len(reactants) - 1 - signs = [] - for m in reactants: - max_x = m._fix_plane_mean(shift_x) - if amount: - max_x += .2 - signs.append(max_x) - amount -= 1 - shift_x = max_x + 1 - arrow_min = shift_x - - if self.reagents: - shift_x += .4 - for m in self.reagents: - max_x = m._fix_plane_min(shift_x, .5) - shift_x = max_x + 1 - shift_x += .4 - if shift_x - arrow_min < 3: - shift_x = arrow_min + 3 - else: - shift_x += 3 - arrow_max = shift_x - 1 - - products = self.products - amount = len(products) - 1 - for m in products: - max_x = m._fix_plane_mean(shift_x) - if amount: - max_x += .2 - signs.append(max_x) - amount -= 1 - shift_x = max_x + 1 - self._arrow = (arrow_min, arrow_max) - self._signs = tuple(signs) - self.flush_cache() - - -__all__ = ['Calculate2DMolecule', 'Calculate2DReaction'] \ No newline at end of file From 9b6b55a2294521e41c657d1251cd4e0e2261595a Mon Sep 17 00:00:00 2001 From: stsouko Date: Wed, 25 Dec 2024 23:23:46 +0100 Subject: [PATCH 58/67] Remove unused ring_atoms prop. Removed redundant `__hash__` implementations from query elements. Morgan hashes refactored. Will affect generated smiles strings. Implemented rings_graph as next level of skin graph. --- chython/algorithms/morgan.py | 3 +- chython/algorithms/rings.py | 79 ++++++++++++++------------- chython/containers/molecule.py | 6 +- chython/periodictable/base/element.py | 3 +- chython/periodictable/base/query.py | 15 ----- 5 files changed, 46 insertions(+), 60 deletions(-) diff --git a/chython/algorithms/morgan.py b/chython/algorithms/morgan.py index c56b5572..8c8c1b30 100644 --- a/chython/algorithms/morgan.py +++ b/chython/algorithms/morgan.py @@ -44,8 +44,7 @@ def atoms_order(self: 'MoleculeContainer') -> Dict[int, int]: return {} elif len(self) == 1: # optimize single atom containers return dict.fromkeys(self, 1) - ring = self.ring_atoms - return _morgan({n: hash((hash(a), n in ring)) for n, a in self.atoms()}, self.int_adjacency) + return _morgan({n: hash(a) for n, a in self.atoms()}, self.int_adjacency) @cached_property def int_adjacency(self: 'MoleculeContainer') -> Dict[int, Dict[int, int]]: diff --git a/chython/algorithms/rings.py b/chython/algorithms/rings.py index 4871d5fa..d4b9c1c2 100644 --- a/chython/algorithms/rings.py +++ b/chython/algorithms/rings.py @@ -51,7 +51,7 @@ def sssr(self) -> List[Tuple[int, ...]]: @cached_property def atoms_rings(self) -> Dict[int, List[Tuple[int, ...]]]: """ - A dictionary with atom numbers as keys and a list of tuples (representing rings) as values. + A dictionary with atom numbers as keys and a list of tuples (representing SSSR rings) as values. """ rings = defaultdict(list) for r in self.sssr: @@ -62,46 +62,10 @@ def atoms_rings(self) -> Dict[int, List[Tuple[int, ...]]]: @cached_property def atoms_rings_sizes(self) -> Dict[int, Set[int]]: """ - Sizes of rings containing atom. + Sizes of SSSR rings containing atom. """ return {n: {len(r) for r in rs} for n, rs in self.atoms_rings.items()} - @cached_property - def ring_atoms(self) -> Set[int]: - """ - Atoms in rings. Not SSSR based fast algorithm. - """ - bonds = _skin_graph(self.not_special_connectivity) - if not bonds: - return set() - - in_rings = set() - atoms = set(bonds) - while atoms: - stack = deque([(atoms.pop(), 0, 0)]) - path = [] - seen = set() - while stack: - c, p, d = stack.pop() - if len(path) > d: - path = path[:d] - if c in in_rings: - continue - path.append(c) - seen.add(c) - - d += 1 - for n in bonds[c]: - if n == p: - continue - elif n in seen: - in_rings.update(path[path.index(n):]) - else: - stack.append((n, c, d)) - - atoms.difference_update(seen) - return in_rings - @cached_property def rings_count(self) -> int: """ @@ -144,6 +108,45 @@ def skin_graph(self: 'MoleculeContainer') -> Dict[int, Set[int]]: """ return _skin_graph(self._bonds) + @cached_property + def rings_graph(self: 'MoleculeContainer'): + """ + Graph of rings. Linkers are not included. Special bonds are considered. + """ + bonds = self.skin_graph + if not bonds: + return bonds + + in_rings = set() + atoms = set(bonds) + while atoms: + stack = deque([(atoms.pop(), 0, 0)]) + path = [] + seen = set() + while stack: + c, p, d = stack.pop() + if len(path) > d: + path = path[:d] + if c in in_rings: + continue + path.append(c) + seen.add(c) + + d += 1 + for n in bonds[c]: + if n == p: + continue + elif n in seen: + in_rings.update(path[path.index(n):]) + else: + stack.append((n, c, d)) + + atoms.difference_update(seen) + for n in bonds.keys() - in_rings: + for m in bonds.pop(n): + bonds[m].discard(n) + return bonds + def _sssr(bonds: Dict[int, Union[Set[int], Dict[int, Any]]], n_sssr: int) -> List[Tuple[int, ...]]: """ diff --git a/chython/containers/molecule.py b/chython/containers/molecule.py index 695852b7..d16d6e40 100644 --- a/chython/containers/molecule.py +++ b/chython/containers/molecule.py @@ -251,8 +251,7 @@ def copy(self, *, keep_sssr=False, keep_components=False) -> 'MoleculeContainer' if keep_sssr: for k, v in self.__dict__.items(): - if k in ('sssr', 'atoms_rings', 'atoms_rings_sizes', - 'ring_atoms', 'not_special_connectivity', 'rings_count'): + if k in ('sssr', 'atoms_rings', 'atoms_rings_sizes', 'not_special_connectivity', 'rings_count'): copy.__dict__[k] = v if keep_components: if 'connected_components' in self.__dict__: @@ -840,8 +839,7 @@ def flush_cache(self, *, keep_sssr=False, keep_components=False): if keep_sssr: # good to keep if no new bonds or bonds deletions or bonds to/from any change for k, v in self.__dict__.items(): - if k in ('sssr', 'atoms_rings', 'atoms_rings_sizes', - 'ring_atoms', 'not_special_connectivity', 'rings_count'): + if k in ('sssr', 'atoms_rings', 'atoms_rings_sizes', 'not_special_connectivity', 'rings_count'): backup[k] = v if keep_components: # good to keep if no new bonds or bonds deletions diff --git a/chython/periodictable/base/element.py b/chython/periodictable/base/element.py index 1185d661..1d066f07 100644 --- a/chython/periodictable/base/element.py +++ b/chython/periodictable/base/element.py @@ -342,7 +342,8 @@ def __eq__(self, other): self.isotope == other.isotope and self.charge == other.charge and self.is_radical == other.is_radical def __hash__(self): - return hash((self.isotope or 0, self.atomic_number, self.charge, self.is_radical, self.implicit_hydrogens or 0)) + return hash((self.isotope or 0, self.atomic_number, self.charge, self.is_radical, + self.implicit_hydrogens or 0, self.in_ring)) def valence_rules(self, valence: int) -> \ List[Tuple[Set[Tuple[int, 'Element']], Dict[Tuple[int, 'Element'], int], int]]: diff --git a/chython/periodictable/base/query.py b/chython/periodictable/base/query.py index 70d1588e..c955da28 100644 --- a/chython/periodictable/base/query.py +++ b/chython/periodictable/base/query.py @@ -236,9 +236,6 @@ def __eq__(self, other): return False return True - def __hash__(self): - return hash((self.neighbors, self.hybridization)) - class AnyElement(ExtendedQuery): __slots__ = () @@ -273,10 +270,6 @@ def __eq__(self, other): return False return True - def __hash__(self): - return hash((self.charge, self.is_radical, self.neighbors, self.hybridization, - self.ring_sizes, self.implicit_hydrogens, self.heteroatoms)) - class ListElement(ExtendedQuery): __slots__ = ('_elements', '__dict__') @@ -339,10 +332,6 @@ def __eq__(self, other): return False return True - def __hash__(self): - return hash((self.atomic_numbers, self.charge, self.is_radical, self.neighbors, self.hybridization, - self.ring_sizes, self.implicit_hydrogens, self.heteroatoms)) - def __repr__(self): return f'{self.__class__.__name__}([{self.atomic_symbol}])' @@ -474,9 +463,5 @@ def __eq__(self, other): return False return True - def __hash__(self): - return hash((self.isotope or 0, self.atomic_number, self.charge, self.is_radical, self.neighbors, - self.hybridization, self.ring_sizes, self.implicit_hydrogens, self.heteroatoms)) - __all__ = ['Query', 'ExtendedQuery', 'QueryElement', 'AnyElement', 'AnyMetal', 'ListElement'] From 9174fb2ea3451e3cde283d5e9c0bf0321a89a7e2 Mon Sep 17 00:00:00 2001 From: stsouko Date: Thu, 26 Dec 2024 16:56:42 +0100 Subject: [PATCH 59/67] dropped rings_graph implemented template based layouting implemented KK atoms preparation todo: implement KK for systems with fixed fragments --- chython/algorithms/calculate2d/__init__.py | 66 +++++++++++++++++----- chython/algorithms/rings.py | 39 ------------- 2 files changed, 51 insertions(+), 54 deletions(-) diff --git a/chython/algorithms/calculate2d/__init__.py b/chython/algorithms/calculate2d/__init__.py index 3817a942..006776b0 100644 --- a/chython/algorithms/calculate2d/__init__.py +++ b/chython/algorithms/calculate2d/__init__.py @@ -17,6 +17,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # +from math import isnan, nan from typing import TYPE_CHECKING, Union from ._templates import rules @@ -36,8 +37,10 @@ def clean2d(self: Union['MoleculeContainer', 'Calculate2DMolecule']): Calculate 2d layout of graph. https://pubs.acs.org/doi/10.1021/acs.jcim.7b00425 JS implementation used as a reference. """ - shift_x = 0 - groups = None + atoms = self._atoms + bonds = self._bonds + components = [] + tail = [] # small components pushed to the right for better visuality for component in self.connected_components: if len(component) == 2: # 2-atom mols always stored horizontally n, m = component @@ -45,15 +48,30 @@ def clean2d(self: Union['MoleculeContainer', 'Calculate2DMolecule']): a._x = a._y = 0 a = self._atoms[m] a._x, a._y = BL, 0 + tail.insert(0, component) elif len(component) > 2: - if groups is None: - # apply templates with predefined layout: rings and hard cases. - groups, seen = self._apply_2d_templates() - super_rings = [r for r in self.sssr if any(n not in seen for n in r)] - - - - # else len == 1: just a dot. no need for layout calculation + components.append(component) + # mark atoms as non-positioned + for n in component: + a = atoms[n] + a._x = a._y = nan + else: # len == 1: just a dot. no need for layout calculation + tail.append(component) + + if components: + # preset coordinates with templates + groups = self._apply_2d_templates() + # apply KK to fix environment or align groups or process unmatched rings + for kk in self._kamada_kawai_candidates(groups): + self._apply_kamada_kawai(kk, [g for g in groups if not g.isdisjoint(kk)]) + + for component in components: + if any(isnan(atoms[n].x) for n in component): + ... # linkers and trees + + components.extend(tail) + shift_x = 0 + for component in components: shift_x = self._fix_plane_mean(shift_x, component=component) + .9 self.__dict__.pop('__cached_method__repr_svg_', None) @@ -63,17 +81,35 @@ def _apply_2d_templates(self): groups = [] for q, layout in rules: for m in q.get_mapping(self, automorphism_filter=False): - if not seen.isdisjoint(m.values()): # avoid any overlap. rules preordered from complex to simple + if not seen.isdisjoint(m.values()): # avoid any overlap continue seen.update(m.values()) - groups.append(list(m.values())) + groups.append(set(m.values())) for n, xy in zip(m.values(), layout): atom = atoms[n] atom._x, atom._y = xy - return groups, seen + return groups - def _apply_kamada_kawai(self, group): - pass + def _kamada_kawai_candidates(self, groups): + atoms = self._atoms + bonds = self._bonds + clusters = [{n} | bonds[n].keys() for n, a in atoms.items() if a.in_ring] + clusters.extend(g | {m for n in g for m in bonds[n]} for g in groups) # add layouted groups + solved = [] + while clusters: + c1 = clusters.pop() + for c2 in clusters: + if not c1.isdisjoint(c2): + c2.update(c1) + break + else: + if c1 not in groups: + solved.append(c1) + return solved + + def _apply_kamada_kawai(self, system, groups): + atoms = self._atoms + bonds = self._bonds def _fix_plane_mean(self: 'MoleculeContainer', shift_x: float, shift_y=0., component=None) -> float: atoms = self._atoms diff --git a/chython/algorithms/rings.py b/chython/algorithms/rings.py index d4b9c1c2..33328636 100644 --- a/chython/algorithms/rings.py +++ b/chython/algorithms/rings.py @@ -108,45 +108,6 @@ def skin_graph(self: 'MoleculeContainer') -> Dict[int, Set[int]]: """ return _skin_graph(self._bonds) - @cached_property - def rings_graph(self: 'MoleculeContainer'): - """ - Graph of rings. Linkers are not included. Special bonds are considered. - """ - bonds = self.skin_graph - if not bonds: - return bonds - - in_rings = set() - atoms = set(bonds) - while atoms: - stack = deque([(atoms.pop(), 0, 0)]) - path = [] - seen = set() - while stack: - c, p, d = stack.pop() - if len(path) > d: - path = path[:d] - if c in in_rings: - continue - path.append(c) - seen.add(c) - - d += 1 - for n in bonds[c]: - if n == p: - continue - elif n in seen: - in_rings.update(path[path.index(n):]) - else: - stack.append((n, c, d)) - - atoms.difference_update(seen) - for n in bonds.keys() - in_rings: - for m in bonds.pop(n): - bonds[m].discard(n) - return bonds - def _sssr(bonds: Dict[int, Union[Set[int], Dict[int, Any]]], n_sssr: int) -> List[Tuple[int, ...]]: """ From 854aa59f372646b6dec197b7e3fde90be9305447 Mon Sep 17 00:00:00 2001 From: stsouko Date: Sat, 28 Dec 2024 15:41:25 +0100 Subject: [PATCH 60/67] Refactor vector operations and coordinate management. Restructure the `Vector` class implementation, introduce a `_xy` attribute for atom coordinates, and replace individual `x, y` attributes with the `Vector` class for better encapsulation. Update relevant methods, algorithms, and modules to utilize the new design with improved maintainability and clarity in operations. --- chython/algorithms/calculate2d/__init__.py | 216 ++++++++++++++++-- chython/containers/_pack_v2.pyx | 4 +- chython/containers/_unpack_v0v2.pyx | 9 +- chython/periodictable/base/element.py | 28 +-- .../base/vector.py} | 25 +- 5 files changed, 230 insertions(+), 52 deletions(-) rename chython/{algorithms/calculate2d/_vector.py => periodictable/base/vector.py} (96%) diff --git a/chython/algorithms/calculate2d/__init__.py b/chython/algorithms/calculate2d/__init__.py index 006776b0..00135208 100644 --- a/chython/algorithms/calculate2d/__init__.py +++ b/chython/algorithms/calculate2d/__init__.py @@ -17,16 +17,27 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from math import isnan, nan +from math import isnan, nan, radians from typing import TYPE_CHECKING, Union from ._templates import rules +from ...exceptions import ImplementationError +from ...periodictable.base.vector import Vector if TYPE_CHECKING: from chython import ReactionContainer, MoleculeContainer +SINGLE = 1 +DOUBLE = 2 # double bond BL = .825 +D0 = 0 +D30 = radians(30) +D60 = radians(60) +D90 = radians(90) +D120 = radians(120) +D180 = radians(180) +D360 = radians(360) class Calculate2DMolecule: @@ -44,17 +55,14 @@ def clean2d(self: Union['MoleculeContainer', 'Calculate2DMolecule']): for component in self.connected_components: if len(component) == 2: # 2-atom mols always stored horizontally n, m = component - a = self._atoms[n] - a._x = a._y = 0 - a = self._atoms[m] - a._x, a._y = BL, 0 + atoms[n].xy = (0., 0.) + atoms[m].xy = (BL, 0.) tail.insert(0, component) elif len(component) > 2: components.append(component) # mark atoms as non-positioned for n in component: - a = atoms[n] - a._x = a._y = nan + atoms[n].xy = (nan, nan) else: # len == 1: just a dot. no need for layout calculation tail.append(component) @@ -67,7 +75,7 @@ def clean2d(self: Union['MoleculeContainer', 'Calculate2DMolecule']): for component in components: if any(isnan(atoms[n].x) for n in component): - ... # linkers and trees + self._position_atoms(component) components.extend(tail) shift_x = 0 @@ -75,6 +83,183 @@ def clean2d(self: Union['MoleculeContainer', 'Calculate2DMolecule']): shift_x = self._fix_plane_mean(shift_x, component=component) + .9 self.__dict__.pop('__cached_method__repr_svg_', None) + def _position_atoms(self: 'MoleculeContainer', component): + atoms = self._atoms + bonds = self._bonds + ctc = self._stereo_cis_trans_centers + ctt = self._stereo_cis_trans_terminals + cte = self.stereogenic_cis_trans + # prepare starting points. pick previous atom for angle calculation + stack = [] + for n in component: + an = atoms[n] + if an.in_ring or isnan(an.x): + continue + # KK/template layouted non-ring atom + # we always have at least 1 layouted neighbor + m = next(m for m in bonds[n] if not isnan(atoms[m].x)) + am = atoms[m] + angle = am.xy.angle(an.xy) + # high priority + stack.append((n, m, angle, -1)) + if not stack: + # pick any terminal atom. we have tree-like molecule without rings. + # we for sure have at least 3 atoms in a row, thus, we have to layout at least 1 extra atom. + for n in component: + ms = bonds[n] + if len(ms) == 1: + m = next(iter(ms)) + atoms[n].xy = (0., 0.) + atoms[m].xy = Vector(BL, 0).rotate(D30) # place second atom always top-right + stack.append((m, n, D30, -1)) + break # 1 is enough + + while stack: + current, previous, angle, sign = stack.pop() + + env = bonds[current] + if len(env) == 1: + # layouting of the branch/molecule is finished + continue + + ac = atoms[current] + + # chiral cis-trans case. + if env[previous] == DOUBLE and (b := ctc.get(current)) and (s := self.bond(*b).stereo) is not None: + # cis-trans case. we came from a cumulene chain, thus, we have one layouted end + t1, t2 = ts = ctt[current] + n11, n21, n12, n22 = cte[ts] + + if len(env) == 3: + n1, n2 = (n for n in env if n != previous) + else: # env == 2 + n1 = next(n for n in env if n != previous) + n2 = None + + if n1 == n11: # picked 1st atom. no need to switch stereo sigh + if not isnan(atoms[n21].x): + m = n21 # picked 1st atom. no need to switch stereo sign + elif not isnan(atoms[n22].x): # stereo sign switch + m = n22 + s = not s + else: + raise ImplementationError + counter = t2 + elif n1 == n12: # picked 2nd atom. stereo sign switch + if not isnan(atoms[n21].x): + m = n21 + s = not s + elif not isnan(atoms[n22].x): # picked 2nd atom. double stereo-switch. keep as is. + m = n22 + else: + raise ImplementationError + counter = t2 + elif n1 == n21: + if not isnan(atoms[n11].x): + m = n11 + elif not isnan(atoms[n12].x): + m = n12 + s = not s + else: + raise ImplementationError + counter = t1 + else: + if not isnan(atoms[n11].x): + m = n11 + s = not s + elif not isnan(atoms[n12].x): + m = n12 + else: + raise ImplementationError + counter = t1 + + vt = atoms[counter].xy + if (atoms[m].xy - vt) @ (ac.xy - vt) > 0: + sign = 1 if s else -1 + else: + sign = -1 if s else 1 + + angle -= sign * D60 + stack.append((n1, current, angle, sign)) + xy = ac.xy + Vector(BL, 0).rotate(angle) + an = atoms[n1] + if not isnan(an.x): + # reached layouted fragment. rotate the whole fragment and drop chain. + stack.pop() + raise NotImplementedError + else: + an.xy = xy + if n2: + angle += sign * D120 + stack.append((n2, current, angle, -sign)) + xy = ac.xy + Vector(BL, 0).rotate(angle) + an = atoms[n2] + if not isnan(an.x): + # reached layouted fragment. rotate the whole fragment and drop chain. + stack.pop() + raise NotImplementedError + else: + an.xy = xy + + # simple non-chiral cases + elif len(env) == 2: + n = next(n for n in env if n != previous) + if ac.hybridization == 3: + # keep the same direction + stack.append((n, current, angle, sign)) + else: + angle += sign * D60 + stack.append((n, current, angle, -sign)) + xy = ac.xy + Vector(BL, 0).rotate(angle) + + an = atoms[n] + if not isnan(an.x): + # reached layouted fragment. rotate the whole fragment and drop chain. + stack.pop() + raise NotImplementedError + else: + an.xy = xy + elif len(env) == 3: + n, m = (n for n in env if n != previous) + # continue to grow to the same direction + angle += sign * D60 + stack.append((n, current, angle, -sign)) + xy = ac.xy + Vector(BL, 0).rotate(angle) + an = atoms[n] + if not isnan(an.x): + # reached layouted fragment. rotate the whole fragment and drop chain. + stack.pop() + raise NotImplementedError + else: + an.xy = xy + + # make a side branch + angle -= sign * D120 + stack.append((m, current, angle, sign)) + xy = ac.xy + Vector(BL, 0).rotate(angle) + am = atoms[m] + if not isnan(am.x): + # reached layouted fragment. rotate the whole fragment and drop chain. + stack.pop() + raise NotImplementedError + else: + am.xy = xy + else: # 4+ neighbors. position on circle + delta = D360 / len(env) + angle += D180 + for n in env: + if n != previous: + angle += delta + xy = ac.xy + Vector(BL, 0).rotate(angle) + stack.append((n, current, angle, sign)) # keep sign to minimize overlaps + an = atoms[n] + if not isnan(an.x): + # reached layouted fragment. rotate the whole fragment and drop chain. + stack.pop() + raise NotImplementedError + else: + an.xy = xy + def _apply_2d_templates(self): atoms = self._atoms seen = set() @@ -86,8 +271,7 @@ def _apply_2d_templates(self): seen.update(m.values()) groups.append(set(m.values())) for n, xy in zip(m.values(), layout): - atom = atoms[n] - atom._x, atom._y = xy + atoms[n].xy = xy return groups def _kamada_kawai_candidates(self, groups): @@ -128,9 +312,9 @@ def _fix_plane_mean(self: 'MoleculeContainer', shift_x: float, shift_y=0., compo max_y = max(atoms[x].y for x in component) mean_y = (max_y + min_y) / 2 - shift_y for n in component: - a = atoms[n] - a._x -= min_x - a._y -= mean_y + a = atoms[n].xy + a.x -= min_x + a.y -= mean_y if -.18 <= right_atom.y <= .18: factor = right_atom.implicit_hydrogens @@ -151,9 +335,9 @@ def _fix_plane_min(self: 'MoleculeContainer', shift_x: float, shift_y=0., compon min_y = min(atoms[x].y for x in component) - shift_y for n in component: - a = atoms[n] - a._x -= min_x - a._y -= min_y + a = atoms[n].xy + a.x -= min_x + a.y -= min_y if shift_y - .18 <= right_atom.y <= shift_y + .18: factor = right_atom.implicit_hydrogens diff --git a/chython/containers/_pack_v2.pyx b/chython/containers/_pack_v2.pyx index 6e2a8b19..744caac8 100644 --- a/chython/containers/_pack_v2.pyx +++ b/chython/containers/_pack_v2.pyx @@ -154,8 +154,8 @@ def pack(object molecule): data[atoms_shift + 3] = isotope << 7 | atomic_number # 1bI , A # 2 float16 big endian - double_to_float16(py_atom._x, &data[atoms_shift + 4]) - double_to_float16(py_atom._y, &data[atoms_shift + 6]) + double_to_float16(py_atom.x, &data[atoms_shift + 4]) + double_to_float16(py_atom.y, &data[atoms_shift + 6]) data[atoms_shift + 8] = hcr atoms_shift += 9 diff --git a/chython/containers/_unpack_v0v2.pyx b/chython/containers/_unpack_v0v2.pyx index 80ab6c59..1f19aa21 100644 --- a/chython/containers/_unpack_v0v2.pyx +++ b/chython/containers/_unpack_v0v2.pyx @@ -29,6 +29,7 @@ from chython.periodictable import (H, He, Li, Be, B, C, N, O, F, Ne, Na, Mg, Al, Ho, Er, Tm, Yb, Lu, Hf, Ta, W, Re, Os, Ir, Pt, Au, Hg, Tl, Pb, Bi, Po, At, Rn, Fr, Ra, Ac, Th, Pa, U, Np, Pu, Am, Cm, Bk, Cf, Es, Fm, Md, No, Lr, Rf, Db, Sg, Bh, Hs, Mt, Ds, Rg, Cn, Nh, Fl, Mc, Lv, Ts, Og) +from chython.periodictable.base.vector import Vector # Format specification:: @@ -72,7 +73,7 @@ def unpack(const unsigned char[::1] data not None): cdef unsigned int size, atoms_shift = 4, bonds_shift, order_shift, cis_trans_shift cdef unsigned char[4096] seen - cdef object py_mol, py_bond, py_n, py_m, py_atom, py_nan_bool + cdef object py_mol, py_bond, py_n, py_m, py_atom, py_nan_bool, py_vector cdef dict py_atoms, py_bonds, py_ngb cdef list py_cis_trans @@ -127,10 +128,12 @@ def unpack(const unsigned char[::1] data not None): else: py_atom._isotope = None + py_vector = object.__new__(Vector) a, b = data[atoms_shift + 4], data[atoms_shift + 5] - py_atom._x = double_from_bytes(a, b) + py_vector.x = double_from_bytes(a, b) a, b = data[atoms_shift + 6], data[atoms_shift + 7] - py_atom._y = double_from_bytes(a, b) + py_vector.y = double_from_bytes(a, b) + py_atom._xy = py_vector a = data[atoms_shift + 8] hydrogens = a >> 5 diff --git a/chython/periodictable/base/element.py b/chython/periodictable/base/element.py index 1d066f07..59f54bfa 100644 --- a/chython/periodictable/base/element.py +++ b/chython/periodictable/base/element.py @@ -20,11 +20,12 @@ from CachedMethods import class_cached_property from collections import defaultdict from typing import Dict, List, Optional, Set, Tuple, Type +from .vector import Vector from ...exceptions import ValenceError class Element(ABC): - __slots__ = ('_isotope', '_charge', '_is_radical', '_x', '_y', '_implicit_hydrogens', + __slots__ = ('_isotope', '_charge', '_is_radical', '_xy', '_implicit_hydrogens', '_explicit_hydrogens', '_stereo', '_parsed_mapping', '_neighbors', '_heteroatoms', '_hybridization', '_ring_sizes', '_in_ring') __class_cache__ = {} @@ -45,7 +46,7 @@ def __init__(self, isotope: Optional[int] = None, *, self.isotope = isotope self.charge = charge self.is_radical = is_radical - self.x, self.y = x, y + self._xy = Vector(x, y) self._implicit_hydrogens = implicit_hydrogens self._stereo = stereo @@ -179,42 +180,33 @@ def x(self) -> float: """ X coordinate of atom on 2D plane """ - return self._x + return self._xy.x @x.setter def x(self, value: float): - if not isinstance(value, float): - raise TypeError('float expected') - self._x = value + self._xy.x = value @property def y(self) -> float: """ Y coordinate of atom on 2D plane """ - return self._y + return self._xy.y @y.setter def y(self, value: float): - if not isinstance(value, float): - raise TypeError('float expected') - self._y = value + self._xy.y = value @property - def xy(self) -> Tuple[float, float]: + def xy(self) -> Vector: """ (X, Y) coordinates of atom on 2D plane """ - return self._x, self._y + return self._xy @xy.setter def xy(self, value: Tuple[float, float]): - if (not isinstance(value, (tuple, list)) - or len(value) != 2 - or not isinstance(value[0], float) - or not isinstance(value[1], float)): - raise TypeError('tuple of 2 floats expected') - self._x, self._y = value + self._xy = Vector(*value) @property def implicit_hydrogens(self) -> Optional[int]: diff --git a/chython/algorithms/calculate2d/_vector.py b/chython/periodictable/base/vector.py similarity index 96% rename from chython/algorithms/calculate2d/_vector.py rename to chython/periodictable/base/vector.py index 3ced4c17..366cd4b3 100644 --- a/chython/algorithms/calculate2d/_vector.py +++ b/chython/periodictable/base/vector.py @@ -19,23 +19,19 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from dataclasses import dataclass from math import cos, sin, hypot, atan2 from typing import List -@dataclass(frozen=True, slots=True) class Vector: - """ - The `Vector` class facilitates operations with coordinates, including vector arithmetic - (addition, subtraction, multiplication/division by scalars), normalization, rotation, and - distance calculations. It also supports methods for determining the angle of a vector, its - length, and whether it lies in a certain quadrant. Additionally, it includes functions for - reflecting vectors about lines, finding the closest atom or point, and rotating vectors around - other vectors or points. - """ - x: float - y: float + __slots__ = ('x', 'y') + + def __init__(self, x: float = 0., y: float = 0.): + self.x = x + self.y = y + + def __repr__(self): + return f'Vector({self.x}, {self.y})' def __neg__(self): """ @@ -79,6 +75,9 @@ def __iter__(self): yield self.x yield self.y + def __matmul__(self, vector): + return self.x * vector.y - self.y * vector.x + def rotate(self, angle: float): """ A method that rotates the vector by the angle in radians @@ -103,7 +102,7 @@ def angle(self, vector: 'Vector' = None) -> float: if vector is None: return atan2(self.y, self.x) else: - return atan2(self.y - vector.y, self.x - vector.x) + return atan2(vector.y - self.y, vector.x - self.x) From a1ea74aee9bd0bcdf1b105f3524cd1f8a2e24737 Mon Sep 17 00:00:00 2001 From: stsouko Date: Tue, 31 Dec 2024 16:26:09 +0100 Subject: [PATCH 61/67] kamada-kawai implemented. todo: improve code. --- chython/algorithms/calculate2d/__init__.py | 134 +++++++++++++++++-- chython/algorithms/calculate2d/_templates.py | 46 ++----- chython/containers/molecule.py | 3 +- chython/periodictable/base/vector.py | 11 +- 4 files changed, 139 insertions(+), 55 deletions(-) diff --git a/chython/algorithms/calculate2d/__init__.py b/chython/algorithms/calculate2d/__init__.py index 00135208..b0a7c6dd 100644 --- a/chython/algorithms/calculate2d/__init__.py +++ b/chython/algorithms/calculate2d/__init__.py @@ -1,6 +1,9 @@ # -*- coding: utf-8 -*- # # Copyright 2019-2024 Ramil Nugmanov +# Copyright 2024 Denis Lipatov +# Copyright 2024 Vyacheslav Grigorev +# Copyright 2024 Timur Gimadiev # Copyright 2019, 2020 Dinar Batyrshin # This file is part of chython. # @@ -17,7 +20,10 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # +from itertools import combinations from math import isnan, nan, radians +from numpy import zeros, linspace, column_stack, sin, cos, sqrt, nan_to_num, argmax, errstate +from scipy.sparse.csgraph import shortest_path from typing import TYPE_CHECKING, Union from ._templates import rules from ...exceptions import ImplementationError @@ -31,6 +37,7 @@ SINGLE = 1 DOUBLE = 2 # double bond BL = .825 +RADIUS = 500 D0 = 0 D30 = radians(30) D60 = radians(60) @@ -43,7 +50,9 @@ class Calculate2DMolecule: __slots__ = () - def clean2d(self: Union['MoleculeContainer', 'Calculate2DMolecule']): + def clean2d(self: Union['MoleculeContainer', 'Calculate2DMolecule'], *, + kk_outer_iterations: int = 1000, kk_outer_threshold: float =.1, + kk_inner_iterations: int = 50, kk_inner_threshold: float =.1): """ Calculate 2d layout of graph. https://pubs.acs.org/doi/10.1021/acs.jcim.7b00425 JS implementation used as a reference. @@ -70,12 +79,12 @@ def clean2d(self: Union['MoleculeContainer', 'Calculate2DMolecule']): # preset coordinates with templates groups = self._apply_2d_templates() # apply KK to fix environment or align groups or process unmatched rings - for kk in self._kamada_kawai_candidates(groups): - self._apply_kamada_kawai(kk, [g for g in groups if not g.isdisjoint(kk)]) + fragments = self._apply_kamada_kawai(groups, kk_outer_iterations, kk_inner_iterations, + kk_outer_threshold, kk_inner_threshold) for component in components: if any(isnan(atoms[n].x) for n in component): - self._position_atoms(component) + self._position_atoms(component, fragments) components.extend(tail) shift_x = 0 @@ -83,7 +92,7 @@ def clean2d(self: Union['MoleculeContainer', 'Calculate2DMolecule']): shift_x = self._fix_plane_mean(shift_x, component=component) + .9 self.__dict__.pop('__cached_method__repr_svg_', None) - def _position_atoms(self: 'MoleculeContainer', component): + def _position_atoms(self: 'MoleculeContainer', component, fragments): atoms = self._atoms bonds = self._bonds ctc = self._stereo_cis_trans_centers @@ -264,17 +273,80 @@ def _apply_2d_templates(self): atoms = self._atoms seen = set() groups = [] + shift_x = max_x = 0 for q, layout in rules: for m in q.get_mapping(self, automorphism_filter=False): if not seen.isdisjoint(m.values()): # avoid any overlap continue seen.update(m.values()) groups.append(set(m.values())) - for n, xy in zip(m.values(), layout): - atoms[n].xy = xy + for i, n in m.items(): + x, y = layout[i - 1] + x += shift_x # keep fragments separated on plane + if x > max_x: + max_x = x + atoms[n].xy = (x, y) + shift_x = max_x + 1 return groups - def _kamada_kawai_candidates(self, groups): + def _apply_kamada_kawai(self, groups, outer_iterations, inner_iterations, outer_threshold, inner_threshold): + atoms = self._atoms + + solved = [] + for cluster, length, strength, coordinates, mapping in self._initialize_kamada_kawai(groups): + pi = -1 + for _ in range(outer_iterations): + diff = coordinates[:, None, :] - coordinates[None, :, :] # NxNx2 + sdiff = diff * diff + energy = diff * (strength * (1 - length / (sqrt(sdiff.sum(-1)) + 1e-5)))[:, :, None] # NxNx2 + forces = energy.sum(1) # Nx2 + total = (forces ** 2).sum(-1) # N + + # pick an atom with the highest force/energy + i = argmax(total) + if i == pi: + total[i] = 0 + i = argmax(total) + pi = i + if total[i] <= outer_threshold: + # if it less than threshold, we have solved system. finish. + break + + li = length[i] # N + si = strength[i] # N + diff_i = diff[i] # Nx2 + sdiff_i = sdiff[i] # Nx2 + for _ in range(inner_iterations): + norm = li / (sdiff_i.sum(-1) ** 1.5 + 1e-5) + dxx, dyy = (si[:, None] * (1 - norm[:, None] * sdiff_i)).sum(0).tolist() + dxy = float((si * norm * diff_i.prod(-1)).sum()) + if abs(dxy) < 0.1: + dxy = 0.1 if dxy > 0 else -0.1 + if abs(dxx) < 0.1: + dxx = 0.1 if dxx > 0 else -0.1 + + d_ex, d_ey = forces[i].tolist() + dy = (d_ex / dxx + d_ey / dxy) / (dxy / dxx - dyy / dxy) + dx = -(dxy * dy + d_ex) / dxx + coordinates[i] += (dx, dy) + + # update forces + diff_i = coordinates[i] - coordinates # Nx2 + sdiff_i = diff_i * diff_i # Nx2 + energy_i = diff_i * (si * (1 - li / (sqrt(sdiff_i.sum(-1)) + 1e-5)))[:, None] # Nx2 + forces[i] = energy_i.sum(0) # 2 + total[i] = (forces[i] ** 2).sum() # 1 + + if total[i] <= inner_threshold: + # local minima for i-th atom found. + break + + for n in cluster: + atoms[n].xy = coordinates[mapping[n]].tolist() + solved.append(cluster) + return solved + + def _initialize_kamada_kawai(self, groups): atoms = self._atoms bonds = self._bonds clusters = [{n} | bonds[n].keys() for n, a in atoms.items() if a.in_ring] @@ -287,13 +359,47 @@ def _kamada_kawai_candidates(self, groups): c2.update(c1) break else: - if c1 not in groups: - solved.append(c1) - return solved + if c1 in groups: + continue + solved.append(c1) - def _apply_kamada_kawai(self, system, groups): - atoms = self._atoms - bonds = self._bonds + for cluster in solved: + mapping = {n: i for i, n in enumerate(cluster)} + + adj = zeros((len(cluster), len(cluster))) + angles = linspace(0, D360, len(cluster) + 1)[:-1] + coordinates = column_stack([cos(angles), sin(angles)]) * RADIUS + + # create adjacency matrix + for n in cluster: + i = mapping[n] + for m in bonds[n].keys() & cluster: + j = mapping[m] + adj[i, j] = BL + + layouted = [] + for g in groups: + if g.isdisjoint(cluster): + continue + # for pre-layouted groups calc pairwise distances + for n, m in combinations(g, 2): + d = atoms[n].xy | atoms[m].xy + i, j = mapping[n], mapping[m] + adj[i, j] = adj[j, i] = d + layouted.extend(g) + length = shortest_path(adj, method='FW', directed=False) + # originally used BL / (topological distance)**2 + # here distance is already BL scaled: BL**3 / (BL*TD)**2 = BL**3 / BL**2 / TD **2 = BL / TD ** 2 + # but we have prelayouted atoms with bonds != BL. Let's just assume they are close enough. + # adj magic here to reset strength of layouted groups to actual distances. + with errstate(divide='ignore'): + strength = nan_to_num(BL**3 / (length ** 2), posinf=0) * (adj == 0) + adj + + if layouted: + center = sum((atoms[n].xy for n in layouted), Vector(0, 0)) / len(layouted) + for n in layouted: + coordinates[mapping[n], :] = tuple(atoms[n].xy - center) + yield cluster, length, strength, coordinates, mapping def _fix_plane_mean(self: 'MoleculeContainer', shift_x: float, shift_y=0., component=None) -> float: atoms = self._atoms diff --git a/chython/algorithms/calculate2d/_templates.py b/chython/algorithms/calculate2d/_templates.py index f1f9d887..1d13a20b 100644 --- a/chython/algorithms/calculate2d/_templates.py +++ b/chython/algorithms/calculate2d/_templates.py @@ -19,49 +19,19 @@ from lazy_object_proxy import Proxy +def aligner(xy): + x0, y0 = min(xy, key=lambda x: x[0]) + return [(round(x - x0, 4), round(y - y0, 4)) for x, y in xy] + + def _rules(): from ... import smarts rules = [] - - # C - # / \ - # C C - # | | - # C C - # \ / - # C - q = smarts('[A;r6]!#;@1!#;@[A]!#;@[A]!#;@[A]!#;@[A]!#;@[A]1') - xy = [(0, 2.31), (0, 0.77), (1.3337, 0), (2.6674, 0.77), (2.6674, 2.31), (1.3337, 3.08)] - rules.append((q, xy)) - - # - # C-,=C - # || || - # C C - # \ / - # C1 - q = smarts('[A;r5]-,:;@1-,:;@[A]!#;@[A]!#;@[A]!#;@[A]1') - xy = [(1.2459, 0), (0, 0.9052), (0.4759, 2.3698), (2.0159, 2.3698), (2.4918, 0.9052)] - rules.append((q, xy)) - - # - # C=,-C - # \ / - # C - # - q = smarts('[A;r3]1-,=;@[A]-;@[A]1') - xy = [(0, 0), (1.54, 0), (.77, -1.333)] - rules.append((q, xy)) - - # - # C=,-C - # | | - # C=,-C - # - q = smarts('[A;r4]-;@1-,=;@[A]-;@[A]-,=;@[A]1') - xy = [(0, 0), (1.54, 0), (1.54, -1.54), (0, -1.54)] + # bicyclo[1.1.1]pentane + q = smarts('[A;r4;D2:2]-1-[A;D3,D4:1]-2-[A;r4;D2:5]-[A;D3,D4:3]-1-[A;r4;D2:4]-2') + xy = [(0.0, 0.0), (0.6674, 0.485), (1.3348, 0.0), (1.0799, -0.7846), (0.2549, -0.7846)] rules.append((q, xy)) return rules diff --git a/chython/containers/molecule.py b/chython/containers/molecule.py index d16d6e40..df8b1017 100644 --- a/chython/containers/molecule.py +++ b/chython/containers/molecule.py @@ -19,6 +19,7 @@ from CachedMethods import cached_args_method from collections import Counter, defaultdict from functools import cached_property +from numpy import uint, zeros from typing import Dict, Iterable, List, Optional, Tuple, Union from zlib import compress, decompress from .bonds import Bond, DynamicBond, QueryBond @@ -106,8 +107,6 @@ def adjacency_matrix(self, set_bonds=False, /): :param set_bonds: if True set bond orders instead of 1. """ - from numpy import uint, zeros - adj = zeros((len(self), len(self)), dtype=uint) mapping = {n: x for x, n in enumerate(self._atoms)} if set_bonds: diff --git a/chython/periodictable/base/vector.py b/chython/periodictable/base/vector.py index 366cd4b3..c4fcf37a 100644 --- a/chython/periodictable/base/vector.py +++ b/chython/periodictable/base/vector.py @@ -75,9 +75,18 @@ def __iter__(self): yield self.x yield self.y - def __matmul__(self, vector): + def __len__(self): + return 2 + + def __matmul__(self, vector: 'Vector'): return self.x * vector.y - self.y * vector.x + def __or__(self, vector: 'Vector'): + """ + Calculate distance between two vectors + """ + return hypot(vector.x - self.x, vector.y - self.y) + def rotate(self, angle: float): """ A method that rotates the vector by the angle in radians From e99358a2ffd7134a35cbea55b24132154693d4ed Mon Sep 17 00:00:00 2001 From: stsouko Date: Wed, 1 Jan 2025 15:52:21 +0100 Subject: [PATCH 62/67] package refactored --- chython/algorithms/calculate2d/__init__.py | 492 +----------------- chython/algorithms/calculate2d/_templates.py | 6 + chython/algorithms/calculate2d/molecule.py | 497 +++++++++++++++++++ chython/algorithms/calculate2d/reaction.py | 80 +++ 4 files changed, 585 insertions(+), 490 deletions(-) create mode 100644 chython/algorithms/calculate2d/molecule.py create mode 100644 chython/algorithms/calculate2d/reaction.py diff --git a/chython/algorithms/calculate2d/__init__.py b/chython/algorithms/calculate2d/__init__.py index b0a7c6dd..be3857dd 100644 --- a/chython/algorithms/calculate2d/__init__.py +++ b/chython/algorithms/calculate2d/__init__.py @@ -1,10 +1,6 @@ # -*- coding: utf-8 -*- # # Copyright 2019-2024 Ramil Nugmanov -# Copyright 2024 Denis Lipatov -# Copyright 2024 Vyacheslav Grigorev -# Copyright 2024 Timur Gimadiev -# Copyright 2019, 2020 Dinar Batyrshin # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -20,492 +16,8 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from itertools import combinations -from math import isnan, nan, radians -from numpy import zeros, linspace, column_stack, sin, cos, sqrt, nan_to_num, argmax, errstate -from scipy.sparse.csgraph import shortest_path -from typing import TYPE_CHECKING, Union -from ._templates import rules -from ...exceptions import ImplementationError -from ...periodictable.base.vector import Vector - - -if TYPE_CHECKING: - from chython import ReactionContainer, MoleculeContainer - - -SINGLE = 1 -DOUBLE = 2 # double bond -BL = .825 -RADIUS = 500 -D0 = 0 -D30 = radians(30) -D60 = radians(60) -D90 = radians(90) -D120 = radians(120) -D180 = radians(180) -D360 = radians(360) - - -class Calculate2DMolecule: - __slots__ = () - - def clean2d(self: Union['MoleculeContainer', 'Calculate2DMolecule'], *, - kk_outer_iterations: int = 1000, kk_outer_threshold: float =.1, - kk_inner_iterations: int = 50, kk_inner_threshold: float =.1): - """ - Calculate 2d layout of graph. - https://pubs.acs.org/doi/10.1021/acs.jcim.7b00425 JS implementation used as a reference. - """ - atoms = self._atoms - bonds = self._bonds - components = [] - tail = [] # small components pushed to the right for better visuality - for component in self.connected_components: - if len(component) == 2: # 2-atom mols always stored horizontally - n, m = component - atoms[n].xy = (0., 0.) - atoms[m].xy = (BL, 0.) - tail.insert(0, component) - elif len(component) > 2: - components.append(component) - # mark atoms as non-positioned - for n in component: - atoms[n].xy = (nan, nan) - else: # len == 1: just a dot. no need for layout calculation - tail.append(component) - - if components: - # preset coordinates with templates - groups = self._apply_2d_templates() - # apply KK to fix environment or align groups or process unmatched rings - fragments = self._apply_kamada_kawai(groups, kk_outer_iterations, kk_inner_iterations, - kk_outer_threshold, kk_inner_threshold) - - for component in components: - if any(isnan(atoms[n].x) for n in component): - self._position_atoms(component, fragments) - - components.extend(tail) - shift_x = 0 - for component in components: - shift_x = self._fix_plane_mean(shift_x, component=component) + .9 - self.__dict__.pop('__cached_method__repr_svg_', None) - - def _position_atoms(self: 'MoleculeContainer', component, fragments): - atoms = self._atoms - bonds = self._bonds - ctc = self._stereo_cis_trans_centers - ctt = self._stereo_cis_trans_terminals - cte = self.stereogenic_cis_trans - # prepare starting points. pick previous atom for angle calculation - stack = [] - for n in component: - an = atoms[n] - if an.in_ring or isnan(an.x): - continue - # KK/template layouted non-ring atom - # we always have at least 1 layouted neighbor - m = next(m for m in bonds[n] if not isnan(atoms[m].x)) - am = atoms[m] - angle = am.xy.angle(an.xy) - # high priority - stack.append((n, m, angle, -1)) - if not stack: - # pick any terminal atom. we have tree-like molecule without rings. - # we for sure have at least 3 atoms in a row, thus, we have to layout at least 1 extra atom. - for n in component: - ms = bonds[n] - if len(ms) == 1: - m = next(iter(ms)) - atoms[n].xy = (0., 0.) - atoms[m].xy = Vector(BL, 0).rotate(D30) # place second atom always top-right - stack.append((m, n, D30, -1)) - break # 1 is enough - - while stack: - current, previous, angle, sign = stack.pop() - - env = bonds[current] - if len(env) == 1: - # layouting of the branch/molecule is finished - continue - - ac = atoms[current] - - # chiral cis-trans case. - if env[previous] == DOUBLE and (b := ctc.get(current)) and (s := self.bond(*b).stereo) is not None: - # cis-trans case. we came from a cumulene chain, thus, we have one layouted end - t1, t2 = ts = ctt[current] - n11, n21, n12, n22 = cte[ts] - - if len(env) == 3: - n1, n2 = (n for n in env if n != previous) - else: # env == 2 - n1 = next(n for n in env if n != previous) - n2 = None - - if n1 == n11: # picked 1st atom. no need to switch stereo sigh - if not isnan(atoms[n21].x): - m = n21 # picked 1st atom. no need to switch stereo sign - elif not isnan(atoms[n22].x): # stereo sign switch - m = n22 - s = not s - else: - raise ImplementationError - counter = t2 - elif n1 == n12: # picked 2nd atom. stereo sign switch - if not isnan(atoms[n21].x): - m = n21 - s = not s - elif not isnan(atoms[n22].x): # picked 2nd atom. double stereo-switch. keep as is. - m = n22 - else: - raise ImplementationError - counter = t2 - elif n1 == n21: - if not isnan(atoms[n11].x): - m = n11 - elif not isnan(atoms[n12].x): - m = n12 - s = not s - else: - raise ImplementationError - counter = t1 - else: - if not isnan(atoms[n11].x): - m = n11 - s = not s - elif not isnan(atoms[n12].x): - m = n12 - else: - raise ImplementationError - counter = t1 - - vt = atoms[counter].xy - if (atoms[m].xy - vt) @ (ac.xy - vt) > 0: - sign = 1 if s else -1 - else: - sign = -1 if s else 1 - - angle -= sign * D60 - stack.append((n1, current, angle, sign)) - xy = ac.xy + Vector(BL, 0).rotate(angle) - an = atoms[n1] - if not isnan(an.x): - # reached layouted fragment. rotate the whole fragment and drop chain. - stack.pop() - raise NotImplementedError - else: - an.xy = xy - if n2: - angle += sign * D120 - stack.append((n2, current, angle, -sign)) - xy = ac.xy + Vector(BL, 0).rotate(angle) - an = atoms[n2] - if not isnan(an.x): - # reached layouted fragment. rotate the whole fragment and drop chain. - stack.pop() - raise NotImplementedError - else: - an.xy = xy - - # simple non-chiral cases - elif len(env) == 2: - n = next(n for n in env if n != previous) - if ac.hybridization == 3: - # keep the same direction - stack.append((n, current, angle, sign)) - else: - angle += sign * D60 - stack.append((n, current, angle, -sign)) - xy = ac.xy + Vector(BL, 0).rotate(angle) - - an = atoms[n] - if not isnan(an.x): - # reached layouted fragment. rotate the whole fragment and drop chain. - stack.pop() - raise NotImplementedError - else: - an.xy = xy - elif len(env) == 3: - n, m = (n for n in env if n != previous) - # continue to grow to the same direction - angle += sign * D60 - stack.append((n, current, angle, -sign)) - xy = ac.xy + Vector(BL, 0).rotate(angle) - an = atoms[n] - if not isnan(an.x): - # reached layouted fragment. rotate the whole fragment and drop chain. - stack.pop() - raise NotImplementedError - else: - an.xy = xy - - # make a side branch - angle -= sign * D120 - stack.append((m, current, angle, sign)) - xy = ac.xy + Vector(BL, 0).rotate(angle) - am = atoms[m] - if not isnan(am.x): - # reached layouted fragment. rotate the whole fragment and drop chain. - stack.pop() - raise NotImplementedError - else: - am.xy = xy - else: # 4+ neighbors. position on circle - delta = D360 / len(env) - angle += D180 - for n in env: - if n != previous: - angle += delta - xy = ac.xy + Vector(BL, 0).rotate(angle) - stack.append((n, current, angle, sign)) # keep sign to minimize overlaps - an = atoms[n] - if not isnan(an.x): - # reached layouted fragment. rotate the whole fragment and drop chain. - stack.pop() - raise NotImplementedError - else: - an.xy = xy - - def _apply_2d_templates(self): - atoms = self._atoms - seen = set() - groups = [] - shift_x = max_x = 0 - for q, layout in rules: - for m in q.get_mapping(self, automorphism_filter=False): - if not seen.isdisjoint(m.values()): # avoid any overlap - continue - seen.update(m.values()) - groups.append(set(m.values())) - for i, n in m.items(): - x, y = layout[i - 1] - x += shift_x # keep fragments separated on plane - if x > max_x: - max_x = x - atoms[n].xy = (x, y) - shift_x = max_x + 1 - return groups - - def _apply_kamada_kawai(self, groups, outer_iterations, inner_iterations, outer_threshold, inner_threshold): - atoms = self._atoms - - solved = [] - for cluster, length, strength, coordinates, mapping in self._initialize_kamada_kawai(groups): - pi = -1 - for _ in range(outer_iterations): - diff = coordinates[:, None, :] - coordinates[None, :, :] # NxNx2 - sdiff = diff * diff - energy = diff * (strength * (1 - length / (sqrt(sdiff.sum(-1)) + 1e-5)))[:, :, None] # NxNx2 - forces = energy.sum(1) # Nx2 - total = (forces ** 2).sum(-1) # N - - # pick an atom with the highest force/energy - i = argmax(total) - if i == pi: - total[i] = 0 - i = argmax(total) - pi = i - if total[i] <= outer_threshold: - # if it less than threshold, we have solved system. finish. - break - - li = length[i] # N - si = strength[i] # N - diff_i = diff[i] # Nx2 - sdiff_i = sdiff[i] # Nx2 - for _ in range(inner_iterations): - norm = li / (sdiff_i.sum(-1) ** 1.5 + 1e-5) - dxx, dyy = (si[:, None] * (1 - norm[:, None] * sdiff_i)).sum(0).tolist() - dxy = float((si * norm * diff_i.prod(-1)).sum()) - if abs(dxy) < 0.1: - dxy = 0.1 if dxy > 0 else -0.1 - if abs(dxx) < 0.1: - dxx = 0.1 if dxx > 0 else -0.1 - - d_ex, d_ey = forces[i].tolist() - dy = (d_ex / dxx + d_ey / dxy) / (dxy / dxx - dyy / dxy) - dx = -(dxy * dy + d_ex) / dxx - coordinates[i] += (dx, dy) - - # update forces - diff_i = coordinates[i] - coordinates # Nx2 - sdiff_i = diff_i * diff_i # Nx2 - energy_i = diff_i * (si * (1 - li / (sqrt(sdiff_i.sum(-1)) + 1e-5)))[:, None] # Nx2 - forces[i] = energy_i.sum(0) # 2 - total[i] = (forces[i] ** 2).sum() # 1 - - if total[i] <= inner_threshold: - # local minima for i-th atom found. - break - - for n in cluster: - atoms[n].xy = coordinates[mapping[n]].tolist() - solved.append(cluster) - return solved - - def _initialize_kamada_kawai(self, groups): - atoms = self._atoms - bonds = self._bonds - clusters = [{n} | bonds[n].keys() for n, a in atoms.items() if a.in_ring] - clusters.extend(g | {m for n in g for m in bonds[n]} for g in groups) # add layouted groups - solved = [] - while clusters: - c1 = clusters.pop() - for c2 in clusters: - if not c1.isdisjoint(c2): - c2.update(c1) - break - else: - if c1 in groups: - continue - solved.append(c1) - - for cluster in solved: - mapping = {n: i for i, n in enumerate(cluster)} - - adj = zeros((len(cluster), len(cluster))) - angles = linspace(0, D360, len(cluster) + 1)[:-1] - coordinates = column_stack([cos(angles), sin(angles)]) * RADIUS - - # create adjacency matrix - for n in cluster: - i = mapping[n] - for m in bonds[n].keys() & cluster: - j = mapping[m] - adj[i, j] = BL - - layouted = [] - for g in groups: - if g.isdisjoint(cluster): - continue - # for pre-layouted groups calc pairwise distances - for n, m in combinations(g, 2): - d = atoms[n].xy | atoms[m].xy - i, j = mapping[n], mapping[m] - adj[i, j] = adj[j, i] = d - layouted.extend(g) - length = shortest_path(adj, method='FW', directed=False) - # originally used BL / (topological distance)**2 - # here distance is already BL scaled: BL**3 / (BL*TD)**2 = BL**3 / BL**2 / TD **2 = BL / TD ** 2 - # but we have prelayouted atoms with bonds != BL. Let's just assume they are close enough. - # adj magic here to reset strength of layouted groups to actual distances. - with errstate(divide='ignore'): - strength = nan_to_num(BL**3 / (length ** 2), posinf=0) * (adj == 0) + adj - - if layouted: - center = sum((atoms[n].xy for n in layouted), Vector(0, 0)) / len(layouted) - for n in layouted: - coordinates[mapping[n], :] = tuple(atoms[n].xy - center) - yield cluster, length, strength, coordinates, mapping - - def _fix_plane_mean(self: 'MoleculeContainer', shift_x: float, shift_y=0., component=None) -> float: - atoms = self._atoms - if component is None: - component = atoms - - left_atom = atoms[min(component, key=lambda x: atoms[x].x)] - right_atom = atoms[max(component, key=lambda x: atoms[x].x)] - - min_x = left_atom.x - shift_x - if len(left_atom.atomic_symbol) == 2: - min_x -= .2 - - max_x = right_atom.x - min_x - min_y = min(atoms[x].y for x in component) - max_y = max(atoms[x].y for x in component) - mean_y = (max_y + min_y) / 2 - shift_y - for n in component: - a = atoms[n].xy - a.x -= min_x - a.y -= mean_y - - if -.18 <= right_atom.y <= .18: - factor = right_atom.implicit_hydrogens - if factor == 1: - max_x += .15 - elif factor: - max_x += .25 - return max_x - - def _fix_plane_min(self: 'MoleculeContainer', shift_x: float, shift_y=0., component=None) -> float: - atoms = self._atoms - if component is None: - component = atoms - - right_atom = atoms[max(component, key=lambda x: atoms[x].x)] - min_x = min(atoms[x].x for x in component) - shift_x - max_x = right_atom.x - min_x - min_y = min(atoms[x].y for x in component) - shift_y - - for n in component: - a = atoms[n].xy - a.x -= min_x - a.y -= min_y - - if shift_y - .18 <= right_atom.y <= shift_y + .18: - factor = right_atom.implicit_hydrogens - if factor == 1: - max_x += .15 - elif factor: - max_x += .25 - return max_x - - -class Calculate2DReaction: - __slots__ = () - - def clean2d(self: 'ReactionContainer'): - """ - Recalculate 2d coordinates - """ - for m in self.molecules(): - m.clean2d() - self.fix_positions() - - def fix_positions(self: 'ReactionContainer'): - """ - Fix coordinates of molecules in reaction - """ - shift_x = 0 - reactants = self.reactants - amount = len(reactants) - 1 - signs = [] - for m in reactants: - max_x = m._fix_plane_mean(shift_x) - if amount: - max_x += .2 - signs.append(max_x) - amount -= 1 - shift_x = max_x + 1 - arrow_min = shift_x - - if self.reagents: - shift_x += .4 - for m in self.reagents: - max_x = m._fix_plane_min(shift_x, .5) - shift_x = max_x + 1 - shift_x += .4 - if shift_x - arrow_min < 3: - shift_x = arrow_min + 3 - else: - shift_x += 3 - arrow_max = shift_x - 1 - - products = self.products - amount = len(products) - 1 - for m in products: - max_x = m._fix_plane_mean(shift_x) - if amount: - max_x += .2 - signs.append(max_x) - amount -= 1 - shift_x = max_x + 1 - self._arrow = (arrow_min, arrow_max) - self._signs = tuple(signs) - self.flush_cache() +from .molecule import * +from .reaction import * __all__ = ['Calculate2DMolecule', 'Calculate2DReaction'] diff --git a/chython/algorithms/calculate2d/_templates.py b/chython/algorithms/calculate2d/_templates.py index 1d13a20b..72974e5f 100644 --- a/chython/algorithms/calculate2d/_templates.py +++ b/chython/algorithms/calculate2d/_templates.py @@ -33,6 +33,12 @@ def _rules(): q = smarts('[A;r4;D2:2]-1-[A;D3,D4:1]-2-[A;r4;D2:5]-[A;D3,D4:3]-1-[A;r4;D2:4]-2') xy = [(0.0, 0.0), (0.6674, 0.485), (1.3348, 0.0), (1.0799, -0.7846), (0.2549, -0.7846)] rules.append((q, xy)) + + # adamantane + q = smarts('[A;D3;r6;z1:1]-1-2-[A:2][A:6]-3-[A:7][A:8]([A:3]-1)[A:9][A:10]([A:4]-2)[A:5]-3') + xy = [(0.9254, -0.0085), (1.3673, -0.2636), (0.4835, -0.2636), (0.9254, 0.5018), (1.4053, 0.8113), + (1.8737, 0.0), (1.4053, -0.8113), (0.4684, -0.8113), (0.0, 0.0), (0.4684, 0.8113)] + rules.append((q, xy)) return rules diff --git a/chython/algorithms/calculate2d/molecule.py b/chython/algorithms/calculate2d/molecule.py new file mode 100644 index 00000000..a0b03f0d --- /dev/null +++ b/chython/algorithms/calculate2d/molecule.py @@ -0,0 +1,497 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2019-2024 Ramil Nugmanov +# Copyright 2024 Denis Lipatov +# Copyright 2024 Vyacheslav Grigorev +# Copyright 2024 Timur Gimadiev +# Copyright 2019, 2020 Dinar Batyrshin +# This file is part of chython. +# +# chython is free software; you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, see . +# +from itertools import combinations +from math import isnan, nan, radians +from numpy import zeros, linspace, column_stack, sin, cos, sqrt, nan_to_num, argmax, errstate +from scipy.sparse.csgraph import shortest_path +from typing import TYPE_CHECKING, Union +from ._templates import rules +from ...exceptions import ImplementationError +from ...periodictable.base.vector import Vector + + +if TYPE_CHECKING: + from chython import MoleculeContainer + + +SINGLE = 1 +DOUBLE = 2 # double bond +BL = .825 +RADIUS = 500 +D0 = 0 +D30 = radians(30) +D60 = radians(60) +D90 = radians(90) +D120 = radians(120) +D180 = radians(180) +D360 = radians(360) + + +class Calculate2DMolecule: + __slots__ = () + + def clean2d(self: Union['MoleculeContainer', 'Calculate2DMolecule'], *, + kk_outer_iterations: int = 1000, kk_outer_threshold: float =.1, + kk_inner_iterations: int = 50, kk_inner_threshold: float =.1): + """ + Calculate 2d layout of graph. + https://pubs.acs.org/doi/10.1021/acs.jcim.7b00425 JS implementation used as a reference. + """ + atoms = self._atoms + components = [] + tail = [] # small components pushed to the right for better visuality + for component in self.connected_components: + if len(component) == 2: # 2-atom mols always stored horizontally + n, m = component + atoms[n].xy = (0., 0.) + atoms[m].xy = (BL, 0.) + tail.insert(0, component) + elif len(component) > 2: + components.append(component) + # mark atoms as non-positioned + for n in component: + atoms[n].xy = (nan, nan) + else: # len == 1: just a dot. no need for layout calculation + tail.append(component) + + if components: + # preset coordinates with templates + groups = self._apply_2d_templates() + # apply KK to process bridged rings + # groups = self._apply_kamada_kawai(groups, kk_outer_iterations, kk_inner_iterations, + # kk_outer_threshold, kk_inner_threshold) + + for component in components: + if any(isnan(atoms[n].x) for n in component): + self._position_atoms(component, groups) + + components.extend(tail) + shift_x = 0 + for component in components: + shift_x = self._fix_plane_mean(shift_x, component=component) + .9 + self.__dict__.pop('__cached_method__repr_svg_', None) + + def _set_starting_points(self: 'MoleculeContainer', component): + """ + Prepare starting point and previous atom for angle calculation + """ + atoms = self._atoms + bonds = self._bonds + + stack = [] + for n in component: + an = atoms[n] + if isnan(an.x): # KK/template layouted + continue + + # todo: fix bond-linked prelayouted groups + + env = bonds[n] + v = Vector(0, 0) + c = -1 + for m in env: + am = atoms[m] + if not isnan(am.x): + v += (am.xy - an.xy).normalise() + c += 1 + delta = D360 / len(env) + angle = v.angle() + delta * c / 2 + + for m in env: + am = atoms[m] + if isnan(am.x): + angle += delta + am.xy = an.xy + Vector(BL, 0).rotate(angle) + stack.append((m, n, angle, -1)) + if stack: + return stack + + for n in component: + if atoms[n].in_ring: + m = next(m for m in bonds[n] if atoms[m].in_ring) + atoms[n].xy = (0., 0.) + atoms[m].xy = (0., BL) # place 1st and second ring atoms always vertical + return [(m, n, D90, -1)] + + # pick any terminal atom. we have tree-like molecule without rings. + # we for sure have at least 3 atoms in a row, thus, we have to layout at least 1 extra atom. + for n in component: + ms = bonds[n] + if len(ms) == 1: + m = next(iter(ms)) + atoms[n].xy = (0., 0.) + atoms[m].xy = Vector(BL, 0).rotate(D30) # place second atom always top-right + return [(m, n, D30, -1)] + + def _position_atoms(self: 'MoleculeContainer', component, groups): + atoms = self._atoms + bonds = self._bonds + ctc = self._stereo_cis_trans_centers + + seen = set() + stack = self._set_starting_points(component) + while stack: + current, previous, angle, sign = stack.pop() + if current in seen: + continue + seen.add(current) + + env = bonds[current] + if len(env) == 1: + # layouting of the branch/molecule is finished + continue + + # chiral cis-trans case. + if env[previous] == DOUBLE: + if b := ctc.get(current): + if (s := self.bond(*b).stereo) is not None: + for n, current, angle, sign, xy in self._position_cis_trans(current, previous, angle, s): + an = atoms[n] + if not isnan(an.x): + # reached layouted fragment. rotate the whole fragment and drop chain. + ... + # prevent double processing in cases of 2 KK layouted fragments linked by chain + seen.add(n) + raise NotImplementedError + else: + an.xy = xy + stack.append((n, current, angle, sign)) + continue + + ac = atoms[current] + # simple non-chiral cases + if len(env) == 2: + n = next(n for n in env if n != previous) + if ac.hybridization == 3: + # keep the same direction + stack.append((n, current, angle, sign)) + else: + angle += sign * D60 + stack.append((n, current, angle, -sign)) + xy = ac.xy + Vector(BL, 0).rotate(angle) + + an = atoms[n] + if not isnan(an.x): + # reached layouted fragment. rotate the whole fragment and drop chain. + stack.pop() + seen.add(n) # prevent double processing in cases of 2 KK layouted fragments linked by chain + raise NotImplementedError + else: + an.xy = xy + elif len(env) == 3: + n, m = (n for n in env if n != previous) + # continue to grow to the same direction + angle += sign * D60 + stack.append((n, current, angle, -sign)) + xy = ac.xy + Vector(BL, 0).rotate(angle) + an = atoms[n] + if not isnan(an.x): + # reached layouted fragment. rotate the whole fragment and drop chain. + stack.pop() + seen.add(n) + raise NotImplementedError + else: + an.xy = xy + + # make a side branch + angle -= sign * D120 + stack.append((m, current, angle, sign)) + xy = ac.xy + Vector(BL, 0).rotate(angle) + am = atoms[m] + if not isnan(am.x): + # reached layouted fragment. rotate the whole fragment and drop chain. + stack.pop() + seen.add(n) + raise NotImplementedError + else: + am.xy = xy + else: # 4+ neighbors. position on circle + delta = D360 / len(env) + angle += D180 + for n in env: + if n != previous: + angle += delta + xy = ac.xy + Vector(BL, 0).rotate(angle) + stack.append((n, current, angle, sign)) # keep sign to minimize overlaps + an = atoms[n] + if not isnan(an.x): + # reached layouted fragment. rotate the whole fragment and drop chain. + stack.pop() + seen.add(n) + raise NotImplementedError + else: + an.xy = xy + + def _position_cis_trans(self: 'MoleculeContainer', current, previous, angle, s): + """ + cis-trans case. we came from a cumulene chain, thus, we have one layouted end + """ + atoms = self._atoms + ctt = self._stereo_cis_trans_terminals + cte = self.stereogenic_cis_trans + + t1, t2 = ts = ctt[current] + n11, n21, n12, n22 = cte[ts] + + ac = atoms[current] + env = self._bonds[current] + if len(env) == 3: + n1, n2 = (n for n in env if n != previous) + else: # env == 2 + n1 = next(n for n in env if n != previous) + n2 = None + + if n1 == n11: # picked 1st atom. no need to switch stereo sigh + if not isnan(atoms[n21].x): + m = n21 # picked 1st atom. no need to switch stereo sign + elif not isnan(atoms[n22].x): # stereo sign switch + m = n22 + s = not s + else: + raise ImplementationError + counter = t2 + elif n1 == n12: # picked 2nd atom. stereo sign switch + if not isnan(atoms[n21].x): + m = n21 + s = not s + elif not isnan(atoms[n22].x): # picked 2nd atom. double stereo-switch. keep as is. + m = n22 + else: + raise ImplementationError + counter = t2 + elif n1 == n21: + if not isnan(atoms[n11].x): + m = n11 + elif not isnan(atoms[n12].x): + m = n12 + s = not s + else: + raise ImplementationError + counter = t1 + else: + if not isnan(atoms[n11].x): + m = n11 + s = not s + elif not isnan(atoms[n12].x): + m = n12 + else: + raise ImplementationError + counter = t1 + + vt = atoms[counter].xy + if (atoms[m].xy - vt) @ (ac.xy - vt) > 0: + sign = 1 if s else -1 + else: + sign = -1 if s else 1 + + angle -= sign * D60 + xy = ac.xy + Vector(BL, 0).rotate(angle) + yield n1, current, angle, sign, xy + if n2: + angle += sign * D120 + xy = ac.xy + Vector(BL, 0).rotate(angle) + yield n2, current, angle, -sign, xy + + def _apply_2d_templates(self): + """ + Use predefined templates to layout atoms. + """ + atoms = self._atoms + seen = set() + groups = [] + for q, layout in rules: + for m in q.get_mapping(self, automorphism_filter=False): + if not seen.isdisjoint(m.values()): # avoid any overlap + continue + seen.update(m.values()) + groups.append(set(m.values())) + for i, n in m.items(): + atoms[n].xy = layout[i - 1] + return groups + + def _apply_kamada_kawai(self, groups, outer_iterations, inner_iterations, outer_threshold, inner_threshold): + atoms = self._atoms + + solved = [] + for cluster, length, strength, coordinates, mapping in self._initialize_kamada_kawai(groups): + pi = -1 + for _ in range(outer_iterations): + diff = coordinates[:, None, :] - coordinates[None, :, :] # NxNx2 + sdiff = diff * diff + energy = diff * (strength * (1 - length / (sqrt(sdiff.sum(-1)) + 1e-5)))[:, :, None] # NxNx2 + forces = energy.sum(1) # Nx2 + total = (forces ** 2).sum(-1) # N + + # pick an atom with the highest force/energy + i = argmax(total) + if i == pi: + total[i] = 0 + i = argmax(total) + pi = i + if total[i] <= outer_threshold: + # if it less than threshold, we have solved system. finish. + break + + li = length[i] # N + si = strength[i] # N + diff_i = diff[i] # Nx2 + sdiff_i = sdiff[i] # Nx2 + for _ in range(inner_iterations): + norm = li / (sdiff_i.sum(-1) ** 1.5 + 1e-5) + dxx, dyy = (si[:, None] * (1 - norm[:, None] * sdiff_i)).sum(0).tolist() + dxy = float((si * norm * diff_i.prod(-1)).sum()) + if abs(dxy) < 0.1: + dxy = 0.1 if dxy > 0 else -0.1 + if abs(dxx) < 0.1: + dxx = 0.1 if dxx > 0 else -0.1 + + d_ex, d_ey = forces[i].tolist() + dy = (d_ex / dxx + d_ey / dxy) / (dxy / dxx - dyy / dxy) + dx = -(dxy * dy + d_ex) / dxx + coordinates[i] += (dx, dy) + + # update forces + diff_i = coordinates[i] - coordinates # Nx2 + sdiff_i = diff_i * diff_i # Nx2 + energy_i = diff_i * (si * (1 - li / (sqrt(sdiff_i.sum(-1)) + 1e-5)))[:, None] # Nx2 + forces[i] = energy_i.sum(0) # 2 + total[i] = (forces[i] ** 2).sum() # 1 + + if total[i] <= inner_threshold: + # local minima for i-th atom found. + break + + for n in cluster: + atoms[n].xy = coordinates[mapping[n]].tolist() + solved.append(cluster) + return solved + + def _initialize_kamada_kawai(self, groups): + atoms = self._atoms + bonds = self._bonds + clusters = [{n} | bonds[n].keys() for n, a in atoms.items() if a.in_ring] + clusters.extend(g | {m for n in g for m in bonds[n]} for g in groups) # add layouted groups + solved = [] + while clusters: + c1 = clusters.pop() + for c2 in clusters: + if not c1.isdisjoint(c2): + c2.update(c1) + break + else: + if c1 in groups: + continue + solved.append(c1) + + for cluster in solved: + mapping = {n: i for i, n in enumerate(cluster)} + + adj = zeros((len(cluster), len(cluster))) + angles = linspace(0, D360, len(cluster) + 1)[:-1] + coordinates = column_stack([cos(angles), sin(angles)]) * RADIUS + + # create adjacency matrix + for n in cluster: + i = mapping[n] + for m in bonds[n].keys() & cluster: + j = mapping[m] + adj[i, j] = BL + + layouted = [] + for g in groups: + if g.isdisjoint(cluster): + continue + # for pre-layouted groups calc pairwise distances + for n, m in combinations(g, 2): + d = atoms[n].xy | atoms[m].xy + i, j = mapping[n], mapping[m] + adj[i, j] = adj[j, i] = d + layouted.extend(g) + length = shortest_path(adj, method='FW', directed=False) + # originally used BL / (topological distance)**2 + # here distance is already BL scaled: BL**3 / (BL*TD)**2 = BL**3 / BL**2 / TD **2 = BL / TD ** 2 + # but we have prelayouted atoms with bonds != BL. Let's just assume they are close enough. + # adj magic here to reset strength of layouted groups to actual distances. + with errstate(divide='ignore'): + strength = nan_to_num(BL**3 / (length ** 2), posinf=0) * (adj == 0) + adj + + if layouted: + center = sum((atoms[n].xy for n in layouted), Vector(0, 0)) / len(layouted) + for n in layouted: + coordinates[mapping[n], :] = tuple(atoms[n].xy - center) + yield cluster, length, strength, coordinates, mapping + + def _fix_plane_mean(self: 'MoleculeContainer', shift_x: float, shift_y=0., component=None) -> float: + atoms = self._atoms + if component is None: + component = atoms + + left_atom = atoms[min(component, key=lambda x: atoms[x].x)] + right_atom = atoms[max(component, key=lambda x: atoms[x].x)] + + min_x = left_atom.x - shift_x + if len(left_atom.atomic_symbol) == 2: + min_x -= .2 + + max_x = right_atom.x - min_x + min_y = min(atoms[x].y for x in component) + max_y = max(atoms[x].y for x in component) + mean_y = (max_y + min_y) / 2 - shift_y + for n in component: + a = atoms[n].xy + a.x -= min_x + a.y -= mean_y + + if -.18 <= right_atom.y <= .18: + factor = right_atom.implicit_hydrogens + if factor == 1: + max_x += .15 + elif factor: + max_x += .25 + return max_x + + def _fix_plane_min(self: 'MoleculeContainer', shift_x: float, shift_y=0., component=None) -> float: + atoms = self._atoms + if component is None: + component = atoms + + right_atom = atoms[max(component, key=lambda x: atoms[x].x)] + min_x = min(atoms[x].x for x in component) - shift_x + max_x = right_atom.x - min_x + min_y = min(atoms[x].y for x in component) - shift_y + + for n in component: + a = atoms[n].xy + a.x -= min_x + a.y -= min_y + + if shift_y - .18 <= right_atom.y <= shift_y + .18: + factor = right_atom.implicit_hydrogens + if factor == 1: + max_x += .15 + elif factor: + max_x += .25 + return max_x + + +__all__ = ['Calculate2DMolecule'] diff --git a/chython/algorithms/calculate2d/reaction.py b/chython/algorithms/calculate2d/reaction.py new file mode 100644 index 00000000..94740b8e --- /dev/null +++ b/chython/algorithms/calculate2d/reaction.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2019-2024 Ramil Nugmanov +# This file is part of chython. +# +# chython is free software; you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, see . +# +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from chython import ReactionContainer + + +class Calculate2DReaction: + __slots__ = () + + def clean2d(self: 'ReactionContainer'): + """ + Recalculate 2d coordinates + """ + for m in self.molecules(): + m.clean2d() + self.fix_positions() + + def fix_positions(self: 'ReactionContainer'): + """ + Fix coordinates of molecules in reaction + """ + shift_x = 0 + reactants = self.reactants + amount = len(reactants) - 1 + signs = [] + for m in reactants: + max_x = m._fix_plane_mean(shift_x) + if amount: + max_x += .2 + signs.append(max_x) + amount -= 1 + shift_x = max_x + 1 + arrow_min = shift_x + + if self.reagents: + shift_x += .4 + for m in self.reagents: + max_x = m._fix_plane_min(shift_x, .5) + shift_x = max_x + 1 + shift_x += .4 + if shift_x - arrow_min < 3: + shift_x = arrow_min + 3 + else: + shift_x += 3 + arrow_max = shift_x - 1 + + products = self.products + amount = len(products) - 1 + for m in products: + max_x = m._fix_plane_mean(shift_x) + if amount: + max_x += .2 + signs.append(max_x) + amount -= 1 + shift_x = max_x + 1 + self._arrow = (arrow_min, arrow_max) + self._signs = tuple(signs) + self.flush_cache(keep_molecule_cache=True) + + +__all__ = ['Calculate2DReaction'] From fbc30f7e43f075ecd6984f1fb4a8af79911f2fda Mon Sep 17 00:00:00 2001 From: stsouko Date: Wed, 1 Jan 2025 19:28:04 +0100 Subject: [PATCH 63/67] initialization implemented for pre-layouted cases --- chython/algorithms/calculate2d/molecule.py | 156 +++++--- chython/periodictable/base/vector.py | 417 +-------------------- 2 files changed, 115 insertions(+), 458 deletions(-) diff --git a/chython/algorithms/calculate2d/molecule.py b/chython/algorithms/calculate2d/molecule.py index a0b03f0d..6f2de3da 100644 --- a/chython/algorithms/calculate2d/molecule.py +++ b/chython/algorithms/calculate2d/molecule.py @@ -24,7 +24,7 @@ from math import isnan, nan, radians from numpy import zeros, linspace, column_stack, sin, cos, sqrt, nan_to_num, argmax, errstate from scipy.sparse.csgraph import shortest_path -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING, Union, Dict from ._templates import rules from ...exceptions import ImplementationError from ...periodictable.base.vector import Vector @@ -32,6 +32,8 @@ if TYPE_CHECKING: from chython import MoleculeContainer + from chython.containers import Bond + from chython.periodictable import Element SINGLE = 1 @@ -49,6 +51,8 @@ class Calculate2DMolecule: __slots__ = () + _atoms: Dict[int, 'Element'] + _bonds: Dict[int, Dict[int, 'Bond']] def clean2d(self: Union['MoleculeContainer', 'Calculate2DMolecule'], *, kk_outer_iterations: int = 1000, kk_outer_threshold: float =.1, @@ -82,8 +86,8 @@ def clean2d(self: Union['MoleculeContainer', 'Calculate2DMolecule'], *, # kk_outer_threshold, kk_inner_threshold) for component in components: - if any(isnan(atoms[n].x) for n in component): - self._position_atoms(component, groups) + if component not in groups: + self._position_atoms(component, [x for x in groups if not component.isdisjoint(x)]) components.extend(tail) shift_x = 0 @@ -91,65 +95,125 @@ def clean2d(self: Union['MoleculeContainer', 'Calculate2DMolecule'], *, shift_x = self._fix_plane_mean(shift_x, component=component) + .9 self.__dict__.pop('__cached_method__repr_svg_', None) - def _set_starting_points(self: 'MoleculeContainer', component): + def _initialize_positioning(self, component, groups): """ Prepare starting point and previous atom for angle calculation """ atoms = self._atoms bonds = self._bonds + if not groups: + # nothing pre-layouted. pick any ring atom if exists or any terminal atom. + for n in component: + if atoms[n].in_ring: + m = next(m for m in bonds[n] if atoms[m].in_ring) + atoms[n].xy = (0., 0.) + atoms[m].xy = (0., BL) # place 1st and second ring atoms always vertical + return [(m, n, D90, -1)] + + # pick any terminal atom. we have tree-like molecule without rings. + # we for sure have at least 3 atoms in a row, thus, we have to layout at least 1 extra atom. + for n in component: + ms = bonds[n] + if len(ms) == 1: + m = next(iter(ms)) + atoms[n].xy = (0., 0.) + atoms[m].xy = Vector(BL, 0).rotate(D30) # place second atom always top-right + return [(m, n, D30, -1)] + + # we have pre-layouted groups. let's prepare them for extention. stack = [] - for n in component: - an = atoms[n] - if isnan(an.x): # KK/template layouted - continue + layouted = set() + seen = set() + for group in groups: + seen.update(group) + for n in group: + env = bonds[n] + if env.keys() <= seen: # neighbors already pre-layouted + continue + an = atoms[n] - # todo: fix bond-linked prelayouted groups + # treat atom in group as star where layouted atoms are close to each other and non-layouted on opposite side + # + # L N + # \ / + # L - L + # / \ + # L N + # + v, c = Vector(0, 0), -1 + for m in env: + if m in seen: + v += (atoms[m].xy - an.xy).normalise() + c += 1 + delta = D360 / len(env) + angle = v.angle() + delta * c / 2 # ideal position of frontal layouted atom - env = bonds[n] - v = Vector(0, 0) - c = -1 - for m in env: - am = atoms[m] - if not isnan(am.x): - v += (am.xy - an.xy).normalise() - c += 1 - delta = D360 / len(env) - angle = v.angle() + delta * c / 2 + for m in env: + if m in seen: # already layouted. + continue - for m in env: - am = atoms[m] - if isnan(am.x): angle += delta - am.xy = an.xy + Vector(BL, 0).rotate(angle) - stack.append((m, n, angle, -1)) - if stack: - return stack + xy = an.xy + Vector(BL, 0).rotate(angle) + + for other in groups: + if not other.isdisjoint(seen): + continue + elif m in other: + # Ring-bond-Ring case + # atom is part of another pre-layouted group. + # let's reposition the whole group. + am, emv = atoms[m], bonds[m] + + self._rotate_group(other, an.xy, angle - an.xy.angle(am.xy)) # fix angle g-n-m + self._shift_group(other, xy - am.xy) # fix position of m ang the whole group + + # calculate opposite angle + v, c = Vector(0, 0), 1 + for k in emv: + if k in other: + v += (atoms[k].xy - am.xy).normalise() + c += 1 + # fix angle o-m-n + self._rotate_group(other, am.xy, v.angle() + D360 / len(emv) * c / 2) + break + elif m in layouted: + # Ring - Linker Atom - Ring case + ... + break + else: # non-layouted atom. + layouted.add(m) + atoms[m].xy = xy + stack.append((m, n, angle, -1)) + continue + return stack - for n in component: - if atoms[n].in_ring: - m = next(m for m in bonds[n] if atoms[m].in_ring) - atoms[n].xy = (0., 0.) - atoms[m].xy = (0., BL) # place 1st and second ring atoms always vertical - return [(m, n, D90, -1)] + def _rotate_group(self, group, point: Vector, angle): + """ + Rotate the whole group around given point + """ + atoms = self._atoms - # pick any terminal atom. we have tree-like molecule without rings. - # we for sure have at least 3 atoms in a row, thus, we have to layout at least 1 extra atom. - for n in component: - ms = bonds[n] - if len(ms) == 1: - m = next(iter(ms)) - atoms[n].xy = (0., 0.) - atoms[m].xy = Vector(BL, 0).rotate(D30) # place second atom always top-right - return [(m, n, D30, -1)] + for n in group: + an = atoms[n] + an.xy = an.xy.rotate(angle, point) + + def _shift_group(self, group, shift: Vector): + """ + Shift the whole group by given vector + """ + atoms = self._atoms + + for n in group: + atoms[n].xy += shift - def _position_atoms(self: 'MoleculeContainer', component, groups): + def _position_atoms(self, component, groups): atoms = self._atoms bonds = self._bonds ctc = self._stereo_cis_trans_centers seen = set() - stack = self._set_starting_points(component) + stack = self._initialize_positioning(component, groups) while stack: current, previous, angle, sign = stack.pop() if current in seen: @@ -242,7 +306,7 @@ def _position_atoms(self: 'MoleculeContainer', component, groups): else: an.xy = xy - def _position_cis_trans(self: 'MoleculeContainer', current, previous, angle, s): + def _position_cis_trans(self, current, previous, angle, s): """ cis-trans case. we came from a cumulene chain, thus, we have one layouted end """ @@ -441,7 +505,7 @@ def _initialize_kamada_kawai(self, groups): coordinates[mapping[n], :] = tuple(atoms[n].xy - center) yield cluster, length, strength, coordinates, mapping - def _fix_plane_mean(self: 'MoleculeContainer', shift_x: float, shift_y=0., component=None) -> float: + def _fix_plane_mean(self, shift_x: float, shift_y=0., component=None) -> float: atoms = self._atoms if component is None: component = atoms @@ -470,7 +534,7 @@ def _fix_plane_mean(self: 'MoleculeContainer', shift_x: float, shift_y=0., compo max_x += .25 return max_x - def _fix_plane_min(self: 'MoleculeContainer', shift_x: float, shift_y=0., component=None) -> float: + def _fix_plane_min(self, shift_x: float, shift_y=0., component=None) -> float: atoms = self._atoms if component is None: component = atoms diff --git a/chython/periodictable/base/vector.py b/chython/periodictable/base/vector.py index c4fcf37a..85f719ce 100644 --- a/chython/periodictable/base/vector.py +++ b/chython/periodictable/base/vector.py @@ -87,13 +87,16 @@ def __or__(self, vector: 'Vector'): """ return hypot(vector.x - self.x, vector.y - self.y) - def rotate(self, angle: float): + def rotate(self, angle: float, vector: 'Vector' = None): """ A method that rotates the vector by the angle in radians """ c = cos(angle) s = sin(angle) - return Vector(self.x * c - self.y * s, self.x * s + self.y * c) + if vector is None: + return Vector(self.x * c - self.y * s, self.x * s + self.y * c) + xy = self - vector + return vector + Vector(xy.x * c - xy.y * s, xy.x * s + xy.y * c) def normalise(self): """ @@ -114,414 +117,4 @@ def angle(self, vector: 'Vector' = None) -> float: return atan2(vector.y - self.y, vector.x - self.x) - - def rotate_around_vector(self, angle: float, vector: 'Vector') -> None: - """ - Rotates a point (or vector) around a given vector by a specified angle. - - Parameters: - :param angle float: - The angle by which to rotate the point, typically measured in radians. - :param vector 'Vector': - The vector around which the rotation occurs. This vector serves as the reference - point. - """ - self.x -= vector.x - self.y -= vector.y - - x = self.x * math.cos(angle) - self.y * math.sin(angle) - y = self.x * math.sin(angle) + self.y * math.cos(angle) - - self.x = x + vector.x - self.y = y + vector.y - - def get_closest_atom(self, atom_1: 'AtomProperties', atom_2: 'AtomProperties') -> 'AtomProperties': - """ - This method determines which of the two atoms (represented by the objects atom_1 and atom_2) - is closer to the current object (represented by self). - - Parameters: - :param atom_1: 'AtomProperties': - The first atom to compare. - :param atom_2: 'AtomProperties': - The second atom to compare. - - Returns 'AtomProperties': - The closest atom. - """ - distance_1 = self.get_squared_distance(atom_1.position) - distance_2 = self.get_squared_distance(atom_2.position) - return atom_1 if distance_1 < distance_2 else atom_2 - - def get_closest_point_index(self, point_1: 'Vector', point_2: 'Vector') -> int: - """ - The method is designed to determine which of the two specified coordinates (point_1: 'Vector', point_2: 'Vector') - closer to the current point. - - Parameters - :param point_1 'Vector': - The first point to be compared with. It can be a tuple, a list, or an object - representing coordinates. - :param point_2 'Vector': - The second point to compare with. Similarly, it can be a tuple, a list, or an - object. - - Returns int: - The index of the nearest point: 0 for point_1 and 1 for point_2. - """ - distance_1 = self.get_squared_distance(point_1) - distance_2 = self.get_squared_distance(point_2) - return 0 if distance_1 < distance_2 else 1 - - def get_squared_length(self) -> float: - """ - Calculates the length squared - - Returns float: - Vector length squared - """ - return self.x ** 2 + self.y ** 2 - - def get_squared_distance(self, vector: 'Vector') -> float: - """ - The method is designed to calculate the square of the distance between the current vector - (represented by self) and the specified vector (or point) represented by the vector object. - - Parameters - :param vector: 'Vector': - An object representing a vector or point from which to calculate the distance. - - Returns float: - The square of the distance - """ - return (vector.x - self.x) ** 2 + (vector.y - self.y) ** 2 - - def get_distance(self, vector: 'Vector') -> float: - """ - The method is designed to calculate the distance between the current vector (represented by self) and - the specified vector (or point) represented by the vector object. - - Parameters - :param vector: 'Vector': - An object representing a vector or point from which to calculate the distance. - - Returns float: - The distance between the coordinates of the current vector and the passed parameter - """ - return math.sqrt(self.get_squared_distance(vector)) - - def get_rotation_away_from_vector(self, vector: 'Vector', center: 'Vector', angle: float) -> float: - """ - The method is designed to determine how much the angle of rotation (in a positive or negative direction) - from a given vector measures the distance to this vector. - - Parameters - :param vector 'Vector': - The vector to "move away from". It can be a point or a direction, relative to which - the rotation is taking place. - :param center 'Vector': - The center of rotation around which the object (represented by self) rotates. - :param angle float: - The angle at which the rotation occurs. This value can be positive or negative. - - Returns returns the rotation angle that minimizes the distance to the vector, - either in a positive or negative direction. - """ - tmp = self.copy() - - tmp.rotate_around_vector(angle, center) - squared_distance_1 = tmp.get_squared_distance(vector) - - tmp.rotate_around_vector(-2.0 * angle, center) - squared_distance_2 = tmp.get_squared_distance(vector) - return angle if squared_distance_2 < squared_distance_1 else -angle - - def rotate_away_from_vector(self, vector: 'Vector', center: 'Vector', angle: float) -> None: - """ - The method is designed to rotate the current object (represented by self) around a given - one center in such a way as to minimize the distance to the specified vector. - If rotation in one direction leads to a decrease in the distance, the function corrects - the rotation,to ensure maximum distance from the vector. - - Parameters - :param vector 'Vector': - The vector to "move away from". It can be a point or a direction, relative to which - the rotation is taking place. - :param center 'Vector': - The center of rotation around which the object rotates. - :param angle float: - The angle at which the rotation occurs. This value can be positive or negative. - """ - self.rotate_around_vector(angle, center) - squared_distance_1 = self.get_squared_distance(vector) - self.rotate_around_vector(-2.0 * angle, center) - squared_distance_2 = self.get_squared_distance(vector) - - if squared_distance_2 < squared_distance_1: - self.rotate_around_vector(2.0 * angle, center) - - def get_clockwise_orientation(self, vector: 'Vector') -> str: - """ - The method is designed to determine the orientation (positive or negative) between - the current object (represented by self) and the specified vector (represented by - the vector object). - - Parameters - :param vector 'Vector': - The vector relative to which the orientation is determined. - - Returns str: - A string indicating whether the orientation is "clockwise", "counterclockwise" - or "neutral". - """ - a: float = self.y * vector.x - b: float = self.x * vector.y - - if a > b: - return 'clockwise' - elif a == b: - return 'neutral' - else: - return 'counterclockwise' - - def mirror_about_line(self, line_point_1: 'Vector', line_point_2: 'Vector') -> None: - """ - The method is designed to reflect the current object (represented by self) relative to a - given line, defined by two points (line_point_1 and line_point_2). After performing this - function, the coordinates of the object will be changed so that it is on the opposite - side of the line, keeping the same distance to the line. - - Parameters - :param line_point_1: 'Vector': - The first point defining the line. - :param line_point_2: 'Vector': - The second point defining the line. - """ - dx = line_point_2.x - line_point_1.x - dy = line_point_2.y - line_point_1.y - - a = (dx * dx - dy * dy) / (dx * dx + dy * dy) - b = 2 * dx * dy / (dx * dx + dy * dy) - - new_x = a * (self.x - line_point_1.x) + b * (self.y - line_point_1.y) + line_point_1.x - new_y = b * (self.x - line_point_1.x) - a * (self.y - line_point_1.y) + line_point_1.y - - self.x = new_x - self.y = new_y - - @staticmethod - def get_position_relative_to_line(vector_start: 'Vector', vector_end: 'Vector', vector: 'Vector') -> int: - """ - Determines the position of a vector relative to a line defined by two points. - - Parameters: - :param vector_start 'Vector': - The start point of the line. - :param vector_end 'Vector': - The end point of the line. - :param vector 'Vector': - The vector whose position relative to the line is to be determined. - - Returns int: - 1 if the vector is to the left of the line, -1 if the vector is to the right of the - line, 0 if the vector lies on the line. - """ - d = (vector.x - vector_start.x) * (vector_end.y - vector_start.y) - (vector.y - vector_start.y) * ( - vector_end.x - vector_start.x) - if d > 0: - return 1 - elif d < 0: - return -1 - else: - return 0 - - @staticmethod - def get_directionality_triangle(vector_a: 'Vector', vector_b: 'Vector', vector_c: 'Vector') -> str: - """ - Determines the directionality of the triangle formed by three vectors (or points). - - Parameters: - :param vector_a 'Vector': - The first vertex of the triangle. - :param vector_b 'Vector': - The second vertex of the triangle. - :param vector_c 'Vector': - The third vertex of the triangle. - - Returns str: - - 'clockwise' if the triangle is oriented in a clockwise direction. - - 'counterclockwise' if the triangle is oriented in a counterclockwise direction. - - None if the three points are collinear (lie on the same line). - """ - determinant = (vector_b.x - vector_a.x) * (vector_c.y - vector_a.y) - (vector_c.x - vector_a.x) * ( - vector_b.y - vector_a.y) - if determinant < 0: - return 'clockwise' - elif determinant == 0: - return None - else: - return 'counterclockwise' - - @staticmethod - def mirror_vector_about_line(line_point_1: 'Vector', line_point_2: 'Vector', point: 'Vector') -> 'Vector': - """ - Mirrors a point (or vector) across a line defined by two points. - - Parameters: - :param line_point_1 'Vector': - The first point defining the line. - :param line_point_2 'Vector': - The second point defining the line. - :param point 'Vector': - The point to be mirrored across the line. - - Returns Vector: - A new Vector representing the mirrored point across the line. - """ - dx = line_point_2.x - line_point_1.x - dy = line_point_2.y - line_point_1.y - - a = (dx * dx - dy * dy) / (dx * dx + dy * dy) - b = 2 * dx * dy / (dx * dx + dy * dy) - - x_new = a * (point.x - line_point_1.x) + b * (point.y - line_point_1.y) + line_point_1.x - y_new = b * (point.x - line_point_1.x) - a * (point.y - line_point_1.y) + line_point_1.y - return Vector(x_new, y_new) - - @staticmethod - def get_line_angle(point_1: 'Vector', point_2: 'Vector') -> float: - """ - Calculates the angle of a line defined by two points with respect to the positive x-axis. - - Parameters: - point_1 'Vector': - The first point defining the line. - point_2 'Vector': - The second point defining the line. - - Returns float: - The angle of the line in radians, in the range [-π, π]. - """ - difference = Vector.subtract_vectors(point_2, point_1) - return difference.angle() - - @staticmethod - def subtract_vectors(vector_1: 'Vector', vector_2: 'Vector') -> 'Vector': - """ - Subtracts one vector from another. - - Parameters: - vector_1 'Vector': - The vector from which to subtract. - vector_2 'Vector': - The vector to subtract. - - Returns Vector: - A new Vector representing the result of the subtraction (vector_1 - vector_2). - """ - x = vector_1.x - vector_2.x - y = vector_1.y - vector_2.y - return Vector(x, y) - - @staticmethod - def add_vectors(vector_1: 'Vector', vector_2: 'Vector') -> 'Vector': - """ - Adds two vectors together. - - Parameters: - vector_1 'Vector': - The first vector to add. - vector_2 'Vector': - The second vector to add. - - Returns Vector: - A new Vector representing the result of the addition (vector_1 + vector_2). - """ - x = vector_1.x + vector_2.x - y = vector_1.y + vector_2.y - return Vector(x, y) - - @staticmethod - def get_midpoint(vector_1: 'Vector', vector_2: 'Vector') -> 'Vector': - """ - Calculates the midpoint between two vectors. - - Parameters: - vector_1 'Vector': - The first vector. - vector_2 'Vector': - The second vector. - - Returns Vector: - A new Vector representing the midpoint between vector_1 and vector_2. - """ - x = (vector_1.x + vector_2.x) / 2 - y = (vector_1.y + vector_2.y) / 2 - return Vector(x, y) - - @staticmethod - def get_average(vectors: List['Vector']) -> 'Vector': - """ - Calculates the average of a list of vectors. - - Parameters: - :param vectors List[Vector]: - A list of vectors for which the average is to be calculated. - - Returns: - Vector: A new Vector representing the average of the input vectors. - """ - average_x = 0.0 - average_y = 0.0 - for vector in vectors: - average_x += vector.x - average_y += vector.y - return Vector(average_x / len(vectors), average_y / len(vectors)) - - @staticmethod - def get_normals(vector_1: 'Vector', vector_2: 'Vector') -> List['Vector']: - """ - Calculates the normal vectors to the line defined by two vectors. - - Parameters: - :param vector_1 'Vector': - The first vector defining the line. - :param vector_2 'Vector': - The second vector defining the line. - - Returns List[Vector]: - A list containing two normal vectors to the line defined by vector_1 and vector_2. - """ - delta = Vector.subtract_vectors(vector_2, vector_1) - return [Vector(-delta.y, delta.x), Vector(delta.y, -delta.x)] - - @staticmethod - def get_angle_between_vectors(vector_1: 'Vector', vector_2: 'Vector', origin: 'Vector') -> float: - """ - Calculates the angle between two vectors relative to a given origin point. - - Parameters: - :param vector_1 'Vector': - The first vector. - :param vector_2 'Vector': - The second vector. - :param origin 'Vector': - The origin point relative to which the angle is calculated. - - Returns: - float: The angle between vector_1 and vector_2 in radians, in the range [0, π]. - """ - v1_x_diff: float = vector_1.x - origin.x - v1_y_diff: float = vector_1.y - origin.y - v2_x_diff: float = vector_2.x - origin.x - v2_y_diff: float = vector_2.y - origin.y - - dot_product: float = v1_x_diff * v2_x_diff + v1_y_diff * v2_y_diff - length_v1: float = math.sqrt(v1_x_diff ** 2 + v1_y_diff ** 2) - length_v2: float = math.sqrt(v2_x_diff ** 2 + v2_y_diff ** 2) - - cos_angle = dot_product / (length_v1 * length_v2) - return math.acos(cos_angle) - - __all__ = ['Vector'] From 00a290540222b12890fea2e7be94d6d9c2fda487 Mon Sep 17 00:00:00 2001 From: stsouko Date: Wed, 1 Jan 2025 20:17:59 +0100 Subject: [PATCH 64/67] fix double alignment of groups --- chython/algorithms/calculate2d/_templates.py | 2 +- chython/algorithms/calculate2d/molecule.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/chython/algorithms/calculate2d/_templates.py b/chython/algorithms/calculate2d/_templates.py index 72974e5f..2a7daaaf 100644 --- a/chython/algorithms/calculate2d/_templates.py +++ b/chython/algorithms/calculate2d/_templates.py @@ -35,7 +35,7 @@ def _rules(): rules.append((q, xy)) # adamantane - q = smarts('[A;D3;r6;z1:1]-1-2-[A:2][A:6]-3-[A:7][A:8]([A:3]-1)[A:9][A:10]([A:4]-2)[A:5]-3') + q = smarts('[A;D3;r6;z1:1]-1-2-[A;D2:2][A:6]-3-[A:7][A:8]([A;D2:3]-1)[A:9][A:10]([A;D2:4]-2)[A:5]-3') xy = [(0.9254, -0.0085), (1.3673, -0.2636), (0.4835, -0.2636), (0.9254, 0.5018), (1.4053, 0.8113), (1.8737, 0.0), (1.4053, -0.8113), (0.4684, -0.8113), (0.0, 0.0), (0.4684, 0.8113)] rules.append((q, xy)) diff --git a/chython/algorithms/calculate2d/molecule.py b/chython/algorithms/calculate2d/molecule.py index 6f2de3da..57f02cbd 100644 --- a/chython/algorithms/calculate2d/molecule.py +++ b/chython/algorithms/calculate2d/molecule.py @@ -164,8 +164,6 @@ def _initialize_positioning(self, component, groups): # atom is part of another pre-layouted group. # let's reposition the whole group. am, emv = atoms[m], bonds[m] - - self._rotate_group(other, an.xy, angle - an.xy.angle(am.xy)) # fix angle g-n-m self._shift_group(other, xy - am.xy) # fix position of m ang the whole group # calculate opposite angle @@ -175,7 +173,7 @@ def _initialize_positioning(self, component, groups): v += (atoms[k].xy - am.xy).normalise() c += 1 # fix angle o-m-n - self._rotate_group(other, am.xy, v.angle() + D360 / len(emv) * c / 2) + self._rotate_group(other, am.xy, angle + D180 - v.angle() + D360 / len(emv) * c / 2) break elif m in layouted: # Ring - Linker Atom - Ring case From 739ae716b152d45792c092465f3e5258b20656d7 Mon Sep 17 00:00:00 2001 From: stsouko Date: Sun, 19 Jan 2025 00:02:41 +0100 Subject: [PATCH 65/67] prepositioned groups/rings now correctly processed during tree layouting --- chython/algorithms/calculate2d/_templates.py | 24 ++- chython/algorithms/calculate2d/molecule.py | 176 +++++++++---------- 2 files changed, 96 insertions(+), 104 deletions(-) diff --git a/chython/algorithms/calculate2d/_templates.py b/chython/algorithms/calculate2d/_templates.py index 2a7daaaf..b44baa1f 100644 --- a/chython/algorithms/calculate2d/_templates.py +++ b/chython/algorithms/calculate2d/_templates.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2024 Ramil Nugmanov +# Copyright 2024, 2025 Ramil Nugmanov # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -30,15 +30,23 @@ def _rules(): rules = [] # bicyclo[1.1.1]pentane - q = smarts('[A;r4;D2:2]-1-[A;D3,D4:1]-2-[A;r4;D2:5]-[A;D3,D4:3]-1-[A;r4;D2:4]-2') - xy = [(0.0, 0.0), (0.6674, 0.485), (1.3348, 0.0), (1.0799, -0.7846), (0.2549, -0.7846)] - rules.append((q, xy)) + q = smarts('[A;r4;D2;z1:2]-1-[A;D3,D4:1]-2-[A;r4;D2:5]-[A;D3,D4:3]-1-[A;r4;D2:4]-2') + xy = {1: (0.0, 0.0), 2: (0.6674, -0.485), 3: (1.3348, 0.0), 4: (1.0799, 0.7846), 5: (0.2549, 0.7846)} + sub = {1: (-0.825, 0.0), 3: (2.1598, 0.0)} + rules.append((q, xy, sub)) # adamantane - q = smarts('[A;D3;r6;z1:1]-1-2-[A;D2:2][A:6]-3-[A:7][A:8]([A;D2:3]-1)[A:9][A:10]([A;D2:4]-2)[A:5]-3') - xy = [(0.9254, -0.0085), (1.3673, -0.2636), (0.4835, -0.2636), (0.9254, 0.5018), (1.4053, 0.8113), - (1.8737, 0.0), (1.4053, -0.8113), (0.4684, -0.8113), (0.0, 0.0), (0.4684, 0.8113)] - rules.append((q, xy)) + q = smarts('[A;D3;r6:1]-1-2-[A;D2:2][A:6]-3-[A:7][A:8]([A;D2:3]-1)[A:9][A:10]([A;D2:4]-2)[A:5]-3') + xy = {1: (0.0084, 0.8368), 2: (0.2398, 1.2541), 3: (0.2542, 0.4277), 4: (-0.4687, 0.8284), 5: (-0.7145, 1.2375), + 6: (0.0, 1.65), 7: (0.7144, 1.2375), 8: (0.7144, 0.4125), 9: (0.0, 0.0), 10: (-0.7145, 0.4125)} + sub = {6: (0.0, 2.475), 7: (1.4289, 1.65), 8: (1.4289, 0.0), + 9: (0.0, -0.825), 10: (-1.429, 0.0), 5: (-1.429, 1.65)} + rules.append((q, xy, sub)) + + # cyclopropane + q = smarts('[A;r3:1]-1-;@[A;r3:2]-;@[A;r3:3]-1') + xy = {1: (0.825, 0.0), 2: (0.0, 0.0), 3: (0.4125, -0.7145)} + rules.append((q, xy, {})) return rules diff --git a/chython/algorithms/calculate2d/molecule.py b/chython/algorithms/calculate2d/molecule.py index 57f02cbd..c4fdb6b5 100644 --- a/chython/algorithms/calculate2d/molecule.py +++ b/chython/algorithms/calculate2d/molecule.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2019-2024 Ramil Nugmanov +# Copyright 2019-2025 Ramil Nugmanov # Copyright 2024 Denis Lipatov # Copyright 2024 Vyacheslav Grigorev # Copyright 2024 Timur Gimadiev @@ -86,8 +86,8 @@ def clean2d(self: Union['MoleculeContainer', 'Calculate2DMolecule'], *, # kk_outer_threshold, kk_inner_threshold) for component in components: - if component not in groups: - self._position_atoms(component, [x for x in groups if not component.isdisjoint(x)]) + if all(component != g.keys() for g in groups): # check for fully layouted component + self._position_atoms(component, [g for g in groups if not component.isdisjoint(g)]) components.extend(tail) shift_x = 0 @@ -102,6 +102,7 @@ def _initialize_positioning(self, component, groups): atoms = self._atoms bonds = self._bonds + directions = {} if not groups: # nothing pre-layouted. pick any ring atom if exists or any terminal atom. for n in component: @@ -109,7 +110,7 @@ def _initialize_positioning(self, component, groups): m = next(m for m in bonds[n] if atoms[m].in_ring) atoms[n].xy = (0., 0.) atoms[m].xy = (0., BL) # place 1st and second ring atoms always vertical - return [(m, n, D90, -1)] + return [(m, n, D90, -1)], directions # pick any terminal atom. we have tree-like molecule without rings. # we for sure have at least 3 atoms in a row, thus, we have to layout at least 1 extra atom. @@ -119,72 +120,16 @@ def _initialize_positioning(self, component, groups): m = next(iter(ms)) atoms[n].xy = (0., 0.) atoms[m].xy = Vector(BL, 0).rotate(D30) # place second atom always top-right - return [(m, n, D30, -1)] + return [(m, n, D30, -1)], directions - # we have pre-layouted groups. let's prepare them for extention. stack = [] - layouted = set() - seen = set() for group in groups: - seen.update(group) - for n in group: - env = bonds[n] - if env.keys() <= seen: # neighbors already pre-layouted + for n, d in group.items(): + if not d: continue - an = atoms[n] - - # treat atom in group as star where layouted atoms are close to each other and non-layouted on opposite side - # - # L N - # \ / - # L - L - # / \ - # L N - # - v, c = Vector(0, 0), -1 - for m in env: - if m in seen: - v += (atoms[m].xy - an.xy).normalise() - c += 1 - delta = D360 / len(env) - angle = v.angle() + delta * c / 2 # ideal position of frontal layouted atom - - for m in env: - if m in seen: # already layouted. - continue - - angle += delta - xy = an.xy + Vector(BL, 0).rotate(angle) - - for other in groups: - if not other.isdisjoint(seen): - continue - elif m in other: - # Ring-bond-Ring case - # atom is part of another pre-layouted group. - # let's reposition the whole group. - am, emv = atoms[m], bonds[m] - self._shift_group(other, xy - am.xy) # fix position of m ang the whole group - - # calculate opposite angle - v, c = Vector(0, 0), 1 - for k in emv: - if k in other: - v += (atoms[k].xy - am.xy).normalise() - c += 1 - # fix angle o-m-n - self._rotate_group(other, am.xy, angle + D180 - v.angle() + D360 / len(emv) * c / 2) - break - elif m in layouted: - # Ring - Linker Atom - Ring case - ... - break - else: # non-layouted atom. - layouted.add(m) - atoms[m].xy = xy - stack.append((m, n, angle, -1)) - continue - return stack + directions[n] = d + stack.append((n, None, None, -1)) # directions already defined. no need for previous and angle + return stack, directions def _rotate_group(self, group, point: Vector, angle): """ @@ -205,42 +150,61 @@ def _shift_group(self, group, shift: Vector): for n in group: atoms[n].xy += shift + def _reset_group(self, current, n, xy, groups): + ac = self._atoms[current] + an = self._atoms[n] + g = next(g for g in groups if n in g) + angle = xy.angle() + D180 - g[n][current].angle() + self._shift_group(g, ac.xy + xy - an.xy) + self._rotate_group(g, an.xy, angle) + return {x: {z: a.rotate(angle) for z, a in y.items()} for x, y in g.items() if y} + def _position_atoms(self, component, groups): atoms = self._atoms bonds = self._bonds ctc = self._stereo_cis_trans_centers seen = set() - stack = self._initialize_positioning(component, groups) + stack, directions = self._initialize_positioning(component, groups) while stack: current, previous, angle, sign = stack.pop() if current in seen: continue seen.add(current) + ac = atoms[current] + if current in directions: + for n, xy in directions[current].items(): + an = atoms[n] + if not isnan(an.x): + seen.add(n) + # reached layouted fragment. rotate the whole fragment. + directions.update(self._reset_group(current, n, xy, groups)) + else: + an.xy = ac.xy + xy + stack.append((n, current, xy.angle(), sign)) + continue + env = bonds[current] if len(env) == 1: # layouting of the branch/molecule is finished continue # chiral cis-trans case. - if env[previous] == DOUBLE: + elif env[previous] == DOUBLE: if b := ctc.get(current): if (s := self.bond(*b).stereo) is not None: for n, current, angle, sign, xy in self._position_cis_trans(current, previous, angle, s): an = atoms[n] if not isnan(an.x): # reached layouted fragment. rotate the whole fragment and drop chain. - ... - # prevent double processing in cases of 2 KK layouted fragments linked by chain seen.add(n) - raise NotImplementedError + directions.update(self._reset_group(current, n, xy, groups)) else: - an.xy = xy + an.xy = ac.xy + xy stack.append((n, current, angle, sign)) continue - ac = atoms[current] # simple non-chiral cases if len(env) == 2: n = next(n for n in env if n != previous) @@ -250,59 +214,58 @@ def _position_atoms(self, component, groups): else: angle += sign * D60 stack.append((n, current, angle, -sign)) - xy = ac.xy + Vector(BL, 0).rotate(angle) - + xy = Vector(BL, 0).rotate(angle) an = atoms[n] if not isnan(an.x): # reached layouted fragment. rotate the whole fragment and drop chain. stack.pop() seen.add(n) # prevent double processing in cases of 2 KK layouted fragments linked by chain - raise NotImplementedError + directions.update(self._reset_group(current, n, xy, groups)) else: - an.xy = xy + an.xy = ac.xy + xy elif len(env) == 3: n, m = (n for n in env if n != previous) # continue to grow to the same direction angle += sign * D60 stack.append((n, current, angle, -sign)) - xy = ac.xy + Vector(BL, 0).rotate(angle) + xy = Vector(BL, 0).rotate(angle) an = atoms[n] if not isnan(an.x): # reached layouted fragment. rotate the whole fragment and drop chain. stack.pop() seen.add(n) - raise NotImplementedError + directions.update(self._reset_group(current, n, xy, groups)) else: - an.xy = xy + an.xy = ac.xy + xy # make a side branch angle -= sign * D120 stack.append((m, current, angle, sign)) - xy = ac.xy + Vector(BL, 0).rotate(angle) + xy = Vector(BL, 0).rotate(angle) am = atoms[m] if not isnan(am.x): # reached layouted fragment. rotate the whole fragment and drop chain. stack.pop() - seen.add(n) - raise NotImplementedError + seen.add(m) + directions.update(self._reset_group(current, m, xy, groups)) else: - am.xy = xy + am.xy = ac.xy + xy else: # 4+ neighbors. position on circle delta = D360 / len(env) angle += D180 for n in env: if n != previous: angle += delta - xy = ac.xy + Vector(BL, 0).rotate(angle) + xy = Vector(BL, 0).rotate(angle) stack.append((n, current, angle, sign)) # keep sign to minimize overlaps an = atoms[n] if not isnan(an.x): # reached layouted fragment. rotate the whole fragment and drop chain. stack.pop() seen.add(n) - raise NotImplementedError + directions.update(self._reset_group(current, n, xy, groups)) else: - an.xy = xy + an.xy = ac.xy + xy def _position_cis_trans(self, current, previous, angle, s): """ @@ -367,11 +330,11 @@ def _position_cis_trans(self, current, previous, angle, s): sign = -1 if s else 1 angle -= sign * D60 - xy = ac.xy + Vector(BL, 0).rotate(angle) + xy = Vector(BL, 0).rotate(angle) yield n1, current, angle, sign, xy if n2: angle += sign * D120 - xy = ac.xy + Vector(BL, 0).rotate(angle) + xy = Vector(BL, 0).rotate(angle) yield n2, current, angle, -sign, xy def _apply_2d_templates(self): @@ -379,16 +342,37 @@ def _apply_2d_templates(self): Use predefined templates to layout atoms. """ atoms = self._atoms + bonds = self._bonds + seen = set() groups = [] - for q, layout in rules: - for m in q.get_mapping(self, automorphism_filter=False): - if not seen.isdisjoint(m.values()): # avoid any overlap + for q, layout, sub in rules: + for mp in q.get_mapping(self, automorphism_filter=False): + if not seen.isdisjoint(mp.values()): # avoid any overlap continue - seen.update(m.values()) - groups.append(set(m.values())) - for i, n in m.items(): - atoms[n].xy = layout[i - 1] + seen.update(mp.values()) + g = {n: None for n in mp.values()} + groups.append(g) + for i, n in mp.items(): + atoms[n].xy = layout[i] + + for i, n in mp.items(): + env = bonds[n] + if not (s := env.keys() - g.keys()): + continue + an = atoms[n] + if len(s) == 1 and i in sub: + xy = Vector(*sub[i]) + g[n] = {s.pop(): xy - an.xy} + continue + + v, c = Vector(0, 0), -1 + for m in env.keys() & g.keys(): + v += (atoms[m].xy - an.xy).normalise() + c += 1 + delta = D360 / len(env) + angle = v.angle() + delta * c / 2 # ideal position of frontal layouted atom + g[n] = {m: Vector(BL, 0).rotate(angle + delta * x) for x, m in enumerate(s, 1)} return groups def _apply_kamada_kawai(self, groups, outer_iterations, inner_iterations, outer_threshold, inner_threshold): From 318fc9dac4182aa667ac7729be64b7e72e77be6e Mon Sep 17 00:00:00 2001 From: Ramil Nugmanov Date: Mon, 29 Dec 2025 13:00:56 +0100 Subject: [PATCH 66/67] restore back smiles drawer code --- chython/algorithms/calculate2d/clean2d.js | 1 + chython/files/daylight/test/__init__.py | 14 - clean2d/README | 3 + clean2d/package-lock.json | 2521 +++++++++++++++++++++ clean2d/package.json | 23 + clean2d/src/index.js | 20 + clean2d/webpack.config.js | 12 + 7 files changed, 2580 insertions(+), 14 deletions(-) create mode 100644 chython/algorithms/calculate2d/clean2d.js create mode 100644 clean2d/README create mode 100644 clean2d/package-lock.json create mode 100644 clean2d/package.json create mode 100644 clean2d/src/index.js create mode 100644 clean2d/webpack.config.js diff --git a/chython/algorithms/calculate2d/clean2d.js b/chython/algorithms/calculate2d/clean2d.js new file mode 100644 index 00000000..6c60ef9b --- /dev/null +++ b/chython/algorithms/calculate2d/clean2d.js @@ -0,0 +1 @@ +!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.$=e():t.$=e()}(self,(function(){return(()=>{var t={348:t=>{class e{static clone(t){let i=Array.isArray(t)?Array():{};for(let r in t){let n=t[r];"function"==typeof n.clone?i[r]=n.clone():i[r]="object"==typeof n?e.clone(n):n}return i}static equals(t,e){if(t.length!==e.length)return!1;let i=t.slice().sort(),r=e.slice().sort();for(var n=0;n-1&&t.splice(i,1),t}static removeAll(t,e){return t.filter((function(t){return-1===e.indexOf(t)}))}static merge(t,e){let i=new Array(t.length+e.length);for(let e=0;e{const r=i(348);i(843),i(421);class n{constructor(t,e="-"){this.element=1===t.length?t.toUpperCase():t,this.drawExplicit=!1,this.ringbonds=Array(),this.rings=Array(),this.bondType=e,this.branchBond=null,this.isBridge=!1,this.isBridgeNode=!1,this.originalRings=Array(),this.bridgedRing=null,this.anchoredRings=Array(),this.bracket=null,this.plane=0,this.attachedPseudoElements={},this.hasAttachedPseudoElements=!1,this.isDrawn=!0,this.isConnectedToRing=!1,this.neighbouringElements=Array(),this.isPartOfAromaticRing=t!==this.element,this.bondCount=0,this.chirality="",this.isStereoCenter=!1,this.priority=0,this.mainChain=!1,this.hydrogenDirection="down",this.subtreeDepth=1,this.hasHydrogen=!1,this.class=void 0}addNeighbouringElement(t){this.neighbouringElements.push(t)}attachPseudoElement(t,e,i=0,r=0){null===i&&(i=0),null===r&&(r=0);let n=i+t+r;this.attachedPseudoElements[n]?this.attachedPseudoElements[n].count+=1:this.attachedPseudoElements[n]={element:t,count:1,hydrogenCount:i,previousElement:e,charge:r},this.hasAttachedPseudoElements=!0}getAttachedPseudoElements(){let t={},e=this;return Object.keys(this.attachedPseudoElements).sort().forEach((function(i){t[i]=e.attachedPseudoElements[i]})),t}getAttachedPseudoElementsCount(){return Object.keys(this.attachedPseudoElements).length}isHeteroAtom(){return"C"!==this.element&&"H"!==this.element}addAnchoredRing(t){r.contains(this.anchoredRings,{value:t})||this.anchoredRings.push(t)}getRingbondCount(){return this.ringbonds.length}backupRings(){this.originalRings=Array(this.rings.length);for(let t=0;t{const r=i(474),n=i(614),{getChargeText:s}=(i(929),i(843),i(421),i(537));t.exports=class{constructor(t,e,i){this.canvas="string"==typeof t||t instanceof String?document.getElementById(t):t,this.ctx=this.canvas.getContext("2d"),this.themeManager=e,this.opts=i,this.drawingWidth=0,this.drawingHeight=0,this.offsetX=0,this.offsetY=0,this.fontLarge=this.opts.fontSizeLarge+"pt Helvetica, Arial, sans-serif",this.fontSmall=this.opts.fontSizeSmall+"pt Helvetica, Arial, sans-serif",this.updateSize(this.opts.width,this.opts.height),this.ctx.font=this.fontLarge,this.hydrogenWidth=this.ctx.measureText("H").width,this.halfHydrogenWidth=this.hydrogenWidth/2,this.halfBondThickness=this.opts.bondThickness/2}updateSize(t,e){this.devicePixelRatio=window.devicePixelRatio||1,this.backingStoreRatio=this.ctx.webkitBackingStorePixelRatio||this.ctx.mozBackingStorePixelRatio||this.ctx.msBackingStorePixelRatio||this.ctx.oBackingStorePixelRatio||this.ctx.backingStorePixelRatio||1,this.ratio=this.devicePixelRatio/this.backingStoreRatio,1!==this.ratio?(this.canvas.width=t*this.ratio,this.canvas.height=e*this.ratio,this.canvas.style.width=t+"px",this.canvas.style.height=e+"px",this.ctx.setTransform(this.ratio,0,0,this.ratio,0,0)):(this.canvas.width=t*this.ratio,this.canvas.height=e*this.ratio)}setTheme(t){this.colors=t}scale(t){let e=-Number.MAX_VALUE,i=-Number.MAX_VALUE,r=Number.MAX_VALUE,n=Number.MAX_VALUE;for(var s=0;so.x&&(r=o.x),n>o.y&&(n=o.y)}var o=this.opts.padding;e+=o,i+=o,r-=o,n-=o,this.drawingWidth=e-r,this.drawingHeight=i-n;var h=this.canvas.offsetWidth/this.drawingWidth,a=this.canvas.offsetHeight/this.drawingHeight,l=h.5&&(e.stroke(),e.beginPath(),e.strokeStyle=this.themeManager.getColor(t.getRightElement())||this.themeManager.getColor("C"),m=!0),r.subtract(o),e.moveTo(r.x,r.y),r.add(n.multiplyScalar(o,2)),e.lineTo(r.x,r.y)}e.stroke(),e.restore()}drawDebugText(t,e,i){let r=this.ctx;r.save(),r.font="5px Droid Sans, sans-serif",r.textAlign="start",r.textBaseline="top",r.fillStyle="#ff0000",r.fillText(i,t+this.offsetX,e+this.offsetY),r.restore()}drawBall(t,e,i){let n=this.ctx;n.save(),n.beginPath(),n.arc(t+this.offsetX,e+this.offsetY,this.opts.bondLength/4.5,0,r.twoPI,!1),n.fillStyle=this.themeManager.getColor(i),n.fill(),n.restore()}drawPoint(t,e,i){let n=this.ctx,s=this.offsetX,o=this.offsetY;n.save(),n.globalCompositeOperation="destination-out",n.beginPath(),n.arc(t+s,e+o,1.5,0,r.twoPI,!0),n.closePath(),n.fill(),n.globalCompositeOperation="source-over",n.beginPath(),n.arc(t+this.offsetX,e+this.offsetY,.75,0,r.twoPI,!1),n.fillStyle=this.themeManager.getColor(i),n.fill(),n.restore()}drawText(t,e,i,n,o,h,a,l,g,d={}){let u=this.ctx,c=this.offsetX,p=this.offsetY;u.save(),u.textAlign="start",u.textBaseline="alphabetic";let f="",v=0;a&&(f=s(a),u.font=this.fontSmall,v=u.measureText(f).width);let m="0",b=0;l>0&&(m=l.toString(),u.font=this.fontSmall,b=u.measureText(m).width),1===a&&"N"===i&&d.hasOwnProperty("0O")&&d.hasOwnProperty("0O-1")&&(d={"0O":{element:"O",count:2,hydrogenCount:0,previousElement:"C",charge:""}},a=0),u.font=this.fontLarge,u.fillStyle=this.themeManager.getColor("BACKGROUND");let y=u.measureText(i);y.totalWidth=y.width+v,y.height=parseInt(this.fontLarge,10);let x=y.width>this.opts.fontSizeLarge?y.width:this.opts.fontSizeLarge;x/=1.5,u.globalCompositeOperation="destination-out",u.beginPath(),u.arc(t+c,e+p,x,0,r.twoPI,!0),u.closePath(),u.fill(),u.globalCompositeOperation="source-over";let S=-y.width/2,A=-y.width/2;u.fillStyle=this.themeManager.getColor(i),u.fillText(i,t+c+S,e+this.opts.halfFontSizeLarge+p),S+=y.width,a&&(u.font=this.fontSmall,u.fillText(f,t+c+S,e-this.opts.fifthFontSizeSmall+p),S+=v),l>0&&(u.font=this.fontSmall,u.fillText(m,t+c+A-b,e-this.opts.fifthFontSizeSmall+p),A-=b),u.font=this.fontLarge;let C=0,R=0;if(1===n){let i=t+c,r=e+p+this.opts.halfFontSizeLarge;C=this.hydrogenWidth,A-=C,"left"===o?i+=A:"right"===o||"up"===o&&h||"down"===o&&h?i+=S:"up"!==o||h?"down"!==o||h||(r+=this.opts.fontSizeLarge+this.opts.quarterFontSizeLarge,i-=this.halfHydrogenWidth):(r-=this.opts.fontSizeLarge+this.opts.quarterFontSizeLarge,i-=this.halfHydrogenWidth),u.fillText("H",i,r),S+=C}else if(n>1){let i=t+c,r=e+p+this.opts.halfFontSizeLarge;C=this.hydrogenWidth,u.font=this.fontSmall,R=u.measureText(n).width,A-=C+R,"left"===o?i+=A:"right"===o||"up"===o&&h||"down"===o&&h?i+=S:"up"!==o||h?"down"!==o||h||(r+=this.opts.fontSizeLarge+this.opts.quarterFontSizeLarge,i-=this.halfHydrogenWidth):(r-=this.opts.fontSizeLarge+this.opts.quarterFontSizeLarge,i-=this.halfHydrogenWidth),u.font=this.fontLarge,u.fillText("H",i,r),u.font=this.fontSmall,u.fillText(n,i+this.halfHydrogenWidth+R,r+this.opts.fifthFontSizeSmall),S+=C+this.halfHydrogenWidth+R}for(let i in d){if(!d.hasOwnProperty(i))continue;let r=0,n=0,h=d[i].element,a=d[i].count,l=d[i].hydrogenCount,g=d[i].charge;u.font=this.fontLarge,a>1&&l>0&&(r=u.measureText("(").width,n=u.measureText(")").width);let f=u.measureText(h).width,v=0,m="",b=0;C=0,l>0&&(C=this.hydrogenWidth),u.font=this.fontSmall,a>1&&(v=u.measureText(a).width),0!==g&&(m=s(g),b=u.measureText(m).width),R=0,l>1&&(R=u.measureText(l).width),u.font=this.fontLarge;let y=t+c,x=e+p+this.opts.halfFontSizeLarge;u.fillStyle=this.themeManager.getColor(h),a>0&&(A-=v),a>1&&l>0&&("left"===o?(A-=n,u.fillText(")",y+A,x)):(u.fillText("(",y+S,x),S+=r)),"left"===o?(A-=f,u.fillText(h,y+A,x)):(u.fillText(h,y+S,x),S+=f),l>0&&("left"===o?(A-=C+R,u.fillText("H",y+A,x),l>1&&(u.font=this.fontSmall,u.fillText(l,y+A+C,x+this.opts.fifthFontSizeSmall))):(u.fillText("H",y+S,x),S+=C,l>1&&(u.font=this.fontSmall,u.fillText(l,y+S,x+this.opts.fifthFontSizeSmall),S+=R))),u.font=this.fontLarge,a>1&&l>0&&("left"===o?(A-=r,u.fillText("(",y+A,x)):(u.fillText(")",y+S,x),S+=n)),u.font=this.fontSmall,a>1&&("left"===o?u.fillText(a,y+A+r+n+C+R+f,x+this.opts.fifthFontSizeSmall):(u.fillText(a,y+S,x+this.opts.fifthFontSizeSmall),S+=v)),0!==g&&("left"===o?u.fillText(m,y+A+r+n+C+R+f,e-this.opts.fifthFontSizeSmall+p):(u.fillText(m,y+S,e-this.opts.fifthFontSizeSmall+p),S+=b))}u.restore()}getChargeText(t){return 1===t?"+":2===t?"2+":-1===t?"-":-2===t?"2-":""}drawDebugPoint(t,e,i="",r="#f00"){this.drawCircle(t,e,2,r,!0,!0,i)}drawAromaticityRing(t){let e=this.ctx,i=r.apothemFromSideLength(this.opts.bondLength,t.getSize());e.save(),e.strokeStyle=this.themeManager.getColor("C"),e.lineWidth=this.opts.bondThickness,e.beginPath(),e.arc(t.center.x+this.offsetX,t.center.y+this.offsetY,i-this.opts.bondSpacing,0,2*Math.PI,!0),e.closePath(),e.stroke(),e.restore()}clear(){this.ctx.clearRect(0,0,this.canvas.offsetWidth,this.canvas.offsetHeight)}}},237:(t,e,i)=>{const r=i(474),n=i(348),s=i(614),o=i(929),h=(i(843),i(826)),a=i(427),l=i(421),g=i(333),d=i(841),u=i(707),c=i(473),p=i(654),f=i(207);t.exports=class{constructor(t){this.graph=null,this.doubleBondConfigCount=0,this.doubleBondConfig=null,this.ringIdCounter=0,this.ringConnectionIdCounter=0,this.canvasWrapper=null,this.totalOverlapScore=0,this.defaultOptions={width:500,height:500,scale:0,bondThickness:1,bondLength:30,shortBondLength:.8,bondSpacing:.17*30,atomVisualization:"default",isomeric:!0,debug:!1,terminalCarbons:!1,explicitHydrogens:!0,overlapSensitivity:.42,overlapResolutionIterations:1,compactDrawing:!0,fontFamily:"Arial, Helvetica, sans-serif",fontSizeLarge:11,fontSizeSmall:3,padding:10,experimentalSSSR:!1,kkThreshold:.1,kkInnerThreshold:.1,kkMaxIteration:2e4,kkMaxInnerIteration:50,kkMaxEnergy:1e9,themes:{dark:{C:"#fff",O:"#e74c3c",N:"#3498db",F:"#27ae60",CL:"#16a085",BR:"#d35400",I:"#8e44ad",P:"#d35400",S:"#f1c40f",B:"#e67e22",SI:"#e67e22",H:"#aaa",BACKGROUND:"#141414"},light:{C:"#222",O:"#e74c3c",N:"#3498db",F:"#27ae60",CL:"#16a085",BR:"#d35400",I:"#8e44ad",P:"#d35400",S:"#f1c40f",B:"#e67e22",SI:"#e67e22",H:"#666",BACKGROUND:"#fff"},oldschool:{C:"#000",O:"#000",N:"#000",F:"#000",CL:"#000",BR:"#000",I:"#000",P:"#000",S:"#000",B:"#000",SI:"#000",H:"#000",BACKGROUND:"#fff"},solarized:{C:"#586e75",O:"#dc322f",N:"#268bd2",F:"#859900",CL:"#16a085",BR:"#cb4b16",I:"#6c71c4",P:"#d33682",S:"#b58900",B:"#2aa198",SI:"#2aa198",H:"#657b83",BACKGROUND:"#fff"},"solarized-dark":{C:"#93a1a1",O:"#dc322f",N:"#268bd2",F:"#859900",CL:"#16a085",BR:"#cb4b16",I:"#6c71c4",P:"#d33682",S:"#b58900",B:"#2aa198",SI:"#2aa198",H:"#839496",BACKGROUND:"#fff"},matrix:{C:"#678c61",O:"#2fc079",N:"#4f7e7e",F:"#90d762",CL:"#82d967",BR:"#23755a",I:"#409931",P:"#c1ff8a",S:"#faff00",B:"#50b45a",SI:"#409931",H:"#426644",BACKGROUND:"#fff"},github:{C:"#24292f",O:"#cf222e",N:"#0969da",F:"#2da44e",CL:"#6fdd8b",BR:"#bc4c00",I:"#8250df",P:"#bf3989",S:"#d4a72c",B:"#fb8f44",SI:"#bc4c00",H:"#57606a",BACKGROUND:"#fff"},carbon:{C:"#161616",O:"#da1e28",N:"#0f62fe",F:"#198038",CL:"#007d79",BR:"#fa4d56",I:"#8a3ffc",P:"#ff832b",S:"#f1c21b",B:"#8a3800",SI:"#e67e22",H:"#525252",BACKGROUND:"#fff"},cyberpunk:{C:"#ea00d9",O:"#ff3131",N:"#0abdc6",F:"#00ff9f",CL:"#00fe00",BR:"#fe9f20",I:"#ff00ff",P:"#fe7f00",S:"#fcee0c",B:"#ff00ff",SI:"#ffffff",H:"#913cb1",BACKGROUND:"#fff"},gruvbox:{C:"#665c54",O:"#cc241d",N:"#458588",F:"#98971a",CL:"#79740e",BR:"#d65d0e",I:"#b16286",P:"#af3a03",S:"#d79921",B:"#689d6a",SI:"#427b58",H:"#7c6f64",BACKGROUND:"#fbf1c7"},"gruvbox-dark":{C:"#ebdbb2",O:"#cc241d",N:"#458588",F:"#98971a",CL:"#b8bb26",BR:"#d65d0e",I:"#b16286",P:"#fe8019",S:"#d79921",B:"#8ec07c",SI:"#83a598",H:"#bdae93",BACKGROUND:"#282828"},custom:{C:"#222",O:"#e74c3c",N:"#3498db",F:"#27ae60",CL:"#16a085",BR:"#d35400",I:"#8e44ad",P:"#d35400",S:"#f1c40f",B:"#e67e22",SI:"#e67e22",H:"#666",BACKGROUND:"#fff"}}},this.opts=f.extend(!0,this.defaultOptions,t),this.opts.halfBondSpacing=this.opts.bondSpacing/2,this.opts.bondLengthSq=this.opts.bondLength*this.opts.bondLength,this.opts.halfFontSizeLarge=this.opts.fontSizeLarge/2,this.opts.quarterFontSizeLarge=this.opts.fontSizeLarge/4,this.opts.fifthFontSizeSmall=this.opts.fontSizeSmall/5,this.theme=this.opts.themes.dark}draw(t,e,i="light",r=!1){this.initDraw(t,i,r),this.infoOnly||(this.themeManager=new p(this.opts.themes,i),this.canvasWrapper=new d(e,this.themeManager,this.opts)),r||(this.processGraph(),this.canvasWrapper.scale(this.graph.vertices),this.drawEdges(this.opts.debug),this.drawVertices(this.opts.debug),this.canvasWrapper.reset(),this.opts.debug&&(console.log(this.graph),console.log(this.rings),console.log(this.ringConnections)))}edgeRingCount(t){let e=this.graph.edges[t],i=this.graph.vertices[e.sourceId],r=this.graph.vertices[e.targetId];return Math.min(i.value.rings.length,r.value.rings.length)}getBridgedRings(){let t=Array();for(var e=0;ei&&(i=h,t=r,e=n)}}let o=-s.subtract(this.graph.vertices[t].position,this.graph.vertices[e].position).angle();if(!isNaN(o)){let t=o%.523599;for(t<.2617995?o-=t:o+=.523599-t,r=0;r1?t:""),i.delete("C")}if(i.has("H")){let t=i.get("H");e+="H"+(t>1?t:""),i.delete("H")}return Object.keys(a.atomicNumbers).sort().map((t=>{if(i.has(t)){let r=i.get(t);e+=t+(r>1?r:"")}})),e}getRingbondType(t,e){if(t.value.getRingbondCount()<1||e.value.getRingbondCount()<1)return null;for(var i=0;in&&(s=e.sourceId,o=e.targetId),this.getSubtreeOverlapScore(o,s,t.vertexScores).value>this.opts.overlapSensitivity){let e=this.graph.vertices[s],i=this.graph.vertices[o],n=i.getNeighbours(s);if(1===n.length){let t=this.graph.vertices[n[0]],s=t.position.getRotateAwayFromAngle(e.position,i.position,r.toRad(120));this.rotateSubtree(t.id,i.id,s,i.position);let o=this.getOverlapScore().total;o>this.totalOverlapScore?this.rotateSubtree(t.id,i.id,-s,i.position):this.totalOverlapScore=o}else if(2===n.length){if(0!==i.value.rings.length&&0!==e.value.rings.length)continue;let t=this.graph.vertices[n[0]],s=this.graph.vertices[n[1]];if(1===t.value.rings.length&&1===s.value.rings.length){if(t.value.rings[0]!==s.value.rings[0])continue}else{if(0!==t.value.rings.length||0!==s.value.rings.length)continue;{let n=t.position.getRotateAwayFromAngle(e.position,i.position,r.toRad(120)),o=s.position.getRotateAwayFromAngle(e.position,i.position,r.toRad(120));this.rotateSubtree(t.id,i.id,n,i.position),this.rotateSubtree(s.id,i.id,o,i.position);let h=this.getOverlapScore().total;h>this.totalOverlapScore?(this.rotateSubtree(t.id,i.id,-n,i.position),this.rotateSubtree(s.id,i.id,-o,i.position)):this.totalOverlapScore=h}}}t=this.getOverlapScore()}}}this.resolveSecondaryOverlaps(t.scores),this.opts.isomeric&&this.annotateStereochemistry(),this.opts.compactDrawing&&"default"===this.opts.atomVisualization&&this.initPseudoElements(),this.rotateDrawing()}initRings(){let t=new Map;for(var e=this.graph.vertices.length-1;e>=0;e--){let r=this.graph.vertices[e];if(0!==r.value.ringbonds.length)for(var i=0;i0&&this.addRingConnection(n)}for(e=0;e0;){let t=-1;for(e=0;er&&(r=e,n=t)}return n}getVerticesAt(t,e,i){let r=Array();for(var n=0;ni;){let n=this.graph.vertices[i],o=this.graph.vertices[r];if(!n.value.isDrawn||!o.value.isDrawn)continue;let h=s.subtract(n.position,o.position).lengthSq();if(hd[1]?0:1,sideCount:l,position:l[0]>l[1]?0:1,anCount:o,bnCount:h}}setRingCenter(t){let e=t.getSize(),i=new s(0,0);for(var r=0;r1||0==e.bnCount&&e.anCount>1){c[0].multiplyScalar(i.opts.halfBondSpacing),c[1].multiplyScalar(i.opts.halfBondSpacing);let t=new o(s.add(d,c[0]),s.add(u,c[0]),l,g),e=new o(s.add(d,c[1]),s.add(u,c[1]),l,g);this.canvasWrapper.drawLine(t),this.canvasWrapper.drawLine(e)}else if(e.sideCount[0]>e.sideCount[1]){c[0].multiplyScalar(i.opts.bondSpacing),c[1].multiplyScalar(i.opts.bondSpacing);let t=new o(s.add(d,c[0]),s.add(u,c[0]),l,g);t.shorten(this.opts.bondLength-this.opts.shortBondLength*this.opts.bondLength),this.canvasWrapper.drawLine(t),this.canvasWrapper.drawLine(new o(d,u,l,g))}else if(e.sideCount[0]e.totalSideCount[1]){c[0].multiplyScalar(i.opts.bondSpacing),c[1].multiplyScalar(i.opts.bondSpacing);let t=new o(s.add(d,c[0]),s.add(u,c[0]),l,g);t.shorten(this.opts.bondLength-this.opts.shortBondLength*this.opts.bondLength),this.canvasWrapper.drawLine(t),this.canvasWrapper.drawLine(new o(d,u,l,g))}else if(e.totalSideCount[0]<=e.totalSideCount[1]){c[0].multiplyScalar(i.opts.bondSpacing),c[1].multiplyScalar(i.opts.bondSpacing);let t=new o(s.add(d,c[1]),s.add(u,c[1]),l,g);t.shorten(this.opts.bondLength-this.opts.shortBondLength*this.opts.bondLength),this.canvasWrapper.drawLine(t),this.canvasWrapper.drawLine(new o(d,u,l,g))}}else if("#"===r.bondType){c[0].multiplyScalar(i.opts.bondSpacing/1.5),c[1].multiplyScalar(i.opts.bondSpacing/1.5);let t=new o(s.add(d,c[0]),s.add(u,c[0]),l,g),e=new o(s.add(d,c[1]),s.add(u,c[1]),l,g);this.canvasWrapper.drawLine(t),this.canvasWrapper.drawLine(e),this.canvasWrapper.drawLine(new o(d,u,l,g))}else if("."===r.bondType);else{let t=h.value.isStereoCenter,e=a.value.isStereoCenter;"up"===r.wedge?this.canvasWrapper.drawWedge(new o(d,u,l,g,t,e)):"down"===r.wedge?this.canvasWrapper.drawDashedWedge(new o(d,u,l,g,t,e)):this.canvasWrapper.drawLine(new o(d,u,l,g,t,e))}if(e){let e=s.midpoint(d,u);this.canvasWrapper.drawDebugText(e.x,e.y,"e: "+t)}}drawVertices(t){var e=this.graph.vertices.length;for(e=0;e0&&(t=this.graph.vertices[this.rings[0].members[0]]),null===t&&(t=this.graph.vertices[0]),this.createNextBond(t,null,0)}backupRingInformation(){this.originalRings=Array(),this.originalRingConnections=Array();for(var t=0;ts.subtract(e,l[0]).lengthSq()&&(u=l[1]);let c=s.subtract(o.position,u),p=s.subtract(h.position,u);-1===c.clockwise(p)?i.positioned||this.createRing(i,u,o,h):i.positioned||this.createRing(i,u,h,o)}else if(1===n.length){t.isSpiro=!0,i.isSpiro=!0;let o=this.graph.vertices[n[0]],h=s.subtract(e,o.position);h.invert(),h.normalize();let a=r.polyCircumradius(this.opts.bondLength,i.getSize());h.multiplyScalar(a),h.add(o.position),i.positioned||this.createRing(i,h,o)}}for(p=0;pr.opts.overlapSensitivity&&(n+=e,h++);let s=r.graph.vertices[t.id].position.clone();s.multiplyScalar(e),o.add(s)})),o.divide(n),{value:n/h,center:o}}getCurrentCenterOfMass(){let t=new s(0,0),e=0;for(var i=0;i1){let e=Array();for(var n=0;nh&&(this.rotateSubtree(t.id,e.common.id,2*r,e.common.position),this.rotateSubtree(i.id,e.common.id,-2*r,e.common.position))}else 1===e.vertices.length&&e.rings.length}}resolveSecondaryOverlaps(t){for(var e=0;ethis.opts.overlapSensitivity){let i=this.graph.vertices[t[e].id];if(i.isTerminal()){let t=this.getClosestVertex(i);if(t){let e=null;e=t.isTerminal()?0===t.id?this.graph.vertices[1].position:t.previousPosition:0===t.id?this.graph.vertices[1].position:t.position;let n=0===i.id?this.graph.vertices[1].position:i.previousPosition;i.position.rotateAwayFrom(e,n,r.toRad(20))}}}}getLastVertexWithAngle(t){let e=0,i=null;for(;!e&&t;)i=this.graph.vertices[t],e=i.angle,t=i.parentVertexId;return i}createNextBond(t,e=null,i=0,o=!1,h=!1){if(t.positioned&&!h)return;let a=!1;if(e){let i=this.graph.getEdge(t.id,e.id);"/"!==i.bondType&&"\\"!==i.bondType||++this.doubleBondConfigCount%2!=1||null===this.doubleBondConfig&&(this.doubleBondConfig=i.bondType,a=!0,null===e.parentVertexId&&t.value.branchBond&&("/"===this.doubleBondConfig?this.doubleBondConfig="\\":"\\"===this.doubleBondConfig&&(this.doubleBondConfig="/")))}if(!h)if(e)if(e.value.rings.length>0){let i=e.neighbours,r=null,o=new s(0,0);if(null===e.value.bridgedRing&&e.value.rings.length>1)for(var l=0;l0){let e=this.getRing(t.value.rings[0]);if(!e.positioned){let i=s.subtract(t.previousPosition,t.position);i.invert(),i.normalize();let n=r.polyCircumradius(this.opts.bondLength,e.getSize());i.multiplyScalar(n),i.add(t.position),this.createRing(e,i,t)}}else{t.value.isStereoCenter;let i=t.getNeighbours(),h=Array();for(l=0;l0){let e=r.toRad(60),n=-e,o=new s(this.opts.bondLength,0),h=new s(this.opts.bondLength,0);o.rotate(e).add(t.position),h.rotate(n).add(t.position);let a=this.getCurrentCenterOfMass(),l=o.distanceSq(a),d=h.distanceSq(a);i.angle=l3?r=r>0?Math.min(1.0472,r):r<0?Math.max(-1.0472,r):1.0472:r||(r=this.getLastVertexWithAngle(t.id).angle,r||(r=1.0472)),e&&!a){let e=this.graph.getEdge(t.id,i.id).bondType;"/"===e?("/"===this.doubleBondConfig||"\\"===this.doubleBondConfig&&(r=-r),this.doubleBondConfig=null):"\\"===e&&("/"===this.doubleBondConfig?r=-r:this.doubleBondConfig,this.doubleBondConfig=null)}i.angle=o?r:-r,this.createNextBond(i,t,g+i.angle)}}else if(2===h.length){let i=t.angle;i||(i=1.0472);let r=this.graph.getTreeDepth(h[0],t.id),n=this.graph.getTreeDepth(h[1],t.id),s=this.graph.vertices[h[0]],o=this.graph.vertices[h[1]];s.value.subtreeDepth=r,o.value.subtreeDepth=n;let a=this.graph.getTreeDepth(e?e.id:null,t.id);e&&(e.value.subtreeDepth=a);let l=0,d=1;"C"===o.value.element&&"C"!==s.value.element&&n>1&&r<5?(l=1,d=0):"C"!==o.value.element&&"C"===s.value.element&&r>1&&n<5?(l=0,d=1):n>r&&(l=1,d=0);let u=this.graph.vertices[h[l]],c=this.graph.vertices[h[d]],p=(this.graph.getEdge(t.id,u.id),this.graph.getEdge(t.id,c.id),!1);ai&&n>s?(o=this.graph.vertices[h[1]],a=this.graph.vertices[h[0]],l=this.graph.vertices[h[2]]):s>i&&s>n&&(o=this.graph.vertices[h[2]],a=this.graph.vertices[h[0]],l=this.graph.vertices[h[1]]),e&&e.value.rings.length<1&&o.value.rings.length<1&&a.value.rings.length<1&&l.value.rings.length<1&&1===this.graph.getTreeDepth(a.id,t.id)&&1===this.graph.getTreeDepth(l.id,t.id)&&this.graph.getTreeDepth(o.id,t.id)>1?(o.angle=-t.angle,t.angle>=0?(a.angle=r.toRad(30),l.angle=r.toRad(90)):(a.angle=-r.toRad(30),l.angle=-r.toRad(90)),this.createNextBond(o,t,g+o.angle),this.createNextBond(a,t,g+a.angle),this.createNextBond(l,t,g+l.angle)):(o.angle=0,a.angle=r.toRad(90),l.angle=-r.toRad(90),this.createNextBond(o,t,g+o.angle),this.createNextBond(a,t,g+a.angle),this.createNextBond(l,t,g+l.angle))}else if(4===h.length){let e=this.graph.getTreeDepth(h[0],t.id),i=this.graph.getTreeDepth(h[1],t.id),n=this.graph.getTreeDepth(h[2],t.id),s=this.graph.getTreeDepth(h[3],t.id),o=this.graph.vertices[h[0]],a=this.graph.vertices[h[1]],l=this.graph.vertices[h[2]],d=this.graph.vertices[h[3]];o.value.subtreeDepth=e,a.value.subtreeDepth=i,l.value.subtreeDepth=n,d.value.subtreeDepth=s,i>e&&i>n&&i>s?(o=this.graph.vertices[h[1]],a=this.graph.vertices[h[0]],l=this.graph.vertices[h[2]],d=this.graph.vertices[h[3]]):n>e&&n>i&&n>s?(o=this.graph.vertices[h[2]],a=this.graph.vertices[h[0]],l=this.graph.vertices[h[1]],d=this.graph.vertices[h[3]]):s>e&&s>i&&s>n&&(o=this.graph.vertices[h[3]],a=this.graph.vertices[h[0]],l=this.graph.vertices[h[1]],d=this.graph.vertices[h[2]]),o.angle=-r.toRad(36),a.angle=r.toRad(36),l.angle=-r.toRad(108),d.angle=r.toRad(108),this.createNextBond(o,t,g+o.angle),this.createNextBond(a,t,g+a.angle),this.createNextBond(l,t,g+l.angle),this.createNextBond(d,t,g+d.angle)}}}getCommonRingbondNeighbour(t){let e=t.neighbours;for(var i=0;i0&&i.value.rings.length>0&&this.areVerticesInSameRing(e,i))}isRingAromatic(t){for(var e=0;el&&(l=a[e][1].length),i=0;ig&&(g=a[e][1][i].length);for(e=0;ee[1][i][r])return-1;if(t[1][i][r]1&&s.value.hasHydrogen,C=s.value.hasHydrogen?1:0;for(e=0;ee[0]?-1:t[0]=0&&(i=i===y?x:y,o[d[e]]!==t);e--);this.graph.getEdge(s.id,t).wedge=i}}s.value.chirality=b}}visitStereochemistry(t,e,i,r,n,s,o=0){i[t]=1;let h=this.graph.vertices[t],a=h.value.getAtomicNumber();r.length<=s&&r.push(Array());for(var l=0;l0)continue;if("P"===i.value.element)continue;if("C"===i.value.element&&3===n.length&&"N"===n[0].value.element&&"N"===n[1].value.element&&"N"===n[2].value.element)continue;let s=0,o=0;for(e=0;e1&&o++}if(o>1||s<2)continue;let h=null;for(e=0;e1&&(h=t)}for(e=0;e1)continue;t.value.isDrawn=!1;let r=a.maxBonds[t.value.element]-t.value.bondCount,s="";t.value.bracket&&(r=t.value.bracket.hcount,s=t.value.bracket.charge||0),i.value.attachPseudoElement(t.value.element,h?h.value.element:null,r,s)}}for(t=0;t{class e{constructor(t,e,i=1){this.id=null,this.sourceId=t,this.targetId=e,this.weight=i,this.bondType="-",this.isPartOfAromaticRing=!1,this.center=!1,this.wedge=""}setBondType(t){this.bondType=t,this.weight=e.bonds[t]}static get bonds(){return{"-":1,"/":1,"\\":1,"=":2,"#":3,$:4}}}t.exports=e},707:(t,e,i)=>{const r=i(474),n=(i(614),i(843)),s=i(826),o=(i(421),i(427));class h{constructor(t,e=!1){this.vertices=Array(),this.edges=Array(),this.vertexIdsToEdgeId={},this.isomeric=e,this._time=0,this._init(t)}_init(t,e=0,i=null,r=!1){let h=new o(t.atom.element?t.atom.element:t.atom,t.bond);h.branchBond=t.branchBond,h.ringbonds=t.ringbonds,h.bracket=t.atom.element?t.atom:null,h.class=t.atom.class;let a=new n(h),l=this.vertices[i];if(this.addVertex(a),null!==i){a.setParentVertexId(i),a.value.addNeighbouringElement(l.value.element),l.addChild(a.id),l.value.addNeighbouringElement(h.element),l.spanningTreeChildren.push(a.id);let t=new s(i,a.id,1),e=null;r?(t.setBondType(a.value.branchBond||"-"),e=a.id,t.setBondType(a.value.branchBond||"-"),e=a.id):(t.setBondType(l.value.bondType||"-"),e=l.id),this.addEdge(t)}let g=t.ringbondCount+1;h.bracket&&(g+=h.bracket.hcount);let d=0;if(h.bracket&&h.bracket.chirality){h.isStereoCenter=!0,d=h.bracket.hcount;for(var u=0;ui[r][s]+i[s][n]&&(i[r][n]=i[r][s]+i[s][n]);return i}getSubgraphDistanceMatrix(t){let e=t.length,i=this.getSubgraphAdjacencyMatrix(t),r=Array(e);for(var n=0;nr[n][o]+r[o][s]&&(r[n][s]=r[n][o]+r[o][s]);return r}getAdjacencyList(){let t=this.vertices.length,e=Array(t);for(var i=0;i0;){let t=n.shift(),i=this.vertices[t];e(i);for(var s=0;sr&&(r=s)}return r+1}traverseTree(t,e,i,r=999999,n=!1,s=1,o=null){if(null===o&&(o=new Uint8Array(this.vertices.length)),s>r+1||1===o[t])return;o[t]=1;let h=this.vertices[t],a=h.getNeighbours(e);(!n||s>1)&&i(h);for(var l=0;lt&&!1===S[u]&&(t=n,e=u,i=s,r=o)}return[e,t,i,r]},D=function(t,e,i){let r=0,n=0,s=0,o=y[t],h=x[t],a=A[t],l=C[t];for(u=f;u--;){if(u===t)continue;let e=y[u],i=x[u],g=a[u],d=l[u],c=(o-e)*(o-e),p=1/Math.pow(c+(h-i)*(h-i),1.5);r+=d*(1-g*(h-i)*(h-i)*p),n+=d*(1-g*c*p),s+=d*(g*(o-e)*(h-i)*p)}0===r&&(r=.1),0===n&&(n=.1),0===s&&(s=.1);let g=e/r+i/s;g/=s/r-n/s;let d=-(s*g+e)/r;y[t]+=d,x[t]+=g;let c,p,v,m,b,S=N[t];for(e=0,i=0,o=y[t],h=x[t],u=f;u--;)t!==u&&(c=y[u],p=x[u],v=S[u][0],m=S[u][1],b=1/Math.sqrt((o-c)*(o-c)+(h-p)*(h-p)),d=l[u]*(o-c-a[u]*(o-c)*b),g=l[u]*(h-p-a[u]*(h-p)*b),S[u]=[d,g],e+=d,i+=g,O[u]+=d-v,M[u]+=g-m);O[t]=e,M[t]=i},F=0,z=0,H=0,V=0,W=0,U=0;for(;g>o&&a>W;)for(W++,[F,g,z,H]=E(),V=g,U=0;V>h&&l>U;)U++,D(F,z,H),[V,z,H]=k(F);for(u=f;u--;){let e=t[u],i=this.vertices[e];i.position.x=y[u],i.position.y=x[u],i.positioned=!0,i.forcePositioned=!0}}_bridgeDfs(t,e,i,r,n,s,o){e[t]=!0,i[t]=r[t]=++this._time;for(var h=0;hi[t]&&o.push([t,a]))}}static getConnectedComponents(t){let e=t.length,i=new Array(e),r=new Array;i.fill(!1);for(var n=0;n1&&r.push(e)}return r}static getConnectedComponentCount(t){let e=t.length,i=new Array(e),r=0;i.fill(!1);for(var n=0;n{const r=i(614);class n{constructor(t=new r(0,0),e=new r(0,0),i=null,n=null,s=!1,o=!1){this.from=t,this.to=e,this.elementFrom=i,this.elementTo=n,this.chiralFrom=s,this.chiralTo=o}clone(){return new n(this.from.clone(),this.to.clone(),this.elementFrom,this.elementTo)}getLength(){return Math.sqrt(Math.pow(this.to.x-this.from.x,2)+Math.pow(this.to.y-this.from.y,2))}getAngle(){return r.subtract(this.getRightVector(),this.getLeftVector()).angle()}getRightVector(){return this.from.x{class e{static round(t,e){return e=e||1,Number(Math.round(t+"e"+e)+"e-"+e)}static meanAngle(t){let e=0,i=0;for(var r=0;r{t.exports=class{static extend(){let t=this,e={},i=!1,r=0,n=arguments.length;"[object Boolean]"===Object.prototype.toString.call(arguments[0])&&(i=arguments[0],r++);let s=function(r){for(var n in r)Object.prototype.hasOwnProperty.call(r,n)&&(i&&"[object Object]"===Object.prototype.toString.call(r[n])?e[n]=t.extend(!0,e[n],r[n]):e[n]=r[n])};for(;r{t.exports=function(){"use strict";function t(e,i,r,n){this.message=e,this.expected=i,this.found=r,this.location=n,this.name="SyntaxError","function"==typeof Error.captureStackTrace&&Error.captureStackTrace(this,t)}return function(t,e){function i(){this.constructor=t}i.prototype=e.prototype,t.prototype=new i}(t,Error),t.buildMessage=function(t,e){var i={literal:function(t){return'"'+n(t.text)+'"'},class:function(t){var e,i="";for(e=0;e0){for(e=1,r=1;ett&&(tt=J,et=[]),et.push(t))}function ht(e,i){return new t(e,null,null,i)}function at(){var t,i,r,n,s,o,a,l,g;if(J,t=J,i=function(){var t;return J,t=function(){var t,i,r,n;return J,t=J,66===e.charCodeAt(J)?(i="B",J++):(i=h,ot(b)),i!==h?(114===e.charCodeAt(J)?(r="r",J++):(r=h,ot(y)),r===h&&(r=null),r!==h?t=i=[i,r]:(J=t,t=h)):(J=t,t=h),t===h&&(t=J,67===e.charCodeAt(J)?(i="C",J++):(i=h,ot(x)),i!==h?(108===e.charCodeAt(J)?(r="l",J++):(r=h,ot(S)),r===h&&(r=null),r!==h?t=i=[i,r]:(J=t,t=h)):(J=t,t=h),t===h&&(A.test(e.charAt(J))?(t=e.charAt(J),J++):(t=h,ot(C)))),t!==h&&(t=(n=t).length>1?n.join(""):n),t}(),t===h&&(t=dt())===h&&(t=function(){var t,i,r,n,s,o,a,l,g,d;return J,t=J,91===e.charCodeAt(J)?(i="[",J++):(i=h,ot(p)),i!==h?(r=function(){var t,i,r,n;return J,t=J,O.test(e.charAt(J))?(i=e.charAt(J),J++):(i=h,ot(M)),i!==h?(k.test(e.charAt(J))?(r=e.charAt(J),J++):(r=h,ot(E)),r===h&&(r=null),r!==h?(k.test(e.charAt(J))?(n=e.charAt(J),J++):(n=h,ot(E)),n===h&&(n=null),n!==h?t=i=[i,r,n]:(J=t,t=h)):(J=t,t=h)):(J=t,t=h),t!==h&&(t=Number(t.join(""))),t}(),r===h&&(r=null),r!==h?("se"===e.substr(J,2)?(n="se",J+=2):(n=h,ot(f)),n===h&&("as"===e.substr(J,2)?(n="as",J+=2):(n=h,ot(v)),n===h&&(n=dt())===h&&(n=function(){var t,i,r;return J,t=J,B.test(e.charAt(J))?(i=e.charAt(J),J++):(i=h,ot(I)),i!==h?(P.test(e.charAt(J))?(r=e.charAt(J),J++):(r=h,ot(L)),r===h&&(r=null),r!==h?t=i=[i,r]:(J=t,t=h)):(J=t,t=h),t!==h&&(t=t.join("")),t}(),n===h&&(n=ut()))),n!==h?(s=function(){var t,i,r,n,s,o,a;return J,t=J,64===e.charCodeAt(J)?(i="@",J++):(i=h,ot(D)),i!==h?(64===e.charCodeAt(J)?(r="@",J++):(r=h,ot(D)),r===h&&(r=J,"TH"===e.substr(J,2)?(n="TH",J+=2):(n=h,ot(F)),n!==h?(z.test(e.charAt(J))?(s=e.charAt(J),J++):(s=h,ot(H)),s!==h?r=n=[n,s]:(J=r,r=h)):(J=r,r=h),r===h&&(r=J,"AL"===e.substr(J,2)?(n="AL",J+=2):(n=h,ot(V)),n!==h?(z.test(e.charAt(J))?(s=e.charAt(J),J++):(s=h,ot(H)),s!==h?r=n=[n,s]:(J=r,r=h)):(J=r,r=h),r===h&&(r=J,"SP"===e.substr(J,2)?(n="SP",J+=2):(n=h,ot(W)),n!==h?(U.test(e.charAt(J))?(s=e.charAt(J),J++):(s=h,ot(q)),s!==h?r=n=[n,s]:(J=r,r=h)):(J=r,r=h),r===h&&(r=J,"TB"===e.substr(J,2)?(n="TB",J+=2):(n=h,ot(j)),n!==h?(O.test(e.charAt(J))?(s=e.charAt(J),J++):(s=h,ot(M)),s!==h?(k.test(e.charAt(J))?(o=e.charAt(J),J++):(o=h,ot(E)),o===h&&(o=null),o!==h?r=n=[n,s,o]:(J=r,r=h)):(J=r,r=h)):(J=r,r=h),r===h&&(r=J,"OH"===e.substr(J,2)?(n="OH",J+=2):(n=h,ot(_)),n!==h?(O.test(e.charAt(J))?(s=e.charAt(J),J++):(s=h,ot(M)),s!==h?(k.test(e.charAt(J))?(o=e.charAt(J),J++):(o=h,ot(E)),o===h&&(o=null),o!==h?r=n=[n,s,o]:(J=r,r=h)):(J=r,r=h)):(J=r,r=h)))))),r===h&&(r=null),r!==h?t=i=[i,r]:(J=t,t=h)):(J=t,t=h),t!==h&&(t=(a=t)[1]?"@"==a[1]?"@@":a[1].join("").replace(",",""):"@"),t}(),s===h&&(s=null),s!==h?(o=function(){var t,i,r,n;return J,t=J,72===e.charCodeAt(J)?(i="H",J++):(i=h,ot(K)),i!==h?(k.test(e.charAt(J))?(r=e.charAt(J),J++):(r=h,ot(E)),r===h&&(r=null),r!==h?t=i=[i,r]:(J=t,t=h)):(J=t,t=h),t!==h&&(t=(n=t)[1]?Number(n[1]):1),t}(),o===h&&(o=null),o!==h?(a=function(){var t;return J,t=function(){var t,i,r,n,s,o;return J,t=J,43===e.charCodeAt(J)?(i="+",J++):(i=h,ot(G)),i!==h?(43===e.charCodeAt(J)?(r="+",J++):(r=h,ot(G)),r===h&&(r=J,O.test(e.charAt(J))?(n=e.charAt(J),J++):(n=h,ot(M)),n!==h?(k.test(e.charAt(J))?(s=e.charAt(J),J++):(s=h,ot(E)),s===h&&(s=null),s!==h?r=n=[n,s]:(J=r,r=h)):(J=r,r=h)),r===h&&(r=null),r!==h?t=i=[i,r]:(J=t,t=h)):(J=t,t=h),t!==h&&(t=(o=t)[1]?"+"!=o[1]?Number(o[1].join("")):2:1),t}(),t===h&&(t=function(){var t,i,r,n,s,o;return J,t=J,45===e.charCodeAt(J)?(i="-",J++):(i=h,ot(X)),i!==h?(45===e.charCodeAt(J)?(r="-",J++):(r=h,ot(X)),r===h&&(r=J,O.test(e.charAt(J))?(n=e.charAt(J),J++):(n=h,ot(M)),n!==h?(k.test(e.charAt(J))?(s=e.charAt(J),J++):(s=h,ot(E)),s===h&&(s=null),s!==h?r=n=[n,s]:(J=r,r=h)):(J=r,r=h)),r===h&&(r=null),r!==h?t=i=[i,r]:(J=t,t=h)):(J=t,t=h),t!==h&&(t=(o=t)[1]?"-"!=o[1]?-Number(o[1].join("")):-2:-1),t}()),t}(),a===h&&(a=null),a!==h?(l=function(){var t,i,r,n,s,o,a;if(J,t=J,58===e.charCodeAt(J)?(i=":",J++):(i=h,ot(Y)),i!==h){if(r=J,O.test(e.charAt(J))?(n=e.charAt(J),J++):(n=h,ot(M)),n!==h){for(s=[],k.test(e.charAt(J))?(o=e.charAt(J),J++):(o=h,ot(E));o!==h;)s.push(o),k.test(e.charAt(J))?(o=e.charAt(J),J++):(o=h,ot(E));s!==h?r=n=[n,s]:(J=r,r=h)}else J=r,r=h;r===h&&(Z.test(e.charAt(J))?(r=e.charAt(J),J++):(r=h,ot($))),r!==h?t=i=[i,r]:(J=t,t=h)}else J=t,t=h;return t!==h&&(a=t,t=Number(a[1][0]+a[1][1].join(""))),t}(),l===h&&(l=null),l!==h?(93===e.charCodeAt(J)?(g="]",J++):(g=h,ot(m)),g!==h?t=i=[i,r,n,s,o,a,l,g]:(J=t,t=h)):(J=t,t=h)):(J=t,t=h)):(J=t,t=h)):(J=t,t=h)):(J=t,t=h)):(J=t,t=h)):(J=t,t=h),t!==h&&(t={isotope:(d=t)[1],element:d[2],chirality:d[3],hcount:d[4],charge:d[5],class:d[6]}),t}(),t===h&&(t=ut())),t}(),i!==h){for(r=[],n=lt();n!==h;)r.push(n),n=lt();if(r!==h){for(n=[],s=J,(o=gt())===h&&(o=null),o!==h&&(a=ct())!==h?s=o=[o,a]:(J=s,s=h);s!==h;)n.push(s),s=J,(o=gt())===h&&(o=null),o!==h&&(a=ct())!==h?s=o=[o,a]:(J=s,s=h);if(n!==h){for(s=[],o=lt();o!==h;)s.push(o),o=lt();if(s!==h)if((o=gt())===h&&(o=null),o!==h)if((a=at())===h&&(a=null),a!==h){for(l=[],g=lt();g!==h;)l.push(g),g=lt();l!==h?t=i=[i,r,n,s,o,a,l]:(J=t,t=h)}else J=t,t=h;else J=t,t=h;else J=t,t=h}else J=t,t=h}else J=t,t=h}else J=t,t=h;return t!==h&&(t=function(t){for(var e=[],i=[],r=0;r{const r=i(348),n=i(614),s=(i(843),i(333));class o{constructor(t){this.id=null,this.members=t,this.edges=[],this.insiders=[],this.neighbours=[],this.positioned=!1,this.center=new n(0,0),this.rings=[],this.isBridged=!1,this.isPartOfBridged=!1,this.isSpiro=!1,this.isFused=!1,this.centralAngle=0,this.canFlip=!0}clone(){let t=new o(this.members);return t.id=this.id,t.insiders=r.clone(this.insiders),t.neighbours=r.clone(this.neighbours),t.positioned=this.positioned,t.center=this.center.clone(),t.rings=r.clone(this.rings),t.isBridged=this.isBridged,t.isPartOfBridged=this.isPartOfBridged,t.isSpiro=this.isSpiro,t.isFused=this.isFused,t.centralAngle=this.centralAngle,t.canFlip=this.canFlip,t}getSize(){return this.members.length}getPolygon(t){let e=[];for(let i=0;i{i(843),i(421),t.exports=class{constructor(t,e){this.id=null,this.firstRingId=t.id,this.secondRingId=e.id,this.vertices=new Set;for(var i=0;i2)return!0;for(let e of this.vertices)if(t[e].value.rings.length>2)return!0;return!1}static isBridge(t,e,i,r){let n=null;for(let s=0;s{const r=i(707);class n{static getRings(t,e=!1){let i=t.getComponentsAdjacencyMatrix();if(0===i.length)return null;let s=r.getConnectedComponents(i),o=Array();for(var h=0;he){if(t===e+1)for(n[a][l]=[r[a][l].length],s=r[a][l].length;s--;)for(n[a][l][s]=[r[a][l][s].length],o=r[a][l][s].length;o--;)for(n[a][l][s][o]=[r[a][l][s][o].length],h=r[a][l][s][o].length;h--;)n[a][l][s][o][h]=[r[a][l][s][o][0],r[a][l][s][o][1]];else n[a][l]=Array();for(i[a][l]=e,r[a][l]=[[]],s=r[a][g][0].length;s--;)r[a][l][0].push(r[a][g][0][s]);for(s=r[g][l][0].length;s--;)r[a][l][0].push(r[g][l][0][s])}else if(t===e){if(r[a][g].length&&r[g][l].length)if(r[a][l].length){let t=Array();for(s=r[a][g][0].length;s--;)t.push(r[a][g][0][s]);for(s=r[g][l][0].length;s--;)t.push(r[g][l][0][s]);r[a][l].push(t)}else{let t=Array();for(s=r[a][g][0].length;s--;)t.push(r[a][g][0][s]);for(s=r[g][l][0].length;s--;)t.push(r[g][l][0][s]);r[a][l][0]=t}}else if(t===e-1)if(n[a][l].length){let t=Array();for(s=r[a][g][0].length;s--;)t.push(r[a][g][0][s]);for(s=r[g][l][0].length;s--;)t.push(r[g][l][0][s]);n[a][l].push(t)}else{let t=Array();for(s=r[a][g][0].length;s--;)t.push(r[a][g][0][s]);for(s=r[g][l][0].length;s--;)t.push(r[g][l][0][s]);n[a][l][0]=t}}return{d:i,pe:r,pe_prime:n}}static getRingCandidates(t,e,i){let r=t.length,n=Array(),s=0;for(let o=0;oa)return l}else for(let r=0;ra)return l}return l}static getEdgeCount(t){let e=0,i=t.length;for(var r=i-1;r--;)for(var n=i;n--;)1===t[r][n]&&e++;return e}static getEdgeList(t){let e=t.length,i=Array();for(var r=e-1;r--;)for(var n=e;n--;)1===t[r][n]&&i.push([r,n]);return i}static bondsToAtoms(t){let e=new Set;for(var i=t.length;i--;)e.add(t[i][0]),e.add(t[i][1]);return e}static getBondCount(t,e){let i=0;for(let r of t)for(let n of t)r!==n&&(i+=e[r][n]);return i/2}static pathSetsContain(t,e,i,r,s,o){for(var h=t.length;h--;){if(n.isSupersetOf(e,t[h]))return!0;if(t[h].size===e.size&&n.areSetsEqual(t[h],e))return!0}let a=0,l=!1;for(h=i.length;h--;)for(var g=r.length;g--;)(i[h][0]===r[g][0]&&i[h][1]===r[g][1]||i[h][1]===r[g][0]&&i[h][0]===r[g][1])&&a++,a===i.length&&(l=!0);let d=!1;if(l)for(let t of e)if(o[t]{t.exports=class{constructor(t,e){this.colors=t,this.theme=this.colors[e]}getColor(t){return t&&(t=t.toUpperCase())in this.theme?this.theme[t]:this.theme.C}setTheme(t){this.colors.hasOwnProperty(t)&&(this.theme=this.colors[t])}}},537:t=>{t.exports={getChargeText:function(t){return 1===t?"+":2===t?"2+":-1===t?"-":-2===t?"2-":""}}},614:t=>{class e{constructor(t,e){0==arguments.length?(this.x=0,this.y=0):1==arguments.length?(this.x=t.x,this.y=t.y):(this.x=t,this.y=e)}clone(){return new e(this.x,this.y)}toString(){return"("+this.x+","+this.y+")"}add(t){return this.x+=t.x,this.y+=t.y,this}subtract(t){return this.x-=t.x,this.y-=t.y,this}divide(t){return this.x/=t,this.y/=t,this}multiply(t){return this.x*=t.x,this.y*=t.y,this}multiplyScalar(t){return this.x*=t,this.y*=t,this}invert(){return this.x=-this.x,this.y=-this.y,this}angle(){return Math.atan2(this.y,this.x)}distance(t){return Math.sqrt((t.x-this.x)*(t.x-this.x)+(t.y-this.y)*(t.y-this.y))}distanceSq(t){return(t.x-this.x)*(t.x-this.x)+(t.y-this.y)*(t.y-this.y)}clockwise(t){let e=this.y*t.x,i=this.x*t.y;return e>i?-1:e===i?0:1}relativeClockwise(t,e){let i=(this.y-t.y)*(e.x-t.x),r=(this.x-t.x)*(e.y-t.y);return i>r?-1:i===r?0:1}rotate(t){let i=new e(0,0),r=Math.cos(t),n=Math.sin(t);return i.x=this.x*r-this.y*n,i.y=this.x*n+this.y*r,this.x=i.x,this.y=i.y,this}rotateAround(t,e){let i=Math.sin(t),r=Math.cos(t);this.x-=e.x,this.y-=e.y;let n=this.x*r-this.y*i,s=this.x*i+this.y*r;return this.x=n+e.x,this.y=s+e.y,this}rotateTo(t,i,r=0){this.x+=.001,this.y-=.001;let n=e.subtract(this,i),s=e.subtract(t,i),o=e.angle(s,n);return this.rotateAround(o+r,i),this}rotateAwayFrom(t,e,i){this.rotateAround(i,e);let r=this.distanceSq(t);this.rotateAround(-2*i,e),this.distanceSq(t)n?i:-i}getRotateToAngle(t,i){let r=e.subtract(this,i),n=e.subtract(t,i),s=e.angle(n,r);return Number.isNaN(s)?0:s}isInPolygon(t){let e=!1;for(let i=0,r=t.length-1;ithis.y!=t[r].y>this.y&&this.x<(t[r].x-t[i].x)*(this.y-t[i].y)/(t[r].y-t[i].y)+t[i].x&&(e=!e);return e}length(){return Math.sqrt(this.x*this.x+this.y*this.y)}lengthSq(){return this.x*this.x+this.y*this.y}normalize(){return this.divide(this.length()),this}normalized(){return e.divideScalar(this,this.length())}whichSide(t,e){return(this.x-t.x)*(e.y-t.y)-(this.y-t.y)*(e.x-t.x)}sameSideAs(t,e,i){let r=this.whichSide(t,e),n=i.whichSide(t,e);return r<0&&n<0||0==r&&0==n||r>0&&n>0}static add(t,i){return new e(t.x+i.x,t.y+i.y)}static subtract(t,i){return new e(t.x-i.x,t.y-i.y)}static multiply(t,i){return new e(t.x*i.x,t.y*i.y)}static multiplyScalar(t,i){return new e(t.x,t.y).multiplyScalar(i)}static midpoint(t,i){return new e((t.x+i.x)/2,(t.y+i.y)/2)}static normals(t,i){let r=e.subtract(i,t);return[new e(-r.y,r.x),new e(r.y,-r.x)]}static units(t,i){let r=e.subtract(i,t);return[new e(-r.y,r.x).normalize(),new e(r.y,-r.x).normalize()]}static divide(t,i){return new e(t.x/i.x,t.y/i.y)}static divideScalar(t,i){return new e(t.x/i,t.y/i)}static dot(t,e){return t.x*e.x+t.y*e.y}static angle(t,i){let r=e.dot(t,i);return Math.acos(r/(t.length()*i.length()))}static threePointangle(t,i,r){let n=e.subtract(i,t),s=e.subtract(r,i),o=t.distance(i),h=i.distance(r);return Math.acos(e.dot(n,s)/(o*h))}static scalarProjection(t,i){let r=i.normalized();return e.dot(t,r)}static averageDirection(t){let i=new e(0,0);for(var r=0;r{const r=i(474),n=i(348),s=i(614);i(427);class o{constructor(t,e=0,i=0){this.id=null,this.value=t,this.position=new s(e||0,i||0),this.previousPosition=new s(0,0),this.parentVertexId=null,this.children=Array(),this.spanningTreeChildren=Array(),this.edges=Array(),this.positioned=!1,this.angle=null,this.dir=1,this.neighbourCount=0,this.neighbours=Array(),this.neighbouringElements=Array(),this.forcePositioned=!1}setPosition(t,e){this.position.x=t,this.position.y=e}setPositionFromVector(t){this.position.x=t.x,this.position.y=t.y}addChild(t){this.children.push(t),this.neighbours.push(t),this.neighbourCount++}addRingbondChild(t,e){if(this.children.push(t),this.value.bracket){let i=1;0===this.id&&0===this.value.bracket.hcount&&(i=0),1===this.value.bracket.hcount&&0===e&&(i=2),1===this.value.bracket.hcount&&1===e&&(i=this.neighbours.length<3?2:3),null===this.value.bracket.hcount&&0===e&&(i=1),null===this.value.bracket.hcount&&1===e&&(i=this.neighbours.length<3?1:2),this.neighbours.splice(i,0,t)}else this.neighbours.push(t);this.neighbourCount++}setParentVertexId(t){this.neighbourCount++,this.parentVertexId=t,this.neighbours.push(t)}isTerminal(){return!!this.value.hasAttachedPseudoElements||null===this.parentVertexId&&this.children.length<2||0===this.children.length}clone(){let t=new o(this.value,this.position.x,this.position.y);return t.id=this.id,t.previousPosition=new s(this.previousPosition.x,this.previousPosition.y),t.parentVertexId=this.parentVertexId,t.children=n.clone(this.children),t.spanningTreeChildren=n.clone(this.spanningTreeChildren),t.edges=n.clone(this.edges),t.positioned=this.positioned,t.angle=this.angle,t.forcePositioned=this.forcePositioned,t}equals(t){return this.id===t.id}getAngle(t=null,e=!1){let i=null;return i=t?s.subtract(this.position,t):s.subtract(this.position,this.previousPosition),e?r.toDeg(i.angle()):i.angle()}getTextDirection(t){let e=this.getDrawnNeighbours(t),i=Array();if(1===t.length)return"right";for(let r=0;r{var e=t&&t.__esModule?()=>t.default:()=>t;return i.d(e,{a:e}),e},i.d=(t,e)=>{for(var r in e)i.o(e,r)&&!i.o(t,r)&&Object.defineProperty(t,r,{enumerable:!0,get:e[r]})},i.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e),i.r=t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})};var r={};return(()=>{"use strict";i.r(r),i.d(r,{clean2d:()=>o});var t=i(237),e=i.n(t),n=i(19),s=i.n(n);function o(t){const i=new(e())({}),r=s().parse(t);i.initDraw(r,"light",!1),i.processGraph();let n=i.graph.vertices,o=Array();for(let t=0;t -======== # Copyright 2025 Ramil Nugmanov ->>>>>>>> master:chython/files/daylight/test/__init__.py # This file is part of chython. # # chython is free software; you can redistribute it and/or modify @@ -20,13 +16,3 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -<<<<<<<< HEAD:chython/periodictable/base/__init__.py -from .dynamic import * -from .element import * -from .query import * - - -__all__ = ['Element', 'DynamicElement', 'Query', 'ExtendedQuery', - 'QueryElement', 'AnyElement', 'AnyMetal', 'ListElement'] -======== ->>>>>>>> master:chython/files/daylight/test/__init__.py diff --git a/clean2d/README b/clean2d/README new file mode 100644 index 00000000..6c4d4216 --- /dev/null +++ b/clean2d/README @@ -0,0 +1,3 @@ +# for rebuilding clean2d blob type: +npm init +npm run build diff --git a/clean2d/package-lock.json b/clean2d/package-lock.json new file mode 100644 index 00000000..47fcd31b --- /dev/null +++ b/clean2d/package-lock.json @@ -0,0 +1,2521 @@ +{ + "name": "clean2d", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@babel/code-frame": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.13.tgz", + "integrity": "sha512-HV1Cm0Q3ZrpCR93tkWOYiuYIgLxZXZFVG2VgK+MBWjUqZTundupbfx2aXarXuw5Ko5aMcjtJgbSs4vUGBS5v6g==", + "dev": true, + "requires": { + "@babel/highlight": "^7.12.13" + } + }, + "@babel/compat-data": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.12.13.tgz", + "integrity": "sha512-U/hshG5R+SIoW7HVWIdmy1cB7s3ki+r3FpyEZiCgpi4tFgPnX/vynY80ZGSASOIrUM6O7VxOgCZgdt7h97bUGg==", + "dev": true + }, + "@babel/core": { + "version": "7.12.16", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.12.16.tgz", + "integrity": "sha512-t/hHIB504wWceOeaOoONOhu+gX+hpjfeN6YRBT209X/4sibZQfSF1I0HFRRlBe97UZZosGx5XwUg1ZgNbelmNw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.12.13", + "@babel/generator": "^7.12.15", + "@babel/helper-module-transforms": "^7.12.13", + "@babel/helpers": "^7.12.13", + "@babel/parser": "^7.12.16", + "@babel/template": "^7.12.13", + "@babel/traverse": "^7.12.13", + "@babel/types": "^7.12.13", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.1", + "json5": "^2.1.2", + "lodash": "^4.17.19", + "semver": "^5.4.1", + "source-map": "^0.5.0" + } + }, + "@babel/generator": { + "version": "7.12.15", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.12.15.tgz", + "integrity": "sha512-6F2xHxBiFXWNSGb7vyCUTBF8RCLY66rS0zEPcP8t/nQyXjha5EuK4z7H5o7fWG8B4M7y6mqVWq1J+1PuwRhecQ==", + "dev": true, + "requires": { + "@babel/types": "^7.12.13", + "jsesc": "^2.5.1", + "source-map": "^0.5.0" + } + }, + "@babel/helper-annotate-as-pure": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.12.13.tgz", + "integrity": "sha512-7YXfX5wQ5aYM/BOlbSccHDbuXXFPxeoUmfWtz8le2yTkTZc+BxsiEnENFoi2SlmA8ewDkG2LgIMIVzzn2h8kfw==", + "dev": true, + "requires": { + "@babel/types": "^7.12.13" + } + }, + "@babel/helper-builder-binary-assignment-operator-visitor": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.12.13.tgz", + "integrity": "sha512-CZOv9tGphhDRlVjVkAgm8Nhklm9RzSmWpX2my+t7Ua/KT616pEzXsQCjinzvkRvHWJ9itO4f296efroX23XCMA==", + "dev": true, + "requires": { + "@babel/helper-explode-assignable-expression": "^7.12.13", + "@babel/types": "^7.12.13" + } + }, + "@babel/helper-compilation-targets": { + "version": "7.12.16", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.12.16.tgz", + "integrity": "sha512-dBHNEEaZx7F3KoUYqagIhRIeqyyuI65xMndMZ3WwGwEBI609I4TleYQHcrS627vbKyNTXqShoN+fvYD9HuQxAg==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.12.13", + "@babel/helper-validator-option": "^7.12.16", + "browserslist": "^4.14.5", + "semver": "^5.5.0" + } + }, + "@babel/helper-create-class-features-plugin": { + "version": "7.12.16", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.12.16.tgz", + "integrity": "sha512-KbSEj8l9zYkMVHpQqM3wJNxS1d9h3U9vm/uE5tpjMbaj3lTp+0noe3KPsV5dSD9jxKnf9jO9Ip9FX5PKNZCKow==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.12.13", + "@babel/helper-member-expression-to-functions": "^7.12.16", + "@babel/helper-optimise-call-expression": "^7.12.13", + "@babel/helper-replace-supers": "^7.12.13", + "@babel/helper-split-export-declaration": "^7.12.13" + } + }, + "@babel/helper-create-regexp-features-plugin": { + "version": "7.12.16", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.12.16.tgz", + "integrity": "sha512-jAcQ1biDYZBdaAxB4yg46/XirgX7jBDiMHDbwYQOgtViLBXGxJpZQ24jutmBqAIB/q+AwB6j+NbBXjKxEY8vqg==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.12.13", + "regexpu-core": "^4.7.1" + } + }, + "@babel/helper-explode-assignable-expression": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.12.13.tgz", + "integrity": "sha512-5loeRNvMo9mx1dA/d6yNi+YiKziJZFylZnCo1nmFF4qPU4yJ14abhWESuSMQSlQxWdxdOFzxXjk/PpfudTtYyw==", + "dev": true, + "requires": { + "@babel/types": "^7.12.13" + } + }, + "@babel/helper-function-name": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.12.13.tgz", + "integrity": "sha512-TZvmPn0UOqmvi5G4vvw0qZTpVptGkB1GL61R6lKvrSdIxGm5Pky7Q3fpKiIkQCAtRCBUwB0PaThlx9vebCDSwA==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.12.13", + "@babel/template": "^7.12.13", + "@babel/types": "^7.12.13" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.12.13.tgz", + "integrity": "sha512-DjEVzQNz5LICkzN0REdpD5prGoidvbdYk1BVgRUOINaWJP2t6avB27X1guXK1kXNrX0WMfsrm1A/ZBthYuIMQg==", + "dev": true, + "requires": { + "@babel/types": "^7.12.13" + } + }, + "@babel/helper-hoist-variables": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.12.13.tgz", + "integrity": "sha512-KSC5XSj5HreRhYQtZ3cnSnQwDzgnbdUDEFsxkN0m6Q3WrCRt72xrnZ8+h+pX7YxM7hr87zIO3a/v5p/H3TrnVw==", + "dev": true, + "requires": { + "@babel/types": "^7.12.13" + } + }, + "@babel/helper-member-expression-to-functions": { + "version": "7.12.16", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.12.16.tgz", + "integrity": "sha512-zYoZC1uvebBFmj1wFAlXwt35JLEgecefATtKp20xalwEK8vHAixLBXTGxNrVGEmTT+gzOThUgr8UEdgtalc1BQ==", + "dev": true, + "requires": { + "@babel/types": "^7.12.13" + } + }, + "@babel/helper-module-imports": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.12.13.tgz", + "integrity": "sha512-NGmfvRp9Rqxy0uHSSVP+SRIW1q31a7Ji10cLBcqSDUngGentY4FRiHOFZFE1CLU5eiL0oE8reH7Tg1y99TDM/g==", + "dev": true, + "requires": { + "@babel/types": "^7.12.13" + } + }, + "@babel/helper-module-transforms": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.12.13.tgz", + "integrity": "sha512-acKF7EjqOR67ASIlDTupwkKM1eUisNAjaSduo5Cz+793ikfnpe7p4Q7B7EWU2PCoSTPWsQkR7hRUWEIZPiVLGA==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.12.13", + "@babel/helper-replace-supers": "^7.12.13", + "@babel/helper-simple-access": "^7.12.13", + "@babel/helper-split-export-declaration": "^7.12.13", + "@babel/helper-validator-identifier": "^7.12.11", + "@babel/template": "^7.12.13", + "@babel/traverse": "^7.12.13", + "@babel/types": "^7.12.13", + "lodash": "^4.17.19" + } + }, + "@babel/helper-optimise-call-expression": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.12.13.tgz", + "integrity": "sha512-BdWQhoVJkp6nVjB7nkFWcn43dkprYauqtk++Py2eaf/GRDFm5BxRqEIZCiHlZUGAVmtwKcsVL1dC68WmzeFmiA==", + "dev": true, + "requires": { + "@babel/types": "^7.12.13" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.12.13.tgz", + "integrity": "sha512-C+10MXCXJLiR6IeG9+Wiejt9jmtFpxUc3MQqCmPY8hfCjyUGl9kT+B2okzEZrtykiwrc4dbCPdDoz0A/HQbDaA==", + "dev": true + }, + "@babel/helper-remap-async-to-generator": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.12.13.tgz", + "integrity": "sha512-Qa6PU9vNcj1NZacZZI1Mvwt+gXDH6CTfgAkSjeRMLE8HxtDK76+YDId6NQR+z7Rgd5arhD2cIbS74r0SxD6PDA==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.12.13", + "@babel/helper-wrap-function": "^7.12.13", + "@babel/types": "^7.12.13" + } + }, + "@babel/helper-replace-supers": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.12.13.tgz", + "integrity": "sha512-pctAOIAMVStI2TMLhozPKbf5yTEXc0OJa0eENheb4w09SrgOWEs+P4nTOZYJQCqs8JlErGLDPDJTiGIp3ygbLg==", + "dev": true, + "requires": { + "@babel/helper-member-expression-to-functions": "^7.12.13", + "@babel/helper-optimise-call-expression": "^7.12.13", + "@babel/traverse": "^7.12.13", + "@babel/types": "^7.12.13" + } + }, + "@babel/helper-simple-access": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.12.13.tgz", + "integrity": "sha512-0ski5dyYIHEfwpWGx5GPWhH35j342JaflmCeQmsPWcrOQDtCN6C1zKAVRFVbK53lPW2c9TsuLLSUDf0tIGJ5hA==", + "dev": true, + "requires": { + "@babel/types": "^7.12.13" + } + }, + "@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.12.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.12.1.tgz", + "integrity": "sha512-Mf5AUuhG1/OCChOJ/HcADmvcHM42WJockombn8ATJG3OnyiSxBK/Mm5x78BQWvmtXZKHgbjdGL2kin/HOLlZGA==", + "dev": true, + "requires": { + "@babel/types": "^7.12.1" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.12.13.tgz", + "integrity": "sha512-tCJDltF83htUtXx5NLcaDqRmknv652ZWCHyoTETf1CXYJdPC7nohZohjUgieXhv0hTJdRf2FjDueFehdNucpzg==", + "dev": true, + "requires": { + "@babel/types": "^7.12.13" + } + }, + "@babel/helper-validator-identifier": { + "version": "7.12.11", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz", + "integrity": "sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw==", + "dev": true + }, + "@babel/helper-validator-option": { + "version": "7.12.16", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.12.16.tgz", + "integrity": "sha512-uCgsDBPUQDvzr11ePPo4TVEocxj8RXjUVSC/Y8N1YpVAI/XDdUwGJu78xmlGhTxj2ntaWM7n9LQdRtyhOzT2YQ==", + "dev": true + }, + "@babel/helper-wrap-function": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.12.13.tgz", + "integrity": "sha512-t0aZFEmBJ1LojdtJnhOaQEVejnzYhyjWHSsNSNo8vOYRbAJNh6r6GQF7pd36SqG7OKGbn+AewVQ/0IfYfIuGdw==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.12.13", + "@babel/template": "^7.12.13", + "@babel/traverse": "^7.12.13", + "@babel/types": "^7.12.13" + } + }, + "@babel/helpers": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.12.13.tgz", + "integrity": "sha512-oohVzLRZ3GQEk4Cjhfs9YkJA4TdIDTObdBEZGrd6F/T0GPSnuV6l22eMcxlvcvzVIPH3VTtxbseudM1zIE+rPQ==", + "dev": true, + "requires": { + "@babel/template": "^7.12.13", + "@babel/traverse": "^7.12.13", + "@babel/types": "^7.12.13" + } + }, + "@babel/highlight": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.12.13.tgz", + "integrity": "sha512-kocDQvIbgMKlWxXe9fof3TQ+gkIPOUSEYhJjqUjvKMez3krV7vbzYCDq39Oj11UAVK7JqPVGQPlgE85dPNlQww==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.12.11", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.12.16", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.12.16.tgz", + "integrity": "sha512-c/+u9cqV6F0+4Hpq01jnJO+GLp2DdT63ppz9Xa+6cHaajM9VFzK/iDXiKK65YtpeVwu+ctfS6iqlMqRgQRzeCw==", + "dev": true + }, + "@babel/plugin-proposal-async-generator-functions": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.12.13.tgz", + "integrity": "sha512-1KH46Hx4WqP77f978+5Ye/VUbuwQld2hph70yaw2hXS2v7ER2f3nlpNMu909HO2rbvP0NKLlMVDPh9KXklVMhA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13", + "@babel/helper-remap-async-to-generator": "^7.12.13", + "@babel/plugin-syntax-async-generators": "^7.8.0" + } + }, + "@babel/plugin-proposal-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.12.13.tgz", + "integrity": "sha512-8SCJ0Ddrpwv4T7Gwb33EmW1V9PY5lggTO+A8WjyIwxrSHDUyBw4MtF96ifn1n8H806YlxbVCoKXbbmzD6RD+cA==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.12.13", + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-proposal-dynamic-import": { + "version": "7.12.16", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.12.16.tgz", + "integrity": "sha512-yiDkYFapVxNOCcBfLnsb/qdsliroM+vc3LHiZwS4gh7pFjo5Xq3BDhYBNn3H3ao+hWPvqeeTdU+s+FIvokov+w==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13", + "@babel/plugin-syntax-dynamic-import": "^7.8.0" + } + }, + "@babel/plugin-proposal-export-namespace-from": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.12.13.tgz", + "integrity": "sha512-INAgtFo4OnLN3Y/j0VwAgw3HDXcDtX+C/erMvWzuV9v71r7urb6iyMXu7eM9IgLr1ElLlOkaHjJ0SbCmdOQ3Iw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3" + } + }, + "@babel/plugin-proposal-json-strings": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.12.13.tgz", + "integrity": "sha512-v9eEi4GiORDg8x+Dmi5r8ibOe0VXoKDeNPYcTTxdGN4eOWikrJfDJCJrr1l5gKGvsNyGJbrfMftC2dTL6oz7pg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13", + "@babel/plugin-syntax-json-strings": "^7.8.0" + } + }, + "@babel/plugin-proposal-logical-assignment-operators": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.12.13.tgz", + "integrity": "sha512-fqmiD3Lz7jVdK6kabeSr1PZlWSUVqSitmHEe3Z00dtGTKieWnX9beafvavc32kjORa5Bai4QNHgFDwWJP+WtSQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" + } + }, + "@babel/plugin-proposal-nullish-coalescing-operator": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.12.13.tgz", + "integrity": "sha512-Qoxpy+OxhDBI5kRqliJFAl4uWXk3Bn24WeFstPH0iLymFehSAUR8MHpqU7njyXv/qbo7oN6yTy5bfCmXdKpo1Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.0" + } + }, + "@babel/plugin-proposal-numeric-separator": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.12.13.tgz", + "integrity": "sha512-O1jFia9R8BUCl3ZGB7eitaAPu62TXJRHn7rh+ojNERCFyqRwJMTmhz+tJ+k0CwI6CLjX/ee4qW74FSqlq9I35w==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13", + "@babel/plugin-syntax-numeric-separator": "^7.10.4" + } + }, + "@babel/plugin-proposal-object-rest-spread": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.12.13.tgz", + "integrity": "sha512-WvA1okB/0OS/N3Ldb3sziSrXg6sRphsBgqiccfcQq7woEn5wQLNX82Oc4PlaFcdwcWHuQXAtb8ftbS8Fbsg/sg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13", + "@babel/plugin-syntax-object-rest-spread": "^7.8.0", + "@babel/plugin-transform-parameters": "^7.12.13" + } + }, + "@babel/plugin-proposal-optional-catch-binding": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.12.13.tgz", + "integrity": "sha512-9+MIm6msl9sHWg58NvqpNpLtuFbmpFYk37x8kgnGzAHvX35E1FyAwSUt5hIkSoWJFSAH+iwU8bJ4fcD1zKXOzg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.0" + } + }, + "@babel/plugin-proposal-optional-chaining": { + "version": "7.12.16", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.12.16.tgz", + "integrity": "sha512-O3ohPwOhkwji5Mckb7F/PJpJVJY3DpPsrt/F0Bk40+QMk9QpAIqeGusHWqu/mYqsM8oBa6TziL/2mbERWsUZjg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13", + "@babel/helper-skip-transparent-expression-wrappers": "^7.12.1", + "@babel/plugin-syntax-optional-chaining": "^7.8.0" + } + }, + "@babel/plugin-proposal-private-methods": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.12.13.tgz", + "integrity": "sha512-sV0V57uUwpauixvR7s2o75LmwJI6JECwm5oPUY5beZB1nBl2i37hc7CJGqB5G+58fur5Y6ugvl3LRONk5x34rg==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.12.13", + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-proposal-unicode-property-regex": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.12.13.tgz", + "integrity": "sha512-XyJmZidNfofEkqFV5VC/bLabGmO5QzenPO/YOfGuEbgU+2sSwMmio3YLb4WtBgcmmdwZHyVyv8on77IUjQ5Gvg==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.12.13", + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-export-namespace-from": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz", + "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-top-level-await": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.12.13.tgz", + "integrity": "sha512-A81F9pDwyS7yM//KwbCSDqy3Uj4NMIurtplxphWxoYtNPov7cJsDkAFNNyVlIZ3jwGycVsurZ+LtOA8gZ376iQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-arrow-functions": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.12.13.tgz", + "integrity": "sha512-tBtuN6qtCTd+iHzVZVOMNp+L04iIJBpqkdY42tWbmjIT5wvR2kx7gxMBsyhQtFzHwBbyGi9h8J8r9HgnOpQHxg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-async-to-generator": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.12.13.tgz", + "integrity": "sha512-psM9QHcHaDr+HZpRuJcE1PXESuGWSCcbiGFFhhwfzdbTxaGDVzuVtdNYliAwcRo3GFg0Bc8MmI+AvIGYIJG04A==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.12.13", + "@babel/helper-plugin-utils": "^7.12.13", + "@babel/helper-remap-async-to-generator": "^7.12.13" + } + }, + "@babel/plugin-transform-block-scoped-functions": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.12.13.tgz", + "integrity": "sha512-zNyFqbc3kI/fVpqwfqkg6RvBgFpC4J18aKKMmv7KdQ/1GgREapSJAykLMVNwfRGO3BtHj3YQZl8kxCXPcVMVeg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-block-scoping": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.12.13.tgz", + "integrity": "sha512-Pxwe0iqWJX4fOOM2kEZeUuAxHMWb9nK+9oh5d11bsLoB0xMg+mkDpt0eYuDZB7ETrY9bbcVlKUGTOGWy7BHsMQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-classes": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.12.13.tgz", + "integrity": "sha512-cqZlMlhCC1rVnxE5ZGMtIb896ijL90xppMiuWXcwcOAuFczynpd3KYemb91XFFPi3wJSe/OcrX9lXoowatkkxA==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.12.13", + "@babel/helper-function-name": "^7.12.13", + "@babel/helper-optimise-call-expression": "^7.12.13", + "@babel/helper-plugin-utils": "^7.12.13", + "@babel/helper-replace-supers": "^7.12.13", + "@babel/helper-split-export-declaration": "^7.12.13", + "globals": "^11.1.0" + } + }, + "@babel/plugin-transform-computed-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.12.13.tgz", + "integrity": "sha512-dDfuROUPGK1mTtLKyDPUavmj2b6kFu82SmgpztBFEO974KMjJT+Ytj3/oWsTUMBmgPcp9J5Pc1SlcAYRpJ2hRA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-destructuring": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.12.13.tgz", + "integrity": "sha512-Dn83KykIFzjhA3FDPA1z4N+yfF3btDGhjnJwxIj0T43tP0flCujnU8fKgEkf0C1biIpSv9NZegPBQ1J6jYkwvQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-dotall-regex": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.12.13.tgz", + "integrity": "sha512-foDrozE65ZFdUC2OfgeOCrEPTxdB3yjqxpXh8CH+ipd9CHd4s/iq81kcUpyH8ACGNEPdFqbtzfgzbT/ZGlbDeQ==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.12.13", + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-duplicate-keys": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.12.13.tgz", + "integrity": "sha512-NfADJiiHdhLBW3pulJlJI2NB0t4cci4WTZ8FtdIuNc2+8pslXdPtRRAEWqUY+m9kNOk2eRYbTAOipAxlrOcwwQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-exponentiation-operator": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.12.13.tgz", + "integrity": "sha512-fbUelkM1apvqez/yYx1/oICVnGo2KM5s63mhGylrmXUxK/IAXSIf87QIxVfZldWf4QsOafY6vV3bX8aMHSvNrA==", + "dev": true, + "requires": { + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.12.13", + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-for-of": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.12.13.tgz", + "integrity": "sha512-xCbdgSzXYmHGyVX3+BsQjcd4hv4vA/FDy7Kc8eOpzKmBBPEOTurt0w5fCRQaGl+GSBORKgJdstQ1rHl4jbNseQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-function-name": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.12.13.tgz", + "integrity": "sha512-6K7gZycG0cmIwwF7uMK/ZqeCikCGVBdyP2J5SKNCXO5EOHcqi+z7Jwf8AmyDNcBgxET8DrEtCt/mPKPyAzXyqQ==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.12.13", + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-literals": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.12.13.tgz", + "integrity": "sha512-FW+WPjSR7hiUxMcKqyNjP05tQ2kmBCdpEpZHY1ARm96tGQCCBvXKnpjILtDplUnJ/eHZ0lALLM+d2lMFSpYJrQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-member-expression-literals": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.12.13.tgz", + "integrity": "sha512-kxLkOsg8yir4YeEPHLuO2tXP9R/gTjpuTOjshqSpELUN3ZAg2jfDnKUvzzJxObun38sw3wm4Uu69sX/zA7iRvg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-modules-amd": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.12.13.tgz", + "integrity": "sha512-JHLOU0o81m5UqG0Ulz/fPC68/v+UTuGTWaZBUwpEk1fYQ1D9LfKV6MPn4ttJKqRo5Lm460fkzjLTL4EHvCprvA==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.12.13", + "@babel/helper-plugin-utils": "^7.12.13", + "babel-plugin-dynamic-import-node": "^2.3.3" + } + }, + "@babel/plugin-transform-modules-commonjs": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.12.13.tgz", + "integrity": "sha512-OGQoeVXVi1259HjuoDnsQMlMkT9UkZT9TpXAsqWplS/M0N1g3TJAn/ByOCeQu7mfjc5WpSsRU+jV1Hd89ts0kQ==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.12.13", + "@babel/helper-plugin-utils": "^7.12.13", + "@babel/helper-simple-access": "^7.12.13", + "babel-plugin-dynamic-import-node": "^2.3.3" + } + }, + "@babel/plugin-transform-modules-systemjs": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.12.13.tgz", + "integrity": "sha512-aHfVjhZ8QekaNF/5aNdStCGzwTbU7SI5hUybBKlMzqIMC7w7Ho8hx5a4R/DkTHfRfLwHGGxSpFt9BfxKCoXKoA==", + "dev": true, + "requires": { + "@babel/helper-hoist-variables": "^7.12.13", + "@babel/helper-module-transforms": "^7.12.13", + "@babel/helper-plugin-utils": "^7.12.13", + "@babel/helper-validator-identifier": "^7.12.11", + "babel-plugin-dynamic-import-node": "^2.3.3" + } + }, + "@babel/plugin-transform-modules-umd": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.12.13.tgz", + "integrity": "sha512-BgZndyABRML4z6ibpi7Z98m4EVLFI9tVsZDADC14AElFaNHHBcJIovflJ6wtCqFxwy2YJ1tJhGRsr0yLPKoN+w==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.12.13", + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.12.13.tgz", + "integrity": "sha512-Xsm8P2hr5hAxyYblrfACXpQKdQbx4m2df9/ZZSQ8MAhsadw06+jW7s9zsSw6he+mJZXRlVMyEnVktJo4zjk1WA==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.12.13" + } + }, + "@babel/plugin-transform-new-target": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.12.13.tgz", + "integrity": "sha512-/KY2hbLxrG5GTQ9zzZSc3xWiOy379pIETEhbtzwZcw9rvuaVV4Fqy7BYGYOWZnaoXIQYbbJ0ziXLa/sKcGCYEQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-object-super": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.12.13.tgz", + "integrity": "sha512-JzYIcj3XtYspZDV8j9ulnoMPZZnF/Cj0LUxPOjR89BdBVx+zYJI9MdMIlUZjbXDX+6YVeS6I3e8op+qQ3BYBoQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13", + "@babel/helper-replace-supers": "^7.12.13" + } + }, + "@babel/plugin-transform-parameters": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.12.13.tgz", + "integrity": "sha512-e7QqwZalNiBRHCpJg/P8s/VJeSRYgmtWySs1JwvfwPqhBbiWfOcHDKdeAi6oAyIimoKWBlwc8oTgbZHdhCoVZA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-property-literals": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.12.13.tgz", + "integrity": "sha512-nqVigwVan+lR+g8Fj8Exl0UQX2kymtjcWfMOYM1vTYEKujeyv2SkMgazf2qNcK7l4SDiKyTA/nHCPqL4e2zo1A==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-regenerator": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.12.13.tgz", + "integrity": "sha512-lxb2ZAvSLyJ2PEe47hoGWPmW22v7CtSl9jW8mingV4H2sEX/JOcrAj2nPuGWi56ERUm2bUpjKzONAuT6HCn2EA==", + "dev": true, + "requires": { + "regenerator-transform": "^0.14.2" + } + }, + "@babel/plugin-transform-reserved-words": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.12.13.tgz", + "integrity": "sha512-xhUPzDXxZN1QfiOy/I5tyye+TRz6lA7z6xaT4CLOjPRMVg1ldRf0LHw0TDBpYL4vG78556WuHdyO9oi5UmzZBg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-shorthand-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.12.13.tgz", + "integrity": "sha512-xpL49pqPnLtf0tVluuqvzWIgLEhuPpZzvs2yabUHSKRNlN7ScYU7aMlmavOeyXJZKgZKQRBlh8rHbKiJDraTSw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-spread": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.12.13.tgz", + "integrity": "sha512-dUCrqPIowjqk5pXsx1zPftSq4sT0aCeZVAxhdgs3AMgyaDmoUT0G+5h3Dzja27t76aUEIJWlFgPJqJ/d4dbTtg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13", + "@babel/helper-skip-transparent-expression-wrappers": "^7.12.1" + } + }, + "@babel/plugin-transform-sticky-regex": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.12.13.tgz", + "integrity": "sha512-Jc3JSaaWT8+fr7GRvQP02fKDsYk4K/lYwWq38r/UGfaxo89ajud321NH28KRQ7xy1Ybc0VUE5Pz8psjNNDUglg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-template-literals": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.12.13.tgz", + "integrity": "sha512-arIKlWYUgmNsF28EyfmiQHJLJFlAJNYkuQO10jL46ggjBpeb2re1P9K9YGxNJB45BqTbaslVysXDYm/g3sN/Qg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-typeof-symbol": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.12.13.tgz", + "integrity": "sha512-eKv/LmUJpMnu4npgfvs3LiHhJua5fo/CysENxa45YCQXZwKnGCQKAg87bvoqSW1fFT+HA32l03Qxsm8ouTY3ZQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-unicode-escapes": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.12.13.tgz", + "integrity": "sha512-0bHEkdwJ/sN/ikBHfSmOXPypN/beiGqjo+o4/5K+vxEFNPRPdImhviPakMKG4x96l85emoa0Z6cDflsdBusZbw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-unicode-regex": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.12.13.tgz", + "integrity": "sha512-mDRzSNY7/zopwisPZ5kM9XKCfhchqIYwAKRERtEnhYscZB79VRekuRSoYbN0+KVe3y8+q1h6A4svXtP7N+UoCA==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.12.13", + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/preset-env": { + "version": "7.12.16", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.12.16.tgz", + "integrity": "sha512-BXCAXy8RE/TzX416pD2hsVdkWo0G+tYd16pwnRV4Sc0fRwTLRS/Ssv8G5RLXUGQv7g4FG7TXkdDJxCjQ5I+Zjg==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.12.13", + "@babel/helper-compilation-targets": "^7.12.16", + "@babel/helper-module-imports": "^7.12.13", + "@babel/helper-plugin-utils": "^7.12.13", + "@babel/helper-validator-option": "^7.12.16", + "@babel/plugin-proposal-async-generator-functions": "^7.12.13", + "@babel/plugin-proposal-class-properties": "^7.12.13", + "@babel/plugin-proposal-dynamic-import": "^7.12.16", + "@babel/plugin-proposal-export-namespace-from": "^7.12.13", + "@babel/plugin-proposal-json-strings": "^7.12.13", + "@babel/plugin-proposal-logical-assignment-operators": "^7.12.13", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.12.13", + "@babel/plugin-proposal-numeric-separator": "^7.12.13", + "@babel/plugin-proposal-object-rest-spread": "^7.12.13", + "@babel/plugin-proposal-optional-catch-binding": "^7.12.13", + "@babel/plugin-proposal-optional-chaining": "^7.12.16", + "@babel/plugin-proposal-private-methods": "^7.12.13", + "@babel/plugin-proposal-unicode-property-regex": "^7.12.13", + "@babel/plugin-syntax-async-generators": "^7.8.0", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-dynamic-import": "^7.8.0", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3", + "@babel/plugin-syntax-json-strings": "^7.8.0", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.0", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.0", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.0", + "@babel/plugin-syntax-optional-chaining": "^7.8.0", + "@babel/plugin-syntax-top-level-await": "^7.12.13", + "@babel/plugin-transform-arrow-functions": "^7.12.13", + "@babel/plugin-transform-async-to-generator": "^7.12.13", + "@babel/plugin-transform-block-scoped-functions": "^7.12.13", + "@babel/plugin-transform-block-scoping": "^7.12.13", + "@babel/plugin-transform-classes": "^7.12.13", + "@babel/plugin-transform-computed-properties": "^7.12.13", + "@babel/plugin-transform-destructuring": "^7.12.13", + "@babel/plugin-transform-dotall-regex": "^7.12.13", + "@babel/plugin-transform-duplicate-keys": "^7.12.13", + "@babel/plugin-transform-exponentiation-operator": "^7.12.13", + "@babel/plugin-transform-for-of": "^7.12.13", + "@babel/plugin-transform-function-name": "^7.12.13", + "@babel/plugin-transform-literals": "^7.12.13", + "@babel/plugin-transform-member-expression-literals": "^7.12.13", + "@babel/plugin-transform-modules-amd": "^7.12.13", + "@babel/plugin-transform-modules-commonjs": "^7.12.13", + "@babel/plugin-transform-modules-systemjs": "^7.12.13", + "@babel/plugin-transform-modules-umd": "^7.12.13", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.12.13", + "@babel/plugin-transform-new-target": "^7.12.13", + "@babel/plugin-transform-object-super": "^7.12.13", + "@babel/plugin-transform-parameters": "^7.12.13", + "@babel/plugin-transform-property-literals": "^7.12.13", + "@babel/plugin-transform-regenerator": "^7.12.13", + "@babel/plugin-transform-reserved-words": "^7.12.13", + "@babel/plugin-transform-shorthand-properties": "^7.12.13", + "@babel/plugin-transform-spread": "^7.12.13", + "@babel/plugin-transform-sticky-regex": "^7.12.13", + "@babel/plugin-transform-template-literals": "^7.12.13", + "@babel/plugin-transform-typeof-symbol": "^7.12.13", + "@babel/plugin-transform-unicode-escapes": "^7.12.13", + "@babel/plugin-transform-unicode-regex": "^7.12.13", + "@babel/preset-modules": "^0.1.3", + "@babel/types": "^7.12.13", + "core-js-compat": "^3.8.0", + "semver": "^5.5.0" + } + }, + "@babel/preset-modules": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.4.tgz", + "integrity": "sha512-J36NhwnfdzpmH41M1DrnkkgAqhZaqr/NBdPfQ677mLzlaXo+oDiv1deyCDtgAhz8p328otdob0Du7+xgHGZbKg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-proposal-unicode-property-regex": "^7.4.4", + "@babel/plugin-transform-dotall-regex": "^7.4.4", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + } + }, + "@babel/runtime": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.13.tgz", + "integrity": "sha512-8+3UMPBrjFa/6TtKi/7sehPKqfAm4g6K+YQjyyFOLUTxzOngcRZTlAVY8sc2CORJYqdHQY8gRPHmn+qo15rCBw==", + "dev": true, + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "@babel/template": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.12.13.tgz", + "integrity": "sha512-/7xxiGA57xMo/P2GVvdEumr8ONhFOhfgq2ihK3h1e6THqzTAkHbkXgB0xI9yeTfIUoH3+oAeHhqm/I43OTbbjA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.12.13", + "@babel/parser": "^7.12.13", + "@babel/types": "^7.12.13" + } + }, + "@babel/traverse": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.12.13.tgz", + "integrity": "sha512-3Zb4w7eE/OslI0fTp8c7b286/cQps3+vdLW3UcwC8VSJC6GbKn55aeVVu2QJNuCDoeKyptLOFrPq8WqZZBodyA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.12.13", + "@babel/generator": "^7.12.13", + "@babel/helper-function-name": "^7.12.13", + "@babel/helper-split-export-declaration": "^7.12.13", + "@babel/parser": "^7.12.13", + "@babel/types": "^7.12.13", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.19" + } + }, + "@babel/types": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.12.13.tgz", + "integrity": "sha512-oKrdZTld2im1z8bDwTOQvUbxKwE+854zc16qWZQlcTqMN00pWxHQ4ZeOq0yDMnisOpRykH2/5Qqcrk/OlbAjiQ==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.12.11", + "lodash": "^4.17.19", + "to-fast-properties": "^2.0.0" + } + }, + "@discoveryjs/json-ext": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.2.tgz", + "integrity": "sha512-HyYEUDeIj5rRQU2Hk5HTB2uHsbRQpF70nvMhVzi+VJR0X+xNEhjPui4/kBf3VeH/wqD28PT4sVOm8qqLjBrSZg==", + "dev": true + }, + "@jridgewell/gen-mapping": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", + "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==", + "dev": true, + "requires": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@jridgewell/resolve-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", + "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", + "dev": true + }, + "@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "dev": true + }, + "@jridgewell/source-map": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz", + "integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@jridgewell/sourcemap-codec": { + "version": "1.4.14", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", + "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", + "dev": true + }, + "@jridgewell/trace-mapping": { + "version": "0.3.14", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.14.tgz", + "integrity": "sha512-bJWEfQ9lPTvm3SneWwRFVLzrh6nhjwqw7TUFFBEMzwvg7t7PCDenf2lDwqo4NQXzdpgBXyFgDWnQA+2vkruksQ==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "@types/eslint": { + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-7.2.6.tgz", + "integrity": "sha512-I+1sYH+NPQ3/tVqCeUSBwTE/0heyvtXqpIopUUArlBm0Kpocb8FbMa3AZ/ASKIFpN3rnEx932TTXDbt9OXsNDw==", + "dev": true, + "requires": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "@types/eslint-scope": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.0.tgz", + "integrity": "sha512-O/ql2+rrCUe2W2rs7wMR+GqPRcgB6UiqN5RhrR5xruFlY7l9YLMn0ZkDzjoHLeiFkR8MCQZVudUuuvQ2BLC9Qw==", + "dev": true, + "requires": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "@types/estree": { + "version": "0.0.46", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.46.tgz", + "integrity": "sha512-laIjwTQaD+5DukBZaygQ79K1Z0jb1bPEMRrkXSLjtCcZm+abyp5YbrqpSLzD42FwWW6gK/aS4NYpJ804nG2brg==", + "dev": true + }, + "@types/json-schema": { + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.7.tgz", + "integrity": "sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==", + "dev": true + }, + "@types/node": { + "version": "14.14.28", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.28.tgz", + "integrity": "sha512-lg55ArB+ZiHHbBBttLpzD07akz0QPrZgUODNakeC09i62dnrywr9mFErHuaPlB6I7z+sEbK+IYmplahvplCj2g==", + "dev": true + }, + "@webassemblyjs/ast": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.0.tgz", + "integrity": "sha512-kX2W49LWsbthrmIRMbQZuQDhGtjyqXfEmmHyEi4XWnSZtPmxY0+3anPIzsnRb45VH/J55zlOfWvZuY47aJZTJg==", + "dev": true, + "requires": { + "@webassemblyjs/helper-numbers": "1.11.0", + "@webassemblyjs/helper-wasm-bytecode": "1.11.0" + } + }, + "@webassemblyjs/floating-point-hex-parser": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.0.tgz", + "integrity": "sha512-Q/aVYs/VnPDVYvsCBL/gSgwmfjeCb4LW8+TMrO3cSzJImgv8lxxEPM2JA5jMrivE7LSz3V+PFqtMbls3m1exDA==", + "dev": true + }, + "@webassemblyjs/helper-api-error": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.0.tgz", + "integrity": "sha512-baT/va95eXiXb2QflSx95QGT5ClzWpGaa8L7JnJbgzoYeaA27FCvuBXU758l+KXWRndEmUXjP0Q5fibhavIn8w==", + "dev": true + }, + "@webassemblyjs/helper-buffer": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.0.tgz", + "integrity": "sha512-u9HPBEl4DS+vA8qLQdEQ6N/eJQ7gT7aNvMIo8AAWvAl/xMrcOSiI2M0MAnMCy3jIFke7bEee/JwdX1nUpCtdyA==", + "dev": true + }, + "@webassemblyjs/helper-numbers": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.0.tgz", + "integrity": "sha512-DhRQKelIj01s5IgdsOJMKLppI+4zpmcMQ3XboFPLwCpSNH6Hqo1ritgHgD0nqHeSYqofA6aBN/NmXuGjM1jEfQ==", + "dev": true, + "requires": { + "@webassemblyjs/floating-point-hex-parser": "1.11.0", + "@webassemblyjs/helper-api-error": "1.11.0", + "@xtuc/long": "4.2.2" + } + }, + "@webassemblyjs/helper-wasm-bytecode": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.0.tgz", + "integrity": "sha512-MbmhvxXExm542tWREgSFnOVo07fDpsBJg3sIl6fSp9xuu75eGz5lz31q7wTLffwL3Za7XNRCMZy210+tnsUSEA==", + "dev": true + }, + "@webassemblyjs/helper-wasm-section": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.0.tgz", + "integrity": "sha512-3Eb88hcbfY/FCukrg6i3EH8H2UsD7x8Vy47iVJrP967A9JGqgBVL9aH71SETPx1JrGsOUVLo0c7vMCN22ytJew==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.0", + "@webassemblyjs/helper-buffer": "1.11.0", + "@webassemblyjs/helper-wasm-bytecode": "1.11.0", + "@webassemblyjs/wasm-gen": "1.11.0" + } + }, + "@webassemblyjs/ieee754": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.0.tgz", + "integrity": "sha512-KXzOqpcYQwAfeQ6WbF6HXo+0udBNmw0iXDmEK5sFlmQdmND+tr773Ti8/5T/M6Tl/413ArSJErATd8In3B+WBA==", + "dev": true, + "requires": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "@webassemblyjs/leb128": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.0.tgz", + "integrity": "sha512-aqbsHa1mSQAbeeNcl38un6qVY++hh8OpCOzxhixSYgbRfNWcxJNJQwe2rezK9XEcssJbbWIkblaJRwGMS9zp+g==", + "dev": true, + "requires": { + "@xtuc/long": "4.2.2" + } + }, + "@webassemblyjs/utf8": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.0.tgz", + "integrity": "sha512-A/lclGxH6SpSLSyFowMzO/+aDEPU4hvEiooCMXQPcQFPPJaYcPQNKGOCLUySJsYJ4trbpr+Fs08n4jelkVTGVw==", + "dev": true + }, + "@webassemblyjs/wasm-edit": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.0.tgz", + "integrity": "sha512-JHQ0damXy0G6J9ucyKVXO2j08JVJ2ntkdJlq1UTiUrIgfGMmA7Ik5VdC/L8hBK46kVJgujkBIoMtT8yVr+yVOQ==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.0", + "@webassemblyjs/helper-buffer": "1.11.0", + "@webassemblyjs/helper-wasm-bytecode": "1.11.0", + "@webassemblyjs/helper-wasm-section": "1.11.0", + "@webassemblyjs/wasm-gen": "1.11.0", + "@webassemblyjs/wasm-opt": "1.11.0", + "@webassemblyjs/wasm-parser": "1.11.0", + "@webassemblyjs/wast-printer": "1.11.0" + } + }, + "@webassemblyjs/wasm-gen": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.0.tgz", + "integrity": "sha512-BEUv1aj0WptCZ9kIS30th5ILASUnAPEvE3tVMTrItnZRT9tXCLW2LEXT8ezLw59rqPP9klh9LPmpU+WmRQmCPQ==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.0", + "@webassemblyjs/helper-wasm-bytecode": "1.11.0", + "@webassemblyjs/ieee754": "1.11.0", + "@webassemblyjs/leb128": "1.11.0", + "@webassemblyjs/utf8": "1.11.0" + } + }, + "@webassemblyjs/wasm-opt": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.0.tgz", + "integrity": "sha512-tHUSP5F4ywyh3hZ0+fDQuWxKx3mJiPeFufg+9gwTpYp324mPCQgnuVKwzLTZVqj0duRDovnPaZqDwoyhIO8kYg==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.0", + "@webassemblyjs/helper-buffer": "1.11.0", + "@webassemblyjs/wasm-gen": "1.11.0", + "@webassemblyjs/wasm-parser": "1.11.0" + } + }, + "@webassemblyjs/wasm-parser": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.0.tgz", + "integrity": "sha512-6L285Sgu9gphrcpDXINvm0M9BskznnzJTE7gYkjDbxET28shDqp27wpruyx3C2S/dvEwiigBwLA1cz7lNUi0kw==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.0", + "@webassemblyjs/helper-api-error": "1.11.0", + "@webassemblyjs/helper-wasm-bytecode": "1.11.0", + "@webassemblyjs/ieee754": "1.11.0", + "@webassemblyjs/leb128": "1.11.0", + "@webassemblyjs/utf8": "1.11.0" + } + }, + "@webassemblyjs/wast-printer": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.0.tgz", + "integrity": "sha512-Fg5OX46pRdTgB7rKIUojkh9vXaVN6sGYCnEiJN1GYkb0RPwShZXp6KTDqmoMdQPKhcroOXh3fEzmkWmCYaKYhQ==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.0", + "@xtuc/long": "4.2.2" + } + }, + "@webpack-cli/configtest": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-1.0.1.tgz", + "integrity": "sha512-B+4uBUYhpzDXmwuo3V9yBH6cISwxEI4J+NO5ggDaGEEHb0osY/R7MzeKc0bHURXQuZjMM4qD+bSJCKIuI3eNBQ==", + "dev": true + }, + "@webpack-cli/info": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-1.2.2.tgz", + "integrity": "sha512-5U9kUJHnwU+FhKH4PWGZuBC1hTEPYyxGSL5jjoBI96Gx8qcYJGOikpiIpFoTq8mmgX3im2zAo2wanv/alD74KQ==", + "dev": true, + "requires": { + "envinfo": "^7.7.3" + } + }, + "@webpack-cli/serve": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-1.3.0.tgz", + "integrity": "sha512-k2p2VrONcYVX1wRRrf0f3X2VGltLWcv+JzXRBDmvCxGlCeESx4OXw91TsWeKOkp784uNoVQo313vxJFHXPPwfw==", + "dev": true + }, + "@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true + }, + "@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true + }, + "acorn": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.0.5.tgz", + "integrity": "sha512-v+DieK/HJkJOpFBETDJioequtc3PfxsWMaxIdIwujtF7FEV/MAyDQLlm6/zPvr7Mix07mLh6ccVwIsloceodlg==", + "dev": true + }, + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true + }, + "ansi-colors": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", + "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "babel-loader": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.2.tgz", + "integrity": "sha512-JvTd0/D889PQBtUXJ2PXaKU/pjZDMtHA9V2ecm+eNRmmBCMR09a+fmpGTNwnJtFmFl5Ei7Vy47LjBb+L0wQ99g==", + "dev": true, + "requires": { + "find-cache-dir": "^3.3.1", + "loader-utils": "^1.4.0", + "make-dir": "^3.1.0", + "schema-utils": "^2.6.5" + } + }, + "babel-plugin-dynamic-import-node": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz", + "integrity": "sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==", + "dev": true, + "requires": { + "object.assign": "^4.1.0" + } + }, + "big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true + }, + "browserslist": { + "version": "4.16.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.16.7.tgz", + "integrity": "sha512-7I4qVwqZltJ7j37wObBe3SoTz+nS8APaNcrBOlgoirb6/HbEU2XxW/LpUDTCngM6iauwFqmRTuOMfyKnFGY5JA==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30001248", + "colorette": "^1.2.2", + "electron-to-chromium": "^1.3.793", + "escalade": "^3.1.1", + "node-releases": "^1.1.73" + }, + "dependencies": { + "caniuse-lite": { + "version": "1.0.30001249", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001249.tgz", + "integrity": "sha512-vcX4U8lwVXPdqzPWi6cAJ3FnQaqXbBqy/GZseKNQzRj37J7qZdGcBtxq/QLFNLLlfsoXLUdHw8Iwenri86Tagw==", + "dev": true + }, + "colorette": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.3.0.tgz", + "integrity": "sha512-ecORCqbSFP7Wm8Y6lyqMJjexBQqXSF7SSeaTyGGphogUjBlFP9m9o08wy86HL2uB7fMTxtOUzLMk7ogKcxMg1w==", + "dev": true + }, + "electron-to-chromium": { + "version": "1.3.802", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.802.tgz", + "integrity": "sha512-dXB0SGSypfm3iEDxrb5n/IVKeX4uuTnFHdve7v+yKJqNpEP0D4mjFJ8e1znmSR+OOVlVC+kDO6f2kAkTFXvJBg==", + "dev": true + }, + "node-releases": { + "version": "1.1.74", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.74.tgz", + "integrity": "sha512-caJBVempXZPepZoZAPCWRTNxYQ+xtG/KAi4ozTA5A+nJ7IU+kLQCbqaUjb5Rwy14M9upBWiQ4NutcmW04LJSRw==", + "dev": true + } + } + }, + "buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "chrome-trace-event": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.2.tgz", + "integrity": "sha512-9e/zx1jw7B4CO+c/RXoCsfg/x1AfUBioy4owYH0bJprEYAx5hRFLRhWBqHAG57D0ZM4H7vxbP7bPe0VwhQRYDQ==", + "dev": true, + "requires": { + "tslib": "^1.9.0" + } + }, + "clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "colorette": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.1.tgz", + "integrity": "sha512-puCDz0CzydiSYOrnXpz/PKd69zRrribezjtE9yd4zvytoRc8+RY/KJPvtPFKZS3E3wP6neGyMe0vOTlHO5L3Pw==", + "dev": true + }, + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=", + "dev": true + }, + "convert-source-map": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz", + "integrity": "sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.1" + } + }, + "core-js-compat": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.8.3.tgz", + "integrity": "sha512-1sCb0wBXnBIL16pfFG1Gkvei6UzvKyTNYpiC41yrdjEv0UoJoq9E/abTMzyYJ6JpTkAj15dLjbqifIzEBDVvog==", + "dev": true, + "requires": { + "browserslist": "^4.16.1", + "semver": "7.0.0" + }, + "dependencies": { + "semver": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz", + "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==", + "dev": true + } + } + }, + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "define-properties": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", + "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "dev": true, + "requires": { + "object-keys": "^1.0.12" + } + }, + "emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true + }, + "enhanced-resolve": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.7.0.tgz", + "integrity": "sha512-6njwt/NsZFUKhM6j9U8hzVyD4E4r0x7NQzhTCbcWOJ0IQjNSAoalWmb0AE51Wn+fwan5qVESWi7t2ToBxs9vrw==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + } + }, + "enquirer": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", + "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==", + "dev": true, + "requires": { + "ansi-colors": "^4.1.1" + } + }, + "envinfo": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.7.4.tgz", + "integrity": "sha512-TQXTYFVVwwluWSFis6K2XKxgrD22jEv0FTuLCQI+OjH7rn93+iY0fSSFM5lrSxFY+H1+B0/cvvlamr3UsBivdQ==", + "dev": true + }, + "es-module-lexer": { + "version": "0.3.26", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.3.26.tgz", + "integrity": "sha512-Va0Q/xqtrss45hWzP8CZJwzGSZJjDM5/MJRE3IXXnUCcVLElR9BRaE9F62BopysASyc4nM3uwhSW7FFB9nlWAA==", + "dev": true + }, + "escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + } + }, + "esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "requires": { + "estraverse": "^5.2.0" + }, + "dependencies": { + "estraverse": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz", + "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==", + "dev": true + } + } + }, + "estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true + }, + "esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true + }, + "events": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.2.0.tgz", + "integrity": "sha512-/46HWwbfCX2xTawVfkKLGxMifJYQBWMwY1mjywRtb4c9x8l5NP3KoJtnIOiL1hfdRkIuYhETxQlo62IF8tcnlg==", + "dev": true + }, + "execa": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.0.0.tgz", + "integrity": "sha512-ov6w/2LCiuyO4RLYGdpFGjkcs0wMTgGE8PrkTHikeUy5iJekXyPIKUjifk5CsE0pt7sMCrMZ3YNqoCj6idQOnQ==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + } + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "fastest-levenshtein": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.12.tgz", + "integrity": "sha512-On2N+BpYJ15xIC974QNVuYGMOlEVt4s0EOI3wwMqOmK1fdDY+FN/zltPV8vosq4ad4c/gJ1KHScUn/6AWIgiow==", + "dev": true + }, + "find-cache-dir": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.1.tgz", + "integrity": "sha512-t2GDMt3oGC/v+BMwzmllWDuJF/xcDtE5j/fCGbqDD7OLuJkj0cfh1YSA5VKPvwMeLFLNDBkwOKZ2X85jGLVftQ==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + } + }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true + }, + "get-intrinsic": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", + "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1" + } + }, + "get-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.0.tgz", + "integrity": "sha512-A1B3Bh1UmL0bidM/YX2NsCOTnGJePL9rO/M+Mw3m9f2gUpfokS0hi5Eah0WSUEWZdZhIZtMjkIYS7mDfOqNHbg==", + "dev": true + }, + "glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, + "graceful-fs": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz", + "integrity": "sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ==", + "dev": true + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "has-symbols": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", + "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", + "dev": true + }, + "human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true + }, + "import-local": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.0.2.tgz", + "integrity": "sha512-vjL3+w0oulAVZ0hBHnxa/Nm5TAurf9YLQJDhqRZyqb+VKGOB6LU8t9H1Nr5CIo16vh9XfJTOoHwU0B71S557gA==", + "dev": true, + "requires": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + } + }, + "interpret": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", + "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", + "dev": true + }, + "is-core-module": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.2.0.tgz", + "integrity": "sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ==", + "dev": true, + "requires": { + "has": "^1.0.3" + } + }, + "is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "requires": { + "isobject": "^3.0.1" + } + }, + "is-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", + "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==", + "dev": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + }, + "jest-worker": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz", + "integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==", + "dev": true, + "requires": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^7.0.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true + }, + "json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "json5": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz", + "integrity": "sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==", + "dev": true, + "requires": { + "minimist": "^1.2.5" + } + }, + "kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true + }, + "loader-runner": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.2.0.tgz", + "integrity": "sha512-92+huvxMvYlMzMt0iIOukcwYBFpkYJdpl2xsZ7LrlayO7E8SOv+JJUEK17B/dJIHAOLMfh2dZZ/Y18WgmGtYNw==", + "dev": true + }, + "loader-utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz", + "integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^1.0.1" + }, + "dependencies": { + "json5": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", + "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + } + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "mime-db": { + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.46.0.tgz", + "integrity": "sha512-svXaP8UQRZ5K7or+ZmfNhg2xX3yKDMUzqadsSqi4NCH/KomcH75MAMYAGVlvXn4+b/xOPhS3I2uHKRUzvjY7BQ==", + "dev": true + }, + "mime-types": { + "version": "2.1.29", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.29.tgz", + "integrity": "sha512-Y/jMt/S5sR9OaqteJtslsFZKWOIIqMACsJSiHghlCAyhf7jfVYjKBmLiX8OgpWeW+fjJ2b+Az69aPFPkUOY6xQ==", + "dev": true, + "requires": { + "mime-db": "1.46.0" + } + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true + }, + "minimist": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", + "dev": true + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, + "npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "requires": { + "path-key": "^3.0.0" + } + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true + }, + "object.assign": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz", + "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3", + "has-symbols": "^1.0.1", + "object-keys": "^1.1.1" + } + }, + "onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "requires": { + "mimic-fn": "^2.1.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, + "path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "requires": { + "find-up": "^4.0.0" + } + }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true + }, + "randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.0" + } + }, + "rechoir": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.0.tgz", + "integrity": "sha512-ADsDEH2bvbjltXEP+hTIAmeFekTFK0V2BTxMkok6qILyAJEXV0AFfoWcAq4yfll5VdIMd/RVXq0lR+wQi5ZU3Q==", + "dev": true, + "requires": { + "resolve": "^1.9.0" + } + }, + "regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true + }, + "regenerate-unicode-properties": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-8.2.0.tgz", + "integrity": "sha512-F9DjY1vKLo/tPePDycuH3dn9H1OTPIkVD9Kz4LODu+F2C75mgjAJ7x/gwy6ZcSNRAAkhNlJSOHRe8k3p+K9WhA==", + "dev": true, + "requires": { + "regenerate": "^1.4.0" + } + }, + "regenerator-runtime": { + "version": "0.13.7", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", + "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==", + "dev": true + }, + "regenerator-transform": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.14.5.tgz", + "integrity": "sha512-eOf6vka5IO151Jfsw2NO9WpGX58W6wWmefK3I1zEGr0lOD0u8rwPaNqQL1aRxUaxLeKO3ArNh3VYg1KbaD+FFw==", + "dev": true, + "requires": { + "@babel/runtime": "^7.8.4" + } + }, + "regexpu-core": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-4.7.1.tgz", + "integrity": "sha512-ywH2VUraA44DZQuRKzARmw6S66mr48pQVva4LBeRhcOltJ6hExvWly5ZjFLYo67xbIxb6W1q4bAGtgfEl20zfQ==", + "dev": true, + "requires": { + "regenerate": "^1.4.0", + "regenerate-unicode-properties": "^8.2.0", + "regjsgen": "^0.5.1", + "regjsparser": "^0.6.4", + "unicode-match-property-ecmascript": "^1.0.4", + "unicode-match-property-value-ecmascript": "^1.2.0" + } + }, + "regjsgen": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.5.2.tgz", + "integrity": "sha512-OFFT3MfrH90xIW8OOSyUrk6QHD5E9JOTeGodiJeBS3J6IwlgzJMNE/1bZklWz5oTg+9dCMyEetclvCVXOPoN3A==", + "dev": true + }, + "regjsparser": { + "version": "0.6.7", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.6.7.tgz", + "integrity": "sha512-ib77G0uxsA2ovgiYbCVGx4Pv3PSttAx2vIwidqQzbL2U5S4Q+j00HdSAneSBuyVcMvEnTXMjiGgB+DlXozVhpQ==", + "dev": true, + "requires": { + "jsesc": "~0.5.0" + }, + "dependencies": { + "jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=", + "dev": true + } + } + }, + "resolve": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz", + "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==", + "dev": true, + "requires": { + "is-core-module": "^2.2.0", + "path-parse": "^1.0.6" + } + }, + "resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "requires": { + "resolve-from": "^5.0.0" + } + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "schema-utils": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", + "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.5", + "ajv": "^6.12.4", + "ajv-keywords": "^3.5.2" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "serialize-javascript": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-5.0.1.tgz", + "integrity": "sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA==", + "dev": true, + "requires": { + "randombytes": "^2.1.0" + } + }, + "shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "requires": { + "kind-of": "^6.0.2" + } + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, + "signal-exit": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", + "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==", + "dev": true + }, + "smiles-drawer": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/smiles-drawer/-/smiles-drawer-2.0.3.tgz", + "integrity": "sha512-G95FIAqeVyZOj5LlAE5QR0ERfGL5cOvx+LQfEGmtqVLQ8LypKRZ9TCo2Ne2lgqhFrdXQpftKf+79o6C0ItDMNg==" + }, + "source-list-map": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", + "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==", + "dev": true + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + }, + "source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "tapable": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.0.tgz", + "integrity": "sha512-FBk4IesMV1rBxX2tfiK8RAmogtWn53puLOQlvO8XuwlgxcYbP4mVPS9Ph4aeamSyyVjOl24aYWAuc8U5kCVwMw==", + "dev": true + }, + "terser": { + "version": "5.14.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.14.2.tgz", + "integrity": "sha512-oL0rGeM/WFQCUd0y2QrWxYnq7tfSuKBiqTjRPWrRgB46WD/kiwHwF8T23z78H6Q6kGCuuHcPB+KULHRdxvVGQA==", + "dev": true, + "requires": { + "@jridgewell/source-map": "^0.3.2", + "acorn": "^8.5.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "dependencies": { + "acorn": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz", + "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==", + "dev": true + } + } + }, + "terser-webpack-plugin": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.1.1.tgz", + "integrity": "sha512-5XNNXZiR8YO6X6KhSGXfY0QrGrCRlSwAEjIIrlRQR4W8nP69TaJUlh3bkuac6zzgspiGPfKEHcY295MMVExl5Q==", + "dev": true, + "requires": { + "jest-worker": "^26.6.2", + "p-limit": "^3.1.0", + "schema-utils": "^3.0.0", + "serialize-javascript": "^5.0.1", + "source-map": "^0.6.1", + "terser": "^5.5.1" + }, + "dependencies": { + "p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "schema-utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.0.0.tgz", + "integrity": "sha512-6D82/xSzO094ajanoOSbe4YvXWMfn2A//8Y1+MUqFAJul5Bs+yn36xbK9OtNDcRVSBJ9jjeoXftM6CfztsjOAA==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.6", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", + "dev": true + }, + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "unicode-canonical-property-names-ecmascript": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz", + "integrity": "sha512-jDrNnXWHd4oHiTZnx/ZG7gtUTVp+gCcTTKr8L0HjlwphROEW3+Him+IpvC+xcJEFegapiMZyZe02CyuOnRmbnQ==", + "dev": true + }, + "unicode-match-property-ecmascript": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-1.0.4.tgz", + "integrity": "sha512-L4Qoh15vTfntsn4P1zqnHulG0LdXgjSO035fEpdtp6YxXhMT51Q6vgM5lYdG/5X3MjS+k/Y9Xw4SFCY9IkR0rg==", + "dev": true, + "requires": { + "unicode-canonical-property-names-ecmascript": "^1.0.4", + "unicode-property-aliases-ecmascript": "^1.0.4" + } + }, + "unicode-match-property-value-ecmascript": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-1.2.0.tgz", + "integrity": "sha512-wjuQHGQVofmSJv1uVISKLE5zO2rNGzM/KCYZch/QQvez7C1hUhBIuZ701fYXExuufJFMPhv2SyL8CyoIfMLbIQ==", + "dev": true + }, + "unicode-property-aliases-ecmascript": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.1.0.tgz", + "integrity": "sha512-PqSoPh/pWetQ2phoj5RLiaqIk4kCNwoV3CI+LfGmWLKI3rE3kl1h59XpX2BjgDrmbxD9ARtQobPGU1SguCYuQg==", + "dev": true + }, + "uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "v8-compile-cache": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.2.0.tgz", + "integrity": "sha512-gTpR5XQNKFwOd4clxfnhaqvfqMpqEwr4tOtCyz4MtYZX2JYhfr1JvBFKdS+7K/9rfpZR3VLX+YWBbKoxCgS43Q==", + "dev": true + }, + "watchpack": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.1.1.tgz", + "integrity": "sha512-Oo7LXCmc1eE1AjyuSBmtC3+Wy4HcV8PxWh2kP6fOl8yTlNS7r0K9l1ao2lrrUza7V39Y3D/BbJgY8VeSlc5JKw==", + "dev": true, + "requires": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + } + }, + "webpack": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.22.0.tgz", + "integrity": "sha512-xqlb6r9RUXda/d9iA6P7YRTP1ChWeP50TEESKMMNIg0u8/Rb66zN9YJJO7oYgJTRyFyYi43NVC5feG45FSO1vQ==", + "dev": true, + "requires": { + "@types/eslint-scope": "^3.7.0", + "@types/estree": "^0.0.46", + "@webassemblyjs/ast": "1.11.0", + "@webassemblyjs/wasm-edit": "1.11.0", + "@webassemblyjs/wasm-parser": "1.11.0", + "acorn": "^8.0.4", + "browserslist": "^4.14.5", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.7.0", + "es-module-lexer": "^0.3.26", + "eslint-scope": "^5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.4", + "json-parse-better-errors": "^1.0.2", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.0.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.1.1", + "watchpack": "^2.0.0", + "webpack-sources": "^2.1.1" + }, + "dependencies": { + "schema-utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.0.0.tgz", + "integrity": "sha512-6D82/xSzO094ajanoOSbe4YvXWMfn2A//8Y1+MUqFAJul5Bs+yn36xbK9OtNDcRVSBJ9jjeoXftM6CfztsjOAA==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.6", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + } + } + }, + "webpack-cli": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.5.0.tgz", + "integrity": "sha512-wXg/ef6Ibstl2f50mnkcHblRPN/P9J4Nlod5Hg9HGFgSeF8rsqDGHJeVe4aR26q9l62TUJi6vmvC2Qz96YJw1Q==", + "dev": true, + "requires": { + "@discoveryjs/json-ext": "^0.5.0", + "@webpack-cli/configtest": "^1.0.1", + "@webpack-cli/info": "^1.2.2", + "@webpack-cli/serve": "^1.3.0", + "colorette": "^1.2.1", + "commander": "^7.0.0", + "enquirer": "^2.3.6", + "execa": "^5.0.0", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^2.2.0", + "rechoir": "^0.7.0", + "v8-compile-cache": "^2.2.0", + "webpack-merge": "^5.7.3" + }, + "dependencies": { + "commander": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.1.0.tgz", + "integrity": "sha512-pRxBna3MJe6HKnBGsDyMv8ETbptw3axEdYHoqNh7gu5oDcew8fs0xnivZGm06Ogk8zGAJ9VX+OPEr2GXEQK4dg==", + "dev": true + } + } + }, + "webpack-merge": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.7.3.tgz", + "integrity": "sha512-6/JUQv0ELQ1igjGDzHkXbVDRxkfA57Zw7PfiupdLFJYrgFqY5ZP8xxbpp2lU3EPwYx89ht5Z/aDkD40hFCm5AA==", + "dev": true, + "requires": { + "clone-deep": "^4.0.1", + "wildcard": "^2.0.0" + } + }, + "webpack-sources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-2.2.0.tgz", + "integrity": "sha512-bQsA24JLwcnWGArOKUxYKhX3Mz/nK1Xf6hxullKERyktjNMC4x8koOeaDNTA2fEJ09BdWLbM/iTW0ithREUP0w==", + "dev": true, + "requires": { + "source-list-map": "^2.0.1", + "source-map": "^0.6.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "wildcard": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.0.tgz", + "integrity": "sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==", + "dev": true + }, + "yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true + } + } +} diff --git a/clean2d/package.json b/clean2d/package.json new file mode 100644 index 00000000..a8d6aa74 --- /dev/null +++ b/clean2d/package.json @@ -0,0 +1,23 @@ +{ + "name": "clean2d", + "version": "1.0.0", + "description": "", + "main": "dist/clean2d.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "build": "webpack && mv dist/clean2d.js ../chython/algorithms/calculate2d" + }, + "dependencies": { + "smiles-drawer": "^2.0.1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@babel/core": "^7.12.16", + "@babel/preset-env": "^7.12.16", + "babel-loader": "^8.2.2", + "webpack": "^5.22.0", + "webpack-cli": "^4.5.0" + } +} diff --git a/clean2d/src/index.js b/clean2d/src/index.js new file mode 100644 index 00000000..0dfaa26a --- /dev/null +++ b/clean2d/src/index.js @@ -0,0 +1,20 @@ +import DrawerBase from 'smiles-drawer/src/DrawerBase'; +import Parser from 'smiles-drawer/src/Parser'; + + +function clean2d(smiles) { + const drawer = new DrawerBase({}); + const parsed = Parser.parse(smiles); + drawer.initDraw(parsed, 'light', false); + drawer.processGraph(); + + let vertices = drawer.graph.vertices; + let xy = Array(); + for (let i = 0; i < vertices.length; i++) { + let position = vertices[i].position; + xy.push([position.x, position.y]); + } + return xy; +} + +export { clean2d }; diff --git a/clean2d/webpack.config.js b/clean2d/webpack.config.js new file mode 100644 index 00000000..7e65ed83 --- /dev/null +++ b/clean2d/webpack.config.js @@ -0,0 +1,12 @@ +const path = require('path'); + +module.exports = { + entry: path.resolve(__dirname, "src/index.js"), + mode: "production", + output: { + filename: 'clean2d.js', + path: path.resolve(__dirname, 'dist'), + library: "$", + libraryTarget: "umd" + } +}; From 1772ad44a0711b4844290da394754b567c27ff5d Mon Sep 17 00:00:00 2001 From: Ramil Nugmanov Date: Mon, 29 Dec 2025 22:38:14 +0100 Subject: [PATCH 67/67] save. no way I will solve it. --- chython/algorithms/calculate2d/Calculate2d.py | 2662 ----------------- chython/algorithms/calculate2d/KKLayout.py | 430 --- chython/algorithms/calculate2d/MathHelper.py | 709 ----- chython/algorithms/calculate2d/Properties.py | 631 ---- chython/algorithms/calculate2d/_templates.py | 56 - .../algorithms/calculate2d/kamada_kawai.py | 179 ++ chython/algorithms/calculate2d/molecule.py | 444 +-- 7 files changed, 191 insertions(+), 4920 deletions(-) delete mode 100644 chython/algorithms/calculate2d/Calculate2d.py delete mode 100644 chython/algorithms/calculate2d/KKLayout.py delete mode 100644 chython/algorithms/calculate2d/MathHelper.py delete mode 100644 chython/algorithms/calculate2d/Properties.py delete mode 100644 chython/algorithms/calculate2d/_templates.py create mode 100644 chython/algorithms/calculate2d/kamada_kawai.py diff --git a/chython/algorithms/calculate2d/Calculate2d.py b/chython/algorithms/calculate2d/Calculate2d.py deleted file mode 100644 index d67bbd19..00000000 --- a/chython/algorithms/calculate2d/Calculate2d.py +++ /dev/null @@ -1,2662 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright 2024 Denis Lipatov -# Copyright 2024 Vyacheslav Grigorev -# Copyright 2024 Timur Gimadiev -# This file is part of chython. -# -# chython is free software; you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation; either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with this program; if not, see . -# -""" -Class for calculating the 2D layout of a molecular graph, returning the coordinates of atom -vertices in a molecular container. - -This class provides methods for calculating and optimizing the 2D structure of a molecule, -including determining atom coordinates, handling collisions, and defining properties of rings -and bonds between atoms. Key functions include: - -- Calculating the initial positions of atoms and their subsequent adjustment to minimize -overlaps. -- Defining and classifying rings within the molecule, including handling bridged, spiro-fused, -and condensed rings. -- Handling cis-trans isomerism and atom configurations. -- Working with various types of bonds (single, double, triple) and their impact on atom -orientation. -- Calculating atom positions in ring structures and aromatic compounds. -- Handling collisions between atoms to improve molecule visualization. - -The class uses auxiliary functions for vector operations, determining atom neighbors, -calculating angles, and handling specific cases such as atoms with one, two, three, or four -neighbors. It also includes methods for working with ring structures, including determining the -ring center and positioning atoms within the ring. - -Utilizes AtomProperties, BondProperties, RingProperties, RingOverlap classes to represent atoms, -bonds, and rings, respectively. Additionally, the KKLayout class, which implements the -Kamada-Kawai algorithm, is used to minimize the system's energy by representing atoms as masses -connected by springs with a certain stiffness (used for calculating coordinates in bridged -cyclic molecules). -""" -from typing import List, Dict, Optional, Set, Tuple, Union, Generator, TYPE_CHECKING -from .MathHelper import Vector, Polygon -from .KKLayout import KKLayout -from .Properties import * -import math - -if TYPE_CHECKING: - from ...containers import MoleculeContainer - - -class Calculate2d: - """ - Class for calculating the 2D layout of a molecular graph, returning the coordinates of atom - vertices in a molecular container. - """ - - def __init__(self) -> None: - """ - The initial attributes initialization of the class includes: - bond_length: int: The bond length between atoms in the molecular graph. Used to determine - the distance between atoms when calculating their coordinates. - overlap_sensitivity: float: Sensitivity to atom overlap. Used to determine how close atoms - can be to each other before they are considered to overlap. - overlap_resolution_iterations: int: The number of iterations for resolving atom overlaps. - Indicates how many times the algorithm will attempt to improve atom positioning to - minimize overlaps. - ring_overlaps: List['RingOverlap']: A list of RingOverlap objects representing overlaps - between rings in the molecule. Used for identifying and handling ring overlaps. - total_overlap_score: float: The total overlap score in the molecule. Used for evaluating the - quality of atom positioning and minimizing overlaps. - finetune: bool: A flag indicating the need for detailed adjustment (finetuning) of atom - positions after the main calculation. Currently always set to True, but can be changed - to disable detailed adjustment. - ring_overlap_id_tracker: int: A counter for assigning unique identifiers to RingOverlap - objects. Used for the unique identification of ring overlaps. - ring_id_tracker: int: A counter for assigning unique identifiers to rings. Simplifies ring - management in the structure. - rings: List['RingProperties']: A list of RingProperties objects representing all rings in - the molecule. Used for working with ring structures and their attributes. - id_to_ring: Dict[int, 'RingProperties']: A dictionary mapping ring identifiers to their - RingProperties objects. Facilitates access to ring properties by their identifiers. - """ - - self.bond_length: int = 15 - self.overlap_sensitivity: float = 0.10 - self.overlap_resolution_iterations: int = 5 - self.ring_overlaps: List['RingOverlap'] = [] - self.total_overlap_score: float = 0.0 - # self.finetune: bool = True # используется в ветвлении, но всегда тру, бесполезная вещь - self.ring_overlap_id_tracker: int = 0 - self.ring_id_tracker: int = 0 - self.rings: List['RingProperties'] = [] - self.id_to_ring: Dict[int, 'RingProperties'] = {} - - - - def _calculate2d_coord(self, order: List[int], mc: 'MoleculeContainer') -> List[List[float]]: - """ - Calculates the coordinates of the vertices of the atoms of the graph and returns the - coordinates as - a two-dimensional array. - - This method computes the 2D coordinates for each atom in the molecular graph based on - the provided order and molecular container. It initializes the properties of the - molecular container, - defines the rings within the molecule, performs an initial approximation of atom - positions, handles collisions between atoms, and finally returns the calculated - coordinates. - - Parameters: - :param order: List[int]: - A list of integers representing the order in which atoms should be processed. This - order determines the sequence for calculating and adjusting atom positions to - minimize overlaps. - :param mc: MoleculeContainer: - An instance of MoleculeContainer that holds the molecular graph, including atoms, - bonds, and rings information necessary for the 2D layout calculation. - - Returns List[List[float]]: - A two-dimensional list where each inner list contains the x and y coordinates of an - atom in the 2D space. The order of coordinates corresponds to the order of atoms as - processed. - """ - self.create_property_attributes(mc) - self.define_rings() - - self.initial_approximation() ##main - self.collision_handling() - return self.get_coord(order) - - - - - def create_property_attributes(self, mc: 'MoleculeContainer') -> None: - """ - Initializes the property attributes for the molecular container, including atoms, bonds, and their - relationships. - - This method sets up the initial properties for the molecular container by creating dictionaries for - atoms and bonds based on the adjacency information provided by the molecular container. It also - refreshes the neighbours list for each atom to ensure accurate representation of the molecular - graph. - - Parameters - :param mc: MoleculeContainer: - An instance of MoleculeContainer that holds the molecular graph, including atoms, bonds, and - rings information necessary for the 2D layout calculation. - - Notes: - - Initializes the `atoms` dictionary with atom indices as keys and AtomProperties instances as - values, where each AtomProperties instance is created with the atom index and its corresponding - symbol. - - Refreshes the neighbours list for each atom based on the adjacency information from the molecular - container. - - Initializes the `bonds` dictionary with tuples of atom indices as keys and BondProperties - instances as values, representing the bonds between atoms. - - Sets up the `graph` dictionary to map each atom to its list of neighbouring atoms, facilitating - the representation of the molecular structure. - - The method is crucial for preparing the molecular container for further calculations by establishing - the basic properties and relationships between atoms and bonds, which are essential for the 2D - layout calculation. - """ - self.mc: 'MoleculeContainer' = mc - - self.atoms: Dict[int, AtomProperties] = {} - for atom in self.mc.int_adjacency: - symbol = self.get_symbol(atom) - self.atoms[atom] = AtomProperties(atom, symbol) - self.refresh_neighbours(self.mc.int_adjacency) - - self.graph: Dict['AtomProperties', List['AtomProperties']] = {} - for atom in self.atoms.values(): - self.graph[atom] = atom.neighbours - - self.bonds: Dict[Tuple[int], 'BondProperties'] = {} - for n, m, bond in self.mc.bonds(): - self.bonds[(n, m)] = BondProperties(self.atoms[n], self.atoms[m], bond) - - - ## creating and refreshing property attributes - - def refresh_neighbours(self, graph: Dict[int, Dict[int, int]]) -> None: - """ - Refreshes the neighbours list for each atom based on the provided graph adjacency information. - - This method updates the neighbours list for each atom in the molecular graph to ensure an accurate - representation of the molecular structure. It iterates through the graph, which is a dictionary - mapping atom indices to their adjacent atom indices, and assigns the corresponding AtomProperties - instances to the neighbours list of each atom. - - Parameters - :param graph: Dict[int, Dict[int, int]]: - A dictionary where keys are atom indices and values are dictionaries mapping to adjacent atom - indices. This structure represents the adjacency information of the molecular graph, indicating - which atoms are directly connected. - """ - for atom_index, neighbor_indexes in graph.items(): - neighbours: List['AtomProperties'] = [] - for neighbour_index in neighbor_indexes: - neighbours.append(self.atoms[neighbour_index]) - self.atoms[atom_index].neighbours = neighbours - - - def get_symbol(self, atom_index: int) -> str: - """ - Returns the atomic symbol of the atom corresponding to the given index. - - This method retrieves the atomic symbol of an atom in the molecular container based on its index. It - is a utility function used to identify the type of atom by its atomic symbol, which is essential for - various calculations and representations in the molecular graph. - - Parameters - :param atom_index: int: - The index of the atom for which the atomic symbol is to be retrieved. This index is used to - access the atom within the molecular container. - - Returns str: - The atomic symbol of the atom as a string, representing the element type of the atom (e.g., 'C' - for Carbon, 'H' for Hydrogen, etc.). - - """ - return self.mc.atom(atom_index).atomic_symbol - - - - def bond_lookup(self, atom: 'AtomProperties', next_atom: 'AtomProperties') -> Optional['BondProperties']: - """ - Возвращает связь, которая находится между этими двумя атомами - """ - return self.bonds.get((atom.id, next_atom.id)) or self.bonds.get((next_atom.id, atom.id)) - - - def get_configuration(self, atom1: 'AtomProperties', atom2: 'AtomProperties') -> Optional[str]: - """ - Проверяет есть ли конфигурация между этими атомами, - в случае отсутствия возвращает None, в ином случае - возвращает строку 'cis' или 'trans' - - self._cis_trans_stereo = {(2, 3): False} это словарь, хранящий значения - о конфигурациях молекулы, его ключами являются тюплы атомов, между которыми - есть могут быть конформации, сверху условие что эта связь обязательно должна - быть двойной, значениями является булевое значение, True если Цис конфигурация, - False если Транс, если конфигурации не предусмотренно вообще, то словарь будет пустым. - """ - if (atom1.id, atom2.id) not in list(self.mc._cis_trans_stereo.keys()): - return None - else: - configuration = self.mc._cis_trans_stereo[(atom1.id, atom2.id)] - return 'cis' if configuration else 'trans' - - -# поиск в базе колец и их классификация -# в структуре кайтона не обрабатываются случаи с мостиковыми кольцами - def define_rings(self) -> None: - """ - Defines the rings within the molecule, identifies ring overlaps, and handles bridged ring systems. - - This method performs several key steps in the process of analyzing the molecular structure: - 1. It initializes the rings present in the molecule by converting the simple cycle list (SSSR) from - the molecular container into RingProperties objects. - 2. Identifies overlaps between rings and creates RingOverlap objects for them. - 3. Finds and processes bridged ring systems, creating a unified representation for interconnected - rings that share atoms. - - Notes - ----- - - Initially, it retrieves the simple cycle list (SSSR) from the molecular container and converts - each cycle into a RingProperties object, adding them to the class's ring list. - - It then iterates through all pairs of rings to identify overlaps, creating RingOverlap objects for - those that share atoms and adding them to the class's ring overlaps list. - - For each ring, it updates the list of neighbouring rings based on identified overlaps, enhancing - the representation of the molecular structure's connectivity. - - The method also handles bridged ring systems by identifying rings that are part of a larger, - interconnected system and merges them into a single RingProperties object, ensuring a coherent - representation of complex cyclic structures. - - This process involves finding all rings involved in a bridged system, removing the original rings - from the list, and adding a new RingProperties object that represents the bridged system. - - Finally, it iterates through all rings to find any bridged systems not yet processed and repeats - the merging process, ensuring that all interconnected rings are represented as unified entities. - """ - rings = self.mc.sssr - if not rings: - return - - for neighbor_indexes in rings: - members_ring: List['AtomProperties'] = [self.atoms[atom_index] for atom_index in neighbor_indexes] - ring = RingProperties(members_ring) - self.add_ring(ring) - - for i, ring_1 in enumerate(self.rings[:-1]): - for ring_2 in self.rings[i + 1:]: - ring_overlap = RingOverlap(ring_1, ring_2) - if len(ring_overlap.atoms) > 0: - self.add_ring_overlap(ring_overlap) - - for ring in self.rings: - neighbouring_rings = self.find_neighbouring_rings(self.ring_overlaps, ring.id) - ring.neighbouring_rings = neighbouring_rings - - while True: - ring_id: int = -1 - for ring in self.rings: - if self.is_part_of_bridged_ring(ring.id) and not ring.bridged: - ring_id: int = ring.id - if ring_id == -1: - break - ring: 'RingProperties' = self.id_to_ring[ring_id] - - involved_ring_ids: Union[list[int], Set[int]] = [] - self.get_bridged_ring_subrings(ring.id, involved_ring_ids) - involved_ring_ids = set(involved_ring_ids) - - self.has_bridged_ring = True - self.create_bridged_ring(involved_ring_ids) - - for involved_ring_id in involved_ring_ids: - involved_ring = self.id_to_ring[involved_ring_id] - self.remove_ring(involved_ring) - - - bridged_systems = self.find_bridged_systems(self.rings, self.ring_overlaps) - if bridged_systems and not self.has_bridged_ring: - self.has_bridged_ring = True - for bridged_system in bridged_systems: - involved_ring_ids = set(bridged_system) - self.create_bridged_ring(involved_ring_ids) - for involved_ring_id in involved_ring_ids: - involved_ring = self.id_to_ring[involved_ring_id] - self.remove_ring(involved_ring) - - - - def add_ring_overlap(self, ring_overlap: 'RingOverlap') -> None: - """ - Adds a new ring overlap to the list of ring overlaps and assigns it a unique identifier. - - This method assigns a unique identifier to the given ring overlap and appends it to the class's list - of ring overlaps. It ensures that each ring overlap is uniquely identifiable and can be tracked - throughout the calculation process. - - Parameters - :param ring_overlap: RingOverlap - The ring overlap to be added to the list of overlaps. This object represents the intersection - between two rings in the molecule, which may need special handling during the layout calculation - to avoid visual clutter or incorrect representation. - """ - ring_overlap.id = self.ring_overlap_id_tracker - self.ring_overlaps.append(ring_overlap) - self.ring_overlap_id_tracker += 1 - - - - def is_part_of_bridged_ring(self, ring_id: int) -> bool: - """ - Determines if a given ring is part of a bridged ring system. - - This method checks if a ring, identified by its ID, is involved in a bridged ring system by - examining the list of ring overlaps. It returns True if the ring is part of a bridged system, - indicating that it is connected to another ring through a bridge, and False otherwise. - - Parameters - :param ring_id: int: - The identifier of the ring to check for involvement in a bridged ring system. - - Returns bool: - True if the ring is part of a bridged ring system, indicating that it is interconnected with - another ring through a bridge, and False otherwise. - """ - return any(ring_overlap.involves_ring(ring_id) and ring_overlap.is_bridge() \ - for ring_overlap in self.ring_overlaps) - - - - def get_bridged_ring_subrings(self, ring_id: int, involved_ring_ids: List[int]) -> None: - """ - Recursively identifies and collects the IDs of all rings involved in a bridged ring system starting - from a given ring ID. - - This method is used to find all rings that are interconnected as part of a bridged ring system, - starting from a specified ring ID. It recursively explores neighboring rings to identify all rings - that are connected through bridges, adding their IDs to a list of involved ring IDs. - - Parameters - :param ring_id: int - The identifier of the starting ring from which to begin the search for interconnected rings in a - bridged system. - :param involved_ring_ids: List[int] - A list to which the IDs of rings involved in the bridged system are appended. This list is - populated with the IDs of all rings found to be part of the bridged ring system. - """ - involved_ring_ids.append(ring_id) - ring = self.id_to_ring[ring_id] - for neighbour_id in ring.neighbouring_rings: - if neighbour_id not in involved_ring_ids and neighbour_id != ring_id and \ - self.rings_connected_by_bridge(self.ring_overlaps, ring_id, neighbour_id): - self.get_bridged_ring_subrings(neighbour_id, involved_ring_ids) - - - - @staticmethod - def rings_connected_by_bridge(ring_overlaps: List['RingOverlap'], ring_id_1: int, ring_id_2: int): - """ - Determines if two rings are connected by a bridge based on the list of ring overlaps. - - This method checks if two rings, identified by their IDs, are connected through a bridge by - examining the list of ring overlaps. It returns True if a bridge connection is found between the - specified rings, and False otherwise. - - Parameters - :param ring_overlaps: List['RingOverlap'] - A list of RingOverlap objects representing overlaps between rings in the molecule. Each - RingOverlap object contains information about the rings involved in the overlap and whether it - constitutes a bridge. - :param ring_id_1: int - The identifier of the first ring to check for a bridge connection. - :param ring_id_2: int - The identifier of the second ring to check for a bridge connection. - - Returns bool: - True if the specified rings are connected by a bridge, indicating a direct connection that forms - part of a bridged ring system, and False otherwise. - """ - for ring_overlap in ring_overlaps: - if ring_id_1 == ring_overlap.ring_id_1 and ring_id_2 == ring_overlap.ring_id_2: - return ring_overlap.is_bridge() - if ring_id_2 == ring_overlap.ring_id_1 and ring_id_1 == ring_overlap.ring_id_2: - return ring_overlap.is_bridge() - return False - - - - - - def create_bridged_ring(self, involved_ring_ids: Set[int]) -> None: - """ - Creates a unified representation for a bridged ring system by merging the specified rings into a - single RingProperties object. - - This method processes a set of ring IDs that are part of a bridged ring system, creating a new - RingProperties object that represents the interconnected rings as a single entity. It involves - identifying all atoms and neighbours involved in the bridged system, determining their roles (e.g., - bridge atoms), and updating the molecular structure to reflect this unified representation. - - Parameters - : param involved_ring_ids: Set[int] - A set of ring IDs that are part of a bridged ring system to be merged into a single - RingProperties object. - - Notes - - Initializes sets for atoms and neighbours involved in the bridged ring system. - - Iterates through each ring ID in the provided set, marking each as part of a subring of the ridged - system and collecting all member atoms and their neighbouring rings. - - Identifies atoms that are part of the bridged system and classifies them based on their nvolvement - in the ring system, distinguishing between those that are bridge atoms and those that are part of he - bridged ring itself. - - Creates a new RingProperties object for the bridged ring, adding it to the class's list of rings - and updating its attributes to reflect its bridged nature and interconnectedness. - - Updates the molecular structure to incorporate the new bridged ring, including updating atom - memberships and removing overlaps between the original rings that are now part of the bridged - system. - - This process is crucial for accurately representing complex cyclic structures within the molecule, - where rings are interconnected in a way that they share atoms, forming a bridged system. It ensures - that the molecular graph accurately reflects the topology of such systems, which is essential for - the correct calculation of atom positions and the overall layout in 2D space. - """ - atoms: Set['AtomProperties'] = set() - neighbours: Set[int] = set() - for ring_id in involved_ring_ids: - ring: 'RingProperties' = self.id_to_ring[ring_id] - ring.subring_of_bridged = True - for atom in ring.members: - atoms.add(atom) - for neighbour_id in ring.neighbouring_rings: - neighbours.add(neighbour_id) - leftovers: Set['AtomProperties'] = set() - ring_members: Set['AtomProperties'] = set() - for atom in atoms: - atom_rings_members_id: Set[int] = {ring.id for ring in atom.rings} - intersect = involved_ring_ids.intersection(atom_rings_members_id) - if len(atom.rings) == 1 or len(intersect) == 1: - ring_members.add(atom) - else: - leftovers.add(atom) - for atom in leftovers: - is_on_ring = False - for bond in self.get_bonds_of_atom(atom): - bond_associated_rings = min(len(bond.atom1.rings), len(bond.atom2.rings)) - if bond_associated_rings == 1: - is_on_ring = True - if is_on_ring: - atom.is_bridge_atom = True - ring_members.add(atom) - else: - atom.is_bridge = True - ring_members.add(atom) - bridged_ring = RingProperties(list(ring_members)) - self.add_ring(bridged_ring) - bridged_ring.bridged = True - bridged_ring.neighbouring_rings = list(neighbours) - for ring_id in involved_ring_ids: - ring = self.id_to_ring[ring_id] - bridged_ring.subrings.append(ring.copy()) - for atom in ring_members: - atom.bridged_ring = bridged_ring.id - for ring_id in involved_ring_ids: - if self.id_to_ring[ring_id] in atom.rings: - atom.rings.remove(self.id_to_ring[ring_id]) - atom.rings.append(bridged_ring) - involved_ring_ids: List[int] = list(involved_ring_ids) - for i, ring_id_1 in enumerate(involved_ring_ids): - for ring_id_2 in involved_ring_ids[i + 1:]: - self.remove_ring_overlaps_between(ring_id_1, ring_id_2) - for neighbour_id in neighbours: - ring_overlaps: List['RingOverlap'] = self.get_ring_overlaps(neighbour_id, involved_ring_ids) - for ring_overlap in ring_overlaps: - - ring_overlap.update_other(bridged_ring.id, neighbour_id) - neighbour = self.id_to_ring[neighbour_id] - neighbour.neighbouring_rings.append(bridged_ring.id) - - - - def remove_ring_overlaps_between(self, ring_id_1: int, ring_id_2: int) -> None: - """ - Removes ring overlaps between two specified rings from the list of ring overlaps. - - This method identifies and removes any ring overlaps between two rings, specified by their IDs, from - the class's list of ring overlaps. It ensures that once rings are merged or otherwise processed in a - way that eliminates their overlap, the record of their previous overlap is removed to maintain an - accurate representation of the molecular structure. - - Parameters - :param ring_id_1: int - The identifier of the first ring for which overlaps should be removed. - :param ring_id_2: int - The identifier of the second ring for which overlaps should be removed. - """ - to_remove = [] - for ring_overlap in self.ring_overlaps: - if (ring_overlap.ring_id_1 == ring_id_1 and ring_overlap.ring_id_2 == ring_id_2) or\ - (ring_overlap.ring_id_2 == ring_id_1 and ring_overlap.ring_id_1 == ring_id_2): - to_remove.append(ring_overlap) - for ring_overlap in to_remove: - self.ring_overlaps.remove(ring_overlap) - - - - def get_ring_overlaps(self, ring_id: int, ring_ids: List[int]) -> List['RingOverlap']: - """ - Retrieves a list of ring overlaps involving a specified ring and a list of other ring IDs. - - Parameters - :param ring_id: int - The identifier of the ring for which overlaps with other rings are to be found. - :param ring_ids: List[int] - A list of ring identifiers to check for overlaps with the specified ring. - - Returns List['RingOverlap'] - A list of RingOverlap objects representing the overlaps between the specified ring and any of - the rings identified by the IDs in the ring_ids list. Each RingOverlap object contains - information about the rings involved in the overlap and the nature of their intersection. - """ - ring_overlaps: List['RingOverlap'] = [] - for ring_overlap in self.ring_overlaps: - for ring_id_2 in ring_ids: - if (ring_overlap.ring_id_1 == ring_id and ring_overlap.ring_id_2 == ring_id_2) or\ - (ring_overlap.ring_id_2 == ring_id and ring_overlap.ring_id_1 == ring_id_2): - ring_overlaps.append(ring_overlap) - return ring_overlaps - - - - def remove_ring(self, ring: 'RingProperties') -> None: - """ - Removes a specified ring from the list of rings and updates the list of ring overlaps accordingly. - - Parameters - :param ring: RingProperties - The RingProperties object to be removed from the list of rings. - """ - self.rings.remove(ring) - overlaps_to_remove = [] - for ring_overlap in self.ring_overlaps: - if ring_overlap.ring_id_1 == ring.id or ring_overlap.ring_id_2 == ring.id: - overlaps_to_remove.append(ring_overlap) - for ring_overlap in overlaps_to_remove: - self.ring_overlaps.remove(ring_overlap) - for neighbouring_ring in self.rings: - if ring.id in neighbouring_ring.neighbouring_rings: - neighbouring_ring.neighbouring_rings.remove(ring.id) - - - - def find_bridged_systems(self, rings: List['RingProperties'], ring_overlaps: 'RingOverlap') -> List: - """ - Identifies bridged ring systems within the molecule based on the provided rings and their overlaps. - - Parameters - :param rings : List['RingProperties'] - A list of RingProperties objects representing the rings within the molecule to be analyzed. - :param ring_overlaps : List['RingOverlap'] - A list of RingOverlap objects representing overlaps between rings, which is used to determine - the interconnectedness of the rings. - - Returns List[List[int]] - A list of ring groups, where each group is represented as a list of ring IDs. Each group is - identified as a bridged system based on the criteria that the number of overlaps is at least as - great as the number of rings in the group, indicating a high likelihood of forming a bridged - ring system. - """ - bridged_systems: List = [] - ring_groups = self.get_ring_groups(rings, ring_overlaps) - for ring_group in ring_groups: - ring_nr: int = len(ring_group) - overlap_nr: int = self.get_group_overlap_nr(ring_group, ring_overlaps) - if overlap_nr >= ring_nr: - bridged_systems.append(ring_group) - return bridged_systems - - - # @TODO: непонятный докстринг, переписать - - def get_ring_groups(self, rings: List['RingProperties'], ring_overlaps: List['RingOverlap']) -> List: - """ - Organizes rings into groups based on their overlaps, identifying interconnected ring systems within - the molecule. - - Parameters - :param rings: List['RingProperties'] - A list of RingProperties objects representing the rings within the molecule to be analyzed. - :param ring_overlaps: List['RingOverlap'] - A list of RingOverlap objects representing overlaps between rings, which is used to determine - the interconnectedness of the rings. - - Returns List[List[int]] - A list of ring groups, where each group is represented as a list of ring IDs. Rings within a - group are interconnected, either directly or through a series of overlaps, indicating potential - bridged or fused ring systems. - - Notes - - Initializes a list of ring groups, starting with each ring as a separate group. - - Iteratively merges groups that have overlaps, indicating a structural relationship between rings, - until no more merges are possible. This is determined by comparing the number of groups before and - after attempting merges. - - Uses a helper method, ring_groups_have_overlap, to identify if two groups share an overlap, - suggesting they should be merged into a single group. - - Merging is done by creating a union of the two groups and removing the original groups from the - list, then adding the merged group. This process simplifies the representation of the molecule's - ring structure by consolidating interconnected rings. - - The merging process continues until the number of groups stabilizes, indicating that all - interconnected rings have been grouped together. - - This method is crucial for simplifying the analysis of molecular structures with complex cyclic - components, as it reduces the complexity of the ring structure by grouping interconnected rings. - This simplification aids in the identification of bridged and fused ring systems, which are - important for accurate layout calculations and visualization. - - By organizing rings into groups, it provides a basis for further analysis, such as identifying - bridged ring systems or resolving the layout of rings in a way that reflects their - interconnectedness, which is essential for the accurate representation of molecular topology in 2D - space. - - The final list of ring groups represents a simplified view of the molecule's cyclic structure, - where each group may correspond to a bridged, fused, or independent ring system, depending on the - overlaps between rings. - """ - ring_groups = [] - for ring in rings: - ring_groups.append([ring.id]) - - current_ring_nr = 0 - previous_ring_nr = -1 - while current_ring_nr != previous_ring_nr: - previous_ring_nr = current_ring_nr - indices = None - new_group = None - for i, ring_group_1 in enumerate(ring_groups): - ring_group_1_found = False - for j, ring_group_2 in enumerate(ring_groups): - if i != j: - if self.ring_groups_have_overlap(ring_group_1, ring_group_2, ring_overlaps): - indices = [i, j] - new_group = list(set(ring_group_1 + ring_group_2)) - ring_group_1_found = True - break - if ring_group_1_found: - break - - if new_group: - indices.sort(reverse=True) - for index in indices: - ring_groups.pop(index) - ring_groups.append(new_group) - - current_ring_nr = len(ring_groups) - return ring_groups - - - - def ring_groups_have_overlap(self, group_1: List[int], group_2: List[int], \ - ring_overlaps: List['RingOverlap']) -> bool: - """ - Determines if two ring groups have an overlap based on the list of ring overlaps. - - Parameters - :param group_1: List[int] - The first group of ring IDs to check for overlaps. - :param group_2: List[int] - The second group of ring IDs to check for overlaps. - :param ring_overlaps: List['RingOverlap'] - A list of RingOverlap objects representing overlaps between rings in the molecule. - Each RingOverlap object contains information about the rings involved in the overlap. - - Returns bool - True if an overlap is found between any rings from the two groups, indicating a structural - relationship, and False otherwise. - """ - # for ring_1 in group_1: - # for ring_2 in group_2: - # if ring_1 in self.find_neighbouring_rings(ring_overlaps, ring_2): - # return True - # return False - # @TODO: ниже моя версия - return any(ring_1 in self.find_neighbouring_rings(ring_overlaps, ring_2) \ - for ring_1 in group_1 for ring_2 in group_2) - - - @staticmethod - def get_group_overlap_nr(ring_group, ring_overlaps: List['RingOverlap']) -> int: - """ - Calculates the number of overlaps within a group of rings based on a list of ring overlaps. - - Parameters: - :param ring_group List[int] - A list of ring identifiers (IDs) representing a group of rings to check for overlaps among. - :param ring_overlaps List['RingOverlap']: - A list of `RingOverlap` objects, where each object represents an overlap between two rings, - identified by their IDs (`ring_id_1` and `ring_id_2`). - - Returns int The total number of overlaps found within the `ring_group`, where an overlap is - counted if both rings involved are members of the group. - """ - # overlaps = 0 - # ring_group = set(ring_group) - # for ring_overlap in ring_overlaps: - # if ring_overlap.ring_id_1 in ring_group and ring_overlap.ring_id_2 in ring_group: - # overlaps += 1 - # return overlaps - # @TODO: моя версия укороченная версиянадо потестировать - ring_group_set = set(ring_group) - return sum(overlap.ring_id_1 in ring_group_set and overlap.ring_id_2 in ring_group_set\ - for overlap in ring_overlaps) - - - def get_bonds_of_atom(self, atom: 'AtomProperties') -> List['BondProperties']: - """ - Retrieves all bonds associated with a specified atom within the molecular graph. - - This method searches the molecular graph for bonds that involve a given atom, returning - a list of all bonds connected to it. Each bond is represented by a BondProperties - object, which encapsulates details about the bond type and the atoms it connects. By - iterating through the collection of all bonds in the graph and checking if the specified - atom is involved in each bond, the method accurately identifies all connections of the - atom, regardless of the atom's role (whether as the starting or ending atom of the - bond). - - Parameters: - :param atom AtomProperties: - The atom whose bonds are to be retrieved. This atom is identified by its unique - identifier within the molecular graph. - - Returns List[BondProperties]: - A list of BondProperties objects representing all bonds connected to the specified - atom. Each entry in the list corresponds to a distinct bond involving the atom, - providing comprehensive information about the atom's connectivity within the - molecular structure. - """ - # bonds: List['BondProperties'] = [] - # for (atom1_id, atom2_id), bond in self.bonds.items(): - # if atom.id in (atom1_id, atom2_id): - # bonds.append(bond) - # return bonds - # @TODO: моя сокращенная версия - return [bond for (atom1_id, atom2_id), bond in self.bonds.items() \ - if atom.id in (atom1_id, atom2_id)] - - - - def add_ring(self, ring: 'RingProperties') -> None: - """ - Adds a new ring to the class's collection and updates the internal tracking of ring - identifiers. - - Parameters: - :param ring 'RingProperties': - The `RingProperties` object representing the ring to be added. This - object encapsulates the properties and characteristics of the ring, such as its member atoms, size, and type (e.g., aromatic, bridged). - """ - ring.id = self.ring_id_tracker - self.rings.append(ring) - self.id_to_ring[ring.id] = ring - self.ring_id_tracker += 1 - - - ##первое приближение - def initial_approximation(self) -> None: - """ - Determines the initial atom from which to start the layout calculation process for a molecular - graph in 2D space. - - This method iterates through the molecular graph to find an appropriate starting atom based on - several criteria: - 1. Prefers an atom that is part of a bridged ring system, indicating complex cyclic structures - that require careful handling. - 2. If no such atom is found, it looks for an atom that belongs to a bridged ring, which suggests - a connection between rings that might need special attention during layout. - 3. If still no suitable atom is found, and if there are rings defined, it selects the first - member of the first ring in the class's ring list. - 4. If no rings are defined or none of the above conditions are met, it selects a terminal atom, - which is an atom with no more than one bond, simplifying the starting conditions. - 5. As a last resort, if no terminal atom is found, it defaults to the first atom in the graph. - - After selecting the starting atom, it initiates the bond creation process by calling - `create_next_bond` with the selected atom, setting the stage for further layout calculations. - """ - start_atom = None - - for atom in self.graph: - if atom.bridged_ring is not None: - start_atom = atom - break - - if start_atom is None: - for ring in self.rings: - if ring.bridged: - start_atom = ring.members[0] - - if start_atom is None: - if len(self.rings) > 0: - start_ring: 'RingProperties' = self.id_to_ring[0] - start_atom = start_ring.members[0] - - if start_atom is None: - for atom in self.graph: - if atom.is_terminal(): - start_atom = atom - break - - if start_atom is None: - start_atom = self.graph[0] - self.create_next_bond(start_atom, None, 0.0) - - - - def create_next_bond(self, atom: 'AtomProperties', previous_atom: Optional['AtomProperties']=None, \ - angle: float=0.0, previous_branch_shortest: bool = False) -> None: - """ - Creates the next bond for an atom in the molecular structure, updating its position - based on the previous atom and angle. - - Parameters: - :param atom: AtomProperties: - The atom for which the next bond is being created. - :param previous_atom: Optional[AtomProperties]: - The previous atom connected to the current atom. If None, it is assumed that the - current atom is the first in the molecular structure. - :param angle: float: - The angle between the previous atom and the current atom in radians. Default is 0.0. - :param previous_branch_shortest: bool: - A flag indicating if the previous branch is the shortest. Default is False. - - Logic: - 1. If the atom is already positioned, the method ends without changes. - 2. If there is no previous atom, a special method for the first atom is used. - 3. If the previous atom is connected to one or more rings, a method for calculating the - atom's position in ring structures is used. - 4. Otherwise, if the previous atom is not connected to rings, a method for calculating - the atom's position without considering rings is used. - 5. If the atom has connected rings, a method for atoms in ring structures is applied. - 6. Depending on the number of neighbors the atom has (from 1 to 4), the corresponding - method is chosen to calculate its position, considering various neighbor configurations. - """ - if atom.positioned: - return - if previous_atom is None: - self.calculate_first_atom(atom) - elif len(previous_atom.rings) > 0: - self.calculate_rings(previous_atom, atom) - else: - self.calculate_NOT_first_atom(atom, previous_atom, angle) - - if len(atom.rings) > 0: - self.calculate_some_rings(atom) - else: - neighbours: List['AtomProperties'] = atom.neighbours[:] - if previous_atom and previous_atom in neighbours: - neighbours.remove(previous_atom) - previous_angle: float = atom.get_angle() - if len(neighbours) == 1: - self.calculate_1_neighbours(neighbours, atom, previous_atom, \ - previous_angle, previous_branch_shortest) - elif len(neighbours) == 2: - self.calculate_2_neighbours(atom, neighbours, previous_atom, previous_angle) - elif len(neighbours) == 3: - self.calculate_3_neighbours(atom, neighbours, previous_atom, previous_angle) - elif len(neighbours) == 4: - self.calculate_4_neighbours(atom , neighbours, previous_angle) - - - def calculate_first_atom(self, atom: 'AtomProperties') -> None: - """ - Calculates the initial position for the first atom in a molecule. - - This method sets the initial position for the first atom in the molecular structure. It - assigns a default position based on the class's bond length and rotates it to a standard - orientation. The atom is marked as positioned if it is not part of a bridged ring - system, ensuring it's ready for further calculations in the molecular layout process. - - Parameters: - :param atom AtomProperties: - The first atom in the molecule to calculate the initial position for. - """ - dummy: Vector = Vector(self.bond_length, 0) - dummy.rotate(math.radians(-60.0)) - atom.previous_position = dummy - atom.previous_atom = None - atom.set_position(Vector(self.bond_length, 0)) - atom.angle = math.radians(-60.0) - if atom.bridged_ring is None: - atom.positioned = True - - - # @TODO: Дать нормальное название - def calculate_NOT_first_atom(self, atom: 'AtomProperties', - previous_atom: 'AtomProperties', angle: float) -> None: - """ - Calculates the position for an atom that is not the first in the molecule, based on its - previous atom and a given angle. - - Parameters: - :param atom AtomProperties: - The atom for which the position is being calculated. - :param previous_atom AtomProperties: - The atom preceding the current atom in the molecular structure, used as a reference - for positioning. - :param angle float: - The angle in radians by which the position vector should be rotated to align the - atom correctly relative to the previous atom. - """ - position: Vector = Vector(self.bond_length, 0) - position.rotate(angle) - position.add(previous_atom.position) - atom.set_position(position) - atom.set_previous_position(previous_atom) - atom.positioned = True - - - - # @TODO: разбить конкретную функцию на несколько логически обоснованных частей - # например отдельно 2-3 связи отдельно кольца и отдельно остальные случаи - def calculate_1_neighbours(self, neighbours: List[int], atom: 'AtomProperties', \ - previous_atom: 'AtomProperties', previous_angle: float, \ - previous_branch_shortest: bool) -> None: - """ - Calculates the position for an atom with exactly one neighbor in the molecular - structure, considering various bonding scenarios and configurations. - - This method is designed to handle the placement of an atom that has only one neighbor - within the molecular structure, taking into account the type of bonds it forms with its - previous atom and the presence of any rings. It adjusts the atom's position based on the - bond type (single, double, or triple) and the configuration of the molecule, including - handling cis and trans isomerism in specific scenarios. The method also considers the - angle of the previous bond and the shortest branch condition to correctly orient the - atom in space. - - Parameters: - :param neighbours List[int]: - A list of atom indices representing the neighbors of the current atom. Since the - atom has only one neighbor, this list should contain a single element. - :param atom AtomProperties: - The atom for which the position is being calculated. - :param previous_atom AtomProperties: - The atom preceding the current atom in the molecular structure, used as a reference - for positioning. - :param previous_angle float: - The angle in radians between the previous atom and the current atom. - :param previous_branch_shortest bool: - Indicates if the previous branch is the shortest, affecting the orientation of the next bond. - """ - next_atom: 'AtomProperties' = neighbours[0] - current_bond: Optional['BondProperties'] = self.bond_lookup(atom, next_atom) - previous_bond: Optional['BondProperties'] = None - if previous_atom: - previous_bond = self.bond_lookup(previous_atom, atom) - if current_bond.type == 'triple' or (previous_bond and previous_bond.type == 'triple') or \ - (current_bond.type == 'double' and previous_bond and previous_bond.type == 'double'\ - and previous_atom and len(previous_atom.rings) == 0 and len(atom.neighbours) == 2): - if current_bond.type == 'double' and previous_bond.type == 'double': - atom.draw_explicit = True - if current_bond.type == 'triple': - atom.draw_explicit = True - next_atom.draw_explicit = True - if current_bond.type == 'double' or current_bond.type == 'triple' or \ - (previous_atom and previous_bond.type == 'triple'): - next_atom.angle = math.radians(0) - angle_ = previous_angle + next_atom.angle - self.create_next_bond(next_atom, atom, angle_) - elif previous_atom and len(previous_atom.rings) > 0: - proposed_angle_1: float = math.radians(60.0) - proposed_angle_2: float = proposed_angle_1 * -1 - - proposed_vector_1: 'Vector' = Vector(self.bond_length, 0) - proposed_vector_2: 'Vector' = Vector(self.bond_length, 0) - proposed_vector_1.rotate(proposed_angle_1 + atom.get_angle()) - proposed_vector_2.rotate(proposed_angle_2 + atom.get_angle()) - proposed_vector_1.add(atom.position) - proposed_vector_2.add(atom.position) - centre_of_mass: Vector = self.get_current_centre_of_mass() - distance_1: float = proposed_vector_1.get_squared_distance(centre_of_mass) - distance_2: float = proposed_vector_2.get_squared_distance(centre_of_mass) - if distance_1 < distance_2: - previous_atom.angle = proposed_angle_2 - else: - previous_atom.angle = proposed_angle_1 - angle_: float = previous_angle + previous_atom.angle - self.create_next_bond(next_atom, atom, angle_) - else: - proposed_angle: float = atom.angle - - if previous_atom and len(previous_atom.neighbours) > 3: - if round(proposed_angle, 2) > 0.00: - proposed_angle: float = min([math.radians(60), proposed_angle]) - elif round(proposed_angle, 2) < 0.00: - proposed_angle: float = max([-math.radians(60), proposed_angle]) - else: - proposed_angle: float = math.radians(60) - elif proposed_angle in (0, None): - last_angled_atom: 'AtomProperties' = self.get_last_atom_with_angle(atom) - proposed_angle: float = last_angled_atom.angle - if proposed_angle is None: - proposed_angle: float = math.radians(60) - - rotatable: bool = True - if previous_atom: - bond: 'BondProperties' = self.bond_lookup(previous_atom, atom) - # This handles cases where there are no second explicit atoms in the - # configuration # of carbons between which cis and trans isomerism can occur - # For example smile = "F/C=C/F" or "F/C=C\F". - if bond.type == 'double': - rotatable: bool = False - previous_previous_atom: 'AtomProperties' = previous_atom.previous_atom - if previous_previous_atom: - if (configuration := self.get_configuration(previous_atom, atom)) is not None: - if configuration == 'cis': - proposed_angle = -proposed_angle - if rotatable: - next_atom.angle = proposed_angle if previous_branch_shortest else -proposed_angle - else: - next_atom.angle = -proposed_angle - self.create_next_bond(next_atom, atom, previous_angle + next_atom.angle) - - - - def calculate_2_neighbours(self, atom: 'AtomProperties', neighbours: List['AtomProperties'], \ - previous_atom: 'AtomProperties', previous_angle: float) -> None: - """ - Calculates the positions for an atom with exactly two neighbours in the molecular - structure, considering cis and trans isomerism and the shortest branch condition. - - This method is responsible for determining the positions of an atom that has exactly two - neighbours within the molecular structure. It takes into account the possibility of cis - and trans isomerism and adjusts the atom's orientation based on the shortest branch - condition to ensure correct spatial arrangement. The method first checks for the - presence of a proposed angle for the atom; if none is found, a default angle is - assigned. It then handles cis and trans isomerism by adjusting the angles of the atom - and its neighbours accordingly. The method also determines whether the previous branch - is the shortest by comparing the sizes of subgraphs involving the previous atom and the - neighbours, which influences the orientation of the new bonds created. Finally, it - creates the next bonds for the atom with its neighbours, incorporating the calculated - angles and the shortest branch condition. - - Parameters: - :param atom AtomProperties: - The atom for which the positions are being calculated. - :param neighbours List[AtomProperties]: - A list of the atom's neighbours, which should contain exactly two elements. - :param previous_atom AtomProperties: - The atom preceding the current atom in the molecular structure, used as a reference - for positioning. - :param previous_angle float: - The angle in radians between the previous atom and the current atom. - """ - proposed_angle = atom.angle - if not proposed_angle: - proposed_angle = math.radians(60) - - self.handle_cis_trans_isomery(atom, neighbours, previous_atom, proposed_angle) - - if previous_atom: - subgraph_3_size: int = self.get_subgraph_size(previous_atom, {atom}) - else: - subgraph_3_size: int = 0 - - previous_branch_shortest = False - if subgraph_3_size < self.get_subgraph_size(neighbours[0], {atom}) and \ - subgraph_3_size < self.get_subgraph_size(neighbours[1], {atom}): - previous_branch_shortest = True - - self.create_next_bond(neighbours[0], atom, previous_angle + neighbours[0].angle, previous_branch_shortest) - self.create_next_bond(neighbours[1], atom, previous_angle + neighbours[1].angle, previous_branch_shortest) - - - - def handle_cis_trans_isomery(self, atom: 'AtomProperties', neighbours: List['AtomProperties'], \ - previous_atom: 'AtomProperties', proposed_angle: float) -> None: - """ - Handles the case of cis and trans isomerism for an atom with two neighbours, adjusting - their angles based on the isomeric configuration. - - This method addresses the specific scenario of cis and trans isomerism for an atom - connected to two neighbours, determining the correct spatial orientation based on the - isomeric configuration and the types of bonds involved. It calculates the subgraph sizes - for each neighbour relative to the atom to identify the cis and trans positions, - adjusting their angles accordingly. The method also considers the bond types between the - atom and its neighbours to further refine the orientation in cases where both bonds are - single, potentially adjusting angles based on the configuration of the previous atom in - the molecular structure. - - Parameters: - :param atom AtomProperties: - The central atom for which cis and trans isomerism is being evaluated. - :param neighbours List[AtomProperties]: - A list containing exactly two neighbours of the atom, between which cis and trans - isomerism is considered. - :param previous_atom AtomProperties: - The atom preceding the current atom in the molecular structure, used for additional - configuration checks. - :param proposed_angle: float: The initial proposed angle for orientation, in radians. - """ - neighbour_1, neighbour_2 = neighbours - subgraph_1_size: int = self.get_subgraph_size(neighbour_1, {atom}) - subgraph_2_size: int = self.get_subgraph_size(neighbour_2, {atom}) - - cis_atom_index: int = 0 - trans_atom_index: int = 1 - - if neighbour_2.symbol == 'C' and neighbour_1.symbol != 'C' and subgraph_2_size > 1 and subgraph_1_size < 5: - cis_atom_index = 1 - trans_atom_index = 0 - elif neighbour_2.symbol != 'C' and neighbour_1.symbol == 'C' and subgraph_1_size > 1 and subgraph_2_size < 5: - cis_atom_index = 0 - trans_atom_index = 1 - elif subgraph_2_size > subgraph_1_size: - cis_atom_index = 1 - trans_atom_index = 0 - - cis_atom: 'AtomProperties' = neighbours[cis_atom_index] - trans_atom: 'AtomProperties' = neighbours[trans_atom_index] - - trans_atom.angle = proposed_angle - cis_atom.angle = -proposed_angle - - cis_bond: 'BondProperties' = self.bond_lookup(atom, cis_atom) - trans_bond: 'BondProperties' = self.bond_lookup(atom, trans_atom) - - if cis_bond.type == 'single' and trans_bond.type == 'single': - if previous_atom: - previous_bond: 'BondProperties' = self.bond_lookup(atom, previous_atom) - if previous_bond.type == 'double': - if previous_atom.previous_atom: - atom1, atom2 = previous_atom, atom - configuration_cis_atom: Optional[str] = self.get_configuration(atom1, atom2) - if configuration_cis_atom == 'trans': - trans_atom.angle = -proposed_angle - cis_atom.angle = proposed_angle - - - - def calculate_3_neighbours(self, atom: 'AtomProperties', neighbours: List['AtomProperties'], \ - previous_atom: 'AtomProperties', previous_angle: float) -> None: - """ - Calculates the positions for an atom with exactly three neighbours in the molecular - structure, adjusting angles based on subgraph sizes and ring involvement. - - This method is designed to handle the placement of an atom that has exactly three - neighbours within the molecular structure. It determines the orientation of these - neighbours based on the sizes of their subgraphs relative to the central atom and - adjusts their angles accordingly. The method identifies a 'straight' atom, which is - considered the primary direction of extension from the central atom, and two side atoms. - The orientation of these atoms is adjusted based on whether they are involved in any - rings and the overall structure of the molecule, ensuring a correct spatial arrangement - that minimizes overlaps and maintains the integrity of the molecular geometry. - - Parameters: - :param atom: AtomProperties - The central atom for which the positions of its neighbours are being calculated. - :param neighbours List[AtomProperties]: - A list of the atom's neighbours, which should contain exactly three elements. - :param previous_atom AtomProperties: - The atom preceding the current atom in the molecular structure, used as a reference - for positioning. - :param previous_angle float: - The angle in radians between the previous atom and the current atom, influencing - the orientation of the neighbours. - """ - subgraph_1_size = self.get_subgraph_size(neighbours[0], {atom}) - subgraph_2_size = self.get_subgraph_size(neighbours[1], {atom}) - subgraph_3_size = self.get_subgraph_size(neighbours[2], {atom}) - straight_atom: 'AtomProperties' = neighbours[0] - left_atom: 'AtomProperties' = neighbours[1] - right_atom: 'AtomProperties' = neighbours[2] - if subgraph_2_size > subgraph_1_size and subgraph_2_size > subgraph_3_size: - straight_atom = neighbours[1] - left_atom = neighbours[0] - right_atom = neighbours[2] - elif subgraph_3_size > subgraph_1_size and subgraph_3_size > subgraph_2_size: - straight_atom = neighbours[2] - left_atom = neighbours[0] - right_atom = neighbours[1] - if previous_atom and len(previous_atom.rings) < 1\ - and len(straight_atom.rings) < 1\ - and len(left_atom.rings) < 1\ - and len(right_atom.rings) < 1\ - and self.get_subgraph_size(left_atom, {atom}) == 1\ - and self.get_subgraph_size(right_atom, {atom}) == 1\ - and self.get_subgraph_size(straight_atom, {atom}) > 1: - straight_atom.angle = atom.angle * -1 #maybe bug - if atom.angle >= 0: - left_atom.angle = math.radians(30) - right_atom.angle = math.radians(90) - else: - left_atom.angle = math.radians(-30) - right_atom.angle = math.radians(-90) - else: - straight_atom.angle = math.radians(0) - left_atom.angle = math.radians(90) - right_atom.angle = math.radians(-90) - self.create_next_bond(straight_atom, atom, previous_angle + straight_atom.angle) - self.create_next_bond(left_atom, atom, previous_angle + left_atom.angle) - self.create_next_bond(right_atom, atom, previous_angle + right_atom.angle) - - - - def calculate_4_neighbours(self, atom: 'AtomProperties', neighbours: List['AtomProperties'], \ - previous_angle: float) -> None: - """ - Handles the case when an atom has exactly four neighbours, adjusting their positions and - angles for correct spatial arrangement. - - This method is responsible for calculating the positions and angles of an atom that is - connected to four neighbours within the molecular structure. It determines the optimal - arrangement of these neighbours based on the sizes of their subgraphs relative to the - central atom, ensuring a non-overlapping and structurally sound configuration. The - method assigns specific angles to each neighbour to maintain a consistent and clear - representation of the molecular geometry, especially in complex molecular structures - where an atom is central to four other atoms. - - Parameters: - :param atom AtomProperties: - The central atom around which the neighbours are positioned. - :param neighbours List[AtomProperties]: - A list of the atom's neighbours, which should contain exactly four elements. - :param previous_angle float: - The angle in radians between the previous atom and the current atom, used as a - reference for positioning the neighbours. - """ - subgraph_1_size = self.get_subgraph_size(neighbours[0], {atom}) - subgraph_2_size = self.get_subgraph_size(neighbours[1], {atom}) - subgraph_3_size = self.get_subgraph_size(neighbours[2], {atom}) - subgraph_4_size = self.get_subgraph_size(neighbours[3], {atom}) - atom_1: 'AtomProperties' = neighbours[0] - atom_2: 'AtomProperties' = neighbours[1] - atom_3: 'AtomProperties' = neighbours[2] - atom_4: 'AtomProperties' = neighbours[3] - if subgraph_2_size > subgraph_1_size and subgraph_2_size > subgraph_3_size\ - and subgraph_2_size > subgraph_4_size: - atom_1 = neighbours[1] - atom_2 = neighbours[0] - elif subgraph_3_size > subgraph_1_size and subgraph_3_size > subgraph_2_size\ - and subgraph_3_size > subgraph_4_size: - atom_1 = neighbours[2] - atom_2 = neighbours[0] - atom_3 = neighbours[1] - elif subgraph_4_size > subgraph_1_size and subgraph_4_size > subgraph_2_size\ - and subgraph_4_size > subgraph_3_size: - atom_1 = neighbours[3] - atom_2 = neighbours[0] - atom_3 = neighbours[1] - atom_4 = neighbours[2] - - atom_1.angle = math.radians(-36) - atom_2.angle = math.radians(36) - atom_3.angle = math.radians(-108) - atom_4.angle = math.radians(108) - self.create_next_bond(atom_1, atom, previous_angle + atom_1.angle) - self.create_next_bond(atom_2, atom, previous_angle + atom_2.angle) - self.create_next_bond(atom_3, atom, previous_angle + atom_3.angle) - self.create_next_bond(atom_4, atom, previous_angle + atom_4.angle) - - - - def get_subgraph_size(self, atom: 'AtomProperties', \ - masked_atoms: Set['AtomProperties']) -> int: - """ - Calculates and returns the size of a subtree rooted at a given atom, excluding bonds adjacent to atoms specified in masked_atoms. - - This method computes the size of a subtree within a molecular graph, starting from a - specified atom and excluding any bonds connected to atoms listed in the `masked_atoms` - set. It recursively explores the molecular structure, adding each visited atom to the - `masked_atoms` set to avoid revisiting, and counts the total number of unique atoms in - the subtree. The size of the subtree is determined by the number of atoms it contains, - excluding the initial atom itself. - - Parameters: - :param atom AtomProperties: - The root atom from which the subtree size is calculated. - :param masked_atoms Set[AtomProperties]: - A set of atoms to be excluded from the subtree calculation, typically used to avoid - counting atoms that have already been considered in previous calculations or are not - relevant to the current analysis. - """ - masked_atoms.add(atom) - for neighbour in atom.neighbours: - if neighbour not in masked_atoms: - self.get_subgraph_size(neighbour, masked_atoms) - return len(masked_atoms) - 1 - - - ## rings calculated - # @TODO: Дать нормальное название - def calculate_rings(self, previous_atom: 'AtomProperties', \ - atom: 'AtomProperties') -> None: - """ - Calculates the positions for atoms within rings and aromatic systems, treating the - calculation as if it's performed from within the ring itself. - - This method is designed to determine the coordinates of atoms that are part of ring - structures or aromatic systems within a molecular graph. It operates under the - assumption that the calculation is being performed from the perspective of being inside - the ring, allowing for accurate positioning of atoms based on their connectivity and the - geometry of the ring. The method takes into account whether the previous atom is part of - a bridged ring and adjusts the position of the current atom accordingly, ensuring that - the ring's integrity and aromaticity are preserved in the molecular representation. It - involves identifying a 'joined vertex' if the previous atom is part of multiple rings, - adjusting the position based on the relative positions of neighbours, and setting the - atom's position to maintain the ring's structure. - - Parameters: - :param previous_atom AtomProperties: - The atom preceding the current atom in the ring, used as a reference for - calculating the current atom's position. - :param atom AtomProperties: - The atom for which the position is being calculated, ensuring it fits correctly - within the ring structure. - """ - neighbours: List['AtomProperties'] = previous_atom.neighbours - joined_vertex: Optional['AtomProperties'] = None - position: Vector = Vector(0, 0) - if previous_atom.bridged_ring is None and len(previous_atom.rings) > 1: - for neighbour in neighbours: - if len(set(neighbour.rings).intersection(set(previous_atom.rings))) == len(previous_atom.rings): - joined_vertex: 'AtomProperties' = neighbour - break - - - if not joined_vertex: - for neighbour in neighbours: - if neighbour.positioned and self.atoms_are_in_same_ring(neighbour, previous_atom): - position.add(Vector.subtract_vectors(neighbour.position, previous_atom.position)) - position.invert() - position.normalise() - position.multiply_by_scalar(self.bond_length) - position.add(previous_atom.position) - else: - position = joined_vertex.position.copy() - position.rotate_around_vector(math.pi, previous_atom.position) - atom.set_previous_position(previous_atom) - atom.set_position(position) - atom.positioned = True - - - # @TODO: Дать нормальное название - def calculate_some_rings(self, atom: 'AtomProperties') -> None: - """ - Calculates the coordinates for an atom connected to a ring, handling both bridged and - non-bridged ring scenarios. - - This method is responsible for determining the coordinates of an atom that is part of a - ring structure within a molecule. It distinguishes between atoms connected to bridged - rings and those that are part of regular rings, adjusting the calculation accordingly to - ensure accurate positioning within the molecular structure. For atoms connected to - bridged rings, it retrieves the specific ring properties to handle the complexity of - bridged systems, while for atoms in regular rings, it defaults to the first ring in the - atom's ring list. The method then calculates the center position of the ring based on - the atom's previous position and the ring's geometry, ensuring the atom is correctly - placed relative to the ring's center. This involves inverting the vector from the atom's - previous position to its current position, normalizing it, and scaling it according to - the ring's radius to find the new center. Finally, it creates the ring with the - calculated center, ensuring the atom's position is accurately represented within the - ring structure. - - Parameters: - :param atom AtomProperties: - The atom for which the ring coordinates are being calculated. This atom is assumed to be part of a ring structure, either directly or through a bridged connection. - """ - if atom.bridged_ring: - next_ring: 'RingProperties' = self.id_to_ring[atom.bridged_ring] - else: - next_ring: 'RingProperties' = atom.rings[0] - - if not next_ring.positioned: - next_center = Vector.subtract_vectors(atom.previous_position, atom.position) - next_center.invert() - next_center.normalise() - radius: float = Polygon.find_polygon_radius(self.bond_length, len(next_ring.members)) - next_center.multiply_by_scalar(radius) - next_center.add(atom.position) - self.create_ring(next_ring, next_center, atom) - - - - def create_ring(self, ring: 'RingProperties', center: Optional[Vector] = None, - start_atom: Optional['AtomProperties'] = None, - previous_atom: Optional['AtomProperties'] = None) -> None: - """ - Creates a ring within a molecular structure, considering its geometry and interaction - with other rings. - - This method is responsible for creating and positioning atoms in ring structures of a - molecule. It takes into account whether the ring is bridged, determines its center, and - arranges atoms according to the geometry of the ring, as well as handles interactions - with other rings through common vertices. For bridged rings, a special algorithm is used - to correctly position atoms relative to the ring center. For non-bridged rings, atoms - are positioned based on a given angle and radius calculated from the bond length and - number of members in the ring. The method also handles cases where ring atoms intersect - with other rings, ensuring correct connections and preventing overlaps. - - Parameters: - :param ring RingProperties: - Properties of the ring for which atom coordinates are being created. - :param center Optional[Vector]: - Center of the ring. If not specified, the origin (0, 0) is used. - :param start_atom Optional[AtomProperties]: - Starting atom for calculating positions of other atoms in the ring. - :param previous_atom Optional[AtomProperties]: - Previous atom used to determine the starting angle. - """ - if ring.positioned: - return - - if center is None: - center = Vector(0, 0) - ordered_neighbour_ids: List[int] = self.get_ordered_neighbours(ring, self.ring_overlaps) - starting_angle: float = 0 - if start_atom: - starting_angle = Vector.subtract_vectors(start_atom.position, center).angle() - ring_size: int = len(ring.members) - radius: float = Polygon.find_polygon_radius(self.bond_length, ring_size) - angle: float = Polygon.get_central_angle(ring_size) - ring.central_angle = angle - if start_atom not in ring.members: - if start_atom: - start_atom.positioned = False - start_atom = ring.members[0] - - - if ring.bridged: - KKLayout(structure=self, - atoms=ring.members, - center=center, - start_atom=start_atom, - bond_length=self.bond_length) - - - ring.positioned = True - self.set_ring_center(ring) - center = ring.center - for subring in ring.subrings: - self.set_ring_center(subring) - else: - self.set_member_positions(ring, start_atom, previous_atom, center, starting_angle, radius, angle) - ring.positioned = True - ring.center = center - - for neighbour_id in ordered_neighbour_ids: - neighbour: 'RingProperties' = self.id_to_ring[neighbour_id] - if neighbour.positioned: - continue - atoms: Optional[List['AtomProperties']] = self.get_vertices(self.ring_overlaps, ring.id, neighbour.id) - if len(atoms) == 2: - self.handle_fused_rings(ring, neighbour, atoms, center) - elif len(atoms) == 1: - self.handle_spiro_rings(ring, neighbour, atoms[0], center) - - for atom in ring.members: - for neighbour in atom.neighbours: - if neighbour.positioned: - continue - atom.connected_to_ring = True - self.create_next_bond(neighbour, atom, 0.0) - - - - def handle_fused_rings(self, ring: 'RingProperties', neighbour: 'RingProperties', - atoms: List['AtomProperties'], center: Vector) -> None: - """ - Handles the processing of fused cyclic systems within molecular structures, such as - decalin ('C12CCCCC1CCCC2'). - - This method addresses the specific challenges of dealing with fused ring systems in - molecular structures, where two rings share common atoms, creating complex cyclic - compounds like decalin. It marks both rings involved as fused, calculates the midpoint - between two shared atoms, determines the normals at this midpoint, and adjusts these - normals based on the apothem of the neighboring ring to find potential centers for the - next ring positions. By comparing distances from a given center to these adjusted - normals, it selects the most suitable center for creating a new ring configuration that - accommodates the fused nature of the system. Depending on the orientation of the shared - atoms, it then creates a new ring with the selected center, ensuring the integrity of - the molecular structure is maintained during the fusion process. - - Parameters: - :param ring RingProperties: - One of the rings involved in the fusion. - :param neighbour RingProperties: - The neighboring ring involved in the fusion. - :param atoms List[AtomProperties]: - A list of atoms shared between the fused rings, typically two atoms common to both - rings. - :param center Vector: - The center point around which the fusion is considered, influencing the orientation - of the newly formed ring structure. - """ - ring.fused = True - neighbour.fused = True - atom_1: 'AtomProperties' = atoms[0] - atom_2: 'AtomProperties' = atoms[1] - midpoint: 'Vector' = Vector.get_midpoint(atom_1.position, atom_2.position) - normals: List['Vector'] = Vector.get_normals(atom_1.position, atom_2.position) - normals[0].normalise() - normals[1].normalise() - - apothem: float = Polygon.get_apothem_from_side_length(self.bond_length, len(neighbour.members)) - normals[0].multiply_by_scalar(apothem) - normals[1].multiply_by_scalar(apothem) - normals[0].add(midpoint) - normals[1].add(midpoint) - next_center: 'Vector' = normals[0] - distance_to_center_1 = Vector.subtract_vectors(center, normals[0]).get_squared_length() - distance_to_center_2 = Vector.subtract_vectors(center, normals[1]).get_squared_length() - if distance_to_center_2 > distance_to_center_1: - next_center = normals[1] - position_1: 'Vector' = Vector.subtract_vectors(atom_1.position, next_center) - position_2: 'Vector' = Vector.subtract_vectors(atom_2.position, next_center) - if position_1.get_clockwise_orientation(position_2) == 'clockwise': - if not neighbour.positioned: - self.create_ring(neighbour, next_center, atom_1, atom_2) - else: - if not neighbour.positioned: - self.create_ring(neighbour, next_center, atom_2, atom_1) - - - - def handle_spiro_rings(self, ring: 'RingProperties', neighbour: 'RingProperties', - atom: 'AtomProperties', center: Vector) -> None: - """ - Handles spirocyclic systems within molecular structures, such as 'C1CCCC11CC1'. - - Spirocyclic systems are characterized by two rings that share a single common atom, - forming a spiro junction. This method marks both rings as spirocyclic, calculates a new - center for the neighboring ring based on the position of the shared atom and the center - of the current ring, and adjusts the position of the neighboring ring accordingly to - maintain the integrity of the spirocyclic structure. - - Parameters: - :param ring RingProperties: - The current ring involved in the spirocyclic system. - :param neighbour RingProperties: - The neighboring ring involved in the spirocyclic system. - :param atom AtomProperties: - The atom shared by both rings at the spiro junction. - :param center Vector: - The center of the current ring, used as a reference for calculating the new center - for the neighboring ring. - """ - ring.spiro = True - neighbour.spiro = True - next_center: 'Vector' = Vector.subtract_vectors(center, atom.position) - next_center.invert() - next_center.normalise() - distance_to_center: float = Polygon.find_polygon_radius(self.bond_length, len(neighbour.members)) - next_center.multiply_by_scalar(distance_to_center) - next_center.add(atom.position) - if not neighbour.positioned: - self.create_ring(neighbour, next_center, atom) - - - ##auxiliary functions for rings calculated - def set_ring_center(self, ring: 'RingProperties') -> None: - """ - Calculates and sets the geometric center of a ring within a molecular structure. - - This method computes the center of a ring by averaging the positions of all atoms that - are members of the ring. It iterates through each atom in the ring, summing their - positions vectorially, and then divides the total by the number of atoms to find the - average position, which represents the ring's center. This center is then assigned to - the ring's `center` attribute, providing a reference point for further calculations and - manipulations involving the ring. - - Parameters: - :param ring RingProperties: - The ring for which the center is to be calculated. This object represents a cyclic - structure within the molecule, containing a list of atoms that are part of the ring. - """ - total: Vector = Vector(0, 0) - for atom in ring.members: - total.add(atom.position) - total.divide(len(ring.members)) - ring.center = total - - - - def atoms_are_in_same_ring(self, atom_1: 'AtomProperties', atom_2: 'AtomProperties') -> bool: - """ - Determines if two atoms are part of the same ring within a molecular structure. - - This method checks if two given atoms are part of the same ring by comparing their ring - memberships. It iterates through the rings of the first atom and checks if any of these - rings are also present in the list of rings for the second atom. If any ring is common - between the two atoms, it indicates that they are part of the same ring structure. - - Parameters: - :param atom_1 AtomProperties: - The first atom to check for ring membership. - :param atom_2 AtomProperties: - The second atom to check for ring membership. - - Returns bool: - True if the atoms are in the same ring, False otherwise. - """ - return any(ring_id_1 == ring_id_2 for ring_id_1 in atom_1.rings \ - for ring_id_2 in atom_2.rings) - - - - def get_vertices(self, ring_overlaps: List['RingOverlap'], ring_id_1: int, \ - ring_id_2: int) -> Optional[List['AtomProperties']]: - """ - Searches for and returns atoms that are in the overlap between two rings identified by ring_id_1 and ring_id_2. - - This method looks for atoms that are located in the overlap between two specified rings. If an overlap is found, it returns a list of atoms that are part of this overlap. If no overlap is found, the method concludes without returning any value, implying a default return of None. - - Parameters: - :param ring_overlaps List[RingOverlap]: - A list of RingOverlap objects representing overlaps between rings in the molecule. - :param ring_id_1 int: - The identifier of the first ring to check for overlaps. - :param ring_id_2 int: - The identifier of the second ring to check for overlaps. - - Returns Optional[List[AtomProperties]: - A list of AtomProperties objects representing atoms in the overlap between the two - rings, or None if no overlap is found. - """ - for ring_overlap in ring_overlaps: - if set((ring_overlap.ring_id_1, ring_overlap.ring_id_2)) == set((ring_id_1, ring_id_2)): - return [atom for atom in ring_overlap.atoms] - - - - def get_ordered_neighbours(self, ring: 'RingProperties', \ - ring_overlaps: List['RingOverlap']) -> List[int]: - """ - Retrieves an ordered list of neighboring rings based on the number of atoms they share in common with the specified ring. - - This method is designed to obtain an ordered list of neighboring rings (or structures) - based on the number of atoms they have in intersection with the given ring. It returns a - list of identifiers for the neighboring rings, sorted by the number of shared atoms in - descending order, allowing for the identification of the most closely related rings in - terms of atomic overlap. - - Parameters: - :param ring RingProperties: - The ring for which neighboring rings are to be identified and ordered. - :param ring_overlaps List[RingOverlap]: - A list of RingOverlap objects representing overlaps between rings in the molecule, - used to determine the intersection of atoms between rings. - - Returns List[int]: - A list of identifiers for neighboring rings, ordered by the number of shared atoms - in descending order. Rings with more shared atoms are listed first. - """ - ordered_neighbours_and_atom_nrs = [] - for neighbour_id in ring.neighbouring_rings: - atoms: Optional[List['AtomProperties']] = self.get_vertices(ring_overlaps, ring.id, neighbour_id) - ordered_neighbours_and_atom_nrs.append((len(atoms), neighbour_id)) - - ordered_neighbours_and_atom_nrs = sorted(ordered_neighbours_and_atom_nrs, key=lambda x: x[0], reverse=True) - ordered_neighbour_ids = [x[1] for x in ordered_neighbours_and_atom_nrs] - return ordered_neighbour_ids - - - - def set_member_positions(self, ring: 'RingProperties', start_atom: 'AtomProperties', \ - previous_atom: Optional['AtomProperties'], center: 'Vector', \ - starting_angle: float, radius: float, angle: float) -> None: - """ - Positions atoms within a ring structure using polar coordinates (center, radius, angle) - and incrementally increases the angle between atoms. - - The primary goal of this method is to arrange atoms in a ring structure by utilizing - polar coordinates, where each atom's position is determined relative to a central point, - at a specified radius, and with a progressively increasing angle to ensure even - distribution around the center. This method iterates through the atoms of the ring, - setting their positions based on these polar coordinates until all atoms are positioned - or a maximum iteration limit is reached. - - Parameters: - :param ring RingProperties: - The ring whose member atoms are to be positioned. - :param start_atom AtomProperties: - The starting atom for positioning within the ring. - :param previous_atom Optional[AtomProperties]: - The atom preceding the current atom in the positioning sequence. Used as a reference - for the first atom's position if it hasn't been positioned yet. - :param center Vector: - The central point around which atoms are positioned. - :param starting_angle float: - The initial angle in radians for the first atom's position relative to the center. - :param radius float: - The distance from the center to the atom's position. - :param angle float: - The angular increment in radians between consecutive atoms in the ring. - """ - current_atom = start_atom - iteration = 0 - while current_atom != None and iteration < 100: - previous = current_atom - if not previous.positioned: - x = center.x + math.cos(starting_angle) * radius - y = center.y + math.sin(starting_angle) * radius - previous.set_position(Vector(x, y)) - starting_angle += angle - - if len(ring.subrings) < 3: - previous.angle = starting_angle - previous.positioned = True - - current_atom = self.get_next_in_ring(ring, current_atom, previous_atom) - previous_atom = previous - - if current_atom == start_atom: - current_atom = None - iteration += 1 - - - - def get_next_in_ring(self, ring: 'RingProperties', current_atom: 'AtomProperties', \ - previous_atom: 'AtomProperties') -> Optional['AtomProperties']: - """ - Searches for the next atom in the ring, excluding the previous atom. - - This method iterates through the neighbors of the current atom to find the next atom - that is a member of the specified ring, excluding the atom that was previously - considered. It checks each neighbor to see if it belongs to the ring and is not the same - as the previous atom. If a suitable atom is found, it is returned; otherwise, None is - returned. This is useful for traversing the atoms in a ring structure while ensuring - that the traversal does not immediately return to the previous atom, allowing for a - continuous loop through the ring's members. - - Parameters: - :param ring RingProperties: - The ring within which to search for the next atom. - :param current_atom AtomProperties: - The current atom from which to start the search. - :param previous_atom AtomProperties: - The atom to exclude from the search, typically the atom preceding the current atom - in the traversal. - - Returns Optional[AtomProperties]: - The next atom in the ring that is different from the previous atom, or None if no - such atom is found. - """ - neighbours: List[int] = current_atom.neighbours - for neighbour in neighbours: - for member in ring.members: - if neighbour == member: - if previous_atom != neighbour: - return neighbour - - - @staticmethod - def find_neighbouring_rings(ring_overlaps: List['RingOverlap'], ring_id: int) -> List[int]: - """ - Finds and returns a list of identifiers for rings neighboring the specified ring. - - This method searches through a list of ring overlaps to identify rings that are adjacent - to a given ring, identified by its ID. It examines each overlap to determine if the - specified ring is involved and collects the identifiers of neighboring rings, excluding - the specified ring itself. This is useful for understanding the connectivity and - structure of rings within a molecular graph, especially in complex molecules where rings - may overlap or share atoms. - - Parameters: - :param ring_overlaps List[RingOverlap]: - A list of RingOverlap objects, each representing an overlap between two rings in the - molecule. These objects contain information about which rings are involved in each - overlap. - :param ring_id int: - The identifier of the ring for which neighboring rings are to be found. - - Returns List[int]: - A list of identifiers for rings that are neighbors to the specified ring, based on - the overlaps. Each identifier represents a ring that shares at least one atom with - the specified ring, indicating a direct connection or overlap. - """ - neighbouring_rings = [] - for ring_overlap in ring_overlaps: - if ring_overlap.ring_id_1 == ring_id: - neighbouring_rings.append(ring_overlap.ring_id_2) - elif ring_overlap.ring_id_2 == ring_id: - neighbouring_rings.append(ring_overlap.ring_id_1) - return neighbouring_rings - - - - def get_current_centre_of_mass(self) -> Vector: - """ - Calculates and returns the current center of mass of the molecular graph. - - This method computes the center of mass of the molecular graph by summing the positions - of all positioned atoms and dividing by the number of positioned atoms. It iterates - through each atom in the graph, adding the position of each atom to a total if the atom - is positioned, and then divides this total by the count of positioned atoms to find the - average position, which represents the center of mass. This center of mass can be used - as a reference point for various calculations and manipulations within the molecular - structure, such as aligning or repositioning atoms relative to the overall structure. - - Returns Vector: - A Vector object representing the coordinates of the center of mass of the molecular - graph, based on the positions of all positioned atoms. - """ - total = Vector(0, 0) - count = 0 - for atom in self.graph: - if atom.positioned: - total.add(atom.position) - count += 1 - total.divide(count) - return total - - - - def get_last_atom_with_angle(self, atom: 'AtomProperties') -> Optional['AtomProperties']: - """ - Retrieves the last atom in a chain that has a defined angle relative to the initial atom. - - This method traverses backwards from a given atom through its predecessors until it - finds an atom with a defined angle or reaches an atom without a predecessor. It starts - with the initial atom's immediate predecessor and continues to trace back through the - chain of previous atoms until it encounters an atom with a non-zero angle or reaches the - beginning of the chain. - - Parameters: - :param atom AtomProperties: - The starting atom from which to begin the search for an atom with a defined angle. - - Returns Optional[AtomProperties]: - The last atom in the chain that has a defined angle, or None if no such atom is - found before reaching the start of the chain. - """ - parent_atom: Optional['AtomProperties'] = atom.previous_atom - angle: float = parent_atom.angle - while parent_atom and not angle: - parent_atom = parent_atom.previous_atom - angle = parent_atom.angle - return parent_atom - - - # упаковка и возвращение координат - def get_coord(self, order: List[int]) -> List[List[float]]: - """ - Packs and returns the coordinates of atoms in a two-dimensional array based on the - specified order. - - Parameters: - :param order List[int]: - A list of integers representing the order in which atom coordinates should be - packed. Each integer corresponds to an atom index in the class's atom dictionary. - - Returns List[List[float]]: - A two-dimensional list where each inner list contains the x and y coordinates of an - atom, following the order specified in the input list. - """ - xy: List[List[float]] = [] - for ord in order: - vector = self.atoms[ord].position - xy.append([vector.x, vector.y]) - return xy - - - # обработка коллизий - def collision_handling(self) -> None: - """ - Handles the positioning of all atoms and resolves any overlaps within the molecular structure. - - This method is responsible for adjusting the positions of atoms to minimize overlaps and - ensure a visually coherent representation of the molecule. It begins by resolving - primary overlaps through a preliminary adjustment phase, followed by an iterative - process to refine the positions of atoms based on their bonds and connectivity. The - process involves calculating the total overlap score, identifying atoms that can be - rotated around their bonds, and adjusting their positions to reduce overlaps. It also - considers the complexity of the subgraphs connected to each atom, preferring to rotate - smaller subgraphs to minimize disruption. Special handling is given to atoms connected - by double bonds, which are less flexible. The method iteratively adjusts the positions - of atoms based on their overlap scores, sensitivity thresholds, and the ability to - rotate around bonds, aiming to find a configuration that minimizes the total overlap - score. Additionally, it accounts for atoms within rings and their specific constraints, - applying rotations to subtrees of atoms to resolve overlaps while maintaining the - integrity of the molecular structure. The process is repeated for a set number of - iterations to gradually improve the layout, with an option for fine-tuning overlaps if - enabled. - - The collision handling process includes: - - Resolving primary overlaps to ensure atoms do not occupy the same space. - - Calculating the total overlap score and identifying atoms that can be rotated to - reduce overlaps. - - Adjusting atom positions based on their connectivity and the presence of double bonds, - which restrict rotation. - - Considering the depth of subgraphs connected to each atom to decide which atom to - rotate in cases of overlap. - - Applying rotations to subtrees to resolve overlaps, with specific logic for atoms - connected by single or double bonds. - - Repeatedly recalculating overlap scores and adjusting positions until a satisfactory - layout is achieved or the maximum number of iterations is reached. - - Optionally, fine-tuning the positions for further refinement if the `finetune` flag is - set. - """ - self.resolve_primary_overlaps() - - self.total_overlap_score, sorted_overlap_scores, atom_to_scores = self.get_overlap_score() - for i in range(self.overlap_resolution_iterations): - for (atom1_index, atom2_index), bond in self.bonds.items(): - n: 'AtomProperties' = self.atoms[atom1_index] - m: 'AtomProperties' = self.atoms[atom2_index] - if self.can_rotate_around_bond(bond): - tree_depth_1: int = self.get_subgraph_size(n, {m}) - tree_depth_2: int = self.get_subgraph_size(m, {n}) - atom_1_rotatable: bool = True - atom_2_rotatable: bool = True - for neighbouring_bond in self.get_bonds_of_atom(n): - if neighbouring_bond.type == 'double': - atom_1_rotatable = False - for neighbouring_bond in self.get_bonds_of_atom(m): - if neighbouring_bond.type == 'double': - atom_2_rotatable = False - if not atom_1_rotatable and not atom_2_rotatable: - continue - elif atom_1_rotatable and not atom_2_rotatable: - atom_2: 'AtomProperties' = n - atom_1: 'AtomProperties' = m - elif atom_2_rotatable and not atom_1_rotatable: - atom_1: 'AtomProperties' = n - atom_2: 'AtomProperties' = m - else: - atom_1: 'AtomProperties' = m - atom_2: 'AtomProperties' = n - if tree_depth_1 > tree_depth_2: - atom_1: 'AtomProperties' = n - atom_2: 'AtomProperties' = m - subtree_overlap_score, _ = self.get_subtree_overlap_score(atom_2, atom_1, atom_to_scores) - if subtree_overlap_score > self.overlap_sensitivity: - - neighbours_2 = atom_2.neighbours[:] - neighbours_2.remove(atom_1) - - if len(neighbours_2) == 1: - neighbour = neighbours_2[0] - angle = neighbour.position.get_rotation_away_from_vector(atom_1.position, atom_2.position, math.radians(120)) - self.rotate_subtree(neighbour, atom_2, angle, atom_2.position) - new_overlap_score, _, _ = self.get_overlap_score() - if new_overlap_score > self.total_overlap_score: - self.rotate_subtree(neighbour, atom_2, -angle, atom_2.position) - else: - self.total_overlap_score = new_overlap_score - elif len(neighbours_2) == 2: - if atom_2.rings and atom_1.rings: - continue - neighbour_1: 'AtomProperties' = neighbours_2[0] - neighbour_2: 'AtomProperties' = neighbours_2[1] - if len(neighbour_1.rings) == 1 and len(neighbour_2.rings) == 1: - if neighbour_1.rings[0] != neighbour_2.rings[0]: - continue - elif neighbour_1.rings or neighbour_2.rings: - continue - else: - angle_1 = neighbour_1.position.get_rotation_away_from_vector(atom_1.position, atom_2.position, math.radians(120)) - angle_2 = neighbour_2.position.get_rotation_away_from_vector(atom_1.position, atom_2.position, math.radians(120)) - self.rotate_subtree(neighbour_1, atom_2, angle_1, atom_2.position) - self.rotate_subtree(neighbour_2, atom_2, angle_2, atom_2.position) - new_overlap_score, _, _ = self.get_overlap_score() - if new_overlap_score > self.total_overlap_score: - self.rotate_subtree(neighbour_1, atom_2, -angle_1, atom_2.position) - self.rotate_subtree(neighbour_2, atom_2, -angle_2, atom_2.position) - else: - self.total_overlap_score = new_overlap_score - self.total_overlap_score, sorted_overlap_scores, atom_to_scores = self.get_overlap_score() - for _ in range(self.overlap_resolution_iterations): - self._finetune_overlap_resolution() - self.total_overlap_score, sorted_overlap_scores, atom_to_scores = self.get_overlap_score() - for i in range(self.overlap_resolution_iterations): - self.resolve_secondary_overlaps(sorted_overlap_scores) - - - ## вспомогательные функции для collision_handling - def resolve_primary_overlaps(self) -> None: - """ - Resolves initial overlaps in the molecular structure, focusing on cases where a ring has - two outgoing edges. - - This method addresses the issue of overlaps that occur when a ring within the molecular - structure has two edges extending outwards, which can lead to collisions in the layout, - especially noticeable in representations of cyclohexane in a quarter-staggered - conformation. It identifies atoms that are part of such overlaps by examining each ring - and its members, and then resolves these overlaps by adjusting the positions of the - involved atoms. The resolution process involves calculating the angle of rotation needed - to minimize the overlap and applying this rotation to the subtrees connected to the - overlapping atoms. - """ - overlaps: List = [] - resolved_atoms: Dict[int, bool] = {atom.id: False for atom in self.graph} - - for ring in self.rings: - for atom in ring.members: - if resolved_atoms[atom.id]: - continue - resolved_atoms[atom.id] = True - non_ring_neighbours: List['AtomProperties'] = self.get_non_ring_neighbours(atom) - if len(non_ring_neighbours) > 1 or (len(non_ring_neighbours) == 1 and len(atom.rings) == 2): - overlaps.append({'common': atom, 'rings': atom.rings, 'vertices': non_ring_neighbours}) - for overlap in overlaps: - branches_to_adjust: List['AtomProperties'] = overlap['vertices'] - rings: List['RingProperties'] = overlap['rings'] - root: 'AtomProperties' = overlap['common'] - if len(branches_to_adjust) == 2: - atom_1, atom_2 = branches_to_adjust - angle = (2 * math.pi - rings[0].get_angle()) / 6.0 - self.rotate_subtree(atom_1, root, angle, root.position) - self.rotate_subtree(atom_2, root, -angle, root.position) - total, sorted_scores, atom_to_score = self.get_overlap_score() - subtree_overlap_atom_1_1, _ = self.get_subtree_overlap_score(atom_1, root, atom_to_score) - subtree_overlap_atom_2_1, _ = self.get_subtree_overlap_score(atom_2, root, atom_to_score) - total_score = subtree_overlap_atom_1_1 + subtree_overlap_atom_2_1 - self.rotate_subtree(atom_1, root, -2.0 * angle, root.position) - self.rotate_subtree(atom_2, root, 2.0 * angle, root.position) - total, sorted_scores, atom_to_score = self.get_overlap_score() - subtree_overlap_atom_1_2, _ = self.get_subtree_overlap_score(atom_1, root, atom_to_score) - subtree_overlap_atom_2_2, _ = self.get_subtree_overlap_score(atom_2, root, atom_to_score) - total_score_2 = subtree_overlap_atom_1_2 + subtree_overlap_atom_2_2 - if total_score_2 > total_score: - self.rotate_subtree(atom_1, root, 2.0 * angle, root.position) - self.rotate_subtree(atom_2, root, -2.0 * angle, root.position) - - ## вспомогательные функции для resolve_primary_overlaps - @staticmethod - def get_non_ring_neighbours(atom: 'AtomProperties') -> List['AtomProperties']: - """ - Identifies and returns a list of neighbours of the specified atom that are not part of - any ring it belongs to. - - This method examines the neighbours of a given atom and filters out those that are part - of the same rings as the atom, focusing on neighbours that are not involved in any ring - structure with the atom. It is useful for understanding the connectivity of an atom - within the molecular graph, especially in contexts where the atom's interactions outside - of cyclic structures are of interest. By comparing the ring memberships of the atom and - its neighbours, it identifies neighbours that do not share any rings with the atom and - are not considered bridge atoms, providing insight into the atom's connections to other - parts of the molecule that are not part of its immediate cyclic environment. - - Parameters: - :param atom AtomProperties: - The atom for which non-ring neighbours are to be identified. - - Returns: List[AtomProperties]: - A list of AtomProperties objects representing neighbours of the specified atom that - are not part of any ring the atom is a member of, excluding bridge atoms. - """ - non_ring_neighbours: List['AtomProperties'] = [] - for neighbour in atom.neighbours: - nr_overlapping_rings = len(set(atom.ring_indexes).intersection(set(neighbour.ring_indexes))) - if nr_overlapping_rings == 0 and not neighbour.is_bridge: - non_ring_neighbours.append(neighbour) - return non_ring_neighbours - - ## вспомогательные функции для resolve_primary_overlaps - def rotate_subtree(self, root: 'AtomProperties', root_parent: 'AtomProperties', \ - angle: float, center: 'Vector') -> None: - """ - Rotates a subtree of the molecular structure around a specified center by a given angle. - - This method rotates a subtree within the molecular graph, starting from a root atom, - around a specified center point by a given angle. It is used to adjust the positions of - atoms and their associated structures, such as anchored rings, to resolve overlaps or to - achieve a desired orientation. The rotation is applied to the root atom and all atoms - connected to it within the subtree, excluding the root's parent to maintain the - integrity of the molecular structure. This is particularly useful in the layout process - to minimize overlaps or to align parts of the molecule according to specific - requirements. The rotation affects not only the positions of atoms in the subtree but - also updates the centers of any anchored rings associated with the atoms, ensuring that - the entire subtree is cohesively repositioned. - - Parameters: - :param root AtomProperties: - The root atom of the subtree to be rotated. This atom serves as the starting point - for the rotation. - :param root_parent AtomProperties: - The parent atom of the root, which is excluded from the rotation to maintain the - connection to the rest of the molecular structure. - :param angle float: - The angle in radians by which the subtree will be rotated around the center. - :param center Vector: - The point around which the rotation is performed. This center is typically the - position of a pivotal atom or a calculated point that serves as the axis of rotation. - """ - for atom in self.traverse_substructure(root, {root_parent}): - atom.position.rotate_around_vector(angle, center) - for anchored_ring in atom.anchored_rings: - if anchored_ring.center: - anchored_ring.center.rotate_around_vector(angle, center) - - - ## вспомогательные функции для rotate_subtree - def traverse_substructure(self, atom: 'AtomProperties', visited: Set['AtomProperties']) \ - -> Generator['AtomProperties', None, None]: - """ - Traverses a substructure of the molecular graph starting from a given atom, yielding - atoms in a depth-first manner. - - This method performs a depth-first traversal of the molecular graph, starting from a - specified atom, and yields atoms that are reachable from it, excluding those already - visited to avoid cycles. It is a generator function that explores the molecular - structure by recursively visiting each atom's neighbours, ensuring that each atom is - visited only once. - - Parameters: - :param atom AtomProperties: - The starting atom for the traversal. The traversal begins from this atom and - explores the connected substructure. - :param visited Set[AtomProperties]: - A set of atoms that have already been visited during the traversal. This is used to - avoid revisiting atoms and to ensure that each atom is processed only once. - - Yields AtomProperties: - Atoms in the connected substructure of the starting atom, yielded one at a time. - This allows for iterative processing or analysis of the substructure without the - need to construct and return a complete list of atoms upfront, which can be - beneficial for large molecular graphs. - """ - yield atom - visited.add(atom) - for neighbour in atom.neighbours: - if neighbour not in visited: - yield from self.traverse_substructure(neighbour, visited) - - - ## вспомогательные функции для resolve_primary_overlaps - def get_overlap_score(self) -> Tuple[float, List[Tuple[float, 'AtomProperties']], Dict[int, float]]: - """ - Calculates the total overlap score and returns a sorted list of atoms by their overlap - scores along with the score dictionary. - - This method computes the total overlap score for the molecular graph represented by the - class and returns a tuple containing the total overlap score, a list of atoms sorted by - their overlap scores in descending order, and a dictionary mapping atom IDs to their - individual overlap scores. The overlap score is a measure of how much atoms overlap with - each other, indicating the compactness or congestion within the molecular structure. It - is calculated based on the distances between atoms, with closer atoms contributing more - significantly to the score. The method iterates through all pairs of atoms, calculates - the overlap score for each pair based on their distance, and aggregates these scores to - determine the total overlap and individual atom overlap scores. The atoms are then - sorted by their scores to identify those with the highest overlap, which can be critical - for resolving spatial conflicts in the molecular layout. - - Returns Tuple[float, List[Tuple[float, 'AtomProperties'], Dict[int, float]]: - A tuple containing: - - The total overlap score for the molecular graph, representing the overall - compactness or congestion. - - A list of tuples, each containing an atom's overlap score and the atom itself, - sorted by the score in descending order. This list helps identify atoms that are - most affected by overlaps and may require adjustment. - - A dictionary mapping atom IDs to their individual overlap scores, providing - detailed insights into the distribution of overlaps across the structure. - """ - total: float = 0.0 - overlap_scores : Dict[int, float]= {} - for atom in self.graph: - overlap_scores[atom.id] = 0.0 - - atoms: List['AtomProperties'] = list(self.graph.keys()) - for i, atom_1 in enumerate(atoms): - for j, atom_2 in enumerate(atoms[i+1:], start=i+1): - distance: float = Vector.subtract_vectors(atom_1.position, atom_2.position).get_squared_length() - if distance < (self.bond_length ** 2): - weight = (self.bond_length - math.sqrt(distance)) / self.bond_length - total += weight - overlap_scores[atom_1.id] += weight - overlap_scores[atom_2.id] += weight - sorted_overlaps: List[Tuple[float, 'AtomProperties']] = [] - for atom in atoms: - sorted_overlaps.append((overlap_scores[atom.id], atom)) - sorted_overlaps.sort(key=lambda x: x[0], reverse=True) - return total, sorted_overlaps, overlap_scores - - - ## вспомогательные функции для resolve_primary_overlaps - def get_subtree_overlap_score(self, root: 'AtomProperties', root_parent: 'AtomProperties', - atom_to_score: Dict[int, float]) -> Tuple[float, Vector]: - """ - Calculates the weighted center and total overlap score for a subtree rooted at a given - atom, excluding its parent. - - This method computes the total overlap score and the weighted center position for a - subtree within the molecular graph, starting from a specified root atom and excluding - its parent. The subtree's overlap score is a measure of how much the atoms within the - subtree overlap with others, indicating the compactness or congestion of the subtree's - layout. The weighted center is calculated based on the positions of atoms that - contribute significantly to the overlap, providing a central point that can be used for - adjusting the subtree's position to reduce overlaps. The method iterates through the - subtree, accumulating the overlap scores of atoms that exceed a sensitivity threshold - and adjusting their positions relative to this score to find a central point of gravity. - This central point, along with the total score, can guide the repositioning of the - subtree to minimize spatial conflicts within the molecular structure. - - Parameters: - :param root AtomProperties: - The root atom of the subtree for which the overlap score and weighted center are - calculated. This atom serves as the starting point for the traversal and score - calculation. - :param root_parent AtomProperties: - The parent atom of the root, which is excluded from the subtree to maintain the - integrity of the molecular graph's structure during calculations. - :param atom_to_score Dict[int, float]: - A dictionary mapping atom IDs to their individual overlap scores, used to determine - the contribution of each atom in the subtree to the total overlap score. - - Returns Tuple[float, Vector]: A tuple containing: - - The average overlap score for the subtree, calculated as the sum of individual - atom scores divided by the number of contributing atoms. This score indicates the - subtree's overall compactness or the extent of overlap among its atoms. - - The weighted center position (Vector) of the subtree, derived from the positions - of atoms with significant overlap scores. This center is calculated by summing the - positions of contributing atoms, each weighted by its overlap score, and then - dividing by the total score to find a central point for potential repositioning. - """ - score = 0.0 - center = Vector(0, 0) - count = 0 - for atom in self.traverse_substructure(root, {root_parent}): - subscore = atom_to_score[atom.id] - if subscore > self.overlap_sensitivity: - score += subscore - count += 1 - position = atom.position.copy() - position.multiply_by_scalar(subscore) - center.add(position) - if score: - center.divide(score) - if count == 0: - count = 1 - return score / count, center - - - @staticmethod - def can_rotate_around_bond(bond: 'BondProperties') -> bool: - """ - Determines whether a bond can be rotated to adjust the molecular structure without - breaking its integrity. - - This method evaluates whether a given bond within a molecular structure can be rotated - as part of layout adjustments, such as resolving overlaps or optimizing the spatial - arrangement of atoms. Rotation around a bond is a common operation in molecular graph - manipulation, but it's constrained by several factors to maintain the molecule's - structural integrity. The method checks the type of the bond, the number of neighbours - each atom involved in the bond has, and their involvement in ring structures. - Specifically, it assesses whether the bond is a single bond (allowing for rotation), - whether either atom has only one neighbour (which would prevent meaningful rotation), - and whether both atoms are part of the same ring (which could disrupt the ring's - geometry if rotated). - - Parameters: - :param bond BondProperties: - The bond to evaluate for potential rotation. This object contains information about - the bond type and the atoms it connects. - - Returns bool: - True if the bond can be safely rotated, indicating that it is a single bond not - involving atoms that are solely connected through this bond or are part of the same - ring, thereby allowing for adjustments that maintain the molecule's integrity. False - otherwise, indicating constraints that prevent rotation to avoid disrupting the - molecular structure. - """ - if bond.type != 'single': - return False - if len(bond.atom1.neighbours) == 1 or len(bond.atom2.neighbours) == 1: - return False - if bond.atom1.rings and bond.atom2.rings and len(set(bond.atom1.rings).intersection(set(bond.atom2.rings))) > 0: - return False - return True - - - def _finetune_overlap_resolution(self) -> None: - """ - Fine-tunes the resolution of overlaps between atoms in the molecular structure by - iteratively adjusting the positions of atoms to minimize the total overlap score. - - This method is designed to refine the positioning of atoms within the molecular - structure to reduce overlaps, focusing on atoms that are too close to each other. It - operates by identifying pairs of clashing atoms, determining the shortest path between - them, and then attempting to rotate the atoms around their bonds to find a configuration - that minimizes the overlap. The process involves several steps: - - 1. Identifies pairs of atoms that are clashing, i.e., too close to each other based on a - predefined sensitivity threshold. - 2. For each pair of clashing atoms, it finds the shortest path connecting them in the - molecular graph. - 3. Along this path, it identifies bonds that are rotatable (excluding double bonds) and - calculates a distance metric for each bond based on its position in the path. - 4. Selects the bond with the smallest distance metric as the best candidate for rotation - to reduce overlap. - 5. Rotates the subtree of atoms around the best bond in increments, evaluating the - overlap score after each rotation to find the optimal rotation angle. - 6. Applies the optimal rotation to minimize the total overlap score. - - The method iteratively adjusts the positions of atoms connected by rotatable bonds to - resolve overlaps, with a preference for rotating smaller subtrees to minimize structural - disruption. It uses a scoring system to evaluate the effectiveness of each rotation and - selects the rotation that results in the lowest overlap score. This process is repeated - for all identified clashing atom pairs until the total overlap score is below a - sensitivity threshold or no further improvement can be made. - """ - if self.total_overlap_score > self.overlap_sensitivity: - clashing_atoms: List[Tuple['AtomProperties', 'AtomProperties']] = self._find_clashing_atoms() - best_bonds: List['BondProperties'] = [] - for atom_1, atom_2 in clashing_atoms: - if self.is_connected(atom_1, atom_2): - shortest_path: List[Union['BondProperties', 'AtomProperties']] = self.find_shortest_path(atom_1, atom_2) - rotatable_bonds: List['BondProperties'] = [] - distances: List[float] = [] - for i, bond in enumerate(shortest_path): - distance_1: int = i - distance_2: int = len(shortest_path) - i - average_distance = len(shortest_path) / 2 - distance_metric = abs(average_distance - distance_1) + abs(average_distance - distance_2) - if self.bond_is_rotatable(bond): # я не дореализовал #fix it - rotatable_bonds.append(bond) - distances.append(distance_metric) - best_bond: Optional['BondProperties'] = None - optimal_distance: float = float('inf') - for i, distance in enumerate(distances): - if distance < optimal_distance: - best_bond: 'BondProperties' = rotatable_bonds[i] - optimal_distance: float = distance - if best_bond is not None: - best_bonds.append(best_bond) - best_bonds = list(set(best_bonds)) - for best_bond in best_bonds: - if self.total_overlap_score > self.overlap_sensitivity: - atom_1, atom_2 = best_bond.atom1, best_bond.atom2 - subtree_size_1: int = self.get_subgraph_size(atom_1, {atom_2}) - subtree_size_2: int = self.get_subgraph_size(atom_2, {atom_1}) - if subtree_size_1 < subtree_size_2: - rotating_atom = atom_1 - parent_atom = atom_2 - else: - rotating_atom = atom_2 - parent_atom = atom_1 - overlap_score, _, _ = self.get_overlap_score() - scores: List[float] = [overlap_score] - # Attempt 12 rotations - for i in range(12): - self.rotate_subtree(rotating_atom, parent_atom, math.radians(30), parent_atom.position) - new_overlap_score, _, _ = self.get_overlap_score() - scores.append(new_overlap_score) - assert len(scores) == 13 - scores = scores[:12] - best_i = 0 - best_score = scores[0] - for i, score in enumerate(scores): - if score < best_score: - best_score = score - best_i = i - self.total_overlap_score = best_score - self.rotate_subtree(rotating_atom, parent_atom, math.radians(30 * best_i + 1), parent_atom.position) - - - - def _find_clashing_atoms(self) -> List[Tuple['AtomProperties', 'AtomProperties']]: - """ - Identifies and returns a list of atom pairs that are clashing, i.e., positioned too - close to each other based on a distance threshold. - - This method scans through all pairs of atoms in the molecular graph to find those that - are closer than a specified distance threshold, indicating a clash or overlap. It - calculates the squared distance between each pair of atoms and compares it against a - threshold value to determine if they are clashing. The threshold is defined as 80% of - the squared bond length, aiming to identify atoms that are significantly closer than - they should be, considering the typical bond length in the molecular structure. - - Returns List[Tuple['AtomProperties', 'AtomProperties']: - A list of tuples, where each tuple contains two 'AtomProperties' objects - representing a pair of atoms that are clashing. Each tuple indicates a pair of atoms - that are positioned too close to each other, based on the distance threshold. - """ - clashing_atoms: List[Tuple['AtomProperties', 'AtomProperties']] = [] - atoms: List['AtomProperties'] = list(self.graph.keys()) - for i, atom_1 in enumerate(atoms): - for j, atom_2 in enumerate(atoms[i+1:], start=i+1): - if self.bond_lookup(atom_1, atom_2) is None: - distance = Vector.subtract_vectors(atom_1.position, atom_2.position).get_squared_length() - if distance < 0.8 * (self.bond_length**2): - clashing_atoms.append((atom_1, atom_2)) - return clashing_atoms - - - - def is_connected(self, atom_1, atom_2) -> bool: - """ - Determines if two atoms are connected within the molecular graph, i.e., part of the same - molecular structure. - - Parameters: - atom_1 AtomProperties: The first atom to check for connectivity. - atom_2 AtomProperties: The second atom to check for connectivity. - - Returns bool: - True if both atoms are present in the molecular graph, indicating they are part of - the same molecular structure, and False otherwise. - """ - return atom_1 in self.graph and atom_2 in self.graph - - - - def bond_is_rotatable(self, bond: 'BondProperties') -> bool: - """ - Determines if a bond can be rotated in the molecular structure drawing, based on its - type and the atoms it connects. - - This method evaluates whether a given bond is rotatable, which is crucial for adjusting the molecular layout to resolve overlaps or achieve a more accurate representation. A bond is considered rotatable if it is not constrained by stereochemical considerations, such as being part of a ring or having a specific type that restricts rotation (e.g., double or triple bonds). The method checks if the bond connects atoms that are part of the same ring, which would prevent rotation, and if the bond type is not a single bond, it further checks the number of neighbors each atom has to determine if rotation is possible. Additionally, it considers chiral centers and specific stereochemical markers that might restrict rotation. The presence of these conditions indicates that the bond is not rotatable, and the method returns False. Otherwise, it returns True, indicating the bond can be rotated to adjust the molecular structure. - - Parameters - :param bond BondProperties: The bond to evaluate for rotatability. - - Returns bool: - True if the bond is rotatable, meaning it can be rotated in the drawing to adjust the molecular structure without violating stereochemical constraints; False if the bond is fixed in place due to being part of a ring, being a non-single bond, or involving chiral centers. - """ - atom_1, atom_2 = bond.atom1, bond.atom2 - if atom_1.rings and atom_2.rings and len(set(atom_1.rings).intersection(set(atom_2.rings))) > 0: - return False - if bond.type != 'single': - if len(atom_1.neighbours) > 1 and len(atom_2.neighbours) > 1: - return False - chiral = False - self.get_bonds_of_atom(atom_1) - # for bond_1 in self.get_bonds_of_atom(atom_1): - # if self.chiral[bond_1]: - # chiral = True - # break - # for bond_2 in self.get_bonds_of_atom(atom_2): - # if self.chiral[bond_2]: - # chiral = True - # break - if chiral: - return False - # if self.chiral_symbol[bond]: - # return False - return True - - - - def find_shortest_path(self, atom_1: 'AtomProperties', atom_2: 'AtomProperties', \ - path_type: str = 'bond') -> List[Union['BondProperties', 'AtomProperties']]: - """ - Finds the shortest path between two atoms in the molecular graph, returning either the - sequence of bonds or atoms along the path. - - This method implements a shortest path algorithm to determine the most direct route - between two specified atoms within the molecular structure. It can return the path as a - list of either the bonds connecting the atoms or the atoms themselves, depending on the - `path_type` parameter. The algorithm initializes by setting the distance to all atoms as - infinite, except for the starting atom, which is set to zero. It then iteratively - selects the atom with the smallest distance that has not been visited, updates the - distances to its neighbors, and marks it as visited. This process continues until the - destination atom is reached or all atoms have been visited. Finally, it constructs the - path from the destination atom back to the starting atom using the recorded previous - hops. - - Parameters - :param atom_1 AtomProperties: - The starting atom from which to find the shortest path. - :param atom_2 AtomProperties: - The destination atom to which to find the shortest path. - :param path_type str: - Specifies the type of elements to return in the path. 'bond' returns the bonds along the path, 'atom' returns the atoms. Default is 'bond'. - - Returns List[Union['BondProperties', 'AtomProperties']: - A list representing the shortest path between `atom_1` and `atom_2`. If `path_type` - is 'bond', the list contains `BondProperties` objects; if 'atom', it contains - `AtomProperties` objects. - - Raises ValueError: - If `path_type` is neither 'bond' nor 'atom'. - """ - distances: Dict['AtomProperties', float] = {} - previous_hop: Dict['AtomProperties', Optional['AtomProperties']] = {} - unvisited: Set['AtomProperties'] = set() - for atom in self.graph: - distances[atom] = float('inf') - previous_hop[atom] = None - unvisited.add(atom) - distances[atom_1] = 0.0 - while unvisited: - current_atom: Optional['AtomProperties'] = None - minimum: float = float('inf') - # Find the atom with the smallest distance value that has not yet been visited - for atom in unvisited: - dist: float = distances[atom] - if dist < minimum: - current_atom: 'AtomProperties' = atom - minimum = dist - if current_atom is None: - break - if current_atom == atom_2: - break - unvisited.remove(current_atom) - # If there exists a shorter path between the source atom and the neighbours, update distance - for neighbour in self.graph[current_atom]: - if neighbour in unvisited: - alternative_distance: float = distances[current_atom] + 1.0 - - if alternative_distance < distances[neighbour]: - distances[neighbour] = alternative_distance - previous_hop[neighbour] = current_atom - # Construct the path of atoms - path_atoms: List['AtomProperties'] = [] - current_atom: Optional['AtomProperties'] = atom_2 - if previous_hop[current_atom] or current_atom == atom_1: - while current_atom: - path_atoms.insert(0, current_atom) - current_atom = previous_hop[current_atom] - if path_type == 'bond': - path: List[Union['BondProperties', 'AtomProperties']] = [] - for i in range(1, len(path_atoms)): - atom_1 = path_atoms[i - 1] - atom_2 = path_atoms[i] - bond = self.bond_lookup(atom_1, atom_2) - path.append(bond) - return path - elif path_type == 'atom': - return path_atoms - else: - raise ValueError("Path type must be 'bond' or 'atom'.") - - - - def resolve_secondary_overlaps(self, sorted_scores: List[Tuple[float, 'AtomProperties']]) -> None: - """ - Resolves secondary overlaps in the molecular structure by adjusting the positions of - atoms based on their overlap scores. - - This method addresses secondary overlaps in the molecular layout by iteratively - adjusting the positions of atoms that have been identified as overlapping beyond a - specified sensitivity threshold. It processes a list of atoms sorted by their overlap - scores, focusing on atoms with scores higher than a predefined sensitivity level. For - each atom, it determines the appropriate action based on the atom's connectivity and - proximity to other atoms to minimize overlaps. The process involves finding the closest - atom if the target atom has only one neighbor or is isolated, calculating a new position - that reduces overlap, and then rotating the atom to this new position. The rotation is - designed to move the atom away from its closest neighbor or a specified position to - alleviate the overlap. - - Parameters - :param sorted_scores List[Tuple[float, AtomProperties]]: - A list of tuples, where each tuple contains an overlap score and an `AtomProperties` - object. The list is sorted by score, with the highest scores (indicating more - significant overlaps) first. - - The steps involved are as follows: - 1. Iterate through atoms sorted by their overlap scores, focusing on those with scores - exceeding the sensitivity threshold. - 2. For atoms with one or no neighbors, find the closest atom in the structure to - determine a direction for rotation. - 3. Calculate a new position for the atom based on the closest atom's position or a - specified reference point, considering the previous positions of both the atom and its - closest neighbor. - 4. Rotate the atom to the new position to reduce overlap, using a predefined angle to - ensure minimal disruption to the molecular structure. - """ - for score, atom in sorted_scores: - if score > self.overlap_sensitivity: - if len(atom.neighbours) <= 1: - if atom.neighbours: - continue - closest_atom: 'AtomProperties' = self.get_closest_atom(atom) - neighbours = closest_atom.neighbours - if len(neighbours) <= 1: - if not closest_atom.previous_position: - closest_position: float = atom.neighbours[0].position - else: - closest_position: float = closest_atom.previous_position - else: - if not closest_atom.previous_position: - closest_position: float = atom.neighbours[0].position - else: - closest_position: float = closest_atom.position - if not atom.previous_position: - atom_previous_position: float = atom.neighbours[0].position - else: - atom_previous_position: float = atom.previous_position - atom.position.rotate_away_from_vector(closest_position, \ - atom_previous_position, math.radians(20)) - - - - def get_closest_atom(self, atom: 'AtomProperties') -> 'AtomProperties': - """ - Identifies and returns the atom closest to a given atom within the molecular graph. - - This method calculates the distance between a specified atom and all other atoms in the - molecular graph to find the closest one. It iterates through each atom in the graph, - comparing their distances to the given atom and updates the closest atom found so far - based on the smallest squared distance. The squared distance is used for efficiency, - avoiding the computational cost of square root calculations. The method is useful for - determining the spatial relationship between atoms, which can be crucial for layout - adjustments, overlap resolution, or identifying nearby atoms for various analyses. - - Parameters - :param atom AtomProperties: - The reference atom from which to find the closest atom in the molecular graph. - - Returns AtomProperties: - The atom determined to be closest to the specified atom based on the shortest - distance. If no closer atom is found (e.g., if the graph only contains the specified - atom), the method returns None. - """ - minimal_distance = float('inf') - closest_atom: Optional['AtomProperties'] = None - for atom_2 in self.graph: - if atom == atom_2: - continue - squared_distance: float = atom.position.get_squared_distance(atom_2.position) - if squared_distance < minimal_distance: - minimal_distance: float = squared_distance - closest_atom: 'AtomProperties' = atom_2 - return closest_atom - -__all__ = ['Calculate2d'] \ No newline at end of file diff --git a/chython/algorithms/calculate2d/KKLayout.py b/chython/algorithms/calculate2d/KKLayout.py deleted file mode 100644 index a6f8b8e7..00000000 --- a/chython/algorithms/calculate2d/KKLayout.py +++ /dev/null @@ -1,430 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright 2024 Denis Lipatov -# Copyright 2024 Vyacheslav Grigorev -# Copyright 2024 Timur Gimadiev -# This file is part of chython. -# -# chython is free software; you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation; either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with this program; if not, see . -# -""" -Defines the KKLayout class, utilized for arranging molecular structures using the Kamada-Kawai -algorithm. - -Description: -The KKLayout class is designed to optimize the layout of molecular structures through the -application of the Kamada-Kawai algorithm, a graph drawing method that models atoms as physical -bodies connected by springs, aiming to find a low-energy configuration. This approach allows for -the visualization of molecular structures in a two-dimensional plane, where atoms are positioned -according to calculated coordinates to reflect their interconnections and minimize the system's -total energy. The class is initialized with essential details about the molecular structure, -enabling the creation of matrices for storing atomic distances, bond stiffness, and interaction -energies. These matrices support the iterative process of finding an optimal layout that -minimizes energy, thereby enhancing the stability and clarity of the molecular representation. - -Outcome: -Achieves a stable molecular layout where atomic positions are optimized for minimal energy, -enhancing the interpretability of complex molecular structures. This optimized arrangement not -only reflects the underlying chemistry but also simplifies the visual analysis of molecular -architecture, making it invaluable for scientific and educational purposes. -""" -from typing import Dict, List, TYPE_CHECKING, Tuple -from .MathHelper import Vector, Polygon -import math -if TYPE_CHECKING: - from .Calculate2d import Calculate2d - from .Properties import * - -class KKLayout: - """ - Class for calculating the optimal arrangement of atoms in a molecular structure - using the Kamada-Kawai algorithm. - """ - def __init__(self, structure: 'Calculate2d', atoms: List['AtomProperties'], \ - center: 'Vector', start_atom: 'AtomProperties', bond_length: float, \ - threshold: float=0.1, inner_threshold: float=0.1, max_iteration: int=2000, - max_inner_iteration: int=50, max_energy: int=1e9): - """ - Initializes the KKLayout object with the necessary parameters to calculate the optimal - arrangement of atoms in a molecular structure using the Kamada-Kawai algorithm. - - Parameters: - :param structure Calculate2d: - An instance of the Calculate2d class used for performing 2D space calculations. - :param atoms List[AtomProperties]: - A list containing instances of the AtomProperties class representing the atoms - in the molecular structure. - :param center Vector: - An instance of the Vector class specifying the geometric center of the molecular - structure. - :param start_atom AtomProperties: - An instance of the AtomProperties class designating the starting atom for - constructing the layout. - :param bond_length float: - The specified length of bonds between atoms in the molecular structure. - :param threshold Optional[float]: - The energy threshold value used to determine when to stop iterations during the - calculation. Defaults to 0.1. - :param inner_threshold Optional[float]: - The inner energy threshold value used to determine when to stop internal iterations - during the calculation. Defaults to 0.1. - :param max_iteration Optional[int]: - The maximum number of iterations allowed for the calculation process. Defaults to - 2000. - :param max_inner_iteration Optional[int]: - The maximum number of inner iterations allowed for the calculation process. - Defaults to 50. - :param max_energy Optional[int]: - The maximum allowable energy level for the system being calculated. Defaults to 1e9. - - Attributes: - self.structure: Stores the Calculate2d instance passed as a parameter. - self.atoms: Stores the list of AtomProperties instances representing the atoms in the - structure. - self.center: Stores the Vector instance representing the center of the molecular - structure. - self.start_atom: Stores the AtomProperties instance representing the starting atom for - the layout construction. - self.edge_strength: Stores the bond length between atoms. - self.threshold: Stores the energy threshold value for stopping iterations. - self.inner_threshold: Stores the inner energy threshold value for internal iterations. - self.max_iteration: Stores the maximum number of iterations allowed. - self.max_inner_iteration: Stores the maximum number of inner iterations allowed. - self.max_energy: Stores the maximum allowable energy level for the system. - - Additional Attributes: - self.x_positions, self.y_positions: Dictionaries storing the X and Y coordinates of each - atom in the structure. - self.positioned: A dictionary indicating whether each atom has been positioned. - self.length_matrix: A matrix storing the lengths of bonds between pairs of atoms. - self.distance_matrix: A matrix storing the distances between pairs of atoms. - self.spring_strengths: A matrix storing the stiffness values of links between pairs of - atoms. - self.energy_matrix: A matrix storing the interaction energy between pairs of atoms. - self.energy_sums_x, self.energy_sums_y: Dictionaries storing the summations of energies - along the X and Y axes for each atom. - """ - self.structure: 'Calculate2d' = structure - self.atoms: List['AtomProperties'] = atoms - self.center: 'Vector' = center - self.start_atom: 'AtomProperties' = start_atom - self.edge_strength: int = bond_length - self.threshold: float = threshold - self.inner_threshold: float = inner_threshold - self.max_iteration: int = max_iteration - self.max_inner_iteration: int = max_inner_iteration - self.max_energy: int = max_energy - - self.x_positions: Dict['AtomProperties', float] = {} - self.y_positions: Dict['AtomProperties', float] = {} - self.positioned: Dict['AtomProperties', bool] = {} - self.length_matrix: Dict['AtomProperties', Dict['AtomProperties', float]] = {} - self.distance_matrix: Dict[int, Dict[int, float]] = {} - self.spring_strengths: Dict['AtomProperties', Dict['AtomProperties', float]] = {} - self.energy_matrix: Dict['AtomProperties', Dict['AtomProperties', Optional[float]]] = {} - self.energy_sums_x: Dict['AtomProperties', Dict['AtomProperties', Optional[float]]] = {} - self.energy_sums_y: Dict['AtomProperties', Dict['AtomProperties', Optional[float]]] = {} - - self.initialise_matrices() - self.get_kk_layout() - - - def initialise_matrices(self) -> None: - """ - Initializes various matrices required for calculating the layout of a molecular - structure. - - This method computes the initial positions of atoms based on the center of the molecule - and bond length. It creates matrices to store bond lengths, link stiffnesses, - interaction energies, - and sums of energy along the X and Y axes. The initialization process involves - calculating the distance matrix, determining initial atom positions, - and preparing matrices for further calculations in the Kamada-Kawai algorithm. - - Steps involved: - 1. Compute the distance matrix to understand the connectivity and distances between - atoms. - 2. Determine initial positions for atoms based on a circular layout around the - molecule's center, considering unpositioned atoms. - 3. Initialize matrices for bond lengths, spring strengths, and interaction energies - between atoms. - 4. Calculate the initial energy matrix based on atom positions and bond lengths. - """ - self.distance_matrix = self.get_subgraph_distance_matrix(self.atoms) - length = len(self.atoms) - radius = Polygon.find_polygon_radius(500, length) - angle = Polygon.get_central_angle(length) - a: float = 0.0 - for atom in self.atoms: - if not atom.positioned: - self.x_positions[atom] = self.center.x + math.cos(a) * radius - self.y_positions[atom] = self.center.y + math.sin(a) * radius - else: - self.x_positions[atom] = atom.position.x - self.y_positions[atom] = atom.position.y - self.positioned[atom] = atom.positioned - a += angle - for atom_1 in self.atoms: - self.length_matrix[atom_1] = {} - self.spring_strengths[atom_1] = {} - self.energy_matrix[atom_1] = {} - self.energy_sums_x[atom_1] = None - self.energy_sums_y[atom_1] = None - for atom_2 in self.atoms: - self.length_matrix[atom_1][atom_2] = self.edge_strength * self.distance_matrix[atom_1][atom_2] - self.spring_strengths[atom_1][atom_2] = self.edge_strength * self.distance_matrix[atom_1][atom_2] ** -2.0 - self.energy_matrix[atom_1][atom_2] = None - for atom_1 in self.atoms: - ux = self.x_positions[atom_1] - uy = self.y_positions[atom_1] - d_ex = 0.0 - d_ey = 0.0 - for atom_2 in self.atoms: - if atom_1 == atom_2: - continue - vx = self.x_positions[atom_2] - vy = self.y_positions[atom_2] - denom = 1.0 / math.sqrt((ux - vx) ** 2 + (uy - vy) ** 2) - self.energy_matrix[atom_1][atom_2] = (self.spring_strengths[atom_1][atom_2] * ((ux - vx) - self.length_matrix[atom_1][atom_2] * (ux - vx) * denom), - self.spring_strengths[atom_1][atom_2] * ((uy - vy) - self.length_matrix[atom_1][atom_2] * (uy - vy) * denom)) - self.energy_matrix[atom_2][atom_1] = self.energy_matrix[atom_1][atom_2] - d_ex += self.energy_matrix[atom_1][atom_2][0] - d_ey += self.energy_matrix[atom_1][atom_2][1] - self.energy_sums_x[atom_1] = d_ex - self.energy_sums_y[atom_1] = d_ey - - - def get_kk_layout(self) -> None: - """ - Initiates the iterative process to find the optimal arrangement of atoms in a molecular - structure using the Kamada-Kawai algorithm. - - This method performs iterations until the system's energy falls below a threshold value - or the maximum number of iterations is reached. At each iteration, - the `update()` method is called to move atoms with the highest energy, aiming to - minimize the overall system energy through gradual adjustments. - - Description: - The Kamada-Kawai algorithm is employed to iteratively refine the positions of atoms - within a molecular structure, seeking a configuration that minimizes the system's - energy. This method orchestrates the iterative process, - adjusting atom positions based on their energy states until a satisfactory layout is - achieved or predefined limits are met. It operates by repeatedly identifying atoms with - the highest energy contributions - and adjusting their positions to reduce strain within the molecular structure, thereby - optimizing the layout towards a state of lower potential energy. - - Process: - - Iterations continue until either the system's energy drops below a specified - threshold, indicating an acceptable level of stability, or the maximum iteration count - is reached, preventing infinite loops. - - At each iteration, the atom contributing most significantly to the system's energy is - identified, and its position is adjusted to decrease overall energy. - - Inner iterations within each main iteration further refine the position of the most - energetic atom, stopping once the change in energy falls below an inner threshold or the - maximum number of inner iterations is reached, - ensuring fine-tuning of atomic positions for optimal placement. - - After concluding iterations, final positions are assigned to atoms, marking them as - positioned and forcing their placement to prevent further adjustments. - - Outcome: - - Achieves a stable molecular layout where atomic positions are optimized to minimize - energy, reflecting the algorithm's goal of balance and stability. - - Marks atoms as positioned and forcibly placed, indicating completion and preventing - further adjustments, ensuring structural integrity. - """ - iteration = 0 - max_energy = self.max_energy - while max_energy > self.threshold and self.max_iteration > iteration: - iteration += 1 - max_energy_atom, max_energy, d_ex, d_ey = self.highest_energy() - delta = max_energy - inner_iteration = 0 - while delta > self.inner_threshold and self.max_inner_iteration > inner_iteration: - inner_iteration += 1 - self.update(max_energy_atom, d_ex, d_ey) - delta, d_ex, d_ey = self.energy(max_energy_atom) - for atom in self.atoms: - atom.position.x = self.x_positions[atom] - atom.position.y = self.y_positions[atom] - atom.positioned = True - atom.force_positioned = True - - - def energy(self, atom: 'AtomProperties') -> List[float]: - """ - Calculates the energy of the system for a given atom. - - The energy is defined as the sum of the squares of the energy components along the X and - Y axes, as well as the components themselves. This allows for an evaluation of the - atom's overall state within the system and its contribution to the total energy. - - Parameters: - :param atom AtomProperties: - The atom for which the energy is calculated. - - Returns List[float]: - A list containing: - - Total energy (sum of the squares of the components), - - Energy along the X-axis, - - Energy along the Y-axis. - """ - energy: List[float] = [self.energy_sums_x[atom]**2 + self.energy_sums_y[atom]**2, \ - self.energy_sums_x[atom], self.energy_sums_y[atom]] - return energy - - - def highest_energy(self) -> Tuple['AtomProperties', float, float, float]: - """ - Identifies the atom with the highest energy among those not yet positioned. - - This method scans through all atoms in the molecular structure to find the one with the - greatest energy contribution that has not been positioned yet. It is crucial for the - iterative process of optimizing the layout according to the Kamada-Kawai algorithm, as - it targets atoms requiring adjustment to minimize overall system energy. - - Returns Tuple[AtomProperties, float, float, float]: - A tuple containing: - - AtomProperties: The atom identified as having the highest energy among those not yet positioned. - - float: The maximum energy value associated with this atom. - - float: The energy component along the X-axis for the atom with the highest energy. - - float: The energy component along the Y-axis for the atom with the highest energy. - """ - max_energy = 0.0 - max_energy_atom = None - max_d_ex = 0.0 - max_d_ey = 0.0 - for atom in self.atoms: - delta, d_ex, d_ey = self.energy(atom) - if delta > max_energy and not self.positioned[atom]: - max_energy = delta - max_energy_atom = atom - max_d_ex = d_ex - max_d_ey = d_ey - return max_energy_atom, max_energy, max_d_ex, max_d_ey - - - def update(self, atom: 'AtomProperties', d_ex: float, d_ey: float) -> None: - """ - Updates the position of a specified atom based on its energy. - - Parameters: - :param atom AtomProperties: - The atom whose position needs to be updated. - :param d_ex float: - Energy along the X-axis for the atom. - :param d_ey float: - Energy along the Y-axis for the atom. - - Description: - This method recalculates and adjusts the position of a given atom within the molecular structure based on its current energy state, aiming to minimize the overall system energy through iterative refinement. It incorporates the Kamada-Kawai algorithm principles, adjusting atomic positions to reduce strain and achieve a stable configuration. By considering the energies along the X and Y axes, the method computes new coordinates that reflect a balance between the atom's interactions with other atoms, effectively reducing its contribution to the system's total energy. The process involves calculating forces acting on the atom due to its connections, represented by springs with specific strengths, and updating its position accordingly. The adjustments are made in both X and Y directions, aiming to move the atom towards a state of lower potential energy, thereby contributing to the optimization of the entire molecular layout. - """ - dxx = 0.0 - dyy = 0.0 - dxy = 0.0 - ux = self.x_positions[atom] - uy = self.y_positions[atom] - lengths_array = self.length_matrix[atom] - strengths_array = self.spring_strengths[atom] - for atom_2 in self.atoms: - if atom == atom_2: - continue - vx = self.x_positions[atom_2] - vy = self.y_positions[atom_2] - length = lengths_array[atom_2] - strength = strengths_array[atom_2] - squared_xdiff = (ux - vx) ** 2 - squared_ydiff = (uy - vy) ** 2 - denom = 1.0 / (squared_xdiff + squared_ydiff) ** 1.5 - dxx += strength * (1 - length * squared_ydiff * denom) - dyy += strength * (1 - length * squared_xdiff * denom) - dxy += strength * (length * (ux - vx) * (uy - vy) * denom) - if dxx == 0: - dxx = 0.1 - if dyy == 0: - dyy = 0.1 - if dxy == 0: - dxy = 0.1 - dy = (d_ex / dxx + d_ey / dxy) / (dxy / dxx - dyy / dxy) - dx = -(dxy * dy + d_ex) / dxx - self.x_positions[atom] += dx - self.y_positions[atom] += dy - d_ex = 0.0 - d_ey = 0.0 - ux = self.x_positions[atom] - uy = self.y_positions[atom] - for atom_2 in self.atoms: - if atom == atom_2: - continue - vx = self.x_positions[atom_2] - vy = self.y_positions[atom_2] - previous_ex = self.energy_matrix[atom][atom_2][0] - previous_ey = self.energy_matrix[atom][atom_2][1] - denom = 1.0 / math.sqrt((ux - vx) ** 2 + (uy - vy) ** 2) - dx = strengths_array[atom_2] * ((ux - vx) - lengths_array[atom_2] * (ux - vx) * denom) - dy = strengths_array[atom_2] * ((uy - vy) - lengths_array[atom_2] * (uy - vy) * denom) - self.energy_matrix[atom][atom_2] = [dx, dy] - d_ex += dx - d_ey += dy - self.energy_sums_x[atom_2] += dx - previous_ex - self.energy_sums_y[atom_2] += dy - previous_ey - self.energy_sums_x[atom] = d_ex - self.energy_sums_y[atom] = d_ey - - - def get_subgraph_distance_matrix(self, atoms: List['AtomProperties']) \ - -> Dict[int, Dict[int, float]]: - """ - Computes the distance matrix between atoms in a subgraph. - - Parameters: - :param atoms List[AtomProperties]: - Specifies the subset of atoms to analyze, focusing the calculation on relevant - components of the molecular structure. - - Returns Dict[int, Dict[int, float]]: - Provides a comprehensive view of atomic distances, where keys represent atoms and - values are dictionaries mapping to other atoms with corresponding shortest path - distances. This structured output facilitates targeted adjustments, ensuring that - atomic placements minimize overall system energy. - - Description: - This method calculates the shortest path distances between all pairs of atoms within a - given subset of a molecular structure, forming a subgraph. It initializes the distance - matrix with infinite distances for all atom pairs except those directly connected, which - are set to 1, indicating a bond exists. It then applies a variation of the - Floyd-Warshall algorithm to find the shortest paths between all atoms, updating the - matrix to reflect the shortest distances found. This process is crucial for - understanding the connectivity and spatial relationships within the molecular structure, - aiding in layout optimization by identifying the most efficient paths between atoms. The - resulting matrix provides insights into the molecular topology, guiding the arrangement - of atoms to minimize overall energy and enhance structural stability. - """ - distance_matrix: Dict[int, Dict[int, float]] = {} - for atom_1 in atoms: - if atom_1 not in distance_matrix: - distance_matrix[atom_1] = {} - - for atom_2 in atoms: - if self.structure.bond_lookup(atom_1, atom_2): - distance_matrix[atom_1][atom_2] = 1 - else: - distance_matrix[atom_1][atom_2] = float('inf') - - for atom_1 in atoms: - for atom_2 in atoms: - for atom_3 in atoms: - if distance_matrix[atom_2][atom_3] > distance_matrix[atom_2][atom_1] + distance_matrix[atom_1][atom_3]: - distance_matrix[atom_2][atom_3] = distance_matrix[atom_2][atom_1] + distance_matrix[atom_1][atom_3] - return distance_matrix \ No newline at end of file diff --git a/chython/algorithms/calculate2d/MathHelper.py b/chython/algorithms/calculate2d/MathHelper.py deleted file mode 100644 index 7e95ac03..00000000 --- a/chython/algorithms/calculate2d/MathHelper.py +++ /dev/null @@ -1,709 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright 2024 Denis Lipatov -# Copyright 2024 Vyacheslav Grigorev -# Copyright 2024 Timur Gimadiev -# This file is part of chython. -# -# chython is free software; you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation; either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with this program; if not, see . -# -""" -This module introduces the `Vector` and `Polygon` classes, designed to perform mathematical -calculations relevant to two-dimensional Cartesian coordinate systems and regular polygons. - -The `Vector` class facilitates operations with coordinates, including vector arithmetic -(addition, subtraction, multiplication/division by scalars), normalization, rotation, and -distance calculations. It also supports methods for determining the angle of a vector, its -length, and whether it lies in a certain quadrant. Additionally, it includes functions for -reflecting vectors about lines, finding the closest atom or point, and rotating vectors around -other vectors or points. - -The `Polygon` class focuses on properties and calculations related to regular polygons, such as -finding the circumradius, calculating central angles, determining the apothem, and identifying -the type of polygon based on its number of sides. It also includes static methods for -calculating normals to lines defined by two vectors and for adding, averaging, and mirroring -vectors. - -Together, these classes provide a comprehensive toolkit for performing geometric computations -essential in fields such as computer graphics, physics simulations, and computational chemistry, -where precise manipulation and analysis of spatial relationships are required. -""" -import math -from typing import Union, TYPE_CHECKING, List - -if TYPE_CHECKING: - from .Properties import AtomProperties - - -class Vector: - """ - The `Vector` class facilitates operations with coordinates, including vector arithmetic - (addition, subtraction, multiplication/division by scalars), normalization, rotation, and - distance calculations. It also supports methods for determining the angle of a vector, its - length, and whether it lies in a certain quadrant. Additionally, it includes functions for - reflecting vectors about lines, finding the closest atom or point, and rotating vectors around - other vectors or points. - """ - def __init__(self, x: Union[int, float], y: Union[int, float]) -> None: - """ - Constructor of the vector class - - Parameters: - :param x Union[int, float]: - The coordinate of the vector along the abscissa axis - :param y Union[int, float]: - The coordinate of the vector along the ordinate axis - - Attributes: - x: the coordinate of the vector along the abscissa axis - y: the coordinate of the vector along the ordinate axis - """ - self.x: float = float(x) - self.y: float = float(y) - - - def __repr__(self) -> str: - """ - The method needed for debugging the code - - Returns a string containing the coordinates of the point - """ - return str(self.x) + ', ' + str(self.y) - - - def copy(self) -> 'Vector': - """ - Creates a copy of the current class object - - Returns a copy of the object - """ - return Vector(self.x, self.y) - - - def subtract(self, vector: 'Vector'): - """ - A method for the operation of subtraction between vectors - - Parameters: - :param vector 'Vector': - Another object of the current class - """ - self.x -= vector.x - self.y -= vector.y - - - def rotate(self, angle: float) -> None: - """ - A method that rotates the vector by the appropriate angle from the signature - of the function and updates the coordinates of the current class object - - Parameters: - :param angle float: - The angle by which the vector should be rotated - """ - new_x: float = self.x * math.cos(angle) - self.y * math.sin(angle) - new_y: float = self.x * math.sin(angle) + self.y * math.cos(angle) - - self.x = new_x - self.y = new_y - - - def add(self, vector: 'Vector') -> None: - """ - A class method that adds vectors and updates the coordinates of the current class object - - Parameters: - :param vector 'Vector': - Another object of the current class - """ - self.x += vector.x - self.y += vector.y - - - def invert(self) -> None: - """ - A class method that inverts the current coordinates of objects of the class - """ - self.x = self.x * -1 - self.y = self.y * -1 - - - def divide(self, scalar: float) -> None: - """ - A class method that divides the coordinates of the current class object - vectors for an arbitrary number - - Parameters: - :param scalar float: - Number divider - """ - self.x = self.x / scalar - self.y = self.y / scalar - - - def normalise(self) -> None: - """ - Normalization of coordinates (dividing them by the length of the vector itself) - """ - if self.length() != 0: - self.divide(self.length()) - - - def angle(self) -> float: - """ - A method that calculates the angle of inclination of the current vector - - Returns float the angle of inclination of the vector - """ - return math.atan2(self.y, self.x) - - - def length(self) -> float: - """ - Calculates the length of the current vector - - Returns float - """ - return math.sqrt((self.x**2) + (self.y**2)) - - - def multiply_by_scalar(self, scalar: float) -> None: - """ - Multiplies the coordinates of the current vector by an arbitrary real number - - Parameters: - :param scalar float - """ - self.x = self.x * scalar - self.y = self.y * scalar - - - def rotate_around_vector(self, angle: float, vector: 'Vector') -> None: - """ - Rotates a point (or vector) around a given vector by a specified angle. - - Parameters: - :param angle float: - The angle by which to rotate the point, typically measured in radians. - :param vector 'Vector': - The vector around which the rotation occurs. This vector serves as the reference - point. - """ - self.x -= vector.x - self.y -= vector.y - - x = self.x * math.cos(angle) - self.y * math.sin(angle) - y = self.x * math.sin(angle) + self.y * math.cos(angle) - - self.x = x + vector.x - self.y = y + vector.y - - - def get_closest_atom(self, atom_1: 'AtomProperties', atom_2: 'AtomProperties') -> 'AtomProperties': - """ - This method determines which of the two atoms (represented by the objects atom_1 and atom_2) - is closer to the current object (represented by self). - - Parameters: - :param atom_1: 'AtomProperties': - The first atom to compare. - :param atom_2: 'AtomProperties': - The second atom to compare. - - Returns 'AtomProperties': - The closest atom. - """ - distance_1 = self.get_squared_distance(atom_1.position) - distance_2 = self.get_squared_distance(atom_2.position) - return atom_1 if distance_1 < distance_2 else atom_2 - - - def get_closest_point_index(self, point_1: 'Vector', point_2: 'Vector') -> int: - """ - The method is designed to determine which of the two specified coordinates (point_1: 'Vector', point_2: 'Vector') - closer to the current point. - - Parameters - :param point_1 'Vector': - The first point to be compared with. It can be a tuple, a list, or an object - representing coordinates. - :param point_2 'Vector': - The second point to compare with. Similarly, it can be a tuple, a list, or an - object. - - Returns int: - The index of the nearest point: 0 for point_1 and 1 for point_2. - """ - distance_1 = self.get_squared_distance(point_1) - distance_2 = self.get_squared_distance(point_2) - return 0 if distance_1 < distance_2 else 1 - - - def get_squared_length(self) -> float: - """ - Calculates the length squared - - Returns float: - Vector length squared - """ - return self.x ** 2 + self.y ** 2 - - - def get_squared_distance(self, vector: 'Vector') -> float: - """ - The method is designed to calculate the square of the distance between the current vector - (represented by self) and the specified vector (or point) represented by the vector object. - - Parameters - :param vector: 'Vector': - An object representing a vector or point from which to calculate the distance. - - Returns float: - The square of the distance - """ - return (vector.x - self.x) ** 2 + (vector.y - self.y) ** 2 - - - def get_distance(self, vector: 'Vector') -> float: - """ - The method is designed to calculate the distance between the current vector (represented by self) and - the specified vector (or point) represented by the vector object. - - Parameters - :param vector: 'Vector': - An object representing a vector or point from which to calculate the distance. - - Returns float: - The distance between the coordinates of the current vector and the passed parameter - """ - return math.sqrt(self.get_squared_distance(vector)) - - - def get_rotation_away_from_vector(self, vector: 'Vector', center: 'Vector', angle: float) -> float: - """ - The method is designed to determine how much the angle of rotation (in a positive or negative direction) - from a given vector measures the distance to this vector. - - Parameters - :param vector 'Vector': - The vector to "move away from". It can be a point or a direction, relative to which - the rotation is taking place. - :param center 'Vector': - The center of rotation around which the object (represented by self) rotates. - :param angle float: - The angle at which the rotation occurs. This value can be positive or negative. - - Returns returns the rotation angle that minimizes the distance to the vector, - either in a positive or negative direction. - """ - tmp = self.copy() - - tmp.rotate_around_vector(angle, center) - squared_distance_1 = tmp.get_squared_distance(vector) - - tmp.rotate_around_vector(-2.0 * angle, center) - squared_distance_2 = tmp.get_squared_distance(vector) - return angle if squared_distance_2 < squared_distance_1 else -angle - - - def rotate_away_from_vector(self, vector: 'Vector', center: 'Vector', angle: float) -> None: - """ - The method is designed to rotate the current object (represented by self) around a given - one center in such a way as to minimize the distance to the specified vector. - If rotation in one direction leads to a decrease in the distance, the function corrects - the rotation,to ensure maximum distance from the vector. - - Parameters - :param vector 'Vector': - The vector to "move away from". It can be a point or a direction, relative to which - the rotation is taking place. - :param center 'Vector': - The center of rotation around which the object rotates. - :param angle float: - The angle at which the rotation occurs. This value can be positive or negative. - """ - self.rotate_around_vector(angle, center) - squared_distance_1 = self.get_squared_distance(vector) - self.rotate_around_vector(-2.0 * angle, center) - squared_distance_2 = self.get_squared_distance(vector) - - if squared_distance_2 < squared_distance_1: - self.rotate_around_vector(2.0 * angle, center) - - - def get_clockwise_orientation(self, vector: 'Vector') -> str: - """ - The method is designed to determine the orientation (positive or negative) between - the current object (represented by self) and the specified vector (represented by - the vector object). - - Parameters - :param vector 'Vector': - The vector relative to which the orientation is determined. - - Returns str: - A string indicating whether the orientation is "clockwise", "counterclockwise" - or "neutral". - """ - a: float = self.y * vector.x - b: float = self.x * vector.y - - if a > b: - return 'clockwise' - elif a == b: - return 'neutral' - else: - return 'counterclockwise' - - - def mirror_about_line(self, line_point_1: 'Vector', line_point_2: 'Vector') -> None: - """ - The method is designed to reflect the current object (represented by self) relative to a - given line, defined by two points (line_point_1 and line_point_2). After performing this - function, the coordinates of the object will be changed so that it is on the opposite - side of the line, keeping the same distance to the line. - - Parameters - :param line_point_1: 'Vector': - The first point defining the line. - :param line_point_2: 'Vector': - The second point defining the line. - """ - dx = line_point_2.x - line_point_1.x - dy = line_point_2.y - line_point_1.y - - a = (dx * dx - dy * dy) / (dx * dx + dy * dy) - b = 2 * dx * dy / (dx * dx + dy * dy) - - new_x = a * (self.x - line_point_1.x) + b * (self.y - line_point_1.y) + line_point_1.x - new_y = b * (self.x - line_point_1.x) - a * (self.y - line_point_1.y) + line_point_1.y - - self.x = new_x - self.y = new_y - - - @staticmethod - def get_position_relative_to_line(vector_start: 'Vector', vector_end: 'Vector', vector: 'Vector') -> int: - """ - Determines the position of a vector relative to a line defined by two points. - - Parameters: - :param vector_start 'Vector': - The start point of the line. - :param vector_end 'Vector': - The end point of the line. - :param vector 'Vector': - The vector whose position relative to the line is to be determined. - - Returns int: - 1 if the vector is to the left of the line, -1 if the vector is to the right of the - line, 0 if the vector lies on the line. - """ - d = (vector.x - vector_start.x) * (vector_end.y - vector_start.y) - (vector.y - vector_start.y) * (vector_end.x - vector_start.x) - if d > 0: - return 1 - elif d < 0: - return -1 - else: - return 0 - - - @staticmethod - def get_directionality_triangle(vector_a: 'Vector', vector_b: 'Vector', vector_c: 'Vector') -> str: - """ - Determines the directionality of the triangle formed by three vectors (or points). - - Parameters: - :param vector_a 'Vector': - The first vertex of the triangle. - :param vector_b 'Vector': - The second vertex of the triangle. - :param vector_c 'Vector': - The third vertex of the triangle. - - Returns str: - - 'clockwise' if the triangle is oriented in a clockwise direction. - - 'counterclockwise' if the triangle is oriented in a counterclockwise direction. - - None if the three points are collinear (lie on the same line). - """ - determinant = (vector_b.x - vector_a.x) * (vector_c.y - vector_a.y) - \ - (vector_c.x - vector_a.x) * (vector_b.y - vector_a.y) - if determinant < 0: - return 'clockwise' - elif determinant == 0: - return None - else: - return 'counterclockwise' - - - @staticmethod - def mirror_vector_about_line(line_point_1: 'Vector', line_point_2: 'Vector', point: 'Vector')-> 'Vector': - """ - Mirrors a point (or vector) across a line defined by two points. - - Parameters: - :param line_point_1 'Vector': - The first point defining the line. - :param line_point_2 'Vector': - The second point defining the line. - :param point 'Vector': - The point to be mirrored across the line. - - Returns Vector: - A new Vector representing the mirrored point across the line. - """ - dx = line_point_2.x - line_point_1.x - dy = line_point_2.y - line_point_1.y - - a = (dx * dx - dy * dy) / (dx * dx + dy * dy) - b = 2 * dx * dy / (dx * dx + dy * dy) - - x_new = a * (point.x - line_point_1.x) + b * (point.y - line_point_1.y) + line_point_1.x - y_new = b * (point.x - line_point_1.x) - a * (point.y - line_point_1.y) + line_point_1.y - return Vector(x_new, y_new) - - - @staticmethod - def get_line_angle(point_1: 'Vector', point_2: 'Vector') -> float: - """ - Calculates the angle of a line defined by two points with respect to the positive x-axis. - - Parameters: - point_1 'Vector': - The first point defining the line. - point_2 'Vector': - The second point defining the line. - - Returns float: - The angle of the line in radians, in the range [-π, π]. - """ - difference = Vector.subtract_vectors(point_2, point_1) - return difference.angle() - - - @staticmethod - def subtract_vectors(vector_1: 'Vector', vector_2: 'Vector')-> 'Vector': - """ - Subtracts one vector from another. - - Parameters: - vector_1 'Vector': - The vector from which to subtract. - vector_2 'Vector': - The vector to subtract. - - Returns Vector: - A new Vector representing the result of the subtraction (vector_1 - vector_2). - """ - x = vector_1.x - vector_2.x - y = vector_1.y - vector_2.y - return Vector(x, y) - - - @staticmethod - def add_vectors(vector_1: 'Vector', vector_2: 'Vector') -> 'Vector': - """ - Adds two vectors together. - - Parameters: - vector_1 'Vector': - The first vector to add. - vector_2 'Vector': - The second vector to add. - - Returns Vector: - A new Vector representing the result of the addition (vector_1 + vector_2). - """ - x = vector_1.x + vector_2.x - y = vector_1.y + vector_2.y - return Vector(x, y) - - - @staticmethod - def get_midpoint(vector_1: 'Vector', vector_2: 'Vector') -> 'Vector': - """ - Calculates the midpoint between two vectors. - - Parameters: - vector_1 'Vector': - The first vector. - vector_2 'Vector': - The second vector. - - Returns Vector: - A new Vector representing the midpoint between vector_1 and vector_2. - """ - x = (vector_1.x + vector_2.x) / 2 - y = (vector_1.y + vector_2.y) / 2 - return Vector(x, y) - - - @staticmethod - def get_average(vectors: List['Vector']) -> 'Vector': - """ - Calculates the average of a list of vectors. - - Parameters: - :param vectors List[Vector]: - A list of vectors for which the average is to be calculated. - - Returns: - Vector: A new Vector representing the average of the input vectors. - """ - average_x = 0.0 - average_y = 0.0 - for vector in vectors: - average_x += vector.x - average_y += vector.y - return Vector(average_x / len(vectors), average_y / len(vectors)) - - - @staticmethod - def get_normals(vector_1: 'Vector', vector_2: 'Vector') -> List['Vector']: - """ - Calculates the normal vectors to the line defined by two vectors. - - Parameters: - :param vector_1 'Vector': - The first vector defining the line. - :param vector_2 'Vector': - The second vector defining the line. - - Returns List[Vector]: - A list containing two normal vectors to the line defined by vector_1 and vector_2. - """ - delta = Vector.subtract_vectors(vector_2, vector_1) - return [Vector(-delta.y, delta.x), Vector(delta.y, -delta.x)] - - - @staticmethod - def get_angle_between_vectors(vector_1: 'Vector', vector_2: 'Vector', origin: 'Vector') -> float: - """ - Calculates the angle between two vectors relative to a given origin point. - - Parameters: - :param vector_1 'Vector': - The first vector. - :param vector_2 'Vector': - The second vector. - :param origin 'Vector': - The origin point relative to which the angle is calculated. - - Returns: - float: The angle between vector_1 and vector_2 in radians, in the range [0, π]. - """ - v1_x_diff: float = vector_1.x - origin.x - v1_y_diff: float = vector_1.y - origin.y - v2_x_diff: float = vector_2.x - origin.x - v2_y_diff: float = vector_2.y - origin.y - - dot_product: float = v1_x_diff * v2_x_diff + v1_y_diff * v2_y_diff - length_v1: float = math.sqrt(v1_x_diff ** 2 + v1_y_diff ** 2) - length_v2: float = math.sqrt(v2_x_diff ** 2 + v2_y_diff ** 2) - - cos_angle = dot_product / (length_v1 * length_v2) - return math.acos(cos_angle) - - - - - - - - - - - - - -class Polygon: - """ - The `Polygon` class focuses on properties and calculations related to regular polygons, such as - finding the circumradius, calculating central angles, determining the apothem, and identifying - the type of polygon based on its number of sides. It also includes static methods for - calculating normals to lines defined by two vectors and for adding, averaging, and mirroring - vectors. - """ - def __init__(self, edge_number: float) -> None: - """ - Initializes a Polygon with a specified number of edges. - - Parameters: - :param edge_number float: - The number of edges (sides) of the polygon. - """ - self.edge_number: float = edge_number - - @staticmethod - def find_polygon_radius(edge_length: float, edge_number: float) -> float: - """ - Calculates the radius of the circumcircle of a regular polygon. - - Parameters: - :param edge_length float: - The length of one edge of the polygon. - :param edge_number float: - The number of edges (sides) of the polygon. - - Returns float: - The radius of the circumcircle. - """ - return edge_length / (2 * math.sin(math.pi / edge_number)) - - @staticmethod - def get_central_angle(edge_number: float) -> float: - """ - Calculates the central angle of a regular polygon. - - Parameters: - :param edge_number float: - The number of edges (sides) of the polygon. - - Returns float: - The central angle in radians. - """ - return math.radians(float(360) / edge_number) - - @staticmethod - def get_apothem(radius: float, edge_number: float) -> float: - """ - Calculates the apothem of a regular polygon. - - Parameters: - :param radius float: - The radius of the circumcircle of the polygon. - :param edge_number float: - The number of edges (sides) of the polygon. - - Returns float: - The length of the apothem. - """ - return radius * math.cos(math.pi / edge_number) - - @staticmethod - def get_apothem_from_side_length(length: float, edge_number: float) -> float: - """ - Calculates the apothem of a regular polygon given the side length. - - Parameters: - :param length float: - The length of one edge of the polygon. - :param edge_number float: - The number of edges (sides) of the polygon. - - Returns float: - The length of the apothem. - """ - radius: float = Polygon.find_polygon_radius(length, edge_number) - return Polygon.get_apothem(radius, edge_number) \ No newline at end of file diff --git a/chython/algorithms/calculate2d/Properties.py b/chython/algorithms/calculate2d/Properties.py deleted file mode 100644 index 9ae9f31f..00000000 --- a/chython/algorithms/calculate2d/Properties.py +++ /dev/null @@ -1,631 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright 2024 Denis Lipatov -# Copyright 2024 Vyacheslav Grigorev -# Copyright 2024 Timur Gimadiev -# This file is part of chython. -# -# chython is free software; you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation; either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with this program; if not, see . -# -""" -This module defines classes that extend the properties of rings, atoms, and bonds within the -Kaiton structure, focusing on attributes and methods necessary for coordinate calculations. - -The classes contained herein serve as supplements to the existing Kaiton structure, offering -additional functionalities tailored for computational chemistry applications. They are designed -to facilitate the calculation of molecular geometries by providing detailed attributes and -methods specific to rings, atoms, and bonds, thereby enhancing the structure's utility in -algorithms that require precise spatial information. These classes include RingProperties, -AtomProperties, BondProperties, and RingOverlap, each tailored to represent different aspects of -molecular structures with attributes and methods that aid in determining spatial relationships -and characteristics inherent to chemical compounds. - -Classes: -- RingProperties: Represents the properties of rings within a molecule, including identifiers, - member atoms, positioning status, geometric center, presence of subrings, and types of rings - (e.g., bridged, spiro, fused). -- AtomProperties: Encapsulates atomic properties crucial for molecular geometry calculations, - such as atomic symbols, positions, and connectivity. -- BondProperties: Details the computational parameters of chemical bonds, including atom - references and bond types. -- RingOverlap: Handles overlaps between rings, identifying shared atoms and determining - structural characteristics like bridging. - -These classes are integral for algorithms that necessitate a deep understanding of molecular -topology and geometry, offering a structured approach to manipulating and analyzing chemical -structures programmatically. They facilitate the representation of complex molecular features -such as ring systems, atomic configurations, and bond characteristics, making them indispensable -for cheminformatics and computational chemistry applications. -""" - -from typing import List, Optional, Tuple -from .MathHelper import Vector -import math - -class RingProperties: - """ - A class on computing parameters of rings - """ - def __init__(self: 'RingProperties', ring: List['AtomProperties']) -> None: - """ - Constructor of the class that complements information about rings in the Kaiton - structure, creating new properties or converting existing ones into a more convenient - form for use in coordinate calculation algorithms. - - Parameters: - :param ring List['AtomProperties']: - A list of AtomProperties objects forming the current ring. - - Attributes: - - id Optional[int]: Contains information about the ring identifier, its sequential - number. - - members List['AtomProperties']: A list of references to corresponding AtomProperties - objects which are participants of this ring. - - members_id List[int]: A list of identifiers for AtomProperties objects which are - participants of this ring. - - positioned bool: A boolean value corresponding to the state of the ring calculation, - returns True if all atoms of this ring have received their coordinates. - - center 'Vector': The center of the ring in coordinates. - - subrings List: A boolean value indicating whether the ring contains subrings. - - bridged bool: A boolean value indicating whether the ring is a bridge ring. - - spiro bool: A boolean value indicating whether the ring is a spirocycle. - - fused bool: A boolean value indicating whether it is part of a condensed cyclic system. - - subring_of_bridged bool: A boolean value indicating whether the subrings are bridged. - - central_angle float: The central angle of the ring. - - neighbouring_rings List[int]: A list of identifiers of neighboring rings to the - current one. - """ - self.id: Optional[int] = None - self.members: List['AtomProperties'] = ring - self.members_id: List[int] = [atom.id for atom in self.members] - self.positioned: bool = False - self.center: 'Vector' = Vector(0, 0) - self.subrings: List = [] - self.bridged = False - self.spiro: bool = False - self.fused: bool = False - self.subring_of_bridged = False - self.central_angle: float = 0.0 - self.neighbouring_rings: List[int] = [] - - # добавляем в свойства атомов то что они находятся в этом кольце - for atom in self.members: - atom.ring_indexes.append(self.id) - atom.rings.append(self) - - - def __eq__(self, other: 'RingProperties') -> bool: - """ - Compares two RingProperties instances for equality based on their identifiers. - - This method checks if the identifiers of the two RingProperties instances being compared - are equal, returning True if they match and False otherwise. It serves as a quick way to - determine if two rings refer to the same entity in terms of their unique identifier. - - Parameters: - :param other RingProperties: - The instance to compare with the current instance. - - Returns bool: - True if the identifiers of the two instances are equal, indicating they represent - the same ring. False otherwise. - """ - return False if other is None else self.id == other.id - - - def __hash__(self) -> int: - """ - Returns the hash value of the current object, which is the unique identifier of the ring. - - This method is used when the object needs to be inserted into a hash-based collection - such as a set or dictionary. - The hash value is derived from the ring's unique identifier, allowing for efficient - storage and retrieval of ring objects - in collections that rely on hashing. - - Returns int: - The unique identifier of the current object of the class, used as the hash value. - """ - return self.id - - - def get_angle(self) -> float: - """ - Calculates the exterior angle of the polygon formed by the ring in radians. - - The exterior angle is determined by subtracting the central angle of the ring from π - (pi), providing a measure - of the angle formed outside the ring by extending one of its sides. This method is - particularly useful for - understanding the geometry of the ring within the context of its surrounding environment. - - Returns float: - The exterior angle of the polygon formed by the ring in radians. - """ - return math.pi - self.central_angle - - - def __repr__(self) -> str: - """ - Provides a human-readable representation of the RingProperties object, primarily - intended for debugging purposes. - - The representation includes the ring's identifier followed by the identifiers of the - atoms that are part of the ring, separated by hyphens. This format offers a concise yet - informative overview of the ring's composition, aiding in the identification and - analysis of rings during development and debugging sessions. - - Returns str: - A string combining the ring's identifier and the identifiers of the atoms that make - up the ring, separated by hyphens. - """ - members: str = '-'.join(str(member) for member in self.members) - return f'{self.id} {members}' - - - def copy(self) -> 'RingProperties': - """ - Creates a deep copy of the current RingProperties instance, duplicating all its - attributes and relationships. - - This method constructs a new RingProperties object that mirrors the current - instance exactly, including the list of member atoms, their identifiers, the - ring's position status, geometric center, presence of subrings, - and various boolean flags indicating the ring's characteristics (e.g., whether it - is bridged, spiro, fused). - Additionally, it copies over the list of neighboring rings and any subrings - associated with the ring. - - Returns RingProperties: - A new instance of the RingProperties class that is a deep copy of the current - instance, complete with all attributes and relationships duplicated. - """ - new_members: List['AtomProperties'] = [] - for atom in self.members: - new_members.append(atom.copy()) - - new_ring = RingProperties(new_members) - new_ring.id = self.id - for ring_id in self.neighbouring_rings: - new_ring.neighbouring_rings.append(ring_id) - - new_ring.positioned = self.positioned - for subring in self.subrings: - new_ring.subrings.append(subring) - new_ring.bridged = self.bridged - new_ring.subring_of_bridged = self.subring_of_bridged - new_ring.spiro = self.spiro - new_ring.fused = self.fused - new_ring.central_angle = self.central_angle - return new_ring - - - - - - - - - - - - - - - - - - - - - - - - -class BondProperties: - """ - A class about computational parameters of links - """ - def __init__(self: 'BondProperties', atom1: 'AtomProperties', \ - atom2: 'AtomProperties', bond) -> None: - """ - Constructor of the class that complements information about bonds in the Kaiton - structure, creating new properties or converting existing ones into a more - convenient form for use in coordinate calculation algorithms. - - Parameters: - :param atom1 'AtomProperties': - Reference to the object of the class of the first atom forming this bond. - :param atom2 'AtomProperties': - Reference to the object of the class of the second atom forming this bond. - :param bond: - Reference to the original Kaiton bond class. - - Attributes: - - id Tuple['AtomProperties']: Identifier of the current bond, which is a tuple of - atom identifiers between which this bond exists. - - n int: Identifier of the first atom of this bond. - - m int: Identifier of the second atom of this bond. - - atom1 'AtomProperties': Reference to the object of the class of the first atom of - this bond. - - atom2 'AtomProperties': Reference to the object of the class of the second atom of - this bond. - - type str: String that characterizes the type of bond, primary, secondary, or - tertiary. - - # center (bool): Placeholder for future expansion. - # chiral (bool): Placeholder for future expansion. - # chiral_symbol (Optional[str]): Placeholder for future expansion. - - The constructor initializes the bond properties based on the provided atoms and - determines its type (single, double, triple) based on the order of the bond. - - """ - self.id: Tuple['AtomProperties'] = (atom1.id, atom2.id) - self.n: int = atom1.id #atom1 index - self.m: int = atom2.id #atom2 index - - self.atom1: 'AtomProperties' = atom1 - self.atom2: 'AtomProperties' = atom2 - - # self.center: bool = False # рудименты кода - # self.chiral: bool = False # рудименты кода - # self.chiral_symbol: Optional[str] = None # # рудименты кода - - self.type = Optional[None] - if bond.order == 1: - self.type = 'single' - elif bond.order == 2: - self.type = 'double' - elif bond.order == 3: - self.type = 'triple' - - - - - - - - - - - - - - - - - - - - - - - - - - -class AtomProperties: - """ - A class about computing parameters of atoms - """ - def __init__(self: 'AtomProperties', atom_index: int, symbol: str) -> None: - """ - Initializes an instance of the AtomProperties class with data about an atom. - - Parameters: - :param atom_index int: - The index of the current atom within the molecular structure. - :param symbol str: - Symbol representing the element according to the periodic table. - - Attributes: - - id int: Unique identifier for the atom. - - symbol str: String characterizing the name of the element according to the periodic - table. - - ring_indexes List[int]: List of identifiers for rings in which the atom is a - participant. - - rings List['RingProperties']: List of references to RingProperties objects - representing the rings in which the atom is involved. - - is_bridge_atom bool: Flag indicating whether the atom is a bridging atom. - - is_bridge bool: Flag indicating whether the atom is a bridging atom. - - bridged_ring Optional['RingProperties']: RingProperties object representing the ring - through which a bridge passes. - - positioned bool: Flag indicating whether the coordinates for the current atom have - been calculated. - - previous_position 'Vector': Coordinates of the preceding atom. - - position 'Vector': Current coordinates of the atom. - - angle Optional[float]: Angle between the current and preceding atom. - - force_positioned bool: Flag indicating whether the atom's position was calculated - forcibly. - - connected_to_ring bool: Flag indicating whether the atom is connected to a ring. - - draw_explicit bool: Flag indicating whether the atom should be drawn explicitly. - - neighbours List['AtomProperties']: List of AtomProperties objects representing atoms - with which the current atom forms bonds. - - previous_atom Optional['AtomProperties']: Reference to an AtomProperties object - representing the preceding atom in the chain. - """ - - self.id: int = atom_index - self.symbol: str = symbol - self.ring_indexes: List[int] = [] - self.rings: List['RingProperties'] = [] - - # self.original_rings: List['RingProperties'] = [] - self.anchored_rings: List['RingProperties'] = [] - self.is_bridge_atom: bool = False - self.is_bridge: bool = False - - self.bridged_ring = None - self.positioned: bool = False - - self.previous_position: 'Vector' = Vector(0, 0) - self.position: 'Vector' = Vector(0, 0) - self.angle: Optional[float] = None - self.force_positioned: bool = False - self.connected_to_ring: bool = False - self.draw_explicit: bool = False - self.neighbours: List['AtomProperties'] = [] - self.previous_atom: Optional['AtomProperties'] = None - - - def __eq__(self, other: 'AtomProperties') -> bool: - """ - Compares two AtomProperties instances for equality based on their identifiers. - - Parameters: - :param other 'AtomProperties': - Another instance of the AtomProperties class. - - Returns bool: - True if both instances represent atoms with the same identifier, otherwise False. - """ - return False if other is None else self.id == other.id - - - def set_position(self, vector: 'Vector') -> None: - """ - Sets the position of the current atom to the specified vector. - - Parameters: - :param vector 'Vector': - An instance of the Vector class, whose coordinates are assigned as the position of the current atom. - """ - self.position: 'Vector' = vector - - - def __hash__(self) -> int: - """ - Returns the hash value of the current object, which is the unique identifier of the atom. - - This method is used when the object needs to be inserted into a hash-based collection - such as a set or dictionary. - - Returns int: - The unique identifier of the current object of the class, used as the hash value. - """ - return self.id - - - def __repr__(self) -> str: - """ - Provides a human-readable representation of the AtomProperties object, primarily - intended for debugging purposes. - - The representation includes the atomic symbol followed by the atomic index, adjusted by - subtracting 1 due to the indexing convention in Chython where numbering starts from 1 - instead of 0. - - Returns str: - A string combining the atomic symbol and the adjusted atomic index. - """ - return f'{self.symbol}_{self.id - 1}' - - - def get_angle(self, reference_vector: Optional['Vector']=None) -> float: - """ - Calculates the angle between the current atom and either the previous atom or a - specified reference vector. - - By default, the angle is calculated between the current atom and the previous atom. - However, if a reference_vector is provided, the angle between the current atom and the - reference_vector will be calculated instead. - - Parameters: - :param reference_vector Optional['Vector']: - An object of the Vector class representing the coordinates with which the angle will - be calculated. If None, the angle between the current atom and the previous atom is - calculated. Defaults to None. - - Returns float: - The angle between the current atom and either the previous atom or the specified - reference vector, depending on the parameter provided. - """ - vector_1: float = self.position - vector_2: float = self.previous_position if not reference_vector else reference_vector - vector = Vector.subtract_vectors(vector_1, vector_2) - return vector.angle() - - - def copy(self) -> 'AtomProperties': - """ - Creates a deep copy of the current AtomProperties instance and returns it as a new - object of the same class. - - This method duplicates all attributes of the current atom, including its position, - connections, and identifiers, ensuring that modifications to the copy do not affect the - original atom object. - - Returns AtomProperties: - A new instance of the AtomProperties class with identical properties to the original - atom, but as a separate object in memory. - """ - new_atom = AtomProperties(self.id, self.symbol) - new_atom.ring_indexes =self.ring_indexes - new_atom.rings = self.rings - # new_atom.original_rings = self.original_rings - new_atom.anchored_rings = self.anchored_rings - new_atom.is_bridge_atom = self.is_bridge_atom - new_atom.is_bridge = self.is_bridge - new_atom.positioned = self.positioned - new_atom.previous_position = self.previous_position - new_atom.position = self.position - new_atom.angle = self.angle - new_atom.force_positioned = self.force_positioned - new_atom.connected_to_ring = self.connected_to_ring - new_atom.draw_explicit = self.draw_explicit - new_atom.neighbours = self.neighbours - new_atom.previous_atom = self.previous_atom - return new_atom - - - def is_terminal(self) -> bool: - "Returns boolean whether a given atom is terminal (has no more than one bond)." - return len(self.neighbours) <= 1 - - def set_previous_position(self, previous_atom: 'AtomProperties') -> None: - "Set previous position atom" - self.previous_position = previous_atom.position - self.previous_atom = previous_atom - - - - - - - - - - - - - -class RingOverlap: - """ - Initializes an instance of the RingOverlap class, which represents the overlap between - two rings. - """ - def __init__(self, ring_1: 'RingProperties', ring_2: 'RingProperties') -> None: - """ - This class is designed to handle situations where two rings share common atoms, - indicating an overlap or intersection between them. - - Parameters: - :param ring_1 RingProperties: - An instance of the RingProperties class representing the first ring involved in the - overlap. - :param ring_2 RingProperties: - An instance of the RingProperties class representing the second ring involved in the - overlap. - - Attributes: - - id: A unique identifier for the overlap instance. Initially set to None, indicating - that the overlap ID may need to be assigned externally. - - ring_id_1 int: The identifier of the first ring participating in the overlap. - - ring_id_2 int: The identifier of the second ring participating in the overlap. - - atoms List['AtomProperties']: A list of AtomProperties instances that are common to - both rings, representing the atoms where the overlap occurs. - - The constructor identifies the common atoms between the two rings by intersecting the - members of both rings and stores their identifiers for reference. - """ - self.id = None - self.ring_id_1: int = ring_1.id - self.ring_id_2: int = ring_2.id - self.atoms: List['AtomProperties'] = set(ring_1.members).intersection(set(ring_2.members)) - - - def __repr__(self) -> str: - """ - Provides a human-readable representation of the RingOverlap object, primarily intended - for debugging purposes. - - Returns a string that includes the overlap identifier and the identifiers of the rings - involved in the overlap, offering a convenient way to inspect the state of the object - quickly. - - Returns str: - A formatted string containing the overlap's unique identifier (`id`) and the - identifiers of the two rings (`ring_id_1` and `ring_id_2`) participating in the overlap. - This representation aids in quickly identifying the instance's state, especially useful - during debugging sessions. - """ - return f'{self.id=}, {self.ring_id_1=}, {self.ring_id_2=}' - - - def is_bridge(self) -> bool: - """ - Determines whether the overlap represents a bridge between rings based on the number of - common atoms and their ring memberships. - - This method checks if the current overlap involves more than two atoms or if any atom - within the overlap participates in more than two rings, indicating a bridging structure. - - Returns bool: - True if the overlap is considered part of a single bridge ring, otherwise False. - Specifically, returns True if either the number of atoms involved in the overlap - exceeds two or if any atom in the overlap belongs to more than two rings, suggesting - a complex bridging configuration. - """ - return len(self.atoms) > 2 or any(len(atom.rings) > 2 for atom in self.atoms) - # ниже старая версия функции - # if len(self.atoms) > 2: - # return True - # for atom in self.atoms: - # if len(atom.rings) > 2: - # return True - # return False - - - def involves_ring(self, ring_id: int) -> bool: - """ - Checks if a ring identified by `ring_id` is involved in the current overlap. - - This method determines whether the specified ring identifier matches either of the two - rings participating in the overlap represented by the current instance of RingOverlap. - - Parameters: - :param ring_id int: - The identifier of the ring to check for involvement in the overlap. - - Returns bool: - True if the specified ring identifier matches either `ring_id_1` or `ring_id_2`, - indicating that the ring is part of the current overlap. False otherwise. - """ - return self.ring_id_1 == ring_id or self.ring_id_2 == ring_id - - - def update_other(self, ring_id: int, other_ring_id: int) -> None: - """ - Updates the current attributes of the class depending on the other ring. - - This method adjusts the internal identifiers of the RingOverlap instance based on the - provided ring identifiers, ensuring that the instance accurately reflects the rings - involved in the overlap. - - Parameters: - :param ring_id int: - Identifier of the first ring to be considered for updating. - :param other_ring_id int: - Identifier of another ring, used to determine which attribute (ring_id_1 or - ring_id_2) needs to be updated. - - If the current instance's ring_id_1 matches other_ring_id, then ring_id is assigned to - ring_id_2, and vice versa. This ensures that the RingOverlap instance correctly tracks - the two rings involved in the overlap. - """ - if self.ring_id_1 == other_ring_id: - self.ring_id_2 = ring_id - else: - self.ring_id_1 = ring_id \ No newline at end of file diff --git a/chython/algorithms/calculate2d/_templates.py b/chython/algorithms/calculate2d/_templates.py deleted file mode 100644 index b44baa1f..00000000 --- a/chython/algorithms/calculate2d/_templates.py +++ /dev/null @@ -1,56 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright 2024, 2025 Ramil Nugmanov -# This file is part of chython. -# -# chython is free software; you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation; either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with this program; if not, see . -# -from lazy_object_proxy import Proxy - - -def aligner(xy): - x0, y0 = min(xy, key=lambda x: x[0]) - return [(round(x - x0, 4), round(y - y0, 4)) for x, y in xy] - - -def _rules(): - from ... import smarts - - rules = [] - - # bicyclo[1.1.1]pentane - q = smarts('[A;r4;D2;z1:2]-1-[A;D3,D4:1]-2-[A;r4;D2:5]-[A;D3,D4:3]-1-[A;r4;D2:4]-2') - xy = {1: (0.0, 0.0), 2: (0.6674, -0.485), 3: (1.3348, 0.0), 4: (1.0799, 0.7846), 5: (0.2549, 0.7846)} - sub = {1: (-0.825, 0.0), 3: (2.1598, 0.0)} - rules.append((q, xy, sub)) - - # adamantane - q = smarts('[A;D3;r6:1]-1-2-[A;D2:2][A:6]-3-[A:7][A:8]([A;D2:3]-1)[A:9][A:10]([A;D2:4]-2)[A:5]-3') - xy = {1: (0.0084, 0.8368), 2: (0.2398, 1.2541), 3: (0.2542, 0.4277), 4: (-0.4687, 0.8284), 5: (-0.7145, 1.2375), - 6: (0.0, 1.65), 7: (0.7144, 1.2375), 8: (0.7144, 0.4125), 9: (0.0, 0.0), 10: (-0.7145, 0.4125)} - sub = {6: (0.0, 2.475), 7: (1.4289, 1.65), 8: (1.4289, 0.0), - 9: (0.0, -0.825), 10: (-1.429, 0.0), 5: (-1.429, 1.65)} - rules.append((q, xy, sub)) - - # cyclopropane - q = smarts('[A;r3:1]-1-;@[A;r3:2]-;@[A;r3:3]-1') - xy = {1: (0.825, 0.0), 2: (0.0, 0.0), 3: (0.4125, -0.7145)} - rules.append((q, xy, {})) - return rules - - -rules = Proxy(_rules) - - -__all__ = ['rules'] diff --git a/chython/algorithms/calculate2d/kamada_kawai.py b/chython/algorithms/calculate2d/kamada_kawai.py new file mode 100644 index 00000000..899abe9a --- /dev/null +++ b/chython/algorithms/calculate2d/kamada_kawai.py @@ -0,0 +1,179 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2025 Ramil Nugmanov +# This file is part of chython. +# +# chython is free software; you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, see . +# +import numpy as np +from itertools import combinations +from math import isnan, nan, radians, sin, cos, sqrt, pi +from numpy.linalg import norm +from scipy.optimize import minimize +from scipy.spatial.distance import pdist, squareform +from typing import Set, Dict, TYPE_CHECKING + + +if TYPE_CHECKING: + from chython.containers import Bond + from chython.periodictable import Element + + +SINGLE = 1 +DOUBLE = 2 # double bond +BL = .825 +BLC = 1.32 +BL25 = BL * 2.5 +BL120 = BL * sqrt(3) + + +class KamadaKawai: + """ + Based on https://pubs.acs.org/doi/10.1021/acs.jcim.6b00391 + """ + __slots__ = () + _atoms: Dict[int, 'Element'] + _bonds: Dict[int, Dict[int, 'Bond']] + + def _apply_kamada_kawai(self, component: Set[int]): + atoms = self._atoms + pos0, adj, bond_force, angle_force, rep_force, mapping = self._initialize_kamada_kawai(component) + + defined = ~np.isnan(adj) + upper = np.triu(np.ones_like(adj, dtype=bool), k=1) + pair_mask = defined & upper + rep_mask = upper + + # Avoid diagonal artifacts in repulsion weights + np.fill_diagonal(rep_force, 0.0) + + def _dist(pos_flat): + pos = pos_flat.reshape(-1, 3) + return pos, squareform(pdist(pos)) + + def _bond_energy(dist, k_mat): + # E = 1/2 * k * (r - r0)^2 over selected pairs + diff = dist[pair_mask] - adj[pair_mask] + k = k_mat[pair_mask] + return 0.5 * np.sum(k * diff * diff) + + def _rep_energy(dist, c_rep): + # If F = c_rep / r, then U = -c_rep * ln(r) (+const) + r = np.clip(dist, 0.1, None) + w = rep_force[rep_mask] + return -c_rep * np.sum(w * np.log(r[rep_mask])) + + def stage1(pos_flat): + pos, dist = _dist(pos_flat) + return _bond_energy(dist, bond_force) + _rep_energy(dist, 0.04) + + def stage2(pos_flat): + pos, dist = _dist(pos_flat) + return _bond_energy(dist, bond_force) + _rep_energy(dist, 1.0) + + def stage3(pos_flat): + pos, dist = _dist(pos_flat) + # flatten: penalize z^2 smoothly (constant force can't be represented as a simple potential) + z_pen = 0.2 * np.sum(pos[:, 2] * pos[:, 2]) + return _bond_energy(dist, bond_force) + _rep_energy(dist, 1.0) + z_pen + + def stage4(pos_flat): + pos, dist = _dist(pos_flat) + z_pen = 0.2 * np.sum(pos[:, 2] * pos[:, 2]) + return _bond_energy(dist, angle_force) + _rep_energy(dist, 1.0) + z_pen + + res = minimize(stage1, pos0, method='L-BFGS-B', options={'maxiter': 1000}) + res = minimize(stage2, res.x, method='L-BFGS-B', options={'maxiter': 1000}) + res = minimize(stage3, res.x, method='L-BFGS-B', options={'maxiter': 1000}) + res = minimize(stage4, res.x, method='L-BFGS-B', options={'maxiter': 1000}) + + final_pos = res.x.reshape(-1, 3).tolist() + for n, i in mapping.items(): + x, y, z = final_pos[i] + atoms[n].xy = x, y + + def _initialize_kamada_kawai(self, component): + atoms = self._atoms + bonds = self._bonds + + mapping = {n: i for i, n in enumerate(component)} + # add "lone pair" to each atom with two neighbours, except allenes/alkynes + lone = {n: bs for n in component if len(bs := bonds[n]) == 2 and atoms[n].hybridization != 3} + + size = len(component) + len(lone) + adj = np.full((size, size), nan) + bond_force = np.zeros((size, size)) + angle_force = np.zeros((size, size)) + rep_force = np.ones((size, size)) + + for i, (n, bs) in enumerate(lone.items(), len(component)): + # set bond length to the lone pair + j = mapping[n] + adj[i, j] = adj[j, i] = BL + bond_force[i, j] = bond_force[j, i] = .1 + angle_force[i, j] = angle_force[j, i] = .2 # doubled force at stage 4 + m1, m2 = bs + j1, j2 = mapping[m1], mapping[m2] + adj[i, j1] = adj[j1, i] = BL120 # set optimal length from LP to neighbours + angle_force[i, j1] = angle_force[j1, i] = .3 + adj[i, j2] = adj[j2, i] = BL120 + angle_force[i, j2] = angle_force[j2, i] = .3 + # set angle spring between neighbours + adj[j1, j2] = adj[j2, j1] = BL120 + angle_force[j1, j2] = angle_force[j2, j1] = .3 + + for n in component: + bs = bonds[n] + if len(bs) == 2 and atoms[n].hybridization == 3: + # set angle force for alkynes/allenes + m1, m2 = bs + j1, j2 = mapping[m1], mapping[m2] + adj[j1, j2] = adj[j2, j1] = BL25 + angle_force[j1, j2] = angle_force[j2, j1] = .3 + elif len(bs) == 3: + # set angle force for triangles + for m1, m2 in combinations(bs, 2): + j1, j2 = mapping[m1], mapping[m2] + adj[j1, j2] = adj[j2, j1] = BL120 + angle_force[j1, j2] = angle_force[j2, j1] = .3 + elif len(bs) == 4: + # set doubled repulsion force for 90C + for m1, m2 in combinations(bs, 2): + j1, j2 = mapping[m1], mapping[m2] + rep_force[j1, j2] = rep_force[j2, j1] = 2 + + # set bonds. order dependent, to be sure angle constants overridden + for n in component: + i = mapping[n] + bs = bonds[n] + for m in bs: + j = mapping[m] + adj[i, j] = BL + bond_force[i, j] = .1 + angle_force[i, j] = .2 + + # extend hypervalent atoms' bond length + for n in component: + bs = bonds[n] + if len(bs) > 4: + i = mapping[n] + for m in bs: + j = mapping[m] + adj[i, j] = adj[j, i] = BLC + + pos0 = np.random.normal(0., max(3., sqrt(size)) * BL, size=(size, 3)).flatten() + return pos0, adj, bond_force, angle_force, rep_force, mapping + + +__all__ = ['KamadaKawai'] diff --git a/chython/algorithms/calculate2d/molecule.py b/chython/algorithms/calculate2d/molecule.py index de684ecd..2d648465 100644 --- a/chython/algorithms/calculate2d/molecule.py +++ b/chython/algorithms/calculate2d/molecule.py @@ -1,9 +1,6 @@ # -*- coding: utf-8 -*- # # Copyright 2019-2025 Ramil Nugmanov -# Copyright 2024 Denis Lipatov -# Copyright 2024 Vyacheslav Grigorev -# Copyright 2024 Timur Gimadiev # Copyright 2019, 2020 Dinar Batyrshin # This file is part of chython. # @@ -20,13 +17,10 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, see . # -from itertools import combinations -from math import isnan, nan, radians +from math import sqrt from random import random -from numpy import zeros, linspace, column_stack, sin, cos, sqrt, nan_to_num, argmax, errstate -from scipy.sparse.csgraph import shortest_path from typing import TYPE_CHECKING, Union, Dict, Literal -from ._templates import rules +from .kamada_kawai import KamadaKawai from ...exceptions import ImplementationError from ...periodictable.base.vector import Vector @@ -50,26 +44,16 @@ ctx = None -SINGLE = 1 -DOUBLE = 2 # double bond BL = .825 -RADIUS = 500 -D0 = 0 -D30 = radians(30) -D60 = radians(60) -D90 = radians(90) -D120 = radians(120) -D180 = radians(180) -D360 = radians(360) -class Calculate2DMolecule: +class Calculate2DMolecule(KamadaKawai): __slots__ = () _atoms: Dict[int, 'Element'] _bonds: Dict[int, Dict[int, 'Bond']] def clean2d(self: Union['MoleculeContainer', 'Calculate2DMolecule'], - *, engine: Literal['rdkit', 'smilesdrawer'] = None): + *, engine: Literal['rdkit', 'smilesdrawer', 'native'] = None): """ Calculate 2d layout of graph. @@ -81,6 +65,9 @@ def clean2d(self: Union['MoleculeContainer', 'Calculate2DMolecule'], if engine is None: from chython import clean2d_engine as engine + if engine == 'native': + return self._clean2d() + plane = {} if engine == 'rdkit': from rdkit.Chem.AllChem import Compute2DCoords @@ -115,7 +102,7 @@ def clean2d(self: Union['MoleculeContainer', 'Calculate2DMolecule'], xm, ym = plane[m] bonds.append(sqrt((xm - xn) ** 2 + (ym - yn) ** 2)) if bonds: - bond_reduce = sum(bonds) / len(bonds) / .825 + bond_reduce = sum(bonds) / len(bonds) / BL else: bond_reduce = 1. @@ -129,10 +116,9 @@ def clean2d(self: Union['MoleculeContainer', 'Calculate2DMolecule'], shift_x = self._fix_plane_mean(shift_x, component=c) + .9 self.__dict__.pop('__cached_method__repr_svg_', None) - - def clean2d(self: Union['MoleculeContainer', 'Calculate2DMolecule'], *, - kk_outer_iterations: int = 1000, kk_outer_threshold: float =.1, - kk_inner_iterations: int = 50, kk_inner_threshold: float =.1): + def _clean2d(self: Union['MoleculeContainer', 'Calculate2DMolecule'], *, + kk_outer_iterations: int = 1000, kk_outer_threshold: float =.1, + kk_inner_iterations: int = 50, kk_inner_threshold: float =.1): """ Calculate 2d layout of graph. https://pubs.acs.org/doi/10.1021/acs.jcim.7b00425 JS implementation used as a reference. @@ -148,422 +134,16 @@ def clean2d(self: Union['MoleculeContainer', 'Calculate2DMolecule'], *, tail.insert(0, component) elif len(component) > 2: components.append(component) - # mark atoms as non-positioned - for n in component: - atoms[n].xy = (nan, nan) + self._apply_kamada_kawai(component) else: # len == 1: just a dot. no need for layout calculation tail.append(component) - if components: - # preset coordinates with templates - groups = self._apply_2d_templates() - # apply KK to process bridged rings - # groups = self._apply_kamada_kawai(groups, kk_outer_iterations, kk_inner_iterations, - # kk_outer_threshold, kk_inner_threshold) - - for component in components: - if all(component != g.keys() for g in groups): # check for fully layouted component - self._position_atoms(component, [g for g in groups if not component.isdisjoint(g)]) - components.extend(tail) shift_x = 0 for component in components: shift_x = self._fix_plane_mean(shift_x, component=component) + .9 self.__dict__.pop('__cached_method__repr_svg_', None) - def _initialize_positioning(self, component, groups): - """ - Prepare starting point and previous atom for angle calculation - """ - atoms = self._atoms - bonds = self._bonds - - directions = {} - if not groups: - # nothing pre-layouted. pick any ring atom if exists or any terminal atom. - for n in component: - if atoms[n].in_ring: - m = next(m for m in bonds[n] if atoms[m].in_ring) - atoms[n].xy = (0., 0.) - atoms[m].xy = (0., BL) # place 1st and second ring atoms always vertical - return [(m, n, D90, -1)], directions - - # pick any terminal atom. we have tree-like molecule without rings. - # we for sure have at least 3 atoms in a row, thus, we have to layout at least 1 extra atom. - for n in component: - ms = bonds[n] - if len(ms) == 1: - m = next(iter(ms)) - atoms[n].xy = (0., 0.) - atoms[m].xy = Vector(BL, 0).rotate(D30) # place second atom always top-right - return [(m, n, D30, -1)], directions - - stack = [] - for group in groups: - for n, d in group.items(): - if not d: - continue - directions[n] = d - stack.append((n, None, None, -1)) # directions already defined. no need for previous and angle - return stack, directions - - def _rotate_group(self, group, point: Vector, angle): - """ - Rotate the whole group around given point - """ - atoms = self._atoms - - for n in group: - an = atoms[n] - an.xy = an.xy.rotate(angle, point) - - def _shift_group(self, group, shift: Vector): - """ - Shift the whole group by given vector - """ - atoms = self._atoms - - for n in group: - atoms[n].xy += shift - - def _reset_group(self, current, n, xy, groups): - ac = self._atoms[current] - an = self._atoms[n] - g = next(g for g in groups if n in g) - angle = xy.angle() + D180 - g[n][current].angle() - self._shift_group(g, ac.xy + xy - an.xy) - self._rotate_group(g, an.xy, angle) - return {x: {z: a.rotate(angle) for z, a in y.items()} for x, y in g.items() if y} - - def _position_atoms(self, component, groups): - atoms = self._atoms - bonds = self._bonds - ctc = self._stereo_cis_trans_centers - - seen = set() - stack, directions = self._initialize_positioning(component, groups) - while stack: - current, previous, angle, sign = stack.pop() - if current in seen: - continue - seen.add(current) - - ac = atoms[current] - if current in directions: - for n, xy in directions[current].items(): - an = atoms[n] - if not isnan(an.x): - seen.add(n) - # reached layouted fragment. rotate the whole fragment. - directions.update(self._reset_group(current, n, xy, groups)) - else: - an.xy = ac.xy + xy - stack.append((n, current, xy.angle(), sign)) - continue - - env = bonds[current] - if len(env) == 1: - # layouting of the branch/molecule is finished - continue - - # chiral cis-trans case. - elif env[previous] == DOUBLE: - if b := ctc.get(current): - if (s := self.bond(*b).stereo) is not None: - for n, current, angle, sign, xy in self._position_cis_trans(current, previous, angle, s): - an = atoms[n] - if not isnan(an.x): - # reached layouted fragment. rotate the whole fragment and drop chain. - seen.add(n) - directions.update(self._reset_group(current, n, xy, groups)) - else: - an.xy = ac.xy + xy - stack.append((n, current, angle, sign)) - continue - - # simple non-chiral cases - if len(env) == 2: - n = next(n for n in env if n != previous) - if ac.hybridization == 3: - # keep the same direction - stack.append((n, current, angle, sign)) - else: - angle += sign * D60 - stack.append((n, current, angle, -sign)) - xy = Vector(BL, 0).rotate(angle) - an = atoms[n] - if not isnan(an.x): - # reached layouted fragment. rotate the whole fragment and drop chain. - stack.pop() - seen.add(n) # prevent double processing in cases of 2 KK layouted fragments linked by chain - directions.update(self._reset_group(current, n, xy, groups)) - else: - an.xy = ac.xy + xy - elif len(env) == 3: - n, m = (n for n in env if n != previous) - # continue to grow to the same direction - angle += sign * D60 - stack.append((n, current, angle, -sign)) - xy = Vector(BL, 0).rotate(angle) - an = atoms[n] - if not isnan(an.x): - # reached layouted fragment. rotate the whole fragment and drop chain. - stack.pop() - seen.add(n) - directions.update(self._reset_group(current, n, xy, groups)) - else: - an.xy = ac.xy + xy - - # make a side branch - angle -= sign * D120 - stack.append((m, current, angle, sign)) - xy = Vector(BL, 0).rotate(angle) - am = atoms[m] - if not isnan(am.x): - # reached layouted fragment. rotate the whole fragment and drop chain. - stack.pop() - seen.add(m) - directions.update(self._reset_group(current, m, xy, groups)) - else: - am.xy = ac.xy + xy - else: # 4+ neighbors. position on circle - delta = D360 / len(env) - angle += D180 - for n in env: - if n != previous: - angle += delta - xy = Vector(BL, 0).rotate(angle) - stack.append((n, current, angle, sign)) # keep sign to minimize overlaps - an = atoms[n] - if not isnan(an.x): - # reached layouted fragment. rotate the whole fragment and drop chain. - stack.pop() - seen.add(n) - directions.update(self._reset_group(current, n, xy, groups)) - else: - an.xy = ac.xy + xy - - def _position_cis_trans(self, current, previous, angle, s): - """ - cis-trans case. we came from a cumulene chain, thus, we have one layouted end - """ - atoms = self._atoms - ctt = self._stereo_cis_trans_terminals - cte = self.stereogenic_cis_trans - - t1, t2 = ts = ctt[current] - n11, n21, n12, n22 = cte[ts] - - ac = atoms[current] - env = self._bonds[current] - if len(env) == 3: - n1, n2 = (n for n in env if n != previous) - else: # env == 2 - n1 = next(n for n in env if n != previous) - n2 = None - - if n1 == n11: # picked 1st atom. no need to switch stereo sigh - if not isnan(atoms[n21].x): - m = n21 # picked 1st atom. no need to switch stereo sign - elif not isnan(atoms[n22].x): # stereo sign switch - m = n22 - s = not s - else: - raise ImplementationError - counter = t2 - elif n1 == n12: # picked 2nd atom. stereo sign switch - if not isnan(atoms[n21].x): - m = n21 - s = not s - elif not isnan(atoms[n22].x): # picked 2nd atom. double stereo-switch. keep as is. - m = n22 - else: - raise ImplementationError - counter = t2 - elif n1 == n21: - if not isnan(atoms[n11].x): - m = n11 - elif not isnan(atoms[n12].x): - m = n12 - s = not s - else: - raise ImplementationError - counter = t1 - else: - if not isnan(atoms[n11].x): - m = n11 - s = not s - elif not isnan(atoms[n12].x): - m = n12 - else: - raise ImplementationError - counter = t1 - - vt = atoms[counter].xy - if (atoms[m].xy - vt) @ (ac.xy - vt) > 0: - sign = 1 if s else -1 - else: - sign = -1 if s else 1 - - angle -= sign * D60 - xy = Vector(BL, 0).rotate(angle) - yield n1, current, angle, sign, xy - if n2: - angle += sign * D120 - xy = Vector(BL, 0).rotate(angle) - yield n2, current, angle, -sign, xy - - def _apply_2d_templates(self): - """ - Use predefined templates to layout atoms. - """ - atoms = self._atoms - bonds = self._bonds - - seen = set() - groups = [] - for q, layout, sub in rules: - for mp in q.get_mapping(self, automorphism_filter=False): - if not seen.isdisjoint(mp.values()): # avoid any overlap - continue - seen.update(mp.values()) - g = {n: None for n in mp.values()} - groups.append(g) - for i, n in mp.items(): - atoms[n].xy = layout[i] - - for i, n in mp.items(): - env = bonds[n] - if not (s := env.keys() - g.keys()): - continue - an = atoms[n] - if len(s) == 1 and i in sub: - xy = Vector(*sub[i]) - g[n] = {s.pop(): xy - an.xy} - continue - - v, c = Vector(0, 0), -1 - for m in env.keys() & g.keys(): - v += (atoms[m].xy - an.xy).normalise() - c += 1 - delta = D360 / len(env) - angle = v.angle() + delta * c / 2 # ideal position of frontal layouted atom - g[n] = {m: Vector(BL, 0).rotate(angle + delta * x) for x, m in enumerate(s, 1)} - return groups - - def _apply_kamada_kawai(self, groups, outer_iterations, inner_iterations, outer_threshold, inner_threshold): - atoms = self._atoms - - solved = [] - for cluster, length, strength, coordinates, mapping in self._initialize_kamada_kawai(groups): - pi = -1 - for _ in range(outer_iterations): - diff = coordinates[:, None, :] - coordinates[None, :, :] # NxNx2 - sdiff = diff * diff - energy = diff * (strength * (1 - length / (sqrt(sdiff.sum(-1)) + 1e-5)))[:, :, None] # NxNx2 - forces = energy.sum(1) # Nx2 - total = (forces ** 2).sum(-1) # N - - # pick an atom with the highest force/energy - i = argmax(total) - if i == pi: - total[i] = 0 - i = argmax(total) - pi = i - if total[i] <= outer_threshold: - # if it less than threshold, we have solved system. finish. - break - - li = length[i] # N - si = strength[i] # N - diff_i = diff[i] # Nx2 - sdiff_i = sdiff[i] # Nx2 - for _ in range(inner_iterations): - norm = li / (sdiff_i.sum(-1) ** 1.5 + 1e-5) - dxx, dyy = (si[:, None] * (1 - norm[:, None] * sdiff_i)).sum(0).tolist() - dxy = float((si * norm * diff_i.prod(-1)).sum()) - if abs(dxy) < 0.1: - dxy = 0.1 if dxy > 0 else -0.1 - if abs(dxx) < 0.1: - dxx = 0.1 if dxx > 0 else -0.1 - - d_ex, d_ey = forces[i].tolist() - dy = (d_ex / dxx + d_ey / dxy) / (dxy / dxx - dyy / dxy) - dx = -(dxy * dy + d_ex) / dxx - coordinates[i] += (dx, dy) - - # update forces - diff_i = coordinates[i] - coordinates # Nx2 - sdiff_i = diff_i * diff_i # Nx2 - energy_i = diff_i * (si * (1 - li / (sqrt(sdiff_i.sum(-1)) + 1e-5)))[:, None] # Nx2 - forces[i] = energy_i.sum(0) # 2 - total[i] = (forces[i] ** 2).sum() # 1 - - if total[i] <= inner_threshold: - # local minima for i-th atom found. - break - - for n in cluster: - atoms[n].xy = coordinates[mapping[n]].tolist() - solved.append(cluster) - return solved - - def _initialize_kamada_kawai(self, groups): - atoms = self._atoms - bonds = self._bonds - clusters = [{n} | bonds[n].keys() for n, a in atoms.items() if a.in_ring] - clusters.extend(g | {m for n in g for m in bonds[n]} for g in groups) # add layouted groups - solved = [] - while clusters: - c1 = clusters.pop() - for c2 in clusters: - if not c1.isdisjoint(c2): - c2.update(c1) - break - else: - if c1 in groups: - continue - solved.append(c1) - - for cluster in solved: - mapping = {n: i for i, n in enumerate(cluster)} - - adj = zeros((len(cluster), len(cluster))) - angles = linspace(0, D360, len(cluster) + 1)[:-1] - coordinates = column_stack([cos(angles), sin(angles)]) * RADIUS - - # create adjacency matrix - for n in cluster: - i = mapping[n] - for m in bonds[n].keys() & cluster: - j = mapping[m] - adj[i, j] = BL - - layouted = [] - for g in groups: - if g.isdisjoint(cluster): - continue - # for pre-layouted groups calc pairwise distances - for n, m in combinations(g, 2): - d = atoms[n].xy | atoms[m].xy - i, j = mapping[n], mapping[m] - adj[i, j] = adj[j, i] = d - layouted.extend(g) - length = shortest_path(adj, method='FW', directed=False) - # originally used BL / (topological distance)**2 - # here distance is already BL scaled: BL**3 / (BL*TD)**2 = BL**3 / BL**2 / TD **2 = BL / TD ** 2 - # but we have prelayouted atoms with bonds != BL. Let's just assume they are close enough. - # adj magic here to reset strength of layouted groups to actual distances. - with errstate(divide='ignore'): - strength = nan_to_num(BL**3 / (length ** 2), posinf=0) * (adj == 0) + adj - - if layouted: - center = sum((atoms[n].xy for n in layouted), Vector(0, 0)) / len(layouted) - for n in layouted: - coordinates[mapping[n], :] = tuple(atoms[n].xy - center) - yield cluster, length, strength, coordinates, mapping - - def _fix_plane_mean(self, shift_x: float, shift_y=0., component=None) -> float: atoms = self._atoms if component is None: