diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 171c4452..83bf271d 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -14,19 +14,27 @@ Changelog .. +++++++++ -0.15.0 / 2020-06-DD +0.15.0 / 2020-06-25 ------------------- New Features ++++++++++++ +- (:pr:`182`) Added experimental protocol for controlling autocorrection attemps. (That is, when a calculation throws a + known error that QCEngine thinks it can tweak the input and rerun.) Currently in trial for NWChem. Enhancements ++++++++++++ -- (:pr:`223`) ``molparse.to_string`` MADNESS dtype developed. +- (:pr:`186`, :pr:`223`) ``molparse.to_string`` Orca and MADNESS dtypes developed. - (:pr:`226`) Allow ``which_import`` to distinguish between ordinary and namespace packages. +- (:pr:`227`) Add non-default ``strict`` argument to ``periodictable.to_Z``, ``to_symbol``, and ``to_element`` that fails when isotope information is given. +- (:pr:`227`) Allow nonphysical masses to pass validation in ``molparse.from_schema(..., nonphysical=True)``. + Also allowed in forming ``qcel.models.Molecule(..., nonphysical=True)``. Bug Fixes +++++++++ +- (:pr:`227`) Fixed deception described in issue 225 where ``qcel.models.Molecule(..., symbols=["O18"])`` accepted "O18" + but did not influence the isotope, as user might have expected. That now raises ``NotAnElementError``, and an example + of correctly setting isotope/masses has been added. This error now caught at ``qcel.molparse.from_arrays`` so general. 0.14.0 / 2020-03-06 diff --git a/docs/source/model_molecule.rst b/docs/source/model_molecule.rst index 2530ce26..6f382354 100644 --- a/docs/source/model_molecule.rst +++ b/docs/source/model_molecule.rst @@ -90,6 +90,7 @@ the ``from_data`` function or by passing explicit fragments in the >>> Ne 0.000000 0.000000 0.000000 >>> -- >>> Ne 3.100000 0.000000 0.000000 + >>> units au >>> """) >>> mol = qcel.models.Molecule( diff --git a/qcelemental/exceptions.py b/qcelemental/exceptions.py index dc0ab8f2..65539ed0 100644 --- a/qcelemental/exceptions.py +++ b/qcelemental/exceptions.py @@ -6,10 +6,12 @@ class NotAnElementError(Exception): """Error when element or nuclide can't be identified.""" - def __init__(self, atom): - self.message = "Atom identifier ({}) uninterpretable as atomic number, element symbol, or nuclide symbol".format( - atom - ) + def __init__(self, atom, strict=False): + if strict: + msg = "atomic number or element" + else: + msg = "atomic number, element symbol, or nuclide symbol" + self.message = f"Atom identifier ({atom}) uninterpretable as {msg}" class DataUnavailableError(Exception): diff --git a/qcelemental/models/molecule.py b/qcelemental/models/molecule.py index 2b33b4c9..c4408b68 100644 --- a/qcelemental/models/molecule.py +++ b/qcelemental/models/molecule.py @@ -281,7 +281,10 @@ def __init__(self, orient: bool = False, validate: Optional[bool] = None, **kwar kwargs["schema_version"] = kwargs.pop("schema_version", 2) # original_keys = set(kwargs.keys()) # revive when ready to revisit sparsity - schema = to_schema(from_schema(kwargs), dtype=kwargs["schema_version"], copy=False, np_out=True) + nonphysical = kwargs.pop("nonphysical", False) + schema = to_schema( + from_schema(kwargs, nonphysical=nonphysical), dtype=kwargs["schema_version"], copy=False, np_out=True + ) schema = _filter_defaults(schema) kwargs["validated"] = True @@ -691,7 +694,7 @@ def to_string( # type: ignore Suggest psi4 --> psi4frag and psi4 route to to_string """ - molrec = from_schema(self.dict()) + molrec = from_schema(self.dict(), nonphysical=True) return to_string( molrec, dtype=dtype, diff --git a/qcelemental/molparse/from_arrays.py b/qcelemental/molparse/from_arrays.py index 717009ad..fbb0ac94 100644 --- a/qcelemental/molparse/from_arrays.py +++ b/qcelemental/molparse/from_arrays.py @@ -188,6 +188,7 @@ def from_arrays( tooclose : float, optional Interatom distance (native `geom` units) nearer than which atoms not allowed. nonphysical : bool, optional + Do allow masses outside an element's natural range to pass validation? speclabel : bool, optional If `True`, interpret `elbl` as potentially full nucleus spec including ghosting, isotope, mass, tagging information, e.g., `@13C_mine` or diff --git a/qcelemental/molparse/from_schema.py b/qcelemental/molparse/from_schema.py index 61a98928..65395b57 100644 --- a/qcelemental/molparse/from_schema.py +++ b/qcelemental/molparse/from_schema.py @@ -7,13 +7,15 @@ from .from_arrays import from_arrays -def from_schema(molschema, *, verbose: int = 1) -> Dict: +def from_schema(molschema, *, nonphysical: bool = False, verbose: int = 1) -> Dict: """Construct molecule dictionary representation from non-Psi4 schema. Parameters ---------- molschema : dict Dictionary form of Molecule following known schema. + nonphysical : bool, optional + Do allow masses outside an element's natural range to pass validation? verbose : int, optional Amount of printing. @@ -82,7 +84,7 @@ def from_schema(molschema, *, verbose: int = 1) -> Dict: speclabel=False, # tooclose=tooclose, # zero_ghost_fragments=zero_ghost_fragments, - # nonphysical=nonphysical, + nonphysical=nonphysical, # mtol=mtol, verbose=verbose, ) diff --git a/qcelemental/molparse/nucleus.py b/qcelemental/molparse/nucleus.py index 0bda25de..7e95798b 100644 --- a/qcelemental/molparse/nucleus.py +++ b/qcelemental/molparse/nucleus.py @@ -165,7 +165,7 @@ def reconcile(exact, tests, feature): def offer_element_symbol(e): """Given an element, what can be suggested and asserted about Z, A, mass?""" - _Z = periodictable.to_Z(e) + _Z = periodictable.to_Z(e, strict=True) offer_atomic_number(_Z) def offer_atomic_number(z): diff --git a/qcelemental/periodic_table.py b/qcelemental/periodic_table.py index b9dcd7a0..d6d110ad 100644 --- a/qcelemental/periodic_table.py +++ b/qcelemental/periodic_table.py @@ -65,28 +65,37 @@ def __init__(self): for EE, m, A in zip(self._EE, self.mass, self.A): self._el2a2mass[EE][A] = float(m) - def _resolve_atom_to_key(self, atom: Union[int, str]) -> str: + def _resolve_atom_to_key(self, atom: Union[int, str], strict: bool = False) -> str: """Given `atom` as element name, element symbol, nuclide symbol, atomic number, or atomic number string, return valid `self._eliso2mass` key, regardless of case. Raises `NotAnElementError` if unidentifiable. """ - try: - self._eliso2mass[atom.capitalize()] # type: ignore - except (KeyError, AttributeError): + + def resolve_eliso(atom): try: - E = self._z2el[int(atom)] - except (KeyError, ValueError): + self._eliso2mass[atom.capitalize()] # type: ignore + except (KeyError, AttributeError): try: - E = self._element2el[atom.capitalize()] # type: ignore - except (KeyError, AttributeError): - raise NotAnElementError(atom) + E = self._z2el[int(atom)] + except (KeyError, ValueError): + try: + E = self._element2el[atom.capitalize()] # type: ignore + except (KeyError, AttributeError): + raise NotAnElementError(atom) + else: + return E else: return E else: - return E - else: - assert isinstance(atom, str) - return atom.capitalize() + assert isinstance(atom, str) + return atom.capitalize() + + eliso = resolve_eliso(atom) + + if strict and eliso not in self.E: + raise NotAnElementError(eliso, strict=strict) + + return eliso def to_mass(self, atom: Union[int, str], *, return_decimal: bool = False) -> Union[float, "Decimal"]: """Get atomic mass of `atom`. @@ -147,7 +156,7 @@ def to_A(self, atom: Union[int, str]) -> int: identifier = self._resolve_atom_to_key(atom) return self._eliso2a[identifier] - def to_Z(self, atom: Union[int, str]) -> int: + def to_Z(self, atom: Union[int, str], strict: bool = False) -> int: """Get atomic number of `atom`. Functions :py:func:`to_Z` and :py:func:`to_atomic_number` are aliases. @@ -156,6 +165,8 @@ def to_Z(self, atom: Union[int, str]) -> int: ---------- atom : int or str Identifier for element or nuclide, e.g., `H`, `D`, `H2`, `He`, `hE4`. + strict + Allow only element identification in `atom`, not nuclide. Returns ------- @@ -166,12 +177,13 @@ def to_Z(self, atom: Union[int, str]) -> int: ------ NotAnElementError If `atom` cannot be resolved into an element or nuclide. + If `strict=True` and `atom` resolves into nuclide, not element. """ - identifier = self._resolve_atom_to_key(atom) + identifier = self._resolve_atom_to_key(atom, strict=strict) return self._el2z[self._eliso2el[identifier]] - def to_E(self, atom: Union[int, str]) -> str: + def to_E(self, atom: Union[int, str], strict: bool = False) -> str: """Get element symbol of `atom`. Functions :py:func:`to_E` and :py:func:`to_symbol` are aliases. @@ -180,16 +192,25 @@ def to_E(self, atom: Union[int, str]) -> str: ---------- atom : Union[int, str] Identifier for element or nuclide, e.g., `H`, `D`, `H2`, `He`, `hE4`. + strict + Allow only element identification in `atom`, not nuclide. Returns ------- str Element symbol, capitalized. + + Raises + ------ + NotAnElementError + If `atom` cannot be resolved into an element or nuclide. + If `strict=True` and `atom` resolves into nuclide, not element. + """ - identifier = self._resolve_atom_to_key(atom) + identifier = self._resolve_atom_to_key(atom, strict=strict) return self._eliso2el[identifier] - def to_element(self, atom: Union[int, str]) -> str: + def to_element(self, atom: Union[int, str], strict: bool = False) -> str: """Get element name of `atom`. Functions :py:func:`to_element` and :py:func:`to_name` are aliases. @@ -198,6 +219,8 @@ def to_element(self, atom: Union[int, str]) -> str: ---------- atom : int or str Identifier for element or nuclide, e.g., `H`, `D`, `H2`, `He`, `hE4`. + strict + Allow only element identification in `atom`, not nuclide. Returns ------- @@ -208,9 +231,10 @@ def to_element(self, atom: Union[int, str]) -> str: ------ NotAnElementError If `atom` cannot be resolved into an element or nuclide. + If `strict=True` and `atom` resolves into nuclide, not element. """ - identifier = self._resolve_atom_to_key(atom) + identifier = self._resolve_atom_to_key(atom, strict=strict) return self._el2element[self._eliso2el[identifier]] to_mass_number = to_A diff --git a/qcelemental/tests/test_molecule.py b/qcelemental/tests/test_molecule.py index baae5667..41e83360 100644 --- a/qcelemental/tests/test_molecule.py +++ b/qcelemental/tests/test_molecule.py @@ -6,6 +6,7 @@ import pytest import qcelemental as qcel +from qcelemental.exceptions import NotAnElementError from qcelemental.models import Molecule from qcelemental.testing import compare, compare_values @@ -715,3 +716,21 @@ def test_sparse_molecule_connectivity(): mol = Molecule(symbols=["He", "He"], geometry=[0, 0, -2, 0, 0, 2]) assert "connectivity" not in mol.dict() + + +def test_bad_isotope_spec(): + with pytest.raises(NotAnElementError) as e: + qcel.models.Molecule(symbols=["He3"], geometry=[0, 0, 0]) + + +def test_good_isotope_spec(): + assert compare_values( + [3.01602932], qcel.models.Molecule(symbols=["He"], mass_numbers=[3], geometry=[0, 0, 0]).masses, "nonstd mass" + ) + + +def test_nonphysical_spec(): + mol = qcel.models.Molecule(symbols=["He"], masses=[100], geometry=[0, 0, 0], nonphysical=True) + assert compare_values([100.0], mol.masses, "nonphysical mass") + + print(mol.to_string(dtype="psi4")) diff --git a/qcelemental/tests/test_molparse_reconcile_nucleus.py b/qcelemental/tests/test_molparse_reconcile_nucleus.py index 8dc7d588..17fae4af 100644 --- a/qcelemental/tests/test_molparse_reconcile_nucleus.py +++ b/qcelemental/tests/test_molparse_reconcile_nucleus.py @@ -63,14 +63,21 @@ def test_reconcile_nucleus_assertionerror(inp): assert co_dominant_shortmass == qcelemental.molparse.reconcile_nucleus(**inp) -@pytest.mark.parametrize("inp", [{"A": 80, "Z": 27}, {"Z": -27, "mass": 200, "nonphysical": True}]) +@pytest.mark.parametrize( + "inp", + [{"A": 80, "Z": 27}, {"Z": -27, "mass": 200, "nonphysical": True}, {"A": 100, "E": "He", "nonphysical": True}], +) def test_reconcile_nucleus_notanelementerror(inp): with pytest.raises(qcelemental.NotAnElementError): qcelemental.molparse.reconcile_nucleus(**inp) def test_reconcile_nucleus_41(): - qcelemental.molparse.reconcile_nucleus(Z=27, mass=200, nonphysical=True) + ans = qcelemental.molparse.reconcile_nucleus(Z=27, mass=200, nonphysical=True) + assert ans == (-1, 27, "Co", 200.0, True, "") + + ans = qcelemental.molparse.reconcile_nucleus(A=None, Z=None, E="He", mass=100, label=None, nonphysical=True) + assert ans == (-1, 2, "He", 100.0, True, "") @pytest.mark.parametrize( diff --git a/qcelemental/tests/test_periodictable.py b/qcelemental/tests/test_periodictable.py index cd1f2615..8e6e7f19 100644 --- a/qcelemental/tests/test_periodictable.py +++ b/qcelemental/tests/test_periodictable.py @@ -4,21 +4,36 @@ import pytest import qcelemental +from qcelemental.exceptions import NotAnElementError @pytest.mark.parametrize( - "inp,expected", [("He", "He"), ("He", "He"), ("He4", "He"), ("he", "He"), ("2", "He"), (2, "He"), (2.0, "He")] + "inp,expected", + [("He", "He"), ("heliuM", "He"), ("He4", "He4"), ("he", "He"), ("2", "He"), (2, "He"), (2.0, "He"), ("D", "D")], ) def test_id_resolution(inp, expected): - assert qcelemental.periodictable._resolve_atom_to_key("He") == "He" + assert qcelemental.periodictable._resolve_atom_to_key(inp) == expected @pytest.mark.parametrize("inp", ["He100", "-1", -1, -1.0, "cat", 200]) def test_id_resolution_error(inp): - with pytest.raises(qcelemental.exceptions.NotAnElementError): + with pytest.raises(NotAnElementError): qcelemental.periodictable._resolve_atom_to_key(inp) +@pytest.mark.parametrize( + "inp,expected", [("He", "He"), ("heliuM", "He"), ("he", "He"), ("2", "He"), (2, "He"), (2.0, "He")] +) +def test_id_resolution_strict(inp, expected): + assert qcelemental.periodictable._resolve_atom_to_key(inp, strict=True) == expected + + +@pytest.mark.parametrize("inp", ["He100", "-1", -1, -1.0, "cat", 200, "He4", "D"]) +def test_id_resolution_strict_error(inp): + with pytest.raises(NotAnElementError): + qcelemental.periodictable._resolve_atom_to_key(inp, strict=True) + + # TODO test ghost @@ -85,6 +100,32 @@ def test_to_atomic_number(inp, expected): assert qcelemental.periodictable.to_atomic_number(inp) == expected +@pytest.mark.parametrize( + "inp,expected", + [ + # Kr 84 + ("kr", 36), + ("KRYPTON", 36), + ("kr84", None), + (36, 36), + # Kr 86 + ("kr86", None), + # Deuterium + ("D", None), + ("h2", None), + ], +) +def test_to_atomic_number_strict(inp, expected): + if expected is None: + with pytest.raises(NotAnElementError): + qcelemental.periodictable.to_Z(inp, strict=True) + with pytest.raises(NotAnElementError): + qcelemental.periodictable.to_atomic_number(inp, strict=True) + else: + assert qcelemental.periodictable.to_Z(inp, strict=True) == expected + assert qcelemental.periodictable.to_atomic_number(inp, strict=True) == expected + + @pytest.mark.parametrize( "inp,expected", [ @@ -105,6 +146,32 @@ def test_to_symbol(inp, expected): assert qcelemental.periodictable.to_symbol(inp) == expected +@pytest.mark.parametrize( + "inp,expected", + [ + # Kr 84 + ("kr", "Kr"), + ("KRYPTON", "Kr"), + ("kr84", None), + (36, "Kr"), + # Kr 86 + ("kr86", None), + # Deuterium + ("D", None), + ("h2", None), + ], +) +def test_to_symbol_strict(inp, expected): + if expected is None: + with pytest.raises(NotAnElementError): + qcelemental.periodictable.to_E(inp, strict=True) + with pytest.raises(NotAnElementError): + qcelemental.periodictable.to_symbol(inp, strict=True) + else: + assert qcelemental.periodictable.to_E(inp, strict=True) == expected + assert qcelemental.periodictable.to_symbol(inp, strict=True) == expected + + @pytest.mark.parametrize( "inp,expected", [ @@ -125,6 +192,32 @@ def test_to_element(inp, expected): assert qcelemental.periodictable.to_name(inp) == expected +@pytest.mark.parametrize( + "inp,expected", + [ + # Kr 84 + ("kr", "Krypton"), + ("KRYPTON", "Krypton"), + ("kr84", None), + (36, "Krypton"), + # Kr 86 + ("kr86", None), + # Deuterium + ("D", None), + ("h2", None), + ], +) +def test_to_element_strict(inp, expected): + if expected is None: + with pytest.raises(NotAnElementError): + qcelemental.periodictable.to_element(inp, strict=True) + with pytest.raises(NotAnElementError): + qcelemental.periodictable.to_name(inp, strict=True) + else: + assert qcelemental.periodictable.to_element(inp, strict=True) == expected + assert qcelemental.periodictable.to_name(inp, strict=True) == expected + + @pytest.mark.parametrize( "inp,expected", [("HE", 1), ("carbon", 2), ("cl35", 3), (36, 4), (37.0, 5), ("mercury", 6), ("Ts", 7)] )