Source code for pywindow._internal.molecular

"""Defines :class:`MolecularSystem` and :class:`Molecule` classes.

This module is the most important part of the ``pywindow`` package, as it is
at the frontfront of the interaction with the user. The two main classes
defined here: :class:`MolecularSystem` and :class:`Molecule` are used to
store and analyse single molecules or assemblies of single molecules.

The :class:`MolecularSystem` is used as a first step to the analysis. It allows
to load data, to refine it (rebuild molecules in a periodic system, decipher
force field atom ids) and to extract single molecules for analysis as
:class:`Molecule` instances.

To get started see :class:`MolecularSystem`.

To get started with the analysis of Molecular Dynamic trajectories go to
:mod:`pywindow.trajectory`.

"""

from __future__ import annotations

import pathlib
from copy import deepcopy
from typing import TYPE_CHECKING

import numpy as np

from pywindow._internal.io_tools import Input, Output
from pywindow._internal.utilities import (
    align_principal_ax,
    center_of_mass,
    create_supercell,
    decipher_atom_key,
    discrete_molecules,
    find_average_diameter,
    find_windows,
    max_dim,
    molecular_weight,
    opt_pore_diameter,
    pore_diameter,
    shift_com,
    sphere_volume,
    to_list,
)

if TYPE_CHECKING:
    import rdkit


class _MolecularSystemError(Exception):
    def __init__(self, message: str) -> None:
        self.message = message


class _NotAModularSystemError(Exception):
    def __init__(self, message: str) -> None:
        self.message = message


[docs] class Molecule: """Container for a single molecule. This class is meant for the analysis of single molecules, molecular pores especially. The object passed to this class should therefore be a finite and interconnected individuum. This class should not be initialised directly, but result from :func:`MolecularSystem.system_to_molecule()` or :func:`MolecularSystem.make_modular()`. Methods in :class:`Molecule` allow to calculate: 1. The maximum diameter of a molecule. 2. The average diameter of a molecule. 3. The intrinsic void diameter of a molecule. 4. The intrinsic void volume of a molecule. 5. The optimised intrinsic void diameter of a molecule. 6. The optimised intrinsic void volume of a molecule. 7. The circular diameter of a window of a molecule. Attributes: mol : :class:`dict` The :attr:`Molecular.System.system` dictionary passed to the :class:`Molecule` which is esentially a container of the information that compose a molecular entity, such as the coordinates and atom ids and/or elements. no_of_atoms : :class:`int` The number of atoms in the molecule. elements : :class:`numpy.array` An array containing the elements, as strings, composing the molecule. atom_ids : :class:`numpy.array` (conditional) If the :attr:`Molecule.mol` contains 'atom_ids' keyword, the force field ids of the elements. coordinates : :class:`numpy.array` The x, y and z atomic Cartesian coordinates of all elements. parent_system : :class:`str` The :attr:`name` of :class:`MolecularSystem` passed to :class:`Molecule`. molecule_id : :class:`any` The molecule id passed when initialising :class:`Molecule`. properties : :class:`dict` A dictionary that is populated by the output of :class:`Molecule` methods. """ def __init__(self, mol: dict, system_name: str, mol_id: int) -> None: # type:ignore[type-arg] self._Output = Output() self.mol = mol self.no_of_atoms = len(mol["elements"]) self.elements = mol["elements"] if "atom_ids" in mol: self.atom_ids = mol["atom_ids"] self.coordinates = mol["coordinates"] self.parent_system = system_name self.molecule_id = mol_id self.properties = {"no_of_atoms": self.no_of_atoms} self._windows = None
[docs] @classmethod def load_rdkit_mol( cls, mol: rdkit.Chem.rdchem.Mol, system_name: str = "rdkit", mol_id: int = 0, ) -> Molecule: """Create a :class:`Molecule` from :class:`rdkit.Chem.rdchem.Mol`. To be used only by expert users. Parameters: mol : :class:`rdkit.Chem.rdchem.Mol` An RDKit molecule object. Returns: :class:`pywindow.Molecule` """ return cls(Input().load_rdkit_mol(mol), system_name, mol_id)
[docs] def full_analysis(self, ncpus: int = 1) -> dict: # type:ignore[type-arg] """Perform a full structural analysis of a molecule. This invokes other methods: 1. :attr:`molecular_weight()` 2. :attr:`calculate_centre_of_mass()` 3. :attr:`calculate_maximum_diameter()` 4. :attr:`calculate_average_diameter()` 5. :attr:`calculate_pore_diameter()` 6. :attr:`calculate_pore_volume()` 7. :attr:`calculate_pore_diameter_opt()` 8. :attr:`calculate_pore_volume_opt()` 9. :attr:`calculate_pore_diameter_opt()` 10. :attr:`calculate_windows()` Parameters: ncpus : :class:`int` Number of CPUs used for the parallelised parts of :func:`pywindow.utilities.find_windows()`. (default=1=serial) Returns: :attr:`Molecule.properties` The updated :attr:`Molecule.properties` with returns of all used methods. """ self.molecular_weight() self.calculate_centre_of_mass() self.calculate_maximum_diameter() self.calculate_average_diameter() self.calculate_pore_diameter() self.calculate_pore_volume() self.calculate_pore_diameter_opt() self.calculate_pore_volume_opt() self.calculate_windows(ncpus=ncpus) return self.properties
def _align_to_principal_axes(self, align_molsys: bool = False) -> None: # noqa: FBT001, FBT002 if align_molsys: raise NotImplementedError # self.coordinates[0] = align_principal_ax_all( # self.elements, self.coordinates self.coordinates[0] = align_principal_ax( self.elements, self.coordinates ) self.aligned_to_principal_axes = True
[docs] def calculate_centre_of_mass(self) -> np.ndarray: # type:ignore[type-arg] """Return the xyz coordinates of the centre of mass of a molecule. Returns: The centre of mass of the molecule. """ self.centre_of_mass = center_of_mass(self.elements, self.coordinates) self.properties["centre_of_mass"] = self.centre_of_mass # type:ignore[assignment] return self.centre_of_mass
[docs] def calculate_maximum_diameter(self) -> float: """Return the maximum diamension of a molecule. Returns: The maximum dimension of the molecule. """ self.maxd_atom_1, self.maxd_atom_2, self.maximum_diameter = max_dim( self.elements, self.coordinates ) self.properties["maximum_diameter"] = { # type:ignore[assignment] "diameter": self.maximum_diameter, "atom_1": int(self.maxd_atom_1), "atom_2": int(self.maxd_atom_2), } return self.maximum_diameter
[docs] def calculate_average_diameter(self) -> float: """Return the average diamension of a molecule. Returns: The average dimension of the molecule. """ self.average_diameter = find_average_diameter( self.elements, self.coordinates ) self.properties["average_diameter"] = self.average_diameter # type:ignore[assignment] return self.average_diameter
[docs] def calculate_pore_diameter(self) -> float: """Return the intrinsic pore diameter. Returns: The intrinsic pore diameter. """ self.pore_diameter, self.pore_closest_atom = pore_diameter( self.elements, self.coordinates ) self.properties["pore_diameter"] = { # type:ignore[assignment] "diameter": self.pore_diameter, "atom": int(self.pore_closest_atom), } return self.pore_diameter
[docs] def calculate_pore_volume(self) -> float: """Return the intrinsic pore volume. Returns: The intrinsic pore volume. """ self.pore_volume = sphere_volume(self.calculate_pore_diameter() / 2) self.properties["pore_volume"] = self.pore_volume # type:ignore[assignment] return self.pore_volume
[docs] def calculate_pore_diameter_opt(self) -> float: """Return the intrinsic pore diameter (for the optimised pore centre). Similarly to :func:`calculate_pore_diameter` this method returns the the intrinsic pore diameter, however, first a better approximation of the pore centre is found with optimisation. Returns: The intrinsic pore diameter. """ ( self.pore_diameter_opt, self.pore_opt_closest_atom, self.pore_opt_COM, ) = opt_pore_diameter(self.elements, self.coordinates) self.properties["pore_diameter_opt"] = { # type:ignore[assignment] "diameter": self.pore_diameter_opt, "atom_1": int(self.pore_opt_closest_atom), "centre_of_mass": self.pore_opt_COM, } return self.pore_diameter_opt
[docs] def calculate_pore_volume_opt(self) -> float: """Return the intrinsic pore volume (for the optimised pore centre). Similarly to :func:`calculate_pore_volume` this method returns the the volume intrinsic pore diameter, however, for the :func:`calculate_pore_diameter_opt` returned value. Returns: The intrinsic pore volume. """ self.pore_volume_opt = sphere_volume( self.calculate_pore_diameter_opt() / 2 ) self.properties["pore_volume_opt"] = self.pore_volume_opt # type:ignore[assignment] return self.pore_volume_opt
[docs] def calculate_windows(self, ncpus: int = 1) -> np.ndarray | None: # type:ignore[type-arg] """Return the diameters of all windows in a molecule. This function first finds and then measures the diameters of all the window in the molecule. Returns: An array of windows' diameters. Or, None, ff no windows were found. """ windows = find_windows( self.elements, self.coordinates, processes=ncpus, ) if windows is not None: self.properties.update( { "windows": { # type:ignore[dict-item] "diameters": windows[0], "centre_of_mass": windows[1], } } ) return windows[0] self.properties.update( {"windows": {"diameters": None, "centre_of_mass": None}} # type:ignore[dict-item] ) return None
[docs] def shift_to_origin(self) -> None: """Shift a molecule to Origin. This function takes the molecule's coordinates and adjust them so that the centre of mass of the molecule coincides with the origin of the coordinate system. Returns: None : :class:`NoneType` """ self.coordinates = shift_com(self.elements, self.coordinates) self._update()
[docs] def molecular_weight(self) -> float: """Return the molecular weight of a molecule. Returns: :class:`float` The molecular weight of the molecule. """ self.MW = molecular_weight(self.elements) return float(self.MW)
[docs] def dump_properties_json( self, filepath: pathlib.Path | str | None = None, molecular: bool = False, # noqa: FBT001, FBT002 override: bool = False, # noqa: FBT001, FBT002 ) -> None: """Dump content of :attr:`Molecule.properties` to a JSON dictionary. Parameters: filepath: The filepath for the dumped file. If :class:`None`, the file is dumped localy with :attr:`molecule_id` as filename. (defualt=None) molecular: If False, dump only the content of :attr:`Molecule.properties`, if True, dump all the information about :class:`Molecule`. override: If True, any file in the filepath will be override. (default=False) """ # We pass a copy of the properties dictionary. dict_obj = deepcopy(self.properties) # If molecular data is also required we update the dictionary. if molecular is True: dict_obj.update(self.mol) # If no filepath is provided we create one. if filepath is None: filepath = pathlib.Path(f"{self.parent_system}_{self.molecule_id}") filepath = pathlib.Path.cwd() / filepath filepath = pathlib.Path(filepath) # Dump the dictionary to json file. self._Output.dump2json( dict_obj, filepath, default=to_list, override=override, )
[docs] def dump_molecule( self, filepath: pathlib.Path | str | None = None, include_coms: bool = False, # noqa: FBT001, FBT002 override: bool = False, # noqa: FBT001, FBT002 ) -> None: """Dump a :class:`Molecule` to a file (PDB or XYZ). For validation purposes an overlay of window centres and COMs can also be dumped as: He - for the centre of mass Ne - for the centre of the optimised cavity Ar - for the centres of each found window Parameters: filepath: The filepath for the dumped file. If :class:`None`, the file is dumped locally with :attr:`molecule_id` as filename. (default=None) include_coms: If True, dump also with an overlay of window centres and COMs. (default=False) override: If True, any file in the filepath will be override. (default=False) """ # If no filepath is provided we create one. if filepath is None: filepath = pathlib.Path( f"{self.parent_system}_{self.molecule_id}.pdb" ) filepath = pathlib.Path.cwd() / filepath filepath = f"{filepath}.pdb" filepath = pathlib.Path(filepath) # Check if there is an 'atom_ids' keyword in the self.mol dict. # Otherwise pass to the dump2file atom_ids='elements'. atom_ids_key = "elements" if "atom_ids" not in self.mol else "atom_ids" # Dump molecule into a file. # If coms are to be included additional steps are required. # First deepcopy the molecule if include_coms is True: mmol = deepcopy(self.mol) # add centre of mass (centre of not optimised pore) as 'He'. mmol["elements"] = np.concatenate( (mmol["elements"], np.array(["He"])) ) if "atom_ids" not in self.mol: pass else: mmol["atom_ids"] = np.concatenate( (mmol["atom_ids"], np.array(["He"])) ) mmol["coordinates"] = np.concatenate( ( mmol["coordinates"], np.array([self.properties["centre_of_mass"]]), ) ) # add centre of pore optimised as 'Ne'. mmol["elements"] = np.concatenate( (mmol["elements"], np.array(["Ne"])) ) if "atom_ids" not in self.mol: pass else: mmol["atom_ids"] = np.concatenate( (mmol["atom_ids"], np.array(["Ne"])) ) mmol["coordinates"] = np.concatenate( ( mmol["coordinates"], np.array( [ self.properties["pore_diameter_opt"][ # type:ignore[index] "centre_of_mass" ] ] ), ) ) # add centre of windows as 'Ar'. if self.properties["windows"]["centre_of_mass"] is not None: # type:ignore[index] range_ = range( len(self.properties["windows"]["centre_of_mass"]) # type:ignore[index] ) for com in range_: mmol["elements"] = np.concatenate( (mmol["elements"], np.array(["Ar"])) ) if "atom_ids" not in self.mol: pass else: mmol["atom_ids"] = np.concatenate( (mmol["atom_ids"], np.array([f"Ar{com + 1}"])) ) mmol["coordinates"] = np.concatenate( ( mmol["coordinates"], np.array( [ self.properties["windows"][ # type:ignore[index] "centre_of_mass" ][com] ] ), ) ) self._Output.dump2file( mmol, filepath, atom_ids_key=atom_ids_key, override=override, ) else: self._Output.dump2file( self.mol, filepath, atom_ids_key=atom_ids_key, override=override, )
def _update(self) -> None: self.mol["coordinates"] = self.coordinates self.calculate_centre_of_mass() self.calculate_pore_diameter_opt()
[docs] class MolecularSystem: """Container for the molecular system. To load input and initialise :class:`MolecularSystem`, one of the :class:`MolecularSystem` classmethods (:func:`load_file()`, :func:`load_rdkit_mol()` or :func:`load_system()`) should be used. :class:`MolecularSystem` **should not be initialised by itself.** Examples: 1. Using file as an input: .. code-block:: python pywindow.MolecularSystem.load_file("filepath") 2. Using RDKit molecule object as an input: .. code-block:: python pywindow.MolecularSystem.load_rdkit_mol(rdkit.Chem.rdchem.Mol) 3. Using a dictionary (or another :attr:`MoleculeSystem.system`) as input: .. code-block:: python pywindow.MolecularSystem.load_system({...}) Attributes: system_id : :class:`str` or :class:`int` The input filename or user defined. system : :class:`dict` A dictionary containing all the information extracted from input. molecules: A dictionary containing all the returned :class:`Molecule` s after using :func:`make_modular()`. """ def __init__(self) -> None: self._Input = Input() self._Output = Output() self.system_id: str | int = 0 self.system: dict = {} # type: ignore[type-arg] self.molecules: dict[int | str, Molecule] = {}
[docs] @classmethod def load_file(cls, filepath: pathlib.Path | str) -> MolecularSystem: """Create a :class:`MolecularSystem` from an input file. Recognized input file formats: XYZ, PDB and MOL (V3000). Parameters: filepath: The input's filepath. Returns: :class:`pywindow.MolecularSystem` """ filepath = pathlib.Path(filepath) obj = cls() obj.system = obj._Input.load_file(filepath) obj.filename = filepath.name # type: ignore[attr-defined] obj.system_id = obj.filename.split(".")[0] # type: ignore[attr-defined] obj.name, ext = obj.filename.split(".") # type: ignore[attr-defined] return obj
[docs] @classmethod def load_rdkit_mol(cls, mol: rdkit.Chem.Mol) -> MolecularSystem: """Create a :class:`MolecularSystem` from :class:`rdkit.Chem.Mol`. Parameters: mol: An RDKit molecule object. Returns: :class:`pywindow.MolecularSystem` """ obj = cls() obj.system = obj._Input.load_rdkit_mol(mol) return obj
[docs] @classmethod def load_system( cls, dict_: dict, # type: ignore[type-arg] system_id: str | int = "system", ) -> MolecularSystem: """Create a :class:`MolecularSystem` from a python :class:`dict`. As the loaded :class:`MolecularSystem` is storred as a :class:`dict` in the :class:`MolecularSystem.system` it can also be loaded directly from a :class:`dict` input. This feature is used by :mod:`trajectory` that extracts trajectory frames as dictionaries and returns them as :class:`MolecularSystem` objects through this classmethod. Parameters: dict_: A python dictionary. system_id: Inherited or user defined system id. (default='system') Returns: :class:`pywindow.MolecularSystem` """ obj = cls() obj.system = dict_ obj.system_id = system_id return obj
[docs] def rebuild_system(self, override: bool = False) -> MolecularSystem: # noqa: FBT001, FBT002 """Rebuild molecules in molecular system. Parameters: override : :class:`bool`, optional (default=False) If False the rebuild molecular system is returned as a new :class:`MolecularSystem`, if True, the current :class:`MolecularSystem` is modified. """ # First we create a 3x3x3 supercell with the initial unit cell in the # centre and the 26 unit cell translations around to provide all the # atom positions necessary for the molecules passing through periodic # boundary reconstruction step. supercell_333 = create_supercell(self.system) discrete = discrete_molecules(self.system, rebuild=supercell_333) # This function overrides the initial data for 'coordinates', # 'atom_ids', and 'elements' instances in the 'system' dictionary. coordinates = np.array([], dtype=np.float64).reshape(0, 3) atom_ids = np.array([]) elements = np.array([]) for i in discrete: coordinates = np.concatenate( [coordinates, i["coordinates"]], axis=0 ) atom_ids = np.concatenate([atom_ids, i["atom_ids"]], axis=0) elements = np.concatenate([elements, i["elements"]], axis=0) rebuild_system = { "coordinates": coordinates, "atom_ids": atom_ids, "elements": elements, } if override is True: self.system.update(rebuild_system) return self.load_system(rebuild_system)
[docs] def swap_atom_keys( self, swap_dict: dict, # type: ignore[type-arg] dict_key: str = "atom_ids", ) -> None: """Swap a force field atom id for another user-defined value. This modified all values in :attr:`MolecularSystem.system['atom_ids']` that match criteria. This function can be used to decipher a whole forcefield if an appropriate dictionary is passed to the function. Example: In this example all atom ids 'he' will be exchanged to 'H'. .. code-block:: python pywindow.MolecularSystem.swap_atom_keys({'he': 'H'}) Parameters: swap_dict: A dictionary containg force field atom ids (keys) to be swapped with corresponding values (keys' arguments). dict_key: A key in :attr:`MolecularSystem.system` dictionary to perform the atom keys swapping operation on. (default='atom_ids') Returns: None : :class:`NoneType` """ # Similar situation to the one from decipher_atom_keys function. if "atom_ids" not in self.system: dict_key = "elements" for atom_key in range(len(self.system[dict_key])): for key, value in swap_dict.items(): if self.system[dict_key][atom_key] == key: self.system[dict_key][atom_key] = value
[docs] def decipher_atom_keys( self, forcefield: str = "DLF", dict_key: str = "atom_ids" ) -> None: """Decipher force field atom ids. This takes all values in :attr:`MolecularSystem.system['atom_ids']` that match force field type criteria and creates :attr:`MolecularSystem.system['elements']` with the corresponding periodic table of elements equivalents. If a forcefield is not supported by this method, the :func:`MolecularSystem.swap_atom_keys()` can be used instead. DLF stands for DL_F notation. See: C. W. Yong, Descriptions and Implementations of DL_F Notation: A Natural Chemical Expression System of Atom Types for Molecular Simulations, J. Chem. Inf. Model., 2016, 56, 1405-1409. Parameters: forcefield: The forcefield used to decipher atom ids. Allowed (not case sensitive): 'OPLS', 'OPLS2005', 'OPLSAA', 'OPLS3', 'DLF', 'DL_F'. (default='DLF') dict_key: The :attr:`MolecularSystem.system` dictionary key to the array containing the force field atom ids. (default='atom_ids') Returns: None : :class:`NoneType` """ # In case there is no 'atom_ids' key we try 'elements'. This is for # XYZ and MOL files mostly. But, we keep the dict_key keyword for # someone who would want to decipher 'elements' even if 'atom_ids' key # is present in the system's dictionary. if "atom_ids" not in self.system: dict_key = "elements" # I do it on temporary object so that it only finishes when successful temp = deepcopy(self.system[dict_key]) for element in range(len(temp)): temp[element] = ( f"{decipher_atom_key(temp[element], forcefield=forcefield)}" ) self.system["elements"] = temp
[docs] def make_modular(self, rebuild: bool = False) -> None: # noqa: FBT001, FBT002 """Find and return all :class:`Molecule` s in :class:`MolecularSystem`. This function populates :attr:`MolecularSystem.molecules` with :class:`Molecule` s. Parameters: rebuild: If True, run first the :func:`rebuild_system()`. Returns: None : :class:`NoneType` """ if rebuild is True: supercell_333 = create_supercell(self.system) else: supercell_333 = None dis = discrete_molecules(self.system, rebuild=supercell_333) self.no_of_discrete_molecules = len(dis) self.molecules = {} for i in range(len(dis)): self.molecules[i] = Molecule( mol=dis[i], system_name=str(self.system_id), mol_id=i, )
[docs] def system_to_molecule(self) -> Molecule: """Return :class:`MolecularSystem` as a :class:`Molecule` directly. Only to be used conditionally, when the :class:`MolecularSystem` is a discrete molecule and no input pre-processing is required. Returns: :class:`pywindow.Molecule` """ return Molecule( mol=self.system, system_name=str(self.system_id), mol_id=0 )
[docs] def dump_system( self, filepath: pathlib.Path | str | None = None, modular: bool = False, # noqa: FBT001, FBT002 override: bool = False, # noqa: FBT001, FBT002 ) -> None: """Dump a :class:`MolecularSystem` to a file (PDB or XYZ). Parameters: filepath: The filepath for the dumped file. If :class:`None`, the file is dumped localy with :attr:`system_id` as filename. (defualt=None) modular: If False, dump the :class:`MolecularSystem` as in :attr:`MolecularSystem.system`, if True, dump the :class:`MolecularSystem` as catenated :class:Molecule objects from :attr:`MolecularSystem.molecules` override: If True, any file in the filepath will be override. (default=False) """ # If no filepath is provided we create one. if filepath is None: filepath = pathlib.Path.cwd() / f"{self.system_id}.pdb" filepath = pathlib.Path(filepath) # If modular is True substitute the molecular data for modular one. system_dict = deepcopy(self.system) if modular is True: elements = np.array([]) atom_ids = np.array([]) coor = np.array([]).reshape(0, 3) for mol in self.molecules.values(): elements = np.concatenate((elements, mol.mol["elements"])) atom_ids = np.concatenate((atom_ids, mol.mol["atom_ids"])) coor = np.concatenate((coor, mol.mol["coordinates"]), axis=0) system_dict["elements"] = elements system_dict["atom_ids"] = atom_ids system_dict["coordinates"] = coor # Check if there is an 'atom_ids' keyword in the self.mol dict. # Otherwise pass to the dump2file atom_ids='elements'. # This is mostly for XYZ files and not deciphered trajectories. atom_ids_key = ( "elements" if "atom_ids" not in system_dict else "atom_ids" ) # Dump system into a file. self._Output.dump2file( system_dict, filepath, atom_ids_key=atom_ids_key, override=override, )
[docs] def dump_system_json( self, filepath: pathlib.Path | str | None = None, modular: bool = False, # noqa: FBT001, FBT002 override: bool = False, # noqa: FBT001, FBT002 ) -> None: """Dump a :class:`MolecularSystem` to a JSON dictionary. The dumped JSON dictionary, with :class:`MolecularSystem`, can then be loaded through a JSON loader and then through :func:`load_system()` to retrieve a :class:`MolecularSystem`. Kwargs are passed to :func:`pywindow.io_tools.Output.dump2json()`. Parameters: filepath: The filepath for the dumped file. If :class:`None`, the file is dumped localy with :attr:`system_id` as filename. (defualt=None) modular: If False, dump the :class:`MolecularSystem` as in :attr:`MolecularSystem.system`, if True, dump the :class:`MolecularSystem` as catenated :class:Molecule objects from :attr:`MolecularSystem.molecules` override: If True, any file in the filepath will be override. (default=False) """ # We pass a copy of the properties dictionary. dict_obj = deepcopy(self.system) # In case we want a modular system. if modular is True: try: if self.molecules: pass except AttributeError: msg = ( "This system is not modular. Please, run first the " "make_modular() function of this class." ) raise _NotAModularSystemError(msg) from None dict_obj = {} for molecule, mol_ in self.molecules.items(): dict_obj[molecule] = mol_.mol # If no filepath is provided we create one. if filepath is None: filepath = pathlib.Path.cwd() / f"{self.system_id}" filepath = pathlib.Path(filepath) # Dump the dictionary to json file. self._Output.dump2json( dict_obj, filepath, default=to_list, override=override, )