Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

float multiplicity #350

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,28 +19,46 @@ Changelog
.. Misc.
.. +++++

- (:pr:`340`, :issue:`330`) Add molecular charge and multiplicity to Molecule repr formula,
so neutral singlet unchanged but radical cation has '2^formula+'.


0.29.0 / 2024-MM-DD (Unreleased)
--------------------------------

Breaking Changes
++++++++++++++++
- (:pr:`341`) `packaging` is now a required dependency.

New Features
++++++++++++
- UNMERGED (:pr:`350`, :pr:`318`, :issue:`317`) Make behavior consistent between molecular_charge/
fragment_charges and molecular_multiplicity/fragment_multiplicities by allowing floating point
numbers for multiplicities. @awvwgk

Enhancements
++++++++++++
- (:pr:`340`, :issue:`330`) Add molecular charge and multiplicity to Molecule repr formula,
so neutral singlet unchanged but radical cation has '2^formula+'. @awvwgk
- (:pr:`341`) Use `packaging` instead of deprecated `setuptools` to provide version parsing for
`qcelemental.util.parse_version` and `qcelemental.util.safe_version`. This behaves slightly
different; "v7.0.0+N/A" was processed ok before but fails with newer version. @berquist
- (:pr:`343`) Molecular and fragment multiplicities are now always enforced to be >=1.0. Previously
this wasn't checked for `Molecule(..., validate=False)`. Error messages will change sometimes
change for `validate=True` (run by default).
- (:pr:`343`) `qcelemental.molparse` newly allows floats that are ints (e.g., 1.0) for multiplicity.
Previously it would raise an error about not being an int.
- (:pr:`337`) Solidify the (unchanged) schema_name for `QCInputSpecification` and `AtomicResult`
into Literals where previously they had been regex strings coerced into a single name. The literals
allow pydantic to discriminate models, which benefits GeneralizedOptimizationInput/Result in
QCManyBody/QCEngine/OptKing. The only way this can interfere is if schema producers have whitespace
around `schema_name` for these models or if any `AtomicResult`s are still using "qc_schema_output",
which looks to have only been added for compatibility with pre-pydantic QCSchema.

Bug Fixes
+++++++++

Misc.
+++++
- (:pr:`342`) Update some docs settings and requirements for newer tools.
- (:pr:`344`, :issue:`282`) Add a citation file since QCElemental doesn't have a paper. @lilyminium
- (:pr:`342`, :issue:`333`) Update some docs settings and requirements for newer tools.


0.28.0 / 2024-06-21
Expand Down
14 changes: 8 additions & 6 deletions qcelemental/models/molecule.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ class Molecule(ProtoModel):
description="Additional comments for this molecule. Intended for pure human/user consumption and clarity.",
)
molecular_charge: float = Field(0.0, description="The net electrostatic charge of the molecule.") # type: ignore
molecular_multiplicity: int = Field(1, description="The total multiplicity of the molecule.") # type: ignore
molecular_multiplicity: float = Field(1, description="The total multiplicity of the molecule.") # type: ignore

# Atom data
masses_: Optional[Array[float]] = Field( # type: ignore
Expand Down Expand Up @@ -257,7 +257,7 @@ class Molecule(ProtoModel):
"if not provided (and :attr:`~qcelemental.models.Molecule.fragments` are specified).",
shape=["nfr"],
)
fragment_multiplicities_: Optional[List[int]] = Field( # type: ignore
fragment_multiplicities_: Optional[List[float]] = Field( # type: ignore
None,
description="The multiplicity of each fragment in the :attr:`~qcelemental.models.Molecule.fragments` list. The index of this "
"list matches the 0-index indices of :attr:`~qcelemental.models.Molecule.fragments` list. Will be filled in based on a set of "
Expand Down Expand Up @@ -421,12 +421,16 @@ def _must_be_n_frag_mult(cls, v, values, **kwargs):
n = len(values["fragments_"])
if len(v) != n:
raise ValueError("Fragment Multiplicities must be same number of entries as Fragments")
v = [(int(m) if m.is_integer() else m) for m in v]
if any([m < 1.0 for m in v]):
raise ValueError(f"Fragment Multiplicity must be positive: {v}")
return v

@validator("molecular_multiplicity")
def _int_if_possible(cls, v, values, **kwargs):
if v.is_integer():
# preserve existing hashes
v = int(v)
if v < 1.0:
raise ValueError("Molecular Multiplicity must be positive")
return v
Expand Down Expand Up @@ -502,7 +506,7 @@ def fragment_charges(self) -> List[float]:
return fragment_charges

@property
def fragment_multiplicities(self) -> List[int]:
def fragment_multiplicities(self) -> List[float]:
fragment_multiplicities = self.__dict__.get("fragment_multiplicities_")
if fragment_multiplicities is None:
fragment_multiplicities = [self.molecular_multiplicity]
Expand Down Expand Up @@ -803,9 +807,7 @@ def get_hash(self):
data = getattr(self, field)
if field == "geometry":
data = float_prep(data, GEOMETRY_NOISE)
elif field == "fragment_charges":
data = float_prep(data, CHARGE_NOISE)
elif field == "molecular_charge":
elif field in ["fragment_charges", "molecular_charge", "fragment_multiplicities", "molecular_multiplicity"]:
data = float_prep(data, CHARGE_NOISE)
elif field == "masses":
data = float_prep(data, MASS_NOISE)
Expand Down
24 changes: 21 additions & 3 deletions qcelemental/molparse/chgmult.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def _high_spin_sum(mult_list):


def _mult_ok(m):
return isinstance(m, (int, np.integer)) and m >= 1
return isinstance(m, (int, np.integer, float, np.float64)) and m >= 1


def _sufficient_electrons_for_mult(z, c, m):
Expand Down Expand Up @@ -430,7 +430,16 @@ def int_if_possible(val):
if molecular_multiplicity is None: # unneeded, but shortens the exact lists
frag_mult_hi = _high_spin_sum(_apply_default(fragment_multiplicities, 2))
frag_mult_lo = _high_spin_sum(_apply_default(fragment_multiplicities, 1))
for m in range(frag_mult_lo, frag_mult_hi + 1):
try:
mult_range = range(frag_mult_lo, frag_mult_hi + 1)
except TypeError:
if frag_mult_lo == frag_mult_hi:
mult_range = [frag_mult_hi]
else:
raise ValidationError(
f"Cannot process: please fully specify float multiplicity: m: {molecular_multiplicity} fm: {fragment_multiplicities}"
)
for m in mult_range:
cgmp_exact_m.append(m)

# * (S6) suggest range of missing mult = tot - high_spin_sum(frag - 1),
Expand All @@ -450,7 +459,16 @@ def int_if_possible(val):

for ifr in range(nfr):
if fragment_multiplicities[ifr] is None: # unneeded, but shortens the exact lists
for m in reversed(range(max(missing_mult_lo, 1), missing_mult_hi + 1)):
try:
mult_range = reversed(range(max(missing_mult_lo, 1), missing_mult_hi + 1))
except TypeError:
if missing_mult_lo == missing_mult_hi:
mult_range = [missing_mult_hi]
else:
raise ValidationError(
f"Cannot process: please fully specify float multiplicity: m: {molecular_multiplicity} fm: {fragment_multiplicities}"
)
for m in mult_range:
cgmp_exact_fm[ifr].append(m)
cgmp_exact_fm[ifr].append(1)
cgmp_exact_fm[ifr].append(2)
Expand Down
57 changes: 49 additions & 8 deletions qcelemental/tests/test_molecule.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Tests the imports and exports of the Molecule object.
"""


import numpy as np
import pytest

Expand Down Expand Up @@ -798,6 +799,15 @@ def test_extras():
"triplet": "7caca87a",
"disinglet": "83a85546",
"ditriplet": "71d6ba82",
# float mult
"singlet_point1": "4e9e2587",
"singlet_epsilon": "ad3f5fab",
"triplet_point1": "ad35cc28",
"triplet_point1_minus": "b63d6983",
"triplet_point00001": "7107b7ac",
"disinglet_epsilon": "fb0aaaca",
"ditriplet_point1": "33d47d5f",
"ditriplet_point00001": "7f0ac640",
}


Expand All @@ -806,14 +816,26 @@ def test_extras():
[
pytest.param(3, 3, False, "triplet"),
pytest.param(3, 3, True, "triplet"),
# 3.1 -> 3 (validate=False) below documents the present bad behavior where a float mult
# simply gets cast to int with no error. This will change soon. The validate=True throws a
# nonspecific error that at least mentions type.
pytest.param(3.1, 3, False, "triplet"),
# before float multiplicity was allowed, 3.1 (below) was coerced into 3 with validate=False,
# and validate=True threw a type-mentioning error. Now, 2.9 is allowed for both validate=T/F
pytest.param(3.1, 3.1, False, "triplet_point1"),
# validate=True counterpart fails b/c insufficient electrons in He for more than triplet
pytest.param(2.9, 2.9, False, "triplet_point1_minus"),
pytest.param(2.9, 2.9, True, "triplet_point1_minus"),
pytest.param(3.00001, 3.00001, False, "triplet_point00001"),
# validate=True counterpart fails like 3.1 above
pytest.param(2.99999, 2.99999, False, "triplet_point00001"), # hash agrees w/3.00001 above b/c <CHARGE_NOISE
pytest.param(2.99999, 2.99999, True, "triplet_point00001"),
pytest.param(3.0, 3, False, "triplet"),
pytest.param(3.0, 3, True, "triplet"),
pytest.param(1, 1, False, "singlet"),
pytest.param(1, 1, True, "singlet"),
pytest.param(1.000000000000000000002, 1, False, "singlet"),
pytest.param(1.000000000000000000002, 1, True, "singlet"),
pytest.param(1.000000000000002, 1.000000000000002, False, "singlet_epsilon"),
pytest.param(1.000000000000002, 1.000000000000002, True, "singlet_epsilon"),
pytest.param(1.1, 1.1, False, "singlet_point1"),
pytest.param(1.1, 1.1, True, "singlet_point1"),
pytest.param(None, 1, False, "singlet"),
pytest.param(None, 1, True, "singlet"),
# fmt: off
Expand Down Expand Up @@ -841,6 +863,9 @@ def test_mol_multiplicity_types(mult_in, mult_store, validate, exp_hash):
[
pytest.param(-3, False, "Multiplicity must be positive"),
pytest.param(-3, True, "Multiplicity must be positive"),
pytest.param(0.9, False, "Multiplicity must be positive"),
pytest.param(0.9, True, "Multiplicity must be positive"),
pytest.param(3.1, True, "Inconsistent or unspecified chg/mult"), # insufficient electrons in He
],
)
def test_mol_multiplicity_types_errors(mult_in, validate, error):
Expand All @@ -859,10 +884,11 @@ def test_mol_multiplicity_types_errors(mult_in, validate, error):
[
pytest.param(5, [3, 3], [3, 3], False, "ditriplet"),
pytest.param(5, [3, 3], [3, 3], True, "ditriplet"),
# 3.1 -> 3 (validate=False) below documents the present bad behavior where a float mult
# simply gets cast to int with no error. This will change soon. The validate=True throws a
# irreconcilable error.
pytest.param(5, [3.1, 3.4], [3, 3], False, "ditriplet"),
# before float multiplicity was allowed, [3.1, 3.4] (below) were coerced into [3, 3] with validate=False.
# Now, [2.9, 2.9] is allowed for both validate=T/F.
pytest.param(5, [3.1, 3.4], [3.1, 3.4], False, "ditriplet_point1"),
pytest.param(5, [2.99999, 3.00001], [2.99999, 3.00001], False, "ditriplet_point00001"),
pytest.param(5, [2.99999, 3.00001], [2.99999, 3.00001], True, "ditriplet_point00001"),
# fmt: off
pytest.param(5, [3.0, 3.], [3, 3], False, "ditriplet"),
pytest.param(5, [3.0, 3.], [3, 3], True, "ditriplet"),
Expand All @@ -871,6 +897,18 @@ def test_mol_multiplicity_types_errors(mult_in, validate, error):
pytest.param(1, [1, 1], [1, 1], True, "disinglet"),
# None in frag_mult not allowed for validate=False
pytest.param(1, [None, None], [1, 1], True, "disinglet"),
pytest.param(1, [1.000000000000000000002, 0.999999999999999999998], [1, 1], False, "disinglet"),
pytest.param(1, [1.000000000000000000002, 0.999999999999999999998], [1, 1], True, "disinglet"),
pytest.param(
1,
[1.000000000000002, 1.000000000000004],
[1.000000000000002, 1.000000000000004],
False,
"disinglet_epsilon",
),
pytest.param(
1, [1.000000000000002, 1.000000000000004], [1.000000000000002, 1.000000000000004], True, "disinglet_epsilon"
),
],
)
def test_frag_multiplicity_types(mol_mult_in, mult_in, mult_store, validate, exp_hash):
Expand Down Expand Up @@ -902,6 +940,9 @@ def test_frag_multiplicity_types(mol_mult_in, mult_in, mult_store, validate, exp
[
pytest.param([-3, 1], False, "Multiplicity must be positive"),
pytest.param([-3, 1], True, "Multiplicity must be positive"),
pytest.param(
[3.1, 3.4], True, "Inconsistent or unspecified chg/mult"
), # insufficient e- for triplet+ on He in frag 1
],
)
def test_frag_multiplicity_types_errors(mult_in, validate, error):
Expand Down
10 changes: 10 additions & 0 deletions qcelemental/tests/test_molparse_validate_and_fill_chgmult.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@
(-2.4, [-2.4, 0, 0], 3, [1, 2, 2]),
"a83a3356",
), # 166
(("He", None, [None], 2.8, [None]), (0, [0], 2.8, [2.8]), "3e10e7b5"), # 180
(("He", None, [None], None, [2.8]), (0, [0], 2.8, [2.8]), "3e10e7b5"), # 181
(("N/N/N", None, [None, None, None], 2.2, [2, 2, 2.2]), (0, [0, 0, 0], 2.2, [2, 2, 2.2]), "798ee5d4"), # 183
(("N/N/N", None, [None, None, None], 4.2, [2, 2, 2.2]), (0, [0, 0, 0], 4.2, [2, 2, 2.2]), "ed6d1f35"), # 185
(("N/N/N", None, [None, None, None], None, [2, 2, 2.2]), (0, [0, 0, 0], 4.2, [2, 2, 2.2]), "ed6d1f35"), # 186
(("N/N/N", None, [2, -2, None], 2.2, [2, 2, 2.2]), (0, [2, -2, 0], 2.2, [2, 2, 2.2]), "66e655c0"), # 187
]


Expand Down Expand Up @@ -153,6 +159,8 @@ def none_y(inp):
("Gh", None, [None], 3, [None]), # 60
("Gh/He", None, [2, None], None, [None, None]), # 62
("Gh/Ne", 2, [-2, None], None, [None, None]), # 65b
("He", None, [None], 3.2, [None]), # 182
("N/N/N", None, [None, None, None], 2.2, [None, None, 2.2]), # 184
],
)
def test_validate_and_fill_chgmult_irreconcilable(systemtranslator, inp):
Expand All @@ -173,6 +181,8 @@ def test_validate_and_fill_chgmult_irreconcilable(systemtranslator, inp):
# 35 - insufficient electrons
# 55 - both (1, (1, 0.0, 0.0), 4, (1, 3, 2)) and (1, (0.0, 0.0, 1), 4, (2, 3, 1)) plausible
# 65 - non-0/1 on Gh fragment errors normally but reset by zero_ghost_fragments
# 182 - insufficient electrons on He
# 184 - decline to guess fragment multiplicities when floats involved


@pytest.fixture
Expand Down
Loading