"""Module defining the main classes used to access orbital data.
Data is organised hierarchically. The top-level |Orbitals| objects provide access to the |FMOs| and |MOs| objects, which provide access to individual |FMO| and |MO| objects.
The |Orbitals| objects serves as the loader of the calculation results.
Typical usage example:
orbs = Orbitals('path/to/adf.rkf')
"""
import pyorbb
from scm import plams
import functools
import os
from typing import List, Dict, Tuple, Union
import math
import re
_ensure_list = lambda x: [x] if not isinstance(x, (list, tuple, set)) else list(x) # noqa: E731
[docs]
class Orbital:
'''
Main class holding orbital information for |MO| and |FMO| objects.
This class is used to obtain information about the orbital, generate cube-files, and visualize orbitals.
'''
def __init__(self, data, parent):
self.data = data
for key, value in data.items():
setattr(self, key, value)
self.parent = parent
def __repr__(self):
return str(self)
@functools.cached_property
def relative_name(self) -> str:
'''
The relative name of the orbital. E.g. HOMO or HOMO-1
'''
orbitals = [orb for orb in self.parent.orbitals if orb.spin == self.spin and orb.spin_total_occupation == self.spin_total_occupation]
if hasattr(self, 'fragment'):
orbitals = [orb for orb in orbitals if orb.fragment == self.fragment]
energies = sorted([orb.energy for orb in orbitals])
order = energies.index(self.energy) + self.degeneracy_index
if self.doubly_occupied:
order = len(orbitals) - order - 1
return f'HOMO-{order}' if order > 0 else 'HOMO'
if self.singly_occupied or self.partially_occupied:
if round(self.occupation) == 1:
order = len(orbitals) - order - 1
return f'SOMO-{order}' if order > 0 else 'SOMO'
else:
return f'SUMO+{order}' if order > 0 else 'SUMO'
if self.unoccupied:
return f'LUMO+{order}' if order > 0 else 'LUMO'
@functools.cached_property
def symmetry_relative_name(self) -> str:
'''
The relative name of the orbital in its irreducible representation.
E.g. the overall HOMO-2 could be the HOMO of its irreducible representation.
'''
orbitals = [orb for orb in self.parent.orbitals if orb.spin == self.spin and orb.spin_total_occupation == self.spin_total_occupation and orb.symmetry == self.symmetry]
if hasattr(self, 'fragment'):
orbitals = [orb for orb in orbitals if orb.fragment == self.fragment]
energies = sorted([orb.energy for orb in orbitals])
order = energies.index(self.energy) + self.degeneracy_index
if self.doubly_occupied:
order = len(orbitals) - order - 1
return f'HOMO-{order}' if order > 0 else 'HOMO'
if self.singly_occupied or self.partially_occupied:
if round(self.occupation) == 1:
order = len(orbitals) - order - 1
return f'SOMO-{order}' if order > 0 else 'SOMO'
else:
return f'SUMO+{order}' if order > 0 else 'SUMO'
if self.unoccupied:
return f'LUMO+{order}' if order > 0 else 'LUMO'
@functools.cached_property
def fully_occupied(self) -> bool:
'''
Whether the orbital is fully occupied.
'''
if self.spin in ['A', 'B']:
return self.singly_occupied
if self.spin == 'AB':
return self.doubly_occupied
@functools.cached_property
def partially_occupied(self) -> bool:
'''
Whether the orbital is not empty and not fully occupied.
'''
return not self.unoccupied and not self.fully_occupied
@functools.cached_property
def doubly_occupied(self) -> bool:
'''
Whether the orbital is doubly occupied.
'''
return self.spin_total_occupation == 2
@functools.cached_property
def singly_occupied(self) -> bool:
'''
Whether the orbital is singly occupied.
'''
return self.spin_total_occupation == 1
@functools.cached_property
def unoccupied(self) -> bool:
'''
Whether the orbital is unoccupied.
'''
return self.spin_total_occupation == 0
@functools.cached_property
def spin_total_occupation(self) -> int:
'''
The occupation of this orbital plus its spin counterpart if it exists.
E.g. if orbital ``5A_A`` has an occupation of 1 and orbitals ``5A_B``has an
occupation of 0 then both orbitals will have the ``spin_total_occupation`` set to ``1``.
'''
return self.occupation + sum(orb.occupation for orb in self.spin_match_orbs)
@functools.cached_property
def spin_match_orbs(self) -> "Orbital":
matching_orbs = [orb for orb in self.parent.orbitals if orb.name == self.name]
if hasattr(self, 'fragment'):
matching_orbs = [orb for orb in matching_orbs if orb.fragment == self.fragment]
return [orb for orb in matching_orbs if orb != self]
[docs]
def cube_file(self, gridsize: str = 'medium', overwrite: bool = False, cube_file_prefix: str = None, preambles=[], grid_around_mol=None, gridextend=6):
'''
Generate a cube-file for this |Orbital| with a certain grid-size.
Args:
gridsize: the size of the grid to generate the cube-file with.
overwrite: whether to overwrite the previous calculation if found.
cube_file_prefix: prefix for the cube file path.
.. seealso::
:meth:`Orbital.draw` to draw and open a TCviewer screen showing this |Orbital|.
:meth:`Orbital.screenshot` to generate a screenshot of this |Orbital|.
'''
from tcmu.job.adf import DensfJob
from tcintegral import grid
# start a Densf job to calculate the cube-file.
# We want to return the cube-file, so we should wait for it to finish.
with DensfJob(wait_for_finish=True, overwrite=overwrite, cube_file_prefix=cube_file_prefix) as job:
[job.add_preamble(preamble) for preamble in preambles]
job.rundir = os.path.split(self.parent.parent.kfpath)[0]
job.name = 'densf'
if isinstance(self, FMO):
job._sfos.append(self)
else:
job._mos.append(self)
job.settings.ADFFile = self.parent.parent.kfpath
job.cube_file_prefix = f"{self.parent.parent.kfpath}.densf/"
os.makedirs(job.cube_file_prefix, exist_ok=True)
if grid_around_mol is not None:
job.grid_around_mol(grid_around_mol, extend=gridextend)
else:
job.gridsize(gridsize, extend=gridextend)
# output_cub_paths returns a list of cube-files generated by the job.
# we only generate one, so we simply return the first element
return grid.from_cub_file(job.output_cub_paths[self])
[docs]
def vtk_file(self, gridsize: str = 'medium', overwrite: bool = False, preambles=[], grid_around_mol=None, gridextend=6):
'''
Generate a cube-file for this |Orbital| with a certain grid-size.
Args:
gridsize: the size of the grid to generate the cube-file with.
overwrite: whether to overwrite the previous calculation if found.
.. seealso::
:meth:`Orbital.draw` to draw and open a TCviewer screen showing this |Orbital|.
:meth:`Orbital.screenshot` to generate a screenshot of this |Orbital|.
'''
from tcmu.job.adf import DensfJob
from tcintegral import grid
import vtk
# start a Densf job to calculate the cube-file.
# We want to return the cube-file, so we should wait for it to finish.
with DensfJob(wait_for_finish=True, overwrite=overwrite) as job:
[job.add_preamble(preamble) for preamble in preambles]
job.generate_vtk()
job.rundir = os.path.split(self.parent.parent.kfpath)[0]
job.name = 'densf'
if isinstance(self, FMO):
job._sfos.append(self)
else:
job._mos.append(self)
job.settings.ADFFile = self.parent.parent.kfpath
job.cube_file_prefix = f"{self.parent.parent.kfpath}.densf/"
os.makedirs(job.cube_file_prefix, exist_ok=True)
if grid_around_mol is not None:
job.grid_around_mol(grid_around_mol, extend=gridextend)
else:
job.gridsize(gridsize, extend=gridextend)
if overwrite:
os.remove(job.output_cub_paths[self])
if job.can_skip():
skipped = True
else:
skipped = False
# add meta-data to the vtk file
if not skipped:
reader = vtk.vtkStructuredPointsReader()
reader.SetFileName(job.output_cub_paths[self])
reader.update()
frag_mol = vtk.vtkFloatArray()
frag_mol.SetName("Molecule")
for atom in self.molecule:
frag_mol.InsertNextValue(atom.atnum)
frag_mol.InsertNextValue(atom.x)
frag_mol.InsertNextValue(atom.y)
frag_mol.InsertNextValue(atom.z)
data = reader.GetOutput()
data.GetFieldData().AddArray(frag_mol)
writer = vtk.vtkStructuredPointsWriter()
writer.SetFileName(job.output_cub_paths[self])
writer.SetInputData(data)
writer.Write()
# output_cub_paths returns a list of cube-files generated by the job.
# we only generate one, so we simply return the first element
return grid.from_vtk_file(job.output_cub_paths[self])
[docs]
def draw(self,
gridsize: str = 'medium',
isovalue: float = 0.03,
overwrite: bool = False,
screen: "tcviewer.screen.Screen" = None, # noqa: F821
transform: "tcmu.geometry.Transform" = None): # noqa: F821
'''
Generate and draw a cube-file for this |Orbital| object.
Args:
gridsize: the size of the grid to generate the cube-file with.
isovalue: the value with which to generate the isosurface of this |Orbital|.
overwrite: whether to overwrite the previous calculation if found.
screen: the ``tcviewer.screen.Screen`` object to use to draw this orbital.
If not given we start a new screen.
transform: the geometrical tranfmormation to use with this orbital.
.. seealso::
:meth:`Orbital.cube_file` to generate and return a cube-file for this |Orbital|.
:meth:`Orbital.screenshot` to generate a screenshot of this |Orbital|.
'''
import tcviewer
# generate a cube-file or load an existing one
cub = self.cube_file(gridsize=gridsize, overwrite=overwrite)
if screen is None:
scr = tcviewer.Screen()
scr.__enter__()
scr.window.show()
else:
scr = screen
# and draw it with a specified isovalue
with scr.add_molscene() as scene:
c1, c2 = ([1, 0, 0], [0, 0, 1]) if self.occupied else ([1, .5, 0], [0, 1, 1])
if transform is not None:
scene.transform = transform.to_vtkTransform()
scene.draw_molecule(self.molecule)
scene.draw_isosurface(cub, -0.03, c1)
scene.draw_isosurface(cub, 0.03, c2)
if screen is None:
scr.__exit__()
return scr
[docs]
def screenshot(self,
output_path: str = None,
gridsize: str = 'medium',
isovalue: float = 0.03,
overwrite: bool = False,
transform: "tcmu.geometry.Transform" = None) -> str: # noqa: F821
'''
Generate a screenshot for this |Orbital| object.
Args:
output_path: the path to save the image to.
gridsize: the size of the grid to generate the cube-file with.
isovalue: the value with which to generate the isosurface of this |Orbital|.
overwrite: whether to overwrite the previous calculation if found.
screen: the ``tcviewer.screen.Screen`` object to use to draw this orbital.
If not given we start a new screen.
transform: the geometrical tranfmormation to use with this orbital.
.. seealso::
:meth:`Orbital.cube_file` to generate and return a cube-file for this |Orbital|.
:meth:`Orbital.draw` to draw and open a TCviewer screen showing this |Orbital|.
'''
import tcviewer
if output_path is None:
output_path = str(self) + '.png'
# generate a cube-file or load an existing one
cub = self.cube_file(gridsize=gridsize, overwrite=overwrite)
# make a new screen and draw the orbital
with tcviewer.Screen(headless=True) as scr:
with scr.add_molscene() as scene:
c1, c2 = ([1, 0, 0], [0, 0, 1]) if self.occupied else ([1, .5, 0], [0, 1, 1])
if transform is not None:
scene.transform = transform.to_vtkTransform()
scene.draw_molecule(self.molecule)
scene.draw_isosurface(cub, -0.03, c1)
scene.draw_isosurface(cub, 0.03, c2)
# and take a screenshot
scene.screenshot(output_path)
return output_path
@functools.cached_property
def degeneracy_index(self) -> int:
'''
The index of this |Orbital| among its degenerate |Orbital| objects.
'''
return self.degenerate_orbitals.index(self)
@functools.cached_property
def degenerate(self) -> bool:
'''
Whether the |Orbital| is degenerate.
'''
return self.degeneracy > 1
@functools.cached_property
def degeneracy(self) -> int:
'''
The number of |Orbital| objects that are degenerate with this one.
'''
return len(self.degenerate_orbitals)
@functools.cached_property
def degenerate_orbitals(self) -> List["Orbital"]:
'''
|Orbital| objects that are very close in energy to this |Orbital|.
'''
return [orb for orb in self.parent.orbitals if math.isclose(orb.energy, self.energy, rel_tol=1e-8)]
[docs]
class MO(Orbital):
'''
Class holding data specifically for molecular orbitals.
Each |MO| holds the following data that can be accessed like attributes.
.. list-table::
:header-rows: 1
* - Variable
- Type
- Description
* - ``index``
- ``int``
- The index of this |MO| in the overal |MOs|.
* - ``name``
- ``str``
- The regular name of this |MO| as it would show up in ADFLevels.
* - ``symmetry``
- ``str``
- The irreducible representation this |MO| belongs to.
* - ``symmetry_index``
- ``int``
- The index of this |MO| in the overal |MOs| that belong to the same irreducible representation.
* - ``spin``
- ``str``
- The spin of this |MO|, either ``'A'``, ``'B'`` or ``'AB'``
* - ``energy``
- ``float``
- The energy of the |MO| in |kcal/mol|.
* - ``kinetic_energy``
- ``float``
- The kinetic energy of the |MO| in |kcal/mol| if it could be read from the calculation.
* - ``occupation``
- ``int``
- The occupation number of this |MO|. Either ``0``, ``1`` or ``2``.
* - ``occupied``
- ``bool``
- Whether the |MO| has electrons in it.
'''
def __str__(self):
if self.spin == 'AB':
return f'{self.name}'
return f'{self.name}_{self.spin}'
[docs]
def fragment_character(self, fragment: str) -> float:
'''
Calculate the total contribution of |FMO| objects from a specific fragment to this |MO|.
The sum of all fragment characters is always ``1`` for each |MO|.
Args:
fragment: the fragment to calculate the character for.
Example:
.. code-block:: python
>>> MO.fragment_character('NH3')
0.469475215528633
>>> MO.fragment_character('BH3')
0.530524784471364
'''
fmos = self.parent.parent.fmos.filter(fragment=fragment)
return sum(fmo.mulliken_contribution(self) for fmo in fmos)
[docs]
def coefficient(self, other: "FMO") -> float:
'''
Get the coefficient of an |FMO| into this |MO|.
Args:
other: the orbital that contributes to this |MO|.
'''
assert isinstance(other, FMO)
return other.coefficient(self)
[docs]
def mulliken_contribution(self, other: "FMO") -> float:
'''
Get the Mulliken contribution of an |FMO| into this |MO|.
Args:
other: the orbital that contributes to this |MO|.
'''
assert isinstance(other, FMO)
return other.mulliken_contribution(self)
[docs]
class FMO(Orbital):
'''
Class holding data specifically for symmetry-adapted fragment orbitals.
Each |FMO| holds the following data that can be accessed like attributes.
.. list-table::
:header-rows: 1
* - Variable
- Type
- Description
* - ``index``
- ``int``
- The index of this |FMO| in the overal |FMOs|.
* - ``name``
- ``str``
- The regular name of this |FMO| as it would show up in ADFLevels.
* - ``symmetry``
- ``str``
- The irreducible representation this |FMO| belongs to.
* - ``symmetry_index``
- ``int``
- The index of this |FMO| in the overal |FMOs| that belong to the same irreducible representation.
* - ``fragment``
- ``str``
- The name of the fragment the |FMO| belongs to.
* - ``fragment_unique``
- ``str``
- If fragments do not have unique names (i.e. with atomic fragments) this name will be unique for the atom.
* - ``fragment_index``
- ``int``
- The index of the |FMO| within the |FMOs| of the same fragment.
* - ``spin``
- ``str``
- The spin of this |FMO|, either ``'A'``, ``'B'`` or ``'AB'``
* - ``energy``
- ``float``
- The regular energy of the |FMO| in |kcal/mol|.
* - ``approx_effective_energy``
- ``float``
- Approximated diagonal element of the Fock matrix belonging to the |FMO| in |kcal/mol|. This is available even if the Fock matrix cannot be read from the calculation.
* - ``effective_energy``
- ``float``
- The diagonal element of the Fock matrix belonging to the |FMO| in |kcal/mol| if it could be read from the calculation.
* - ``effective_energy_SCF0``
- ``float``
- The diagonal element of the Fock matrix after 0 SCF cycles belonging to the |FMO| in |kcal/mol| if it could be read from the calculation.
* - ``occupation``
- ``int``
- The occupation number of this |FMO|. Either ``0``, ``1``, ``2``, or a fractional value if the electronic configuration is non-aufbau.
* - ``occupied``
- ``bool``
- Whether the |FMO| has electrons in it.
* - ``gross_population``
- ``float``
- The gross Mulliken population of this |FMO|.
* - ``gross_spin``
- ``float``
- The gross Mulliken spin population of this |FMO|.
* - ``molecule``
- :class:`plams.Molecule`
- The molecule object containing the atoms belonging to the fragment of this |FMO|.
'''
def __str__(self):
return self.make_name()
[docs]
def overlap(self, other: "FMO") -> float:
'''
Get the overlap between this |FMO| and another |FMO|.
Args:
other: the orbital to get the overlap with.
.. note::
The matmul operation ``@`` redirects to this method.
'''
assert isinstance(other, FMO)
# these conditions apply due to orthonormality
if self.spin != other.spin:
return 0
if self.symmetry != other.symmetry:
return 0
# access the right overlap matrix and return the right value
S = self.parent.parent.data['matrices']['overlap'][self.symmetry][self.spin]
return S[other.symmetry_index-1][self.symmetry_index-1]
[docs]
def fock(self, other: "FMO") -> float:
'''
Get the Fock matrix element between this |FMO| and another |FMO|.
Args:
other: the orbital to get the Fock matrix element with.
'''
assert isinstance(other, FMO)
if self.spin != other.spin:
return 0
if self.symmetry != other.symmetry:
return 0
F = self.parent.parent.data['matrices']['fock'][self.symmetry][self.spin]
return F[other.symmetry_index-1][self.symmetry_index-1]
[docs]
def mulliken_contribution(self, other: "MO", normalized=False) -> float:
'''
Get the mulliken contribution of this |FMO| into an |MO|.
Args:
other: the orbital to get the Mulliken contribution with.
'''
assert isinstance(other, MO)
if self.spin != other.spin and other.spin != 'AB':
return 0
if self.symmetry != other.symmetry:
return 0
if normalized:
c = self.parent.parent.data['matrices']['mulliken_contribution_normalized'][self.symmetry][other.spin]
else:
c = self.parent.parent.data['matrices']['mulliken_contribution'][self.symmetry][other.spin]
return c[other.symmetry_index-1][self.symmetry_index-1]
[docs]
def coefficient(self, other: "MO") -> float:
'''
Get the coefficient of this |FMO| into an |MO|.
Args:
other: the orbital to get the coefficient with.
'''
assert isinstance(other, MO)
if self.spin != other.spin and other.spin != 'AB' and self.spin != 'AB':
return 0
if self.symmetry != other.symmetry:
return 0
c = self.parent.parent.data['matrices']['coefficients'][self.symmetry][other.spin]
return c[other.symmetry_index-1][self.symmetry_index-1]
def __matmul__(self, other: "FMO") -> float:
'''
Short-hand notation for getting the overlap with another |FMO|.
'''
return self.overlap(other)
[docs]
def make_name(self, spin: bool = True, frag_name: bool = True, relative_name: bool = False) -> str:
'''
Generate a name for this |FMO| with several options to modify it.
Args:
spin: whether to include spin in the name. It will be appended to the end as ``_{spin}``.
frag_name: whether to include the fragment's unique name in the name as ``{fragment_unique}(...)``.
relative_name: whether to use the relative name instead of the regular name.
Examples:
Generate the regular name of this |FMO|. This is the default name when printing the object.
.. code-block:: python
>>> fmo.make_name()
'NH3(4A1)'
One can also use relative naming.
.. code-block:: python
>>> fmo.make_name(relative_name=True)
'NH3(LUMO)'
One can also only get the name of the orbital by disabling the fragment name.
.. code-block:: python
>>> fmo.make_name(frag_name=False)
'4A1'
'''
name = ''
if frag_name:
name += self.fragment + '('
if relative_name:
name += self.relative_name
else:
name += self.name
if frag_name:
name += ')'
if self.spin != 'AB' and spin:
name += f'_{self.spin}'
return name
@functools.cached_property
def subspecies_relative_name(self) -> str:
'''
The relative name of the orbital in its irreducible representation.
E.g. the overall HOMO-2 could be the HOMO of its irreducible representation.
'''
orbitals = [orb for orb in self.parent.orbitals if orb.spin == self.spin and orb.spin_total_occupation == self.spin_total_occupation and orb.subspecies == self.subspecies]
if hasattr(self, 'fragment'):
orbitals = [orb for orb in orbitals if orb.fragment == self.fragment]
energies = sorted([orb.energy for orb in orbitals])
order = energies.index(self.energy) + self.degeneracy_index
if self.doubly_occupied:
order = len(orbitals) - order - 1
return f'HOMO-{order}' if order > 0 else 'HOMO'
if self.singly_occupied:
if self.occupation == 1:
order = len(orbitals) - order - 1
return f'SOMO-{order}' if order > 0 else 'SOMO'
else:
return f'SUMO+{order}' if order > 0 else 'SUMO'
if self.unoccupied:
return f'LUMO+{order}' if order > 0 else 'LUMO'
[docs]
class Orbitals:
'''
Container class that stores information about both |MOs| and |FMOs|.
|Orbitals| can also be given the paths to ``adf.rkf`` files from
related calculations to obtain more information. For example, the
path to a calculation with the number of SCF cycles set to 0 populates
the ``effective_energy_SCF0`` properties of the FMOs.
Args:
path: the path to an ``adf.rkf`` file containing information about the system of interest.
path_SCF0: the path to an ``adf.rkf`` file containing information about a calculation with 0 SCF cycles.
This argument is required to populate the ``FMO.effective_energy_scf0`` property
path_fragments: dictionary containing fragment name as the key and path to its ``adf.rkf`` as the value.
path_output: the path to an ``.out`` file generated by ADF.
This is required to read the kinetic energies for the MOs.
Attributes:
fmos (|FMOs|): the |FMOs| object storing the |FMO| objects associated with this system. Use this to select specific |FMO| for further analysis.
mos (|MOs|): the |MOs| object storing the |MO| objects associated with this system.
charges (Dict[str,int]): a dictionary storing formal charges of the complex and each fragment.
'''
def __init__(self, path: str, path_SCF0: str = None, path_fragments: Dict[str, str] = None, path_output: str = None):
self.reader = plams.KFReader(path)
self.kfpath = os.path.abspath(path)
self.SCF0_kfpath = path_SCF0
self.SCF0_reader = plams.KFReader(path_SCF0) if path_SCF0 else None
self.fragment_kfpaths = path_fragments
if self.fragment_kfpaths:
self.fragment_orbs = {frag: Orbitals(fpath) for frag, fpath in path_fragments.items()}
else:
self.fragment_orbs = {}
self.output = os.path.abspath(path_output) if path_output else None
self._get_data()
self._gather_fmos()
self._gather_mos()
self._determine_formal_charges()
self._gather_notices()
def _get_data(self):
self.data = pyorbb.orbitals.adf.read_data(self.reader, SCF0_reader=self.SCF0_reader, output=self.output)
def _gather_fmos(self):
self.fmos = FMOs([], self)
fmo_mo_spin_match = self.data['calc_info']['unrestricted_mos'] == self.data['calc_info']['unrestricted_fmos']
for fmo_idx in range(self.data['FMOs']['number']):
for spin_idx, fmo_spin in enumerate(self.data['calc_info']['fmo_spins']):
symlabel = self.data['FMOs']['symlabel'][fmo_idx]
frag = self.data['FMOs']['fragment_types'][fmo_idx]
if not fmo_mo_spin_match:
if self.data['calc_info']['unrestricted_mos']:
gross_pop = self.data['FMOs']['gross_population']['A'][fmo_idx] + self.data['FMOs']['gross_population']['B'][fmo_idx]
gross_spin = self.data['FMOs']['gross_population']['A'][fmo_idx] - self.data['FMOs']['gross_population']['B'][fmo_idx]
else:
gross_pop = self.data['FMOs']['gross_population']['AB'][fmo_idx]
gross_spin = 0
else:
gross_pop = self.data['FMOs']['gross_population'][fmo_spin][fmo_idx]
gross_spin = 0
data = {
'index': fmo_idx + 1 + self.data['MOs']['nfrozencores'][symlabel],
'name': self.data['FMOs']['adf_names'][fmo_spin][fmo_idx].removesuffix('_AB').removesuffix('_A').removesuffix('_B'),
'subspecies': self.data['FMOs']['subspecies'][fmo_idx],
'symmetry': symlabel,
'symmetry_index': self.data['FMOs']['symmetry_index'][fmo_idx] + 1 + self.data['MOs']['nfrozencores'][symlabel],
'densf_index': self.data['FMOs']['symmetry_index'][fmo_idx] + 1,
'fragment': frag,
# 'fragment_unique': self.data['FMOs']['fragment_unique']['total'][fmo_idx],
'fragment_index': self.data['FMOs']['fragment_index'][fmo_idx],
'spin': fmo_spin,
'energy': self.data['FMOs']['energy'][fmo_spin][fmo_idx] * 27.2114079527,
'occupation': float(self.data['FMOs']['occupation'][fmo_spin][fmo_idx]),
'occupied': int(self.data['FMOs']['occupation'][fmo_spin][fmo_idx]) > 0,
'gross_population': gross_pop,
'gross_spin': gross_spin,
'molecule': self.data['molecules'][frag],
}
if fmo_spin in self.data['FMOs']['approx_effective_energy']:
data['approx_effective_energy'] = self.data['FMOs']['approx_effective_energy'][fmo_spin][fmo_idx] * 27.2114079527
else:
data['approx_effective_energy'] = (self.data['FMOs']['approx_effective_energy']['A'][fmo_idx] + self.data['FMOs']['approx_effective_energy']['B'][fmo_idx]) * 27.2114079527
if 'adf_names_fixed_principal' in self.data['FMOs']:
data['name'] = self.data['FMOs']['adf_names_fixed_principal'][fmo_spin][fmo_idx].removesuffix('_AB').removesuffix('_A').removesuffix('_B')
# frag_unique = str(self.data['FMOs']['fragment_unique']['total'][fmo_idx])
if symlabel in self.data['calc_info']['fmo_spinpolarizations'][frag]:
occs = self.data['calc_info']['fmo_spinpolarizations'][frag][symlabel]
spinpol = occs[0] - occs[1]
data['spin_pol'] = spinpol
else:
data['spin_pol'] = 0
data['effective_energy'] = None
if 'effective_energy' in self.data['FMOs']:
data['effective_energy'] = self.data['FMOs']['effective_energy'][fmo_spin][fmo_idx] * 27.2114079527
data['effective_energy_SCF0'] = None
if 'effective_energy_SCF0' in self.data['FMOs']:
data['effective_energy_SCF0'] = self.data['FMOs']['effective_energy_SCF0'][fmo_spin][fmo_idx] * 27.2114079527
fmo = FMO(data, self.fmos)
self.fmos.orbitals.append(fmo)
def _gather_mos(self):
self.mos = MOs([], self)
for moi in range(self.data['FMOs']['number']):
for spin_idx, mo_spin in enumerate(self.data['calc_info']['mo_spins']):
symm_idx = self.data['MOs']['symmetry_index'][moi]
symlabel = self.data['MOs']['symlabel'][moi]
occ = int(self.data['MOs']['occupation'][symlabel][mo_spin][symm_idx])
if 'kinetic_energy' in self.data['MOs']:
kin = self.data['MOs']['kinetic_energy'][symlabel][symm_idx] * 27.2114079527 if occ else 0
else:
kin = None
data = {
'index': moi + 1,
'name': f'{symm_idx+1}{symlabel}'.removesuffix('_AB').removesuffix('_A').removesuffix('_B'),
'symmetry': symlabel,
'symmetry_index': self.data['MOs']['symmetry_index'][moi] + 1,
'densf_index': self.data['MOs']['symmetry_index'][moi] + 1,
'spin': mo_spin,
'energy': self.data['MOs']['energy'][symlabel][mo_spin][symm_idx] * 27.2114079527,
'occupation': occ,
'occupied': int(self.data['MOs']['occupation'][symlabel][mo_spin][symm_idx]) > 0,
'kinetic_energy': kin,
'molecule': self.data['molecules']['complex'],
}
fmo = MO(data, self.mos)
self.mos.orbitals.append(fmo)
def _gather_notices(self):
self.notices = {'warning': [], 'error': [], 'info': []}
if not self._check_effective_energies_available():
self.notices['warning'].append(('No effective energies',
'''Effective energies are not available for
this calculation. To obtain them, please
rerun the calculation with the following
settings:
Engine ADF
PRINT FMATFMO
FullFock Yes
AllPoints Yes
EndEngine
''', None))
if any(c != 0 for c in self.charges.values()):
self.notices['warning'].append(('Charged fragments',
'''This system contains charged fragments.
We recommended you to check if effective
energies are required.
''', None))
if self._check_spurious_mulliken_contr():
fmos = self._mulliken_unstable_fmos()
err = ''
if len(fmos) > 0:
max_fmo_len = max([len(str(fmo)) for fmo, _ in fmos])
err += '\n FMO'.ljust(max_fmo_len + 2) + ' Abs. Contr.\n'
err += ' ' + '_' * (len('\n FMO'.ljust(max_fmo_len + 2) + ' Abs. Contr.\n') - 4) + '\n'
for fmo, score in fmos:
err += f' {str(fmo):{max_fmo_len}} {score: .1%}\n'
mos = self._mulliken_unstable_mos()
if len(mos) > 0:
max_mo_len = max([len(str(mo)) for mo, _ in mos])
err += '\n MO'.ljust(max_mo_len + 2) + ' Abs. Contr.\n'
err += ' ' + '_' * (len('\n MO'.ljust(max_mo_len + 2) + ' Abs. Contr.\n') - 4) + '\n'
for mo, score in mos:
err += f' {str(mo):{max_mo_len}} {score: .1%}\n'
self.notices['warning'].append(('Mulliken artifacts',
f'''We detected artifacts in the
Mulliken analysis. Be carefull when
interpreting Mulliken contributions,
populations, and approximate effective
energies from the following orbitals:
{err}''', [fmo for fmo, _ in fmos] + [mo for mo, _ in mos]))
# check the EDA terms
# OI: check irreps
positive_eoi_irreps = []
for lab in self.reader.read('Symmetry', 'symlab').split():
l = lab.split(':')[0]
if l not in positive_eoi_irreps:
Eoi = float(self.reader.read('Energy', f'Orb.Int. {l}'))
if Eoi > 0:
positive_eoi_irreps.append(l)
if len(positive_eoi_irreps) > 0:
s = r" \n".join(positive_eoi_irreps)
self.notices['error'].append(('Positive orb. int. energy',
f'''The orbital interaction energy
is positive for the following irreps:
{s}''', None))
# check the electronic preparation
for frag in self.fragments:
fmos = [fmo for fmo in self.fmos if fmo.fragment == frag]
polarized_fmos = self._polarized_fmos(fmos)
if len(polarized_fmos) > 0:
s = f'The "{frag}" fragment has at least\none large electronic shift\n\nMain polarized FMOs:\n'
fmo_name_len = max([len(str(fmo)) for fmo, _ in polarized_fmos])
for (fmo, dp) in polarized_fmos:
s += f' {str(fmo):>{fmo_name_len}s}: {dp:+.2f} electrons\n'
s += '\nCheck the electronic configuration!'
self.notices['error'].append(('Incorrect electronic preparation', s, [r[0] for r in polarized_fmos]))
if self._check_noninteger_occs():
wrong_fmos = self._get_noninteger_occs()
fmo_names = [str(fmo) for fmo in wrong_fmos]
max_len = max(len(name) for name in fmo_names)
occs = [f'{fmo.occupation:.2f}' for fmo in wrong_fmos]
s = 'The following fractionally occupied\nFMOS were found:\n'
for name, occ in zip(fmo_names, occs):
s += f' {name.ljust(max_len)} {occ} electrons\n'
s += '\nCheck the electronic configuration!'
self.notices['warning'].append(('Fractional occupations', s, wrong_fmos))
def _polarized_fmos(self, fmos: List[FMO]) -> List[Tuple[FMO, float]]:
'''
Check given |FMO| objects for large changes in electronic population.
Args:
fmos: A list of |FMO| objects to check.
Returns:
A list of tuples with |FMO| objects and their difference in gross-population and occupation. The difference must be at least 0.7 electrons.
'''
polarized_fmos = []
for fmo in fmos:
dp = fmo.gross_population - fmo.occupation
if abs(dp) > 0.7:
polarized_fmos.append((fmo, dp))
polarized_fmos = sorted(polarized_fmos, key=lambda r: -abs(r[1]))
return polarized_fmos
def _check_noninteger_occs(self):
for fmo in self.fmos:
if round(fmo.occupation) != fmo.occupation:
return True
return False
def _get_noninteger_occs(self):
ret = []
for fmo in self.fmos:
if round(fmo.occupation) != fmo.occupation:
ret.append(fmo)
return ret
def _check_effective_energies_available(self):
return any(hasattr(fmo, 'effective_energy') for fmo in self.fmos)
def _mulliken_unstable_fmos(self):
c = self.data['matrices']['mulliken_contribution']['total']
ret = []
for fmo in self.fmos:
abs_C = sum(abs(c[:, fmo.index - 1]))
if abs_C > 1.3:
ret.append((fmo, abs_C))
ret = sorted(ret, key=lambda row: -row[1])
return ret
def _mulliken_unstable_mos(self):
c = self.data['matrices']['mulliken_contribution']['total']
ret = []
for mo in self.mos:
abs_C = sum(abs(c[mo.index - 1]))
if abs_C > 1.3:
ret.append((mo, abs_C))
ret = sorted(ret, key=lambda row: -row[1])
return ret
def _check_spurious_mulliken_contr(self):
unstable_fmos = self._mulliken_unstable_fmos()
unstable_mos = self._mulliken_unstable_mos()
return len(unstable_fmos) > 0 or len(unstable_mos) > 0
def _determine_formal_charges(self):
# build up the effective charges of the atoms
# this takes into account the atom number and number of frozen core electrons
atomtypes = self.reader.read('Geometry', 'atomtype').split()
eff_charges = self.reader.read('Geometry', 'atomtype effective charge')
if isinstance(eff_charges, float):
eff_charges = [eff_charges]
if isinstance(atomtypes, float):
atomtypes = [atomtypes]
atomtype_charges = {typ: charge for typ, charge in zip(atomtypes, eff_charges)}
# calculate the charges for the fragments and the complex
charges = {}
for frag in self.fragments:
fmos = self.fmos.filter(fragment=frag)
# we need the atoms in the molecule
mol = fmos[0].molecule
expected_Nelectrons = sum(atomtype_charges[atom.symbol] for atom in mol)
actual_Nelectrons = round(sum(fmo.occupation for fmo in fmos))
charges[frag] = expected_Nelectrons - actual_Nelectrons
charges['complex'] = sum(charges.values())
self.charges = charges
@property
def molecule(self) -> plams.Molecule:
'''
The molecule corresponding to the overall system.
'''
mol = plams.Molecule()
for frag in self.fragments:
fmo = [fmo for fmo in self.fmos if fmo.fragment == frag][0]
mol += fmo.molecule
return mol
@property
def fragments(self) -> List[str]:
'''
The names of the fragments defined in the calculation.
'''
return self.fmos.fragments
[docs]
def write_excel(self, out_file: str = None):
'''
Write the data corresponding to the system into an Excel file.
Args:
out_file: The filename of the Excel file to write.
'''
from pyorbb import write_excel
if out_file is None:
out_file = os.path.join(os.path.dirname(self.kfpath), 'PyOrbb.xlsx')
write_excel.to_excel(self, out_file)
@property
def fmo_energy_types(self) -> List[str]:
'''
Get the orbital energy types that are available for the provided system.
.. seealso::
This property is a redirection of :attr:`FMOs.energy_types <pyorbb.orbitals.objects.FMOs.energy_types>`.
'''
return self.fmos.energy_types
[docs]
def rename_fragment(self, old: str, new: str):
'''
Rename the ``old`` fragment to ``new``.
Args:
old: the name of the fragment to rename.
new: the name to rename the fragment to.
Raises:
ValueError: if the new name is already in use.
.. seealso::
See :attr:`Orbitals.fragments <pyorbb.orbitals.objects.Orbitals.fragments>` to obtain a list of fragment names that are currently used.
'''
if new in self.fragments:
raise ValueError(f'Fragment name ``{new}`` is already in use.')
for fmo in self.fmos:
if fmo.fragment == old:
fmo.fragment = new
[docs]
def get_mixer(self) -> "pyorbb.Mixer":
return pyorbb.Mixer(self)
[docs]
class OrbitalSelector:
'''
Class used to select |MOs| or |FMOs|.
It is responsible for decoding selection keys and filtering orbitals based on the selection key.
Args:
orbitals: a list of |FMOs| or |MOs| that will be managed by this class.
parent: the parent |Orbitals| object.
'''
def __init__(self, orbitals: List[Orbital], parent: Orbitals):
self.orbitals = orbitals
self.parent = parent
def __getitem__(self, key: int or str) -> List[Orbital] or Orbital:
return self.get(key)
[docs]
def get(self, key: int or str) -> List[Orbital] or Orbital:
'''
Get |Orbital| objects based on the given key.
Args:
key: a string describing the orbital to be selected or the integer index of the orbital.
Returns:
A list of |Orbital| objects that match the given key.
If there is only one return a single |Orbital| object.
Examples:
Select the HOMO of the NH3 fragment.
.. code-block:: python
>>> FMOs.get('NH3(HOMO)')
NH3(3A1)
>>> FMOs['NH3(HOMO)']
NH3(3A1)
Select a specific |MO|.
.. code-block:: python
>>> MOs.get('6A1')
6A1
>>> MOs['6A1']
6A1
.. seealso::
:func:`~OrbitalSelector.filter` and :func:`~OrbitalSelector.decode_key`.
.. note::
The ``__getitem__`` method of this class redirects to this method,
allowing you to use indexing notation to obtain orbitals.
'''
return self.filter(**self.decode_key(key))
[docs]
def decode_key(self, key: Union[str, int]) -> dict:
'''
Decode a key into the relevant parts.
Keys are given in the following format:
{fragname}({orbname}[_{spin}][ {symmetry}])
Where [:fragment_index], [_{spin}], and [ {symmetry}] are optional.
If an FMO is desired you must begin the key with the fragment name
and put the rest of the key within parentheses.
Returns:
A dictionary containing ``index``, ``fragment``,
``orbname``, ``spin``, ``symmetry``.
Examples:
Decode a key specifying an MO.
.. code-block:: python
>>> MOs.decode_key('4A1')
{'orbname': '4A1'}
One can also use relative naming. Also specify alpha spin.
.. code-block:: python
>>> MOs.decode_key('HOMO-2_A')
{'orbname': 'HOMO-2', 'spin': 'A'}
Decode a key for an FMO specifying the fragment, orbname and spin.
.. code-block:: python
>>> FMOs.decode_key('NH3(1E1:1_B)')
{'fragment': 'NH3', 'orbname': '1E1:1_B'}
If multiple fragments have the same name (e.g. in a non-fragment analysis with atomic fragments)
we can specify the fragment index with the colon.
.. code-block:: python
>>> FMOs.decode_key('C:4(1P:x)')
{'fragment': 'C:4', 'orbname': '1P:x'}
'''
decoded = {
'index': None,
'global_index': None,
'fragment': None,
'orbname': None,
'spin': None,
'symmetry': None,
}
# if a single integer is given return only the index
if isinstance(key, int):
decoded['global_index'] = key
return decoded
# in case we have a FMO we need a fragment name
fmo_regex = re.compile(r'(.+)\((\d+.+)\)_?([AB]?)')
fmo_regex_result = fmo_regex.findall(key)
if fmo_regex_result != []:
decoded['fragment'], decoded['orbname'], decoded['spin'] = fmo_regex_result[0]
if decoded['spin'] == '':
decoded['spin'] = None
return {k: v for k, v in decoded.items() if v is not None}
# in case we have a FMO we need a fragment name
fmo_relname_regex = re.compile(r'(.+)\(((?:HOMO|SOMO|LUMO|SUMO)(?:[+-]\d+)?)_?([AB])?\)')
fmo_relname_regex_result = fmo_relname_regex.findall(key)
if fmo_relname_regex_result != []:
decoded['fragment'], decoded['orbname'], decoded['spin'] = fmo_relname_regex_result[0]
if decoded['spin'] == '':
decoded['spin'] = None
return {k: v for k, v in decoded.items() if v is not None}
# if the FMO regex fails we try the MO regex
mo_regex = re.compile(r'(\d+[^_]+)_?([AB]?)')
mo_regex_result = mo_regex.findall(key)
if mo_regex_result != []:
decoded['orbname'], decoded['spin'] = mo_regex_result[0]
if decoded['spin'] == '':
decoded['spin'] = None
return {k: v for k, v in decoded.items() if v is not None}
# if the FMO regex fails we try the MO regex
mo_relname_regex = re.compile(r'((?:HOMO|SOMO|LUMO|SUMO)(?:[+-]\d+)?)_?([AB])?')
mo_relname_regex_result = mo_relname_regex.findall(key)
if mo_relname_regex_result != []:
decoded['orbname'], decoded['spin'] = mo_relname_regex_result[0]
if decoded['spin'] == '':
decoded['spin'] = None
return {k: v for k, v in decoded.items() if v is not None}
[docs]
def filter(self,
index: int or List[int] = None,
global_index: int or List[int] = None,
symmetry: str or List[str] = None,
subspecies: str or List[str] = None,
spin: str or List[str] = None,
fragment: str or List[str] = None,
fragment_index: int or List[str] = None,
orbname: str or List[str] = None,
occupation: float or str or List[float] or List[str] = None) -> Orbital or List[Orbital]:
'''
filter |Orbital| objects that match the given parameters.
If any of the arguments is given as a ``Container`` we check for membership.
Arguments:
index: the index of the orbital.
global_index: the global index of the orbital.
symmetry: the symmetry label of the orbital.
subspecies: the subspecies label of the orbital.
spin: the spin label of the orbital, should be one of [``A``, ``B``, ``AB``].
fragment: the fragment name of the FMO.
fragment_index: the index of the fragment of the FMO.
orbname: the name of the orbital. Can be either the proper name or a relative name,
e.g. ``SOMO`` or ``LUMO+5``.
occupation: what kind of occupation to allow. Can be a floating point number
specifying the occupation or a string from one of [``unoccupied``, ``partially_occupied``, ``fully_occupied``].
Floating point numbers will be rounded to 2 decimals before comparison.
Returns:
The |Orbital| objects that match the provided arguments.
If there is only one |Orbital| object selected, return only that one.
Otherwise return a ``list`` of |Orbital| objects.
Returns ``None`` if no matching |Orbital| objects were found.
Examples:
Select all FMOs of a given fragment.
.. code-block:: python
>>> FMOs.filter(fragment='NH3')
[NH3(1A1), NH3(2A1), NH3(3A1), ...]
Select all FMOs from the A2 irrep of the BH3 fragment.
.. code-block:: python
>>> FMOs.filter(symmetry='A2', fragment='BH3')
[BH3(1A2), BH3(2A2), BH3(3A2), BH3(4A2)]
Select all MOs that are named '1E1:1' or '1E1:2'.
.. code-block:: python
>>> MOs.filter(orbname=('1E1:1', '1E1:2'))
[1E1:1, 1E1:2]
Select the HOMO of the NH3 fragment.
.. code-block:: python
>>> FMOs.filter(orbname='HOMO', fragment='NH3')
NH3(3A1)
Get 1P orbitals for all carbons
.. code-block:: python
>>> FMOs.filter(orbname=('1P:x', '1P:y', '1P:z'), fragment='C')
[C:1(1P:x), C:1(1P:y), C:1(1P:z), C:2(1P:x), C:2(1P:y), C:2(1P:z), C:3(1P:x), C:3(1P:y), C:3(1P:z), C:4(1P:x), C:4(1P:y), C:4(1P:z)]
Get 1P orbitals for the second carbon
.. code-block:: python
>>> FMOs.filter(orbname=('1P:x', '1P:y', '1P:z'), fragment='C:2')
[C:2(1P:x), C:2(1P:y), C:2(1P:z)]
>>> FMOs.filter(orbname=('1P:x', '1P:y', '1P:z'), fragment='C', fragment_index=2)
[C:2(1P:x), C:2(1P:y), C:2(1P:z)]
'''
orbs = self.orbitals
# filter down the orbitals in this object
if index is not None:
orbs = [orb for orb in orbs if orb.index in _ensure_list(index)]
if global_index is not None:
orbs = [orbs[i] for i in _ensure_list(global_index)]
if symmetry is not None:
orbs = [orb for orb in orbs if orb.symmetry in _ensure_list(symmetry)]
if subspecies is not None:
orbs = [orb for orb in orbs if orb.subspecies in _ensure_list(subspecies)]
if spin is not None:
orbs = [orb for orb in orbs if orb.spin in _ensure_list(spin)]
# we match based on either fragment or fragment_unique
# this ensures that if we select for instance "C(1P:x)" we match ALL carbons
# if we match "C:1(1P:x)" we match only the first carbon
if fragment is not None:
orbs = [orb for orb in orbs if orb.fragment in _ensure_list(fragment)]
if fragment_index is not None:
orbs = [orb for orb in orbs if orb.fragment_index in _ensure_list(fragment_index)]
# orbname can be either the proper name or the relative name
if orbname is not None:
orbs = [orb for orb in orbs if orb.name in _ensure_list(orbname) or orb.relative_name in _ensure_list(orbname)]
# check for the occupation of the orbitals
if occupation is not None:
orbs_ = []
for orb in orbs:
for occ in _ensure_list(occupation):
if isinstance(occ, float):
if round(orb.occupation, 2) == round(occ, 2):
orbs_.append(orb)
if isinstance(occ, str):
if occ == 'occupied' and orb.occupied:
orbs_.append(orb)
continue
if occ == 'unoccupied' and orb.unoccupied:
orbs_.append(orb)
continue
if occ == 'fully_occupied' and orb.fully_occupied:
orbs_.append(orb)
continue
if occ == 'partially_occupied' and orb.partially_occupied:
orbs_.append(orb)
continue
orbs = orbs_
# return None if nothing was found
if len(orbs) == 0:
return None
# squeeze the list if only one element exists
if len(orbs) == 1:
return orbs[0]
return orbs
def __len__(self):
return len(self.orbitals)
def __iter__(self):
return iter(self.orbitals)
@property
def spins(self) -> List[str]:
'''
The spin species that are present in the given orbitals.
'''
return list(sorted({orb.spin for orb in self.orbitals}))
@property
def symmetry(self) -> List[str]:
'''
The spin species that are present in the given orbitals.
'''
return list(sorted({orb.symmetry for orb in self.orbitals}))
@property
def unrestricted(self) -> bool:
'''
Whether the calculation was performed in an unrestricted manner.
'''
return all(orb.spin in ['A', 'B'] for orb in self.orbitals)
[docs]
class FMOs(OrbitalSelector):
'''
Object storing all |FMO| objects for the given calculation.
'''
@property
def fragments(self) -> List[str]:
'''
Return a list of fragment names found in the orbitals.
'''
frags = []
for fmo in self.orbitals:
if fmo.fragment not in frags:
frags.append(fmo.fragment)
return frags
@property
def energy_types(self) -> List[str]:
'''
Object storing all |FMO| objects for the |Orbitals| objects.
Returns:
A list potentially containing ``energy``, ``effective_energy`` and ``effective_energy_SCF0``.
'''
ret = []
if len(self.orbitals) > 0:
orb = self.orbitals[0]
if orb.energy is not None:
ret.append('energy')
if orb.effective_energy is not None:
ret.append('effective_energy')
if orb.approx_effective_energy is not None:
ret.append('approx_effective_energy')
if orb.effective_energy_SCF0 is not None:
ret.append('effective_energy_SCF0')
return ret
@property
def subspecies(self) -> List[str]:
'''
The spin species that are present in the given orbitals.
'''
return list(sorted({orb.subspecies for orb in self.orbitals}))
[docs]
class MOs(OrbitalSelector):
'''
Object storing all |MO| objects for the |Orbitals| objects.
'''
...
if __name__ == '__main__':
orbs = Orbitals('/Users/yumanhordijk/PhD/Programs/TheoCheM/PyOrbb/calculations/PyOrb_testing_2022/DonorAcceptor/NH3BH3.rkf')
# for fmo in orbs.fmos:
# print(fmo.fragment_unique)
# print(orbs.data.mos.kinetic_energy)
# for mo in orbs.mos:
# print(mo, mo.kinetic_energy)
# orbs.write_excel2()
fmos = orbs.fmos.filter(symmetry='A2', fragment='BH3')
print(fmos)
fmos = orbs.fmos.filter(fragment='NH3')
print(fmos)
fmos = orbs.mos.filter(orbname=('1E1:1', '1E1:2'))
print(fmos)
fmos = orbs.fmos.filter(orbname='HOMO', fragment='NH3')
print(fmos)
mo = orbs.fmos.get('NH3(HOMO)')
print(mo)
mo = orbs.fmos['NH3(HOMO)']
print(mo)
mo = orbs.fmos['NH3']
print(mo)
dk = orbs.mos.decode_key('HOMO-2_A')
print(dk)
dk = orbs.fmos.decode_key('NH3(1E1:1_B)')
print(dk)
dk = orbs.fmos.decode_key('C:4(1P:x)')
print(dk)
orbs = Orbitals('/Users/yumanhordijk/PhD/Programs/TheoCheM/pyorbb/calculations/PyOrb_testing_2022/TransitionState/DielsAlder.Diene.results/adf.rkf')
print(orbs.fmos.filter(orbname=('1P:x', '1P:y', '1P:z'), fragment='C', fragment_index=2))
orbs = Orbitals('/Users/yumanhordijk/Downloads/pyr_c2v_frageda_occ2_pyridone.adf.rkf')
for fmo in orbs.fmos.filter(fragment='CO'):
if fmo.spin_pol != 0:
print(fmo, fmo.spin_pol)
for fmo in orbs.fmos.filter(fragment='NH'):
if fmo.spin_pol != 0:
print(fmo, fmo.spin_pol)