diff --git a/qcelemental/models/molecule.py b/qcelemental/models/molecule.py index ed18b52b..8da81429 100644 --- a/qcelemental/models/molecule.py +++ b/qcelemental/models/molecule.py @@ -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 @@ -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 " @@ -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 @@ -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] @@ -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) diff --git a/qcelemental/molparse/chgmult.py b/qcelemental/molparse/chgmult.py index a311d08d..7f2cad76 100644 --- a/qcelemental/molparse/chgmult.py +++ b/qcelemental/molparse/chgmult.py @@ -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): @@ -430,7 +430,14 @@ 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), @@ -450,7 +457,14 @@ 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) diff --git a/qcelemental/tests/test_molecule.py b/qcelemental/tests/test_molecule.py index c3ab65e8..869c4e6f 100644 --- a/qcelemental/tests/test_molecule.py +++ b/qcelemental/tests/test_molecule.py @@ -798,6 +798,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", } @@ -806,14 +815,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 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"), @@ -871,6 +896,10 @@ 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): @@ -902,6 +931,7 @@ 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): diff --git a/qcelemental/tests/test_molparse_validate_and_fill_chgmult.py b/qcelemental/tests/test_molparse_validate_and_fill_chgmult.py index 0c18649e..0f85bb03 100644 --- a/qcelemental/tests/test_molparse_validate_and_fill_chgmult.py +++ b/qcelemental/tests/test_molparse_validate_and_fill_chgmult.py @@ -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 ] @@ -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): @@ -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