#!/usr/bin/env python3
#
# elements.py
"""
Provides classes to model period table elements.
"""
#
# Copyright (c) 2020 Dominic Davis-Foster <dominic@davis-foster.co.uk>
#
# This program 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, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
# MA 02110-1301, USA.
#
# Based on molmass (https://github.com/cgohlke/molmass)
# | Copyright (c) 1990-2020, Christoph Gohlke
# | All rights reserved.
# | Licensed under the BSD 3-Clause License
# | Redistribution and use in source and binary forms, with or without
# | modification, are permitted provided that the following conditions are met:
# |
# | 1. Redistributions of source code must retain the above copyright notice,
# | this list of conditions and the following disclaimer.
# |
# | 2. Redistributions in binary form must reproduce the above copyright notice,
# | this list of conditions and the following disclaimer in the documentation
# | and/or other materials provided with the distribution.
# |
# | 3. Neither the name of the copyright holder nor the names of its
# | contributors may be used to endorse or promote products derived from
# | this software without specific prior written permission.
# |
# | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# | POSSIBILITY OF SUCH DAMAGE.
# |
#
# stdlib
import functools
from typing import Dict, Iterable, Iterator, List, Optional, Tuple, Union, overload
# 3rd party
from domdf_python_tools import doctools
from domdf_python_tools.bases import Dictable
from domdf_python_tools.doctools import prettify_docstrings
# this package
from chemistry_tools._memoized_property import memoized_property
# this package
from . import _elements, _table
__all__ = ["Element", "Isotope", "Elements", "HeavyHydrogen", "IsotopeDict"]
#: Type alias for isotope dictionaries.
IsotopeDict = Dict[int, Union["Isotope", Tuple[float, float]]]
[docs]@prettify_docstrings
class Element(Dictable):
"""
Chemical element.
:param number: The atomic number of the element.
:param symbol: The chemical symbol of the element.
:param name: The name of the element in English.
:param group: The number of electrons in the element.
:param period: The number of protons in the element.
:param block: The group of the element in the periodic table.
:param series: The Period of the element in the periodic table.
:param mass: The relative atomic mass.
:param eleneg: The Electronegativity (Pauling scale).
:param eleaffin: The electron affinity in eV.
:param covrad: The Covalent radius in Angstrom.
:param atmrad: The Atomic radius in Angstrom.
:param vdwrad: The Van der Waals radius in Angstrom.
:param tboil: The boiling temperature in K.
:param tmelt: The melting temperature in K.
:param density: The density at 295K in g/cm³ respectively g/L.
:param eleconfig: The Ground state electron configuration.
:param oxistates: The oxidation states.
:param ionenergy: The ionization energies in ``eV``.
:param isotopes: The Isotopic composition. A mapping of isotope mass numbers to :class:`~.Isotope` objects.
:param description: A description of the element.
.. autosummary-widths:: 30/100
"""
_ionenergy: Tuple
def __init__(
self,
number: int,
symbol: str,
name: str,
group: int = 0,
period: int = 0,
block: str = '',
series: int = 0,
mass: float = 0.0,
eleneg: float = 0.0,
eleaffin: float = 0.0,
covrad: float = 0.0,
atmrad: float = 0.0,
vdwrad: float = 0.0,
tboil: float = 0.0,
tmelt: float = 0.0,
density: float = 0.0,
eleconfig: str = '',
oxistates: str = '',
ionenergy: Optional[Tuple] = None, # TODO
isotopes: Optional[IsotopeDict] = None,
description: str = '',
):
super().__init__()
self._number: int = number
self._symbol: str = symbol
self._name: str = name
self._electrons: int = number
self._protons: int = number
self._group: int = group
self._period: int = period
self._block: str = block
self._series: int = series
self._mass: float = mass
self._eleneg: float = eleneg
self._eleaffin: float = eleaffin
self._covrad: float = covrad
self._atmrad: float = atmrad
self._vdwrad: float = vdwrad
self._tboil: float = tboil
self._tmelt: float = tmelt
self._density: float = density
self._eleconfig: str = eleconfig
self._oxistates: str = oxistates
self._description: str = description
if ionenergy is None:
self._ionenergy = tuple()
else:
self._ionenergy = ionenergy
self._isotopes: Dict[int, Isotope] = {}
if isotopes is not None:
for massnumber, isotope in isotopes.items():
if isinstance(isotope, Isotope):
self._isotopes[int(massnumber)] = isotope
elif isinstance(isotope, (list, tuple)):
self._isotopes[int(massnumber)] = Isotope(*isotope, massnumber)
@property
def __dict__(self): # noqa: MAN002
return dict(
number=self._number,
symbol=self._symbol,
name=self._name,
group=self._group,
period=self._period,
block=self._block,
series=self._series,
mass=self._mass,
eleneg=self._eleneg,
eleaffin=self._eleaffin,
covrad=self._covrad,
atmrad=self._atmrad,
vdwrad=self._vdwrad,
tboil=self._tboil,
tmelt=self._tmelt,
density=self._density,
eleconfig=self._eleconfig,
oxistates=self._oxistates,
ionenergy=self._ionenergy,
isotopes=self._isotopes,
description=self._description
)
@memoized_property
def number(self) -> int:
"""
The atomic number of the element.
"""
return self._number
@memoized_property
def symbol(self) -> str:
"""
The chemical symbol of the element.
"""
return self._symbol
@memoized_property
def name(self) -> str:
"""
The name of the element in English.
"""
return self._name
@memoized_property
def electrons(self) -> int:
"""
The number of electrons in the element.
"""
return self._electrons
@memoized_property
def protons(self) -> int:
"""
The number of protons in the element.
"""
return self._protons
@memoized_property
def group(self) -> int:
"""
The group of the element in the periodic table.
"""
return self._group
@memoized_property
def period(self) -> int:
"""
The Period of the element in the periodic table.
"""
return self._period
@memoized_property
def block(self) -> str:
"""
The Block of the element in the periodic table.
"""
return self._block
@memoized_property
def series(self) -> int:
"""
Index to chemical series.
"""
return self._series
@memoized_property
def mass(self) -> float:
"""
The relative atomic mass.
Ratio of the average mass of atoms.
"""
return self._mass
molecular_weight = mass
@memoized_property
def eleneg(self) -> float:
"""
The Electronegativity (Pauling scale).
:rtype:
.. latex:clearpage::
"""
return self._eleneg
@memoized_property
def eleaffin(self) -> float:
"""
The electron affinity in eV.
"""
return self._eleaffin
@memoized_property
def covrad(self) -> float:
"""
The Covalent radius in Angstrom.
"""
return self._covrad
@memoized_property
def atmrad(self) -> float:
"""
The Atomic radius in Angstrom.
"""
return self._atmrad
@memoized_property
def vdwrad(self) -> float:
"""
The Van der Waals radius in Angstrom.
"""
return self._vdwrad
@memoized_property
def tboil(self) -> float:
"""
The boiling temperature in K.
"""
return self._tboil
@memoized_property
def tmelt(self) -> float:
"""
The melting temperature in K.
"""
return self._tmelt
@memoized_property
def density(self) -> float:
"""
The density at 295K in g/cm³ respectively g/L.
"""
return self._density
@memoized_property
def eleconfig(self) -> str:
"""
The Ground state electron configuration.
"""
return self._eleconfig
@memoized_property
def oxistates(self) -> str:
"""
The oxidation states.
"""
return self._oxistates
@memoized_property
def ionenergy(self) -> Tuple: # TODO
"""
The ionization energies in ``eV``.
"""
return self._ionenergy
@memoized_property
def isotopes(self) -> Dict[int, "Isotope"]:
"""
The Isotopic composition.
* **keys**: isotope mass number
* **values**: Isotope(relative atomic mass, abundance)
.. latex:vspace:: -10px
"""
return self._isotopes
@memoized_property
def description(self) -> str:
"""
A description of the element.
"""
return self._description
[docs] def __str__(self) -> str:
return self.name
[docs] def __repr__(self) -> str:
ionenergy_list = []
for i, j in enumerate(self.ionenergy):
if i and (i % 5 == 0):
ionenergy_list.append(f"\n {j}")
else:
ionenergy_list.append(f"{j}")
ionenergy = ", ".join(ionenergy_list)
if len(self.ionenergy) > 5:
ionenergy = f"(\n {ionenergy},\n )"
elif len(self.ionenergy) == 1:
ionenergy = f"({ionenergy},)"
else:
ionenergy = f"({ionenergy})"
isotopes_list = []
for massnum in sorted(self.isotopes):
iso = self.isotopes[massnum]
isotopes_list.append(f"{massnum}: Isotope({iso.mass}, {iso.abundance}, {massnum})")
isotopes = ",\n ".join(isotopes_list)
if len(self.isotopes) > 1:
isotopes = f"{{\n {isotopes},\n }},"
else:
isotopes = f"{{{isotopes}}},"
return ",\n ".join((
f"Element(\n {self.number}, '{self.symbol}', '{self.name}'",
f"group={self.group}, period={self.period},"
f" block='{self.block}', series={self.series}",
f"mass={self.mass}, eleneg={self.eleneg},"
f" eleaffin={self.eleaffin}",
f"covrad={self.covrad}, atmrad={self.atmrad},"
f" vdwrad={self.vdwrad}",
f"tboil={self.tboil}, tmelt={self.tmelt}, density={self.density}",
f"eleconfig='{self.eleconfig}'",
f"oxistates='{self.oxistates}'",
f"ionenergy={ionenergy}",
f"isotopes={isotopes}\n)"
))
@memoized_property
def nominalmass(self) -> int:
"""
The mass number of the most abundant natural stable isotope.
"""
nominalmass = 0
maxabundance = 0
for massnum, iso in self.isotopes.items():
if iso.abundance > maxabundance:
maxabundance = iso.abundance
nominalmass = massnum
return nominalmass
@memoized_property
def neutrons(self) -> int:
"""
The number of neutrons in the most abundant natural stable isotope.
"""
return self.nominalmass - self.protons
@memoized_property
def exactmass(self) -> float:
"""
The relative atomic mass calculated from the isotopic composition.
"""
return sum(iso.mass * iso.abundance for iso in self.isotopes.values())
@memoized_property
def eleconfig_dict(self) -> Dict[Tuple, int]: # TODO
"""
The ground state electron configuration.
Mapping of Tuple(shell, subshell): electrons.
"""
adict = {}
if self.eleconfig.startswith('['):
base = self.eleconfig.split(' ', 1)[0][1:-1]
adict.update(_elements.ELEMENTS[base].eleconfig_dict)
for e in self.eleconfig.split()[bool(adict):]:
adict[(int(e[0]), e[1])] = int(e[2:]) if len(e) > 2 else 1
return adict
@memoized_property
def eleshells(self) -> Tuple[int, ...]:
"""
The number of electrons per shell as tuple.
"""
eleshells = [0, 0, 0, 0, 0, 0, 0]
for key, val in self.eleconfig_dict.items():
eleshells[key[0] - 1] += val
return tuple(ele for ele in eleshells if ele)
[docs] def validate(self) -> None:
"""
Check consistency of the data.
:raises ValueError: If there are any validation issues.
"""
assert self.period in _table.PERIODS
assert self.group in _table.GROUPS
assert self.block in _table.BLOCKS
assert self.series in _table.SERIES
if self.number != self.protons:
raise ValueError(f"{self.symbol} - atomic number must equal proton number")
if self.protons != sum(self.eleshells):
raise ValueError(f"{self.symbol} - number of protons must equal electrons")
if len(self.ionenergy) > 1:
ionev_ = self.ionenergy[0]
for ionev in self.ionenergy[1:]:
if ionev <= ionev_:
raise ValueError(f"{self.symbol} - ionenergy not increasing")
ionev_ = ionev
mass = 0.0
frac = 0.0
for iso in self.isotopes.values():
mass += iso.abundance * iso.mass
frac += iso.abundance
if abs(mass - self.mass) > 0.03:
raise ValueError(
f"{self.symbol} - average of isotope masses "
f"({mass:.4f}) != mass ({self.mass:.4f})"
)
if abs(frac - 1.0) > 1e-9:
raise ValueError(f"{self.symbol} - sum of isotope abundances != 1.0")
[docs]@prettify_docstrings
class Isotope(Dictable):
"""
Isotope massnumber, relative atomic mass, and abundance.
:param mass: The mass of the isotope.
:param abundance: The natural abundance of the isotope.
:param massnumber: The mass number of the isotope.
.. latex:clearpage::
"""
def __init__(self, mass: float = 0.0, abundance: float = 1.0, massnumber: int = 0):
super().__init__()
self._mass: float = mass
self._abundance: float = abundance
self._massnumber: int = massnumber
@memoized_property
def mass(self) -> float:
"""
The mass of the isotope.
"""
return self._mass
@memoized_property
def abundance(self) -> float:
"""
The natural abundance of the isotope.
"""
return self._abundance
@memoized_property
def massnumber(self) -> int:
"""
The mass number of the isotope.
"""
return self._massnumber
[docs] def __str__(self) -> str:
return f"{self.massnumber}, {self.mass:.4f}, {self.abundance * 100:.6f}%"
[docs] def __repr__(self) -> str:
return f"Isotope({repr(self.mass)}, {repr(self.abundance)}, {repr(self.massnumber)})"
@property
def __dict__(self): # noqa: MAN002
return dict(
mass=self.mass,
abundance=self.abundance,
massnumber=self.massnumber,
)
# Isotope 0 Key:
# mass of the most abundant isotope and 1.0 abundance.
# TODO: make frozen
[docs]@prettify_docstrings
class Elements(Iterable[Element]):
r"""
Ordered dict of Elements with lookup by number, symbol, and name.
:param \*elements: The elements to add to the dictionary.
.. autosummary-widths:: 1/2
.. latex:vspace:: -10px
"""
def __init__(self, *elements: Element):
self._list: List[Element] = []
self._dict: Dict[Union[str, int], Element] = {}
for element in elements:
if element.number > len(self._list) + 1:
raise ValueError("Elements must be added in order")
if element.number <= len(self._list):
self._list[element.number - 1] = element
else:
self._list.append(element)
self._dict[element.number] = element
self._dict[element.symbol] = element
self.add_alternate_spelling(element, element.name)
[docs] def __str__(self) -> str:
return f'[{", ".join(ele.symbol for ele in self._list)}]'
[docs] def __repr__(self) -> str:
elements = ",\n ".join(
"\n ".join(line for line in repr(element).splitlines()) for element in self._list
)
elements = f"Elements(\n {elements},\n)"
return elements
[docs] def __contains__(self, item) -> bool: # noqa: MAN001
return item in self._dict
[docs] def __iter__(self) -> Iterator[Element]:
"""
Returns an iterator over the elements, in order.
"""
return iter(self._list)
[docs] def __len__(self) -> int:
"""
Returns the number of elements.
"""
return len(self._list)
@overload
def __getitem__(self, key: slice) -> List[Element]: ...
@overload
def __getitem__(self, key: Union[str, int, float]) -> Element: ...
[docs] def __getitem__(self, key): # noqa: MAN001,MAN002
"""
Return ``self[key]``.
:param key: If a string, return the :class:`~.Element` with that name or symbol.
If a number, return the element with that atomic number.
"""
# TODO: slice docstring
if isinstance(key, str):
try:
return self._dict[key.casefold()]
except KeyError:
return self._dict[key]
elif isinstance(key, int):
return self._dict[key]
elif isinstance(key, float):
return self._dict[int(key)]
elif isinstance(key, slice):
start, stop, step = key.indices(len(self._list))
return self._list[slice(start - 1, stop - 1, step)]
else:
try:
symbol, isotope = self.split_isotope(key)
return self._dict[symbol.capitalize()]
except (ValueError, KeyError):
raise KeyError(f"Unknown key: '{key}'")
[docs] @functools.lru_cache()
def split_isotope(self, string: str) -> Tuple[str, int]:
"""
Returns the symbol and mass number for the isotope represented by ``string``.
Valid isotopes include ``'[C12]'``, ``'C[12]'`` and ``'[12C]'``.
:param string:
:return: Tuple representing the element and the isotope number.
"""
# this package
from chemistry_tools.formulae.utils import split_isotope
return split_isotope(string)
[docs] def add_alternate_spelling(self, element: Element, spelling: str) -> None:
"""
Adds an alternate spelling for an element.
:param element:
:param spelling:
"""
self._dict[spelling] = element
self._dict[spelling.lower()] = element
self._dict[spelling.casefold()] = element
@memoized_property
def symbols(self) -> List[str]:
"""
The symbols of the elements.
"""
return [element.symbol for element in sorted(self._list, key=lambda e: e.number)]
@memoized_property
def names(self) -> List[str]:
"""
The names of the elements.
"""
return [str(element) for element in sorted(self._list, key=lambda e: e.number)]
@memoized_property
def lower_names(self) -> List[str]:
"""
The names of the elements, all in lowercase.
"""
return [str(element).lower() for element in sorted(self._list, key=lambda e: e.number)]
[docs]@doctools.append_docstring_from(Element)
class HeavyHydrogen(Element):
"""
Subclass of :class:`~.Element` to handle the Heavy Hydrogen isotopes Deuterium and Tritium.
"""
@functools.wraps(Element.__init__)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.symbol not in {'D', 'T'}:
raise ValueError("'HeavyHydrogen' can only be used for Deuterium and Tritium")
@memoized_property
def nominalmass(self) -> int:
"""
Return mass number of most abundant natural stable isotope.
"""
if self.symbol == 'D':
return 2
elif self.symbol == 'T':
return 3
else:
raise ValueError("Unknown heavy hydrogen isotope.")
@memoized_property
def as_isotope(self) -> str:
"""
Return the isotope in ``H[X]`` format.
"""
return f"H[{self.nominalmass}]"